阅读 5649

字节跳动的微前端沙盒实践

本文以字节跳动的微前端项目运行时隔离任务出发,深入讲解 sandbox (后称沙盒)的技术实现,以及上述实际实现过程中一些发生过的问题。把一些关键细节和多年的采坑经验充分的分享给读者,希望能对大家有一些帮助和启发。至于对微前端的整体思考,可以参考:《前端微服务在字节跳动的打磨与应用》,希望读者在读本文之前已经有所了解。

1. 沙盒应该做什么

首先从沙盒对微前端乃至前端的意义开始讲起。沙盒对软件工程来说其概念不算是新鲜事物,仅看前端对隔离的需求也由来已久。并且根据不同的实际业务场景,已经有过非常多和有特色的探索。

古老的 Iframe

一切都从 iframe 开始讲起了,反正是个看上去很美的解决方案。没有真的用过的人肯定都会这样想象。但有些可能等到你要真的搞一个通过 iframe 全面聚合的才知道。单纯的 iframe 聚合非常麻烦,需要很多补漏的劳动量。

旧的 iframe 的方案可以在一定程度上解决了耦合问题。具体是把一个站点页面拆成 N 个 frame,每个 frame 单独跑一个独立的域名。

它的好处非常清楚,独立上下,独立运行,谁都不会妨碍谁。但是是不是这样沙盒就完成了呢?这样算不算沙盒就不好说了,会有很多不同的观点和理论。比如一些观点可能会觉得沙盒不是像这样全独立,而是以隔离模拟独立。后面我们会再次谈到这个观点,并从实现角度分享一些我们的思考成果。

因为一个完整的项目包含大量公用的功能和代码,例如登录身份、站内信,业务模块只是其中的一个部分。这部分完全用跨 window 通信实现起来很费时费力,并且单页应用了 React 或类似的加载技术展示之后,iframe 的效果也逊色很多。想要突破这些限制,困难就很多了。

古老的困难

第一点不用多说各位都会想到 deeplinking 的问题,对吧,至少这点得做到才能算是一个工程,尤其 MVC 时代以来路由一直非常重要。

还有就是各种共享的东西,比如登录怎么共享。iframe 当然也不是不行。和前面后面提到的诸多问题一样都不是不行、而是很麻烦,要针对他去解决很多困难。从效果上讲,最终也完全可以形成一个不错的 iframe 沙盒。

另一显然的困难是,组件库、组件风格的父子传递,以及 React VUE 等渲染引擎的底层代码、内存对象的传递。初步的实现是加入分片打包功能,拆 common chunk 并独立部署 CDN 上,最后在加载时,通过浏览器自身的缓存能力加速访问。但是运行时内存并不共享,对包的运行时修改也难以复用。

还有数据层的设计,数据 Store 等等。数据层至少要有一定的事件打通的功能。搞不好就牵一发动全身,改一个需求,不得不发布四五个项目。

2. 沙盒应该像什么

前面提到过我们不希望沙盒是完全独立的运行时环境,而是有分享和协同的可靠环境。这一章我们希望能说清我们希望沙盒提供怎样的功能、如何使用。

虚拟化、容器化、Docker

到这里开始讲到美好的景物了,docker。从解耦的角度看,服务端的微服务主要是通过 docker 技术实现虚拟化的底层支持,使服务开发者可以体会不到环境的区别、抹平运行时差异的。可以说对微服务来说,docker 是这些年能得到如此的发展的一个基石。

单纯说微服务的概念本身,很久之前也有这个概念的,有面向服务编程的理论。但是发展很少,兑现仍然很困难,搞虚拟机很麻烦。而且还包括开发体验的东西,我打包的镜像——要想交付一致得包含整个操作系统吗?这对开发体验影响很坏。

在 docker 得到普遍应用之前,微服务在服务端的使用主要基于虚机。相比之下使用非常复杂、维护成本提高。虚拟机不说多麻烦了,大家都懂。它吃掉的资源,和容器化技术比完全不在一个量级。还有比如当你想要打个快照连磁盘都吃掉。并且多个服务之间的资源协调和有效分配,实现起来也极端困难。

诸多扩大的成本问题直到随着 docker 的沙盒体系才得以解决。微服务才成为一个趋势。可惜的是这样的容器环境在前端浏览器内的运行时还不存在。

所以这里已经可以看出我们提出的前端沙盒、对它的期望,是让它就像 Docker(而 iframe 在这个比喻里就相当于虚拟机)那样。我们搞的这套机制是像 Docker 的前端运行时容器,像 Docker 一样让前端的拆分能轻松一点、分享容易一点、资源节约一点。当然这不是否定 iframe 的方案。

3. 沙盒应该怎么做

那么我们说的这种沙盒,这种轻量级、强调组件间协同沟通、非常节省资源的沙盒怎么做呢,下面我分 3 个方向分别介绍。(这块还不会有具体的实现,主要从可能性上分析在浏览器里怎么造沙盒。)

3.1 单进程与多进程

参考单核、操作系统进程,模拟进程切换策略。 我们的沙盒实质上在让一个浏览器去跑多个“独立”的应用,那么这里对操作系统的模仿、最终趋同一定避不开。在这个角度上,和其他语言相比 JavaScript 占了一个独特的执行特点的便宜:它自身是单线程的。我怎么做实质上也都是在一个线程内。相当于我们这个操作系统从一开始就限定了单核只有一个出力气。

那么一个操作系统,怎么做多进程并行呢, 单进程可简单通过根路由等等规则控制,每次只激活一个,大家做 context 切换即可;多进程并行就正好可以利用 JavaScript 的特性,我可以封装每个独立的事件循环。比如 setTimeout、各种事件回调的 handler,我们在实际 function 外面先切换 context,再执行你原本希望绑定的 function。这样是线程安全的。总结下来就会是下面两条:

  1. 用路由切换封装,模拟单核单进程。
  2. 用事件循环的总体封装,模拟单核多进程。

3.2 Context 切换

context 切换来模拟线程安全, 具体的意思是在每个隔离的子应用“进程”即将开始激活时,先查找当前被激活的、其他的子应用,然后为这个将要退出的应用录制“操作系统”的全现场状态,保存为它的 context。最后为即将激活的新“进程”恢复、新建它自己的 context

如上面所说,我把当前状态记录为 context,保证每个子应用都适用在自己的 context 内,不影响和改变别人的 context。这个操作全部由托管了子应用的父系统统一来切换。

本次重点讲落地实践,和一些踩坑经验。欢迎大家持续关注,我们会输出更多关于这部分的实践经验。

体现删除 key 必须要遍历两次, 才能保证每个对象都遍历到一遍。这块要强调一个点,当你拿到新旧两个对象比较,遍历其中一个的 key、到另一个里面找,只这样做是不够的,因为有可能又删掉的东西。删掉导致 key 没有了,自然也遍历不到。想体现这个删除,必须要遍历两次,新旧两个对象都遍历一遍,才能知道相比之下谁多了什么谁少了什么。尤其是大家做“空闲”到新开沙盒的比较的时候,特别容易忘了这个细节。

Context 切换的性能够好吗? 先说说这个快照的空间性能。如果你有 N 个沙盒需要有多少切换的组合呢,是不是 context 的全文、或者任意两个沙盒之间的 context 差异都要完整储存?实际上不用。我们只需要记录差别、context 的变化,并且只记录他对 “idle”状态的差别。例如 A、B、C、D、E、F、G。我们不需要记录 A→B A→C 这样的切换,而是虚拟一个空闲状态:O,都是 A→O,B→O,只保存他们和 O 之间的差别。需要记录比较的变量数量从子应用个数的乘法变成了加法。写一个循环就可以快速比较完变化。

综上所述就是让每个子应用的开始和结束、互相切换,都先回到一个虚拟的“初始状态”,恢复现场,再进入被激活的沙盒状态,每次切换仅记录一个沙盒信息。避免了切换算法计算笛卡尔平方积导致比较、和保存沙盒切换信息过大的问题。

4. 字节跳动的沙盒采取的方案

虽然这个章节的名字是字节跳动的实现,并且前面提到这次我们主要分享实现层面的技术细节,但我们不会仅仅探讨和分享自己落地采用的方案,探讨的内容也会包括研究过和对比过的。如果我们认为有好的、适合其他场景的技术方案,我们也都尽量分享出来。

4.1 CSS 沙盒

先说 CSS 沙盒。 这块 webComponent 已经做了很多发展了很多了。这里忍不住要说,web 标准里一度有一个非常吸引我、让我感觉非常有意思的内容是 scoped css——就是加个 Attribute 就能结合 DOM 树限制 CSS 作用范围。后来这个标准被取消了。因为让路给了 ShadowDOM 体系。

这个我不是很理解:因为 scoped CSS 是外面的规则能进来、里面的规则不出去,但 shadowDOM 是完全的割裂。这个巨大的区别使得它们的工程意义相差甚远。后面我们会说到 css module,它的表现显然和 scoped style 一样,和 Shadow 不一样。

