前言
有一個(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è)方案
- 通過(guò) 注冊(cè)組件 的方式,把代碼注冊(cè)為vue實(shí)例的組件,注冊(cè)組件又分全局注冊(cè)和局部注冊(cè)兩種方式
- 通過(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ò)誤大致可以分兩種
- 用戶(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
}
- 組件運(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):
需要一個(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運(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ò)誤首次掛載需要制造一定的延遲才能渲染
由于掛載點(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)
依賴(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),加載成功后再通知主域掛載。iframe重新繪制需要各種元素操作只能由iframe自己完成
在非跨域iframe模式下所有的元素操作都在主域中完成,在跨域模式下這些操作和流程控制都需要以script編碼的方式內(nèi)置在html內(nèi),在接到主域的掛載消息后,完整掛載過(guò)程。主域與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):
組件: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注入與安全
通常情況下,在需要將用戶(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ù)覽