一、前言

qiankun 开始,沙箱(或沙盒、sandbox)已经成了几乎所有微前端框架的标配功能。但事实上其内部涉及实现的大量细节,导致每家的能力参差不齐。

严格来说,沙箱并没有遵循的标准,在一些细节上的实现也没有对错,具体行为还是要取决于业务的需求。

Node.jsvm 模块并不能直接移植到浏览器端,一个很大的原因在于浏览器涉及的视图(包括 DOM、URL)无法被拷贝,只能共享,那么共享到什么水平就成了沙箱方案能力差别的衡量标准之一。

二、变量隔离

沙箱的基础能力就是隔离上下文,让下列操作都只局限在特定的上下文内,不会干扰到外部:

  • 删除已有变量,如 delete obj.aRelect.deleteProperty(obj, ‘a’)
  • 修改已有变量
    • 修改取值,如 obj.a=1Reflect.set(obj, ‘a’, 1)Reflect.defineProperty(obj, ‘a’, { value: 1 })
    • 修改描述符,如 Reflect.defineProperty(obj, ‘a’, { writable: false })
    • 修改 frozen、sealed、extensible 状态,如 Object.freeze(obj.a)Object.seal(obj.a)Object.preventExtensions(obj.a)
    • 修改原型链,如 Object.setPrototypeOf(obj.a, null)
  • 创建新的变量,如 obj.a=1Relect.defineProperty(obj, ‘a’, {value: 1})

上面的 obj 对象即指上下文对象,在浏览器中通常是 windowdocument ,这两个全局对象。但事实上,window 下的所有属性都可以直接取到,如 addEventListenernameCSSlocationhistorynavigatorHTMLElement 等等,不胜枚举。因此, 沙箱不可能监视所有变量的属性删除/修改/创建,因此也就不可能实现“完美”沙箱 ,毕竟你不能遍历 window 下的所有属性,都监视一遍。

with(){} 的做法不在考虑范围之内,对性能损耗过大。

这个事实带来的后果是,如果你想逃逸出沙箱,是非常容易的,比如 navigator.no=1 。所以,沙箱在微前端中有使用价值的前提是, 你必须尽可能保障对全局变量的访问是可控、无副作用的 ,这是沙箱的脆弱之处,也是一种规范。接下来我们将在这一规范下继续讨论沙箱的实现问题,假设我们只考量对 window 和 document 这两个变量的属性监视。

2.1 属性监视

毫无疑问,在现代浏览器中,Proxy 是监视对象的最佳方案,通过它,我们应该可以被通知且控制获取、修改、删除、遍历等几乎所有操作。但是,proxy 对象真的可以为所欲为吗?

观察下列代码:

这里涉及到了关于 configurable 导致的错误,事实上,有大量的操作都是被 proxy 所禁止的,在 ECMA262 上的Proxy部分搜索 Invariants 能查询得到。因此 proxy 对象并非无所不能,它无法任意伪装原始对象的行为, 该失败的必须失败 。相关规则包括如下:

对象操作 不变量
defineProperty
  1. 如果目标对象是不可扩展的,那么使用defineProperty新增属性时不能返回true;
  2. 如果目标对象没有不可配置属性a,则代理对象也不能用defineProperty在将属性a定义成不可配置时时返回true;
  3. 如果目标对象没有不可配置且不可写的属性a,则代理对象也不能用defineProperty在将不可配置属性a定义成不可写时返回true
deleteProperty
  1. 如果目标对象有不可配置属性a,那么代理对象在用deleteProperty删除a时不能返回true;
  2. 如果目标对象是不可扩展的,且有属性a,那么代理对象在用deleteProperty删除a时不能返回true;
get
  1. 如果目标对象有不可配置且不可写的属性a,那么代理对象在用get取值a时必须返回和目标对象相同值;
  2. 如果目标对象的属性a是不可配置的,且是缺少get的存取类型,那么代理在用get取值a时必须返回undefined
getOwnPropertyDescriptor
  1. 如果目标对象有不可配置的属性a,那么代理对象在用getOwnPropertyDescriptor获取a时不能返回undefined;
  2. 如果目标对象是不可扩展的,且有属性a,那么代理对象在用getOwnPropertyDescriptor获取a时不能返回undefined;
  3. 如果目标对象是不可扩展的,且没有有属性a,那么代理对象在用getOwnPropertyDescriptor获取a时必须返回undefined;
  4. 除非目标对象有不可配置且不可写的属性a,那么代理对象在用getOwnPropertyDescriptor获取a时就不能是不可配置且不可写的;
getPrototypeOf
  1. 如果目标对象是不可扩展的,那么代理对象在用getPrototypeOf时必须返回与在目标对象上调用的返回值
has
  1. 如果目标对象有不可配置的属性a,那么代理对象在用has获取a时不能返回false;
  2. 如果目标对象是不可扩展的,且有属性a,那么代理对象在用has获取a时不能返回false;
ownKeys
  1. 如果目标对象是不可扩展的,那么代理对象在用ownKeys时必须返回目标对象的全部属性名,不能包含额外属性名
set
  1. 如果目标对象有不可配置且不可写的属性a,那么代理对象就不能用set给a设置不同的值;
  2. 如果目标对象的属性a是不可配置的,且是缺少set的存取类型,那么代理在用set设值a时必须返回false

看似复杂,但实际总结来看,就是代理对象必须遵循一个原则: 操作的结果要真实反应 target 的最新状态。

举几个例子:

  • 如果Reflect.has(proxyObj, ‘a’)返回true,那么target就必须不能有一个不可配置的属性a;
  • 如果Reflect.set(proxyObj, ‘a’, 1)返回true,那么target对象必然不能是不可扩展的,也不可以有一个不可写也不可配置的属性a

如下的做法,直接代理原始 window、document 肯定是不可以的,根据上面的 Invariants 可知我们几乎必须把属性同步给原始的 window、document 才能不报错,显然违背我们做沙箱的初衷。

1
const proxy = new Proxy(window, {});

因此,通常的做法是把一个 新创建的对象 当作原始对象进行代理,下文简称为 target

