# 探索-如何將單個vue檔案轉換為小程式所需的四個檔案(wxml, wxss, json, js)

前端首席鼓勵師發表於2019-08-27

探索-如何將單個vue檔案轉換為小程式所需的四個檔案(wxml, wxss, json, js)

最近在做需求的時候,經常是,同一個需求是在h5端實現一次,再在小程式實現一次,公司的h5端是用vue寫的,微信小程式則是小程式的原生語言,這就導致了很多很重複的勞動,雖然語言不同,但邏輯和設計都是一模一樣的。

而公司也沒想過花點時間統一一下,比如考慮使用一下mpvue之類的,所以,在本著偷懶的心態下,開始想著如何能避免重複性的工作,比如只需要寫一套程式碼。但是跟mpvue不一樣,不需要一個DSL工程化的東西,只需要轉換一下自己想轉換的檔案。

於是就有了這個想法,把所需要單個vue檔案的轉換為小程式原生語言所需要的四個檔案(wxml, wxss, json, js)

有點長,需要耐心讀一下。

預備知識

AST

在開始之前,需要了解一點AST(抽象語法樹)的相關知識。

比如JavaScript在執行之前,會經過詞法分析語法分析兩個步驟之後,得到一個抽象語法樹

比如下面這段程式碼

const foo = (item) => item.id
複製程式碼

得到的抽象語法樹如下圖。 這是在AST Explorer轉換得到的。

抽象語法樹
抽象語法樹

可以看到我們的js程式碼已經被轉換成一個json物件,這個json物件的描述了這段程式碼。 我們可以通過拿到這個json物件去進行樹形遍歷,從而把這一段js程式碼進行加工成一段我們想要的程式碼。比如可以把它轉換成一段ES5的程式碼

這裡就不描述具體步驟了,在後面的將script -> js中有具體描述。

這是js的部分。而在vue中,也是將template中的程式碼轉換成了AST結構的json檔案。後面我們需要使用到的postcss也是把less或者css檔案轉換成一個AST結構的json檔案,然後再加工,輸出成所需要的檔案。

vue-template-compiler

另外還有一個需要了解的是vue-template-compiler。 我們寫的單個vue檔案叫做SFC(Single File Components)。 vue-template-compiler 就是解析SFC檔案,提取每個語言塊,將單個VUE檔案的template、script、styles分別解析,得到一個json檔案。

具體步驟如下。

const fs = require('fs');
const compiler = require('vue-template-compiler')

// 讀取vue檔案
const vueFileContent = fs.readFileSync('./target/target.vue', 'utf8');
const sfc = compiler.parseComponent(vueFileContent)

複製程式碼

得到的sfc的json檔案的結構如下:

SFC
SFC

可以看到單個的vue檔案已經被解析成了三個部分,styles是一個陣列,因為在vue檔案中可以寫多個style標籤。 我們拿到解析後的json檔案之後,就可以正式開始了。

style -> wxss檔案

首先從最簡單的開始。將styles部分轉換成wxss檔案

因為在vue中我們使用的是less的語法,所以解析出來的styles中content的程式碼是less語法。但是小程式需要的是css的語法。所以我們需要將less轉換成css。另外在h5端我們less的單位是rem,所以還需要將rem轉換成rpx

將less換成css,將rem轉換成rpx的方案有很多,這裡採用的是postcss。另外還有gulp的方案也可以試試。

postcss已經有外掛可以將less轉換成css,rem轉換成rpx。所以我們直接用postcss以及postcss的外掛(postcss-less-engine, postcss-clean, postcss-rem2rpx)

具體步驟如下:

const compiler = require('vue-template-compiler')

const postcss = require('postcss');
const less = require('postcss-less-engine');
const clean = require('postcss-clean');
const rem2rpx = require('postcss-rem2rpx');

// 讀取vue檔案
const vueFileContent = fs.readFileSync('./target/target.vue', 'utf8');
const sfc = compiler.parseComponent(vueFileContent)

