13 KiB
34 | 编译原理(下):编译原理给我们带来了什么?
你好,我是大圣。
上一讲我们深入研究了 Vue 里的 compiler-dom 和 compiler-core 的流程,相信学完之后,你已经对编译原理的基础知识很熟悉了。
这时候你肯定会有一个疑问,AST、transform、generate这些概念以前工作中也没遇见过,难道学了这个就只能面试用吗? 当然不是,编译原理作为计算机世界的一个重要的学科,除了探究原理和源码之外,我们工作中也有很多地方可以用到。
从宏观视角来看,编译原理实现的功能就是代码之间的转换。哪怕我们只是掌握了入门知识,也能可以实现Vue中 template到render函数转化这样的功能。
现在的前端发展,很大程度上离不开编译原理在前端圈的落地实践,只要是我们想做自动化代码转化的地方,都可以看到编译的身影。
举个例子,Babel把ES6中的新语法转换成低版本浏览器支持的语法,我们才能在项目中愉快地使用箭头函数等特性,把浏览器的兼容性交给Babel来处理,甚至现在社区内还出现了gogocode这种把Vue 2代码转换成Vue 3代码的工具。
在工作中我们可以借助Babel和vite提供给我们的能力,parse,transform,generate等代码都不需要我们自己实现,只需要考虑代码转换的逻辑就可以了,下面我给你举几个小例子。
vite 插件
首先我们在项目中使用了script setup来组织我们的代码,虽然组件引入之后有了自动注册的功能,但是每一个组件内部都肯定要用到ref、computed等Vue提供的API。我们还想要多一步,项目大了只引入ref的语句就写了几百行,就会非常地繁琐,这时候就可以使用编译的思想来解决这个问题。
首先ref、computed、watch等Vue提供的API,我们在后面的代码调用可以通过正则匹配的方式,完全可以分析出来当前组件依赖的API有哪些。这样,我们就可以在组件执行之前自动导入这些API。
我们在weiyouyi项目中使用vite插件的形式来完成这个工作。社区内已经有可用的 auto-imput 插件了,不过这里为了加深对技术的理解,咱们还是自己来实现一个。
首先我们进入到根目录下的vite.config.js文件中,导入autoPlugin插件后,配置在vite的plugins插件中。
import vue from '@vitejs/plugin-vue'
import autoPlgin from './src/auto-import'
export default defineConfig({
plugins: [vue(),autoPlgin()]
})
然后我们来实现autoPlugin函数,vite的插件开发文档你可以在官网中查询,这里就不赘述了。
我们直接看代码,我们先定义了Vue 3提供的API数组,有ref、computed等等。然后,autoImportPlugin函数对外导出一个对象,transform函数就是核心要实现的逻辑。
这里的helper和我们在32讲中的工具函数实现逻辑一致,通过new Regexp创建每个函数匹配的正则。如果匹配到对应的API,就把API的名字加入到helper集合中,最后在script setup的最上方加入一行import语句。
const vue3 = [
'ref',
'computed',
'reactive',
'onMounted',
'watchEffect',
'watch'
] // 还有很多....
export default function autoImportPlugin() {
return {
name: 'vite-plugin-auto-import', // 必须的,将会在 warning 和 error 中显示
enforce:'pre',
transform(code,id){
vueReg = /\.vue$/
if(vueReg.test(id)){
const helpers = new Set()
vue3.forEach(api=>{
const reg = new RegExp(api+"(.*)")
if(reg.test(code)){
helpers.add(api)
}
})
return code.replace('<script setup>',`<script setup>
import {${[...helpers].join(',')}} from 'vue' //俺是自动导入的
`)
}
return code
}
}
}
接着,我们在项目的src目录下新建App.vue。下面的代码实现了一个简易的累加器,并且还会在onMount之后打印一条信息,这里的ref、computed和onMounted都是没有导入的。我们在浏览器就能看到页面可以正常显示,这时我们在浏览器调试窗口的sources页面中,就可以看到App.vue的代码已经自动加上了import语句。
<template>
<div @click="add">
{{num}} * 2 = {{double}}
</div>
</template>
<script setup>
let num = ref(1)
let double = computed(()=>num.value*2)
function add(){
num.value++
}
onMounted(()=>{
console.log('mounted')
})
</script>
这里的代码都是硬编码实现的,逻辑也比较简单。不过,实际场景中判断ref等API调用的正则和导入import的方式,都不会这么简单。如果我们自己每次都写一个parse模块比较麻烦,所以我们实际开发中会借助现有的工具对代码进行解析,而代码转换的场景下最成熟的工具就是Babel。
Babel
我们在项目中异步的任务有很多,经常使用async+ await的语法执行异步任务,比如网络数据的获取。但 await是异步任务,如果报错,我们需要使用try catch语句进行错误处理,每个catch语句都是一个打印语句会让代码变得冗余,但我们有了代码转化的思路后,这一步就能用编译的思路自动来完成。
首先我们在根目录的src/main.js中新增下面代码,我们使用delyError函数模拟异步的任务报错,在代码中使用await来模拟异步任务。
这里我们希望每个await都能跟着一个try代码,在catch中能够打印错误消息提示的同时,还能够使用调用错误监控的函数,把当前错误信息发给后端服务器进行报警,当然也可以打印一个自动去stackoverflow查询的链接。
function delyError(message){
return new Promise((resolve,reject)=>{
setTimeout(()=>{
reject({message})
},1000)
})
}
async function test(){
await delyError('ref is not defined')
}
// 我们期望的代码
async function test(){
try{
await delyError('ref is not defined')
}catche(e){
console.error(e.message)
_errorTrack(e.message,location.pathname)
console.log('https://stackoverflow.com/search?q=[js]+'+encodeURI(e.message))
}
}
test()
页面中await语句变多了之后,手动替换的成本就比较高,我们可以继续使用vite的插件来实现。这次我们就是用Babel提供好的代码解析能力对代码进行转换。Babel都提供了哪些API,你可以在Babel的官网进行深入学习。
Babel提供了完整的编译代码的功能后函数,包括AST的解析、语义分析、代码生成等,我们可以通过下面的函数去实现自己的插件。
- @babel/parser提供了代码解析的能力,能够把js代码解析成AST,代码就从字符串变成了树形结构,方便我们进行操作;
- @babel/traverse提供了遍历AST的能力,我们可以从travser中获取每一个节点的信息后去修改它;
- @babe/types提供了类型判断的函数,我们可以很方便的判断每个节点的类型;
- @babel/core提供了代码转化的能力。
下面的代码中我们实现了vite-plugin-auto-try插件,由babel/parer解析成为AST,通过travser遍历整个AST节点,配置的AwaitExpression会识别出AST中的await调用语句,再用isTryStatement判断await外层是否已经包裹了try语句。如果没有try语句的话,就使用tryStatement函数生成新的AST节点。
这个AST包裹当前的节点,并且我们在内部加上了stackoverflow链接的打印。最后,使用babel/core提供的transformFromAstSync函数,把优化后的AST生成新的JavaScript代码,自动新增try代码的插件就实现了。
import { parse } from '@babel/parser'
import traverse from '@babel/traverse'
import {
isTryStatement,
tryStatement,
isBlockStatement,
catchClause,
identifier,
blockStatement,
} from '@babel/types'
import { transformFromAstSync } from '@babel/core'
const catchStatement = parse(`
console.error(err)
console.log('https://stackoverflow.com/search?q=[js]+'+encodeURI(err.message))
`).program.body
export default function autoImportPlugin() {
return {
name: 'vite-plugin-auto-try', // 必须的,将会在 warning 和 error 中显示
enforce:'pre',
transform(code,id){
fileReg = /\.js$/
if(fileReg.test(id)){
const ast = parse(code, {
sourceType: 'module'
})
traverse(ast, {
AwaitExpression(path){
console.log(path)
if (path.findParent((path) => isTryStatement(path.node))) {
// 已经有try了
return
}
// isBlockStatement 是否函数体
const blockParentPath = path.findParent((path) => isBlockStatement(path.node))
const tryCatchAst = tryStatement(
blockParentPath.node,
// ast中新增try的ast
catchClause(
identifier('err'),
blockStatement(catchStatement),
)
)
// 使用有try的ast替换之前的ast
blockParentPath.replaceWithMultiple([tryCatchAst])
}
})
// 生成代码,generate
code = transformFromAstSync(ast,"",{
configFile:false
}).code
return code
}
return code
}
}
}
然后,我们在根目录下的src/main.js中写入下面的代码。两个await语句一个使用try包裹,一个没有使用try包裹。
接着我们启动项目后,就来到了浏览器的调试窗口中的source页面,可以看到下图中解析后的main.js代码,现在没有try的await语句已经自动加上了try语句。
你看,这次我们基于babel来实现,就省去了我们写正则的开发成本。Babel提供了一整套关于JavaScirpt中语句的转化函数,有兴趣的同学可以去Babel官网了解。
import { createApp } from "vue";
import App from './App.vue'
createApp(App)
.mount('#app')
async function test(){
await delyError('ref is not defined')
}
async function test2(){
try{
await delyError('reactive is not defined')
}catch(e){
console.error(e)
}
}
test()
function delyError(message){
return new Promise((resolve,reject)=>{
setTimeout(()=>{
reject({message})
},1000)
})
}
有了Babel提供的能力之后,我们可以只关注于代码中需要转换的逻辑,比如我们可以使用Babel实现国际化,把每种语言在编译的时候自动替换语言,打包成独立的项目;也可以实现页面的自动化监控,在一些操作函数里面加入监控的代码逻辑。你可以自行发挥想象力,使用编译的思想来提高日常的开发效率。
最后我们回顾一下Vue中的compiler。Vue中的compiler-dom提供了compile函数,具体的compile逻辑我们在上一讲中已经详细学习了。其实我们也可以手动导入compiler-dom包之后,自己实现对vue template的解析。另外,Vue中还提供了@vue/compiler-sfc包,用来实现单文件组件.vue的解析,还有@vue/compiler-ssr包,它实现了服务端渲染的解析。
下一讲我们一起来手写vite的代码内容,我们就需要在nodejs中实现对Vue单文件组件的解析工作,实现浏览器中直接导入单文件组件的功能,敬请期待。
总结
最后我们总结一下今天学到的内容。
我们把Vue内部的compiler原理融会贯通之后,今天尝试把template到render转化过程的思想应用到实际项目中。Vue中的compiler在转化的过程中还做了静态标记的优化,我们在实际开发中可以借鉴编译的思路,提高开发的效率。
我们一起回顾一下代码自动导入的操作思路。首先我们可以实现页面中ref、computed的API的自动化导入,在vite插件的transform函数中获取到待转换的代码,通过对代码的内容进行正则匹配,实现如果出现了ref,computed等函数的调用,我们可以把这些依赖的函数收集在helper中。最终在script setup标签之前新增import语句来导入依赖的API,最终就可以实现代码的自动导入。
实际开发中,我们可以把使用到的组件库Element3,工具函数vueuse等框架都进行语法的解析,实现函数和组件的自动化导入和按需加载。这样能在提高开发效率的同时,也提高我们书写vite插件的能力。
思考题
最后留一个思考题吧,你觉得在工作项目中有哪里需要用到代码转化的思路呢?欢迎在评论区分享你的答案,也欢迎你把这一讲的内容分享给你的同事和朋友们,我们下一讲再见!