1
2
const target = {};
const winProxy = new Proxy(target, {});

所有的操作几乎都是最终体现在 target 对象上的,个别稍有例外。

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
27
28
const target = {} as Window;

const winProxy = new Proxy(target, {
defineProperty: function (target: Window, p: PropertyKey, attributes: PropertyDescriptor): boolean {
return Reflect.defineProperty)(target, p, attributes);
},
deleteProperty: function (target: Window, p: PropertyKey): boolean {
return Reflect.deleteProperty(target, p);
},
get: function (target: T, p: PropertyKey /*, receiver: any */): any {
return Reflect.get(target, p);
},
getOwnPropertyDescriptor: function (target: T, p: PropertyKey): PropertyDescriptor | undefined {
return Reflect.getOwnPropertyDescriptor(target, p);
},
has: function (target: T, p: PropertyKey): boolean {
return Reflect.has(target, p);
},
ownKeys: function (target: T): ArrayLike<string | symbol> {
return Reflect.ownKeys(target);
},
set: function (target: T, p: PropertyKey, value: unknown /*, receiver: unknown */): boolean {
return Reflect.set(target, p, value);
},
getPrototypeOf() {
return Reflect.getPrototypeOf(window);
}
});

但显然这样是有严重问题的,因为 target 是伪装的 Window 对象,它身上没有任何属性,这不但会影响 get、getOwnPropertyDescriptor、has、ownKeys 这些只读操作的结果,由于 Proxy 的规则,同样也会影响 defineProperty、deletePrperty、set 这些写操作的结果。

举个例子,本来真实 window 对象上有一个不可配置的属性 foo,正常来说,我们用 defineProxy 修改其描述符类型时一定会报错,但是 target 本身并没有任何属性,Reflect.defineProperty(target)却是成功的,不符合期望。

于是,业界常规的做法都是会把 原始对象的自身属性拷贝到 target 中,特别是那些不可配置的属性 。这样无论是读操作还是写操作,其结果都真实反应到了代码对象的 target 中,不会被任何 Proxy 原则所影响。

1
2
3
4
5
6
const target = {} as Window;

for (let key of Object.getOwnPropertyNames(window)) {
const descriptor = Reflect.getOwnPropertyDescriptor(window, key);
if (!descriptor.configurable) Reflect.defineProperty(target, p, descriptor);
}

这一步的成本稍高,但是又是必须的。在具体的实现策略上也可以区分为一次性拷贝和懒惰式拷贝,即用到某属性时才执行拷贝。

2.2 主动变量逃逸

虽然沙箱的关键作用就是为了限制变量的访问和变更范围,但是毕竟在同一个浏览器页面之下,难免有需要例外放行的 case。我们称这类变量为 exception 或者 escaped,这种功能称之为“主动变量逃逸”。

实现主动变量逃逸比较简单,以 set 和 get 操作为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const target = {} as Window;

const winProxy = new Proxy(target, {
get: function (target: T, p: PropertyKey /*, receiver: any */): any {
if (isEscaped(p)) {
return Reflect.get(window, p);
}
return Reflect.get(target, p);
},
set: function (
target: T,
p: PropertyKey,
value: unknown /*, receiver: unknown */
): boolean {
if (isEscaped(p)) {
return Reflect.set(window, p, value);
}
return Reflect.set(target, p, value);
},
});

不过别忘记了 Proxy 的那些 Invariants 限制,上述这些操作的结果都要反应到 target 身上,所以最后还是得把原始对象(如 window)上的属性同步到 target 上。

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
27
28
29
30
31
32
33
const target = {} as Window;

const winProxy = new Proxy(target, {
get: function (target: T, p: PropertyKey /*, receiver: any */): any {
if (isEscaped(p)) {
// 同步到target中
Reflect.defineProperty(
target,
p,
Reflect.getOwnPropertyDescriptor(window, p)
);
return Reflect.get(window, p);
}
return Reflect.get(target, p);
},
set: function (
target: T,
p: PropertyKey,
value: unknown /*, receiver: unknown */
): boolean {
if (isEscaped(p)) {
// 同步到target中
Reflect.defineProperty(
target,
p,
Reflect.getOwnPropertyDescriptor(window, p)
);
Reflect.set(window, p, value);
return Reflect.set(target, p, value);
}
return Reflect.set(target, p, value);
},
});

2.3 函数属性上下文

上面提到,我们需要把原始对象(window、document)的属性同步到 target 对象中,Proxy 才会不受到 Invariants 的影响,能更真实的模拟读写操作。

我们看下面一个例子:

再来看这样一个例子:

有这样一类函数,它们只能在指定的上下文中执行,即便是 Proxy 也不可以,否则在 Chrome 下就会报告 Illegal invocation 错误。在 Firefox 和 Safari 上的错误信息会更通俗易懂一些。

目标没有很好的办法来解决这个问题,毕竟函数内部的逻辑是无法预测的,只能尽可能兼容。一些策略有:

  • 如果属性名是 constructor,无需特殊处理;
  • 如果属性名以大些字母开头, 认为它们是构造函数 ,无需特殊处理;
  • 创建包装函数来锁定上下文:
1
2
3
4
5
6
7
8
const newValueInTarget = function (this: any, ...args: unknown[]): unknown {
// 小写字母也可能是构造函数
if (new.target) {
return Reflect.construct(valueInRaw, args);
}

return Reflect.apply(valueInRaw, raw, args);
};
  • 一些特殊属性的处理,例如 window 上的 eval、isFinite、isNaN、parseFloat、parseInt、hasOwnProperty、decodeURI、decodeURIComponent、encodeURI、encodeURIComponent,直接走主动变量逃生即可

三、执行 JavaScript 代码

上面讨论的是沙箱的最关键能力——变量隔离,但无论实现怎样的能力,子应用的 JS 代码还是要得到执行,那么该如何执行的?

3.1 eval

业界普遍的做法是异步 fetch 到源代码,然后 eval 它,虽然需要跨域环境的支持,但并不是难事。只是 eval 需要一些技巧。

首先,eval 需要在真正的 window 上下文中执行,避免调用环境的影响,这一点,目前已经有比较明确的实现方案,就是“间接调用”:

1
2
3
function evalScript(code: string): any {
return ("", eval)(code);
}

其次,利用函数入参来改变一些全局变量名的作用域,将其指向既定的对象,比如:

1
2
3
4
evalScript(`;
(function (window, self, parent, top, globalThis, document) {
${appCode}
}).call(winProxy, winProxy.window, winProxy.self, winProxy.parent, winProxy.top, winProxy.globalThis, winProxy.document)`);

从这里也能看出,如果直接引用如 location、navigator、history 将无法被沙箱捕获,你需要用 window.location、window.navigator、window.history 的方法。进而可以推断出你在全局定义的变量,也必须以 window 属性的方式来读取,比如:

1
2
3
4
5
6
7
8
9
10
11
12
<head>
<title></title>
<script>
var loadStartTime = Date.now();
</script>
</head>
<body>
<script>
var loadCost = Date.now() - loadStartTime;
</script>
<script src="./entry.js" entry></script>
</body>

像上面这种 case,如果不以 window.loadStartTime 的方式能不能读取得到呢?也有一些技巧可做到。

比如使用嵌套递归作用域的方式来实现,相当于每执行一次 JS 之后,就会为下一次执行生成一个新的嵌套上下文,这样后面的 JS 就可以直接读取上次的变量。

1
2
3
4
5
6
function() {
var loadStartTime = Date.now();
function() {
var loadCost = Date.now() - loadStartTime;
}
}

具体的实现机理稍有复杂,理论上也会带来额外的性能开销,而且对于下面这种双向访问的场景也无法支持:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<head>
<title></title>
<script>
var loadStartTime = Date.now();
function sendLog() {
// 访问未预定义变量
sendToServer({ loadCost });
}
</script>
</head>
<body>
<script>
var loadCost = Date.now() - loadStartTime;
</script>
<script src="./entry.js"></script>
</body>

不过仍然具有一定的价值,对于以 HTML 作为 entry 的子应用的容纳范围更广,子应用的灵活度更高。

3.2 环境变量

有时,需要暴露给子应用的 JS 一些临时的虚拟变量,比如 qiankun 提供的 __POWERED_BY_QIANKUN__ ,而且允许不同子应用读取到的同一名称的变量有不同的取值。

如果不开启沙箱的话,这一功能反而困难,需要在 window 上定义变量,然后以同步的形式运行子应用 JS 代码,最后在从 window 上移除掉。这个过程不但很可能和 window 上已经有的同名变量冲突,而且也只能保证同步代码中能读取到,异步代码中就读不到。举例说明:

1
2
3
4
5
if (window.__IN_MICRO_ENV__) {
Promise.resolve().then(() => {
console.log(window.__IN_MICRO_ENV__); // undefined
});
}

在沙箱环境中,实现环境变量更简单,而且可以不受同步/异步的影响,可以持续访问。

1
2
3
4
evalScript(`;
(function (window, document, __IN_MICRO_ENV__) {
${appCode}
}).call(winProxy, winProxy.document, winProxy.__IN_MICRO_ENV__)`);

3.3 ESM

ESM 格式的 JS 代码不能直接 eval 来执行。事实上,浏览器还未提供能直接运行 ESM 源码的方法。Garfish 采取来将源码转换成 URI 的方式实现了一定程度的 eval 能力,但是需要正则匹配 import 指令,存在一定的隐患。即便如此,因为不能用函数直接封装 ESM 源码,因此也无法实现沙箱运行。因此,garfish 还实现了一套运行时转译 ESM 的机制,但是对性能有较大影响,相信其稳定性也存在安全隐患。

四、DOM 结构

一般来说,子应用有自认为的 DOM 环境,比如 html、body、head 以及#app 等等。

4.1 固定 DOM

在沙箱环境中,如果把真正的 html、body、head 暴露给子应用,那么它很有可能在上面做一些副作用的操作,比如插入新 DOM、修改样式等等。为了避免这种情况,微前端框架一般都会给子应用生成一个模拟的 DOM 结构,比如:

1
2
3
4
5
6
<pseudo-html>
<pseudo-head>
<pseudo-title></pseudo-title>
<pseudo-head>
<pseudo-body></pseudo-body>
</pseudo-html>

DOM API 中的 document.documentElementdocument.bodydocument.head 也都指向它们。

根据业务需求,可以做更深的伪装定制,通过以下测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
document.documentElement.tagName === "HTML";
document.documentElement.nodeName === "HTML";
document.documentElement.version === "";
document.documentElement.parentNode === document;
document.documentElement.parentElement === null;
document.documentElement.constructor === HTMLHtmlElement;
document.documentElement instanceof HTMLHtmlElement === true;

document.body.tagName === "BODY";
document.body.nodeName === "BODY";
document.body.constructor === HTMLBodyElement;
document.body instanceof HTMLBodyElement === true;

document.head.tagName === "HEAD";
document.head.nodeName === "HEAD";
document.head.constructor === HTMLHeadElement;
document.head instanceof HTMLHeadElement === true;

注意 document.documentElement.parentElement ,如果等于 null,可能对一些视觉框架、组件库等需要用 parentElement 向上递归搜索的功能不友好。可根据需要是否开启以上伪装能力。

4.2 存量 DOM

存量 DOM 是指那些在子应用的 HTML entry 中已有的 DOM 结构,简单的如#app,也可能有更复杂的结构。

通常需要把它们同步到上述固定 DOM 的 body 中,也有些方案把 head 中的 meta 都同步了过来。

一旦需要拷贝,需要考虑如下问题:

  • 非法元素、样式的过滤;
  • 元素在沙箱环境的适配

4.3 新增 DOM

新增 DOM 有多种创建方式:

  • document.createElement();
  • dom.clone();
  • dom.innerHTML=

通常来说只有第一种会被沙箱接管,使得新创建的 DOM 的 ownerDocument、baseURI 是符合沙箱环境的。

需要特别关注的是,新创建的 script 元素会被转换成一个无实际功能的<pseudo-script>元素。框架会在后台自行下载/执行其代码,模拟了 script 的能力。

