手把手教你为 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!(~ ̄▽ ̄)~附:个人简历

相关文章
相关标签/搜索