跳到主要內容
黯羽輕揚每天積累一點點

Chrome 擴充功能開發常見問題

免費2016-09-24#Solution#同步注入脚本#inject script sync#chrome插件模拟请求#chrome插件拦截请求

如何同步注入腳本、如何存取數據、腳本之間如何通訊

寫在前面

這段時間捏了一個 Chrome 擴充功能,遇到很多問題,現在終於進入砌磚階段

一. 目標需求

把介面作為分界線,前端開發可以和後端開發同時進行,對前端來說有 2 個階段:

  1. 介面沒完成。先用假數據將就,調頁面跑邏輯

  2. 介面完成了。去掉假數據,用後端服務提供的數據再調

那麼肯定存在一份假數據,把它寫在業務程式碼裡,介面完成後注釋掉?提出來作為獨立檔案,在頁面中引入,介面完成後刪掉 script 標籤?起本地服務單獨配置假數據,介面完成後替掉本地介面?……放在哪裡都難受,因為介面完成後需要改動現有程式碼,有改動就可能出錯

那有沒有不需要改動程式碼的方式?

有的。把假數據放在 Chrome 擴充功能裡,這樣假數據和業務程式碼就完全分離了

二. 實作方案

業務程式碼通常會在 DOM ready 時請求介面,拿到數據後在頁面上展現出來,當然,也有可能在 DOM ready 之前,一些請求就發輸出了

那麼應該在一切業務程式碼執行之前,把請求攔截邏輯和假數據注入頁面,然後由攔截邏輯給請求分發對應假數據

看起來至少需要攔截 Ajax 請求、JSONP 的 get 請求,攔截不是問題,關鍵是過濾,把非數據請求發出去,把數據請求攔下來並返回假數據,考慮 RESTful API 的話,假數據會稍微複雜一點

但 Hybrid App 有一些特殊的地方,比如:

  • 客戶端會把某些功能暴露出來,以客戶端介面的形式供內嵌頁面使用

  • 數據請求需要經過客戶端轉發,由客戶端加密,提供安全保障

那麼不用管什麼 Ajax、JSONP 了,因為數據請求是透過客戶端介面發送的,數據也是客戶端「提供」的,那麼只要模擬客戶端就可以了

連請求攔截都不用考慮了,直接冒充客戶端提供數據請求介面就行(當然,也可以提供其他功能介面,做個全套的話,調試就幾乎不依賴客戶端了)

P.S. Chrome 擴充功能提供了 "webRequest", "webRequestBlocking" 等權限用來處理頁面請求,更多資訊請查看 官方文件

確定方案後,還需要考慮細節:

  • iOS 一般透過第三方庫 WebViewJavascriptBridge 提供 native 介面

  • Android 提供介面最簡單的方式就是暴露一個全域物件給 JS

WebViewJavascriptBridge 的「native ready」通知方式是在 document 上觸發一個自定義事件 WebViewJavascriptBridgeReady,頁面 JS 監聽該事件完成「連接」。Android 則簡單粗暴得多,注入的全域物件隨時都能存取,在頁面 JS 執行之前,早就「native ready」了,所以不需要「連接」

那麼,冒充客戶端的方法就是對於 Android,要在頁面所有 JS 執行之前掛上自定義全域物件,對於 iOS,隨便什麼時候手動觸發 WebViewJavascriptBridgeReady 都行。所以 Android 需要同步注入假介面,iOS 無所謂,而假數據沒有必要同步注入(假數據可能比較龐大,非同步注入讓頁面更快一點,假數據沒到之前,可以把請求先塞到佇列裡)

三. 問題與解決方案

1. content_scripts 對本地檔案無效

擴充功能管理頁中有些擴充功能下方有個核取方塊「允許存取檔案網址」,該選項預設是沒有的,如果配置文件 manifest.json 中出現了:

"content_scripts": [{
  "matches": ["<all_urls>"]
}]

才會有這個選項,勾選之後 content_scripts 就可以處理本地檔案了

