React進階小冊

圓兒圈圈發表於2018-11-21

在工作中我們一直使用react及react相關框架(antd/antd-mobile)

但是對於react的深層的瞭解不夠:JSX語法與虛擬DOM的關係?高階元件是如何實現的?dom diff的原理?

通過寫一篇react小冊來查缺補漏。

JSX和虛擬DOM

import React from 'react';
import ReactDOM from 'react-dom';

ReactDOM.render(
    <label className="test" htmlFor='hello'>
        hello<span>world</span>
    </label>, 
    document.getElementById('root')
);
複製程式碼

使用ReactDOM.render,第一個引數傳入JSX語法糖,第二個引數傳入container,能簡單實現在document上建立h1 dom節點。

其實,內部的執行方式如下:

import React from 'react';
import {render} from 'react-dom';

render(
    React.createElement(
        'label',
        {htmlFor:'hello',className:'test'},
        'hello',
        React.createElement(
            'span',
            null,
            'world'
        )
    ),
    document.getElementById('root')
);
複製程式碼

所以ReactDOM.render的時候,看似引入的React沒有用,但必須引入因為用到了React,createElement方法。

render出的HTML:

<label for="hello" class="test">hello<span>world</span></label>
複製程式碼

為了瞭解react createElement的流程,我們看一下原始碼:

var React = {
    ...
    createElement: createElementWithValidation,//React上定義了createElement方法
    ...
}

function createElementWithValidation(type, props, children) {
   
    var element = createElement.apply(this, arguments);
    
    ...//校驗迭代器陣列是否存在唯一key
    ...//校驗fragment片段props
    ...//props type校驗
    
    return element    
}

function createElement(type, config, children) {
  var propName = void 0;

  // Reserved names are extracted
  var props = {};

  var key = null;
  var ref = null;
  var self = null;
  var source = null;
  
  ...
  
  return ReactElement(type, key, ref, self, source, ReactCurrentOwner.current, props);

}
複製程式碼

React.createElement開始執行,完成了props、types等引數校驗,迭代展開childrens的引數:type, props, key, ref, self, source。返回了一個類似於babel語法樹結構的巢狀物件(只是個人認為...),如圖下:

React進階小冊

我們保留返回物件中最關鍵的屬性(type,props),然後簡化createElement方法便於理解:

function ReactElement(type,props) {
    this.type = type;
    this.props = props;
}
let React = {
    createElement(type,props={},...childrens){
        childrens.length===1?childrens = childrens[0]:void 0
        return new ReactElement(type,{...props,children:childrens})
    }
};
複製程式碼

React.createElement返回的是一個含有type(標籤),和它標籤屬性和內部物件(children)的Object,作為引數傳給ReactDom.render()方法

{
    props:{
        childrens:['text',{type:'xx',props:{}}]
        name:'xx'
        className:'xx'
    }
    type:'xx'
}
複製程式碼

於是我們可以根據ReactDom.render()的入參,簡寫出它的實現方法。

let render = (vNode,container)=>{
    let {type,props} = vNode;
    let elementNode = document.createElement(type); // 建立第一個元素
    for(let attr in props){ // 迴圈所有屬性
        if(attr === 'children'){ // 如果是children表示有巢狀關係
            if(typeof props[attr] == 'object'){ // 看是否是隻有一個文字節點
                props[attr].forEach(item=>{ // 多個的話迴圈判斷 如果是物件再次呼叫render方法
                    if(typeof item === 'object'){
                        render(item,elementNode)
                    }else{ //是文字節點 直接建立即可
                        elementNode.appendChild(document.createTextNode(item));
                    }
                })
            }else{ // 只有一個文字節點直接建立即可
                elementNode.appendChild(document.createTextNode(props[attr]));
            }
        }else{
            elementNode = setAttribute(elementNode,attr,props[attr])
        }
    }
    container.appendChild(elementNode)
};

function setAttribute(dom,name,value) {
    if(name === 'className') name = 'class'

    if(/on\w+/.test(name)){
        name = name.toLowerCase();
        dom[ name ] = value || '';
    }else if ( name === 'style' ) {
        if ( !value || typeof value === 'string' ) {
            dom.style.cssText = value || '';
        } else if ( value && typeof value === 'object' ) {
            for ( let name in value ) {
                dom.style[ name ] = typeof value[ name ] === 'number' ? value[ name ] + 'px' : value[ name ];
            }
        }
    }else{
        if ( name in dom ) {
            dom[ name ] = value || '';
        }
        if ( value ) {
            dom.setAttribute( name, value );
        } else {
            dom.removeAttribute( name );
        }
    }
    return dom
}
複製程式碼