Custom Element默认是inline类型,除了在shadow DOM内部使用 :host{display:block} 外,只能在外部用选择器覆盖。未来如果Safari支持继承built-in元素后可以解决。

五、总结

  • 沙箱只能处理有限范围内的变量隔离,通常为 window 和 document;
  • 以 eval function 的方式执行 JS 源码,全局变量引用应以 window 属性的方式使用;ESM 无法支持沙箱;
  • 子应用的 DOM 结构可以被伪装,但仍然能轻易实现逃逸

沙箱的本质是为子应用打造一个微型的独立浏览器环境,受限于成本,无法做到尽善尽美,仍然需要子应用遵循一定的规范和约定 。而由于微前端的主、子应用在管理上的独立性,往往沙箱能力的升级会对子应用造成较大的影响。

六、未来发展

业界对沙箱的实现均强依赖 Proxy 技术,区别仅在于对副作用的拦截能力多少。随着业务复杂度的提升,以及一些存量旧业务在遵循冲突约定的改造成本上的考虑,逐渐意识到 Proxy 的能力仍然有限,想实现健壮性更强的沙箱环境,开发成本高且性能不稳定。

业界已经有一些方案开始逐渐回归 iframe。iframe 天生具有强隔离性,不必对全局变量一一关注即可达到期望的隔离性能。不过这种一刀切的做法在实际的业务中仍然受到挑战,比如对主动逃逸变量的支持,比如对 DOM 的操作等等,仍然需要一定的机制将 iframe 和主页面整合到一起。这一步,仍然离不开 Proxy 的支持。

ECMA 已经有一个新的提案,叫做ShadowRealm,位于 Stage3,对于创建独立的 JS 执行环境是一个比较理想的方案。不过微前端离不开视图,如何共享视图对象以及控制共享的粒度,就不是 ShadowRealm 的范围了。

没坏的话就不要修

这里讲的系统应该能够治愈。不过在现实场景中不太可能,即使可能在时效性上也不允许。

如果这里我们把这句话理解成:不要瞎折腾,没事找事,显然有一种消极的态度,如果一直安于现状,就会阻断创新。

从咨询师的角度来看更容易理解这句话,咨询师是系统的“外来物”,不是对于系统出现的所有问题都要横加干涉,适当的放任反而有益于系统的成长。可以借着下一句话来深入理解:

反复治疗一个可以治愈的系统最终让它不能治愈

引用书中的一句话就能轻松解释:

那些孩子四岁多了还帮忙擦鼻子的父母,以及靠为同一个客户反复解决同一个问题混饭吃的顾问,都应该牢记这个秘密。

那是那句话,不是都有问题都要横加干涉。否则最终会超出自己的能力范畴。

每个处方都包含两部分:药品和正确使用它的方法

我能把它理解成:“文档很重要,是产品的一部分”么?

我们在提供服务的时候,除了服务实体本身之外,还需提供使用说明。某些领域会提到以代码自注释能力来衡量代码的优劣,我觉得有一定道理但不是全部。

简单的系统确实可以做到轻松上手的目的,但也仅限于简单的系统。

如果已经做过的事情没能解决问题,就告诉他们做点别的

好打浑的说辞,不过确实有效。系统的某些问题往往出乎所有人意外,如果不能亲身实地地验证,仅仅依赖现有经验的人脑逻辑推断,确实不一定能够做出最终的诊断。

所以,在走投无路的时候,要学会接纳那些看似绝对无效的方案,说不定有意外的收获。

务必让他们付给你足够多的钱,这样他们才会照你说的去做

就像练习书法用廉价的草纸会比用昂贵的字帖更不容易练好一样,只有你付出的成本更高,你才会更加专注和认真,因为失败的损失更大。

有另一种基于责任归属的解释,如果用字帖还练不好书法,那么将无法将责任落在纸的种类身上。

因此,通过提高任务的成本和消耗,将更有可能让执行脉络贴近自己的设计。

来得早不如来得巧

时机很重要。

【伯登法则】要是你不能改掉缺点,就把它变成特点

这一法则将适用很多行业黑话,比如公司提供晚餐意味着加班更多。产品的特性从不同的视角可能会有截然相反的描述,核心原则就是避重就轻。这不一定就是一种欺骗,因为很可能那些未提及的缺陷对客户压根没有影响,或者打一个时间差,等客户发现了的时候,缺陷已经被修复,如果是可能甚至还可以是付费升级。

【镀金法则】要是没法当成特点来宣扬,那就冒充一下

这是“扒瞎”的意思吗?也许有作用,只要没人拆台和较真。对于聚集了大量耿直的人的行业中,可能风险性较大。

如果仅仅是电视广告在那里“镀金”的话,相信很少有人会真的打电话投诉;如果在一个小型的会议现场胡说八道,我想大概率会被“怼”。

这种缺乏货真价实干货的纯语言技巧,肯定不能撑太久。因此才会有下一句:

所有镀金的东西都得改正

eslint 早已取代 jslint 成为了 JavaScript 标准的风格检查工具,提供了大量规则(rule)。这些规则部分是可以支持 fixable 的,也就是说可以自动修复代码。但是现在来看,eslint 提供的修复功能还是太弱。因此 Facebook 开发了 prettier,一个专门司职代码格式化的工具,它不仅仅支持 JavaScript 语法,甚至还支持 Markdown。因此,把它们配合起来是一种自然而然的想法,首先我们有一个 eslint-config 定义,我们希望经过 format 之后的代码能100%通过 lint 校验。我们也希望在 git hook 中能自动 format 代码。

在实际应用中,让 prettier 完美配合 eslint 并不容易。根本原因在于 prettier 格式化代码的一些行为是不可配置的,而这些配置极有可能与 eslint 配置是冲突的。

例如下面的代码:

1
a === 1 || b === 2 || c ===3

在 eslint 中,我们经常配置在折行时把符号写在前边:

1
2
3
a === 1
|| b === 2
|| c ===3

但 prettier 则强行写在后面:

1
2
3
a === 1 ||
b === 2 ||
c ===3

