总结一篇 TypeScript 浏览器插件开发经验

推荐模块

  1. webextension-polyfill-ts

    浏览器插件API的TS包,开发插件必备

    import { browser } from 'webextension-polyfill-ts'
    

    避坑:封装网络请求用browser.runtime.onMessage.addListener不能直接返回axios,虽然TS不会报错,但运行结果会是undefined

    正确方法:

    browser.runtime.onMessage.addListener((request) => {
      const params =
        request.type === 'GET' ? { params: request.params } : { data: request.data }
    
      return new Promise((resolve, reject) => {
        axios({
          method: request.type,
          url: request.url,
          ...params,
          headers: request.headers || {}
        }).then((response) => {
          resolve(response.data)
        })
      })
    })
    
  2. write-json-webpack-plugin

    操作json文件的插件,用于修改manifest.json

    // webpack.config.js
    const WriteJsonWebpackPlugin = require('write-json-webpack-plugin')
    // ...
    module.exports = () => {
        let manifestJSON = require('./src/manifest.json')
        // 版本号
        manifestJSON.version = '2.0.0'
    
        const config =
            // ...
            plugins: 
                manifestJSON &&
                new WriteJsonWebpackPlugin({
                    pretty: false,
                    object: manifestJSON,
                    path: '/',
                    filename: 'manifest.json'
                })
            ]
            // ...
        }
        return config
    }
    
  3. webpack-extension-reloader

    自动重新加载插件,自动刷新插件作用的网页,测试神器

    const ExtensionReloader = require('webpack-extension-reloader')
    // 也是 webpack plugins 以下就简单一写了
    const config = {
        entry: {
          content: './src/content.ts',
          background:'./src/background/background.ts',
          popup: './src/popup/popup.ts',
          options: './src/options/options.ts'
        },
        // ...
        plugins: [
            new ExtensionReloader({
                reloadPage: true,
                // entry
                entries: {
                    contentScript: 'content',
                    background: 'background',
                    extensionPage: ['popup', 'options']
                }
            })
        ]
    }
    

Btools

最后宣传一下自己写的插件,Btools,以B站为主的网站页面优化,增强用户体验

现阶段正进行重构,使用TypeScript+Vue,欢迎参考:GithubGitee

对流程的封装

Btools 分为两大模块:Linstener 模块Watcher 模块

  • Linstener 模块

    用于监听来自background-js的反馈,background-js会监听网页中指定的请求

    比如我需要获取页面上的评论,以前的做法是傻傻的用计时器循环获取页面元素,现在先通过background-js监听,如果监听到获取评论的API请求会发消息给content-js,接到消息后调用相应的模块

    这样做的好处是虽然也会用到计时器循环获取页面元素,但至少知道页面元素马上会加载好,一定程度上避免不必要的消耗

  • Watcher 模块

    根据不同的网址进行不同的操作,举个例子:

    import { WatcherBase, HandleOptions } from '@/Watcher/WatcherBase'
    export class GetPicWatcher extends WatcherBase {
        // 初始化函数中设置网址
        protected init(): void {
            this.urls[GetPicEnum.Video] = /bilibili\.com\/video/
            this.urls[GetPicEnum.Bangumi] = /bilibili\.com\/bangumi/
            this.urls[GetPicEnum.Watchlater] = /bilibili\.com\/medialist\/play\/watchlater/
            this.urls[GetPicEnum.Read] = /bilibili\.com\/read/
            this.urls[GetPicEnum.LiveRoom] = /live\.bilibili\.com/
        }
        // 处理函数
        protected handle(options: HandleOptions): void {
            // 父级调用时会把 GetPicEnum 的值传回来,这里就可以区分是哪个网址
            switch (options.index) {
                case GetPicEnum.Video:
                    this.video()
                    break
                case GetPicEnum.Bangumi:
                    this.bangumi()
                    break
                case GetPicEnum.Watchlater:
                    this.watchlater()
                    break
    
                case GetPicEnum.Read:
                    this.read()
                    break
                case GetPicEnum.LiveRoom:
                    this.liveRoom()
                    break
            }
        }
        
        video() {
            // ...
        }
        
        bangumi() {
            // ...
        }
        
        watchlater() {
            // ...
        }
        
        read() {
            // ...
        }
        
        liveRoom() {
            // ...
        }
    }
    
    // 用于区分网址
    enum GetPicEnum {
      /**
       * 视频页面
       */
      Video,
    
      /**
       * 番剧、电影
       */
      Bangumi,
    
      /**
       * 稍后再看
       */
      Watchlater,
    
      /**
       * 专栏
       */
      Read,
    
      /**
       * 直播间
       */
      LiveRoom
    }
    

