手把手教你為 React 新增雙向資料繫結(一)

六面體混凝土移動師發表於2017-10-27

0. Something To Say

該系列文章計劃中一共有三篇,在這三篇文章裡我將手把手教大家使用 Babel 為 React 實現雙向資料繫結。在這系列文章你將:

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

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

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

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

1. Why

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

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

2. How

不難發現,這種情況在 React 中總是有相同的的處理方法:通過 “value” 屬性實現 Model => View 的資料流,通過繫結 “ onChange” Handler 實現 View => Model 的資料流。

由於 JSX 不能直接在瀏覽器執行,需要使用 Babel 編譯成普通的 JS 檔案, 因此這讓我們有機會在編譯時對程式碼進行處理實現無需 Runtime 的雙向資料繫結。

如: 在 JSX 中,在 “Input” 標籤中使用 “model” 屬性來指定要繫結的資料:

class App extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            name: 'Joe'
        }
    }

    render() { return (
        <div>
            <h1>I'm {this.state.name}</h1>
            <input type="text" model={this.state.name}/>
        </div>
    )}
}複製程式碼

繫結 “model” 屬性的標籤在編譯時將會同時被繫結的 “value” 屬性和 “onChange” Handler:

class App extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            name: 'Joe'
        }
    }

    render() { return (
        <div>
            <h1>I'm {this.state.name}</h1>
            <input
                type="text"
                value={this.state.name}
                onChange={e => this.setState({ name: e.target.value })}
            />
        </div>
    )}
}複製程式碼

3. About Babel

下面需要了解一些知識:

Babel 編譯 JS 檔案的步驟分為解析(parse),轉換(transform),生成(generate)三個步驟。

