普通视图
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开发干货
字节CEO梁汝波:公司2026年关键词是 “勇攀高峰”
热门中概股美股盘前涨跌互现,蔚来涨超1%
美股大型科技股盘前涨跌不一,Meta涨超8%
三星AR眼镜官宣2026年内发布,主打多模态AI体验
印度航空增购30架737飞机,并签署787机队服务协议
皖维高新:海螺集团将成间接控股股东
特斯拉的销量已经不重要了
本文来自微信公众号:山上,作者:薛星星,编辑:蒋浇,题图来自:视觉中国
对于特斯拉——这家全球有史以来市值最高、同时也是无数车企争相模仿和致敬的电动汽车公司来说,交付量已经不再重要了。更吊诡的是,销量越差,市场对这家公司的信心似乎就更加充足。
今日(1 月 29 日)凌晨,特斯拉对外公布 2025 年第四季度及全年财报。财报显示,当季特斯拉营收 249 亿美元,同比下降 3%,全年营收 948 亿美元,同比下降 3%。这是特斯拉成立以来首次年营收下滑。
营收下降同时,特斯拉净利跌幅更大。当季特斯拉 GAAP 净利润 8.4 亿美元,同比大降 61%,部分来自于去年底比特币价格的震荡影响。如果看 non-GAAP 口径,当季特斯拉净利润 17.6 亿美元,同比下降 16%,全年为 58.6 亿美元,同比下降 26%,跌幅依然显著高于营收。
![]()
特斯拉 2025 年第四季度财报
更关键的是,特斯拉的核心收入来源汽车业务收入持续萎缩。当季,特斯拉汽车业务收入同比大降 11%至 176.9 亿美元。全年汽车业务收入 695 亿美元,在去年同比下降 6.5%的基础上跌幅再度扩大至 10%,连续两年收缩。
此前特斯拉公布的交付量数字显示,当季特斯拉交付量为 41.8 万辆,同比下降 16%。全年交付量 163.6 万辆,同比下降 9%,同样连续两年下滑。
![]()
特斯拉汽车季度生产及交付量
如此差劲的财报数字,却仍然没有打消资本市场对特斯拉的偏爱。今晨美股收盘,特斯拉股价仅微跌 0.1% 至 430.46 美元,仍然处于历史高位区间。马斯克在财报电话会上发表那番高昂对 AI 和自动驾驶未来的美好愿景时,特斯拉盘中股价甚至一度上涨 4%。
去年四季度销量持续下滑时,特斯拉股价一度飙涨至 491.5 美元,市值达到 1.63 万亿美元,创下历史新高。过去一年美股七巨头股价普涨,但特斯拉是唯一一家在营收与利润都双双下滑的同时,股价依然高挺的公司。
从 2019 年开始,马斯克就在逐步弱化特斯拉汽车公司的定位,孜孜不倦地向市场传达不能再用汽车公司的眼光来衡量特斯拉。现在,外界似乎终于信服了马斯克的观点,即便它的绝大部分利润和营收仍然来自卖车。
华尔街的一些分析师们说,特斯拉完全不像是有着稳定营收和利润的一家上市公司,而更像是一家高风险和高回报并存的初创公司。
在交出一份首次年营收下滑的财报后,特斯拉同时宣布了有史以来最为庞大的资本开支计划,全年投资将高达 200 亿美元。这些投资大都集中于 AI、无人驾驶与机器人业务。
“我们正在为一个史诗级的未来进行长远布局。”马斯克在财报电话会上说。
跌跌不休的 2025 年
整个 2025 年,特斯拉几乎没有交出一份令市场满意的季度财报。去年第一季度,特斯拉交付量仅为 33.7 万辆,同比下降 13.5%,跌幅甚至超出市场最悲观的预期,创下特斯拉自 2022 年第二季度以来的最低季度水平。
去年第二季度情况依然未有明显好转,季度交付量继续同比下降 13% 至 38.4 万辆,营收同比大降 12%,为十年来最大单季跌幅。
到了第三季度,特斯拉似乎终于从低谷走出,当季营收同比增长 11.6%,是过去一年来唯一收入正增长的季度。交付量创下当年季度新高,同比增长 5.8% 至 49.7 万辆。
只是增长趋势没能延续到年底。第四季度一直是特斯拉的传统销售旺季,但当季特斯拉交付量仅为 41.8 万辆,同比下降 16%,不仅低于上一季度,也远逊于市场对旺季反弹的预期。
特斯拉去年全年交付量最终定格在 163.6 万辆,同比下降 9%,连续两年下滑。现在已经不能再称呼特斯拉为全球最大的电动汽车制造商了,因为比亚迪去年卖了 226 万辆电动汽车,首次超越特斯拉。
昨晚的财报电话会上,马斯克几乎没有谈及任何与交付量有关的话题,甚至整场财报电话会上都没有与销量下滑有关的提问。特斯拉 CFO 瓦伊巴夫·塔内贾(Vaibhav Taneja)倒是在开场简单聊了聊销量数字,他的形容是当季销量“很有趣”(interesting)。
他说,美国市场第三季度的销量激增,导致提前透支了年底的需求。但同时,特斯拉在马来西亚、挪威、波兰等小体量市场交付量创下新高,亚太、中东及非洲地区也持续走强,特斯拉在年底的积压订单比往年同期都更多。
总之,在特斯拉的管理层看来,人们对于特斯拉汽车的热爱依旧,特斯拉的销量依然景气。塔内贾还特意强调,美国市场以外的地区都还没有上线最新版的 FSD,言外之意特斯拉的销量还有很大空间。
但在消费者们的眼光中,特斯拉今年更显著的改变或许是越来越便宜了。特斯拉廉价版 Model 3 已经在美国、英国、中东及亚洲部分市场上线,韩国市场补贴后价格甚至低至 17.4 万元。竞争激烈的中国市场,特斯拉在去年推出 5 年免息的基础上,今年初进一步加码至“7 年超低息”,让消费者感慨买车堪比按揭买房。
交付量的持续下滑,导致特斯拉全年营收及利润双双下滑。2025 年特斯拉全年营收首次下降,全年 GAAP 净利润更是大跌 46%。
自动驾驶的广阔未来
马斯克似乎并不在意现有车型的销量,他对于传统汽车已经兴致寥寥。在财报电话会上,马斯克宣布了 Model S 和 Model X 停产的消息,“是时候让 Model S 和 Model X 圆满谢幕了。”
Model S/X 是特斯拉旗下豪华电动汽车型号,起售价高达 10 万美元。它们曾是特斯拉打开电动汽车市场大门的关键产品,但对于现在的特斯拉来说,这些售价高昂、销量低迷的豪华车型已经不再是未来。
在马斯克看来,自动驾驶才是特斯拉汽车的未来。他说,未来特斯拉制造的车辆都会是自动驾驶汽车。(超豪华电动超跑 Rodaster 是唯一的例外。Roadster 是特斯拉第一款实现量产并交付的车型,第二代版本于 2017 年发布,最早计划在 2020 年投入量产,但至今仍未交付。)
特斯拉即将量产的下一代车型将是完全为自动驾驶打造的 CyberCab。这辆车没有方向盘或踏板,只允许两人乘坐。它将完全依赖自动驾驶行驶,马斯克说“这款车要么自己开,要么就开不动”。同样跳票多年后,马斯克定下的最新目标是今年 4 月量产。按照他的说法,这款车的产量将超过特斯拉其他所有车型的总和。
特斯拉车辆工程副总裁拉尔斯·莫拉维(Lars Moravy)补充说,CyberCab 将把特斯拉 FSD 带进更广阔的市场。“在这个新的自动驾驶市场中,你必须开始将特斯拉看作是‘交通即服务(TaaS)’的供应商,而不仅仅关注传统汽车的销量。”马斯克说,未来特斯拉制造的车辆都会是自动驾驶汽车。
去年,特斯拉的自动驾驶车队已经在奥斯汀运营,并在年底首次尝试运营全无人驾驶服务,车内无需配备安全员。特斯拉计划今年上半年将自动驾驶车队扩展至更多地区,包括达拉斯、休斯顿、凤凰城、迈阿密等。马斯克在财报电话会上透露,目前特斯拉自动驾驶车队规模“远超 500 辆”。
![]()
特斯拉自动驾驶车队累计运营里程及城市开拓计划
目前,美国各州对自动驾驶车队的准入标准并不统一。马斯克表示,在获得监管许可的前提下,特斯拉预计到年底可将自动驾驶服务覆盖至约一半美国人口所在区域。如果审批进展不及预期,他们只能按城市和州为单位逐步推进。
在马斯克关于自动驾驶的宏伟设想里,未来所有的特斯拉车主都可以选择随时把车子出租给自动驾驶车队,来帮车主赚钱,“就像 Airbnb 一样。”
今年初,特斯拉宣布将自今年 2 月 14 日起取消 FSD 的一次性买断服务,未来车主都需按月订阅付费,每月需支付 99 美元。此前,北美地区 FSD 的买断价格为 8000 美元,中国为 6.4 万人民币。
特斯拉在本季度首次披露 FSD 的全球付费用户规模,全球付费用户接近 110 万,约占公司累计汽车销量的 12%,其中近 70%为买断用户。
AI、机器人与芯片
和大多数瞄准 AI 未来的科技公司们类似,特斯拉也即将进入一个资本开支大年。他们今年的资本开支将达到史无前例的 200 亿美元,比华尔街此前预期高出一倍。马斯克说,“这是我们深思熟虑后的决定,因为我们正在为一个史诗级的未来进行长远布局。”
200 亿美元的资本开支将主要用于电池工厂、人形机器人 Opitimus、AI 基础设施以及自动驾驶车队等。特斯拉 CFO 塔内贾说,他们今年要新建 6 座工厂,包括锂精炼厂、LFP 电池工厂、CyberCab 工厂、Semi 工厂、一个新的 Mega 储能工厂以及 Optimus 工厂。
特斯拉计划将于今年发布 Optimus 第三代机器人。Model S/X 停产后,现有产线将改造后用于 Optimus 生产,马斯克定下的目标是年产 100 万台 Optimus 机器人。马斯克称,第三代 Optimus 机器人将在几个月内发布。它已经跳票多年。
![]()
Optimus 机器人
去年,国产机器人凭借春晚的亮相一跃进入大众视野,包括宇树科技、智元机器人等初创公司崭露头角。不少国产汽车厂商也开始入局机器人赛道,小鹏汽车已在去年对外展示了旗下首款人形机器人 IRON,理想汽车也在年初加码人形机器人的研发。
马斯克认为,人形机器人领域最大的竞争肯定来自于中国。“中国非常擅长 AI、擅长制造,绝对是特斯拉最强劲的竞争对手。我总是认为中国以外的人都有点低估中国。”不过他也强调,特斯拉的 Optimus 将会比任何在研的中国机器人都强大得多。
“人形机器人有三大难题,一是打造一只和人有同等自由度和灵活度的机械手,二是现实世界 AI,最后是规模化生产。特斯拉是唯一同时具备这三大要素的公司。”马斯克说。
但已经宣布的 200 亿美元还远不是特斯拉最终的投资规模。他们还在计划建设太阳能工厂以及自建晶圆厂。已经难产多年的太阳能工厂姑且不提,特斯拉最新宣布的超级晶圆厂 TeraFab 建设计划与其 AI 战略紧密相关。特斯拉的 Optimus、自动驾驶乃至汽车业务都赖于芯片支撑。
马斯克认为,未来三四年限制特斯拉增长的瓶颈只能是芯片,“现在我在芯片上的投入比目前特斯拉任何其他事务都要多。”
“如果不打造 TeraFab,特斯拉未来必然会受制于外部芯片产能。”马斯克说。目前,特斯拉的自动驾驶与 AI 核心芯片仍主要由台积电、三星等外部供应商。
在他看来,随着算力需求快速膨胀,单纯依赖现有半导体厂商的产能规划,难以支撑特斯拉对 AI 训练和推理的长期需求。“这对于特斯拉来说是生死攸关的,因为没有 AI 芯片,Optimus 就完全是个没用的空壳。”
为此,他们不得不计划自建晶圆厂,“一座集逻辑芯片、存储芯片和封装工艺于一体的超大型芯片工厂。”特斯拉的 TeraFab 晶圆厂投资规模庞大,初期计划月产能 10 万片晶圆,长远规划月产能将高达 100 万片,且均用于特斯拉自身,不会对外出售。
分析师们尤为关心特斯拉从何处筹集如此庞大的资金投入。塔内贾强调,特斯拉账面上拥有超过 440 亿美元的现金及投资余额,他们会优先使用自有现金。除此之外,也在考虑通过自动驾驶车队向银行贷款融资。但关于基础设施建设方面,尚未有明确规划。
2025 年第四季度财报公布前不久,特斯拉还宣布向马斯克的 xAI 投资 20 亿美元。目前,xAI 的 Grok 大模型已经在特斯拉汽车中上线。马斯克在财报电话会中解释说,特斯拉投资 xAI 是应股东要求。此外,他认为 Grok 也有助于提升自动驾驶车队的运营效率,也可以帮助提升特斯拉的工厂管理。
无论你如何看待,电动汽车似乎都不足以涵盖马斯克设想的特斯拉未来。长久以来,特斯拉的核心愿景都是“加速世界向可持续能源的转型”,现在,马斯克已经将这一目标更改为“建设一个富足非凡的世界”。
本文来自微信公众号:山上,作者:薛星星,编辑:蒋浇
下载虎嗅APP,第一时间获取深度独到的商业科技资讯,连接更多创新人群与线下活动
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
交流讨论
文章如有错误或需要改进之处,欢迎指正