阅读视图

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

Vue3 响应式系统核心执行器:Reactive Effect 与依赖调度机制


```js
 ┌─────────────────┐
 │ reactive.ts     │   ← 负责对象/数组/Ref 的响应式代理
 │ - track()   → 收集依赖
 │ - trigger() → 触发更新
 └─────────┬───────┘
           │ 调用
           ▼
 ┌─────────────────┐
 │ dep.ts          │   ← 管理单个依赖 Dep(存放订阅它的 effect)
 │ - Link 双向链表(Dep.deps 与 Effect.deps 双向绑定)
 │ - addSub()     → 添加 effect
 │ - removeSub()  → 移除 effect
 └─────────┬───────┘
           │ 调用
           ▼
 ┌───────────────────────────────────────────────┐
 │ effect.ts                                      │
 │   核心:ReactiveEffect 类,封装副作用函数逻辑     │
 │                                                │
 │ ReactiveEffect 类                               │
 │  - run()             → 执行副作用函数,触发依赖收集 │
 │  - trigger()         → 外部触发更新,决定是否重新运行 │
 │  - notify()          → 将 effect 加入批处理队列     │
 │  - stop()            → 停止 effect,解绑所有依赖     │
 │  - pause()/resume()  → 暂停/恢复依赖收集            │
 │                                                │
 │ 工具函数                                         │
 │  - prepareDeps()     → 标记当前依赖,等待收集       │
 │  - cleanupDeps()     → 清理失效的依赖关系          │
 │  - cleanupEffect()   → 执行注册的清理回调函数       │
 │  - isDirty()         → 判断 computed 是否过期       │
 │  - removeSub()       → 从 dep 移除 effect           │
 │  - removeDep()       → 从 effect 移除 dep           │
 │                                                │
 │ 调度机制                                         │
 │  - batch() / endBatch() → 批量执行 effect           │
 │  - runIfDirty()       → 脏检查后决定是否执行        │
 │                                                │
 │ 追踪控制                                         │
 │  - pauseTracking()    → 暂停依赖收集               │
 │  - enableTracking()   → 开启依赖收集               │
 │  - resetTracking()    → 恢复追踪栈状态             │
 │                                                │
 │ 外部接口                                         │
 │  - effect(fn)         → 创建并运行 ReactiveEffect   │
 │  - stop(runner)       → 停止某个 effect             │
 │  - onEffectCleanup(fn)→ 注册 effect 销毁时回调      │
 └─────────┬─────────────────────────────────────┘
           │ 关联
           ▼
 ┌──────────────────────────┐
 │ computed.ts              │ ← 基于 effect 实现的特殊 effect
 │ - ComputedRefImpl        → 使用 ReactiveEffect 封装 getter
 │ - refreshComputed()      → 调用 effect.run() 刷新缓存
 └──────────────────────────┘

```js
// -----------------------------
// Imports / Types
// -----------------------------

// EN: import helper utilities: `extend` to copy properties, `hasChanged` to compare values.
// CN: 导入工具函数:`extend` 用于合并/复制属性,`hasChanged` 用于比较值是否变化。
import { extend, hasChanged } from '@vue/shared'

// EN: import the ComputedRefImpl type to allow refreshComputed to access internals.
// CN: 导入 ComputedRefImpl 类型,使 refreshComputed 能访问 computed 的内部字段。
import type { ComputedRefImpl } from './computed'

// EN: import types for track/trigger op kinds (used in debugger event types).
// CN: 导入 track/trigger 操作类型,用于调试器事件类型定义。
import type { TrackOpTypes, TriggerOpTypes } from './constants'

// EN: Link type and globalVersion used by dep implementation.
// CN: Link 类型与 globalVersion,供 dep 实现使用。
import { type Link, globalVersion } from './dep'

// EN: activeEffectScope is used to register created effects to a scope for cleanup.
// CN: activeEffectScope 用于将创建的 effect 注册到当前的 effectScope,便于后续集中清理。
import { activeEffectScope } from './effectScope'

// EN: warn helper for dev-only warnings.
// CN: 开发环境使用的警告函数。
import { warn } from './warning'

// -----------------------------
// Type aliases & interfaces
// -----------------------------

// EN: Scheduler type for custom scheduling of effect runs.
// CN: effect 调度器类型,允许用户自定义何时执行 effect。
export type EffectScheduler = (...args: any[]) => any

// EN: Debugger event contains the effect (subscriber) plus extra info about the operation.
// CN: 调试事件,包含 effect(subscriber)以及关于操作的额外信息。
export type DebuggerEvent = { effect: Subscriber } & DebuggerEventExtraInfo

export type DebuggerEventExtraInfo = {
  target: object
  type: TrackOpTypes | TriggerOpTypes
  key: any
  newValue?: any
  oldValue?: any
  oldTarget?: Map<any, any> | Set<any>
}

// EN: Debugger hooks for track/trigger events.
// CN: 调试选项:在 track/trigger 时会调用的钩子。
export interface DebuggerOptions {
  onTrack?: (event: DebuggerEvent) => void
  onTrigger?: (event: DebuggerEvent) => void
}

// EN: Options for ReactiveEffect: scheduler, allowRecuse, onStop, plus debugger hooks.
// CN: ReactiveEffect 可配置项:scheduler、allowRecurse、onStop,以及调试钩子。
export interface ReactiveEffectOptions extends DebuggerOptions {
  scheduler?: EffectScheduler
  allowRecurse?: boolean
  onStop?: () => void
}

// EN: Runner function returned by `effect`, with a reference to its effect instance.
// CN: `effect` 返回的 runner 函数类型,同时包含 `.effect` 指向 ReactiveEffect 实例。
export interface ReactiveEffectRunner<T = any> {
  (): T
  effect: ReactiveEffect
}

// EN: The active subscriber (effect/computed) at runtime.
// CN: 当前处于活跃状态的订阅者(可能是 effect 或 computed)。
export let activeSub: Subscriber | undefined

// -----------------------------
// Effect Flags (bitmask) - compact state flags
// -----------------------------

// EN: Bit flags to track effect/computed state efficiently.
// CN: 使用位掩码表示 effect 的各种状态,便于快速判断与组合状态。
export enum EffectFlags {
  /** ReactiveEffect only */
  ACTIVE = 1 << 0,
  RUNNING = 1 << 1,
  TRACKING = 1 << 2,
  NOTIFIED = 1 << 3,
  DIRTY = 1 << 4,
  ALLOW_RECURSE = 1 << 5,
  PAUSED = 1 << 6,
  EVALUATED = 1 << 7,
}

// -----------------------------
// Subscriber interface (internal)
// -----------------------------

// EN: Subscriber represents anything that subscribes to deps (effects, computed).
// CN: Subscriber 表示订阅依赖的实体(例如 effect 或 computed)。
export interface Subscriber extends DebuggerOptions {
  /** Head of the doubly linked list representing the deps @internal */
  deps?: Link
  /** Tail of the same list @internal */
  depsTail?: Link
  /** @internal */
  flags: EffectFlags
  /** @internal */
  next?: Subscriber
  /** returning `true` indicates it's a computed that needs to call notify on its dep too @internal */
  notify(): true | void
}

// -----------------------------
// ReactiveEffect class
// -----------------------------

// EN: A set to collect effects that were paused and should be triggered once resumed.
// CN: 用于缓存处于 PAUSED 状态且等待恢复后触发的 effects 的集合(弱引用,方便 GC)。
const pausedQueueEffects = new WeakSet<ReactiveEffect>()

// EN: ReactiveEffect implements Subscriber and holds effect runtime data and methods.
// CN: ReactiveEffect 实现 Subscriber,保存运行时数据与方法(run/stop/trigger 等)。
export class ReactiveEffect<T = any> implements Subscriber, ReactiveEffectOptions {
  /** @internal */ deps?: Link = undefined
  /** @internal */ depsTail?: Link = undefined
  /** @internal */ flags: EffectFlags = EffectFlags.ACTIVE | EffectFlags.TRACKING
  /** @internal */ next?: Subscriber = undefined
  /** @internal */ cleanup?: () => void = undefined

  // EN: optional scheduler and lifecycle/debug hooks
  // CN: 可选的调度器与生命周期/调试钩子
  scheduler?: EffectScheduler = undefined
  onStop?: () => void
  onTrack?: (event: DebuggerEvent) => void
  onTrigger?: (event: DebuggerEvent) => void

  // EN: constructor registers effect to active effect scope if any, and stores fn.
  // CN: 构造函数:如存在 activeEffectScope,会将该 effect 注册进去;并保存传入的 fn。
  constructor(public fn: () => T) {
    if (activeEffectScope && activeEffectScope.active) {
      activeEffectScope.effects.push(this)
    }
  }

  // EN: Mark the effect as paused (it will not run immediately on trigger).
  // CN: 将 effect 标记为 PAUSED(触发时不会立即运行,改为放入 pausedQueueEffects 中)。
  pause(): void {
    this.flags |= EffectFlags.PAUSED
  }

  // EN: Resume the effect: if it was queued while paused, run it now.
  // CN: 恢复 effect;若此前因为暂停被加入 pausedQueueEffects,会立刻触发一次。
  resume(): void {
    if (this.flags & EffectFlags.PAUSED) {
      this.flags &= ~EffectFlags.PAUSED
      if (pausedQueueEffects.has(this)) {
        pausedQueueEffects.delete(this)
        this.trigger()
      }
    }
  }

  /** @internal */
  // EN: notify is called by deps to schedule this effect for execution.
  // CN: notify 由 dep 调用以安排 effect 的执行(通过批处理机制或 scheduler)。
  notify(): void {
    if (
      this.flags & EffectFlags.RUNNING &&
      !(this.flags & EffectFlags.ALLOW_RECURSE)
    ) {
      // EN: if currently running and recursion not allowed, skip.
      // CN: 若正在运行且不允许递归,则忽略这次通知,避免无限递归。
      return
    }
    if (!(this.flags & EffectFlags.NOTIFIED)) {
      // EN: mark as notified and add to batch queue
      // CN: 标记为 NOTIFIED 并加入批处理队列
      batch(this)
    }
  }

  // EN: Run the effect function immediately (collect deps). Returns the result of fn().
  // CN: 立即执行 effect 函数并收集依赖,返回 fn 的执行结果。
  run(): T {
    // TODO cleanupEffect

    // EN: if effect is not active (stopped), still run fn but without tracking.
    // CN: 若 effect 已停止(ACTIVE 标志清除),则直接运行 fn(不进行依赖收集)。
    if (!(this.flags & EffectFlags.ACTIVE)) {
      // stopped during cleanup
      return this.fn()
    }

    // EN: mark running, prepare and cleanup deps before/after run
    // CN: 标记为 RUNNING,清理上次遗留的 effect 外部状态,准备 deps 以便收集。
    this.flags |= EffectFlags.RUNNING
    cleanupEffect(this)
    prepareDeps(this)
    const prevEffect = activeSub
    const prevShouldTrack = shouldTrack
    activeSub = this
    shouldTrack = true

    try {
      return this.fn()
    } finally {
      // EN: restore active effect and tracking state; cleanup any deps marked unused.
      // CN: 恢复先前的 activeSub 与 shouldTrack,清理未被使用的 deps,并清除 RUNNING 标记。
      if (__DEV__ && activeSub !== this) {
        warn(
          'Active effect was not restored correctly - ' +
            'this is likely a Vue internal bug.',
        )
      }
      cleanupDeps(this)
      activeSub = prevEffect
      shouldTrack = prevShouldTrack
      this.flags &= ~EffectFlags.RUNNING
    }
  }

  // EN: stop the effect and remove it from all deps; call onStop hook.
  // CN: 停止 effect:从所有 dep 中移除自己,执行 cleanup 与 onStop 回调,并清除 ACTIVE 标志。
  stop(): void {
    if (this.flags & EffectFlags.ACTIVE) {
      for (let link = this.deps; link; link = link.nextDep) {
        removeSub(link)
      }
      this.deps = this.depsTail = undefined
      cleanupEffect(this)
      this.onStop && this.onStop()
      this.flags &= ~EffectFlags.ACTIVE
    }
  }

  // EN: when triggered, either queue (if paused), call scheduler, or runIfDirty.
  // CN: trigger 时的行为:若 PAUSED,则排队;若有 scheduler 用 scheduler;否则按脏检查执行。
  trigger(): void {
    if (this.flags & EffectFlags.PAUSED) {
      pausedQueueEffects.add(this)
    } else if (this.scheduler) {
      this.scheduler()
    } else {
      this.runIfDirty()
    }
  }

  /** @internal */
  // EN: run effect only if it's considered dirty by `isDirty`.
  // CN: 仅当 isDirty 返回 true(依赖发生变化或版本不同等)时调用 run。
  runIfDirty(): void {
    if (isDirty(this)) {
      this.run()
    }
  }

  // EN: query property to check if effect is dirty.
  // CN: 通过 isDirty 检查 effect 是否“脏”(需要重新执行)。
  get dirty(): boolean {
    return isDirty(this)
  }
}

// -----------------------------
// Batching and queue handling
// -----------------------------

// EN: depth of nested batches; batching defers triggers until matching endBatch.
// CN: 批处理深度计数;允许嵌套批处理,只有当最外层 endBatch 被调用时才真正触发队列中的 effects。
let batchDepth = 0
let batchedSub: Subscriber | undefined
let batchedComputed: Subscriber | undefined

// EN: Add a subscriber to the batch queue. Computed effects are queued separately.
// CN: 将 subscriber 加入批处理队列。computed 类型单独队列以保证更新顺序/语义。
export function batch(sub: Subscriber, isComputed = false): void {
  sub.flags |= EffectFlags.NOTIFIED
  if (isComputed) {
    sub.next = batchedComputed
    batchedComputed = sub
    return
  }
  sub.next = batchedSub
  batchedSub = sub
}

/** @internal */
// EN: Start a batch: increase nesting depth.
// CN: 开始一个批处理(深度 +1)。
export function startBatch(): void {
  batchDepth++
}

/** Run batched effects when all batches have ended @internal */
// EN: End batch: if outermost, flush queued computed first (clear notified) then normal effects.
// CN: 结束批处理:若为最外层,则先处理 batchedComputed(仅清理状态),再处理 batchedSub 并触发正常 effects。
export function endBatch(): void {
  if (--batchDepth > 0) {
    return
  }

  if (batchedComputed) {
    let e: Subscriber | undefined = batchedComputed
    batchedComputed = undefined
    while (e) {
      const next: Subscriber | undefined = e.next
      e.next = undefined
      e.flags &= ~EffectFlags.NOTIFIED
      e = next
    }
  }

  let error: unknown
  while (batchedSub) {
    let e: Subscriber | undefined = batchedSub
    batchedSub = undefined
    while (e) {
      const next: Subscriber | undefined = e.next
      e.next = undefined
      e.flags &= ~EffectFlags.NOTIFIED
      if (e.flags & EffectFlags.ACTIVE) {
        try {
          // ACTIVE flag is effect-only
          ;(e as ReactiveEffect).trigger()
        } catch (err) {
          if (!error) error = err
        }
      }
      e = next
    }
  }

  if (error) throw error
}

// -----------------------------
// Dep tracking helpers
// -----------------------------

// EN: prepareDeps marks existing links with version -1 and saves previous activeLink
//     so that after running the effect we can detect which deps were not re-used.
// CN: prepareDeps 将现有依赖链(link)的 version 标为 -1,并保存原先的 activeLink。
//     运行后通过版本判断哪些依赖未被重新使用(用于移除过时订阅)。
function prepareDeps(sub: Subscriber) {
  // Prepare deps for tracking, starting from the head
  for (let link = sub.deps; link; link = link.nextDep) {
    // set all previous deps' (if any) version to -1 so that we can track
    // which ones are unused after the run
    // 将之前的每个 link 的 version 设为 -1,以便在执行后识别未使用的 dep
    link.version = -1
    // store previous active sub if link was being used in another context
    // 保存之前 link.dep 的 activeLink(如果该 link 被其他 subscriber 使用)
    link.prevActiveLink = link.dep.activeLink
    // 将 dep 的 activeLink 指向当前 link,以标识当前 link 为激活状态
    link.dep.activeLink = link
  }
}

// EN: cleanupDeps traverses from tail to head and removes links whose version remained -1
//     (i.e., not used in latest run). Also restores dep.activeLink to previous.
// CN: cleanupDeps 从尾向头遍历;对于仍为 -1 的 link(未在此次运行中使用),从 dep 的订阅列表移除。
//     同时恢复 dep.activeLink 为之前保存的值。
function cleanupDeps(sub: Subscriber) {
  // Cleanup unsued deps
  let head
  let tail = sub.depsTail
  let link = tail
  while (link) {
    const prev = link.prevDep
    if (link.version === -1) {
      if (link === tail) tail = prev
      // unused - remove it from the dep's subscribing effect list
      // 未使用:从 dep 的订阅者列表中移除
      removeSub(link)
      // also remove it from this effect's dep list
      // 也从当前 effect 的 dep 双链表中移除该 link
      removeDep(link)
    } else {
      // The new head is the last node seen which wasn't removed
      // 新的 head 为最后一个未被移除的节点(从尾向前)
      head = link
    }

    // restore previous active link if any
    // 恢复 link.dep 的 activeLink 为保存的 prevActiveLink
    link.dep.activeLink = link.prevActiveLink
    link.prevActiveLink = undefined
    link = prev
  }
  // set the new head & tail
  // 更新 effect 的 deps head / tail 引用
  sub.deps = head
  sub.depsTail = tail
}

// EN: isDirty determines if a subscriber should re-run by checking deps' version or computed status.
// CN: isDirty 检查 subscriber 是否“脏”:遍历其 deps,若 dep.version 与 link.version 不匹配,或者对应的 computed 需要刷新则为脏。
function isDirty(sub: Subscriber): boolean {
  for (let link = sub.deps; link; link = link.nextDep) {
    if (
      link.dep.version !== link.version ||
      (link.dep.computed &&
        (refreshComputed(link.dep.computed) ||
          link.dep.version !== link.version))
    ) {
      return true
    }
  }
  // @ts-expect-error only for backwards compatibility where libs manually set
  // this flag - e.g. Pinia's testing module
  // EN: backward-compat check for libraries that manually set `_dirty`.
  // CN: 向后兼容:某些库(如 Pinia 测试模块)可能手动设置 `_dirty`。
  if (sub._dirty) {
    return true
  }
  return false
}

// -----------------------------
// Computed refresh logic
// -----------------------------

/**
 * Returning false indicates the refresh failed
 * @internal
 */
export function refreshComputed(computed: ComputedRefImpl): undefined {
  // EN: If computed is tracking and not marked dirty, no need to refresh.
  // CN: 若 computed 正在 TRACKING 且未标记为 DIRTY,则无需刷新。
  if (
    computed.flags & EffectFlags.TRACKING &&
    !(computed.flags & EffectFlags.DIRTY)
  ) {
    return
  }
  // EN: clear DIRTY flag since we are about to recompute/validate.
  // CN: 清除 DIRTY 标志(准备刷新或验证)。
  computed.flags &= ~EffectFlags.DIRTY

  // EN: fast path: if no global reactive change (globalVersion unchanged), skip.
  // CN: 全局版本快速路径:若 computed.globalVersion 与全局 globalVersion 相同,说明自上次计算后没有全局 reactive 变更,直接跳过。
  if (computed.globalVersion === globalVersion) {
    return
  }
  computed.globalVersion = globalVersion

  // EN: SSR / no-subscriber / already evaluated handling:
  //    If there's no SSR and computed was evaluated before and either has no deps
  //    or isn't dirty, we can skip re-evaluation.
  // CN: SSR 情况(服务端渲染没有 render effect)、无 deps 或已经评估且不脏时可以跳过重新评估。
  if (
    !computed.isSSR &&
    computed.flags & EffectFlags.EVALUATED &&
    ((!computed.deps && !(computed as any)._dirty) || !isDirty(computed))
  ) {
    return
  }

  // EN: mark as running and set up activeSub/shouldTrack to collect deps while evaluating.
  // CN: 将 computed 标记为 RUNNING,并设置 activeSub、shouldTrack 以便在求值时收集依赖。
  computed.flags |= EffectFlags.RUNNING

  const dep = computed.dep
  const prevSub = activeSub
  const prevShouldTrack = shouldTrack
  activeSub = computed
  shouldTrack = true

  try {
    // EN: evaluate computed.fn with previous value (some computed accept oldValue).
    // CN: 执行 computed.fn,可带入旧值作为参数(实现细节依赖具体 computed 实现)。
    prepareDeps(computed)
    const value = computed.fn(computed._value)
    // EN: if first time (dep.version===0) or value changed, set EVALUATED and update _value.
    // CN: 若是首次或值发生变化,设置 EVALUATED,将新值赋给 _value,并增加 dep.version。
    if (dep.version === 0 || hasChanged(value, computed._value)) {
      computed.flags |= EffectFlags.EVALUATED
      computed._value = value
      dep.version++
    }
  } catch (err) {
    // EN: ensure version increments on error to avoid silent stale caches.
    // CN: 出错时也增加 dep.version,避免缓存停滞导致数据不更新。
    dep.version++
    throw err
  } finally {
    // EN: restore active effect and tracking state, cleanup deps, clear RUNNING flag.
    // CN: 恢复 activeSub 与 shouldTrack,清理 deps,并清除 RUNNING 标志。
    activeSub = prevSub
    shouldTrack = prevShouldTrack
    cleanupDeps(computed)
    computed.flags &= ~EffectFlags.RUNNING
  }
}

// -----------------------------
// Helpers to remove subscriptions / deps
// -----------------------------

