关于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),不定时推送文章。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理