前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Vue隐藏技能:运行时渲染用户写入的组件代码!

Vue隐藏技能:运行时渲染用户写入的组件代码!

作者头像
@超人
发布2021-02-26 11:07:54
3.6K0
发布2021-02-26 11:07:54
举报
文章被收录于专栏:Vue中文社区

作者:还没想好

https://zhuanlan.zhihu.com/p/347509262

前段时间接了一个需求:能不能让用户自制组件,从而达到定制渲染某个区域的目的。说实话接到这个需求心中一惊,感叹这个想法真是大胆呀,但作为打工人,秉承着只要思想不滑坡,办法总比困难多的打工魂,即使是刀山也得上呀,历经几日的摸索调研,发现其实 VUE 一早就支持了这么做,只不过时过境迁,渐渐被遗忘了这个隐藏的技能。

大致说一下项目的背景:我们做了一个拖拽生成报表的系统,通过拖拽内置的组件供用户定制自己的报表形态,但毕竟内置的组件有限,可定制性不高,那么给用户开放一个 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 就可以渐进式开发了,也就具备了运行时+编译器,如下

代码语言:javascript
复制
<!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]。

此时我们就有了一个运行时编译环境

第二步:把用户的代码注册到系统中

把代码渲染出来有两个方案

  1. 通过 注册组件[5] 的方式,把代码注册为 vue 实例的组件,注册组件又分 全局注册 和 局部注册 两种方式
  2. 通过挂载点直接挂载 vue 实例, 即通过new Vue({ el: '#id' })的方式
第一种方案:动态组件

对于这种方式,在官方文档中,组件注册章节,最后给出了一个注意点

记住全局注册的行为必须在根 Vue 实例 (通过 new Vue) 创建之前发生。

因此,并不能通过调用Vue.component('my-component-name', {/* */})的方式将用户的代码注册到系统中,因为运行时 Vue 实例已经创建完,用户的代码是在实例完 Vue 后才进来的,那我们只能通过局部注册的方式了,类似这样

代码语言:javascript
复制
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 的容器,容器负责渲染用户的代码,使用地方把容器挂上就行了。

  • 容器核心代码
代码语言:javascript
复制
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>
    );
  },
};
  • 容器使用
代码语言:javascript
复制
<template>
  <custom-code :js="js" :template="template" :css="css" />
</template>

以上只是核心的逻辑部分,除了这些,在项目实战中还应考虑容错处理,错误大致可以分两种

用户代码语法错误

主要是 js 部分,对于 css 和 template 的错误,浏览器有一定的纠错的机制,不至于崩了。

这部分的处理主要借助于safeStringToObject这个函数,如果有语法错误,则返回 Error,处理一下回显给用户,代码大致如下

代码语言:javascript
复制
// 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],来捕获子组件的错误,因为并没有一个途径可以获取组件自身运行时错误的钩子。代码大致如下

代码语言:javascript
复制
errorCaptured(err, vm, info) {
  this.subCompErr = {
    msg: err && err.toString && err.toString() || err,
    type: '自定义组件运行时错误:',
  }
  console.error('自定义组件运行时错误:', err, vm, info)
},

结合错误处理,如果希望用户能看到错误信息,则 render 函数需要把错误展示出来,代码大致如下

代码语言:javascript
复制
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钩子解决,代码大致如下

代码语言:javascript
复制
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对象进行渲染,但也有些许的区别,动态实例比动态组件需要多考虑以下几点:

  1. 需要一个稳定的挂载点 从 vue2.0 开始,vue 实例的挂载策略变更为,所有的挂载元素会被 Vue 生成的 DOM 替换[10],在此策略下,一旦执行挂载,原来的 DOM 就会消失,不能再次挂载。但我们需要实现代码变更后能够重新渲染,这就要求挂载点要稳定存在,解决方案是对用户的 template 进行注入,每次渲染前,在 template 外层包一层带固定 id 的 DOM
  2. 运行时错误捕获errorCaptured需要注入到component对象上,不再需要注入beforeUpdate钩子 因为通过new Vue()的方式创建了一个新的 vm 实例,不再是容器组件的子组件,所以容器组件上的errorCaptured无法捕获新 vm 的运行时错误,new Vue(component)中参数 component 是顶层组件,根据 Vue 错误传播规则[11] 可知,在非特殊控制的情况下,顶层的 errorCaptured 会捕获到错误
  3. 首次挂载需要制造一定的延迟才能渲染 由于挂载点含在 DOM 在容器内,与计算属性导出的component对象在首次挂载时时序基本是一致的,导致挂载 vm($mount('#id'))时,DOM 可能还没有渲染到文档流上,因此在首次渲染时需要一定的延迟后再挂载 vm。

