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执行来自外部文件的内容,比如在插件安装时,读取配置数据并写入stroage,此时用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扩展中脚本的运行机制和通信方式

参考资料

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code