webpack5 进阶

11/1/2020 webpack

# react-cli 脚手架分析

create-react-app 01.react-cli

# package.json 指令分析

  "scripts": {
    "start": "react-scripts start", // 编译环境运行
    "build": "react-scripts build", // 生成环境打包
    "test": "react-scripts test", // 自动化测试
    "eject": "react-scripts eject" // 暴露配置文件
  },
npm run eject

# 配置结构目录分析

01.react-cli
├── config
│   ├── env.js
│   ├── getHttpsConfig.js
│   ├── modules.js
│   ├── paths.js # 路径处理模块
│   ├── pnpTs.js
│   ├── webpack.config.js # 打包|运行配置
│   └── webpackDevServer.config.js
├── scripts # node 目录目录
│   ├── build.js # 运行打包命令
│   ├── start.js # 运行编译命令
│   └── test.js  # 运行测试命令
└── src

# vue-cli 脚手架分析

vue create app 02.vue-cli
vue inspect --mode=development > webpack.dev.js
vue inspect --mode=production > webpack.prod.js

#

# 自定义 loader

# 实现一个简单的 loader

// webpack.config.ts
import path from 'path'
import { Configuration, LoaderDefinition } from 'webpack'

const config: Configuration = {
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'loader-1'
      }
    ]
  },
  entry: './src/index.js',
  // 配置 loader 的解析规则
  resolveLoader: {
    modules: ['node_modules', path.resolve(__dirname, 'loaders')]
  }
}

export default config

// loaders/loader-1.js
module.exports = function(content, map, meta) {
    // content 打印编译文件内容
    return content
}

# loader 的执行顺序

// loaders 的执行顺序默认是从下往上执行
module.exports = async function (content) {
}
// loaders pitch 则从上往下执行
module.exports.pitch = function () {
}

# loader 获取配置与校验

const { getOptions } = require('loader-utils')
const { validate } = require('schema-utils')

const schema = {
  type: 'object',
  properties: {
    name: {
      type: 'string',
      description: '名称~'
    },
    // 是否允许追加属性
    additionalProperties: true
  }
}

module.exports = async function (content) {
  // 获取配置
  const options = getOptions(this)
    // 校验配置
  validate(schema, options, {
    name: '我是 loader-3 的 name 字段'
  })
}
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: 'loader-3',
            options: { name: '' }
          }
        ]
      }
    ]
  }
}

# 自定义 babel-loader

使用 babel 写一个 webpack loader,将配置传递到 babel transform 配置中,返回 code

// loaders/babel-loader.js
const { getOptions } = require('loader-utils')
const { validate } = require('schema-utils')
const { promisify } = require('util')
const babel = require('@babel/core')

// node async callback to promise function
const transform = promisify(babel.transform)

// options validate schema
const schema = {
  type: 'object',
  properties: {
    presets: { type: 'array' }
  },
  addtionalProperties: true
}

module.exports = async function (content, _map, _meta) {
  // 获取 loader 的 options 配置
  const options = getOptions(this) || {}
  // 校验 loader 的 options 配置
  validate(schema, options, { name: 'Babel Loader' })

  // transform content
  const { code, map } = await transform(content, options)

  // call return code
  this.callback(null, code, map, _meta)
}
// webpack.config.ts
import path from 'path'
import { Configuration, LoaderDefinition } from 'webpack'

const config: Configuration = {
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        options: {
          presets: ['@babel/preset-env']
        }
      }
    ]
  },
  mode: 'production',
  entry: './src/index.js',
  // 配置 loader 的解析规则
  resolveLoader: {
    modules: ['node_modules', path.resolve(__dirname, 'loaders')]
  }
}

export default config

#

# 自定义 plugin (tabpable)

tabpable (opens new window) 是一个基于观察者模式的工具库,提供了同步/异步的观察者构造函数,webpack plugin 的 compiler 基于 tabpable 实现,向外提供 webpack 的生命周期钩子。