// EN: removeSub unlinks a subscription link from both dep and the subscriber.
//     `soft` indicates not to decrement sc or remove entry from dep.map (used when computed unsubscribes soft).
// CN: removeSub 将 link 从 dep 的订阅链表和 subscriber 的链表中解开。
//     soft 参数用于 computed 的“软取消订阅”(不减少 dep.sc,也不从 dep.map 删除),以便 computed 能被 GC。
function removeSub(link: Link, soft = false) {
  const { dep, prevSub, nextSub } = link
  if (prevSub) {
    prevSub.nextSub = nextSub
    link.prevSub = undefined
  }
  if (nextSub) {
    nextSub.prevSub = prevSub
    link.nextSub = undefined
  }
  if (__DEV__ && dep.subsHead === link) {
    // was previous head, point new head to next
    // EN: dev-only: 更新 dep.subsHead(如果需要)。
    // CN: 开发模式:若 link 是旧的 head,更新 head 指向 next。
    dep.subsHead = nextSub
  }

  if (dep.subs === link) {
    // was previous tail, point new tail to prev
    // EN: 更新 dep.subs(tail 指针)。
    // CN: 若 link 是 tail(dep.subs),将 tail 指向 prevSub。
    dep.subs = prevSub

    if (!prevSub && dep.computed) {
      // EN: if computed and no tail after removal, unsubscribe it from all deps so it can be GCed.
      // CN: 如果这是 computed 并且没有 prevSub(意味着 dep 变为空),对 computed 进行彻底“软取消订阅”以便 GC。
      dep.computed.flags &= ~EffectFlags.TRACKING
      for (let l = dep.computed.deps; l; l = l.nextDep) {
        // here we are only "soft" unsubscribing because the computed still keeps
        // referencing the deps and the dep should not decrease its sub count
        // EN: 软取消订阅:不减 dep.sc
        // CN: 软取消订阅(不减少 dep.sc),以保持 computed 仍引用 deps 的结构一致性。
        removeSub(l, true)
      }
    }
  }

  if (!soft && !--dep.sc && dep.map) {
    // #11979
    // property dep no longer has effect subscribers, delete it
    // this mostly is for the case where an object is kept in memory but only a
    // subset of its properties is tracked at one time
    // EN: 如果非 soft,且 subscriber count 减为 0,并且 dep.map 存在,则从 dep.map 删除该键,节省内存。
    // CN: 当某属性再无订阅者时(并且 dep.map 存在),删除该属性的 dep 条目以释放内存。
    dep.map.delete(dep.key)
  }
}

// EN: removeDep unlinks a link from the subscriber's dep double-linked list.
// CN: removeDep 从 effect/computed 的 deps(双向链)中移除 link 链接。
function removeDep(link: Link) {
  const { prevDep, nextDep } = link
  if (prevDep) {
    prevDep.nextDep = nextDep
    link.prevDep = undefined
  }
  if (nextDep) {
    nextDep.prevDep = prevDep
    link.nextDep = undefined
  }
}

// -----------------------------
// Public API: effect / stop
// -----------------------------

export interface ReactiveEffectRunner<T = any> {
  (): T
  effect: ReactiveEffect
}

export function effect<T = any>(
  fn: () => T,
  options?: ReactiveEffectOptions,
): ReactiveEffectRunner<T> {
  // EN: If fn is already a runner (i.e., we passed the runner itself), extract its effect.fn
  // CN: 允许把已有 runner 直接传入 effect,若传入的是 runner,则取出其内部的 fn。
  if ((fn as ReactiveEffectRunner).effect instanceof ReactiveEffect) {
    fn = (fn as ReactiveEffectRunner).effect.fn
  }

  // EN: create new ReactiveEffect, extend with options (scheduler, hooks...), run immediately.
  // CN: 创建 ReactiveEffect 实例,扩展 options(合并到实例上),并立即执行一次以收集初始 deps。
  const e = new ReactiveEffect(fn)
  if (options) {
    extend(e, options)
  }
  try {
    e.run()
  } catch (err) {
    e.stop()
    throw err
  }
  const runner = e.run.bind(e) as ReactiveEffectRunner
  runner.effect = e
  return runner
}

/**
 * Stops the effect associated with the given runner.
 *
 * @param runner - Association with the effect to stop tracking.
 */
export function stop(runner: ReactiveEffectRunner): void {
  // EN: stop wrapper: call stop on the underlying ReactiveEffect instance.
  // CN: stop 的封装:调用 runner.effect.stop() 停止该 effect。
  runner.effect.stop()
}

// -----------------------------
// Global tracking toggle utilities
// -----------------------------

/** @internal */
export let shouldTrack = true
const trackStack: boolean[] = []

/** Temporarily pauses tracking. */
// EN: pause tracking: push current state and set shouldTrack=false
// CN: 暂时暂停依赖收集:把当前 shouldTrack 压入栈,然后置 false。
export function pauseTracking(): void {
  trackStack.push(shouldTrack)
  shouldTrack = false
}

/** Re-enables effect tracking (if it was paused). */
// EN: enable tracking: push current state and set shouldTrack=true
// CN: 开启依赖收集(也会保存先前状态以便 later reset)。
export function enableTracking(): void {
  trackStack.push(shouldTrack)
  shouldTrack = true
}

/** Resets the previous global effect tracking state. */
// EN: restore previous shouldTrack from stack (or default to true).
// CN: 重置为上一次的 shouldTrack 状态(若无则默认为 true)。
export function resetTracking(): void {
  const last = trackStack.pop()
  shouldTrack = last === undefined ? true : last
}

// -----------------------------
// onEffectCleanup / cleanupEffect
// -----------------------------

/**
 * Registers a cleanup function for the current active effect.
 * The cleanup function is called right before the next effect run, or when the
 * effect is stopped.
 *
 * Throws a warning if there is no current active effect. The warning can be
 * suppressed by passing `true` to the second argument.
 *
 * @param fn - the cleanup function to be registered
 * @param failSilently - if `true`, will not throw warning when called without
 * an active effect.
 */
// EN: Register a cleanup callback associated with the current active ReactiveEffect.
// CN: 为当前活跃的 ReactiveEffect 注册 cleanup 回调(在下一次运行前或 stop 时会执行)。
export function onEffectCleanup(fn: () => void, failSilently = false): void {
  if (activeSub instanceof ReactiveEffect) {
    activeSub.cleanup = fn
  } else if (__DEV__ && !failSilently) {
    warn(
      `onEffectCleanup() was called when there was no active effect` +
        ` to associate with.`,
    )
  }
}

// EN: Execute and clear effect cleanup (run without any active effect).
// CN: 执行 effect 的 cleanup(在无 activeEffect 的上下文中运行),然后清空 cleanup 引用。
function cleanupEffect(e: ReactiveEffect) {
  const { cleanup } = e
  e.cleanup = undefined
  if (cleanup) {
    // run cleanup without active effect
    // EN: run the cleanup with activeSub temporarily unset to avoid accidental tracking.
    // CN: 运行 cleanup 时临时清除 activeSub,防止 cleanup 内部访问响应式数据导致错误的依赖收集。
    const prevSub = activeSub
    activeSub = undefined
    try {
      cleanup()
    } finally {
      activeSub = prevSub
    }
  }
}

Web发展与Vue.js导读

Web,即 World Wide Web,中文译作“万维网”。从静态网页到现代前端框架,Web的发展经历了几个明显阶段。理解这些阶段,有助于更好地理解 Vue 的出现背景和价值。


一、Web的历史演变

1. 石器时代:静态网页

早期网页没有数据库支持,本质上是一张可以在网络上浏览的“报纸”。用户浏览网页时,内容固定、交互有限。直到 CGI 技术的出现,网页才能通过小段代码与数据库或文件系统进行交互,例如 1998 年的 Google 就依赖此技术。

特点:

  • 页面静态,浏览体验单一
  • 数据交互能力有限

2. 文明时代:服务端渲染

2005 年左右,ASP(微软)和 JSP(Java Server Pages)开始流行,取代了 CGI 技术。它们可以在服务器端处理逻辑并生成 HTML 返回客户端。示例 JSP 代码:

<%@ page language="java" contentType="text/html; charset=utf-8"
    pageEncoding="utf-8"%>
<title>JSP demo</title>

特点:

  • 增强了 Web 与服务器交互的安全性
  • 页面灵活度低,每次请求都依赖服务器渲染
  • 同期,Ajax 技术开始普及,实现异步数据更新

3. 工业革命时代:前端框架的出现

移动设备普及后,SPA(Single Page Application 单页面应用)开始兴起。Backbone、EmberJS、AngularJS 等前端框架陆续出现,提高了开发效率,降低了开发门槛。

特点:

  • 提高开发效率,减少重复劳动
  • 解决复杂页面交互问题
  • 初期 SPA 面临 SEO 和复杂 View 绑定问题

4. 百花齐放时代:现代前端

如今,前端技术多样化,框架和工具层出不穷,每种技术都为特定场景提供解决方案。Vue 的出现就是为了解决开发灵活性和易用性问题。


二、Vue.js简介

Vue.js(/vjuː/,简称 Vue)是一个用于构建用户界面的开源 JavaScript 框架,也是创建单页应用的前端框架。它专注于视图层,同时能方便地处理数据更新,实现视图与模型交互。

  • 作者:尤雨溪
  • 发布:2014 年 2 月
  • 特点:轻量、易上手、高开发效率
  • 社区活跃度:GitHub 上星标数排名前三

三、Vue核心特性

1. 数据驱动(MVVM)

MVVM 模型包括:

  • Model:处理业务逻辑和服务器交互
  • View:负责 UI 展示
  • ViewModel:连接 Model 与 View,实现双向绑定

简图:

Model ↔ ViewModel ↔ View

2. 组件化

概念:将界面和逻辑抽象为独立可复用单元,每个 .vue 文件即为一个组件。

优势:

  • 降低耦合度,可快速替换组件
  • 调试方便,定位问题高效
  • 提高可维护性,组件复用提升整体系统质量

3. 指令系统

指令是带 v- 前缀的特殊属性,当数据改变时,DOM 自动响应更新。

常用指令:

  • 条件渲染:v-if
  • 列表渲染:v-for
  • 属性绑定:v-bind
  • 事件绑定:v-on
  • 双向绑定:v-model

与传统开发相比,Vue 不直接操作 DOM,而是通过数据驱动视图变化。


四、Vue与传统开发对比

以“注册账号”功能为例:

  • jQuery 实现

    • 手动获取 DOM 元素
    • 点击按钮显示/隐藏页面元素
    • 逻辑耦合度高
  • Vue 实现

    • 使用变量控制 DOM 显示与否
    • 点击按钮只需修改变量,DOM 自动更新
    • 核心理念:操作数据,而非直接操作 DOM

五、Vue与React对比

特性 Vue React
组件化
服务器端渲染 支持 支持
虚拟 DOM
数据驱动视图 可变数据 不可变数据
原生方案 Weex React Native
构建工具 vue-cli Create React App
组件通信 子向父通过事件和回调 子向父通过回调函数
diff算法 双向指针边对比边更新 diff队列批量更新DOM

Vue 与 React 没有绝对优劣,选择取决于具体场景需求。


参考资料


**声明:**本文部分内容借助 AI 辅助生成,并由作者整理审核。

四年前端分享给你的高效开发工具库

作为一名拥有四年前端开发经验的工程师,我主要在互联网 C 端项目工作,常常需要处理金额计算、数据加密、表单校验、时间处理、状态持久化等问题。 在这类项目中,选对工具能大大提升开发效率、降低 bug 率。

本文整理了我在实际项目中长期使用、验证过的高频实用库,涵盖数值处理、加密、校验、时间管理、状态持久化、XML 解析以及代码辅助开发工具。

希望这份清单能帮助你在项目中少踩坑、快开发!


1. 数字精度处理:decimal.js

核心问题:
JavaScript 原生浮点数计算会产生精度问题,例如:

console.log(0.1 + 0.2); // 0.30000000000000004

在涉及金额或积分计算时,这种误差可能造成严重问题。

为什么选择 decimal.js

  • 支持任意精度,不会出现浮点数误差。
  • API 简单易用,比 big.js 功能更完整。

示例 1:基础计算

import Decimal from 'decimal.js';

// 金额计算
const total = new Decimal(0.1).plus(0.2).toString();
console.log(total); // "0.3"

示例 2:金融场景 - 保留两位小数

const price = new Decimal(19.995);
console.log(price.toFixed(2)); // "20.00"

示例 3:大数字计算

const largeNumber = new Decimal('123456789123456789').mul(2);
console.log(largeNumber.toString()); // "246913578246913578"

适用场景:

  • 金融项目、支付、订单金额计算
  • 大数字统计(积分、虚拟币等)

2. 数据加密与哈希:crypto-js

核心问题:
前端经常需要做数据安全处理,比如密码加密接口签名Token 加密等。

为什么选择 crypto-js

  • 内置常用加密算法(MD5、SHA1、SHA256、AES、HmacSHA256 等)。
  • 前后端通用,方便与后端保持一致。
  • 体积小,易集成。

示例 1:MD5 加密

import CryptoJS from 'crypto-js';

const password = 'mySecretPassword';
const hash = CryptoJS.MD5(password).toString();
console.log(hash); // f857606c76b9d72353257dbd273c9b9e

示例 2:SHA256 签名

const data = 'userId=123&timestamp=1695712312';
const signature = CryptoJS.SHA256(data).toString();
console.log(signature);

示例 3:AES 加解密

const secretKey = 'abcdef123456';
const encrypted = CryptoJS.AES.encrypt('Sensitive Data', secretKey).toString();
console.log(encrypted);

const decrypted = CryptoJS.AES.decrypt(encrypted, secretKey).toString(CryptoJS.enc.Utf8);
console.log(decrypted); // "Sensitive Data"

适用场景:

  • 登录密码加密
  • 接口安全签名
  • 敏感数据存储(如手机号、身份证号)

3. 表单与数据校验:validator

核心问题:
表单提交前,需要验证邮箱、手机号、URL、身份证号等是否合规。

为什么选择 validator

  • 超过 130 个校验函数,几乎涵盖所有常见场景。
  • 链式调用简单,覆盖率高。

示例 1:校验邮箱

import validator from 'validator';

console.log(validator.isEmail('test@example.com')); // true
console.log(validator.isEmail('not-an-email')); // false

示例 2:校验手机号

// 第二个参数指定地区
console.log(validator.isMobilePhone('13800000000', 'zh-CN')); // true
console.log(validator.isMobilePhone('12345', 'zh-CN')); // false

示例 3:复杂规则组合

function validatePassword(password) {
  return (
    validator.isLength(password, { min: 8 }) &&
    validator.isAlphanumeric(password) &&
    validator.matches(password, /[A-Z]/)
  );
}

console.log(validatePassword('Test1234')); // true

适用场景:

  • 表单验证
  • 数据格式检查(手机号、URL、IP 等)

4. 日期时间处理:moment.js

核心问题:
处理日期和时间,比如格式化、计算时间差、处理时区等。

为什么选择 moment.js

  • 功能强大,API 设计简单。

示例 1:格式化日期

import moment from 'moment';

console.log(moment().format('YYYY-MM-DD HH:mm:ss')); // 2025-09-27 00:55:01

示例 2:计算时间差

const start = moment('2025-09-01');
const end = moment('2025-09-26');

console.log(end.diff(start, 'days')); // 25

示例 3:显示相对时间

console.log(moment('2025-09-25').fromNow()); // "一天前"

适用场景:

  • 订单时间、出行时间、飞机起飞到达时间等
  • 日历、排班等复杂业务

5. 状态持久化:pinia-plugin-persistedstate

核心问题:
使用 Vue + Pinia 时,刷新页面会导致状态丢失。

为什么选择它

  • 无需自己手动写 localStorage/sessionStorage 逻辑。
  • 可配置存储位置、加密策略。

示例:

import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({
    token: '',
    userInfo: {},
  }),
  persist: {
    storage: localStorage, // 默认 localStorage
  },
});

适用场景:

  • 登录状态保存
  • 表单缓存
  • 用户偏好设置

6. XML 转 JSON:x2js

核心问题:
在旅游、电商、酒店预订等行业,后端可能返回 XML 数据,前端需要解析成 JSON。

为什么选择 x2js

  • 轻量、易用。
  • 比 DOMParser 更直观。

示例:

import X2JS from 'x2js';

const x2js = new X2JS();
const xmlData = `<root><hotel><name>Hilton</name></hotel></root>`;
const jsonObj = x2js.xml2js(xmlData);

console.log(jsonObj.root.hotel.name); // Hilton

适用场景:

  • 旅游、酒店预订、机票系统静态的详情描述
  • 解析第三方 XML 接口

7. 元素定位代码:code-inspector-plugin

核心问题:
当页面出现问题时,想快速定位前端代码位置,但大型项目文件多,手动查找非常耗时。

如果是vue开发者,其实还有一个vue devtools,只能定位到组件级,没法定位到元素的具体行,这儿就不展开说了,因为它的功能不只是定位元素,更多的是调试响应式数据的,Respect!

为什么选择 code-inspector-plugin

  • 点击页面元素,直接跳转到对应的代码文件行(IDE 自动打开)。
  • 特别适合多人协作和大型项目调试。
  • 同事用了都叫好, 还说我怎么不早点分享

示例:Vite 配置 (注意只在开发环境配置)

// vite.config.js
import { defineConfig } from 'vite';
import { codeInspectorPlugin } from 'code-inspector-plugin';

export default defineConfig({
  plugins: [
      codeInspectorPlugin({
          bundler: 'vite',
          showSwitch: true, //开启后会出来一个按钮,点击按钮之后点击想看的元素就能定位了
        }),
  ],
});

使用方式:

  1. 启动项目后,按住 Ctrl + 鼠标左键 点击页面元素。
  2. VSCode 会直接打开对应组件文件。

适用场景:

  • 大型项目调试
  • 新人快速熟悉项目
  • 组件层级深

8. 总结与建议

以上是我在 C 端项目中高频使用的工具库,它们在解决实际问题上都非常高效、稳定。 当然除了这些,还有i18n以及lodash等等,这里就不一一叙述了。

选库建议:

  1. 以业务需求为导向:先明确问题,再选工具。
  2. 关注社区活跃度:定期维护的库更靠谱。
  3. 团队统一标准:减少重复选择,避免兼容性问题。
库名 主要用途 体积 特点
decimal.js 数字精度计算 任意精度,金融常用
crypto-js 加密/哈希 前后端通用,支持多种算法
validator 数据校验 场景覆盖全,表单友好
moment.js / dayjs 日期时间处理 大/小 moment 功能全,dayjs 轻量
pinia-plugin-persistedstate 状态持久化 与 Pinia 完美集成
x2js XML 转 JSON 旅游、酒店行业常用
code-inspector-plugin 代码定位辅助 开发效率提升神器

结语

选对工具不仅能提高开发效率,还能让项目更加稳定、可维护。
希望这篇文章能为你提供一些参考,让你在前端开发的日常工作中更加得心应手。 如果你有其他高频使用的工具库,也欢迎在评论区分享,一起交流!

实现AI对话光标跟随效果

概述

在使用一些AI对话工具的时候,比如gtp的聊天,在内容不断生成过程中,末尾会有光标跟随的特效,标识当前的实时位置,下面我们自己模拟实现一下。

效果

动画.gif

实现思路

  • 首先聊天内容是实时不断更新的过程,实现通过模拟数据生成
  • 要实现跟随文本生成最后位置生成一个圆点(自定义),需要找到最后一个文本节点
  • 然后追加一个文本
  • 获取文本相对页面的位置信息
  • 设置光标dom元素到上面的位置
  • 最后删除多余的文本

涉及到的DOM API

如下两个API在我们获取位置的时候非常关键,可以自行查阅相关用法

  • getBoundingClientRect
  • document.createRange

实现

生成聊天内容

使用如下测试数据

 const str = `核心对比:现代公共性观赏 vs 古代私人雅集式观赏。 
        开头引用民间说法和《本草纲目》,指出大蒜对眼睛有害。
        接着从临床经验、现代医学、中医理论多角度解释为什么有害。
        然后不仅讲对眼睛的害处,还提到蒜是发物,会刺激加重其他疾病(如肠炎)。
        作者态度是:
        现代公共观赏是主流,若强装古人雅集式观赏会被嘲笑。
        选项分析:
        历史越往后发展,艺术品越具有公共性——文段
        中国人艺术修为在不断进化——文段没有谈修为进化然后不仅讲对眼睛的害处,还提到蒜是发物,会刺激加重其他疾病(如肠炎)。
        我们分析一下文段结构:
        开头引用民间说法和《本草纲目》,指出大蒜对眼睛有害。然后不仅讲对眼睛的害处,还提到蒜是发物,会刺激加重其他疾病(如肠炎)。
        接着从临床经验、现代医学、中医理论多角度解释为什么有害。
       然后不仅讲对眼睛的害处,还提到蒜是发物,会刺激加重其他疾病(如肠炎)。
        `
        
           function transformTag(str) {
            return str.split("\n").map(t => `<p>${t}</p>`).join("")
        }

延迟函数

一个简单的Promise应用

   function delay(duration) {
            return new Promise((resolve) => {
                setTimeout(() => {
                    resolve();
                }, duration);
            });
        }

获取最后一文本节点

      function getLastTextNode(node) {
            if (node.nodeType === Node.TEXT_NODE) {
                return node
            }
            const childNodeList = Array.from(node.childNodes)
            for (let i = childNodeList.length - 1; i >= 0; i--) {
                const child = childNodeList[i]
                const res = getLastTextNode(child)
                if (res) {
                    return res
                }
            }
            return null
        }

更新光标位置

    function updateCursor() {
            const lastTextNode = getLastTextNode(wrapper)
            const curSorNode = document.createTextNode("|")
            if (lastTextNode) {
                lastTextNode.after(curSorNode)
            } else {
                wrapper.appendChild(curSorNode)
            }
            // 获取光标位置元素节点位置

            const range = document.createRange();
            range.setStart(curSorNode, 0);
            range.setEnd(curSorNode, 0);
            const rect = range.getBoundingClientRect();
            const wrapperRect = wrapper.getBoundingClientRect()

            const left = rect.left - wrapperRect.left
            const top = rect.top - wrapperRect.top
            console.log("rect", rect)
            // 设置光标位置
            if (!dot) {
                dot = document.createElement("span")
                dot.className = "blinking-dot"
                document.body.appendChild(dot)
            }
            const dotRect = dot.getBoundingClientRect()
            dot.style.left = rect.left + "px"
            dot.style.top = rect.top + rect.height / 2 - dotRect.height / 2 + "px"
            curSorNode.remove()

        }

渲染开始

 async function renderContent() {
            for (let i = 0; i < str.length; i++) {
                const text = str.slice(0, i);
                const html = transformTag(text)
                wrapper.innerHTML = html
                updateCursor()
                await delay(180)

            }
        }
        renderContent()

样式

    .blinking-dot {
            width: 15px;
            height: 15px;
            background-color: #000;
            /* 圆点颜色 */
            border-radius: 50%;
            position: fixed;
            /* 圆形 */
            animation: blink 0.8s infinite;
            /* 动画设置 */
            box-shadow: 0 0 10px rgba(255, 0, 0, 0.5);
            /* 可选的光晕效果 */
        }

        /* 闪烁动画定义 */
        @keyframes blink {
            0% {
                opacity: 1;
                /* 完全显示 */
                transform: scale(1);
                /* 正常大小 */
            }

            50% {
                opacity: 0.3;
                /* 半透明 */
                transform: scale(0.8);
                /* 稍微缩小 */
            }

            100% {
                opacity: 1;
                /* 恢复完全显示 */
                transform: scale(1);
                /* 恢复大小 */
            }
        }

