webpack

214 阅读10分钟

目录

  • webpack 简介
    • 什么是webpack? webpack 能干什么?
  • 核心概念(concepts)
    • entry、output、module、chunk、loader、plugin 是什么?
  • 体验 webpack 功能
    • demo
  • loader & 写一个
    • loader 的工作原理及如何写一个 loader?
  • plugin & 写一个
    • plugin 的工作原理及如何写一个 plugin?
  • 优化(optimize)
    • 缩短编译时间
      • happypack 多进程打包
    • 减小打包后的文件大小
      • tree-shaking
      • 提取公共代码
      • 动态导入,懒加载
      • 开启 Scope Hoisting
    • HMR 热更新提高开发效率

webpack

  • 什么是webpack

    webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle。

  • webpack 能做什么

    • 代码转换:TypeScript 编译成 JavaScript、SCSS 编译成 CSS 等。
    • 文件优化:压缩 JavaScript、CSS、HTML 代码,压缩合并图片等。
    • 代码分割:提取多个页面的公共代码、提取首屏不需要执行部分的代码让其异步加载。
    • 模块合并:在采用模块化的项目里会有很多个模块和文件,需要构建功能把模块分类合并成一个文件。
    • 自动刷新:监听本地源代码的变化,自动重新构建、刷新浏览器。
    • 代码校验:在代码被提交到仓库前需要校验代码是否符合规范,以及单元测试是否通过。

核心概念(concepts)

  • entry

    入口,Webpack 执行构建的第一步将从 Entry 开始

  • output

    输出结果,在 Webpack 经过一系列处理并得出最终想要的代码后输出结果。

  • module

    模块,在 Webpack 里一切皆模块,一个模块对应着一个文件。Webpack 会从配置的 Entry 开始递归找出所有依赖的模块。

  • chunk

    代码块,一个 Chunk 由多个模块组合而成,用于代码合并与分割。

  • loader

    模块转换器,用于把模块原内容按照需求转换成新内容。

  • plugin

    扩展插件,在 Webpack 构建流程中的特定时机注入扩展逻辑来改变构建结果或做你想要的事情。

体验 webpack 功能

这里会罗列一些 webpack 的使用事例

事例启动介绍

我希望每个 demo 有自己的 webpack.config.js 文件, 所有的 demo 共用 package.json 文件和 node_modules。

我可以像下面这样运行我的demo。

    npm run build demo1

简单配置如下: package.json

//...
 "script": {
    "build": "webpack --progress --provide",
    "dev": "webpack-dev-server --open --provide"  // --provide 本来是向模块中添加自定变量,我这里用来传命令行参数以获取要打包的 demo 名 
 }
//...

B-webpack/webpack.config.js

const demo = process.argv[4] || process.argv[3] // 因为执行 dev 和 build 命令时参数不太一样

module.exports = merge({
    entry: `./src/${demo}/index.js` // 动态修改为每个 demo 的入口文件
}, demoConfig)

webpack 基本用法 demo1

webpack 支持 ES6、commonjs、AMD 规范编写。

demo1/webpack.config.js

module.exports = {
    entry: '',// default value --> ./src/index.js
    output: {
        path: path.resolve(), // 输出目录 绝对路径。default value --> dist
        filename: '' // 输出文件名。 default value ---> main.sj
    }
}

配置开发服务器 demo2

配置 devServer 方便预览 web UI(包括引入的 js 和 css)。

npm i webpack-dev-server -D

B-webpack/webpack.config.js

因为所有的demo都会用到, 所以就放在了根目录下的webpack.config.js 文件中

// ...
module.exports = {
    // ..
    devServer: {
        contentBase: path.resolve('__dirname', 'dist'), // 把 dist 目录作为静态服务目录
        host: 'www.baidu.com',
        compress: true, // 是否启动 gzip 压缩
        port: 8080
    }
}
//...

加载资源文件demo3

demo3/webpack.config.js

    module: {
        mode: 'development',
        rules: [
            // 处理 css 
            {
                test: /\.css$/,
                // 这里配置 loader 有三种写法
                loader: ['style-loader', 'css-loader']
                // use: ['style-loader', 'css-loader']
                // use: [
                //   {
                //     loader: 'style-loader',
                //     options: {}
                //   }, 'css-loader'
                // ]
            },
            // 处理图片
            {
                test: /\.(png|svg|jpg|gif)$/,
                use: ['file-loader']
           },
           // 处理字体
           {
                test: /\.(woff|woff2|eot|ttf|otf)$/,
                use: ['file-loader']
           },
           // 处理 csv, tsv 文件
            {
                test: /\.(csv|tsv)$/,
                use: [ 'csv-loader' ]
           },
           // 处理 xml 文件。对 JSON 的支持是内置的。
           {
                test: /\.xml$/,
                use: [ 'xml-loader' ]
           }
        ]
    }