prettier 提供了与 eslint 配合使用的官方方法。但这种方式的本质是使用 eslint-config-prettier 来抹平 prettier 与 eslint 之间不可调和的冲突。一旦我们定义的私有 eslint-config 与 eslint-config-prettier 有影响格式化结果的冲突,那么 lint 必然失败。不过这种方式的一大好处是仅仅对接 eslint 就能完成 lint 与 fix 的操作,集成性很强。

理解这种现象很容易,eslint 的 rules 是没有顺序的,因此在 fix 阶段,极有可能 prettier 的 rule 执行在最后阶段,结果显然与我们定义的最终 eslint 配置是冲突的。


相比于引用一系列 eslint-config,并维护它们的顺序,我们索性只维护我们自己的一份 eslint-config,反而更清晰。有这一份配置来执行 fix,产出一定可以通过lint。

但我们肯定不想直接抛弃 prettier,那么就把它的执行放在最前面,让它的产出再通过 eslint 即可。

已经存在这种现成的解决方案,就是 prettier-eslint。虽然看起来比较畸形,但可能是唯一一种比较能用且清晰的方案了。

但也有缺点,一旦将来某些 prettier 的不可配置的行为也不能被 eslint 所 fix,工作流就 gg 了。整合的过程就是这样,本来是相互分离的两个系统,让它们一起工作,总要失去点什么。

Code ➡️ prettier ➡️ eslint –fix ➡️ Formatted Code ✨

Q:如果能保证 prettier 提供的 rule 能先行执行呢?

继了解完“==”的原理后,再来了解一下加号“+”的 ECMAScript 实现原理。

例如:

1
a + b

第一步,对 a 和 b 执行 *toPrimitive()*,不指定 hint

toPrimitive(a) 为例,先看 a 有没有定义 Symbol.toPrimitive,比如:

1
2
3
4
5
6
7
8
9
10
11
12

a[Symbol.toPrimitive] = customToPrimitive;
function customToPrimitive(hint) {
switch (hint) {
case 'number':
...
case 'string':
...
default: // 'default'
}
};

如果定义了 Symbol.toPrimitive 属性,则执行:

1
customToPrimitive.call(a, 'default')

否则执行原生 *toPrimitive(“number”)*,也就是要依次调用 a 的 valueOftoString 方法。

如果执行 a.valueOf() 返回不是 Object,则为 toPrimitive(a) 的终值,否则执行 *a.toString()*。

接下来,看 a 有没有定义 toString,如果有,则执行 *a.toString()*,为 toPrimitive(a) 的终值。

如果没有,则执行原型链顶端的 Object.prototype.toString,这首先要看 a 有没有定义 Symbol.toStringTag

如果定义了,比如:

1
2
3
4
5
6
7
8

function customStringTag() {
...
}

Object.defineProperty(a, Symbol.toStringTag, {
get: customStringTag
});

那么返回 *”[object “ + customStringTag.apply(a) + “]”*,否则返回 *”[object Object]”*,为 toPrimitive(a) 的终值。

如果发现 toPrimitive(a) 或者 toPrimitive(b) 任一为字符串,则执行字符串拼接,否则执行数字加法,即 *toNumber(a) + toNumber(b)*,这也能解释 true + true = 2 的问题。


可见 a + b 核心就是 toPrimitive 操作,只不过从 ES2015 以来,toPrimitive 受到 Symbol 的影响,变得越来越复杂。

相比之下,减号“-”就很简单了,就是执行 *toNumber(a) - toNumber(b)*。

对于社会主义的中国人来讲,经过初中、高中、大学的思想政治课的洗礼,理解矛盾并非难事,我们辩证唯物主义的内容要更丰富得多。

第二章 培养矛盾的思维框架

不要理性,要合理

这两个相似的词忽然让我很难揣摩这句只包含6个字的话。什么样的事情是合理但不理性的呢?我理解,理性是对事件真实客观的描述,是能用逻辑严格证明的,往往获取“理性”需要很大的成本,甚至在有意义的时间内无法获取;而合理则是一种模棱两可的适当妥协,在有些事情无法获取“理性”之时,委曲求全的权宜之计都可能是一种合理。合理是一种“反牛角尖的”行为,似乎更倾向于“糊弄”。

在作者涉猎的软件咨询行业,很难想象不求甚解的方案也能行得通。从技术上来讲,确实再隐晦的故障都是可以绝对定位的,但对于项目来说,往往最初的设计和真正的落地会有很大差距,如果没有绝对的经验积累,那么项目在循循渐进的过程中往往都会带着猜的成份。换句话说,没有理性,只要比上一版本又说改进,即为合理。从这角度上来说,一味得追求最佳既是不可能的任务,也不能称之为理性的抉择,毕竟世界是个矛盾体,在矛盾中寻求理性的过程收获利益,才是上上之选。

从这里可以衍生出一种经典的产品进化模型,比如想造一辆汽车,下面有两种方案:

  1. 第一种,先造轮子、发动机、车架,最终拼接成完善的汽车;
  2. 第二种,先造一辆自行车、摩托车、三轮车,最后一辆汽车

第二种方案看似费时费力,往往更容易被人接受,也更容易实现。

自以为无所不知的人最容易上当

过于自信就是自负,往往容易对客观事实视而不见。这和“撞车的都是老司机”、“淹死的都是会水的”是类似的道理。

生活太重要,所以不能太较真

这论题太大了,似乎已经上升到了哲学和三观的高度上来了。我想作者要表达的意思应该是,对于无法解释的事物不必过于在意,毕竟你的无知正是矛盾世界里的必不可少的成份。

不付出就什么也得不到

正是矛盾的合理体现,要想得到什么,不可避免地要失去什么,当然,反过来不一定成立,往往都不成立。

提升一方面,就要牺牲另一方面

同上

费舍基本定理——你越适应现状,就越难适应变化

显然,与现有系统耦合越大,与其它系统兼容越小。抽象与具象。

伴随着年纪的增长,我觉得自己就越来越难以适应变化。

顾问一般在解决你提出的第三个问题时最有成效

这也解释了为什么人在20多岁的时候最富创造性,因为年老后才更“善于”和这个“平庸”的世界打交道。

