「轉轉二手」是我司用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 和 初始值:1 java
這就是一個簡單的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)
}
}
複製代碼
//XmlParser定義
const Xmldom = require('xmldom')
const utils = require('../utils')
class XmlParser extends Parser {
constructor(){
super()
}
createParser(){
return new Xmldom.DOMParser({errorHandler: {
warning (x) {
if (x.indexOf('missed value!!') > -1) {
// ignore warnings
} else
console.warn(x);
},
error (x) {
console.error(x);
}
}});
}
parse(fileText){
fileText = utils.replaceTagAndEventBind(fileText)
return this.createParser().parseFromString(fileText);
}
}
複製代碼
不一樣節點的處理邏輯,定義在一個叫作typesHandler的對象裏面存放,接下來咱們看下不一樣類型代碼片斷的處理邏輯
因篇幅有限,本文只列舉一部分代碼轉換的目標,實際上要比這些更復雜
接下來咱們對代碼進行轉換:
轉換目標
核心流程
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,
}
`
複製代碼
*生命週期須要進行對應關係處理,略複雜,本文不贅述
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的命令行工具,但願對你有所收穫。
最重要的事: 我司 轉轉 正在招聘前端高級開發工程師數名,有興趣來轉轉跟我一塊兒搞事情的,請發簡歷到zhangsuoyong@zhuanzhuan.com
轉載請註明來源及做者:張所勇@轉轉