乐趣区

关于javascript:Webpack-原理浅析

作者:凹凸曼 – 风魔小次郎

背景

Webpack 迭代到 4.x 版本后,其源码曾经非常宏大,对各种开发场景进行了高度形象,浏览老本也愈发低廉。然而为了理解其外部的工作原理,让咱们尝试从一个最简略的 webpack 配置动手,从工具设计者的角度开发一款低配版的 Webpack

开发者视角

假如某一天,咱们接到了需要,须要开发一个 react 单页面利用, 页面中蕴含一行文字和一个按钮,须要反对每次点击按钮的时候让文字发生变化。于是咱们新建了一个我的项目,并且在 [根目录]/src 下新建 JS 文件。为了模仿 Webpack 追踪模块依赖进行打包的过程,咱们新建了 3 个 React 组件,并且在他们之间建设起一个简略的依赖关系。

// index.js 根组件
import React from 'react'
import ReactDom from 'react-dom'
import App from './App'
ReactDom.render(<App />, document.querySelector('#container'))
// App.js 页面组件
import React from 'react'
import Switch from './Switch.js'
export default class App extends React.Component {constructor(props) {super(props)
    this.state = {toggle: false}
  }
  handleToggle() {
    this.setState(prev => ({toggle: !prev.toggle}))
  }
  render() {const { toggle} = this.state
    return (
      <div>
        <h1>Hello, {toggle ? 'NervJS' : 'O2 Team'}</h1>
        <Switch handleToggle={this.handleToggle.bind(this)} />
      </div>
    )
  }
}
// Switch.js 按钮组件
import React from 'react'

export default function Switch({handleToggle}) {
  return (<button onClick={handleToggle}>Toggle</button>
  )
}

接着咱们须要一个配置文件让 Webpack 晓得咱们冀望它如何工作,于是咱们在根目录下新建一个文件 webpack.config.js 并且向其中写入一些根底的配置。(如果不太熟悉配置内容能够先学习 webpack 中文文档)

// webpack.config.js
const resolve = dir => require('path').join(__dirname, dir)

module.exports = {
  // 入口文件地址
  entry: './src/index.js',
  // 输入文件地址
  output: {path: resolve('dist'),
    fileName: 'bundle.js'
  },
  // loader
  module: {
    rules: [
      {test: /\.(js|jsx)$/,
        // 编译匹配 include 门路的文件
        include: [resolve('src')
        ],
        use: 'babel-loader'
      }
    ]
  },
  plugins: [new HtmlWebpackPlugin()
  ]
}

其中 module 的作用是在 test 字段和文件名匹配胜利时就用对应的 loader 对代码进行编译,Webpack自身只意识 .js.json 这两种类型的文件,而通过 loader,咱们就能够对例如 css 等其余格局的文件进行解决。

而对于 React 文件而言,咱们须要将 JSX 语法转换成纯 JS 语法,即 React.createElement 办法,代码才可能被浏览器所辨认。平时咱们是通过 babel-loader 并且配置好 react 的解析规定来做这一步。

通过以上解决之后。浏览器真正浏览到的按钮组件代码其实大略是这个样子的。

...
function Switch(_ref) {
  var handleToggle = _ref.handleToggle;
  return _nervjs["default"].createElement("button", {onClick: handleToggle}, "Toggle");
}

而至于 plugin 则是一些插件,这些插件能够将对编译后果的处理函数注册在 Webpack 的生命周期钩子上,在生成最终文件之前对编译的后果做一些解决。比方大多数场景下咱们须要将生成的 JS 文件插入到 Html 文件中去。就须要应用到 html-webpack-plugin 这个插件,咱们须要在配置中这样写。

const HtmlWebpackPlugin = require('html-webpack-plugin');

const webpackConfig = {
  entry: 'index.js',
  output: {path: path.resolve(__dirname, './dist'),
    filename: 'index_bundle.js'
  },
  // 向 plugins 数组中传入一个 HtmlWebpackPlugin 插件的实例
  plugins: [new HtmlWebpackPlugin()]
};

