【Chrome插件开发】ReRes和request-interceptor源码赏析+复现+插件开发完整解决方案

夏和雅
2023-12-01

引言

这个项目主要目的是用前端工程化技术栈复现ReResrequest-interceptor,希望将两者的功能结合起来。request-interceptor是前端开发调试常用工具,提供了多种修改请求的功能,但无法将请求映射到本地的文件。ReRes是JS逆向工程师常用工具,可以用来更改页面请求响应的内容。可以把请求映射到其他的url,也可以映射到本机的文件或者目录。因为manifest version 3无法实现这两个插件的功能,所以这个项目仍然使用manifest version 2。本文假设你了解:

  • Chrome插件开发的manifest.json常见字段,尤其是browser_actionpopup页面)、options_pageoptions页面,扩展程序选项)和backgroundbackground.js)。

修改请求的代码都是在background.js中实现的。background.js实际上也在一个独立的页面运行。在chrome://extensions/点击插件的“背景页”链接即可对background.js进行调试。

亮点

  1. 赏析了若干源码:ReResrequest-interceptorhusky……
  2. 探讨了jest配置的若干问题。如:使用“鸭子类型”技巧解决模块不可测试的问题、配置路径别名……
  3. 编写构建脚本scripts/build.ts使得构建过程更为灵活。
  4. 使用react + vite展示了一套完整的Chrome插件开发的解决方案。包括:开发时预览、单元测试、构建。
  5. useLocalStorageStatehook源码进行了少量修改,并增加了配套的单元测试用例,以适应Chrome插件开发的需求。

本文52pojie:https://www.52pojie.cn/thread-1757481-1-1.html

本文CSDN:https://blog.csdn.net/hans774882968/article/details/129483966

本文juejin:https://juejin.cn/post/7209625823581601848

作者:hans774882968以及hans774882968以及hans774882968

后续还会更新:仿request-interceptor规则组、批量导入规则、react + vite项目引入OB混淆……

Chrome插件ReRes源码赏析

popup页面和options页面和background.js唯一的联系就是,其他页面需要将数据写入背景页的localStorage

    var bg = chrome.extension.getBackgroundPage();

    //保存规则数据到localStorage
    function saveData() {
        $scope.rules = groupBy($scope.maps, 'group');
        bg.localStorage.ReResMap = angular.toJson($scope.maps);
    }

background.js注释版源码如下:

var ReResMap = [];
var typeMap = {
    "txt"   : "text/plain",
    "html"  : "text/html",
    "css"   : "text/css",
    "js"    : "text/javascript",
    "json"  : "text/json",
    "xml"   : "text/xml",
    "jpg"   : "image/jpeg",
    "gif"   : "image/gif",
    "png"   : "image/png",
    "webp"  : "image/webp"
}
// 从背景页的localStorage读取ReResMap
function getLocalStorage() {
    ReResMap = window.localStorage.ReResMap ? JSON.parse(window.localStorage.ReResMap) : ReResMap;
}

// xhr请求本地文件的url,进行文本拼接,转为data url
function getLocalFileUrl(url) {
    var arr = url.split('.');
    var type = arr[arr.length-1];
    var xhr = new XMLHttpRequest();
    xhr.open('get', url, false);
    xhr.send(null);
    var content = xhr.responseText || xhr.responseXML;
    if (!content) {
        return false;
    }
    content = encodeURIComponent(
        type === 'js' ?
        content.replace(/[\u0080-\uffff]/g, function($0) {
            var str = $0.charCodeAt(0).toString(16);
            return "\\u" + '00000'.substr(0, 4 - str.length) + str;
        }) : content
    );
    return ("data:" + (typeMap[type] || typeMap.txt) + ";charset=utf-8," + content);
}

// 看MDN即可,https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/webRequest/onBeforeRequest
chrome.webRequest.onBeforeRequest.addListener(function (details) {
        // 这个url会在循环中被修改
        var url = details.url;
        for (var i = 0, len = ReResMap.length; i < len; i++) {
            var reg = new RegExp(ReResMap[i].req, 'gi');
            if (ReResMap[i].checked && typeof ReResMap[i].res === 'string' && reg.test(url)) {
                if (!/^file:\/\//.test(ReResMap[i].res)) {
                    // 普通url,只进行正则替换
                    do {
                        url = url.replace(reg, ReResMap[i].res);
                    } while (reg.test(url))
                } else {
                    do {
                        // file协议url,先正则替换,再转为data url
                        url = getLocalFileUrl(url.replace(reg, ReResMap[i].res));
                    } while (reg.test(url))
                }
            }
        }
        return url === details.url ? {} : { redirectUrl: url };
    },
    {urls: ["<all_urls>"]},
    ["blocking"]
);

getLocalStorage();
window.addEventListener('storage', getLocalStorage, false);

Chrome插件request-interceptor background.js源码赏析

request-interceptor作者说没有开源,但我们仍然能轻易找到其background.js地址。幸好没有特意进行混淆

  1. 安装插件。
  2. 以macOS为例,执行命令:open ~/Library/Application\ Support/Google/Chrome/Default/Extensions,打开Chrome插件安装路径。
  3. 根据插件ID找到对应的文件夹。

如何获得request-interceptorbackground.js所使用的数据结构:阅读源码后知道,只需要在background.js控制台运行以下代码即可:

let dataSet1 = {};
let storageKey1 = '__redirect__chrome__extension__configuration__vk__';
chrome.storage.local.get(storageKey1, config => {
    dataSet1 = {};
    Object.assign(dataSet1, (config || {})[storageKey1] || {});
});

代码比较长就不完整贴出啦。带注释版源码地址,注释中包含对数据结构的讲解~

可以学到什么:

  1. 作者设计规则所执行的操作的时候,借鉴了http状态码设计的思想。add-request-headeradd-response-header等操作的类型都是“add”,于是可以有下面的代码:
const modifyHeaders = (headers, action, name, value) => {
  if (!headers || !action) {
    return;
  }
  if (action === 'add') {
    headers.set(name, value);
  } else if (action === 'modify') {
    if (headers.has(name)) {
      headers.set(name, value);
    }
  } else if (action === 'delete') {
    headers.delete(name);
  }
};
// 调用
actionType = type.split('-')[0];
modifyHeaders(obj.responseHeaders, actionType, updatedName, updatedValue);

