手把手教你为 React 添加双向数据绑定(一)

13,103 阅读9分钟

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!(~ ̄▽ ̄)~附:我的简历