这样,html-webpack-plugin 会被注册在打包的实现阶段,并且会获取到最终打包实现的入口 JS 文件门路,生成一个形如 <script src="./dist/bundle_[hash].js"></script> 的 script 标签插入到 Html 中。这样浏览器就能够通过 html 文件来展现页面内容了。

ok,写到这里,对于一个开发者而言,所有配置项和须要被打包的工程代码文件都曾经筹备结束,接下来须要的就是将工作交给打包工具 Webpack,通过 Webpack 将代码打包成咱们和浏览器心愿看到的样子

工具视角

首先,咱们须要理解 Webpack 打包的流程

Webpack 的工作流程中能够看出,咱们须要实现一个 Compiler 类,这个类须要收集开发者传入的所有配置信息,而后指挥整体的编译流程。咱们能够把 Compiler 了解为公司老板,它统领全局,并且把握了全局信息(客户需要)。在理解了所有信息后它会调用另一个类 Compilation 生成实例,并且将所有的信息和工作流程托付给它,Compilation 其实就相当于老板的秘书,须要去调动各个部门依照要求开始工作,而 loaderplugin 则相当于各个部门,只有在他们特长的工作(js , css , scss , jpg , png…)呈现时才会去解决

为了既实现 Webpack 打包的性能,又只实现外围代码。咱们对这个流程做一些简化

首先咱们新建了一个 webpack 函数作为对外裸露的办法,它承受两个参数,其中一个是配置项对象,另一个则是谬误回调。

const Compiler = require('./compiler')

function webpack(config, callback) {
  // 此处应有参数校验
  const compiler = new Compiler(config)
  // 开始编译
  compiler.run()}

module.exports = webpack

1. 构建配置信息

咱们须要先在 Compiler 类的构造方法外面收集用户传入的信息

class Compiler {constructor(config, _callback) {
    const {
      entry,
      output,
      module,
      plugins
    } = config
    // 入口
    this.entryPath = entry
    // 输入文件门路
    this.distPath = output.path
    // 输入文件名称
    this.distName = output.fileName
    // 须要应用的 loader
    this.loaders = module.rules
    // 须要挂载的 plugin
    this.plugins = plugins
     // 根目录
    this.root = process.cwd()
     // 编译工具类 Compilation
    this.compilation = {}
    // 入口文件在 module 中的相对路径,也是这个模块的 id
    this.entryId = getRootPath(this.root, entry, this.root)
  }
}

同时,咱们在构造函数中将所有的 plugin 挂载到实例的 hooks 属性中去。Webpack 的生命周期治理基于一个叫做 tapable 的库,通过这个库,咱们能够十分不便的创立一个公布订阅模型的钩子,而后通过将函数挂载到实例上(钩子事件的回调反对同步触发、异步触发甚至进行链式回调),在适合的机会触发对应事件的处理函数。咱们在 hooks 上申明一些生命周期钩子:

const {AsyncSeriesHook} = require('tapable') // 此处咱们创立了一些异步钩子
constructor(config, _callback) {
  ...
  this.hooks = {
    // 生命周期事件
    beforeRun: new AsyncSeriesHook(['compiler']), // compiler 代表咱们将向回调事件中传入一个 compiler 参数
    afterRun: new AsyncSeriesHook(['compiler']),
    beforeCompile: new AsyncSeriesHook(['compiler']),
    afterCompile: new AsyncSeriesHook(['compiler']),
    emit: new AsyncSeriesHook(['compiler']),
    failed: new AsyncSeriesHook(['compiler']),
  }
  this.mountPlugin()}
// 注册所有的 plugin
mountPlugin() {for(let i=0;i<this.plugins.length;i++) {const item = this.plugins[i]
    if ('apply' in item && typeof item.apply === 'function') {
      // 注册各生命周期钩子的公布订阅监听事件
      item.apply(this)
    }
  }
}
// 当运行 run 办法的逻辑之前
run() {
  // 在特定的生命周期公布音讯,触发对应的订阅事件
  this.hooks.beforeRun.callAsync(this) // this 作为参数传入,对应之前的 compiler
  ...
}

