最近在前端论坛闲逛, 看到了一些讲 parcel,webpack 的文章, 就突然很好奇, 每天都在用的打包工具, 他们打包的原理究竟是什么. 只有知道了这一点, 才可以在众多的打包工具里, 找到最适合的那个它. 在了解打包原理之前, 先花一些篇章说明了一下为什么要使用打包工具.
0. 模块系统
前端产品的交付是基于浏览器, 这些资源是通过增量加载的方式运行到浏览器端, 如何在开发环境组织好这些碎片化的代码和资源, 并且保证他们在浏览器端快速, 优雅的加载和更新, 就需要一个模块化系统. 这个理想中的模块化系统是前端工程师多年来一直探索的难题.
模块系统主要解决模块的定义, 依赖和导出. 原始的 <script> 标签加载方式有一些常见的弊端: 例如全局作用域下容易造成变量冲突; 文件只能按照 < script > 的书写顺序进行加载; 开发人员必须主观解决模块和代码库的依赖关系等.
因此衍生出很多模块化方案:
1.CommonJs: 优点: 服务器端模块便于重用. 缺点: 同步的模块加载方式不适合在浏览器环境中, 同步意味着阻塞加载, 浏览器资源是异步加载的.
2.AMD: 依赖前置. 优点: 适合在浏览器环境异步加载; 缺点: 阅读和书写比较困难.
3.CMD: 依赖就近, 延迟执行. 优点: 很容易在 node 中运行; 缺点: 依赖 spm 打包, 模块的加载逻辑偏重.
4.ES6 模块:: 尽量的静态化, 使得编译时就能确定模块的依赖关系, 以及输入输出的变量. CommonJS 和 AMD 模块, 都只能在运行时确定这些东西. 优点: 容易进行静态分析; 缺点: 原生浏览器未实现该标准.
说到模块的加载和传输, 若是每个文件都单独请求, 会导致请求次数过多, 导致启动速度过慢. 若是全部打包在一块只请求一次, 会导致流量浪费, 初始化过程慢. 因此最佳方案是分块传输, 按需进行懒加载, 在实际用到某些模块的时候再增量更新. 要实现模块的按需加载, 就需要一个对整个代码库中的模块进行静态分析, 编译打包的过程. Webpack 就是在这样的需求中应运而生.
注: 要注意一个概念, 一切皆模块. 样式, 图片, 字体, html 模板等等众多的资源, 都可以视作模块.
1. 模块打包器: webpack
Webpack 是一个模块打包器. 它将根据模块的依赖关系进行静态分析, 然后将这些模块按照指定的规则生成对应的静态资源. 那么问题来了, webpack 真的能做到上述提到的静态分析, 编译打包么? 我们首先来看一下 webpack 能做什么:
1. 代码拆分 Webpack 有两种组织模块依赖的方式, 同步和异步. 异步依赖作为分割点, 形成一个新的块. 在优化了依赖树后, 每一个异步区块都作为一个文件被打包.
2.Loader Webpack 本身只能处理原生的 JavaScript 模块, 但是 loader 转换器可以将各种类型的资源转换成 JavaScript 模块. 这样, 任何资源都可以成为 Webpack 可以处理的模块.
3. 智能解析 Webpack 有一个智能解析器, 几乎可以处理任何第三方库, 无论它们的模块形式是 CommonJS, AMD 还是普通的 JS 文件.
4. 插件系统 Webpack 还有一个功能丰富的插件系统. 大多数内容功能都是基于这个插件系统运行的, 还可以开发和使用开源的 Webpack 插件, 来满足各式各样的需求.
5. 快速运行 Webpack 使用异步 I/O 和多级缓存提高运行效率, 这使得 Webpack 能够以令人难以置信的速度快速增量编译.
以上是 webpack 五个主要特点, 但是看完还是觉得有些雾里看山, webpack 到底是如何把一些分散的小模块, 整合成大模块? 又是如何处理好各模块的依赖关系? 下面就以 parcel 核心开发者 @ronami 的开源项目 minipack 为例, 说明以上问题.
2. 打包工具核心原理 -- 以 minipack 为例
打包工具就是负责把一些分散的小模块, 按照一定的规则整合成一个大模块的工具. 与此同时, 打包工具也会处理好模块之间的依赖关系, 将项目运行在平台上. minipack 项目最想说明的问题, 也是打包工具最核心的部分, 就是如何处理好模块间的依赖关系.
首先, 打包工具会从一个入口文件开始, 分析里面的依赖, 并进一步地分析依赖中的依赖. 我们新建三个文件, 并建立依赖:
- /* name.js */
- export const name = 'World'
- /* message.js */
- import { name } from './name.js'
- export default `Hello ${name}!`
- /* entry.js */
- import message from './message.js'
- console.log(message)
首先引入必要的工具
- /* minipack.js */
- const fs = require('fs')
- const path = require('path')
- const babylon = require('babylon')
- const traverse = require('babel-traverse').default
- const { transformFromAst } = require('babel-core')
接着我们将创建一个函数, 参数是文件的路径, 作用是读取文件内容并提取它的依赖关系.
- function createAsset(filename) {
- // 以字符串形式读取文件的内容.
- const content = fs.readFileSync(filename, 'utf-8');
- // 现在我们试图找出这个文件依赖于哪个文件. 虽然我们可以通过查看其内容来获取 import 字符串. 然而, 这是一个非常笨重的方法, 我们将使用 JavaScript 解析器来代替.
- // JavaScript 解析器是可以读取和理解 JavaScript 代码的工具, 它们生成一个更抽象的模型, 称为 `ast (抽象语法树)(https://astexplorer.net)`.
- const ast = babylon.parse(content, {
- sourceType: 'module',
- });
- // 定义数组, 这个数组将保存这个模块依赖的模块的相对路径.
- const dependencies = [];
- // 我们遍历 `ast` 来试着理解这个模块依赖哪些模块, 要做到这一点, 我们需要检查 `ast` 中的每个 `import` 声明.
- // `Ecmascript` 模块相当简单, 因为它们是静态的. 这意味着你不能 `import` 一个变量, 或者有条件地 `import` 另一个模块. 每次我们看到 `import` 声明时, 我们都可以将其数值视为 ` 依赖性 `.
- traverse(ast, {
- ImportDeclaration: ({node}) =>
- // 我们将依赖关系存入数组
- dependencies.push(node.source.value);
- },
- });
- // 我们还通过递增简单计数器为此模块分配唯一标识符.
- const id = ID++;
- // 我们使用 `Ecmascript` 模块和其他 JavaScript, 可能不支持所有浏览器.
- // 为了确保我们的程序在所有浏览器中运行,
- // 我们将使用 [babel](https://babeljs.io) 来进行转换.
- // 我们可以用 `babel-preset-env`` 将我们的代码转换为浏览器可以运行的东西.
- const {code} = transformFromAst(ast, null, {
- presets: ['env'],
- });
- // 返回有关此模块的所有信息.
- return {
- id,
- filename,
- dependencies,
- code,
- };
- }
现在我们可以提取单个模块的依赖关系, 那么, 我们将提取它的每一个依赖关系的依赖关系, 并循环下去, 直到我们了解应用程序中的每个模块以及他们是如何相互依赖的.
- function createGraph(entry) {
- // 首先解析整个文件.
- const mainAsset = createAsset(entry);
- // 我们将使用 queue 来解析每个 asset 的依赖关系.
- // 我们正在定义一个只有 entry asset 的数组.
- const queue = [mainAsset];
- // 我们使用一个 `for ... of` 循环遍历 队列.
- // 最初 这个队列 只有一个 asset, 但是当我们迭代它时, 我们会将额外的 assert 推入到 queue 中.
- // 这个循环将在 queue 为空时终止.
- for (const asset of queue) {
- // 我们的每一个 asset 都有它所依赖模块的相对路径列表.
- // 我们将重复它们, 用我们的 `createAsset() ` 函数解析它们, 并跟踪此模块在此对象中的依赖关系.
- asset.mapping = {};
- // 这是这个模块所在的目录.
- const dirname = path.dirname(asset.filename);
- // 我们遍历其相关路径的列表
- asset.dependencies.forEach(relativePath => {
- // 我们可以通过将相对路径与父资源目录的路径连接, 将相对路径转变为绝对路径.
- const absolutePath = path.join(dirname, relativePath);
- // 解析 asset, 读取其内容并提取其依赖关系.
- const child = createAsset(absolutePath);
- // 了解 `asset` 依赖取决于 `child` 这一点对我们来说很重要.
- // 通过给 `asset.mapping` 对象增加一个新的属性 (值为 child.id) 来表达这种一一对应的关系.
- asset.mapping[relativePath] = child.id;
- // 最后, 我们将 `child` 这个资产推入队列, 这样它的依赖关系也将被迭代和解析.
- queue.push(child);
- });
- }
- return queue;
- }
接下来我们定义一个函数, 传入上一步的 graph, 返回一个可以在浏览器上运行的包.
- function bundle(graph) {
- let modules = '';
- // 在我们到达该函数的主体之前, 我们将构建一个作为该函数的参数的对象.
- // 请注意, 我们构建的这个字符串被两个花括号 ({}) 包裹, 因此对于每个模块,
- // 我们添加一个这种格式的字符串: `key: value,`.
- graph.forEach(mod => {
- // 图表中的每个模块在这个对象中都有一个 entry. 我们用模块的 id` 作为 `key`, 用数组作为 `value`
- // 第一个参数是用函数包装的每个模块的代码. 这是因为模块应该被限定范围: 在一个模块中定义变量不会影响其他模块或全局范围.
- // 对于第二个参数, 我们用 `stringify` 解析模块及其依赖之间的关系(也就是上文的 asset.mapping). 解析后的对象看起来像这样: `{'./relative/path': 1}`.
- // 这是因为我们模块的被转换后会通过相对路径来调用 `require()`. 当调用这个函数时, 我们应该能够知道依赖图中的哪个模块对应于该模块的相对路径.
- modules += `${mod.id}: [
- function (require, module, exports) { ${mod.code} },
- ${JSON.stringify(mod.mapping)},
- ],`;
- });
运行!
- const graph = createGraph('./example/entry.js');
- const result = bundle(graph);
- // 得到结果, 开心!
- console.log(result);
3. 总结
webpack 解决了包与包之间潜在的循环依赖难题, 同时, 按需合并静态文件, 以避免浏览器在网络取数阶段的并发瓶颈. 除了打包, 还可以进一步实现压缩 (减少网络传输) 和编译 (ES6,JSX 等语法向下兼容) 的功能.
基于对 webpack.config.js 文件的配置, 执行打包时的工作原理, 可总结为: 把页面逻辑当作一个整体, 通过一个给定的入口文件, webpack 从这个文件开始, 找到所有的依赖文件, 进行打包, 编译, 压缩, 最后输出一个浏览器可识别的 JS 文件.
一个模块打包工具, 第一步会从入口文件开始, 对其进行依赖分析, 第二步对其所有依赖再次递归进行依赖分析, 第三步构建出模块的依赖图集, 最后一步根据依赖图集使用 CommonJS 规范构建出最终的代码.
4. 参考网址
https://mp.weixin.qq.com/s/w-oXmHNSyu0Y_IlfmDwJKQ https://github.com/chinanf-boy/minipack-explain/blob/master/src/minipack.js https://zhaoda.net/webpack-handbook/configuration.html
来源: https://juejin.im/post/5b38d27451882574d87aa5d5