從頭實現一個簡易版React(三)

寫在開頭

從頭實現一個簡易版React(二)地址:https://segmentfault.com/a/11...
在上一節,咱們的react已經具有了渲染功能。
在這一節咱們將着重實現它的更新,說到更新,你們可能都會想到React的diff算法,它能夠說是React性能高效的保證,同時也是最神祕,最難理解的部分(我的以爲),想當初我也是看了好多文章,敲了N次代碼,調試了幾十遍,才總算理解了它的大概。在這也算是把個人理解闡述出來。javascript

進入正題

一樣,咱們會實現三種ReactComponent的update方法。不過在這以前,咱們先想一想,該如何觸發React的更新呢?沒錯,就是setState方法。html

// 全部自定義組件的父類
class Component {
  constructor(props) {
    this.props = props
  }

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

這裏的reactInternalInstance就是咱們在渲染ReactCompositeComponent時保存下的自身的實例,經過它調用了ReactCompositeComponent的update方法,接下來,咱們就先實現這個update方法。java

ReactCompositeComponent

這裏的update方法同mount有點相似,都是調用生命週期和render方法,先上代碼:react

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

有兩點要說明:git

  1. 熟悉React的都知道,不少時候組件的更新,vDom並無變化,咱們能夠經過shouldComponentUpdate這個生命週期來優化這點,當shouldComponentUpdate爲false時,直接return,不執行下面的代碼。
  2. 當調用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。es6

ReactTextComponent

ReactTextComponent的update方法很是簡單,判斷新舊文本是否相同,不一樣則更新內容,直接貼代碼:github

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,事件特殊處理。segmentfault

_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塊,分別是數組

  1. tree diff
  2. component diff
  3. 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技術棧》

相關文章
相關標籤/搜索