普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月31日技术

type-challenges(ts类型体操): 15 - 最后一个元素

作者 fxss
2026年1月31日 13:49

15 - 最后一个元素

by Anthony Fu (@antfu) #中等 #array

题目

在此挑战中建议使用TypeScript 4.0

实现一个Last<T>泛型,它接受一个数组T并返回其最后一个元素的类型。

例如

type arr1 = ['a', 'b', 'c']
type arr2 = [3, 2, 1]

type tail1 = Last<arr1> // 应推导出 'c'
type tail2 = Last<arr2> // 应推导出 1

在 Github 上查看:tsch.js.org/15/zh-CN

代码

/* _____________ 你的代码 _____________ */

type Last<T extends any[]> = T extends [...infer _, infer R] ? R : never

关键解释:

  • T extends [...infer _, infer R]:通过 infer 提取数组的最后一个元素 R
  • ? R : never:如果数组非空,返回 R;否则返回 never

相关知识点

extends

使用维度 核心作用 示例场景
类型维度 做类型约束或条件判断(类型编程核心) 限定泛型范围、判断类型是否兼容、提取类型片段
语法维度 做继承(复用已有结构) 接口继承、类继承
extends 做类型约束或条件判断
  1. 泛型约束:限定泛型的取值范围
// 约束 T 必须是「拥有 length 属性」的类型(比如 string/数组)
function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

// 合法调用(符合约束)
getLength("hello"); // ✅ string 有 length,返回 5
getLength([1, 2, 3]); // ✅ 数组有 length,返回 3

// 非法调用(超出约束)
getLength(123); // ❌ 报错:number 没有 length 属性
  1. 条件类型:类型版 三元运算符
// 基础示例:判断类型是否为字符串
type IsString<T> = T extends string ? true : false;

type A = IsString<"test">; // true(符合)
type B = IsString<123>; // false(不符合)

分布式条件类型(联合类型专用): 当 T 是联合类型时,extends 会自动拆分联合类型的每个成员,逐个判断后再合并结果。

type Union = string | number | boolean;

// 拆分逻辑:string→string,number→never,boolean→never → 合并为 string
type OnlyString<T> = T extends string ? T : never;
type Result = OnlyString<Union>; // Result = string

注意:只有泛型参数是 裸类型(没有被 []/{} 包裹)时,才会触发分布式判断:

// 包裹后不触发分布式,整体判断 [string|number] 是否兼容 [string]
type NoDist<T> = [T] extends [string] ? T : never;
type Result2 = NoDist<Union>; // never(整体不兼容)
  1. 配合 infer:提取类型片段(黄金组合)
// 提取 Promise 的返回值类型
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;

type C = UnwrapPromise<Promise<string>>; // string(提取成功)
type D = UnwrapPromise<number>; // number(不满足条件,返回原类型)
extends 做继承(复用已有结构)
  1. 接口继承:复用 + 扩展属性
// 基础接口
interface User {
  id: number;
  name: string;
}

// 继承 User,并扩展新属性
interface Admin extends User {
  role: "admin" | "super_admin"; // 新增权限属性
}

// 必须包含继承的 + 扩展的所有属性
const admin: Admin = {
  id: 1,
  name: "张三",
  role: "admin"
};

// 多接口继承
interface HasAge { age: number; }
interface Student extends User, HasAge {
  className: string; // 同时继承 User + HasAge
}
  1. 类继承:复用父类的属性 / 方法
class Parent {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  sayHi() {
    console.log(`Hi, ${this.name}`);
  }
}

// 继承 Parent 类
class Child extends Parent {
  age: number;
  constructor(name: string, age: number) {
    super(name); // 必须调用父类构造函数(初始化父类属性)
    this.age = age;
  }
  // 重写父类方法
  sayHi() {
    super.sayHi(); // 调用父类原方法
    console.log(`I'm ${this.age} years old`);
  }
}

const child = new Child("李四", 10);
child.sayHi(); // 输出:Hi, 李四 → I'm 10 years old

