前言
有一個需求:能不能讓用戶自制組件,從而達到定制渲染某個區(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)中
把代碼渲染出來有兩個方案
- 通過 注冊組件 的方式,把代碼注冊為vue實例的組件低匙,注冊組件又分全局注冊和局部注冊兩種方式
- 通過掛載點直接掛載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)考慮容錯處理措嵌,錯誤大致可以分兩種
- 用戶代碼語法錯誤
主要是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
}
- 組件運行時錯誤
既然把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)組件需要多考慮以下幾點:
需要一個穩(wěn)定的掛載點
從vue2.0開始,vue實例的掛載策略變更為压真,所有的掛載元素會被 Vue 生成的 DOM 替換娩嚼,在此策略下,一旦執(zhí)行掛載滴肿,原來的DOM就會消失岳悟,不能再次掛載。但我們需要實現(xiàn)代碼變更后能夠重新渲染泼差,這就要求掛載點要穩(wěn)定存在贵少,解決方案是對用戶的template進行注入,每次渲染前堆缘,在template外層包一層帶固定id的DOM運行時錯誤捕獲
errorCaptured
需要注入到component
對象上滔灶,不再需要注入beforeUpdate
鉤子
因為通過new Vue()
的方式創(chuàng)建了一個新的vm實例,不再是容器組件的子組件吼肥,所以容器組件上的errorCaptured
無法捕獲新vm的運行時錯誤宽气,new Vue(component)
中參數(shù)component是頂層組件,根據(jù) Vue錯誤傳播規(guī)則 可知潜沦,在非特殊控制的情況下萄涯,頂層的errorCaptured
會捕獲到錯誤首次掛載需要制造一定的延遲才能渲染
由于掛載點含在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酗电。
此限制帶來的變化有以下幾點
依賴的資源需要提前內(nèi)置在iframe內(nèi)魄藕。
內(nèi)置指的是將依賴的資源通過script,link標簽添加到html文件中撵术,隨html一并加載背率。有一點還需要注意,如果掛載vm時需要依賴某些資源,需要添加資源加載的回調(diào)退渗,加載成功后再通知主域掛載移稳。iframe重新繪制需要各種元素操作只能由iframe自己完成
在非跨域iframe模式下所有的元素操作都在主域中完成蕴纳,在跨域模式下這些操作和流程控制都需要以script編碼的方式內(nèi)置在html內(nèi)会油,在接到主域的掛載消息后,完整掛載過程古毛。主域與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>`
},
}
完整的代碼見
:
組件
:https://github.com/hqiaozhang/vue-custom-code/blob/master/src/views/customCode/mountCrossIframe.vueiframe
: https://github.com/hqiaozhang/vue-custom-code/blob/master/public/iframe.html](https://link.zhihu.com/?target=https%3A//gitlab.com/merfais/static-page/-/blob/master/public/iframe.html)
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ù)覽