dom diff

Ract作為資料渲染DOM的框架,如果用傳統的刪除整個節點並新建節點的方式會很消耗效能。

React渲染頁面的方法時比較對比虛擬DOM前後的變化,再生產新的DOM。

檢查一個節點是否變化,要比較節點自身及它的父子節點,所以查詢任意兩棵樹之間最少修改數的時間複雜度是O(n^3)。

React進階小冊

React比較只比較當前層(同一顏色層),將比較步驟優化到了接近O(n)。

一、建立dom

優化JSX和虛擬DOM中,createElement方法:

element.js

let utils = require('./utils')

class Element {
    constructor(tagName, attrs, key, children) {
        this.tagName = tagName;
        this.attrs = attrs;
        this.key = key;
        this.children = children || [];
    }

    render() {
        let element = document.createElement(this.tagName);
        for (let attr in this.attrs) {
            utils.setAttribute(element, attr, this.attrs[attr]);
            element.setAttribute('key', this.key)
        }
        let children = this.children || [];
        //先序深度遍歷
        children.forEach(child => {
            let childElement = (child instanceof Element) ? child.render() : document.createTextNode(child);
            element.appendChild(childElement);
        });
        return element;
    }
}
複製程式碼
引申一下(先序遍歷)
class Tree {
    constructor(v, children) {
        this.v = v
        this.children = children || null
    }
}

const tree = new Tree(10, [
    new Tree(5),
    new Tree(3, [new Tree(7), new Tree(11)]),
    new Tree(2)
])

module.exports = tree
複製程式碼
const tree = require('./1.Tree')

function tree_transverse(tree) {
    console.log(tree.v)//10 5 3 7 11 2
    tree.children && tree.children.forEach(tree_transverse)
}

tree_transverse(tree)
複製程式碼

建立原始dom dom1,插入到頁面。

let ul1 = createElement('ul', {class: 'list'}, 'A', [
    createElement('li', {class: 'list1'}, 'B', ['1']),
    createElement('li', {class: 'list2'}, 'C', ['2']),
    createElement('li', {class: 'list3'}, 'D', ['3'])
]);
let root = dom1.render();
document.body.appendChild(root);
複製程式碼

React進階小冊

建立節點變化的DOM樹 dom2,修改了dom2的父節點ul的屬性class,新增並修改了子節點的位置

let ul2 = createElement('ul', {class: 'list2'}, 'A', [
    createElement('li', {class: 'list4'}, 'E', ['6']),
    createElement('li', {class: 'list1'}, 'B', ['1']),
    createElement('li', {class: 'list3'}, 'D', ['3']),
    createElement('li', {class: 'list5'}, 'F', ['5']),
]);
複製程式碼

我們不能生硬得去直接銷燬dom1,新建dom2。而是應該比較新舊兩個dom,在原始dom上增刪改。

let patches = diff(dom1, dom2,root)
複製程式碼
  1. 首先對兩個節點進行文字節點比較
function diff(oldTree, newTree, root) {
    let patches = {};
    let index = 0;
    walk(oldTree, newTree, index, patches, root);
    return patches;
}
function walk(oldNode, newNode, index, patches, root) {
    let currentPatch = [];
    if (utils.isString(oldNode) && utils.isString(newNode)) {
        if (oldNode != newNode) {
            currentPatch.push({type: utils.TEXT, content: newNode});
        }
    } 
}    
複製程式碼

如果文字不同,我們打補丁,記錄修改的型別和文字內容

  1. 標籤比較:如果標籤一致,進行屬性比較。不一致說明節點被替換,記錄替換補丁
    ··
    else if (oldNode.tagName == newNode.tagName) {
        let attrsPatch = diffAttrs(oldNode, newNode);
        if (Object.keys(attrsPatch).length > 0) {
            currentPatch.push({type: utils.ATTRS, node: attrsPatch});
        }
    } else {
        currentPatch.push({type: utils.REPLACE, node: newNode});
    }
    ···
複製程式碼
  1. 根據補丁,修改原始dom節點
