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


不久前,因为一个短视频的原因,忽然对江户川乱步的这篇《人间椅子》有很大的兴趣,便从网上买了一本来阅读。

看完过后的感受是果然现在的短视频平台都是标题党,这篇小说肯定没有吓死人那么夸张,但由于我买的这本新星出版社的书是一本小说集(这是必然的,毕竟《人间椅子》只是一篇短篇小说),我有机会阅读到了更多江户川乱步的作品,进而接触到了日本推理小说的早期形式,特别是本格推理。

这本书的目录如下:

  1. 《人间椅子》
  2. 《接吻》
  3. 《跳舞的一寸法师》
  4. 《毒草》
  5. 《蒙面的舞者》
  6. 《飞灰四起》
  7. 《火星运河》
  8. 《花押字》
  9. 《阿势登场》
  10. 《非人之恋》
  11. 《镜地狱》
  12. 《旋转木马》
  13. 《烟虫》
  14. 《带着贴画旅行的人》
  15. 《目罗博士不可思议的犯罪》

这些作品各有特色,大部分带有悬疑元素,但并非都有让读者意外的结局。甚至其中一部分还带有开放性的结尾,引人深思,读完仍能回味无穷。

故事梗概

人间椅子

《人间椅子》确实是一个不错的故事,反转设计得很惊艳,而且是两次反转。可惜的是,作为今天被各种悬疑电影、短视频信息污染的我,在阅读的中途就猜出来后续的故事走向。

为什么我能猜到有人藏在椅子当中呢?第一是我可能把小说名记成了“人体椅子”,第二是看过一部来自国外的叫做《stuck》的短视频,讲的是一个男人藏在体操垫子里偷窥女运动员的故事。

但我应该还看过和《人间椅子》剧情更接近的故事但细节已经想不起来了。总之,各种因素的影响之下,这篇小说对于我的惊奇程度并不高,但是我相信在 100 年前的那个时代,读者一定有非常不一样的感受。

值得一提的是,椅子中藏人这种桥段,在乱步的多部作品中都有使用,比如《黑蜥蜴》就不下一处。

除了带有一些惊悚和悬疑元素之外,我们还能看到《人间椅子》中的主人公,即制作这把椅子的工匠,虽然拥有了一份能自食其力的工作,但毕竟人生受困于经济条件和阶层,对有钱人的生活充满好奇以至于最终走上偷窥这条路的心理,多少带有一定的变态。

这种变态是小人物的无奈,是不同阶层的矛盾。在现今的小说和影视剧中,有许多这种情节,让人看着既心酸又可气。越处于社会下层的读者越会觉得感同身受:“凭什么他们那么有钱人,而我只能每日辛辛苦苦工作才能勉强吃饱饭”,“如果有机会,我不惜冒险也要去获取富人的财物!”。而社会上层的人对此类小人物必然是嗤之以鼻的,“你算什么东西,敢觊觎我的财物和生活环境”。

这种矛盾是必然的,社会的分层在目前的人类社会发展阶段是无法短时间内消除,贫富的加大虽然有一定的客观因素,但是却容易被放大到完全忽视个人的主观条件,引起一些人在心理上的扭曲。

回到《人间椅子》这部作品本身,乱步在最后给出了两级反转,工匠先说他就是那个窥视女主人的藏在椅子里的人,而后又说他这只是一部他自己的文学创作。孰真孰假?显然,这是不可能在真实世界上发生的,只不过留给读者一个回味的元素。在故事里同时也让女主人真假难辨,即便只是工匠的一部文学创作,想必她也会把那个椅子扔出家门吧。

接吻

这一部小说的绝大部分篇幅都是从男主人公山名宗三的角度描述他的心理活动。故事以他在偶然间的一次下班回家后发现妻子阿花捧着一幅照片亲吻开头,他发现那幅照片是他的上司,同时也是阿花远方亲戚的村山。因此,新婚后本来是“乐得手足舞蹈”的他越想越气,最后甚至直接向上司村山发火,怒而离职,回到家和妻子摊牌。

妻子知道事情缘由之后恍然大悟,立即解释了这一切,原来是宗三看到阿花亲吻照片的时候,碰巧是通过一面镜子看到的,导致他后来去寻找照片的时候,翻错了抽屉,看到了村山的照片。事实上,阿花是在另一个抽屉面前亲吻的是宗三本人的照片。由于这一次的误会,使得宗三丢掉了公务员的职务,对家庭的经济造成了无可挽回的影响。