这一技巧可以减少一些重复的if-else

技术选型

React Hooks + vite + jest。使用下面的命令来创建:

npm init @vitejs/app

如果对这条命令所做的事感兴趣,可以看参考链接4

但这条命令创建出的项目的文件结构是为构建单页应用而服务的,并不符合Chrome插件开发的需要,我们需要进行改造。我们期望的Chrome插件的manifest.json如下:

{
  "manifest_version": 2,
  "name": "hans-reres",
  "version": "0.0.0",
  "description": "hans-reres旨在用前端工程化技术栈复现ReRes。ReRes是JS逆向工程师常用工具,可以用来更改页面请求响应的内容。通过指定规则,您可以把请求映射到其他的url,也可以映射到本机的文件或者目录。ReRes支持单个url映射,也支持目录映射。",
  "browser_action": {
    "default_icon": "assets/icon.png",
    "default_title": "hans-reres-popup",
    "default_popup": "popup.html"
  },
  "icons": {
    "16": "assets/icon.png",
    "48": "assets/icon48.png"
  },
  "options_page": "options.html",
  "background": {
    "scripts": [
      "background.js"
    ],
    "persistent": true
  },
  "permissions": [
    "tabs",
    "webRequest",
    "webRequestBlocking",
    "<all_urls>",
    "unlimitedStorage"
  ],
  "homepage_url": "https://github.com/Hans774882968/hans-reres"
}

所以我们需要:

  1. manifest.json
  2. background.ts
  3. popup.html和它引用的src/popup/popup.tsx
  4. options.html和它引用的src/options/options.tsx
  5. 一系列供tsx文件和background.ts共同使用的代码。
  6. 静态文件,放在src/assets文件夹下。

核心是希望构建流程用到这些文件,生成符合Chrome插件结构的产物,详见下文《构建流程》一节。

配置stylelint

根据参考链接1,首先

npm install stylelint stylelint-config-standard stylelint-order postcss-less -D

然后添加.stylelintrc.cjs.stylelintignore,最后package.json scripts添加一条命令:

"lint:s": "stylelint \"**/*.{css,scss,less}\" --fix",

即可通过npm run lint:sformat less文件了。

更多stylelint规则介绍见参考链接2

vscode配置保存自动修复

vscode打开设置,再打开settings.json

{
    "editor.codeActionsOnSave": {
        "source.fixAll.eslint": true,
        "source.fixAll.stylelint": true,
    },
}

若不生效,尝试重启vscode。

配置postcss、CSS Modules

react + vite项目已经内置postcss,可以从package-lock.json中看出:

    "vite": {
      "requires": {
        "esbuild": "^0.16.14",
        // 省略其他
        "postcss": "^8.4.21",
      },
      "dependencies": {
        "rollup": {
          "requires": {
            "fsevents": "~2.3.2"
          }
        }
      }
    },

postcss-preset-env

装一下postcss-preset-env插件,这个插件支持css变量、一些未来css语法以及自动补全:

npm i postcss-preset-env -D

添加postcss.config.cjs

const postcssPresetEnv = require('postcss-preset-env');

module.exports = {
  plugins: [postcssPresetEnv()]
};

配置postcss-preset-env插件前:

._app_1afpm_1 {
    padding: 20px;
    user-select: none;
}

配置该插件后:

._app_1afpm_1 {
    padding: 20px;
    -webkit-user-select: none;
    -moz-user-select: none;
    user-select: none;
}

flex-gap-polyfill

这个插件的配置步骤和上面的一样,不赘述。

代码:

.app {
  padding: 20px;
  display: flex;
  gap: 20px;
}

效果:

._app_13518_1 {
    padding: 20px;
    display: flex;
    --fgp-gap: var(--has-fgp, 20px);
    gap: 20px;
    gap: var(--fgp-gap, 0px);
    margin-top: var(--fgp-margin-top, var(--orig-margin-top));
    margin-left: var(--fgp-margin-left, var(--orig-margin-left));
}
._app_13518_1 {
    --has-fgp: ;
    --element-has-fgp: ;
    pointer-events: none;
    pointer-events: var(--has-fgp) none;
    --fgp-gap-row: 20px;
    --fgp-gap-column: 20px;
}
._app_13518_1 {
    --fgp-margin-top: var(--has-fgp) calc(var(--fgp-parent-gap-row, 0px) / (1 + var(--fgp--parent-gap-as-decimal, 0)) - var(--fgp-gap-row) + var(--orig-margin-top, 0px)) !important;
    --fgp-margin-left: var(--has-fgp) calc(var(--fgp-parent-gap-column, 0px) / (1 + var(--fgp--parent-gap-as-decimal, 0)) - var(--fgp-gap-column) + var(--orig-margin-left, 0px)) !important;
}

flex-gap-polyfill踩坑

但要注意flex-gap-polyfill使用上有些坑:

  1. 当你有这样的结构:<div style="padding: 20px;"><div class="flex-and-gap"></div><div></div></div>,那么.flex-and-gap会因为使用了负margin,导致它右侧的div错位。解决方案:在.flex-and-gap外面再套一层div,让.flex-and-gap的负margin不产生影响。
  2. 打包体积增大。在只使用了3处flex-gap的情况下,css大小3.17kb -> 11.0kb

CSS Modules VSCode中点击查看样式

react + vite项目使用less + CSS Modules很简单。但使用VSCode时如何在不跳到less文件的前提下方便地查看样式?根据参考链接12,安装VSCode CSS Modules插件后,用小驼峰命名styles.xxContainer即可点击查看样式,但类名也要一起更改为小驼峰命名法。

另外,如果配置了stylelint,还需要修改selector-class-pattern

{ 'selector-class-pattern': '^[a-z]([A-Z]|[a-z]|[0-9]|-)+$' }

配置husky + commitlint

根据参考链接8

(1)项目级安装commitlint

