基於 JSX 的動態數據綁定

基於 JSX 的動態數據綁定歸屬於筆者的 React 與前端工程化實踐中的,本文中設計的引用資料參考 React 學習與實踐資料索引,如果有對 JavaScript 基礎語法尚存疑惑的可以參閱現代 JavaScript 開發:語法基礎與實踐技巧。

基於 JSX 的動態數據綁定

筆者在 2016-我的前端之路: 工具化與工程化一文中提及,前端社區用了 15 年的時間來分割 HTML、JavaScript 與 CSS,但是隨著 JSX 的出現彷彿事物一夕回到解放前。在 Angular、Vue.js 等 MVVM 前端框架中都是採用了指令的方式來描述業務邏輯,而 JSX 本質上還是 JavaScript,即用 JavaScript 來描述業務邏輯。雖然 JSX 被有些開發者評論為醜陋的語法,但是筆者還是秉持 JavaScript First 原則,儘可能地用 JavaScript 去編寫業務代碼。在前文 React 初窺:JSX 詳解中我們探討了 JSX 的前世今生與基本用法,而本部分我們著手編寫簡單的面向 DOM 的 JSX 解析與動態數據綁定庫;本部分所涉及的代碼歸納於 Ueact 庫。

JSX 解析與 DOM 元素構建

元素構建

筆者在 JavaScript 語法樹與代碼轉化實踐 一文中介紹過 Babel 的原理與用法,這裡我們仍然使用 Babel 作為 JSX 語法解析工具;為了將 JSX 聲明轉化為 createElement 調用,這裡需要在項目的 .babelrc 文件中做如下配置:

"plugins": [ "transform-decorators-legacy", "async-to-promises", [ "transform-react-jsx", { "pragma": "createElement" } ] ],

這裡的 createElement 函數聲明如下:

/** * Description 從 JSX 中構建虛擬 DOM * @param tagName 標籤名 * @param props 屬性 * @param childrenArgs 子元素列表 */export function createElement( tagName: string, props: propsType, ...childrenArgs: [any]) {}

該函數包含三個參數,分別指定標籤名、屬性對象與子元素列表;實際上經過 Babel 的轉化之後,JSX 文本會成為如下的函數調用(這裡還包含了 ES2015 其他的語法轉化):

... (0, _createElement.createElement)( "section", null, (0, _createElement.createElement)( "section", null, (0, _createElement.createElement)( "button", { className: "link", onClick: handleClick }, "Custom DOM JSX" ), (0, _createElement.createElement)("input", { type: "text", onChange: function onChange(e) { console.log(e); } }) ) ),...

在獲取到元素標籤之後,我們首先要做的就是創建元素;創建元素 createElementByTag 過程中我們需要注意區分普通元素與 SVG 元素:

export const createElementByTag = (tagName: string) => {if (isSVG(tagName)) {return document.createElementNS("http://www.w3.org/2000/svg", tagName); }return document.createElement(tagName);};

屬性處理

在創建了新的元素對象之後,我們需要對 createElement 函數傳入的後續參數進行處理,也就是為元素設置對應的屬性;基本的屬性包含了樣式類、行內樣式、標籤屬性、事件、子元素以及樸素的 HTML 代碼等。首先我們需要對子元素進行處理:

// 處理所有子元素,如果子元素為單純的字元串,則直接創建文本節點const children = flatten(childrenArgs).map(child => { // 如果子元素同樣為 Element,則創建該子元素的副本if (child instanceof HTMLElement) {return child; }if (typeof child === "boolean" || child === null) { child = ""; }return document.createTextNode(child);});

這裡可以看出,對 createElement 函數的執行是自底向上執行的,因此傳入的子元素參數實際上是已經經過渲染的 HTML 元素。接下來我們還需要對其他屬性進行處理:

...// 同時支持 class 與 className 設置const className = props.class || props.className;// 如果存在樣式類,則設置if (className) { setAttribute(tagName, el, "class", classNames(className));}// 解析行內樣式getStyleProps(props).forEach(prop => { el.style.setProperty(prop.name, prop.value);});// 解析其他 HTML 屬性getHTMLProps(props).forEach(prop => { setAttribute(tagName, el, prop.name, prop.value);});// 設置事件監聽,這裡為了解決部分瀏覽器中非同步問題因此採用同步寫法let events = getEventListeners(props);for (let event of events) { el[event.name] = event.listener;}...

React 中還允許直接設置元素的內部 HTML 代碼,這裡我們也需要判斷是否存在有 dangerouslySetInnerHTML 屬性:

// 如果是手動設置 HTML,則添加 HTML,否則設置顯示子元素if (setHTML && setHTML.__html) { el.innerHTML = setHTML.__html;} else { children.forEach(child => { el.appendChild(child); });}

到這裡我們就完成了針對 JSX 格式的樸素的 DOM 標籤轉化的 createElement 函數,完整的源代碼參考這裡。

簡單使用

這裡我們依舊使用 create-webpack-app 腳手架來搭建示例項目,這裡我們以簡單的計數器為例描述其用法。需要注意的是,本部分尚未引入雙向數據綁定,或者說是自動狀態變化更新,還是使用的樸素的 DOM 選擇器查詢更新方式:

// App.jsimport { createElement } from "../../../src/dom/jsx/createElement";// 頁面內狀態const state = { count: 0};/** * Description 點擊事件處理 * @param e */const handleClick = e => { state.count++; document.querySelector("#count").innerText = state.count;};export default ( <div className="header"> <section> <section> <button className="link" onClick={handleClick}> Custom DOM JSX </button> <input type="text" onChange={(e)=>{ console.log(e); }} /> </section> </section> <svg> <circle cx="64" cy="64" r="64" stylex="fill: #00ccff;" /> </svg> <br /> <span id="count" stylex={{ color: "red" }}> {state.count} </span> </div>);// client.js// @flowimport App from "./component/Count";document.querySelector("#root").appendChild(App);

數據綁定

當我們使用 Webpack 在後端編譯 JSX 時,會將其直接轉化為 JavaScript 中函數調用,因此可以自然地在作用域中聲明變數然後在 JSX 中直接引用;不過筆者在設計 Ueact 時考慮到,為了方便快速上手或者簡單的 H5 頁面開發或者已有的代碼庫的升級,還是需要支持運行時動態編譯的方式;本部分我們即討論如何編寫 JSX 格式的 HTML 模板並且進行數據動態綁定。本部分我們的 HTML 模板即是上文使用的 JSX 代碼,不同的是我們還需要引入 babel-standalone 以及 Ueact 的 umd 模式庫:

然後在本頁面的 script 標籤中,我們可以對模板進行渲染並且綁定數據:

<script> var ele = document.querySelector("#inline-jsx"); Ueact.observeDOM( ele, {state: { count: 0, delta: 1, items: [1, 2, 3] }, methods: { handleClick: function () { this.state.count+=this.state.delta; this.state.items.push(this.state.count); }, handleChange:function (e) { let value = parseInt(e.target.value); if(!Number.isNaN(value)){ this.state.delta = value; } } }, hooks: { mounted: function () { console.log("mounted"); }, updated:function () { console.log("updated"); } } }, Babel );</script>

這裡我們調用 Ueact.observeDOM 函數對模板進行渲染,該函數會獲取指定元素的 outerHTML 屬性,然後通過 Babel 動態插件進行編譯:

let input = html2JSX(ele.outerHTML); let output = Babel.transform(input, { presets: ["es2015"], plugins: [ [ "transform-react-jsx", { pragma: "Ueact.createElement" } ] ] }).code;

值得一提的是,因為 HTML 語法與 JSX 語法存在一定的差異,我們獲取渲染之後的 DOM 對象之後,還需要對部分元素語法進行修正;主要包括了以下三個場景:

  • 自閉合標籤處理,即 <input > => <input />
  • 去除輸入的 HTML 中的事件監聽的引號,即 onclick="{methods.handleClick}" => onclick={methods.handleClick}
  • 移除 value 值額外的引號,即 value="{state.a}" => value={state.a}

到這裡我們得到了經過 Babel 轉化的函數調用代碼,下面我們就需要去執行這部分代碼並且完成數據填充。最簡單的方式就是使用 eval 函數,不過因為該函數直接暴露在了全局作用域下,因此並不被建議使用;我們使用動態構造 Function 的方式來進行調用:

/** * Description 從輸入的 JSX 函數字元串中完成構建 * @param innerContext */function renderFromStr(innerContext) { let func = new Function( "innerContext", ` let { state, methods, hooks } = innerContext; let ele = ${innerContext.rawJSX}return ele; ` ).bind(innerContext); // 構建新節點 let newEle: Element = func(innerContext); // 使用指定元素的父節點替換自身 innerContext.root.parentNode.replaceChild(newEle, innerContext.root); // 替換完畢之後刪除舊節點的引用,觸發 GC innerContext.root = newEle;}

innerContext 即包含了我們定義的 State 與 Methods 等對象,這裡利用 JavaScript 詞法作用域(Lexical Scope)的特性進行變數傳遞;本部分完整的代碼參考這裡。

變化監聽與重渲染

筆者在 2015-我的前端之路:數據流驅動的界面中討論了從以 DOM 為核心到數據流驅動的變化,本部分我們即討論如何自動監聽狀態變化並且完成重渲染。這裡我們採用監聽 JavaScript 對象屬性的方式進行狀態變化監聽,採用了筆者另一個庫 Observer-X,其基本用發如下:

import { observe } from "../../dist/observer-x";const obj = observe( {}, { recursive: true });obj.property = {};obj.property.listen(changes => { console.log(changes); console.log("changes in obj");});obj.property.name = 1;obj.property.arr = [];obj.property.arr.listen(changes => { // console.log("changes in obj.arr");});// changes in the single event loop will be print outsetTimeout(() => { obj.property.arr.push(1); obj.property.arr.push(2); obj.property.arr.splice(0, 0, 3);}, 500);

核心即是當某個對象的屬性發生變化(增刪賦值)時,觸發註冊的回調事件;即:

... // 將內部狀態轉化為可觀測變數 let state = observe(innerContext.state); ...state.listen(changes => { renderFromStr(innerContext); innerContext.hooks.updated && innerContext.hooks.updated(); }); ...

完整的在線 Demo 可以查看 基於 JSX 與 Observer-X 的簡單計數器


推薦閱讀:

React 從青銅到王者系列教程之倔強青銅篇
如何評價React VR Project?
為什麼 GUI 編程中,Web 平台的技術革新特別火爆,而 Android 和 iOS 沒什麼成果?
知乎是否會停止使用React?
關於在react中request到底是應該寫在哪裡?

TAG:JavaScript | React | JS调试 |