阅读视图

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

TypeScript 类型体操练习笔记(二)

进度(90 /188)

其中标记 ※ 的是我认为比较难或者涉及新知识点的题目

刷题也许没有什么意义,但是喜欢一个人思考一整天的灵光一现,也喜欢看到新奇的答案时的恍然大悟,仅此而已。

42. Medium - 1130 - ReplaceKeys ※

实现一个类型 ReplaceKeys,用于替换联合类型中的键,如果某个类型不包含该键则跳过替换。该类型接受三个参数。

一开始我只是想这么写,我想分布式条件类型 + Pick + Omit 来实现。

type ReplaceKeys<U, T, Y> = U extends any 
 ? Omit<U, T & keyof U> & Pick<Y, T & keyof U & keyof Y>
 : any

理论上 case1 是能通过的,但是一直报错。然后我又试了一下,看来判断的 Equal 不认为这两种是相等的:

type T1 = { a: number }
type T2 = { b: number }
type E = Equal<T1 & T2, { a: number, b: number }> // false

不过还是有办法的,我们可以通过一层映射把交叉类型拍平:

type IntersectionToObj<T> = {
  [K in keyof T]: T[K]
}
type E1 = Equal<IntersectionToObj<T1 & T2>, { a: number, b: number }> // true

不过我试了下第二个 case 还是不太好实现,那就直接用映射类型来解决。

利用分布式特性处理联合元素,然后遍历 U 的属性然后按要求进行处理即可。

type ReplaceKeys<U, T, Y> = U extends any
  ? {
    [K in keyof U]: K extends T ? (K extends keyof Y ? Y[K] : never) : U[K]
  }
  : never // 不会进入这个分支

但是看到别人的答案我又开始困惑了:

type ReplaceKeys<U, T, Y> = {
  [K in keyof U]: K extends T ? (K extends keyof Y ? Y[K] : never) : U[K]
}

查了半天只有这个 pr官方文档里也没有明确说明。

形如 { [P in keyof T]: X } 的映射类型(其中 T 是类型参数)被称为 isomorphic mapped type(同构映射类型),因为它会产生一个与 T 具有相同结构的类型。通过此 PR,我们使同构映射类型的实例化在联合类型上具有分布性。

43. Medium - 1367 - Remove Index Signature 移除索引签名 ※

实现 RemoveIndexSignature<T>,移除一个对象类型的索引签名。

索引签名(Index Signature) 是 TypeScript 中用于描述对象中未明确声明的属性的类型。它允许你定义一个对象可以有任意数量的属性,只要这些属性的键和值符合指定的类型。

interface StringDictionary {
  [key: string]: string;  // 索引签名
  // 表示该对象可以有任意多个属性,键必须是 string 类型,值也必须是 string 类型。
}

和索引签名对应的是具体属性,这两种也可以混合使用,但是具体属性的类型必须是索引签名类型的子类型:

interface MixedType {
  // 具体属性
  name: string;
  age: number;
  // 索引签名
  [key: string]: string | number;  // 必须包含具体属性的类型
}

要处理这个问题,就要针对索引签名的特点,他是一个宽泛的类型(string/number/symbol),而具体属性是一个字面量类型,比如 "name" ,我们依次判断它是否为 stringnumbersymbol 都不是则证明是具体属性,否则为索引签名。

