使用 webpack 構建小程式專案

劉某發表於2019-03-03

本文內容

  • 如何開發 webpack 外掛及開發小程式打包外掛的過程
  • 在開發過程中遇到的問題以及如何解決的
  • mini-program-webpack-loader 可以做什麼

開發 webpack 外掛

相信開發過外掛的同學,都看過 Writing a Plugin 或類似的文章,因為 mini-program-webpack-loader 這個工具開發時正好 webpack 4 釋出了,所以就閱讀了這篇文章,順便看了以下幾篇文件。

如果你看過文件,相信你一定知道:

  • 每個外掛必須要有 apply 方法,用於 webpack 引擎執行你想要執行的程式碼。
  • 兩個重要的物件 Compiler 和 Compilation,你可以在上面繫結事件鉤子(webpack 執行到該步驟的時候呼叫),具體有哪些事件鉤子可以閱讀 Compiler hooks
  • module 和 chunk 的關係,我們可以理解為每個檔案都會有一個 module,而一個 chunk 則是由多個 module 來組成。
  • webpack 整個打包流程有那些事件
  • 如何寫一個簡單的 loader

如果感覺無從著手,可以繼續看看我是如何一步步開發並完善 mini-program-webpack-loader 來打包小程式的。

小程式有一個固定的套路,首先需要有一個 app.json 檔案來定義所有的頁面路徑,然後每個頁面有四個檔案組成:.js,.json,.wxml,.wxss。所以我以 app.json 作為 webpack entry,當 webpack 執行外掛的 apply 的時候,通過獲取 entry 來知道小程式都有哪些頁面。大概流程像下面一張圖,一個小程式打包外掛差不多就這樣完成了。

使用 webpack 構建小程式專案

這裡使用了兩個外掛 MultiEntryPlugin,SingleEntryPlugin。為什麼要這樣做呢?因為 webpack 會根據你的 entry 配置(這裡的 entry 不只是 webpack 配置裡的 entry,import(), require.ensure() 都會生成一個 entry)來決定生成檔案的個數,我們不希望把所有頁面的 js 打包到一個檔案,需要使用 SingleEntryPlugin 來生成一個新的 entry module;而那些靜態資源,我們可以使用 MultiEntryPlugin 外掛來處理,把這些檔案作為一個 entry module 的依賴,在 loader 中配置 file-loader 即可把靜態檔案輸出。虛擬碼如下:

 const MultiEntryPlugin = require('webpack/lib/MultiEntryPlugin');
 const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin');
 
 class MiniPlugin {
    apply (compiler) {
        let options = compiler.options
        let context = compiler.rootContext
        let entry = options.entry
        let files = loadFiles(entry)
        let scripts = files.filter(file => /\.js$/.test(file))
        let assets = files.filter(file => !/\.js$/.test(file))
        
       new  MultiEntryPlugin(context, assets, '__assets__').apply(compiler)
        
        scripts.forEach((file => {
            let fileName = relative(context, file).replace(extname(file), '');
            new SingleEntryPlugin(context, file, fileName).apply(compiler);
        })
    }
 }
複製程式碼

當然,如果像上面那樣做,你會發現最後會多出一個 main.js,xxx.js(使用 MultiEntryPlugin 時填的名字),main.js 對應的是配置的 entry 生成的檔案,xxx.js 則是 MultiEntryPlugin 生成的。這些檔案不是我們需要的,所以需要去掉他。如果熟悉 webpack 文件,我們有很多地方可以修改最終打包出來的檔案,如 compiler 的 emit 事件,compilation 的 optimizeChunks 相關的事件都可以實現。其本質上就是去修改 compilation.assets 物件。

在 mini-program-webpack-loader 中就使用了 emit 事件來處理這種不需要輸出的內容。大概流程就像下面這樣:

使用 webpack 構建小程式專案

小程式打包當然沒這麼簡單,還得支援wxml、wxss、wxs和自定義元件的引用,所以這個時候就需要一個 loader 來完成了,loader 需要做的事情也非常簡單 —— 解析依賴的檔案,如 .wxml 需要解析 import 元件的 src,wxs 的 src,.wxss 需要解析 @import,wxs 的 require,最後在 loader 中使用 loadModule 方法新增即可。自定義元件一開始在 add entry 步驟的時候直接獲取了,所以不需要 loader 來完成。這個時候的圖:

使用 webpack 構建小程式專案

這樣做也沒什麼問題,可是開發體驗是比較差的,如再新增一個自定義元件,一個頁面,webpack 是無感知的,所以需要在頁面中的 .json 發生改變時檢查是不是新增了自定義元件或者新增了頁面。這個時候遇到一個問題,自定義元件的 js 是不能通過 addModule 的方式來新增的,因為自定義元件的 js 必須作為獨立的入口檔案。在 loader 中是做不了,所以嘗試把檔案傳到 plugin 中(因為 plugin 先於 loader 執行,所以是可以建立 loader 和 plugin 通訊的)。簡單粗暴的方式:

// loader.js
class MiniLoader {}

module.exports = function (content) {
    new MiniLoader(this, content)
}
module.exports.$applyPluginInstance = function (plugin) {
  MiniLoader.prototype.$plugin = plugin
}

// plugin.js
const loader = require('./loader')
class MiniPlugin {
    apply (compiler) {
        loader.$applyPluginInstance(this);
    }
}
複製程式碼

但是...。檔案是傳到 plugin 了,可是再使用 SingleEntryPlugin 時你會發現,沒效果。因為在 compiler make 之後 webpack 已經不能感知新的 module 新增了,所以是沒有用的,這個時候就需要根據文件猜,怎麼樣才能讓 webpack 感知到新的 module,根據文件中的事件做關鍵字查詢,可以發現在編譯完成的時候會呼叫 compilation needAdditionalPass 事件鉤子:

    this.emitAssets(compilation, err => {
    	if (err) return finalCallback(err);
    
    	if (compilation.hooks.needAdditionalPass.call()) {
    		compilation.needAdditionalPass = true;
    
    		const stats = new Stats(compilation);
    		stats.startTime = startTime;
    		stats.endTime = Date.now();
    		this.hooks.done.callAsync(stats, err => {
    			if (err) return finalCallback(err);
    
    			this.hooks.additionalPass.callAsync(err => {
    				if (err) return finalCallback(err);
    				this.compile(onCompiled);
    			});
    		});
    		return;
    	}
    
    	this.emitRecords(err => {
    		if (err) return finalCallback(err);
    
    		const stats = new Stats(compilation);
    		stats.startTime = startTime;
    		stats.endTime = Date.now();
    		this.hooks.done.callAsync(stats, err => {
    			if (err) return finalCallback(err);
    			return finalCallback(null, stats);
    		});
    	});
    });
複製程式碼

如果在這個事件鉤子返回一個 true 值,則可以使 webpack 呼叫 compiler additionalPass 事件鉤子,嘗試在這裡新增檔案,果然是可以的。這個時候的圖就成了這樣:

使用 webpack 構建小程式專案

當然,小程式打包還有些不同的地方,比如分包,如何用好 splitchunk,就不在囉嗦了,當你開始以後你會發現有很多的方法來實現想要的效果。

外掛開發到這裡差不多了,總的來說,webpack 就是變著花樣的回撥,當你知道每個回撥該做什麼的時候,webpack 用起來就輕鬆了。明顯我不知道,因為在開發過程中遇到了一些問題。

遇到的問題

1.如何在小程式程式碼中支援 resolve alias,node_modules?

既然是工具,當然需要做更多的事情,有讚的小程式那麼複雜,如果支援 resolve alias,node_modules 可以使得專案更方便維護,或許你會說這不是 webpack 最基本的功能嗎,不是的,我們當然是希望可以在任何檔案中使用 alias,node_modules 支援的不僅僅是 js。當然這樣做就意味著事情將變得複雜,首先就是獲取檔案路徑,必須是非同步的,因為在 webpack 4 中 resolve 不再支援 sync。其次就是小程式的目錄名不能是 node_modules,這時就需要一種計算相對路徑的規則,還是相對打包輸出的,而不是相對當前專案目錄。

2.多個小程式專案的合併

有贊從小程式來講,有微商城版,有零售版,以及公共版,其中大多基礎功能,業務都是相同的,當然不能再每個小程式在開發一次,所以這個工具具備合併多個小程式當然是必須的。這樣的合併稍微又要比從 node_modules 中取檔案複雜一些,因為需要保證多個小程式合併後的頁面是正確的,而且要保證路徑不變。

這兩個問題的最終的解決方案既是以 webpack rootContext 的 src 目錄為基準目錄,以該目錄所在路徑計算打包檔案的絕對路徑,然後根據入口檔案的 app.json 所在目錄的路徑計算出最終輸出路徑。


exports.getDistPath = (compilerContext, entryContexts) => {
  /**
   * webpack 以 config 所在目錄的 src 為打包入口
   * 所以可以根據該目錄追溯原始檔地址
   */
  return (path) => {
    let fullPath = compilerContext
    let npmReg = /node_modules/g
    let pDirReg = /^[_|\.\.]\//g

    if (isAbsolute(path)) {
      fullPath = path
    } else {
      // 相對路徑:webpack 最後生成的路徑,打包入口外的檔案都以 '_' 表示上級目錄

      while (pDirReg.test(path)) {
        path = path.substr(pDirReg.lastIndex)
        fullPath = join(fullPath, '../')
      }

      if (fullPath !== compilerContext) {
        fullPath = join(fullPath, path)
      }
    }
    // 根據 entry 中定義的 json 檔案目錄獲取打包後所在目錄,如果不能獲取就返回原路徑
    let contextReg = new RegExp(entryContexts.join('|'), 'g')

    if (fullPath !== compilerContext && contextReg.exec(fullPath)) {
      path = fullPath.substr(contextReg.lastIndex + 1)
      console.assert(!npmReg.test(path), `檔案${path}路徑錯誤:不應該還包含 node_modules`)
    }

    /**
     * 如果有 node_modules 字串,則去模組名稱
     * 如果 app.json 在 node_modules 中,那 path 不應該包含 node_modules 
     */

    if (npmReg.test(path)) {
      path = path.substr(npmReg.lastIndex + 1)
    }

    return path
  }
}
複製程式碼

3.如何把子包單獨依賴的內容打包到子包內

解決這個問題的方法是通過 optimizeChunks 事件,在每個 chunk 的依賴的 module 中新增這個 chunk 的入口檔案,然後在 splitChunk 的 test 配置中檢查 module 被依賴的數量。如果只有一個,並且是被子包依賴,則打包到子包內。

4.webpack 支援單檔案失敗

這是一個未解決的問題,當嘗試使用 webpack 來支援單檔案的時候,好像沒那麼方便:

  • 單檔案拆分為四個檔案後,可以使用 emitFile 和 addDependency 來建立檔案,但是建立的檔案不會執行 loader
  • 使用 loadModule 會因為檔案系統不存在該檔案會報錯

寫在最後

最後當然是介紹 mini-program-webpack-loader 可以做什麼了。

該工具主要解決以下問題:

  • 小程式不支援 npm
  • 目錄巢狀太深,路徑難以管理
  • 舊專案太大,想要使用新工具成本太高
  • 提供大型小程式專案中常規優化建議

重複一遍

  • 你可以直接使用 npm 來下載 zanui-weapp
  • 你可以告別 “../../../../../xxx” 了
  • 你可以使用 mpVue,taro 來寫新功能了,不用擔心不相容

最後的最後留下文件地址:mini-program-webpack-loader

相關文章