始终关注自己在什么时候拥有最高效的进步,在合适的时候,应当勇于舍弃舒适的温床。

我确实应该换个工作了🍟

我们能做,这是所需的费用

没有成本的改变是不现实的,懂得这个道理,避免在愚蠢的任务上浪费时间与口舌。

元素在文档流中的位置应与页面滚动量无关,指的是该元素左上角(包括border,但不包括margin)距离整个页面左上角的水平和垂直位置。

获取此位置有两种方法,观察 jQuery 2.2 源码

1
2
3
4
5
6
var rect = elem.getBoundingClientRect();
var win = elem.ownerDocument.defaultView;
return {
top: rect.top + win.pageYOffset,
left: rect.left + win.pageXOffset
};

getBoundingClientRect 是一个十分高效的方法,用来获取元素相对于可见的视口的位置,而这个位置是与滚动量有关的,只有这个位置加上滚动量,即是与文档左上角的距离。

如果 getBoundingClientRect 不存在,我们依然可以通过遍历计算出结果。首先需要了解 DOM 元素的几个属性:

  1. offsetParent 向上祖先中第一个定位元素;
  2. offsetLeft 距离 offsetParent 左边界的水平位置,与滚动量无关
  3. offsetTop 距离 offsetParent 上边界的垂直位置,与滚动量无关
  4. clientLeft 一般为左边框宽度
  5. clientTop 一般为上边框宽度
  6. scrollLeft 水平滚动距离
  7. scrollTop 垂直滚动距离

因为我们要得到的距离是当时的绝对距离,与该元素的各个祖先元素的滚动量是有关的,因此我们不能简单地通过加和 offsetLeftoffsetTop 来得到最后的值,必须减去每一级祖先的滚动量。

具体逻辑大概就是,向该元素的上面遍历,减去每个元素的滚动量,一旦遇到是 offsetParent,则加上 offsetLeftoffsetTop。具体可参考 jQuery 1.4.4 源码。简单来讲就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function offset(ele) {
var top = ele.offsetTop;
var left = ele.offsetLeft;

var offsetParent = ele.offsetParent;

while((ele = ele.parentNode) && ele !== document) {
left -= (ele.scrollLeft);
top -= (ele.scrollTop);
if (offsetParent === ele) {
top += ele.offsetTop + ele.clientTop;
left += ele.offsetLeft + ele.clientLeft;
offsetParent = offsetParent.offsetParent;
}
}

return {top,left};
}

当然 clientTop 与 clientLeft 并非永远代表的是上边框和左边框的宽度。

上一篇我们熟悉了一下 Inferno.js 的 DOM Diff 算法。今天我们来看 Vue.js 框架的 DOM Diff 算法。

Vue.js 的作者没有编写自己的算法,而是使用了 Snabbdom,并做了适当的修改。

DOM Diff 的关键算法在 https://github.com/snabbdom/snabbdom/blob/v0.7.1/src/snabbdom.ts#L179

同样,我们依然假设有原始的 DOM 集合A为 “dfibge”,更新后的集合B为 “igfheb”。

创建4个指针 oldStartIdxoldEndIdxnewStartIdxnewEndIdx,初始分别指向A的起始点、结束点和B的起始点、结束点。显然:

1
2
3
4
oldStartIdx=0
oldEndIdx=5
newStartIdx=0
newEndIdx=5

第一步,比较 A[oldStartIdx] 和 B[newStartIdx],如果相同,则 oldStartIdx++、newStartIdx++。
第二步,比较 A[oldEndIdx] 和 B[newEndIdx],如果相同,则 oldEndIdx++、newEndIdx++。

在本例中,以上两步全部不满足,跳过。

第三步,比较 A[oldStartVnode] 和 B[newEndVnode],如果相同,则节点发生了右移。
第四步,比较 A[oldEndIdx] 和 B[newStartIdx],如果相同,则节点发生了左移。

在本例中,以上两步全部不满足,跳过。

第五步,在A中搜索 B[newStartIdx],即 i,找到则把A中的 i 移到 A[oldStartVnode] 前面并 newStartIdx++、oldStartVnode++,否则则创建它。

在本例中,A变成 idfbge

返回第一步。


可以看到这个算法类似于优化后的插入排序。按照此算法,A的变换路径为:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
A:dfibge
B:igfheb

=>

A:idfbge
B:igfheb
^

=>

A:igdfbe
B:igfheb
^

=>

A:igfdbe
B:igfheb
^

=>

A:igfhdbe
B:igfheb
^

=>

A:igfhedb
B:igfheb
^

=>

A:igfhebd
B:igfheb
^
=>

A:igfheb
B:igfheb
^

用一句话概括此算法的核心就是:依次遍历B集合,在A集合中找到对应项,放到与在B集合中相同的位置上。只不过 Snabbdom 使用了双向同时遍历来进行优化。

事实上,React 的 DOM Diff 算法与此也是非常类似的,只不过受限于 Fiber,只进行了单向搜索。但是即便如此, React 也引入了优化策略,尽量使得更多的元素不必移动。从本质上来看,Inferno 和 React 都利用递增子序列来进行了优化,但是 Inferno 使用算法来保证是最大递增子序列,而 React 的子序列是一定从第一个元素开始的,因此不一定是最大子序列。这在尾部元素移动到首部的时候,差异表现得更明显。

已经关注 Inferno.js 有两年的时间,终于在刚刚不久的过去建立起了官网和文档。

值得特别关注的是,Inferno.js 提供了类 React API 的同时,在 DOM Diff 算法上借鉴了 ivijs,取得了更高的效率,从而有较强的性能表现。

优秀的 DOM Diff 算法的核心都是在将一个 DOM 集合转换成另一个 DOM 集合的同时,尽可能地复用已有 DOM 并具有较少的 DOM 移动操作,这是由于 DOM 操作(甚至访问)的成本较高。

Inferno.js 在两个 VDom 之间进行比较,从而避免了频繁访问 DOM 的性能开销。下面我们看一下它实现的 DOM Diff 算法。

我们假设有原始的 DOM 集合A为 “dfibge”,更新后的集合B为 “igfheb”。