对本地存储的封装

Btools 有着非常完善的本地存储封装,不同模块之间不会互相干扰

首先有一个模板基类

export default class TemplateBase {
  private _name: string

  protected _data: Object

  public constructor(data: Object) {
    this._data = data
    this._name = (this as any).__proto__.constructor.name
  }
  public GetName(): string {
    return this._name
  }

  public GetData(): Object {
    return this._data
  }

  public SetData(data: Object): void {
    this._data = data
  }
}

这里的_name非常关键,其他模板类继承后被实例化事,这个_name就会变成子类类名,这就是不同模块之间不会互相干扰的关键

然后看一下某一个子类

/**
 * 找回失效视频存储模板
 */

import TemplateBase from '@base/storage/template/TemplateBase'

export interface IVideoInfo extends Object {
  /**
   * 视频标题
   */

  title: string

  /**
   * 视频图片链接
   */
  pic: string
}

export interface IRetrieveInvalidVideo extends Object {
  videoInfo: { [key: string]: IVideoInfo }
}

// 这里继承了基类
export class TRetrieveInvalidVideo extends TemplateBase {
  constructor(data: IRetrieveInvalidVideo) {
    super(data)
  }
}

在构造函数中会把一个类型为IRetrieveInvalidVideo的参数传进去

然后是对本地存储的读写封装

/**
 * 扩展本地存储
 */

import _ from 'lodash'

import { browser } from 'webextension-polyfill-ts'

import TemplateBase from '@/scripts/base/storage/template/TemplateBase'
import Singleton from '@/scripts/base/singletonBase/Singleton'

export default class ExtStorage extends Singleton {
    // ...
    getStorage<T extends TemplateBase, TResult>(
      configs: T
    ): Promise<TResult> {
        return new Promise((resolve) => {
            const space = new Object()
            space[configs.GetName()] = configs.GetData()

            browser.storage.local.get(space).then((items) => {
                resolve({
                    ...configs.GetData(),
                    ...items[configs.GetName()]
                })
            })
        })
    }

    setStorage<T extends TemplateBase, TResult>(
      configs: T
    ): Promise<TResult> {
        return new Promise((resolve) => {
            const space = {}
            space[configs.GetName()] = configs.GetData()

            browser.storage.local.set(space).then(() => {
                resolve(configs.GetData() as TResult)
            })
        })
    }
}

传进来的config类型是TemplateBase,这个类有个方法是GetName,也就是获取类名

存取时space[configs.GetName()] = configs.GetData()在外面包一层类名

这样即使是有重名,也是OK的

使用时是这样的:

import {
  TRetrieveInvalidVideo,
  IRetrieveInvalidVideo,
  IVideoInfo
} from '@/scripts/base/storage/template'

export class RetrieveInvalidVideo extends ModuleBase {
    protected handle() {
        // 获取本地数据
        const localData = ExtStorage.Instance().getStorage<
              TRetrieveInvalidVideo,
              IRetrieveInvalidVideo
        >(
            new TRetrieveInvalidVideo({
                videoInfo: {}
            })
        )
    }
}

避坑:谷歌插件中,获取时传入一个默认值,如果没有读取到则会返回默认值。但火狐不会自动返回默认值,所以需要自己处理

也就是扩展本地存储代码中的2324行,先解构传过来的configs,再解构读取到的items

比如我configs传入的{ a: [], b: [] },但本地存储里只有{ a: [1,2,3] }

那么先解构configs获得{ a: [], b: [] },再解构items会把a替换掉,获得{ a: [1,2,3], b: [] }

相当于自己封装了返回默认值

收工

OK,以上就是一些小总结,告辞