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

文章目录

    • 引言
    • 亮点
    • Chrome插件ReRes源码赏析
    • Chrome插件request-interceptor background.js源码赏析
    • 技术选型
    • 配置stylelint
      • vscode配置保存自动修复
    • 配置postcss、CSS Modules
      • postcss-preset-env
      • flex-gap-polyfill
      • flex-gap-polyfill踩坑
      • CSS Modules VSCode中点击查看样式
    • 配置husky + commitlint
      • husky add、install命令解析
        • vscode调试node cli程序
        • husky add
        • husky install
    • 配置jest
      • jest不支持es模块的npm包(如:lodash-es)如何解决?
    • 构建流程
      • shell脚本:输出构建耗时
      • ES6 import json:--experimental-json-modules 选项
    • 项目配置路径别名
      • vite配置路径别名
      • jest配置路径别名
    • 引入i18n
    • 动态切换暗黑主题
    • 插件核心功能:数据结构设计
    • 插件核心功能:正式实现
      • 重定向时保持URLSearchParams的功能
      • Mock Response功能
      • 请求头、响应头的处理
      • 读取POST请求体内容
      • lodash按需导入:tree-shaking
      • jest如何测试使用了TextEncoder和TextDecoder的模块?
      • 后记
    • 参考资料

引言

这个项目主要目的是用前端工程化技术栈复现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: [""]},
    ["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",
    "",
    "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. 当你有这样的结构:
    ,那么.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": [
        "/**"
      ],
    }
  ]
}

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

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参数不能跳出项目根目录
    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: [
    '/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 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: {
    '^@/(.*)$': '/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);
};