# SyncHooks

创建同步的 hooks ,任务依次执行,参数为可接收的值

import { SyncHooks } from 'tabable'
// 创建一个 hooks
const hooks = new SyncHooks(['address'])
// 往 hooks 容器中注册事件/添加回调函数
hooks.tab('class-0318', (address) => {
    /* .... */
})
// 触发所有钩子函数(监听者)
hooks.call('北京')

# SyncBailHooks

与 SyncHooks 基本一致,但回调中遇到返回值则停止调用剩余的 hooks

# AsyncParallelHook

创建一个异步并行的 hooks,并行执行

const asyncHooks = new AsyncParallelHook(['name', 'age'])
  // 添加一个异步的 hook, 通过钓鱼 callback 代表执行完毕
  hooks.leave.tapAsync('class-0510', async (name, age, callback) => {
    await awaitPromise(1000)
    console.log('class-0510', name, age)
    callback()
  })

# 自定义 plugin (get compiler)

自定义插件的定义,有两种方式,一种是使用类的方式,一种是函数方式,

compiler 是 webpack 提供的内置对象,有丰富的生命周期。

# 类模式

class Plugin {
  apply(compiler) {}
}
// webpack.config.ts
const config = {
    plugins: [new Plugin()]
}

# 函数模式

const plugin = (compiler) => {}
// webpack.config.ts
const config = {
    plugins: [plugin]
}

# compiler 基本生命周期

Compiler 模块是 webpack 的主要引擎,它通过 CLI (opens new window) 传递的所有选项, 或者 Node API (opens new window),创建出一个 compilation 实例。 它扩展(extend)自 Tapable 类,用来注册和调用插件。 大多数面向用户的插件会首先在 Compiler 上注册。

const plugin = (compiler) => {
  // 输出 asset 到 output 目录之前执行
  compiler.hooks.emit.tap('plugin-1', (compilation) => {
    console.log('emit.tab 1')
  })
  compiler.hooks.emit.tapPromise('plugin-1', async (compilation) => {
    await awaitPromise(1000)
    console.log('emit.tab 1')
  })
  // 输出 asset 到 output 目录之后执行
  compiler.hooks.afterEmit.tap('plugin-1', (compilation) => {
    console.log('afterEmit.tab 1')
  })
  compiler.hooks.done.tap('plugin-1', (stats) => {
    console.log('done.tab 1')
  })
}

# compilation 基本生命周期

Compilation 模块会被 Compiler 用来创建新的 compilation 对象(或新的 build 对象)。 compilation 实例能够访问所有的模块和它们的依赖(大部分是循环依赖)。 它会对应用程序的依赖图中所有模块, 进行字面上的编译(literal compilation)。 在编译阶段,模块会被加载(load)、封存(seal)、优化(optimize)、 分块(chunk)、哈希(hash)和重新创建(restore)。

// 获取 compilation 
const plugin = (compiler) => {
  compiler.hooks.thisCompilation.tab('plugin', (compilation) => {
      console.log(compilation)
  })
}

上述在初始化 compilation 时调用获取,在触发 compilation 事件之前调用,使用compilation 可对打包文件进行在处理。

import { WebpackPluginInstance, Compiler, sources } from 'webpack'
import fs from 'fs-extra'
import path from 'path'

class Plugin implements WebpackPluginInstance {
  apply(compiler: Compiler) {
    compiler.hooks.thisCompilation.tap('plugin-2', (compilation) => {
      compilation.hooks.additionalAssets.tapPromise('plugin-2', async () => {
        // 文章路径
        const articlePath = path.resolve(__dirname, '../src/article.txt')

        // 往要输出的资源中, 添加一个 article.text
        const buff = await fs.readFile(articlePath)

        // 创建 webpack 风格文件源
        const sourceFile = new sources.RawSource(buff)

        // 方式一: 直接添加
        compilation.assets['article-1.txt'] = sourceFile
        // 方式二: emitAsset
        compilation.emitAsset('article-2.txt', sourceFile)
      })
    })
  }
}
export default Plugin