CSS module 和 CSS in JS 都是把样式写成或编译成脚本,同时把脚本生成的 DOM 的最外面一层加一个 nounce 的 attribute;然后再给所有受控的 CSS 规则都套上这个 "attribute"。缺点是相对麻烦了一点、并且要完全控制掌握所有 DOM 创建。在前端框架里,Angular 这样做很自然。

后面还会提到这方面最流行的 NPM 包有个好玩的 feature 可能会造成不小心的 bug。

我们采用的是 DOM 沙盒保护 head 内标签。 这样的 style 和 link 本身都可以受到沙盒统一保护。在实际应用中我们的子应用开发者在业务组件里也有用 CSS module 的,我们也不用管——反正去掉标签这个事情最安全。

DOM 沙盒就看管好某个 DOM 标签,谁要改了,沙盒切换时改回来。对绝大多数情况绑定的 style 和 link 标签都有效。但这个只限于单进程的情景。

如果如前面提到的多进程的情况(就是理解成同时有 N 个沙盒在一起运行、并行的系统)。那 CSS 肯定不能和 JavaScript 的单线程运行时一样那么搞,所以一定要用 moduled CSS。也不难搞,很多开源库可以用。即使出现大家引用同一个组件库的不同版本、各自 hack 过、失手创造了什么“幺蛾子”也不用怕,因为他们都是编译好、作用域有限了的。

用 NPM 上的 styled-component 包时要小心, 他们会根据环境变量判断环境;然后对 prod 环境启用一个叫“speedy”的模式,它将不用 innerText 写样式规则,而是用 addRules 那一整套 API。但是这套标准似乎没明确界定这个标签被从文档 DOM 树里移除时的行为和表现,也许因为显而易见 rules 也应当一起移除。但我们插回来时,这种含糊就乱套了。浏览器实际的表现是移除再插回的标签 rules 都没了。这里显然需要我们额外处理。

4.2 全局变量沙盒

另一个重要的是全局变量干扰问题。 Polyfill 等运行环境相关的全局对象、环境变量等具体实现上有非常大的差别,又全部作用于全局。它们对子应用、模块化的子组件来说,又属于自身全局外部的环境。

这块是微前端实施的一大重点。我个人觉得是这样的。可以看出来大家都不是很信。“谁不知道不要写全局变量啊?不会有这么不靠谱的人”。事实上真的试过才会发现会有好多。例如头条号里面用到了某个剪裁图片的插件库。它是个非常完善、正派、和古典的包,同时支持 ReactJQuery。它给全局写了一个单例的实现。并且在开发调试过程中我们不同业务线的团队就真的用了这个包的不同版本。

当然这个不重要,也没有造成问题。一个比较严峻的例子是这个—— reGeneratorRuntime。它是编译 async 语法用的,在某个 config 下的 Babel 会 delete 这个对象。到底是啥原理不清楚也不需要多讲,但是非常肯定会冲突并造成问题。曾经我们的西瓜号团队的 polyfill 规则和另一个业务线就发生了这类冲突。所以要比较 delete,恢复删除,切换回去西瓜再删掉。

Identifier 是另一个关注点。 你是否完全清楚 Identifier 是什么?Identifier 就是在某个 scope 下起作用的变量名啊什么的,包括 functionletclassconst。只有 var 出来的东西特殊一些、不会占用 Identifier,以上几个会,占用后不可以重复用。

这些东西首先你遍历不了,没有枚举器;其次他们不是某个对象的成员,而仅是编译层面的名字。一旦产生了绝对删不掉。

在全局作用域下 var a 的时候,实际上是生成了一个超范围的 Identifier 并且额外在 global 上创建一个同名的 key,指向同一个地址。这是 var 语句额外的操作。这让我们可以用遍历 window 的方式来处置全局变量。

但如果你来个 const 就没办法了,没有任何办法。 class 也一样。没办法枚举也没办法删除。最多就是再用 class 关键字声明一下覆盖掉。

总之这个事不要多想,new function 包起来几乎必不可少。还可以传入如 setTimeout 这种入参,用来控制异步实现“多进程”并行。

还有个 location 不要挪, 会刷新页面。黑名单掉它。

还有个好玩的事:functionvar 一样会额外在 window 上增加个 key。这个 propertyconfigurablefalse——也就是不能删除。但是可以赋值。

所以如果你如果光 var a,就可以 delete window.a;再写 a 就是 undefined。写个 function a,再写个 delete a 就无效。 但如果你写个 function a,再写个 var a = 1。啥效果呢,你给 window 上绑了个删不掉的数字,延续了 function a 的不可删除属性和 var a 的值。

更好玩的是 class,你 class B {} ,再 console log window.B,咋么样,undefind。再写 B = 1;然后再看 window.B 怎么样?继续 undefined 了, B = 1 没有效果。

