手把手教你為 React 添加雙向數據綁定(一)

0. Something To Say

t該系列文章計劃中一共有三篇,在這三篇文章里我將手把手教大家使用 Babel 為 React 實現雙向數據綁定。在這系列文章你將:

  • 了解一些非常基本的編譯原理中的概念
  • 了解 JS 編譯的過程與原理
  • 學會如何編寫 babel-plugin
  • 學會如何修改 JS AST 來實現自定義語法

t該系列文章實現的 babel-plugin-jsx-two-way-binding 在我的 GitHub 倉庫,歡迎參考或提出建議。

t你也可以使用 `npm install --save-dev babel-plugin-jsx-two-way-binding` 來安裝並直接使用該 babel-plugin。

另:本人 18 屆前端萌新正在求職,如果有大佬覺得我還不錯,請私信我或給我發郵件:i@do.codes !(~ ̄▽ ̄)~附:我的簡歷。

1. Why

t在 Angular、Vue 等現代前端框架中,雙向數據綁定是一個很有用的特性,為處理表單帶來了很大的便利。

tReact 官方一直提倡單向數據流的思想,雖然我個人十分喜歡 React 的設計哲學,但在實際需求中,有時會遇到 View 層與 Model 層存在大量的數據需要同步的情況,這時為每一個表單都添加一個 Handler 反而會讓事情變得更加繁瑣。

2. How

t不難發現,這種情況在 React 中總是有相同的的處理方法:通過 「value」 屬性實現 Model => View 的數據流,通過綁定 「 onChange」 Handler 實現 View => Model 的數據流。

t由於 JSX 不能直接在瀏覽器運行,需要使用 Babel 編譯成普通的 JS 文件, 因此這讓我們有機會在編譯時對代碼進行處理實現無需 Runtime 的雙向數據綁定。

如: 在 JSX 中,在 「Input」 標籤中使用 「model」 屬性來指定要綁定的數據:

class App extends React.Component {n constructor(props) {n super(props);n this.state = {n name: Joen }n }nn render() { return (n <div>n <h1>Im {this.state.name}</h1>n <input type="text" model={this.state.name}/>n </div>n )}n}n

綁定 「model」 屬性的標籤在編譯時將會同時被綁定 「value」 屬性和 「onChange」 Handler:

class App extends React.Component {n constructor(props) {n super(props);n this.state = {n name: Joen }n }nn render() { return (n <div>n <h1>Im {this.state.name}</h1>n <inputn type="text"n value={this.state.name}n onChange={e => this.setState({ name: e.target.value })}n />n </div>n )}n}n

3. About Babel

t下面需要了解一些知識:

tBabel 編譯 JS 文件的步驟分為解析(parse),轉換(transform),生成(generate)三個步驟。

t解析步驟接收代碼並輸出 AST(Abstract syntax tree: 抽象語法樹, 參考: Abstract syntax tree)。 這個步驟分為兩個階段:詞法分析(Lexical Analysis)和語法分析(Syntactic Analysis)。

t轉換步驟接收 AST 並對其進行遍歷,在此過程中對節點進行添加、更新及移除等操作。

t代碼生成步驟深度優先遍歷最終的 AST 轉換成字元串形式的代碼,同時還會創建源碼映射(source maps)。

t要達到我們的目標,我們需要在轉換步驟操作 AST 並對其進行更改。 AST 在 Babel 中以 JS 對象的形式存在,因此我們需要遍歷每一個 AST 節點。

t在 Babel 及其他很多編譯器中,都使用訪問者模式來遍歷 AST 節點(參考:Visitor pattern - Wikipedia)。當我們談及遍歷到一個 AST 節點時,實際上我們是在訪問它,這時 Babel 將會調用該類型節點的 Handler。如,當訪問到一個函數聲明時(FunctionDeclaration),將會調用 FunctionDeclaration() 方法並將當前訪問的節點作為參數傳入該函數。我們需要做的工作就是編寫對應訪問者的 Handler 來處理添加了雙向數據綁定的標籤的 AST 並為其添加 「value」 屬性 和 「onChange」 handler。

一個重要的工具:

AST explorer:可以把我們的代碼轉換為 Babel AST 樹,我們需要參考它來對我們的 AST 樹進行修改。

一些參考資料:

BabelHandbook:教你如何使用 Babel 以及如何編寫 Babel 插件和預設。

BabelTypes 文檔:我們需要查閱該文檔來構建新的的 AST 節點。

4. Let『s Do It!

t首先, 使用 `npm init` 創建一個空的項目,然後在項目目錄下創建 「index.js」:

module.exports = function ({ types: t }) {n return {n visitor: {n JSXElement: function(node) {n // TODOn }n }n }n};n

t在 「index.s」 中我們導出一個方法作為該 babel-plugin 的主體,該方法接受一個 babel 對象作為參數,返回一個包含各個 Visitor Handler 方法的對象。傳入的 babel 對象包含一個 types 屬性,它用來構造新的 AST 節點,如,可以使用 `t.jSXAttribute(name, value)` 來構造一個新的 JSX 屬性節點; 每個 Visitor 方法接受一個 Path 作為參數。AST 通常會有許多節點,babel 使用一個可操作和訪問的巨大可變對象表示節點之間的關聯關係。Path 是表示兩個節點之間連接的對象。

t因為我們要修改 JSX 標籤的屬性並對其添加 「value」 和 「onChange」 屬性,因此我們需要在 JSXElement Visitor Handler 中遍歷 JSXAttribute。Visitor Handler 中傳入的的 Path 參數中有個 traverse 方法可以用來遍歷所有的節點。現在,我們來添加一個遍歷 JSX 屬性的方法:

module.exports = function ({ types: t }) {n function JSXAttributeVisitor(node) {n // TODOn }nn function JSXElementVisitor(path) {n path.traverse({n JSXAttribute: JSXAttributeVisitorn });n }nn return {n visitor: {n JSXElement: JSXElementVisitorn }n }n};n

t然後我們來具體實現 JSXAttributeVisitor 方法。首先,我們需要拿到雙向數據綁定的值,並保存到一個變數(我們默認使用 「model」 屬性來進行雙向數據綁定),然後把 「model」 屬性名改為 「value」:

function JSXAttributeVisitor(node) {n if (node.node.name.name === model) {n const model = node.node.value.expression;n // 將 model 屬性名改為 valuen node.node.name.name = value;n }n}n

t這時我們拿到的 model 屬性是一個 expression 對象,我們需要將其轉化成類似 「this.state.name」 這樣的字元串方便我們在後面使用,在這裡我們實現一個方法將 expression 對象轉換成字元串:

function objExpression2Str(expression) {n let objStr;n switch (expression.object.type) {n case MemberExpression:n objStr = objExpression2Str(expression.object);n break;n case Identifier:n objStr = expression.object.name;n break;n case ThisExpression:n objStr = this;n break;n }n return objStr + . + expression.property.name;n}n

t因為我們需要在自動綁定的 handler 裡面使用 「this.setState」 方法,因此我們暫時只考慮對 State 對象的數據綁定進行處理。讓我們繼續改進 JSXAttributeVisitor 方法:

function JSXAttributeVisitor(node) {n if (node.node.name.name === model) {n let modelStr = objExpression2Str(node.node.value.expression).split(.);n // 如果雙向數據綁定的值不是 this.state 的屬性,則不作處理n if (modelStr[0] !== this || modelStr[1] !== state) return;n // 將 modelStr 從類似 『this.state.name.value』 變為 『name.value』 的形式n modelStr = modelStr.slice(2, modelStr.length).join(.);nn node.node.name.name = value;n }n}n

然後我們開始構建 onChange Handler 的 AST 節點,因為我們調用 「this.setState」 時需要以對象的形式傳入參數,因此我們創建兩個方法,objPropStr2AST 方法以字元串傳入 key 和 value,返回一個對象 AST 節點;objValueStr2AST 方法以字元串傳入 value,返回對象的屬性的值的 AST 節點:

// 把 key - value 字元串轉換為 { key: value } 這樣的對象 AST 節點nfunction objPropStr2AST(key, value, t) {n return t.objectProperty(n t.identifier(key),n objValueStr2AST(value, t)n );n}n

// 把類似 「this.state.name」 這樣的字元串轉換為 AST 節點nfunction objValueStr2AST(objValueStr, t) {n const values = objValueStr.split(.);n if (values.length === 1)n return t.identifier(values[0]);n return t.memberExpression(n objValueStr2AST(values.slice(0, values.length - 1).join(.), t),n objValueStr2AST(values[values.length - 1], t)n )n}n

讓我繼續構建 onChange Handler AST ,接著剛剛的 JSXAttributeVisitor 方法,在後面加上:

// 創建一個函數調用節點(創建 AST 節點需要參閱 BabelTypes 文檔)n// 需要傳入 callee(調用的方法)和 arguments(調用時傳入的參數)兩個參數nconst setStateCall = t.callExpression(n // 調用的方法為 『this.setState』n t.memberExpression(n t.thisExpression(),n t.identifier(setState)n ),n // 調用時傳入的參數為一個對象n // key 為剛剛拿到的 modelStr,value 為 e.target.valuen [t.objectExpression(n [objPropStr2AST(modelStr, e.target.value, t)]n )]n);n

t終於,讓我們加上 onChange Handler:

// 使用 insertAfter 方法在當前 JSXAttribute 節點後添加一個新的 JSX 屬性節點nnode.insertAfter(t.JSXAttribute(n // 屬性名為 「onChange」n t.jSXIdentifier(onChange),n // 屬性值為一個 JSX 表達式n t.JSXExpressionContainer(n // 在表達式中使用箭頭函數n t.arrowFunctionExpression(n // 該函數接受參數 『e』n [t.identifier(e)],n // 函數體為一個包含剛剛創建的 『setState『 調用的語句塊n t.blockStatement([t.expressionStatement(setStateCall)])n )n )n));n

5. Well Done!

恭喜!到這裡我們已經實現了我們需要的基本功能,完整的 『index.js』 代碼為:

module.exports = function ({ types: t}) {n function JSXAttributeVisitor(node) {n if (node.node.name.name === model) {n let modelStr = objExpression2Str(node.node.value.expression).split(.);n // 如果雙向數據綁定的值不是 this.state 的屬性,則不作處理n if (modelStr[0] !== this || modelStr[1] !== state) return;n // 將 modelStr 從類似 『this.state.name.value』 變為 『name.value』 的形式n modelStr = modelStr.slice(2, modelStr.length).join(.);nn // 將 model 屬性名改為 valuen node.node.name.name = value;nn const setStateCall = t.callExpression(n // 調用的方法為 『this.setState』n t.memberExpression(n t.thisExpression(),n t.identifier(setState)n ),n // 調用時傳入的參數為一個對象n // key 為剛剛拿到的 modelStr,value 為 e.target.valuen [t.objectExpression(n [objPropStr2AST(modelStr, e.target.value, t)]n )]n );nn node.insertAfter(t.JSXAttribute(n // 屬性名為 「onChange」n t.jSXIdentifier(onChange),n // 屬性值為一個 JSX 表達式n t.JSXExpressionContainer(n // 在表達式中使用箭頭函數n t.arrowFunctionExpression(n // 該函數接受參數 『e』n [t.identifier(e)],n // 函數體為一個包含剛剛創建的 『setState『 調用的語句塊n t.blockStatement([t.expressionStatement(setStateCall)])n )n )n ));n }n }nn function JSXElementVisitor(path) {n path.traverse({n JSXAttribute: JSXAttributeVisitorn });n }nn return {n visitor: {n JSXElement: JSXElementVisitorn }n }n};nn// 把 expression AST 轉換為類似 「this.state.name」 這樣的字元串nfunction objExpression2Str(expression) {n let objStr;n switch (expression.object.type) {n case MemberExpression:n objStr = objExpression2Str(expression.object);n break;n case Identifier:n objStr = expression.object.name;n break;n case ThisExpression:n objStr = this;n break;n }n return objStr + . + expression.property.name;n}nn// 把類似 「this.state.name」 這樣的字元串轉換為 AST 節點nfunction objPropStr2AST(key, value, t) {n return t.objectProperty(n t.identifier(key),n objValueStr2AST(value, t)n );n}nn// 把 key - value 字元串轉換為 { key: value } 這樣的對象 AST 節點nfunction objValueStr2AST(objValueStr, t) {n const values = objValueStr.split(.);n if (values.length === 1)n return t.identifier(values[0]);n return t.memberExpression(n objValueStr2AST(values.slice(0, values.length - 1).join(.), t),n objValueStr2AST(values[values.length - 1], t)n )n}n

現在我們已經能夠成功使用 『model』 屬性綁定數據並自動為其添加 『value』 屬性與 『onChange』 Handler 來實現雙向數據綁定!

讓我們試試效果:編輯 『.babelrc』 配置文件:

{n "plugins": [n "path/to/your/index.js(我們創建的 index.js 文件路徑)",n ...n ]n}n

t然後編寫一個 React 組件,你會發現,使用 『model』 屬性即可實現雙向數據綁定,就像在 Angular 或 Vue 里那樣,簡單而自然!

6. So What『s Next?

t目前我們已經實現了基本的雙向數據綁定,但是還存在一些缺陷:我們手動添加的 onChange Handler 會被覆蓋掉,並且只能對非嵌套的屬性進行綁定!

t接下來的兩篇文章里我們會對這些問題進行解決,歡迎關注我的知乎或者專欄(Code & Design),敬請期待!

PS:

t如果你覺得這篇文章或者 babel-plugin-jsx-two-way-binding 對你有幫助,請不要吝嗇你的 Star!如果有錯誤或者不準確的地方,歡迎提出!

本人 18 屆前端萌新正在求職,如果有大佬覺得我還不錯,請私信我或給我發郵件:i@do.codes !(~ ̄▽ ̄)~附:我的簡歷。


推薦閱讀:

一年前端開發,學習永遠趕不上潮流,有一定的PHP基礎,現在動搖了,不知道該繼續前端,還是轉PHP?
前端開發,開發人員怎麼方便的自測IE各個版本?
天天演算法 | Easy | 10. 有效括弧:Valid Parentheses
我的第一個響應式頁面

TAG:React | Babel | 前端开发 |