资讯专栏INFORMATION COLUMN

从头实现一个简易版React(三)

yvonne / 242人阅读

摘要:写在开头从头实现一个简易版二地址在上一节,我们的已经具备了渲染功能。参考资料,感谢几位前辈的分享陈屹深入技术栈

写在开头

从头实现一个简易版React(二)地址:https://segmentfault.com/a/11...
在上一节,我们的react已经具备了渲染功能。
在这一节我们将着重实现它的更新,说到更新,大家可能都会想到React的diff算法,它可以说是React性能高效的保证,同时也是最神秘,最难理解的部分(个人觉得),想当初我也是看了好多文章,敲了N次代码,调试了几十遍,才总算理解了它的大概。在这也算是把我的理解阐述出来。

进入正题

同样,我们会实现三种ReactComponent的update方法。不过在这之前,我们先想想,该如何触发React的更新呢?没错,就是setState方法。

// 所有自定义组件的父类
class Component {
  constructor(props) {
    this.props = props
  }

  setState(newState) {
    this._reactInternalInstance.updateComponent(null, newState)
  }
}
//代码地址:src/react/Component.js

这里的reactInternalInstance就是我们在渲染ReactCompositeComponent时保存下的自身的实例,通过它调用了ReactCompositeComponent的update方法,接下来,我们就先实现这个update方法。

ReactCompositeComponent

这里的update方法同mount有点类似,都是调用生命周期和render方法,先上代码:

class ReactCompositeComponent extends ReactComponent {
  constructor(element) {
    super(element)
    // 存放对应的组件实例
    this._instance = null
    this._renderedComponent = null
  }
  
 mountComponent(rootId) {
  //内容略
  }

  // 更新
  updateComponent(nextVDom, newState) {
    // 如果有新的vDom,就使用新的
    this._vDom = nextVDom || this._vDom
    const inst = this._instance
    // 获取新的state,props
    const nextState = { ...inst.state, ...newState }
    const nextProps = this._vDom.props

    // 判断shouldComponentUpdate
    if (inst.shouldComponentUpdate && (inst.shouldComponentUpdate(nextProps, nextState) === false)) return

    inst.componentWillUpdate && inst.componentWillUpdate(nextProps, nextState)

    // 更改state,props
    inst.state = nextState
    inst.props = nextProps

    const prevComponent = this._renderedComponent

    // 获取render新旧的vDom
    const prevRenderVDom = prevComponent._vDom
    const nextRenderVDom = inst.render()

    // 判断是需要更新还是重新渲染
    if (shouldUpdateReactComponent(prevRenderVDom, nextRenderVDom)) {
      // 更新
      prevComponent.updateComponent(nextRenderVDom)
      inst.componentDidUpdate && inst.componentDidUpdate()
    } else {
      // 重新渲染
      this._renderedComponent = instantiateReactComponent(nextRenderVDom)
      // 重新生成对应的元素内容
      const nextMarkUp = this._renderedComponent.mountComponent(this._rootNodeId)
      // 替换整个节点
      $(`[data-reactid="${this._rootNodeId}"]`).replaceWith(nextMarkUp)
    }
  }
}
//代码地址:src/react/component/ReactCompositeComponent.js

有两点要说明:

熟悉React的都知道,很多时候组件的更新,vDom并没有变化,我们可以通过shouldComponentUpdate这个生命周期来优化这点,当shouldComponentUpdate为false时,直接return,不执行下面的代码。

当调用render获取到新的vDom时,将会比较新旧的vDom类型是否相同,这也属于diff算法优化的一部分,如果类型相同,则执行更新,反之,就重新渲染。

// 判断是更新还是渲染
function shouldUpdateReactComponent(prevVDom, nextVDom) {
  if (prevVDom != null && nextVDom != null) {
    const prevType = typeof prevVDom
    const nextType = typeof nextVDom

    if (prevType === "string" || prevType === "number") {
      return nextType === "string" || nextType === "number"
    } else {
      return nextType === "object" && prevVDom.type === nextVDom.type && prevVDom.key === nextVDom.key
    }
  }
}
//代码地址:src/react/component/util.js

