blog-md
blog-md copied to clipboard
san的热更新思路&实现
san的热更新思路&实现
前一篇文章简单介绍了webpack热更新的原理和踩坑,本文主要说说在san框架下的热更新实现过程。
默认webpack版本2.0+,已经安装了webpack-dev-server和webpack-hot-middleware。
注意
本文实现针对的是san + san-router, 不包括san-store。
store实现热更新有两个思路,当store的actions被修改、连接的组件被修改时,或者store实例提供了类似reset的方法,或者store根据修改后的依赖重新实例化。
san-store的connect.san
方法连接的store实例是san-store默认提供的,既没有提供保存状态、重新初始化的api,也没有重新实例化的机会。
这个connect.san
方法提供了更便利的开发体验,使用另外的开发方式连接store也是可以做到热加载的,但这样就违背了热加载的初衷:提供更优秀的开发体验。
懒人包
server端
server端分为两种,轻巧灵便的webpack-dev-server以及可以利用express强大生态的webpack-hot-middleware中间件。
webpack-dev-server
启用此功能实际上相当简单。
而我们要做的,就是更新 webpack-dev-server 的配置,和使用 webpack 内置的 HMR 插件。
module.exports = {
entry: {
app: './src/index.js'
},
devtool: 'inline-source-map',
devServer: {
contentBase: './dist',
// 告诉webpack启动dev-server的hot模式
+ hot: true
},
plugins: [
new HtmlWebpackPlugin({
title: 'Hot Module Replacement'
}),
// 然后在这里增加以下hmr的插件就可以了
+ new webpack.HotModuleReplacementPlugin()
+ // 这个插件可以更清楚的handle errors
+ new webpack.NoErrorsPlugin()
],
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist')
}
};
你也可以通过命令来修改 webpack-dev-server 的配置:webpack-dev-server --hotOnly
。
webpack-hot-middware
1.不出意外还是增加热更新插件。
plugins: [
new webpack.HotModuleReplacementPlugin(),
new webpack.NoErrorsPlugin()
]
2.添加webpack-hot-middleware/client
到entry数组中。这将和server端建立连接,以便在重建时接收通知,然后相应地更新client端的代码。
var webpack = require('webpack');
var webpackConfig = require('./webpack.config');
var compiler = webpack(webpackConfig);
// dev-middleware
app.use(require("webpack-dev-middleware")(compiler, {
noInfo: true, publicPath: webpackConfig.output.publicPath
}));
// hot-middleware
app.use(require("webpack-hot-middleware")(compiler));
client端更新
上面可以看到,server端需要做的事很固定,无非选择一下dev-server或者中间件来添加点儿插件和中间件就好了,重点其实在client端。
每一个框架的原理和实现都千差万别,如何针对不同框架实现热更新才是难点所在,需要对框架及生态有更深入的理解,同时这些库也要提供相应支持。
目前san、san-router及san-store以及san-loader都没有提供应有的api去做热更新,所以以下仅从思路角度去谈谈热更新的实现。
组件级别(最小粒度更新, 暂未实现)
当修改一个组件后,修改过后的组件替换掉更新前的组件完成最小粒度刷新。具体实现首先是要对san-loader进行改造。
组件级别热更新还会涉及到san-store保存state,修改后reset;san-router修改后重启路由,目前都木有做支持。
san-loader改造
san-loader和vue-loader1.x很像,都是对三段式的组件进行拆分,交给相应的loader做下一步的处理。
san-loader和后者的差异在于:将template属性挂到__san_proto__
上,导出的是san.defineComponent(__san_proto__)
。
这样做其实是有改进空间和加入新特性的可能的,后续文章会提到,按下不婊。
//hmr用到的热更api
var hotApi = require('san-hot-reload-api');
// san-loader的输出中增加以下代码
if (
!this.minimize &&
process.env.NODE_ENV !== 'production' &&
(parts.script.length || parts.template.length)
) {
// 唯一标识
var hotId = JSON.stringify(moduleId + '/' + fileName)
output +=
// 告诉webpack,用以下代码处理组件修改后的热更逻辑
'if (module.hot) {(function () {' +
// 此模块接受热更
' module.hot.accept()\n' +
// hotApi安装
' hotAPI.install(require("san"), false)\n' +
' var id = ' + hotId + '\n' +
' if (!module.hot.data) {\n' +
// 初始化时,让模块们燥起来
' hotAPI.createRecord(id, __san_proto__)\n' +
' } else {\n' +
// 热更新后处理重载
' hotAPI.reload(id,__san_proto__)\n' +
' }\n' +
'})()}'
}
热更的思路是在san-loader中引入hotAPI,主要做两件事:
- 在每一个组件的
attached
、disposed
生命周期注入hook函数,函数中来记录和删除模块使得模块可以被追踪; - 修改组件后,处理新老模块的各种data、消息、事件、父子关系等,完成一次reload。
引入hotAPI
var San;
var map = window.__SAN_HOT_MAP__ = {};
var installed = false;
var IndexedList = require('./IndexedList');
exports.install = function (san) {
if (installed) {
return;
}
installed = true;
San = san.__esModule ? san.default : san;
};
exports.createRecord = function (id, options) {
var Ctor = San.defineComponent(options);
// new Ctor();
makeOptionsHot(id, options);
map[id] = {
Ctor: Ctor,
options: options,
instances: []
};
};
function injectHook(options, name, hook) {
var existing = options[name];
options[name] = existing
? function () {
existing.call(this);
hook.call(this);
}
: hook;
}
function makeOptionsHot(id, options) {
injectHook(options, 'inited', function () {
map[id].instances.push(this);
});
injectHook(options, 'disposed', function () {
var instances = map[id].instances;
instances.splice(instances.indexOf(this), 1);
});
}
function tryWrap(fn) {
return function (id, arg) {
try {
fn(id, arg);
} catch (e) {
console.error(e);
console.warn('Something went wrong during hot-reload, Full reload required.');
}
};
}
第一件事注入hook函数如上述代码所示。 第二件事reload的实现思路有两种:
新的组件初始化,旧的实例detach接受anode再次attach
erik大神提供了一个思路,组件修改后,oldInstance先detach
掉,newCtor实例化后得到的aNode中的childs
、binds
、events
等传递给oldInstance,然后重新编译再attach。
代码实现如下所示:
exports.reload = tryWrap(function (id, newOptions) {
var record = map[id];
debugger;
var proto = record.Ctor.prototype;
var newProto;
makeOptionsHot(id, newOptions);
record.Ctor = San.defineComponent(newOptions);
var newInstance = new record.Ctor();
newProto = record.Ctor.prototype;
// 先清空old上的各种属性
Object.keys(proto).forEach(function (name) {
proto[name] = undefined;
});
Object.keys(newProto).forEach(function (name) {
proto[name] = newProto[name];
});
// 想办法重新走一遍编译流程
delete proto._cmptReady;
delete proto._compiled;
record.instances.forEach(function (instance) {
var parentEl = instance.el.parentElement;
var beforeEl = instance.el.nextElementSibling;
debugger;
// 新实例initData的初始值要传给旧实例
// Object.keys(newInstance.data.raw).forEach(function (name) {
// instance.data.raw[name] = newInstance.data.raw[name];
// });
// 绑定
['binds', 'events', 'childs'].forEach(function (key) {
instance.aNode[key] = newProto.aNode[key];
});
newInstance.dispose();
// // 防止重新编译后两份childs
// instance.childs.forEach(function (child) {
// child.dispose();
// });
// instance.childs = [];
instance.detach();
instance.el = undefined;
// 实例的create方法有点小问题,这里调用私有方法实现
instance._create();
instance._toPhase('created');
instance.attach(parentEl, beforeEl);
});
});
写了一下发现还是存在问题。
实例init
方法中做了以下几件事:
-
_compile
方法对实例的components
属性做预处理,区分是object、self等 - 非根节点init时父组件会传入
options
,包含aNode
、subTag
等去搞点事情,通过传入的options的childs做slot解析,创建本实例的aNode
,events
绑定监听等 - 到达
compiled
生命周期 - 从
initData
和传入的options.data
初始化实例的data - 元素初始化
-
dataTypes
、计算属性、dataChanger
绑定 - 到达
inited
生命周期
可以看出,init
方法走完了compile
和init
生命周期,正常情况下每个实例只会走一次,所以旧的实例detach后重新attach也不会再做初始化时的事了。
实例重新init
另一种思路就是当组件修改后,新的实例拿到旧实例的一些父子关系、数据绑定等重新初始化。
非根节点init方法需要options参数,options对象具有几个属性: events
、subTag
、aNode
、owner
、data
。
其他属性都可以从旧的实例拿到,但aNode.events
属性在绑定事件后就丢掉了,没有aNode.events
这个属性,没有办法重新init构建正常的父子关系(或者说动态创建可以任意指定父组件并获得数据、事件绑定的子组件)。
所以这条路暂时也不可行。
全局App级别
这条路的思路就是,当应用中的组件或路由发生改变后,销毁原有的App和router,重新编译并实例化。
因为webpack会处理模块依赖引用的问题,完美避开了san生态中components属性持有的子组件引用,所以这条路实现起来不需要做loader方面的改造了。
// main.js 项目入口文件
import App from './App.san';
import routes from './routes';
import {Router} from 'san-router';
const app = new App();
const router = new Router();
app.attach(document.getElementById('app'));
routes.forEach(route => router.add(route));
router.start();
// hmr 更新逻辑
if (module.hot) {
module.hot.data = {app, router};
// 接受热更新的依赖数组
module.hot.accept(['./routes', './App.san'], () => {
// 销毁旧的app
module.hot.data.app.dispose();
// 停止router并销毁
module.hot.data.router.stop();
// 这里要注意,router中存活的组件实例和App是没有父子关系的
// 所以要手动dispose
module.hot.data.router.routeAlives.forEach(item => {
item.component.dispose();
});
module.hot.data.router = null;
// 创建新的app和router
const app = new App();
const router = new Router();
app.attach(document.getElementById('app'));
routes.forEach(route => router.add(route));
router.start();
// app传递给module.hot.data以便下次更新时销毁
module.hot.data = {app, router};
});
}
需要注意的是,module.hot
后面的代码是重载后的逻辑,包括了销毁原有App、router,重新实例化App、router并通过module.hot.data
保存传递。
module.hot.accpet
第一个参数接收一个数组,接收的文件如果有变动或是引用有变动,都会触发热更新。
考虑到每个项目的结构组织方式不太一样,所以没有做封装,保持最大的灵活性。
副作用
上述代码可以看到,每次热更新以后,浏览器中更新前的App根组件以及san-store中routeAlives
存在的组件都销毁了。
但是以下几种情况:
- 开发者手动初始化并且attach的组件,且没有销毁逻辑
- 脱离框架自行生成的dom节点、监听事件等
- 第三方库中引入的不可控代码
会使得热更新后存在副作用,比如每次热更新都会生成的dom节点、没有销毁的listener、san组件等。
解决方案: 1.规范自己的代码,动态组件请详读官方文档相关说明 2.当你这样写的时候,思考一下必要性,大部分场景都无需自行生成dom节点 3.别忘了在组件销毁时处理好监听事件等