补充:类实现接口用 implements(不是 extends

// 定义接口(契约:规定必须有 id、name 属性,以及 greet 方法)
interface Person {
  id: number;
  name: string;
  greet(): void; // 仅定义方法签名,无实现
}

// 类实现接口(必须严格遵守契约)
class Employee implements Person {
  // 必须实现接口的所有属性
  id: number;
  name: string;

  // 构造函数初始化属性
  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 必须实现接口的 greet 方法(具体实现由类自己定义)
  greet() {
    console.log(`Hi, I'm ${this.name}, ID: ${this.id}`);
  }
}

// 实例化使用
const emp = new Employee(1, "张三");
emp.greet(); // 输出:Hi, I'm 张三, ID: 1


// 接口1:基础信息
interface Identifiable {
  id: number;
  getId(): number;
}

// 接口2:可打印
interface Printable {
  printInfo(): void;
}

// 类同时实现两个接口(必须实现所有接口的成员)
class Product implements Identifiable, Printable {
  id: number;
  name: string; // 类可扩展接口外的属性

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 实现 Identifiable 的方法
  getId(): number {
    return this.id;
  }

  // 实现 Printable 的方法
  printInfo() {
    console.log(`Product: ${this.name}, ID: ${this.getId()}`);
  }
}

const product = new Product(100, "手机");
console.log(product.getId()); // 100
product.printInfo(); // Product: 手机, ID: 100

infer

infer 是 TypeScript 在条件类型中提供的关键字,用于声明一个 待推导的类型变量(类似给类型起一个临时名字),只能在 extends 子句中使用。它的核心作用是:从已有类型中提取 / 推导我们需要的部分,而无需手动硬编码类型。

infer 必须配合条件类型使用,语法结构如下:

// 基础结构:推导 T 的类型为 U,若能推导则返回 U,否则返回 never
type InferType<T> = T extends infer U ? U : never;

type Example = InferType<string>; // Example 类型为 string
type Example2 = InferType<number[]>; // Example2 类型为 number[]

高频使用场景:

1. 提取函数的返回值类型
// 定义类型工具:提取函数的返回值类型
type GetReturnType<Fn> = Fn extends (...args: any[]) => infer R ? R : never;

// 测试用函数
const add = (a: number, b: number): number => a + b;
const getUser = () => ({ name: "张三", age: 20 });

// 使用类型工具
type AddReturn = GetReturnType<typeof add>; // AddReturn 类型为 number
type UserReturn = GetReturnType<typeof getUser>; // UserReturn 类型为 { name: string; age: number }
2. 提取数组的元素类型
// 定义类型工具:提取数组元素类型
type GetArrayItem<T> = T extends (infer Item)[] ? Item : never;

// 测试
type NumberArray = GetArrayItem<number[]>; // NumberArray 类型为 number
type StringArray = GetArrayItem<string[]>; // StringArray 类型为 string
type MixedArray = GetArrayItem<[string, number]>; // MixedArray 类型为 string | number
3. 提取 Promise 的泛型参数类型
// 定义类型工具:提取 Promise 的泛型类型
type GetPromiseValue<T> = T extends Promise<infer Value> ? Value : never;

// 测试
type PromiseString = GetPromiseValue<Promise<string>>; // PromiseString 类型为 string
type PromiseUser = GetPromiseValue<Promise<{ id: number }>>; // PromiseUser 类型为 { id: number }
4. 提取函数的参数类型
// 定义类型工具:提取函数参数类型
type GetFunctionParams<Fn> = Fn extends (...args: infer Params) => any ? Params : never;

// 测试
const fn = (name: string, age: number): void => {};
type FnParams = GetFunctionParams<typeof fn>; // FnParams 类型为 [string, number]

// 进一步:提取第一个参数的类型
type FirstParam = GetFunctionParams<typeof fn>[0]; // FirstParam 类型为 string

never

never 表示永不存在的类型

  1. 没有任何类型能赋值给 never(除了 never 自身);
  2. never 可以赋值给任意类型(因为它是所有类型的子类型);
  3. 不会有任何实际值属于 never 类型。
let n: never;
let num: number = 123;
let u: unknown = "hello";
let v: void = undefined;

// 1. 任何类型都不能赋值给 never(除了自身)
n = num;   // ❌ 报错:number 不能赋值给 never
n = u;     // ❌ 报错:unknown 不能赋值给 never
n = v;     // ❌ 报错:void 不能赋值给 never
n = undefined; // ❌ 报错:undefined 也不行
n = n;     // ✅ 仅自身可赋值

// 2. never 可以赋值给任意类型
num = n;   // ✅ 正常
u = n;     // ✅ 正常
v = n;     // ✅ 正常
  1. 泛型的边界约束: 通过泛型约束让不满足条件的泛型类型变为 never,从而达到限制类型范围的目的。
// 定义泛型:仅允许 T 为 string 类型,否则 T 为 never
type OnlyString<T> = T extends string ? T : never;

// 满足条件:T 为 string,结果正常
type Str1 = OnlyString<"hello">; // Str1 = "hello"
type Str2 = OnlyString<string>;  // Str2 = string

// 不满足条件:T 为非 string,结果为 never
type Num = OnlyString<number>;   // Num = never
type Bool = OnlyString<boolean>; // Bool = never
type Unk = OnlyString<unknown>;  // Unk = never

// 实际使用:强制函数参数只能是 string 类型
function printStr<T>(val: OnlyString<T>) {
  console.log(val);
}

printStr("hello"); // ✅ 正常
printStr(123);     // ❌ 报错:number 不能赋值给 never

测试用例

/* _____________ 测试用例 _____________ */
import type { Equal, Expect } from '@type-challenges/utils'

type cases = [
  Expect<Equal<Last<[]>, never>>,
  Expect<Equal<Last<[2]>, 2>>,
  Expect<Equal<Last<[3, 2, 1]>, 1>>,
  Expect<Equal<Last<[() => 123, { a: string }]>, { a: string }>>,
]

相关链接

分享你的解答:tsch.js.org/15/answer/z… 查看解答:tsch.js.org/15/solution… 更多题目:tsch.js.org/zh-CN

下面是我的公众号,欢迎关注。关注后有新的功能点会及时收到推送。

实战为王!专注于汇总各种功能点,致力于打造一系列能够帮助工程师实现各种功能的想法思路的优质文章。

前端功能点

type-challenges(ts类型体操): 14 - 第一个元素

作者 fxss
2026年1月31日 13:44

14 - 第一个元素

by Anthony Fu (@antfu) #简单 #array

题目

实现一个First<T>泛型,它接受一个数组T并返回它的第一个元素的类型。

例如:

type arr1 = ['a', 'b', 'c']
type arr2 = [3, 2, 1]

type head1 = First<arr1> // 应推导出 'a'
type head2 = First<arr2> // 应推导出 3

在 Github 上查看:tsch.js.org/14/zh-CN

代码

/* _____________ 你的代码 _____________ */

type First<T extends unknown[]> = T extends [] ? never : T[0]

关键解释:

  1. T extends unknown[] 用于约束 T 必须是一个数组类型。
  2. T extends [] 用于判断数组是否为空。
  3. T[0] 用于获取数组的第一个元素。
  4. never 用于表示空数组的情况。

相关知识点

extends

使用维度 核心作用 示例场景
类型维度 做类型约束或条件判断(类型编程核心) 限定泛型范围、判断类型是否兼容、提取类型片段
语法维度 做继承(复用已有结构) 接口继承、类继承
extends 做类型约束或条件判断
  1. 泛型约束:限定泛型的取值范围
// 约束 T 必须是「拥有 length 属性」的类型(比如 string/数组)
function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

// 合法调用(符合约束)
getLength("hello"); // ✅ string 有 length,返回 5
getLength([1, 2, 3]); // ✅ 数组有 length,返回 3

// 非法调用(超出约束)
getLength(123); // ❌ 报错:number 没有 length 属性
  1. 条件类型:类型版 三元运算符
// 基础示例:判断类型是否为字符串
type IsString<T> = T extends string ? true : false;

type A = IsString<"test">; // true(符合)
type B = IsString<123>; // false(不符合)

分布式条件类型(联合类型专用): 当 T 是联合类型时,extends 会自动拆分联合类型的每个成员,逐个判断后再合并结果。

type Union = string | number | boolean;

// 拆分逻辑:string→string,number→never,boolean→never → 合并为 string
type OnlyString<T> = T extends string ? T : never;
type Result = OnlyString<Union>; // Result = string

注意:只有泛型参数是 裸类型(没有被 []/{} 包裹)时,才会触发分布式判断:

// 包裹后不触发分布式,整体判断 [string|number] 是否兼容 [string]
type NoDist<T> = [T] extends [string] ? T : never;
type Result2 = NoDist<Union>; // never(整体不兼容)
  1. 配合 infer:提取类型片段(黄金组合)
// 提取 Promise 的返回值类型
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;

type C = UnwrapPromise<Promise<string>>; // string(提取成功)
type D = UnwrapPromise<number>; // number(不满足条件,返回原类型)
extends 做继承(复用已有结构)
  1. 接口继承:复用 + 扩展属性
// 基础接口
interface User {
  id: number;
  name: string;
}

// 继承 User,并扩展新属性
interface Admin extends User {
  role: "admin" | "super_admin"; // 新增权限属性
}

// 必须包含继承的 + 扩展的所有属性
const admin: Admin = {
  id: 1,
  name: "张三",
  role: "admin"
};

// 多接口继承
interface HasAge { age: number; }
interface Student extends User, HasAge {
  className: string; // 同时继承 User + HasAge
}
  1. 类继承:复用父类的属性 / 方法
class Parent {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  sayHi() {
    console.log(`Hi, ${this.name}`);
  }
}

// 继承 Parent 类
class Child extends Parent {
  age: number;
  constructor(name: string, age: number) {
    super(name); // 必须调用父类构造函数(初始化父类属性)
    this.age = age;
  }
  // 重写父类方法
  sayHi() {
    super.sayHi(); // 调用父类原方法
    console.log(`I'm ${this.age} years old`);
  }
}

const child = new Child("李四", 10);
child.sayHi(); // 输出:Hi, 李四 → I'm 10 years old

补充:类实现接口用 implements(不是 extends

// 定义接口(契约:规定必须有 id、name 属性,以及 greet 方法)
interface Person {
  id: number;
  name: string;
  greet(): void; // 仅定义方法签名,无实现
}

// 类实现接口(必须严格遵守契约)
class Employee implements Person {
  // 必须实现接口的所有属性
  id: number;
  name: string;

  // 构造函数初始化属性
  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 必须实现接口的 greet 方法(具体实现由类自己定义)
  greet() {
    console.log(`Hi, I'm ${this.name}, ID: ${this.id}`);
  }
}

// 实例化使用
const emp = new Employee(1, "张三");
emp.greet(); // 输出:Hi, I'm 张三, ID: 1


// 接口1:基础信息
interface Identifiable {
  id: number;
  getId(): number;
}

// 接口2:可打印
interface Printable {
  printInfo(): void;
}

// 类同时实现两个接口(必须实现所有接口的成员)
class Product implements Identifiable, Printable {
  id: number;
  name: string; // 类可扩展接口外的属性

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 实现 Identifiable 的方法
  getId(): number {
    return this.id;
  }

  // 实现 Printable 的方法
  printInfo() {
    console.log(`Product: ${this.name}, ID: ${this.getId()}`);
  }
}

const product = new Product(100, "手机");
console.log(product.getId()); // 100
product.printInfo(); // Product: 手机, ID: 100

unknown

作用是替代 any 处理 类型未知 的场景,同时保证类型检查的安全性。

  1. 所有类型(基本类型、对象、函数、数组等)都可以赋值给 unknown 类型的变量;

  2. unknown 类型的变量不能随意赋值给其他类型(仅能赋值给 unknownany);

  3. 也不能直接操作 unknown 类型的变量(比如调用方法、访问属性、做算术运算),必须先通过类型收窄确定其具体类型,这是它比 any 安全的关键。

  4. any 的区别

any任意类型,会关闭 TypeScript 的类型检查,而 unknown未知类型,保留类型检查,仅允许在确定类型后操作。两者的规则对比如下:

规则 unknown any
所有类型可赋值给它 ✅ 支持 ✅ 支持
它可赋值给其他类型 ❌ 仅能赋值给 unknown/any ✅ 可赋值给任意类型(无限制)
直接操作变量(调用方法 / 访问属性) ❌ 不允许(必须类型收窄) ✅ 允许(关闭类型检查)
// 1. 所有类型都能赋值给 unknown/any
let u: unknown = 123;
u = "hello";
u = [1,2,3];

let a: any = 123;
a = "hello";
a = [1,2,3];

// 2. unknown 仅能赋值给 unknown/any(赋值给其他类型报错)
let num: number = u; // ❌ 报错:Type 'unknown' is not assignable to type 'number'
let u2: unknown = u; // ✅ 正常
let a2: any = u;     // ✅ 正常

// any 可赋值给任意类型(无报错,即使类型不匹配)
let num2: number = a; // ✅ 无报错(但运行时可能出问题,类型不安全)

// 3. 直接操作 unknown 报错,操作 any 无限制
u.toFixed(); // ❌ 报错:Object is of type 'unknown'
a.toFixed(); // ✅ 无报错(即使 a 可能是字符串,TS 不检查)
  1. 类型收窄

2.1 typeof检查(适用于基本类型:number/string/boolean/undefined/null/symbol/bigint

function handleUnknown(val: unknown) {
  // 先通过 typeof 收窄为数字类型
  if (typeof val === "number") {
    console.log(val.toFixed(2)); // ✅ 正常:val 已确定是 number
  }
  // 收窄为字符串类型
  else if (typeof val === "string") {
    console.log(val.toUpperCase()); // ✅ 正常:val 已确定是 string
  }
  // 收窄为布尔类型
  else if (typeof val === "boolean") {
    console.log(val ? "真" : "假"); // ✅ 正常:val 已确定是 boolean
  }
}

handleUnknown(123.456); // 输出 123.46
handleUnknown("hello"); // 输出 HELLO
handleUnknown(true);    // 输出 真

2.2 instanceof检查(适用于引用类型:数组 / 类实例 / RegExp/Date 等)

function handleUnknown2(val: unknown) {
  // 收窄为数组类型
  if (val instanceof Array) {
    console.log(val.push(4)); // ✅ 正常:val 已确定是 Array
  }
  // 收窄为 Date 类型
  else if (val instanceof Date) {
    console.log(val.toLocaleString()); // ✅ 正常:val 已确定是 Date
  }
}

handleUnknown2([1,2,3]); // 输出 4(数组长度)
handleUnknown2(new Date()); // 输出当前时间字符串

2.3 类型断言

let u: unknown = "这是一个字符串";

// 断言为 string 类型后操作
let str = u as string;
console.log(str.length); // ✅ 正常:输出 7

// 错误断言(运行时报错)
let num = u as number;
console.log(num.toFixed()); // ❌ 运行时报错:num.toFixed is not a function

never

never 表示永不存在的类型

  1. 没有任何类型能赋值给 never(除了 never 自身);
  2. never 可以赋值给任意类型(因为它是所有类型的子类型);
  3. 不会有任何实际值属于 never 类型。
let n: never;
let num: number = 123;
let u: unknown = "hello";
let v: void = undefined;

// 1. 任何类型都不能赋值给 never(除了自身)
n = num;   // ❌ 报错:number 不能赋值给 never
n = u;     // ❌ 报错:unknown 不能赋值给 never
n = v;     // ❌ 报错:void 不能赋值给 never
n = undefined; // ❌ 报错:undefined 也不行
n = n;     // ✅ 仅自身可赋值

// 2. never 可以赋值给任意类型
num = n;   // ✅ 正常
u = n;     // ✅ 正常
v = n;     // ✅ 正常
  1. 泛型的边界约束: 通过泛型约束让不满足条件的泛型类型变为 never,从而达到限制类型范围的目的。
// 定义泛型:仅允许 T 为 string 类型,否则 T 为 never
type OnlyString<T> = T extends string ? T : never;

// 满足条件:T 为 string,结果正常
type Str1 = OnlyString<"hello">; // Str1 = "hello"
type Str2 = OnlyString<string>;  // Str2 = string

// 不满足条件:T 为非 string,结果为 never
type Num = OnlyString<number>;   // Num = never
type Bool = OnlyString<boolean>; // Bool = never
type Unk = OnlyString<unknown>;  // Unk = never

// 实际使用:强制函数参数只能是 string 类型
function printStr<T>(val: OnlyString<T>) {
  console.log(val);
}

printStr("hello"); // ✅ 正常
printStr(123);     // ❌ 报错:number 不能赋值给 never

测试用例

/* _____________ 测试用例 _____________ */
import type { Equal, Expect } from '@type-challenges/utils'

type cases = [
  Expect<Equal<First<[3, 2, 1]>, 3>>,
  Expect<Equal<First<[() => 123, { a: string }]>, () => 123>>,
  Expect<Equal<First<[]>, never>>,
  Expect<Equal<First<[undefined]>, undefined>>,
]

type errors = [
  // @ts-expect-error
  First<'notArray'>,
  // @ts-expect-error
  First<{ 0: 'arrayLike' }>,
]

相关链接

分享你的解答:tsch.js.org/14/answer/z… 查看解答:tsch.js.org/14/solution… 更多题目:tsch.js.org/zh-CN

下面是我的公众号,欢迎关注。关注后有新的功能点会及时收到推送。

实战为王!专注于汇总各种功能点,致力于打造一系列能够帮助工程师实现各种功能的想法思路的优质文章。

前端功能点

Vue-组件通信全攻略

2026年1月31日 13:38

前言

在 Vue 开发中,组件通信是构建复杂应用的基础。随着 Vue 3 的普及,通信方式发生了不少变化(如 defineProps 的引入、EventBus 的退场)。本文将对比 Vue 2 与 Vue 3,带你梳理最常用的 5 种通信方案。

一、 父子组件通信:最基础的单向数据流

这是最常用的通信方式,遵循“Props 向下传递,Emit 向上通知”的原则。

1. Vue 2 经典写法

  • 接收:使用 props 选项。
  • 发送:使用 this.$emit

2. Vue 3 + TS 标准写法

在 Vue 3 <script setup> 中,我们使用 definePropsdefineEmits

父组件:Parent.vue

<template>
  <ChildComponent :id="currentId" @childEvent="handleChild" />
</template>

<script setup lang="ts">
import { ref } from 'vue';
import ChildComponent from './Child.vue';

const currentId = ref<string>('001');
const handleChild = (msg: string) => {
  console.log('接收到子组件消息:', msg);
};
</script>

子组件:Child.vue

<script setup lang="ts">
// 使用 TS 类型定义 Props
const props = defineProps<{
  id: string
}>();

// 使用 TS 定义 Emits,具备更好的类型检查
const emit = defineEmits<{
  (e: 'childEvent', args: string): void;
}>();

const sendMessage = () => {
  emit('childEvent', '这是来自子组件的参数');
};
</script>

二、 跨级调用:通过 Ref 访问实例

有时父组件需要直接调用子组件的内部方法。

1. Vue 2 模式

直接通过 this.$refs.childRef.someMethod() 调用。

2. Vue 3 模式(显式暴露)

Vue 3 的组件默认是关闭的。如果父组件想访问子组件的方法,子组件必须使用 defineExpose

子组件:Child.vue

<script setup lang="ts">
const childFunc = () => {
  console.log('子组件方法被调用');
};

// 必须手动暴露,父组件才能访问
defineExpose({
  childFunc
});
</script>

父组件:Parent.vue

<template>
  <Child ref="childRef" />
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import Child from './Child.vue';

// 这里的类型定义有助于获得代码提示
const childRef = ref<InstanceType<typeof Child> | null>(null);

onMounted(() => {
  childRef.value?.childFunc();
});
</script>

三、 非父子组件通信:事件总线 (EventBus)

1. Vue 2 做法

利用一个新的 Vue 实例作为中央调度器。

import Vue from 'vue';
export const EventBus = new Vue();

// 组件 A 发送
EventBus.$emit('event', data);
// 组件 B 接收
EventBus.$on('event', (data) => { ... });

2. Vue 3 重要变更

Vue 3 官方已移除了 $on$off$once 方法,因此不再支持直接通过 Vue 实例创建 EventBus。

  • 官方推荐方案:使用第三方库 mitttiny-emitter
  • 补充:如果逻辑简单,可以使用 Vue 3 的 provide / inject 实现跨级通信。

provide / inject 示例:

  1. 祖先组件:提供数据 (App.vue)
<template>
  <div class="ancestor">
    <h1>祖先组件</h1>
    <p>当前主题:{{ theme }}</p>
    <Middle />
  </div>
</template>

<script setup lang="ts">
import { ref, provide } from 'vue';
import Middle from './Middle.vue';

// 1. 定义响应式数据
const theme = ref<'light' | 'dark'>('light');

// 2. 定义修改数据的方法(推荐在提供者内部定义,保证数据流向清晰)
const toggleTheme = () => {
  theme.value = theme.value === 'light' ? 'dark' : 'light';
};

// 3. 注入 key 和对应的值/方法
provide('theme', theme);
provide('toggleTheme', toggleTheme);
</script>
  1. 中间组件:无需操作 (Middle.vue)

    中间组件不需要显式接收 theme,直接透传即可

  2. 后代组件:注入并使用 (DeepChild.vue)

<template>
  <div class="descendant">
    <h3>深层子组件</h3>
    <p>接收到的主题:{{ theme }}</p>
    <button @click="toggleTheme">切换主题</button>
  </div>
</template>

<script setup lang="ts">
import { inject } from 'vue';

// 使用 inject 获取,第二个参数为默认值(可选)
const theme = inject('theme');
const toggleTheme = inject<() => void>('toggleTheme');
</script>

四、 集中式状态管理:Vuex 与 Pinia

当应用变得庞大,组件间的关系交织成网时,我们需要一个“单一事实来源”。

  • Vuex:Vue 2 时代的标准。基于 Mutation(同步)和 Action(异步)。

  • Pinia:Vue 3 的官方推荐。

    • 优势:更完美的 TS 支持、没有 Mutation 的繁琐逻辑、极其轻量。
    • 核心stategettersactions

Pinia 示例:

import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({
    name: '张三',
    age: 18
  }),
  actions: {
    updateName(newName: string) {
      this.name = newName;
    }
  }
});

五、 总结与纠错

  1. 安全性建议:在使用 defineExpose 时,尽量只暴露必要的接口,遵循最小暴露原则。
  2. EventBus 警示:Vue 3 开发者请注意,不要再尝试使用 new Vue() 来做事件总线,应当转向 Pinia 或全局状态。

type-challenges(ts类型体操): 12 - 可串联构造器

作者 fxss
2026年1月31日 13:38

12 - 可串联构造器

by Anthony Fu (@antfu) #中等 #application

题目

在 JavaScript 中我们经常会使用可串联(Chainable/Pipeline)的函数构造一个对象,但在 TypeScript 中,你能合理的给它赋上类型吗?

在这个挑战中,你可以使用任意你喜欢的方式实现这个类型 - Interface, Type 或 Class 都行。你需要提供两个函数 option(key, value)get()。在 option 中你需要使用提供的 key 和 value 扩展当前的对象类型,通过 get 获取最终结果。

例如

declare const config: Chainable

const result = config
  .option('foo', 123)
  .option('name', 'type-challenges')
  .option('bar', { value: 'Hello World' })
  .get()

// 期望 result 的类型是:
interface Result {
  foo: number
  name: string
  bar: {
    value: string
  }
}

你只需要在类型层面实现这个功能 - 不需要实现任何 TS/JS 的实际逻辑。

你可以假设 key 只接受字符串而 value 接受任何类型,你只需要暴露它传递的类型而不需要进行任何处理。同样的 key 只会被使用一次。

在 Github 上查看:tsch.js.org/12/zh-CN

代码

/* _____________ 你的代码 _____________ */

/**
 * 定义可串联构造器的类型
 * @template T 当前构造器对象的状态,默认为空对象
 */
type Chainable<T = {}> = {
  /**
   * 用于扩展或修改当前对象的方法
   * @template K 要添加或修改的键,必须是字符串类型
   * @template V 要添加或修改的值的类型
   * @param key 要添加或修改的键,根据情况可能为 never 或 K
   * @param value 要添加或修改的值
   * @returns 一个新的 Chainable 实例,包含更新后的对象状态
   */
  option: <K extends string, V>(key: K extends keyof T ? V extends T[K] ? never : K : K, value: V) => Chainable<Omit<T, K> & Record<K, V>>
  /**
   * 获取当前构造器对象的最终状态
   * @returns 当前对象的状态
   */
  get(): T
}

关键解释:

  • Chainable<T>:泛型参数,代表当前构造器对象的状态,默认为空对象;
  • option(key, value):方法,用于扩展或修改当前对象的状态;
    • K extends string:约束 K 必须是字符串类型;
    • V:要添加或修改的值的类型;
    • key: K extends keyof T ? V extends T[K] ? never : K : K:约束 key 必须是 T 中不存在的属性名,或者 value 类型与 T[K] 不同的属性名;
    • value: V:要添加或修改的值;
    • Chainable<Omit<T, K> & Record<K, V>>:返回一个新的 Chainable 实例,包含更新后的对象状态;
  • get():方法,用于获取当前构造器对象的最终状态;
    • T:当前构造器对象的状态。

相关知识点

extends

使用维度 核心作用 示例场景
类型维度 做类型约束或条件判断(类型编程核心) 限定泛型范围、判断类型是否兼容、提取类型片段
语法维度 做继承(复用已有结构) 接口继承、类继承
extends 做类型约束或条件判断
  1. 泛型约束:限定泛型的取值范围
// 约束 T 必须是「拥有 length 属性」的类型(比如 string/数组)
function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

// 合法调用(符合约束)
getLength("hello"); // ✅ string 有 length,返回 5
getLength([1, 2, 3]); // ✅ 数组有 length,返回 3

// 非法调用(超出约束)
getLength(123); // ❌ 报错:number 没有 length 属性
  1. 条件类型:类型版 三元运算符
// 基础示例:判断类型是否为字符串
type IsString<T> = T extends string ? true : false;

type A = IsString<"test">; // true(符合)
type B = IsString<123>; // false(不符合)

分布式条件类型(联合类型专用): 当 T 是联合类型时,extends 会自动拆分联合类型的每个成员,逐个判断后再合并结果。

type Union = string | number | boolean;

// 拆分逻辑:string→string,number→never,boolean→never → 合并为 string
type OnlyString<T> = T extends string ? T : never;
type Result = OnlyString<Union>; // Result = string

注意:只有泛型参数是 裸类型(没有被 []/{} 包裹)时,才会触发分布式判断:

// 包裹后不触发分布式,整体判断 [string|number] 是否兼容 [string]
type NoDist<T> = [T] extends [string] ? T : never;
type Result2 = NoDist<Union>; // never(整体不兼容)
  1. 配合 infer:提取类型片段(黄金组合)
// 提取 Promise 的返回值类型
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;

type C = UnwrapPromise<Promise<string>>; // string(提取成功)
type D = UnwrapPromise<number>; // number(不满足条件,返回原类型)
extends 做继承(复用已有结构)
  1. 接口继承:复用 + 扩展属性
// 基础接口
interface User {
  id: number;
  name: string;
}

// 继承 User,并扩展新属性
interface Admin extends User {
  role: "admin" | "super_admin"; // 新增权限属性
}

// 必须包含继承的 + 扩展的所有属性
const admin: Admin = {
  id: 1,
  name: "张三",
  role: "admin"
};

// 多接口继承
interface HasAge { age: number; }
interface Student extends User, HasAge {
  className: string; // 同时继承 User + HasAge
}
  1. 类继承:复用父类的属性 / 方法
class Parent {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  sayHi() {
    console.log(`Hi, ${this.name}`);
  }
}

// 继承 Parent 类
class Child extends Parent {
  age: number;
  constructor(name: string, age: number) {
    super(name); // 必须调用父类构造函数(初始化父类属性)
    this.age = age;
  }
  // 重写父类方法
  sayHi() {
    super.sayHi(); // 调用父类原方法
    console.log(`I'm ${this.age} years old`);
  }
}

const child = new Child("李四", 10);
child.sayHi(); // 输出:Hi, 李四 → I'm 10 years old

补充:类实现接口用 implements(不是 extends

// 定义接口(契约:规定必须有 id、name 属性,以及 greet 方法)
interface Person {
  id: number;
  name: string;
  greet(): void; // 仅定义方法签名,无实现
}

// 类实现接口(必须严格遵守契约)
class Employee implements Person {
  // 必须实现接口的所有属性
  id: number;
  name: string;

  // 构造函数初始化属性
  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 必须实现接口的 greet 方法(具体实现由类自己定义)
  greet() {
    console.log(`Hi, I'm ${this.name}, ID: ${this.id}`);
  }
}

// 实例化使用
const emp = new Employee(1, "张三");
emp.greet(); // 输出:Hi, I'm 张三, ID: 1


// 接口1:基础信息
interface Identifiable {
  id: number;
  getId(): number;
}

// 接口2:可打印
interface Printable {
  printInfo(): void;
}

// 类同时实现两个接口(必须实现所有接口的成员)
class Product implements Identifiable, Printable {
  id: number;
  name: string; // 类可扩展接口外的属性

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 实现 Identifiable 的方法
  getId(): number {
    return this.id;
  }

  // 实现 Printable 的方法
  printInfo() {
    console.log(`Product: ${this.name}, ID: ${this.getId()}`);
  }
}

const product = new Product(100, "手机");
console.log(product.getId()); // 100
product.printInfo(); // Product: 手机, ID: 100

keyof

keyof 运算符用于获取一个类型(接口、类型别名、对象类型等)的所有公共属性名,并返回这些属性名组成的联合类型。

例如:

interface Todo {
  title: string
  description: string
  completed: boolean
}

type TodoKeys = keyof Todo // "title" | "description" | "completed"

Omit

Omit<T, K> 用于从类型 T 中排除 K 中的属性,返回一个新类型。

例如:

interface Todo {
  title: string
  description: string
  completed: boolean
}

type TodoPreview = Omit<Todo, 'description'>
// TodoPreview 类型为:
// {
//   title: string
//   completed: boolean
// }

&

& 交叉类型运算符用于将多个类型合并为一个新类型,它会将所有属性合并到新类型中。

例如:

interface Todo {
  title: string
  description: string
  completed: boolean
}

type TodoPreview = Omit<Todo, 'description'> & {
  time: Date
}
// TodoPreview 类型为:
// {
//   title: string
//   completed: boolean
//   time: Date
// }

基础类型的交叉,只有类型完全一致时才会保留原类型,类型不一致时会得到 never

type A = number & string // never
type B = number & boolean // never
type C = number & symbol // never
type D = string & boolean // never
type E = string & symbol // never
type F = boolean & symbol // never

同名属性的类型冲突时,会得到 never

interface A {
  x: string; // 同名属性,类型 string
}
interface B {
  x: number; // 同名属性,类型 number
}

type C = A & B;
// C 的 x 类型为 string & number → never
const c: C = {
  x: 123, // 报错:类型 number 不能赋值给 never
  x: "abc" // 同样报错
};

Record

Record<K, T> 是用于定义键值对结构对象类型,能快速指定对象的键类型和值的统一类型。

第一个参数 K(键类型):必须是 string | number | symbol 及其子类型(比如字符串字面量、数字字面量、联合类型),否则会报错。 第二个参数 T(值类型):可以是任意类型(基础类型、对象类型、函数类型等)。

  1. 字符串键 + 基础类型值
// 用 Record 定义:键是 string,值是 number
type ScoreMap = Record<string, number>;
// 等价于手动写索引签名:{ [key: string]: number }
type ScoreMap2 = { [key: string]: number };

// 正确使用:所有键的值必须是数字
const studentScores: ScoreMap = {
  2: 90, // 数字字面量键会自动转为字符串,合法
  "李四": 85,
  wangwu: 95
};
  1. 字面量联合键 + 基础类型值:用字符串 / 数字字面量联合类型作为键,定义固定键、统一值类型的映射表(如状态码、枚举映射、地区编码),TS 会严格校验键的合法性(只能是联合类型中的值)
// 固定键:联合类型(字符串字面量)
type UserRole = "admin" | "editor" | "visitor";
// Record 定义:键只能是 UserRole 中的值,值是 string(角色描述)
type RoleDesc = Record<UserRole, string>;

// 正确使用:必须包含所有固定键,值为字符串
const roleDescription: RoleDesc = {
  admin: "超级管理员,拥有所有权限",
  editor: "内容编辑,可修改文章",
  visitor: "游客,仅可查看内容"
};

// 错误示例1:缺少键(editor)→ TS 报错
const err1: RoleDesc = { admin: "xxx", visitor: "xxx" };
// 错误示例2:多余键(test)→ TS 报错
const err2: RoleDesc = { admin: "xxx", editor: "xxx", visitor: "xxx", test: "xxx" };
// 错误示例3:值类型错误(数字)→ TS 报错
const err3: RoleDesc = { admin: 123, editor: "xxx", visitor: "xxx" };

Record + Partial → 固定键,值类型可选(部分赋值)

type UserRole = "admin" | "editor" | "visitor";
// 需求:固定角色键,允许部分赋值(不是所有角色都需要写描述)
type PartialRoleDesc = Partial<Record<UserRole, string>>;

// 正确使用:可包含任意数量的键(0个、1个、多个、全部)
const emptyDesc: PartialRoleDesc = {}; // 正常
const partialDesc: PartialRoleDesc = { admin: "超级管理员" }; // 正常
const fullDesc: PartialRoleDesc = { admin: "xxx", editor: "xxx", visitor: "xxx" }; // 正常

测试用例

/* _____________ 测试用例 _____________ */
import type { Alike, Expect } from '@type-challenges/utils'

declare const a: Chainable

const result1 = a
  .option('foo', 123)
  .option('bar', { value: 'Hello World' })
  .option('name', 'type-challenges')
  .get()

const result2 = a
  .option('name', 'another name')
  // @ts-expect-error
  .option('name', 'last name')
  .get()

const result3 = a
  .option('name', 'another name')
  .option('name', 123)
  .get()

type cases = [
  Expect<Alike<typeof result1, Expected1>>,
  Expect<Alike<typeof result2, Expected2>>,
  Expect<Alike<typeof result3, Expected3>>,
]

type Expected1 = {
  foo: number
  bar: {
    value: string
  }
  name: string
}

type Expected2 = {
  name: string
}

type Expected3 = {
  name: number
}

相关链接

分享你的解答:tsch.js.org/12/answer/z… 查看解答:tsch.js.org/12/solution… 更多题目:tsch.js.org/zh-CN

下面是我的公众号,欢迎关注。关注后有新的功能点会及时收到推送。

实战为王!专注于汇总各种功能点,致力于打造一系列能够帮助工程师实现各种功能的想法思路的优质文章。

前端功能点

《实时渲染》第2章-图形渲染管线-2.5像素处理

作者 charlee44
2026年1月31日 13:33

实时渲染

2. 图形渲染管线

2.5 像素处理

这个阶段是所有先前阶段组合的结果,并且已经找到了在三角形或其他图元内被考虑的所有像素。像素处理阶段分为像素着色和合并,如图2.8右侧所示。像素处理是对图元内部的像素或样本执行逐像素或逐样本计算和操作的阶段。

2.5.1 像素着色

此处执行的任何逐像素着色计算,是使用内插着色数据作为输入的。最终结果是将一种或多种颜色传递到下一阶段。与通常由专用的,硬连线的芯片执行的三角形设置和遍历阶段不同,像素着色阶段由可编程GPU内核执行。为此,程序员为像素着色器(或在OpenGL中称为片元着色器)提供了一个程序,该程序可以包含任何所需的计算。这里可以使用多种技术,其中最重要的一种是纹理贴图。纹理在第6章中进行了更详细的论述。简单地说,纹理对象意味着将一个或多个图像“粘合”到该对象上,用于各种目的。图2.9描述了此过程的一个简单示例。图像可以是一维、二维或三维,其中二维图像最为常见。最简单的情况是,最终产品是每个片元的颜色值,这些值被传递到下一个子阶段。

图2.9. 没有纹理的龙模型显示在左上角。图像纹理中的片段“粘”在龙上,结果显示在左下角。

2.5.2 合并

每个像素的信息存储在颜色缓冲区中,它是一个矩形的颜色数组(每种颜色具有红色、绿色和蓝色分量)。合并阶段的职责是将像素着色阶段产生的片元颜色与当前存储在缓冲区中的颜色相结合。此阶段也称为ROP,表示“(管线)光栅操作”或“渲染输出单元”,具体取决于你访问的对象。与着色阶段不同,执行此阶段的GPU子单元通常不是完全可编程的。但是,它是高度可配置的,可以实现各种效果。

此阶段还负责解决可见性问题。这意味着当整个场景被渲染后,颜色缓冲区应该包含场景中从相机的角度可见的图元的颜色。对于大多数甚至所有图形硬件,这是通过z缓冲区(也称为深度缓冲区)算法完成的[238]。z缓冲区的大小和形状与颜色缓冲区相同,并且对于每个像素,它将z值存储到当前最接近的图元。这意味着当一个图元被渲染到某个像素时,该图元在该像素上的z值被计算并与同一像素的z缓冲区的内容进行比较。如果新的z值小于z缓冲区中的 z 值,则正在渲染的图元比之前在该像素处最靠近相机的图元更靠近相机。因此,该像素的z值和颜色将使用正在绘制的图元的z值和颜色进行更新。如果计算出的z值大于z缓冲区中的z值,则颜色缓冲区和z缓冲区保持不变。z缓冲区算法很简单,具有O(n)收敛性(其中n是正在渲染的图元数量),并且可以适用于为每个(相关)像素计算z值的任何绘图图元。另请注意,该算法允许以任何顺序呈现大多数图元,这是其流行的另一个原因。但是,z 缓冲区仅在屏幕上的每个点存储单个深度,因此它不能用于部分透明的图元。透明图元必须在所有不透明基元之后渲染,并以从后到前的顺序呈现,或使用单独的与顺序无关的算法(第5.5节)。透明度是基本z缓冲区算法的主要弱点之一。

我们已经提到颜色缓冲区用于存储颜色,而z缓冲区存储每个像素的z值。但是,还有其他通道和缓冲区可用于过滤和捕获片元信息。Alpha通道与颜色缓冲区相关联,并为每个像素存储相关的不透明度值(第5.5节)。在较旧的API中,alpha通道还用于通过alpha测试功能有选择地丢弃像素。如今,可以将丢弃操作插入到像素着色器程序中,并且可以使用任何类型的计算来触发丢弃。此类测试可用于确保完全透明的片段不会影响z缓冲区(第6.6节)。

模板缓冲区是一个离屏缓冲区,用于记录渲染图元的位置。它通常包含每像素 8 位。可以使用各种函数将图元渲染到模板缓冲区中,然后可以使用缓冲区的内容来控制渲染到颜色缓冲区和z缓冲区中。例如,假设一个实心圆已被绘制到模板缓冲区中。这可以与允许将后续图元渲染到仅存在圆圈的颜色缓冲区中的运算符结合使用。模板缓冲区可以成为生成某些特殊效果的强大工具。管线末端的所有这些功能都称为光栅操作 (ROP) 或混合操作。可以将当前在颜色缓冲区中的颜色与三角形内正在处理的像素的颜色混合。这可以启用诸如透明度或颜色样本累积等效果。如前所述,混合操作通常可以使用API进行配置,而不是完全可编程的。但是,某些API支持光栅顺序视图,也称为像素着色器排序,可实现可编程混合功能。

帧缓冲区通常由系统上的所有缓冲区组成。

当图元到达并通过光栅化阶段时,从相机的角度上看,这些可见的图元将会显示在屏幕上。屏幕显示颜色缓冲区的内容。为了避免让人类观察者在被光栅化并发送到屏幕时看到图元,使用了双缓冲。这意味着场景的渲染发生在屏幕外的后台缓冲区中。在后台缓冲区中渲染场景后,后台缓冲区的内容将与之前显示在屏幕上的前台缓冲区的内容交换。交换通常发生在垂直重描期间,这是安全的时候。

有关不同缓冲区和缓冲方法的更多信息,请参阅第5.4.223.623.7节。

纯 CSS 实现拟人化亲吻动画:布局居中与关键帧协同设计详解

2026年1月31日 12:30

纯 CSS 实现拟人化亲吻动画:布局居中与关键帧协同设计详解

在现代 Web 开发中,CSS 不仅用于样式控制,更成为实现丰富交互动画的重要工具。本文将深入剖析一段精巧的纯 CSS 动画代码——一个由两个圆形头像组成的“亲吻”互动场景。该动画无需 JavaScript,仅通过 CSS 布局、伪元素和 @keyframes 关键帧即可实现生动拟人效果。我们将从居中布局策略动画分层设计逻辑以及关键帧协同机制三大维度展开技术解析,揭示其背后的设计哲学与工程技巧。


一、精准居中:两种主流布局方法对比

动画容器 .container 需要精确位于视口中央,这是视觉表现的基础。当前代码采用的是经典的 绝对定位 + transform 平移法

.container {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

1.1 绝对定位 + Transform 法(当前方案)

此方法的核心在于:

  • 利用 top: 50%left: 50% 将元素左上角锚定在视口中心;
  • 再通过 transform: translate(-50%, -50%) 将元素自身向左、向上平移其宽高的一半,从而实现几何中心对齐。

优势

  • 兼容性极佳(IE9+ 支持 transform);
  • 适用于任意尺寸元素,无需预知宽高;
  • 不影响文档流,适合叠加层或独立组件。

局限

  • 若父容器非视口(如嵌套在其他定位元素中),需确保定位上下文正确;
  • 在极端缩放或高 DPI 屏幕下可能出现亚像素渲染偏差(通常可忽略)。

1.2 Flexbox 居中法(现代替代方案)

作为对比,现代开发更推荐使用 Flexbox:

body {
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  margin: 0;
}
.container {
  /* 无需额外定位 */
}

优势

  • 语义清晰,代码简洁;
  • 自动响应容器尺寸变化;
  • 支持多子元素对齐(如需扩展)。

适用场景

  • 主体内容居中;
  • 响应式布局项目。

结论:本例选择绝对定位法,因其轻量、独立,且不依赖修改 body 布局,符合“组件化”思维——动画模块可无缝嵌入任何页面。


二、动画架构:分层角色与时间轴编排

整个动画时长为 4 秒,循环播放(animation: ... infinite)。设计者将动画拆解为多个角色(左侧球、右侧球、眼睛、嘴巴、亲吻印记),每个角色拥有独立但协同的动画轨道,形成完整的叙事节奏:

时间段 动作描述 触发元素
0–0.8s 左脸靠近 #l-ball 右移
0.8–1.4s 停留等待 #l-ball 静止
1.4–2.2s 左脸返回 #l-ball 左移
2.0s 右脸惊讶后退 #r-ball 右移 + 旋转
2.4s 右脸猛冲亲吻 #r-ball 左移33px
2.64s 亲吻印记闪现 .kiss-m opacity=1
2.64s 嘴巴暂时隐藏 .mouth-r opacity=0

这种分镜式时间轴设计,使得静态图形具备了“情绪”与“故事性”。


三、关键帧动画详解

3.1 左侧球体:主动靠近(close 动画)

@keyframes close {
  0%   { transform: translateX(0); }
  20%  { transform: translateX(20px); }
  35%  { transform: translateX(20px); }
  55%  { transform: translateX(0); }
  100% { transform: translateX(0); }
}
  • 0–20%(0–0.8s):平滑右移 20px,模拟靠近;
  • 20–35%(0.8–1.4s):保持位置,制造“凝视”停顿;
  • 35–55%(1.4–2.2s):缓慢返回,体现犹豫或试探。

配合 ease 缓动函数,运动符合“启动快、停止缓”的自然惯性。

3.2 左侧脸部细节:头部微倾(face 动画)

@keyframes face {
  20%  { transform: translateX(5px) rotate(-2deg); }
  28%  { transform: none; }
  35%  { transform: translateX(5px) rotate(-2deg); }
  50%  { transform: none; }
}

在靠近瞬间(20% 和 35%),头部轻微右倾(rotate(-2deg) 表示顺时针旋转),模拟人类“歪头示好”的微表情,极大增强拟人感。

3.3 右侧球体:戏剧性回应(kiss 动画)

@keyframes kiss {
  40% { transform: none; }
  50% { transform: translateX(30px) rotate(20deg); } /* 惊讶后退 */
  60% { transform: translateX(-33px); }              /* 猛冲亲吻 */
  67% { transform: translateX(-33px); }              /* 保持接触 */
  77% { transform: none; }                           /* 弹回原位 */
}

此动画是全片高潮:

  • 50%(2.0s):先夸张后退并抬头(rotate(20deg)),表现“惊喜”;
  • 60%(2.4s):突然左移 33px,远超左球位置,制造“主动亲吻”错觉;
  • 67–77%:短暂停留后弹性回弹,模拟物理反弹效果。

这种“先退后进”的反差设计,是动画趣味性的核心来源。

3.4 嘴巴与亲吻印记:状态切换魔法

(1)嘴巴消失(mouth-m 动画)
@keyframes mouth-m {
  54.9% { opacity: 1; }
  55%   { opacity: 0; }
  66%   { opacity: 0; }
  66.1% { opacity: 1; }
}

在亲吻前(55%)瞬间隐藏嘴巴,避免视觉冲突。

(2)亲吻印记闪现(kiss-m 动画)
@keyframes kiss-m {
  66%   { opacity: 0; }
  66.1% { opacity: 1; }
  66.2% { opacity: 0; }
}

利用 0.1% 的时间窗口(约 4 毫秒)闪现一个心形/唇印图形(通过 .kiss 的边框与圆角模拟),人眼会捕捉到这一瞬态画面,形成“亲到了”的心理暗示——这是典型的视觉暂留应用。

技巧提示:此类“瞬时效果”常用于点击反馈、消息提示等场景,成本低、效果强。


四、工程启示:CSS 动画的最佳实践

  1. 模块化角色设计
    每个动画元素职责单一(移动、旋转、显隐),便于调试与复用。

  2. 时间轴对齐
    所有动画共享同一时长(4s),通过百分比关键帧精确同步事件,避免时间漂移。

  3. 缓动函数选择
    ease 适用于大多数自然运动;若需弹性效果,可结合 cubic-bezier() 自定义。

  4. 性能考量
    仅使用 transformopacity(触发合成层,GPU 加速),避免 width/height/margin 等触发布局重排的属性。

  5. 无障碍兼容
    可通过 prefers-reduced-motion 媒体查询禁用动画,提升可访问性:

    @media (prefers-reduced-motion: reduce) {
      .ball { animation: none !important; }
    }
    

结语

这段不足百行的 CSS 代码,展示了前端动画的无限可能:无需框架、不依赖脚本,仅凭对布局、变换与时间的理解,即可创造出富有情感的交互体验。它不仅是技术实现,更是一种用代码讲故事的艺术。在追求极致性能与用户体验的今天,掌握此类纯 CSS 动画技巧,将为开发者提供轻量、高效且富有创意的解决方案。

基于uniapp和 Android Studio实现安卓离线打包

2026年1月31日 10:22

一、准备工作

二、 创建 uniapp 工程

创建工程,选择** uni-app** ,选择默认模板或者 hello uni-app 等。

不要勾选 uni-app x,因为 uni-app x 暂时不支持快速安心打包;

三、uni-app 项目快速安心打包

打包简单、代码不用上传,但是需要等,因为是在云端打包;花钱可以快速打包;

四、修改 Android 离线 SDK 的配置文件

查看通过HBuilder X创建的项目

使用 Android Studio 打开 Android 离线SDK(解压)HBuilder-Integrate-AS 工程;

修改三个文件,build.gradle、dcloud_control.xml、AndroidManifest.xml;

build.gradle 文件修改包名和配置的签名信息,签名信息需要和准备工作的第三步保持一致;

dcloud_control.xml 修改 appid,这是基于 Hbuilder X 应用自动生成的;

修改AndroidManifest.xml 文件的 AppKey,如何生成和获取,请看第五步;

五、生成 AppKey

在我的应用里面,双击项目名称进入,点击各平台信息,点击新增按钮,会看到以下界面;

其中 应用签名SHA1 和 应用签名SHA256 是在准备工作第三步生成;

点击创建离线 Key 后,在点击查看离线 Key,就可以看到 AppKey 了。

六、离线打包

生成 uniapp 编译文件

将编译好的文件拷贝

七、Android Studio 创建设备

八、设置自适应图标、应用名称和编译后的安装包名称

使用 Image Asset 工具生成(最推荐)

不要手动去切几十张不同尺寸的图片,Android Studio 自带的工具能一键搞定:

  1. 打开工具:在 Android Studio 项目的 res 文件夹上点击 右键 -> New -> Image Asset
  2. 设置前景 (Foreground Layer)
    • Asset TypeImage
    • Path 选择你高清的 Logo 原图。
    • 调整 Resize 滑块:观察预览窗口中的圆圈,确保 Logo 完全在圈内。
  3. 设置背景 (Background Layer)
    • 你可以选一种颜色(Color),也可以选一张背景图(Image)。
  4. 设置预览 (Options)
    • Name 建议保持默认的 ic_launcher
  5. 生成:点击 Next -> Finish。它会自动在 res/mipmap-xxxx 下生成所有分辨率的图片。

检查清单文件 (AndroidManifest.xml)

生成好图片后,确保你的 AndroidManifest.xml 指向了这些文件:

<application
  android:icon="@mipmap/ic_launcher"
  android:roundIcon="@mipmap/ic_launcher_round"
  ...>
</application>

Vue2(二)——创建一个Vue实例

2026年1月31日 10:15

Vue 实例学习笔记

来源:v2.cn.vuejs.org/v2/guide/in…

一、创建一个 Vue 实例

1.1 基本语法

每个 Vue 应用都是通过用 Vue 函数创建一个新的 Vue 实例开始的:

var vm = new Vue({
  // 选项对象
})

关于变量名 vm:虽然没有完全遵循 MVVM 模型,但是 Vue 的设计也受到了它的启发。因此在文档中经常会使用 vm (ViewModel 的缩写) 这个变量名表示 Vue 实例。

1.2 选项对象

当创建一个 Vue 实例时,你可以传入一个选项对象。这篇教程主要描述的就是如何使用这些选项来创建你想要的行为。

⭐ 选项对象是什么?

选项对象就是在 new Vue({ ... }) 时传入的那个大括号包裹的对象,包含各种配置项,例如:

var vm = new Vue({
  el: '#app',        // 选项1:挂载点
  data: {            // 选项2:数据
    message: 'Hello'
  },
  created: function() {  // 选项3:生命周期钩子
    console.log('实例已创建')
  }
})

在这个例子中,eldatacreated 都是选项 property(选项属性)


二、Vue 应用的结构(重点理解)

2.1 根实例 + 组件树

原文:"一个 Vue 应用由一个通过 new Vue 创建的根 Vue 实例,以及可选的嵌套的、可复用的组件树组成。"

这句话的意思是:

  1. 根 Vue 实例:每个 Vue 应用有且只有一个根实例,通过 new Vue() 创建
  2. 组件树:在这个根实例下面,可以嵌套多个子组件,这些组件形成树状结构

用一个形象的比喻:

  • 根 Vue 实例 = 树的(只有一个)
  • 组件树 = 树的枝叶(可以有很多层,可以重复使用)

2.2 组件树示例

一个 todo 应用的组件树可以是这样的:

根实例 (Root Vue Instance - 通过 new Vue() 创建)
 └─ TodoList (组件)
    ├─ TodoItem (组件)
    │  ├─ TodoButtonDelete (组件)
    │  └─ TodoButtonEdit (组件)
    └─ TodoListFooter (组件)
       ├─ TodosButtonClear (组件)
       └─ TodoListStatistics (组件)

2.3 代码示例

// 1. 创建根实例(唯一的入口)
var vm = new Vue({
  el: '#app',
  data: {
    todos: [...]
  }
})

// 2. 在这个根实例中,可以嵌套多个组件
// 每个组件也是 Vue 实例,但不是通过 new Vue() 创建,而是通过 Vue.component() 定义

2.4 重要概念

所有的 Vue 组件都是 Vue 实例,并且接受相同的选项对象(一些根实例特有的选项除外)。

  • 根实例:通过 new Vue() 创建
  • 子组件:通过 Vue.component() 或组件选项创建

三、数据与方法

3.1 响应式系统

当一个 Vue 实例被创建时,它将 data 对象中的所有的 property 加入到 Vue 的响应式系统中。当这些 property 的值发生改变时,视图将会产生"响应",即匹配更新为新的值。

// 我们的数据对象
var data = { a: 1 }

// 该对象被加入到一个 Vue 实例中
var vm = new Vue({
  data: data
})

// 获得这个实例上的 property
// 返回源数据中对应的字段
vm.a == data.a // => true

// 设置 property 也会影响到原始数据
vm.a = 2
data.a // => 2

// ……反之亦然
data.a = 3
vm.a // => 3

3.2 响应式的限制

只有当实例被创建时就已经存在于 data 中的 property 才是响应式的。

如果你添加一个新的 property,比如:

vm.b = 'hi'

那么对 b 的改动将不会触发任何视图的更新。

解决方法:如果你知道你会在晚些时候需要一个 property,但是一开始它为空或不存在,那么你仅需要设置一些初始值:

data: {
  newTodoText: '',
  visitCount: 0,
  hideCompletedTodos: false,
  todos: [],
  error: null
}

3.3 使用 Object.freeze()

使用 Object.freeze() 会阻止修改现有的 property,也意味着响应系统无法再追踪变化。

var obj = {
  foo: 'bar'
}

Object.freeze(obj)

new Vue({
  el: '#app',
  data: obj
})
<div id="app">
  <p>{{ foo }}</p>
  <!-- 这里的 `foo` 不会更新! -->
  <button v-on:click="foo = 'baz'">Change it</button>
</div>

3.4 实例 Property 与方法

除了数据 property,Vue 实例还暴露了一些有用的实例 property 与方法。它们都有前缀 $,以便与用户定义的 property 区分开来。

var data = { a: 1 }
var vm = new Vue({
  el: '#example',
  data: data
})

vm.$data === data // => true
vm.$el === document.getElementById('example') // => true

// $watch 是一个实例方法
vm.$watch('a', function (newValue, oldValue) {
  // 这个回调将在 `vm.a` 改变后调用
})

四、实例生命周期钩子

4.1 什么是生命周期钩子

每个 Vue 实例在被创建时都要经过一系列的初始化过程——例如,需要设置数据监听、编译模板、将实例挂载到 DOM 并在数据变化时更新 DOM 等。同时在这个过程中也会运行一些叫做生命周期钩子的函数,这给了用户在不同阶段添加自己的代码的机会。

4.2 常用的生命周期钩子

  • created:实例创建完成后调用
  • mounted:实例挂载到 DOM 后调用
  • updated:数据更新导致视图重新渲染后调用
  • destroyed:实例销毁后调用

示例:

new Vue({
  data: {
    a: 1
  },
  created: function () {
    // `this` 指向 vm 实例
    console.log('a is: ' + this.a)
  }
})
// => "a is: 1"

4.3 ⭐ 避免使用箭头函数(重要)

不要在选项 property 或回调上使用箭头函数,比如:

// ❌ 错误写法
created: () => console.log(this.a)

vm.$watch('a', newValue => this.myMethod())

⭐ 什么是"选项 property"?

选项 property 就是在创建 Vue 实例时传入的选项对象的各个属性,例如:

new Vue({
  data: { ... },      // data 是一个选项 property
  created: function() {},  // created 是一个选项 property
  methods: { ... },   // methods 是一个选项 property
  computed: { ... }   // computed 是一个选项 property
})

为什么不能使用箭头函数?

因为箭头函数并没有 thisthis 会作为变量一直向上级词法作用域查找,直至找到为止,经常导致以下错误:

  • Uncaught TypeError: Cannot read property of undefined
  • Uncaught TypeError: this.myMethod is not a function

✅ 正确写法

new Vue({
  data: {
    a: 1
  },
  created: function () {
    // 使用普通函数,this 指向 Vue 实例
    console.log(this.a)
  },
  methods: {
    myMethod: function() {
      console.log(this.a)
    }
  }
})

// 或者使用简写形式(ES6)
new Vue({
  data: {
    a: 1
  },
  created() {
    console.log(this.a)
  },
  methods: {
    myMethod() {
      console.log(this.a)
    }
  }
})

简单记忆

  • 选项 property(如 datacreatedmethodscomputed)的值如果是函数,必须用普通函数,不能用箭头函数
  • 这样 this 才能正确指向 Vue 实例

五、生命周期图示

下图展示了实例的生命周期:

     创建 Vue 实例
          ↓
    beforeCreate(实例创建前)
          ↓
    created(实例创建后)← 可以在这里访问 data、methods
          ↓
    beforeMount(挂载前)
          ↓
    mounted(挂载后)← DOM 已经渲染完成
          ↓
      [运行中]
          ↓
    beforeUpdate(数据更新前)
          ↓
    updated(数据更新后)
          ↓
    beforeDestroy(销毁前)
          ↓
    destroyed(销毁后)

六、总结

6.1 核心概念

  1. 每个 Vue 应用通过 new Vue() 创建一个根实例
  2. 根实例下可以嵌套多个组件,形成组件树
  3. 所有 Vue 组件都是 Vue 实例

6.2 重要注意事项

  1. 响应式限制:只有在创建实例时存在于 data 中的 property 才是响应式的
  2. 避免箭头函数:在选项 property 中不要使用箭头函数,否则 this 指向会出错
  3. 实例 property:使用 $ 前缀的属性和方法,如 vm.$datavm.$elvm.$watch()

6.3 选项对象结构

new Vue({
  // 挂载点
  el: '#app',

  // 数据
  data: { ... },

  // 方法(不要用箭头函数)
  methods: {
    methodName() { ... }
  },

  // 生命周期钩子(不要用箭头函数)
  created() { ... },
  mounted() { ... },

  // 计算属性
  computed: { ... }
})

告别繁琐解析!Proxy 如何重塑 Vue3 编译时性能?

作者 boooooooom
2026年1月31日 09:45

在 Vue3 的全链路优化体系中,Proxy 不仅是响应式系统的核心基石,更是编译时优化的“隐形推手”。前文我们探讨了 Tree-shaking、静态提升、PatchFlag 等编译与渲染层面的优化,而这些优化能高效落地,离不开 Proxy 从底层重构了“响应式追踪”与“编译时解析”的逻辑。与 Vue2 依赖 Object.defineProperty 必须进行大量字符串解析不同,Vue3 Proxy 凭借其原生特性,彻底摆脱了繁琐的字符串解析开销,让编译时效率实现质的飞跃。本文将深度拆解 Proxy 带来的编译时优化核心,对比 Vue2 与 Vue3 编译时解析的差异,揭秘 Proxy 如何简化编译流程、提升解析效率,完善 Vue3 优化知识体系。

一、先回顾:Vue2 编译时的痛点——大量字符串解析的无奈

Vue2 的响应式系统基于 Object.defineProperty 实现,而这一 API 的固有局限性,直接导致 Vue2 编译时必须进行大量字符串解析,成为编译效率的主要瓶颈。要理解 Proxy 带来的优化,首先要明确 Vue2 字符串解析的“无奈之处”。

1. Object.defineProperty 的核心局限:只能监听具体属性

Object.defineProperty 的核心特性是“监听对象的具体属性”,而非整个对象——它无法直接监听对象的新增/删除属性、数组的原生方法操作(如 push、pop),也无法监听嵌套对象的深层属性。为了规避这一局限,Vue2 只能在编译阶段通过“字符串解析”,提前拆解响应式数据的访问路径,才能实现后续的依赖追踪。

2. Vue2 编译时的字符串解析:繁琐且低效

Vue2 在编译模板(如 {{ user.info.name }})和处理响应式数据时,必须进行大量字符串解析操作,核心场景有两个,且均存在明显性能开销:

场景1:模板插值的字符串拆分与解析

当模板中出现嵌套插值(如 {{ user.info.name }})时,Vue2 编译器无法直接识别该表达式的访问路径,只能将其当作字符串进行拆分解析:

  1. 将插值表达式 "user.info.name" 拆分为字符串数组 ["user", "info", "name"]
  2. 通过循环遍历数组,逐层访问对象属性(先取 user,再取 user.info,最后取 user.info.name);
  3. 为每一层属性单独通过 Object.defineProperty 绑定监听,确保深层属性的响应式生效。

// Vue2 编译时字符串解析逻辑(简化版)
// 模板:{{ user.info.name }}
const expr = "user.info.name";
// 字符串拆分(核心开销)
const keys = expr.split("."); 

// 逐层绑定监听(需循环解析)
function defineReactive(obj, keys, index) {
  const key = keys[index];
  Object.defineProperty(obj, key, {
    get() {
      // 依赖收集
      track();
      // 若未到最后一层,继续解析下一层
      if (index < keys.length - 1) {
        defineReactive(obj[key], keys, index + 1);
      }
      return obj[key];
    },
    set(newVal) {
      obj[key] = newVal;
      // 通知更新
      trigger();
    }
  });
}

// 初始调用,从第一层属性开始解析绑定
defineReactive(data, keys, 0);

场景2:数组操作的字符串解析与重写

由于 Object.defineProperty 无法监听数组原生方法(push、pop、splice 等),Vue2 只能通过“重写数组原型方法”的方式规避,但这也需要额外的字符串解析:

  • 编译时解析数组操作的字符串(如 arr.push(1)),判断是否为需要重写的原生方法;
  • 重写数组原型方法时,需解析方法参数的字符串,判断是否包含响应式数据,确保新增元素也能被绑定监听;
  • 这种字符串解析不仅繁琐,还会导致数组操作的编译开销增加,尤其在长数组、频繁操作数组的场景下,性能损耗明显。

字符串解析的核心弊端

Vue2 依赖的字符串解析,本质是“弥补 Object.defineProperty 局限性的被动方案”,其弊端十分突出,也是 Vue2 编译时效率低下的核心原因:

  1. 性能开销大:字符串拆分、循环解析、逐层绑定,每一步都需要消耗计算资源,嵌套层级越深、表达式越复杂,开销越大;
  2. 编译逻辑繁琐:编译器需要额外处理字符串解析、路径校验、异常捕获(如表达式错误),增加了编译复杂度;
  3. 扩展性差:无法高效支持动态属性名(如 obj[dynamicKey]),这类场景下字符串解析会失效,只能通过 $set 等手动 API 补充,进一步增加开发成本与编译开销。

二、Proxy 带来的编译时革命:无需字符串解析,直接监听全量

Vue3 放弃 Object.defineProperty,采用 ES6 原生 Proxy 重构响应式系统,其核心优势不仅是“支持新增/删除属性、数组原生方法监听”,更重要的是——Proxy 能直接监听整个对象(或数组),无需拆分属性路径,彻底摆脱字符串解析,让编译时逻辑大幅简化,效率显著提升。

1. Proxy 的核心特性:监听整个对象,无需属性拆分

Proxy 可以直接监听整个对象的“访问、设置、删除”等行为,无论属性是静态存在、动态新增,还是嵌套层级有多深,都能被 Proxy 统一捕获,无需像 Object.defineProperty 那样逐层绑定、拆分路径。这一特性从根源上消除了“字符串解析”的需求。


// Vue3 Proxy 编译时逻辑(简化版)
// 模板:{{ user.info.name }}
const data = reactive({ user: { info: { name: "Vue3" } } });

// Proxy 直接监听整个 data 对象,无需字符串拆分
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      // 依赖收集(自动追踪当前访问的属性,无需路径解析)
      track(target, key);
      const value = Reflect.get(target, key, receiver);
      // 若属性值是对象,自动递归监听(无需循环解析)
      if (typeof value === "object" && value !== null) {
        return reactive(value);
      }
      return value;
    },
    set(target, key, value, receiver) {
      // 通知更新
      trigger(target, key);
      return Reflect.set(target, key, value, receiver);
    },
    deleteProperty(target, key) {
      // 支持删除属性的监听,无需额外处理
      trigger(target, key);
      return Reflect.deleteProperty(target, key);
    }
  });
}

