手寫簡易前端框架:vdom 渲染和 jsx 編譯

作爲前端工程師,前端框架幾乎每天都要用到,需要好好掌握,而對某項技術的掌握程度可以根據是否能實現一個來判斷。手寫一個前端框架對更好的掌握它是很有幫助的事情。

現代前端框架經過多年的迭代都已經變得很複雜,理清它們的實現原理變得困難重重。所以我想寫一個最簡單版本的前端框架來幫助大家理清思路。

一個完整的前端框架涉及到的內容還是比較多的,我們一步步的來,這篇文章來實現下 vdom 的渲染。

vdom 的渲染

vdom 全稱 virtual dom,用來聲明式的描述頁面,現代前端框架很多都基於 vdom。前端框架負責把 vdom 轉爲對真實 dom 的增刪改,也就是 vdom 的渲染。

那麼 vdom 是什麼樣的?又是怎麼渲染的呢?

dom 主要是元素、屬性、文本,vdom 也是一樣,其中元素是 {type、props、children} 的結構,文本就是字符串、數字。

比如這樣一段 vdom:

{
    type: 'ul',
    props: {
        className: 'list'
    },
    children: [
        {
            type: 'li',
            props: {
                className: 'item',
                style: {
                    background: 'blue',
                    color: '#fff'
                },
                onClick: function() {
                    alert(1);
                }
            },
            children: [
                'aaaa'
            ]
        },
        {
            type: 'li',
            props: {
                className: 'item'
            },
            children: [
                'bbbbddd'
            ]
        },
        {
            type: 'li',
            props: {
                className: 'item'
            },
            children: [
                'cccc'
            ]
        }
    ]
}

不難看出,它描述的是一個 ul 的元素、它有三個 li 子元素,其中第一個子元素有 style 的樣式、還有 onClick 的事件。

前端框架就是通過這樣的對象結構來描述界面的,然後把它渲染到 dom。

這樣的對象結構怎麼渲染呢?

明顯要用遞歸,對不同的類型做不同的處理。

所以,vdom 的 render 邏輯就是這樣的:

if (isTextVdom(vdom)) {
    return mount(document.createTextNode(vdom));
} else if (isElementVdom(vdom)) {
    const dom = mount(document.createElement(vdom.type));
    for (const child of vdom.children) {
        render(child, dom);
    }
    for (const prop in vdom.props) {
        setAttribute(dom, prop, vdom.props[prop]);
    }
    return dom;
}

文本的判斷就是字符串和數字:

function isTextVdom(vdom) {
    return typeof vdom == 'string' || typeof vdom == 'number';
}

元素的判斷就是對象,並且 type 爲標籤名的字符串:

function isElementVdom(vdom) {
   return typeof vdom == 'object' && typeof vdom.type == 'string';
}

元素創建出來之後如果有父節點要掛載到父節點,組裝成 dom 樹:

const mount = parent ? (el => parent.appendChild(el)) : (el => el);

所以,完整的 render 函數就是這樣的:

const render = (vdom, parent = null) ={
    const mount = parent ? (el => parent.appendChild(el)) : (el => el);
    if (isTextVdom(vdom)) {
        return mount(document.createTextNode(vdom));
    } else if (isElementVdom(vdom)) {
        const dom = mount(document.createElement(vdom.type));
        for (const child of vdom.children) {
            render(child, dom);
        }
        for (const prop in vdom.props) {
            setAttribute(dom, prop, vdom.props[prop]);
        }
        return dom;
    }
};

其中,元素的 dom 還要設置屬性,比如上面 vdom 裏有 style 和 onClick 的屬性要設置。

style 屬性是樣式,支持對象,要把對象合併之後設置到 style,而 onClick 屬性是事件監聽器,用 addEventListener 設置,其餘的屬性都用 setAttribute 來設置。

const setAttribute = (dom, key, value) ={
    if (typeof value == 'function' && key.startsWith('on')) {
        const eventType = key.slice(2).toLowerCase();
        dom.addEventListener(eventType, value);
    } else if (key == 'style' && typeof value == 'object') {
        Object.assign(dom.style, value);
    } else if (typeof value != 'object' && typeof value != 'function') {
        dom.setAttribute(key, value);
    }
}

就這樣,vdom 的渲染邏輯就完成了。

用上面那段 vdom 渲染試下效果:

