前端测试框架Jest基础入门

目前最流行的前端测试框架是Jest

Jest 是 Facebook 开发的 Javascript 测试框架,用于创建、运行和编写测试的 JavaScript 库。

Jest 开箱即用,具有强大的模拟功能,能并行执行测试,加快速度,是目前前端最流行的测试库之一。

安装依赖

javascript 复制代码
npm install --save-dev jest

javascript 复制代码
pnpm add --save-dev jest

简单示例

新建一个sum.js文件

javascript 复制代码
function sum(a, b) {
  return a + b;
}
module.exports = sum;

并新建sun.test.js文件,这特殊的后缀是 Jest 的约定,便于查找所有的测试文件。

javascript 复制代码
const sum = require("./sum");

test("adds 1 + 2 to equal 3", () => {
  expect(sum(1, 2)).toBe(3);
});

package.json文件添加

javascript 复制代码
"scripts": {
   "test": "jest"
},

运行命令,jest会查找所有测试文件(".test.js"后缀)并做测试

javascript 复制代码
npm run test

结果如下,可以发现测试通过

![image-20240823162633972](/Users/chenkai/Desktop/:Users:chenkai:Library:Application Support:typora-user-images:image-20240823162633972.png)

ES6 module支持

Jest 默认不支持ES6模块,因此直接使用 Jest 运行测试时会抛出此错误。

如果需要支持ES6模块,则必须添加 babel。

安装babel-jest @babel/preset-env依赖

javascript 复制代码
pnpm add --save-dev babel-jest @babel/preset-env

package.json文件添加

javascript 复制代码
"type": "module",

创建jest.config.js配置文件

javascript 复制代码
export default {
  transform: {
    "^.+\\.[t|j]sx?$": "babel-jest",
  },
};

创建babel.config.json配置文件

javascript 复制代码
{
  "presets": ["@babel/preset-env"]
}

匹配器

Jest 使用"匹配器"让您以不同的方式测试值。

toBe

上面的例子中,.toBe(3)是匹配器,判断"期望"值是否完全相等。

明显单这个匹配器无法满足所有测试需求,jest还提供了很多其他匹配器。

toBe并不是用===测试精确相等性,而是基于Object.is,其二者有以下区别

javascript 复制代码
console.log(+0 === -0); // true
console.log(Object.is(+0, -0)); // false
console.log(NaN === NaN); // false
console.log(Object.is(NaN, NaN)); // true

以下测试会通过

javascript 复制代码
test("toBe", () => {
  expect(NaN).toEqual(NaN);
});

以下测试会失败

javascript 复制代码
test("toBe", () => {
  expect(+0).toEqual(-0);
});

not

可以用not测试匹配器的对立面

javascript 复制代码
test('adding positive numbers is not zero', () => {
  for (let a = 1; a < 10; a++) {
    for (let b = 1; b < 10; b++) {
      expect(a + b).not.toBe(0);
    }
  }
});

toEqual

如果是检测对象或数组的值,会用到toEqualtoEqual会递归检查对象或数组的每个字段。

javascript 复制代码
test("object assignment", () => {
  const data = { a: 1 };
  data["b"] = 2;
  expect(data).toEqual({ a: 1, b: 2 });
});

其实检测普通类型的值也可以用toEqual

javascript 复制代码
test("toEqual", () => {
  const n = 2 + 2;
  const v = "a";
  const a = false;
  expect(n).toEqual(4);
  expect(v).toEqual("a");
  expect(a).toEqual(false);
});

toBeNull

仅匹配null

javascript 复制代码
test("toBeNull", () => {
  const n = null;
  expect(n).toBeNull();
});

toBeUndefined

仅匹配undefined

javascript 复制代码
test("toBeUndefined", () => {
  const n = undefined;
  expect(n).toBeUndefined();
});

toBeDefined

相反的toBeUndefined

javascript 复制代码
test("toBeDefined", () => {
  const a = 1;
  const b = "b";
  const c = false;
  const d = {};
  const e = [];
  const f = undefined;
  expect(a).toBeDefined();
  expect(b).toBeDefined();
  expect(c).toBeDefined();
  expect(d).toBeDefined();
  expect(e).toBeDefined();
  expect(f).not.toBeDefined();
});

toBeTruthy