对比 Vue2 的字符串拆分+逐层绑定,Vue3 Proxy 的优势一目了然:对于 user.info.name 这样的嵌套属性,Proxy 在 get 捕获器中自动递归监听,无需拆分字符串路径,也无需循环绑定每一层属性,编译时逻辑大幅简化。

2. 编译时优化核心:3 大场景彻底摆脱字符串解析

基于 Proxy 的特性,Vue3 在编译时的三大核心场景中,彻底抛弃了字符串解析,实现效率飞跃,每一个场景都与前文的编译优化形成协同。

场景1:嵌套属性编译:自动递归监听,无需路径拆分

无论是模板插值({{ user.info.name }})还是代码中访问响应式数据(data.user.info.name),Vue3 编译器都无需再拆分属性路径字符串:

  • 编译时仅需识别响应式数据的引用(如 user),无需解析后续的 info.name 路径;
  • 运行时 Proxy 捕获到 user 的访问后,自动递归监听 user.infouser.info.name,无需编译时提前处理;
  • 嵌套层级越深,Proxy 带来的编译优化越明显——Vue2 需拆分多次字符串、循环绑定,Vue3 仅需一次监听,编译开销几乎不受嵌套层级影响。

场景2:数组操作编译:原生方法直接监听,无需重写解析

Proxy 能直接监听数组的所有原生方法(push、pop、splice 等),无需像 Vue2 那样重写数组原型,更无需解析数组操作的字符串:

  • 编译时遇到数组操作(如 arr.push(1)),仅需识别数组是响应式数据,无需解析 push 方法的参数、无需判断是否需要重写;
  • 运行时 Proxy 捕获到数组的 push 操作后,自动监听新增元素,无需编译时额外处理;
  • 这不仅简化了编译逻辑,还解决了 Vue2 数组操作的诸多限制(如无法监听稀疏数组、长数组操作卡顿等)。

场景3:动态属性编译:直接监听,无需手动 API 补充

Vue2 中,动态属性名(如 obj[dynamicKey])无法通过字符串解析识别,只能通过 $set 手动绑定,编译时还需额外解析判断是否为动态属性;而 Vue3 Proxy 能直接监听动态属性的访问与设置,编译时无需任何特殊处理:

<!-- Vue2:动态属性需手动 $set,编译时无法解析 dynamicKey -->
<script>
export default {
  methods: {
    setKey(dynamicKey, value) {
      this.$set(this.obj, dynamicKey, value); // 手动绑定
    }
  }
}
&lt;/script&gt;

<!-- Vue3:Proxy 直接监听动态属性,编译时无需解析 -->
<script setup>
import { reactive } from 'vue'
const obj = reactive({})
const setKey = (dynamicKey, value) => {
  obj[dynamicKey] = value; // 直接赋值,自动响应式
}
</script>

编译时,Vue3 仅需识别obj 是响应式数据,无需解析 dynamicKey 的具体值,大幅简化了编译逻辑,同时提升了开发体验。

三、Proxy 编译时优化的底层逻辑:编译与运行时的协同

Proxy 带来的编译时优化,本质是“将原本需要编译时完成的字符串解析、路径拆分工作,转移到运行时自动处理”,而这种转移之所以能提升整体效率,核心在于“运行时处理可复用、编译时逻辑可简化”,同时与 Vue3 其他编译优化形成协同。

1. 核心逻辑:编译时“轻量识别”,运行时“精准监听”

Vue3 编译时的核心职责从“复杂解析”转变为“轻量识别”:

  1. 编译时:仅识别模板/代码中的响应式数据引用(如 userarr),无需解析属性路径、无需处理动态属性、无需重写数组方法,编译逻辑大幅简化,编译速度提升;
  2. 运行时:Proxy 负责精准捕获所有属性访问、设置、删除行为,自动递归监听嵌套属性、自动处理数组操作、自动识别动态属性,无需编译时提前干预;
  3. 这种分工让“编译时更轻、运行时更智能”,整体效率远高于 Vue2“编译时大量解析、运行时逐层监听”的模式。

2. 与其他编译优化的协同效应

