React基礎與原理實現

JS_Even_JS 發表於 2020-11-22
React

一、前言

React是用於構建使用者介面的 JavaScript。其有著許多優秀的特性,使其受到大眾的歡迎。
① 宣告式渲染:
所謂宣告式,就是關注結果而不是關注過程。比如我們常用的html標記語言就是一種宣告式的,我們只需要在.html檔案上,寫上宣告式的標記如<h1>這是一個標題</h1>,瀏覽器就能自動幫我們渲染出一個標題元素。同樣react中也支援jsx的語法,可以在js中直接寫html,由於其對DOM操作進行了封裝,react會自動幫我們渲染出對應的結果。

② 元件化:
元件是react的核心,一個完整的react應用是由若干個元件搭建起來的,每個元件有自己的資料和方法,元件具體如何劃分,需要根據不同的專案來確定,而元件的特徵是可複用,可維護性高。

③ 單向資料流:
子元件對於父元件傳遞過來的資料是只讀的子元件不可直接修改父元件中的資料,只能通過呼叫父元件傳遞過來的方法,來間接修改父元件的資料,形成了單向清晰的資料流。防止了當一個父元件的變數被傳遞到多個子元件中時,一旦該變數被修改,所有傳遞到子元件的變數都會被修改的問題,這樣出現bug除錯會比較困難,因為不清楚到底是哪個子元件改的,把對父元件的bug除錯控制在父元件之中。


之後的內容,我們將一步步瞭解React相關知識,並且簡單實現一個react。

二、jsx

剛接觸react的時候,首先要了解的就是jsx語法,jsx其實是一種語法糖,是js的一種擴充套件語法它可以讓你在js中直接書寫html程式碼片段,並且react推薦我們使用jsx來描述我們的介面,例如下面一段程式碼:

// 直接在js中,將一段html程式碼賦值給js中的一個變數

const element =  <h1>Hello, react!</h1\>;

在普通js中,執行這樣一段程式碼,會提示Uncaught SyntaxError: Unexpected token '<',也就是不符合js的語法規則。那麼為什麼react能夠支援這樣的語法呢?
因為react程式碼在打包編譯的過程中,會通過babel進行轉化,會對jsx中的html片段進行解析,解析出來標籤名屬性集子元素,並且作為引數傳遞到React提供的createElement方法中執行。如上面程式碼的轉換結果為:

// babel編譯轉換結果

const element = React.createElement("h1", null, "Hello, react!");

可以看到,babel轉換的時候,識別到這是一個h1標籤,並且標籤上沒有任何屬性,所以屬性集為null,其有一個子元素,純文字"Hello, react!",所以經過babel的這麼一個騷操作,React就可以支援jsx語法了。因為這個轉換過程是由babel完成的,所以我們也可以通過安裝babel的jsx轉換包,從而讓我們自己的專案程式碼也可以支援jsx語法。

三、讓我們的專案支援jsx語法

因為我們要實現一個簡單的react,由於我們使用react程式設計的時候是可以使用jsx語法的,所以我們首先要讓我們的專案支援jsx語法。
① 新建一個名為my-react的專案
在專案根目錄下新建一個src目錄,裡面存放一個index.js作為專案的入口檔案,以及一個public目錄,裡面存放一個index.html檔案,作為單頁面專案的入口html頁面,如:

cd /path/to/my-react // 進入到專案根目錄下

npm init --yes // 自動生成專案的package.json檔案
// project_root/src/index.js 內容
const element = <h1>hello my-react</h1>;
// project_root/public/index.html 內容
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>my-react</title>
</head>
<body>
    <div id="root"></div>
    <script src="../src/index.js"></script>
</body>
</html>

② 安裝 parcel-bundler 模組
parcel-bundler是一個打包工具,它速度非常快,並且可以零配置,相對webpack而言,不需要進行復雜的配置即可實現web應用的打包,並且可以以任何型別的檔案作為打包入口,同時自動啟動內建的web伺服器方便除錯。