// 將styles陣列中的content合併成一個字串
const stylesSting = sfc.styles.reduce((pre, cur) => {
  return pre + cur.content.trim() + '\n'
}, '')

postcss([
  less({ strictMath: true }),
  rem2rpx({ rootFontSize: 50 }),
  clean()
])
.process(stylesSting, { parser: less.parser, from: 'res-styles-ast.less' })
.then((result) =>{
  fs.writeFileSync('./dist/res-style.wxss', result.css);
}, (err) =>{
  console.log(err);
});

複製程式碼

這裡有幾個需要注意的點。

1.由於styles是一個陣列,postcss需要處理的是一個字串,所以我們需要事先使用reduce把styles陣列中的content合併成一個字串。

2.在rem2rpx中,需要設定一個rootFontSize,這就需要根據自己的專案情況來。

3.如果style中有@import "./assets/styles/mixin.less";這樣的import程式碼,則需要把這個檔案copy到本地來。

4.這裡安裝的less包版本為"less": "2.7.1",版本3以上好像postcss-less-engine好像會失效。

script -> js檔案

babel

在進行這個步驟之前,先得講一個很重要的工具,就是 Babel

在將vue中的script部分轉換成小程式需要的js檔案過程中,最重要的就是Babel。

比如需要把created方法轉換為小程式的 onLoad 或者 元件中的 attached方法, 我們需要使用Babel把script部分的程式碼解析成一個AST抽象語法樹,再用Babel的api去轉換和修改這顆抽象語法樹,最後再生成所需要的程式碼。

bable在這裡就像一把帶有魔法的手術刀, 可以把現有程式碼轉換成任意程式碼。這一點有點lisp的感覺。

總結一下 Babel 的三個主要步驟是:

1.解析(parse)

利用 babylon 對原始碼字串進行解析並生成初始 AST 抽象語法樹

2.轉換(transform)

遍歷初始的 AST 抽象語法樹,babel 中有個babel-core ,它向外暴露出 babel.transform 介面。

3.生成(generate)

生成部分 babel 會利用 babel-generator 將轉換後的 AST 樹轉換為新的程式碼字串。

以上是理論,下面我們來實踐一下。還是那上面AST的箭頭函式來練手,將它變成一個ES5語法的函式。

const babel = require('babel-core')
const types = require('babel-types'); // types就是用來構造一個新的node節點的

const visitor = {
  ArrowFunctionExpression(path) { // 在visitor中攔截箭頭函式
    let params = path.node.params // 獲取函式引數
    const returnStatement = types.returnStatement(path.node.body) //構建一個return表示式
    const blockStatement = types.blockStatement([returnStatement]) // 構建一個blockStatement
    // babel-types的functionExpression構造成一個新的ES function語法的函式
    let func = types.functionExpression(null, params, blockStatement, false, false)
    //替換當前箭頭函式節點
    path.replaceWith(func)
  },
  VariableDeclaration(path) { // 在visitor中變數宣告
    path.node.kind = 'var'
  }
}

const scriptContent = 'const foo = (item) => item.id' // 原始碼
const result = babel.transform(scriptContent, {
  plugins: [
      { visitor }
  ]
})

console.log(result.code.trim())
// 結果為:
// var foo = function (item) {
//   return item.id;
// };
複製程式碼

以上只是簡單地講解了下babel執行原理,然後舉了一個簡單的例子,整個過程基本是這樣的,複雜的部分主要是對每一個需要攔截的節點進行處理。

如果想多瞭解一點可以參考一下這裡

Babel 外掛手冊

babel-types的使用手冊

處理import匯入檔案

現在可以正式開始了。

首先來看一下vue檔案中script的基本結構。

script的基本結構
script的基本結構

可以看到在 export default 中有 directives components 兩個屬性與import匯入的檔案有關

小程式中,directives不需要,需要刪除這個節點,同時也要刪除import進來的這個檔案;components也不需要,但是components 中的檔案需要放到小程式的json檔案中的usingComponents中。

所以下面先處理import部分:

// ......
const compiler = require('vue-template-compiler')

