完整的 webpack loader 执行顺序

2022-07-25 · 1,129 chars · 6 min read

这两天在看 Preact Cli,想了解下这个声称「100 Lighthouse score」的脚手架内部是怎么运行的。刚好我对目前使用的框架也有些不满意的地方,就顺便看看有没有什么亮点值得借鉴的。

Preact is an excellent choice for Progressive Web Apps that wish to load and become interactive quickly. Preact CLI codifies this into an instant build tool that gives you a PWA with a 100 Lighthouse score right out of the box.

其中有个核心功能是拆包,就是按照路由将整个 APP 拆分开来,按需加载。我们之前一直用的是 loadable-components,但是 preact 是通过 @preact/async-loader 实现的,这个 loader 其实是一个 pitch loader,配合 enforce: 'pre' 的 babel-loader 一起处理 js 代码。

由于我以前没写过 pitch loader,便很好奇它和 pre 阶段的 loader 放在一起,执行顺序是怎么样的。网上搜索了一番,大多数文章是把 pre & post 和 pitch & normal 分开讲的。官方的文档也说的比较简单,于是自己试验了一番,这篇笔记就简单记录下。

Note

Preact CLI 架构相关的内容,等彻底研究完再看,有价值的话单独写文章

准备代码#

使用版本:

  • webpack:5.73.0
  • webpack-cli:4.10.0

webpack.config.js 代码如下:

const path = require("path")

module.exports = {
  entry: "./index.js",
  output: {
    filename: "bundle.js",
    path: path.resolve(__dirname, "dist"),
  },
  mode: "development",
  module: {
    rules: [
      {
        enforce: "pre",
        test: /\.md$/i,
        use: ["pre-a-loader", "pre-b-loader"],
      },
      {
        test: /\.md$/i,
        use: ["a-loader", "b-loader", "c-loader"],
      },
      {
        enforce: "post",
        test: /\.md$/i,
        use: ["post-a-loader", "post-b-loader"],
      },
    ],
  },
  resolveLoader: {
    modules: [path.resolve(__dirname, "node_modules"), path.resolve(__dirname, "loaders")],
  },
}

为了完整测试,一共添加了 7 个 loader,覆盖除了 inline loader 之前的全部场景(目前很少用 inline)。每个 loader 除了 console.log 的内容外,都长一样的。例如 a-loader.js

function loader(content, map, meta) {
  console.log('a normal loader')
  return '123'
}

loader.pitch = () => {
  console.log('[pitch] a normal loader')
}

module.exports = loader;

测试#

直接执行 npx webpack,输出:

[pitch] a post loader
[pitch] b post loader
[pitch] a normal loader
[pitch] b normal loader
[pitch] c normal loader
[pitch] a pre loader
[pitch] b pre loader
b pre loader
a pre loader
c normal loader
b normal loader
a normal loader
b post loader
a post loader
asset bundle.js 2.38 KiB [compared for emit] (name: main)
./index.js 23 bytes [built] [code generated]
./README.md 3 bytes [built] [code generated]
webpack 5.73.0 compiled successfully in 81 ms

b-loader.jspitch 方法里 return 点东西

// ...省略
loader.pitch = () => {
  console.log('[pitch] b normal loader')
  return '123' // 加上这句
}
// ...省略

执行结果是这样的:

[pitch] a post loader
[pitch] b post loader
[pitch] a normal loader
[pitch] b normal loader
a normal loader
b post loader
a post loader
asset bundle.js 2.38 KiB [compared for emit] (name: main)
./index.js 23 bytes [built] [code generated]
./README.md 3 bytes [built] [code generated]
webpack 5.73.0 compiled successfully in 97 ms

Tip

不清楚 pitch 的朋友看官方文档 pitch loader,还有 Rule.enforce

结论#

正常情况下的执行顺序是:

  • 不同阶段之间:pre -> normal -> post
  • 每个阶段内部:use 数组里,从右到左执行

pitch 是完全反过来的

  • 不同阶段之间:post -> normal -> pre
  • 每个阶段内部:use 数组里,从左到右执行

如果某个 pitch 里有返回值,立刻停止,调头回去,之前执行过 pitch 的 loader(不包括自己),继续执行 loader 本身。

其实...这就和 koa 中间件的洋葱模型类似

Preact CLI#

虽然这篇笔记是讲 webpack loader 执行顺序的,但是是由 preact 引起的,还是稍微讲一下。

Preact CLI 将同步的代码转化为异步代码的核心原理:

  • 筛选路径是 routes 等的文件
  • 使用只含 pitch 方法的 @preact/async-loader,返回一段异步加载原文件的代码

webpack 部分的关键代码:

[
  {
    test: /\.[jt]sx?$/,
    include: [
      filter(source('routes') + '/{*,*/index}.{js,jsx,ts,tsx}'),
      filter(source('components') + '/{routes,async}/{*,*/index}.{js,jsx,ts,tsx}'),
    ],
    loader: asyncLoader, // @preact/async-loader
    options: {
      name(filename) {
        filename = normalizePath(filename);
        let relative = filename.replace(normalizePath(src), '');
        if (!relative.includes('/routes/')) return false;
        return 'route-' + cleanFilename(relative);
      },
      formatName(filename) {
        filename = normalizePath(filename);
        let relative = filename.replace(normalizePath(source('.')), '');
        return cleanFilename(relative);
      },
    },
  }
]

@preact/async-loader 的 pitch 方法:

return `
  import Async from ${stringifyRequest(
    this,
    path.resolve(
      __dirname,
      mode === PREACT_LEGACY_MODE ? 'async-legacy.js' : 'async.js'
    )
  )};

  function load(cb) {
    require.ensure([], function (require) {
      var result = require(${stringifyRequest(this, '!!' + req)});
      typeof cb === 'function' && cb(result);
    }${name ? ', ' + JSON.stringify(name) : ''});
  }

  export default Async(load);
`;

async.js 返回一个只负责加载异步组件的空组件

function AsyncComponent() {
  Component.call(this);

  if (!component) {
    this.componentWillMount = () => {
      load(mod => {
        component = (mod && mod.default) || mod;
        this.setState({});
      });
    };

    this.shouldComponentUpdate = () => component != null;
  }

  this.render = props => {
    if (component) {
      return h(component, props);
    }

    const prev = getPreviousSibling(this.__v);
    const me =
      (prev && prev.nextSibling) || (this.__P || this._parentDom).firstChild;

    if (!me) return;
    if (me.nodeType === 3) return me.data;
    return h(me.localName, {
      dangerouslySetInnerHTML: PENDING,
    });
  };
}

很巧妙的 this.setState({}) 触发渲染。

赞赏

微信