let keyIndex = 0;
let utils = require('./utils');
let allPatches;//這裡就是完整的補丁包
let {Element} = require('./element')

function patch(root, patches) {
    allPatches = patches;
    walk(root);
}

function walk(node) {
    let currentPatches = allPatches[keyIndex++];
    (node.childNodes || []).forEach(child => walk(child));
    if (currentPatches) {
        doPatch(node, currentPatches);
    }
}

function doPatch(node, currentPatches) {
    currentPatches.forEach(patch=> {
        switch (patch.type) {
            case utils.ATTRS:
                for (let attr in patch.node) {
                    let value = patch.node[attr];
                    if (value) {
                        utils.setAttribute(node, attr, value);
                    } else {
                        node.removeAttribute(attr);
                    }
                }
                break;
            case utils.TEXT:
                node.textContent = patch.content;
                break;
            case utils.REPLACE:
                let newNode = (patch.node instanceof Element) ? patch.node.render() : document.createTextNode(patch.node);
                node.parentNode.replaceChild(newNode, node);
                break;
            case utils.REMOVE:
                node.parentNode.removeChild(node);
                break;
        }
    })
}

module.exports = patch
複製程式碼

進行到這裡,我們已經完成了父節點的修補。

React進階小冊

對於ul的子節點,我們可以使用同樣的方法進行迭代一次。但是我們推薦用子節點的key來更快速得去判斷是否刪除、新增、順序變換。

React進階小冊

在oldTree中,有三個子元素 B、C、D 在newTree中,有四個子元素 E、B、C、D

  1. 在oldTree中去除newTree中沒有的元素
function childDiff(oldChildren, newChildren) {
    let patches = []
    let newKeys = newChildren.map(item=>item.key)
    let oldIndex = 0;
    while (oldIndex < oldChildren.length) {
        let oldKey = oldChildren[oldIndex].key;//A
        if (!newKeys.includes(oldKey)) {
            remove(oldIndex);
            oldChildren.splice(oldIndex, 1);
        } else {
            oldIndex++;
        }
    }
}    

//標記去除的index
function remove(index) {
    patches.push({type: utils.REMOVE, index})
}
複製程式碼
  1. 接下來將newTree陣列合併到oldTree中,我的口訣是:新向舊合併,相等舊位移,記錄新位標(O(∩_∩)O哈哈哈~)
function childDiff(oldChildren, newChildren) {
    ...
    
    oldIndex = 0;
    newIndex = 0;

    while (newIndex < newChildren.length) {
        let newKey = (newChildren[newIndex] || {}).key;
        let oldKey = (oldChildren[oldIndex] || {}).key;
        if (!oldKey) {
            insert(newIndex,newChildren[newIndex]);
            newIndex++;
        } else if (oldKey != newKey) {
            let nextOldKey = (oldChildren[oldIndex + 1] || {}).key;
            if (nextOldKey == newKey) {
                remove(newIndex);
                oldChildren.splice(oldIndex, 1);
            } else {
                insert(newIndex, newChildren[newIndex]);
                newIndex++;
            }
        } else {
            oldIndex++;
            newIndex++;
        }
    }
    
    function remove(index) {
        patches.push({type: utils.REMOVE, index})
    }
    ...
複製程式碼
  1. 刪除多餘節點
    while (oldIndex++ < oldChildren.length) {
        remove(newIndex)
    }
複製程式碼
  1. 根據補丁修改節點
function childPatch(root, patches = []) {

    let nodeMap = {};

    (Array.from(root.childNodes)).forEach(node => {
        nodeMap[node.getAttribute('key')] = node
    });

    patches.forEach(path=> {
        let oldNode
        switch (path.type) {
            case utils.INSERT:
                let newNode = nodeMap[path.node.key] || path.node.render()
                oldNode = root.childNodes[path.index]
                if (oldNode) {
                    root.insertBefore(newNode, oldNode)
                } else {
                    root.appendChild(newNode)
                }
                break;
            case utils.REMOVE:
                oldNode = root.childNodes[path.index]
                if (oldNode) {
                    root.removeChild(oldNode)
                }
                break;
            default:
                throw new Error('沒有這種補丁型別')
        }
    })
}

複製程式碼

記錄補丁修改節點結果:

React進階小冊

詳細的dom-diff地址

高階元件

(未完待續~~)

相關文章