冷常识:
每一个 plugin Class 都必须实现一个 apply 办法,这个办法接管 compiler 实例,而后将真正的钩子函数挂载到 compiler.hook 的某一个申明周期上。
如果咱们申明了一个 hook 然而没有挂载任何办法,在 call 函数触发的时候是会报错的。然而实际上 Webpack 的每一个生命周期钩子除了挂载用户配置的 plugin , 都会挂载至多一个 Webpack 本人的 plugin,所以不会有这样的问题。更多对于 tapable 的用法也能够移步 Tapable

2. 编译

接下来咱们须要申明一个 Compilation 类,这个类次要是执行编译工作。在 Compilation 的构造函数中,咱们先接管来自老板 Compiler 下发的信息并且挂载在本身属性中。

class Compilation {constructor(props) {
    const {
      entry,
      root,
      loaders,
      hooks
    } = props
    this.entry = entry
    this.root = root
    this.loaders = loaders
    this.hooks = hooks
  }
  // 开始编译
  async make() {await this.moduleWalker(this.entry)
  }
  // dfs 遍历函数
  moduleWalker = async () => {}
}

因为咱们须要将打包过程中援用过的文件都编译到最终的代码包里,所以须要申明一个深度遍历函数 moduleWalker(这个名字是笔者取的,不是 webpack 官网取的),顾名思义,这个办法将会从入口文件开始,顺次对文件进行第一步和第二步编译,并且收集援用到的其余模块,递归进行同样的解决。

编译步骤分为两步

  1. 第一步是应用所有满足条件的 loader 对其进行编译并且返回编译之后的源代码
  2. 第二步相当于是 Webpack 本人的编译步骤,目标是构建各个独立模块之间的依赖调用关系。咱们须要做的是将所有的 require 办法替换成 Webpack 本人定义的 __webpack_require__ 函数。因为所有被编译后的模块将被 Webpack 存储在一个闭包的对象 moduleMap 中,而 __webpack_require__ 函数则是惟一一个有权限拜访 moduleMap 的办法。

一句话解释 __webpack_require__的作用就是,将模块之间本来 文件地址 -> 文件内容 的关系替换成了 对象的 key -> 对象的 value(文件内容) 这样的关系。

在实现第二步编译的同时,会对以后模块内的援用进行收集,并且返回到 Compilation 中,这样moduleWalker 能力对这些依赖模块进行递归的编译。当然其中大概率存在循环援用和反复援用,咱们会依据援用文件的门路生成一个举世无双的 key 值,在 key 值反复时进行跳过。

i. moduleWalker 遍历函数

// 寄存处理完毕的模块代码 Map
moduleMap = {}

// 依据依赖将所有被援用过的文件都进行编译
async moduleWalker(sourcePath) {if (sourcePath in this.moduleMap) return
  // 在读取文件时,咱们须要残缺的以.js 结尾的文件门路
  sourcePath = completeFilePath(sourcePath)
  const [sourceCode, md5Hash] = await this.loaderParse(sourcePath)
  const modulePath = getRootPath(this.root, sourcePath, this.root)
  // 获取模块编译后的代码和模块内的依赖数组
  const [moduleCode, relyInModule] = this.parse(sourceCode, path.dirname(modulePath))
  // 将模块代码放入 ModuleMap
  this.moduleMap[modulePath] = moduleCode
  this.assets[modulePath] = md5Hash
  // 再顺次对模块中的依赖项进行解析
  for(let i=0;i<relyInModule.length;i++) {await this.moduleWalker(relyInModule[i], path.dirname(relyInModule[i]))
  }
}

如果将 dfs 的门路给 log 进去,咱们就能够看到这样的流程

ii. 第一步编译 loaderParse函数

