当前位置: 首页 > 科技观察

深入分析Vue的热更新原理,尤其是如何巧妙利用源码中的细节?

时间:2023-03-22 13:17:20 科技观察

本文转载自微信公众号《前端从进阶到入学》,作者ssh前端。转载本文请联系前端从进阶到录取公众号。每个人都使用过Vue-CLI创建Vue应用程序。在开发过程中,我们修改了Vue文件并保存了文件。我们编写的组件内容会在浏览器上自动更新。非常流畅流畅,大大提高了开发效率。想知道这是如何在幕后实现的吗?其实代码并不复杂。该功能的底层实现使用了vue-hot-load-api[1]库。得益于Vue的良好设计,实现热更新只需要一个js文件和200行代码,绰绰有余。而这个库涉及到的技巧,非常适合我们深入了解vue的一些内部机制,快来和我一起学习吧。摘要本文简单从vue-hot-load-api库入手,在浏览器环境下运行Vue的热更新实例。测试的主要组件都是普通的vue组件,而不是functional之类的特殊组件。了解热更新的原理。源码分析贴出的代码会省略一些无关的过程,更容易理解。分析从github仓库example入手,进入这个github仓库后,首先看的一定是Readme中的example。在看例子的时候,作者给出的注释非常重要,他会在每一个重要的环节都标注出来。而我们要结合自己的一些经验,排除与这个库无关的代码。(本例中,webpack的相关代码可以不用过多关注。)第一步是调用install方法,传入vue的构造函数。根据评论,这一步是为了知道这个库和vue版本的区别。是否兼容。//让API知道你正在使用的Vue。//也检查兼容性。api.install(Vue);接下来的注释告诉我们,我们需要为每个需要热更新的组件选项对象创建一个唯一的id,这段代码需要在初始化时完成。if(initialization){//foreachcomponentoptionobjecttobehot-reloaded,//youneedtocreatearecordforitwithauniqueid.//dothisonceonstartup.api.createRecord('very-unique-id',myComponentOptions);}最后就是激动人心的热更新时间了,根据评论,这个库的使用分为两种情况。仅当模板或渲染函数已更改时才使用重新渲染。reload如果模板或者render没有变化,这个函数需要调用reload方法销毁然后重新创建(包括它的子组件)。//ifacomponenthasonlyitstemplateorrenderfunctionchanged,//youcanforceare-renderforallitsactiveinstanceswithout//destroying/re-creatingthem.Thiskeepsallcurrentappstateintact.api.rerender('very-unique-id',myComponentOptions);//---或---//ifacomponenthasnon-tiontemplateangedoprender,//itneedstobefullyreloaded.Thiswilldestroyandre-createallits//activeinstances(andtheirchildren).api.reload('very-unique-id',myComponentOptions);从这个简单的例子可以看出,这个库的核心流程是:api.install检查兼容性。api.createRecord为具有唯一ID的组件对象创建记录。api.rerender或api.reload用于组件的热更新。什么,Readme示例到此结束?这个非常独特的ID是什么,myComponentOptions是什么样的?因为这个仓库可能不是针对广大开发者的,所以它的文档非常简短。其实看完这个简单的例子,大家肯定还是一头雾水。在看一个自己使用不熟练的库的源码时,其实还有一个很关键的步骤,就是看测试用例。探索测试用例测试用例[2]总结了上面两个关键的apirerender和reload之后,我们就带着目的来看一下测试用例。constVue=require('vue');constapi=require('../src');//初始化api.install(Vue);//该方法接受id和组件选项对象,//通过createRecord记录组件//然后返回一个vue组件实例。functionprepare(id,Comp){api.createRecord(id,Comp);returnnewVue({render:h=>h(Comp),});}重新渲染用例constid0='rerender:mounted';test(id0,done=>{//使用'rerender:mounted'作为这个组件对象的id,//这个组件的内容应该是