Proxy 带来的编译时优化,并非孤立存在,而是与前文提到的 Tree-shaking、静态提升、PatchFlag 等优化形成协同,构建起 Vue3 全链路优化体系:

  • 与 Tree-shaking 协同:Proxy 让响应式 API(ref、reactive)可按需引入,编译时无需解析全局响应式数据,进一步减少冗余编译逻辑,配合 Tree-shaking 移除未使用的响应式 API;
  • 与静态提升协同:编译时无需解析静态节点的属性路径(静态节点无需响应式监听),可快速将静态节点提升至渲染函数外部,Proxy 仅监听动态节点对应的响应式数据;
  • 与 PatchFlag 协同:编译时无需解析动态节点的属性路径,仅需为动态节点打上 PatchFlag,运行时 Proxy 捕获属性变化后,配合 PatchFlag 精准更新,无需全量 Diff。

四、实战对比:Proxy 编译优化的性能提升

以“嵌套属性访问+数组操作”为核心场景,对比 Vue2 与 Vue3 的编译时开销(基于相同模板、相同数据规模,生产环境打包):

场景 Vue2(Object.defineProperty) Vue3(Proxy) 性能提升
嵌套属性(3 层:obj.a.b.c)编译 需拆分 3 次字符串,循环绑定 3 层属性,编译耗时约 8ms 无需字符串拆分,一次监听,编译耗时约 2ms 75%
数组 push 操作(100 条数据)编译 需解析 push 方法字符串,重写原型方法,编译耗时约 12ms 无需解析,直接监听原生方法,编译耗时约 1ms 92%
动态属性赋值编译 需解析判断动态属性,手动绑定 $set,编译耗时约 6ms 无需解析,直接监听,编译耗时约 1ms 83%

实测数据显示,Proxy 彻底摆脱字符串解析后,Vue3 编译时效率平均提升 70% 以上,尤其在复杂嵌套、频繁操作数组的场景下,优化效果更为显著。同时,编译逻辑的简化也让 Vue3 编译器的维护成本降低,扩展性大幅提升。

五、避坑指南:Proxy 编译优化的注意事项

虽然 Proxy 带来了显著的编译时优化,但在实际开发中,仍需注意以下几点,避免浪费优化收益:

1. 避免过度嵌套响应式数据

Proxy 虽支持自动递归监听,但过度嵌套(如 10 层以上)仍会增加运行时监听开销,编译时虽无需解析,但运行时递归监听会消耗资源。建议合理拆分响应式数据,避免不必要的深层嵌套。

2. 区分响应式与非响应式数据

静态数据(无需响应式监听)无需用 reactive/ref 包裹,否则 Proxy 会额外监听,增加编译与运行时开销。配合前文的静态提升,将静态数据与响应式数据分离,最大化利用优化收益。

3. 避免频繁动态新增属性

Proxy 支持动态新增属性的监听,但频繁新增属性会导致运行时 trigger 频繁触发,虽不影响编译时效率,但会影响运行时性能。建议提前定义响应式属性,避免频繁动态新增。

4. 兼容处理:IE 浏览器不支持 Proxy

Proxy 是 ES6 原生 API,不支持 IE 浏览器。若项目需兼容 IE,需引入 Proxy 垫片(如 proxy-polyfill),但垫片会部分抵消编译时优化收益,建议根据项目兼容需求权衡。

六、总结:Proxy 重构 Vue3 编译时的核心价值

Proxy 带来的编译时优化,核心是“摆脱字符串解析的束缚”,将 Vue2 中“编译时繁琐解析、运行时逐层监听”的低效模式,重构为“编译时轻量识别、运行时精准监听”的高效模式。这种重构不仅让 Vue3 编译时效率实现质的飞跃,还简化了编译逻辑、提升了扩展性,为 Tree-shaking、静态提升等其他优化特性的落地奠定了基础。

从 Object.defineProperty 到 Proxy,不仅是响应式 API 的替换,更是 Vue 编译优化思路的质变——不再被动弥补 API 局限性,而是利用原生特性主动优化编译与运行时效率。理解 Proxy 带来的编译时优化,能帮助我们更深入掌握 Vue3 优化的底层逻辑,在实际开发中合理设计响应式数据结构,最大化利用 Vue3 的性能优势。

至此,Vue3 编译优化系列的核心知识点(静态提升、PatchFlag、Block Tree、Tree-shaking、Proxy 编译优化)已全部梳理完毕,这些特性相互协同,构建起 Vue3 全链路的性能优化体系,让 Vue3 相比 Vue2 在编译、渲染、打包等各个环节都实现了效率的全面提升。

相关文章

避坑+实战|Vue3 hoistStatic静态提升,让渲染速度翻倍的秘密

吃透 Vue3 PatchFlag!8 大类标识含义+精准比对逻辑

Vue3 渲染优化双核心:Block Tree 原理与 Fragment 根节点妙用

webpack 完成工程化建设

作者 ccccc__
2026年1月31日 09:34

引言 -- 我们为什么需要 webpack 等打包工具来进行工程化

我们在实现一个网页项目的时候,通常都会有很多的 js,css,其他的一些资源文件。如果我们直接丢给浏览器的话,他将会面临两个大问题: 1.要请求加载这些文件,速度很慢,会影响加载的效率。 2.浏览器对于某些文件无法识别,例如第三方node_module, ts,less 代码等文件。

这涉及到开发过程和运行过程,这些问题都是我们的痛点所在。开发过程我们希望改动文件后编辑加载的速度能更快,让我们更好的调试代码。运行上线的时候我们则是希望能够加载的更加顺畅。这就是我们的打包工具所要解决的问题,我们通过使用打包工具,将这些文件进行加工,将他们整合成尽可能少的文件数量,以及浏览器能够识别的文件来进行编译。

核心概念 => webpack

webpack

webpack 是一个用于前端开发的静态模块打包工具,能够将项目中的各种资源(如javaScript,css,图片等)视为模块,通过分析依赖关系打包成优化后的静态文件来提升开发效率和项目可维护性。

关键组成部分

入口:Entry:指定打包的入口文件 出口:Output:定义打包后的文件的输出路径和名称。 加载器:Loader:负责将各类非 JavaSript 资源转化成模块。(帮助浏览器将有可能无法识别的文件转化成能够识别的文件) 插件:Plugin:用于执行复杂的自定义任务,扩展 webpack 功能。 文件别名配置:resolve

module.exports = {
    // 入口配置
    entry: './src/main.js',  
    // 产物输出路径
    output: {}, 
    // 配置模块解析的具体行为,(定义 webpack 在打包的时候,是怎么找到并解析具体模块)
    resolve: {
      extensions: [".js", ".vue", ".less", "css"] // 自动解析这些文件
      alias: { $xxxx: xxxx } // 可以将对应的路径文件指向一个具体的替代
    }, 
    // 配置插件
    plugins: [
        // 处理vue文件,将我们设定的规则都能够在vue文件中运行
        new VueLoaderPlugin(),
        // 把第三方库暴露到全局 window 上下文, 可以在文件中直接使用,例如 vue, axios等库,无需再单独导入
        new webpack.ProvidePlugin({
             Vue: "vue",
             axios: "axios",
             _: "lodash",
        }),
        // 定义全局常量 关于vue的一些设置
        new webpack.DefinePlugin({
          _VUE_OPTIONS_API_: true, // 支持vue解析optionsApi
          _VUE_PROD_DEVTOOLS_: false, // 禁用vue调试工具
          _VUE_PROD_HYDRATIO_MISMATCH_DETAILS_: false, // 禁用生产环境显示 '水合' 信息
        }),
        // 渲染我们指定的模板代码
         new HtmlWebpackPlugin({
              // 产物(最终模板)输出路径
              filename: path.resolve(process.cwd(), "../app/public/dist/entry.tpl"),
              // 指定要使用的模板文件
              template: path.resolve(__dirname, "../../view/entry.tpl"), 
              // 要注入的代码块
              chunks: [`${enrtyName}`], 
    })
    ], 
    // 打包输出优化(配置代码分割,缓存,Treeshaing,压缩优化行为)
    optimization: {
     // 将 js 文件打包成三种类型
     // 1. vendor: 第三方库代码
     // 2. common:业务组件代码的公共部分
     // 3. entry:每个页面独有的业务代码
     // 将改动频率不一样的js区分出来,更好的利用浏览器缓存的效果
     splitChunks: {
         // 对所有的模板都进行分割
         chunks: 'all', 
         // 每次异步加载的最大并行请求数
         maxAsyncRequests: 10,
         // 入口点的最大并行请求数量
         maxInitialRequests: 10,
          cacheGroups: {
          vendor: {
            //第三方依赖库
            test: /[\\/]node_modules[\\/]/, //打包node_modules中的文件
            name: "vendor", //模块名称
            priority: 20, //优先级
            enforce: true, //强制执行
            reuseExistingChunk: true, //可复用已存在的模块
          },
          common: {
            //公共模块
            test: /[\\/]common|widgets[\\/]/,
            name: "common", //模块名称
            minChunks: 2, //最少被两个模块引用才打包
            minSize: 1, //最小分割文件大小
            priority: 10, // 优先级
            reuseExistingChunk: true, //可复用已存在的模块
          },
        },
     }
    } 
}

基础的配置如上述代码所示,这样子我们就完成了一个基础的 webpack 配置。

工程化设计

多环境差异化配置

我们可以划分成为开发环境和生产环境,每个环境对应的配置有共同性,也有差异性,开发环境更注重调试和开发效率,而生产环境则更重视代码压缩和资源优化,所以我们将其配置文件划分成为三块

image.png

  • webpack.base.js: 做多入口文件配置,模板生成,还有一些共同性的配置,然后暴露出去为baseConfig,用于其他环境配置对他进行扩展。
  • webpack.dev.js: 热更新HMR,devtool开发工具,热更新插件配置
const merge = require('webpack-merge');
const path = require('path')
const webpack = require('webpack')
// 基类配置
const baseConfig = require('./webpack.base.js');

const DEV_SERVER_CONFIG = {
    HOST: '127.0.0.1',
    PORT: 9002,
    HMR_PATH: '__webpack_hmr', // 官方规定,修正命名
    TIMEOUT: 20000
}


//开发阶段的entry配置需要加入hmr
Object.keys(baseConfig.entry).forEach(v => {
    //第三方包不作为hmr入口
    if (v !== 'vendor') {
        baseConfig.entry[v] = [
            //主入口文件
            baseConfig.entry[v],
            //hmr更新入口,官方指定的hmr路径
            `${require.resolve('webpack-hot-middleware/client')}?path=http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/${DEV_SERVER_CONFIG.HMR_PATH}&timeout=${DEV_SERVER_CONFIG.TIMEOUT}&reload=true`
        ]
    }
}) 

//开发环境 webpack 配置
const webpackDevConfig = merge.smart(baseConfig, {
    //指定开发环境
    mode: 'development',
    //source-map 开发工具,呈现代码映射关系,以便在开发过程中调试代码
    devtool: 'eval-cheap-module-source-map',
    //开发阶段output配置
    output: {
        filename: 'js/[name]_[chunkhash:8].bundle.js',
        path: path.resolve(process.cwd(), './app/public/dist/dev/'), //输出文件存储路径
        publicPath: `http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/public/dist/dev`, //外部资源公共路径
        globalObject: 'this'
    },
    //开发阶段插件
    plugins: [
        //用于实现热模块替换()
        //允许在运用程序运行时替换模块
        //极大的提升开发效率,因为能让应用程序一直保持运行状态
        new webpack.HotModuleReplacementPlugin({
            multiStep: false,
        })
    ]
})

module.exports = {
    // webpack 配置(导出为 `webpackDevConfig` 以匹配使用处)
    webpackDevConfig,
    // devServer 配置 暴露给 dev.js 使用
    DEV_SERVER_CONFIG
};
  • webpack.prod.js: 重视代码资源,会做一些代码压缩操作,tree-shaking 剔除无用代码,以及happypack多线程打包
const path = require('path');
const merge = require('webpack-merge');
const os = require('os');
const HappyPack = require('happypack');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CleanWEbpackPlugin = require('clean-webpack-plugin');
const CSSMinimizerPlugin = require('css-minimizer-webpack-plugin');
const HtmlWebpackInjectAttributesPlugin = require('html-webpack-inject-attributes-plugin');
const TerserPlugin = require('terser-webpack-plugin');
// 基类配置
const baseConfig = require('./webpack.base.js');

//多线程build配置
const happypackCommonConfig = {
    debug: false,
    threadPool: HappyPack.ThreadPool({ size: os.cpus().length }),
}


//生产环境 webpack 配置
const webpackProdConfig = merge.smart(baseConfig, {
    mode: 'production', //指定生产环境
    output: baseConfig.output = {
        filename: 'js/[name]_[chunkhash:8].bundle.js',
        path: path.join(process.cwd(), './app/public/dist/prod'),
        publicPath: '/dist/prod/',
        crossOriginLoading: 'anonymous'
    },
    module: {
        rules: [{
            test: /\.css$/,
            use: [MiniCssExtractPlugin.loader, `${require.resolve('happypack/loader')}?id=css`]
        }, {
            test: /\.js$/,
            include: [
                path.resolve(__dirname, '../../pages'),
                // 处理业务目录下的
                path.resolve(process.cwd(), "./app/pages"),
            ],
            use: [`${require.resolve('happypack/loader')}?id=js`]
        }]
    },
    // webpack 不会有大量 hints 信息, 默认为 warning
    performance: {
        hints: false
    },
    plugins: [
        //每次 build 前清空public/dist目录
        new CleanWEbpackPlugin(['public/dist/prod'], {
            root: path.resolve(process.cwd(), './app/'),
            exclued: [],
            verbose: true,
            dry: false
        }),
        //提取css的公共部分,有效利用缓存,非公共的css使用inline
        new MiniCssExtractPlugin({
            chunkFilename: 'css/[name]_[contenthash:8].bundle.css',
        }),
        //优化并压缩css资源
        new CSSMinimizerPlugin(),
        //多线程打包js,加快打包速度
        new HappyPack({
            ...happypackCommonConfig,
            id: 'js',
            loaders: [`${require.resolve('babel-loader')}?${JSON.stringify({
                presets: [require.resolve('@babel/preset-env')],
                plugins: [
                    require.resolve('@babel/plugin-transform-runtime'),
                ]
            })}`]
        }),
        //多线程打包css,加快打包速度
        new HappyPack({
            ...happypackCommonConfig,
            id: 'css',
            loaders: [{
                path: require.resolve('css-loader'),
                options: {
                    importLoaders: 1
                }
            }]
        }),
        //浏览器在请求资源的时候,不发送用户的身份凭证
        new HtmlWebpackInjectAttributesPlugin({
            corssorigin: 'anonymous'
        })
    ],
    optimization: {
        //使用 TerserPlugin 的并发和缓存,提升压缩阶段的性能
        //清除console.log
        minimize: true,
        minimizer: [
            new TerserPlugin({
                cache: true, //启用缓存来加速构建过程
                parallel: true, //利用多核cpu的优势加快压缩速度
                terserOptions: {
                    compress: {
                        drop_console: true //去掉console.log
                    }
                }
            })
        ]
    }
})

module.exports = webpackProdConfig;

多入口配置,只要按照约定好的文件格式创建文件,即可自动引入为入口文件,并且生成对应的HTML模板,无需手动一个个配置入口文件
// 动态构建 pageEntryies htmlWebpackPluginList
const elpisPageEntries = {};
const elpisHtmlWebpackPPluginList = [];

// 获取 app/pages 下的所有入口文件
const elpisEntryList = path.resolve(__dirname, "../../pages/**/entry.*.js");

glob.sync(elpisEntryList).forEach((file) => {
  handleEntry(file, elpisPageEntries, elpisHtmlWebpackPPluginList);
});

// 动态构建 业务页面入口
const businessEntries = {};
const businessHtmlWebpackPPluginList = [];

const businessEntryList = path.resolve(
  process.cwd(),
  "./app/pages/**/entry.*.js"
);
glob.sync(businessEntryList).forEach((file) => {
  handleEntry(file, businessEntries, businessHtmlWebpackPPluginList);
});

function handleEntry(file, entries = {}, htmlWebpackPPluginList = []) {
  const enrtyName = path.basename(file, ".js");
  //构造entry
  entries[enrtyName] = file;
  //构造最终渲染的页面文件
  htmlWebpackPPluginList.push(
    new HtmlWebpackPlugin({
      filename: path.resolve(
        process.cwd(),
        "./app/public/dist/",
        `${enrtyName}.tpl`
      ), //产物(最终模板)输出路径
      template: path.resolve(__dirname, "../../view/entry.tpl"), //指定要使用的模板文件
      chunks: [`${enrtyName}`], //要注入的代码块
    })
  );
}

// 加载 业务 webpack 配置
let businessWebpackConfig = {};
try {
  businessWebpackConfig = require(`${process.cwd()}/app/webpack.config.js`);
} catch (e) {}
如何实现热更新?

热更新的流程可以概括为:监控文件改动 -> 识别模块变化 -> 通知浏览器 -> 浏览器局部更新。 热更新可以划分成为两个重点,如何监听文件修改?,如何告知浏览器? webpack其实提供了两个核心的中间件来帮助我们实现热更新,devMiddleware(文件监控和编译),hotMiddleware(与浏览器通信)

 app.use(
    devMiddleware(compiler, {
      //落地文件
      writeToDisk: (filePath) => {
        return filePath.endsWith(".tpl");
      },
      //资源路径
      publicPath: webpackDevConfig.output.publicPath,

      //headers配置
      headers: {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Methods":
          "GET, POST, PUT, DELETE, PATCH, OPTIONS",
        "Access-Control-Allow-Headers":
          "X-Requested-With, content-type, Authorization",
      },

      stats: {
        color: true,
      },
    })
  );
  //引用hotMiddleware中间件,实现热更新
  app.use(
    hotMiddleware(compiler, {
      path: `/${DEV_SERVER_CONFIG.HMR_PATH}`,
      log: () => {},
    })
  );

React、TypeScript、reduce 以及 Ant Design Pro Components(antProcomponent)搭建开发环境教程

2026年1月31日 09:29

你想要的是一套完整的教程,指导如何搭建一个结合了 React、TypeScript、reduce 以及 Ant Design Pro Components(antProcomponent)的开发环境,我会一步步带你完成这个过程。

一、环境准备(前置条件)

在开始前,请确保你的电脑已安装以下工具:

  1. Node.js(推荐 16.x 或 18.x 版本,可通过 node -v 检查)
  2. npm 或 yarn(npm 随 Node.js 自带,yarn 可通过 npm install -g yarn 安装)
  3. 代码编辑器(推荐 VS Code)

二、搭建 React + TypeScript 基础项目

Ant Design Pro 提供了开箱即用的脚手架,能快速集成 TypeScript 和 Pro Components,比手动配置更高效:

步骤 1:创建 Ant Design Pro 项目

打开终端,执行以下命令(选择 TypeScript 模板):

# 使用官方脚手架创建项目
npm create umi@latest my-pro-app
# 或用 yarn
yarn create umi my-pro-app

执行后会出现交互式选择,按以下选项配置:

  1. 选择 ant-design-pro(Pro 模板)
  2. 选择 TypeScript(语言)
  3. 选择 simple(简易模板,适合新手)
  4. 确认安装依赖(等待依赖下载完成)
步骤 2:安装依赖并启动项目

进入项目目录,安装依赖并启动:

cd my-pro-app
# 安装依赖
npm install
# 启动开发服务
npm run start

启动成功后,浏览器会自动打开 http://localhost:8000,能看到 Ant Design Pro 的基础页面,说明 React + TypeScript + Pro Components 环境已搭建完成。

三、集成 reduce(数组方法)的实战示例

React 中常使用 reduce 处理数组数据(比如数据聚合、筛选、格式化),结合 TypeScript 能保证类型安全,以下是结合 Pro Components(如 ProTable)的实战示例:

示例:用 reduce 处理表格数据并渲染 ProTable
  1. src/pages 下新建 ReduceDemo.tsx 文件,写入以下代码:
import React from 'react';
import { ProTable } from '@ant-design/pro-components';

// 定义数据类型(TypeScript 类型约束)
interface UserData {
  id: number;
  name: string;
  department: string;
  salary: number;
}

const ReduceDemo: React.FC = () => {
  // 原始模拟数据
  const rawData: UserData[] = [
    { id: 1, name: '张三', department: '前端', salary: 15000 },
    { id: 2, name: '李四', department: '后端', salary: 18000 },
    { id: 3, name: '王五', department: '前端', salary: 16000 },
    { id: 4, name: '赵六', department: '后端', salary: 20000 },
  ];

  // 用 reduce 聚合:按部门分组,计算每个部门的平均工资
  const departmentSalary = rawData.reduce((acc, item) => {
    // 类型守卫,确保 acc 结构正确
    if (!acc[item.department]) {
      acc[item.department] = {
        total: 0,
        count: 0,
        average: 0,
        list: [] as UserData[],
      };
    }
    // 累加工资和人数
    acc[item.department].total += item.salary;
    acc[item.department].count += 1;
    // 存入当前用户
    acc[item.department].list.push(item);
    // 计算平均工资(保留2位小数)
    acc[item.department].average = Number(
      (acc[item.department].total / acc[item.department].count).toFixed(2)
    );
    return acc;
  }, {} as Record<string, { total: number; count: number; average: number; list: UserData[] }>);

  // 转换为 ProTable 可渲染的格式
  const tableData = Object.entries(departmentSalary).map(([dept, data]) => ({
    department: dept,
    totalSalary: data.total,
    personCount: data.count,
    averageSalary: data.average,
  }));

  // ProTable 列配置
  const columns = [
    {
      title: '部门',
      dataIndex: 'department',
      key: 'department',
    },
    {
      title: '总工资',
      dataIndex: 'totalSalary',
      key: 'totalSalary',
    },
    {
      title: '人数',
      dataIndex: 'personCount',
      key: 'personCount',
    },
    {
      title: '平均工资',
      dataIndex: 'averageSalary',
      key: 'averageSalary',
    },
  ];

  return (
    <div style={{ padding: 20 }}>
      <h2>Reduce + ProTable 实战示例</h2>
      <ProTable
        dataSource={tableData}
        columns={columns}
        rowKey="department"
        pagination={false}
        title={() => '各部门工资统计(基于reduce聚合)'}
      />
    </div>
  );
};

export default ReduceDemo;
  1. 配置路由:修改 src/config/routes.ts,添加新路由:
import { IRouteProps } from '@umijs/max';

const routes: IRouteProps[] = [
  {
    path: '/',
    redirect: '/reduce-demo',
  },
  {
    path: '/reduce-demo',
    name: 'Reduce示例',
    component: './ReduceDemo',
  },
];

export default routes;
  1. 重启项目(npm run start),访问 http://localhost:8000/reduce-demo,就能看到用 reduce 聚合后的数据通过 ProTable 渲染的表格。

四、关键知识点解释

  1. TypeScript 类型约束
  • 定义 UserData 接口规范原始数据结构,避免类型错误;
  • Record<string, ...> 约束 reduce 累加器的类型,确保 acc 的属性和值类型符合预期。
  1. reduce 核心逻辑
  • 第一个参数:回调函数,接收 acc(累加器)和 item(当前项),返回更新后的累加器;
  • 第二个参数:累加器初始值(这里是空对象,且用 TypeScript 定义了结构);
  • 作用:替代循环,高效完成“分组 + 计算”的聚合操作。
  1. Ant Design Pro Components 优势
  • ProTable 内置了分页、筛选、排序等功能,比原生 Table 更易用;
  • 支持 TypeScript 类型推导,列配置的 dataIndex 会自动校验是否匹配数据源。

总结

  1. 环境搭建核心:通过 Ant Design Pro 脚手架快速集成 React + TypeScript + Pro Components,无需手动配置复杂的 Webpack/TSConfig;
  2. reduce 实战:结合 TypeScript 类型约束,用 reduce 完成数组聚合(分组、计算),再通过 ProTable 渲染结果;
  3. 关键要点:TypeScript 接口/类型守卫保证类型安全,reduce 替代循环提升代码简洁性,Pro Components 简化业务组件开发。

如果需要扩展功能(比如接口请求、状态管理),可以基于这个基础环境,结合 Umi Max 的请求库(@umijs/max)或 React Context/Redux 进一步开发。

纯原生html js实现幸运抽奖转盘功能-奖项不重复(javascript小案例)

作者 崧峻
2026年1月31日 09:21

创建项目