以上的不同点,并未给渲染用户自定义代码带来任何优势,反而增加了限制,尤其 需要稳定挂载点 这一条,需要对用户提供的 template 做二次注入,包裹挂载点,才能实现用户修改组件后的实时渲染更新,因此,也不能支持用户定义 render 函数,因为无法获取未经运行的 render 函数的返回值,也就无法注入外层的挂载点。

另外一点也需要注意,这种方式也是无法在容器组件中使用 template 定义渲染模板的,因为如果在 template 中写 style 标签会出现以下编译错误,但 style 标签是必须的,需要为自定义组件提供 scoped 的样式。(当然,也可以通过提供 appendStyle 函数实现动态添加 style 标签,但这样并没有更方便,因此没有必要)

代码语言:javascript
复制
  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 的方式,这样更具备通用性,可以用于任意的站点。核心代码如下

代码语言:javascript
复制
<template>
  <iframe ref="iframe" frameborder="0" scrolling="no" width="100%" />
</template>

由于是位于同域,主站与 iframe 可以互相读取 window 和 document 引用,因为,可以动态加载资源,核心代码如下

代码语言:javascript
复制
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 函数。先上核心代码,再说注意点。

代码语言:javascript
复制
  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')
    },
  },

注意点:

  1. iframe 的渲染到文档流后才能添加依赖资源,依赖资源加载完才能执行 vm 的挂载,首次加载时需要控制时序
  2. vm 挂载点的重建采用了永远添加在 body 的第一个子元素的方式,这么做的原因是一些第三方的库(如 ant-design-vue)也会向 body 中动态添加 element,虽然采用docment.body.innerHTML=''的方式可以快速且干净的清空 body 内容,但也会将第三方库添加的内容给干掉,导致第三方库全部或部分不可用。
  3. 为了使 css 变化后也引发重绘,在计算属性component中也绑定了 css 的值,但这对于新建 vm 实例这个字段是无用的,也可以通过 watch css 的方式实现

接下来考虑错误处理,对于 iframe 挂载的错误处理稍有不同,为了尽量不干预用户的代码,此模式下的错误渲染采用重建 DOM,重新渲染 vm 的策略,即发生错误后,无论是静态的语法错误还是运行时错误,都重绘。当然这种做法也就丢失了组件自刷新的功能,因为一旦发生错误,原来的组件会被卸载,渲染为错误信息。核心代码如下

代码语言:javascript
复制
 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 的高度是错的。核心代码如下

代码语言:javascript
复制
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。

此限制带来的变化有以下几点

  1. 依赖的资源需要提前内置在 iframe 内。 内置指的是将依赖的资源通过 script,link 标签添加到 html 文件中,随 html 一并加载。有一点还需要注意,如果挂载 vm 时需要依赖某些资源,需要添加资源加载的回调,加载成功后再通知主域挂载。
  2. iframe 重新绘制需要各种元素操作只能由 iframe 自己完成 在非跨域 iframe 模式下所有的元素操作都在主域中完成,在跨域模式下这些操作和流程控制都需要以 script 编码的方式内置在 html 内,在接到主域的挂载消息后,完整挂载过程。
  3. 主域与 iframe 的通信需要通过postMessage。 为了通用性,调用postMessage时可以设置origin = *,但由于接收 postMessage 消息通过 window.addEventListener("message", callback)这种通用的方式,可能会接受来自多个域的非期待的消息,因此,需要对通信消息定制特殊协议格式,防止出现处理了未知消息而发生异常。 两者间通信是双向的,主站向 iframe 只需传递一种消息,即含组件完整内容的挂载消息,iframe 接到消息后执行重绘渲染逻辑;iframe 向主站传递两种消息,一是可以挂载的状态消息,主站接到消息后执行首次渲染逻辑,即发送首次挂载消息,二是 body size 变化的消息,主站接到消息后修改 iframe 的尺寸。

