Анализ принципа Webpack

JavaScript Webpack
Анализ принципа Webpack

Автор: Bumpman - Фэнмо Кодзиро

задний план

WebpackПосле итерации до версии 4.x ее исходный код уже очень велик, а различные сценарии разработки сильно абстрагированы, а стоимость чтения становится все дороже и дороже. Но чтобы понять его внутреннюю работу, давайте попробуем начать с самой простой конфигурации веб-пакета и разработать низкопрофильную версию с точки зрения разработчика инструментов.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Когда поле и имя файла совпадают, соответствующий загрузчик скомпилируется с соответствующим загрузчиком.Webpackтолько знаю.js,.jsonЭти два типа файлов и через загрузчик мы можем обрабатывать файлы в других форматах, таких как 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.

ок, тут написано, для разработчика все элементы конфигурации и файлы кода проекта, которые нужно запаковать, подготовлены, и дальше дело сдать работу инструменту упаковкиWebpack,пройти черезWebpackУпакуйте код так, как мы и браузер хотим, чтобы он выглядел

Перспектива инструмента

Прежде всего, нам нужно понять процесс упаковки Webpack.

отWebpackКак видно из рабочего процесса, нам нужно реализоватьCompilerКласс, этот класс должен собрать всю информацию о конфигурации, переданную разработчиком, а затем управлять общим процессом компиляции. мы можем поставитьCompilerПоймите его как босса компании, он командует общей ситуацией и владеет общей информацией (потребностями клиентов). Зная всю информацию, он вызывает другой классCompilationсоздать экземпляр и доверить ему всю информацию и рабочий процесс,CompilationПо сути, это эквивалент секретаря начальника, которому необходимо мобилизовать различные отделы, чтобы начать работу по мере необходимости, иloaderа такжеpluginЭто эквивалентно различным отделам и будет иметь дело с ними только тогда, когда появится их специализированная работа ( 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метод, этот метод получаетcompilerinstance, а затем смонтируйте настоящую функцию ловушки вcompiler.hookна определенном цикле объявления.
Если мы объявим хук, но не смонтируем какой-либо метод, он сообщит об ошибке при срабатывании функции вызова. Но по факту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(Это имя взято автором, а не вебпаком официально), как следует из названия, этот метод будет начинаться с файла входа, компилировать файлы на первом и втором шагах по очереди и собирать другие модули, на которые есть ссылки, рекурсивно сделать ту же обработку.

Этап компиляции делится на два этапа

  1. Первый шаг – использовать всеloaderСкомпилируйте его и верните скомпилированный исходный код
  2. Второй шаг эквивалентенWebpackЦелью собственного шага компиляции является построение отношения вызова зависимостей между каждым независимым модулем. Что нам нужно сделать, так это поставить всеrequireметод заменен наWebpackсамоопределяющийся__webpack_require__функция. потому что все скомпилированные модули будутWebpackОбъекты хранятся в замыканииmoduleMapв то время как__webpack_require__Функция является единственной, которая имеет разрешение на доступmoduleMapМетоды.

объяснение одним предложением__webpack_require__Функция состоит в том, чтобы изменить оригинал между модулями文件地址 -> 文件内容Отношения заменяются на对象的key -> 对象的value(文件内容)такое отношение.

Когда второй шаг компиляции завершен, ссылки в текущем модуле собираются и возвращаются вCompilation, такmoduleWalkerЭти зависимые модули могут быть скомпилированы рекурсивно. Конечно, существует высокая вероятность циклических ссылок и повторных ссылок, мы будем генерировать уникальное значение ключа в соответствии с путем файла, на который указывает ссылка, и пропускать при повторении значения ключа.

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, то можем увидеть такой процесс

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/coreа такжеwebpackСоединения нет, поэтому я могу только усердно работать и писать метод загрузчика для его разбора.JSа такжеES6синтаксис.

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Создать пакетный файл

Выполните этот шаг,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]%>`
             );
         }),
     <%}%>
});

Сгенерировать пакет.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"
}

Это также часто используемое суждение при возобновлении файловой точки останова, поэтому я не буду подробно его здесь раскрывать.


тестовое задание

После этого шага мы в основном закончили (ошибка: если не учитывать умопомрачительный процесс отладки), то мы находимся вpackage.jsonНастройте скрипт пакета внутри

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

бегатьyarn build

(@ο@) Вау~ Пришло захватывающее время.

Однако...

Увидев, что эта странная штуковина, которая была упакована, сделала ошибку, мне все же захотелось рассмеяться в душе. Я проверил и обнаружил, что объединенная строка закончилась преждевременно, потому что обратные кавычки встретились с обратными кавычками в комментарии. хорошо, тогда яbabel traverseЯ добавил несколько строк кода и удалил все комментарии в коде. Но потом пришли другие проблемы.

Ну, наверное, на самом делеreactЕсть еще несколько шагов в производстве упаковки, но это выходит за рамки темы сегодняшнего обсуждения. В этот момент на ум пришла призрачная рамка. На мой взгляд, я думал о высокой производительности, разработанной JD.reactверсия фреймворка, похожего на реакциюNervJS,возможноNervJSДоступный (неправильный) код для поддержки этого жалкого упаковщика

По этому мыbabel.config.jsНастроить псевдоним для замены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. Заинтересованные студенты также могут попробовать написать самостоятельно или напрямую изздесьПод клоном зрения

без сомнений,WebpackЭто очень хороший инструмент для упаковки модулей кода (хотя его официальный веб-сайт очень сдержан и не содержит никаких слоганов). Очень хороший инструмент должен сохранять свои характеристики, давая другим разработчикам возможность расширять свои возможности на его основе. Если у нас будет возможность подробно изучить эти инструменты, наши знания в области разработки кода также значительно улучшатся.


Добро пожаловать в блог Bump Labs:aotu.io

Или обратите внимание на официальный аккаунт AOTULabs и время от времени публикуйте статьи:

image