Node.js 中的 Module 类,编译过程中执行字符串形式代码的方法

mini-css-extract-plugin 中有一个直接执行字符串形式的 commonjs 代码,在编译阶段获取 css-loader 产物的方法。学习了一下这个方法所做的事情。

在写编译工具的时候,我们经常需要提取文件中的特定内容来完成后续的编译。举例来说假如我们正在写一个编译插件,需要提取 .vue 文件中 data 的初始值,如果想要用 babel 之类的工具来解析文件通过 AST 来获取,会是很麻烦的一件事。

如果 .vue 文件写的非常简单,例如这种:

1
2
3
4
5
6
7
8
9
<script>
export default {
data() {
return {
someData: 'abc'
}
}
}
</script>

或许比较好说,我们只需要提取 data 函数中 return 的对象就可以了,但是只要稍微复杂一点,我们就无能为力了,例如 data 初始值中需要执行一个函数:

1
2
3
4
5
6
7
8
9
10
11
12
<script>
function getData(more) {
return 'abc' + more;
}
export default {
data() {
return {
someData: getData('d')
}
}
}
</script>

由于写法非常灵活,想要继续通过静态分析来找到内容是非常困难的了。这时候一个可能更好的方法,是我们在编译阶段直接执行一下这段 JS 文件,执行一下 data 方法,那么就可以直接拿到 data 的值了。现在问题来了,如何能在编译阶段执行一下这段代码呢?

简陋方法

之前我一直的做法是编译成 commonjs 文件:

1
2
3
4
5
6
7
8
9
10
function getData(more) {
return 'abc' + more;
}
module.exports = {
data() {
return {
someData: getData('d')
}
}
}

之后 new 一个 Function,传入假的 exports 方法,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const tmpFunc = new Function('exports', 'module', 'require', `
function getData(more) {
return 'abc' + more;
}
module.exports = {
data() {
return {
someData: getData('d')
}
}
}
`);
const fakeModule = {
exports: {}
};
tmpFunc(fakeModule.exports, fakeModule, () => {});
console.log(fakeModule.exports.data());

非常容易看出来,这种方法最大的问题是使用了假的 require 方法。这里直接传入 node 的 require 方法也是不行的,因为 require 可能写相对路径或者包名称,而我们传入的 require 方法缺少了路径信息,会找不到对应的依赖。

mini-css-extract-plugin 插件所用的方法

最近看 webpack 的 mini-css-extract-plugin 插件时,看到这个插件为了提取 css-loader 输出的样式,实现了一个 evalModuleCode 函数,这个函数的位置在这里:https://github.com/webpack-contrib/mini-css-extract-plugin/blob/master/src/loader.js#L40,内容是这样的:

1
2
3
4
5
6
7
8
9
10
import NativeModule from 'module';
function evalModuleCode(loaderContext, code, filename) {
const module = new NativeModule(filename, loaderContext);

module.paths = NativeModule._nodeModulePaths(loaderContext.context); // eslint-disable-line no-underscore-dangle
module.filename = filename;
module._compile(code, filename); // eslint-disable-line no-underscore-dangle

return module.exports;
}

使用起来大概是这样的:

1
2
3
evalModuleCode({context: __dirname}, `
exports.aaa = 123;
`, 'aaa.js')

如果执行一下,我们就可以拿到代码中输出的内容了:

Untitled.png

我们来看下这个函数如何实现的:

  • 首先引入了 module 模块。
  • 之后创建了一个 NativeModule 实例。
  • 接下来设置了模块的 pathsfilename
  • 最后执行了 _compile 方法。

这个 module 模块是一个 Node.js 提供的模块,文档地址在这里:https://nodejs.org/api/modules.html#modules_the_module_object_1,注意这个跟我们直接使用的 module.exports 并不是同一个(module.exports 中的 moduleNativeModule 的一个实例)。

但是我们如果看这个文档,会发现文档中的内容只有非常有限的几个方法,并没有提及到上面的任何操作😓。

于是我们只能看代码了。

Node.js 的 module 类

require('module') 引入的文件在 Node.js 源文件中的 lib/module.js。这个文件又 exports 出了 internal/modules/cjs/loader

Untitled.png

我们对照着 evalModuleCode 的实现,一步一步在 internal/modules/cjs/loader 文件中找对应的内容:

Untitled.png

首先是构造函数,Module 的构造函数没有做什么特殊的内容,只是设置了一些属性。其中 updateChildren,是为了设置不同 module 之前的对应关系,这里其实我们并没有用到:

Untitled.png

之后是 _nodeModulePaths 方法,这个方法有 window 和 posix 两个版本,区别只是对路径的处理方式不同,所做的工作其实就是从当前目录开始,向上遍历出所有的 node_modules 文件夹路径,用于在这些目录中查找对应的包 :

Untitled.png

例如当前我们的路径是 /home/work/code/tmp,那么解析出的 paths 路径就是这样的:

Untitled.png

再接下来是设置了 filename 属性,之后就是调用 _compile 方法了:

Untitled.png

其中重点关注上面这两句,第一句 wrapSafe 做的事情是将 JS 源代码,使用函数包装一下,参数增加 requires、module 这些,类似这样:

Untitled.png

之后在 V8 中编译执行,执行结果就得到了包装后的函数。之后执行这个方法,就可以得到最终输出的结果了。

Untitled.png

这里比较有意思的是 module 参数,可以看到传入的是 this,而 this 就是一个 Module 实例。

执行过程大概就是这样了,其实 我们直接用的 require 方法,也是在 Module 上定义的,evalModuleCode 是将 require 的过程简化了,感兴趣的同学可以自己看一下 require 方法是如何实现的:

Untitled.png