構建本身的React:(3)Instances, reconciliation and virtual DOM

翻譯自:https://engineering.hexacta.c...javascript

截止目前咱們已經可使用JSX來建立並渲染頁面DOM。在這一節咱們將會把重點放在如何更新DOM上。java

在介紹setState以前,更新DOM只能經過更改入參並再次調用render方法來實現。若是咱們想實現一個時鐘,代碼大概下面這個樣子:node

const rootDom = document.getElementById("root");

function tick() {
  const time = new Date().toLocaleTimeString();
  const clockElement = <h1>{time}</h1>;
  render(clockElement, rootDom);
}

tick();
setInterval(tick, 1000);

事實上,上面的代碼運行後並不能達到預期的效果,屢次調用當前版本的render方法只會不斷往頁面上添加新的元素,而不是咱們預期的更新已經存在的元素。下面咱們想辦法實現更新操做。在render方法末尾,咱們能夠去檢查父類元素是否含有子元素,若是有,咱們就用新生成的元素去替換舊的元素。react

function render(element, parentDom){
    // ...
    // Create dom from element
    // ...
    if(!parentDom.lastChild){
        parentDom.appendChild(dom);
    } else {
        parentDom.replaceChild(dom, parentDom.lastChild);
    }
}

針對開頭那個時鐘的例子,上面render的實現是沒問題的。但對於更復雜的狀況,好比有多個子元素時上面代碼就不能知足要求了。正確的作法是咱們須要比較先後兩次調用render方法時所生成的元素樹,對比差別後只更新有變化的部分。算法

Virtual DOM and Reconciliation

React把一致性校驗的過程稱做「diffing」,咱們要作的和React同樣。首先須要把當前的元素樹保存起來以便和後面新的元素樹比較,也就是說,咱們須要把當前頁面內容所對應的虛擬DOM保存下來。數組

這顆虛擬DOM樹的節點有必要討論一下。一種選擇是使用Didact Elements,它們已經含有props.children屬性,咱們能夠根據這個屬性構建出虛擬DOM樹。如今有兩個問題擺在面前:首先,爲了方便比較,咱們須要保存每一個虛擬DOM指向的真實DOM的引用(校驗過程當中咱們有須要會去更新實際DOM的屬性),而且元素還要是不可變的;第二,目前元素還不支持含有內部狀態(state)的組件。app

Instances

咱們須要引入一個新的概念-----instances-----來解決上面的問題。一個實例表示一個已經渲染到DOM的元素,它是含有elementdomchildInstances屬性的一個JS對象。childInstances是由子元素對應實例組成的數組。dom

注意,這裏說的實例和Dan Abramov在 React Components, Elements, and Instances中提到的實例並非一回事。Dan說的是公共實例,是調用繼承自 React.Component的組件的構造函數後返回的東西。咱們將在後面的章節添加公共實例。

每一個DOM節點都會有對應的實例。一致性校驗的目的之一就是儘可能避免去建立或者移除實例。建立和移除實例意味着咱們要修改DOM樹,因此咱們越多的重用實例就會越少的去修改DOM樹。函數

Refactoring

接下來咱們來重寫render方法,增長一致性校驗算法,同時增長一個instantiate方法來爲元素建立實例。翻譯

let rootInstance = null; // 用來保存上一次調用render產生的實例

function render(element, container){
    const prevInstance = rootInstance;
    const nextInstance = reconcile(container, prevInstance, element);
    rootInstance = nextInstace;
}

// 目前只是針對根元素的校驗,沒有處理到子元素
function reconcile(parentDom, instance, element){
    if(instance === null){
        const newInstance = instantiate(element);
        parentDom.appendChild(newInstance.dom);
        return newInstance;
    } else {
        const newInstance = instantiate(element);
        parentDom.replaceChild(newInstance.dom, instance.dom);
        return newInstance;
    }
}