如图所示创建一个基本html项目,创建好之后如图2所示 image.png

九宫格效果

我们先来实现九宫格的效果吧,有两种一种是按照顺序的九宫格如图1所示,一种是一圈的效果如图2所示,我们本期以第一种九宫格为案例进行实现

image.pngimage.png

html部分代码

<body>
<div id="turntable-box">
<div>一等奖</div>
<div>二等奖</div>
<div>三等奖</div>
<div>四等奖</div>
<div id="startBtn">参与游戏<br />赢得大奖</div>
<div>五等奖</div>
<div>六等奖</div>
<div>七等奖</div>
<div>八等奖</div>
</div>
</body>

css部分代码样式

<style type="text/css">

  


/* 转盘的外层边框 */

#turntable-box {

display: flex;

align-items: center;

justify-content: center;

flex-wrap: wrap;

gap: 15px;

border-radius: 15px;

background-color: #922a21;

position: relative;

width: 510px;

height: 420px;

margin: 0 auto;

padding: 20px;

}

  


/* 转盘下面的每个模块样式 */

#turntable-box > div {

display: flex;

align-items: center;

justify-content: center;

border-radius: 25px;

width: 160px;

background-color: #f8e7c1;

height: 130px;

text-align: center;

font-size: 25px;

}

</style>

这个就是很简单的一个换行排序 flex布局

#turntable-box .reward:nth-child(1) {
top: 10px;
left: 10px;
}

#turntable-box .reward:nth-child(2) {
top: 10px;
left: 175px;
}
#turntable-box .reward:nth-child(3) {
top: 10px;
left: 340px;
}
#turntable-box .reward:nth-child(4) {
left: 340px;
top: 145px;
}
#turntable-box .reward:nth-child(5) {
left: 340px;
top: 280px;
}
#turntable-box .reward:nth-child(6) {
top: 280px;
left: 175px;
}
#turntable-box .reward:nth-child(7) {
left: 10px;
top: 280px;
}
#turntable-box .reward:nth-child(8) {
left: 10px;
top: 145px;
}

如果大家想要一圈形式的布局代码参考如下 其实就是利用了定位让他形成了一圈

美化样式

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>幸运转盘</title>
<style type="text/css">
body {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
overflow: hidden;
width: 100vw;
height: 100vh;
background: url('img/background.jpg');
/* 背景图片居中 */
background-position: center;
/* 背景图片不重复 */
background-repeat: no-repeat;
}


/* 幸运抽奖的标题图片 */
.draw-title {
z-index: 10;
width: 300px;
transform: translateY(10px);
}


/*  转盘的外层边框 */
#turntable-content {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 5px;
border-radius: 15px;
background-color: #922a21;
position: relative;
width: 510px;
height: 410px;
margin: 0 auto;
padding: 10px;
}


/*  转盘下面的每个模块样式 */
#turntable-content>div {
display: flex;
align-items: center;
justify-content: center;
border-radius: 25px;
width: 160px;
height: 130px;
text-align: center;
font-size: 25px;
}






/* 幸运转盘最外层 */
.draw-turntable-box {
display: flex;
align-items: center;
justify-content: center;
border-radius: 5%;
background: #de4b3f;
height: 500px;
width: 600px;
}


/* 开始抽奖按钮 */
#startBtn {
cursor: pointer;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
white-space: normal;
background-color: #eb5e4a;
color: white;
}

/* 奖项 */
.unselected {
background-color: #f8e7c1;
}

#selected {
background-color: #ee7c45;
}
</style>
</head>
<body>
<img src="./img/draw.jpg" alt="抽奖标题" class="draw-title" />
<div class="draw-turntable-box">
<div id="turntable-content">
<div class="award unselected">一等奖</div>
<div class="award unselected">二等奖</div>
<div class="award unselected">三等奖</div>
<div class="award unselected">四等奖</div>
<div id="startBtn">参与游戏<br />赢得大奖</div>
<div class="award unselected">五等奖</div>
<div class="award unselected">六等奖</div>
<div class="award unselected">七等奖</div>
<div class="award unselected">八等奖</div>
</div>
</div>
</body>
</html>

js代码实现-抽奖不重复

image.png 实现原理是给开始按钮绑定一个点击事件,然后进行依次变色,最后随机一个没有抽到过的数,然后抽奖成功后把这个数放到一个变量中存储进行去重,完整js代码如下 如图所示

// 奖项元素列表
var award = document.getElementsByClassName("award");
// 已经拿到的奖励序号
const lucks = [];
// 是否正在抽奖
let running = false;

// 开始按钮元素
var startBtn = document.getElementById("startBtn");

startBtn.onclick = function() {
// 判断是否正在抽奖
if (running) return;
// 设置为正在抽奖
running = true;

// 可抽取的奖项编号(1~8)排除已经抽取的
const available = Array.from({ length: award.length }, (_, i) => i + 1)
.filter(i => !lucks.includes(i));

// 全部抽完
if (available.length === 0) {
startBtn.innerHTML = "所有奖项已抽完";
running = false;
return;
}

// 随机中奖编号(1~8中未抽的一个)
const target = available[Math.floor(Math.random() * available.length)];

// 随机圈数(2~4圈)
const rounds = Math.floor(Math.random() * 2) + 2;
const totalSteps = rounds * award.length + (target - 1);

let current = 0;
let speed = 60;

function spin() {
const index = current % award.length;

// 重置未中奖项颜色
for (let i = 0; i < award.length; i++) {
if (!lucks.includes(i + 1)) {
award[i].className = "award unselected";
}
}

// 高亮当前
award[index].className = "selected award";

current++;

// 是否结束
if (current > totalSteps) {
lucks.push(index + 1);
startBtn.innerHTML = `恭喜您中了<br>${index + 1}等奖 🎊`;
running = false;
return;
}

// 越接近目标速度越慢
if (totalSteps - current < award.length) {
speed += 40;
}

setTimeout(spin, speed);
}

spin();
};
</script>

完整代码



<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>幸运转盘</title>
<style type="text/css">
body {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
overflow: hidden;
width: 100vw;
height: 100vh;
background: url('img/background.jpg');
/* 背景图片居中 */
background-position: center;
/* 背景图片不重复 */
background-repeat: no-repeat;
}


/* 幸运抽奖的标题图片 */
.draw-title {
z-index: 10;
width: 300px;
transform: translateY(10px);
}


/*  转盘的外层边框 */
#turntable-content {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 5px;
border-radius: 15px;
background-color: #922a21;
position: relative;
width: 510px;
height: 410px;
margin: 0 auto;
padding: 10px;
}


/*  转盘下面的每个模块样式 */
#turntable-content>div {
display: flex;
align-items: center;
justify-content: center;
border-radius: 25px;
width: 160px;
height: 130px;
text-align: center;
font-size: 25px;
}






/* 幸运转盘最外层 */
.draw-turntable-box {
display: flex;
align-items: center;
justify-content: center;
border-radius: 5%;
background: #de4b3f;
height: 500px;
width: 600px;
}


/* 开始抽奖按钮 */
#startBtn {
cursor: pointer;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
white-space: normal;
background-color: #eb5e4a;
color: white;
}

/* 奖项 */
.unselected {
background-color: #f8e7c1;
}

.selected {
background-color: #ee7c45;
}
</style>
</head>
<body>
<img src="./img/draw.jpg" alt="抽奖标题" class="draw-title" />
<div class="draw-turntable-box">
<div id="turntable-content">
<div class="award unselected">一等奖</div>
<div class="award unselected">二等奖</div>
<div class="award unselected">三等奖</div>
<div class="award unselected">四等奖</div>
<div id="startBtn">参与游戏<br />赢得大奖</div>
<div class="award unselected">五等奖</div>
<div class="award unselected">六等奖</div>
<div class="award unselected">七等奖</div>
<div class="award unselected">八等奖</div>
</div>
</div>
</body>


<script type="text/javascript">
// 奖项元素列表
var award = document.getElementsByClassName("award");
// 已经拿到的奖励序号
const lucks = [];
// 是否正在抽奖
let running = false;

// 开始按钮元素
var startBtn = document.getElementById("startBtn");

startBtn.onclick = function() {
// 判断是否正在抽奖
if (running) return;
// 设置为正在抽奖
running = true;

// 可抽取的奖项编号(1~8)排除已经抽取的
const available = Array.from({ length: award.length }, (_, i) => i + 1)
.filter(i => !lucks.includes(i));

// 全部抽完
if (available.length === 0) {
startBtn.innerHTML = "所有奖项已抽完";
running = false;
return;
}

// 随机中奖编号(1~8中未抽的一个)
const target = available[Math.floor(Math.random() * available.length)];

// 随机圈数(2~4圈)
const rounds = Math.floor(Math.random() * 2) + 2;
const totalSteps = rounds * award.length + (target - 1);

let current = 0;
let speed = 60;

function spin() {
const index = current % award.length;

// 重置未中奖项颜色
for (let i = 0; i < award.length; i++) {
if (!lucks.includes(i + 1)) {
award[i].className = "award unselected";
}
}

// 高亮当前
award[index].className = "selected award";

current++;

// 是否结束
if (current > totalSteps) {
lucks.push(index + 1);
startBtn.innerHTML = `恭喜您中了<br>${index + 1}等奖 🎊`;
running = false;
return;
}

// 越接近目标速度越慢
if (totalSteps - current < award.length) {
speed += 40;
}

setTimeout(spin, speed);
}

spin();
};
</script>
</html>

转载自DOIT社区纯原生html js实现幸运抽奖转盘功能-奖项不重复(javascript小案例)-DOIT社区 如有需要大家可以去此地址拿取文件

JavaScript原型链 - 继承的基石与核心机制

作者 wuhen_n
2026年1月31日 07:55

前言:从一道面试题说起

function Person() {}
const person = new Person();

console.log(person.__proto__ === Person.prototype); // true
console.log(Person.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__); // null

console.log(person.constructor === Person); // true
console.log(Person.constructor === Function); // true
console.log(person.constructor === Person.prototype.constructor); // true (!)
console.log(Function.constructor === Function); // true (!)

如果你能完全理解上面的代码,那么你已经掌握了原型链的核心。如果不能,本篇文章将带我们一步步揭开原型链的神秘面纱。

构造函数、实例、原型的三者关系

在JavaScript中,每个对象都有一个特殊的内部属性[[Prototype]](可以通过__proto__访问),它指向该对象的原型对象。

function Person(name) {
    this.name = name;
}

// 原型对象:所有实例共享的方法和属性
Person.prototype.sayHello = function() {
    console.log(`Hello, I'm ${this.name}`);
};

// 实例:通过new创建的对象
const zhangsan = new Person('zhangsna');

// 三者关系验证
console.log(zhangsan.__proto__ === Person.prototype); // true
console.log(Person.prototype.constructor === Person); // true

prototype和__proto__的区别与联系

  • prototype 是函数特有的属性。
  • __proto__ 是每个对象都有的属性。
function Foo() {}
const obj = new Foo();

console.log(typeof Foo.prototype); // "object"
console.log(typeof obj.__proto__); // "object"

console.log(Foo.prototype === obj.__proto__); // true
console.log(Foo.__proto__ === Function.prototype); // true

// 一定要注意:函数也是对象,所以函数也有__proto__
console.log(Foo.__proto__ === Function.prototype); // true
console.log(Function.prototype.__proto__ === Object.prototype); // true
  1. 每个函数都有一个 prototype 属性,指向该函数的原型对象
  2. 每个对象都有一个 __proto__ 属性,指向创建该对象的构造函数的原型对象
  3. 原型对象的 constructor 属性指向创建该实例对象的构造函数。
  4. Function 是一个特殊的函数,它的 constructor 指向它自己。

完整的原型链结构

function Animal(name) {
    this.name = name;
}

Animal.prototype.eat = function() {
    console.log(`${this.name} is eating`);
};

function Dog(name, breed) {
    Animal.call(this, name); // 调用父类构造函数
    this.breed = breed;
}

// 设置原型链
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog; // 修复constructor指向

Dog.prototype.bark = function() {
    console.log(`${this.name} is barking`);
};

const myDog = new Dog('Buddy', 'Golden Retriever');

// 原型链查找路径:
console.log(myDog.__proto__ === Dog.prototype); // true
console.log(Dog.prototype.__proto__ === Animal.prototype); // true
console.log(Animal.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__); // null

上述代码中,原型链的查找过程:

  1. myDog 本身有 namebreed 属性
  2. myDog.__proto__ (Dog.prototype) 有 bark 方法
  3. Dog.prototype.__proto__ (Animal.prototype) 有 eat 方法
  4. Animal.prototype.__proto__ (Object.prototype) 有 toString 等方法
  5. Object.prototype.__proto__null,查找结束

Object.prototype是所有原型链的终点。

属性屏蔽规则

hasOwnProperty vs in操作符

  • hasOwnProperty: 检查属性是否在对象自身(不在原型链上)
  • in操作符: 检查属性是否在对象自身或原型链上

属性屏蔽的三种情况

function Parent() {
    this.value = 'parent value';
}

Parent.prototype.shared = 'parent shared';

function Child() {
    this.value = 'child value'; 
}

Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

Child.prototype.shared = 'child shared'; 

const child = new Child();

情况1:对象自身有该属性(完全屏蔽)

以上述代码为例,Child 本身有自己的 value 属性,当调用 value 属性时,会完全屏蔽原型链上的同名 value 属性:

console.log(child.value); // 'child value'(不是'parent value')
console.log(child.hasOwnProperty('value')); // true

情况2:对象自身本来没有,但添加对象自身属性

console.log(child.shared); // 'child shared'(来自Child.prototype)
console.log(child.hasOwnProperty('shared')); // false

// 添加对象自身属性
child.shared = 'instance shared';
console.log(child.shared); // 'instance shared'(现在自身有了)
console.log(child.hasOwnProperty('shared')); // true

情况3:属性是只读的

Parent.prototype.readOnly = 'cannot change';

// 试图修改只读属性
child.readOnly = 'try to change';
console.log(child.readOnly); // 'cannot change'(修改失败)
console.log(child.hasOwnProperty('readOnly')); // false(没有添加成功)

原型链的基础结构

function Person(name) {
    this.name = name;
}

Person.prototype.sayHello = function() {
    console.log(`Hello, ${this.name}`);
};

const zhangsan = new Person('zhangsan');

其原型结构图如下:

zhangsan (实例)
  ├── __proto__: Person.prototype
  │      ├── constructor: Person
  │      ├── sayHello: function()
  │      └── __proto__: Object.prototype
  │             ├── constructor: Object
  │             ├── toString: function()
  │             ├── hasOwnProperty: function()
  │             └── __proto__: null
  ├── name: "zhangsan"
  └── (其他实例属性...)

Person (构造函数)
  ├── prototype: Person.prototype
  └── __proto__: Function.prototype
           ├── constructor: Function
           ├── apply: function()
           ├── call: function()
           └── __proto__: Object.prototype

原型链的实际应用

实现继承的多种方式

原型链继承(经典方式)

function Parent(name) {
    this.name = name;
}

function Child(age) {
    this.age = age;
}

// 关键:让子类原型指向父类实例
Child.prototype = new Parent();

// 修复constructor指向
Child.prototype.constructor = Child;
  • 问题:引用类型的属性会被所有实例共享

组合继承(最常用)

function Parent(name) {
    this.name = name ;
}

function Child(name, age) {
    // 继承属性
    Parent.call(this, name);  // 第二次调用Parent
    this.age = age;
}

// 继承方法
Child.prototype = new Parent();  // 第一次调用Parent
Child.prototype.constructor = Child;
  • 优点:结合了原型链和构造函数的优点
  • 缺点:父类构造函数被调用了两次

寄生组合式继承(最佳实践)

function inheritPrototype(child, parent) {
    // 创建父类原型的副本
    const prototype = Object.create(parent.prototype);
    // 修复constructor指向
    prototype.constructor = child;
    // 设置子类原型
    child.prototype = prototype;
}

function Parent(name) {
    this.name = name;
}

function Child(name, age) {
    Parent2.call(this, name);
    this.age = age;
}

inheritPrototype(Child, Parent);
  • 只调用一次父类构造函数
  • 避免在子类原型上创建不必要的属性
  • 原型链保持不变

ES6 class继承

class Parent3 {
    constructor(name) {
        this.name = name;
    }
}

class Child3 extends Parent3 {
    constructor(name, age) {
        super(name);
        this.age = age;
    }
    
    sayAge() {
        console.log(this.age);
    }
}
  • 语法简洁,现代解决方案,但需要ES6+支持

实现混入(Mixin)

const canEat = {
    eat: function(food) {
        console.log(`${this.name} is eating ${food}`);
        this.energy += 10;
    }
};

const canSleep = {
    sleep: function(hours) {
        console.log(`${this.name} is sleeping for ${hours} hours`);
        this.energy += hours * 5;
    }
};

const canWalk = {
    walk: function(distance) {
        console.log(`${this.name} is walking ${distance} km`);
        this.energy -= distance * 2;
    }
};

// 混入函数
function mixin(target, ...sources) {
    Object.assign(target.prototype, ...sources);
}

// 创建动物类
function Animal(name) {
    this.name = name;
    this.energy = 100;
}

mixin(Animal, canEat, canSleep, canWalk);

// 创建鸟类,额外添加飞行能力
const canFly = {
    fly: function(distance) {
        console.log(`${this.name} is flying ${distance} km`);
        this.energy -= distance * 5;
    }
};

function Bird(name) {
    Animal.call(this, name);
    this.wings = 2;
}

// 设置原型链
Bird.prototype = Object.create(Animal.prototype);
Bird.prototype.constructor = Bird;

// 添加飞行能力
Object.assign(Bird.prototype, canFly);

原型链常见陷阱

陷阱1:修改原型会影响所有实例

function Person(name) {
    this.name = name;
}

const zhangsan = new Person('zhangsan');
const lisi = new Person('lisi');

// 修改原型
Person.prototype.sayHello = function() {
    console.log(`Hello, ${this.name}`);
};

zhangsan.sayHello(); // Hello, zhangsan (正常)
lisi.sayHello();   // Hello, lisi (正常)

陷阱2:原型上的引用类型属性被所有实例共享

function Problem() {
    this.values = []; // 正确:每个实例有自己的数组
}

Problem.prototype.sharedValues = []; // 错误:所有实例共享同一个数组

const p1 = new Problem();
const p2 = new Problem();

p1.sharedValues.push('from p1');
p2.sharedValues.push('from p2');

console.log(p1.sharedValues); // ['from p1', 'from p2']
console.log(p2.sharedValues); // ['from p1', 'from p2']

陷阱3:for...in会遍历原型链上的可枚举属性

function Parent() {
    this.parentProp = 'parent';
}

Parent.prototype.inheritedProp = 'inherited';

function Child() {
    this.childProp = 'child';
}

Child.prototype = new Parent();

const child = new Child();

for (let key in child) {
    console.log(key); // childProp, parentProp, inheritedProp
}

解决方案:使用hasOwnProperty过滤:

for (let key in child) {
    if (child.hasOwnProperty(key)) {
        console.log(key); // childProp, parentProp
    }
}

原型链的最佳实践

实践1:使用Object.create设置原型链

function Parent(name) {
    this.name = name;
}

Parent.prototype.sayName = function() {
    console.log(this.name);
};

function Child(name, age) {
    Parent.call(this, name);
    this.age = age;
}

// 最佳方式
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

// 添加子类方法
Child.prototype.sayAge = function() {
    console.log(this.age);
};

实践2:使用class语法(ES6+)

class GoodParent {
    constructor(name) {
        this.name = name;
    }
    
    sayName() {
        console.log(this.name);
    }
}

class GoodChild extends GoodParent {
    constructor(name, age) {
        super(name);
        this.age = age;
    }
    
    sayAge() {
        console.log(this.age);
    }
}

实践3:安全地检查属性

const obj = { ownProp: 'value' };

// 不好的做法
if (obj.property) {
    // 如果property值为falsy(0, '', false, null, undefined),会被误判
}

// 好的做法
if (obj.hasOwnProperty('property')) {
    // 明确检查自身属性
}

// 更好的做法(防止hasOwnProperty被覆盖)
if (Object.prototype.hasOwnProperty.call(obj, 'property')) {
    // 最安全的方式
}

实践4:避免修改内置对象的原型

// 非必要情况,不得进行以下操作
if (!Array.prototype.customMethod) {
    Array.prototype.customMethod = function() {
        // 实现
    };
}

思考题:以下代码的输出是什么?为什么?

function Foo() {}
function Bar() {}

Bar.prototype = Object.create(Foo.prototype);

const bar = new Bar();

console.log(bar instanceof Bar); 
console.log(bar instanceof Foo); 
console.log(bar instanceof Object);

console.log(Bar.prototype.isPrototypeOf(bar)); 
console.log(Foo.prototype.isPrototypeOf(bar)); 
console.log(Object.prototype.isPrototypeOf(bar)); 

结语

原型链是 JavaScript 面向对象编程的基石,在 JavaScript 中没有真正的类,只有对象和它们之间的链接(原型链),对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

浏览器缓存:从底层原理到最佳实践

作者 wuhen_n
2026年1月31日 07:29

缓存是前端性能优化的核心,但我们真的理解各种缓存之间的关系吗?本篇文章将彻底解析浏览器缓存机制,帮我们建立完整的知识体系。

缓存全景图:一张图看懂所有缓存关系

缓存全景图

HTTP缓存:强缓存 vs 协商缓存

HTTP缓存是浏览器缓存体系的核心,分为两大策略:强缓存协商缓存

强缓存:浏览器自主决策

浏览器检查本地缓存是否在有效期内,若有效则直接使用,不发送任何网络请求。

# 第一次请求:服务器设置缓存策略
GET /static/logo.png
HTTP/1.1 200 OK
Cache-Control: max-age=31536000, public
Expires: Mon, 01 Jan 2024 00:00:00 GMT

# 后续请求:浏览器自主决策(在有效期内)
GET /static/logo.png
# 浏览器检查:max-age=31536000 → 还有效!
# 直接从缓存返回,不发送网络请求

协商缓存:服务端验证

当请求资源时,浏览器携带缓存标识询问服务器资源是否变更,若没有变更,直接从缓存中读取资源。

# 第一次请求:服务器返回资源标识
GET /api/user/profile
HTTP/1.1 200 OK
ETag: "abc123xyz"
Last-Modified: Wed, 21 Oct 2022 07:28:00 GMT
Cache-Control: no-cache

# 第二次请求:带上标识询问服务器
GET /api/user/profile
If-None-Match: "abc123xyz"
If-Modified-Since: Wed, 21 Oct 2022 07:28:00 GMT

# 服务器响应
HTTP/1.1 304 Not Modified
# 资源未变,使用本地缓存

强缓存 vs 协商缓存对比:

特性 强缓存 协商缓存
决策机制 浏览器自己决定 浏览器和服务器协商
检查时机 优先检查 强缓存失效后检查
网络请求 可能完全不发请求 一定发送请求(验证)
典型状态码 200 (from cache) 304 (Not Modified)
响应速度 极快(直接本地读取) 较慢(需要网络往返)
适用场景 版本固定的静态资源 频繁更新的动态资源
更新时机 缓存过期时更新 每次请求都验证

浏览器缓存:Memory Cache vs Disk Cache

Memory Cache:高速内存缓存

  • 位置:浏览器进程内存空间
  • 容量:有限,依赖设备内存
  • 生命周期:会话级别,标签页关闭则释放
  • 特点:读取速度极快(纳秒级)

适用场景

  • 当前页面已加载的资源
  • 预加载的脚本和样式
  • 小体积的Base64图片

Disk Cache:持久化磁盘缓存

  • 位置:用户磁盘的缓存目录
  • 容量:较大(通常数百MB到数GB)
  • 生命周期:长期持久化
  • 特点:读取速度较慢(毫秒级),但容量大

适用场景

  • 大体积静态资源(图片、字体、视频)
  • 跨会话共享的资源
  • 低频访问但需要持久化的内容

Service Worker:应用层缓存控制

Service Worker 运行在浏览器后台,作为代理服务器,赋予开发者完全控制缓存的能力。

Service Worker 缓存特点

  1. 独立线程运行,不阻塞主线程
  2. 完全控制网络请求,可自定义缓存策略
  3. 支持离线体验,是PWA的核心技术
  4. 生命周期独立,可长期缓存资源

