快速上手油猴插件开发(实战篇)

8,299 阅读6分钟

通过这篇文章你能够学习到:

  1. 快速使用油猴插件进行浏览器插件开发
  2. 用wokoo脚手架开发2个有意思的插件: 划词搜索MoveSearch 和 知乎目录zhihu-helper
  3. 有精力的话可以继续学习wokoo脚手架的搭建

为什么要学习浏览器插件开发

浏览器插件开发有的同学可能接触的不多,或者有疑问,学习开发浏览器插件有什么用呢?这里我列举一些工作中的场景:

场景:

当测试的同学发现问题时,需要截图,打开测试平台,填写具体的问题,提交测试报告。如果能有插件在当前页面中弹出要填写的测试表单,是不是就减少了测试同学的工作流程?

学会了浏览器插件开发后,可以做一些工作场景相关的插件,来帮助自己以及同事提高效率。这不管是述职晋升还是跳槽换工作都会是一个加分的亮点。

正式开始插件开发

一、划词搜索MoveSearch

成品展示

安装地址:

  1. 安装油猴插件
  2. 安装MoveSearch

代码地址:wokoo/example/MoveSearch

开发步骤

1.1 项目安装 & 初始化配置

npm i wokoo -g
wokoo MoveSearch

选择模板

  • vue
  • react

这里选择react

安装完成后,出现安装成功的界面

根据提示执行下命令

