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

0. Something To Say

該系列文章計劃中一共有三篇,在這三篇文章裏我將手把手教你們使用 Babel 爲 React 實現雙向數據綁定。在這系列文章你將:前端

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

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

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

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

1. Why

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

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

2. How

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

因爲 JSX 不能直接在瀏覽器運行,須要使用 Babel 編譯成普通的 JS 文件, 所以這讓咱們有機會在編譯時對代碼進行處理實現無需 Runtime 的雙向數據綁定。前端框架

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

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:markdown

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!(~ ̄▽ ̄)~附:個人簡歷

相關文章
相關標籤/搜索