这是一部典型的解谜故事,最后的解释也算逻辑通顺,因此我觉得可以算是本格派的作品。但是《接吻》的一大亮点在于,乱步在揭示谜底之后,竟然“打破第四面墙”,有一段和读者直接对话的文字。

这段文字在今天看来自然有一些争议的描述,比如:

“反倒是女人表面上好像傻得一问三不知,心底里其实都盘踞着天生的狡诈”。

不过这并不重要,重要的是乱步给出了这个故事的另一个可能的真相,这种开放的结局在乱步的作品中多次用到(比如《盗难》中给出了不止两种)。如果有画面的话,只需要阿花嘴角的一次邪笑,就能让人无限联想,这在现代的悬疑类型的影视作品中几乎是标配的元素,为下一部的故事埋下伏笔。

跳舞的一寸法师

这部小说讲述的是一个“逼人太甚”的恐怖故事。一寸法师是马戏团里的侏儒角色,在下班之余总会受到团内其他成员的无情戏弄,苦不堪言。

在一次戏弄过后,大家起哄让一寸法师表现节目,他就和踩球美女阿花(话说阿花真是乱步的一个常用人名,就如同希区柯克的玛丽一样)一起表现斩首魔术。

斩首魔术自然就是将美女置于箱中,将几把大刀插入箱内,营造出一种必然插入箱中美女身体的假象,而后甚至将美女头体分离。

不过一寸法师借着酒劲,假戏真做,真的将阿花杀死,并将其斩首。当阿花被刀刺发出尖叫时,周围的人还以为她在表演,而无任何察觉。

显然,一寸法师长期受到同事的压迫戏弄,忍无可忍,才做出了如此行径。我们虽然不能赞成其做法,但必然能深深理解其心理上的痛楚。

单纯作为一篇悬疑反转作品来看,此类意外并不少见,我在阅读到一寸法师用刀刺阿花的情节时,就已经能猜测到了结局,少了不少乐趣,不过这是由于我先入为主地以悬疑小说的角度来阅读所造成的,这种阅读心理,在遇到有推动情节的关键行为时,就会基于经验来极力搜索所有可能性。

毒草

这是一篇只有五千字的短文,情节倒是很紧凑,讲述的仍然是关于底层苦难人物的故事。

“我”与友人在野外散步遇到一株具有堕胎功效的草药,不经意将其用法讲了出来,未料到被身后不远处的一个女人听见。

这个女人是“我”的邻居——邮差家的妻子。邮差50多岁,收入十分微薄,却顿顿要喝酒,最重要的是家中还有6个孩子,有的还有病。这一切都压得女人喘不过气来,而她现在又有了身孕。

毫无意外地,草药能堕胎的消息不胫而走,不仅邮差妻流产了,连带着的还有另外两家女人。因此可以说,那一天“我”和友人的谈话间接导致了3条生命的流逝,那一株草药也被薅“秃”了。

我们在这里要看到的是底层人物的不幸,至于“我”的行为是对是错,难以分辨,虽然对3条无辜生命是不公的,但对家庭来说,未尝是坏事。

不景气的经济是乱步作品中很多故事、很多小人物的背景,这也是乱步早期作品的写作时局,当时正值一战之后,东京遭遇大地震,又逢经济危机,造成很多人陷入失业窘境。我们将在后面的作品中看到更多同一时期的故事。

蒙面的舞者

这是一篇十分荒唐的故事,主要元素仍然是悬疑小说中常用的误解、巧合。

“我”被邀请参加一个非常私密的俱乐部,该俱乐部面向上流阶层来定期组织刺激性的活动,会员17人,轻易不扩充会员。只有一名会员退出的时候,才会邀请新成员加入。

这一天,会长来“我”家做客,邀请“我”参加他组织的一项俱乐部新活动,即蒙面舞会。17位会员,外加17位不知名的女士一同参与。

容易让人想歪,虽然剧情还没如此不堪,但也称得上是恶作剧了。在舞会上,34人组成17对男女,在舞池中跳舞。等到活动结束,“我”烂醉如泥,摘下了面罩,却吓得女方落荒而逃,留下一封信,“我”第二天才看到。

原来女方是另一名会员井上次郎的妻子!“我”开始怪罪会长竟然安排如此污秽不堪的活动,但后来会长的说辞是——每对男女其实是夫妻而已:

“以为是宝箱,打开一看,竟是旧货。”

但“我”显然搞错了,竟然和井上次郎互换了女伴而不自知,全都是因为“我”看错了号码,把7看成1,导致了如此荒诞的结果。

在无聊生活中寻求刺激,这种元素在乱步作品中多次出现,大部分自然是有钱人,穷人都在为生活奔波,哪有时间无聊呢。印象最深的当属《红色房间》,这是一个更离奇的故事,后面再聊。

飞灰四起

《飞灰四起》讲述了一个杀人犯自以为是的故事。庄太郎是一名画家,与奥村一郎是情敌,但却要靠后者接济生活。在一次“借”钱不成之后,庄太郎拿起手枪杀死了奥村一郎,然后逃走。

庄太郎害怕杀人的事实被发现,因而想出来一个嫁祸他人的主意。他找到奥村一郎的弟弟奥村二郎,并从奥村一郎房间的火盆中找到一个脏兮兮的球。他引导奥村二郎认为是自己打球时,球飞入二楼的屋子中,刚好击中奥村一郎拿着枪的手,引起走火,而后飞入地上的火盆灰之中。庄太郎希望奥村二郎将这样的推论告诉警察,从而给案件定下意外的结论,他就能逃过一劫。

没想到很快奥村二郎却带着警察找到并逮捕了庄太郎,原来奥村二郎反应了过来,那个火盆是他和庄太郎谈话当天才搬进来的,怎么会装有奥村一郎被杀当天的球呢?而且,警察找到了卖给庄太郎球的商店老板,庄太郎买到球之后做旧,故意放在了火盆之中。

在间接证据之下,庄太郎认了罪。原来这只不过是他的一场拙劣的推理表演。

这个故事带有显著的逻辑推理元素,不过和一般的在开始把疑点抛给读者的故事不同,《飞灰四起》在开始就把真相叙述了出来,并和读者一起构造出了不存在的真相,最后再迅速推翻它。

我觉得故事中反而对人物的刻画倒是很逼真,比如庄太郎杀人后瘫痪式的体态表现和断片式的思维,真实地表现出一名普通人在如此遭遇后的反应。

如果要挑刺儿的话,一名落魄的画家,哪里来的能力在短时间内构造一个逻辑推理的故事呢?当然,结果也反映了他的“不专业”。

火星运河

这部讲述的是一个男人的奇怪梦境,倾向于抒情,词藻比较华丽,但基本不属于推理悬疑范围,不做评论。

花押字

这又是一篇带有浓重误解、巧合元素的故事,两级反转,还算是比较有意思,结局令人唏嘘。

老门卫栗原一造讲述他一段年轻时的故事。在30岁左右的时候,因为失业(“不管干哪一行,都撑不了一年”,简直是乱步在描述自己,同样背景在经济不景气时期),整天在公园的长椅上久坐。

这一日,他在公园邂逅了来东京找工作的田中三良。田中认为自己见过栗原,二人相互述说了自己的过往经历,发现并没有重合。几日后,栗原造访田中,田中想起了为何他觉得见过栗原。

原来,田中有一个他姐姐北川澄子过世后留下的盒子,里面的随身镜后面藏着一张照片,照片里正是学生时代的栗原一造。田中常常拿出照片,怀念姐姐,所以误以为认识栗原。

因此,一个可能的事实上,北川澄子曾经倾心于栗原一造,但栗原原本认为北川过于高冷,不容易接近,最后娶了北川的同学阿园。北川可能因而抑郁而死。

得知此事,栗原一造自然感到造物弄人,他和田中要到了哪个盒子和随身镜,常常睹物思人,甚至想去北川的墓碑前,无奈路费不足。

谁知,这一天,这件遗物被妻子阿园发现了,不想从她口中得知了一个令人震惊的事实。原来这个盒子原本就属于阿园的,甚至她在上面刻下了有她和栗原名字首字母的押字,但是在一次修学旅行中丢失了。

这样看来,阿园和北川具有同样首字母造成了一些误解,甚至栗原倾慕的北川还可能是个小偷!

多么可笑的故事啊,利用死者遗物造成误解的故事,乱步还有其它作品,比如《戒指》。从真相上来看,是不是栗原要对妻子阿园更好一点才对啊,毕竟她在十几年前学生时代就倾心于栗原,否则也不会把他们俩的名字刻在盒子里。