# 自定义 plugin (copy-plugin)

// webpack.config.ts
import { Configuration } from 'webpack'
import CopyWebpackPlugin from './plugins/copy-webpack-plugin'

const config: Configuration = {
  mode: 'development',
  plugins: [new CopyWebpackPlugin({ from: 'public', ignore: ['**/index.html'] })]
}

export default config
// plugin/copy-webpack-plugin.ts
import { WebpackPluginInstance, Compiler, sources } from 'webpack'
import { validate } from 'schema-utils'
import { Schema } from 'schema-utils/declarations/validate'
import fg from 'fast-glob'
import path from 'path'
import fs from 'fs-extra'

/**
 * CopyWebpackPlugin 配置
 * @property from   - copy 路径
 * @property to     - dist 拼接路径
 * @property ignore - 忽略文件
 */
interface CopyWebpackOption {
  from: string
  to?: string
  ignore?: string[]
}

/** 校验映射 */
const schema: Schema = {
  type: 'object',
  properties: {
    from: { type: 'string' },
    to: { type: 'string' },
    ignore: { type: 'array' }
  },
  additionalProperties: false
}

/** 插件名称 */
const PLUGIN_NAME = 'copy-webpack-plugin'

/**
 * @name CopyWebpackPlugin
 * @description 复制 webpack 任意项目路径中的文件到 dist 打包目录
 */
class CopyWebpackPlugin implements WebpackPluginInstance {
  constructor(private options: CopyWebpackOption) {
    // 处理参数, 兼容 fast-glob 读取
    options.from = path.join(options.from, './**').replace(/\\/g, '/')
    // 校验参数
    validate(schema, options, { name: PLUGIN_NAME })
  }
  apply(compiler: Compiler) {
    const { to = '', from, ignore } = this.options
    compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
      /** 将 from 中的资源复制并输出到 to 中 */
      compilation.hooks.additionalAssets.tapPromise(PLUGIN_NAME, onAdditionalAssets)

      async function onAdditionalAssets() {
        // 1. 读取 from 中所有资源, 过滤掉 ignore 的文件
        const paths = await fg(from, { ignore })

        // 2. 生成 webpack 格式的资源
        const sourceFiles = paths.map((p) => {
          const basename = path.basename(p)
          const assetPath = to ? path.join(basename) : basename
          const file = fs.readFileSync(p)
          const source = new sources.RawSource(file)
          return { assetPath, source }
        })

        // 3. 添加 compilation assets 当中
        sourceFiles.forEach(({ assetPath, source }) => {
          compilation.emitAsset(assetPath, source)
        })
      }
    })
  }
}

export default CopyWebpackPlugin

#

# 手写 mini-webpack

使用 babel 手写 mini 版 webpack

# 基本实现

一个简单的 webpack 模块打包器

// lib/webpack/core.ts
// eslint-disable-next-line import/named
import { DeepRequired } from '@tuimao/core'
import path from 'path'
import { merge } from 'lodash'
import fs from 'fs-extra'

// @babel/parser 用于将字符串转换为 ast
import { parse } from '@babel/parser'

// @babel/traverse 用于节点遍历查询
import traverse from '@babel/traverse'

// @babel/generator 用于将节点转换为代码
import { transformFromAstSync } from '@babel/core'

export interface Configuration {
  entry?: string
  output?: {
    path?: string
    filename?: string
  }
}

const base = process.cwd()
const defaultConfig = {
  entry: path.resolve(base, './src/index.js'),
  output: {
    filename: path.resolve(base, 'main.js'),
    path: path.resolve(base, './dist')
  }
}