cd MoveSearch
npm start
  • 打开油猴脚本编辑器,把tampermonkey.js的内容复制进去。(注意:这里是指把tampermonkey.js里的所有内容都复制进去,包括注释。因为此文件中被注释掉的//@xxx 都有含义,可以对应着 tampermonkey开发文档 理解。比如@match https://代表在https页面下才会启动插件)

  • 打开网页开发者搜索,右上角出现猴子的logo,说明环境跑通

1.2 实现基本功能

先整理一下思路,要实现划词搜索功能,主要实现以下步骤:

  1. 监听mouseup事件,触发时检查是否有文字被选中,如果有则弹窗展示
  2. 弹窗展示前发请求到baidu的开发者搜索的接口,获取搜索内容
  3. 如果搜索内容不为空,将内容展示到弹窗中
  4. 边界检测,点击弹窗区域外的内容,弹窗关闭

此处有个难点,就是baidu的开发者搜索接口不允许跨域,比如在掘金的网页中去请求开发者搜索的接口会被拦住。为解决这个问题,我使用vercel提供的serverless服务做了一次代理,在代理中给响应头增加了Access-Control-Allow-Origin等字段,开启跨域。具体方法在1.3给出,此时我们先在开发者搜索页面里面进行开发,先保证是同域下。

步骤1. 监听mouseup事件,触发时检查是否有文字被选中

编辑/src/app.js文件,增加对mouseup的监听:

import React from 'react'
import axios from 'axios'
import './app.less'

export default class extends React.Component {
  constructor(props) {
    super(props)
    this.state = { show: false }
  }
  componentDidMount() {
    document.addEventListener('mouseup', (e) => {
      var selectionObj = window.getSelection()
      var selectedText = selectionObj.toString()
      console.log('selectedText', selectedText)
    })
  }

  render() {
    let { show } = this.state
    return <>{show ? <div className="Wokoo"></div> : null}</>
  }
}

步骤2. 弹窗展示前发请求到开发者搜索的接口,获取搜索内容

  • 安装axios,引入axios
  • 对componentDidMount内的代码进行改造,请求开发者搜索 获取搜索结果
componentDidMount() {
    document.addEventListener('mouseup', (e) => {
      var selectionObj = window.getSelection()
      var selectedText = selectionObj.toString()
      console.log('selectionObj::', selectedText)
      if (selectedText.length === 0) {
      } else {
        axios
          .get(
            `https://kaifa.baidu.com/rest/v1/search?query=${selectedText}&pageNum=1&pageSize=10`
          )
          .then((res) => {
            let { data } = res.data.data.documents
            console.log(data)
            if (data.length) {
              this.setState({
                data: data,
                show: true,
              })
            }
          })
      }
    })
  }
  • 修改render,展示搜索结果
render() {
    let { show, data } = this.state
    return (
      <>
        {show ? (
          <div className="Wokoo">
            <ul>
              {data.map((i) => (
                <li>{i.title}</li>
              ))}
            </ul>
          </div>
        ) : null}
      </>
    )
  }

此时,网页中能够看到搜索的结果了,基本功能搞定了。

步骤3. 计算弹窗出现的位置,让弹窗出现在选中文字下方

我们可以得出计算公式为:

left = x + width/2 - MODAL_WIDTH/2

top = y + height

此时,app.js的代码被改造成这样:

import React from 'react'
import axios from 'axios'
import './app.less'
const MODAL_WIDTH = 350
export default class extends React.Component {
  constructor(props) {
    super(props)
    this.state = { show: false, data: [] }
  }
  componentDidMount() {
    document.addEventListener('mouseup', (e) => {
      var selectionObj = window.getSelection()
      var selectedText = selectionObj.toString()
      if (selectedText.length === 0) {
      } else {
        var selectionObjRect = selectionObj
          .getRangeAt(0)
          .getBoundingClientRect()
        let { x, y, height, width } = selectionObjRect // 获取选中文字的位置,x y是横纵坐标,height width是选中文字的高度和宽度
        // 计算弹窗位置,算出left和top
        var left = x - MODAL_WIDTH / 2 + width / 2
        left = left > 10 ? left : 10
        var top = y + height
        var scrollLeft =
          document.documentElement.scrollLeft || document.body.scrollLeft
        var scrollTop =
          document.documentElement.scrollTop || document.body.scrollTop
        axios
          .get(
            `https://kaifa.baidu.com/rest/v1/search?query=${selectedText}&pageNum=1&pageSize=10`
          )
          .then((res) => {
            let { data } = res.data.data.documents
            if (data.length) {
              this.setState({
                data,
                show: true,
                selectedText: selectedText,
                modalPosition: {
                  left: left + scrollLeft,
                  top: top + scrollTop,
                },
              })
            }
          })
      }
    })
  }

  render() {
    let { show, selectedText, modalPosition, data } = this.state
    return (
      <>
        {show && data && data.length ? (
          <div
            className="move-search"
            id="MoveSearchApp"
            style={{
              ...modalPosition,
            }}
          >
            <div className="move-search-content">
              <ul className="move-search-ul">
                {data.map((l) => (
                  <li className="move-search-li" key={l.id}>
                    <a href={l.url} target="_blank">
                      {l.title}
                    </a>
                    <span>{l.summary}</span>
                  </li>
                ))}
              </ul>
            </div>
            <div className="move-search-bottom-fade"></div>
            <footer className="move-search-footer">
              <a
                href={`https://kaifa.baidu.com/searchPage?wd=${selectedText}`}
                target="_blank"
              >
                Read More
              </a>
            </footer>
          </div>
        ) : null}
      </>
    )
  }
}

别忘了增加样式,修改app.less

.move-search {
  position: absolute;
  text-align: center;
  width: 350px;
  max-height: 300px;
  top: 0;
  right: 0;
  border-radius: 5px;
  z-index: 2147483647;
  box-shadow: rgba(0, 0, 0, 0.2) 0px 16px 100px 0px;
  transition: all 0.1s ease-out 0s;
  border: 1px solide #282a33;
  background: #fff;
  font-size: 12px;
  overflow: scroll;
  text-align: left;
}

步骤4. 边界检测,点击弹窗外部,弹窗隐藏

边界检测的判断条件如下:

left+width > x > left && top+height > y > top

function boundaryDetection(x, y, modalPosition = { left: 0, top: 0 }) {
  let { left, top } = modalPosition
  if (
    x > left &&
    x < left + MODAL_WIDTH &&
    y > top &&
    y < top + MoveSearchApp.offsetHeight
  ) {
    return true
  }
  return false
}

修改app.js增加弹窗消失的逻辑,在selectedText为空时判断鼠标位置是否在弹窗内。

import React from 'react'
import axios from 'axios'
import './app.less'
// 弹窗宽度
const MODAL_WIDTH = 350
/**
 * 边界检测,鼠标点击modal之外,modal隐藏
 * @param {*} x 鼠标的x轴位置
 * @param {*} y 鼠标的y轴位置
 * @param {*} modalPosition 弹窗的left和top
 */
function boundaryDetection(x, y, modalPosition = { left: 0, top: 0 }) {
  let { left, top } = modalPosition
  if (
    x > left &&
    x < left + MODAL_WIDTH &&
    y > top &&
    y < top + MoveSearchApp.offsetHeight
  ) {
    return true
  }
  return false
}
export default class extends React.Component {
  constructor(props) {
    super(props)
    this.state = { show: false, data: [] }
  }
  componentDidMount() {
    document.addEventListener('mouseup', (e) => {
      var selectionObj = window.getSelection()
      var selectedText = selectionObj.toString()
      if (selectedText.length === 0) {
        if (this.state.show) {
          // 重新计算是否关闭弹窗
          // 检测鼠标位置是否在弹窗内,不是则关闭弹窗
          var inModal = boundaryDetection(
            e.clientX,
            e.clientY,
            this.state.modalPosition
          )
          if (!inModal) {
            this.setState({
              show: false,
              data: [],
            })
          }
        }
      } else {
        var selectionObjRect = selectionObj
          .getRangeAt(0)
          .getBoundingClientRect()
        let { x, y, height, width } = selectionObjRect // 获取选中文字的位置,x y是横纵坐标,height width是选中文字的高度和宽度
        // 计算弹窗位置,算出left和top
        var left = x - MODAL_WIDTH / 2 + width / 2
        left = left > 10 ? left : 10
        var top = y + height
        var scrollLeft =
          document.documentElement.scrollLeft || document.body.scrollLeft
        var scrollTop =
          document.documentElement.scrollTop || document.body.scrollTop
        axios
          .get(
            `https://kaifa.baidu.com/rest/v1/search?query=${selectedText}&pageNum=1&pageSize=10`
          )
          .then((res) => {
            let { data } = res.data.data.documents
            if (data.length) {
              this.setState({
                data,
                show: true,
                selectedText: selectedText,
                modalPosition: {
                  left: left + scrollLeft,
                  top: top + scrollTop,
                },
              })
            }
          })
      }
    })
  }

  render() {
    let { show, selectedText, modalPosition, data } = this.state
    return (
      <>
        {show && data && data.length ? (
          <div
            className="move-search"
            id="MoveSearchApp"
            style={{
              ...modalPosition,
            }}
          >
            <div className="move-search-content">
              <ul className="move-search-ul">
                {data.map((l) => (
                  <li className="move-search-li" key={l.id}>
                    <a href={l.url} target="_blank">
                      {l.title}
                    </a>
                    <span>{l.summary}</span>
                  </li>
                ))}
              </ul>
            </div>
            <div className="move-search-bottom-fade"></div>
            <footer className="move-search-footer">
              <a
                href={`https://kaifa.baidu.com/searchPage?wd=${selectedText}`}
                target="_blank"
              >
                Read More
              </a>
            </footer>
          </div>
        ) : null}
      </>
    )
  }
}

此时,项目的基本功能已经开发完成🎉,一些样式问题可以自行调整。

具体的代码逻辑可看github地址:MoveSearch

1.3 解决跨域问题

百度的接口不允许跨域访问。也就是说如果我们在一个第三方页面,比如cdn,掘金等的网页里,要访问百度的接口就存在跨域限制,没法访问。我采用增加node中间层,在node中给响应增加Access-Control-Allow-Origin请求头的方式开启跨域。

目前有现成的提供serverless服务的第三方厂家,提供服务器,node环境等服务,我们只要关心node的代码逻辑即可,部署服务器配置环境等问题交给第三方厂家解决。我采用vercel提供的服务。

下面的内容和插件开发关系不大,如果不感兴趣的同学可以直接使用我配置好的域名:https://movesearch.vercel.app/api/baidu。 也就是将代码中axios请求的url由

`https://kaifa.baidu.com/rest/v1/search?query=${selectedText}&pageNum=1&pageSize=10`

替换成

`https://movesearch.vercel.app/api/baidu?query=${selectedText}&pageNum=1&pageSize=10`

如果对于如何实现跨域配置感兴趣的话,可以继续往下看👇。

  1. 登录vercel官网,根据提示绑定github账号;
  2. 点击新建next项目。vercel会自动给你的github上创建新的仓库,并有一个初始化项目;(注意:此处会让你填写仓库名,此仓库名和域名是相关联的。)
  3. git clone 此项目到本地,根据readme的提示,执行npm run dev,启动项目
  4. 增加src/api/baidu.js文件,内容如下:

稍微解释一下,src/api/baidu.js下的文件对应的就是/api/baidu接口

const { createProxyMiddleware } = require('http-proxy-middleware')

// restream parsed body before proxying
var restream = function (proxyRes, req, res, options) {
  proxyRes.headers['Access-Control-Allow-Origin'] = '*'
  proxyRes.headers['Access-Control-Allow-Headers'] = '*'
  proxyRes.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, OPTIONS'
}

const apiProxy = createProxyMiddleware({
  target: 'https://kaifa.baidu.com/',
  changeOrigin: true,
  pathRewrite: { '^/api/baidu': '/rest/v1/search' },
  secure: false,
  onProxyRes: restream,
})

module.exports = function (req, res) {
  apiProxy(req, res, (result) => {
    console.log('result:', result)
    if (result instanceof Error) {
      throw result
    }
    throw new Error(
      `Request '${req.url}' is not proxied! We should never reach here!`
    )
  })
}

修改完成后直接git push就行。代码推仓库后会,vercel会自动拉取最新代码更新到它的服务器,我们只要调用一下接口看是否通就行。 具体的代码逻辑可看github地址:nextjs

二、知乎专栏目录zhihu-helper

成品展示

安装地址:

  1. 安装油猴插件 (如果安装过则不用再安)
  2. 安装 zhihu-helper

代码地址:wokoo/zhihu-helper

开发步骤

1.1 项目安装 & 初始化配置

npm i wokoo -g
wokoo zhihu-help

选择模板

  • vue
  • react

这里选择react,等待项目安装。项目安装完成后,根据提示执行下命令:

cd zhihu-helper
npm start
  • 打开油猴脚本编辑器,把tampermonkey.js的内容复制进去。

    tampermonkey

    copy-tampermonkey

  • 打开网页知乎专栏,右上角出现一只猴子图标,说明项目已跑通。

补充说明

此处有的浏览器不会出现猴子图标。打开控制台可见报错:

再看请求的html资源的响应头,可以发现多了一条content-security-policy规则。也就是说知乎使用了csp内容安全策略,通过content-security-policy中的script-src字段可知,知乎只允许加载指定域名的js。具体情况可阅读 👉 内容安全策略( CSP )

如何绕过此安全策略,此处给两个方法:

  1. 安装插件Disable Content-Security-Policy, 在调试知乎页面时开启插件,自动把html页面的content-security-policy给设置为空。 注意,开启页面后,要点一下插件,按钮变成彩色了才算开启成功

  1. 会配置charles的同学,可以设置一条转发规则

这两种方法选一种就行。

遇到这中csp内容安全策略的网页,在上线到油猴商店的时候不能用托管cdn的方式,要将代码复制到编辑框中。

1.2 开发基本功能

整理思路

  1. 绘制左侧抽屉弹窗
  2. 弹窗弹出时请求知乎列表接口,拿到列表数据
  3. 下拉时实现加载更多的功能

下面我们来逐步实现吧~

步骤1. 绘制左侧抽屉弹窗

主要通过this.state.show控制弹窗的显示隐藏。此步骤挺简单的,可以直接看代码:step1,可以将index.js入口里的app替换成step1查看效果。

步骤2. 请求知乎列表接口,获取列表数据

  • 安装axios,并引入
npm install axios
  • 计算请求参数

分析请求的url可知,请求接口为:

`https://www.zhihu.com/api/v4/columns${this.queryName}/items?limit=20&offset=${offset}`

其中this.queryName是专栏名称,当页面为专栏列表页时就是pathname; 当页面为专栏详情页时,需要通过类名为ColumnPageHeader-TitleColumn的a标签的href来获取。

zhihu

zhihu1

通过这两张图应该更好理解getQueryName方法

getQueryName = () => {
    let pathname = location.pathname
    let detailRegExp = /^\/p\/\d+/
    let queryName = ''
    // 专栏详情页
    if (detailRegExp.test(pathname)) {
      let aTage = document.getElementsByClassName(
        'ColumnPageHeader-TitleColumn'
      )
      let url = aTage[0].href
      queryName = url.slice(url.lastIndexOf('/'))
    } else {
      // 专栏列表页
      // http://zhuanlan.zhihu和http://zhihu/column两种情况都是专栏
      if (pathname.indexOf('/column') === 0) {
        pathname = pathname.slice('/column'.length)
      }
      queryName = pathname
    }
    this.queryName = queryName
  }

而列表页又存在两种域名:www.zhihu.com/column/mand…zhuanlan.zhihu.com/mandy 所以在else逻辑里针对https://www.zhihu.com/column/mandy 做了处理,只保留/mandy

通过getQueryName方法,我们获取到了请求参数

  • 发送请求,拉取目录列表
getList = async () => {
    if (!this.state.hasMore) return
    let { offset } = this.state
    let { data } = await axios.get(
      `https://www.zhihu.com/api/v4/columns${this.queryName}/items?limit=20&offset=${offset}`
    )
    let list = data.data.map((i) => ({
      title: i.title,
      url: i.url,
      id: i.id,
      commentCount: i.comment_count,
      voteupCount: i.voteup_count,
    }))
    if (data.paging.is_end) {
      this.setState({ hasMore: false })
    }
    offset += limit

    this.setState({
      list: [...this.state.list, ...list],
      offset,
    })
  }

第二步的过程实现完了,代码在这里👉step2 ,可以将index.js入口里的app替换成step2查看效果。

这时插件的效果是这样的,除了没有下拉加载功能,其他基本完工了。

image-20210206004837608

步骤3. 下拉时实现加载更多的功能

无限滚动的组件引了第三方库react-infinite-scroll-component

npm install react-infinite-scroll-component

主要是在render函数里增加InfiniteScroll组件,注意InfiniteScroll的height需要通过计算给一个固定的值,否则无法触发滚动。

<ul className="list-ul" onMouseLeave={this.handleMouseLeave}>
  <InfiniteScroll
		dataLength={list.length}
		next={this.handleInfiniteOnLoad}
		hasMore={hasMore}
		loader={<h4>Loading...</h4>}
    height={document.documentElement.clientHeight - 53}
		endMessage={
  		<p style={{ textAlign: 'center' }}>
    		<b>到底了,没内容啦~</b>
			</p>
		}
  >
    {list.map((i) => (
      <li className="list-li" key={i.id}>
      ...
      </li>
))}
  </InfiniteScroll>
</ul>

此时功能已经开发完成,具体代码查看 👉app.js

部署插件到油猴商店

3.1 构建

执行命令

npm run build

3.2 确认油猴脚本文件tampermonkey.js

此文件中被注释掉的//@xxx 都有含义,可以对应着 tampermonkey开发文档 理解。

  • @description 插件描述

  • @match 指定某些域名下开启此插件,默认配了两条,// @match https://*/*// @match https://*/*表示在所有域名下都开启。但是此处希望只在zhihu专栏里使用此插件,所以要修改@math字段。

    // @match        https://zhuanlan.zhihu.com/*
    // @match        https://www.zhihu.com/column/*
    
  • @require 油猴脚本内部帮忙引入第三方资源,比如jquery,react等。

    // @require https://unpkg.com/react@17/umd/react.production.min.js
    // @require https://unpkg.com/react-dom@17/umd/react-dom.production.min.js
    

3.3 发布插件到油猴市场

发布油猴市场的优点是不用审核,即发即用,非常方便。

  1. 将/dist/app.bundle.js 文件部署到 cdn 上,获取到对应 url。

注意:

  • js文件可放到 github 上,如果托管到 github 上最好做 cdn 加速(我使用cdn.jsdelivr.net进行cdn加速)。

  • 如果没有cdn服务器可跳过此步骤,在步骤4直接将app.bundle.js复制到油猴脚本编辑器中

  1. 登录油猴市场,谷歌账号或 github 账号都可使用。

  2. 点击账号名称,再点击「发布你编写的脚本」

    wokoo-tamp3

    wokoo-tamp4

  3. 进入编辑页,将 tampermonkey.js 里的内容复制到编辑框中

    注意:

    • 步骤1中如果托管了cdn,需要将代码中的localhost:8080网址替换成静态资源 url

    • 步骤1中没有托管cdn,不能直接将/dist/app.bundle.js文件里的内容复制编辑框。因为编辑框内代码有最大限制,我们构建的app.bundle.js把react等三方库构建进去超过最大限制了。

      需要对构建结果进行拆包

      4.1 修改 tampermonkey.js ,通过@require方式引入react和react-dom

      // ==UserScript==
      // @name         zhihu-helper
      // @namespace    http://tampermonkey.net/
      // @version      0.0.1
      // @description  知乎目录
      // @author       xx
      // @match        https://zhuanlan.zhihu.com/*
      // @match        https://www.zhihu.com/column/*
      // @require https://unpkg.com/react@17/umd/react.production.min.js
      // @require https://unpkg.com/react-dom@17/umd/react-dom.production.min.js
      
      // ==/UserScript==
      
      // app.bundle.js构建好的代码
      

      4.2 修改webpack.config.base.js的entry字段

      entry: {
          app: '/src/index.js',
          vendor: [
            // 将react和react-dom这些单独打包出来,减小打包文件体积
            'react',
            'react-dom',
          ],
        },
      

      4.3 重新执行npm run build 构建出新的app.bundle.js,复制到油猴市场的编辑框内。

      zhihu-tampermonkey

  4. 点击 「发布脚本」即可

wokoo脚手架的搭建

感兴趣的同学可以阅读 wokoo脚手架(搭建篇)