走进Chrome拓展开发,定制自己的图床扩展

前言

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的形式(点击右上角弹出扩展)来说明拓展的开发,这也是我们最常用的拓展方式。

Hello World

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的能力,来实现一个翻译插件。

translate

页面UI非常的简单(简陋),输入你要翻译的英文,点击GO帮你翻译成中文,点击复制把翻译结果复制到粘贴板中。

趁着开发之前,我们先来讲一下扩展的目录结构,一个popup拓展的目录结构大概是下面这个样子的

project
    -css
    -js
    manifest.json
    popup.html
    popup.js
复制代码

在这个扩展的开发过程中,我们主要关注popup.htmlpopup.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一毛一样。

gg

复制

在完成了最基本的翻译功能对接之后,接下来就是如何更方便地使用翻译后的结果。一键点击复制是令人幸福指数增加的操作,所以接下来要做的事情就是点击按钮,将翻译结果复制到粘贴板中。浏览器提供了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

token

由图可以看到它的过期时间是30天,也就是说绝大多数的令牌请求都是可以去掉的,那我们就一定要想办法把它存起来了。如果是在常规的前端开发中我们很容易就想到把它存在localStorage或者indexDB等地方。

在扩展开发的场景下,Chrome也提供了本地存储的APIchrome.storage。它与localStorage具体区别有以下几点:

  • 数据可以与Chrome同步
  • 即使使用隐身模式也可以正常存储
  • 用户数据可以存储为对象
  • 无需后台页面,内容脚本也可以直接使用
  • 提供批量的异步读写API,性能更好

值得一提的是,要使用storage功能,别忘了在manifest.json加上权限配置。

{
    "permissions":[
        "storage"
    ]
}
复制代码

在了解了API提供的存储能力后,就可以改造一下我们的代码,把access_token存起来了,对我们的代码也会做一些如下改造,思路参见如下流程图。

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.jsonpopup.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.jscontent-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扩展的APIpopup.js就是弹出类型插件的脚本,它与background.js十分类似,运用于弹出类型插件的逻辑开发,content-scripts是扩展注入当前tab页面的脚本,它可以访问的Chrome扩展只有我们上面提到的chrome.runtimechrome.storage还有chrome.18n等,这些API已经够用了,如果还有别的需求的话,可以通过脚本通信让background.js来帮忙调用。

background.js的调试需要你进入Chrome的扩展页面,点击如下按钮打开其控制台

popup.js的调试就是在popup页面直接右键打开开发者工具即可

content-scripts的调试最简单,直接在当前的tab页打开控制台调试就行

图床扩展

在了解上面的知识后,我们就要做一开始所说的事情了,就是开发一款自己的图床拓展。我们当然是用popup类型的扩展来开发,上传图片的方式点击、拖拽、复制这三种我们都会盘一下,至于后台服务的话则需要一台云服务器或者其他的云存储服务,我没购买云存储服务,只能自己写一个简单的接口了,实际上使用云存储服务应该是会更方便一些。最后实现的效果大概是这样,话不多说接下来进入开发。

ff

图床实现

我们开发的是一个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扩展开发的所有内容,现在你已经了解了一些基本概念以及如何调试,发挥你的主观能动性,开发一些扩展小工具来让你的生活更加方便吧~

Guess you like

Origin juejin.im/post/7077069434803716110