不同前端框架下的程式碼轉換

閒魚技術發表於2019-03-20

背景

整個前端領域在這幾年迅速發展,前端框架也在不斷變化,各團隊選擇的解決方案都不太一致,此外像小程式這種跨端場景和以往的研發方式也不太一樣。在日常開發中往往會因為投放平臺的不一樣需要進行重新編碼。

前段時間我們需要在淘寶頁面上投放閒魚元件,淘寶前端研發DSL(React/Rax)和閒魚前端研發DSL(Vue/Weex)不一致,有沒有辦法直接將已有的Vue元件轉化為React元件呢,閒魚技術團隊從程式碼編譯的角度提出了一種解決方案。

編譯器的工作原理

日常工作中我們接觸最多的編譯器就是Babel,Babel可以將最新的Javascript語法編譯成當前瀏覽器相容的JavaScript程式碼,Babel工作流程分為三個步驟,由下圖所示:不同前端框架下的程式碼轉換0抽象語法樹AST是什麼

在電腦科學中,抽象語法樹(Abstract Syntax Tree,AST),或簡稱語法樹(Syntax tree),是原始碼語法結構的一種抽象表示。它以樹狀的形式表現程式語言的語法結構,樹上的每個節點都表示原始碼中的一種結構,詳見維基百科。這裡以 consta=1轉成 vara=1操作為例看下Babel是如何工作的。

0將程式碼解析成抽象語法樹

Babel提供了@babel/parser將程式碼解析成AST。

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

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

0經過遍歷和分析轉換對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,將 consta=1解析得到的AST裡的 declarations直接設定給 declarations

  1. const traverse = require('@babel/traverse').default;

  2. const t = require('@babel/types');

  3. traverse(ast, {

  4. VariableDeclaration: function(path) { //識別在變數宣告的時候

  5. if (path.node.kind === 'const') { //只有const的時候才處理

  6. path.replaceWith(

  7. t.variableDeclaration('var', path.node.declarations) //替換成var

  8. );

  9. }

  10. path.skip();

  11. }

  12. });

0將最終轉換的AST重新生成程式碼

Babel提供了@babel/generator將AST再還原成程式碼。

  1. const generate = require('@babel/generator').default;

  2. let code = generate(ast).code;

Vue和React的異同

我們來看下Vue和React的異同,如果需要做轉化需要有哪些處理,Vue的結構分為style、script、template三部分

0style

樣式這部分不用去做特別的轉化,Web下都是通用的

0script

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需要額外做標記。

0template