匹配if视为true的任何内容

javascript 复制代码
test("toBeTruthy", () => {
  const a = 1;
  const b = [];
  const c = false;
  const d = undefined;
  expect(a).toBeTruthy();
  expect(b).toBeTruthy();
  expect(c).not.toBeTruthy();
  expect(d).not.toBeTruthy();
});

toBeFalsy

匹配if视为false的任何内容

javascript 复制代码
test("toBeFalsy", () => {
  const a = 1;
  const b = [];
  const c = false;
  const d = undefined;
  expect(a).not.toBeFalsy();
  expect(b).not.toBeFalsy();
  expect(c).toBeFalsy();
  expect(d).toBeFalsy();
});

toBeGreaterThan

测试"期待"的数字大于指定的值

javascript 复制代码
test("toBeGreaterThan", () => {
  const value = 1 + 3;
  expect(value).toBeGreaterThan(3);
  expect(value).not.toBeGreaterThan(5);
});

toBeGreaterThanOrEqual

测试"期待"的数字大于或等于指定的值

javascript 复制代码
test("toBeGreaterThanOrEqual", () => {
  const value = 1 + 3;
  expect(value).toBeGreaterThanOrEqual(3);
  expect(value).toBeGreaterThanOrEqual(4);
});

toBeLessThan

测试"期待"的数字小于指定的值

javascript 复制代码
test("toBeLessThan", () => {
  const value = 1 + 3;
  expect(value).toBeLessThan(5);
  expect(value).not.toBeLessThan(4);
});

toBeLessThanOrEqual

测试"期待"的数字小于或等于指定的值

javascript 复制代码
test("toBeLessThanOrEqual", () => {
  const value = 1 + 3;
  expect(value).toBeLessThanOrEqual(5);
  expect(value).toBeLessThanOrEqual(4);
});

toBeCloseTo

测试浮点数相等

JavaScript 存在浮点数运算精度丢失的问题。(由于计算机内部使用二进制表示浮点数,而二进制无法精确表示所有的十进制小数,这就导致了所谓的"精度丢失")

javascript 复制代码
console.log(0.1 + 0.2 === 0.3); // false

所以对于浮点数相等,我们应该用toBeCloseTo,而不是toBetoEqual

javascript 复制代码
test("toBeCloseTo", () => {
  const value = 0.1 + 0.2;
  expect(value).not.toBe(0.3);
  expect(value).not.toEqual(0.3);
  expect(value).toBeCloseTo(0.3);
});

toContainEqual

检查具有特定结构和值的项是否包含在数组中。为了测试数组中的项,此匹配器会递归检查所有字段的相等性。

javascript 复制代码
test("toContainEqual", () => {
  const list = [1, 2];
  expect(list).toContainEqual(2);
});

测试异步代码

Promise

在测试中返回一个Promise,Jest 将等待该Promise的状态变为resolve或reject。

javascript 复制代码
const fun = () =>
  new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("a");
    }, 200);
  });

test("the data is a", () => {
  return fun().then((data) => expect(data).toBe("a"));
});

test("the data is a", () => {
  return expect(fun()).resolves.toBe("a");
});
javascript 复制代码
const fun = () =>
  new Promise((resolve, reject) => {
    setTimeout(() => {
      reject("error");
    }, 200);
  });

test("error", () => {
  return fun().catch((data) => expect(data).toBe("error"));
});

test("error", () => {
  return expect(fun()).rejects.toBe("error");
});

Async/Await

javascript 复制代码
const fun = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("a");
  }, 200);
});

test("the data is a", async () => {
  const data = await fun;
  expect(data).toBe("a");
});

test("the data is a", async () => {
  await expect(fun).resolves.toBe("a");
});

回调

也可以使用回调来测试异步代码,使用回调测试异步代码时,需要使用一个名为done的参数。done是一个回调函数,Jest 将等到done回调被调用后才完成测试。

javascript 复制代码
const fetchData = (callback) => {
  setTimeout(() => {
    callback("a");
  }, 200);
};

test("callback", (done) => {
  function callback(data) {
    try {
      expect(data).toBe("a");
      done();
    } catch (error) {
      done(error);
    }
  }

  fetchData(callback);
});

钩子函数

beforeEach

在当前文件中的每一个测试开始之前会调用