完整代码

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        .blinking-dot {
            width: 15px;
            height: 15px;
            background-color: #000;
            /* 圆点颜色 */
            border-radius: 50%;
            position: fixed;
            /* 圆形 */
            animation: blink 0.8s infinite;
            /* 动画设置 */
            box-shadow: 0 0 10px rgba(255, 0, 0, 0.5);
            /* 可选的光晕效果 */
        }

        /* 闪烁动画定义 */
        @keyframes blink {
            0% {
                opacity: 1;
                /* 完全显示 */
                transform: scale(1);
                /* 正常大小 */
            }

            50% {
                opacity: 0.3;
                /* 半透明 */
                transform: scale(0.8);
                /* 稍微缩小 */
            }

            100% {
                opacity: 1;
                /* 恢复完全显示 */
                transform: scale(1);
                /* 恢复大小 */
            }
        }
    </style>
</head>

<body>
    <!-- 内容容器 -->
    <div class="wrapper"></div>
    
    <script>
        // 要显示的文本内容
        const str = `核心对比:现代公共性观赏 vs 古代私人雅集式观赏。 
        开头引用民间说法和《本草纲目》,指出大蒜对眼睛有害。
        接着从临床经验、现代医学、中医理论多角度解释为什么有害。
        然后不仅讲对眼睛的害处,还提到蒜是发物,会刺激加重其他疾病(如肠炎)。
        作者态度是:
        现代公共观赏是主流,若强装古人雅集式观赏会被嘲笑。
        选项分析:
        历史越往后发展,艺术品越具有公共性——文段
        中国人艺术修为在不断进化——文段没有谈修为进化然后不仅讲对眼睛的害处,还提到蒜是发物,会刺激加重其他疾病(如肠炎)。
        我们分析一下文段结构:
        开头引用民间说法和《本草纲目》,指出大蒜对眼睛有害。然后不仅讲对眼睛的害处,还提到蒜是发物,会刺激加重其他疾病(如肠炎)。
        接着从临床经验、现代医学、中医理论多角度解释为什么有害。
        然后不仅讲对眼睛的害处,还提到蒜是发物,会刺激加重其他疾病(如肠炎)。
        `;
        
        // 获取内容容器元素
        const wrapper = document.querySelector('.wrapper');
        // 用于存储闪烁圆点的引用
        let dot = null;
        
        /**
         * 延迟函数,返回一个Promise,在指定时间后resolve
         * @param {number} duration 延迟时间(毫秒)
         * @returns {Promise} 延迟完成的Promise
         */
        function delay(duration) {
            return new Promise((resolve) => {
                setTimeout(() => {
                    resolve();
                }, duration);
            });
        }
        
        /**
         * 将文本转换为HTML段落
         * @param {string} str 要转换的文本
         * @returns {string} 转换后的HTML字符串
         */
        function transformTag(str) {
            // 按换行符分割文本,每行用<p>标签包裹
            return str.split("\n").map(t => `<p>${t}</p>`).join("");
        }
        
        /**
         * 异步渲染内容,实现打字机效果
         */
        async function renderContent() {
            // 逐个字符显示文本
            for (let i = 0; i < str.length; i++) {
                // 获取当前要显示的文本部分
                const text = str.slice(0, i);
                // 转换为HTML格式
                const html = transformTag(text);
                // 更新容器内容
                wrapper.innerHTML = html;
                // 更新光标位置
                updateCursor();
                // 延迟一段时间,控制打字速度
                await delay(180);
            }
        }
        
        /**
         * 递归查找DOM节点中的最后一个文本节点
         * @param {Node} node 要查找的节点
         * @returns {Node|null} 找到的文本节点或null
         */
        function getLastTextNode(node) {
            // 如果当前节点是文本节点,直接返回
            if (node.nodeType === Node.TEXT_NODE) {
                return node;
            }
            
            // 获取所有子节点并转换为数组
            const childNodeList = Array.from(node.childNodes);
            
            // 从后往前遍历子节点
            for (let i = childNodeList.length - 1; i >= 0; i--) {
                const child = childNodeList[i];
                // 递归查找子节点中的最后一个文本节点
                const res = getLastTextNode(child);
                if (res) {
                    return res;
                }
            }
            
            // 如果没有找到文本节点,返回null
            return null;
        }
        
        /**
         * 更新光标位置
         */
        function updateCursor() {
            // 查找最后一个文本节点
            const lastTextNode = getLastTextNode(wrapper);
            // 创建光标节点(竖线符号)
            const curSorNode = document.createTextNode("|");
            
            // 如果找到文本节点,将光标插入其后
            if (lastTextNode) {
                lastTextNode.after(curSorNode);
            } else {
                // 如果没有文本节点,将光标添加到容器末尾
                wrapper.appendChild(curSorNode);
            }
            
            // 创建Range对象用于获取光标位置
            const range = document.createRange();
            range.setStart(curSorNode, 0); // 设置Range起点
            range.setEnd(curSorNode, 0);   // 设置Range终点
            // 获取光标位置信息
            const rect = range.getBoundingClientRect();
            // 获取容器位置信息
            const wrapperRect = wrapper.getBoundingClientRect();
            
            // 计算相对于容器的位置
            const left = rect.left - wrapperRect.left;
            const top = rect.top - wrapperRect.top;
            console.log("光标位置:", rect);
            
            // 创建或更新闪烁圆点
            if (!dot) {
                // 如果圆点不存在,创建新元素
                dot = document.createElement("span");
                dot.className = "blinking-dot";
                document.body.appendChild(dot);
            }
            
            // 获取圆点尺寸
            const dotRect = dot.getBoundingClientRect();
            // 设置圆点位置:水平位置与光标对齐,垂直位置与光标中心对齐
            dot.style.left = rect.left + "px";
            dot.style.top = rect.top + rect.height / 2 - dotRect.height / 2 + "px";
            
            // 移除临时光标节点
            curSorNode.remove();
        }
        
        // 页面加载完成后开始渲染内容
        window.addEventListener('DOMContentLoaded', () => {
            renderContent();
        });
    </script>
</body>
</html>

【TS 设计模式完全指南】构建你的专属“通知中心”:深入观察者模式

一、什么是观察者模式?

观察者模式(Observer Pattern)是一种行为设计模式,它定义了一种一对多的依赖关系。当一个对象(被称为主题 Subject发布者 Publisher)的状态发生改变时,所有依赖于它的对象(被称为观察者 Observers订阅者 Subscribers)都会得到通知并自动更新。

二、观察者模式的核心组件

  1. 主题接口 (Subject Interface):声明了用于管理观察者的方法,通常包括 subscribe() , unsubscribe() , 和 notify()
  2. 观察者接口 (Observer Interface):声明了所有具体观察者必须实现的通知方法,通常是 update()
  3. 具体主题 (Concrete Subject):实现了主题接口。它维护着一个观察者列表,并在自身状态改变时,调用 notify() 方法通知所有观察者。
  4. 具体观察者 (Concrete Observer):实现了观察者接口。在 update() 方法中定义了收到通知后要执行的具体逻辑。

三、示例:实现一个商品到货通知系统

3.1 定义接口 (Interfaces)

首先定义好我们的“合同”——SubjectObserver 接口。

// Observer Interface
interface IObserver {
    update(subject: ISubject): void;
}

// Subject Interface
interface ISubject {
    subscribe(observer: IObserver): void;
    unsubscribe(observer: IObserver): void;
    notify(): void;
}
  • update 方法传入 subject 本身,这样观察者就可以在需要时从中获取更新后的状态。

3.2 创建具体主题 (Concrete Subject)

我们的 Product 类就是具体主题。它管理自己的库存状态和订阅者列表。

// Concrete Subject
class Product implements ISubject {
    public readonly name: string;
    private observers: IObserver[] = [];
    private _inStock: boolean = false;

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

    get inStock(): boolean {
        return this._inStock;
    }

    // 状态改变的方法
    public setStockStatus(status: boolean): void {
        console.log(`\n[PRODUCT]:商品 "${this.name}" 的库存状态变为 ${status ? '有货' : '缺货'}.`);
        this._inStock = status;
        this.notify(); // 状态改变,通知所有观察者!
    }

    public subscribe(observer: IObserver): void {
        const isExist = this.observers.includes(observer);
        if (isExist) {
            return console.log('Observer has been attached already.');
        }
        this.observers.push(observer);
        console.log(`[SYSTEM]: ${observer.constructor.name} 成功订阅 "${this.name}".`);
    }

    public unsubscribe(observer: IObserver): void {
        const observerIndex = this.observers.indexOf(observer);
        if (observerIndex === -1) {
            return console.log('Nonexistent observer.');
        }
        this.observers.splice(observerIndex, 1);
        console.log(`[SYSTEM]: ${observer.constructor.name} 已取消订阅.`);
    }

    public notify(): void {
        console.log(`[PRODUCT]: 正在通知所有 ${this.observers.length} 位订阅者...`);
        for (const observer of this.observers) {
            observer.update(this);
        }
    }
}

3.3 创建具体观察者 (Concrete Observers)

现在,我们创建UI更新器和邮件服务,它们都是观察者。

// Concrete Observer 1: UI Updater
class UINotifier implements IObserver {
    update(subject: ISubject): void {
        if (subject instanceof Product) {
            console.log(
                `[UI]: 收到通知!商品 "${subject.name}" 现在 ${
                    subject.inStock ? '有货' : '缺货'
                },正在更新页面显示...`
            );
        }
    }
}

// Concrete Observer 2: Email Service
class EmailNotifier implements IObserver {
    update(subject: ISubject): void {
        if (subject instanceof Product && subject.inStock) {
            console.log(`[Email]: 收到通知!商品 "${subject.name}" 已到货,正在准备发送邮件...`);
        }
    }
}

3.4 客户端代码:将一切联系起来

const ps5 = new Product('PlayStation 5');

const ui = new UINotifier();
const emailService = new EmailNotifier();

// 订阅
ps5.subscribe(ui);
ps5.subscribe(emailService);

// 状态变化 -> 缺货 (假设初始为缺货,这里为了演示,手动设置一次)
ps5.setStockStatus(false); 
// 此时只会触发UI更新缺货状态,邮件服务因为逻辑判断不会发送邮件

// 关键时刻:到货了!
ps5.setStockStatus(true);
// 此时UI和Email服务都会收到通知并执行相应操作

// 一个用户不再关心了,取消订阅邮件
ps5.unsubscribe(emailService);

// 再次变为缺货
ps5.setStockStatus(false);
// 这次只有UI会收到通知

为了方便大家学习和实践,本文的所有示例代码和完整项目结构都已整理上传至我的 GitHub 仓库。欢迎大家克隆、研究、提出 Issue,共同进步!

📂 核心代码与完整示例: GoF

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多JavaScript/TypeScript开发干货

楖览:Vue3 源码研究导读

前言

Vue3 的源码采用 模块化 Monorepo 架构,分布在 packages/ 目录下。每个模块都承担着清晰的职责:有的处理模板编译,有的负责运行时渲染,有的提供响应式引擎。
理解这些模块的关系,是研究源码的第一步。


一、编译层

1. compiler-core

  • 作用:模板编译的核心逻辑。

    • 输入:模板字符串 <div>{{ msg }}</div>
    • 输出:渲染函数源码
  • 特点:平台无关,只管 AST 生成和转换。

  • 示例

    import { baseParse } from '@vue/compiler-core'
    
    const ast = baseParse('<p>{{ hello }}</p>')
    console.log(ast.children[0].tag) // 输出 "p"
    

2. compiler-dom

  • 作用:扩展 compiler-core,加入浏览器平台相关逻辑。

  • 应用场景:处理 DOM 专用指令,例如 v-model、事件绑定。

  • 示例

    import { compile } from '@vue/compiler-dom'
    
    const { code } = compile('<button @click="count++">{{ count }}</button>')
    console.log(code) 
    // 输出渲染函数源码字符串,内部包含 _createElementVNode 等调用
    

3. compiler-sfc

  • 作用:处理单文件组件(.vue),解析 <template><script><style>

  • 示例

    import { parse } from '@vue/compiler-sfc'
    
    const source = `
    <template><div>{{ msg }}</div></template>
    <script>export default { data(){ return { msg: 'hi' } } }</script>
    `
    const { descriptor } = parse(source)
    console.log(descriptor.template.content) // "<div>{{ msg }}</div>"
    

4. compiler-ssr

  • 作用:专用于服务端渲染,输出字符串拼接代码而不是 DOM 操作。

  • 示例

    import { compile } from '@vue/compiler-ssr'
    
    const { code } = compile('<div>{{ msg }}</div>')
    console.log(code) 
    // 输出包含 ctx.msg 的字符串拼接函数
    

二、运行时层

1. runtime-core

  • 作用:Vue 运行时的核心,包含组件系统、虚拟 DOM、生命周期调度。

  • 特点:不依赖任何平台 API,可移植。

  • 示例

    import { h, render } from '@vue/runtime-core'
    
    const vnode = h('h1', null, 'Hello Core')
    render(vnode, document.body)
    

2. runtime-dom

  • 作用:为浏览器环境实现 runtime-core 的渲染逻辑,调用真实的 DOM API。

  • 示例

    import { createApp } from 'vue'
    
    const App = {
      data: () => ({ count: 0 }),
      template: `<button @click="count++">{{ count }}</button>`
    }
    
    createApp(App).mount('#app')
    

3. runtime-test

  • 作用:提供一个测试用渲染器,不依赖真实 DOM。

  • 示例

    import { createApp, h } from '@vue/runtime-test'
    
    const App = { render: () => h('div', 'test') }
    const root = {}
    createApp(App).mount(root)
    
    console.log(root.children[0].type) // "div"
    

4. server-renderer

  • 作用:在 Node.js 环境下生成 HTML 字符串,配合 compiler-ssr 使用。

  • 示例

    import { renderToString } from '@vue/server-renderer'
    import { createSSRApp } from 'vue'
    
    const app = createSSRApp({ template: `<h1>Hello SSR</h1>` })
    const html = await renderToString(app)
    console.log(html) // "<h1>Hello SSR</h1>"
    

三、基础模块

1. reactivity

  • 作用:Vue 响应式系统的核心。

  • 示例

    import { ref, effect } from '@vue/reactivity'
    
    const count = ref(0)
    effect(() => console.log('count changed:', count.value))
    
    count.value++ // 触发 effect
    

2. shared

  • 作用:公共工具函数与常量,整个源码都会用到。

  • 示例

    import { isArray } from '@vue/shared'
    
    console.log(isArray([1, 2, 3])) // true
    

四、整合入口

1. vue

  • 作用:开发者使用的入口包,整合了所有子模块。

  • 示例

    import { createApp, ref } from 'vue'
    
    const App = {
      setup() {
        const msg = ref('Hello Vue3')
        return { msg }
      },
      template: `<p>{{ msg }}</p>`
    }
    
    createApp(App).mount('#app')
    

五、模块关系图

        compiler-core
         /       \
 compiler-dom   compiler-ssr
       |             |
   compiler-sfc   server-renderer
       |
     vue (整合入口)
       |
 runtime-core —— reactivity —— shared
       |
 runtime-dom / runtime-test

六、结语

Vue3 的源码像一座“分层工厂”:

  • 编译层:把模板翻译成渲染函数。
  • 运行时层:执行渲染函数,生成 DOM 或字符串。
  • 基础模块:提供响应式与工具函数。
  • 整合入口:最终打包成 vue,交到开发者手里。

这张导览图与示例,能帮助你快速定位源码,带着问题深入研究细节。

本文部分内容借助 AI 辅助生成,并由作者整理审核。

前端项目中的测试分类与实践 —— 以 Vue 项目为例

在现代前端工程化体系中,测试已经成为保障代码质量和开发效率的关键环节。一个大型框架(如 Vue)通常会设计多种测试命令,来覆盖不同层面的需求。以下将对常见的几类测试命令进行拆解说明,并配合示例代码来帮助理解。


