普通视图
type-challenges(ts类型体操): 9 - 对象属性只读(递归)
9 - 对象属性只读(递归)
by Anthony Fu (@antfu) #中等 #readonly #object-keys #deep
题目
实现一个泛型 DeepReadonly<T>,它将对象的每个参数及其子对象递归地设为只读。
您可以假设在此挑战中我们仅处理对象。不考虑数组、函数、类等。但是,您仍然可以通过覆盖尽可能多的不同案例来挑战自己。
例如
type X = {
x: {
a: 1
b: 'hi'
}
y: 'hey'
}
type Expected = {
readonly x: {
readonly a: 1
readonly b: 'hi'
}
readonly y: 'hey'
}
type Todo = DeepReadonly<X> // should be same as `Expected`
在 Github 上查看:tsch.js.org/9/zh-CN
代码
/* _____________ 你的代码 _____________ */
type DeepReadonly<T> = {
readonly [P in keyof T]: keyof T[P] extends never ? T[P] : DeepReadonly<T[P]>
}
关键解释:
-
readonly [P in keyof T]: ...用于将对象的每个属性设为只读。 -
keyof T[P] extends never ? T[P] : DeepReadonly<T[P]>用于递归处理子对象。 -
keyof T[P] extends never用于判断T[P]是否为基础类型(不包含子对象)。 -
T[P]用于获取属性的类型。 -
DeepReadonly<T[P]>用于递归处理子对象。
相关知识点
readonly
- 核心作用:标记后,目标(属性 / 数组 / 元组)只能在初始化阶段赋值(比如接口实例化、类构造函数、变量声明时),后续任何修改操作都会被 TS 编译器拦截报错;
- 运行时特性:
readonly仅做编译时检查,不会生成任何额外 JS 代码,也无法真正阻止运行时的修改(比如通过类型断言绕开的话,运行时仍能改); - 与
const的区别:const是变量层面的不可重新赋值(但变量指向的对象 / 数组内部属性仍可改),readonly是属性 / 类型层面的不可修改(变量本身可重新赋值,除非变量也用const)。
常用使用场景:
- 作用于接口 / 类型别名的属性(最基础)
// 定义带只读属性的接口
interface User {
readonly id: number; // 只读属性:只能初始化赋值,后续不可改
name: string; // 普通属性:可修改
}
// 初始化时赋值(合法)
const user: User = { id: 1, name: "张三" };
// 尝试修改只读属性(报错)
user.id = 2; // ❌ 报错:无法分配到 "id",因为它是只读属性
// 修改普通属性(合法)
user.name = "李四"; // ✅ 合法
- 作用于类的属性: 类中使用
readonly标记属性,只能在声明时或构造函数中赋值,后续无法修改
class Person {
readonly id: number; // 只读属性
name: string;
// 构造函数中给 readonly 属性赋值(唯一合法的后续赋值方式)
constructor(id: number, name: string) {
this.id = id;
this.name = name;
}
updateInfo() {
this.id = 100; // ❌ 报错:id 是只读属性
this.name = "王五"; // ✅ 合法
}
}
const person = new Person(1, "赵六");
person.id = 2; // ❌ 报错:只读属性不可修改
- 作用于数组 / 元组(只读数组):
readonly可标记数组为 “只读数组”,禁止修改数组元素、调用push/pop等修改方法
// 方式1:使用 readonly 修饰数组类型
const arr1: readonly number[] = [1, 2, 3];
arr1.push(4); // ❌ 报错:readonly 数组不存在 push 方法
arr1[0] = 10; // ❌ 报错:无法修改只读数组的元素
// 方式2:使用 ReadonlyArray<T> 类型(等价于 readonly T[])
const arr2: ReadonlyArray<string> = ["a", "b"];
arr2.pop(); // ❌ 报错
// 作用于元组(只读元组)
type Point = readonly [number, number];
const point: Point = [10, 20];
point[0] = 30; // ❌ 报错:只读元组元素不可修改
- 结合
keyof+in批量创建只读类型(映射类型)
interface Product {
name: string;
price: number;
stock: number;
}
// 批量创建只读版本的 Product(TS 内置的 Readonly<T> 就是这么实现的)
type ReadonlyProduct = {
readonly [K in keyof Product]: Product[K];
};
const product: ReadonlyProduct = { name: "手机", price: 2999, stock: 100 };
product.price = 3999; // ❌ 报错:price 是只读属性
// TS 内置了 Readonly<T>,可直接使用(无需手动写映射类型)
const product2: Readonly<Product> = { name: "电脑", price: 5999, stock: 50 };
product2.stock = 60; // ❌ 报错
- 只读索引签名:如果类型使用索引签名,也可以标记为
readonly,禁止通过索引修改属性
// 只读索引签名:只能读取,不能修改
type ReadonlyDict = {
readonly [key: string]: number;
};
const dict: ReadonlyDict = { a: 1, b: 2 };
dict["a"] = 3; // ❌ 报错:索引签名是只读的
console.log(dict["b"]); // ✅ 合法:仅读取
keyof
keyof 操作符用于获取对象类型的所有属性名(包括索引签名),并将其转换为联合类型。
例如:
interface Todo {
title: string
description: string
completed: boolean
}
type TodoKeys = keyof Todo // "title" | "description" | "completed"
in
in 操作符用于遍历联合类型中的每个成员。
例如:
interface Todo {
title: string
description: string
completed: boolean
}
type TodoKeys = 'title' | 'description' | 'completed'
type TodoPreview = {
[P in TodoKeys]: Todo[P]
}
// TodoPreview 类型为:
// {
// title: string
// description: string
// completed: boolean
// }
extends
| 使用维度 | 核心作用 | 示例场景 |
|---|---|---|
| 类型维度 | 做类型约束或条件判断(类型编程核心) | 限定泛型范围、判断类型是否兼容、提取类型片段 |
| 语法维度 | 做继承(复用已有结构) | 接口继承、类继承 |
extends 做类型约束或条件判断
- 泛型约束:限定泛型的取值范围
// 约束 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 属性
- 条件类型:类型版 三元运算符
// 基础示例:判断类型是否为字符串
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(整体不兼容)
- 配合
infer:提取类型片段(黄金组合)
// 提取 Promise 的返回值类型
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;
type C = UnwrapPromise<Promise<string>>; // string(提取成功)
type D = UnwrapPromise<number>; // number(不满足条件,返回原类型)
extends 做继承(复用已有结构)
- 接口继承:复用 + 扩展属性
// 基础接口
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
}
- 类继承:复用父类的属性 / 方法
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
测试用例
/* _____________ 测试用例 _____________ */
import type { Equal, Expect } from '@type-challenges/utils'
type cases = [
Expect<Equal<DeepReadonly<X1>, Expected1>>,
Expect<Equal<DeepReadonly<X2>, Expected2>>,
]
type X1 = {
a: () => 22
b: string
c: {
d: boolean
e: {
g: {
h: {
i: true
j: 'string'
}
k: 'hello'
}
l: [
'hi',
{
m: ['hey']
},
]
}
}
}
type X2 = { a: string } | { b: number }
type Expected1 = {
readonly a: () => 22
readonly b: string
readonly c: {
readonly d: boolean
readonly e: {
readonly g: {
readonly h: {
readonly i: true
readonly j: 'string'
}
readonly k: 'hello'
}
readonly l: readonly [
'hi',
{
readonly m: readonly ['hey']
},
]
}
}
}
type Expected2 = { readonly a: string } | { readonly b: number }
相关链接
分享你的解答:tsch.js.org/9/answer/zh… 查看解答:tsch.js.org/9/solutions 更多题目:tsch.js.org/zh-CN
下面是我的公众号,欢迎关注。关注后有新的功能点会及时收到推送。
实战为王!专注于汇总各种功能点,致力于打造一系列能够帮助工程师实现各种功能的想法思路的优质文章。
![]()
type-challenges(ts类型体操): 8 - 对象部分属性只读
8 - 对象部分属性只读
by Anthony Fu (@antfu) #中等 #readonly #object-keys
题目
实现一个泛型MyReadonly2<T, K>,它带有两种类型的参数T和K。
类型 K 指定 T 中要被设置为只读 (readonly) 的属性。如果未提供K,则应使所有属性都变为只读,就像普通的Readonly<T>一样。
例如
interface Todo {
title: string
description: string
completed: boolean
}
const todo: MyReadonly2<Todo, 'title' | 'description'> = {
title: "Hey",
description: "foobar",
completed: false,
}
todo.title = "Hello" // Error: cannot reassign a readonly property
todo.description = "barFoo" // Error: cannot reassign a readonly property
todo.completed = true // OK
在 Github 上查看:tsch.js.org/8/zh-CN
代码
/* _____________ 你的代码 _____________ */
type MyReadonly2<T, K extends keyof T = keyof T> = {
readonly [P in K]: T[P]
} & Omit<T, K>
关键解释:
-
MyReadonly2<T, K extends keyof T = keyof T>表示MyReadonly2是一个泛型,它有两个类型参数T和K。 -
K extends keyof T = keyof T表示K是T的属性名的子类型,默认值为keyof T,即所有属性都为只读。 -
readonly [P in K]: T[P]表示将K中的属性名P转换为只读属性,属性值为T[P]。 -
& Omit<T, K>表示将T中除了K中的属性名外的其他属性保留下来。
相关知识点
extends
| 使用维度 | 核心作用 | 示例场景 |
|---|---|---|
| 类型维度 | 做类型约束或条件判断(类型编程核心) | 限定泛型范围、判断类型是否兼容、提取类型片段 |
| 语法维度 | 做继承(复用已有结构) | 接口继承、类继承 |
extends 做类型约束或条件判断
- 泛型约束:限定泛型的取值范围
// 约束 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 属性
- 条件类型:类型版 三元运算符
// 基础示例:判断类型是否为字符串
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(整体不兼容)
- 配合
infer:提取类型片段(黄金组合)
// 提取 Promise 的返回值类型
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;
type C = UnwrapPromise<Promise<string>>; // string(提取成功)
type D = UnwrapPromise<number>; // number(不满足条件,返回原类型)
extends 做继承(复用已有结构)
- 接口继承:复用 + 扩展属性
// 基础接口
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
}
- 类继承:复用父类的属性 / 方法
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
readonly
- 核心作用:标记后,目标(属性 / 数组 / 元组)只能在初始化阶段赋值(比如接口实例化、类构造函数、变量声明时),后续任何修改操作都会被 TS 编译器拦截报错;
- 运行时特性:
readonly仅做编译时检查,不会生成任何额外 JS 代码,也无法真正阻止运行时的修改(比如通过类型断言绕开的话,运行时仍能改); - 与
const的区别:const是变量层面的不可重新赋值(但变量指向的对象 / 数组内部属性仍可改),readonly是属性 / 类型层面的不可修改(变量本身可重新赋值,除非变量也用const)。
常用使用场景:
- 作用于接口 / 类型别名的属性(最基础)
// 定义带只读属性的接口
interface User {
readonly id: number; // 只读属性:只能初始化赋值,后续不可改
name: string; // 普通属性:可修改
}
// 初始化时赋值(合法)
const user: User = { id: 1, name: "张三" };
// 尝试修改只读属性(报错)
user.id = 2; // ❌ 报错:无法分配到 "id",因为它是只读属性
// 修改普通属性(合法)
user.name = "李四"; // ✅ 合法
- 作用于类的属性: 类中使用
readonly标记属性,只能在声明时或构造函数中赋值,后续无法修改
class Person {
readonly id: number; // 只读属性
name: string;
// 构造函数中给 readonly 属性赋值(唯一合法的后续赋值方式)
constructor(id: number, name: string) {
this.id = id;
this.name = name;
}
updateInfo() {
this.id = 100; // ❌ 报错:id 是只读属性
this.name = "王五"; // ✅ 合法
}
}
const person = new Person(1, "赵六");
person.id = 2; // ❌ 报错:只读属性不可修改
- 作用于数组 / 元组(只读数组):
readonly可标记数组为 “只读数组”,禁止修改数组元素、调用push/pop等修改方法
// 方式1:使用 readonly 修饰数组类型
const arr1: readonly number[] = [1, 2, 3];
arr1.push(4); // ❌ 报错:readonly 数组不存在 push 方法
arr1[0] = 10; // ❌ 报错:无法修改只读数组的元素
// 方式2:使用 ReadonlyArray<T> 类型(等价于 readonly T[])
const arr2: ReadonlyArray<string> = ["a", "b"];
arr2.pop(); // ❌ 报错
// 作用于元组(只读元组)
type Point = readonly [number, number];
const point: Point = [10, 20];
point[0] = 30; // ❌ 报错:只读元组元素不可修改
- 结合
keyof+in批量创建只读类型(映射类型)
interface Product {
name: string;
price: number;
stock: number;
}
// 批量创建只读版本的 Product(TS 内置的 Readonly<T> 就是这么实现的)
type ReadonlyProduct = {
readonly [K in keyof Product]: Product[K];
};
const product: ReadonlyProduct = { name: "手机", price: 2999, stock: 100 };
product.price = 3999; // ❌ 报错:price 是只读属性
// TS 内置了 Readonly<T>,可直接使用(无需手动写映射类型)
const product2: Readonly<Product> = { name: "电脑", price: 5999, stock: 50 };
product2.stock = 60; // ❌ 报错
- 只读索引签名:如果类型使用索引签名,也可以标记为
readonly,禁止通过索引修改属性
// 只读索引签名:只能读取,不能修改
type ReadonlyDict = {
readonly [key: string]: number;
};
const dict: ReadonlyDict = { a: 1, b: 2 };
dict["a"] = 3; // ❌ 报错:索引签名是只读的
console.log(dict["b"]); // ✅ 合法:仅读取
keyof
keyof 操作符用于获取对象类型的所有属性名(包括索引签名),并将其转换为联合类型。
例如:
interface Todo {
title: string
description: string
completed: boolean
}
type TodoKeys = keyof Todo // "title" | "description" | "completed"
in
in 操作符用于遍历联合类型中的每个成员。
例如:
interface Todo {
title: string
description: string
completed: boolean
}
type TodoKeys = 'title' | 'description' | 'completed'
type TodoPreview = {
[P in TodoKeys]: Todo[P]
}
// TodoPreview 类型为:
// {
// title: string
// description: string
// completed: boolean
// }
Omit<T, K>
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" // 同样报错
};
测试用例
/* _____________ 测试用例 _____________ */
import type { Alike, Expect } from '@type-challenges/utils'
type cases = [
Expect<Alike<MyReadonly2<Todo1>, Readonly<Todo1>>>,
Expect<Alike<MyReadonly2<Todo1, 'title' | 'description'>, Expected>>,
Expect<Alike<MyReadonly2<Todo2, 'title' | 'description'>, Expected>>,
Expect<Alike<MyReadonly2<Todo2, 'description' >, Expected>>,
]
// @ts-expect-error
type error = MyReadonly2<Todo1, 'title' | 'invalid'>
interface Todo1 {
title: string
description?: string
completed: boolean
}
interface Todo2 {
readonly title: string
description?: string
completed: boolean
}
interface Expected {
readonly title: string
readonly description?: string
completed: boolean
}
相关链接
分享你的解答:tsch.js.org/8/answer/zh… 查看解答:tsch.js.org/8/solutions 更多题目:tsch.js.org/zh-CN
下面是我的公众号,欢迎关注。关注后有新的功能点会及时收到推送。
实战为王!专注于汇总各种功能点,致力于打造一系列能够帮助工程师实现各种功能的想法思路的优质文章。
![]()
10分钟带你用Three.js手搓一个3D世界,代码少得离谱!
🎬 核心概念:上帝的“片场”
在 Three.js 的世界里,想要画面动起来,你只需要凑齐这“四大金刚”:
1. 场景 (Scene) —— 你的片场
想象你是一个导演,首先你得有个场地。在 Three.js 里,Scene 就是这个场地。它是一个容器,用来放置所有的物体、灯光和摄像机。
const scene = new THREE.Scene(); //(这就开辟了一个场地)
2. 摄像机 (Camera) —— 你的眼睛
片场有了,观众怎么看?得架摄像机。 Three.js 里最常用的是 透视摄像机 (PerspectiveCamera)。 这就好比人的眼睛,近大远小。
- 你需要告诉它:
- 视角 (FOV):镜头是广角还是长焦?
- 长宽比 (Aspect):电影是 16:9 还是 4:3?
- 近剪切面 & 远剪切面:太近看不见,太远也看不见。
3. 渲染器 (Renderer) —— 你的放映机
场景布置好了,摄像机架好了,谁把画面画到屏幕(Canvas)上?这就是渲染器的工作。它负责计算每一帧画面,把 3D 的数据“拍扁”成 2D 的像素点显示在网页上。
4. 网格 (Mesh) —— 你的演员 🕺
这是最关键的部分!片场不能是空的,得有东西。在 Three.js 里,一个可见的物体通常被称为 Mesh (网格)。 一个 Mesh 由两部分组成(缺一不可):
-
几何体 (Geometry):演员的身材。是方的?圆的?还是复杂的角色模型?(比如
BoxGeometry就是个立方体骨架)。 -
材质 (Material):演员的衣服。是金属质感?塑料质感?还是发光的?什么颜色?(比如
MeshPhongMaterial就是一种这就好比给骨架穿上了皮肤)。
⚡️ 实战:3分钟手搓一个旋转立方体
别眨眼,核心代码真的少得离谱。我们来把上面的概念串起来:
第一步:搭建舞台(初始化)
// 1. 创建场景
const scene = new THREE.Scene();
// 2. 创建摄像机 (视角75度, 宽高比, 近距0.1, 远距1000)
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
// 把摄像机往后拉一点,不然就在物体肚子里了
camera.position.z = 5;
// 3. 创建渲染器
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
// 把渲染出来的 canvas 塞到页面里
document.body.appendChild(renderer.domElement);
第二步:请演员入场(创建物体)
// 1. 骨架:一个 1x1x1 的立方体
const geometry = new THREE.BoxGeometry(1, 1, 1);
// 2. 皮肤:绿色的,对光照有反应的材质
const material = new THREE.MeshPhongMaterial({ color: 0x44aa88 });
// 3. 合体:创建网格
const cube = new THREE.Mesh(geometry, material);
// 4. 放到场景里!
scene.add(cube);
第三步:打光(Light)
如果你用的是 MeshPhongMaterial 这种高级材质,没有光就是漆黑一片。
// 创建一个平行光(类似太阳光)
const light = new THREE.DirectionalLight(0xFFFFFF, 1);
light.position.set(-1, 2, 4);
scene.add(light);
第四步:Action!(动画循环)
电影是每秒 24 帧的静态图,Three.js 也一样。我们需要一个循环,不停地让渲染器“拍照”。
function animate() {
requestAnimationFrame(animate); // 浏览器下次重绘前调用我
// 让立方体动起来
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
// 咔嚓!渲染一帧
renderer.render(scene, camera);
}
animate(); // 开始循环
📂 核心代码与完整示例: my-three-app
总结
如果你喜欢本教程,记得点赞+收藏!关注我获取更多Three.js开发干货
RBAC 权限系统实战(一):页面级访问控制全解析
前言
本篇文章主要讲解 RBAC 权限方案在中后台管理系统的实现
在公司内部写过好几个后台系统,都需要实现权限控制,在职时工作繁多,没有系统性的来总结一下相关经验,现在人已离职,就把自己的经验总结一下,希望能帮助到你
本文是《通俗易懂的中后台系统建设指南》系列的第九篇文章,该系列旨在告诉你如何来构建一个优秀的中后台管理系统
权限模型有哪些?
主流的权限模型主要分为以下五种:
- ACL模型:访问控制列表
- DAC模型:自主访问控制
- MAC模型:强制访问控制
- ABAC模型:基于属性的访问控制
- RBAC模型:基于角色的权限访问控制
这里不介绍全部的权限模型,有兴趣你可以看看这篇文章:权限系统就该这么设计,yyds
如果你看过、用过市面上一些开源后台系统及权限设计,你会发现它们主要都是基于 RBAC 模型来实现的
为什么是 RBAC 权限模型?
好问题!我帮你问了下 AI
| 对比维度 | ACL (访问控制列表) | RBAC (基于角色) | ABAC (基于属性) |
|---|---|---|---|
| 核心逻辑 |
用户 ↔ 权限 直接点对点绑定,无中间层 |
用户 ↔ 角色 ↔ 权限 引入“角色”解耦,权限归于角色 |
属性 + 规则 = 权限 动态计算 (Who, When, Where) |
| 优点 | 模型极简,开发速度快,适合初期 MVP | 结构清晰,复用性高,符合企业组织架构,维护成本低 | 极度灵活,支持细粒度控制 (如:只能在工作日访问) |
| 缺点 | 用户量大时维护工作呈指数级增长,极易出错 | 角色爆炸:若特例过多,可能导致定义成百上千个角色 | 开发复杂度极高,规则引擎难设计,有一定的性能消耗 |
| 适用场景 | 个人博客、小型内部工具 | 中大型后台系统、SaaS 平台 (行业标准) | 银行风控、AWS IAM、国家安全级系统 |
总结来说,在后台系统的场景下,RBAC 模型在灵活性(对比ACL)和复杂性(对比ABAC)上取得了一个很好的平衡
RBAC 概念理解
RBAC 权限模型,全称 Role-Based Access Control,基于角色的权限访问控制
模型有三要素:
- 用户(User):系统主体,即操作系统的具体人员或账号
- 角色(Role):角色是一组权限的集合,代表了用户在组织中的职能或身份
- 权限(Permission):用户可以对系统资源进行的访问或操作能力
RBAC 的设计是将角色绑定权限,用户绑定角色,从而实现权限控制
![]()
并且,它们之间的逻辑关系通常是多对多的:
用户 - 角色 (User-Role): 一个用户可以拥有多个角色(例如:某人既是“项目经理”又是“技术委员会成员”)
角色 - 权限(Role-Permission): 一个角色包含多个权限(例如:“人事经理”角色拥有“查看员工”、“编辑薪资”等权限)
主导权限控制的前端、后端方案
市面上这些开源 Admin 的权限控制中,存在两种主要的权限主导方案:前端主导的权限方案和后端主导的权限方案
前端主导的权限方案
前端主导的权限方案,一个主要的特征是菜单数据由前端维护,而不是存在数据库中
后端只需要在登录后给到用户信息,这个信息中会包含用户的角色,根据这个角色信息,前端可以筛选出具有权限的菜单、按钮
这种方案的主要逻辑放在前端,而不是后端数据库,所以安全性没保障,灵活性也较差,要更新权限,就需要改动前端代码并重新打包上线,无法支持“动态配置权限”
适合一些小型、简单系统
后端主导的权限方案
后端控制方案,即登录后在返回用户信息时,还会给到此用户对应的菜单数据和按钮权限码等
菜单数据、按钮权限码等都存在数据库,这样一来,安全性、灵活性更高,要更新权限数据或用户权限控制,提供相应接口即可修改
倒也不是说前端完全不用管菜单数据,而是前端只需要维护一些静态菜单数据,比如登录页、异常页(404、403...)
在企业级后台系统中,后端主导的权限方案是比较常用的,本文只介绍后端主导的权限方案
权限方案整体流程
在开始写代码之前,要清晰知道整体实现流程,我画了一张图来直观展示:
![]()
后台系统中的 RBAC 权限实战
权限菜单类型定义
首先,在前后端人员配合中,我们最好约定一套菜单数据的结构,比如:
import type { RouteMeta, RouteRecordRaw, RouteRecordRedirectOption } from 'vue-router';
import type { Component } from 'vue';
import type { DefineComponent } from 'vue';
import type { RouteType } from '#/type';
declare global {
export interface CustomRouteRecordRaw extends Omit<RouteRecordRaw, 'meta'> {
/**
* 路由地址
*/
path?: string;
/**
* 路由名称
*/
name?: string;
/**
* 重定向路径
*/
redirect?: RouteRecordRedirectOption;
/**
* 组件
*/
component?: Component | DefineComponent | (() => Promise<unknown>);
/**
* 子路由信息
*/
children?: CustomRouteRecordRaw[];
/**
* 路由类型
*/
type?: RouteType;
/**
* 元信息
*/
meta: {
/**
* 菜单标题
*/
title: string;
/**
* 菜单图标
*/
menuIcon?: string;
/**
* 排序
*/
sort?: number;
/**
* 是否在侧边栏菜单中隐藏
* @default false
*/
hideMenu?: boolean;
/**
* 是否在面包屑中隐藏
* @default false
*/
hideBreadcrumb?: boolean;
/**
* 当只有一个子菜单时,是否隐藏父级菜单直接显示子菜单内容
* @default false
*/
hideParentIfSingleChild?: boolean;
};
}
/**
* 后端返回的权限路由类型定义
*/
export type PermissionRoute = Omit<CustomRouteRecordRaw, 'component' | 'children' | 'type'> & {
/**
* 路由ID
*/
id?: number;
/**
* 路由父ID
*/
parentId?: number;
/**
* 组件路径(后端返回时为字符串,前端处理后为组件)
*/
component: string;
/**
* 子路由信息
*/
children?: PermissionRoute[];
/**
* 路由类型
*/
type: RouteType;
};
}
在 router.d.ts 找到类型文件
以上面的类型定义为例,我们约定 PermissionRoute 类型是后端返回的权限路由类型:
我这里使用 ApiFox 来 Mock 权限路由数据,数据是这样的:
![]()
从登录页到路由守卫
权限方案的第一步,是登录并拿到用户信息
假设我们现在用 Element Plus 搭建起了一个登录页面,当用户点击登录时,我们需要做这几件事:
- 调用登录接口,将账号、密码发送给后端进行验证,验证通过则返回 JWT 信息
- 将返回的 JWT 信息保存到本地,后续每次请求都携带 Token 来识别用户身份并决定你能拿到的权限路由数据
- 触发路由守卫拦截
![]()
在 account-login.vue 找到全部代码
基本 Vue Router 配置
登录完成后,我们就可以触发路由守卫了,但在写路由守卫之前,我们先来配置一下基本的 Vue Router
在整个权限系统中,我们将路由数据分为两种:
- 静态路由:系统固定的路由,比如登录页、异常页(404、403...)
- 动态路由:由后端接口返回的用户角色对应的菜单路由数据
静态路由是直接由前端定义,不会从后端接口返回、不会根据用户角色动态变化,所以这部分路由我们直接写好然后注册到 Vue Router 中即可
Vue Router 配置:
import { createRouter, createWebHashHistory } from 'vue-router';
import type { RouteRecordRaw } from 'vue-router';
import type { App } from 'vue';
import type { ImportGlobRoutes } from './typing';
import { extractRoutes } from './helpers';
import { afterEachGuard, beforeEachGuard } from './guards';
/** 静态路由 */
const staticRoutes = extractRoutes(
import.meta.glob<ImportGlobRoutes>(['./modules/constant-routes/**/*.ts'], {
eager: true,
}),
);
/** 系统路由 */
const systemRoutes = extractRoutes(
import.meta.glob<ImportGlobRoutes>(['./modules/system-routes/**/*.ts'], {
eager: true,
}),
);
const router = createRouter({
history: createWebHashHistory(),
routes: [...staticRoutes, ...systemRoutes] as RouteRecordRaw[],
strict: true,
scrollBehavior: () => ({ left: 0, top: 0 }),
});
beforeEachGuard(router);
afterEachGuard(router);
/** 初始化路由 */
function initRouter(app: App<Element>) {
app.use(router);
}
export { router, initRouter, staticRoutes };
图中的静态路由和系统路由是同一类路由数据,即静态路由
这个配置文件可以在 router/index.ts 找到
这个基本的 Vue Router 配置,做了这么几件事:
- 导入
modules文件夹下的静态路由进行注册 - 路由初始化配置
initRouter,在main.ts中调用 - 注册全局前置守卫
beforeEach、全局后置守卫afterEach
我们实现动态路由注册的逻辑就写在 beforeEach 中
值得一提的是,使用了 import.meta.glob 来动态导入指定路径下的文件模块,这是 Vite 提供的一种导入方式,参考:Vite Glob 导入
路由守卫与动态注册
路由守卫是 Vue Router 提供的一种机制,主要用来通过跳转或取消的方式守卫导航:Vue Router 路由守卫
重头戏在全局前置守卫 router.beforeEach 中实现,来看看我们做哪些事:
import { ROUTE_NAMES } from '../config';
import type { RouteRecordNameGeneric, RouteRecordRaw, Router } from 'vue-router';
import { getLocalAccessToken } from '@/utils/permission';
import { userService } from '@/services/api';
import { nprogress } from './helpers';
import { storeToRefs } from 'pinia';
/** 登录认证页面:账号登录页、短信登录页、二维码登录页、忘记密码页、注册页... */
const authPages: RouteRecordNameGeneric[] = [
ROUTE_NAMES.AUTH,
ROUTE_NAMES.ACCOUNT_LOGIN,
ROUTE_NAMES.SMS_LOGIN,
ROUTE_NAMES.QR_LOGIN,
ROUTE_NAMES.FORGOT_PASSWORD,
ROUTE_NAMES.REGISTER,
];
/** 页面白名单:不需要登录也能访问的页面 */
const pageWhiteList: RouteRecordNameGeneric[] = [...authPages];
export function beforeEachGuard(router: Router) {
router.beforeEach(async (to) => {
/** 进度条:开始 */
nprogress.start();
const { name: RouteName } = to;
const userStore = useUserStore();
const { getAccessToken, getRoutesAddStatus, registerRoutes } = storeToRefs(userStore);
const { setRoutesAddStatus, setUserInfo, logout } = userStore;
/** 访问令牌 */
const accessToken = getAccessToken.value || getLocalAccessToken();
// 1.用户未登录(无 Token)
if (!accessToken) {
const isWhitePage = pageWhiteList.includes(RouteName);
// 1.1 未登录,如果访问的是白名单中的页面,直接放行
if (isWhitePage) return true;
nprogress.done();
// 1.2 未登录又不在白名单,则拦截并重定向到登录页
return { name: ROUTE_NAMES.ACCOUNT_LOGIN };
}
// 如果已登录用户试图访问登录页,避免重复登录,要强制重定向到首页
if (authPages.includes(RouteName)) {
nprogress.done();
return { name: ROUTE_NAMES.ROOT };
}
// 判断是否需要动态加载路由的操作
if (!getRoutesAddStatus.value) {
// isRoutesAdded 默认为 false(未持久化),在已经动态注册过时会设置为true,在页面刷新时会重置为 false
try {
// 1.拉取用户信息
const userInfo = await userService.getUserInfo();
// 2.将用户信息存入 Store
setUserInfo(userInfo);
// 3.动态注册路由,registerRoutes 是处理后的路由表
registerRoutes.value.forEach((route) => {
router.addRoute(route as unknown as RouteRecordRaw);
});
// 4.标记路由已添加
setRoutesAddStatus(true);
// 5.中断当前导航,重新进入守卫
return { ...to, replace: true };
} catch (error) {
// 获取用户信息失败(如 Token 过期失效、网络异常)
logout();
nprogress.done();
// 重定向回登录页,让用户重新登录
return { name: ROUTE_NAMES.ACCOUNT_LOGIN };
}
}
return true;
});
}
在 before-each-guard.ts 找到全部代码
上面的代码已经给出了很详细的注释,从整体角度来讲,我们做了两件事:
- 处理一些情况,比如用户未登录、登录后访问登录页、白名单等情况
- 拉取用户信息,动态注册路由
![]()
在路由守卫中“拉取用户信息”,一般来说,除了返回用户本身的信息外,还会给到权限路由信息、权限码信息,这里的数据结构可以跟后端进行约定
![]()
比如在 vue-clean-admin 中,返回的数据结构是这样的:
在 ApiFox 文档可以找到用户接口说明:ApiFox 文档 - 用户信息
![]()
后端路由结构的转化
在通过“拉取用户信息”拿到路由数据后,并不是直接注册到 Vue Router,而是需要进行处理转化,才能符合 Vue Router 定义的路由表结构,registerRoutes 就是处理后的路由表,处理后的类型定义可以参考 CustomRouteRecordRaw
处理什么内容呢?
比如,接口拿到的路由数据字段 component 是一个字符串路径,这是一个映射路径,映射到前端项目下的真实组件路径
![]()
实现路由结构转换的代码,我写在了 router/helpers.ts,最主要逻辑是 generateRoutes 函数:
/**
* 生成符合 Vue Router 定义的路由表
* @param routes 未转化的路由数据
* @returns 符合结构的路由表
*/
export function generateRoutes(routes: PermissionRoute[]): CustomRouteRecordRaw[] {
if (!routes.length) return [];
return routes.map((route) => {
const { path, name, redirect, type, meta } = route;
const baseRoute: Omit<CustomRouteRecordRaw, 'children'> = {
path,
name,
redirect,
type,
component: loadComponent(route),
meta: {
...meta,
// 是否在侧边栏菜单中隐藏
hideMenu: route.meta?.hideMenu || false,
// 是否在面包屑中隐藏
hideBreadcrumb: route.meta?.hideBreadcrumb || false,
// 当只有一个子菜单时,是否隐藏父级菜单直接显示子菜单内容
hideParentIfSingleChild: route.meta?.hideParentIfSingleChild || false,
},
};
// 是目录数据,设置重定向路径
if (type === PermissionRouteTypeEnum.DIR) {
baseRoute.redirect = redirect || getRedirectPath(route);
}
// 递归处理子路由
const processedChildren =
route.children && route.children.length ? generateRoutes(route.children) : undefined;
return {
...baseRoute,
...(processedChildren ? { children: processedChildren } : {}),
};
});
}
经过 generateRoutes 处理的路由表,再 addRoute 到 Vue Router 中
侧边栏菜单的渲染
当路由守卫的逻辑走完后,就进入到首页,在首页中,我们会根据路由表(转换过的)来渲染侧边栏菜单
侧边栏菜单是拿 Element Plus 的 el-menu 组件来做的,我们封装了一个菜单组件,除了渲染路由数据外,也更方便自定义配置菜单属性(meta)来实现一些功能
封装不难,就是拿处理后的路由表循环渲染 menu-item,根据 meta 配置项来实现"是否隐藏菜单","当只有一个子菜单时,是否隐藏父级菜单直接显示子菜单内容"等
![]()
菜单组件的封装代码在 basic-menu 文件夹中
到这一步,已经实现了动态权限路由及侧边栏菜单的渲染,但还不算完
因为我们还不能自由定义菜单信息、角色信息、用户信息来实现权限控制,在下一篇文章来聊聊管理模块
了解更多
系列专栏地址:GitHub 博客 | 掘金专栏 | 思否专栏
实战项目:vue-clean-admin
交流讨论
文章如有错误或需要改进之处,欢迎指正
Kimi 的一体化,Manus 的分层
一、
前天,Kimi 突然发布了旗舰模型 K2.5,事先没有一点风声。
![]()
在国内,Kimi 是比较低调的公司,关注度相对不高。但是,它的产品并不弱。
半年前,K2 模型一鸣惊人,得到了很高的评价,公认属于全球第一梯队。所以,新版本 K2.5 出来以后,立刻上了新闻,在黑客新闻、推特等平台都是热门话题。
著名开发者 Simon Willion 当天就写了详细介绍。
![]()
但是,这一次真正有趣的地方,不是模型本身,而是 Kimi 做了另一件事。
二、
这次的 K2.5 很强,各方面比 K2 都有进步。官方给出的评测跑分,基本都是全球前三位,甚至第一名(见发布说明)。
根据 LMArena(现改名为 arena.ai)的榜单,Kimi K2.5 的编码能力,是所有开源模型的第一,在总榜上仅次于 Claude 和 Gemini(下图)。
![]()
但是,最大的亮点其实不是模型,而是 Kimi 同时发布了一个基于这个模型的 Agent(智能体)。
也就是说,这次其实同时发布了两样东西:K2.5 模型和 K2.5 Agent。K2.5 是底层模型,K2.5 Agent 则是面向最终用户的一个网络应用。
![]()
我的印象中,这好像是第一次,大模型公司这么干。以前发布的都是模型本身,没见过谁把模型和 Agent 绑在一起发布的。
这么说吧,Kimi 走上了一体化的道路。
三、
大家知道,大模型是底层的处理引擎,Agent 是面向用户的上层应用。
它们的关系无非就是两种:分层开发和一体化。前者是大模型跟 agent 分开,各自开发;后者是做成一个整体一起开发。
前不久,被 Meta 公司高价收购的 Manus,就是分层开发的最好例子。
![]()
Manus 使用的模型是 Anthropic 公司的 Claude,它自己在其上开发一个独立的智能体,最终被收购。
它的成功鼓舞了许多人投入智能体的开发。因为模型的投入太大,不是谁都能搞的,而智能体的投入比较少,再小的开发者都能搞。
Kimi 这一次的尝试,则是朝着另一个方向迈出了一大步,把大模型和 Agent 合在了一起。毕竟,大模型公司自己来做这件事更方便,更有利于扩大市场份额、争取用户。
很难说,这两种做法哪一种更好。就像手机一样,苹果和安卓的外部应用,可以更好地满足用户需求,而自带的内置应用则能充分跟操作系统融合,用起来更顺滑。
四、
模型的测试已经很多了,下面我就来测一下,这次发布的 K2.5 Agent。
看得出来,Kimi 对 Agent 很重视,倾注了很大心血,发布说明的大部分篇幅介绍的都是 Agent 的功能。
其中有几个功能是比较常规的:
(1)Kimi Office Agent:专家级的 Word、Excel、PowerPoint 文件生成。
(2)Kimi Code:对标 Claude Code 的命令行工具,专门用于代码生成。
(3)长程操作:一次性完成最多1500步的操作,这显然在对标以多步骤操作闻名的 Manus。
我比较在意的是下面两个全新的功能,都是第一次看到,其他公司好像没有提过。
(4)视觉编程:通过模型的视觉能力,理解图片和视频,进而用于编程。只要上传设计稿和网页视频,就能把网页生成出来。
(5)蜂群功能(agent swarm):遇到复杂任务时,Agent 内部会自动调用最多100个 Agent,组成一个集群,并发执行任务,比如并发下载、并发生成等。
碍于篇幅,我就简单说一下,我的"视觉编程"测试结果。
五、
首先,打开 Kimi 官网,K2.5 已经上线了,能够直接使用(下图)。
![]()
注意,模型要切换到"智能体模式" K2.5 Agent。
![]()
我的第一个测试是动效生成,即上传一段动画效果的视频,让它来生成。下面是原始动画,是用 Lottie 库做的。
![]()
上传后,在网页输入提示词:
视频里面的动画效果,一模一样地在网页上还原出来
模型很快推断出,这是橘猫玩球的动画。然后,居然把动画每一帧都截图了,进行还原。
![]()
最终,它使用 Python 生成了 SVG 动画文件。
![]()
尾巴、眼球、小球滚动的动画效果,都正确还原出来了。可惜的是,主体的小猫是由多个 SVG 形状拼接而成,没法做到很像。
大家可以去这个网址,查看最终效果和网页代码。
六、
第二个测试是上传一段网站视频,让模型生成网站。
我在 B 站上,随便找了一个设计师网站的视频。
大家可以去访问这个网站,看看原始网页的效果。
![]()
我把视频上传到模型,然后要求"把视频里面的网站还原出来"。
生成的结果(下图)完全超出了我的预期,还原度非常高,几乎可以直接上线。
![]()
![]()
大家可以去这个网址,查看生成的结果。
七、
经过简单测试,我的评价是,Kimi K2.5 Agent 的"视觉编程"不是噱头,确实有视觉理解能力,完全能够生成可用的结果。
目前看上去,Kimi 这次"模型 + Agent"的一体化尝试是成功的。一方面,强大的 Agent 发挥出了底层模型的能力,方便了用户使用;另一方面,模型通过 Agent 扩展了各种用例,可以吸引更多的用户,有利于自身的推广。
最后,在当下国际竞争的格局之中,一体化还有一个额外的优势。
Manus 依赖的是美国模型,最终不得不选择在海外注册公司,而 Kimi 的底层模型是自研的,而且开源,完全不存在卡脖子的风险。
(完)
文档信息
- 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)
- 发表日期: 2026年1月29日
线程池的类型和原理
参考文章:
Java线程池的四种创建方式 - 绝不妥协绝不低头 - 博客园 (cnblogs.com)
JAVA线程池原理详解一 - 冬瓜蔡 - 博客园 (cnblogs.com)
1.线程池创建之使用线程池工厂
1.1.定长线程池
Executors.newFixedThreadPool(2),核心线程和线程总数一样,使用LinkedBlockingQueue队列(链表,容量Integer.Max)
//1.步骤一
ExecutorService newFixedThreadPool=Executors.newFixedThreadPool(2);
for(int j=0;j<4;j++){
final int index=j;
newFixedThreadPool.execute(new MyRunnable(index));
}
//2.步骤二 LinkedBlockingQueue队列的容量为Integer.Max
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
//3.步骤三 Integer.MAX_VALUE
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
Executors.defaultThreadFactory(), defaultHandler);
}
//4.步骤四 defaultHandler实现了
private static final RejectedExecutionHandler defaultHandler = new AbortPolicy();
public static class AbortPolicy implements RejectedExecutionHandler {
/**
* Creates an {@code AbortPolicy}.
*/
public AbortPolicy() { }
/**
* Always throws RejectedExecutionException.
*
* @param r the runnable task requested to be executed
* @param e the executor attempting to execute this task
* @throws RejectedExecutionException always
*/
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
throw new RejectedExecutionException("Task " + r.toString() +
" rejected from " +
e.toString());
}
}
1.2.周期性程池,支持周期性或者定时任务。
Executors.newScheduleThreadPool 核心线程是固定的,线程总数是Integer.Max_value,使用DelayedWorkQueue队列(最小堆,容量16)
//1.步骤一
ScheduledExecutorService newScheduleThreadPool= Executors.newScheduledThreadPool(2);
for(int k=0;k<4;k++){
final int index=k;
//执行结果:延迟三秒之后执行,除了延迟执行之外和newFixedThreadPool基本相同
newScheduleThreadPool.schedule(new MyRunnable(index),3, TimeUnit.SECONDS);
}
// 2.步骤二
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
return new ScheduledThreadPoolExecutor(corePoolSize);
}
//3.步骤三
public ScheduledThreadPoolExecutor(int corePoolSize) {
super(corePoolSize, Integer.MAX_VALUE,
DEFAULT_KEEPALIVE_MILLIS, MILLISECONDS,
new DelayedWorkQueue());
}
//步骤四
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
Executors.defaultThreadFactory(), defaultHandler);
}
1.3.可缓存线程池
Executors.newCachedThreadPool 核心线程数为0,线程总数为Integer.Max,使用SynchronousQueue同步队列(双向链表,容量0) 特点:SynchronousQueue没有容量,可以确保任务立即被处理,而不是排队等待。
//1.步骤一
ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();
for(int i=0;i<4;i++) {
final int index=i;
newCachedThreadPool.execute(new MyRunnable(index));
}
//2.步骤二
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
//3.步骤三
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
Executors.defaultThreadFactory(), defaultHandler);
}
1.4.单线程池
Executors.newSingleThreadExecutor 核心线程数和线程总数都是1,使用的LinkedBlockingQueue队列(链表实现,容量默认Integer.Max)
//1.步骤一
ExecutorService newSingleThreadExtutor=Executors.newSingleThreadExecutor();
for(int l=0;l<4;l++){
final int index=l;
//执行结果:只存在一个线程,顺序执行
newSingleThreadExtutor.execute(new MyRunnable(index));
}
//2.步骤二
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
//步骤三
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
Executors.defaultThreadFactory(), defaultHandler);
}
2.线程池创建之使用ThreadPoolExecutor构造方法自定义线程池
- int corePoolSize 核心线程数
- int maximumPoolSize 线程总数
- long keepAliveTime 非核心空闲线程存活时间
- TimeUnit unit 非核心空闲线程存活时间单位
- BlockingQueue workQueue 任务队列
- ThreadFactory 线程工厂类 (工厂模式的方式创建线程)(接口,实现了newThread方法)【默认实现】
- RejectedExecutionHandler 拒绝策略 (接口,实现了RejectedExecution方法) 【默认实现】 #####2.1.自定义线程池 注意:如果想要自定线程工程,或者自定义拒绝策略都可以.
LinkedBlockingQueue queue=new LinkedBlockingQueue();
ThreadPoolExecutor theadPool=new ThreadPoolExecutor(
2,4,10,
TimeUnit.SECONDS,queue,
Executors.defaultTheadFactory,
defaultHandler );
2.2.系统默认的线程工工厂和拒绝策略
//线程工厂,用于批量创建线程
public interface ThreadFactory {
Thread newThread(Runnable r);
}
public static ThreadFactory defaultThreadFactory() {
return new DefaultThreadFactory();
}
//拒绝策略,默认为抛出异常
public interface RejectedExecutionHandler {
void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}
private static final RejectedExecutionHandler defaultHandler =new AbortPolicy();
2.3.默认的线程池工厂实现类
private static class DefaultThreadFactory implements ThreadFactory {
private static final AtomicInteger poolNumber = new AtomicInteger(1);
private final ThreadGroup group;
private final AtomicInteger threadNumber = new AtomicInteger(1);
private final String namePrefix;
DefaultThreadFactory() {
SecurityManager s = System.getSecurityManager();
group = (s != null) ? s.getThreadGroup() :
Thread.currentThread().getThreadGroup();
namePrefix = "pool-" +
poolNumber.getAndIncrement() +
"-thread-";
}
public Thread newThread(Runnable r) {
Thread t = new Thread(group, r,
namePrefix + threadNumber.getAndIncrement(),
0);
if (t.isDaemon())
t.setDaemon(false);
if (t.getPriority() != Thread.NORM_PRIORITY)
t.setPriority(Thread.NORM_PRIORITY);
return t;
}
}
2.4.拒绝策略【A-C-D-D】 (在阻塞队列达到最大值,而且线程数也达到最大值了,线程池无法再处理新提交过来的任务,此时使用拒绝策略)
- AbortPolicy 默认策略,抛出异常,终止任务
public static class AbortPolicy implements RejectedExecutionHandler {
public AbortPolicy() { }
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
throw new RejectedExecutionException("Task " + r.toString() +
" rejected from " +
e.toString());
}
}
- CallerRunsPolicy 是使用当前提交任务的线程来执行任务
public static class CallerRunsPolicy implements RejectedExecutionHandler{
public CallerRunsPolicy(){ }
public void rejectedExecution(Runnable r , ThreadPoolExecutor e){
if(!e.shutdown()){
r.run();//当前提交任务的线程来执行任务
}
}
}
- DiscardPolicy 什么都不做,直接丢弃任务
public static class DiscardPolicy implements RejectExecutionHandler{
public DiscardPolicy(){}
public void rejectExecution(Runnable r, ThreadPoolExecutor e){ //do nothing
}
}
- DiscardOldestPolicy 丢弃最先放入队列的任务,然后将当前任务提交到线程池
public static class DiscardOldestPolicy implements RejectExecutionHandler{
public DiscardOldestPolicy(){}
public void rejectExecution(Runnable r, ThreadPoolExecutor e){
if(!e.isShutdown()){
e.getQueue().poll();
e.execute(r);
}
}
}
3.线程池源码解析
3.1.创建的线程池具体配置为:核心线程数量为5个;全部线程数量为10个;工作队列的长度为5。
3.2.我们通过queue.size()的方法来获取工作队列中的任务数。
3.3.线程池原理分析.
当向线程池提交一个任务时,首先判断当前正在工作的线程数是否>=核心线程数,如果小于核心线程数,那么创建新的线程,达到核心线程数量5个后,新的任务进来后不再创建新的线程,而是将任务加入工作队列,任务队列到达上线5个后,新的任务又会创建新的普通线程,直到达到线程池最大的线程数量10个,后面的任务则根据配置的饱和策略来处理。我们这里没有具体配置,使用的是默认的配置AbortPolicy:直接抛出异常。当然,为了达到我需要的效果,上述线程处理的任务都是利用休眠导致线程没有释放!!! #####3.4.线程池的核心线程会被回收吗? 默认情况下,线程池的核心线程是不会被回收的,即使他们处于空闲状态。这样可以避免频繁的创建线程,节省系统开销。当设置allowCoreThreadTimeCount(true),核心线程在空闲时超过keepAliveTime时,会被回收.
3.5.线程池源码分析
3.5.1.核心接口和类
![]()
Executor
Executor接口只是定义了一个基础的execute方法.
public interface Executor{
void execute(Runnable);
}
ExecutorService
ExecutorService接口定义了线程池的一些常用操作.
public interface ExecutorService extends Executor{
// 终止线程池,不再接受新任务,会将任务队列的任务执行完成
void shutdown();
//立即终止线程池,任务队列的任务不在执行,返回未执行任务集合.
List<Runnable> shutdownNow();
//判断线程池是否停掉,只要线程池不是RUNNING状态,都会返回true
boolean isShutdown();
//判断线程池是否完成终止,状态是TERMINATED
boolean isTerminated();
//提交Runnable任务,返回Future,Future的get方法返回值就是result参数.//get方法会阻塞当前线程
<T> Future<T> submit(Runnable task,T result);
//提交Rennable任务,返回Future,Future的get方法返回值就是null.//get方法会阻塞当前线程
Future<?> submit(Runnable task);
}
AbstractExecutorService
AbstractExecutorService是一个抽象类,实现了接口的一些方法,未实现的方法继续留给子类实现.
public abstract class AbstractExecutorService implement ExecutorService{
//提交Runnalbe任务,返回Future,Future的get方法返回值是null//get方法会阻塞当前线程
public Future<?> submit(Runnable task) {
if (task == null) {
throw new NullPointerException();
}
RunnableFuture<Void> ftask = newTaskFor(task, null);
execute(ftask);//真正执行任务的是子类实现的execute方法
return ftask;
}
//提交Runnable任务,返回Future,Future的get方法返回值是result参数.//get方法会阻塞当前线程
public <T> Future<T> submit(Runnable task, T result) {
if (task == null) throw new NullPointerException();
RunnableFuture<T> ftask = newTaskFor(task, result);
execute(ftask);//真正执行任务的是子类实现的execute方法
return ftask;
}
}
任务执行
public class ThreadPoolExecutor extends AbstractExecutorService {
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
int c = ctl.get();
//1.判断工作线程数是否核心线程数
if (workerCountOf(c) < corePoolSize) {
//2.添加工作线程执行任务
if (addWorker(command, true))
return;
c = ctl.get();
}
//3.线程池为Running状态且任务添加到阻塞队列成功
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);//添加工作线程执行任务
}
//4.队列满了,达到最大线程数之后,就会走拒绝策略,addWorker()返回值就是false
else if (!addWorker(command, false))
reject(command);
}
}
添加工作线程
- 第一阶段:设置ctl的线程数+1,主要是判断线程池状态以及线程数是否超限,然后对ctrl的线程数加1。
- 第二阶段:创建一个线程并启动执行,将创建的工作线程类Worker放入线程池工作线程集合里并启动,另外,如果出现异常情况,就走finally{},移除工作线程Worker,并执行ctl的线程数减1.
private final HashSet<Worker> workers = new HashSet<>(); //线程池工作线程集合
private boolean addWorker(Runnable firstTask, boolean core) {
retry:
for (int c = ctl.get();;) {
for (;;) {
//如果是核心线程 ,线程池中工作线程总数>=corePoolSize,返回false
//如果是非核心线程 ,线程池中工作线程总数>=maximumPoolSize,返回false
if (workerCountOf(c)>= ((core ? corePoolSize : maximumPoolSize) & COUNT_MASK))
return false;
//设置ctl的线程数+1,跳出整个for循环
if (compareAndIncrementWorkerCount(c))
break retry; //跳出retry for循环
}
}
//以上只是将ctl的线程数+1了,以下是真正的创建一个工作线程
boolean workerStarted = false;
boolean workerAdded = false;
Worker w = null;
try {
//Worker构造方法中会使用ThreadFactory创建一个新线程
w = new Worker(firstTask);
final Thread t = w.thread;
if (t != null) {
// 创建工作线程时,需要加锁,防止其他线程并发操作。
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
int c = ctl.get();
if (isRunning(c) ||
(runStateLessThan(c, STOP) && firstTask == null)) {
if (t.getState() != Thread.State.NEW)
throw new IllegalThreadStateException();
workers.add(w); //并将Worker放入工作线程集合里
workerAdded = true;
int s = workers.size();
// 这里就是标记线程池里创建线程的最大值,这个值最大也不会超过maximumPoolSize。
if (s > largestPoolSize)
largestPoolSize = s;
}
} finally {
mainLock.unlock();//释放锁
}
if (workerAdded) {
t.start(); //启动工作线程>>Worker.run()>>runWorker()>>firstTask.run()
workerStarted = true;
}
}
} finally {
if (! workerStarted)
addWorkerFailed(w);
}
return workerStarted;
}
//如果添加子线程工作流程失败,
private void addWorkerFailed(Worker w) {
final ReentrantLock mainLock = this.mainLock;
// 涉及工作线程的相关操作都需要加锁
mainLock.lock();
try {
// 从工作线程集合里移除worker
if (w != null)
workers.remove(w);
// cas操作ctl线程数减1
decrementWorkerCount();
// 判断是否需要终止线程池
tryTerminate();
} finally {
mainLock.unlock();
}
}
工作线程类Worker
Worker类是具体的工作线程类,持有一个Thread线程和一个Runnable任务实例,并实现了Runnable接口.
private final class Worker extends AbstractQueuedSynchronizer implements Runnable {
// Worker的Thread属性,其实干活的就是这个线程
final Thread thread;
// 任务
Runnable firstTask;
// 线程已经执行完成的任务总数
volatile long completedTasks;
// 构造方法
Worker(Runnable firstTask) {
setState(-1); // inhibit interrupts until runWorker
this.firstTask = firstTask;
// 线程执行时调用的就是Worker.run() >>runWorker()>>firstTask.run()
//使用线程工程批量创建线程.
this.thread = getThreadFactory().newThread(this);
}
// run方法执行任务,调用的是外部ThreadPoolExecutor的runWorker方法
public void run() {
runWorker(this);
}
}
执行任务runWorker
final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
w.unlock(); // allow interrupts
boolean completedAbruptly = true;
try {
while (task != null || (task = getTask()) != null) {
w.lock();
if ((runStateAtLeast(ctl.get(), STOP) ||
(Thread.interrupted() &&
runStateAtLeast(ctl.get(), STOP))) &&
!wt.isInterrupted())
wt.interrupt();
try {
beforeExecute(wt, task);
try {
task.run(); //执行任务
afterExecute(task, null);
} catch (Throwable ex) {
afterExecute(task, ex);
throw ex;
}
} finally {
task = null;
w.completedTasks++;
w.unlock();
}
}
completedAbruptly = false;
} finally {
processWorkerExit(w, completedAbruptly);
}
}
浅谈常用的设计模式
######参考文章:
1.单例模式
(1):懒汉(线程不安全,在多线程中无法正常工作)
public class SingleTon {
private SingleTon(){}
private static SingleTon instance;
public static SingleTon getInstance(){
if(instance==null){
instance=new SingleTon();
}
return instance;
}
}
(2):懒汉(线程··安全 synchronized同步静态方法,实际锁住了class类,效率低下,无论instance有没有被实例化,每次调用该方法都要检查锁是否释放,耗费资源,所以(3)的这种双重校验锁的方式就是优化方式)
public class SingleTon {
private SingleTon(){}
private static SingleTon instance;
//synchronized给静态方法加锁,实际上锁住的是SingleTon 这个class类,意味着其他现在在执行这个
// getInstance()静态方法时,必须等待class类锁被释放。
public static synchronized SingleTon getInstance(){
if(instance==null){
instance=new SingleTon();
}
return instance;
}
}
(3):懒汉(线程··安全 synchronized同步代码块,双重校验锁,synchronized关键字内外都加了一层 if 条件判断)
public class SingleTon {
private SingleTon(){}
private volatile static SingleTon instance;
public static SingleTon getInstance(){
if(instance==null){
synchronized (SingleTon.class){ //和静态方法一样,也是锁住的class类
if(instance==null){
//1.在堆上分配内存空间存储SingleTon对象>>2.实例化instance引用对象>>3.内存空间返回的地
//址赋 值给instance引用.
instance=new SingleTon(); //volatile修饰Instance,禁止指令重排,防止报错
}
}
}
return instance;
}
}
(4).懒汉(静态内部类,这种方式的好处是即使SingleTon被装载了,instance也不会立马实例化)
public class SingleTon {
private SingleTon(){}
private static class SingleTonHolder {
private static SingleTon instance=new SingleTon();
}
public static SingleTon getInstance(){
return SingleTonHolder.instance;
}
}
(5).饿汉(一旦SingleTon 被装载了,instance会立马实例化)
public class SingleTon {
private SingleTon(){}
private static SingleTon instance=new SingleTon();
public static SingleTon getInstance(){
return instance;
}
}
(6)饿汉(静态代码块)
public class SingleTonSeven {
private SingleTonSeven() {
}
private static SingleTonSeven singleTonSeven;
static {
singleTonSeven=new SingleTonSeven();
}
public static SingleTonSeven getInstance(){
return singleTonSeven;
}
}
(7)枚举
// 单例
public enum SingleTonSix {
Instance;
SingleTonSix() {
System.out.println("init");
}
public void print(){
System.out.println("ffffffffffff");
}
}
//测试
public static void main(String[] args) {
// 系统内存中该类只存在一个对象,节省了系统资源,对于一些需要频繁创建销毁的对象,使用单例模式可以提高系统能。
SingleTonSix singleTonSix1=SingleTonSix.Instance;
singleTonSix1.print();
SingleTonSix singleTonSix2=SingleTonSix.Instance;
singleTonSix2.print();
SingleTonSix singleTonSix3=SingleTonSix.Instance;
singleTonSix3.print();
SingleTonSix singleTonSix4=SingleTonSix.Instance;
singleTonSix4.print();
}
执行效率上:
饿汉式没有加任何的锁,因此执行效率比较高。
懒汉式一般使用都会加同步锁,效率比饿汉式差。
性能上:
饿汉式在类加载的时候就初始化,不管你是否使用,它都实例化了,
所以会占据空间,浪费内存。
懒汉式什么时候需要什么时候实例化,相对来说不浪费内存。
2.工厂模式
(1).普通工厂模式
public interface Sender {
public void send();
}
public class MailSender implements Sender{
@Override
public void send() {
System.out.println("this is mail sender");
}
}
public class SmsSender implements Sender {
@Override
public void send() {
System.out.println("this is sms sender");
}
}
public class SendFactoryOne {
public Sender produce(String type){
if(type.equals("mail")){
return new MailSender();
}else if(type.equals("sms")){
return new SmsSender();
}else{
return null;
}
}
}
(2).工厂方法模式
public class SendFactoryTwo {
public Sender produceMail(){
return new MailSender();
}
public Sender produceSms(){
return new SmsSender();
}
}
(3).静态工厂方法模式
public class SendFactoryThree {
public static Sender produceMail(){
return new MailSender();
}
public static Sender produceSms(){
return new SmsSender();
}
}
(4).抽象工程模式
注意:工程方法模式有个问题,类的创建和扩展必须修改工厂类,这违背了闭包原则,所有用到抽象工厂模式,创建多个工厂类,这样一来,直接增加工厂类就可以了,不需要修改之前的代码。
public interface Provider {
public Sender produce();
}
public class SendMailFactory implements Provider {
@Override
public Sender produce() {
return new MailSender();
}
}
public class SendSmsFactory implements Provider {
@Override
public Sender produce() {
return new SmsSender();
}
}
public class MainTest {
public static void main(String[] args){
SendFactoryOne sendFactoryOne=new SendFactoryOne();
sendFactoryOne.produce("mail");
sendFactoryOne.produce("sms");
SendFactoryTwo sendFactoryTwo=new SendFactoryTwo();
sendFactoryTwo.produceMail();
sendFactoryTwo.produceSms();
SendFactoryThree.produceMail();
SendFactoryThree.produceSms();
Provider provider=new SendMailFactory();
Sender sender=provider.produce();
sender.send();
Provider provider1=new SendSmsFactory();
Sender sender1=provider1.produce();
sender1.send();
}
}
3.观察者模式
类似于邮件订阅,当我们浏览一些博客或者wiki时,当你订阅了改文章,如果后续有更新,会及时通知你,是一种一对多的关系。(www.cnblogs.com/luohanguo/p…
(1) 、定义一个抽象观察者接口
public interface Observer {
public void update();
}
(2)、定义一个抽象被观察者接口
public interface Observerable {
public void registerObserver(Observer observer);
public void removeObserver(Observer observer);
public void notifyObservers();
}
(3).定义被观察者,实现了Observerable接口,对Observerable接口的三个方法进行了具体实现,同时有一个List集合,用以保存注册的观察者,等需要通知观察者时,遍历该集合即可,通知新增一个operation()用于通知所有的观察者。
public class WeChatServer implements Observerable {
List<Observer> list=new ArrayList<>();//面向接口编程
@Override
public void registerObserver(Observer observer) {
list.add(observer);
}
@Override
public void removeObserver(Observer observer) {
if(!list.isEmpty()){
list.remove(observer);
}
}
@Override
public void notifyObservers() {
for(int i=0;i<list.size();i++){
list.get(i).update();
}
}
public void operation() {
notifyObservers();
}
}
(4)、定义具体观察者,微信公众号的具体观察者为用户User1,User2
public class User1 implements Observer {
@Override
public void update() {
System.out.println(User1.class.toString() +"has received the push message!");
}
}
public class User2 implements Observer {
@Override
public void update() {
System.out.println(User2.class.toString() +"has received the push message!");
}
}
(5)、编写一个测试类
public class MainTest {
public static void main(String[] args){
WeChatServer server=new WeChatServer();
User1 user1=new User1();
User2 user2=new User2();
server.registerObserver(user1);
server.registerObserver(user2);
server.operation();
}
}
![]()
4.装饰模式
装饰模式就是给一个对象增加一些新的功能,而且是动态的,要求装饰对象和被装饰对象实现统一接口或者继承同一个父类,装饰对象持有被装饰对象的实例。
(1).Food类,让其他所有食物都来继承这个类
public class Food {
private String food_name;
public Food(){
}
public Food(String food_name) {
this.food_name = food_name;
}
public String make(){
return food_name;
}
}
(2).Bread类,Cream类,Vegetable类
public class Bread extends Food{
private Food basic_food;
public Bread(Food basic_food){
this.basic_food=basic_food;
}
@Override
public String make() {
return basic_food.make()+"+面包";
}
}
public class Cream extends Food {
private Food basic_food;
public Cream(Food basic_food) {
this.basic_food = basic_food;
}
@Override
public String make() {
return basic_food.make()+"+奶油";
}
}
public class Vegetable extends Food {
private Food basic_food;
public Vegetable(Food basic_food) {
this.basic_food = basic_food;
}
@Override
public String make() {
return basic_food.make()+"+蔬菜";
}
}
(3).编写一个测试类
public class MainTest {
public static void main(String[] args){
Food food=new Food("香肠");
Bread bread=new Bread(food);
Cream cream=new Cream(bread);
Vegetable vegetable=new Vegetable(cream);
System.out.print("运行结果:"+vegetable.make()+"\n");
}
}
![]()
5.适配器模式
(1).类适配器模式(通过继承来实现适配器功能)
我们以ps2与usb的转接为例: Ps2接口:
public interface Ps2 {
void isPs2();
}
Usb接口:
public interface Usb {
void isUsb();
}
Usb接口实现类:Usber
public class Usber implements Usb {
@Override
public void isUsb() {
}
}
适配器:AdapterOne
public class AdapterOne extends Usber implements Ps2{
@Override
public void isPs2() {
isUsb();
}
}
测试方法:
public static void main(String[] args){
//1.类适配,通过继承类适配
Ps2 p=new AdapterOne();
p.isPs2();
}
(2).对象适配器模式(通过组合来实现适配器的功能)
适配器:AdapterTwo
public class AdapterTwo implements Ps2{
private Usber usber;
public AdapterTwo(Usber usber) {
this.usber = usber;
}
@Override
public void isPs2() {
usber.isUsb();
}
}
测试方法:
public class MainTest {
public static void main(String[] args){
//2.对象适配,通过组合实现
Ps2 p=new AdapterTwo(new Usber());
p.isPs2();
}
}
注意:类适配和对象适配模式中所有的adapter均需要实现Ps2接口
(3).接口适配器模式(通过抽象类来实现)
目标接口A:
public interface A {
void a();
void b();
void c();
void d();
void e();
}
A的实现类:Adapter
public abstract class Adapter implements A {
@Override
public void a() {
}
@Override
public void b() {
}
@Override
public void c() {
}
@Override
public void d() {
}
@Override
public void e() {
}
}
继承自Adapter的MyAdapter:
public class MyAdapter extends Adapter {
@Override
public void a() {
super.a();
System.out.println("实现A方法");
}
@Override
public void b() {
super.b();
System.out.println("实现B方法");
}
}
测试方法:
public static void main(String[] args){
//3.接口适配,通过抽象类实现
A aRealize=new MyAdapter();
aRealize.a();
aRealize.b();
}
6.策略模式(一个人走楼梯上楼或者走电梯上楼)
这里以加减算法为例: (1).定义抽象策略角色接口:Strategy
public interface Strategy {
int calc(int num1,int num2);
}
(2).定义加法策略:AddStrategy
public class AddStrategy implements Strategy{
@Override
public int calc(int num1, int num2) {
return num1+num2;
}
}
(3).定义减法策略:SubStrategy
public class SubStrategy implements Strategy {
@Override
public int calc(int num1, int num2) {
return num1-num2;
}
}
(4).环境角色:Environment
public class Environment {
private Strategy strategy;
public Environment(Strategy strategy) {
this.strategy = strategy;
}
public int calc(int a,int b){
return strategy.calc(a,b);
}
}
测试方法:
public static void main(String[] args){
Strategy addStrage=new AddStrategy();
Environment environment=new Environment(addStrage);
int sum1=environment.calc(10,10);
System.out.println("Result1:"+sum1);
Strategy subStrage=new SubStrategy();
int sum2=subStrage.calc(20,10);
System.out.println("Result2:"+sum2);
}
7.Builder模式
public class Request {
private String name;
private String reason;
private String days;
private String groupLeaderInfo;
private String managerInfo;
private String departmentHeaderInfo;
private String customInfo;
public Request(Builder builder){
// super();
this.name=builder.name;
this.reason=builder.reason;
this.days=builder.days;
this.groupLeaderInfo=builder.groupLeaderInfo;
this.managerInfo=builder.managerInfo;
this.departmentHeaderInfo=builder.departmentHeaderInfo;
this.customInfo=builder.customInfo;
}
public static class Builder{
private String name;
private String reason;
private String days;
private String groupLeaderInfo;
private String managerInfo;
private String departmentHeaderInfo;
private String customInfo;
public Builder setName(String name) {
this.name = name;
return this;
}
public Builder setReason(String reason) {
this.reason = reason;
return this;
}
public Builder setDays(String days) {
this.days = days;
return this;
}
public Builder setGroupLeaderInfo(String groupLeaderInfo) {
this.groupLeaderInfo = groupLeaderInfo;
return this;
}
public Builder setManagerInfo(String managerInfo) {
this.managerInfo = managerInfo;
return this;
}
public Builder setDepartmentHeaderInfo(String departmentHeaderInfo) {
this.departmentHeaderInfo = departmentHeaderInfo;
return this;
}
public Builder setCustomInfo(String customInfo) {
this.customInfo = customInfo;
return this;
}
public Builder newRequest(Request request){
this.name=request.name;
this.days=request.days;
this.reason=request.reason;
if(request.getGroupLeaderInfo()!=null&&!request.getGroupLeaderInfo().equals("")){
this.groupLeaderInfo=request.groupLeaderInfo;
}
if(request.getManagerInfo()!=null&&!request.getManagerInfo().equals("")){
this.managerInfo=request.managerInfo;
}
if(request.getDepartmentHeaderInfo()!=null&&!request.getDepartmentHeaderInfo().equals("")){
this.departmentHeaderInfo=request.getDepartmentHeaderInfo();
}
return this;
}
public Request build(){
return new Request(this);
}
}
public String getName() {
return name;
}
public String getReason() {
return reason;
}
public String getDays() {
return days;
}
public String getGroupLeaderInfo() {
return groupLeaderInfo;
}
public String getManagerInfo() {
return managerInfo;
}
public String getDepartmentHeaderInfo() {
return departmentHeaderInfo;
}
public String getCustomInfo() {
return customInfo;
}
@Override
public String toString() {
return "Request{" +
"name='" + name + '\'' +
", reason='" + reason + '\'' +
", days='" + days + '\'' +
", groupLeaderInfo='" + groupLeaderInfo + '\'' +
", managerInfo='" + managerInfo + '\'' +
", departmentHeaderInfo='" + departmentHeaderInfo + '\'' +
", customInfo='" + customInfo + '\'' +
'}';
}
}
测试方法:
public static void main(String[] args){
Request request=new Request.Builder()
.setName("shuijian")
.setReason("GoHome")
.setDays("2days")
.build();
System.out.println(request.toString());
}
8.责任链模式
实例场景 在公司内部员工请假一般情况是这样的:员工在OA系统中提交一封请假邮件,该邮件会自动转发到你的直接上级领导邮箱里,如果你的请假的情况特殊的话,该邮件也会转发到你上级的上级的邮箱,根据请假的情况天数多少,系统会自动转发相应的责任人的邮箱。我们就以这样一种场景为例完成一个责任链模式的代码。为了更清晰的描述这种场景我们规定如下: ① GroupLeader(组长 ):他能批准的假期为2天,如果请假天数超过2天就将请假邮件自动转发到组长和经理邮箱。 ② Manager(经理):他能批准的假期为4天以内,如果请假天数大于4天将该邮件转发到自动转发到组长、经理和部门领导的邮箱。 ③ DepartmentHeader(部门领导):他能批准的假期为7天以内,如果大于7天就只批准7天。
(1).构造Request对象,如:Builder模式中的Request
(2).构造批准结果对象Result
public class Result {
public boolean isRality;
public String info;
public Result(boolean isRality, String info) {
this.isRality = isRality;
this.info = info;
}
public boolean isRality() {
return isRality;
}
public void setRality(boolean rality) {
isRality = rality;
}
public String getInfo() {
return info;
}
public void setInfo(String info) {
this.info = info;
}
@Override
public String toString() {
return "Result{" +
"isRality=" + isRality +
", info='" + info + '\'' +
'}';
}
}
(3).定义一个接口,这个接口用于处理Request和获取请求结果Result
public interface Ratify {
//处理请求
public Result deal(Chain chain);
//对request和result封装,用来转发
interface Chain{
//获取当前的request
Request request();
//转发Request
Result proceed(Request request);
}
}
看到上面的接口,可能会有人迷惑:在接口Ratify中为什么又定义一个Chain接口呢?其实这个接口是单独定义还是内部接口没有太大关系,但是考虑到Chain接口与Ratify接口的关系为提高内聚性就定义为内部接口了。定义Ratify接口是为了处理Request那为什么还要定义Chain接口呢?这正是责任链接口的精髓之处:转发功能及可动态扩展“责任人”,这个接口中定义了两个方法一个是request()就是为了获取request,如果当前Ratify的实现类获取到request之后发现自己不能处理或者说自己只能处理部分请求,那么他将自己的那部分能处理的就处理掉,然后重新构建一个或者直接转发Request给下一个责任人。可能这点说的不容易理解,我举个例子,在Android与后台交互中如果使用了Http协议,当然我们可能使用各种Http框架如HttpClient、OKHttp等,我们只需要发送要请求的参数就直接等待结果了,这个过程中你可能并没有构建请求头,那么框架帮你把这部分工作给做了,它做的工程中如果使用了责任链模式的话,它肯定会将Request进行包装(也就是添加请求头)成新的Request,我们姑且加他为Request1,如果你又希望Http做本地缓存,那么Request1又会被转发到并且重新进一步包装为Request2。总之Chain这个接口就是起到对Request进行重新包装的并将包装后的Request进行下一步转发的作用。如果还不是很明白也没关系,本实例会演示这一功能机制。
(4).实现Chain接口的的真正的包装Request和转发功能
public class RealChain implements Ratify.Chain {
public Request request;
public List<Ratify> ratifyList;
public int index;
/**
* 构造方法
*
* @param ratifyList
* Ratify接口的实现类集合
* @param request
* 具体的请求Request实例
* @param index
* 已经处理过该request的责任人数量
*/
public RealChain(List<Ratify> ratifyList, Request request,int index) {
this.request = request;
this.ratifyList = ratifyList;
this.index = index;
}
/**
* 方法描述:具体转发功能
*/
@Override
public Result proceed(Request request) {
Result proceed=null;
if(ratifyList.size()>index){
RealChain realChain=new RealChain(ratifyList,request,index+1);
Ratify ratify=ratifyList.get(index);
proceed=ratify.deal(realChain);
}
return proceed;
}
/***
* 方法描述:返回当前Request对象或者返回当前进行包装后的Request对象
* @return
*/
@Override
public Request request() {
return request;
}
}
(5).GroupLeader、Manager和DepartmentHeader,并让他们实现Ratify接口
public class GroupLeader implements Ratify {
@Override
public Result deal(Chain chain) {
Request request=chain.request();
System.out.println("GroupLeader====>request:"+request.toString());
if(Integer.parseInt(request.getDays())>1){
//包装新的Request对象
Request newRequest=new Request.Builder().newRequest(request).setGroupLeaderInfo(request.getName()+"平时表现不错,而且现在项目不忙").build();
return chain.proceed(newRequest);
}
return new Result(true,"GroupLeader:早去早回");
}
}
public class Manager implements Ratify {
@Override
public Result deal(Chain chain) {
Request request=chain.request();
System.out.println("Manager====>request:"+request.toString());
if(Integer.parseInt(request.getDays())>3){
//构建新的Request
Request newRequest=new Request.Builder().newRequest(request).setManagerInfo(request.getName()+"每月的KPI考核还不错,可以批准").build();
return chain.proceed(newRequest);
}
return new Result(true,"Manager:早点把事情办完,项目离不开你");
}
}
public class DepartmentHeader implements Ratify {
@Override
public Result deal(Chain chain) {
Request request=chain.request();
System.out.println("DepartmentHeader=====>request:"+request.toString());
if(Integer.parseInt(request.getDays())>7){
return new Result(false,"DepartmentHeader:你这个时间太长,不能批准");
}
return new Result(true,"DepartmentHeader:不要着急,把事情处理完在回来!");
}
}
public class CustomInterceptor implements Ratify {
@Override
public Result deal(Chain chain) {
Request request=chain.request();
System.out.println("CustomInterceptor====>"+request.toString());
String reason=request.getReason();
if(reason!=null&&reason.equals("事假")){
Request newRequest=new Request.Builder().newRequest(request).setCustomInfo(request.getName()+"请的是事假,而且很着急,请领导重视一下").build();
System.out.println("CustomInterceptor====>转发请求");
return chain.proceed(newRequest);
}
return new Result(true,"同意请假");
}
}
(6).责任链模式工具类
public class ChainOfResponsibilityClient {
private ArrayList<Ratify> ratifies;
public ChainOfResponsibilityClient() {
ratifies=new ArrayList<>();
}
/**
* 为了展示“责任链模式”的真正的迷人之处(可扩展性),在这里构造该方法以便添加自定义的“责任人”
* @param ratify
*/
public void addRatifys(Ratify ratify){
ratifies.add(ratify);
}
/***
*
* 方法描述:执行请求
* @param request
* @return
*/
public Result execute(Request request){
ArrayList<Ratify> arrayList=new ArrayList<>();
arrayList.addAll(ratifies);
arrayList.add(new GroupLeader());
arrayList.add(new Manager());
arrayList.add(new DepartmentHeader());
RealChain realChain=new RealChain(arrayList,request,0);
return realChain.proceed(request);
}
}
(6).测试方法
public static void main(String[] args){
//写法一
Request.Builder builder=new Request.Builder();//通过静态内部类构建builder对象
builder.setName("zhangsan");
builder.setDays("5");
builder.setReason("事假");
Request request=builder.build();//build方法返回request对象
//写法二
Request request1=new Request.Builder().setName("lisi").setDays("7").setReason("事假").build();
//System.out.print("结果:"+request.toString());
ChainOfResponsibilityClient client=new ChainOfResponsibilityClient();
//添加自定义的拦截器到责任人列表顶端
client.addRatifys(new CustomInterceptor());
Result result=client.execute(request);
System.out.println("结果:"+result.toString());
}
9.享元模式
(1).定义一个接口作为享元角色
public interface IBike {
void ride(int hours);
}
(2).实现IBike接口,作为具体的享元角色
public class ShareBike implements IBike{
private int price=2 ;
@Override
public void ride(int hours) {
int total=2*hours;
System.out.println("ride bike total spend "+total+" RMB");
}
}
(3).创建享元工厂
public class ShareBikeFactory {
Map<String,IBike> pool=new HashMap<>();
public IBike getBike(String name){
IBike iBike=null;
if(!pool.containsKey(name)){
iBike=new ShareBike();
pool.put(name,iBike);
System.out.println("交了199元押金,可以用车:"+name);
}else{
iBike=pool.get(name);
System.out.println("押金已交,直接用车:"+name);
}
return iBike;
}
}
(4).测试类
public static void main(String[] args) {
ShareBikeFactory shareBikeFactory=new ShareBikeFactory();
//第一次骑ofo,交押金
IBike ofo1=shareBikeFactory.getBike("ofo");
ofo1.ride(2);
//第一次骑mobike,交押金
IBike mobike=shareBikeFactory.getBike("mobike");
mobike.ride(3);
//第二次骑mobike,不交押金
IBike ofo2=shareBikeFactory.getBike("ofo");
ofo2.ride(4);
}
10.模板方法模式
(1).创建抽象类,定义算法框架
public abstract class Postman {
public final void post(){//这里声明为final,不希望子类覆盖这个方法,防止更改流程
prepare();
call();
if(isSign()){
sign();
}else{
refuse();
}
}
protected void refuse() {
}
protected void sign() {
System.out.println("派送完毕,客户已经签收!");
}
protected boolean isSign() {
return true;
}
protected abstract void call();
protected void prepare() {
System.out.println("快递已经到达,准备派送...");
}
}
需要注意的是上面的抽象类(Postman)包含了三种类型的方法:抽象方法、具体方法和钩子方法。 抽象方法:需要子类去实现。如上面的call()。 具体方法:抽象父类中直接实现。如上面的prepare()和sign()。 钩子方法:有两种,第一种,它是一个空实现的方法,子类可以视情况来决定是否要覆盖它,如上面的refuse();第二种,它的返回类型通常是boolean类型的,一般用于对某个条件进行判断,如果条件满足则执行某一步骤,否则将不执行,如上面的isSign()。 (2).创建具体实现类,定义算法框架 PostmanA:
public class PostmanA extends Postman{
@Override
protected void call() {
System.out.println("联系收货人A,准备派送...");
}
}
PostmanB:
public class PostmanB extends Postman{
@Override
protected void call() {
System.out.println("联系收货人B,准备派送...");
}
@Override
protected boolean isSign() {
return false;
}
@Override
protected void refuse() {
super.refuse();
System.out.println("商品与实物不符,拒绝签收!");
}
}
(3).测试类
public static void main(String[] args){
//A收货人正常签收
Postman postmanA=new PostmanA();
postmanA.post();
//B收货人拒绝签收
Postman postmanB=new PostmanB();
postmanB.post();
}
![]()
11.备忘录模式
以游戏存档为例: (1).创建发起人角色:Game
public class Game {
private int mLevel=0;
private int mIcon=0;
/***
* 开始游戏
*/
public void paly(){
System.out.print("升级了");
mLevel++;
System.out.println("当前等级为:"+mLevel);
mIcon+=32;
System.out.println("获得金币:"+mIcon);
System.out.println("当前金币数量为:"+mIcon);
}
/***
* 退出游戏
*/
public void exit(){
System.out.println("退出游戏,属性为:等级="+mLevel+",金币="+mIcon);
}
//创建备忘录
public Memo createMemo(){
Memo memo=new Memo();
memo.setmLevel(mLevel);
memo.setmIcon(mIcon);
return memo;
}
public void setMemo(Memo memo){
mLevel=memo.getmLevel();
mIcon=memo.getmIcon();
}
}
(2).创建备忘录角色:Memo
public class Memo {
private int mLevel;//等级
private int mIcon;//金币数量
public int getmLevel() {
return mLevel;
}
public void setmLevel(int mLevel) {
this.mLevel = mLevel;
}
public int getmIcon() {
return mIcon;
}
public void setmIcon(int mIcon) {
this.mIcon = mIcon;
}
}
(3).创建负责人角色:CreateMemo
public class CreateMemo {
private Memo memo;
public Memo getMemo() {
return memo;
}
public void setMemo(Memo memo) {
this.memo = memo;
}
}
(4).测试类
public static void main(String[] args){
Game game=new Game();
game.paly();
CreateMemo createMemo=new CreateMemo();
createMemo.setMemo(game.createMemo());//游戏存档
game.exit();
//第二次进入游戏
System.out.println("第二次进入游戏");
Game secondGame=new Game();
secondGame.setMemo(createMemo.getMemo());//取出之前备忘录中的数据
secondGame.paly();
secondGame.exit();
}
12.原型模式
(1).创建具体原型类 实现Cloneable接口:
public class Card implements Cloneable {
private int num;//卡号
private Spec spec=new Spec();
public Card(){
System.out.println("Card 执行构造函数");
}
@Override
protected Card clone() throws CloneNotSupportedException {
System.out.println("clone时不执行构造函数");
Card card= (Card) super.clone();
card.spec=spec.clone();//对Spce对象也调用clone,实现深拷贝
return card;
}
public int getNum() {
return num;
}
public void setNum(int num) {
this.num = num;
}
public Spec getSpec() {
return spec;
}
public void setSpec(int width,int length) {
spec.width=width;
spec.length=length;
}
public class Spec implements Cloneable{
private int width;
private int length;
public int getWidth() {
return width;
}
public void setWidth(int width) {
this.width = width;
}
public int getLength() {
return length;
}
public void setLength(int length) {
this.length = length;
}
@Override
protected Spec clone() throws CloneNotSupportedException {
return (Spec) super.clone();
}
}
@Override
public String toString() {
return "Card{" +
"num=" + num +
", spec=" +"{width="+spec.getWidth()+",length="+spec.getLength()+
'}';
}
}
(2).测试类
public static void main(String[] args) throws CloneNotSupportedException {
Card card1=new Card();
card1.setNum(111);
card1.setSpec(66,67);
System.out.println(card1.toString());
System.out.println("---------------------");
//拷贝
Card card2=card1.clone();
System.out.println(card2.toString());
System.out.println("---------------------");
//拷贝之后,card2对num进行重新赋值
card2.setNum(222);
System.out.println(card1.toString());
System.out.println(card2.toString());
System.out.println("---------------------");
//拷贝之后,card2对Spec进行重新赋值之后,连card1也跟着改变了,这种就是浅拷贝
card2.setSpec(76,77);
System.out.println(card1.toString());
System.out.println(card2.toString());
System.out.println("---------------------");
}
![]()
13.命令模式
(1).创建命令接口Command
public interface Command {
void execute();
}
(2).创建命令接口实现类:ShutDownCommand
public class ShutDownCommand implements Command {
Receiver receiver;
public ShutDownCommand(Receiver receiver) {
this.receiver = receiver;
}
@Override
public void execute() {
System.out.println("命令角色执行关机命令");
receiver.action();
}
}
(3).创建命令执行者Receiver
public class Receiver {
public void action(){
System.out.println("接收者执行具体的操作");
System.out.println("开始执行关机命令");
System.out.println("退出所有程序");
System.out.println("关机...");
}
}
(4).创建调用者Invoker
public class Invoker {
private Command command;
public Invoker(Command command) {
this.command = command;
}
public void action(){
System.out.println("调用者执行命令");
command.execute();
}
}
测试方法:
public static void main(String[] args){
Receiver receiver=new Receiver();//创建命令接收者
Command command=new ShutDownCommand(receiver);//创建一个命令的具体实现对象,并指定命令接收者
Invoker invoke=new Invoker(command);//创建一个命令调用者,并指定具体命令
invoke.action();
}
注意:此处调用者与接受者之间的解藕。易于扩展,扩展命令只需新增具体命令类即可,符合开放封闭原则。
![]()
14.代理模式
(1).静态代理 IBuy接口
public interface IBuy {
void buy();
}
IBuy接口实现类:Home,OverSea
public class Home implements IBuy {
@Override
public void buy() {
System.out.println("国内要买一个包");
}
}
public class Oversea implements IBuy {
IBuy buyer;
public Oversea(IBuy buyer) {
this.buyer=buyer;
}
@Override
public void buy() {
System.out.println("我是海外代购");
buyer.buy();
}
}
测试方法:
public static void main(String[] args){
//静态代理
IBuy home=new Home();
IBuy oversea=new Oversea(home);
oversea.buy();
System.out.println("----------------------------------------");
}
(2).动态代理(代理类在程序运行时动态生成) 动态代理类:DynamicProxy
public class DynamicProxy implements InvocationHandler {
private Object obj;//被代理的对象
public DynamicProxy(Object obj) {
this.obj=obj;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("海外动态代理调用方法:"+method.getName());
Object result=method.invoke(obj,args);
return result;
}
}
测试方法:
public static void main(String[] args){
//动态代理
IBuy home=new Home();//被代理类
ClassLoader classLoader=demestics.getClass().getClassLoader();//获取classloader
Class[] classes=new Class[]{IBuy.class};//接口类的class数组
DynamicProxy dynamicProxy=new DynamicProxy(home);//创建动态代理
IBuy oversea1= (IBuy) Proxy.newProxyInstance(classLoader,classes,dynamicProxy);
oversea1.buy();//调用海外代购的buy,此处实际上是调用dynamicProxy.invoke()方法
System.out.println("----------------------------------------");
}
![]()
JAVA基础之集合框架详解
参考文章:
1.集合框架图
Java的集合类主要由两个接口派生而出:Collection和Map,Collection和Map是Java集合框架的根接口,这两个接口又包含了一些子接口或实现类。
![]()
2.Collection接口(Collection包含了List和Set两大分支。)
2.1 List接口的实现类(ArrayList,Vector,LinkedList,Stack)
(1).ArrayList
ArrayList是一个动态数组,它允许任何符合规则的元素插入甚至包括null,每一个ArrayList都有一个初始容量(10),随着容器中的元素不断增加,容器的大小也会随着增加。在每次向容器中增加元素的同时都会进行容量检查,当快溢出时,就会进行扩容操作。所以如果我们明确所插入元素的多少,最好指定一个初始容量值,避免过多的进行扩容操作而浪费时间、效率。
注意: ArrayList擅长于随机访问。同时ArrayList是非同步的。
(2).Vector
与ArrayList相似,但是Vector是同步的。所以说Vector是线程安全的动态数组。它的操作与ArrayList几乎一样。
(3).LinkedList
同样实现List接口的LinkedList与ArrayList不同,ArrayList是一个动态数组,而LinkedList是一个双向链表, 由于实现的方式不同,LinkedList不能随机访问,它所有的操作都是要按照双重链表的需要执行。在列表中索引的操作将从开头或结尾遍历列表(从靠近指定索引的一端)。这样做的好处就是可以通过较低的代价在List中进行插入和删除操作。 与ArrayList一样,LinkedList也是非同步的。如果多个线程同时访问一个List,则必须自己实现访问同步。
注意:创建List时构造一个同步的List:List list = Collections.synchronizedList(new LinkedList(...));
######(4).Stack Stack继承自Vector,实现一个后进先出的堆栈。Stack提供5个额外的方法使得Vector得以被当作堆栈使用。基本的push和pop 方法,还有peek方法得到栈顶的元素,empty方法测试堆栈是否为空,search方法检测一个元素在堆栈中的位置。Stack刚创建后是空栈。
private static void testStack() {
/***
* 栈是一种只能在一端进行插入或删除操作的线性表
* 特性:先进后出
*/
Stack<String> stack=new Stack<>();
//进栈push()
stack.push("1");
stack.push("2");
stack.push("3");
stack.push("4");
System.out.println("statck data:"+stack.toString());
// 取栈顶值(不出栈)
System.out.println("stack top:"+stack.peek());
//出栈
// stack.pop();
// stack.pop();
//stack.pop();
System.out.println("stack data:"+stack.toString());
System.out.println("stack is empty:"+stack.empty());
int index=stack.search("3");//计数从顶部开始
System.out.println("stack search index:"+index);
System.out.println("stack search result:"+ stack.get(0));
List<String> list=new ArrayList<>();
list.add("1");
list.add("2");
list.add("3");
list.add("4");
System.out.println("list is empty:"+list.get(3));
Iterator<String> iterator=list.iterator();
while (iterator.hasNext()){
System.out.println(iterator.next());
}
}
2.2 Set接口的实现类(HashSet,LinkedHashSet,TreeSet)
(1)HashSet
HashSet 是一个没有重复元素的集合。它是由HashMap实现的,不保证元素的顺序(这里所说的没有顺序是指:元素插入的顺序与输出的顺序不一致),而且HashSet允许使用null 元素。HashSet是非同步的,如果多个线程同时访问一个哈希set,而其中至少一个线程修改了该set,那么它必须保持外部同步。 HashSet按Hash算法来存储集合的元素,因此具有很好的存取和查找性能。
注意:HashSet是由HashMap实现,不保证元素的插入顺序,可以存放null值,仅仅能够存入一个null值。
private static void testHashSet() {
/****
* 元素不重复
*/
Set<String> hashSet=new HashSet<>();
hashSet.add("javabbb");
hashSet.add("java01");
hashSet.add("java01");
hashSet.add("java03");
hashSet.add("java02");
Set<String> hashSet1=new HashSet<>();
hashSet1.add("java05");
hashSet1.add("java04");
hashSet1.add("javaaaa");
hashSet.add(null);//可以插入null
hashSet.add(null);
hashSet.addAll(hashSet1);
boolean isEmpty=hashSet.isEmpty();
//遍历
Iterator<String> iterator= hashSet.iterator();
while (iterator.hasNext()){
System.out.println(iterator.next());
}
}
输出结果:
![]()
(2)LinkedHashSet
LinkedHashSet继承自HashSet,其底层是基于LinkedHashMap来实现的,有序,非同步。LinkedHashSet集合同样是根据元素的hashCode值来决定元素的存储位置,但是它同时使用链表维护元素的次序。这样使得元素看起来像是以插入顺序保存的,也就是说,当遍历该集合时候,LinkedHashSet将会以元素的添加顺序访问集合的元素。
注意:LinkedHashSet底层是基于LinkedHashMap来实现
private static void testLinkedHashSet() {
/****
* 因为是链表,所以有序输出
* 元素不重复
*/
Set<String> linkedHashSet=new LinkedHashSet<>();
linkedHashSet.add("java01");
linkedHashSet.add("java01");
linkedHashSet.add("java02");
linkedHashSet.add("java03");
Set<String> linkedHashSet1=new LinkedHashSet<>();
linkedHashSet1.add("java04");
linkedHashSet1.add("java05");
linkedHashSet1.add(null);
linkedHashSet1.add(null);
linkedHashSet.addAll(linkedHashSet1);
//遍历
Iterator<String> iterator= linkedHashSet.iterator();
while (iterator.hasNext()){
System.out.println(iterator.next());
}
}
输出结果:
![]()
(3)TreeSet
TreeSet是一个有序集合,其底层是基于TreeMap实现的,非线程安全。TreeSet可以确保集合元素处于排序状态。TreeSet支持两种排序方式,自然排序和定制排序,其中自然排序为默认的排序方式。当我们构造TreeSet时,若使用不带参数的构造函数,则TreeSet的使用自然比较器;若用户需要使用自定义的比较器,则需要使用带比较器的参数。
注意:TreeSet底层是基于TreeMap来实现,Set集合都是非线程安全的
private static void testIntegerSort() {
System.out.println("Integer对象自然排序:");
TreeSet<Integer> treeSetFirst = new TreeSet<>();
treeSetFirst.add(2);
treeSetFirst.add(1);
treeSetFirst.add(4);
treeSetFirst.add(3);
treeSetFirst.add(5);
Iterator<Integer> iterator=treeSetFirst.iterator();
while (iterator.hasNext()){
System.out.println(iterator.next());
}
}
private static void testDictionarySort() {
System.out.println("Dictionary对象自然排序:");
TreeSet<String> treeSetFirst = new TreeSet<>();
treeSetFirst.add("Baidu");
treeSetFirst.add("Tecent");
treeSetFirst.add("Ali");
treeSetFirst.add("WanDa");
treeSetFirst.add("HengDa");
treeSetFirst.add("12");
treeSetFirst.add("23a#");
treeSetFirst.add("#");
Iterator<String> iterator=treeSetFirst.iterator();
while (iterator.hasNext()){
System.out.println(iterator.next());
}
}
private static void testCompatorSort() {
Set<Student> treeSet=new TreeSet<>();
treeSet.add(new Student("tecent",2));
treeSet.add(new Student("JD",1));
treeSet.add(new Student("wanda",3));
treeSet.add(new Student("baidu",2));
treeSet.add(new Student("ali",2));
treeSet.add(new Student("tecent",2));//重复的元素被剔除了
System.out.println(treeSet);
Iterator itTSet = treeSet.iterator();//遍历输出
while(itTSet.hasNext()){
System.out.print(itTSet.next() + "\n");
}
}
private static void testSubHeadTailSet() {
TreeSet nums = new TreeSet();
nums.add(5);
nums.add(2);
nums.add(3);
nums.add(8);
nums.add(8);
//输出集合元素,可以看到集合元素已经处于排序状态,输出【2,3,5,8】
System.out.println(nums);
//输出排序后集合里的第一个元素2
System.out.println(nums.first());
//输出排序后集合里最后一个元素
System.out.println(nums.last());
//输出小于4的集合,不包含4,输出【2,3】
System.out.println(nums.headSet(4));
//输出大于5的集合,如果set集合中有5,子集中还应该有5,输出【5,8】
System.out.println(nums.tailSet(5));
//输出大于2,小于5的子集,包括2,不包括5,输出集合【2,3】
System.out.println(nums.subSet(2, 5));
}
public static class Student implements Comparable {
int num;
String name;
public Student( String name,int num) {
this.num = num;
this.name = name;
}
@Override
public String toString() {
return "StudentNo:" + num + " ,StudentName:" + name ;
}
public int getNum() {
return num;
}
public void setNum(int num) {
this.num = num;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public int compareTo(Object o) {
Student student= (Student) o;
if(num<student.getNum()){//升序排列
return -1;
}else if(num==student.getNum()){
return name.compareTo(student.getName());
}else{
return 1;
}
}
}
输出结果:
![]()
3.Map接口
Map与List、Set接口不同,它是由一系列键值对组成的集合,提供了key到Value的映射。同时它也没有继承Collection。在Map中它保证了key与value之间的一一对应关系。也就是说一个key对应一个value,所以它不能存在相同的key值,当然value值可以相同。 ####(1).HashMap HashMap可以通过调用Collections的静态方法Collections.synchronizedMap(Map map)进行同步,最多只允许一条记录的键为Null,不支持线程的同步,无序
private static void testHashMap() {
/***
* hashmap的key和value都可以为null
*/
Map<String, String> map = new HashMap<>();
for (int i = 0; i <= 3; i++) {
map.put("key" + i, "value" + i);
}
map.put(null,"value4");
map.put(null,"value5");
map.put("key6",null);
map.put("key7",null);
map.get("key" + 5);
for (String key : map.keySet()) {
System.out.println(map.get(key));
}
}
输出结果:
![]()
输出结果:
![]()
(2).LinkedHashMap
LinkedHashMap是HashMap的一个子类,它保留插入的顺序,如果需要输出的顺序和输入时的相同,那么就选用LinkedHashMap。允许使用null值和null键
private static void testLinkHashMap() {
//linkedhashmap extends hashmap 比hashmap功能更强大
Map<String, String> map = new LinkedHashMap<>();
for (int i = 0; i <= 3; i++) {
map.put("key" + i, "value" + i);
}
map.put(null,"value4");
map.put(null,"value5");
map.put("key6",null);
map.put("key7",null);
map.get("key" + 5);
for (String key : map.keySet()) {
System.out.println(map.get(key));
}
}
输出结果:
![]()
(2).Hashtable
线程同步,同时key,value都不可以为null,无序的
private static void testHashtable() {
/***
* 线程同步的,同时key,value都不可以为null,无序的
*/
Hashtable<String,Object> hashtable = new Hashtable();
hashtable.put("baidu","101");
hashtable.put("ali","102");
hashtable.put("tencent","103");
hashtable.put("wanda","105");
hashtable.put("pingan","107");
hashtable.put("hengda","106");
hashtable.put("transsion","104");
// hashtable.put(null,"2");//java.lang.NullPointerException
// hashtable.put("wanke",null);//java.lang.NullPointerException
for(String key:hashtable.keySet()){
System.out.println(key+"="+hashtable.get(key));
}
}
(4).ConCurrentHashMap
ConcurrentHashMap和HashTable都是线程安全的,无序的,key和value都不能为null,性能上要比Hashtable要强,是一个加强版本的Hashtable。
private static void testConcurrentHashMap() {
/***
* ConcurrentHashMap和HashTable都是线程安全的,可以在多线程中进行,
* key和value都不能为null,性能上要比Hashtable要强
* 线程同步的,同时key,value都不可以为null
*/
ConcurrentHashMap<String,Object> concurrentHashMap = new ConcurrentHashMap();
concurrentHashMap.put("baidu","101");
concurrentHashMap.put("ali","102");
concurrentHashMap.put("tencent","103");
concurrentHashMap.put("wanda","105");
concurrentHashMap.put("pingan","107");
concurrentHashMap.put("hengda","106");
concurrentHashMap.put("transsion","104");
// concurrentHashMap.put(null,"2");//java.lang.NullPointerException
// concurrentHashMap.put("wanke",null);//java.lang.NullPointerException
for(String key:concurrentHashMap.keySet()){
System.out.println(key+"="+concurrentHashMap.get(key));
}
}
输出结果:
![]()
(5).TreeMap
TreeMap 是一个有序的key-value集合,非同步,基于红黑树(Red-Black tree)实现,每一个key-value节点作为红黑树的一个节点。TreeMap存储时会进行排序的,会根据key来对key-value键值对进行排序,其中排序方式也是分为两种,一种是自然排序,一种是定制排序,具体取决于使用的构造方法。 ######注意:key不能为null,value可以为null
//自然排序顺序:
public static void naturalSort(){
//第一种情况:Integer对象
System.out.println("Integer对象自然排序:");
TreeMap<Integer,String> treeMapFirst = new TreeMap<Integer, String>();
treeMapFirst.put(1,"jiaboyan");
treeMapFirst.put(6,"jiaboyan");
treeMapFirst.put(3,"jiaboyan");
treeMapFirst.put(10,"jiaboyan");
treeMapFirst.put(7,"jiaboyan");
treeMapFirst.put(13,"jiaboyan");
//treeMapFirst.put(null,"jiaboyan");java.lang.NullPointerException
treeMapFirst.put(14,null);//可以运行
System.out.println(treeMapFirst.toString());
//第二种情况:SortedTest对象
System.out.println("SortedTest对象排序一:");
TreeMap<SortedTest,String> treeMapSecond = new TreeMap<SortedTest, String>();
treeMapSecond.put(new SortedTest(10),"jiaboyan");
treeMapSecond.put(new SortedTest(1),"jiaboyan");
treeMapSecond.put(new SortedTest(13),"jiaboyan");
treeMapSecond.put(new SortedTest(4),"jiaboyan");
treeMapSecond.put(new SortedTest(0),"jiaboyan");
treeMapSecond.put(new SortedTest(9),"jiaboyan");
System.out.println(treeMapSecond.toString());
//默认是根据key的自然排序来组织(比如integer的大小,String的字典排序)
System.out.println("integer和字典对象排序二:");
TreeMap<String,SortedTest> treeMapThree = new TreeMap<String,SortedTest >();
treeMapThree.put("2key1",new SortedTest(10));
treeMapThree.put("1key2",new SortedTest(1));
treeMapThree.put("bey3",new SortedTest(13));
treeMapThree.put("key6",new SortedTest(4));
treeMapThree.put("key5",new SortedTest(0));
treeMapThree.put("key4",new SortedTest(9));
System.out.println(treeMapThree.toString());
}
public static class SortedTest implements Comparable<SortedTest> {
private int age;
public SortedTest(int age){
this.age = age;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
@Override
public String toString() {
return "age:"+age;
}
//自定义对象,实现compareTo(T o)方法:
public int compareTo(SortedTest sortedTest) {
int num = this.age - sortedTest.getAge();
//为0时候,两者相同:
if(num==0){
return 0;
//大于0时,传入的参数小:
}else if(num>0){
return 1;
//小于0时,传入的参数大:
}else{
return -1;
}
}
}
输出结果:
![]()
【配置化 CRUD 01】搜索重置组件:封装与复用
一:前言
在后台管理系统的配置化 CRUD 开发中,搜索+重置是高频组合场景,几乎所有列表页都需要通过搜索筛选数据、通过重置恢复初始查询状态等等...。基于此,本文将详细讲解「搜索重置组件」的封装思路及使用方法,该组件基于Vue3 + Element Plus 开发,支持配置化扩展、响应式联动,可直接集成到配置化 CRUD 体系中,提升开发效率与代码一致性。
二:解决问题
封装搜索重置组件主要为了解决以下几个问题:
1.代码冗余 : 每个列表页都要重复编写表单结构、搜索按钮、重置按钮,以及对应的点击事件、数据校验逻辑;
2.风格不统一:不同开发人员编写的搜索表单,在布局、按钮尺寸、标签宽度、间距等细节上可能存在差异;
3.维护成本高:当需要修改搜索表单的布局、按钮样式,需要逐个页面排查修改;
...
三:具体实现
在开始之前,请先阅读一下本专栏的第一篇文章,动态表单的实现是搜索重置组件的基础:
![]()
接下来我们可以思考一下一个通用的搜索重置组件具备的基本功能:
1.搜索项的展示
2.搜索项默认展示搜索初始值
3.搜素与重置按钮功能
...
扩展功能:
1.搜索表单项的联动
2.搜索表单项的校验
...
接下来我们一步一步来实现:
3.1 基础功能实现
先完成最简单的部分 : 展示搜索项和搜索重置按钮、以及基本样式统一处理
组件基础实现:
type SearchPrams = {
schema?: FormOptions[] // 配置表
search?: () => void // 搜索回调
reset?: () => void // 重置回调
labelWidth?: string,
flex?: number
}
const props = withDefaults(defineProps<SearchPrams>(), {
schema: {},
labelWidth:'140px',
flex:5
})
const codeFormRef = ref(null)
//搜索
const search = async () => {
const data = await codeFormRef?.value?.getData()
emits('search', data)
}
//重置
const reset = () => {
codeFormRef?.value?.resetFields('')
emits('reset', {})
}
<div class="sea-box">
<CodeForm
class="form-box"
:style="{ flex: props?.flex || 5 }"
layoutType="cell"
ref="codeFormRef"
:schema="schema"
:labelWidth="props.labelWidth"
>
</CodeForm>
<div class="sea-btn-box">
<div>
<ElButton
type="primary"
:style="{ width: '80px' }"
@click="search"
>{{ $t('Search') }}</ElButton
>
<ElButton
:style="{ width: '80px', marginLeft: '15px' }"
@click="reset"
>{{ $t('Reset') }}</ElButton
>
</div>
</div>
</div>
<style scoped>
.sea-btn-box {
flex: 1;
display: flex;
justify-content: flex-end;
}
.form-box {
flex: 5;
}
.sea-box {
display: flex;
padding: 20px;
padding-bottom: 0;
padding-top: 0;
}
</style>
外部定义配置表:
const searchColumn = [
{
label: '姓名',
prop: 'name',
component: 'Input',
},
{
label: '年龄',
prop: 'age',
component: 'Input',
},
{
label: '上学阶段',
prop: 'jieduan',
component: 'Select',
componentProps: {
options: [
{
label: '幼儿园',
value: 1
},
{
label: '其他阶段',
value: 2
}
]
}
},
]
引入组件使用:
<Search
:schema="allshema.searchcolumns"
@search="(params) => console.log('点击查询:',{params})"
@reset="() => setSearchParams({}, true, true)"
>
</Search>
运行截图:
![]()
到这一步我们就已经实现了基本功能:展示表单、统一风格、查询重置。
当然我们可能会想要某些表单项具有初始值,或者不展示重置按钮,只要组件内部稍加改造一下就行:
type SearchPrams = {
showSearch?: boolean // 展示搜索按钮
showReset?: boolean // 展示重置按钮
schema?: any // 配置表
search?: () => any
reset?: () => any
labelWidth?: string,
flex?: number
}
<div class="sea-btn-box">
<div>
<ElButton
v-if="showSearch"
type="primary"
:style="{ width: '80px' }"
@click="search"
>{{ $t('Search') }}</ElButton
>
<ElButton
v-if="showReset"
:style="{ width: '80px', marginLeft: '15px' }"
@click="reset"
>{{ $t('Reset') }}</ElButton
>
</div>
</div>
外部引入:
const searchColumn = [
{
label: '姓名',
prop: 'name',
initValue: '初始化名字',
component: 'Input',
},
...
]
<Search
:schema="searchColumn"
@search="(params) => console.log('点击查询:',{params})"
:showReset="false"
>
</Search>
运行截图:
![]()
这样就实现了按钮的展示与隐藏以及初始化默认值。
3.2 扩展功能实现
接下来我们继续实现一下扩展功能:
1.表单项的联动
利用动态表单组件内置的 setValues、setSchemas方法,
组件内部增加方法定义及暴露:
const setValues = (data: any) => {
codeFormRef?.value?.setValues(data)
}
const setSchemas = (data: any) => {
codeFormRef?.value?.setSchemas(data)
}
defineExpose({
getData,
setValues,
setSchemas
})
外部增加搜索组件的ref引用:
const searchRef: any = ref(null)
const searchColumn = [
{
label: '姓名',
prop: 'name',
initValue: '初始化名字',
component: 'Input',
componentProps: {
onInput: (e: any) => {
console.log('姓名输入框输入事件', e)
searchRef.value?.setSchemas([
{
prop: 'age',
path: 'componentProps.placeholder',
value: `请输入${e}的年龄`
}
])
}
}
},
{
label: '年龄',
prop: 'age',
component: 'Input',
},
...
]
<Search
ref="searchRef"
:schema="allshema.searchcolumns"
@search="setSearchParams"
@reset="() => setSearchParams({}, true, true)"
>
</Search>
运行截图:
![]()
这样就实现了搜索表单项之间的联动。
2.表单项的校验
组件内部改动:
type SearchPrams = {
showSearch?: boolean // 展示搜索
showReset?: boolean // 展示重置按钮
isVaildSearch?: boolean // 是否校验搜索
schema?: any // 配置表
search?: () => any
reset?: () => any
labelWidth?: string,
flex?: number
}
const props = withDefaults(defineProps<SearchPrams>(), {
showSearch: true,
showReset: true,
isVaildSearch: false,
schema: {}, // 表单配置
labelWidth:'140px',
flex:5
})
const search = async () => {
if(props.isVaildSearch) {
const valid = await codeFormRef?.value?.validate();
if(!valid) return;
}
const data = await codeFormRef?.value?.getData()
emits('search', data)
}
外部引入使用:
const searchColumn = [
...,
{
label: '年龄',
prop: 'age',
component: 'Input',
formItemProps: {
rules:[
{
required: true,
message: '请输入年龄',
trigger: 'blur'
}
]
}
},
...
]
<Search
ref="searchRef"
:schema="searchColumn"
@search="(params) => console.log('点击查询:',{params})"
:showReset="false"
:isVaildSearch="true"
>
</Search>
运行截图:
![]()
这样就实现了搜索表单项的表单校验。
以上就是搜索重置组件的核心实现步骤~
专业指南:从核心概念到3D动效实现
CSS3 专业指南:从核心概念到3D动效实现
引言:CSS3的演进与现代化布局体系
CSS3不仅是CSS2.1的简单扩展,而是Web样式设计的一次革命性升级。自2011年开始逐步标准化,CSS3引入了模块化设计理念,将样式规范拆分为独立模块,每个模块可以独立演进。这种设计使得Flexbox、Grid、动画、变换等现代特性得以快速发展,彻底改变了前端开发者的工作方式。
一、CSS3核心模块架构
1.1 选择器模块:精准元素定位
/* 属性选择器 - 精准匹配 */
input[type="email"] {
border-color: #3498db;
}
/* 结构伪类选择器 */
li:nth-child(2n) { /* 偶数项 */
background-color: #f8f9fa;
}
li:nth-child(odd) { /* 奇数项 */
background-color: #e9ecef;
}
/* 目标伪类 */
section:target {
background-color: #fff3cd;
animation: highlight 1s ease;
}
/* 否定伪类 */
div:not(.exclude) {
opacity: 1;
transition: opacity 0.3s;
}
/* 状态伪类 */
input:focus-within {
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.3);
}
1.2 盒模型增强:多列布局实践
/* 经典多列布局 */
.article-content {
column-count: 3;
column-gap: 2em;
column-rule: 1px solid #dee2e6;
column-width: 300px;
/* 避免元素跨列断开 */
break-inside: avoid;
}
/* 列间平衡优化 */
.balanced-columns {
column-count: 3;
column-fill: balance; /* 列高尽量平衡 */
}
/* 响应式多列 */
@media (max-width: 768px) {
.article-content {
column-count: 2;
}
}
@media (max-width: 480px) {
.article-content {
column-count: 1;
}
}
二、Flexbox:一维布局的革命
2.1 Flex容器与项目的基础配置
<div class="flex-container">
<div class="flex-item">项目1</div>
<div class="flex-item">项目2</div>
<div class="flex-item">项目3</div>
</div>
.flex-container {
display: flex;
flex-direction: row; /* 主轴方向: row | row-reverse | column | column-reverse */
flex-wrap: wrap; /* 换行: nowrap | wrap | wrap-reverse */
justify-content: center; /* 主轴对齐 */
align-items: center; /* 交叉轴对齐 */
align-content: stretch; /* 多行对齐 */
gap: 1rem; /* 项目间距 */
min-height: 300px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.flex-item {
flex: 1 0 200px; /* grow | shrink | basis */
min-height: 100px;
background: rgba(255, 255, 255, 0.9);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
transition: all 0.3s ease;
}
.flex-item:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
}
2.2 Flex项目的高级控制
/* 项目排序控制 */
.flex-item:nth-child(1) { order: 3; }
.flex-item:nth-child(2) { order: 1; }
.flex-item:nth-child(3) { order: 2; }
/* 项目对齐覆盖 */
.flex-item.special {
align-self: flex-start; /* 覆盖容器align-items */
flex-grow: 2; /* 比其他项目多占空间 */
}
/* 响应式Flex调整 */
@media (max-width: 768px) {
.flex-container {
flex-direction: column;
}
.flex-item {
flex-basis: auto;
width: 100%;
}
}
三、CSS Grid:二维布局的终极解决方案
3.1 网格系统基础架构
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-template-rows: auto;
gap: 1.5rem;
padding: 20px;
background: #f8f9fa;
min-height: 400px;
}
.grid-item {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 显式网格定位 */
.grid-item:nth-child(1) {
grid-column: 1 / 3; /* 跨越两列 */
grid-row: 1;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.grid-item:nth-child(2) {
grid-column: 3;
grid-row: 1 / 3; /* 跨越两行 */
}
/* 隐式网格行为 */
.grid-container {
grid-auto-flow: dense; /* 自动填充空白 */
grid-auto-rows: minmax(150px, auto); /* 隐式行高 */
}
3.2 高级网格布局模式
/* 杂志式布局 */
.magazine-layout {
display: grid;
grid-template-areas:
"header header header"
"sidebar content ads"
"footer footer footer";
grid-template-columns: 200px 1fr 200px;
grid-template-rows: auto 1fr auto;
gap: 20px;
height: 100vh;
}
.header { grid-area: header; background: #2c3e50; color: white; }
.sidebar { grid-area: sidebar; background: #ecf0f1; }
.content { grid-area: content; background: white; }
.ads { grid-area: ads; background: #f1c40f; }
.footer { grid-area: footer; background: #34495e; color: white; }
/* 响应式网格调整 */
@media (max-width: 1024px) {
.magazine-layout {
grid-template-areas:
"header"
"sidebar"
"content"
"ads"
"footer";
grid-template-columns: 1fr;
grid-template-rows: auto;
}
}
四、水平垂直居中:全方位解决方案
4.1 传统居中方案
/* 方案1:绝对定位 + transform */
.centered-1 {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #3498db;
color: white;
padding: 2rem;
border-radius: 8px;
}
/* 方案2:表格单元格 */
.parent-table {
display: table;
width: 100%;
height: 300px;
background: #ecf0f1;
}
.child-table {
display: table-cell;
vertical-align: middle;
text-align: center;
}
4.2 现代居中方案
/* 方案3:Flexbox居中 */
.parent-flex {
display: flex;
justify-content: center;
align-items: center;
height: 300px;
background: linear-gradient(45deg, #ff9a9e, #fad0c4);
}
.child-flex {
padding: 2rem;
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
}
/* 方案4:Grid居中 */
.parent-grid {
display: grid;
place-items: center; /* 一行代码实现居中 */
height: 300px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
/* 方案5:Margin auto (块级元素) */
.block-centered {
width: 200px;
height: 200px;
margin: 50px auto;
background: #2ecc71;
color: white;
display: flex;
align-items: center;
justify-content: center;
}
4.3 文本居中与行内元素
/* 文本水平居中 */
.text-center {
text-align: center;
}
/* 行内元素居中 */
.inline-parent {
text-align: center;
height: 100px;
line-height: 100px; /* 单行文本垂直居中 */
background: #f8f9fa;
}
/* 多行文本垂直居中 */
.multiline-center {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
text-align: center;
background: #fff3cd;
}
五、CSS3变换与过渡:交互动效基础
5.1 2D变换系统
.transform-demo {
width: 200px;
height: 200px;
background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
transition: all 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
}
/* 旋转变换 */
.transform-demo.rotate:hover {
transform: rotate(45deg);
}
/* 缩放变换 */
.transform-demo.scale:hover {
transform: scale(1.2);
}
/* 倾斜变换 */
.transform-demo.skew:hover {
transform: skew(15deg, 15deg);
}
/* 多重变换 */
.transform-demo.multiple:hover {
transform: rotate(15deg) scale(1.1) translateX(20px);
box-shadow: 0 20px 40px rgba(0,0,0,0.2);
}
/* 变换原点控制 */
.transform-demo.origin {
transform-origin: top left; /* 左上角为变换原点 */
}
5.2 过渡动画高级应用
/* 复杂过渡效果 */
.card {
width: 300px;
height: 400px;
background: white;
border-radius: 20px;
overflow: hidden;
position: relative;
transition: all 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275);
box-shadow: 0 15px 35px rgba(0,0,0,0.1);
}
.card:hover {
transform: translateY(-20px) scale(1.05);
box-shadow: 0 30px 60px rgba(0,0,0,0.2);
}
.card-content {
padding: 2rem;
opacity: 0;
transform: translateY(50px);
transition: all 0.5s ease 0.2s; /* 延迟触发 */
}
.card:hover .card-content {
opacity: 1;
transform: translateY(0);
}
/* 过渡性能优化 */
.optimized-transition {
/* 使用transform和opacity以获得GPU加速 */
transition: transform 0.3s ease, opacity 0.3s ease;
/* 避免动画布局抖动 */
will-change: transform, opacity;
}
六、CSS3 3D变换:深度与空间感
6.1 3D变换基础
<div class="scene">
<div class="cube">
<div class="face front">前面</div>
<div class="face back">后面</div>
<div class="face right">右面</div>
<div class="face left">左面</div>
<div class="face top">上面</div>
<div class="face bottom">下面</div>
</div>
</div>
/* 3D场景设置 */
.scene {
width: 200px;
height: 200px;
perspective: 1000px; /* 透视距离,值越小3D效果越强 */
margin: 100px auto;
}
/* 3D容器 */
.cube {
width: 100%;
height: 100%;
position: relative;
transform-style: preserve-3d; /* 保持3D空间 */
transition: transform 1s ease-in-out;
animation: rotateCube 10s infinite linear;
}
/* 立方体面 */
.face {
position: absolute;
width: 200px;
height: 200px;
background: rgba(52, 152, 219, 0.8);
border: 2px solid white;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: white;
font-weight: bold;
backface-visibility: visible;
}
/* 各面定位 */
.front { transform: rotateY(0deg) translateZ(100px); }
.back { transform: rotateY(180deg) translateZ(100px); }
.right { transform: rotateY(90deg) translateZ(100px); }
.left { transform: rotateY(-90deg) translateZ(100px); }
.top { transform: rotateX(90deg) translateZ(100px); }
.bottom { transform: rotateX(-90deg) translateZ(100px); }
/* 立方体旋转动画 */
@keyframes rotateCube {
0% { transform: rotateX(0) rotateY(0) rotateZ(0); }
25% { transform: rotateX(90deg) rotateY(180deg); }
50% { transform: rotateX(180deg) rotateY(360deg); }
75% { transform: rotateX(270deg) rotateY(540deg); }
100% { transform: rotateX(360deg) rotateY(720deg); }
}
/* 交互式旋转 */
.cube:hover {
animation-play-state: paused;
transform: rotateX(45deg) rotateY(45deg);
}
6.2 3D卡片翻转效果
/* 3D卡片翻转 */
.flip-card {
width: 300px;
height: 400px;
perspective: 1000px;
cursor: pointer;
}
.flip-card-inner {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.8s;
transform-style: preserve-3d;
}
.flip-card:hover .flip-card-inner {
transform: rotateY(180deg);
}
.flip-card-front,
.flip-card-back {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 15px 35px rgba(0,0,0,0.1);
}
.flip-card-front {
background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 2rem;
}
.flip-card-back {
background: linear-gradient(45deg, #667eea, #764ba2);
transform: rotateY(180deg);
padding: 2rem;
color: white;
display: flex;
flex-direction: column;
justify-content: center;
}
七、CSS动画系统:关键帧动画详解
7.1 复杂关键帧动画
/* 多阶段动画 */
@keyframes multiStep {
0% {
transform: translateX(0) scale(1);
background-color: #ff6b6b;
}
25% {
transform: translateX(100px) scale(1.2);
background-color: #4ecdc4;
}
50% {
transform: translateX(200px) scale(1);
background-color: #45b7d1;
}
75% {
transform: translateX(100px) scale(0.8);
background-color: #96ceb4;
}
100% {
transform: translateX(0) scale(1);
background-color: #ff6b6b;
}
}
.animated-box {
width: 100px;
height: 100px;
border-radius: 12px;
animation: multiStep 4s ease-in-out infinite;
animation-fill-mode: both;
}
/* 动画控制 */
.paused {
animation-play-state: paused;
}
.slow {
animation-duration: 8s;
animation-timing-function: ease-in;
}
.alternate {
animation-direction: alternate;
}
7.2 性能优化的动画实践
/* GPU加速动画 */
.gpu-animated {
/* 使用transform和opacity触发GPU加速 */
transform: translateZ(0);
will-change: transform, opacity;
animation: smoothSlide 2s ease-in-out infinite;
}
@keyframes smoothSlide {
0%, 100% {
transform: translateX(0) translateZ(0);
}
50% {
transform: translateX(100px) translateZ(0);
}
}
/* 减少重绘的动画 */
.optimized-animation {
/* 只动画transform和opacity属性 */
animation: optimizedMove 3s infinite;
/* 创建独立的合成层 */
backface-visibility: hidden;
-webkit-font-smoothing: subpixel-antialiased;
}
@keyframes optimizedMove {
0% {
transform: translateX(0) scale(1);
opacity: 1;
}
100% {
transform: translateX(300px) scale(1.5);
opacity: 0.8;
}
}
八、现代CSS特性与最佳实践
8.1 CSS自定义属性(CSS变量)
:root {
--primary-color: #3498db;
--secondary-color: #2ecc71;
--spacing-unit: 8px;
--border-radius: 12px;
--transition-speed: 0.3s;
--box-shadow: 0 10px 30px rgba(0,0,0,0.1);
--gradient-bg: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
}
.component {
background: var(--gradient-bg);
padding: calc(var(--spacing-unit) * 3);
border-radius: var(--border-radius);
transition: all var(--transition-speed) ease;
box-shadow: var(--box-shadow);
}
.component:hover {
--primary-color: #2980b9; /* 动态修改变量 */
transform: translateY(-5px);
box-shadow: 0 20px 40px rgba(0,0,0,0.2);
}
/* JS与CSS变量交互 */
const element = document.querySelector('.component');
element.style.setProperty('--primary-color', '#e74c3c');
8.2 现代布局技术整合
/* 现代响应式布局系统 */
.modern-layout {
display: grid;
grid-template-columns:
[full-start] minmax(var(--spacing-unit), 1fr)
[content-start] min(100% - 2rem, 1200px)
[content-end] minmax(var(--spacing-unit), 1fr)
[full-end];
gap: var(--spacing-unit);
}
.item {
grid-column: content;
/* 内部使用Flexbox */
display: flex;
flex-wrap: wrap;
gap: var(--spacing-unit);
}
/* 子元素使用CSS Grid自动布局 */
.sub-item {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: calc(var(--spacing-unit) * 2);
}
/* 容器查询(前沿特性) */
@container (min-width: 400px) {
.card {
display: grid;
grid-template-columns: 1fr 2fr;
}
}
九、性能优化与最佳实践
9.1 渲染性能优化
/* 硬件加速技巧 */
.optimized-element {
/* 触发GPU加速 */
transform: translateZ(0);
backface-visibility: hidden;
perspective: 1000px;
/* 减少布局抖动 */
will-change: transform, opacity;
/* 优化动画性能 */
animation: optimizedAnimation 0.3s ease forwards;
}
@keyframes optimizedAnimation {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* 减少重排和重绘 */
.stable-layout {
/* 避免频繁修改会引起重排的属性 */
position: fixed; /* 脱离文档流 */
/* 使用transform代替top/left */
transition: transform 0.3s ease;
}
/* 使用content-visibility优化渲染 */
.large-list {
content-visibility: auto;
contain-intrinsic-size: 0 500px; /* 预估高度 */
}
9.2 现代CSS工作流程
/* 层叠层管理 */
@layer base, components, utilities;
@layer base {
/* 重置样式和基础样式 */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; }
}
@layer components {
/* 组件样式 */
.card { /* 卡片组件样式 */ }
.button { /* 按钮组件样式 */ }
}
@layer utilities {
/* 工具类 */
.text-center { text-align: center; }
.flex-center { display: flex; align-items: center; justify-content: center; }
}
/* 容器查询支持 */
@container (min-width: 400px) {
.responsive-card {
grid-template-columns: 1fr 2fr;
}
}
/* 媒体查询的现代写法 */
@media (width >= 768px) {
.responsive-element {
font-size: 1.125rem;
}
}
结论:CSS3的演进与未来趋势
CSS3的发展已经从简单的样式描述语言演变为功能强大的布局和动画引擎。通过掌握Flexbox和Grid,我们可以构建响应式、灵活的布局系统;通过transform、transition和animation,我们可以创建流畅的交互动效;通过3D变换,我们可以为Web应用增加深度和空间感。
关键要点总结:
- 布局选择:Flexbox用于一维布局,Grid用于二维布局
- 居中策略:根据上下文选择最合适的居中方案
- 动画优化:优先使用transform和opacity,减少布局抖动
- 性能优先:合理使用GPU加速,避免强制同步布局
- 渐进增强:使用特性检测,为不支持的环境提供降级方案
随着CSS Container Queries、Subgrid、CSS Nesting等新特性的逐步落地,CSS的能力边界还在不断扩展。作为前端开发者,持续学习并掌握这些现代CSS特性,是构建高性能、高用户体验Web应用的关键所在。
学习路径建议:
- 掌握基础选择器和盒模型
- 深入学习Flexbox和Grid布局
- 实践变换和过渡效果
- 探索关键帧动画和3D变换
- 学习性能优化和现代工作流程
通过不断实践和探索,你将能够利用CSS3的强大功能,创造出既美观又高性能的现代Web界面。
AI全栈筑基:React Router DOM 路由配置
在AI全栈项目的开发征途中,路由配置往往是前端“骨架”搭建完成的标志性节点。当我们敲下最后一行路由代码,看着项目目录从混沌走向清晰,这不仅仅是功能的实现,更是架构思维的落地。
最近在搭建一个基于 React + NestJS + AI 的全栈项目时,我对前端路由有了更深层次的思考。路由不仅仅是URL的映射,它是连接用户与功能的桥梁,更是决定应用性能与可维护性的核心。
本文将结合我在项目中的实际配置,深入探讨 React Router DOM 在企业级应用中的核心应用、易错点以及与全栈架构的协同。
🚦 1. 路由模式的选择:History 与 Hash 的博弈
在项目初始化阶段,选择合适的路由模式是至关重要的决策。
现代 React 应用普遍倾向于使用 BrowserRouter(History 模式)。它利用 HTML5 History API 提供了干净、美观的 URL 结构(如 /home),符合 RESTful 规范,对 SEO 友好。
// src/App.jsx
import { BrowserRouter as Router } from 'react-router-dom';
export default function App() {
return (
<Router>
{/* 路由内容 */}
</Router>
);
}
💡 架构思考:
虽然 BrowserRouter 看起来很“温柔”,但它背后隐藏着锋利的一面:它要求服务器端必须配置“兜底”策略。
如果你的应用部署在 Nginx 或 Node 服务上,必须确保所有非 API 请求都重定向到 index.html。否则,当用户直接访问 /user/123 时,后端会因为找不到该路径而返回 404。这标志着在前后端分离架构中,前端不再是孤立的,而是需要与后端部署策略紧密配合。
🏗️ 2. 路由形态的深度解析:从嵌套到鉴权
在构建复杂应用时,单一的路由模式显然不够用。我们需要构建一套层次分明的路由体系。
2.1 嵌套路由:保持布局一致性
在项目中,我为产品模块配置了嵌套路由。父组件 Product 负责承载公共的导航栏或侧边栏,而子组件(详情页、新增页)通过 <Outlet> 渲染在指定位置。
// src/router/index.jsx
{
path: "/product",
element: <Product />, // 父级布局
children: [
{ path: ":productId", element: <ProductDetail /> }, // 子路由
{ path: "new", element: <NewProduct /> }, // 子路由
],
}
这种模式避免了在每个子页面中重复编写相同的布局代码,极大地提升了用户体验的连贯性。
2.2 鉴权路由:路由守卫的实现
对于支付等敏感页面,直接暴露是危险的。我在路由配置中引入了 ProtectRoute 组件。
{
path: "/pay",
element: (
<ProtectRoute>
<Pay />
</ProtectRoute>
),
}
💡 核心逻辑:ProtectRoute 本质上是一个高阶组件(HOC)。它在渲染 props.children(即 Pay 组件)之前,会先检查用户的登录状态(如检查 Token)。如果未通过校验,直接重定向到登录页;如果通过,则放行。这种将横切关注点(Cross-Cutting Concerns)剥离的方式,是企业级应用的必备手段。
⚡ 3. 性能优化:懒加载与用户体验
单页应用(SPA)的一大痛点是首屏体积过大。为了解决这个问题,我采用了路由级代码分割(Code Splitting) 。
3.1 React.lazy 与 Suspense
利用 Webpack 的动态导入功能,我将不同页面的代码拆分成独立的 Chunk。
const Home = React.lazy(() => import('../pages/Home'));
const About = React.lazy(() => import('../pages/About'));
// 在渲染层
<Suspense fallback={<LoadingFallback />}>
<Routes>{/* 路由配置 */}</Routes>
</Suspense>
只有当用户访问 /about 路径时,About 组件的代码才会被动态加载。这显著减小了首包体积,提升了首屏渲染速度。
3.2 加载状态的优雅处理
React.lazy 的动态导入是异步的,网络延迟不可避免。如果直接展示白屏,用户体验极差。
因此,<Suspense fallback={<LoadingFallback />}> 的作用至关重要。LoadingFallback 组件(如骨架屏或加载动画)作为“占位符”,在组件加载完成前提供视觉反馈。这是提升用户体验的微小但关键的细节。
🚨 4. 容错与边界处理:NotFound 的自动化
对于无效的 URL,我们需要一个“守门员”。我配置了通配符路由 * 来捕获所有未匹配的请求。
// NotFound.jsx
const NotFound = () => {
let navigate = useNavigate();
useEffect(() => {
// 6秒后自动跳回首页,防止用户迷失
setTimeout(() => { navigate('/') }, 6000)
}, []);
return <> 404 Not Found </>
}
这种自动化的跳转策略,比单纯展示一个死板的 404 页面更加人性化,能有效挽留因误操作而流失的用户。
🔮 5. 结语:全栈视角下的路由未来
路由配置的完成,标志着前端骨架的搭建完毕。从 BrowserRouter 的部署考量,到 ProtectRoute 的逻辑复用,再到 React.lazy 的性能优化,每一个细节都体现了工程化的思维。
站在这个基石上,我们已经可以看到后端 NestJS 框架的轮廓,以及 AI 模型接入的无限可能。未来的路由或许不仅仅是页面的跳转,它可能结合 AI 能力,根据用户的意图动态生成内容或调整导航路径。
全栈之路,始于足下,路由为引,未来可期。
Vercel 团队 10 年 React 性能优化经验:10 大核心策略让性能提升 300%
Vercel 最近发布了 React 最佳实践库,将十余年来积累的 React 和 Next.js 优化经验整合到了一个指南中。
其中一共包含8 个类别、40 多条规则。
这些原则并不是纸上谈兵,而是 Vercel 团队在 10 余年从无数生产代码库中总结出的经验之谈。它们已经被无数成功案例验证,能切实改善用户体验和业务指标。
以下将是对你的 React 和 Next.js 项目影响最大的 10 大实践。
1. 将独立的异步操作并行
请求瀑布流是 React 应用性能的头号杀手。
每次顺序执行 await 都会增加网络延迟,消除它们可以带来最大的性能提升。
❌ 错误:
async function Page() {
const user = await fetchUser();
const posts = await fetchPosts();
return <Dashboard user={user} posts={posts} />;
}
✅ 正确:
async function Page() {
const [user, posts] = await Promise.all([fetchUser(), fetchPosts()]);
return <Dashboard user={user} posts={posts} />;
}
当处理多个数据源时,这个简单的改变可以将页面加载时间减少数百毫秒。
![]()
2. 避免桶文件导入
从桶文件导入会强制打包程序解析整个库,即使你只需要其中一个组件。
这就像把整个衣柜都搬走,只为了穿一件衣服。
❌ 错误:
import { Check, X, Menu } from "lucide-react";
✅ 正确:
import Check from "lucide-react/dist/esm/icons/check";
import X from "lucide-react/dist/esm/icons/x";
import Menu from "lucide-react/dist/esm/icons/menu";
更好的方式(使用 Next.js 配置):
// next.config.js
module.exports = {
experimental: {
optimizePackageImports: ["lucide-react", "@mui/material"],
},
};
// 然后保持简洁的导入方式
import { Check, X, Menu } from "lucide-react";
直接导入可将启动速度提高 15-70%,构建难度降低 28%,冷启动速度提高 40%,HMR 速度显著提高。
![]()
3. 使用延迟状态初始化
当初始化状态需要进行耗时的计算时,将初始化程序包装在一个函数中,确保它只运行一次。
❌ 错误:
function Component() {
const [config, setConfig] = useState(JSON.parse(localStorage.getItem("config")));
return <div>{config.theme}</div>;
}
✅ 正确:
function Component() {
const [config, setConfig] = useState(() => JSON.parse(localStorage.getItem("config")));
return <div>{config.theme}</div>;
}
组件每次渲染都会从 localStorage 解析 JSON 配置,但其实它只需要在初始化的时候读取一次,将其封装在回调函数中可以消除这种浪费。
![]()
4. 最小化 RSC 边界的数据传递
React 服务端/客户端边界会将所有对象属性序列化为字符串并嵌入到 HTML 响应中,这会直接影响页面大小和加载时间。
❌ 错误:
async function Page() {
const user = await fetchUser(); // 50 fields
return <Profile user={user} />;
}
("use client");
function Profile({ user }) {
return <div>{user.name}</div>; // uses 1 field
}
✅ 正确:
async function Page() {
const user = await fetchUser();
return <Profile name={user.name} />;
}
("use client");
function Profile({ name }) {
return <div>{name}</div>;
}
只传递客户端组件实际需要的数据。
![]()
5. 动态导入大型组件
仅在功能激活时加载大型库,减少初始包体积。
❌ 错误:
import { AnimationPlayer } from "./heavy-animation-lib";
function Component() {
const [enabled, setEnabled] = useState(false);
return enabled ? <AnimationPlayer /> : null;
}
✅ 正确:
function AnimationPlayer({ enabled, setEnabled }) {
const [frames, setFrames] = useState(null);
useEffect(() => {
if (enabled && !frames && typeof window !== "undefined") {
import("./animation-frames.js").then((mod) => setFrames(mod.frames)).catch(() => setEnabled(false));
}
}, [enabled, frames, setEnabled]);
if (!frames) return <Skeleton />;
return <Canvas frames={frames} />;
}
typeof window 可以防止将此模块打包用于 SSR,优化服务端包体积和构建速度。
![]()
6. 延迟加载第三方脚本
分析和跟踪脚本不要阻塞用户交互。
❌ 错误:
export default function RootLayout({ children }) {
useEffect(() => {
initAnalytics();
}, []);
return (
<html>
<body>{children}</body>
</html>
);
}
✅ 正确:
import { Analytics } from "@vercel/analytics/react";
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
);
}
在水合后加载分析脚本,优先处理交互内容。
![]()
7. 使用 React.cache() 进行请求去重
防止服务端在同一渲染周期内重复请求。
❌ 错误:
async function Sidebar() {
const user = await fetchUser();
return <div>{user.name}</div>;
}
async function Header() {
const user = await fetchUser(); // 重复请求
return <nav>{user.email}</nav>;
}
✅ 正确:
import { cache } from "react";
const getUser = cache(async () => {
return await fetchUser();
});
async function Sidebar() {
const user = await getUser();
return <div>{user.name}</div>;
}
async function Header() {
const user = await getUser(); // 已缓存,无重复请求
return <nav>{user.email}</nav>;
}
![]()
8. 实现跨请求数据的 LRU 缓存
React.cache() 仅在单个请求内有效,因此对于跨连续请求共享的数据,使用 LRU 缓存。
❌ 错误:
import { LRUCache } from "lru-cache";
const cache = new LRUCache({
max: 1000,
ttl: 5 * 60 * 1000, // 5 分钟
});
export async function getUser(id) {
const cached = cache.get(id);
if (cached) return cached;
const user = await db.user.findUnique({ where: { id } });
cache.set(id, user);
return user;
}
这在 Vercel 的 Fluid Compute 中特别有效,多个并发请求共享同一个函数实例。
9. 通过组件组合实现并行化
React 服务端组件在树状结构中按顺序执行,因此需要使用组合对组件树进行重构以实现并行化数据获取:
❌ 错误:
async function Page() {
const data = await fetchPageData();
return (
<>
<Header />
<Sidebar data={data} />
</>
);
}
✅ 正确:
async function Page() {
return (
<>
<Header />
<Sidebar />
</>
);
}
async function Sidebar() {
const data = await fetchPageData();
return <div>{data.content}</div>;
}
这样一来,页眉和侧边栏就可以并行获取数据了。
10. 使用 SWR 进行客户端请求去重
当客户端上的多个组件请求相同的数据时,SWR 会自动对请求进行去重。
❌ 错误:
function UserProfile() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch("/api/user")
.then((r) => r.json())
.then(setUser);
}, []);
return <div>{user?.name}</div>;
}
function UserAvatar() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch("/api/user")
.then((r) => r.json())
.then(setUser);
}, []);
return <img src={user?.avatar} />;
}
✅ 正确:
import useSWR from "swr";
const fetcher = (url) => fetch(url).then((r) => r.json());
function UserProfile() {
const { data: user } = useSWR("/api/user", fetcher);
return <div>{user?.name}</div>;
}
function UserAvatar() {
const { data: user } = useSWR("/api/user", fetcher);
return <img src={user?.avatar} />;
}
SWR 只发出一个请求,并将结果在两个组件之间共享。
11. 总结
这些最佳实践的美妙之处在于:它们不是复杂的架构变更。大多数都是简单的代码修改,却能产生显著的性能改进。
一个 600ms 的瀑布等待时间,会影响每一位用户,直到被修复。
一个桶文件导入造成的包膨胀,会减慢每一次构建和每一次页面加载。
所以越早采用这些实践,就能避免积累越来越多的性能债务。
![]()
现在开始应用这些技巧,让你的 React 应用快如闪电吧!
我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。
欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。
Dart - 全面认识Stream
第一章:Flutter 进阶——为什么你需要 Stream?从 Future 到流的思维跃迁
在 Flutter 和 Dart 的异步编程世界里,大多数开发者都是从 Future 开始入门的。我们习惯了 await 一个网络请求,然后等待结果返回。
但是,当你试图实现一个“倒计时”、“文件下载进度条”或者“实时聊天室”时,你会发现 Future 变得力不从心。这时候,你就需要升级你的武器库,引入一个更强大的概念 —— Stream (流)。
本章我们将不再仅仅罗列 API,而是从内存和执行原理的角度,解剖 Stream 到底是什么,以及它为什么被称为“异步数据的生命之河”。
一、 Future 的“一锤子买卖”
先来看一段我们最熟悉的 Future 代码:
Future<String> fetchUser() async {
await Future.delayed(Duration(seconds: 2));
return "Jason"; // 任务结束,返回结果
}
Future 的设计哲学非常简单:一次请求,一次响应。
它就像是网购。你下个单(调用函数),过几天快递员给你一个包裹(返回值)。交易完成后,你和快递员的关系就结束了。
在底层内存模型中,当你执行 return "Jason" 的那一瞬间,发生了两件事:
- 结果交付:数据被发送给了等待者。
-
现场销毁:
fetchUser函数的栈帧 (Stack Frame) 被弹出、销毁。这个函数“死”了,它的生命周期彻底结束。
痛点来了: 如果你想要的是“连续的数据”呢? 比如,你不仅想知道“文件下载完了没有”,还想知道“现在下载了百分之几”。
- 如果你用
Future,你只能得到一张下载完成时的截图。 - 但你真正想要的是一段录像。
这时候,我们需要一个能“活着”并在时间轴上源源不断吐出数据的机制。
二、 Stream:时间轴上的传送带
如果说 Future 是静态的点,那么 Stream 就是动态的线。
你可以把 Stream 想象成回转寿司店里的一条自动传送带。
在这个模型里,有三个核心角色:
- Sink (入口/厨师):这是生产端。厨师(生成器)把一盘盘寿司(数据)按顺序放上传送带。
- Stream (管道):这是传送带本身。它负责搬运数据,它不关心谁吃,只负责转动。
- Listener (出口/食客):这是消费端。你坐在传送带末端,监听 (Listen) 着它。来一盘,你吃一盘。
与 Future 不同,Stream 是一种 异步的 Iterable (Asynchronous Iterable)。它代表的不是“一个值”,而是“可能随时间推移而到达的一系列值”。
三、 语法上的“基因突变”:async* 与 yield
为了支持这种“源源不断”的特性,Dart 在语法层面做了一个极具深意的设计。
我们要重点关注两个关键字:async* 和 yield。
1. 那个神秘的星号 (*)
你可能注意到了,Stream 函数的定义后面必须带一个 *:
// Future: 单数
Future<int> getScore() async { ... }
// Stream: 复数(生成器)
Stream<int> getScores() async* { ... }
这个星号 * 代表 Generator (生成器)。在计算机科学中,它暗示着“多”和“生产能力”。它告诉编译器:“嗨,这个函数有点特殊,它不会跑一遍就死掉,它是一个状态机。”
2. yield vs return:暂停与销毁
这是理解 Stream 底层原理最关键的一步。请看这段代码:
Stream<int> countDown() async* {
for (int i = 5; i > 0; i--) {
await Future.delayed(Duration(seconds: 1));
yield i; // <--- 关键看这里!
}
}
-
return(辞职): 当普通函数执行return时,它是彻底退出。它的栈帧被销毁,局部变量全部清空。下次再调用,一切从头开始。 -
yield(停薪留职/暂停): 当生成器函数执行yield i时,它做的是 “交出数据,原地暂停”。 -
交出数据:把
i扔进事件循环,发给监听者。 -
保留现场:关键点! 此时函数的栈帧并没有被销毁!当前的局部变量
i的值、代码执行到了第几行,通通被“冻结”在内存里。 -
恢复执行:当函数再次被唤醒时,它会从
yield的下一行继续执行,仿佛从未中断过。
正是因为有了 yield 这种**“保留状态”**的能力,Stream 才能做到记住了循环到了哪里,从而源源不断地产生数据。
四、 为什么我们需要 Stream?
既然 Future 简单好用,为什么还要折腾 Stream?
1. 解决“过程”问题 现实世界的交互往往是连续的。
- 倒计时:5, 4, 3, 2, 1...
- 搜索联想:你输一个字母,推荐列表变一次。
-
WebSocket:服务器随时可能推一条新消息过来。
这些场景,用
Future这种“一次性承诺”是无法优雅实现的,必须用Stream。
2. 变“主动轮询”为“被动响应”
- 传统方式 (Pull):你不停地问服务器“好了没?好了没?”(轮询),浪费资源。
-
Stream 方式 (Push):你注册一个监听器
listen(),然后去干别的事。一旦有数据,Stream 会主动推给你。这也就是现在流行的 响应式编程 (Reactive Programming) 的核心思想。
小结
| 特性 | Future (未来) | Stream (流) |
|---|---|---|
| 数据量 | 单个值 (Single) | 多个值 (Multiple) |
| 生命周期 | 一次性 (One-shot) | 持续的时间轴 (Continuous) |
| 结束动作 | Return (销毁) | Stream Done (关闭) |
| 核心机制 | 栈帧销毁 | 栈帧暂停 (yield) |
| 生活类比 | 拍一张照片 | 录一段视频 |
理解了 Stream 的传送带模型和 yield 的暂停机制,你就已经迈过了异步编程最难的一道坎。
但是,现在的传送带还很简陋。如果我想让多个人同时看一条传送带(多订阅)?或者我想在传送带中间加一个滤网,只过滤出我想要的寿司(操作符)?
下一章,我们将深入探讨 Stream 的两种形态:单订阅 (Single-subscription) 与 广播 (Broadcast)。
第二章:Flutter 进阶——Stream 的两种形态与掌控权
在上一章中,我们用 async* 函数轻松创建了一条传送带。
但是,当你试图在代码中对同一个 Stream 调用两次 listen 时,程序会毫不留情地抛出一个异常:
Bad state: Stream has already been listened to.
这并不是 Bug,这是 Dart Stream 设计哲学的核心:根据消费场景的不同,Stream 分为两种截然不同的形态。
本章我们将深入探讨 单订阅 (Single-subscription) 与 广播 (Broadcast) 的区别,并解锁 Stream 的手动挡模式 —— StreamController。
一、 私密对话 vs 公共广播
在内存世界里,数据的流动方式决定了 Stream 的类型。
1. 单订阅 Stream (Single-subscription) —— “我的汉堡”
这是 Stream 的默认形态。当你使用 async* 或者 File.openRead() 创建流时,它就是单订阅的。
- 特点:一对一。这条传送带是为你专门铺设的。
- 形象比喻:“在餐厅点餐”。 厨师为你做了一份炒饭。这份炒饭(数据)只能被你一个人吃(消费)。如果你的朋友也想吃,他必须重新下一单(创建一个新的 Stream ),厨师会重新做一份。
- 底层逻辑: 数据是为了保证完整性和顺序性。比如读取文件,你绝不希望两个人在同时读一个文件流,导致你读一半,他读一半,数据全乱套了。
- 致命限制:只能监听一次! 即使第一个监听者取消了订阅 (cancel),这条 Stream 也废了,不能再被监听。
2. 广播 Stream (Broadcast) —— “村口大喇叭”
这是 Stream 的另一种形态。通常用于事件总线、鼠标点击、系统通知等场景。
- 特点:一对多。
- 形象比喻:“听收音机”。 电台(数据源)在不停地播放。你听,或者隔壁老王听,甚至一百个人同时听,互不影响。
- 关键差异:
- 过时不候:广播流通常是 "Hot" (热) 的。如果你 10:00 打开收音机,你听不到 9:50 播放的新闻。数据发出去没人听,就直接丢弃了。
- 随时监听:你可以随时加入,也可以随时退出,支持多个监听者同时存在。
3. 代码实战:如何转换?
如果我非要让那盘“炒饭”大家一起吃怎么办?Dart 提供了 asBroadcastStream() 方法。
// 1. 创建一个普通的单订阅流
Stream<int> stream = getScoreStream();
// 2. 强行变成广播流
Stream<int> broadcastStream = stream.asBroadcastStream();
// 3. 现在可以多次监听了
broadcastStream.listen((v) => print("老王听到了: $v"));
broadcastStream.listen((v) => print("小李听到了: $v"));
二、 手动挡:StreamController
到目前为止,我们都是通过 async* 函数来**“自动”**生成 Stream。这种方式很简单,但它是被动的——必须等到函数里的 yield 执行时才有数据。
如果我们想在一个按钮点击事件里发送数据?或者在网络请求回调里发送数据? 这时候,我们需要 StreamController (流控制器)。
如果说 async* 是设定好程序的自动流水线,那 StreamController 就是一个万能遥控器。
1. 结构解剖
StreamController 把 Stream 的结构拆解得清清楚楚:
-
入口 (Sink):你可以随时随地调用
sink.add(data)往里面扔数据。 -
出口 (Stream):就是我们熟悉的那个 Stream,给别人去
listen的。 - 控制器 (Controller):管理开关、暂停、以及流的状态。
2. 极简代码示范
import 'dart:async';
void main() {
// 1. 创建控制器 (买了一个遥控器)
// 如果想做广播流,就用 StreamController.broadcast();
final controller = StreamController<String>();
// 2. 拿到出口 (给别人听的)
controller.stream.listen(
(data) => print("收到推流: $data"),
onError: (err) => print("发生错误: $err"),
onDone: () => print("直播结束"),
);
// 3. 拿到入口 (自己在任意地方控制)
print("准备发射数据...");
controller.sink.add("第一条消息"); // 像不像 EventBus?
controller.sink.add("第二条消息");
// 4. 模拟发生错误
controller.addError("信号丢失!");
// 5. 关流 (非常重要!!!)
// 不关流会导致内存泄漏,因为监听者会一直干等着
controller.close();
}
3. 为什么它在 Flutter 中如此重要?
几乎所有 Flutter 的状态管理库(BLoC, Provider, Riverpod 等)的底层,或多或少都用到了 StreamController 的思想。
-
UI 层:只管
add事件(比如点击按钮)。 -
逻辑层:通过
Controller处理业务。 -
UI 层:
StreamBuilder监听Controller.stream并刷新界面。
这就是 “输入与输出分离” 的架构雏形。
三、 避坑指南:内存泄漏的隐患
在使用 StreamController 时,有一个新手最容易犯的错误:忘了关流 (Close)。
-
原理:
StreamController在底层会持有监听者的引用。如果你的页面销毁了,但 Controller 没关闭,这个 Stream 依然认为“有人在听”,它不会释放资源,导致 内存泄漏 (Memory Leak)。 -
铁律:在 Flutter 的
dispose()方法中,一定要调用controller.close()。
小结
这一章我们完成了从“使用者”到“掌控者”的转变:
- 分清形态:
- 单订阅(默认):数据完整,一对一,错过即毁。
- 广播(Broadcast):实时性强,一对多,过时不候。
- 掌握控制:
- 使用
StreamController可以让我们在代码的任何地方主动地“推”数据,它是连接命令式代码(普通函数)和响应式代码(Stream)的桥梁。
了解了形态和控制,下一章我们将进入 Stream 最强大的领域 —— 数学般的魔法。
我们将探索如何像操作数组一样操作时间流:map、where、debounce(防抖)以及 distinct。这些操作符将彻底改变你写业务逻辑的方式。
第三章:流上建造流水线
在上一章,我们学会了用 StreamController 制造传送带。但在真实开发中,原始数据往往是“脏”的或者“不符合 UI 胃口”的。
- 后端:推给你一堆 JSON 字符串。
-
UI层:想要的是一个转换好的
User对象。 - 用户:手抖,一秒钟点了 5 次按钮。
- 逻辑层:只希望处理最后一次点击。
如果把这些逻辑都写在 listen 的回调里,代码会变成一坨乱麻。
Dart Stream 赋予了我们一种能力:在数据到达监听者之前,先在传送带上架设一排“机器手臂”,对数据进行全自动加工。
这就是 操作符 (Operators)。
一、 熟悉的配方:从 List 到 Stream
Dart 最优雅的设计之一,就是它让操作 Stream (时间流) 就像操作 List (静态数组) 一样简单。
如果你会用 List 的方法,你已经学会了 90% 的 Stream 操作。
1. 过滤与转换 (Where & Map)
想象传送带上流过来的是一堆数字 1, 2, 3, 4, 5...。
- 需求:我只想要偶数,而且要把它放大 10 倍。
Stream<int> rawStream = Stream.fromIterable([1, 2, 3, 4, 5]);
rawStream
.where((event) => event % 2 == 0) // 机器手臂1:过滤。只放行偶数。
.map((event) => event * 10) // 机器手臂2:加工。变成原来的10倍。
.listen((data) {
print(data); // 输出:20, 40
});
底层原理:
每个操作符(.where, .map)本质上都返回了一个新的 Stream。
这就像接水管一样,我们把一节节短管子(操作符)拧在一起,构成了一条长长的处理管道。原始数据从一头进,经过层层净化,最后流出来的就是我们想要的纯净水。
二、 解决现实痛点:那些 Stream 独有的神技
除了通用的 map/where,Stream 还有一些专门处理“时间轴”问题的神技。
1. 去重神技:distinct
- 场景:你要实现一个搜索框。用户想搜 "Flutter",但他输入 "F", "Fl", "Flu"...
- 痛点:如果用户输入了 "Flu",停了一下,删掉 "u",又输了一次 "u"。输入内容还是 "Flu"。如果不处理,你会发两次完全一样的网络请求。
- 解法:
inputStream
.distinct() // 只有当新数据和上一次数据不一样时,才放行
.listen((text) => search(text));
它就像一个极其严格的质检员,拿着上一个通过的产品做对比,一样的直接扔掉。
2. 扁平化神技:expand 与 asyncExpand
这是一个高级但必用的操作符。
- 场景:Stream 里流过来的是“文件夹”,但监听者想要的是“文件”。 即:Stream 发出的每个数据,本身又包含了一组数据(Stream of List)。
-
解法:
expand会把“流过来的每一个元素”炸开,变成一堆元素,然后重新铺平在传送带上。
// 假设流过来的是:[1, 2], [3, 4]
stream
.expand((element) => element)
.listen(print);
// 输出:1, 2, 3, 4 (变成了扁平的流)
三、 终极武器:StreamTransformer
有时候,官方提供的 map、where 不够用了。
比如,Socket 连接发过来的是字节流 (List<int>),但你想按**“换行符”切分成一行行的文本流 (String)**。
这时候,你需要自定义一个“变压器” —— StreamTransformer。
它是 stream.transform() 方法的参数。Dart 官方贴心地在 dart:convert 库里内置了一些最常用的变压器:
import 'dart:convert';
import 'dart:io';
void readFile() {
File('log.txt')
.openRead() // 原始流:一堆二进制字节
.transform(utf8.decoder) // 变压器1:字节 -> 字符串
.transform(const LineSplitter()) // 变压器2:一整块字符串 -> 按换行符切开的一行行字符串
.listen((line) {
print("读取到一行日志: $line");
});
}
底层逻辑:
transform 是将流的控制权完全交给你。你可以控制输入什么,缓存多少,什么时候输出,甚至可以把一个数据变成两个,或者把两个数据合并成一个。
四、 降维打击:RxDart 的防抖与节流
讲到 Stream 操作符,如果不提 RxDart,那就是耍流氓。 虽然 Dart 原生库很强,但在处理复杂的交互事件时,RxDart 提供了“外挂”级别的操作符。
Flutter 面试必问的两大杀手锏:
- 防抖 (Debounce):
- 比喻:电梯关门。如果一直有人进电梯(事件一直来),电梯门就一直不关。只有当大家都不动了(间隔超过一定时间),电梯门才会关上(执行逻辑)。
- 用途:搜索框联想。打字停顿 500ms 后再请求 API。
- 节流 (Throttle):
- 比喻:机关枪射速限制。不管你扣扳机的手速有多快,子弹最快只能每秒发 10 发。
- 用途:防止按钮连点。
(注:RxDart 本质上就是把 StreamTransformer 封装好了给你用。)
小结
这一章我们把 Stream 从“传输工具”升级成了“处理工具”。
-
管道思维:用
map、where像搭积木一样处理数据。 -
独有技能:用
distinct过滤重复信号。 -
高级定制:用
transform处理复杂的数据转换(如二进制转文本)。
现在,我们有了数据源(Controller),有了处理逻辑(Operators),有了监听者(Listen)。
但是,在 Flutter 的 UI 代码里写 listen 和 setState 依然很痛苦,很容易忘掉 cancel 导致内存泄漏。
有没有一种 Widget,能直接把 Stream 插上去,它自己就会根据数据变来变去,还自动管理内存?
下一章,我们将介绍 Flutter 官方提供的终极组件 —— StreamBuilder,它是连接逻辑层与 UI 层的跨海大桥。
第四章:Flutter 实战——告别 setState,拥抱 StreamBuilder
在前面的章节中,我们在纯 Dart 环境下把 Stream 玩出了花。但当我们回到 Flutter 的 Widget 世界时,会遇到一个尴尬的现实。
痛点:手动管理的“地狱”
如果你不用专门的工具,想在界面上显示一个 Stream 的数据,你需要写大量的模版代码:
- 必须用
StatefulWidget。 - 在
initState里手动listen。 - 在回调里手动
setState触发刷新。 -
最要命的:必须在
dispose里手动subscription.cancel()。哪怕忘写一次,你的 App 就会在后台默默发生内存泄漏,直到崩溃。
为了把开发者从这种重复劳动中解救出来,Flutter 提供了一个终极组件 —— StreamBuilder。
一、 什么是 StreamBuilder?
StreamBuilder 是一个 Widget,但它不画任何东西。它的唯一工作就是 “自动帮你看传送带”。
-
自动化:它负责
listen,它负责setState,它负责dispose。你完全不用管。 -
响应式:传送带上每过来一个新数据,它就自动调用一次
builder方法,重新画一遍子组件。
二、 代码实战:一个最简单的电子表
我们来做一个每秒更新时间的电子表。
1. 准备 Stream(数据源)
Stream<String> getTimerStream() async* {
while (true) {
await Future.delayed(Duration(seconds: 1));
yield DateTime.now().toString().substring(11, 19); // 返回 "12:00:01"
}
}
2. 使用 StreamBuilder(UI 构建)
class MyClock extends StatelessWidget { // 注意:可以用 StatelessWidget 了!
final Stream<String> _timerStream = getTimerStream();
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: StreamBuilder<String>(
stream: _timerStream, // 1. 插上网线
builder: (context, snapshot) { // 2. 根据快照画图
// snapshot 包含了当前时刻 Stream 的所有信息
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator(); // 还没数据时显示转圈
}
if (snapshot.hasError) {
return Text('出错了: ${snapshot.error}');
}
// 有数据了!
return Text(
snapshot.data ?? '无数据',
style: TextStyle(fontSize: 30),
);
},
),
),
);
}
}
看,我们甚至不需要 StatefulWidget!所有的状态变化都封装在了 StreamBuilder 内部。
三、 解剖核心:AsyncSnapshot (快照)
builder 回调函数里那个 snapshot 参数,是理解 StreamBuilder 的关键。
你可以把它想象成 “Stream 在这一瞬间的体检报告”。它包含三个核心指标:
- ConnectionState (连接状态):
-
none: 没插网线(stream 为 null)。 -
waiting: 插了网线,但第一个数据还没来(通常显示 Loading)。 -
active: 数据正在源源不断地来(最主要的状态)。 -
done: 传送带停了(Stream 关闭)。
- data (数据):
- 如果不为 null,说明这就是最新拿到的数据。
- error (错误):
- 如果不为 null,说明刚才流里传来了一个错误事件。
最佳实践写法:
不要只写一个 return Text(...),一定要养成习惯处理三种状态:加载中、错误、正常显示。
builder: (context, snapshot) {
if (snapshot.hasError) return ErrorWidget();
switch (snapshot.connectionState) {
case ConnectionState.waiting: return LoadingWidget();
case ConnectionState.active:
case ConnectionState.done:
return DataWidget(snapshot.data);
default: return SizedBox();
}
}
四、 新手必踩的超级大坑
在使用 StreamBuilder 时,90% 的新手会犯同一个错误:在 build 方法里创建 Stream。
❌ 错误示范:
@override
Widget build(BuildContext context) {
return StreamBuilder(
// 错!每次父组件刷新,build 都会跑一遍
// 这一行就会创建一个全新的 Stream!
stream: createMyStream(),
builder: ...
);
}
💥 后果:
每次你的界面刷新(比如键盘弹起、父组件 setState),createMyStream() 就会重新执行。
这就意味着:原本的连接断开了,一个新的连接建立了。
你会看到 Loading 转圈圈无限闪烁,或者倒计时明明走到 5 了,突然又变回 10 重新开始。
✅ 正确姿势:
Stream 实例的创建必须在 build 方法之外。
- 如果是
StatefulWidget,在initState里创建。 - 如果是 BLoC/Provider 模式,Stream 应该由业务逻辑类提供,UI 只负责引用。
小结
这一章我们见证了 Stream 与 Flutter UI 的完美融合。
- StreamBuilder 是连接逻辑层与 UI 层的万能适配器。
- AsyncSnapshot 是携带数据的快递盒,我们要学会检查盒子的状态(Waiting/Active/Error)。
-
铁律:永远不要在
build方法里创建 Stream,那是“一次性筷子”,用完就丢,会导致状态重置。
到这里,关于 Stream 的基础、进阶和 UI 实战我们都讲完了。
但是,如果你正在开发一个中大型 APP,你会发现光有 Stream 还是不够。你需要一种架构模式,把 Stream 组织起来,让代码井井有条。 这就是 Flutter 官方推荐的 —— BLoC (Business Logic Component) 模式。
第五章:Flutter 实战——BLoC 模式,给你的代码定规矩
经过前四章的学习,你手中已经握有了强大的武器:Stream,并且学会了 Stream 的所有招式(创建、变形、消费),是时候把它们组合成一套绝世武功了。
但你可能会发现一个新的问题:武器太灵活了,容易误伤自己。
如果你在 UI Widget 里随便创建 Controller,在 build 方法里随意处理数据,很快你的代码就会变成一碗“意大利面”——逻辑和 UI 纠缠不清,难以维护,难以测试。
为了解决这个问题,Flutter 社区诞生了一种基于 Stream 的架构模式:BLoC (Business Logic Component)。
它的核心思想只有一句话:让 UI 只是 UI,让逻辑只是逻辑,两者通过 Stream 对话。
一、 BLoC 的“黑盒模型”
把 BLoC 想象成一台自动售货机。
- 输入 (Input):你按下一个按钮(比如“购买可乐”)。这在 BLoC 里叫 Event (事件)。
- 黑盒 (Processing):机器内部听到指令,检查库存,扣除余额,驱动机械臂。这就是 Business Logic (业务逻辑)。
- 输出 (Output):机器吐出一听可乐,或者显示“余额不足”。这在 BLoC 里叫 State (状态)。
关键规则:
- UI 组件(Widget)绝对不允许直接修改数据。
- UI 只能做一件事:往 BLoC 的 Sink 里扔事件。
- UI 只能听一件事:听 BLoC 的 Stream 里流出来的状态。
二、 手写一个纯粹的 BLoC
在引入第三方库之前,我们先用原生 Dart 代码写一个 BLoC,你会发现它本质上就是我们第二章学的 StreamController 的封装。
我们来重构之前的“电子表”或“计数器”。
1. 定义 BLoC 类 (逻辑层)
import 'dart:async';
class CounterBloc {
// 1. 状态流控制器 (Output):告诉 UI 当前是几
// 使用广播流,允许多个页面同时监听
final _stateController = StreamController<int>.broadcast();
int _count = 0;
// 2. 暴露给外部的 Stream (只读)
Stream<int> get stream => _stateController.stream;
// 3. 事件入口 (Input):UI 只能调这个方法
void increment() {
_count++;
// 逻辑处理完,把新状态推出去
_stateController.sink.add(_count);
}
// 4. 资源释放
void dispose() {
_stateController.close();
}
}
2. 在 UI 中使用 (视图层)
class CounterPage extends StatefulWidget {
@override
_CounterPageState createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
final bloc = CounterBloc(); // 创建 BLoC
@override
void dispose() {
bloc.dispose(); // 记得关流
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("BLoC 模式")),
body: Center(
// 使用 StreamBuilder 监听 BLoC 的输出
child: StreamBuilder<int>(
stream: bloc.stream,
initialData: 0,
builder: (context, snapshot) {
return Text(
'${snapshot.data}',
style: TextStyle(fontSize: 40)
);
},
),
),
floatingActionButton: FloatingActionButton(
// UI 只负责触发动作
onPressed: () => bloc.increment(),
child: Icon(Icons.add),
),
);
}
}
看,代码清爽多了!
-
build方法里没有任何_count++这样的逻辑。 - 逻辑代码全在
CounterBloc里,你可以不依赖 Flutter UI 直接对CounterBloc写单元测试。
三、 进阶:为什么要用 flutter_bloc 库?
虽然手写 BLoC 帮我们理清了原理,但实际开发中,手动管理 StreamController 的关闭、手动定义 Sink 和 Stream 还是太繁琐了。
于是,大神 Felix Angelov 开源了 flutter_bloc 库,它把这套流程标准化了。
在 flutter_bloc 库中:
- 不再需要手动写 Controller:库帮你封装好了。
- 强制的 Event/State 定义:你必须先定义好所有的“动作”和“结果”,强制代码规范。
-
BlocBuilder:它就是
StreamBuilder的亲儿子,专门用来简化 BLoC 的监听。
四、 BLoC 的哲学意义
学习 BLoC,实际上是在学习一种 “单向数据流” (Unidirectional Data Flow) 的思想。
- 没有 BLoC 时:数据满天飞,A 组件改了 B 的数据,C 组件又回调了 A 的方法。出了 Bug 根本找不到源头。
- 有了 BLoC 后:
- 数据永远是 从上往下流 (Stream)。
- 事件永远是 从下往上发 (Sink)。
- 形成了一个完美的闭环。
五、 全剧终:Stream 学习之路
恭喜你!从第一章的 Future 单次请求,到 Stream 的传送带模型,再到 StreamController 的手动控制,最后上升到 BLoC 的架构模式。
你已经走完了 Dart 异步编程最核心的旅程。
回顾一下我们的成就:
-
底层原理:你懂了
yield暂停机制,知道了异步不是魔法,是状态机的切换。 - 内存模型:你分清了单订阅和广播,知道如何避免内存泄漏。
-
工具箱:你掌握了
map,where,debounce等操作符,能像做手术一样处理数据。 - 架构思维:你学会了用 Stream 将 UI 和逻辑彻底分离。
20个例子掌握RxJS——第十三章使用 interval 和 scan 实现定时器
RxJS 实战:使用 interval 和 scan 实现定时器
概述
定时器是一个常见的功能,用于测量经过的时间。在 Web 开发中,我们经常需要实现秒表、倒计时等功能。本章将介绍如何使用 RxJS 的 interval、scan 和 takeUntil 操作符实现一个功能完整的定时器。
定时器的基本概念
定时器用于测量从某个时间点开始经过的时间。常见的定时器场景包括:
- 秒表功能:测量经过的时间
- 倒计时器:从指定时间倒计时到 0
- 任务计时:记录任务执行时间
- 游戏计时:游戏中的计时功能
为什么使用 RxJS?
使用 RxJS 实现定时器有以下优势:
- 响应式编程:使用 Observable 流处理时间,代码更清晰
- 易于控制:可以轻松实现开始、暂停、重置等功能
-
自动清理:使用
takeUntil可以优雅地取消订阅 - 组合性强:可以轻松与其他 RxJS 操作符组合
核心操作符
1. interval
interval 创建一个按固定时间间隔发出递增数字的 Observable。
interval(1000) // 每秒发出一个值:0, 1, 2, 3...
2. scan
scan 类似数组的 reduce,但会发出每次累加的结果。
scan((acc, value) => acc + value, 0)
// 输入:0, 1, 2, 3...
// 输出:0, 1, 3, 6, 10...
3. startWith
startWith 在 Observable 开始前发出指定的值。
interval(1000).pipe(startWith(0))
// 立即发出 0,然后每秒发出 1, 2, 3...
4. takeUntil
takeUntil 当另一个 Observable 发出值时,完成当前 Observable。
interval(1000).pipe(takeUntil(stop$))
// 当 stop$ 发出值时,停止发出值
实战场景:实现一个秒表
假设我们需要实现一个秒表,具有开始、暂停、重置功能。
实现思路
- 使用
interval(1000)每秒发出一个值 - 使用
startWith(0)立即开始 - 使用
scan累加时间 - 使用
takeUntil控制停止、暂停和重置
核心代码
// 定时器状态
isRunning = false;
currentTime = 0;
// 销毁 Subject
private destroySubject$ = new Subject<void>();
// 暂停/继续控制 Subject
private pauseSubject$ = new Subject<void>();
// 重置控制 Subject
private resetSubject$ = new Subject<void>();
// 开始定时器
private startTimer(): void {
if (this.isRunning) {
return;
}
this.isRunning = true;
// 使用 interval(1000) 每秒发出一个值
// 使用 scan 累加时间
// 使用 startWith 从当前时间开始
// 使用 takeUntil 控制停止
interval(1000)
.pipe(
startWith(0),
scan((acc) => acc + 1, this.currentTime),
takeUntil(this.destroySubject$),
takeUntil(this.pauseSubject$),
takeUntil(this.resetSubject$)
)
.subscribe({
next: (time) => {
this.currentTime = time;
this.cdr.detectChanges();
},
complete: () => {
// 如果是暂停,保持状态
if (!this.pauseSubject$.closed) {
this.isRunning = false;
this.cdr.detectChanges();
}
}
});
}
// 暂停定时器
private pauseTimer(): void {
if (!this.isRunning) {
return;
}
this.pauseSubject$.next();
this.isRunning = false;
this.cdr.detectChanges();
}
// 重置定时器
resetTimer(): void {
// 如果正在运行,先停止
if (this.isRunning) {
this.pauseSubject$.next();
}
// 重置时间
this.currentTime = 0;
this.isRunning = false;
// 创建新的 pauseSubject 和 resetSubject
this.pauseSubject$ = new Subject<void>();
this.resetSubject$ = new Subject<void>();
this.cdr.detectChanges();
}
关键点解析
1. interval 的使用
interval(1000) 每秒发出一个值,从 0 开始:
- 0 秒:发出 0
- 1 秒:发出 1
- 2 秒:发出 2
- ...
2. scan 累加时间
scan((acc) => acc + 1, this.currentTime) 的作用:
- 从
this.currentTime开始累加 - 每次收到新值,累加 1
- 如果从 10 秒开始,会输出:10, 11, 12, 13...
3. startWith 的作用
startWith(0) 确保:
- 立即发出初始值,不等待第一个 interval
- 定时器可以立即开始计时
4. takeUntil 的多重控制
使用多个 takeUntil 可以灵活控制定时器的停止:
-
takeUntil(this.destroySubject$):组件销毁时停止 -
takeUntil(this.pauseSubject$):暂停时停止 -
takeUntil(this.resetSubject$):重置时停止
5. 暂停和重置的实现
暂停和重置需要创建新的 Subject,确保可以重新启动:
// 暂停后,创建新的 Subject
this.pauseSubject$ = new Subject<void>();
// 重置后,创建新的 Subject
this.resetSubject$ = new Subject<void>();
时间格式化
定时器通常需要将秒数格式化为 HH:MM:SS 格式:
formatTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return [
hours.toString().padStart(2, '0'),
minutes.toString().padStart(2, '0'),
secs.toString().padStart(2, '0')
].join(':');
}
与其他方案的对比
方案 1:使用 setInterval(不推荐)
// ❌ 不推荐:难以控制,容易导致内存泄漏
let interval: any;
let currentTime = 0;
function startTimer() {
interval = setInterval(() => {
currentTime++;
updateDisplay();
}, 1000);
}
function pauseTimer() {
clearInterval(interval);
}
function resetTimer() {
clearInterval(interval);
currentTime = 0;
updateDisplay();
}
问题:
- 需要手动管理 interval ID
- 容易忘记清理,导致内存泄漏
- 代码不够优雅
方案 2:使用 RxJS(推荐)✅
// ✅ 推荐:响应式编程,易于控制
interval(1000)
.pipe(
startWith(0),
scan((acc) => acc + 1, this.currentTime),
takeUntil(this.pauseSubject$)
)
.subscribe(time => {
this.currentTime = time;
});
优势:
- 响应式编程,代码清晰
- 自动管理订阅,避免内存泄漏
- 易于扩展和维护
高级用法
1. 倒计时器
实现从指定时间倒计时到 0:
const initialTime = 60; // 60秒倒计时
interval(1000)
.pipe(
startWith(0),
scan((acc) => acc - 1, initialTime),
takeWhile(time => time >= 0),
takeUntil(this.destroySubject$)
)
.subscribe({
next: (time) => {
this.currentTime = time;
if (time === 0) {
this.onCountdownComplete();
}
}
});
2. 多段计时
记录多个时间段:
interface TimeSegment {
id: number;
startTime: number;
endTime?: number;
duration?: number;
}
private segments: TimeSegment[] = [];
private currentSegmentId = 0;
startSegment(): void {
const segment: TimeSegment = {
id: ++this.currentSegmentId,
startTime: this.currentTime
};
this.segments.push(segment);
}
endSegment(segmentId: number): void {
const segment = this.segments.find(s => s.id === segmentId);
if (segment) {
segment.endTime = this.currentTime;
segment.duration = segment.endTime - segment.startTime;
}
}
3. 精确计时
使用更小的间隔实现更精确的计时:
// 每 100 毫秒更新一次(精确到 0.1 秒)
interval(100)
.pipe(
startWith(0),
scan((acc) => acc + 0.1, 0),
takeUntil(this.pauseSubject$)
)
.subscribe(time => {
this.currentTime = Math.round(time * 10) / 10; // 保留一位小数
});
4. 条件停止
根据条件自动停止:
interval(1000)
.pipe(
startWith(0),
scan((acc) => acc + 1, 0),
takeWhile(time => time < 60), // 60秒后自动停止
takeUntil(this.destroySubject$)
)
.subscribe({
next: (time) => {
this.currentTime = time;
},
complete: () => {
this.onTimerComplete();
}
});
实际应用场景
1. 秒表功能
// 测量经过的时间
startStopwatch(): void {
interval(1000)
.pipe(
startWith(0),
scan((acc) => acc + 1, 0),
takeUntil(this.pauseSubject$)
)
.subscribe(time => {
this.elapsedTime = time;
});
}
2. 任务计时
// 记录任务执行时间
startTaskTimer(taskId: string): void {
const startTime = Date.now();
interval(1000)
.pipe(
map(() => Math.floor((Date.now() - startTime) / 1000)),
takeUntil(this.taskComplete$)
)
.subscribe(time => {
this.taskTimes[taskId] = time;
});
}
3. 游戏计时
// 游戏中的计时功能
startGameTimer(): void {
interval(1000)
.pipe(
startWith(0),
scan((acc) => acc + 1, 0),
takeUntil(this.gameOver$)
)
.subscribe(time => {
this.gameTime = time;
this.updateGameUI();
});
}
性能优化建议
1. 使用 ChangeDetectorRef
在 Angular 中,使用 ChangeDetectorRef 手动触发变更检测,避免不必要的检查:
.subscribe({
next: (time) => {
this.currentTime = time;
this.cdr.detectChanges(); // 手动触发变更检测
}
});
2. 限制更新频率
如果不需要每秒更新,可以降低更新频率:
// 每 5 秒更新一次
interval(5000)
.pipe(
startWith(0),
scan((acc) => acc + 5, 0)
)
3. 在页面不可见时暂停
使用 Page Visibility API 在页面不可见时暂停定时器:
fromEvent(document, 'visibilitychange')
.pipe(
switchMap(() => {
if (document.hidden) {
this.pauseTimer();
return EMPTY;
} else {
// 页面可见时可以选择恢复
return EMPTY;
}
})
)
.subscribe();
注意事项
- 内存泄漏:确保在组件销毁时取消订阅
- 变更检测:在 Angular 中,可能需要手动触发变更检测
-
浏览器环境:使用
isPlatformBrowser检查,避免 SSR 问题 - 暂停和重置:需要创建新的 Subject,确保可以重新启动
-
精度问题:
interval不是完全精确的,可能受到浏览器性能影响
总结
使用 RxJS 实现定时器是一个优雅的解决方案,它通过响应式编程的方式:
- 代码清晰:使用 Observable 流处理时间,逻辑清晰
- 易于控制:可以轻松实现开始、暂停、重置等功能
-
自动清理:使用
takeUntil可以优雅地取消订阅 - 组合性强:可以轻松与其他 RxJS 操作符组合
记住:定时器是响应式编程的典型应用场景,使用 RxJS 可以让代码更加优雅和可维护。
20个例子掌握RxJS——第十二章使用 throttleTime 实现弹幕系统
RxJS 实战:使用 throttleTime 实现弹幕系统
概述
弹幕(Danmaku)是一种在视频或直播中实时显示用户评论的功能。在实现弹幕系统时,我们需要处理:
- 点击节流:用户快速点击时,限制弹幕创建频率
- 动画管理:管理弹幕的创建、动画和销毁
- 位置随机:弹幕在随机位置出现
- 性能优化:避免创建过多弹幕导致性能问题
本章将介绍如何使用 RxJS 的 throttleTime 操作符实现弹幕系统,并处理点击事件的节流。
弹幕系统的基本需求
- 输入框发送:用户输入文字后发送弹幕
- 点击触发:用户点击区域时创建随机弹幕
- 动画效果:弹幕从右到左移动
- 自动清理:弹幕动画结束后自动移除
实现思路
1. 弹幕数据结构
// 弹幕项接口
interface DanmakuItem {
id: number;
text: string;
top: number; // 弹幕的垂直位置(百分比)
color: string; // 弹幕颜色
speed: number; // 弹幕速度(秒)
}
2. 点击节流
使用 throttleTime 限制点击事件的触发频率:
// 点击节流 Subject
private clickSubject$ = new Subject<MouseEvent>();
// 销毁 Subject
private destroySubject$ = new Subject<void>();
ngOnInit(): void {
// 设置点击节流:每 300ms 最多触发一次
this.clickSubject$
.pipe(
throttleTime(300), // 节流:每 300ms 最多触发一次
takeUntil(this.destroySubject$)
)
.subscribe((event) => {
this.createDanmakuFromClick(event);
});
}
// 点击区域触发弹幕(带节流)
onDanmakuAreaClick(event: MouseEvent): void {
this.clickSubject$.next(event);
}
3. 创建弹幕
// 弹幕颜色池
private readonly colors = [
'#ffffff',
'#ff6b6b',
'#4ecdc4',
'#45b7d1',
'#f9ca24',
'#6c5ce7',
'#a29bfe',
'#fd79a8',
'#00b894',
'#e17055',
];
// 创建弹幕
private createDanmaku(text: string): void {
const danmaku: DanmakuItem = {
id: ++this.danmakuIdCounter,
text,
top: Math.random() * 80 + 10, // 10% - 90% 之间的随机位置
color: this.colors[Math.floor(Math.random() * this.colors.length)],
speed: Math.random() * 3 + 5, // 5-8 秒之间随机速度
};
this.danmakuList.push(danmaku);
this.cdr.detectChanges();
// 弹幕动画结束后移除(速度 + 0.5秒缓冲)
setTimeout(() => {
const index = this.danmakuList.findIndex((item) => item.id === danmaku.id);
if (index !== -1) {
this.danmakuList.splice(index, 1);
this.cdr.detectChanges();
}
}, (danmaku.speed + 0.5) * 1000);
}
// 从点击事件创建弹幕
private createDanmakuFromClick(event: MouseEvent): void {
const clickTexts = [
'666',
'太棒了!',
'厉害!',
'赞!',
'好!',
'不错!',
'支持!',
'加油!',
'很棒!',
'优秀!',
];
const randomText = clickTexts[Math.floor(Math.random() * clickTexts.length)];
this.createDanmaku(randomText);
}
4. 输入框发送
// 弹幕输入文字
danmakuText = '';
// 发送弹幕(从输入框)
sendDanmaku(): void {
if (!this.danmakuText.trim()) {
return;
}
this.createDanmaku(this.danmakuText.trim());
this.danmakuText = ''; // 清空输入框
}
// 回车键发送弹幕
onKeyDown(event: KeyboardEvent): void {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.sendDanmaku();
}
}
CSS 动画实现
弹幕的移动动画通过 CSS 实现:
.danmaku-item {
position: absolute;
white-space: nowrap;
font-size: 20px;
font-weight: bold;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
animation: danmaku-move linear;
pointer-events: none;
z-index: 10;
}
@keyframes danmaku-move {
from {
left: 100%;
transform: translateX(0);
}
to {
left: 0;
transform: translateX(-100%);
}
}
关键点解析
1. throttleTime 的作用
使用 throttleTime(300) 可以:
- 限制点击事件的触发频率
- 避免用户快速点击时创建过多弹幕
- 提升性能和用户体验
2. 弹幕位置随机
通过 Math.random() * 80 + 10 生成 10% - 90% 之间的随机位置,避免弹幕重叠。
3. 弹幕速度随机
通过 Math.random() * 3 + 5 生成 5-8 秒之间的随机速度,让弹幕移动更自然。
4. 自动清理
使用 setTimeout 在弹幕动画结束后自动移除,避免内存泄漏。
执行流程示例
假设用户快速点击弹幕区域:
-
0ms:用户点击 →
clickSubject$发出事件 -
0ms:
throttleTime立即处理 → 创建弹幕 A -
100ms:用户再次点击 →
clickSubject$发出事件 -
100ms:
throttleTime忽略(在 300ms 内) -
200ms:用户再次点击 →
clickSubject$发出事件 -
200ms:
throttleTime忽略(在 300ms 内) -
400ms:用户再次点击 →
clickSubject$发出事件 -
400ms:
throttleTime处理(已超过 300ms)→ 创建弹幕 B
结果:300ms 内只创建 1 个弹幕,避免过多弹幕。
与其他方案的对比
方案 1:不使用节流(有问题)
// ❌ 错误示例:快速点击会创建过多弹幕
onDanmakuAreaClick(event: MouseEvent): void {
this.createDanmakuFromClick(event); // 每次点击都创建
}
方案 2:使用防抖(不适合)
// ⚠️ 不适合:防抖会等待用户停止点击,但弹幕需要即时反馈
onDanmakuAreaClick(event: MouseEvent): void {
debounceTime(300).subscribe(() => {
this.createDanmakuFromClick(event);
});
}
方案 3:使用节流(推荐)✅
// ✅ 推荐:限制频率但保持即时反馈
this.clickSubject$.pipe(
throttleTime(300)
).subscribe(event => {
this.createDanmakuFromClick(event);
});
实际应用场景
1. 视频弹幕
// 视频播放时显示弹幕
playVideo().pipe(
switchMap(() =>
this.danmakuService.getDanmakus(videoId).pipe(
mergeMap(danmaku => {
// 根据视频时间显示弹幕
return timer(danmaku.time * 1000).pipe(
map(() => danmaku)
);
})
)
)
).subscribe(danmaku => {
this.createDanmaku(danmaku.text);
});
2. 直播弹幕
// 接收直播弹幕
this.websocketService.onMessage('danmaku').pipe(
throttleTime(100) // 限制弹幕创建频率
).subscribe(danmaku => {
this.createDanmaku(danmaku.text);
});
3. 互动游戏
// 游戏中的弹幕效果
onPlayerAction(action: string): void {
this.actionSubject$.next(action);
}
this.actionSubject$.pipe(
throttleTime(500) // 限制动作触发频率
).subscribe(action => {
this.createDanmaku(action);
});
性能优化建议
1. 限制弹幕数量
限制同时显示的弹幕数量,避免性能问题:
// 限制弹幕数量
private readonly MAX_DANMAKU = 50;
private createDanmaku(text: string): void {
// 如果弹幕数量超过限制,移除最旧的
if (this.danmakuList.length >= this.MAX_DANMAKU) {
this.danmakuList.shift();
}
// 创建新弹幕
// ...
}
2. 使用虚拟滚动
对于大量弹幕,可以使用虚拟滚动技术:
// 只渲染可见区域的弹幕
getVisibleDanmakus(): DanmakuItem[] {
return this.danmakuList.filter(danmaku => {
// 判断弹幕是否在可见区域
return this.isDanmakuVisible(danmaku);
});
}
3. 使用 CSS 动画
使用 CSS 动画而不是 JavaScript 动画,性能更好:
.danmaku-item {
animation: danmaku-move linear;
will-change: transform; /* 优化性能 */
}
4. 防抖输入框
对于输入框发送,可以结合防抖:
this.danmakuInput.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged()
).subscribe(value => {
// 输入框变化处理
});
注意事项
- 内存泄漏:确保弹幕动画结束后及时移除
- 性能问题:限制同时显示的弹幕数量
- 用户体验:合理设置节流时间,既限制频率又保持响应
-
动画流畅:使用 CSS 动画和
will-change优化性能
总结
使用 throttleTime 实现弹幕系统是一个优雅的解决方案,它通过限制点击事件的触发频率来确保:
- 性能优化:避免创建过多弹幕导致性能问题
- 用户体验:保持即时反馈,但限制频率
- 代码简洁:使用 RxJS 操作符,代码清晰易读
- 易于扩展:可以轻松添加更多功能(如弹幕过滤、弹幕样式等)
通过合理使用 RxJS 操作符(throttleTime、takeUntil 等),我们可以构建一个流畅、高效的弹幕系统。
记住:节流适合需要即时反馈但需要限制频率的场景,而防抖适合等待用户完成操作的场景。
20个例子掌握RxJS——第十章使用 RxJS 实现大文件分片上传
RxJS 实战:使用 RxJS 实现大文件分片上传
概述
大文件上传是 Web 开发中的常见需求。直接上传大文件可能会遇到以下问题:
- 超时:文件太大,上传时间过长,导致请求超时
- 内存占用:大文件占用大量内存
- 网络中断:网络不稳定时,需要重新上传整个文件
- 用户体验差:无法显示上传进度,用户不知道上传状态
分片上传(Chunk Upload)是解决这些问题的有效方案。本章将介绍如何使用 RxJS 实现大文件分片上传,包括断点续传、进度显示、并发控制等功能。
分片上传的基本概念
分片上传是指将大文件分割成多个小片段(Chunk),逐个上传,最后在服务器端合并。主要优势包括:
- 避免超时:每个分片较小,上传时间短
- 断点续传:网络中断后,只需上传未完成的分片
- 进度显示:可以显示每个分片和整体的上传进度
- 并发控制:可以控制同时上传的分片数量
实现思路
1. 文件分片
将文件按照指定大小(如 2MB)分割成多个分片:
private createChunks(file: File): ChunkInfo[] {
const chunks: ChunkInfo[] = [];
const totalChunks = Math.ceil(file.size / this.CHUNK_SIZE);
for (let i = 0; i < totalChunks; i++) {
const start = i * this.CHUNK_SIZE;
const end = Math.min(start + this.CHUNK_SIZE, file.size);
const blob = file.slice(start, end);
chunks.push({
index: i,
start,
end,
blob,
uploaded: false,
progress: 0
});
}
return chunks;
}
2. 上传单个分片
使用 HttpRequest 的 reportProgress 选项来跟踪上传进度:
private uploadChunk(chunk: ChunkInfo, fileId: string, file: File): Observable<{ index: number; progress: number }> {
// 如果已经上传,直接返回
if (chunk.uploaded) {
return of({ index: chunk.index, progress: 100 });
}
const formData = new FormData();
formData.append('file', chunk.blob);
formData.append('chunkIndex', chunk.index.toString());
formData.append('fileId', fileId);
formData.append('fileName', file.name);
formData.append('totalChunks', Math.ceil(file.size / this.CHUNK_SIZE).toString());
const req = new HttpRequest('POST', `${this.API_BASE_URL}/api/upload/chunk`, formData, {
reportProgress: true // 启用进度报告
});
return this.http.request(req).pipe(
map((event: HttpEvent<any>) => {
switch (event.type) {
case HttpEventType.UploadProgress:
if (event.total) {
const progress = Math.round((100 * event.loaded) / event.total);
return { index: chunk.index, progress };
}
return { index: chunk.index, progress: 0 };
case HttpEventType.Response:
return { index: chunk.index, progress: 100 };
default:
return { index: chunk.index, progress: 0 };
}
}),
catchError(error => {
console.error(`分片 ${chunk.index} 上传失败:`, error);
throw { index: chunk.index, error };
})
);
}
3. 并发上传多个分片
使用 mergeMap 并发上传多个分片,并通过第二个参数限制并发数:
// 获取未上传的分片
const pendingChunks = chunks.filter(c => !c.uploaded);
// 创建分片上传流
const chunkStreams$ = from(pendingChunks).pipe(
mergeMap(chunk => {
return this.uploadChunk(chunk, fileId, file).pipe(
takeUntil(this.currentUploadCancel$),
catchError(error => {
// 处理错误,继续上传其他分片
console.error(`分片 ${chunk.index} 上传失败:`, error);
if (this.uploadState) {
const chunkToUpdate = this.uploadState.chunks.find(c => c.index === chunk.index);
if (chunkToUpdate) {
chunkToUpdate.progress = 0;
}
}
return EMPTY;
})
);
}, this.CONCURRENT_LIMIT), // 并发限制:最多同时上传 3 个分片
takeUntil(this.destroy$)
);
4. 聚合进度
使用 scan 操作符聚合所有分片的上传进度:
chunkStreams$.pipe(
scan((acc, chunkProgress) => {
if (!this.uploadState) {
return acc;
}
const chunk = this.uploadState.chunks.find(c => c.index === chunkProgress.index);
if (chunk) {
chunk.progress = chunkProgress.progress;
if (chunkProgress.progress === 100) {
chunk.uploaded = true;
}
}
// 计算总进度
const uploadedSize = this.uploadState.chunks.reduce((sum, c) => {
if (c.uploaded) {
return sum + c.blob.size;
}
return sum + (c.blob.size * c.progress / 100);
}, 0);
const uploadedChunks = this.uploadState.chunks.filter(c => c.uploaded).length;
const progress = {
loaded: uploadedSize,
total: this.uploadState.file.size,
percentage: Math.round((uploadedSize / this.uploadState.file.size) * 100),
uploadedChunks,
totalChunks: this.uploadState.chunks.length
};
// 更新状态
if (this.uploadState && this.uploadState.status === 'uploading') {
this.uploadState.progress = progress;
this.saveUploadProgress(this.uploadState); // 保存进度到 localStorage
this.cdr.detectChanges();
}
return progress;
}, this.uploadState.progress)
)
5. 断点续传
使用 localStorage 保存上传进度,支持断点续传:
// 保存上传进度
private saveUploadProgress(state: UploadState): void {
try {
const dataToSave = {
fileId: state.fileId,
chunks: state.chunks.map(c => ({
index: c.index,
uploaded: c.uploaded,
progress: c.progress
})),
progress: state.progress,
status: state.status
};
localStorage.setItem(`${STORAGE_KEY_PREFIX}${state.fileId}`, JSON.stringify(dataToSave));
} catch (e) {
console.error('保存上传进度失败:', e);
}
}
// 加载上传进度
private loadUploadProgress(fileId: string): Partial<UploadState> | null {
const stored = localStorage.getItem(`${STORAGE_KEY_PREFIX}${fileId}`);
if (stored) {
try {
return JSON.parse(stored);
} catch (e) {
console.error('解析上传进度失败:', e);
}
}
return null;
}
6. 合并分片
所有分片上传完成后,调用合并接口:
// 合并所有分片
private mergeChunks(fileId: string, fileName: string, totalChunks: number): Observable<any> {
const params = new HttpParams()
.set('fileId', fileId)
.set('fileName', fileName)
.set('totalChunks', totalChunks.toString());
return this.http.post(`${this.API_BASE_URL}/api/upload/merge`, null, { params }).pipe(
catchError(error => {
console.error('合并分片失败:', error);
return of({ success: true, message: '合并成功(模拟)' });
})
);
}
完整流程
1. 开始上传
startUpload(): void {
const file = this.selectedFile;
const fileId = this.generateFileId(file);
// 创建分片
let chunks = this.createChunks(file);
// 尝试从 localStorage 恢复进度
const savedProgress = this.loadUploadProgress(fileId);
if (savedProgress && savedProgress.chunks) {
// 恢复已上传的分片信息
chunks = chunks.map(chunk => {
const saved = savedProgress.chunks?.find(c => c.index === chunk.index);
if (saved) {
return {
...chunk,
uploaded: saved.uploaded || false,
progress: saved.progress || 0
};
}
return chunk;
});
}
// 初始化上传状态
this.uploadState = {
file,
fileId,
chunks,
progress: { /* ... */ },
status: 'uploading'
};
// 开始上传未完成的分片
// ...
}
2. 暂停上传
pauseUpload(): void {
if (this.uploadState && this.uploadState.status === 'uploading') {
this.currentUploadCancel$.next(); // 取消当前上传
this.currentUploadCancel$ = new Subject<void>(); // 创建新的取消 Subject
this.uploadState.status = 'paused';
this.saveUploadProgress(this.uploadState); // 保存进度
this.cdr.detectChanges();
}
}
3. 继续上传
resumeUpload(): void {
if (this.uploadState && this.uploadState.status === 'paused') {
this.startUpload(); // 从暂停处继续
}
}
关键点解析
1. 并发控制
使用 mergeMap 的第二个参数限制并发数:
mergeMap(chunk => this.uploadChunk(chunk), 3) // 最多同时上传 3 个分片
2. 进度计算
总进度 = 所有分片的已上传大小 / 文件总大小
const uploadedSize = chunks.reduce((sum, c) => {
if (c.uploaded) {
return sum + c.blob.size; // 已上传的分片,使用完整大小
}
return sum + (c.blob.size * c.progress / 100); // 正在上传的分片,按进度计算
}, 0);
3. 错误处理
单个分片上传失败不影响其他分片:
catchError(error => {
// 记录错误,继续上传其他分片
console.error(`分片 ${chunk.index} 上传失败:`, error);
return EMPTY; // 不中断流
})
4. 取消上传
使用 Subject 实现取消功能:
private currentUploadCancel$ = new Subject<void>();
// 上传时使用 takeUntil
this.uploadChunk(chunk).pipe(
takeUntil(this.currentUploadCancel$)
)
// 取消时发出信号
cancelUpload(): void {
this.currentUploadCancel$.next();
}
实际应用场景
1. 大文件上传
适用于上传视频、大型文档等大文件。
2. 断点续传
网络中断后,可以从上次中断的地方继续上传。
3. 进度显示
实时显示上传进度,提升用户体验。
4. 并发优化
通过控制并发数,平衡上传速度和服务器压力。
性能优化建议
1. 合理设置分片大小
根据网络环境和文件大小设置合理的分片大小:
- 网络好:2-5MB
- 网络一般:1-2MB
- 网络差:500KB-1MB
2. 合理设置并发数
根据服务器性能设置合理的并发数:
- 服务器性能好:3-5 个
- 服务器性能一般:2-3 个
- 服务器性能差:1-2 个
3. 压缩文件
对于可以压缩的文件(如图片),先压缩再上传。
4. 使用 Web Workers
对于大文件的分片处理,可以使用 Web Workers 避免阻塞主线程。
注意事项
- localStorage 限制:localStorage 有大小限制(通常 5-10MB),大文件的进度信息可能无法完全保存
- 服务器支持:需要服务器支持分片上传和合并接口
- 文件完整性:合并后需要验证文件完整性(如 MD5)
- 内存占用:大文件分片仍会占用内存,需要注意
总结
使用 RxJS 实现大文件分片上传是一个完整的解决方案,它提供了:
- 分片上传:将大文件分割成小片段上传
- 断点续传:支持从上次中断处继续上传
- 进度显示:实时显示上传进度
- 并发控制:控制同时上传的分片数量
- 错误处理:单个分片失败不影响其他分片
通过合理使用 RxJS 操作符(mergeMap、scan、takeUntil 等),我们可以构建一个功能完整、性能优良的大文件上传系统。
为什么程序员不自己开发一个小程序赚钱
大家好,我是凌览。
- 个人网站:blog.code24.top
- 去水印下载鸭:nologo.code24.top
如果本文能给你提供启发或帮助,欢迎动动小手指,一键三连(点赞、评论、转发),给我一些支持和鼓励谢谢。
刷到一个挺扎心的话题:程序员为什么不自己做产品赚钱。
身边还真有不少人问过类似的话:"你天天写代码这么厉害,怎么不自己搞个App、做个小程序?随便弄弄不就发财了?"
每次听到这种问题,我都不知道该从哪儿开始解释。
![]()
最近在 X 乎上看到同行的回答,看完只能说:太真实了。
理想很丰满、现实很骨感
首先,假装我们是程序员,某天深夜加班回家,瘫在沙发上刷手机,突然一个念头炸开——"我去,这个功能市面上根本没有!我要是做一个,肯定爆火!”。
脑子里的画面瞬间清晰:产品上线、用户疯涨、投资人排队、财务自由...,满脑子都是"老子不干了,要创业"。
说干就干,流程走起来:
第一步:注册账号结果发现邮箱早就被自己多年前注册过,还冻结了。解冻、换邮箱,折腾一圈。
第二步:想名字绞尽脑汁想了个好名字,一搜,已被占用。再想想想,终于通过。
第三步:开发前端后端一把抓,不会前端?没事,Ai结伴编程一把梭。uniapp启动,一套代码多端运行,微信、QQ、抖音、快手平台全都要上。
第四步:买服务器,阿里云一核两G,一年600块,付款的时候手还没抖。
第五步:搞域名,随便挑一个,一年30块,便宜。
第六步:备案到这里,噩梦开始了。拍照、填表、等审核,来来回回折腾。好不容易过了,提交小程序审核——"该项目类型个人不支持,需要企业认证。"
卒。亏损-630元。
但程序员嘛,头铁。不信邪,继续:
第七步:注册公司个体户要经营场所,干脆直接注册公司。准备材料、开对公账户、刻公章,又是一顿操作。
第八步:重新认证企业认证要的材料堆成山,干脆重新注册个小程序。又是想名字(原来的还要等两天才能释放)、填资料、承诺书、盖章...
终于,小程序上线了。
上线只是开始,赚钱才是难题。
每天努力宣传、引流,结果广告收益长这样:昨日收入0.65元。
对,你没看错,六毛五。折线图上的曲线在0.3元到1.8元之间反复横跳,月收入6.72元。服务器钱还没赚回来,先赔进去几百块。
什么会这样?
- 个人开发者不能收费,只能通过挂广告,而广告收入低到离谱。激励广告单价居然只有4.29元/千次展示,Banner广告更惨,几块钱千次展示。算笔账:日访问量要达到2万,才能日入500。2万UV什么概念?很多小公司的官网一天都没这么多人。
- 推广难,小程序是个封闭生态,你不能诱导分享,否则直接封号。只能从其他平台往微信导流,但用户路径一长,流失率奇高。要开通流量主还得先引流500人,这第一道门槛就卡死不少人。
- 审核机制让人头大,页面上文字一多,就说你涉及"内容资讯",不给过。个人开发者经营类目受限,动不动就踩红线。
不是技术问题,是商业问题
程序员不做小程序赚钱,不是因为不会写代码,而是因为写代码只是万里长征第一步。
做一个能赚钱的小程序,需要:
- 产品能力:做什么?解决谁的什么问题?凭什么用你的?
- 运营能力:流量从哪来?怎么留存?怎么变现?
- 商业资质:公司、对公账户、各种许可证,合规成本不低;
- 时间和精力:白天上班,晚上搞副业,服务器半夜挂了还得爬起来修。
而大多数程序员,只是喜欢写代码而已。让他们去搞流量、谈商务、处理工商税务,比写一万行代码还痛苦。
更扎心的是,就算你愿意干这些,小程序的红利期也早过了。2017年刚出来那会儿,确实有人靠简单工具类小程序赚到第一桶金。现在?各大平台库存量几百万个,用户注意力被某音、被红书切得稀碎,新入局者基本就是炮灰。
成功案例
网上经常能看到"做小程序月入过万"的帖子,但仔细看会发现,要么是卖课的,要么是有特殊资源的(比如手里有公众号矩阵导流),要么是早期入局者吃到了红利。 对于普通程序员来说,接个外包项目,按时薪算可能比折腾三个月小程序赚得还多,还省心。
技术只是工具,商业才是战场。会拿锤子的不一定会盖房子,会写代码的不一定能做出赚钱的产品。这不是技术问题,这是两个完全不同的赛道。
最后
所以,开发一个小程序到底能不能赚钱?
能,但跟你关系不大。
要么你有现成的流量池,比如几十万粉丝的公众号、抖音号,小程序只是变现工具;要么你有特殊资源,比如独家数据、行业资质;再要么你踩中了某个极小概率的风口,比如当年疫情期间的健康码周边工具。否则,个人开发者大概率是炮灰。
写代码是确定性的事,输入逻辑输出结果;做生意是概率性的事,投入不一定有回报。 大多数人适合前者,却误以为自己能驾驭后者。
你呢?有没有过"做个产品改变世界"的冲动?最后成了吗?
20个例子掌握RxJS——第九章使用 exhaustMap 实现轮询机制
RxJS 实战:使用 exhaustMap 实现轮询机制
概述
轮询(Polling)是一种定期检查数据更新的技术,常用于实时性要求不高的场景,比如检查任务状态、获取最新数据等。本章将介绍如何使用 RxJS 的 timer 和 exhaustMap 操作符实现优雅的轮询机制。
轮询的基本概念
轮询是指定期(如每 3 秒)发起请求,检查数据是否有更新。常见的轮询场景包括:
- 任务状态检查:定期检查后台任务是否完成
- 数据同步:定期从服务器获取最新数据
- 消息通知:定期检查是否有新消息
为什么使用 exhaustMap?
在轮询场景中,如果前一个请求还没完成,新的轮询周期又到了,我们通常希望:
- 忽略新的请求:等待前一个请求完成
- 避免请求堆积:防止多个请求同时进行
exhaustMap 正是为此设计的:它会忽略新的值,直到当前的内部 Observable 完成。
exhaustMap vs 其他操作符
| 操作符 | 行为 | 适用场景 |
|---|---|---|
mergeMap |
并发执行所有请求 | 需要所有请求的结果 |
concatMap |
按顺序执行请求 | 需要保证顺序 |
switchMap |
取消之前的请求 | 只需要最新结果 |
exhaustMap |
忽略新的请求 | 避免请求堆积(轮询) |
实战场景:定期轮询 API
假设我们需要每 3 秒轮询一次 API,获取最新数据。如果前一个请求还没完成,应该忽略新的轮询周期。
实现思路
- 使用
timer(0, 3000)创建定时器(立即执行第一次,然后每 3 秒执行一次) - 使用
exhaustMap确保前一个请求完成后再执行下一个 - 使用
catchError处理单个请求的错误 - 记录每次轮询的结果
核心代码
// 轮询间隔(毫秒)
readonly pollInterval = 3000; // 3秒
// 轮询订阅
private pollSubscription?: Subscription;
// 轮询状态
isPolling = false;
// 开始轮询
startPolling(): void {
// 如果已经在轮询,先停止
if (this.isPolling) {
return;
}
this.isPolling = true;
// 使用 timer(0, 3000) 立即执行第一次请求,然后每3秒执行一次
// 使用 exhaustMap 确保前一个请求完成后再执行下一个,避免请求堆积
this.pollSubscription = timer(0, this.pollInterval)
.pipe(
exhaustMap(() => {
const recordId = ++this.recordCounter;
const startTime = new Date().toISOString();
// 创建记录(先标记为 pending,实际在响应中更新)
return this.http.get<PollApiResponse>(`${this.apiBaseUrl}${this.pollApiUrl}`)
.pipe(
catchError(error => {
// 错误处理
const errorRecord: PollRecord = {
id: recordId,
timestamp: startTime,
status: 'error',
error: error.message || '请求失败'
};
this.pollRecords.unshift(errorRecord);
// 限制记录数量,最多保留50条
if (this.pollRecords.length > 50) {
this.pollRecords = this.pollRecords.slice(0, 50);
}
this.cdr.detectChanges();
return of(null);
})
);
})
)
.subscribe({
next: (response) => {
if (response) {
const record: PollRecord = {
id: this.recordCounter,
timestamp: new Date().toISOString(),
response,
status: response.success ? 'success' : 'error'
};
this.pollRecords.unshift(record);
// 限制记录数量,最多保留50条
if (this.pollRecords.length > 50) {
this.pollRecords = this.pollRecords.slice(0, 50);
}
this.cdr.detectChanges();
}
},
error: (error) => {
console.error('轮询错误:', error);
this.stopPolling();
}
});
}
// 停止轮询
stopPolling(): void {
if (this.pollSubscription) {
this.pollSubscription.unsubscribe();
this.pollSubscription = undefined;
}
this.isPolling = false;
this.cdr.detectChanges();
}
关键点解析
1. timer 操作符
timer(0, 3000) 的含义:
- 第一个参数(0):延迟时间,0 表示立即执行第一次
- 第二个参数(3000):间隔时间,每 3000 毫秒(3 秒)执行一次
2. exhaustMap 的作用
exhaustMap 确保:
- 如果前一个请求还在进行,忽略新的轮询周期
- 只有当前一个请求完成后,才会处理下一个轮询周期
- 避免请求堆积,减少服务器压力
3. 执行流程示例
假设 API 响应时间为 2 秒:
- 0 秒:timer 发出第一个值 → exhaustMap 发起请求 A(2 秒)
- 3 秒:timer 发出第二个值 → exhaustMap 忽略(请求 A 还在进行)
- 4 秒:请求 A 完成 → 可以处理下一个值
- 6 秒:timer 发出第三个值 → exhaustMap 发起请求 B(2 秒)
- 8 秒:请求 B 完成
结果:每 3-4 秒执行一次请求,不会堆积。
4. 错误处理
使用 catchError 确保单个请求的错误不会中断整个轮询流程:
catchError(error => {
// 记录错误,但继续轮询
this.handleError(error);
return of(null);
})
与其他方案的对比
方案 1:使用 setInterval(不推荐)
// ❌ 不推荐:无法优雅地取消,容易导致内存泄漏
const interval = setInterval(() => {
this.http.get('/api/data').subscribe();
}, 3000);
// 需要手动清理
clearInterval(interval);
方案 2:使用 mergeMap(有问题)
// ⚠️ 问题:可能同时发起多个请求
timer(0, 3000).pipe(
mergeMap(() => this.http.get('/api/data'))
)
方案 3:使用 exhaustMap(推荐)✅
// ✅ 推荐:避免请求堆积
timer(0, 3000).pipe(
exhaustMap(() => this.http.get('/api/data'))
)
高级用法
1. 条件轮询
根据条件决定是否继续轮询:
timer(0, 3000).pipe(
exhaustMap(() => this.http.get('/api/task-status')),
takeWhile(response => response.status !== 'completed'), // 任务完成时停止轮询
finalize(() => console.log('轮询结束'))
)
2. 动态调整轮询间隔
根据响应结果动态调整轮询间隔:
let pollInterval = 3000;
timer(0, pollInterval).pipe(
exhaustMap(() => this.http.get('/api/data')),
tap(response => {
// 根据响应调整轮询间隔
if (response.hasUpdate) {
pollInterval = 1000; // 有更新时加快轮询
} else {
pollInterval = 5000; // 无更新时减慢轮询
}
})
)
3. 指数退避轮询
如果请求失败,逐渐增加轮询间隔:
let pollInterval = 3000;
let consecutiveErrors = 0;
timer(0, pollInterval).pipe(
exhaustMap(() =>
this.http.get('/api/data').pipe(
tap(() => {
consecutiveErrors = 0; // 成功时重置错误计数
pollInterval = 3000; // 重置间隔
}),
catchError(error => {
consecutiveErrors++;
pollInterval = Math.min(3000 * Math.pow(2, consecutiveErrors), 30000); // 指数退避
return of(null);
})
)
)
)
实际应用场景
1. 任务状态检查
// 检查后台任务是否完成
startPollingTaskStatus(taskId: string): void {
timer(0, 2000).pipe(
exhaustMap(() => this.getTaskStatus(taskId)),
takeWhile(status => status !== 'completed' && status !== 'failed'),
finalize(() => {
// 任务完成,停止轮询
this.onTaskComplete();
})
).subscribe();
}
2. 数据同步
// 定期同步数据
startDataSync(): void {
timer(0, 5000).pipe(
exhaustMap(() => this.syncData()),
catchError(error => {
console.error('同步失败:', error);
return of(null); // 继续轮询
})
).subscribe();
}
3. 消息通知
// 定期检查新消息
startMessagePolling(): void {
timer(0, 3000).pipe(
exhaustMap(() => this.checkNewMessages()),
tap(messages => {
if (messages.length > 0) {
this.showNotifications(messages);
}
})
).subscribe();
}
性能优化建议
1. 合理设置轮询间隔
根据业务需求设置合理的轮询间隔:
- 实时性要求高:1-3 秒
- 一般场景:3-5 秒
- 实时性要求低:10-30 秒
2. 限制记录数量
对于轮询结果,限制记录数量,避免内存占用过大:
if (this.pollRecords.length > 50) {
this.pollRecords = this.pollRecords.slice(0, 50);
}
3. 在页面不可见时暂停轮询
使用 Page Visibility API 在页面不可见时暂停轮询:
fromEvent(document, 'visibilitychange').pipe(
switchMap(() => {
if (document.hidden) {
this.stopPolling();
return EMPTY;
} else {
this.startPolling();
return EMPTY;
}
})
).subscribe();
注意事项
- 内存泄漏:确保在组件销毁时取消订阅
- 服务器压力:合理设置轮询间隔,避免给服务器造成过大压力
- 网络消耗:轮询会持续消耗网络资源,考虑使用 WebSocket 替代
- 用户体验:给用户适当的反馈,告知正在轮询
总结
使用 exhaustMap 实现轮询机制是一个优雅的解决方案,它通过忽略新的请求来确保:
- 避免请求堆积:前一个请求完成后再执行下一个
- 资源节约:不会同时发起多个请求
- 代码简洁:使用 RxJS 操作符,代码清晰易读
- 易于管理:可以轻松启动和停止轮询
记住:轮询是一种简单但有效的实时数据获取方式,但对于实时性要求高的场景,考虑使用 WebSocket 或 Server-Sent Events。