Skip to content

基本概念 ​

本质上,Webpack 是一个用于现代 JavaScript 应用程序的静态模块打包工具。当 Webpack 处理应用程序时,它会在内部从一个或多个入口点构建一个依赖图(dependency graph),然后将你项目中所需的每一个模块组合成一个或多个 bundles,它们均为静态资源,用于展示你的内容。

Chunk 和 Bundle ​

Chunk 是打包过程中代码块,Bundle 是打包结果输出的代码块,Chunk 在构建完成后就呈现为 Bundle。

Chunk: Chunk 是 Webpack 打包过程中 Module 的集合,是「打包过程中」的概念(Webpack 通过引用关系打包模块,这些 module 就形成一个 chunk)

Bundle: 是 Webpack 打包后最终输出的一个或者多个打包好的文件

Chunk 和 Bundle 的关系是什么?

大多数情况下,一个 Chunk 会产生一个 Bundle。但是也有例外,比如:

  • 加了 sourcemap,则打包结果中会有 sourcemap 的 Bundle
  • 配置了 splitChunks,则 Chunk 会被分割为多个,打包结果中会依据分包配置输出多个 Bundle

entry ​

entry 是 Webpack 开始分析依赖的入口。Webpack 从 entry 开始,遍历整个项目的依赖。entry 可以有一个或多个:

js
module.exports = {
    entry: './path/to/my/entry/files.js',
}

// 多入口
module.exports = {
    entry: {
        app: './src/app.js',
        adminApp: './src/adminApp.js',
    },
}

output ​

output 用来指示 Webpack 将打包后的 bundle 文件放在什么位置:

js
const path = require('path');

module.exports = {
    entry: './path/to/my/entry/files.js',
    output: {
        path: path.resolve(__dirname, 'dist'),
        fileName: 'my-fist-webpack-bundle.js'
    },
}

高级用法:

js
module.exports = {
    output: {
        // 输出 bundle 文件名,contenthash 是 wepack 根据资源内容使用散列算法生成一段字符串,
        // 这样每次打包的文件名都不一样,浏览器即使缓存,每次也能加载最新代码
        filename:'[name].[contenthash:8].bundle.js',
        // 输出的 chunk 文件名,一般是非 entry 打包出的文件
        chunkFilename:'[id].js',
    },
}

loader 和 plugin ​

loader: 模块转换器,将非 js 模块转化为 Webpack 能识别的 js 模块。本质上 Webpack loader 将所有类型的文件,转换为应用程序的依赖图可以直接引用的模块。

plugin: 扩展插件,Webpack 运行的各个阶段,都会广播出对应的事件,插件去监听对应的事件。

Compiler: 对象,包含了 Webpack 环境的所有配置信息,包括 options、loader、plugins,Webpack 启动的时候实例化,它在全局是唯一的,可以简单理解为 Webpack 的实例。

Compilation: 包含了当前的模块资源,Webpack 在开发模式下运行时,每当检测到一个文件变化,就会创建一次新的 Compilation。

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

module.exports = {
    entry: './path/to/my/entry/files.js',
    output: {
        path: path.resolve(__dirname, 'dist'),
        fileName: 'my-fist-webpack-bundle.js'
    },
    module: {
        rules: [
            {
                test: '/\.txt$/',
                use: 'raw-loader',
            },
            {
                test: /\.css$/,
                // 注意:loader 的执行顺序是反的,从数组的最后往前执行
                use: ['style-loader', 'css-loader'],
            },
        ],
    },
    plugins: [
        // HtmlWebpackPlugin 为应用生成一个 html 文件,并且自动注入所有生成的 js bundle,这是 loader 所做不到的
        new HtmlWebpackPlugin({ template: './src/index.html' }),
    ]
}

过程及原理 ​

过程 ​

  1. 初始化参数:解析 webpakc.config.js 配置文件
  2. 开始编译:初始化一个 Compiler 对象,加载所有配置,开始执行编译
  3. 确定入口:根据 entry 中的配置,找出所有的入口文件
  4. 编译模块:从入口文件开始,调用所有的 loader,再去递归的查找依赖
  5. 完成模块编译:得到每个模块编译后的最终内容以及他们之间的依赖关系
  6. 输出资源:根据得到的依赖关系,组装成一个个包含多个 module 的 chunk
  7. 输出完成:根据配置,确定输出的文件名及文件路径

实现 ​

我们根据 Webpack 构建的基本原理,来实现一个简易的打包器:

js
/**
 * webpack 基础原理实现
 * 1. 找到一个入口文件
 * 2. 解析这个入口文件,收集他的依赖
 * 3. 解析入口文件依赖的依赖,递归去创建一个文件间的依赖关系图,描述所有文件的依赖
 * 4. 把所有文件打包成一个文件
 */
const fs = require('fs');
const path = require('path');
const babylon = require('babylon');
const traverse = require('babel-traverse').default;
const babel = require('babel-core');

let ID = 0;
function createAssets(filename) {
    const content = fs.readFileSync(filename, 'utf-8');
    const ast = babylon.parse(content, {
        sourceType: 'module'
    });
    const dependencies = [];

    traverse(ast, {
        ImportDeclaration: ({ node }) => {
            dependencies.push(node.source.value);
        }
    });

    const id = ID++;
    const { code } = babel.transformFromAst(ast, null, {
        presets: ['env'],
    });

    return {
        id,
        filename,
        dependencies,
        code,
    }
}

function createGraph(entry) {
    const mainAsset = createAssets(entry);
    const allAsset = [mainAsset];

    // 递归遍历所有依赖
    for (let asset of allAsset) {
        const dirname = path.dirname(asset.filaname);

        asset.mapping = {};
        asset.dependencies.forEach(relativePath => {
            const absolutePath = path.join(dirname, relativePath);
            const childAsset = createAsset(absolutePath);

            asset.mapping[relativePath] = childAsset.id;

            allAsset.push(childAsset);
        });
    }

    return allAsset;
}

// 创建结果代码
function bundle(graph) {
    let modules = '';

    graph.forEach(module => {
        modules += `${module.id}: [
            function (require, module, exports) {
                ${module.code}
            },
            ${JSON.stringify(module.mapping)},
        ],`
    });

    // 实现 require 方法
    const result = `
        (function (modules) {
            function require(id) {
                const [fn, mapping] = modules[id];

                function localRequire(relativePath) {
                    return require(mapping[relativePath]);
                }

                const modules = { exports: {} };

                fn(localRequire, modules, modules.exports);

                return modules.exports;
            }
            require(0);
        })({${modules}})
    `;

    return result;
}

const graph = createGraph('./source/entry.js');
const result = bundle(graph);

console.log(result);

Loader & Plugin ​

Webpack 整体基于两大支柱功能:一个是 loader,用于对模块的源码进行转换,基于管道模式;另一个就是 plugin,用于解决 loader 无法解决的问题,顾名思义,plugin 就是基于插件机制的。

Loader ​

所谓 loader 只是一个导出为函数的 JavaScript 模块。loader runner 会调用这个函数,然后把上一个 loader 产生的结果或者资源文件(resource file)传入进去。函数的 this 上下文将由 webpack 填充,并且 loader runner 具有一些有用方法,可以使 loader 改变为异步调用方式,或者获取 query 参数。

同步 loader ​

无论是 return 还是 this.callback 都可以同步地返回转换后的 content 内容:

js
/**
 * @param {String | Buffer} content 资源文件的内容
 * @param {Object} map SourceMap,格式为 JSON 对象
 * @param {Any} 可以是任何东西(例如一些元数据),会被 webpack 忽略
 */
module.exports = function(content, map, meta) {
    return someSyncOperation(content);
};

// or
module.exports = function(content, map, meta) {
    this.callback(null, someSyncOperation(content), map, meta);
    return; // 当调用 callback() 时总是返回 undefined
};

异步 loader ​

对于异步 loader,使用 this.async 来获取 callback 函数:

js
module.exports = function(content, map, meta) {
    var callback = this.async();
    someAsyncOperation(content, function(err, result) {
        if (err) return callback(err);
        callback(null, result, map, meta);
    });
};

Pitching loader ​

loader 总是从右到左地被调用。有些情况下,loader 只关心 request 后面的元数据(metadata),并且忽略前一个 loader 的结果。在实际(从右到左)执行 loader 之前,会先从左到右调用 loader 上的 pitch 方法。对于以下 use 配置:

json
use: [
    'a-loader',
    'b-loader',
    'c-loader'
]

将会发生这些步骤:

|- a-loader `pitch`
  |- b-loader `pitch`
    |- c-loader `pitch`
      |- requested module is picked up as a dependency
    |- c-loader normal execution
  |- b-loader normal execution
|- a-loader normal execution

那么,为什么 loader 可以利用「跳跃(pitching)」阶段呢?

首先,传递给 pitch 方法的 data,在执行阶段也会暴露在 this.data 之下,并且可以用于在循环时,捕获和共享前面的信息。

js
module.exports = function(content) {
    return someSyncOperation(content, this.data.value);
};

module.exports.pitch = function(remainingRequest, precedingRequest, data) {
    data.value = 42;
};

其次,如果某个 loader 在 pitch 方法中给出一个结果,那么这个过程会回过身来,并跳过剩下的 loader。在我们上面的例子中,如果 b-loader 的 pitch 方法返回了一些东西:

js
module.exports = function(content) {
    return someSyncOperation(content);
};

module.exports.pitch = function(remainingRequest, precedingRequest, data) {
    if (someCondition()) {
        return "module.exports = require(" + JSON.stringify("-!" + remainingRequest) + ");";
    }
};

上面的步骤将被缩短为:

|- a-loader `pitch`
  |- b-loader `pitch` returns a module
|- a-loader normal execution

Plugin ​

来看一个典型的 Webpack 插件:

js
const pluginName = 'ConsoleLogOnBuildWebpackPlugin';

class ConsoleLogOnBuildWebpackPlugin {
    apply(compiler) {
        compiler.hooks.compile.tap(pluginName, params => {
            console.log('以同步方式触及 compile 钩子');
        });

        compiler.hooks.run.tapAsync(pluginName, (compiler, callback) => {
            console.log('以异步方式触及 run 钩子');
            callback();
        });

        compiler.hooks.run.tapPromise(pluginName, compiler => {
            return new Promise(resolve => setTimeout(resolve, 1000)).then(() => {
                console.log('以具有延迟的异步方式触及 run 钩子');
            });
        });
    }
}

module.exports = ConsoleLogOnBuildWebpackPlugin;

Webpack 在初始化时会统一执行插件的 apply 方法。插件通过注册 Compiler 和 compilation 的钩子函数,在整个编译生命周期都可以访问 compiler 对象,完成插件功能。同时整个事件驱动的功能都是基于 Webpack 的核心工具 Tapable。

Tapable ​

Tapable 是一个插件框架,也是 Webpack 的核心依赖。Webpack 中许多对象扩展自 Tapable 类。这个类暴露 tap, tapAsync 和 tapPromise 方法,可以使用这些方法,注入自定义的构建步骤,这些步骤将在整个编译过程中不同时机触发。

js
const {
    SyncHook,               // 同步钩子
    SyncBailHook,           // 同步熔断钩子
    SyncWaterfallHook,      // 同步流水钩子
    SyncLoopHook,           // 同步循环钩子
    AsyncParalleHook,       // 异步并发钩子
    AsyncParallelBaillHook, // 异步并发熔断钩子
    AsyncSeriesHook,        // 异步串行钩子
    AsyncSeriesBailHook,    // 异步串行熔断钩子
    AsyncSeriesWaterfallHook // 异步串行流水钩子
} = require('tapable');

Tapable 的使用:

js
const { SyncHook } = require('tapable');
// 创建实例
const syncHoook = new SyncHook(['name', 'age']);

// 注册事件
syncHook.tap('1', (name, age) => { console.log('1', name, age) });
syncHook.tap('2', (name, age) => { console.log('1', name, age) });
syncHook.tap('3', (name, age) => { console.log('1', name, age) });

syncHook.call('Harry Potter', 18);

// output:
// 1 Harry Potter 18
// 2 Harry Potter 18
// 3 Harry Potter 18

构建优化 ​

构建速度 ​

  • 构建耗时分析 speed-measure-webpack-plugin
  • 优化 resolve 配置
  • externals
  • 缩小查找范围 include、exclude
  • noParse
  • IgnorePlugin
  • 多进程配置
  • 缓存利用
  • 延迟处理/按需构建

构建结果 ​

运行时体验 ​

运行时优化的核心就是提升首屏的加载速度,主要的方式就是降低首屏加载文件体积,首屏不需要的文件进行预加载或者按需加载。

  • 入口点分割
  • splitChunks 分包配置
  • 代码懒加载
  • prefetch 与 preload

参考资料 ​