相信開發(fā)過插件的同學,都看過Writing a Plugin 或類似的文章,因為 mini-program-webpack-loader 這個工具開發(fā)時正好 webpack 4 發(fā)布了,所以就閱讀了這篇文章,順便看了以下幾篇文檔。 如果你看過文檔,相信你一定知道:
如果感覺無從著手,可以繼續(xù)看看我是如何一步步開發(fā)并完善 mini-program-webpack-loader 來打包小程序的。 小程序有一個固定的套路,首先需要有一個 app.json 文件來定義所有的頁面路徑,然后每個頁面有四個文件組成:.js,.json,.wxml,.wxss。所以我以 app.json 作為 webpack entry,當 webpack 執(zhí)行插件的 apply 的時候,通過獲取 entry 來知道小程序都有哪些頁面。大概流程像下面一張圖,一個小程序打包插件差不多就這樣完成了。 這里使用了兩個插件 MultiEntryPlugin,SingleEntryPlugin。為什么要這樣做呢?因為 webpack 會根據你的 entry 配置(這里的 entry 不只是 webpack 配置里的 entry,import(), require.ensure() 都會生成一個 entry)來決定生成文件的個數,我們不希望把所有頁面的 js 打包到一個文件,需要使用 SingleEntryPlugin 來生成一個新的 entry module;而那些靜態(tài)資源,我們可以使用 MultiEntryPlugin 插件來處理,把這些文件作為一個 entry module 的依賴,在 loader 中配置 file-loader 即可把靜態(tài)文件輸出。偽代碼如下: 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); }) } } 復制代碼 當然,如果像上面那樣做,你會發(fā)現最后會多出一個 main.js,xxx.js(使用 MultiEntryPlugin 時填的名字),main.js 對應的是配置的 entry 生成的文件,xxx.js 則是 MultiEntryPlugin 生成的。這些文件不是我們需要的,所以需要去掉他。如果熟悉 webpack 文檔,我們有很多地方可以修改最終打包出來的文件,如 compiler 的 emit 事件,compilation 的 optimizeChunks 相關的事件都可以實現。其本質上就是去修改 compilation.assets 對象。 在 mini-program-webpack-loader 中就使用了 emit 事件來處理這種不需要輸出的內容。大概流程就像下面這樣: 小程序打包當然沒這么簡單,還得支持wxml、wxss、wxs和自定義組件的引用,所以這個時候就需要一個 loader 來完成了,loader 需要做的事情也非常簡單 —— 解析依賴的文件,如 .wxml 需要解析 import 組件的 src,wxs 的 src,.wxss 需要解析 @import,wxs 的 require,最后在 loader 中使用 loadModule 方法添加即可。自定義組件一開始在 add entry 步驟的時候直接獲取了,所以不需要 loader 來完成。這個時候的圖: 這樣做也沒什么問題,可是開發(fā)體驗是比較差的,如再添加一個自定義組件,一個頁面,webpack 是無感知的,所以需要在頁面中的 .json 發(fā)生改變時檢查是不是新增了自定義組件或者新增了頁面。這個時候遇到一個問題,自定義組件的 js 是不能通過 addModule 的方式來添加的,因為自定義組件的 js 必須作為獨立的入口文件。在 loader 中是做不了,所以嘗試把文件傳到 plugin 中(因為 plugin 先于 loader 執(zhí)行,所以是可以建立 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 時你會發(fā)現,沒效果。因為在 compiler make 之后 webpack 已經不能感知新的 module 添加了,所以是沒有用的,這個時候就需要根據文檔猜,怎么樣才能讓 webpack 感知到新的 module,根據文檔中的事件做關鍵字查詢,可以發(fā)現在編譯完成的時候會調用 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 事件鉤子,嘗試在這里添加文件,果然是可以的。這個時候的圖就成了這樣: 當然,小程序打包還有些不同的地方,比如分包,如何用好 splitchunk,就不在啰嗦了,當你開始以后你會發(fā)現有很多的方法來實現想要的效果。 插件開發(fā)到這里差不多了,總的來說,webpack 就是變著花樣的回調,當你知道每個回調該做什么的時候,webpack 用起來就輕松了。明顯我不知道,因為在開發(fā)過程中遇到了一些問題。 遇到的問題1.如何在小程序代碼中支持 resolve alias,node_modules? 既然是工具,當然需要做更多的事情,有贊的小程序那么復雜,如果支持 resolve alias,node_modules 可以使得項目更方便維護,或許你會說這不是 webpack 最基本的功能嗎,不是的,我們當然是希望可以在任何文件中使用 alias,node_modules 支持的不僅僅是 js。當然這樣做就意味著事情將變得復雜,首先就是獲取文件路徑,必須是異步的,因為在 webpack 4 中 resolve 不再支持 sync。其次就是小程序的目錄名不能是 node_modules,這時就需要一種計算相對路徑的規(guī)則,還是相對打包輸出的,而不是相對當前項目目錄。 2.多個小程序項目的合并 有贊從小程序來講,有微商城版,有零售版,以及公共版,其中大多基礎功能,業(yè)務都是相同的,當然不能再每個小程序在開發(fā)一次,所以這個工具具備合并多個小程序當然是必須的。這樣的合并稍微又要比從 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 來支持單文件的時候,好像沒那么方便:
|