注意,这里我们使用到了key,当type相同时使用key可以快速准确得出两个vDom是否相同,这是为什么React要求我们在循环渲染时必须添加key这个props。

ReactTextComponent

ReactTextComponent的update方法非常简单,判断新旧文本是否相同,不同则更新内容,直接贴代码:

class ReactTextComponent extends ReactComponent {
  mountComponent(rootId) {
  //省略
  }

  // 更新
  updateComponent(nextVDom) {
    const nextText = "" + nextVDom

    if (nextText !== this._vDom) {
      this._vDom = nextText
    }
    // 替换整个节点
    $(`[data-reactid="${this._rootNodeId}"]`).html(this._vDom)
  }
// 代码地址:src/react/component/ReactTextComponent.js
}
ReactDomComponent

ReactDomComponent的update最复杂,可以说diff的核心都在这里,本文的重心也就放在这。
整个update分为两块,props的更新和children的更新。

class ReactDomComponent extends ReactComponent {
  mountComponent(rootId) {
  //省略
  }

  // 更新
  updateComponent(nextVDom) {
    const lastProps = this._vDom.props
    const nextProps = nextVDom.props

    this._vDom = nextVDom

    // 更新属性
    this._updateDOMProperties(lastProps, nextProps)
    // 再更新子节点
    this._updateDOMChildren(nextVDom.props.children)
  }
// 代码地址:src/react/component/ReactDomComponent.js
}

props的更新非常简单,无非就是遍历新旧props,删除不在新props里的老props,添加不在老props里的新props,更新新旧都有的props,事件特殊处理。

  _updateDOMProperties(lastProps, nextProps) {
    let propKey = ""

    // 遍历,删除已不在新属性集合里的老属性
    for (propKey in lastProps) {
      // 属性在原型上或者新属性里有,直接跳过
      if (nextProps.hasOwnProperty(propKey) || !lastProps.hasOwnProperty(propKey)) {
        continue
      }

      // 对于事件等特殊属性,需要多带带处理
      if (/^on[A-Za-z]/.test(propKey)) {
        const eventType = propKey.replace("on", "")
        // 针对当前的节点取消事件代理
        $(document).undelegate(`[data-reactid="${this._rootNodeId}"]`, eventType, lastProps[propKey])
        continue
      }
      
    }

    // 对于新的属性,需要写到dom节点上
    for (propKey in nextProps) {
      // 更新事件属性
      if (/^on[A-Za-z]/.test(propKey)) {
        var eventType = propKey.replace("on", "")

        // 以前如果已经有,需要先去掉
        lastProps[propKey] && $(document).undelegate(`[data-reactid="${this._rootNodeId}"]`, eventType, lastProps[propKey])

        // 针对当前的节点添加事件代理
        $(document).delegate(`[data-reactid="${this._rootNodeId}"]`, `${eventType}.${this._rootNodeId}`, nextProps[propKey])
        continue
      }

      if (propKey === "children") continue

      // 更新普通属性
      $(`[data-reactid="${this._rootNodeId}"]`).prop(propKey, nextProps[propKey])
    }
  }
// 代码地址:src/react/component/ReactDomComponent.js

children的更新则相对复杂了很多,陈屹老师的《深入React技术栈》中提到,diff算法分为3块,分别是

tree diff

component diff

element diff

上文中的shouldUpdateReactComponent就属于component diff,接下来,让我们依据这三种diff实现updateChildren。