// 生成元素對應實例的方法
function instantiate(element){
    const { type, props} = element;
    
    const isTextElement = type === 'TEXT_ELEMENT';
    const dom = isTextElement ? document.createTextNode('') 
        : document.createElement(type);
        
    // 添加事件
    const isListener = name => name.startsWith("on");
    Object.keys(props).filter(isListener).forEach(name => {
        const eventType = name.toLowerCase().substring(2);
        dom.addEventListener(eventType, props[name]);
     });

      // 設置屬性
      const isAttribute = name => !isListener(name) && name != "children";
      Object.keys(props).filter(isAttribute).forEach(name => {
        dom[name] = props[name];
      });
      
      const childElements = props.children || [];
      const childInstances = childElements.map(instantiate);
      const childDoms = childInstances.map(childInstance => childInstace.dom);
      childDoms.forEach(childDom => dom.appendChild(childDOm));
      
      const instance = {dom, element, childInstances};
      return instance;
}

上面的render方法和以前的差很少,不一樣之處是保存了上次調用render方法產生的實例。咱們還把一致性校驗的功能從建立實例的代碼中分離了出來。

爲了重用dom節點,咱們須要一個能更新dom屬性的方法,這樣就不用每次都建立新的dom節點了。咱們來改造一下現有代碼中設置屬性的那部分的代碼。

function instantiate(element) {
  const { type, props } = element;

  // 建立DOM元素
  const isTextElement = type === 'TEXT_ELEMENT';
  const dom = isTextElement
    ? document.createTextNode("")
    : document.createElement(type);

  updateDomProperties(dom, [], props); // 實例化一個新的元素

  // 實例化並添加子元素
  const childElements = props.children || [];
  const childInstances = childElements.map(instantiate);
  const childDoms = childInstances.map(childInstance => childInstance.dom);
  childDoms.forEach(childDom => dom.appendChild(childDom));

  const instance = { dom, element, childInstances };
  return instance;
}

function updateDomProperties(dom, prevProps, nextProps){
    const isEvent = name => name.startsWith('on');
       const isAttribute = name => !isEvent(name) && name != 'children';
       
       Object.keys(prevProps).filter(isEvent).forEach(name => {
        const eventType = name.toLowerCase().substring(2);
        dom.removeEventListener(eventType, prevProps[name]);
       });
       
       Object.keys(preProps).filter(isAttribute).forEach(name => {
        dom[name] = nextProps[name];
       });
       
       // 設置屬性
      Object.keys(nextProps).filter(isAttribute).forEach(name => {
        dom[name] = nextProps[name];
      });

      // 添加事件監聽
      Object.keys(nextProps).filter(isEvent).forEach(name => {
        const eventType = name.toLowerCase().substring(2);
        dom.addEventListener(eventType, nextProps[name]);
      });
}

updateDomProperties方法會移除全部舊的屬性,而後再添加新屬性。若是屬性沒有變化的話依然會進行移除和添加操做,這必定程度上有些浪費,但咱們先這樣放着,後面再處理。

Reusing DOM nodes

前面說過,一致性校驗算法須要儘量多的去重用已經建立的節點。由於目前元素的type都是表明HTML中標籤名的字符串,因此若是同一位置先後兩次渲染的元素的類型同樣則表示二者爲同一類元素,對應的已經渲染到頁面上的dom節點就能夠被重用。下面咱們在reconcile中增長判斷先後兩次渲染的元素類型是否相同的功能,相同的話執行更新操做,不然是新建或者替換。

function reconcile(parentDom, instance, element) {
  if (instance == null) {
    // 建立實例
    const newInstance = instantiate(element);
    parentDom.appendChild(newInstance.dom);
    return newInstance;
  } else if (instance.element.type === element.type) { // 和老的實例進行類型比較
    // 更新
    updateDomProperties(instance.dom, instance.element.props, element.props);
    instance.element = element;
    return instance;
  } else {
    // 若是不相等的話直接替換
    const newInstance = instantiate(element);
    parentDom.replaceChild(newInstance.dom, instance.dom);
    return newInstance;
  }
}

Children Reconciliation