首先得到集合B中元素在A中的原始位置,如果在A中不存在则为-1,得到:

1
2
3
var A       = [d, f, i,  b, g, e];
var B = [i, g, f, h, e, b];
var sources = [2, 4, 1, -1, 5, 3];

现在我们获取该数组的最大递增子序列的序列位置,为:

1
var seq = [0, 1, 4];// [2, 4, 5]

这个数组的意义在于:“新数组中第0、1、4位置的元素在原始数组中是无需移动的”,这是我们能获取最小移动步数的关键。

一共6个成员,3个无需移动,那么一定有3个需要操作

现在我们从后往前遍历集合B,观察每个成员的位置是否存在于 seq 中,存在则不必操作,不存在则需移动。

首先我们需要先删除B中存在,但A中不存在的元素 d,此时:

1
A=[f, i, b, g, e]

观察B中最后一个元素:

1
2
3
A=[f, i, b, g, e]
B=[i, g, f, h, e, b]
^ pos=5

位置是5,不存在于 seq 中,需要把集合A中的 b 移到最后,此时:

1
A=[f, i, g, e, b] --- (1)

观察B中倒数第二个元素:

1
2
3
A=[f, i, b, g, e]
B=[i, g, f, h, e, b]
^ pos=4

位置是4,存在于 seq 中,无需操作。

观察B中倒数第三个元素:

1
2
3
A=[f, i, b, g, e]
B=[i, g, f, h, e, b]
^ pos=3

在A中不存在,我们需要创建 h,并放到 e 的前面,此时:

1
A=[f, i, g, h, e, b] --- (2)

观察B中倒数第四个元素:

1
2
3
A=[f, i, g, h, e, b]
B=[i, g, f, h, e, b]
^ pos=2

位置是2,不存在于 seq 中,需要把集合A中的 f 移到 h 的前面,此时:

1
A=[i, g, f, h, e, b] --- (3)

观察B中倒数第五个元素:

1
2
3
A=[i, g, f, h, e, b]
B=[i, g, f, h, e, b]
^ pos=1

位置是1,存在于 seq 中,无需任何操作。

观察B中倒数第六个元素,也是最后一个元素:

1
2
3
A=[f, i, b, g, e]
B=[i, g, f, h, e, b]
^ pos=0

位置是0,存在于seq中,也无需任何操作。

此时,所有操作都已结束,仅需3步,集合A已经转换成了集合B。其中,对 d 的移除和对 h 的创建是不可避免的,除此之外,仅进行了两次DOM移动,也印证了上面提到的 6-3=3 的操作步骤。

我们在移动 DOM 的时候都是执行的“插在XXX之前”,是因为 DOM 中的 insertBefore 方法,如果存在 insertAfter,那么从前往后操作也是等效的。

我们都知道 JavaScript 是通过原型链继承的,在不支持 class、extends 语法的环境里,继承又该如何实现呢?

首先我们来整理实现继承后的效果表征,假设存在两个类(函数)A 和 B,我们要实现 B 继承于 A,那么需要满足:

  1. new B() instanceof A 为真;
  2. B.prototype.constructor === B 为真;
  3. 如果 A.foo = 1,则 B.foo = 1 为真,即静态变量可继承;
  4. 在普通方法 foo 中可以调用 super.foo() 调用基类方法;
  5. 在构造方法中可以调用 super() 调用基类构造方法

在很多年前,jQuery 作者 John Resig 曾写过一个继承的实现,在那个还在 ES3 语法的时代已经非常难得。在今天看来,这种实现方式的语义已经远远落后,同时也不能实现静态变量。


下面我们来一步一步地去理解 babel 的实现方式。

首先定义两个类(函数),以及子类的实例:

1
2
3
4
function A() {}
function B() {}

var b = new B();

想实现 B 继承于 A,那边必然 b instanceof B 为真,根据 instanceof 的意义,一定有:

1
b.__proto__.__proto__... === A.prototype

我们知道:

1
b.__proto__ === B.prototype

那么我们只需要:

1
B.prototype.__proto__... === A.prototype

于是我们覆写 B.prototype

1
B.prototype = Object.create(A.prototype)

这样一定有 A.prototype.isPrototypeOf(B.prototype) 为真。

现在我们已经实现了:

1
b instanceof A === true

也就是说第一条我们已经实现了,下面看第二条,只需要:

1
B.prototype.constructor = B;

或者更优雅一些:

1
2
3
4
5
6
7
8
B.prototype = Object.create(A.prototype, {
constructor: {
value: B,
enumerable: false,
writable: true,
configurable: true
}
});

现在我们要实现静态成员的继承:

1
Object.setPrototypeOf(B, A);

目前为止,我们定义的是两个空类,现在我们为他们增加 name 属性:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
function A() {}
function B() {}

A.prototype = Object.create(null, {
constructor: {
value: A,
enumerable: false,
writable: true,
configurable: true
},
name: {
get: function() {
return 'A';
},
enumerable: true,
configurable: true
}
});

B.prototype = Object.create(A.prototype, {
constructor: {
value: B,
enumerable: false,
writable: true,
configurable: true
},
name: {
get: function() {
return 'B';
},
enumerable: true,
configurable: true
}
});

Object.setPrototypeOf(B, A);

console.log(new A().name); // => A
console.log(new B().name); // => B

现在我们要在 B 的 name 中获取父类的 name 属性,那么我们就需要找到 B.prototype 中的 name。

在 B 环境中,灵活的做法是通过 B.prototype 找到 A.prototype,根据 *B.prototype = Object.create(A.prototype)*,我们知道:

1
A.prototype === Object.getPrototypeOf(B.prototype)

于是:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
function A() {}
function B() {}

A.prototype = Object.create(null, {
constructor: {
value: A,
enumerable: false,
writable: true,
configurable: true
},
name: {
get: function() {
return 'A';
},
enumerable: true,
configurable: true
}
});

B.prototype = Object.create(A.prototype, {
constructor: {
value: B,
enumerable: false,
writable: true,
configurable: true
},
name: {
get: function() {
var sup = Object.getPrototypeOf(B.prototype);
var desc = Object.getOwnPropertyDescriptor(sup, 'name');
return desc.get.call(this) + 'B';
},
enumerable: true,
configurable: true
}
});

