在实现了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的定位
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)
}