微信小程序因为其便捷的使用方式,以极快的速度传播开来吸引了大量的使用者。市场需求急剧增加的情况下,每家互联网企业都想一尝甜头,因此掌握小程序开发这一技术无疑是一名前端开发者不可或缺的技能。但小程序开发当中总有一些不便一直让开发者诟病不已,主要表现在:
有了不少的问题之后,我开始思考如何将现代的工程化技术与小程序相结合。初期在社区中查阅资料时,许多前辈都基于gulp去做了不少实践,对于小程序这种多页应用来说gulp的流式工作方式似乎更加方便。在实际的实践过后,我不太满意应用gulp这一方案,所以我转向了对webpack的实践探索。我认为选择webpack作为工程化的支持,尽管它相对gulp更难实现,但在未来的发展中一定会有非凡的效果,
我们先不考虑预编译、规范等等较为复杂的问题,我们的第一个目标是如何应用webpack将源代码文件夹下的文件输出到目标文件夹当中,接下来我们就一步步来创建这个工程项目:
/* 创建项目 */ $ mkdir wxmp-base $ cd ./wxmp-base /* 创建package.json */ $ npm init /* 安装依赖包 */ $ npm install webpack webpack-cli --dev 复制代码
安装好依赖之后我们为这个项目创建基础的目录结构,如图所示:
上图所展示的是一个最简单的小程序,它只包含 app 全局配置文件和一个 home 页面。接下来我们不管全局或是页面,我们以文件类型划分为需要待加工的 js 类型文件和不需要再加工可以直接拷贝的 wxml 、 wxss 、 json 文件。以这样的思路我们开始编写供webpack执行的配置文件,在项目根目录下创建一个build目录存放webpack.config.js文件。
$ mkdir build $ cd ./build $ touch webpack.config.js 复制代码
/** webpack.config.js */ const path = require('path'); const CopyPlugin = require('copy-webpack-plugin'); const ABSOLUTE_PATH = process.cwd(); module.exports = { context: path.resolve(ABSOLUTE_PATH, 'src'), entry: { app: './app.js', 'pages/home/index': './pages/home/index.js' }, output: { filename: '[name].js', path: path.resolve(ABSOLUTE_PATH, 'dist') }, module: { rules: [ { test: /\.js$/, exclude: /node_modules/, use: { loader: 'babel-loader', options: { presets: ['@babel/preset-env'], plugins: ['@babel/plugin-transform-runtime'], }, }, } ] }, plugins: [ new CopyPlugin([ { from: '**/*.wxml', toType: 'dir', }, { from: '**/*.wxss', toType: 'dir', }, { from: '**/*.json', toType: 'dir', } ]) ] }; 复制代码
在编写完上述代码之后,为大家解释一下上述的代码究竟会做些什么:
我们了解完这些代码的实际作用之后就可以在终端中运行 webpack --config build/webpack.config.js 命令。webpack会将源代码编译到 dist 文件夹中,这个文件夹中的内容就可用在开发者工具中运行、预览、上传。
完成了最基础的webpack构建策略后,我们实现了 app 和 home 页面的转化,但这还远远不够。我们还需要解决许多的问题:
接下来我们针对以上几点进行webpack策略的升级:
一开始我的实现方法是写一个工具函数利用 glob 收集pages和components下的 js 文件然后生成入口对象传递给 entry 。但是在实践过程中,我发现这样的做法有两个弊端:
本着程序员应该是极度慵懒,能交给机器完成的事情绝不自己动手的信条,我开始研究新的入口生成方案。最终确定下来编写一个webpack的插件,在webpack构建的生命周期中生成入口,废话不多说上代码:
/** build/entry-extract-plugin.js */ const fs = require('fs'); const path = require('path'); const chalk = require('chalk'); const replaceExt = require('replace-ext'); const { difference } = require('lodash'); const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin'); const MultiEntryPlugin = require('webpack/lib/MultiEntryPlugin'); class EntryExtractPlugin { constructor() { this.appContext = null; this.pages = []; this.entries = []; } /** * 收集app.json文件中注册的pages和subpackages生成一个待处理数组 */ getPages() { const app = path.resolve(this.appContext, 'app.json'); const content = fs.readFileSync(app, 'utf8'); const { pages = [], subpackages = [] } = JSON.parse(content); const { length: pagesLength } = pages; if (!pagesLength) { console.log(chalk.red('ERROR in "app.json": pages字段缺失')); process.exit(); } /** 收集分包中的页面 */ const { length: subPackagesLength } = subpackages; if (subPackagesLength) { subpackages.forEach((subPackage) => { const { root, pages: subPages = [] } = subPackage; if (!root) { console.log(chalk.red('ERROR in "app.json": 分包配置中root字段缺失')); process.exit(); } const { length: subPagesLength } = subPages; if (!subPagesLength) { console.log(chalk.red(`ERROR in "app.json": 当前分包 "${root}" 中pages字段为空`)); process.exit(); } subPages.forEach((subPage) => pages.push(`${root}/${subPage}`)); }); } return pages; } /** * 以页面为起始点递归去寻找所使用的组件 * @param {String} 当前文件的上下文路径 * @param {String} 依赖路径 * @param {Array} 包含全部入口的数组 */ addDependencies(context, dependPath, entries) { /** 生成绝对路径 */ const isAbsolute = dependPath[0] === '/'; let absolutePath = ''; if (isAbsolute) { absolutePath = path.resolve(this.appContext, dependPath.slice(1)); } else { absolutePath = path.resolve(context, dependPath); } /** 生成以源代码目录为基准的相对路径 */ const relativePath = path.relative(this.appContext, absolutePath); /** 校验该路径是否合法以及是否在已有入口当中 */ const jsPath = replaceExt(absolutePath, '.js'); const isQualification = fs.existsSync(jsPath); if (!isQualification) { console.log(chalk.red(`ERROR: in "${replaceExt(relativePath, '.js')}": 当前文件缺失`)); process.exit(); } const isExistence = entries.includes((entry) => entry === absolutePath); if (!isExistence) { entries.push(relativePath); } /** 获取json文件内容 */ const jsonPath = replaceExt(absolutePath, '.json'); const isJsonExistence = fs.existsSync(jsonPath); if (!isJsonExistence) { console.log(chalk.red(`ERROR: in "${replaceExt(relativePath, '.json')}": 当前文件缺失`)); process.exit(); } try { const content = fs.readFileSync(jsonPath, 'utf8'); const { usingComponents = {} } = JSON.parse(content); const components = Object.values(usingComponents); const { length } = components; /** 当json文件中有再引用其他组件时执行递归 */ if (length) { const absoluteDir = path.dirname(absolutePath); components.forEach((component) => { this.addDependencies(absoluteDir, component, entries); }); } } catch (e) { console.log(chalk.red(`ERROR: in "${replaceExt(relativePath, '.json')}": 当前文件内容为空或书写不正确`)); process.exit(); } } /** * 将入口加入到webpack中 */ applyEntry(context, entryName, module) { if (Array.isArray(module)) { return new MultiEntryPlugin(context, module, entryName); } return new SingleEntryPlugin(context, module, entryName); } apply(compiler) { /** 设置源代码的上下文 */ const { context } = compiler.options; this.appContext = context; compiler.hooks.entryOption.tap('EntryExtractPlugin', () => { /** 生成入口依赖数组 */ this.pages = this.getPages(); this.pages.forEach((page) => void this.addDependencies(context, page, this.entries)); this.entries.forEach((entry) => { this.applyEntry(context, entry, `./${entry}`).apply(compiler); }); }); compiler.hooks.watchRun.tap('EntryExtractPlugin', () => { /** 校验页面入口是否增加 */ const pages = this.getPages(); const diffPages = difference(pages, this.pages); const { length } = diffPages; if (length) { this.pages = this.pages.concat(diffPages); const entries = []; /** 通过新增的入口页面建立依赖 */ diffPages.forEach((page) => void this.addDependencies(context, page, entries)); /** 去除与原有依赖的交集 */ const diffEntries = difference(entries, this.entries); diffEntries.forEach((entry) => { this.applyEntry(context, entry, `./${entry}`).apply(compiler); }); this.entries = this.entries.concat(diffEntries); } }); } } module.exports = EntryExtractPlugin; 复制代码
由于webpack的 plugin 相关知识不在我们这篇文章的讨论范畴,所以我只简单的介绍一下它是如何介入webpack的工作流程中并生成入口的。(如果有兴趣想了解这些可以私信我,有时间的话可能会整理一些资料出来给大家)该插件实际做了两件事:
entry entry
现在我们将这个插件应用到之前的webpack策略中,将上面的配置更改为:(记得安装 chalk replace-ext 依赖)
/** build/webpack.config.js */ const EntryExtractPlugin = require('./entry-extract-plugin'); module.exports = { ... entry: { app: './app.js' }, plugins: [ ... new EntryExtractPlugin() ] } 复制代码
样式预编译和EsLint应用其实已经有许多优秀的文章了,在这里我就只贴出我们的实践代码:
/** build/webpack.config.js */ const MiniCssExtractPlugin = require('mini-css-extract-plugin'); module.exports = { ... module: { rules: [ ... { enforce: 'pre', test: /\.js$/, exclude: /node_modules/, loader: 'eslint-loader', options: { cache: true, fix: true, }, }, { test: /\.less$/, use: [ { loader: MiniCssExtractPlugin.loader, }, { loader: 'css-loader', }, { loader: 'less-loader', }, ], }, ] }, plugins: [ ... new MiniCssExtractPlugin({ filename: '[name].wxss' }) ] } 复制代码
我们修改完策略后就可以将 wxss 后缀名的文件更改为 less 后缀名(如果你想用其他的预编译语言,可以自行修改loader),然后我们在 js 文件中加入 import './index.less' 语句就能看到样式文件正常编译生成了。样式文件能够正常的生成最大的功臣就是 mini-css-extract-plugin 工具包,它帮助我们转换了后缀名并且生成到目标目录中。
环境变量的切换我们使用 cross-env 工具包来进行配置,我们在 package.json 文件中添加两句脚本命令:
"scripts": { "dev": "cross-env OPERATING_ENV=development webpack --config build/webpack.config.js --watch", "build": "cross-env OPERATING_ENV=production webpack --config build/webpack.config.js } 复制代码
相应的我们也修改一下webpack的配置文件,将我们应用的环境也告诉webpack,这样webpack会针对环境对代码进行优化处理。
/** build/webpack.config.js */ const { OPERATING_ENV } = process.env; module.exports = { ... mode: OPERATING_ENV, devtool: OPERATING_ENV === 'production' ? 'source-map' : 'inline-source-map' } 复制代码
虽然我们也可以通过命令为webpack设置 mode ,这样也可以在项目中通过 process.env.NODE_ENV 访问环境变量,但是我还是推荐使用工具包,因为你可能会有多个环境 uat test pre 等等。
小程序对包的大小有严格的要求,单个包的大小不能超过2M,所以我们应该对JS做进一步的优化,这有利于我们控制包的大小。我所做的优化主要针对runtime和多个入口页面之间引用的公共部分,修改配置文件为:
/** build/webpack.config.js */ module.exports = { ... optimization: { splitChunks: { cacheGroups: { commons: { chunks: 'initial', name: 'commons', minSize: 0, maxSize: 0, minChunks: 2, }, }, }, runtimeChunk: { name: 'manifest', }, }, } 复制代码
webpack会将公共的部分抽离出来在 dist 文件夹根目录中生成 common.js 和 manifest.js 文件,这样整个项目的体积就会有明显的缩小,但是你会发现当我们运行命令是开发者工具里面项目其实是无法正常运行的,这是为什么?
这主要是因为这种优化使小程序其他的 js 文件丢失了对公共部分的依赖,我们对webpack配置文件做如下修改就可以解决了:
/** build/webpack.config.js */ module.exports = { ... output: { ... globalObject: 'global' }, plugins: [ new webpack.BannerPlugin({ banner: 'const commons = require("./commons");\nconst runtime = require("./runtime");', raw: true, include: 'app.js', }) ] } 复制代码
许多读者可能会有疑惑,为什么你不直接使用已有的框架进行开发,这些能力已经有许多框架支持了。选择框架确实是一个不错的选择,毕竟开箱即用为开发者带来了许多便利。但是这个选择是有利有弊的,我也对市面上的较流行框架做了一段时间的研究和实践。较为早期的腾讯的wepy、美团的mpvue,后来者居上的京东的taro、Dcloud的uni-app等,这些在应用当中我认为有以下一些点不受我青睐:
以上基本是我为什么要自己探索小程序工程化的理由(其实还有一点就是求知欲,嘻嘻)