阅读视图

发现新文章,点击刷新页面。

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)。

常用使用场景:

  1. 作用于接口 / 类型别名的属性(最基础)
// 定义带只读属性的接口
interface User {
  readonly id: number; // 只读属性:只能初始化赋值,后续不可改
  name: string; // 普通属性:可修改
}

// 初始化时赋值(合法)
const user: User = { id: 1, name: "张三" };

// 尝试修改只读属性(报错)
user.id = 2; // ❌ 报错:无法分配到 "id",因为它是只读属性
// 修改普通属性(合法)
user.name = "李四"; // ✅ 合法
  1. 作用于类的属性: 类中使用 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; // ❌ 报错:只读属性不可修改
  1. 作用于数组 / 元组(只读数组): 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; // ❌ 报错:只读元组元素不可修改
  1. 结合 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; // ❌ 报错
  1. 只读索引签名:如果类型使用索引签名,也可以标记为 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 做类型约束或条件判断
  1. 泛型约束:限定泛型的取值范围
// 约束 T 必须是「拥有 length 属性」的类型(比如 string/数组)
function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

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

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

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

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

type Union = string | number | boolean;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

测试用例

/* _____________ 测试用例 _____________ */
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>,它带有两种类型的参数TK

类型 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 是一个泛型,它有两个类型参数 TK
  • K extends keyof T = keyof T 表示 KT 的属性名的子类型,默认值为 keyof T,即所有属性都为只读。
  • readonly [P in K]: T[P] 表示将 K 中的属性名 P 转换为只读属性,属性值为 T[P]
  • & Omit<T, K> 表示将 T 中除了 K 中的属性名外的其他属性保留下来。

相关知识点

extends

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

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

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

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

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

type Union = string | number | boolean;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

readonly

  • 核心作用:标记后,目标(属性 / 数组 / 元组)只能在初始化阶段赋值(比如接口实例化、类构造函数、变量声明时),后续任何修改操作都会被 TS 编译器拦截报错;
  • 运行时特性:readonly 仅做编译时检查,不会生成任何额外 JS 代码,也无法真正阻止运行时的修改(比如通过类型断言绕开的话,运行时仍能改);
  • const 的区别:const 是变量层面的不可重新赋值(但变量指向的对象 / 数组内部属性仍可改),readonly 是属性 / 类型层面的不可修改(变量本身可重新赋值,除非变量也用 const)。

常用使用场景:

  1. 作用于接口 / 类型别名的属性(最基础)
// 定义带只读属性的接口
interface User {
  readonly id: number; // 只读属性:只能初始化赋值,后续不可改
  name: string; // 普通属性:可修改
}

// 初始化时赋值(合法)
const user: User = { id: 1, name: "张三" };

// 尝试修改只读属性(报错)
user.id = 2; // ❌ 报错:无法分配到 "id",因为它是只读属性
// 修改普通属性(合法)
user.name = "李四"; // ✅ 合法
  1. 作用于类的属性: 类中使用 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; // ❌ 报错:只读属性不可修改
  1. 作用于数组 / 元组(只读数组): 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; // ❌ 报错:只读元组元素不可修改
  1. 结合 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; // ❌ 报错
  1. 只读索引签名:如果类型使用索引签名,也可以标记为 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年关键词是 “勇攀高峰”

36氪获悉,1月29日,字节跳动官方账号“字节范儿”披露了该公司当日召开的2026年首次全员会议部分内容。公司CEO 梁汝波在会上表示,字节跳动将2026年的关键词设定为“勇攀高峰”。此外,他还表示,勇攀高峰需要提高人才密度,加大对人才的激励。字节跳动会继续加大对人才的投入,让薪酬和激励在全球各个市场都领先于头部水平。

热门中概股美股盘前涨跌互现,蔚来涨超1%

36氪获悉,热门中概股美股盘前涨跌互现,截至发稿,阿里巴巴、蔚来涨超1%,拼多多涨0.98%,百度涨0.45%,小鹏汽车涨0.21%,理想汽车涨0.12%;B站跌超1%,网易跌0.85%,爱奇艺跌0.47%。

印度航空增购30架737飞机,并签署787机队服务协议

1月29日,印度航空宣布向波音公司增购20架737-8型及10架737-10型飞机,该订单将纳入其2023年与波音签订的220架飞机采购协议中。印度航空同时与波音就其787机队签署了多年期零部件服务协议。(界面)

皖维高新:海螺集团将成间接控股股东

