「轉轉二手」是我司用 wepy 開發的功能與 APP 類似度很是高的小程序,實現了大量的功能性頁面,而新業務 H5 項目在開發過程當中有時也常常須要一些公共頁面和功能,但新項目又有本身的獨特色,這些頁面需求從新開發成本很高,但若是把小程序代碼轉換成 VUE 就會容易的多,所以須要這樣一個轉換工具。javascript
本文將經過實戰帶你體驗 HTML、css、JavaScript 的 AST 解析和轉換過程css
若是你看完以爲有用,請點個贊~html
AST 全稱是叫抽象語法樹,網絡上有不少對 AST 的概念闡述和 demo,其實能夠跟 XML 類比,目前不少流行的語言均可以經過 AST 解析成一顆語法樹,也能夠認爲是一個 JSON,這些語言包括且不限於:CSS、HTML、JavaScript、PHP、Java、SQL 等,舉一個簡單的例子:前端
var a = 1;
這句簡單的 JavaScript 代碼經過 AST 將被解析成一顆「有點複雜」的語法樹:vue
這句話從語法層面分析是一次變量聲明和賦值,因此父節點是一個 type 爲 VariableDeclaration(變量聲明)的類型節點,聲明的內容又包括兩部分,標識符:a 和 初始值:1java
這就是一個簡單的 AST 轉換,你能夠經過 astexplorer可視化的測試更多代碼。node
AST 能夠將代碼轉換成 JSON 語法樹,基於語法樹能夠進行代碼轉換、替換等不少操做,其實 AST 應用很是普遍,咱們開發當中使用的 less/sass、eslint、TypeScript 等不少插件都是基於 AST 實現的。npm
本文的需求若是用文本替換的方式也可能能夠實現,不過須要用到大量正則,且出錯風險很高,若是用 AST 就能輕鬆完成這件事。json
AST 處理代碼一版分爲如下兩個步驟:小程序
詞法分析會把你的代碼進行大拆分,會根據你寫的每個字符進行拆分(會捨去註釋、空白符等無用內容),而後把有效代碼拆分紅一個個 token。
接下來 AST 會根據特定的「規則」把這些 token 加以處理和包裝,這些規則每一個解析器都不一樣,但作的事情大致相同,包括:
每種語言都有不少解析器,使用方式和生成的結果各不相同,開發者能夠根據須要選擇合適的解析器。
JavaScript
HTML
CSS
XML
接下來咱們開始實戰了,這個需求咱們用到的技術有:
咱們先看一段簡單的 wepy 和 VUE 的代碼對比:
//wepy版 <template> <view class="userCard"> <view class="basic"> <view class="avatar"> <image src="{{info.portrait}}"></image> </view> <view class="info"> <view class="name">{{info.nickName}}</view> <view class="label" wx:if="{{info.label}}"> <view class="label-text" wx:for="{{info.label}}">{{item}}</view> </view> <view class="onsale">在售寶貝{{sellingCount}}</view> <view class="follow " @tap="follow">{{isFollow ? '取消關注' : '關注'}}</view> </view> </view> </view> </template> <style lang="less" rel="stylesheet/less" scoped> .userCard { position:relative; background: #FFFFFF; box-shadow: 0 0 10rpx 0 rgba(162,167,182,0.31); border-radius: 3rpx; padding:20rpx; position: relative; } /* css太多了,省略其餘內容 */ </style> <script> import wepy from 'wepy' export default class UserCard extends wepy.component { props = { info:{ type:Object, default:{} } } data = { isFollow: false, } methods = { async follow() { await someHttpRequest() //請求某個接口 this.isFollow = !this.isFollow this.$apply() } } computed = { sellingCount(){ return this.info.sellingCount || 1 } } onLoad(){ this.$log('view') } } </script>
//VUE版 <template> <div class="userCard"> <div class="basic"> <div class="avatar"> <img src="info.portrait"></img> </view> <view class="info"> <view class="name">{{info.nickName}}</view> <view class="label" v-if="info.label"> <view class="label-text" v-for="(item,key) in info.label">{{item}}</view> </view> <view class="onsale">在售寶貝{{sellingCount}}</view> <view class="follow " @click="follow">{{isFollow ? '取消關注' : '關注'}}</view> </view> </view> </view> </template> <style lang="less" rel="stylesheet/less" scoped> .userCard { position:relative; background: #FFFFFF; box-shadow: 0 0 10rpx 0 rgba(162,167,182,0.31); border-radius: 3*@px; padding:20*@px; position: relative; } /* css太多了,省略其餘內容 */ </style> <script> export default { props : { info:{ type:Object, default:{} } } data(){ return { isFollow: false, } } methods : { async follow() { await someHttpRequest() //請求某個接口 this.isFollow = !this.isFollow } } computed : { sellingCount(){ return this.info.sellingCount || 1 } } created() { this.$log('view') } } </script>
咱們先寫個讀取文件的入口方法
const cwdPath = process.cwd() const fse = require('fs-extra') const convert = async function(filepath){ let fileText = await fse.readFile(filepath, 'utf-8'); fileHandle(fileText.toString(),filepath) } const fileHandle = async function(fileText,filepath){ //dosth... } convert(`${cwdPath}/demo.wpy`)
在 fileHandle 函數中,咱們能夠獲得代碼的文本內容,首先咱們將對其進行 XML 解析,把 template、css、JavaScript 拆分紅三部分。 有同窗可能問爲何不直接正則匹配出來,由於開發者的代碼可能有不少風格,好比有兩部分 style,可能有不少意外狀況是使用正則考慮不到的,這也是使用 AST 的意義。
//首先須要完成Xml解析及路徑定義: //初始化一個Xml解析器 let xmlParser = new XmlParser(), //解析代碼內容 xmlParserObj = xmlParser.parse(fileText), //正則匹配產生文件名 filenameMatch = filepath.match(/([^\.|\/|\\]+)\.\w+$/), //若是沒有名字默認爲blank filename = filenameMatch.length > 1 ? filenameMatch[1] : 'blank', //計算出模板文件存放目錄dist的絕對地址 filedir = utils.createDistPath(filepath), //最終產出文件地址 targetFilePath = `${filedir}/${filename}.vue` //接下來建立目標目錄 try { fse.ensureDirSync(filedir) }catch (e){ throw new Error(e) } //最後根據xml解析出來的節點類型進行不一樣處理 for(let i = 0 ;i < xmlParserObj.childNodes.length;i++){ let v = xmlParserObj.childNodes[i] if(v.nodeName === 'style'){ typesHandler.style(v,filedir,filename,targetFilePath) } if(v.nodeName === 'template'){ typesHandler.template(v,filedir,filename,targetFilePath) } if(v.nodeName === 'script'){ typesHandler.script(v,filedir,filename,targetFilePath) } }
不一樣節點的處理邏輯,定義在一個叫作 typesHandler 的對象裏面存放,接下來咱們看下不一樣類型代碼片斷的處理邏輯
<u>因篇幅有限,本文只列舉一部分代碼轉換的目標,實際上要比這些更復雜</u>
接下來咱們對代碼進行轉換:
轉換目標
核心流程
let templateContent = v.childNodes.toString(), //初始化一個解析器 templateParser = new TemplateParser() //生成語法樹 templateParser.parse(templateContent).then((templateAst)=>{ //進行上述目標的轉換 let convertedTemplate = templateConverter(templateAst) //把語法樹轉成文本 templateConvertedString = templateParser.astToString(convertedTemplate) templateConvertedString = `<template>\r\n${templateConvertedString}\r\n</template>\r\n` fs.writeFile(targetFilePath,templateConvertedString, ()=>{ resolve() }); }).catch((e)=>{ reject(e) })
const Parser = require('./Parser') //基類 const htmlparser = require('htmlparser2') //html的AST類庫 class TemplateParser extends Parser { constructor(){ super() } /** * HTML文本轉AST方法 * @param scriptText * @returns {Promise} */ parse(scriptText){ return new Promise((resolve, reject) => { //先初始化一個domHandler const handler = new htmlparser.DomHandler((error, dom)=>{ if (error) { reject(error); } else { //在回調裏拿到AST對象 resolve(dom); } }); //再初始化一個解析器 const parser = new htmlparser.Parser(handler); //再經過write方法進行解析 parser.write(scriptText); parser.end(); }); } /** * AST轉文本方法 * @param ast * @returns {string} */ astToString (ast) { let str = ''; ast.forEach(item => { if (item.type === 'text') { str += item.data; } else if (item.type === 'tag') { str += '<' + item.name; if (item.attribs) { Object.keys(item.attribs).forEach(attr => { str += ` ${attr}="${item.attribs[attr]}"`; }); } str += '>'; if (item.children && item.children.length) { str += this.astToString(item.children); } str += `</${item.name}>`; } }); return str; } } module.exports = TemplateParser
//html標籤替換規則,能夠添加更多 const tagConverterConfig = { 'view':'div', 'image':'img' } //屬性替換規則,也能夠加入更多 const attrConverterConfig = { 'wx:for':{ key:'v-for', value:(str)=>{ return str.replace(/{{(.*)}}/,'(item,key) in $1') } }, 'wx:if':{ key:'v-if', value:(str)=>{ return str.replace(/{{(.*)}}/,'$1') } }, '@tap':{ key:'@click' }, } //替換入口方法 const templateConverter = function(ast){ for(let i = 0;i<ast.length;i++){ let node = ast[i] //檢測到是html節點 if(node.type === 'tag'){ //進行標籤替換 if(tagConverterConfig[node.name]){ node.name = tagConverterConfig[node.name] } //進行屬性替換 let attrs = {} for(let k in node.attribs){ let target = attrConverterConfig[k] if(target){ //分別替換屬性名和屬性值 attrs[target['key']] = target['value'] ? target['value'](node.attribs[k]) : node.attribs[k] }else { attrs[k] = node.attribs[k] } } node.attribs = attrs } //由於是樹狀結構,因此須要進行遞歸 if(node.children){ templateConverter(node.children) } } return ast }
轉換目標
核心過程
let styleText = utils.deEscape(v.childNodes.toString())
if(v.attributes){ //檢測css是哪一種類型 for(let i in v.attributes){ let attr = v.attributes[i] if(attr.name === 'lang'){ type = attr.value } } }
less.render(styleText).then((output)=>{ //output是css內容對象 })
const CSSOM = require('cssom') //css的AST解析器 const replaceTagClassName = function(replacedStyleText){ const replaceConfig = {} //匹配標籤選擇器 const tagReg = /[^\.|#|\-|_](\b\w+\b)/g //將css文本轉換爲語法樹 const ast = CSSOM.parse(replacedStyleText), styleRules = ast.cssRules if(styleRules && styleRules.length){ //找到包含tag的className styleRules.forEach(function(item){ //可能會有 view image {...}這多級選擇器 let tags = item.selectorText.match(tagReg) if(tags && tags.length){ let newName = '' tags = tags.map((tag)=>{ tag = tag.trim() if(tag === 'image')tag = 'img' return tag }) item.selectorText = tags.join(' ') } }) //使用toString方法能夠把語法樹轉換爲字符串 replacedStyleText = ast.toString() } return {replacedStyleText,replaceConfig} }
replacedStyleText = replacedStyleText.replace(/([\d\s]+)rpx/g,'$1*@px')
replacedStyleText = `<style scoped>\r\n${replacedStyleText}\r\n</style>\r\n` fs.writeFile(targetFilePath,replacedStyleText,{ flag: 'a' },()=>{ resolve() });
轉換目標
核心過程
在瞭解如何轉換以前,咱們先簡單瞭解下 JavaScript 轉換的基本流程:
借用其餘做者一張圖片,能夠看出轉換過程分爲解析->轉換->生成 這三個步驟。
具體以下:
v.childNodes.toString()
let javascriptContent = utils.deEscape(v.childNodes.toString())
let javascriptParser = new JavascriptParser()
這個解析器裏封裝了什麼呢,看代碼:
const Parser = require('./Parser') //基類 const babylon = require('babylon') //AST解析器 const generate = require('@babel/generator').default const traverse = require('@babel/traverse').default class JavascriptParser extends Parser { constructor(){ super() } /** * 解析前替換掉無用字符 * @param code * @returns */ beforeParse(code){ return code.replace(/this\.\$apply\(\);?/gm,'').replace(/import\s+wepy\s+from\s+['"]wepy['"]/gm,'') } /** * 文本內容解析成AST * @param scriptText * @returns {Promise} */ parse(scriptText){ return new Promise((resolve,reject)=>{ try { const scriptParsed = babylon.parse(scriptText,{ sourceType:'module', plugins: [ // "estree", //這個插件會致使解析的結果發生變化,所以去除,這原本是acron的插件 "jsx", "flow", "doExpressions", "objectRestSpread", "exportExtensions", "classProperties", "decorators", "objectRestSpread", "asyncGenerators", "functionBind", "functionSent", "throwExpressions", "templateInvalidEscapes" ] }) resolve(scriptParsed) }catch (e){ reject(e) } }) } /** * AST樹遍歷方法 * @param astObject * @returns {*} */ traverse(astObject){ return traverse(astObject) } /** * 模板或AST對象轉文本方法 * @param astObject * @param code * @returns {*} */ generate(astObject,code){ const newScript = generate(astObject, {}, code) return newScript } } module.exports = JavascriptParser
值得注意的是:babylon 的 plugins 配置有不少,如何配置取決於你的代碼裏面使用了哪些高級語法,具體能夠參見文檔或者根據報錯提示處理
javascriptContent = javascriptParser.beforeParse(javascriptContent)
javascriptParser.parse(javascriptContent)
let {convertedJavascript,vistors} = componentConverter(javascriptAst)
componentConverter 是轉換的方法封裝,轉換過程略複雜,咱們先了解幾個概念。
假如咱們拿到了 AST 對象,咱們須要先對他進行遍歷,如何遍歷呢,這樣一個複雜的 JSON 結構若是咱們用循環或者遞歸的方式去遍歷,那無疑會很是複雜,因此咱們就藉助了 babel 裏的traverse這個工具,文檔:babel-traverse。
traverse 接受兩個參數:AST 對象和 vistor 對象
vistor 就是配置遍歷方式的對象
主要有兩種:
const componentVistor = { enter(path) { if (path.isIdentifier({ name: "n" })) { path.node.name = "x"; } }, exit(path){ //do sth } }
const componentVistor = { FunctionDeclaration(path) { path.node.id.name = "x"; } }
本文代碼主要使用了樹狀遍歷的方式,代碼以下:
const componentVistor = { enter(path) { //判斷若是是類屬性 if (t.isClassProperty(path)) { //根據不一樣類屬性進行不一樣處理,把wepy的類屬性寫法提取出來,放到VUE模板中 switch (path.node.key.name){ case 'props': vistors.props.handle(path.node.value) break; case 'data': vistors.data.handle(path.node.value) break; case 'events': vistors.events.handle(path.node.value) break; case 'computed': vistors.computed.handle(path.node.value) break; case 'components': vistors.components.handle(path.node.value) break; case 'watch': vistors.watch.handle(path.node.value) break; case 'methods': vistors.methods.handle(path.node.value) break; default: console.info(path.node.key.name) break; } } //判斷若是是類方法 if(t.isClassMethod(path)){ if(vistors.lifeCycle.is(path)){ vistors.lifeCycle.handle(path.node) }else { vistors.methods.handle(path.node) } } } }
本文的各類 vistor 主要作一個事,把各類類屬性和方法收集起來,基類代碼:
class Vistor { constructor() { this.data = [] } handle(path){ this.save(path) } save(path){ this.data.push(path) } getData(){ return this.data } } module.exports = Vistor
這裏還須要補充講下@babel/types這個類庫,它主要是提供了 JavaScript 的 AST 中各類節點類型的檢測、改造、生成方法,舉例:
//類型檢測 if(t.isClassMethod(path)){ //若是是類方法 } //創造一個對象節點 t.objectExpression(...)
經過上面的處理,咱們已經把 wepy 裏面的各類類屬性和方法收集好了,接下來咱們看如何生成 vue 寫法的代碼
convertedJavascript = componentTemplateBuilder(convertedJavascript,vistors)
看下 componentTemplateBuilder 這個方法如何定義:
const componentTemplateBuilder = function(ast,vistors){ const buildRequire = template(componentTemplate); ast = buildRequire({ PROPS: arrayToObject(vistors.props.getData()), LIFECYCLE: arrayToObject(vistors.lifeCycle.getData()), DATA: arrayToObject(vistors.data.getData()), METHODS: arrayToObject(vistors.methods.getData()), COMPUTED: arrayToObject(vistors.computed.getData()), WATCH: arrayToObject(vistors.watch.getData()), }); return ast }
這裏就用到了@babel/template這個類庫,主要做用是能夠把你的代碼數據組裝到一個新的模板裏,模板以下:
const componentTemplate = ` export default { data() { return DATA }, props:PROPS, methods: METHODS, computed: COMPUTED, watch:WATCH, } `
<u>*生命週期須要進行對應關係處理,略複雜,本文不贅述</u>
let codeText = `<script>\r\n${generate(convertedJavascript).code}\r\n</script>\r\n` fs.writeFile(targetFilePath,codeText, ()=>{ resolve() });
這裏用到了@babel/generate類庫,主要做用是把 AST 語法樹生成文本格式
上述過程的代碼實現整體流程
const JavascriptParser = require('./lib/parser/JavascriptParser') //先反轉義 let javascriptContent = utils.deEscape(v.childNodes.toString()), //初始化一個解析器 javascriptParser = new JavascriptParser() //去除無用代碼 javascriptContent = javascriptParser.beforeParse(javascriptContent) //解析成AST javascriptParser.parse(javascriptContent).then((javascriptAst)=>{ //進行代碼轉換 let {convertedJavascript,vistors} = componentConverter(javascriptAst) //放到預先定義好的模板中 convertedJavascript = componentTemplateBuilder(convertedJavascript,vistors) //生成文本並寫入到文件 let codeText = `<script>\r\n${generate(convertedJavascript).code}\r\n</script>\r\n` fs.writeFile(targetFilePath,codeText, ()=>{ resolve() }); }).catch((e)=>{ reject(e) })
上面就是 wepy 轉 VUE 工具的核心代碼實現流程了
經過這個例子但願你們能瞭解到如何經過 AST 的方式進行精準的代碼處理或者語法轉換
既然咱們已經實現了這個轉換工具,那接下來咱們但願給開發者提供一個命令行工具,主要有兩個部分:
{ "name": "@zz-vc/fancy-cli", "bin": { "fancy": "bin/fancy" }, //其餘配置 }
#!/usr/bin/env node process.env.NODE_PATH = __dirname + '/../node_modules/' const { resolve } = require('path') const res = command => resolve(__dirname, './commands/', command) const program = require('commander') program .version(require('../package').version ) program .usage('<command>') //註冊convert命令 program .command('convert <componentName>') .description('convert a component,eg: fancy convert Tab.vue') .alias('c') .action((componentName) => { let fn = require(res('convert')) fn(componentName) }) program.parse(process.argv) if(!program.args.length){ program.help() }
convert 命令對應的代碼:
const cwdPath = process.cwd() const convert = async function(filepath){ let fileText = await fse.readFile(filepath, 'utf-8'); fileHandle(fileText.toString(),filepath) } module.exports = function(fileName){ convert(`${cwdPath}/${fileName}`) }
fileHandle 這塊的代碼最開始已經講過了,忘記的同窗能夠從頭再看一遍,你就能夠整個串起來這個工具的總體實現邏輯了
至此本文就講完了如何經過 AST 寫一個 wepy 轉 VUE 的命令行工具,但願對你有所收穫。
最重要的事: <u>我司 轉轉 正在招聘前端高級開發工程師數名,有興趣來轉轉跟我一塊兒搞事情的,請發簡歷到zhangsuoyong@zhuanzhuan.com</u>
轉載請註明來源及做者:張所勇@轉轉