Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

现代前端科技解析 —— Javascript Bundler #66

Open
jin5354 opened this issue Oct 18, 2017 · 0 comments
Open

现代前端科技解析 —— Javascript Bundler #66

jin5354 opened this issue Oct 18, 2017 · 0 comments

Comments

@jin5354
Copy link
Owner

jin5354 commented Oct 18, 2017

title: 现代前端科技解析 —— Javascript Bundler
categories:

  • Code
    tags:
  • Modern web development tech analysis
  • 现代前端科技解析
  • webpack
  • javascript bundler
    toc: true
    date: 2017-10-16 15:45:11

现代的单页应用开发已经离不开 Webpack 类打包工具的帮助。本文将以 Webpack 1 为例解析 Javascript bundler 的工作原理,包括 code splitting。

注:
原始链接: https://www.404forest.com/2017/10/16/modern-web-development-tech-analysis-javascript-bundler/
文章备份: #66

1. 分析 bundle.js

我们创建一个最小化的 bundle 示例,查看打包产物是什么样子:

entry.js

const a = require('./a.js')
const b = require('./b.js')

a()
b()

a.js

function a() {
  console.log('module a function')
}

module.exports = a

b.js

const c = require('./c.js')

function b() {
  c()
  console.log('module b function')
}

module.exports = b

c.js

function c() {
  console.log('module c function')
}

module.exports = c

共 4 个模块,entry、a、b、c,entry.js 为入口。打包后:

bundle.js

/******/(function(modules) {
  /******/	const installedModules = {}
  /******/	function require(moduleId) {
  /******/		if(installedModules[moduleId]) {
  /******/       return installedModules[moduleId].exports
  /******/    }
  /******/		const module = installedModules[moduleId] = {
  /******/			exports: {}
  /******/		}
  /******/		modules[moduleId](module, module.exports, require)
  /******/		return module.exports
  /******/	}
  /******/	return require(0)
  /******/})/******/({
/******/0: function(module, exports, require) {

const a = require(/* ./a.js */1)
const b = require(/* ./b.js */2)

a()
b()


/******/},
/******/
/******/1: function(module, exports, require) {

function a() {
  console.log('module a function')
}

module.exports = a


/******/},
/******/
/******/2: function(module, exports, require) {

const c = require(/* ./c.js */3)

function b() {
  c()
  console.log('module b function')
}

module.exports = b


/******/},
/******/
/******/3: function(module, exports, require) {

function c() {
  console.log('module c function')
}

module.exports = c


/******/},
/******/
/******/})

执行该文件,会正常输出:

module a function
module c function
module b function

分析 bundle 可知:

  1. bundle 为自执行函数,函数接收一个参数:modules
  2. modules 是 object, entry、a、b、c 等所有用到的模块包裹为一个函数。模块中所需的变量 module, require 将由参数传入。模块函数以编号为 key 按顺序挂在其下面。
  3. 所有模块内部的 require 函数的参数由相对路径被替换成了模块编号
  4. 定义了 require 函数,接收模块编号作为参数。当执行 require 时先寻找 installedModules[模块编号] 是否存在,若不存在,则执行被 require 的模块函数,并将 module.exports 值挂在 installedModules[模块编号] 上。
  5. 由上条可知 installedModules 为缓存,这样在多次 require 同一个模块时不必多次执行该模块函数。
  6. 自执行函数中通过 require(0) 执行入口js,启动程序

简单画一个示例图:

bundler-1

2. 实现基本的 bundle 功能

可参考 leaf-bundler 的在这个 commit 时的代码

2.1 读取 config

bundler 是命令行程序,我们一般这样用:

$ bundler --config example/example1/bundler.config.js

bundler.config.js

module.exports = {
  entry: './entry.js',
  output: {
    filename: 'bundle.js'
  }
}

命令行的支持通过 commander 或同类模块可以轻松实现。获得 config 文件的相对路径后,直接 require(path.resolve(configPath)) 即可拿到 config 文件的内容。此处不赘述,可参考 bin/leaf-bundler 相关代码。

2.2 依赖收集

依赖收集是 bundle 过程中最关键的一环。我们需要从 entry 开始遍历分析每个模块,收集所有依赖的文件路径等信息,构造出一棵依赖树。

如何从模块中收集依赖?使用正则表达式可以匹配出代码中包含 require 的部分,但正则一是难以过滤注释中的代码,二是难以分析复杂的 require 表达式(如 require('a' + 'b')())。对于分析代码含义的需求,最恰当的是使用 Javascript Parse 工具,如 esprima

调用 esprima 分析如下代码,会产生一个语法树:

const a = require('./a.js')
a()
{
  "type": "Program",
  "body": [
    {
      "type": "VariableDeclaration",
      "declarations": [
        {
          "type": "VariableDeclarator",
          "id": {
            "type": "Identifier",
            "name": "a"
          },
          "init": {
            "type": "CallExpression",
            "callee": {
              "type": "Identifier",
              "name": "require"
            },
            "arguments": [
              {
                "type": "Literal",
                "value": "./a.js",
                "raw": "'./a.js'"
              }
            ]
          }
        }
      ],
      "kind": "const"
    },
    {
      "type": "ExpressionStatement",
      "expression": {
        "type": "CallExpression",
        "callee": {
          "type": "Identifier",
          "name": "a"
        },
        "arguments": []
      }
    }
  ],
  "sourceType": "script"
}

纵览一下这个 AST 语法树即可发现 esprima 将代码的全部语义都进行了提取。代码包括两句,第一句是变量声明(VariableDeclaration),第二句是表达式(ExpressionStatement);变量声明时,变量名为 a,赋值内容为函数表达式(CallExpression),函数名为 require,值为字面量(Literal)"./a.js"……

通过深度优先遍历这个 AST ,查找 require 相关表达式,我们就可以准确的识别出某一段代码中的依赖了。具体代码可见 parse.js。拿到依赖后,递归对依赖文件进行分析。

由于在之后的流程中需要将 require('./a.js') 替换为模块代码 require(1),使用 esprima 时还需添加选项 {range: true},记录 require 的参数的具体位置。从入口文件开始进行依赖分析,随后通过递归,对依赖继续进行分析,直到收集全所有依赖。重点要收集到:模块的绝对路径名(可作为唯一标识符)、模块源码、模块ID、模块的依赖、依赖的绝对路径名、ID和其在源码中的位置。

对第一节的例子进行依赖收集,最终的依赖树如下所示:

{
  "/Users/jin/playground/leaf-bundler/example/example1/entry.js": {
    "id": 0,
    "filename": "/Users/jin/playground/leaf-bundler/example/example1/entry.js",
    "name": "./entry.js",
    "requires": [
      {
        "name": "./a.js",
        "filename": "/Users/jin/playground/leaf-bundler/example/example1/a.js",
        "nameRange": [
          18,
          26
        ],
        "id": 1
      },
      {
        "name": "./b.js",
        "filename": "/Users/jin/playground/leaf-bundler/example/example1/b.js",
        "nameRange": [
          46,
          54
        ],
        "id": 2
      }
    ],
    "asyncs": [],
    "source": "const a = require('./a.js')\nconst b = require('./b.js')\n\na()\nb()\n"
  },
  "/Users/jin/playground/leaf-bundler/example/example1/a.js": {
    "id": 1,
    "filename": "/Users/jin/playground/leaf-bundler/example/example1/a.js",
    "name": "./a.js",
    "requires": [],
    "asyncs": [],
    "source": "function a() {\n  console.log('module a function')\n}\n\nmodule.exports = a\n"
  },
  "/Users/jin/playground/leaf-bundler/example/example1/b.js": {
    "id": 2,
    "filename": "/Users/jin/playground/leaf-bundler/example/example1/b.js",
    "name": "./b.js",
    "requires": [
      {
        "name": "./c.js",
        "filename": "/Users/jin/playground/leaf-bundler/example/example1/c.js",
        "nameRange": [
          18,
          26
        ],
        "id": 3
      }
    ],
    "asyncs": [],
    "source": "const c = require('./c.js')\n\nfunction b() {\n  c()\n  console.log('module b function')\n}\n\nmodule.exports = b\n"
  },
  "/Users/jin/playground/leaf-bundler/example/example1/c.js": {
    "id": 3,
    "filename": "/Users/jin/playground/leaf-bundler/example/example1/c.js",
    "name": "./c.js",
    "requires": [],
    "asyncs": [],
    "source": "function c() {\n  console.log('c')\n}\n\nmodule.exports = c\n"
  }
}

2.3 源码 require 替换

由于最终模块都以 ID 为 key 挂在一个对象上,我们需要将源码中的 require('./a.js') 改成 require(1) 来保证 require 的正常工作。在依赖收集阶段,我们已经拿到了每个模块中的 require 的参数的具体位置,只需对源码用 splice 进行替换即可。

特别要注意的是,如果一个模块代码中有多个 require,需要进行多次替换,必须要从后向前进行替换。从前进行替换会导致其后的 require 位置发生变动,依赖收集时提供的位置数值就不准确了。替换部分代码可见 writeSource.js

以 entry.js 为例,替换前后如下所示:

const a = require('./a.js')
const b = require('./b.js')
...
const a = require(/* ./a.js */1)
const b = require(/* ./b.js */2)
...

2.4 拼装生成

拼装最终 bundle 就很简单了,参考第一节的示例进行拼装:

头部:

/******/(function(modules) {
  /******/	const installedModules = {}
  /******/	function require(moduleId) {
  /******/		if(installedModules[moduleId]) {
  /******/       return installedModules[moduleId].exports
  /******/    }
  /******/		const module = installedModules[moduleId] = {
  /******/			exports: {}
  /******/		}
  /******/		modules[moduleId](module, module.exports, require)
  /******/		return module.exports
  /******/	}
  /******/	return require(0)
  /******/})/******/({

循环注入模块:

// 伪代码
/******/
/******/{{模块ID}}: function(module, exports, require) {

{{模块内容}}

/******/},

尾部:

/******/
/******/})

3. code splitting

code splitting 将一个 bundle 切成了多个 chunk,且异步 chunk 为懒加载的——执行到 require.ensure 时才拉取并执行。为了实现这个功能,大体思路如下:

  • 通过 require.ensure 标识新 chunk
  • 依赖收集时,单独标识异步依赖
  • 执行 require.ensure 时,拉取新 chunk
  • 新 chunk 设计为一个 jsonp 函数,由 webpackJsonp 函数包裹
  • 实现 webpackJsonp 函数,其会将新拉下来的 chunk 中的模块添加到主 modules 上,随后执行 require.ensure 的回调

code splitting 相关代码可参考这个 commit

本节的例子会将 entry.js 中的依赖 b 改为异步依赖,其他不变。

entry.js

const a = require('./a.js')
a()

require.ensure(['./b.js'], () => {
  const b = require('./b.js')
  b()
})

3.1 单独收集异步依赖

单独收集 require.ensure 所标识的依赖,并为每个 chunk 赋予 ID。依然使用 esprima 解析出语法树,并对语法树进行递归遍历。对于模块中收集到的依赖,加入字段标识其是否为异步依赖。

{
  "/Users/jin/playground/leaf-bundler/example/example2/entry.js": {
    "id": 0,
    "filename": "/Users/jin/playground/leaf-bundler/example/example2/entry.js",
    "name": "./entry.js",
    "requires": [
      {
        "name": "./a.js",
        "filename": "/Users/jin/playground/leaf-bundler/example/example2/a.js",
        "nameRange": [
          18,
          26
        ],
        "async": false,
        "id": 1
      },
      {
        "name": "./b.js",
        "filename": "/Users/jin/playground/leaf-bundler/example/example2/b.js",
        "nameRange": [
          88,
          96
        ],
        "async": true,
        "id": 2
      }
    ],
    "asyncs": [
      {
        "requires": [
          {
            "name": "./b.js",
            "filename": "/Users/jin/playground/leaf-bundler/example/example2/b.js"
          }
        ],
        "namesRange": [
          48,
          58
        ],
        "chunkId": 1,
        "chunks": [
          1
        ]
      }
    ],
    "source": "const a = require('./a.js')\na()\n\nrequire.ensure(['./b.js'], () => {\n  const b = require('./b.js')\n  b()\n})\n\n",
    "chunkId": 0,
    "chunks": [
      0
    ]
  },
  "/Users/jin/playground/leaf-bundler/example/example2/a.js": {
    "id": 1,
    "filename": "/Users/jin/playground/leaf-bundler/example/example2/a.js",
    "name": "./a.js",
    "requires": [],
    "asyncs": [],
    "source": "function a() {\n  console.log('module a function')\n}\n\nmodule.exports = a\n",
    "chunks": [
      0
    ]
  },
  "/Users/jin/playground/leaf-bundler/example/example2/b.js": {
    "id": 2,
    "filename": "/Users/jin/playground/leaf-bundler/example/example2/b.js",
    "name": "./b.js",
    "requires": [
      {
        "name": "./c.js",
        "filename": "/Users/jin/playground/leaf-bundler/example/example2/c.js",
        "nameRange": [
          18,
          26
        ],
        "async": false,
        "id": 3
      }
    ],
    "asyncs": [],
    "source": "const c = require('./c.js')\n\nfunction b() {\n  c()\n  console.log('module b function')\n}\n\nmodule.exports = b\n",
    "chunks": [
      1
    ]
  },
  "/Users/jin/playground/leaf-bundler/example/example2/c.js": {
    "id": 3,
    "filename": "/Users/jin/playground/leaf-bundler/example/example2/c.js",
    "name": "./c.js",
    "requires": [],
    "asyncs": [],
    "source": "function c() {\n  console.log('c')\n}\n\nmodule.exports = c\n",
    "chunks": [
      1
    ]
  }
},