javascript 复制代码
let count = 0;
beforeEach(() => {
  count++;
});

test("count is one", () => {
  expect(count).toBe(1);
}); // 测试通过

test("count is two", () => {
  expect(count).toBe(2);
}); // 测试通过

test("count is three", () => {
  expect(count).toBe(3);
}); // 测试通过

afterEach

在当前文件中的每一个测试结束之后会调用

javascript 复制代码
let count = 0;
afterEach(() => {
  count++;
});

test("count is one", () => {
  expect(count).toBe(0);
}); // 测试通过

test("count is two", () => {
  expect(count).toBe(1);
}); // 测试通过

test("count is three", () => {
  expect(count).toBe(2);
}); // 测试通过

beforeAll

在当前文件中的全部测试开始之前进行一次调用,只调用一次

javascript 复制代码
let count = 0;
beforeAll(() => {
  count++;
});

test("count is one", () => {
  expect(count).toBe(1);
}); // 测试通过

test("count is two", () => {
  expect(count).not.toBe(2);
}); // 测试通过

afterAll

在当前文件中的全部测试结束之后进行一次调用,只调用一次

javascript 复制代码
let count = 0;
afterAll(() => {
  count++;
});

test("count is one", () => {
  expect(count).toBe(0);
}); // 测试通过

test("count is two", () => {
  expect(count).not.toBe(1);
}); // 测试通过

describe

顶层的钩子函数适用于文件中的每个测试。在describe块内声明的钩子函数仅适用于该块内的测试。

javascript 复制代码
let count = "a";
beforeEach(() => {
  count = "b";
});

describe("describe", () => {
  beforeEach(() => {
    count = "c";
  });
  test("the data is c", () => {
    expect(count).toBe("c");
  });
});

test("the data is b", () => {
  expect(count).toBe("b");
});

顶层的钩子函数会在describe块内的钩子函数之前执行。

javascript 复制代码
test("", () => console.log("test--1"));

describe("Scoped / Nested block", () => {
  beforeAll(() => console.log("describe - beforeAll"));
  afterAll(() => console.log("describe - afterAll"));
  beforeEach(() => console.log("describe - beforeEach"));
  afterEach(() => console.log("describe - afterEach"));

  test("", () => console.log("describe - test"));
});

beforeEach(() => console.log("beforeEach"));
afterEach(() => console.log("afterEach"));
beforeAll(() => console.log("beforeAll"));
afterAll(() => console.log("afterAll"));

test("", () => console.log("test--2"));

// beforeAll
// beforeEach
// test--1
// afterEach
// describe - beforeAll
// beforeEach
// describe - beforeEach
// describe - test
// describe - afterEach
// afterEach
// describe - afterAll
// beforeEach
// test--2
// afterEach
// afterAll

模拟函数

模拟函数也称为"间谍",因为它们允许您监视由其他代码间接调用的函数的行为,而不仅仅是测试输出。通过jest.fn创建模拟函数,可以捕获函数的调用、参数等数据。如果没有给出实现,则模拟函数将undefined在调用时返回。

创建模拟函数

创建模拟函数可以通过jest.fn()实现。

javascript 复制代码
const mockFn = jest.fn((num) => 7 + num);

console.log(mockFn(1)); // 8
console.log(mockFn(2)); // 9

mockFn.mock

所有模拟函数都有这个特殊.mock属性,其中保存了有关函数如何被调用以及函数返回的数据等等。该.mock属性还跟踪每次调用的值this,因此也可以检查它。

mockFn.mock.calls

包含对此模拟函数进行的所有调用的调用参数的数组

javascript 复制代码
test("fn", () => {
  const myJestFn = jest.fn((a, b) => a + b);
  const result = myJestFn(1, 2);
  myJestFn(result, 3);
  console.log(myJestFn.mock.calls); // [ [ 1, 2 ], [ 3, 3 ] ] 
  // 第一次调用传递的参数是1和2,第二次调用传递的参数是3和3
});

mockFn.mock.lastCall

包含对此模拟函数进行的最后一次调用的调用参数的数组。

javascript 复制代码
test("fn", () => {
  const myJestFn = jest.fn((a, b) => a + b);
  const result = myJestFn(1, 2);
  myJestFn(result, 3);
  // 包含对此模拟函数进行的最后一次调用的调用参数的数组。
  console.log(myJestFn.mock.lastCall); // [3, 3]
});