阿势登场

这是一个类似《水浒传》中武大郎的故事。格太郎是个时日无多的病人,妻子阿势常常出门与姘头鬼混。但是格太郎为了孩子,仍然与其维持表面上的和平。

这一天,阿势打扮停当,又出门去了。格太郎吃过饭,儿子正一带着小伙伴来家里玩。格太郎给孩子们讲了几则故事之后,便和他们玩起了捉迷藏。

格太郎将自己藏在了母亲出嫁时的旧大衣箱中,结果孩子们没能找到他,都出去了。等到他自己想出来时,才发现刚巧不巧,箱子的金属扣子意外锁住了!

格太郎呼喊,但是没人听得见他。他想挣脱,但是箱子很结实,他又患病,根本没有力气,只能疯狂抓挠。慢慢地,箱子里的空气开始耗尽。

刚好,阿势回来了,她隐约听见了格太郎的动静,刚将锁扣打开要把箱子盖揭起,忽然冒出了一个恶毒的念头。

她又迅速将盖子压紧,扣上锁扣,并离开房间,任凭格太郎癫狂地抓挠呼喊。

最后,格太郎死亡,警察认定这是一场意外。等尸体搬走,阿势与格太郎的弟弟格二郎都看到了箱子里有格太郎的抓痕,隐约可辨认是“阿势”二字。

这里,乱步给出了两种解读,一种是格太郎死前还惦记着阿势,这也是格二郎所认为的。而我们知道,应该是第二种,其实是对阿势的控诉和诅咒。

阿势获得了不菲的遗产,将房子卖掉,带着儿子离开了。她也卖掉了那个旧大衣箱子——如此不祥之物。

最后,乱步引导读者进行了一次天马行空的设想,如果有人买下了那个大衣箱子,他会理解上面抓痕的意义吗?留在箱中的恶念,会给新主人带来什么呢?

我立刻就能想起看过的无数恐怖片,依据一个大衣箱这个旧物件,《阿势登场》刚好可以作为故事的前传。

这个故事的特殊之处显然在于“恶人”阿势没有最终得到惩罚,这里有一个发人深思的问题。作恶和不作为,前者真的比后者更严重吗?阿势听到丈夫的呼喊不去救,与救到半路又反悔,哪一个更为人所痛恨呢?

即便不去作恶,如果仅仅是作出看似无关痛痒的轻微合法行为,但是却引起了严重意外后果,这又和上述两种行为如何对比呢?

将来我们还是看看《红色房间》的恐怖假设吧。

非人之恋

这篇故事剧情比较简单。以一名女子(京子,即“我”)对10年前的回忆开头。

当年十九岁的“我”通过媒人介绍嫁给了世家望族的丈夫门野。在出嫁之家,就有传言说门野性情古怪,脾气不好,并且还厌恶女人。不过门野清秀帅气的外貌和忧郁的气质还是吸引了“我”。

婚礼之后,门野对“我”呵护有加,让“我”悬着的心多少落地了。但是这样美好的时光不过半年,“我”便发现了古怪。

先是丈夫的态度发生转变,不再像之前那样关切“我”。而后,“我”发现他常常在“我”睡熟之后,偷偷起床,奔屋后的土仓库二楼而去。

土仓库二楼本来只装了一切祖先的旧物,一开始“我”并没有在意,但随着丈夫一次又一次如此,甚至还刻意探查“我”是否睡熟的鼻息后才离开,更加让人对真相欲罢不能。

为了搞清楚,在一次丈夫出门后,“我”悄悄地跟了上去,在黑暗中爬上了土仓库二楼,在门外听到了可怕的动静。

屋内有男女交谈的声音,男声自然就是丈夫门野。这声音自然让“我”认定是丈夫金屋藏娇,顿感悲伤。等门野从屋里出来,“我”想继续等待,想看看到底是哪位女子,但却一直没有人从屋里出来。

三番两次的监听之后,女子再也坐不住了,她几次来到土仓库内部搜寻,想找到有其他女子存在的证据,但是翻来翻去,找到一具精致的人偶。难道丈夫每晚都在和这具人偶说话吗?那女声来自哪里?

人偶在日本民间具有不一样的意义,历史上存在许多相关的传说,比如照着某人形象扎的人偶,生死就会同步到其人身上,如同巫毒娃娃一样,在中国的很多宫廷故事中也有类似的记载。还存在一种职业,叫做人偶师,专门制作精良的逼真的人偶,也算是一种文化,在维基百科的词条“日本人形”可以查阅到更多信息。

