小程序打包插件开发体验

相信开发过插件的同学,都看过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 被依赖的数量。如果只有一个,并且是被子包依赖,则打包到子包内。

4.webpack 支持文件失败

这是一个解决的问题,当尝试使用 webpack 来支持文件的时候,好像没那么方便:

相关文章

开发微信小程序的用户授权登录功能
小程序开发页面如何实现跳转?
浅谈小程序开发中蓝牙连接错误分析及解决方法
什么是小程序?它有哪些功能?
如何配置小程序开发项目结构?(教程)
怎么把自己的店加入小程序