myJestFn.mock.results

包含对此 mock 函数进行的所有调用的结果的数组。

数组的每一项是一个对象,对象包括

  • type:type有三个值
    • return:表示调用正常返回,完成。
    • throw:表示调用正常返回,完成。
    • incomplete:表示调用尚未完成。
  • value:表示返回的值
type为'return'
javascript 复制代码
test("fn", () => {
  const myJestFn = jest.fn((a, b) => a + b);
  const result = myJestFn(1, 2);
  myJestFn(result, 3);
  
  console.log(myJestFn.mock.results); // [ { type: 'return', value: 3 }, { type: 'return', value: 6 } ]
});
type为'throw'
javascript 复制代码
test("fn", () => {
  const myJestFn = jest.fn(() => {
    throw new Error("This is a mock error");
  });

  // 捕获异常以避免测试直接失败
  try {
    myJestFn();
  } catch (e) {
    // 忽略异常
  }

  console.log(myJestFn.mock.results); 
  /*
  [
      {
        type: 'throw',
        value: Error: This is a mock error
            at ...
            ...
            ...
      }
    ]
  */
});

mockFn.mock.contexts

包含模拟函数所有调用的上下文的数组。上下文是this函数调用时接收的值。

可以使用Function.prototype.bindFunction.prototype.call或来设置上下文Function.prototype.apply

javascript 复制代码
test("fn", () => {
  const AContext = { name: "A" };
  const BContext = { name: "B" };
  const CContext = { name: "C" };

  const mockFn = jest.fn((a) => this.name + a);

  mockFn();
  mockFn.bind(AContext)("a");
  mockFn.call(BContext, "b");
  mockFn.apply(CContext, ["c"]);

  console.log(mockFn.mock.contexts);
  // [ undefined, { name: 'A' }, { name: 'B' }, { name: 'C' } ]
});

mockFn.mock.instances

包含使用此模拟函数实例化的所有对象实例的数组new

javascript 复制代码
test("fn", () => {
  const mockFn = jest.fn();

  const A = new mockFn();
  const B = new mockFn();

  expect(mockFn.mock.instances[0]).toBe(A);
  expect(mockFn.mock.instances[1]).toBe(B);
});

模拟返回值

mockFn.mockReturnValue()

可以使用mockFn.mockReturnValue()设置模拟函数调用时返回的固定值。

javascript 复制代码
test("mockReturnValue", () => {
  const mockFn = jest.fn(() => 10);
  console.log(mockFn()); // 10
  mockFn.mockReturnValue(17);
  console.log(mockFn()); // 17
  console.log(mockFn()); // 17
});

mockFn.mockReturnValueOnce()

如果想要多次调用返回不同的模拟返回值,则可以使用mockFn.mockReturnValueOnce()

javascript 复制代码
test("mockReturnValueOnce", () => {
  const mockFn = jest
    .fn(() => 10)
    .mockReturnValueOnce(17)
    .mockReturnValueOnce(18)
    .mockReturnValueOnce(19);
  console.log(mockFn()); // 17
  console.log(mockFn()); // 18
  console.log(mockFn()); // 19
  console.log(mockFn()); // 10
  console.log(mockFn()); // 10
});

如上面的例子所示,当没有更多的"mockReturnValueOnce"值可用后,将返回原先的返回值。

模拟返回值在实际测试中十分有用,例如当我们在模拟时需要依赖另一个函数,可以使用"mockReturnValue"来模拟依赖函数的返回,从而隔离测试逻辑;或在模拟第三方库时,有时需要模拟第三方库的函数或方法,从而避免实际调用库的逻辑等等

假如有个需要使用axios调用API获取用户数据的逻辑,为了不实际访问API,我们可以使用jest.mock...函数自动模拟axios模块,然后用mockReturnValue返回我们想要测试断言的数据,也就是说我们只需要一个假的响应。

javascript 复制代码
import axios from "axios";
import { fetchUser } from "./userService";

jest.mock("axios");

test("fetchUser returns mock user data", async () => {
  axios.get.mockReturnValue(Promise.resolve({ data: { id: 1, name: "John" } })); // 模拟返回值

  const user = await fetchUser(1);
  expect(user).toEqual({ id: 1, name: "John" });
});