2. 如何同步注入腳本?

content script 可以操作頁面 DOM,但無法直接修改 JS 變數,因為二者的 JS 執行環境不同。想要修改頁面 JS,只能透過注入 script 標籤來實現,那麼分為 2 步:

  1. 獲取將要注入頁面的腳本

  2. 建立 script 標籤插入頁面

非同步注入腳本非常容易:

var loadScript = function(filename) {
    var s = document.createElement('script');
    // 获取插件安装后,mock.js的file:///路径
    //! 需要在manifest.json中声明web_accessible_resources
    s.src = chrome.extension.getURL(filename);
    // head还没准备好的话,取html
    var doc = document.head || document.documentElement;
    return doc.appendChild(s);
}
// test
loadScript('mock.js');

頁面會被插入一個 script 標籤,src 屬性為 file:///xxx,會非同步載入腳本檔案,載入完畢後執行

實際上該腳本執行的時間是在 DOM ready 之後的,在這之前的請求溜掉了,因為假客戶端還在載入中。必須保證在頁面所有 JS 執行之前,執行完 mock 邏輯,這樣 Android UA 下才不會出現請求溜掉的情況

那麼需要一種同步注入腳本的方案(注入後立即執行,而不是非同步載入執行),先同步讀取腳本,再插入一個內嵌腳本即可,如下:

// 1.同步获取脚本内容
var readFileSync = function(filename, callback) {
    // read script sync
    var xhr = new XMLHttpRequest();
    var scriptUrl = chrome.extension.getURL(filename);
    //!!! disable async
    xhr.open("GET", scriptUrl, false);
    xhr.onreadystatechange = function() {
        if (xhr.readyState === 4 && xhr.status === 200) {
            callback(xhr.responseText);
        }
    };
    xhr.send(null);
};
// 2.插入内联脚本
var writeScriptSync = function(code) {
    var s = document.createElement('script');
    s.textContent = code;
    var doc = document.head || document.documentElement;
    return doc.appendChild(s);
};
// test
readFileSync('mock.js', writeScriptSync);

關鍵xhr.open("GET", scriptUrl, false);s.textContent = code;,前者同步 Ajax 讀取檔案內容,後者把程式碼字串寫入 script 標籤

3. 工具列圖示與擴充功能管理頁圖示

正經擴充功能首先要有個圖示,需要分別設置配置文件 mainfest.jsonbrowser_action.default_iconicons 欄位,示例如下:

// 工具栏图标
"browser_action": {
  "default_icon": "icon/icon.png",
},
// 扩展管理页图标
"icons": {
  "128": "icon/128.png",
  "16": "icon/16.png",
  "48": "icon/48.png"
}

4. 數據讀寫

可選方案有很多:

  • window.localStorage 同步讀寫,存在 CORS 限制、5M 限制

  • chrome.storage.local/sync 非同步讀寫,存在 5M 限制,聲明權限後可以去掉

  • IndexedDB 非同步讀寫,NoSQL 資料庫

  • WebSQL 非同步讀寫,SQLite 資料庫,非規範

非規範的 WebSQL 不考慮,window.localStorage 沒有太大優勢,chrome.storage.local/sync 稍微強大點,但讀寫效率不如 IndexedDB,所以建議選擇 IndexedDB

此外,各有各的優勢:

  • window.localStorage 是同步讀寫的,在某些要求同步的場景下很有用

  • chrome.storage.local 是最簡單的方式,能夠滿足少量數據的讀寫需求

  • chrome.storage.sync 能夠多設備自動同步,很強大

實際使用中發現,chrome.storage.syncset 失敗問題(get 取到 undefined),暫時改用 local 儲存狀態數據。聲明 unlimitedStorage 權限能夠去掉 5M 限制,但會帶來新的限制:

Note:This permission applies only to Web SQL Database and application cache (see issue 58985). Also, it doesn't currently work with wildcard subdomains such ashttp://*.example.com.