針對文字節點和元素節點處理不一致,文字節點需要對內容 {{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程式碼為例

  1. <template>

  2. <div>

  3. <p class="title" @click="handleClick">{{title}}</p>

  4. <p class="name" v-if="show">{{name}}</p>

  5. </div>

  6. </template>

  7. <style>

  8. .title {font-size: 28px;color: #333;}

  9. .name {font-size: 32px;color: #999;}

  10. </style>

  11. <script>

  12. export default {

  13. props: {

  14. title: {

  15. type: String,

  16. default: "title"

  17. }

  18. },

  19. data() {

  20. return {

  21. show: true,

  22. name: "name"

  23. };

  24. },

  25. mounted() {

  26. console.log(this.name);

  27. },

  28. methods: {

  29. handleClick() {}

  30. }

  31. };

  32. </script>

我們需要先解析Vue程式碼變成AST值。這裡使用了Vue官方的 vue-template-compiler來分別提取Vue元件程式碼裡的template、style、script,考慮其他DSL的通用性後續可以遷移到更加適用的html解析模組,例如 parse5等。通過 vue-template-compilerparseComponent方法得到了分離的template、style、script。 style不用額外解析成AST了,可以直接用於React程式碼。template可以通過 vue-template-compilercompile方法轉化為AST值。script用 @babel/parser來處理,對於script的解析不僅僅需要獲得整個script的AST值,還需要分別將 datapropscomputedcomponentsmethods引數提取出來,以便後面在轉化的時候區分具體屬於哪個屬性。以 data的處理為例:

  1. const traverse = require('@babel/traverse').default;

  2. const t = require('@babel/types');

  3. const analysis = (body, data, isObject) => {

  4. data._statements = [].concat(body); // 整個表示式的AST值

  5. let propNodes = [];

  6. if (isObject) {

  7. propNodes = body;

  8. } else {

  9. body.forEach(child => {

  10. if (t.isReturnStatement(child)) { // return表示式的時候

  11. propNodes = child.argument.properties;

  12. data._statements = [].concat(child.argument.properties); // 整個表示式的AST值

  13. }

  14. });

  15. }

  16. propNodes.forEach(propNode => {

  17. data[propNode.key.name] = propNode; // 對data裡的值進行提取,用於後續的屬性取值

  18. });

  19. };

  20. const parse = (ast) => {

  21. let data = {

  22. };

  23. traverse(ast, {

  24. ObjectMethod(path) {

  25. /*

  26. 物件方法

  27. data() {return {}}

  28. */

  29. const parent = path.parentPath.parent;

  30. const name = path.node.key.name;

  31. if (parent && t.isExportDefaultDeclaration(parent)) {

  32. if (name === 'data') {

  33. const body = path.node.body.body;

  34. analysis(body, data);

  35. path.stop();

  36. }

  37. }

  38. },

  39. ObjectProperty(path) {

  40. /*

  41. 物件屬性,箭頭函式

  42. data: () => {return {}}

  43. data: () => ({})

  44. */

  45. const parent = path.parentPath.parent;

  46. const name = path.node.key.name;

  47. if (parent && t.isExportDefaultDeclaration(parent)) {

  48. if (name === 'data') {

  49. const node = path.node.value;

  50. if (t.isArrowFunctionExpression(node)) {

  51. /*

  52. 箭頭函式

  53. () => {return {}}

  54. () => {}

  55. */

  56. if (node.body.body) {

  57. analysis(node.body.body, data);

  58. } else if (node.body.properties) {

  59. analysis(node.body.properties, data, true);

  60. }

  61. }

  62. path.stop();

  63. }

  64. }

  65. }

  66. });

  67. /*

  68. 最終得到的結果

  69. {

  70. _statements, //data解析AST值

  71. list //data.list解析AST值

  72. }

  73. */

  74. return data;

  75. };

  76. module.exports = parse;

最終處理之後得到這樣一個結構:

  1. app: {

  2. script: {

  3. ast,

  4. components,

  5. computed,

  6. data: {

  7. _statements, //data解析AST值

  8. list //data.list解析AST值

  9. },

  10. props,

  11. methods

  12. },

  13. style, // style字串值

  14. template: {

  15. ast // template解析AST值

  16. }

  17. }

React程式碼的轉化

最終轉化的React程式碼會包含兩個檔案(css和js檔案)。用style字串直接生成index.css檔案,index.js檔案結構如下圖, transform指將Vue AST值轉化成React程式碼的偽函式。

  1. import { createElement, Component, PropTypes } from 'React';

  2. import './index.css';

  3. export default class Mod extends Component {

  4. ${transform(Vue.script)}

  5. render() {

  6. ${transform(Vue.template)}

  7. }

  8. }

script AST值的轉化不一一說明,思路基本都一致,這裡主要針對Vue data繼續說明如何轉化成React state,最終解析Vue data得到的是 {_statements:AST}這樣的一個結構,轉化的時候只需要執行如下程式碼

  1. const t = require('@babel/types');

  2. module.exports = (app) => {

  3. if (app.script.data && app.script.data._statements) {

  4. // classProperty 類屬性 identifier 識別符號 objectExpression 物件表示式

  5. return t.classProperty(t.identifier('state'), t.objectExpression(app.script.data._statements));

  6. } else {

  7. return null;

  8. }

  9. };

針對template AST值的轉化,我們先看下Vue template AST的結構:

  1. {

  2. tag: 'div',

  3. children: [{

  4. tag: 'text'

  5. },{

  6. tag: 'div',

  7. children: [……]

  8. }]

  9. }

轉化的過程就是遍歷上面的結構針對每一個節點生成渲染程式碼,這裡以 v-if的處理為例說明下節點屬性的處理,實際程式碼中會有兩種情況:

  • 不包含 v-else的情況, <divv-if="xxx"/>轉化為 {xxx&&<div/>}

  • 包含 v-else的情況, <divv-if="xxx"/><textv-else/>轉化為 {xxx?<div/>:<text/>}

經過 vue-template-compiler解析後的template AST值裡會包含 ifConditions屬性值,如果 ifConditions的長度大於1,表明存在 v-else,具體處理的邏輯如下:

  1. if (ast.ifConditions && ast.ifConditions.length > 1) {

  2. // 包含v-else的情況

  3. let leftBlock = ast.ifConditions[0].block;

  4. let rightBlock = ast.ifConditions[1].block;

  5. let left = generatorJSXElement(leftBlock); //轉化成JSX元素

  6. let right = generatorJSXElement(rightBlock); //轉化成JSX元素

  7. child = t.jSXExpressionContainer( //JSX表示式容器

  8. // 轉化成條件表示式

  9. t.conditionalExpression(

  10. parseExpression(value),

  11. left,

  12. right

  13. )

  14. );

  15. } else {

  16. // 不包含v-else的情況

  17. child = t.jSXExpressionContainer( //JSX表示式容器

  18. // 轉化成邏輯表示式

  19. t.logicalExpression('&&', parseExpression(value), t.jsxElement(

  20. t.jSXOpeningElement(

  21. t.jSXIdentifier(tag), attrs),

  22. t.jSXClosingElement(t.jSXIdentifier(tag)),

  23. children

  24. ))

  25. );

  26. }

template裡引用的屬性/方法提取,在AST值表現上都是識別符號( Identifier),可以在traverse的時候將 Identifier提取出來。這裡用了一個比較取巧的方法,在template AST值轉化的時候我們不對這些識別符號做判斷,而在最終轉化的時候在render return之前插入一段引用。以下面的程式碼為例

  1. <text class="title" @click="handleClick">{{title}}</text>

  2. <text class="list-length">list length:{{length}}</text>

  3. <div v-for="(item, index) in list" class="list-item" :key="`item-${index}`">

  4. <text class="item-text" @click="handleClick" v-if="item.show">{{item.text}}</text>

  5. </div>

我們能解析出template裡的屬性/方法以下面這樣一個結構表示:

  1. {

  2. title,

  3. handleClick,

  4. length,

  5. list,

  6. item,

  7. index

  8. }

在轉化程式碼的時候將它與app.script.data、app.script.props、app.script.computed和app.script.computed分別對比判斷,能得到title是props、list是state、handleClick是methods,length是computed,最終我們在return前面插入的程式碼如下:

  1. let {title} = this.props;

  2. let {state} = this.state;

  3. let {handleClick} = this;

  4. let length = this.length();

最終示例程式碼的轉化結果

  1. import { createElement, Component, PropTypes } from 'React';

  2. export default class Mod extends Component {

  3. static defaultProps = {

  4. title: 'title'

  5. }

  6. static propTypes = {

  7. title: PropTypes.string

  8. }

  9. state = {

  10. show: true,

  11. name: 'name'

  12. }

  13. componentDidMount() {

  14. let {name} = this.state;

  15. console.log(name);

  16. }

  17. handleClick() {}

  18. render() {

  19. let {title} = this.props;

  20. let {show, name} = this.state;

  21. let {handleClick} = this;

  22. return (

  23. <div>

  24. <p className="title" onClick={handleClick}>{title}</p>

  25. {show && (

  26. <p className="name">{name}</p>

  27. )}

  28. </div>

  29. );

  30. }

  31. }

總結與展望

本文從Vue元件轉化為React元件的具體案例講述了一種通過程式碼編譯的方式進行不同前端框架程式碼的轉化的思路。我們在生產環境中已經將十多個之前的Vue元件直接轉成React元件,但是實際使用過程中研發同學的編碼習慣差別也比較大,需要處理很多特殊情況。

這套思路也可以用於小程式互轉等場景,減少編碼的重複勞動,但是在這類跨端的非保準Web場景需要考慮更多,例如小程式環境特有的元件以及API等,閒魚技術團隊也會持續在這塊做嘗試。

相關文章