Service Worker 缓存策略

Service Worker 使用的是典型的缓存优先策略:

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(cachedResponse => {
        // 1. 缓存优先
        if (cachedResponse) {
          // 后台更新缓存
          event.waitUntil(
            updateCache(event.request)
          );
          return cachedResponse;
        }
        
        // 2. 网络回退
        return fetch(event.request)
          .then(networkResponse => {
            // 缓存新资源
            return cacheResponse(event.request, networkResponse);
          })
          .catch(() => {
            // 3. 回退到离线页面
            return caches.match('/offline.html');
          });
      })
  );
});

Service Worker 使用场景

场景 策略 优势
离线应用 Cache-First + Network-Fallback 提供完整的离线体验
性能优化 Stale-While-Revalidate 快速响应+后台更新
资源预加载 Precache + Runtime Caching 减少关键资源加载时间
不可靠网络 Network-First + Cache-Fallback 提升弱网环境体验

缓存查找顺序

async function fetchWithCache(request) {
  // 1. Service Worker 拦截(最高优先级)
  if (navigator.serviceWorker?.controller) {
    const swResponse = await checkServiceWorkerCache(request);
    if (swResponse) return swResponse;
  }
  
  // 2. Memory Cache 检查(最快路径)
  const memoryCached = memoryCache.get(request.url);
  if (memoryCached && !isExpired(memoryCached)) {
    reportCacheHit('memory');
    return memoryCached;
  }
  
  // 3. Disk Cache 检查(强缓存验证)
  const diskCached = await checkDiskCache(request);
  if (diskCached) {
    if (diskCached.cacheControl === 'immutable' || 
        !isExpired(diskCached)) {
      // 更新 Memory Cache 提升后续访问速度
      memoryCache.set(request.url, diskCached);
      reportCacheHit('disk');
      return diskCached;
    }
  }
  
  // 4. 准备网络请求(设置协商缓存头)
  const init = prepareRequest(request);
  
  // 5. 发起网络请求
  let response = await fetch(request, init);
  
  // 6. 处理响应,决定是否缓存
  if (shouldCache(response)) {
    await cacheInLayers(request, response.clone());
  }
  
  return response;
}

  • 强缓存资源:可以同时存在于Memory和Disk Cache,浏览器根据大小和类型智能分配
  • 协商缓存资源:主要在Disk Cache,Memory Cache可能不存储或短期存储
  • 开发者控制策略:通过HTTP头控制缓存策略,浏览器自动选择存储位置
  • 性能考量:小文件强缓存 → Memory Cache极速;大文件强缓存 → Disk Cache持久

本地存储体系

存储类型 容量 生命周期 同步性 使用场景
Cookie 4KB 可设置过期时间 每次请求自动携带 用户认证、服务端状态
LocalStorage 5-10MB 永久(除非手动清除) 同步阻塞 用户偏好、应用配置
SessionStorage 5-10MB 标签页关闭时清除 同步阻塞 表单草稿、临时状态
IndexedDB 数百MB 永久 异步非阻塞 大量结构化数据
Cache API 依赖设备 Service Worker控制 异步 网络资源缓存、PWA

Chrome DevTools中的缓存标识

在Chrome开发者工具中,不同缓存来源有不同的标识:

Size列显示 状态码 含义 缓存来源
(memory cache) 200 内存缓存 Memory Cache
(disk cache) 200 磁盘缓存 Disk Cache
(ServiceWorker) 200 Service Worker缓存 Service Worker Cache
文件大小 304 协商缓存生效 服务器验证通过
文件大小 200 新请求 网络获取

现代框架下的缓存最佳实践

Vue 3 + Vite 缓存配置

vite.config.js 中配置:

export default defineConfig({
  build: {
    // 生成带hash的文件名,实现长期缓存
    rollupOptions: {
      output: {
        entryFileNames: 'assets/[name]-[hash].js',
        chunkFileNames: 'assets/[name]-[hash].js',
        assetFileNames: 'assets/[name]-[hash].[ext]'
      }
    }
  },
  
  server: {
    headers: {
      // 开发环境:禁用缓存
      'Cache-Control': 'no-store'
    }
  }
});

生产环境 Nginx 配置:

server {
    # HTML文件:短缓存 + 协商缓存
    location = /index.html {
        add_header Cache-Control "public, max-age=300, must-revalidate";
    }
    
    # 带hash的静态资源:长期缓存 + 不可变
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
        # 匹配带hash的文件名
        if ($request_uri ~* "\.([0-9a-f]{8,})\.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$") {
            add_header Cache-Control "public, max-age=31536000, immutable";
        }
        # 无hash的资源使用协商缓存
        if ($request_uri !~* "\.([0-9a-f]{8,})\.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$") {
            add_header Cache-Control "public, max-age=604800, must-revalidate";
        }
    }
    
    # API请求:不缓存或短缓存
    location /api/ {
        add_header Cache-Control "no-store, no-cache, must-revalidate";
        proxy_pass http://api-backend;
    }
}

React 缓存策略建议

使用 React Query 进行数据缓存管理:

import { useQuery, QueryClient } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5分钟
      cacheTime: 10 * 60 * 1000, // 10分钟
      retry: 1,
    },
  },
});

function UserProfile({ userId }) {
  const { data, isLoading } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    // 根据用户交互决定缓存策略
    cacheTime: userId ? 15 * 60 * 1000 : 0, // 登录用户缓存15分钟
  });
  
  // 预加载相关数据
  const prefetchPosts = () => {
    queryClient.prefetchQuery({
      queryKey: ['posts', userId],
      queryFn: () => fetchUserPosts(userId),
    });
  };
}

常见问题与解决方案

用户反映看到的是旧版本页面

原因:HTML文件被缓存,导致加载的是旧版本的静态资源引用。 解决方案:在nginx中配置合理的缓存策略:

location / {
    # HTML文件:短时间缓存 + 协商缓存
    add_header Cache-Control "public, max-age=300, must-revalidate";
}

location /assets/ {
    # 静态资源:长时间缓存 + 文件hash
    add_header Cache-Control "public, max-age=31536000, immutable";
}

如何优雅地清除缓存?

vite/webpack 会自动生成文件hash,以此进行清除。

缓存最佳实践总结

资源类型 推荐策略 缓存时间 更新机制
HTML入口文件 协商缓存 短时间(5分钟) 内容hash或版本号
JS/CSS(带hash) 强缓存 长期(1年) 文件名hash变更
图片/字体 强缓存 中期(1个月) URL变更或版本控制
API数据(列表) 内存缓存 短期(1-5分钟) 时间过期或主动失效
用户偏好配置 LocalStorage 永久 用户操作触发更新
离线资源 Service Worker 长期 版本化预缓存

未来趋势

  • HTTP/3的缓存改进:更智能的缓存协商机制
  • AI驱动的缓存策略:根据用户行为预测缓存需求
  • 边缘计算缓存:CDN与浏览器缓存的深度融合
  • 隐私保护的缓存:沙盒化缓存防止指纹追踪

结语

浏览器缓存是一个多层次的复杂系统,理解各层缓存的工作原理、生命周期和适用场景是进行有效缓存管理的关键,对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

使用 vite-plugin-vue-devtools 实现 IDE 一键打开选中组件

作者 LaLu
2026年1月31日 00:33

使用 vite-plugin-vue-devtools 实现 IDE 一键打开选中组件

在 Vue 项目开发中,快速定位组件源码是提升开发效率的关键。本文将介绍如何使用 vite-plugin-vue-devtools 插件,实现在浏览器中选中组件后一键在 IDE 中打开对应源码文件。

一、背景与痛点

大型 Vue 项目,我们经常会遇到以下场景:

  • 页面上有一个组件需要修改,但不知道它对应的源文件在哪里
  • 项目组件层级很深,手动查找费时费力
  • 新接手项目,对代码结构不熟悉

vite-plugin-vue-devtools 正是解决这个问题的利器。

二、vite-plugin-vue-devtools作用

vite-plugin-vue-devtools 是一个为 Vue 3 + Vite 项目设计的开发工具插件,它提供了:

  • 📍 组件检查器:在页面上悬停/点击组件,查看组件信息
  • 🔗 一键跳转:点击组件直接在 IDE 中打开源码文件
  • 📊 组件树可视化:查看组件的层级关系
  • 🔧 状态调试:实时查看和修改组件状态
  • 性能分析:分析组件渲染性能

这里只做IDE跳转文件的分享

三、安装和配置

3.1 安装

# npm
npm install vite-plugin-vue-devtools -D

# yarn
yarn add vite-plugin-vue-devtools -D

# pnpm
pnpm add vite-plugin-vue-devtools -D

3.2 配置 vite.config.js

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import VueDevTools  from 'vite-plugin-vue-devtools'

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    VueDevTools ({
      launchEditor: 'webstorm64', //这里很关键,windows下看IDE安装目录的exe文件名
    }),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    },
  },
})

3.3 launchEditor 支持的编辑器

github.com/yyx990803/l…

四、使用方法

4.1 启动开发服务器

npm run dev

启动后,你会在页面底部看到一个 Vue DevTools 的悬浮按钮,点击定位图标,就可以直接选中页面上的元素跳转IDE对应的文件。

PixPin_2026-01-31_00-24-38.gif

五、常见问题与解决方案

5.1 点击组件后 IDE 没有反应

可能原因

  1. launchEditor 配置的编辑器名称不正确
  2. 编辑器没有加入系统环境变量

解决方案

方案一:使用完整路径

VueDevTools({
  launchEditor: 'C:\\Program Files\\JetBrains\\WebStorm\\bin\\webstorm64.exe',
})

方案二:检查环境变量 确保在命令行中可以直接运行 webstorm64 或 code 命令,这个主要看IDE在bin目录下exe对应的文件名叫什么,例如我的文件名是webstorm64,配置里面就写的就是launchEditor: 'webstorm64'

image.png


参考资料


Vue-从 Vue 2 到 Vue 3:生命周期全图鉴与实战指南

2026年1月31日 12:19

前言

生命周期钩子(Lifecycle Hooks)是 Vue 组件从诞生到销毁的全过程记录。掌握生命周期,不仅能让我们在正确的时间点执行逻辑,更是优化性能、排查内存泄露的关键。

一、 生命周期四大阶段

Vue 的生命周期大体可分为:创建、挂载、更新、销毁


二、 Vue 2 vs Vue 3 生命周期对比图

在 Vue 3 组合式 API 中,生命周期钩子需要从 vue 中导入,且命名上增加了 on 前缀。

阶段 Vue 2 (选项式 API) Vue 3 (组合式 API) 备注
创建 beforeCreate / created setup() Vue 3 中 setup 包含了这两个时期
挂载 beforeMount / mounted onBeforeMount / onMounted 常用:操作 DOM、请求接口
更新 beforeUpdate / updated onBeforeUpdate / onUpdated 响应式数据变化时触发
销毁 beforeDestroy / destroyed onBeforeUnmount / onUnmounted 注意:Vue 3 中命名的变更
缓存 activated / deactivated onActivated / onDeactivated 配合 <keep-alive> 使用

三、 详细解析与实战场景

1. 创建阶段 (Creation)

  • Vue 2 (beforeCreate / created)

    • beforeCreate:组件实例刚在内存中被创建,此时还没有初始化好 datamethods 属性。适合插件开发,注入全局变量。
    • created:实例已创建,响应式数据data、methods 已准备好。
      • 场景:最早可发起异步请求的时机。
  • Vue 3 (setup)

    • 在 Vue 3 中,setup 的执行早于 beforeCreate,它是组合式 API 的入口。

2. 挂载阶段 (Mounting)

  • Vue 2 (beforeMount / mounted)
    • beforeMount:此时已经完成了模板的编译,但是还没有挂载到页面中

    • mounted:此时已经将编译好的模板挂载到了页面指定的容器中,可以访问页面中的dom了

      • 场景:dom已创建,可用于获取接口数据和dom元素、访问子组件

  • Vue 3 (onBeforeMount / onMounted)
    • onBeforeMount:模板编译完成,但尚未渲染到 DOM 树中。

    • onMounted:组件已挂载,可以安全地访问 DOM 元素。

      • 场景:获取接口数据、初始化第三方插件(如 ECharts)、访问子组件。

3. 更新阶段 (Updating)

  • Vue 2 (beforeUpdate / updated)

    • beforeUpdate:数据状态更新之前执行,此时 data 中的状态值是最新的,但是界面上显示的数据还是旧的,因为此时还没有开始重新渲染DOM节点。

      • 场景 :此时view层还未更新,可用于获取更新前各种状态。
    • updated:实例更新完毕之后调用,此时 data 中的状态值和界面上显示的数据,都已经完成了更新,界面已经被重新渲染好了。

  • Vue 3 (onBeforeUpdate / onUpdated)

    • onBeforeUpdate:数据已更新,但 DOM 尚未重新渲染。可用于获取更新前的 DOM 状态。
    • onUpdated:DOM 已完成更新。注意:不要在此钩子中修改状态,否则可能导致死循环。

4. 销毁阶段 (Unmounting / Destruction)

  • Vue 2 (beforeDestroy / destroyed)

    • beforeDestroy:实例销毁之前调用。
      • 场景:清理工作,如 清除定时器 (setInterval)、解绑全局事件监听、取消订阅
    • destroyed:Vue 实例销毁后调用。组件彻底从 DOM 中移除,所有的指令和事件监听都会被解除。
  • Vue 3 (onBeforeUnmount / onUnmounted)

    • onBeforeUnmount:实例销毁之前调用。

    • onUnmounted:组件彻底从 DOM 中移除,所有的指令和事件监听都会被解除。

5. 缓存阶段 (Keep-alive)

如果使用了keep-alive缓存组件会新增两个生命周期函数

  • onActivated:组件进入视野,被重新激活时调用。
  • onDeactivated:组件移出视野,进入缓存状态时调用。

四、 Vue 3 + TypeScript 实战演示

以下是使用 script setup 语法编写的生命周期示例:

<template>
  <div ref="container">
    <h2>当前计数:{{ count }}</h2>
    <button @click="count++">增加</button>
  </div>
</template>

<script setup lang="ts">
import { 
  ref, 
  onMounted, 
  onBeforeUpdate, 
  onUpdated, 
  onBeforeUnmount 
} from 'vue'

const count = ref<number>(0)
const container = ref<HTMLElement | null>(null)
let timer: number | null = null

// 挂载阶段
onMounted(() => {
  console.log('Component Mounted. DOM element:', container.value)
  // 模拟一个定时任务
  timer = window.setInterval(() => {
    console.log('Timer running...')
  }, 1000)
})

// 运行阶段
onBeforeUpdate(() => {
  console.log('Data updated, but DOM is not yet re-rendered.')
})

onUpdated(() => {
  console.log('Data updated and DOM re-rendered.')
})

// 销毁阶段
onBeforeUnmount(() => {
  console.log('Cleanup before unmount.')
  if (timer) {
    clearInterval(timer) // 关键:防止内存泄漏
  }
})
</script>

五、 进阶:父子组件生命周期执行顺序

为了清晰起见,我们将顺序拆解为三个主要场景(vue3):

1. 初始挂载阶段

父组件必须等待所有子组件挂载完成后,才能完成自己的挂载逻辑。

  1. setup(开始创建)
  2. onBeforeMount
  3. setup
  4. onBeforeMount
  5. onMounted (子组件渲染完毕,向上通知)
  6. onMounted (父组件接收到信号,宣布整体挂载完毕)

记忆口诀: 父创 -> 子创 -> 子挂 -> 父挂。


2. 更新阶段

当父组件传递给子组件的 props 发生变化时,更新逻辑如下:

  • onBeforeUpdate
  • onBeforeUpdate
  • onUpdated
  • onUpdated

注意: 如果只是父组件自身的私有状态更新,且未影响到子组件,则子组件的更新钩子不会被触发。


3. 销毁阶段

销毁过程同样是“递归”式的,父组件先启动销毁,等子组件销毁完毕后,父组件正式功成身退。

  1. onBeforeUnmount
  2. onBeforeUnmount
  3. unmounted
  4. onUnmounted

六、 Vue 3 + TS 模拟演示

你可以通过以下代码在控制台直接观察执行逻辑。

父组件 Parent.vue

<script setup lang="ts">
import { onMounted, onBeforeMount } from 'vue'
import Child from './Child.vue'

console.log('1. 父 - setup')

onBeforeMount(() => console.log('3. 父 - onBeforeMount'))
onMounted(() => console.log('8. 父 - onMounted'))
</script>

<template>
  <div class="parent">
    <h1>父组件</h1>
    <Child />
  </div>
</template>

子组件 Child.vue

<script setup lang="ts">
import { onMounted, onBeforeMount } from 'vue'

console.log('4. 子 - setup')

onBeforeMount(() => console.log('6. 子 - onBeforeMount'))
onMounted(() => console.log('7. 子 - onMounted'))
</script>

<template>
  <div class="child">子组件内容</div>
</template>

📝 总结与避坑

  1. 接口请求放哪里?

    • 如果子组件的渲染依赖父组件接口返回的数据,请在父组件的 created(Vue 2)或 setup(Vue 3)中请求。
    • 注意:即便你在父组件的 onMounted 发请求,子组件此时也已经渲染完成了。
  2. Refs 访问时机

    • 父组件想通过 ref 访问子组件实例,必须在父组件的 onMounted 之后,因为只有这时子组件才真正挂载完成。
  3. 异步组件

    • 如果子组件是异步组件(如使用 defineAsyncComponent),顺序会发生变化,父组件可能会先执行 onMounted

比黄金暴跌更难过的是 Vue 3.6 构建工具全线换成 Rolldown 了😰

作者 VaJoy
2026年1月31日 11:41

最近在尝试解析 Vue 3.6 的源码,前些天从底层构建系统着手,辛辛苦苦分析了 Vue 是如何通过 Rollup 和 esbuild 来分别构建出生产环境、开发环境产物的,并精心沉淀出了第一篇技术文章。

今天一看天塌了 ———— Vue 3.6 beta5 开始把全线构建工具统一为了 Rolldown,所有构建相关的脚本均被改写,意味着我之前案牍劳形进行的分析、写的文章好像打了水漂。

这简直比黄金暴跌更让我心疼!!(因为我其实也没买)。

官方改动具体见 Commit - build: use rolldown

Rolldown 目前处于 RC 预发布候选版本,所以没预料到 Vue 这么快就将其应用在源码构建上,盲猜其原因为:

  • Rolldown 在 Vite 生态内部已经经历了较长时间的真实项目验证,官方团队对其成熟度相对有信心,得以进一步推进到 Vue 源码仓库中集成使用。
  • Rolldown 是使用 Rust 编写的现代化打包器,据称在性能上会比 Rollup 快 10~30 倍。
  • Rollup 的模块图主要面向「一次性构建」设计,缺乏面向长期运行进程的增量构建能力(这是之前为何要使用 esbuild 作为开发环境构建工具的原因);而 Rolldown 把模块图视为长期驻留的一等公民,从架构层面支持精确失效与增量重建,得以适配开发态的高频变更场景。
  • 开发环境和构建环境统一只使用一套「构建底座」,可以大幅提升构建配置和脚本的一致性,例如不同的需求(例如占位符替换)不再需要配套不同的构建插件,进而大幅降低维护成本。

对此个人很欣赏 Vue 与时俱进的态度,也期待 3.6 正式版本能早日顺利发布。

最后贴下 Rollup 搭配 esbuild 的「老款」构建系统解析文章,还是可以帮助大家了解 Vue 项目底层构建的流程和原理。


《Vue 3.6 beta 4 源码解析 —— 项目结构和构建雏形 Pt.1》

本文基于 Vue 3.6 beta 4 版本。

本文案例源码可在 Github 仓库(Tag 1.1.1) 上获取。

Vue 3.6 的源码并不是一个单体项目,而是一个高度模块化、工程化的系统。在正式深入分析各个核心模块之前,如果不先理解其整体的项目结构与工程形态,很容易在阅读源码时陷入局部实现细节,而缺乏对整体设计与模块协作关系的把握。

作为本专栏的开篇,本文将从工程视角出发,介绍并讨论 Vue 3.6 的项目结构雏形与构建技术选型,并实现一个最基础的工程化方案。

1、项目初始化

我们创建一个名为 vue 的文件夹作为源码项目,执行 pnpm init 进行初始化、生成 package.json 文件。

Vue 源码项目是严格要求使用 pnpm 来作为包管理器的,我们可以在 package.jsonscript 字段添加 preinstall 钩子指令,在使用者通过包管理器安装项目依赖时,能自动检查并确保项目只能使用 pnpm 作为包管理器:

{
  "name": "vue",
  "version": "3.6.0",
  "type": "module",
  "scripts": {
    "preinstall": "npx only-allow pnpm"    // 确保项目只能使用 pnpm 作为包管理器
  },
  "license": "MIT"
}

其中 only-allow 是 pnpm 官方提供的一个检测工具,可以检测当前项目是否使用了指定的包管理器(若不符合条件会报错并强行退出程序)。

preinstall 钩子中,我们通过 npx only-allow pnpm 来确保项目只能使用 pnpm 作为包管理器。

💡 Vue 是基于 Monorepo 架构来维护各模块的,而 pnpm 对 Monorepo 有很好的支持,我们会在后文了解到这一点。

2、Monorepo

2.1 Vue 中的 Monorepo

在业务项目中,我们可以通过 npm install vue 等方式来下载和使用 Vue,不过 Vue 除了这个覆盖完整功能的 npm 包,还提供了多个独立的核心子模块包,每个包都有其特定的功能和作用:

  • @vue/shared:Vue 3 中被各模块共享的工具函数模块。
  • @vue/reactivity:Vue 3 的响应式模块。
  • @vue/compiler-core:Vue 3 的模板编译核心模块。
  • @vue/compiler-dom:Vue 3 的 DOM 编译模块。
  • @vue/runtime-core:Vue 3 的运行时核心模块。
  • ...

例如你可以在业务项目中独立下载 Vue 的响应式模块 @vue/reactivity

npm install @vue/reactivity

并在项目中使用它:

import { reactive } from '@vue/reactivity'

const state = reactive({ msg: 'hi' })

此举仅会下载 @vue/reactivity 模块(及其依赖模块)的代码,而不会下载 Vue 的所有模块。

然而 Vue 并没有给每个核心子模块都独立创建一个 Git 仓库,而是将它们统一放在 Vue 源码项目的 packages 文件夹下进行维护:

vue
├── packages
│   ├── shared
│   ├── reactivity
│   ├── compiler-core
│   ├── compiler-dom
│   ├── runtime-core
│   ├── ...

此类「单仓库多模块」的架构形式,被称为 Monorepo

2.2 以 shared 为例的模块结构

以「共享工具函数模块」shared 为例,其目录结构 packages/shared 非常简练:

vue
├── packages
│   ├── shared
│   │   ├── src           // 存放实际源码
│   │   │   ├── general.ts
│   │   │   ├── makeMap.ts
│   │   │   ├── ...
│   │   │   └── index.ts
│   │   ├── package.json  // npm 包配置
│   │   └── index.ts      // 包入口

src 文件夹

shared/src 文件夹用于存放 shared 模块实际的源码文件,源码文件会按功能维度拆分成多个子模块。

简单起见,我们目前只实现 shared/src/general.tsshared/src/makeMap.ts 两个功能子模块,它们的代码如下:

/** shared/src/makeMap.ts **/

/**
 * 把一个用逗号分隔的字符串(例如 "a,b,c" )预处理成一个“成员判断函数”,
 * 用于在运行时高频地判断某个 key 是否属于某个固定集合。
 * 
 * 示例:
 * const isHTMLTag = makeMap('div,span,p')
 * isHTMLTag('div') // true
 * isHTMLTag('a')   // false
 */

export function makeMap(str: string): (key: string) => boolean {
  const map = Object.create(null)
  for (const key of str.split(',')) map[key] = 1
  return val => val in map
}
/** shared/src/general.ts **/

import { makeMap } from './makeMap'

/** 空对象 */
export const EMPTY_OBJ: { readonly [key: string]: any } = {}

/** 空数组 */
export const EMPTY_ARR: readonly never[] = []

/** 空函数 */
export const NOOP = (): void => {}