在处理主域将组件内容通过postMessage传给 iframe 时,碰到了一个棘手的问题,postMessage 对可传递的数据有限制,具体的限制可查看 The structured clone algorithm[16],这个限制导致Function类型的数据无法传过去,但组件很多功能需要使用函数才能实现,无法跨越这个限制,组件能力将损失过半或更甚。

对于这个限制的解决方案是:对不支持的数据类型进行序列化,转成支持的类型,如 string,渲染时再反序列化回来。核心代码如下

代码语言:javascript
复制
// 序列化
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;
}

序列化方案看似完美,其实也有诸多的不便,毕竟是一种降级,需要特别注意的一点是,闭包被破坏,或者说是不支持闭包函数,举个例子:

代码语言:javascript
复制
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的两个参数,正常情况下运行是没有问题的,typemsg的引用(引用计数)会等到render函数执行后才会释放(引用计数清零)。

但 component 对象经过序列化后,其内部的函数被转成了字符串,因而丢失了函数的所有特性,闭包也因此丢失,经反序列化回来后,虽然还原了函数,但闭包关系无法恢复,因此,这种写法,在执行 render 时,typemsg两个参数会变为undefined

为了规避这种限制,应在导出 component 对象时避免使用含闭包的函数, 上例中的错误处理可通过以下方式解决

代码语言:javascript
复制
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>`
  },
}

完整的代码见:

  • 组件:https://github.com/merfais/vue-demo/blob/main/src/views/customCode/mountCrossIframe.vue[17]
  • iframe: https://gitlab.com/merfais/static-page/-/blob/master/public/iframe.html[18]

完整的 demo 见:https://merfais.github.io/vue-demo/#/custom-code[19]

XSS 注入与安全

通常情况下,在需要将用户输入持久化的系统中,都要考虑 XSS 的注入攻击,而防止注入的主要表现则是使用户输入的数据不被执行,或不能被执行。

而前文介绍的要支持用户自定义组件的渲染,恰好就是要执行用户代码,可见,此功能势必会带来 XSS 注入风险。

因此,在使用此功能时要慎重,在不同的应用场景中,要根据系统的安全级别,选取相应的方案。对比以上四种方案(1 种动态组件,3 种动态挂载)可做以下选择

在一些相对安全(允许 xss 注入,注入后没有安全问题)的系统中,可以使用前三种方案中的任意一种,这三种都是可以通过注入获取用户 cookie 的。个人推荐使用第一种动态渲染方案,因为此方案灵活性和渲染完整度都是最高的。

在一些不太安全(xss 注入可能会泄露 cookie 中的身份信息)的系统中,推荐使用最后一种跨域组件挂载方案,通过完全隔离策略可以最大程度的降低风险,当然此方案也有很多的局限性。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-02-22,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Vue中文社区 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前段时间接了一个需求:能不能让用户自制组件,从而达到定制渲染某个区域的目的。说实话接到这个需求心中一惊,感叹这个想法真是大胆呀,但作为打工人,秉承着只要思想不滑坡,办法总比困难多的打工魂,即使是刀山也得上呀,历经几日的摸索调研,发现其实 VUE 一早就支持了这么做,只不过时过境迁,渐渐被遗忘了这个隐藏的技能。
  • 重提渐进式
  • 本地编译与运行时编译
  • 技术干货
    • 第一步:需要一个运行时编译环境
      • 第二步:把用户的代码注册到系统中
        • 第一种方案:动态组件
        • 第二种方案:动态实例
    • XSS 注入与安全
    相关产品与服务
    容器服务
    腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
    领券
    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档