这里我特意写了 mode: 'development' 是因为,development 模式下,一切都是正常的,可以把 css 文件正常的打包进来。

而 mode: 'production' 时,我们通过

import './css/index.css'

这种方式引入的 css 文件会被删除。而不能正常的引入 css。详细原因可以看下面 tree-shaking 。

loader & 写一个 demo5

loader 是导出为一个函数的 node 模块。该函数在 loader 转换资源的时候调用。给定的函数将使用提供给它的 this 上下文访问 Loader API

链式调用多个 loader 的时候,从右向左执行。

  • 最后的loader最早调用,将会传入原始资源内容。
  • 第一个loader最后调用,期望值是传出 Javascript 和 source map(可选)。
  • 中间的loader执行时, 会传入前一个loader传出的结果。

loader 工具库

  • loader-utils 最常用的一种工具是获取传递给 loader 的选项。
  • schema-utils 用于保证 loader 选项,进行与 JSON Schema 结构一致的校验。
const { getOPtions } = require('loader-utils')

module.exports = function(source) {
    const options = getOptions(this)
    // 1. 通过this.callback 告诉 webpack 返回的结果。
    this.callback(null, source, sourceMaps)
    // 当你使用 this.callback 返回内容时,该Loader 必须返回 undefined,
    // 以让 webpack 知道该 Loader 返回的结果在 this.callback 中, 而不是 return 中。
    return;
}

完整格式

    this.callback(
        // 当无法转换原内容时,给 Webpack 返回一个 Error
        err: Error | null,
        // 原内容转换后的内容
        content: string | Buffer,
        // 用于把转换后的内容得出原内容的 Source Map,方便调试
        sourceMap?: SourceMap,
        // 如果本次转换为原内容生成了 AST 语法树,可以把这个 AST 返回,
        // 以方便之后需要 AST 的 Loader 复用该 AST,以避免重复生成 AST,提升性能
        abstractSyntaxTree?: AST
    );

上下文 this 有哪些常用的属性和方法

this.context //	当前处理文件的所在目录,假如当前 Loader 处理的文件是 /src/main.js,则 this.context 就等于 /src

this.resolve //	像 require 语句一样获得指定文件的完整路径,使用方法为 resolve(context: string, request: string, callback: function(err, result: string))
this.async //告诉 loader-runner 这个 loader 将会异步地回调。返回 this.callback。

plugin & 写一个 demo6

一个插件由以下构成

  • 一个具名 JavaScript 函数。
  • 在它的原型上定义 apply 方法。
  • 指定一个触及到 webpack 本身的 事件钩子。
  • 操作 webpack 内部的实例特定数据。
  • 在实现功能后调用 webpack 提供的 callback。

webpack 执行流程:

初始化阶段:

  • 初始化参数: 从配置文件和 shell 语句中读取与合并参数,得出最终的参数。
  • 实例化 Compiler: 用上一步得到的参数初始化 Compiler 实例, Compiler 负责文件监听和启动编译。Compiler 实例中包含了完整的 Webpack 配置, 全局只用一个Compiler 实例。
  • 加载插件 依次调用插件的 apply 方法,让插件可以监听后续的所有事件节点。同时给插件传入compiler 实例的引用,以方便插件通过 compiler 调用 Webpack 提供的API。
  • environment:开始应用Node.js 风格的文件系统到 compiler 对象,以方便后续的文件寻找和读取。
  • entry-option: 读取配置的 Entrys, 为每个 Entry 实例化一个对应的 EntryPlugin, 为后面该Entry 的递归解析工作做准备。
  • after-plugins: 调用完所有内置的和配置的插件的 apply 方法。
  • after-resolvers: 根据配置初始化完resolver, resolver 负责在文件系统中寻找指定路径的文件。

编译阶段:

  • run: 启动一次新的编译。
  • watch-run:和run类似,区别在于他是在监听模式下启动的编译,在这个事件中可以获取到是哪些文件发生了变化导致重新启动一次新的编译。
  • compile: 该事件是为了插件一次新的编译将要启动,同时会给插件带上 compiler 对象。
  • compilation: 当Webpack 以开发模式运行时,每当检测到文件变化,一次新的 compilation 将被创建。一个compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。compilation 对象也提供了很多事件回调供插件做扩展。
  • make: 一个新的 compilation 创建完毕,即将从 Entry 开始读取文件,根据文件类型和配置的loader 对文件进行编译,编译完后再找出该文件依赖的文件,递归的编译和解析。
  • after-compile: 一次 compilation 执行完成。

在编译阶段中,最重要的要数compilation 事件了,因为在compilation 阶段调用了 loader 完成了每个模块的转换操作, 在compilation 阶段又包括很多小事件他们分别是:

  • build-module: 使用对应的 loader 去转换一个模块。
  • normal-module-loader: 再用loader 对一个模块转换完成后,使用 acorn 解析转换后的内容,输出对应的抽象语法树 (AST),以便webpack后面对代码的分析。
  • program: 从配置入口模块开始,分析其AST,当遇到require等导入其他模块语句时,便将其加入到依赖的模块列表,同时对新找出的依赖模块递归分析,最终搞清所有模块的依赖关系。
  • seal: 所有模块及其依赖的模块都通过 loader 转换完成后,根据依赖关系开始生成 chunK。

输出阶段

  • should-emit: 所有需要输出的文件已经生成好,询问插件哪些文件需要输出, 哪些文件不需要。
  • emit: 确定好要输出哪些文件后,执行文件输出,可以在这了获取和修改输出内容。
  • after-emit: 文件输出完毕。
  • done: 成功完成一次完整的编译和输出流程。
  • failed: 如果在编译和输出流程中遇到异常导致 webpack 退出时, 就会直接跳转到本步骤,插件可以在本事件中获取到具体的错误原因。

编译核心对象 Compilation

this.compiler = compiler;   // compiler 对象
this.resolvers = compiler.resolvers; // 模块解析器
this.mainTemplate = new MainTemplate(this.outputOptions);  // 生成主模块 js
this.chunkTemplate = new ChunkTemplate(this.outputOptions, this.mainTemplate); // 异步加载的模块 JS
this.hotUpdateChunkTemplate = new HotUpdateChunkTemplate(this.outputOptions); // 热更新的代码块模板js
this.moduleTemplate = new ModuleTemplate(this.outputOptions); // 入口模块代码
this.entries = []; // 入口
this.preparedChunks = []; // 预先加载的chunk
this.chunks = []; // 所有的 chunk
this.namedChunks = []; // 每一个都对应一个名字, 可以通过 namedChunks[name] 获取chunk
this.modules = []; // 所有 modules
this.assets = {}; // 保存所有生成的文件
this.children = []; // 保存子 compilation 对象, 子compilation 对象依赖他的上级compilation 对象生成的结果,所以要等父compilation 编译完成才能开始。
this.dependencyFactories = new ArrayMap(); // 保存Dependency 和 ModuleFactory 的对应关系,方便创建该依赖对应的Module
this.dependencyTemplates = new ArrayMap();// 保存Dependency 和 Template 对应关系 方便生成加载此模块的代码。


优化(optimize)

编译时间优化

打包文件优化

tree-shaking

这个库字面意思就是摇树,把死叶子摇下来,只保留正常的活叶子。

那在我们写的代码中哪些是 '死代码' 会被 tree-shaking 呢。

// 1.
// 导入并赋值给 JavaScript 对象,然后在下面的代码中被用到
// 这会被看作 '活代码', 不会做 tree-shaking
 import liveCode from './liveCode'
 doSomething(liveCode)

// 2.
// 导入并赋值给 JavaScript 对象,但在接下来的代码中没有用到
// 这就会被当做 '死代码',会被 tree-shaking
 import deadCode from './deadCode'
 doSomething();

// 3.
// 导入但没有赋值给 JavaScript 对象,也没有在代码里用到
// 这会被当做 '死代码' 会被 tree-shaking
 import './deadCode'
 doSomething()

// 4.
// 导入整个库,但是没有赋值给 JavaScript 对象,也没有在代码里用到
// 这被当做 '活代码', 因为 webpack 对库的导入和本地代码的导入处理方式不同
 import 'liveCodeLib'
 doSomthing()

因此当我们引入工具库 lodash 库时。应该避免引入整个库。

// 不建议直接导入整个包,不支持 tree-shaking
import _ from 'lodash'
// 建议 具名导入,支持 tree-shaking
import { debounce } from 'lodash'
// 建议直接导入具体的模块, 支持 tree-shaking
import debounce from 'lodash/debounce'

那我们怎么开启 tree-shaking 模式呢?

  • webpack 处于 mode: 'production' 这种情况下会开启 tree-shaking 模式
  • tree-shaking 的实现基于 ES6 modules 的设计(ES6模块依赖关系是确定的,和运行时的状态无关,可以进行可靠的静态分析)。

这样就万事大吉了吗?

不是的。像下面这样:

math.js

import { debounce } from 'lodash/debounce'

export function cube(x) {
    return  x * x
}

export function square() {
    debounce()
}

index.js

import { cube } from './math.js'
cube()

lodash 中的 debounce 方法在 main.js 中用到了,但 index.js 中只引入 cube 方法,所以实际上 debounce 方法还是没用到。这种情况下应该也是被 tree-shaking 的,但代码中却引入进来了。