/** 生成一个方法,用于判断一个属性名是否是保留属性 */
export const isReservedProp: (key: string) => boolean = /*@__PURE__*/ makeMap(
  ',key,ref,ref_for,ref_key,' +
    'onVnodeBeforeMount,onVnodeMounted,' +
    'onVnodeBeforeUpdate,onVnodeUpdated,' +
    'onVnodeBeforeUnmount,onVnodeUnmounted',
)

接着我们在模块的内部出口文件 shared/src/index.ts 中,导出所有功能子模块(目前仅 generalmakeMap)的接口:

/** shared/src/index.ts **/

export * from './general'
export * from './makeMap'

后续仅需引用该出口文件,就能通过「一拖多」的形式间接引入 shared 模块的全部功能接口。

待构建的 dist 文件夹

作为一个被多运行时、多构建环境复用的基础工具模块,通常需要提供多种构建格式(例如 ESM、CJS 等),以适配不同的加载方式与运行场景。

对于 shared 模块而言,其职责是为 Vue 各核心包提供最基础、最通用的工具能力,因此需要将 shared/src 下的源码构建为多种产出物,以覆盖以下几类典型使用场景:

  • 开发环境下的 CommonJS 引用

    即通过 require('@vue/shared') 引入 shared 模块,且运行于开发环境(process.env.NODE_ENV === 'development')的场景,需确保所引用的 shared 模块产出物为 CommonJS 版本,且包含用于开发期的调试逻辑(如 warning、assert 等)。

    我们拟定该产出物名为 shared.cjs.js

  • 生产环境下的 CommonJS 引用

    即同样通过 require('@vue/shared') 引入 shared 模块,但运行于生产环境的场景,需确保所引用的 shared 模块产出物为 CommonJS 版本,且剔除了所有用于开发期的调试逻辑(确保最终代码体积最小)。

    我们拟定该产出物名为 shared.cjs.prod.js

  • ESM 引用(Bundler 场景)

    即通过 import 引入 shared 模块的场景,需确保所引用的 shared 模块产出物为 ESM bundler 版本。

    我们拟定该产出物名为 shared.esm-bundler.js

    💡 与 CommonJS 不同,ESM 版本并不会拆分出「开发环境」和「生产环境」两套文件。

    💡 这得益于 ESM 的静态导入特性,Vue 框架使用者在业务侧进行二次构建的过程中,构建工具可在编译期将环境变量替换为常量,并通过 Tree-shaking / Dead Code Elimination 精确移除不可达的环境逻辑,从而保证最终生产代码中不存在任何冗余分支。

在后文我们会通过构建工具,将 shared/src 的源文件构建出适配上述三个场景的产出物,并创建 shared/dist 文件夹来存放这些产物。

另外我们也将为 shared 模块构建其 TypeScript 声明文件 shared/dist/shared.d.ts,用于在 TypeScript 项目中提供类型检查与智能提示。

npm 包入口文件

shared/index.tsshared 模块的包入口文件,在下文将介绍的 shared/package.json 中会通过 main 字段指定该文件为 CommonJS 生态下的传统入口。

其内容为根据当前的运行环境,引入对应的 CommonJS 版本构建产物:

'use strict'

if (process.env.NODE_ENV === 'production') {
  module.exports = require('./dist/shared.cjs.prod.js')
} else {
  module.exports = require('./dist/shared.cjs.js')
}

留意在这类 CommonJS 入口文件中,Vue 都对它们启用了严格模式('use strict'),这是为了确保代码能及时暴露一些潜在的问题(例如使用未声明的变量),并提升代码的质量和可维护性。

💡 严格模式在 ESM 里是默认开启的,因此 ESM 模块里无需标记 'use strict'

npm 包配置文件

shared/package.jsonshared 模块的 npm 包配置文件,其职责不只是提供「npm 发布描述」,还包含了工程化相关的配置信息:

/** shared/package.json **/

{
  "name": "@vue/shared",
  "version": "3.6.0",
  "main": "index.js",                     // 指定 CommonJS 生态下的传统入口,主要用于 不支持 exports 的旧 NodeJS / 工具链
  "module": "dist/shared.esm-bundler.js", // 指定 ESM bundler 生态下的入口
  "types": "dist/shared.d.ts",            // 指定类型声明文件
  "files": [                              // 指定了在发布到 npm 时,哪些文件会被包含在发布包中(避免将 src、测试代码、构建脚本等无关内容发布到 npm)
    "index.js",
    "dist"
  ],
  "exports": {                            // 更为完善的新规范入口配置字段
    ".": {
      "types": "./dist/shared.d.ts",                 // 指定类型声明文件
      "node": {                                      // 传统 NodeJS(CommonJS),开发环境和生产环境对应的入口
        "production": "./dist/shared.cjs.prod.js",
        "development": "./dist/shared.cjs.js",
        "default": "./index.js"
      },
      "module": "./dist/shared.esm-bundler.js",      // ESM bundler 入口(用于部分构建工具识别)
      "import": "./dist/shared.esm-bundler.js",      // ESM bundler 入口(用于 NodeJS 侧识别)
      "require": "./index.js"                        // 传统 CommonJS 默认入口(兜底)
    },
    "./*": "./*"                                     // 指定可访问的子路径映射
  },
  "sideEffects": false          // 声明该包在模块【初始化阶段】不存在副作用,当模块的导出未被使用时,整个模块可被剔除(Tree-shaking)
  }
}

其中 mainmoduletypes 三个字段是为了兼容旧生态所保留的约定式入口,exports 则是更为完善的新规范字段(可以指定更细粒度的入口映射),现代 NodeJS 或构建工具会优先采用 exports 字段指定的入口。

2.3 Monorepo 模块之间的联系

新建 reactivity 模块

为了更好地了解 Monorepo 下各独立模块之间的联系,我们仿照 shared 模块的结构,在 packages 下创建一个名为 reactivity 的响应式模块:

vue
├── packages
│   ├── reactivity        // 新增响应式模块文件夹
│   │   ├── src           // 存放实际源码
│   │   │   ├── reactive.ts
│   │   │   └── index.ts
│   │   ├── package.json  // npm 包配置
│   │   └── index.ts      // 包入口

reactivity 下各文件的内容和 shared 的基本一致。

目前我们仅打算搭建一个项目雏形,因此 reactivity 模块的内容尽量简单化,其中 src/reactive.ts 的代码仅用来模拟 shared 模块接口的引入和导出:

/** src/reactive.ts **/

export * from '@vue/shared'  // 仅用于调试

此时你会看到 IDE 中会标红报错,提示 TypeScript 找不到 @vue/shared 模块:

p1.png

我们可以在 vue 根目录下新增 tsconfig.json 文件,用于配置 TypeScript 的编译选项:

{
  "compilerOptions": {
    "strict": true,
    "rootDir": ".",
    "paths": {                            // IDE 类型检查路径
      "@vue/*": ["./packages/*/src"],     // 把 @vue/xxx 映射到 packages/xxx/src 下
      "*": ["./*"]
    }
  },
  "include": [
    "packages/*/src",
  ]
}

此时 src/reactive.ts 中不再标红报错,IDE 的 TypeScript 类型检查功能已可成功识别到 @vue/shared 模块。

pnpm 的 workspace 协议

如同「2.1 Vue 中的 Monorepo」所提及的,@vue/reactivity 需要被独立发布到 npm 上(供开发者下载),我们需要为其声明对 @vue/shared npm 包的依赖:

/** reactivity/package.json **/

{
  "name": "@vue/reactivity",
  "version": "3.6.0",
  "main": "index.js",
  "module": "dist/reactivity.esm-bundler.js",
  // 略...(和 shared/package.json 基本一致)

  "dependencies": {                 // 补充对 shared 模块的依赖信息
    "@vue/shared": "3.6.0"
  }
}

然而这里所填入的 @vue/shared 版本号 3.6.0 存在一个问题 —— 该远程版本的内容并非我们本地上最新的 packages/shared 下的内容,且每次发布 npm 包之前都需要手动更新该依赖包的版本号,一旦遗漏就会出错。

pnpm 官方提供了一个解决方案,即使用 workspace 协议来替换依赖包的版本号,它表示该依赖必须解析自当前 Monorepo 中声明的 workspace 包,而不会从远程 npm registry 下载:

/** reactivity/package.json **/

  "dependencies": {      
    "@vue/shared": "workspace:*"     // 更替为 workspace 协议
  }

另外,我们还需在 vue 根目录下新增 pnpm-workspace.yaml 文件,用于告知 pnpm「哪些文件夹可以作为独立的 workspace」:

/** pnpm-workspace.yaml **/

packages:
  - "packages/*"  // 把 packages 文件夹里的每一个一级子目录,都当作一个独立的 workspace 包

pnpm 在执行时会扫描 pnpm-workspace.yaml 中所配置的目录,并将其中包含 package.json 的子目录注册为 workspace 成员,后续在解析 workspace:* 时,会从这些 workspace 成员中进行检索和匹配。

在经过这番配置后,通过 pnpm publish 指令来发布 @vue/reactivity 包时,pnpm 会把将要提交到 npm registry 的 manifest 中的 @vue/shared 依赖包版本号,填写为本地对应 workspace 成员 packages.json(即 packages/shared/package.json)中的版本号。

3、构建系统雏形

在理解了 Vue 3.6 的 Monorepo 结构与包之间的依赖关系之后,下一步就是要实现一个基础的构建系统,将分散在 packages/*/src 目录下的源码,构建出预期的各版本并放置在 packages/*/dist 中)。

3.1 技术选型

Vue 使用了 Rollup 作为「生产构建器」,开发态的快速构建则采用的 esbuild

生产构建选择 Rollup 的原因

Rollup 自诞生以来就是为了打包 JavaScript 库而设计的:

  • Tree-shaking 的极致优化 —— Rollup 基于 ES Modules (ESM),它能生成非常「扁平」的输出代码,进而确保冗余代码能被精确裁剪、输出内容足够干净。
  • 作用域提升(Scope Hoisting) —— Rollup 默认会将所有模块提升到同一个作用域内,这不仅进一步减小了代码体积,还能提高执行性能。
  • 多格式输出支持 —— Rollup 的插件系统和配置机制,可以高效地构建出 Vue 的多版本产物:
    • esm-bundler (给 Vite / Webpack 用);
    • esm-browser (给浏览器 <script type="module"> 用);
    • cjs (CommonJS 语法,给 NodeJS 环境用);
    • global (传统的 <script> 引入) 。

最重要的是,Vue 需要被构建为一个「通用的前端框架库」,而不是被构建为一个「Web 应用」,因此需要尽可能地保证输出代码的可阅读性。相比 Webpack 会在每个模块周围包裹大量的 __webpack_require__ 等运行时代码,Rollup 输出的代码更加「原汁原味」、易于使用者阅读和调试。

开发构建选择 esbuild 的原因

虽然 Rollup 产物精美,但在速度上它并不是最快的。

在开发、调试场景中,我们对于构建速度的需求要远高于极致性能的需求,而 esbuild 非常契合这一场景:

  • 极速的增量构建 —— esbuild 采用了基于 Go 语言的编译后端,编译速度非常快,能够在毫秒级完成增量构建,适合频繁改动源码、快速看效果的 watch 开发。
  • 简单的配置 —— esbuild 的配置选项非常简单,无需复杂的插件系统即可实现基本的构建需求。

因此,虽然 esbuild 的输出代码不够完美(例如 Tree-Shaking 没有 Rollup 极致),但非常适用于在开发场景中用于响应迅速的「粗加工」。

💡 读者需注意区分各类构建面向的主体对象 —— Vue 源码项目的「生产构建」是面向 Vue 框架使用者的,因此「生产构建」的产物包括了使用者在其生产环境中使用的产物,也包括了使用者在其开发环境中使用的产物;而 Vue 源码项目的「开发构建」只面向 Vue 源码开发者(与使用者无关)。

3.2 shared 模块的生产构建方案实现

rollup.config.js 配置

Vue 是通过拼接 Rollup 的命令行指令来执行生产构建的,我们可以按照 Rollup 官方文档,先在项目根目录创建一个 rollup.config.js 配置文件,用于告诉 Rollup 在执行指令时「如何打包一个 package」。

以「构建一个 packages/shared/dist/shared.cjs.js 为例」,其配置内容参考如下:

/** rollup.config.js */

import { fileURLToPath } from 'node:url'
import path from 'node:path'
import esbuild from 'rollup-plugin-esbuild'

const __dirname = fileURLToPath(new URL('.', import.meta.url))

const packageConfigs = [{
  input: path.resolve(__dirname, './packages/shared/src/index.ts'),

  plugins: [
    // 插件
    esbuild({       // TypeScript / JS 语法转译
      tsconfig: path.resolve(__dirname, 'tsconfig.json'),
      minify: false,
      target: 'es2016',
    }),
  ],

  output: {
    file: path.resolve(__dirname, './packages/shared/dist/shared.cjs.js'),
    format: "cjs",
  },
  treeshake: {
moduleSideEffects: false,    // 声明模块无副作用,允许 Rollup 安全地进行 Tree-Shaking
},
}];

export default packageConfigs

留意这里我们使用了 rollup-plugin-esbuild 插件,这是因为 Rollup 本身是无法识别 TypeScript 语法的,因此需要利用 esbuild 来充当「语法翻译官」,把 TypeScript 代码转译为 JavaScript 代码。

💡 esbuild 在开发模式中充当了构建器 + bundler + runner 的角色,但在本小节的生产构建中被降级为一个编译器插件。

💡 读者请自行安装 rolluptypescriptrollup-plugin-esbuild 等 npm 依赖包,本系列文章不会提及依赖包的安装。

此时执行 rollup -c 即可构建出 packages/shared/dist/shared.cjs.js 文件。

然而除了 cjs 格式的文件,还要为 shared 模块构建 esm-bundler 格式的文件,这需要往 packageConfigs 数组中 push 多个配置项,我们可以封装一个 createConfig 方法来创建配置项:

/** rollup.config.js */

// 略...

const outputConfigs = {
  'esm-bundler': {
    file: path.resolve(__dirname, './packages/shared/dist/shared.esm-bundler.js'),
    format: 'es',
  },
  'cjs': {
    file: path.resolve(__dirname, './packages/shared/dist/shared.cjs.js'),
    format: 'cjs',
  },
}

const packageFormats = ['esm-bundler', 'cjs']

const packageConfigs = packageFormats.map(format => createConfig(format, outputConfigs[format]))

export default packageConfigs

function createConfig(format, output, plugins = []) {
console.log(`正在创建 ${format} 格式的构建配置...`)

return {
input: path.resolve(__dirname, './packages/shared/src/index.ts'),
plugins: [   
esbuild({  
tsconfig: path.resolve(__dirname, 'tsconfig.json'),
minify: false,
target: 'es2016',
}),
...plugins    // 支持扩展自定义插件
],
output,
    treeshake: {
moduleSideEffects: false,
},
}
}

此时执行 rollup -c 可构建出 packages/shared/dist/shared.cjs.jspackages/shared/dist/shared.esm-bundler.js 文件。

利用 NODE_ENVPROD_ONLY 参数指定产出物环境

根据「待构建的 dist 文件夹」小节所罗列的构建产物,我们还需为 shared 模块构建一个 CommonJS 格式的生产环境产物 shared.cjs.prod.js

另外,Vue 属于一个多模块、复杂度较高的项目,会有「按需构建」的需求,用于节省构建的等待时间。例如开发者会希望通过执行不同的 Rollup 的指令来满足如下三种场景:

  • 构建所有生产环境和开发环境的产物;
  • 只构建生产环境的产物;
  • 只构建开发环境的产物。

我们可以通过 Rollup 执行指令中的 environment 参数来传值处理:

  • 执行 rollup -c --environment NODE_ENV:production 时构建出所有生产环境和开发环境的产物;
  • 执行 rollup -c --environment NODE_ENV:production,PROD_ONLY:true 时只构建生产环境的产物;
  • 执行 rollup -c --environment NODE_ENV:development 时只构建开发环境的产物。

这里我们自定义了两个参数 NODE_ENVPROD_ONLY,分别用于指定「构建目标环境」和「是否只构建生产环境的产物」,我们可以在 rollup.config.js 中通过 process.env 来读取传入的自定义参数值,并做相应的逻辑处理:

/** rollup.config.js */

// 略...
const packageFormats = ['esm-bundler', 'cjs']

// 通过 process.env 获取传入的 PROD_ONLY 参数
const packageConfigs = process.env.PROD_ONLY ? [] : packageFormats.map(format => createConfig(format, outputConfigs[format]))

// 若需要构建生产环境产物,针对 cjs 格式,添加其生产环境的配置项
if (process.env.NODE_ENV === 'production') {    // 通过 process.env 获取传入的 NODE_ENV 参数
  packageFormats.forEach(format => {
    if (format === 'cjs') {
      packageConfigs.push(createProductionConfig(format))
    }
  })
}

export default packageConfigs

function createConfig(format, output, plugins = []) {
const isProductionBuild = /\.prod\.js$/.test(output.file)
console.log(`正在创建${isProductionBuild ? '生产环境' : '开发环境'} ${format} 格式的构建配置...`)

return {
// 略...
}
}

// 创建生产环境配置项
function createProductionConfig(format) {
  return createConfig(format, {
    ...outputConfigs[format],
    file: path.resolve(__dirname, './packages/shared/dist/shared.cjs.prod.js'),  // 构建 cjs.prod.js 文件
  })
}

此时执行 rollup -c --environment NODE_ENV:production,PROD_ONLY:true,会单独构建出 packages/shared/dist/shared.cjs.prod.js 文件。

开发环境占位符 __DEV__ 及其替换

查看前文所构建出的 shared.cjs.jsshared.cjs.prod.js 文件,会发现它们的内容是完全相同的,读者可能会因此感到困惑。

为了便于区分不同环境产物的内容(而不仅仅是文件名不同),我们修改 packages/shared/src/general.ts 的代码,加上一个自定义的开发环境占位符 __DEV__

/** packages/shared/src/general.ts */

/** 空对象 */
export const EMPTY_OBJ: { readonly [key: string]: any } = __DEV__
  ? Object.freeze({})
  : {}

/** 空数组 */
export const EMPTY_ARR: readonly never[] = __DEV__ ? Object.freeze([]) : []

此举目的是让 Vue 框架使用者在开发环境中,通过 Object.freeze 来避免改动到 Vue 源码内置的空对象和空数组两个常量,在生产环境时则改为使用性能最优的空对象和空数组即可。

IDE 在此时会对 __DEV__ 进行标红,提示「找不到名称__DEV__」,我们可以在 packages 下创建一个 global.d.ts 文件,用于声明这些自定义的变量:

// Global compile-time constants
declare var __DEV__: boolean

接着我们在 rollup.config.js 中,通过 rollup-plugin-esbuild 插件的 define 属性,对 __DEV__ 占位符进行替换:

/** rollup.config.js */

// 略...

function createConfig(format, output, plugins = []) {
const isProductionBuild = /\.prod\.js$/.test(output.file)
  const isBundlerESMBuild = /esm-bundler/.test(format)      // 是否为 ESM Bundler 格式的构建
// 略...

return {
input: path.resolve(__dirname, './packages/shared/src/index.ts'),
plugins: [  
...resolveReplace(),
esbuild({  
tsconfig: path.resolve(__dirname, 'tsconfig.json'),
minify: false,
target: 'es2016',
define: resolveDefine(),    // 替换自定义占位符
}),
...plugins
],
// 略...
}

  function resolveDefine() {
    const replacements = {}

    if (isBundlerESMBuild) {
      replacements.__DEV__ = `!!(process.env.NODE_ENV !== 'production')`
    } else {
      replacements.__DEV__ = String(!isProductionBuild)
    }

    return replacements
  }
}

留意在新增的 resolveDefine 方法中,是否处于 ESM Bundler 格式的构建会影响占位符 __DEV__ 的替换内容:

  • ESM Bundler 格式的构建产物会把 __DEV__ 占位符替换为 !!(process.env.NODE_ENV !== 'production'),因为该产物是会交由业务侧去执行二次构建的,由业务侧的构建工具再进一步去替换 process.env.NODE_ENV 即可;
  • 非 ESM Bundler 格式的构建产物属于「最终运行时代码」,不会经历二次构建,因此要把 __DEV__ 占位符根据构建环境替换为明确的 truefalse,esbuild 插件会在构建过程通过 Tree-Shaking 移除未命中的逻辑分支,确保产物简洁且可以直接运行。

然而此时执行 rollup -c --environment NODE_ENV:production 指令,会出现报错:

- [!] (plugin esbuild) Error: Transform failed with 1 error:
- error: Invalid define value (must be an entity name or JS literal): !!(process.env.NODE_ENV !== 'production')

这是因为 esbuild 的 define 对替换内容具有严格的要求,其仅用于将全局标识符替换为静态常量,它要求替换的内容必须是布尔值、数字、字符串,或者是另一个标识符,但不能是 !!(process.env.NODE_ENV !== 'production') 这样的表达式语句。

esbuild 会试图把自定义的 Key 替换成一个合法的 AST 节点,当要替换的值是一个复杂的表达式时,esbuild 解析器无法将其作为一个单一的「值」或「标识符」插入到 AST 中,因此会直接抛出 Invalid define value 错误来阻止构建。

针对此问题,我们可以在 ESM Bundler 场景改为使用 @rollup/plugin-replace 插件来进行占位符替换:

/** rollup.config.js */

import replace from '@rollup/plugin-replace'
// 略...

function createConfig(format, output, plugins = []) {
// 略...

return {
input: path.resolve(__dirname, './packages/shared/src/index.ts'),
plugins: [         
...resolveReplace(),     // 使用 @rollup/plugin-replace 插件
esbuild({
tsconfig: path.resolve(__dirname, 'tsconfig.json'),
minify: false,
target: 'es2016',
define: resolveDefine(),
}),
...plugins
],
// 略...
}

function resolveDefine() {
const replacements = {}

if (!isBundlerESMBuild) {
replacements.__DEV__ = String(!isProductionBuild)
}

return replacements
}

function resolveReplace() {
const replacements = {}

    // ESM Bundler 格式的构建产物使用 @rollup/plugin-replace 来替换占位符
if (isBundlerESMBuild) {
Object.assign(replacements, {
__DEV__: `!!(process.env.NODE_ENV !== 'production')`,
})
}

return [replace({ 
      values: replacements, 
      preventAssignment: true  // 若变量出现在赋值号( = )的左边,就不要进行替换。目的是防止把赋值语句当成常量替换(例如 __DEV__ = true)
    })]
}
}

此时执行 rollup -c --environment NODE_ENV:production 指令,构建出的所有 shared 模块产物内容如下:

/** packages/shared/dist/shared.cjs.js */

'use strict';

const EMPTY_OBJ = Object.freeze({}) ;
// 略...
exports.EMPTY_OBJ = EMPTY_OBJ;
// 略...
/** packages/shared/dist/shared.cjs.prod.js */

'use strict';

const EMPTY_OBJ = {};
// 略...
exports.EMPTY_OBJ = EMPTY_OBJ;
// 略...
/** packages/shared/dist/shared.esm-bundler.js */

const EMPTY_OBJ = !!(process.env.NODE_ENV !== "production") ? Object.freeze({}) : {};
// 略...

export { 
  EMPTY_OBJ, 
  // 略... 
}

我们会在下篇文章为构建系统补充 reactivity 等其它模块的构建能力。

3.3 shared 模块的开发构建方案实现

我们已经在前文了解过,Vue 源码项目只通过 esbuild 来负责开发调试时的构建,我们在 Vue 源码项目的根目录创建 ./scripts/dev.js 文件来配置 esbuild:

import esbuild from 'esbuild'
import { dirname, relative, resolve } from 'node:path'
import { fileURLToPath } from 'url'
import { parseArgs } from 'util'

const __dirname = dirname(fileURLToPath(import.meta.url))

const {
    values: { format: rawFormat, prod, },
} = parseArgs({
    options: {
        format: {
            type: 'string',
            short: 'f',
            default: 'cjs',
        },
        prod: {
            type: 'boolean',
            short: 'p',
            default: false,
        },
    },
})

const format = rawFormat || 'cjs'
const outputFormat = format === 'cjs' ? 'cjs' : 'esm'