npm i -D @commitlint/config-conventional @commitlint/cli

(2)添加commitlint.config.cjs(如果package.json配置了"type": "module"就需要.cjs,否则git commit时会报错)

module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {}
};

(3)安装husky:npm i -D husky

(4)对于husky版本>=5.0.0,根据官方文档,首先安装git钩子:npx husky install,运行后会生成.husky/_文件夹,下面有.gitignorehusky.sh文件,都是被忽略的。接下来添加几个钩子:

npx husky add .husky/pre-commit "npm run lint"
npx husky add .husky/pre-commit "npm run lint:s"
npx husky add .husky/commit-msg 'npx commitlint --edit $1'

会生成.husky/commit-msg.husky/pre-commit两个文件。不用命令,自己手动编辑也是可行的,分析过程见下文《husky add、install命令解析》。

接下来可以尝试提交了。效果:

⧗   input: README添加husky + commitlint
✖   subject may not be empty [subject-empty]
✖   type may not be empty [type-empty]

husky add、install命令解析

vscode调试node cli程序

创建.vscode/launch.json

{
  // 使用 IntelliSense 了解相关属性。 
  // 悬停以查看现有属性的描述。
  // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node-terminal",
      "request": "launch",
      "command": "npx husky add .husky/pre-commit 'npm run lint:s'",
      "name": "npx husky add",
      "skipFiles": [
        "<node_internals>/**"
      ],
    }
  ]
}

之后可以直接在“运行和调试”选择要执行的命令了。

husky add

命令举例:npx husky add .husky/commit-msg 'npx commitlint --edit $1'

cli的入口node_modules/husky/lib/bin.js

const [, , cmd, ...args] = process.argv;
const ln = args.length;
const [x, y] = args;
const hook = (fn) => () => !ln || ln > 2 ? help(2) : fn(x, y);
const cmds = {
    install: () => (ln > 1 ? help(2) : h.install(x)),
    uninstall: h.uninstall,
    set: hook(h.set),
    add: hook(h.add),
    ['-v']: () => console.log(require(p.join(__dirname, '../package.json')).version),
};
try {
    cmds[cmd] ? cmds[cmd]() : help(0);
}

x, y分别表示文件名.husky/commit-msg和待添加的命令npx commitlint --edit $1h就是node_modules/husky/lib/index.js。找到相关函数:

function set(file, cmd) {
    const dir = p.dirname(file);
    if (!fs.existsSync(dir)) {
        throw new Error(`can't create hook, ${dir} directory doesn't exist (try running husky install)`);
    }
    fs.writeFileSync(file, `#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

${cmd}
`, { mode: 0o0755 });
    l(`created ${file}`); // 创建文件后会输出 husky - created .husky/pre-commit
}

function add(file, cmd) {
    if (fs.existsSync(file)) {
        fs.appendFileSync(file, `${cmd}\n`);
        l(`updated ${file}`); // 在已有文件后添加后则会输出 husky - updated .husky/pre-commit
    }
    else {
        set(file, cmd);
    }
}

总而言之,不执行这条命令,直接在.husky/commit-msg之后加命令是等效的。

husky install

此时我们也可以快速了解npx husky install所做的事。

const git = (args) => cp.spawnSync('git', args, { stdio: 'inherit' });
function install(dir = '.husky') {
    if (process.env.HUSKY === '0') {
        l('HUSKY env variable is set to 0, skipping install');
        return;
    }
    /* 执行 git rev-parse 命令,正常情况下无输出
    git(['rev-parse']){
      output: (3) [null, null, null]
      pid: 90205
      signal: null
      status: 0
      stderr: null
      stdout: null
    }
    */
    if (git(['rev-parse']).status !== 0) {
        l(`git command not found, skipping install`);
        return;
    }
    const url = 'https://typicode.github.io/husky/#/?id=custom-directory';
    // npx husky install <dir>的dir参数不能跳出项目根目录
    if (!p.resolve(process.cwd(), dir).startsWith(process.cwd())) {
        throw new Error(`.. not allowed (see ${url})`);
    }
    if (!fs.existsSync('.git')) {
        throw new Error(`.git can't be found (see ${url})`);
    }
    try {
        // 创建“.husky/_”文件夹
        fs.mkdirSync(p.join(dir, '_'), { recursive: true });
        // 创建“.husky/_/.gitignore”文件
        fs.writeFileSync(p.join(dir, '_/.gitignore'), '*');
        // .husky/_/husky.sh 来源于 node_modules
        fs.copyFileSync(p.join(__dirname, '../husky.sh'), p.join(dir, '_/husky.sh'));
        // 执行 git config core.hooksPath .husky 命令
        // 同理取消githooks只需要执行 git config --unset core.hooksPath
        const { error } = git(['config', 'core.hooksPath', dir]);
        if (error) {
            throw error;
        }
    }
    catch (e) {
        l('Git hooks failed to install');
        throw e;
    }
    l('Git hooks installed');
}

配置jest

根据参考链接3

1、安装jest:

npm install jest @types/jest -D

2、生成jest配置文件:

npx jest --init

生成的jest.config.ts

import { Config } from '@jest/types';
/*
 * For a detailed explanation regarding each configuration property and type check, visit:
 * https://jestjs.io/docs/configuration
 */

const config: Config.InitialOptions = {
  // Automatically clear mock calls, instances, contexts and results before every test
  clearMocks: true,
  // A preset that is used as a base for Jest's configuration
  preset: 'ts-jest',
  restoreMocks: true,
  testEnvironment: 'jsdom'
};

export default config;

注意:

  1. 即使指定测试环境是jsdom,我们发起向本地文件的XHR请求时仍会报跨域错误,所以发起XHR请求的模块必须mock
  2. 对于use-local-storage-state包的测试文件test/useLocalStorageStateBrowser.test.tsx(我将use-local-storage-state包的代码复制到自己的项目里,进行了更改,以满足Chrome插件开发的需求),必须指定测试环境是jsdom
  3. 指定测试环境是jsdom时需要npm install jest-environment-jsdom -D

3、配置babel:

npm install babel-jest @babel/core @babel/preset-env @babel/preset-typescript -D