一、单元测试(test-unit

概念

单元测试(Unit Test)关注的是最小逻辑单元,例如一个函数或一个小组件。

示例

// math.ts
export function add(a: number, b: number) {
  return a + b;
}
// math.test.ts
import { describe, it, expect } from 'vitest';
import { add } from './math';

describe('math utils', () => {
  it('add should return correct sum', () => {
    expect(add(1, 2)).toBe(3); // ✅ 测试基本逻辑
  });

  it('add should work with negative numbers', () => {
    expect(add(-1, 5)).toBe(4);
  });
});

👉 说明

  • 粒度小,执行快。
  • 不依赖构建产物,直接跑源码。

二、端到端测试(test-e2e

概念

端到端测试(E2E Test)是模拟用户的实际操作来验证系统行为,常用于验证打包后的产物。

示例

<!-- App.vue -->
<template>
  <button @click="count++">Clicked {{ count }} times</button>
</template>

<script setup lang="ts">
import { ref } from 'vue';
const count = ref(0);
</script>
// app.e2e.test.ts
import { test, expect } from '@playwright/test';

test('button click should increase counter', async ({ page }) => {
  await page.goto('http://localhost:5173'); // 假设启动了 dev server
  const button = page.getByRole('button');
  await button.click();
  await expect(button).toHaveText('Clicked 1 times'); // ✅ 模拟真实点击
});

👉 说明

  • 需要先 build 出产物,然后在浏览器中运行。
  • 粒度大,接近真实用户体验。
  • 执行速度比单测慢。

三、类型声明测试(test-dts / test-dts-only

概念

类型测试的目标是保证生成的 .d.ts 文件能正常工作,让 TypeScript 用户拥有良好的类型提示。

示例

// vue-shim.d.ts (假设库生成的声明文件)
export function createApp(rootComponent: any): {
  mount(selector: string): void;
};
// dts.test.ts
import { createApp } from 'vue';

// ✅ 正确用法
createApp({}).mount('#app');

// ❌ 错误用法:少了 mount 参数
// 这里应该触发 TS 编译错误
// createApp({}).mount();

👉 说明

  • test-dts 会先生成 .d.ts 文件再检查。
  • test-dts-only 直接用现有的 .d.ts 文件进行编译验证。
  • 关键在于保障 API 类型和实际逻辑一致。

四、覆盖率测试(test-coverage

概念

覆盖率测试不仅运行单元测试,还会统计代码哪些部分被执行,输出报告(如语句、分支、函数、行覆盖率)。

示例

// stringUtils.ts
export function greet(name?: string) {
  if (name) {
    return `Hello, ${name}`;
  }
  return 'Hello, guest';
}
// stringUtils.test.ts
import { describe, it, expect } from 'vitest';
import { greet } from './stringUtils';

describe('greet', () => {
  it('should greet with name', () => {
    expect(greet('Alice')).toBe('Hello, Alice');
  });
});

👉 说明

  • 测试只覆盖了有 name 的情况,guest 分支没测到。
  • test-coverage 运行后会提示覆盖率不足,提醒你写额外测试:
it('should greet guest when no name provided', () => {
  expect(greet()).toBe('Hello, guest');
});

五、对比总结

命令 测试范围 示例场景
test-unit 模块逻辑 add(1,2) → 3
test-e2e 打包产物 & 用户行为 点击按钮计数器增加
test-dts 类型声明生成 + 检查 createApp().mount() 类型是否报错
test-dts-only 仅检查现有类型声明 不构建,直接验证
test-coverage 单测 + 覆盖率报告 提示 guest 分支未覆盖
test 全部测试集合 本地一键跑完

六、潜在问题

  1. E2E 测试执行慢:CI/CD 环境可能成为瓶颈。
  2. 覆盖率追求过度:高覆盖率不代表高质量,测试内容比数字更重要。
  3. 类型声明忽视:很多库项目容易忽略 d.ts 测试,导致 TS 用户踩坑。
  4. 依赖构建链路:像 test-e2etest-dts 一旦构建失败,测试链全挂。

结语

不同的测试类型各有侧重:

  • 单元测试 → 保证基础逻辑正确。
  • 端到端测试 → 模拟用户真实场景。
  • 类型测试 → 保证 TypeScript 用户的体验。
  • 覆盖率测试 → 衡量测试充分性。

它们共同构建了一个完整的质量保障体系,帮助项目在开发和交付中保持高可靠性。


本文部分内容借助 AI 辅助生成,并由作者整理审核。

CEF框架实践:构建Mac混合桌面应用

目录

  1. CEF框架概述
  2. 架构设计与核心组件
  3. 开发环境配置
  4. 核心API详解
  5. 实际应用开发
  6. 构建与部署
  7. 性能优化与最佳实践
  8. 常见问题与解决方案

CEF框架概述

什么是CEF?

Chromium Embedded Framework (CEF) 是一个基于Google Chromium项目的开源框架,由Marshall Greenblatt于2008年创立。CEF专门为在第三方应用程序中嵌入基于Chromium的浏览器而设计,提供了生产级的稳定API和二进制分发版本。

CEF的主要特性

  • 跨平台支持:支持Windows、macOS、Linux等主流操作系统
  • 多语言绑定:提供C/C++原生接口,支持.NET、Python、Java等多种语言绑定
  • 生产级稳定性:提供稳定的API接口,跟踪特定Chromium版本的发布分支
  • 丰富的功能:支持HTML5、WebGL、Canvas、WebRTC等现代Web技术
  • 灵活的集成:可轻松集成到新建或现有应用程序中

CEF的应用场景

  1. 嵌入式浏览器控件:在现有原生应用中嵌入HTML5兼容的Web浏览器控件
  2. 混合应用开发:创建轻量级原生"外壳"应用,主要使用Web技术开发用户界面
  3. 离屏渲染:在具有自定义绘图框架的应用中离屏渲染Web内容
  4. 自动化测试:作为现有Web属性和应用的自动化测试宿主

架构设计与核心组件

整体架构

CEF采用多进程架构,主要包含以下进程:

┌─────────────────────────────────────────────────────────┐
│                    CEF架构图                            │
├─────────────────────────────────────────────────────────┤
│  主进程 (Browser Process)                               │
│  ┌─────────────────┐  ┌─────────────────┐               │
│  │   CefApp        │  │ CefClient       │               │
│  │                 │  │                 │               │
│  │ - 应用程序逻辑   │  │ - 浏览器控制    │               │
│  │ - 进程管理      │  │ - 事件处理      │               │
│  └─────────────────┘  └─────────────────┘               │
│           │                     │                       │
│  ┌─────────────────┐  ┌─────────────────┐               │
│  │ BrowserProcess  │  │ CefBrowser      │               │
│  │ Handler         │  │                 │               │
│  │                 │  │ - 窗口管理      │               │
│  │ - 上下文初始化  │  │ - 导航控制      │               │
│  └─────────────────┘  └─────────────────┘               │
└─────────────────────────────────────────────────────────┘
           │
           ▼
┌─────────────────────────────────────────────────────────┐
│  渲染进程 (Render Process)                              │
│  ┌─────────────────┐  ┌─────────────────┐               │
│  │ RenderProcess   │  │ V8 JavaScript   │               │
│  │ Handler         │  │ Engine          │               │
│  │                 │  │                 │               │
│  │ - 渲染逻辑      │  │ - JS执行环境    │               │
│  │ - DOM处理       │  │ - 扩展支持      │               │
│  └─────────────────┘  └─────────────────┘               │
└─────────────────────────────────────────────────────────┘

核心组件详解

1. CefApp - 应用程序接口

CefApp是CEF应用程序的主入口点,负责:

class SimpleApp : public CefApp, public CefBrowserProcessHandler {
public:
    SimpleApp();
    
    // CefApp方法
    CefRefPtr<CefBrowserProcessHandler> GetBrowserProcessHandler() override {
        return this;
    }
    
    // CefBrowserProcessHandler方法
    void OnContextInitialized() override;
    CefRefPtr<CefClient> GetDefaultClient() override;
    
private:
    IMPLEMENT_REFCOUNTING(SimpleApp);
};

主要职责:

  • 应用程序级别的回调处理
  • 浏览器进程管理
  • 全局设置配置

2. CefClient - 浏览器客户端

CefClient是浏览器功能的核心接口:

class SimpleHandler : public CefClient,
                      public CefDisplayHandler,
                      public CefLifeSpanHandler,
                      public CefLoadHandler {
public:
    // 获取各种处理器
    CefRefPtr<CefDisplayHandler> GetDisplayHandler() override { return this; }
    CefRefPtr<CefLifeSpanHandler> GetLifeSpanHandler() override { return this; }
    CefRefPtr<CefLoadHandler> GetLoadHandler() override { return this; }
    
    // 生命周期管理
    void OnAfterCreated(CefRefPtr<CefBrowser> browser) override;
    bool DoClose(CefRefPtr<CefBrowser> browser) override;
    void OnBeforeClose(CefRefPtr<CefBrowser> browser) override;
    
private:
    IMPLEMENT_REFCOUNTING(SimpleHandler);
};

3. CefBrowser - 浏览器实例

CefBrowser代表一个浏览器窗口或标签页:

// 创建浏览器
CefWindowInfo window_info;
CefBrowserSettings browser_settings;

CefRect rect(0, 0, width, height);
window_info.SetAsChild(parent_window, rect);

CefRefPtr<CefBrowser> browser = CefBrowserHost::CreateBrowserSync(
    window_info, handler, url, browser_settings, nullptr, nullptr);

进程间通信

CEF使用以下机制进行进程间通信:

  1. 进程消息:通过CefProcessMessage在进程间传递数据
  2. 共享内存:使用CefSharedMemoryRegion进行大数据传输
  3. IPC回调:通过回调函数处理跨进程调用

开发环境配置

系统要求

macOS环境

  • 操作系统:macOS 12.0 (Monterey) 或更新版本
  • 开发工具:Xcode 13.5 到 16.4
  • 命令行工具:必须安装Xcode命令行工具
  • CMake:版本3.21或更新

依赖库安装

# 使用Homebrew安装依赖
brew install cmake ninja

# 安装Xcode命令行工具
xcode-select --install

项目结构配置

典型的CEF项目结构:

MyCEFApp/
├── CMakeLists.txt              # 主CMake配置文件
├── src/                        # 源代码目录
│   ├── main.cpp               # 主程序入口
│   ├── app.h/app.cpp          # CefApp实现
│   └── handler.h/handler.cpp  # CefClient实现
├── resources/                  # 资源文件
│   └── html/                  # HTML/CSS/JS文件
└── cmake/                     # CMake模块
    └── FindCEF.cmake         # CEF查找模块

CMake配置示例

cmake_minimum_required(VERSION 3.21)

project(MyCEFApp)

# 设置CEF路径
set(CEF_ROOT "/path/to/cef_binary_139.0.28+g55ab8a8+chromium-139.0.7258.139_macosarm64")
set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CEF_ROOT}/cmake")

# 查找CEF
find_package(CEF REQUIRED)

# 添加libcef_dll_wrapper
add_subdirectory(${CEF_LIBCEF_DLL_WRAPPER_PATH} libcef_dll_wrapper)

# 创建可执行文件
add_executable(MyCEFApp
    src/main.cpp
    src/app.cpp
    src/handler.cpp
)

# 链接库
target_link_libraries(MyCEFApp
    libcef_dll_wrapper
    ${CEF_STANDARD_LIBS}
)

# 设置包含目录
target_include_directories(MyCEFApp PRIVATE ${CEF_INCLUDE_DIRS})

核心API详解

初始化与配置

1. 应用程序初始化

int main(int argc, char* argv[]) {
    // 加载CEF库
    CefScopedLibraryLoader library_loader;
    if (!library_loader.LoadInMain()) {
        return 1;
    }
    
    // 提供命令行参数
    CefMainArgs main_args(argc, argv);
    
    // 配置CEF设置
    CefSettings settings;
    settings.no_sandbox = true;  // 禁用沙箱(开发环境)
    settings.multi_threaded_message_loop = false;  // 单线程消息循环
    
    // 创建应用程序实例
    CefRefPtr<SimpleApp> app(new SimpleApp);
    
    // 初始化CEF
    if (!CefInitialize(main_args, settings, app.get(), nullptr)) {
        return CefGetExitCode();
    }
    
    // 运行消息循环
    CefRunMessageLoop();
    
    // 关闭CEF
    CefShutdown();
    
    return 0;
}

2. 浏览器创建

void SimpleApp::OnContextInitialized() {
    // 创建窗口信息
    CefWindowInfo window_info;
    CefBrowserSettings browser_settings;
    
    // 设置窗口属性
    CefRect rect(0, 0, 1024, 768);
    window_info.SetAsChild(parent_window_handle, rect);
    
    // 配置浏览器设置
    browser_settings.plugins = STATE_DISABLED;  // 禁用插件
    browser_settings.javascript = STATE_ENABLED;  // 启用JavaScript
    
    // 创建浏览器实例
    CefBrowserHost::CreateBrowserSync(
        window_info,
        SimpleHandler::GetInstance(),
        "https://www.example.com",
        browser_settings,
        nullptr,
        nullptr
    );
}

事件处理机制

1. 生命周期事件

class SimpleHandler : public CefClient, public CefLifeSpanHandler {
public:
    // 浏览器创建后调用
    void OnAfterCreated(CefRefPtr<CefBrowser> browser) override {
        browser_list_.push_back(browser);
    }
    
    // 浏览器关闭前调用
    bool DoClose(CefRefPtr<CefBrowser> browser) override {
        // 返回false允许关闭,返回true阻止关闭
        return false;
    }
    
    // 浏览器完全关闭后调用
    void OnBeforeClose(CefRefPtr<CefBrowser> browser) override {
        browser_list_.remove(browser);
        
        // 如果所有浏览器都关闭了,退出应用程序
        if (browser_list_.empty()) {
            CefQuitMessageLoop();
        }
    }
    
private:
    std::list<CefRefPtr<CefBrowser>> browser_list_;
};

2. 加载事件

class SimpleHandler : public CefClient, public CefLoadHandler {
public:
    // 页面开始加载
    void OnLoadStart(CefRefPtr<CefBrowser> browser,
                     CefRefPtr<CefFrame> frame,
                     TransitionType transition_type) override {
        if (frame->IsMain()) {
            // 主框架开始加载
            std::cout << "Main frame loading started" << std::endl;
        }
    }
    
    // 页面加载完成
    void OnLoadEnd(CefRefPtr<CefBrowser> browser,
                   CefRefPtr<CefFrame> frame,
                   int httpStatusCode) override {
        if (frame->IsMain()) {
            // 主框架加载完成
            std::cout << "Main frame loaded with status: " << httpStatusCode << std::endl;
        }
    }
    
    // 加载错误
    void OnLoadError(CefRefPtr<CefBrowser> browser,
                     CefRefPtr<CefFrame> frame,
                     ErrorCode errorCode,
                     const CefString& errorText,
                     const CefString& failedUrl) override {
        if (frame->IsMain()) {
            // 显示错误页面
            std::string error_html = "<html><body><h1>Error " + 
                                   std::to_string(errorCode) + 
                                   "</h1><p>" + errorText.ToString() + 
                                   "</p></body></html>";
            frame->LoadString(error_html, failedUrl);
        }
    }
};

JavaScript集成

1. 执行JavaScript代码

// 执行JavaScript代码
browser->GetMainFrame()->ExecuteJavaScript(
    "console.log('Hello from C++!');",
    browser->GetMainFrame()->GetURL(),
    0
);

2. 注册JavaScript扩展

class JSExtension : public CefV8Handler {
public:
    bool Execute(const CefString& name,
                 CefRefPtr<CefV8Value> object,
                 const CefV8ValueList& arguments,
                 CefRefPtr<CefV8Value>& retval,
                 CefString& exception) override {
        
        if (name == "showMessage") {
            if (arguments.size() == 1 && arguments[0]->IsString()) {
                std::string message = arguments[0]->GetStringValue();
                std::cout << "JavaScript called: " << message << std::endl;
                
                // 返回结果给JavaScript
                retval = CefV8Value::CreateString("Message received");
                return true;
            }
        }
        
        return false;
    }
    
private:
    IMPLEMENT_REFCOUNTING(JSExtension);
};

// 注册扩展
void RegisterJSExtensions(CefRefPtr<CefBrowser> browser) {
    CefRefPtr<CefV8Context> context = browser->GetMainFrame()->GetV8Context();
    context->Enter();
    
    CefRefPtr<CefV8Value> global = context->GetGlobal();
    CefRefPtr<JSExtension> handler = new JSExtension();
    
    global->SetValue("nativeAPI", 
                    CefV8Value::CreateFunction("showMessage", handler),
                    V8_PROPERTY_ATTRIBUTE_NONE);
    
    context->Exit();
}

实际应用开发

macOS应用开发示例

基于我们分析的示例代码,创建一个完整的macOS CEF应用:

1. AppDelegate实现

// AppDelegate.h
#import <Cocoa/Cocoa.h>
#include "include/cef_browser.h"
#include "include/cef_client.h"

class SimpleHandler : public CefClient, public CefLifeSpanHandler {
public:
    SimpleHandler() {}
    
    CefRefPtr<CefLifeSpanHandler> GetLifeSpanHandler() override {
        return this;
    }
    
    void OnAfterCreated(CefRefPtr<CefBrowser> browser) override {
        // 保存浏览器对象引用
    }
    
    IMPLEMENT_REFCOUNTING(SimpleHandler);
};

@interface AppDelegate : NSObject <NSApplicationDelegate>
@property (strong) NSWindow *window;
@end
// AppDelegate.mm
@implementation AppDelegate {
    CefRefPtr<CefBrowser> browser_;
    CefRefPtr<SimpleHandler> handler_;
}

- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
    // 1. 创建窗口
    NSRect frame = NSMakeRect(100, 100, 1024, 768);
    self.window = [[NSWindow alloc] initWithContentRect:frame
                                              styleMask:(NSWindowStyleMaskTitled |
                                                         NSWindowStyleMaskClosable |
                                                         NSWindowStyleMaskResizable)
                                                backing:NSBackingStoreBuffered
                                                  defer:NO];
    [self.window setTitle:@"CEF Demo"];
    [self.window makeKeyAndOrderFront:nil];
    
    // 2. 延迟创建浏览器,确保contentView尺寸正确
    dispatch_async(dispatch_get_main_queue(), ^{
        NSView *contentView = [self.window contentView];
        
        CefWindowInfo window_info;
        CefBrowserSettings browser_settings;
        
        CefRect rect(0, 0,
                     (int)contentView.bounds.size.width,
                     (int)contentView.bounds.size.height);
        window_info.SetAsChild((__bridge CefWindowHandle)contentView, rect);
        
        self->handler_ = new SimpleHandler();
        
        self->browser_ = CefBrowserHost::CreateBrowserSync(
            window_info,
            self->handler_,
            "https://www.apple.com.cn",
            browser_settings,
            nullptr,
            nullptr
        );
    });
    
    // 3. 监听窗口resize事件
    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(windowDidResizeNotification:)
                                                 name:NSWindowDidResizeNotification
                                               object:self.window];
}

- (void)windowDidResizeNotification:(NSNotification *)notification {
    if (browser_) {
        browser_->GetHost()->WasResized();
    }
}

@end

2. 主程序入口

// main.mm
#import <Cocoa/Cocoa.h>
#include "include/cef_application_mac.h"
#include "include/wrapper/cef_library_loader.h"
#include "AppDelegate.h"

int main(int argc, char* argv[]) {
    // 加载CEF库
    CefScopedLibraryLoader library_loader;
    if (!library_loader.LoadInMain()) {
        return 1;
    }
    
    @autoreleasepool {
        // 创建NSApplication
        [NSApplication sharedApplication];
        
        // 设置应用程序委托
        AppDelegate *delegate = [[AppDelegate alloc] init];
        [NSApp setDelegate:delegate];
        
        // 运行应用程序
        [NSApp run];
    }
    
    return 0;
}

高级功能实现

1. 自定义协议处理

class CustomSchemeHandler : public CefResourceHandler {
public:
    CustomSchemeHandler() : offset_(0) {}
    
    bool ProcessRequest(CefRefPtr<CefRequest> request,
                       CefRefPtr<CefCallback> callback) override {
        // 处理自定义协议请求
        std::string url = request->GetURL().ToString();
        
        if (url.find("custom://") == 0) {
            // 生成自定义内容
            content_ = "<html><body><h1>Custom Protocol</h1><p>This is custom content</p></body></html>";
            mime_type_ = "text/html";
            status_code_ = 200;
            status_text_ = "OK";
            
            callback->Continue();
            return true;
        }
        
        return false;
    }
    
    void GetResponseHeaders(CefRefPtr<CefResponse> response,
                           int64& response_length,
                           CefString& redirectUrl) override {
        response->SetStatus(status_code_);
        response->SetStatusText(status_text_);
        response->SetMimeType(mime_type_);
        response_length = content_.length();
    }
    
    bool ReadResponse(void* data_out,
                     int bytes_to_read,
                     int& bytes_read,
                     CefRefPtr<CefCallback> callback) override {
        bool has_data = false;
        bytes_read = 0;
        
        if (offset_ < content_.length()) {
            int copy_size = std::min(bytes_to_read,
                                   static_cast<int>(content_.length() - offset_));
            memcpy(data_out, content_.c_str() + offset_, copy_size);
            offset_ += copy_size;
            bytes_read = copy_size;
            has_data = true;
        }
        
        return has_data;
    }
    
private:
    std::string content_;
    std::string mime_type_;
    int status_code_;
    std::string status_text_;
    size_t offset_;
    
    IMPLEMENT_REFCOUNTING(CustomSchemeHandler);
};

// 注册自定义协议
class CustomSchemeHandlerFactory : public CefSchemeHandlerFactory {
public:
    CefRefPtr<CefResourceHandler> Create(CefRefPtr<CefBrowser> browser,
                                        CefRefPtr<CefFrame> frame,
                                        const CefString& scheme_name,
                                        CefRefPtr<CefRequest> request) override {
        return new CustomSchemeHandler();
    }
    
private:
    IMPLEMENT_REFCOUNTING(CustomSchemeHandlerFactory);
};

// 在应用程序初始化时注册
void SimpleApp::OnRegisterCustomSchemes(CefRawPtr<CefSchemeRegistrar> registrar) {
    registrar->AddCustomScheme("custom", 
                              CEF_SCHEME_OPTION_STANDARD | 
                              CEF_SCHEME_OPTION_SECURE |
                              CEF_SCHEME_OPTION_CORS_ENABLED);
}

2. 文件下载处理

class DownloadHandler : public CefDownloadHandler {
public:
    void OnBeforeDownload(CefRefPtr<CefBrowser> browser,
                         CefRefPtr<CefDownloadItem> download_item,
                         const CefString& suggested_name,
                         CefRefPtr<CefBeforeDownloadCallback> callback) override {
        
        // 设置下载路径
        std::string download_path = "/Users/username/Downloads/" + 
                                  suggested_name.ToString();
        
        callback->Continue(download_path, false);
    }
    
    void OnDownloadUpdated(CefRefPtr<CefBrowser> browser,
                          CefRefPtr<CefDownloadItem> download_item,
                          CefRefPtr<CefDownloadItemCallback> callback) override {
        
        // 更新下载进度
        int percentage = download_item->GetPercentComplete();
        std::cout << "Download progress: " << percentage << "%" << std::endl;
        
        // 可以取消下载
        if (should_cancel_download_) {
            callback->Cancel();
        }
    }
    
private:
    bool should_cancel_download_ = false;
    IMPLEMENT_REFCOUNTING(DownloadHandler);
};

构建与部署

CMake构建配置

完整的CMakeLists.txt

cmake_minimum_required(VERSION 3.21)

project(MyCEFApp)

# 设置CEF路径
set(CEF_ROOT "/path/to/cef_binary_139.0.28+g55ab8a8+chromium-139.0.7258.139_macosarm64")
set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CEF_ROOT}/cmake")

# 查找CEF
find_package(CEF REQUIRED)

# 添加libcef_dll_wrapper
add_subdirectory(${CEF_LIBCEF_DLL_WRAPPER_PATH} libcef_dll_wrapper)

# 设置C++标准
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# 创建可执行文件
add_executable(MyCEFApp
    src/main.cpp
    src/app.cpp
    src/handler.cpp
    src/AppDelegate.mm
)

# 设置Objective-C++编译选项
set_source_files_properties(src/AppDelegate.mm PROPERTIES
    COMPILE_FLAGS "-x objective-c++"
)

# 链接库
target_link_libraries(MyCEFApp
    libcef_dll_wrapper
    ${CEF_STANDARD_LIBS}
    "-framework Cocoa"
    "-framework CoreFoundation"
    "-framework Security"
    "-framework SystemConfiguration"
)

# 设置包含目录
target_include_directories(MyCEFApp PRIVATE 
    ${CEF_INCLUDE_DIRS}
    src/
)

# 设置编译定义
target_compile_definitions(MyCEFApp PRIVATE
    $<$<CONFIG:Debug>:DEBUG>
    $<$<CONFIG:Release>:NDEBUG>
)

# 设置输出目录
set_target_properties(MyCEFApp PROPERTIES
    RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
    RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}/bin"
    RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/bin"
)

构建命令

# 创建构建目录
mkdir build && cd build

# 配置项目
cmake -G "Ninja" -DCMAKE_BUILD_TYPE=Debug ..

# 编译项目
ninja MyCEFApp

# 或者使用Xcode
cmake -G "Xcode" -DPROJECT_ARCH="arm64" ..
open MyCEFApp.xcodeproj

应用程序打包

macOS App Bundle结构

MyCEFApp.app/
├── Contents/
│   ├── Info.plist                    # 应用程序信息
│   ├── MacOS/
│   │   └── MyCEFApp                  # 主可执行文件
│   ├── Frameworks/
│   │   └── Chromium Embedded Framework.framework/
│   │       ├── Chromium Embedded Framework
│   │       ├── Libraries/
│   │       │   ├── libEGL.dylib
│   │       │   ├── libGLESv2.dylib
│   │       │   └── libvk_swiftshader.dylib
│   │       └── Resources/
│   │           ├── chrome_100_percent.pak
│   │           ├── chrome_200_percent.pak
│   │           ├── resources.pak
│   │           ├── icudtl.dat
│   │           └── v8_context_snapshot.bin
│   └── Resources/
│       └── MyCEFApp Helper.app/      # 辅助进程
│           ├── Contents/
│           │   ├── Info.plist
│           │   └── MacOS/
│           │       └── MyCEFApp Helper

自动化打包脚本

#!/bin/bash
# build_and_package.sh

set -e

# 构建配置
BUILD_TYPE=${1:-Debug}
APP_NAME="MyCEFApp"
CEF_ROOT="/path/to/cef_binary_139.0.28+g55ab8a8+chromium-139.0.7258.139_macosarm64"

# 创建构建目录
mkdir -p build
cd build

# 配置CMake
cmake -G "Ninja" -DCMAKE_BUILD_TYPE=$BUILD_TYPE ..

# 编译
ninja $APP_NAME

# 创建App Bundle
mkdir -p $APP_NAME.app/Contents/{MacOS,Frameworks,Resources}

# 复制可执行文件
cp bin/$APP_NAME $APP_NAME.app/Contents/MacOS/

# 复制CEF Framework
cp -R $CEF_ROOT/Release/Chromium\ Embedded\ Framework.framework \
      $APP_NAME.app/Contents/Frameworks/

# 复制Helper应用
cp -R $CEF_ROOT/Release/Chromium\ Embedded\ Framework.framework/Resources/MyCEFApp\ Helper.app \
      $APP_NAME.app/Contents/Resources/

# 创建Info.plist
cat > $APP_NAME.app/Contents/Info.plist << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleExecutable</key>
    <string>$APP_NAME</string>
    <key>CFBundleIdentifier</key>
    <string>com.example.$APP_NAME</string>
    <key>CFBundleName</key>
    <string>$APP_NAME</string>
    <key>CFBundleVersion</key>
    <string>1.0.0</string>
    <key>CFBundleShortVersionString</key>
    <string>1.0.0</string>
    <key>CFBundlePackageType</key>
    <string>APPL</string>
    <key>LSMinimumSystemVersion</key>
    <string>12.0</string>
</dict>
</plist>
EOF

echo "Build completed successfully!"
echo "App bundle created at: $(pwd)/$APP_NAME.app"

性能优化与最佳实践

内存管理

1. 智能指针使用

// 使用CefRefPtr进行自动引用计数管理
class MyHandler : public CefClient {
public:
    // 正确:使用CefRefPtr管理浏览器对象
    void OnAfterCreated(CefRefPtr<CefBrowser> browser) override {
        browser_list_.push_back(browser);  // 自动增加引用计数
    }
    
