Vue2的大致实现流程
1. new Vue()的过程发生了什么?
Vue在源码中为了方便扩展,定义一个函数式的类Vue。在构造函数Vue中调用了定义在Vue原型上的_init方法。在_init方法中进行了一系列初始化操作:实现了合并配置、定义全局变量、调用一些初始化的函数。
1.1 合并配置
将用户传递的options和当前构造函数的options合并成为一个对象赋值到vm.$options上。
// 合并用户传递的options和vm的配置到vm.$options上
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor as any),
options || {},
vm
)
1.2 定义了一些全局变量
vm._isVue = true
// avoid instances from being observed
vm.__v_skip = true
// effect scope
vm._scope = new EffectScope(true /* detached */)
vm._scope._vm = true
1.3 调用一些初始化的函数
下面展示一些 内联代码片
。
// 初始化生命周期
initLifecycle(vm)
// 初始化事件中心
initEvents(vm)
// 初始化渲染
initRender(vm)
// 调用执行beforeCreate的钩子函数
callHook(vm, 'beforeCreate', undefined, false /* setContext */)
// 初始化injections
initInjections(vm)
// 初始化状态
initState(vm)
// 初始化provide
initProvide(vm)
// 调用created的钩子函数
callHook(vm, 'created')
1.4 调用$mount挂载
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
2. init初始化
分析1.3中执行的一系列初始化函数的具体实现。
2.1 initLifecycle 初始化生命周期
initLifecycle中主要在Vue实例上挂载了一些属性并设置了默认值。值得注意的是$parent和 $root。
如果当前的parent存在并且当前实例不是抽象组件,如果当前父组件是抽象组件并且当前父组件也存在父级就继续向上找,直至找到第一个不是抽象组件的父级,就将当前父级赋值给vm. $parent。如果当前父级存在,给当前父级添加 $children属性,将当前组件实例vm push到 $children数组中,作为其中的一个子属性。通过这样的方式把父子组件关联上,在使用组件的时候可以通过this. p a r e n t / t h i s . parent / this.parent/this.children获取当前组件的父组件/子组件集合。
给当前组件实例vm添加 当前组件的根实例属性$root,判断当前组件父级是否存在,如果存在则组件的根实例就是当前的父级的根实例否则就是当前组件实例。
function initLifecycle(vm: Component) {
const options = vm.$options
let parent = options.parent
// 如果parent存在并且不是抽象组件
if (parent && !options.abstract) {
// 循环条件:如果当前组件的父组件是抽象组件并且也存在父级 继续往上寻找父级
while (parent.$options.abstract && parent.$parent) {
parent = parent.$parent
}
// 当前组件的父级存在就在父级挂载$children属性 并将值设置为当前组件
parent.$children.push(vm)
}
vm.$parent = parent
// 当前组件的父级存在 那么当前的根实例就是父级的根实例$root 否则就是自己
vm.$root = parent ? parent.$root : vm
vm.$children = []
vm.$refs = {}
vm._provided = parent ? parent._provided : Object.create(null)
vm._watcher = null
vm._inactive = null
vm._directInactive = false
vm._isMounted = false
vm._isDestroyed = false
vm._isBeingDestroyed = false
}
2.2 initEvents 初始化事件中心
在vm上新增对象属性_events用于存储事件,所有vm.$on 注册的事件都存储在 vm._events中。
在模板解析的过程中是逐个字符进行解析的,解析出来的事件将转化成对象存储在vm的属性
vm. $options._parentListeners中。如果事件存在,将调用updateComponentListeners方法,这个方法接收三个参数,当前组件实例,当前事件对象,上一次的事件对象,然后调用updateListeners进行事件更新。updateListeners方法会接收当前事件对象on,上一次事件对象oldon,新增方法add,删除方法remove等。updateListeners中会循环listeners和oldListeners进行对比,其对比的方式有三种
- on中的事件名对应的事件为null/undefined 控制台报错
- on中存在的事件,oldon中不存在,调用add方法添加事件
- on中的事件和oldon中的事件均存在 ,但是事件不同则替换…
// 初始化事件
export function initEvents(vm: Component) {
// 新增_events对象存储事件
vm._events = Object.create(null)
vm._hasHookEvent = false
// _parentListeners: 模板编译阶段解析的事件集合
const listeners = vm.$options._parentListeners
if (listeners) {
// 将父组件注册到子组件的事件注册到子组件实例中
updateComponentListeners(vm, listeners)
}
}
// 更新组件事件的方法
function updateComponentListeners(
vm: Component,
listeners: Object,
oldListeners?: Object | null
) {
target = vm
// 这个方法的逻辑其实就是对比listeners和oldListeners 如果需要新增事件就调用add方法 如果需要删除事件就调用remove方法
updateListeners(
listeners,
oldListeners || {},
add,
remove,
createOnceHandler,
vm
)
target = undefined
}
2.3 使用callHook调用生命周期钩子函数
在vm.$options中获取hook的回调函数,注意这里的vm. $options[hook]获取到的是一个数组,获取到一个数组后再遍历依次执行。那为什么同名的函数要返回一个数组呢?还要遍历执行,是否增加了复杂度?为什么要这样设计?这是因为vue2为了支持mixin混入钩子函数,混入几个钩子函数就执行几次,所以这里是一个数组,然后遍历执行。在vue2中,生命周期的钩子函数都是通过callHook来调用执行的。callHook其实就是会获取到函数然后遍历执行的简单函数。
export function callHook(
vm: Component,
hook: string,
args?: any[],
setContext = true
) {
// .... 循环执行
const handlers = vm.$options[hook]
const info = `${hook} hook`
if (handlers) {
for (let i = 0, j = handlers.length; i < j; i++) {
invokeWithErrorHandling(handlers[i], vm, args || null, vm, info)
}
}
// .....
}
2.4 initState初始化状态
初始化状态这部分逻辑会比较复杂,这个小部分调用的代码就不贴了。只关注主要逻辑。initProps initMethods initData…
2.4.1 initProps 初始化props
initProps中所实现的核心逻辑为:
- 遍历父组件所传入的props列表
- 校验每个属性的命名、类型、default等,然后调用defineReactive将属性设置成响应式的
- 使用proxy将_props上的属性代理的实例vm上
function initProps(vm: Component, propsOptions: Object) {
// 获取$options上的props
const propsData = vm.$options.propsData || {}
const props = (vm._props = shallowReactive({}))
const keys: string[] = (vm.$options._propKeys = [])
// 遍历props
for (const key in propsOptions) {
keys.push(key)
// 校验props
const value = validateProp(key, propsOptions, propsData, vm)
// .....
// 将props属性设置成响应式的
defineReactive(props, key, value)
if (!(key in vm)) {
// 将_props上的属性代理到vm上 方便直接访问
proxy(vm, `_props`, key)
}
}
}
2.4.2 initData初始化data
initData中初始化的逻辑比较重要,重点分析。initData中的核心逻辑为:
- 初始化data并获取到data对象的keys集合
- 遍历keys集合,判断有没有和props里的属性名或者methods里的方法名重名的,这也是为什么先初始化props和methods,然后在初始化data的时候做校验。
- 校验若有重名的就直接报警告,否则调用proxy函数,将vm._data上的属性代理到vm上去,方便调用
- 然后调用observe方法,将属性变成响应式的,检测属性的变化。
// 简化版初始化initData
function initData(vm: Component) {
// 获取当前实例的data
let data = vm.$options.data;
// 判断data的类型 如果是函数类型 就调用该函数 否则就直接返回
data = vm._data = typeof data === 'function'?getData(data,vm):data||{};
// 获取当前实例的data属性名集合
const keys = Object.keys(data)
// 获取当前的props
const props = vm.$options.props;
// 获取当前的methods
const methods = vm.$options.methods;
let i = keys.length;
// 遍历keys
while(i--) {
const key = keys[i];
// methods中有重复的key
if(methods && hasOwn(methods, key)) {
warn(`Methods方法不能重复声明`)
}else if(props && hasOwn(props, key)) {
warn(`Props不能重复声明`)
}else {
// 将_data上的属性代理到vm上
proxy(vm, `_data`, key)
}
}
// 调用observe监听data的变化
observe(data, true)
}
初始化的方法中调用了observe方法,我们看看observe的具体实现
function observe(value: any) {
// observe是检测对象属性的 如果不是对象就不用往下走了
if(!isObject(value)) {
return;
}
// 定义局部属性 判断是否已经创建了监听函数 如果已经创建了就不再创建 直接使用缓存 节省性能
let ob: Observer | void;
if(hasOwn(value, '__ob__')) {
// 使用缓存对象
ob = value.__ob__;
}else {
// 创建监听
ob = new Observer(value);
}
return ob;
}
Observer是一个类,下面看看是怎么定义类的
Observer中实现了对象和数组响应式方法的调用,如果是对象就调用defineReactive()方法 如果是数组就调用observe方法。
class Observer {
value: any;
// 定义用于存储响应式对象的数组
dep: Dep;
vmCount: number; // 根对象上的vm数量
constructor(value: any) {
this.value = value;
this.dep = new Dep();
this.vmCount = 0;
// 添加__ob__属性 如果存在就不会做第二次响应式监听了
def(value, '__ob__', this)
if(Array.isArray(value)) {
// 调用重写数组的方法
}else {
// 调用walk
this.walk()
}
}
// 如果是对象类型
walk(obj: Object) {
const keys = Object.keys(obj);
// 遍历所有的key 转化为响应式对象
for(let i = 0;i<keys.length;i++) {
defineReactive(obj, keys[i]);
}
}
// 如果是数组类型 监听数组
observeArray(items: Array<any>) {
// 遍历数组 对每一个元素进行监听
for(let i = 0,l=items.length;i<l;i++) {
observe(items[i])
}
}
}
接下来看看vue2中响应式原理实现的核心方法。
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
// 创建 dep 实例 用于收集依赖
const dep = new Dep()
// 使用Object.defineProperty()实现响应式 这是vue2中实现响应式的核心方法
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
// 拦截 getter,当取值时会触发该函数
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
// 进行依赖收集
// 初始化渲染 watcher 时访问到需要双向绑定的对象,从而触发 get 函数
if (Dep.target) {
dep.depend()
if (childOb) {
childOb.dep.depend()
if (Array.isArray(value)) {
dependArray(value)
}
}
}
return value
},
// 拦截 setter,当值改变时会触发该函数
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
// 判断是否发生变化
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
// 没有 setter 的访问器属性
if (getter && !setter) return
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
// 如果新值是对象的话递归监听
childOb = !shallow && observe(newVal)
// 派发更新
dep.notify()
}
})
}
Dep类其实就是用于收集依赖的,收集观察者Watcher的,也可以移除Watcher,是对Watcher进行操作的一个类。那么Watcher是什么?Watcher是一个类,是一个中介者,数据发生变化的时候通知他,他再去通知其他地方。
3.将模板转换成AST抽象语法树
vue的template模板想要转化成html模板是无法转化的,需要借助于AST抽象语法树作为中介进行转化。先转化成AST抽象语法树,然后再转化成html模板。其实不只是vue是这样的,其他涉及到模板转化的,都使用了AST作为中间媒介进行转化。
那么AST是什么?AST是一个对象,模板转化成AST,其实就是将模板字符串转化成一个对象,然后再将对象转化成html模板。模板转化成AST是通过解析器进行解析的,解析器分为好几个子解析器,例如HTML解析器、文本解析器、过滤器解析器,其中最主要的是HTML解析器。HTML解析器是用来解析HTML的,它在解析的过程中会不断的触发钩子函数。这些钩子函数包括解析开始标签钩子函数、解析结束标签钩子函数、解析文本钩子函数、解析注释钩子函数。
// HTML解析器的伪代码
parseHTML(template, {
start(tag, attrs, unary) {
// 解析开始标签位置时 触发该函数
},
end() {
// 解析结束标签位置时 触发该函数
},
chars(text) {
// 解析文本时 触发该函数
},
comment(text) {
// 解析注释时 触发该函数
}
})
为了更好的理解,举个简单的例子
<div><p>我是文本</p></div>
当上面这个模板被HTML解析器解析时,所触发点钩子函数依次是start start chars end end。
我们可以看到start钩子函数有三个参数,标签名tag、标签属性attrs、标签是否闭合unary。
可以在start的钩子函数中调用createASTElement来创建AST语法树
// 定义创建AST的函数
function createASTElement(tag, attrs, parent) {
return {
type: 1,
tag,
attrsList: attrs,
parent,
children: []
}
}
parseHTML(template, {
start(tag, attrs, unary) {
// 调用createASTElement创建AST元素
let element = createASTElement(tag, attrs, currentParent);
},
chars(text) {
// 如果是文本节点 直接使用text参数构建一个文本类型的AST节点
let element = {type: 3, text}
},
comment(text) {
// 如果是注释节点,直接手动构建一个AST节点
let element = {type: 3, isComment: true}
}
})
以上就是构建AST节点的大致方法,但是像这样构建的是一个个的节点,节点与节点之间并没有产生什么关系。所谓AST语法树是需要有层级关系的,所以我们需要一种方法去实现这种层级关系。
vue中使用栈的数据结构来实现层级关系的。HTML解析器在解析时,是从前向后解析的。每当遇到开始标签就触发start钩子函数,把当前构建的节点推入到栈中,然后触发钩子函数end就从栈中弹出一个节点。这样通过栈的顺序来构建出AST节点之间的关系。
那么vue2是怎么解析开始标签的呢?(以解析开始标签为例分析)
/**
* 匹配开始标签的正则
*/
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const start = html.match(startTagOpen)
if (start) {
const match = {
tagName: start[1],
attrs: [],
start: index
}
}
可以看到是通过正则匹配的方式找到开始标签的,也可以通过正则匹配到结束标签,文本内容可以通过查找‘<’来获取。然后分别调用createASTElement方法生成AST节点。那么如何保证AST节点的层级关系呢?关于这个问题,vue中使用栈的方式来解决的。Vue在HTML解析器的开头定义了一个栈stack,这个栈的作用就是用来维护AST节点层级的。HTML解析器在从前向后解析模板字符串时,每当遇到开始标签时就会调用start钩子函数,那么在start钩子函数内部我们可以将解析得到的开始标签推入栈中,而每当遇到结束标签时就会调用end钩子函数,那么我们也可以在end钩子函数内部将解析得到的结束标签所对应的开始标签从栈中弹出。
看一个例子:
<div><p><span></span></p></div>
当解析到开始标签 div 时,就把div推入栈中,然后继续解析,当解析到标签p时,再把p推入栈中,同理,再把span推入栈中,当解析到结束标签时,此时栈顶的标签刚好是span的开始标签,那么就用span的开始标签和结束标签构建AST节点,并且从栈中把span的开始标签弹出,那么此时栈中的栈顶标签p就是构建好的span的AST节点的父节点,这样我们就找到了当前被构建节点的父节点。以此类推。
HTML解析器其实也是通过栈的方式来维护DOM层级的。解析到开始标签,就向栈中推进去一个;解析到结束标签就弹出来一个。同时HTML解析器还可以监测HTML标签是否闭合,例如:
<div><p></div>
上面的代码中,p标签没有结束标签。那么当HTML解析器解析到div的结束标签时,栈顶的元素却是p标签。这个时候从栈顶向栈底找到div标签,发现找到div标签之前遇到的其他标签没有闭合标签,因此Vue在非生产环境下的控制台中打印警告日志。
4. 将AST语法树转换成render函数
Vue实例在挂载的时候会调用其自身的render函数来生成实例上的template选项所对应的VNode,简单的来说就是Vue只要调用了render函数,就可以把模板转换成对应的虚拟DOM。那么render函数从哪里来呢?render函数可以是用户手写的,如果用户手写了那就会直接调用用户手写的render函数,如果用户没有手写,Vue就要自己根据模板内容生成一个render函数供组件挂载的时候调用。
代码生成器的作用是将AST转换成渲染函数中的内容,这个内容称之为代码字符串。代码字符串可以被包装做函数中执行,这个函数就是渲染函数。
代码生成器 可以将AST转化成代码字符串,生成后的代码字符串如下例子所示:
with(this) {
return _c("div",{
attrs: {"id": "el"}
},
[
_v("Hello "+_s(name))
]
)
}
如上代码所示,这其实就是一个嵌套的函数调用。函数_c的参数执行了函数_v,而函数_v又执行了函数_s.
函数_c其实就是函数createElement的别名。createElement的作用是创建虚拟节点,有三个参数,分别是标签名、数据对象、子节点列表。createElement函数可以得到一个VNode,渲染函数因为调用了createElement,所以可以创建一个VNode。
整个过程比较简单,就是递归AST来生成字符串,最先生成根节点,然后在子节点字符串生成后,将其拼接在根结点的参数中,子节点的子节点拼接在子节点的参数中,这样一层一层的拼接,直到最后拼接成完整的字符串。
5. render函数生成虚拟节点
render函数的核心是调用createElement函数,所以我们先看createElement函数的实现。
function createElement (context, tag, data, children, normalizationType, alwaysNormalize) {
// 不传data 就将每个参数往前移动一个
if (Array.isArray(data) || isPrimitive(data)) {
normalizationType = children
children = data
data = undefined
}
// 如果alwaysNormalize是true
// 那么normalizationType应该设置为常量ALWAYS_NORMALIZE的值
if (alwaysNormalize) normalizationType = ALWAYS_NORMALIZE
// 调用_createElement创建虚拟节点
return _createElement(context, tag, data, children, normalizationType)
}
createElement函数主要调用_createElement函数去实现生成VNode的任务,所以我们再看看 _createElement的具体实现
function _createElement (context, tag, data, children, normalizationType) {
/**
* 如果存在data.__ob__,说明data是被Observer观察的数据
* 不能用作虚拟节点的data
* 需要抛出警告,并返回一个空节点
*
* 被监控的data不能被用作vnode渲染的数据的原因是:
* data在vnode渲染过程中可能会被改变,这样会触发监控,导致不符合预期的操作
*/
if (data && data.__ob__) {
process.env.NODE_ENV !== 'production' && warn(
`Avoid using observed data object as vnode data: ${JSON.stringify(data)}\\n` +
'Always create fresh vnode data objects in each render!',
context
)
return createEmptyVNode()
}
// 当组件的is属性被设置为一个falsy的值
// Vue将不会知道要把这个组件渲染成什么
// 所以渲染一个空节点
if (!tag) {
return createEmptyVNode()
}
// 作用域插槽
if (Array.isArray(children) &&
typeof children[0] === 'function') {
data = data || {}
data.scopedSlots = { default: children[0] }
children.length = 0
}
// 根据normalizationType的值,区别就是一个需要自己去创建VNode 一个render直接生成了
if (normalizationType === ALWAYS_NORMALIZE) {
children = normalizeChildren(children)
} else if (normalizationType === SIMPLE_NORMALIZE) {
// render函数是编译生成的 编译生成的children都已经是VNode类型的
children = simpleNormalizeChildren(children)
}
let vnode, ns
// 如果标签名是字符串类型
if (typeof tag === 'string') {
let Ctor
// 获取标签名的命名空间
ns = config.getTagNamespace(tag)
// 判断是否为保留标签
if (config.isReservedTag(tag)) {
// 如果是保留标签,就创建一个这样的vnode
vnode = new VNode(
config.parsePlatformTagName(tag), data, children,
undefined, undefined, context
)
// 如果不是保留标签,那么我们将尝试从vm的components上查找是否有这个标签的定义
} else if ((Ctor = resolveAsset(context.$options, 'components', tag))) {
// 如果找到了这个标签的定义,就以此创建虚拟组件节点
vnode = createComponent(Ctor, data, context, children, tag)
} else {
// 兜底方案,正常创建一个vnode
vnode = new VNode(
tag, data, children,
undefined, undefined, context
)
}
// 当tag不是字符串的时候,我们认为tag是组件的构造类
// 所以直接创建
} else {
// 如果是组件 就调用创建组件的VNode
vnode = createComponent(tag, data, context, children)
}
// 如果有vnode
if (vnode) {
// 如果有namespace,就应用下namespace,然后返回vnode
if (ns) applyNS(vnode, ns)
return vnode
// 否则,返回一个空节点
} else {
return createEmptyVNode()
}
}
_createElement中首先判断属性不能是响应式的,如果是响应式的就报警告,因为data在vnode渲染过程中可能会被改变,这样会触发监控,导致不符合预期的操作。然后判断normalizationType的类型,其实就是判断是vue生成的render还是用户手写的,用户手写的render需要手动创建VNode节点。然后判断是否是组件,如果是组件调用createComponent方法创建组件的VNode,否则就可以直接通过new VNode去创建VNode节点。
6. 虚拟节点转化成真实节点 && 将真实DOM挂载到节点上
虚拟DOM最核心的是patch,它可以将虚拟DOM渲染成真实DOM。patching算法是渲染真实DOM时,并不是暴力覆盖原有的DOM,而是对比新旧vnode之间有哪些不同,然后根据对比结果好处需要更新的节点进行更新。之所以这么做,主要是因为DOM操作的执行速度远不如Javascript的运算速度快。因此把大量的DOM操作搬运到Javascript中,使用patching算法来计算真正需要更新的节点,最大限度减少DOM的操作,从而提升性能。本质上是使用Javascript的运算成本来替换DOM操作的执行成本,而Javascript的运算速度要比DOM快很多,这样做很划算。
patch算法对比新旧DOM节点的大致流程如下图所示:
 {
// 判断第一个参数是DOM节点还是虚拟节点。如果是DOM节点就是第一次渲染的节点,如果是虚拟节点,那就不是第一次渲染的节点
if(oldVnode.sel == '' || oldVnode.sel == undefined) {
// 传入的第一个节点是DOM节点 就要包装成虚拟节点
// 在vue中有一个Vnode的一个类 可以直接传入参数创建 在真实的源码中 这里是封装好的函数可以直接调用函数创建vnode
// 这里对参数进行解释下 参数分别指的是
// 标签名 data属性 children子节点 text文本 真实DOM对象
oldVnode = new Vnode(oldVnode.tagName.toLowerCase(),{},[],undefined,oldVnode)
}
// 如果是oldVnode是虚拟节点,就会走下面的逻辑
// 判断oldVode和newVnode是不是同一个节点
// 判断oldVnode和newVnode是不是同一个节点是依据是key相同并且标签名tag相同
if(oldVnode.key == newVnode.key && oldVnode.sel == newVnode.sel) {
// 是同一个节点
// 判断新旧vnode是否是同一个对象
if(oldVnode === newVnode) return;
// 判断vnode有没有text属性
if(newVnode.text != undefined && (newVnode.children == undefined || newVnode.children.length == 0)) {
if(newVnode.text != oldVnode.text) {
oldVnode.elm.innerText = newVnode.text;
}
}else {
if(oldVnode.children != undefined && oldVnode.children.length > 0) {
// 新老的虚拟节点都有children 此时就是最复杂的情况
}else {
// 清空来的节点的内容
oldVnode.elm.innerHTML = '';
// 遍历新的vnode子节点 上树
for(let i=0;i<newVnode.children.length;i++) {
let dom = createElement(vnode.children[i]);
oldVnode.elm.appendChild(dom)
}
}
}
}else {
// 不是同一个节点 暴力删除旧的节点 插入新的节点 这里可以调用封装
let newVnodeElm = createElement(newVnode);
// 插入到老节点之前
if(oldVnode.elm && newVnodeElm) {
oldVode.elm.parentNode.insertBefore(newVnodeElm,oldVode.elm);
}
// 删除老节点 他会把老节点删除 所以在开发的过程中不能将html模板中的节点用作根结点 会被替换 影响模板结构
oldVnode.elm.parentNode.removeChild(oldVnode.elm);
}
}
// 定义createElement函数
function createElement(vnode) {
// 创建DOM节点
let domNode = document.createElement(vnode.sel);
// 判断是有子节点还是有文本?这里是简单的函数 只有子节点或者是文本
if(vnode.text != '' && (vnode.children == undefined || vnode.children.length == 0)) {
// 它内部是文字
domNode.innderText = vnode.text;
vnode.elm = domNode;
}else if(Array.isArray(vnode.children) && vnode.children.length > 0) {
// 子节点是数组的情况递归创建子节点
for(let i = 0; i< vnode.length;i++) {
// 得到当前这个children
let ch = vnode.children[i];
// 创建它的DOM 一旦调用了createElement意味着创建出DOM了 elm属性就有值了 是一个孤儿节点
let chDom = createElement(ch);
// 上树 将真实的DOM挂载到节点上
domNode.appendChild(chDOM);
}
}
// 补充elm属性 elm就是真正的DOM节点
vnode.elm = domNode;
// 返回vnode的真实DOM
return vnode.elm;
}
对现有DOM进行修改需要做三件事:
- 创建新增节点
创建新的节点其实就是调用createElement来创建新的节点,如果要创建子节点,就通过递归的方式创建,然后调用appendChild将子节点插入到父节点中。 - 删除已经废弃的节点
删除节点调用removeChild的方法即可。 - 修改需要更新的节点
通过使用双指针的方案来标记第一个节点和最后一个节点。oldVNode和VNode的第一个节点指针startIndex和最后一个节点指针endIndex一一对比。如果一致就移动指针继续对比,如果oldVNode中的节点多了就删除节点,不一致就修改节点,少了就新增节点。