// 安裝parcel-bundler
npm install parcel-bundler --save-dev
// 修改package.json,執行parcel-bundler命令並傳遞入口檔案路徑作為引數
{
    "scripts": {
        "start": "parcel -p 8080 ./public/index.html"
    }
}
// 啟動專案
npm run start

parcel啟動的時候會在8080埠上啟動Web伺服器,並且以public目錄下的index.html檔案作為入口檔案進行打包,因為index.html檔案中有一行<script src="../src/index.js"></script>,所以index.html依賴src目錄下的index.js,所以又會編譯src目錄下的index.js並打包。
此時執行npm run start會報錯,因為此時還不支援jsx語法的編譯。

③ 安裝@babel/plugin-transform-react-jsx模組
安裝好@babel/plugin-transform-react-jsx模組後,還需要新建一個.babelrc檔案,配置如下:

// .babelrc
{
    "plugins": [
        ["@babel/plugin-transform-react-jsx", {
          "pragma": "React.createElement" // default pragma is React.createElement
        }]
    ]
}

其作用就是,遇到jsx語法的時候,將解析後的結果傳遞給React.createElement()方法,預設是React.createElement,可以自定義。此時編譯就可以通過了,可以檢視編譯後的結果,如下:

var element = React.createElement("h1", null, "hello my-react");

四、實現React.createElement

此時專案雖然能編譯jsx了,但是執行的時候會報錯,因為還沒有引入React以及其createElement()方法,React的createElement()方法作用就是建立虛擬DOM,虛擬DOM其實就是一個普通的JavaScript物件,裡面包含了tagattrschildren等屬性。
① 在src目錄下新建一個react目錄
在react目錄下新建一個index.js作為模組的預設匯出,裡面主要就是createElement方法的實現,babel解析jsx後,如果有多個子節點,那麼所有的子節點都會以引數的形式傳入createElement函式中,所以createElement的第三個引數可以用es6剩餘引數語法,以一個陣列的方式來接收所有的子節點,如:

// src/react/index.js
// 作用就是接收babel解析jsx後的結果作為引數,建立並返回虛擬DOM節點物件
function createElement(tag, attrs, ...children) {
    attrs = attrs || {}; // 如果元素的屬性為null,即元素上沒有任何屬性,則設定為一個{}空的物件
    const key = attrs.key || null; // 如果元素上有key,則去除key,如果沒有則設定為null
    if (key) {
        delete attrs.key; // 如果傳了key,則將key屬性從attrs屬性物件中移除
    }
    return { // 建立一個普通JavaScript物件,並將各屬性新增上去,作為虛擬DOM進行返回
        tag,
        key,
        attrs,
        children
    }
}
export default {
    createElement // 將createElement函式作為react的方法匯出
}

至此,react上已經新增了createElement函式了,然後在src/index.js中引入react模組即可。

// src/index.js
import React from "./react"; // 引入react模組
const element = <h1>hello my-react</h1>;
console.log(element);

引入react後,由於React上有了createElement方法,所以可以正常執行,並且拿到返回的虛擬DOM節點,如下:
虛擬DOM節點.png

五、實現ReactDOM.render

此時,我們已經能夠拿到對應的虛擬DOM節點了,由於虛擬DOM只是一個普通的JavaScript物件,不是真正的DOM,所以需要對虛擬DOM進行render,建立對應的真實DOM並新增到頁面中,才能在頁面中看到,react中專門提供了一個ReactDOM模組用於處理DOM相關的操作
① 在src目錄下新建一個react-dom目錄
在react-dom目錄下新建一個index.js作為模組的預設匯出,並在其中建立一個render方法並對外暴露,render函式需要接收一個虛擬DOM節點一個掛載點,即將虛擬DOM渲染成了真實DOM後,需要將其掛載到哪裡,這個掛載點就是一個容器,即應用的根節點。

