[笔记2]手写react——ToyReact dom更新及setState的实现

1,048 阅读5分钟

在实现了ToyReact的自定义组件的jsx语法后,我们的ToyReact可以跑起来啦

✿✿ヽ(°▽°)ノ✿

但是,ToyReact不能自动更新,只是一个空壳子

(⊙︿⊙)

现在就让我们实现dom的更新,以及setState的支持吧~

1. state的实现

在react中,自定义组件拥有自己的state,所以我们可以理解为,Component是不需要有自己的state,但需要设置setState方法,已使自定义组件调用setState

class MyComponent extends Component {
	constructor () {
    	// 执行Component的构造函数
    	super()
        this.state = {
        	a: 1,
            b: 2
        }
    }
    
    render () {
    	return <div>
        <h1>MyComponent</h1>
        <span>{this.state.a.toString()}</span>
        {this.children}
        </div>
    
    }
}

2. ToyReact dom更新

之前的render函数是基于root实现的,是一个“取得自定义组件的root -> 取得root中自定义组件的root -> 递归直至TextWrapper或者ElementWrapper(包含真正的root)”

要更新dom,我们需要锁定节点的位置。在react中,因为采用了虚拟dom,所以更新dom会十分精巧。而目前ToyReact采用的是实际dom,所以更新dom意味着重新渲染整个this.root,但是我们依旧可以通过RangeAPI实现dom的定位

Range的MDN文档

2.1 重写Component的get root

// 通过symbol定义方法名称,保证私有性
const RENDER_TO_DOM = Symbol("render to dom")

export class Component {
    constructor () {
        this.props = Object.create(null)
        this.children = []
        this._root = null
        this._range = null
    }
    
    setAttribute (name, value) {
        this.props[name] = value
    }
    
    appendChild (component) {
        this.children.push(component)
    }
    
    // 使用[]将Symbol作为函数名
    // 传入的是range
    [RENDER_TO_DOM] (range) {
    	// 组件的this.render()会返回组件,之后再调用组件的[RENDER_TO_DOM]方法
    	this.render()[RENDER_TO_DOM](range)
    }
    // 之前的get root 方法被[RENDER_TO_DOM]替代
}

2.2 重写TextWrapper和ElementWrapper

在更新了Component的[RENDER_TO_DOM]方法之后,需要在TextWrapper和ElementWrapper中增加对应的[RENDER_TO_DOM]方法

class ElementWrapper {
    constructor (type) {
        this.root = document.createElement(type)
    }
    
    setAttribute (name, value) {
        this.root.setAttribute(name, value)
    }
    
    // 增加[RENDER_TO_DOM]方法
    [RENDER_TO_DOM] (range) {
    	// 首先从文档中移除 Range 包含的内容。
    	range.deleteContents()
        //再将root插入range,完成渲染
        range.insertNode(this.root)
    }
    
    // 由于采用了range,所以增加child也要修改
    appendChild (component) {
    	let range = document.createRange()
    	// 将新增的元素置于range末尾
    	range.setStart(this.root, this.root.childNodes.length)
    	range.setEnd(this.root, this.root.childNodes.length)
    	component[RENDER_TO_DOM](range)
    }
}
//文本节点不需要设置属性及添加子元素
class TextWrapper {
    constructor (content) {
        this.root = document.createTextNode(content)
    }
    
    // 增加[RENDER_TO_DOM]方法
    [RENDER_TO_DOM] (range) {
    	// 首先从文档中移除 Range 包含的内容。
    	range.deleteContents()
        //再将root插入range,完成渲染
        range.insertNode(this.root)
    }
}

2.3 更新render函数

export function render (component, parentElement) {
	// 在parentElement尾部增加range
    let range = document.createRange()
    
    // 将range的start节点设置为parentElement,offset为0,说明range将包含parentElement的全部children
    range.setStart(parentElement, 0)
    
    // 因为parentElement中会有文本节点和注释节点,所以offset不是parentElement.children.length
    range.setEnd(parentElement, parentElement.childNodes.length)
    
    // 清空range
    range.deleteContents()
    
    // 调用[RENDER_TO_DOM]方法
    component[RENDER_TO_DOM](range)
}