P.S. WebSQL 規範化進程擱淺了,因為各瀏覽器都是基於 SQLite 實作的,規範化進程受到了 SQLite 的限制,陷入僵局,更多資訊請查看 Web SQL Database

5. eval 權限

擴充功能中預設不允許使用 eval 執行來自外部檔案的內容,比如在擴充功能安裝時,讀取配置數據並寫入 storage,此時用 eval 會報錯,非要用的話,需要在配置文件 mainfest.json 中修改 CSP(內容安全策略):

"content_security_policy": "script-src 'unsafe-eval'; object-src 'self'"

預設的 script-src 'self'; object-src 'self' 不允許不安全的 eval

四. IndexedDB

原生 API 用著比較難受,還需要瞭解各種奇怪的概念:objectStorekeyPathtransaction 等等,建議直接選用第三方庫 Dexie.js 作為 DBHelper

API 設計很精巧,增刪改查示例如下:

// 建库建表
var db = new Dexie('db');
db.version(1).stores({
    // ++表示自增字段,&表示unique约束
    tb: '++id, &key, value, desc, other'
});
// 增
db.tb.bulkAdd([{
    key: 1,
    value: '1'
}, {
    key: 2.5,
    value: '2.5'
}, {
    key: 2,
    value: '2'
}]);
// 删
db.tb
    .where('key')
    .equals(2.5)
    .toArray()
    .then(function(res) {
        console.log(res);
        // 删除
        db.tb.delete(res[0].id);
    }, console.log.bind(console));
// 改
db.tb.put({
    key: 'getPoiInfo',
    value: 'value'
});
// 查
db.tb
    .where('id')
    .inAnyRange([[0, 100]])
    .each(function(item) {
        console.log(item);
    });
// 查+改
db.tb
    .where('key')
    .equals(1)
    .modify({value: 'value1'});
// 自定义全表查找
db.tb
    .filter(item => /get.*/i.test(item.key))
    .each(function(item) {
        console.log(item.value);
    });

五. 腳本通訊方式

擴充功能開發中存在多種類型的腳本,不同腳本之間需要通訊,如下:

  • content script 和「完全屬於擴充功能的腳本」之間的通訊

  • injected script 和 content script 之間的通訊

  • 「完全屬於擴充功能的腳本」們之間通訊

  • 擴充功能和外部伺服器的通訊

我們關注的是 content script 和「完全屬於擴充功能的腳本」之間的通訊,因為在 content script 中需要向後台頁面索要假數據,可以透過 runtime message 來實現:

// 建立通信连接
var port = chrome.runtime.connect({name: "app"});
// content script发
port.postMessage({
    action: 'KEYQUERY',
    key: key
});
// 后台页面background.js收
chrome.runtime.onConnect.addListener(function(port) {
    port.onMessage.addListener(function(msg) {
        if (port.name != "app") return;
        switch (msg.action) {
            case 'KEYQUERY':
                db.tb
                    .filter(item => item.key === msg.key)
                    .toArray()
                    .then(function(arr){
                        port.postMessage({
                            _action: msg.action,
                            data: arr
                        });
                    }, function() {
                        port.postMessage({
                            _action: msg.action,
                            data: []
                        });
                    });
        }
    }
}

上面先建立了通訊連接,再發消息,也可以不建立連接,發單次消息:

// 发
chrome.runtime.sendMessage({greeting: "hello"}, function(response) {
    console.log(response.farewell);
});
// 收
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"});
    }
);

但需要注意,單次消息因為沒有穩定存在的連接,無法非同步回應,例如:

// 收
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")
        // 异步回应
        setTimeout(() => {
            sendResponse({farewell: "goodbye"});
        }, 1000);
        //!!! 这里会直接触发发送方的回调,返回undefined
        // 1秒后的sendResponse丢失了
    }
);

此時,非同步 sendResponse 會丟失,所以建議採用建立通訊連接的方式通訊

關於腳本通訊方式的更多資訊,請查看 Chrome 擴充功能開發之二——Chrome 擴充功能中腳本的運行機制和通訊方式

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論