运行时渲染vue3组件

业务上碰到一个需求,用户在已经发布的web中自制vue3组件,还需要接收web在运行时产生的数据并渲染定制的页面。在最初学习vue的时候学的是vue2,当时直接在scriptnew Vue()以达到使用vue的功能,后来逐渐接触项目工程化,借助npm、yarn、vite直接来创建项目,渐渐忘记了vue最初的模样。经过一段时间摸索,vue3也能够实现此方法。

项目背景

给用户一个代码编辑页面,分别可以编辑template、js和css,然后在需要展示的地方再对用户编写的组件进行渲染。

实现思路

正常开发一个vue网页的流程为,使用cli创建项目、编写代码、编译最后发布,而编译这一步就会将代码编译为不需要vue也能够运行的普通代码,而用户编写的肯定不能使用这个流程,不然每次用户写完就要重新编译发布,并且用户所编写的代码文件也是存储在数据库中的,这显然不适合。
前端从数据库拿到用户所写的js文件,肯定是不能直接运行的,第一步就是将字符串格式的js代码转为js对象,然后再运行。
当然,转为js对象后也不能直接运行,因为本地编写的代码在编译后就没有Vue这个环境了,直接调用new Vue()方法是行不通的,所以需要将Vue的环境保留在编译后代码中,已提供Vue运行时。

引入Vue运行时

按照官方文档,通过script标签就可以引入一个Vue的运行时。CodePen示例

<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

<div id="app">{{ message }}</div>

<script>
  const { createApp } = Vue
  
  createApp({
    data() {
      return {
        message: 'Hello Vue!'
      }
    }
  }).mount('#app')
</script>

但是我们使用cli进行开发时就已经使用npm引入了Vue3的包,此时在通过cdn链接引入就显得有些多余了,我使用的Vite创建的项目,可以在打包时,将Vue的js文件保留。

import { ConfigEnv, UserConfigExport } from 'vite'

// 因为还有其他自定义需求,这里使用的是UserConfigExport
export default ({ command }: ConfigEnv): UserConfigExport => {
    ......
    return {
        build: {
            resolve: {
                alias: {
                    '@': fileURLToPath(new URL('./src', import.meta.url)),
                    // 使用command判断为开发环境或编译环境
                    // 当command === 'build'是在编译,使用 vue 提供的`vue/dist/vue.esm-browser.prod.js`发布版本js代码
                    'vue': command === 'serve' ? 'vue/dist/vue.esm-browser.js' : 'vue/dist/vue.esm-browser.prod.js'
                }
            }
        }
    }
}

编写组件

一个已*.vue结尾的组件实际为一个js对象,当我们打印一个引入的组件:

import HelloWorld from "@/components/HelloWorld.vue";
......
console.log('hello组件:',HelloWorld)

组合式api输出:
选项式
选项式api输出:
组合式
一个组件的基本组成就是:

// 组合式api对象
{
    props: {...},
    render: ()=> ...,
    setup: ()=> ...
},
// 选项式api对象
{
    name: string,
    props: {...},
    render: ()=> ...,
    methods: {...},
    template: string
    
}

阅读官方文档,使用组合式api,不使用<script setup>的语法糖,则需要用户自己在setup中手动返回template中需要用到的变量与方法,为了方便与避免用户忘记返回所需要的变量与方法,我们实现选项式api,避免语法糖的解析。组合式 API:setup()
为了方便组件的实现,我们使用jsx,不然就需要h()定义函数函数,核心代码:

// RuntimeComponent.jsx
export default defineComponent((props) => {
  // 局部css名称
  const className = computed(() => {  
    // 生成唯一class,主要用于做scoped的样式  
    const uid = Math.random().toString(36).slice(2)  
    return `runtime-component-${uid}`  
  })
  // 将接口获取到的css类名在前方加上局部css名称
  // 例:
  // 局部类名为.ccs
  // .a{} -> .css .a{}
  const scopedStyle = computed(() => {  
    if (props.css) {  
      const scope = `.${className.value}`  
      const regex = /(^|\})\s*([^{]+)/g  
      // 为class加前缀,做类似scope的效果  
      return props.css.trim().replace(regex, (m, g1, g2) => {  
        return g1 ? `${g1} ${scope} ${g2}` : `${scope} ${g2}`  
      })  
    }  
    return ''  
  })
  const Component = computed(() => {
    // 将js代码字符串转为js对象
    const result = runFnInVm(props.js.trim(), {})
    if (result.error) { // 转换过程中时候有错误
      return <div style="color: #a23511;">{result.error.message}</div>
    }
    const component = result.value
    if (props.template) {
      component.template = props.template
    } else {
      component.template = `<div>模版为空</div>`
    }
    return component
  })
  onErrorCaptured((error) => {
    console.error('组件运行错误:', error)  
    return false  
  })  
  console.log(Component.value)  
  return () => {  
    return (<div class={className.value}>  
      <style>{scopedStyle.value}</style>  
      <Component.value data={props.data}/>  
    </div>)  
  }  
}, {  
  props: {  
    template: String,  
    js: String,  
    css: String,  
    data: Object,  
  },  
})