一种日本人偶(图片来自维基百科)

不过《非人之恋》中的人偶,与其说拥有摄人心魄的神秘力量,倒不如说是门野迷恋人偶的奇异心理作祟。有这种心理的人物和故事有很多,乱步在《乱影城主》中就有他收集到的多个奇异故事。

说回故事,最后“我”一怒之下砸碎了这具人偶,等到丈夫发现时,悲痛欲绝,竟然选择自杀与之殉情,可见其迷恋人偶之深。

这种小说中的悬疑色彩并不浓烈,不过从阅读体验上来看,乱步描述“我”心理活动的文字能够一步一步引导读者读下去,比如以回忆作为故事开头的方式,为读者埋下了疑惑的种子。接下来,又以门野的传闻作为引子,将读者一步步带入故事情节中,逐步渲染出关键疑点(土仓库),最后突然揭开谜底。

无论怎样总结,都不如阅读原文的体验。我越来越觉得,即便对于重在叙事的悬疑小说,如果没有丰富词藻的陪衬,效果也会大打折扣,成为像希区柯克那样的流水账。

镜地狱

这一篇讲述的是一个离奇的故事,却没有多少悬疑成分在内。

故事讲述者(即“我”)的一位朋友,家境殷实,有着一种异于常人的爱好——镜子,包括各种透镜。他喜欢用望远镜观察远处的人,监视仆人的一举一动;也会用显微镜观察细小的如昆虫般的事物。

他迷恋镜子越来越深,就像打开了一扇通往异世界的大门。他消耗父母留下的遗产,在家中建立起一座实验室,整日忙于其中,其痴迷程度甚至开始影响他的健康。

他不断收集各种各样的镜子、透镜,以至于最后在家里搭建起一座玻璃工厂,以制造在市面上买不到的形状。

有一日,“我”忽然被叫去查看这位朋友的境况,似乎出了莫大的问题。到达他的实验室之后,发现里面有一个容人的玻璃球,里面发出了咯咯的疯狂笑声。急切之下,我“打破”了玻璃球,里面正是那位形同枯槁的朋友。

最后那位朋友不治而亡,至于他为何最终癫狂,不知为何,可能是他在这个球形镜子内部看到了什么可惧的事物。

这种带有发狂元素的故事,读起来有点像爱伦·坡。爱伦·坡善于刻画人的心理,特别是变态的心理,发狂并不罕见。乱步的作品相比之下更现实,少有对发狂人物的塑造。

不过对于镜子这种东西,乱步的作品中很常见,这源于他本人对镜子的痴迷,他在《幻影城主》中就说到过自己对镜子的无限遐想,包括星空宇宙,他还对美国新建设的巨大口径的望远镜表现得饶有兴致。

用望远镜窥视他人的行为,乱步在多部作品中有使用,比如接下来的《带着贴画旅行的人》,以及中篇小说《阴兽》。

值得一提的事,文中曾提到这位朋友用镜子将昆虫放大数百倍,让“我”心生恐惧,这段描写应该源于乱步对蜘蛛一类生物的天生惧怕心理。

旋转木马

格二郎是一位落魄的喇叭手,在游乐场的旋转木马上日复一日地吹奏相同的乐曲。他有一位“黄脸婆”妻子和三个孩子,大的12岁,小的才3岁,糟糕的经济状况和生活环境让他每日早早地离家赶去上班。

他上班的另一个、或者说是最重要的原因还是因为同样在旋转木马上工作的年轻售票女孩阿冬。阿冬18岁,和格二郎一样穷困,姿色一般,但是毕竟豆蔻年华,仍然具有一定的吸引力。

阿冬和格二郎住在同一个方向,每日下班后同路。渐渐地,比阿冬大一轮的格二郎对她产生了好感,如果哪一日阿冬没来上班,他会觉得工作很无聊,喇叭吹得都没有气力。

一日下班路上,阿冬对街道旁商店里的一条披肩产生了兴趣,非常渴求能买下它,不过毕竟经济上不允许,她每月赚的20多圆薪资不可能去买7圆多的披肩。格二郎想替阿冬买下披肩,不过他赚的也不多,而且家里还有歇斯底里的黄脸婆和三个儿女。

