普通视图

发现新文章,点击刷新页面。
今天 — 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 中没有真正的类,只有对象和它们之间的链接(原型链),对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

❌
❌