微信小程(cheng)(cheng)序因為其(qi)便(bian)捷的(de)(de)使用(yong)方式,以極(ji)快的(de)(de)速度(du)傳播開來吸引(yin)了大量的(de)(de)使用(yong)者。市場需(xu)求急劇增加的(de)(de)情況下,每(mei)家(jia)互(hu)聯網(wang)企業都(dou)想一(yi)(yi)嘗(chang)甜頭(tou),因此掌(zhang)握小程(cheng)(cheng)序開發(fa)這一(yi)(yi)技術無疑(yi)是一(yi)(yi)名前端開發(fa)者不(bu)可或(huo)缺的(de)(de)技能。但小程(cheng)(cheng)序開發(fa)當(dang)中總有(you)一(yi)(yi)些不(bu)便(bian)一(yi)(yi)直讓開發(fa)者詬病(bing)不(bu)已,主要表(biao)現在(zai):
有(you)了不少(shao)的(de)問題之后,我(wo)開始思考如何將現代的(de)工(gong)程(cheng)(cheng)化技術與小程(cheng)(cheng)序相結合。初期在(zai)社區中查閱資料時,許多前(qian)輩都基(ji)于(yu)gulp去(qu)做了不少(shao)實(shi)(shi)踐,對于(yu)小程(cheng)(cheng)序這種多頁應用來(lai)說gulp的(de)流(liu)式工(gong)作(zuo)方(fang)(fang)式似乎更(geng)加方(fang)(fang)便(bian)。在(zai)實(shi)(shi)際的(de)實(shi)(shi)踐過后,我(wo)不太滿意應用gulp這一方(fang)(fang)案,所以我(wo)轉向了對webpack的(de)實(shi)(shi)踐探索。我(wo)認為選擇webpack作(zuo)為工(gong)程(cheng)(cheng)化的(de)支持,盡管它相對gulp更(geng)難實(shi)(shi)現,但在(zai)未來(lai)的(de)發展中一定會有(you)非凡的(de)效果(guo),
我們先不考慮預編(bian)譯、規范等等較為(wei)復(fu)雜的(de)問(wen)題,我們的(de)第一(yi)(yi)個目標是如何應用webpack將源代碼文件(jian)(jian)夾下(xia)的(de)文件(jian)(jian)輸(shu)出到目標文件(jian)(jian)夾當中,接下(xia)來(lai)我們就一(yi)(yi)步(bu)步(bu)來(lai)創建這個工程項(xiang)目:
/* 創建項目 */ $ mkdir wxmp-base $ cd ./wxmp-base /* 創建package.json */ $ npm init /* 安裝依賴包 */ $ npm install webpack webpack-cli --dev 復制代碼
安(an)裝好依賴之后我們為這個(ge)項目創(chuang)建基礎的(de)目錄結構(gou),如(ru)圖所示:
上圖所展示的(de)是(shi)一(yi)個(ge)最(zui)簡單的(de)小程序,它只(zhi)包含 app 全局配(pei)置文(wen)(wen)件和(he)一(yi)個(ge) home 頁(ye)面。接下(xia)來我(wo)們不管全局或是(shi)頁(ye)面,我(wo)們以文(wen)(wen)件類型(xing)劃分(fen)為(wei)需(xu)要待加工(gong)的(de) js 類型(xing)文(wen)(wen)件和(he)不需(xu)要再加工(gong)可以直接拷貝的(de) wxml 、 wxss 、 json 文(wen)(wen)件。以這樣的(de)思路(lu)我(wo)們開始編寫供webpack執行的(de)配(pei)置文(wen)(wen)件,在項(xiang)目(mu)根(gen)目(mu)錄下(xia)創(chuang)建一(yi)個(ge)build目(mu)錄存放webpack.config.js文(wen)(wen)件。
$ 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',
}
])
]
};
復制代碼
在編寫(xie)完上述代(dai)碼之后,為大家解釋一下上述的代(dai)碼究竟(jing)會做些什么(me):
我們了解完(wan)這些(xie)代碼(ma)的(de)(de)實際作用之后就可以(yi)在終端中運行 webpack --config build/webpack.config.js 命令。webpack會(hui)將源代碼(ma)編譯到 dist 文件夾中,這個文件夾中的(de)(de)內容(rong)就可用在開發(fa)者(zhe)工具中運行、預覽(lan)、上傳。
完成了最基礎的(de)webpack構建策略后,我們實現了 app 和 home 頁面(mian)的(de)轉化,但(dan)這還遠遠不(bu)夠。我們還需要解決許多的(de)問題:
接下來我(wo)們針對以上幾點進行webpack策(ce)略的升級:
一開(kai)始我(wo)的實現方法(fa)是寫一個工具函數利用 glob 收(shou)集(ji)pages和components下的 js 文(wen)件然后生成入口對象傳遞給(gei) entry 。但(dan)是在實踐過程中,我(wo)發現這樣的做法(fa)有兩個弊端:
本著程序員(yuan)應該(gai)是(shi)極度(du)慵懶,能交給機器完成的(de)事(shi)情絕不自己動手的(de)信條,我(wo)開始(shi)研究新的(de)入口(kou)生成方案。最終確定下來編(bian)寫一個webpack的(de)插件,在webpack構建的(de)生命周期中生成入口(kou),廢(fei)話不多(duo)說(shuo)上代碼:
/** 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的(de) plugin 相關知識不(bu)在我(wo)(wo)們這(zhe)篇(pian)文章(zhang)的(de)討論范疇,所(suo)以(yi)我(wo)(wo)只簡單的(de)介(jie)紹一(yi)下(xia)它是如何介(jie)入(ru)webpack的(de)工作流(liu)程中(zhong)并生成入(ru)口的(de)。(如果有(you)興趣想了解這(zhe)些可以(yi)私(si)信我(wo)(wo),有(you)時(shi)間的(de)話(hua)可能會整理一(yi)些資料出來給(gei)大家(jia))該插件實際做(zuo)了兩(liang)件事:
entry entry
現在我們將這個插(cha)件應(ying)用到之(zhi)前的webpack策略中,將上面的配置更改為:(記得安裝 chalk replace-ext 依賴)
/** build/webpack.config.js */
const EntryExtractPlugin = require('./entry-extract-plugin');
module.exports = {
...
entry: {
app: './app.js'
},
plugins: [
...
new EntryExtractPlugin()
]
}
復制代碼
樣式預編譯和(he)EsLint應用其實已經(jing)有許多(duo)優秀的(de)(de)文章了,在這里我就(jiu)只貼出我們的(de)(de)實踐代碼:
/** 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' })
]
}
復制代碼
我(wo)們(men)修(xiu)改完策(ce)略后(hou)就可以將 wxss 后(hou)綴名的(de)(de)文(wen)(wen)件更改為(wei) less 后(hou)綴名(如果你想用(yong)其他的(de)(de)預編(bian)譯語言,可以自(zi)行修(xiu)改loader),然后(hou)我(wo)們(men)在 js 文(wen)(wen)件中加入 import './index.less' 語句就能(neng)看到(dao)樣(yang)式文(wen)(wen)件正常編(bian)譯生(sheng)成(cheng)了(le)。樣(yang)式文(wen)(wen)件能(neng)夠(gou)正常的(de)(de)生(sheng)成(cheng)最大的(de)(de)功臣就是 mini-css-extract-plugin 工具包,它幫(bang)助(zhu)我(wo)們(men)轉換了(le)后(hou)綴名并且生(sheng)成(cheng)到(dao)目(mu)標(biao)目(mu)錄中。
環境變(bian)量的切換我(wo)們使(shi)用 cross-env 工具包來進(jin)行配置(zhi),我(wo)們在 package.json 文件中添(tian)加兩句腳本命令(ling):
"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
}
復制代碼
相應的(de)(de)我(wo)(wo)們也(ye)修改一下webpack的(de)(de)配置文(wen)件,將(jiang)我(wo)(wo)們應用的(de)(de)環境也(ye)告訴webpack,這樣(yang)webpack會針對環境對代碼(ma)進行優化處理(li)。
/** build/webpack.config.js */
const { OPERATING_ENV } = process.env;
module.exports = {
...
mode: OPERATING_ENV,
devtool: OPERATING_ENV === 'production' ? 'source-map' : 'inline-source-map'
}
復制代碼
雖然我們也可以通過命令(ling)為webpack設(she)置(zhi) mode ,這樣也可以在項目中通過 process.env.NODE_ENV 訪問環境變量(liang),但是我還(huan)是推薦使用工(gong)具包,因(yin)為你可能會有多個環境 uat test pre 等等。
小程(cheng)序(xu)對包的(de)(de)(de)(de)大(da)小有嚴(yan)格的(de)(de)(de)(de)要(yao)求,單個包的(de)(de)(de)(de)大(da)小不能超(chao)過2M,所以我(wo)(wo)們應該對JS做進一步(bu)的(de)(de)(de)(de)優(you)(you)化,這有利于我(wo)(wo)們控制包的(de)(de)(de)(de)大(da)小。我(wo)(wo)所做的(de)(de)(de)(de)優(you)(you)化主要(yao)針對runtime和(he)多個入口頁(ye)面之間引用的(de)(de)(de)(de)公共部分,修(xiu)改配置文件為:
/** build/webpack.config.js */
module.exports = {
...
optimization: {
splitChunks: {
cacheGroups: {
commons: {
chunks: 'initial',
name: 'commons',
minSize: 0,
maxSize: 0,
minChunks: 2,
},
},
},
runtimeChunk: {
name: 'manifest',
},
},
}
復制代碼
webpack會將公共的(de)部分抽(chou)離出來(lai)在(zai) dist 文(wen)件夾根目錄中生成 common.js 和 manifest.js 文(wen)件,這樣(yang)整個項目的(de)體(ti)積就會有明顯的(de)縮小,但(dan)是(shi)(shi)你會發現(xian)當我們運行命令是(shi)(shi)開發者工具里面項目其(qi)實是(shi)(shi)無法正(zheng)常運行的(de),這是(shi)(shi)為什么?
這主要是因為這種優化使小程序其他(ta)的(de) js 文件丟失了對(dui)公共(gong)部分(fen)的(de)依賴,我們(men)對(dui)webpack配置(zhi)文件做如下修改就可(ke)以解決了:
/** 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',
})
]
}
復制代碼
許(xu)多讀(du)者(zhe)可(ke)能會(hui)有(you)(you)疑惑,為(wei)(wei)(wei)什么你不(bu)直接使用(yong)已(yi)有(you)(you)的(de)(de)(de)框架(jia)進行開發(fa),這(zhe)些能力已(yi)經有(you)(you)許(xu)多框架(jia)支持了(le)。選(xuan)擇(ze)框架(jia)確實是(shi)一個(ge)不(bu)錯的(de)(de)(de)選(xuan)擇(ze),畢竟開箱(xiang)即用(yong)為(wei)(wei)(wei)開發(fa)者(zhe)帶來了(le)許(xu)多便利。但是(shi)這(zhe)個(ge)選(xuan)擇(ze)是(shi)有(you)(you)利有(you)(you)弊的(de)(de)(de),我(wo)也對市面上(shang)的(de)(de)(de)較流(liu)行框架(jia)做了(le)一段時(shi)間的(de)(de)(de)研究和實踐。較為(wei)(wei)(wei)早期(qi)的(de)(de)(de)騰訊的(de)(de)(de)wepy、美團的(de)(de)(de)mpvue,后來者(zhe)居上(shang)的(de)(de)(de)京東的(de)(de)(de)taro、Dcloud的(de)(de)(de)uni-app等,這(zhe)些在(zai)應用(yong)當中我(wo)認為(wei)(wei)(wei)有(you)(you)以(yi)下(xia)一些點不(bu)受我(wo)青(qing)睞:
以上基本(ben)是我為(wei)什么(me)要(yao)自己(ji)探索小程(cheng)序工程(cheng)化(hua)的(de)理由(其實還(huan)有一點就是求知欲(yu),嘻嘻)