在一次工作中,格二郎看到阿冬卖票时,被一位鬼鬼祟祟的男青年偷偷地塞了一封信到她的裤袋中。他觉得一定是一封情书,不免心生醋意。经过了一番心理斗争之后,在下班时,还是偷偷地把那封信从阿冬身上顺了过来。

打开一看,那不是什么情书,竟然是藏有一百圆的某人的薪水袋。原来那位青年是个扒手,在被警察盯上之后,急忙之下将赃物藏在了完全不知情的阿冬身上,被格二郎误以为是在送情书。

现在,失主和扒手都不可能来取钱,即便来,格二郎也不会承认,而阿冬则完全不知道身上曾有一笔“巨款”,考验格二郎的时候到了,他该如何处理这些钱呢?

显然,这笔意外之财能让他有能力为阿冬买下那条披肩了,想到这不禁激动万分,甚至叫上游乐场的同事开启了一场无声的狂欢……

格二郎又是一位乱步笔下的生活在那段经济低迷社会环境下的小人物,虽然年纪已长,但是仍然不得不为了生活去做那不值一提的、按部就班的老工作。年轻的阿冬成为了他生活中的一道光,吸引着他不断逃离压抑的、“歇斯底里”的家庭。而旋转木马,是格二郎的工作,同时也影射着他日复一日不断重复的无奈生活。只有依赖一笔“幸运”的外财,才能让格二郎短暂地随意所欲一次。

烟虫

这是一篇带有反战色彩的故事,在二战期间一度被禁止出版,不过它写于九·一八事变之前的1929年。

在战场上身负重伤的须永中尉,由于失去全部四肢、听觉和声带,导致他只能以眼神和外界交流(描述具体形象的文字令人感到不适)。妻子时子因为数年来“无微不至”的照顾,成为了忠烈的典型。夫妻二人目前无偿借住在须永的老领导家的别馆里,得到了老领导的一定照顾。

在须永中尉刚刚负伤时,社会各界都对以报以热烈的嘉奖,让夫妻二人感受到了短暂的荣耀。不过时间一长,大家都全部忘记了,时至今日再也没人提起须永中尉的“英勇事迹”。只有老领导每每还穷尽溢美之辞赞扬须永中尉夫妻二人。

面对如此微薄的精神施舍,听得多了,时子也开始觉得刺耳。除了拜访老领导家以外,时子无处可去,丈夫不能说话也听不见,只能靠眼神和用嘴写字与其简单交流。不过须永中尉原本就是没读过多少书的粗人,残废后更加是只能整日发呆。

与向外表现的忠烈不尽相同,私下里,时子却把已成为了一团肉的须永中尉当作玩偶一般,表现出了对似人非人怪物的极近变态的蹂躏欲望。具体细节不方便多讲,乱步也没赤裸裸地描述,只能脑补。

终于在一次发泄之后,时子突然发现须永的双眼流血,赶紧找来医生,也只能简单包扎,连医生都被这团肉吓得够呛。

果然,须永的眼睛瞎了,除了触觉之外的唯一感官被剥夺了。试想一下一个人,不能自由行动(但还能扭动),看不见,听不见,说不了,几乎像有意识但却不能动的植物人一样,把灵魂封锁在了肉体内,是何等地残忍,简直就是受罪一般。

果然,等到时子将情况讲给老领导,并与其一起来看望须永中尉时,忽然发现他不见了,他竟然靠着像虫子一般的身体蠕动着下了楼梯,在草丛中爬行。虽然他眼睛看不到,每次也只能前进一寸,但是仍然在时子和老领导的目光中精准地找到了那一口枯井,毫无犹豫地栽了下去……

这篇故事描绘是战争给个人所带来的极度创伤,即便再多的荣誉,也不过是暂时的体面,余生必将留下无尽的痛苦。须永中尉是直接受害者,但是妻子时子不也是么。她对已成为怪物的丈夫虽然没有直接放弃,但是孤苦伶仃的生活也让她滋生出反常的扭曲心理。

带着贴画旅行的人

这是乱步口中“不知是现实还是梦境”类型作品中的一篇。乱步学生时代酷爱冥想,甚至在宿舍内将自己关在衣橱内发呆。在路上行走,如果有人跟他讲话,他都会觉得打扰了他的思考。

