Build Your Own React:第一次渲染
背景
近幾年 React 徹底改變了前端的開發方式,在 Web Component 未能成為現實之前首先確立了前端組件化的現實標準,我們在使用 React 的同時也可以不再關心一些細節問題。但是在 React 提供便利的同時,我們也應該明白它到底為我們做了什麼,為什麼這麼做。
一些文章通過講述 React 的使用或者最佳範式試圖讓你理解 React 幹了什麼,也有一些文章是直接分析 React 的源碼。而只談使用過於空泛,讀源碼的工作量又太大,容易陷入無邊的細節。本文則試圖填補這中間的空白,我們會實現一個簡化版的 React -- React-tiny,覆蓋 React 的一些核心功能。從而讓我們身臨其境地理解 React 到底為我們解決了什麼問題,以及為什麼要這麼解決。
由於 React 本身過於複雜,筆者本身也不能非常全面的了解其具體的實現和表現,所以 React-tiny 並不會拘泥於 React 的真正實現方式,我會按照自己的理解儘可能貼近 React 的行為,如果有不一致的地方還望指正。
原型
為了快速地驗證我們的思路,也為了鼓勵讀者自己動手嘗試,我們直接在 html 文件里通過 babel-standalone 直接寫 ES6 和 JSX。
我們計劃覆蓋 React 最核心的一小塊功能,為了避免手工驗證帶來的重複工作和不可靠性,我們將使用 jasmine 來編寫自動化測試案例,它能運行在瀏覽器中進行自動化測試,並且直接在 HTML 上提供測試的結果。
目前這篇文章的代碼見:CodeFalling/react-tiny
為什麼需要單元測試
由於一些操作是每次測試時都需要的,例如創建一個容器並且渲染到頁面上,所以我們把他放在公共的函數中做。有些同學對於前端測試可能比較陌生,認為前端沒必要寫測試或者只有非常嚴肅的項目才寫測試。其實在這個例子中整體的需求非常穩定,寫單元測試除了是為了保證質量外,也是極大程度上避免了重複勞動。針對於 React-tiny 的測試就是 為了替代我們的手工驗證,例如說點擊一個按鈕然後查看其標題是否變化。
公用的函數,用於創建一個容器,並且給他加上一個 append 方法,可以直接 append 到測試容器中,並返回其第一個子節點(這往往是測試的對象)。
function createContainer(name) {const container = document.createElement("div"); container.setAttribute("id", "test-container-" + name); container.append = function () { con.appendChild(container);return container.childNodes[0]; };return container;}
下面我們就來寫第一個測試案例
describe("First-time render", () => { it("string should be rendered into span", function () {const con = createContainer("render-string"); render("test", con);const target = con.append(); expect(target.nodeName).toEqual("SPAN"); expect(target.innerText).toEqual("test"); });// 更多測試}
上面這個測試就是針對 render 函數,直接 render 一個字元串,應該在 DOM 上反應為一個 span 元素包住的字元串。如果沒有單元測試,我們就需要每次都去肉眼驗證是否為 span,內容是否為期望的字元串。而單元測試不但自動完成了這個過程,而且在我們後面的改動中仍然會保留前面的測試案例,以防止我們的改動導致之前完好的功能損壞。
JSX
React 最讓人眼前一亮的莫過於 JSX 了,在 Babel 等轉譯工具的幫助下,JSX 能夠讓 JavaScript 和 HTML 模板很自然的混合在一起。而事實上對於 JSX 的解析和轉譯等完全是由 Babel 完成的,React 本身並不需要提供什麼特別的支持。
例如
<div> <span>Test</span> Hey {name}</div>
這樣的代碼經過 Babel 的轉譯後,得到的是
React.createElement("div",null, React.createElement("span",null,"Test"), "Hey ", name);
"https://zhuanlan.zhihu.com/p/28257907/15011579204982.html">ps: 可以通過 Babel REPL Online 很方便的看到 JSX 翻譯的結果,這對於我們的開發有幫助
而 React 只需提供 React.createElement 這個函數即可,所以我們要做的就是在完成 React-tiny 的 createElement 函數後,令 React.createElement 等於它。
第一次渲染
文位元組點
了解了 JSX 是如何被構造出來的,我們就再來研究它們是怎麼被渲染的頁面上的,先從最簡單的文字開始,如上面的第一個測試案例寫的,render 一個字元串,應該渲染一個包裹著字元串的 span 元素到頁面上。
const DATA_ATTR_REACT_ID = "data-reactid";let currentRootId = 0;class VElement { constructor(type, props) {this.type = type;this.props = props; }}function instantiateInternalComponent(vElem) {if (_.isString(vElem) || _.isNumber(vElem)) {return new TextInternalComponent(vElem); }}class InternalComponent { constructor(vElem) {this._vElem = vElem; }}class TextInternalComponent extends InternalComponent { mount(rootID) {this._rootID = rootID;const result = document.createElement("span"); result.innerText = this._vElem; result.setAttribute(DATA_ATTR_REACT_ID, rootID);this._dom = result;return result; }}function h(type, attrs, ...children) {return new VElement(type, { ...attrs, children, });}function render(vElem, container) { container.appendChild(instantiateInternalComponent(vElem).mount(currentRootId++));}React = { createElement: h, render,}
如果只是為了渲染一個文位元組點其實不用這麼複雜,我們之所以將其分成多個數據結構,是為了後面能夠更加自然的實現其他類型尤其是用戶定義的 Component。
大致的流程是 babel 把 JSX 轉譯成含有 React.createElement 的 js 代碼後,createElement 返回一個 VElement 對象,以一個樹狀的數據結構保存著元素的父子關係,props 等(其實在這裡 VElement 就是 VirtualDOM Element 的縮寫)。而後 render 函數在拿到 VElement 後先實例化一個內部的 InternalComponent,然後調用其 mount 函數得到 DOM 元素並且渲染到頁面上。
之所以還需要一個內部的 InternalComponent 是因為事實上 React 組件是有生命周期的,我們需要使用 InternalComponent 來維護整個組件樹真正的狀態。當前只有 TextInternalComponent 表示文字組件,後面在如何更新的部分會了解到這個數據結構存在的意義。
另外在這裡我們的 mount 函數中使用了 document.createElement 來創建元素,事實上在 React v15 以前的版本或者伺服器端渲染中採用的是拼接字元串的方法。除此之外,在 React v15 後 React 不再在渲染文字結點的時候套一層 span,也不再在客戶端渲染中在頁面上渲染 react-id,本文出於方便仍然予以保留,歡迎大家針對這點進行改進。
列表
React 支持渲染
{list.map(item => <li>{item.name}</li>)}
這樣的 JSX,其實它返回的就是一個數組,我們特意把列表放在前面來講,因為 DOMInternalComponent 中需要一些邏輯來保證列表的正確渲染。
class ListInternalComponent extends ParentInternalComponent { mount(rootID) {this._rootID = rootID;return this._mountChildren(rootID); }}
列表元素 mount 的結果是一個 "[object HTMLElement],[object HTMLElement]" 這樣的數組,所以在 DOM 元素渲染時把它拍平。
DOM 節點
function transPropName(propName) {const table = { className: "class" };if (table[propName]) return table[propName];return propName.split("") .map(c => /A-Z/.test(c) ? `-${c.toLowerCase()}` : c) .join("");}class ParentInternalComponent extends InternalComponent { _mountChildren(rootID) {const children = _.isArray(this._vElem) ?this._vElem : _.get(this._vElem.props, "children");// mount childrenconst result = []; _.forEach(children, (child, index) => { result.push(instantiateInternalComponent(child).mount(`${rootID}.${index}`)); });return result; }}class DOMInternalComponent extends ParentInternalComponent { mount(rootID) {const { type, props } = this._vElem;const result = document.createElement(type); result.setAttribute(DATA_ATTR_REACT_ID, rootID); _.forEach(props, (value, key) => {if (key !== "children") {const keyResult = transPropName(key); result.setAttribute(keyResult, value); } });const children = this._mountChildren(rootID);// 打平 children 是為了能夠渲染列表 _.forEach(_.flatten(children), child => { result.appendChild(child); });this._dom = result;return result; }}
DOM 元素的渲染只需要遞歸渲染子元素即可,同時把 props 渲染到 DOM 上,transPropName 將 JSX 中的 props 和 DOM 的 attributes 做一個映射。
_.flatten 可以很方便地把列表返回的 DOM 數組打平然後直接 append 到元素上。
自定義 Component
組件化是 React 的核心,用戶通過
class Custom React extends React.Component{ render() {return <div>Test</div> }}
可以書寫自己的組件,這裡的 Component 其實並非 InternalComponent,只是提供給用戶一個好看的介面。
class Component {// _internalComponent: InternalComponent constructor(props) {this.props = props; }}class CompositeInternalComponent extends InternalComponent { mount(rootID) {this._rootID = rootID;const componentClass = this._vElem.type;const props = this._vElem.props;const componentIns = new componentClass(props); componentIns.componentWillMount && ins.componentWillMount();// 獲取 render 結果let renderedVElem = componentIns.render();const renderedInternalComponent = instantiateInternalComponent(renderedVElem);const result = renderedInternalComponent.mount(rootID); componentIns.componentDidMount && componentIns.componentDidMount(); componentIns._internalComponent = this;this._renderedInternalComponent = renderedInternalComponent;this._componentInstance = componentIns;this._dom = result;return result; }}
JSX 中的 <CustomComponent/> 其實並非實例化的 CustomComponent,而是其構建函數,所以它實際上是 ComponentClass,會在 CompositeInternalComponent 中將它實例化,再調用 ComponentClass.render() 得到應該渲染的 VElement,而後根據 VElement 實例化渲染結果對應的 InternalComponent 並且 mount。
同時 CompositeInternalComponent 和 render 出來的 VElement、針對 VElement 實例化的 InternalComponent 和最後得到的 DOM 元素相互之間有關聯。
這裡多個數據結非常容易混淆,用戶自己定義的 Component,這個自定義 Component 對應的 InternalComponent,其 render 函數返回的 VirtualDOM Element,以及根據這個 VirtualDOM Element 構造來的 InternalComponent 是幾個不同的概念。
Pure Functional Component
React 還支持純函數組件,即
const PureFunctionalComponent = ({name}) => {return <div>{name}</div>;}
這樣的組件同樣是組件,由於純函數組件實際上是沒有生命周期和 state 的,它實際上完全等價於自己 render 的結果。所以只需在渲染 CompositeInternalComponent 時根據 render 返回的結果是否是純函數組件,如果是再取其 render 結果即可。
let renderedVElem = componentIns.render();while(isElementOfPureFunctionalComponent(renderedVElem)) { renderedVElem = instanceInternalComponent(renderedVElem).mount(rootID);}
判斷其是否為 PureFunctionalComponent 也非常簡單,只要它是函數,而且不是從 React.Component 繼承來的,我們就可以認為是 PureFunctionalComponent。
判斷繼承關係我們可以參照 babel 對 ES6 類繼承的實現:
function _inherits(subClass, superClass) {if (typeof superClass !== "function" && superClass !== null) {throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } });if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;}
可以看到 subClass 的 prototype 指向 superClass 的 prototype,而 superClass 的原型(__proto__)指向 superClass。所以當我們要判斷一個對象是否由繼承自 superClass 的類實例化就非常簡單:
function isInheritFrom(childObject, parentClass) {return childObject.prototype instanceof parentClass;}
事件機制
目前為止我們只是把 JSX 渲染到了頁面上,但是原來在 DOM 中的事件綁定例如 onClick並不會生效,所以接下來要實現事件機制。
為了統一管理,保持事件冒泡的一致性,React 採用在頂層做事件代理的方式。React 為了消除不同瀏覽器之間的差異,還實現了一層合成事件,在這裡我們不做實現。直接用比較取巧的方式監聽所有的事件。
const allEvent = _(window).keys() .filter(item => /^on/.test(item)) .map(item => item.replace(/^on/, "")) .value()_.forEach(allEvent, event => { document.addEventListener(event, e => {const target = e.target;const targetID = target.getAttribute && target.getAttribute(DATA_ATTR_REACT_ID);// TODO: 根據拿到的 react-id 觸發事件 });});
通過監聽 window 上所有的事件,我們可以拿到觸發事件的 target 所帶的 react-id。然後觸發相應的事件,而註冊事件只需用一個對象保存事件,對象和相應的回調事件即可。
比較有意思的是冒泡的實現,因為我們是在頂層的事件代理中直接監聽了所有的事件,事件的響應也是由我們調用相應的 callback,所以要重新實現事件的捕獲和冒泡,而 react-id 在此處同樣起到了非常大的作用。
當我們點擊一個組件時,假設它的 react-id 是 2.2.4,事件代理在拿到事件後,只需要按照下面的順序觸發即可:
eventCaptureHandler["2"]["click"]()eventCaptureHandler["2.2"]["click"]()eventCaptureHandler["2.2.4"]["click"]()eventBubbleHandler["2.2.4"]["click"]()eventBubbleHandler["2.2"]["click"]()eventBubbleHandler["2"]["click"]()
同時需要在前面渲染 DOMInternalComponent 時判斷 props 的名字是否是一個事件,如果是則註冊到事件代理中。
針對事件的測試案例也可以體現自動化測試的優勢,我們不需要每次都去點擊按鈕查看時間是否生效。
it("Event should be bind", () => {const con = createContainer("render-bind-event");const funcs = { handleClick: () => { } };// 監聽 funcs.handleClick 函數 spyOn(funcs, "handleClick"); render(<input value="TEST BUTTON" type="button" onClick={funcs.handleClick} />, con); const target = con.append(); target.click(); // funcs.handleClick 應該被調用 expect(funcs.handleClick).toHaveBeenCalled();});
後續
由於涉及到的內容過多,這篇文章暫時介紹到第一次渲染結束,後續的文章會逐步介紹 VirtualDOM,setState 等。
拓展閱讀
- React v15: Major changes
推薦閱讀:
※基於 Webpack 的應用包體尺寸優化
※React 實現一個漂亮的 Table
※React Conf 2017 不能錯過的大起底——Day 1!
※解析 Redux 源碼
※我對Flexbox布局模式的理解
TAG:React |