腾讯AlloyTeam

腾讯AlloyTeam 查看完整档案

深圳编辑  |  填写毕业院校深圳腾讯公司  |  前端工程师 编辑 www.alloyteam.com 编辑
编辑

AlloyTeam 欢迎优秀的小伙伴加入。

简历投递: alloyteam@qq.com

详情可点击腾讯AlloyTeam招募Web前端工程师(社招)

个人动态

腾讯AlloyTeam 发布了文章 · 1月12日

大型 web 前端架构设计-面向抽象编程入门

原文:大型 web 前端架构设计-面向抽象编程入门 | AlloyTeam
作者:曾探

面向抽象编程,是构建一个大型系统非常重要的参考原则。

但对于许多前端同学来说,对面向抽象编程的理解说不上很深刻。大部分同学的习惯是 拿到需求单和设计稿之后就开始编写 UI 界面,UI 里哪个按钮需要调哪些方法,接下来再编写这些方法,很少去考虑复用性。当某天发生需求变更时,才发现目前的代码很难适应这些变更,只能重写。日复一日,如此循环。

面向具体实现编程:

当第一次看到“将抽象和具体实现分开”这句话的时候,可能很难明白它表达的是什么意思。什么是抽象,什么又是具体实现?为了理解这段话,我们耐下性子,先看一个假想的小例子,回忆下什么是面向具体实现编程

假设我们正在开发一个类似“模拟人生”的程序,并且创造了小明,为了让他的每一天都有规律的生活下去,于是给他的核心程序里设置了如下逻辑:

  1. 1、8 点起床
  2. 2、9 点吃面包
  3. 3、17 点打篮球

过了一个月,小明厌倦了一成不变的重复生活,某天早上起来之后他突然想吃薯片,而不是面包。等到傍晚的时候他想去踢足球,而不是继续打篮球,于是我们只好修改源代码:

  1. 1、8 点起床
  2. 2、9 点吃面包 -> 9 点吃薯片
  3. 3、17 点打篮球 -> 17 点踢足球

又过了一段时间,小明希望周 3 和周 5 踢足球,星期天打羽毛球,这时候为了满足需求,我们的程序里可能会被加进很多 ifelse语句。

为了满足需求的变换,跟现实世界很相似,我们需要深入核心源代码,做大量改动。现在再想想自己的代码里,是不是有很多似曾相识的场景?

这就是一个面向具体实现编程的例子,在这里,吃面包、吃薯片、打篮球、踢足球这些动作都属于具体实现,映射到程序中,它们就是一个模块、一个类,或者一个函数,包含着一些具体的代码,去负责某件具体的事情。

一旦我们想在代码中更改这些实现,必然需要被迫深入和修改核心源代码。当需求发生变更时,一方面,如果核心代码中存在各种各样的大量具体实现,想去全部重写这些具体实现的工作量是巨大的,另一方面,修改代码总是会带来未知的风险,当模块间的联系千丝万缕时,修改任何一个模块都得小心翼翼,否则很可能发生改好 1 个 bug,多出 3 个 bug 的情况。

抽取出共同特性

抽象的意思是:从一些事物中抽取出共同的、本质性的特征。

如果我们总是针对具体实现去编写代码,就像上面的例子,要么写死 9 点吃面包,要么写死 9 点吃薯片。这样一来,在业务发展和系统迭代过程中,系统就会变得僵硬和修改困难。产品需求总是多变的,我们需要在多变的环境里,尽量让核心源代码保持稳定和不用修改。

方法就是需要抽取出 9点吃面包9点吃薯片的通用特性,这里可以用 9点吃早餐来表示这个通用特性。同理,我们抽取出 17点打篮球17点踢足球的通用特性,用 17点做运动来代替它们。然后让这段核心源代码去依赖这些“抽象出来的通用特性”,而不再是依赖到底是“吃面包”还是“吃早餐”这种“具体实现”。

我们将这段代码写成:

  1. 1、8 点起床
  2. 2、9 点吃早餐
  3. 3、17 点做运动

这样一来,这段核心源代码就变得相对稳定多了,不管以后小明早上想吃什么,都无需再改动这段代码,只要在后期,由外层程序将“吃早餐”还是“吃薯片”注入进来即可。

真实示例

刚才是一个虚拟的例子,现在看一段真实的代码,这段代码依然很简单,但可以很好的说明抽象的好处。

在某段核心业务代码里,需要利用 localstorge 储存一些用户的操作信息,代码很快就写好了:

import 'localstorge' from 'localstorge';
class User{
 save(){
 localstorge.save('xxx');
 }
}
const user = new User();
user.save();

这段代码本来工作的很好,但是有一天,我们发现用户信息相关数据量太大, 超过了 localstorge 的储存容量。这时候我们想到了 Indexdb,似乎用 Indexdb 来存储会更加合理一些。

现在我们需要将 localstorge 换成 Indexdb,于是不得不深入 User 类,将调用 localstorge 的地方修改为调用 Indexdb。似乎又回到了熟悉的场景,我们发现程序里,在许多核心业务逻辑深处,不只一个,而是有成百上千个地方调用了 localstorge,这个简单的修改都成了灾难。

所以,我们依然需要提取出 localstorge 和 Indexdb 的共同抽象部分,很显然,localstorge 和 Indexdb 的共同抽象部分,就是都会向它的消费者提供一个 save 方法。作为它的消费者,也就是业务中的这些核心逻辑代码,并不关心它到底是 localstorge 还是 Indexdb,这件事情完全可以等到程序后期再由更外层的其他代码来决定。

我们可以申明一个拥有 save 方法的接口:

interface DB{
 save(): void;
}

然后让核心业务模块 User 仅仅依赖这个接口:

import DB from 'DB';
class User{
 constructor(
 private db: DB
){
 }
 save(){
 this.db.save('xxx');
 }
}

接着让 Localstorge 和 Indexdb 分别实现 DB 接口:

class Localstorge implements DB{
 save(str:string){
 ...//do something
 }
}
class Indexdb implements DB{
 save(str:string){
 ...//do something
 }
}
const user = new User( new Localstorage() );
//or
const user = new User( new Indexdb() );
userInfo.save();

这样一来,User 模块从依赖 Localstorge 或者 Indexdb 这些具体实现,变成了依赖 DB 接口,User 模块成了一个稳定的模块,不管以后我们到底是用 Localstorage 还是用 Indexdb,User 模块都不会被迫随之进行改动。

让修改远离核心源代码

可能有些同学会有疑问,虽然我们不用再修改 User 模块,但还是需要去选择到底是用 Localstorage 还是用 Indexdb,我们总得在某个地方改动代码把,这和去改动 User 模块的代码有什么区别呢?

实际上,我们说的面向抽象编程,通常是针对核心业务模块而言的。User 模块是属于我们的核心业务逻辑,我们希望它是尽量稳定的。不想仅仅因为选择使用 Localstorage 还是 Indexdb 这种事情就得去改动 User 模块。因为 User 模块这些核心业务逻辑一旦被不小心改坏了,就会影响到千千万万个依赖它的外层模块。

如果 User 模块现在依赖的是 DB 接口,那它被改动的可能性就变小了很多。不管以后的本地存储怎么发展,只要它们还是对外提供的是 save 功能,那 User 模块就不会因为本地存储的变化而发生改变。

相对具体行为而言,接口总是相对稳定的,因为接口一旦要修改,意味着具体实现也要随之修改。而反之当具体行为被修改时,接口通常是不用改动的。

至于选择到底是用 Localstorage 还是用 Indexdb 这件事情放在那里做,有很多种实现方式,通常我们会把它放在更容易被修改的地方,也就是远离核心业务逻辑的外层模块,举几个例子:

  • 在 main 函数或者其他外层模块中生成 Localstorage 或者 Indexdb 对象,在 User 对象被创建时作为参数传给 User
  • 用工厂方法创建 Localstorage 或者 Indexdb
  • 用依赖注入的容器来绑定 DB 接口和它具体实现之间的映射

内层、外层和单向依赖关系

将系统分层,就像建筑师会将大厦分为很多层,每层有特有的设计和功能,这是构建大型系统架构的基础。除了过时的 MVC 分层架构方式外,目前常用的分层方式有洋葱架构(整洁架构)、DDD(领域驱动设计)架构、六边形架构(端口-适配器架构)等,这里不会详细介绍每个分层模式,但不管是洋葱架构、DDD 架构、还是六边形架构,它们的层与层之间,都会被相对而动态地区分为外层和内层。

前面我们也提过好几次内层和外层的概念(大部分书里称为高层和低层),那么在实际业务中,哪些模块会对应内层,而哪些模块应该被放在外层,到底由什么规律来决定呢?

先观察下自然届,地球围绕着太阳转,我们认为太阳是内层,地球是外层。眼睛接收光线后通过大脑成像,我们认为大脑是内层,眼睛是外层。当然这里的内层和外层不是由物理位置决定的,而是基于模块的稳定性,即越稳定越难修改的模块应该被放在越内层,而越易变越可能发生修改的模块应该被放在越外层。就像用积木搭建房子时,我们需要把最坚固的积木搭在下面。

这样的规则设置是很有意义的,因为一个成熟的分层系统都会严格遵守单向依赖关系。

我们看下面这个图:

图片

假设系统中被分为了 A、B、C、D 这 4 层,那么 A 是相对的最内层,外层依次是 B、C、D。在一个严格单向依赖的系统中,依赖关系总是只能从外层指向内层。

这是因为,如果最内层的 A 模块被修改,则依赖 A 模块的 B、C、D 模块都会分别受到牵连。在静态类型语言中,这些模块因为 A 模块的改动都要重新进行编译,而如果它们引用了 A 模块的某个变量或者调用了 A 模块中的某个方法,那么它们很可能因为 A 模块的修改而需要随之修改。所以我们希望 A 模块是最稳定的,它最好永远不要发生修改。

但如果外层的模块被修改呢?比如 D 模块被修改之后,因为它处在最外层,没有其他模块依赖它,它影响的仅仅是自己而已,A、B、C 模块都不需要担心它们收到任何影响,所以,当外层模块被修改时,对系统产生的破坏性相对是比较小的。

如果从一开始就把容易变化,经常跟着产品需求变更的模块放在靠近内层,那意味着我们经常会因为这些模块的改动,不得不去跟着调整或者测试系统中依赖它的其他模块。

可以设想一下,造物者也许也是基于单向依赖原则来设置宇宙和自然界的,比如行星依赖恒星,没有地球并不会对太阳造成太大影响,而如果失去了太阳,地球自然也不存在。眼睛依赖大脑,大脑坏了眼睛自然失去了作用,但眼睛坏了大脑的其他功能还能使用。看起来地球只是太阳的一个插件,而眼睛只是大脑的一个插件。

回到具体的业务开发,核心业务逻辑一般是相对稳定的,而越接近用户输入输出的地方(越接近产品经理和设计师,比如 UI 界面),则越不稳定。比如开发一个股票交易软件,股票交易的核心规则是很少发生变化的,但系统的界面长成什么样子很容易发生变化。所以我们通常会把核心业务逻辑放在内层,而把接近用户输入输出的模块放在外层。

在腾讯文档业务中,核心业务逻辑指的就是将用户输入数据通过一定的规则进行计算,转换成文档数据。这些转换规则和具体计算过程是腾讯文档的核心业务逻辑,它们是非常稳定的,从微软 office 到谷歌文档到腾讯文档,30 多年了也没有太多变化,它们理应被放在系统的内层。另一方面,不管这些核心业务逻辑跑在浏览器、终端或者是 node 端,它们也都不应该变化。而网络层、存储层,离线层、用户界面这些是易变的,在终端环境里,终端用户界面层和 web 层的实现就完全不一样。在 node 端,存储层或许可以直接从系统中剔除掉,因为在 node 端,我们只需要利用核心业务逻辑模块对函数进行一些计算。同理,在单元测试或者集成测试的时候,离线层和存储层可能都是不需要的。在这些易变的情况下,我们需要把非核心业务逻辑都放在外层,方便它们被随时修改或替换。

所以,遵守单向依赖原则能极大提高系统稳定性,减少需求变更时对系统的破坏性。我们在设计各个模块的时候,要将相当多的时间花在设计层级、模块的切分,以及层级、模块之间的依赖关系上,我们常说“分而治之”, “分”就是指层级、模块、类等如何切分,“治”就是指如何将分好的层级、模块、类合理的联系起来。这些设计比具体的编码细节工作要更加重要。

依赖反转原则

依赖反转原则的核心思想是:内层模块不应该依赖外层模块,它们都应该依赖于抽象。

尽管我们会花很多时间去考虑哪些模块分别放到内层和外层,尽量保证它们处于单向依赖关系。但在实际开发中,总还是有不少内层模块需要依赖外层模块的场景。

比如在 Localstorge 和 Indexdb 的例子里,User 模块作为内层的核心业务逻辑,却依赖了外层易变的 Localstorage 和 Indexdb 模块,导致 User 模块变得不稳定。

import 'localstorge' from 'localstorge';
class User{
 save(){
 localstorge.save('xxx');
 }
}
const user = new User();
user.save();

为了解决 User 模块的稳定性问题,我们引入了 DB 抽象接口,这个接口是相对稳定的,User 模块改为去依赖 DB 抽象接口,从而让 User 变成一个稳定的模块。

Interface DB{
 save(): void;
}

然后让核心业务模块 User 仅仅依赖这个接口:

import DB from 'DB';
class User{
 constructor(
 private db: DB
){
 }
 save(){
 this.db.save('xxx');
 }
}

接着让 Localstorge 和 Indexdb 分别实现 DB 接口:

class Localstorge implements DB{
 save(str:string){
 ...//do something
 }
}

依赖关系变成:

User -> DB <- Localstorge

现在,User 模块不再显式的依赖 Localstorge,而是依赖稳定的 DB 接口,DB 到底是什么,会在程序后期,由其他外层模块将 Localstorge 或者 Indexdb 注入进来,这里的依赖关系看起来被反转了,这种方式被称为“依赖反转”。

找到变化,并将其抽象和封装出来

我们的主题“面向抽象编程”,很多时候其实就是指的“面向接口编程”,面向抽象编程站在系统设计的更宏观角度,指导我们如何构建一个松散的低耦合系统,而面向接口编程则告诉我们具体实现方法。依赖倒置原则告诉我们如何通过“面向接口编程”,让依赖关系总是从外到内,指向系统中更稳定的模块。

知易行难,面向抽象编程虽然概念上不难理解,但在真实实施中却总是不太容易。哪些模块应该被抽象,哪些依赖应该被倒转,系统中引入多少抽象层是合理的,这些问题都没有标准答案。

我们在接到一个需求,对其进行模块设计时,要先分析这个模块以后有没有可能随着需求变更被替换,或是被大范围修改重构?当我们发现可能会存在变化之后,就需要将这些变化封装起来,让依赖它的模块去依赖这些抽象。

比如上面例子中的 Localstorge 和 Indexdb,有经验的程序会很容易想到它们是有可能需要被互相替换的,所以它们最好一开始就被设计为抽象的。

同理,我们的数据库也可能产生变化,也许今天使用的是 mysql,但明年可能会替换为 oracle,那么我们的应用程序里就不应该强依赖 mysql 或者 oracle,而是要让它们依赖 mysql 和 oracle 的公共抽象。

再比如,我们经常会在程序中使用 ajax 来传输用户输入数据,但有一天可能会想将 ajax 替换为 websocket 的请求,那么核心业务逻辑也应该去依赖 ajax 和 websocket 的公共抽象。

封装变化与设计模式

实际上常见的 23 种设计模块,都是从封装变化的角度被总结出来的。拿创建型模式来说,要创建一个对象,是一种抽象行为,而具体创建什么对象则是可以变化的,创建型模式的目的就是封装创建对象的变化。而结构型模式封装的是对象之间的组合关系。行为型模式封装的是对象的行为变化。

比如工厂模式,通过将创建对象的变化封装在工厂里,让核心业务不需要依赖具体的实现类,也不需要了解过多的实现细节。当创建的对象有变化的时候,我们只需改动工厂的实现就可以,对核心业务逻辑没有造成影响。

比如模块方法模式,封装的是执行流程顺序,子类会继承父类的模版函数,并按照父类设置好的流程规则执行下去,具体的函数实现细节,则由子类自己来负责实现。

通过封装变化的方式,可以把系统中稳定不变的部分和容易变化的部分隔离开来。在系统的演变过程中,只需要替换或者修改那些容易变化的部分,如果这些部分是已经封装好的,替换起来也相对容易。这可以最大程度地保证程序的稳定性。

避免过度抽象

虽然抽象提高了程序的扩展性和灵活性,但抽象也引入了额外的间接层,带来了额外的复杂度。本来一个模块依赖另外一个模块,这种依赖关系是最简单直接的,但我们在中间每增加了一个抽象层,就意味着需要一直关注和维护这个抽象层。这些抽象层被加入系统中,必然会增加系统的层次和复杂度。

如果我们判断某些模块相对稳定,很长时间内都不会发生变化,那么没必要一开始就让它们成为抽象。

比如 java 中的 String 类,它非常稳定,所以并没有对 String 做什么抽象。

比如一些工具方法,类似 utils.getCookie(),我很难想象 5 年内有什么东西会代替 cookie,所以我更喜欢直接写 getCookie。

比如腾讯文档 excel 的数据 model,它属于内核中的内核,像整个身体中的骨骼和经脉,已经融入到了各个应用逻辑中,它被替换的可能性非常小,难度也非常大,不亚于重写一个腾讯文档 excel,所以也没有必要对 model 做过度抽象。

结语

面向抽象编程有 2 个最大好处。

一方面,面向抽象编程可以将系统中经常变化的部分封装在抽象里,保持核心模块的稳定。

另一方面,面向抽象编程可以让核心模块开发者从非核心模块的实现细节中解放出来,将这些非核心模块的实现细节留在后期或者留给其他人。

这篇文章讨论的实际主要偏重第一点,即封装变化。封装变化是构建一个低耦合松散系统的关键。

这篇文章,作为面向抽象编程的入门,希望能帮助一些同学认识面向抽象编程的好处,以及掌握一些基础的面向抽象编程的方法。

团队持续招人中,欢迎联系


AlloyTeam 欢迎优秀的小伙伴加入。
简历投递: alloyteam@qq.com
详情可点击 腾讯AlloyTeam招募Web前端工程师(社招)

clipboard.png

查看原文

赞 9 收藏 2 评论 0

腾讯AlloyTeam 发布了文章 · 2020-12-22

编译的速度与激情:从10mins到1s

原文:编译的速度与激情:从10mins到1s | AlloyTeam
作者:glendonli
导语:对于大型前端项目而言,构建的稳定性和易用性至关重要,腾讯文档在迭代过程中,复杂的项目结构和编译带来的问题日益增多,极大的增加了新人上手与日常搬砖的开销。恰逢 Webpack5 上线,不如来一次彻底的魔改~

1.前言

腾讯文档最近基于刚刚发布的 Webpack5 进行了一次编译的大重构,作为一个多个仓库共同构成的大型项目,任意品类的代码量都超过百万。对于腾讯文档这样一个快速迭代,高度依赖自动化流水线,常年并行多个大型需求和无数小需求的项目来说,稳定且快速的编译对于开发效率至关重要。

这篇文章,就是笔者最近进行重构,成功将日常开发优化到 1s 的过程中,遇到的一些大型项目特有的问题和思考,希望能给大家在前端项目构建的优化中带来一些参考和启发。

2.大型项目编译之痛

随着项目体系的逐渐扩大,往往会遇到旧的编译配置无法支持新特性,由于各种 config 文件自带的阅读 debuff,以及累累的技术债,大家总会趋于不去修改旧配置,而是试图新增一些配置在外围对编译系统进行修正。也是这样类似的原因,腾讯文档过去的编译编译也并不优雅:

图片

多级的子仓库结构,复杂的编译系统造成很高的理解和改动成本,也带来了较高的编译耗时,对于整个团队的开发效率有着不小的影响。

3.All in One

为了解决编译复杂和缓慢的问题,至关重要的,就是禁止套娃:多层级混合的系统必须废除,统一的编译才是王道。在所有编译系统中,Webpack 在大项目的打包上具备很强优势,插件系统最为丰满,并且 Webpack5 的带来了 Module Federation 新特性,因此笔者选择了用 Webpack 来统合多个子仓库的编译。

3.1.整合基于 lerna 的仓库结构

腾讯文档使用了 lerna 来管理仓库中的子包,使用 lerna 的好处此处就不作展开了。不过 lerna 的通用用法也带来了一定的问题,lerna 将一个仓库变成了结构上的多个仓库,如果按照默认的使用方式,每个仓库都会有自己的编译配置,单个项目的编译变成了多个项目的联编联调,修改配置和增量优化都会变得比较困难。

虽然使用 lerna 的目的是使各个子包相对独立,但是在整个项目的编译调试中,往往需要的是所有包的集合,那么,笔者就可以忽略掉这个子包间的物理隔离,把子仓库作为子目录来看待。不依赖 lerna,笔者需要解决的,是子包间的引用问题:

/** package/layout/src/xxx.ts **/
import { Stream } from "@core/model";
// do something

事实上,笔者可以通过 webpack 配置中 resolve 的 alias 属性来达到相应效果:

{
 resolve: {
 alias: {
 '@core/model': 'word/package/model/src/',
 }
 }
}

