普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月19日技术

每日一题-计数二进制子串🟢

2026年2月19日 00:00

给定一个字符串 s,统计并返回具有相同数量 01 的非空(连续)子字符串的数量,并且这些子字符串中的所有 0 和所有 1 都是成组连续的。

重复出现(不同位置)的子串也要统计它们出现的次数。

 

示例 1:

输入:s = "00110011"
输出:6
解释:6 个子串满足具有相同数量的连续 1 和 0 :"0011"、"01"、"1100"、"10"、"0011" 和 "01" 。
注意,一些重复出现的子串(不同位置)要统计它们出现的次数。
另外,"00110011" 不是有效的子串,因为所有的 0(还有 1 )没有组合在一起。

示例 2:

输入:s = "10101"
输出:4
解释:有 4 个子串:"10"、"01"、"10"、"01" ,具有相同数量的连续 1 和 0 。

 

提示:

  • 1 <= s.length <= 105
  • s[i]'0''1'

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

2026年2月18日 23:53

进度(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]
}

一次遍历,简洁写法(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2026年2月7日 09:22

题意:子串必须形如 $\underbrace{\texttt{0}\cdots \texttt{0}}{k\ 个\ \texttt{0}}\underbrace{\texttt{1}\cdots \texttt{1}}{k\ 个\ \texttt{1}}$ 或者 $\underbrace{\texttt{1}\cdots \texttt{1}}{k\ 个\ \texttt{1}}\underbrace{\texttt{0}\cdots \texttt{0}}{k\ 个\ \texttt{0}}$。只能有一段 $\texttt{0}$ 和一段 $\texttt{1}$,不能是 $\texttt{00111}$(两段长度不等)或者 $\texttt{010}$(超过两段)等。

例如 $s = \texttt{001110000}$,按照连续相同字符,分成三组 $\texttt{00},\texttt{111},\texttt{0000}$。

  • 在前两组中,我们可以得到 $2$ 个合法子串:$\texttt{0011}$ 和 $\texttt{01}$。
  • 在后两组中,我们可以得到 $3$ 个合法子串:$\texttt{111000}$、$\texttt{1100}$ 和 $\texttt{10}$。

一般地,遍历 $s$,按照连续相同字符分组,计算每一组的长度。设当前这组的长度为 $\textit{cur}$,上一组的长度为 $\textit{pre}$,那么当前这组和上一组,能得到 $\min(\textit{pre},\textit{cur})$ 个合法子串,加到答案中。

###py

class Solution:
    def countBinarySubstrings(self, s: str) -> int:
        n = len(s)
        pre = cur = ans = 0
        for i in range(n):
            cur += 1
            if i == n - 1 or s[i] != s[i + 1]:
                # 遍历到了这一组的末尾
                ans += min(pre, cur)
                pre = cur
                cur = 0
        return ans

###java

class Solution {
    public int countBinarySubstrings(String S) {
        char[] s = S.toCharArray();
        int n = s.length;
        int pre = 0;
        int cur = 0;
        int ans = 0;
        for (int i = 0; i < n; i++) {
            cur++;
            if (i == n - 1 || s[i] != s[i + 1]) {
                // 遍历到了这一组的末尾
                ans += Math.min(pre, cur);
                pre = cur;
                cur = 0;
            }
        }
        return ans;
    }
}

###cpp

class Solution {
public:
    int countBinarySubstrings(string s) {
        int n = s.size();
        int pre = 0, cur = 0, ans = 0;
        for (int i = 0; i < n; i++) {
            cur++;
            if (i == n - 1 || s[i] != s[i + 1]) {
                // 遍历到了这一组的末尾
                ans += min(pre, cur);
                pre = cur;
                cur = 0;
            }
        }
        return ans;
    }
};

###c

#define MIN(a, b) ((b) < (a) ? (b) : (a))

int countBinarySubstrings(char* s) {
    int pre = 0, cur = 0, ans = 0;
    for (int i = 0; s[i]; i++) {
        cur++;
        if (s[i] != s[i + 1]) {
            // 遍历到了这一组的末尾
            ans += MIN(pre, cur);
            pre = cur;
            cur = 0;
        }
    }
    return ans;
}

###go

func countBinarySubstrings(s string) (ans int) {
n := len(s)
pre, cur := 0, 0
for i := range n {
cur++
if i == n-1 || s[i] != s[i+1] {
// 遍历到了这一组的末尾
ans += min(pre, cur)
pre = cur
cur = 0
}
}
return
}

###js

var countBinarySubstrings = function(s) {
    const n = s.length;
    let pre = 0, cur = 0, ans = 0;
    for (let i = 0; i < n; i++) {
        cur++;
        if (i === n - 1 || s[i] !== s[i + 1]) {
            // 遍历到了这一组的末尾
            ans += Math.min(pre, cur);
            pre = cur;
            cur = 0;
        }
    }
    return ans;
};

###rust

impl Solution {
    pub fn count_binary_substrings(s: String) -> i32 {
        let s = s.as_bytes();
        let n = s.len();
        let mut pre = 0;
        let mut cur = 0;
        let mut ans = 0;
        for i in 0..n {
            cur += 1;
            if i == n - 1 || s[i] != s[i + 1] {
                // 遍历到了这一组的末尾
                ans += pre.min(cur);
                pre = cur;
                cur = 0;
            }
        }
        ans
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $s$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。

专题训练

见下面双指针题单的「六、分组循环」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

【计数二进制子串】计数

作者 ikaruga
2020年8月10日 10:00

思路

  1. 对于 000111 来说,符合要求的子串是 000111 0011 01
    1. 不难发现,如果我们找到一段类似 000111 的数据,就可以用来统计答案
    2. 即 这样前面是连续 0/1 后面是连续 1/0 的数据
    3. 这一段的所有 3 个子串,取决于前面 0/1 的个数和后面 1/0 的个数
    4. min(cnt_pre, cnt_cur)

图片.png

  1. 遍历时,当数字再一次改变时(或到达结尾时),意味着一段结束,并能得到这一段前面和后面数字的个数。
    1. 11101 来说,当我们遍历到最后的 1 时,1110 就是一段可以用来统计答案的数据
    2. 而末尾的 01 则是另一段可以用来统计答案的数据

<图片.png,图片.png>

  1. 小技巧,对字符串结尾增加一个字符,可以将判断逻辑写在一个地方

答题

class Solution {
public:
    int countBinarySubstrings(string s) {
        int ans = 0;
        char last = '-';
        int cnt_pre = 0;
        int cnt_cur = 0;

        s += '-';
        for (auto c : s) {
            if (last != c) {
                last = c;
                ans += min(cnt_pre, cnt_cur);
                cnt_pre = cnt_cur;
                cnt_cur = 0;
            }
            cnt_cur++;
        }
        return ans;
    }
};

致谢

感谢您的观看,如果感觉还不错就点个赞吧,关注我的 力扣个人主页 ,欢迎热烈的交流!

计数二进制子串

2020年8月9日 21:31

方法一:按字符分组

思路与算法

我们可以将字符串 $s$ 按照 $0$ 和 $1$ 的连续段分组,存在 $\textit{counts}$ 数组中,例如 $s = 00111011$,可以得到这样的 $\textit{counts}$ 数组:$\textit{counts} = {2, 3, 1, 2}$。

这里 $\textit{counts}$ 数组中两个相邻的数一定代表的是两种不同的字符。假设 $\textit{counts}$ 数组中两个相邻的数字为 $u$ 或者 $v$,它们对应着 $u$ 个 $0$ 和 $v$ 个 $1$,或者 $u$ 个 $1$ 和 $v$ 个 $0$。它们能组成的满足条件的子串数目为 $\min { u, v }$,即一对相邻的数字对答案的贡献。

我们只要遍历所有相邻的数对,求它们的贡献总和,即可得到答案。

不难得到这样的实现:

###C++

class Solution {
public:
    int countBinarySubstrings(string s) {
        vector<int> counts;
        int ptr = 0, n = s.size();
        while (ptr < n) {
            char c = s[ptr];
            int count = 0;
            while (ptr < n && s[ptr] == c) {
                ++ptr;
                ++count;
            }
            counts.push_back(count);
        }
        int ans = 0;
        for (int i = 1; i < counts.size(); ++i) {
            ans += min(counts[i], counts[i - 1]);
        }
        return ans;
    }
};

###Java

class Solution {
    public int countBinarySubstrings(String s) {
        List<Integer> counts = new ArrayList<Integer>();
        int ptr = 0, n = s.length();
        while (ptr < n) {
            char c = s.charAt(ptr);
            int count = 0;
            while (ptr < n && s.charAt(ptr) == c) {
                ++ptr;
                ++count;
            }
            counts.add(count);
        }
        int ans = 0;
        for (int i = 1; i < counts.size(); ++i) {
            ans += Math.min(counts.get(i), counts.get(i - 1));
        }
        return ans;
    }
}

###JavaScript

var countBinarySubstrings = function(s) {
    const counts = [];
    let ptr = 0, n = s.length;
    while (ptr < n) {
        const c = s.charAt(ptr);
        let count = 0;
        while (ptr < n && s.charAt(ptr) === c) {
            ++ptr;
            ++count;
        }
        counts.push(count);
    }
    let ans = 0;
    for (let i = 1; i < counts.length; ++i) {
        ans += Math.min(counts[i], counts[i - 1]);
    }
    return ans;
};

###Go

func countBinarySubstrings(s string) int {
    counts := []int{}
    ptr, n := 0, len(s)
    for ptr < n {
        c := s[ptr]
        count := 0
        for ptr < n && s[ptr] == c {
            ptr++
            count++
        }
        counts = append(counts, count)
    }
    ans := 0
    for i := 1; i < len(counts); i++ {
        ans += min(counts[i], counts[i-1])
    }
    return ans
}

###C

int countBinarySubstrings(char* s) {
    int n = strlen(s);
    int counts[n], counts_len = 0;
    memset(counts, 0, sizeof(counts));
    int ptr = 0;
    while (ptr < n) {
        char c = s[ptr];
        int count = 0;
        while (ptr < n && s[ptr] == c) {
            ++ptr;
            ++count;
        }
        counts[counts_len++] = count;
    }
    int ans = 0;
    for (int i = 1; i < counts_len; ++i) {
        ans += fmin(counts[i], counts[i - 1]);
    }
    return ans;
}

###Python

class Solution:
    def countBinarySubstrings(self, s: str) -> int:
        counts = []
        ptr, n = 0, len(s)
        
        while ptr < n:
            c = s[ptr]
            count = 0
            while ptr < n and s[ptr] == c:
                ptr += 1
                count += 1
            counts.append(count)
        
        ans = 0
        for i in range(1, len(counts)):
            ans += min(counts[i], counts[i - 1])
        
        return ans

###C#

public class Solution {
    public int CountBinarySubstrings(string s) {
        List<int> counts = new List<int>();
        int ptr = 0, n = s.Length;
        
        while (ptr < n) {
            char c = s[ptr];
            int count = 0;
            while (ptr < n && s[ptr] == c) {
                ptr++;
                count++;
            }
            counts.Add(count);
        }
        
        int ans = 0;
        for (int i = 1; i < counts.Count; i++) {
            ans += Math.Min(counts[i], counts[i - 1]);
        }
        
        return ans;
    }
}

###TypeScript

function countBinarySubstrings(s: string): number {
    const counts: number[] = [];
    let ptr = 0, n = s.length;
    
    while (ptr < n) {
        const c = s[ptr];
        let count = 0;
        while (ptr < n && s[ptr] === c) {
            ptr++;
            count++;
        }
        counts.push(count);
    }
    
    let ans = 0;
    for (let i = 1; i < counts.length; i++) {
        ans += Math.min(counts[i], counts[i - 1]);
    }
    
    return ans;
}

###Rust

impl Solution {
    pub fn count_binary_substrings(s: String) -> i32 {
        let mut counts = Vec::new();
        let bytes = s.as_bytes();
        let n = bytes.len();
        let mut ptr = 0;
        
        while ptr < n {
            let c = bytes[ptr];
            let mut count = 0;
            while ptr < n && bytes[ptr] == c {
                ptr += 1;
                count += 1;
            }
            counts.push(count);
        }
        
        let mut ans = 0;
        for i in 1..counts.len() {
            ans += counts[i].min(counts[i - 1]);
        }
        
        ans
    }
}

这个实现的时间复杂度和空间复杂度都是 $O(n)$。

对于某一个位置 $i$,其实我们只关心 $i - 1$ 位置的 $\textit{counts}$ 值是多少,所以可以用一个 $\textit{last}$ 变量来维护当前位置的前一个位置,这样可以省去一个 $\textit{counts}$ 数组的空间。

代码

###C++

class Solution {
public:
    int countBinarySubstrings(string s) {
        int ptr = 0, n = s.size(), last = 0, ans = 0;
        while (ptr < n) {
            char c = s[ptr];
            int count = 0;
            while (ptr < n && s[ptr] == c) {
                ++ptr;
                ++count;
            }
            ans += min(count, last);
            last = count;
        }
        return ans;
    }
};

###Java

class Solution {
    public int countBinarySubstrings(String s) {
        int ptr = 0, n = s.length(), last = 0, ans = 0;
        while (ptr < n) {
            char c = s.charAt(ptr);
            int count = 0;
            while (ptr < n && s.charAt(ptr) == c) {
                ++ptr;
                ++count;
            }
            ans += Math.min(count, last);
            last = count;
        }
        return ans;
    }
}

###JavaScript

var countBinarySubstrings = function(s) {
    let ptr = 0, n = s.length, last = 0, ans = 0;
    while (ptr < n) {
        const c = s.charAt(ptr);
        let count = 0;
        while (ptr < n && s.charAt(ptr) === c) {
            ++ptr;
            ++count;
        }
        ans += Math.min(count, last);
        last = count;
    }
    return ans;
};

###Go

func countBinarySubstrings(s string) int {
    var ptr, last, ans int
    n := len(s)
    for ptr < n {
        c := s[ptr]
        count := 0
        for ptr < n && s[ptr] == c {
            ptr++
            count++
        }
        ans += min(count, last)
        last = count
    }

    return ans
}

###C

int countBinarySubstrings(char* s) {
    int ptr = 0, n = strlen(s), last = 0, ans = 0;
    while (ptr < n) {
        char c = s[ptr];
        int count = 0;
        while (ptr < n && s[ptr] == c) {
            ++ptr;
            ++count;
        }
        ans += fmin(count, last);
        last = count;
    }
    return ans;
}

###Python

class Solution:
    def countBinarySubstrings(self, s: str) -> int:
        ptr, n = 0, len(s)
        last, ans = 0, 0
        
        while ptr < n:
            c = s[ptr]
            count = 0
            while ptr < n and s[ptr] == c:
                ptr += 1
                count += 1
            ans += min(count, last)
            last = count
        
        return ans

###C#

public class Solution {
    public int CountBinarySubstrings(string s) {
        int ptr = 0, n = s.Length;
        int last = 0, ans = 0;
        
        while (ptr < n) {
            char c = s[ptr];
            int count = 0;
            while (ptr < n && s[ptr] == c) {
                ptr++;
                count++;
            }
            ans += Math.Min(count, last);
            last = count;
        }
        
        return ans;
    }
}

###TypeScript

function countBinarySubstrings(s: string): number {
    let ptr = 0, n = s.length;
    let last = 0, ans = 0;
    
    while (ptr < n) {
        const c = s[ptr];
        let count = 0;
        while (ptr < n && s[ptr] === c) {
            ptr++;
            count++;
        }
        ans += Math.min(count, last);
        last = count;
    }
    
    return ans;
}

###Rust

impl Solution {
    pub fn count_binary_substrings(s: String) -> i32 {
        let bytes = s.as_bytes();
        let n = bytes.len();
        let mut ptr = 0;
        let mut last = 0;
        let mut ans = 0;
        
        while ptr < n {
            let c = bytes[ptr];
            let mut count = 0;
            while ptr < n && bytes[ptr] == c {
                ptr += 1;
                count += 1;
            }
            ans += count.min(last);
            last = count;
        }
        
        ans
    }
}

复杂度分析

  • 时间复杂度:$O(n)$。
  • 空间复杂度:$O(1)$。
昨天 — 2026年2月18日技术

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

作者 NEXT06
2026年2月18日 21:22

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 黑名单 的组合拳,以在安全性、性能和扩展性之间取得最佳平衡。

MySQL/MariaDB Cheatsheet

Connect and Exit

Open a SQL session and disconnect safely.

Command Description
mysql -u root -p Connect as root (prompt for password)
mysql -u user -p -h 127.0.0.1 Connect to specific host
mysql -u user -p -P 3306 Connect on custom port
sudo mysql Login using Unix socket auth (common on Debian/Ubuntu)
exit Leave MySQL/MariaDB shell

Basic SQL Checks

Run quick checks after connecting.

SQL Description
SELECT VERSION(); Show server version
SHOW DATABASES; List all databases
USE db_name; Switch active database
SHOW TABLES; List tables in current database
SHOW GRANTS FOR 'user'@'host'; Show user privileges

Database Management

Create, inspect, and remove databases.

SQL Description
CREATE DATABASE db_name; Create a database
CREATE DATABASE db_name CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; Create DB with charset/collation
SHOW DATABASES LIKE 'db_name'; Check if DB exists
DROP DATABASE db_name; Delete a database
SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA; List schemas via information schema

User Management

Create users and manage authentication.

SQL Description
CREATE USER 'app'@'localhost' IDENTIFIED BY 'strong_password'; Create local user
CREATE USER 'app'@'%' IDENTIFIED BY 'strong_password'; Create remote-capable user
ALTER USER 'app'@'localhost' IDENTIFIED BY 'new_password'; Change password
DROP USER 'app'@'localhost'; Delete user
SELECT user, host FROM mysql.user; List users and hosts

Privileges

Grant, review, and revoke access.

SQL Description
GRANT ALL PRIVILEGES ON db_name.* TO 'app'@'localhost'; Grant full DB access
GRANT SELECT,INSERT,UPDATE,DELETE ON db_name.* TO 'app'@'localhost'; Grant specific privileges
REVOKE ALL PRIVILEGES ON db_name.* FROM 'app'@'localhost'; Remove DB privileges
SHOW GRANTS FOR 'app'@'localhost'; Review granted privileges
FLUSH PRIVILEGES; Reload grant tables

Backup and Restore

Use mysqldump for exports and imports.

Command Description
mysqldump -u root -p db_name > db_name.sql Backup one database
mysqldump -u root -p --databases db1 db2 > multi.sql Backup multiple databases
mysqldump -u root -p --all-databases > all.sql Backup all databases
mysql -u root -p db_name < db_name.sql Restore into existing database
gunzip < db_name.sql.gz | mysql -u root -p db_name Restore compressed SQL dump

Import and Export Data

Common import/export patterns.

Command Description
mysql -u root -p -e "SHOW DATABASES;" Run one-off SQL from shell
mysql -u root -p db_name -e "SOURCE /path/file.sql" Import SQL file via client
mysql -u root -p -Nse "SELECT NOW();" Non-interactive query output
mysqldump -u root -p db_name table_name > table.sql Export one table
mysql -u root -p db_name < table.sql Import one table dump

Service and Health Checks

Verify daemon state and open ports.

Command Description
systemctl status mysql Check MySQL service status
systemctl status mariadb Check MariaDB service status
sudo systemctl restart mysql Restart MySQL service
sudo systemctl restart mariadb Restart MariaDB service
ss -tulpn | grep 3306 Confirm server is listening

Common Troubleshooting

Quick checks for frequent connection and auth problems.

Issue Check
Access denied for user Verify user/host pair and password, then check SHOW GRANTS
Can't connect to local MySQL server through socket Check service status and socket path in config
Can't connect to MySQL server on 'host' Confirm host/port reachability and firewall rules
Unknown database Verify database name with SHOW DATABASES;
Restore fails on collation/charset Ensure server supports source collation and set utf8mb4 where needed

Related Guides

Use these articles for detailed MySQL/MariaDB workflows.

Guide Description
How to Manage MySQL Databases and Users from the Command Line End-to-end admin workflow
How to Create MySQL Users Accounts and Grant Privileges User and privilege setup
How to Back Up and Restore MySQL Databases with Mysqldump Backup and restore patterns
How to Show a List of All Databases in MySQL Database discovery commands
List (Show) Tables in a MySQL Database Table listing commands
How to Check the MySQL Version Version checks and verification

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

作者 SuperEugene
2026年2月18日 13:56

日常开发里,列表、表格、统计几乎都绕不开「对象数组」的排序和分组。本文不讲底层原理,只讲怎么选、为什么选、容易踩哪些坑。适合会写 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,你的电子学友。

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

UFW Cheatsheet

Basic Commands

Start with status and firewall state.

Command Description
ufw status Show firewall status and rules
ufw status verbose Show detailed status and defaults
sudo ufw enable Enable UFW
sudo ufw disable Disable UFW
sudo ufw reload Reload rules
sudo ufw reset Reset UFW to defaults

Default Policies

Set default inbound and outbound behavior.

Command Description
sudo ufw default deny incoming Deny all incoming by default
sudo ufw default allow outgoing Allow all outgoing by default
sudo ufw default deny outgoing Deny all outgoing by default
sudo ufw default allow incoming Allow all incoming (not recommended on servers)

Allow and Deny Rules

Allow or block traffic by port and protocol.

Command Description
sudo ufw allow 22 Allow port 22 (TCP and UDP)
sudo ufw allow 80/tcp Allow HTTP over TCP
sudo ufw allow 443/tcp Allow HTTPS over TCP
sudo ufw deny 25 Deny SMTP port 25
sudo ufw reject 23 Reject Telnet connections
sudo ufw limit 22/tcp Rate-limit SSH connections

Rule Management

List, delete, and clean specific rules.

Command Description
sudo ufw status numbered List rules with numbers
sudo ufw delete allow 80/tcp Delete matching rule
sudo ufw delete 3 Delete rule by number
sudo ufw delete deny 25 Delete a deny rule

IP-Based Rules

Allow or deny traffic from specific hosts and networks.

Command Description
sudo ufw allow from 203.0.113.10 Allow all traffic from one IP
sudo ufw deny from 203.0.113.10 Block all traffic from one IP
sudo ufw allow from 203.0.113.10 to any port 22 Allow SSH from one IP
sudo ufw allow from 10.0.0.0/24 to any port 3306 Allow MySQL from a subnet
sudo ufw deny from 198.51.100.0/24 to any port 22 proto tcp Deny TCP SSH from subnet

Application Profiles

Use service profiles from /etc/ufw/applications.d/.

Command Description
sudo ufw app list List available application profiles
sudo ufw app info "Nginx Full" Show ports/protocols for profile
sudo ufw allow "OpenSSH" Allow profile rules
sudo ufw deny "Nginx HTTP" Deny profile rules
sudo ufw delete allow "OpenSSH" Remove allowed profile

Logging

Control and inspect UFW logging.

Command Description
sudo ufw logging on Enable logging
sudo ufw logging off Disable logging
sudo ufw logging low Set low log level
sudo ufw logging medium Set medium log level
sudo ufw logging high Set high log level

Common Server Setup

Baseline rules for a web server.

Command Description
sudo ufw default deny incoming Deny incoming by default
sudo ufw default allow outgoing Allow outgoing by default
sudo ufw allow OpenSSH Keep SSH access
sudo ufw allow 80/tcp Allow HTTP
sudo ufw allow 443/tcp Allow HTTPS
sudo ufw enable Activate firewall
sudo ufw status verbose Verify active rules

Troubleshooting

Quick checks for common UFW issues.

Issue Check
SSH access lost after enable Ensure OpenSSH is allowed before ufw enable
Rule did not apply Run sudo ufw reload and re-check with ufw status numbered
Service still unreachable Confirm service is listening (ss -tulpn) and port/protocol match
Rules conflict Check order with ufw status numbered and delete/re-add as needed
UFW not active at boot Verify service state with systemctl status ufw

Related Guides

Use these guides for full UFW workflows.

Guide Description
How to Set Up a Firewall with UFW on Ubuntu 20.04 Full UFW setup on Ubuntu 20.04
How to Set Up a Firewall with UFW on Ubuntu 18.04 UFW setup on Ubuntu 18.04
How to Set Up a Firewall with UFW on Debian 10 UFW setup on Debian 10
How to List and Delete UFW Firewall Rules Rule management and cleanup

Flutter 为什么能运行在 HarmonyOS 上

作者 Bowen_Jin
2026年2月18日 13:42

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 图形系统

025444

四、输入事件处理

因为事件处理需要在渲染完成后(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组件开发中如何兼顾复用性、可维护性与性能优化?

作者 kknone
2026年2月18日 12:33

一、组件开发的基本原则

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>

八、参考链接

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

2026年2月18日 11:29

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

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

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

对比:普通函数 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的旅游足迹,可嵌入个人博客中

作者 AomanHao
2026年2月18日 10:45

一、足迹地图效果

制作最基础的旅行足迹地图,显示效果见下图,可以查看下面的 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主页,欢迎访问

我的知乎主页,欢迎访问

O(1) 做法原理讲解(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2026年2月18日 08:26

为了做到 $\mathcal{O}(1)$ 时间,我们需要快速判断所有相邻比特位是否都不同

如何判断不同?用哪个位运算最合适?

异或运算最合适。对于单个比特的异或,如果两个数不同,那么结果是 $1$;如果两个数相同,那么结果是 $0$。

如何对所有相邻比特位做异或运算?

例如 $n = 10101$,可以把 $n$ 右移一位,得到 $01010$,再与 $10101$ 做异或运算,计算的就是相邻比特位的异或值了。

如果异或结果全为 $1$,就说明所有相邻比特位都不同。

如何判断一个二进制数全为 $1$?

这相当于判断二进制数加一后,是否为 231. 2 的幂

设 $x$ 为 (n >> 1) ^ n,如果 (x + 1) & x 等于 $0$,那么说明 $x$ 全为 $1$。

class Solution:
    def hasAlternatingBits(self, n: int) -> bool:
        x = (n >> 1) ^ n
        return (x + 1) & x == 0
class Solution {
    public boolean hasAlternatingBits(int n) {
        int x = (n >> 1) ^ n;
        return ((x + 1) & x) == 0;
    }
}
class Solution {
public:
    bool hasAlternatingBits(int n) {
        uint32_t x = (n >> 1) ^ n;
        return ((x + 1) & x) == 0;
    }
};
bool hasAlternatingBits(int n) {
    uint32_t x = (n >> 1) ^ n;
    return ((x + 1) & x) == 0;
}
func hasAlternatingBits(n int) bool {
x := n>>1 ^ n
return (x+1)&x == 0
}
var hasAlternatingBits = function(n) {
    const x = (n >> 1) ^ n;
    return ((x + 1) & x) === 0;
};
impl Solution {
    pub fn has_alternating_bits(n: i32) -> bool {
        let x = (n >> 1) ^ n;
        (x + 1) & x == 0
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(1)$。
  • 空间复杂度:$\mathcal{O}(1)$。

专题训练

见下面位运算题单的「一、基础题」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

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

作者 SmalBox
2026年2月18日 09:17

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

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

作者 随逸177
2026年2月17日 21:53

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

在前端开发中,我们经常会遇到高频触发的事件——比如搜索框输入、页面滚动、按钮连续点击、窗口缩放等。如果不对这些事件进行处理,频繁执行回调函数(尤其是复杂任务如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 的常见的数组 和 字符串方法有哪些 ? ”

2026年2月17日 21:25

盘点 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. 相同的树:两种解法(递归+迭代)详解

作者 Wect
2026年2月17日 20:50

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. 对称二叉树,只是判断的是“自身的左孩子和右孩子”,逻辑高度相似)。

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

2026年2月17日 19:41

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

前言

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

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

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

第一章:理解数据结构

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. 实际应用场景:省市区联动、组织架构、权限菜单等

掌握了这个算法,你不仅能应对面试,在实际开发中也能游刃有余地处理各种树形结构数据。


如果这篇文章对你有帮助,欢迎点赞收藏,也欢迎在评论区提出你的问题!

❌
❌