隨着web瀏覽器的發展,瀏覽器的性能愈來愈好,WebGL和WebAssembly提供愈來愈多的可能性。不少本來只能在終端運行的程序都開始開發web版本例如CAD的web版本,PS的web版本,figma。這一個個的設計協做平臺本來在終端都有插件機制。那麼若是在web端能提供一個插件機制,對於有一點編程能力的用戶,就能夠提供更好的用戶體驗和開發更多的可能性。如何開發一個好的插件系統呢?javascript
一個javascript的插件系統須要知足如下幾個方面:html
當咱們在程序中執行第三方的代碼的時候,首先第一個應該會想到的就是iframe。iframe不是咱們天天都會用到的html標籤。要理解爲何iframe爲何安全,咱們有不要想一下iframe標籤是用來幹什麼的。java
iframe比較典型的使用場景就是在一個網頁中嵌入一個其餘的網頁。舉個例子來講,你須要在網站中嵌入谷歌地圖的頁面來實現地圖的展示功能。你不會但願谷歌地圖的頁面中的代碼有能力訪問你自己的一些代碼和敏感數據,相應的谷歌地圖也不但願你可以訪問他頁面中的數據和代碼。c++
這意味着一切和iframe的交互都受限於瀏覽器。當iframe和原網頁有不一樣的域(imow.cn和google.com),他們是徹底隔絕的。那麼網頁和iframe交互的惟一辦法就是經過 postMessage
。這個message是一個string。須要交互的雙方能夠選擇忽略這個message或者作對應的動做。git
iframe和原網頁是徹底獨立的,其實,若是你想要的話瀏覽器容許咱們經過另一個線程來建立一個iframe。這裏.github
當咱們瞭解了iframe是如何工做了之後,咱們能夠在咱們須要執行第三方插件的時候創一個iframe,將插件的代碼在iframe中執行。在iframe中插件能夠執行任何代碼,也不會影響到主程序,除非經過提早申明好的message。同時咱們能夠給iframe的域名設置爲null,這意味着根據瀏覽器跨域保護策略,iframe沒法給域名發送任何請求。web
iframe就這樣很簡單的成爲咱們執行第三方插件的沙盒環境,他的安全性也經過瀏覽器來保證。插件在沙盒中執行,經過主程序提供的api(postMeassge)和主程序進行交互。代碼就像下面這個樣子編程
const scene = await main.loadScene() // 從主程序獲取界面數據
scene.selection[0].width *= 2 // 修改界面數據
scene.createNode({
type: 'RECTANGLE',
x: 10, y: 20,
...
})
await main.updateScene() // 向主程序發送修改後的界面數據
複製代碼
這裏主要的代碼是loadScene(發送消息給主程序,而後得到主程序界面的document拷貝),而後修改完之後經過調用updateScene(發送更新消息給主程序).這裏須要注意的是api
這種實現方式第一個問題就是對於一些不那麼瞭解javascript的新手或者設計師來講,async/await
關鍵字仍是很是陌生的。可是要使用postMessge是一個異步操做。因此不可避免的要使用async/await
來控制異步流程。可是若是隻是須要在開頭和結束的時候調用咱們的api還方便,咱們能夠告訴用戶在調用咱們的api時候在前面加上async/await
即便他們不知道這個關鍵字的做用也不會對他們的操做形成很是大的困擾。跨域
可是問題是有些插件須要執行很是複雜的邏輯,在修改一個layout的屬性的時候有時候會引發其餘好幾個layout的更新。好比更新外層的layout的屬性以後,內部的layout的屬性也可能發生了更新,這個時候你須要先提交你的屬性,而後在從新或者視圖的屬性,那這個時候你的代碼就會變成這樣:
await mian.loadScene()
... 操做 ...
await mian.updateScene()
await mian.loadScene()
... 操做 ...
await mian.updateScene()
await mian.loadScene()
... 操做 ...
await mian.updateScene()
複製代碼
這個代碼一會兒就變的不可控了,並且用戶也很難確認何時應該要提交個人屬性更新。
iframe這種實現方式的第二個問題就是,當你須要給插件發送視圖信息的時候你須要序列化你的document發送給你iframe,當你的視圖很是很是大的時候,這個序列化的操做是很是耗時的,甚至會致使內存溢出。 即便咱們可使用增量的加載數據或者懶加載數據這種方式仍然有他的問題:
總的來講若是你的主程序有很是大的document要交給第三方插件來進行操做,那麼iframe的這種實現方式就不是很是理想的解決方案
若是能在主線程上執行插件代碼,那麼在性能上就會好不少,可是咱們又不能簡單的eval(code)
執行插件代碼,由於這樣是很不安全的。
若是咱們退一步想:是什麼使eval
方法不安全?若是咱們只是執行一段很單純的代碼
let code = 'let a = (7 + 1) * 8;'
eval(code)
複製代碼
若是隻是一段邏輯代碼,那麼這個代碼是沒有什麼不安全的。之因此認爲eval執行的代碼不安全是由於在插件代碼中有可能會發送網絡請求,修改全局的state變量,或者直接修改dom對象等等這些使得咱們的插件代碼變的不可控,換句話來講是插件具備瀏覽器api訪問的能力讓咱們插件的代碼變的不可控。
若是咱們能把全局的對象藏起來,保證插件代碼中只能作變量的賦值或者一些if判斷的邏輯代碼,沒有了全局對象xhr,插件將沒法發送請求,沒有document對象,插件也不具有訪問dom的能力,那麼插件能力是否是能在咱們的可控範圍裏面了。
隱藏全局對象,理論上是可行的。可是咱們很難僅僅經過隱藏全局對象來建立一個絕對安全的運行環境。舉例來講,咱們如今把window對象設置爲null,可是代碼仍是能夠經過({}).constructor
來訪問全局對象。因此找到全部有可能訪問危險api的對象,把全部的路所有堵死是很是難的一件事情。
是否是咱們能夠找到一個這些全局對象從一開始就不存在的沙盒環境?
Duktape是一個輕量級的用c++寫的javascript解釋器,他能夠將javascript編譯成WebAssembly,通過test262測試以後,能夠肯定他全面的支持了ES5的語法。
這種實現方法有如下幾種優缺點
看起來好像不錯,可是他做爲一個線上項目的表現到底怎麼樣呢?一個javascript引擎來執行另一個引擎?WebAssembly自己也是比較新的一個東西,咱們是否是真的須要一個相對複雜的解決方案?有沒有更簡單的方法了?
這個技術能夠建立一個沙盒環境來支持插件,當我看到他readme文檔的時候,就一會兒提起了個人興趣,Intuitions
這不就是咱們須要的嗎?他的代碼看起來是這個樣子
let g = window; // outer global
let r = new Realm(); // root realm
let f = r.evaluate("(function() { return 17 })");
f() === 17 // true
Reflect.getPrototypeOf(f) === g.Function.prototype // false
Reflect.getPrototypeOf(f) === r.global.Function.prototype // true
複製代碼
是否是很酷。這個技術其實能夠用如今已有的可是不經常使用的一個javascript功能來實現。代碼想這樣
function simplifiedEval(scopeProxy, code) {
'use strict'
with (scopeProxy) {
eval(code)
}
}
複製代碼
這個就像一個簡單版本的Realms,可是管中窺豹,咱們能夠看見兩個關鍵代碼with
和Proxy
對象。
with(obj)
表達式建立了一個做用域,當尋找變量的時候,可使用這個obj
的屬性.看個例子:
with (Math) {
a = PI * r * r
x = r * cos(PI)
y = r * sin(PI)
console.log(x, y)
}
複製代碼
在這個例子裏,當咱們訪問PI
,cos
,sin
的時候,就會找到Math
的屬性。可是console
由於Math
沒有就仍然會找到全局對象。
知道了with
表達式,接下來就是Proxy
對象,這個對象有下面幾個特性
obj.x
訪問對象的屬性值.get
方法來實現obj.x
操做,實際上只執行這個get
方法.const scopeProxy = new Proxy(whitelist, {
get(target, prop) {
// target === whitelist
if (prop in target) {
return target[prop]
}
return undefined
}
}
複製代碼
接下來咱們就能夠把這個scopeProxy
對象做爲參數傳入with
中,他就捕獲做用域全部的變量查找,在這個scopeProxy
的get方法中進行查找這個變量:
with (scopeProxy) {
document // undefined!
eval("xhr") // undefined!
}
複製代碼
這裏只有whitelist的屬性會被返回,其餘都會返回undefined
.可是其實利用一些相似({}).constructor
表達式仍是有可能訪問全局對象的.此外,這個沙盒其實仍是須要訪問一些全局對象的方法的,相似Object.keys
爲要給咱們的插件系統訪問受限全局api的方法而後又不會把window搞亂,Realms沙盒經過建立一個和主程序同源的iframe
用來拷貝須要用到的全局API。這個iframe和咱們第一種實現中建立的iframe不同,他不是做爲運行程序的沙盒。當你建立一個和主程序同源的iframe之後
咱們將這些全局對象放入到Proxy的白名單(whitelist)中,這樣在插件代碼中就能夠訪問這些全局對象了。經過建立iframe來拷貝全局對象有一個很重要的好處:即便是經過({}).constructor
對象訪問到的全局對象,也會是iframe中拷貝的全局對象。這樣的實現方式有這些優勢:
那麼就剩下最後一個問題.他真的夠安全了嗎?
這樣看起來結合了iframe的Realms看起來彷佛已經挺不錯的了,並且他自己也是tc39下面的項目,因此可靠性應該也不錯。可是光有一個安全的沙盒環境是不夠的,你的插件確定須要和主程序進行交互,那麼咱們就確定要爲咱們的插件系統提供API,提供給插件的API系統也必定要是安全的。
舉個例子,console.log
是瀏覽器的api是否是javascript功能,那麼咱們要爲插件系統提供一個console.log
方法,咱們能夠這樣寫:
realm.evaluate(USER_CODE, { log: console.log })
複製代碼
或者爲了隱藏方法自己,咱們能夠要求他只傳參數
realm.evaluate(USER_CODE, { log: (...args) => { console.log(...args) } })
複製代碼
看起來是這麼回事,很惋惜,這實際上是一個安全漏洞,即便是第二種方法咱們仍是在Realms外面建立了一個匿名方法,而後直接傳入到Realms中。這意味着插件能夠經過方法的原型鏈訪問到外部。
正確建立console.log
方法的方法是,將這個方法經過Realms包裹起來在Realms內部建立像這樣
// 建立一個工廠方法
// 這個工廠方法返回一個新的方法他保存一個閉包
const safeLogFactory = realm.evaluate(` (function safeLogFactory(unsafeLog) { return function safeLog(...args) { unsafeLog(...args); } }) `);
// 建立一個安全的方法
const safeLog = safeLogFactory(console.log);
const outerIntrinsics = safeLog instanceof Function;
const innerIntrinsics = realm.evaluate(`log instanceof Function`, { log: safeLog });
if (outerIntrinsics || !innerIntrinsics) throw new TypeError();
// 使用
realm.evaluate(`log("Hello outside world!")`, { log: safeLog });
複製代碼
一般來講,在沙盒中不該該可以訪問到外部的任何對象包括做用域。由於咱們的插件和主程序運行在一個線程中,因此在提供api的時候要很是當心,特別是當你的api須要在realm內部操做外部對象的時候。這對於開發api的開發人員來講是否是有點太不友好了,一不當心就產生了安全隱患,(todo:完善起來)。
若是咱們的主程序不是特別複雜並且龐大的話,第一種經過iframe的實現方式應該是最爲簡單的。
若是咱們的主程序自己就是經過WebAssembly建立的例如CAD網頁版,咱們想第二種方式多是比較適合他們的,或者他們提供更加優秀的基於WebAssembly的解決方案
最後一種方式若是咱們能提供一種簡單又安全的開發API的辦法,這應該是一種性價比比較高的解決方案。