Tree Shaking In Practice

引言

随着产品的迭代升级和人员变更,我们的代码往往会逐渐变得不可控,有一点表现的尤为明显,即之前的代码逻辑模块被废弃不用,但是该模块又被引入了进来,对应的模块在编译理论中被称之为 Dead Code。我们可以采用相应的方法把这些 Dead Code 删除掉, 这种解决问题的方法被称为 DCE(Dead Code Elimination)。而 DCE 在前端领域中的实现就是常常被提到的Tree Shaking

Tree Shaking 的作用

Tree Shaking 的相关概念最早是在 Rollup 中引入的,那么我们就以 Rollup(^2.78.0) 来举例:

  1. 新建两个文件main.mjsoutput.mjs,两个文件内容如下:
  • output.mjs

    1
    2
    export const a = 1;
    export const b = 2;
  • main.mjs:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    import { a } from './output.mjs';

    console.log(a);
    const c = 3;

    function foo() {
    console.log('aaaa');
    return;
    console.log('bbbb');
    }

    foo();
  1. 执行命令rollup main.mjs --file bundle.js生成bundle.js文件,内容如下:
1
2
3
4
5
6
7
8
9
10
const a = 1;

console.log(a);

function foo() {
console.log('aaaa');
return;
}

foo();

可以看到 output 中的const b = 2;以及 main 中的const c = 3;和 return 之后的console.log('bbbb');在最终打包结果 bundle 中都被移除掉了。对比打包之前和打包之后的结果我们可以总结出 Tree Shaking 主要做了以下几点事情:

  • 消除无用的变量,函数等未被引用的内容
  • 消除代码中不会被执行的逻辑
  • 消除引入的其他模块中不被使用的内容

注意以上列举的几点是 Tree Shaking 通用的作用,但是并不是所有的打包工具都做到了这些,如默认情况下 esbuild 并不能消除掉上述示例代码中 return 之后的部分,但是可以借助工具在压缩阶段去除。

Read more

Multi-tasks With Process & Thread

现代操作系统都是「多任务」的,也就是操作系统可以「并发」处理多个任务,比如可以在浏览页面的时候同时播放音乐,但是一般来说我们的 PC 一般都只有一个物理 CPU,那么是如何做到只有一个 CPU 的情况下并发处理多个任务的呢,我们简单探究一下。

前置知识

我们先简单熟悉一下需要理解的 CPU 硬件相关的术语

1.Sockets(physical CPU): 物理 CPU,指我们主板上实际插入的 CPU,一般来说 PC 只有一个,服务器可能会有多个
2.Cores: CPU 物理核心,CPU 商品上宣传的一共几核指代的就是这个
3.Logical Processors: 逻辑处理器,如果采用超线程(多线程)技术的话,会比物理核心数多

总的来说: Logical Processors = Sockets _ Cores _ SMT(HT) Multiple
逻辑处理器数量也就代表了操作系统认为能「并行」执行的任务的最高数量

并发 VS 并行

我们对「并发」和「并行」先下个定义,「并发」指的是系统允许多个任务同时存在,「并行」则指的是系统支持多个任务同时执行,「并发」和「并行」的关键区别在于是否能同时执行。在只有单一逻辑处理器的情况下,我们的操作系统只能「并发」执行任务,比如早期的单核 CPU 电脑,但是我们仍然可以边听歌边浏览网页,这是因为 CPU 速度足够快,可以在系统的使用过程中快速切换任务,这样我们就感觉到多个任务同时存在在单一逻辑处理器的情况下,虽然我们可以「并发」执行任务,实际上我们同时也只能执行一个任务,对于 IO 密集类型的任务,我们用到 CPU 的时间不多,决定任务快慢的往往是硬盘以及网络等硬件,「并发」执行也未尝不可,但是对于计算密集型的任务,我们需要占用更多的 CPU 时间,如果「并发」执行,则往往会造成任务的卡顿(响应时间过长),因此我们需要「并行」的执行该任务,而逻辑处理器的数量代表了能「并行」执行任务的最高数量,这也是为什么现在的处理器大多是多核处理器的原因所在。

进程 VS 线程

我们使用的一个个程序可以称为「进程」(process),而 process 下可以开辟多个「线程」(thread),这里引用一下 Microsoft 官方对于进程和线程的解释About Processes and Threads:

Each process provides the resources needed to execute a program. A process has a virtual address space, executable code, open handles to system objects, a security context, a unique process identifier, environment variables, a priority class, minimum and maximum working set sizes, and at least one thread of execution. Each process is started with a single thread, often called the primary thread, but can create additional threads from any of its threads.

