写在前面
这段时间捏了一个Chrome插件,遇到很多问题,现在终于进入砌砖阶段
一.目标需求
把接口作为分界线,前端开发可以和后端开发同时进行,对前端来说有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步:
获取将要注入页面的脚本
创建
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.json
的browser_action.default_icon
和icons
字段,示例如下:
// 工具栏图标
"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.sync
有set
失败问题(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用着比较难受,还需要了解各种奇怪的概念:objectStore
、keyPath
、transaction
等等,建议直接选用第三方库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扩展中脚本的运行机制和通信方式