循序渐进DIY一个react(三)

852 阅读10分钟

在正式进入实现之前,我们先来了解一下几个概念。首先,“映射”这个概念已经在“第一篇文章里”里面介绍过了,这里就不在赘述了。我们来讲讲这里所说的“整树”和“协调”到底指的是什么?

熟悉react的读者都知道,完整的react应用是可以用一颗组件树来表示的。而组件树背后对应的归根到底还是virtual DOM对象树。react官方推荐仅调用一次ReactDOM.render()来将这颗virtual DOM对象树挂载在真实的文档中去。所以,这里,我们就将调用render方法时传入的第一参数称之为“整树”(整一颗virtual DOM对象树):

const rootNode = document.getElementById('root')
const app = (
    <div id="test" onClick={() => { alert('I been clicked')}>
        我是文本节点1
        <a href="https://www.baidu.com">百度一下</a>
    </div>
)

render(app,rootNode)

在上面的示例代码中,app变量所指向的virtual DOM对象树就是我们所说的“整树”。

那“协调”又是啥意思呢?协调,原概念来自于英文单词“Reconciliation”,你也可以翻译为“调和”,“和解”或者什么的。在react中,“Reconciliation”是个什么样的定义呢?官方文档好像也没有给出,官方文档只是给出了一段稍微相关的解释而已:

React provides a declarative API so that you don’t have to worry about exactly what changes on every update. This makes writing applications a lot easier, but it might not be obvious how this is implemented within React. This article explains the choices we made in React’s “diffing” algorithm so that component updates are predictable while being fast enough for high-performance apps.

同时,官方提醒我们,reconciliation算法的实现经常处于变动当中。我们可以把这个提醒这理解为官方也难以给reconciliation算法下一个准确的定义。但是reconciliation算法的目标是明确的,那就是“在更新界面的过程中尽可能少地进行DOM操作”。所以,我们可以把react.js中“协调”这个概念简单地理解为:

“协调”,是指在尽量少地操作DOM的前提下,将virtual DOM 映射为真实文档树的过程。

综上所述,我们这一篇章要做的事就是:“在将整颗virtual DOM对象树映射为真实文档过程中,如何实现尽量少地操作DOM”。为什么我们总在强调要尽量少地操作DOM呢?这是因为,javascript是足够快的,慢的是DOM操作。在更新界面的过程,越是少地操作DOM,UI的渲染性能越好。

在上一个篇章里面,我们实现了重头戏函数-render。如果将render函数的第一次调用,称作为“整树的初始挂载”,那么往后的调用就是“整树的更新”了。拿我们已经实现的render函数来说,如果我要更新界面,我只能传入一个新的element,重复调用render:

const root = document.getElementById('root')

const initDivElement = (
    <div id="test" onClick={() => { alert('I been clicked')}>
        我是文本节点
    </div>
)

const newDivElement = (
    <div id="test" onClick={() => { alert('I been clicked')}>
        我是更新后的文本节点
    </div>
)
// 初始挂载
render(initDivElement,root)

// 更新
render(newDivElement,root)

代码一执行,你发现运行结果明显不是我们想要的。因为,目前render函数只会往容器节点里面追加子元素,而不是替代原有的子元素。所以我们得把最后一块代码的逻辑改一改:

 function render(element, domContainer) {
    // ......
    if(domContainer.hasChildNodes()){
        domContainer.replaceChild(domNode,domContainer.lastChild)
    }else {
        domContainer.appendChild(domNode)
    }
 }

以最基本的要求看上面的实现,它是没问题的。但是如果用户只更新整树的根节点上的一个property,又或者更新一颗深度很深,广度很广的整树呢?在这些情况下,再这么粗暴地直接替换一整颗现有的DOM树显得太没有技术追求了。我们要时刻记住,DOM操作是相对消耗性能的。我们的目标是:尽量少地操作DOM。所以,我们还得继续优化。

鉴于virtual DOM字面量对象所带来的声明范式,我们可以把一个react element看做是屏幕上的一帧。渲染是一帧一帧地进行的,所以我们能想到的做法就是通过对比上一帧和现在要渲染的这一帧,找出两者之间的不同点,针对这些不同点来执行相应的DOM更新。

那么问题来啦。程序在运行时,我们该如何访问先前的virtual DOM对象呢?我们该如何复用已经创建过的原生DOM对象呢?想了很久,我又想到字面量对象了。是的,我们需要创建一个字面量对象,让它保存着先前virtual DOM对象的引用和已创建原生DOM对象的引用。我们还需要保存一个指向各个子react element的引用,以便使用递归法则对他们进行“协调”。综合考虑一下,我们口中的这个“字面量对象”的数据结构如下:

// 伪代码
const 字面量对象 = {
    dom: element对应的原生DOM对象,
    element:上一帧所对应的element,
    children:[子virtual DOM对象所对应的字面量对象]
}

熟悉react概念的读者肯定会知道,我们口中的这种“字面量对象”,就是react.js源码中的instance的概念。注意,这个instance的概念跟面向对象编程中instance(实例)的概念是不同的。它是一个相对的概念。如果讲react element(virutal DOM对象)是“虚”的,那么使用这个react element来创建的原生DOM对象(在render函数的实现中有相关代码)就是“实”的。我们把这个“实”的东西挂载在一个字面量对象上,并称这个字面量对象为instance,称这个过程为“实例化”,好像也说得过去。加入instance概念之后,值得强调的一点是:“一旦一个react element创建过对应的原生DOM对象,我们就说这个element被实例化过了”。

现在,我们来看看react element,原生DOM对象和instance三者之间的关系吧:

是的,它们之间是一一对应的关系。 instance概念的引入非常重要,它是我们实现Reconciliation算法的基石。所以,在这,我们有必要重新整理一下它的数据结构:

const instance = {
    dom: DOMObject,
    element:reactElement,
    childInstances:[childInstance1,childInstance2]
}

梳理完毕,我们就开始重构我们的代码。 万事从头起,对待树状结构的数据更是如此。是的,我们需要一个root instance,并且它应该是一个全局的“单例”。同时,我们以instance这个概念为关注点分离的启发点,将原有的render函数根据各自的职责将它分离为三个函数:(新的)render函数,reconcile函数和instantiate函数。各自的函数签名和职责如下:

// 主要对root element调用reconcile函数,维护一份新的root instance
render:(element,domContainer) => void

// 主要负责对根节点执行一些增删改的DOM操作,并且通过调用instantiate函数,
// 返回当前element所对应的新的instance
reconcile:(instance,element,domContainer) => instance

// 负责具体的“实例化”工作。
// “实例化”工作大概包含两部分:
// 1)创建相应的DOM对象 2)为创建的DOM对象设置相应的属性
instantiate:(element) => instance

下面看看具体的实现代码:

let rootInstance = null

function render(element,domContainer){
    const prevRootInstance = rootInstance
    const newRootInstance = reconcile(prevRootInstance,element,domContainer)
    rootInstance = newRootInstance
}

function reconcile(instance,element,domContainer){
    let newInstance
    // 对应于整树的初始挂载
    if(instance === null){
        newInstance = instantiate(element)
        domContainer.appendChild(newInstance.dom)
    }else { // 对应于整树的更新
        newInstance = instantiate(element)
        domContainer.replaceChild(newInstance.dom,instance.dom)
    }
    return newInstance
}

//大部分复用原render函数的实现
function instantiate(element){
     const { type, props } = element
    
    // 创建对应的DOM节点
    const isTextElement = type === 'TEXT_ELEMENT'
    const domNode = isTextElement ? document.createTextNode('') : document.createElement(type)
    
    // 给DOM节点的属性分类:事件属性,普通属性和children
    const keys = Object.keys(props)
    const isEventProp = prop => /^on[A-Z]/.test(prop)
    const eventProps = keys.filter(isEventProp) // 事件属性
    const normalProps = keys.filter((key) => !isEventProp(key) && key !== 'children') // 普通属性
    const children = props.children // children
    
    // 对事件属性,添加对应的事件监听器
    eventProps.forEach(name => {
        const eventType = name.toLowerCase().slice(2)
        const eventHandler = props[name]
        domNode.addEventListener(eventType,eventHandler)
    })
    
    // 对普通属性,直接设置
     normalProps.forEach(name => {
        domNode[name] = props[name]
    })
    
    // 对children element递归调用instantiate函数
    const childInstances = []
     if(children && children.length){
        childInstances = children.map(childElement => instantiate(childElement))
        const childDoms = childInstances.map(childInstance => childInstance.dom)
        childDoms.forEach(childDom => domNode.appendChild(childDom)
    }
    
    const instance = {
        dom:domNode,
        element,
        childInstances
    }
    
    return instance
}

从上面的实现可以看出,reconcile函数主要负责对root element进行映射来完成整树的初始挂载或更新。在条件分支语句中,第一个分支实现的是初始挂载,第二个分支实现的是更新,对应的是“增”和“改”。那“删除”去去哪啦?好吧,我们补上这个分支:

function reconcile(instance,element,domContainer){
    let newInstance
    
    if(instance === null){// 整树的初始挂载
        newInstance = instantiate(element)
        domContainer.appendChild(newInstance.dom)
    }else if(element === null){ // 整树的删除
        newInstance = null
        domContainer.removeChild(instance.dom)
    }else { // 整树的更新
        newInstance = instantiate(element)
        domContainer.replaceChild(newInstance.dom,instance.dom)
    }
    return newInstance
}

还记得我们的上面提到的目标吗?所以,我们在仔细审视一下自己的代码,看看还能有优化的空间不?果不其然,对待“更新”,我们直接一个“replaceChild”操作,未免也显得太简单粗暴了吧?细想一下,root element的映射过程中的更新,也可以分为两种情况,第一种是root element的type属性值变了,另外一个种是type属性值不变,变的是另外两个属性-props和children。在补上另外一个分支之前,我们不妨把对DOM节点属性的操作的实现逻辑从instantiate函数中抽出来,封装一下,使之能够同时应付属性的设置和更新。我们给它命名为“updateDomProperties”,函数签名为:

updateDomProperties:(domNode,prevProps,currProps) => void

下面,我们来实现它:

function updateDomProperties(domNode,prevProps,currProps){

    // 给DOM节点的属性分类:事件属性,普通属性
    const isEventProp = prop => /^on[A-Z]/.test(prop)
    const isNormalProp = prop => { return !isEventProp(prop) && prop !== 'children'}

    // 如果先前的props是有key-value值的话,则先做一些清除工作。否则容易导致内存溢出
    if(Object.keys(prevProps).length){
        // 清除domNode的事件处理器
        Object.keys(prevProps).filter(isEventProp).forEach(name => {
            const eventType = name.toLowerCase().slice(2)
            const eventHandler = props[name]
            domNode.removeEventListener(eventType,eventHandler
        })
        
        // 清除domNode上的旧属性
        Object.keys(prevProps).filter(isNormalProp).forEach(name => {
            domNode[name] = null
        })
    }
    
    // current props
    const keys = Object.keys(currProps)
    const eventProps = keys.filter(isEventProp) // 事件属性
    const normalProps = keys.filter(isNormalProp) // 普通属性
    
    // 挂载新的事件处理器
    eventProps.forEach(name => {
        const eventType = name.toLowerCase().slice(2)
        const eventHandler = props[name]
        domNode.addEventListener(eventType,eventHandler)
    })
    
    // 设置新属性
    normalProps.forEach(name => {
        domNode[name] = currProps[name]
    })
}

同时,我也更新一下instantiate的实现:

function instantiate(element){
     const { type, props } = element
    
    // 创建对应的DOM节点
    const isTextElement = type === 'TEXT_ELEMENT'
    const domNode = isTextElement ? document.createTextNode('') : document.createElement(type)
    
    // 设置属性
    updateDomProperties(domNode,{},props)
    
    // 对children element递归调用instantiate函数
    const children = props.children 
    let childInstances = []
     if(children && children.length){
        childInstances = children.map(childElement => instantiate(childElement))
        const childDoms = childInstances.map(childInstance => childInstance.dom)
        childDoms.forEach(childDom => domNode.appendChild(childDom))
    }
    
    const instance = {
        dom:domNode,
        element,
        childInstances
    }
    
    return instance
}

updateDomProperties函数实现完毕,最后我们去reconcile函数那里把前面提到的那个条件分支补上。注意,这个分支对应的实现的成本很大一部分是花在对children的递归协调上:

function reconcile(instance,element,domContainer){
    let newInstance = {} 
    // 整树的初始挂载
    if(instance === null){
        newInstance = instantiate(element)
        domContainer.appendChild(newInstance.dom)
    }else if(element === null){ // 整树的删除
        newInstance = null
        domContainer.removeChild(instance.dom)
    }else if(element.type === instance.element.type){ // 整树的更新
        newInstance.dom = instance.dom
        newInstance.element = element
        
        // 更新属性
        updateDomProperties(instance.dom,instance.element.props,element.props)
        
        // 递归调用reconcile来更新children
        newInstance.childInstances = (() => {
            const parentNode = instance.dom
            const prevChildInstances = instance.childInstances
            const currChildElement = element.props.children || []
            const nextChildInstances = []
            const count = Math.max(prevChildInstances.length,element.props.children.length)
            for(let i=0 ; i<count ; i++){
                const childInstance = prevChildInstances[i]
                const childElement = currChildElement[i]
                
                // 增加子元素
                if(childInstance === undefined){
                    childInstance = null
                }
                // 删除子元素
                if(childElement === undefined){
                    childElement = null
                }
                const nextChildInstance = reconcile(childInstance,childElement,parentNode)
                 
                 //过滤为null的实例
                if(nextChildInstance !== null){
                    nextChildInstances.push(nextChildInstance)
                }
            }
            
            return nextChildInstances
        })()
    }else { // 整树的替换
        newInstance = instantiate(element)
        domContainer.replaceChild(newInstance.dom,instance.dom)
    }
    return newInstance
}

我们用四个函数就实现了整一个virtual DOM映射过程中的协调了。我们可以这么说:

协调的对象是virtual DOM对象树和real DOM对象树;协调的媒介是instance;协调的路径是始于根节点,终于末端节点。

到目前为止,如果我们想要更新界面,我们只能对virtual DOM对象树的根节点调用render函数,协调便会在整颗树上发生。如果这颗树深度很深,广度很广的话,即使有了协调,渲染性能也不会太可观。下一篇章,我们一起来运用react分而治之的理念,引入“component”概念,实现更细粒度的协调。

下一篇:循序渐进DIY一个react(四)