Pixiv - KiraraShss
Vitest 测试实战:现代前端单元测试的最佳实践
3338 字
17 分钟
Vitest 测试实战:现代前端单元测试的最佳实践
前言
测试是前端工程化的基石,但长期以来,前端测试体验并不好——Jest 配置繁琐、转换慢、ESM 支持差。Vitest 的出现彻底改变了这个局面。它基于 Vite 构建,天然支持 ESM、TypeScript、JSX,零配置即可开箱使用,而且速度快得惊人。
本文将深入 Vitest 的实战使用,涵盖与 Jest 的对比、快照测试、组件测试、Mock 策略、覆盖率分析和 CI 集成,帮助你建立一套现代化的前端测试体系。
一、Vitest vs Jest:为什么要迁移?
1.1 核心差异对比
| 特性 | Jest | Vitest |
|---|---|---|
| 模块系统 | CJS 为主,ESM 实验性 | 原生 ESM |
| TypeScript | 需要 ts-jest/babel | 原生支持(esbuild) |
| 配置共享 | 独立配置 | 复用 vite.config.ts |
| 热更新 | 无 | Watch 模式极快 |
| 执行速度 | 中等 | 极快(Vite 的按需编译) |
| 兼容性 | Jest API | 兼容 Jest API |
1.2 速度差异的本质原因
Jest 的慢在于它的转换管道:每个文件都需要通过 Babel/ts-jest 转换,然后在 Node.js 的 CJS 环境中执行。而 Vitest 直接复用 Vite 的开发服务器能力——按需编译、原生 ESM、esbuild 转换。
Jest 执行流程:源文件 → Babel/ts-jest 转换 → CJS 模块 → Node.js 执行(每个文件都要完整转换)
Vitest 执行流程:源文件 → esbuild 快速转换 → ESM 模块 → Vite 的模块图按需加载(只转换用到的模块,增量更新)1.3 快速上手
# 安装npm install -D vitest @vitest/coverage-v8
# 如果需要 UI 界面npm install -D @vitest/ui// vite.config.ts - Vitest 配置直接写在 Vite 配置中/// <reference types="vitest/config" />import { defineConfig } from 'vite';import vue from '@vitejs/plugin-vue';
export default defineConfig({ plugins: [vue()], test: { globals: true, // 全局注入 describe/it/expect environment: 'jsdom', // DOM 环境 include: ['src/**/*.{test,spec}.{ts,js}'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], include: ['src/**/*.ts'], exclude: ['src/**/*.d.ts', 'src/**/*.test.ts'], }, // 测试超时 testTimeout: 10000, // 开启多线程 pool: 'threads', },});{ "scripts": { "test": "vitest", "test:run": "vitest run", "test:coverage": "vitest run --coverage", "test:ui": "vitest --ui" }}二、测试编写基础
2.1 基础断言
Vitest 的断言 API 与 Jest 完全兼容,同时还扩展了更多能力:
export function add(a: number, b: number): number { return a + b;}
export function divide(a: number, b: number): number { if (b === 0) throw new Error('Division by zero'); return a / b;}
export function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max);}
export function isEven(n: number): boolean { return n % 2 === 0;}import { describe, it, expect } from 'vitest';import { add, divide, clamp, isEven } from './math';
describe('math utils', () => { describe('add', () => { it('should add two positive numbers', () => { expect(add(1, 2)).toBe(3); });
it('should handle negative numbers', () => { expect(add(-1, -2)).toBe(-3); expect(add(-1, 2)).toBe(1); });
it('should handle floating point', () => { // 注意浮点精度 expect(add(0.1, 0.2)).toBeCloseTo(0.3, 10); }); });
describe('divide', () => { it('should divide two numbers', () => { expect(divide(10, 2)).toBe(5); });
it('should throw on division by zero', () => { expect(() => divide(10, 0)).toThrowError('Division by zero'); });
// 使用 each 进行参数化测试 it.each([ [10, 2, 5], [9, 3, 3], [100, 10, 10], [7, 2, 3.5], ])('divide(%i, %i) = %f', (a, b, expected) => { expect(divide(a, b)).toBe(expected); }); });
describe('clamp', () => { it('should return value when within range', () => { expect(clamp(5, 0, 10)).toBe(5); });
it('should clamp to min', () => { expect(clamp(-5, 0, 10)).toBe(0); });
it('should clamp to max', () => { expect(clamp(15, 0, 10)).toBe(10); }); });});2.2 异步测试
export interface User { id: number; name: string; email: string;}
export async function fetchUser(id: number): Promise<User> { const response = await fetch(`/api/users/${id}`); if (!response.ok) { throw new Error(`User not found: ${id}`); } return response.json();}
export function fetchUserWithCallback( id: number, callback: (err: Error | null, user?: User) => void): void { fetch(`/api/users/${id}`) .then(res => { if (!res.ok) throw new Error(`User not found: ${id}`); return res.json(); }) .then(user => callback(null, user)) .catch(err => callback(err));}import { describe, it, expect, vi, beforeEach } from 'vitest';import { fetchUser } from './user';
// Mock fetchconst mockFetch = vi.fn();vi.stubGlobal('fetch', mockFetch);
describe('fetchUser', () => { beforeEach(() => { mockFetch.mockReset(); });
it('should fetch user successfully', async () => { const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUser), });
const user = await fetchUser(1);
expect(user).toEqual(mockUser); expect(mockFetch).toHaveBeenCalledWith('/api/users/1'); });
it('should throw on non-ok response', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
await expect(fetchUser(999)).rejects.toThrowError('User not found: 999'); });
it('should handle network errors', async () => { mockFetch.mockRejectedValueOnce(new TypeError('Network error'));
await expect(fetchUser(1)).rejects.toThrowError('Network error'); });});三、快照测试
3.1 基础快照
快照测试非常适合验证输出结构不会意外改变:
export interface FormattedOutput { title: string; body: string; meta: Record<string, string>;}
export function formatArticle(raw: string, author: string): FormattedOutput { const lines = raw.trim().split('\n'); const title = lines[0].replace(/^#\s*/, ''); const body = lines.slice(1).join('\n').trim();
return { title, body, meta: { author, wordCount: String(body.length), createdAt: new Date().toISOString().split('T')[0], }, };}import { describe, it, expect, vi } from 'vitest';import { formatArticle } from './formatter';
describe('formatArticle', () => { // 冻结时间以确保快照稳定 beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-04-05')); });
afterEach(() => { vi.useRealTimers(); });
it('should format article correctly', () => { const raw = `# Hello WorldThis is the body of the article.It has multiple lines.`;
// 内联快照 - 直接写在测试文件中 expect(formatArticle(raw, 'Alice')).toMatchInlineSnapshot(` { "body": "This is the body of the article. It has multiple lines.", "meta": { "author": "Alice", "createdAt": "2026-04-05", "wordCount": "50", }, "title": "Hello World", } `); });
it('should match file snapshot', () => { const raw = `# TestBody content`;
// 文件快照 - 保存到 __snapshots__ 目录 expect(formatArticle(raw, 'Bob')).toMatchSnapshot(); });});3.2 自定义序列化器
import { expect } from 'vitest';
// 自定义快照序列化器 - 隐藏动态字段expect.addSnapshotSerializer({ serialize(val: any, config, indentation, depth, refs, printer) { // 将日期字符串替换为占位符 const sanitized = JSON.parse(JSON.stringify(val, (key, value) => { if (key === 'createdAt' || key === 'updatedAt') { return '[DATE]'; } if (key === 'id' && typeof value === 'string' && value.length > 20) { return '[UUID]'; } return value; })); return printer(sanitized, config, indentation, depth, refs); }, test(val) { return val && typeof val === 'object' && ('createdAt' in val || 'id' in val); },});四、Mock 策略深入
4.1 模块 Mock
import { sendEmail } from './email';import { sendSMS } from './sms';import { logger } from './logger';
export async function notifyUser( userId: string, message: string, channels: ('email' | 'sms')[]): Promise<{ success: boolean; channels: string[] }> { const sent: string[] = [];
for (const channel of channels) { try { if (channel === 'email') { await sendEmail(userId, message); sent.push('email'); } else if (channel === 'sms') { await sendSMS(userId, message); sent.push('sms'); } } catch (err) { logger.error(`Failed to send ${channel} to ${userId}`, err); } }
return { success: sent.length > 0, channels: sent };}import { describe, it, expect, vi, beforeEach } from 'vitest';import { notifyUser } from './notification';
// Mock 整个模块vi.mock('./email', () => ({ sendEmail: vi.fn().mockResolvedValue(undefined),}));
vi.mock('./sms', () => ({ sendSMS: vi.fn().mockResolvedValue(undefined),}));
vi.mock('./logger', () => ({ logger: { error: vi.fn(), info: vi.fn(), },}));
// 导入被 mock 的模块以进行断言import { sendEmail } from './email';import { sendSMS } from './sms';import { logger } from './logger';
describe('notifyUser', () => { beforeEach(() => { vi.clearAllMocks(); });
it('should send email notification', async () => { const result = await notifyUser('user-1', 'Hello!', ['email']);
expect(result).toEqual({ success: true, channels: ['email'] }); expect(sendEmail).toHaveBeenCalledWith('user-1', 'Hello!'); expect(sendSMS).not.toHaveBeenCalled(); });
it('should send both email and sms', async () => { const result = await notifyUser('user-1', 'Hello!', ['email', 'sms']);
expect(result).toEqual({ success: true, channels: ['email', 'sms'] }); expect(sendEmail).toHaveBeenCalledOnce(); expect(sendSMS).toHaveBeenCalledOnce(); });
it('should handle partial failure gracefully', async () => { // email 失败,sms 成功 vi.mocked(sendEmail).mockRejectedValueOnce(new Error('SMTP error'));
const result = await notifyUser('user-1', 'Hello!', ['email', 'sms']);
expect(result).toEqual({ success: true, channels: ['sms'] }); expect(logger.error).toHaveBeenCalledWith( 'Failed to send email to user-1', expect.any(Error) ); });
it('should return failure when all channels fail', async () => { vi.mocked(sendEmail).mockRejectedValueOnce(new Error('SMTP error')); vi.mocked(sendSMS).mockRejectedValueOnce(new Error('SMS quota exceeded'));
const result = await notifyUser('user-1', 'Hello!', ['email', 'sms']);
expect(result).toEqual({ success: false, channels: [] }); expect(logger.error).toHaveBeenCalledTimes(2); });});4.2 Spy 与部分 Mock
export class StorageService { private prefix: string;
constructor(prefix = 'app') { this.prefix = prefix; }
private getKey(key: string): string { return `${this.prefix}:${key}`; }
set(key: string, value: unknown): void { const serialized = JSON.stringify(value); localStorage.setItem(this.getKey(key), serialized); }
get<T>(key: string): T | null { const raw = localStorage.getItem(this.getKey(key)); if (raw === null) return null; try { return JSON.parse(raw) as T; } catch { return null; } }
remove(key: string): void { localStorage.removeItem(this.getKey(key)); }
clear(): void { const keys = Object.keys(localStorage); for (const key of keys) { if (key.startsWith(`${this.prefix}:`)) { localStorage.removeItem(key); } } }}import { describe, it, expect, vi, beforeEach } from 'vitest';import { StorageService } from './storage';
describe('StorageService', () => { let storage: StorageService;
beforeEach(() => { // 使用 jsdom 的 localStorage localStorage.clear(); storage = new StorageService('test'); });
it('should set and get values', () => { storage.set('user', { name: 'Alice', age: 30 });
const user = storage.get<{ name: string; age: number }>('user'); expect(user).toEqual({ name: 'Alice', age: 30 }); });
it('should return null for missing keys', () => { expect(storage.get('nonexistent')).toBeNull(); });
it('should handle invalid JSON gracefully', () => { // 直接写入非法 JSON localStorage.setItem('test:broken', '{invalid json}'); expect(storage.get('broken')).toBeNull(); });
it('should use spy to verify localStorage calls', () => { const setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
storage.set('key', 'value');
expect(setItemSpy).toHaveBeenCalledWith('test:key', '"value"'); setItemSpy.mockRestore(); });
it('should clear only prefixed keys', () => { storage.set('a', 1); storage.set('b', 2); localStorage.setItem('other:c', '3'); // 不同前缀
storage.clear();
expect(storage.get('a')).toBeNull(); expect(storage.get('b')).toBeNull(); expect(localStorage.getItem('other:c')).toBe('3'); });});4.3 Timer Mock
export function debounce<T extends (...args: any[]) => any>( fn: T, delay: number): (...args: Parameters<T>) => void { let timer: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => { if (timer !== null) { clearTimeout(timer); } timer = setTimeout(() => { fn(...args); timer = null; }, delay); };}import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';import { debounce } from './debounce';
describe('debounce', () => { beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('should delay function execution', () => { const fn = vi.fn(); const debounced = debounce(fn, 300);
debounced(); expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(299); expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(1); expect(fn).toHaveBeenCalledOnce(); });
it('should reset timer on subsequent calls', () => { const fn = vi.fn(); const debounced = debounce(fn, 300);
debounced(); vi.advanceTimersByTime(200); debounced(); // 重置计时器 vi.advanceTimersByTime(200);
expect(fn).not.toHaveBeenCalled(); // 还没到 300ms
vi.advanceTimersByTime(100); expect(fn).toHaveBeenCalledOnce(); });
it('should pass arguments correctly', () => { const fn = vi.fn(); const debounced = debounce(fn, 100);
debounced('hello', 42); vi.runAllTimers();
expect(fn).toHaveBeenCalledWith('hello', 42); });});五、Vue 组件测试
5.1 环境搭建
npm install -D @vue/test-utils @vitejs/plugin-vue<script setup lang="ts">import { ref, computed } from 'vue';
const props = withDefaults(defineProps<{ initial?: number; min?: number; max?: number;}>(), { initial: 0, min: -Infinity, max: Infinity,});
const emit = defineEmits<{ change: [value: number];}>();
const count = ref(props.initial);
const canDecrement = computed(() => count.value > props.min);const canIncrement = computed(() => count.value < props.max);
function increment() { if (canIncrement.value) { count.value++; emit('change', count.value); }}
function decrement() { if (canDecrement.value) { count.value--; emit('change', count.value); }}</script>
<template> <div class="counter"> <button data-testid="decrement" :disabled="!canDecrement" @click="decrement" > - </button> <span data-testid="count">{{ count }}</span> <button data-testid="increment" :disabled="!canIncrement" @click="increment" > + </button> </div></template>import { describe, it, expect } from 'vitest';import { mount } from '@vue/test-utils';import Counter from './Counter.vue';
describe('Counter', () => { it('should render with default initial value', () => { const wrapper = mount(Counter); expect(wrapper.get('[data-testid="count"]').text()).toBe('0'); });
it('should render with custom initial value', () => { const wrapper = mount(Counter, { props: { initial: 10 }, }); expect(wrapper.get('[data-testid="count"]').text()).toBe('10'); });
it('should increment on click', async () => { const wrapper = mount(Counter);
await wrapper.get('[data-testid="increment"]').trigger('click'); expect(wrapper.get('[data-testid="count"]').text()).toBe('1'); });
it('should decrement on click', async () => { const wrapper = mount(Counter, { props: { initial: 5 } });
await wrapper.get('[data-testid="decrement"]').trigger('click'); expect(wrapper.get('[data-testid="count"]').text()).toBe('4'); });
it('should emit change event', async () => { const wrapper = mount(Counter);
await wrapper.get('[data-testid="increment"]').trigger('click');
expect(wrapper.emitted('change')).toHaveLength(1); expect(wrapper.emitted('change')![0]).toEqual([1]); });
it('should respect max limit', async () => { const wrapper = mount(Counter, { props: { initial: 2, max: 3 }, });
await wrapper.get('[data-testid="increment"]').trigger('click'); // 3 await wrapper.get('[data-testid="increment"]').trigger('click'); // still 3
expect(wrapper.get('[data-testid="count"]').text()).toBe('3'); expect( wrapper.get('[data-testid="increment"]').attributes('disabled') ).toBeDefined(); });
it('should respect min limit', async () => { const wrapper = mount(Counter, { props: { initial: 1, min: 0 }, });
await wrapper.get('[data-testid="decrement"]').trigger('click'); // 0 await wrapper.get('[data-testid="decrement"]').trigger('click'); // still 0
expect(wrapper.get('[data-testid="count"]').text()).toBe('0'); expect( wrapper.get('[data-testid="decrement"]').attributes('disabled') ).toBeDefined(); });});5.2 测试含有 Pinia Store 的组件
import { defineStore } from 'pinia';import { ref, computed } from 'vue';
export interface Todo { id: number; text: string; done: boolean;}
export const useTodoStore = defineStore('todo', () => { const todos = ref<Todo[]>([]); let nextId = 1;
const remaining = computed(() => todos.value.filter(t => !t.done).length);
function addTodo(text: string) { todos.value.push({ id: nextId++, text, done: false }); }
function toggleTodo(id: number) { const todo = todos.value.find(t => t.id === id); if (todo) todo.done = !todo.done; }
function removeTodo(id: number) { todos.value = todos.value.filter(t => t.id !== id); }
return { todos, remaining, addTodo, toggleTodo, removeTodo };});import { describe, it, expect, beforeEach } from 'vitest';import { setActivePinia, createPinia } from 'pinia';import { useTodoStore } from './todo';
describe('Todo Store', () => { beforeEach(() => { // 每个测试创建新的 pinia 实例,确保隔离 setActivePinia(createPinia()); });
it('should start empty', () => { const store = useTodoStore(); expect(store.todos).toHaveLength(0); expect(store.remaining).toBe(0); });
it('should add todo', () => { const store = useTodoStore(); store.addTodo('Buy milk');
expect(store.todos).toHaveLength(1); expect(store.todos[0]).toMatchObject({ text: 'Buy milk', done: false }); expect(store.remaining).toBe(1); });
it('should toggle todo', () => { const store = useTodoStore(); store.addTodo('Buy milk');
store.toggleTodo(store.todos[0].id); expect(store.todos[0].done).toBe(true); expect(store.remaining).toBe(0);
store.toggleTodo(store.todos[0].id); expect(store.todos[0].done).toBe(false); expect(store.remaining).toBe(1); });
it('should remove todo', () => { const store = useTodoStore(); store.addTodo('A'); store.addTodo('B');
const idToRemove = store.todos[0].id; store.removeTodo(idToRemove);
expect(store.todos).toHaveLength(1); expect(store.todos[0].text).toBe('B'); });});六、覆盖率分析
6.1 配置覆盖率
export default defineConfig({ test: { coverage: { provider: 'v8', reporter: ['text', 'json-summary', 'html', 'lcov'], include: ['src/**/*.{ts,vue}'], exclude: [ 'src/**/*.d.ts', 'src/**/*.test.ts', 'src/**/*.spec.ts', 'src/main.ts', 'src/App.vue', ], // 覆盖率阈值 - 低于这些值 CI 会失败 thresholds: { branches: 80, functions: 80, lines: 80, statements: 80, }, // 是否统计未被测试文件导入的源文件 all: true, }, },});6.2 覆盖率报告解读
运行 vitest run --coverage 后会输出类似:
% Coverage report from v8------------------------------|---------|----------|---------|---------|File | % Stmts | % Branch | % Funcs | % Lines |------------------------------|---------|----------|---------|---------|All files | 92.31 | 87.50 | 90.00 | 92.31 | src/utils/math.ts | 100.00 | 100.00 | 100.00 | 100.00 | src/utils/debounce.ts | 100.00 | 100.00 | 100.00 | 100.00 | src/utils/storage.ts | 85.71 | 75.00 | 80.00 | 85.71 | src/services/notification.ts | 88.89 | 83.33 | 100.00 | 88.89 |------------------------------|---------|----------|---------|---------|关键指标:
- Statements(语句):每条语句是否被执行
- Branches(分支):if/else、switch 等分支是否都走到
- Functions(函数):每个函数是否被调用
- Lines(行):每一行是否被执行
七、CI 集成
7.1 GitHub Actions 配置
name: Test
on: push: branches: [main, develop] pull_request: branches: [main]
jobs: test: runs-on: ubuntu-latest
strategy: matrix: node-version: [18, 20, 22]
steps: - uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm'
- name: Install dependencies run: npm ci
- name: Run tests with coverage run: npx vitest run --coverage --reporter=json --outputFile=test-results.json
- name: Upload coverage to Codecov if: matrix.node-version == 20 uses: codecov/codecov-action@v4 with: files: ./coverage/lcov.info fail_ci_if_error: true
- name: Check coverage thresholds run: npx vitest run --coverage --coverage.thresholds.100=false7.2 预提交钩子
npm install -D husky lint-staged{ "lint-staged": { "src/**/*.{ts,vue}": [ "vitest related --run" ] }}npx lint-stagedvitest related --run 会自动找出与修改文件相关的测试并执行,避免每次提交都跑全量测试。
八、高级技巧
8.1 测试工厂函数
当多个测试需要相似的数据结构时,使用工厂函数避免重复:
import type { User } from '@/types';
let idCounter = 0;
export function createUser(overrides: Partial<User> = {}): User { return { id: ++idCounter, name: `User ${idCounter}`, email: `user${idCounter}@example.com`, role: 'member', createdAt: new Date().toISOString(), ...overrides, };}
// 使用it('should format admin user differently', () => { const admin = createUser({ role: 'admin', name: 'Admin Alice' }); expect(formatUserBadge(admin)).toContain('⭐');});8.2 并发测试
// 使用 concurrent 标记可并发执行的测试describe.concurrent('independent tests', () => { it('test 1', async () => { await new Promise(r => setTimeout(r, 100)); expect(1 + 1).toBe(2); });
it('test 2', async () => { await new Promise(r => setTimeout(r, 100)); expect(2 + 2).toBe(4); });
// 这两个测试会并行执行,总耗时约 100ms 而非 200ms});8.3 类型测试
Vitest 支持直接测试 TypeScript 类型:
import { describe, it, expectTypeOf } from 'vitest';import { add, clamp } from './utils/math';
describe('type tests', () => { it('add should accept numbers and return number', () => { expectTypeOf(add).toBeFunction(); expectTypeOf(add).parameter(0).toBeNumber(); expectTypeOf(add).returns.toBeNumber(); });
it('clamp should require three number params', () => { expectTypeOf(clamp).parameters.toEqualTypeOf<[number, number, number]>(); });});运行类型测试:vitest typecheck
总结
Vitest 不仅仅是”更快的 Jest”——它代表了前端测试的现代化方向:
- 零配置:与 Vite 深度集成,无需额外的转换器和配置
- 原生 ESM:告别 CJS 的兼容性噩梦
- 极致速度:Watch 模式下几乎即时反馈
- 完整生态:覆盖率、UI、类型测试一应俱全
- Jest 兼容:迁移成本极低
如果你的项目已经在使用 Vite,迁移到 Vitest 几乎是无脑的选择。即使不用 Vite,Vitest 独立运行的体验也远超 Jest。写测试应该是愉快的,而不是负担——Vitest 做到了。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
Vitest 测试实战:现代前端单元测试的最佳实践
https://boke.hackerdream.xyz/posts/vitest-testing-patterns/ 相关文章 智能推荐
1
Rspack 深度解析:Rust 构建工具如何重塑前端工程化格局
工程化 深入剖析 Rspack 的架构设计、性能优势与 AI 辅助迁移实践,对比 Webpack/Vite 的构建方案选型指南。
2
Astro 博客在 4G 内存服务器上的构建优化实战
工程化与工具 2026-03-25
3
Astro Islands 架构深入:选择性注水的革命性方案
工程化与工具 2026-03-23
4
esbuild 深入:为什么它比 Webpack 快 100 倍
工程化与工具 2026-03-12
5
Vite 插件开发实战:从零手写一个自动导入插件
工程化与工具 2026-03-09
随机文章 随机推荐