首先我们要区分vue当中两个概念,一个是渲染器(renderer),一个是渲染(render)。
前者renderer,渲染器的作用是将我们的vdom转化成真实的DOM元素来呈现。
后者render,渲染函数的作用是将我们的vnode添加并且挂载到真实DOM容器下。
1、options
:抽离依赖环境方法,使其变成自定义渲染器
2、render
:将vnode
节点渲染到真实的DOM
元素当中
3、patch
:挂载、更新vnode
节点
4、mountElement
:挂载vnode
节点为真实的DOM
元素
5、patchProps
:挂载、更新props
属性
6、patchElement
:更新vnode
节点
7、patchChildren
:更新子节点
/*
renderer{
options
render
patch:挂载和更新vnode节点
mountElement: 渲染为真实dom元素
}
vnode{
type
props
children:啥也没有 | 文本节点 | 子节点
el:真实DOM
}
*/
const options = {
createElement(tag){
return document.createElement(tag)
},
setElementText(el,text){
el.textContent = text
},
insert(el,parent,anchor){
parent.insertBefore(el,anchor)
},
createText(text){
return document.createTextNode(text)
},
setText(el,text){
el.nodeValue = text
},
patchProps(el,key,preValue,nextValue){
function shouldSetAsProps(el,key,value){
// 处理特殊情况
if(key === 'form' && el.tagName === 'INPUT') return false
return key in el
}
// 当前属性是否存在于DOM properties当中,如果存在那么我们可以进行操作,如果不存在,而只是在HTMl Attribute当中存在就用setAttribute
if(/^on/.test(key)){
// 事件名称
const name = key.slice(2).toLowerCase()
// 利用伪事件函数处理,减少一次removeEventListener
const invokers = el._vei || (el._vei = {})
let invoker = invokers[key]
if(nextValue){
// 不存在的情况下,就需要初始化一个
if(!invoker){
invoker = el._vei[key] = (e)=>{
// 正确处理事件执行顺序,当执行实际早于绑定时机就不进行执行
if(e.timeStamp < invoker.attached) return
if(Array.isArray(invoker.value)){
invoker.value.forEach(fn=>fn(e))
}else{
invoker.value(e)
}
}
invoker.value = nextValue
// 添加invoker的attached属性,存储事件处理函数被绑定时间
invoker.attached = performance.now()
// 真实dom'监听事件
el.addEventListener(name,invoker)
}
// 存在的情况下,就需要更新它的值即可
else{
invoker.value = nextValue
}
}
}
else if(key === 'class'){
el.className = nextValue || ''
}
else if(shouldSetAsProps(el,key,nextValue)){
let type = typeof el[key]
// 获取到DOM property属性对应的类型,去对它的值进行矫正
if(type === 'boolean' && nextValue === ''){
el[key] = true
}else{
el[key] = nextValue
}
}else{
el.setAttribute(key,nextValue)
}
},
unmount(vnode){
if(vnode.type === Fragment){
vnode.children.forEach(c => unmount(c))
return
}
let parent = vnode.el.parentNode
if(parent){
parent.removeChild(vnode.el)
}
}
}
function createRenderer(options){
const { createElement,setElementText,insert } = options
function mountElement(vnode,container){
// 创建真实的dom元素
let el = vnode.el = createElement(vnode.type)
if(typeof vnode.children === 'string'){
setElementText(el,vnode.children)
}else if(Array.isArray(vnode.children)){
vnode.children.forEach(child=>{
patch(null,child,el)
})
}
// 属性挂载
if(vnode.props){
for(const key in vnode.props){
let value = vnode.props[key]
patchProps(el,key,null,value)
}
}
// 添加到容器当中
insert(el,container)
}
function patchChildren(n1,n2,container){
// 子节点更新有九种情况:
// 1、新子节点为文本节点:1、旧子节点不存在 2、旧子节点为文本节点 3、旧子节点为一组子节点(diff算法)
// 2、新子节点为一组子节点:1、旧子节点不存在 2、旧子节点为文本节点 3、旧子节点为一组子节点(diff算法)
// 3、新子节点不存在:1、旧子节点不存在 2、旧子节点为文本节点 3、旧子节点为一组子节点(diff算法)
if(typeof n2.children === 'string'){
if(Array.isArray(n1.children)){
n1.children.forEach((c=>unmount(c)))
}
setElementText(container,n2.children)
}else if(Array.isArray(n2.children)){
// Diff算法
if(Array.isArray(n1.children)){
}else{
setElementText(container,'')
n2.children.forEach((c)=>patch(null,c,container))
}
}else{
if(Array.isArray(n1.children)){
n1.children.forEach((c)=>unmount(c))
}else if(typeof n1.children === 'string'){
setElementText(container,'')
}
}
}
function patchElement(n1,n2){
// 同步真实的DOM
const el = n2.el = n1.el
const oldProps = n1.props
const newProps = n2.props
// 更新props
for(const key in newProps){
if(newProps[key] !== oldProps[key]){
patchProps(el,key,oldProps[key],newProps[key])
}
}
for(const key in oldProps){
if(!(key in newProps)){
patchProps(el,key,oldProps[key],null)
}
}
// 更新子节点
patchChildren(n1,n2,el)
}
function patch(oldVnode,newVnode,container){
// patch分为三种情况:1、旧节点不存在 2、旧节点存在但类型不同 3、旧节点存在类型相同,但是旧节点存在,但类型不同,直接卸载掉旧节点的内容,挂载新的,其实就是和情况一相同,所以走到下方只用考虑两种情况,旧存在和不存在
if(oldVnode && oldVnode.type !== newVnode.type){
unmount(oldVnode)
oldVnode = null
}
const { type } = newVnode
// vnode元素是一个dom元素
if(typeof type === 'string'){
// 旧节点不存在就是挂载
if(!oldVnode){
mountElement(newVnode,container)
}
// 旧节点存在就是打补丁更新
else{
patchElement(oldVnode,newVnode)
}
}
// vnode元素是一个文本节点
else if(type === Text){
const el = n2.el = n1.el
// 挂载
if(!n1){
const el = n2.el = createTextNode(n2.children)
insert(el,container)
}else{
const el = n2.el = n1.el
if(n2.children !== n1.children){
setText(el,n2.children)
}
}
}
// vnode元素是一个Fragment(文档碎片)
else if(type === Fragment){
if(!n1){
n2.children.forEach((c)=>patch(null,c,container))
}else{
patchChildren(n1,n2,container)
}
}
}
function render(vnode,container){
// 当前有新的vnode节点
if(vnode){
patch(container._vnode,vnode,container)
}
// 当前没有新的vnode节点,就将旧的内容进行清除
else{
unmount(container._vnode)
}
// 将vnode存储在container下,作为oldVnode去比较
container._vnode = vnode
}
return {
render
}
}
Diff算法的引出:
对于oldVnode
和newVnode
不同的children
进行更新操作,最容易想到的就是将旧的子节点全部卸载,然后重新挂载最新的子节点。但是对于这个操作会有很大的性能开销,因为直接操作Dom了。
优化和改进:
对于更新子节点情况,有时候可能只是单纯顺序不一样,因此其实可以通过去判断vnode
的type
去判断,但是这里也有一个缺陷,就是我们的children值可能是不同的,因此引入key
去确定是否可以复用当前的这个DOM节点,然后再去判断移动位置、卸载和挂载节点。
执行流程:
核心代码:
function patchKeyedChildren(n1,n2,container) {
const oldChildren = n1.children
const newChildren = n2.children
// 最大索引值
let lastIndex = 0
for(let i=0;i<newChildren.length;i++) {
let newVnode = newChildren[i]
for(let j=0;j<oldChildren.length;j++) {
// 标记当前节点是否可以找到
let find = false
let oldVnode = oldChildren[j]
// 如果新节点在旧节点中可以找到就进行更新
if(oldVnode.key === newVnode.key) {
find = true
// 更新当前节点
patch(oldVnode,newVnode,container)
// 看当前节点是否移动
if(j < lastIndex){
let preVnode = newChildren[i-1]
if(preVnode) {
const anchor = preVnode.el.nextSibling
insert(newVnode.el,container.anchor)
}
}else{
lastIndex = j
}
break
}
// 当前节点无法找到就进行新增
if(!find){
const preVnode = newChildren[i-1]
let anchor = preVnode ? preVnode.el.nextSibling : container.firstChild
pacth(null,newVnode,container,anchor)
}
}
}
// 遍历旧的节点删除不要的节点
for(let i=0;i<oldChildren.length;i++){
const oldVnode = oldChildren[i]
const has = newChildren.find(vnode => vnode.key === oldVnode.key)
if(!has){
unmount(oldVnode)
}
}
}
优势:
执行的DOM移动操作次数更少
执行过程:
key
进行比较key
进行比较key
进行比较key
进行比较添加、删除真实DOM的极端case
1、添加情况下的极端case
2、删除情况下的极端case
按照上面双端Diff算法的执行过程,那么这两种情况下,均会跳出执行的循环,因此我们需要在循环外部再做一些操作
核心代码:
function patchKeyedChildren(n1,n2,container){
let oldChildren = n1.children
let newChildren = n2.children
// 四个索引值
let oldStartIdx = 0
let oldEndIdx = oldChildren.length - 1
let newStartIdx = 0
let newEndIdx =newChildren.length - 1
// 四个节点
let oldStartVnode = oldChildren[oldStartIdx]
let oldEndVnode = oldChildren[oldEndIdx]
let newStartVnode = newChildren[newStartIdx]
let newEndVnode = newChildren[newEndIdx]
// diff执行
while(newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx){
// 排除掉结点为undefined的情况,处理过了跳过就行
if(!oldStartVnode) {
oldStartVnode = oldChildren[++oldStartIdx]
}else if(!oldEndVnode) {
oldEndVnode = oldChildren[--oldEndIdx]
}
// 双端Diff的核心处理四步
else if(newStartVnode.key === oldStartVnode.key){
patch(oldStartVnode,newStartVnode,container)
// 更新索引值和节点
newStartVnode = newChildren[++newStartIdx]
oldStartVnode = oldChildren[++oldStartIdx]
}else if(newEndVnode.key === oldEndVnode.key){
patch(oldEndVnode,newEndVnode,container)
// 更新索引值和节点
newEndVnode = newChildren[--newEndIdx]
oldEndVnode = oldChildren[--oldEndIdx]
}else if(newEndVnode.key === oldStartVnode.key){
patch(oldStartVnode,newEndVnode,container)
// 移动真实的DOM
insert(oldStartVnode.el,container,oldEndVnode.el.nextSibling)
// 更新索引值和节点
newEndVnode = newChildren[--newEndIdx]
oldStartVnode = oldChildren[++oldStartIdx]
}else if(newStartVnode.key === oldEndVnode.key){
patch(oldEndVnode,newStartVnode,container)
// 移动真实的DOM
insert(oldEndVnode.el,container,oldStartVnode.el)
// 更新索引值和节点
newStartVnode = newChildren[++newStartIdx]
oldEndVnode = oldChildren[--oldEndIdx]
}else{
// 在旧子节点当中查找,新前节点是否存在
const idxInOld = oldChildren.findIndex(node => node.key === newStartVnode.key)
// 查找到,要将真实dom移动到头部
if(idxInOld > 0){
// 要移动的旧节点
const vnodeToMove = oldChildren[idxInOld]
// 更新节点
patch(vnodeToMove,newStartVnode,container)
// 将旧节点移动到头部
insert(vnodeToMove.el,container,oldStartVnode.el)
// 由于旧节点的双端指针没有改变,可能会扫描到,因此要将此处操作过的旧节点置为undefined
oldChildren[idxInOld] = undefined
}
// 查找不到,直接在容器头部挂载
else{
// 挂载新前节点
patch(null,newStartVnode,container,oldStartVnode.el)
}
// 更新索引
newStartVnode = newChildren[++newStartIdx]
}
}
// 处理添加、删除情况和极端case
if(oldEndIdx > oldStartIdx && newStartIdx <= newEndIdx){
// 如果旧节点没有了,新节点还有,就要挂载他们
for(let i = newStartIdx;i <= newEndIdx;i++){
patch(null,newChildren[i],container,oldStartVnode.el)
}
}else if(oldEndIdx >= oldStartIdx && newStartIdx > newEndIdx){
// 如果新节点没有了,旧节点有,就要删除旧节点
for(let i = oldStartIdx;i <= oldEndIdx;i++){
unmount(oldChildren[i])
}
}
}
执行流程:
source
数组即索引数组来获得最长递增子序列来判断剩余的节点哪些需要进行移动(优化:在进行对source数组进行填充的时候通过索引表降低复杂度来填充
)核心代码:
function patchKeyedChildren(n1,n2,container) {
const oldChildren = n1.children
const newChildren = n2.children
// 处理相同的前置节点
let j = 0
let oldVnode = oldChildren[j]
let newVnode = newChildren[j]
while(oldVnode.key === newVnode.key) {
patch(oldVnode,newVnode,container)
j++
oldVnode = oldChildren[j]
newVnode = newChildren[j]
}
// 处理相同的后置节点
let newEnd = newChildren.length - 1
let oldEnd = oldChildren.length - 1
newVnode = newChildren[newEnd]
oldVnode = oldChildren[oldEnd]
while(newEndVnode.key === oldEndVnode.key){
patch(oldVnode,newVnode,container)
newVnode = newChildren[--newEnd]
oldVnode = oldChildren[--oldEnd]
}
// 预处理完毕,有可能会有一边节点处理完毕,如果新子节点处理完毕就是进行删除,旧子节点处理完毕就进行新增
// 新增
if(j > oldEnd && j <= newEnd){
const anchorIndex = newEnd + 1
const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null
while(j <= newEnd){
patch(null,newChildren[j++],container,anchor)
}
}
// 删除节点
else if(j <= oldEnd && j > newEnd) {
while(j <= oldEnd) {
unmount(oldChildren[j++])
}
}
// 移动原话节点,处理非理想情况
else {
// 创建source数组
const count = newEnd - j + 1
const source = new Array(count).fill(-1)
// 创建起始索引
let newStart = j
let oldStart = j
let moved = false
let pos = 0
let patched = 0
// 建立索引表
let keyIndex = {}
for(let i = newStart;i <= newEnd;i++){
keyIndex[newChildren[i].key] = i
}
// 遍历剩余旧的子节点数组
for(let i = oldStart;i <= oldEnd;i++){
oldVnode = oldChildren[i]
if(patched <= count){
// 获取当前旧节点在新节点数组的索引位置
const k = keyIndex[oldVnode.key]
if(typeof k !== 'undefined'){
patch(oldVnode,newChildren[k],container)
patched ++
source[k - newStart] = i
// 确定是否移动原始节点,和简单Diff算法一样,比较当前最大索引,比它小就要移动,比它大就不用移动
if(k < pos){
moved = true
}else{
pos = k
}
}else{
unmount(oldVnode)
}
}else{
unmount(oldVnode)
}
}
// 可能是整个剩余部分需要移动,可能某一个节点需要进行移动,具体移动看最长递增子序列
if(moved) {
// 计算最长递增子序列,得到的是source的符合要求的元素索引,会和去除前置节点的索引一致
const seq = lis(sources)
// 利用source数组获取最长递增子序列来看哪些节点需要进行移动,原理:由于索引如果呈递增的情况,那么说明节点顺序是正确不需要移动,且尽可能保证更多的节点不去移动
let s = seq.length - 1
let i = count - 1
for(i;i >= 0;i--){
// source[i] === -1说明当前节点不存在于旧节点数组当中
if(source[i] === -1) {
const pos = i + newStart
const newVnode = newChildren[pos]
const nextPos = pos + 1
const anchor = nextPos < newChildren.length ? newChildren[pos].el : null
patch(null,newVnode,container,anchor)
}
// 如果索引值不匹配,那么说明该节点需要移动
else if(i !== seq[s]){
const pos = i + newStart
const newVnode = newChildren[pos]
const nextPos = pos + 1
const anchor = nextPos < newChildren.length ? newChildren[pos].el : null
insert(newVnode.el,container,anchor)
}
// 相等的情况下,不进行移动
else{
s--
}
}
}
}
}
Diff算法根本的核心:
三个Diff算法的速度性能:
简单Diff < 双端Diff < 快速Diff
主要的根本原因是这三个Diff算法层层减少了移动DOM的操作,从而大大提高了性能