乱步对梦境着迷,或者是精神方面,他阅读弗洛伊德,创作出许多与梦境有关的似是而非的故事,其中包括梦游。可能正因为他偏爱沉浸于自己的思想世界,才自称《幻影城主》。也许这也是一个人能成为作家的必要特质吧。

故事讲的是在火车上偶遇到的一位老人的自述。“我”去鱼津看海市蜃楼回程路上,火车座位的斜对面,坐着一位打扮得体的似40岁又似60岁的男人,他随身携带着的一幅贴画引起了“我”的注意。

这位老人拿出了一个19世纪的古老望远镜让我观察这幅画,我意外拿反望远镜让老人十分惊慌。“我”观察后,对画中栩栩如生的人物感到十分震惊,乱步在这里不吝词藻地加以描述。

而后,老人讲述了这幅画的故事。少年时,他的哥哥虽然每日仍旧上班,但明显变得意志消沉,沉默寡言,家人都很担心他。明治28年(即1895年)4月的一天,老人(当时不满20岁)跟踪哥哥的去向,想知道他到底在干什么。

哥哥来到了东京浅草十二阶,拿着望远镜向下看。这时,弟弟叫住了哥哥,哥哥才道出了原委。

原来,某日哥哥向下用望远镜观察时,意外发现一位端庄美丽的女子,顿时患上相思病,每次前来只为能再一次一睹芳容,无奈事与愿违,摘下望远镜后便难觅其踪。

这次,哥哥似乎又看到了那位女子,急忙叫上弟弟奔向浅草寺寻找,但是找来找去却找不到,连那幢用作标记位置的房屋都没看到,二人不慎走散。

等到弟弟发现哥哥,他驻足在窥孔机关(应该是类似那种通过一个小洞观察内部玄机的生意)的小摊前面,上去查问,原来哥哥已发现那位美人实际上是一幅贴画中的人物“阿七”。

哥哥让弟弟倒置望远镜看他,弟弟看着看着,只见哥哥越来越小,最后似飘在空中不见了。最后, 他竟然发现哥哥已成为了贴画上的人物,与阿七左右相伴,也算随了他的愿。

火车上的老人看到“我”拿反了望远镜如此惊慌便是因为如此。故事没有完,阿七本来就是假人,因此青春常在,但是哥哥却容颜渐老,直到现在已白发苍苍。所以,“我”在火车上看到的,实际是一位妙龄女子和一位白发老人的贴画。哥哥因为自己年老,不甚哀伤,所以弟弟带着“他和阿七”一起坐火车旅行,欣赏沿途风景。故事线回到了火车上。

这篇故事中,望远镜仍然是乱步作品中惯用的经典道具,虽然他对镜子很上瘾,但是在小说中以弟弟的口吻却说这种东西会把小虫子放大成怪物,很可怖,算是呼应了《镜地狱》中的描述。在《幻影城主》中,乱步坦言和父亲一样,自小十分害怕蜘蛛这类生物,原因可能是埋着他胎盘的土地上方第一个爬过的生物就是蜘蛛(一种市井传说)。

哥哥隐入贴画中与女子为伴的故事虽然在现实中不可能发生,但未尝不是一个引入瞎想的浪漫故事,我隐约记得之前也读过类似的,但一时想不起来。

目罗博士不可思议的犯罪

这篇故事的一个特殊之处是以江户川乱步自己的身份作为第一人称的,讲述的是关于模仿和月光的恐怖杀人故事。

这一日,乱步出门闲逛,寻找写作灵感,在东京一处动物园的猴笼前邂逅了一位衣着破旧、精神头却很好的年轻人。

年轻人向乱步感叹猿猴和人一样,都是模仿的动物,先讲了一则小故事作为佐证。说一人的短刀被猴子夺去,他使出一招便成功拿回。怎么做的呢?他捡起一根树棍,在自己脖子上划来划去,那只猴子由于模仿的天性使然,也拿着短刀在自己脖子上划来划去,以至于血流如注,当场毙命。

猿猴如此,相似的人类也有类似宿命。年轻人又讲述了一个自己亲身经历(案件的当事人)的故事。

他之前在东京某处作为安保人员,看管两幢长得几乎一模一样的大楼,就好像是镜面倒影一般(乱步又提及了关于镜子的恐怖之处)。特别是在月光之下,这两幢钢筋水泥建筑看起来更加阴森。

接下来,位于五楼的某个用于办公的出租房间内,在短时间内发生了三起命案,每次无不是租客在房间外挂电线的横木上上吊自杀,诡异至极,但却无迹可寻,警察只能当作悬案处理。