type RemoveIndexSignature<T> = {
  [K in keyof T as
  string extends K 
    ? never
    : number extends K 
      ? never
      : symbol extends K 
        ? never
        : K
  ]: T[K]
}`

在评论区看到一个很天才的解法

type RemoveIndexSignature<T, P = PropertyKey> = {
  [K in keyof T as P extends K? never : K extends P ? K : never]: T[K]
}

其中 PropertyKey 上是 TypeScript 的内置类型 type PropertyKey = string | number | symbol;。它的判断过程如下:

P extends K ? never : (K extends P ? K : never)  /* P = string | number | symbol */

// becomes
(string | number | symbol) extends K ? never : (K extends P ? K : never)

// becomes
| string extends K ? never : (K extends string ? K : never)
| number extends K ? never : (K extends number ? K : never)
| symbol extends K ? never : (K extends symbol ? K : never)

本质上和我们上面的写法是一样的,但是利用条件类型的分布性,一下子判断了三种类型。୧(๑•̀◡•́๑)૭

44. Medium - 1978 - Percentage Parser 百分比解析器

实现类型 PercentageParser。根据规则 /^(\+|\-)?(\d*)?(\%)?$/ 匹配类型 T

匹配的结果由三部分组成,分别是:[正负号, 数字, 单位],如果没有匹配,则默认是空字符串。

type Sign = '+' | '-'
type PercentageParser<A extends string> =
  A extends `${infer F}%`
  /** 存在 % */
    ? F extends `${infer S extends Sign}${infer N}`
      ? [S, N, '%']
      : ['', F, '%']
  /** 不存在 % */
    : A extends `${infer S extends Sign}${infer N}`
      ? [S, N, '']
      : ['', A, '']

题目不难,加几个分支判断就可以了。或者这样写优雅一点(大概):

type SignParser<A extends string> = A extends `${infer S extends '+' | '-'}${infer N}` ? [S, N] : ['', A]
type PercentageParser<A extends string> = A extends `${infer F}%` ? [...SignParser<F>, '%'] : [...SignParser<A>, '']

45. Medium - 2070 - Drop Char 删除字符

从字符串中剔除指定字符。

type DropChar<S, C> = S extends `${infer F}${infer R}`
  ? F extends C 
    ? DropChar<R, C>
    : `${F}${DropChar<R, C>}`
  : ''

没有新的知识点,简单题。

46. Medium - 2257 - MinusOne 减一 ※

给定一个正整数作为类型的参数,要求返回的类型是该数字减 1。

有点意思的一道题目,没有新的知识点,但是类似于算法中的模拟题。需要递归加不同情况的判断,复杂度较高。

我先想到了一个比较搞的办法,生成长度为 T 的数组,然后移除一个元素,再获取数组长度。

type MakeArray<T extends number, R extends any[] = []> =
  R['length'] extends T ? R : MakeArray<T, [...R, any]>
type MinusOne<T extends number> = 
  MakeArray<T> extends [infer _F, ...infer R] ? R['length'] : never

1000 以内是可行的,但是再大就会出现错误:

type A = MakeArray<1101> // error: 类型实例化过深,且可能无限。ts(2589)

那么只能换一种方法,通过模拟减法的方式实现,枚举最后一位即可,如果最后一位大于 0 则只需要操作最后一位,否则需要递归处理:

type MinusOne2String<T extends string> =
  T extends `${infer F}0` // 如果最后一位是0,则把此位改为9,然后递归处理(题目限定了是正数)
  ? `${MinusOne2String<F>}9`
  : T extends `${infer F}9` // 其他情况直接把最后一位减一
  ? `${F}8`
  : T extends `${infer F}8`
  ? `${F}7`
  : T extends `${infer F}7`
  ? `${F}6`
  : T extends `${infer F}6`
  ? `${F}5`
  : T extends `${infer F}5`
  ? `${F}4`
  : T extends `${infer F}4`
  ? `${F}3`
  : T extends `${infer F}3`
  ? `${F}2`
  : T extends `${infer F}2`
  ? `${F}1`
  : T extends `${infer F}1`
  ? `${F}0`
  : '0'
// 100-1=099 这种情况需要删除前导零
type removeLeadZero<T extends string> = 
  T extends '0' ? '0' : T extends `0${infer R}` ? removeLeadZero<R> : T 
// 删除前导零后,转换为数字类型
type MinusOne<T extends number> = 
  removeLeadZero<MinusOne2String<`${T}`>> extends `${infer X extends number}` ? X : 0

47. Medium - 2595 - PickByType

T 中选择可赋值给 U 的属性类型集合。

type PickByType<T, U> = {
  [K in keyof T as T[K] extends U ? K : never ]: T[K]
}

送分题,知识点前面的题目都有涉及。

48. Medium - 2688 - StartsWith

实现 StartsWith<T, U>,接收两个 string 类型参数,然后判断 T 是否以 U 开头,根据结果返回 truefalse

type StartsWith<T extends string, U extends string> =
  T extends `${U}${infer F}` ? true : false

送分题+1,模板字符串类型基础。

49. Medium - 2693 - EndsWith

实现 EndsWith<T, U>,接收两个 string 类型参数,然后判断 T 是否以 U 结尾,根据结果返回 truefalse

type EndsWith<T extends string, U extends string> =
  T extends `${string}${U}` ? true : false

50. Medium - 2757 - PartialByKeys

实现一个通用的 PartialByKeys<T, K>,它接收两个类型参数 TK

K 指定应设置为可选的 T 的属性集。当没有提供 K 时,它就和普通的 Partial<T> 一样使所有属性都是可选的。

前面已经讲过 IntersectionToObj 这个小技巧,这里就比较简单了,其中 Partial 是内置的工具类型,可以把一个对象类型的全部属性都变成可选。

type IntersectionToObj<T> = {
  [K in keyof T]: T[K]
}

type PartialByKeys<T , K extends keyof T = keyof T> = 
  IntersectionToObj< Omit<T, K> & Partial<Pick<T, K>> >

51. Medium - 2759 - RequiredByKeys

实现一个通用的 RequiredByKeys<T, K>,它接收两个类型参数 TK

K 指定应设为必选的 T 的属性集。当没有提供 K 时,它就和普通的 Required<T> 一样使所有的属性成为必选的。

PartialByKeys 本质上没有什么区别,Required 也是内置的工具类型,可以把一个对象类型的全部属性,用于将一个类型 T 中的所有属性转换为‌必填属性(即移除其可选性 ?)。

type IntersectionToObj<T> = {
  [K in keyof T]: T[K]
}
type RequiredByKeys<T, K extends keyof T = keyof T> =
  IntersectionToObj<Omit<T, K> & Required<Pick<T, K>>>

52. Medium - 2793 - Mutable ※

实现一个通用的类型 Mutable<T>,使类型 T 的全部属性可变(非只读)。

这题不难,但是涉及到映射类型的一个语法,之前没有涉及过。mapped-types

type Mutable<T extends object> ={
  -readonly [K in keyof T]: T[K]
}

53. Medium - 2852 - OmitByType

从类型 T 中选择不可赋值给 U 的属性成为一个新的类型。

直到了 asMapped Types 的用法,这也很简单,和之前的 Omit 没什么区别。

type OmitByType<T, U> = {
  [K in keyof T as T[K] extends U ? never : K]: T[K]
}

54. Medium - 2946 - ObjectEntries

实现 Object.entries 的类型版本。

首先这题需要应用分布式条件类型,所以需要先构造一个由类型key组成的联合类型 U 然后 U extends ... 触发分布式。

type ObjectEntries<T, U = keyof T> =
  U extends keyof T ? [U, T[U]] : never

不过有个case 过不去。

type eq = Equal<ObjectEntries<Partial<Model>>, ModelEntries> // false
type o = ObjectEntries<Partial<Model>>
// ["name", string | undefined] | ["age", number | undefined] | ["locations", string[] | null | undefined]

可以看到由于 Partial 导致每个类型都多了一个 undefined。很明显这里需要 Required,但是需要先了解一下它的特性。

type r1 = Required<{ key?: undefined }> // {key: never}
type r2 = Required<{ key: undefined }> // {key: undefined}
type r3 = Required<{ key: string | undefined }> // {key: string | undefined}
type r4 = Required<{ key?: string | undefined }>  // {key:string}

可以看到在存在 ? 时,Required 会删除类型中的 undefined,否则不会。

而此题的要求是:如果类型存在 ? 就删除 undefined,但是如果类型只有 undefined 则不处理。我只能说,题本身不难,但是描述的不清楚,只能看用例。

type ObjectEntries<T, U = keyof T> =
  U extends keyof T 
    ? [U, [T[U]] extends [undefined] ? undefined : Required<T>[U]] 
    : never

55. Medium - 3062 - Shift

实现类型版本的 Array.shift

type Shift<T extends any[]> = T extends [infer F, ...infer R] ? [...R] : []

infer 的基础应用,在最前面的 First of Array 就了解过了。

56. Medium - 3188 - Tuple to Nested Object

给一个只包含字符串类型的元组 T 和一个类型 U 递归构建一个对象。

type TupleToNestedObject<T extends string[], U> = 
  T extends [infer F extends string, ...infer R extends string[]] 
    ? Record<F, TupleToNestedObject<R, U>> : U

每次提取数组中第一个元素,然后把该元素作为键,递归构造的对象作为值。

57. Medium - 3192 - Reverse

实现类型版本的数组反转 Array.reverse

type Reverse<T extends any[]> = T extends [infer F, ...infer R] 
  ? [...Reverse<R>, F]
  : []

使用递归的方式,每次都把第一个元素移到最后一个。

58. Medium - 3196 - Flip Arguments

实现 lodash 中 _.flip 函数的类型版本。

类型转换函数 FlipArguments<T> 要求函数类型 T,并返回一个新的函数类型,该类型具有与 T 相同的返回类型但参数顺序颠倒。

type FlipArguments<T extends Function> = 
  T extends (...args: infer P) => infer R ? (...args: Reverse<P>) => R : never

通过 infer 获取函数的参数和返回值,并且通过上一题实现的 Reverse 将参数反转。

59. Medium - 3243 - FlattenDepth

递归展开数组至指定深度

首先需要实现一个铺平一次的函数,这个比较简单

type FlattenOnce<A extends any[]> = A extends [infer F, ...infer R]
  ? F extends any[] ? [...F, ...FlattenOnce<R>] : [F, ...FlattenOnce<R>]
  : []

TypeScript 中无法进行数字计算,我们可以通过邪修实现,这点我们在前面的 MinusOne 已经实现,所以这里直接引用 MinusOne 就可以了。

type FlattenDepth<T extends any[], depth extends number = 1> =
  depth extends 0 // 判断深度为零,则已经不需要铺平了
  ? T
  : FlattenOnce<T> extends T // 判断是否铺平前后的结果一致,一致则不需要再处理了
    ? T 
    : FlattenDepth<FlattenOnce<T>, MinusOne<depth>>

当然我们可以用之前在里面用过的用数组记录数字的方法,只不过 TypeScript 中数组长度有限制,当然这一题中是没有问题的,嵌套最多才 5 层

type FlattenDepth<T extends any[], depth = 1, depArr extends any[] = []> = 
  depArr['length'] extends depth
    ? T 
    : FlattenOnce<T> extends T ? T : FlattenDepth<FlattenOnce<T>, depth, [...depArr, any]>

60. Medium - 3326 - BEM style string

使用块(Block)、元素(Element)、修饰符(Modifier)命名(BEM)是 CSS 中类的一种流行命名约定。

例如,块组件表示为 btn,依赖于块的元素表示为 btn__price,更改块样式的修饰符表示为 btn-bigbtn__prise-warning

实现 BEM<B,E,M>,从这三个参数生成字符串并集。其中 B 是字符串文字,EM 是字符串数组(可以为空)。

// 把A和B连接,先把B处理为联合类型,然后用S连接
type JoinWithSeparator<A extends string, B extends string[], S extends string, B2Union extends string = B[number]> = 
  B2Union extends any ? `${A}${S}${B2Union}` : never

type BEM<B extends string, E extends string[], M extends string[]> = 
  E['length'] extends 0 // 判断E是否为空
    ? JoinWithSeparator<B, M, '--'> // 如果E为空, B和M连接
    : M['length'] extends 0 // 判断M是否为空
      ? JoinWithSeparator<B, E, '__'> // E不为空,M为空,把B和M连接
      : JoinWithSeparator<JoinWithSeparator<B, E, '__'>, M, '--'> // 都不为空,先把B和E连接,然后再加上M

需要写一个辅助工具类型 JoinWithSeparator 用于连接一个字符和数组,逻辑有点小复杂,已经加了完整注释。

61. Medium - 3376 - InorderTraversal

实现二叉树中序遍历的类型版本。

如果会中序遍历二叉树这题就不难了,不会的可以先学学数据结构。

type InorderTraversal<T extends TreeNode | null> = 
 T extends null 
   ? [] 
   : [...InorderTraversal<T['left']>, T['val'], ...InorderTraversal<T['right']>]

比较麻烦的是,T extends null 语法无法判断第二个分支中 T 不为空,所以可以反过来,判断 T 是否为 TreeNode

type InorderTraversal<T extends TreeNode | null> =
  T extends TreeNode
  ? [
    ...InorderTraversal<T['left']>,
    T['val'],
    ...InorderTraversal<T['right']>
  ] : []

62. Medium - 4179 - Flip

实现 just-flip-object 的类型版本(把类型的键和值类型反转)。

type Flip<T> = {
  [K in keyof T as T[K] extends number | string | boolean ? `${T[K]}` : never]: K
}

为了保证 T[K] 类型正确,加了一个 extends number | string | boolean 的限制。

63. Medium - 4182 - Fibonacci Sequence 斐波那契序列 ※

实现一个通用的 Fibonacci<T>,它接受一个数字 T 并返回其相应的斐波纳契数

序列开始:1、1、2、3、5、8、13、21、34、55、89、144...

首先斐波纳契公式 f(n)=f(n-1)+f(n-2) 可以递归实现。由于 TypeScript 类型无法使用加法,所以我们通过数组的元素个数来变向进行计算,至于减法可以复用之前实现的 MinusOne

type FibonacciArray<T extends number, A extends any[] = []> =
    T extends 1
    ? [any]
    : T extends 2
      ? [any]
      : [...FibonacciArray<MinusOne<MinusOne<T>>>, ...FibonacciArray<MinusOne<T>>]
type Fibonacci<T extends number> = FibonacciArray<T>['length']

看了下别人的答案,优化空间还是很大的,下面是正向计算,Index 表示计算到了第 n 个数字,Cur 表示 f(n)Prev 表示 f(n-1)

type Fibonacci<
  T extends number,
  Index extends any[] = [any, any],
  Cur extends any[] = [any],
  Prev extends any[] = [any]
> =
  T extends 1 | 2
    ? 1
    : Index['length'] extends T
      ? Cur['length']
      : Fibonacci<T, [...Index, any], [...Cur, ...Prev], Cur>

64. Medium - 4260 - AllCombinations ※

实现 AllCombinations<S> 类型,该类型返回使用 S 中的字符所组成的所有字符串,每个字符最多使用一次。

type AllCombinations<S extends string, P extends string = ''> =
  S extends `${infer F}${infer R}` 
    ? '' | F | `${F}${AllCombinations<`${R}${P}`>}` // S[0] 开头的所有排列情况
      | AllCombinations<R, `${P}${F}`> // 除了 S[0] 开头以外的所有情况
    : ''

很有意思的题目,实现一个字符串中字符的所有组合。我的解法是 AllCombinations<S, P> 表示获取字符串 ${S}${P} 中,以 S 每个字母开头的全排列组合。

所以 AllCombinations<S, ''> 就是答案,而它等于 S[0] 为开头的所有情况,再加上 AllCombinations<S.split(1), S[0]>(伪代码示例)

S[0] 为开头的所有情况,就是求 S[0] 连接剩余字符的全排列,也就是 AllCombinations<S.split(1), ''>

65. Medium - 4425 - Greater Than

在本次挑战中,你需要实现一个类似 T > U 的类型: GreaterThan<T, U> 负数无需考虑。

这种题我可以说不难,就是有点恶心。我不喜欢!下面的代码我加了注释,应该可以看懂。

type LengthOfString<S extends string> = Split<S>['length'];
type FirstOfString<S extends string> = S extends `${infer F}${infer R}`
  ? F
  : never;
type RestOfString<S extends string> = S extends `${infer F}${infer R}`
  ? R
  : never;

type Split<S extends string> = S extends `${infer F}${infer R}`
  ? [F, ...Split<R>]
  : [];

// 比较10以内数字的大小
type GreaterThanDigit<
  T extends string,
  U extends string,
  D extends string = '9876543210'
> = D extends `${infer F}${infer R}` // 从大到小依次比较每一个数字
  ? F extends U // 如果先匹配了U 则证明T≤U 返回false
    ? false
    : F extends T // 如果先匹配了T 则证明T>U 返回true
      ? true
      : GreaterThanDigit<T, U, R> // 再尝试匹配下一个数字
  : false;

type GreaterThanString<
  T extends string,
  U extends string,
  LEN_T extends number = LengthOfString<`${T}`>, // T的长度
  LEN_U extends number = LengthOfString<`${U}`>, // U的长度
  FIRST_T extends string = FirstOfString<`${T}`>, // T的长度
  FIRST_U extends string = FirstOfString<`${U}`> // U的长度
> = LEN_U extends LEN_T // 判断长度是否相同
  ? LEN_T extends 1 // 判断相同,长度是否为1
    ? GreaterThanDigit<FIRST_T, FIRST_U> // 长度为1 直接比较首位
    : FIRST_T extends FIRST_U // 长度相同,且长度不为1,依次比较每一位,先判断首位
      ? GreaterThanString<RestOfString<`${T}`>, RestOfString<`${U}`>> // 首位相同 则比较下一位
      : GreaterThanDigit<FIRST_T, FIRST_U> // 首位不同,则比较大小
  : GreaterThan<LEN_T, LEN_U>; // 如果长度不相同,则长度大的数字更大

type GreaterThan<T extends number, U extends number> = GreaterThanString<
  `${T}`,
  `${U}`
>;

66. Medium - 4471 - Zip

在这个挑战中,你需要实现一个类型 Zip<T, U>,其中 TU 必须是元组。

就是把所有元组中的第1项组成结果中的第1项,所有元组中的第2项组成结果中的第2项....所有元组中的第n项组成结果中的第n项,比较简单。

type Zip<T extends any[], U extends any[]> = T extends [infer TF, ...infer TR]
  ? U extends [infer UF, ...infer UR]
    ? [[TF, UF], ...Zip<TR, UR>]
    : []
  : [];

67. Medium - 4484 - IsTuple ※

实现类型 IsTuple, 传入类型 T 并返回类型 T 是否为一个元组类型

tuple type is another sort of Array type that knows exactly how many elements it contains, and exactly which types it contains at specific positions.

元组类型 是另一种“数组”类型,它确切地知道它包含多少元素,以及它在特定位置包含哪些类型。

T extends readonly any[] 判断 T 为数组和元素,添加 readonly 可以兼容 readonly [1] 这种情况。

根据定义可以知道,元组类型的长度是固定的,所以 Tuple['length'] 是一个具体的数字,而数组 A['length']number

因此,可以通过 number extends T['length'] 来判断 T 是否为元组而不是数组。

type IsTuple<T> = [T] extends [never]
  ? false
  : T extends readonly any[]
    ? number extends T['length']
      ? false
      : true
    : false;

68. Medium - 4499 - Chunk

你知道 lodash 吗?Chunk 是其中一个非常有用的函数,现在让我们实现它。Chunk<T,N> 接受两个必需的类型参数,T 必须是元组,N 必须是大于等于 1 的整数。

type Chunk<
  T extends any[],
  N extends number,
  Result extends any[] = [],
  Current extends any[] = []
> = T extends [infer F, ...infer R] // 判断是否还有元素
  ? Current['length'] extends N // 有元素,判断当前的块已经满了
    ? Chunk<R, N, [...Result, Current], [F]> // 如果当前的块已经满了,把它放进结果数组里
    : Chunk<R, N, Result, [...Current, F]> // 没有满,就把元素放进当前块
  : Current['length'] extends 0 // T中所有元素都处理完了,判断当前块中是否有元素
    ? Result // 当前块为空的,直接返回结果
    : [...Result, Current]; // 否则把当前块放进结果,再返回

69. Medium - 4518 - Fill

Fill 是一个通用的 JavaScript 函数,现在来实现它的类型版本。Fill<T, N, Start?, End?> 接受 4 个参数, T 是一个元组,N 是任意类型, Start and End 是大于等于 0 的整数。把 T[Start.End] 范围内的元素都替换为 N

type Fill<
  T extends unknown[], // 原数组
  N, // 要填充的类型
  Start extends number = 0, // 开始下标
  End extends number = T['length'], // 结束下标
  Result extends unknown [] = [], // 结果数组
  In extends boolean = false // 是否在[Start,End]范围内
> = T extends [infer F, ...infer R] // T是否存在第一个元素
  ? Result['length'] extends End // 先判断是否为结束下标
    ? Fill<R, N, Start, End, [...Result, F], false>  // 是结束下标,则证明已经填充完了,后面填充T的内容就行
    : Result['length'] extends Start // 不是,判断是否为开始下标
      ? Fill<R, N, Start, End, [...Result, N], true>  // 是开始下标,则填充N,用IN=true表示已经在范围内
      : In extends true // 判断是否在[Start,End]范围内
        ? Fill<R, N, Start, End, [...Result, N], true> // 如果在范围内 则用N填充
        : Fill<R, N, Start, End, [...Result, F], false> // 不在范围内 用T中内容填充
  : Result // 处理完成,返回结果

70. Medium - 4803 - Trim Right

实现 TrimRight<T>,它接收确定的字符串类型并返回一个新的字符串,其中新返回的字符串删除了原字符串结尾的空白字符串。

type TrimRight<S extends string> =
  S extends `${infer F}${' '|'\n'|'\t'}` ? TrimRight<F> : S

71. Medium - 5117 - Without

实现一个像 Lodash.without 函数一样的泛型 Without<T, U>,它接收数组类型的 T 和数字或数组类型的 U 为参数,会返回一个去除 U 中元素的数组 T

Equal 是玄学,别问,用就完事了。

type Includes<T extends readonly unknown[], U> = 
  T extends [infer F, ...infer Rest]
    ? Equal<F, U> extends true ? true : Includes<Rest, U>
    : false;

type Without<T extends any[], U extends any> = T extends [infer F, ...infer R]
  ? U extends any[]
    ? Includes<U, F> extends true // 如果U是数组类型,使用Includes判断是否包含
      ? Without<R, U>
      : [F, ...Without<R, U>]
    : F extends U // 如果U不是数组,直接判断
      ? Without<R, U>
      : [F, ...Without<R, U>]
  : []

评论区看到了更好的解法,先转成联合在判断是否包含

type ToUnion<T extends any> = T extends any[] ? T[number] : T;

type Without<T extends any[], U extends any> = 
T extends [infer F, ...infer R]
  ? F extends ToUnion<U>
    ? Without<R, U>
    : [F, ...Without<R, U>]
  : [];

72. Medium - 5140 - Without

实现 Math.trunc 的类型版本,它接受字符串或数字,并通过删除所有小数来返回数字的整数部分。

简单的模板字符串模式匹配,注意 '-.3' 这种删除小数点后面的内容后需要手动补 0。

type Trunc<T extends number | string> =
  `${T}` extends `${infer F}.${infer _}`
    ? F extends '-' | ''
      ? `${F}0`
      : F
    : `${T}`;

73. Medium - 5153 - IndexOf

实现类型版本的 Array.indexOfindexOf<T, U> 接受两个参数,数组 T 和任意类型 U 返回 UT 中第一次出现的下标,不存在返回 -1

type IndexOf<T extends any[], U, Pre extends any[] = []> =
  T extends [infer F, ...infer R]
    ? Equal<F, U> extends true
      ? Pre['length']
      : IndexOf<R, U, [...Pre, F]>
    : -1

因为 TypeScript 无法进行计算,所以思路还是一样,用一个数组 Pre 记录已经遍历了几个数字,用 Pre['length'] 计数。

74. Medium - 5310 - Join

实现类型版本的 Array.joinJoin<T, U>接受一个数组 T 字符串或数字类型 U,返回 T 中的所有元素用 U 连接的字符串,U 默认为 ','

type Join<
  T extends any[],
  U extends string | number = ',',
  Pre extends string = ''
> = T extends [infer F, ...infer R]
  ? Pre extends ''
    ? Join<R, U, `${F & string}`>
    : Join<R, U, `${Pre}${U}${F & string}`>
  : Pre;

其中 F & string 因为 F 的类型是 any 但是只有一部分类型可以反正模板字符串中,所以这里类型会把报错,通过 & string 限制为 string

75. Medium - 5317 - LastIndexOf

实现类型版本的 Array.lastIndexOfLastIndexOf<T, U> 接受数组 T, any 类型的 U, 如果 U 存在于 T 中, 返回 U 在数组 T 中最后一个位置的索引, 不存在则返回 -1

type LastIndexOf<
  T extends any[],
  U,
  Pre extends any[] = [],
  Result extends number = -1
> = T extends [infer F, ...infer R]
? Equal<F, U> extends true // 判断当前的值是否为U
  ? LastIndexOf<R, U, [...Pre, F], Pre['length']> // 如果是,更新Result,然后继续处理
  : LastIndexOf<R, U, [...Pre, F], Result>
: Result

76. Medium - 5360 - Unique 数组去重

实现类型版本的 Lodash.uniq 方法,Unique 接收数组类型 T, 返回去重后的数组类型。

type Includes<T extends readonly unknown[], U> = 
  T extends [infer F, ...infer Rest]
    ? Equal<F, U> extends true ? true : Includes<Rest, U>
    : false;

type Unique<
  T extends any[],
  Result extends any[] = [],
> = T extends [infer F, ...infer R]
  ? Includes<Result, F> extends true
    ? Unique<R, Result>
    : Unique<R, [...Result, F]>
  : Result;

顺便评论区看到另一个,算是利用分布式条件类型更简洁的实现了 Includes

type Unique<T, U = never> =
  T extends [infer F, ...infer R]
    ? true extends (U extends U ? Equal<U, [F]> : never)
      ? Unique<R, U>
      : [F, ...Unique<R, U | [F]>]
    : []

77. Medium - 5821 - MapTypes 映射类型

实现 MapTypes<T, R>,把对象 T 中的类型根据 R 做转换。比如 R{ mapFrom: string, mapTo: boolean } 表示把 T 中的所有 string 类型改为 boolean

// 根据类型映射的定义 和 原类型 获取映射后的类型
type getType<T extends { mapFrom: any; mapTo: any }, P> = T extends any
  ? T['mapFrom'] extends P // 利用分布式条件类型,依次判断是否匹配mapFrom类型
    ? T['mapTo'] // 符合的返回对应的mapTo类型
    : never // 不符合返回never 其他类型和never联合只会剩下其他类型
  : never;

type MapTypes<T, R extends { mapFrom: any; mapTo: any }> = {
  // { mapFrom: T[K]; mapTo: any } 是否可以赋值给R
  [K in keyof T]: { mapFrom: T[K]; mapTo: any } extends R
    ? getType<R, T[K]> // 证明他的类型匹配了mapFrom 需要返回对应的mapTo
    : T[K]; // 否则不需要调整
};

78. Medium - 7544 - Construct Tuple 构造元组 ※

构造一个给定长度的元组。

这题简直太水了,递归就可以 TypeScript 最多递归到999层,所以最后一个 case Expect<Equal<ConstructTuple<1000>['length'], 1000>> 会失败。

// 生成元组,但是TS递归只能到999
type ConstructTuple<
  N extends string | number,
  Result extends any[] = []
> = `${Result['length']}` extends `${N}`
  ? Result
  : ConstructTuple<N, [...Result, unknown]>;

但是,让 9999 成功才是有趣的问题,我开始一直想二分,把自己困住了,后来发现简单的按位计算就可以。参考github.com/type-challe…

// 生成元组,但是TS递归只能到999
type ConstructTupleSimple<
  N extends string | number,
  Result extends any[] = []
> = `${Result['length']}` extends `${N}`
  ? Result
  : ConstructTupleSimple<N, [...Result, unknown]>;

// 把数组T中的元素数量*10
type Multi10<T extends any[]> = [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T]

// 从左到右依次计算 例321 = (3*10+2)*10+1
type ConstructTuple<
  L extends number | string,
  Result extends any[] = []
> = `${L}` extends `${infer F}${infer R}`
  ? ConstructTuple<R, [...Multi10<Result>, ...ConstructTupleSimple<F>]>
  : Result;

79. Medium - 8640 - Number Range

构造指定范围内所有数字的联合。

好像在前面做过类似的题目……通过 Arr 辅助记录遍历数字,Result 记录结果,InRange 记录是否在范围内。

type NumberRange<L, H, Arr extends any[] = [], Result = never, InRange = false> =
  Arr['length'] extends L
    ? NumberRange<L, H, [...Arr, unknown], L | Result, true>
    : Arr['length'] extends H
      ? Result | H
      : InRange extends true
        ? NumberRange<L, H, [...Arr, unknown], Arr['length'] | Result, InRange>
        : NumberRange<L, H, [...Arr, unknown], Result, InRange>

也有更直观的解法:

type ConstructUnion<
  N extends string | number,
  Result extends any[] = []
> = `${Result['length']}` extends `${N}`
  ? Result[number]
  : ConstructUnion<N, [...Result, Result['length']]>;

type NumberRange<L extends number, H extends number> =
  | Exclude<ConstructUnion<H>, ConstructUnion<L>>
  | L
  | H;

80. Medium - 8767 - Combination ※

给定一个字符串数组,执行排列和组合。

// 计算T中每个元素开头的 T和P中所有元素组成的全部组合
type Combination<T extends string[], P extends string[] = []> =
  T extends [infer F extends string, ...infer R extends string[]] // 先计算F开头的所有情况
    ? `${F} ${Combination<[...R, ...P]>}` // 首个单词是F 然后连接 其他单词的全排列
        //(在模板字符串中的联合类型会自动生成所有情况的模板字符串结果的联合)
      | Combination<R, [...P, F]> // 继续计算单个单词剩余单词的情况
      | F // 单个单词
    : never

这种题我每次都要花一个小时做出来,头痛。看了下别人的解法很nb,利用联合类型分布式遍历 T,少了一次递归。

type Combination<T extends string[], All = T[number], Item = All>
  = Item extends string
    ? Item | `${Item} ${Combination<[], Exclude<All, Item>>}`
    : never

81. Medium - 8987 - Subsequence ※

给定一个唯一元素数组,返回所有可能的子序列。

type UnionAddT<U extends any[], T> = U extends any ? [T, ...U] : never
type Subsequence<T extends any[]> = T extends [infer F, ...infer R]
  ? Subsequence<R> // 不包含F的所有子序列
      | UnionAddT<Subsequence<R>, F> // 包含F的所有子序列
  : []

这题不难,加了重点标识因为新学了一个语法:当 ... 展开运算符应用到联合类型时,会对联合类型的每个成员分别展开,然后将结果再组成联合类型。

type Subsequence<T extends unknown[]> = T extends [infer X, ...infer Y]
  ? [X, ...Subsequence<Y>] | Subsequence<Y>
  : [];

82. Medium - 9142 - CheckRepeatedChars

实现类型 CheckRepeatedChars<S> 返回 S 中是否有重复字符。

type CheckRepeatedChars<
  T extends string,
  visited = never
> = T extends `${infer F}${infer R}`
  ? F extends visited
    ? true
    : CheckRepeatedChars<R, visited | F>
  : false;

83. Medium - 9286 - FirstUniqueCharIndex

给一个字符串 S 找到第一个不重复字符的下标,不存在返回 -1。 (灵感来自 leetcode 387)(笑死力扣都来了)

type GetRepectChars<T extends string, Once = never, Repeated = never> = 
  T extends `${infer F}${infer R}`
    ? F extends Once
      ? GetRepectChars<R, Once, Repeated | F>
      : GetRepectChars<R, Once | F, Repeated>
    : Repeated

type FirstUniqueCharIndex<T extends string, Repeated = GetRepectChars<T>, Index extends any[] = []> = 
  T extends `${infer F}${infer R}`
    ? F extends Repeated
      ? FirstUniqueCharIndex<R, Repeated, [...Index, F]>
      : Index['length']
    : -1

84. Medium - 9616 - Parse URL Params

实现类型层面的解析器,把 URL 中的参数字符串解析为一个联合。

type ParseUrlParams<T extends string> = T extends `${infer F}/${infer R}`
  ? F extends `:${infer P}`
    ? P | ParseUrlParams<R>
    : ParseUrlParams<R>
  : T extends `:${infer P}`
    ? P
    : never

85. Medium - 9896 - GetMiddleElement

通过实现一个 GetMiddleElement 方法,获取数组的中间元素,用数组表示

如果数组的长度为奇数,则返回中间一个元素 如果数组的长度为偶数,则返回中间两个元素

type GetMiddleElement<T extends any[]> =
  T['length'] extends 0 | 1 | 2
    ? T
    : T extends [infer _L, ...infer M, infer _R]
      ? GetMiddleElement<M>
      : []

简单,每次删除前后两个元素,对长度为 0 1 2 的数组特殊处理。

86. Medium - 9898 - Appear only once

找出目标数组中只出现过一次的元素。例如:输入 [1,2,2,3,3,4,5,6,6,6],输出 [1,4,5]

// 判断联合类型T中是否存在U
type Includes<T, U> = true extends (T extends any ? Equal<T, U> : never)
  ? true
  : false;

type FindEles<
  T extends any[],
  Pre extends any[] = [],
  Res extends any[] = []
> = T extends [infer F, ...infer R]
  ? Includes<[...Pre, ...R][number], F> extends true // 如果F前后组成的数组是否包含F
    ? FindEles<R, [...Pre, F], Res> // 包含F 则证明不唯一 结果不添加F
    : FindEles<R, [...Pre, F], [...Res, F]> // 不包含F 则证明唯一 结果添加F
  : Res; // 遍历结束 返回结果

87. Medium - 9989 - Count Element Number To Object

通过实现一个 CountElementNumberToObject 方法,统计数组中相同元素的个数。

// 把数组拍平,然后把其中never元素删除
type Flatten<A extends any[]> = A extends [infer F, ...infer R] // 判断A存在第一个元素F
  ? [F] extends [never]
    ? [...Flatten<R>]
    : F extends any[]
    ? [...Flatten<F>, ...Flatten<R>]
    : [F, ...Flatten<R>]
  : [];

type CountElementNumberToObject<
  T extends any[],
  // 辅助计数的对象,用数组计数
  Aux extends Record<string | number, any[]> = {},
  // T中有嵌套数组 把T拍平
  F extends (number | string)[] = Flatten<T>
> = F extends [
  infer L extends number | string, // 取第一个元素
  ...infer R extends (number | string)[]
]
  ? CountElementNumberToObject<
      R,
      {
        [K in keyof Aux | L]: K extends L // 遍历Aux中key
          ? L extends keyof Aux // 遍历到L了,判断如果L是Aux中的key
            ? [...Aux[K], unknown] // 就在对应数组中添加一个元素
            : [unknown] // 不在就新创建一个数组,添加一个元素
          : Aux[K]; // 其他key不做处理
      }
    >
  : {
      [K in keyof Aux]: Aux[K]['length']; // 把结果映射为数组的长度
    };

88. Medium - 10969 - Integer ※

请完成类型 Integer<T>,类型 T 继承于 number,如果 T 是一个整数则返回它,否则返回 never

type OnlyZero<T> = T extends `${infer F}${infer R}`
  ? F extends '0'
    ? OnlyZero<R>
    : false
  : true;
type ToNumber<T> = T extends `${infer N extends number}` ? N : never;
type Integer<T> = T extends number
  ? number extends T
    ? never
    : `${T}` extends `${infer Int}.${infer Deci}`
    ? OnlyZero<Deci> extends true
      ? ToNumber<Int>
      : never
    : T
  : never;

虽然也不算难,但是一看评论区天塌了。

type Integer<T extends string | number> = number extends T
  ? never
  : `${T}` extends `${string}.${string}`
  ? never
  : T;
// 或者这样,因为 bigint 只能是整数
type Integer<T extends number> = `${T}` extends `${bigint}` ? T : never

我自己试了一下数字转字符串,发现对于多余的小数点后面的 0 会被删除。

type x = `${1.0}` // "1"
type x1 = `${1.2}` // "1.2"
type x2 = `${1.200}` // "1.2"

89. Medium - 16259 - ToPrimitive ※

把对象中类型为字面类型(标签类型)的属性,转换为基本类型。

这题可以枚举类型实现,不过就没啥意思了。看到一种神奇的解法,用到了 valueOf

type ToPrimitive<T> = T extends (...args: any[]) => any
  ? Function
  : T extends object
    ? { [K in keyof T]: ToPrimitive<T[K]> }
    : T extends { valueOf: () => infer R }
      ? R
      : T;

JavaScript 中每个包装对象都有 valueOf() 方法:

  • String.prototype.valueOf() 返回 string
  • Number.prototype.valueOf() 返回 number
  • ...

在 TypeScript 类型系统中,我们可以利用这个特性:

interface String {
  /** Returns the primitive value of the specified object. */
  valueOf(): string;
  // ... 其他方法
}

// string 字面量类型
type Test1 = 'Tom' extends { valueOf: () => infer R } ? R : never
// 'Tom' 是 string 类型,string 有 valueOf(): string
// R = string ✅

type Test2 = 30 extends { valueOf: () => infer R } ? R : never
// 30 是 number 类型,number 有 valueOf(): number
// R = number ✅

90. Medium - 17973 - DeepMutable

实现一个通用的 DeepMutable ,它使对象的每个属性,及其递归的子属性 - 可变。

type DeepMutable<T extends object> = {
  -readonly [K in keyof T]: T[K] extends (...args: any) => any 
    ? T[K]
    : T[K] extends object ? DeepMutable<T[K]> : T[K]
}

深度解析 JWT:从 RFC 原理到 NestJS 实战与架构权衡

1. 引言

HTTP 协议本质上是无状态(Stateless)的。在早期的单体应用时代,为了识别用户身份,我们通常依赖 Session-Cookie 机制:服务端在内存或数据库中存储 Session 数据,客户端浏览器通过 Cookie 携带 Session ID。

然而,随着微服务架构和分布式系统的兴起,这种有状态(Stateful)的机制暴露出了明显的弊端:Session 数据需要在集群节点间同步(Session Sticky 或 Session Replication),这极大地限制了系统的水平扩展能力(Horizontal Scaling)。

为了解决这一痛点,JSON Web Token(JWT)应运而生。作为一种轻量级、自包含的身份验证标准,JWT 已成为现代 Web 应用——特别是前后端分离架构与微服务架构中——主流的身份认证解决方案。本文将从原理剖析、NestJS 实战、架构权衡及高频面试考点四个维度,带你全面深入理解 JWT。

2. 什么是 JWT

JWT(JSON Web Token)是基于开放标准 RFC 7519 定义的一种紧凑且自包含的方式,用于在各方之间以 JSON 对象的形式安全地传输信息。

核心特性:

  • 紧凑(Compact) :体积小,可以通过 URL 参数、POST 参数或 HTTP Header 发送。
  • 自包含(Self-contained) :Payload 中包含了用户认证所需的所有信息,避免了多次查询数据库。

主要应用场景:

  1. 身份认证(Authorization) :这是最常见的使用场景。一旦用户登录,后续请求将包含 JWT,允许用户访问该令牌允许的路由、服务和资源。
  2. 信息交换(Information Exchange) :利用签名机制,确保发送者的身份是合法的,且传输的内容未被篡改。

3. JWT 的解剖学:原理详解

一个标准的 JWT 字符串由三部分组成,通过点(.)分隔:Header(请求头).Payload(载荷).Signature(签名信息)。

3.1 Header(头部)

Header 通常包含两部分信息:令牌的类型(即 JWT)和所使用的签名算法(如 HMAC SHA256 或 RSA),一般会有多种算法,如果开发者无选择,那么默认是HMAC SHA256算法。

JSON

{
  "alg": "HS256",
  "typ": "JWT"
}

该 JSON 被 Base64Url 编码后,构成 JWT 的第一部分。

3.2 Payload(负载)

Payload 包含声明(Claims),即关于实体(通常是用户)和其他数据的声明。声明分为三类:

  1. Registered Claims(注册声明) :一组预定义的、建议使用的权利声明,如:

    • iss (Issuer): 签发者
    • exp (Expiration Time): 过期时间
    • sub (Subject): 主题(通常是用户ID)
    • aud (Audience): 受众
  2. Public Claims(公共声明) :可以由使用 JWT 的人随意定义。

  3. Private Claims(私有声明) :用于在同意使用这些定义的各方之间共享信息,如 userId、role 等。

架构师警示:
Payload 仅仅是进行了 Base64Url 编码(Encoding) ,而非 加密(Encryption)
这意味着,任何截获 Token 的人都可以通过 Base64 解码看到 Payload 中的明文内容。因此,严禁在 Payload 中存储密码、手机号等敏感信息。

3.3 Signature(签名)

签名是 JWT 安全性的核心。它是对前两部分(编码后的 Header 和 Payload)进行签名,以防止数据被篡改。

生成签名的公式如下(以 HMAC SHA256 为例):

Code

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

原理解析:
服务端持有一个密钥(Secret),该密钥绝不能泄露给客户端。当服务端收到 Token 时,会使用同样的算法和密钥重新计算签名。如果计算出的签名与 Token 中的 Signature 一致,说明 Token 是由合法的服务端签发,且 Payload 中的内容未被篡改(完整性校验)。

4. 实战:基于 NestJS 实现 JWT 认证

NestJS 是 Node.js 生态中优秀的企业级框架。下面演示如何使用 @nestjs/jwt 和 @nestjs/passport 实现标准的 JWT 认证流程。

4.1 依赖安装

Bash

npm install --save @nestjs/passport passport @nestjs/jwt passport-jwt
npm install --save-dev @types/passport-jwt

4.2 Module 配置

在 AuthModule 中注册 JwtModule,配置密钥和过期时间。

TypeScript

// auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';

@Module({
  imports: [
    PassportModule,
    JwtModule.register({
      secret: 'YOUR_SECRET_KEY', // 生产环境请使用环境变量
      signOptions: { expiresIn: '60m' }, // Token 有效期
    }),
  ],
  providers: [AuthService, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

4.3 Service 层:签发 Token

实现登录逻辑,验证用户信息通过后,生成 JWT。

TypeScript

// auth.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(private readonly jwtService: JwtService) {}

  async login(user: any) {
    const payload = { username: user.username, sub: user.userId };
    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}

4.4 Strategy 实现:解析 Token

编写策略类,用于解析请求头中的 Bearer Token 并进行验证。

TypeScript

// jwt.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false, // 拒绝过期 Token
      secretOrKey: 'YOUR_SECRET_KEY', // 需与 Module 中配置一致
    });
  }

  async validate(payload: any) {
    // passport 会自动把返回值注入到 request.user 中
    return { userId: payload.sub, username: payload.username };
  }
}

4.5 Controller 使用:路由保护

TypeScript

// app.controller.ts
import { Controller, Get, UseGuards, Request } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Controller('profile')
export class ProfileController {
  @UseGuards(AuthGuard('jwt'))
  @Get()
  getProfile(@Request() req) {
    return req.user; // 这里是通过 JwtStrategy.validate 返回的数据
  }
}

5. 深度分析:JWT 的优缺点与架构权衡

优点

  1. 无状态与水平扩展(Stateless & Scalability) :服务端不需要存储 Session 信息,完全消除了 Session 同步问题,非常适合微服务和分布式架构。
  2. 跨域友好:不依赖 Cookie(尽管可以结合 Cookie 使用),在 CORS 场景下处理更为简单,且天然适配移动端(iOS/Android)开发。
  3. 性能:在不涉及黑名单机制的前提下,验证 Token 只需要 CPU 计算签名,无需查询数据库,减少了 I/O 开销。

缺点与挑战

  1. 令牌体积:JWT 包含了 Payload 信息,相比仅存储 ID 的 Cookie,其体积更大,这会增加每次 HTTP 请求的 Header 大小,影响流量。
  2. 撤销难题(Revocation) :这是 JWT 最大 的痛点。JWT 一旦签发,在有效期内始终有效。服务端无法像 Session 那样直接删除服务器端数据来强制用户下线。

6. 面试高频考点与解决方案(进阶)

在面试中,仅仅展示如何生成 JWT 是远远不够的,面试官更关注安全性与工程化挑战。

问题 1:JWT 安全吗?如何防范攻击?

  • XSS(跨站脚本攻击) :如果将 JWT 存储在 localStorage 或 sessionStorage,恶意 JS 脚本可以轻松读取 Token。

    • 解决方案:建议将 Token 存储在标记为 HttpOnly 的 Cookie 中,这样 JS 无法读取。
  • CSRF(跨站请求伪造) :如果使用 Cookie 存储 Token,则会面临 CSRF 风险。

    • 解决方案:使用 SameSite=Strict 属性,或配合 CSRF Token 防御。如果坚持存储在 localStorage 并通过 Authorization Header 发送,则天然免疫 CSRF,但需重点防范 XSS。
  • 中间人攻击:由于 Header 和 Payload 是明文编码。

    • 解决方案:必须强制全站使用 HTTPS

问题 2:如何实现注销(Logout)或强制下线?

既然 JWT 是无状态的,如何实现“踢人下线”?这实际上是无状态管控性之间的权衡。

  • 方案 A:黑名单机制(Blacklist)

    • 将用户注销或被封禁的 Token ID (jti) 存入 Redis,设置过期时间等于 Token 的剩余有效期。
    • 每次请求验证时,先校验签名,再查询 Redis 是否在黑名单中。
    • 权衡:牺牲了部分“无状态”优势(引入了 Redis 查询),但获得了即时的安全管控。
  • 方案 B:版本号/时间戳控制

    • 在 JWT Payload 中加入 token_version。
    • 在数据库用户表中也存储一个 token_version。
    • 当用户修改密码或注销时,增加数据库中的版本号。
    • 权衡:每次验证都需要查询数据库比对版本号,退化回了 Session 的模式,性能开销大。

问题 3:Token 续签(Refresh Token)机制是如何设计的?

为了解决 JWT 有效期过长不安全、过短体验差的问题,业界标准做法是 双 Token 机制

  1. Access Token:有效期短(如 15 分钟),用于访问业务接口。
  2. Refresh Token:有效期长(如 7 天),用于换取新的 Access Token。

流程设计:

  • 客户端请求接口,若 Access Token 过期,服务端返回 401。
  • 客户端捕获 401,携带 Refresh Token 请求 /refresh 接口。
  • 服务端验证 Refresh Token 合法(且未在黑名单/数据库中被禁用),签发新的 Access Token。
  • 关键点:Refresh Token 通常需要在服务端(数据库)持久化存储,以便管理员可以随时禁用某个 Refresh Token,从而间接实现“撤销”用户的登录状态。

7. 结语

JWT 并不是银弹。它通过牺牲一定的“可控性”换取了“无状态”和“扩展性”。

在架构选型时:

  • 如果你的应用是小型单体,且对即时注销要求极高,传统的 Session 模式可能更简单有效。
  • 如果你的应用是微服务架构,或者需要支持多端登录,JWT 是不二之选。
  • 在构建企业级应用时,切勿盲目追求纯粹的无状态。推荐使用 JWT + Access/Refresh Token 双令牌 + Redis 黑名单 的组合拳,以在安全性、性能和扩展性之间取得最佳平衡。

对象数组的排序与分组:sort / localeCompare / 自定义 compare

日常开发里,列表、表格、统计几乎都绕不开「对象数组」的排序和分组。本文不讲底层原理,只讲怎么选、为什么选、容易踩哪些坑。适合会写 JS 但概念有点混的同学,也适合想补齐基础的前端老手。

一、Array.sort 到底在干什么

1.1 三个关键点

要点 说明
原地排序 sort() 会直接修改原数组,不会返回新数组
默认行为 不传比较函数时,按字符串逐个字符比较
compare 返回值 负数:a 排前面;0:不变;正数:b 排前面

有没有同学会有这样的疑问:compare 返回值?这是啥? 解释:

  • 这里的 compare 指的是 Array.sort() 方法中传入的比较函数(也就是你后面写的 (a, b) => a - b 这种形式)。
  • 简单说:当你用sort()排序时,传入的这个函数就是 compare,它的作用是告诉 sort() 两个元素(ab)该怎么排,返回值直接决定排序结果,和表格里的说明完全对应。
  • 比如 nums.sort((a, b) => a - b) 中,(a, b) => a - b 就是 compare 比较函数。

1.2 第一个坑:数字数组直接用 sort

const nums = [10, 2, 1];
nums.sort(); // 这一步已经把原数组 nums 改了!以为会得到 [1, 2, 10]
console.log(nums); // 打印的是被修改后的原数组,不是初始值。实际得到 [1, 10, 2] —— 按字符串 "10"、"2"、"1" 比较了!
// ✅ 正确写法
nums.sort((a, b) => a - b);   // 升序 [1, 2, 10]
nums.sort((a, b) => b - a);   // 降序 [10, 2, 1]

:为什么按字符串比较会得到 [1, 10, 2]? sort() 默认的字符串比较规则是「逐字符按 Unicode 码点比较」,不是看数字大小,步骤拆解如下:

  1. 先把数组里的数字都转成字符串:10→"10"、2→"2"、1→"1";
  2. 从第一个字符开始比,字符的 Unicode 码点:"1"(码点 49)< "2"(码点 50);
  3. 具体比较过程:
    • 比较 "1" 和 "10":第一个字符都是 "1"(码点相同),但 "1" 没有第二个字符,所以 "1" < "10";
    • 比较 "10" 和 "2":第一个字符 "1" < "2",所以 "10" < "2"

1.3 第二个坑:原数组被改了

const original = [3, 1, 2];
const sorted = original.sort((a, b) => a - b);

console.log(sorted);   // [1, 2, 3]
console.log(original); // [1, 2, 3] —— 原数组也被改了!

// ✅ 需要保留原数组时,先浅拷贝再排序
const sorted2 = [...original].sort((a, b) => a - b);

二、对象数组按不同字段排序

2.1 按数字排序

const users = [
  { name: '张三', age: 25 },
  { name: '李四', age: 18 },
  { name: '王五', age: 30 }
];

// 按 age 升序
users.sort((a, b) => a.age - b.age);
// 结果:李四(18) → 张三(25) → 王五(30)

// 按 age 降序
users.sort((a, b) => b.age - a.age);

写法记忆:升序 a - b,降序 b - a

2.2 按字符串排序

// 按 name 字母/拼音顺序
users.sort((a, b) => a.name.localeCompare(b.name));

直接用 a.name > b.name ? 1 : -1 可以工作,但遇到中文、大小写、多语言时容易出问题,所以更推荐 localeCompare,后面会细讲。

2.3 按日期排序

日期有两种常见形式:字符串和时间戳。

const orders = [
  { id: 1, date: '2025-02-15' },
  { id: 2, date: '2025-01-20' },
  { id: 3, date: '2025-02-10' }
];

// 方式一:YYYY-MM-DD 格式的字符串可以直接用 localeCompare
orders.sort((a, b) => a.date.localeCompare(b.date));

// 方式二:转时间戳(适用各种日期格式)
orders.sort((a, b) => new Date(a.date) - new Date(b.date));

建议:后端返回的日期如果是 YYYY-MM-DD,用 localeCompare 即可;格式不统一时,统一用 new Date() 转时间戳再比较。

2.4 多字段排序

先按 A 排序,A 相同再按 B 排序,可以用 || 链式比较:

users.sort((a, b) => {
  if (a.age !== b.age) return a.age - b.age;  // 先按年龄
  return a.name.localeCompare(b.name);        // 年龄相同再按姓名
});

// 更简洁的写法
users.sort((a, b) => a.age - b.age || a.name.localeCompare(b.name));

原理a.age - b.age 为 0 时,0 || xxx 会取后面的 localeCompare 结果。

三、localeCompare:字符串排序的正确姿势

3.1 为什么不用 >、< 比较字符串?

const arr = ['张三', '李四', '王五', 'apple', 'Apple'];
arr.sort((a, b) => a > b ? 1 : -1);  // 按 Unicode 比较,中文结果不符合直觉
arr.sort((a, b) => a.localeCompare(b));  // 按语言规则,更符合人类习惯

localeCompare 可以:

  • 中文按拼音
  • 控制大小写敏感
  • 数字按数值比较(如 "10" 在 "2" 后面)

3.2 常用用法

// 指定语言(中文按拼音)
'张三'.localeCompare('李四', 'zh-CN');  // 负数,张在李后面

// 忽略大小写
'apple'.localeCompare('Apple', undefined, { sensitivity: 'base' });  // 0,视为相等

// 数字按数值比较
['10', '2', '1'].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
// 结果:['1', '2', '10']

3.3 兼容性说明

现代浏览器和 Node 都支持 localeCompare。带 options 配置的 localeCompare 写法,在老环境(旧浏览器 / 旧 Node 版本)中可能表现不一致,生产环境建议先小范围验证。

// 忽略大小写(options:{ sensitivity: 'base' })
'apple'.localeCompare('Apple', undefined, { sensitivity: 'base' });
// 数字按数值比较(options:{ numeric: true })
['10','2'].sort((a,b) => a.localeCompare(b, undefined, { numeric: true }));

老环境问题:像旧版 IE、低版本 Node(比如 Node.js 10 以下),对这些options配置支持不完善(比如不识别numeric: true),导致排序结果出错,所以生产环境要先小范围验证。

3.4 补充localeCompareoptions写法 老环境兼容技巧

核心兼容思路:降级处理——先判断环境是否支持localeCompareoptions配置,支持则用带options的简洁写法,不支持则降级为基础写法,保证排序效果一致,且代码简单可直接套用(无需额外引入兼容库)。

场景1:忽略大小写排序(对应options: { sensitivity: 'base' })音标:/sensəˈtɪvəti/

老环境兼容写法(适配旧IE、低版本Node):

// 兼容函数:忽略大小写比较两个字符串
function compareIgnoreCase(a, b) {
  // 先统一转小写,再用基础localeCompare(老环境均支持无options写法)
  const lowerA = a.toLowerCase();
  const lowerB = b.toLowerCase();
  return lowerA.localeCompare(lowerB, 'zh-CN'); // 中文场景可加语言标识
}

// 用法(和带options写法效果一致)
const arr = ['apple', 'Apple', 'Banana', 'banana'];
arr.sort(compareIgnoreCase); // 结果:['apple', 'Apple', 'Banana', 'banana']

场景2:数字字符串按数值排序(对应options: { numeric: true }

老环境兼容写法(避免老环境不识别numeric 音标:/njuːˈmerɪk/ 配置导致排序错乱):

// 兼容函数:数字字符串按数值排序
function compareNumericStr(a, b) {
  // 降级思路:转成数字比较(贴合原文数字排序逻辑,老环境完全支持)
  const numA = Number(a);
  const numB = Number(b);
  return numA - numB; // 升序,降序则改为numB - numA
}

// 用法(和带options写法效果一致)
const arr = ['10', '2', '1', '25'];
arr.sort(compareNumericStr); // 结果:['1', '2', '10', '25']

关键注意点

  • 无需判断环境:上述兼容写法兼容所有环境(老环境正常运行,新环境也不影响效果),不用额外写环境判断代码,简化开发。

  • 生产环境验证:如果老环境占比极低,可直接用带options写法,上线前用老环境(如IE11、Node.js 8)简单测试1个排序案例即可。

四、分组统计:从排序到 groupBy 【分组】

排序和分组是两个不同操作:

  • 排序:改变顺序,不拆分数组
  • 分组:按某个字段把数组拆成多组

JS 没有内置 groupBy,可以用 reduce 实现:

const orders = [
  { id: 1, status: 'paid', amount: 100 },
  { id: 2, status: 'pending', amount: 50 },
  { id: 3, status: 'paid', amount: 200 }
];

const byStatus = orders.reduce((acc, item) => {
  const key = item.status;
  if (!acc[key]) acc[key] = [];
  acc[key].push(item);
  return acc;
}, {});

// 结果:
// {
//   paid: [{ id: 1, ... }, { id: 3, ... }],
//   pending: [{ id: 2, ... }]
// }

分组后再排序

分组后,如果每组内部还要排序:

Object.keys(byStatus).forEach(key => {
  byStatus[key].sort((a, b) => b.amount - a.amount);  // 每组按金额降序
});

分组 + 统计

需要同时统计每组数量或汇总值时:

const stats = orders.reduce((acc, item) => {
  const key = item.status;
  if (!acc[key]) {
    acc[key] = { list: [], total: 0, count: 0 };
  }
  acc[key].list.push(item);
  acc[key].total += item.amount;
  acc[key].count += 1;
  return acc;
}, {});

// 结果示例:{ paid: { list: [...], total: 300, count: 2 }, ... }

五、踩坑速查表

坑点 错误表现 正确写法
数字数组排序错乱 [10, 2, 1].sort()[1, 10, 2] arr.sort((a, b) => a - b)
原数组被修改 排序后原数组也变了 [...arr].sort(...)
中文排序不对 直接用 >< 比较 a.localeCompare(b, 'zh-CN')
多字段排序只写了一层 只按第一个字段排 a.age - b.age || a.name.localeCompare(b.name)
日期格式不统一 字符串比较出错 new Date(a.date) - new Date(b.date)

六、小结

  1. 数字排序:用 (a, b) => a - bb - a,不要用默认 sort()
  2. 字符串排序:优先用 localeCompare,尤其是中文和多语言场景。
  3. 日期排序YYYY-MM-DDlocaleCompare,其他格式用时间戳。
  4. 多字段排序:用 || 串联多个比较。
  5. 分组:用 reducegroupBy,再按需对每组排序或统计。
  6. 保留原数组:排序前先 [...arr] 浅拷贝。

这些写法足够覆盖大部分日常需求,记住上面的速查表,可以少踩很多坑。


以上就是本次的学习分享,欢迎大家在评论区讨论指正,与大家共勉。

我是 Eugene,你的电子学友。

如果文章对你有帮助,别忘了点赞、收藏、加关注,你的认可是我持续输出的最大动力~

Flutter 为什么能运行在 HarmonyOS 上

335328e4fabd7656e8f1e9587269d3a4.jpeg

前言

Flutter 是 Google 推出的跨平台 UI 框架,最初只支持 iOS 和 Android。随着 HarmonyOS 的崛起,Flutter 也能在鸿蒙系统上运行了。这背后到底是怎么实现的呢?本文将从源码层面进行解析。


一、核心原理:Flutter 分层架构

要理解 Flutter 如何在 HarmonyOS 上运行,首先需要了解 Flutter 的架构。Flutter 采用分层设计,从上到下分为三层:

┌─────────────────────────────────┐
│   Framework 层(Dart)           │  ← Flutter 代码
├─────────────────────────────────┤
│   Engine 层(C++)               │  ← 渲染引擎(Impeller)
├─────────────────────────────────┤
│   Embedder 层(平台相关)         │  ← 与操作系统交互(调用 HarmonyOS 原生 API)
└─────────────────────────────────┘

前面两层完全复用现有Dart和C++代码,而 Embedder 层则是为 HarmonyOS 定制的。

关键点:Embedder 层

Embedder 层是 Flutter 能够跨平台运行的关键。它负责:

  • 创建和管理窗口

  • 处理输入事件

  • 调用系统 API

  • 管理渲染 Surface

**不同平台有不同的 Embedder 实现: **

  • Android:platform_view_android.cc

  • iOS:platform_view_ios.mm

  • HarmonyOS:platform_view_ohos.cpp


cc和cpp是标准的C++语言代码后缀

鸿蒙的系统API是C++ 实现的,所以鸿蒙platform_view 使用C++实现进行调用最方便**

二、HarmonyOS Embedder 的核心实现

让我们看看 HarmonyOS Embedder 的核心代码结构:

2.1 平台视图(PlatformViewOHOS)

这是 HarmonyOS Embedder 的核心类,位于: engine/src/flutter/shell/platform/ohos/platform_view_ohos.cpp

class PlatformViewOHOS final : public PlatformView {
 public:
  PlatformViewOHOS(PlatformView::Delegate& delegate,
                   const flutter::TaskRunners& task_runners,
                   const std::shared_ptr<PlatformViewOHOSNapi>& napi_facade,
                   const std::shared_ptr<flutter::OHOSContext>& ohos_context);

  // 通知窗口创建
  void NotifyCreate(fml::RefPtr<OHOSNativeWindow> native_window); 

  // 更新显示尺寸
  void UpdateDisplaySize(int width, int height);

  // 分发平台消息
  void DispatchPlatformMessage(std::string name, void* message, ...);
 private:
  std::shared_ptr<OHOSContext> ohos_context_;  // HarmonyOS 图形上下文
  std::shared_ptr<PlatformViewOHOSNapi> napi_facade_;  // NAPI装饰器(NAPI 是 HarmonyOS 提供的 JavaScript 接口, 用于调用 HarmonyOS 系统 API
  std::unique_ptr<OHOSSurface> ohos_surface_;  // HarmonyOS 渲染 Surface, surface 是渲染的目标画布, 可以是窗口, 也可以是离屏缓冲区
};

**这个类做了什么? **

  1. 继承自 PlatformView(Flutter 的通用平台视图接口)

  2. 持有 HarmonyOS 的图形上下文 OHOSContext

  3. 持有 NAPI装饰器 PlatformViewOHOSNapi(用于调用 HarmonyOS 原生 API)

  4. 管理渲染 Surface OHOSSurface

2.2 Shell 持有者(OHOSShellHolder)

Shell 是 Flutter 引擎的核心,负责管理 Flutter 应用的生命周期、渲染循环、事件处理等, OHOSShellHolder 负责创建和管理 Shell:

class OHOSShellHolder {
 public:
  // 构造函数
  // settings: Flutter 引擎启动参数(如是否启用 Impeller、日志级别等)

  // napi_facade: 与 HarmonyOS 原生层交互的 NAPI 装饰器

  // platform_loop: HarmonyOS 平台线程的 looper,用于投递平台任务
  OHOSShellHolder(const flutter::Settings& settings,
                  std::shared_ptr<PlatformViewOHOSNapi> napi_facade,
                  void* platform_loop);

  // 析构函数:确保 Shell 安全退出并释放所有资源
  ~OHOSShellHolder();
 
  // 启动 Flutter 引擎,加载 Dart 代码并开始渲染
  // hap_asset_provider: HarmonyOS HAP 包资源提供器,用于读取 assets、fonts、kernel_blob 等
  // entrypoint: Dart 入口函数名(默认为 main)

  // libraryUrl: Dart 库 URI(如 package:my_app/main.dart)

  // entrypoint_args: 传给 Dart main 的命令行参数列表

  void Launch(std::unique_ptr<OHOSAssetProvider> hap_asset_provider,
              const std::string& entrypoint,
              const std::string& libraryUrl,
              const std::vector<std::string>& entrypoint_args);
  // 优雅地停止 Flutter Shell,等待所有任务完成后退出
  void Shutdown();
  // 获取 PlatformViewOHOS 的弱引用,用于在平台线程安全地访问平台视图
  fml::WeakPtr<PlatformViewOHOS> GetPlatformView();
  // 设置应用生命周期回调,供 HarmonyOS 通知 Flutter 前后台切换
  void SetLifecycleHandler(std::function<void(AppLifecycleState)> handler);
  // 设置平台消息回调,供 HarmonyOS 主动发消息到 Dart 侧
  void SetPlatformMessageHandler(
      std::function<void(const std::string& channel,
                         const std::vector<uint8_t>& message,
                         std::function<void(std::vector<uint8_t>)> reply)> handler);
  // 向 Dart 侧发送平台消息,支持异步回调
  void SendPlatformMessage(const std::string& channel,
                           const std::vector<uint8_t>& message,
                           std::function<void(std::vector<uint8_t>)> reply = nullptr);
  // 通知 Flutter 引擎窗口尺寸变化,触发重新布局
  void NotifyViewportMetricsChanged(const ViewportMetrics& metrics);
  // 通知 Flutter 引擎内存压力,触发 Dart 侧 GC 或资源释放
  void NotifyLowMemoryWarning();
  // 获取当前 Shell 的运行状态
  enum class ShellState { kNotStarted, kRunning, kShuttingDown, kStopped };
  ShellState GetShellState() const;
  // 返回当前线程安全的 Shell 指针,仅用于调试或测试
  Shell* GetShellUnsafe() const { return shell_.get(); }
 private:
  // 创建并配置 Flutter Shell,内部调用 Shell::Create
  void CreateShell(const flutter::Settings& settings,
                   std::unique_ptr<OHOSAssetProvider> asset_provider);
  // 初始化平台任务执行器,将 HarmonyOS 平台任务映射到 Flutter 的任务队列
  void SetupTaskRunners(void* platform_loop);
  // 注册 HarmonyOS 平台视图到 Shell,完成平台桥接
  void RegisterPlatformView();
  // 加载 Dart AOT 或 Kernel,决定运行模式(Release/Profile 使用 AOT,Debug 使用 Kernel)
  void LoadDartCode(const std::string& entrypoint,
                    const std::string& libraryUrl,
                    const std::vector<std::string>& entrypoint_args);
  // 释放所有资源,顺序:PlatformView → Shell → TaskRunners
  void Teardown();
 private:
  std::unique_ptr<Shell> shell_;                         // Flutter 引擎核心
  std::shared_ptr<PlatformViewOHOSNapi> napi_facade_;  // NAPI 装饰器
  fml::WeakPtrFactory<OHOSShellHolder> weak_factory_;    // 弱引用工厂,防止悬空指针
  ShellState state_ = ShellState::kNotStarted;           // 当前 Shell 状态
  flutter::TaskRunners task_runners_;                    // 跨平台任务队列(UI/GPU/IO/Platform)
  std::mutex state_mutex_;                               // 保护 state_ 的线程安全
};

三、图形渲染适配

Flutter 在 HarmonyOS 上支持三种渲染方式:

3.1 鸿蒙三种渲染方式

enum class OHOSRenderingAPI {
  kSoftware,          // 软件渲染, 基于 CPU 进行渲染, 性能较低, 不依赖于 GPU,适用于简单场景。
  kOpenGLES,          // OpenGL ES 渲染(Skia), 基于 OpenGL ES 进行渲染, 性能较高, 依赖于 GPU, 适用于复杂场景。
  kImpellerVulkan,    // Vulkan 渲染(Impeller), 基于 Vulkan 进行渲染, 性能最高, 依赖于 GPU, 适用于需要高性能渲染的场景。
};

platform_view_ohos.cpp 中,根据渲染方式创建不同的Surface

std::unique_ptr<OHOSSurface> OhosSurfaceFactoryImpl::CreateSurface() {
  switch (ohos_context_->RenderingApi()) {
    case OHOSRenderingAPI::kSoftware:
      return std::make_unique<OHOSSurfaceSoftware>(ohos_context_); // 软件渲染, 基于 CPU 进行渲染, 性能较低, 不依赖于 GPU,适用于简单场景。
    case OHOSRenderingAPI::kOpenGLES:
      return std::make_unique<OhosSurfaceGLSkia>(ohos_context_); // OpenGL ES 渲染(Skia), 基于 OpenGL ES 进行渲染, 性能较高, 依赖于 GPU, 适用于复杂场景。
    case flutter::OHOSRenderingAPI::kImpellerVulkan:
      return std::make_unique<OHOSSurfaceVulkanImpeller>(ohos_context_); // Vulkan 渲染(Impeller), 基于 Vulkan 进行渲染, 性能最高, 依赖于 GPU, 适用于需要高性能渲染的场景。
    default:
      return nullptr;
  }
}

3.2 原生窗口(OHOSNativeWindow)

HarmonyOS 的窗口系统通过 OHNativeWindow 暴露给 Flutter:

class OHOSNativeWindow : public fml::RefCountedThreadSafe<OHOSNativeWindow> {
 public:
  Handle Gethandle() const// 获取 HarmonyOS 原生窗口句柄
  bool IsValid() const;      // 检查窗口是否有效
  SkISize GetSize() const;   // 获取窗口尺寸
 private:
  Handle window_;  // OHNativeWindow*
};

**渲染流程: **

Flutter Engine
    ↓
PlatformViewOHOS
    ↓
OHOSSurface(根据渲染方式创建不同的Surface)
    ↓
OHOSNativeWindow(HarmonyOS 原生窗口)
    ↓
HarmonyOS 图形系统

四、输入事件处理

因为事件处理需要在渲染完成后(VSync同步流程)才能触发, 否则会导致事件处理与渲染不一致的问题。

4.1 VSync 同步

VSync(垂直同步)信号是渲染的关键,它是每次屏幕刷新周期开始时发送的信号,用于同步渲染和显示。

Flutter 需要等待系统的 VSync 信号,才能触发下一帧渲染。

class VsyncWaiterOHOS final : public VsyncWaiter {
 public:
  explicit VsyncWaiterOHOS(const flutter::TaskRunners& task_runners,
                           std::shared_ptr<bool>& enable_frame_cache);

 private:
  OH_NativeVSync* vsync_handle_;  // HarmonyOS VSync 句柄
  void AwaitVSync() override// 等待 VSync 信号
  static void OnVsyncFromOHOS(long long timestamp, void* data); // 接收 HarmonyOS VSync 信号, 通知 Flutter Engine 触发下一帧渲染
};

**工作流程: **

HarmonyOS VSync 信号
    ↓
VsyncWaiterOHOS::OnVsyncFromOHOS
    ↓
通知 Flutter Engine
    ↓
触发下一帧渲染
    ↓
渲染完成
    ↓
触发事件处理

4.2 触摸事件处理

HarmonyOS 的输入事件需要转换为 Flutter 的事件格式:

触摸事件通过 OhosTouchProcessor 处理:

class OhosTouchProcessor {
 public:
  // 处理 HarmonyOS 触摸事件
  void ProcessTouchEvent(const OH_NativeXComponent_TouchEvent* event);
 private:
  // 转换为 Flutter 触摸事件格式
  std::vector<PointerData> ConvertToFlutterTouchEvents(
      const OH_NativeXComponent_TouchEvent* event);
};

五、平台消息通信

Flutter 与 HarmonyOS 的通信通过 Platform Channel 实现:

5.1 NAPI 装饰器(PlatformViewOHOSNapi)

NAPI(Native API)是 HarmonyOS 提供的原生 API 接口:

class PlatformViewOHOSNapi {
 public:
  // 发送平台消息到 HarmonyOS
  void SendPlatformMessage(const std::string& channel,
                           const std::vector<uint8_t>& message);
  // 接收来自 HarmonyOS 的平台消息
  void SetPlatformMessageHandler(
      std::function<void(const std::string&, const std::vector<uint8_t>&)> handler);
 private:
  napi_env env_;  // NAPI 环境
};

5.2 消息处理流程

Flutter 代码(Dart)
    ↓
MethodChannel.invokeMethod
    ↓
PlatformViewOHOS::DispatchPlatformMessage
    ↓
PlatformViewOHOSNapi::SendPlatformMessage
    ↓
HarmonyOS 原生代码(ArkTS/C++)
    ↓
返回结果
    ↓
Flutter 接收响应

六、完整的工作流程

让我们把所有部分串联起来,看看 Flutter 应用在 HarmonyOS 上是如何运行的:

6.1 初始化流程

1. HarmonyOS 应用启动
    ↓
2. 调用 OhosMain::NativeInit(NAPI 入口)
    ↓
3. 创建 OHOSShellHolder
    ↓
4. 创建 PlatformViewOHOS
    ↓
5. 创建 OHOSContext(图形上下文)
    ↓
6. 创建 OHOSSurface(渲染表面)
    ↓
7. 创建 Flutter Shell(引擎)
    ↓
8. 加载 Dart 代码
    ↓
9. 开始渲染

6.2 渲染流程

1. Dart 代码构建 Widget 树
    ↓
2. Framework 层生成 Layer 树
    ↓
3. Engine 层生成 Scene
    ↓
4. Impeller 渲染引擎绘制
    ↓
5. 通过 OHOSSurface 提交绘制指令
    ↓
6. OHOSNativeWindow 接收绘制结果
    ↓
7. HarmonyOS 图形系统显示到屏幕

6.3 事件处理流程

1. 用户触摸屏幕
    ↓
2. HarmonyOS 接收触摸事件
    ↓
3. OhosTouchProcessor 处理
    ↓
4. 转换为 Flutter 触摸事件格式
    ↓
5. PlatformViewOHOS 分发事件
    ↓
6. Framework 层处理事件
    ↓
7. Widget 响应用户操作

七、关键代码示例

7.1 创建 HarmonyOS Embedder

// 创建图形上下文
std::unique_ptr<OHOSContext> CreateOHOSContext(
    const flutter::TaskRunners& task_runners,
    OHOSRenderingAPI rendering_api,
    bool enable_vulkan_validation,
    bool enable_opengl_gpu_tracing,
    bool enable_vulkan_gpu_tracing) {
  switch (rendering_api) {
    case OHOSRenderingAPI::kSoftware:
      return std::make_unique<OHOSContext>(OHOSRenderingAPI::kSoftware);
    case OHOSRenderingAPI::kOpenGLES:
      return std::make_unique<OhosContextGLSkia>(OHOSRenderingAPI::kOpenGLES,
                                                 task_runners);
    case OHOSRenderingAPI::kImpellerVulkan:
      return std::make_unique<OHOSContextVulkanImpeller>(
          enable_vulkan_validation, enable_vulkan_gpu_tracing);
    default:
      return nullptr;
  }
}
// 创建平台视图
PlatformViewOHOS::PlatformViewOHOS(
    PlatformView::Delegate& delegate,
    const flutter::TaskRunners& task_runners,
    const std::shared_ptr<PlatformViewOHOSNapi>& napi_facade,
    const std::shared_ptr<flutter::OHOSContext>& ohos_context)
    : PlatformView(delegate, task_runners),
      napi_facade_(napi_facade),
      ohos_context_(ohos_context) {
  // 创建 Surface 工厂
  surface_factory_ = std::make_shared<OhosSurfaceFactoryImpl>(ohos_context_);
  // 创建渲染 Surface
  ohos_surface_ = surface_factory_->CreateSurface();
  // 预加载 GPU Surface(加速首帧渲染)
  task_runners_.GetRasterTaskRunner()->PostDelayedTask(
      [surface = ohos_surface_]() { surface->PrepareGpuSurface(); },
      fml::TimeDelta::FromMicroseconds(1000));
}

7.2 通知窗口创建

void PlatformViewOHOS::NotifyCreate(
    fml::RefPtr<OHOSNativeWindow> native_window) {
  FML_LOG(INFO) << "NotifyCreate start";
  // 缓存原生窗口
  native_window_ = native_window;
  // 通知 Surface 窗口已创建
  ohos_surface_->SetNativeWindow(native_window);
  // 获取窗口尺寸
  SkISize size = native_window->GetSize();
  // 更新视口尺寸
  UpdateDisplaySize(size.width(), size.height());

  // 通知 Flutter 引擎窗口已创建
  NotifyCreated();
}

7.3 处理平台消息

void PlatformViewOHOS::DispatchPlatformMessage(
    std::string name,
    void* message,
    int messageLength,
    int responseId) {
  // 创建平台消息
  fml::MallocMapping buffer = fml::MallocMapping(
      static_cast<const uint8_t*>(message), messageLength);
  auto platform_message = std::make_unique<PlatformMessage>(
      name,
      std::move(buffer),
      responseId,
      fml::TimePoint::Now());
  // 分发到 Flutter 引擎
  DispatchPlatformMessage(std::move(platform_message));
}

八、为什么 Flutter 能在 HarmonyOS 上运行?

通过上面的代码分析,我们可以总结出以下几个关键原因:

8.1 架构设计优势

Flutter 的分层架构设计使得 Embedder 层可以独立适配不同平台:

  • Framework 层Engine 层是平台无关的

  • 只有 Embedder 层需要针对不同平台实现

8.2 HarmonyOS 提供的开放接口

HarmonyOS 提供了丰富的原生 API,使得 Flutter 可以:

  • 通过 OHNativeWindow 获取窗口句柄

  • 通过 OH_NativeVSync 获取 VSync 信号

  • 通过 NAPI 调用系统能力

  • 通过 XComponent 组件集成 Flutter 视图

8.3 图形接口兼容

HarmonyOS 支持标准的图形接口:

  • OpenGL ES:Skia 渲染引擎可以直接使用

  • Vulkan:Impeller 渲染引擎可以直接使用

  • NativeWindow:提供了跨平台的窗口抽象

8.4 社区共同努力

  • 华为官方和 Flutter 社区共同维护 flutter_flutter 项目

  • 基于 Flutter Engine 源码进行适配

  • 提供完整的开发工具链


从代码层面看,核心就是实现了 PlatformViewOHOSOHOSShellHolderOHOSContext 等类,将 Flutter Engine 与 HarmonyOS 系统连接起来。

**一句话总结:Flutter 通过实现 HarmonyOS 专属的 Embedder 层,将 Flutter Engine 与 HarmonyOS 的窗口系统、图形系统、输入系统对接,从而实现了跨平台运行。 **


九、参考资料

Vue3组件开发中如何兼顾复用性、可维护性与性能优化?

一、组件开发的基本原则

1.1 单一职责原则

每个组件应专注于完成一个核心功能,避免将过多无关逻辑塞进同一个组件。例如,一个用户信息组件只负责展示用户头像、名称和基本资料,而不处理表单提交或数据请求逻辑。这种设计让组件更易于理解、测试和维护。

1.2 可复用性原则

通过Props、插槽和组合式API提高组件的复用性。例如,一个按钮组件可以通过Props定义不同的尺寸、颜色和状态,通过插槽支持自定义内容,从而在多个页面中重复使用。

1.3 可维护性原则

  • 命名规范:使用有意义的组件名称(如UserAvatar而非Avatar1),Props和事件名称采用kebab-case(如max-count而非maxCount)。
  • 模块化结构:将组件按功能划分到不同目录(如components/UI存放通用UI组件,components/Features存放业务功能组件)。
  • 注释文档:为组件和关键逻辑添加注释,说明组件用途、Props含义和事件触发时机。

二、组件设计的最佳实践

2.1 Props设计规范

使用TypeScript定义Props类型,设置默认值和校验规则,避免传递无效数据导致组件异常。

<template>
  <div class="counter">
    <button @click="decrement">-</button>
    <span>{{ count }}</span>
    <button @click="increment">+</button>
  </div>
</template>

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

// 定义Props类型和默认值
interface Props {
  count?: number;
  step?: number;
  min?: number;
  max?: number;
}

const props = withDefaults(defineProps<Props>(), {
  count: 0,
  step: 1,
  min: 0,
  max: 100
});

const emit = defineEmits<{
  'update:count': [value: number]
}>();

const increment = () => {
  const newValue = props.count + props.step;
  if (newValue <= props.max) {
    emit('update:count', newValue);
  }
};

const decrement = () => {
  const newValue = props.count - props.step;
  if (newValue >= props.min) {
    emit('update:count', newValue);
  }
};
</script>

2.2 自定义事件处理

通过defineEmits定义组件触发的事件,避免直接修改父组件状态,保持数据流的单向性。

<!-- 父组件 -->
<template>
  <Counter :count="count" @update:count="count = $event" />
</template>

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

const count = ref(0);
</script>

2.3 灵活使用插槽

通过插槽让组件支持自定义内容,提高组件的灵活性和复用性。

<!-- Card.vue -->
<template>
  <div class="card">
    <div class="card-header">
      <slot name="header">默认标题</slot>
    </div>
    <div class="card-body">
      <slot>默认内容</slot>
    </div>
    <div class="card-footer">
      <slot name="footer" :current-time="currentTime">
        默认页脚 - {{ currentTime }}
      </slot>
    </div>
  </div>
</template>

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

const currentTime = ref(new Date().toLocaleTimeString());
</script>
<!-- 使用Card组件 -->
<template>
  <Card>
    <template #header>
      <h2>用户详情</h2>
    </template>
    <p>这是用户的详细信息...</p>
    <template #footer="{ currentTime }">
      更新时间:{{ currentTime }}
    </template>
  </Card>
</template>

2.4 组合式API的逻辑复用

往期文章归档
免费好用的热门在线工具

将组件逻辑抽离成可复用的Composables,提高代码的可维护性和复用性。

// composables/useCounter.ts
import { ref, computed } from 'vue';

export function useCounter(initialCount = 0, step = 1) {
  const count = ref(initialCount);
  
  const increment = () => count.value += step;
  const decrement = () => count.value -= step;
  const doubleCount = computed(() => count.value * 2);
  
  return { count, increment, decrement, doubleCount };
}
<!-- 在组件中使用 -->
<template>
  <div class="counter">
    <button @click="decrement">-</button>
    <span>{{ count }}</span>
    <span>双倍值:{{ doubleCount }}</span>
    <button @click="increment">+</button>
  </div>
</template>

<script setup lang="ts">
import { useCounter } from '@/composables/useCounter';

const { count, increment, decrement, doubleCount } = useCounter(0, 2);
</script>

三、组件通信的多种实现方式

graph TD
    A[父组件] -->|Props| B[子组件]
    B -->|Events| A
    A -->|Provide| C[深层子组件]
    C -->|Inject| A
    D[Pinia Store] -->|读取/修改| A
    D -->|读取/修改| B
    D -->|读取/修改| C

3.1 父子组件通信:Props与Events

这是最基础的通信方式,父组件通过Props传递数据给子组件,子组件通过Events通知父组件更新状态。

3.2 跨层级通信:Provide与Inject

适用于深层嵌套组件之间的通信,父组件通过provide提供数据,子组件通过inject获取数据。

<!-- 父组件 -->
<script setup lang="ts">
import { provide } from 'vue';
import ChildComponent from './ChildComponent.vue';

provide('theme', 'dark');
</script>
<!-- 深层子组件 -->
<script setup lang="ts">
import { inject } from 'vue';

const theme = inject('theme', 'light'); // 默认值为light
</script>

3.3 全局状态管理:Pinia

对于需要在多个组件之间共享的状态(如用户登录状态、购物车数据),推荐使用Pinia进行全局状态管理。

// stores/counter.ts
import { defineStore } from 'pinia';

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  actions: {
    increment() { this.count++; },
    decrement() { this.count--; }
  },
  getters: {
    doubleCount: (state) => state.count * 2
  }
});
<!-- 在组件中使用 -->
<template>
  <div>
    <span>{{ store.count }}</span>
    <button @click="store.increment">+</button>
  </div>
</template>

<script setup lang="ts">
import { useCounterStore } from '@/stores/counter';

const store = useCounterStore();
</script>

四、组件性能优化策略

4.1 异步组件与懒加载

使用defineAsyncComponent实现组件懒加载,减少初始包体积,提高页面加载速度。

<template>
  <Suspense>
    <AsyncChart />
    <template #fallback>
      <div>图表加载中...</div>
    </template>
  </Suspense>
</template>

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

const AsyncChart = defineAsyncComponent(() => import('./Chart.vue'));
</script>

4.2 使用Memoization减少重渲染

使用memo包裹组件,只有当Props发生变化时才重新渲染组件。

<script setup lang="ts">
import { memo } from 'vue';
import ExpensiveComponent from './ExpensiveComponent.vue';

const MemoizedComponent = memo(ExpensiveComponent);
</script>

4.3 虚拟列表处理大量数据

对于包含大量数据的列表,使用虚拟列表技术只渲染可见区域的元素,提高页面性能。推荐使用vue-virtual-scroller库:

npm install vue-virtual-scroller
<template>
  <RecycleScroller
    class="scroller"
    :items="largeList"
    :item-size="50"
  >
    <template v-slot="{ item }">
      <div class="list-item">{{ item }}</div>
    </template>
  </RecycleScroller>
</template>

<script setup lang="ts">
import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';

const largeList = Array.from({ length: 10000 }, (_, i) => `Item ${i}`);
</script>

五、常见问题排查与调试技巧

5.1 Props类型不匹配问题

问题:父组件传递的Props类型与子组件定义的类型不匹配(如传递字符串"5"而非数字5)。 解决:在父组件中转换数据类型,或在子组件中使用类型转换:

// 子组件中处理
const safeCount = Number(props.count);

5.2 事件绑定错误

问题:父组件监听的事件名称与子组件emit的事件名称不一致(如子组件emit('updateCount'),父组件监听update:count)。 解决:统一事件名称,使用kebab-case规范:

// 子组件
emit('update:count', newValue);

// 父组件
<Counter @update:count="handleUpdate" />

5.3 响应式数据更新不及时

问题:直接修改数组索引或对象属性,Vue无法检测到变化:

// 错误写法
const list = ref([1,2,3]);
list.value[0] = 4; // Vue无法检测到

// 正确写法
list.value.splice(0, 1, 4);

六、课后Quiz

问题1:如何在Vue3中实现跨层级组件通信?请至少列举两种方式并说明适用场景。

答案解析:

  1. Provide/Inject:适用于深层嵌套组件之间的通信(如主题设置、全局配置)。父组件通过provide提供数据,子组件通过inject获取数据。优点是无需逐层传递Props,缺点是可能导致组件耦合度升高。
  2. Pinia状态管理:适用于全局状态共享(如用户登录状态、购物车数据)。通过Pinia Store统一管理状态,任何组件都可以读取和修改Store中的数据。优点是状态管理集中化,缺点是需要额外引入Pinia库。
  3. Event Bus:使用mitt库创建事件总线,组件之间通过发布/订阅事件通信。但Vue3官方不推荐使用,建议优先使用Pinia。

七、常见报错解决方案

7.1 Props类型不匹配警告

错误信息[Vue warn]: Invalid prop: type check failed for prop "count". Expected Number, got String. 原因:父组件传递的Props类型与子组件定义的类型不匹配。 解决:在父组件中传递正确类型的数据,或在子组件中转换类型:

// 父组件
<Counter :count="5" /> <!-- 使用v-bind传递数字 -->

// 子组件
const safeCount = Number(props.count);

7.2 未定义的属性或方法错误

错误信息[Vue warn]: Property "increment" was accessed during render but is not defined on instance. 原因:在<script setup>中未正确导出变量或方法,或在选项式API中未在methods中定义方法。 解决:在<script setup>中确保变量和方法是顶级声明(自动导出),或在选项式API中添加到methods对象中。

7.3 生命周期钩子调用错误

错误信息[Vue warn]: Invalid hook call. Hooks can only be called inside the body of a setup() function. 原因:在setup函数外部调用了组合式API钩子(如onMounted)。 解决:确保所有钩子函数在setup函数内部调用:

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

onMounted(() => {
  console.log('组件挂载完成');
});
</script>

八、参考链接

面试官 : “ 请问你实际开发中用过 函数柯理化 吗? 能讲一下吗 ?”

一、先搞懂:柯里化到底是什么?

核心定义:柯里化是把接收多个参数的函数,转换成一系列只接收单个参数的函数,并持续返回新函数,直到所有参数都被传入后,才执行最终逻辑并返回结果。

用 “人话” 说:原本要一次性传完所有参数的函数,现在可以 “分批传”,传一个参数就返回一个新函数等着接下一个,直到传完为止。

对比:普通函数 vs 柯里化函数

// 普通函数:一次性传所有参数
function add(a, b, c) {
  return a + b + c;
}
add(1, 2, 3); // 6

// 柯里化函数:分批次传参数
function curriedAdd(a) {
  return function(b) {
    return function(c) {
      return a + b + c;
    };
  };
}
curriedAdd(1)(2)(3); // 6(传一个参数,返回新函数,直到传完3个)

二、手动实现一个通用柯里化函数

你不用为每个函数单独写柯里化逻辑,这里写一个通用的 curry 工具函数,能把任意多参数函数转换成柯里化函数:

// 通用柯里化函数
function curry(fn) {
  // 保存原函数的参数个数
  const argsLength = fn.length;
  
  // 递归接收参数
  function curried(...args) {
    // 1. 如果已传参数 >= 原函数需要的参数,执行原函数
    if (args.length >= argsLength) {
      return fn.apply(this, args);
    }
    // 2. 否则,返回新函数,继续接收参数
    return function(...newArgs) {
      return curried.apply(this, [...args, ...newArgs]);
    };
  }
  
  return curried;
}

// 测试:给加法函数做柯里化
const add = (a, b, c) => a + b + c;
const curriedAdd = curry(add);

// 支持多种传参方式(核心优势)
console.log(curriedAdd(1)(2)(3)); // 6(逐个传)
console.log(curriedAdd(1, 2)(3)); // 6(分批传)
console.log(curriedAdd(1)(2, 3)); // 6(混合传)
console.log(curriedAdd(1, 2, 3)); // 6(一次性传)

三、柯里化的核心价值(为什么要用?)

  1. 参数复用:提前固定部分参数,生成新函数,避免重复传参。示例:固定 “税率” 参数,复用计算逻辑

    // 原函数:计算税后价格(价格 + 税率)
    const calculateTax = (taxRate, price) => price * (1 + taxRate);
    // 柯里化后,固定税率为10%
    const calculateTax10 = curry(calculateTax)(0.1);
    // 后续只用传价格,不用重复传税率
    calculateTax10(100); // 110
    calculateTax10(200); // 220
    
  2. 延迟执行:先收集参数,不立即执行,等参数凑齐后再执行。示例:表单提交前收集多个字段,凑齐后再验证提交

    const submitForm = (name, phone, address) => {
      console.log(`提交:${name} ${phone} ${address}`);
    };
    const curriedSubmit = curry(submitForm);
    
    // 分步收集参数(比如用户分步填写表单)
    const step1 = curriedSubmit("张三"); // 收集姓名,未执行
    const step2 = step1("13800138000"); // 收集手机号,未执行
    step2("北京市"); // 收集地址,参数凑齐,执行 → 输出:提交:张三 13800138000 北京市
    
  3. 适配函数参数:把多参数函数转换成单参数函数,适配只接收单参数的场景(比如 React 的高阶组件、数组的 map/filter 等)。示例:适配数组 map 的单参数回调

    // 原函数:乘以指定倍数
    const multiply = (multiplier, num) => num * multiplier;
    const curriedMultiply = curry(multiply);
    
    // 固定倍数为2,生成单参数函数
    const double = curriedMultiply(2);
    // 适配 map 的单参数回调
    [1,2,3].map(double); // [2,4,6]
    

四、常见误区

❌ 误区:“柯里化就是把函数拆成只传一个参数的函数,必须链式调用 (a)(b)(c)”

✅ 纠正:柯里化的核心是 “参数分批传递 + 延迟执行”,支持任意分批方式(比如 (a,b)(c)、(a)(b,c)),不一定非要逐个传。

❌ 误区:“柯里化能提升性能”

✅ 纠正:柯里化本质是多了层函数嵌套,性能略有损耗,它的价值是提升代码复用性和可读性,而非性能。

总结

  1. 柯里化核心:把多参数函数转成 “单参数函数链”,支持参数分批传递,凑齐后执行;
  2. 实现关键:通过闭包保存已传参数,递归判断参数是否凑齐,凑齐则执行原函数;
  3. 核心用途:参数复用、延迟执行、适配单参数场景。

基于高德地图JS的旅游足迹,可嵌入个人博客中

一、足迹地图效果

制作最基础的旅行足迹地图,显示效果见下图,可以查看下面的 Demo 演示,显示标记地点的名称和经纬度,并在地图上用红点显示

足迹footprint - AomanHao的博客空间

以前的足迹地图因为地图不合规,显示效果也不太好,如下图

二、足迹地图制作

教大家如何将制作好的足迹地图嵌入到我们自己的博客中,基于 高德地图 (AMap) 来实现这个功能,因为它对中国地图的支持非常完善,且接入简单。整个页面会包含:

  1. 中国地图的基础展示
  2. 已去过地点的标记(带经纬度显示)

2.1 高德地图 Key 获取

前往 高德开放平台 注册账号,创建应用即可获取(免费)。

1)注册高德开放平台,注册个人认证开发者

2)创建新应用,选择web应用,选择web端JS相关

3)把生成的key复制,替换到代码中高德地图key

2.2 高德地图 Key 应用

把生成的key复制,找到到代码中高德地图key的地方,替换上

html文件在本地浏览器可以直接预览

2.3 线上部署

将该文件放到博客的静态资源目录(如 static/pages/footprint_lite.html)。

在博客导航栏添加链接指向该页面即可。

三、后记

1、因为是静态网页展示,足迹地点需要手动离线更新,然后把新文件覆盖到博客部署文件地址上。

2、考虑足迹更新频次很低,静态更新地址完全是OK的


四、代码链接

足迹代码链接:FootPrint/基于高德地图JS at main · AomanHao/FootPrint · GitHub

推荐用lite版本


我的个人博客主页,欢迎访问

我的CSDN主页,欢迎访问

我的GitHub主页,欢迎访问

我的知乎主页,欢迎访问

【节点】[MainLightDirection节点]原理解析与实际应用

【Unity Shader Graph 使用与特效实现】专栏-直达

在Unity URP(Universal Render Pipeline)着色器图形(Shader Graph)中,Main Light Direction节点是一个功能强大且常用的工具节点,它为着色器开发者提供了访问场景中主要光源方向的能力。这个节点在创建各种光照效果、阴影计算和视觉渲染方面发挥着至关重要的作用。通过准确获取主光源的方向信息,开发者能够实现更加真实和动态的光照交互效果,提升项目的视觉质量和用户体验。

Main Light Direction节点的核心价值在于它能够智能地识别场景中的主要光源,无论是用于阴影投射的主方向光,还是作为备用的第一个非阴影投射方向光。这种智能回退机制确保了在各种光照配置下都能获得可用的光源方向数据,使得着色器开发更加灵活和可靠。在URP渲染管线中,正确理解和应用Main Light Direction节点对于创建高质量、性能优化的实时渲染效果至关重要。

随着现代游戏和实时应用对视觉效果要求的不断提高,对光照系统的精细控制变得愈发重要。Main Light Direction节点作为URP着色器图形中光照系统的关键组成部分,为开发者提供了直接访问引擎底层光照数据的接口。通过掌握这个节点的使用方法和应用场景,开发者能够创建出更加生动、响应迅速的光照效果,从而提升整体项目的视觉表现力。

描述

Main Light Direction节点是URP着色器图形中专门用于获取场景中主方向光方向信息的核心节点。在实时渲染中,光源方向是计算光照、阴影和各种光学效果的基础参数,而Main Light Direction节点正是提供这一关键数据的桥梁。该节点设计精巧,能够适应不同的光照场景配置,确保在各种情况下都能返回有意义的光源方向值。

主方向光的定义与识别机制

在URP渲染管线中,主方向光通常指的是场景中最主要的方向光源,这个光源负责提供场景的基础照明和投射主要阴影。Main Light Direction节点通过一套智能的识别机制来确定哪个光源应该被视为"主方向光":

  • 首先,节点会搜索场景中所有设置了投射阴影(Cast Shadows)属性的方向光
  • 如果存在多个投射阴影的方向光,节点会选择其中强度最高或者被认为是最主要的那一个
  • 如果场景中没有任何方向光设置了投射阴影属性,节点会回退到选择第一个不投射阴影的方向光
  • 这种回退机制确保了即使在没有阴影投射光源的情况下,节点仍然能够提供可用的方向数据

光源方向的计算与标准化

Main Light Direction节点输出的方向向量是经过归一化处理的,这意味着向量的长度始终为1。归一化处理在光照计算中非常重要,因为它确保了方向向量只表示方向信息而不包含强度或距离因素。这种标准化输出使得该节点可以直接用于点积计算、反射计算和其他需要纯方向数据的着色器操作。

光源方向的计算基于世界空间坐标系,这意味着无论相机如何移动或旋转,返回的方向向量都始终保持在世界空间中的一致性。这种世界空间的表示方式使得光照计算更加直观和一致,开发者不需要担心相机变换对光照方向的影响。

节点在渲染管线中的角色

在URP渲染管线的光照处理流程中,Main Light Direction节点扮演着信息传递的角色。它从URP的光照系统中获取当前帧的主光源方向数据,并将其提供给着色器图形使用。这个过程发生在每一帧的渲染过程中,因此即使光源在运行时发生移动或变化,节点也能实时更新方向信息。

该节点的设计考虑了性能优化因素,它通过URP的内部接口直接访问已经计算好的光源数据,避免了在着色器中重复计算光源方向的性能开销。这种高效的数据访问方式使得即使在性能受限的平台上,使用Main Light Direction节点也不会对渲染性能造成显著影响。

与其他光照节点的协同工作

Main Light Direction节点通常不单独使用,而是与其他光照相关的节点配合工作,共同构建完整的光照解决方案:

  • 与Main Light Color节点配合,可以同时获取光源的方向和颜色信息
  • 与光照计算节点(如Dot Product、Reflection等)结合,实现复杂的光照效果
  • 在自定义光照模型中作为关键输入参数,替代标准的URP光照计算

这种协同工作的能力使得Main Light Direction节点成为构建高级自定义着色效果的基础构建块。通过将其与其他节点组合,开发者可以创建出从简单的朗伯反射到复杂的各向异性高光等各种光照效果。

端口

Main Light Direction节点的端口设计简洁而高效,只包含一个输出端口,这反映了其功能的专一性——专注于提供主光源的方向信息。这种简洁的设计使得节点易于理解和使用,同时也保证了其在着色器图中的高效执行。

Direction输出端口

Direction端口是Main Light Direction节点唯一的输出接口,它负责提供世界空间中主方向光的归一化方向向量。理解这个端口的特性和正确使用其输出数据对于实现准确的光照效果至关重要。

端口数据类型与特性

Direction端口输出的是Vector 3类型的数据,包含三个浮点数值,分别表示在世界空间坐标系中X、Y、Z轴方向上的分量:

  • X分量:表示光源方向在世界空间X轴上的投影
  • Y分量:表示光源方向在世界空间Y轴上的投影
  • Z分量:表示光源方向在世界空间Z轴上的投影

向量的归一化特性意味着无论实际光源的强度或距离如何,这个方向向量的长度(模)始终为1。数学上表示为:√(X² + Y² + Z²) = 1。这种特性简化了后续的光照计算,因为开发者不需要手动对向量进行归一化处理。

方向向量的几何意义

从几何角度理解,Direction端口输出的向量表示从场景中的表面点指向光源的方向。这一点在光照计算中非常重要,因为标准的光照模型(如Phong或Blinn-Phon模型)通常要求光向量指向光源而非从光源发出。

在实际使用时需要注意,某些光照计算(特别是基于物理的渲染PBR)可能需要不同定义的光向量。在这种情况下,可能需要对Direction端口的输出取反,以获得从光源发出的方向向量。

世界空间坐标系的重要性

Direction端口输出的是世界空间中的方向向量,这一特性具有重要优势:

  • 一致性:世界空间坐标与场景的全局坐标系一致,不受相机或物体变换的影响
  • 预测性:向量的值在场景布局不变的情况下是稳定的,便于调试和效果预测
  • 通用性:世界空间是大多数光照计算和物理模拟的自然选择

当需要在其他坐标系(如视图空间或切线空间)中进行计算时,开发者可以使用相应的变换节点将世界空间的方向向量转换到目标空间。

端口数据的实时性

Direction端口输出的数据是实时更新的,这意味着当场景中的主光源发生移动、旋转或被替换时,端口的输出值会立即反映这些变化。这种实时性使得基于Main Light Direction节点的着色器效果能够动态响应光照环境的变化,创造出更加生动和沉浸式的视觉体验。

在动画或游戏场景中,这种实时更新特性特别有价值。例如,当实现日夜循环系统时,Main Light Direction节点可以自动提供不断变化的太阳方向,而不需要额外的脚本或手动调整。

与其他节点的连接方式

Direction输出端口可以连接到任何接受Vector 3类型数据的输入端口,这种灵活性使得Main Light Direction节点能够与着色器图中的多种节点配合使用:

  • 直接连接到光照计算节点的向量输入
  • 作为参数传递给自定义函数节点
  • 与其他向量运算节点结合,构建复杂的光照模型

在实际连接时,通常需要使用适当的向量运算节点(如Negate、Transform或Normalize)来调整方向向量,使其符合特定光照计算的要求。

使用场景与示例

Main Light Direction节点在URP着色器开发中有着广泛的应用场景,从基础的光照计算到高级的渲染效果都能见到它的身影。理解这些应用场景并通过实际示例学习其使用方法,对于掌握该节点的全面应用至关重要。

基础光照计算

在实现自定义光照模型时,Main Light Direction节点是最基础的构建块之一。通过将其与简单的数学运算节点结合,可以创建各种基本的光照效果。

朗伯反射(漫反射)计算

朗伯反射是模拟粗糙表面光照的最基本模型,它计算光线方向与表面法线之间的夹角:

  • 将Main Light Direction的Direction输出与表面法线向量进行点积计算
  • 使用Dot Product节点计算两个向量的点积结果
  • 使用Saturate节点将结果限制在0-1范围内,避免负值
  • 将结果与主光源颜色相乘,得到最终的漫反射光照

这种简单的漫反射计算能够为物体提供基础的立体感和形状定义,是大多数着色器的起点。

镜面高光计算

基于主光源方向的镜面高光计算可以增加表面的光泽感和材质感:

  • 使用Main Light Direction和相机方向计算半角向量(Half Vector)
  • 将半角向量与表面法线进行点积计算
  • 使用Power节点对结果进行指数运算,控制高光的锐利度
  • 结合光源颜色和强度参数,输出镜面高光分量

通过调整高光的强度和范围,可以模拟从塑料到金属等各种不同材质的表面特性。

高级渲染效果

除了基础光照,Main Light Direction节点在实现各种高级渲染效果中也发挥着关键作用。

动态阴影效果

虽然URP提供了内置的阴影映射系统,但有时需要实现自定义的阴影效果:

  • 使用Main Light Direction确定阴影投射的方向
  • 基于光源方向计算虚拟的阴影投影矩阵
  • 实现屏幕空间或物体空间的阴影映射
  • 创建软阴影或特殊风格的阴影效果

这种自定义阴影系统可以用于实现风格化渲染或特殊视觉效果。

环境光遮蔽与全局光照

在实现简化的环境光遮蔽或全局光照效果时,主光源方向可以作为重要的参考:

  • 基于主光源方向调整环境光遮蔽的强度和分布
  • 实现方向性的环境光遮蔽,增强场景的立体感
  • 结合主光源方向模拟简单的全局光照效果
  • 创建基于光源方向的环境光反射和折射

这些效果可以显著提升场景的真实感和视觉质量。

风格化与非真实感渲染

在风格化渲染中,Main Light Direction节点可以用于创建各种艺术化的光照效果:

卡通着色(Cel Shading)

实现卡通渲染中的硬边缘光照效果:

  • 使用Main Light Direction计算基础的光照强度
  • 通过Step或SmoothStep节点将连续的光照强度量化为离散的色阶
  • 基于光源方向添加轮廓线或边缘高光
  • 创建方向性的色调分离效果

这种技术常用于动漫风格或低多边形风格的游戏中。

水墨与绘画风格

模拟传统艺术媒介的渲染效果:

  • 基于主光源方向控制笔触的方向和密度
  • 实现方向性的纹理化或噪波效果
  • 创建光源方向影响的色彩扩散或混合
  • 模拟光线在特定方向上的散射效果

这些效果可以创造出独特的视觉风格和艺术表达。

性能优化实践

在使用Main Light Direction节点时,合理的性能优化策略非常重要:

计算复杂度管理

  • 避免在片段着色器中进行复杂的光照计算,尽可能在顶点着色器阶段处理
  • 使用适当的精度修饰符(如half或fixed)减少计算开销
  • 将复杂的光照计算预处理为查找表或简化公式

分支优化策略

  • 尽量减少基于光源方向的条件分支
  • 使用数学技巧替代条件判断,如使用max、saturate等函数
  • 将光源方向相关的计算分组,提高缓存效率

通过这些优化实践,可以在保持视觉效果的同时确保渲染性能。

常见问题与解决方案

在使用Main Light Direction节点的过程中,开发者可能会遇到各种问题和技术挑战。了解这些常见问题及其解决方案有助于提高开发效率和代码质量。

光源方向不正确

有时可能会发现Main Light Direction节点返回的方向与预期不符,这通常由以下原因引起:

坐标系理解错误

  • 问题描述:开发者可能误解了方向向量的几何意义,错误地认为向量是从光源发出而非指向光源
  • 解决方案:在使用方向向量前,明确其几何定义。如需从光源发出的方向,对向量取反即可
  • 验证方法:在简单场景中测试,确认光照效果与场景中实际的光源方向一致

空间变换问题

  • 问题描述:在世界空间中进行计算时,忽略了物体的变换关系,导致光照方向不正确
  • 解决方案:确保所有参与计算的向量都在同一坐标系中,必要时使用Transform节点进行空间转换
  • 调试技巧:使用可视化节点将方向向量显示为颜色,直观检查向量的正确性

性能相关问题

在复杂场景或低性能平台上,基于Main Light Direction节点的着色器可能会遇到性能瓶颈。

计算开销过大

  • 问题描述:在片段着色器中进行基于光源方向的复杂计算,导致填充率受限
  • 解决方案:将计算上移到顶点着色器,或使用简化计算模型
  • 优化策略:使用插值方式在顶点和片段间传递光照计算结果,减少每像素计算量

频繁的向量运算

  • 问题描述:不必要的向量归一化、变换或其他运算重复执行
  • 解决方案:缓存常用计算结果,避免重复运算
  • 最佳实践:在着色器图的子图中封装常用的光照计算,确保计算的一致性

平台兼容性问题

不同平台对着色器的支持和优化程度不同,可能会导致Main Light Direction节点在不同设备上表现不一致。

移动平台限制

  • 问题描述:在移动设备上,复杂的光照计算可能导致性能下降或精度问题
  • 解决方案:使用简化光照模型,减少基于光源方向的复杂运算
  • 适配策略:为移动平台创建专门简化版本的着色器,保持核心视觉效果的同时优化性能

图形API差异

  • 问题描述:不同图形API对向量运算的精度和处理方式可能存在细微差异
  • 解决方案:使用URP提供的跨平台兼容函数和数据类型
  • 测试建议:在目标平台上进行全面测试,确保光照效果的一致性

调试与验证技巧

有效的调试方法对于解决Main Light Direction节点相关的问题至关重要。

方向向量可视化

  • 将Direction输出直接连接到基础色,通过颜色直观判断方向向量的值和变化
  • 使用不同的颜色映射方案表示向量的不同分量或方向
  • 创建调试视图,同时显示光源方向和其他相关参数

数值验证方法

  • 在简单测试场景中验证方向向量的准确性
  • 使用脚本输出光源方向的实际值,与着色器中的计算结果对比
  • 创建单元测试场景,自动化验证光照计算的正确性

最佳实践与高级技巧

掌握Main Light Direction节点的高级使用技巧和最佳实践,可以帮助开发者创建出更加高效、美观的视觉效果。

高效的光照模型设计

设计基于Main Light Direction节点的光照模型时,应考虑计算效率和视觉质量的平衡。

多光源支持策略

虽然Main Light Direction节点只提供主光源方向,但可以通过特定技术模拟多光源效果:

  • 使用光照贴图或光照探针提供额外的静态光照信息
  • 实现简化的多光源累积模型,将次要光源作为环境光处理
  • 结合屏幕空间光照信息,增强场景的光照丰富度

实时全局光照技巧

利用主光源方向实现近似的实时全局光照效果:

  • 基于光源方向预计算环境光的分布
  • 使用球谐函数或其它基函数表示方向性的环境光照
  • 实现简化的光线追踪或光线步进效果,增强场景的真实感

艺术导向的视觉效果

将技术实现与艺术表达相结合,创建具有独特视觉风格的效果。

风格化光照控制

通过参数化控制实现灵活的艺术化光照:

  • 创建可调节的光照方向偏移,用于艺术夸张或风格化表达
  • 实现非真实的光照衰减模型,增强视觉冲击力
  • 基于光源方向控制特效的生成和表现

动态效果集成

将Main Light Direction节点与各种动态效果系统集成:

  • 与天气系统结合,实现基于光源方向的风、雨、雪等效果
  • 集成到材质系统中,实现光源方向敏感的动态材质变化
  • 与后期处理效果配合,创建方向性的色彩分级或光晕效果

性能与质量平衡

在保持高质量视觉效果的同时,确保渲染性能的优化。

多层次细节策略

实现基于距离或重要性的多层次光照计算:

  • 在远距离使用简化的光照模型,减少计算开销
  • 根据表面特性动态调整光照计算的复杂度
  • 使用计算着色器或GPU实例化优化批量对象的光照计算

自适应质量调整

根据运行时的性能指标动态调整光照质量:

  • 监控帧率并相应调整光照计算的采样率或精度
  • 在性能受限时使用预计算的光照数据替代实时计算
  • 实现可伸缩的光照系统,适应不同的硬件能力

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

《吃透防抖与节流:从原理到实战,彻底解决高频事件性能问题》

吃透防抖与节流:从原理到实战,彻底解决高频事件性能问题

在前端开发中,我们经常会遇到高频触发的事件——比如搜索框输入、页面滚动、按钮连续点击、窗口缩放等。如果不对这些事件进行处理,频繁执行回调函数(尤其是复杂任务如AJAX请求),会导致页面卡顿、请求开销激增,严重影响用户体验和系统性能。

而防抖(Debounce)和节流(Throttle),就是解决这类高频事件性能问题的两大“神器”。它们基于闭包原理实现,用法相似但场景不同,很多新手容易混淆。今天就结合实战代码,从原理、区别、场景到实战,彻底吃透这两个知识点,帮你在项目中精准落地性能优化。

一、先搞懂核心痛点:为什么需要防抖节流?

我们先看一个真实场景:百度搜索建议(baidu ajax suggest)。当你在搜索框输入关键词时,每输入一个字符,浏览器都会触发一次keyup事件,若直接绑定AJAX请求,就会出现高频请求的问题。

如果不做任何处理,会出现两个核心问题:

  • 执行太密集:用户输入速度快(比如每秒输入3个字符),会在1秒内触发3次keyup事件、发送3次AJAX请求,不仅服务器压力大,也会浪费前端性能;
  • 用户体验失衡:请求太快,频繁发送请求可能导致响应混乱、页面卡顿;请求太慢,又会让联想建议延迟,影响使用体验。

类似的场景还有很多,比如代码编辑器的代码提示(code suggest)、页面滚动加载、按钮重复提交、窗口resize等——这些高频触发的事件,都需要通过防抖或节流来优化,避免“性能浪费”。

而这一切的实现,都离不开 闭包 的支持:利用闭包保留定时器ID、上一次执行时间等状态,让函数能够“记住”之前的执行情况,从而实现精准的触发控制,这也是防抖节流的核心底层逻辑。

二、防抖(Debounce):管你触发多少次,我只执行最后一次

1. 防抖核心定义

防抖的核心逻辑:在规定时间内,无论事件触发多少次,都只执行最后一次回调。就像你反复按电梯按钮,电梯只会在你停止按按钮后的一定时间内关门,不会因为你按了多次就多次关门。

对应到前端场景:搜索框keyup事件太频繁,没必要每次触发都执行AJAX请求,我们用防抖控制——无论用户快速输入多少字符,都只在用户停止输入500ms(可自定义)后,发送一次AJAX请求,既节约请求资源,又保证用户体验。

2. 防抖的关键实现(基于闭包+定时器)

以下是防抖的实战实现代码,逐行解析核心逻辑,可直接复制到HTML中运行:

// 模拟AJAX请求(复杂任务,频繁执行会消耗性能)
function ajax(content) {
  console.log('ajax request', content);
}

// 防抖函数(高阶函数:参数或返回值是函数,依托闭包实现)
function debounce(fn, delay) {
  var id; // 自由变量(闭包核心):保存定时器ID,方便后续清除
  return function(args) {
    if(id) clearTimeout(id); // 每次触发事件,先清除之前的定时器,重置倒计时
    var that = this; // 保存当前this指向,避免定时器内this丢失
    id = setTimeout(function(){
      fn.call(that, args); // 推迟执行:延迟delay毫秒后,执行目标函数(最后一次触发的回调)
    }, delay);
  }
}

// 生成防抖后的AJAX函数(延迟500ms执行)
let debounceAjax = debounce(ajax, 500);

// 给防抖输入框绑定keyup事件(高频触发)
const inputb = document.getElementById('debounce');
inputb.addEventListener('keyup', function(e) {
  debounceAjax(e.target.value); // 触发防抖后的函数,而非直接执行ajax
});

3. 防抖核心逻辑拆解(新手必看)

  • 闭包的作用:变量id是定义在debounce函数内部的自由变量,被返回的匿名函数引用。因此即使debounce执行完毕,id也不会被垃圾回收,能持续保存定时器ID,实现“记住”上一次定时器的效果——这是防抖能“重置倒计时”的关键。
  • 定时器的作用:通过setTimeout推迟目标函数(ajax)的执行,每次触发keyup事件时,先清除上一次的定时器(clearTimeout(id)),再重新设置新的定时器。这样无论触发多少次,只有最后一次的定时器会生效,实现“只执行最后一次”。
  • this指向问题:定时器内部的this默认指向window,因此用var that = this保存当前事件触发的上下文(比如input元素),再通过fn.call(that, args)绑定this,确保目标函数(ajax)内的this指向正确,避免出现bug。

4. 防抖的典型应用场景

  • 搜索框输入联想(百度搜索、谷歌搜索):用户不断输入值时,用防抖节约请求资源;
  • 代码编辑器的代码提示(code suggest):避免输入时频繁触发提示逻辑;
  • 按钮防重复提交:比如表单提交按钮,避免用户连续点击发送多次请求;
  • 窗口resize事件:调整窗口大小时,避免频繁执行布局调整逻辑。

三、节流(Throttle):每隔一定时间,只执行一次

1. 节流核心定义

节流的核心逻辑:在规定时间内,无论事件触发多少次,都只执行一次回调。它和防抖的区别在于:防抖是“最后一次触发后延迟执行”,节流是“间隔固定时间执行一次”。

用一个形象的比喻:函数节流就像是FPS游戏的射速,就算你一直按着鼠标射击,也只会在规定射速内射出子弹(比如每秒3发),不会无限制触发——无论触发多频繁,都严格按照固定间隔执行。

对应到前端场景:页面滚动加载数据时,用户可能会一直滚动页面,若每次滚动都触发AJAX请求,会导致请求密集。用节流控制后,每隔500ms只执行一次请求,既保证数据及时加载,又避免性能浪费。

2. 节流的关键实现(基于闭包+时间戳+定时器)

以下是节流的实战实现代码,可直接和防抖代码配合运行,拆解核心逻辑:

// 节流函数(依托闭包,保留上一次执行时间和定时器状态)
function throttle(fn, delay) {
  let last, // 闭包变量:记录上一次执行目标函数的时间戳(毫秒数)
      deferTimer; // 闭包变量:保存尾部执行的定时器ID
  return function() {
    let that = this; // 保存当前this指向,避免this丢失
    let _args = arguments; // 保存事件参数(类数组对象),方便传递给目标函数
    let now = + new Date(); // 类型转换:获取当前时间戳(毫秒数),等价于Date.now()
    
    // 核心判断:上次执行过,且当前时间还没到“上一次执行时间+节流间隔”
    if(last && now < last + delay) {
      clearTimeout(deferTimer); // 清除之前的尾部定时器,避免重复执行
      // 重新设置定时器,延迟执行(尾部补执行,避免最后一次触发被忽略)
      deferTimer = setTimeout(function(){
        last = now; // 更新上一次执行时间为当前时间
        fn.apply(that, _args); // 执行目标函数,绑定this和参数
      }, delay);
    } else {
      // 否则:第一次执行,或已过节流间隔,立即执行目标函数
      last = now; // 更新上一次执行时间为当前时间
      fn.apply(that, _args); // 立即执行目标函数
    }
  }
}

// 生成节流后的AJAX函数(每隔500ms执行一次)
let throttleAjax = throttle(ajax, 500);

// 给节流输入框绑定keyup事件(高频触发)
const inputc = document.getElementById('throttle');
inputc.addEventListener('keyup', function(e) {
  throttleAjax(e.target.value); // 触发节流后的函数
});

3. 节流核心逻辑拆解(新手必看)

  • 闭包的作用:变量last(上一次执行时间)和deferTimer(定时器ID)都是闭包变量,被返回的匿名函数引用,持续保留状态——即使节流函数执行完毕,这两个变量也不会被销毁,确保每次触发都能判断“是否到了执行时间”。
  • 时间戳的作用+ new Date() 将日期对象转为毫秒级时间戳,通过now < last + delay 判断当前时间是否在节流间隔内,决定是否立即执行目标函数。
  • 尾部补执行逻辑:当触发时间在节流间隔内时,通过定时器实现“尾部补执行”——避免最后一次触发被忽略(比如用户滚动页面停止后,确保最后一次滚动能触发数据加载)。
  • 参数和this处理_args = arguments 保存事件参数(比如keyup事件的e对象),that = this 保存当前上下文,确保目标函数(ajax)能正确接收参数、this指向正确。

4. 节流的典型应用场景

  • 页面滚动加载:用户不断滚动页面时,用节流节约请求资源,固定间隔加载数据;
  • 鼠标移动事件:比如拖拽元素时,避免频繁触发位置更新逻辑;
  • 高频点击按钮:比如游戏中的攻击按钮,限制每秒点击次数;
  • 窗口scroll事件:监听页面滚动位置,固定间隔执行导航栏样式切换逻辑。

四、防抖与节流的核心区别(必记,避免混淆)

很多新手会把防抖和节流搞混,其实两者的核心区别很简单,用一句话就能分清,整理如下:

1. 核心逻辑区别

  • 防抖(Debounce) :在一定时间内,只执行最后一次触发的回调(依托setTimeout实现);
  • 节流(Throttle) :每隔一定时间,只执行一次回调(依托时间戳+setTimeout实现,类似setInterval,但更灵活)。

2. 形象对比

  • 防抖:像按电梯,反复按,只在最后一次按完后延迟关门;
  • 节流:像FPS游戏射速,一直按鼠标,只按固定间隔射出子弹。

3. 场景对比(精准落地,避免用错)

特性 防抖(Debounce) 节流(Throttle)
核心逻辑 最后一次触发后延迟执行 固定间隔执行一次
依托技术 闭包 + setTimeout 闭包 + 时间戳 + setTimeout
典型场景 搜索建议、按钮防重复提交 滚动加载、鼠标拖拽
核心目的 避免“无效触发”(比如输入时的中间字符) 避免“密集触发”(比如滚动时的连续触发)

五、实战演示:三者对比(无处理、防抖、节流)

为了让你更直观看到效果,以下是“无处理、防抖、节流”三种效果的完整对比代码,复制到本地即可运行,清晰感受三者差异:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>防抖与节流实战对比</title>
  <style>
    input { margin: 10px 0; padding: 8px; width: 300px; }
    div { font-size: 14px; color: #666; }
  </style>
</head>
<body>
  <div>无处理(高频触发):</div>
  <input type="text" id="undebounce" />
  <br>
  <div>防抖(500ms,只执行最后一次):</div>
  <input type="text" id="debounce" />
  <br>
  <div>节流(500ms,每隔500ms执行一次):</div>
  <input type="text" id="throttle" />

  <script>
  // 模拟AJAX请求(复杂任务)
  function ajax(content) {
    console.log('ajax request', content);
  }

  // 防抖函数
  function debounce(fn, delay) {
    var id;
    return function(args) {
      if(id) clearTimeout(id);
      var that = this;
      id = setTimeout(function(){
        fn.call(that, args)
      }, delay);
    }
  }

  // 节流函数
  function throttle(fn, delay) {
    let last, deferTimer;
    return function() {
      let that = this;
      let _args = arguments;
      let now = + new Date();
      if(last && now < last + delay) {
        clearTimeout(deferTimer);
        deferTimer = setTimeout(function(){
          last = now;
          fn.apply(that, _args);
        }, delay);
      } else {
        last = now;
        fn.apply(that, _args);
      }
    }
  }
  
  // 获取三个输入框元素
  const inputa = document.getElementById('undebounce');
  const inputb = document.getElementById('debounce');
  const inputc = document.getElementById('throttle');

  // 生成防抖、节流函数
  let debounceAjax = debounce(ajax, 500);
  let throttleAjax = throttle(ajax, 500);

  // 1. 无处理:keyup每次触发都执行ajax(高频触发)
  inputa.addEventListener('keyup', function(e) {
    ajax(e.target.value); // 频繁触发,控制台会疯狂打印
  })

  // 2. 防抖处理:keyup触发后,500ms内无新触发才执行ajax
  inputb.addEventListener('keyup', function(e) {
    debounceAjax(e.target.value);
  })

  // 3. 节流处理:keyup触发后,每隔500ms只执行一次ajax
  inputc.addEventListener('keyup', function(e) {
    throttleAjax(e.target.value);
  })
  </script>
</body>
</html>

运行效果说明

  • 无处理输入框:快速输入字符,控制台会疯狂打印“ajax request”,触发频率和keyup一致;
  • 防抖输入框:快速输入字符,控制台只在停止输入500ms后,打印最后一次输入的内容;
  • 节流输入框:快速输入字符,控制台每隔500ms打印一次当前输入内容,严格按照固定间隔执行。

六、总结与注意事项(新手避坑)

1. 核心总结

  • 防抖和节流的核心目的一致:优化高频事件的性能,避免频繁执行复杂任务(如AJAX请求、DOM操作);
  • 两者的核心区别:防抖“只执行最后一次”,节流“间隔固定时间执行一次”;
  • 底层依赖:两者都基于闭包实现,通过闭包保留状态(定时器ID、上一次执行时间),实现精准控制;
  • 场景选择:需要“最后一次触发生效”用防抖,需要“固定间隔生效”用节流。

2. 新手避坑点

  • 不要混淆防抖和节流的场景:比如搜索建议用防抖(避免中间输入触发请求),滚动加载用节流(保证固定间隔加载),用反会影响用户体验;
  • 注意this指向:定时器内部this默认指向window,一定要提前保存this(如var that = this),避免出现this丢失问题;
  • 参数传递:若目标函数需要接收参数(如ajax的content),要保存事件参数(如_args = arguments),并通过call/apply传递;
  • 延迟时间选择:根据场景调整delay(如搜索建议500ms,滚动加载1000ms),太快达不到优化效果,太慢影响用户体验。

防抖和节流是前端性能优化的基础知识点,也是面试高频考点。掌握它们的原理和场景,能帮你在实际项目中解决很多性能问题,提升页面体验。建议把上面的实战代码复制到本地运行,亲手感受三者的区别,加深理解~

面试官 : “ 请说一下 JS 的常见的数组 和 字符串方法有哪些 ? ”

盘点 JS 数组和字符串的核心方法,我会按「常用场景 + 功能分类」整理,每个方法标注作用 + 示例 + 关键说明,既好记又能直接用,适合复习和开发时快速查阅。

一、数组(Array)方法

数组方法是 JS 高频考点,按「增删改查、遍历、转换、排序 / 过滤 / 聚合」分类,重点标⭐️

1. 增删改查(修改原数组)

方法 作用 示例 关键说明
⭐️ push() 末尾添加元素 [1,2].push(3) → [1,2,3] 返回新长度,修改原数组
⭐️ pop() 末尾删除元素 [1,2,3].pop() → 3 返回删除的元素,修改原数组
⭐️ unshift() 头部添加元素 [2,3].unshift(1) → [1,2,3] 返回新长度,修改原数组
⭐️ shift() 头部删除元素 [1,2,3].shift() → 1 返回删除的元素,修改原数组
⭐️ splice(start, delNum, ...add) 任意位置增删改 [1,2,3].splice(1,1,4) → [1,4,3] 返回删除的元素,修改原数组
fill(val, start, end) 填充数组 [1,2,3].fill(0, 1, 2) → [1,0,3] 修改原数组

2. 遍历(不修改原数组)

方法 作用 示例 关键说明
⭐️ forEach() 遍历数组,无返回值 [1,2].forEach(item => console.log(item)) 无法中断(break 无效)
⭐️ map() 遍历 + 返回新数组 [1,2].map(item => item*2) → [2,4] 不修改原数组,必用 return
⭐️ filter() 过滤符合条件的元素 [1,2,3].filter(item => item>1) → [2,3] 返回新数组,保留满足条件的元素
⭐️ find() 找第一个符合条件的元素 [1,2,3].find(item => item>1) → 2 找到即返回,无则 undefined
⭐️ findIndex() 找第一个符合条件的索引 [1,2,3].findIndex(item => item>1) → 1 无则返回 -1
every() 所有元素满足条件? [1,2,3].every(item => item>0) → true 全满足返回 true
some() 至少一个元素满足条件? [1,2,3].some(item => item>2) → true 有一个满足就返回 true
reduce() 聚合(求和 / 拼接等) [1,2,3].reduce((sum, item) => sum+item, 0) → 6 第二个参数是初始值,核心是 “累积”

3. 转换 / 拼接(不修改原数组)

方法 作用 示例 关键说明
⭐️ join(sep) 数组转字符串 [1,2].join('-') → "1-2" sep 是分隔符,默认逗号
⭐️ concat() 拼接数组 [1,2].concat([3,4]) → [1,2,3,4] 返回新数组,不修改原数组
⭐️ slice(start, end) 截取数组(左闭右开) [1,2,3].slice(0,2) → [1,2] 不修改原数组,end 可选(默认到末尾)
flat(depth) 扁平化数组 [1,[2,[3]]].flat(2) → [1,2,3] depth 是层级,默认 1,Infinity 拍平所有
flatMap() map + flat(1) [1,2].flatMap(item => [item, item*2]) → [1,2,2,4] 比先 map 再 flat 高效

4. 排序 / 查找(部分修改原数组)

方法 作用 示例 关键说明
⭐️ sort(compare) 排序 [3,1,2].sort((a,b) => a-b) → [1,2,3] 修改原数组,默认按字符串排序(需传比较函数)
⭐️ reverse() 反转数组 [1,2,3].reverse() → [3,2,1] 修改原数组
⭐️ includes(val) 判断是否包含元素 [1,2].includes(2) → true 区分类型(1 !== '1')
indexOf(val) 找元素首次出现的索引 [1,2,1].indexOf(1) → 0 无则返回 -1
lastIndexOf(val) 找元素最后出现的索引 [1,2,1].lastIndexOf(1) → 2 无则返回 -1

二、字符串(String)方法

字符串方法均不修改原字符串(字符串是不可变类型),按「查找 / 截取、替换 / 分割、转换、判断」分类。

1. 查找 / 截取

方法 作用 示例 关键说明
⭐️ charAt(index) 获取指定位置字符 "abc".charAt(1) → "b" 索引越界返回空字符串
⭐️ indexOf(str) 找子串首次出现的索引 "abcab".indexOf("ab") → 0 无则返回 -1
⭐️ lastIndexOf(str) 找子串最后出现的索引 "abcab".lastIndexOf("ab") → 3 无则返回 -1
⭐️ slice(start, end) 截取字符串(左闭右开) "abcde".slice(1,3) → "bc" start 负数表示从末尾数
substring(start, end) 截取字符串 "abcde".substring(1,3) → "bc" 类似 slice,但 start>end 会自动交换
substr(start, length) 按长度截取 "abcde".substr(1,2) → "bc" 已废弃,优先用 slice
⭐️ includes(str) 判断是否包含子串 "abc".includes("b") → true 区分大小写
startsWith(str) 判断是否以子串开头 "abc".startsWith("ab") → true 可传第二个参数(起始位置)
endsWith(str) 判断是否以子串结尾 "abc".endsWith("bc") → true 可传第二个参数(截取长度)

2. 替换 / 分割

方法 作用 示例 关键说明
⭐️ replace(str/regex, newStr) 替换子串 "abc".replace("b", "x") → "axc" 只替换第一个,全局替换用 /g 正则
⭐️ split(sep) 字符串转数组 "a-b-c".split("-") → ["a","b","c"] sep 为空字符串则拆成单个字符
replaceAll(str/regex, newStr) 全局替换 "abab".replaceAll("a", "x") → "xbxb" ES2021 新增,无需 /g 正则

3. 转换 / 格式化

方法 作用 示例 关键说明
⭐️ toLowerCase() 转小写 "ABC".toLowerCase() → "abc" 不修改原字符串
⭐️ toUpperCase() 转大写 "abc".toUpperCase() → "ABC" 不修改原字符串
⭐️ trim() 去除首尾空格 " abc ".trim() → "abc" 不处理中间空格
trimStart()/trimLeft() 去除开头空格 " abc".trimStart() → "abc" 别名,作用一致
trimEnd()/trimRight() 去除结尾空格 "abc ".trimEnd() → "abc" 别名,作用一致
repeat(n) 重复字符串 "ab".repeat(2) → "abab" n 为 0 返空,负数报错
padStart(len, str) 头部补全 "123".padStart(5, "0") → "00123" 常用于补零
padEnd(len, str) 尾部补全 "123".padEnd(5, "0") → "12300" 超出长度则截断

三、数组 & 字符串互通方法

场景 实现方式 示例
数组 → 字符串 arr.join(sep) [1,2].join("") → "12"
字符串 → 数组 str.split(sep) "abc".split("") → ["a","b","c"]
遍历字符串 转数组后用数组遍历方法 "abc".split("").forEach(char => console.log(char))

总结

  1. 数组核心:修改原数组的方法(push/pop/splice/sort)要注意副作用,遍历优先用 map/filter/reduce(返回新数组),列表查找用 find/findIndex 更高效;
  2. 字符串核心:所有方法不修改原字符串,截取用 slice、替换用 replace/replaceAll、分割用 split,判断包含用 includes;
  3. 高频互通:数组转字符串用 join,字符串转数组用 split,是开发中最常用的联动操作。

LeetCode 100. 相同的树:两种解法(递归+迭代)详解

LeetCode简单难度的经典二叉树题目——100. 相同的树,这道题虽然难度不高,但非常适合入门二叉树的遍历思想,尤其是递归和迭代两种核心思路的对比练习,新手朋友可以重点看看,老手也可以快速回顾巩固一下。

先简单梳理一下题目要求,避免踩坑:给两棵二叉树的根节点p和q,判断这两棵树是否“相同”。这里的相同有两个核心条件,缺一不可:结构上完全一致,并且对应位置的节点值完全相等

举个直观的例子:如果p是一棵只有根节点(值为1)的树,q也是只有根节点(值为1),那它们是相同的;但如果p的根节点是1、左孩子是2,q的根节点是1、右孩子是2,哪怕节点值都一样,结构不同,也不算相同。

一、题目前置准备

题目已经给出了二叉树节点的定义,用TypeScript实现的,这里再贴一遍,方便大家对照代码理解(注释已补充,新手可重点看构造函数的逻辑):

class TreeNode {
  val: number; // 节点值
  left: TreeNode | null; // 左孩子,可能为null(没有左孩子)
  right: TreeNode | null; // 右孩子,可能为null(没有右孩子)
  // 构造函数:初始化节点,val默认0,左右孩子默认null
  constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
    this.val = (val === undefined ? 0 : val); // 节点值为空时,默认设为0
    this.left = (left === undefined ? null : left); // 左孩子为空时,默认设为null
    this.right = (right === undefined ? null : right); // 右孩子为空时,默认设为null
  }
}

二、解法一:递归解法

1. 递归思路分析

递归的核心思想是“分而治之”,把判断两棵大树是否相同,拆解成判断无数个“小问题”——判断当前两个节点是否相同,以及它们的左孩子、右孩子是否分别相同。

递归的终止条件(也是边界情况)很关键,分三步判断,逻辑层层递进:

  • 如果p和q都为null(两个节点都不存在):说明这两个位置的节点是相同的,返回true;

  • 如果p和q中一个为null、另一个不为null(一个节点存在,一个不存在):说明结构不同,返回false;

  • 如果p和q都不为null,但它们的val不相等(节点值不同):说明节点不相同,返回false;

如果以上三种情况都不满足,说明当前两个节点是相同的,接下来就递归判断它们的左孩子(p.left和q.left)和右孩子(p.right和q.right),只有左右孩子都相同,整棵树才相同(用&&连接两个递归结果)。

2. 递归代码实现

// 递归解法:isSameTree_1
function isSameTree_1(p: TreeNode | null, q: TreeNode | null): boolean {
  // 边界情况1:两个节点都为空,相同
  if (p === null && q === null) {
    return true;
  }
  // 边界情况2:一个为空,一个不为空,结构不同,不相同
  if (p === null || q === null) {
    return false;
  }
  // 边界情况3:两个节点都不为空,但值不同,不相同
  if (p.val !== q.val) {
    return false;
  }
  // 递归:当前节点相同,判断左孩子和右孩子是否都相同
  return isSameTree_1(p.left, q.left) && isSameTree_1(p.right, q.right);
};

3. 递归解法总结

优点:代码极度简洁,逻辑清晰,完全贴合二叉树的递归特性,容易理解和编写,新手友好;

缺点:递归依赖调用栈,如果二叉树深度极深(比如链式二叉树),可能会出现栈溢出的情况(但LeetCode的测试用例一般不会卡这种极端情况,日常刷题完全够用);

时间复杂度:O(n),n是两棵树中节点数较少的那一个,每个节点只会被访问一次;

空间复杂度:O(h),h是树的高度,最坏情况下(链式树)h=n,最好情况下(平衡树)h=logn。

三、解法二:迭代解法(用栈模拟递归,避免栈溢出)

1. 迭代思路分析

迭代解法的核心是“用栈模拟递归的调用过程”,通过手动维护一个栈,把需要判断的节点对(p的节点和q的对应节点)压入栈中,然后循环弹出节点对进行判断,本质上和递归的逻辑是一致的,只是实现方式不同。

具体步骤:

  1. 先判断两棵树的根节点是否都为空(和递归边界1一致),如果是,直接返回true;

  2. 如果根节点一个为空、一个不为空(和递归边界2一致),直接返回false;

  3. 初始化一个栈,把根节点对(p和q)压入栈中(注意压入顺序,后续弹出时要对应);

  4. 循环:只要栈不为空,就弹出两个节点(pNode和qNode),进行判断;

  5. 判断弹出的两个节点:如果都为空,跳过(继续判断下一组节点);如果一个为空一个不为空,返回false;如果值不相等,返回false;

  6. 如果当前节点对相同,就把它们的左孩子对、右孩子对依次压入栈中(注意压入顺序,先压右孩子,再压左孩子,因为栈是“后进先出”,和递归的顺序保持一致);

  7. 循环结束后,说明所有节点对都判断完毕,没有发现不相同的情况,返回true。

这里有个小细节:压入栈的顺序是“p.left、q.left、p.right、q.right”,弹出的时候会先弹出p.right和q.right,再弹出p.left和q.left,和递归时“先判断左孩子,再判断右孩子”的顺序是一致的,不影响结果,但要注意保持对应关系,不能压混。

2. 迭代代码实现

// 迭代解法:isSameTree_2(栈模拟递归)
function isSameTree_2(p: TreeNode | null, q: TreeNode | null): boolean {
  // 先处理根节点的边界情况(和递归一致)
  if (p === null && q === null) {
    return true;
  }
  if (p === null || q === null) {
    return false;
  }
  // 初始化栈,压入根节点对(p和q)
  let stack: (TreeNode | null)[] = [];
  stack.push(p);
  stack.push(q);
  
  // 循环:栈不为空时,持续判断节点对
  while (stack.length > 0) {
    // 弹出两个节点,注意栈是后进先出,所以先弹出q,再弹出p(对应压入顺序)
    let qNode: TreeNode | null = stack.pop() ?? null;
    let pNode: TreeNode | null = stack.pop() ?? null;
    
    // 两个节点都为空,跳过(继续判断下一组)
    if (pNode === null && qNode === null) {
      continue;
    }
    // 一个为空一个不为空,结构不同,返回false
    if (pNode === null || qNode === null) {
      return false;
    }
    // 节点值不同,返回false
    if (pNode.val !== qNode.val) {
      return false;
    }
    
    // 当前节点对相同,压入它们的左孩子对和右孩子对(保持对应关系)
    stack.push(pNode.left);
    stack.push(qNode.left);
    stack.push(pNode.right);
    stack.push(qNode.right);
  }
  
  // 所有节点对都判断完毕,没有不相同的情况,返回true
  return true;
}

3. 迭代解法总结

优点:不依赖递归调用栈,避免了极端情况下的栈溢出问题,稳定性更好;

缺点:代码比递归稍长,需要手动维护栈和循环逻辑,对新手来说稍微复杂一点;

时间复杂度:O(n),和递归一致,每个节点只会被压入栈、弹出栈各一次,访问一次;

空间复杂度:O(n),最坏情况下(平衡树),栈中会存储n/2个节点对,空间复杂度为O(n);最好情况下(链式树),栈中最多存储2个节点对,空间复杂度为O(1)。

四、两种解法对比 & 刷题建议

解法类型 优点 缺点 适用场景
递归 代码简洁、逻辑清晰、易编写 极端情况下可能栈溢出 日常刷题、二叉树深度不深的场景
迭代(栈) 无栈溢出问题、稳定性好 代码稍长、需维护栈逻辑 二叉树深度极深、生产环境场景

刷题建议:新手先掌握递归解法,因为它最贴合二叉树的特性,后续做二叉树的遍历、对称树、翻转树等题目时,思路可以无缝迁移;掌握递归后,再理解迭代解法,重点体会“栈模拟递归”的思想,这是二叉树迭代题目的核心套路。

五、常见踩坑点提醒

  • 踩坑点1:忽略“结构不同”的情况,只判断节点值。比如p有左孩子、q没有左孩子,但其他节点值都相同,此时会误判为相同;

  • 踩坑点2:递归时忘记写终止条件,导致无限递归,栈溢出;

  • 踩坑点3:迭代时压入栈的节点对顺序混乱,导致弹出时判断的不是“对应节点”(比如把p.left和q.right压在一起);

  • 踩坑点4:处理节点为null时不严谨,比如用p.val === q.val时,没有先判断p和q是否为null,导致空指针错误(代码中已规避此问题)。

六、总结

LeetCode 100. 相同的树,本质上是二叉树“同步遍历”的入门练习,核心是判断“结构+节点值”是否双匹配。递归解法胜在简洁,迭代解法胜在稳定,两种解法的逻辑完全一致,只是实现方式不同。

刷这道题的重点不在于“写出代码”,而在于理解“如何同步判断两棵树的对应节点”,这个思路后续会用到很多类似题目中(比如101. 对称二叉树,只是判断的是“自身的左孩子和右孩子”,逻辑高度相似)。

面试必考:如何优雅地将列表转换为树形结构?

面试必考:如何优雅地将列表转换为树形结构?

前言

在前端开发中,我们经常会遇到这样一个场景:后端返回的是一个扁平的列表数据,但前端需要渲染成树形结构。比如:

  • 省市区三级联动
  • 组织架构树
  • 权限菜单树
  • 商品分类树

今天我们就来深入探讨这个经典面试题,从递归到优化,一步步掌握列表转树的精髓。

第一章:理解数据结构

1.1 什么是扁平列表?

想象一下,你有一张Excel表格,每一行都是一个独立的数据,它们之间通过某个字段(比如parentId)来表明谁是谁的“爸爸”:

// 这是一个扁平的列表
const list = [
    {id: 1, name: 'A', parentId: 0},  // A是根节点(parentId为0表示没有父节点)
    {id: 2, name: 'B', parentId: 1},  // B的爸爸是A(parentId=1)
    {id: 3, name: 'C', parentId: 1},  // C的爸爸也是A
    {id: 4, name: 'D', parentId: 2}   // D的爸爸是B
]

这种数据的特点:

  • 每条数据都有一个唯一的 id(就像每个人的身份证号)
  • 通过 parentId 来表示父子关系(就像你知道你爸爸的身份证号)
  • parentId: 0 表示根节点(没有爸爸,或者爸爸是“虚拟”的根)

1.2 什么是树形结构?

树形结构就像你家的家族族谱:爷爷下面有爸爸和叔叔,爸爸下面有你和你兄弟姐妹:

// 我们希望转换成的树形结构
[
  {
    id: 1,
    name: 'A',
    parentId: 0,
    children: [  // children表示“孩子”们
      {
        id: 2,
        name: 'B',
        parentId: 1,
        children: [
          { id: 4, name: 'D', parentId: 2 }  // D是B的孩子
        ]
      },
      { id: 3, name: 'C', parentId: 1 }  // C是A的孩子,但没有自己的孩子
    ]
  }
]

1.3 为什么要转换?

后端为什么给扁平列表?因为存数据方便(只需要一张表)。 前端为什么要树结构?因为展示数据方便(树形菜单、级联选择器等)。

第二章:递归法(最直观的思路)

2.1 什么是递归?

递归就像俄罗斯套娃:一个大娃娃里面套着一个小娃娃,小娃娃里面还套着更小的娃娃...用程序的话说,就是函数调用自身

2.2 思路分析

想象你在整理家族族谱:

  1. 先找到所有没有爸爸的人(parentId: 0),他们是第一代人
  2. 然后为每个人找他的孩子:遍历整个列表,找到所有爸爸ID等于这个人ID的人
  3. 对每个孩子重复第2步(递归!)

2.3 基础版代码实现(逐行解释)

function listToTree(list, parentId = 0) {
    // result用来存放最终的结果
    // 比如第一次调用时,它用来存放所有根节点
    const result = []
    
    // 遍历列表中的每一项
    list.forEach(item => {
        // 检查当前项的父亲是不是我们要找的那个父亲
        // 比如parentId=0时,我们就在找所有根节点
        if (item.parentId === parentId) {
            
            // ★ 关键递归:找当前项的孩子
            // 把当前项的id作为新的parentId,去找它的孩子
            const children = listToTree(list, item.id)
            
            // 如果找到了孩子(children数组不为空)
            if (children.length) {
                // 给当前项添加一个children属性,把孩子们放进去
                item.children = children
            }
            
            // 把处理好的当前项放进结果数组
            result.push(item)
        }
    })
    
    // 返回这一层找到的所有人
    return result
}

2.4 代码执行过程演示

假设我们有这样的数据:

const list = [
    {id: 1, name: 'A', parentId: 0},  // 爷爷
    {id: 2, name: 'B', parentId: 1},  // 爸爸
    {id: 3, name: 'C', parentId: 1},  // 叔叔
    {id: 4, name: 'D', parentId: 2}   // 孙子
]

第一次调用listToTree(list, 0)

  • 找爸爸ID为0的人 → 找到A(id=1)
  • 调用listToTree(list, 1)找A的孩子

第二次调用listToTree(list, 1)

  • 找爸爸ID为1的人 → 找到B(id=2)和C(id=3)
  • 先处理B:调用listToTree(list, 2)找B的孩子
  • 再处理C:调用listToTree(list, 3)找C的孩子

第三次调用listToTree(list, 2)

  • 找爸爸ID为2的人 → 找到D(id=4)
  • 调用listToTree(list, 4)找D的孩子(没找到)
  • 返回[D],作为B的children

第四次调用listToTree(list, 3)

  • 找爸爸ID为3的人 → 没找到
  • 返回[],作为C的children(所以C没有children属性)

2.5 进阶版:使用ES6简化(逐行解释)

function listToTree(list, parentId = 0) {
    // 1. 先用filter过滤出当前层的所有节点
    // 比如找所有parentId等于0的根节点
    return list
        .filter(item => item.parentId === parentId)
        
        // 2. 然后用map对每个节点进行处理
        .map(item => ({
            // 这里用了三个点,后面会详细解释
            ...item,
            
            // 3. 递归找当前节点的孩子
            children: listToTree(list, item.id)
        }))
}

这段代码虽然简洁,但做了三件事:

  1. filter:从列表中筛选出符合条件的节点(比如所有根节点)
  2. map:对每个筛选出的节点进行处理
  3. 递归:为每个节点找它的孩子

2.6 递归法的优缺点

优点

  • 逻辑清晰,容易理解
  • 代码简洁优雅
  • 符合人的思维习惯

缺点

  • 时间复杂度 O(n²) - 每个节点都需要遍历整个列表
  • 列表越长,性能越差
  • 可能造成栈溢出(数据量极大时)

第三章:深入理解 ...item 的作用

3.1 如果不使用 ...item 会怎样?

很多初学者可能会这样写:

// 错误示例 ❌
map[item.id] = item
map[item.id].children = []  // 这样会修改原始数据!

3.2 为什么不能直接使用原对象?

让我们用一个生活例子来理解:

假设你有一张原始的家族成员名单

const originalList = [
    {id: 1, name: '爷爷'}
]

情况1:直接使用原对象(坏的做法)

const map = {}
map[1] = originalList[0]  // 把爷爷的原始记录放进map
map[1].children = ['孙子']  // 在原始记录上添加孙子信息

console.log(originalList[0])  
// 输出:{id: 1, name: '爷爷', children: ['孙子']}
// 哎呀!原始名单被修改了!

情况2:使用 ...item 复制(好的做法)

const map = {}
map[1] = { ...originalList[0] }  // 复制一份爷爷的记录
map[1].children = ['孙子']  // 在**副本**上添加孙子信息

console.log(originalList[0])  
// 输出:{id: 1, name: '爷爷'}
// 太好了!原始名单完好无损!

3.3 ...item 到底在做什么?

... 是JavaScript的扩展运算符,它的作用就像复印机:

const 原件 = { name: '张三', age: 18 }

// 用...复制一份
const 复印件 = { ...原件 }

// 现在原件和复印件是两份独立的数据
复印件.age = 19

console.log(原件.age)    // 18(没变)
console.log(复印件.age)  // 19(变了)

3.4 在列表转树中的应用

在我们的代码中:

map[item.id] = {
    ...item,        // 把item的所有属性复制过来
    children: []    // 再添加一个新的children属性
}

这相当于:

// 如果item是 {id: 1, name: 'A', parentId: 0}
// 那么新对象就是:
{
    id: 1,           // 从item复制来的
    name: 'A',       // 从item复制来的
    parentId: 0,     // 从item复制来的
    children: []     // 新添加的
}

3.5 什么时候必须用 ...item

必须用的场景:当你不想修改原始数据时

// 场景1:后端返回的数据,你不想污染它
const dataFromServer = [...]
const myCopy = { ...dataFromServer[0] }

// 场景2:多次使用同一份数据
const baseConfig = { theme: 'dark' }
const user1Config = { ...baseConfig, name: '张三' }
const user2Config = { ...baseConfig, name: '李四' }
// user1Config和user2Config互不影响

// 场景3:需要添加新属性,又不影响原对象
const original = { x: 1, y: 2 }
const enhanced = { ...original, z: 3 }
// original还是{x:1,y:2},enhanced是{x:1,y:2,z:3}

第四章:Map优化法(空间换时间)

4.1 为什么要优化?

递归法虽然好理解,但有个严重的问题:太慢了

想象一下:

  • 100个节点:递归法要做100×100=10000次操作
  • 1000个节点:要做1000×1000=1000000次操作
  • 10000个节点:...算了,太可怕了!

这就是我们常说的时间复杂度O(n²),数据量越大越慢。

4.2 优化思路

就像你去图书馆找书:

  • 递归法:每次找一本书都要把整个图书馆逛一遍
  • 优化法:先做一个索引表,想看什么书直接查索引

4.3 基础版代码实现(逐行解释)

function listToTree(list) {
    // 1. 第一步:创建"索引表"(map)
    // 这个map就像一个电话簿,通过id能直接找到对应的人
    const map = {}
    
    // 2. 第二步:存放最终结果(根节点们)
    const result = []
    
    // 3. 第一次遍历:把所有人都放进"电话簿"
    list.forEach(item => {
        // 对每个人,都做一份复印件(用...item复制)
        // 并且给复印件加一个空的"孩子名单"(children数组)
        map[item.id] = {
            ...item,        // 复印个人信息
            children: []    // 准备一个空的孩子名单
        }
    })
    
    // 4. 第二次遍历:建立父子关系
    list.forEach(item => {
        // 判断:这个人是不是根节点(没有爸爸)?
        if (item.parentId === 0) {
            // 是根节点:直接放进最终结果
            result.push(map[item.id])
        } else {
            // 不是根节点:找到他爸爸,把自己加入爸爸的孩子名单
            
            // map[item.parentId] 通过爸爸的ID找到爸爸
            // ?. 是可选链操作符,意思是:如果爸爸存在,就执行后面的操作
            // .children.push() 把自己加入爸爸的孩子名单
            map[item.parentId]?.children.push(map[item.id])
        }
    })
    
    // 5. 返回最终结果
    return result
}

4.4 图解Map优化法

假设有这样的数据:

原始列表:
[  {id:1, parentId:0, name:'A'},  // 根节点  {id:2, parentId:1, name:'B'},  // A的孩子  {id:3, parentId:1, name:'C'}   // A的孩子]

第一次遍历后(建立索引表):
map = {
  1: {id:1, name:'A', children:[]},
  2: {id:2, name:'B', children:[]},
  3: {id:3, name:'C', children:[]}
}

第二次遍历(建立关系):
- 处理item1: parentId=0 → result = [map[1]]
- 处理item2: parentId=1 → map[1].children.push(map[2])
- 处理item3: parentId=1 → map[1].children.push(map[3])

最终result:
[{
  id:1, name:'A',
  children: [
    {id:2, name:'B', children:[]},
    {id:3, name:'C', children:[]}
  ]
}]

4.5 使用ES6 Map版本(更专业的写法)

function listToTree(list) {
    // 使用ES6的Map数据结构代替普通对象
    // Map相比普通对象有更多优点:键可以是任何类型,有size属性等
    const nodeMap = new Map()
    const tree = []
    
    // 第一次遍历:初始化所有节点
    list.forEach(item => {
        nodeMap.set(item.id, {
            ...item,
            children: []
        })
    })
    
    // 第二次遍历:构建树结构
    list.forEach(item => {
        if (item.parentId === 0) {
            // 根节点直接加入树
            tree.push(nodeMap.get(item.id))
        } else {
            // 非根节点找爸爸
            const parentNode = nodeMap.get(item.parentId)
            if (parentNode) {
                // 把自己加入爸爸的孩子名单
                parentNode.children.push(nodeMap.get(item.id))
            }
        }
    })
    
    return tree
}

4.6 为什么返回result就是返回所有树的元素?

这是一个非常关键的知识点!很多初学者会疑惑:为什么最后返回result就能得到完整的树?

让我们用一个比喻来理解:

想象你是一个班主任,要整理全校学生的家族关系:

  1. 你有一张全校学生名单(list
  2. 你做了一个索引表(map),通过学号能快速找到每个学生
  3. 你有一个空的花名册(result),用来放每个家族的"族长"(根节点)

关键理解:当你把"族长"放进result时,这个"族长"已经通过索引表关联了所有的子孙后代!

// 实际内存中的关系
map[1] = { id:1, name:'A', children: [] }  // 族长A
map[2] = { id:2, name:'B', children: [] }  // A的儿子B
map[3] = { id:3, name:'C', children: [] }  // A的儿子C

// 建立关系后
map[1].children.push(map[2])  // 现在 map[1].children 里有 map[2] 的引用
map[1].children.push(map[3])  // 现在 map[1].children 里有 map[3] 的引用

// 把map[1]放入result
result.push(map[1])

// 此时的map[1]长这样:
{
    id: 1,
    name: 'A',
    children: [
        { id:2, name:'B', children:[] },  // 注意:这里是完整的B对象
        { id:3, name:'C', children:[] }   // 注意:这里是完整的C对象
    ]
}

重点来了:虽然我们只把A放进了result,但是A的children数组里直接存储了B和C对象的引用。也就是说:

  • result[0] 就是 A
  • result[0].children[0] 就是 B
  • result[0].children[1] 就是 C

所以通过result,我们就能访问到整棵树的所有节点!

如果有多个根节点:

// 假设还有另一个家族:D是族长,E是D的儿子
map[4] = { id:4, name:'D', children: [] }
map[5] = { id:5, name:'E', children: [] }
map[4].children.push(map[5])

// 把map[4]也放入result
result.push(map[4])

// 最终result:
[
    {  // 第一棵树
        id: 1, name:'A',
        children: [ { id:2, name:'B' }, { id:3, name:'C' } ]
    },
    {  // 第二棵树
        id: 4, name:'D',
        children: [ { id:5, name:'E' } ]
    }
]

所以返回result就是返回了所有的树,因为:

  1. 每个根节点都包含了它的所有子孙节点(通过引用)
  2. result数组收集了所有的根节点
  3. 通过这些根节点,我们可以访问到整个森林的所有节点

4.7 为什么说"空间换时间"?

  • 递归法:速度快吗?慢!占内存吗?少!(时间多,空间少)
  • Map优化法:速度快吗?快!占内存吗?多!(时间少,空间多)

就像搬家:

  • 递归法:每次需要什么都临时去买(耗时但省地方)
  • Map优化法:先把所有东西都买好放仓库(费地方但省时间)

第五章:两种方法的详细对比

对比维度 递归法 Map优化法 通俗解释
时间复杂度 O(n²) O(n) 100个数据:递归法要查10000次,Map法只要查200次
空间复杂度 O(1) O(n) 递归法基本不占额外内存,Map法需要建一个索引表
代码长度 短(3-5行) 稍长(10-15行) 递归法更简洁
可读性 ⭐⭐⭐⭐⭐ ⭐⭐⭐ 递归法更容易理解
适用场景 小数据量(<100条) 大数据量(>100条) 根据数据量选择

第六章:实际应用场景(详细版)

6.1 省市区三级联动

// 实际开发中,后端通常只返回扁平列表
const areas = [
    {id: 1, parentId: 0, name: '中国'},
    {id: 2, parentId: 1, name: '北京'},
    {id: 3, parentId: 1, name: '上海'},
    {id: 4, parentId: 2, name: '东城区'},
    {id: 5, parentId: 2, name: '西城区'},
    {id: 6, parentId: 3, name: '黄浦区'}
]

// 转换后,就可以方便地实现三级联动选择器
const areaTree = listToTree(areas)
// 用户选择"中国"后,自动显示"北京"、"上海"
// 选择"北京"后,自动显示"东城区"、"西城区"

6.2 组织架构树

// 公司人员列表
const employees = [
    {id: 1, parentId: 0, name: '张总', position: 'CEO'},
    {id: 2, parentId: 1, name: '李经理', position: '技术总监'},
    {id: 3, parentId: 1, name: '王经理', position: '市场总监'},
    {id: 4, parentId: 2, name: '赵前端', position: '前端工程师'},
    {id: 5, parentId: 2, name: '钱后端', position: '后端工程师'},
    {id: 6, parentId: 3, name: '孙策划', position: '市场专员'}
]

// 转换后可以渲染出组织架构图
const orgTree = listToTree(employees)

6.3 权限菜单树

// 后台管理系统的菜单
const menus = [
    {id: 1, parentId: 0, name: '系统管理', icon: '⚙️'},
    {id: 2, parentId: 1, name: '用户管理', icon: '👤'},
    {id: 3, parentId: 1, name: '角色管理', icon: '🔑'},
    {id: 4, parentId: 2, name: '新增用户', permission: 'user:add'},
    {id: 5, parentId: 2, name: '编辑用户', permission: 'user:edit'}
]

// 转换后可以方便地渲染侧边栏菜单
const menuTree = listToTree(menus)

第七章:常见问题解答(FAQ)

Q1: 如果数据中有多个根节点怎么办?

A: 没问题!result 数组会包含所有根节点,形成一个"森林"(多棵树)。每个根节点都带着自己的子树。

Q2: 如果数据中有循环引用(A的爸爸是B,B的爸爸是A)怎么办?

A: 这会导致无限递归!需要先做数据校验,或者设置最大递归深度。

Q3: 什么情况下用递归法,什么情况下用Map法?

A:

  • 数据量小(<100条):用递归法,简单易懂
  • 数据量大(>100条):用Map法,性能好
  • 面试时:先说递归法展示思路,再说Map法展示优化能力

Q4: 为什么 map[item.parentId]?.children 要加问号?

A: 问号是可选链操作符,意思是:如果 map[item.parentId] 存在,才执行后面的 .children.push。防止出现"找不到爸爸"的错误。

Q5: 为什么返回result就能得到完整的树?

A: 因为每个根节点的children数组里存储的是子节点的引用,而不是复制品。当你通过result访问根节点时,实际上可以通过引用链访问到所有后代节点。

第八章:面试技巧

当面试官问到这个问题时,可以这样回答:

  1. 第一层(基础):"我可以用递归实现,先找根节点,再递归找每个节点的子节点。"

  2. 第二层(优化):"但递归的时间复杂度是O(n²),数据量大时会很慢。我可以用Map优化到O(n)。"

  3. 第三层(细节):"在实现时要注意用...item复制对象,避免修改原始数据。还要处理边界情况,比如找不到父节点的情况。"

  4. 第四层(原理):"Map优化法的核心是空间换时间,通过索引表实现O(1)查找。返回result之所以能得到完整的树,是因为JavaScript的对象引用机制——根节点的children里存储的是子节点的引用。"

  5. 第五层(应用):"这个算法在实际开发中很常用,比如我在做省市区选择器、后台管理系统菜单时都用到了。"

第九章:总结与思考

通过这篇文章,我们学习了:

  1. 什么是列表转树:把扁平数据变成树形结构
  2. 递归法:直观但性能较差
  3. ...item的作用:复制对象,避免修改原始数据
  4. Map优化法:性能好但稍微复杂
  5. 返回结果的原理:通过引用机制,根节点包含所有子孙节点
  6. 实际应用场景:省市区联动、组织架构、权限菜单等

掌握了这个算法,你不仅能应对面试,在实际开发中也能游刃有余地处理各种树形结构数据。


如果这篇文章对你有帮助,欢迎点赞收藏,也欢迎在评论区提出你的问题!

LeetCode 104. 二叉树的最大深度:解题思路+代码解析

LeetCode基础题第104题「二叉树的最大深度」,这道题是二叉树遍历的经典入门题,核心考察对二叉树层次遍历(BFS)的理解和应用,适合新手入门练手。今天就来详细拆解这道题,从题目理解到代码实现,再到细节优化,一步步讲清楚,看完就能轻松掌握。

一、题目解读

题目描述

给定一个二叉树 root ,返回其最大深度。二叉树的最大深度是指从根节点到最远叶子节点的最长路径上的节点数。

简单来说,就是要找到二叉树“最深”的那一层,统计从根到这一层的所有节点个数。举个例子:

  • 如果二叉树只有根节点,没有左右子节点,最大深度就是1;

  • 如果根节点有左子节点,左子节点又有一个子节点,那么最大深度就是3;

  • 如果二叉树是空树(root为null),最大深度就是0。

核心考点

这道题的核心是「二叉树的遍历」,常见的解法有两种:

  1. 层次遍历(BFS,广度优先搜索):按层遍历二叉树,每遍历完一层,深度加1,最终的深度就是最大深度(本文重点讲解这种解法,对应给出的代码);

  2. 深度优先搜索(DFS):递归遍历左右子树,取左右子树的最大深度,再加上当前根节点的深度1,即为整个树的最大深度(后续会补充备选代码)。

二、代码解析(TypeScript)

先贴出完整代码(已优化,解决原代码潜在问题),再逐行拆解思路,新手也能看懂~

/**
 * Definition for a binary tree node.
 * 二叉树节点的定义(题目已给出,无需修改)
 */
class TreeNode {
  val: number
  left: TreeNode | null
  right: TreeNode | null
  constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
    this.val = (val === undefined ? 0 : val)
    this.left = (left === undefined ? null : left)
    this.right = (right === undefined ? null : right)
  }
}

/**
 * 计算二叉树最大深度(层次遍历/BFS解法)
 * @param root 二叉树根节点
 * @returns 二叉树的最大深度
 */
function maxDepth(root: TreeNode | null): number {
  // 边界处理:空树直接返回深度0(避免后续无效循环)
  if (root === null) {
    return 0;
  }

  let depth = 0; // 记录当前深度,初始化为0
  // 用数组模拟队列,存储当前层的所有节点(初始存入根节点)
  const queue: TreeNode[] = [root];

  // 队列不为空,说明还有节点未遍历,继续循环
  while (queue.length > 0) {
    const levelSize = queue.length; // 当前层的节点个数
    // 遍历当前层的所有节点
    for (let i = 0; i < levelSize; i++) {
      // 取出当前层的节点(优化:用pop+unshift替代shift,提升性能)
      const node = queue.pop()!;
      
      // 若当前节点有右子节点,存入队列(先存右,再存左,保证遍历顺序)
      if (node.right) {
        queue.unshift(node.right);
      }
      // 若当前节点有左子节点,存入队列
      if (node.left) {
        queue.unshift(node.left);
      }
    }
    // 可选:打印队列,观察每一层遍历后的节点变化(调试用)
    
    
    // 当前层遍历完毕,深度加1
    depth++;
  }

  return depth;
}

逐行拆解思路

1. 边界处理(关键!)

if (root === null) { return 0; }

这一步是很多新手容易忽略的点:如果二叉树是空树(root为null),没有任何节点,最大深度自然是0。如果不做这个判断,后续队列会存入null,导致循环多执行一次,最终返回错误的深度1。

2. 初始化变量

  • let depth = 0;:用于记录二叉树的深度,初始值为0(因为还未开始遍历任何一层);

  • const queue: TreeNode[] = [root];:用数组模拟队列(队列是BFS的核心数据结构),初始时将根节点存入队列,代表从根节点开始遍历。

3. 层次遍历核心循环

while (queue.length > 0) { ... }:队列不为空,说明还有节点未遍历,循环继续。每一次循环,都代表遍历完「一层」节点。

4. 遍历当前层节点

const levelSize = queue.length;:获取当前层的节点个数,这个值是固定的(因为后续队列会存入下一层的节点,不能直接用queue.length判断当前层节点数)。

for (let i = 0; i < levelSize; i++) { ... }:循环遍历当前层的每一个节点,把每个节点的左右子节点(如果有的话)存入队列,为下一层遍历做准备。

5. 节点取出与子节点入队(性能优化点)

原代码中如果用queue.shift()取出节点,会有性能问题——因为数组的shift()方法是O(n)时间复杂度(需要将数组中所有元素向前移动一位),当二叉树节点较多时,效率会很低。

优化方案:用queue.pop()(O(1)时间复杂度)取出队列尾部的节点,同时调整子节点入队顺序(先存右子节点,再存左子节点),保证遍历顺序和shift()一致,既提升性能,又不影响结果。

这里的!是非空断言,因为我们已经通过边界处理和循环条件,确保队列中的节点一定是TreeNode类型(不会为null),所以可以安全使用非空断言。

6. 深度递增

depth++;:每遍历完一层,说明二叉树的深度增加了1,所以深度加1。当循环结束时,depth就是二叉树的最大深度。

三、代码优化说明

对比LeetCode题目给出的初始模板,这段代码做了3个关键优化,兼顾性能和正确性:

  1. 新增边界处理:解决空树返回错误深度的问题,让代码更健壮;

  2. 性能优化:用pop() + unshift()替代shift(),将节点取出操作的时间复杂度从O(n)降至O(1),适合处理节点较多的二叉树;

可读性优化:添加详细注释,变量命名语义化(如levelSize代表当前层节点数),方便新手理解每一步的执行过程。

四、备选解法(DFS递归版)

除了上述层次遍历(BFS)解法,这道题还可以用深度优先搜索(DFS)的递归写法,代码更简洁,适合树深度不大的场景(避免递归栈溢出),新手也可以了解一下:

function maxDepthRecursive(root: TreeNode | null): number {
  // 空树返回0
  if (root === null) {
    return 0;
  }
  // 递归计算左右子树的最大深度,当前深度 = 左右子树最大深度 + 1(当前节点)
  return Math.max(maxDepthRecursive(root.left), maxDepthRecursive(root.right)) + 1;
}

递归解法的核心思路:二叉树的最大深度 = 左子树最大深度和右子树最大深度的最大值 + 1(当前根节点),本质是遍历到最底层的叶子节点,再回溯计算深度。

五、总结

LeetCode 104题是二叉树遍历的入门题,难度简单,但能很好地巩固BFS和DFS的基础思路。本文讲解的层次遍历(BFS)解法,适合所有场景(包括极深的二叉树,避免递归栈溢出),优化后的代码兼顾性能和可读性,新手可以重点掌握。

解题关键记住两点:

  1. 边界处理:空树直接返回0,避免错误;

  2. 层次遍历核心:用队列存储每一层的节点,每遍历完一层,深度加1。

实战|DeLinkedIn 全栈开发:Web3 身份验证 + 数字资产确权,搭建职场社交新生态

前言

本文主要整合往期发布的 DAO、SSI 身份、社区所有权社交 等相关内容,实现一个简洁的去中心化社区实例。延续以往风格 理论加代码实践相结合。

概述

在 2026 年,职场社交正在经历从“平台信用”向“加密证明”的范式转移。传统的 LinkedIn 依赖于用户自述的简历,而 DeLinkedIn 则是通过 SSI (自主主权身份)Social NFT (内容所有权)  和 DAO (去中心化治理)  的三位一体架构,构建了一个真实、透明且价值对等的职业生态。

核心架构理论:三权分立

  1. 身份层 (SSI/SBT):真实性的根基

    • 理论:简历不再是 PDF,而是由大学、前雇主或开源组织签发的灵魂绑定代币 (SBT)
    • 解决痛点:消除简历造假。只有持有特定技能凭证的用户才能进入高阶人才库。
  2. 社交层 (Community Ownership):内容即资产

    • 理论:每一次职场深度分享都铸造为 ERC-721 NFT
    • 解决痛点:创作者拥有粉丝关系和内容的所有权,平台无法通过流量抽成剥削职场博主。
  3. 治理层 (DAO/Token):共建者激励

    • 理论:平台由持有 Governance Token 的成员共有。优质内容的产出直接由 DAO 金库进行代币奖励。
    • 解决痛点:将“用户流量”转化为“社区股份”,实现利益共担。

猎头赏金:智能合约如何重构招聘经济学

  1. 企业发布(Post & Lock):企业发布职位并锁定一定数额的平台代币(赏金)到合约。
  2. 用户推荐(Referral):用户通过自己的 DID 身份推荐好友。
  3. 多签结算(Settlement):当好友入职通过试用期,企业或 DAO 触发结算,赏金自动拨付给推荐人。

DeLinkedIn的BountyLinkedIn合约将传统猎头的"人治"流程改造为无需信任的自动化协议

三步闭环

步骤 角色 链上动作 传统痛点 合约解决方案
1. 锁仓 企业 postJobBounty() 锁定赏金 口头承诺无保障 资金托管在合约,无法撤回
2. 推荐 专业人士 referCandidate() 记录关系 推荐关系难证明 DID身份绑定,链上可追溯
3. 结算 企业/DAO fulfillBounty() 自动拨付 结算周期长、扯皮多 条件触发,秒级到账

智能合约落地全流程

智能合约

  • 去中心化领英
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";

// 身份接口:用于核验用户是否拥有“技能凭证”
interface ISoulboundIdentity {
    function balanceOf(address owner) external view returns (uint256);
}

contract DeLinkedIn is ERC721, AccessControl {
    bytes32 public constant MODERATOR_ROLE = keccak256("MODERATOR_ROLE");
    IERC20 public platformToken;
    ISoulboundIdentity public sbtIdentity;

    uint256 private _nextPostId;
    uint256 public constant POST_REWARD = 10 * 10**18; // 发帖奖励 10 Token

    struct WorkPost {
        address author;
        string metadataUri; // 职业动态内容
        bool isVerifiedPro; // 是否为核验专家
    }

    mapping(uint256 => WorkPost) public posts;

    error NotSkillCertified(); // 未获得技能认证(SSI 拦截)
    error rewardTransferFailed();

    constructor(address _token, address _sbt) ERC721("DeLinkedInPost", "DLP") {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        platformToken = IERC20(_token);
        sbtIdentity = ISoulboundIdentity(_sbt);
    }
    function supportsInterface(bytes4 interfaceId)
            public
            view
            override(ERC721, AccessControl)
            returns (bool)
        {
            return super.supportsInterface(interfaceId);
        }
    /**
     * @dev 发布职场动态:只有持有 SSI 技能凭证的用户才能发布
     */
    function publishProfessionalInsight(string memory _uri) external {
        // 1. SSI 身份核验:检查用户是否持有灵魂绑定技能凭证
        if (sbtIdentity.balanceOf(msg.sender) == 0) {
            revert NotSkillCertified();
        }

        // 2. 社区所有权:内容 NFT 化
        uint256 tokenId = _nextPostId++;
        _safeMint(msg.sender, tokenId);
        posts[tokenId] = WorkPost(msg.sender, _uri, true);

        // 3. 经济激励:给创作者发放平台代币奖励(由 DAO 金库支持)
        bool success = platformToken.transfer(msg.sender, POST_REWARD);
        if (!success) revert rewardTransferFailed();
    }
}

  • 猎头赏金:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "./DeLinkedIn.sol"; // 继承之前的逻辑

contract BountyLinkedIn is DeLinkedIn {
    struct JobBounty {
        address employer;
        uint256 rewardAmount;
        bool isActive;
    }

    // 职位 ID => 赏金信息
    mapping(uint256 => JobBounty) public jobBounties;
    // 推荐记录:候选人地址 => 推荐人地址
    mapping(address => address) public referrals;

    event BountyPosted(uint256 jobId, uint256 amount);
    event BountyClaimed(uint256 jobId, address indexed referrer, address indexed candidate);

    constructor(address _token, address _sbt) DeLinkedIn(_token, _sbt) {}

    /**
     * @dev 企业发布赏金职位
     */
    function postJobBounty(uint256 _jobId, uint256 _amount) external {
        platformToken.transferFrom(msg.sender, address(this), _amount);
        jobBounties[_jobId] = JobBounty(msg.sender, _amount, true);
        emit BountyPosted(_jobId, _amount);
    }

    /**
     * @dev 用户提交推荐:记录推荐关系
     */
    function referCandidate(address _candidate) external {
        if (sbtIdentity.balanceOf(msg.sender) == 0) revert NotSkillCertified();
        referrals[_candidate] = msg.sender;
    }

    /**
     * @dev 企业确认入职,拨付赏金给推荐人
     */
    function fulfillBounty(uint256 _jobId, address _candidate) external {
        JobBounty storage bounty = jobBounties[_jobId];
        require(msg.sender == bounty.employer, "Only employer can fulfill");
        require(bounty.isActive, "Bounty not active");

        address referrer = referrals[_candidate];
        require(referrer != address(0), "No referrer found");

        bounty.isActive = false;
        platformToken.transfer(referrer, bounty.rewardAmount);

        emit BountyClaimed(_jobId, referrer, _candidate);
    }
}

测试脚本

测试用例:DeLinkedIn 综合项目测试 (SSI + Social + DAO)

  • 核验通过的专业人士应能发布动态并获得代币奖励
  • 未获得 SSI 认证的‘游客’尝试发布应被拒绝
import assert from "node:assert/strict";
import { describe, it, beforeEach } from "node:test";
import { network } from "hardhat";
import { parseEther } from 'viem';

describe("DeLinkedIn 综合项目测试 (SSI + Social + DAO)", function () {
    let delinkedIn: any, token: any, sbt: any;
    let publicClient, testClient;
    let admin: any, user: any, stranger: any;

    beforeEach(async function () {
        const { viem: v } = await (network as any).connect();
        publicClient = await v.getPublicClient();
        testClient = await v.getTestClient();
        [admin, user, stranger] = await v.getWalletClients();

        // 1. 部署基础设施
        token = await v.deployContract("contracts/DAO.sol:MyToken", []); 
        sbt = await v.deployContract("contracts/SoulboundIdentity.sol:SoulboundIdentity", []);
        
        // 2. 部署领英主合约
        delinkedIn = await v.deployContract("contracts/DeLinkedIn.sol:DeLinkedIn", [
            token.address, 
            sbt.address
        ]);

        // 3. 注入 DAO 奖励金库资金
        await token.write.transfer([delinkedIn.address, parseEther("1000")]);

        // 4. 为 SSI 合约设置签发者并给 user 签发一个技能凭证
        const ISSUER_ROLE = await sbt.read.ISSUER_ROLE();
        await sbt.write.grantRole([ISSUER_ROLE, admin.account.address]);
        await sbt.write.issueIdentity([user.account.address, "ipfs://Senior-Dev-Cert", 0n]);
    });

    it("核验通过的专业人士应能发布动态并获得代币奖励", async function () {
        const initialBalance = await token.read.balanceOf([user.account.address]);
        
        // 用户发布动态
        await delinkedIn.write.publishProfessionalInsight(["ipfs://My-Web3-Insight"], { account: user.account });

        // 验证 1: 内容所有权 (NFT)
        const nftBalance = await delinkedIn.read.balanceOf([user.account.address]);
        assert.equal(nftBalance, 1n, "应获得内容所有权 NFT");

        // 验证 2: 经济激励 (Token)
        const finalBalance = await token.read.balanceOf([user.account.address]);
        assert.equal(finalBalance - initialBalance, parseEther("10"), "应获得 10 枚代币奖励");
    });

    it("未获得 SSI 认证的‘游客’尝试发布应被拒绝", async function () {
        await assert.rejects(
            delinkedIn.write.publishProfessionalInsight(["ipfs://Fake-Insight"], { account: stranger.account }),
            /NotSkillCertified/,
            "未认证用户必须被 SSI 逻辑拦截"
        );
    });
});

测试用例:猎头赏金流程测试

  • 发布赏金 -> 推荐好友 -> 入职结算的闭环
import assert from "node:assert/strict";
import { describe, it, beforeEach } from "node:test";
import { network} from "hardhat";
import { parseEther } from 'viem';

describe("DeLinkedIn 猎头赏金流程测试", function () {
    let bountyContract: any, token: any, sbt: any;
    let publicClient;
    let employer: any, referrer: any, candidate: any;

    beforeEach(async function () {
        const { viem: v } = await (network as any).connect();
        publicClient = await v.getPublicClient();
        [employer, referrer, candidate] = await v.getWalletClients();

        // 部署
        token = await v.deployContract("contracts/DAO.sol:MyToken", []);
        sbt = await v.deployContract("contracts/SoulboundIdentity.sol:SoulboundIdentity", []);
        bountyContract = await v.deployContract("contracts/BountyLinkedIn.sol:BountyLinkedIn", [token.address, sbt.address]);

        // 初始化:给推荐人签发技能身份,给企业发钱
        const ISSUER_ROLE = await sbt.read.ISSUER_ROLE();
        await sbt.write.grantRole([ISSUER_ROLE, employer.account.address]);
        await sbt.write.issueIdentity([referrer.account.address, "ipfs://Expert", 0n]);
        await token.write.transfer([employer.account.address, parseEther("500")]);
    });

    it("应该完成:发布赏金 -> 推荐好友 -> 入职结算的闭环", async function () {
        const bountyAmount = parseEther("100");

        // 1. 企业发布赏金
        await token.write.approve([bountyContract.address, bountyAmount], { account: employer.account });
        await bountyContract.write.postJobBounty([1n, bountyAmount], { account: employer.account });

        // 2. 推荐人推荐候选人
        await bountyContract.write.referCandidate([candidate.account.address], { account: referrer.account });

        // 3. 企业确认入职并拨付
        const initialBalance = await token.read.balanceOf([referrer.account.address]);
        await bountyContract.write.fulfillBounty([1n, candidate.account.address], { account: employer.account });

        // 4. 验证推荐人收到赏金
        const finalBalance = await token.read.balanceOf([referrer.account.address]);
        assert.equal(finalBalance - initialBalance, bountyAmount, "推荐人应收到 100 枚代币赏金");
    });
});

部署脚本

// scripts/deploy.js
import { network, artifacts } from "hardhat";
async function main() {
  // 连接网络
  const { viem } = await network.connect({ network: network.name });//指定网络进行链接
  
  // 获取客户端
  const [deployer] = await viem.getWalletClients();
  const publicClient = await viem.getPublicClient();
 
  const deployerAddress = deployer.account.address;
   console.log("部署者的地址:", deployerAddress);
  // 加载合约
  const TokenArtifact = await artifacts.readArtifact("contracts/DAO.sol:MyToken");
  const SoulboundIdentityArtifact = await artifacts.readArtifact("contracts/SoulboundIdentity.sol:SoulboundIdentity");
  const DeLinkedInArtifact = await artifacts.readArtifact("contracts/DeLinkedIn.sol:DeLinkedIn");
  const BountyLinkedInArtifact = await artifacts.readArtifact("contracts/BountyLinkedIn.sol:BountyLinkedIn");
 const TokenHash = await deployer.deployContract({
    abi: TokenArtifact.abi,//获取abi
    bytecode: TokenArtifact.bytecode,//硬编码
    args: [],
  });
  const TokenReceipt = await publicClient.waitForTransactionReceipt({ hash: TokenHash });
  console.log("Token合约地址:", TokenReceipt.contractAddress);
  // 部署
  const SoulboundIdentityHash = await deployer.deployContract({
    abi: SoulboundIdentityArtifact.abi,//获取abi
    bytecode: SoulboundIdentityArtifact.bytecode,//硬编码
    args: [],
  });
   const SoulboundIdentityReceipt = await publicClient.waitForTransactionReceipt({ hash: SoulboundIdentityHash });
   console.log("SoulboundIdentity合约地址:", SoulboundIdentityReceipt.contractAddress);
   const DeLinkedInHash = await deployer.deployContract({
    abi: DeLinkedInArtifact.abi,//获取abi
    bytecode: DeLinkedInArtifact.bytecode,//硬编码
    args: [TokenReceipt.contractAddress, SoulboundIdentityReceipt.contractAddress],
  });
   const DeLinkedInReceipt = await publicClient.waitForTransactionReceipt({ hash: DeLinkedInHash });
   console.log("DeLinkedIn合约地址:", DeLinkedInReceipt.contractAddress);
   const BountyLinkedInHash = await deployer.deployContract({
    abi: BountyLinkedInArtifact.abi,//获取abi
    bytecode: BountyLinkedInArtifact.bytecode,//硬编码
    args: [TokenReceipt.contractAddress, SoulboundIdentityReceipt.contractAddress],
  });
   const BountyLinkedInReceipt = await publicClient.waitForTransactionReceipt({ hash: BountyLinkedInHash });
   console.log("BountyLinkedIn合约地址:", BountyLinkedInReceipt.contractAddress);
}
main().catch(console.error);

结语

至此,基于DAO、SSI身份、社区所有权社交三大核心技术的综合案例——2026去中心化领英(DeLinkedIn)已全部完成。本文延续了“理论+代码”的呈现风格,从项目架构设计、核心业务逻辑拆解,到智能合约开发、测试脚本编写、部署脚本实现,完整呈现了去中心化社区的落地过程。

【翻译】我竟渐渐迷上了生成器的设计巧思

原文链接:macarthur.me/posts/gener…

我花了些功夫深入学习迭代器、可迭代对象和生成器,如今总算开始体会到其中的精妙之处。

我钟爱过去十年里 JavaScript 出现的那些 “语法糖”(箭头函数、模板字符串、解构赋值等等)。究其原因,是这些特性大多解决了我实际开发中的痛点 —— 有些痛点我甚至自己都未曾察觉。它们的优势显而易见,也让我有大把机会在开发中大展拳脚。

但这些新特性里,也有一些 “异类”,比如生成器函数。它和 ES2015 的其他核心特性诞生于同一时期,实用性却始终没怎么被大众认可,你甚至可能一眼都认不出它的写法:

function* generateAlphabet() {
  yield "a";
  yield "b";
  // … 依次生成
  yield "z";
}

平心而论,我至少发现过一次它的实用之处。我曾写过一篇文章,讲如何用生成器按需解构任意数量的元素,直到现在我依然觉得这个用法超棒。但我总忍不住想,自己是不是还错过了它更多的妙用。

于是,我决定认认真真、沉下心来研究它一番。或许深入了解后,就能发现它的更多适用场景。没想到,我竟真的开始欣赏起它的设计巧思,以及它所传递的编程思维模式 —— 至少在某些场景下是这样。接下来,我就和大家聊聊我的心得体会。首先,我们先退一步,把相关的基础概念梳理清楚。

迭代器协议与可迭代协议

生成器的底层依赖两个截然不同的协议,不了解它们,就无法真正理解生成器:迭代器协议和可迭代协议。这两个协议都用于生成一组长度不确定的序列值,后者是在前者的基础上构建而来的。

迭代器协议

该协议标准化了生成序列的对象的结构和行为。一个对象只要满足以下条件,就是一个迭代器:它暴露一个next()方法,该方法返回一个包含两个属性的对象:

  • value: any:序列中的当前值
  • done: boolean:标记序列是否遍历完毕

仅此而已。来看一个简单易懂的示例:

const gospelIterator = {
  index: -1,

  next() {
    const gospels = ["马太福音", "马可福音", "路加福音", "约翰福音"];
    this.index++;

    return {
      value: gospels.at(this.index),
      done: this.index + 1 > gospels.length,
    };
  },
};

gospelIterator.next(); // {value: '马太福音', done: false}
gospelIterator.next(); // {value: '马可福音', done: false}
gospelIterator.next(); // {value: '路加福音', done: false}
gospelIterator.next(); // {value: '约翰福音', done: false}
gospelIterator.next(); // {value: undefined, done: true}

顺带一提,迭代器生成的序列并非必须有终点,无限迭代器是完全合法的:

const infiniteIterator = {
  count: 0,
  next() {
    this.count++;

    return {
      value: this.count,
      done: false,
    };
  },
};

infiniteIterator.next(); // 会一直生成递增的数字…

单看这个协议,除了能让迭代行为保持一致,似乎没什么实际用处。而可迭代协议,会让它的价值显现出来。

可迭代协议

一个对象如果拥有[Symbol.iterator]()方法,且该方法返回一个迭代器对象,那么这个对象就是可迭代对象。我们平时使用的for...of循环、数组解构,底层都是基于这个协议实现的。JavaScript 的常见原生类型StringArrayMap)都内置了这个协议。

自己实现可迭代协议,就能自定义for...of循环的行为。基于上面的示例,我们来实现一个可迭代对象:

const gospelIterable = {
  [Symbol.iterator]() {
    return {
      index: -1,

      next() {
        const gospels = ["马太福音", "马可福音", "路加福音", "约翰福音"];
        this.index++;

        return {
          value: gospels.at(this.index),
          done: this.index + 1 > gospels.length,
        };
      },
    };
  },
};

现在,这个对象就能直接用for...of循环遍历,也能进行解构操作了:

for (const author of gospelIterable) {
  console.log(author); // 马太福音、马可福音、路加福音、约翰福音
}

console.log([...gospelIterable]);
// ['马太福音', '马可福音', '路加福音', '约翰福音']

接下来我们看一个更进阶的示例,这个示例的效果很难用简单的数组实现:生成 1900 年之后的所有闰年并遍历:

function isLeapYear(year) {
  // 闰年判断规则:整百年能被400整除,非整百年能被4整除
  return year % 100 === 0 ? year % 400 === 0 : year % 4 === 0;
}

const leapYears = {
  [Symbol.iterator]() {
    return {
      startYear: 1900,
      currentYear: new Date().getFullYear(),
      next() {
        this.startYear++;

        // 找到下一个闰年
        while (!isLeapYear(this.startYear)) {
          this.startYear++;
        }

        return {
          value: this.startYear,
          done: this.startYear > this.currentYear,
        };
      },
    };
  },
};

for (const leapYear of leapYears) {
  console.log(leapYear);
}

注意看,我们无需提前生成所有的闰年序列,所有状态都存储在可迭代对象内部,下一个值会在需要时才计算生成。这一点非常重要,值得我们重点关注。

惰性求值

惰性求值是可迭代对象最受推崇的优势之一:我们无需从一开始就生成序列中的所有值。在某些情况下,这能有效避免性能问题。

再看上面的闰年可迭代对象示例。如果不用可迭代对象,而是用普通的for循环实现相同的功能,你大概率会提前生成一个包含所有闰年的数组:

const leapYears = [];
const startYear = 1900;
const currentYear = new Date().getFullYear();

// 先遍历生成所有闰年,存入数组
for (let year = startYear + 1; year <= currentYear; year++) {
  if (isLeapYear(year)) {
      leapYears.push(year);
    }
}

// 再遍历数组使用闰年
for (const leapYear of leapYears) {
  console.log(leapYear);
}

这段代码的可读性确实很高(很多人会觉得比可迭代对象的版本更易读),但也存在明显的取舍:需要执行两层for循环,更重要的是,所有值都会被提前计算并存储。在这个示例中,性能影响微乎其微,但如果是计算成本极高的操作,或是处理超大规模的数据集,这种方式的性能损耗就会非常明显。比如这样的场景:

for (const thing of getExpensiveThings(1000)) {
  // 对每个元素执行重要操作
}

如果getExpensiveThings()底层没有自定义可迭代对象支撑,那么循环执行前,必须先生成包含 1000 个元素的完整数组。从执行脚本到真正开始处理业务逻辑,会产生不必要的时间损耗。

同理,当我们不需要序列中的所有值时,惰性求值的优势会更突出。比如,我们想根据一个人的出生年份,找到其经历的第一个闰年。一旦找到目标值,就无需继续计算后续的闰年了。如果用提前生成数组的方式,数组中后续的元素就相当于白生成了。

function getFirstLeapYear(birthYear) {
  for (const leapYear of leapYears) {
    if (leapYear >= birthYear) return leapYear;
  }
  
  return null;
}

// 只会计算到1992年的闰年,后续会直接终止
getFirstLeapYear(1989) // 1992

显然,在计算资源高度密集的场景中,惰性求值带来的效率提升会更显著,相信你已经明白其中的道理了:不需要的元素,就不会浪费计算资源去生成

顺带说一句,如果你觉得手动实现可迭代对象的过程过于繁琐,其实很多人都有同感。所以,我们终于可以聊生成器了 —— 这个特性的诞生,就是为了让这一切实现起来更简洁、更优雅。

用生成器简化协议实现

下面我们用生成器函数重写上面的可迭代对象,生成器函数会返回一个生成器对象

function* generateGospels() {
  yield "马太福音";
  yield "马可福音";
  yield "路加福音";
  yield "约翰福音";
}

这里有两个关键的语法:function*yield关键字。前者标记这是一个生成器函数;而yield关键字你可以理解为 “暂停键”—— 每当生成器被请求获取下一个值时,执行就会在yield处暂停。

生成器的底层,依然会调用next()方法。每次调用该方法,执行都会推进到下一个yield语句(如果还有的话)。

const generator = generateGospels();

console.log(generator.next()); // {value: '马太福音', done: false}

当然,生成器对象也能直接用for...of循环遍历,效果和预期一致:

for (const gospel of generateGospels()) {
  console.log(gospel);
}

// 马太福音
// 马可福音
// 路加福音
// 约翰福音

记住:可迭代对象(包括生成器)可以是无限的,所以你可能会在实际开发中看到这样的代码:

function* multipleGenerator(base) {
  let current = base;

  while (true) {
    yield current;
    current += base;
  }
}

这样的无限循环看起来很吓人,但并不会导致浏览器卡死。因为每次迭代之间都有yield语句,每当请求下一个值时,执行就会暂停,主线程就能继续处理其他任务。

const multiplier = multipleGenerator(22);

multiplier.next(); // {value: 22, done: false}
multiplier.next(); // {value: 44, done: false}
multiplier.next(); // {value: 66, done: false}

// … 并不会出现无限循环!

不过有一点需要注意:生成器的执行是同步的,所以依然有可能阻塞主线程。好在我们有办法避免这个问题,比如AsyncGenerator异步生成器)对象,就能帮我们解决这类问题。

我开始偏爱生成器的几个原因

生成器并非什么具有突破性的特性,我很难找到一个只能用它解决、而普通方法无法实现的问题。但随着使用次数的增多,我对它的好感也与日俱增。总结下来,主要有这几个原因:

减少代码的紧耦合

生成器(以及所有迭代器)的一大优势是高度的封装性,包括自身的状态管理。我越来越发现,这一特性能有效降低组件之间的耦合度 —— 而我以前总是下意识地让组件之间产生不必要的依赖。

举个场景:点击按钮时,需要按时间顺序展示某一价格过去五年里的移动平均值,从最早的时间段开始。我们每次只需要一个时间段的平均值,甚至可能用不到所有的结果(用户可能不会一直点击按钮)。用普通方法实现的代码大概是这样的:

// 全局作用域的状态变量,用于标记当前计算的起始位置
let windowStart = 0;

function calculateMovingAverage(values, windowSize) {
  // 截取当前窗口的数值
  const section = values.slice(windowStart, windowStart + windowSize);

  if (section.length < windowSize) return null;

  // 计算移动平均值
  return section.reduce((sum, val) => sum + val, 0) / windowSize;
}

loadButton.addEventListener("click", function () {
  const avg = calculateMovingAverage(prices, 5);
  average.innerHTML = `平均值:$${avg}`;
  // 事件监听器需要负责更新状态
  windowStart++;
});

每次点击按钮,页面就会渲染下一个平均值。但这种实现有个明显的问题:我们需要在高层作用域定义一个持久化的windowStart变量,而且让事件监听器负责更新状态,这让我很不舒服 —— 我希望监听器只专注于更新 UI。

除此之外,如果页面的其他地方也需要计算这个移动平均值,这种实现方式会让代码变得一团糟:各个逻辑相互交织,边界模糊,更谈不上可移植性。

而生成器能完美解决这些问题:

function* calculateMovingAverage(values, windowSize) {
  // 状态变量封装在生成器内部,仅在需要时暴露
  let windowStart = 0;

  while (windowStart <= values.length - 1) {
    const section = values.slice(windowStart, windowStart + windowSize);
    
    yield section.reduce((sum, val) => sum + val, 0) / windowSize;
    
    windowStart++;
  }
}

// 初始化生成器,传入参数
const generator = calculateMovingAverage(prices, 5);

loadButton.addEventListener("click", function () {
  // 监听器只需要请求值并更新UI,无需关心内部逻辑
  const { value } = generator.next();
  average.innerHTML = `平均值:$${value}`;
});

这样的实现有很多优点:

  • windowStart变量只在需要它的地方暴露,不会污染外部作用域;
  • 状态和逻辑自包含,我们可以同时创建多个独立的生成器实例,彼此互不影响;
  • 职责更单一:生成器负责计算和状态管理,点击监听器只负责更新 DOM,代码边界清晰。

我很喜欢这种编程模式,而且我们还能把它做得更极致。到目前为止,都是由点击监听器主动请求下一个值,直接依赖生成器的返回结果。但我们可以反过来,让生成器只负责生成就绪的值,监听器只负责消费这些值 —— 两者都无需知道对方的内部实现细节。

// 生成器控制迭代节奏,监听器只负责响应事件
for (const value of calculateMovingAverage(prices, 5)) {
  await new Promise((r) => {
    loadButton.addEventListener(
      "click",
      function () {
        average.innerHTML = `平均值:$${value}`;
        r();
      },
      { once: true } // 事件只触发一次,自动移除监听器
    );
  });
}

我猜你看到这段代码可能会感到诧异,甚至有点费解。这确实不是一种很自然的编程模式,但我很认可它的实现思路 ——控制反转。这段代码中,两个模块之间几乎没有任何依赖,彼此都不需要知道对方的实现细节。事件处理完成后,监听器会被自动清理,执行权交还给生成器。我觉得,鲍勃大叔(《代码整洁之道》作者)至少会认可这个设计思路(如果不认可,那就让他穿着浴袍吐槽我吧🤞)。

避开那些令人 “心烦” 的写法

我惊讶地发现,过去开发中很多不得不使用的繁琐写法,都能用生成器替代。比如递归、回调函数等等 —— 这些写法本身没有问题,但用起来总让人觉得不爽

其中一个典型场景就是循环执行的任务。比如,仪表盘需要每秒刷新一次应用的最新运行指标。这个需求可以拆分成两个职责:请求数据、渲染 UI。实现的方式有很多种:

方式 1:使用 setInterval

你可以选择经典的setInterval—— 它的设计初衷就是重复执行某个操作,看起来是最适合的选择:

// 一直重复执行!
function monitorVitals(cb) {
  setInterval(async () => {
    const vitals = await requestVitals();
    // 借助回调函数传递数据,更新UI
    cb(vitals);
  }, 1000);
}

// 传入回调函数处理UI更新
monitorVitals((vitals) => {
  console.log("更新UI...", vitals);
});

但这种写法也有两个让人不爽的点:为了分离 “请求数据” 和 “渲染 UI” 的职责,必须传递回调函数,这可能会让人想起 Promise 出现前 JavaScript 的 “回调地狱”;此外,setInterval不会关心数据请求的耗时,如果请求耗时超过 1 秒,就会出现数据返回顺序错乱的问题。

方式 2:使用 setTimeout + 递归

作为替代方案,你可能会用 Promise 封装setTimeout,再结合递归实现:

async function monitorVitals(cb) {
  const vitals = await requestVitals();
  cb(vitals);

  await new Promise((r) => {
    // 递归调用,实现循环执行
    setTimeout(() => monitorVitals(cb), 1000);
  });
}

monitorVitals((vitals) => {
  console.log("更新UI...", vitals);
});

这种写法能解决时序问题,但递归可能会让你产生心理阴影(毕竟很多人都踩过递归的坑),而且依然需要传递回调函数。

方式 3:使用无限 while 循环

还可以用无限while循环,结合异步代码实现:

async function monitorVitals(cb) {
  while (true) {
    await new Promise((r) => setTimeout(r, 1000));
    const vitals = await requestVitals();
    cb(vitals);
  }
}

monitorVitals((vitals) => {
  console.log("更新UI...", vitals);
});

这种写法没有了递归,但回调函数依然存在。

再次强调,上面这些写法本身都没有本质问题,只是用起来总觉得有那么点别扭。好在,我们还有另一种选择。

方式 4:使用异步生成器

我之前简单提过异步生成器,在生成器函数前加上async关键字,普通生成器就变成了异步生成器。这个写法看似理所当然,却有一个特殊的能力:异步生成器可以配合for await...of循环,遍历所有解析后的异步值。

async function* generateVitals() {
  while (true) {
    const result = await requestVitals();
    await new Promise((r) => setTimeout(r, 1000));
    // 生成异步结果
    yield result
  }
}

// 用for await...of循环遍历消费
for await (const vitals of generateVitals()) {
  console.log("更新UI...", vitals);
}

实现的效果和前面的方式一致,但却避开了那些让人不舒服的写法:没有时序问题、没有递归、没有回调函数,各个职责之间完美解耦,你只需要专注于处理序列本身即可。

让全量分页查询更高效

如果你做过分页接口的全量数据查询,大概率写过这样的代码:

async function fetchAllItems() {
  let currentPage = 1;
  let hasMore = true;
  let items = [];

  while (hasMore) {
    const data = await requestFromApi(currentPage);
    hasMore = data.hasMore;
    currentPage++;
    // 拼接每一页的结果,存入数组
    items = items.concat(data.items);
  }

  // 所有数据查询完成后,才返回结果
  return items;
}

看着这些辅助变量和数组拼接的逻辑,很少有人能觉得这种写法优雅。更重要的是,你必须等所有分页数据都查询完成,才能开始处理数据:

const allItems = await fetchAllItems();

// 必须等所有数据查询完成,这段代码才会执行
for (const item of items) {
  // 处理数据
}

从时间和内存效率来看,这都不是最优解。我们可以重构代码,让每查询一页数据就立即处理,但这样又会遇到前面提到的那些问题。

不妨试试用异步生成器实现:

async function* fetchAllItems() {
  let currentPage = 1;

  while (true) {
    const data = await requestFromApi(currentPage);
    // 没有更多数据时,直接终止
    if (!data.hasMore) return;

    currentPage++;
    // 每查询一页,就生成一页的数据,立即供外部处理
    yield data.items;
  }
}

// 边查询边处理,无需等待全量数据
for await (const items of fetchAllItems()) {
  // 处理当前页的数据
}

这种写法的辅助变量更少,能更快开始处理数据,而且各个职责依然保持解耦,效果非常不错。

便捷地按需生成批量元素

我在文章开头提过这个用法,它实在太好用了,我必须再夸一遍。因为生成器是可迭代对象,所以它能像数组一样被解构。如果你需要一个工具函数来批量生成任意元素,生成器会让这个过程变得无比简单:

function* getElements(tagName = 'div') {
  // 无限生成指定标签的DOM元素
  while (true) yield document.createElement(tagName);
}

现在,你可以随心所欲地解构生成器,获取任意数量的元素:

// 解构生成3个div元素
const [el1, el2, el3] = getElements('div');

客观来说,这个写法简直太优雅了。想了解这个技巧的更多细节,可以看看我之前写的完整文章。

未来可期

我还不确定自己对生成器的这份喜爱能持续多久(可能现在还处于 “蜜月期”)。

但即便这份热情明天就消退,我也很庆幸自己多了一项编程技能。掌握一个工具固然重要,但被迫重新思考自己的常规解题思路,收获会更大 —— 这绝对是一笔划算的投入。

无感监控:深度拆解监控 SDK 的性能平衡术与调度策略

对于正在自研监控系统的架构师来说,“无感监控”不仅是一个性能指标,更是一场对浏览器底层调度机制的深度极限利用

如果 SDK 导致用户页面出现 50ms 以上的 Long Task,或者因为上报请求过多导致业务接口排队(Connection Queueing),那监控系统本身就成了“最大的线上事故”。


一、 算力调度:别在主线程“虎口夺食”

浏览器主线程(Main Thread)是极其珍贵的资源。监控 SDK 涉及大量的 DOM 访问、对象序列化和字符串拼接,处理不好就会触发“卡顿(Jank)”。

1. 任务切片与 requestIdleCallback

监控脚本的初始化和历史数据扫描往往属于“非紧急任务”。

  • 底层机制:利用浏览器在每一帧渲染完成后的空闲时间(Idle Period)执行。
  • 进阶技巧:由于 requestIdleCallback 的优先级极低,在页面高频交互时可能永远不被触发。
  • 实战代码策略:设置一个 timeout(如 2000ms)。如果在 2 秒内主线程一直很忙,SDK 会强制在下一个事件循环中执行,平衡了“不阻塞”与“不丢失”。

2. 规避重排陷阱:静态属性抓取

很多 SDK 在捕获点击事件时,为了获取元素位置,会频繁调用 getBoundingClientRect()

  • 风险点:这类 API 会强制浏览器立即重新计算样式和布局(Reflow),导致主线程瞬间阻塞。
  • 优化方案:尽量使用 IntersectionObserver 异步监听元素可见性,或者直接通过 event 对象获取 clientX/Y 等预计算好的坐标,严禁在全局滚动事件中进行同步 DOM 测量。

二、 传输链路:打通“只发不接”的特权通道

在大规模数据上报时,网络请求的开销(建立连接、占用并发数)往往比计算开销更致命。

1. navigator.sendBeacon:浏览器的“离线快递”

这是无感监控的核心利器。

  • 非阻塞:它将数据交给浏览器管理的独立队列。即使你的页面逻辑已经开始处理复杂的动画,浏览器也会在后台悄悄把数据发出去。
  • 生存保障:在页面卸载(beforeunload/unload)时,普通的 XHR 或 Fetch 请求大概率会被截断,导致关键的延迟数据丢失。sendBeacon 能确保即使窗口关闭,数据也能安全到达服务器。

2. Fetch 的 keepalive 选项

如果你需要处理更复杂的响应(虽然监控通常不需要),可以给 fetch 设置 keepalive: true。它的作用类似于 sendBeacon,允许请求在页面销毁后继续在后台存活。


三、 内存管理:警惕监控 SDK 的“自增长”

监控系统需要监听全局的 PromiseConsoleNetwork。这些“劫持”行为极易产生长期持有的闭包。

1. 影子 DOM(Shadow DOM)隔离

如果你的 SDK 需要在页面上注入 UI(如录屏控制、错误弹窗),请务必使用 Shadow DOM

  • 价值:它可以防止 SDK 的样式污染业务页面,同时避免业务代码的 CSS 选择器误伤 SDK 元素,减少浏览器的样式重算(Recalculate Style)范围。

2. 对象池与缓冲区(Buffer)

  • 按需序列化:不要捕获整个 Error 对象,它包含极其复杂的原型链。只抽取 messagestack 和自定义上下文。
  • 弱引用利用:在一些需要暂存 DOM 节点的场景,使用 WeakMapWeakSet,确保当业务代码删除 DOM 后,SDK 不会成为阻碍 GC 回收的罪魁祸首。

四、 采样与降级:稳健策略

你应该明白“全量监控”在超大规模流量下是不可持续的。

1. 动态采样率(Sampling Rate)

  • 逻辑:针对 200 OK 的请求,采样率设为 1%;针对 5xx 错误或 Long Task,采样率设为 100%。
  • 实现:由后端下发控制指令,SDK 动态调整收集频率,实现“平时安静,出事警觉”。

2. 自我熔断机制

  • 监控 SDK 的监控:在 SDK 内部记录自身的执行耗时。
  • 熔断条件:如果 SDK 连续多次初始化耗时超过 100ms,或者本地队列堆积超过 1000 条,SDK 应当自动进入“休眠模式”,停止一切捕获,保护主业务不崩溃。

向 Native 借力:深度拆解 SIMD 加速与 Node.js 异步原生解析

当 Node.js 的内置 JSON.parse 成为系统吞吐量的瓶颈时,你已经触及了 V8 引擎的物理边界。

此时,代码优化的边际效应已经极低,真正的破局点在于**“降维打击” :利用 Node-API (N-API) 绕过 JavaScript 的单线程限制,直接调度 CPU 的 SIMD 指令集多线程并行能力**。


一、 V8 的天花板:为什么内置解析器跑不动了?

尽管 V8 引擎是工业界的巅峰之作,但它的 JSON.parse 在处理大规模监控原始数据(GB 级别)时,存在三个结构性缺陷:

  1. 单线程阻塞(Stop-the-world)

    由于 JS 是单线程的,执行 JSON.parse 时,V8 必须停止所有业务逻辑。解析 500MB 的 JSON 字符串通常需要数百毫秒,这在高性能网关中会导致灾难性的请求堆积。

  2. 非必要的中间表示(Double Allocation)

    V8 必须先将原始二进制 Buffer 转换为 UTF-16 字符串,然后再解析成 JS 对象图。这个过程涉及大量的内存分配和垃圾回收(GC)压力。

  3. 串行化解析逻辑

    传统的解析器是“标量”的,即一次只能读取并判断一个字符。它无法利用现代 CPU 一次处理多个数据块的并发特性。


二、 极致黑科技:SIMD 与 simdjson 的并行艺术

在 Native 领域,simdjson 的出现彻底重塑了 JSON 解析的性能标准。它的核心秘诀在于利用现代 CPU 的 SIMD(Single Instruction, Multiple Data,单指令多数据流)

1. 结构化索引(Stage 1:Rapid Identification)

传统的解析器在遇到逗号或冒号时需要进行分支判断。而 SIMD 允许 CPU 通过位掩码(Bitmask)一次性检查 64 个字节

  • 原理:它利用 _mm512_cmpeq_epi8 等指令,瞬间在内存中标记出所有语法关键符号({, }, [, ], :, ,)。
  • 收益:解析器在处理业务逻辑前,就已经拥有了一张完整的“地图”,跳过了 90% 的低效分支预测。

2. 异步并行架构(Stage 2:Multi-threading)

通过原生扩展,我们可以真正实现后台解析

  • 逻辑:Node.js 接收到日志 Buffer 后,仅传递一个内存地址给 C++/Rust 扩展。
  • 执行:原生插件在 Libuv 线程池中开启多个子线程并行处理。
  • 同步:主线程继续处理其他请求,待解析完成后,通过异步回调将结果返回给 JS。

三、 工程化落地:利用 napi-rs 构建原生利刃

作为 8 年资深开发,推荐使用 napi-rs。它比传统的 C++ node-gyp 更安全且更高效。

1. 零拷贝(Zero-copy)的终极优化

在监控场景中,我们往往只需要 JSON 中的某几个字段。传统的解析会把整个 JSON 变成巨大的 JS 对象。

  • 原生方案:在 Native 层解析后,不将其转换成 JS 对象,而是建立一个内存索引树
  • 按需读取:JS 层通过 Getter 函数访问属性。只有当 JS 真正访问某个字段时,才进行必要的转换。
  • 效果:对于 1GB 的日志,如果只读 10% 的字段,内存占用能从数 GB 降至数百 MB。

2. 线程安全的回调(Thread-safe Function)

在 Native 层完成繁重的解析后,如何安全地把数据塞回 JS 环境?

  • 机制:利用 napi_threadsafe_function。它能确保即使 Rust 在后台多线程并行,最终返回 JS 时的上下文也是线程安全的,避免了 Node.js 进程莫名崩溃(Segment Fault)。

四、成本与红线

在追求极致性能时,必须保持清醒的架构判断:

  1. 边界跨越开销:JS 调用 Native 是有成本的(Context Switch)。对于小于 50KB 的数据,JSON.parse 依然是最快的。只有在处理持续高频大容量数据时,Native 扩展才具有性价比。
  2. 内存生命周期管理:在 Native 层操作 Buffer 时,必须确保 JS 端的 Buffer 不会被 GC 回收。你需要手动使用 napi_ref 来锁定内存地址,否则会发生内存踩踏。
  3. SIMD 兼容性:不同的 CPU 支持不同的指令集(AVX2, AVX-512, NEON)。你的扩展必须具备动态指令集探测能力,否则在旧机器上会直接退出。

💡 结语

JSON 的优化已经聊到了底层硬件级别。如果你的监控系统依然面临压力,那么下一步就不是优化 JSON,而是更换协议

React 性能优化:图片懒加载

引言

在现代 Web 应用开发中,首屏加载速度(FCP)和最大内容绘制(LCP)是衡量用户体验的核心指标。随着富媒体内容的普及,图片资源往往占据了页面带宽的大部分。如果一次性加载页面上的所有图片,不仅会阻塞关键渲染路径,导致页面长时间处于“白屏”或不可交互状态,还会浪费用户的流量带宽。

图片懒加载(Lazy Loading)作为一种经典的性能优化策略,其核心思想是“按需加载”:即只有当图片出现在浏览器可视区域(Viewport)或即将进入可视区域时,才触发网络请求进行加载。这一策略能显著减少首屏 HTTP 请求数量,降低服务器压力,并提升页面的交互响应速度。

本文将基于 React 生态,从底层原理出发,深入探讨图片懒加载的多种实现方案,并重点分析如何解决布局偏移(CLS)等用户体验问题。

核心原理剖析

图片懒加载的本质是一个“可见性检测”问题。我们需要实时判断目标图片元素是否与浏览器的可视区域发生了交叉。在技术实现上,主要依赖以下两种依据:

  1. 几何计算:通过监听滚动事件,实时计算元素的位置坐标。核心公式通常涉及 scrollTop(滚动距离)、clientHeight(视口高度)与元素 offsetTop(偏移高度)的比较,或者使用 getBoundingClientRect() 获取元素相对于视口的位置。
  2. API 监测:利用浏览器提供的 IntersectionObserver API。这是一个异步观察目标元素与祖先元素或顶级文档视窗交叉状态的方法,它将复杂的几何计算交由浏览器底层处理,性能表现更优。

方案一:原生 HTML 属性(最简方案)

HTML5 标准为  标签引入了 loading 属性,这是实现懒加载最简单、成本最低的方式。

Jsx

const NativeLazyLoad = ({ src, alt }) => {
  return (
    <img 
      src={src} 
      alt={alt} 
      loading="lazy" 
      width="300" 
      height="200"
    />
  );
};

分析:

  • 优点:零 JavaScript 代码,完全依赖浏览器原生行为,不会阻塞主线程。

  • 缺点

    • 兼容性:虽然现代浏览器支持度已较高,但在部分旧版 Safari 或 IE 中无法工作。
    • 不可控:开发者无法精确控制加载的阈值(Threshold),也无法在加载失败或加载中注入自定义逻辑(如骨架屏切换)。
    • 功能单一:仅适用于 img 和 iframe 标签,无法用于 background-image。

方案二:传统 Scroll 事件监听(兼容方案)

在 IntersectionObserver 普及之前,监听 scroll 事件是主流做法。其原理是在 React 组件挂载后绑定滚动监听器,在回调中计算图片位置。

React 实现示例:

Jsx

import React, { useState, useEffect, useRef } from 'react';
import placeholder from './assets/placeholder.png';

// 简单的节流函数,生产环境建议使用 lodash.throttle
const throttle = (func, limit) => {
  let inThrottle;
  return function() {
    const args = arguments;
    const context = this;
    if (!inThrottle) {
      func.apply(context, args);
      inThrottle = true;
      setTimeout(() => inThrottle = false, limit);
    }
  }
};

const ScrollLazyImage = ({ src, alt }) => {
  const [imageSrc, setImageSrc] = useState(placeholder);
  const imgRef = useRef(null);
  const [isLoaded, setIsLoaded] = useState(false);

  useEffect(() => {
    const checkVisibility = () => {
      if (isLoaded || !imgRef.current) return;

      const rect = imgRef.current.getBoundingClientRect();
      const windowHeight = window.innerHeight || document.documentElement.clientHeight;

      // 设置 100px 的缓冲区,提前加载
      if (rect.top <= windowHeight + 100) {
        setImageSrc(src);
        setIsLoaded(true);
      }
    };

    // 必须使用节流,否则滚动时会频繁触发重排和重绘,导致性能灾难
    const throttledCheck = throttle(checkVisibility, 200);

    window.addEventListener('scroll', throttledCheck);
    window.addEventListener('resize', throttledCheck);
    
    // 初始化检查,防止首屏图片不加载
    checkVisibility();

    return () => {
      window.removeEventListener('scroll', throttledCheck);
      window.removeEventListener('resize', throttledCheck);
    };
  }, [src, isLoaded]);

  return <img ref={imgRef} src={imageSrc} alt={alt} />;
};

关键点分析:

  1. 节流(Throttle) :scroll 事件触发频率极高,若不加节流,每次滚动都会执行 DOM 查询和几何计算,占用大量主线程资源,导致页面掉帧。
  2. 回流(Reflow)风险:频繁调用 getBoundingClientRect() 或 offsetTop 会强制浏览器进行同步布局计算(Synchronous Layout),这是性能杀手。

方案三:IntersectionObserver API(现代标准方案)

这是目前最推荐的方案。IntersectionObserver 运行在独立线程中,不会阻塞主线程,且浏览器对其进行了内部优化。

React 实现示例:

我们可以将其封装为一个通用的组件 LazyImage。

Jsx

import React, { useState, useEffect, useRef } from 'react';
import './LazyImage.css'; // 假设包含样式

const LazyImage = ({ src, alt, placeholderSrc, width, height }) => {
  const [imageSrc, setImageSrc] = useState(placeholderSrc || '');
  const [isVisible, setIsVisible] = useState(false);
  const imgRef = useRef(null);

  useEffect(() => {
    let observer;
    
    if (imgRef.current) {
      observer = new IntersectionObserver((entries) => {
        const entry = entries[0];
        // 当元素进入视口
        if (entry.isIntersecting) {
          setImageSrc(src);
          setIsVisible(true);
          // 关键:图片加载触发后,立即停止观察,释放资源
          observer.unobserve(imgRef.current);
          observer.disconnect();
        }
      }, {
        rootMargin: '100px', // 提前 100px 加载
        threshold: 0.01
      });

      observer.observe(imgRef.current);
    }

    // 组件卸载时的清理逻辑
    return () => {
      if (observer) {
        observer.disconnect();
      }
    };
  }, [src]);

  return (
    <img
      ref={imgRef}
      src={imageSrc}
      alt={alt}
      width={width}
      height={height}
      className={`lazy-image ${isVisible ? 'loaded' : ''}`}
    />
  );
};

export default LazyImage;

优势分析:

  • 高性能:异步检测,无回流风险。
  • 资源管理:通过 unobserve 和 disconnect 及时释放观察者,避免内存泄漏。
  • 灵活性:rootMargin 允许我们轻松设置预加载缓冲区。

进阶:用户体验与 CLS 优化

仅仅实现“懒加载”是不够的。在工程实践中,如果处理不当,懒加载会导致严重的累积布局偏移(CLS, Cumulative Layout Shift) 。即图片加载前高度为 0,加载后撑开高度,导致页面内容跳动。这不仅体验极差,也是 Google Core Web Vitals 的扣分项。

1. 预留空间(Aspect Ratio)

必须在图片加载前确立其占据的空间。现代 CSS 提供了 aspect-ratio 属性,配合宽度即可自动计算高度。

CSS

/* LazyImage.css */
.img-wrapper {
  width: 100%;
  /* 假设图片比例为 16:9,或者由后端返回具体宽高计算 */
  aspect-ratio: 16 / 9; 
  background-color: #f0f0f0; /* 骨架屏背景色 */
  overflow: hidden;
  position: relative;
}

.lazy-image {
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity 0.3s ease-in-out;
}

.lazy-image.loaded {
  opacity: 1;
}

2. 结合数据的完整 React 组件

结合后端返回的元数据(如宽高、主色调),我们可以构建一个体验极佳的懒加载组件。

Jsx

const AdvancedLazyImage = ({ data }) => {
  // data 结构示例: { url: '...', width: 800, height: 600, basicColor: '#a44a00' }
  const imgRef = useRef(null);
  const [isLoaded, setIsLoaded] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        const img = entry.target;
        // 使用 dataset 获取真实地址,或者直接操作 state
        img.src = img.dataset.src;
        
        img.onload = () => setIsLoaded(true);
        observer.unobserve(img);
      }
    });

    if (imgRef.current) observer.observe(imgRef.current);

    return () => observer.disconnect();
  }, []);

  return (
    <div 
      className="img-container"
      style={{
        // 核心:使用 aspect-ratio 防止 CLS
        aspectRatio: `${data.width} / ${data.height}`,
        // 核心:使用图片主色调作为占位背景,提供渐进式体验
        backgroundColor: data.basicColor 
      }}
    >
      <img
        ref={imgRef}
        data-src={data.url} // 暂存真实地址
        alt="Lazy load content"
        style={{
          opacity: isLoaded ? 1 : 0,
          transition: 'opacity 0.5s ease'
        }}
      />
    </div>
  );
};

方案对比与场景选择

方案 实现难度 性能 兼容性 适用场景
原生属性 (loading="lazy") 中 (现代浏览器) 简单的 CMS 内容页、对交互要求不高的场景。
Scroll 监听 低 (需节流) 高 (全兼容) 必须兼容 IE 等老旧浏览器,或有特殊的滚动容器逻辑。
IntersectionObserver 极高 高 (需 Polyfill) 现代 Web 应用、无限滚动列表、对性能和体验有高要求的场景。

结语

图片懒加载是前端性能优化的基石之一。从早期的 Scroll 事件监听,到如今标准化的 IntersectionObserver API,再到原生 HTML 属性的支持,技术在不断演进。

在 React 项目中落地懒加载时,我们不能仅满足于“功能实现”。作为架构师,更应关注性能损耗(如避免主线程阻塞)、资源管理(及时销毁 Observer)以及用户体验(防止 CLS、优雅的过渡动画)。通过合理利用 aspect-ratio 和占位策略,我们可以让懒加载不仅“快”,而且“稳”且“美”。

别让字体拖了后腿:FOIT/FOUT 深度解析与字体加载优化全攻略

🖋️ 别让字体拖了后腿:FOIT/FOUT 深度解析与字体加载优化全攻略

前端性能优化专栏 - 第九篇

在网页设计中,字体往往被视为“灵魂”。它不仅关乎品牌识别视觉统一,更能直接影响界面的质感专业感。好的字体能降低用户的认知成本,传递出产品想要营造的独特氛围。

然而,字体作为一种静态资源,必须经过下载和管理。如果处理不当,它就会变成性能的“累赘”,甚至引发一些让用户抓狂的“怪现象”。


⚠️ 字体加载中的两大“怪现象”

你是否遇到过这样的场景:页面打开了,但文字部分是一片空白,过了好几秒才突然蹦出来?或者文字先是以一种普通的系统字体显示,然后突然“闪”一下,变成了精美的设计字体?

这可不是浏览器的 Bug,而是浏览器默认的字体加载策略在作祟。

1. FOIT:不可见文本闪现 (Flash of Invisible Text)

这是 Chrome 等现代浏览器的典型行为。当自定义字体还没下载完时,浏览器会选择完全不渲染文本

  • 用户体验:网速慢时,用户看到的是大片空白,甚至会误以为页面挂了。
  • 本质:先保证样式统一,再显示内容。
  • 优点:避免了字体闪变,视觉风格高度一致。
  • 缺点:对弱网用户极其不友好,内容被阻塞。

2. FOUT:无样式文本闪现 (Flash of Unstyled Text)

这是 IE 等浏览器的传统行为。当自定义字体未加载完时,浏览器先用后备字体(系统自带字体)渲染。

  • 用户体验:页面一开始就能阅读,但字体样式会明显“跳”一下。
  • 本质:先保证内容可见,再追求样式正确。
  • 优点:文字优先可见,对内容型页面(如博客、资讯)非常友好。
  • 缺点:字体闪变会瞬间破坏设计感。

FOIT 与 FOUT 现象对比图


✨ 浏览器如何做权衡?

字体加载本质上是一个异步网络请求。在等待期间,浏览器必须决定:

  1. 是否渲染文本?
  2. 用什么字体渲染?
  3. 多久之后放弃等待?

为了不让浏览器“瞎猜”,我们需要一种方式显式地告诉它:“在这个项目里,你应该如何平衡内容、样式和性能。”


🔧 终极武器:font-display

@font-face 中使用 font-display 属性,可以精准控制字体在不同加载阶段的渲染策略。

@font-face {
  font-family: 'My Custom Font';
  src: url(/fonts/my-font.woff2) format('woff2');
  font-display: swap; /* 关键控制位 */
}

font-display 四种策略示意图

✅ 1. font-display: swap (强烈推荐)

这是目前最主流、最被推荐的策略。

  • 行为:立即使用后备字体渲染,等自定义字体准备好后再替换。
  • 特点:主动选择 FOUT,几乎没有文本阻塞时间。
  • 适用场景:新闻站、博客、社区等以内容为主的页面。

🚫2. font-display: block

  • 行为:设置约 3 秒的阻塞期。期间文本不可见(FOIT),超时后才回退到后备字体。
  • 特点:偏向 FOIT,强调视觉一致性。
  • 适用场景:品牌 Logo、视觉主标题等字体是核心识别部分的区域。切记不要对全站正文使用!

⚖️3. font-display: fallback

  • 行为:设置极短的阻塞期(约 100ms)。如果字体没到,立即用后备字体,且通常不再替换。
  • 特点:折中方案。快则用好字体,慢则全程用旧字体,不折腾,不闪变
  • 适用场景:希望有自定义字体,但不愿为此牺牲太多性能,也不希望看到闪变的场景。

⚡4. font-display: optional

  • 行为:阻塞期极短,且赋予浏览器更大的决策权。网络差时可能直接不加载字体。
  • 特点:性能优先级最高,字体属于“锦上添花”。
  • 适用场景:装饰性字体、非核心模块的氛围字体。

💡 总结

字体优化不是简单的“全都要”,而是一场关于内容可见性视觉一致性的博弈。

  • 如果你追求内容第一,请毫不犹豫地选择 swap
  • 如果你追求品牌至上,可以在局部使用 block
  • 如果你想要稳定体验fallbackoptional 是你的好伙伴。

合理利用 font-display,让你的网页在保持美感的同时,也能拥有丝滑的加载体验!


下一篇预告: 页面加载完了,但一滚动就发现元素在“乱跳”?这种让人头大的现象叫布局抖动(Layout Thrashing) 。下一篇我们将深入探讨如何识别并优化布局抖动,让你的页面稳如泰山!敬请期待!

❌