天天看點

懂編譯真的可以為所欲為|不同前端架構下的代碼轉換

作者:閑魚技術-玉缜

背景

整個前端領域在這幾年迅速發展,前端架構也在不斷變化,各團隊選擇的解決方案都不太一緻,此外像小程式這種跨端場景和以往的研發方式也不太一樣。在日常開發中往往會因為投放平台的不一樣需要進行重新編碼。前段時間我們需要在淘寶頁面上投放閑魚元件,淘寶前端研發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/traverse

對解析後的AST進行處理。

@babel/traverse

能夠接收AST以及visitor兩個參數,AST是上一步parse得到的抽象文法樹,visitor提供通路不同節點的能力,當周遊到一個比對的節點時,能夠調用具體方法對于節點進行處理。

@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/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

來處理,對于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等,閑魚技術團隊也會持續在這塊做嘗試。

繼續閱讀