作者:还没想好
https://zhuanlan.zhihu.com/p/347509262
大致说一下项目的背景:我们做了一个拖拽生成报表的系统,通过拖拽内置的组件供用户定制自己的报表形态,但毕竟内置的组件有限,可定制性不高,那么给用户开放一个 code 组件,让用户自己通过写template
+ js
+ css
的方式自由定制岂不是妙哉。
那么该怎么实现呢?我们先来看一 vue 官方的介绍
Vue (读音 /vjuː/,类似于 view) 是一套用于构建用户界面的渐进式框架。与其它大型框架不同的是,Vue 被设计为可以自底向上逐层应用。[1]
很多时候我们貌似已经忽略了渐进式
这回事,现在基于 VUE 开发的项目大多都采用 vue cli 生成,以 vue 单文件的方式编码,webpack 编译打包的形式发布。这与渐进式有什么关系呢,确实没有关系。
渐进式其实指的在一个已存在的但并未使用 vue 的项目上接入 vue,使用 vue,直到所有的 HTML 渐渐替换为通过 vue 渲染完成,渐进开发,渐进迁移,这种方式在 vue 刚出现那几年比较多,现在或许在一些古老的项目也会出现。
为什么要提渐进式
呢?因为渐进式是不需要本地编译的,有没有 get 到点!对,就是不需要本地编译,而是运行时编译
。
用户想通过编写template
+ js
+ css
的方式实现运行时渲染页面,那肯定是不能本地编译的(此处的编译指将 vue 文件编译为 js 资源文件),即不能把用户写的代码像编译源码一样打包成静态资源文件。
这些代码只能原样持久化到数据库,每次打开页面再恢复回来,实时编译。毕竟不是纯 js 文件,是不能直接运行的,它需要一个运行时环境,运行时编译,这个环境就是 vue 的运行时 + 编译器[2]。
有了思路也只是窥到了天机,神功练成还是要打磨细节。具体怎么做,容我一步步道来。
按官方的介绍[3],通过 script 标签引入 vue 就可以渐进式开发了,也就具备了运行时+编译器,如下
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
</head>
<body>
<div id="app">{{message}}</div>
<script type="text/javascript">
var app = new Vue({
el: "#app",
data: {
message: "Hello Vue!",
},
});
</script>
</body>
</html>
但通过 vue 单文件+webpack 编译的方式,再引入一个 vue 就多余了,通过 CLI 也是可以的,只需要在 vue.config.js 中打开 runtimeCompiler 开关就行了,详细看文档[4]。
此时我们就有了一个运行时编译环境
把代码渲染出来有两个方案
new Vue({ el: '#id' })
的方式对于这种方式,在官方文档中,组件注册章节,最后给出了一个注意点
记住全局注册的行为必须在根 Vue 实例 (通过 new Vue)
创建之前
发生。
因此,并不能通过调用Vue.component('my-component-name', {/* */})
的方式将用户的代码注册到系统中,因为运行时 Vue 实例已经创建完,用户的代码是在实例完 Vue 后才进来的,那我们只能通过局部注册的方式了,类似这样
var ComponentB = {
components: {
"component-a": {
...customJsLogic,
name: "custom-component",
template: "<div>custom template</div>",
},
},
// ...
};
但想一下,好像不太对,这还是在写源码,运行时定义了ComponentB
组件怎么用呢,怎么把ComponentB
在一个已经编译完页面上渲染出来呢?找不到入口点,把用户代码注入到components
对象上也无法注册到系统中,无法渲染出来。
就止步于此了吗?该怎么办呢?
想一下为什么要在components
中先注册(声明)下组件,然后才能使用?component 本质上只不过是一个 js object 而已。其实主要是为了服务于 template 模板语法,当你在 template 中写了 <compA propA='value'/>
,有了这个注册声明才能在编译时找到compA
。如果不使用 template,那么这个注册就可以省了。
不使用 template 怎么渲染呢,使用render 函数[6]呀!
在 render 函数中如果使用 createElement 就比较麻烦了,API 很复杂,对于渲染一整段用户定义的 template 也略显吃力,使用 jsx 就方便多了,都 1202 年了,想必大家对 jsx 都应该有所了解。
回到项目上,需要使用用户代码的地方不止一处,都用 render 函数写一遍略显臃肿,那么做一个 code 的容器,容器负责渲染用户的代码,使用地方把容器挂上就行了。
export default {
name: "customCode",
props: {
template: String, // template模板
js: String, // js逻辑
css: String, // css样式
},
computed: {
className() {
// 生成唯一class,主要用于做scoped的样式
const uid = Math.random().toString(36).slice(2);
return `custom-code-${uid}`;
},
scopedStyle() {
if (this.css) {
const scope = `.${this.className}`;
const regex = /(^|\})\s*([^{]+)/g;
// 为class加前缀,做类似scope的效果
return this.css.trim().replace(regex, (m, g1, g2) => {
return g1 ? `${g1} ${scope} ${g2}` : `${scope} ${g2}`;
});
}
return "";
},
component() {
// 把代码字符串转成js对象
const component = safeStringToObject(this.js);
// 去掉template的前后标签
const template = (this.template || "")
.replace(/^ *< *template *>|<\/ *template *> *$/g, "")
.trim();
// 注入template或render,设定template优先级高于render
if (this.template) {
component.template = this.template;
component.render = undefined;
} else if (!component.render) {
component.render = "<div>未提供模板或render函数</div>";
}
return component;
},
},
render() {
const { component } = this;
return (
<div class={this.className}>
<style>{this.scopedStyle}</style>
<component />
</div>
);
},
};
<template>
<custom-code :js="js" :template="template" :css="css" />
</template>
以上只是核心的逻辑部分,除了这些,在项目实战中还应考虑容错处理,错误大致可以分两种
用户代码语法错误
主要是 js 部分,对于 css 和 template 的错误,浏览器有一定的纠错的机制,不至于崩了。
这部分的处理主要借助于safeStringToObject
这个函数,如果有语法错误,则返回 Error,处理一下回显给用户,代码大致如下
// component对象在result.value上取,如果result.error有值,则代表出现了错误
component() {
// 把代码字符串转成js对象
const result = safeStringToObject(this.js)
const component = result.value
if (result.error) {
console.error('js 脚本错误', result.error)
result.error = {
msg: result.error.toString(),
type: 'js脚本错误',
}
result.value = { hasError: true }
return result
}
// ...
retrun result
}
组件运行时错误
既然把 js 逻辑交给了用户控制,那么像类型错误,从 undefined 中读值,把非函数变量当函数运行,甚至拼写错误等这些运行时错误就很有可能发生。
这部分的处理需要通过在容器组件上添加 `errorCaptured`这个官方钩子[7],来捕获子组件的错误,因为并没有一个途径可以获取组件自身运行时错误的钩子。代码大致如下
errorCaptured(err, vm, info) {
this.subCompErr = {
msg: err && err.toString && err.toString() || err,
type: '自定义组件运行时错误:',
}
console.error('自定义组件运行时错误:', err, vm, info)
},
结合错误处理,如果希望用户能看到错误信息,则 render 函数需要把错误展示出来,代码大致如下
render() {
const { error: compileErr, value: component } = this.component
const error = compileErr || this.subCompErr
let errorDom
if (error) {
errorDom = <div class='error-msg-wrapper'>
<div>{error.type}</div>
<div>{error.msg}</div>
</div>
}
return <div class='code-preview-wrapper'>
<div class={this.className}>
<style>{this.scopedStyle}</style>
<component />
</div>
{errorDom}
</div>
},
这里还有一个点,用户发现组件发生了错误后会修改代码,使其再次渲染,错误的回显需要特别处理下。
对于 js 脚本错误,因 component 是计算属性,随着 computed 计算属性再次计算,如果 js 脚本没有错误,导出的 component 可重绘出来,
但对于运行时错误,使用this.subCompErr
内部变量保存,props 修改了,这个值却不会被修改,因此需要打通 props 关联,通过添加 watch 的方式解决,这里为什么没有放在 component 的计算属性中做,一是违背计算属性设计原则,二是 component 可能并不仅仅依赖 js,css,template 这个 props 的变化,而this.subCompErr
只需要和这个三个 props 关联,这么做会有多余的重置逻辑。
还有一种场景就是子组件自身可能有定时刷新逻辑,定期或不定期的重绘,一旦发生了错误,也会导致一直显示错误信息,因为用户的代码拿不到this.subCompErr
的值,因此也无法重置此值,这种情况,可通过注入beforeUpdate
钩子解决,代码大致如下
computed: {
component() {
// 把代码字符串转成js对象
const result = safeStringToObject(this.js)
const component = result.value
// ...
// 注入mixins
component.mixins = [{
// 注入 beforeUpdate 钩子,用于子组件重绘时,清理父组件捕获的异常
beforeUpdate: () => {
this.subCompErr = null
},
}]
// ...
return result
},
},
watch: {
js() {
// 当代码变化时,清空error,重绘
this.subCompErr = null
},
template() {
// 当代码变化时,清空error,重绘
this.subCompErr = null
},
css() {
// 当代码变化时,清空error,重绘
this.subCompErr = null
},
},
完整的代码见:https://github.com/merfais/vue-demo/blob/main/src/views/customCode/withComponent.vue[8]
完整的 demo 见:https://merfais.github.io/vue-demo/#/custom-code[9]
我们知道在利用 vue 构建的系统中,页面由组件构成,页面本身其实也是组件,只是在部分参数和挂载方式上有些区别而已。这第二种方式就是将用户的代码视为一个 page,通过 new 一个 vm 实例,再在 DOM 挂载点挂载 vm(new Vue(component).$mount('#id')
)的方式渲染。
动态实例方案与动态组件方案大致相同,都要通过 computed 属性,生成component
对象和scopedStyle
对象进行渲染,但也有些许的区别,动态实例比动态组件需要多考虑以下几点:
errorCaptured
需要注入到component
对象上,不再需要注入beforeUpdate
钩子
因为通过new Vue()
的方式创建了一个新的 vm 实例,不再是容器组件的子组件,所以容器组件上的errorCaptured
无法捕获新 vm 的运行时错误,new Vue(component)
中参数 component 是顶层组件,根据 Vue 错误传播规则[11] 可知,在非特殊控制的情况下,顶层的 errorCaptured
会捕获到错误component
对象在首次挂载时时序基本是一致的,导致挂载 vm($mount('#id')
)时,DOM 可能还没有渲染到文档流上,因此在首次渲染时需要一定的延迟后再挂载 vm。以上的不同点,并未给渲染用户自定义代码带来任何优势,反而增加了限制,尤其 需要稳定挂载点 这一条,需要对用户提供的 template 做二次注入,包裹挂载点,才能实现用户修改组件后的实时渲染更新,因此,也不能支持用户定义 render 函数,因为无法获取未经运行的 render 函数的返回值,也就无法注入外层的挂载点。
另外一点也需要注意,这种方式也是无法在容器组件中使用 template 定义渲染模板的,因为如果在 template 中写 style 标签会出现以下编译错误,但 style 标签是必须的,需要为自定义组件提供 scoped 的样式。(当然,也可以通过提供 appendStyle 函数实现动态添加 style 标签,但这样并没有更方便,因此没有必要)
Errors compiling template:
Templates should only be responsible for mapping the state to the UI. Avoid placing tags with side-effects in your templates, such as <style>, as they will not be parsed.
2 | <span :class="className">
3 | <span id="uid" />
4 | <style>{this.scopedStyle}</style>
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
5 | </span>
| ^^^^^^^
鉴于以上缺点,就不提供核心代码示范了,直接给源码和 demo
完整的代码见:https://github.com/merfais/vue-demo/blob/main/src/views/customCode/withMount.vue[12]
完整的 demo 见:https://merfais.github.io/vue-demo/#/custom-code[13]
想一下,如果动态实例方案仅仅有以上缺点,那考虑这种方案有什么意义呢?其实,它的意义在于,动态实例方案主要应用于 iframe 渲染,而使用 iframe 渲染的目的则是为了隔离。
iframe 会创建独立于主站的一个域,这种隔离可以很好地防止 js 污染和 css 污染,隔离方式又分为跨域隔离和非跨域隔离两种,跨域则意味着完全隔离,非跨域则是半隔离,其主要区别在于安全策略的限制,这个我们最后再说。
iframe 是否跨域由 iframe 的 src 的值决定,设置同域的 src 或不设置 src 均符合同域策略,否则是跨域。对于没有设置 src 的 iframe,页面只能加载一个空的 iframe,因此还需要在 iframe 加载完后再动态加载依赖的资源,如:vuejs,其他运行时的依赖库(示例 demo 加载了 ant-design-vue)等。如果设置了 src,则可以将依赖通过 script 标签和 link 标签提前写到静态页面文件中,使依赖资源在加载 iframe 时自动完成加载。
先介绍半隔离方式,即通过非跨域 iframe 渲染,首先需要渲染一个 iframe,我们使用不设置 src 的方式,这样更具备通用性,可以用于任意的站点。核心代码如下
<template>
<iframe ref="iframe" frameborder="0" scrolling="no" width="100%" />
</template>
由于是位于同域,主站与 iframe 可以互相读取 window 和 document 引用,因为,可以动态加载资源,核心代码如下
methods: {
mountResource() {
// 添加依赖的css
appendLink('https://cdn.bootcdn.net/ajax/libs/ant-design-vue/1.7.2/antd.min.css', this.iframeDoc)
// 添加依赖的js,保留handler用于首次渲染的异步控制
this.mountResourceHandler = appendScriptLink([{
src: 'https://cdn.bootcdn.net/ajax/libs/vue/2.6.12/vue.min.js',
defer: true,
}, {
src: 'https://cdn.bootcdn.net/ajax/libs/ant-design-vue/1.7.2/antd.min.js',
defer: true,
}], this.iframeDoc)
},
},
mounted() {
this.iframeDoc = this.$refs.iframe.contentDocument
this.mountResource()
},
接下来是组件对象组装和挂载,基本上和动态组件的大同小异,只是挂载不再通过 render 函数。先上核心代码,再说注意点。
computed: {
component() {
// 把代码字符串转成js对象
const component = safeStringToObject(this.js)
// 关联css,为的是修改css后可自动重绘
component.css = this.css
// 去掉template的前后标签
const template = (this.template || '')
.replace(/^ *< *template *>|<\/ *template *> *$/g, '')
.trim()
// 注入template或render,设定template优先级高于render
if (template) {
component.template = template
component.render = undefined
} else if (!component.render) {
component.template = '<span>未提供模板或render函数</span>'
}
return component
},
},
watch: {
component() {
if (this.hasInit) {
this.mountCode()
} else if (this.mountResourceHandler) {
this.mountResourceHandler.then(() => {
this.hasInit = true
this.mountCode()
})
}
},
},
methods: {
mountCode() {
// 添加css
const css = this.component.css
delete this.component.css
removeElement(this.styleId, this.iframeDoc)
this.styleId = appendStyle(css, this.iframeDoc)
// 重建挂载点
if (this.iframeDoc.body.firstElementChild) {
this.iframeDoc.body.removeChild(this.iframeDoc.body.firstElementChild)
}
prependDom({ tag: 'div', id: 'app' }, this.iframeDoc)
// 挂载实例
const Vue = this.iframeWin.Vue
new Vue(this.component).$mount('#app')
},
},
注意点:
docment.body.innerHTML=''
的方式可以快速且干净的清空 body 内容,但也会将第三方库添加的内容给干掉,导致第三方库全部或部分不可用。component
中也绑定了 css 的值,但这对于新建 vm 实例这个字段是无用的,也可以通过 watch css 的方式实现接下来考虑错误处理,对于 iframe 挂载的错误处理稍有不同,为了尽量不干预用户的代码,此模式下的错误渲染采用重建 DOM,重新渲染 vm 的策略,即发生错误后,无论是静态的语法错误还是运行时错误,都重绘。当然这种做法也就丢失了组件自刷新的功能,因为一旦发生错误,原来的组件会被卸载,渲染为错误信息。核心代码如下
computed: {
component() {
if (this.subCompErr) {
return this.renderError(this.subCompErr)
}
// 把代码字符串转成js对象
const result = safeStringToObject(this.js)
if (result.error) {
return this.renderError({
type: 'js脚本错误',
msg: result.error.toString(),
})
}
const component = result.value
// 注入errorCaptured, 用于错误自定义组件运行时捕获
component.errorCaptured = (err, vm, info) => {
this.subCompErr = {
msg: err && err.toString && err.toString(),
type: '自定义组件运行时错误:',
}
console.error('自定义组件运行时错误:', err, vm, info)
}
return component
},
},
watch: {
js() {
// 当代码变化时,清空error,重绘
this.subCompErr = null
},
template() {
// 当代码变化时,清空error,重绘
this.subCompErr = null
},
css() {
// 当代码变化时,清空error,重绘
this.subCompErr = null
},
},
methods: {
renderError({ type, msg }) {
return {
render() {
return <div style='color: red'>
<div>{type}</div>
<div>{msg}</div>
</div>
},
}
},
},
除了错误处理,还需解决一下 iframe 的一些特性,比如边框,滚动条,默认宽高,其中比较棘手是 iframe 高度有默认值,并不会随着 iframe 的内容自适应高度,但对于自定义组件的渲染,需要动态计算高度,固定高度是不行的。
边框,滚动条,宽度可通过修改 iframe 的属性解决,见上面的 template 代码。
高度自适应的解决方案是通过MutationObserver
观测 iframe 的 body 变化,在回调中计算挂载点(第一个子元素)的高度,然后再修改 iframe 本身的高度。之所以没有直接使用 body 的高度,是因为 body 有默认的高度,当被渲染的组件高度小于 body 高度时,直接使用 body 的高度是错的。核心代码如下
mounted() {
// 通过观察器观察iframe的body变化后修改iframe的高度,
// 使用iframe后垂直的margin重合效果会丢失
const observer = new MutationObserver(() => {
const firstEle = this.iframeDoc.body.firstElementChild
const rect = firstEle.getBoundingClientRect()
const marginTop = parseFloat(window.getComputedStyle(firstEle).marginTop, 10)
const marginBottom = parseFloat(window.getComputedStyle(firstEle).marginBottom, 10)
this.$refs.iframe.height = `${rect.height + marginTop + marginBottom}px`
})
observer.observe(this.iframeDoc.body, { childList: true })
},
使用 iframe 还存在一些局限性,最需要注意的一点就是由于 iframe 是独立的窗体,那么渲染出来的组件只能封在这个窗体内,因此,像一些本应该是全局的 toast, modal, drawer 都会被局限在 iframe 内,无法覆盖到全局上。
完整的代码见:https://github.com/merfais/vue-demo/blob/main/src/views/customCode/mountSameIframe.vue[14]
完整的 demo 见:https://merfais.github.io/vue-demo/#/custom-code[15]
至此非跨域 iframe 渲染全部逻辑介绍完毕,接下来看一下跨域 iframe 的渲染。跨域 iframe 与非跨域 iframe 的渲染过程基本是一致的,只是有由于跨域,隔离的更彻底。其主要体现在主域与 iframe 域不能互相读写对方的文档流 document。
此限制带来的变化有以下几点
postMessage
。
为了通用性,调用postMessage
时可以设置origin = *
,但由于接收 postMessage 消息通过 window.addEventListener("message", callback)
这种通用的方式,可能会接受来自多个域的非期待的消息,因此,需要对通信消息定制特殊协议格式,防止出现处理了未知消息而发生异常。
两者间通信是双向的,主站向 iframe 只需传递一种消息,即含组件完整内容的挂载消息,iframe 接到消息后执行重绘渲染逻辑;iframe 向主站传递两种消息,一是可以挂载的状态消息,主站接到消息后执行首次渲染逻辑,即发送首次挂载消息,二是 body size 变化的消息,主站接到消息后修改 iframe 的尺寸。在处理主域将组件内容通过postMessage
传给 iframe 时,碰到了一个棘手的问题,postMessage 对可传递的数据有限制,具体的限制可查看 The structured clone algorithm[16],这个限制导致Function
类型的数据无法传过去,但组件很多功能需要使用函数才能实现,无法跨越这个限制,组件能力将损失过半或更甚。
对于这个限制的解决方案是:对不支持的数据类型进行序列化,转成支持的类型,如 string,渲染时再反序列化回来。核心代码如下
// 序列化
function serialize(data) {
// 对象深度递归
if (Object.prototype.toString.call(data) === "[object Object]") {
const result = {};
forEach(data, (item, key) => {
result[key] = this.serialize(item);
});
return result;
}
if (Array.isArray(data)) {
return data.map((item) => this.serialize(item));
}
// 函数前后打上特殊标记后转成string
if (typeof data === "function") {
return encodeURI(`##${data.toString()}##`);
}
// 其他类型直接返回
return data;
}
// 反序列化
function deserialize(data) {
// 对象深度递归
if (Object.prototype.toString.call(data) === "[object Object]") {
const result = {};
Object.keys(data).forEach((key) => {
result[key] = this.deserialize(data[key]);
});
return result;
}
if (Array.isArray(data)) {
return data.map((item) => this.deserialize(item));
}
// string类型尝试解析
if (typeof data === "string") {
const str = decodeURI(data);
// 匹配特殊标记,匹配成功,反转为function
const matched = str.match(/^##([^#]*)##$/);
if (matched) {
// string转成function可以用eval也可用new Function
return newFn(matched[1]);
}
return data;
}
// 其他类型直接返回
return data;
}
序列化方案看似完美,其实也有诸多的不便,毕竟是一种降级,需要特别注意的一点是,闭包被破坏,或者说是不支持闭包函数,举个例子:
computed: {
component() {
// 把代码字符串转成js对象
const result = safeStringToObject(this.js)
if (result.error) {
return this.renderError({
type: 'js脚本错误',
msg: result.error.toString(),
})
}
// ...
return component
},
},
methods: {
renderError({ type, msg }) {
return {
// 这里用到了闭包,render函数使用了外层变量type和msg,
// renderError函数执行结束后这两个变量并不会释放,需等render函数执行后才会释放
render() {
return <div style='color: red'>
<div>{type}</div>
<div>{msg}</div>
</div>
}
}
},
},
上面在生成 component 对象时调用了函数renderError
,此函数返回了一个函数render
,且使用了外层函数renderError
的两个参数,正常情况下运行是没有问题的,type
和msg
的引用(引用计数)会等到render
函数执行后才会释放(引用计数清零)。
但 component 对象经过序列化后,其内部的函数被转成了字符串,因而丢失了函数的所有特性,闭包也因此丢失,经反序列化回来后,虽然还原了函数,但闭包关系无法恢复,因此,这种写法,在执行 render 时,type
和msg
两个参数会变为undefined
。
为了规避这种限制,应在导出 component 对象时避免使用含闭包的函数, 上例中的错误处理可通过以下方式解决
computed: {
component() {
// 把代码字符串转成js对象
const result = safeStringToObject(this.js)
if (result.error) {
const template = this.genErrorTpl({
type: 'js脚本错误',
msg: result.error.toString(),
})
return { template }
}
// ...
return component
},
},
methods: {
genErrorTpl({ type, msg }) {
return `<div style='color: red'><div>${type}</div><div>${msg}</div></div>`
},
}
完整的代码见:
完整的 demo 见:https://merfais.github.io/vue-demo/#/custom-code[19]
通常情况下,在需要将用户输入持久化的系统中,都要考虑 XSS 的注入攻击,而防止注入的主要表现则是使用户输入的数据不被执行,或不能被执行。
而前文介绍的要支持用户自定义组件的渲染,恰好就是要执行用户代码,可见,此功能势必会带来 XSS 注入风险。
因此,在使用此功能时要慎重,在不同的应用场景中,要根据系统的安全级别,选取相应的方案。对比以上四种方案(1 种动态组件,3 种动态挂载)可做以下选择
在一些相对安全(允许 xss 注入,注入后没有安全问题)的系统中,可以使用前三种方案中的任意一种,这三种都是可以通过注入获取用户 cookie 的。个人推荐使用第一种动态渲染方案,因为此方案灵活性和渲染完整度都是最高的。
在一些不太安全(xss 注入可能会泄露 cookie 中的身份信息)的系统中,推荐使用最后一种跨域组件挂载方案,通过完全隔离策略可以最大程度的降低风险,当然此方案也有很多的局限性。