// 全局的更新深度标识,用来判定触发patch的时机
let updateDepth = 0
// 全局的更新队列
let diffQueue = []

 _updateDOMChildren(nextChildVDoms) {
    updateDepth++

    // diff用来递归查找差异,组装差异对象,并添加到diffQueue中
    this._diff(diffQueue, nextChildVDoms)
    updateDepth--

    if (updateDepth === 0) {
      // 具体的dom渲染
      this._patch(diffQueue)
      diffQueue = []
    }

这里通过updateDepth对vDom树进行层级控制,只会对相同层级的DOM节点进行比较,只有当一棵DOM树全部遍历完,才会调用patch处理差异。也就是所谓的tree diff。
确保了同层次后,我们要实现_diff方法。
已经渲染过的子ReactComponents在这里是数组,我们要遍历出里面的vDom进行比较,这里就牵扯到上文中的key,在有key时,我们优先用key来获取vDom,所以,我们首先遍历数组,将其转为map(这里先用object代替,以后会更改成es6的map),如果有key值的,就用key值作标识,无key的,就用index。
下面是array到map的代码:

// 将children数组转化为map
export function arrayToMap(array) {
  array = array || []
  const childMap = {}

  array.forEach((item, index) => {
    const name = item && item._vDom && item._vDom.key ? item._vDom.key : index.toString(36)
    childMap[name] = item
  })
  return childMap
}

部分diff方法:

// 将之前子节点的component数组转化为map
const prevChildComponents = arrayToMap(this._renderedChildComponents)
// 生成新的子节点的component对象集合
const nextChildComponents = generateComponentsMap(prevChildComponents, nextChildVDoms)

将ReactComponent数组转化为map后,用老的ReactComponents集合和新vDoms数组生成新的ReactComponents集合,这里会使用shouldUpdateReactComponent进行component diff,如果相同,则直接更新即可,反之,就重新生成ReactComponent

/**
 * 用来生成子节点的component
 * 如果是更新,就会继续使用以前的component,调用对应的updateComponent
 * 如果是新的节点,就会重新生成一个新的componentInstance
 */
function generateComponentsMap(prevChildComponents, nextChildVDoms = []) {
  const nextChildComponents = {}

  nextChildVDoms.forEach((item, index) => {
    const name = item.key ? item.key : index.toString(36)
    const prevChildComponent = prevChildComponents && prevChildComponents[name]

    const prevVdom = prevChildComponent && prevChildComponent._vDom
    const nextVdom = item

    // 判断是更新还是重新渲染
    if (shouldUpdateReactComponent(prevVdom, nextVdom)) {
      // 更新的话直接递归调用子节点的updateComponent
      prevChildComponent.updateComponent(nextVdom)
      nextChildComponents[name] = prevChildComponent
    } else {
      // 重新渲染的话重新生成component
      const nextChildComponent = instantiateReactComponent(nextVdom)
      nextChildComponents[name] = nextChildComponent
    }
  })
  return nextChildComponents
}

经历了以上两步,我们已经获得了新旧同层级的ReactComponents集合。需要做的,只是遍历这两个集合,进行比较,同属性的更新一样,进行移动,新增,和删除,当然,在这个过程中,我会包含我们的第三种优化,element diff。它的策略是这样的:首先对新集合的节点进行循环遍历,通过唯一标识可以判断新老集合中是否存在相同的节点,如果存在相同节点,则进行移动操作,但在移动前需要将当前节点在老集合中的位置与 lastIndex 进行比较,if (prevChildComponent._mountIndex < lastIndex),则进行节点移动操作,否则不执行该操作。这是一种顺序优化手段,lastIndex 一直在更新,表示访问过的节点在老集合中最右的位置(即最大的位置),如果新集合中当前访问的节点比 lastIndex 大,说明当前访问节点在老集合中就比上一个节点位置靠后,则该节点不会影响其他节点的位置,因此不用添加到差异队列中,即不执行移动操作,只有当访问的节点比 lastIndex 小时,才需要进行移动操作。
上完整的diff方法代码:

// 差异更新的几种类型
const UPDATE_TYPES = {
  MOVE_EXISTING: 1,
  REMOVE_NODE: 2,
  INSERT_MARKUP: 3
}

   // 追踪差异
  _diff(diffQueue, nextChildVDoms) {
    // 将之前子节点的component数组转化为map
    const prevChildComponents = arrayToMap(this._renderedChildComponents)
    // 生成新的子节点的component对象集合
    const nextChildComponents = generateComponentsMap(prevChildComponents, nextChildVDoms)

    // 重新复制_renderChildComponents
    this._renderedChildComponents = []
    for (let name in nextChildComponents) {
      nextChildComponents.hasOwnProperty(name) && this._renderedChildComponents.push(nextChildComponents[name])
    }

    let lastIndex = 0 // 代表访问的最后一次老的集合位置
    let nextIndex = 0 // 代表到达的新的节点的index

    // 通过对比两个集合的差异,将差异节点添加到队列中
    for (let name in nextChildComponents) {
      if (!nextChildComponents.hasOwnProperty(name)) continue

      const prevChildComponent = prevChildComponents && prevChildComponents[name]
      const nextChildComponent = nextChildComponents[name]

      // 相同的话,说明是使用的同一个component,需要移动
      if (prevChildComponent === nextChildComponent) {
        // 添加差异对象,类型:MOVE_EXISTING
        prevChildComponent._mountIndex < lastIndex && diffQueue.push({
          parentId: this._rootNodeId,
          parentNode: $(`[data-reactid="${this._rootNodeId}"]`),
          type: UPDATE_TYPES.MOVE_EXISTING,
          fromIndex: prevChildComponent._mountIndex,
          toIndex: nextIndex
        })

        lastIndex = Math.max(prevChildComponent._mountIndex, lastIndex)
      } else {
        // 如果不相同,说明是新增的节点
        // 如果老的component在,需要把老的component删除
        if (prevChildComponent) {
          diffQueue.push({
            parentId: this._rootNodeId,
            parentNode: $(`[data-reactid="${this._rootNodeId}"]`),
            type: UPDATE_TYPES.REMOVE_NODE,
            fromIndex: prevChildComponent._mountIndex,
            toIndex: null
          })

          // 去掉事件监听
          if (prevChildComponent._rootNodeId) {
            $(document).undelegate(`.${prevChildComponent._rootNodeId}`)
          }

          lastIndex = Math.max(prevChildComponent._mountIndex, lastIndex)
        }

        // 新增加的节点
        diffQueue.push({
          parentId: this._rootNodeId,
          parentNode: $(`[data-reactid="${this._rootNodeId}"]`),
          type: UPDATE_TYPES.INSERT_MARKUP,
          fromIndex: null,
          toIndex: nextIndex,
          markup: nextChildComponent.mountComponent(`${this._rootNodeId}.${name}`)
        })
      }

      // 更新_mountIndex
      nextChildComponent._mountIndex = nextIndex
      nextIndex++
    }

    // 对于老的节点里有,新的节点里没有的,全部删除
    for (let name in prevChildComponents) {
      const prevChildComponent = prevChildComponents[name]

      if (prevChildComponents.hasOwnProperty(name) && !(nextChildComponents && nextChildComponents.hasOwnProperty(name))) {
        diffQueue.push({
          parentId: this._rootNodeId,
          parentNode: $(`[data-reactid="${this._rootNodeId}"]`),
          type: UPDATE_TYPES.REMOVE_NODE,
          fromIndex: prevChildComponent._mountIndex,
          toIndex: null
        })

        // 如果渲染过,去掉事件监听
        if (prevChildComponent._rootNodeId) {
          $(document).undelegate(`.${prevChildComponent._rootNodeId}`)
        }
      }
    }
  }
//  代码地址:src/react/component/ReactDomCompoent.js

调用diff方法后,会回到tree diff那一步,当一整棵树遍历完后,就需要通过Patch将更新的内容渲染出来了,patch方法相对比较简单,由于我们把更新的内容都放入了diffQueue中,只要遍历这个数组,根据不同的类型进行相应的操作就行。

  // 渲染
  _patch(updates) {
    // 处理移动和删除的
    updates.forEach(({ type, fromIndex, toIndex, parentNode, parentId, markup }) => {
      const updatedChild = $(parentNode.children().get(fromIndex))

      switch (type) {
        case UPDATE_TYPES.INSERT_MARKUP:
          insertChildAt(parentNode, $(markup), toIndex) // 插入
          break
        case UPDATE_TYPES.MOVE_EXISTING:
          deleteChild(updatedChild) // 删除
          insertChildAt(parentNode, updatedChild, toIndex)
          break
        case UPDATE_TYPES.REMOVE_NODE:
          deleteChild(updatedChild)
          break
        default:
          break
      }
    })
  }
// 代码地址:src/react/component/ReactDomComponent.js
总结

以上,整个简易版React就完成了,可以试着写些简单的例子跑跑看了,是不是非常有成就感呢?

总结下更新:
ReactCompositeComponent:负责调用生命周期,通过component diff将更新都交给了子ReactComponet
ReactTextComponent:直接更新内容
ReactDomComponent:先更新props,在更新children,更新children分为三步,tree diff保证同层级比较,使用shouldUpdateReactComponent进行component diff,最后在element diff通过lastIndex顺序优化

至此,整个从头实现简易版React就结束了,感谢大家的观看。

参考资料,感谢几位前辈的分享:
https://www.cnblogs.com/sven3...
https://github.com/purplebamb...
陈屹 《深入React技术栈》

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/93134.html

相关文章

  • 从头实现一个简易React(一)

    摘要:既然看不懂,那就看看社区前辈们写的一些源码分析文章以及实现思路吧,又这么过了几天,总算是摸清点思路,于是在参考了前辈们的基础上,实现了一个简易版的。总结以上就是实现一个的总体思路,下节我们重点放在不同的上。 写在开头 工作中使用react也很长一段时间了,虽然对它的用法,原理有了一定的了解,但是总感觉停留在表面。本着知其然知其所以然的态度,我试着去看了react源码,几天下来,发现并不...

    meislzhua 评论0 收藏0
  • 从头实现一个简易React(二)

    摘要:写在开头从头实现一个简易版一地址上一节,我们详细介绍了实现一个简易的思路以及整体的结构,但是对于渲染和更新的原理,却还没有提及,因此,本节我们将重点放在的渲染上。 写在开头 从头实现一个简易版React(一)地址:https://segmentfault.com/a/11...上一节,我们详细介绍了实现一个简易React的思路以及整体的结构,但是对于渲染和更新的原理,却还没有提及,因此...

    vvpvvp 评论0 收藏0
  • 基于react native的登录界面demo 超简易教程 redux

    摘要:登录视图登陆失败用户名或密码不能为空弹出提示框成功是点击登录按钮后调用的函数,这里的功能比较简单。通过把发出去密码登录声明组件需要整个中的哪一部分数据作为自己的将和组件联系在一起编写是负责生成的,所以在大项目中还会用到合并。 本猪说 本猪猪刚学react,也刚看RN,就叫写这个,苦不堪言,搭环境就搭了好久。看网上教程也是改了好多小地方才写完了。本着雷锋精神手把手教你写(假的)。 sho...

    scq000 评论0 收藏0
  • 关于Vue2一些值得推荐的文章 -- 五、六月份

    摘要:五六月份推荐集合查看最新的请点击集前端最近很火的框架资源定时更新,欢迎一下。苏幕遮燎沈香宋周邦彦燎沈香,消溽暑。鸟雀呼晴,侵晓窥檐语。叶上初阳乾宿雨,水面清圆,一一风荷举。家住吴门,久作长安旅。五月渔郎相忆否。小楫轻舟,梦入芙蓉浦。 五、六月份推荐集合 查看github最新的Vue weekly;请::点击::集web前端最近很火的vue2框架资源;定时更新,欢迎 Star 一下。 苏...

    Terry_Tai 评论0 收藏0
  • 关于Vue2一些值得推荐的文章 -- 五、六月份

    摘要:五六月份推荐集合查看最新的请点击集前端最近很火的框架资源定时更新,欢迎一下。苏幕遮燎沈香宋周邦彦燎沈香,消溽暑。鸟雀呼晴,侵晓窥檐语。叶上初阳乾宿雨,水面清圆,一一风荷举。家住吴门,久作长安旅。五月渔郎相忆否。小楫轻舟,梦入芙蓉浦。 五、六月份推荐集合 查看github最新的Vue weekly;请::点击::集web前端最近很火的vue2框架资源;定时更新,欢迎 Star 一下。 苏...

    sutaking 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<