san-hot-loader 应用及原理实现

之前的 San 系列文章中已经详细介绍过 San 的最新脚手架及开发工具,本篇就从 使用方法 和 实现原理 两个方面入手向大家介绍下 San 的 HMR 插件 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
},

关于 webpack HMR 更多细节见 模块热替换
 [1]

以下介绍两种使用 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'
]
}
}
}
]
}
]
}
}

更多配置项详见 san-hot-loader 文档
 [2]

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 的工作过程可以简化如下:

  1. 首先,在 watch 模式下,webpack 监听到文件变化后对模块重新编译打包,将代码打包到内存中。
  2. webpack-dev-server 会监听打包后的静态文件的变化,并与浏览器端建立 WebSocket 长连接,将 webpack 监听静态文件变化的信息告知浏览器端。
  3. HMR 客户端根据这些 WebSocket 消息进行操作。在 HMR 中,WebSocket 传递的服务端消息最主要信息是新模块的 hash 值和更新指令。
  4. 浏览器运行的 HMR 客户端接收到包含新模块的 hash 值的 type 为 hash 消息,将 hash 值暂存起来,当接收到 type 为 ok 的消息后对应用执行 reload 操作。WebSocket 消息例如: 

  5. HMR 双端确定需要进行 HMR 更新后,HMR 客户端向 HMR server 端发送 Ajax 请求,返回包含所有要更新的模块 hash 值的 json。例如,收到上图消息后发出 Ajax 请求返回值: 
  6. 获取到更新列表后,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')) {
// 用户使用特殊格式注释关闭 HMR
return 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,用于处理模块,详情可见  module.hot
[4]
.

此处 __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-hot-loader/lib/runtime/component-client-api.js
[5]

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 = '

Hello {{name}}

'
;

initData() {
return {name: 'San'};
}
}

能被 san-hot-loader 识别的常规 san 组件也可以用 san.defineComponent
或者 ES5 Function Constructor 等方式来定义,只需包含以下特征:

  1. 文件引入  san
    模块(import、require)
  2. 使用  san
    模块所提供的 API(defineComponent、Component)定义组件
  3. 将定义好的组件作为默认模块导出(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: '

Hello {{name}}

'
,

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 组件:

  1. 文件引入  san-store
    (import、require)
  2. san-store
    connect.san
    connect.createConnector(store)
    
  3. 将得到的新组件作为默认模块导出(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 store
if (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 是百度自研的高性能 MVVM 框架,目前已落地百度 APP 核心业务,服务于亿级用户,开源社区已有 36 位贡献者,Star 数量超过 4.3K。开源维护不易,如果你看到这行小字,求给我们一个 Star
 
San: https://github.com/baidu/san
 
San CLI: https://github.com/ecomfe/san-cli
 
San DevTools: https://github.com/baidu/san-devtools
 
san-loader: https://github.com/ecomfe/san-loader
 
san-hot-loader: https://github.com/ecomfe/san-hot-loader

参考资料

[1]
模块热替换: https://webpack.docschina.org/guides/hot-module-replacement/

[2]
san-hot-loader 文档: https://github.com/ecomfe/san-hot-loader

[3]
Hot Module Replacement: https://webpack.js.org/api/hot-module-replacement/

[4]
module.hot: https://webpack.js.org/api/module-variables/#modulehot-webpack-specific/

[5]   
san-hot-loader/lib/runtime/component-client-api.js: https://github.com/ecomfe/san-hot-loader/blob/master/lib/runtime/component-client-api.js
EOF

作者:潘铭
2021 年 2 月 4 日