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

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

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. Welcome Back!

在上一篇文章裡我們瞭解了一些基本的關於 JS 編譯的知識,並且學會了使用 Babel 來在編譯時 JSX AST 為 React 實現最基本的雙向資料繫結。

但是目前的雙向資料繫結仍然存在一些問題,如:我們手動新增的 onChange Handler 會被覆蓋掉,並且只能對非巢狀的屬性進行繫結!

2. Any Problem?

在這篇文章,讓我們繼續完善我們的 babel-plugin 來支援巢狀屬性的雙向資料繫結!

現在,當我們繫結巢狀的屬性時,如:

class App extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            profile: {
                name: {
                    type: 'str',
                    value: 'Joe'
                },
                age: {
                    type: 'int',
                    value: 21
                }
            }
        }
    }

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

編譯時會出現類似這樣的錯誤:

ERROR in ./index.js
Module parse failed: Unexpected token (59:35)
You may need an appropriate loader to handle this file type.
|                 _react2.default.createElement('input', { type: 'text', value: this.state.profile.name.vaue, onChange: function onChange(e) {
|                         _this2.setState({
|                             profile.name.vaue: e.target.value
|                         });
|                     }複製程式碼

根據報錯資訊,是因為我們目前的 babel-plugin 編譯出了這樣的程式碼:

onChange = { e =>this.setState({
      profile.name.vaue: e.target.value
})}複製程式碼

這顯然不符合 JS 的語法。為了實現巢狀屬性值的繫結,我們需要使用 ES6 中新增的 Object.assign 方法(參考:Object.assign() - JavaScript | MDN)。
我們的目標是編譯出這樣的程式碼:

onChange = { e => {
    const _state = this.state;
    this.setState({
        profile: Object.assign({}, _state.profile, {
            name: Object.assign({}, _state.profile.name, {
                value: e.target.value
            })
        })
    });
}}複製程式碼

3. Let’s Do It?

OK,問題出在 setStateCall,目前的 setStateCall AST 是這樣的:

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)]
    )]
);複製程式碼

如果我們繫結了 model = {this.state.profile.name.value}, 經過 objPropStr2AST 方法的轉換,相當於呼叫了 this.setState({ this.state.profile.name.value: e.target.value })。先讓我們改進 objPropStr2AST 方法:

function objPropStr2AST(key, value, t) {
    // 將 key 轉換為陣列形式
    key = key.split('.');
    return t.objectProperty(
        t.identifier(key[0]),
        key2ObjCall(key, value, t)
    );
}複製程式碼

在這裡我們呼叫了一個 key2ObjCall 方法, 這個方法將類似{ profile.name.value: value }這樣的 key-value 結構轉換為類似下面這樣的這樣的 AST 節點:

{
    profile: Object.assign({}, _state.profile, {
        name: Object.assign({}, _state.profile.name, {
            value: value
        })
    })
}複製程式碼

讓我們開始構建 key2ObjCall 方法,該方法接受陣列形式的 key 和字串形式的 value 為引數。在這裡我們需要使用遞迴地遍歷 key 陣列,因此我們還需第三個參數列示遍歷到的 key 的元素的索引:

function key2ObjCall(key, value, t, index) {
    // 初始化 index 為 0
    !index && (index = 0);
    // 若 key 只含有一個元素(key.length - 1 < index)
    // 或遍歷到 key 的最後一個元素(key.length - 1 === index)
    if (key.length - 1 <= index)
        // 直接返回 value 形式的 AST
        return objValueStr2AST(value, t);
    // 否則,返回 Object.assign({}, ...) 形式的 AST
    // 如:key 為 ['profile', 'name', 'value'],
    // value 為 e.target.value,index 為 0
    // 將返回 Object.assign({},
    //      indexKey2Str(0 + 1, ['profile', 'name', 'value']),
    //      { ['profile', 'name', 'value'][0 + 1]: key2ObjCall(['profile', 'name', 'value'], t, 0 + 1) }
    // ) 即 Object.assign({},
    //      this.state.profile,
    //      { name: key2ObjCall(['profile', 'name', 'value'], t, 1) }
    // ) 的 AST
    return t.callExpression(
        t.memberExpression(
            t.identifier('Object'),
            t.identifier('assign')
        ),
        [
            t.objectExpression([]),
            objValueStr2AST(indexKey2Str(index + 1, key), t),
            t.objectExpression([
                t.objectProperty(
                    t.identifier(key[index + 1]),
                    key2ObjCall(key, t, index + 1)
                )
            ])
        ]
    );
}複製程式碼

