前言
Chrome
应该是我们每天都会打开的软件了,我在没有用过Chrome
之前,对浏览器似乎没什么要求,360、搜狗都挺好的,但是用过Chrome
之后,之后就再也不会去用别的浏览器了(这里没有贬低其他浏览器的意思)。Chrome
让人青睐的一大原因之一我觉得应该是他的拓展生态吧,也有很多人把它叫做插件,不过它的英文叫做Chrome Extension
,那在这篇文章里面我们就把它叫做扩展了,大家知道是同一个东西就行。
最近在写文章的时候苦于没有图床软件使用,网上的图床多多少少差点意思,所以决定自己弄一个简单的图床。第一时间就想到了把图床的展示形式做成Chrome
拓展,因为它足够的便捷轻量,也可以趁此机会了解一下Chrome
拓展的开发。本文不会过多介绍Chrome
在拓展方面提供的API
能力,在需要用到某一个能力的时候你可以去查询文档,主要专注的是扩展开发过程中需要了解的重要概念,在这里我会展示几个例子来实战这些概念,也是我平时遇到的一些想用工具解决的问题。
开始之前,先来贴一份官方文档,extensions-doc,现在官方推荐的版本是v3
,所以我这里直接贴了v3
的文档。
基本概念
好的,让我们从几个基本概念开始,走进Chrome
扩展开发。这里所说的概念可能会比较枯燥,不过不要着急,下面我们会有实战例子去帮你巩固这些概念,不想看的同学可以直接跳过。
manifest
这是扩展开发的第一步,你需要在这个配置文件里面填入你的扩展的各种信息,下面举一个简单的例子来稍微了解一下这个配置文件,其他更具体的选项建议移步API
文档,这里就不一一赘述。
{ "name": "翻译", //拓展名称 "description": "快速翻译", //拓展描述 "version": "1.0", "manifest_version": 3, //固定为3 "permissions": [ "storage", //权限申请,如果要使用chrome.xxx这样的api,首先要在这里描述 "activeTab", "scripting" ], "action": { //拓展的展示页面 "default_popup": "popup.html" }, "host_permissions": [//信任的域名 "<all_urls>" ], "content_scripts": [//后面会介绍这个东西 { "matches": [ "<all_urls>" //只会在匹配规则下执行,all_urls表示匹配所有网页 ], "js": [ "js/content-script.js" ], "css": [ "css/style.css" ], "run_at": "document_start" //这里一定要填写,不然对应的脚本不会执行 } ] }
这里主要会以popup
的形式(点击右上角弹出扩展)来说明拓展的开发,这也是我们最常用的拓展方式。
background-scripts
从名字上看这是一个运行在后台的脚本,实际上它干的事情也差不多是这样。生命周期从浏览器的打开开始,结束于浏览器关闭,它可以使用所有的拓展API
。与popup
页面中的脚本(下文会称为popup.js
)大同小异,最不一样的是他们的生命周期。popup.js
的生命周期随着popup
页面的关闭而结束。在manifest.json
中加入如下代码来注册你的background script
。
"background": { "service_worker": "background.js" }
content-scripts
这个属性让开发者可以往当前的tab页面中注入样式或者script
脚本,script
脚本与当前的tab
页面共用dom
元素。
实战开发
讲得再多概念(其实上面讲的不过寥寥数行),不如来实战一把,我们就以开发一个建议的popup
页面为例,学习一下Chrome
扩展的最基本开发,在实际开发的过程中,我相信可以让你更加深刻地去理解扩展开发中的主要概念。
翻译扩展
在看拓展文档的时候,纯英文看的我真是蛋疼,我需要不停的在文档与翻译网站之间来回切换。那我们这里利用一些翻译API
的能力,来实现一个翻译插件。
页面UI
非常的简单(简陋),输入你要翻译的英文,点击GO
帮你翻译成中文,点击复制把翻译结果复制到粘贴板中。
趁着开发之前,我们先来讲一下扩展的目录结构,一个popup
拓展的目录结构大概是下面这个样子的
project -css -js manifest.json popup.html popup.js
在这个扩展的开发过程中,我们主要关注popup.html
和popup.js
就行。页面模版内容简陋如下:
<div> <input placeholder="输入要翻译的内容" id="input" /> <button id="translate">GO</button> </div> <div> <textarea readonly id="result"></textarea> <button id="copy">复制</button> </div> <script src="popup.js"></script>
在逻辑实现中,翻译服务我用的是百度的API
,免费额度是500万
个字符(应该够我用很久了),具体的接入方式可以点击这里。那么现在已经有了后端接口,只要开始写一些简单的前端逻辑就行。在配置好域名权限后,popup.js
或者background-scripts
发起网络请求是不受跨域限制的,所以开发者可以尽情的挥洒笔墨,但更多时候作为扩展使用者的我们应当提高警惕,尽量去官方市场中下载正版扩展来使用。
这里使用的是fetch
来发起网络请求,直接把输入框的内容当成参数请求API
即可,直接看代码吧。
const input = document.querySelector('#input') const translateEl = document.querySelector('#translate') const resultEl = document.querySelector('#result') const copy = document.querySelector('#copy') input.focus() let loading = false let val = '' //API调用所需的参数,详情可看文档 const grantType = 'client_credentials' //固定值 const clientId = '你的clientId' const clientSecret = '你的clientSecret' const accessTokenUrl = `https://aip.baidubce.com/oauth/2.0/token?grant_type=${grantType}&client_id=${clientId}&client_secret=${clientSecret}` const translateUrl = `https://aip.baidubce.com/rpc/2.0/mt/texttrans/v1` //稍微封装一个请求函数 function request(url, config = {}) { const defaultConfig = { method: 'GET', headers: { 'Content-Type': 'application/json;charset=utf-8' } } const requestConfig = Object.assign({}, defaultConfig, config) if (requestConfig.body) { requestConfig.body = JSON.stringify(requestConfig.body) } return new Promise((resolve, reject) => { fetch(url, requestConfig).then(res => res.json()).then(data => { resolve(data) }).catch(err => { reject(err) }) }) } async function translate(val) { const tokenData = await getAccessToken(); const { access_token: accessToken } = tokenData const config = { method: "POST", body: { from: 'en', //这里固定了英->中,实际上如有需要可以做成更灵活的配置 to: 'zh', q: val } } const url = `${translateUrl}?access_token=${accessToken}` const data = await request(url, config) const { result } = data const dst = result?.trans_result[0]?.dst resultEl.value = dst } function getAccessToken() { return new Promise(async (resolve, reject) => { try { const data = await request(accessTokenUrl) resolve(data) } catch (error) { reject(error) } }) } input.addEventListener('input', e => { const { value } = e.target val = value }) translateEl.addEventListener('click', async () => { if (!val.trim()) { return } translate(val) })
上述代码十分简单,这样我们就实现了一个简陋但也许比较方便的翻译扩展。忘了说一句,要调试popup.js
的话直接右键拓展的页面打开开发者工具就行,跟我们平时调试web
一毛一样。
复制
在完成了最基本的翻译功能对接之后,接下来就是如何更方便地使用翻译后的结果。一键点击复制是令人幸福指数增加的操作,所以接下来要做的事情就是点击按钮,将翻译结果复制到粘贴板中。浏览器提供了copy
命令,可以复制选中的内容,具体代码实现如下。
copy.addEventListener('click', () => { resultEl.select() try { document.execCommand('copy') alert('复制成功') } catch (error) { console.log(error) } })
这样几行代码就可以将内容复制到粘贴板中了,但是resultEl.select()
这行代码会选中输入框的所有文本并激活输入框,看起来不是那么的舒服。实际上我们可以用一个隐藏起来的输入框,让它执行select
就好了。
let fakeTextarea = null copy.addEventListener('click', () => { const value = resultEl.value if (!fakeTextarea) { const textarea = document.createElement('textarea') textarea.style = 'position:absolute;top:-999px;left:-999px'; //隐藏起来 document.body.appendChild(textarea) fakeTextarea = textarea } fakeTextarea.value = value fakeTextarea.select() try { document.execCommand('copy') alert('复制成功') } catch (error) { console.log(error) } })
看到这里你肯定已经知道如何通用地复制一个标签的内容,只要将其内容放到隐藏的输入框中执行select
即可。
Token存储
细心的同学已经发现,我们每一次调用翻译接口之前都会去拿一次access_token
,实际上它在一段时间内都是有效的,在这段时间内我们都不需要去取新的access_token
。
由图可以看到它的过期时间是30天,也就是说绝大多数的令牌请求都是可以去掉的,那我们就一定要想办法把它存起来了。如果是在常规的前端开发中我们很容易就想到把它存在localStorage
或者indexDB
等地方。
在扩展开发的场景下,Chrome
也提供了本地存储的API
:chrome.storage
。它与localStorage
具体区别有以下几点:
- 数据可以与
Chrome
同步 - 即使使用隐身模式也可以正常存储
- 用户数据可以存储为对象
- 无需后台页面,内容脚本也可以直接使用
- 提供批量的异步读写
API
,性能更好
值得一提的是,要使用storage
功能,别忘了在manifest.json
加上权限配置。
{ "permissions":[ "storage" ] }
在了解了API
提供的存储能力后,就可以改造一下我们的代码,把access_token
存起来了,对我们的代码也会做一些如下改造,思路参见如下流程图
const ACCESS_TOKEN_STORAGE_KEY = 'ACCESS_TOKEN_STORAGE_KEY' let globalAccessToken = null //内存里也缓存一个 function getResultFromStorage(keys = []) { return new Promise(resolve => { //读缓存 chrome.storage.sync.get(keys, result => { resolve(result) }) }) } function setStorage(key, value) { return new Promise(resolve => { //写缓存 chrome.storage.sync.set({ [key]: value }, res => { resolve() }) }) } async function translate(val) { // ······ const accessToken = globalAccessToken ? globalAccessToken : await getAccessToken() const url = `${translateUrl}?access_token=${accessToken}` const data = await request(url, config) const { result, error_code } = data if (error_code === 110) { //token过期 await refreshToken() loading = false //重放一次请求 translate(val) } else { //正常写入结果 } } async function refreshToken() { //刷新token的时候只要把内存的和缓存的清掉即可,这样两处地方都没有值就会发请求拿 globalAccessToken = null await setStorage(ACCESS_TOKEN_STORAGE_KEY, globalAccessToken) } function getAccessToken() { return new Promise(async (resolve, reject) => { try { // 先从缓存拿token const accessToken = await getResultFromStorage([ACCESS_TOKEN_STORAGE_KEY]) if (!accessToken[ACCESS_TOKEN_STORAGE_KEY]) { //缓存拿不到就发请求拿,拿到后写入缓存和内存 const data = await request(accessTokenUrl) globalAccessToken = data.access_token await setStorage(ACCESS_TOKEN_STORAGE_KEY, globalAccessToken) } else { //写入内存 globalAccessToken = accessToken[ACCESS_TOKEN_STORAGE_KEY] } resolve(globalAccessToken) } catch (error) { reject(error) } }) }
以上就是这个popup
拓展的全部内容,希望能够帮助你理解manifest.json
、popup.js
,对你入门Chrome
拓展开发有所帮助。
右键拓展
有的同学可能会说,我还是觉得这个拓展比较鸡肋或者使用起来步骤还是不够简洁。在阅读英文文档的场景下,我希望直接右键选中某一段英文直接就翻译出来。这个需求是比较常见的,而Chrome
也为我们提供了右键菜单的拓展开发。具体的效果图如下: 链接
话不多说,我们马上开始,先简单看下目录结构:
menu background.js content-script.js manifest.json
这里的manifest.json
配置稍有不同,我们一起来看一下:
"permissions": [ "storage", "activeTab", "contextMenus" //右键菜单权限记得打开 ], "background": { "service_worker": "background.js" }, //域名权限记得打开,不然会被同源策略拦截 "host_permissions": [ "https://aip.baidubce.com/" ]
content-scripts
的配置已经在上面提过了,这里便不再赘述。我们先来思考一下,作为一个右键拓展,应该是每一个页面都有,所以它的点击回调逻辑应该是注册在background.js
里面,因为只有它的生命周期可以满足这个需求。再者,从上面的gif
看来,弹出的内容应该跟现在所处的浏览器tab
是有关系的,所以这个弹出逻辑应该写在content-scripts
中。进而来说,background.js
与content-scripts
没有什么直观的联系,所以这里就需要用到Chrome
提供的通信能力。那么到这里我们的思路已经十分清晰,主要分为以下几步去实现这个扩展即可:
注册菜单
话不多说,直接上代码。
//background.js const QUICK_TRANSLATE = 'quick-translate' chrome.contextMenus.create({ id: QUICK_TRANSLATE, //id来区分点击的对应菜单项 title: '快速翻译:%s', contexts: ['selection'], //当文字被选中时触发,%s是被选中的文字 }) //监听菜单项点击回调 chrome.contextMenus.onClicked.addListener(menuItemClickCallback) function menuItemClickCallback(info) { const { menuItemId } = info if (menuItemId === QUICK_TRANSLATE) { translateCallback(info) } } async function translateCallback({ selectionText }) { const res = await translate(selectionText) //之前实现的翻译函数 }
可以看到上面的代码十分简单,注册菜单、监听回调、发起翻译请求拿到结果,流程十分清晰。在拿到结果之后,如何把结果显示出来,最好的方法当然是把结果交给当前使用扩展的tab
来展示,这里就涉及到两个脚本之间的通信。
脚本通信
这里直接上代码就行,实现也十分简单。
//background.js async function translateCallback({ selectionText }) { const res = await translate(selectionText) sendMessageToContentScript({res}) } async function sendMessageToContentScript(message) { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) chrome.tabs.sendMessage(tab.id, message, function (response) { console.log(response); }); } //content-scripts.js chrome.runtime.onMessage.addListener( function (request, sender, sendResponse) { sendResponse('success') alert(request.res) } );
上面我只是将结果alert
了出来(因为太懒),当然你也可以做成更好的交互。毕竟content-scripts
跟当前的tab
页是共用dom
元素的,所以你可以尽情发挥。
实战小结
在上面,我们介绍了manifest.json
文件的配置,在使用某些API
或者开发某些功能之前记得先来这里注册对应的权限;同时我们介绍了两种插件的类型,一种是右上角弹出,一种是右键菜单,实际上还有别的类型,在这里就不再展开,感兴趣的同学请自行查阅文档;在开发这两种插件的过程中我们了解到了“三种js
”;background.js
是常驻后台的脚本,可以使用任何Chrome
扩展的API
,popup.js
就是弹出类型插件的脚本,它与background.js
十分类似,运用于弹出类型插件的逻辑开发,content-scripts
是扩展注入当前tab
页面的脚本,它可以访问的Chrome
扩展只有我们上面提到的chrome.runtime
、chrome.storage
还有chrome.18n
等,这些API
已经够用了,如果还有别的需求的话,可以通过脚本通信让background.js
来帮忙调用。
background.js
的调试需要你进入Chrome
的扩展页面,点击如下按钮打开其控制台
popup.js
的调试就是在popup页面直接右键打开开发者工具即可
content-scripts
的调试最简单,直接在当前的tab
页打开控制台调试就行
图床扩展
在了解上面的知识后,我们就要做一开始所说的事情了,就是开发一款自己的图床拓展。我们当然是用popup
类型的扩展来开发,上传图片的方式点击、拖拽、复制这三种我们都会盘一下,至于后台服务的话则需要一台云服务器或者其他的云存储服务,我没购买云存储服务,只能自己写一个简单的接口了,实际上使用云存储服务应该是会更方便一些。最后实现的效果大概是这样,话不多说接下来进入开发。
图床实现
我们开发的是一个popup
类型的拓展,配置文件啥的这里就不再说了。其实我们要开发的就是一个上传图片的功能,实现上并没有什么困难的地方。记得利用一些样式把input框隐藏起来,这也是比较公认的做法。要注意的是,一般来说,在popup.html
中是不支持写内嵌的javascript
代码的。
<!-- popup.html --> <div class="content"> 点击、拖拽、粘贴上传 </div> <input id="upload" type="file" />
//popup.js const url = 'yourhostname' const content = document.querySelector('.content') content.addEventListener('click', () => { uploadEL.click() }) const uploadEL = document.querySelector("#upload") uploadEL.addEventListener('change', async e => { const file = e.target.files[0] upload(file) }) function request(url, config) { return new Promise((resolve, reject) => { fetch(`${url}`, config).then(res => res.json()).then(data => { resolve(data) }).catch(err => { reject(err) }) }) } async function upload(file) { var formData = new FormData(); formData.append('file', file); const res = await request(`${url}/upload.php`, { method: 'POST', body: formData }) const { code, path } = res if (code === 200) { afterUpload(path) //将上传结果回填到页面中 } }
点击上传的代码也十分简单,没有什么特别值得讲的地方。你会看到我的截图上给出了几种不同结果的复制,这存粹是为了让我自己用起来更舒服而已,你也可以自行定制上传成功后的交互逻辑。
拖拽&复制
拖拽的实现主要依赖drop
事件,不过记得在drop
之前的drag
阶段中需要阻止默认事件,不然的话drop
事件是不会生效的。
content.addEventListener('dragover', e => { //这里一定要阻止默认事件,要不然drop是不会生效的 e.preventDefault() }) content.addEventListener('drop', e => { e.preventDefault() const file = e.dataTransfer.files[0] upload(file) })
复制的实现主要依赖paste
事件,粘贴的内容不一定是图片,所以我们有必要对内容进行一下过滤,实现起来也比较简单。
document.addEventListener('paste', async event => { if (event.clipboardData || event.originalEvent) { const clipboardData = (event.clipboardData || event.originalEvent.clipboardData); const { items } = clipboardData const { length } = items let blob = null for (let i = 0; i < length; i++) { if (items[i].type.indexOf("image") !== -1) { blob = items[i].getAsFile() } } upload(blob) } })
接口实现
至于上传图片的接口我是用PHP
写的,感兴趣的同学可以看一看,我会把注释写好。
//upload.php <?php $date = date('Y-m-d'); $file = $_FILES['file']; $file_name = $file['name']; //校验文件是否为空 if (empty($file_name)) { echo 400; die; } //允许上传的文件类型 $type = array('image/jpg', 'image/gif', 'image/png', 'image/bmp'); //获取文件后缀 $file_type = $file['type']; $ext = explode('/',$file_type)[1]; //上传路径 $upload_path = '/img/'; $res = []; if (in_array($file_type, $type)) { //do···while给文件生成一个随机名 do { $new_name = get_file_name(10) . '.' . $ext; $path = $upload_path . $new_name; } while (file_exists($path)); $temp_file = $_FILES['file']['tmp_name']; //把文件移动到上传路径 move_uploaded_file($temp_file, $path); $res['path'] = $new_name; $res['code'] = 200; } else { $res['code'] = 400; } //随机返回一个文件名 function get_file_name($len) { $new_file_name = ''; $chars = "1234567890qwertyuiopasdfghjklzxcvbnm"; for ($i = 0; $i < $len; $i++) { $new_file_name .= $chars[mt_rand(0, strlen($chars) - 1)]; } return $new_file_name; } //吐出结果 echo json_encode($res);
读图片的时候我用的也是接口而不是直接静态资源指向,最好不要直接暴露你的静态资源。
<?php //获取参数 $name = $_GET['name']; if (empty($name)) { echo '404'; die; } //过滤不安全的字符 $name = addslashes($name); $name = str_replace('/','',$name); //拼接路径 $path = '/img/' . $name; //判断文件是否存在 if (!file_exists($path)) { echo '404'; die; } $ext = explode('.',$name)[1]; //设置强缓存 header('Cache-Control: public'); header('Pragma: cache'); $offset = 30 * 60 * 60 * 24;//1个月 $exp_str = 'Expires: ' . gmdate('D, d M Y H:i:s', time() + $offset) . ' GMT'; header($exp_str); header('Content-type: image/'.$ext); //吐出文件内容 echo file_get_contents($path);
最后
以上就是本文分享的关于Chrome
扩展开发的所有内容,现在你已经了解了一些基本概念以及如何调试,发挥你的主观能动性,开发一些扩展小工具来让你的生活更加方便吧~