4、创建babel.config.cjs

module.exports = {
  presets: [
    ['@babel/preset-env', { targets: { node: 'current' }}],
    '@babel/preset-typescript'
  ]
};

5、如果你在第2步创建的jest配置文件是ts,则还需要装ts-node,否则会报错:Jest: 'ts-node' is required for the TypeScript configuration files.。抛出这个错误的代码可以自己顺着stack trace往上找一下~

npm install ts-jest ts-node -D

总的来说,只需要:(1)安装若干devDependencies的npm包。(2)创建babel.config.cjsjest.config.ts

jest不支持es模块的npm包(如:lodash-es)如何解决?

根据参考链接17,这是因为lodash-es是一个es module且没有被jest转换。

(1)安装相关依赖:

npm install -D babel-jest @babel/core @babel/preset-env babel-plugin-transform-es2015-modules-commonjs

(2)jest.config.ts配置:

import { Config } from '@jest/types';
/*
 * For a detailed explanation regarding each configuration property and type check, visit:
 * https://jestjs.io/docs/configuration
 */

const config: Config.InitialOptions = {
  preset: 'ts-jest', // 这个和以前一样,保持不变
  // 对于js文件用babel-jest转换,ts、tsx还是用ts-jest转换
  transform: {
    '^.+\\.(ts|tsx)$': 'ts-jest',
    '^.+\\.js$': 'babel-jest'
  },
  // 为了效率,默认是忽略node_modules里的文件的,因此要声明不忽略 lodash-es
  transformIgnorePatterns: [
    '<rootDir>/node_modules/(?!lodash-es)'
  ]
}

(3)含泪把之前的babel.config.ts改为babel.config.cjs,配置babel插件babel-plugin-transform-es2015-modules-commonjs

module.exports = {
  plugins: ['transform-es2015-modules-commonjs'], // 刚刚安装的
  presets: [
    ['@babel/preset-env', { targets: { node: 'current' }}],
    '@babel/preset-typescript'
  ]
};

为什么要改成.cjs?相同的内容,只不过后缀名为.js不行嘛?亲测不行,报错You appear to be using a native ECMAScript module configuration file, which is only supported when running Babel asynchronously.。这是因为vite脚手架创建的项目package.json有一句万恶的声明:"type": "module"

构建流程

《技术选型》一节提到,我们需要打包出manifest.jsonpopup.html及其配套CSS、JS;options.html及其配套CSS、JS;background.js;静态资源。这就是一个典型Chrome插件的构成。我们需要设计一个构建流程,生成上述产物。下面列举我遇到的几个基本问题和解决方案:

  1. 静态资源:直接用rollup-plugin-copy复制到manifest.json定义的位置即可。
  2. manifest.json需要修改某些字段:vite没有loader的概念,所以需要想其他办法。可以尝试构造一个专门import 'xx.json'导入json文件的入口ts文件,然后匹配xx.json进行处理,但这种写法获得的文件内容,是json文本转化为js对象的结果,不是很简洁。最终我的做法是:在writeBundle阶段,先读入manifest.json,再进行修改,最后写入目标位置,类似于rollup-plugin-copy代码实现传送门
  3. background.tspopup.html / options.html依赖的tsx文件希望共享某些代码,但不希望background.js打包结果出现import语句,因为这会导致插件无法工作:我们发现background.ts的可靠性可以靠单测来保证,于是只需要保证popup.html / options.html的本地预览功能可用。所以解决方案异常简单,构建2次即可。构建命令修改为tsc && vite build && vite build --config vite-bg.config.ts

至此,Chrome插件开发与普通的⌨️前端开发没有任何区别。

shell脚本:输出构建耗时

令人震惊的是,vite缺乏一个输出构建耗时的可靠插件(0 star的插件还是有的)!这个小需求可以自己写vite插件来解决,也可以用一个更简单的方式来解决:写一个shell脚本。

我们在配置jest时安装了ts-node,因此这里可以直接写ts脚本。scripts/build.ts传送门

import spawn from 'cross-spawn';
import chalk from 'chalk';

function main () {
  const startTime = new Date().valueOf();
  const cmds = [
    'npx tsc',
    'npx vite build',
    'npx vite build --config vite-bg.config.ts'
  ];
  const buildCmd = cmds.join(' && ');
  console.log(chalk.greenBright('Build command:', buildCmd));
  const spawnReturn = spawn.sync(buildCmd, [], { stdio: 'inherit', shell: true });
  if (spawnReturn.error) {
    console.error(chalk.redBright('Build failed with error'), spawnReturn.error);
    return;
  }
  const duration = ((new Date().valueOf() - startTime) / 1000).toFixed(2);
  console.log(chalk.greenBright(`✨  Done in ${duration}s.`));
}

main();
  1. cross-spawn可以理解成一个跨平台版的child_process.spawn,避免自己处理跨平台适配。spawn.sync就是child_process.spawnSync参考链接5
  2. chalk用来输出彩色文本。
  3. 添加shell: true可解决MAC上运行报错Error: spawnSync <cmd> ENOENT导致无法构建的问题,参考链接7

根据参考链接6,构建命令要相应地修改为:

node --loader ts-node/esm ./scripts/build.ts

命令并不能直接使用ts-node scripts/build.ts,因为会报错TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts"

相关依赖:

npm install chalk cross-spawn @types/cross-spawn ts-node -D

ES6 import json:–experimental-json-modules 选项

vite.config.ts可以直接import pkg from './package.json';但我们用ts-node运行的脚本不能。为了解决这个问题,可以尝试:

  1. import assertion。import pkg from '../package.json' assert { type: 'json' };。但只能运行于高版本的node
  2. --experimental-json-modules选项。把构建命令改为:node --loader ts-node/esm --experimental-json-modules scripts/build.ts即可。这样低版本node也支持了~

项目配置路径别名

根据参考链接15,配置路径别名一般分为:cli支持IDE支持两部分,逐个击破即可。

vite配置路径别名

cli支持:vite.config.ts配置resolve.alias

defineConfig({
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src')
    }
  }
});

IDE支持:tsconfig.json配置compilerOptions.paths

