本文基於 webpack 4 和 babel 7,Mac OS,VS Codejavascript
小程序開發現狀:css
小程序開發者工具很差用,官方對 npm 的支持有限,缺乏對 webpack, babel 等前端經常使用工具鏈的支持。html
多端框架(Mpvue, Taro)崛起,但限制了原生小程序的能力。前端
我司在使用一段時間多端開發框架後,決定回退到原生方案,除了多端框架對原生能力有所限制外,最重要的是,咱們只須要一個微信小程序,並不須要跨端。vue
程序雖小,但須要長期維護,多人維護,所以規範的工程化實踐就很重要了。本系列文章分上下兩篇,上篇主要講 webpack, babel, 環境配置,下篇主要講 Typescript, EsLint, 單元測試,CI / CD。java
經過本文,你將學會使用如何使用前端工程技術來開發原生小程序:node
webpack 基礎配置以及高級配置webpack
webpack 構建流程,這是編寫 webpack 插件的基礎git
編寫 webpack 插件,核心源碼不到 50 行,使得小程序開發支持 npmgithub
爲你講述 webpack 插件中關鍵代碼的做用,而不只僅是提供源碼
優化 webpack 配置,剔除沒必要要的代碼,減小包體積
支持 sass 等 css 預處理器
支持 npm 是小程序工程化的前提,微信官方聲稱支持 npm,但實際操做使人窒息。
這也是做者爲何要花大力氣學習如何編寫 webpack 插件,使得小程序能夠像 Web 應用那樣支持 npm 的緣故。不得不說,這也是一個學習編寫 webpack 插件的契機。
先讓咱們來吐槽官方對 npm 的支持。
打開微信開發者工具 -> 項目 -> 新建項目,使用測試號建立一個小程序項目
經過終端,初始化 npm
npm init --yes
複製代碼
能夠看到,咱們的項目根目錄下,生成了一個 package.json 文件
如今讓咱們經過 npm 引入一些依賴,首先是大名鼎鼎的 moment 和 lodash
npm i moment lodash
複製代碼
點擊微信開發者工具中的菜單欄:工具 -> 構建 npm
能夠看到,在咱們項目的根目錄下,生成了一個叫 miniprogram_npm 的目錄
修改 app.js,添加以下內容
// app.js
+ import moment from 'moment';
App({
onLaunch: function () {
+ console.log('-----------------------------------------------x');
+ let sFromNowText = moment(new Date().getTime() - 360000).fromNow();
+ console.log(sFromNowText);
}
})
複製代碼
並保存,能夠看到微信開發者工具控制檯輸出:
再來測試下 lodash,修改 app.js,添加以下內容
// app.js
+ import { camelCase } from 'lodash';
App({
onLaunch: function () {
+ console.log(camelCase('OnLaunch'));
}
})
複製代碼
保存,而後出錯了
而後做者又嘗試了 rxjs 這個庫,也一樣失敗了。查閱了一些資料,說是要把 rxjs 的源碼 clone 下來編譯,並將編譯結果複製到 miniprogram_npm 這個文件夾。嘗試了下,確實可行。但這種使用 npm 的方式也實在是太奇葩了吧,太反人類了,不是咱們熟悉的味道。
在持續查閱了一些資料和嘗試後,發現使用 webpack 來和 npm 搭配纔是王道。
先把 app.js 中新增的代碼移除
// app.js
- import moment from 'moment';
- import { camelCase } from 'lodash';
App({
onLaunch: function () {
- console.log('-----------------------------------------------x');
- let sFromNowText = moment(new Date().getTime() - 360000).fromNow();
- console.log(sFromNowText);
- console.log(camelCase('OnLaunch'));
}
})
複製代碼
刪掉 miniprogram_npm 這個文件夾,這真是個異類。
新建文件夾 src,把 pages, utils, app.js, app.json, app.wxss, sitemap.json 這幾個文件(夾)移動進去
安裝 webpack 和 webpack-cli
npm i --save-dev webpack webpack-cli copy-webpack-plugin clean-webpack-plugin
複製代碼
在根目錄下,新建 webpack.config.js 文件,添加以下內容
const { resolve } = require('path')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
module.exports = {
context: resolve('src'),
entry: './app.js',
output: {
path: resolve('dist'),
filename: '[name].js',
},
plugins: [
new CleanWebpackPlugin({
cleanStaleWebpackAssets: false,
}),
new CopyWebpackPlugin([
{
from: '**/*',
to: './',
},
]),
],
mode: 'none',
}
複製代碼
修改 project.config.json 文件,指明小程序的入口
// project.config.json
{
"description": "項目配置文件",
+ "miniprogramRoot": "dist/",
}
複製代碼
在終端輸入 npx webpack。
能夠看到,在小程序開發者工具的模擬器中,咱們的小程序刷新了,並且控制檯也沒有錯誤提示。
在咱們項目的根目錄中,生成了一個叫 dist 的文件夾,裏面的內容和 src 中如出一轍,除了多了個 main.js 文件。
對 webpack 有所瞭解的同窗都知道,這是 webpack 化項目的經典結構
若是你對 webpack 從不瞭解,那麼此時你應該去閱讀如下文檔,直到你弄明白爲何會多了個 main.js 文件。
在上面的例子中,咱們只是簡單地將 src 中的文件原封不動地複製到 dist 中,而且讓微信開發者工具感知到,dist 中才是咱們要發佈的代碼。
這是重要的一步,由於咱們搭建了一個 webpack 化的小程序項目。
咱們使用 npm,主要是爲了解決 js 代碼的依賴問題,那麼 js 交給 webpack 來處理,其它文件諸如 .json, .wxml, .wxss 直接複製就行了,這麼想,事情就會簡單不少。
若是你對 webpack 已有基本瞭解,那麼此時,你應該理解小程序是個多頁面應用程序,它有多個入口。
下面,讓咱們修改 webpack.config.js 來配置入口
- entry: './app.js'
+ entry: {
+ 'app' : './app.js',
+ 'pages/index/index': './pages/index/index.js',
+ 'pages/logs/logs' : './pages/logs/logs.js'
+ },
複製代碼
webpack 須要藉助 babel 來處理 js,所以 babel 登場。
npm i @babel/core @babel/preset-env babel-loader --save-dev
複製代碼
在根目錄建立 .babelrc 文件,添加以下內容
// .babelrc
{
"presets": ["@babel/env"]
}
複製代碼
修改 webpack.config.js,使用 babel-loader 來處理 js 文件
module.exports = {
+ module: {
+ rules: [
+ {
+ test: /\.js$/,
+ use: 'babel-loader'
+ }
+ ]
+ },
}
複製代碼
從 src 複製文件到 dist 時,排除 js 文件
new CopyWebpackPlugin([
{
from: '**/*',
to: './',
+ ignore: ['**/*.js']
}
])
複製代碼
此時,webpack.config.js 文件看起來是這樣的:
const { resolve } = require('path')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
module.exports = {
context: resolve('src'),
entry: {
app: './app.js',
'pages/index/index': './pages/index/index.js',
'pages/logs/logs': './pages/logs/logs.js',
},
output: {
path: resolve('dist'),
filename: '[name].js',
},
module: {
rules: [
{
test: /\.js$/,
use: 'babel-loader',
},
],
},
plugins: [
new CleanWebpackPlugin({
cleanStaleWebpackAssets: false,
}),
new CopyWebpackPlugin([
{
from: '**/*',
to: './',
ignore: ['**/*.js'],
},
]),
],
mode: 'none',
}
複製代碼
執行 npx webpack
能夠看到,在 dist 文件夾中,main.js 不見了,同時消失的還有 utils 整個文件夾,由於 utils/util.js 已經被合併到依賴它的 pages/logs/logs.js 文件中去了。
爲何 main.js 會不見了呢?
能夠看到,在小程序開發者工具的模擬器中,咱們的小程序刷新了,並且控制檯也沒有錯誤提示。
把下面代碼添加回 app.js 文件,看看效果如何?
// app.js
+ import moment from 'moment';
+ import { camelCase } from 'lodash';
App({
onLaunch: function () {
+ console.log('-----------------------------------------------x');
+ let sFromNowText = moment(new Date().getTime() - 360000).fromNow();
+ console.log(sFromNowText);
+ console.log(camelCase('OnLaunch'));
}
})
複製代碼
能夠看到,無論是 moment 仍是 lodash, 都能正常工做。
這是重要的里程碑的一步,由於咱們終於可以正常地使用 npm 了。
而此時,咱們尚未開始寫 webpack 插件。
若是你有留意,在執行 npx webpack
命令時,終端會輸出如下信息
生成的 app.js 文件竟然有 1M 那麼大,要知道,小程序有 2M 的大小限制,這個不用擔憂,稍後咱們經過 webpack 配置來優化它。
而如今,咱們開始寫 webpack 插件。
前面,咱們經過如下方式來配置小程序的入口,
entry: {
'app': './app.js',
'pages/index/index': './pages/index/index.js',
'pages/logs/logs': './pages/logs/logs.js',
},
複製代碼
這實在是太醜陋啦,這意味着每寫一個 page 或 component,就得配置一次,咱們寫個 webpack 插件來處理這件事情。
首先安裝一個能夠替換文件擴展名的依賴
npm i --save-dev replace-ext
複製代碼
在項目根目錄中建立一個叫 plugin 的文件夾,在裏面建立一個叫 MinaWebpackPlugin.js 的文件,內容以下:
// plugin/MinaWebpackPlugin.js
const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin')
const MultiEntryPlugin = require('webpack/lib/MultiEntryPlugin')
const path = require('path')
const fs = require('fs')
const replaceExt = require('replace-ext')
function itemToPlugin(context, item, name) {
if (Array.isArray(item)) {
return new MultiEntryPlugin(context, item, name)
}
return new SingleEntryPlugin(context, item, name)
}
function _inflateEntries(entries = [], dirname, entry) {
const configFile = replaceExt(entry, '.json')
const content = fs.readFileSync(configFile, 'utf8')
const config = JSON.parse(content)
;['pages', 'usingComponents'].forEach(key => {
const items = config[key]
if (typeof items === 'object') {
Object.values(items).forEach(item => inflateEntries(entries, dirname, item))
}
})
}
function inflateEntries(entries, dirname, entry) {
entry = path.resolve(dirname, entry)
if (entry != null && !entries.includes(entry)) {
entries.push(entry)
_inflateEntries(entries, path.dirname(entry), entry)
}
}
class MinaWebpackPlugin {
constructor() {
this.entries = []
}
// apply 是每個插件的入口
apply(compiler) {
const { context, entry } = compiler.options
// 找到全部的入口文件,存放在 entries 裏面
inflateEntries(this.entries, context, entry)
// 這裏訂閱了 compiler 的 entryOption 事件,當事件發生時,就會執行回調裏的代碼
compiler.hooks.entryOption.tap('MinaWebpackPlugin', () => {
this.entries
// 將文件的擴展名替換成 js
.map(item => replaceExt(item, '.js'))
// 把絕對路徑轉換成相對於 context 的路徑
.map(item => path.relative(context, item))
// 應用每個入口文件,就像手動配置的那樣
// 'app' : './app.js',
// 'pages/index/index': './pages/index/index.js',
// 'pages/logs/logs' : './pages/logs/logs.js',
.forEach(item => itemToPlugin(context, './' + item, replaceExt(item, '')).apply(compiler))
// 返回 true 告訴 webpack 內置插件就不要處理入口文件了,由於這裏已經處理了
return true
})
}
}
module.exports = MinaWebpackPlugin
複製代碼
該插件所作的事情,和咱們手動配置 entry 所作的如出一轍,經過代碼分析 .json 文件,找到全部可能的入口文件,添加到 webpack。
修改 webpack.config.js,應用該插件
+ const MinaWebpackPlugin = require('./plugin/MinaWebpackPlugin');
module.exports = {
context: resolve('src'),
- entry: {
- 'app' : './app.js',
- 'pages/index/index': './pages/index/index.js',
- 'pages/logs/logs' : './pages/logs/logs.js'
- },
+ entry: './app.js',
plugins: [
+ new MinaWebpackPlugin()
],
mode: 'none'
};
複製代碼
執行 npx webpack
,順利經過!
上面的插件代碼是否讀得不太懂?由於咱們尚未了解 webpack 的工做流程。
編程就是處理輸入和輸出的技術,webpack 比如一臺機器,entry 就是原材料,通過若干道工序(plugin, loader),產生若干中間產物 (dependency, module, chunk, assets),最終將產品放到 dist 文件夾中。
在講解 webpack 工做流程以前,請先閱讀官方編寫一個插件指南,對一個插件的構成,事件鉤子有哪些類型,如何觸及(訂閱),如何調用(發佈),有一個感性的認識。
咱們在講解 webpack 流程時,對理解咱們將要編寫的小程序 webpack 插件有幫助的地方會詳情講解,其它地方會簡略,若是但願對 webpack 流程有比較深入的理解,還須要閱讀其它資料以及源碼。
當咱們執行 npx webpack
這樣的命令時,webpack 會解析 webpack.config.js 文件,以及命令行參數,將其中的配置和參數合成一個 options 對象,而後調用 webpack 函數
// webpack.js
const webpack = (options, callback) => {
let compiler
// 補全默認配置
options = new WebpackOptionsDefaulter().process(options)
// 建立 compiler 對象
compiler = new Compiler(options.context)
compiler.options = options
// 應用用戶經過 webpack.config.js 配置或命令行參數傳遞的插件
if (options.plugins && Array.isArray(options.plugins)) {
for (const plugin of options.plugins) {
plugin.apply(compiler)
}
}
// 根據配置,應用 webpack 內置插件
compiler.options = new WebpackOptionsApply().process(options, compiler)
// compiler 啓動
compiler.run(callback)
return compiler
}
複製代碼
在這個函數中,建立了 compiler 對象,並將完整的配置參數 options 保存到 compiler 對象中,最後調用了 compiler 的 run 方法。
compiler 對象表明了完整的 webpack 環境配置。這個對象在啓動 webpack 時被一次性創建,並配置好全部可操做的設置,包括 options,loader 和 plugin。可使用 compiler 來訪問 webpack 的主環境。
從以上源碼能夠看到,用戶配置的 plugin 先於內置的 plugin 被應用。
WebpackOptionsApply.process 註冊了至關多的內置插件,其中有一個:
// WebpackOptionsApply.js
class WebpackOptionsApply extends OptionsApply {
process(options, compiler) {
new EntryOptionPlugin().apply(compiler)
compiler.hooks.entryOption.call(options.context, options.entry)
}
}
複製代碼
WebpackOptionsApply 應用了 EntryOptionPlugin 插件並當即觸發了 compiler 的 entryOption 事件鉤子,
而 EntryOptionPlugin 內部則註冊了對 entryOption 事件鉤子的監聽。
entryOption 是個 SyncBailHook, 意味着只要有一個插件返回了 true, 註冊在這個鉤子上的後續插件代碼,將不會被調用。咱們在編寫小程序插件時,用到了這個特性。
// EntryOptionPlugin.js
const itemToPlugin = (context, item, name) => {
if (Array.isArray(item)) {
return new MultiEntryPlugin(context, item, name)
}
return new SingleEntryPlugin(context, item, name)
}
module.exports = class EntryOptionPlugin {
apply(compiler) {
compiler.hooks.entryOption.tap('EntryOptionPlugin', (context, entry) => {
if (typeof entry === 'string' || Array.isArray(entry)) {
// 若是沒有指定入口的名字,那麼默認爲 main
itemToPlugin(context, entry, 'main').apply(compiler)
} else if (typeof entry === 'object') {
for (const name of Object.keys(entry)) {
itemToPlugin(context, entry[name], name).apply(compiler)
}
} else if (typeof entry === 'function') {
new DynamicEntryPlugin(context, entry).apply(compiler)
}
// 注意這裏返回了 true,
return true
})
}
}
複製代碼
EntryOptionPlugin 中的代碼很是簡單,它主要是根據 entry 的類型,把工做委託給 SingleEntryPlugin
, MultiEntryPlugin
以及 DynamicEntryPlugin
。
這三個插件的代碼也並不複雜,邏輯大體相同,最終目的都是調用 compilation.addEntry
,讓咱們來看看 SingleEntryPlugin 的源碼
// SingleEntryPlugin.js
class SingleEntryPlugin {
constructor(context, entry, name) {
this.context = context
this.entry = entry
this.name = name
}
apply(compiler) {
// compiler 在 run 方法中調用了 compile 方法,在該方法中建立了 compilation 對象
compiler.hooks.compilation.tap('SingleEntryPlugin', (compilation, { normalModuleFactory }) => {
// 設置 dependency 和 module 工廠之間的映射關係
compilation.dependencyFactories.set(SingleEntryDependency, normalModuleFactory)
})
// compiler 建立 compilation 對象後,觸發 make 事件
compiler.hooks.make.tapAsync('SingleEntryPlugin', (compilation, callback) => {
const { entry, name, context } = this
// 根據入口文件和名稱建立 Dependency 對象
const dep = SingleEntryPlugin.createDependency(entry, name)
// 隨着這個方法被調用,將會開啓編譯流程
compilation.addEntry(context, dep, name, callback)
})
}
static createDependency(entry, name) {
const dep = new SingleEntryDependency(entry)
dep.loc = { name }
return dep
}
}
複製代碼
那麼 make 事件又是如何被觸發的呢?當 WebpackOptionsApply.process 執行完後,將會調用 compiler 的 run 方法,而 run 方法又調用了 compile 方法,在裏面觸發了 make 事件鉤子,以下面代碼所示:
// webpack.js
const webpack = (options, callback) => {
// 根據配置,應用 webpack 內置插件,其中包括 EntryOptionPlugin,並觸發了 compiler 的 entryOption 事件
// EntryOptionPlugin 監聽了這一事件,並應用了 SingleEntryPlugin
// SingleEntryPlugin 監聽了 compiler 的 make 事件,調用 compilation 對象的 addEntry 方法開始編譯流程
compiler.options = new WebpackOptionsApply().process(options, compiler)
// 這個方法調用了 compile 方法,而 compile 觸發了 make 這個事件,控制權轉移到 compilation
compiler.run(callback)
}
複製代碼
// Compiler.js
class Compiler extends Tapable {
run(callback) {
const onCompiled = (err, compilation) => {
// ...
}
// 調用 compile 方法
this.compile(onCompiled)
}
compile(callback) {
const params = this.newCompilationParams()
this.hooks.compile.call(params)
// 建立 compilation 對象
const compilation = this.newCompilation(params)
// 觸發 make 事件鉤子,控制權轉移到 compilation,開始編譯流程
this.hooks.make.callAsync(compilation, err => {
// ...
})
}
newCompilation(params) {
const compilation = this.createCompilation()
// compilation 對象建立後,觸發 compilation 事件鉤子
// 若是想要監聽 compilation 中的事件,這是個好時機
this.hooks.compilation.call(compilation, params)
return compilation
}
}
複製代碼
webpack 函數建立了 compiler 對象,而 compiler 對象建立了 compilation 對象。compiler 對象表明了完整的 webpack 環境配置,而 compilatoin 對象則負責整個打包過程,它存儲着打包過程的中間產物。compiler 對象觸發 make 事件後,控制權就會轉移到 compilation,compilation 經過調用 addEntry 方法,開始了編譯與構建主流程。
如今,咱們有足夠的知識理解以前編寫的 webpack 插件了
// MinaWebpackPlugin.js
class MinaWebpackPlugin {
constructor() {
this.entries = []
}
apply(compiler) {
const { context, entry } = compiler.options
inflateEntries(this.entries, context, entry)
// 和 EntryOptionPlugin 同樣,監聽 entryOption 事件
// 這個事件在 WebpackOptionsApply 中觸發
compiler.hooks.entryOption.tap(pluginName, () => {
this.entries
.map(item => replaceExt(item, '.js'))
.map(item => path.relative(context, item))
// 和 EntryOptionPlugin 同樣,爲每個 entry 應用 SingleEntryPlugin
.forEach(item => itemToPlugin(context, './' + item, replaceExt(item, '')).apply(compiler))
// 和 EntryOptionPlugin 同樣,返回 true。因爲 entryOption 是個 SyncBailHook,
// 而自定義的插件先於內置的插件執行,因此 EntryOptionPlugin 這個回調中的代碼不會再執行。
return true
})
}
}
複製代碼
爲了動態註冊入口,除了能夠監聽 entryOption 這個鉤子外,咱們還能夠監聽 make 這個鉤子來達到一樣的目的。
addEntry
中調用了私有方法 _addModuleChain
,這個方法主要作了兩件事情。一是根據模塊的類型獲取對應的模塊工廠並建立模塊,二是構建模塊。
這個階段,主要是 loader 的舞臺。
class Compilation extends Tapable {
// 若是有留意 SingleEntryPlugin 的源碼,應該知道這裏的 entry 不是字符串,而是 Dependency 對象
addEntry(context, entry, name, callback) {
this._addModuleChain(context, entry, onModule, callbak)
}
_addModuleChain(context, dependency, onModule, callback) {
const Dep = dependency.constructor
// 獲取模塊對應的工廠,這個映射關係在 SingleEntryPlugin 中有設置
const moduleFactory = this.dependencyFactories.get(Dep)
// 經過工廠建立模塊
moduleFactory.create(/* 在這個方法的回調中, 調用 this.buildModule 來構建模塊。 構建完成後,調用 this.processModuleDependencies 來處理模塊的依賴 這是一個循環和遞歸過程,經過依賴得到它對應的模塊工廠來構建子模塊,直到把全部的子模塊都構建完成 */)
}
buildModule(module, optional, origin, dependencies, thisCallback) {
// 構建模塊
module.build(/* 而構建模塊做爲最耗時的一步,又可細化爲三步: 1. 調用各 loader 處理模塊之間的依賴 2. 解析經 loader 處理後的源文件生成抽象語法樹 AST 3. 遍歷 AST,獲取 module 的依賴,結果會存放在 module 的 dependencies 屬性中 */)
}
}
複製代碼
在全部的模塊構建完成後,webpack 調用 compilation.seal
方法,開始生成 chunks。
// https://github.com/webpack/webpack/blob/master/lib/Compiler.js#L625
class Compiler extends Tapable {
compile(callback) {
const params = this.newCompilationParams()
this.hooks.compile.call(params)
// 建立 compilation 對象
const compilation = this.newCompilation(params)
// 觸發 make 事件鉤子,控制權轉移到 compilation,開始編譯流程
this.hooks.make.callAsync(compilation, err => {
// 編譯和構建流程結束後,回到這裏,
compilation.seal(err => {})
})
}
}
複製代碼
每個入口起點、公共依賴、動態導入、runtime 抽離 都會生成一個 chunk。
seal
方法包含了優化、分塊、哈希,編譯中止接收新模塊,開始生成 chunks。此階段依賴了一些 webpack 內部插件對 module 進行優化,爲本次構建生成的 chunk 加入 hash 等。
// https://github.com/webpack/webpack/blob/master/lib/Compilation.js#L1188
class Compilation extends Tapable {
seal(callback) {
// _preparedEntrypoints 在 addEntry 方法中被填充,它存放着 entry 名稱和對應的 entry module
// 將 entry 中對應的 module 都生成一個新的 chunk
for (const preparedEntrypoint of this._preparedEntrypoints) {
const module = preparedEntrypoint.module
const name = preparedEntrypoint.name
// 建立 chunk
const chunk = this.addChunk(name)
// Entrypoint 繼承於 ChunkGroup
const entrypoint = new Entrypoint(name)
entrypoint.setRuntimeChunk(chunk)
entrypoint.addOrigin(null, name, preparedEntrypoint.request)
this.namedChunkGroups.set(name, entrypoint)
this.entrypoints.set(name, entrypoint)
this.chunkGroups.push(entrypoint)
// 創建 chunk 和 chunkGroup 之間的關係
GraphHelpers.connectChunkGroupAndChunk(entrypoint, chunk)
// 創建 chunk 和 module 之間的關係
GraphHelpers.connectChunkAndModule(chunk, module)
// 代表這個 chunk 是經過 entry 生成的
chunk.entryModule = module
chunk.name = name
this.assignDepth(module)
}
// 遍歷 module 的依賴列表,將依賴的 module 也加入到 chunk 中
this.processDependenciesBlocksForChunkGroups(this.chunkGroups.slice())
// 優化
// SplitChunksPlugin 會監聽 optimizeChunksAdvanced 事件,抽取公共模塊,造成新的 chunk
// RuntimeChunkPlugin 會監聽 optimizeChunksAdvanced 事件, 抽離 runtime chunk
while (
this.hooks.optimizeChunksBasic.call(this.chunks, this.chunkGroups) ||
this.hooks.optimizeChunks.call(this.chunks, this.chunkGroups) ||
this.hooks.optimizeChunksAdvanced.call(this.chunks, this.chunkGroups)
) {
/* empty */
}
this.hooks.afterOptimizeChunks.call(this.chunks, this.chunkGroups)
// 哈希
this.createHash()
// 經過這個鉤子,能夠在生成 assets 以前修改 chunks,咱們後面會用到
this.hooks.beforeChunkAssets.call()
// 經過 chunks 生成 assets
this.createChunkAssets()
}
}
複製代碼
Compilation 在實例化的時候,就會同時實例化三個對象:MainTemplate, ChunkTemplate,ModuleTemplate。這三個對象是用來渲染 chunk 對象,獲得最終代碼的模板。
class Compilation extends Tapable {
// https://github.com/webpack/webpack/blob/master/lib/Compilation.js#L2373
createChunkAssets() {
// 每個 chunk 會被渲染成一個 asset
for (let i = 0; i < this.chunks.length; i++) {
// 若是 chunk 包含 webpack runtime 代碼,就用 mainTemplate 來渲染,不然用 chunkTemplate 來渲染
// 關於什麼是 webpack runtime, 後續咱們在優化小程序 webpack 插件時會講到
const template = chunk.hasRuntime() ? this.mainTemplate : this.chunkTemplate
}
}
}
複製代碼
接下來咱們看 MainTemplate 是如何渲染 chunk 的。
// https://github.com/webpack/webpack/blob/master/lib/MainTemplate.js
class MainTemplate extends Tapable {
constructor(outputOptions) {
super()
this.hooks = {
bootstrap: new SyncWaterfallHook(['source', 'chunk', 'hash', 'moduleTemplate', 'dependencyTemplates']),
render: new SyncWaterfallHook(['source', 'chunk', 'hash', 'moduleTemplate', 'dependencyTemplates']),
renderWithEntry: new SyncWaterfallHook(['source', 'chunk', 'hash']),
}
// 自身監聽了 render 事件
this.hooks.render.tap('MainTemplate', (bootstrapSource, chunk, hash, moduleTemplate, dependencyTemplates) => {
const source = new ConcatSource()
// 拼接 runtime 源碼
source.add(new PrefixSource('/******/', bootstrapSource))
// 拼接 module 源碼,mainTemplate 把渲染模塊代碼的職責委託給了 moduleTemplate,自身只負責生成 runtime 代碼
source.add(this.hooks.modules.call(new RawSource(''), chunk, hash, moduleTemplate, dependencyTemplates))
return source
})
}
// 這是 Template 的入口方法
render(hash, chunk, moduleTemplate, dependencyTemplates) {
// 生成 runtime 代碼
const buf = this.renderBootstrap(hash, chunk, moduleTemplate, dependencyTemplates)
// 觸發 render 事件,請注意 MainTemplate 自身在構造函數中監聽了這一事件,完成了對 runtime 代碼和 module 代碼的拼接
let source = this.hooks.render.call(
// 傳入 runtime 代碼
new OriginalSource(Template.prefix(buf, ' \t') + '\n', 'webpack/bootstrap'),
chunk,
hash,
moduleTemplate,
dependencyTemplates,
)
// 對於每個入口 module, 即經過 compilation.addEntry 添加的模塊
if (chunk.hasEntryModule()) {
// 觸發 renderWithEntry 事件,讓咱們有機會修改生成後的代碼
source = this.hooks.renderWithEntry.call(source, chunk, hash)
}
return new ConcatSource(source, ';')
}
renderBootstrap(hash, chunk, moduleTemplate, dependencyTemplates) {
const buf = []
// 經過 bootstrap 這個鉤子,用戶能夠添加本身的 runtime 代碼
buf.push(this.hooks.bootstrap.call('', chunk, hash, moduleTemplate, dependencyTemplates))
return buf
}
}
複製代碼
所謂渲染就是生成代碼的過程,代碼就是字符串,渲染就是拼接和替換字符串的過程。
最終渲染好的代碼會存放在 compilation 的 assets 屬性中。
最後,webpack 調用 Compiler 的 emitAssets 方法,按照 output 中的配置項將文件輸出到了對應的 path 中,從而結束整個打包過程。
// https://github.com/webpack/webpack/blob/master/lib/Compiler.js
class Compiler extends Tapable {
run(callback) {
const onCompiled = (err, compilation) => {
// 輸出文件
this.emitAssets(compilation, err => {})
}
// 調用 compile 方法
this.compile(onCompiled)
}
emitAssets(compilation, callback) {
const emitFiles = err => {}
// 在輸入文件以前,觸發 emit 事件,這是最後能夠修改 assets 的機會了
this.hooks.emit.callAsync(compilation, err => {
outputPath = compilation.getPath(this.outputPath)
this.outputFileSystem.mkdirp(outputPath, emitFiles)
})
}
compile(onCompiled) {
const params = this.newCompilationParams()
this.hooks.compile.call(params)
// 建立 compilation 對象
const compilation = this.newCompilation(params)
// 觸發 make 事件鉤子,控制權轉移到 compilation,開始編譯 module
this.hooks.make.callAsync(compilation, err => {
// 模塊編譯和構建完成後,開始生成 chunks 和 assets
compilation.seal(err => {
// chunks 和 assets 生成後,調用 emitAssets
return onCompiled(null, compilation)
})
})
}
}
複製代碼
如今,回到咱們的小程序項目,確保 app.js 已經移除了下列代碼
// app.js
- import moment from 'moment';
- import { camelCase } from 'lodash';
App({
onLaunch: function () {
- console.log('-----------------------------------------------x');
- let sFromNowText = moment(new Date().getTime() - 360000).fromNow();
- console.log(sFromNowText);
- console.log(camelCase('OnLaunch'));
}
})
複製代碼
執行 npx webpack
,觀察生成的代碼
由 mainTemplate 生成的 webpackBootstrap 代碼就是 webpack runtime 的代碼,是整個應用的執行起點。moduleTemplate 則把咱們的代碼包裹在一個模塊包裝器函數中。
代碼行有 /******/
前綴的表示該行代碼由 mainTemplate 生成,有 /***/
前綴的表示該行代碼由 moduleTemplate 生成,沒有前綴的就是咱們編寫的通過 loader 處理後的模塊代碼。
咱們再來看看 dist/logs/logs.js 的代碼
能夠看到
一樣生成了 webpack runtime 代碼,
utils/util.js 中的代碼被合併到了 dist/logs/logs.js
logs.js 和 util.js 中的代碼分別被包裹在模塊包裝器中
哪些數字是什麼意思呢?它們表示模塊的 id。
從上面的代碼能夠看到,logs.js 經過 __webpack_require__(3)
導入了 id 爲 3 的模塊,這正是 util.js。
咱們不但願每一個入口文件都生成 runtime 代碼,而是但願將其抽離到一個單獨的文件中,以減小 app 的體積。咱們經過配置 runtimeChunk 來達到這一目的。
修改 webpack.config.js 文件,添加以下配置
module.exports = {
+ optimization: {
+ runtimeChunk: {
+ name: 'runtime'
+ }
+ },
mode: 'none'
}
複製代碼
執行 npx webpack
,
能夠看到,在 dist 目錄中,生成了名爲 runtime.js 的文件
這是一個 IIFE。
如今咱們開看看 dist/app.js
這彷佛是要把 app.js 模塊存放到全局對象 window 中,可是小程序中並無 window 對象,只有 wx。咱們在 webpack.config.js 中,把全局對象配置爲 wx
module.exports = {
output: {
path: resolve('dist'),
- filename: '[name].js'
+ filename: '[name].js',
+ globalObject: 'wx'
},
}
複製代碼
然而,仍是有問題,咱們的小程序已經跑不起來了
這是由於小程序和 web 應用不同,web 應用能夠經過 <script>
標籤引用 runtime.js,然而小程序卻不能這樣。
咱們必須讓其它模塊感知到 runtime.js 的存在,由於 runtime.js 裏面是個當即調用函數表達式,因此只要導入 runtime.js 便可。
咱們在 assets 渲染階段曾經提到過:
// 對於每個入口 module, 即經過 compilation.addEntry 添加的模塊
if (chunk.hasEntryModule()) {
// 觸發 renderWithEntry 事件,讓咱們有機會修改生成後的代碼
source = this.hooks.renderWithEntry.call(source, chunk, hash)
}
複製代碼
以前的 MinaWebpackPlugin 是用來處理 entry 的,這裏咱們遵循單一職責原則,編寫另外一個插件來處理 runtime。
幸運的是,已經有人寫好這樣一個插件了,咱們能夠直接拿來用 MinaRuntimePlugin。
爲了方便講解和學習,咱們將其中的代碼略做刪改,複製到 plugin/MinaRuntimePlugin.js 中
// plugin/MinaRuntimePlugin.js
/* * copied from https://github.com/tinajs/mina-webpack/blob/master/packages/mina-runtime-webpack-plugin/index.js */
const path = require('path')
const ensurePosix = require('ensure-posix-path')
const { ConcatSource } = require('webpack-sources')
const requiredPath = require('required-path')
function isRuntimeExtracted(compilation) {
return compilation.chunks.some(chunk => chunk.isOnlyInitial() && chunk.hasRuntime() && !chunk.hasEntryModule())
}
function script({ dependencies }) {
return ';' + dependencies.map(file => `require('${requiredPath(file)}');`).join('')
}
module.exports = class MinaRuntimeWebpackPlugin {
constructor(options = {}) {
this.runtime = options.runtime || ''
}
apply(compiler) {
compiler.hooks.compilation.tap('MinaRuntimePlugin', compilation => {
for (let template of [compilation.mainTemplate, compilation.chunkTemplate]) {
// 監聽 template 的 renderWithEntry 事件
template.hooks.renderWithEntry.tap('MinaRuntimePlugin', (source, entry) => {
if (!isRuntimeExtracted(compilation)) {
throw new Error(
[
'Please reuse the runtime chunk to avoid duplicate loading of javascript files.',
"Simple solution: set `optimization.runtimeChunk` to `{ name: 'runtime.js' }` .",
'Detail of `optimization.runtimeChunk`: https://webpack.js.org/configuration/optimization/#optimization-runtimechunk .',
].join('\n'),
)
}
// 若是不是入口 chunk,即不是經過 compilation.addEntry 添加的模塊所生成的 chunk,就不要管它
if (!entry.hasEntryModule()) {
return source
}
let dependencies = []
// 找到該入口 chunk 依賴的其它全部 chunk
entry.groupsIterable.forEach(group => {
group.chunks.forEach(chunk => {
/** * assume output.filename is chunk.name here */
let filename = ensurePosix(path.relative(path.dirname(entry.name), chunk.name))
if (chunk === entry || ~dependencies.indexOf(filename)) {
return
}
dependencies.push(filename)
})
})
// 在源碼前面拼接 runtime 以及公共代碼依賴
source = new ConcatSource(script({ dependencies }), source)
return source
})
}
})
}
}
複製代碼
修改 webpack.config.js,應用該插件
const MinaWebpackPlugin = require('./plugin/MinaWebpackPlugin');
+ const MinaRuntimePlugin = require('./plugin/MinaRuntimePlugin');
module.exports = {
plugins: [
new MinaWebpackPlugin(),
+ new MinaRuntimePlugin()
],
}
複製代碼
執行 npx webpack
,咱們的小程序此時應該能正常跑起來了。
查看 dist/app.js, dist/pages/index/index.js 等文件,它們的首行都添加了相似 ;require('./../../runtime');
的代碼。
到目前爲止,咱們每修改一次代碼,便執行一次 npx webpack
,這有些麻煩,能不能讓 webpack 檢測文件的變化,自動刷新呢?答案是有的。
webpack 能夠以 run 或 watchRun 的方式運行
// https://github.com/webpack/webpack/blob/master/lib/webpack.js#L62
const webpack = (options, callback) => {
if (options.watch === true || (Array.isArray(options) && options.some(o => o.watch))) {
const watchOptions = Array.isArray(options) ? options.map(o => o.watchOptions || {}) : options.watchOptions || {}
// 若是執行了 watch 就不會執行 run
return compiler.watch(watchOptions, callback)
}
compiler.run(callback)
return compiler
}
複製代碼
修改 plugin/MinaWebpackPlugin.js 文件
class MinaWebpackPlugin {
constructor() {
this.entries = []
}
applyEntry(compiler, done) {
const { context } = compiler.options
this.entries
.map(item => replaceExt(item, '.js'))
.map(item => path.relative(context, item))
.forEach(item => itemToPlugin(context, './' + item, replaceExt(item, '')).apply(compiler))
if (done) {
done()
}
}
apply(compiler) {
const { context, entry } = compiler.options
inflateEntries(this.entries, context, entry)
compiler.hooks.entryOption.tap('MinaWebpackPlugin', () => {
this.applyEntry(compiler)
return true
})
// 監聽 watchRun 事件
compiler.hooks.watchRun.tap('MinaWebpackPlugin', (compiler, done) => {
this.applyEntry(compiler, done)
})
}
}
複製代碼
執行 npx webpack --watch --progress
便可開啓 watch 模式,修改源代碼並保存,將會從新生成 dist。
webpack 能夠幫助咱們 ES6 轉 ES5,壓縮和混淆代碼,所以這些事情,不須要微信開發者工具幫咱們作了。點擊微信開發者工具右上角的詳情按鈕,在項目設置中,反勾選 ES6 轉 ES5,上傳代碼時自動壓縮混淆等選項,如圖所示:
修改 src/pages/index/index.js 文件,
+ const util = require('../../utils/util.js');
+ console.log(util.formatTime(new Date()));
const app = getApp();
複製代碼
執行 npx webpack
能夠看到,生成的 dist/pages/index/index.js 和 dist/pages/logs/logs.js 文件都有一樣的代碼
;(function(module, exports) {
// util.js
var formatTime = function formatTime(date) {
var year = date.getFullYear()
var month = date.getMonth() + 1
var day = date.getDate()
var hour = date.getHours()
var minute = date.getMinutes()
var second = date.getSeconds()
return [year, month, day].map(formatNumber).join('/') + ' ' + [hour, minute, second].map(formatNumber).join(':')
}
var formatNumber = function formatNumber(n) {
n = n.toString()
return n[1] ? n : '0' + n
}
module.exports = {
formatTime: formatTime,
}
})
複製代碼
這不是咱們但願看到的,咱們須要把這些公共代碼分離到一個獨立的文件當中,相關文檔看這裏。
修改 webpack.config.js 文件
optimization: {
+ splitChunks: {
+ chunks: 'all',
+ name: 'common',
+ minChunks: 2,
+ minSize: 0,
+ },
runtimeChunk: {
name: 'runtime',
},
},
複製代碼
執行 npx webpack
能夠看到 dist 目錄下生成了一個 common.js 文件,裏面有 util.js 的代碼,而 dist/pages/index/index.js 和 dist/pages/logs/logs.js 的首行代碼則導入了 common 文件:;require('./../../runtime');require('./../../common');
目前,咱們經過 npx webpack
生成的代碼都是未通過壓縮和優化的,稍不注意,就會超過微信 2M 大小的限制。
咱們能夠在生成 dist 代碼時,移除哪些咱們歷來沒有使用過的方法,這種方式叫作 tree shaking。就是把樹上的枯枝敗葉給搖下來,比喻移除無用的代碼。
請根據文檔指引進行配置,這裏不做展開。
下面咱們執行 npx webpack --mode=production
能夠看到生成的 app.js 文件大小還不到 1KB
下面,咱們引入個大文件
修改 src/app.js 文件,從新引入 lodash
// app.js
+ import { camelCase } from 'lodash';
App({
onLaunch: function () {
+ console.log('-----------------------------------------------x');
+ console.log(camelCase('OnLaunch'));
}
})
複製代碼
執行 npx webpack --mode=production
,能夠看到 app.js 文件有將近 70 KB 那麼大,爲了使用 lodash,這代價也太大了。不過幸虧有優化方法:
首先安裝如下兩個依賴
npm i --save-dev babel-plugin-lodash lodash-webpack-plugin
複製代碼
修改 webpack.config.js 文件
const MinaRuntimePlugin = require('./plugin/MinaRuntimePlugin');
+ const LodashWebpackPlugin = require('lodash-webpack-plugin');
new MinaRuntimePlugin(),
+ new LodashWebpackPlugin()
複製代碼
修改 .babelrc 文件
{
"presets": ["@babel/env"],
+ "plugins": ["lodash"]
}
複製代碼
再次執行 npx webpack --mode=production
,能夠看到 app.js 只有 4K 不到的大小了,所以咱們能夠愉快地使用 lodash 了。
至於 moment, 優化後仍是有將近 70K,對於小程序來講,這可能難以接受。Github 上有一個項目,叫作 You Dont Need Moment。
這裏的環境是指小程序的服務器地址,咱們的小程序,在開發時,在測試時,在發佈時,所須要訪問的服務器地址是不同的。咱們一般區分開發環境、測試環境、預發佈環境、生產環境等。
如今咱們來談談 mode,它一般被認爲和多環境配置有關。
咱們在 tree shaking 一節中已經對 mode 有所認識。
mode 有三個可能的值,分別是 production, development, none,小程序不能用 development,因此只有 production 和 none 這兩個值。
咱們看到 production 和 development 這樣的單詞時,很容易將它們和生產環境、開發環境關聯起來,這很容易形成誤解。
咱們除了須要區分環境,實際上還須要區分構建類型(release, debug)。
咱們應該把 mode 看做是構建類型的配置,而不是環境配置。
構建類型和環境能夠相互組合,譬如開發環境的 debug 包,生產環境的 debug 包,生產環境的 release 包等等。
因此最佳實踐應該是,使用 mode 來決定要不要打通過壓縮和優化的包,使用 EnvironmentPlugin 來配置多環境。
修改 webpack.config.js 文件
+ const webpack = require('webpack');
+ const debuggable = process.env.BUILD_TYPE !== 'release'
module.exports = {
plugins: [
+ new webpack.EnvironmentPlugin({
+ NODE_ENV: JSON.stringify(process.env.NODE_ENV) || 'development',
+ BUILD_TYPE: JSON.stringify(process.env.BUILD_TYPE) || 'debug',
+ }),
],
- mode: 'none',
+ mode: debuggable ? 'none' : 'production',
}
複製代碼
默認狀況下,webpack 會幫咱們把 process.env.NODE_ENV
的值設置成 mode 的值
使用 NODE_ENV 來區分環境類型是約定俗成的,看這裏
咱們在代碼中,能夠經過如下方式讀取這些環境變量
console.log(`環境:${process.env.NODE_ENV} 構建類型:${process.env.BUILD_TYPE}`)
複製代碼
咱們如何注入 NODE_ENV
這些變量的值呢?咱們藉助 npm scripts 來實現。webpack 官方文檔也有關於 npm scripts 的介紹,建議讀一讀。
首先安裝
npm i --save-dev cross-env
複製代碼
修改 package.json 文件,添加 scripts
{
"scripts": {
"start": "webpack --watch --progress",
"build": "cross-env NODE_ENV=production BUILD_TYPE=release webpack"
}
}
複製代碼
如今,可使用 npm run build
命令,來替代咱們以前使用的 npx webpack --mode=production
命令。
使用 npm start
來替代咱們以前使用的 npx webpack --watch --progress
命令。
開發時,爲了方便調試,上線時,爲了能定位到是哪行代碼出了問題,咱們都須要用到 source mapping。
微信小程序官方是這麼描述 source map 的,點擊這裏查看。
修改 webpack.config.js 文件
mode: debuggable ? 'none' : 'production',
+ devtool: debuggable ? 'inline-source-map' : 'source-map',
複製代碼
sass 是一種 css 預處理器,固然也可使用其它 css 預處理器,這裏僅以 sass 爲例,讀者很容易觸類旁通。
安裝相關依賴
npm i --save-dev sass-loader node-sass file-loader
複製代碼
修改 webpack.config.js 文件
module.exports = {
module: {
rules: [
+ {
+ test: /\.(scss)$/,
+ include: /src/,
+ use: [
+ {
+ loader: 'file-loader',
+ options: {
+ useRelativePath: true,
+ name: '[path][name].wxss',
+ context: resolve('src'),
+ },
+ },
+ {
+ loader: 'sass-loader',
+ options: {
+ includePaths: [resolve('src', 'styles'), resolve('src')],
+ },
+ },
+ ],
+ },
],
},
plugins: [
new CopyWebpackPlugin([
{
from: '**/*',
to: './',
- ignore: ['**/*.js', ],
+ ignore: ['**/*.js', '**/*.scss'],
},
]),
new MinaWebpackPlugin({
+ scriptExtensions: ['.js'],
+ assetExtensions: ['.scss'],
}),
],
}
複製代碼
在上面的配置中,咱們使用到了 file-loader, 這是一個能夠直接輸出文件到 dist 的 loader。
咱們在分析 webpack 工做流程時,曾經提到過,loader 主要工做在 module 構建階段。也就是說,咱們依然須要添加 .scss 文件做爲 entry,讓 loader 能有機會去解析它,並輸出最終結果。
每個 entry 都會對應一個 chunk, 每個 entry chunk 都會輸出一個文件。由於 file-loader 已經幫助咱們輸出最終咱們想要的結果了,因此咱們須要阻止這一行爲。
修改 plugin/MinaWebpackPlugin.js 文件,如下是修改後的樣子
// plugin/MinaWebpackPlugin.js
const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin')
const MultiEntryPlugin = require('webpack/lib/MultiEntryPlugin')
const path = require('path')
const fs = require('fs')
const replaceExt = require('replace-ext')
const assetsChunkName = '__assets_chunk_name__'
function itemToPlugin(context, item, name) {
if (Array.isArray(item)) {
return new MultiEntryPlugin(context, item, name)
}
return new SingleEntryPlugin(context, item, name)
}
function _inflateEntries(entries = [], dirname, entry) {
const configFile = replaceExt(entry, '.json')
const content = fs.readFileSync(configFile, 'utf8')
const config = JSON.parse(content)
;['pages', 'usingComponents'].forEach(key => {
const items = config[key]
if (typeof items === 'object') {
Object.values(items).forEach(item => inflateEntries(entries, dirname, item))
}
})
}
function inflateEntries(entries, dirname, entry) {
entry = path.resolve(dirname, entry)
if (entry != null && !entries.includes(entry)) {
entries.push(entry)
_inflateEntries(entries, path.dirname(entry), entry)
}
}
function first(entry, extensions) {
for (const ext of extensions) {
const file = replaceExt(entry, ext)
if (fs.existsSync(file)) {
return file
}
}
return null
}
function all(entry, extensions) {
const items = []
for (const ext of extensions) {
const file = replaceExt(entry, ext)
if (fs.existsSync(file)) {
items.push(file)
}
}
return items
}
class MinaWebpackPlugin {
constructor(options = {}) {
this.scriptExtensions = options.scriptExtensions || ['.ts', '.js']
this.assetExtensions = options.assetExtensions || []
this.entries = []
}
applyEntry(compiler, done) {
const { context } = compiler.options
this.entries
.map(item => first(item, this.scriptExtensions))
.map(item => path.relative(context, item))
.forEach(item => itemToPlugin(context, './' + item, replaceExt(item, '')).apply(compiler))
// 把全部的非 js 文件都合到同一個 entry 中,交給 MultiEntryPlugin 去處理
const assets = this.entries
.reduce((items, item) => [...items, ...all(item, this.assetExtensions)], [])
.map(item => './' + path.relative(context, item))
itemToPlugin(context, assets, assetsChunkName).apply(compiler)
if (done) {
done()
}
}
apply(compiler) {
const { context, entry } = compiler.options
inflateEntries(this.entries, context, entry)
compiler.hooks.entryOption.tap('MinaWebpackPlugin', () => {
this.applyEntry(compiler)
return true
})
compiler.hooks.watchRun.tap('MinaWebpackPlugin', (compiler, done) => {
this.applyEntry(compiler, done)
})
compiler.hooks.compilation.tap('MinaWebpackPlugin', compilation => {
// beforeChunkAssets 事件在 compilation.createChunkAssets 方法以前被觸發
compilation.hooks.beforeChunkAssets.tap('MinaWebpackPlugin', () => {
const assetsChunkIndex = compilation.chunks.findIndex(({ name }) => name === assetsChunkName)
if (assetsChunkIndex > -1) {
// 移除該 chunk, 使之不會生成對應的 asset,也就不會輸出文件
// 若是沒有這一步,最後會生成一個 __assets_chunk_name__.js 文件
compilation.chunks.splice(assetsChunkIndex, 1)
}
})
})
}
}
module.exports = MinaWebpackPlugin
複製代碼