[笔记3]手写react——ToyReact 虚拟dom的实现

420 阅读4分钟

现在,我们拥有了基于实体dom的ToyReact,可喜可贺

✿✿ヽ(°▽°)ノ✿

但是,react的核心是虚拟dom,毕竟有了虚拟dom,我们的组件刷新才更高效嘛

那么,我们来实现这个功能吧

1. 实现虚拟dom树的创建及渲染

要支持虚拟dom,我们需要重构ElementWrapper、TextWrapper以及Component,毕竟它们通过root这一实际dom进行渲染和更新

1.1 ElementWrapper的vdom实现

在ElementWrapper中我们增加vdom的get

get vdom () {
	return {
    	type: this.type,
        props: this.props,
        // 拿到每个child的vdom
        children: this.children.map(child => child.vdom)
    }
}

在setAtrribute方法中,我们存储了this.props,而在appendChild中,我们存储了this.children。这两种方法的逻辑,和Component有重合,所以ElementWrapper和TextWrapper可以继承Component

1.2 TextWrapper的vdom实现

get vdom () {
	return {
    	type: "#text",
        content: this.content
    }
}

1.3 Component的vdom实现

get vdom () {
	return this.render().vdom
}

1.4 vdom到实体dom的patch

我们之前在ElementWrapper中设置的setAtrribute和appendChild方法,实际创建了需要渲染的dom,而实现vdom到实体dom的patch的过程,这两个方法可以删除,其逻辑可以合并到[RENDER_TO_DOM]方法中。 因为基于vdom,所以保存实际dom的this.root属性可以删去,实际dom只在[RENDER_TO_DOM]方法中存在

在删除setAtrribute和appendChild方法之后,因为 get vdom 的操作只返回了对象,并没有返回方法,无法重绘,所以ElementWrapper和TextWrapper的get vdom的正解应是:

// ElementWrapper
get vdom () {
	this.vchildren = this.children.map(child => child.vdom)
	return this
}

// TextWrapper
get vdom () {
	return this
}

那么,vdom的对象属性,就是ElementWrapper和TextWrapper中的属性。个人理解,这里将数据和行为进一步解耦。

1.4.1 [RENDER_TO_DOM]方法中patch的实现

// ElementWrapper
[RENDER_TO_DOM] (range) {
	range.deleteContents()
    
	// 创建实体dom,root
    let root = document.createElement(this.type)
    
    // props内容抄写,setAttribute逻辑的实现
    for (let name in this.props) {
    
    	let value = this.props[name]
        
    	if (name.match(/^on([\s\S]+)/)){
            root.addEventListener(RegExp.$1.replace(/^[\s\S]/, c => c.toLowerCase()), value)
        } else{
            if (name === "className"){
                root.setAttribute("class", value)
            } else {
                root.setAttribute(name, value)
            }
        }
    }
    
    // children的处理
     for (let child of this.children) {
     	
        let childRange = document.createRange()
        // 将新增的元素置于range末尾
        childRange.setStart(root, root.childNodes.length)
        childRange.setEnd(root, root.childNodes.length)
        child[RENDER_TO_DOM](childRange)
     }
    
    //挂载 root
    range.insertNode(root)
}

1.5 修复bug

现在,我们的vdom树已经可以建起来啦~

不过还是留了一些尾巴,特别在于vchildren的处理上,这部分还需要优化,需要去掉Component的vchildren getter

对于ElementWrapper和TextWrapper,我们需要在在[RENDER_TO_DOM]方法中保存之前的range

// TextWrapper
[RENDER_TO_DOM] (range) {
	this._range = range
    let root = document.createTextNode(this.content)
    range.deleteContents()
    range.insertNode(root)
}

由于ElementWrapper和TextWrapper的[RENDER_TO_DOM]依旧采用先删除range,再渲染this.root的方式,会导致空range被吞,所以我们要优化[RENDER_TO_DOM]。而[RENDER_TO_DOM]的逻辑在不同位置都有使用,所以我们单独封装一个repalceContent方法

function replaceContent (range, node) {

	// 将node插入range,此时node在range的最前位置
	range.insertNode(node)
    
    // range挪到node之后
    range.setStartAfter(node)
    // 清空range
    range.deleteContents()
    
    // 重设range的位置
    range.setStartBefore(node)
    range.setEndAfter(node)
}

现在我们来改进[RENDER_TO_DOM]中range的处理

// TextWrapper
[RENDER_TO_DOM] (range) {
	let root = document.createTextNode(this.content)
	this._range = range
    replaceContent(range, root)
}