// src/react-dom/index.js
// 負責將虛擬DOM渲染到容器之下
function render(vnode, container) {
    if (container) {
        container.appendChild(_render(vnode)); // 虛擬DOM渲染成真實DOM後將其加入到容器之下
    }
}
// 負責將虛擬DOM轉換為真實DOM
function _render(vnode) {

}
export default {
    render
}

render函式主要就是將傳入的虛擬DOM渲染成真實的DOM之後,再將其加入到容器內。這點和Vue是不同的,Vue是將根元件渲染成真實DOM後,再替換掉容器節點

接下來就是實現_render()函式,主要就是對傳入的虛擬DOM型別進行判斷並進行相應的處理,建立出對應的DOM節點

function _render(vnode) {
    if (typeof vnode === "undefined" || vnode === null || typeof vnode === "boolean") {
        vnode = ""; // 如果傳入的虛擬DOM是undefined、null、true、false,則直接轉換為空字串
    }
    if (typeof vnode === "number") {
        vnode = String(vnode); // 如果傳入的虛擬DOM是數字,那麼將其轉換為字串形式
    }
    if (typeof vnode === "string") { // 如果傳入的虛擬DOM是字串,則直接建立一個文字節點即可
        return document.createTextNode(vnode);
    }
    const {tag, attrs, children} = vnode;
    const dom = document.createElement(tag);
    if (attrs) {
        Object.keys(attrs).forEach((key) => { // 遍歷屬性
            const value = attrs[key];
            setAttribute(dom, key, value); // 設定屬性
        });
    }
    if (children) {
        children.forEach((child) => {
            render(child, dom); // 遞迴渲染子節點
        });
    }
    return dom;
}

接下來實現setAttribute()方法,主要就是給DOM元素設定屬性、樣式、事件等。

function setAttribute(dom, key, value) {
    if (key === "className") {
        key = "class";
    }
    if (/on\w+/.test(key)) {
        key = key.toLowerCase();
        dom[key] = value || "";
    } else if(key === "style") {
        if (!value || typeof value === "string") {
            dom.style.cssText = value || "";
        } else if (value && typeof value === "object") {
            for (let key in value) {
                if (typeof value[key] === "number") {
                    dom.style[key] = value[key] + "px";
                } else {
                    dom.style[key] = value[key];
                }
            }
        }
    } else {
        if (key in dom) { // 如果是dom的原生屬性,直接賦值
            dom[key] = value || "";
        }
        if (value) {
            dom.setAttribute(key, value);
        } else {
            dom.removeAttribute(key);
        }
    }
}

測試是否可以渲染一段JSX。

// src/index.js
import React from "./react"; // 引入react模組
import ReactDOM from "./react-dom"; // 引入react-dom模組
function doClick() {
    console.log("doClick method run.");
}
const element = <h1 onClick={doClick}>hello my-react</h1>;
console.log(element);
ReactDOM.render(element, document.getElementById("root"));

這裡繫結了一個onClick事件,此時啟動專案執行,可以看到頁面上已經能看到渲染後的結果了,並且點選文字,可以看到事件處理函式執行了。
render結果.png

六、實現元件功能

此時已經完成了基本的宣告式渲染功能了,但是目前只能渲染html中存在的標籤元素,而我們的react是支援自定義元件的,可以讓其渲染出我們自定義的標籤元素。react中的元件支援函式元件類元件函式元件的效能比類元件的效能要高,因為類元件使用的時候要例項化,而函式元件直接執行函式取返回結果即可。為了提高效能,儘量使用函式元件。但是函式元件沒有this沒有生命週期沒有自己的state狀態

① 首先實現函式元件功能
函式元件相對較簡單,我們先看一下怎麼使用函式元件,就是直接定義一個函式,然後其返回一段jsx,然後將函式名作為自定義元件名,像html標籤元素一樣使用即可,如:

// src/index.js
import React from "./react"; // 引入react模組
import ReactDOM from "./react-dom"; // 引入react-dom模組
function App(props) {
    return <h1>hello my-{props.name}-function</h1>
}
console.log(<App name="react"/>);
ReactDOM.render(<App name="react"/>, document.getElementById("root"));

<App name="react"/>經過babel轉換之後,tag就變成了App函式,所以我們不能直接通過document.createElement("App")去建立App元素了,我們需要執行App()函式拿到其返回值<h1>hello my-{props.name}</h1>,而這個返回值是一段jsx,所以會被babel轉換為一個虛擬DOM節點物件,我們只需要執行該函式就能拿到該函式元件對應的虛擬DOM節點了,然後將函式元件對應的虛擬DOM轉換為真實DOM並加入到其父節點之下即可,如:

// 修改src/react-dom/index.js
function _render(vnode) {
    ......
    if (typeof tag === "function") { // 如果是函式元件
        const vnode = tag(attrs); // 執行函式並拿到對應的虛擬DOM節點
        return _render(vnode); // 將虛擬DOM節點渲染為真實的DOM節點,並加入到其父節點下
    }
    ......
}

函式元件渲染如下:
函式元件.png

②支援類元件
在定義類元件的時候,是通過繼承React.Component類的,所以我們需要建立一個元件基類即Component,在src/react目錄下新建一個component.js檔案,如下:

// src/react/component.js
class Component {
    constructor(props = {}) {
        this.props = props; // 儲存props屬性集
        this.state = {}; // 儲存狀態資料
    }
}
export default Component;

我們在看一下類元件的使用方式,如下:

// src/index.js
class App extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            count: 0
        }
    }
    render() {
        return <h1>hell my-{this.props.name}-class-state-{this.state.count}</h1>
    }
}
console.log(<App name="react"/>);
ReactDOM.render(<App name="react"/>, document.getElementById("root"));

<App name="react"/>元件經過babel轉換後,tag變成了一個class函式,如果class類函式的原型上有render()方法,那麼就是一個類元件,我們可以通過類元件的類名建立出對應的類元件物件,然後呼叫其render()函式拿到對應的虛擬DOM節點即可。

// 修改src/react-dom/index.js
function _render(vnode) {
    ......
    if (tag.prototype && tag.prototype.render) { // 如果是類元件
        const comp = new tag(attrs); // 通過類建立出對應的元件例項物件
        setComponentProps(comp, attrs); // 設定元件例項的屬性
        return comp.base; // 返回類元件渲染後掛在元件例項上的真實DOM
    } else if (typeof tag === "function") {
        const vnode = tag(attrs); // 執行函式並拿到對應的虛擬DOM節點
        return _render(vnode); // 將虛擬DOM節點渲染為真實的DOM節點,並加入到其父節點下
    }
    ......
}

實現setComponentProps(),主要就是設定元件的屬性開始啟動元件的渲染

// 給元件設定屬性,並開始渲染元件
function setComponentProps(comp, attrs) {
    comp.props = attrs;
    renderComponent(comp); // 啟動元件的渲染
}

實現renderComponent(),主要就是執行元件例項的render()方法拿到對應的虛擬DOM,然後將虛擬DOM渲染為真實DOM並掛在元件例項上,如:

// 渲染元件,根據元件的虛擬DOM渲染成真實DOM
export function renderComponent(comp) {
    const vnode = comp.render(); // 執行元件的render()函式拿到對應的虛擬DOM
    const base = _render(vnode); // 將元件對應的虛擬DOM渲染成真實DOM
    comp.base = base; // 將元件對應的真實DOM掛在元件例項上
}

類元件渲染結果:
類元件.png

七、讓類元件支援setState

react中setState是Component中的一個方法,用於修改元件的狀態資料的。當元件中呼叫setState函式的時候,元件的狀態資料被更新,同時會觸發元件的重新渲染,所以需要修改Component.js並在其中新增一個setState函式。如:

// src/react/component.js
import {renderComponent} from "../react-dom/index";
class Component {
    constructor(props = {}) {
        this._container = null; // 儲存元件所在容器
    }
    setState(stateChange) {
        Object.assign(this.state, stateChange); // 更新狀態資料
        renderComponent(this); // 重新渲染元件
    }
}

此時元件呼叫setState之後會改變元件的狀態,然後呼叫renderComponent()方法進行元件的重新渲染,但是此時元件並沒有重新渲染,因為目前renderComponent()方法只是負責執行元件例項的render()方法拿到對應的虛擬DOM然後將其渲染為真實DOM,此時只是建立出了真實DOM並沒有掛載到DOM樹中,所以我們需要判斷當前元件是否已經渲染過,如果是渲染過了,那麼我們可以通過之前渲染的真實DOM找到其父節點,然後用最新的DOM替換掉之前舊的DOM即可。

// 修改renderComponent
export function renderComponent(comp) {
    const vnode = comp.render(); // 執行元件的render()函式拿到對應的虛擬DOM
    const base = _render(vnode); // 將元件對應的虛擬DOM渲染成真實DOM
    if (comp.base) { // base存在表示已經渲染過了
        // 找到上一次渲染結果的父節點,並用最新渲染的DOM替換掉之前舊的真實DOM
        comp.base.parentNode.replaceChild(base, comp.base);
    }
    comp.base = base; // 將元件對應的真實DOM掛在元件例項上
}

測試類元件渲染:

// src/index.js上測試
class App extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            count: 0
        }
    }
    doClick() {
        this.setState({
            count: 1
        });
    }
    render() {
        return <h1 onClick={this.doClick.bind(this)}>hell my-{this.props.name}-class-state-{this.state.count}</h1>
    }
}
console.log(<App name="react"/>);
ReactDOM.render(<App name="react"/>, document.getElementById("root"));

八、支援生命週期

在啟動渲染前主要有componentWillMountcomponentWillReceiveProps兩個生命週期,如果啟動渲染前,元件還沒有建立出來,那麼就會執行componentWillMount,如果元件已經建立,那麼就會執行componentWillReceiveProps

function setComponentProps(comp, attrs) {
    if (!comp.base) { // 如果啟動渲染前,元件沒有對應的真實DOM,則表示首次渲染,執行componentWillMount
        if (comp.componentWillMount) {
            comp.componentWillMount();
        }
    } else if(comp.componentWillReceiveProps) { // 如果啟動渲染前,元件有對應的真實DOM,則表示非首次渲染,則執行componentWillReceiveProps
        comp.componentWillReceiveProps();
    }
    comp.props = attrs;
    renderComponent(comp); // 啟動元件的渲染
}

啟動渲染之後主要有componentDidMountcomponentWillUpdatecomponentDidUpdate三個生命週期。啟動渲染之後,如果元件還沒有建立出來,那麼執行componentDidMount,如果元件已經建立,那麼執行componentWillUpdatecomponentDidUpdate

export function renderComponent(comp) {
    const vnode = comp.render(); // 執行元件的render()函式拿到對應的虛擬DOM
    if (comp.base && comp.componentWillUpdate) { // 如果元件已經渲染過,則執行componentWillUpdate
        comp.componentWillUpdate();
    }
    const base = _render(vnode); // 將元件對應的虛擬DOM渲染成真實DOM
    if (comp.base) { // base存在表示已經渲染過了
        // 找到上一次渲染結果的父節點,並用最新渲染的DOM替換掉之前舊的真實DOM
        comp.base.parentNode.replaceChild(base, comp.base);
        if (comp.componentDidUpdate) { // 將新的DOM替換舊的DOM後,如果元件存在真實DOM則執行componentDidUpdate
            comp.componentDidUpdate();
        }
    } else { // 如果元件還沒有渲染過,則執行componentDidMount
        if (comp.componentDidMount) {
            comp.componentDidMount();
        }
    }
    comp.base = base; // 將元件對應的真實DOM掛在元件例項上
}

九、優化setState

此時如果我們在元件渲染完成後執行如下程式碼,我們可以發現,會執行10次setState操作,同時元件也會被連續更新10次,這樣非常損耗效能,其實沒有必要更新10次,我們只需要更新10次狀態,然後用最後的狀態更新一次元件即可。我們可以在執行setState的時候不立即更新元件,而是將狀態和元件進行快取起來等所有狀態都更新完畢之後再一次性更新元件

for (let i = 0; i < 10; i++) {
    this.setState({ // 這裡只是一個入棧的操作,元件的狀態還沒有發生變化
        num: this.state.num + 1
    });
    console.log(this.state.num); // 元件狀態還沒有變化,所以仍然為0
}

在react模組下新建一個set_state_queue.js,其對外暴露一個enqueueSetState()函式,負責狀態和元件的入棧,如果當前狀態棧為空,則開啟一個微任務等元件狀態和元件都入棧完畢之後再開啟一次性更新操作。

// react/set_state_queue.js
import {renderComponent} from "../react-dom/index";
let stateQueue = []; // 狀態棧
let renderQueue = []; // 元件棧

function defer(fn) {
    return Promise.resolve().then(fn)
}
export function enqueueSetState(stateChange, component) {
    if (stateQueue.length === 0) { // 如果狀態棧為空,則開啟一個微任務,等狀態入棧完畢之後再開啟元件的一次性更新
        defer(flush);
    }
    stateQueue.push({ // 將狀態資料入棧
        stateChange,
        component
    });
    const hasComponent = renderQueue.some((item) => { // 判斷元件棧中是否已經入棧過該元件
        return item ===  component;
    });
    if (!hasComponent) { // 如果該元件沒有入棧過,則入元件棧
        renderQueue.push(component);
    }
}

實現flush,主要就是先遍歷狀態棧,在真實的React中,stateChange可以是物件,也可以是函式,是函式的時候會傳入上一次的狀態元件的props,然後返回一個新的狀態,再與元件的狀態進行合併。由於stateChange為物件的時候,拿不到之前的狀態,所以不管合併多少次都相當於只合並了一次,stateChange為函式的時候,可以拿到之前的狀態,所以合併多次,最終狀態也會變化多次。接著遍歷元件棧重新渲染該元件一次即可。

// react/set_state_queue.js
function flush() {
    stateQueue.forEach((item) => {
        const {stateChange, component} = item;
        if (!component.prevState) { // 初始化prevState
            component.prevState = component.state;
        }
        // 合併狀態,每次遍歷都會更新元件的state
        if (typeof stateChange === "function") {
            Object.assign(component.state, stateChange(component.prevState, component.props));
        } else { // stateChange為物件的時候,因為呼叫setState的時候,元件狀態還沒有變化,所以每次遍歷stateChange都是一樣的,此時不管執行多少次,相當於執行了一次
            Object.assign(component.state, stateChange);
        }
        // 將最新的狀態儲存為prevState,以便在stateChange為函式的時候能夠拿到最新的狀態
        component.prevState = component.state;
    });
    stateQueue = []; // 清空狀態棧
    // 遍歷元件棧
    renderQueue.forEach((component) => {
        renderComponent(component);
    });
    renderQueue = []; // 清空元件棧
}

十、總結

至此,已經基本實現react的基本功能,包括宣告式渲染元件支援setSate生命週期。其過程為,首先通過babel將jsx語法進行編譯轉換,babel會將jsx語法解析為三部分,標籤名、屬性集、子節點,然後用React.createElement()函式進行包裹,react實現createElement函式,用於建立虛擬DOM節點,然後呼叫render()函式對虛擬DOM節點進行分析,並建立對應的真實DOM,然後掛載到頁面中。然後提供自定義元件的支援,自定義元件,無非就是將jsx定義到了函式和類中,如果是函式,那麼就直接執行就可返回對應的jsx,也即拿到了對應的虛擬DOM,如果是類,那麼就建立元件類例項,然後呼叫其render()函式,那麼也可以拿到對應的jsx,也即拿到了對應的虛擬DOM,然後掛載到頁面中。類元件中新增setSate函式,用於更新元件例項上的資料,然後setState函式會觸發元件的重新渲染,從而更新渲染出帶最新資料的元件。