VUE学习笔记—Vue运行时渲染

前言

有一个需求:能不能让用户自制组件,从而达到定制渲染某个区域的目的。
在线DOME预览VUE学习笔记—Vue运行时渲染_第1张图片

大致说一下项目的背景:我们做了一个拖拉拽生成界面的系统,通过拖拽内置的组件供用户定制自己的界面,但毕竟内置的组件有限,可定制性不高,那么给用户开放一个自定义代码组件,让用户自己通过写template + js + css的方式自由定制岂不是妙哉。

那么该怎么实现呢?我们先来看一vue官方的介绍

很多时候我们貌似已经忽略了渐进式这回事,现在基于VUE开发的项目大多都采用vue cli生成,以vue单文件的方式编码,webpack编译打包的形式发布。这与渐进式有什么关系呢,确实没有关系。

渐进式其实指的在一个已存在的但并未使用vue的项目上接入vue,使用vue,直到所有的HTML渐渐替换为通过vue渲染完成,渐进开发,渐进迁移,这种方式在vue刚出现那几年比较多,现在或许在一些古老的项目也会出现。

为什么要提渐进式呢?因为渐进式是不需要本地编译的,有没有get到点!对,就是不需要本地编译,而是运行时编译。

本地编译与运行时编译

用户想通过编写template + js + css的方式实现运行时渲染页面,那肯定是不能本地编译的(此处的编译指将vue文件编译为js资源文件),即不能把用户写的代码像编译源码一样打包成静态资源文件。

这些代码只能原样持久化到数据库,每次打开页面再恢复回来,实时编译。毕竟不是纯js文件,是不能直接运行的,它需要一个运行时环境,运行时编译,这个环境就是 vue的运行时 + 编译器。

有了思路也只是窥到了天机,神功练成还是要打磨细节。具体怎么做,容我一步步道来。

技术干货
第一步:需要一个运行时编译环境

按官方的介绍,通过script标签引入vue就可以渐进式开发了,也就具备了运行时+编译器,如下




  Document
  


  
{{message}}

但通过vue单文件+webpack编译的方式,再引入一个vue就多余了,通过CLI也是可以的,只需要在vue.config.js中打开runtimeCompiler开关就行了,详细看文档。

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

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

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

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

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

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

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

var ComponentB = {
  components: {
    'component-a': {
      ...customJsLogic,
      name: 'custom-component',
      template: '
custom template
', } }, // ... }

但想一下,好像不太对,这还是在写源码,运行时定义了ComponentB组件怎么用呢,怎么把ComponentB在一个已经编译完页面上渲染出来呢?找不到入口点,把用户代码注入到components对象上也无法注册到系统中,无法渲染出来。

就止步于此了吗?该怎么办呢?

想一下为什么要在components中先注册(声明)下组件,然后才能使用?component本质上只不过是一个js object而已。其实主要是为了服务于template模板语法,当你在template中写了 ,有了这个注册声明才能在编译时找到compA。如果不使用template,那么这个注册就可以省了。

不使用template怎么渲染呢,使用render函数呀!

在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 = '
未提供模板或render函数
' } return component }, }, render() { const { component } = this return
}, }
  • 容器使用

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

  1. 用户代码语法错误
    主要是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
}
  1. 组件运行时错误
    既然把js逻辑交给了用户控制,那么像类型错误,从undefined中读值,把非函数变量当函数运行,甚至拼写错误等这些运行时错误就很有可能发生。
    这部分的处理需要通过在容器组件上添加 errorCaptured这个官方钩子,来捕获子组件的错误,因为并没有一个途径可以获取组件自身运行时错误的钩子。代码大致如下`
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 = 
{error.type}
{error.msg}
} return
{errorDom}
},

这里还有一个点,用户发现组件发生了错误后会修改代码,使其再次渲染,错误的回显需要特别处理下。

对于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/hqiaozhang/vue-custom-code/blob/master/src/views/customCode/withComponent.vue

第二种方案:动态实例

我们知道在利用vue构建的系统中,页面由组件构成,页面本身其实也是组件,只是在部分参数和挂载方式上有些区别而已。这第二种方式就是将用户的代码视为一个page,通过new一个vm实例,再在DOM挂载点挂载vm(new Vue(component).$mount('#id'))的方式渲染。

动态实例方案与动态组件方案大致相同,都要通过computed属性,生成component对象和scopedStyle对象进行渲染,但也有些许的区别,动态实例比动态组件需要多考虑以下几点:

  1. 需要一个稳定的挂载点
    从vue2.0开始,vue实例的挂载策略变更为,所有的挂载元素会被 Vue 生成的 DOM 替换,在此策略下,一旦执行挂载,原来的DOM就会消失,不能再次挂载。但我们需要实现代码变更后能够重新渲染,这就要求挂载点要稳定存在,解决方案是对用户的template进行注入,每次渲染前,在template外层包一层带固定id的DOM

  2. 运行时错误捕获errorCaptured需要注入到component对象上,不再需要注入beforeUpdate钩子
    因为通过new Vue()的方式创建了一个新的vm实例,不再是容器组件的子组件,所以容器组件上的errorCaptured无法捕获新vm的运行时错误,new Vue(component)中参数component是顶层组件,根据 Vue错误传播规则 可知,在非特殊控制的情况下,顶层的 errorCaptured 会捕获到错误

  3. 首次挂载需要制造一定的延迟才能渲染
    由于挂载点含在DOM在容器内,与计算属性导出的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 
     |    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  5  |  
     |  ^^^^^^^

鉴于以上缺点,就不提供核心代码示范了,直接给源码和demo

完整的代码见:https://github.com/hqiaozhang/vue-custom-code/blob/master/src/views/customCode/withMount.vue

想一下,如果动态实例方案仅仅有以上缺点,那考虑这种方案有什么意义呢?其实,它的意义在于,动态实例方案主要应用于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的方式,这样更具备通用性,可以用于任意的站点。核心代码如下