业务上碰到一个需求,用户在已经发布的web中自制vue3组件,还需要接收web在运行时产生的数据并渲染定制的页面。在最初学习vue的时候学的是vue2,当时直接在script
中new 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,
},
})