VUE學(xué)習(xí)筆記—Vue運(yùn)行時(shí)渲染

前言

有一個(gè)需求:能不能讓用戶(hù)自制組件,從而達(dá)到定制渲染某個(gè)區(qū)域的目的。
在線DOME預(yù)覽

大致說(shuō)一下項(xiàng)目的背景:我們做了一個(gè)拖拉拽生成界面的系統(tǒng),通過(guò)拖拽內(nèi)置的組件供用戶(hù)定制自己的界面,但畢竟內(nèi)置的組件有限,可定制性不高,那么給用戶(hù)開(kāi)放一個(gè)自定義代碼組件,讓用戶(hù)自己通過(guò)寫(xiě)template + js + css的方式自由定制豈不是妙哉。

那么該怎么實(shí)現(xiàn)呢?我們先來(lái)看一vue官方的介紹

很多時(shí)候我們貌似已經(jīng)忽略了漸進(jìn)式這回事,現(xiàn)在基于VUE開(kāi)發(fā)的項(xiàng)目大多都采用vue cli生成,以vue單文件的方式編碼,webpack編譯打包的形式發(fā)布。這與漸進(jìn)式有什么關(guān)系呢,確實(shí)沒(méi)有關(guān)系。

漸進(jìn)式其實(shí)指的在一個(gè)已存在的但并未使用vue的項(xiàng)目上接入vue,使用vue,直到所有的HTML漸漸替換為通過(guò)vue渲染完成,漸進(jìn)開(kāi)發(fā),漸進(jìn)遷移,這種方式在vue剛出現(xiàn)那幾年比較多,現(xiàn)在或許在一些古老的項(xiàng)目也會(huì)出現(xiàn)。

為什么要提漸進(jìn)式呢?因?yàn)闈u進(jìn)式是不需要本地編譯的,有沒(méi)有g(shù)et到點(diǎn)!對(duì),就是不需要本地編譯,而是運(yùn)行時(shí)編譯。

本地編譯與運(yùn)行時(shí)編譯

用戶(hù)想通過(guò)編寫(xiě)template + js + css的方式實(shí)現(xiàn)運(yùn)行時(shí)渲染頁(yè)面,那肯定是不能本地編譯的(此處的編譯指將vue文件編譯為js資源文件),即不能把用戶(hù)寫(xiě)的代碼像編譯源碼一樣打包成靜態(tài)資源文件。

這些代碼只能原樣持久化到數(shù)據(jù)庫(kù),每次打開(kāi)頁(yè)面再恢復(fù)回來(lái),實(shí)時(shí)編譯。畢竟不是純js文件,是不能直接運(yùn)行的,它需要一個(gè)運(yùn)行時(shí)環(huán)境,運(yùn)行時(shí)編譯,這個(gè)環(huán)境就是 vue的運(yùn)行時(shí) + 編譯器。

有了思路也只是窺到了天機(jī),神功練成還是要打磨細(xì)節(jié)。具體怎么做,容我一步步道來(lái)。

技術(shù)干貨

第一步:需要一個(gè)運(yùn)行時(shí)編譯環(huán)境

官方的介紹,通過(guò)script標(biāo)簽引入vue就可以漸進(jìn)式開(kāi)發(fā)了,也就具備了運(yùn)行時(shí)+編譯器,如下

<!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>

但通過(guò)vue單文件+webpack編譯的方式,再引入一個(gè)vue就多余了,通過(guò)CLI也是可以的,只需要在vue.config.js中打開(kāi)runtimeCompiler開(kāi)關(guān)就行了,詳細(xì)看文檔

此時(shí)我們就有了一個(gè)運(yùn)行時(shí)編譯環(huán)境

第二步:把用戶(hù)的代碼注冊(cè)到系統(tǒng)中

把代碼渲染出來(lái)有兩個(gè)方案

  1. 通過(guò) 注冊(cè)組件 的方式,把代碼注冊(cè)為vue實(shí)例的組件,注冊(cè)組件又分全局注冊(cè)和局部注冊(cè)兩種方式
  2. 通過(guò)掛載點(diǎn)直接掛載vue實(shí)例, 即通過(guò)new Vue({ el: '#id' })的方式

第一種方案:動(dòng)態(tài)組件

對(duì)于這種方式,在官方文檔中,組件注冊(cè)章節(jié),最后給出了一個(gè)注意點(diǎn)

記住全局注冊(cè)的行為必須在根 Vue 實(shí)例 (通過(guò) new Vue) 創(chuàng)建之前發(fā)生。

因此,并不能通過(guò)調(diào)用Vue.component('my-component-name', {/* */})的方式將用戶(hù)的代碼注冊(cè)到系統(tǒng)中,因?yàn)檫\(yùn)行時(shí)Vue實(shí)例已經(jīng)創(chuàng)建完,用戶(hù)的代碼是在實(shí)例完Vue后才進(jìn)來(lái)的,那我們只能通過(guò)局部注冊(cè)的方式了,類(lèi)似這樣

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

但想一下,好像不太對(duì),這還是在寫(xiě)源碼,運(yùn)行時(shí)定義了ComponentB組件怎么用呢,怎么把ComponentB在一個(gè)已經(jīng)編譯完頁(yè)面上渲染出來(lái)呢?找不到入口點(diǎn),把用戶(hù)代碼注入到components對(duì)象上也無(wú)法注冊(cè)到系統(tǒng)中,無(wú)法渲染出來(lái)。

就止步于此了嗎?該怎么辦呢?

想一下為什么要在components中先注冊(cè)(聲明)下組件,然后才能使用?component本質(zhì)上只不過(guò)是一個(gè)js object而已。其實(shí)主要是為了服務(wù)于template模板語(yǔ)法,當(dāng)你在template中寫(xiě)了 <compA propA='value'/>,有了這個(gè)注冊(cè)聲明才能在編譯時(shí)找到compA。如果不使用template,那么這個(gè)注冊(cè)就可以省了。

不使用template怎么渲染呢,使用render函數(shù)呀!

在render函數(shù)中如果使用createElement就比較麻煩了,API很復(fù)雜,對(duì)于渲染一整段用戶(hù)定義的template也略顯吃力,使用jsx就方便多了,都1202年了,想必大家對(duì)jsx都應(yīng)該有所了解。

回到項(xiàng)目上,需要使用用戶(hù)代碼的地方不止一處,都用render函數(shù)寫(xiě)一遍略顯臃腫,那么做一個(gè)code的容器,容器負(fù)責(zé)渲染用戶(hù)的代碼,使用地方把容器掛上就行了。

  • 容器核心代碼
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加前綴,做類(lèi)似scope的效果
        return this.css.trim().replace(regex, (m, g1, g2) => {  
          return g1 ? `${g1} ${scope} ${g2}` : `${scope} ${g2}`
        })
      }
      return ''
    },
    component() {
      // 把代碼字符串轉(zhuǎn)成js對(duì)象
      const component = safeStringToObject(this.js)

      // 去掉template的前后標(biāo)簽
      const template = (this.template || '')
        .replace(/^ *< *template *>|<\/ *template *> *$/g, '')
        .trim()

      // 注入template或render,設(shè)定template優(yōu)先級(jí)高于render
      if (this.template) {
        component.template = this.template
        component.render = undefined
      } else if (!component.render) {
        component.render = '<div>未提供模板或render函數(shù)</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>

以上只是核心的邏輯部分,除了這些,在項(xiàng)目實(shí)戰(zhàn)中還應(yīng)考慮容錯(cuò)處理,錯(cuò)誤大致可以分兩種

  1. 用戶(hù)代碼語(yǔ)法錯(cuò)誤
    主要是js部分,對(duì)于css和template的錯(cuò)誤,瀏覽器有一定的糾錯(cuò)的機(jī)制,不至于崩了。
    這部分的處理主要借助于safeStringToObject這個(gè)函數(shù),如果有語(yǔ)法錯(cuò)誤,則返回Error,處理一下回顯給用戶(hù),代碼大致如下
// component對(duì)象在result.value上取,如果result.error有值,則代表出現(xiàn)了錯(cuò)誤
component() {
  // 把代碼字符串轉(zhuǎn)成js對(duì)象
  const result = safeStringToObject(this.js)
  
  const component = result.value
  if (result.error) {
    console.error('js 腳本錯(cuò)誤', result.error)
    result.error = {
      msg: result.error.toString(),
      type: 'js腳本錯(cuò)誤',
    }
    result.value = { hasError: true }
    return result
  }
  
  // ...
  
  retrun result
}
  1. 組件運(yùn)行時(shí)錯(cuò)誤
    既然把js邏輯交給了用戶(hù)控制,那么像類(lèi)型錯(cuò)誤,從undefined中讀值,把非函數(shù)變量當(dāng)函數(shù)運(yùn)行,甚至拼寫(xiě)錯(cuò)誤等這些運(yùn)行時(shí)錯(cuò)誤就很有可能發(fā)生。
    這部分的處理需要通過(guò)在容器組件上添加 errorCaptured這個(gè)官方鉤子,來(lái)捕獲子組件的錯(cuò)誤,因?yàn)椴](méi)有一個(gè)途徑可以獲取組件自身運(yùn)行時(shí)錯(cuò)誤的鉤子。代碼大致如下`
errorCaptured(err, vm, info) {
  this.subCompErr = {
    msg: err && err.toString && err.toString() || err,
    type: '自定義組件運(yùn)行時(shí)錯(cuò)誤:',
  }
  console.error('自定義組件運(yùn)行時(shí)錯(cuò)誤:', err, vm, info)
},

結(jié)合錯(cuò)誤處理,如果希望用戶(hù)能看到錯(cuò)誤信息,則render函數(shù)需要把錯(cuò)誤展示出來(lái),代碼大致如下

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>
},

這里還有一個(gè)點(diǎn),用戶(hù)發(fā)現(xiàn)組件發(fā)生了錯(cuò)誤后會(huì)修改代碼,使其再次渲染,錯(cuò)誤的回顯需要特別處理下。

對(duì)于js腳本錯(cuò)誤,因component是計(jì)算屬性,隨著computed計(jì)算屬性再次計(jì)算,如果js腳本沒(méi)有錯(cuò)誤,導(dǎo)出的component可重繪出來(lái),

但對(duì)于運(yùn)行時(shí)錯(cuò)誤,使用this.subCompErr內(nèi)部變量保存,props修改了,這個(gè)值卻不會(huì)被修改,因此需要打通props關(guān)聯(lián),通過(guò)添加watch的方式解決,這里為什么沒(méi)有放在component的計(jì)算屬性中做,一是違背計(jì)算屬性設(shè)計(jì)原則,二是component可能并不僅僅依賴(lài)js,css,template這個(gè)props的變化,而this.subCompErr只需要和這個(gè)三個(gè)props關(guān)聯(lián),這么做會(huì)有多余的重置邏輯。

還有一種場(chǎng)景就是子組件自身可能有定時(shí)刷新邏輯,定期或不定期的重繪,一旦發(fā)生了錯(cuò)誤,也會(huì)導(dǎo)致一直顯示錯(cuò)誤信息,因?yàn)橛脩?hù)的代碼拿不到this.subCompErr的值,因此也無(wú)法重置此值,這種情況,可通過(guò)注入beforeUpdate鉤子解決,代碼大致如下

computed: {
    component() {
      // 把代碼字符串轉(zhuǎn)成js對(duì)象
      const result = safeStringToObject(this.js)
      const component = result.value
      // ...
      // 注入mixins
      component.mixins = [{
        // 注入 beforeUpdate 鉤子,用于子組件重繪時(shí),清理父組件捕獲的異常
        beforeUpdate: () => {
          this.subCompErr = null
        },
      }]
      // ...
      return result
    },
},      
watch: {
    js() {
      // 當(dāng)代碼變化時(shí),清空error,重繪
      this.subCompErr = null
    },
    template() {
      // 當(dāng)代碼變化時(shí),清空error,重繪
      this.subCompErr = null
    },
    css() {
      // 當(dāng)代碼變化時(shí),清空error,重繪
      this.subCompErr = null
    },
  },

完整的代碼見(jiàn)https://github.com/hqiaozhang/vue-custom-code/blob/master/src/views/customCode/withComponent.vue

第二種方案:動(dòng)態(tài)實(shí)例

我們知道在利用vue構(gòu)建的系統(tǒng)中,頁(yè)面由組件構(gòu)成,頁(yè)面本身其實(shí)也是組件,只是在部分參數(shù)和掛載方式上有些區(qū)別而已。這第二種方式就是將用戶(hù)的代碼視為一個(gè)page,通過(guò)new一個(gè)vm實(shí)例,再在DOM掛載點(diǎn)掛載vm(new Vue(component).$mount('#id'))的方式渲染。

動(dòng)態(tài)實(shí)例方案與動(dòng)態(tài)組件方案大致相同,都要通過(guò)computed屬性,生成component對(duì)象和scopedStyle對(duì)象進(jìn)行渲染,但也有些許的區(qū)別,動(dòng)態(tài)實(shí)例比動(dòng)態(tài)組件需要多考慮以下幾點(diǎn):

  1. 需要一個(gè)穩(wěn)定的掛載點(diǎn)
    從vue2.0開(kāi)始,vue實(shí)例的掛載策略變更為,所有的掛載元素會(huì)被 Vue 生成的 DOM 替換,在此策略下,一旦執(zhí)行掛載,原來(lái)的DOM就會(huì)消失,不能再次掛載。但我們需要實(shí)現(xiàn)代碼變更后能夠重新渲染,這就要求掛載點(diǎn)要穩(wěn)定存在,解決方案是對(duì)用戶(hù)的template進(jìn)行注入,每次渲染前,在template外層包一層帶固定id的DOM

  2. 運(yùn)行時(shí)錯(cuò)誤捕獲errorCaptured需要注入到component對(duì)象上,不再需要注入beforeUpdate鉤子
    因?yàn)橥ㄟ^(guò)new Vue()的方式創(chuàng)建了一個(gè)新的vm實(shí)例,不再是容器組件的子組件,所以容器組件上的errorCaptured無(wú)法捕獲新vm的運(yùn)行時(shí)錯(cuò)誤,new Vue(component)中參數(shù)component是頂層組件,根據(jù) Vue錯(cuò)誤傳播規(guī)則 可知,在非特殊控制的情況下,頂層的 errorCaptured 會(huì)捕獲到錯(cuò)誤

  3. 首次掛載需要制造一定的延遲才能渲染
    由于掛載點(diǎn)含在DOM在容器內(nèi),與計(jì)算屬性導(dǎo)出的component對(duì)象在首次掛載時(shí)時(shí)序基本是一致的,導(dǎo)致掛載vm($mount('#id'))時(shí),DOM可能還沒(méi)有渲染到文檔流上,因此在首次渲染時(shí)需要一定的延遲后再掛載vm。

以上的不同點(diǎn),并未給渲染用戶(hù)自定義代碼帶來(lái)任何優(yōu)勢(shì),反而增加了限制,尤其 需要穩(wěn)定掛載點(diǎn) 這一條,需要對(duì)用戶(hù)提供的template做二次注入,包裹掛載點(diǎn),才能實(shí)現(xiàn)用戶(hù)修改組件后的實(shí)時(shí)渲染更新,因此,也不能支持用戶(hù)定義render函數(shù),因?yàn)闊o(wú)法獲取未經(jīng)運(yùn)行的render函數(shù)的返回值,也就無(wú)法注入外層的掛載點(diǎn)。

另外一點(diǎn)也需要注意,這種方式也是無(wú)法在容器組件中使用template定義渲染模板的,因?yàn)槿绻趖emplate中寫(xiě)style標(biāo)簽會(huì)出現(xiàn)以下編譯錯(cuò)誤,但style標(biāo)簽是必須的,需要為自定義組件提供scoped的樣式。(當(dāng)然,也可以通過(guò)提供appendStyle函數(shù)實(shí)現(xiàn)動(dòng)態(tài)添加style標(biāo)簽,但這樣并沒(méi)有更方便,因此沒(méi)有必要)

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>
     |  ^^^^^^^

鑒于以上缺點(diǎn),就不提供核心代碼示范了,直接給源碼和demo

完整的代碼見(jiàn)https://github.com/hqiaozhang/vue-custom-code/blob/master/src/views/customCode/withMount.vue

想一下,如果動(dòng)態(tài)實(shí)例方案僅僅有以上缺點(diǎn),那考慮這種方案有什么意義呢?其實(shí),它的意義在于,動(dòng)態(tài)實(shí)例方案主要應(yīng)用于iframe渲染,而使用iframe渲染的目的則是為了隔離。

iframe會(huì)創(chuàng)建獨(dú)立于主站的一個(gè)域,這種隔離可以很好地防止js污染和css污染,隔離方式又分為跨域隔離和非跨域隔離兩種,跨域則意味著完全隔離,非跨域則是半隔離,其主要區(qū)別在于安全策略的限制,這個(gè)我們最后再說(shuō)。

iframe是否跨域由iframe的src的值決定,設(shè)置同域的src或不設(shè)置src均符合同域策略,否則是跨域。對(duì)于沒(méi)有設(shè)置src的iframe,頁(yè)面只能加載一個(gè)空的iframe,因此還需要在iframe加載完后再動(dòng)態(tài)加載依賴(lài)的資源,如:vuejs,其他運(yùn)行時(shí)的依賴(lài)庫(kù)(示例demo加載了ant-design-vue)等。如果設(shè)置了src,則可以將依賴(lài)通過(guò)script標(biāo)簽和link標(biāo)簽提前寫(xiě)到靜態(tài)頁(yè)面文件中,使依賴(lài)資源在加載iframe時(shí)自動(dòng)完成加載。

先介紹半隔離方式,即通過(guò)非跨域iframe渲染,首先需要渲染一個(gè)iframe,我們使用不設(shè)置src的方式,這樣更具備通用性,可以用于任意的站點(diǎn)。核心代碼如下

<template>
  <iframe ref='iframe' frameborder="0" scrolling="no" width="100%" />
</template>

由于是位于同域,主站與iframe可以互相讀取window和document引用,因?yàn)?,可以?dòng)態(tài)加載資源,核心代碼如下

methods: {
  mountResource() {
    // 添加依賴(lài)的css
    appendLink('https://cdn.bootcdn.net/ajax/libs/ant-design-vue/1.7.2/antd.min.css', this.iframeDoc)
    // 添加依賴(lài)的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()
},

接下來(lái)是組件對(duì)象組裝和掛載,基本上和動(dòng)態(tài)組件的大同小異,只是掛載不再通過(guò)render函數(shù)。先上核心代碼,再說(shuō)注意點(diǎn)。

computed: {
    component() {
      // 把代碼字符串轉(zhuǎn)成js對(duì)象
      const component = safeStringToObject(this.js)

      // 關(guān)聯(lián)css,為的是修改css后可自動(dòng)重繪
      component.css = this.css

      // 去掉template的前后標(biāo)簽
      const template = (this.template || '')
        .replace(/^ *< *template *>|<\/ *template *> *$/g, '')
        .trim()

      // 注入template或render,設(shè)定template優(yōu)先級(jí)高于render
      if (template) {
        component.template = template
        component.render = undefined
      } else if (!component.render) {
        component.template = '<span>未提供模板或render函數(shù)</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)

      // 重建掛載點(diǎn)
      if (this.iframeDoc.body.firstElementChild) {
        this.iframeDoc.body.removeChild(this.iframeDoc.body.firstElementChild)
      }
      prependDom({ tag: 'div', id: 'app' }, this.iframeDoc)

      // 掛載實(shí)例
      const Vue = this.iframeWin.Vue
      new Vue(this.component).$mount('#app')
    },
  },

注意點(diǎn):

iframe的渲染到文檔流后才能添加依賴(lài)資源,依賴(lài)資源加載完才能執(zhí)行vm的掛載,首次加載時(shí)需要控制時(shí)序。
vm掛載點(diǎn)的重建采用了永遠(yuǎn)添加在body的第一個(gè)子元素的方式,這么做的原因是一些第三方的庫(kù)(如ant-design-vue)也會(huì)向body中動(dòng)態(tài)添加element,雖然采用docment.body.innerHTML=''的方式可以快速且干凈的清空body內(nèi)容,但也會(huì)將第三方庫(kù)添加的內(nèi)容給干掉,導(dǎo)致第三方庫(kù)全部或部分不可用。
為了使css變化后也引發(fā)重繪,在計(jì)算屬性component中也綁定了css的值,但這對(duì)于新建vm實(shí)例這個(gè)字段是無(wú)用的,也可以通過(guò)watch css的方式實(shí)現(xiàn)
接下來(lái)考慮錯(cuò)誤處理,對(duì)于iframe掛載的錯(cuò)誤處理稍有不同,為了盡量不干預(yù)用戶(hù)的代碼,此模式下的錯(cuò)誤渲染采用重建DOM,重新渲染vm的策略,即發(fā)生錯(cuò)誤后,無(wú)論是靜態(tài)的語(yǔ)法錯(cuò)誤還是運(yùn)行時(shí)錯(cuò)誤,都重繪。當(dāng)然這種做法也就丟失了組件自刷新的功能,因?yàn)橐坏┌l(fā)生錯(cuò)誤,原來(lái)的組件會(huì)被卸載,渲染為錯(cuò)誤信息。核心代碼如下

computed: {
    component() {
      if (this.subCompErr) {
        return this.renderError(this.subCompErr)
      }

      // 把代碼字符串轉(zhuǎn)成js對(duì)象
      const result = safeStringToObject(this.js)
      if (result.error) {
        return this.renderError({
          type: 'js腳本錯(cuò)誤',
          msg: result.error.toString(),
        })
      }

      const component = result.value

      // 注入errorCaptured, 用于錯(cuò)誤自定義組件運(yùn)行時(shí)捕獲
      component.errorCaptured = (err, vm, info) => {
        this.subCompErr = {
          msg: err && err.toString && err.toString(),
          type: '自定義組件運(yùn)行時(shí)錯(cuò)誤:',
        }
        console.error('自定義組件運(yùn)行時(shí)錯(cuò)誤:', err, vm, info)
      }

      return component
    },
  },
  watch: {
    js() {
      // 當(dāng)代碼變化時(shí),清空error,重繪
      this.subCompErr = null
    },
    template() {
      // 當(dāng)代碼變化時(shí),清空error,重繪
      this.subCompErr = null
    },
    css() {
      // 當(dāng)代碼變化時(shí),清空error,重繪
      this.subCompErr = null
    },
  },
  methods: {
    renderError({ type, msg }) {
      return {
        render() {
          return <div style='color: red'>
            <div>{type}</div>
            <div>{msg}</div>
          </div>
        },
      }
    },
  },

除了錯(cuò)誤處理,還需解決一下iframe的一些特性,比如邊框,滾動(dòng)條,默認(rèn)寬高,其中比較棘手是iframe高度有默認(rèn)值,并不會(huì)隨著iframe的內(nèi)容自適應(yīng)高度,但對(duì)于自定義組件的渲染,需要?jiǎng)討B(tài)計(jì)算高度,固定高度是不行的。

邊框,滾動(dòng)條,寬度可通過(guò)修改iframe的屬性解決,見(jiàn)上面的template代碼。

高度自適應(yīng)的解決方案是通過(guò)MutationObserver觀測(cè)iframe的body變化,在回調(diào)中計(jì)算掛載點(diǎn)(第一個(gè)子元素)的高度,然后再修改iframe本身的高度。之所以沒(méi)有直接使用body的高度,是因?yàn)閎ody有默認(rèn)的高度,當(dāng)被渲染的組件高度小于body高度時(shí),直接使用body的高度是錯(cuò)的。 核心代碼如下

mounted() {
  // 通過(guò)觀察器觀察iframe的body變化后修改iframe的高度,
  // 使用iframe后垂直的margin重合效果會(huì)丟失
  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還存在一些局限性,最需要注意的一點(diǎn)就是由于iframe是獨(dú)立的窗體,那么渲染出來(lái)的組件只能封在這個(gè)窗體內(nèi),因此,像一些本應(yīng)該是全局的toast, modal, drawer都會(huì)被局限在iframe內(nèi),無(wú)法覆蓋到全局上。

完整的代碼見(jiàn)https://github.com/merfais/vue-demo/blob/main/src/views/customCode/mountSameIframe.vue

完整的demo見(jiàn)https://merfais.github.io/vue-d

至此非跨域iframe渲染全部邏輯介紹完畢,接下來(lái)看一下跨域iframe的渲染??缬騣frame與非跨域iframe的渲染過(guò)程基本是一致的,只是有由于跨域,隔離的更徹底。其主要體現(xiàn)在主域與iframe域不能互相讀寫(xiě)對(duì)方的文檔流document。

此限制帶來(lái)的變化有以下幾點(diǎn)

  1. 依賴(lài)的資源需要提前內(nèi)置在iframe內(nèi)。
    內(nèi)置指的是將依賴(lài)的資源通過(guò)script,link標(biāo)簽添加到html文件中,隨html一并加載。有一點(diǎn)還需要注意,如果掛載vm時(shí)需要依賴(lài)某些資源,需要添加資源加載的回調(diào),加載成功后再通知主域掛載。

  2. iframe重新繪制需要各種元素操作只能由iframe自己完成
    在非跨域iframe模式下所有的元素操作都在主域中完成,在跨域模式下這些操作和流程控制都需要以script編碼的方式內(nèi)置在html內(nèi),在接到主域的掛載消息后,完整掛載過(guò)程。

  3. 主域與iframe的通信需要通過(guò)postMessage
    為了通用性,調(diào)用postMessage時(shí)可以設(shè)置origin = *,但由于接收postMessage消息通過(guò) window.addEventListener("message", callback)這種通用的方式,可能會(huì)接受來(lái)自多個(gè)域的非期待的消息,因此,需要對(duì)通信消息定制特殊協(xié)議格式,防止出現(xiàn)處理了未知消息而發(fā)生異常。
    兩者間通信是雙向的,主站向iframe只需傳遞一種消息,即含組件完整內(nèi)容的掛載消息,iframe接到消息后執(zhí)行重繪渲染邏輯;iframe向主站傳遞兩種消息,一是可以掛載的狀態(tài)消息,主站接到消息后執(zhí)行首次渲染邏輯,即發(fā)送首次掛載消息,二是body size變化的消息,主站接到消息后修改iframe的尺寸。

在處理主域?qū)⒔M件內(nèi)容通過(guò)postMessage傳給iframe時(shí),碰到了一個(gè)棘手的問(wèn)題,postMessage對(duì)可傳遞的數(shù)據(jù)有限制,具體的限制可查看 The structured clone algorithm,這個(gè)限制導(dǎo)致Function類(lèi)型的數(shù)據(jù)無(wú)法傳過(guò)去,但組件很多功能需要使用函數(shù)才能實(shí)現(xiàn),無(wú)法跨越這個(gè)限制,組件能力將損失過(guò)半或更甚。

對(duì)于這個(gè)限制的解決方案是:對(duì)不支持的數(shù)據(jù)類(lèi)型進(jìn)行序列化,轉(zhuǎn)成支持的類(lèi)型,如string,渲染時(shí)再反序列化回來(lái)。核心代碼如下

// 序列化
function serialize(data) {
  // 對(duì)象深度遞歸
  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))
  }
  // 函數(shù)前后打上特殊標(biāo)記后轉(zhuǎn)成string
  if (typeof data === 'function') {
    return encodeURI(`##${data.toString()}##`)
  }
  // 其他類(lèi)型直接返回
  return data
}
// 反序列化
function deserialize(data) {
  // 對(duì)象深度遞歸
  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類(lèi)型嘗試解析
  if (typeof data === 'string') {
    const str = decodeURI(data)
    // 匹配特殊標(biāo)記,匹配成功,反轉(zhuǎn)為function
    const matched = str.match(/^##([^#]*)##$/)
    if (matched) {
      // string轉(zhuǎn)成function可以用eval也可用new Function
      return newFn(matched[1])
    }
    return data
  }
  // 其他類(lèi)型直接返回
  return data
}

序列化方案看似完美,其實(shí)也有諸多的不便,畢竟是一種降級(jí),需要特別注意的一點(diǎn)是,閉包被破壞,或者說(shuō)是不支持閉包函數(shù),舉個(gè)例子:

computed: {
  component() {
    // 把代碼字符串轉(zhuǎn)成js對(duì)象
    const result = safeStringToObject(this.js)
    if (result.error) {
      return this.renderError({
        type: 'js腳本錯(cuò)誤',
        msg: result.error.toString(),
      })
    }
    // ...  
    return component
  },
},
methods: {
  renderError({ type, msg }) {
    return {
       // 這里用到了閉包,render函數(shù)使用了外層變量type和msg, 
       // renderError函數(shù)執(zhí)行結(jié)束后這兩個(gè)變量并不會(huì)釋放,需等render函數(shù)執(zhí)行后才會(huì)釋放
       render() {
         return <div style='color: red'>
           <div>{type}</div>
           <div>{msg}</div>
         </div>
       }
    }
  },
},

上面在生成 component 對(duì)象時(shí)調(diào)用了函數(shù)renderError,此函數(shù)返回了一個(gè)函數(shù)render,且使用了外層函數(shù)renderError的兩個(gè)參數(shù),正常情況下運(yùn)行是沒(méi)有問(wèn)題的,type和msg的引用(引用計(jì)數(shù))會(huì)等到render函數(shù)執(zhí)行后才會(huì)釋放(引用計(jì)數(shù)清零)。

但 component 對(duì)象經(jīng)過(guò)序列化后,其內(nèi)部的函數(shù)被轉(zhuǎn)成了字符串,因而丟失了函數(shù)的所有特性,閉包也因此丟失,經(jīng)反序列化回來(lái)后,雖然還原了函數(shù),但閉包關(guān)系無(wú)法恢復(fù),因此,這種寫(xiě)法,在執(zhí)行render時(shí),type和msg兩個(gè)參數(shù)會(huì)變?yōu)閡ndefined。

為了規(guī)避這種限制,應(yīng)在導(dǎo)出 component 對(duì)象時(shí)避免使用含閉包的函數(shù), 上例中的錯(cuò)誤處理可通過(guò)以下方式解決

computed: {
  component() {
    // 把代碼字符串轉(zhuǎn)成js對(duì)象
    const result = safeStringToObject(this.js)
    if (result.error) {
      const template = this.genErrorTpl({
        type: 'js腳本錯(cuò)誤',
        msg: result.error.toString(),
      })
      return { template }
    }
    // ...  
    return component
  },
},
methods: {
  genErrorTpl({ type, msg }) {
    return `<div style='color: red'><div>${type}</div><div>${msg}</div></div>`
  },
}

完整的代碼見(jiàn)

XSS注入與安全
通常情況下,在需要將用戶(hù)輸入持久化的系統(tǒng)中,都要考慮XSS的注入攻擊,而防止注入的主要表現(xiàn)則是使用戶(hù)輸入的數(shù)據(jù)不被執(zhí)行,或不能被執(zhí)行。

而前文介紹的要支持用戶(hù)自定義組件的渲染,恰好就是要執(zhí)行用戶(hù)代碼,可見(jiàn),此功能勢(shì)必會(huì)帶來(lái)XSS注入風(fēng)險(xiǎn)。

因此,在使用此功能時(shí)要慎重,在不同的應(yīng)用場(chǎng)景中,要根據(jù)系統(tǒng)的安全級(jí)別,選取相應(yīng)的方案。對(duì)比以上四種方案(1種動(dòng)態(tài)組件,3種動(dòng)態(tài)掛載)可做以下選擇

在一些相對(duì)安全(允許xss注入,注入后沒(méi)有安全問(wèn)題)的系統(tǒng)中,可以使用前三種方案中的任意一種,這三種都是可以通過(guò)注入獲取用戶(hù)cookie的。個(gè)人推薦使用第一種動(dòng)態(tài)渲染方案,因?yàn)榇朔桨胳`活性和渲染完整度都是最高的。

在一些不太安全(xss注入可能會(huì)泄露cookie中的身份信息)的系統(tǒng)中,推薦使用最后一種跨域組件掛載方案,通過(guò)完全隔離策略可以最大程度的降低風(fēng)險(xiǎn),當(dāng)然此方案也有很多的局限性。

最后附上一個(gè)在線DOME預(yù)覽

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容