# 探索-如何將單個vue文件轉換爲小程序所需的四個文件(wxml, wxss, json, js)

探索-如何將單個vue文件轉換爲小程序所需的四個文件(wxml, wxss, json, js)

最近在作需求的時候,常常是,同一個需求是在h5端實現一次,再在小程序實現一次,公司的h5端是用vue寫的,微信小程序則是小程序的原生語言,這就致使了不少很重複的勞動,雖然語言不一樣,但邏輯和設計都是如出一轍的。javascript

而公司也沒想過花點時間統一一下,好比考慮使用一下mpvue之類的,因此,在本着偷懶的心態下,開始想着如何能避免重複性的工做,好比只須要寫一套代碼。可是跟mpvue不同,不須要一個DSL工程化的東西,只須要轉換一下本身想轉換的文件。css

因而就有了這個想法,把所須要單個vue文件的轉換爲小程序原生語言所須要的四個文件(wxml, wxss, json, js)vue

有點長,須要耐心讀一下。 java

預備知識

AST

在開始以前,須要瞭解一點AST(抽象語法樹)的相關知識。node

好比JavaScript在執行以前,會通過詞法分析語法分析兩個步驟以後,獲得一個抽象語法樹git

好比下面這段代碼github

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

獲得的抽象語法樹以下圖。 這是在AST Explorer轉換獲得的。express

抽象語法樹
抽象語法樹

能夠看到咱們的js代碼已經被轉換成一個json對象,這個json對象的描述了這段代碼。 咱們能夠經過拿到這個json對象去進行樹形遍歷,從而把這一段js代碼進行加工成一段咱們想要的代碼。好比能夠把它轉換成一段ES5的代碼json

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

這是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

相關文章
相關標籤/搜索