本文整理自“莺见”微信公众号:


最近一个多月,机缘巧合地读了几本江户川乱步的侦探小说,得知包括柯南·道尔在内的这些推理作家,都或多或少地受到了美国作家爱伦·坡的影响。带着好奇的心情,从网上花了9块钱淘了一本《爱伦·坡短篇小说集》。

这一本252页的书包含了11篇小故事:

  1. 《红死魔的假面具》
  2. 《厄舍府的倒塌》
  3. 《一桶蒙特亚白葡萄酒》
  4. 《泄密的心》
  5. 《威廉·威尔逊》
  6. 《黑猫》
  7. 《莫格街凶杀案》
  8. 《失窃的信》
  9. 《金甲虫》
  10. 《瓶中手稿》
  11. 《乌鸦》

我阅读过后的感受,怎么说呢,非常困惑,从两个方面来讲。

文字晦涩,环境和心理刻画篇幅过多

首先是感觉文字表述的方式非常不符合中国人的口味,过于啰嗦,典型的“翻译腔”。当然,中国人对于这种来自欧美的文学有这样的感受是很正常的,记得中学时候读福尔摩斯也是如此。

不过爱伦·坡作品的这种感受更强烈一些,除了不同语言带来的表述鸿沟之外,还要“怪罪”于他对环境氛围心理活动的刻画实在过于细节,最终表现就是用了相当大的篇幅来做铺垫。最典型的莫过于《威廉·威尔逊》,在开始正式叙事之前竟然用了7页的内容做人物、心理和环境介绍,故事还未开始,恐怕心急的读者已经不耐烦了。

这对于先入为主地以侦探、推理小说的预期来阅读的我就会感到非常困惑,特别是在有噪音干扰的环境中,很难有心思能揣摩作者要表达的意境。这种困惑在几乎所有11篇故事中都有体现。

来看《红死魔的假面具》对室内环境氛围的一段表述:

“这里的玻璃窗是暗红色的——深暗的血液的颜色。这七间屋子里,没有一盏灯,也没有烛台,只有耀目的金器散布在各处,或者从天花板上垂吊下来。但在围绕着的回廊上,每一扇窗户对面都摆放着一只沉重的三角支架,三角支架上都放着一盆火,火光透过彩色玻璃,把房间照得通亮,于是就产生了大量奇形怪状、魑魅魍魉似的景象”

有没有惊悚的感觉?再来看《泄密的心》中对心理活动的一段表述:

“那个念头是如何钻进我的脑子的呢?现在已经很难说清这个问题了。可是,自从它钻入我的脑子,就日夜萦绕着我。那位老人对我十分友善,它一直以公允的态度对待我,也从未侮辱过我。对于他,我心存爱意,从没想过利用他,也没想过要伤害他,更不想得到他的钱财,”

是不是够婆婆妈妈?带着这种困惑我去查阅了爱伦·坡本人的经历以及身后评价,原来他的作品有两个特点:哥特风与心理刻画

哥特风

哥特(Goths)原本是古代欧洲的一支民族,逐渐发展成了野蛮的代名词,与惊悚、恐怖、阴暗联系在了一起,在建筑等多种领域都有体现。文学上的哥特式必然要用一些文字来做阴森的氛围渲染。我想中国人对于哥特文化最熟悉的莫过于吸血鬼了,一些经典的吸血鬼影视作品,比如《黑夜传说》,画面格调一直都是灰暗的,故事的场景也是下水道、城堡等场景。

哥特的恐怖元素对于中国人来说,应该还是相对陌生的。我会觉得它确实很惊悚,但是仅仅是视觉上的,还远远达到不了直击心灵的效果。它表现出的恐怖往往是活人的无耻、粗鲁、暴力、阴郁、变态所带来的。而东亚文化中更恐怖的恐怕应该是像《山村老尸》、《咒怨》、《连体阴》等死人带来的复仇。后者则更能反映因果论,前者更现实,在因果关系上的表现则更需要发狂的活人来表达。

在我读的这一本书中,《红死魔的假面具》、《一桶蒙特亚白葡萄酒》、《厄舍府的倒塌》、《泄密的心》、《威廉·威尔逊》应该都是哥特元素足够丰富的恐怖小说。

《红死魔的假面具》讲了一个这样一个故事,在传染性的红死病流行期间,一位富人在庄园中大肆组织狂欢会,结果被“红死魔”团灭。故事非常简单,大量篇幅都在描述环境,比如每一间房子的奇幻装修风格,以及疯狂的聚会者如何作乐。对于“红死魔”,爱伦·坡用僵尸来描述他的肤色。这样一个猎奇的、恐怖的的场景就出现在我们面前。至于最后故事的戛然而止,作为叙事故事肯定是让人失望的,但要记住,这仅仅就是一部恐怖小说。

《红死魔的假面具》插画,作者:Harry Clarke

