目錄:
如果你對GmailAssist感興趣,可以在chrome商店中搜索“Gmail助手”,或點擊這里直接訪問商店來安裝試用;
如果你對GmailAssist的源碼感興趣,可以在我的GitHub上查看它的源碼。
零、首先來一波注意事項:
chrome不允許擴展中的HTML頁面內直接內嵌js腳本,像這樣:
<script type="text/javascript"> document.write("Hello World!"); </script>
而要求所有的腳本都作為外部src來引入,像這樣:
<script src="hello.js" charset="utf-8"></script>
再提一點:chrome擴展中涉及的js腳本一定是要運行在網頁上下文中的,也就是說任何一個腳本要跑起來,它必須是屬於某一個網頁的。
進入正題。有了基礎的網頁編程知識后,我們知道我們要做的擴展中,全指着javascript(也就是js)來實現各種程序功能,而HTML和CSS主要是負責顯示。那么在Chrome擴展中運行的腳本有哪些呢?我的理解是大致有這么四類:background、popup頁面內的js、content script、injected js。下面先分別介紹一下這四類腳本,從幾個角度對比一下它們,然后再談談它們之間的聯系和通信。這里的介紹更多地是側重理解,而不是具體的書寫格式,至於后者,最好的參考就是google官方的文檔。
一.Chrome擴展中有哪些類型的腳本
1.injected
- 生存周期
這種腳本,和原網頁自帶的腳本,就完全是一路貨了。有多種方式來在擴展程序中向正在瀏覽的頁面注入這樣的腳本,我只說一種最常用也是最被推薦的:先把腳本保存在js文件里(比如GmailAssist中的tableInited.js),然后在匹配當前頁面的content script中(如GmailAssist中的content.js)用類似下面這樣的代碼來把tableInited.js注入瀏覽中的頁面:
var s = document.createElement('script'); s.src = chrome.extension.getURL('tableInited.js'); s.onload = function() { this.parentNode.removeChild(this); }; (document.head || document.documentElement).appendChild(s);
這里要注意一點:你要注入的inject.js需要在manifest中的web_accessible_resources字段里進行聲明。否則,擴展程序在加載到瀏覽器中時,將會報錯,如圖(圖中是某個.css文件沒有在manifest中聲明導致的報錯,和這里說的錯誤原因是類似的):
那么web_accessible_resources字段是啥呢?說白了就是你的擴展中的文件,有哪些是要允許從網頁可訪問的,就需要挨個在這里面聲明。像這樣:
"web_accessible_resources" : [ "oauth2/oauth2.html", "js/tableInited.js", "css/style.css", "js/table_sort_script.js", "images/sort.gif", ]
否則會報上面的錯。
顯然,這類腳本在每次頁面刷新時是會被重新載入的。
- 可用API范圍
只有網頁通用的API是可用的,而chrome為擴展提供的API(chrome.*),這種完全注入到用戶瀏覽的頁面中的腳本都不能訪問。
- 作用范圍/運行環境
完全和網頁原有的腳本文件一樣,我稱它為“不屬於擴展程序的腳本”。
可以訪問網頁原有js的變量空間。
- 何時使用
我的建議是,僅當你需要獲取被瀏覽頁面中原有js中的變量時,才把你的腳本inject到用戶瀏覽的頁面中,然后通過接下來例子里這種方式,把它傳到content script中。當然了,有一些單純地操縱DOM元素而不需要它們再返回什么數據的腳本,也可以直接inject到頁面里。
- 例子
獲取ik的值。我在GmailAssist構建初期,嘗試過一個gmail的非官方的庫,當時為了獲取郵箱用戶的唯一標識(即ik,它是在gmail原有js的變量空間中的全局變量即GLOBALS中的),就不得不通過向頁面中注入injected script來獲取到GLOBALS。獲取到之后要傳給擴展程序的其他部分,則要通過event listener來完成。部分代碼如下:
injected script中:
if(email_data) { window.postMessage({"usrik": JSON.stringify(userik) }, '*');//userik就從GLOBALS中取得
}
content script中:
window.addEventListener("message", function(event) { if(event.data.usrik) { usrik = event.data.usrik console.log(usrik); } }, false);
2.content script
- 生存周期
和injected script相似,它也是被注入到用戶當前瀏覽的頁面中的。但區別在於,它不是真正完全融入網頁上下文的,而是運行在一個單獨的被隔離的環境中。它的生存周期也就是跟瀏覽的網頁一樣,最遲到網頁加載完全完成時,content script就開始跑了,直到用戶當前瀏覽的網頁被關閉。每次刷新時將重新載入。
- 可用API范圍
網頁通用的API,跨域xhr請求,以及chrome為擴展程序提供的API中的一部分,具體有:(開頭都是chrome.)
extension(getURL、inIncognitoContext、lastError、onRequest、sendRequest)
i18n
runtime(connect、getManifest、getURL、id、onConnect、onMessage、sendMessage)
storage
- 作用范圍/運行環境
它是注入用戶瀏覽的網頁中的,但又不像injected script那樣徹底,而是單獨運行在一個隔離環境里。
它可以訪問一部分chrome給擴展程序提供的API,但也只有一部分。
它不運行在網頁的真正上下文中,因而只能訪問和操縱頁面DOM,但訪問不到頁面里js的變量空間(當然也訪問不到頁面里js定義的函數們)。
它不可以訪問background和popup頁面中的腳本(我稱后面這兩類為“完全屬於擴展程序的腳本”,接下來介紹)的變量和函數,但可以通過和background的通信來和擴展程序的其他部分實現數據交流。(這句和上句,這兩種不可以,都是好理解的,只要你記住content script是運行在專門為它們准備的隔離環境里的即可。)因此我稱content script為“半屬於擴展程序的腳本”。
需要注意到,manifest中聲明的形式,content_scripts字段的值是一個數組。也就是說一個擴展程序可以向一個頁面中插入多個content script,而每個content script可以有多個 JavaScript 和 CSS 文件。那么有個問題,同一個頁面內注入的多個content script可能來自同一個擴展程序,也可能來自不同的擴展程序,那么這些content script可以互相訪問對方的變量空間嗎?都不可以。
- 何時使用
需要操縱頁面DOM時,需要與具體頁面匹配時,需要接受injected js傳出來的數據時,以及每次刷新網頁都需要重新載入的腳本,就可以作為content script來寫。
- 例子
向gmail服務器發xhr請求數據、操縱gmail頁面的DOM,把返回的數據顯示出來。
3.popup
- 生存周期
在用戶點擊擴展程序圖標時(無論是page action還是browser action),都可以設置彈出一個popup頁面。而這個頁面中自然是可以有運行的腳本的(比如就叫popup.js)。它會在每次popup頁面彈出時重新載入。
- 可用API范圍
這類腳本和下一類(background),我都稱為“完全屬於擴展程序的腳本”。它們不僅可以訪問普通網頁API、可以發起跨域xhr請求,而且可以訪問chrome為擴展程序專門提供的API(即chrome.*)中的全部。
- 作用范圍/運行環境
“完全屬於擴展程序的腳本”之間是可以互相訪問的,但popup頁面中的腳本,會在每次用戶呼出popup頁面時重新載入。
- 何時使用
僅針對popup頁面內起作用的、比較小的(這樣每次重新載入不會太久)腳本,用這一類來實現,比較合適。當然,這種腳本可以向外部請求數據,也可以訪問本地存儲API(chrome.storage),那么是可以通過這類腳本來寫的。
- 例子
授權按鈕(加載很快,而且只在每次用戶點擊圖標時加載即可,獲取的token通過localStorage保存在本地,功能完成后即可把該腳本占據的資源騰出來)。
來個反例:
最初我因為對這幾類腳本的認識還比較模糊,所以圖方便把獲取附件列表的功能寫在了popup頁面中,並且沒有調用本地存儲來在本地cache一份。這就導致每次用戶想操作附件時,都得點圖標然后等一會才能繼續,這樣的體驗是很差勁的,所以我就把獲取附件列表的邏輯挪到了content script中。當然,現在在了解了本地存儲API后,我依然認為不應該在popup頁面中完成諸如獲取附件列表這樣的功能,原因有二:1.popup頁面每次重新加載,即使數據從本地加載比較快,頁面元素的加載時間總是不可避免的;2.popup頁面的位置和大小限制,從用戶角度來看,用它來實現和用戶交互的界面,比較不舒服。
4.跑在后台(background)頁面中的腳本
- 生存周期
這類腳本是運行在瀏覽器后台的,注意它是與當前瀏覽頁面無關的。
所謂的后台腳本,在chrome擴展中又分為兩類,分別運行於后台頁面(background page)和事件頁面(event page)中。兩者區別在於,前者(后台頁面)持續運行,生存周期和瀏覽器相同,即從打開瀏覽器到關閉瀏覽器期間,后台腳本一直在運行,一直占據着內存等系統資源;而后者(事件頁面)只在需要活動時活動,在完全不活動的狀態持續幾秒后,chrome將會終止其運行,從而釋放其占據的系統資源,而在再次有事件需要后台腳本來處理時,重新載入它。這兩類咋區分呢?通過你在manifest中的聲明:
"background": { "scripts": ["background.js"], "persistent": false },
正如上一節說過的,這里persistent的值默認是true,此時這個js就是運行在后台頁面的(持續的);若這個值為false,那就是事件頁面(非持續的)了。
- 可用API范圍
和popup那種一樣。
- 作用范圍/運行環境
作為“完全屬於擴展程序的腳本”,它可以和其他的“完全屬於擴展程序的腳本”之間通信;也是運行在瀏覽器的環境內的,而與當前瀏覽的頁面無關。
- 何時使用
需要持續運行在后台的,肯定就選這種了,而且要把persistent置為true。需要在后台處理些事件啊之類的,包括要用到content script無法訪問的擴展程序專用API們時,也應該用這種,不過只要你不是需要它必須持續運行的,就把它設置成事件頁面,從而提高性能。
- 例子
下載(因為1.能調用chrome給擴展程序提供的下載API的,只有“完全屬於擴展程序的腳本”。2.“完全屬於擴展程序的腳本”中,只有background這種可以持續運行在后台,或者被別的事件從后台激活,而另一類——popup頁面中的腳本則是每次彈出popup頁面時都被重新加載一次的,也只有用戶點了擴展圖標時才會彈出popup頁面,若用popup無疑是增加了用戶的操作復雜度)。
二、這幾類腳本之間的通信
我們現在知道,chrome擴展中的通信可以有這么幾類:content script和“完全屬於擴展程序的腳本”之間的通信、injected script和content script之間的通信、“完全屬於擴展程序的腳本”們互相之間的通信、擴展程序的腳本和外部服務器之間的通信。
先提一個概念,“異步”。異步和同步相對,同步就可以理解為“串行”,即完成一件事,才能做下一件;而異步就是,這件事的命令發出去就可以做其他事了,等這件事完成,可以通過“回調函數(callback)”來返回執行結果。曾經看到過一個很形象的例子,異步就相當於你去永和豆漿吃早飯,你把點的東西在櫃台告訴服務員,然后你領個號就可以去做你自己的事了,等你的飯做好了,服務員會叫你的號(回調函數,callback),然后你去拿飯就可以吃了。類似的情景里,同步是啥呢,就是你點了餐,然后就只能在櫃台那等你的餐做好,然后把飯給你你再走(相當於給你一個返回值,return)。
chrome擴展內部的信息交換都是異步的。當然xhr也可以設置成異步的,也可以設置成同步的。
1. content script和“完全屬於擴展程序的腳本”之間的通信
兩者可以互相發送消息,接收端的處理方式是一樣的:(由於content script可以調用chrome.runtime中和信息傳遞相關的幾個API,而“完全屬於擴展程序的腳本”可以訪問到全部chrome.*的API,自然可以調用chrome.runtime.onMessage通信API)
chrome.runtime.onMessage.addListener( function(request, sender, sendResponse) { console.log(sender.tab ?
"from a content script:" + sender.tab.url : "from the extension"); if (request.greeting == "hello") sendResponse({farewell: "goodbye"}); });
發送端有所區別。(當然兩種方向的發送,都是可以選擇有沒有回復)
從content script向“完全屬於擴展程序的腳本”發消息:
chrome.runtime.sendMessage({greeting: "hello"}, function(response) { console.log(response.farewell); });
從“完全屬於擴展程序的腳本”向content script發消息:
chrome.tabs.query({active: true, currentWindow: true}, function(tabs) { chrome.tabs.sendMessage(tabs[0].id, {greeting: "hello"}, function(response) { console.log(response.farewell); }); });//和上面的區別是,這種消息發送的方向,需要你指明你信息要發到哪個頁面(用戶瀏覽的頁面)中,也就是哪個tab中(chrome中每個頁面都在一個tab,即標簽中打開嘛)的content script;但顯然上面那種就不需要,而且在接收端也不需要。
這部分的實例代碼,很容易理解,我也都是直接摘抄的chrome的官方文檔,更多更具體的關於這些通信API信息,可以訪問這里和這里得到。
2. injected script和content script之間的通信:
通過window.addEventListener和 window.postMessage來實現,代碼可以參考上面獲取user_ik的那個。
這種通信方式的原理是基於content script和頁面內的腳本(injected script自然也屬於頁面內的腳本)之間唯一共享的東西就是頁面的DOM元素,window就是頁面元素,因而可以被用來傳遞消息。
3.“完全屬於擴展程序的腳本”們之間通信
首先,他們之間函數是可以直接互相訪問的;其次,它們可以互相訪問對方的DOM元素;因而他們之間事實上不存在什么復雜的通信,大家都是一家人。
4. 擴展程序和外部服務器的通信
這里要補充幾個概念:
1)域(domain)
這個表格來自我上一節中提到的電子書《Chrome擴展及應用開發》。其中列出了普通的網站在進行XHR時,必須遵守的規則。但在Chrome擴展中,可以通過在manifest的permissions屬性中聲明需要跨域的權限,來實現跨域xhr。
2) XHR(XMLHttpRequest)
這里簡單理解為頁面或站點之間請求資源所要遵循的格式即可。需要知道,請求有兩種結果,一種成功,將在返回信息中附帶值為200的狀態碼;而另一種失敗,將根據不同的失敗原因在返回信息中附上不同的狀態碼和具體的失敗原因描述(比如最常見的404,頁面不存在)。
你向外部服務器請求數據就要通過發起xhr(當然這要求你先把你要請求數據的站點聲明在manifest的permissions字段里,這樣chrome才允許你跨域,否則根據默認不允許跨域的原則,你只能訪問你的擴展程序中的資源——html、js、圖片之類的都是資源。說到這,也就可以更好理解前面提到過的manifest中的web_accessible_resource字段的含義了吧)。
發起xhr的格式比較固定,篇幅原因,具體可以參考GmailAssist的源碼。或者直接百度一下都有一大把。
最后補充幾點,
1. 與擴展程序相關的信息的傳送形式都是JSON字符串(可以簡單理解為就是把JSON對象加上引號變成字符串),但你並不需要額外弄個JSON的庫到你的程序中,chrome自動集成了這些東西;
2. 除了與外部的xhr交流,內部的這些信息傳送不需要你手動轉成字符串或者手動從字符串轉成對象(JSON.stringify或JSON.parse),這兩種過程是自動的,你可以直接認為你傳送的就是JSON對象,而不是字符串;但在和外部進行XHR交流時,你需要把發出去的信息手動stringify一下,把收到的信息parse一下,才可以正常使用;
3. 從安全性的角度出發,不要讓你的程序接收到外來的信息時,用eval等方法來處理它們,這可能導致惡意腳本的執行,而應該使用JSON.parse這種不會引起腳本執行的方法來處理收到的信息(這一點如果不好理解,可以不去深究,只要記住,用xhr請求來的信息,統統用JSON.parse來解析即可);
4. 可以認為還有一種通信方式,在除了injected script外的剩下幾種腳本之間,都可以通過本地保存的數據進行通信(即通過localStorage或者chrome.storage,這二者的概念,下一節中我會介紹)。
三、GmailAssist中安排哪些邏輯寫在哪種腳本中
清楚了上面這些基礎知識,結合着對Gmail API的認識,我把GmailAssist要實現的每個功能,按照“它應該被寫在哪一類腳本中”,細分成幾個具體的小部分(並不是原子操作,這些小部分是可以再細分的,但這是后話):
- 1. 授權(用了一個現成的庫)。
1# 用戶點擊授權或取消授權按鈕,發起授權請求或刪除本地保存的OAuth token;(popup)
- 2. 獲取列表並顯示。
1# 向服務器發請求(用message.list)(content script)
2# 在gmail的頁面的DOM中加入一塊頁面(直接自己布置在一個div中,或者弄個iframe都可以,我這里采用了前者)(content script)
3# 收到服務器返回的數據並顯示在2#中生成的區域內(content script)
4# 把列表緩存到本地(用chrome.storage,后面的篇章中會介紹)(content script)
- 3. 單獨下載列表中的某個附件
1# 向服務器發請求(content script)
2# 收到服務器返回的附件下載地址后,把它傳遞給background(content script)
3# 彈出下載對話框,讓用戶選擇保存位置和設置保存的文件名(background)——由上面介紹的background和content script的特點,就不難理解為什么需要把下載地址傳遞給background了。
- 4. 向草稿中插入附件(都是get、post方法調用gmail API,都在content script中完成即可)
1# 獲取草稿
2# 獲取附件內容
3# 拼接出新草稿(雖然不是調用gmail API,但顯然也應該直接在content script中完成這部分工作)
4# 上傳新草稿
- 5. 批量操作、列表過濾等等針對獲取到本地的數據的操作,包括操縱頁面DOM的操作,也可以一並都放在content script中來實現。
- 6. i18n(即國際化/多語言)相關的內容,其實已經基本不涉及js腳本了,我會放在i18n那一節里說,具體請看頂端的目錄。
不難發現,GmailAssist的主要功能都是放在content script中實現的。
當你在開發你的擴展程序時,如何決定你的邏輯用哪類js來實現,可以參考我上面的分類介紹。
四、總結
chrome擴展中的腳本運行機制和通信方式,就基本是這樣。上面的介紹中我沒有放多少代碼,因為實際開發時,最靠譜的參考一定是官方的文檔。上面這些只是記錄我的理解,也希望幫助新手更好地理解chrome擴展的architecture。下一節將介紹Chrome擴展中的數據存儲和下載。