{
  "compilerOptions": {
    "paths": {
      "@/*": [
        "./src/*"
      ],
    }
  }
}

jest配置路径别名

cli支持:jest.config.ts

const config: Config.InitialOptions = {
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1'
  },
};

IDE支持:依旧是配置tsconfig.jsoncompilerOptions.paths。但有一个问题:VSCode只认tsconfig.json,不认自己指定的tsconfig.test.json。最后还是让ts-jest直接读tsconfig.json配置了,又不是不能用

引入i18n

根据参考链接9,我们可以用react-i18next快速为react项目引入i18n。

(1)安装依赖

npm i i18next react-i18next i18next-browser-languagedetector
  • react-i18next是一个i18next插件,用来降低 react 的使用成本。
  • i18next-browser-languagedetector是一个i18next插件,它会自动检测浏览器的语言。

(2)我们建一个文件夹src/i18n存放i18n相关的代码。i18n需要考虑的一个核心问题是:资源文件的加载、使用策略。为了简单,我们直接使用.ts文件。创建src/i18n/i18n-init.ts如下。

  1. i18n.use注册i18next插件。
  2. 这里封装了一个$gt函数,期望能直接调用$gt而不需要在组件里多写一句const { t } = useTranslation()。但麻烦的是,t函数必须直接在组件中引用,甚至不能在组件内定义的函数里调用,否则它会直接抛出错误让我们整个应用崩溃……幸好本插件规模很小,这个问题可以容忍。
import i18n from 'i18next';
import { initReactI18next, useTranslation } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import en from './en';
import zh from './zh';

i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    debug: true,
    fallbackLng: 'en',
    interpolation: {
      escapeValue: false
    },
    resources: {
      en: {
        translation: en
      },
      zh: {
        translation: zh
      }
    }
  });

export const $gt = (key: string | string[]) => {
  const { t } = useTranslation();
  return t(key);
};

export const langOptions = [
  { value: 'en', label: 'English' },
  { value: 'zh', label: '中文' }
];

export default i18n;

(3)语言切换功能。useTranslation()也会返回一个i18n对象,我们调用i18n.changeLanguage即可切换语言。

/*
export const langOptions = [
  { value: 'en', label: 'English' },
  { value: 'zh', label: '中文' }
];
*/
const changeLang = (langValue: string) => {
  i18n.changeLanguage(langValue);
};
<Select
  defaultValue={i18n.resolvedLanguage}
  placeholder={$gt('Select language')}
  options={langOptions}
  onChange={changeLang}
/>

动态切换暗黑主题

根据参考链接10,antd5提供了动态切换主题的能力,只需要使用ConfigProvider

import theme from 'antd/es/theme';
import ConfigProvider from 'antd/es/config-provider';
<ConfigProvider theme={{
  algorithm: preferDarkTheme ? theme.darkAlgorithm : theme.defaultAlgorithm
}}>
    <MyComponents />
</ConfigProvider>

使用预设算法是成本最低的方式,当然功能也最局限。为简单起见,我们就采用这种方式。

首先需要一个bool来控制当前是暗色主题还是灰色主题:

const [preferDarkTheme, setPreferDarkTheme] = useLocalStorageState('preferDarkTheme', {
  defaultValue: true
});

导航栏的开关只需要调用setPreferDarkTheme即可切换主题。

另外,项目有一些组件没有用antd,不在预设算法的覆盖范围内,比如导航栏。不优美但肯定最简单的解决方案就是:我们在根组件定义各个主题的类名prefix:

enum ClassNamePrefix {
  DARK = 'custom-theme-dark',
  DEFAULT = 'custom-theme-default'
}
const curClassNamePrefix = preferDarkTheme ? ClassNamePrefix.DARK : ClassNamePrefix.DEFAULT;

然后通过Context传给子组件:

<ThemeContext.Provider value={{ curClassNamePrefix, preferDarkTheme, setPreferDarkTheme }}>
</ThemeContext.Provider>

子组件直接消费即可:

<Row className={styles[`${curClassNamePrefix}-navbar`]} />

插件核心功能:数据结构设计

我们希望这个插件支持:

  • 重定向到某URL,包括file://这种指向本地文件的(来自ReRes)。
  • 对于GET请求,可以进行URLSearchParams的增删改。
  • 对请求头进行增删改。
  • 对响应头进行增删改。
  • 拦截请求。
  • ……

拟定这些需求是参考了Chrome插件request-interceptorbackground.js的核心代码,如下:

const applyRuleActions = (rule, details, obj) => {
    if (!rule.actions || !rule.enabled) {
        return;
    }

    // const count = countMap.get(rule.id) ?? 0;
    // countMap.set(rule.id, count + 1);

    const matches = getMatches(rule, details);

    (rule.actions || []).forEach((action) => {
        if (!action.details) {
            action.details = {};
        }

        const {type, details: {name, value}} = action;
        const updatedName = patternMatchingReplace(name, matches);
        const updatedValue = patternMatchingReplace(value, matches);

        let actionType;

        switch (type) {
            case 'block-request':
                obj.cancel = true; break;
            case 'add-request-header':
            case 'modify-request-header':
            case 'delete-request-header':
                actionType = type.split('-')[0];
                modifyHeaders(obj.requestHeaders, actionType, updatedName, updatedValue);
                obj.requestHeadersModified = true;
                break;
            case 'add-response-header':
            case 'modify-response-header':
            case 'delete-response-header':
                actionType = type.split('-')[0];
                modifyHeaders(obj.responseHeaders, actionType, updatedName, updatedValue);
                obj.responseHeadersModified = true;
                break;
            case 'add-query-param':
            case 'modify-query-param':
            case 'delete-query-param':
                actionType = type.split('-')[0];
                modifyQueryParams(obj.queryParams, actionType, updatedName, updatedValue);
                obj.queryParamsModified = true;
                break;
            case 'redirect-to':
                // Preflight requests can not be redirected
                if (details.method.toLowerCase() !== 'options') {
                    obj.redirectUrl = updatedValue;
                }

                break;
            case 'throttle':
                obj.redirectUrl = `https://deelay.me/${updatedValue}/${details.url}`; break;
        }

    });
};

