TLDR
完整的可执行代码在 StackBlitz 。
背景
JavaScript 诞生于 1995 年,很长一段时间里,JavaScript 都没有语言级的模块语法。这不是一个问题,因为最初的脚本又小又简单,所以没必要将其模块化。
直到 2009 年,随着 Node.js 的出现,CommonJS 作为 Node.js 的默认模块化解决方案在服务端大放异彩。然而开发者想要在浏览器端使用模块化开发,会面临的主要问题是:
CommonJS 这样的模块化开发方案被设计为同步加载,而浏览器通过网络加载资源,是天然异步的,会产生网络上的开销和时序上的问题。
于是社区开始出现 browserify / webpack 这样的构建工具,开发者可以通过模块化的语法编写代码,再通过构建工具将模块打包成浏览器能运行的代码。
时至今日,随着 2015年的 ES Module 规范的发布,我们已经可以在浏览器中使用 ES Module 语法进行开发,如下面的代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>hello world</title>
</head>
<body>
<div id="root"></div>
<script type="module">
import React from 'https://esm.sh/react@18'
import ReactDOM from 'https://esm.sh/react-dom@18'
const App = () => React.createElement('h1', null, 'Hello World')
ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(App))
</script>
</body>
</html>
但是我们在日常开发中,仍然需要一个构建工具来帮助我们打包代码,为什么呢:
- 浏览器兼容性问题。 目前浏览器对 ES Module 的支持还不完善。
- 代码分割和代码压缩。 使用构建工具能够更高效地进行代码分割和代码压缩。
- 过多的模块引用会导致 网络 I/O 的性能问题。大型项目很容易拥有上千个模块依赖,嵌套导入会导致额外的网络往返,即使使用 HTTP/2,网络 I/O 的开销也是不可忽略的。
本文将实现一个简单的 webpack,帮助读者理解一个构建工具的基本工作原理。
原理
从 webpack 的官网上我们可以了解到关于 webpack 的基本概念:
webpack 是一个用于现代 JavaScript 应用程序的 静态模块打包工具。当 webpack 处理应用程序时,它会在内部从一个或多个入口点构建一个 依赖图(dependency graph),然后将你项目中所需的每一个模块组合成一个或多个 bundles,它们均为静态资源,用于展示你的内容。
我们可以看到 webpack 干了这样的事情(为了简单起见,我们只考虑一个文件入口,和生成一个 js 文件输出的场景):
- 从一个根结点开始遍历依赖关系;
- 根据遍历的结果生成一个依赖图;
- 根据依赖图生成一个最终可在浏览器端执行的 js 文件。
实现
首先,构造一个简单的项目结构:
example
├── entry.js
├── utils.js
└── var.js
其中 entry.js 是入口文件,utils.js 和 var.js 是 entry.js 的依赖。具体代码如下:
// entry.js
import { add } from './utils.js'
import { a, b } from './var.js'
console.log(`${a} + ${b} = ${add(a, b)}`)
// utils.js
export function add(a, b) {
return a + b
}
// var.js
export const a = 1
export const b = 2
依赖分析
接下来,我们需要实现一个函数,这个函数接收一个文件路径作为参数,返回这个文件的依赖关系。我们将这个函数命名为 parse:
const parser = require('@babel/parser')
function parse(pathname) {
const content = fs.readFileSync(pathname, 'utf-8')
const ast = parser.parse(content, {
sourceType: 'module'
})
const dependencies = []
const importDeclarations = ast.program.body.filter(item => item.type === 'ImportDeclaration')
importDeclarations.forEach(item => {
dependencies.push(path.join(path.dirname(pathname), item.source.value))
})
return {
pathname,
dependencies,
}
}
代码中我们借助 @babel/parser 这个库来将源代码转换成 AST,然后遍历 AST,找到所有的 import 声明,将其依赖的文件路径收集起来。读者可以在 AST Explorer 这个网站中查看源代码转换成 AST 的结果。
除此之外,我们还需要对源码进行一些转换:
- 将 ES Module 的语法转换成 CommonJS 的语法。因为我们的构建工具最终要在浏览器端运行,CommonJS 语法中的 require 和 module.exports 都是简单的函数和对象,我们可以在浏览器端实现他们,如果读者看过 webpack 的开发环境构建产物,可以看到 webpack 就实现了一个 webpack_require 方法。将 ES Module 的语法转换成 CommonJS 的语法我们可以借助 babel 插件
@babel/plugin-transform-modules-commonjs; - 将相对路径转换成绝对路径。因为每个模块都需要一个唯一 ID 来进行索引,我们把文件的绝对路径作为索引 ID 方便后面打包时使用(具体实现见后文)。
// 代码略...
const { code } = babel.transformFromAstSync(ast, null, {
plugins: [
pluginTransfromPath2AbsolutePath,
["@babel/plugin-transform-modules-commonjs", { "strict": true }]
],
})
// 代码略...
return {
pathname,
dependencies,
code,
}
对入口文件执行 parse 函数,可以得到这样的结果:
{
pathname: 'example/entry.js',
dependencies: [ 'example/utils.js', 'example/var.js' ],
code: '"use strict";\n' +
'\n' +
'var _utils = require("./utils.js");\n' +
'var _var = require("./var.js");\n' +
'console.log(`${_var.a} + ${_var.b} = ${(0, _utils.add)(_var.a, _var.b)}`);'
}
依赖图
接下来,我们开始构造依赖图,我们从入口文件开始,遍历文件树,将遍历的结果存在数组里,因为打包后的产物需要从入口文件开始执行,数组结构可以方便我们找到入口文件的代码。具体代码如下:
function createGraph(entry) {
const mainAsset = parse(entry)
const queue = [mainAsset]
// 从根节点开始进行层序遍历
for (const asset of queue) {
asset.dependencies.forEach(pathname => {
const child = parse(pathname)
queue.push(child)
})
}
return queue
}
打包
有了依赖图,我们就可以开始打包了,我们将编写一个 bundle 函数,bundle 的工作是,通过我们编写的一段代码,生成另一段可执行的代码。编写 bundle 的代码时,需要考虑下面的问题:
- 每一个模块都有独立的作用域,确保模块内部的变量不会影响到其他模块,我们可以通过将模块代码用
() => {/* ... */}包裹在函数作用域里来实现; - 模块之间的依赖关系需要通过
require和module.exports来串联,我们需要实现require方法和module.exports对象。
由 bundle 生成的目标代码大致如下(文章后面的完整代码和下面的代码组织结构略有不同):
(() => {
const modules = {
'example/entry.js': (module, exports, require) => {
// entry.js 的代码
},
'example/utils.js': (module, exports, require) => {
// utils.js 的代码
},
//...
}
// 自定义的 require 方法,向每个模块注入 module, module.exports, require 三个参数
const require = (pathname) => {
const fn = modules[pathname]
const module = { exports: {}}
fn(require, module, module.exports)
return module.exports
}
// 从入口文件开始执行,这里的入口文件是我们构造的依赖图的第一项
require('example/entry.js')
})()
bundle 函数实现如下:
function bundle(graph) {
let modules = ""
graph.forEach((mod) => {
modules += /* js */`
"${mod.pathname}":function (require, module, exports){
${mod.code}
},
`
})
const result = /* js */`
(function(modules){
function require(pathname) {
const fn = modules[pathname]
const module = {exports: {}}
fn(require, module, module.exports)
return module.exports
}
require("${graph[0].pathname}")
})({${modules}})
`;
return result
}
读者可以在 StackBlitz 上看到完整的代码实现。
总结
以上是一个省略了大量细节的简单的 webpack 的实现,webpack 作为时下最流行的构建工具,还做了很多其他的工作,比如:
- 处理非 js 资源;
- 通过 webpack-dev-server 和 HMR 提高开发体验;
- 优秀的代码分割和 tree shaking 能力;
- 插件和 loader 机制;
- 利用 source maps 进行源码映射;
- ...
希望本文对你有所帮助,感谢阅读。