什么是前端工程化
来源:Lucas HC
https://www.zhihu.com/question/433854153/answer/1713597311
我认为,「什么是前端工程化」——这是一个很好的问题,但同时也是一个非常「务虚」的问题。
因为前端工程化是一个极度宽泛且宏大的概念,我们很难去下一个定义,也无法给出一个样例来解释。我试图从工程(构建)工具对比和一个线上 bug 的处理来侧面说明。
工具篇
提到工程化(构建)工具,作为经验丰富的前端开发者,相信你能列举出不同时代的代表:从 Browserify + Gulp 到 Parcel,从 Webpack 到 Rollup,甚至 @尤雨溪编写的 Vite,相信你也并不陌生。没错,前端发展到现在,工程化工具琳琅满目。但很多工具的实现和设计非常复杂,甚至出现了「面向 webpack 编程」的调侃。
ToolingReport 是由 Chrome core team 核心成员以及业内著名开发者打造的构建工具比对平台。这个平台对比了 Webpack v4、Rollup v2、Parcel v2、Browserify + Gulp 在不同维度下的表现,如下图所示:
测评通过的 test 得分只是一个方面,实际情况也和不同构建工具的设计目标有关。比如,Webpack 的构建主要依赖了插件和 loader,因此它的能力虽然强大,但配置信息较为烦琐。而 Parcel 的设计目标之一就是零配置,开箱即用,但是在功能的集成上相对有限。
但从工程化的角度出发,我们还是从上面的分数分析,来看看这些分数评测的维度。这些分数来自以下 6 个维度的评测:
Code Splitting Hashing Importing Modules Non-JavaScript Resources Output Module Formats Transformations
和工程化主题相关的是:这 6 个维度到底是什么,为什么它们能作为考量指标被选取为评测参考标准?下面我们逐一进行分析。
Code Splitting,即代码分割。这意味着在构建打包时,能够将静态资源拆分,因此在页面加载时,实现最合理的按需加载策略。
实际上,Code Splitting 是一个很深的话题。比如:不同模块间的代码分割机制能否支持不同的上下文环境(Web worker 环境等特殊上下文情况),如何实现对 Dynamic Import 语法特性的支持,应用配置多入口/单入口时是否支持重复模块的抽取并打包,代码模块间是否支持 Living Bindings(如果被依赖的 module 中的值发生了变化,则会映射到所有依赖该值的模块中)。
总之,Code Splitting 直接决定了前端的静态资源产出情况,影响着项目应用的性能表现。是前端工程化这颗大树的一个分支。
Hashing,即对打包资源进行版本信息映射。这个话题背后的重要技术点是最合理地利用缓存机制。我们知道有效的缓存策略将直接影响页面加载表现,决定用户体验。那么对于前端工程化来说,为了实现更合理的 hash 机制,工具就需要分析各种打包资源,导出模块间依赖关系,依据依赖关系上下文决定产出包的哈希值。因为一个资源的变动,将会引起其依赖下游的关联资源变动,因此工程工具进行打包的前提就是对各个模块依赖关系进行分析,并根据依赖关系,支持开发者自行定义哈希策略。比如,Webpack 提供的不同类型 hash 的区别:hash/chunkhash/contenthash,这三种 hashing 策略你都了解吗?为什么有这三种策略的设计呢?具体我就不展开了。
Output Module Formats,工程输出的模块化方式也需要更加灵活,比如开发者可配置 ESM、CommonJS 等规范的构建内容导出。
Transformations,前端工程化离不开编译/转义过程。比如对 JavaScript 代码的压缩、对无用代码的删除(DCE)等。这里需要站在工程化视觉上注意的是,我们在设计构建工具时,对于类似 JSX 的编译、.vue 文件的编译,不会内置到工具当中,而是利用 Babel 等社区能力,「无缝融合」到工程化流程里。工程化工具只做分内的事情,其他扩展能力通过插件化机制来完成,显然是一个非常工程化的设计。
其他 Importing Modules 以及 Non-JavaScript Resources 我不多说了,虽然这是评测工程化工具的几个大方向,但每一个都是前端工程化的重要主题。
线上问题篇
这一部分,让我们以一篇文章《报告老板,我们的 H5 页面在 iOS 11 系统上白屏了!》分析,我先简单梳理和总结一下文章表达的内容,读者看我总结即可:
笔者发现某些机型上出现页面白屏情况; 出现在报错页面上的信息非常明显,即当前浏览器不支持 ...
扩展运算符;出错的代码(使用了扩展运算符的代码)属于某个公共库代码,它没有使用 Babel 插件进行降级处理,因此线上源代码出现了 ...
扩展运算符。
现在问题找到了,或许直接将出现问题的公共库代码用 Babel 进行编译降级就可以了。在文中环境下,需要在 vue.config.js
中加入对问题公共库 module-name/library-name
的 Babel 编译流程:
transpileDependencies: [
'module-name/library-name' // 出现问题的那个库
],
vue-cli 对 transpileDependencies 也有如下说明:
默认情况下 babel-loader 会忽略所有
node_modules
中的文件。如果你想要通过 Babel 显式转译一个依赖,可以在这个选项中列出来。
按照上述操作,却得到了新的报错:Uncaught TypeError: Cannot assign to read only property 'exports' of object '#<Object>'
。
究其原因,module-name/library-name
这个库对外输出的是 CommonJS 类型源码,我们对该库进行编译后,项目基础设施中会通过 babel-transform-runtime 在编译时增加 helper 代码,而这些 helper 使用的是 import 引入。最终编译结果出现了 ESM 包含 CommonJS 的情况,是不会被 Webpack 处理的。
我再次分析下出现的新的问题:
plugin-transform-runtime 会根据 sourceType 选择注入 import 或者 require,sourceType 的默认值是 module,就会默认注入 import; Webpack 不会处理包含 import/export 的文件中的 module.exports 导出,所以需要让 Babel 自动判断 sourceType,根据文件内是否存在 import/export 来决定注入什么样的代码。
为了适配上述问题,Babel 设置了 sourceType
属性,sourceType:unambiguous
表示 Babel 会根据文件上下文(比如是否含有 import/export)来决定是否按照 ESM 语法处理文件。
这时候就需要配置 Babel 内容了:
module.exports = {
... // 省略的配置
sourceType: 'unambiguous',
... // 省略的配置
}
但是这种做法在工程上并不推荐,上述更改方式对所有编译文件都生效,但也增加了编译成本(因为设置 sourceType:unambiguous
后,编译时需要做的事情更多),还有个潜在问题:
Unambiguous can be quite useful in contexts where the type is unknown, but it can lead to false matches because it's perfectly valid to have a module file that does not use import/export statements.
翻译过来,就是说并不是所有的 ESM 模块(这里指使用 ESNext 特性的文件)都含有 import/export,因此即便某个待编译文件属于 ESM 模块,也可能被 Babel 错误地判断为 CommonJS 模块而引发误判。
**基于这一点,一个更合适的做法是:**只对目标第三方库 'module-name/library-name'
使用 sourceType:unambiguous
,这时 Babel overrides 属性就派上用场了:
Allows users to provide an array of options that will be merged into the current configuration one at a time. This feature is best used alongside the "test"/"include"/"exclude" options to provide conditions for which an override should apply.
具体使用方式:
module.exports = {
... // 省略的配置
overrides: [
{ include: './node_modules/module-name/library-name/name.common.js', // 使用的第三方库
sourceType: 'unambiguous'
}
],
... // 省略的配置
};
至此,这个“iOS 11 系统白屏”问题就算告一段落了(你有没有被各种配置和设计搞得云里雾里?)。
我整理了解决路线,如下图所示:
我们回过头再来看这个问题,问题其实出现在一个公共库上,因而前端生态的混乱和复杂也许是更本质的原因,但这都转嫁为前端工程化的难点。
我们进一步思考:
作为公共库,我应该如何构建编译代码,让业务方更有保障地使用? 作为使用者,我应该如何处理第三方公共库,是否还需要对其进行额外编译和处理?
被动地发现问题、解决问题只会让我们被「牵着鼻子走」——这不是我们的目的。感兴趣的读者可以点赞,关注,我会很快输出更多关于「前端工程化」的内容。
最后的话
对于很多前端工程师来说,你可能配置过 Babel/Webpack,也可能看过一些关于 Babel/Webpack 插件或原理的文章。但我认为,通过阅读几篇 Babel/Webpack 插件编写甚至 AST 分析的文章并不能让我们真正掌握前端工程化。这也完全完全不是前端工程化的要义。
「配置工程师」只是我们的起点。作为前端开发者,你可能会被繁琐的配置和工具所困扰,自己的终端脆弱无比,出现各种报错。此时,你可能花费了一天的时间,通过 Google 找到了最终的配置解法;或者通过:
rm -rf node_modules + npm install + npm run dev
规避了问题。但是解决之道却没搞清楚,得过且过,今后依然被类似的困境袭扰。
当我们对配置、工具、构建流程、架构设计、生产发布等环节的各种挑战和问题能有系统化的思考时,「前端工程化」自然也不会再是一个困惑。
其实很抱歉我无法回答题主这个宏大的问题,我自己也受此困扰,仅以两个小的细节方面抛砖引玉(闲时我也会持续输出更多关于「前端工程化」的内容)。
总之,前端既收获着快速发展,也迎接着批量劣汰;前端技术有着与生俱来的混乱,也有着与之抗衡的规范 —— 这都对前端工程化提出了更高的挑战。