2.5 支持重新绘制dom

需要修改[RENDER_TO_DOM]方法

class ElementWrapper {
    constructor (type) {
        this.root = document.createElement(type)
    }
    
    setAttribute (name, value) {
        this.root.setAttribute(name, value)
    }
    
    // 支持dom重绘
    [RENDER_TO_DOM] (range) {
    	range.deleteContents()
        range.insertNode(this.root)
    }
    
    // 由于采用了range,所以增加child也要修改
    appendChild (component) {
    	let range = document.createRange()
    	// 将新增的元素置于range末尾
    	range.setStart(this.root, this.root.childNodes.length)
    	range.setEnd(this.root, this.root.childNodes.length)
    	component[RENDER_TO_DOM](range)
    }
}

//文本节点不需要设置属性及添加子元素
class TextWrapper {
    constructor (content) {
        this.root = document.createTextNode(content)
    }
    
    // 增加[RENDER_TO_DOM]方法
    [RENDER_TO_DOM] (range) {
    	range.deleteContents()
        range.insertNode(this.root)
    }
}

export class Component {
    constructor () {
        this.props = Object.create(null)
        this.children = []
        this._root = null
        // 初始化_range
        this._range = null      
    }
    
    setAttribute (name, value) {
        this.props[name] = value
    }
    
    appendChild (component) {
        this.children.push(component)
    }
    
    [RENDER_TO_DOM] (range) {
    	this._range = range
    	this.render()[RENDER_TO_DOM](range)
    }
    
    // 定义重绘方法
    rerender () {
    	this._range.deleteContents()
        this[RENDER_TO_DOM](this._range)
    }
}

这样,我们就可以在自定义组件中调用重绘方法,支持setState的操作了

class MyComponent extends Component {
	constructor () {
    	// 执行Component的构造函数
    	super()
        this.state = {
        	a: 1,
            b: 2
        }
    }
    
    render () {
    	return <div>
        <h1>MyComponent</h1>
        <button onClick={() => {
        	this.state.a++
            this.rerender()
        	}}></button>
        <span>{this.state.a.toString()}</span>
        <span>{this.state.b.toString()}</span>
        </div>
    }
}

2.6 支持自定义事件事件绑定