    void OnBeforeClose(CefRefPtr<CefBrowser> browser) override {
        browser_list_.remove(browser);     // 自动减少引用计数
    }
    
private:
    std::list<CefRefPtr<CefBrowser>> browser_list_;  // 使用CefRefPtr容器
    
    IMPLEMENT_REFCOUNTING(MyHandler);  // 必须实现引用计数
};

2. 对象生命周期管理

// 避免循环引用
class ParentHandler : public CefClient {
public:
    void SetChild(CefRefPtr<ChildHandler> child) {
        child_ = child;
        child->SetParent(this);  // 使用弱引用避免循环
    }
    
private:
    CefRefPtr<ChildHandler> child_;
    IMPLEMENT_REFCOUNTING(ParentHandler);
};

class ChildHandler : public CefClient {
public:
    void SetParent(ParentHandler* parent) {
        parent_ = parent;  // 使用原始指针避免循环引用
    }
    
private:
    ParentHandler* parent_;  // 弱引用
    IMPLEMENT_REFCOUNTING(ChildHandler);
};

线程安全

1. 线程模型理解

class ThreadSafeHandler : public CefClient {
public:
    // 这些方法在UI线程中调用
    void OnAfterCreated(CefRefPtr<CefBrowser> browser) override {
        // UI线程操作
        browser_list_.push_back(browser);
    }
    
    // 从其他线程调用时需要切换到UI线程
    void CreateBrowserFromOtherThread() {
        if (!CefCurrentlyOn(TID_UI)) {
            // 切换到UI线程执行
            CefPostTask(TID_UI, base::BindOnce(&ThreadSafeHandler::DoCreateBrowser, this));
            return;
        }
        DoCreateBrowser();
    }
    
private:
    void DoCreateBrowser() {
        // 在UI线程中执行浏览器创建
        // ...
    }
    
    std::list<CefRefPtr<CefBrowser>> browser_list_;
    IMPLEMENT_REFCOUNTING(ThreadSafeHandler);
};

2. 跨线程通信

// 使用CefPostTask进行跨线程调用
void SendMessageToUIThread(const std::string& message) {
    CefPostTask(TID_UI, base::BindOnce([](const std::string& msg) {
        // 在UI线程中执行
        std::cout << "UI Thread received: " << msg << std::endl;
    }, message));
}

// 使用CefPostDelayedTask延迟执行
void DelayedExecution() {
    CefPostDelayedTask(TID_UI, base::BindOnce([]() {
        std::cout << "This executes after 5 seconds" << std::endl;
    }), 5000);  // 5秒延迟
}

性能优化技巧

1. 浏览器设置优化

void OptimizeBrowserSettings(CefBrowserSettings& settings) {
    // 禁用不需要的功能
    settings.plugins = STATE_DISABLED;           // 禁用插件
    settings.javascript_close_windows = STATE_DISABLED;  // 禁用JS关闭窗口
    
    // 优化JavaScript性能
    settings.javascript = STATE_ENABLED;
    settings.javascript_open_windows = STATE_DISABLED;
    
    // 优化渲染性能
    settings.windowless_frame_rate = 30;         // 限制帧率
    settings.background_color = 0xFFFFFFFF;      // 设置背景色
    
    // 禁用开发者工具(生产环境)
    #ifndef DEBUG
    settings.devtools_open = false;
    #endif
}

2. 资源加载优化

class ResourceOptimizedHandler : public CefClient, public CefRequestHandler {
public:
    // 拦截资源请求进行优化
    CefRefPtr<CefResourceRequestHandler> GetResourceRequestHandler(
        CefRefPtr<CefBrowser> browser,
        CefRefPtr<CefFrame> frame,
        CefRefPtr<CefRequest> request,
        bool is_navigation,
        bool is_download,
        const CefString& request_initiator,
        bool& disable_default_handling) override {
        
        // 对于特定类型的资源,可以返回自定义处理器
        std::string url = request->GetURL().ToString();
        
        if (url.find(".jpg") != std::string::npos || 
            url.find(".png") != std::string::npos) {
            // 对图片资源使用自定义处理
            return new OptimizedImageHandler();
        }
        
        return nullptr;  // 使用默认处理
    }
    
private:
    IMPLEMENT_REFCOUNTING(ResourceOptimizedHandler);
};

最佳实践总结

1. 代码组织

// 良好的代码组织结构
namespace MyApp {
    // 应用程序级别
    class App : public CefApp, public CefBrowserProcessHandler {
        // 应用程序逻辑
    };
    
    // 浏览器级别
    class BrowserHandler : public CefClient {
        // 浏览器相关逻辑
    };
    
    // 功能模块
    namespace Network {
        class RequestHandler : public CefRequestHandler {
            // 网络请求处理
        };
    }
    
    namespace UI {
        class DisplayHandler : public CefDisplayHandler {
            // 显示相关处理
        };
    }
}

2. 错误处理

class RobustHandler : public CefClient {
public:
    void OnLoadError(CefRefPtr<CefBrowser> browser,
                     CefRefPtr<CefFrame> frame,
                     ErrorCode errorCode,
                     const CefString& errorText,
                     const CefString& failedUrl) override {
        
        // 记录错误信息
        LOG(ERROR) << "Load error: " << errorCode 
                   << " - " << errorText.ToString()
                   << " - URL: " << failedUrl.ToString();
        
        // 根据错误类型采取不同处理
        switch (errorCode) {
            case ERR_INTERNET_DISCONNECTED:
                ShowOfflinePage(frame);
                break;
            case ERR_CONNECTION_TIMED_OUT:
                ShowTimeoutPage(frame);
                break;
            default:
                ShowGenericErrorPage(frame, errorCode, errorText);
                break;
        }
    }
    
private:
    void ShowOfflinePage(CefRefPtr<CefFrame> frame) {
        std::string offline_html = R"(
            <html>
            <body>
                <h1>You're offline</h1>
                <p>Please check your internet connection and try again.</p>
                <button onclick="window.location.reload()">Retry</button>
            </body>
            </html>
        )";
        frame->LoadString(offline_html, frame->GetURL());
    }
    
    IMPLEMENT_REFCOUNTING(RobustHandler);
};

常见问题与解决方案

编译问题

1. 头文件包含问题

// 正确的头文件包含顺序
#include "include/cef_base.h"           // 基础类型
#include "include/cef_app.h"            // 应用程序
#include "include/cef_browser.h"        // 浏览器
#include "include/cef_client.h"         // 客户端

// 避免的包含方式
// #include "include/cef_*.h"  // 不要使用通配符

2. 链接错误

# 正确的链接配置
target_link_libraries(MyCEFApp
    libcef_dll_wrapper
    ${CEF_STANDARD_LIBS}  # 必须包含CEF标准库
    "-framework Cocoa"    # macOS必需框架
    "-framework Security"
    "-framework SystemConfiguration"
)

# 设置正确的包含目录
target_include_directories(MyCEFApp PRIVATE 
    ${CEF_INCLUDE_DIRS}  # CEF头文件目录
)

运行时问题

1. 沙箱问题

// 开发环境禁用沙箱
CefSettings settings;
settings.no_sandbox = true;  // 仅在开发环境使用

// 生产环境沙箱配置
#ifdef PRODUCTION
settings.no_sandbox = false;
settings.log_severity = LOGSEVERITY_WARNING;  // 减少日志输出
#endif

2. 内存泄漏

// 使用RAII管理资源
class ResourceManager {
public:
    ResourceManager() {
        // 构造函数中获取资源
    }
    
    ~ResourceManager() {
        // 析构函数中释放资源
        Cleanup();
    }
    
private:
    void Cleanup() {
        // 清理逻辑
    }
};

// 使用智能指针
std::unique_ptr<ResourceManager> manager = 
    std::make_unique<ResourceManager>();

3. 线程问题

// 检查当前线程
void ThreadSafeFunction() {
    if (!CefCurrentlyOn(TID_UI)) {
        // 切换到UI线程
        CefPostTask(TID_UI, base::BindOnce(&ThreadSafeFunction));
        return;
    }
    
    // 在UI线程中执行
    // ...
}

// 使用线程本地存储
thread_local std::unique_ptr<LocalData> local_data;

调试技巧

1. 日志配置

// 配置详细的日志输出
CefSettings settings;
settings.log_severity = LOGSEVERITY_VERBOSE;
settings.log_file = "cef_debug.log";

// 在代码中添加日志
LOG(INFO) << "Browser created: " << browser->GetIdentifier();
LOG(ERROR) << "Load error: " << errorText.ToString();

2. 开发者工具

// 启用开发者工具(仅调试时)
#ifdef DEBUG
void ShowDevTools(CefRefPtr<CefBrowser> browser) {
    CefWindowInfo windowInfo;
    CefBrowserSettings browserSettings;
    
    browser->GetHost()->ShowDevTools(
        windowInfo, 
        browser->GetHost()->GetClient(), 
        browserSettings, 
        CefPoint()
    );
}
#endif

3. 内存调试

// 使用AddressSanitizer检测内存问题
// 编译时添加:-fsanitize=address

// 定期检查内存使用
void CheckMemoryUsage() {
    size_t memory_usage = GetMemoryUsage();
    if (memory_usage > MEMORY_THRESHOLD) {
        LOG(WARNING) << "High memory usage: " << memory_usage << " bytes";
        // 触发垃圾回收或其他清理操作
    }
}

性能问题

1. 渲染性能

// 优化渲染设置
CefBrowserSettings settings;
settings.windowless_frame_rate = 30;      // 限制帧率
settings.background_color = 0xFFFFFFFF;   // 设置背景色

// 使用硬件加速
CefSettings app_settings;
app_settings.windowless_rendering_enabled = true;  // 启用离屏渲染

2. 网络性能

// 配置网络缓存
CefSettings settings;
settings.cache_path = "/path/to/cache";           // 设置缓存路径
settings.persist_session_cookies = true;          // 持久化会话Cookie
settings.persist_user_preferences = true;         // 持久化用户偏好

// 优化资源加载
class PerformanceHandler : public CefResourceRequestHandler {
public:
    CefRefPtr<CefResourceHandler> GetResourceHandler(
        CefRefPtr<CefBrowser> browser,
        CefRefPtr<CefFrame> frame,
        CefRefPtr<CefRequest> request) override {
        
        // 对静态资源使用缓存
        std::string url = request->GetURL().ToString();
        if (IsStaticResource(url)) {
            return new CachedResourceHandler(url);
        }
        
        return nullptr;  // 使用默认处理
    }
    
private:
    bool IsStaticResource(const std::string& url) {
        return url.find(".css") != std::string::npos ||
               url.find(".js") != std::string::npos ||
               url.find(".png") != std::string::npos ||
               url.find(".jpg") != std::string::npos;
    }
    
    IMPLEMENT_REFCOUNTING(PerformanceHandler);
};

总结

CEF框架为开发者提供了一个强大而灵活的解决方案,用于在原生应用程序中嵌入现代Web浏览器功能。通过本文的详细分析,我们了解了:

  1. 架构设计:CEF采用多进程架构,通过清晰的接口分离关注点
  2. 开发流程:从环境配置到应用开发的完整流程
  3. 核心API:关键接口的使用方法和最佳实践
  4. 性能优化:内存管理、线程安全和性能调优技巧
  5. 问题解决:常见问题的诊断和解决方案

CEF框架的灵活性使其适用于各种应用场景,从简单的嵌入式浏览器控件到复杂的混合应用程序。通过合理使用CEF提供的API和遵循最佳实践,开发者可以创建高性能、稳定可靠的应用程序。

随着Web技术的不断发展,CEF框架也在持续演进,为开发者提供最新的Web标准支持和性能优化。掌握CEF框架的开发技能,将为现代应用程序开发打开新的可能性。


本文基于CEF 139.0.28版本编写,具体API可能会随版本更新而变化,请参考官方文档获取最新信息。

【uniapp】小程序端实现分包异步化

背景

微信小程序、支付宝小程序、抖音小程序等支持 占位组件,但是 uniapp 只支持在 pages.json 下的页面中配置 componentPlaceholder, 并不支持在某个具体的组件中添加此配置,于是我写了 github.com/chouchouji/… 解决此问题。

特性

  • 配置简单,开箱即用
  • 支持 vue、nvue、uvue 三种文件
  • 支持 uni-app 和 uni-app-x 项目
  • 支持 组合式 和 选项式 两种写法

使用指南

安装

// npm
npm i @binbinji/vite-plugin-component-placeholder -D

// yarn
yarn add @binbinji/vite-plugin-component-placeholder -D

// pnpm
pnpm add @binbinji/vite-plugin-component-placeholder -D

引入插件

// vite.config.*
import componentPlaceholderPlugin from '@binbinji/vite-plugin-component-placeholder'
import uni from '@dcloudio/vite-plugin-uni'
import { defineConfig } from 'vite'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [uni(), componentPlaceholderPlugin()],
})

在 vue/nuve/uvue 文件中添加配置

vue 相关文件中添加 componentPlaceholder 配置

组合式文件
<template>
  <view>test page</view>
  <test></test>
</template>

<script setup>
import test from './test.vue'

defineOptions({
  componentPlaceholder: {
    test: 'view',
  },
})
</script>

image.png

选项式文件
<template>
  <view>test page</view>
  <test></test>
</template>

<script>
import test from './test.vue'

export default {
  components: {
    test,
  },
  componentPlaceholder: {
    test: 'view',
  },
}
</script>

image.png

前端流式解析chunk数据思路

前端流式解析chunk数据相关面试题答案

1. 后端 chunked 传输的 HTTP 协议规范、响应头与数据格式

(1)HTTP 协议规范要求

后端实现 chunked 传输需遵循 HTTP/1.1 协议,核心是通过特定响应头告知前端 “数据将分块传输”,且每块数据需包含 “长度标识 + 数据内容”,最后以 “0\r\n\r\n” 标识传输结束。

(2)关键响应头字段
  • Transfer-Encoding: chunked:必选字段,明确告知前端当前响应采用分块传输,替代传统的Content-Length(分块传输时无需指定总长度)。

  • Content-Type:可选但建议设置,如application/json; charset=utf-8,告知前端分块数据的业务格式,避免解析时格式混淆。

  • 其他可选头:如Cache-Control(控制缓存)、Connection(保持连接)等,按业务需求添加。

(3)chunk 数据格式要求

每块 chunk 由 “长度行” 和 “数据行” 组成,格式如下:

  1. 长度行:16 进制数字(表示当前 chunk 数据的字节数) + \r\n

  2. 数据行:对应长度的二进制 / 文本数据 + \r\n

  3. 结束标识:最后一块 chunk 为 “0\r\n\r\n”(0 表示无数据,连续两个\r\n表示传输终止)。

(4)完整示例(以 JSON 业务数据为例)
HTTP/1.1 200 OK

Transfer-Encoding: chunked

Content-Type: application/json; charset=utf-8

a\r\n          // 16进制a=10,当前chunk数据占10字节

{"id": 1,"\r\n  // 10字节数据(注意未闭合的JSON结构)

7\r\n          // 16进制7=7,当前chunk数据占7字节

title":"test"}\r\n  // 7字节数据(补全JSON结构)

0\r\n          // 结束标识:无数据

\r\n           // 终止符

2. 前端获取流对象、Reader.read () 返回结构与代码示例

(1)获取可读取流对象的 API

通过fetch()API 发起请求时,默认返回的Response对象包含body属性,其类型为ReadableStream(可读取流),这是前端流式处理的核心入口。

注:XMLHttpRequest也可实现流式处理(需监听progress事件),但fetch()+ReadableStream是现代前端更推荐的方案,API 更简洁、原生支持流操作。

(2)Reader.read () 返回结果结构

创建ReadableStreamDefaultReader后,调用read()方法会返回一个Promise,该 Promise resolved 后的结果是一个对象,包含两个核心属性:

  • done:布尔值,true表示流已全部读取完毕,false表示仍有数据可读取;

  • value:当前读取到的数据块,类型为Uint8Array(二进制数据),若done=true,则valueundefined

(3)完整代码示例(从获取流到首次读数据)
async function streamParse() {

  try {

    // 1. 发起fetch请求,获取包含ReadableStream的Response对象

    const response = await fetch('/api/chunked-data', {

      method: 'GET',

      headers: { 'Accept': 'application/json' }

    });

    // 2. 检查响应是否支持流(排除错误状态或不支持流的情况)

    if (!response.ok || !response.body) {

      throw new Error('请求失败或浏览器不支持流式处理');

    }

    // 3. 创建ReadableStreamDefaultReader(获取流的读取器)

    const reader = response.body.getReader();

    // 4. 创建TextDecoder(用于将二进制数据解码为UTF-8文本)

    const decoder = new TextDecoder('utf-8');

    // 5. 首次读取数据块

    const firstChunk = await reader.read();

    if (firstChunk.done) {

      console.log('流已无数据可读取');

      return;

    }

    // 6. 解码并处理首次读取的数据

    const firstChunkText = decoder.decode(firstChunk.value, { stream: true });

    console.log('首次读取的文本数据:', firstChunkText);

    console.log('是否还有数据:', !firstChunk.done); // 输出false(仍有数据)

  } catch (error) {

    console.error('流式解析异常:', error.message);

  }

}

streamParse();

3. 流式解析中判断完整题目 / 图表 JSON 结构的逻辑设计

(1)核心判断思路

需结合 “业务数据结构约定” 和 “JSON 语法规则” 双重校验,避免因 JSON 嵌套、特殊字符(如字符串中的{/})导致误判。

前提:需与后端约定题目 / 图表的 JSON 节点结构,例如:

  • 题目节点:{"question": {...}}question为固定 key,值为对象,包含idcontent等必选字段);

  • 图表节点:{"chart": {...}}chart为固定 key,值为对象,包含typedata等必选字段)。

(2)需规避的 JSON 语法陷阱
  1. 嵌套结构:如{"question": {"options": [{"label": "A"}, ...]}},需确保读取到的片段包含完整的嵌套对象,而非部分嵌套;

  2. 字符串特殊字符:如{"question": "请解释{JSON}的作用"},字符串中的{/}不是 JSON 结构的一部分,需排除其对结构判断的干扰;

  3. 数组结构:如{"questions": [{"id":1}, {"id":2}]},需判断数组内单个题目对象是否完整,而非整个数组。

(3)具体判断逻辑方案(以题目节点为例)
/**

* 校验当前JSON片段是否包含完整的题目节点

* @param {string} jsonFragment - 拼接后的JSON文本片段

* @returns {Object|null} - 完整题目对象(若存在),否则返回null

*/

function checkCompleteQuestion(jsonFragment) {

// 1. 提取所有包含"question" key的片段(基于业务约定)

    const questionReg = /"question"\s*:\s*\{([^}]+)\}/g;

    const matches = [...jsonFragment.matchAll(questionReg)];

    for (const match of matches) {

    // 2. 还原完整的题目对象JSON字符串(补全外层{})

        const questionJson = `{"question": {${match[1]}}}`;

        try {

            // 3. 尝试JSON.parse(核心校验:若能成功解析,说明结构完整)

            const parsed = JSON.parse(questionJson);

            // 4. 业务字段校验(确保包含必选字段,避免空对象或无效数据)

            if (parsed.question && parsed.question.id && parsed.question.content) {

            return parsed.question; // 返回完整题目对象

        }

        } catch (e) {

            // 解析失败:说明当前"question"片段不完整(如缺少闭合}),跳过

            continue;

        }

    }

    return null; // 无完整题目节点

}
(4)方案合理性说明
  • 基于JSON.parse()校验:利用 JSON 语法的严格性,避免手动写复杂正则判断嵌套结构,减少误判概率;

  • 结合业务字段校验:排除 “结构完整但无有效业务数据” 的情况(如{"question": {}}),确保渲染的数据有效;

  • 分步提取与校验:先通过正则定位目标节点,再单独校验每个节点,避免整个片段解析失败导致所有数据无法处理。

4. TextDecoder 处理多字节字符分割的机制与解决方案

(1)TextDecoder 自身的应对机制

TextDecoder 原生支持多字节字符的跨块拼接,核心原理是通过decode()方法的stream参数(布尔值)实现 “状态保留”:

  • 当调用decoder.decode(chunk, { stream: true })时,TextDecoder 会自动检测当前 chunk 的最后一个字符是否为 “不完整的多字节字符”(如 UTF-8 中中文占 3 字节,若 chunk 末尾仅包含 1 字节);

  • 若检测到不完整字符,TextDecoder 会暂存该部分字节,待下一个 chunk 传入时,先将暂存的字节与新 chunk 的字节拼接,再进行解码;

  • 当流读取完毕(最后一次调用decode()时),需将stream设为false(默认值),此时 TextDecoder 会对剩余暂存字节进行 “补零处理”(若仍不完整则视为无效字符,替换为�)。

(2)代码示例(验证多字节字符解码完整性)
async function decodeMultiByteChar() {

  const reader = response.body.getReader();

  const decoder = new TextDecoder('utf-8');

  let remainingText = '';

  while (true) {

    const { done, value } = await reader.read();

    if (done) {

      // 最后一次解码:stream=false,处理剩余暂存字节

      remainingText += decoder.decode();

      console.log('最终完整文本:', remainingText);

      break;

    }

    // 非最后一次解码:stream=true,保留不完整字符状态

    remainingText += decoder.decode(value, { stream: true });

    console.log('当前拼接文本:', remainingText);

  }

}
(3)特殊情况处理(若 TextDecoder 无法覆盖)

若因浏览器兼容性问题(如极旧浏览器不支持stream参数)导致解码乱码,可手动实现 “字节缓存” 逻辑:

async function manualDecodeMultiByteChar() {

  const reader = response.body.getReader();

  const decoder = new TextDecoder('utf-8');

  let byteBuffer = new Uint8Array(0); // 缓存不完整字节的缓冲区

  while (true) {

    const { done, value } = await reader.read();

    if (done) {

      // 处理最后剩余的字节

      const finalText = decoder.decode(byteBuffer);

      console.log('最终完整文本:', finalText);

      break;

    }

    // 拼接历史缓存字节与当前新字节

    const combinedBytes = new Uint8Array(byteBuffer.length + value.length);

    combinedBytes.set(byteBuffer, 0);

    combinedBytes.set(value, byteBuffer.length);

    // 尝试解码:若解码后末尾有�(无效字符),说明存在不完整字节

    const decodedText = decoder.decode(combinedBytes, { stream: false });

    if (decodedText.endsWith('�')) {

      // 计算不完整字节数(UTF-8中中文占3字节,可通过字节范围判断)

      const invalidByteCount = getInvalidByteCount(combinedBytes);

      // 缓存不完整字节,下次拼接

      byteBuffer = combinedBytes.slice(combinedBytes.length - invalidByteCount);

      // 截取有效文本(排除末尾的�)

      remainingText += decodedText.slice(0, -1);

    } else {

      // 无无效字符,全部解码

      remainingText += decodedText;

      byteBuffer = new Uint8Array(0); // 清空缓存

    }

  }

  // 辅助函数:判断UTF-8字节序列中不完整字节的数量

  function getInvalidByteCount(bytes) {

    const lastByte = bytes\[bytes.length - 1];

    if ((lastByte & 0b10000000) === 0) return 0; // 单字节字符(完整)

    if ((lastByte & 0b11100000) === 0b11000000) return 1; // 双字节字符,缺1字节

    if ((lastByte & 0b11110000) === 0b11100000) return 2; // 三字节字符,缺2字节

    if ((lastByte & 0b11111000) === 0b11110000) return 3; // 四字节字符,缺3字节

    return 0;

  }

}

5. 流式解析中断的异常处理(Reader 状态、Promise 机制与代码示例)

(1)后端中断传输时 Reader.read () 的状态

当后端中断传输(如网络断开、服务器 500 错误),reader.read()返回的 Promise 会rejected,而非 resolved 为{done: true, value: undefined}

rejected 的错误对象通常包含name(如NetworkError)和message(如 “Failed to fetch”),可通过错误类型判断中断原因。

(2)异常捕获与优雅处理逻辑

需从三个层面处理异常:

  1. Promise.reject 捕获:通过try/catch包裹reader.read(),捕获网络或服务器错误;

  2. 流的释放:异常发生后调用reader.releaseLock()释放流锁,避免内存泄漏;

  3. 用户体验与数据恢复:提示用户异常、清理已解析的临时数据、提供重试入口。

(3)关键代码片段
async function streamWithErrorHandle() {

  let reader;

  let tempJson = ''; // 暂存已解析的JSON片段

  const decoder = new TextDecoder('utf-8');

  try {

    const response = await fetch('/api/chunked-data');

    if (!response.ok) {

      // 处理HTTP错误(如404、500),此时response.body仍可能存在,需手动终止

      throw new Error(`HTTP错误:${response.status} ${response.statusText}`);

    }

    reader = response.body.getReader();

    while (true) {

      // 捕获read()的Promise reject异常

      const { done, value } = await reader.read();

      if (done) {

        console.log('流式解析完成');

        return;

      }

      // 正常解析逻辑

      const chunkText = decoder.decode(value, { stream: true });

      tempJson += chunkText;

      // 假设存在renderPartialData函数,用于渲染已完整的节点

      renderPartialData(tempJson);

    }

  } catch (error) {

    console.error('流式解析中断:', error.message);

    // 1. 释放流锁(避免内存泄漏)

    if (reader && !reader.closed) {

      await reader.releaseLock();

    }

    // 2. 清理临时数据(避免下次解析时数据污染)

    tempJson = '';

    // 3. 用户提示与重试入口

    const userConfirm = confirm(`解析失败:${error.message},是否重试?`);

    if (userConfirm) {

      streamWithErrorHandle(); // 重试(可添加重试次数限制,避免无限循环)

    }

  }

}

6. JSON 片段语法校验方案对比(正则、轻量库、自定义解析器)

(1)方案 1:正则表达式校验
  • 原理:通过正则匹配 JSON 的核心语法规则(如引号闭合、括号配对、逗号位置等),判断片段是否符合 “可 parse 的子集”。

  • 示例正则(简化版)