《一桶蒙特亚白葡萄酒》简单来说就是一个人把他的“仇人”骗到地窖中困其致死的故事。显然,故事情节并不复杂,也不是重点,重点是爱伦·坡在篇幅中用大量的文字来描述了两方面内容:一是阴森的地窖环境,二是主人公的心理变化。中国人应该很难想象在自家房子的地下埋藏着先人遗骸这件事,不过西方应该并不陌生,英国王室成员就是集体埋葬在一座大教堂下面的。

《一桶蒙特亚白葡萄酒》插画,作者:Harry Clarke

类似的情节在本书的《厄舍府的倒塌》一篇也有。它讲的故事是在阴暗的城堡内,主人公和城堡主人将其妹妹活埋的故事,城堡主人的阴郁给人留下了深刻印象。

《厄舍府的倒塌》插画,作者:Harry Clarke

回到《一桶蒙特亚白葡萄酒》,爱伦·坡描述整个地窖的墙壁镶嵌着许多人类的遗骸,两位角色提着灯在里面走了很远,想想就足够惊悚。主人公一边骗对方喝酒,一边向前走,同时描述着主人公心理的所思所想,直到最后他疯狂地用砌墙的方式将对方封死在地窖内。网上看到一篇论文对此的描述是主人公借着复仇的名义发泄着自己的罪恶意念。从这里可以看到,爱伦·坡在恐怖小说中对人物心理的刻画是十分丰满的。

心理刻画

事实上,这些小说中爱伦·坡对心理活动的描写所用的篇幅要远比对环境氛围的描写所用篇幅更大。

《黑猫》、《泄密的心》、《威廉·威尔逊》等都具有非常类似的、鲜明的心理刻画特征,甚至主人公开始表现出癫狂和多重人格的特质。

《黑猫》以第一人称的方式来讲述了主人公病态的心理变化。黑猫原本是主人公的一只温顺的宠物,主人公却因为心理的莫名变化,挖走了它的一只眼睛,最后把它给吊死了。主人公家里火灾过后,黑猫吊死的印记出现在了墙上,描绘出了一幅回魂复仇的画面。而后主人公又收养了一只来历不明的黑猫,却在第二天也丢失了一只眼睛,这给故事增加了相当大的魔幻氛围。最后主人公因为又想杀死这只猫,却失手杀死了妻子,为了避免东窗事发,他只好把尸体藏在了墙里面,在警察盘问时还能做到镇定自如。谁知那只黑猫也被意外地藏在了墙里面,它的叫声暴露了一切。

《黑猫》插画,作者:Harry Clarke

《泄密的心》表现出的人物心理则更具不可理解。一位老人本没有任何过错,对待主人公也很好,仅仅是因为主人公不喜欢他的眼睛,就在深夜谋害了他,并把他藏在了屋子的地板下。结果等警察来询问时,主人公却发疯似的不打自招。读到这里可能会有莫名其妙的感受,但这正反应了人物扭曲、变态的心理。如果说需要一种熟悉的解释的话,那可能就是作恶终归过不了自己那一关。

《泄密的心》插画,作者:Harry Clarke

《威廉·威尔逊》更上一层楼,讲来讲去甚至就只有主人公一位人物,他的所谓这位同名同姓同日生的同学,实际上就是他自己,不用读到最后就能猜得到。故事表达了这位主人公极具分裂的多重人格,杀死对方的同时,也将自己杀死。

《威廉·威尔逊》插画,作者:Harry Clarke

如此复杂狂乱的心境描写,可能是爱伦·坡本人经历的影射。他自小没有父母陪伴,长大后又因玩忽职守被军队开除,后来妻子感染肺结核死去,他酗酒,在创作道路上的努力也都没有结果,这一些经历恐怕对于任何人都是一种恐怖的压抑。他会有挫败感吗?我们不得而知,有才是正常的。大胆猜测这样的经历已经影响了他的精神状态。

因此,把爱伦·坡简单地当作一位侦探、推理小说家,显然是过于片面了。事实上,他一生中的推理小说作品只有四、五部,但依然被认可为推理小说的鼻祖。我就从我阅读的几篇来感受一下爱伦·坡在推理小说上的开创性工作。

推理

在本书中,《莫格街凶杀案》、《失窃的信》、《金甲虫》毫无疑问是最具推理特色的,情节节奏也更快,相对来说少了很多铺垫语言。而前两者更是引出了著名的侦探角色杜宾。从这一点上来说,我猜测柯南·道尔的福尔摩斯和华生这一对搭档角色,灵感就来自于此,不知是否正确

《莫格街凶杀案》公认是世界上首部推理小说,并带来了推理情节的经典元素——密室。不过我阅读后觉得还是有两点可惜。

《莫格街凶杀案》插画,作者:Harry Clarke

一是破案的关键点,即窗户的插销在之前并没有足够的铺垫,直接来到揭秘环节过于突兀。你没有给读者看,你怎么知道读者就没有能力发现呢?这也是侦探元素在书籍这种载体上的尴尬之处,如果有文字描述了,几乎相当于给你缩小了解谜的范围,如果不描述,就是上面这种突兀的感受。

