詳解微信小程式工程化探索之webpack實戰
前言
微信小程式因為其便捷的使用方式,以極快的速度傳播開來吸引了大量的使用者。市場需求急劇增加的情況下,每家網際網路企業都想一嘗甜頭,因此掌握小程式開發這一技術無疑是一名前端開發者不可或缺的技能。但小程式開發當中總有一些不便一直讓開發者詬病不已,主要表現在:
- 初期缺乏方便的npm包管理機制(現階段確實可以使用npm包,但是操作確實不便)
- 不能使用預編譯語言處理樣式
- 無法通過指令碼命令切換不同的開發環境,需手動修改對應環境所需配置(常規專案至少具備開發與生產環境)
- 無法將規範檢查工具結合到專案工程中(諸如EsLint、StyleLint的使用)
有了不少的問題之後,我開始思考如何將現代的工程化技術與小程式相結合。初期在社群中查閱資料時,許多前輩都基於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',{ from: '**/*.json',} ]) ] };
在編寫完上述程式碼之後,為大家解釋一下上述的程式碼究竟會做些什麼:
- 入口entry物件中我寫了兩個屬性,意在將app.js和home/index.js作為webpack的構建入口,它會以這個檔案為起始點建立各自的依賴關係,這樣當我們在入口檔案中引入其他檔案時,被引入的檔案也能被webpack所處理。
- module中我使用了babel-loader對js檔案進行ES6轉換為ES5的處理,並且加入了對新語法的處理,這樣我們就解決了在原生小程式開發中總是要反覆引入regenerator-runtime的問題。(這一步我們需要安裝@babel/core、@babel/preset-env、@babel/plugin-transform-runtime、@babel/runtime、babel-loader這幾個依賴包)
- 使用copy-webpack-plugin來處理不需要再加工的檔案,這個外掛可以直接將檔案複製到目標目錄當中。
我們瞭解完這些程式碼的實際作用之後就可以在終端中執行webpack --config build/webpack.config.js命令。webpack會將原始碼編譯到dist資料夾中,這個資料夾中的內容就可用在開發者工具中執行、預覽、上傳。
優化
完成了最基礎的webpack構建策略後,我們實現了app和home頁面的轉化,但這還遠遠不夠。我們還需要解決許多的問題:
- 頁面檔案增多怎麼辦,元件怎麼處理
- 預期的預編譯如何做
- 規範如何結合到工程中
- 環境變數怎麼處理
接下來我們針對以上幾點進行webpack策略的升級:
頁面與元件
一開始我的實現方法是寫一個工具函式利用glob收集pages和components下的js檔案然後生成入口物件傳遞給entry。但是在實踐過程中,我發現這樣的做法有兩個弊端:
- 當終端中已經啟動了命令,這時候新增頁面或元件都不會自動生成新的入口,也就是我們要重跑一遍命令。
- 工具函式寫死了匹配pages和components資料夾下的檔案,不利於專案的延展性,如果我們需要分包或者資料夾命名需要改動時,我們就需要改動工具函式。
本著程式設計師應該是極度慵懶,能交給機器完成的事情絕不自己動手的信條,我開始研究新的入口生成方案。最終確定下來編寫一個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,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,entries)); /** 去除與原有依賴的交集 */ const diffEntries = difference(entries,this.entries); diffEntries.forEach((entry) => { this.applyEntry(context,`./${entry}`).apply(compiler); }); this.entries = this.entries.concat(diffEntries); } }); } } module.exports = EntryExtractPlugin;
由於webpack的plugin相關知識不在我們這篇文章的討論範疇,所以我只簡單的介紹一下它是如何介入webpack的工作流程中並生成入口的。(如果有興趣想了解這些可以私信我,有時間的話可能會整理一些資料出來給大家)該外掛實際做了兩件事:
- 通過compiler的entryOption鉤子,我們將遞迴生成的入口陣列一項一項的加入entry中。
- 通過compiler的watchRun鉤子監聽重新編譯時是否有新的頁面加入,如果有就會以新加入的頁面生成一個依賴陣列,然後再加入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
樣式預編譯和EsLint應用其實已經有許多優秀的文章了,在這裡我就只貼出我們的實踐程式碼:
/** build/webpack.config.js */ const MiniCssExtractPlugin = require('mini-css-extract-plugin'); module.exports = { ... module: { rules: [ ... { enforce: 'pre',test: /\.js$/,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等等。
針對JS優化
小程式對包的大小有嚴格的要求,單個包的大小不能超過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等,這些在應用當中我認為有以下一些點不受我青睞:
- 黑盒使我們有時很難定位問題究竟是出在自身的程式碼當中還是在框架的編譯流程中(這讓我踩了不少坑)
- 圍繞框架展開的可以使用的資源有限,例如UI的使用基本依賴於官方團隊進行配套開發,如果沒有社群也極難找到需要的資源(這一點我認為uni-app的社群做得挺不錯)
- 與已有的一些原生的資源無法結合,這些框架基本都是基於編譯原理提供了以react或者vue為開發語言的能力,這使得原生的資源要無縫接入很難實現(假如你們公司已經積澱了一些業務元件那你會很頭疼)。
- 最後一點,也是我擔心的最重要的一點,框架的升級速度是否能跟得上官方的迭代速度,如果滯後了已有的專案該如何處理
以上基本是我為什麼要自己探索小程式工程化的理由(其實還有一點就是求知慾,嘻嘻)
寫在最後
以上是我對原生小程式工程化的探索,在我所在的團隊中還應用了一些相關的樣式規範,在這篇文章中我沒有具體的說,有興趣的話可以檢視我的專欄中《團隊規範之樣式規範實踐》一文。其實還有靜態資源的管理,專案的目錄的補充這些細節可以依照團隊的需要去完善補充。本文希望對有需要做這方面實踐的團隊有所幫助,如有觀點不正確或需要改進的地方,望可以評論告知我。
到此這篇關於詳解微信小程式工程化探索之webpack實戰的文章就介紹到這了,更多相關小程式 webpack 內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!