对需求进行简单分析后,我认为background.ts的一条规则这样描述看上去还算合理,完整代码

// 为节省篇幅,只展示了一部分
export enum RewriteType {
  SET_UA = 'Set UA',
  REDIRECT = 'Redirect',
  ADD_QUERY_PARAM = 'Add Query Param'
}
// localStorage中的核心数据结构:{ hansReResMap: RequestMappingRule[] }
export interface RequestMappingRule {
  req: string
  action: Action
  checked: boolean
}

export interface Action {
  type: RewriteType
}

export interface RedirectAction extends Action {
  res: string
}

export interface SetUAAction extends Action {
  newUA: string
}

export interface AddQueryParamAction extends Action {
  name: string
  value: string
}

export interface ModifyQueryParamAction extends Action {
  name: string
  value: string
}

export interface DeleteQueryParamAction extends Action {
  name: string
}
// 在此仅展示对 URLSearchParams 的操作
export type QueryParamAction = AddQueryParamAction | ModifyQueryParamAction | DeleteQueryParamAction;

export function isAddQueryParamAction (o: Action): o is AddQueryParamAction {
  return o.type === RewriteType.ADD_QUERY_PARAM;
}

export type ReqHeaderAction = AddReqHeaderAction | ModifyReqHeaderAction | DeleteReqHeaderAction;

export function isReqHeaderAction (o: Action): o is ReqHeaderAction {
  return isAddReqHeaderAction(o) ||
    isModifyReqHeaderAction(o) ||
    isDeleteReqHeaderAction(o);
}

前文提到,request-interceptor源码设计描述操作的常量(add|modify|delete)-response-header时,借鉴了http状态码的思想,第一个词表示操作类型。但我不打算这么写,而是采用类型安全但比较啰嗦的写法。

popupoptions页面需要用到的,各类型Action提供的默认值如下:

export const actionDefaultResultValueMap = {
  [RewriteType.REDIRECT]: { res: 'https://baidu.com' },
  [RewriteType.SET_UA]: { newUA: 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_0 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1 FingerBrowser/1.5' },
  [RewriteType.BLOCK_REQUEST]: {},
  [RewriteType.ADD_QUERY_PARAM]: { name: 'role', value: 'acmer' },
  [RewriteType.MODIFY_QUERY_PARAM]: { name: 'rate', value: '2400' },
  [RewriteType.DELETE_QUERY_PARAM]: { name: 'param_to_delete' },
  [RewriteType.ADD_REQ_HEADER]: { name: 'X-Role', value: 'ctfer' },
  [RewriteType.MODIFY_REQ_HEADER]: { name: 'X-Rate', value: '2400' },
  [RewriteType.DELETE_REQ_HEADER]: { name: 'Request-Header' },
  [RewriteType.ADD_RESP_HEADER]: { name: 'Y-Role', value: 'acmer' },
  [RewriteType.MODIFY_RESP_HEADER]: { name: 'Y-Rate', value: '2400' },
  [RewriteType.DELETE_RESP_HEADER]: { name: 'Response-Header' }
};

RequestMappingRule的数据结构设计符合直觉,但这个设计对象里有对象,引入了一个问题:如果想直接使用antdForm组件,Form.useForm<RequestMappingRule>()里的action属性(是Action接口)应该是无法直接映射到表单的字段。如何解决呢?借鉴适配器模式,我引入了以下数据结构(有更好的做法请佬们教教!):

export interface FlatRequestMappingRule {
  req: string
  checked: boolean
  action: RewriteType
  res: string
  newUA: string
  name: string
  value: string
}

然后在Form组件onFinish事件里将FlatRequestMappingRule翻译为RequestMappingRule,这样就能顺利写入localStorage啦。同理,从localStorage加载RequestMappingRule后,也要翻译为FlatRequestMappingRule才能顺利输入Form组件,渲染Edit对话框。两者相互转化的函数如下:

export function transformIntoRequestMappingRule (o: FlatRequestMappingRule): RequestMappingRule {
  const action: Action = (() => {
    if (o.action === RewriteType.REDIRECT) return { type: o.action, res: o.res };
    if (o.action === RewriteType.SET_UA) return { type: o.action, newUA: o.newUA };
    return { type: o.action, name: o.name, value: o.value };
  })();
  return {
    req: o.req,
    checked: o.checked,
    action
  };
}

export function transformIntoFlatRequestMappingRule (o: RequestMappingRule): FlatRequestMappingRule {
  const ret: FlatRequestMappingRule = {
    req: o.req,
    checked: o.checked,
    action: o.action.type,
    res: '',
    newUA: '',
    name: '',
    value: ''
  };
  return { ...ret, ...o.action };
}

缺点:

  1. 对于新增的Action类型,不鼓励新增字段名,因为改动会更大,一般都是直接使用已有的name, value属性。这恰好和request-interceptor的源码一致。

插件核心功能:正式实现

赏析ReResrequest-interceptor两个插件的源码,并结合typescript进行数据结构设计后,我们就可以开始实现本插件的核心功能了。代码传送门

模仿ReRes写一个加载数据结构的函数:

function getMapFromLocalStorage (): RequestMappingRule[] {
  const hansReResMap = window.localStorage.getItem(hansReResMapName);
  return hansReResMap ? JSON.parse(hansReResMap) : [];
}

值得注意的是,ReRes源码使用了

window.addEventListener('storage', getLocalStorage, false);

popup, options页面更新localStorage后更新数据结构,于是可以直接将ReResMap作为全局变量,理论上可以提高性能。但我这边尝试使用这行代码发现并没有及时更新,因此没有使用全局变量,而是退而求其次,在每个listener执行时都重新调用getMapFromLocalStorage加载。

因为测试是保证background.ts可靠性的唯一手段,所以为了可测性,我把大部分代码都移动到src/utils.ts了。期间遇到了一个typescript中才有的问题:chrome在测试环境中不存在,因此在不mock的情况下,只有将代码移动到其他文件,才能测试。但有些类型依赖chrome变量,如:import HttpHeader = chrome.webRequest.HttpHeader;。因为HttpHeader字段少,所以可以使用“鸭子类型”的技巧来解决这个问题:

export interface MockHttpHeader {
  name: string;
  value?: string | undefined;
  binaryValue?: ArrayBuffer | undefined;
}

PS:鸭子类型的介绍,来自《JavaScript设计模式与开发实践》Chap1。

JavaScript 是动态语言,无需进行类型检测,可以调用对象的任意方法。这一切都建立在鸭子类型上,即:如果它走起路来像鸭子,叫起来像鸭子,那它就是鸭子。

鸭子模型指导我们关注对象的行为,而不是对象本身,也就是关注 Has-A,而不是 Is-A。利用鸭子模式就可以实现动态类型语言一个原则"面向接口编程而不是面向实现编程"。

之后HttpHeader类型的变量都可以用MockHttpHeader代替,而两者是兼容的,所以ts不会报类型错误。

onBeforeRequest的入口,我模仿了request-interceptor的写法,优先级cancel > redirect > queryParamsModified。唯一不同点是,request-interceptor为了简化代码,实现为一个对returnObject的副作用;而我实现的processRequest是一个纯函数:

const onBeforeRequestListener = (details: WebRequestBodyDetails) => {
  const hansReResMap = getMapFromLocalStorage();
  const actionDescription = processRequest(details.url, hansReResMap);

  const { redirectUrl = '', cancel, queryParamsModified } = actionDescription;
  // 约定优先级:cancel > redirect > queryParamsModified
  if (cancel) {
    return { cancel: true };
  }
  if (redirectUrl) {
    try {
      // Unchecked runtime.lastError: redirectUrl 'baidu.com/' is not a valid URL.
      // 针对Chrome的这种报错,我们只会尝试给出一个友好点的报错提示,不会擅自阻止报错的产生
      new URL(redirectUrl);
    } catch (e) {
      console.error(`Please make sure that redirectURL '${redirectUrl}' is a valid url when using hans-reres. For example, 'baidu.com' is not a valid url.`);
    }
    return redirectUrl === details.url ? {} : { redirectUrl };
  }
  if (queryParamsModified) {
    const { urlObject } = actionDescription;
    urlObject.search = actionDescription.queryParams.toString();
    return { redirectUrl: urlObject.toString() };
  }
  return {};
};

chrome.webRequest.onBeforeRequest.addListener(
  onBeforeRequestListener,
  { urls: ['<all_urls>'] },
  ['blocking']
);

获取到actionDescription后,就按照优先级来决定操作。这里引入了一个限制:读取一系列规则后,对一个请求只有一个操作。request-interceptor引入这个限制是为了简化代码,但这个限制也是合理的。因为用户希望重定向URL时,一般不会希望在重定向后再对新URL的URLSearchParams进行增删改。

重定向时保持URLSearchParams的功能

考虑一个场景:前端开发过程中,希望把GET请求转发到YAPI,来方便地使用Mock数据。但是在request-interceptor中配置重定向规则后,发现会丢失查询字符串。而我在实现重定向规则时,是模仿ReRes,对请求URL进行replace,所以看起来可以保留查询字符串。但面对响应URL有查询字符串的情况,新URL的查询字符串会不符合预期。所以我们在此引入一个小功能:对于重定向规则,在popup, options页面可以勾选是否需要保持查询字符串。若某条重定向规则指出需要保持,则把新URL的查询字符串覆盖为原始URL(未读取规则前的URL)的查询字符串。

首先给RedirectAction加个选项:

export interface RedirectAction extends Action {
  res: string
  keepQueryParams: boolean
}

最后只需要在每次循环获取redirectUrl后,对其进行一个后置处理。

export function overrideQueryParams (urlObject: URL, redirectUrl: string, action: RedirectAction) {
  if (!action.keepQueryParams) return redirectUrl;
  try {
    const redirectUrlObject = new URL(redirectUrl);
    redirectUrlObject.search = urlObject.search;
    redirectUrl = redirectUrlObject.toString();
  } catch (e) {
    console.error('overrideQueryParams() error', e);
  }
  return redirectUrl;
}

Mock Response功能

我的插件的Mock Response功能可以说是前端开发的利器——从此Mock接口返回数据没有任何门槛。这一节就讲述这个功能的实现思路。我们已经从ReRes源码学到:为了实现将请求重定向到本机(即file协议URL),需要先发XHR请求获取本机文件内容,再将其拼接为data协议的URL。于是我们可以在ReRes和我的插件的重定向功能中,直接指定重定向URL为data协议URL,来实现Mock Response功能。但这样不太方便,所以在此我引入一个新的操作类型MockResponseAction

export interface MockResponseAction extends Action {
  dataType: ResponseType // necessary,background.js 用不到但编辑对话框要用到
  value: string
}

接下来在表单中加一个下拉框,可以选择编程语言。对于选中的编程语言,展示的组件为对应语言的编辑器(可以附加一个“格式化”按钮)。代码传送门

这一块在交互方面的想象空间不小,比如:每种语言提供一个功能强大的编辑器。可惜这里(IDE)空白处太小,写不下

另外,为了可测试性,应该把负责格式化操作的代码和与UI有关的代码隔离开。格式化相关代码

请求头、响应头的处理

listener的代码结构和上述processRequest类似:(1)一个纯函数。(2)返回值包括:要使用到的数据和一系列是否需要进行某操作的bool变量。定义如下:

export type HeadersMap = Map<string, string>;

export interface ProcessHeadersReturn {
  headersModified: boolean
  requestHeadersMap: HeadersMap
  responseHeadersMap: HeadersMap
}

实现难度较低,不再赘述。相关代码传送门:background.tsutils.ts

details.requestHeadersdetails.responseHeaders的类型是chrome.webRequest.HttpHeader[] | undefined,这个数据结构对修改操作不友好。request-interceptor为了降低修改操作的时间复杂度,引入了转化为Map的前置操作和重新转为数组的后置操作。咱们用TS模仿实现时,需要再次使用“鸭子类型”的技巧,相关代码如下:

export interface MockHttpHeader {
  name: string;
  value?: string | undefined;
  binaryValue?: ArrayBuffer | undefined;
}

export type HeadersMap = Map<string, string>;

export function mapToHttpHeaderArray (mp: HeadersMap): MockHttpHeader[] {
  return [...mp.entries()].map(([name, value]) => ({ name, value }));
}
// getHeadersMap 直接在 listener 中调用
export function getHeadersMap (headers: MockHttpHeader[]) {
  return new Map(headers.map(header => [header.name, header.value || '']));
}

读取POST请求体内容

遗憾的是,根据参考链接14,Chrome永远不会支持POST请求体的修改。但我们依旧可以读取请求体,所以仍然可以定这么一个需求:若请求体的JSON某字段包含特定的name,则拦截请求。

查看MDN可知,请求体类型定义如下:

export interface UploadData {
    /** Optional. An ArrayBuffer with a copy of the data. */
    bytes?: ArrayBuffer | undefined;
    /** Optional. A string with the file's path and name. */
    file?: string | undefined;
}

export interface WebRequestBody {
    /** Optional. Errors when obtaining request body data. */
    error?: string | undefined;
    /**
     * Optional.
     * If the request method is POST and the body is a sequence of key-value pairs encoded in UTF8, encoded as either multipart/form-data, or application/x-www-form-urlencoded, this dictionary is present and for each key contains the list of all values for that key. If the data is of another media type, or if it is malformed, the dictionary is not present. An example value of this dictionary is {'key': ['value1', 'value2']}.
     */
    formData?: { [key: string]: string[] } | undefined;
    /**
     * Optional.
     * If the request method is PUT or POST, and the body is not already parsed in formData, then the unparsed request body elements are contained in this array.
     */
    raw?: UploadData[] | undefined;
}

这给我们读取请求体的JSON制造了不少困难。我们有必要写一个方法,负责从ArrayBuffer中读取到请求体的JSON对象。实现如下:

// 调用
const postBodyList = parsePostBody(details.requestBody?.raw);

export function parsePostBody (rawData: MockUploadData[] | undefined): plainObject[] {
  if (!rawData) return [];
  return rawData.filter((item) => {
    let strData = '';
    try {
      strData = new TextDecoder().decode(item.bytes);
    } catch (e) {
      return false;
    }
    if (!isValidJson(strData)) return false;
    const obj = JSON.parse(strData);
    if (!isPlainObject(obj)) return false;
    return true;
  }).map((item) => JSON.parse(new TextDecoder().decode(item.bytes)));
}

这里为了避免引入chrome导致无法测试,再次使用了“鸭子类型”的技巧:

export type plainObject = Record<string, unknown>;

export interface MockUploadData {
  bytes?: ArrayBuffer | undefined;
  file?: string | undefined;
}

有读取JSON对象的能力后,其他部分的实现都很简单,看相关代码实现即可:background.tsutils.ts

另外,为了读取请求体数据,需要添加requestBody权限:

chrome.webRequest.onBeforeRequest.addListener(
  onBeforeRequestListener,
  { urls: ['<all_urls>'] },
  ['blocking', 'requestBody']
);

lodash按需导入:tree-shaking

一般我们只使用lodash的少数函数,但构建时会将所有模块打包进来。可以按需导入嘛?根据参考链接18参考链接19,可以使用lodash-esvite项目基本上正常import,比如:import { isPlainObject } from 'lodash-es';,就可以获得tree-shaking的能力了。我遇到的问题见上文《jest不支持es模块的npm包(如:lodash-es)如何解决?》

jest如何测试使用了TextEncoder和TextDecoder的模块?

如果用到了TextEncoderTextDecoder,那么jest运行会报错。目前我使用的是一个workaround(参考链接16):

(1)jest.config.ts

const config: Config.InitialOptions = {
  setupFilesAfterEnv: ['<rootDir>/test/setupTests.ts'],
  // npm install jest-environment-jsdom -D
  testEnvironment: 'jsdom'
}

(2)test/setupTests.ts

// npm i @testing-library/jest-dom -D
import '@testing-library/jest-dom';
import { TextDecoder, TextEncoder } from 'util';

global.TextEncoder = TextEncoder;
// 不转为any会报类型不匹配的错误
global.TextDecoder = TextDecoder as any;

后记

配置难度(一生之敌)排名:1、jestyyds。2、eslint暂时的神

参考资料

  1. https://juejin.cn/post/7185920750765735973
  2. stylelint规则文档:https://ask.dcloud.net.cn/article/36067
  3. https://juejin.cn/post/7078330175145902110
  4. npm init @vitejs/app到底干了什么:https://juejin.cn/post/6948202986573135908
  5. https://www.cnblogs.com/cangqinglang/p/14761536.html
  6. 使用ts-node运行ts脚本及踩过的坑:https://juejin.cn/post/6939538768911138823
  7. https://stackoverflow.com/questions/27688804/how-do-i-debug-error-spawn-enoent-on-node-js
  8. 使用commitlint规范commit格式:https://juejin.cn/post/6990307028162281508
  9. https://juejin.cn/post/7139855730105942030
  10. antd5定制主题官方文档:https://ant-design.gitee.io/docs/react/customize-theme-cn
  11. onBeforeRequest MDN:https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/webRequest/onBeforeRequest
  12. https://juejin.cn/post/7097312790511091719
  13. jest jsdom环境TextEncoderTextDecoder未定义:https://stackoverflow.com/questions/68468203/why-am-i-getting-textencoder-is-not-defined-in-jest
  14. https://bugs.chromium.org/p/chromium/issues/detail?id=91191
  15. vite配置路径别名:https://juejin.cn/post/7051507089574723620
  16. jest如何测试使用了TextEncoderTextDecoder的模块:https://github.com/inrupt/solid-client-authn-js/issues/1676
  17. 解决jest处理es模块:https://www.cnblogs.com/xueyoucd/p/10495922.html
  18. lodash按需引入:https://www.cnblogs.com/fancyLee/p/10932050.html
  19. https://blog.battlefy.com/tree-shaking-lodash-with-vite
 类似资料: