【Vue2.x源码系列03】数据驱动渲染(Render、Update)
上一篇文章我们介绍了 Vue2模版编译原理,这一章我们的目标是弄清楚模版 template和响应式数据是如何渲染成最终的DOM。数据更新驱动视图变化这部分后期会单独讲解
我们先看一下模版和响应式数据是如何渲染成最终DOM 的流程
Vue初始化
new Vue发生了什么
Vue入口构造函数
function Vue(options) {
this._init(options) // options就是用户的选项
...
}
initMixin(Vue) // 在Vue原型上扩展初始化相关的方法,_init、$mount 等
initLifeCycle(Vue) // 在Vue原型上扩展渲染相关的方法,_render、_c、_v、_s、_update 等
export default Vue
initMixin、initLifeCycle方法
export function initMixin(Vue) {
Vue.prototype._init = function (options) {
const vm = this
vm.$options = options // 将用户的选项挂载到实例上
// 初始化数据
initState(vm)
if (options.el) {
vm.$mount(options.el)
}
}
Vue.prototype.$mount = function (el) {
const vm = this
el = document.querySelector(el)
let ops = vm.$options
// 这里需要对模板进行编译
const render = compileToFunction(template)
ops.render = render
// 实例挂载
mountComponent(vm, el)
}
}
export function initLifeCycle(Vue) {
Vue.prototype._render = function () {} // 渲染方法
Vue.prototype._c = function () {} // 创建节点虚拟节点
Vue.prototype._v = function () {} // 创建文本虚拟节点
Vue.prototype._s = function () {} // 处理变量
Vue.prototype._update = function () {} // 初始化元素 和 更新元素
}
在 initMixin 方法中,我们重点关注 compileToFunction模版编译 和 mountComponent实例挂载 2个方法。我们已经在上一篇文章详细介绍过 compileToFunction 编译过程,接下来我们就把重心放在 mountComponent 方法上,它会用到在 initLifeCycle 方法给Vue原型上扩展的方法,在 render 和 update章节会做详细讲解
实例挂载
mountComponent 方法主要是 实例化了一个渲染 watcher,updateComponent 作为回调会立即执行一次。watcher 还有一个其他作用,就是当响应式数据发生变化时,也会通过内部的 update方法执行updateComponent 回调。
现在我们先无需了解 watcher 的内部实现及其原理,后面会作详细介绍
vm._render 方法会创建一个虚拟DOM(即以 VNode节点作为基础的树),vm._update 方法则是把这个虚拟DOM 渲染成一个真实的 DOM 并渲染出来
export function mountComponent(vm, el) {
// 这里的el 是通过querySelector获取的
vm.$el = el
const updateComponent = () => {
// 1.调用render方法创建虚拟DOM,即以 VNode节点作为基础的树
const vnode = vm._render() // 内部调用 vm.$options.render()
// 2.根据虚拟DOM 产生真实DOM,插入到el元素中
vm._update(vnode)
}
// 实例化一个渲染watcher,true用于标识是一个渲染watche
const watcher = new Watcher(vm, updateComponent, true)
}
接下来我们会重点分析最核心的 2 个方法:vm._render
和 vm._update
render
我们需要在Vue原型上扩展 _render 方法
Vue.prototype._render = function () {
// 当渲染的时候会去实例中取值,我们就可以将属性和视图绑定在一起
const vm = this
return vm.$options.render.call(vm) // 模版编译后生成的render方法
}
在之前的 Vue $mount过程中,我们已通过 compileToFunction方法将模版template 编译成 render方法,其返回一个 虚拟DOM。template转化成render函数的结果如下
<div id="app" style="color: red; background: yellow">
hello {{name}} world
<span></span>
</div>
ƒ anonymous(
) {
with(this){
return _c('div',{id:"app",style:{"color":"red","background":"yellow"}},
_v("hello"+_s(name)+"world"),
_c('span',null))
}
}
render 方法内部使用了 _c、_v、_s 方法,我们也需要在Vue原型上扩展它们
- _c: 创建节点虚拟节点(VNode)
- _v: 创建文本虚拟节点(VNode)
- _s: 处理变量
// _c('div',{},...children)
// _c('div',{id:"app",style:{"color":"red"," background":"yellow"}},_v("hello"+_s(name)+"world"),_c('span',null))
Vue.prototype._c = function () {
return createElementVNode(this, ...arguments)
}
// _v(text)
Vue.prototype._v = function () {
return createTextVNode(this, ...arguments)
}
Vue.prototype._s = function (value) {
if (typeof value !== 'object') return value
return JSON.stringify(value)
}
接下来我们看一下 createElementVNode 和 createTextVNode 是如何创建 VNode 的
createElement
每个 VNode 有 children,children 每个元素也是一个 VNode,这样就形成了一个虚拟树结构,用于描述真实的DOM树结构,即我们的虚拟DOM
// h() _c() 创建元素的虚拟节点 VNode
export function createElementVNode(vm, tag, data, ...children) {
if (data == null) {
data = {}
}
let key = data.key
if (key) {
delete data.key
}
return vnode(vm, tag, key, data, children)
}
// _v() 创建文本虚拟节点
export function createTextVNode(vm, text) {
return vnode(vm, undefined, undefined, undefined, undefined, text)
}
// 虚拟节点
function vnode(vm, tag, key, data, children, text) {
return {
vm,
tag,
key,
data,
children,
text,
// ....
}
}
VNode 和 AST一样吗?
我们的 VNode 描述的是 DOM元素
AST 做的是语法层面的转化,它描述的是语法本身 ,可以描述 js css html
虚拟DOM
DOM是很慢的,其元素非常庞大,当我们频繁的去做 DOM更新,会产生一定的性能问题,我们可以直观感受一下div元素包含的海量属性
在Javascript对象中,Virtual DOM 表现为一个 Object对象。并且最少包含标签名 (tag)、属性 (attrs) 和子元素对象 (children) 三个属性,不同框架对这三个属性的名命可能会有差别。
实际上它只是一层对真实DOM的抽象,以JavaScript 对象 (VNode 节点) 作为基础的树,用对象的属性来描述节点,最终可以通过一系列操作使这棵树映射到真实环境上
vue中 VNode结构如下
export default class VNode {
tag: string | void;
data: VNodeData | void;
children: ?Array<VNode>;
text: string | void;
elm: Node | void;
ns: string | void;
context: Component | void; // rendered in this component's scope
functionalContext: Component | void; // only for functional component root nodes
key: string | number | void;
componentOptions: VNodeComponentOptions | void;
componentInstance: Component | void; // component instance
parent: VNode | void; // component placeholder node
raw: boolean; // contains raw HTML? (server only)
isStatic: boolean; // hoisted static node
isRootInsert: boolean; // necessary for enter transition check
isComment: boolean; // empty comment placeholder?
isCloned: boolean; // is a cloned node?
isOnce: boolean; // is a v-once node?
constructor (
tag?: string,
data?: VNodeData,
children?: ?Array<VNode>,
text?: string,
elm?: Node,
context?: Component,
componentOptions?: VNodeComponentOptions
) {
/*当前节点的标签名*/
this.tag = tag
/*当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息*/
this.data = data
/*当前节点的子节点,是一个数组*/
this.children = children
/*当前节点的文本*/
this.text = text
/*当前虚拟节点对应的真实dom节点*/
this.elm = elm
/*当前节点的名字空间*/
this.ns = undefined
/*编译作用域*/
this.context = context
/*函数化组件作用域*/
this.functionalContext = undefined
/*节点的key属性,被当作节点的标志,用以优化*/
this.key = data && data.key
/*组件的option选项*/
this.componentOptions = componentOptions
/*当前节点对应的组件的实例*/
this.componentInstance = undefined
/*当前节点的父节点*/
this.parent = undefined
/*简而言之就是是否为原生HTML或只是普通文本,innerHTML的时候为true,textContent的时候为false*/
this.raw = false
/*静态节点标志*/
this.isStatic = false
/*是否作为跟节点插入*/
this.isRootInsert = true
/*是否为注释节点*/
this.isComment = false
/*是否为克隆节点*/
this.isCloned = false
/*是否有v-once指令*/
this.isOnce = false
}
// DEPRECATED: alias for componentInstance for backwards compat.
/* istanbul ignore next https://github.com/answershuto/learnVue*/
get child (): Component | void {
return this.componentInstance
}
}
- 提升效率。操作 DOM的代价是昂贵的,使用 diff算法,可以减少 JavaScript操作真实DOM 带来的性能消耗
通过 Virtual DOM 改变真正的 DOM并不比直接操作 DOM效率更高。恰恰相反,Virtual DOM 仍需要调用 DOM API 去操作 DOM,并且还会额外占用内存。but!!!我们可以通过 diff算法,找到需要更新的最小单位,最大限度地减少DOM操作。而且在大量频繁数据更新后,并不会立即重流重绘,而是批量操作真实的 DOM,最大限度的减少DOM操作,从而提升性能
- 跨平台。抽象了原本的渲染过程,提供了一个中间抽象层(runtime-dom/src/nodeOps),使我们可以在不接触真实DOM 的情况下操作 DOM,实现了跨平台的能力。而不仅仅局限于浏览器的 DOM,可以是安卓和 IOS 的原生组件,也可以是近期很火热的小程序。
runtime-dom/src/nodeOps 这里存放常见 DOM操作API,不同运行时(浏览器、小程序......)提供的具体实现不一样,最终将操作方法传递到 runtime-core中,所以 runtime-core不需要关心平台相关代码
update
vm._update 的作用就是把 VNode 渲染成真实的DOM
vm._update 被调用的时机有 2 个,一个是首次渲染,一个是数据更新的时候。我们暂时先不考虑数据更新部分
Vue.prototype._update = function (vnode) {
// 将vnode转化成真实dom
const vm = this
const el = vm.$el
// patch既有初始化元素的功能 ,又有更新元素的功能
vm.$el = patch(el, vnode)
}
vm._update 核心就是调用 patch 方法,parentElm 就是 oldVNode 的父元素,即我们的 body 节点,通过 createElm 递归创建一个完整的 DOM树 并 插入到 body 节点中,然后删除老节点
// 利用vnode创建真实元素
function createElm(vnode) {
let { tag, data, children, text } = vnode
if (typeof tag === 'string') {
// 标签
vnode.el = document.createElement(tag) // 这里将真实节点和虚拟节点对应起来,后续如果修改属性了
patchProps(vnode.el, data)
children.forEach(child => {
vnode.el.appendChild(createElm(child))
})
} else {
vnode.el = document.createTextNode(text)
}
return vnode.el
}
// 对比属性打补丁
function patchProps(el, props) {
for (let key in props) {
if (key === 'style') {
// { color: 'red', "background": 'yellow' }
for (let styleName in props.style) {
console.log(styleName, props.style[styleName])
el.style[styleName] = props.style[styleName]
}
} else {
el.setAttribute(key, props[key])
}
}
}
// patch既有初始化元素的功能 ,又有更新元素的功能
function patch(oldVNode, vnode) {
// 写的是初渲染流程
const isRealElement = oldVNode.nodeType
if (isRealElement) {
const elm = oldVNode // 获取真实元素
const parentElm = elm.parentNode // 拿到父元素
let newElm = createElm(vnode)
parentElm.insertBefore(newElm, elm.nextSibling)
parentElm.removeChild(elm) // 删除老节点
return newElm
} else {
// diff算法,暂时先不考虑
}
}