JS 在最初设计时是用来做脚本语言的,但随着 JS 的发展,触及的领域越来越多,逐渐成了一个完全的编程语言,但工具链缺失的问题也逐渐凸显。在这数十年间,一个又一个的工具试图成为 JS 工具链的一部分,直至今日,这种竞争也仍为停止。在充分的竞争条件下,整套工具链也达到了一个相对完善的程度。本文对 JS 工具链所解决的问题进行概述,以对 JS infra 有一个相对清晰的了解。
梦开始的地方💭#
众所周知,Brendan Eich 用 10 天的时间设计并实现了 JS。也因此,即使是作为一个编程语言,本身也具有很多缺陷(不一致),造就了这张广为流传的meme
。
好在最初的场景也很简单,都可以处理,只是用在浏览器中,通过<script>
标签加载然后运行,是纯粹的脚本语言。
JS 最初是用在 Netscape Navigator 中的(1995 年),第二年,微软的 IE 3 发布,携带了另一版本的 JS 实现,称为(JScript
)。这导致第一个问题出现,JS 在不同的 Runtime 中有不同的行为(时至今日,不同 Runtime 的兼容仍是 JS 所面临的一个重要问题)。
Note
为了缓解这个问题,1997 年,Netscape 求助于欧洲计算机制造商协会 (ECMA),要求他们对 JavaScript 进行标准化。ECMA 委员会创建了一个称为 ECMA-262 的标准,该标准定义了一种称为 ECMAScript
的新脚本语言(后文简称 ES)。所有浏览器都需要将其作为其浏览器内部 JavaScript 实现的基础,从而实现一定程度上的兼容。
随着网站复杂化,很自然的会出现拆分脚本的情况,要根据不同的功能模块拆分。与此同时,脚本与脚本之间的隔离问题也开始凸显。
模块化#
在 JS 只停留在浏览器中的那些年,因为没有语言标准层面上的支持, 只能采取一些很另类的 hacker 方式来模块化实现(模拟命名空间、闭包模拟)。
// 1.模拟命名空间
var MyLibrary = {
utils: {
formatDate: function() { },
parseDate: function() { }
},
ui: {
createDialog: function() { },
showMessage: function() { }
}
};
// 使用
MyLibrary.utils.formatDate();
MyLibrary.ui.showMessage();
// 2. 通过闭包模拟进行封装
var MyModule = (function() {
// 私有变量和函数
var privateVariable = 'Hello';
function privateFunction() {
console.log(privateVariable);
}
// 返回公共接口
return {
publicMethod: function() {
privateFunction();
}
};
})();
// 使用方式
MyModule.publicMethod();
这种模式可以达到封装的目的,但难以清晰的声明依赖项,而且还有一个隐含假设:依赖项在执行这个脚本时一定是可用的,这也限制了依赖项的加载策略。
将视角切换到另外一面。09 年 Ryan Dahl 创建了 NodeJS,它引入了一个运行时来在服务器上运行 JavaScript 代码。如同其他语言一样,Node 需要一个模块化 / 打包系统,也就是 CommonJS
,通过exports/module.exports
和 require
,可以定义 / 引用一个 Node 模块,再通过 npm registry
进行分发。这种架构的可复用强太多了。
// filename: foo.js
// dependencies
var $ = require('jquery');
// methods
function myFunc(){};
// exposed public method (single)
module.exports = myFunc;
借着 CommonJS
给予的灵感,各种模块化标准、方案开始涌现。
- AMD(Asynchronous Module Definition)为浏览器提供了一种模块化方案(RequireJS)
- CMD(Common Module Definition)类似 AMD,(
Sea.js
) - UMD(Universal Module Definition)尝试提出一种通用的模块定义规范
umdjs/umd
,尝试兼容AMD
,CJS
- ESM(ESModule)由 ECMA 委员会在 ES6(2015 年) 中提出的模块化标准,对 JS 模块化方案进行了统一。只是时至今日,CJS 因其先发优势,还有很高的使用率。
包管理#
在模块化之后,可以将包发布出去提供给其他开发者使用。自然涉及到包管理的问题。除了 node 自带的npm
之外,先后出现了诸多 js 包管理器,如yarn
、pnpm
。
包管理作为开发中面临的普遍问题,更多细节不在此进行赘述。
编译 / 转义#
一方面,就像 Java
一样,因为 JS 本身的设计缺陷,无法适应很多现代需要,先后出现了许多方言,尝试做BetterJS
。
- coffeescript, 主要在语法上进行增强,提高简洁性。
- flow,为 JS 添加的类型检查工具。类似于 TS,更轻量级
- elm,专注于函数式编程,内置了一些对 Browser 进行操作的 Stdlib
- reasonML,提供类型系统、基于 OCaml
- Closurescript
- typescript,主要功能是为 JS 加上了类型标注,附带一些其他功能(如 enum、decorator、namespace),通过强大的类型系统成为方言之争最后的赢家。
另外一方面,ES 标准的更新要先于浏览器对 ES 标准的实现,很多新的 API 无法使用在旧版本浏览器上使用,因此通过将新 API 转义为旧 API,提高兼容性同时提高编程者的体验。 随着发展,逐渐形成一个转义层,用合适的插件系统,通过插件去转化不一样的语法 / 方言 (如 jsx、tsx)。
这其中又分为两类
- 一种是类语法糖性质的,新代码中有类似的 / 可替代概念(如
const -> let
),可以无痛转换为旧代码 - 另一种需要底层支持 (如 promise),需要用一个旧代码实现来模拟新特性,提供相同功能(称为 polyfill)
因此常规意义上作为解释型语言即用即跑的 JS,也引入了编译 / 转义的过程。
一个典型的过程如图:
打包#
在大型项目中,我们通过模块拆分,将一个 JS 程序拆分成成百上千乃至上万个独立文件,相互之间通过模块机制进行联接,从而降低开发人员的心智负担。
将视角拉回到 JS 的基本盘,在浏览器端,在数十年前,HTTP1.1 作为最为广泛的版本,存在着很多毛病,比如队头堵塞问题。
在这样的场景下,开发人员拆分的众多独立 JS 文件如果原样在浏览器端引用,让浏览器一个一个去请求,无疑是一场灾难。因此将多个模块文件打包到一起,放到一个或少数几个文件中,从而可以提高客户端的性能。这是打包最初的目的。
时至今日,随着各种优化方案的提出,打包这一过程,作为产物进入生产环境前的最后一道工序,逐渐变得臃肿起来。
Deps Resolver#
对于绝大部分项目,基本都会包含对其他包的依赖。因此在打包过程需要将使用到的依赖也一并打包。这还涉及到不同的模块化方案,因此在一些情况下涉及解析源码,读取 AST,将模块化方案替换(也可以通过 polyfill 实现,如 browserify)成适用于打包目标的情况(如 AMD/CommonJS 的替换)。
最初 browserify 实现了一个 CommonJS 的 polyfill,然后将依赖到的代码进行拼接,得到最终产物,依赖解析这一步并不独立。随着需求变更,现在更好的实现是生成一个依赖图,提供给其他步骤使用。
CodeSplitting#
在项目代码打包到一起之后,确实只需要一个请求就可以加载所有的脚本内容了,但是在大型项目中,这个最终产物有可能变得非常大。而实际情况下,很多代码也不会在一开始就用上。
因此将代码拆分为多个部分,最终产物可以拆成多个文件,在需要的时候进行加载。
代码拆分的基础是动态导入 (异步)。
// main.js
// static import
import doSomething from './lib1.js';
doSomething();
if (condition) {
await import('/lib2.js');
}
当打包器检测到动态导入时,可以生成两部分main.chunk.js
和 lib2.chunk.js
产物。
在浏览器执行main.chunk.js
时,如果条件匹配,就下载 lib.chunk.js
。
这样可以在一定程度上减轻首屏渲染的压力。
Note
在拆分成多个产物的情况下,浏览器完成了首屏渲染后,可以在空闲时对产物进行预取 / 预加载,减少动态导入时所需要的等待时间。
DeadCodeElimination/TreeShaking#
从实际情况来看,代码中通常会存在一些没有使用到的部分。可能是开发过程中为以后的特性预留的,也可能是依赖中没有使用到的部分。这些部分代码在最终产物中是无用的,因此可以清除掉。
最初的各种实现思路中,主要是通过静态分析分析出没有使用过的代码,随后进行清除,因此最开始称为DeadCodeElimination
。但这样存在的问题是,可能出现无法判断的情况,无法确认是否可以清除而不影响主要内容,只得保留。
后来转变了思路,从分析无用代码转变为分析使用过的代码,只保留使用过的部分,这种功能被称为TreeShaking
。
Note
值得注意的是,TreeShaking 与 ES 搭配有更好的效果,相比于 CommonJS,其静态分析难度更低。
Compressing/obfuscating#
在实际开发中,为了可读性,我们可能会为代码附带很长的注释、有语义的变量名 / 函数名。这很好,也很有用。但是在实际执行中,浏览器不会关心这些,因此我们可以将这些对浏览器无用的信息删除 / 替换,从而压缩产物大小。
另外一方面,为了安全性,提高逆向的难度,可以对产物进行代码混淆。
DevServer#
在实际开发(Web 开发)中,我们需要模拟一个环境,以模拟用户的体验,从而提高功能开发的正确性。
最初时,可以通过完全模拟的方式。每当代码发生变更时,手动走完完整的编译、打包、部署流程,然后手动进行测试。这样的反馈链路太耗时了。
所以构建工具通常会内置一个 Dev Server,然后通过监控开发文件变化,每当保存时,重新触发编译、打包,随后手动刷新页面,即可得到更新后的内容。但是这还是需要手动刷新,意味着页面上原有的状态都会被清空,重新加载。这在页面内容很多的情况下也是一个高负载。
HMR(Hot Module Replacement)#
全量刷新的成本很高,因此有人提出只替换更新的部分。在浏览器端引入一个 HMR Runtime
,每当更新时,通知该模块,将更新的信息交个它,由该模块对页面上需要更新的内容进行替换。这称为 HMR。
即使是这样,在大型项目中,HMR 的反馈链路可能还是很长。与此同时,浏览器对 ES 模块化也逐渐有了相对成熟的支持。因此我们可以回归原始,取消 bundler
的操作。直接将编译后基于 ES 模块的 js 文件更新同步到 HMR Runtime。实现 Unbundled Development。这可以大幅提升 HMR 的响应速度。
以 snowpack 的图为例。
Note
值得注意的是,在 unbundled 情况下,开发仍会遇到前面提到的问题,初始加载页面是会发起很多请求。这对用户客户端是个高负载,但但这是在本地开发环境的,同时有 HTTP2.0 支持来缓解大量并发请求的问题,这是可以接受的。
Build Tool(Task Runner)#
如此之多的步骤、工序。需要一个工具来将这些步骤串联起来,形成一个自动化流程。一种简单明了的方案当然是使用shell
脚本。但是这是一个相对普遍的需求,可以复用。因此在 JS 领域涌现出一批这样的工具,如Grunt
,通过合适的插件机制实现复用与定制。
另外一方面,经过上面的介绍也能发现涉及到的工具非常多,这本身也是一种心智负担,对新手也很不友好。因此许多新的构建工具尝试打造一个 All In One 的体验(如 Vite),去屏蔽这些工序,同时也方便着手进行一些优化。
Other Tool#
除了上面用于构建产品所需的一些工具之外,开过程发中也存在诸多需要解决的问题,也有相应的工具去解决。
Linter & Formatter#
团队开发中,因为每个人的代码风格不同,水平也有高有低,因此需要一个工具来保证一个统一的代码风格,保证一个下限。
对于 JS
,在其演进过程中,存在着多种方式实现同一目的,而有些方式被视为不好的实践,如不变量的 let
声明,ts
中的 any
类型等。通过 Linter 工具,检查这些问题的存在并报告。避免后续的隐性问题。如 ESLint
而在换行、空行、注释、缩进等更个人风格的事情上,Formatter 可以进行格式化。如Prettier
。
还有些新工具一并将这部分进行了统一处理。如 Biome
Monorepo management#
https://monorepo.tools/
在过去的项目中,我们习惯每个项目一个 git 仓库(polyrepo
)。
随着项目发展,出现了多项目结构,项目之间具有明确的关系,可以共同组成一个整体。
而且每一个都有一些通用的共同的依赖模块。如果仍然采用原来的结构,会十分的割裂,造成不一致、冗余。因此将这些有一定关系的项目放在的同一个仓库下称为 monorepo。
monorepo 的管理就成了问题。比如多个项目共同的依赖关系,项目之间依赖关系,构建任务的管理。典型的如 nx
,turborepo
,lerna
,gradle