render(vdom, document.getElementById('root'));

vdom 的渲染成功!

小結一下:

「vdom 會遞歸的進行渲染,根據類型的不同,元素、文本會分別用 createTextNode、createElement 來遞歸創建 dom 並組裝到一起,其中元素還要設置屬性,style、事件監聽器和其他屬性分別用 addEventListener、setAttribute 等 api 進行設置。」

「通過不同的 api 創建 dom 和設置屬性,這就是 vdom 的渲染流程。」

但是,vdom 寫起來也太麻煩了,沒人會直接寫 vdom,一般是通過更友好的 DSL(領域特定語言) 來寫,然後編譯成 vdom,比如 jsx 和 template。

這裏我們使用 jsx 的方式,因爲可以直接用 babel 編譯。

jsx 編譯成 vdom

上面的 vdom 改爲 jsx 來寫就是這樣的:

const jsx = <ul class>
    <li class style={{ background: 'blue', color: 'pink' }} onClick={() => alert(2)}>aaa</li>
    <li class>bbbb</li>
    <li class>cccc</li>
</ul>

render(jsx, document.getElementById('root'));

明顯比直接寫 vdom 緊湊了不少,但是需要做一次編譯。

配置下 babel 來編譯 jsx:

module.exports = {
    presets: [
        [
            '@babel/preset-react',
            {
                pragma: 'createElement'
            }
        ]
    ]
}

編譯產物是這樣的:

const jsx = createElement("ul"{
  className: "list"
}, createElement("li"{
  className: "item",
  style: {
    background: 'blue',
    color: 'pink'
  },
  onClick: () => alert(2)
}"aaa"), createElement("li"{
  className: "item"
}"bbbb"), createElement("li"{
  className: "item"
}"cccc"));
render(jsx, document.getElementById('root'));

爲啥不直接是 vdom,而是一些函數呢?

因爲這樣會有一次執行的過程,可以放入一些動態邏輯,

比如從 data 取值:

const data = {
    item1: 'bbb',
    item2: 'ddd'
}
const jsx = <ul class>
    <li class style={{ background: 'blue', color: 'pink' }} onClick={() => alert(2)}>aaa</li>
    <li class>{data.item1}</li>
    <li class>{data.item2}</li>
</ul>

會編譯成:

const data = {
  item1: 'bbb',
  item2: 'ddd'
};
const jsx = createElement("ul"{
  className: "list"
}, createElement("li"{
  className: "item",
  style: {
    background: 'blue',
    color: 'pink'
  },
  onClick: () => alert(2)
}"aaa"), createElement("li"{
  className: "item"
}, data.item1), createElement("li"{
  className: "item"
}, data.item2));

這叫做 render function,它執行的返回值就是 vdom。

這個 render function 名字之所以是 createElement,是因爲我們上面 babel 配置裏指定了 pragma 爲 createElement。

render function 就是生成 vdom 的,所以實現很簡單:

const createElement = (type, props, ...children) ={
  return {
    type,
    props,
    children
  };
};

我們來測試下改爲 jsx 之後的渲染:

渲染成功!

我們在 vdom 的基礎上更進了一步,通過 jsx 來寫一些動態邏輯,然後編譯成 render function,執行之後產生 vdom。

這樣比直接寫 vdom 更簡單,可以做更靈活的 vdom 生成邏輯。

代碼上傳到了 github:https://github.com/QuarkGluonPlasma/frontend-framework-exercize

總結

手寫前端框架是更好的掌握它的最直接的方式,我們會逐步實現一個功能完整的前端框架。

本文我們實現了 vdom 的渲染。vdom 是描述界面的對象,它的渲染就是通過 createElement、createTextNode 等 api 來遞歸創建和組裝元素、文本等 dom 的過程,其中元素節點還需要設置屬性,style、event listener 等屬性會用不同的 api 設置。

雖然最終是 vdom 的渲染,但是開發時不會直接寫 vdom,而是通過 jsx 來描述頁面,然後編譯成 render function,執行後產生 vdom。這樣寫起來更簡潔,而且支持動態邏輯。(jsx 的編譯使用 babel,可以指定 render function 的名字)

vdom 渲染和 jsx 是前端框架的基礎,其他的功能比如組件是在這個基礎之上實現的,下篇文章我們就來實現組件的渲染。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/xwt5bd31IZChpEx79w5E5g