不过当时作为安保人员的年轻人并不这样认为。因为他在最后一次命案当场,月光之下,看到了对面楼房开着的窗户中一闪而过一张蜡黄的人脸。

多日之后,年轻人发现了秘密,大楼附近开眼科诊所的目罗博士,和那日在对面房间看到的脸长得一模一样,他认为其中一定有不可告人的秘密,以后便随时留意目罗博士的动向。

半年后,那间房间再次出租,年轻人终于观察到了目罗博士的反常行为。他看到目罗博士买下了一套与新租户相似的衣服,套在了一尊蜡像身上,将蜡像打扮的和租户几乎一模一样。于是,年轻人大致猜到了是什么回事。这一晚,月光皎洁。

入夜,等到租户离开,年轻人便穿上与其类似的着装,用备用钥匙潜入了那间房间,背对着窗户伏案等待。果然,半夜时分,听到窗外猫头鹰的一声怪叫,他知道这是吸引他看向窗外的暗号。

他回过头来,打开窗户,一眼就能看到了和这边一摸一样的对面大楼,几乎如镜中倒影一般。唯一不同的是,对面没有我自己的身影。不对,有我,不过是吊在一根细绳之上——没错,就是那尊蜡像。在朦胧月光的诡异氛围之下,如果换做一般人,肯定就鬼使神差地模仿对面的景象,自己上吊去了。

但是年轻人有备而来,他知道这是目罗博士的诡计。于是他也拿出了预先准备好的道具——一尊和目罗博士一模一样的蜡像。他把蜡像搬上窗台,对面的目罗博士受到氛围的感染,竟也坐上了窗台。而后年轻人一把将蜡像推下,目罗博士也跳下了楼。不过年轻人事先已经在蜡像上绑上了绳子,又将蜡像拉回了房间……

这篇故事和前面的猴子拿刀的故事都描述了关于模仿的离奇之处,只不过一个是猿猴,一个是人类。沐浴在月光之下,显得寂静而诡秘。

之所以称之为不可思议的犯罪,显然,目罗博士对之前的三名租客,或者是年轻人对目罗博士,充其量不过是起了一个引诱的作用,并没有直接的作案动机和接触式的谋杀行为。个中责任如何定论呢,发人深省。

总结

这本书的15篇故事就简介至此了。总体来说,还是要感叹乱步丰富的想象力。单纯以犯罪推理的角度看,其实这些故事大多没有特别曲折的情节,只是简单离奇故事的氛围化表述,在结尾处能带来一定的反转,和本格不本格关系并不大。这些故事涉及方方面面,不拘泥于家庭、职场,使得乱步的作品具有可贵的多样化特征,吸引着各类读者。

乱步的作品带有浓烈的个人色彩,比如前面提到的镜子、虫子、土仓库,另外还有他写作时期(20世纪20年代)的大环境时局体现。如果没有丰富的切身经历,文字不可能细腻到让读者带有如此画面感的体验。

我近期同时在看乱步的《幻影城主》,他在年轻时曾经阅读过大量文学作品,他的母亲、祖母甚至可称为其在推理文学上的启蒙者。相比这段阅读时光,对他的日后创作起到了相当大的作用。

100年后的今日,充斥着各种各样的信息输入,手机、短视频无时不刻不占据着一个人的时间,可悲还有几家人能培养其这样的阅读环境。

再者,如今丰富的悬疑影视作品,多种多样的悬疑剧情,让大多人先入为主地对一些常见伎俩有了预测能力。反过来阅读乱步的作品,必然没有特别震撼的感受。要知道,在100年以前,此类故事并不多见,就如今的人首次观看3D电影一样,都是新玩意儿,因此,纯粹地以今天的视角去审视乱步的作品是不合适的。

毫无争议地说,一个世纪之前,在推理文学启蒙的那个时期,必然会出现一位破局者,他一定是江户川乱步吗?不然,但是,乱步对此却做好了准备,真可谓时势造英雄,英雄亦适时耶

(文中大部分图片采用AI生成)

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


最近一个多月,机缘巧合地读了几本江户川乱步的侦探小说,得知包括柯南·道尔在内的这些推理作家,都或多或少地受到了美国作家爱伦·坡的影响。带着好奇的心情,从网上花了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,那么从前往后操作也是等效的。

0%