const babelrc = path.resolve('./.babelrc') //拿到本地的 babelrc 的配置

const vueFileContent = fs.readFileSync('./target/target.vue', 'utf8');
const sfc = compiler.parseComponent(vueFileContent)

const scriptContent = sfc.script.content // 拿到解析後的sfc中的script部分的原始碼
const babelOptions = { extends: babelrc, plugins: [{visitor: parseImportVisitor}] } // 配置一個 parseImportVisitor
const result = babel.transform(scriptContent, babelOptions)
fs.writeFileSync('./dist/res-js.js', result.code.trim());

複製程式碼

下面是在parseImportVisitor中攔截ImportSpecifier,ImportDefaultSpecifier具體處理,ImportDefaultSpecifier是從node_modules中匯入的檔案,ImportSpecifier是從自己寫的檔案。 要對兩個type進行相同的處理可以用一個管道符號 | ,像這樣ImportSpecifier|ImportDefaultSpecifier

const parseImportVisitor = {
  "ImportSpecifier|ImportDefaultSpecifier"(path) {
    const currentName = path.node.local.name // 獲取import進來的名稱,比如上圖中script的基本結構的 TransferDom, XDialog, stars

    const parentPath = path.findParent((path) => path.isImportDeclaration()); //找到當前節點的 ImportDeclaration 型別父節點
    const [ ExportDefaultDeclaration ] = parentPath.container.filter(item => item.type === 'ExportDefaultDeclaration') //通過父節點去找到 ExportDefaultDeclaration 型別的節點,就是export default中程式碼
    const { properties } = ExportDefaultDeclaration.declaration // 獲取 export default 中所有屬性

    const [ directivesProperty ] = properties.filter(item => item.key.name === 'directives')
    if (directivesProperty) {
      const { properties } = directivesProperty.value // directives中的屬性值
      // 遍歷 directives 中的屬性值
      properties.forEach(p => {
        const value = p.value.name || p.value.value
        if (value === currentName) {
          // 如果在 directives中找到了和當前import進來的名字一樣的,就需要把當前的節點刪除
          // 比如 import { TransferDom, XDialog } from 'vux'; 刪除後會變成 import { XDialog } from 'vux';
          path.remove() 
          if (!parentPath.node.specifiers.length) { //如果父節點為空,需要把父節點也完全刪除
            path.parentPath.remove()
          }
        }
      })
    }
    
    // 上面對 directives 的處理是直接刪除
    // 下面對 components 的處理則需要儲存起來,主要是儲存在 path.hub.file 中的 metadata 中
    const { metadata } = path.hub.file
    const [ componentsProperty ] = properties.filter(item => item.key.name === 'components')
    const usingComponents = {...metadata.usingComponents} //建立一個 usingComponents 物件
    if (componentsProperty) {
      const { properties } = componentsProperty.value // 獲取 components 中的屬性值
      // 遍歷 components 中的屬性值
      properties.forEach(p => {
        const value = p.value.name || p.value.value
        if (value === currentName) {
          // 如果在 components 中找到了和當前import進來的名字一樣的,就需要把當前的節點放入 usingComponents 中,然後刪除
          usingComponents[value] = parentPath.node.source.value
          path.remove()
          if (!parentPath.node.specifiers.length) { //如果父節點為空,需要把父節點也完全刪除
            path.parentPath.remove()
          }
        }
      })

    }
    metadata.usingComponents = usingComponents

  },
}
複製程式碼

上面的程式碼將 components 中的元件放到了 path.hub.file.metadata中,這樣可便於在最後拿到結果的時候把 usingComponents 直接寫到 json 檔案中。

// 生成json檔案
// ......
const result = babel.transform(scriptContent, babelOptions)

const jsonFile = {
  component: result.metadata.isComponent ? true : undefined,
  usingComponents: result.metadata.usingComponents // 取出 metadata中的usingComponents
}
fs.writeFileSync('./dist/res-json.json', circularJSON.stringify(jsonFile, null, 2)); // 寫到 json 檔案中
複製程式碼