// ElementWrapper
[RENDER_TO_DOM] (range) {
	this._range = range
	// 通过replaceContent代替初始时range.deleteContents()
	// range.deleteContents()
	
    let root = document.createElement(this.type)
    
    for (let name in this.props) {
    	let value = this.props[name]
    	if (name.match(/^on([\s\S]+)/)){
            root.addEventListener(RegExp.$1.replace(/^[\s\S]/, c => c.toLowerCase()), value)
        } else{
            if (name === "className"){
                root.setAttribute("class", value)
            } else {
                root.setAttribute(name, value)
            }
        }
    }
    
     for (let child of this.children) {
        let childRange = document.createRange()
        childRange.setStart(root, root.childNodes.length)
        childRange.setEnd(root, root.childNodes.length)
        child[RENDER_TO_DOM](childRange)
     }
    
    // 完成root的挂载
    replaceContent(range, root)
}

2. vdom比对的实现

在拥有了vdom树的创建能力之后,我们的rerender函数可以退休了,代替它的是vdom更新,在Component基类中实现

在Component的[RENDER_TO_DOM]方法中,我们需要先更新vdom,再渲染

// Component
[RENDER_TO_DOM] (range) {
	// 保存range和vdom
    this._range = range
    // 由于this.vdom是getter,所以会重新调用组件的render方法,返回新的vdom,实现vdom更新
    this._vdom = this.vdom
    // 渲染旧的vdom
    this._vdom[RENDER_TO_DOM](range)
    
}

2.1 vdom更新

在Component的unpdate方法中,我们实现vdom的更新

update () {
	 let update = (oldNode, newNode) => {
     // 更新为newNode
     }
    
    // 保存新的vdom
    let vdom = this.vdom
    // 对比vdom
    update(this._vdom, vdom)
    // 重新赋值
    this._vdom = vdom
}

那么在实现更新vdom功能前,我们需要对比根节点,细化vdom更新范围

2.2 简单的 dom diff 算法

我们的节点更新逻辑如下:

  • 根节点的type是否一致,不一致,则更新为新的根节点
  • 根节点的props是否一致,不一致,则更新为新的根节点
  • 根节点的children是否一致,不一致,则更新为新的根节点
  • 对于文本节点,content不一致,则更新为新节点

我们采用最土,最傻瓜最简练的方式更新dom

  • 只对比相同位置的vdom
  • 采用直接替换的方式更新节点
update () {

	let isSameNode = (oldNode, newNode) => {

		// type不同,则为不同节点
		if (oldNode.type !== newNode.type) {
    		return false
    	}
    
    	// props不同,则为不同节点
    	for ( let name in newNode.props) {
    		// 属性值要相同
        	if (newNode.props[name] !== oldNode.props[name]) {
        		return false
        	}
    	}
    	// props的长度不相同,节点不相同
    	if (Object.keys(oldNode.props).length > Object.keys(newNode.props)) {
    		return false
    	}
    
    	// 文本节点,比对content
    	if (newNode.type === "#text") {
    		if (newNode.content !== oldNode.content) {
    			return false
    		}
    	}
    
		return true
	}
    
    let update = () => {}
    
    // 保存新的vdom
    let vdom = this.vdom
    // 对比vdom
    update(this._vdom, vdom)
    // 重新赋值
    this._vdom = vdom
}

2.3 newNode更新

update () {
	let isSameNode = (oldNode, newNode) => {
    	... ...
    }

	let update = (oldNode, newNode) => {
    
    	// 根节点不同,则全部重新渲染
    	if (!isSameNode(oldNode, newNode)) {
        	// 替换oldNode
        	newNode[RENDER_TO_DOM](oldNode._range)
            return
        }
        newNode._range = oldNode._range
        
        // children的处理
        // 因为children属性是实体dom,所以我们要拿到vchildren
        let newChildren = newNode.vchildren
        let oldChildren = oldNode.vchildren
        
        if (!newChildren || !newChildren.length) {
        	return
        }
        
        // 记录oldChildren的尾部位置
        let tailRange = oldChildren[oldChildren.length - 1]._range
        // 两个数组一起循环,所以不用 for of循环
        for (let i = 0; i < newChildren.length; i++) {
        	let newChild = newChildren[i]
            let oldChild = oldChildren[i]
            if (i < oldChildren.length) {
            	update(oldChild, newChild)
            } else {
            	// 如果newChild比oldChild元素多,我们需要在newChild进行节点插入
                // 创建一个需要插入的range
                let range = document.createRange()
                range.setStart(tailRange.endContainer, tailRange.endOffset)
               range.setEnd(tailRange.endContainer, tailRange.endOffset)
               newChild[RENDER_TO_DOM](range)
               tailRange = range
            }
        }
    }
    
    let vdom = this.vdom
    // 更新vdom
    update(this._vdom, vdom)
    // 重新赋值, 此时的“旧vdom”是vdom
    this._vdom = vdom
}

之后在setState中调用update,即可实现虚拟dom的更新

至此,塑料版react toyReact就完成啦

✿✿ヽ(°▽°)ノ✿