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 环境的所有配置信息,包括 optionsloaderplugins,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-loaderpitch 方法返回了一些东西:

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 方法。插件通过注册 Compilercompilation 的钩子函数,在整个编译生命周期都可以访问 compiler 对象,完成插件功能。同时整个事件驱动的功能都是基于 Webpack 的核心工具 Tapable

Tapable

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

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
  • 缩小查找范围 includeexclude
  • noParse
  • IgnorePlugin
  • 多进程配置
  • 缓存利用
  • 延迟处理/按需构建

构建结果

运行时体验

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

  • 入口点分割
  • splitChunks 分包配置
  • 代码懒加载
  • prefetchpreload

参考资料