foo
//调用$mount生成一个dom节点constapp=prepare(id0,{render:h=>h('div','foo'),}).$mount();//$el为组件生成的dom元素,期望的textContent文本内容为fooexpect(app.$el.textContent).toBe('foo');//重新渲染后,dom节点变为
bar
api.rerender(id0,{render:h=>h('div','bar'),});//通过nextTick确保dom节点已经更新//期望textContent的文本内容为barVue.nextTick(()=>{expect(app.$el.textContent).toBe('酒吧');完成();});});reloadusecaseconstid1='reload:mounted';test(id1,done=>{//countthroughacountletcount=0;//appcomponentwillcount+1whencreated//count-1constapp=whendestroyed(id1,{created(){count++;},destroyed(){count--;},data:()=>({msg:'foo'}),render(h){returnh('div',this.msg);},}).$mount();//确保内容正确expect(app.$el.textContent).toBe('foo');//确保创建的循环执行次数为1expect(count).toBe(1);//调用创建当ed传入新组件的created时,count会是-1api.reload(id1,{created(){count--;},data:()=>({msg:'bar'}),render(h){returnh('div',this.msg);},});Vue.nextTick(()=>{//确保内容正确expect(app.$el.textContent).toBe('bar');//在reload之前计数为1//调用reload之后,会先调用之前组件的destroy生命周期,此时count为0//接下来调用新形成的created生命周期,此时count为-1??expect(count).toBe(-1);完成();});});具体过程在注释里已经分析过了,和示例代码注释里写的是一样的,至此我们就更清楚这个api的使用方法了总结最简单可用的demoimportapifrom'vue-hot-reload-api';importVuefrom'vue';//初始化api.install(Vue,true);constappOptions={render:h=>h('div','foo'),};api.createRecord('my-app',appOptions);newVue(appOptions).$mount('#app');setTimeout(()=>{api.rerender('my-app',{渲染:h=>h('div','bar'),});},2000);本demo(源码[3])在浏览器直接可用,效果如下:源码分析源码地址[4]全局变量进入js文件的入口,首先定义一些变量//Vue构造函数让Vue;//latebind//Vue版本letversion;//createRecord方法保存id->component映射关系的对象constmap=Object.create(null);if(typeofwindow!=='undefined'){//在窗口window上存储地图对象.__VUE_HOT_MAP__=map;}//是否已经安装letisstalled=false;//这个变量暂时没用letisBrowserify=false;//initialization生命周期的名称默认为Vue的beforeCreate生命周期letinitHookName='beforeCreate';其实看到window对象的出现,我们就已经可以确定这个api是可以在浏览器端调用的了。installexports.install=function(vue,browserify){//如果已安装,不要重复安装if(installed){return;}installed=true;//兼容esmodules模块Vue=vue.__esModule?vue.default:vue;//将vue版本如2.6.3分隔成[2,6,3]的数组version=Vue.version.split('.').map(Number);isBrowserify=browserify;//compatwith<2.0.0-alpha.7//兼容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]Youareusingaversionofvue-hot-reload-apithatis'+'onlycompatiblewithVue.jscore^2.0.0.');return;}};可见install方法非常简单。帮助大家查看Vue的版本是否在2.0以上,确认兼容性。关于初始化生命周期,本文不考虑2.0.0-alpha。对于7以下的版本,可以认为这个库的初始化是在beforeCreate的生命周期中进行的。createRecord/***Createarecordforahotmodule,whichkeepstrackofitsconstructor*andinstances**@param{String}id*@param{Object}options*/exports.createRecord=function(id,options){//如果已经存储,则返回if(map[id]){return;}//下一步关键过程是解析makeOptionsHot(id,options);//将记录存入map中//实例变量应该不难猜到是实例对象的。map[id]={options:options,instances:[],};};这一步,将id和对应的options对象存入map后,就什么都不用做了。关键步骤必须是makeOptionsHot方法。/***MakeaComponentoptionsobjecthot.*使一个组件对象成为热点……哦不,它支持热更新。**@param{String}id*@param{Object}options*/functionmakeOptionsHot(id,options){//options是我们传入的组件对象//initHookName是'beforeCreate'injectHook(options,initHookName,function(){//这个函数会在beforeCreate语句循环中执行constrecord=map[id];if(!record.Ctor){//此时this已经是vue的实例对象//赋值组件实例的构造函数到recordCtor属性。record.Ctor=this.constructor;}//将这个实例存储在instances.record.instances.push(this);});//组件销毁时删除上面存储的实例。injectHook(options,'beforeDestroy',function(){constinstances=map[id].instances;instances.splice(instances.indexOf(this),1);});}//在生命周期函数中注入一个方法injectHook(options,name,hook){constexisting=options[name];options[name]=existing?Array.isArray(existing)?existing.concat(hook):[existing,hook]:[hook];}读完这篇文章后几个函数,大家应该对createRecord有一个清晰的认识。例如,在我们上面的示例中,此代码constappOptions={render:h=>h('div','foo'),};api.createRecord('my-app',appOptions);在map中创建一条记录,这条记录有options字段,就是上面传入的组件对象,instances用来记录激活组件的实例,Ctor用来记录组件的构造函数。//map{my-app:{options:appOptions,instances:[],Ctor:null}}在appOptions中,混入生命周期方法beforeCreate,在组件的这个生命周期中,将组件本身的example推入map对应instances数组,在Ctor字段记录自己的构造函数。执行beforeCreate后,地图对象如下所示。接下来进入keyrerender函数。rerenderexports.rerender=(id,options)=>{constrecord=map[id];if(!options){//如果不传第二个参数,调用所有实例$forceUpdaterecord.instances.slice().forEach(instance=>{instance.$forceUpdate();});return;}record.instances.slice().forEach(instance=>{//直接将实例上$options上的render替换为新传入的renderFunction实例。$options.render=options.render;//执行$forceUpdate更新视图实例。$forceUpdate();});};其实这个原函数很长,但是这些都是简化后改变视图的核心方法。通常我们写vue单文件组件的时候都是这样写的:这样的.vue文件会被vue-loader编译成一个单独的组件选项对象,模板中的部分会被编译成一个render函数挂在组件上。最终生成的对象类似于:exportdefault{data(){return{msg:'HelloWorld',};},render(h){returnh('span',this.msg);},};而在运行时,组件实例(即生命周期或方法中访问的this对象)会通过option.render来实现。我们可以去vue的源码中去验证一下我们的猜想。_render已在options.render中替换为新的渲染方法。这时候调用$forceUpdate会渲染新传过来的render吗?不得不佩服这个runtime的鬼鬼祟祟~reloadreload的解释是基于这样一个Example:一开始会显示foo的文字,一秒后显示为bar。functionprepare(id,Comp){api.createRecord(id,Comp);returnnewVue({render:h=>h(Comp),});}constid1='reload:mounted';constapp=prepare(id1,{data:()=>({msg:'foo'}),render(h){returnh('div',this.msg);},}).$mount('#app');//reloadsetTimeout(()=>{api.reload(id1,{data:()=>({msg:'bar'}),render(h){returnh('div',this.msg);},});},1000);reload的情况会比较复杂,涉及到很多Vue内部的运行原理,这里只能简单介绍一下。exports.reload=function(id,options){constrecord=map[id];if(options){//在reload的情况下,传入的options会被当作一个新的组件//所以使用makeOptionsHot重新做记录makeOptionsHot(id,options);constnewCtor=record.Ctor.super.extend(options);newCtor.options._Ctor=record.options._Ctor;record.Ctor.options=newCtor.options;record.Ctor.cid=newCtor.cid;record.Ctor.prototype=newCtor.prototype;}record.instances.slice().forEach(function(instance){instance.$vnode.context.$forceUpdate();});};这段代码的关键点StartedwithconstnewCtor=record.Ctor.super.extend(options);使用新传递的配置生成了一个新的组件构造函数,然后对记录上的Ctor进行了一系列分配newCtor.options._Ctor=record.options._Ctor;record.Ctor.options=newCtor.options;record.Ctor.cid=newCtor.cid;record.Ctor.prototype=newCtor.prototype;注意,第一次调用reload时,这里的record.Ctor仍然是初始传入的Ctor是由constapp=prepare(id1,{data:()=>({msg:'foo'}),render创建的(h){returnh('div',this.msg);},}).$mount('#app');这个配置对象生成的构造函数,但是构造函数的options、cid和prototype被替换成了api.reload(id1,{data:()=>({msg:'bar'}),render(h){returnh('div',this.msg);},});这个配置对象生成的constructor上的options,cid,prototype这时候肯定是不一样的,就是constructor的cid变了!,记得后面测试这点!继续阅读源码record.instances.slice().forEach(function(instance){instance.$vnode.context.$forceUpdate();});此时只有一个实例,就是在reload之前运行的msg为foo的实例。它的$vnode.context是什么?直接把截图打印在控制台上。这个上下文是一个vue实例。注意这个options中的render函数不是很熟悉,没错,这个vue实例其实就是newVue({render:h=>h(Comp),});返回的vm实例。在我们的准备功能中。那么这个函数的$forceUpdate必然会触发render:h=>h(Comp)函数。在这一点上,我们似乎仍然不明白为什么这些操作会销毁旧组件并创建新组件。所以这个时候,我们只能探究一下这个h是干什么的。这个h对应于$createElement方法。$createElement方法$createElement创建vnode时,底层会调用一个createComponent方法,该方法使用Comp对象作为Ctor,然后调用Vue.extendAPI创建构造函数。默认情况下,h(Comp)会在第一次生成类似于vue-component-${cid}的标签作为组件。本例中,第一次渲染msg为foo的组件时,标签为vue-component-1,这个构造函数会缓存在变量_Ctor中,这样下次渲染执行到createComponent时,就没有了需要重新生成构造函数。当Vue选择更新策略时,它会调用一个sameVnode方法来决定是打补丁还是完全销毁并重建。这个sameVnode如下:functionsameVnode(a,b){return(//Omitothers...a.tag===b.tag);}其中比较关键的是a.tag===b.tag但是reload方式将Ctor的cid改为2,生成的vnode的tag是基于vue-component-2的。后面调用context.$forceUpdate的时候,会发现两个组件的标签不一样,所以销毁->重新创建的过程就没有了。总结一下,这个库里面还是有很多很棒的编程风格,非常适合学习,但是reload方法必须要深入了解Vue源码才能明白生效的原理。可以看到Vue的很多第三方库都是利用Vue提供的一些机制实现的,甚至是一些只有了解源码细节才能想到的hack。所以,如果你想更深入的玩Vue,去源码学习是很有必要的,在学习Vue源码的过程中,你会被代码规范和一些精巧的设计所折服,你一定会收获很多。全面深入解析Vue.js源码(含Vue3.0源码解析)[5]