微信小程序因為其便捷的使用方式,以極快的速度傳播開來吸引了大量的使用者。市場需求急劇增加的情況下,每家互聯(lián)網(wǎng)企業(yè)都想一嘗甜頭,因此掌握小程序開發(fā)這一技術無疑是一名前端開發(fā)者不可或缺的技能。但小程序開發(fā)當中總有一些不便一直讓開發(fā)者詬病不已,主要表現(xiàn)在:
有了不少的問題之后,我開始思考如何將現(xiàn)代的工程化技術與小程序相結合。初期在社區(qū)中查閱資料時,許多前輩都基于gulp去做了不少實踐,對于小程序這種多頁應用來說gulp的流式工作方式似乎更加方便。在實際的實踐過后,我不太滿意應用gulp這一方案,所以我轉向了對webpack的實踐探索。我認為選擇webpack作為工程化的支持,盡管它相對gulp更難實現(xiàn),但在未來的發(fā)展中一定會有非凡的效果,
我們先不考慮預編譯、規(guī)范等等較為復雜的問題,我們的第一個目標是如何應用webpack將源代碼文件夾下的文件輸出到目標文件夾當中,接下來我們就一步步來創(chuàng)建這個工程項目:
/* 創(chuàng)建項目 */ $ mkdir wxmp-base $ cd ./wxmp-base /* 創(chuàng)建package.json */ $ npm init /* 安裝依賴包 */ $ npm install webpack webpack-cli --dev 復制代碼
安裝好依賴之后我們?yōu)檫@個項目創(chuàng)建基礎的目錄結構,如圖所示:
上圖所展示的是一個最簡單的小程序,它只包含 app 全局配置文件和一個 home 頁面。接下來我們不管全局或是頁面,我們以文件類型劃分為需要待加工的 js 類型文件和不需要再加工可以直接拷貝的 wxml 、 wxss 、 json 文件。以這樣的思路我們開始編寫供webpack執(zhí)行的配置文件,在項目根目錄下創(chuàng)建一個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 文件夾中,這個文件夾中的內容就可用在開發(fā)者工具中運行、預覽、上傳。
完成了最基礎的webpack構建策略后,我們實現(xiàn)了 app 和 home 頁面的轉化,但這還遠遠不夠。我們還需要解決許多的問題:
接下來我們針對以上幾點進行webpack策略的升級:
一開始我的實現(xiàn)方法是寫一個工具函數(shù)利用 glob 收集pages和components下的 js 文件然后生成入口對象傳遞給 entry 。但是在實踐過程中,我發(fā)現(xiàn)這樣的做法有兩個弊端:
本著程序員應該是極度慵懶,能交給機器完成的事情絕不自己動手的信條,我開始研究新的入口生成方案。最終確定下來編寫一個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生成一個待處理數(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(); } /** 收集分包中的頁面 */ 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} 包含全部入口的數(shù)組 */ 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文件中有再引用其他組件時執(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')}": 當前文件內容為空或書寫不正確`)); 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', () => { /** 生成入口依賴數(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', () => { /** 校驗頁面入口是否增加 */ 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
現(xiàn)在我們將這個插件應用到之前的webpack策略中,將上面的配置更改為:(記得安裝 chalk replace-ext 依賴)
/** build/webpack.config.js */ const EntryExtractPlugin = require('./entry-extract-plugin'); module.exports = { ... entry: { app: './app.js' }, plugins: [ ... new EntryExtractPlugin() ] } 復制代碼
樣式預編譯和EsLint應用其實已經(jīng)有許多優(yōu)秀的文章了,在這里我就只貼出我們的實踐代碼:
/** 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 工具包,它幫助我們轉換了后綴名并且生成到目標目錄中。
環(huán)境變量的切換我們使用 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的配置文件,將我們應用的環(huán)境也告訴webpack,這樣webpack會針對環(huá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' } 復制代碼
雖然我們也可以通過命令為webpack設置 mode ,這樣也可以在項目中通過 process.env.NODE_ENV 訪問環(huán)境變量,但是我還是推薦使用工具包,因為你可能會有多個環(huán)境 uat test pre 等等。
小程序對包的大小有嚴格的要求,單個包的大小不能超過2M,所以我們應該對JS做進一步的優(yōu)化,這有利于我們控制包的大小。我所做的優(yōu)化主要針對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 文件,這樣整個項目的體積就會有明顯的縮小,但是你會發(fā)現(xiàn)當我們運行命令是開發(fā)者工具里面項目其實是無法正常運行的,這是為什么?
這主要是因為這種優(yōu)化使小程序其他的 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', }) ] } 復制代碼
許多讀者可能會有疑惑,為什么你不直接使用已有的框架進行開發(fā),這些能力已經(jīng)有許多框架支持了。選擇框架確實是一個不錯的選擇,畢竟開箱即用為開發(fā)者帶來了許多便利。但是這個選擇是有利有弊的,我也對市面上的較流行框架做了一段時間的研究和實踐。較為早期的騰訊的wepy、美團的mpvue,后來者居上的京東的taro、Dcloud的uni-app等,這些在應用當中我認為有以下一些點不受我青睞:
以上基本是我為什么要自己探索小程序工程化的理由(其實還有一點就是求知欲,嘻嘻)