如今校驗過程尚未對子元素進行處理。針對子元素的校驗是React中的一個關鍵部分,這一過程須要元素的一個額外屬性key來完成,若是某個元素在新舊虛擬DOM上的key值相同,則表示該元素沒有發生變化,直接重用便可。在當前版本的代碼中咱們會遍歷instance.childInstanceselement.props.children,並對同一位置的實例和元素進行比較,經過這種方式完成對子元素的一致性校驗。這種方法的缺點就是,若是子元素只是調換了位置,那麼對應的DOM節點將無法重用。

咱們把同一實例上一次的instance.childInstances和此次對應元素的element.props.children進行遞歸比較,而且保存每次reconcile返回的結果以便更新childInstances

function reconcile(parentDom, instance, element){
    if(instance == null){
       const newInstance = instantiate(element);
        parentDom.appendChild(newInstance.dom);
        return newInstance;
    } else if(instance.element.type === element.type){
        updateDomProperties(instance.dom, instance.element.props, element.props);
        instance.childInstances = reconcileChildren(instance, element);
        instance.element = element;
        return instance;
    } else {
        const newInstance = instantiate(element);
        parentDom.replaceChild(newInstance.dom, instance.dom);
        return newInstance;
    }
}

function reconcileChildren(instance, element){
    const dom = instance.dom;
    const childInstances = instance.childInstances;
    const nextChildElements = element.props.children || [];
    const newChildInstances = [];
    const count = Math.max(childInstances.length, nextChildElements.length);
    for(let i = 0; i< count; i++){
        const childInstance = childInstances[i]; 
        const childElement = nextChildElements[i];//上面一行和這一行都容易出現空指針,稍後處理
        const newChildInstance = reconcile(dom, childInstance, childElement);
        newChildInstances.push(newChildInstance);
    }
    return newChildInstances;
}

Removing DOM nodes

若是nextChildElements數量多於childInstances,那麼對子元素進行一致性校驗時就容易出現undefined與剩下的子元素進行比較的狀況。不過這不是什麼大問題,由於在reconcile中的if(instance == null)會處理這種狀況,而且會根據多出來的元素建立新的實例。若是childInstances的數量多於nextChildElement,那麼reconcile就會收到一個undefined做爲其element參數,而後在嘗試獲取element.type時就會拋出錯誤。

出現這個錯誤是由於咱們沒有考慮DOM節點須要移除的狀況。因此接下來咱們要作兩件事情,一個是在reconcile中增長增長element === null的校驗,一個是在reconcileChildren中過濾掉值爲nullchildInstances元素。

function reconcile(parentDom, instance, element){
    if(instance == null){
        const newInstance = instantiate(element);
        parentDom.appendChild(newInstance.dom);
        return Instance;
    } else if(element == null){
        parentDom.removeChild(instance.dom);
        return null; // 注意這地方返回null了
    } else if(instance.element.type === element.type){
        updateDomProperties(instance.dom, instance.element.props, element.props);
        instance.childInstances = reconcileChildren(instance, element);
        instance.element = element;
        return instance;
    } else {
        const newInstance = instantiate(element);
        parentDom.replaceChild(newInstance.dom, instance.dom);
        return newInstance;
    }
}

function reconcileChildren(instance, element){
    const dom = instance.dom;
    const childInstances = instance.childInstances;
    const nextChildElements = element.props.children || [];
    const newChildInstances = [];
    const count = Math.max(childInstances.length, nextChildElements.length);
    for(let i = 0; i < count; i++){
        const childInstance = childInstances[i];
        const childElement = nextChildElements[i];
        const newChildInstances = reconcile(dom, childInstance, childElement);
        newChildInstances.push(newChildInstance);
    }
    return newChildInstances.filter(instance => instance != null)
}

Summary

這一節,咱們爲Didact增長了更新DOM的功能。咱們經過重用節點,避免了頻繁的建立和移除DOM節點,提升了Didact的工做效率。重用節點還有必定的好處,好比保存了DOM的位置或者焦點等一些內部狀態信息。

目前咱們是在根元素上調用render方法的,每次有變化時也是針對整棵元素樹進行的一致性校驗。下一節咱們將介紹組件。有了組件咱們就能夠只針對有變化的那一部分子樹進行一致性校驗。

相關文章
相關標籤/搜索