相信开发过插件的同学,都看过Writing a Plugin 或类似的文章,因为 mini-program-webpack-loader 这个工具开发时正好 webpack 4 发布了,所以就阅读了这篇文章,顺便看了以下几篇文档。
如果你看过文档,相信你一定知道:
- 每个插件必须要有 apply 方法,用于 webpack 引擎执行你想要执行的代码。
- 两个重要的对象 Compiler 和 Compilation,你可以在上面绑定事件钩子(webpack 执行到该步骤的时候调用),具体有哪些事件钩子可以阅读Compiler hooks。
- module 和 chunk 的关系,我们可以理解为每个文件都会有一个 module,而一个 chunk 则是由多个 module 来组成。
- webpack 整个打包流程有那些事件
- 如何写一个简单的 loader
如果感觉无从着手,可以继续看看我是如何一步步开发并完善 mini-program-webpack-loader 来打包小程序的。
小程序有一个固定的套路,首先需要有一个 app.json 文件来定义所有的页面路径,然后每个页面有四个文件组成:.js,.json,.wxml,.wxss。所以我以 app.json 作为 webpack entry,当 webpack 执行插件的 apply 的时候,通过获取 entry 来知道小程序都有哪些页面。大概流程像下面一张图,一个小程序打包插件差不多就这样完成了。
这里使用了两个插件 MultiEntryPlugin,SingleEntryPlugin。为什么要这样做呢?因为 webpack 会根据你的 entry 配置(这里的 entry 不只是 webpack 配置里的 entry,import(),require.ensure() 都会生成一个 entry)来决定生成文件的个数,我们不希望把所有页面的 js 打包到一个文件,需要使用 SingleEntryPlugin 来生成一个新的 entry module;而那些静态资源,我们可以使用 MultiEntryPlugin 插件来处理,把这些文件作为一个 entry module 的依赖,在 loader 中配置 file-loader 即可把静态文件输出。伪代码如下:
const MultiEntryPlugin = require('webpack/lib/MultiEntryPlugin');
const SingleEntryPlugin = 'webpack/lib/SingleEntryPlugin');
class MiniPlugin {
apply (compiler) {
let options = compiler.options
let context = compiler.rootContext
let entry = options.entry
let files = loadFiles(entry)
let scripts = files.filter(file => /\.js$/.test(file))
let assets = files.filter(file => !/\.js$/.test(file))
new MultiEntryPlugin(context,assets,'__assets__').apply(compiler)
scripts.forEach((file => {
let fileName = relative(context,file).replace(extname(file),0);">'');
new SingleEntryPlugin(context,file,fileName).apply(compiler);
})
}
}
复制代码
当然,如果像上面那样做,你会发现最后会多出一个 main.js,xxx.js(使用 MultiEntryPlugin 时填的名字),main.js 对应的是配置的 entry 生成的文件,xxx.js 则是 MultiEntryPlugin 生成的。这些文件不是我们需要的,所以需要去掉他。如果熟悉 webpack 文档,我们有很多地方可以修改最终打包出来的文件,如 compiler 的 emit 事件,compilation 的 optimizeChunks 相关的事件都可以实现。其本质上就是去修改 compilation.assets 对象。
在 mini-program-webpack-loader 中就使用了 emit 事件来处理这种不需要输出的内容。大概流程就像下面这样:
小程序打包当然没这么简单,还得支持wxml、wxss、wxs和自定义组件的引用,所以这个时候就需要一个 loader 来完成了,loader 需要做的事情也非常简单 —— 解析依赖的文件,如 .wxml 需要解析 import 组件的 src,wxs 的 src,.wxss 需要解析 @import,wxs 的 require,最后在 loader 中使用 loadModule 方法添加即可。自定义组件一开始在 add entry 步骤的时候直接获取了,所以不需要 loader 来完成。这个时候的图:
这样做也没什么问题,可是开发体验是比较差的,如再添加一个自定义组件,一个页面,webpack 是无感知的,所以需要在页面中的 .json 发生改变时检查是不是新增了自定义组件或者新增了页面。这个时候遇到一个问题,自定义组件的 js 是不能通过 addModule 的方式来添加的,因为自定义组件的 js 必须作为独立的入口文件。在 loader 中是做不了,所以尝试把文件传到 plugin 中(因为 plugin 先于 loader 执行,所以是可以建立 loader 和 plugin 通信的)。简单粗暴的方式:
// loader.js
MiniLoader {}
module.exports = function (content) {
new MiniLoader(this,content)
}
module.exports.$applyPluginInstance = function (plugin) {
MiniLoader.prototype.$plugin = plugin
}
// plugin.js
const loader = './loader')
MiniPlugin {
apply (compiler) {
loader.$applyPluginInstance(this);
}
}
复制代码
但是...。文件是传到 plugin 了,可是再使用 SingleEntryPlugin 时你会发现,没效果。因为在 compiler make 之后 webpack 已经不能感知新的 module 添加了,所以是没有用的,这个时候就需要根据文档猜,怎么样才能让 webpack 感知到新的 module,根据文档中的事件做关键字查询,可以发现在编译完成的时候会调用 compilation needAdditionalPass 事件钩子:
如果在这个事件钩子返回一个 true 值,则可以使 webpack 调用 compiler additionalPass 事件钩子,尝试在这里添加文件,果然是可以的。这个时候的图就成了这样:
当然,小程序打包还有些不同的地方,比如分包,如何用好 splitchunk,就不在啰嗦了,当你开始以后你会发现有很多的方法来实现想要的效果。
插件开发到这里差不多了,总的来说,webpack 就是变着花样的回调,当你知道每个回调该做什么的时候,webpack 用起来就轻松了。明显我不知道,因为在开发过程中遇到了一些问题。
遇到的问题
1.如何在小程序代码中支持 resolve alias,node_modules?
既然是工具,当然需要做更多的事情,有赞的小程序那么复杂,如果支持 resolve alias,node_modules 可以使得项目更方便维护,或许你会说这不是 webpack 最基本的功能吗,不是的,我们当然是希望可以在任何文件中使用 alias,node_modules 支持的不仅仅是 js。当然这样做就意味着事情将变得复杂,首先就是获取文件路径,必须是异步的,因为在 webpack 4 中 resolve 不再支持 sync。其次就是小程序的目录名不能是 node_modules,这时就需要一种计算相对路径的规则,还是相对打包输出的,而不是相对当前项目目录。
2.多个小程序项目的合并
有赞从小程序来讲,有微商城版,有零售版,以及公共版,其中大多基础功能,业务都是相同的,当然不能再每个小程序在开发一次,所以这个工具具备合并多个小程序当然是必须的。这样的合并稍微又要比从 node_modules 中取文件复杂一些,因为需要保证多个小程序合并后的页面是正确的,而且要保证路径不变。
这两个问题的最终的解决方案既是以 webpack rootContext 的 src 目录为基准目录,以该目录所在路径计算打包文件的绝对路径,然后根据入口文件的 app.json 所在目录的路径计算出最终输出路径。
exports.getdistPath = (compilerContext,entryContexts) => {
/**
* webpack 以 config 所在目录的 src 为打包入口
* 所以可以根据该目录追溯源文件地址
*/
return (path) => {
let fullPath = compilerContext
let npmReg = /node_modules/g
let pDirReg = /^[_|\.\.]\//g
if (isAbsolute(path)) {
fullPath = path
} else {
// 相对路径:webpack 最后生成的路径,打包入口外的文件都以 '_' 表示上级目录
while (pDirReg.test(path)) {
path = path.substr(pDirReg.lastIndex)
fullPath = join(fullPath,0);">'../')
}
if (fullPath !== compilerContext) {
fullPath = join(fullPath,path)
}
}
// 根据 entry 中定义的 json 文件目录获取打包后所在目录,如果不能获取就返回原路径
let contextReg = new RegExp(entryContexts.join('|'),0);">'g')
if (fullPath !== compilerContext && contextReg.exec(fullPath)) {
path = fullPath.substr(contextReg.lastIndex + 1)
console.assert(!npmReg.test(path),0);">`文件${path}路径错误:不应该还包含 node_modules`)
}
/**
* 如果有 node_modules 字符串,则去模块名称
* 如果 app.json 在 node_modules 中,那 path 不应该包含 node_modules
*/
if (npmReg.test(path)) {
path = path.substr(npmReg.lastIndex + 1)
}
return path
}
}
复制代码
3.如何把子包单独依赖的内容打包到子包内
解决这个问题的方法是通过 optimizeChunks 事件,在每个 chunk 的依赖的 module 中添加这个 chunk 的入口文件,然后在 splitChunk 的 test 配置中检查 module 被依赖的数量。如果只有一个,并且是被子包依赖,则打包到子包内。