class ElementWrapper {
    constructor (type) {
        this.root = document.createElement(type)
    }
    // 支持事件绑定
    setAttribute (name, value) {
    	// 采用正则,判断name是否为on开头
        if (name.match(/^on([\s\S]+)/) {
        	// [\s\S] 表示全部字符 \s为非空白,\S为空白,两个集合互补
            // 由于此处采用match,所以RegExp.$1将拿到匹配的字符,即on之后的部分
            // RegExp.$1.replace(/^[\s\S]/, c => c.toLowerCase())
            //确保事件名小写,将第一个字母转换为小写
            this.root.addEventListener(RegExp.$1.replace(/^[\s\S]/, c => c.toLowerCase()), value)
        } else {
        	// 其他属性,直接调用root的setAttribute方法
        	this.root.setAttribute(name, value)
        }
    }
    
    [RENDER_TO_DOM] (range) {
    	range.deleteContents()
        range.insertNode(this.root)
    }
    
    appendChild (component) {
    	let range = document.createRange()
    	range.setStart(this.root, this.root.childNodes.length)
    	range.setEnd(this.root, this.root.childNodes.length)
    	component[RENDER_TO_DOM](range)
    }
}

3 实现setState

目前,我们的state可以实现更新,但是并不能实现state原有状态的存储,只是单纯的覆盖,所以我们要完善setState,实现state的深拷贝更新

//Component的setState方法
setState (newState) {
	// state为null时的处理
    if (this.state === null || typeof this.state !== "object") {
    	// 如果state为null或不是对象,直接为state赋值newState,并重新渲染组件
    	this.state = newState
        this.rerender()
        return
    }
    
	// 采用递归的方式访问state
	let merge = (oldState, newState) => {
    	for (let p in newState) {
            
        	if (typeof oldState[p] === null || typeof oldState[p] !== "object"){
            	oldState[p] = newState[p]
            } else {
            	// 如果oldSate的p属性为对象,那么就递归调用merge,实现深拷贝
            	merge(oldState[p], newState[p])
            }
        }
    }
    merge(this.state, newState)
    this.rerender()
}

4 尝试使用运行react TicTacToe demo

TicTacToe是react的官方教程,我们拿现在toyReact试着跑一下吧~

🏃🏃🏃

不负众望✿✿ヽ(°▽°)ノ✿没有运行起来

调试后发现,主要问题存在于 createElement函数的insertChildren操作,没有兼顾到children为null的状况,而TicTacToe demo采用了children为null的设置

我们来修复这个问题

4.1 insertChildren支持child为null

export function createElement (tagType, attributes, ...children) {
    ... ...
    
    let insertChildren = (children) => {
        for (let child of children) {
        
            // 如果child为null,不做任何处理
            if (child === null) {
                continue
            }
            
            if (typeof child === "string") {
                child = new TextWrapper(child)
            }
            
            if (typeof child === "object" && child instanceof Array) {
                insertChildren(child)
            } else {
                e.appendChild(child)
            }

        }
    }

    insertChildren(children)

    return e
}

需要注意的是,demo中的采用了函数组件,我们需要修改为class组件。另外,我们的toyReact目前还没有支持className的绑定

我们需要在ElementWrapper的setAttribute方法中,实现className的绑定

4.2 className的绑定

class ElementWrapper {
    constructor (type) {
        this.root = document.createElement(type)
    }
    // 配置属性
    setAttribute (name, value) {
    	if (name === "className") {
        	this.root.setAttribute("class", value)
        } else {
        	this.root.setAttribute(name, value)
        }
    }
   
    appendChild (component) {
        this.root.appendChild(component.root)
    }
}

4.3 修复由于range被吞导致的问题

我们通过range API实现了组件的重新渲染,但是我们使用range.deleteContents()的时机,导致TicTacToe demo在重新渲染时,存在丢失dom的情况,所以需要修复

问题存在于Component的rerender方法,函数执行时,会先清空range,这会导致空range被相邻的range“吞了”,所以在rerender执行时,需要保证range不空。

所以,我们需要先插入range,再删除它

    rerender () {
    	let range = document.createRange()
        // 新创建的range没有宽度
        range.setStart(this._range.startContainer, this._range.startOffset)
        range.setEnd(this._range.startContainer, this._range.startOffset)
        this[RENDER_TO_DOM](range)
    	this._range.deleteContents()
    }

但是在Component[RENDER_TO_DOM]方法中,我们保留了this._range,所以不能直接在rerender时直接清空

	// [RENDER_TO_DOM]方法保留了this._range 
	 [RENDER_TO_DOM] (range) {
    	this._range = range
    	this.render()[RENDER_TO_DOM](range)
    }

所以我们需要改进一下

	rerender () {
    	// 保存this._range
    	let oldRange = this._range
        
        // 新创建的range没有宽度,但会改变oldRange的宽度
        // 新创建的range在this._range的start处
    	let range = document.createRange()
        range.setStart(oldRange.startContainer, oldRange.startOffset)
        range.setEnd(oldRange.startContainer, oldRange.startOffset)
        
        this[RENDER_TO_DOM](range)
        
        // 重设oldRange的start节点,跳过插入的range
        oldRange.setStart(range.endContainer, range.endOffset)
        // 清除oldRange的内容
    	oldRange.deleteContents()
    }
    
    // [RENDER_TO_DOM]方法保留了this._range 
	 [RENDER_TO_DOM] (range) {
    	this._range = range
    	this.render()[RENDER_TO_DOM](range)
    }