“转转二手”是我司用 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 的对象里面存放,接下来咱们看下不一样类型代码片断的处理逻辑
因篇幅有限,本文只列举一部分代码转换的目标,实际上要比这些更复杂
接下来咱们对代码进行转换:
转换目标
核心流程
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
转载请注明来源及做者:张所勇@转转