const pkgBasePath = `../packages/shared`
const outfile = resolve(__dirname, `${pkgBasePath}/dist/shared.${format}.${prod ? `prod.` : ``}js`)

const relativeOutfile = relative(process.cwd(), outfile)


/** @type {Array<import('esbuild').Plugin>} */
const plugins = [
    {
        name: 'log-rebuild',    // 在构建完成时,打印构建完成的文件路径
        setup(build) {
            build.onEnd(() => {
                console.log(`built: ${relativeOutfile}`)
            })
        },
    },
]

const entry = 'index.ts'

esbuild
    .context({
        entryPoints: [resolve(__dirname, `${pkgBasePath}/src/${entry}`)],
        outfile,
        bundle: true,
        sourcemap: true,
        format: outputFormat,
        platform: format === 'cjs' ? 'node' : 'browser',
        plugins,
        define: {
            __DEV__: prod ? `false` : `true`,
        },
    })
    .then(ctx => ctx.watch())    // 监听源码变化并执行实时增量构建

其中我们使用了 NodeJS util 原生模块的 parseArgs 来解析命令行参数,例如:

node scripts/dev.js -f esm-bundler

当执行该指令时,parseArgs 会捕获传入的 f 参数并将其值(esm-bundler)赋给 rawFormat 变量。

最后我们调用了 esbuild 的 context 方法来自定义配置并执行构建。以上述的指令为例,esbuild 会直接构建出 packages/shared/dist/shared.esm-bundler.js 文件,且实时监听 shared 模块的源码变化并执行增量构建。

💡 Vue 源码在开发调试环节,需要通过 esbuild 构建的场景其实不多(在开发后期可以创建一个 vite 项目来配合调试),开发的前期更多还是通过 vitest 对模块源码进行单元测试。我们会在后续的文章中进行了解。

补充:鉴于 Vue 3.6 beta 5 已全线替换构建工具,本文「后续的文章」也只能跟着断更。后续可能以 Vue 官方最新版的项目重新进行源码解析和输出解析文章。共勉。

Wujie微前端

2026年1月30日 23:15

wujie使用

基座应用是“容器”,负责加载和展示子应用。

假设你用 Vue3 作为基座(最常见),配置超级简单。

1. 创建一个普通的 Vue3 项目(如果你没有)

用 Vue CLI 或 Vite 创建:

text

npm create vue@latest
# 或者 vite:npm create vite@latest

选 Vue + TypeScript 或 JS 都行,随便。

2. 安装 Wujie 的 Vue3 包

在你的基座项目里运行:

text

npm i wujie-vue3 -S
3. 在 main.js(或 main.ts)里引入并注册

打开 src/main.js,加上这些代码:

JavaScript

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'  // 如果你有路由

// 引入 WujieVue
import WujieVue from 'wujie-vue3'

const app = createApp(App)

// 注册 WujieVue 插件
app.use(WujieVue)

// 如果有路由
app.use(router)

app.mount('#app')
4. 在页面里使用wujie-vue组件加载子应用

打开一个页面组件,比如 src/views/Home.vue 或 App.vue,写一个容器:

vue

<template>
  <div>
    <h1>这是基座应用</h1>
    
    <!-- 这里放子应用,width 和 height 自己调 -->
    <wujie-vue
      width="100%"
      height="800px"
      name="唯一名字"        <!-- 随便取但要唯一比如 "vue3-app" -->
      url="子应用的地址"     <!-- 子应用的线上地址或本地地址,比如 http://localhost:3000 -->
      :sync="true"            <!-- 可选:路由同步,推荐开 -->
      :fetch="自定义fetch"    <!-- 可选:如果子应用有跨域问题,可以自定义fetch -->
      :props="{ token: '123' }"  <!-- 可选:给子应用传数据 -->
      :beforeLoad="beforeLoad"  <!-- 可选:生命周期钩子 -->
      :beforeMount="beforeMount"
      :afterMount="afterMount"
    ></wujie-vue>
  </div>
</template>

<script setup>
// 可选:生命周期钩子函数
const beforeLoad = () => {
  console.log('子应用开始加载')
}
const beforeMount = () => {
  console.log('子应用即将挂载')
}
const afterMount = () => {
  console.log('子应用挂载完成')
}
</script>

关键参数解释(小白必看):

  • name:子应用的唯一名字,不能重复。
  • url:子应用的入口地址(必须是完整的 http:// 或 https://)。
  • sync:true 表示路由同步(子应用路由变了,浏览器地址栏也会变)。
  • props:可以给子应用传数据,子应用里用 window.$wujie.props 拿。
5. 预加载(推荐,加速切换)

在基座的入口文件(比如 App.vue 的 mounted)里预加载:

JavaScript

import { preloadApp } from 'wujie-vue3'

preloadApp({
  name: '唯一名字',
  url: '子应用地址'
})
6. 启动基座

text

npm run dev

打开页面,你就会看到子应用嵌进来了!

第三步:子应用怎么配置(最友好的是零改造!)

子应用就是你原来的普通项目(Vue、React、Vite 等)。

超级好消息:用“保活模式”或“重建模式”,子应用完全不用改代码!

推荐方式:保活模式(keep-alive,最丝滑,无白屏)

在基座的 组件上加一个属性:

vue

<wujie-vue
  ...
  alive="true"   <!-- 就是这一行!开启保活 -->
></wujie-vue>

完了!子应用零改造,切换回来不会重新加载,超级快。

如果你想用单例模式(多个地方共用同一个实例,需要改造一点)

只在子应用的入口文件(main.js 或 index.js)里包一层判断:

Vue3 示例(vite 项目) : 在 main.js 最外面包起来:

JavaScript

let instance

if (window.__POWERED_BY_WUJIE__) {
  // 被 Wujie 加载时的生命周期
  window.__WUJIE_MOUNT = () => {
    instance = createApp(App).use(router).mount('#app')
  }
  window.__WUJIE_UNMOUNT = () => {
    instance.unmount()
  }
  // 如果是 Vite 构建的,需要主动触发
  window.__WUJIE.mount()
} else {
  // 独立运行时
  createApp(App).use(router).mount('#app')
}

其他框架(React、Vue2)文档里都有示例,基本就是把 mount 和 unmount 挂到 window 上。

强烈建议小白先用保活模式,完全不用改子应用。

单例模式VS保活模式

保活模式

什么是保活模式?想象子应用是一个“玩具房子”;

  • 正常情况,你离开房间(切换页面),房子就被拆掉(卸载),回来需要重新搭(重新加载,有白屏)
  • 保活模式,房子一直留在原地不拆(保持活着),你回来直接玩(零白屏,状态完全保留,比如输入框的内容还在)
  • 优点:
    • 配置最简单
    • 零改造子应用
    • 切换最快、无白屏
  • 缺点: 如果基座多个地方加载同一个子应用,状态不共享(每个是独立的“房子”)
  • 适合场景:导航菜单切换子应用,Tab页切换,大多数项目都先用这个

单例模式

什么是单例模式? 全局只有一个房子!不管你在几个房间放几个门(多个容器加载同一个子应用),开门进门的都是同一个房子(共享同一个实例和状态)。 怎么配置: * 基座不用特殊配置(可以结合alive=true一起使用) * 关键: 子应用需要改造代码(手动控制挂载和卸载) 子应用改造示例(Vue3+Vite项目为例,在main.js或main.ts最外面包一层):

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

let instance = null  // 全局只有一个 instance

function mount() {
  if (!instance) {
    instance = createApp(App)
    instance.use(router)
    instance.mount('#app')  // 挂载到容器
  }
}

function unmount() {
  if (instance) {
    instance.unmount()
    instance = null  // 可选:销毁实例
  }
}

// 被 Wujie 加载时(微前端模式)
if (window.__POWERED_BY_WUJIE__) {
  // 挂到 window 上,Wujie 会自动调用
  window.__WUJIE_MOUNT = () => {
    mount()
  }
  window.__WUJIE_UNMOUNT = () => {
    unmount()
  }
  
  // Vite 项目需要主动触发一次(有些构建工具需要)
  window.__WUJIE.mount?.()
} else {
  // 独立运行时(直接 npm run dev)
  createApp(App).use(router).mount('#app')
}
  • 单例模式特点:
    • 全局只有一个子应用实例
    • 多个地方加载同一个url的子应用,共享状态(比如登录用户信息全局共享)
    • 切换时可以不重建(如果结合保活)
    • 需要改在子应用(__WUJIE_MOUNT和__WUJIE_UNMOUNT)
  • 优点:
    • 状态全局共享(适合需要单点登录、共享用户数据等)
    • 内存占用更少(只有一个实例)
  • 缺点:
    • 需要改造子应用代码
    • 如果不结合保活,切换可能有轻微重建
  • 适合场景: 子应用需要在基座多个位置出现,但状态一致(比如全局共享的侧边栏,头部组件)
  • 最强组合:单例模式+保活模式一起用!

第四步:常见问题注意事项(小白必看)

  1. 跨域问题:子应用的地址必须和基座同域,或者子应用服务器开 CORS(允许跨域)。本地开发时,两个项目用不同端口,就要开代理或用 nginx。
  2. 子应用地址:本地开发时,子应用先单独跑起来(npm run dev),拿到 http://localhost:xxxx,然后填到基座的 url。
  3. 样式隔离:Wujie 天生隔离好,子应用样式不会污染基座。
  4. 通信:基座传数据用 props;子应用发事件用 bus.emit(事件,数据);基座监听bus.emit('事件名', 数据);基座监听 bus.on('事件名', handler)。

Wujie微前端的通信方案(基座 ↔ 子应用、子应用 ↔ 子应用)

Wujie 的通信设计非常简单和强大,它内置了一个全局事件总线(EventBus) ,所有子应用和基座都共享同一个 bus 对象。这意味着:

  • 基座 → 子应用:单向传值最常用的是 props
  • 子应用 → 基座:子应用通过 bus.$emit 发事件,基座监听
  • 子应用 ↔ 子应用:直接通过同一个 bus 互相发事件和监听(不需要经过基座中转!)
  • 另外还有一些辅助方式(如插件共享状态)

下面我一步一步慢慢讲,每种方案都配代码示例(基于 Vue3 基座 + Vue3/React 子应用通用)。

方案1:基座 → 子应用(推荐:props 单向传值)

这是最简单、最常用的方式。基座把数据作为 props 传给子应用,子应用实时接收(数据变化会自动同步)。

基座配置( 组件上加 props)

vue

<template>
  <wujie-vue
    name="sub-app1"
    url="http://localhost:3001"
    :props="subAppProps"
  ></wujie-vue>
</template>

<script setup>
import { ref } from 'vue'

const subAppProps = ref({
  token: 'abc123',
  userName: '小明',
  count: 0,
  // 甚至可以传函数!
  jumpTo: (path) => {
    console.log('子应用调用了基座的函数,跳转到', path)
    // 这里可以控制基座路由
  }
})

// 基座修改数据,子应用会自动收到更新
const updateCount = () => {
  subAppProps.value.count += 1
}
</script>

子应用接收(在任何地方都可以拿到)

JavaScript

// 子应用里(Vue/React 都一样)
if (window.__POWERED_BY_WUJIE__) {
  // 被 Wujie 加载时
  const props = window.$wujie?.props || {}
  console.log('收到基座传来的数据:', props)
  // props.token, props.userName, props.count, props.jumpTo()

  // 如果想响应式(Vue3 示例)
  import { watch } from 'vue'
  watch(() => window.$wujie?.props, (newProps) => {
    console.log('props 更新了:', newProps)
  }, { deep: true })
}

优点:实时同步、类型安全、支持传函数(子应用可以调用基座方法) 缺点:单向(子应用不能直接改基座的数据)

方案2:子应用 → 基座、子应用 → 子应用(全局 EventBus)

Wujie 内置了一个全局 bus,所有子应用和基座共享同一个实例。子应用之间可以直接通信,不需要基座中转。

基座监听事件(在任意组件里)

vue

<script setup>
import { onMounted, onUnmounted } from 'vue'
import { bus } from 'wujie-vue3'  // 基座直接导入

onMounted(() => {
  // 监听所有子应用发的事件
  bus.$on('click-btn', (data) => {
    console.log('基座收到事件,数据是:', data)
    // 这里可以更新基座状态、跳转路由等
  })

  // 监听特定子应用(可选)
  bus.$on('sub-app1-event', (data) => { ... })
})

onUnmounted(() => {
  bus.$off('click-btn')  // 记得销毁,防止内存泄漏
})
</script>

子应用发事件(在子应用任意位置)

JavaScript

// 子应用里(不需要额外安装,直接用 window.$wujie.bus)
const sendEvent = () => {
  window.$wujie?.bus.$emit('click-btn', {
    msg: '来自子应用1的数据',
    value: 999
  })
}

// 子应用2 也可以直接监听子应用1的事件!
window.$wujie?.bus.$on('click-btn', (data) => {
  console.log('子应用2 收到子应用1的事件:', data)
})

关键点

  • 所有子应用共享同一个 bus,所以子应用A emit的事件,子应用B可以直接emit 的事件,子应用B 可以直接 on 收到。
  • 事件名建议加前缀避免冲突,比如 'sub-app1-click'。
  • 支持一次性监听:bus.$once('event', handler)
  • 销毁监听:$off

优点:双向、实时、子应用间直接通信、非常灵活 缺点:需要手动管理事件名和销毁监听

方案3:基座 → 所有子应用广播(结合 props + bus)

如果你想基座主动推送数据给所有子应用:

  • 用 bus.$emit 发事件,所有子应用监听同一个事件。
  • 或者用插件(见下面)共享状态。
方案4:共享状态(插件方式,适合复杂状态管理)

如果你的项目状态很多,推荐用 Wujie 的插件系统共享 Pinia/Vuex/Redux 等。

官方推荐一个插件:wujie-polyfill,可以共享状态管理库。

示例:共享 Pinia(Vue)

  1. 基座和所有子应用都安装同一个 Pinia store。
  2. 用插件让它们共享实例(具体代码看官方插件文档)。

或者更简单:用 window 共享一个全局对象(不推荐,容易污染)。

总结对比(小白一看就懂)
场景 推荐方案 方向 是否实时 代码复杂度 备注
基座 → 子应用(传数据/函数) props 单向 最常用
子应用 → 基座 bus.emit+emit + on 双向
子应用 → 子应用 bus.emit+emit + on 双向 直接通信,超方便
全局共享复杂状态 插件(Pinia等) 双向 项目大时用

wujie源码

wujie源码主要包含以下几部分:

  • wujie-core
  • wujie-vue2/wujie-vue3
  • wujie-react
  • wujie-polyfill

wujie-core

  • 入口文件:index.ts
// 导出核心 API
export { startApp, preloadApp, destroyApp } from './start'  // 启动、预加载、销毁
export { bus } from './bus'  // 通信总线
export { Wujie } from './wujie'  // 主类

// 全局配置
export function setupApp(options) { ... }

// 预加载
export async function preloadApp(options: PreloadAppOptions) {
  // 提前下载资源,缓存 HTML/JS/CSS
}

// 启动应用
export async function startApp(options: StartAppOptions) {
  // 创建沙箱、加载资源、执行代码
}
* startApp主要是创建沙箱-》fetch资源=〉执行JS=》挂载DOM
* Wujie有两种沙箱模式
    * 默认:Proxy沙箱(快,推荐):wujie用Proxy代理window对象,让子应用以为自己在操作全局window,但其实操作的是“假window”
    * iframe沙箱(最强隔离,但慢)
    
class WujieSandbox {
active = true;  // 沙箱是否激活
proxyWindow: WindowProxy;  // 代理的 window

constructor(name) {
 // 创建假 window(其实是空对象)
 const fakeWindow = Object.create(null);

 // 记录修改的变量(为了销毁时恢复)
 const modifiedMap = new Map();
 const reversedMap = new Map();  // 恢复用

 this.proxyWindow = new Proxy(fakeWindow, {
   get(target, key) {
     // 1. 先从子应用自己的变量拿
     if (key in target) return target[key];
     // 2. 否则从真实 window 拿(基座共享)
     return window[key];
   },
   set(target, key, value) {
     if (this.active) {
       // 激活时:子应用修改变量,只改假 window
       modifiedMap.set(key, value);
       target[key] = value;
     } else {
       // 不激活时:直接改真实 window(共享)
       window[key] = value;
     }
     return true;
   },
   // delete、has 等也类似处理
 });

 // 避免 with 语句(历史问题,已优化)
 // 用 with 包裹代码时特殊处理
}

// 销毁沙箱:恢复基座 window
destroy() {
 this.active = false;
 // 把子应用改的变量恢复原样
 modifiedMap.forEach((value, key) => {
   window[key] = reversedMap.get(key) || originalValue;
 });
}
}
  • 怎么解决全局变量冲突?Proxy拦截get/set,只让子应用改自己的;这个比single-spa的snapshot快,比乾坤的Proxy更完善

框架适配包

  • wujie-vue3(vue组件怎么实现的?)其实就是一个wrapper,核心还是core的startApp

总结

Wujie的目标:让子应用(Vue/React等)像嵌入iframe一样隔离强,性能好,无白屏 整体流程:

  1. 加载子应用HTML:用importHTML函数fetch子应用的index.html,解析它,提取所有的

  2. 解析HTML:用preocessTp把HTML拆成template,scripts,styles

  3. 创建沙箱:new 一个Wujie的类实例。它会:

    • 创建一个隐藏的iframe(JS执行环境)
    • 用Shadow DOM(影子根)隔离CSS/DOM
    • 用Proxy代理window/document/location(JS隔离,最牛的地方)
    • 如果浏览器太老,降级用纯iframe
  4. 执行资源:加载CSS(内联或替换路径),执行JS(内联eval或动态script),支持插件修改代码。

  5. 挂载:把解析后的template塞到Shadow DOM或iframe,执行mount生命周期。

  6. 通信: 左右子应用和基座共享一个bus(EventBus),emit/emit/on发事件。

  7. 保活/销毁:支持保活模式(不销毁DOM),销毁时恢复变量,清除事件。

  8. 插件系统:所有步骤可以插插件(比如改CSS路径,排除JS)

为什么强

* JS隔离:Proxy代理window,子应用改变量只改变自己的“假window”
* CSS隔离:Shdow DOM
* 样式/JS路径自动修正
* 通信简单:全局Bus
* 性能好: 预加载,保活,无白屏

wujie-core 中所有重要函数/类罗列(按重要度排序)

从源码文件看,核心是这些(没有一个“startApp”函数,可能是框架包里调的,core里是底层):

  1. bus(全局 EventBus 实例,index.ts)—— 通信核心
  2. EventBus 类(event.ts)—— on/on/emit/$off 等通信方法
  3. importHTML(entry.ts)—— 加载和解析子应用HTML,最重要加载函数
  4. processTpl(template.ts)—— 解析HTML提取script/style
  5. class Wujie(sandbox.ts)—— 沙箱主类,constructor/mount/destroy 等
  6. proxyGenerator / localGenerator(proxy.ts)—— 创建Proxy代理 window/document/location
  7. getPlugins / defaultPlugin(plugin.ts)—— 插件系统
  8. 其他辅助:processCssLoader、getEmbedHTML 等(加载CSS)

Web文件下载 : 从PDF预览Bug到Hook架构演进

作者 im_AMBER
2026年1月30日 22:54

大家好,我是 AY。

在 Web 开发中,下载功能看似简单,却隐藏着浏览器行为差异与跨域安全限制的陷阱。

今天,我原本只想做一个导出不同文件格式的功能,却遇到了一个bug:生成Word或MD文件时,Chrome浏览器都会正常弹出下载框,但导出PDF文件时却不行——PDF会直接在当前页面预览,看起来明明是要下载PDF,结果却直接进入了预览模式,而且我原本打开的页面还被这个预览页面覆盖了。

一、为什么 PDF 会“不请自来”地预览?

1.浏览器的 MIME 类型策略

浏览器如何处理一个 URL,取决于服务器返回的 MIME 类型(Multipurpose Internet Mail Extensions)

  • Word/MD 文件:由于 Chrome 等浏览器没有内置渲染引擎,它会识别为“不可直接读取的内容”,从而触发下载。
  • PDF 文件:现代浏览器均内置了功能强大的 PDF 渲染器。当它接收到 application/pdf 类型时,默认行为是 “当前窗口导航(Navigation)”

2. 被忽略的 download 属性

我们通常尝试通过 <a download> 标签强制下载,但它受到 同源策略(Same-Origin Policy) 的严格限制:

  • 同源请求download 属性正常工作,强制下载。
  • 跨域请求:如果资源来自不同的域名/端口,浏览器出于安全考虑会 无视 download 属性,将其降级为一个普通链接,导致 PDF 直接在当前页打开。

二、Blob 对象与 Object URL

为了绕过跨域下载限制并防止原页面丢失,最稳健的方案是利用 Blob (Binary Large Object)

1. 内存中的“影子文件”

通过 fetch 请求将远程文件拉取到内存中转换为 Blob,我们可以利用 URL.createObjectURL(blob) 生成一个临时的 blob: 协议链接。

MDN 定义 - URL.createObjectURL():

该方法创建一个 DOMString。该 URL 的生命周期与其创建时的 document 绑定。

2. 为什么 Blob 能解决问题?

  • 伪装同源:生成的 blob:// 链接与当前页面拥有相同的 Origin,这使得 download 属性 100% 被浏览器尊重。
  • 生命周期管理:虽然 URL 与 DOM 树绑定,但它仅仅是指向内存的指针。通过手动创建 a 标签并设置 target="_blank" 或触发 .click(),我们可以精确控制它是静默下载还是新窗口预览。

三、架构升级:自定义 Hook 的解耦艺术

在复杂的业务逻辑中,我原本将文件获取、Blob 转换、动态创建 DOM 节点等代码堆积在 index.tsx 中会导致维护灾难。

1. 逻辑抽离的必要性

  • 关注点分离:UI 组件只负责“展示”,而下载的繁琐逻辑应该交给专门的逻辑单元。
  • 复用性:自定义 Hook 可以让下载逻辑在全站不同页面间自由导入。

2. 最佳实践代码实现

我们将这一过程封装为 useDownload Hook,实现一处定义,随处调用:

import { useState } from 'react';

/**
 * 自定义下载 Hook
 * 封装了从获取流到触发 DOM 点击的全过程
 */
export const useDownload = () => {
  const [loading, setLoading] = useState(false);

  const handleDownload = async (fileUrl: string, fileName: string) => {
    setLoading(true);
    try {
      // 1. 获取资源并转化为二进制 Blob
      const response = await fetch(fileUrl);
      const blob = await response.blob();

      // 2. 生成内存 URL
      const url = window.URL.createObjectURL(blob);

      // 3. 动态注入 a 标签触发下载
      const link = document.createElement('a');
      link.href = url;
      link.download = fileName; // 此时同源,download 属性生效
      document.body.appendChild(link);
      link.click();

      // 4. 清理现场
      document.body.removeChild(link);
      window.URL.revokeObjectURL(url); // 必须释放内存,防止溢出
    } catch (e) {
      console.error("下载失败", e);
    } finally {
      setLoading(false);
    }
  };

  return { handleDownload, loading };
};

🌟 总结

一个 PDF 跳转的小 Bug,我补充了一些Web API 的学习,本质上是浏览器安全策略与渲染机制的综合体现。

  1. 明确边界:知道 download 属性何时失效,比盲目调试代码更重要。
  2. 生命周期意识:在使用 createObjectURL 时,必须养成配套使用 revokeObjectURL 的习惯。
  3. 架构思维:即便是一个“很小的 Bug”,也值得通过 自定义 Hook 进行架构级的封装,从而实现从“业务实现”到“工程设计”的跨越。

 参考文献:

: The Anchor element - HTML | MDN:锚元素 - HTML(超文本标记语言) | MDN

reportlab.com/docs/report…

使用 Effect 进行同步 – React 中文文档: The Anchor element - HTML | MDN

URL:createObjectURL() 静态方法 - Web API | MDN

Document - Web API | MDN

File - Web API | MDN

Blob - Web API | MDN

MediaSource - Web API | MDN

使用自定义 Hook 复用逻辑 – React 中文文档

浏览器的同源策略 - 安全 | MDN

❌
❌