處理ExportDefaultDeclaration

接下來處理 export default 中的程式碼。所以需要加一個 visitor


const scriptContent = sfc.script.content
const babelOptions = { extends: babelrc, plugins: [{visitor: parseImportVisitor}, { visitor: parseExportDefaultVisitor }] } // 這裡新增了 一個 parseExportDefaultVisitor的方法
const result = babel.transform(scriptContent, babelOptions)
fs.writeFileSync('./dist/res-js.js', result.code.trim());

複製程式碼

下面是 parseExportDefaultVisitor


const parseExportDefaultVisitor = {
  ExportDefaultDeclaration: function (path) { // 這裡攔截 ExportDefaultDeclaration
    // 這裡只處理 ExportDefaultDeclaration, 就是把export default 替換成 Page 或者 Component
    // 其它都交給 traverseJsVisitor 處理
    path.traverse(traverseJsVisitor)

    // 把export default 替換成 Page 或者 Component
    const { metadata } = path.hub.file
    const { declaration } = path.node
    const newArguments = [declaration]
    const name = metadata.isComponent ? 'Component' : 'Page'
    const newCallee = types.identifier(name)
    const newCallExpression = types.CallExpression(newCallee, newArguments)
    path.replaceWith(newCallExpression)
  }
}

複製程式碼

這裡需要注意的點是, export default 如何替換成 Page 或者 Component ,在 traverseJsVisitor 會判斷當前檔案是否是一個元件, 然後把isComponent儲存到metadata中,在ExportDefaultDeclaration就可以取到 isComponent 的值,從而決定是生成 Page還是Component。

而在小程式 Page({}) 或者Component({}) 是一個CallExpression, 所以需要構造一個CallExpression 來替換掉ExportDefaultDeclaration

處理props, created, mounted, destroyed

traverseJsVisitor來處理props, created, mounted, destroyed

props => properties

created => attached || onLoad

mounted => ready || onReady

destroyed => detached || onUnload

這裡只是做了一下簡單對映,如果onShow或者active其它生命週期或者其它屬性需要對映的話,以後慢慢改進。

// ......
const traverseJsVisitor = {
  
  Identifier(path) {
    const { metadata } = path.hub.file
    // 替換 props
    if (path.node.name === 'props') {
      metadata.isComponent = true //在這裡判斷當前檔案是否是一個元件

      const name = types.identifier('properties') //建立一個識別符號
      path.replaceWith(name) // 替換掉當前節點
    }
    
    if (path && path.node.name === 'created'){
      let name
      if (metadata.isComponent) { //判斷是否是元件
        name = types.identifier('attached') //建立一個識別符號
      } else {
        name = types.identifier('onLoad') //建立一個識別符號
      }
      path.replaceWith(name) // 替換掉當前節點
    }
    if (path && path.node.name === 'mounted'){
      let name
      if (metadata.isComponent) { //判斷是否是元件
        name = types.identifier('ready') //建立一個識別符號
      } else {
        name = types.identifier('onReady') //建立一個識別符號
      }
      path.replaceWith(name) // 替換掉當前節點
    }
    if (path && path.node.name === 'destroyed'){
      let name
      if (metadata.isComponent) { //判斷是否是元件
        name = types.identifier('detached') //建立一個識別符號
      } else {
        name = types.identifier('onUnload') //建立一個識別符號
      }
      path.replaceWith(name) // 替換掉當前節點
    }
  },
}
複製程式碼

處理 methods

往 traverseJsVisitor 中 再加入一個 ObjectProperty的攔截器,因為小程式中,元件檔案的方法都是寫在 methods 屬性中, 而在非元件檔案中 方法是直接和生命週期一個層級的,所以需要對 methods 進行處理