Object.setPrototypeOf(B, A);

console.log(new A().name); // => A
console.log(new B().name); // => AB

这就实现了 super.name 的效果。当然,我们假设 B 的父类是定义了 name 的,如果没有找到 name,那应该沿着原型链继续向上寻找。因此更健壮的写法是:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
function A() {}
function B() {}

A.prototype = Object.create(null, {
constructor: {
value: A,
enumerable: false,
writable: true,
configurable: true
},
name: {
get: function() {
return 'A';
},
enumerable: true,
configurable: true
}
});

B.prototype = Object.create(A.prototype, {
constructor: {
value: B,
enumerable: false,
writable: true,
configurable: true
},
name: {
get: function() {
var sup = B.prototype, desc;
do {
sup = Object.getPrototypeOf(sup);
if (!sup) {
break;
}
desc = Object.getOwnPropertyDescriptor(sup, 'name');
} while(!desc);

return (desc ? desc.get.call(this) : undefined) + 'B';
},
enumerable: true,
configurable: true
}
});

Object.setPrototypeOf(B, A);

console.log(new A().name); // => A
console.log(new B().name); // => AB

这里没有考虑 name 不是 getter 的情形,不过原理类似,不再冗述。

最后我们来实现在构造方法中调用 super()。显然,实质是在 B 函数中找到函数 A,根据 *Object.setPrototypeOf(B, A)*,我们有:

1
2
3
function B() {
Object.getPrototypeOf(B).call(this)
}

我们为 A 添加一个 age 构造参数:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
function A(age) {
this.age = age;
}
function B(age) {
Object.getPrototypeOf(B).call(this, age);
}

A.prototype = Object.create(null, {
constructor: {
value: A,
enumerable: false,
writable: true,
configurable: true
},
name: {
get: function() {
return 'A';
},
enumerable: true,
configurable: true
}
});

B.prototype = Object.create(A.prototype, {
constructor: {
value: B,
enumerable: false,
writable: true,
configurable: true
},
name: {
get: function() {
var sup = B.prototype, desc;
do {
sup = Object.getPrototypeOf(sup);
if (!sup) {
break;
}
desc = Object.getOwnPropertyDescriptor(sup, 'name');
} while(!desc);

return (desc ? desc.get.call(this) : undefined) + 'B';
},
enumerable: true,
configurable: true
}
});

Object.setPrototypeOf(B, A);

console.log(new B(18).age); // => 18

于是,一个基本的继承的手写版本就实现了,当然,babel 还考虑了更多细节,比如构造方法有返回值的情况等等,但基本的继承原理就是这个样子。

下图很好地反应了诸多对象的关系。值得一提的是, b 也可能成为下一级类的 prototype,你可以清晰地看到通过 __proto__ 搜索原型链的轨迹。

js-inherit


在现代浏览器的不断迭代下,已经有大部分版本都实现了对 class 关键字和继承的原生支持,相信在不久的将来,就无需在这么麻烦地实现继承了。无论如何,JavaScript 继承的原理仍然是不变的,上面这些内容有助于理解原型链是怎么样工作的。

开头一个问题是:什么是 Plain Object

并没有看到有官方去专门定义它,更可能它只不过是业界的一种通俗叫法,因此也没有严格的定义。但我们在汉语环境里通常叫它“纯对象”。

业界解释:https://www.quora.com/What-is-a-plainObject-in-JavaScript

下面我们来看一下常见 Library 对 isPlainObject 函数的实现。

jQuery

jQuery 3.3 版本中的 isPlainObject 定义在这里

为便于阅读,核心代码经过整理后如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function isPlainObject(obj) {
var proto, Ctor;

// (1) null 肯定不是 Plain Object
// (2) 使用 Object.property.toString 排除部分宿主对象,比如 window、navigator、global
if (!obj || ({}).toString.call(obj) !== "[object Object]") {
return false;
}

proto = Object.getPrototypeOf(obj);

// 只有从用 {} 字面量和 new Object 构造的对象,它的原型链才是 null
if (!proto) {
return true;
}

// (1) 如果 constructor 是对象的一个自有属性,则 Ctor 为 true,函数最后返回 false
// (2) Function.prototype.toString 无法自定义,以此来判断是同一个内置函数
Ctor = ({}).hasOwnProperty.call(proto, "constructor") && proto.constructor;
return typeof Ctor === "function" && Function.prototype.toString.call(Ctor) === Function.prototype.toString.call(Object);
}

lodash

lodash 4.0.6 版本中的 isPlainObject 定义在这里

基本与 jQuery 版本相同,多了一个 Ctor instanceof Ctor 的条件,满足此条件的仅有 FunctionObject 两个函数。

1
2
3
4
5
6
7
8
9
10
11
function isPlainObject(value) {
if (!value || typeof value !== 'object' || ({}).toString.call(value) != '[object Object]' ) {
return false;
}
var proto = Object.getPrototypeOf(value);
if (proto === null) {
return true;
}
var Ctor = hasOwnProperty.call(proto, 'constructor') && proto.constructor;
return typeof Ctor == 'function' && Ctor instanceof Ctor && Function.prototype.toString.call(Ctor) === Function.prototype.toString.call(Object);
}

redux

redux 从 4.0.0 开始在测试中使用了 isPlainObject,代码在这里

它的实现比较简单。

1
2
3
4
5
6
7
8
9
10
function isPlainObject(obj) {
if (typeof obj !== 'object' || obj === null) return false

let proto = obj
while (Object.getPrototypeOf(proto) !== null) {
proto = Object.getPrototypeOf(proto)
}
// proto = null
return Object.getPrototypeOf(obj) === proto
}

我们并没有一个能判断 Plain Object 的清晰逻辑,大概能理出来的思路是:

  1. 先判断 obj 本身是否满足我们熟悉的合法对象概念;
  2. 判断 obj 的构造函数是不是 Object

至于判断 prototype 是不是 null,无非是一种 shortcut 罢了。

0%