class Compiler {
  $options: DeepRequired<Configuration>
  constructor(options: Configuration) {
    this.$options = merge(defaultConfig, options)
  }
  /**
   * 创建 webpack 打包
   */
  run() {
    // 获取到文件文件夹路径
    const dirname = path.dirname(this.$options.entry)

    // 1. 读取入口文件内容
    const entryFile = fs.readFileSync(this.$options.entry, 'utf-8')
    // 2. 将其解析成 ast 抽象语法树
    const ast = parse(entryFile, {
      sourceType: 'module'
    })

    // 定义储存依赖的容器
    const deps: Record<string, string> = {}

    // 递归收集入口依赖
    traverse(ast, {
      // 内部遍历 ast 中的 program.body 判断里面语句类型
      // 如果 type = ImportDeclaration 则触发当前函数
      ImportDeclaration({ node }) {
        const importPath = node.source.value
        // 这里只做一个简单的拼接, 实际情况要复杂很多
        // - 是否携带后缀 .js | .ts
        // - 是否是 node_modules 中的 package
        const absolutePath = path.resolve(dirname, importPath)
        // 进行收集
        deps[importPath] = absolutePath
      }
    })

    // 将入口文件编译为浏览器可识别代码
    const { code } = transformFromAstSync(ast, null, { presets: ['@babel/preset-env'] })
    console.log(code)
  }
}

export function webpack(config: Configuration) {
  return new Compiler(config)
}

整段代码可以看到,收集的 deps 还没有处理,这里先不着急,把功能模块分离

# 模块分离

入口文件:webpack/index.ts

import { Configuration, defaultConfig } from './core'
import Compiler from './compiler'
import { merge } from 'lodash'

export function webpack(config: Configuration) {
  const $config = merge(defaultConfig, config)
  return new Compiler($config)
}

源码配置:webpack/core.ts

import path from 'path'

/** 基本地址 */
export const basePath = process.cwd()

/** 外部配置 */
export interface Configuration {
  entry?: string
  output?: {
    path?: string
    filename?: string
  }
}

/** 内部读取配置 */
export type ConfigurationRead = Configuration & typeof defaultConfig

/** 默认配置 */
export const defaultConfig = {
  entry: path.resolve(basePath, './src/index.js'),
  output: {
    filename: path.resolve(basePath, 'main.js'),
    path: path.resolve(basePath, './dist')
  }
}

解析方法:webpack/parser.ts

import fs from 'fs-extra'
import path from 'path'

// @babel/parser 用于将字符串转换为 ast
import { parse, ParserOptions } from '@babel/parser'

// @babel/traverse 用于节点遍历查询
import traverse from '@babel/traverse'

// transformFromAstSync 用于将节点转换
import { transformFromAstSync, Node } from '@babel/core'

/**
 * 同步读取文件 ast
 * @param path 路径
 * @param options {ParserOptions}
 */
export const readFileAstSync = (path: string, options?: ParserOptions) => {
  const file = fs.readFileSync(path, 'utf-8')
  return parse(file, options || { sourceType: 'module' })
}

/**
 * 读取 entry ast 中 module 并收集依赖
 * @param entry
 */
export const readFileAstModuleDeps = (entry: string) => {
  const ast = readFileAstSync(entry)
  // 获取到文件文件夹路径
  const dirname = path.dirname(entry)

  // 定义储存依赖的容器
  const deps: Record<string, string> = {}
  // 递归收集入口依赖
  traverse(ast, {
    // 内部遍历 ast 中的 program.body 判断里面语句类型
    // 如果 type = ImportDeclaration 则触发当前函数
    ImportDeclaration({ node }) {
      const importPath = node.source.value
      // 这里只做一个简单的拼接, 实际情况要复杂很多
      // - 是否携带后缀 .js | .ts
      // - 是否是 node_modules 中的 package
      const absolutePath = path.resolve(dirname, importPath)
      // 进行收集
      deps[importPath] = absolutePath.replace(/\\/g, '/')
    }
  })
  return deps
}