async loaderParse(entryPath) {
  // 用 utf8 格局读取文件内容
  let [content, md5Hash] = await readFileWithHash(entryPath)
  // 获取用户注入的 loader
  const {loaders} = this
  // 顺次遍历所有 loader
  for(let i=0;i<loaders.length;i++) {const loader = loaders[i]
    const {test : reg, use} = loader
    if (entryPath.match(reg)) {
      // 判断是否满足正则或字符串要求
      // 如果该规定须要利用多个 loader, 从最初一个开始向前执行
      if (Array.isArray(use)) {while(use.length) {const cur = use.pop()
          const loaderHandler = 
            typeof cur.loader === 'string' 
            // loader 也可能来源于 package 包例如 babel-loader
              ? require(cur.loader)
              : (
                typeof cur.loader === 'function'
                ? cur.loader : _ => _
              )
          content = loaderHandler(content)
        }
      } else if (typeof use.loader === 'string') {const loaderHandler = require(use.loader)
        content = loaderHandler(content)
      } else if (typeof use.loader === 'function') {
        const loaderHandler = use.loader
        content = loaderHandler(content)
      }
    }
  }
  return [content, md5Hash]
}

然而这里遇到了一个小插曲,就是咱们平时应用的 babel-loader 仿佛并不能在 Webpack 包以外的场景被应用,在 babel-loader 的文档中看到了这样一句话

This package allows transpiling JavaScript files using Babel and webpack.

不过好在 @babel/corewebpack 并无分割,所以只能辛苦一下,再手写一个 loader 办法去解析 JSES6 的语法。

const babel = require('@babel/core')

