微信小程序因?yàn)槠浔憬莸氖褂梅绞剑詷O快的速度傳播開(kāi)來(lái)吸引了大量的使用者。市場(chǎng)需求急劇增加的情況下,每家互聯(lián)網(wǎng)企業(yè)都想一嘗甜頭,因此掌握小程序開(kāi)發(fā)這一技術(shù)無(wú)疑是一名前端開(kāi)發(fā)者不可或缺的技能。但小程序開(kāi)發(fā)當(dāng)中總有一些不便一直讓開(kāi)發(fā)者詬病不已,主要表現(xiàn)在:
有了不少的問(wèn)題之后,我開(kāi)始思考如何將現(xiàn)代的工程化技術(shù)與小程序相結(jié)合。初期在社區(qū)中查閱資料時(shí),許多前輩都基于gulp去做了不少實(shí)踐,對(duì)于小程序這種多頁(yè)應(yīng)用來(lái)說(shuō)gulp的流式工作方式似乎更加方便。在實(shí)際的實(shí)踐過(guò)后,我不太滿(mǎn)意應(yīng)用gulp這一方案,所以我轉(zhuǎn)向了對(duì)webpack的實(shí)踐探索。我認(rèn)為選擇webpack作為工程化的支持,盡管它相對(duì)gulp更難實(shí)現(xiàn),但在未來(lái)的發(fā)展中一定會(huì)有非凡的效果,
我們先不考慮預(yù)編譯、規(guī)范等等較為復(fù)雜的問(wèn)題,我們的第一個(gè)目標(biāo)是如何應(yīng)用webpack將源代碼文件夾下的文件輸出到目標(biāo)文件夾當(dāng)中,接下來(lái)我們就一步步來(lái)創(chuàng)建這個(gè)工程項(xiàng)目:
/* 創(chuàng)建項(xiàng)目 */ $ mkdir wxmp-base $ cd ./wxmp-base /* 創(chuàng)建package.json */ $ npm init /* 安裝依賴(lài)包 */ $ npm install webpack webpack-cli --dev 復(fù)制代碼
安裝好依賴(lài)之后我們?yōu)檫@個(gè)項(xiàng)目創(chuàng)建基礎(chǔ)的目錄結(jié)構(gòu),如圖所示:
上圖所展示的是一個(gè)最簡(jiǎn)單的小程序,它只包含 app 全局配置文件和一個(gè) home 頁(yè)面。接下來(lái)我們不管全局或是頁(yè)面,我們以文件類(lèi)型劃分為需要待加工的 js 類(lèi)型文件和不需要再加工可以直接拷貝的 wxml 、 wxss 、 json 文件。以這樣的思路我們開(kāi)始編寫(xiě)供webpack執(zhí)行的配置文件,在項(xiàng)目根目錄下創(chuàng)建一個(gè)build目錄存放webpack.config.js文件。
$ mkdir build $ cd ./build $ touch webpack.config.js 復(fù)制代碼
/** 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',
}
])
]
};
復(fù)制代碼
在編寫(xiě)完上述代碼之后,為大家解釋一下上述的代碼究竟會(huì)做些什么:
我們了解完這些代碼的實(shí)際作用之后就可以在終端中運(yùn)行 webpack --config build/webpack.config.js 命令。webpack會(huì)將源代碼編譯到 dist 文件夾中,這個(gè)文件夾中的內(nèi)容就可用在開(kāi)發(fā)者工具中運(yùn)行、預(yù)覽、上傳。
完成了最基礎(chǔ)的webpack構(gòu)建策略后,我們實(shí)現(xiàn)了 app 和 home 頁(yè)面的轉(zhuǎn)化,但這還遠(yuǎn)遠(yuǎn)不夠。我們還需要解決許多的問(wèn)題:
接下來(lái)我們針對(duì)以上幾點(diǎn)進(jìn)行webpack策略的升級(jí):
一開(kāi)始我的實(shí)現(xiàn)方法是寫(xiě)一個(gè)工具函數(shù)利用 glob 收集pages和components下的 js 文件然后生成入口對(duì)象傳遞給 entry 。但是在實(shí)踐過(guò)程中,我發(fā)現(xiàn)這樣的做法有兩個(gè)弊端:
本著程序員應(yīng)該是極度慵懶,能交給機(jī)器完成的事情絕不自己動(dòng)手的信條,我開(kāi)始研究新的入口生成方案。最終確定下來(lái)編寫(xiě)一個(gè)webpack的插件,在webpack構(gòu)建的生命周期中生成入口,廢話(huà)不多說(shuō)上代碼:
/** 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文件中注冊(cè)的pages和subpackages生成一個(gè)待處理數(shù)組
*/
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();
}
/** 收集分包中的頁(yè)面 */
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": 當(dāng)前分包 "${root}" 中pages字段為空`));
process.exit();
}
subPages.forEach((subPage) => pages.push(`${root}/${subPage}`));
});
}
return pages;
}
/**
* 以頁(yè)面為起始點(diǎn)遞歸去尋找所使用的組件
* @param {String} 當(dāng)前文件的上下文路徑
* @param {String} 依賴(lài)路徑
* @param {Array} 包含全部入口的數(shù)組
*/
addDependencies(context, dependPath, entries) {
/** 生成絕對(duì)路徑 */
const isAbsolute = dependPath[0] === '/';
let absolutePath = '';
if (isAbsolute) {
absolutePath = path.resolve(this.appContext, dependPath.slice(1));
} else {
absolutePath = path.resolve(context, dependPath);
}
/** 生成以源代碼目錄為基準(zhǔn)的相對(duì)路徑 */
const relativePath = path.relative(this.appContext, absolutePath);
/** 校驗(yàn)該路徑是否合法以及是否在已有入口當(dāng)中 */
const jsPath = replaceExt(absolutePath, '.js');
const isQualification = fs.existsSync(jsPath);
if (!isQualification) {
console.log(chalk.red(`ERROR: in "${replaceExt(relativePath, '.js')}": 當(dāng)前文件缺失`));
process.exit();
}
const isExistence = entries.includes((entry) => entry === absolutePath);
if (!isExistence) {
entries.push(relativePath);
}
/** 獲取json文件內(nèi)容 */
const jsonPath = replaceExt(absolutePath, '.json');
const isJsonExistence = fs.existsSync(jsonPath);
if (!isJsonExistence) {
console.log(chalk.red(`ERROR: in "${replaceExt(relativePath, '.json')}": 當(dāng)前文件缺失`));
process.exit();
}
try {
const content = fs.readFileSync(jsonPath, 'utf8');
const { usingComponents = {} } = JSON.parse(content);
const components = Object.values(usingComponents);
const { length } = components;
/** 當(dāng)json文件中有再引用其他組件時(shí)執(zhí)行遞歸 */
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')}": 當(dāng)前文件內(nèi)容為空或書(shū)寫(xiě)不正確`));
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) {
/** 設(shè)置源代碼的上下文 */
const { context } = compiler.options;
this.appContext = context;
compiler.hooks.entryOption.tap('EntryExtractPlugin', () => {
/** 生成入口依賴(lài)數(shù)組 */
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', () => {
/** 校驗(yàn)頁(yè)面入口是否增加 */
const pages = this.getPages();
const diffPages = difference(pages, this.pages);
const { length } = diffPages;
if (length) {
this.pages = this.pages.concat(diffPages);
const entries = [];
/** 通過(guò)新增的入口頁(yè)面建立依賴(lài) */
diffPages.forEach((page) => void this.addDependencies(context, page, entries));
/** 去除與原有依賴(lài)的交集 */
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;
復(fù)制代碼
由于webpack的 plugin 相關(guān)知識(shí)不在我們這篇文章的討論范疇,所以我只簡(jiǎn)單的介紹一下它是如何介入webpack的工作流程中并生成入口的。(如果有興趣想了解這些可以私信我,有時(shí)間的話(huà)可能會(huì)整理一些資料出來(lái)給大家)該插件實(shí)際做了兩件事:
entry entry
現(xiàn)在我們將這個(gè)插件應(yīng)用到之前的webpack策略中,將上面的配置更改為:(記得安裝 chalk replace-ext 依賴(lài))
/** build/webpack.config.js */
const EntryExtractPlugin = require('./entry-extract-plugin');
module.exports = {
...
entry: {
app: './app.js'
},
plugins: [
...
new EntryExtractPlugin()
]
}
復(fù)制代碼
樣式預(yù)編譯和EsLint應(yīng)用其實(shí)已經(jīng)有許多優(yōu)秀的文章了,在這里我就只貼出我們的實(shí)踐代碼:
/** 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' })
]
}
復(fù)制代碼
我們修改完策略后就可以將 wxss 后綴名的文件更改為 less 后綴名(如果你想用其他的預(yù)編譯語(yǔ)言,可以自行修改loader),然后我們?cè)?nbsp;js 文件中加入 import './index.less' 語(yǔ)句就能看到樣式文件正常編譯生成了。樣式文件能夠正常的生成最大的功臣就是 mini-css-extract-plugin 工具包,它幫助我們轉(zhuǎn)換了后綴名并且生成到目標(biāo)目錄中。
環(huán)境變量的切換我們使用 cross-env 工具包來(lái)進(jìn)行配置,我們?cè)?nbsp;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
}
復(fù)制代碼
相應(yīng)的我們也修改一下webpack的配置文件,將我們應(yīng)用的環(huán)境也告訴webpack,這樣webpack會(huì)針對(duì)環(huán)境對(duì)代碼進(jìn)行優(yōu)化處理。
/** build/webpack.config.js */
const { OPERATING_ENV } = process.env;
module.exports = {
...
mode: OPERATING_ENV,
devtool: OPERATING_ENV === 'production' ? 'source-map' : 'inline-source-map'
}
復(fù)制代碼
雖然我們也可以通過(guò)命令為webpack設(shè)置 mode ,這樣也可以在項(xiàng)目中通過(guò) process.env.NODE_ENV 訪問(wèn)環(huán)境變量,但是我還是推薦使用工具包,因?yàn)槟憧赡軙?huì)有多個(gè)環(huán)境 uat test pre 等等。
小程序?qū)Π拇笮∮袊?yán)格的要求,單個(gè)包的大小不能超過(guò)2M,所以我們應(yīng)該對(duì)JS做進(jìn)一步的優(yōu)化,這有利于我們控制包的大小。我所做的優(yōu)化主要針對(duì)runtime和多個(gè)入口頁(yè)面之間引用的公共部分,修改配置文件為:
/** build/webpack.config.js */
module.exports = {
...
optimization: {
splitChunks: {
cacheGroups: {
commons: {
chunks: 'initial',
name: 'commons',
minSize: 0,
maxSize: 0,
minChunks: 2,
},
},
},
runtimeChunk: {
name: 'manifest',
},
},
}
復(fù)制代碼
webpack會(huì)將公共的部分抽離出來(lái)在 dist 文件夾根目錄中生成 common.js 和 manifest.js 文件,這樣整個(gè)項(xiàng)目的體積就會(huì)有明顯的縮小,但是你會(huì)發(fā)現(xiàn)當(dāng)我們運(yùn)行命令是開(kāi)發(fā)者工具里面項(xiàng)目其實(shí)是無(wú)法正常運(yùn)行的,這是為什么?
這主要是因?yàn)檫@種優(yōu)化使小程序其他的 js 文件丟失了對(duì)公共部分的依賴(lài),我們對(duì)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',
})
]
}
復(fù)制代碼
許多讀者可能會(huì)有疑惑,為什么你不直接使用已有的框架進(jìn)行開(kāi)發(fā),這些能力已經(jīng)有許多框架支持了。選擇框架確實(shí)是一個(gè)不錯(cuò)的選擇,畢竟開(kāi)箱即用為開(kāi)發(fā)者帶來(lái)了許多便利。但是這個(gè)選擇是有利有弊的,我也對(duì)市面上的較流行框架做了一段時(shí)間的研究和實(shí)踐。較為早期的騰訊的wepy、美團(tuán)的mpvue,后來(lái)者居上的京東的taro、Dcloud的uni-app等,這些在應(yīng)用當(dāng)中我認(rèn)為有以下一些點(diǎn)不受我青睞:
以上基本是我為什么要自己探索小程序工程化的理由(其實(shí)還有一點(diǎn)就是求知欲,嘻嘻)