const jsonValidReg = /^[\],:{}\s]*$/.test(

    jsonFragment.replace(/\\["\\\/bfnrtu]/g, '@') // 替换转义字符

    .replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?/g, ']') // 替换字符串、布尔值、数字、null为]

    .replace(/(?:^|:|,)(?:\s*\[)+/g, '') // 移除数组起始标记

);
  • 优点:无依赖、体积小、校验速度快(适合简单场景);

  • 缺点:无法处理复杂嵌套结构(如多层对象 / 数组)、易误判(如字符串中的特殊字符)、正则维护成本高;

  • 适用场景:校验结构简单的 JSON 片段(如单层对象)、对性能要求极高且可接受一定误判率的场景。

(2)方案 2:轻量级 JSON 解析库(如jsonlint-lightfast-json-parse
  • 原理:基于有限状态机(FSM)实现简化的 JSON 解析逻辑,仅校验语法合法性,不完整片段直接返回错误。

  • 示例(使用fast-json-parse

import fastJsonParse from 'fast-json-parse';

function validateJsonFragment(jsonFragment) {

  const result = fastJsonParse(jsonFragment);

  return !result.error; // 无error则说明语法合法

}
  • 优点:校验准确率高(支持嵌套结构)、API 简单、体积小(如 `fast-json-parse

仅 1.5KB)、无多余功能(专注校验);

  • 缺点:需引入第三方依赖(增加项目体积,虽小但需维护)、极端场景下仍可能存在解析误差(如非标准 JSON 格式);

  • 适用场景:大多数流式解析场景(如题目 / 图表分段渲染)、对校验准确率有要求且可接受轻量依赖的项目。

(3)方案 3:自定义 JSON 语法分析器
  • 原理:基于 JSON 语法规则(ECMA-404 标准)实现手动解析,通过 “状态追踪” 判断片段完整性(如追踪括号配对数、引号闭合状态)。

  • 核心逻辑示例(简化版)

function customJsonValidator(jsonFragment) {

    let inString = false; // 是否处于字符串内部(字符串中的特殊字符不参与语法判断)

    let braceCount = 0; // 大括号配对计数({: +1, }: -1)

    let bracketCount = 0; // 中括号配对计数([: +1, ]: -1)

    let escapeMode = false; // 是否处于转义状态(如\"中的\)

    for (const char of jsonFragment) {

        if (escapeMode) {

            escapeMode = false;

            continue;

        }

        if (char === '\\') {

            escapeMode = true;

            continue;

        }

        if (char === '"') {

            inString = !inString;

            continue;

        }

        // 仅在非字符串内部时,才判断括号配对

        if (!inString) {

            switch (char) {

            case '{': braceCount++; break;

            case '}': braceCount--; break;

            case '[': bracketCount++; break;

            case ']': bracketCount--; break;

            }

            // 若括号计数为负(如先出现}再出现{),直接判定不合法

            if (braceCount < 0 || bracketCount < 0) return false;

        }

    }

    // 合法条件:括号完全配对 + 字符串闭合 + 无转义残留

    return braceCount === 0 && bracketCount === 0 && !inString && !escapeMode;

}
  • 优点:完全可控(可根据业务需求定制规则)、无依赖、准确率高(可覆盖复杂嵌套场景);

  • 缺点:开发成本高(需完整实现 JSON 语法规则)、维护难度大(需适配边缘场景,如 Unicode 转义);

  • 适用场景:对 JSON 格式有特殊定制需求(如自定义扩展语法)、不允许引入第三方依赖的严格场景。

(4)方案对比总结
校验方案 准确率 开发成本 依赖情况 适用场景
正则表达式 简单单层 JSON、性能优先场景
轻量级库 轻量依赖 大多数标准流式解析场景
自定义解析器 定制化语法、无依赖严格场景

7. 流式解析中题目 / 图表节点识别与分段渲染策略

(1)目标节点识别逻辑

需结合 “JSON 路径约定” 和 “业务字段特征” 实现精准识别,核心步骤如下:

  1. 约定 JSON 路径:与后端提前定义目标节点的固定路径,例如:
  • 题目节点路径:data.items[*].questiondata为根节点,items为数组,每个元素包含question对象);

  • 图表节点路径:data.items[*].chart(同理,chart为图表对象)。

  1. 流式路径追踪:在拼接 JSON 片段时,通过 “有限状态机” 追踪当前解析到的 JSON 路径,例如:
  • 当解析到"data": {时,进入data节点;

  • 当解析到"items": [时,进入data.items数组;

  • 当数组内出现"question": {时,标记为目标节点开始。

  1. 字段特征校验:识别到目标节点后,校验必选业务字段(如question.idchart.type),排除无效节点(如空对象)。
(2)渲染策略对比与优化
策略 1:立即暂停流读取,渲染完成后继续
  • 核心逻辑:识别到完整目标节点后,调用reader.releaseLock()暂停流读取,执行渲染逻辑(如 DOM 挂载、图表初始化),渲染完成后重新获取流读取器继续读取。

  • 代码片段

async function streamWithPauseRender() {

  let reader = response.body.getReader();

  const decoder = new TextDecoder('utf-8');

  let tempJson = '';

  while (true) {

    const { done, value } = await reader.read();

    if (done) break;

    tempJson += decoder.decode(value, { stream: true });

    // 检查是否包含完整题目节点

    const completeQuestion = checkCompleteQuestion(tempJson); // 复用第3题的校验函数

    if (completeQuestion) {

      // 1. 暂停流读取(释放锁)

      await reader.releaseLock();

      // 2. 渲染题目(同步/异步操作)

      await renderQuestion(completeQuestion); // 假设为异步渲染函数(如初始化富文本)

      // 3. 重新获取流读取器,继续读取

      reader = response.body.getReader();

      // 4. 清空已渲染的片段(避免重复处理)

      tempJson = tempJson.replace(`"question": ${JSON.stringify(completeQuestion)}`, '');

    }

  }

}
  • 优点:避免渲染与流读取的资源竞争(如 CPU、内存),渲染过程更稳定;

  • 缺点:流读取会中断,增加整体数据处理时间(尤其渲染耗时较长时)、用户可能感知到 “数据加载卡顿”。

  • 优化方向

    • 对渲染任务进行 “优先级分级”:简单 DOM 渲染(如文本)可快速执行,复杂渲染(如 ECharts 图表)可先展示加载态,后台异步执行;

    • 记录流暂停位置:通过response.bodycancel()+clone() API,实现断点续读(避免重新读取已处理数据)。

策略 2:继续读取流,并行执行渲染
  • 核心逻辑:识别到完整目标节点后,不暂停流读取,而是将渲染任务放入 “微任务队列” 或 “Web Worker”,与流读取并行执行。

  • 代码片段

async function streamWithParallelRender() {

  const reader = response.body.getReader();

  const decoder = new TextDecoder('utf-8');

  let tempJson = '';

  while (true) {

    const { done, value } = await reader.read();

    if (done) break;

    tempJson += decoder.decode(value, { stream: true });

    const completeQuestion = checkCompleteQuestion(tempJson);

    if (completeQuestion) {

      // 1. 复制目标节点数据(避免后续拼接污染)

      const questionCopy = JSON.parse(JSON.stringify(completeQuestion));

      // 2. 并行渲染(放入微任务,不阻塞流读取)

      queueMicrotask(() => renderQuestion(questionCopy));

      // 3. 清理已处理片段

      tempJson = tempJson.replace(`"question": ${JSON.stringify(completeQuestion)}`, '');

    }

  }

}
  • 优点:流读取不中断,整体处理效率高、用户感知到 “数据实时加载”(体验更流畅);

  • 缺点:并行任务可能导致资源竞争(如主线程 CPU 占用过高)、复杂渲染可能出现 “卡顿”(如图表动画与流解析抢占主线程)。

  • 优化方向

    • 使用 Web Worker:将 JSON 解析与渲染任务放入 Worker,避免阻塞主线程(需注意 Worker 与主线程的通信开销);

    • 任务节流:通过requestIdleCallback,在主线程空闲时执行渲染任务,减少卡顿。

(3)最终推荐策略
  • 若业务以 “快速展示” 为核心(如考试系统、实时报表):优先选择 “并行渲染 + Web Worker”,兼顾效率与流畅度;

  • 若业务对 “渲染稳定性” 要求极高(如数据可视化大屏):选择 “暂停流 + 分级渲染”,避免渲染异常影响整体流程。

8. 流式解析 API 的浏览器兼容性处理(降级、Polyfill、版本检测)

(1)核心 API 兼容性现状
  • ReadableStream:支持 Chrome 52+、Firefox 65+、Edge 79+,不支持 IE(全版本)、Safari 10-(11 + 部分支持);

  • TextDecoder:支持 Chrome 38+、Firefox 19+、Edge 12+,不支持 IE(需 Polyfill);

  • fetch():支持 Chrome 42+、Firefox 39+、Edge 14+,不支持 IE(需用 XHR 降级)。

(2)降级方案设计(核心思路:“渐进式增强”)
一级降级:不支持 ReadableStream,但支持 fetch ()
  • 逻辑:使用fetch()获取完整响应数据(不启用流式解析),待数据全部下载后,再进行 JSON 解析与分段渲染。

  • 代码片段

async function fetchWithFallback() {

  try {

    const response = await fetch('/api/chunked-data');

    if (!response.ok) throw new Error('请求失败');

    // 检测是否支持ReadableStream

    if (window.ReadableStream && response.body) {

      // 支持:启用流式解析

      await streamParse(response); // 调用第2题的流式解析函数

    } else {

      // 不支持:下载完整数据后解析

      const fullData = await response.json();

      // 模拟分段渲染(按业务逻辑拆分数据)

      fullData.data.items.forEach(item => {

        if (item.question) renderQuestion(item.question);

        if (item.chart) renderChart(item.chart);

      });

    }

  } catch (error) {

    console.error('处理失败:', error);

  }

}
二级降级:不支持 fetch ()(如 IE)
  • 逻辑:使用XMLHttpRequest替代 fetch (),通过onload事件获取完整数据,后续处理与一级降级一致。

  • 代码片段

function xhrFallback() {

  const xhr = new XMLHttpRequest();

  xhr.open('GET', '/api/chunked-data', true);

  xhr.responseType = 'json';

  xhr.onload = function() {

    if (xhr.status >= 200 && xhr.status < 300) {

      const fullData = xhr.response;

      fullData.data.items.forEach(item => {

        if (item.question) renderQuestion(item.question);

        if (item.chart) renderChart(item.chart);

      });

    } else {

      console.error('XHR请求失败:', xhr.statusText);

    }

  };

  xhr.onerror = function() {

    console.error('XHR网络错误');

  };

  xhr.send();

}
(3)Polyfill 选择与使用
推荐 Polyfill 库及理由
API 推荐 Polyfill 库 体积 优点
ReadableStream web-streams-polyfill ~15KB 完全遵循 WHATWG 标准、支持所有流操作
TextDecoder text-encoding ~8KB 支持 UTF-8/GBK 等多编码、兼容 IE
fetch() whatwg-fetch ~5KB 最小化实现 fetch 标准、无多余依赖
Polyfill 引入策略(“按需加载”)
  • 方式 1:条件引入(HTML 中)
<!-- 检测ReadableStream,不支持则加载Polyfill -->

<script>

  if (!window.ReadableStream) {

    document.write('<script src="https://cdn.jsdelivr.net/npm/web-streams-polyfill@3.2.0/dist/ponyfill.min.js"><\script>');

  }

</script>

<!-- 同理引入其他API的Polyfill -->

<script>

  if (!window.TextDecoder) {

    document.write('<script src="https://cdn.jsdelivr.net/npm/text-encoding@0.7.0/lib/encoding.min.js"><\/script>');

  }

</script>
  • 方式 2:构建工具集成(Webpack/Rollup)

    通过@babel/preset-envuseBuiltIns配置,自动根据目标浏览器引入所需 Polyfill,避免冗余。

// .babelrc配置

{

  "presets": [

    ["@babel/preset-env", {

      "targets": "> 0.25%, not dead",

      "useBuiltIns": "usage",

      "corejs": 3

    }]

  ]

}
(4)浏览器版本检测逻辑
  • 核心检测点:通过navigator.userAgentAPI存在性判断浏览器版本,避免 “版本误判”(如 Edge 79 + 基于 Chrome 内核,支持大部分现代 API)。

  • 示例函数

function isModernBrowser() {

    // 检测核心API是否支持(优先)

    const supportsStream = window.ReadableStream && window.TextDecoder && window.fetch;

    if (supportsStream) return true;

    // 特殊处理:IE浏览器(直接判定为非现代浏览器)

    const isIE = /MSIE|Trident/.test(navigator.userAgent);

    if (isIE) return false;

    // 其他浏览器:检测版本(如Safari 11+支持部分API)

    const safariVersion = /Version\/(\d+)/.exec(navigator.userAgent);

    if (safariVersion && parseInt(safariVersion[1]) >= 11) {

        return window.ReadableStream && window.fetch; // Safari 11+需单独校验

    }

    return false;

}

9. 后端 chunk 中元数据与业务数据的分离解析方案

(1)元数据设计原则
  • 轻量性:元数据仅包含控制所需信息,避免占用过多带宽(如 chunk 序号、数据类型、校验码);

  • 易识别:元数据与业务数据的分隔符需唯一(如|||),避免与业务数据冲突;

  • 容错性:元数据需包含校验字段(如checksum),确保解析准确性。

(2)推荐的封装格式(“元数据 + 分隔符 + 业务数据”)
后端返回的 chunk 格式示例
123|||{"id":1,"question":"..."}  // 123:chunk序号(元数据),|||:分隔符,后续为业务数据
元数据字段定义(JSON 格式)

为提高扩展性,元数据建议使用 JSON 格式封装,最终 chunk 格式如下:

{"chunkId":1,"totalChunks":10,"dataType":"question","checksum":"a1b2c3"}|||{"id":1,"question":"..."}
  • chunkId:当前 chunk 的序号(从 1 开始),用于前端排序(避免 chunk 乱序);

  • totalChunks:总 chunk 数量,用于前端判断是否接收完整;

  • dataType:数据类型(如question/chart),用于前端提前准备渲染逻辑;

  • checksum:业务数据的 MD5 校验值,用于前端验证数据完整性。

(3)前端解析逻辑(“拆分 - 校验 - 提取” 三步法)
核心代码示例
async function parseWithMetadata() {

  const reader = response.body.getReader();

  const decoder = new TextDecoder('utf-8');

  const metadataCache = {}; // 缓存元数据(key:chunkId)

  const businessDataCache = []; // 缓存业务数据

  while (true) {

    const { done, value } = await reader.read();

    if (done) break;

    const chunkText = decoder.decode(value, { stream: true });

    // 1. 拆分元数据与业务数据(按分隔符|||)

    const [metadataStr, businessDataStr] = chunkText.split('|||');

    if (!metadataStr || !businessDataStr) {

      console.warn('chunk格式错误,缺少元数据或业务数据');

      continue;

    }

    // 2. 解析并校验元数据

    let metadata;

    try {

        metadata = JSON.parse (metadataStr);

        // 校验元数据必选字段

        const requiredMetaFields = ['chunkId', 'totalChunks', 'dataType', 'checksum'];

        const hasAllFields = requiredMetaFields.every (field => metadata[field] !== undefined);

        if (!hasAllFields) {

            console.warn (' 元数据缺少必选字段 ', metadata);

            continue;

        }

    } catch (error) {

        console.error (' 元数据解析失败 ', error);

        continue;

    }

    // 3. 校验业务数据完整性(通过 checksum)

    // 注:需引入 md5 库(如 crypto-js),或后端使用简单校验算法(如长度校验)

    const calculatedChecksum = md5 (businessDataStr); // 假设 md5 函数已实现

    if (calculatedChecksum !== metadata.checksum) {

        console.warn (`chunk ${metadata.chunkId} 数据校验失败,丢弃该chunk`);

        continue;

    }

    // 4. 提取业务数据并缓存(按 dataType 分类)

    try {

        const businessData = JSON.parse (businessDataStr);

        // 缓存元数据与业务数据(用于后续排序与完整性判断)

        metadataCache[metadata.chunkId] = metadata;

        businessDataCache.push ({

            chunkId: metadata.chunkId,

            dataType: metadata.dataType,

            data: businessData

        });

        // 5. 若数据类型为 question/chart,立即渲染

        if (['question', 'chart'].includes (metadata.dataType)) {

            if (metadata.dataType === 'question') {

                renderQuestion (businessData);

            } else {

                renderChart (businessData);

            }

        }

        // 6. 判断是否接收完整所有 chunk(可选,用于最终校验)

        const receivedChunkIds = Object.keys (metadataCache).map (Number);

        const isAllReceived = receivedChunkIds.length === metadata.totalChunks &&

        receivedChunkIds.every (id => id >= 1 && id <= metadata.totalChunks);

            if (isAllReceived) {

                console.log (' 所有 chunk 已接收并处理完成 ');

            }

        } catch (error) {

            console.error (' 业务数据解析失败 ', error);

            continue;

        }

    }

}

关键处理细节
  • 分隔符冲突处理:若业务数据中可能出现|||,可将分隔符改为“特殊字符+随机字符串”(如###abc123###),后端生成时动态生成随机串,前端通过首段元数据获取分隔符;

  • chunk乱序处理:通过chunkIdtotalChunks,前端可对businessDataCache进行排序,确保渲染顺序与后端发送顺序一致;

  • 断点续传支持:若需实现断点续传,可将已接收的chunkId存储到localStorage,重新请求时通过请求头告知后端“已接收的chunkId”,避免重复传输。

10. 流式处理与传统完整数据处理方案的多维度对比

(1)网络传输效率对比
对比维度 后端chunk分段 + 前端流式解析 传统完整JSON返回 + 一次性解析
首字节时间(TTFB) 更优:后端无需等待所有数据生成,可实时返回首个chunk,TTFB显著缩短(尤其数据量较大时,如10MB+的题库数据);例如:生成10个chunk的题库,首个chunk可在100ms内返回,而传统方案需等待500ms生成完整数据。 较差:后端需生成完整JSON数据后才开始传输,TTFB较长;数据量越大,TTFB延迟越明显。
传输中断重试成本 更低:支持“断点续传”,中断后仅需重新请求未接收的chunk(通过chunkId标记);例如:已接收8个chunk,中断后仅需请求剩余2个,节省80%带宽。 更高:中断后需重新请求完整数据,即使仅差最后1%数据,也需重新传输100%内容,带宽浪费严重。
网络适应性 更强:支持“渐进式传输”,在弱网环境下,可优先传输核心数据(如题目文本),后续传输非核心数据(如图表图片URL),提升弱网体验。 较弱:弱网环境下,需等待完整数据传输完成才能展示,用户可能长时间看到空白页。
(2)前端内存占用对比
对比维度 后端chunk分段 + 前端流式解析 传统完整JSON返回 + 一次性解析
峰值内存消耗 更低:仅需缓存当前未解析完成的JSON片段(通常KB级),解析完成并渲染后可立即释放该片段内存;例如:处理1000道题的题库,峰值内存仅需500KB(缓存1-2个chunk)。 更高:需将完整JSON数据加载到内存(可能MB级甚至GB级),解析为JS对象后,整个对象仍占用内存;例如:1000道题的完整JSON约5MB,解析后JS对象可能占用10MB+内存。
内存释放时机 更早:每解析并渲染一个完整节点(如一道题),即可释放该节点对应的JSON片段内存,内存占用呈“波浪式”波动(解析时上升,渲染后下降)。 更晚:需等待整个页面生命周期结束(或主动销毁),才能释放完整JS对象的内存;若页面长时间不关闭,内存可能持续占用,增加内存泄漏风险。
大型数据处理能力 更强:可处理超大型数据(如1GB+的日志数据、百万级条目列表),无需担心内存溢出;例如:流式解析1GB日志,内存占用始终控制在1MB以内。 较弱:处理超大型数据时,易触发“内存溢出”错误(如Chrome浏览器单标签内存限制约4GB),导致页面崩溃。
(3)用户体验对比
对比维度 后端chunk分段 + 前端流式解析 传统完整JSON返回 + 一次性解析
页面渲染延迟 更短:支持“分段渲染”,首个完整节点(如第一道题)可在接收后立即渲染,用户无需等待所有数据;例如:题库页面,1秒内可展示第一道题,后续题目陆续加载,用户感知“即时响应”。 更长:需等待完整数据下载+解析完成后才开始渲染,用户可能等待3-5秒(数据量大时),易产生“页面卡顿”“无响应”的负面感知。
交互响应速度 更快:流式解析与渲染不阻塞主线程(若配合Web Worker),用户可在数据加载过程中进行交互(如切换题目、输入答案);例如:用户在第二道题加载时,可对已渲染的第一道题进行“收藏”操作。 更慢:数据下载与解析过程中,主线程可能被阻塞(尤其是大型JSON解析),用户交互(如点击按钮)无响应,体验卡顿。
加载状态感知 更清晰:可通过“已加载节点数/总节点数”展示进度(如“已加载3题/共10题”),用户明确感知加载进度;同时支持“加载失败重试单个chunk”,无需重新加载全部。 模糊:通常仅能展示“加载中”动画,用户无法知晓具体加载进度;若加载失败,需重新点击“刷新”按钮,体验较差。
(4)适用场景总结
  • 流式处理方案更优的场景
  1. 大型数据展示(如百万级题库、超长篇报告、实时日志);

  2. 弱网或不稳定网络环境(如移动端4G/3G网络、跨国传输);

  3. 对首屏加载速度要求高的业务(如考试系统、实时报表、新闻资讯流);

  4. 需支持断点续传或部分数据更新的场景(如文档协作、增量数据同步)。

  • 传统处理方案更优的场景
  1. 小型数据接口(如返回10条以内的列表、用户信息,数据量<100KB);

  2. 对数据完整性要求极高,不允许分段处理的场景(如支付订单数据、交易凭证);

  3. 浏览器兼容性要求覆盖极旧版本(如IE8及以下,不支持Polyfill适配);

  4. 开发成本有限,无需复杂流式逻辑的简单业务(如个人博客、静态页面数据接口)。

Zustand 实战:10 行代码搞定全局状态

Zustand 实战:10 行代码搞定全局状态

在 React 状态管理的世界里,Redux 和 Context 一直是常见的选择。但很多时候我们只是需要一个轻量、简单的全局状态管理工具。这时候 Zustand 就是一个很好的选择:API 简洁,代码量极少,学习成本极低。

本文将用不到 10 行代码演示如何在 React 应用里快速接入 Zustand。


一、安装

npm install zustand

yarn add zustand

二、创建全局状态

使用 create 函数定义 store:

import { create } from 'zustand';

const useStore = create((set) => ({
  count: 0,
  increase: () => set((state) => ({ count: state.count + 1 })),
}));
  • count 是一个全局状态。
  • increase 是修改状态的方法。

三、在组件中使用

function Counter() {
  const { count, increase } = useStore();

  return (
    <div>
      <p>{count}</p>
      <button onClick={increase}>+1</button>
    </div>
  );
}

任何使用 useStore 的组件,都会在 count 改变时自动更新,而无需手动传递 props。


四、对比 Redux / Context

特性 Redux Toolkit Context API Zustand
学习曲线 中等 极低
模板代码 较多 较少 极少
状态共享
调试工具 ✅(支持)

可以看到,Zustand 用最小的心智负担,提供了类似 Redux 的全局状态体验。


五、总结

  • Zustand 轻量、灵活,10 行代码即可实现全局状态管理。
  • 对于小型和中型应用,它是 Redux 和 Context 的极佳替代方案。
  • 如果你觉得 Redux 模板代码繁琐,不妨试试 Zustand。

React 状态管理的演进与最佳实践

React 状态管理的演进与最佳实践

在 React 应用开发中,状态管理始终是绕不开的话题。从最初的 useState 到后来的 Context,再到 Redux、MobX、Zustand、Recoil 等不同的状态管理方案,开发者们一直在寻找「更优雅的状态管理方式」。本文将带你梳理 React 状态管理的演进过程,并对常见方案做一份对比。


一、为什么需要状态管理?

在一个小型应用里,我们完全可以用 useState 搭配 props 传递来管理状态:

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

但是随着应用复杂度增加,会出现几个痛点:

  1. 状态共享困难
    父组件需要把状态层层传递给子组件(props drilling)。
  2. 状态修改不透明
    当多个组件依赖同一份状态时,难以追踪是谁修改了状态。
  3. 副作用管理困难
    比如:异步请求数据、缓存管理等。

因此,引入「集中化、可预测」的状态管理方案就显得必要。


二、React 状态管理的演进

1. useStateuseReducer(组件局部状态)

  • 适用场景:组件内部状态、简单计数器、表单。
  • 优点:API 简单直观,无需额外依赖。
  • 缺点:状态共享困难。
const [state, dispatch] = useReducer(reducer, initialState);

2. Context API(跨组件共享)

  • 适用场景:主题切换(dark/light)、用户信息、语言环境。
  • 优点:避免 props drilling。
  • 缺点:过度使用会导致性能问题(所有 Consumer 都会重新渲染)。
const ThemeContext = createContext();

function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Child />
    </ThemeContext.Provider>
  );
}

3. Redux(集中化状态管理)

  • 特点:单一数据源(store)、不可变状态(immutable)、纯函数 reducer。
  • 优点:可预测、可调试(Redux DevTools)。
  • 缺点:模板代码多,学习曲线偏高。
  • 适用场景:大型应用,团队协作需要严格规范。
dispatch({ type: "ADD_TODO", payload: "学习React" });

4. MobX(响应式状态管理)

  • 特点:基于观察者模式,状态变化会自动更新 UI。
  • 优点:语法直观,学习成本低。
  • 缺点:调试难度较高,不如 Redux 可预测。
  • 适用场景:中大型应用,偏爱响应式思维的团队。

5. 新兴方案:Zustand / Recoil / Jotai

  • Zustand:轻量、无样板代码,API 简洁,基于 hooks。
  • Recoil:由 Facebook 开发,支持 原子化状态,天然适配 React 并发模式。
  • Jotai:原子化状态管理,语法极简。

👉 这些库都比 Redux 更轻量,正在成为越来越多团队的选择。


三、该如何选择?

场景 推荐方案
小型应用,本地状态为主 useState + useReducer
中型应用,少量全局状态 Context API + hooks
大型应用,团队协作、调试需求强 Redux Toolkit
追求简洁、快速开发 Zustand / Jotai
想尝鲜 Facebook 方案 Recoil

四、实践建议

  1. 不要过早引入全局状态
    先用 useState,真的出现状态共享问题时,再考虑 Context 或第三方库。

  2. Redux 优先用 Redux Toolkit
    它大大简化了 Redux 的模板代码,并内置了 Immer。

  3. 性能优化

    • 使用 React.memo 避免无意义的渲染。
    • 对 Context 拆分,减少不必要的更新。

总结

  • React 状态管理没有「银弹」,要根据项目规模和团队习惯来选择。
  • 小型应用用 useState/Context 足够,大型应用更适合 Redux Toolkit。
  • Zustand / Recoil / Jotai 等新兴方案值得关注,未来可能成为主流。

micro-zoe子应用路由路径污染问题

背景

公司需要做一个hybrid app,而且这个可以兼容web端,所以做在同一个项目。项目需要内嵌其他业务部分的项目,很自然地想到用micro-zoe/microapp来做微前端方案。

技术栈

windows系统 父应用: vue3全家桶+micro-zoe/microapp,路由是用hash模式 子应用:vue2全家桶,yarn,路由是用histroy模式

遇到问题.

hybrid端,父应用正常加载,子应用只加载了main.js,没有加载页面。

排查定位&尝试过的方案

  1. 在web端,父应用和子应用都正常加载。起码说明micro-app的配置是没有问题的,子应用的配置是iframe模式,虚拟路由模式是pure
  2. 在hybrid端,子应用打印出来的this.$route,路由信息带了windows的盘符,譬如说,页面路由是/pageA,但是打印出来的是/D:/pageA
  3. 基于2的信息,判断是路由history模式导致的,具体来说,应该是路由配置的base参数有关
  4. 尝试过在父应用传参子应用<micro-app baseroue="/"></micro-app>,也试过<micro-app baseroue="http://domain.com/"></micro-app>也不行,在子应用console出来的__MICRO_APP_BASE_ROUTE__还是空字符串,原来配置了pure模式的虚拟路由,就不会透传baseroute,所以在子应用通过__MICRO_APP_BASE_ROUTE__读取出来时空字符串
  5. 也尝试过在子应用的new Router的时候写死配置base/,也不行
  6. 搜了ai,尝试了<base href="/">的方案,也不行,而且发现了就算是iframe模式,也会htmlhead头也会污染父应用,很奇妙,因为父应用的资源都加载失败了,具体来说就是加载了hybrid所在的盘的目录的根目录。
  7. 想到在web端部署一切正常,所以肯定是在hybrid端这个环境导致的问题。而且在子应用出来的pathfullPath都有问题,就判断是vue-routerhybrid环境导致的解析问题
  8. 我不想读源码,直接扔给ai去读vue-router的源码,定位到原因,就是normalizeBaseresolvePathparsePath getLocation这四个函数导致的,其中就是读取了window.location,所以肯定会读到file协议,所以解析下来的都是错的。我需要做的就是把他做兼容。让ai给了一些改动建议
  9. 用了patch-packagepostinstall-postinstall来打补丁,具体的方法不赘述了

最后的解决方案

  1. 需要锁定当前的vue-router版本,以防版本变动
  2. 修改vue-router/dist/vue-router.esm.js
export function getLocation(base: string): string {
  let path = window.location.pathname

  // 直接在原 path 变量上处理,不新增变量
  if (window.location.protocol === 'file:') {
    path = path
      .replace(/^\/[a-zA-Z]:\//, '/')
      .replace(/^\/(Volumes|Users|Applications)\/[^/]+(\/|$)/, '/');
  }

  const pathLowerCase = path.toLowerCase()
  const baseLowerCase = base.toLowerCase()

  if (base && ((pathLowerCase === baseLowerCase) ||
    (pathLowerCase.indexOf(cleanPath(baseLowerCase + '/')) === 0))) {
    path = path.slice(base.length)
  }

  return (path || '/') + window.location.search + window.location.hash
}

export function parsePath(path: string): {
  path: string;
  query: string;
  hash: string;
} {
  let hash = ''
  let query = ''

  const hashIndex = path.indexOf('#')
  if (hashIndex >= 0) {
    hash = path.slice(hashIndex)
    path = path.slice(0, hashIndex)
  }

  const queryIndex = path.indexOf('?')
  if (queryIndex >= 0) {
    query = path.slice(queryIndex + 1)
    path = path.slice(0, queryIndex)
  }

  // 直接修改原 path 变量,不新增 cleanPath
  path = path
    .replace(/^\/[a-zA-Z]:\//, '/')
    .replace(/^\/(Volumes|Users|Applications)\/[^/]+(\/|$)/, '/');

  return {
    path,
    query,
    hash
  }
}

export function resolvePath(
  relative: string,
  base: string,
  append?: boolean
): string {
  // 直接在原变量上处理,不新增 cleanBase/cleanRelative
  if (base) {
    base = base
      .replace(/^\/[a-zA-Z]:\//, '/')
      .replace(/^\/(Volumes|Users|Applications)\/[^/]+(\/|$)/, '/');
  }
  if (relative.charAt(0) === '/') {
    relative = relative
      .replace(/^\/[a-zA-Z]:\//, '/')
      .replace(/^\/(Volumes|Users|Applications)\/[^/]+(\/|$)/, '/');
  }

  const firstChar = relative.charAt(0)
  if (firstChar === '/') {
    return relative
  }

  if (firstChar === '?' || firstChar === '#') {
    return base + relative
  }

  const stack = base.split('/')

  if (!append || !stack[stack.length - 1]) {
    stack.pop()
  }

  const segments = relative.replace(/^\//, '').split('/')
  for (let i = 0; i < segments.length; i++) {
    const segment = segments[i]
    if (segment === '..') {
      stack.pop()
    } else if (segment !== '.') {
      stack.push(segment)
    }
  }

  if (stack[0] !== '') {
    stack.unshift('')
  }

  return stack.join('/')
}

function normalizeBase(base: ?string): string {
  if (!base) {
    if (inBrowser) {
      const baseEl = document.querySelector('base')
      base = (baseEl && baseEl.getAttribute('href')) || '/'
      // 复用 base 变量,链式处理协议和系统前缀
      base = base
        .replace(/^[a-zA-Z]+:\/\/[^\/]+/, '') // 移除所有协议
        .replace(/^\/[a-zA-Z]:\//, '/') // 移除 Windows 盘符
        .replace(/^\/(Volumes|Users|Applications)\/[^/]+(\/|$)/, '/'); // 移除 macOS 根目录
    } else {
      base = '/'
    }
  }
  if (base.charAt(0) !== '/') {
    base = '/' + base
  }
  return base.replace(/\/$/, '')
}
  1. 注意,需要跟你的项目打包target决定改哪个vue-router/dist/vue-router.*.js

总结

ai时代排查问题就是高效~~~

快速上手Leaflet:轻松创建你的第一个交互地图

环境准备

此前使用的是vite快速搭建的项目框架,在这里就不过多赘述了。
在这里使用npm的方式导入leaflet包

npm install leaflet

全局引入

因为涉及项目需要用到的地图界面很多,所以就直接在main.js文件中全局引入

// 引入Leaflet库
import L from "leaflet";

创建基础地图

初始化HTML容器。

 <div id="my_map"></div>

在这里一定要给地图容器设置宽高,不然会报错
通过Leaflet API创建地图实例并设置初始视图(中心点坐标、缩放级别)。

const map_ref= ref(null);//地图实例
// 天地图服务地址
const TDT_provider = (key) => {
  return `http://t0.tianditu.gov.cn/${key}_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=${key}&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=个人申请的天地图开发者密钥`;
};
// 初始化地图
const initMap = () => {
  const base_layer_option = {
    maxZoom: 18.0,
    minZoom: 4.0,
  };
  // 创建地图层
  const base_layer_ref = L.tileLayer(
    TDT_provider("img"),//影像底图
    base_layer_option
  );
  // 创建包含标注信息的地图层
  const label_layer_ref = L.tileLayer(
    TDT_provider("cia"),//影像注记
    base_layer_option
  );
   // 检查并销毁旧的地图实例
   let mapContainerA = L.DomUtil.get("my_map");
   if (mapContainerA !== null) {
    mapContainerA._leaflet_id = null; // 清除旧的 Leaflet ID
   }
   // 初始化地图
   map_ref= L.map("my_map", {
   center: [29.5446061089, 106.530635013],
   current_zoom: 8.0,
   zoom: 8.0,
   // 禁用默认的缩放控制器
   zoomControl: false,
   // 禁用版权信息控件
   attributionControl: false,
   // 设置地图显示范围
   maxBounds: L.latLngBounds(
   L.latLng(60.20689, 58.76758),
   L.latLng(0.03133, 150.81885)
   ),
   // 设置默认添加到地图上的图层组
   layers: [base_layer_ref , label_layer_ref ], //将影像地图和注记添加到地图中
  })

};

添加地图图层

 这里我是用的是天地图的底图服务,上述代码已有,具体获取方式可以查看官方文档:天地图的地图api  示例代码中的开发者密钥可自行去天地图官网申请天地图统一身份认证平台。 如此,一个简单的地图容器便已经搭建好了 image.png

标记与交互

添加标记点(L.marker

var marker = L.marker([51.5, -0.09]).addTo(map_ref);

具体也可参照官方教程leaflet中文版官方教程
新手刚开始尝试,慢慢摸索,感谢各位大佬前辈的经验,[后续我也会分享自己的一些成果]

文件导出的几种方式

1、返回文件流(二进制数据)

这是最常用的导出方式 —— 后端实时生成文件(如 Excel、PDF),不落地存储,直接以二进制流形式返回给前端,前端接收后解析为可下载的文件。

 
import axios from 'axios';

// 导出Excel接口请求
async function exportExcel() {
  try {
    const response = await axios({
      method: 'GET', // 也可用POST(传筛选参数时用POST)
      url: '/api/export/user-data', // 后端导出接口
      params: { status: 'active' }, // 筛选参数(可选)
      responseType: 'blob', // 关键:声明接收Blob二进制流
      headers: { 'Authorization': 'Bearer ' + token } // 权限头(可选)
    });

    // 1. 从响应头提取文件名(处理中文编码)
    const disposition = response.headers['content-disposition'];
    let filename = '默认文件名.xlsx';
    if (disposition) {
      // 匹配 filename*=UTF-8''xxx 格式(RFC 5987标准,支持中文)
      const match = disposition.match(/filename\*=UTF-8''([^;]+)/);
      if (match && match[1]) {
        filename = decodeURIComponent(match[1]); // 解码中文
      }
    }

    // 2. 转换Blob为下载URL
    const blob = new Blob([response.data], {
      // 手动指定MIME类型(与后端Content-Type一致,避免识别错误)
      type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
    });
    const downloadUrl = URL.createObjectURL(blob);

    // 3. 创建<a>标签触发下载
    const a = document.createElement('a');
    a.href = downloadUrl;
    a.download = filename; // 文件名(浏览器会忽略URL中的文件名,以这个为准)
    document.body.appendChild(a);
    a.click(); // 模拟点击下载

    // 4. 清理资源(避免内存泄漏)
    document.body.removeChild(a);
    URL.revokeObjectURL(downloadUrl); // 销毁临时URL
  } catch (error) {
    console.error('导出失败:', error);
    // 特殊处理:若后端返回JSON错误(如参数错误),需解析Blob为JSON
    if (error.response?.data instanceof Blob) {
      const errorText = await new Response(error.response.data).text();
      alert('导出失败:' + JSON.parse(errorText).message);
    }
  }
}

2、 文件下载链接(URL)

后端先将文件生成并存储在服务器 / 云存储(如 OSS、S3),再返回一个 临时有效的下载链接(含签名 / 过期时间,保证安全),前端直接通过链接触发下载。

async function getExportUrl() {
  try {
    const response = await axios.get('/api/export/get-download-url', {
      params: { status: 'active' } // 筛选参数
    });
    const { downloadUrl, filename } = response.data.data;

    // 方式1:用<a>标签下载(推荐,可指定文件名)
    const a = document.createElement('a');
    a.href = downloadUrl;
    a.download = filename; // 强制指定文件名(若链接含文件名也可省略)
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);

    // 方式2:直接跳转(简单,但无法指定文件名,且可能触发浏览器预览)
    // window.location.href = downloadUrl;
  } catch (error) {
    console.error('获取下载链接失败:', error);
  }
}

//返回格式
 
{
  "code": 200,
  "message": "success",
  "data": {
    "downloadUrl": "https://xxx.com/files/export/user_123.xlsx?sign=abc123&expire=1717248000",
    "filename": "用户数据.xlsx",
    "expireTime": "2024-06-01 12:00:00"
  }
}
 

3、 后端返回「Base64 编码字符串」

后端将文件转换为 Base64 字符串(二进制→ASCII 字符),嵌入 JSON 返回给前端,前端解码后转换为文件。仅适合小文件(如 100KB 以内,Base64 会比原文件大 30%,大文件会导致请求体过大)。

async function exportBase64File() {
  try {
    const response = await axios.get('/api/export/get-base64-file');
    const { base64Str, filename } = response.data.data;

    // 1. 解析Base64字符串(分离MIME类型和编码内容)
    const [meta, base64Data] = base64Str.split(',');
    const mime = meta.match(/:(.*?);/)[1]; // 提取MIME类型

    // 2. Base64解码为二进制数据(Uint8Array)
    const binaryStr = atob(base64Data); // atob解码Base64
    const len = binaryStr.length;
    const uint8Array = new Uint8Array(len);
    for (let i = 0; i < len; i++) {
      uint8Array[i] = binaryStr.charCodeAt(i); // 转换为二进制数组
    }

    // 3. 转换为Blob并下载(后续逻辑与文件流一致)
    const blob = new Blob([uint8Array], { type: mime });
    const downloadUrl = URL.createObjectURL(blob);

    const a = document.createElement('a');
    a.href = downloadUrl;
    a.download = filename;
    document.body.appendChild(a);
    a.click();

    // 清理资源
    document.body.removeChild(a);
    URL.revokeObjectURL(downloadUrl);
  } catch (error) {
    console.error('导出失败:', error);
  }
}

//返回格式 
{
  "code": 200,
  "message": "success",
  "data": {
    "base64Str": "data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,UEsDBBQABgAIAAAAIQ...",
    "filename": "用户数据.xlsx"
  }
} 

教你如何用 JS 实现 Agent 系统(3)—— 借鉴 Cursor 的设计模式实现深度搜索

前言

大家好,我是唐某人~ 我非常乐意与大家分享我在AI领域的经验和心得。我的目标是帮助更多的前端工程师以及其他开发岗位的朋友,能够以一种轻松易懂的方式掌握AI应用开发的技能。希望我们能一起学习,共同进步!

1.1 回顾

《教你如何用 JS 实现 Agent 系统(2)—— 开发 ReAct 版本的“深度搜索”》中,我分享了什么是深度搜索,什么是 ReAct 设计模式以及 ReAct 版本深度搜索的实现思路。

1.2 目标

不知道你是否发现,现在的 Cursor 或者 Trae,它们在解决问题前,会先列一个 To-dos 任务清单,然后一步步的处理这些任务。

相比之前的处理方式,这种“先规划再行动”的模式可以让 LLM 对未来要做的事情有一个大体的规划,避免了方向跑偏。同时也可以让用户知道 AI 工具的解决思路,而不是等着一个“黑盒”给你最终的结果。

所以,这篇我们就一起来学习这种“先规划,再行动”的设计模式,然后用这种模式来实现深度搜索。我们的目标是:

  • 学习 ReAct 模式有哪些不足
  • 了解 Plan And Execute 设计模式是怎么工作的
  • 用 Plan And Execute 的模式实现深度搜索

二、什么是 Plan And Execute

2.1 ReAct 缺点

在上一篇文章中,我们提到ReAct通过多轮“思考→行动→观察”的循环逐步接近目标。这种方法存在几个问题:

  1. 缺乏整体规划:采用走一步看一步的方式,可能导致推理方向不确定且难以控制,随着步骤增加可能偏离目标。
  2. 效率低下:由于是按顺序执行任务,复杂的问题需要更多的推理步骤,从而增加了完成时间。
  3. 上下文限制:所有工具的结果都会累积到一个模型的上下文中,因为没有拆分和规划,很容易超出上下文容量限制。

2.2 由来和概念

ReAct 模式有如上的缺点,所以后来人们设计了很多改进的方法。今天我们来讲讲其中的一种叫 Plan And Execute 的模式。这个方法受到了 Plan-and-Solve 论文和 Baby-AGI 项目的启发。主要分为三个步骤:

  1. 规划阶段:先制定一个详细的多步骤行动计划。
  2. 执行阶段:按照计划一步步执行,并返回每一步的结果。
  3. 重规划阶段:根据执行结果调整计划。

这种模式的好处就是它会先制定一个整体计划,然后把每个具体任务分配给另一个独立的Agent来完成。这样做的好处有两点:

  1. 有一个总管全局的Agent,确保解决问题的方向不会跑偏。
  2. 每个任务的执行,交给独立的 Agent。这样任务范围小、职责单一、上下文不容易撑爆。

为了让大家对这个流程有一个更直观的理解,我准备了一张流程图供大家参考。

画板

首先,我们从主体来看:

  1. 用户:提问题、发起对话的人
  2. Plan And Exectue Agents:一个由多 Agent 组成的系统。Plan Agent 复杂制定初始化计划、Execute Agent 负责执行任务、RePlan Agent 负责分析结果并调整计划状态。

然后再来看流程细节:

  1. 用户提出问题
  2. Plan Agent 根据问题制定任务计划
  3. 根据任务列表开始循环
  4. 每次取出第一个任务,交给 Exectue Agent 执行并记录结果
  5. Replan 拿到所有任务的执行结果,加上当前任务计划判断是否已经解决用户问题
  6. 未解决,生成新的执行计划(去掉执行完的任务,也可能补充新的任务),继续循环
  7. 解决了,直接回复用户

三、代码实现

接下来,我们就用这个模式来实现一个新版本的深度搜索。它主要由三个逻辑部分组成。

3.1 Plan

演示

首先是实现一个 Plan 规划器。它的主要任务就是像一个主管一样,先分析用户提出的问题,然后拆分成一步步可以执行的小任务,最后分配给“小弟”们执行。例如,下面我问的是“特斯拉和英伟达,谁的股价更高”,它于是给出一个执行计划:

  1. 搜索特斯拉股价
  2. 搜索英伟达股价
  3. 比较两者股价

实现

这个 Plan Agent 的实现很简单,我们只需要定义清楚它的提示词即可。

import { Block } from '../base/block';

const prompt = `
  你是一个研究主管。你的任务是针对给定目标,制定一个简单的分步计划。这些任务会分配给研究助理,所以不要过分细化。
  该计划应包含各项独立任务,这些任务若执行正确,就能得出正确答案。请勿添加任何多余步骤。
  最后一步的结果须为最终答案。确保每一步都包含所需的全部信息 —— 不要省略步骤。

  当前时间是:${new Date().toLocaleString()}

  输出的计划必须是一个 JSON 格式的内容,示例如下:
  \`\`\`json
  ["1.步骤", "2.步骤", "3.步骤"]
  \`\`\`
`;

export const planer = new Block({
  instruction: prompt,
  responseFormat: {
    type: 'json_object',
  },
  name: 'planer',
});

基于现有的Block类(详情见GitHub),创建Agent时,提示词应包括:

  1. 确定主管身份
  2. 明确职责:分析问题、拆解任务
  3. 规定JSON返回格式,便于后续处理

注意:因为我演示的案例使用的是 DeepSeek 的模型,你可以使用设置 json_object 来更加强制的要求的模型必须返回 JSON 的格式。

然后需要实现一个 PlanAndExecuteAgent 的类,来组合这些子 Agent

import { UserMessage } from '../base/message';
import { planer } from './plan';

export class PlanondExecutorAgent {
  async invoke(query: string) {
    const planMessage = await planer.invoke([new UserMessage(query)]);
    console.log('planMessage', planMessage.content);

    // 当前计划
    let plans = JSON.parse(planMessage.content) as string[];
    // 是否完成
    let isComplete = false;
    // 最终结果
    let finalResult;
    // 已执行计划的结果
    const pastSteps: string[] = [];

    console.table(plans.map((plan, index) => ({ 步骤: index + 1, 计划: plan })));
  }
}

一切顺利的话,输入问题后,它应该会返回一个任务的 JSON 数组给你。

3.2 Executer

演示

Executer 执行器负责接收子任务并利用现有工具解决问题。比如,当它接到“获取特斯拉当前股价”的任务时,会通过调用搜索工具来实时查询股价。

最后得出结论

实现

这里我们直接采用 ReAct 的模式来实现执行任务的子 Agent。让它收到任务后,会按照“思考-行动-观察”的模式,解决子任务的问题。

如果你有看过上一篇《教你如何用 JS 实现 Agent 系统(2)—— 开发 ReAct 版本的“深度搜索”》,那你应该很清楚如何实现 ReAct 的 Agent 了,思路就是设定提示词、设定解决这类的问题具体工具。

import { Block } from '../base/block';
import { Tools } from '../base/tool';
import { thoughtTool } from '../tools/thought';
import { tavilySearchTool } from '../tools/tavily-search';

export const createExecuter = (name: string) => {
  return new Block({
    instruction: `
  你是一个研究助理,你的职责是解决研究主管委派给你的任务。

  当前时间是:${new Date().toLocaleString()}
  
  你有如下核心工具:
  - thought: 用于思考和决策。注意,在调用 tavilySearch 之前,你必须先调用 thought 分析这么做的原因。在调用 tavilySearch 之后,你必须调用 thought 观察上下文并思考分析后续步骤。
  - tavilySearch: 用于搜索互联网
  `,
    tools: new Tools([thoughtTool, tavilySearchTool]),
    name,
    debug: false,
  });
};

这里我们采用函数调用的方式,这样每次执行一个子任务,就创建一个新的 Agent。工具的话还是复用之前准备的思考工具和查询工具。

组合

设定好 Executer Agent 以后,接下就是让它和 Plan Agent 组合起来工作。思路就是构建一个循环,每一次循环都取出任务列表中的第一个任务执行,并且需要记录执行的结果。

import { UserMessage } from '../base/message';
import { planer } from './plan';
import { createExecuter } from './executer';
import { getNewPlans } from './replan';

export class PlanondExecutorAgent {
  async invoke(query: string) {
    const planMessage = await planer.invoke([new UserMessage(query)]);

    console.log('planMessage', planMessage.content);

    // 当前计划
    let plans = JSON.parse(planMessage.content) as string[];
    // 是否完成
    let isComplete = false;
    // 最终结果
    let finalResult;
    // 已执行计划的结果
    const pastSteps: string[] = [];

    console.table(plans.map((plan, index) => ({ 步骤: index + 1, 计划: plan })));

    // 构建循环
    while (plans.length > 0) {
      // 取出第一个任务
      const step = plans[0];
      const prompt = `你的任务是:${step}`;
      // 分配任务
      console.log(`正在处理任务:${step}`);
      const exectuerMessage = await createExecuter('executer').invoke([new UserMessage(prompt)]);
      console.log('exectuerMessage', exectuerMessage.content);
      // 记录结果
      pastSteps.push(exectuerMessage.content);
    }
  }
}

3.3 RePlan

最后是 RePlan 重规划器。它的主要工作就是分析用户的问题、当前的计划、已经执行的步骤和结果,判断问题是否已经解决了。如果解决了,它会终止循环,直接给出答案;如果没有解决,它会继续循环,并更新任务的状态。

演示

例如,当 “获取特斯拉(Tesla)当前股价” 任务执行完毕后。它会收到问题、计划、执行结果等信息,发现还需完成剩余两步才可以解决用户问题,于是它更新了当前计划的进度,并再次循环。

第二次循环中,Executer Agent 取到的第一个任务是 “获取英伟达(NVIDIA)当前股价”,于是它开始查询英伟达股价,并最后结果也记录起来。

现在有了特斯拉和英伟达的股价了,当 RePlan 有了这些信息后,就可以给出最终答案了。

实现

RePlan 的实现跟 Plan 差异不大,本质都是一个纯提示词工程。

import { Block } from '../base/block';
import { UserMessage } from '../base/message';

const generateReplanPrompt = (input: string, plan: string, past_steps: string) => {
  return `
  你是一个研究主管。针对给定目标,制定一个简单的分步计划。该计划应包含各项独立任务,这些任务若执行正确,就能得出正确答案。请勿添加任何多余步骤。
  最后一步的结果须为最终答案。注意,必须确保每一步都包含所需的全部信息————不要跳过步骤。

  当前时间是:${new Date().toLocaleString()}

  你的目标如下:
  ${input}
  你最初的计划如下:
  ${plan}
  你目前已完成以下步骤:
  ${past_steps}
  请据此更新你的计划。若无需再执行其他步骤,且可以向用户反馈最终的结果,则直接回复该结论;若仍需执行步骤,请完善计划内容。仅添加仍需完成的步骤,切勿将已完成的步骤纳入更新后的计划中。
  
  注意,计划必须是一个 JSON 格式的内容,示例如下:
  ["1.步骤", "2.步骤", "3.步骤"]
  注意,如果返回的是最终结果,那么答案也必须是一个 JSON 格式的内容,示例如下:
  {"answer": "答案"}
  `;
};

export const getNewPlans = async (input: string, plan: string, past_steps: string) => {
  const prompt = generateReplanPrompt(input, plan, past_steps);
  const replanner = new Block({
    instruction: prompt,
    name: 'replan',
    responseFormat: {
      type: 'json_object',
    },
  });
  const res = await replanner.invoke([new UserMessage(prompt)]);
  console.log('replan', res.content);
  return JSON.parse(res.content);
};

这个提示词核心的逻辑有这三个:

  1. 确定身份和职责
  2. 接受目标、计划、已经执行的计划和结果
  3. 更新计划:一是去掉已执行的任务,二是可能补充新的任务
  4. 返回内容:判断执行进度。未解决,更新一个新的任务数组;已解决,回复一个包含结果的对象

但是,这里需要重点讲一下更新计划的这个过程。为什么会出现追加任务的场景呢?

这个是 Plan And Execute 这种模式比较有意思的地方。因为一开始的规划,可能会是不完善的,随着子任务执行,上下文信息逐步全面,LLM 就会补充一些新任务来让这个问题解决的更好。本质是模拟人类不断完善方案的一个过程。

组合

接着就是把这几个 Agent 进行一个完整的组成,组成一个真正的 Agentic System。

import { UserMessage } from '../base/message';
import { planer } from './plan';
import { createExecuter } from './executer';
import { getNewPlans } from './replan';

export class PlanondExecutorAgent {
  async invoke(query: string) {
    const planMessage = await planer.invoke([new UserMessage(query)]);

    console.log('planMessage', planMessage.content);

    // 当前计划
    let plans = JSON.parse(planMessage.content) as string[];
    // 是否完成
    let isComplete = false;
    // 最终结果
    let finalResult;
    // 已执行计划的结果
    const pastSteps: string[] = [];

    console.table(plans.map((plan, index) => ({ 步骤: index + 1, 计划: plan })));

    while (plans.length > 0) {
      const step = plans[0];
      const prompt = `你的任务是:${step}`;
      console.log(`正在处理任务:${step}`);
      const exectuerMessage = await createExecuter('executer').invoke([new UserMessage(prompt)]);
      console.log('exectuerMessage', exectuerMessage.content);
      pastSteps.push(exectuerMessage.content);

      const plansStr = plans.join('\n');
      const pastStepsStr = pastSteps.join('\n');
      const result = await getNewPlans(query, plansStr, pastStepsStr);
      if (result?.answer) {
        isComplete = true;
        finalResult = result?.answer;
        // 返回结果,终止循环
        return finalResult;
      } else {
        // 更新计划
        plans = result as string[];
        console.table(plans.map((plan, index) => ({ 步骤: index + 1, 计划: plan })));
      }
    }
  }
}

四、优缺点

4.1 优点

可以发现,相比于 ReAct 模式,Plan And Execute 的模式,具备两个明显的优点:

  • 动态规划能力
  • 避免上下文限制

ReAct 模式上下文容易撑爆;随着上下文内容增多,LLM 的注意力能力会下降。

画板

Plan And Execute 模式会先规划任务,确定方向,然后把子任务分给不同的 Agent。因为子 Agent 关注的问题更新,所以需要步骤相对更少,上下文就不容易受限制。

画板

4.2 缺点

但是 Plan And Execute 模式任然有他的缺点。这种模式执行任务的效率并不高,哪怕拆分了任务,但是它任然是串行的执行。例如我们上面的例子中,其实查询特斯拉股票和查询英伟达股票,其实是可以并行进行的,因为它们并不是相互依赖的任务。

五、最后

5.1 思考

在上一篇文章中,提出了一个问题,就是如果 LLM 一直不停的调用工具怎么办?其实最好的解决办法就是:

  1. 告诉 LLM 有限的迭代次
  2. 在执行工具的时候,统计迭代次数,如果超出了次数,就强制 LLM 直接答复。

然后这次再抛一个问题给大家,既然 Plan And Execute 不能并行执行任务,如果是你,你该怎么设计和优化呢?

5.2 结语

下一遍,我们会继续分享 Agent 的设计模式——如何用 ReWOO 模式实现深度搜索。如果你觉得内容对你有帮助,请关注我,我会持续更新~

最后,关于“深度搜索”实现的完整的代码内容,我都放在这个仓库 github.com/zixingtangm… 了,大家可以直接查看。

原创不易,转载请私信我。

使用[DeepSeek]快速定位nginx前端部署后报错:500 Internal Server Error nginx/1.29.1

使用[DeepSeek]快速定位nginx前端部署问题

本文用于记录使用DeepSeek,快速定位排查使用nginx部署前端后,访问前端报错问题:
500 Internal Server Error nginx/1.29.1

1.背景

  • 项目: 该项目是一个用vue3 + vite + vant 开发的一个h5项目
  • 后端: 后端用的是微服务架构,使用docker + k8s部署,使用kuboard k8s 管理面板
  • 前端部署: 前端也使用 docker 部署

2.问题现象

前端部署使用节点挂载的方式部署,即拉取nginx镜像,并创建容器运行,配置节点挂载的方式将前端包部署到nginx,同时将nginx的access error 日志挂载到节点,记录相关日志

  • 1.不配置节点挂载时: 通过kuboard创建了部署需要的 deployment Pod service 后,能正常访问默认的nginx前端。
  • 2.添加节点挂载后: 修改完nginx的配置文件及前端包,重启再次访问,直接就是报错:500 Internal Server Error nginx/1.29.1
  • 3.无论访问前端的任何路径任何静态资源都是返回同样的报错: 500 Internal Server Error nginx/1.29.1

3.问题分析

3.1初步分析

在不配置节点挂载的方式部署时,访问默认nginx前端是可以正常访问的,说明整个使用kuboard创建部署过程是没有问题的,只有在增加节点挂载及nginx配置后才出现问题的,所以初步判断可能是以下问题:

  • 1.节点挂载配置错误

    通过现有其他项目部署对比发现,节点挂载配置是没有问题的

  • 2.前端包问题

    后面直接将前端包改成一个简单的 index.html 测试文件,500 问题依然出现

  • 3.nginx配置问题
    server {
         listen       80;
         client_max_body_size 500M; 
    
         location / {
             root /home/robot-h5-fe/html/robot-h5;
             try_files $uri $uri/ /index.html;
         }
    
         error_page   500 502 503 504  /50x.html;
         location = /50x.html {
             root   /usr/share/nginx/html;
         }
     }
    

    使用 nginx -t 命令检查nginx 配置文件,检测通过,没有问题,也反复检查了前端包的路径,都没问题。

3.2进一步排查

通过前面几步的排查,到这里有点懵了,初步想到的三个原因排除了。接着又想着从nginx 的日志看看能不能找到一些线索。

  • 1.access.log

    该日志没有任何内容

  • 2.error.log

    查看日志如图所示,问了AI,解释说是:无线循环重定向到/index.html, 触发报错 500的
    心想这也没问题啊,其他的项目也是这么配的,怎么就这个项目就出问题了😥
    但是还是根据AI提供的解决方法,一通操作,最后还是没能解决😰

    image.png

3.2借助DeepSeek定位问题

后面想: 直接把相关的文件及配置以及现象给DeepSeek让他来分析排查,于是直接给出了以下内容作为提示词,发给DeepSeek

  • k8s部署yaml文件: deployment的yaml文件,整个文件可以说是k8s容器部署的配置文件了,deployment Pod service的配置都包含了
  • nginx的配置文件
  • 问题现象: 访问前端页面提示: 500 Internal Server Error nginx/1.29.1

使用docker k8s 部署前端,这是yaml文件,部署之后访问前端,
报错: 500 Internal Server Error nginx/1.29.1

deployment yaml文件
---
apiVersion: apps/v1
kind: Deployment
metadata:
  annotations:
    deployment.kubernetes.io/revision: '5'
    k8s.kuboard.cn/workload: nginx-robot-h5
  creationTimestamp: '2025-09-17T02:54:00Z'.........
---

nginx配置文件: 
server {
    listen       80;
    client_max_body_size 500M; 
    location / {
        root /home/robot-h5-fe/html/robot-h5;
        try_files $uri $uri/ /index.html;
    }
    
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}

前端只有一个测试的index.html文件
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <h1>test</h1>
  </body>
</html>


根据以上信息,分析问题并解决报错问题
DeepSeek回答分析如下:

image.png

我一看nginx配置文件,嘛耶,竟然就是挂载路径的问题😥
原来我一直都想错了,之前一直想着,容器节点挂载ngixn的配置文件,配置的路径是节点的路径,所以nginx中配置的 root 写的就是 节点的 路径。
对比其他项目的配置,确实是,nginx 配置中路径应该对应的是容器内部的路径。顿时恍然大悟😥😥

4.问题解决

其他任何配置 任何步骤都没有问题,需要修改nginx配置文件

  • 1.修改配置文件

    server {
        listen       80;
        client_max_body_size 500M; 
    
        location / {
            add_header Cache-Control no-store;
    
            # ✅ 正确写法
            root   /usr/share/nginx/html/robot-h5;
    
            # ❌ 错误写法 写的是节点路径了😥
            # root   /home/robot-h5/html/robot-h5;
    
            try_files $uri $uri/ /index.html;
        }
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   /usr/share/nginx/html;
        }
    }
    

    呢其实也说明了,如果nginx配置的路径不存在呢会出现nginx 报500 的错误了。

  • 2.重启容器,访问前端一切正常,至此终于解决了

5.总结

  • 1.使用DeeSeek分析问题:
    • 利用DeepSeek解决问题,提示词很关键,好的提示词可以快速定位问题,提高效率
    • 尽可能的提供用于分析的文件
    • 将问题、现象描述清楚
  • 2.多对比: 其实开始的排查已经接近真相了,就是不够仔细,现有项目已有例子了,只需要仔细对比即可发现问题所在了
  • 3.需要提高写提示词的能力: 多学习优秀的提示词,提高写提示词水平

6.讨论与交流

你在使用Nginx部署前端时遇到过类似的问题吗?欢迎在评论区分享你的经验和解决方案!

相关话题:

  • K8s中Nginx的最佳实践
  • 前端静态资源部署的常见坑点
  • AI工具在故障排查中的应用技巧

flutter 使用dio发送本地https请求报错

大家好,我是1024小神,技术群 / 私活群 / 股票群 或 交朋友 都可以私信我。 如果你觉得本文有用,一键三连 (点赞、评论、关注),就是对我最大的支持~

flutter: 测试异常: DioException [unknown]: null Error: HandshakeException: Handshake error in client (OS Error: CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate(../../third_party/boringssl/src/ssl/handshake.cc:393))

包括使用 Image.network 加载 https://192.168.31.164:9080/static/images/userimg.png 网络图片,也报错: ======== Exception caught by image resource service ================================================ The following HandshakeException was thrown resolving an image codec: Handshake error in client (OS Error: CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate(../../third_party/boringssl/src/ssl/handshake.cc:393))

dio报错解决办法

设置不进行https校验:

Image.network报错

本质是 Flutter 内置的网络请求(包括 Image.network 使用的 HttpClient)会验证 HTTPS 证书,而你本地服务用的是自签/不受信任的证书,所以握手失败。

Image.network 没法直接传 badCertificateCallback,所以要从 全局层面修改 HttpClient 的证书验证策略


✅ 方案 1:在开发环境全局忽略证书(最简单)

新建一个类继承 HttpOverrides,并在 main() 里覆盖:

import 'dart:io';
import 'package:flutter/material.dart';

class MyHttpOverrides extends HttpOverrides {
  @override
  HttpClient createHttpClient(SecurityContext? context) {
    return super.createHttpClient(context)
      ..badCertificateCallback = (X509Certificate cert, String host, int port) => true;
  }
}

void main() {
  HttpOverrides.global = MyHttpOverrides(); // 忽略所有证书验证
  runApp(const MyApp());
}

再次使用就可以了

大家好,我是1024小神,技术群 / 私活群 / 股票群 或 交朋友 都可以私信我。 如果你觉得本文有用,一键三连 (点赞、评论、关注),就是对我最大的支持~
❌