jest.mock 模拟模块

上面例子中用到了jest.mock()jest.mock() 是 Jest 中用于创建模块或文件的模拟(mock)的函数。它允许你在测试中用自定义实现或伪造模块替换实际的依赖模块,方便隔离测试逻辑,控制模块行为。

jest.mock(moduleName, factory, options)

  • moduleName: 要模拟的模块的名称或路径。
  • factory (可选): 一个返回模块模拟实现的函数。
  • options (可选): 配置模拟的选项,例如是否自动清除模拟。

默认情况下,jest.mock() 会为指定模块创建一个空的自动模拟对象。

javascript 复制代码
jest.mock('axios'); // 自动创建模拟

import axios from 'axios';

可以通过工厂函数为模拟模块提供自定义实现。

javascript 复制代码
// foo.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
javascript 复制代码
import foo from "./foo.js";

jest.mock("./foo.js", () => ({
  add: jest.fn(() => 42),
  subtract: jest.fn(() => -1),
}));

test("mock", () => {
  expect(foo.add(1, 2)).toBe(42);
  expect(foo.subtract(3, 2)).toBe(-1);
});

也可以只部分模拟部分

javascript 复制代码
// foo.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export default () => "foo";
javascript 复制代码
import foo, { add, subtract } from "./foo.js";

jest.mock("./foo.js", () => {
  const actual = jest.requireActual("./foo.js"); // 通过jest.requireActual 引用实际模块
  return {
    __esModule: true,
    ...actual,
    default: jest.fn(() => "mocked foo"),
    subtract: jest.fn(() => -1),
  };
});

test("mock", () => {
  expect(foo()).toBe("mocked foo");
  expect(add(1, 2)).toBe(3);
  expect(subtract(3, 2)).toBe(-1);
});

模拟实现

除了上面的模拟返回值,有些情况下,超越指定返回值的能力并完全替换模拟函数的实现是有用的。

mockFn.mockImplementation(fn)

mockFn.mockImplementation(fn),接受一个应该用作模拟实现的函数。模拟本身仍将记录进入它的所有调用和来自它自身的实例。

javascript 复制代码
test("mockFn.mockImplementation", () => {
  const mockFn = jest.fn((num) => {
    return 7 + num;
  });

  console.log(mockFn(1)); // 8
  console.log(mockFn(2)); // 9

  mockFn.mockImplementation((num) => 17 + num);

  console.log(mockFn(1)); // 18
  console.log(mockFn(2)); // 19
});

当你需要定义从另一个模块创建的模拟函数的默认实现时,该方法很有用。

javascript 复制代码
// foo.js
module.exports = function () {
  return "foo";
};
javascript 复制代码
test("foo.mockImplementation", () => {
  jest.mock("./foo");
  const foo = require("./foo");

  foo.mockImplementation(() => "bar");

  console.log(foo()); // bar
});

mockFn.mockImplementationOnce(fn)

当你需要重新创建模拟函数的复杂行为,使得多个函数调用产生不同的结果时,可以使用mockFn.mockImplementationOnce(fn),其接受一个函数,该函数将用作对模拟函数的一次调用的模拟实现。可以链接起来,以便多个函数调用产生不同的结果。

javascript 复制代码
test("mockFn.mockImplementationOnce", () => {
  const myMockFn = jest
    .fn(() => "default")
    .mockImplementationOnce(() => "first call")
    .mockImplementationOnce(() => "second call");
  console.log(myMockFn()); // first call
  console.log(myMockFn()); // second call
  console.log(myMockFn()); // default
});

匹配器

为了减少断言模拟函数如何被调用的难度,jest为模拟函数提供了一些方便的匹配器

toHaveBeenCalled

用于确保模拟函数至少被调用一次

javascript 复制代码
test("toHaveBeenCalled", () => {
  const myMockFn = jest.fn((a, b) => a + b);
  expect(myMockFn).not.toHaveBeenCalled();
  myMockFn(1, 2);
  expect(myMockFn).toHaveBeenCalled();
  // 也可以用以下的简化版(.mock)写法,效果一样
  expect(myMockFn.mock.calls.length).toBeGreaterThan(0);
});

toHaveBeenCalledWith