// ......
const traverseJsVisitor = {
  
  ObjectProperty: function (path) {
    const { metadata } = path.hub.file

     //是否是元件,如果是則不動, 如果不是,則用 methods 中的多個方法一起來替換掉當前的 methods節點
    if (path && path.node && path.node.key.name === 'methods' && !metadata.isComponent) {
      path.replaceWithMultiple(path.node.value.properties );
      return;
    }
    // 刪除 name directives components
    if (path.node.key.name === 'name' || path.node.key.name === 'directives' || path.node.key.name === 'components') {
      path.remove();
      return;
    }
  },
}
複製程式碼

將this.xxx 轉換成 this.data.xxx, 將 this.xx = xx 轉換成 this.setData

這裡其實是留了坑的,因為如果有多個this.xx = xx ,我這裡並沒有將他們合併到一個this.setData中,留點坑,以後填...

// ......
const traverseJsVisitor = {
  // 將this.xxx 轉換成 this.data.xxx
  MemberExpression(path) { // 攔截 MemberExpression
    const { object, property} = path.node
    if (object.type === 'ThisExpression' && property.name !== 'data') {
      const container = path.container
      if (container.type === 'CallExpression') {
        return;
      }
      if (property.name === '$router') {
        return;
      }
      // 將 this.xx 轉換成 this.data.xx
      const dataProperty = types.identifier('data')
      const newObject = types.memberExpression(object, dataProperty, false)
      const newMember = types.memberExpression(newObject, property, false)
      path.replaceWith(newMember)
    }
  },
  // 將 this.xx == xx 轉換成 this.setData
  AssignmentExpression(path) {  // 攔截 AssignmentExpression
    const leftNode = path.node.left
    const { object, property } = leftNode

    if (leftNode.type === 'MemberExpression' && leftNode.object.type === 'ThisExpression') {
      
      const properties = [types.objectProperty(property, path.node.right, false, false, null)]
      const arguments = [types.objectExpression(properties)]

      const object = types.thisExpression()
      const setDataProperty = types.identifier('setData')
      const callee = types.memberExpression(object, setDataProperty, false)

      const newCallExpression = types.CallExpression(callee, arguments)

      path.replaceWith(newCallExpression)
    }
  },
}
複製程式碼

處理 props中的default;把 data 函式轉換為 data 屬性;處理watch

// ......
const traverseJsVisitor = {
  ObjectMethod: function(path) {
    // 替換 props 中 的defalut
    if (path && path.node && path.node.key.name === 'default') {
      
      const parentPath = path.findParent((path) => path.isObjectProperty());
      const propsNode = parentPath.findParent((findParent) => findParent.isObjectExpression()).container
      if (propsNode.key.name === 'properties') {
        const key = types.identifier('value')
        const value = path.node.body.body[0].argument
        const newNode = types.objectProperty(key, value, false, false, null)
        path.replaceWith(newNode)
      }
    }
    if (path && path.node.key.name === 'data') {
      const key = types.identifier('data')
      const value = path.node.body.body[0].argument
      const newNode = types.objectProperty(key, value, false, false, null)

      path.replaceWith(newNode)
    }

    if (path && path.node && path.node.key.name === 'created') {
      const watchIndex = path.container.findIndex(item => item.key.name === 'watch')
      const watchItemPath = path.getSibling(watchIndex)
      if (watchItemPath) {
        const { value } = watchItemPath.node
        const arguments = [types.thisExpression(), value]
        const callee = types.identifier('Watch')
  
        const newCallExpression = types.CallExpression(callee, arguments)
        path.get('body').pushContainer('body', newCallExpression);
        watchItemPath.remove()
      }

      return;
    }
  },
}
複製程式碼

這裡有一點需要注意的是watch的處理,因為小程式沒有watch,所以我在小程式手寫了一個簡單watch

而且小程式中的watch需要放在onLoad 或者attached 生命週期中。

// 以下兩個函式實現watch 未實現deep功能
const Watch = (ctx, obj) => {
  Object.keys(obj).forEach((key) => {
    defineProperty(ctx.data, key, ctx.data[key], (value) => {
      obj[key].call(ctx, value);
    });
  });
};