A thread is the entity within a process that can be scheduled for execution. All threads of a process share its virtual address space and system resources. In addition, each thread maintains exception handlers, a scheduling priority, thread local storage, a unique thread identifier, and a set of structures the system will use to save the thread context until it is scheduled. The thread context includes the thread’s set of machine registers, the kernel stack, a thread environment block, and a user stack in the address space of the thread’s process. Threads can also have their own security context, which can be used for impersonating clients.

在操作系统层面,process 相互独立,拥有一块独立的虚拟地址空间(内存中),而同一 process 下的 thread 共享该虚拟地址空间,这也是 process 和 thread 最典型,最根本的区别

多进程 VS 多线程

假如我们现在要开发一款浏览器,浏览器的基础功能包括 HTTP 请求,GUI 渲染等功能,如果我们采用单线程来开发,那么势必会遇到一个问题: 当需要网络请求的时候,我们的浏览器就会卡住,所有的用户操作如输入等都没有响应,等网络请求完成,我们才可以进行后续操作,非常影响用户体验,这也是为什么像浏览器这样的程序大多都是多线程的原因,我们需要任务同时进行。但是我们前面讲到的多进程也可以多任务同时进行,那么问题就来了,当我们需要实现多任务的时候,多进程和多线程该如何选择呢?

Read more

家庭网络部署指南

最近新房入住,也趁着这次机会对家里的网络进行了一次改造,这篇文章把整个流程记录梳理一下,希望对有和我类似需求的人有所帮助。

整体需求

  1. 网速尚可,外网下载速度支持 4K 视频不卡顿
  2. 全局代理
  3. 照片视频类的资源存储在本地
  4. 24h 全天候内部服务器
  5. 尽可能的利用现有资源: 两台旧的笔记本

初次搭建

第一次的搭建主要核心在 openWRT 软路由上,整体网络拓补图如下所示:
初次搭建

Read more

module federation

概述

前端的工程化已经持续了很长时间,从最开始的手动更新和维护,之后以文件为单位的自动化工具的产生,在开发和部署的过程中进行一系列的操作,最终组成一个较大的 SPA 或者 MPA。随着业务的发展,网页应用越来越多,以文件为单位进行构建则难以维护与复用。webpack5 引入了一个重要的特性 module federation,目的是解决多业务线,多工程下的模块复用问题。

使用方法

关于module federation的用法,官方给出了很详细的示例,我们以automatic-vendor-sharing为例简单来看一下是如何使用的:

线上 demo

app1

先看一下 app1 的 webpack 关键配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
new ModuleFederationPlugin({
name: 'app1',
filename: 'remoteEntry.js',
remotes: {
app2: 'app2@http://localhost:3002/remoteEntry.js',
},
exposes: {
'./Button': './src/Button',
},
shared: {
...deps,
react: {
singleton: true,
},
'react-dom': {
singleton: true,
},
},
}),

这里的关键信息是ModuleFederationPlugin中声明了remotes,然后我们就可以使用了,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import LocalButton from './Button';
import React from 'react';

const RemoteButton = React.lazy(() => import('app2/Button'));

const App = () => (
<div>
<h1>Bi-Directional</h1>
<h2>App 1</h2>
<LocalButton />
<React.Suspense fallback="Loading Button">
<RemoteButton />
</React.Suspense>
</div>
);

export default App;

app2

我们回到 app1 再看一下 app1 的 webpack 配置,注意到 app1 配置了namefilename,以及exposes等信息,在开发和部署时会生成对应的文件,供其它 app 使用,我们来看一下 app2 的 webpack 关键配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
new ModuleFederationPlugin({
name: 'app2',
filename: 'remoteEntry.js',
remotes: {
app1: 'app1@http://localhost:3001/remoteEntry.js',
},
exposes: {
'./Button': './src/Button',
},
shared: {
...deps,
react: {
singleton: true,
},
'react-dom': {
singleton: true,
},
},
}),

app2 中也调用了 app1 的组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import LocalButton from './Button';
import React from 'react';

const RemoteButton = React.lazy(() => import('app1/Button'));

const App = () => (
<div>
<h1>Bi-Directional</h1>
<h2>App 2</h2>
<LocalButton />
<React.Suspense fallback="Loading Button">
<RemoteButton />
</React.Suspense>
</div>
);

export default App;

如上示例所示,两个应用相互调用对方暴露出来的模块,那么扩展到多个应用也同样合适,这样就可以较好的解决模块复用问题

Read more

How to Clone

日常开发中会涉及到对象拷贝相关的运用,怎么拷贝是个值得探讨的问题。

浅拷贝

JavaScript一共有七种基础类型,分别为string,number,boolean,null,undefined,symbol,bigint,浅拷贝遇到基础类型的值会直接重新拷贝该值,而对于object类型的值,则会拷贝该值的引用地址

Object.assign()

