背景

整个前端领域在这几年迅速发展,前端框架也在不断变化,各团队选择的解决方案都不太一致,此外像小程序这种跨端场景和以往的研发方式也不太一样。在日常开发中往往会因为投放平台的不一样需要进行重新编码。前段时间我们需要在淘宝页面上投放闲鱼组件,淘宝前端研发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将代码解析成AST。

const parse = require('@babel/parser').parse;

const ast = parse('const a = 1');

经过遍历和分析转换(transform)对AST进行处理

Babel提供了@babel/traverse对解析后的AST进行处理。@babel/traverse能够接收AST以及visitor两个参数,AST是上一步parse得到的抽象语法树,visitor提供访问不同节点的能力,当遍历到一个匹配的节点时,能够调用具体方法对于节点进行处理。@babel/types用于定义AST节点,在visitor里做节点处理的时候用于替换等操作。在这个例子中,我们遍历上一步得到的AST,在匹配到变量声明(VariableDeclaration)的时候判断是否const操作时进行替换成vart.variableDeclaration(kind, declarations)接收两个参数kinddeclarations,这里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将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需要转化为stateprops需要转化为defaultPropspropTypescomponents的引用需要提取到组件声明以外,methods里的方法需要提取到组件的属性上。还有一些属性比较特殊,比如computed,React里是没有这个概念的,我们可以考虑将computed里的值转化成函数方法,上面示例中的length,可以转化为length()这样的函数调用,在React的render()方法以及其他方法中调用。
Vue的生命周期和React的生命周期有些差别,但是基本都能映射上,下面列举了部分生命周期的映射

  • created -> componentWillMount
  • mounted -> componentDidMount
  • updated -> componentDidUpdate
  • beforeDestroy -> componentWillUnmount
    在Vue内函数的属性取值是通过this.xxx的方式,而在Rax内需要判断是否stateprops还是具体的方法,会转化成this.statethis.props或者this.xxx的方式。因此在对Vue特殊属性的处理中,我们对于datapropsmethods需要额外做标记。

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组件代码里的templatestylescript,考虑其他DSL的通用性后续可以迁移到更加适用的html解析模块,例如parse5等。通过require('vue-template-compiler').parseComponent得到了分离的templatestylescriptstyle不用额外解析成AST了,可以直接用于React代码。template可以通过require('vue-template-compiler').compile转化为AST值。script@babel/parser来处理,对于script的解析不仅仅需要获得整个script的AST值,还需要分别将datapropscomputedcomponentsmethods等参数提取出来,以便后面在转化的时候区分具体属于哪个属性。以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等,闲鱼技术团队也会持续在这块做尝试。



本文作者:闲鱼技术-玉缜

阅读原文

04-04 12:27