You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

13 KiB

34 | 编译原理(下):编译原理给我们带来了什么?

你好,我是大圣。

上一讲我们深入研究了 Vue 里的 compiler-dom 和 compiler-core 的流程,相信学完之后,你已经对编译原理的基础知识很熟悉了。

这时候你肯定会有一个疑问AST、transform、generate这些概念以前工作中也没遇见过难道学了这个就只能面试用吗 当然不是,编译原理作为计算机世界的一个重要的学科,除了探究原理和源码之外,我们工作中也有很多地方可以用到。

从宏观视角来看编译原理实现的功能就是代码之间的转换。哪怕我们只是掌握了入门知识也能可以实现Vue中 template到render函数转化这样的功能。

现在的前端发展,很大程度上离不开编译原理在前端圈的落地实践,只要是我们想做自动化代码转化的地方,都可以看到编译的身影。

举个例子Babel把ES6中的新语法转换成低版本浏览器支持的语法我们才能在项目中愉快地使用箭头函数等特性把浏览器的兼容性交给Babel来处理甚至现在社区内还出现了gogocode这种把Vue 2代码转换成Vue 3代码的工具。

在工作中我们可以借助Babel和vite提供给我们的能力parsetransformgenerate等代码都不需要我们自己实现只需要考虑代码转换的逻辑就可以了下面我给你举几个小例子。

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函数中获取到待转换的代码通过对代码的内容进行正则匹配实现如果出现了refcomputed等函数的调用我们可以把这些依赖的函数收集在helper中。最终在script setup标签之前新增import语句来导入依赖的API最终就可以实现代码的自动导入。

实际开发中我们可以把使用到的组件库Element3工具函数vueuse等框架都进行语法的解析实现函数和组件的自动化导入和按需加载。这样能在提高开发效率的同时也提高我们书写vite插件的能力

思考题

最后留一个思考题吧,你觉得在工作项目中有哪里需要用到代码转化的思路呢?欢迎在评论区分享你的答案,也欢迎你把这一讲的内容分享给你的同事和朋友们,我们下一讲再见!