多进程打包:thread-loader 源码(1)

1,303 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的6天,点击查看活动详情

一、背景

这不又到年初了,老板又拉了一大票成长计划(O(JB)KR),我头脑一热果断选择了一个我最不太熟悉的领域——并发程(多线程/进程)打包。

然而最近业务繁重,根本时间搞,连摸鱼的时间都没有了,咋办?

思虑再三,决定接着写小作文,一点一点磕,磕一点就写一点,这样也有动力,毕竟这种贡(刷)献(存)社(在)区(感)的摸鱼工作还是值得坚持下去的。

前面写的浅羲Vue源码这个大坑还没填完,但是又不得不开一个新坑,我保证这事儿干完接着填 Vue 源码的大坑。

多进程(线程)打包的实现有很多,比如大家都听过的子编译happypackthread-loader... happypack 作者对 js 兴趣退散了,最近不咋维护了,所以我们选择了 thread-loader 这个方案。

不能再多说背景了,再多说就被同事发现我了,这个活儿是记名的,否则这番吐槽就要到老板耳朵里面了

二、thread-loader 是个啥?

thread-loder 是个 loader,他不处理具体的转换模块到js的工作,而是把他后面的 loader 扔进一个工作线程池(worker pool)中并发运行,上传送门# thread-loader

2.1 thread-loader 的限制

运行在 worker pool 中的 loader 是受限制的,主要体现在以下几方面:

  1. 这些 loader 不能 通过 this.emitFile 生成一个新文件webpack 文档传送门 # this.emitFile
  2. 这些 loader 不能使用插件自定义的 loader 方法,所谓插件自定义就是通过插件向 loaderContext 扩展自定义的方法,loaderContextwebpack 提供的一个 loader 运行时的上下文对象;
  3. 这些 loader 同样也无法获取 webpack 打包的配置对象;

2.2 独立进程

thread-loader 虽然介绍时使用的是 worker pool 但是,它却不是真正的 worker 线程,而是实实在在的子进程(child_process);

官方说大约会有 600ms 左右的开销,并且进程间天然隔离,不能共享数据,只推荐在那些耗时比较长的 loader 中使用;

三、thread-loader 入口文件

3.1 搞源码

去 github 上克隆下来就行:

$ git clone git@github.com:webpack-contrib/thread-loader.git

大致文件结构如下:

├── CHANGELOG.md
├── LICENSE
├── README.md
├── babel.config.js
├── commitlint.config.js
├── example
    .....
│   └── webpack.config.js
├── husky.config.js
├── lint-staged.config.js
├── package-lock.json
├── package.json
├── src
│   ├── WorkerError.js
│   ├── WorkerPool.js
│   ├── cjs.js
│   ├── index.js
│   ├── readBuffer.js
│   ├── serializer.js
│   ├── worker.js
│   └── workerPools.js
└── test
    ├── ....

3.2 入口文件

package.jsonmain 字段得知,这个包的入口文件为 dist/cjs.js

image.png

但是你会发现,上面的目录中压根没有 dist 目录。。。WHAT?

此时别着急,这种情况下都是作者在本地打包好,然后用本地文件发包,只不过 git 仓库是忽略掉 dist 目录。这就要求我们看看作者是用啥打包的,一般的不外乎:webpack、esbuild、rollup、babel、snowpack...

打开 package.jsonscripts 脚本,一般打包命令都写在这里面:

{
  "name": "thread-loader",
  "main": "dist/cjs.js",
  "engines": {
    "node": ">= 10.13.0"
  },
  "scripts": {
    "build": "cross-env NODE_ENV=production babel src -d dist --copy-files"
  },
  "files": [
    "dist"
  ],
  
}

所以这个包使用 babel 打包的了,并且是打包一个目录(src)输出到 dist 目录,并且吧 src 目录下的文件复制到 dist(--copy-files),这部分内容属于 babel cli

执行下面的依赖安装和打包命令:

$ npm ci
$ npm run build

这样 dist 目录生成了:

image.png

dist 目录下的文件结构:

.
├── WorkerError.js
├── WorkerPool.js
├── cjs.js
├── index.js
├── readBuffer.js
├── serializer.js
├── worker.js
└── workerPools.js

这看起来和 src 下的文件别无二致,为啥还要打包?

打包的原因是作者用 ESModule 开发的,通过 Babel 打包成 CommonJS;但是我们阅读,就从打包的入口文件(thread-loader/src/index.js)看起就好了,这里算是分享一个我早期阅读源码的一个疑惑点。

四、loader.noraml & loader.pitch

4.1 入口模块的代码结构

import loaderUtils from 'loader-utils';

import { getPool } from './workerPools';

function pitch() {
  // ...
}

function warmup(options, requires) {
  // ....
}

export { pitch, warmup }; // eslint-disable-line import/prefer-default-export

在这个入口文件中定义并且导出了两个方法:pitchwarmup,我们先忽略方法中的具体逻辑,先说说这个结构。

值得一提的是 pitch 方法,这个名字不是瞎叫的,而是在 webpack 的整个生命周期中有这深远意义的名字;

4.2 pitch 方法

这需要你了解 webpackloader 的执行的全过程,我在前面的文章中写过一些关于 pitch 的内容

大致回顾一下,loader 的运行分为两个过程:pitchnormal 阶段,所谓 normal 就是实现具体功能的 loader 函数本身了,而 pitch 则是挂载 loader 上的一个特殊方法。

normal 阶段是大家熟知的按照 loader 添加的顺序倒序执行,但是在此之前还有一个 pitching 阶段,这个阶段会按照 loader 的添加顺如逐个执行 loaderpitch 方法;

pitch 是越过的意思,上官方文档### 越过 loader(Pitching loader)

也就是说你声明一个 loader 时,还可以声明一个 loader.pitch,用于跳过其余的 loader 进入到剩余 loadernormal 阶段;

比如我们对一个模块添加了三个 loader: [a, b, c],这些 loader 的整个执行过程如下:

a.pitch
  b.pitch
    c.pitch
      request modeule 被拾取成为依赖
    c.normal
  b.normal
a.normal  

那么 pitch 如何发挥作用实现跳过呢?只要让 loader.pitch 方法返回一个非 undefined 的值就可以了。比如 b.pitch 返回了一段新的 request

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

// loaderB.pitch
module.exports.pitch = function(remainingRequest, precedingRequest, data) {
  return 'module.exports = require(' + JSON.stringify('-!' + remainingRequest) + ');';
};

此时这些 loader 顺序变成 c.pitch/c.normal/b.normal 都被跳过,如下:

a.pitch
  b.pitch return 一个新的request
a.normal  

4.3 pitch 对于 thread-loader 的意义

结合前面的介绍,thread-loader 会把放在他后面的 loader 放到 worker pool 中并发执行。那么如何截住它后面的 loader 们呢?

正如你所料,thread loader 就是在 pitch loader 中把剩余的 loader 扔到线程池中运行;

五、总结

本篇小作文开启了一个新坑,本文主要讨论了以下几个问题:

  1. thread-loader 作用;
  2. babel 打包 thread-loader 及打包的入口文件;
  3. 复习 loader.pitch & normal 以及两者的顺序;
  4. thread-loader 利用 pitch 截取后面的 loader 扔到线程池;