const defineProperty = (data, key, val, fn) => {
  Object.defineProperty(data, key, {
    configurable: true,
    enumerable: true,
    get() {
      return val;
    },
    set(newVal) {
      if (newVal === val) return;
      if (fn) fn(newVal);
      val = newVal;
    },
  });
};
複製程式碼

所以只需要將vue中的watch轉換為這樣子的形式的寫法就行了。比如:

watch: {
  test(newVal, oldVal) {
    if (newVal === 1) {
      return 123;
    }
  }
},
複製程式碼

需要轉換成

Watch(this, {
  test(newVal, oldVal) {
    if (newVal === 1) {
      return 123;
    }
  }
})
複製程式碼

處理路由跳轉

處理路由跳轉有點複雜,需要將this.$router.push 或者 this.$router.replace 轉換為 wx.navigateTo 或者 wx.redirectTo

this.$routerparams 引數和 query 引數合併到一起

併合成一個字串url,比如:

this.$router.push({
  name: 'ProductList',
  params: { countryId: this.product.visa_country_id},
});
複製程式碼

需要轉換成

wx.navigateTo({
  url: `ProductList?countryId=${this.data.product.visa_country_id}`
});
複製程式碼

下面是具體轉換過程:

const traverseJsVisitor = {
    CallExpression(path) {
    // 處理 router 路由跳轉
    const { arguments, callee } = path.node
    
    const { object, property } = callee
    if (object && object.type === 'MemberExpression' && object.property.name === '$router') { //攔截到$router
      const properties = arguments[0].properties
      // vue裡面這裡只能獲取到 路由名稱,但是小程式需要的是page頁面的路徑,這裡就沒有做轉換了,直接拿了路由名稱充當小程式跳轉的url,到時候手動改
      const [ nameInfo ] = properties.filter(item => item.key.name === 'name')
      const [ paramsInfo ] = properties.filter(item => item.key.name === 'params') //拿到router的params引數
      const [ queryInfo ] = properties.filter(item => item.key.name === 'query') //拿到router的query引數

      // 把params和query的引數都合併到一個陣列當中去,然後 map 出 key 和 value
      const paramsValue = paramsInfo && paramsInfo.value
      const queryValue = queryInfo && queryInfo.value
      const paramsValueList = paramsValue && paramsValue.properties ? paramsValue.properties : []
      const queryValueList = queryValue && queryValue.properties ? queryValue.properties : []
      const paramsItems = [].concat(paramsValueList, queryValueList).map(item => ({ key: item.key, value: item.value }))

      const url = types.identifier('url') // 建立一個 叫做 url 的識別符號
      const routeName = nameInfo.value.value // 跳轉的路由名稱
      
      let expressions, quasis
      if (paramsItems.some(item => types.isCallExpression(item.value) || types.isMemberExpression(item.value))) {
        const expressionList = paramsItems.filter(item => types.isCallExpression(item.value) || types.isMemberExpression(item.value))
        const literalList = paramsItems.filter(item => types.isLiteral(item.value))

        // 把引數都合併成一個字串
        const templateElementLastItem = literalList.reduce((finalString, cur) => {
          return `${finalString}&${cur.key.name}=${cur.value.value}`
        }, '')

        const templateElementItemList = expressionList.map((item, index) => {
          if (index === 0) {
            return `${routeName}?${item.key.name}=`
          }
          return `&${item.key.name}=`
        })
        
        expressions = expressionList.map(item => item.value)
        quasis = [ ...templateElementItemList, templateElementLastItem ].map(item => {
          return types.templateElement({ raw: item, cooked: item }, false)
        })
      }
      const newTemplateLiteral = types.templateLiteral(quasis, expressions) //建立一個 templateLiteral
      const objectProperty = types.objectProperty(url, newTemplateLiteral, false, false, null)

      // 構造一個CallExpression
      let newPoperty
      if (property.name === 'replace') {
        newPoperty = types.identifier('redirectTo')
      }
      if (property.name === 'push') {
        newPoperty = types.identifier('navigateTo')
      }
      const newArguments = [types.objectExpression([objectProperty])]

      const newObject = types.identifier('wx')
      const newCallee = types.memberExpression(newObject, newPoperty, false)

      const newCallExpression = types.CallExpression(newCallee, newArguments)
      path.replaceWith(newCallExpression)
    }
  }
}
複製程式碼

