- 测试
- 开发工具
【免费下载链接】sinon
Test spies, stubs and mocks for JavaScript.
本文是 Sinon.JS 官方 "How-to" 系列实践指南的完整导读与深度讲解。围绕docs/guides/how-to/index.md索引下收录的五类高频测试场景——用 fake timers 加速依赖定时器的异步测试、通过 link seams 隔离 CommonJS 被测系统、直接桩掉模块依赖、让 ES Module 命名空间可被 stub、以及 TypeScript + SWC 真实世界依赖替换案例——逐一给出可复制、可运行的代码与配置,并结合当前仓库源码与测试印证底层原理。读完本文,你将掌握在 Node.js 与各类构建工具链中隔离被测系统、注入测试替身(test double)的完整方法论与排错思路。
一、How-to 指南总览
Sinon.JS 的核心定位是"创建并注入测试替身(spies、fakes、stubs)"的库,而不是模块拦截库。因此在面对"如何替换被测模块的依赖"这类问题时,答案高度依赖运行环境与实现方式。官方在 docs/guides/how-to/index.md 中收录了五篇独立成文的实战指南:
| 指南 | 相对路径 | 核心主题 |
|---|---|---|
| Async functions with fake timers | docs/guides/how-to/fake-timers-async.md | 加速依赖定时器的测试,同步触发计划中的回调 |
| Link seams (CommonJS) | docs/guides/how-to/link-seams-commonjs.md | 用 proxyquire 这类工具 hook 进require,隔离被测系统 |
| Stub a dependency | docs/guides/how-to/stub-dependency.md | 直接在测试中桩掉 CommonJS 模块的导出方法 |
| Stub ES module imports | docs/guides/how-to/stub-esm.md | 让 ES Module 命名空间可变,从而支持 Sinon 桩 |
| TypeScript and SWC | docs/guides/how-to/typescript-swc.md | 真实世界依赖替换的详细排错案例研究 |
五篇指南遵循同一条主线:先弄清楚构建产物到底是什么样,再选择对应的隔离手段。下面逐篇深入。
二、用 Fake Timers 加速异步函数测试
2.1 基本思路:跳过等待,同步推进时钟
依赖setTimeout、setInterval等定时器 API 的代码在测试中通常需要真实等待,拖慢整个测试套件。Sinon 的 fake timers 把全局定时器替换为可控的假实现,让测试可以调用clock.tick(ms)直接"快进"时间,同步触发计划中的回调。
以官方指南中的 maker 模块为例(docs/guides/how-to/fake-timers-async.md):
// maker.js module.exports.callAfterOneSecond = (callback) => { setTimeout(callback, 1000); };对应的测试使用 Mocha 风格的生命周期钩子在测试前后安装与恢复时钟:
// test.js before(function () { this.clock = sinon.useFakeTimers(); }); after(function () { this.clock.restore(); }); it("should call after one second", function () { const spy = sinon.spy(); maker.callAfterOneSecond(spy); // callback 不会立即被调用 assert.ok(!spy.called); // 时钟快进 1000ms 后,回调被同步触发 this.clock.tick(1000); assert.ok(spy.called); // PASS });关键点在于:tick(1000)之后断言立刻执行,测试几乎瞬时完成,而不是真的等待 1 秒。
2.2 返回 Promise 的函数
指南进一步演示了"测试返回 Promise 的函数,同时推进假时钟"的模式。被测函数通过setTimeout在 1 秒后 resolve:
module.exports.fulfillAfterOneSecond = () => { return new Promise((resolve) => { setTimeout(() => resolve(42), 1000); }); };测试返回 Promise 本身(利用测试运行器对 Promise 的原生支持),在tick之后通过.then断言:
it("should be fulfilled after one second", function () { const promise = maker.fulfillAfterOneSecond(); this.clock.tick(1000); return promise.then((result) => assert.equal(result, 42)); // PASS });由于async函数与显式返回 Promise 的函数行为一致,同一个模式可以直接套用在async/await代码上:
// maker.js module.exports.asyncReturnAfterOneSecond = async () => { const setTimeoutPromise = (timeout) => { return new Promise((resolve) => setTimeout(resolve, timeout)); }; await setTimeoutPromise(1000); return 42; };it("should return 42 after 1000ms", async function () { const promise = maker.asyncReturnAfterOneSecond(); this.clock.tick(1000); const result = await promise; assert.equal(result, 42); // PASS });2.3 底层原理与注意事项
从源码看,sinon.useFakeTimers的实现在 src/sinon/util/fake-timers.js:它委托给@sinonjs/fake-timers包,支持三种入参形态——无参(now: 0)、数字或Date(作为起始纪元)、以及配置对象(可携带global指定安装目标上下文),并返回一个带restore(内部即uninstall)方法的 clock 实例。
该 API 的配置选项在 docs/concepts/fake-timers/use-fake-timers.md 中有完整表格,常用项包括:
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
config.now | Number/Date | 0 | 安装时钟时指定的起始时间戳 |
config.toFake | String[] | 除nextTick外全部 | 显式指定要替换的函数名,不能与toNotFake并用 |
config.toNotFake | String[] | [] | 显式指定保持原生的函数名 |
config.loopLimit | Number | 1000 | 调用runAll()时最多执行的定时器数量 |
config.shouldAdvanceTime | Boolean | false | 根据真实系统时间流逝自动推进模拟时间 |
config.advanceTimeDelta | Number | 20 | 仅配合shouldAdvanceTime: true使用,真实时间每过 1ms 推进模拟时间多少 ms |
config.shouldClearNativeTimers | Boolean | false | 安装前清除原生定时器 |
config.ignoreMissingTimers | Boolean | false | 忽略环境中不存在的定时器方法 |
config.target | Object | global | 指定安装目标对象(如 JSDOM 窗口) |
例如限制runAll()循环上限并指定起始时间:
sinon.useFakeTimers({ now: 1483228800000, loopLimit: 10 });仓库测试 docs/tests/docs/fake-timers/config-example.test.js 正是该组合的验证用例:安装时钟后断言Date.now()与配置一致,调度多个 timeout 后调用clock.runAll()全部执行,最后clock.restore()收尾。
需要注意:尽管这些测试几乎瞬时通过,它们本质上仍是异步的——与第一个同步回调示例不同,它们返回 Promise 而不是在clock.tick(1000)之后立刻运行断言,因为Promise 的then()永远异步执行。但假时钟依然把等待时间压缩到了接近零。
三、通过 Link Seams 隔离 CommonJS 被测系统
3.1 什么是 link seams
"Seam"(接缝)概念源自经典著作Working Effectively with Legacy Code:它是代码中允许你不动被测代码、从外部替换其依赖的位置。本指南的目标是定位 link seams 并用自己的桩替换依赖(docs/guides/how-to/link-seams-commonjs.md)。
3.2 为什么 CommonJS 仍然值得关注
指南明确指出:尽管 ES Module 标准 2015 年就已出现,但转译器与打包器至今仍可把代码输出为 CJS 模块——例如 TypeScript 到 2023 年默认输出仍是 CJS,import foo from './foo'最终可能被转译成const foo = require('./foo')。因此理解 CJS 场景依然必要。ESM 场景请转至 Stub ES module imports 指南。
3.3 Hooking intorequire
要替换require底层完成的加载行为,需要一个能 hook 进模块加载过程的工具:rewire、proxyquire、Quibble 等皆可。指南以proxyquire为例,其机制对其他工具同样适用。
3.4 完整示例
示例目录结构:
. ├── lib │ └── does-file-exist.js └── test └── does-file-exist.test.js源文件只依赖一个模块fs:
// lib/does-file-exist.js var fs = require("fs"); function doesFileExist(path) { return fs.existsSync(path); } module.exports = doesFileExist;测试文件用proxyquire以假实现替换fs,其中existsSync是我们在每个测试前新创建的 Sinon 桩,完全控制其行为:
// test/does-file-exist.test.js var proxyquire = require("proxyquire"); var sinon = require("sinon"); var assert = require("referee").assert; var doesFileExist; // 被测模块 var existsSyncStub; // 依赖上的假方法 describe("example", function () { beforeEach(function () { existsSyncStub = sinon.stub(); // 每个测试都创建新桩 // 用假依赖导入被测模块 doesFileExist = proxyquire("../lib/does-file-exist", { fs: { existsSync: existsSyncStub, }, }); }); describe("when a path exists", function () { beforeEach(function () { existsSyncStub.returns(true); // 设置想要的返回值 }); it("should return `true`", function () { var actual = doesFileExist("9d7af804-4719-4578-ba1d-5dd8a4dae89f"); assert.isTrue(actual); }); }); });这个模式的价值在于:被测模块doesFileExist完全不知道自己被测试——它依旧require("fs"),只是require的解析被 proxyquire 接管,返回了我们注入的、带桩方法的对象。
四、直接桩掉模块依赖(CommonJS)
4.1 适用边界与方法前提
Sinon 是桩库而非模块拦截库,依赖桩的可行性高度依赖环境与实现。官方对 Node 环境的推荐通常是link seams 方案(见上一节)或显式依赖注入;但在一些更基础的场景下,仅用 Sinon 就能通过修改依赖模块的导出属性达到目的(docs/guides/how-to/stub-dependency.md)。
要桩掉被测模块的依赖(被 import 的模块),做法是:在测试中显式 import 该依赖,然后 stub 其目标方法。前提是:该方法不能被解构(destructured)——无论在被测模块里还是测试里都不行,因为解构会捕获方法引用,桩替换的是模块导出对象上的属性,已捕获的绑定不受影响。
4.2 基础示例
依赖模块与被测模块:
// dependencyModule.js function getSecretNumber() { return 44; } module.exports = { getSecretNumber, };// moduleUnderTest.js const dependencyModule = require("./dependencyModule"); function getTheSecret() { return `The secret was: ${dependencyModule.getSecretNumber()}`; } module.exports = { getTheSecret, };注意被测模块是通过dependencyModule.getSecretNumber()访问的,而非解构。测试如下:
// test.js const assert = require("assert"); const sinon = require("sinon"); const dependencyModule = require("./dependencyModule"); const { getTheSecret } = require("./moduleUnderTest"); describe("moduleUnderTest", function () { describe("when the secret is 3", function () { it("should be returned with a string prefix", function () { sinon.stub(dependencyModule, "getSecretNumber").returns(3); const result = getTheSecret(); assert.equal(result, "The secret was: 3"); }); }); });4.3 异步依赖的复杂示例
当依赖返回 Promise 时,在测试方法上加async关键字、用await调用被测方法即可。被测链路由一个分页拉取用户列表的 API 模块和聚合逻辑模块组成:
// userApi.js const axios = require("axios"); async function getPageOfUsers(page) { const result = await axios({ method: "GET", url: `https://reqres.in/api/users?page=${page}`, }); return result.data; } module.exports = { getPageOfUsers };// userUtils.js const userApi = require("./userApi"); async function getAllUsers() { const users = []; let page = 0, usersPage = null; do { page += 1; usersPage = await userApi.getPageOfUsers(page); users.push(...usersPage.data); } while (usersPage.total_pages > page); return users; } module.exports = { getAllUsers };测试用sinon.stub(userApi, "getPageOfUsers")替换整个方法,并在afterEach中restore():
// UserUtils-test.js const assert = require("assert"); const sinon = require("sinon"); const userUtils = require("./userUtils"); const userApi = require("./userApi"); function aUser(id) { return { id, email: `someemail@user${id}.com`, first_name: `firstName${id}`, last_name: `lastName${id}`, avatar: `https://www.somepage${id}.com`, }; } describe("userUtils", function () { let getPageOfUsersStub; beforeEach(function () { getPageOfUsersStub = sinon.stub(userApi, "getPageOfUsers"); }); afterEach(function () { getPageOfUsersStub.restore(); }); describe("when a single page of users exists", function () { it("should return users from that page", async function () { const pageOfUsers = { page: 1, total_pages: 1, data: [aUser(1), aUser(2), aUser(3)], }; getPageOfUsersStub.returns(Promise.resolve(pageOfUsers)); const result = await userUtils.getAllUsers(); assert.equal(result.length, 3); assert.equal(getPageOfUsersStub.calledOnce, true); }); }); describe("when multiple pages of users exists", function () { it("should return a combined list of all users", async function () { const pageOfUsers1 = { page: 1, total_pages: 2, data: [aUser(1), aUser(2), aUser(3)], }; const pageOfUsers2 = { page: 2, total_pages: 2, data: [aUser(4), aUser(5)], }; getPageOfUsersStub.withArgs(1).returns(Promise.resolve(pageOfUsers1)); getPageOfUsersStub.withArgs(2).returns(Promise.resolve(pageOfUsers2)); const result = await userUtils.getAllUsers(); assert.equal(result.length, 5); assert.equal(getPageOfUsersStub.callCount, 2); }); }); });此处展示了 Sinon 桩的两项实用能力:withArgs(...)按参数分别配置返回值,calledOnce/callCount验证调用次数。单页场景下getAllUsers只请求一次;两页场景下根据total_pages循环两次,聚合出 5 个用户。
五、桩掉 ES Module 导入:让命名空间可变
5.1 问题根源:ESM 绑定是 live 且不可变的
ES Modules 被静态分析,其绑定按 ECMAScript 规范是live 且 immutable的——命名空间对象的属性不可写、不可配置、不可删除(docs/guides/how-to/stub-esm.md)。因此直接对 ES 模块的具名导出执行sinon.stub会抛出:
TypeError: ES Modules cannot be stubbed复现路径如下。源模块与消费者:
// src/math.mjs export function add(a, b) { return a + b; }// src/calculator.mjs import { add } from "./math.mjs"; export function calculate(a, b) { return add(a, b); }测试会失败:
// test/calculator.test.mjs import sinon from "sinon"; import * as mathModule from "../src/math.mjs"; import { calculate } from "../src/calculator.mjs"; describe("calculator", () => { it("should use the add function", () => { // 这里会抛错:TypeError: ES Modules cannot be stubbed sinon.stub(mathModule, "add").returns(99); }); });Sinon 正确地在此报错——这正是 ESM 规范所要求的属性不可变性。
5.2 解决方案:esm包的mutableNamespace选项
esm包是面向 Node.js 的 ES 模块加载器,其mutableNamespace选项能让模块命名空间对象变得可写,这正是 Sinon 安装桩所需的条件。操作步骤:
Step 1:安装依赖
npm install --save-dev esmStep 2:创建加载器/初始化文件(放在项目根目录,如esm-loader.cjs)
// esm-loader.cjs require = require("esm")(module, { cjs: true, mutableNamespace: true, });注意:
.cjs扩展名(或package.json中不带"type": "module")确保该文件按 CommonJS 处理,这是调用require('esm')所必需的。
Step 3:注册加载器
在package.json的 test 脚本中用--require先加载初始化文件,再启动测试运行器:
{ "scripts": { "test": "mocha --require ./esm-loader.cjs 'test/**/*.test.mjs'" } }Step 4:正常编写测试
// test/calculator.test.mjs import sinon from "sinon"; import * as mathModule from "../src/math.mjs"; import { calculate } from "../src/calculator.mjs"; import assert from "assert"; describe("calculator", () => { afterEach(() => { sinon.restore(); }); it("should delegate to the add function", () => { sinon.stub(mathModule, "add").returns(99); const result = calculate(1, 2); assert.equal(result, 99); assert.ok(mathModule.add.calledOnce); }); });5.3 完整项目布局
. ├── src │ ├── math.mjs │ └── calculator.mjs ├── test │ └── calculator.test.mjs ├── esm-loader.cjs └── package.jsonpackage.json完整示例:
{ "name": "esm-sinon-example", "version": "1.0.0", "scripts": { "test": "mocha --require ./esm-loader.cjs 'test/**/*.test.mjs'" }, "devDependencies": { "esm": "^3.2.25", "mocha": "^10.0.0", "sinon": "*" } }加上esm-loader.cjs、src/math.mjs、src/calculator.mjs以及上面的测试文件,即构成完整可运行示例。测试还可以验证"未桩时调用真实实现"的行为,形成对照:
it("should call the real add function when not stubbed", () => { const result = calculate(3, 4); assert.equal(result, 7); });5.4 为什么能生效,以及注意事项
esm包 hook 进 Node.js 的模块加载系统。当mutableNamespace: true开启时,它用Proxy包装 ES 模块命名空间对象,允许属性赋值;Sinon 的stub()正是替换命名空间对象上的属性,有了 Proxy 后赋值成功而不再抛错。
局限性需牢记:
- 仅对
esm包有效:原生--experimental-vm-modules或其他 loader 默认不支持mutableNamespace; - 转译产物无需此方案:如果 TypeScript/Babel 已把 ESM 编译成 CommonJS,直接走 Stub a dependency 方案;
- 解构导入无法被桩:若被测模块
import { add } from './math.mjs'且以局部绑定使用add,命名空间上的桩不会影响已捕获的绑定——消费者必须通过模块命名空间对象访问导出,桩才会生效; mutableNamespace是非标准行为:它偏离 ESM 规范,应视为测试便利手段而非生产技巧。
相关指南相互衔接:CommonJS link seams 方案 与 TypeScript + SWC 真实案例。
六、案例研究:TypeScript + SWC 下的真实世界依赖桩
6.1 场景与问题
Sinon 只做简单几件事并力求做好:创建并注入测试替身。但在构建管线、转译器与多模块系统并存的今天,"简单的事"会迅速变难。本篇案例(docs/guides/how-to/typescript-swc.md)的选型是:Mocha 驱动测试 + SWC(Rust 实现的高性能转译器)转译 TypeScript,目标模块系统为 CommonJS,目标是能在测试中用 Sinon 替身替换./other模块的导出。
初始代码:
// main.ts import { toBeMocked } from "./other"; export function main() { const out = toBeMocked(); console.log(out); }// other.ts export function toBeMocked() { return "I am the original function"; }// main.spec.ts import sinon from "sinon"; import "./init"; import * as Other from "./other"; import { main } from "./main"; import { expect } from "chai"; const sandbox = sinon.createSandbox(); describe("main", () => { let mocked; it("should mock", () => { mocked = sandbox.stub(Other, "toBeMocked").returns("mocked"); main(); expect(mocked.called).to.be.true; }); });同样的代码用ts-node跑没问题,换成 SWC 后 Mocha 报错:
1) main should mock: TypeError: Descriptor for property toBeMocked is non-configurable and non-writable6.2 定位问题:属性描述符对比
错误信息说明 Sinon 无法处理一个属性描述符近乎不可变的对象。指南建议用console.log打印Object.getOwnPropertyDescriptors(Other)来诊断:
console.log("Other", Other); console.log( "Other property descriptors", Object.getOwnPropertyDescriptors(Other), );SWC 配置运行下的输出:
Other { toBeMocked: [Getter] } Other property descriptors { __esModule: { value: true, writable: false, enumerable: false, configurable: false }, toBeMocked: { get: [Function: get], set: undefined, enumerable: true, configurable: false } }ts-node配置运行下的输出:
Other { toBeMocked: [Function: toBeMocked] } Other property descriptors { __esModule: { value: true, writable: false, enumerable: false, configurable: false }, toBeMocked: { value: [Function: toBeMocked], writable: true, enumerable: true, configurable: true } }关键差异:ts-node下toBeMocked是可写的简单 value;SWC 下它是不可配置的 getter。Getter 本身对 Sinon 不是问题(有很多替换手段),但configurable: false会让 Sinon 束手无策。结论:SWC 把import * as Other from './other'转译成"导出经由不可变访问器暴露"的对象。由此得到三条解决路线:
- 重配 SWC,让测试产物变成可写 value 或可配置 getter;
- 使用纯依赖注入,从内部打开
./other.ts; - 干预模块加载方式,注入额外的
requirehook。
6.3 方案一:修改转译器输出
安装 SWC 插件swc_mut_cjs_exports,在.swcrc的jsc键下加入:
"experimental": { "plugins": [[ "swc_mut_cjs_exports", {} ]] },此后属性描述符变为可配置。由于 getter 与 value 不同,测试代码需改用replaceGetter配合 fake 替换 getter:
const stub = sandbox.fake.returns("mocked"); sandbox.replaceGetter(Other, "toBeMocked", () => stub);6.4 方案二:纯依赖注入
版本 1:全手动模式。此技术不依赖语言、模块系统、打包器与工具链,但需要轻微改动被测系统,且 Sinon 不会自动恢复状态:
// other.ts function _toBeMocked() { return "I am the original function"; } export let toBeMocked = _toBeMocked; export function _setToBeMocked(mockImplementation) { toBeMocked = mockImplementation; }// main.spec.ts describe("main", () => { let mocked; let original = Other.toBeMocked; after(() => Other._setToBeMocked(original)); it("should mock", () => { mocked = sandbox.stub().returns("mocked"); Other._setToBeMocked(mocked); main(); expect(mocked.called).to.be.true; }); });版本 2:借助 Sinon 的自动清理。Sinon 16.1 起获得了"赋值并恢复由访问器(accessor)定义的属性"的能力:只要对外暴露带 setter/getter 的对象,Sinon 就能替你善后:
// other.ts function _toBeMocked() { return "I am the original function"; } export let toBeMocked = _toBeMocked; export const myMocks = { set toBeMocked(mockImplementation) { toBeMocked = mockImplementation; }, get toBeMocked() { return _toBeMocked; }, };// main.spec.ts describe("main", () => { after(() => sandbox.restore()); it("should mock", () => { mocked = sandbox.fake.returns("mocked"); sandbox.replace.usingAccessor(Other.myMocks, 'toBeMocked', mocked); main(); expect(mocked.called).to.be.true; }); });从当前仓库源码可以印证这些 API 的存在与分工:在 src/sinon/sandbox.js 中,sandbox.replace处理普通属性替换,sandbox.replace.usingAccessor(第 394 行附近)面向访问器定义的值,sandbox.replaceGetter/sandbox.replaceSetter则分别针对 getter/setter 属性(第 446、494 行附近),并且对非 getter 属性调用replaceGetter会明确抛错(第 42 行)。这套 API 正是本方案"自动恢复原值"能力的基础。
6.5 方案三:Hook 进 Node 模块加载
这正是 Link seams(CommonJS)指南 的主题,这里把 proxyquire 换成 Quibble——它更简洁,还支持作为 ESM loader 使用。效果如下:
describe("main module", () => { let mocked, main; before(() => { mocked = sandbox.stub().returns("mocked"); quibble("./other", { toBeMocked: mocked }); ({ main } = require("./main")); }); it("should mock", () => { main(); expect(mocked.called).to.be.true; }); });6.6 案例启示
同一目标存在多条路径:改转译输出、纯依赖注入、hook 模块加载,各有取舍。关键在于先理解转译产物与属性描述符的实际情况,再挑选适合自己的组合——这套方法论对其他工具链组合同样适用。
七、总结:如何选择正确的隔离策略
回顾五篇指南,可以提炼出一张决策简表:
| 场景 | 推荐方案 | 关键前提 |
|---|---|---|
| 被测代码依赖定时器 / Promise 延时 | fake timers +tick() | 记得restore();断言注意 Promise 的then()异步语义 |
| Node 环境,模块走 CommonJS | proxyquire/Quibble 等 link seams 工具 | 在beforeEach中注入桩,按测试需要配置返回值 |
| 简单依赖替换,无复杂工具链 | sinon.stub(dep, "method")直接改导出 | 方法不能被解构,需在测试中显式 import 依赖 |
| 被测代码为原生 ESM | esm包 +mutableNamespace | 消费者需经命名空间对象访问导出;非标准手段 |
| TypeScript + SWC 等转译链 | 改转译输出 / 依赖注入 / require hook | 先用getOwnPropertyDescriptors摸清产物属性描述符 |
Sinon 始终聚焦"创建并注入测试替身"这一件事;当模块系统与构建管线带来复杂性时,官方给出的统一心法是:弄清最终产物的真实形态,再从 link seams、依赖注入与模块加载 hook 三条路线中选择可行者。更多 API 细节可继续查阅 docs/concepts 下的分类文档,以及 docs/tests/docs 中与本文示例一一对应的可运行测试。
- 测试
- 开发工具
【免费下载链接】sinon
Test spies, stubs and mocks for JavaScript.
相关推荐
Sinon.JS 实战指南:使用 Fake Timers 高效测试异步函数
Sinon.JS 实战指南:使用 Fake Timers 高效测试异步函数 测试依赖定时器的代码往往耗时且不稳定:一个 setTimeout fn, 1000
测试开发工具yq tag 运算符实战:读取与设置节点类型标签(!!str、!!int、!!bool)
yq tag 运算符实战:读取与设置节点类型标签(!!str、!!int、!!bool) tag 是 yq 中用于读取或设置节点类型标签(YAML Tag,如
测试开发工具Sinon.js 假时间器(Fake Timers)使用指南
Sinon.js 假时间器(Fake Timers)使用指南 项目介绍 Sinon.js 的 Fake Timers 是一个强大的JavaScript库,它模拟
开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考