jiangjiu / blog-md

前端/健身 思考与笔记~

Home Page:https://github.com/jiangjiu/blog-md/issues

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

san的热更新思路&实现

jiangjiu opened this issue · comments

commented

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也是可以做到热加载的,但这样就违背了热加载的初衷:提供更优秀的开发体验。

懒人包

github的san-hmr-template

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,主要做两件事:

  1. 在每一个组件的attacheddisposed生命周期注入hook函数,函数中来记录和删除模块使得模块可以被追踪;
  2. 修改组件后,处理新老模块的各种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中的childsbindsevents等传递给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方法中做了以下几件事:

  1. _compile方法对实例的components属性做预处理,区分是object、self等
  2. 非根节点init时父组件会传入options,包含aNodesubTag等去搞点事情,通过传入的options的childs做slot解析,创建本实例的aNodeevents绑定监听等
  3. 到达compiled生命周期
  4. initData和传入的options.data初始化实例的data
  5. 元素初始化
  6. dataTypes、计算属性、dataChanger绑定
  7. 到达inited生命周期

可以看出,init方法走完了compileinit生命周期,正常情况下每个实例只会走一次,所以旧的实例detach后重新attach也不会再做初始化时的事了。

实例重新init

另一种思路就是当组件修改后,新的实例拿到旧实例的一些父子关系、数据绑定等重新初始化。

非根节点init方法需要options参数,options对象具有几个属性: eventssubTagaNodeownerdata

其他属性都可以从旧的实例拿到,但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存在的组件都销毁了。

但是以下几种情况:

  1. 开发者手动初始化并且attach的组件,且没有销毁逻辑
  2. 脱离框架自行生成的dom节点、监听事件等
  3. 第三方库中引入的不可控代码

会使得热更新后存在副作用,比如每次热更新都会生成的dom节点、没有销毁的listener、san组件等。

解决方案:
1.规范自己的代码,动态组件请详读官方文档相关说明
2.当你这样写的时候,思考一下必要性,大部分场景都无需自行生成dom节点
3.别忘了在组件销毁时处理好监听事件等