Vue隐藏技能:运行时渲染用户写入的组件代码!
作者:還沒想好
https://zhuanlan.zhihu.com/p/347509262
一語驚人
前段時間接了一個需求:能不能讓用戶自制組件,從而達(dá)到定制渲染某個區(qū)域的目的。說實話接到這個需求心中一驚,感嘆這個想法真是大膽呀,但作為打工人,秉承著只要思想不滑坡,辦法總比困難多的打工魂,即使是刀山也得上呀,歷經(jīng)幾日的摸索調(diào)研,發(fā)現(xiàn)其實 VUE 一早就支持了這么做,只不過時過境遷,漸漸被遺忘了這個隱藏的技能。
大致說一下項目的背景:我們做了一個拖拽生成報表的系統(tǒng),通過拖拽內(nèi)置的組件供用戶定制自己的報表形態(tài),但畢竟內(nèi)置的組件有限,可定制性不高,那么給用戶開放一個 code 組件,讓用戶自己通過寫template?+?js?+?css的方式自由定制豈不是妙哉。
重提漸進(jìn)式
那么該怎么實現(xiàn)呢?我們先來看一 vue 官方的介紹
Vue (讀音 /vju?/,類似于 view) 是一套用于構(gòu)建用戶界面的漸進(jìn)式框架。與其它大型框架不同的是,Vue 被設(shè)計為可以自底向上逐層應(yīng)用。[1]
很多時候我們貌似已經(jīng)忽略了漸進(jìn)式這回事,現(xiàn)在基于 VUE 開發(fā)的項目大多都采用 vue cli 生成,以 vue 單文件的方式編碼,webpack 編譯打包的形式發(fā)布。這與漸進(jìn)式有什么關(guān)系呢,確實沒有關(guān)系。
漸進(jìn)式其實指的在一個已存在的但并未使用 vue 的項目上接入 vue,使用 vue,直到所有的 HTML 漸漸替換為通過 vue 渲染完成,漸進(jìn)開發(fā),漸進(jìn)遷移,這種方式在 vue 剛出現(xiàn)那幾年比較多,現(xiàn)在或許在一些古老的項目也會出現(xiàn)。
為什么要提漸進(jìn)式呢?因為漸進(jìn)式是不需要本地編譯的,有沒有 get 到點!對,就是不需要本地編譯,而是運行時編譯。
本地編譯與運行時編譯
用戶想通過編寫template?+?js?+?css的方式實現(xiàn)運行時渲染頁面,那肯定是不能本地編譯的(此處的編譯指將 vue 文件編譯為 js 資源文件),即不能把用戶寫的代碼像編譯源碼一樣打包成靜態(tài)資源文件。
這些代碼只能原樣持久化到數(shù)據(jù)庫,每次打開頁面再恢復(fù)回來,實時編譯。畢竟不是純 js 文件,是不能直接運行的,它需要一個運行時環(huán)境,運行時編譯,這個環(huán)境就是?vue 的運行時 + 編譯器[2]。
有了思路也只是窺到了天機,神功練成還是要打磨細(xì)節(jié)。具體怎么做,容我一步步道來。
技術(shù)干貨
第一步:需要一個運行時編譯環(huán)境
按官方的介紹[3],通過 script 標(biāo)簽引入 vue 就可以漸進(jìn)式開發(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)就行了,詳細(xì)看文檔[4]。
此時我們就有了一個運行時編譯環(huán)境
第二步:把用戶的代碼注冊到系統(tǒng)中
把代碼渲染出來有兩個方案
通過?注冊組件[5]?的方式,把代碼注冊為 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 后才進(jìn)來的,那我們只能通過局部注冊的方式了,類似這樣
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 模板語法,當(dāng)你在 template 中寫了?<compA propA='value'/>,有了這個注冊聲明才能在編譯時找到compA。如果不使用 template,那么這個注冊就可以省了。
不使用 template 怎么渲染呢,使用render 函數(shù)[6]呀!
在 render 函數(shù)中如果使用 createElement 就比較麻煩了,API 很復(fù)雜,對于渲染一整段用戶定義的 template 也略顯吃力,使用 jsx 就方便多了,都 1202 年了,想必大家對 jsx 都應(yīng)該有所了解。
回到項目上,需要使用用戶代碼的地方不止一處,都用 render 函數(shù)寫一遍略顯臃腫,那么做一個 code 的容器,容器負(fù)責(zé)渲染用戶的代碼,使用地方把容器掛上就行了。
容器核心代碼
容器使用
以上只是核心的邏輯部分,除了這些,在項目實戰(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.valueif?(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ù)變量當(dāng)函數(shù)運行,甚至拼寫錯誤等這些運行時錯誤就很有可能發(fā)生。
這部分的處理需要通過在容器組件上添加?`errorCaptured`這個官方鉤子[7],來捕獲子組件的錯誤,因為并沒有一個途徑可以獲取組件自身運行時錯誤的鉤子。代碼大致如下
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.componentconst?error?=?compileErr?||?this.subCompErrlet?errorDomif?(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//?...//?注入mixinscomponent.mixins?=?[{//?注入?beforeUpdate?鉤子,用于子組件重繪時,清理父組件捕獲的異常beforeUpdate:?()?=>?{this.subCompErr?=?null},}]//?...return?result}, }, watch:?{js()?{//?當(dāng)代碼變化時,清空error,重繪this.subCompErr?=?null},template()?{//?當(dāng)代碼變化時,清空error,重繪this.subCompErr?=?null},css()?{//?當(dāng)代碼變化時,清空error,重繪this.subCompErr?=?null},},完整的代碼見:https://github.com/merfais/vue-demo/blob/main/src/views/customCode/withComponent.vue[8]
完整的 demo 見:https://merfais.github.io/vue-demo/#/custom-code[9]
第二種方案:動態(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對象進(jìn)行渲染,但也有些許的區(qū)別,動態(tài)實例比動態(tài)組件需要多考慮以下幾點:
需要一個穩(wěn)定的掛載點
從 vue2.0 開始,vue 實例的掛載策略變更為,所有的掛載元素會被 Vue 生成的 DOM 替換[10],在此策略下,一旦執(zhí)行掛載,原來的 DOM 就會消失,不能再次掛載。但我們需要實現(xiàn)代碼變更后能夠重新渲染,這就要求掛載點要穩(wěn)定存在,解決方案是對用戶的 template 進(jìn)行注入,每次渲染前,在 template 外層包一層帶固定 id 的 DOM
運行時錯誤捕獲errorCaptured需要注入到component對象上,不再需要注入beforeUpdate鉤子
因為通過new Vue()的方式創(chuàng)建了一個新的 vm 實例,不再是容器組件的子組件,所以容器組件上的errorCaptured無法捕獲新 vm 的運行時錯誤,new Vue(component)中參數(shù) component 是頂層組件,根據(jù)?Vue 錯誤傳播規(guī)則[11]?可知,在非特殊控制的情況下,頂層的?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 標(biāo)簽會出現(xiàn)以下編譯錯誤,但 style 標(biāo)簽是必須的,需要為自定義組件提供 scoped 的樣式。(當(dāng)然,也可以通過提供 appendStyle 函數(shù)實現(xiàn)動態(tài)添加 style 標(biāo)簽,但這樣并沒有更方便,因此沒有必要)
??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/merfais/vue-demo/blob/main/src/views/customCode/withMount.vue[12]
完整的 demo 見:https://merfais.github.io/vue-demo/#/custom-code[13]
想一下,如果動態(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 標(biāo)簽和 link 標(biāo)簽提前寫到靜態(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()?{//?添加依賴的cssappendLink('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.contentDocumentthis.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的前后標(biāo)簽const?template?=?(this.template?||?'').replace(/^?*<?*template?*>|<\/?*template?*>?*$/g,?'').trim()//?注入template或render,設(shè)定template優(yōu)先級高于renderif?(template)?{component.template?=?templatecomponent.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?=?truethis.mountCode()})}},},methods:?{mountCode()?{//?添加cssconst?css?=?this.component.cssdelete?this.component.cssremoveElement(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.Vuenew?Vue(this.component).$mount('#app')},},注意點:
iframe 的渲染到文檔流后才能添加依賴資源,依賴資源加載完才能執(zhí)行 vm 的掛載,首次加載時需要控制時序
vm 掛載點的重建采用了永遠(yuǎn)添加在 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)的語法錯誤還是運行時錯誤,都重繪。當(dāng)然這種做法也就丟失了組件自刷新的功能,因為一旦發(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()?{//?當(dāng)代碼變化時,清空error,重繪this.subCompErr?=?null},template()?{//?當(dāng)代碼變化時,清空error,重繪this.subCompErr?=?null},css()?{//?當(dāng)代碼變化時,清空error,重繪this.subCompErr?=?null},},methods:?{renderError({?type,?msg?})?{return?{render()?{return?<div?style='color:?red'><div>{type}</div><div>{msg}</div></div>},}},},除了錯誤處理,還需解決一下 iframe 的一些特性,比如邊框,滾動條,默認(rèn)寬高,其中比較棘手是 iframe 高度有默認(rèn)值,并不會隨著 iframe 的內(nèi)容自適應(yīng)高度,但對于自定義組件的渲染,需要動態(tài)計算高度,固定高度是不行的。
邊框,滾動條,寬度可通過修改 iframe 的屬性解決,見上面的 template 代碼。
高度自適應(yīng)的解決方案是通過MutationObserver觀測 iframe 的 body 變化,在回調(diào)中計算掛載點(第一個子元素)的高度,然后再修改 iframe 本身的高度。之所以沒有直接使用 body 的高度,是因為 body 有默認(rèn)的高度,當(dāng)被渲染的組件高度小于 body 高度時,直接使用 body 的高度是錯的。核心代碼如下
mounted()?{//?通過觀察器觀察iframe的body變化后修改iframe的高度,//?使用iframe后垂直的margin重合效果會丟失const?observer?=?new?MutationObserver(()?=>?{const?firstEle?=?this.iframeDoc.body.firstElementChildconst?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[14]
完整的 demo 見:https://merfais.github.io/vue-demo/#/custom-code[15]
至此非跨域 iframe 渲染全部邏輯介紹完畢,接下來看一下跨域 iframe 的渲染。跨域 iframe 與非跨域 iframe 的渲染過程基本是一致的,只是有由于跨域,隔離的更徹底。其主要體現(xiàn)在主域與 iframe 域不能互相讀寫對方的文檔流 document。
此限制帶來的變化有以下幾點
依賴的資源需要提前內(nèi)置在 iframe 內(nèi)。
內(nèi)置指的是將依賴的資源通過 script,link 標(biāo)簽添加到 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[16],這個限制導(dǎo)致Function類型的數(shù)據(jù)無法傳過去,但組件很多功能需要使用函數(shù)才能實現(xiàn),無法跨越這個限制,組件能力將損失過半或更甚。
對于這個限制的解決方案是:對不支持的數(shù)據(jù)類型進(jìn)行序列化,轉(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ù)前后打上特殊標(biāo)記后轉(zhuǎn)成stringif?(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);//?匹配特殊標(biāo)記,匹配成功,反轉(zhuǎn)為functionconst?matched?=?str.match(/^##([^#]*)##$/);if?(matched)?{//?string轉(zhuǎn)成function可以用eval也可用new?Functionreturn?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/merfais/vue-demo/blob/main/src/views/customCode/mountCrossIframe.vue[17]
iframe:?https://gitlab.com/merfais/static-page/-/blob/master/public/iframe.html[18]
完整的 demo 見:https://merfais.github.io/vue-demo/#/custom-code[19]
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)險,當(dāng)然此方案也有很多的局限性。
參考資料
[1]
Vue (讀音 /vju?/,類似于 view) 是一套用于構(gòu)建用戶界面的漸進(jìn)式框架。與其它大型框架不同的是,Vue 被設(shè)計為可以自底向上逐層應(yīng)用。:?https://cn.vuejs.org/v2/guide/index.html#Vue-js-%E6%98%AF%E4%BB%80%E4%B9%88
[2]vue 的運行時 + 編譯器:?https://cn.vuejs.org/v2/guide/installation.html#%E8%BF%90%E8%A1%8C%E6%97%B6-%E7%BC%96%E8%AF%91%E5%99%A8-vs-%E5%8F%AA%E5%8C%85%E5%90%AB%E8%BF%90%E8%A1%8C%E6%97%B6
[3]官方的介紹:?https://learning.dcloud.io/#/?vid=2
[4]詳細(xì)看文檔:?https://cli.vuejs.org/zh/config/#runtimecompiler
[5]注冊組件:?https://cn.vuejs.org/v2/guide/components-registration.html
[6]render 函數(shù):?https://cn.vuejs.org/v2/guide/render-function.html
[7]errorCaptured這個官方鉤子:?https://cn.vuejs.org/v2/api/index.html#errorCaptured
[8]https://github.com/merfais/vue-demo/blob/main/src/views/customCode/withComponent.vue:?https://github.com/merfais/vue-demo/blob/main/src/views/customCode/withComponent.vue
[9]https://merfais.github.io/vue-demo/#/custom-code:?https://merfais.github.io/vue-demo/#/custom-code
[10]所有的掛載元素會被 Vue 生成的 DOM 替換:?https://cn.vuejs.org/v2/api/#el
[11]Vue 錯誤傳播規(guī)則:?https://cn.vuejs.org/v2/api/#errorCaptured
[12]https://github.com/merfais/vue-demo/blob/main/src/views/customCode/withMount.vue:?https://github.com/merfais/vue-demo/blob/main/src/views/customCode/withMount.vue
[13]https://merfais.github.io/vue-demo/#/custom-code:?https://merfais.github.io/vue-demo/#/custom-code
[14]https://github.com/merfais/vue-demo/blob/main/src/views/customCode/mountSameIframe.vue:?https://github.com/merfais/vue-demo/blob/main/src/views/customCode/mountSameIframe.vue
[15]https://merfais.github.io/vue-demo/#/custom-code:?https://merfais.github.io/vue-demo/#/custom-code
[16]The structured clone algorithm:?https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
[17]https://github.com/merfais/vue-demo/blob/main/src/views/customCode/mountCrossIframe.vue:?https://github.com/merfais/vue-demo/blob/main/src/views/customCode/mountCrossIframe.vue
[18]https://gitlab.com/merfais/static-page/-/blob/master/public/iframe.html:?https://gitlab.com/merfais/static-page/-/blob/master/public/iframe.html
[19]https://merfais.github.io/vue-demo/#/custom-code:?https://merfais.github.io/vue-demo/#/custom-code
關(guān)注公眾號秋風(fēng)的筆記,一個專注于前端面試、工程化、開源的前端公眾號
關(guān)注后回復(fù)簡歷獲取100+套的精美簡歷模板
關(guān)注后回復(fù)好友拉你進(jìn)技術(shù)交流群+面試交流群
歡迎關(guān)注秋風(fēng)的筆記
總結(jié)
以上是生活随笔為你收集整理的Vue隐藏技能:运行时渲染用户写入的组件代码!的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 编程语言的宗教狂热和十字军东征
- 下一篇: 入侵大型网站的完整思路