前言
1)说明
- 该篇重点不是如何转为h5,而是了解babel如何使用。
- 该项目案例场景,适合一些正在重构项目,或者快速转换不同终端的场景。
- 本章仅是个人提供的基础入门,转换点下边已说明。
- 本项目需要借助服务端实现,基于koa开发,需要简单了解koa2与babel的基础。此外,需要适当了解一下相关知识点:正则表达式的抒写,babylon解析器的执行原理,htmlToH5Compiler转换等。
- 案例通过babel格式化之后将无格式,需要自己借助eslint格式化。
2)github源码
因时间原因,github源码可能稍后处理,对应地址:github.com/zhuangweizh…
3)参考工具
- 关于ast树的类型:astexplorer.net/
- babel转换的类型:github.com/babel/babel…
步骤
1)koa项目搭建
koa只列举基本的创建过程,如有需要直接拷贝笔者github
npm install -g koa-generator
koa2 webTool
需要安装koa相关插件,babel相关插件等,这里不再描述,可查看package.json:
{
"name": "test_koa",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "node bin/www",
"start2": "node bin/www",
"dev": "./node_modules/.bin/nodemon bin/www LIMIT=8192 increase-memory-limit",
"prd": "node bin/www",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"@babel/core": "^7.4.5",
"@babel/generator": "^7.10.5",
"@babel/preset-env": "^7.4.5",
"@babel/traverse": "^7.10.5",
"babel-extract-comments": "^1.0.0",
"babel-loader": "^8.2.2",
"babylon": "^6.18.0",
"connect-timeout": "^1.9.0",
"debug": "^2.6.3",
"escodegen": "^2.0.0",
"esprima": "^4.0.1",
"estraverse": "^5.2.0",
"htmlparser2": "^6.1.0",
"koa": "^2.2.0",
"koa-bodyparser": "^3.2.0",
"koa-convert": "^1.2.0",
"koa-json": "^2.0.2",
"koa-logger": "^2.0.1",
"koa-onerror": "^1.2.1",
"koa-router": "^7.1.1",
"koa-static": "^3.0.0",
"koa-views": "^5.2.1",
"request": "^2.88.2",
"sync-request": "^6.1.0",
"template-parser": "^1.0.0"
},
"devDependencies": {
"increase-memory-limit": "^1.0.6",
"nodemon": "^1.8.1"
}
}
2)切割html, js, css
前端拿到需要转换的字符串后,post到服务端。此时,我们需要分成html, js, css切割开分别进行处理。将输入字符串,最外层template模块的内容为html, script的代码模块内容为js, style模块内容为css
笔者的思路是利用正则:
async parse(source) {
const htmlExp = /(?<=template)(?![\w\W]*template\>)[\w\W]+/gi;
const styleExp = /<style([\s\S]*?)<\/style>/gi;
const jsExp = /<script([\s\S]*?)<\/script>/gi;
// 获取到的html
const htmlStr = source
.replace(htmlExp, '>')
.toString()
.replace(/\<template\>/g, '')
.replace(/(.*)<\/template>/, '$1');
// 获取到的css
const styleStr = source.match(styleExp, '$1').toString()
// 获取到的js
const jsStr = source.match(jsExp);
const jsContent = jsStr[0]
.toString()
.replace(/\<script\>/g, '')
.replace(/(.*)<\/script>/, '$1');
...
}
此时,我们可以分别拿到对应的html, js, css。
3)html与ast树的转换
下来重点描述html与ast树的转换,笔者目前找到比较适合的插件是htmlparser2。
此时定义一个专门转换html的类库TemplateParser.js,重点包含两个方法:HTML文本转AST方法, AST转文本方法。
HTML文本转AST方法,相对比较简单,我们只需要转换为Node Tree即可。而AST转文本方法是核心,我们要替换成我们想要的结果,再返回到客户端,完成我们的html替换过程。
const htmlparser = require('htmlparser2') //html的AST类库
class TemplateParser {
constructor(){
}
/**
* HTML文本转AST方法
* @param scriptText
* @returns {Promise}
*/
parse(scriptText){
return new Promise((resolve, reject) => {
//先初始化一个domHandler
const handler = new htmlparser.DomHandler((error, dom)=>{
if (error) {
console.log(`reject.error`, 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转ast, ast转html,都已经完成。
4)html标签的转换
此时,html转ast, ast转html,都已经完成。那么,这里中间还少一个最核心的步骤,就是ast,如何在转为html之前,转为成我们想要的ast树。
我们接着进行html的转换。这时候我们需要一个能解析html的类库。笔者目前找到比较适合的是htmlparser2。 但htmlparser2通过实践,发现一个比较严重的bug,就是需要关闭标签。如<input type="text" />, htmlparser2识别不到标签,需要转换为<input type="text" ></input>。
此时,笔者想到的替换思路,依然是正则:
const pattern = /<(\w+)\s*(.*?)\/>/ig
htmlStr = htmlStr.replace( pattern, '<$1 $2></$1>');
通过文档查阅,可以了解到node的节点,有type类型。我们当前需要替换标签,识别node.type = 'tag',即说明为标签节点。
我们先定义好我们需要替换的标签,如uni的text标签需要转换为span, 如view标签需要转换为div标签。
// 需要替换的标签对象
const tagConverterConfig = {
text: 'span',
view: 'div',
image: 'img',
...
};
//替换入口方法
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];
}
node.attribs = attrs;
}
//因为是树状结构,所以需要进行递归
if (node.children) {
templateConverter(node.children);
}
}
return ast;
};
此时,即完成整个ast标签替换。我们只需要将传入的字符串,执行一遍,即可返回我们想要的html结果:
let templateParser = new TemplateParser();
const templateAst = await templateParser.parse(htmlStr);
//进行上述目标的转换
const convertedTemplate = templateConverter(templateAst);
//把语法树转成文本
let templateConvertedString = templateParser.astToString(convertedTemplate);
5)html标签属性的转换
上述,我们已经将html标签替换为想要的标签结果,此时,需要替换属性的话,同样的原理。属性在node.attribs,我们只需要替换成我们最终想要的node.attribs。即可完成需求。
改造一下templateConverter方法:
const attrConverterConfig = {
'@tap': {
key: '@click',
value: str => {
return str;
},
},
':nodes': {
key: 'v-html',
},
};
//替换入口方法
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;
};
此时,我们已经可以通过配置tagConverterConfig, attrConverterConfig, 来达到我们产出html的目的。
6)单位的替换
如果是sass,less转化为css,该部分复杂一点。由于两边都支持sass,我们只需要转换一下单位即可。
转换单位的方案,当前案例没有使用转义,用的是最简单的正则替换:
const styleResult = styleStr.replace(/(\d+)rpx/g,function(val,group){
return group + 'px'
})
7)js与ast树的转换
该章节,需要了解什么是 ast树,什么是babel。我们直接看他们的整个转换过程:
我们借助babylon来实践一次该过程:
// babylon 将源码转成ast Babylon 是 Babel 中使用的 JavaScript 解析器。
// @babel/traverse 对ast解析遍历语法树
// @babel/types 用于AST节点的Lodash-esque实用程序库
// @babel/generator 结果生成
const types = require('@babel/types');
const babylon = require('babylon');
const traverse = require('@babel/traverse').default;
const generator = require('@babel/generator').default;
let jsAst = babylon.parse(jsContent, { sourceType: 'module' });
traverse(jsAst, {
...转换规则
});
jsContent = generator(jsAst).code;
上述,parse 与 traverse 与 generator,即是整个ast树的转换过程。其核心,还是在traverse转换为我们想要的结果。下述将开启实践。
8)import的删除与替换
这里以vuex,做一个栗子。假设转换前需要引入vuex,转换后不需要,我们只需要在ImportDeclaration监听删除即可。
traverse(jsAst, {
...
// 引入部分处理
ImportDeclaration(path) {
const importUrl = path.node.source.value;
if (importUrl.includes('vuex')) {
path.remove();
return;
}
},
}
如果你好奇为什么是ImportDeclaration,这里就需要阅读整个node的节点变量的含义。可参考github.com/babel/babel… 查看所有的node节点对应的意义。
同理,要是所有的import的路径发生变化,如原来的@需要替换为@/src
traverse(jsAst, {
...
// 引入部分处理
ImportDeclaration(path) {
...
path.node.source.value = path.node.source.value.replace('@', '@/src);
}
});
9)自动引用公用文件
由于uni或小程序提供的一些内置方法,如上拉onReachBottom等函数,都是vue-cli本身不具备的。
此时,如果通过上拉监听,再调用onReachBottom,即可完成转换。
问题来了,这样上拉监听的方法执行,需要一个页面一个页面去补充。
笔者想到的方案,就是在转换的过程中,通过mixin补充:
1) 首先,我们在整个ast树中,补充多一个import所需要的mixin文件,PageMixins。 2)在暴露的对象中,插多一个mixins属性到对应的文件。
traverse(jsAst, {
...
// 监听整个ast节点
Program(path) {
// 添加import导入
const importDefaultSpecifier = [types.ImportDefaultSpecifier(types.Identifier('PageMixins'))];
const importDeclaration = types.ImportDeclaration(importDefaultSpecifier, types.StringLiteral('@/mixins/PageMixins'));
path.get('body')[0].insertBefore(importDeclaration);
},
// export的监听
ExportDefaultDeclaration(path) {
const obj = types.ObjectProperty(types.Identifier('mixins'), types.ArrayExpression([types.identifier('PageMixins')]));
path.node.declaration.properties.unshift(obj);
}
})
这样,转换后,代码将自动引入mixins。
10)修改生命周期名称
在uni中,生命周期,用到onLoad等。而我们想要的vue-cli代码,是没有onLoad的方法,我们需要替换成created.
同样,我们需要监听整个export对象,替换掉原来的onLoad:
traverse(jsAst, {
...,
ExportDefaultDeclaration(path) {
...,
path.node.declaration.properties.forEach( (item, index)=> {
if( item.key.name === 'onLoad' ){
item.key.name = "created"
}
});
}
});
或者,是不是还有一种思路,写死created调用methods中的onLoad方法。
我们要完成三个步骤:
1)页面写死created调用onLoad发放,可以利用上述"引入公用文件"的方案引入。
2)将onLoad方法移到methods中。
步骤1)在上个节点已描述,我们来模拟2)的代码:
traverse(jsAst, {
...,
ExportDefaultDeclaration(path) {
...,
let onLoadIndex = -1;
let methodsIndex = -1;
path.node.declaration.properties.forEach( (item, index)=> {
if( item.key.name === 'onLoad' ){
onLoadIndex = index;
}else if( item.key.name === 'methods' ){
methodsIndex = index;
}
})
const onLoadObj = path.node.declaration.properties[onLoadIndex];
onLoadIndex > -1 && path.node.declaration.properties[methodsIndex].value.properties.unshift( onLoadObj )
path.node.declaration.properties.forEach( (item, index)=> {
if( item.key.name === 'onLoad' ){
path.node.declaration.properties.splice(index , 1 );
}
})
}
})
此时,完成生命周期的转换
11)uni对象的替换
在uni中,很多方法通过uni对象,或者wx对象去替换。而在vue-cli中,并没有该对象。
笔者想到友好的方案,就是将对应的方法,嵌入到vue的原型中。
即将uni.navigateTo 替换为 this.utils.goPage, 将uni.navigateBack 替换为 this.utils.goBack。
那么通过babel如何处理?我们可以通过CallExpression对方法的监听:
traverse(jsAst, {
...,
if (name === 'uni') {
// 需要转换完内部封装api
const changeMyApi = ['navigateTo','navigateBack'];
if (changeMyApi.includes(propertyName)) {
// 将uni转成this.utils
path.node.callee.object.name = 'this.utils';
switch (propertyName) {
case 'navigateTo':
path.node.callee.property.name = 'goPage';
break;
case 'navigateBack':
path.node.callee.property.name = 'goBack';
break;
}
}
}
})
此时,uni.navigateTo将会替换为 this.utils.goPage。我们可以通过同样的原理,来完成整个项目的方法替换。
12)返回完整的字符串
上述已经将html, js, css分别转换。此时,只要拼接返回客户端即可。
如果中间再嵌入一个自动格式化(有推荐没?)