36氪获悉,皖维高新公告,公司接到控股股东皖维集团通知,海螺集团拟通过现金增资49.98亿元的方式持有皖维集团60%的股权,间接控制皖维高新18.24%的股份;省投资集团、省国控集团拟通过无偿划转方式分别取得皖维集团各20%的股权、皖维高新各7.50%的股份。本次收购完成后,皖维集团仍为皖维高新的控股股东,海螺集团将成为皖维高新间接控股股东,皖维高新实际控制人仍为安徽省国资委。本次收购尚需完成相关审查及过户登记手续。

特斯拉的销量已经不重要了

本文来自微信公众号:山上,作者:薛星星,编辑:蒋浇,题图来自:视觉中国


对于特斯拉——这家全球有史以来市值最高、同时也是无数车企争相模仿和致敬的电动汽车公司来说,交付量已经不再重要了。更吊诡的是,销量越差,市场对这家公司的信心似乎就更加充足。


今日(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 的设计是将角色绑定权限,用户绑定角色,从而实现权限控制

image.png

并且,它们之间的逻辑关系通常是多对多的:

用户 - 角色 (User-Role): 一个用户可以拥有多个角色(例如:某人既是“项目经理”又是“技术委员会成员”)

角色 - 权限(Role-Permission): 一个角色包含多个权限(例如:“人事经理”角色拥有“查看员工”、“编辑薪资”等权限)

主导权限控制的前端、后端方案

市面上这些开源 Admin 的权限控制中,存在两种主要的权限主导方案:前端主导的权限方案和后端主导的权限方案

前端主导的权限方案

前端主导的权限方案,一个主要的特征是菜单数据由前端维护,而不是存在数据库中

后端只需要在登录后给到用户信息,这个信息中会包含用户的角色,根据这个角色信息,前端可以筛选出具有权限的菜单、按钮

这种方案的主要逻辑放在前端,而不是后端数据库,所以安全性没保障,灵活性也较差,要更新权限,就需要改动前端代码并重新打包上线,无法支持“动态配置权限”

适合一些小型、简单系统

后端主导的权限方案

后端控制方案,即登录后在返回用户信息时,还会给到此用户对应的菜单数据和按钮权限码等

菜单数据、按钮权限码等都存在数据库,这样一来,安全性、灵活性更高,要更新权限数据或用户权限控制,提供相应接口即可修改

倒也不是说前端完全不用管菜单数据,而是前端只需要维护一些静态菜单数据,比如登录页、异常页(404、403...)

在企业级后台系统中,后端主导的权限方案是比较常用的,本文只介绍后端主导的权限方案

权限方案整体流程

在开始写代码之前,要清晰知道整体实现流程,我画了一张图来直观展示:

image.png

后台系统中的 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 权限路由数据,数据是这样的:

clean-admin ApiFox 文档在线地址

image.png

从登录页到路由守卫

权限方案的第一步,是登录并拿到用户信息

假设我们现在用 Element Plus 搭建起了一个登录页面,当用户点击登录时,我们需要做这几件事:

  1. 调用登录接口,将账号、密码发送给后端进行验证,验证通过则返回 JWT 信息
  2. 将返回的 JWT 信息保存到本地,后续每次请求都携带 Token 来识别用户身份并决定你能拿到的权限路由数据
  3. 触发路由守卫拦截

image.png

account-login.vue 找到全部代码

基本 Vue Router 配置

登录完成后,我们就可以触发路由守卫了,但在写路由守卫之前,我们先来配置一下基本的 Vue Router

在整个权限系统中,我们将路由数据分为两种:

  1. 静态路由:系统固定的路由,比如登录页、异常页(404、403...)
  2. 动态路由:由后端接口返回的用户角色对应的菜单路由数据

静态路由是直接由前端定义,不会从后端接口返回、不会根据用户角色动态变化,所以这部分路由我们直接写好然后注册到 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 配置,做了这么几件事:

  1. 导入 modules 文件夹下的静态路由进行注册
  2. 路由初始化配置 initRouter ,在 main.ts 中调用
  3. 注册全局前置守卫 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 找到全部代码

上面的代码已经给出了很详细的注释,从整体角度来讲,我们做了两件事:

  1. 处理一些情况,比如用户未登录、登录后访问登录页、白名单等情况
  2. 拉取用户信息,动态注册路由

image.png

在路由守卫中“拉取用户信息”,一般来说,除了返回用户本身的信息外,还会给到权限路由信息、权限码信息,这里的数据结构可以跟后端进行约定

image.png

比如在 vue-clean-admin 中,返回的数据结构是这样的:

在 ApiFox 文档可以找到用户接口说明:ApiFox 文档 - 用户信息

image.png

后端路由结构的转化

在通过“拉取用户信息”拿到路由数据后,并不是直接注册到 Vue Router,而是需要进行处理转化,才能符合 Vue Router 定义的路由表结构,registerRoutes 就是处理后的路由表,处理后的类型定义可以参考 CustomRouteRecordRaw

处理什么内容呢?

比如,接口拿到的路由数据字段 component 是一个字符串路径,这是一个映射路径,映射到前端项目下的真实组件路径

image.png

实现路由结构转换的代码,我写在了 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 配置项来实现"是否隐藏菜单","当只有一个子菜单时,是否隐藏父级菜单直接显示子菜单内容"等

image.png

菜单组件的封装代码在 basic-menu 文件夹中

到这一步,已经实现了动态权限路由及侧边栏菜单的渲染,但还不算完

因为我们还不能自由定义菜单信息、角色信息、用户信息来实现权限控制,在下一篇文章来聊聊管理模块

了解更多

系列专栏地址:GitHub 博客 | 掘金专栏 | 思否专栏

实战项目:vue-clean-admin

交流讨论

文章如有错误或需要改进之处,欢迎指正

宇树宣布开源UnifoLM-VLA-0

36氪获悉,宇树宣布开源UnifoLM-VLA-0。UnifoLM-VLA-0是UnifoLM系列下面向通用人形机器人操作的视觉-语言-动作(VLA)大模型。该模型旨在突破传统VLM在物理交互中的局限,通过在机器人操作数据上的继续预训练,实现了从通用“图文理解”向具备物理常识的“具身大脑”的进化。

长川科技:定增申请获得深交所审核通过

36氪获悉,长川科技公告,公司于2026年1月29日收到深交所上市审核中心出具的审核中心意见告知函,公司向特定对象发行股票的申请符合发行条件、上市条件和信息披露要求。但该事项尚需经中国证监会同意注册后方可实施,最终能否获得注册决定及其时间尚存在不确定性。

荣盛发展:债务重组涉及多项交易,总额达25.24亿元

36氪获悉,荣盛发展公告,公司及子公司拟与符合条件的第三方进行债务重组,涉及总额达25.24亿元。主要交易包括:债务豁免与以物抵债,涉及本金9.74亿元及利息、罚息等;第三方公司依法合规受让债权后,将对债务进行豁免;中信金融河北拟将部分债权转让给符合条件的第三方公司。此外,公司拟采用先债务加入后以物抵债的方式,对盛宏地产和超安园林的债务进行抵偿,抵债金额合计为13.10亿元。同时,公司及相关子公司同意继续为其担保的原债务项下未偿还部分提供抵质押担保。

华为乾崑正式推送 ADS 4.1

36氪获悉,1月29日,华为乾崑ADS 4.1版本正式推送。从功能表现来看,ADS 4.1主要在安全及体验两大维度提升,新版本NCA支持九级路、支持潮汐车道天空牌、AES防夹心、人驾防御性限速、手势泊车、在园区巡航支持一键寻位泊车等二十余项关键功能。

启迪环境:预计2025年度期末归属于母公司净资产可能为负值,股票交易可能被实施退市风险警示

36氪获悉,启迪环境公告,预计2025年归属于上市公司股东的净利润为-28亿元至-35亿元,预计2025年度期末归属于母公司净资产可能为负值。若公司2025年度经审计的期末归属于母公司净资产为负值,公司股票交易可能在披露2025年年度报告后被实施退市风险警示。

REDMI Turbo 5 Max发布

36氪获悉,2026年1月29日,REDMI召开新品发布会,正式发布REDMI Turbo 5系列手机、REDMI Pad 2 Pro平板以及REDMI Buds 8 Pro无线耳机。

手机或Pocket?想要生活「电影化」,你更信谁?

春节临近,假日出行气氛拉满,年轻人的心已经不在工位了——新春旅程,是去滑雪场体验速度与激情,逛逛热闹的庙会?还是去温暖的海岛感受浪与海风?

虽然年轻人早已习惯随时掏出手机记录旅拍,能被成功收藏的精品照片却少之又少——手机里的影像完全无法还原旅途中肉眼所见精彩的万分之一。拍景,不够绚丽,拍人,更成了翻车现场,想给亲朋好友留份纪念,却反而为拍摄效果烦恼不断,错过当下享受。

年轻人的拍照痛点究竟有多普遍?为此,“后浪研究所”发起了一项“年轻人拍照痛点小调查”,共有458位读者参与,9成人都在18-35岁之间。调研显示,越来越多年轻人正转向独立影像设备以提升记录质量,而大疆Pocket 3成为其中的“高票”优选,7成因为“拍照痛点”而点进问卷的年轻人,都曾体验过Pocket 3带来的更专业的摄影体验。

当大多数人还停留在“拍到就行”的阶段时,新一代“氛围派”与“记录派”已然崛起。这份“手机拍照痛点调查”,或许能揭露年轻人在“生活记录”场景下的需求迁移与兴趣所在。

每到聚餐,“手机先吃”似乎已成常态;各种社交媒体上,出片的教程也是层出不穷。“记录生活”已成为年轻人的新刚需,当我们把拍照这件事拆分成从Lv1到Lv6的六个层级,发现年轻人对影像的追求出现了清晰的分层。

根据调查,Lv4氛围感占比最高,占30.1%,他们不满足于简单记录,而是追求光影、构图和氛围的整体塑造;紧随其后的是Lv3记录派,看到有趣的就想拍;还有21.0%的Lv2打卡派,只在重要的时刻拍照记录。

不同年龄层的拍照需求也各不相同,我们发现,数字原住民00后近一半都是“氛围派”。

这也不难理解——00后的成长期几乎与移动互联网和社交媒体的爆发期完全重合,从他们有审美意识开始,所见即各类精心构图、滤镜调色、氛围拉满的“高质感”图片,审美基准线自然也被拔高了。对年轻一代,拍照不再是简单地记录现实,更像是创造出一个符合自己“心中的美”的视觉作品。此外,在00后的社交语境中,影像是一种强大的“社交货币”和“身份标签”。一张充满氛围感的照片,传递的不仅是“我在哪里、做了什么”,更是“我的品味如何、我属于怎样的圈子、我拥有怎样的生活格调”。

95后和90后多数属于“记录派”,分别占34.1%和37.2%;如果说00后追求的是用影像表达“我是谁”,那么很多95后和90后则更看重用影像留住“我经历了什么”。他们的拍摄视角多是“向内”的,是为了给自己留下一份从前的美好记忆。

颇有意思的是,到了85后,“氛围派”又异军突起,成为占比最高的派别。大概也是一种“中年叛逆”,到了一定年纪,人反而开始追求氛围和感觉,主打一个用影像疗愈。

至于什么时候年轻人最喜欢掏出手机拍照,“自然风光”“旅行”“生活碎片”,是绝大多数人的不二之选。

当然,小众的爱好也值得被记录。这些看似微小的热爱,恰恰是年轻人生活里最真实的切片。

我们还发现,年轻人会更偏好相对更有“自然感”的图片。

精心剪辑的Vlog固然精彩,但live照片别有一番风味。与vlog和静态图相比,Live Photo的魅力在于它记录了按下快门前后1.5秒的动态瞬间,信息量恰到好处——短到让人愿意立刻看完,又动态到能讲清一个呼吸间的小故事。它记录下的也是一个鲜活灵动的过程,不可复制而显得更有感染力。

现在的年轻人,似乎更喜欢“随手一拍”的松弛感——这不代表他们对出片没有要求,相反,他们追求的是足够的便捷性,以及随手拍出“高质大片”的痛快。相比而言,手机确实难当大任了。在年轻人最不能忍受的手机拍摄痛点TOP10中,画质和便捷性是两大核心问题。

他们想要的生活记录必须满足以下特点——画面清晰,细节丰富;有独特的色调和电影感、动态感;当然,方便拍摄和分享也是必不可少的。

这也就解释了,为什么年轻人越来越需要一台独立于手机的记录设备。问卷中,近半数年轻人已拥有独立于手机之外的拍摄设备,31%的人在关注考虑中,仅16.6%的人认为手机暂时够用。

对于年轻人来说,拍照拍视频已经不止停留在简单的打卡/社交分享这类功能性需求,91.7%的人都同意,拍照是为了记录真实的生活、留住回忆;也有近6成人追求的是自我创作的愉悦感。一言以蔽之——拍照/视频,是“爱你老己”的一种深刻体现

渐渐地,手机已经无法承载年轻人日益膨胀的拍摄需求了。

旅行时,想用手机拍一段行云流水的风景,画面抖得像“地震实录”;给家里萌宠拍视频,它一动,就成了模糊的“高速虚影”;演唱会现场,激动地举起手机,录下的歌声却混杂着尖叫与噪音……但有一群年轻人,已经找到了手机之外的“外挂”——他们用Pocket 3,把以上的翻车现场,都拍成了“人生电影”。

满怀期待地用手机想记录下烟花绽开的瞬间,结果回看时才发现,所谓的震撼现场只剩下一团模糊的光斑和满是噪点的漆黑背景——这也是年轻人最不能忍受的手机拍摄痛点TOP1。

但换上大疆Pocket 3,就像内置了一个“夜视仪”,1英寸大底能真实地捕捉到每一束光芒的轨迹。它不靠算法硬拉亮度,而是真实还原暗部细节。

拍极光,能看到色彩的流动;

拍烟花,每一束光都绚烂夺目。

配合立体收音,烟花升空的呼啸和绽放的轰鸣,都能被原汁原味记录下来,仿佛一秒穿越回现场。

想拍一段动感视频,结果发现画面颠簸得像全程在坐过山车,不仅运镜成谜,看久了还有点想吐;而这正好是Pocket 3的“舒适区”,经调查表明,62.9%的人认为Pocket 3最有吸引力的一点是“物理云台防抖,极致稳定”。

它内置的三轴机械云台,就像给相机装上了三脚架。不管是追着跑跳的孩子,还是边走边拍的旅行Vlog,画面始终和电影轨道一样丝滑。

想用手机捕捉雪花飘落的静谧瞬间,却总在按下录制键时慢了一拍,抓到的只剩模糊的残影;而开启Pocket 3的4K/120fps高帧率慢动作拍摄,漫天飞雪的5秒钟,仿佛被拉长成一段唯美的文艺片。每一片雪花的晶莹都清晰可见,让平凡的生活碎片,瞬间充满仪式感。

在发朋友圈前,许多年轻人都会陷入近半小时的调色纠结,不是担心太假就是担心色彩太过平淡;但Pocket 3就不一样了: 自带的色彩调教让画面自然通透,原图直出就能秒发朋友圈,省去P图时间。只要稍加剪辑修饰,就能成为对标电影级的个人大片。

对于想进阶的用户,它还提供各种大师滤镜,轻松调出高级电影感。怎么手搓一座赛博朋克之城?请看Pocket镜头——

萌娃、萌宠可爱但难拍:想用手机拍视频, 背景杂乱,宠物一动就失焦;有时候拍出的视频画面粗糙,像监控录像。而Pocket 3的智能跟随功能,自动跟随拍摄主体,能为拍摄者带来更好的沉浸感,享受美好时也不用一直盯着屏幕,眼睛欣赏着,镜头也自动跟随着;

社交媒体上有用户写到:“某天突然发现Pocket能把自家毛孩子拍得更可爱”,瞬间这笔消费就值回了本。一台独立于手机的拍摄设备就是有这样的魅力——平庸的生活细节,换一个镜头,也就换了个视角,生活突然又变得灿烂了起来。每次回看,都像重新经历一次那种温暖。

一个很朴素的道理:如果某个场景成为用户高频需求,那它就值得一个“专属装备”。就像最近在AI录音笔圈火爆的Plaud,当录音转文字变得和发微信一样日常,专门为它打造一个设备就变得理所应当。

这个逻辑,放在生活影像记录上同样成立,甚至更早——早在2018年,大疆就敏锐地察觉到:年轻人记录生活的需求将会在未来井喷,而手机断然是无法完全承载的。存储空间告急、电量飘红、突然弹出的通知,加上并不舒适的握持体验,这些手机拍照痛点让记录本身充满焦虑,而非享受。每一个突发状况都在提醒你:它的主要定位还是一部通信工具。

而当第一代Osmo Pocket应运而生时,生活记录的需求才真正被越来越多年轻人重视——大疆Pocket 3不是手机的替代品,而是专为“拍好生活”这个高频场景而生的独立工具。

在我们的调查问卷中,有超过60%的人体验过大疆Pocket 3,吸引他们的前三大理由分别是:小巧易携带(72.5%)、防抖稳定(62.9%)、画质好(42.8%)。

真正使用时会发现,那些曾让你焦虑的问题被温柔地解决了——

画质上,Pocket 3告别了手机的“算法感”,与手机1/3英寸底不同,Pocket 3采用了专业相机才会搭载的1英寸大底,它不再依赖复杂的算法去“模拟”出夜晚的明亮,而是通过更大的传感器,像一扇更明亮的窗,让光线自己说话。于是,烛光晚餐的暖调、城市霓虹的层次,都得以最接近肉眼所见的方式被留存下来;

稳定性上,Pocket 3对标专业摄影师肩头云台,仿佛一台mini电影机,无论是追拍欢跑的萌宠,还是记录骑行中的风景,都能留下流畅的动态记录。曾经不少海外KOL掀起一阵用Pocket 3拍摄微电影的风潮,普通人也能将自己脑内的构想呈现为电影大片;

@Blake ridder用Pocket 3拍摄微电影

Pocket 3正如其名,小巧便捷、能放入口袋,携带出门随处可拍,从诞生想要记录的念头到实时记录,只需要从口袋里掏出Pocket 3,旋转屏幕,一秒开机——这也是它最有仪式感的一环体验,就像一个身份的切换开关,打开Pocket 3就像一键切换成了自己的“生活导演”,不必担心告急的手机电量和内存,让你能全然沉浸在拍摄的当下,不错过任何值得被记录的细节。

在调查中,年轻人也将“稳定外挂、画质升级和解放手机”列为TOP3的Pocket 3产品种草点,无论如何,一台独立的拍摄设备,放大了生活记录这一需求,给了用户更多心无旁骛、沉浸于当下创作的机会,其背后的情绪价值感是不言而喻的。

梦回2018年,第一代Pocket诞生,那是个Vlog概念刚刚萌芽的时代。当多数人还在用抖动的手持画面记录生活时,大疆做出了一个有远见的决定——将专业的三轴机械云台微型化,创造出一个全新的“口袋云台相机”品类。这不是对市场的简单回应,而是为即将到来的视频表达时代,提前铺好了第一条专业轨道。

2020年,视频已成为社交语言的核心。Pocket 2的出现也是大疆在初代开创赛道上持续深耕的证明:更广的20mm焦段让画面更有故事感,专业无线麦克风让声音更有感染力。在“生活记录”这一场景下,大疆正在为年轻人铺平道路,让普通人也能轻松拍出富有情绪张力的内容。

到今天,Pocket 3已经成为社交媒体上的一种潮流符号,是年轻人心中随手拍的“影像神器”——这种品牌认知的建立不在朝夕,而是提前布局、近十年的技术积淀所得。

正如大疆开创消费级无人机,将专业的天空观景位开放给普通人一样,大疆Pocket系列的诞生,也是将那套经受过考验的成熟云台技术,带给每个拥有创作欲的普通人,重塑了大众在影像记录这件事上的习惯。

属于专业影视领域的“电影感”,已经悄然折叠,被装进每个普通人的口袋,大疆Pocket 3让稳定、流畅、富有情感的叙事,成为人人可及的日常。

这种引领地位的背后,是一份执着于真实质感的硬核浪漫。在手机厂商纷纷涌向“计算摄影”、依赖算法裁切画面以换取稳定时,大疆却选择坚守物理结构的底线,Pocket 3承袭了奥斯卡级剧组标配——如影(Ronin)系列的精巧工程能力,将电影工业的专业方案浓缩进掌心,让每一帧叙事都拥有无需修饰的原生电影质感

Pocket 3让记录脱离了手机,从冰冷的数据升华为温暖的情感表达——在快节奏的社会里,很多时候人们忙于两点一线的生活,追赶社会时钟,容易忽视掉生活里那些朴素的关键时刻。在心理学上有个概念叫“情绪补偿机制”:越是在不确定性的环境里,年轻人更愿意去寻求当下可感知、能获得即时反馈的情绪回报

一件更具质感格调的专业影像设备就是一种对自我的小馈赠。在它的陪伴下,每个人都能以导演的视角重新审视自己的生活,挖掘“电影感”的生活细节,正逐渐成为普通人治愈自我、重建生活秩序感的“情绪出口”。拿起Pocket 3一键旋屏,不止是切换了视角,也是切换了人生舞台的主体,让每个被生活压迫的年轻人角色翻转,在创作中感受到主体性与情绪愉悦。

它是一种生活态度的宣告:我珍视我的生活,我值得用更好的方式记录它。

平凡的日常也可以瞬间“电影化”,每个普通人也都能透过Pocket 3,将自己的生活变成一部电影。那些被郑重记录下的、4K/60帧的鲜活记忆,不仅是社交媒体分享的素材,更是留给自己未来的一份无比珍贵、可以反复回味的幸福瞬间。

❌