轉換結果

這裡有一個例子。

轉換前的vue程式碼:

轉換前的vue程式碼
轉換前的vue程式碼

轉換後的小程式程式碼:

轉換後的小程式程式碼
轉換後的小程式程式碼

template -> wxml檔案

將 template 程式碼轉換為 AST樹

接下來是 將 template 部分 轉換為 wxml 檔案。這裡要先用 vue-template-compiler 的 compiler 將 template 程式碼轉換為 AST樹。

然後再實現一個解析這個 AST樹的函式 parseHtml

const compiler = require('vue-template-compiler')
// 讀取vue檔案
const vueFileContent = fs.readFileSync('./target/target.vue', 'utf8');
const sfc = compiler.parseComponent(vueFileContent)

const astTplRes = compiler.compile(sfc.template.content, {
  comments: true,
  preserveWhitespace: false,
  shouldDecodeNewlines: true
}).ast

const wxmlResult = parseHtml(astTplRes)

複製程式碼

解析出來的 AST樹的結果如下:

template AST樹
template AST樹

可以看出對我們有用的屬性就幾個

  • tag: 標籤
  • type: 型別,1-標籤;2-表示式節點(Mustache);3-純文字節點和comment節點
  • attrsMap: 標籤上的屬性集合
  • children: 元素的子元素,需要遞迴遍歷處理

還有一些特殊的屬性

  • classBinding、styleBinding: 動態繫結的class、style
  • if、elseif、else: 條件語句中的條件
  • ifConditions: 條件語句的else、elseif的節點資訊都放在ifConditions的block裡了
  • isComment:是否是註釋

給AST樹的每個節點加上開始標籤和結束標籤

拿到這個結構之後要怎麼轉換呢。

我的思路是,因為這是一個樹形結構,所以可以採用深度優先遍歷,廣度優先遍歷或者遞迴遍歷。

通過遍歷給每一個節點加上一個開始標籤 startTag,和一個 結束標籤 endTag。這裡採用遞迴遍歷。

程式碼如下:

const parseHtml = function(tagsTree) {
  return handleTagsTree(tagsTree)
}
複製程式碼

const handleTagsTree = function (topTreeNode) {

  // 為每一個節點生成開始標籤和結束標籤
  generateTag(topTreeNode)

};

// 遞迴生成 首尾標籤
const generateTag = function (node) {
  let children = node.children
  // 如果是if表示式 需要做如下處理
  if (children && children.length) {
    let ifChildren
    const ifChild = children.find(subNode => subNode.ifConditions && subNode.ifConditions.length)
    if (ifChild) {
      const ifChildIndex = children.findIndex(subNode => subNode.ifConditions && subNode.ifConditions.length)
      ifChildren = ifChild.ifConditions.map(item => item.block)
      delete ifChild.ifConditions
      children.splice(ifChildIndex, 1, ...ifChildren)
    }
    children.forEach(function (subNode) {
      generateTag(subNode)
    })
  }
  node.startTag = generateStartTag(node) // 生成開始標籤
  node.endTag = generateEndTag(node) //生成結束標籤
}
複製程式碼

下面是生成開始標籤的程式碼:


const generateStartTag = function (node) {
  let startTag
  const { tag, attrsMap, type, isComment, text } = node
  // 如果是註釋
  if (type === 3) {
    startTag = isComment ? `<!-- ${text} -->` : text
    return startTag;
  }
  // 如果是表示式節點
  if (type === 2) {
    startTag = text.trim()
    return startTag;
  }
  switch (tag) {
    case 'div':
    case 'p':
    case 'span':
    case 'em':
      startTag = handleTag({ tag: 'view', attrsMap });
      break;
    case 'img':
      startTag = handleTag({ tag: 'image', attrsMap });
      break;
    case 'template':
      startTag = handleTag({ tag: 'block', attrsMap });
      break;
    default:
      startTag = handleTag({ tag, attrsMap });
  }
  return startTag
}

