從零開始實現React(一):JSX和虛擬DOM

莫夭發表於2018-03-20

前言

React是前端最受歡迎的框架之一,解讀其原始碼的文章非常多,但是我想從另一個角度去解讀React:從零開始實現一個React,從API層面實現React的大部分功能,在這個過程中去探索為什麼有虛擬DOM、diff、為什麼setState這樣設計等問題。

提起React,總是免不了和Vue做一番對比

Vue的API設計非常簡潔,但是其實現方式卻讓人感覺是“魔法”,開發者雖然能馬上上手,但是為什麼能實現功能卻很難說清楚。

相比之下React的設計哲學非常簡單,雖然經常有需要自己處理各種細節問題,但是卻讓人感覺它非常“真實”,能清楚地感覺到自己仍然是在寫js。

關於jsx

在開始之前,我們有必要搞清楚一些概念。

我們來看一下這樣一段程式碼:

const title = <h1 className="title">Hello, world!</h1>;
複製程式碼

這段程式碼並不是合法的js程式碼,它是一種被稱為jsx的語法擴充套件,通過它我們就可以很方便的在js程式碼中書寫html片段。

本質上,jsx是語法糖,上面這段程式碼會被babel轉換成如下程式碼

const title = React.createElement(
    'h1',
    { className: 'title' },
    'Hello, world!'
);
複製程式碼

你可以在babel官網提供的線上轉譯測試jsx轉換後的程式碼,這裡有一個稍微複雜一點的例子

準備工作

為了集中精力編寫邏輯,在程式碼打包工具上選擇了最近火熱的零配置打包工具parcel,需要先安裝parcel:

npm install -g parcel-bundler
複製程式碼

接下來新建index.jsindex.html,在index.html中引入index.js

當然,有一個更簡單的方法,你可以直接下載這個倉庫的程式碼:

https://github.com/hujiulong/simple-react/tree/chapter-1

注意一下babel的配置 .babelrc

{
    "presets": ["env"],
    "plugins": [
        ["transform-react-jsx", {
            "pragma": "React.createElement"
        }]
    ]
}
複製程式碼

這個transform-react-jsx就是將jsx轉換成js的babel外掛,它有一個pragma項,可以定義jsx轉換方法的名稱,你也可以將它改成h(這是很多類React框架使用的名稱)或別的。

準備工作完成後,我們可以用命令parcel index.html將它跑起來了,當然,現在它還什麼都沒有。

React.createElement和虛擬DOM

前文提到,jsx片段會被轉譯成用React.createElement方法包裹的程式碼。所以第一步,我們來實現這個React.createElement方法

從jsx轉譯結果來看,createElement方法的引數是這樣:

createElement( tag, attrs, child1, child2, child3 );
複製程式碼

第一個引數是DOM節點的標籤名,它的值可能是divh1span等等 第二個引數是一個物件,裡面包含了所有的屬性,可能包含了classNameid等等 從第三個引數開始,就是它的子節點

我們對createElement的實現非常簡單,只需要返回一個物件來儲存它的資訊就行了。

function createElement( tag, attrs, ...children ) {
    return {
        tag,
        attrs,
        children
    }
}
複製程式碼

函式的引數...children使用了ES6的rest引數,它的作用是將後面child1,child2等引數合併成一個陣列children。

現在我們來試試呼叫它

// 將上文定義的createElement方法放到物件React中
const React = {
    createElement
}

const element = (
    <div>
        hello<span>world!</span>
    </div>
);
console.log( element );
複製程式碼

開啟除錯工具,我們可以看到輸出的物件和我們預想的一致

1

我們的createElement方法返回的物件記錄了這個DOM節點所有的資訊,換言之,通過它我們就可以生成真正的DOM,這個記錄資訊的物件我們稱之為虛擬DOM

ReactDOM.render

接下來是ReactDOM.render方法,我們再來看這段程式碼

ReactDOM.render(
    <h1>Hello, world!</h1>,
    document.getElementById('root')
);
複製程式碼

經過轉換,這段程式碼變成了這樣

ReactDOM.render(
    React.createElement( 'h1', null, 'Hello, world!' ),
    document.getElementById('root')
);
複製程式碼

所以render的第一個引數實際上接受的是createElement返回的物件,也就是虛擬DOM 而第二個引數則是掛載的目標DOM

總而言之,render方法的作用就是將虛擬DOM渲染成真實的DOM,下面是它的實現:

function render( vnode, container ) {
    
    // 當vnode為字串時,渲染結果是一段文字
    if ( typeof vnode === 'string' ) {
        const textNode = document.createTextNode( vnode );
        return container.appendChild( textNode );
    }

    const dom = document.createElement( vnode.tag );

    if ( vnode.attrs ) {
        Object.keys( vnode.attrs ).forEach( key => {
            if ( key === 'className' ) key = 'class';            // 當屬性名為className時,改回class
            dom.setAttribute( key, vnode.attrs[ key ] )
        } );
    }

    vnode.children.forEach( child => render( child, dom ) );    // 遞迴渲染子節點

    return container.appendChild( dom );    // 將渲染結果掛載到真正的DOM上
}
複製程式碼

這裡注意React為了避免類名class和js關鍵字class衝突,將類名改成了className,在渲染成真實DOM時,需要將其改回。

這裡其實還有個小問題:當多次呼叫render函式時,不會清除原來的內容。所以我們將其附加到ReactDOM物件上時,先清除一下掛載目標DOM的內容:

const ReactDOM = {
    render: ( vnode, container ) => {
        container.innerHTML = '';
        return render( vnode, container );
    }
}
複製程式碼

渲染和更新

到這裡我們已經實現了React最為基礎的功能,可以用它來做一些事了。

我們先在index.html中新增一個根節點

<div id="root"></div>
複製程式碼

我們先來試試官方文件中的Hello,World

ReactDOM.render(
    <h1>Hello, world!</h1>,
    document.getElementById('root')
);
複製程式碼

可以看到結果:

2

試試渲染一段動態的程式碼,這個例子也來自官方文件

function tick() {
    const element = (
        <div>
            <h1>Hello, world!</h1>
            <h2>It is {new Date().toLocaleTimeString()}.</h2>
        </div>
      );
    ReactDOM.render(
        element,
        document.getElementById( 'root' )
    );
}

setInterval( tick, 1000 );
複製程式碼

可以看到結果:

2

後話

這篇文章中,我們實現了React非常基礎的功能,也瞭解了jsx和虛擬DOM,下一篇文章我們將實現非常重要的元件功能。

最後留下一個小問題 在定義React元件或者書寫React相關程式碼,不管程式碼中有沒有用到React這個物件,我們都必須將其import進來,這是為什麼?

例如:

import React from 'react';    // 下面的程式碼沒有用到React物件,為什麼也要將其import進來
import ReactDOM from 'react-dom';

ReactDOM.render( <App />, document.getElementById( 'editor' ) );
複製程式碼

不知道答案的同學再仔細看看這篇文章哦

從零開始實現React系列

React是前端最受歡迎的框架之一,解讀其原始碼的文章非常多,但是我想從另一個角度去解讀React:從零開始實現一個React,從API層面實現React的大部分功能,在這個過程中去探索為什麼有虛擬DOM、diff、為什麼setState這樣設計等問題。

整個系列大概會有六篇左右,我每週會更新一到兩篇,我會第一時間在github上更新,有問題需要探討也請在github上回復我~

部落格地址: github.com/hujiulong/b… 關注點star,訂閱點watch

相關文章