说明潜在的某个机制在 class 关键字执行的时候,给 global 绑上了个叫 B 的 property,但是是个无法枚举和访问的 propertypropertywritable true, enumerable: true, configurable: true 之外的隐藏属性。

4.3 其他

还有好多需要进程安全的对象, 比如 cookie,但这个其实不特别重要,简单约定一个使用 path 就可以了——cookie 除了设置 domain 还可以设置 path。只不过大部分人都不设它(也就是设为根目录“/”)。

localStorage 可以也保护一下。 取决于你的业务。因为这些都属于 windows 的全局变量,所以实现一个包装过的 class 集成并模拟 localStorage 原本的行为就可以。让它所有方法都先给 key 加 prefix,再执行方法的 super。这个 prefix 可以简单地写死当前沙盒的 uuid 即可,因为 window.localStorage 作为全局变量本身就在沙盒保护之内。

5. 沙盒的其他功能

下面是最后一章节,会讲沙盒下有些特殊的东西,它们都需要额外处理。其中重点说一下埋点。多数微前端项目一个页面里的埋点已经属于不同项目了,这块就得搞清楚具体什么子应用、用的什么统计代码、需要处理哪些级别的缓存。

5.1 埋点缓存系统

像前面说的,把 Storage 缓存全部用沙盒包装过,对埋点体系来说还不算完。绝大多数埋点系统的事件发送都是异步、找网络空闲的。并且这些源码通常又在 SDK 里面、不在父工程直接控制的代码里。所以可操作余地不多。实际上只能是把缓存数据、项目信息都好好保存好,再把收集数据的缓存和产生数据时的沙盒状态对应起来。

5.2 console

沙盒可能会包一层或者多层运行时,所以 console 读会比较累。开发的时候可以额外处理一下,为开发者提供便利。 而且现在的前端项目,线上希望 console 打印越少越好、内容越正规越好,并且调试又非常忌讳别人遗留的打印干扰。这些都是沙盒可以做的。我们甚至做了把内容直接对接到采集系统里的上传 log。

具体来说,为 log 注入 callstack 是用 new Error 的方式。这样可以通过 error.stack 拿到调用堆栈。这个值直接是个字符串、是换行分割的 Markdown,可以写链接进去,也能对应到调试窗口的 source code。

同理的是当遇到真的 exception 也应当如此管控。从 catch 到异常、再次 throw 出去之前,可以给 error.stack 值全都 hack 掉。去掉不必要的 stack——比如你包的那层 new Function,删掉那一行。提示的内容也可以改。

5.3 sourceMapping

sourceMapping 是谷歌 closure 发明的一个、现在成为 ES6 标准的东西。原理是一个字符位置到字符位置的映射。那么在 new Function 下的沙盒里能不能用呢?当然可以。

我们先说说 new Function 的表现。在 chrome 里它在调试中是一个新的匿名环境:anonymous,字符行列位置就从函数字符串开头第一行开始算起。如果你是把编译并且生成了 sourceMap 后的 bundle 放到 new function 里执行,这个位置是完全对应的,不需要做任何额外的 hack。

这同时是因为 chrome 能正常识别 new Function 参数字符串末尾的 sourcemappingUrl= 的注释。对应在 callstack 里一切都对。有好多时候我们发现业务方会不放心这个事,会觉得我直接下载的 .js 被包了,不放心调试的 call stack 和 sourceMap。事实上没问题。这两方面都没问题。

另外我们之前其他场合分享也提到了,我们认为微前端的诸多必备条件,其中一个是要有一个服务发现资源和版本管理平台,管理微前端的独立发布、上下线和组合测试等等问题的。这样顺便也给了我们一个条件,可以给 sourceMap 管理起来。

结语

上面就是本次分享的全部,分享了关于微前端沙盒字节跳动两年来使用和实现的经验,以及面对这些挑战时的思考过程。非常幸运我们的项目有足够多给力的伙伴们支持,最终获得了比较大的成功,也非常明显地提升了重量级的产品的质量。

微前端和很多前沿和刚刚发展的概念一样,本身还在快速的演进和验证的过程中,我们的具体实践也一直在快速的变化,在不断地发现弱点和纠正它们,也在努力发展更多的可能。在这个从种种不完美到更完美的奋斗过程中,能给读者分享我们的成果是我们的一种荣幸。而且在分享后,如果能收到指教、讨论和建议我们会更加感激,并且非常欢迎。也欢迎更多的有识之士加入我们,联系请发往邮箱 aishiguang@bytedance.com 或关注 内推链接

欢迎关注「字节跳动技术团队」

关注下面的标签,发现更多相似文章
评论