vuejs构建后的版本分为完整版和只包含运行时的版本,后者与前者的区别只是少了模板编译的部分,所以此篇笔记主要介绍完整版的原理学习过程。
整体架构
vuejs的整体结构分为3个部分:核心代码、跨平台相关的代码和公用的工具函数(就是代码中的各种辅助函数)。其架构是分层的,最底层是一个普通函数,最上层是一个出口,也就是将一个完整的构造函数导出给用户使用。以构建web平台下运行的完整版为例,首先创建一个Vue的构造函数,然后向构造函数中的prototype上面添加一些方法,再向Vue构造函数自身添加一些全局的api,然后将web平台特有的代码导入进来,最后将编译器导入进来,最终将所有代码连同整个Vue函数导出出去,到现在我们已经可以使用vuejs正常操作了。
以脚手架搭建起来的vue框架为例:
可以看到代码中是实例化出了一个Vue然后将其挂载到了一个dom元素上(或者传入选项el参数),所以首先要知道new Vue()的时候Vue都做了什么操作来为我们后续的使用提供便利。
想知道new Vue()的时候Vue做了什么,就先来了解一下Vue的生命周期。
生命周期
vuejs实例的生命周期分为4个阶段:初始化阶段->模板编译阶段->挂载阶段->卸载阶段
- 初始化阶段是在new Vue()至created生命周期钩子之间,会触发beforeCreate和created两个生命周期钩子。这个阶段的主要目的是在vuejs实例上面初始化一些属性,事件以及响应式数据。如props、methods、data、computed、watch、provide、inject等
- 模板编译阶段是在created到beforeMount之间,在模板编译的过程中,不会触发生命周期钩子函数,它只会判断是否有el选项然后判断是否有template选项,通过这两个选项获取到了模板之后,将模板编译成渲染函数。(只有完整版才会有的过程)
- 挂载阶段是将模板渲染到指定的dom节点中,并且开启Watcher来追踪依赖的变化。此阶段会触发两个生命周期钩子函数,beforeMount和mounted。挂载好的组件还会进行更新等操作,更新操作也会触发两个生命周期钩子beforeUpdate和updated
- 卸载阶段卸载依赖追踪,子组件与事件监听器等,会触发beforeDestroy和destroy
到此,已经得出结论,当new Vue()被调用的时候,会首先进行一些初始化的操作,然后进入模板编译阶段,最后进入挂载阶段。当vue被挂载好了之后,我们就可以使用它提供的方法和API进行后续的更新内容、卸载组件等开发工作了。
- 那vue是怎么进行初始化的?
- 初始化之后又是怎么进行模板编译的?
- 模板编译好了又是怎样实现挂载的?
vue的初始化工作
Vue.prototype._init = function( options ){ //初始化的一些代码 }
Vue通过在原型上添加_init方法实现数据的初始化,在new Vue()时执行这个方法,即可完成初始化的工作。
Vue.prototype._init = function( options ){
vm.$options = mergeOptions(resolveConstructorOptions(vm.constructor),options || {},vm)
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm,'beforeCreate')
initInjections(vm)
initState(vm)
initProvide(vm)
callHook(vm,'created')
//然后判断是否在实例化vuejs的时候传递了el选项,如果传递了那么就开启模板编译和挂载阶段
//如果没有传递el选项,则不进入下一个生命周期流程
//需要手动执行$mounted方法,手动开启模板编译阶段与挂载阶段
}
其中resolveConstructorOptions是获取当前实例中构造函数的options选项以及其父级的构造函数options(因为vue可能是一个子组件)。mergeOPtions就是将这些选项合并成一个对象,并且赋值到当前的vue实例的$options属性上。这样在当前组件就可以看到传入的选项,父级的选项和自身的选项,之后就可以利用$options中各种属性,辅助一些其它的流程设置。
初始化的顺序:先执行initLifecycle->initEvents->initRender 然后触发beforeCreate生命周期钩子 之后再执行 initIjections->initState->initProvide 最后触发created生命周期钩子,则初始化工作完成!
那这些init的函数分别都做了一些什么工作呢?
- initLifecycle:初始化实例属性。初始化流程的第一步是实例化vuejs内部需要用到的属性和提供给用户使用的外部属性。其内部实现并不复杂,只是在vue实例上面设置一些属性并给定一个初始值。(就是向对象中添加属性和默认值)
- initEvents:初始化事件。初始化事件是指将父组件在模板中使用v-on注册的事件添加到子组件的事件系统中。不包括HTML标签上面的绑定事件(因为事件是指绑定在模板上面的,模板编译的过程中,如果标签是普通标签,会将标签上面绑定的事件注册到浏览器事件中。如果标签是一个组件,则会实例化这个组件并将组件上绑定的属性和事件当做参数传递给它。所以我们只需要初始化父组件传递过来的事件就可以完成初始化事件的工作)
- 初始化的第一步是在当前vue实例上面初始化一个_events属性(为空对象,属性为事件名,值为事件函数),用来保存当前实例下的父级传递过来的事件
- 从vm.$options._parentListeners中取出父级传递过来的事件,如果组件是第一次被实例化,那么把这些事件进行遍历(使用this.$on)注册事件到当前实例的_events中。如果不是第一次进行实例化,那么就把原来的事件列表与这次实例化的事件列表进行对比。如果旧列表有而新列表没有,那么就需要将这个事件从_events中移除(使用this.$off),反之,要向_events中添加。如果新旧列表都有这个事件属性,但是事件函数有变化,那么就在_events中替换当前属性的值为新列表中的事件函数。
- initInjections:初始化inject。inject用法不赘述。通过provide注入的内容可以被所有子孙组件通过inject得到。所以inject就是通过配置的key去当前组件读取内容(去当前组件或者父组件的_provide属性去读取,因为在provide注入内容的时候,其实是将内容注册到当前组件实例的_provide中),读不到就去它的父组件读($parent,初始化实例属性这一步处理的),以此类推直到读到为止。读到了之后就把它保存在当前实例上,所以我们可以通过this点inject中的key的方式访问到provide所注入的内容。
- initState:初始化状态。我们在编写代码的过程中,会在组件中添加data,methods,computed等状态,那它们是怎样被初始化的呢?首先,顺序是这样的:如果你的组件(一个Vue实例)中用到了props,那就先初始化props,随后如果你写了methods,那就随后初始化methods,然后data,然后computed,然后watch。显然,有一些简单的组件如果没有用到某一个状态,那么它是不会参与初始化状态的流程的。这个初始化顺序也决定可我们在后一个状态可以观察前一个状态。比如:我们可以在data中使用props中的属性。
- 在初始化状态的这一步,我们首先会向Vue实例(当前初始化的组件)中初始化一个_watchers的属性。它可以保存当前组件中的所有watcher实例,无论是使用this.$watcher注册的还是watcher()注册的。
- 初始化props:我们设置props的意义就是从传递过来的(有可能是父组件传递过来的,也有可能是实例化的时候传递过来的选项)数据中,挑选出当前组件需要的数据,然后保存在当前实例中。回顾一下props的写法,可以是个对象,可以是个数组,所以初始化props的第一步就是将props的格式统一(规格化),所以随便你怎么写,怎么在父组件传,它的内部都是一个以小驼峰为key的对象结构进行操作的。
- 规格化props数据之后就进行数据挑选,细节处理。初始化的过程中有两种props数据,一种是在当前组件自己设置的,一种是模板编译父组件传递过来的或者实例化的时候传递进来的选项(反正就是外部传递过来的),这时候就需要对比两个props,从传入进来的props中取出当前组件需要的props的各项key的各项值,并保存在当前组件的_props属性中,然后通过代理,设置访问this.X时就访问到this._props.X。然后我们就可以在后续操作通过this.XXX访问props的数据。这其中有各种细节操作:
- 根实例的props需要设置响应式
- 如果父级没有传递props的值就就取默认值
- props值的各种类型处理
- 保存当前props的key到当前实例的_propKeys中便于后续的更新操作
- 初始化methods:初始化methods,只需要循环遍历选项中的methods对象并将每个属性依次挂载到当前组件上就行。(在这期间需要判断methods中的属性名是否与props中的重复,或者以$或者_开头就会在控制台发出警告)
- 初始化data:就是将选项中的data数据保存在vm._data中,然后在vm上设置一个代理,通过this.X就可以访问到this._data.X(同初始化props)。不过在此期间需要做一些细节判断,比如data选项是否合法。其值是否为需要执行的函数(如果是就执行之后将返回的结果赋值给data)。是否与props和methods属性名重复(如果与props重复就不会设置在代理上,如果与methods重复则还是会设置在代理上),两种情况都会在控制台发出警告。是否是以$或者_开头,如果是以二者之一为开头,那么不会将属性设置在代理上,并且在控制台发出警告。最后将vm._data中的数据转换为响应式的。 响应式原理
- 初始化computed:计算属性的结果会被缓存,只有在计算属性所依赖的响应式属性或者说计算属性的返回值发生了变化,计算属性才会被重新计算。
- 初始化计算属性的时候,会在vm上新增_computedWatchers用来保存当前实例上面的computed的watcher。
- 计算属性的结果是否发生了变化是通过watcher的dirty属性来分辨的。当dirty为true的时候,说明需要重新计算,反之,直接返回缓存的结果。
- 模板中使用watcher读取计算属性,计算属性读取属性函数中的数据并将自己的watcher和组件的watcher添加至这些属性的依赖列表当中,当计算属性中的某一个数据发生了变化,就通知计算属性的watcher数据发生了变化,计算属性的watcher收到通知之后,重新进行计算,再对比计算属性的值是否发生了变化,如果发生了变化,就通知组件重新进行渲染。
- 如果我们自己设置了计算属性的watch,那么当数据发生变化时会通知我们设置的watch.如果没有设置,就会通知供计算属性使用的watch实例。
- 初始化计算属性的时候,如果发现计算属性与data或者props重名的话,会在控制台打出警告。如果与methods重名,计算属性不会发出警告,并且会失效。
- 初始化watch:初始化watch的逻辑内部其实就是是调用了this.$watch(expOrFn,handler,options)方法。将不同写法的watch统一进行处理。
- 初始化provide:初始化provide时,只需要将provide选项添加到vm._provided中即可(provide要么是一个对象,要么是返回一个对象的函数,如果是对象直接将对象赋值给vm._provided,如果是函数,那么就执行了之后赋值给vm._provided)
至此,Vue的初始化工作完成,接下来该进入模板编译阶段。
Vue的模板编译
初始化工作完成之后,就要进入模板编译阶段。首先vue需要判断我们是否传入了el选项,如果没有el选项,当vm.$mount被调用的时候判断是否传入了模板选项(template)?如果有template则通过template选项获取模板,如果没有则通过el选项获取模板。随后将获取到的模板编译成为渲染函数。
那模板是怎么编译成渲染函数的呢? 将模板编译成渲染函数分为两个步骤:
-
1.将模板解析为AST(抽象语法树)
-
2.使用AST生成渲染函数 但是由于静态节点不需要总是遍历,所以还要加一个操作,遍历AST标记静态节点,这样在虚拟dom中更新节点的时候,如果发现节点有这个标记,就不会重新渲染它。 这三步操作分别对应了三个模块来具体实现:
-
将模板解析成AST(解析器)
-
遍历AST标记静态节点(优化器)
-
使用AST生成渲染函数
解析器
解析器要实现的功能就是将模板解析成AST(就是一个对象,用来描述节点,对象里保存了一些节点的相关信息,例如节点类型,节点名称,节点parent,节点children)。解析器分为好几个子解析器,模板编译最主要的是HTML解析器。它的内部实现了很多钩子函数:start,end,chars,comment等,这些钩子函数在解析html模板时相应的被触发,例如,解析到开始标签就会触发start钩子函数。
模板就是一个字符串,模板解析就是要将模板字符串解析成AST语法树(解析成一个大对象)。当遇到开始标签的时候触发start钩子函数,构建AST并将当前节点压入栈中,随后继续解析,遇到文本节点就触发chars钩子函数构建AST写入children属性中(因为文本节点没有子节点所以不会被推入栈中),直到遇见一个结束标签,触发end钩子函数,并从栈中弹出,一个小的AST对象就构建完成。(压入栈中是为了构建出父子间的层级关系,遇到一个结束标签就弹出一个,是为了保证在栈中的节点就是当前构建节点的父节点)。直到HTML解析器的钩子函数不在被触发,则AST构建完成。
优化器
优化器主要的工作就两点:
- 找出所有的静态节点并标记
- 找出所有的静态根节点并标记 判断当前节点是否是静态节点,在用递归判断子节点。静态节点的概念:
- 当前节点不能是带变量的文本节点
- 当前节点没有动态绑定
- 没有v-if,v-for,v-else
- 不是内置标签
- 不是组件
- 节点中不存在动态节点才会有的属性 因为判断是从根节点向下判断的,所以当父节点被标记为静态节点而子节点为动态节点时就会有一个冲突,因为一个静态节点下的子节点都应该是静态节点,不然这个节点就是一个动态节点。所以,当递归发现该节点下的子节点为动态节点时,还要将父级的静态节点标记取消。(所谓的标记,就是在AST对象中增加一个static属性用true和false区分)。找出静态节点后寻找静态节点,一旦节点被标记为静态根节点,就停止寻找,因为静态根节点的子节点一定是静态节点。
代码生成器
代码生成器是模板编译的最后一步,它的作用是将AST转换成渲染函数中的内容(生成代码字符串)。代码字符串可以被包装在渲染函数中执行,执行后的结果是生成一份VNode,虚拟dom可以通过这个VNode来渲染视图。
生成代码字符串其实就是遍历AST生成字符串然后拼接在一起的过程,最先生成根节点,然后在子节点字符串生成之后,拼接在根节点的参数中,一层一层的拼接直到完成。代码字符串格式如下:
_c('div',{attrs:{"id":"XXX"},[_c('div',[_c('p',[_v("hello"+_s(name))])])])
_c函数就是创建元素(createElement),_v就是创建文本节点,_s就是toString变量。他们被with包裹,当渲染函数被执行的时候,with中的代码字符串也被执行,创建出一份VNode用于视图渲染。
到此,模板编译已经完成,那编译好的模板是怎么挂载的?
$mount
模板编译成的渲染函数会赋值给options.render。
挂载的方法:(comp是要挂载的组件)
- comp.$mount("#app")
- comp({el:"#app"})
- document.querySelector("#app").appendChild(comp.$el)
挂载的第一步,就是要先获取即将要挂载到的元素,也就是我们传入的el选项(el:"#app"),如果获取不到,就创建一个div,将这一步的获取结果保存下来。(渲染函数执行后的结果要挂载到该元素下)
第二步:判断options.render中是否有保存的渲染函数。如果没有发现模板编译后的渲染函数,vue就会去判断用户是否传入模板(template),也就是我们实例化的时候是否传入了这个选项。如果我们没有传入template选项,vue就会从我们传入的el选项中获取模板,如果获取不到就创建一个div将第一步保存的结果克隆到这一步创建的div中并并重新赋值给template。如果找到了,(template可以有三种方式存在,一种是模板字符串,一种是#开头的选择器,一种是DOM元素),对三种情况分别进行处理,处理后重新赋值给template。此时,无论最开始找没找到template模板,现在都有一个template模板了,然后在内部重新给它编译喽,重新生成一个render函数,并赋值给options.render。
第三步:在保证有渲染函数可以执行渲染之后,就开始实现挂载。在挂载前触发beforeMount生命周期钩子,随后使用new Watcher()将组件作为一个watcher保存在vm._watcher中,于是当组件状态发生了变化在watcher的update函数中重新执行渲染函数vm.render()。从而实现了挂载和响应式。然后触发mounted生命周期钩子。响应式原理
vm._watcher = new Watcher(vm,()=>{ // 实例化的时候执行第一次render()
// 在watcher中,如果第二个参数是一个函数,那么会监听该函数中用到的所有响应式数据
// 当其中一个状态发生了变化,watcher都能够得到通知。
// 当数据变化,触发watcher时,会执行update()使渲染函数重新进行渲染
vm.update(vm.render())
})
至此,vue就完成了从创建到呈现的过程。
本篇笔记参考《深入浅出Vue.js》 刘博文 著