module.exports = function BabelLoader (source) {
  const res = babel.transform(source, {sourceType: 'module' // 编译 ES6 import 和 export 语法})
  return res.code
}

当然,编译规定能够作为配置项传入,然而为了模仿实在的开发场景,咱们须要配置一下 babel.config.js文件

module.exports = function (api) {api.cache(true)
  return {
    "presets": [
      ['@babel/preset-env', {
        targets: {"ie": "8"},
      }],
      '@babel/preset-react', // 编译 JSX
    ],
    "plugins": [
      ["@babel/plugin-transform-template-literals", {"loose": true}]
    ],
    "compact": true
  }
}

于是,在取得了 loader 解决过的代码之后,实践上任何一个模块都曾经能够在浏览器或者单元测试中间接应用了。然而咱们的代码是一个整体,还须要一种正当的形式来组织代码之间相互援用的关系。

下面也解释了咱们为什么要应用 __webpack_require__ 函数。这里咱们失去的代码依然是字符串的模式,为了不便咱们应用 eval 函数将字符串解析成间接可读的代码。当然这只是求快的形式,对于 JS 这种解释型语言,如果一个一个模块去解释编译的话,速度会十分慢。事实上真正的生产环境会将模块内容封装成一个 IIFE(立刻自执行函数表达式)

总而言之,在第二部编译 parse 函数中咱们须要做的事件其实很简略,就是将所有模块中的 require 办法的函数名称替换成 __webpack_require__ 即可。咱们在这一步应用的是 babel 全家桶。babel 作为业内顶尖的 JS 编译器,剖析代码的步骤次要分为两步,别离是词法剖析和语法分析。简略来说,就是对代码片段进行逐词剖析,依据以后单词生成一个上下文语境。而后进行再判断下一个单词在上下文语境中所起的作用。

留神,在这一步中咱们还能够“顺便”收集模块的依赖项数组一起返回(用于 dfs 递归)

const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const types = require('@babel/types')
const generator = require('@babel/generator').default
...
// 解析源码,替换其中的 require 办法来构建 ModuleMap
parse(source, dirpath) {
  const inst = this
  // 将代码解析成 ast
  const ast = parser.parse(source)
  const relyInModule = [] // 获取文件依赖的所有模块
  traverse(ast, {
    // 检索所有的词法剖析节点,当遇到函数调用表达式的时候执行,对 ast 树进行改写
    CallExpression(p) {
      // 有些 require 是被_interopRequireDefault 包裹的
      // 所以须要先找到_interopRequireDefault 节点
      if (p.node.callee && p.node.callee.name === '_interopRequireDefault') {const innerNode = p.node.arguments[0]
        if (innerNode.callee.name === 'require') {inst.convertNode(innerNode, dirpath, relyInModule)
        }
      } else if (p.node.callee.name === 'require') {inst.convertNode(p.node, dirpath, relyInModule)
      }
    }
  })
  // 将改写后的 ast 树从新组装成一份新的代码, 并且和依赖项一起返回
  const moduleCode = generator(ast).code
  return [moduleCode, relyInModule]
}
/**
 * 将某个节点的 name 和 arguments 转换成咱们想要的新节点
 */
convertNode = (node, dirpath, relyInModule) => {
  node.callee.name = '__webpack_require__'
  // 参数字符串名称,例如 'react', './MyName.js'
  let moduleName = node.arguments[0].value
  // 生成依赖模块绝对【我的项目根目录】的门路
  let moduleKey = completeFilePath(getRootPath(dirpath, moduleName, this.root))
  // 收集 module 数组
  relyInModule.push(moduleKey)
  // 替换__webpack_require__的参数字符串,因为这个字符串也是对应模块的 moduleKey,须要放弃对立
  // 因为 ast 树中的每一个元素都是 babel 节点,所以须要应用 '@babel/types' 来进行生成
  node.arguments = [types.stringLiteral(moduleKey) ]
}

3. emit 生成 bundle 文件

执行到这一步,compilation 的使命其实就曾经实现了。如果咱们平时有去察看生成的 js 文件的话,会发现打包进去的样子是一个立刻执行函数,主函数体是一个闭包,闭包中缓存了曾经加载的模块 installedModules,以及定义了一个 __webpack_require__ 函数,最终返回的是函数入口所对应的模块。而函数的参数则是各个模块的 key-value 所组成的对象。

咱们在这里通过 ejs 模板去进行拼接,将之前收集到的 moduleMap 对象进行遍历,注入到 ejs 模板字符串中去。

模板代码

// template.ejs
(function(modules) { // webpackBootstrap
  // The module cache
  var installedModules = {};
  // The require function
  function __webpack_require__(moduleId) {
      // Check if module is in cache
      if(installedModules[moduleId]) {return installedModules[moduleId].exports;
      }
      // Create a new module (and put it into the cache)
      var module = installedModules[moduleId] = {
          i: moduleId,
          l: false,
          exports: {}};
      // Execute the module function
      modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
      // Flag the module as loaded
      module.l = true;
      // Return the exports of the module
      return module.exports;
  }
  // Load entry module and return exports
  return __webpack_require__(__webpack_require__.s = "<%-entryId%>");
})({<%for(let key in modules) {%>
     "<%-key%>":
         (function(module, exports, __webpack_require__) {
             eval(`<%-modules[key]%>`
             );
         }),
     <%}%>
});

生成 bundle.js

/**
 * 发射文件,生成最终的 bundle.js
 */
emitFile() { // 发射打包后的输入后果文件
  // 首先比照缓存判断文件是否变动
  const assets = this.compilation.assets
  const pastAssets = this.getStorageCache()
  if (loadsh.isEqual(assets, pastAssets)) {
    // 如果文件 hash 值没有变动,阐明无需重写文件
    // 只须要顺次判断每个对应的文件是否存在即可
    // 这一步省略!} else {
    // 缓存未能命中
    // 获取输入文件门路
    const outputFile = path.join(this.distPath, this.distName);
    // 获取输入文件模板
    // const templateStr = this.generateSourceCode(path.join(__dirname, '..', "bundleTemplate.ejs"));
    const templateStr = fs.readFileSync(path.join(__dirname, '..', "template.ejs"), 'utf-8');
    // 渲染输入文件模板
    const code = ejs.render(templateStr, {entryId: this.entryId, modules: this.compilation.moduleMap});
    
    this.assets = {};
    this.assets[outputFile] = code;
    // 将渲染后的代码写入输入文件中
    fs.writeFile(outputFile, this.assets[outputFile], function(e) {if (e) {console.log('[Error]' + e)
      } else {console.log('[Success] 编译胜利')
      }
    });
    // 将缓存信息写入缓存文件
    fs.writeFileSync(resolve(this.distPath, 'manifest.json'), JSON.stringify(assets, null, 2))
  }
}

在这一步中咱们依据文件内容生成的 Md5Hash 去比照之前的缓存来放慢打包速度,仔细的同学会发现 Webpack 每次打包都会生成一个缓存文件 manifest.json,形如

{
  "main.js": "./js/main7b6b4.js",
  "main.css": "./css/maincc69a7ca7d74e1933b9d.css",
  "main.js.map": "./js/main7b6b4.js.map",
  "vendors~main.js": "./js/vendors~main3089a.js",
  "vendors~main.css": "./css/vendors~maincc69a7ca7d74e1933b9d.css",
  "vendors~main.js.map": "./js/vendors~main3089a.js.map",
  "js/28505f.js": "./js/28505f.js",
  "js/28505f.js.map": "./js/28505f.js.map",
  "js/34c834.js": "./js/34c834.js",
  "js/34c834.js.map": "./js/34c834.js.map",
  "js/4d218c.js": "./js/4d218c.js",
  "js/4d218c.js.map": "./js/4d218c.js.map",
  "index.html": "./index.html",
  "static/initGlobalSize.js": "./static/initGlobalSize.js"
}

这也是文件断点续传中罕用到的一个判断,这里就不做具体的开展了


测验

做完这一步,咱们曾经根本功败垂成了(误:如果不思考令人智息的 debug 过程的话),接下来咱们在 package.json 外面配置好打包脚本

"scripts": {"build": "node build.js"}

运行 yarn build

(@ο@) 哇~激动人心的时刻到了。

然而 …

看着打包进去的这一坨奇怪的货色报错,心里还是有点想笑的。查看了一下发现是因为反引号遇到正文中的反引号于是拼接字符串提前结束了。好吧,那么我在 babel traverse 时加了几句代码,删除掉了代码中所有的正文。然而随之而来的又是一些其余的问题。

好吧,可能在理论 react 生产打包中还有一些其余的步骤,然而这不在明天探讨的话题当中。此时,鬼魅的框架涌上心头。我脑中想起了京东凹凸实验室自研的高性能,兼容性优良,紧跟 react 版本的类 react 框架 NervJS,或者 NervJS 平易近人 (误) 的代码可能反对这款令人道歉的打包工具

于是咱们在 babel.config.js 中配置 alias 来替换 react 依赖项。(React我的项目转 NervJS 就是这么简略)

module.exports = function (api) {api.cache(true)
  return {
        ...
    "plugins": [
            ...
      [
        "module-resolver", {"root": ["."],
          "alias": {
            "react": "nervjs",
            "react-dom": "nervjs",
            // Not necessary unless you consume a module using `createClass`
            "create-react-class": "nerv-create-class"
          }
        }
      ]
    ],
    "compact": true
  }
}

运行 yarn build


(@ο@) 哇~代码终于胜利运行了起来,尽管存在着许多的问题,然而至多这个 webpack 在设计如此简略的状况下曾经有能力反对大部分 JS 框架了。感兴趣的同学也能够本人尝试写一写,或者间接从这里 clone 下来看

毫无疑问,Webpack 是一个十分优良的代码模块打包工具(尽管它的官网十分低调的没有任何 slogen)。一款十分优良的工具,必然是在放弃了本人自身的个性的同时,同时可能赋予其余开发者在其根底上拓展构想之外作品的能力。如果有能力深刻学习这些工具,对于咱们在代码工程畛域的认知也会有很大的晋升。


欢送关注凹凸实验室博客:aotu.io

或者关注凹凸实验室公众号(AOTULabs),不定时推送文章。

退出移动版