本文基于 webpack 4 和 babel 7,Mac OS,VS Code
小程序开发现状:
小程序开发者工具不好用,官方对 npm 的支持有限,缺少对 webpack, babel 等前端常用工具链的支持。
多端框架(Mpvue, Taro)崛起,但限制了原生小程序的能力。
我司在使用一段时间多端开发框架后,决定回退到原生方案,除了多端框架对原生能力有所限制外,最重要的是,我们只需要一个微信小程序,并不需要跨端。
程序虽小,但需要长期维护,多人维护,因此规范的工程化实践就很重要了。本系列文章分上下两篇,上篇主要讲 webpack, babel, 环境配置,下篇主要讲 Typescript, EsLint, 单元测试,CI / CD。
通过本文,你将学会使用如何使用前端工程技术来开发原生小程序:
webpack 基础配置以及高级配置
webpack 构建流程,这是编写 webpack 插件的基础
编写 webpack 插件,核心源码不到 50 行,使得小程序开发支持 npm
为你讲述 webpack 插件中关键代码的作用,而不仅仅是提供源码
优化 webpack 配置,剔除不必要的代码,减少包体积
支持 sass 等 css 预处理器
微信小程序官方对 npm 的支持程度
支持 npm 是小程序工程化的前提,微信官方声称支持 npm,但实际操作令人窒息。
这也是作者为什么要花大力气学习如何编写 webpack 插件,使得小程序可以像 Web 应用那样支持 npm 的缘故。不得不说,这也是一个学习编写 webpack 插件的契机。
先让我们来吐槽官方对 npm 的支持。
打开微信开发者工具 - 项目 - 新建项目,使用测试号创建一个小程序项目
通过终端,初始化 npm
npm init --yes复制代码可以看到,我们的项目根目录下,生成了一个 package.json 文件
现在让我们通过 npm 引入一些依赖,首先是大名鼎鼎的 moment 和 lodash
npm i moment lodash复制代码点击微信开发者工具中的菜单栏:工具 - 构建 npm
可以看到,在我们项目的根目录下,生成了一个叫 miniprogram_npm 的目录
修改 app.js,添加如下内容
// app.js+ import moment from 'moment';App({ onLaunch: function () {+ console.log('-----------------------------------------------x');+ let sFromNowText = moment(new Date().getTime() - 360000).fromNow();+ console.log(sFromNowText); }})复制代码并保存,可以看到微信开发者工具控制台输出:
再来测试下 lodash,修改 app.js,添加如下内容
// app.js+ import { camelCase } from 'lodash';App({ onLaunch: function () {+ console.log(camelCase('OnLaunch')); }})复制代码保存,然后出错了
然后作者又尝试了 rxjs 这个库,也同样失败了。查阅了一些资料,说是要把 rxjs 的源码 clone 下来编译,并将编译结果复制到 miniprogram_npm 这个文件夹。尝试了下,确实可行。但这种使用 npm 的方式也实在是太奇葩了吧,太反人类了,不是我们熟悉的味道。
在持续查阅了一些资料和尝试后,发现使用 webpack 来和 npm 搭配才是王道。
创建 webpack 化的小程序项目
先把 app.js 中新增的代码移除
// app.js- import moment from 'moment';- import { camelCase } from 'lodash';App({ onLaunch: function () {- console.log('-----------------------------------------------x');- let sFromNowText = moment(new Date().getTime() - 360000).fromNow();- console.log(sFromNowText);- console.log(camelCase('OnLaunch')); }})复制代码删掉 miniprogram_npm 这个文件夹,这真是个异类。
新建文件夹 src,把 pages, utils, app.js, app.json, app.wxss, sitemap.json 这几个文件(夹)移动进去
安装 webpack 和 webpack-cli
npm i --save-dev webpack webpack-cli copy-webpack-plugin clean-webpack-plugin复制代码在根目录下,新建 webpack.config.js 文件,添加如下内容
const { resolve } = require('path')const CopyWebpackPlugin = require('copy-webpack-plugin')const { CleanWebpackPlugin } = require('clean-webpack-plugin')module.exports = { context: resolve('src'), entry: './app.js', output: { path: resolve('dist'), filename: '[name].js', }, plugins: [ new CleanWebpackPlugin({ cleanStaleWebpackAssets: false, }), new CopyWebpackPlugin([ { from: '**/*', to: './', }, ]), ], mode: 'none',}复制代码修改 project.config.json 文件,指明小程序的入口
// project.config.json{"description": "项目配置文件",+ "miniprogramRoot": "dist/",}复制代码在终端输入 npx webpack。
可以看到,在小程序开发者工具的模拟器中,我们的小程序刷新了,而且控制台也没有错误提示。
在我们项目的根目录中,生成了一个叫 dist 的文件夹,里面的内容和 src 中一模一样,除了多了个 main.js 文件。
对 webpack 有所了解的同学都知道,这是 webpack 化项目的经典结构
如果你对 webpack 从不了解,那么此时你应该去阅读以下文档,直到你弄明白为什么会多了个 main.js 文件。
起步
入口起点(entry points)
入口和上下文(entry and context)
输出(output)
loader
插件(plugin)
在上面的例子中,我们只是简单地将 src 中的文件原封不动地复制到 dist 中,并且让微信开发者工具感知到,dist 中才是我们要发布的代码。
这是重要的一步,因为我们搭建了一个 webpack 化的小程序项目。
我们使用 npm,主要是为了解决 js 代码的依赖问题,那么 js 交给 webpack 来处理,其它文件诸如 .json, .wxml, .wxss 直接复制就好了,这么想,事情就会简单很多。
如果你对 webpack 已有基本了解,那么此时,你应该理解小程序是个多页面应用程序,它有多个入口。
下面,让我们修改 webpack.config.js 来配置入口
- entry: './app.js'+ entry: {+ 'app' : './app.js',+ 'pages/index/index': './pages/index/index.js',+ 'pages/logs/logs' : './pages/logs/logs.js'+ },复制代码webpack 需要借助 babel 来处理 js,因此 babel 登场。
npm i @babel/core @babel/preset-env babel-loader --save-dev复制代码在根目录创建 .babelrc 文件,添加如下内容
// .babelrc{ "presets": ["@babel/env"]}复制代码修改 webpack.config.js,使用 babel-loader 来处理 js 文件
module.exports = {+ module: {+ rules: [+ {+ test: /.js$/,+ use: 'babel-loader'+ }+ ]+ },}复制代码从 src 复制文件到 dist 时,排除 js 文件
new CopyWebpackPlugin([ { from: '**/*', to: './',+ ignore: ['**/*.js'] }])复制代码此时,webpack.config.js 文件看起来是这样的:
const { resolve } = require('path')const CopyWebpackPlugin = require('copy-webpack-plugin')const { CleanWebpackPlugin } = require('clean-webpack-plugin')module.exports = { context: resolve('src'), entry: { app: './app.js', 'pages/index/index': './pages/index/index.js', 'pages/logs/logs': './pages/logs/logs.js', }, output: { path: resolve('dist'), filename: '[name].js', }, module: { rules: [ { test: /.js$/, use: 'babel-loader', }, ], }, plugins: [ new CleanWebpackPlugin({ cleanStaleWebpackAssets: false, }), new CopyWebpackPlugin([ { from: '**/*', to: './', ignore: ['**/*.js'], }, ]), ], mode: 'none',}复制代码执行 npx webpack
可以看到,在 dist 文件夹中,main.js 不见了,同时消失的还有 utils 整个文件夹,因为 utils/util.js 已经被合并到依赖它的 pages/logs/logs.js 文件中去了。
为什么 main.js 会不见了呢?
可以看到,在小程序开发者工具的模拟器中,我们的小程序刷新了,而且控制台也没有错误提示。
把下面代码添加回 app.js 文件,看看效果如何?
// app.js+ import moment from 'moment';+ import { camelCase } from 'lodash';App({ onLaunch: function () {+ console.log('-----------------------------------------------x');+ let sFromNowText = moment(new Date().getTime() - 360000).fromNow();+ console.log(sFromNowText);+ console.log(camelCase('OnLaunch')); }})复制代码可以看到,不管是 moment 还是 lodash, 都能正常工作。
这是重要的里程碑的一步,因为我们终于能够正常地使用 npm 了。
而此时,我们还没有开始写 webpack 插件。
如果你有留意,在执行 npx webpack 命令时,终端会输出以下信息
生成的 app.js 文件居然有 1M 那么大,要知道,小程序有 2M 的大小限制,这个不用担心,稍后我们通过 webpack 配置来优化它。
而现在,我们开始写 webpack 插件。
第一个 webpack 插件
前面,我们通过以下方式来配置小程序的入口,
entry: { 'app': './app.js', 'pages/index/index': './pages/index/index.js', 'pages/logs/logs': './pages/logs/logs.js',},复制代码这实在是太丑陋啦,这意味着每写一个 page 或 component,就得配置一次,我们写个 webpack 插件来处理这件事情。
首先安装一个可以替换文件扩展名的依赖
npm i --save-dev replace-ext复制代码在项目根目录中创建一个叫 plugin 的文件夹,在里面创建一个叫 MinaWebpackPlugin.js 的文件,内容如下:
// plugin/MinaWebpackPlugin.jsconst SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin')const MultiEntryPlugin = require('webpack/lib/MultiEntryPlugin')const path = require('path')const fs = require('fs')const replaceExt = require('replace-ext')function itemToPlugin(context, item, name) { if (Array.isArray(item)) { return new MultiEntryPlugin(context, item, name) } return new SingleEntryPlugin(context, item, name)}function _inflateEntries(entries = [], dirname, entry) { const configFile = replaceExt(entry, '.json') const content = fs.readFileSync(configFile, 'utf8') const config = JSON.parse(content) ;['pages', 'usingComponents'].forEach(key = { const items = config[key] if (typeof items === 'object') { Object.values(items).forEach(item = inflateEntries(entries, dirname, item)) } })}function inflateEntries(entries, dirname, entry) { entry = path.resolve(dirname, entry) if (entry != null && !entries.includes(entry)) { entries.push(entry) _inflateEntries(entries, path.dirname(entry), entry) }}class MinaWebpackPlugin { constructor() { this.entries = [] } // apply 是每一个插件的入口 apply(compiler) { const { context, entry } = compiler.options // 找到所有的入口文件,存放在 entries 里面 inflateEntries(this.entries, context, entry) // 这里订阅了 compiler 的 entryOption 事件,当事件发生时,就会执行回调里的代码 compiler.hooks.entryOption.tap('MinaWebpackPlugin', () = { this.entries // 将文件的扩展名替换成 js .map(item = replaceExt(item, '.js')) // 把绝对路径转换成相对于 context 的路径 .map(item = path.relative(context, item)) // 应用每一个入口文件,就像手动配置的那样 // 'app' : './app.js', // 'pages/index/index': './pages/index/index.js', // 'pages/logs/logs' : './pages/logs/logs.js', .forEach(item = itemToPlugin(context, './' + item, replaceExt(item, '')).apply(compiler)) // 返回 true 告诉 webpack 内置插件就不要处理入口文件了,因为这里已经处理了 return true }) }}module.exports = MinaWebpackPlugin复制代码该插件所做的事情,和我们手动配置 entry 所做的一模一样,通过代码分析 .json 文件,找到所有可能的入口文件,添加到 webpack。
修改 webpack.config.js,应用该插件
+ const MinaWebpackPlugin = require('./plugin/MinaWebpackPlugin');module.exports = { context: resolve('src'),- entry: {- 'app' : './app.js',- 'pages/index/index': './pages/index/index.js',- 'pages/logs/logs' : './pages/logs/logs.js'- },+ entry: './app.js', plugins: [+ new MinaWebpackPlugin() ], mode: 'none'};复制代码执行 npx webpack,顺利通过!
上面的插件代码是否读得不太懂?因为我们还没有了解 webpack 的工作流程。
webpack 构建流程
编程就是处理输入和输出的技术,webpack 好比一台机器,entry 就是原材料,经过若干道工序(plugin, loader),产生若干中间产物 (dependency, module, chunk, assets),最终将产品放到 dist 文件夹中。
在讲解 webpack 工作流程之前,请先阅读官方编写一个插件指南,对一个插件的构成,事件钩子有哪些类型,如何触及(订阅),如何调用(发布),有一个感性的认识。
我们在讲解 webpack 流程时,对理解我们将要编写的小程序 webpack 插件有帮助的地方会详情讲解,其它地方会简略,如果希望对 webpack 流程有比较深刻的理解,还需要阅读其它资料以及源码。
初始化阶段
当我们执行 npx webpack 这样的命令时,webpack 会解析 webpack.config.js 文件,以及命令行参数,将其中的配置和参数合成一个 options 对象,然后调用 webpack 函数
// webpack.jsconst webpack = (options, callback) = { let compiler // 补全默认配置 options = new WebpackOptionsDefaulter().process(options) // 创建 compiler 对象 compiler = new Compiler(options.context) compiler.options = options // 应用用户通过 webpack.config.js 配置或命令行参数传递的插件 if (options.plugins && Array.isArray(options.plugins)) { for (const plugin of options.plugins) { plugin.apply(compiler) } } // 根据配置,应用 webpack 内置插件 compiler.options = new WebpackOptionsApply().process(options, compiler) // compiler 启动 compiler.run(callback) return compiler}复制代码在这个函数中,创建了 compiler 对象,并将完整的配置参数 options 保存到 compiler 对象中,最后调用了 compiler 的 run 方法。
compiler 对象代表了完整的 webpack 环境配置。这个对象在启动 webpack 时被一次性建立,并配置好所有可操作的设置,包括 options,loader 和 plugin。可以使用 compiler 来访问 webpack 的主环境。
从以上源码可以看到,用户配置的 plugin 先于内置的 plugin 被应用。
WebpackOptionsApply.process 注册了相当多的内置插件,其中有一个:
// WebpackOptionsApply.jsclass WebpackOptionsApply extends OptionsApply { process(options, compiler) { new EntryOptionPlugin().apply(compiler) compiler.hooks.entryOption.call(options.context, options.entry) }}复制代码WebpackOptionsApply 应用了 EntryOptionPlugin 插件并立即触发了 compiler 的 entryOption 事件钩子,
而 EntryOptionPlugin 内部则注册了对 entryOption 事件钩子的监听。
entryOption 是个 SyncBailHook, 意味着只要有一个插件返回了 true, 注册在这个钩子上的后续插件代码,将不会被调用。我们在编写小程序插件时,用到了这个特性。
// EntryOptionPlugin.jsconst itemToPlugin = (context, item, name) = { if (Array.isArray(item)) { return new MultiEntryPlugin(context, item, name) } return new SingleEntryPlugin(context, item, name)}module.exports = class EntryOptionPlugin { apply(compiler) { compiler.hooks.entryOption.tap('EntryOptionPlugin', (context, entry) = { if (typeof entry === 'string' || Array.isArray(entry)) { // 如果没有指定入口的名字,那么默认为 main itemToPlugin(context, entry, 'main').apply(compiler) } else if (typeof entry === 'object') { for (const name of Object.keys(entry)) { itemToPlugin(context, entry[name], name).apply(compiler) } } else if (typeof entry === 'function') { new DynamicEntryPlugin(context, entry).apply(compiler) } // 注意这里返回了 true, return true }) }}复制代码EntryOptionPlugin 中的代码非常简单,它主要是根据 entry 的类型,把工作委托给 SingleEntryPlugin, MultiEntryPlugin 以及 DynamicEntryPlugin。
这三个插件的代码也并不复杂,逻辑大致相同,最终目的都是调用 compilation.addEntry,让我们来看看 SingleEntryPlugin 的源码
// SingleEntryPlugin.jsclass SingleEntryPlugin { constructor(context, entry, name) { this.context = context this.entry = entry this.name = name } apply(compiler) { // compiler 在 run 方法中调用了 compile 方法,在该方法中创建了 compilation 对象 compiler.hooks.compilation.tap('SingleEntryPlugin', (compilation, { normalModuleFactory }) = { // 设置 dependency 和 module 工厂之间的映射关系 compilation.dependencyFactories.set(SingleEntryDependency, normalModuleFactory) }) // compiler 创建 compilation 对象后,触发 make 事件 compiler.hooks.make.tapAsync('SingleEntryPlugin', (compilation, callback) = { const { entry, name, context } = this // 根据入口文件和名称创建 Dependency 对象 const dep = SingleEntryPlugin.createDependency(entry, name) // 随着这个方法被调用,将会开启编译流程 compilation.addEntry(context, dep, name, callback) }) } static createDependency(entry, name) { const dep = new SingleEntryDependency(entry) dep.loc = { name } return dep }}复制代码那么 make 事件又是如何被触发的呢?当 WebpackOptionsApply.process 执行完后,将会调用 compiler 的 run 方法,而 run 方法又调用了 compile 方法,在里面触发了 make 事件钩子,如下面代码所示:
// webpack.jsconst webpack = (options, callback) = { // 根据配置,应用 webpack 内置插件,其中包括 EntryOptionPlugin,并触发了 compiler 的 entryOption 事件 // EntryOptionPlugin 监听了这一事件,并应用了 SingleEntryPlugin // SingleEntryPlugin 监听了 compiler 的 make 事件,调用 compilation 对象的 addEntry 方法开始编译流程 compiler.options = new WebpackOptionsApply().process(options, compiler) // 这个方法调用了 compile 方法,而 compile 触发了 make 这个事件,控制权转移到 compilation compiler.run(callback)}复制代码// Compiler.jsclass Compiler extends Tapable { run(callback) { const onCompiled = (err, compilation) = { // ... } // 调用 compile 方法 this.compile(onCompiled) } compile(callback) { const params = this.newCompilationParams() this.hooks.compile.call(params) // 创建 compilation 对象 const compilation = this.newCompilation(params) // 触发 make 事件钩子,控制权转移到 compilation,开始编译流程 this.hooks.make.callAsync(compilation, err = { // ... }) } newCompilation(params) { const compilation = this.createCompilation() // compilation 对象创建后,触发 compilation 事件钩子 // 如果想要监听 compilation 中的事件,这是个好时机 this.hooks.compilation.call(compilation, params) return compilation }}复制代码webpack 函数创建了 compiler 对象,而 compiler 对象创建了 compilation 对象。compiler 对象代表了完整的 webpack 环境配置,而 compilatoin 对象则负责整个打包过程,它存储着打包过程的中间产物。compiler 对象触发 make 事件后,控制权就会转移到 compilation,compilation 通过调用 addEntry 方法,开始了编译与构建主流程。
现在,我们有足够的知识理解之前编写的 webpack 插件了
// MinaWebpackPlugin.jsclass MinaWebpackPlugin { constructor() { this.entries = [] } apply(compiler) { const { context, entry } = compiler.options inflateEntries(this.entries, context, entry) // 和 EntryOptionPlugin 一样,监听 entryOption 事件 // 这个事件在 WebpackOptionsApply 中触发 compiler.hooks.entryOption.tap(pluginName, () = { this.entries .map(item = replaceExt(item, '.js')) .map(item = path.relative(context, item)) // 和 EntryOptionPlugin 一样,为每一个 entry 应用 SingleEntryPlugin .forEach(item = itemToPlugin(context, './' + item, replaceExt(item, '')).apply(compiler)) // 和 EntryOptionPlugin 一样,返回 true。由于 entryOption 是个 SyncBailHook, // 而自定义的插件先于内置的插件执行,所以 EntryOptionPlugin 这个回调中的代码不会再执行。 return true }) }}复制代码为了动态注册入口,除了可以监听 entryOption 这个钩子外,我们还可以监听 make 这个钩子来达到同样的目的。
module 构建阶段
addEntry 中调用了私有方法 _addModuleChain ,这个方法主要做了两件事情。一是根据模块的类型获取对应的模块工厂并创建模块,二是构建模块。
这个阶段,主要是 loader 的舞台。
class Compilation extends Tapable { // 如果有留意 SingleEntryPlugin 的源码,应该知道这里的 entry 不是字符串,而是 Dependency 对象 addEntry(context, entry, name, callback) { this._addModuleChain(context, entry, onModule, callbak) } _addModuleChain(context, dependency, onModule, callback) { const Dep = dependency.constructor // 获取模块对应的工厂,这个映射关系在 SingleEntryPlugin 中有设置 const moduleFactory = this.dependencyFactories.get(Dep) // 通过工厂创建模块 moduleFactory.create(/* 在这个方法的回调中, 调用 this.buildModule 来构建模块。 构建完成后,调用 this.processModuleDependencies 来处理模块的依赖 这是一个循环和递归过程,通过依赖获得它对应的模块工厂来构建子模块,直到把所有的子模块都构建完成 */) } buildModule(module, optional, origin, dependencies, thisCallback) { // 构建模块 module.build(/* 而构建模块作为最耗时的一步,又可细化为三步: 1. 调用各 loader 处理模块之间的依赖 2. 解析经 loader 处理后的源文件生成抽象语法树 AST 3. 遍历 AST,获取 module 的依赖,结果会存放在 module 的 dependencies 属性中 */) }}复制代码chunk 生成阶段
在所有的模块构建完成后,webpack 调用 compilation.seal 方法,开始生成 chunks。
// https://github.com/webpack/webpack/blob/master/lib/Compiler.js#L625class Compiler extends Tapable { compile(callback) { const params = this.newCompilationParams() this.hooks.compile.call(params) // 创建 compilation 对象