/**
 * 将 ast 经过 @babel/preset-env 处理返回代码
 * @param ast
 */
export const transformPresetEnvCode = (ast: Node) => {
  const { code } = transformFromAstSync(ast, null, { presets: ['@babel/preset-env'] })
  return code || ''
}

编译器:webpack/compiler.ts

import { ConfigurationRead } from './core'
import { readFileAstSync, readFileAstModuleDeps, transformPresetEnvCode } from './parser'

class Compiler {
  constructor(private $options: ConfigurationRead) {}
  /**
   * 创建 webpack 打包
   */
  run() {
    // 1. 读取入口文件 ast
    const ast = readFileAstSync(this.$options.entry)

    // 2. 收集引入依赖
    const deps = readFileAstModuleDeps(this.$options.entry)

    // 3. 将入口文件编译
    const code = transformPresetEnvCode(ast)

    console.log(ast, deps, code)
  }
}

export default Compiler

# 代码生成

import { ConfigurationRead } from './core'
import { readFileAstSync, readFileAstModuleDeps, transformPresetEnvCode } from './parser'
import path from 'path'
import fs from 'fs-extra'
interface Source {
  path: string
  deps: Record<string, string>
  code: string
}

interface Dependencys {
  [key: string]: {
    code: string
    deps: Record<string, string>
  }
}

class Compiler {
  constructor(private $options: ConfigurationRead, private modules: Source[] = []) {}
  /**
   * 创建 webpack 打包
   */
  run() {
    // 1. 初次构建, 得到入口文件的信息
    const entry = this.$options.entry
    const source = this.build(entry)

    this.modules.push(source)
    // 2. 遍历所有的依赖, 收集所有模块
    for (const { deps } of this.modules) {
      // 遍历当前文件的所有依赖
      for (const [_, absolutePath] of Object.entries(deps)) {
        const depSource = this.build(absolutePath)
        // 将处理信息再次加入 modules 中
        // 下一步遍历将遍历该模块
        this.modules.push(depSource)
      }
    }
    // 3. 将依赖整理成依赖关系表
    const depsGraph = this.modules.reduce((graph, source) => {
      return {
        ...graph,
        [source.path]: {
          code: source.code,
          deps: source.deps
        }
      }
    }, <Dependencys>{})
    this.generate(depsGraph)
  }

  /**
   * 构建某个 js 文件
   * @param filePath
   */
  build(filePath: string) {
    // 1. 读取文件 ast
    const ast = readFileAstSync(filePath)

    // 2. 收集引入依赖
    const deps = readFileAstModuleDeps(filePath)

    // 3. 将文件编译
    const code = transformPresetEnvCode(ast)

    return <Source>{ path: filePath, deps, code }
  }

  generate(depsGraph: Dependencys) {
    const bundle = `
    ;(function (depsGraph) {
      // require 目的: 加载入口文件
      function require(module) {
        // 模块内部的 require 函数, 调用模块中依赖模块, 再次进入外部 require
        function localRequire(depModule) {
          return require(depsGraph[module].deps[depModule])
        }

        // 定义暴露对象 (将来模块要暴露的内容)
        let exports = {}

        ;(function (require, exports, code) {
          // 调用 code, 存在 require 会进入内部的 localRequire
          // 从而形成递归调用, 调用 code > 存在依赖 > 调用 localRequire > 调用 code ....
          eval(code)
        })(localRequire, exports, depsGraph[module].code)

        // 返回模块中的 exports 对象
        // 这里其实做的不完整, 因为 exports 会因为 module.exports 给覆盖
        return exports
      }

      // 加载入口文件
      require('${this.$options.entry}')
    })(${JSON.stringify(depsGraph)})
    `

    const filePath = path.resolve(this.$options.output.path, this.$options.output.filename)
    fs.ensureDirSync(this.$options.output.path)
    fs.writeFileSync(filePath, bundle)
  }
}

export default Compiler