san-hot-loader 应用及原理实现
作者:潘銘
1. san-hot-loader 使用介紹
模塊熱替換(hot module replacement,簡寫 HMR)允許在運行時更新模塊,而無需刷新頁面,在開發環境引入 HMR 可以極大提升開發體驗。webpack 提供了熱替換 HMR 的接口,san-hot-loader 使用 webpack HMR API,針對 San 框架實現了 San 組件和 San Store 的熱更新功能。
啟用 HMR
webpack HMR 功能啟用后,san-hot-loader 才能利用 HMR API 發揮熱更新作用。因此,當我們想要在項目開發過程中使用 San 熱更新功能時,需要先配置項目的 webpack HMR 功能。webpack-dev-server 支持 hot 模式,在試圖重新加載整個頁面之前,hot 模式下會嘗試使用 HMR 來更新。啟用 HMR 最簡單常用的方式是在 devServer 中添加?hot?配置:
devServer: {contentBase: path.resolve(__dirname, 'dist'),hot: true },以下介紹兩種使用 San 框架進行開發的過程中常用的配置方式。
使用 San CLI
San CLI 是一個內置了 webpack 的前端工程化命令行工具,使用 San CLI 創建的工程中已經集成了 san-hot-loader,開箱即用,具體的配置可在項目目錄執行?san inspect?命令來查看。更多 San CLI 文檔詳見:走進 San CLI(上):使用介紹
使用 webpack
除此之外,當然也可以不使用 San CLI 而直接使用 webpack。首先需要在 webpack 配置文件當中添加 san-hot-loader 相關配置信息,在啟動 webpack-dev-server 進行代碼調試的時候,使 San 組件與 San Store 的熱更新功能生效。有關配置如下所示:
module.exports = {// ... 其他的 webpack 配置信息module: {rules: [{test: /\.js$/,use: ['san-hot-loader']}// ... 其他的 loader 信息]} }當項目代碼使用了 ES7 及以上的語法時,通常需要 babel-loader 將代碼進行轉換成 ES5 語法,這個轉換過程可能會帶來額外的 Babel Helper、Polyfill 代碼的注入,在這種情況下,san-hot-loader 同時也提供了 babel 插件來實現熱更新代碼注入:
module.exports = {// ... 其他的 webpack 配置信息module: {rules: [{test: /\.js$/,use: [{loader: 'babel-loader',options: {plugins: [// 通過 babel plugin 的形式添加require.resolve('san-hot-loader/lib/babel-plugin')]}}]}// ... 其他的 loader 信息]} }san-hot-loader 提供一系列個性化配置,由 webpack loader 的配置項傳入,可以根據項目需求靈活配置。例如:
// webpack loader 配置 module.exports = {// ...module: {rules: [// ...{test: /\.js$/,use: [{loader: 'san-hot-loader',options: {enable: process.env.NODE_ENV === 'development',component: {patterns: [/\.san\.js$/,'auto']},store: {patterns: [function (resourcePath) {return /\.store\.js$/.test(resourcePath);},'auto']}}}]}]} }2. san-hot-loader 原理實現
簡單說完使用方法,再重點看看 san-hot-loader 的原理和實現。
對模塊來說,HMR 是可選功能,只會影響包含 HMR 代碼的模塊,如果模塊沒有 HMR 的處理,則模塊上產生的更新操作會以持續冒泡的形式影響整個模塊樹。san-hot-loader 實現了 HMR 接口,針對 San 組件和 San Store 分別描述了模塊被更新后會發生什么,以此來實現組件和 store 的熱更新。既然它生效依賴 webpack HMR,那我們首先簡單介紹下 webpack HMR 的工作原理。
2.1 HMR 工作原理
HMR 的工作原理如下:
HMR 的工作過程可以簡化如下:
首先,在 watch 模式下,webpack 監聽到文件變化后對模塊重新編譯打包,將代碼打包到內存中。
webpack-dev-server 會監聽打包后的靜態文件的變化,并與瀏覽器端建立 WebSocket 長連接,將 webpack 監聽靜態文件變化的信息告知瀏覽器端。
HMR 客戶端根據這些 WebSocket 消息進行操作。在 HMR 中,WebSocket 傳遞的服務端消息最主要信息是新模塊的 hash 值和更新指令。
HMR 雙端確定需要進行 HMR 更新后,HMR 客戶端向 HMR server 端發送 Ajax 請求,返回包含所有要更新的模塊 hash 值的 json。例如,收到上圖消息后發出 Ajax 請求返回值:?
獲取到更新列表后,HMR 客戶端通過 jsonp 請求,拿到最新的模塊代碼,再對比新舊模塊,更新模塊及其依賴。
當 第 6 步 HMR 失敗后,回退到 live reload 操作,瀏覽器通過刷新頁面來獲取最新打包代碼。
了解 webpack HMR 工作過程之后,我們再來看 san-hot-loader 的設計思路。
2.2 san-hot-loader 設計思路
webpack 提供了一系列供開發者使用的 HMR API,分為?Module API?和?Management API?兩部分,分別用于模塊處理和 HMR 狀態管理。在 San 中,需要更新的模塊有 San 組件(component)和 San Store (store) 兩種,在 HMR 中都屬于 “模塊”,因此在 san-hot-loader 中主要使用的是?Module API。HMR API 詳細文檔見?Hot Module Replacement[3]
san-hot-loader 通過 HMR 接收更新,判斷需要更新的文件是 component 還是 store,根據不同的文件調用不同的處理方法,使用?Module API?完成 HMR 新模塊替換舊的模塊的更新操作,其簡化的工作流程如下:
san-hot-loader 提供了?webpack loader?和?babel plugin?兩種使用方式,二者入口文件不同,實現方法稍有差異,但核心實現邏輯是相同的。接下來這部分就以?index.js(lib/loader.js)?作為入口,介紹 san-hot-loader 中的一些執行邏輯。
入口文件中,首先獲取到當前的 loader 配置,在 HMR 選項開啟的情況下,通過?getHandler?方法將處理方式分成了 component 和 store 兩種,并連同配置項以及 webpack loader 傳入的代碼和 sourceMap 一并交給了各自匹配的 handler 處理,San Component 交給?ComponentHandler,San Store 由?StoreHandler?處理,component 和 store 各自內部的處理邏輯相似,以 component 模塊為例,它的處理方式如下:?
?
在?ComponentHandler(component/component-hmr-handler.js)?class 中,關鍵的功能點有兩處:匹配代碼模塊和向組件中注入 HMR 監聽代碼。接下來一節通過分析一些關鍵代碼,帶領大家了解 san-hot-loader 是如何實現 HMR 功能的。
2.3 san-hot-loader 關鍵代碼解析
通過上述介紹,HMR 功能僅對經過 HMR 處理的模塊生效,未經 HMR 處理的模塊上發生的更新操作會以冒泡的形式向上層模塊傳遞,因此,『如何準確地匹配出 San 組件模塊和 San Store 模塊』、以及『如何使模塊中注入 HMR 代碼完成模塊熱更新』就是 san-hot-loader 實現過程中的兩個關鍵問題。
2.3.1 匹配代碼模塊
ComponentHandler?class 內部實現?match?方法來匹配 component 時,需要判斷?pattern?和特殊格式注釋兩個部分。pattern?是 webpack 傳入的配置,可以由用戶在項目的?config?中配置,默認值為?auto?,也可以配置為正則和處理?resourcePath?的?function,而使用中遇到特殊情況,例如文件不希望被熱更新、或文件希望被熱更新但不滿足匹配條件時,則需要通過使用特殊格式注釋來開啟 HMR 功能。
if (hasComment(ast, 'san-hmr disable')) {// 用戶使用特殊格式注釋關閉 HMRreturn false; }for (let pattern of this.options.patterns) {let tester = pattern && pattern.component || pattern;if (tester === 'auto') {// 默認配置下利用 AST 來判斷if (matchByAst(ast)) {return true;}}else if (tester instanceof RegExp) {if (tester.test(this.resourcePath)) {return true;}}else if (tester instanceof Function) {if (tester(this.resourcePath)) {return true;}} }if (!hasModuleHot(ast) && hasComment(ast, 'san-hmr component')) {return true; }當?pattern === 'auto'?時,?ComponentHandler?實例使用為 San component 封裝的?matchByAst?方法來匹配代碼模塊是否為 San 組件,代碼文件為?component/match-by-ast.js,節選關鍵代碼如下:
// 判斷是否引入 'san' 模塊 if (!isModuleImported(ast, 'san')) {return false; }// 判斷是否有默認導出模塊 const defaultModule = getExportDefault(ast); if (!defaultModule) {return false; }let trackers = getTopLevelIdentifierTracker(ast, defaultModule); if (!trackers) {return false; }// 判斷是否為 San 組件 let component = getSanStoreConnectComponent(ast, trackers[0]) || trackers[0]; return isSanComponent(ast, component);創建一個 San 組件首先需要引入?san?模塊,然后使用?san?模塊所提供的 API 進行組件定義,并將定義好的組件作為默認模塊導出。文件需要同時滿足上述條件時才會進一步判斷是 San 組件還是結合了 San Store 的 San 組件。
2.3.2 向模塊中注入 HMR 監聽代碼
向模塊中注入 HMR 監聽代碼是實現 HMR 的重要步驟,以 San 組件為例,注入 HMR 代碼的方式如下:
async genCode() {const hmrCode = this.genHmrCode();const source = this.source;if (!this.needMap) {return {code: source + hmrCode};}// 注入 HMR 代碼const result = await append(source, hmrCode, {inputSourceMap: this.inputSourceMap,resourcePath: this.resourcePath});return result; }genHmrCode() {return tpl({resourcePath: this.resourcePath}); }至此?ComponentHandler?和?StoreHandler?的處理方式都相差不大,使用各自封裝的?tpl?方法返回 調用 HMR API 實現局部熱更新的代碼,并將代碼注入到用戶代碼中。
但組件和 store 二者是不同的模塊,處理起來也有差異。接下來我們分別看一下組件和 store 是如何處理的。
San 組件熱更新處理
對組件來說,實現熱更新需要從組件的生命周期入手,在恰當的時機卸載舊組件,并將新組件掛載到舊組件原來的位置,完成組件的替代。在?ComponentHandler?封裝的?tpl?方法中,調用 HMR API 實現熱更新的代碼是這樣封裝的:
module.exports = function ({resourcePath }) {const context = path.dirname(resourcePath);const componentId = genId(resourcePath, context);return `if (module.hot) {var __HOT_API__ = require('${componentHmrPath}');var __HOT_UTILS__ = require('${utilsPath}');var __SAN_COMPONENT__ = __HOT_UTILS__.getExports(module);if (__SAN_COMPONENT__.template || __SAN_COMPONENT__.prototype.template) {module.hot.accept();__HOT_API__.install(require('san'));var __HMR_ID__ = '${componentId}';if (!module.hot.data) {__HOT_API__.createRecord(__HMR_ID__, __SAN_COMPONENT__);}else {__HOT_API__.hotReload(__HMR_ID__, __SAN_COMPONENT__);}}}`; };module.hot?是?HotModuleReplacementPlugin?暴露出的 API,用于處理模塊。
此處__HOT_API__?是 san-hot-loader 中為 San Component 封裝的一套?真正?實現組件熱更新的 runtime API,提供?install、createRecord、hotReload?三個方法,是 san-hot-loader 的核心代碼之一。在通過?HotModuleReplacementPlugin?暴露出的?module.hot.data?拿到熱更新代碼后,調用?hotReload?方法實現舊組件卸載和新組件掛載,其核心代碼如下:
if (recANode != null) {recDesc.proto.aNode = recANode;recDesc.proto._cmptReady = recCmptReady;instance.dispose();newDesc.proto.aNode = newANode;newDesc.proto._cmptReady = newCmptReady;newInstance = new newDesc.Ctor(options);newInstance.attach(parentEl, beforeEl); } else {instance.dispose();newInstance = new newDesc.Ctor(options);newInstance.attach(parentEl, beforeEl); }San Store 熱更新處理
與組件不同的是,store 是有狀態的,實現熱更新需要保存當前的 store 狀態,代碼更新之后恢復 store 狀態。san-hot-loader 為 San Store 提供了?StoreHandler?來實現 action 的熱更新功能,即在修改 San Store 注冊 action 的文件時,store 與組件所保存的狀態都不會丟失,并且在觸發 action 時自動使用最新的 action。?StoreHandler?實現熱更新也需要匹配 San Store 模塊和注入 HMR 代碼兩部分,和?ComponentHandler?差別不大,使用?StoreHandler?內部封裝的 tpl 方法生成 調用 HMR API 實現局部熱更新的代碼,如下:
module.exports = function ({resourcePath }) {const context = path.dirname(resourcePath);const id = genId(resourcePath, context);return `if (module.hot) {var __SAN_STORE_ID__ = '${id}';var __SAN_STORE_CLIENT_API__ = require('${storeClientApiPath}');var __UTILS__ = require('${runtimeUtilPath}');module.hot.accept();var __SAN_STORE_INSTANCE__ = __UTILS__.getExports(module) || require('san-store').store;__SAN_STORE_CLIENT_API__.update(__SAN_STORE_ID__, __SAN_STORE_INSTANCE__);}`; };此處的?__SAN_STORE_CLIENT_API__?是 san-hot-loader 中為 San Store 封裝的一套真正實現組件熱更新的 runtime API,只提供了?update?一個方法來進行 store 更新,在需要更新時保存 store 已有的狀態和 action,使 store 與組件所保存的狀態都不會丟失,并且在觸發 action 時自動使用最新的 action。這也是 san-hot-loader 的核心代碼之一,其核心代碼如下:
function updateStore(id, store) {if (!storeCache[id]) {wrapStore(id, store);storeCache[id] = store;initDataCache[id] = deepClone(store.raw);done(id);return;}if (storeCache[id] === store) {done(id);return;}var newData = store.raw;var newActions = store.actions;var savedStore = storeCache[id];var savedData = initDataCache[id];var actionNames = Object.keys(newActions);for (var i = 0; i < actionNames.length; i++) {var name = actionNames[i];savedStore.addAction(name, newActions[name]);}done(id); }關鍵代碼介紹至此,可以看出在『2.3.1 匹配代碼模塊』小節中,我們使用的?matchByAst?方法,才是用于匹配 San 組件和 San Store 模塊的核心所在。接下來,我們就重點介紹下 san-hot-loader 中如何識別 San 組件和 San Store 模塊。
2.4 如何識別 San 組件和 San Store 模塊
對 san-hot-loader 來說,在對代碼模塊進行 HMR 處理,調用 HMR API 實現熱更新之前,最重要的就是如何識別 San 組件和 San Store 模塊。
在默認情況下,san-hot-loader 自動開啟對 San 組件與 San Store 模塊的熱更新代碼注入,通過自動檢測的手段來判斷哪些是 San 組件,哪些是 San Store 模塊,讓 HMR 僅作用于該作用的模塊,因此需要一些手段來判斷哪些是 San 組件,哪些是 San Store 模塊。
san-hot-loader 文檔中提到,如果想讓熱更新,文件需要滿足以下任意一個熱更新的條件:
-
文件是常規 San 組件
-
文件是結合了 San Store 的組件
-
文件是 San Store 模塊
-
特殊注釋
我們首先來看 san-hot-loader 是如何識別常規 San 組件的。
2.4.1 識別 San 組件
一個常規的 San 組件寫法可能是這樣:
import {Component} from 'san'; export default class App extends Component {static template = '<p>Hello {{name}}</p>';initData() {return {name: 'San'}; } }能被 san-hot-loader 識別的常規 san 組件也可以用?san.defineComponent?或者 ES5 Function Constructor 等方式來定義,只需包含以下特征:
文件引入?san?模塊(import、require)
使用?san?模塊所提供的 API(defineComponent、Component)定義組件
將定義好的組件作為默認模塊導出(export default、module.exports)
而當項目使用 San Store 時,需要熱更新的 San 組件也可以是結合了 San Store 的組件:
import {defineComponent} from 'san'; import {connect} from 'san-store'; import store from './store';const App = defineComponent({template: '<p>Hello {{name}}</p>',initData: function () {return {name: 'San'};} }); // connect 到自定義 storeconst connector = connect.createConnector(store);const NewApp = connector({name: 'name'})(App);export default NewApp;根據相關文檔的說明,結合了 San Store 的組件書寫時需要使用 San Store 提供的?connect?方法將狀態源與組件關聯起來。這種情況下,san-hot-loader 對于滿足以下特征的文件,也同樣能夠識別為 San 組件:
文件引入?san-store(import、require)
使用?san-store?提供的?connect.san?或?connect.createConnector(store)?得到的方法連接 store 與當前組件,獲得新的組件
將得到的新組件作為默認模塊導出(export default、module.exports)
根據以上規則,在上述 san-hot-loader 實現時使用的?isSanComponent?內部邏輯如下:
function isSanComponent(ast, node) {let trackers = getTopLevelIdentifierTracker(ast, node);let apiName;let subNode;let component = trackers[0];// san.defineComponent({})if (component.type === 'CallExpression') {apiName = 'defineComponent';subNode = component.callee;}// class Comp extends san.Component {}else if (component.type === 'ClassDeclaration' || component.type === 'ClassExpression') {apiName = 'Component';subNode = component.superClass;}// function Comp(options) { san.Component(this, options) }else if (component.type === 'FunctionDeclaration'&& component.params.length === 1) {for (let statement of component.body.body) {if (statement.type === 'ExpressionStatement'&& statement.expression.type === 'CallExpression'&& statement.expression.arguments.length === 2&& statement.expression.arguments[0]&& statement.expression.arguments[0].type === 'ThisExpression'&& statement.expression.arguments[1]&& (statement.expression.callee.type === 'Identifier'|| statement.expression.callee.type === 'MemberExpression')) {apiName = 'Component';subNode = statement.expression.callee;}}}return isImportedAPI(ast, subNode, 'san', apiName); }2.4.2 識別 San Store 文件
說完 San 組件,再來看看 San Store。San Store 提供了默認 store 和自定義 store 兩種。對默認 store 而言,使用時可以通過?import {store} from 'san-store'?獲取該實例對象,調用其?addAction?方法即可完成對默認 store 的 action 注冊。
一個簡單的對默認 store 實例注冊 action 的代碼如下所示:
// register-store-actions.js import {store} from 'san-store'; import {builder} from 'san-update'; store.addAction('increase', function (num) {builder().set('num', num + 1); });它具有以下特征:
-
文件引入?san-store;
-
使用?san-store?提供的?store.addAction?方法注冊 action;
-
文件不存在任何模塊導出(export、export default、module.exports、exports.xxx);
而一個簡單的自定義 store 代碼如下所示:
import {Store} from 'san-store'; import {builder} from 'san-update';export default new Store({initData: {num: 0},actions: {increase(num) {return builder().set('num', num + 1);}} });可以看出自定義 store 的文件應具有以下特征:
-
文件引入?san-store
-
使用?san-store?提供的?Store?方法實例化自定義 store
-
將自定義 store 以默認模塊導出
San Store 的變化不如 San Component 多樣,因此在匹配 San Store 模塊時,判斷的方式也簡單很多。判斷默認 store 的方法是從?import {store} from 'san-store'?入手,且它無需使用?export?等再將 store 導出:
function isGlobalActions(ast) {// 判斷條件 1:global actions 無需 export default storeif (hasExport(ast)) {return false;}const body = getProgramBody(ast);let result = false;for (let node of body) {// import {store} from 'san-store'// store.addAction(XX, XX)if (node.type === 'ExpressionStatement'&& node.expression.type === 'CallExpression'&& node.expression.callee.type === 'MemberExpression'&& node.expression.callee.property.name === 'addAction'&& node.expression.arguments.length === 2) {result = isImportedAPI(ast, node.expression.callee.object, 'san-store', 'store');if (!result) {return false;}}}return result; }而判斷自定義 store 的方法則是『引用了 san-store 模塊,并導出新的 store』:
function isInstantStore(ast) {// 判斷條件// import {Store} from 'san-store'// export default new Store({})let defaultModule = getExportDefault(ast);if (!defaultModule) {return false;}let trackers = getTopLevelIdentifierTracker(ast, defaultModule);if (!trackers || trackers.length !== 1) {return false;}let newStore = trackers[0];if (newStore.type !== 'NewExpression') {return false;}if (!isImportedAPI(ast, newStore.callee, 'san-store', 'Store')) {return false;}if (newStore.arguments.length !== 1) {return false;}return true; }3. 結語
HMR 是一個非常實用的設計,san-hot-loader 的原理介紹至此也已完成,此時再看源碼定會有不一樣的感受。當然這只是 san-hot-loader 核心代碼中的一部分,在實現過程中還有很多功能沒有介紹到,比如怎樣通過 babel 生成的 AST 判斷代碼是否符合某種格式等。
總結
以上是生活随笔為你收集整理的san-hot-loader 应用及原理实现的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 百度分布式配置中心BRCC正式开源
- 下一篇: 百度单测生成技术如何召回线上服务的异常问