我们可以使用Object.assign({}, targetObject)来进行浅拷贝,如下所示:

1
2
3
4
5
6
7
8
9
10
11
const bar = {
foo: 1n,
bar: {
a: 1,
},
};

const newBar = Object.assign({}, bar);
console.log(newBar.bar === bar.bar); // output: true
newBar.bar.a = 2;
console.log(bar.bar.a); //output: 2

可以观察到对拷贝之后的newBar.bar进行重新赋值操作,依然可以影响到原来的bar.bar,这也是浅拷贝的主要问题。

Object Spread Operation

我们也可以使用对象扩展运算符来进行浅拷贝,如下所示:

1
2
3
4
5
6
7
8
9
10
11
const bar = {
foo: 1n,
bar: {
a: 1,
},
};

const newBar = { ...bar };
console.log(newBar.bar === bar.bar); // output: true
newBar.bar.a = 2;
console.log(bar.bar.a); //output: 2

总的来说,浅拷贝使用简单明了,但是对于非基础类型值的处理而导致的问题,往往让我们放弃浅拷贝而转用深拷贝。

深拷贝

深拷贝则没有上述浅拷贝带来的问题,对于object等类型的值的处理是重新生成相同的值,这样新值和旧值不会相互影响,解决了浅拷贝的问题。

JSON.parse(JSON.stringify(targetObj))

使用JSON相关的方法来对一个对象深拷贝是常用的深拷贝方法之一,优点是调用简单,且速度较快,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
const bar = {
foo: 1,
bar: {
a: 1,
},
bas: [1, 2, 3],
};

const newBar = JSON.parse(JSON.stringify(bar));
console.log(newBar.bar === bar.bar); // output: false
newBar.bar.a = 2;
console.log(bar.bar.a); //output: 1

缺点也比较多,比如对递归引用的值无法处理,以及对Set,Map,Date等类型的对象无法处理等,但是一般来说后端返回的内容不会涉及到如上数据类型,所以JSON.parse(JSON.stringify(targetObj))适用于大部分的情况。

Recursive

当然我们也可以使用递归的思想来解决深拷贝的问题,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const bar = {
foo: 1,
bar: {
a: 1,
},
bas: [1, 2, 3],
};

function deepClone(target) {
if (typeof target !== 'object') {
return target;
}
if (Array.isArray(target)) {
return target.map(item => deepClone(item));
}
return Object.keys(target).reduce((ret, key) => {
ret[key] = deepClone(target[key]);
return ret;
}, {});
}

const newBar = deepClone(bar);

console.log(newBar.bar === bar.bar); // output: false
newBar.bar.a = 2;
console.log(bar.bar.a); //output: 1

可以看到deepClone简单实现了一个深拷贝,当然这里的deepClone还有很多情况没有考虑,比如对Set等数据结构的处理,这种情况下我们可以借助第三方库如lodash实现,感兴趣的可以看一下lodash关于这块的处理cloneDeep(obj)

structuredClone()

那么有没有比较简单的原生支持的深拷贝,既能避免JSON.parse(JSON.stringify(targetObj))不能处理Set,Map等数据结构的问题,又不需要引入lodash等三方库呢?答案是有的,那就是structuredClone()

structuredClone()目前只有 Firefox 94,Deno 1.14 以及 Node.js 17.0.0 支持,但是实际上相关算法已经在较早之前运用到了 MessageChannelNotification等地方,以MessageChannel为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function structuredClone(originObj) {
const { port1, port2 } = new MessageChannel();
return new Promise(resolve => {
port1.onmessage = function (event) {
resolve(event.data);
};
port2.postMessage(originObj);
});
}

const obj = {
foo: new Date(),
bar: {
a: new Set([1, 2, 3]),
b: 2,
},
};

const newObj = await structuredClone(obj);

console.log(obj.foo === newObj.foo); // output: false
newObj.foo = new Map([
[1, 2],
[3, 4],
]);
console.log(Object.prototype.toString.call(obj.foo)); // output: [object Date]

可以看到structuredClone()可以正常的处理Set,Date等数据结构,并且成功进行了深拷贝。

总结

  1. 日常开发中JSON.parse(JSON.stringify(targetObj))基本上可以应对大部分情况,且较为高效
  2. 涉及到Map,Set等数据结构的情况下,考虑使用structuredClone(targetObj)
  3. 最后我们仍然可以借用lodash等三方库帮我们处理深拷贝问题

链接

How Does babel-plugin-import Work

Babel 的处理流程

作为一个transpilerBabel处理的事情比较简单,就是将各种形式的源代码翻译成目标环境的代码,整个过程主要由三个部分组成,如下图所示:

Parse