二是铺垫了这样离奇的犯罪现场,最终案犯竟然是一只猩猩,大大降低了读者的感官刺激。相当于现在的发现了麦田怪圈,最后结果就是外星人搞的事情。我突然想起了10年前看过的美剧《迷失》,前期铺垫了大量未解之谜,最后圆不回去了,连时空穿越都搞出来了,类似的还有美剧《危机边缘》。

《失窃的信》中,侦探杜宾尝试完全从对方的角度来思考,是一大特色,并且得到的结论就是那封信就放在显而易见的位置,只不过按照常规思维根本不应该存在的位置,形象地描绘了一种最危险的地方就是最安全的地方的画面,用古话说,叫做大隐隐于市。不过虽然这篇小说广受赞誉,但是以现代人读取的感受来说,太过突兀和儿戏,第一是同样的问题,那封显而易见的信一开始并没有且也没办法给读者交代;第二是这位家族没落的杜宾先生,竟然能随意拜访大臣。不过这都不重要了,毕竟这部小说发表在将近200年前,以当时的文学环境来说,这样的一篇故事应该是相当吸引人的。

《金甲虫》是我认为的书中最具精彩的一个故事,我指的并非其中对密码的解谜(虽然我也认为在当时是相当新奇的了),而是其中对羊皮纸如何出现骷髅的解释。因为这张羊皮纸在前面已经有明显的交代,而且给出了二人在此的争议的画面。读者从这里很难能猜到是因为隐形墨水的原因,导致的后续威廉·勒格朗一系列怪异行为。

《金甲虫》插画,作者:Harry Clarke

除此之外,我读的这本书中还有两篇故事,《瓶中手稿》和《乌鸦》,它们和《红死魔的假面具》共同导致了我的另一处困惑。

故事情节架空,莫名其妙

从今天的视角很难理解爱伦·坡当时的创作意境。《红死魔的假面具》就是这样的一篇,一群人的狂欢会,怎么就有一个怪人突然杀了所有人。

不可否认地这篇小说有相当多的对人性的讽刺,但是红死病到底是什么呢,未免太多让人好奇。网上有资料说它指的是肺结核病或者霍乱,它们在当时都很常见。显然前者更可信一些:

  1. 《红死魔的假面具》发表于1842年,这一年爱伦·坡的妻子开始感染肺结核,并在5年后去世;
  2. 肺结核是传染病,症状会发展为咳血

爱伦·坡当时可能正是基于这样的担忧,才写出了这篇小说,可能也代表着一种宿命感。

所以说,历史人物的行为都不能离开当时的社会环境来空谈,抛开爱伦·坡的生平来阅读这篇小说,必然不能感同身受。

起码《红死魔的假面具》还能了解到其创作背景,但是《瓶中手稿》呢?这篇小说则是一篇来源于传说(幽灵船和地底空洞)的魔幻创作,对于当时的读者来说,猎奇的感受更多一些,不过对于信息爆炸的今天,新鲜感骤减。不过还是那句话,要看历史环境,况且,这只是爱伦·坡为了参加比赛的投稿作品。

《瓶中手稿》插画,作者:Harry Clarke

最后是《乌鸦》,这是最让我困惑甚至恼怒的一篇,完全不知道它在讲些什么,这样都能成为知名作品,恐怕要求也太低了。为此我不得不去查询这篇文章的背景资料——噢,原来这是一篇诗,爱伦·坡也是一位诗人。

这里我就不对诗加以评论了,毕竟我对现代诗嗤之以鼻,况且,翻译也失去了原作的韵味。

总结

毫无疑问,爱伦·坡是一位复杂的作家,虽然被称为推理小说的鼻祖,影响了柯南·道尔、江户川乱步、希区柯克等一系列大作家,但我们却不能以推理作家来称呼他。

他的作品在氛围风格和心理刻画上的表现,也许是他40年命途多舛生命中的流露。这一生中,他少年颠沛流离,中年亡妻、酗酒,在写作职业上的努力均付之东流。上面的这些作品,一篇最多为他带来几十美元的收入。在这样的逆境之下,《黑猫》、《泄密的心》、《威廉·威尔逊》中主人公的心理活动何尝不是爱伦·坡本人心境的反应。

我觉得他和梵高的境遇有一些类似,都是生前默默无闻,死后方才发现其才华。当然,梵高也是爱伦·坡的后辈,他出生时是1851年,而爱伦·坡死于1849年,以当时的卫生条件,加上他的不良习惯,这并不意外。

总之,以读故事的猎奇心态读爱伦·坡的作品,必然是一种浪费。你需要回到200年前那个时代,切身感受爱伦·坡的成长历程和际遇,才能读懂他的作品。

(文中插画来自于wikiart.org)

一、前言

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 继承的原理仍然是不变的,上面这些内容有助于理解原型链是怎么样工作的。

0%