详解Medusa 微信小程序工程化实践方案
导读
前言
4月份的时候我曾发布过《微信小程序工程化探索之webpack实战》一文,当时是我探索微信小程序工程化的第一阶段。起初我只是为了验证微信小程序与 webpack 是否能够相结合(很大程度是被对于技术的好奇心驱使),对于工程化的持续交付并没有过多的思考。但是在内部需求的不断冲击下,我开始萌生以工程化手段持续简化微信小程序开发难度的想法,最终衍生的产物就是这套以 Medusa 命名的微信小程序快速开发方案。
接下来我将较为详细的分享达成这一方案的实践过程,下文中将提到的工具我也已经发布在 npm 上供大家下载使用。这篇文章将会覆盖之前发表的那篇文章的全部内容并且内容更加丰富,所以篇幅方面也较为长请读者们耐心阅读。
webpack-build-miniprogram
webpack-build-miniprogram 是 Medusa 方案的基础也是核心,这一工具包提供了以 webpack 构建微信小程序的能力,并且我们可以利用 webpack 的生态持续丰富 Medusa 的功能。在讲述基础构建配置之前,我们先来看看 Medusa 的目录结构基础,有了相应的目录约束才使得项目更加规范化。
|-- dist 编译结果目录 |-- src 源代码目录 | |-- app.js 项目入口文件 | |-- app.json 小程序配置文件 | |-- sitemap.json sitemap配置文件 | |-- assets 静态资源存放目录 | | |-- .gitkeep | |-- components 公共组件存放目录 | | |-- .gitkeep | |-- dicts 公共字典存放目录 | | |-- .gitkeep | |-- libs 第三方工具库存放目录(外部引入) | | |-- .gitkeep | |-- pages 页面文件存放目录 | | |-- index | | |-- index.js | | |-- index.json | | |-- index.less | | |-- index.wxml | |-- scripts 公共脚本存放目录(wxs) | | |-- .gitkeep | |-- services API服务存放目录 | | |-- .gitkeep | |-- styles | | |-- index.less 项目总通用样式 | | |-- theme.less 项目主题样式 | |-- templates 公共模板存放目录 | | |-- .gitkeep | |-- utils 公共封装函数存放目录(自我封装) | |-- .gitkeep |-- .env 环境变量配置文件 |-- config.yaml 编译配置文件 |-- webpack.config.js webpack 配置扩展文件 |-- project.config.json 开发者工具配置文件 └── package.json复制代码
基础篇
webpack 这一工具现在已经成为前端工程师的必备技能,复杂的工作原理让我们对它总是有种敬畏感,所以在做微信小程序构建策略过程中,我们先将它简单的理解为一个“搬运工具”。它将源代码目录中的文件加以某些处理之后再输出到目标目录中。现在我们明确一下我们要搬运哪些文件,微信小程序中涉及到的主要有:
逻辑文件
.js
配置文件
.json
模板文件
.wxml
样式文件
.wxss
.less
.scss
脚本文件
.wxs
静态资源文件
assets/
基础搬运功能
接下来我们将书写 webpack 的公共部分配置,利用 copy-webpack-plugin 这一插件来完成大部分文件的搬运工作。
/** config/webpack.common.js */const CopyPlugin = require("copy-webpack-plugin");const config = { context: SOURCE, devtool: 'none', entry: { app: './app.js' }, output: { filename: '[name].js', path: DESTINATION }, plugins: [ new CopyPlugin([ { from: 'assets/', to: 'assets/', toType: 'dir' }, { from: '**/*.wxml', toType: 'dir' }, { from: '**/*.wxss', toType: 'dir' }, { from: '**/*.json', toType: 'dir' }, { from: '**/*.wxs', toType: 'dir' } ]) ] };复制代码
以上简单的配置我们就实现了除逻辑文件与预编译语言文件以外的搬运工作,在配置中出现了 SOURCE
、 DESTINATION
两个常量,它们分别代表的是源代码目录与目标代码目录的绝对路径,我们将它们抽离在单独的字典文件中:
/** libs/dicts.js */const path = require("path"); exports.ROOT = process.cwd(); exports.SOURCE = path.resolve(this.ROOT, 'src'); exports.DESTINATION = path.resolve(this.ROOT, 'dist'); exports.NODE_ENV = process.argv.splice(2, 1)[0];复制代码
上面搬运的文件因为不需要特殊的内容处理,所以完全交由插件去实现,剩余两种类型的文件我们就需要使用到 webpack 的入口(entry)、插件(plugin) 和 loader 协同合作才能完成搬运工作。
核心入口功能
首先我们要解决如何生成入口的问题,解决了入口生成的问题才能借助 loader 去完成文件内容的转化。对于入口生成这一问题,我开发了另外一个插件 entry-extract-webpack-plugin 去解决。这一插件我并不打算详细的讲解实现的过程,我只会阐述它的核心实现思路(如果你有兴趣进一步了解可以下载下来直接看源码)。
微信小程序需要建立入口网络其实是有规律可循的,主包、分包都会配置在 app.json 文件中,页面所需要的组件也会配置在 [page].json 文件中。抓住这一特点,我们可以将实现插件功能的核心罗列为以下几点:
通过
node.js
提供的path
与fs
模块功能,以 app.json 文件中配置的路径为基础,递归的去寻找每个 page 所依赖的 component 路径,最终整合在同个数组中。利用 webpack 提供的 SingleEntryPlugin 和 MultiEntryPlugin 插件,在 entryOption 生命周期钩子中将第一步收集的路径数组注入到构建当中形成入口(entry)。
构建监听的过程中如果有新的页面添加,则通过 watchRun 生命周期将新的入口加入到之前的入口(entry)中。
以上三点是实现生成入口这一功能的核心思路,除了核心的实现思路外,我还想简单的讲解下我们如何去写一个 webpack 插件:
class EntryExtractPlugin { constructor(options) {} apply(compiler) { compiler.hooks.entryOption.tap('EntryExtractPlugin', () => { ... }); compiler.hooks.watchRun.tap('EntryExtractPlugin', () => { ... }); } }复制代码
webpack 的插件大致是以类的形式存在,当你使用插件时,它会自动执行 apply 方法, 然后使用 compiler.hooks
对象上的各种生命周期属性便可以将我们需要的处理逻辑植入到 webpack 的构建流程当中。
逻辑与样式
上面解决了生成入口(entry)的问题,接下来我们在原有的基础上完善一下策略。由于预编译语言的类型较多,我为了策略的可扩展性将样式部分的策略抽离为单独的部件,然后在通过 webpack-merge 这一工具将它们合并起来,完整的实现如下:
/** config/webpack.parts.js */exports.loadCSS = ({ reg = /\.css$/, include, exclude, use = [] }) => ({ module: { rules: [ { include, exclude, test: reg, use: [ { loader: require('mini-css-extract-plugin').loader }, { loader: 'css-loader' } ].concat(use) } ] } });复制代码
/** config/webpack.common.js */const { merge } = require('webpack-merge');const MiniCssExtractPlugin = require('mini-css-extract-plugin');const parts = require('./webpack.parts.js');const config = { ... module: { rules: [ { test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/ } ] }, plugins: [ ... new MiniCssExtractPlugin({ filename: '[name].wxss' }) ] };module.export = merge([ config, parts.loadCSS({ reg: /\.less$/, use: ['less-loader'] }) ]);复制代码
以上就是基础的 webpack 策略,接下来我们书写一个工具包的可执行文件便可以在项目当中通过 medusa-server {mode}
使用 webpack 提供的功能了。我们需要在工具包的 package.json 文件中配置 bin 字段,它标志了我们通过命令会自动执行哪个文件。
{ ... main: "index.js", bin: { "medusa-server": "index.js" } }复制代码
/** index.js */const webpack = require('webpack');const { merge } = require('webpack-merge');const chalk = require('chalk');const commonConfig = require('./config/webpack.common');const { NODE_ENV } = require('./libs/dicts');const config = (function(mode) { if (mode === 'production') { return merge([commonConfig, { mode }]); } return merge([commonConfig, { mode: 'development', watch: true, watchOptions: { ignored: /node_modules/ } }]) })(NODE_ENV); webpack(config, (err, stats) => { if (err) { console.log(chalk.red(err.stack || err)); if (err.details) { console.log(chalk.red(err.details)); } return undefined; } const info = stats.toJson(); if (stats.hasErrors()) { console.log(chalk.red(info.errors)); } if (stats.hasWarnings()) { console.log(chalk.yellow(info.warnings)); } });复制代码
进阶篇
基础篇当中完成的 webpack 配置已经可以满足两点功能,一是对常规文件的输出,二是具备开发与生产两种不同的模式。对于基础篇我还有两点要说明一下:
为什么没有应用ES6(更改版本)转为ES5的相关插件?
因为在实践当中发现IOS的10.x版本存在async/await语法无法正常使用的情况,所以索性就让构建更加纯粹一些 ,然后启用微信开发者工具的 ES6 转 ES5 与 增强编译 这两项功能,由官方工具去处理新特性。
为什么 devtool 要设置为 none,难道不需要 sourceMap 吗?
微信官方已经原生提供了SourceMap功能,这在你上传版本时开发者工具中就已经有体现了。
接下来就进入进阶篇的梳理,在满足正常的输出后其实与原生开发好像并没有太大差异,这完全体现不出 webpack 的作用,所以我们要利用 webpack 的能力及其相关的工具生态来扩展下 Medusa 的功能。接下来我们将赋予 Medusa 以下几点功能:
路径别名 @
根据环境自动注入相应的环境域名
ESLint、StyleLint代码规范检查
路由功能
公共代码抽取
环境变量
webpack 可扩展
路径别名 @
原生微信小程序只支持相对路径的引入方式,但是我们难免会遇到必须移动某些文件的情况,假设这个文件在多处被引用,那就头疼了。所以我们通过 webpack 的能力以及搭配 jsconfig.json 配置文件可以让我们有更好的开发体验。
/** config/webpack.common.js */const config = { ... resolve: { alias: { '@': SOURCE } } };复制代码
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"], }, "target": "ES6", "module": "commonjs", "allowSyntheticDefaultImports": true, }, "include": ["src/**/*"], "exclude": ["node_modules"], }复制代码
通过 alias 别名配置,@ 符号就可以指代 src 源代码目录,然后在实际业务项目的根目录下创建一个 jsconfig.json 文件,通过以上的配置在使用 vscode 编辑器时就可以获得良好的智能路径提示功能。
自动注入相应的环境域名
在介绍这个功能之前,我需要先讲解一下 config.yaml 这个配置文件的作用。这是我一开始设想用来扩展各种功能的配置文件,它应用在实际业务项目的根目录中。现阶段它的功能还相当少,不过将来应该会逐步迭代增加的。
# 当前项目应用平台platform: wx# 样式单位 px 转换 rpx 的比例设定css_unit_ratio: 1# 域名配置development_host: api: https://www.miniprogram.dev.comproduction_host: api: https://www.miniprogram.pro.com复制代码
这其中的域名配置是可以自由增减的,书写形式形如例子当中所示。当我们在其中配置好对应环境的域名后,我们在业务代码当中便可以用 mc.$hosts.api
变量轻松的访问域名。在构建过程中工具会自动帮你将对应环境的域名注入代码当中,你再也不用关心如何去管理和切换不同环境域名的问题了。下面展示一下我是如何实现这一功能的:
/** libs/dicts */exports.CONFIG = path.resolve(this.ROOT, 'config.yaml'); exports.DEFAULT_CONFIG = { platform: 'wx', css_unit_ratio: 1, };复制代码
/** libs/index.js */const fs = require('fs');const yaml = require('js-yaml');const { CONFIG, DEFAULT_CONFIG } = require('./dicts'); exports.yamlConfig = (function() { try { /** 将 yaml 格式的内容解析为 object 对象 */ const config = yaml.load(fs.readFileSync(CONFIG, { encoding: 'utf-8' })); return config; } catch(e) { return DEFAULT_CONFIG; } })();复制代码
/** webpack.common.js */const webpack = require('webpack');const { yamlConfig } = require('../libs');const { NODE_ENV } = require('../libs/dicts');const config = { ... plugins: [ ... new webpack.DefinePlugin({ mc: JSON.stringify({ $hosts: yamlConfig[`${NODE_ENV}_host`] || {} }) }) ] };复制代码
webpack.DefinePlugin
插件可以判别代码中 mc 这个标识符然后进行相应的替换功能,这样就能保证代码的正常运作。
ESLint 与 StyleLint
编码规范已经是老生常谈的话题了,今年我所处的前端团队扩容到二十几人,在多人共同协作和不同成员维护的情况下,我们迫切的需要统一的编码规范来减少维护的成本。(在规范落地上,我还有两句题外话要说,当我们需要落地一项规范的时候,不要停留在讨论规则上,也不要妄想用人的自律去约束编码。如果你的团队也需要实施这些东西,希望你能够成为先驱者将规则先定下初版,然后找一个合适的工具,通过工具去约束人的行为。)接下来我们就来看下我们所需要的检查工具是如何配置的:
/** webpack.common.js */const StylelintPlugin = require('stylelint-webpack-plugin');const config = { module: { rules: [ { enforce: 'pre', test: /\.js$/, exclude: /node_modules/, loader: 'eslint-loader', options: { fix: true, cache: false, formatter: require('eslint-friendly-formatter') } } ] }, plugins: [ new StylelintPlugin({ fix: true, files: '**/*.(sa|sc|le|wx|c)ss' }) ] };复制代码
以上配置的两个工具可以对 js 与样式文件按照一定的规则集进行检查,对于部分不符合规范的代码可以自行修复,无法自动修复的部分将会给出相应的提示。应用什么规则集你可以通过业务项目根目录中的 .eslintrc
和 .stylelintrc
文件去决定(下面的章节我会对规范这一块再进行相应的展开)。
公共代码抽取
在应用 webpack 之后,我们为微信小程序开发赋予了 npm 加载依赖的能力。在模块化打包的机制下,工具包会被加载进引用它的入口当中,这会导致微信小程序包大小及其容易就超出限定值了,所以我们的解决方案是将多次出现的代码抽离为单独的公共部分,具体的实施代码如下:
/** config/webpack.common.js */const config = { ... output: { ..., globalObject: 'global' }, plugins: [ ... new webpack.BannerPlugin({ raw: true, include: 'app.js', banner: 'const vendors = require("./vendors");\nconst commons = require("./commons");\nconst manifest = require("./manifest");' }) ], optimization: { cacheGroups: { vendors: { chunks: 'initial', name: 'vendors', test: /[\\/]node_modules[\\/]/, minChunks: 3, priority: 20 }, commons: { chunks: 'initial', name: 'commons', test: /[\\/](utils|libs|services|apis|models|actions|layouts)[\\/]/, minChunks: 3, priority: 10 } }, runtimeChunk: { name: 'manifest' } } };复制代码
微信小程序的全局变量可以通过 global 访问,所以我们需要将 output.globalObject
属性设置为 global。webpack 内置的 BannerPlugin 插件可以将我们需要的语句插入在指定的文件的头部,利用它我们就做到了将抽离出来的公共文件重新引入到依赖网格中。
环境变量
我们在使用 Vue 、React 的脚手架创建的项目时会见到 .env
这个文件,它的主要作用是扩展开发者所需的环境变量。在微信小程序扩展环境变量变量这样的需求可能少之又少,但是我们可以换一种思路,我们可以利用它来扩展开发者所需的全局变量。接下来我们将利用 dotenv-webpack 这一工具来实现这个功能,它可以读取业务项目根目录下的 .env
文件内容,使得我们在编码中也可以使用当中的变量值。
/** config/webpack.common.js */const Dotenv = require('dotenv-webpack');const { ENV_CONFIG } = require('../libs/dicts');const config = { ... plugins: [ ... new Dotenv({ path: ENV_CONFIG }) ] };复制代码
webpack 扩展
通过 webpack 我已经实现了通用的许多功能,但是我所处的公司开发的微信小程序颇多,所以难免有一些项目需要个性化的定制策略。既然有这样的需求,我们就应该提供这样的能力给到业务开发者,一来我们可以从多个项目当中吸收更多需要集成的功能点,二来可以暂时减轻自身的负担。webpack-merge 这一工具其实在前面样式部分合并我已经使用过了,我们只需要提供业务项目 webpack 配置的路径即可,再修改下之前的执行文件。
/** index.js */const { WEBPACK_CONFIG } = require('./libs/dicts');const config = (function(mode) { if (mode === 'production') { return merge([commonConfig, { mode }, WEBPACK_CONFIG]); } return merge([commonConfig, { mode: 'development', watch: true, watchOptions: { ignored: /node_modules/ } }, WEBPACK_CONFIG]) })(NODE_ENV); ...复制代码
路由功能
微信官方提供了关于路由跳转的 API ,但我认为官方的 API 在日常开发中有几点不便:
需要输入完整的路径字符串,路径太长难以记忆不说,假如页面路径有所修改需要投入较高的维护成本。
跳转方式多样,四种不同类型的跳转 API 较为常用的是 navigateTo,因为 API 有多个并且参数也较多,所以使用时难免都省不了再去查阅一遍文档。
官方提供的任何一种 API 最终目标页面中接收到的 query 都是字符串类型,这一定程度上限制了我们的编码设计。
为了解决上述的三个问题,我将 API 进行了二次封装,从中抹除四种跳转类型的差异,通过统一的接口就可以达到四种跳转方法的效果。并且通过 webpack 的全局变量注入功能优化了路径字符串的获取,方便使用并且容易维护。
mc.$routes
我将页面的文件夹名称与路径相关联,两者形成映射关系的话,我们只需要书写文件夹名称便可。原来我们需要使用 pages/home/index
访问 home 页面,通过我的改造之后,我们可以通过 mc.$routes.home
访问 home 页面。
medusa-wx-router
medusa-wx-router 是我对路由功能进行二次封装后的工具包,具体的实现过程在这里我就不详述了,你可以自行下载使用或是依照你的需求进行再次改造,下面我只展示一下在业务代码中结合 mc.$routes
如何使用:
mc.routerTo({ url: mc.$routes.home, type: 'push', query: { id: 0, bool: true }, success: () => console.log('successfully') });/** push 方式快捷形式 */mc.routerTo(mc.$routes.home, { id: 0, bool: true });复制代码
为了省去 import 路由工具包这一步骤,我使用了 webpack.ProvidePlugin 这一插件自动帮我们在有使用的地方补充 import 功能。
/** config/webpack.common.js */const config = { ... plugins: [ ... new webpack.ProvidePlugin({ 'mc.routerTo': ['medusa-wx-router', 'routerTo'], 'mc.decoding': ['medusa-wx-router', 'decoding'], 'mc.back': ['medusa-wx-router', 'back'], 'mc.goHome': ['medusa-wx-router', 'goHome'], }) ] };复制代码
规范
规范是工程的重要一环,一个团队必须遵照同一套规则进行编码,规范的存在使得代码的质量得以提升,有统一的规范认知使得成员互相交接项目更加轻松高效。在前面的实践当中,我已经将规范的检查工具集成在了构建流程当中,本节我将补充一下应用于微信小程序的规则集配置和相关编辑器插件,当然我还希望你阅读一下我对于命名规范的一些总结希望对你有所启发《你可能需要的统一命名规范》。
规则集其实就是一个包含规则的配置文件,接下来我会给出具体的配置内容。当然在考虑到规则集的团队定制性和升级的问题,我将 ESLint 和 StyleLint 的规则都制作成了 npm 包,这就解决了所有业务项目统一规则的问题。对应的 npm 包分别是 eslint-config-medusa 和 stylelint-config-medusa ,这是我所处的团队所需要的,所以对于你们在实践时可以结合你们团队的现有情况进行改造。
# EditorConfig is awesome: https://EditorConfig.org # top-most EditorConfig file root = true [*] charset = utf-8 indent_style = space indent_size = 2 end_of_line = lf insert_final_newline = true [*.md] trim_trailing_whitespace = false复制代码
在业务项目的根目录中创建 .editorconfig 文件配合 vscode 的 EditorConfig for VS Code 插件便可以对常规的文件进行基础的编码约束。
module.exports = { parserOptions: { ecmaVersion: "2018", sourceType: "module" }, parser: "babel-eslint", env: { node: true, commonjs: true, es6: true }, globals: { mc: false, wx: true, swan: true, App: true, Page: true, Component: true, Behavior: true, getApp: true, getCurrentPages: true }, extends: ["airbnb-base"], rules: { // disallow use of console "no-console": "warn", // disallow use of debugger "no-debugger": "error", // disallow dangling underscores in identifiers 'no-underscore-dangle': 'off', // specify the maximum length of a line in your program "max-len": ["error", 120], // require return statements to either always or never specify values "consistent-return": ["error", { "treatUndefinedAsUnspecified": true }], // disallow usage of expressions in statement position "no-unused-expressions": "off" }, settings: { "import/resolver": { alias: { map: [ ["@", "./src"], ["utils", "./src/utils"], ["services", "./src/services"] ], extensions: [".js", ".json", ".less"] } } } };复制代码
module.exports = { rules: { // Disallow unknown type selectors 'selector-type-no-unknown': null, // Disallow unknown units 'unit-no-unknown': null }, extends: [ 'stylelint-config-standard', 'stylelint-config-recess-order' ], plugins: ['stylelint-order'] };复制代码
上面展示的就是我所完成的两个规则集依赖包的入口文件,当我们 install 依赖包之后,我们就可以在业务项目的 .eslintrc 和 .stylelintrc 文件中通过 extends 字段将它们引入。搭配 vscode 编辑器的 ESLint 和 stylelint-plus 插件,它们可以在编码过程中就提醒你相应的错误规则。
脚手架
前面说的构建与规范全都是服务于具体的业务项目的,在拥有了基础的能力之后,我们就该思考如何使得业务开发人员能够快速并且符合要求的进行业务系统开发。让他们不再需要考虑目录应该怎么约定,工具如何集成,编码规范究竟如何应用诸如这些问题。为了达成快速开发这一要求,我着手制作具备初始化项目这一简易功能的脚手架。最终我将这一工具分为两个项目,其一是具备初始化项目结构、下载相关依赖包功能的 @chirono/medusa-cli,另外是约定好项目结构与必要配置文件的 miniprogram-base 。
业务开发者只需要通过 npm 全局安装 @chirono/medusa-cli 工具,便可以通过 medusa create <project-name>
命令初始化一个工程项目,该工具还会提示必要的项目信息让开发者输入,用于完善业务项目的 package.json 文件。
结语
以上是我对 Medusa 这一工程的总结,现在这一工程已经进入较为稳定的阶段也已经顺利投入到多个实际项目当中。对于上述的总结可能有某些部分写得相对简略,如果你却有兴趣我建议你直接下载源码去研究,因为编码的实际操作确实无法用三言两语解释清楚。我写下这一篇文章主要想表达的是工程链路的完整性能够为实际的项目开发产生相当大的效益,而且实践工程当中不仅是单一工具的开发,要将工具串起来。接下来我可能还会对UI打包、TS编译、测试工具这些内容进行知识总结。最后,感谢你的阅读,如果你有疑问或是建议可以留言与我共同探讨。