前端测试框架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%
    },
  },
};

运行结果如下

相关推荐
XDU小迷弟34 分钟前
第30天:PHP应用&组件框架&前端模版渲染&三方插件&富文本编辑器&CVE审计
开发语言·前端·网络安全·php
明月看潮生36 分钟前
青少年编程与数学 02-006 前端开发框架VUE 09课题、计算属性
前端·javascript·vue.js·青少年编程·编程与数学
布兰妮甜1 小时前
Three.js - 打开Web 3D世界的大门
前端·javascript·3d·动画·three.js
小皮虾1 小时前
几行代码封装,让小程序云函数变为真正云函数,开发体验直接起飞
前端·javascript·微信小程序
Traced back1 小时前
在vue3项目中利用自定义ref实现防抖
前端·javascript·vue.js
木易66丶2 小时前
Vue中el-tree结合vuedraggable实现跨组件元素拖拽
前端·笔记
黑客KKKing2 小时前
网络安全-web应用程序发展历程(基础篇)
前端·安全·web安全
长风清留扬2 小时前
小程序开发-页面事件之上拉触底实战案例
前端·javascript·css·ios·微信小程序·小程序·html
还是大剑师兰特2 小时前
面试题: 对象继承的方式有哪些
开发语言·javascript·原型模式
时间sk2 小时前
CSS——25.伪元素1(“::first-letter ,::first-line ”)
前端·javascript·css