3.2.管理游离于打包系统之外的文件

在大型项目中,有时会存在一些特殊的静态代码文件,它们往往并不参与到打包系统中,而是由其他方式直接引入 html,或者合并到最终的结果中。

这样的文件,一般分为如下几类:

图片

  1. 加载时机较早的外部 sdk 文件,本身以 minify 文件提供
  2. 外部文件依赖的其他框架文件,比如 jquery
  3. 一些 polyfill
  4. 一些特殊的必须早期运行的独立逻辑,比如初始化 sdk 等

由于 polyfill 和外部 sdk 往往直接通过挂全局变量运行的模式,项目中往往会通过直接写入 html script 标签的方式引用它们。不过,随着此类文件的增多,直接利用标签引用,对于版本管理和编译流程都不友好,它们对应的一些初始化逻辑,也无法添加到打包流程中来。这种情况,笔者建议手工的创建一个 js 入口文件,对以上文件进行引用,并作为 webpack 的一个入口。如此,就能通过代码的方式,将这些散装文件管理起来了:

图片

import "jquery";
import "raven.min.js";
import "log.js";
// ...

但是,一些外部的 js 可能依赖于其他 sdk,比如 jQuery,但是打包系统并不知道它们之间的依赖关系,导致 jQuery 没有及时暴露到全局中,该怎么办呢?事实上,webpack 提供了很灵活的方案来处理这些问题,比如,笔者可以通过 expose-loader,将 jQuery 的暴露到全局,供第三方引用。

在腾讯文档中,还包含了一些对远程 cdn 的 sdk 组件,这些 sdk 也需要引用一些库,比如 jQuery 的。因此,笔者还通过 splitChunks 的配置,将 jQuery 重新分离出来,放在了较早的加载时机,保证基于 cdn 加载的 sdk 亦能正常初始化。

图片

通过代码引用,一方面,可以很好的进行依赖文件的版本管理;另一方面,由于对应文件的编译也加入了打包流程,所有对应文件的改动都可以被动态监视到,有利于后续进行增量编译。同时,由于 webpack 的封装特点,每个库都会被包含在一个 webpack_require 的特殊函数之中,全局变量的暴露数量也变得较为可控。

3.3.定制化的 webpack 流程

Webpack 提供了一个非常灵活的 html-webpack-plugin 来进行 html 生成,它支持模板和一众的专属插件,但是,仍然架不住项目有一些特殊的需求,通用的插件配置要么无法满足这些需求,要么适配的结果就十分难懂。这也是腾讯文档在最初使用了 gulp 来生成 html 的原因,在 gulp 配置中,有很多自定义流程来满足腾讯文档的发布要求。

既然,gulp 可以自定义流程来实现 html 生成,那么,笔者也可以单独写一个 webpack 插件来实现定制的流程。

Webpack 本身是一个非常灵活的系统,它是一个按照特定的流程执行的框架,在每个流程的不同的阶段提供了不同的钩子,通过各种插件去实现这些钩子的回调,来完成代码的打包,事实上,webpack 本身就是由无数原生插件组成的。在这整个流程中,笔者可以做各种不同的事情来定制它。

图片

对于生成 html 的场景,通过增加一个插件,在 webpack 处理生成文件的阶段,将生成的 js、css 等资源文件,以及 ejs 模板和特殊配置整合到一起,再添加到 webpack 的 assets 集合中,便可以完成一次自定义的 html 生成。

图片

compilation.hooks.processAssets.tap(
 {
 name: "TemplateHtmlEmitPlugin",
 stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
 },
 () => {
 // custom generation
 compilation.emitAsset(
 `${parsed.name}.html`,
 new sources.RawSource(source, true)
 );
 compilation.fileDependencies.addAll(dependencies);
 }
);

在以上代码中,大家可以留意到最后一句: compilation.fileDependencies.addAll(dependencies),通过这一句,笔者可以将所有被自定义生成所依赖的文件加入的 webpack 的依赖系统中,那么当这些文件发生变更的时候,webpack 能够自动再次触发对应的生成流程。

3.4.一键化的开发体验

至此,各路编译都已经统一化,笔者可以用一个 webpack 编译整个项目了,watch 和 devServer 也可以一起 high 起来。不过,既然编译可以统一,何不让所有操作都整合起来呢?

基于 node_modules 不应该手工操作的假设,笔者可以创建 package.json 中依赖的快照,每次根据 package 的变化来判断是否需要重新安装,避免开发同学同步代码后的手动判断,跳过不必要的步骤。

public async install(force = false) {
 const startTime = performance.now();
 const lastSnapshot = this.readSnapshot();
 const snapshot = this.createSnapshot();
 const runs = this.repoInfos.map((repo) => {
 if (
 this.isRepoInstallMissing(repo.root)
 || (!repo.installed
 && (force || !isEqual(snapshot[repo.root], lastSnapshot[repo.root])))
 ) {
 // create and return install cmd
 }
 return undefined;
 }).filter(script => !!script);
 const { info } = console;
 if (runs.length > 0) {
 try {
 // 执行安装并保存快照
 await Promise.all(runs.map(run => this.exec(run!.cmd, run!.cwd, run!.name)));
 this.saveSnapshot(snapshot);
 } catch (e) {
 this.removeSnapshot();
 throw e;
 }
 } else {
 info(chalk.green('Skip install'));
 }
 info(chalk.bgGreen.black(`Install cost: ${TimeUtil.formatTime(performance.now() - startTime)}`));
}

同样的,腾讯文档的本地调试是基于特殊的测试环境,通过 whislte 进行代理,这样的步骤也可以自动化,那么,对于开发来说,一切就很轻松了,一条命令,轻松搬砖~

图片

不过,作为是一个复杂的系统,第一次使用,总需要初始化的吧,如果编译系统的依赖尚未安装,没有鸡,怎么生蛋呢?

其实不然,笔者不妨在整套编译系统的外层套个娃,做前端开发,node 总会先安装的吧?那么,在执行正在的编译命令之前,笔者执行一个只依赖于 node 的脚本,这个脚本会尝试执行主要命令,如果主命令直接 crash,说明安装环境尚未准备完毕,那么这个时候,对编译系统进行初始化就 ok 了。如此,就真的可以做到一键开发了。

const cmd = "启动编译的命令";
const main = (extraArg) =>
 childProcess.execSync(`${cmd} ${extraArg}`, {
 stdio: "inherit",
 cwd: __dirname,
 });
try {
 main("");
} catch (e) {
 // 初始化
 main("after-initialize");
}

3.5.编译系统代码化

在这一次的重构过程中,笔者将原本的编译配置改为了由 ts 调用 webpack 的 nodeApi 来执行编译。代码化的编译系统有诸多好处:

  1. 使用 api 调用,可以享受 IDE 带来的代码提示,再也不会因为不小心在配置文件里面打了一个 typo 而调试一整天。
  2. 使用代码 api,能够更好的实现编译的结构,特别是有多重输出的时候,比起简单的 config 文件组合,更好管理。
  3. 使用代码化的编译系统,还有一个特别的作用,编译系统也可以写测试了!啥?编译系统要写测试?事实上,在腾讯文档历次的发布中,经历过数次莫名的 bug,在上线前的测试中,整个程序的表现突然就不正常了。相关代码,并没有任何改动,大家地毯式的排查了很久,才发现编译的结果和以前有微小的不同。

    事实上,在系统测试环境生成的前五个小时,一个编译所依赖的插件默默的更新了一个小版本,而笔者在 package.json 中对该插件使用的是默认的^xx.xx,流水线 install 到了最新的版本,导致了 crash。当时笔者得出了一个结论,编译相关的库需要锁定版本。但是,锁定版本并不能真正的解决问题,编译所使用的组件,总有升级的一天,如果保证这个升级不会引起问题呢?这就是自动化测试的范畴了。

    如果大家看看 Webpack 的代码,会发现他们也做了很多测试用例来编译的一致性,但是,webpack 的插件五花八门,并不是每一个作者在质量保障上都有足够的投入,因此,用自动化测试保证编译系统的稳定性,也是一个可以深入研究的课题。

4.编译提速

在涉及 typescript 编译的项目中,基本的提速操作,就是异步的类型检查,ts-loader 的 tranpsileOnly 参数和 fork-ts-checker 的组合拳百试不厌。不过,对于复杂的大型项目来说,这一套组合拳的启用过程未必是一帆风顺,不妨随着笔者一起看看,在腾讯文档中,启用快速编译的坎坷之路。

4.1.消失的 enum

在启用 transpileOnly 参数后,编译速度立即有了质的提升,但是,结果并不乐观。编译后,页面还没打开,就 crash 了。根据报错查下去,发现一个从依赖库导入的对象变成了 undefined,从而引起程序崩溃。这个变为 undefined 的对象,是一个 enum,定义如下:

export const enumScope{
 VAL1= 0,
 VAL2= 1,
}

为什么当笔者启用了 transpileOnly 后它就为空了呢?这和它的特殊属性有关,它不是一个普通的 enum,它是一个 const enum。众所周知,枚举是 ts 的语法糖,每一个枚举,对应了 js 中的一个对象,所以,一个普通的枚举,转化为 js 之后,会变成这样:

// ts
export enum Scope {
 VAL1 = 0,
 VAL2 = 1,
}
const a = Scope.VAL1;
// js
constScope = {
 VAL1: 0,
 VAL2: 1,
 0: "VAL1",
 1: "VAL2",
};
const a = Scope.EDITOR;

如果笔者给 Scope 加上一个 const 关键字呢?它会变成这样:

// ts
export const enumScope{
 VAL1= 0,
 VAL2= 1,
}
const a = Scope.VAL1;
// js
const a = 0;

也就是说,const enum 就和宏是等效的,在翻译成 js 之后,它就不存在了。可是,为何在关闭 transpileOnly 时,编译结果可以正常运行呢?其实,仔细翻看外部库的声明文件.d.ts,就会发现,在这个.d.ts 文件中,Scope 被原封不动的保留了下来。

// .d.ts
export const enumScope{
 VAL1= 0,
 VAL2= 1,
}

在正常的编译流程下,tsc 会检查.d.ts 文件,并且已经预知了这一个定义,因此,它能够正确的执行宏转换,而对于 transpileOnly 开启的情况下,所有的类型被忽略,由于原本的库模块中已经不存在 Scope 了,所以编译结果无法正常执行(PS:tsc 官方已经表态 transpile 模式下的编译不解析.d.ts 是标准 feature,丢失了 const enum 不属于 bug,所以等待官方支持是无果的)。既然得知了缘由,就可以修复了。四种方案:

  • 方案一,遵循官方指导,对于不导出 const enum,只对内部使用的枚举 const 化,也就是说,需要修改依赖库。当然,腾讯文档本次 crash 所有依赖库确实属于自有的 sdk,但是如果是外部的库引起了该问题呢?所以该方案并不保险。
  • 方案二,完美版,手动解析.d.ts 文件,寻找所有 const enum 并提取定义。但是,transpileOnly 获取的编译加速真是得益于忽略.d.ts 文件,如果笔者再去为了一个 enum 手工解析.d.ts,而.d.ts 文件可能存在复杂的引用链路,是极其耗时的。

图片

  • 方案三,字符串替换,既然 const enum 是宏,那么笔者可以手工通过 string-replace-loader 达到类似效果。不过,字符串替换方式依旧过于暴力,如果使用了类似于 Scope['VAL1']的用法,可能就猝不及防的失效了。

图片

  • 方案四,也是笔者最终所采取的方案,既然定义消失了,重新定义就好,通过 Webpack 的 DefinePlugin,笔者可以重新定义丢失的对象,保证编译的正常解析。
new DefinePlugin({
 Scope: { VAL1: 0, VAL2: 1 },
});

4.2.爱恨交加的 decorator 及依赖注入

很不幸,仅仅是解决了编译对象丢失的问题,代码依旧无法运行。程序在初始化的时候,依旧迷之失败了,经过一番调试,发现,初始化流程有一些微妙的不同。很明显,transpileOnly 开启的情况下,编译的结果发生了变化。 

要解决这个问题,就需要对 transpileOnly 模式的实现一探究竟了。transpileOnly 底层是基于 tsc 的 transpileModule 功能来实现的,transpileModule 的作用,是将每一个文件当做独立的个体进行解析,每一个 import 都会被当做一个整体模块来看待,编译器不会再解析模块导出与文件的具体关系,举个例子:

// src/base/a.ts
export class A {}
// src/base/b.ts
export class B {}
// src/base/index.ts
export * from "./a";
export * from "./b";
// src/app.ts
import { A } from "./base";
const a = new A();

如上是常见的代码写法,我们往往会通过一个 index.ts 导出 base 中的模块,这样,在其他模块中,笔者就不需要引用到文件了。在正常模式下,编辑器解析这段代码,会附带信息,告知 webpack,A 是由 a.ts 导出的,因此,webpack 在打包时,可以根据具体场景将 A、B 打包到不同的文件中。

但是,在 transpileModule 模式下,webpack 所知道的,只有 base 模块导出了 A,但是它并不知道 A 具体是由哪个文件导出的,因此,此时的 webpack 一定会将 A、B 打包到一个文件中,作为一整个模块,提供给 App。对于腾讯文档,这个情况发生了如下变化(模块按照 1、2、3、4、5 的顺序进行加载,模块的视觉大小表示体积大小):

图片

可以看到,在 transpileOnly 开启的情况下,大量的文件被打包到了模块 1 中,被提前加载了。不过,一般情况下,模块被打包到什么位置,并不应该影响代码的表现,不是么?毕竟,关闭 code splitting,代码是可以不拆包的。对于一般的情况而言,这样理解并没有错。但是,对于使用了 decorator 的项目而言,就不适用了。

在代码普遍会转为 es5 的时代,decorator 会被转换为一个__decorator 函数,这个函数,是代码加载时的一个自执行函数。如果代码打包的顺序发生了变化,那么自执行函数的执行顺序也就可能发生了变化。那么,这又如何导致了腾讯文档无法正常启动呢?这,就要从腾讯文档全面引入依赖注入技术开始说起。

在腾讯文档中,每一个功能都是一个 feature,这个 feature 并不会手动初始化,而是通过一个特殊装饰器,注入到腾讯文档的 DI 框架中,然后,由注入框架进行统一的实例创建。

举个例子,在正常的变一下,由三个 Feature A、B、C,A、B 被编译在模块 1 中,C 被编译到模块 2 中。在模块 1 加载时,workbench 会进行一轮实例创建和初始化,此时,FeatureA 的初始化带来了某个副作用。然后,模块 2 加载了,workbench 再次进行一轮实力创建和初始化,此时 FeatureC 的初始化依赖了 FeatureA 的副作用,但是第一轮初始化已经结束,因此 C 顺利实例化了。

图片

当 transpileOnly 被开启式,一切变了样,由于无法区分导出,Feature A、B、C 被打包到同一个模块了。可想而知,在 Feature C 初始化时,由于副作用尚未发生,C 的初始化就失败了。

图片

既然 transpileOnly 与依赖注入先天不兼容,那笔者就需要想办法修复它。如果,笔者将 app 中的引用进行替换:

// src/app.ts
import { A } from "./base/a";
const a = new A();

模块导出的解析问题,是否就迎刃而解了?不过,这么多的代码,改成这样的引用,不但难看,反人类,工作量也很大。因此,让笔者设计一个 plugin/loader 组合在编译时来解决问题吧。在编译的初始阶段,笔者通过一个 plugin,对项目文件进行解析,将其中的 export 提取出来,找到每一个 export 和文件的对应关系,并储存起来(此处,可能大家会担心 IO 读写对性能的影响,考虑到现在开发人均都是高速 SSD,这点 IO 吞吐真的不算什么,实测这个 export 解析<1s),然后在编译过程中,笔者再通过一个自定义的 loader 将对应的 import 语句进行替换,这样,就可以实现在不影响正常写代码的情况下,保持 transpileOnly 解析的有效性了。

图片

经过一番折腾,终于,成功的将腾讯文档在高速编译模式下运行了起来,达到了预定的编译速度。

5.Webpack5 升级之路

5.1.一些兼容问题处理

Webpack5 毕竟属于一次非兼容的大升级,在腾讯文档编译系统重构的过程中,也遇到诸多问题。

5.1.1. SplitChunks 自定义 ChunkGroups 报错

如果你也是 splitChunks 的重度用户,在升级 webpack5 的过程中,你可能会遇到如下警告:

图片

这个警告的说明并不是十分明确,用大白话来说,出现了这个提示,说明你的 chunkGroups 配置中,出现了 module 同时属于 A、B 两组(此处 A、B 是两个 Entrypoint 或者两个异步模块),但是你明确指定了将模块属于 A 的情况。为何此时 Webpack5 会报出警告呢?因为从通常情况来说,module 分属于两个 Entrypoint 或者异步模块,module 应该被提取为公共模块的,如果 module 被归属于 A,那么 B 模块如果单独加载,就无法成功了。

不过,一般来说,出现这样的指定,如果不是配置错误,那就是 A、B 之间已经有明确的加载顺序。但是这个加载顺序,Webpack 并不知道。对于 entrypoint,webpack5 中,允许通过 dependOn 属性,指定 entry 之间的依赖关系。但是对于异步模块,则没有这么遍历的设置。当然,笔者也可以通过自定义插件,在 optimize 之前,对已有的模块依赖关系以及修改,保证 webpack 能够知晓额外的信息:

compiler.hooks.thisCompilation.tap("DependOnPlugin", (compilation) => {
 compilation.hooks.optimize.tap("DependOnPlugin", () => {
 forEach(this.dependencies, (parentNames, childName) => {
 const child = compilation.namedChunkGroups.get(childName);
 if (child) {
 parentNames.forEach((parentName) => {
 const parent = compilation.namedChunkGroups.get(parentName);
 if (parent && !child.hasParent(parent)) {
 parent.addChild(child);
 child.addParent(parent);
 }
 });
 }
 });
 });
});

5.1.2.plugin 依赖的 api 已删除

Webpack5 发布后,各大主流 plugin 都已经相继适配,大家只要将插件更新到最新版本即可。不过,也有一些插件因为诸多缘由,一些插件并没有及时更新。(PS:目前,没有匹配的插件大多已经比较小众了。)总之,这个问题是比较无解的,不过可以适当等待,应该在近期,大部分插件都会适配 webpack5,事实上 webpack5 也是用了不少改名大法,部分接口进行转移,调用方式发生了改变,倒也没有全部翻天覆地的变化,所以,实在等不及的小插件不妨试试自己 fork 修改一下。

5.2.Module Federation 初体验

通常,对于一个大型项目来说,笔者会抽取很多公共的组件来提高项目间的模块共享,但是,这些模块之间,难免会有一些共同依赖,比如 React、ReactDOM,JQuery 之类的基础库。这样,就容易造成一个问题,公共组件抽取后,项目体积膨胀了。随着公共组件的增多,项目体积的膨胀变得十分可怕。在传统打包模型上,笔者摸索出了一套简单有效的方法,对于公共组件,笔者使用 external,将这些公共部分抠出来,变成一个残疾的组件。

图片

但是,随着组件的增多,共享组件的 Host 增多,这样的方式带来了一些问题:

  1. Component 需要为 Host 专门打包,它不是一个可以独立运行的组件,每一个运行该 Component 的 Host 必须携带完整的运行时,否则 Component 就需要为不同的 Host 打出不同的残疾包。
  2. Component 与 Component 之间如果存在较大的共享模块,无法通过 external 解决。

这个时候,Module Federation 出现了,它是 Webpack 从静态打包到完整运行时的一个转变,Module Federation 中,提出了 Host 和 Remote 的概念。Remote 中的内容可以被 Host 消费,而在这个消费过程中,可以通过 webpack 的动态加载运行时,只加载其中需要的部分,对于已经存在的部分,则不作二次加载。(下图中,由于 host 中已经包含了 jQuery、react 和 dui,Webpack 的运行时将只加载 Remote1 中的 Component1 和 Remote2 中的 Component2。)

图片

也就是说,公共组件作为一个 Remote,它包含了完整的运行时,Host 无需知道需要准备什么样的运行时才可以运行 Remote,但是 Webpack 的加载器保证了共享的代码不作加载。如此一来,就避免了传统 external 打包模式下的诸多问题。事实上,一个组件可以同时是 Host 和 Remote,也就是说,一个程序既可以作为主程运行,也可以作为一个在线的 sdk 仓库。关于 Module Federation 的实现原理,此处不再赘述,大家感兴趣可以参考《探索 webpack5 新特性 Module federation 在腾讯文档的应用》中的解析,也可以多多参考 module-federation-examples 这个仓库中的实例。

Webpack5 的 Module Federation 是依赖于其动态加载机制的,因此,在它的演示实例中,你都可以看到这样的结构:

// bootstrap.js
import App from "./App";
import React from "react";
import ReactDOM from "react-dom";
ReactDOM.render(<App />, document.getElementById("root"));
// index.js
import("./bootstrap.js");

而 Webpack 的入口配置,都设置在了 index.js 上,这里,是因为所有的依赖都需要动态判定加载,如果不把入口变成一个异步的 chunk,那如何去保障依赖能够按顺序加载内?毕竟实现 Moudle Federation 的核心是 基于 webpack_require 的动态加载系统。

由于 Module Federation 需要多个仓库的联动,它的推进必然是相对漫长的过程。那么笔者是否有必要将现有的项目直接改造为 index-bootstrap 结构呢?事实上,笔者依然可以利用 Webpack 的插件机制,动态实现这一个异步化过程:

private bootstrapEntry(entrypoint: string) {
const parsed = path.parse(entrypoint);
const bootstrapPath = path.join(parsed.dir, `${parsed.name}_bootstrap${parsed.ext}`);
this.virtualModules[bootstrapPath] = `import('./${parsed.name}${parsed.ext}')`;
return bootstrapPath;
}

在上述的 bootstrapEntry 方法中,笔者基于原本的 entrypoint 文件,创建一个虚拟文件,这个文件的内容就是:

import("./entrypoint.ts");

再通过 webpack-virtual-modules 这个插件,在相同目录生成一个虚拟文件,将原本的入口进行替换,就完成了 module-federation 的结构转换。这样,配合一些其他的相应配置,笔者就可以通过一个简单参数开启和关闭 module-federation,把项目变成一个 Webpack5 ready 的结构,当相关项目陆续适配成功,便可以一起欢乐的上线了。

6.后记

对编译的大重构是笔者蓄谋已久的事情,犹记得加入团队之时,第一次接触到编译链路如此复杂的项目,深感自己的项目经历太浅,接触的编译和打包都如此简单,如今亲自操刀才知道,这中间除了许多技术难题,大项目必有的祖传配置也是阻碍项目进步的一大阻力。

Webpack 5 的 Beta 周期很长,所以在 Webpack5 发布之后,兼容问题还真不如预想的那么多,不过 Webpack5 的文档有些坑,如果不是使用 NodeApi 的时候,有类型声明,笔者绝对无法发现官方不少文档的给的参数还是 webpack4 的旧数据,对不上号。于是不得不埋着头调试源代码,寻找正确的配置方式,不过也因此收获良多。

同时 Webpack5 还在持续迭代中,还存在一些 bug,比如 Module Federation 中使用了不恰当的配置,可能会导致奇怪的编译结果,并且不会报错。所以,遇到问题大家要大胆的提 issue。本次重构的经验就暂时说到这儿,如有不当之处,欢迎斧正。

最后,腾讯文档大量招人,如果你也想来研究这么有趣的技术,欢迎加入腾讯文档的大家庭,欢迎联系笔者glendonzli@qq.com。


AlloyTeam 欢迎优秀的小伙伴加入。
简历投递: alloyteam@qq.com
详情可点击 腾讯AlloyTeam招募Web前端工程师

clipboard.png

查看原文

赞 10 收藏 7 评论 0

腾讯AlloyTeam 发布了文章 · 2020-10-19

通过编译器插件实现代码注入

原文:通过编译器插件实现代码注入 | AlloyTeam
作者:林大妈

背景问题

大型的前端系统一般是模块化的。每当发现问题时,模块负责人总是要重复地在浏览器中找出对应的模块,略读代码后在对应的函数内打上断点,最终开始排查。

大部分情况下,我们会选择在固定的位置(例如模块的入口,或是类的构造函数)打上断点。也就意味着打断点的过程对于开发者来说是机械的劳动。那么有没有办法在不污染源代码的基础上通过配置来为代码打上断点呢?

实现思路

要想不污染源代码,只能选择在编译时进行处理,才能将想要的内容注入到目标代码中。代码编译的基本原理是将源代码处理成单词串,再将单词串组织成抽象语法树,最终再通过遍历抽象语法树并转换上面的节点而形成目标代码。

因此,代码注入的关键点就在于在抽象语法树形成时对语法树节点进行处理。前端代码通常会使用 babel 进行编译。

熟悉 babel 的基本原理

babel 的组成

babel 的核心是 babel-core。babel-core 可被划分成三个部分,分别处理对应的三个编译过程:

  1. babel-parser —— 负责将源代码字符串“单词化”并转化成抽象语法树
  2. babel-traverse —— 负责遍历抽象语法树并附加处理
  3. babel-generator —— 负责通过抽象语法树生成目标代码

babel-parser

整个 babel-parser 使用继承的方式,根据功能的不同逐层包装:

tokenizer

babel-parser 的一个核心是“tokenizer”,可以理解为“单词生成器”。babel 维护了一个 state(一个全局的状态),它会通过记录一些元信息提供给编译器,例如:

  • “这段 JavaScript 代码是否使用了严格模式?”
  • “我们现在识别到第几行第几列了?”
  • “这段代码里有哪些注释?”

tokenizer 的内部定义了不同的方法以识别不同的内容,例如:

  • 读到引号时通过 readString 方法尝试生成一个字符串 token
  • 读到数字时通过 readNumber 方法尝试生成一个数字 token

LVal/Expression/StatementParser

babel-parser 的另一个核心是“parser”,可以理解为“语法树生成器”。其中,StatementParser 是子类,当我们引入 babel-parser 并调用 parse 方法时,识别过程将从此处启动(babel 应该是将整个文件认为是一个语句节点)。

同样地,这些 parser 的内部也都为识别不同的内容而定义不同的方法,例如:

  • 识别到 true 时,生成一个 Boolean 字面量表达式
  • 识别到 @ 这个符号时,生成一个  装饰器语句节点

babel-traverse

babel-traverse 提供方法遍历语法树。它使用访问者模式,为外界提供添加遍历时附加操作的入口。

在访问者模式(Visitor Pattern)中,我们使用了一个访问者类,它改变了元素类的执行算法。通过这种方式,元素的执行算法可以随着访问者改变而改变。

TraversalContext

遍历语法树时,babel 同样定义了一个 context 判断是否需要遍历以及遍历的方式。

TraversalContext 先将节点和外来的访问者进行有效化的处理,然后构造访问队列,最后启动深度优先遍历整棵语法树的过程。

class TraversalContext {
 // ...
 visitQueue(queue: Array<NodePath>) {
 // 一些预处理
 // 深度优先遍历
 for (const path of queue) {
 if (path.visit()) {
 stop = true;
 break;
 }
 }
 // ...
 }
 // ...
}

visitor

babel 使用的访问者模式,非常地利于开发者编写插件。编写 babel 插件的核心思路就是编写 visitor,以附加对语法树进行的操作。

在 babel 中,visitor 是一个对象(可以通过 babel 的 ts 声明文件找到类型规范),通过在这个对象中新增 key(需要访问的节点)和 value(执行的函数)可以使遍历语法树时对应执行指定的操作:

// 如:编写一个插件,每次遍历到标识符时就输出该变量名
const visitor = {
 Identifier(path, state) {
 console.log(path.node.name);
 },
};
// 或
const visitor = {
 Identifier: {
 enter(path, state) {
 console.log(path.node.name);
 },
 exit() {
 // do nothing...
 },
 },
};

path

path 是每个 visitor 方法中传入的第一个参数,它表示树上的该节点与其它节点的关系。编写 babel 插件,最核心的是了解并利用好 path 上挂载的元数据以及对外暴露的 API。

path 上有以下相对重要的属性:

  • node 节点
  • parent 父节点
  • parentPath 父节点的 path
  • container 包含所有同级节点的元素
  • context 节点对应的 TraversalContext
  • contexts 节点对应的多个 TraversalContext
  • scope 节点的作用域
  • ……

path 的原型上还挂载了许多其它的处理方法:

  • get (静态方法)获取节点的属性
  • insertBefore 在当前节点前增加指定的元素
  • insertAfter 在当前节点后增加指定的元素
  • unshiftContainer 将指定的节点插入该节点的 container 的首位
  • pushContainer 将指定的节点插入该节点的 container 的末位
  • ……

state

state 表示当前遍历的状态,记录了一些元信息,与 tokenizer 的 state 类似。

babel-generator

babel-generator 主要实现了两个功能:

  1. 使用缓冲区分步生成目标代码
  2. 源码映射(sourcemap)

babel-generator 暴露了 generate 函数,接收语法树、配置以及源代码为参数。其中,语法树用于生成目标代码,而源代码用作 sourcemap。babel-generator 中的代码业务逻辑较多,没有太过复杂的设计,但拆分函数非常细,所有的判断以及不同种符号的处理都被拆开了,新增功能非常简单。

Buffer

buffer 中定义了一个存放目标代码的字符串数组,以及一个存放末尾符号(空格、分号以及'n')的队列。字符串数组采用按行插入的方式。存放末尾符号的队列用以处理行末多余的空格(即每次插入末尾符号前 pop 出所有的空格)。

SourceMap

babel-generator 采用了 npm library source-map 来构建 sourceMap。babel 在输出代码时,只要位置不是在目标代码的换行处,都会进行一次标记以提供参数给 source-map 库,目前 source-map 库具体内容还未细致研究。

插件的具体实现

了解 babel 以后,结合我们的需求,基本目标可定为:编写可配置的 babel 插件,使开发人员通过配置文件在特定位置下放断点。

babel 插件的核心是 visitor,这里我们举一个具体而特殊的例子来描述如何实现以上的目标:

将特定的注释替换成调试语句

首先,应从 babel 构造的语法树上找到对应的注释节点。但我们发现,在 babel 构造的语法树中,无论何种注释,都不是一个具体的节点:

例如,对于以下的代码:

// @debug
const a = 1;

在它的语法树中,注释节点只属于某段具体的代码的"leadingComments"属性,而非独立的树节点。再考虑以下代码:

const a = 1;
// @debug
const b = 2;

在它的语法树中,注释节点既属于第一段的"trailingComments"属性,也属于第二段代码的"leadingComments"属性。包括代码和注释同行,结果也是相同的。

因此,在编写 visitor 前,需要注意两个点:

  1. 注释并不是特定的语法树节点,而是节点上的一个属性。
  2. 遍历所有语句时,前一句的"trailingComments"和后一句的"leadingComments"会发生重复。

采取的解决方案是:

  1. 直接在 visitor 中添加"CommentLine"属性进行处理是无用的。可选择在 traverse 时使用"enter"方法统一检测所有节点的前后注释。
  2. “后顾”,当前节点有"trailingComments"需要替换时,要遍历后一个兄弟节点的"leadingComments"进行去重,或者每次替换时直接将注释内容删除。

完整的 visitor 代码如下:

export const visitor = {
 enter(path) {
 addDebuggerToDebugCommentLine(path);
 // 添加其它的处理方法……
 },
};
// 通过key值防止重复
let dulplicationKey = null;
function addDebuggerToDebugCommentLine(path) {
 const node = path.node;
 if (hasLeadingComments(node)) {
 // 遍历所有的前缀注释
 node.leadingComments.forEach((comment) => {
 const content = comment.value;
 // 检测该key值与防重复key值相同
 if (path.key === dulplicationKey) {
 return;
 }
 // 检测注释是否符合debug模式
 if (!isDebugComment(content)) {
 return;
 }
 // 传入参数,插入调试代码
 path.insertBefore();
 });
 }
 if (hasTrailingComments(node)) {
 // 遍历所有的后缀注释
 node.trailingComments.forEach((comment) => {
 const content = comment.value;
 // 检测注释是否符合debug模式
 if (!isDebugComment(content)) {
 return;
 }
 // 防止下一个sibling节点重复遍历注释
 dulplicationKey = path.key + 1;
 // 传入参数,插入调试代码
 path.insertBefore();
 });
 }
}

上述的例子之所以说特殊,是因为注释不是语法树上的节点,而是节点上的一个属性。当仅需要识别某类节点时,方法就更为简单了,直接通过为 visitor 定义更多的方法即可完成:

export const visitor = {
 Expression(path) {
 addDebuggerToExpression(path);
 },
 Statement(path) {
 addDebuggerToStatement(path);
 },
 // 添加其它需要的方法……
};

当出现更复杂的情况(例如要在调试语句中传入参数)时,丰富以上的函数。通过使用解析注释或在 webpack loader 中解析配置项文件获得参数,对应传入即可。

用途

根据以上的代码编译出的代码是经过处理后的代码。它部署到某个测试环境后,有以下的用途:

  • 灰度某个用户,即可随时排查该用户的使用问题。
  • 在项目中增加不污染源代码的配置文件,使开发人员通过配置下放指定代码。
  • 甚至还可以增加可视化界面进行配置。

通用化

了解插件知识后,我们可以总结出插件的最大特点:几乎可以在代码任意处修改任意内容。理论上,只要逻辑打通,语法树有无穷的玩法。例如刚才提到的根据配置下放调试代码和常见的单测覆盖率统计等。

因此,还可以对插件进行更高级的抽象,做成插件工厂,可供用户配置生成对应功能的插件并重新执行编译等。


AlloyTeam 欢迎优秀的小伙伴加入。
简历投递: alloyteam@qq.com
详情可点击 腾讯AlloyTeam招募Web前端工程师(社招)

clipboard.png

查看原文

赞 7 收藏 5 评论 1

腾讯AlloyTeam 发布了文章 · 2020-07-21

Web Worker 文献综述

作者:TAT.cnt

导语

Web Worker 作为浏览器多线程技术, 在页面内容不断丰富, 功能日趋复杂的当下, 成为缓解页面卡顿, 提升应用性能的可选方案. 但她的容颜, 隐藏在边缘试探的科普文章和不知深浅的兼容性背后; 对 JS 单线程面试题倒背如流的前端工程师, 对多线程开发有着天然的陌生感.

Web Worker 文献综述

Web Worker 作为浏览器多线程技术, 在页面内容不断丰富, 功能日趋复杂的当下, 成为缓解页面卡顿, 提升应用性能的可选方案.

但她的容颜, 隐藏在边缘试探的科普文章和不知深浅的兼容性背后; 对 JS 单线程面试题倒背如流的前端工程师, 对多线程开发有着天然的陌生感.

图片来源

背景

文献综述

文献综述(Literature Review)是学术研究领域一个常见概念, 写过毕业论文的同学应该还有印象. 它向读者介绍与主题有关的详细资料、动态、进展、展望以及对以上方面的评述.

近期笔者关注 Web Worker, 并落地到了大型复杂前端项目. 开源了 Worker 通信框架 alloy-worker, 正在写实践总结文章. 其间查阅了相关资料(50+文章, 10+技术演讲), 独立写成这篇综述性文章.

主要内容

发展历史

简介

前端同学对 Web Worker 应该不陌生, 即使没有动手实践过, 应该也在社区上看过相关文章. 在介绍和使用上, 官方文档是 MDN 的 Web Workers API. 其对 Web Worker 的表述是:

Web Workers makes it possible to run a script operation in a background thread separate from the main execution thread of a web application.

如下图所示, Web Worker 实现了多线程运行 JS 能力. 之前页面更新要先串行(Serial) 做 2 件事情; 使用 Worker 后, 2 件事情可并行(Parallel) 完成.

图片来源

可以直观地联想: 并行可能会提升执行效率; 运行任务拆分能减少页面卡顿. 后面应用场景章节将继续讨论.

技术规范

Web Worker 属于 HTML 规范, 规范文档见 Web Workers Working Draft, 有兴趣的同学可以读一读. 而它并不是很新的技术, 如下图所示: 2009 年就提出了草案.

图片来源

同年在 FireFox3.5 上率先实现, 可以在 using web workers: working smarter, not harder 中看到早期的实践. 2012年发布的 IE10 也实现了 Web Worker, 标志着主流浏览器上的全面支持. IE10 的 Web Worker 能力测试如下图所示:

图片来源

在预研 Worker 方案时, 开发人员会有兼容性顾虑. 这种顾虑的普遍存在, 主要由于业界 Worker 技术实践较少和社区推广不活跃. 单从发展历史看, Worker 从 2012 年起就广泛可用; 后面兼容性章节将继续讨论.

DedicatedWorker 和 SharedWorker

Web Worker 规范中包括: DedicatedWorkerSharedWorker; 规范并不包括 Service Worker, 本文也不会展开讨论.

图片来源

如上图所示, DedicatedWorker 简称 Worker, 其线程只能与一个页面渲染进程(Render Process)进行绑定和通信, 不能多 Tab 共享. DedicatedWorker 是最早实现并最广泛支持的 Web Worker 能力.

而 SharedWorker 可以在多个浏览器 Tab 中访问到同一个 Worker 实例, 实现多 Tab 共享数据, 共享 webSocket 连接等. 看起来很美好, 但 safari 放弃了 SharedWorker 支持, 因为 webkit 引擎的技术原因. 如下图所示, 只在 safari 5~6 中短暂支持过.

图片来源

社区也在讨论 是否继续支持 SharedWorker; 多 Tab 共享资源的需求建议在 Service Worker 上寻找方案.

相比之下, DedicatedWorker 有着更广的兼容性和更多业务落地实践, 本文后面讨论中的 Worker 都是特指 DedicatedWorker.

主线程和多线程

用户使用浏览器一般会打开多个页面(多 Tab), 现代浏览器使用单独的进程(Render Process)渲染每个页面, 以提升页面性能和稳定性, 并进行操作系统级别的内存隔离.

图片来源

主线程(Main Thread)

页面内, 内容渲染和用户交互主要由 Render Process 中的主线程进行管理. 主线程渲染页面每一帧(Frame), 如下图所示, 会包含 5 个步骤: JavaScript → Style → Layout → Paint → Composite, 如果 JS 的执行修改了 DOM, 可能还会暂停 JS, 插入并执行 Style 和 Layout.

图片来源

而我们熟知的 JS 单线程和 Event Loop, 是主线程的一部分. JS 单线程执行避免了多线程开发中的复杂场景(如竞态和死锁). 但单线程的主要困扰是: 主线程同步 JS 执行耗时过久时(浏览器理想帧间隔约 16ms), 会阻塞用户交互和页面渲染.

图片来源

如上图所示, 长耗时任务执行时, 页面将无法更新, 也无法响应用户的输入/点击/滚动等操作. 如果卡死太久, 浏览器可能会抛出卡顿的提示. 如下图所示.

  • Chrome81

  • IE11

多线程

Web Worker 会创建操作系统级别的线程.

The Worker interface spawns real OS-level threads. -- MDN

JS 多线程, 是有独立于主线程的 JS 运行环境. 如下图所示: Worker 线程有独立的内存空间, Message Queue, Event Loop, Call Stack 等, 线程间通过 postMessage 通信.

多个线程可以并发运行 JS. 熟悉 JS 异步编程的同学可能会说, setTimeout / Promise.all 不就是并发吗, 我写得可溜了.

JS 单线程中的"并发", 准确来说是 Concurrent. 如下图所示, 运行时只有一个函数调用栈, 通过 Event Loop 实现不同 Task 的上下文切换(Context Switch). 这些 Task 通过 BOM API 调起其他线程为主线程工作, 但回调函数代码逻辑依然由 JS 串行运行.

Web Worker 是 JS 多线程运行技术, 准确来说是 Parallel. 其与 Concurrent 的区别如下图所示: Parallel 有多个函数调用栈, 每个函数调用栈可以独立运行 Task, 互不干扰.

应用场景

讨论完主线程和多线程, 我们能更好地理解 Worker 多线程的应用场景:

  • 可以减少主线程卡顿.
  • 可能会带来性能提升.

减少卡顿

根据 Chrome 团队提出的用户感知性能模型 RAIL, 同步 JS 执行时间不能过长. 量化来说, 播放动画时建议小于 16ms, 用户操作响应建议小于 100ms, 页面打开到开始呈现内容建议小于 1000ms.

逻辑异步化

减少主线程卡顿的主要方法为异步化执行, 比如播放动画时, 将同步任务拆分为多个小于 16ms 的子任务, 然后在页面每一帧前通过 requestAnimationFrame 按计划执行一个子任务, 直到全部子任务执行完毕.

图片来源

拆分同步逻辑的异步方案对大部分场景有效果, 但并不是一劳永逸的银弹. 有以下几个问题:

  • 不是所有 JS 逻辑都可拆分. 比如数组排序, 树的递归查找, 图像处理算法等, 执行中需要维护当前状态, 且调用上非线性, 无法轻易地拆分为子任务.
  • 可以拆分的逻辑难以把控粒度. 如下图所示, 拆分的子任务在高性能机器(iphoneX)上可以控制在 16ms 内, 但在性能落后机器(iphone6)上就超过了 deadline. 16ms 的用户感知时间, 并不会因为用户手上机器的差别而变化, Google 给出的建议是再拆小到 3-4ms.

图片来源

  • 拆分的子任务并不稳定. 对同步 JS 逻辑的拆分, 需要根据业务场景寻找原子逻辑, 而原子逻辑会跟随业务变化, 每次改动业务都需要去 review 原子逻辑.

Worker 一步到位

Worker 的多线程能力, 使得同步 JS 任务的拆分一步到位: 从宏观上将整个同步 JS 任务异步化. 不需要再去苦苦寻找原子逻辑, 逻辑异步化的设计上也更加简单和可维护.

这给我们带来更多的想象空间. 如下图所示, 在浏览器主线程渲染周期内, 将可能阻塞页面渲染的 JS 运行任务(Jank Job)迁移到 Worker 线程中, 进而减少主线程的负担, 缩短渲染间隔, 减少页面卡顿.

性能提升

Worker 多线程并不会直接带来计算性能的提升, 能否提升与设备 CPU 核数和线程策略有关.

多线程与 CPU 核数

CPU 的单核(Single Core)和多核(Multi Core)离前端似乎有点远了. 但在页面上运用多线程技术时, 核数会影响线程创建策略.

进程是操作系统资源分配的基本单位,线程是操作系统调度 CPU 的基本单位. 操作系统对线程能占用的 CPU 计算资源有复杂的分配策略. 如下图所示:

  • 单核多线程通过时间切片交替执行.
  • 多核多线程可在不同核中真正并行.

Worker 线程策略

一台设备上相同任务在各线程中运行耗时是一样的. 如下图所示: 我们将主线程 JS 任务交给新建的 Worker 线程, 任务在 Worker 线程上运行并不会比原本主线程更快, 而线程新建消耗和通信开销使得渲染间隔可能变得更久.

图片来源

在单核机器上, 计算资源是内卷的, 新建的 Worker 线程并不能为页面争取到更多的计算资源. 在多核机器上, 新建的 Worker 线程和主线程都能做运算, 页面总计算资源增多, 但对单次任务来说, 在哪个线程上运行耗时是一样的.

真正带来性能提升的是多核多线程并发.

如多个没有依赖关系的同步任务, 在单线程上只能串行执行, 在多核多线程中可以并行执行. 如下图 alloy-worker图像处理 demo 所示, 在 iMac 上运行时创建了 6 条 Worker 线程, 图像处理总时间比主线程串行处理快了约 2000ms.

值得注意的是, 目前移动设备的核心数有限. 最新 iPhone Max Pro 上搭载的 A13 芯片 号称 6 核, 也只有 2 个高性能核芯(2.61G), 另外 4 个是低频率的能效核心(0.58G). 所以在创建多条 Worker 线程时, 建议区分场景和设备.

把主线程还给 UI

Worker 的应用场景, 本质上是从主线程中剥离逻辑, 让主线程专注于 UI 渲染. 这种架构设计并非 Web 技术上的独创.

Android 和 iOS 的原生开发中, 主线程负责 UI 工作; 前端领域热门的小程序, 实现原理上就是渲染和逻辑的完全分离.

本该如此.

Worker API

通信 API

如上图所示的 Worker 通信流程, Worker 通信 API 非常简单. 通俗中文教程可以参考 Web Worker 使用教程. 使用细节建议看官方文档.

双向通信示例代码如下图所示, 双向通信只需 7 行代码.

主要流程为:

  1. 主线程调用 new Worker(url) 创建 Worker 实例, url 为 Worker JS 资源 url.
  2. 主线程调用 postMessage 发送 hello, 在 onmesssage 中监听 Worker 线程消息.
  3. Worker 线程在 onmessage 中监听主线程消息, 收到主线程的 hello; 通过 postMessage 回复 world.
  4. 主线程在消息回调中收到 Worker 的 world 信息.

postMessage 会在接收线程创建一个 MessageEvent, 传递的数据添加到 event.data, 再触发该事件; MessageEvent 的回调函数进入 Message Queue, 成为待执行的宏任务. 因此 postMessage 顺序发送的信息, 在接收线程中会顺序执行回调函数. 而且我们无需担心实例化 Worker 过程中 postMessage 的信息丢失问题, 对此 Worker 内部机制已经处理.

Worker 事件驱动(postMessage/onmessage) 的通信 API 虽然简洁, 但大多数场景下通信需要等待响应(类似 HTTP 请求的 Request 和 Response), 并且多次同类型通信要匹配到各自的响应. 所以业务使用一般会封装原生 API, 如封装为 Promise 调用. 这也是笔者开发 alloy-worker 的原由之一.

运行环境

在 Worker 线程中运行 JS, 会创建独立于主线程的 JS 运行环境, 称之为 DedicatedWorkerGlobalScope. 开发者需关注 Worker 环境和主线程环境的异同, 以及 Worker 在不同浏览器上的差异.

Worker 环境和主线程环境的异同

Worker 是无 UI 的线程, 无法调用 UI 相关的 DOM/BOM API. Worker 具体支持的 API 可参考 MDN 的 functions and classes available to workers.

图片来源

上图展示了 Worker 线程与主线程的异同点. Worker 运行环境与主线程的共同点主要包括:

  • 包含完整的 JS 运行时, 支持 ECMAScript 规范定义的语言语法和内置对象.
  • 支持 XmlHttpRequest, 能独立发送网络请求与后台交互.
  • 包含只读的 Location, 指向 Worker 线程执行的 script url, 可通过 url 传递参数给 Worker 环境.
  • 包含只读的 Navigator, 用于获取浏览器信息, 如通过 Navigator.userAgent 识别浏览器.
  • 支持 setTimeout / setInterval 计时器, 可用于实现异步逻辑.
  • 支持 WebSocket 进行网络 I/O; 支持 IndexedDB 进行文件 I/O.

从共同点上看, Worker 线程其实很强大, 除了利用独立线程执行重度逻辑外, 其网络 I/O 和文件 I/O 能力给业务和技术方案带来很大的想象空间.

另一方面, Worker 线程运行环境和主线程的差异点有:

  • Worker 线程没有 DOM API, 无法新建和操作 DOM; 也无法访问到主线程的 DOM Element.
  • Worker 线程和主线程间内存独立, Worker 线程无法访问页面上的全局变量(window, document 等)和 JS 函数.
  • Worker 线程不能调用 alert() 或 confirm() 等 UI 相关的 BOM API.
  • Worker 线程被主线程控制, 主线程可以新建和销毁 Worker.
  • Worker 线程可以通过 self.close 自行销毁.

从差异点上看, Worker 线程无法染指 UI, 并受主线程控制, 适合默默干活.

Worker 在不同浏览器上的差异

各家浏览器实现 Worker 规范有差异, 对比主线程, 部分 API 功能不完备, 如:

  • IE10 发送的 AJAX 请求没有 referer, 请求可能被后台服务器拒绝.
  • Edge18 上字符编码/ Buffer 的实现有问题.

好在这种场景并不多. 并且可以在运行时通过错误监控发现问题, 并定位和修复(polyfill).

另一方面, 一些新增的 HTML 规范 API 只在较新的浏览器上实现, Worker 运行环境甚至主线程上没有, 使用 Worker 时需判断和兼容.

多线程同构代码

Worker 线程不支持 DOM, 这点和 Node.js 非常像. 我们在 Node.js 上做前后端同构的 SSR 时, 经常会遇到调用 BOM/DOM API 导致的报错. 如下图所示:

在开发 Worker 前端项目或迁移已有业务代码到 Worker 中时, 同构代码比例可能很高, 容易调到 BOM/DOM API. 可以通过构建变量区分代码逻辑, 或运行时动态判断所在线程, 实现同构代码在不同线程环境下运行.

通信速度

Worker 多线程虽然实现了 JS 任务的并行运行, 也带来额外的通信开销. 如下图所示, 从线程A 调用 postMessage 发送数据到线程B onmessage 接收到数据有时间差, 这段时间差称为通信消耗.

图片来源

提升的性能 = 并行提升的性能 – 通信消耗的性能. 在线程计算能力固定的情况下, 要通过多线程提升更多性能, 需要尽量减少通信消耗.

而且主线程 postMessage 会占用主线程同步执行, 占用时间与数据传输方式和数据规模相关. 要避免多线程通信导致的主线程卡顿, 需选择合适的传输方式, 并控制每个渲染周期内的数据传输规模.

数据传输方式

我们先来聊聊主线程和 Worker 线程的数据传输方式. 根据计算机进程模型, 主线程和 Worker 线程属于同一进程, 可以访问和操作进程的内存空间. 但为了降低多线程并发的逻辑复杂度, 部分传输方式直接隔离了线程间的内存, 相当于默认加了锁.

通信方式有 3 种: Structured Clone, Transfer Memory 和 Shared Array Buffer.

Structured Clone

Structured Clone 是 postMessage 默认的通信方式. 如下图所示, 复制一份线程A 的 JS Object 内存给到线程B, 线程B 能获取和操作新复制的内存.

Structured Clone 通过复制内存的方式简单有效地隔离不同线程内存, 避免冲突; 且传输的 Object 数据结构很灵活. 但复制过程中, 线程A 要同步执行 Object Serialization, 线程B 要同步执行 Object Deserialization; 如果 Object 规模过大, 会占用大量的线程时间.

Transfer Memory

Transfer Memory 意为转移内存, 它不需要 Serialization/Deserialization, 能大大减少传输过程占用的线程时间. 如下图所示 , 线程A 将指定内存的所有权和操作权转给线程B, 但转让后线程A 无法再访问这块内存.

Transfer Memory 以失去控制权来换取高效传输, 通过内存独占给多线程并发加锁. 但只能转让 ArrayBuffer 等大小规整的二进制(Raw Binary)数据; 对矩阵数据(如 RGB 图片)比较适用. 实践上也要考虑从 JS Object 生成二进制数据的运算成本.

Shared Array Buffers

Shared Array Buffer 是共享内存, 线程A 和线程B 可以同时访问和操作同一块内存空间. 数据都共享了, 也就没有传输什么事了.

但多个并行的线程共享内存, 会产生竞争问题(Race Conditions). 不像前 2 种传输方式默认加锁, Shared Array Buffers 把难题抛给开发者, 开发者可以用 Atomics 来维护这块共享的内存. 作为较新的传输方式, 浏览器兼容性可想而知, 目前只有 Chrome 68+ 支持.

传输方式小结

  • 全浏览器兼容的 Structured Clone 是较好的选择, 但要考虑数据传输规模, 下文我们会详细展开.
  • Transfer Memory 的兼容性也不错(IE11+), 但数据独占和数据类型的限制, 使得它是特定场景的最优解, 不是通用解;
  • Shared Array Buffers 当下糟糕的兼容性和线程锁的开发成本, 建议先暗中观察.

JSON.stringify 更快?

使用 Structured Clone 传输数据时, 有个阴影一直笼罩着我们: postMessage 前要不要对数据 JSON.stringify 一把, 听说那样更快?

2016 年的 High-performance Web Worker messages 进行了测试, 确实如此. 但是文章的测试结果也只能停留在 2016 年. 2019 年 Surma 进行新的测试: 如下图所示, 横轴上相同的数据规模, 直接 postMessage 的传输时间普遍比 JSON.stringify 更少.

图片来源

2020 年的当下, 不需要再使用 JSON.stringify. 其一是 Structured Clone 内置的 serialize/deserialize 比 JSON.stringify 性能更高; 其二是 JSON.stringify 只适合序列化基本数据类型, 而 Structured Clone 还支持复制其他内置数据类型(如 Map, Blob, RegExp 等, 虽然大部分应用场景只用到基本数据类型).

数据传输规模

我们再来聊聊 Structured Clone 的数据传输规模. Structured Clone 的 serialize/deserialize 执行耗时主要受数据对象复杂度影响, 这很好理解, 因为 serialize/deserialize 至少要以某种方式遍历对象. 数据对象的复杂度本身难以度量, 可以用序列化后的数据规模(size)作为参考.

2015 年的 How fast are web workers中等性能手机上进行了测试: postMessage 发送数组的通信速率为 80KB/ms, 相当于理想渲染周期(16ms)内发送 1300KB.

2019 年 Surma 对 postMessage 的数据传输能力进行了更深入研究, 具体见 Is postMessage slow. 高性能机器(macbook) 上的测试结果如下图所示:

图片来源

其中:

  • 测试数据为嵌套层数 1 到 6 层(payload depth, 图中纵坐标), 每层节点的子节点 1 到 6 个(payload breadth, 图中横坐标)的对象, 数据规模从 10B 到 10MB.
  • 在 macbook 上, 10MB 的数据传递耗时 47ms, 16ms 内可以传递 1MB 级别的数据.

低性能机器(nokia2) 上的测试结果如下图所示:

图片来源

其中:

  • 在 nokia2 上传输 10MB 的数据耗时 638ms, 16ms 内可以传递 10KB 级别的数据.
  • 高性能机器和低性能机器有超过 10 倍的传输效率差距.

不管用户侧的机器性能如何, 用户对流畅的感受是一致的: 前端同学的老朋友 16ms 和 100ms. Surma 兼顾低性能机型上 postMessage 容易造成主线程卡顿, 提出的数据传输规模建议是:

  • 如果 JS 代码里面不包括动画渲染(100ms), 数据传输规模应该保持在 100KB 以下;
  • 如果 JS 代码里面包括动画渲染(16ms), 数据传输规模应该保持在 10KB 以下.

笔者认为, Surma 给出的建议偏保守, 传输规模可以再大一些.

总之, 数据传输规模并没有最佳实践. 而是充分理解 Worker postMessage 的传输成本, 在实际应用中, 根据业务场景去评估和控制数据规模.

兼容性

兼容性是前端技术方案评估中需要关注的问题. 对 Web Worker 更是如此, 因为 Worker 的多线程能力, 要么业务场景完全用不上; 要么一用就是重度依赖的基础能力.

兼容性还不错

从前文 Worker 的历史和 兼容性视图 上看, Worker 的兼容性应该挺好的.

如上图所示, 主流浏览器在几年前就支持 Worker.

PC端:

  • IE10(2012/09)
  • Chrome4(2010/01)
  • Safari4(2009)
  • Firefox3.5(2009)

移动端:

  • iOS5(2012)
  • Android4.4(2013)

可用性评估指标

使用 Worker 并不是一锤子买卖, 我们不止关注浏览器 Worker 能力的有或没有; 也关注 Worker 能力是否完备可用. 为此笔者设计了以下几个指标来评估 Worker 可用性:

  • 是否有 Worker 能力: 通过浏览器是否有 window.Worker 来判断.
  • 能否实例化 Worker: 通过监控 new Worker() 是否报错来判断.
  • 能否跨线程通信: 通过测试双向通信来验证, 并设置超时.
  • 首次通信耗时: 页面开始加载 Worker 脚本到首次通讯完成的耗时. 该指标与 JS 资源加载时长, 同步逻辑执行耗时相关.

统计数据

有了可用性评估指标, 就可以给出量化的兼容性统计数据. 你将看到的, 是开放社区上唯一一份量化数据, 2019~2020 年某大型前端项目(亿级 MAU)的统计结果(By AlloyTeam alloy-worker).

其中:

  • 有 Worker 能力的终端超过 99.91%.
  • Worker 能力完全可用的终端达到 99.58%.
  • 而且 99.58% 到 99.91% 的差距大部分由于通信超时.

小结

可见当下浏览器已经较好地支持 Worker, 只要对 0.09% 的不支持浏览器做好回退策略(如展示一个 tip), Worker 可以放心地应用到前端业务中.

调试工具用法

前端工程师对 Worker 多线程开发方式比较陌生, 对开发中的 Worker 代码调试也是如此. 本章以 Chrome 和 IE10 为例简单介绍调试工具用法. 示例页面为 https://alloyteam.github.io/alloy-worker, 感兴趣的同学可以打开页面调试一把.

Chrome 调试

Chrome 已完善支持 Worker 代码调试, 开发者面板中的调试方式与主线程 JS 一致.

Console 调试

Console Panel 中可以查看页面全部的 JS 运行环境, 并通过下拉框切换调试的当前环境. 如下图所示, 其中 top 表示主线程的 JS 运行环境, alloyWorker--test 表示 Worker 线程的 JS 运行环境.

切换到 alloyWorker--test 后, 就可以在 Worker 运行环境中执行调试代码. 如下图所示, Worker 环境的全局对象为 self, 类型为 DedicatedWorkerGlobalScope.

断点调试

Worker 断点调试方式和主线程一致: 源码中添加 debugger 标识的代码位置会作为断点. 在 Sources Panel 查看页面源码时, 如下图所示, 左侧面板展示 Worker 线程的 alloy-worker.js资源; 运行到 Worker 线程断点时, 右侧的 Threads 提示所在的运行环境是名为 alloyWorker--test 的 Worker 线程.

性能调试

使用 Performance Panel 的录制功能即可. 如下图红框所示, Performance 中也记录了 Worker 线程的运行情况.

查看内存占用

Worker 的使用场景偏向数据和运算, 开发中适时回顾 Worker 线程的内存占用, 避免内存泄露干扰整个 Render Process. 如下图所示, 在 Memory Panel 中 alloyWorker-test 线程占用的内存为 1.2M.

IE10 调试

在比较极端的情况下, 我们需要到 IE10 这种老旧的浏览器上定位代码兼容性问题. 好在 IE10 也支持 Worker 源码调试. 可以参考微软官方文档, 具体步骤为:

  • F12 打开调试工具, 在 Script Panel 中, 开始是看不到 Worker 线程源码的, 点击 Start debugging, 就能看到 Worker 线程的 alloy-worker.js 源码.

  • 在 Worker 源码上打断点, 就能进行调试.

数据流调试

跨线程通信数据流是开发和调试中比较复杂的部分. 因为页面上可能有多个 Worker 实例; Worker 实例上有不同的数据类型(payload); 而且相同类型的通信可能会多次发起.

通过 onmessage 回调打 log 调试数据流时, 建议添加当前 Worker 实例名称, 通信类型, 通信负载等信息. 以 alloy-worker 调试模式的 log 为例:

如上图所示:

  • 每行信息包括: 线程名称, [时间戳, 会话 Id, 事务类型, 事务负载].
  • 绿色的向下箭头(⬇)表示 Worker 线程收到的信息.
  • 粉红的向上箭头(⬆)表示 Worker 线程发出的信息.

社区配套工具

现代化前端开发都采用模块化的方式组织代码, 使用 Web Worker 需将模块源码构建为单一资源(worker.js). 另一方面, Worker 原生的 postMessage/onmessage 通信 API 在使用上并不顺手, 复杂场景下往往需要进行通信封装和数据约定.

因此, 开源社区提供了相关的配套工具, 主解决 2 个关键问题:

  • Worker 代码打包. 将模块化的多个文件, 打包为单一 JS 资源.
  • Worker 通信封装. 封装多线程通信, 简化调用; 或约定通信负载的数据格式.

下面介绍社区的一些主要工具, star 数统计时间为 2020.06.

worker-loader (1.1k star)

Webpack 官方的 Worker loader. 负责将 Worker 源码打包为单个 chunk; chunk 可以是独立文件, 或 inline 的 Blob 资源.
输出内嵌 new Worker() 的 function, 通过调用该 function 实例化 Worker.

但 worker-loader 没有提供构建后的 Worker 资源 url, 上层业务进行定制有困难. 已有相关 issue 讨论该问题; worker-loader 也不对通信方式做额外处理.

worker-plugin (1.6k star)

GoogleChromeLabs 提供的 Webpack 构建 plugin.

作为 plugin, 支持 Worker 和 SharedWorker 的构建. 无需入侵源码, 通过解析源码中 new Workernew SharedWorker 语法, 自动完成 JS 资源的构建打包. 也提供 loader 功能: 打包资源并且返回资源 url, 这点比 worker-loader 有优势.

comlink (6.2k star)

也来自 GoogleChromeLabs 团队, 由 Surma 开发. 基于 ES6 的 Proxy 能力, 对 postMessage 进行 RPC
(Remote Procedure Call) 封装, 将跨线程的函数调用封装为 Promise 调用.

但它不涉及 Worker 资源构建打包, 需要其他配套工具. 且 Proxy 在部分浏览器中需要 polyfill, 可 polyfill 程度存疑.

workerize-loader (1.7k star)

目前社区比较完整, 且兼容性好的方案.

类似 worker-loader + comlink 的合体. 但不是基于 Proxy, 而在构建时根据源码 AST 提取出调用函数名称, 在另一线程内置同名函数; 封装跨线程函数为 RPC 调用.

与 workerize-loader 关联的另一个项目是 workerize (3.8k star). 支持手写文本函数, 内部封装为 RPC; 但手写文本函数实用性不强.

userWorker (1.8k star)

很有趣的项目, 将 Worker 封装为 React Hook. 基本原理是: 将传入 Hook 的函数处理为 BlobUrl 去实例化 Worker. 因为会把函数转为 BlobUrl 的字符串形式, 限制了函数不能有外部依赖, 函数体中也不能调用其他函数.

比较适合一次性使用的纯函数, 函数复杂度受限.

其他可参考项目

现有工具缺陷

现有的社区工具解决了 Worker 技术应用上的一些难点, 但目前还有些不足:

  • Web Worker 并不是 100% 可用的, 社区工具并没有给出回退方案.
  • 对大规模使用的场景, 代码的组织架构和构建方式并没较好的方案.
  • 部分工具在通信数据约定上缺乏强约束, 可能导致运行时意外的错误.
  • 支持 TypeScript 源码的较少, 编辑器中的函数提示也有障碍.

以上不足促使笔者开源了 alloy-worker, 面向事务的高可用 Web Worker 通信框架.
更加详细的工具讨论, 请查阅 alloy-worker 的业界方案对比.

业界实践回顾

实践场景

Web Worker 作为浏览器多线程技术, 在页面内容不断丰富, 功能日趋复杂的当下, 成为缓解页面卡顿, 提升应用性能的可选方案.

2010 年

2010 年, 文章 The Basics of Web Workers 列举的 Worker 可用场景如下:

2010 年的应用场景主要涉及数据处理, 文本处理, 图像/视频处理, 网络处理等.

当下

2018 年, 文章 Parallel programming in JavaScript using Web Workers 列举的 Worker 可用场景如下:

可见, 近年来 Worker 的场景比 2010 年更丰富, 拓展到了 Canvas drawing(离屏渲染方面), Virtual DOM diffing(前端框架方面), indexedDB(本地存储方面), Webassembly(编译型语言方面)等.

总的来说, Worker 对页面的计算任务/后台任务有用武之地. 接下来笔者将分享的一些具体 case, 并进行简析.

重度计算场景

石墨表格之 Web Worker 应用实战

2017 年的文章, 非常好的实践. 在线表格排序是 CPU 密集型场景, 复杂任务原子化和异步化后依然难以消除页面卡顿. 将排序迁移到 Worker 后, 对 2500 行数据的排序操作, Scripting 时间从 9984ms 减少到 3650ms .

Making TensorflowJS work faster with WebWorkers

2020 年的文章, 使用生动的图例说明 TF.js 在主线程运行造成的掉帧. 以实时摄像头视频的动作检测为例子, 通过 Worker 实现视频动画不卡顿(16ms内); 动作检测耗时 50ms, 但是不阻塞视频, 也有约 15FPS.

腾讯文档 Excel 函数实践

笔者撰写文章中, 近期发布.

前端框架场景

neo -- webworkers driven UI framework

2019 年开源的 Worker 驱动前端框架. 其将前端框架的拆分为 3 个 Worker: App Worker, Data Worker 和 Vdom Worker. 主线程只需要维护 DOM 和代理 DOM 事件到 App Worker 中; Data Worker 负责进行后台请求和托管数据 store; Vdom Worker 将模板字符串转换为虚拟节点, 并对每次变化生成增量去更新.

worker-dom

Google AMP 项目一部分. 在 Worker 中实现 DOM 操作 API 和 DOM 事件监听, 并将 DOM 变化应用到主线程真实 DOM 上. 官方 Demo 在 Worker 中直接引入 React 并实现 render!

Angular

Angular8 CLI 支持创建 Web Worker 指令, 并将耗 CPU 计算迁移到 Worker 中; 但是 Angular 本身并不能在 Worker 中运行. 官网 angular.io 也用 Worker 来提升搜索性能.

数据流场景

Off-main-thread React Redux with Performance

2019 年的文章. 将 Reduxaction 部分迁移到 Worker 中, 开源了项目 redux-in-worker.
做了 Worker Redux 的 benchmark: 和主线程相差不大(但是不卡了).

Off Main Thread Architecture with Vuex

2019 年的文章. 简单分析 UI 线程过载和 Worker 并发能力. 对 Vue 数据流框架 Vuex 进行分解, 发现 action 可以包含异步操作, 适合迁移到 Worker. 实现了 action 的封装函数和质数生成的 demo.

可视化场景

PROXX

PROXX 是 GoogleChromeLabs 开发的在线扫雷游戏, 其 Worker 能力由 Surma 开发的 Comlink 提供. Surma 特地开发了 Worker 版本和非 Worker 版本: 在高性能机型 Pixel3 和 MacBook 上, 两者差异不大; 但在低性能机型 Nokia2 上, 非 Worker 版本点击动作卡了 6.6s, Worker 版本点击回调需要 48ms.

图片风格处理

2013 年的文章. 使用 Worker 将图片处理为复古色调. 在当年先进的 12 核机器上, 使用 4 个 Worker 线程后, 处理时间从 150ms 减低到 80ms; 在当年的双核机器上, 处理时间从 900ms 减低到 500ms.

OpenCV directly in the browser (webassembly + webworker)

2020 的文章. 基于 OpenCV 项目, 将项目编译为 webassembly, 并且在 Worker 中动态加载 opencv.js, 实现了图片的灰度处理.

大型项目

OffscreenCanvas

Chrome69+ 支持, 能将主线程 Canvas 的绘制权 transfer 给 Worker 线程的 OffscreenCanvas, 在 Worker 中绘制后渲染直接到页面上; 也支持在 Worker 中新建 Canvas 绘制图形, 通过 imagebitmap transfer 到主线程展示.

hls.js

hls 是基于 JS 实现的 HTTP 实时流媒体播放库. 其使用 Worker 用于流数据的解复用(demuxer), 使用 Transfer Memory 来最小化传输的消耗.

pdf.js

判断浏览器是否支持 Worker 能力, 有 Worker 能力时将 pdf 文件解析放在 Worker 线程中.

相关视频/分享 PPT

Web Workers -- I like the way you work it

2016年的分享 ppt, Pokedex.org 项目在 Web Worker 中进行 Virtual DOM 的更新, 显著提升快速滚动下的渲染效率.

The main thread is overworked & underpaid

Chrome Dev Summit 2019, 非常精彩的分享, 来自 google 的工程师 Surma. 演讲指出页面主线程工作量过大, 特别是发展中国家有大量的低性能设备. 运算在 Worker 慢一点但页面不掉帧优于运算在主线程快一点但卡顿.

Is postMessage slow? - HTTP 203

同样来自 Surma 的技术访谈. 主要讨论 postMessage 的性能问题. 本文在通信速度部分大量引用 Surma 的研究.

Surma 在 Worker 领域写了多篇文章, 并开源了 Comlink.

前端項目上 Web Worker 實踐

2019 年的演讲, 笔者前同事, 曾在 Worker 实践上紧密合作. 演讲讨论 Web Worker 的使用场景; Worker 的注意点和适应多线程的代码改造; 以及实践中遇到的问题和解决方案.

Weaving Webs of Workers

2019 年的演讲, 来自 Netflix 的工程师. 总结使用 Web Worker 遇到的 4 大问题, 并通过引入社区多个配套工具逐一解决.

Web Workers: A graphical introduction

2018年的演讲, 讲多线程和 postMessage 数据传递部分图很漂亮. 将 Web Worker 应用在他开发的 Web 钢琴弹奏器.

What the heack is the event Loop anyway

2014年的演讲, 使用生动的图例介绍主线程 Event Loop.

实践建议

如上文所述, 社区已有许多 Worker 技术的应用实践. 如果你的业务也有使用 Worker 的需求, 以下是几个实践的建议.

也许你不需要 Worker

使用 Worker 是有成本的: Worker 线程会占用系统资源; 同构代码和异步通信会增加维护成本; 多线程编程会挑战前端仔的思维.

David 的文章指出, 迫切需要 Worker 的场景并不多, 开发者需要考虑投入效益比. 简单来说, 如果页面的某个操作会耗时, 同时不想让用户察觉(转菊花), 那就用 Worker 吧.

Worker 应该是常驻线程

虽然 Worker 规范提供了 terminate API 来结束 Worker 线程, 但线程的频繁新建会消耗资源. 大多数场景下, Worker 线程应该用作常驻的线程. 开发中优先复用常驻线程.

控制 Worker 线程数目

这也很好理解, Worker 线程在争取 CPU 计算资源时, 受限于 CPU 的核心数, 过多的线程并不能线性地提升性能, 而每个 Worker 线程会有约 1M 的固有内存消耗.

理解多线程开发方式

多线程开发的思维和方式, 是个比较大的话题. 开发者需要控制线程间的通信规模, 减少线程间数据和状态的依赖, 尝试去了解和控制 Worker 线程.

展望

本文试图梳理 2020 年当下 Web Worker 技术的现状和发展.

从现状上看, Worker 已经普遍可用, 业界也有业务和框架上的实践, 但在配套工具上仍有不足.

从发展趋势上看, Worker 的多线程能力有望成为复杂前端项目的标配, 在减少 UI 线程卡顿和压榨计算机性能上有收益. 但目前国内实践较少, 一方面是业务复杂程度未触及; 另一方面是社区缺少科普和实践分享.

前端多线程开发正当时. 笔者维护的 Worker 通信框架 alloy-worker 已经开源, 大型前端项目落地的文章正在路上. 鸡汤和勺子都给了, 加点老干妈, 真香!

References

  • alloy-worker
https://github.com/AlloyWorker/alloy-worker
  • Web Workers API
https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API
  • Remove shared workers?
whatwg/html#315
  • Using web Workers
https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers
  • Web Workers Working Draft
https://www.w3.org/TR/workers/
  • using web workers: working smarter, not harder (2009 年 firefox 上的实践)
https://hacks.mozilla.org/2009/07/working-smarter-not-harder/
  • Is postMessage slow? (数据通信实验设计)
https://dassur.ma/things/is-postmessage-slow/
  • 另眼看 Web Worker (讨论异步化编程)
https://www.ithome.com.tw/voice/132997
  • The Basics of Web Workers (2010, 谈到错误处理和安全限制)
https://www.html5rocks.com/en/tutorials/workers/basics/
  • Blink Workers (Blink 框架 Worker 实现介绍)
https://docs.google.com/document/d/1i3IA3TG00rpQ7MKlpNFYUF6EfLcV01_Cv3IYG_DjF7M/edit#heading=h.7smox3ra3f6n
  • Should you be using Web Workers (配图非常棒)
https://medium.com/@david.gilbertson/should-you-should-be-using-web-workers-hint-probably-not-9b6d26dc8c6a
  • How JavaScript works
https://blog.sessionstack.com/how-javascript-works-the-building-blocks-of-web-workers-5-cases-when-you-should-use-them-a547c0757f6a
  • Parallel programming in JavaScript using Web Workers
https://itnext.io/achieving-parallelism-in-javascript-using-web-workers-8f921f2d26db
  • So you want to use a Web Worker
https://povioremote.com/blog/so-you-want-to-use-a-web-worker/

EOF

AlloyTeam 欢迎优秀的小伙伴加入。
简历投递: alloyteam@qq.com
详情可点击 腾讯AlloyTeam招募Web前端工程师(社招)

clipboard.png

查看原文

赞 19 收藏 13 评论 0

腾讯AlloyTeam 发布了文章 · 2020-04-15

探索 webpack5 新特性 Module federation 在腾讯文档的应用

原文:探索webpack5新特性Module federation在腾讯文档的应用 | AlloyTeam
作者:TAT.jay

前言:

webpack5的令人激动的新特性Module federation可能并不会让很多开发者激动,但是对于深受多应用伤害的腾讯文档来说,却是着实让人眼前一亮,这篇文章就带你了解腾讯文档的困境以及Module federation可以如何帮助我们走出这个困境。

0x1 腾讯文档的困境

1.1 多应用场景背景

腾讯文档从功能层面上来说,用户最熟悉的可能就是word、excel、ppt、表单这四个大品类,四个品类彼此独立,可能由不同的团队主要负责开发维护,那从开发者角度来说,四个品类四个仓库各自独立维护,好像事情就很简单,但是现实情况实际上却复杂很多。我们来看一个场景:

通知中心的需求

image-20200329125332068

对于复杂的权限场景,为了让使用者能快速能获得最新状态,我们实际上有一个通知中心的需求,在pc的样式大致就是上图里面的样子。这里是在腾讯文档的列表页看到的入口,实际上在上面提到的四大品类里面,都需要嵌入这样的一个页面。

那么问题来了,为了最小化这里的开发和维护成本,肯定是各个品类公用一套代码是最好的,那最容易想到的就是使用独立npm包的方式来引入。确实,腾讯文档的内部很多功能现在是使用npm包的方式来引入的,但是实际上这里会遇到一些问题:

问题一:历史代码

腾讯文档的历史很复杂,简而言之,在刚开始的时候,代码里面是不支持写ES6的,所以没办法引入npm包。一时半会想改造完成是不现实的,产品需求也不会等你去完成这样的改造

问题二:发布效率

这里的问题实际上也是现在我们使用npm包的问题,其实还是我们懒,想投机取巧。以npm包的方式引入的话,一旦有改动,你需要改5个仓库(四个品类+列表页)去升级这里的版本,实际上发布成本是蛮大的,对于开发者来说其实也很痛苦

1.2 我们的解决方案

为了能在不支持ES6代码的环境下快速引入React来加速需求开发,我们想出了一个所谓的Script-Loader(下面会简称SL)的模式。

整体架构如图:

image-20200329131110457

简单来说就是,参考jquery的引入方式,我们用另外一个项目去实现这些功能,然后把代码打包成ES5代码,对外提供很多接口,然后在各个品类页,引入我们提供的加载脚本,内部会自动去加载文件,获取每个模块的js文件的CDN地址并且加载。这样做到各个模块各自独立,并且所有模块和各个品类形成独立。

在这种模式下,每次发布,我们只需要去发布各个改动的模块以及最新的配置文件,其他品类就能获得自动更新。

这个模式并不一定适合所有项目,也不一定是最好的解决方案,从现在的角度来看,有点像微前端的概念,但是实际上却也是有区别的,这里就不展开了。这种模式目前确实能解决腾讯文档这种多应用复用代码的需求。

1.3 遇到的问题

这种模式本质上目前没有很严重的问题,但是有一个很痛点一直困扰我们,那就是品类代码和SL的代码共享问题。举个例子:

Excel品类改造后使用了React,SL的模块A、模块B、模块C引入了React

因为SL的模块之间是各自独立的,所以React也是各自打包的,那就是说当你打开Excel的时候,如果你用了模块A、B、C,那你最终页面会加载四份React代码,虽然不会带上什么问题,但是对于有追求的前端来说,我们还是想去解决这样的问题。

解决方案: External

对于React来说,我们可以默认品类是加载了React,所以我们直接把SL里面的React配置为External,这样就不会打包了,但是实际上情况没有这么简单:

问题一:模块可能独立页面

就以上面的通知中心来说,在移动端上面就不是嵌入的了,而且独立页面,所以这个独立页面需要你手动引入React

问题二:公共包不匹配

简单来说,就是SL依赖的包,在品类里面可能并没有使用,例如Mobx或者Redux

问题三:不是所有包都可以直接配置External

这里的问题是说像React这种包我们可以通过配置External为window.React来达到共用,但是不是所有包都可以这样的,那对于不能配置为全局环境的包来说,还没法解决这里的代码共享问题

基于这些问题,我们目前的选择是一种折中方案,我们把可以配置全局环境的包提取出来,每个模块指明依赖,然后在SL内部,加载模块代码之前会去检测依赖,依赖加载完成才会加载执行实际模块代码。

这种方式有很大问题,你需要手动去维护这样的依赖,每个共享包实际上你都是需要单独打包成一个CDN文件,为的是当依赖检测失败的时候,可以有一个兜底加载文件。因此,实际上目前也只有React包做了这个共享。

那么到这里,核心问题就变成了品类代码和SL如何做到代码共享。对于其他项目来说,其实也就是多应用如何做到代码共享

0x2 webpack的打包原理

为了解决上面的问题,我们实际上想从webpack入手,去实现这样的一个插件帮我们解决这个问题。核心思路就是hook webpack的内部require函数,在这之前我们先来看一下webpack打包后的一些原理,这个也是后面理解Module federation的核心。如果这里你比较熟悉,也可以快速跳过到第三节,但是不熟悉的同学还是建议认真了解一下。

2.1 chunk和module

webpack里面有两个很核心的概念,叫chunk和module,这里为了简单,只看js相关的,用笔者自己的理解去解释一下他们直接的区别:

module:每一个源码js文件其实都可以看成一个module

chunk:每一个打包落地的js文件其实都是一个chunk,每个chunk都包含很多module

默认的chunk数量实际上是由你的入口文件的js数量决定的,但是如果你配置动态加载或者提取公共包的话,也会生成新的chunk。

2.2 打包代码解读

有了基本理解后,我们需要去理解webpack打包后的代码在浏览器端是如何加载执行的。为此我们准备一个非常简单的demo,来看一下它的生成文件。

src
---main.js
---moduleA.js
---moduleB.js

/**
* moduleA.js
*/
export default function testA() {
    console.log('this is A');
}


/**
* main.js
*/
import testA from './moduleA';

testA();

import('./moduleB').then(module => {

});