解析步驟接收程式碼並輸出 AST(Abstract syntax tree: 抽象語法樹, 參考: en.wikipedia.org/wiki/Abstra… 這個步驟分為兩個階段:詞法分析(Lexical Analysis)和語法分析(Syntactic Analysis)。

轉換步驟接收 AST 並對其進行遍歷,在此過程中對節點進行新增、更新及移除等操作。
程式碼生成步驟深度優先遍歷最終的 AST 轉換成字串形式的程式碼,同時還會建立原始碼對映(source maps)。

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

在 Babel 及其他很多編譯器中,都使用訪問者模式來遍歷 AST 節點(參考: Visitor pattern - Wikipedia)。當我們談及遍歷到一個 AST 節點時,實際上我們是在訪問它,這時 Babel 將會呼叫該型別節點的 Handler。如,當訪問到一個函式宣告時(FunctionDeclaration),將會呼叫 FunctionDeclaration() 方法並將當前訪問的節點作為引數傳入該函式。我們需要做的工作就是編寫對應訪問者的 Handler 來處理新增了雙向資料繫結的標籤的 AST 併為其新增 “value” 屬性 和 “onChange” handler。

一個重要的工具:
AST Explorer(AST explorer):可以把我們的程式碼轉換為 Babel AST 樹,我們需要參考它來對我們的 AST 樹進行修改。

一些參考資料:
BabelHandBook (GitHub - thejameskyle/babel-handbook: A guided handbook on how to use Babel and how to create plugins for Babel.):教你如何使用 Babel 以及如何編寫 Babel 外掛和預設。

BabelTypes 文件(babel/packages/babel-types at master · babel/babel · GitHub):我們需要查閱該文件來構建新的的 AST 節點。

4. Let‘s Do It!

首先,使用 npm init 建立一個空的專案,然後在專案目錄下建立 “index.js”:

module.exports = function ({ types: t }) {
    return {
        visitor: {
            JSXElement: function(node) {
                // TODO
            }
        }
    }
};複製程式碼

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

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

module.exports = function ({ types: t }) {
      function JSXAttributeVisitor(node) {
            // TODO
      }

    function JSXElementVisitor(path) {
        path.traverse({
            JSXAttribute: JSXAttributeVisitor
        });
    }

    return {
        visitor: {
            JSXElement: JSXElementVisitor
        }
    }
}複製程式碼

然後我們來具體實現 JSXAttributeVisitor 方法。首先,我們需要拿到雙向資料繫結的值,並儲存到一個變數(我們預設使用 “model” 屬性來進行雙向資料繫結),然後把 “model” 屬性名改為 “value”:

function JSXAttributeVisitor(node) {
    if (node.node.name.name === 'model') {
        const model = node.node.value.expression;
        // 將 model 屬性名改為 value
        node.node.name.name = 'value';
    }
}複製程式碼

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

// 把 expression AST 轉換為類似 “this.state.name” 這樣的字串
function objExpression2Str(expression) {
    let objStr;
    switch (expression.object.type) {
        case 'MemberExpression':
            objStr = objExpression2Str(expression.object);
            break;
        case 'Identifier':
            objStr = expression.object.name;
            break;
        case 'ThisExpression':
            objStr = 'this';
            break;
    }
    return objStr + '.' + expression.property.name;
}複製程式碼

因為我們需要在自動繫結的 handler 裡面使用 “this.setState” 方法,因此我們暫時只考慮對 State 物件的資料繫結進行處理。讓我們繼續改進 JSXAttributeVisitor 方法:

function JSXAttributeVisitor(node) {
    if (node.node.name.name === 'model') {
        let modelStr = objExpression2Str(node.node.value.expression).split('.');
        // 如果雙向資料繫結的值不是 this.state 的屬性,則不作處理
        if (modelStr[0] !== 'this' || modelStr[1] !== 'state') return;
        // 將 modelStr 從類似 ‘this.state.name.value’ 變為 ‘name.value’ 的形式
        modelStr = modelStr.slice(2, modelStr.length).join('.');

        node.node.name.name = 'value';
    }
}複製程式碼

然後我們開始構建 onChange Handler 的 AST 節點,因為我們呼叫 “this.setState” 時需要以物件的形式傳入引數,因此我們建立兩個方法,objPropStr2AST 方法以字串傳入 key 和 value,返回一個物件 AST 節點;objValueStr2AST 方法以字串傳入 value,返回物件的屬性的值的 AST 節點:

// 把 key - value 字串轉換為 { key: value } 這樣的物件 AST 節點
function objPropStr2AST(key, value, t) {
    return t.objectProperty(
        t.identifier(key),
        objValueStr2AST(value, t)
    );
}複製程式碼
// 把類似 “this.state.name” 這樣的字串轉換為 AST 節點
function objValueStr2AST(objValueStr, t) {
    const values = objValueStr.split('.');
    if (values.length === 1)
        return t.identifier(values[0]);
    return t.memberExpression(
        objValueStr2AST(values.slice(0, values.length - 1).join('.'), t),
        objValueStr2AST(values[values.length - 1], t)
    )
}複製程式碼

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

// 建立一個函式呼叫節點(建立 AST 節點需要參閱 BabelTypes 文件)
// 需要傳入 callee(呼叫的方法)和 arguments(呼叫時傳入的引數)兩個引數
const setStateCall = t.callExpression(
    // 呼叫的方法為 ‘this.setState’
    t.memberExpression(
        t.thisExpression(),
        t.identifier('setState')
    ),
    // 呼叫時傳入的引數為一個物件
    // key 為剛剛拿到的 modelStr,value 為 e.target.value
    [t.objectExpression(
        [objPropStr2AST(modelStr, 'e.target.value', t)]
    )]
);複製程式碼

終於,讓我們加上 onChange Handler:

// 使用 insertAfter 方法在當前 JSXAttribute 節點後新增一個新的 JSX 屬性節點
node.insertAfter(t.JSXAttribute(
    // 屬性名為 “onChange”
    t.jSXIdentifier('onChange'),
    // 屬性值為一個 JSX 表示式
    t.JSXExpressionContainer(
        // 在表示式中使用箭頭函式
        t.arrowFunctionExpression(
            // 該函式接受引數 ‘e’
            [t.identifier('e')],
            // 函式體為一個包含剛剛建立的 ‘setState‘ 呼叫的語句塊
            t.blockStatement([t.expressionStatement(setStateCall)])
        )
    )
));複製程式碼

5. Well Done!

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

module.exports = function ({ types: t}) {
    function JSXAttributeVisitor(node) {
        if (node.node.name.name === 'model') {
            let modelStr = objExpression2Str(node.node.value.expression).split('.');
            // 如果雙向資料繫結的值不是 this.state 的屬性,則不作處理
            if (modelStr[0] !== 'this' || modelStr[1] !== 'state') return;
            // 將 modelStr 從類似 ‘this.state.name.value’ 變為 ‘name.value’ 的形式
            modelStr = modelStr.slice(2, modelStr.length).join('.');

            // 將 model 屬性名改為 value
            node.node.name.name = 'value';

            const setStateCall = t.callExpression(
                // 呼叫的方法為 ‘this.setState’
                t.memberExpression(
                    t.thisExpression(),
                    t.identifier('setState')
                ),
                // 呼叫時傳入的引數為一個物件
                // key 為剛剛拿到的 modelStr,value 為 e.target.value
                [t.objectExpression(
                    [objPropStr2AST(modelStr, 'e.target.value', t)]
                )]
            );

            node.insertAfter(t.JSXAttribute(
                // 屬性名為 “onChange”
                t.jSXIdentifier('onChange'),
                // 屬性值為一個 JSX 表示式
                t.JSXExpressionContainer(
                    // 在表示式中使用箭頭函式
                    t.arrowFunctionExpression(
                        // 該函式接受引數 ‘e’
                        [t.identifier('e')],
                        // 函式體為一個包含剛剛建立的 ‘setState‘ 呼叫的語句塊
                        t.blockStatement([t.expressionStatement(setStateCall)])
                    )
                )
            ));
        }
    }

    function JSXElementVisitor(path) {
        path.traverse({
            JSXAttribute: JSXAttributeVisitor
        });
    }

    return {
        visitor: {
            JSXElement: JSXElementVisitor
        }
    }
};

// 把 expression AST 轉換為類似 “this.state.name” 這樣的字串
function objExpression2Str(expression) {
    let objStr;
    switch (expression.object.type) {
        case 'MemberExpression':
            objStr = objExpression2Str(expression.object);
            break;
        case 'Identifier':
            objStr = expression.object.name;
            break;
        case 'ThisExpression':
            objStr = 'this';
            break;
    }
    return objStr + '.' + expression.property.name;
}

// 把類似 “this.state.name” 這樣的字串轉換為 AST 節點
function objPropStr2AST(key, value, t) {
    return t.objectProperty(
        t.identifier(key),
        objValueStr2AST(value, t)
    );
}

// 把 key - value 字串轉換為 { key: value } 這樣的物件 AST 節點
function objValueStr2AST(objValueStr, t) {
    const values = objValueStr.split('.');
    if (values.length === 1)
        return t.identifier(values[0]);
    return t.memberExpression(
        objValueStr2AST(values.slice(0, values.length - 1).join('.'), t),
        objValueStr2AST(values[values.length - 1], t)
    )
}複製程式碼

現在我們已經能夠成功使用 ‘model’ 屬性繫結資料並自動為其新增 ‘value’ 屬性與 ‘onChange’ Handler 來實現雙向資料繫結!

讓我們試試效果:編輯 ‘.babelrc’ 配置檔案:

{
  "plugins": [
    "path/to/your/index.js(我們建立的 index.js 檔案路徑)",
      ...
  ]
}複製程式碼

然後編寫一個 React 元件,你會發現,使用 ‘model’ 屬性即可實現雙向資料繫結,就像在 Angular 或 Vue 裡那樣,簡單而自然!

6. So What‘s Next?

目前我們已經實現了基本的雙向資料繫結,但是還存在一些缺陷:我們手動新增的 onChange Handler 會被覆蓋掉,並且只能對非巢狀的屬性進行繫結!

接下來的兩篇文章裡我們會對這些問題進行解決,歡迎關注我的掘金專欄GitHub

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

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

相關文章