动手实现一个简单的 webpack

315 阅读4分钟

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 的代码时,需要考虑下面的问题:

  • 每一个模块都有独立的作用域,确保模块内部的变量不会影响到其他模块,我们可以通过将模块代码用 () => {/* ... */} 包裹在函数作用域里来实现;
  • 模块之间的依赖关系需要通过 requiremodule.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 进行源码映射;
  • ...

希望本文对你有所帮助,感谢阅读。

参考