非常简单,入口js是main.js,里面就是直接引入moduleA.js,然后动态引入 moduleB.js,那么最终生成的文件就是两个chunk,分别是:

  1. main.jsmoduleA.js组成的bundle.js
  2. `moduleB.js组成的0.bundle.js

如果你了解webpack底层原理的话,那你会知道这里是用mainTemplate和chunkTemplate分别渲染出来的,不了解也没关系,我们继续解读生成的代码

import变成了什么样

整个main.js的代码打包后是下面这样的

(function (module, __webpack_exports__, __webpack_require__) {

    "use strict";
    __webpack_require__.r(__webpack_exports__);
    /* harmony import */
    var _moduleA__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__( /*!         ./moduleA */ "./src/moduleA.js");


    Object(_moduleA__WEBPACK_IMPORTED_MODULE_0__["default"])();

    __webpack_require__.e( /*! import() */ 0).then(__webpack_require__.bind(null, /*! ./moduleB             */ "./src/moduleB.js")).then(module => {

    });

})

可以看到,我们的直接import moduleA最后会变成webpack_require,而这个函数是webpack打包后的一个核心函数,就是解决依赖引入的。

webpack_require是怎么实现的

那我们看一下webpack_require它是怎么实现的:

function __webpack_require__(moduleId) {
    // Check if module is in cache
    // 先检查模块是否已经加载过了,如果加载过了直接返回
    if (installedModules[moduleId]) {
        return installedModules[moduleId].exports;
    }
    // Create a new module (and put it into the cache)
    // 如果一个import的模块是第一次加载,那之前必然没有加载过,就会去执行加载过程
    var module = installedModules[moduleId] = {
        i: moduleId,
        l: false,
        exports: {}
    };
    // Execute the module function
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    // Flag the module as loaded
    module.l = true;
    // Return the exports of the module
    return module.exports;
}

如果简化一下它的实现,其实很简单,就是每次require,先去缓存的installedModules这个缓存map里面看是否加载过了,如果没有加载过,那就从modules这个所有模块的map里去加载。

modules从哪里来的

那相信很多人都有疑问了,modules这么个至关重要的map是从哪里来的呢,我们把bundle.js生成的js再简化一下:

(function (modules) {})({
    "./src/main.js": (function (module, __webpack_exports__, __webpack_require__) {}),
    "./src/moduleA.js": (function (module, __webpack_exports__, __webpack_require__) {})
});

所以可以看到,这其实是个立即执行函数,modules就是函数的入参,具体值就是我们包含的所有module,到此,一个chunk是如何加载的,以及chunk如何包含module,相信大家一定会有自己的理解了。

动态引入如何操作呢

上面的chunk就是一个js文件,所以维护了自己的局部modules,然后自己使用没啥问题,但是动态引入我们知道是会生成一个新的js文件的,那这个新的js文件0.bundle.js里面是不是也有自己的modules呢?那bundle.js如何知道0.bundle.js里面的modules

先看动态import的代码变成了什么样:

__webpack_require__.e( /*! import() */ 0).then(__webpack_require__.bind(null, /*! ./moduleB             */ "./src/moduleB.js")).then(module => {

});

从代码看,实际上就是外面套了一层webpck_require.e,然后这是一个promise,在then里面再去执行webpack_require。

实际上webpck_require.e就是去加载chunk的js文件0.bundle.js,具体代码就不贴了,没啥特别的。

等到加载回来后它认为bundle.js里面的modules就一定会有了0.bundle.js包含的那些modules,这是如何做到的呢?

我们看0.bundle.js到底是什么内容,让它拥有这样的魔力:

(window["webpackJsonp"] = window["webpackJsonp"] || []).push(
    [
        [0],
        {
            "./src/moduleB.js": (function (module, __webpack_exports__, __webpack_require__) {})
        }
    ]
);

拿简化后的代码一看,大家第一眼想到的是jsonp,但是很遗憾的是它不是一个函数,却只是向一个全局数组里面push了自己的模块id以及对应的modules。那看起来魔法的核心应该是在bundle.js里面了,事实的确也是如此。

var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
jsonpArray.push = webpackJsonpCallback;
jsonpArray = jsonpArray.slice();
for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
var parentJsonpFunction = oldJsonpFunction;

bundle.js的里面,我们看到这么一段代码,其实就是说我们劫持了push函数,那0.bundle.js一旦加载完成,我们岂不是就会执行这里,那不就能拿到所有的参数,然后把0.bundle.js里面的所有module加到自己的modules里面去!

2.3 总结一下

如果你没有很理解,可以配合下面的图片,再把上面的代码读几遍。

image-20200329143727089

其实简单来说就是,对于mainChunk文件,我们维护一个modules这样的所有模块map,并且提供类似webpack_require这样的函数。对于chunkA文件(可能是因为提取公共代码生成的、或者是动态加载)我们就用类似jsonp的方式,让它把自己的所有modules添加到主chunk的modules里面去。

2.4 如何解决腾讯文档的问题?

基于这样的一个理解,我们就在思考,那腾讯文档的多应用代码共享能不能解决呢?

具体到腾讯文档的实际场景,就是如下图:

image-20200329143446668

因为是独立的项目,所以webpack打包也是有两个mainChunk,然后有各自的chunk(其实这里会有chunk覆盖或者chunk里面的module覆盖问题,所以id要采用md5)。

那问题的核心就是如何打通两个mainChunk的modules

如果是自由编程,我想大家的实现方式可就太多了,但是在webpack的框架限制下面,如何快速的实现这个,我们也一直在思考方案,目前想到的方案如下:

SL模块内部的webpack_require被我们hack,每次在modules里面找不到的时候,我们去Excel的modules里面去找,这样需要把Excel的modules作为全局变量

但是对于Excel不存在的模块我们需要怎么处理?

这种很明显就是运行时环境,我们需要做好加载时的失败降级处理,但是这样就会遇到同步转异步的问题,本来你是同步引入一个模块的,但是如果它在Excel的modules不存在的时候,你就需要先一步加载这个module对应的chunk,变成了类似动态加载,但是你的代码还是同步的,这样就会有问题。

所以我们需要将依赖前置,也就是说在加载SL模块后,它知道自己依赖哪些共享模块,然后去检测是否存在,不存在则依次去加载,所有依赖就位后才开始执行自己。

0x3 webpack5的Module federation

说实话,webpack底层还是很复杂的,在不熟悉的情况下而且定制程度也不能确定,所以我们也是迟迟没有去真正做这个事情。但是偶然的机会了解到了webpack5的Module federation,通过看描述,感觉和我们想要的东西很像,于是我们开始一探究竟!

3.1 Module federation的介绍

关于Module federation是什么,有什么作用,现在已经有一些文章去说明,这里贴一篇,大家可以先去了解一下

Module federation allows a JavaScript application to dynamically run code from another bundle/build, on both client and server

简单来说就是允许运行时动态决定代码的引入和加载。

3.2 Module federation的demo

我们最关心的还是Module federation的的实现方式,才能决定它是不是真的适合腾讯文档。

这里我们用已有的demo:

module-federation-examples/basic-host-remote

在此之前,还是需要向大家介绍一下这个demo做的事情

app1
---index.js 入口文件
---bootstrap.js 启动文件
---App.js react组件

app2
---index.js 入口文件
---bootstrap.js 启动文件
---App.js react组件
---Button.js react组件

这是文件结构,其实你可以看成是两个独立应用app1和app2,那他们之前有什么爱恨情仇呢?

/** app1 **/
/**
* index.js
**/
import('./bootstrap');

/**
* bootstrap.js
**/
import('./bootstrap');
import App from "./App";
import React from "react";
import ReactDOM from "react-dom";

ReactDOM.render(<App />, document.getElementById("root"));


/**
* App.js
**/
import('./bootstrap');
import React from "react";

import RemoteButton from 'app2/Button';

const App = () => (
  <div>
    <h1>Basic Host-Remote</h1>
    <h2>App 1</h2>
    <React.Suspense fallback="Loading Button">
      <RemoteButton />
    </React.Suspense>
  </div>
);

export default App;

我这里只贴了app1的js代码,app2的代码你不需要关心。代码没有什么特殊的,只有一点,app1的App.js里面:

import RemoteButton from 'app2/Button';

也就是关键来了,跨应用复用代码来了!app1的代码用了app2的代码,但是这个代码最终长什么样?是如何引入app2的代码的?

3.3 Module federation的配置

先看我们的webpack需要如何配置:

/**
 * app1/webpack.js
 */
{
    plugins: [
        new ModuleFederationPlugin({
            name: "app1",
            library: {
                type: "var",
                name: "app1"
            },
            remotes: {
                app2: "app2"
            },
            shared: ["react", "react-dom"]
        })
    ]
}

这个其实就是Module federation的配置了,大概能看到想表达的意思:

  1. 用了远程模块app2,它叫app2
  2. 用了共享模块,它叫shared

remotes和shared还是有一点区别的,我们先来看效果。

生成的html文件:

<html>
  <head>
    <script data-original="app2/remoteEntry.js"></script>
  </head>
  <body>
    <div id="root"></div>
  <script data-original="app1/app1.js"></script><script data-original="app1/main.js"></script></body>
</html>

ps:这里的js路径有修改,这个是可以配置的,这里只是表明从哪里加载了哪些js文件

app1打包生成的文件:

app1/index.html
app1/app1.js
app1/main.js
app1/react.js
app1/react-dom.js
app1/src_bootstrap.js

ps: app2你也需要打包,只是我没有贴app2的代码以及配置文件,后面需要的时候会再贴出来的

最终页面表现以及加载的js:

image-20200329152614947

从上往下加载的js时序其实是很有讲究的,后面将会是解密的关键:

app2/remoteEntry.js
app1/app1.js
app1/main.js
app1/react.js
app1/react-dom.js
app2/src_button_js.js
app1/src_bootstrap.js

这里最需要关注的其实还是每个文件从哪里加载,在不去分析原理之前,看文件加载我们至少有这些结论:

  1. remotes的代码自己不打包,类似external,例如app2/button就是加载app2打包的代码
  2. shared的代码自己是有打包的

Module federation的原理

在讲解原理之前,我还是放出之前的一张图,因为这是webpack的文件模块核心,即使升级5,也没有发生变化

image-20200329152252834

app1和app2还是有自己的modules,所以实现的关键就是两个modules如何同步,或者说如何注入,那我们就来看看Module federation如何实现的。

3.3.1 import变成了什么
// import源码
import RemoteButton from 'app2/Button';

// import打包代码 在app1/src_bootstrap.js里面
/* harmony import */
var app2_Button__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__( /*! app2/Button */ "?ad8d");
/* harmony import */
var app2_Button__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/ __webpack_require__.n(app2_Button__WEBPACK_IMPORTED_MODULE_1__);

从这里来看,我们好像看不出什么,因为还是正常的webpack_require,难道说它真的像我们之前所设想的那样,重写了webpack_require吗?

遗憾的是,从源码看这个函数是没有什么变化的,所以核心点不是这里。

但是你注意看加载的js顺序:

app2/remoteEntry.js
app1/app1.js
app1/main.js
app1/react.js
app1/react-dom.js
app2/src_button_js.js // app2的button竟然先加载了,比我们的自己启动文件还前面
app1/src_bootstrap.js

回想上一节我们自己的分析

所以我们需要将依赖前置,也就是说在加载SL模块后,它知道自己依赖哪些共享模块,然后去检测是否存在,不存在依次去加载,所以依赖就位后才开始执行自己。

所以它是不是通过依赖前置来解决的呢?

3.3.2 main.js文件内容

因为html里面和app1相关的只有两个文件:app1/app1.js以及app1/main.js

那我们看看main.js到底写了啥

(() => { // webpackBootstrap
    var __webpack_modules__ = ({})

    var __webpack_module_cache__ = {};

    function __webpack_require__(moduleId) {

        if (__webpack_module_cache__[moduleId]) {
            return __webpack_module_cache__[moduleId].exports;
        }
        var module = __webpack_module_cache__[moduleId] = {
            exports: {}
        };
        __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
        return module.exports;
    }
    __webpack_require__.m = __webpack_modules__;

    __webpack_require__("./src/index.js");
})()

可以看到区别不大,只是把之前的modules换成了webpack_modules,然后把这个modules的初始化由参数改成了内部声明变量。

那我们来看看webpack_modules内部的实现:

var __webpack_modules__ = ({

    "./src/index.js": ((__unused_webpack_module, __unused_webpack_exports, __webpack_require__) => {
        __webpack_require__.e( /*! import() */ "src_bootstrap_js").then(__webpack_require__.bind(__webpack_require__, /*! ./bootstrap */ "./src/bootstrap.js"));
    }),

    "container-reference/app2": ((module) => {
        "use strict";
        module.exports = app2;
    }),

    "?8bfd": ((module, __unused_webpack_exports, __webpack_require__) => {
        "use strict";
        var external = __webpack_require__("container-reference/app2");
        module.exports = external;
    })
});

从代码看起来就三个module:

./src/index.js 这个看起来就是我们的app1/index.js,里面去动态加载bootstrap.js对应的chunk src_bootstrap_js
container-reference/app2 直接返回一个全局的app2,这里感觉和我们的app2有关系
?8bfd 这个字符串是我们上面提到的app2/button对应的文件引用id

那在加载src_bootstrap.js之前加载的那些react文件还有app2/button文件都是谁做的呢?通过debug,我们发现秘密就在webpack_require__.e("src_bootstrap_js")这句话

在第二节解析webpack加载的时候,我们得知了:

实际上webpck_require.e就是去加载chunk的js文件0.bundle.js,等到加载回来后它认为bundle.js里面的modules就一定会有了0.bundle.js包含的那些modules

也就是说原来的webpack_require__.e平淡无奇,就是加载一个script,以致于我们都不想去贴出它的代码,但是这次升级后一切变的不一样了,它成了关键中的关键!

3.3.3 webpack_require__.e做了什么
__webpack_require__.e = (chunkId) => {
    return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
        __webpack_require__.f[key](chunkId, promises);
        return promises;
    }, []));
};

看代码,的确发生了变化,现在底层是去调用webpack_require.f上面的函数了,等到所有函数都执行完了,才执行promise的then

那问题的核心又变成了webpack_require.f上面有哪些函数了,最后发现有三个函数:

一:overridables

/* webpack/runtime/overridables */
__webpack_require__.O = {};
var chunkMapping = {
    "src_bootstrap_js": [
        "?a75e",
        "?6365"
    ]
};
var idToNameMapping = {
    "?a75e": "react-dom",
    "?6365": "react"
};
var fallbackMapping = {
    "?a75e": () => {
        return __webpack_require__.e("vendors-node_modules_react-dom_index_js").then(() => () => __webpack_require__("./node_modules/react-dom/index.js"))
    },
    "?6365": () => {
        return __webpack_require__.e("vendors-node_modules_react_index_js").then(() => () => __webpack_require__("./node_modules/react/index.js"))
    }
};
__webpack_require__.f.overridables = (chunkId, promises) => {}

二:remotes

/* webpack/runtime/remotes loading */
var chunkMapping = {
    "src_bootstrap_js": [
        "?ad8d"
    ]
};
var idToExternalAndNameMapping = {
    "?ad8d": [
        "?8bfd",
        "Button"
    ]
};
__webpack_require__.f.remotes = (chunkId, promises) => {}

三:jsonp

/* webpack/runtime/jsonp chunk loading */
var installedChunks = {
    "main": 0
};


__webpack_require__.f.j = (chunkId, promises) => {}

这三个函数我把核心部分节选出来了,其实注释也写得比较清楚了,我还是解释一下:

  1. overridables 可覆盖的,看代码你应该已经知道和shared配置有关
  2. remotes 远程的,看代码非常明显是和remotes配置相关
  3. jsonp 这个就是原有的加载chunk函数,对应的是以前的懒加载或者公共代码提取
3.3.4 加载流程

知道了核心在webpack_require.e以及内部实现后,不知道你脑子里是不是对整个加载流程有了一定的思路,如果没有,容我来给你解析一下

  1. 先加载src_main.js,这个没什么好说的,注入在html里面的
  2. src_main.js里面执行webpack_require("./src/index.js")
  3. src/index.js这个module的逻辑很简单,就是动态加载src_bootstrap_js这个chunk
  4. 动态加载src_bootstrap_js这个chunk时,经过overridables,发现这个chunk依赖了react、react-dom,那就看是否已经加载,没有加载就去加载对应的js文件,地址也告诉你了
  5. 动态加载src_bootstrap_js这个chunk时,经过remotes,发现这个chunk依赖了?ad8d,那就去加载这个js
  6. 动态加载src_bootstrap_js这个chunk时,经过jsonp,就正常加载就好了
  7. 所有依赖以及chunk都加载完成了,就去执行then逻辑:webpack_require src_bootstrap_js里面的module:./src/bootstrap.js

到此就一切都正常启动了,其实就是我们之前提到的依赖前置,先去分析,然后生成配置文件,再去加载

看起来一切都很美好,但其实还是有一个关键信息没有解决!

3.3.5 如何知道app2的存在

上面的第4步加载react的时候,因为我们自己实际上也打包了react文件,所以当没有加载的时候,我们可以去加载一份,也知道地址

但是第五步的时候,当页面从来没有加载过app2/Button的时候,我们去什么地址加载什么文件呢?

这个时候就要用到前面我们提到的main.js里面的webpack_modules

var __webpack_modules__ = ({
        
    "container-reference/app2": 
        ((module) => {
            "use strict";
            module.exports = app2;
        }),
        
    "?8bfd":
        ((module, __unused_webpack_exports, __webpack_require__) => {
        "use strict";
            var external = __webpack_require__("container-reference/app2");
            module.exports = external;
        })
});

这里面有三个module,我们还有 ?8bfd、container-reference/app2 没有用到,我们再看一下remotes的实现

/* webpack/runtime/remotes loading */
var chunkMapping = {
    "src_bootstrap_js": [
        "?ad8d"
    ]
};
var idToExternalAndNameMapping = {
    "?ad8d": [
        "?8bfd",
        "Button"
    ]
};
__webpack_require__.f.remotes = (chunkId, promises) => {
    if (__webpack_require__.o(chunkMapping, chunkId)) {
        chunkMapping[chunkId].forEach((id) => {
            if (__webpack_modules__[id]) return;
            var data = idToExternalAndNameMapping[id];
            promises.push(Promise.resolve(__webpack_require__(data[0]).get(data[1])).then((factory) => {
                __webpack_modules__[id] = (module) => {
                    module.exports = factory();
                }
            }))
        });
    }
}

当我们加载src_bootstrap_js这个chunk时,经过remotes,发现这个chunk依赖了?ad8d,那在运行时的时候:

id = "?8bfd"
data = [
   "?8bfd",
   "Button"
]
// 源码
__webpack_require__(data[0]).get(data[1])
// 运行时
__webpack_require__('?8bfd').get("Button")

结合main.js的module ?8bfd的代码,那最终就是app2.get("Button")

这不就是个全局变量吗?看起来有些蹊跷啊!

3.3.6 再看app2/remoteEntry.js

我们好像一直忽略了这个文件,它是第一个加载的,必然有它的作用,带着对全局app2有什么蹊跷的疑问,我们去看了这个文件,果然发现了玄机!

var app2;
app2 =
    (() => {
        "use strict";
        var __webpack_modules__ = ({
            "?8619": ((__unused_webpack_module, exports, __webpack_require__) => {
                var moduleMap = {
                    "Button": () => {
                        return __webpack_require__.e("src_Button_js").then(() => () => __webpack_require__( /*! ./src/Button */ "./src/Button.js"));
                    }
                };
                var get = (module) => {
                    return (
                        __webpack_require__.o(moduleMap, module) ?
                        moduleMap[module]() :
                        Promise.resolve().then(() => {
                            throw new Error("Module " + module + " does not exist in container.");
                        })
                    );
                };
                var override = (override) => {
                    Object.assign(__webpack_require__.O, override);
                }

                __webpack_require__.d(exports, {
                    get: () => get,
                    override: () => override
                });
            })
        });
        return __webpack_require__("?8619");
    })()

如果你细心看,就会发现,这个文件定义了全局的app2变量,然后提供了一个get函数,里面实际上就是去加载具体的模块

所以app2.get("Button")在这里就变成了app2内部定义的get函数,随后执行自己的webpack_require

是不是有种焕然大悟的感觉!

原来它是这样在两个独立打包的应用之间,通过全局变量去建立了一座彩虹桥!

当然,app2/remoteEntry.js是由app2根据配置打包出来的,里面实际上就是根据配置文件的导出模块,生成对应的内部modules

你可能忽略的bootstrap.js

细心的读者如果注意的话,会发现,在入口文件index.js和真正的文件app.js之间多了一个bootstrap.js,而且里面内容就是异步加载app.js

那这个文件是不是多余的,笔者试了一下,直接把入口换成app.js或者这里换成同步加载,整个应用就跑不起来了

其实从原理上分析后也是可以理解的:

因为依赖需要前置,并且等依赖加载完成后才能执行自己的入口文件,如果不把入口变成一个异步的chunk,那如何去实现这样的依赖前置呢?毕竟实现依赖前置加载的核心是webpack_require.e

3.3.7 总结

至此,Module federation如何实现shared和remotes两个配置我相信大家都有了理解了,其实还是逃不过在第二节末尾说的问题:

  1. 如何解决依赖问题,这里的实现方式是重写了加载chunk的webpack_require.e,从而前置加载依赖
  2. 如何解决modules的共享问题,这里是使用全局变量来hook

整体看起来实现还是挺巧妙的,不是webpack核心开发者,估计不能想到这样解决,实际上改动也是蛮大的。

这种实现方式的优缺点其实也明显:

优点:做到代码的运行时加载,而且shared代码无需自己手动打包

缺点:对于其他应用的依赖,实际上是强依赖的,也就是说app2有没有按照接口实现,你是不知道的

至于网上一些其他文章所说的app2的包必须在代码里面异步使用,这个你看前面的demo以及知道原理后也知道,根本没有这样的限制!

0x4 总结

对于腾讯文档来说,实际上更需要的是目前的shared能力,对一些常见的公共依赖库配置shared后就可以解决了,但是也只是理想上的,实际上还是会遇到一些可见的问题,例如:

  1. 不同的版本生成的公共库id不同,还是会导致重复加载
  2. app2的remotEntry更新后如何获取最新地址
  3. 如何获知其他应用导出接口

但是至少带来了解决这个问题的希望,remotes配置也让我们看到了多应用共享代码的可能,所以还是会让人眼前一亮,期待webpack5的正式发布!

最后,如果有写的不正确的地方,欢迎斧正~


AlloyTeam 欢迎优秀的小伙伴加入。
简历投递: alloyteam@qq.com
详情可点击 腾讯AlloyTeam招募Web前端工程师(社招)

clipboard.png

查看原文

赞 9 收藏 3 评论 2

腾讯AlloyTeam 发布了文章 · 2020-02-29

iOS 键盘难题与可见视口(VisualViewport)API

原文:iOS 键盘难题与可见视口(VisualViewport)API | AlloyTeam
作者:TAT.rikumi
Web 开发者与 iOS 长达四年的较量,终于在 iOS 13 发布这一刻落下帷幕。

iOS 8.2 和它的键盘难题

2015 年三月,iOS 发布了 8.2 版本。这在当时看来也许只是这个现代的操作系统的一次小更新,但在 Web 开发者眼里,有些微妙的问题产生了。这是一件在 Android 世界里想象不到的麻烦事儿。

在此之前 Web 开发者都非常清楚,在 window 全局对象上的 innerWidth/innerHeight 表示浏览器窗口中可以看到页面的区域的尺寸,而 outerWidth/outerHeight 表示浏览器窗口整体的尺寸。可以看到页面的区域又被称为「视口」(Viewport),在 CSS 的世界里,任何 position: fixed 的元素都会脱离文档流并以视口为基准进行定位,以便在页面滚动时让这些元素相对于窗口固定,例如桌面 Web 设计中常见的头部、侧边栏、「返回顶部」按钮等等。

可是从 iOS 8.2 开始,这些概念开始不那么灵了。

难题一:不可靠的 fixed

image

iOS 8.2 以后,也许是为了满足设计上的磨砂半透明键盘后面能有点东西,达到若隐若现的效果,又或者是因为交互体验上,不想因为键盘动画上推过程中发生多次重新渲染,iOS 唯一指定浏览器内核、Webkit 鼻祖 Safari 将 fixed 元素的布局基准区域从键盘上方的可见区域改成了键盘背后的整个视窗。

上图是对于一般情况的呈现。当你使用其他传统设备访问一个页面时(如左图),滚动到某个位置(紫色边框线的顶部)后,使用双指放大到一个小区域内(图中「可视区域」+「不透明键盘」的区域),然后点击某个输入框开始编写文字。此时,窗口(window 对象)会产生一次 resize 事件,由于键盘的挤压,fixed 元素的基准区域会变成紫色边框线标注的区域。

在 iOS 8.2+ 设备中(如右图),滚动到某个位置后,使用双指放大到一个小区域内(图中「可视区域」+「半透明键盘」的区域),然后点击某个输入框开始编写文字,此时 window 对象不再产生 resize 事件,CSS 和 JS 都无从得知软键盘的开启,更不知道键盘占据了多少区域,因此,fixed 元素的基准区保留在右图紫色区域,不再变化。

因为上图是一种一般情况,这里考虑了放大,似乎从肉眼看来,可视区域内的布局没有受到什么影响。但在现代移动端 Web 设计中,我们常常使用 Viewport Meta Tag 以及屏蔽多点触摸和双击手势等方式来禁止放大页面,此时问题就会凸显出来:

image

进入移动互联网时代之后,我们在手机上浏览的页面更多变成了专为移动设备设计的页面,它们狭长、不需要放大就适合阅读。这时,在其他传统设备上,键盘弹起后,window 对象发生 resize,所有 fixed 布局的元素自动被推至键盘上方的区域之内;而到了 iOS 8.2 的设备上,键盘弹起后,window 对象不再发生 resizefixed 元素也保留在原来的位置,丝毫注意不到键盘的存在。

这对于普通的 Web 应用来说不会带来太大的影响,但对于一些需要追求特殊交互的应用来说,打击是巨大的。最大的问题在于,再也没有东西可以牢靠地吸附在键盘上方了,无论是一行提示语、一条工具栏,还是一个自动完成列表,都再也做不到了。

难题二:自作聪明的页面上推

正如上图右侧所呈现的,当键盘弹起时,页面无法感知到键盘的存在。那么,如果将要输入的目标(即「输入框」,例如 inputtextarea 或一般的 contenteditable 元素)正好被弹起的键盘遮住,体验不会很糟糕吗?

iOS 的设计者想到了这一点,然后它们以一个聪明的方式解决了:滚动。

image

像上图这样,点击输入框开始输入时,键盘动画弹起的过程中,页面会随之一起滚动(如果满足一定的条件也会同时进行缩放,此处忽略这种情况),但滚动的结果有些出乎意料:输入框本身可以理解地滚动到了实际可视区域的正中间,但 fixed 元素不会发生重新计算,而是保持原来的相对位置,跟着输入框一起被上推;在滚动过程中,还会允许屏幕底部超出页面底部(「滚动过头」),以便让输入框尽可能露出来。收起键盘后,「滚动过头」的部分会被弹回,fixed元素发生重新计算,但页面并不会回到与打开键盘前相同的位置

这看起来并没有太多问题,但这里的问题是:假如我们有一个单屏 Web 应用,即将 html 元素设置为 overflow: hidden,问题就会变成这样:

image

打开键盘前,页面处于不可滚动的状态,这完全符合我们的预期;但打开键盘后,无论键盘是否遮住输入框,页面变得可滚动了。换句话说,视口(Viewport)这个概念在这样的情况下竟然「悬空」,与屏幕上实际的显示区域脱离,并且可以上下滚动起来。这个滚动可以通过阻止 touchmove 事件的默认行为来屏蔽,但键盘刚刚弹出时,仍然会自动向上滚动那一大段距离。

更加顺理成章却又无法接受的问题是,假如恰好页面内有不小心垂直溢出的内容的话,当键盘收起后,进入了一个「奇怪的状态」:明明无法滚动的 html 区域,却显示了向下滚动一段距离后的内容(例如,底部出现大量留白),且因为 overflow: hidden 的作用而无法滚动回来。

在很多不便使用 100% 的情况下,我们会在 CSS 中使用 100vh 的的概念来代表视口高度,而这个高度在 Safari 中似乎是表示工具栏自动收起时,视口的最大高度,因此会导致 100vh 高度的元素很可能已经溢出了 html 区域。这也是这里会提到单屏 Web 应用的页面中可能会存在垂直溢出内容的主要原因。

有必要提到,如果我们在这样的「奇怪状态」下,依然认为页面是单屏不会滚动的页面,而继续使用触摸事件到屏幕/视口顶部的距离(screenYclientY)来参与一些比较复杂的逻辑计算的话,会导致触摸的位置与换算到页面上需要响应的位置之间存在偏差。

以往的解决办法

在 iOS 13 出现之前,fixed 不可靠问题是无法解决的,除非在 Native 侧对 WKWebViewscrollView 做一些判断,并通过 JS API 暴露给 Web —— 但把 Web 应用的能力限制在某个特定的客户端内,是一件很不优雅的事情。

针对键盘打开时发生强制滚动且无法手动滚回的问题(难题 2),有三种可行的解决思路:

1. 主动避开键盘后再聚焦

image

这是一种较为通用且简便易行的办法:在输入目标(input 等)发生 touchend 时,阻止默认行为,提前重新布局,将输入框移到不太可能被键盘遮挡的位置(当然,具体多高才不受遮挡,当时只能靠猜),然后立即调用 focus() 方法主动聚焦输入框。

但键盘打开后,仍然需要使用防止滚动的措施(阻止整个页面上 touchmove 的默认行为),来防止用户手动将页面上推。

2. 反向滚动

image

在键盘弹起的瞬间(focus 事件的下一个宏任务周期),我们可以从 window.scrollY 得知页面滚动的目标位置。很容易想到,此时我们可以通过 window.scrollTo(0, 0) 来恢复到原位置,但在实际尝试中,我们会发现,这样处理会导致页面整体向下瞬移,然后再逐渐移回到屏幕上。

这是为什么呢?我们可以用上面这张图来解释。在之前的图中我们看到了,iOS 对键盘弹出时的视口处理是浮动的,因此我们可以大胆猜测,在键盘弹起的瞬间,视口事实上发生了瞬移。 在页面 window.scrollY 变成目标值的同时,视口瞬移到页面下方同样的距离,这使得从肉眼看起来,页面依然处于原来的位置。随后,视口带着页面开始一起上移,直到再次与屏幕重合,产生了页面被强制滚动的效果,而在此过程中 window.scrollY 并不会逐渐变化,而是只在开始的一瞬间发生变化。因此,如果我们直接在键盘打开时执行 window.scrollTo(0, 0),页面会跟随视口一同瞬移到较低的位置,然后随视口一起回到屏幕上。

换句话说,键盘打开时的强制滚动并非 window.scrollTosmooth 模式,而是由 iOS Native 的滚动容器来驱动的。只要在 focus 的瞬间,键盘可能会遮住输入框,我们就无法阻止强制滚动的发生和进行。

image

既然我们无法阻止,我们可以用一个反向滚动的动画来抵消它。以聚焦后的 window.scrollY 为起点,聚焦前的 window.scrollY(通常为 0)为终点,构造与 iOS Spring Animation 相反的缓动曲线,用向下滚动的动画抵消向上滚动的动画,可以允许输入框在键盘弹起时被遮住,而页面只会发生轻微的抖动。

我们的目的当然不是让键盘遮住输入框,而是首先保证页面不受强制滚动的影响。因此,在执行反向滚动后,同样可以将输入框的位置移动到可视范围之内,避开键盘。

使用这种方案,同样需要配合上面所说的防止手动滚动的措施。

3. 收起键盘时恢复原位

上面两种方案是针对于不希望强制滚动的情况。如果可以允许键盘弹起时强制滚动,但希望键盘收起时回到原位,只需要在键盘收起的 blur 事件中,使用 window.scrollTo 让页面回到原位置即可。

iOS 13 VisualViewport API 与新思路

昨天,我在 Google 搜索 iOS Safari 的键盘问题,已经不知道是第几次这样绝望地寻找了,直到我找到了这篇 Safari 13, Mobile Keyboards, And The VisualViewport API.。文章指出,Safari 13(iOS 13)已经支持了 VisualViewport API,这是一个可以反映实际可视区域的实验性标准。根据 MDN 页面,目前只有 IE 和 Legacy Edge 不支持这个 API。

经过测试,iOS 13 对于这个 API 支持非常完善,已经能够完全体现页面上不含键盘的可视区域所在的位置了。可是,明明只有 iOS 8.2 不会报告键盘弹出,为何却有一个跨平台的 API 来补偿呢?其他浏览器有 window.innerWidthwindow.innerHeightresize 事件不是就足够好了吗?

这就需要回归到本文的第一张图片来解释了:

image

没错,问题在于页面缩放。可以看出,当页面发生放大后,fixed 元素是不会一起移动到实际可视区域的。而且经过测试发现,Android 下的 window.innerWidthwindow.innerHeight 也不会随页面放大而一起变化。反而在 iOS 下,window.innerWidthwindow.innerHeight 会随着页面放大而等比例减小,虽然不会去掉键盘高度,但确实反映了显示在屏幕内的页面区域尺寸。

而 VisualViewport API 在 Android 和 iOS 两端,都完整反映了在缩放和键盘弹出等一系列影响下,实际可视区域在页面中的位置和大小

因此,VisualViewport API 对于 iOS 以外的平台,最大的意义是可以反映页面的放大区域;而对于 iOS Safari 浏览器,最大的意义是可以反映键盘的弹出。 基于这一点,我们可以实现一个真正相对于可视区域 fixed(固定)的 fixed 容器。

实现一个 VisualViewport 组件

如何实现一个 fixed 容器?关于这一点,也许有一部分 Web 开发者并不知情。在 Web 开发者的直觉中,fixed 元素是始终相对于视口定位,没有任何一个元素能够改变它的定位方式;但事实上,问题却有些不同。

如果你曾经使用过一些性能优良的滚动容器,如 iScroll、BetterScroll、AlloyTouch 等,你可能会遇到这样一个问题:fixed「不灵了」,它们可能不再相对于视口定位,而是被限制在了滚动容器之内。

这是因为,在滚动容器经常会遇到的性能瓶颈中,组件的开发者通常会选择 CSS 3D Transform 来强制硬件加速,让滚动体验更顺畅。在开启了 3D Transform 的容器内,由于渲染限制,fixed 元素无法再相对于视口布局,而是被「圈」在了 3D Transform 容器之内。我们只需要反其道而行之,给一个容器开启 3D Transform,就可以让内部的 fixed 元素相对于该容器布局了。

下面我们以 React 为例,实现一个可以兼容 Android/iOS 13+,始终贴着可视区域的 VisualViewport 组件。

定义 VisualViewport 类型

由于我目前使用的 TypeScript 3.7.5 还没有定义 VisualViewport API,首先我们需要手动进行类型抹平。

interface VisualViewport extends EventTarget {
    width: number;
    height: number;
    scale: number;
    offsetTop: number;
    offsetLeft: number;
    pageTop: number;
    pageLeft: number;
}

// eslint-disable-next-line
declare global {
    interface Window {
        visualViewport?: VisualViewport;
    }
}

定义组件

在组件中,我们对于支持 VisualViewport API 的平台使用 VisualViewport API,对于不支持的平台可以使用 window.innerWidth/window.innerHeight 进行兼容。

import * as React from 'react';

interface VisualViewportComponentProps {
    className?: string;
    style?: React.CSSProperties;
}

interface VisualViewportComponentState {
    visualViewport: VisualViewport | null;
    windowInnerWidth: number;
    windowInnerHeight: number;
}

export default class VisualViewportComponent extends React.Component<{}, VisualViewportComponentState> {
    state: VisualViewportComponentState = {
        visualViewport: null,
        windowInnerWidth: window.innerWidth,
        windowInnerHeight: window.innerHeight,
    }

    componentDidMount() {
        // TODO: 挂载事件监听器
    }

    componentWillUnmount() {
        // TODO: 卸载事件监听器
    }

    getStyles(): React.CSSProperties {
        // TODO: 根据 state 计算样式
        return {};
    }

    render() {
        return <div className={'visual-viewport ' + (this.props.className || '')} style={this.getStyles()}>
            {this.props.children}
        </div>;
    }
}

定义事件监听器

通过监听 window.visualViewportresizescroll 事件以及 windowresize 事件,我们将可见视口和实际视口的尺寸变化转化为组件内的 state 变化,以便触发重渲染。

    componentDidMount() {
        if (typeof window.visualViewport !== 'undefined') {
            window.visualViewport.addEventListener('resize', this.onVisualViewportChange);
            window.visualViewport.addEventListener('scroll', this.onVisualViewportChange);
        }
        window.addEventListener('resize', this.onResize);
    }

    componentWillUnmount() {
        if (typeof window.visualViewport !== 'undefined') {
            window.visualViewport.removeEventListener('resize', this.onVisualViewportChange);
            window.visualViewport.removeEventListener('scroll', this.onVisualViewportChange);
        }
        window.removeEventListener('resize', this.onResize);
    }

    onVisualViewportChange = (e: Event) => {
        this.setState({
            visualViewport: e.target as VisualViewport || window.visualViewport
        });
    }

    onResize = () => {
        this.setState({
            windowInnerWidth: window.innerWidth,
            windowInnerHeight: window.innerHeight
        });
    }

计算样式

下面,我们根据 state 中提供的可见视口和实际视口尺寸,对可见视口在实际视口中的相对位置进行计算,并应用到组件容器的样式中。

    getStyles() {
        const {
            visualViewport,
            windowInnerWidth,
            windowInnerHeight,
        } = this.state;

        // 开启 3D Transform,让 fixed 的子元素相对于容器定位
        // 同时自身也设置为 fixed,以便在非放大情况下不需要频繁移动位置
        const styles: React.CSSProperties = {
            position: 'fixed',
            transform: 'translateZ(0)',
            ...this.props.style || {}
        };

        // 支持 VisualViewport API 情况下直接计算
        if (visualViewport != null) {
            // 需要针对 iOS 越界弹性滚动的情况进行边界检查
            styles.left = Math.max(0, Math.min(
                document.documentElement.scrollWidth - visualViewport.width,
                visualViewport.offsetLeft
            )) + 'px';

            // 需要针对 iOS 越界弹性滚动的情况进行边界检查
            styles.top = Math.max(0, Math.min(
                document.documentElement.scrollHeight - visualViewport.height,
                visualViewport.offsetTop
            )) + 'px';

            styles.width = visualViewport.width + 'px';
            styles.height = visualViewport.height + 'px';
        } else {
            // 不支持 VisualViewport API 情况下(如 iOS 8~12)
            styles.top = '0';
            styles.left = '0';
            styles.width = windowInnerWidth + 'px';
            styles.height = windowInnerHeight + 'px';
        }

        return styles;
    }

效果和总结

image

经过这样的实现,我们的组件可以在支持的浏览器中正确定位到当前可见视口的位置(上图中的靛蓝色区域),并将内部的元素以可见视口为基准进行定位。对于移动端 Web 应用来说,这样的组件有很多用途,例如吸附键盘的工具栏或自动完成列表、需要避开键盘居中的对话框等等。值得一提的是,在 PC 浏览器上,这个 API 也同样适用(可以响应页面的放大)。

在 iOS 下,这样的实现还存在一些迟钝和小 bug(例如,键盘展开后的强制滚动状态下向上滑动,可以露出不论是 Viewport 还是 VisualViewport 都无法到达的白色衬底区域)。

但至少,在 iOS 8.2 发布四年后,iOS 13 对 VisualViewport 的支持,让获取键盘高度、避开键盘、吸附键盘这三件事终于有了相对优雅的办法。


AlloyTeam 欢迎优秀的小伙伴加入。
简历投递: alloyteam@qq.com
详情可点击 腾讯AlloyTeam招募Web前端工程师(社招)

clipboard.png

查看原文

赞 25 收藏 14 评论 2

腾讯AlloyTeam 发布了文章 · 2019-12-26

小程序同构方案kbone分析与适配

原文:小程序同构方案kbone分析与适配 | AlloyTeam
作者:flyfu wang

在微信小程序的开发的过程中,我们会存在小程序和 H5页面共存的场景,而让小程序原生和web h5独立开发,往往会遇到需要两套人力去维护。对开发者而言,加大了工作量成本,对于产品而言,容易出现展示形态同步不及时问题。在这种情况下,我们急需要找到一个既能平衡性能,也能满足快速迭代的方案。

主流的小程序同构方案

web-view 组件

web-view组件是一个承载网页的容器,最简单的方案就是使用原h5的代码,通过 web-view组件进行展示。其优点是业务逻辑无需额外开发与适配,只需要处理小程序特有的逻辑,然后通过jssdk 与原生小程序通信。

使用 webview 加载 h5的问题也非常明显,首先是体验问题,用户见到页面会经过以下环节:加载小程序包,初始化小程序,再加载 webview中的 html页面,然后加载相关资源,渲染h5页面,最后进行展示。最终导致的结果是打开体验非常差。另外其他缺点是小程序对web-view部分特性有限制,比如组件会自动铺满整个小程序页面,不支持自定义导航效果等。

静态编译兼容

静态编译是最为主流的小程序同构方案,类似的有 taro, mpvue等。其思路是在构建打包过程,把一种结构化语言,转换成另一种结构化语言。比如,taro 把jsx在构建时进行词法分析,解析代码获取AST,然后将 AST递归遍历生成wxml目标代码。

静态编译的好处是非常明显,一套代码,通过编译分别转h5和小程序,兼具性能与跨平台。另一方面,随着这种方案的流行,大家也感受到了其明显的问题,首先,由于小程序本身的限制,比如无法dom操作,js 与 webview 双线程通信等,导致静态编译语法转换,不能做到彻底的兼容,开发体验受制于框架本身的支持程度,相信踩过坑的同学应该非常有痛的感悟。其次,静态编译转换逻辑需要与小程序最新的特性保持同步,不断升级。

小程序运行时兼容方案

静态编译的方案实现了同构,但它只是以一种中间态的结构化语法去编码,非真正的web,牺牲了大量的灵活性。我们来看下另外一种更灵活的方案———运行时兼容。

我们回到小程序本身的限制上来。由于小程序采用双线程机制,内部通过一个 webview 用于承载页面渲染,但小程序屏蔽了它原本的 DOM/BOM接口,自己定义了一套组件规范;另一方面,使用独立的js-core 负责对 javascript 代码进行解析,让页面和js-core之间进行相互通信(setData),从而达到执行与渲染的分离。而浏览器的 DOM接口是大量web得以显示的底层依赖,这也是h5代码无法直接在小程序中运行的主要原因。

那么如何突破小程序对DOM接口的屏蔽呢? 最直接的思路就是用JS实现和仿造一层浏览器环境DOM相关的标准接口,让用户的JS代码可以无感知的自由操作DOM。通过仿造的底层DOM接口,web 代码执行完后,最终生成一层仿造的 DOM树,然后将这棵 DOM 树转换成小程序的wxml构成的DOM树,最后由小程序原生去负责正确的渲染出来。

kbone

kbone 是微信官方出一套小程序运行时兼容方案,目前已经接入的小程序有小程序官方社区,及腾讯课堂新人礼包等。并且有专人维护,反馈及时~~。

kbone方案核心主要有两大模块,第一是miniprogram-render实现了对浏览器环境下dom/bom的仿造,构建dom树,及模拟 web 事件机制。第二个模块是miniprogram-element是原生小程序渲染入口,主要监听仿造dom树的变化,生成对应的小程序的dom 树,另外一个功能是监听原生小程序事件,派发到仿造的事件中心处理。

DOM/BOM仿造层

DOM、BOM相关的接口模拟,主要是按照web标准构建 widow、document、node节点等相关 api,思路比较清晰,我们简单看下其流程。

首先在用户层有一个配置文件miniprogram.config,里面有必要信息origin、entry等需要配置。在 miniprogram-render 的入口文件createPage方法中,配置会初始化到一个全局cache对象中,然后根据配置初始化 Window 和 Document 这两个重要的对象。Location、Navigator、Screen、History等 BOM 实例都是在 window初始化过程中完成。DOM 节点相关 api 都是在Document 类中初始化。所有生成的节点和对象都会通过全局的pageMap管理,在各个流程中都能获取到。

小程序渲染层

miniprogram-element 负责监听仿造DOM仿造的变化,然后生成对应小程序组件。由于小程序中提供的组件和 web 标准并不完全一样,而我们通过 html 生成的 dom 树结构千差万别,如和保证任意的html dom树可以映射到小程序渲染的dom树上呢?kbone 通过小程序自定义组件去做了这件事情。

简单说下什么是自定义组件,既将特定的代码抽象成一个模块,可以组装和复用。以 react 为例,div、span 等标签是原生组件,通过react.Component将div 和 span 组合成一个特定的 react 组件,在小程序中用自带的 view、image 等标签通过Component写法就能组合成小程序自定义组件。

和大部分 web 框架的自定义组件类似,小程序自定义组件也能够自己递归地调用自己,通过将伪造的dom结构数据传给自定义组件作为子组件,然后再递归的调用,直到没有子节点为止,这样就完成了一个小程序 dom 树的生成。

性能问题

多层dom组合

大量小程序自定义组件会有额外的性能损耗,kbone 在实现时提供了一些优化。其中最基本的一个优化是将多层小程序原生标签作为一个自定义组件。dom 子树作为自定义组件渲染的层级数是可以通过配置传入,理论上层级越多,使用自定义组件数量越少,性能也就越好。



以上逻辑就是通过DOM_SUB_TREE_LEVEL 层级数对节点过滤,更新后,检测是否还有节点,再触发更新。

节点缓存

在页面onUnload卸载的过程中,kbone会将当前节点放入缓存池中,方便下次初始化的时候优先从缓存中读取。


kbone 接入与适配

kbone 作为一种运行时兼容方案,适配成本相对于静态编译方案而言会低很多,总体来说对原代码侵入性非常少,目前接入过程比较顺利(期间遇到的坑,感谢 作者june 第一时间帮忙更新发布[玫瑰])

svg资源适配

小程序不支持 svg,对于使用 svg 标签作为图片资源的应用而言,需要从底层适配。在一开始我们想到的方案有通过 肝王的cax进行兼容,但评估后不太靠谱,cax 通过 解析svg 绘制成 canvas,大量 icon会面临比较严重的性能问题。那么最直接暴力的办法就是使用 webpack 构建过程直接把 svg 转 png?后面一位给力的小伙伴想到通过把 svg 标签转成Data URI作为背景图显示,最终实践验证非常可靠,具体可以参考kbone svg 适配

网络层适配/cookie

微信小程序环境拥有自己定义的一套 wx.request API, web 中的XMLHttpRequest对象是无法直接使用。由于我们代码中使用了 axios,所以在预言阶段直接简单通过axios-miniprogram-adapter进行适配器,后面发现部分业务没有使用 axios,兼容并不够彻底。于是直接从底层构建了一个XMLHttpRequest模块,将web网络请求适配到 wx.request。同时做了 cookie 的自动存取逻辑适配(小程序网络请求默认不带 cookie)。这一层等完善好了看是否能 pull request到 kbone代码仓库中。

差异性 DOM/BOM API 适配

部分web 中的接口在小程序无法完全获得模拟,比如getBoundingClientRect在小程序中只能通过异步的方式实现。类似的有removeProperty、stopImmediatePropagation

等接口在 kbone 中没有实现,performance等web特有的全局变量的需要兼容。这些扩展API可以通过kbone对外暴露的dom/bom 扩展 API进行适配。

getBoundingClientRect

对于元素的的高度height offsetHeight获取,我们只能通过$getBoundingClientRect异步接口,如果是body scroll-view 实现的,getBoundingClientRect 返回的是scrollHeight。

滚动

web的全局滚动事件默认是无法触发,需要通过配置windowScroll来监听,启用这个特性会影响性能。

global: {
    windowScroll: true
},

样式适配

标签选择器

kbone 样式有一个坑,就是它会将标签选择器转换成类选择器去适配小程序环境,比如

span { } =>  .h5-span{  }

这样带来的副作用就是选择器的权重会被自动提升,对选择器权重依赖的标签样式需要去手动调整兼容。

其他适配点

注意使用标准的style属性,比如有webkit-transform会不支持,及小程序样式和web差异性兼容等。

  style: {
      'WebkitTransform': 'translate(' + x + 'px, 0)' // 正确
     // '-webkit-transform': 'translate(' + x + 'px, 0)' 报错
  }

路由适配

在初始化路由阶段,曾经遇到过Redux 更新dom后偶现节点销毁,最终定位到是kbone对Location等BOM实例化过晚,最终在june帮忙及时调整了顺序,更新了一个版本,现最新本所有BOM对象会在业务执行前准备好。

//初始化dom
this.window.$$miniprogram.init()
...
//初始化业务
init(this.window, this.document)

隐式全局变量兼容

在模拟XMLHttpRequest模块的过程中遇到一个问题,什么时候初始化这个对象,我们可以选择在网络请求库初始化前引入它,挂载在仿造的 window 对象下。但仍然会出现一个问题,第三放库直接使用的是XMLHttpRequest 对象,而非通过 window 访问。

var request = new XMLHttpRequest() // 报错

var request = new window.XMLHttpRequest() // 正确

在正常的 web 环境,window 是默认的顶层作用域,而小程序中隐式的使用window 对象则会报错。

为了解决这一问题,可以通过配置文件的globalVars字段,将 XMLHttpRequest 直接进行定义。

 globalVars: [
    ['XMLHttpRequest', 'require("libs/xmlhttprequest.js")']
]

构建的过程中会在所有依赖前转成如下代码 :

 var XMLHttpRequest = require("libs/xmlhttprequest.js")

这样做解决了隐式访问 window 作用域问题。但又面临另一个问题,那就是xmlhttprequest模块本身内部由依赖仿造window对象,比如 cookie 访问,而此时因为require的模块独立的作用域无法访问到其他模块的仿造window 对象。于是最终通过导入一个 function 传入 window 作用域,然后初始化xmlhttprequest。

  globalVars: [
      ['XMLHttpRequest', 'require("libs/xmlhttprequest.js").init(window, document)']
]

多端构建

小程序和web端需要的资源及部分逻辑是有差异,通过webpck配置进行差异化处理,具体可以参考文档编写kbone webpack 配置。

大概是这样的区分跨端配置:

分离打包入口文件:

小程序打包入口依赖的 dom 节点,需要主动创建。详细示例参照官方demo.

export default function createApp() {
    initialize(function() {
        let Root = require('./root/index').default;
     
        const container = document.createElement('div')
        container.id = 'pages';
        document.body.appendChild(container);
        render(<Root />, container)
    })
}

由于小程序本身是没有真正userAgent,kbone内部是是根据当前环境进行仿造。

//miniprogram-render/src/bom/navigator#45
this.$_userAgent = `${this.appCodeName}/${appVersion} (${platformContext}) AppleWebKit/${appleWebKitVersion} (KHTML, like Gecko) Mobile MicroMessenger/${this.$_wxVersion} Language/${this.language}`

在业务中有需要区分小程序平台的场景,我们可以通过webpack DefinePlugin插件进行注入,然后通过定义变量进行判断。

if (!process.env.isWxMiniProgram) {
    render(
        <Root />,
        document.getElementById('pages')
    );
}

小程序分包

在腾讯文档的小程序中,有一个独立的小程序仓库。 而文档管理列表是另外一个独立的H5项目,嵌入到小程序webview动态加载。通过kbone转原生打包后,这部分代码需要继承到小程序仓库中。

首先我们可以通过脚本,在webpack构建过程,将kbone 编译后的包copy到独立小程序仓库的目录下,合并小程序相关配置,从而实现功能合并。同时通过FileWebpackPlugin过滤掉无用的web平台资源。

这样遇到一个问题是主包大小仍然超过限制,最后通过小程序分包可以解决这个问题,将原小程序非首屏页面全部放分包之中,配置preloadRule 字段再预加载分包。

"subpackages": [
    {
      "root": "packageA",
      "pages": [
        "pages/cat"      ]
    }
  ]
  "preloadRule": {
    "pages/index": {
      "network": "all",
      "packages": ["important"]
    }  
}

通过对目前各种小程序同构方案的对比与实践,kbone是一种非常值得推荐的新思路,新方法,兼具性能与灵活。唯一不足的地方就是目前仍有不少底层工作需要适配,更多的问题在继续探索中,相信随着不断迭代及采坑后的反馈,kbone会变得越来稳定和成熟。

(最后感谢作者junexie及dntzhang大神的鼎力支持~~也欢迎大家一起参与共建kbone


AlloyTeam 欢迎优秀的小伙伴加入。
简历投递: alloyteam@qq.com
详情可点击 腾讯AlloyTeam招募Web前端工程师(社招)

clipboard.png

查看原文

赞 2 收藏 0 评论 0

腾讯AlloyTeam 关注了专栏 · 2019-10-16

TAFE - 腾讯动漫前端开发团队

TAFE - 腾讯动漫前端开发团队,分享高质量前端好文。

关注 828

腾讯AlloyTeam 关注了专栏 · 2019-10-16

腾讯开源

腾讯官方开源动态、开发经验分享

关注 921

腾讯AlloyTeam 发布了文章 · 2019-09-23

面试之前,简历之上:给前端校招同学的简历建议

原文:面试之前,简历之上:给前端校招同学的简历建议 | AlloyTeam
作者:TAT.老教授
前言:作为前端面试官老司机,之前分享过我对前端校招面试的一些见解,这次来说下简历。早就想写这个,刚好最近又刷了一批秋招提前批,看多了简历,心有所感,闲聊几句,希望对大家参加前端校招有所帮助吧!

一家之言,仅供参考~

简历经常是给面试官的第一印象,但很多还没踏出校门的同学们不知道怎么写好简历,我时常有拿着一份简历不知道该怎么面试他的情况。而在秋招这种大规模招聘的季节,面试官刷简历时如果一份简历没法在很短的时间内吸引到注意力,那很容易就被漏过了,因为这样被淘汰岂不可惜。

那怎样的前端简历才算好简历?

简历的思路

大公司经常有技术评审的制度。以我司为例,只要你在当前技术级别待的年限到了,你就可以准备一份 PPT,向公司安排的几位高级别技术人员(评委)做 presentation。这个过程中,你就要证明你达到下一个技术级别的要求了,一般会简述自己的职业经历、项目经历,然后用技术案例说话,用一个或多个技术案例,来说明自己的技术给项目给团队给公司带来了什么价值。评委会挑战你,看你是否真吃透这块技术,评价你的技术成果产生的价值,最后决定你是否通过。

重点是什么?第一点,你。你要讲清楚你的角色你的作用要讲你确切经历或执导的项目,评委说到底考察的是你一个人。第二点,技术产出。作为一个技术开发,你参与的项目再好再多人用那也是产品经理运营人员牛,不关你事,技术人看的还是技术相关的优化和产出,当然一个技术上很复杂的很难实现的功能被你做好了也能说明技术能力。两点合起来就是:你技术牛。

技术评审如此,实习生留用述职不也类似,写简历不也类似?

以此作为核心思路,那简历里写团队项目,你是否写清楚自己的角色和作用?实习过几家大公司不是重点,重点是你实习中做出了什么成果。写再多的获取多少奖学金、写了多牛的论文,分量没那么重的,面试前端大家还是想听前端相关的。写自己的兴趣爱好有多广泛、性格到底有多好都是虚的,先拿技术说话,面试过程中的你的性格别人自然能感受出来。

想清楚了这思路,就知道简历的侧重点了。

我怎么刷简历的

打开简历,首先看一眼大学。不管你愿不愿意,有些大学在编程开发方面的氛围和质量就是相对好一些,会有一些隐形加分。

接着,实习经历,在腾讯、阿里、头条之类的大公司实习肯定是大优势(如果你有,那重点标大标黑不过分),业内知名的公司也不错。所以如果有意走这条路,那大学前几年争取去大公司实习吧。

职业技能,这块特别一点,如果是在大简历池子里筛简历,会很快扫过,确定不是后台同学走错片场就行。如果是面试前看简历就会细看一点,这个我后面小节会讲到。

然后,重点来了,项目经历。看什么,关键词!诸如写了XX论文、参加社会实践,很快扫过去,重点还是”在线系统开发“、”某某小程序开发“、”XX webapp 开发“、”某个开源组件作者“之类的项目上。然后一大段关于项目的介绍和描述没多大用,重点还是项目里面透露出来的技术能力、技术关键字,比如”应用 Service Worker 做了XX“、“基于React Hook”、“实现了完善的单元测试”、“应用了某某思路提升了页面的打开时间”,等等等等,都令我感兴趣。如果这些都没有,就干巴巴地写了用了 React+Redux ,用了 node+Koa,或者干脆都在讲开发了哪些哪些页面,基本都很快放弃。

就这几块,快速浏览过去脑海里也就能建立这个学生的技术印象了。没有一些关键字快速抓住面试官,引起他的兴趣,而是靠一大段文字让他做阅读理解,我很难过你也很难过。

怎么写好简历

以上是跟大家讲简历会被别人怎么看(虽来自我的经验,但我觉得有一定代表性),那接下来讲讲几个主要模块该怎么写,个人基础信息模块就不说了,太基础。

技术能力(也叫个人技能、专业技能)

常见的一个 part,但很多人没写好没重视。经常见到类似这样的描述:

你怎么看?

我觉得一般,因为里面有意思的信息不够。如果以简历筛选的标准,里面的关键字还是可以的:

但如果是面试前看到,又会觉得有意思的点太少了。一面来看,熟悉三语言,太基础的要求了,熟悉 MVVM、React、Redux、计算机网络这些,倒可以问问。第三点就有点虚了,而第五点基本是直接无视的。如果到我现在负责的二面,我会开始找亮点。为什么?因为一面通过的基础都不会太差再考察意义不太大,校招同学的项目实践一般都不会太丰富可能不够展开。那这时候比什么?那我会期待他对某个或某几个小技术项有稍微深入的研究,有一些自己的思考,自己感兴趣的技术。这就是亮点。

还是以上面的简历为例,我觉得有意思的点就一个:

因为它是相对较新的技术,因为很多校招同学没提到,因为它不是被研究透的技术还有不少讨论的地方。这时候我会问:“Hook 是怎么写的”、“为什么要搞出 hook,优势在哪”、“Hook 的原理大概是怎样的”、“如果我有一个现有的 React 插件也想支持 hook 类型的写法,该怎么做”。逐层深入,真正区分面试者的强弱,如果这些你真答好了,那基本妥了。毕竟我们很难要求校招学生前端知识面很广,但如果某一方面掌握得深了解得透,即使他知识面不广,我们也相信他后面接触别的前端领域也能学好做好,因为这里反映出来的学习能力、探索能力尤为重要。这或许解答了不少同学的困惑:我面试觉得答得还可以,为何没过?因为了解得多却不深,没亮点,没能展现自己学习能力这些虚的特质,自然被比下去(PS:我这是大公司的视角,因为大公司更看重的是培养潜质)。

当然,我不是让你去对死 React Hook 下功夫,不是告诉你捷径。要知道,一个资深的面试官也有技术短板,他如果不熟悉 Hook,也就不会多问。但是类似的可以作为亮点的并不少,比如“喜欢钻研 Webpack,写过 webpack 插件提升监控和打包效率”、“熟悉性能相关工具,熟悉 Lighthouse,了解它评价网页表现的一些细节”、“了解 SSR,实践过,用业内工具做过压测,了解一些容易成为性能瓶颈的点”,等等等等,学生也能做,有心研究,深入思考,总能有 catch 到面试官的亮点。这些你即使没法答得很深入透彻,也是容易比别人出彩的地方。

不过呢,最基础的那些还是要掌握好,要不然笔试一面都没过更别谈展现亮点。

扯远了,回来。技术能力这一节,你最好除了列一些通用的技术能力,也写上一些对某个技术点的深入研究。而我更希望看到这一节是这样写的:

先区分大能力:

  • 掌握前端基础能力
  • 有移动 Web 开发能力
  • 有 Nodejs 开发能力
  • 有小程序开发能力
  • 有 Electron 客户端开发能力
  • 了解新技术
  • ……

再归类小能力,如:

有移动 Web 开发能力:

  • 了解常用的屏幕适配方案
  • 了解移动端与 PC 开发的差异
  • 了解 base64 等移动端常用的优化方案

最后补上亮点:

  • ……
  • 研究过一些手势库,了解基本原理,能定制一些特殊手势实现特殊功能;

这样整理你的技术能力,岂不比前面干巴巴的几个宽泛的点,来得有条理、更深入?

项目经验

项目经验这节可以写:参加实习做的项目、在学校工作室或参加编程比赛做的项目、自己折腾的技术项目。一般按照时间顺序来梳理。

此时不建议把非编程甚至非前端的经验放上去,比如用 C# 做了一个什么样的软件,为什么不太好,因为你面试的是前端,你的面试官不一定熟悉这门语言,那他对你这项经历就不知道怎么问,不知道怎么考察它做得好不好,于是直接忽略了。除非你能把做得好不好加上去,比如拿去参加微软的全国比赛拿了二等奖之类,但即使这样,它也不是一个重要的案例(除非你没别的前端项目 = =)。

另外不建议写两年以前的项目,一般超过两年前的项目即使你写了好几页,我也直接一扫而过。前端的发展变化太快了,每月每天都有各种新的文章新的实践,有时一个框架发布一个新版本都能给我们带来新的思路,两年前项目用的技术要不已经成了常识要不已经过时了,甚至一年前的都不算新。如果你想做前端,那我们希望你是持续学习持续关注前端的状态,拿一些早早之前做的成绩说明不了什么。

我个人比较喜欢问校招学生你自己折腾过什么技术项目。因为在这一阶段你很难有机会去主导或主要开发一个实习项目,大多数情况是跟着参与项目的,配角。但个人项目则不同,从这里我可以看出你是否真的喜欢前端,是否会自己去做一些自己觉得有趣的 Web 实践,是否能融入自己的思考把它做好。一个喜欢折腾的同学,在工作中更容易给我们带来惊喜,更容易创造些额外的价值。比如曾

好了,写哪些项目实践明确了,接下来聊聊怎么写。

我看过这样的项目描述:

能理解我第一节讲的简历思路的同学,应该也能理解我为什么看着这段项目经历不知道怎么问。因为它一没讲清楚你的技术产生的技术成果,二没讲清楚遇到难点有多难怎么解决来体现技术能力。讲那么一大段项目描述有用吗?没用!我真不关心项目的产品逻辑,除非这是一个技术型的项目(如开发一个XX技术框架)。讲责任描述是没错,你要讲清楚你的角色,但很虚地讲确保项目高质量整合控制开发进程,还不如简述一个事例,给一个具体策略,比如用了单测做了怎样的架构分层都行,这才是技术简历。不要求写一大段具体描述也不实际,但至少给几句切实的具体的描述。性能优化给的小点也是太普通。

项目经历真不是给面试官讲清楚项目是什么,而是突出项目背后通用的技术知识的应用和你解决技术难题的思路。

这一个模块我理想情况是这么写的:

  1. 项目名称,你的角色,时间;
  2. 项目简介,一句话讲清楚项目的功能和产生的价值,比如“简历管理系统,为学生提供简历建议和生成管理功能,已在学校超过3个学院应用上”;
  3. 技术栈简介;
  4. 项目中遇到的技术难点,以及怎么解决。比如优化相关,“在开发这个 Web 游戏中,画面元素繁多,贴图质量高,导致游戏流畅度一直不够,此时通过 XXX 策略,优化了 XXX,再通过 XXX 提升了 XXX,最后游戏帧率保持在接近60帧的体验”,比如复杂功能,“怎么实现多人协同编辑脑图,怎么解决冲突是个难点,此时我预研了 XXX 方案,借鉴了 XXX 框架源码,最后结合项目特点做了策略:XXX,实现了功能”;
  5. 相关产出,比如输出了《Web 开发在线游戏的性能建议》博文附链接,比如将某块技术开源,等;

说到底,讲技术,而且最好是一些能够跳出具体项目的点,可以给其他人启发的点,这样的项目经历才容易和面试官产生共同话题,聊得来。

其他模块

跟岗位能力关联不大的,基本不重要,比如科研成果、比如兴趣爱好,都可以省去,把上面的小节想明白写清楚,简历已经较为充实了。

当然你如果有其他技术相关的,也可以补上。比如单独一节写社区贡献,“在掘金知乎开个人技术专栏,写了N篇博文,其中一篇还获得XX访问量,被首页推荐过”,“nodejs的贡献者之一,提过N个MR被采纳”,“XX 框架的中文翻译者之一”,“XX 前端技术会议做过展台分享”,诸如此类,也是挺赞的。

更多细节

blabla 说了这么多,最后再给一些零碎的细节建议吧:

1. 关键字

不再赘述,根据第一节的思路来分析,直接标黑加重;

2. 要有亮点

基础扎实我就不说了,如果连事件代理、缓存相关都讲不好没必要面下去了。做过的项目用过的框架,最好都要有自己的思考理解,才容易去挖掘亮点。也不用说全篇都是亮点,几十处,想想自己最擅长哪块技术,要有一个主案例,主要亮点。

3. 主动递话题

在 2 的基础上加强,想清楚主案例主亮点后,简历中适可而止地描述一下,不用带上过多细节,吸引面试官在面试时在这方面和你交流这个早准备好的技术点,是种高级的技(tao)巧(lu)。

4. 简历专项专用

不要想着一份简历打天下,面试前端、客户端、后台都用一份简历,很容易让面试官看出你的不用心。比如这样:

5. 重视前实习经历

较早之前遇到过一个同学,一面通过到我这,看了下面试评价还挺不错,之前也在公司的另一个部门实习过,于是看了下内网招聘网站上的资料,看到之前的实习评价,不太好,主要问题是对分配事务的积极主动性不够。这就让我对他的印象降低了不少分,这种面试很难考察到的问题是我们最怕的。后来犹豫了很久,才决定给他二面,带着挑剔审视的眼光,发现他二面时对各个技术的理解还不错,也有自己的思考,旁敲侧击发现他对自己之前实习的问题也有意识到,也有思考过怎么改进。最后让他过了二面。

你的实习经历表现,是你的另一种简历。积极对待每一份实习经历,即使不太适合自己也做到位一点,不然下次你就坑到自己了。

6. 简历文件格式

PDF。

大公司基本上都有自己的招聘简历平台,刷简历时如果是 PDF 格式,chrome 直接就可以打开了,非常方便。如果是 word 格式,还要下载下来,再打开,等 word 启动再查看,给面试官就带来一些麻烦了。更不太推荐搞成 zip 包,把自己的作品打包进去,先不说整个过程更费事,面试官都是有业务开发任务的未必有那么多时间来仔细看你的作品,此时更好的做法是在简历里附上自己的 github 地址并做说明,能让面试官感兴趣他自然会去看看。

虽说面试官不会因为简历文件格式而拒绝一个人,但校招同学也没必要给自己制造障碍吧。

总结

讲了这么多就是为了偷懒,如果大家的简历核心突出,信息明了,那我们面试官可以节省多少工夫,看简历也能看得舒服,多好~

最后说一句,功夫在纸外,一切粉饰和技术的虚浮包装都可能被戳破,简历不过是你扎实技术基础和良好学习态度后的一道助力罢了。喜欢挑战和反套路的我现在把守在二面了,面试时遇到我继续自求多福吧~





PS1: 附上我上一篇文章供参考 《前端校招该考察什么?一个面试官的思考》

PS2: 文章中用到的简历案例均已脱敏,如简历作者仍觉不妥可联系我换掉

PS3: 下次想写一篇讲二面的,不过目前感觉积累不太够思考不够深,再拖一下吧~


AlloyTeam 欢迎优秀的小伙伴加入。
简历投递: alloyteam@qq.com
详情可点击 腾讯AlloyTeam招募Web前端工程师(社招)

clipboard.png

查看原文

赞 48 收藏 27 评论 1

认证与成就

  • 获得 513 次点赞
  • 获得 1 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 1 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

  • 《JavaScript设计模式与开发实践》

    作者:曾探 本书在尊重《设计模式》原意的同时,针对JavaScript语言特性全面介绍了更适合JavaScript程序员的了16个常用的设计模式,讲解了JavaScript面向对象和函数式编程方面的基础知识,介绍了面向对象的设计原则及其在设计模式中的体现,还分享了面向对象编程技巧和日常开发中的代码重构。本书将教会你如何把经典的设计模式应用到JavaScript语言中,编写出优美高效、结构化和可维护的代码。

  • 《小程序开发原理与实战》

    作者:王贝珊 李成熙 戴頔 本书全面讲解小程序开发原理、运行机制和云开发。首先,从小程序开发入门开始,通过实用的项目案例,教会读者如何快速编写小程序应用;其次,深入讲解小程序底层框架设计原理和运行机制,为读者提供了全方位的实战技巧以及工具和管理平台的实用指南;最后,全面介绍了小程序提供的云端能力,结合云开发轻松实现Serverless架构,提高开发效率和降低成本。

注册于 2018-11-30
个人主页被 6.8k 人浏览