超详细的Vue渲染原理讲解 |
您所在的位置:网站首页 › 网页渲染流程视频 › 超详细的Vue渲染原理讲解 |
目录
一、Vue简介1. MVVM、MVP和MVC2. Vue的基本配置
二、Vue渲染原理1. HTML与模板2. Vue组件的完整渲染过程(1). Vue自身的初始化阶段(2). 组件实例的生命周期管理阶段a. 实例初始化阶段b. 组件挂载、更新和销毁阶段
总结
本文的主要内容是详细地介绍Vue的内部渲染原理,从而帮助大家深入掌握关于Vue Options、生命周期等概念。为了帮助Vue使用经验较少的同学快速理解Vue,我们先从Vue的简介开始,第二部分再详细介绍Vue渲染原理。
一、Vue简介
1. MVVM、MVP和MVC
MVVM,即model、view、view-model,业务层、视图层以及两者的绑定层。Vue的设计参考了MVVM架构,但不完全是一个MVVM框架,因为它没有严格意义上的绑定层。 MVVM要求开发者将业务层和视图层分开:业务层负责管理数据;视图层负责页面渲染;绑定层负责双向绑定,即视图层操作通过绑定层影响业务数据,业务数据的变化通过绑定层影响视图渲染,这三层是完全解耦的: 这里h1的文本内容就是由view层管理的;而model层负责的是管理业务数据title。现在view和model层都有了,下面我们就要让h1的文本内容和title的内容保持同步,这就是view-model层要做的事。假设我们有这样一个xml文件: {{title}}它表示h1的文本和变量title的值是绑定的,当一个发生变化时,另一个应该同步变化。 如果我们能够编写一个框架,自动根据一个值,更新另一个值,那么实际上就是实现了view-model层,我们的框架就可以称为一个MVVM框架。以后只要我们定义好视图和业务逻辑,并用一个xml文件描述两者的绑定关系,就可以实现视图和数据的同步了,这也是谷歌的Data Binding的基本实现思路。 MVVM模式参考自MVP模式,而两者都是借鉴自经典的MVC模式。先来说说MVVM和MVP的差异。 MVP的全写是Model-View-Presenter,即业务层、视图层和控制层。这里的控制层Presenter与view-model层的作用是完全一样的,就是负责对视图层和业务层进行同步。但不同的是,Presenter的实现较为复杂,它要求开发者必须手动封装两者的同步逻辑,如jQuery框架就可以看做一个MVP模式的实现: let title = '这是标题'; $('h1').text(title);开发者需要定义当变量变化时如何更新视图,以及获取到用户输入时如何更新变量,这两者加起来就是它的Presenter层实现。这种方式也可以实现视图和业务逻辑的同步,但显然,MVP的控制层逻辑要比MVVM的声明式绑定写起来复杂得多,所以MVP模式基本上已经被MVVM代替。 而MVC是上述两个模式的鼻祖,也曾是java中最经典的模式之一,它的全写是Model-View-Controller。model和view层与上述两个模式一致,controller层与MVP的Presenter层一样,也被称作控制层。不过,MVC中的controller功能很弱,它实际上只是一个路由层,真正实现视图与业务数据同步的是model层的service,controller的作用就是找到对应的service而已。controller层的功能过于薄弱使得model层变得很复杂,所以目前MVC模式已经很少使用。 Vue之所以不是一个MVVM框架,是因为它没有真正的view-model层。在Vue中,view-model是通过模板语法间接实现的,Vue通过编译模板,可以解析出视图层和业务层的绑定关系,通过响应式系统和虚拟DOM来实现两者的同步,详细的过程后面会加以介绍。 2. Vue的基本配置由于讲解Vue配置不是本文的重点,这里我们只是简单地概括一下,需要详细学习这部分内容的可以阅读Vue的官方文档:Vue官方网站。 为了简单,我们先以一个cdn版本的Vue为例: let app = new Vue({ el: '#app', data: { title: '标题' }, template: '{{title}}', methods: { changeTitle (title) { this.title = title; } } }); setTimeout(function () { app.changeTitle('新标题'); }, 1000);执行完script脚本对应的框架代码后,window上会新增一个构造函数Vue,用于构建Vue实例。我们向new Vue传入了一个配置对象,这个对象包含如el、data、template、methods等属性,用于为Vue实例添加属性和方法。Vue会根据这些配置,生成一个可以自动生成视图的响应式的Vue组件,它不仅负责管理视图层和业务层,还负责两者的同步。 我们来简单看一下一些常用配置的作用: el 根元素,该参数只能由根节点声明,表示当前Vue应用需要被挂载到页面的哪个DOM节点上。如上面的例子指定了根元素为#app,那么该Vue实例生成的DOM就会直接替换id为app的元素。name 组件的名字,主要用于全局注册组件,如: import MyComponent from 'MyComponent'; Vue.component(MyComponent.name, MyComponent); components 声明当前组件的外部依赖,相当于局部注册组件,在编写单组件时,如果需要用到其他的项目内组件通常会提供该参数。props 来自父级组件的数据依赖,这个依赖是响应式的。data 业务数据,这个参数是model层的核心,相关的业务逻辑都是围绕data展开的。computed 计算属性,定义一组变量,这组变量的值是基于一个或多个props、data计算而来,computed内变量的值会根据这些依赖的值变化而自动更新,并且会自动缓存上次的计算结果。watch 手动监控props、data或者computed的变化,定义变化时的回调函数。生命周期方法 定义Vue组件在各个生命周期需要执行的回调函数,Vue在执行到对应的阶段时会调用它们。生命周期与Vue组件创建的细节是第二部分渲染原理的重点。methods 组件的工具方法集。methods定义了一组工具方法,可以在computed、watch、生命周期方法或者其他工具方法中调用。有了这些基本知识的铺垫,下面我们就开始详细介绍Vue的渲染过程。 二、Vue渲染原理我们先来打通HTML与Vue模板的关系。 1. HTML与模板下面是一个常见的Vue例子: 注意,这并不是HTML代码,它仍然是Vue模板(只是这里没有定义数据绑定而已)。Vue会用纯JavaScript来描述上述结构,类似下面这样(这不是真正的内部表示,后面我们会看到Vue的真实内部表示): 我们看到,Vue用一个JavaScript对象描述了编译出来的模板(如果有数据绑定,它还会描述模板与数据的绑定关系)。接下来只需要调用原生的DOM方法依次创建这里的每一个节点,然后将它们挂载成一棵DOM子树,并插入页面,就可以得到真正的HTML。我们一般把这个树状JavaScript对象称为虚拟DOM树。下面是上面的JavaScript对象对应的DOM结构: Vue的执行过程主要分两大阶段:Vue自身的初始化阶段和实例的生命周期管理阶段。 当通过脚本或者import Vue from 'vue'引入Vue时,Vue框架本身的代码会被执行,这一阶段的作用是对框架自身进行初始化。简单来说,就是定义构造函数function Vue,并为其添加大量的原型方法(以及一些工具方法),下面是一个说明示例: (function(){ ... // 定义构造函数 function Vue (options) { this._init(options); } // 定义原型方法 Vue.prototype._init = function (options) { ... } Vue.prototype._update = function () { ... } ... window.Vue = Vue; })();而在执行new Vue({ ... })语句时,就进入了实例的生命周期管理阶段。这一阶段是调用上述构造函数,构造和初始化Vue实例,并且管理它的整个生命周期。 下面我们就具体来看看这两个阶段都做了什么。 (1). Vue自身的初始化阶段打开Vue源码的src > core > instance > index.js文件,可以看到以下代码: {{ message }} ", data () { return { message: 1 } }, mounted () { console.log(this.$data); setTimeout(() => { this.message = 2; }, 1000); setTimeout(() => { this.$destroy(); }, 5000); } })这个vue-2.6.10-learning.js是我下载到本地的一个Vue代码文件,我在文件内各个关键位置打上了console输出,以此来显式观察Vue的执行过程,下面是输出结果(以$开头的是直接暴露给开发者的接口,以_开头的是框架内部方法,不推荐开发者使用): 首先initMixin为Vue混入了_init原型方法,它的作用是根据传入的options初始化Vue组件实例。具体的初始化过程是生命周期管理阶段的重点之一,下一部分会详细介绍。 接着stateMixin为Vue混入了$data、$props、$set、$delete和$watch这5个与组件状态有关的原型方法或属性:$data和$props是_data和_props(这两个属性是初始化Vue实例时由_init添加到组件对象上的)的只读版本;$set和$delete是Vue提供的全局响应式方法,我们知道,由于JavaScript的限制,直接为已有对象添加或删除属性时,该属性不会被响应式系统观测到,$set和$delete就是响应式地新增或删除属性的全局方法;$watch与watch配置的作用是一致的,只是它可以通过js来手动调用,而不用提前在options中声明。 下面eventsMixin混入了$on、$once、$off、$emit这四个与事件相关的原型方法。$on用于向实例注册事件监听;$once则是注册一个只会被调用一次的事件监听;$off用于取消某个或某类事件监听;$emit用于触发某个事件。 然后lifecycleMixin则向Vue混入了_update、$forceUpdate和$destroy这4个与实例生命周期相关的原型方法。_update负责组件的更新;$forceUpdate用于强制更新组件,一般是由于某些编码bug导致数据与视图不同步时手动调用;$destroy用于销毁组件。 最后,renderMixin会向Vue混入$nextTick和_render这两个与组件渲染相关的原型方法。$nextTick用于将一段代码逻辑推入微任务队列,以保证视图更新后才会执行;_render负责渲染组件,它的主要实现逻辑是调用组件的render函数(render函数由模板编译而来,也可以手工编写)生成DOM,然后挂载到页面上。 上面的方法位于Vue的原型对象上,对任何一个Vue组件都是通用的,执行完上述代码后,内存中的Vue结构是这样的: 这一阶段开始的标志就是调用new vue()来构造一个Vue组件实例。自该语句开始,一个Vue应用正式被构建。该阶段大致又可分为两个阶段,分别是初始化阶段和挂载(销毁)阶段。当初始化完成时,如果el配置存在,则立即进入挂载阶段,否则将等待手动调用$mount才会进入挂载阶段。 我们回顾一下Vue构造函数的实现: function Vue (options) { if (process.env.NODE_ENV !== 'production' && !(this instanceof Vue) ) { warn('Vue is a constructor and should be called with the `new` keyword') } this._init(options) }真正有效的就只有一行代码:this._init(options),即调用原型上的_init方法,传入options,初始化组件实例。下面是初始化阶段的整个过程输出: 初始化完毕后的内存图是这样的: 如果没有el属性,则需要等到手动调用$mount方法时才会进行挂载。 在讲解挂载阶段之前,我们再回头探讨一下响应式系统。我们知道,响应式系统的核心对象是data,所以响应式系统主要是在initData中构建起来的(props、computed等都间接地依赖data,因此它们的响应式本质上都来自于data的响应式特性),我们剥离出initData最关键的一行代码: function initData () { ... // 调用observe观测data observe(data, true /* asRootData */) }observe函数用于将data转化为响应式,也就是搭建响应式系统。响应式系统包括三个核心对象:Observer、Dep和Watcher。 Observer以__ob__的属性的形式存在与数据对象上,用于观测对象属性的变化。Dep以dep属性的形式存在于__ob__属性内,负责帮助Observer收集和通知订阅者。而Watcher就是订阅者,它存在于dep属性的subs数组属性内,负责在数据发生变化时执行某些操作(如更新视图或执行回调)。三者的结构如下: // initData执行完毕后组件的_data属性 // 包含__ob__属性证明它已经是响应式的 this._data = { message: ‘’, __ob__: { dep: { subs: [watcher, …] // 组件外部watcher } }, get message (): {} // 调用get时,依赖会被收集 set message (): { // 内部包含对该属性的观察者对象 // 这里包含组件内对message的订阅者(watcher) } }调用observe观测data时,Vue会为它添加一个Observer类型的__ob__属性,这个过程中使用Object.defineProperty递归地修改data每个属性的get和set,同时__ob__属性还会初始化一个dep属性,用于管理相关依赖,这些依赖(即watchers)被保存在dep属性的subs数组内。调用new Watcher生成一个订阅者时,它会自动进入该数据对象的订阅者队列,而当数据变化时,Observer会通知Dep,Dep则依次调用每个watcher提供的run方法,执行对应的回调,以此实现响应式系统。具体的过程可参考我之前关于响应式系统的介绍:Vue源码笔记之响应式系统。 b. 组件挂载、更新和销毁阶段组件初始化完毕后,如果el属性存在,就可以进行挂载以生成真正的DOM了。下面是整个挂载、更新和销毁过程: 渲染函数: vm._render = function(){ with(this){ return _c('div’, { attrs:{"id":"app"} }, [_c('ul',_l((items),function(item){ return _c('li', [_v("\n itemid:"+_s(item.id)+"\n ")] )} ) )] )} }上述模板与下面的渲染函数完全等价,可以相互转换。渲染函数里的_c、_l、_v、_s等都是Vue定义的辅助渲染函数,用于解析模板中不同的部分。如_c用于创建DOM,它主要基于document.createElement;_l用于解析列表,如v-for列表;_v用于解析标签文本;_s用于解析变量的值,辅助渲染函数还有很多,这里暂不一一详述。 有了渲染函数,接下来就是定义一个用于渲染和更新组件的函数:updateComponent,它的大致实现如下: const updateComponent = () => { vm._update(vm._render()); }我们来看它的作用。vm._render()内部会调用上述render函数,新生成一个对DOM的虚拟描述,以下就是调用上述渲染函数生成的JavaScript对象: 这个虚拟DOM就是我们最终要渲染到页面上的HTML的js版本,它被传递给组件的_update方法执行渲染。这里所说的渲染包括首次绘制和更新,_update内部会根据旧的vnode是否存在来判断是首绘还是更新。_update的实现大致如下: Vue.prototype._update = function (vnode, hydrating){ ... if (!prevVnode) { // initial render vm.$el = vm.__patch__(vm.$el,vnode,hydrating,false) } else { // updates vm.$el = vm.__patch__(prevVnode, vnode) } ... }当旧的vnode不存在,说明这是首次绘制,__patch__将依据虚拟DOM生成真实DOM并绘制到页面。如果旧的vnode是存在的,说明当前组件已经被绘制到页面上了,这时候__patch__将负责比对两个vnode,然后判断如何最高效地更新真实DOM,最后去更新视图。__patch__过程较为复杂,如果感兴趣,可以参考我之前关于虚拟DOM的博客:Vue源码笔记之虚拟DOM,里面有详细的patch过程和图解。 也就是说,调用updateComponent时,如果组件尚未渲染,则依据vnode渲染组件(该过程主要就是用document.createElement创建真实DOM标签,然后用appendChild添加到页面上);如果组件已经存在,则比对vnode,产生高效更新算法,用原生的DOM方法去操作真实DOM,完整视图更新。 显然,定义这个函数是为了在数据变化时自动调用以更新视图,也就是说它必须接入到响应式系统才有意义。接下来的代码就是将其接入响应式系统: function mountCompnent () { ... // 上述函数 const updateComponent = () => { vm._update(vm._render()); } // 将updateComponent接入响应式系统 new Watcher(vm, updateComponent, noop, { before () { callhook('beforeUpdate'); } }) }还记得watcher的作用吗,它是数据对象的订阅者,负责在数据变化时执行某些操作。上面的代码为当前组件实例构造了一个watcher,初始化watcher的过程中会触发data属性的get方法,因此这个watcher就会被Dep收集起来,传入的回调函数正是它的updateComponent方法。当数据变化时,Observer会通知Dep,Dep依次调用订阅者watcher的run方法,run里面会执行上述回调函数(即updateComponent),于是视图得到更新。这样就实现了修改数据之后自动更新视图。再次看一下此时data的结构: this._data = { message: ‘’, __ob__: { dep: { subs: [] } }, get message (): {} // 调用get时,依赖会被收集 set message (): { // 内部包含对该属性的观察者对象 // 这里包含组件内对message的订阅者(watcher) } }注意,_data内的__ob__.dep.subs保存的并不是当前组件内的watcher,而是外部watcher。如果当前模板中这样绑定了data内的message:{{message}},那么组件对应的watcher就会以闭包的形式保存在set message () { ... }内。当message值变化时,set方法就会调用,它会继而触发依赖收集者dep的notify方法通知订阅者,该方法会依次调用subs中每个watcher的run方法,依次调用它们提供的回调函数。对于用于更新组件的watcher来说,这个回调函数就是它的updateComponent方法。 而每当updateComponent被调用前,Vue都会调用callHook('beforeUpdate'),执行该生命周期钩子函数,因为视图即将被更新。当然,当updateComponent执行完毕后,Vue又会调用callHook('updated'),执行更新完毕的生命周期钩子函数。 最后是组件的销毁过程。当手动调用this.$destroy(),或由于v-if属性等原因导致组件必须被销毁时,Vue主要执行了以下过程: 最后附赠本文的示例代码和完整的console输出供大家学习: var app = new Vue({ el: '#app', template: "标题{{ message }} ", data () { return { message: 1 } }, mounted () { console.log(this.$data); setTimeout(() => { this.message = 2; }, 1000); setTimeout(() => { this.$destroy(); }, 5000); } })本文主要讲解了Vue组件的完整渲染过程,如果能结合源码看本文,效果会更好。通过本文,我希望能够帮助读者对Vue的渲染过程有一个全局的了解,从而能够更深入地思考实际项目中出现的一些问题。 |
今日新闻 |
推荐新闻 |
CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3 |