背景
整个前端领域在这几年迅速发展, 前端框架也在不断变化, 各团队选择的解决方案都不太一致, 此外像小程序这种跨端场景和以往的研发方式也不太一样. 在日常开发中往往会因为投放平台的不一样需要进行重新编码. 前段时间我们需要在淘宝页面上投放闲鱼组件, 淘宝前端研发 DSL 主要是 React(Rax), 而闲鱼前端之前研发 DSL 主要是 vue(Weex), 一般这种情况我们都是重新用 React 开发, 有没有办法一键将已有的 Vue 组件转化为 React 组件呢, 闲鱼技术团队从代码编译的角度提出了一种解决方案.
编译器是如何工作的
日常工作中我们接触最多的编译器就是 Babel,Babel 可以将最新的 JavaScript 语法编译成当前浏览器兼容的 JavaScript 代码, Babel 工作流程分为三个步骤, 由下图所示:
抽象语法树 AST 是什么
在计算机科学中, 抽象语法树(Abstract Syntax Tree,AST), 或简称语法树(Syntax tree), 是源代码语法结构的一种抽象表示. 它以树状的形式表现编程语言的语法结构, 树上的每个节点都表示源代码中的一种结构, 详见维基百科. 这里以 const a = 1 转成 var a = 1 操作为例看下 Babel 是如何工作的.
将代码解析 (parse) 成抽象语法树 AST
Babel 提供了 @babel/parser https://babeljs.io/docs/en/next/babel-parser 将代码解析成 AST.
- const parse = require('@babel/parser').parse;
- const ast = parse('const a = 1');
经过遍历和分析转换 (transform) 对 AST 进行处理
Babel 提供了 @babel/traverse https://babeljs.io/docs/en/next/babel-traverse 对解析后的 AST 进行处理.@babel/traverse 能够接收 AST 以及 visitor 两个参数, AST 是上一步 parse 得到的抽象语法树, visitor 提供访问不同节点的能力, 当遍历到一个匹配的节点时, 能够调用具体方法对于节点进行处理.@babel/types https://babeljs.io/docs/en/next/babel-types 用于定义 AST 节点, 在 visitor 里做节点处理的时候用于替换等操作. 在这个例子中, 我们遍历上一步得到的 AST, 在匹配到变量声明 (VariableDeclaration) 的时候判断是否 const 操作时进行替换成 var.t.variableDeclaration(kind, declarations)接收两个参数 kind 和 declarations, 这里 kind 设为 var, 将 const a = 1 解析得到的 AST 里的 declarations 直接设置给 declarations.
- const traverse = require('@babel/traverse').default;
- const t = require('@babel/types');
- traverse(ast, {
- VariableDeclaration: function(path) { // 识别在变量声明的时候
- if (path.node.kind === 'const') { // 只有 const 的时候才处理
- path.replaceWith(
- t.variableDeclaration('var', path.node.declarations) // 替换成 var
- );
- }
- path.skip();
- }
- });
将最终转换的 AST 重新生成 (generate) 代码
Babel 提供了 @babel/generator https://babeljs.io/docs/en/next/babel-generator 将 AST 再还原成代码.
- const generate = require('@babel/generator').default;
- let code = generate(ast).code;
Vue 和 React 的异同
我们来看下 Vue 和 React 的异同, 如果需要做转化需要有哪些处理, Vue 的结构分为 style,script,template 三部分
style
样式这部分不用去做特别的转化, web 下都是通用的
script
Vue 某些属性的名称和 React 不太一致, 但是功能上是相似的. 例如 data 需要转化为 state,props 需要转化为 defaultProps 和 propTypes,components 的引用需要提取到组件声明以外, methods 里的方法需要提取到组件的属性上. 还有一些属性比较特殊, 比如 computed,React 里是没有这个概念的, 我们可以考虑将 computed 里的值转化成函数方法, 上面示例中的 length, 可以转化为 length()这样的函数调用, 在 React 的 render()方法以及其他方法中调用.
Vue 的生命周期和 React 的生命周期有些差别, 但是基本都能映射上, 下面列举了部分生命周期的映射
- created -> componentWillMount
- mounted -> componentDidMount
- updated -> componentDidUpdate
- beforeDestroy ->
- componentWillUnmount
在 Vue 内函数的属性取值是通过 this.xxx 的方式, 而在 Rax 内需要判断是否 state,props 还是具体的方法, 会转化成 this.state,this.props 或者 this.xxx 的方式. 因此在对 Vue 特殊属性的处理中, 我们对于 data,props,methods 需要额外做标记.
template
针对文本节点和元素节点处理不一致, 文本节点需要对内容 {{title}} 进行处理, 变为{title}
.
Vue 里有大量的增强指令, 转化成 React 需要额外做处理, 下面列举了部分指令的处理方式
事件绑定的处理,@click -> onClick
逻辑判断的处理, v-if="item.show" ->
{item.show && ......}
动态参数的处理,:title="title" -> title={title}
还有一些是正常的 html 属性, 但是 React 下是不一样的, 例如 style -> className.
指令里和 model 里的属性值需要特殊处理, 这部分的逻辑其实和 script 里一样, 例如需要 {{title}} 转变成{this.props.title}
Vue 代码的解析
以下面的 Vue 代码为例
- <template>
- <div>
- <p class="title" @click="handleClick">{{title}}</p>
- <p class="name" v-if="show">{{name}}</p>
- </div>
- </template>
- <style>
- .title {font-size: 28px;color: #333;}
- .name {font-size: 32px;color: #999;}
- </style>
- <script>
- export default {
- props: {
- title: {
- type: String,
- default: "title"
- }
- },
- data() {
- return {
- show: true,
- name: "name"
- };
- },
- mounted() {
- console.log(this.name);
- },
- methods: {
- handleClick() {}
- }
- };
- </script>
我们需要先解析 Vue 代码变成 AST 值. 这里使用了 Vue 官方的 vue-template-compiler 来分别提取 Vue 组件代码里的 template,style,script, 考虑其他 DSL 的通用性后续可以迁移到更加适用的 HTML 解析模块, 例如 parse5 等. 通过 require('vue-template-compiler').parseComponent 得到了分离的 template,style,script.style 不用额外解析成 AST 了, 可以直接用于 React 代码. template 可以通过 require('vue-template-compiler').compile 转化为 AST 值. script 用 @babel/parser https://babeljs.io/docs/en/next/babel-parser 来处理, 对于 script 的解析不仅仅需要获得整个 script 的 AST 值, 还需要分别将 data,props,computed,components,methods 等参数提取出来, 以便后面在转化的时候区分具体属于哪个属性. 以 data 的处理为例:
- const traverse = require('@babel/traverse').default;
- const t = require('@babel/types');
- const analysis = (body, data, isObject) => {
- data._statements = [].concat(body); // 整个表达式的 AST 值
- let propNodes = [];
- if (isObject) {
- propNodes = body;
- } else {
- body.forEach(child => {
- if (t.isReturnStatement(child)) { // return 表达式的时候
- propNodes = child.argument.properties;
- data._statements = [].concat(child.argument.properties); // 整个表达式的 AST 值
- }
- });
- }
- propNodes.forEach(propNode => {
- data[propNode.key.name] = propNode; // 对 data 里的值进行提取, 用于后续的属性取值
- });
- };
- const parse = (ast) => {
- let data = {
- };
- traverse(ast, {
- ObjectMethod(path) {
- /*
- 对象方法
- data() {return {}}
- */
- const parent = path.parentPath.parent;
- const name = path.node.key.name;
- if (parent && t.isExportDefaultDeclaration(parent)) {
- if (name === 'data') {
- const body = path.node.body.body;
- analysis(body, data);
- path.stop();
- }
- }
- },
- ObjectProperty(path) {
- /*
- 对象属性, 箭头函数
- data: () => {return {}}
- data: () => ({})
- */
- const parent = path.parentPath.parent;
- const name = path.node.key.name;
- if (parent && t.isExportDefaultDeclaration(parent)) {
- if (name === 'data') {
- const node = path.node.value;
- if (t.isArrowFunctionExpression(node)) {
- /*
- 箭头函数
- () => {return {}}
- () => {}
- */
- if (node.body.body) {
- analysis(node.body.body, data);
- } else if (node.body.properties) {
- analysis(node.body.properties, data, true);
- }
- }
- path.stop();
- }
- }
- }
- });
- /*
- 最终得到的结果
- {
- _statements, //data 解析 AST 值
- list //data.list 解析 AST 值
- }
- */
- return data;
- };
- module.exports = parse;
最终处理之后得到这样一个结构:
- App: {
- script: {
- ast,
- components,
- computed,
- data: {
- _statements, //data 解析 AST 值
- list //data.list 解析 AST 值
- },
- props,
- methods
- },
- style, // style 字符串值
- template: {
- ast // template 解析 AST 值
- }
- }
React 代码的转化
最终转化的 React 代码会包含两个文件(CSS 和 JS 文件). 用 style 字符串直接生成 index.CSS 文件, index.JS 文件结构如下图, transform 指将 Vue AST 值转化成 React 代码的伪函数.
- import { createElement, Component, PropTypes } from 'React';
- import './index.css';
- export default class Mod extends Component {
- ${transform(Vue.script)}
- render() {
- ${transform(Vue.template)}
- }
- }
script AST 值的转化不一一说明, 思路基本都一致, 这里主要针对 Vue data 继续说明如何转化成 React state, 最终解析 Vue data 得到的是 {_statements: AST} 这样的一个结构, 转化的时候只需要执行如下代码
- const t = require('@babel/types');
- module.exports = (App) => {
- if (App.script.data && App.script.data._statements) {
- // classProperty 类属性 identifier 标识符 objectExpression 对象表达式
- return t.classProperty(t.identifier('state'), t.objectExpression(App.script.data._statements));
- } else {
- return null;
- }
- };
针对 template AST 值的转化, 我们先看下 Vue template AST 的结构:
- {
- tag: 'div',
- children: [{
- tag: 'text'
- },{
- tag: 'div',
- children: [......]
- }]
- }
转化的过程就是遍历上面的结构针对每一个节点生成渲染代码, 这里以 v-if 的处理为例说明下节点属性的处理, 实际代码中会有两种情况:
不包含 v-else 的情况,<div v-if="xxx"/>转化为{ xxx && <div /> }
包含 v-else 的情况,
<div v-if="xxx"/><text v-else/>
转化为
{ xxx ? <div />: <text /> }
经过 vue-template-compiler 解析后的 template AST 值里会包含 ifConditions 属性值, 如果 ifConditions 的长度大于 1, 表明存在 v-else, 具体处理的逻辑如下:
- if (ast.ifConditions && ast.ifConditions.length> 1) {
- // 包含 v-else 的情况
- let leftBlock = ast.ifConditions[0].block;
- let rightBlock = ast.ifConditions[1].block;
- let left = generatorJSXElement(leftBlock); // 转化成 JSX 元素
- let right = generatorJSXElement(rightBlock); // 转化成 JSX 元素
- child = t.jSXExpressionContainer( //JSX 表达式容器
- // 转化成条件表达式
- t.conditionalExpression(
- parseExpression(value),
- left,
- right
- )
- );
- } else {
- // 不包含 v-else 的情况
- child = t.jSXExpressionContainer( //JSX 表达式容器
- // 转化成逻辑表达式
- t.logicalExpression('&&', parseExpression(value), t.jsxElement(
- t.jSXOpeningElement(
- t.jSXIdentifier(tag), attrs),
- t.jSXClosingElement(t.jSXIdentifier(tag)),
- children
- ))
- );
- }
template 里引用的属性 / 方法提取, 在 AST 值表现上都是标识符(Identifier), 可以在 traverse 的时候将 Identifier 提取出来. 这里用了一个比较取巧的方法, 在 template AST 值转化的时候我们不对这些标识符做判断, 而在最终转化的时候在 render return 之前插入一段引用. 以下面的代码为例
- <text class="title" @click="handleClick">{{title}}</text>
- <text class="list-length">list length:{{length}}</text>
- <div v-for="(item, index) in list" class="list-item" :key="`item-${index}`">
- <text class="item-text" @click="handleClick" v-if="item.show">{{item.text}}</text>
- </div>
我们能解析出 template 里的属性 / 方法以下面这样一个结构表示:
- {
- title,
- handleClick,
- length,
- list,
- item,
- index
- }
在转化代码的时候将它与 App.script.data,App.script.props,App.script.computed 和 App.script.computed 分别对比判断, 能得到 title 是 props,list 是 state,handleClick 是 methods,length 是 computed, 最终我们在 return 前面插入的代码如下:
- let {
- title
- } = this.props;
- let {
- state
- } = this.state;
- let {
- handleClick
- } = this;
- let length = this.length();
最终示例代码的转化结果
- import { createElement, Component, PropTypes } from 'React';
- export default class Mod extends Component {
- static defaultProps = {
- title: 'title'
- }
- static propTypes = {
- title: PropTypes.string
- }
- state = {
- show: true,
- name: 'name'
- }
- componentDidMount() {
- let {name} = this.state;
- console.log(name);
- }
- handleClick() {}
- render() {
- let {title} = this.props;
- let {show, name} = this.state;
- let {handleClick} = this;
- return (
- <div>
- <p className="title" onClick={handleClick}>{title}</p>
- {show && (
- <p className="name">{name}</p>
- )}
- </div>
- );
- }
- }
总结与展望
本文从 Vue 组件转化为 React 组件的具体案例讲述了一种通过代码编译的方式进行不同前端框架代码的转化的思路. 我们在生产环境中已经将十多个之前的 Vue 组件直接转成 React 组件, 但是实际使用过程中研发同学的编码习惯差别也比较大, 需要处理很多特殊情况. 这套思路也可以用于小程序互转等场景, 减少编码的重复劳动, 但是在这类跨端的非保准 Web 场景需要考虑更多, 例如小程序环境特有的组件以及 API 等, 闲鱼技术团队也会持续在这块做尝试.
来源: https://segmentfault.com/a/1190000018753707