Vue热更新原理和源码解析
提到热更新,首先我们要有一个概念:Vue有热更新模块,而webpack也有它的HRM模块(HotModuleReplacement)。Vue热更新是基于webpack的热更新之下的粒度更小的更新,它是依托于webpack-dev-middleware对文件的监听的,是整个webpack热更新的一部分。
所以想要理解Vue的热更新,必须先要了解webpack HMR的一个流程。
webpack一些概念介绍
先介绍一些比较重要的概念:
概念 |
作用 |
备注 |
Webpack Compiler | 将 JS 编译成 Bundle | |
Bundle Server | 提供文件在浏览器端以服务器的方式的访问(正常是通过文件目录来访问) | 比如说编译好的 bundle.js,其实在浏览器里面正常访问是以文件目录的形式来访问的,然后使用 BundleServer 可以让我们以类似于服务器的方式来访问,比如说 localhost:8080 |
HMR Server | 将热更新的文件输出给 HMR Runtime | |
HMR Runtime | 在项目打包阶段,会被注入到浏览器端的bundle.js里面 | 开发阶段,打包阶段,会注入到浏览器中的 bundle.js里面,这样的话浏览器端的 bundle.js 会和服务器建立一个链接,通常会是 websocket。这样就能动态更新了。 |
bundle.js | 构建后最终输出的文件 |
webpack热更新流程
webpack热更新流程图解:
为什么 webpack 没有将文件直接打包到 output.path 目录下呢?文件又去了哪儿?
webpack 将 bundle.js 文件打包到了内存中,不生成文件的原因就在于访问内存中的代码比访问文件系统中的文件更快,而且也减少了代码写入文件的开销,这一切都归功于memory-fs,memory-fs 是 webpack-dev-middleware 的一个依赖库,webpack-dev-middleware 将 webpack 原本的 outputFileSystem 替换成了MemoryFileSystem 实例,这样代码就将输出到内存中。
从图中可以看到,从模块文件到浏览器页面显示的流程主要分为了两种情况,分别是第一次启动项目,和项目启动完之后的热更新
1. 启动阶段 ①->②->A->B
- 代码文件通过
webpack Compile
进行打包 - 将打包好的文件传输给
Bundle Server(使用了内存文件系统,无实际的文件生成)
- 然后
Bundle Server
会让文件以服务器的方式让浏览器可以访问到 - 代码展示到浏览器
2. 更新阶段 ①->②->③->④
- 变更后的代码同样会通过
webpack Compile
进行打包编译(依赖了webpack-dev-middleware来监听变化和获取文件) - 编译好之后会发送给
HMR Server
HMR Server
即可知道哪些资源、js模块、文件发生了改变- 然后通过 websorket 传输
.hot-update.json
的形式,通知HMR Runtime
HMR Runtime
在接收到文件变化后,就会更新代码- 最终代码更新,且无需刷新浏览器
在其中最需要我们关心的,应该是
HMR Runtime
在接收到文件变化后,就会更新代码 这一步,在获取到了变更的文件后,到底是通过怎么样的方式对页面进行刷新的呢?/* hot reload */ Component.options.__file = "app/web/page/about/about.vue" if (true) {(function () { var hotAPI = __webpack_require__(3) hotAPI.install(__webpack_require__(0), false) if (!hotAPI.compatible) return module.hot.accept() if (!module.hot.data) { hotAPI.createRecord("data-v-aafed0d8", Component.options) } else { hotAPI.reload("data-v-aafed0d8", Component.options) ' + ' } module.hot.dispose(function (data) { disposed = true }) })()}
vue的组件热更新模块
let Vue // late bind let version const map = Object.create(null) if (typeof window !== 'undefined') { window.__VUE_HOT_MAP__ = map } let installed = false let isBrowserify = false let initHookName = 'beforeCreate' exports.install = (vue, browserify) => { if (installed) return installed = true Vue = vue.__esModule ? vue.default : vue version = Vue.version.split('.').map(Number) isBrowserify = browserify // compat with < 2.0.0-alpha.7 if (Vue.config._lifecycleHooks.indexOf('init') > -1) { initHookName = 'init' } // 只有Vue在2.0以上的版本才支持这个库 exports.compatible = version[0] >= 2 if (!exports.compatible) { console.warn( '[HMR] You are using a version of vue-hot-reload-api that is ' + 'only compatible with Vue.js core ^2.0.0.' ) return } }
这一段是安装部分,主要用来判断有无重复安装、即将使用的vue的版本是否过低等。
createdRecord创造模块纪录部分:
exports.createRecord = (id, options) => { if(map[id]) return let Ctor = null if (typeof options === 'function') { Ctor = options options = Ctor.options } makeOptionsHot(id, options) map[id] = { Ctor,// 构造函数 options, // vue options instances: [] // 实例对象数组 } }
在这个函数中,将传入的唯一id对应其vue option, 储存到map中,并采用makeOptionsHot这个函数将这个模块对应的option注入hot,即使其hot化(即生命周期中被注入了某些代码)。
function makeOptionsHot(id, options) { if (options.functional) {// 判断组件是否函数化,若函数化,则无需injectHook const render = options.render options.render = (h, ctx) => { const instances = map[id].instances if (ctx && instances.indexOf(ctx.parent) < 0) { instances.push(ctx.parent) } return render(h, ctx) // 给函数化组件的render加入了注入实例到map[id].instance的功能 } } else { injectHook(options, initHookName, function() { const record = map[id] if (!record.Ctor) { record.Ctor = this.constructor } record.instances.push(this) }) injectHook(options, 'beforeDestroy', function() { const instances = map[id].instances instances.splice(instances.indexOf(this), 1) }) } } // 往生命周期里注入某个方法 function injectHook(options, name, hook) { const existing = options[name]; options[name] = existing ? Array.isArray(existing) ? existing.concat(hook) : [existing, hook] : [hook]; }
先来看injectHook函数,这个函数接受三个参数,options,name,hook,分别对应vue的option,生命周期的名字,以及想要混入该生命周期函数中的操作hook。
在makeOptionsHot中,这个injectHook函数被调用了两次,将传入的id对应的map中的options的beforeCreate和beforeDestory方法中混入了新增/清除map中options的模块实例的功能。这样子就能在每个组件创建之前,拿到他们的实例对象,以便后续进行操作。
而当vue组件为函数化组件时,无需injectHook,而是给函数化组件的render加入了注入实例到map[id].instance功能。
接下来就到了比较重要的两个函数了,首先是修改render或template时触发的rerender函数。
精简版的rerender,删去了处理cache和funtional部分,只留了核心代码:
exports.rerender = (id, options) => { const record = map[id]; if (!options) { // 如果没传第二个参数 就把所有实例调用 $forceUpdate record.instances.slice().forEach(instance => { instance.$forceUpdate(); }); return; } record.instances.slice().forEach(instance => { // 将实例上的 $options上的render直接替换为新传入的render函数 instance.$options.render = options.render; // 执行 $forceUpdate更新视图 instance.$forceUpdate(); }); };
在只更新template或者是render的情况下,调用的是rerender函数。
rerender函数接受id和新options,如果options不传,则直接刷新id对应的所有实例;如果传了的话,就将该options赋予所有id对应的实例,再刷新。
接下来是直接重新渲染组件的reload函数:
exports.reload = (id, options) => { const record = map[id] if (options) { if (typeof options === 'function') { options = options.options } makeOptionsHot(id, options)// 因为这个options是新的,所以需要再次让它hot化 if (record.Ctor) { const newCtor = record.Ctor.super.extend(options) // prevent record.options._Ctor from being overwritten accidentally newCtor.options._Ctor = record.options._Ctor record.Ctor.options = newCtor.options record.Ctor.cid = newCtor.cid // 这一步很重要,改变了构造函数的cid record.Ctor.prototype = newCtor.prototype } record.instances.slice().forEach(instance => { if (instance.$vnode && instance.$vnode.context) { instance.$vnode.context.$forceUpdate() } else { console.warn( 'Root or manually mounted instance modified. Full reload required.' ) } }) }
在这一步中,首先将传入的options调用makeOptionsHot函数hot化,在生命周期注入代码。之后用原有的构造函数的父函数继承options(这里不是很懂为啥不直接用Vue.extend(options)),然后再注入诸如options,cid,prototype之类的东西。
最后对每一个实例进行更新。在Vue更新实例的时候,会去检查实例生成的组件的构造方法的cid是否有改变,有改变的话就不使用缓存,所以说cid赋值的一步很重要。
至此,vue-hot-reload-api中最重要的两个方法已经介绍完毕。
然后回到我们刚刚的浏览器的HMR runtime获取到最新代码的那步,刚刚提到所有的Vue文件在编译完后会多出的那段代码,其实是Vue loader做的。
vue-loader 源码:
if (needsHotReload) { code += `\n` + genHotReloadCode(id, hasFunctional, templateRequest) }
const hotReloadAPIPath = JSON.stringify(require.resolve('vue-hot-reload-api')) const genTemplateHotReloadCode = (id, request) => { return ` module.hot.accept(${request}, function () { api.rerender('${id}', { render: render, staticRenderFns: staticRenderFns }) }) `.trim() } exports.genHotReloadCode = (id, functional, templateRequest) => { return ` /* hot reload */ if (module.hot) { var api = require(${hotReloadAPIPath}) api.install(require('vue')) if (api.compatible) { module.hot.accept() if (!api.isRecorded('${id}')) { api.createRecord('${id}', component.options) } else { api.${functional ? 'rerender' : 'reload'}('${id}', component.options) } ${templateRequest ? genTemplateHotReloadCode(id, templateRequest) : ''} } } `.trim() }
仓库地址:https://github.com/vuejs/vue-loader
可以看到,当热更新触发时,浏览器中的 HRM runtime会去先install,然后根据情况的不同进行createRecord,rerender或reload等操作。
至此,整个热更新的流程到vue 热更新的api实现已经讲完了。
总结
来总结一下:
webpack的热更新原理:
webpack-dev-server:
webpack-dev-server 主要包含了三个部分:
1.webpack: 负责编译代码
2.webpack-dev-middleware: 主要负责构建内存文件系统,把webpack的 OutputFileSystem 替换成 InMemoryFileSystem。同时作为Express的中间件拦截请求,从内存文件系统中把结果拿出来。
3.express:负责搭建请求路由服务。
工作流程:
1.启动dev-server,webpack开始构建,在编译期间会向 entry 文件注入热更新代码;
2.Client 首次打开后,Server 和 Client 基于Socket建立通讯渠道;
3.修改文件,Server 端监听文件发送变动,webpack开始编译,直到编译完成会触发"Done"事件;
4.Server通过socket 发送消息告知 Client;
5.Client根据Server的消息(hash值和state状态),通过ajax请求获取 Server 的manifest描述文件;
6.Client对比当前 modules tree ,再次发请求到 Server 端获取新的JS模块;
7.Client获取到新的JS模块后,会更新 modules tree并替换掉现有的模块;
8.最后调用 module.hot.accept() 完成热更新;
其中vue 的模块热更新接在了最后的8那里,module.hot.accept()是调用了vue-hot-reload-api的接口,而这个module.hot.accept的函数内容是在vue-loader的时候注入进去的。
补上一张webpack更新的流程图:
完结撒花~~~
参考资料