3.2 生成 chunk 索引

加入代码分割功能后,生成文件变为多个,每个 chunk 一个 js,需要在生成前根据依赖树进行梳理。具体代码可见 buildTree 部分。

整理后的 chunk 索引如下:

"chunks": {
  "0": {
    "id": 0,
    "modules": {
      "0": "include",
      "1": "include"
    }
  },
  "1": {
    "id": 1,
    "modules": {
      "2": "include",
      "3": "include"
    },
    "parents": [
      0
    ]
  }
}

即最终有 2 个 chunk,第一个 chunk 内含 0、1 两个模块,第二个 chunk 内含 2、3 两个模块。

3.3 补充 require.ensure 和 webpackJsonp 函数

require.ensure 和 webpackJsonp 函数都将添加到最终产物的生成模板中。require.ensure 的主要功能是:根据 chunkID 拉取远程 chunk。webpackJsonp 函数的主要功能是:将拉取下来的 chunk 中的新模块添加到 modules 对象上,随后执行 require.ensure 的回调。

/******/ ...
/******/		require.ensure = function(chunkId, callback) {
/******/			if(installedChunks[chunkId] === 1) return callback(require);
/******/			if(installedChunks[chunkId] !== undefined)
/******/				installedChunks[chunkId].push(callback)
/******/			else {
/******/				installedChunks[chunkId] = [callback]
/******/				var head = document.getElementsByTagName('head')[0]
/******/				var script = document.createElement('script')
/******/				script.type = 'text/javascript'
/******/				script.src = chunkId + '.chunk.js'
/******/				head.appendChild(script)
/******/			}
/******/		};
/******/		window.webpackJsonp = function(chunkId, moreModules) {
/******/			for(var moduleId in moreModules)
/******/				modules[moduleId] = moreModules[moduleId]
/******/			var callbacks = installedChunks[chunkId]
/******/			installedChunks[chunkId] = 1
/******/			for(var i = 0; i < callbacks.length; i++)
/******/				callbacks[i](require)
/******/		}
/******/ ...

3.4 拼装生成

根据 3.2 节的 chunk 索引,每一个 chunk 都要生成一个 js 文件。第一个 chunk 为自执行函数,添加了 require.ensure 和 webpackJsonp 两个工具函数,其他结构不变;其他 chunk 都是一个 webpackJsonp 函数。

webpackJsonp chunk 模板:

头部:

//伪代码
/*****/webpackJsonp({{chunkID}}, {

循环注入所有模块:

/******/{{模块ID}}: function(module, exports, require) {

{{模块代码}}

/******/},

尾部:

/******/
/******/})

流程图:

bundler-2

4. 后记

webpack 的构建环节着实多,本想事无巨细的列出各部分代码,恐怕该文长度会大大膨胀,又无意将 webpack 拓展为系列,所以本文仅列出环节核心思路,不再逐个方法做示例。

loader 负责对各种类型的模块进行预处理,实质是一个字符串处理函数,输入字符串,输出字符串。require css 文件,或者图片这些在 commonJS 规范中不支持的操作,都是因为预先由 loader 将资源转换成 JS Module 才得以实现。例如 require css 文件的效果是将该段 css 包裹在 <style> 标签中打入 <head>:

require('test.css') // .test{font-size: 18px;}

实际会被 loader 转换为类似下文的结构:

require(addStyle(style))

//由于复用,addStyle 和 style 也是从其他文件 require 而来,形如 require(require(1)(require(2)))
function addStyle(cssCode) {
  let styleElement = document.createElement('style')
  styleElement.type = 'text/css'
  if(styleElement.styleSheet) {
    styleElement.styleSheet.cssText = cssCode
  }else {
    styleElement.appendChild(document.createTextNode(cssCode))
  }
  document.getElementsByTagName('head')[0].appendChild(styleElement)
}

style = `.test{font-size: 18px;}`

而 plugin 负责处理 loader 做不了的事情。webpack 内部有一套生命周期,plugin 可以在生命周期的各个环节拿到数据并进行处理。具体生命周期可参见 Compiler

5.参考资料

  1. webpack源码学习系列之一:如何实现一个简单的webpack
  2. webpack源码学习系列之二:code-splitting(代码切割)
  3. webpack源码学习系列之三:loader 机制
  4. Writing a Plugin
  5. 细说 webpack 之流程篇
@jin5354 jin5354 changed the title 现代前端科技解析 —— Javascript bundler 现代前端科技解析 —— Javascript Bundler Oct 18, 2017
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant