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

前言

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

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

那么該怎么實現(xiàn)呢调煎?我們先來看一vue官方的介紹

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

漸進式其實指的在一個已存在的但并未使用vue的項目上接入vue坚洽,使用vue戈稿,直到所有的HTML漸漸替換為通過vue渲染完成,漸進開發(fā)讶舰,漸進遷移鞍盗,這種方式在vue剛出現(xiàn)那幾年比較多,現(xiàn)在或許在一些古老的項目也會出現(xiàn)跳昼。

為什么要提漸進式呢般甲?因為漸進式是不需要本地編譯的,有沒有g(shù)et到點鹅颊!對敷存,就是不需要本地編譯,而是運行時編譯堪伍。

本地編譯與運行時編譯

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

這些代碼只能原樣持久化到數(shù)據(jù)庫涮俄,每次打開頁面再恢復(fù)回來,實時編譯尸闸。畢竟不是純js文件彻亲,是不能直接運行的,它需要一個運行時環(huán)境吮廉,運行時編譯苞尝,這個環(huán)境就是 vue的運行時 + 編譯器

有了思路也只是窺到了天機宦芦,神功練成還是要打磨細節(jié)宙址。具體怎么做,容我一步步道來踪旷。

技術(shù)干貨

第一步:需要一個運行時編譯環(huán)境

官方的介紹曼氛,通過script標簽引入vue就可以漸進式開發(fā)了,也就具備了運行時+編譯器令野,如下

<!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開關(guān)就行了聊浅,詳細看文檔

此時我們就有了一個運行時編譯環(huán)境

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

把代碼渲染出來有兩個方案

  1. 通過 注冊組件 的方式,把代碼注冊為vue實例的組件低匙,注冊組件又分全局注冊和局部注冊兩種方式
  2. 通過掛載點直接掛載vue實例旷痕, 即通過new Vue({ el: '#id' })的方式

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

對于這種方式,在官方文檔中顽冶,組件注冊章節(jié)欺抗,最后給出了一個注意點

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

因此强重,并不能通過調(diào)用Vue.component('my-component-name', {/* */})的方式將用戶的代碼注冊到系統(tǒng)中绞呈,因為運行時Vue實例已經(jīng)創(chuàng)建完,用戶的代碼是在實例完Vue后才進來的间景,那我們只能通過局部注冊的方式了佃声,類似這樣

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

但想一下,好像不太對倘要,這還是在寫源碼圾亏,運行時定義了ComponentB組件怎么用呢,怎么把ComponentB在一個已經(jīng)編譯完頁面上渲染出來呢封拧?找不到入口點志鹃,把用戶代碼注入到components對象上也無法注冊到系統(tǒng)中,無法渲染出來哮缺。

就止步于此了嗎弄跌?該怎么辦呢甲喝?

想一下為什么要在components中先注冊(聲明)下組件尝苇,然后才能使用?component本質(zhì)上只不過是一個js object而已埠胖。其實主要是為了服務(wù)于template模板語法糠溜,當你在template中寫了 <compA propA='value'/>,有了這個注冊聲明才能在編譯時找到compA直撤。如果不使用template非竿,那么這個注冊就可以省了。

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

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

回到項目上,需要使用用戶代碼的地方不止一處,都用render函數(shù)寫一遍略顯臃腫隶症,那么做一個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() {
      // 把代碼字符串轉(zhuǎn)成js對象
      const component = safeStringToObject(this.js)

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

      // 注入template或render胁住,設(shè)定template優(yōu)先級高于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>

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

  1. 用戶代碼語法錯誤
    主要是js部分躲叼,對于css和template的錯誤,瀏覽器有一定的糾錯的機制企巢,不至于崩了枫慷。
    這部分的處理主要借助于safeStringToObject這個函數(shù),如果有語法錯誤浪规,則返回Error或听,處理一下回顯給用戶,代碼大致如下
// component對象在result.value上取笋婿,如果result.error有值誉裆,則代表出現(xiàn)了錯誤
component() {
  // 把代碼字符串轉(zhuǎn)成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中讀值足丢,把非函數(shù)變量當函數(shù)運行,甚至拼寫錯誤等這些運行時錯誤就很有可能發(fā)生庇配。
    這部分的處理需要通過在容器組件上添加 errorCaptured這個官方鉤子斩跌,來捕獲子組件的錯誤,因為并沒有一個途徑可以獲取組件自身運行時錯誤的鉤子捞慌。代碼大致如下`
errorCaptured(err, vm, info) {
  this.subCompErr = {
    msg: err && err.toString && err.toString() || err,
    type: '自定義組件運行時錯誤:',
  }
  console.error('自定義組件運行時錯誤:', err, vm, info)
},

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

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

這里還有一個點袖订,用戶發(fā)現(xiàn)組件發(fā)生了錯誤后會修改代碼,使其再次渲染嗅虏,錯誤的回顯需要特別處理下洛姑。

對于js腳本錯誤,因component是計算屬性旋恼,隨著computed計算屬性再次計算吏口,如果js腳本沒有錯誤奄容,導(dǎo)出的component可重繪出來,

但對于運行時錯誤产徊,使用this.subCompErr內(nèi)部變量保存昂勒,props修改了,這個值卻不會被修改舟铜,因此需要打通props關(guān)聯(lián)戈盈,通過添加watch的方式解決,這里為什么沒有放在component的計算屬性中做谆刨,一是違背計算屬性設(shè)計原則塘娶,二是component可能并不僅僅依賴js,css,template這個props的變化,而this.subCompErr只需要和這個三個props關(guān)聯(lián)痊夭,這么做會有多余的重置邏輯刁岸。

還有一種場景就是子組件自身可能有定時刷新邏輯,定期或不定期的重繪她我,一旦發(fā)生了錯誤屠凶,也會導(dǎo)致一直顯示錯誤信息涌萤,因為用戶的代碼拿不到this.subCompErr的值统扳,因此也無法重置此值捍歪,這種情況,可通過注入beforeUpdate鉤子解決恨狈,代碼大致如下

computed: {
    component() {
      // 把代碼字符串轉(zhuǎn)成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

第二種方案:動態(tài)實例

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

動態(tài)實例方案與動態(tài)組件方案大致相同,都要通過computed屬性缩抡,生成component對象和scopedStyle對象進行渲染奠宜,但也有些許的區(qū)別,動態(tài)實例比動態(tài)組件需要多考慮以下幾點:

  1. 需要一個穩(wěn)定的掛載點
    從vue2.0開始,vue實例的掛載策略變更為压真,所有的掛載元素會被 Vue 生成的 DOM 替換娩嚼,在此策略下,一旦執(zhí)行掛載滴肿,原來的DOM就會消失岳悟,不能再次掛載。但我們需要實現(xiàn)代碼變更后能夠重新渲染泼差,這就要求掛載點要穩(wěn)定存在贵少,解決方案是對用戶的template進行注入,每次渲染前堆缘,在template外層包一層帶固定id的DOM

  2. 運行時錯誤捕獲errorCaptured需要注入到component對象上滔灶,不再需要注入beforeUpdate鉤子
    因為通過new Vue()的方式創(chuàng)建了一個新的vm實例,不再是容器組件的子組件吼肥,所以容器組件上的errorCaptured無法捕獲新vm的運行時錯誤宽气,new Vue(component)中參數(shù)component是頂層組件,根據(jù) Vue錯誤傳播規(guī)則 可知潜沦,在非特殊控制的情況下萄涯,頂層的 errorCaptured 會捕獲到錯誤

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

以上的不同點燃逻,并未給渲染用戶自定義代碼帶來任何優(yōu)勢,反而增加了限制臂痕,尤其 需要穩(wěn)定掛載點 這一條伯襟,需要對用戶提供的template做二次注入,包裹掛載點握童,才能實現(xiàn)用戶修改組件后的實時渲染更新姆怪,因此,也不能支持用戶定義render函數(shù)澡绩,因為無法獲取未經(jīng)運行的render函數(shù)的返回值稽揭,也就無法注入外層的掛載點。

另外一點也需要注意肥卡,這種方式也是無法在容器組件中使用template定義渲染模板的溪掀,因為如果在template中寫style標簽會出現(xiàn)以下編譯錯誤,但style標簽是必須的步鉴,需要為自定義組件提供scoped的樣式揪胃。(當然璃哟,也可以通過提供appendStyle函數(shù)實現(xiàn)動態(tài)添加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/hqiaozhang/vue-custom-code/blob/master/src/views/customCode/withMount.vue

想一下册舞,如果動態(tài)實例方案僅僅有以上缺點蕴掏,那考慮這種方案有什么意義呢?其實调鲸,它的意義在于盛杰,動態(tài)實例方案主要應(yīng)用于iframe渲染,而使用iframe渲染的目的則是為了隔離藐石。

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

iframe是否跨域由iframe的src的值決定,設(shè)置同域的src或不設(shè)置src均符合同域策略恋腕,否則是跨域抹锄。對于沒有設(shè)置src的iframe,頁面只能加載一個空的iframe荠藤,因此還需要在iframe加載完后再動態(tài)加載依賴的資源伙单,如:vuejs,其他運行時的依賴庫(示例demo加載了ant-design-vue)等哈肖。如果設(shè)置了src吻育,則可以將依賴通過script標簽和link標簽提前寫到靜態(tài)頁面文件中,使依賴資源在加載iframe時自動完成加載淤井。

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

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

由于是位于同域,主站與iframe可以互相讀取window和document引用梢为,因為渐行,可以動態(tài)加載資源轰坊,核心代碼如下

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

接下來是組件對象組裝和掛載祟印,基本上和動態(tài)組件的大同小異肴沫,只是掛載不再通過render函數(shù)。先上核心代碼蕴忆,再說注意點颤芬。

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

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

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

      // 注入template或render套鹅,設(shè)定template優(yōu)先級高于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)

      // 重建掛載點
      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')
    },
  },

注意點:

iframe的渲染到文檔流后才能添加依賴資源站蝠,依賴資源加載完才能執(zhí)行vm的掛載,首次加載時需要控制時序卓鹿。
vm掛載點的重建采用了永遠添加在body的第一個子元素的方式菱魔,這么做的原因是一些第三方的庫(如ant-design-vue)也會向body中動態(tài)添加element,雖然采用docment.body.innerHTML=''的方式可以快速且干凈的清空body內(nèi)容吟孙,但也會將第三方庫添加的內(nèi)容給干掉澜倦,導(dǎo)致第三方庫全部或部分不可用。
為了使css變化后也引發(fā)重繪杰妓,在計算屬性component中也綁定了css的值藻治,但這對于新建vm實例這個字段是無用的,也可以通過watch css的方式實現(xiàn)
接下來考慮錯誤處理巷挥,對于iframe掛載的錯誤處理稍有不同栋艳,為了盡量不干預(yù)用戶的代碼,此模式下的錯誤渲染采用重建DOM句各,重新渲染vm的策略吸占,即發(fā)生錯誤后,無論是靜態(tài)的語法錯誤還是運行時錯誤凿宾,都重繪矾屯。當然這種做法也就丟失了組件自刷新的功能,因為一旦發(fā)生錯誤初厚,原來的組件會被卸載件蚕,渲染為錯誤信息。核心代碼如下

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

      // 把代碼字符串轉(zhuǎn)成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的內(nèi)容自適應(yīng)高度,但對于自定義組件的渲染泪酱,需要動態(tài)計算高度派殷,固定高度是不行的。

邊框墓阀,滾動條毡惜,寬度可通過修改iframe的屬性解決,見上面的template代碼岂津。

高度自適應(yīng)的解決方案是通過MutationObserver觀測iframe的body變化虱黄,在回調(diào)中計算掛載點(第一個子元素)的高度,然后再修改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是獨立的窗體危纫,那么渲染出來的組件只能封在這個窗體內(nèi),因此乌庶,像一些本應(yīng)該是全局的toast, modal, drawer都會被局限在iframe內(nèi)种蝶,無法覆蓋到全局上。

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

完整的demo見https://merfais.github.io/vue-d

至此非跨域iframe渲染全部邏輯介紹完畢瞒大,接下來看一下跨域iframe的渲染螃征。跨域iframe與非跨域iframe的渲染過程基本是一致的透敌,只是有由于跨域盯滚,隔離的更徹底。其主要體現(xiàn)在主域與iframe域不能互相讀寫對方的文檔流document酗电。

此限制帶來的變化有以下幾點

  1. 依賴的資源需要提前內(nèi)置在iframe內(nèi)魄藕。
    內(nèi)置指的是將依賴的資源通過script,link標簽添加到html文件中撵术,隨html一并加載背率。有一點還需要注意,如果掛載vm時需要依賴某些資源,需要添加資源加載的回調(diào)退渗,加載成功后再通知主域掛載移稳。

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

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

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

對于這個限制的解決方案是:對不支持的數(shù)據(jù)類型進行序列化静稻,轉(zhuǎn)成支持的類型警没,如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))
  }
  // 函數(shù)前后打上特殊標記后轉(zhuǎn)成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)
    // 匹配特殊標記杀迹,匹配成功,反轉(zhuǎn)為function
    const matched = str.match(/^##([^#]*)##$/)
    if (matched) {
      // string轉(zhuǎn)成function可以用eval也可用new Function
      return newFn(matched[1])
    }
    return data
  }
  // 其他類型直接返回
  return data
}

序列化方案看似完美押搪,其實也有諸多的不便树酪,畢竟是一種降級浅碾,需要特別注意的一點是,閉包被破壞续语,或者說是不支持閉包函數(shù)垂谢,舉個例子:

computed: {
  component() {
    // 把代碼字符串轉(zhuǎn)成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函數(shù)使用了外層變量type和msg疮茄, 
       // renderError函數(shù)執(zhí)行結(jié)束后這兩個變量并不會釋放滥朱,需等render函數(shù)執(zhí)行后才會釋放
       render() {
         return <div style='color: red'>
           <div>{type}</div>
           <div>{msg}</div>
         </div>
       }
    }
  },
},

上面在生成 component 對象時調(diào)用了函數(shù)renderError,此函數(shù)返回了一個函數(shù)render力试,且使用了外層函數(shù)renderError的兩個參數(shù)徙邻,正常情況下運行是沒有問題的,type和msg的引用(引用計數(shù))會等到render函數(shù)執(zhí)行后才會釋放(引用計數(shù)清零)畸裳。

但 component 對象經(jīng)過序列化后缰犁,其內(nèi)部的函數(shù)被轉(zhuǎn)成了字符串,因而丟失了函數(shù)的所有特性怖糊,閉包也因此丟失帅容,經(jīng)反序列化回來后,雖然還原了函數(shù)蓬抄,但閉包關(guān)系無法恢復(fù)丰嘉,因此,這種寫法嚷缭,在執(zhí)行render時饮亏,type和msg兩個參數(shù)會變?yōu)閡ndefined。

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

computed: {
  component() {
    // 把代碼字符串轉(zhuǎn)成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>`
  },
}

完整的代碼見

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

而前文介紹的要支持用戶自定義組件的渲染砰识,恰好就是要執(zhí)行用戶代碼,可見佣渴,此功能勢必會帶來XSS注入風(fēng)險辫狼。

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

在一些相對安全(允許xss注入鹃答,注入后沒有安全問題)的系統(tǒng)中,可以使用前三種方案中的任意一種突硝,這三種都是可以通過注入獲取用戶cookie的测摔。個人推薦使用第一種動態(tài)渲染方案,因為此方案靈活性和渲染完整度都是最高的狞换。

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

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末路媚,一起剝皮案震驚了整個濱河市黄琼,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌整慎,老刑警劉巖脏款,帶你破解...
    沈念sama閱讀 218,204評論 6 506
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異裤园,居然都是意外死亡撤师,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,091評論 3 395
  • 文/潘曉璐 我一進店門拧揽,熙熙樓的掌柜王于貴愁眉苦臉地迎上來剃盾,“玉大人,你說我怎么就攤上這事淤袜⊙髑矗” “怎么了?”我有些...
    開封第一講書人閱讀 164,548評論 0 354
  • 文/不壞的土叔 我叫張陵铡羡,是天一觀的道長积蔚。 經(jīng)常有香客問我,道長烦周,這世上最難降的妖魔是什么尽爆? 我笑而不...
    開封第一講書人閱讀 58,657評論 1 293
  • 正文 為了忘掉前任,我火速辦了婚禮读慎,結(jié)果婚禮上漱贱,老公的妹妹穿的比我還像新娘。我一直安慰自己贪壳,他們只是感情好饱亿,可當我...
    茶點故事閱讀 67,689評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般彪笼。 火紅的嫁衣襯著肌膚如雪钻注。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,554評論 1 305
  • 那天配猫,我揣著相機與錄音幅恋,去河邊找鬼。 笑死泵肄,一個胖子當著我的面吹牛捆交,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播腐巢,決...
    沈念sama閱讀 40,302評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼品追,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了冯丙?” 一聲冷哼從身側(cè)響起肉瓦,我...
    開封第一講書人閱讀 39,216評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎胃惜,沒想到半個月后泞莉,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,661評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡船殉,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,851評論 3 336
  • 正文 我和宋清朗相戀三年鲫趁,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片利虫。...
    茶點故事閱讀 39,977評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡挨厚,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出列吼,到底是詐尸還是另有隱情幽崩,我是刑警寧澤,帶...
    沈念sama閱讀 35,697評論 5 347
  • 正文 年R本政府宣布寞钥,位于F島的核電站慌申,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏理郑。R本人自食惡果不足惜蹄溉,卻給世界環(huán)境...
    茶點故事閱讀 41,306評論 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望您炉。 院中可真熱鬧柒爵,春花似錦、人聲如沸赚爵。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,898評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至唁奢,卻和暖如春霎挟,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背麻掸。 一陣腳步聲響...
    開封第一講書人閱讀 33,019評論 1 270
  • 我被黑心中介騙來泰國打工酥夭, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人脊奋。 一個月前我還...
    沈念sama閱讀 48,138評論 3 370
  • 正文 我出身青樓熬北,卻偏偏與公主長得像,于是被迫代替她去往敵國和親诚隙。 傳聞我的和親對象是個殘疾皇子讶隐,可洞房花燭夜當晚...
    茶點故事閱讀 44,927評論 2 355

推薦閱讀更多精彩內(nèi)容