将源代码转换为Abstract Syntax Tree(AST),Babel 相关的AST Node Types详见spec,这个过程会涉及到@babel/plugin-syntax-*相关的插件,并且这个过程不支持自定义插件,只能通过配置来增减需要的官方插件。整个 Parse 的过程我们基本上不需要做什么,Babel 会把最终的 AST 返回给我们以便进行后续的遍历和转换操作。

Read more

Caching Files With Service Worker

浏览器的缓存是把双刃剑,使用得当的话,可以加快页面的加载速度,减少向服务器的请求次数,进而减少带宽和服务器压力。但是如果使用不得当的话,会造成新功能或者问题修复未能正常生效等问题,用户体验有所下降。常见的缓存方式包括强制缓存和协商缓存,分别用在不同情境下,通过相关的 Headers 控制(感兴趣的可以看之前 HTTP Headers 文章中关于缓存的介绍),也是大多数网站的首要技术选择。那么还有什么其他缓存相关的技术可以使用吗?让我们来关注一下Service Worker

快速入门

Service Worker旨在通过代码精确控制缓存文件和 HTTP 请求,是已经被废弃掉的AppCache技术的替代方案。Service Worker有相关的生命周期概念,如下所示:

Read more

HTTP Request In Browser

日常开发中和服务器进行数据交换我们会发起 HTTP 请求,比较传统的做法是使用 XMLHttpRequest,也出现了很多基于 XMLHttpRequest 封装的比较不错的库如axios,或者使用较为新的 Fetch。本篇文章从 HTTP Messages 出发(关于 HTTP Messages 详见之前的文章),讲述 XMLHttpRequest 和 Fetch 两种方式下 Request 和 Response 组成内容对应的属性或方法,以及两种方式比较重要的配置项,最后探讨一下两种方式的优缺点。

Read more

HTTP Headers

上一篇我们对 HTTP 做了整体的介绍,主要介绍了 HTTP 不同版本的区别以及 HTTP Messages 相关的 Method,Status 和 Reason Phrase。这篇文章我们将重点放在 Headers 上,谈一谈 Headers 相关的 API 以及日常开发中涉及到的内容。

The Headers Interface

Headers 在浏览器环境下是有相关的 Interface 的,常和 Fetch API 结合使用。Headers 实例内部部署了 Iterator,所以可以使用for...of来遍历,相关的方法从字面意义上都很好分辨用途,如下所示:

  • append(name,value): 添加一个 name 对应的新值,如果 name 存在则在原来的 value 上追加新值,用逗号隔开
  • set(name, value): 添加一个 name 对应的新值,如果 name 存在则覆盖原来 name 对应的 value,所以更推荐使用set来添加新值
  • get(name): 返回 name 对应的 value
  • delete(name): 删除 name 及其对应的 value
  • has(name): 判断 name 是否存在
  • keys(),values(),entires(): 返回对应的迭代器用于遍历

Headers Interface 为我们提供了一整套维护 Headers 的机制,并且可以很方便的遍历其中的内容,所以推荐使用HeadersInterface 来维护需要的 Headers。

在使用Headers Interface 时需要注意,大写的 name 会自动转为小写,因为 HTTP 规范中明确 Headers 是大小写不敏感的,并且 HTTP/2 只支持小写的 Header name。

Read more

HTTP Overview

作为 HTTP 系列文章的开篇,我们先快速回顾一下 HTTP 的发展史:

  • HTTP/0.9: 只支持 GET 请求,并且没有 Headers 的概念,并且只能返回 HTML
  • HTTP/1.0: 增添了 POST 以及 HEAD 请求,增添了状态码以及 Headers 的概念,依托于 Headers,可以返回除 HTML 文件之外的内容。HTTP 相关的基础内容在这个版本基本添加完毕,但是还存在无法连接复用以及请求只能排队发送的问题。
  • HTTP/1.1: 增添了 PUT,DELETE,TRACE,OPTIONS,PATCH,CONNECT 请求,增加了缓存的相关的 headers,增加了 headerConnection:keep-alive解决了连接复用问题,同时请求可以同时发送,并且增加了断点续传功能。这个版本增加了缓存以及连接复用等优化点,是目前很多网站最常使用的版本。但是还是不能解决队头堵塞的问题(虽然请求可以同时发送了,但是响应还是按照请求发送的顺序依次返回)
  • HTTP/2: 将原先的文本传输改为二进制格式传输(二进制分帧),同时共用一个 TCP 连接,并且请求和响应没有顺序的概念(多路复用)。并且将请求头部压缩,同时服务端可以主动推动消息给客户端。总的来说HTTP/2做了很多传输方面的优化。
  • HTTP/3: 弃用 TCP 协议,改为基于 UDP 协议的 QUIC 协议,主要解决队头堵塞的问题,目前还处于草案阶段。
Read more