在上面我們呼叫了一個 indexKey2Str 方法,傳入 key 和 index,以字串返回物件屬性名。如,傳入 1 和 ['profile', 'name', 'value’],返回 ‘_state.profile.name’,讓我們來實現這個方法:

function indexKey2Str(index, key) {
    const str = ['_state'];
    for (let i = 0; i < index; i++) str.push(key[i]);
    return str.join('.')
}複製程式碼

現在,我們需要更改 JSXAttributeVisitor 方法,在 setStateCall 後面建立一個變數宣告 AST 用於在 onChange Handler 裡宣告 const _state = this.state:

const stateDeclaration = t.variableDeclaration(
    'const', [
        t.variableDeclarator(
            t.identifier('_state'),
            t.memberExpression(
                t.thisExpression(),
                t.identifier('state')
            )
        )
    ]
);複製程式碼

終於,最後一步!我們需要更改我們插入的 JSXAttribute AST 節點:

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

4. 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';

            // setState 呼叫 AST
            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)]
                )]
            );

            // const _state = this.state 宣告
            const stateDeclaration = t.variableDeclaration(
                'const', [
                    t.variableDeclarator(
                        t.identifier('_state'),
                        t.memberExpression(
                            t.thisExpression(),
                            t.identifier('state')
                        )
                    )
                ]
            );

            node.insertAfter(t.JSXAttribute(
                // 屬性名為 “onChange”
                t.jSXIdentifier('onChange'),
                // 屬性值為一個 JSX 表示式
                t.JSXExpressionContainer(
                    // 在表示式中使用箭頭函式
                    t.arrowFunctionExpression(
                        // 該函式接受引數 ‘e’
                        [t.identifier('e')],
                        // 函式體為一個包含剛剛建立的 ‘setState‘ 呼叫的語句塊
                        t.blockStatement([
                            // const _state = this.state 宣告
                            stateDeclaration,
                            // setState 呼叫
                            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;
}

// 把 key - value 字串轉換為 { key: value } 這樣的物件 AST 節點
function objPropStr2AST(key, value, t) {
    // 將 key 轉換為陣列形式
    key = key.split('.');
    return t.objectProperty(
        t.identifier(key[0]),
        key2ObjCall(key, value, t)
    );
}

function key2ObjCall(key, value, t, index) {
    // 初始化 index 為 0
    !index && (index = 0);
    // 若 key 只含有一個元素(key.length - 1 < index)
    // 或遍歷到 key 的最後一個元素(key.length - 1 === index)
    if (key.length - 1 <= index)
    // 直接返回 value 形式的 AST
        return objValueStr2AST(value, t);
    // 否則,返回 Object.assign({}, ...) 形式的 AST
    // 如:key 為 ['profile', 'name', 'value'],
    // value 為 e.target.value,index 為 0
    // 將返回 Object.assign({},
    //      indexKey2Str(0 + 1, ['profile', 'name', 'value']),
    //      { ['profile', 'name', 'value'][0 + 1]: key2ObjCall(['profile', 'name', 'value'], t, 0 + 1) }
    // ) 即 Object.assign({},
    //      this.state.profile,
    //      { name: key2ObjCall(['profile', 'name', 'value'], t, 1) }
    // ) 的 AST
    return t.callExpression(
        t.memberExpression(
            t.identifier('Object'),
            t.identifier('assign')
        ),
        [
            t.objectExpression([]),
            objValueStr2AST(indexKey2Str(index + 1, key), t),
            t.objectExpression([
                t.objectProperty(
                    t.identifier(key[index + 1]),
                    key2ObjCall(key, value, t, index + 1)
                )
            ])
        ]
    );
}

// 傳入 key 和 index,以字串返回物件屬性名
// 如,傳入 1 和 ['profile', 'name', 'value’],返回 ‘_state.profile.name’
function indexKey2Str(index, key) {
    const str = ['_state'];
    for (let i = 0; i < index; i++) str.push(key[i]);
    return str.join('.')
}

// 把類似 “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)
    )
}複製程式碼

5. So What‘s Next?

目前我們已經實現了達成了本節目標,但是還存在一些缺陷:我們手動新增的 onChange Handler 會被覆蓋掉,並且不能自定義雙向資料繫結的 attrName !

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

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

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

相關文章