用于确保模拟函数使用指定的参数至少调用模拟函数一次

javascript 复制代码
test("toHaveBeenCalledWith", () => {
  const myMockFn = jest.fn((a, b) => a + b);
  myMockFn(1, 2);
  expect(myMockFn).not.toHaveBeenCalledWith(1, 3);
  expect(myMockFn).toHaveBeenCalledWith(1, 2);
  // 也可以用以下的简化版(.mock)写法,效果一样
  expect(myMockFn.mock.calls).toContainEqual([1, 2]);
});

toHaveBeenLastCalledWith

用于确保对mock函数的最后一次调用是使用指定的参数调用的

javascript 复制代码
test("toHaveBeenLastCalledWith", () => {
  const myMockFn = jest.fn((a, b) => a + b);
  myMockFn(1, 2);
  myMockFn(3, 4);
  expect(myMockFn).toHaveBeenLastCalledWith(3, 4);
  // 也可以用以下的简化版(.mock)写法,效果一样
  expect(myMockFn.mock.calls[myMockFn.mock.calls.length - 1]).toEqual([3, 4]);
});

持续监听

为了提高效率,可以在jest命令添加启动参数的方式让jest持续监听文件的修改而触发执行测试用例,这样就不用每次修改完再重新执行测试用例了。

只需要修改以下命令

javascript 复制代码
"scripts": {
  "test": "jest --watchAll"
},

这样运行后,就会

生成测试覆盖率报告

单元测试覆盖率(Unit Test Coverage)是一个指标,用来衡量你的代码在单元测试中被测试的程度。它帮助开发者了解在项目中哪些代码已经被测试过,哪些代码可能还存在未测试的风险区域。

测试覆盖率通常以百分比表示。它基于代码的不同维度来计算,例如:语句覆盖率、分支覆盖率、函数覆盖率等。

其中最基础的计算方式为 单元测试覆盖率 = 被测代码行数 / 参测代码总行数 * 100%

只需要在jest.config.js中添加显示覆盖率报告的配置即可

javascript 复制代码
module.exports = {
  collectCoverage: true,
};

运行后如下

图中每个参数的含义如下

  • % Stmts:语句覆盖率,确保每个语句都执行了
  • % Branch:分支覆盖率,确保每个 if 代码块都执行了
  • % Funcs:函数覆盖率,确保每个函数都调用了
  • % Lines :行覆盖率,确保每一行都执行了

也可以在jest.config.js中设置每个参数的覆盖率阀值,来提示用户是否测试质量达标。

javascript 复制代码
module.exports = {
  collectCoverage: true,
  coverageThreshold: {
    global: {
      statements: 90, // 保证所有语句至少执行了90%
      functions: 90, // 保证所有函数至少调用了90%
      branches: 90, // 保证所有分支至少执行了90%
      lines: 90, // 保证所有代码行至少执行了90%
    },
  },
};

运行结果如下

相关推荐
辻戋2 小时前
从零实现React Scheduler调度器
前端·react.js·前端框架
徐同保2 小时前
使用yarn@4.6.0装包,项目是react+vite搭建的,项目无法启动,报错:
前端·react.js·前端框架
Qrun3 小时前
Windows11安装nvm管理node多版本
前端·vscode·react.js·ajax·npm·html5
中国lanwp3 小时前
全局 npm config 与多环境配置
前端·npm·node.js
JELEE.4 小时前
Django登录注册完整代码(图片、邮箱验证、加密)
前端·javascript·后端·python·django·bootstrap·jquery
TeleostNaCl6 小时前
解决 Chrome 无法访问网页但无痕模式下可以访问该网页 的问题
前端·网络·chrome·windows·经验分享
前端大卫7 小时前
为什么 React 中的 key 不能用索引?
前端
你的人类朋友7 小时前
【Node】手动归还主线程控制权:解决 Node.js 阻塞的一个思路
前端·后端·node.js
小李小李不讲道理9 小时前
「Ant Design 组件库探索」五:Tabs组件
前端·react.js·ant design
毕设十刻9 小时前
基于Vue的学分预警系统98k51(程序 + 源码 + 数据库 + 调试部署 + 开发环境配置),配套论文文档字数达万字以上,文末可获取,系统界面展示置于文末
前端·数据库·vue.js