從Preact瞭解一個類React的框架是怎麼實現的(一): 元素建立

請叫我王磊同學發表於2017-09-11

  首先歡迎大家關注我的掘金賬號和Github部落格,也算是對我的一點鼓勵,畢竟寫東西沒法獲得變現,能堅持下去也是靠的是自己的熱情和大家的鼓勵。
  之前分享過幾篇關於React的文章:

  其實我在閱讀React原始碼的時候,真的非常痛苦。React的程式碼及其複雜、龐大,閱讀起來挑戰非常大,但是這卻又擋不住我們的React的原理的好奇。前段時間有人就安利過Preact,千行程式碼就基本實現了React的絕大部分功能,相比於React動輒幾萬行的程式碼,Preact顯得別樣的簡潔,這也就為了我們學習React開闢了另一條路。本系列文章將重點分析類似於React的這類框架是如何實現的,歡迎大家關注和討論。如有不準確的地方,歡迎大家指正。
  
  關於Preact,官網是這麼介紹的:   

Fast 3kb React alternative with the same ES6 API. Components & Virtual DOM.

  我們用Preact編寫程式碼就雷同於React,比如舉個例子:   

import { Component , h } from 'preact'
export default class TodoList extends Component {
    state = { todos: [], text: '' };
    setText = e => {
        this.setState({ text: e.target.value });
    };
    addTodo = () => {
        let { todos, text } = this.state;
        todos = todos.concat({ text });
        this.setState({ todos, text: '' });
    };
    render({ }, { todos, text }) {
        return (
            <form onSubmit={this.addTodo} action="javascript:">
                <input value={text} onInput={this.setText} />
                <button type="submit">Add</button>
                <ul>
                    { todos.map( todo => (
                        <li>{todo.text}</li>
                    )) }
                </ul>
            </form>
        );
    }
}複製程式碼

  上面就是用Preact編寫TodoList的例子,掌握React的你是不是感覺再熟悉不過了,上面的例子和React不太相同的地方是render函式有引數傳入,分別是render(props,state,context),其目的是為了你解構賦值方便,當然你仍然可以render函式中通過this來引用propsstatecontext。語法方面我們不再多做贅述,現在正式開始我們的內容。

  本人還是非常推崇React這一套機制的,React這套機制提我們完成了資料和檢視的繫結,使得開發人員只需要關注資料和資料流的改變,從而極大的降低的開發的關注度,使得我們能夠集中精力於資料本身。而且React引入了虛擬DOM(virtual-dom)的機制,從而提升渲染效能。在開始接觸React時,覺得虛擬DOM機制十分的高大上,但經過一段時間的學習,開始對虛擬DOM有了進一步的認識。虛擬DOM從本質上將就是將複雜的DOM轉化成輕量級的JavaScript物件,不同的渲染中會生成同的虛擬DOM物件,然後通過高效優化過的Diff演算法,比較前後的虛擬DOM物件,以最小的變化去更新真實DOM。

  正如上面的圖,其實類React的框架的程式碼都基本可以分為兩部分,元件到虛擬DOM的轉化、以及虛擬DOM到真實DOM的對映。當然細節性的東西還有非常多,比如生命週期、事件機制(代理)、批量重新整理等等。其實Preact精簡了React中的很多部分,比如React中採用的是事件代理機制,Preact就沒這麼做。這篇文章將著重於敘述Preact的JSX與元件相關的部分程式碼。
  
  最開始學習React的時候,以為JSX是React的所獨有的,現在其實明白了JSX語法並不是某個庫所獨有的,而是一種JavaScript函式呼叫的語法糖。我們舉個例子,假如有下面的程式碼:   

import ReactDOM from 'react-dom'

const App = (props)=>(<div>Hello World</div>)
ReactDOM.render(<APP />, document.body);複製程式碼

  請問可以執行嗎?事實上是不能只能的,瀏覽器會告訴你:

Uncaught ReferenceError: React is not defined

  如果你不瞭解JSX你就會感覺奇怪,因為沒有地方顯式地呼叫React,但是事實上上面的程式碼確實用到了React模組,奧祕就在於JSX。JSX其實相當於JavaScript + HTML(也被稱為hyperscript,即hyper + script,hyper是HyperText超文字的簡寫,而script是JavaScript的簡寫)。JSX並不屬於新的語法,其目的也只是為了在JavaScript指令碼中更方便的構建UI檢視,相比於其他的模板語言更加的易於上手,提升開發效率。上面的例項如果經過Babel轉化其實會得到下面結果:   

var App = function App(props) {
  return React.createElement(
    'div',
    null,
    'Hello World'
  );
};複製程式碼

  我們可以看到,之前的JSX語法都被轉換成函式React.createElement的呼叫方式。這就是為什麼在React中有JSX的地方都需要顯式地引入React的原因,也是為什麼說JSX只是JavaScript的語法糖。但是按照上面的說法,所有的JSX語法都會被轉化成React.createElement,那豈不是JSX只是React所獨有的?當然不是,比如下面程式碼:

/** @jsx h */
let foo = <div id="foo">Hello!</div>;複製程式碼

  我們通過為JSX新增註釋@jsx(這也被成為Pragma,即編譯註釋),可以使得Babel在轉化JSX程式碼時,將其裝換成函式h的呼叫,轉化結果成為:

/** @jsx h */
var foo = h(
  "div",
  { id: "foo" },
  "Hello!"
);複製程式碼

  當然在每個JSX上都設定Pragma是沒有必要的,我們可以在工程全域性進行配置,比如我們可以在Babel6中的.babelrc檔案中設定:

{
  "plugins": [
    ["transform-react-jsx", { "pragma":"h" }]
  ]
}複製程式碼

  這樣工程中所有用到JSX的地方都是被Babel轉化成使用h函式的呼叫。
  
  
  說了這麼多,我們開始瞭解一下Preact是怎麼構造h函式的(關於為什麼Preact將其稱為h函式,是因為作為hyperscript的縮寫去命名的),Preact對外提供兩個介面: hcreateElement,都是指向函式h:

import {VNode} from './vnode';

const stack = [];

const EMPTY_CHILDREN = [];

export function h(nodeName, attributes) {
    let children = EMPTY_CHILDREN, lastSimple, child, simple, i;
    for (i = arguments.length; i-- > 2;) {
        stack.push(arguments[i]);
    }
    if (attributes && attributes.children != null) {
        if (!stack.length) stack.push(attributes.children);
        delete attributes.children;
    }
    while (stack.length) {
        if ((child = stack.pop()) && child.pop !== undefined) {
            for (i = child.length; i--;) stack.push(child[i]);
        }
        else {
            if (typeof child === 'boolean') child = null;

            if ((simple = typeof nodeName !== 'function')) {
                if (child == null) child = '';
                else if (typeof child === 'number') child = String(child);
                else if (typeof child !== 'string') simple = false;
            }

            if (simple && lastSimple) {
                children[children.length - 1] += child;
            }
            else if (children === EMPTY_CHILDREN) {
                children = [child];
            }
            else {
                children.push(child);
            }

            lastSimple = simple;
        }
    }

    let p = new VNode();
    p.nodeName = nodeName;
    p.children = children;
    p.attributes = attributes == null ? undefined : attributes;
    p.key = attributes == null ? undefined : attributes.key;

    return p;
}複製程式碼

  函式h接受兩個引數節點名nodeName,與屬性attributes。然後將除了前兩個之外的引數都壓如棧stack。這種寫法挺令人吐槽的,寫成h(nodeName, attributes, ...children)不是一目瞭然嗎?因為h的引數是不限的,從第三個引數起的所有引數都是節點的子元素,所以棧儲存的是當前元素的子元素。然後會再排除一下第二個引數(其實就是props)中是否含有children屬性,有的話也將其壓如棧中,並且從attributes中刪除。然後迴圈遍歷棧中的每一個子元素:

  • 首先判別該元素是不是陣列型別,這裡採用的就是鴨子型別(duck type),即看起來來一隻鳥走起來像鴨子、游泳起來像鴨子、叫起來也像鴨子,那麼這隻鳥就可以被稱為鴨子,我們在這裡通過是否含有函式pop去判別是否是一個陣列,如果子元素是一個陣列,就將其全部壓入棧中。為什麼這麼做呢?因為子元素有可能是陣列,比如:
    render(){
    return(
        <ul>
            {
                [1,2,3].map((val)=><li>{val}</li>)
            }
        </ul>
    )
    }複製程式碼
  • 因為子元素是不支援布林型別的,因此將其置為: null。 如果傳入的節點不是函式的話,分別判斷如果是null,則置為空字元,如果是數字的話,將其轉化成字串型別。變數simple用來記錄節點是否是簡單型別,比如dom名稱或者函式就不屬於,如果是字串或者是數字,就會被認為是簡單型別

  • 然後程式碼

    if (simple && lastSimple) {
     children[children.length - 1] += child;
    }複製程式碼

    其實做的就是一個字串拼接,lastSimple是用來記錄上次的節點是否是簡單型別。之所以這麼做,是因為某些編譯器會將下面程式碼

    let foo = <div id="foo">Hello World!</div>;複製程式碼

    轉化為:

    var foo = h(
    "div",
    { id: "foo" },
    "Hello",
    "World!"
    );複製程式碼

    這是時候h函式就會將後兩個引數拼接成一個字串。

  • 最後將處理子節點的傳入陣列children中,現在傳入children中的節點有三種型別: 純字串、代表dom節點的字串以及代表元件的函式(或者是類)

  函式結束迴圈遍歷之後,建立了一個VNODE,並將nodeNamechildrenattributeskey都賦值到節點中。需要注意的是,VNODE只是一個普通的建構函式:   

function VNode() {}複製程式碼

說了這麼多,我們看幾個轉化之後的例子:

//jsx
let foo = <div id="foo">Hello World!</div>;  

//js
var Element = h(
  "div",
  { id: "foo" },
  "Hello World!"
);

//轉化為的元素節點
{
    nodeName: "div", 
    children: [
        "Hello World!"
    ], 
    attributes: {
        id: "foo"
    },
    key: undefined
}複製程式碼
/* jsx
class App extends Component{
//....
}

class Child extends Component{
//....
}
*/

let Element = <App><Child>Hello World!</Child></App>

//js
var Element = h(
  App,
  null,
  h(
    Child,
    null,
    "Hello World!"
  )
);

//轉化為的元素節點
{
    nodeName: ƒ App(argument), 
    children: [
        {
            nodeName: ƒ Child(argument),
            children: ["Hello World!"],
            attributes: undefined,
            key: undefined
        }
    ], 
    attributes: undefined,
    key: undefined
}複製程式碼

  上面JSX元素轉化成的JavaScript物件就是DOM在記憶體中的表現。在Preact中不同的資料會生成不同的虛擬DOM節點,通過比較前後的虛擬DOM節點,Preact會找出一種最簡單的方式去更新真實DOM,以使其匹配當前的虛擬DOM節點,當然這會在後面的系列文章講到,我們會將原始碼和概念分割成一塊塊內容,方便大家理解,這篇文章著重講述了Preact的元素建立與JSX,之後的文章會繼續圍繞Preact類似於diff、元件設計等概念展開,歡迎大家關注我的賬號獲得最新的文章動態。

相關文章