const handleTag = function ({
  attrsMap,
  tag
}) {
  let stringExpression = ''
  if (attrsMap) {
    stringExpression = handleAttrsMap(attrsMap)
  }
  return `<${tag} ${stringExpression}>`
}


// 這個函式是處理 AttrsMap,把 AttrsMap 的所有值 合併成一個字串
const handleAttrsMap = function(attrsMap) {
  let stringExpression = ''
  stringExpression = Object.entries(attrsMap).map(([key, value]) => {
    // 替換 bind 的 :
    if (key.charAt(0) === ':') {
      return `${key.slice(1)}="{{${value}}}"`
    }
    // 統一做成 bindtap
    if (key === '@click') {
      const [ name, params ] = value.split('(')
      let paramsList
      let paramsString = ''
      if (params) {
        paramsList = params.slice(0, params.length - 1).replace(/\'|\"/g, '').split(',')
        paramsString = paramsList.reduce((all, cur) => {
          return `${all} data-${cur.trim()}="${cur.trim()}"`
        }, '')
      }
      return `bindtap="${name}"${paramsString}`
    }
    if (key === 'v-model') {
      return `value="{{${value}}}"`
    }
    if (key === 'v-if') {
      return `wx:if="{{${value}}}"`
    }
    if (key === 'v-else-if') {
      return `wx:elif="{{${value}}}"`
    }
    if (key === 'v-else') {
      return `wx:else`
    }
    if (key === 'v-for') {
      const [ params, list ] = value.split('in ')
      
      const paramsList = params.replace(/\(|\)/g, '').split(',')
      const [item, index] = paramsList
      const indexString = index ? ` wx:for-index="${index.trim()}"` : ''
      return `wx:for="{{${list.trim()}}}" wx:for-item="${item.trim()}"${indexString}`
    }
    return `${key}="${value}"`
  }).join(' ')
  return stringExpression
}

複製程式碼

結束標籤很簡單。 這裡是生成結束標籤的程式碼:


const generateEndTag = function (node) {
  let endTag
  const { tag, attrsMap, type, isComment, text } = node
  // 如果是表示式節點或者註釋
  if (type === 3 || type === 2) {
    endTag = ''
    return endTag;
  }
  switch (tag) {
    case 'div':
    case 'p':
    case 'span':
    case 'em':
      endTag = '</view>'
      break;
    case 'img':
      endTag = '</image>'
      break;
    case 'template':
      endTag = '</block>'
      break;
    default:
      endTag = `</${tag}>`
  }
  return endTag
}

複製程式碼

將開始標籤和結束標籤合併

拿到開始標籤和結束標籤之後,接下來就是重組程式碼了。


const handleTagsTree = function (topTreeNode) {

  // 為每一個節點生成開始標籤和結束標籤
  generateTag(topTreeNode)

  return createWxml(topTreeNode)
};

複製程式碼

 // 遞迴生成 所需要的文字
const createWxml = function(node) {
  let templateString = '';
  const { startTag, endTag, children } = node
  let childrenString = ''
  if (children && children.length) {
    childrenString = children.reduce((allString, curentChild) => {
      const curentChildString = createWxml(curentChild)
      return `${allString}\n${curentChildString}\n`
    }, '')
  }
  return `${startTag}${childrenString}${endTag}`
}

複製程式碼

轉換結果

轉換完的格式還是需要自己調整一下。

轉換前的vue程式碼:

轉換前的template程式碼
轉換前的template程式碼

轉換後的小程式程式碼:

轉換後的小程式wxml程式碼
轉換後的小程式wxml程式碼

總結

留下的坑其實還蠻多,以後慢慢完善。做這個不是想做一個工程化的東西,工程化的東西已經有mpvue等框架了。

就是想偷點懶...哈哈。歡迎一起交流。

完整程式碼在 ast-h5-wp

相關文章