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