阅读视图

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

前端看go并发

Go 语言的 协程 (Goroutine) 和 JavaScript 的 Web Workers 都是为了处理并发任务,但它们在底层实现、资源消耗和通信方式上有本质区别。


1. 核心差异对比表

特性 Go 协程 (Goroutine) JS Web Workers
本质 用户态轻量级线程 (M:N 调度) 操作系统级线程 (1:1 映射)
内存消耗 极小 (初始约 2KB) 较大 (通常几 MB)
启动速度 极快 (纳秒级) 较慢 (需要启动独立环境)
通信方式 Channel (管道) 或 共享内存 postMessage (结构化克隆数据)
数据共享 可以共享 (通过指针/引用) 完全隔离 (无法直接操作主线程变量)
数量级 轻松开启 百万级 通常建议 不超过 CPU 核心数

2. Go 协程代码演示

Go 协程的特点是:极其简单、共享内存、通信高效

package main

import (
"fmt"
"time"
)

func task(id int, ch chan string) {
// 协程可以直接访问外部变量,也可以通过 channel 通信
result := fmt.Sprintf("任务 %d 完成", id)
ch <- result
}

func main() {
ch := make(chan string)

// 开启 1000 个协程几乎不占资源
for i := 0; i < 1000; i++ {
go task(i, ch) 
}

// 接收结果
for i := 0; i < 10; i++ {
fmt.Println(<-ch)
}
time.Sleep(time.Second)
}

3. Web Worker 代码演示

Web Worker 的特点是:完全隔离、环境独立、通信开销大

主线程 (main.js):

const worker = new Worker('worker.js');

// 只能通过发送消息通信
worker.postMessage({ id: 1 });

// 监听返回
worker.onmessage = function(e) {
    console.log('收到结果:', e.data.result);
};

工作线程 (worker.js):

onmessage = function(e) {
    // 这里无法访问主线程的 window, document 或任何变量
    const result = `任务 ${e.data.id} 完成`;
    postMessage({ result: result });
};

4. 深度区别详解

A. 内存与上下文切换

  • Go: 协程是协作式调度的。Go 运行时(Runtime)会管理成千上万个协程,并将它们映射到少量的系统线程上。切换协程只涉及少量寄存器的保存,代价极低。
  • JS: 每一个 Web Worker 都是一个真实的操作系统线程,拥有独立的内存空间、独立的 V8 实例。这意味着启动一个 Worker 的代价非常高,且它们之间切换由操作系统控制。

B. 数据通信(关键点)

  • Go: 遵循 “不要通过共享内存来通信,而要通过通信来共享内存” 的哲学。你可以通过 Channel 传递指针(高效),也可以加锁(Mutex)直接修改同一个变量。
  • JS: 遵循 “零共享”postMessage 传递数据时,浏览器会先对数据进行结构化克隆(Structured Clone),即深拷贝。如果你传一个 1GB 的对象,主线程和 Worker 都会占用 1GB 内存,且拷贝过程非常耗时。

C. 适用场景

  • Go 协程: 几乎所有高并发场景。高并发 Web 服务器、微服务、大规模爬虫、实时推送系统。
  • Web Worker: 计算密集型任务。例如在浏览器中处理超大图片、加解密、复杂物理计算、视频转码。它存在的意义是不让耗时计算卡死主线程(UI 渲染)。

总结建议

  • 如果你追求 海量任务、极速响应,Go 的协程是绝对的王者。
  • 如果你在浏览器中为了 不让页面卡顿 而处理耗时逻辑,Web Worker 是唯一的选择。
  • 在 Node.js 服务端,如果需要类似 Go 的并发,通常使用 worker_threads 模块,它的行为更接近 Web Worker,依然是基于线程隔离的。

go 并发 并行 异步

在 Go 语言中,理解 并发 (Concurrency)并行 (Parallelism)异步 (Asynchrony) 的区别是进阶的关键。Go 的设计哲学深受这些概念的影响。

1. 核心概念对比 (Analogy)

我们可以用“咖啡馆”来做类比:

概念 类比场景 关注点
并发 (Concurrency) 一个服务员同时为多桌客人服务。他在 A 桌点完菜,不等菜上桌,就去 B 桌倒水。他是在处理多件事,但某一瞬间只能做一件事。 结构 (Structure):如何组织代码以处理多个任务。
并行 (Parallelism) 多个服务员同时工作。服务员 A 在给 A 桌点菜,服务员 B 同时在给 B 桌倒水。他们在执行多件事。 执行 (Execution):在多核 CPU 上同时运行。
异步 (Asynchrony) 客人点完餐后拿到一个取餐号,然后回座位玩手机。等厨房做好了,会通过广播(回调/信号)通知他。 非阻塞 (Non-blocking):发起请求后立即返回,不原地等待结果。

2. Go 语言中的实现

A. 并发 (Concurrency) —— Go 的强项

Go 通过 Goroutine (协程) 实现并发。Go 的口号是:“不要通过共享内存来通信,而要通过通信来共享内存。” 并发是 Go 程序的设计属性。即使在单核 CPU 上,你也可以开启 100 万个协程。

B. 并行 (Parallelism) —— 硬件支撑

Go 运行时(Runtime)会自动将并发的协程调度到多个系统线程上。如果你的电脑有多个 CPU 核心,Go 就会自动实现并行。 你可以通过 runtime.GOMAXPROCS(n) 来限制并行使用的核心数。

C. 异步 (Asynchrony) —— “伪同步”写法

在 Node.js 中,异步通常通过 callback, Promise, async/await 实现。 在 Go 中,异步逻辑是用同步的方式写的。当你发起一个网络请求时,当前的协程会“阻塞”,但 Go 运行时的底层其实是异步非阻塞的(使用 epoll/kqueue),它会自动把 CPU 让给其他协程。


3. 代码演示与详解

package main

import (
"fmt"
"runtime"
"sync"
"time"
)

func task(name string, wg *sync.WaitGroup) {
defer wg.Done()
for i := 1; i <= 3; i++ {
fmt.Printf("任务 %s: 正在处理第 %d 步\n", name, i)
// 模拟耗时操作(这里会触发协程切换,体现并发)
time.Sleep(time.Millisecond * 100)
}
}

func main() {
// 1. 并行度设置:查看当前系统的 CPU 核心数
cpuCores := runtime.NumCPU()
fmt.Printf("系统核心数: %d\n", cpuCores)
    
// 2. 这里的 WaitGroup 用于同步等待所有协程完成(类似于 Promise.all)
var wg sync.WaitGroup

fmt.Println("--- 程序开始运行 ---")

// 3. 启动两个并发任务
wg.Add(2)
go task("A", &wg) // 开启协程 A
go task("B", &wg) // 开启协程 B

// 这里的代码继续执行,体现了“异步”发起的特性
fmt.Println("主线程:我已经下达了任务,现在我去忙别的了...")

wg.Wait() // 阻塞等待 A 和 B 完成
fmt.Println("--- 所有任务完成 ---")
}

4. 深度对比:Go vs Node.js

特性 Go 语言 Node.js (JavaScript)
模型 CSP (通信顺序进程) Event Loop (事件循环)
线程 多线程 (M:N 调度) 单线程 (通过异步 I/O 模拟并发)
阻塞感 看起来是同步阻塞的,实则异步。代码顺序执行,逻辑清晰。 必须使用 await 或回调,否则会产生异步副作用。
复杂任务 擅长 CPU 密集型 + I/O 密集型。 擅长 I/O 密集型,CPU 密集型会阻塞事件循环。

总结:如何理解?

  1. 并发是逻辑上的:你在代码里写了 go func(),你的程序就具备了并发处理的能力(结构)。
  2. 并行是物理上的:当你的程序运行在多核机器上,Go 自动让这些 go func() 在不同的核心上同时跑(效率)。
  3. 异步是体验上的:在 Go 里,你不需要写复杂的 thencallback。Go 让你用最简单的同步代码,享受高性能的异步底层。

一句话:Go 的伟大之处在于,它用并发(协程)的简单模型,完美利用了并行的硬件能力,并屏蔽了异步编程的复杂性。

01-想做 Code Agent,但不想只会调 API?我把 Claude Code 源码拆成了一套教程

关键词:Code Agent / Claude Code / CLI / Bootstrap / QueryEngine / Agent 架构 / 工程设计

别把 Code Agent 当聊天机器人:先看懂 Claude Code 的总架构和启动链路

很多人分析 Code Agent,一上来就盯着模型调用,结果越看越碎。

真正的问题不是“它调了哪个模型”,而是这套系统怎么从一个 CLI 命令,变成一个能长期执行任务的 Agent。Claude Code 的前两章其实就在回答这件事:一个 Code Agent 的骨架到底长什么样,它又是怎么被启动起来的。

一、先把范式分清:Chatbot、Copilot、Agent 不是同一种东西

从工程上看,这三者的差异不在 UI,而在执行边界。

类型 能做什么 不能做什么
Chatbot 一问一答、生成文本 不主动行动
Copilot 读编辑器上下文、给建议 通常不闭环执行
Code Agent 调工具、看结果、继续推进 不能没有状态机

Code Agent 的本质不是“更强的聊天”,而是下面这条循环:

flowchart TD
    U["用户输入"] --> C["组装上下文"]
    C --> M["调用模型"]
    M --> R{"返回类型"}
    R --> |"最终回答"| END["结束"]
    R --> |"tool_use"| T["调用工具"]
    T --> TR["工具结果回写历史"]
    TR --> M

只要系统进入这个闭环,它就不再是 Chatbot,而是执行器。

二、Claude Code 的总架构,其实是七块东西咬在一起

把源码抽掉细节,Claude Code 的主骨架是这样的:

CLI / Bootstrap
    ↓
QueryEngine / queryLoop
    ├─ Context Management
    ├─ Tool System
    ├─ Permissions / Hooks
    ├─ Skills / Plugins / MCP
    └─ UI Layer (Ink/React)

这里真正的主干不是 UI,也不是模型 SDK,而是中间三层:

  • queryLoop:负责让任务一轮轮继续;
  • Context:负责让模型每一轮都知道自己处在什么状态;
  • Tool System:负责把模型意图变成真实操作。

换句话说,Claude Code 不是“终端里包了一个 LLM”,而是“用 LLM 驱动的一套工具执行框架”。

三、CLI 启动的第一原则:快路径不能被慢路径拖累

Claude Code 在 cli.tsx 里先做了快速路径分流。像 --version 这种命令,根本不值得把主系统拉起来:

async function main(): Promise<void> {
  const args = process.argv.slice(2);

  if (args.length === 1 && (args[0] === '--version' || args[0] === '-v')) {
    console.log(`${MACRO.VERSION} (Claude Code)`);
    return;
  }
}

这个做法看起来普通,但它代表一个很成熟的 CLI 判断:

非主路径必须延迟加载,不能污染主路径的冷启动时间。

所以内部 MCP 模式、daemon worker、后台会话管理等路径,全部走动态 import()
不需要的模块,不在普通交互场景里承担启动成本。

四、main.tsx 做得最好的地方,不是功能多,而是“等待重叠”

进入 main.tsx 后,Claude Code 立刻做三件事:

import { profileCheckpoint } from './utils/startupProfiler.js';
profileCheckpoint('main_tsx_entry');

import { startMdmRawRead } from './utils/settings/mdm/rawRead.js';
startMdmRawRead();

import { startKeychainPrefetch } from './utils/secureStorage/keychainPrefetch.js';
startKeychainPrefetch();

这意味着:

  • 入口开始时先打性能点;
  • 企业配置读取立即启动;
  • Keychain 中的 token / API key 预取立即启动;
  • 后续模块加载继续往下跑。
sequenceDiagram
    participant M as main.tsx
    participant K as Keychain
    participant L as 模块加载

    M->>K: startKeychainPrefetch()
    M->>L: 继续加载 Ink / Commander / React
    par 并行
      K-->>K: 读取凭证
      L-->>L: 模块求值
    end

它不是在做复杂优化,而是在贯彻一个很基本的工程原则:把等待和计算重叠起来
CLI 工具的启动体验,往往就输赢在这种细节上。

五、执行模式必须尽早判断:交互式和无头模式根本不是一回事

Claude Code 很早就判断当前是不是非交互模式:

const hasPrintFlag = cliArgs.includes('-p') || cliArgs.includes('--print');
const isNonInteractive =
  hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || !process.stdout.isTTY;

关键点不是 -p,而是这一句:

!process.stdout.isTTY

这意味着只要输出不是终端,比如:

  • 被管道消费;
  • 被重定向到文件;
  • 跑在 CI 里;

它就自动转成非交互模式。

这才是一个能被脚本和流水线真正利用的 Agent CLI。
如果一个 Agent 只能服务“人在终端前手动敲命令”的场景,它的工程价值会被大幅限制。

六、参数解析不是装饰,它定义了系统有多少种工作姿态

main.tsx 里用 Commander.js 注册了大量参数。重要的不是“参数多”,而是参数直接映射系统姿态:

  • --print:无头执行;
  • --bare:跳过 hooks、LSP、插件同步等附加能力;
  • --permission-mode:切换权限模型;
  • --model / --effort:改变推理策略;
  • --allowed-tools / --disallowed-tools:收紧或放宽工具池;
  • --add-dir:调整文件可访问范围。

这说明 Claude Code 不是只有一种运行方式。
它是同一套核心循环,在不同环境里切换不同外壳。

七、真正的入口不是 main(),而是 query()

CLI 解决的是“怎么启动”,真正决定 Agent 行为的是 src/query.ts 里的 query()

export async function* query(
  params: QueryParams,
): AsyncGenerator<StreamEvent | RequestStartEvent | Message | ...> {
  const consumedCommandUuids: string[] = []
  const terminal = yield* queryLoop(params, consumedCommandUuids)
  for (const uuid of consumedCommandUuids) {
    notifyCommandLifecycle(uuid, 'completed')
  }
  return terminal
}

最值得注意的是它为什么是 async function*

因为 Agent 不是“跑完再返回”的程序,而是会在过程中持续产生事件:

  • 模型输出;
  • 工具调用;
  • 工具执行进度;
  • 工具结果;
  • 结束原因。

如果不用 async generator,就很难同时做好实时 UI 和中间状态分发。

八、queryLoop() 是整套系统的心跳

真正执行循环的是 queryLoop()。它维护一份跨轮次状态:

type State = {
  messages: Message[]
  toolUseContext: ToolUseContext
  autoCompactTracking: AutoCompactTrackingState | undefined
  maxOutputTokensRecoveryCount: number
  turnCount: number
  transition: Continue | undefined
}

这份状态告诉我们,Claude Code 从来不是“多调几次模型”。
它是一个明确的状态推进器。

每轮循环都做这几件事:

  1. 取当前消息;
  2. 检查是否需要压缩;
  3. 调模型;
  4. 看到 tool_use 就派发工具;
  5. 收集结果回写历史;
  6. 判断是否继续。
flowchart TD
    A["准备消息"] --> B["必要时压缩上下文"]
    B --> C["调用模型"]
    C --> D{"出现 tool_use?"}
    D --> |"是"| E["执行工具"]
    E --> F["工具结果回写 messages"]
    F --> A
    D --> |"否"| G{"是否完成?"}
    G --> |"是"| H["返回 Terminal"]
    G --> |"否"| A

只要你理解了这一步,就会知道为什么很多 Agent Demo 看起来会动,但一进真实任务就塌:
它们没有把“任务推进”做成状态机,只是做成了多轮问答。

九、第一篇该记住的,不是某个函数,而是三条原则

Claude Code 的前两章合起来,核心其实只有三条:

1. Agent 不是聊天产品,而是执行系统

只要它进入“调用工具-观察结果-继续决策”的闭环,它就不是普通聊天。

2. 启动路径本身就是工程能力的一部分

快路径分流、并行预热、模式早判,这些都不是边角优化,而是主能力。

3. 一切最终都收敛到 queryLoop 这个状态推进器

CLI、参数、模式、上下文、工具,最后都只是为了让这条循环稳定工作。

最后

如果把 Claude Code 当成“会写代码的聊天机器人”,后面很多设计你都会看不懂。
只有把它当成一个长期运行的执行框架,你才会理解:

  • 为什么启动要这么抠延迟;
  • 为什么模式要这么早分流;
  • 为什么 query() 要做成 async generator;
  • 为什么整个系统要围绕 queryLoop 组织。

这才是看 Claude Code 前两章最该拿到的东西。

React&Vue知识点汇总

Vue

1. 声明式渲染

  • 模板语法{{ }} 文本插值、v-html 输出 HTML

  • 指令

    • 内置指令:v-bindv-onv-modelv-if/v-else-if/v-elsev-showv-forv-prev-cloakv-once
    • 自定义指令:全局 Vue.directive(Vue 2)/ app.directive(Vue 3),局部 directives 选项

2. 响应式系统

  • Vue 2:基于 Object.defineProperty,递归遍历对象属性,无法检测

    • 对象属性的添加/删除(需用 Vue.set / this.$set
    • 数组索引修改(需用变异方法或 Vue.set
    • 数组长度变化(需用 splice 等)
  • Vue 3:基于 Proxy,可监听动态添加属性、数组索引修改,性能更优,支持 MapSet 等原生集合的响应式

  • 响应式原理

    • 数据劫持:Vue 对 data 中的属性进行响应式处理(Vue 2 用 Object.defineProperty,Vue 3 用 Proxy)。
    • 依赖收集:当组件渲染或计算属性等执行时,会访问响应式数据,此时将当前正在执行的 Watcher(观察者)添加到该数据的依赖列表(Dep)中。
    • 派发更新:当数据被修改时,Dep 通知所有依赖它的 Watcher 执行更新。
    • 异步更新队列Watcher 更新时并不立即执行 DOM 操作,而是将自身推入一个队列,在下一个事件循环(microtask)中统一执行,并利用 nextTick 提供更新后的回调。

3. vue3 API

API 说明
ref() 声明任意类型的响应式数据,需通过 .value 访问。
reactive() 声明对象/数组类型的响应式数据,可直接访问属性。
computed() 定义计算属性,基于响应式依赖缓存结果。
watch() 监听特定数据源,在数据变化时执行副作用。
watchEffect() 自动追踪其内部使用的响应式数据,并在数据变化时立即重新运行。
onMounted()onUpdated()onUnmounted() 等 组件生命周期不同阶段执行的钩子函数,用法与选项式 API 对应。
provide()inject() 用于跨层级组件通信,祖先组件提供数据,后代组件注入使用。
toRefs()toRef()isRef()unref() 等 用于处理 ref / reactive 对象的辅助函数,帮助进行响应式转换和判断。
defineProps()defineEmits() 在 <script setup> 中声明组件的 props 和 emits,享受完整类型推导。
defineExpose() 在 <script setup> 中声明当前组件暴露给父组件的属性或方法。
defineOptions() 在 <script setup> 中声明组件名 (name) 或 inheritAttrs 等选项。

内置组件

组件 说明
<component> 用于动态渲染不同组件的“元组件”,通过 is 属性决定
<transition> 为单个元素或组件添加进入/离开过渡动画
<transition-group> 为列表中的多个元素或组件添加过渡动画
<keep-alive> 缓存动态组件,避免重复渲染和状态丢失
<teleport> 将组件模板的一部分渲染到 DOM 树中的指定位置
<suspense> 管理异步组件或依赖异步数据的组件,在等待时显示后备内容
<slot> 作为组件模板中的插槽出口,接收父组件分发的内容

内置指令

指令 说明
v-model 在表单元素或组件上创建双向绑定。
v-ifv-else-ifv-else 条件渲染,为 false 时不渲染元素。
v-show 条件渲染,通过 CSS 的 display 属性切换。
v-for 基于源数据多次渲染元素或模板块。
v-on (@) 绑定事件监听器。
v-bind (:) 动态地绑定一个或多个属性。
v-slot (#) 用于声明具名插槽或作用域插槽。

4. 生命周期钩子

阶段 Vue 2 钩子 Vue 3 钩子(Options API) Vue 3 钩子(Composition API)
初始化 beforeCreate, created setup() 代替 beforeCreate/created
挂载 beforeMount, mounted onBeforeMount, onMounted
更新 beforeUpdate, updated onBeforeUpdate, onUpdated
卸载 beforeDestroy, destroyed beforeUnmount, unmounted onBeforeUnmount, onUnmounted
错误捕获 errorCaptured onErrorCaptured
其他 activated, deactivated(keep-alive) onActivated, onDeactivated
调试 renderTracked, renderTriggered(开发) onRenderTracked, onRenderTriggered

5. 插槽

  • 默认插槽、具名插槽、作用域插槽(slot-scope in Vue 2,v-slot in Vue 2.6+ & Vue 3)
  • Vue 3 中 v-slot 统一为指令语法,slot 和 slot-scope 被废弃

6. 混入(Mixin)

  • 全局混入、局部混入
  • 合并策略:数据递归合并,同名钩子合并为数组,方法/组件/指令等直接覆盖
  • 缺点:命名冲突、隐式依赖、代码不直观 → 推荐组合式 API 替代

7. 自定义指令

  • 钩子函数:

    • Vue 2:bindinsertedupdatecomponentUpdatedunbind
    • Vue 3:beforeMountmountedbeforeUpdateupdatedbeforeUnmountunmounted
  • 参数:elbindingvnodeprevVnode

8. 过滤器(Filters)

  • Vue 2 支持模板内过滤器({{ msg | filter }})及全局/局部定义
  • Vue 3 中移除,推荐用计算属性或方法替代

9. 动画与过渡

  • <transition> 单元素过渡
  • <transition-group> 多元素/列表过渡
  • 类名约定:v-enter-from/v-enter-to 等(Vue 3 命名变化)
  • JavaScript 钩子:@before-enter@enter@after-enter 等

10、组件通信方式(详细对比)

方式 Vue 2 Vue 3
props / $emit 支持 支持,emit 需在 setup 中声明
v-model 单个,value + input 可多个,modelValue + update:modelValue,支持自定义修饰符
refs/refs / parent / $children $children 存在 移除 $children,推荐 ref + $parent 或组合式 API
provide / inject 默认非响应式,可传递响应式对象 支持响应式传递,可提供 ref/reactive
event bus new Vue() 作为总线 推荐用 mitt 等第三方库
Vuex Vuex 3 Vuex 4 / Pinia
slot 作用域 slot-scope v-slot 统一语法
组合式 API 可直接使用 ref 传递,逻辑复用更灵活

11、Vue Router 对比(3.x vs 4.x)

特性 Vue Router 3(Vue 2) Vue Router 4(Vue 3)
创建方式 new VueRouter(...) createRouter({ ... })
模式 mode: 'history' / 'hash' history: createWebHistory() / createWebHashHistory()
路由守卫 beforeEach / beforeResolve / afterEach 同,但支持组合式 API 中的 onBeforeRouteUpdate 等
路由元信息 meta
动态路由 addRoutes addRoute,且支持动态删除
导航故障 NavigationFailureType 更完善的类型
组合式 API 不支持 useRouteruseRoute

12、状态管理:Vuex vs Pinia

特性 Vuex 3/4 Pinia
设计理念 基于 Flux,强调 mutations / actions / getters 更简洁,直接修改 state,支持组合式 API
类型推断 需要额外处理 原生 TypeScript 支持
模块化 通过 modules 通过多个 store 自然分割
异步处理 actions 中 actions 中,可直接使用 async/await
代码量 较多模板代码 更少,更直观
热更新 有限支持 支持 store 热更新
Vue 3 推荐 可用,但官方转向 Pinia 官方推荐,轻量且强大

13、构建工具:Vue CLI vs Vite

特性 Vue CLI(基于 webpack) Vite
启动速度 慢(打包后启动) 极快(按需编译,原生 ES modules)
生产构建 基于 webpack,配置灵活但复杂 基于 Rollup,预配置更简单
插件生态 丰富的 webpack 插件 插件系统兼容 Rollup 插件,且提供 Vite 插件
配置方式 vue.config.js vite.config.js
开发环境 HMR 较慢(大规模项目) HMR 快速,保留状态
环境变量 VUE_APP_* VITE_*

14、响应式原理(Vue 2 vs Vue 3)

Vue 2 响应式

  • 遍历 data 对象,对每个属性递归调用 defineReactive,为每个属性创建 Dep(依赖收集器)。

  • 每个属性对应一个 Watcher(观察者),在渲染时收集依赖。

  • 缺点:

    • 无法检测对象属性的新增/删除(需用 Vue.set / this.$set)。
    • 无法直接通过索引修改数组(arr[0] = xx 不触发更新,需用变异方法如 pushsplice)。
    • 初始化时需要递归遍历,性能略差。

Vue 3 响应式

  • 基于 Proxy 代理整个对象,可拦截 getsetdeleteProperty 等操作。

  • 优点:

    • 动态添加/删除属性自动响应。
    • 数组索引修改和 length 变化自动响应。
    • 支持 MapSet 等原生集合。
    • 惰性响应式:只有访问到属性时才会递归代理,性能更好。

总结

Vue 2 通过 Object.defineProperty 劫持对象属性的 getter/setter 来实现响应式,但存在局限性,比如无法监听动态添加的属性,需要通过 Vue.set 处理。Vue 3 改用 Proxy,可以代理整个对象,支持多种操作拦截,解决了上述问题,同时性能更优。响应式核心是依赖收集和派发更新,在 getter 中收集依赖,在 setter 中触发更新,并通过异步队列实现批量更新

15、虚拟 DOM 与 diff 算法

diff 策略

  • 同层比较:只比较同一层节点,不跨层。
  • 双端比较(Vue 2):新旧 VNode 的 children 数组通过头尾交叉比较,找到可复用的节点。
  • 静态提升(Vue 3):编译时标记静态节点,更新时跳过它们。
  • Patch flag(Vue 3):标记动态节点,只更新变化的部分

总结

虚拟 DOM 是一种用 JS 对象模拟真实 DOM 的结构,通过 diff 算法对比新旧 VNode,找出差异并批量更新真实 DOM,减少了直接操作 DOM 的性能开销。Vue 2 的 diff 采用双端比较,Vue 3 则引入了静态提升和 patch flags,进一步优化了更新效率。key 是 diff 过程中识别节点的重要依据,使用稳定的 key 可以保证节点复用,避免渲染错误。

16、 生命周期钩子(执行顺序、使用场景)

父子组件生命周期顺序

  • 创建:父 beforeCreate → 父 created → 子 beforeCreate → 子 created → 子 beforeMount → 子 mounted → 父 mounted
  • 更新:父 beforeUpdate → 子 beforeUpdate → 子 updated → 父 updated
  • 销毁:父 beforeDestroy → 子 beforeDestroy → 子 destroyed → 父 destroyed

常用钩子作用

  • beforeCreate:实例初始化后,数据观测和事件配置之前。无法访问 data、props。
  • created:可访问数据,但 DOM 未挂载,适合异步请求、初始化数据。
  • mounted:DOM 已挂载,可操作 DOM,适合第三方库初始化。
  • beforeDestroy:销毁前,适合清除定时器、取消订阅。
  • activated / deactivatedkeep-alive 组件激活/停用。

17、$nextTick 原理及使用场景

原理

Vue 的异步更新队列。数据变化后,Vue 将开启一个队列,把同一个事件循环内的所有数据变化缓存起来,然后在下一个事件循环(microtask)统一执行 DOM 更新。$nextTick 的回调会在 DOM 更新完成后执行。

使用场景

  • 在数据变化后,需要获取更新后的 DOM 结构。
  • 需要在 mounted 钩子中确保子组件渲染完成。
  • 异步操作后需要等待 DOM 同步。

面试回答

$nextTick 利用 Promise 或 MutationObserver 等微任务机制,将回调延迟到下次 DOM 更新循环之后执行。我们常用来解决数据变化后立即操作 DOM 的问题,比如滚动到底部、获取元素宽高等。Vue 3 中同样有 nextTick 函数,可在组合式 API 中使用。

18、 keep-alive 实现原理及生命周期

作用

缓存不活动的组件实例,避免反复渲染。

原理

内部维护一个缓存对象(键是组件的 key 或自身),当组件切换时,将被移除的组件实例保留在缓存中,而不是销毁。再次激活时从缓存取出复用,触发 activated 和 deactivated 钩子。

相关属性

  • include / exclude:正则或数组,指定要缓存/不缓存的组件。
  • max:最大缓存数,超出时根据 LRU 策略删除。

生命周期

  • 首次进入:created → mounted → activated
  • 缓存后再次进入:activated(不会重新执行 created / mounted
  • 离开时:deactivated

面试回答

keep-alive 是一个抽象组件,它通过缓存 VNode 来保留组件状态,避免重复渲染。内部使用 LRU 算法管理缓存,可以通过 include 和 max 控制缓存策略。被缓存的组件会多出 activated 和 deactivated 钩子,用于在激活/停用时执行逻辑。

19、 组合式 API 与选项式 API 的优缺点

选项式 API(Vue 2 主流):

  • 优点:结构清晰(data、methods、computed 分块),适合初学者。
  • 缺点:逻辑分散,复杂组件难以维护;复用逻辑需借助 mixin,存在缺陷。

组合式 API(Vue 3 引入):

  • 优点:

    • 逻辑集中,按功能组织代码,可读性和可维护性高。
    • 逻辑复用简单,通过组合函数(hooks)实现,无命名冲突。
    • 更好的 TypeScript 类型推断。
  • 缺点:学习曲线稍陡,对初学者不够直观。

面试回答

“选项式 API 将组件选项按类型划分,代码直观但逻辑分散。组合式 API 将相关逻辑聚合在 setup 中,通过组合函数实现复用,尤其适合大型复杂组件。Vue 3 并未废弃选项式 API,两者可混用,但组合式 API 提供了更好的逻辑复用能力和类型支持,是未来的推荐写法。”

20、SSR 原理及优缺点

原理

  • 服务端运行 Vue 应用,生成 HTML 字符串直接返回给浏览器,客户端再“激活”(hydrate)为可交互应用。
  • 同构:同一份代码在服务端和客户端均可运行。

优点

  • 更好的 SEO:搜索引擎能抓取完整 HTML。
  • 更快的首屏加载:用户无需等待 JS 下载即可看到内容。

缺点

  • 开发复杂度高:需考虑 Node.js 环境兼容性。
  • 服务器负载大:每个请求都重新渲染,需注意缓存策略。
  • 部分 API 在服务端不可用(如 window、document),需条件判断。

面试回答

“SSR 在服务端将 Vue 组件渲染成 HTML,发送给客户端,然后客户端进行激活。它主要解决 SPA 的 SEO 问题和首屏加载速度。但实现成本较高,需要处理服务端和客户端环境的差异,并关注服务器性能。通常我们会借助 Nuxt.js(Vue 2)或 Nuxt 3(Vue 3)这样的框架来简化 SSR 开发。”

21、Vue 3 新特性及与 Vue 2 的区别

核心新特性

  • 组合式 API:更好的逻辑复用和代码组织。
  • Proxy 响应式:解决 Vue 2 的响应式局限,性能更优。
  • Teleport:将组件内容渲染到任意 DOM 位置。
  • Fragment:组件支持多个根节点。
  • Suspense:用于异步组件加载时的占位。
  • 全局 API 改造createApp 替代 new Vue,全局配置隔离。
  • 更好的 TypeScript 支持:源码用 TS 重写,类型更完善。
  • 性能提升:编译优化(静态提升、patch flag),打包体积更小。
  • Vite 官方构建工具:开发体验极大提升。

破坏性变更

  • 移除过滤器、$children$on/$once/$offv-on.native 等。
  • v-model 默认 prop 和事件变化,支持多个绑定。
  • v-if 与 v-for 优先级改变。

面试回答

“Vue 3 相比 Vue 2 在响应式系统、组合式 API、性能、TypeScript 支持等方面有重大改进。它引入了 Teleport、Suspense 等内置组件,并用 createApp 创建应用,避免全局污染。虽然有一些破坏性变更,但官方提供了迁移构建和工具帮助升级。Vue 3 也带来了更现代的构建工具 Vite,提升了开发体验。”

22、常见API使用方式 defineEmits、defineExpose、defineOptions、defineProps、

1. defineProps – 接收父组件传递的数据

作用:声明组件的 props(属性),代替传统的 props 选项。

基本用法

<script setup>
// 运行时声明(自动推断类型)
const props = defineProps(['title', 'count'])

// 带类型的声明(TypeScript)
const props = defineProps<{
  title: string
  count?: number   // 可选
}>()
</script>

父组件使用

<MyComponent title="Hello" :count="10" />

2. defineEmits – 向父组件发送事件

作用:声明组件可以触发的事件。

<script setup>
// 简单声明
const emit = defineEmits(['update', 'delete'])

// 带参数验证(TypeScript)
const emit = defineEmits<{
  (e: 'update', id: number): void
  (e: 'delete', name: string): void
}>()

// 触发事件
emit('update', 123)
</script>

父组件监听

<MyComponent @update="handleUpdate" @delete="handleDelete" />

3. defineExpose – 暴露组件内部属性/方法给父组件(通过 ref)

作用:默认 <script setup> 下的组件是关闭的,父组件无法通过 ref 访问其内部成员。使用 defineExpose 明确暴露。

<script setup>
import { ref } from 'vue'

const count = ref(0)
const increment = () => count.value++

// 只暴露 increment 和 count,其他不暴露
defineExpose({
  increment,
  count
})
</script>

父组件访问

<template>
  <MyComponent ref="childRef" />
</template>

<script setup>
import { ref, onMounted } from 'vue'
const childRef = ref()

onMounted(() => {
  childRef.value.increment()   // 调用子组件方法
  console.log(childRef.value.count) // 读取子组件数据
})
</script>

4. defineOptions – 设置组件选项(Vue 3.3+)

作用:在 <script setup> 中声明组件名、继承属性、自定义选项等,无需单独的 <script> 块。

<script setup>
defineOptions({
  name: 'MyCustomName',      // 组件名称
  inheritAttrs: false,       // 是否继承非 prop 属性
  // 其他选项(如 components、directives 一般不在这里,但可自定义)
})
</script>

典型场景

  • 设置组件名(方便 Vue Devtools 识别)
  • 关闭属性继承(手动控制 $attrs

23、wacth & watchEffect 区别

特性 watch watchEffect
依赖收集 显式指定要监听的数据源(ref、reactive 属性、getter 函数) 自动收集回调函数中使用的所有响应式数据
初始执行 默认不执行,数据第一次变化时才执行(可配置 immediate: true 立即执行一次,同时收集依赖
访问新旧值 回调中提供旧值和新值 只能访问新值(无法直接获取旧值)
监听多个源 支持同时监听多个数据源(数组形式) 自动收集多个依赖,无需显式指定
精准控制 可以配置 deepflushimmediate 等选项 只有 flush 选项(以及 onTrack/onTrigger 调试)
停止监听 调用返回的函数 同样返回停止函数
适用场景 需要知道具体哪个数据变化、需要旧值、需要惰性执行 简单副作用,自动跟踪依赖,不需要旧值

1、watch 基础用法

<script setup>
import { ref, reactive, watch } from 'vue'

const count = ref(0)
const state = reactive({ name: 'Vue', age: 3 })

// 监听单个 ref
watch(count, (newVal, oldVal) => {
  console.log(`count 从 ${oldVal} 变为 ${newVal}`)
})

// 监听 getter 函数
watch(
  () => state.age,
  (newAge, oldAge) => {
    console.log(`年龄从 ${oldAge} 变为 ${newAge}`)
  }
)

// 监听多个源(数组)
watch([count, () => state.age], ([newCount, newAge], [oldCount, oldAge]) => {
  console.log(`count: ${oldCount}->${newCount}, age: ${oldAge}->${newAge}`)
})

// 立即执行 + 深度监听
watch(
  () => state,
  (newVal, oldVal) => {
    console.log('state 变化了', newVal)
  },
  { immediate: true, deep: true }
)
</script>

2、watchEffect 基础用法

<script setup>
import { ref, reactive, watchEffect } from 'vue'

const count = ref(0)
const state = reactive({ name: 'Vue', age: 3 })

// 自动收集依赖:count 和 state.age
watchEffect(() => {
  console.log(`count: ${count.value}, age: ${state.age}`)
})
// 初始立即输出:count: 0, age: 3
// 之后任何依赖变化都会重新执行

// 停止监听
const stop = watchEffect(() => { /* ... */ })
stop() // 手动停止
</script>

24、provide() 和 inject() 跨层级组件通信例子

<!-- Ancestor.vue -->
<script setup>
import { provide, ref } from 'vue'

// 提供普通值
provide('theme', 'dark')

// 提供响应式数据(推荐)
const count = ref(0)
const updateCount = () => count.value++
provide('count', count)
provide('updateCount', updateCount)
</script>
<!-- Descendant.vue -->
<script setup>
import { inject } from 'vue'

const theme = inject('theme', 'light') // 默认值 'light'
const count = inject('count')
const updateCount = inject('updateCount')

// 使用
console.log(theme)   // 'dark'
count.value++        // 响应式更新
updateCount()        // 调用方法
</script>

25、toRefs、toRef、isRef、unref 响应式引用工具

1. toRefs – 将响应式对象转换为普通对象,每个属性都是 ref

作用:解构 reactive 对象时保持响应性。

import { reactive, toRefs } from 'vue'

const state = reactive({ count: 0, name: 'Vue' })

// ❌ 直接解构会丢失响应性
let { count, name } = state
count++  // 不会触发视图更新

// ✅ 使用 toRefs 包装
const stateRefs = toRefs(state)
const { count, name } = stateRefs
count.value++ // 响应式生效

原理toRefs 为每个属性创建一个 ref 链接到原对象的对应属性。

2. toRef – 为响应式对象的单个属性创建 ref

作用:保持对源对象属性的响应式引用,常用于将 props 的某个属性转为 ref 以便传递。

import { reactive, toRef } from 'vue'

const state = reactive({ count: 0 })
const countRef = toRef(state, 'count')

countRef.value++   // 同时修改 state.count
console.log(state.count) // 1

典型场景:组合函数接收 props 中的某个属性并保持响应性。

// useFeature.js
import { toRef, watchEffect } from 'vue'
export function useFeature(propRef) {
  const propVal = toRef(propRef)  // 确保是 ref
  watchEffect(() => {
    console.log(propVal.value)
  })
}

3. isRef – 判断某个值是否为 ref

import { ref, reactive, isRef } from 'vue'

const count = ref(0)
const state = reactive({})
console.log(isRef(count)) // true
console.log(isRef(state)) // false

4. unref – 如果参数是 ref 则返回其 value,否则返回参数本身

作用:方便地获取值,无需手动判断 .value

import { ref, unref } from 'vue'

const count = ref(0)
const plain = 42

console.log(unref(count)) // 0
console.log(unref(plain)) // 42

// 等价于
function myUnref(val) {
  return isRef(val) ? val.value : val
}

常用场景:在组合函数中,参数可能是 ref 也可能是普通值,使用 unref 统一处理。

React

1. React 是什么?核心特点

React 是 Meta 开源的 JavaScript UI 库,专注于构建用户界面。核心特点

  • 声明式编程:描述 UI 状态,React 自动处理 DOM 更新。
  • 组件化:UI 拆分为独立可复用的组件。
  • 单向数据流:数据从父组件流向子组件,可预测、易调试。
  • 虚拟 DOM + Fiber:高效更新,可中断渲染。
  • JSX:JavaScript 语法扩展,允许在 JS 中写类似 HTML 的标记。

2. JSX

JSX 是 React.createElement 的语法糖

// JSX 写法
const element = <h1 className="title">Hello React</h1>;

// 编译后
const element = React.createElement('h1', { className: 'title' }, 'Hello React');

浏览器无法识别 JSX,需要通过 Babel 编译为普通 JS 代码才能执行

3、组件间通信

React 组件间通信方式取决于组件关系,主要方式如下

方式 适用场景 示例
Props 父→子传递数据 <Child message={msg} />
回调函数 子→父传递数据 父传函数给子,子调用 props.onChildData(data)
Context API 跨层级组件通信(避免 props 逐层传递) createContext + useContext
状态管理库 大型应用全局状态 Redux Toolkit、Zustand、Jotai
Refs 直接访问 DOM 或子组件实例 useRef + ref 属性
自定义 Hooks 复用状态逻辑 useAuthuseFetch 等
高阶组件(HOC) 共享逻辑(较少使用,Hooks 更优) 接收组件返回增强组件
Event Bus(事件总线) 任意组件通信(非 React 原生) mitt 等第三方库

4、API

4.1 内置 Hooks

Hook 说明
useState 在函数组件中添加和管理局部状态,返回当前状态和更新函数
useReducer 用于管理包含多个子值或依赖先前状态的复杂组件逻辑,基于 reducer 模式
useContext 读取并订阅组件中的 Context 值,避免 props 逐层传递
useRef 声明一个可变引用,可以保存任何可变值,最常见的用途是访问 DOM 元素
useImperativeHandle 自定义通过 ref 暴露给父组件的实例值,通常与 forwardRef 配合使用
useEffect 将组件连接到外部系统并处理副作用,如数据获取、订阅、手动 DOM 操作,在渲染后执行
useLayoutEffect 在浏览器重新绘制屏幕之前同步触发,用法与 useEffect 相同,但会阻塞视觉更新
useInsertionEffect 在 DOM 变异之前触发,专为 CSS-in-JS 库注入样式而设计
useMemo 缓存昂贵计算的结果,避免在每次渲染时重复计算,仅在依赖项变化时重新计算
useCallback 缓存函数定义,防止因函数重新创建导致的子组件不必要重新渲染
useTransition 将状态更新标记为"过渡",这种更新可以被中断,以避免阻塞用户界面
useDeferredValue 延迟更新 UI 的某一部分,以优先响应用户输入
useId 生成在客户端和服务器上保持稳定的唯一 ID,主要用于可访问性属性
useDebugValue 在 React DevTools 中为自定义 Hook 添加标签,用于调试
useSyncExternalStore 允许函数组件订阅外部 store(如第三方状态管理库或浏览器 API)
useOptimistic 允许在后台操作完成前乐观地更新 UI,提供即时反馈
useActionState 管理表单 action 的状态,包括 pending 状态和返回数据
use 通用的资源读取 API,用于读取 Promise 或 Context 等资源的值,可以在条件语句中调用

4.2 内置组件

这些是可以在 JSX 中使用的 React 内置组件,以 Symbol 常量形式导出

组件 说明
<Fragment> 让你无需向 DOM 添加额外节点即可对子元素列表进行分组,支持简写 <>...</>
<Profiler> 用于编程式测量 React 应用的渲染性能
<StrictMode> 用于检测应用中潜在问题的工具,不会渲染任何可见 UI
<Suspense> 允许在子组件完成加载前显示一个回退 UI
<Activity> React 19 新增 API,用于隐藏和恢复其子组件的 UI 和内部状态

4.3 工具类 API

API 说明
createContext 创建一个 Context 对象,可供组件向其子组件提供数据,搭配 useContext 使用
forwardRef 允许组件将 DOM 节点作为 ref 暴露给父组件,搭配 useRef 使用
lazy 允许在组件第一次被渲染前延迟加载其代码,实现代码分割
memo 允许组件在 props 未发生变化时跳过重新渲染,搭配 useMemo 和 useCallback 使用
startTransition 允许将状态更新标记为非紧急的,与 useTransition 类似但用于非 Hook 场景
act 在测试中用于包裹渲染和交互,确保在断言前所有更新已处理完毕
createElement 创建 React 元素,通常用 JSX 替代,但可在非 JSX 环境中使用
cloneElement 克隆并返回一个新的 React 元素,可覆盖原元素的 props
isValidElement 检查某个值是否为 React 元素
Children 提供 mapforEachcountonlytoArray 等工具方法,用于处理 props.children 不透明数据结构
Component 定义类组件的基类
PureComponent 与 Component 类似,但自带 shouldComponentUpdate 浅比较实现
createRef 创建 ref 对象,类组件中用于访问 DOM 元素

4.4 通用 DOM API

API 说明
createPortal 允许将子组件渲染到 DOM 树中父组件 DOM 层次之外的不同位置,常用于模态框、全局提示等
flushSync 强制 React 同步执行状态更新并立即刷新 DOM

4.5 资源预加载 API

这些 API 用于预加载脚本、样式表、字体等资源,从而让应用更快。基于 React 的框架通常会自动处理资源加载

API 说明
prefetchDNS 预解析 DNS 域名,提前获取 IP 地址,减少后续请求的 DNS 查询时间
preconnect 提前连接到预计请求资源的服务器,建立 TCP 连接和 TLS 握手,即使尚不确定具体需要哪些资源
preload 预获取并缓存预计要使用的资源(如样式表、字体、图片、外部脚本),但不执行,可节省时间
preloadModule 预获取预计要使用的 ESM 模块,但不执行
preinit 预获取并执行外部脚本,或预获取并插入样式表
preinitModule 预获取并执行一个 ESM 模块

4.6 通用 DOM API

API 说明
createPortal 允许将子组件渲染到 DOM 树中父组件 DOM 层次之外的不同位置,常用于模态框、全局提示等
flushSync 强制 React 同步执行状态更新并立即刷新 DOM

5、React Router(路由)

React Router v6 完全利用 Hooks 重构。

核心组件

组件 作用
BrowserRouter history 模式路由容器
HashRouter hash 模式路由容器
Routes / Route 定义路由规则
Link / NavLink 声明式导航
Outlet 嵌套路由占位符

核心 Hooks

Hook 作用
useParams 获取路由参数
useLocation 获取当前 location 对象
useNavigate 程序化导航
useRoutes 配置式路由(替代 Routes + Route

6、React-Redux

API 说明
<Provider store> 顶层组件,使 store 对下层组件可用。
connect(mapStateToProps, mapDispatchToProps, mergeProps, options) 高阶组件(HOC),将 store 中的 state 和 dispatch 映射到组件的 props。
useSelector(selector) Hook,从 store 中提取数据,当数据变化时强制组件重新渲染。
useDispatch() Hook,返回 store 的 dispatch 函数。
useStore() Hook,返回 store 实例本身(不常用)。

Redux 现代推荐: (Redux Toolkit + React-Redux Hooks )

必须掌握的核心 API

API 作用 一句话说明
configureStore 创建 store 像 createStore 但更智能,自动加 thunk 和 DevTools
createSlice 同时生成 reducer 和 action creators 传入 name、initialState、reducers 对象,自动生成
createAsyncThunk 处理异步 action 自动生成 pending/fulfilled/rejected 三个 action,并在 extraReducers 中处理
// store.js
import { configureStore, createSlice, createAsyncThunk } from '@reduxjs/toolkit';

// 1. 同步 slice
const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: state => { state.value += 1 },     // 直接“修改”
    decrement: state => { state.value -= 1 },
    incrementByAmount: (state, action) => { state.value += action.payload }
  }
});

// 2. 异步 thunk
export const fetchUser = createAsyncThunk('user/fetch', async (userId) => {
  const res = await fetch(`/api/user/${userId}`);
  return res.json();
});

// 3. 异步 slice (处理 thunk 的三种状态)
const userSlice = createSlice({
  name: 'user',
  initialState: { data: null, loading: false, error: null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => { state.loading = true })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.loading = false;
        state.data = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message;
      });
  }
});

// 导出 action creators 和 reducer
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
const store = configureStore({
  reducer: {
    counter: counterSlice.reducer,
    user: userSlice.reducer
  }
});

export default store;

React-Redux Hooks —— 在组件里用状态

两个核心 Hook

Hook 作用 类比
useSelector 从 store 中读取数据 类似 mapStateToProps
useDispatch 拿到 dispatch 函数 类似 mapDispatchToProps

使用步骤(配合上面的 store)

1. 顶层用 Provider 包裹

// main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import store from './store';
import App from './App';

ReactDOM.createRoot(document.getElementById('root')).render(
  <Provider store={store}>
    <App />
  </Provider>
);

2. 组件内读取和派发

// Counter.jsx
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement, incrementByAmount } from './store';

function Counter() {
  // 读取状态
  const count = useSelector(state => state.counter.value);
  const dispatch = useDispatch();

  return (
    <div>
      <span>{count}</span>
      <button onClick={() => dispatch(increment())}>+1</button>
      <button onClick={() => dispatch(decrement())}>-1</button>
      <button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
    </div>
  );
}

3. 异步 thunk 的派发

import { fetchUser } from './store';

function UserProfile({ userId }) {
  const dispatch = useDispatch();
  const { data, loading, error } = useSelector(state => state.user);

  useEffect(() => {
    dispatch(fetchUser(userId));
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>{data?.name}</div>;
}

7、React 底层原理

1. 虚拟 DOM

虚拟 DOM 是用 JS 对象描述真实 DOM 结构

好处

  • 减少频繁的真实 DOM 操作
  • 跨平台(React Native)
  • 便于 diff 算法计算差异

2. Diff 算法

核心策略

  • 同层比较,不跨层级
  • 类型不同 → 直接销毁重建
  • 类型相同 → 对比属性
  • 列表靠 key 识别节点复用,复杂度优化到 O(n)

为什么 key 不能用 index?
数组增删前置元素会导致 index 错乱,引发组件状态错位、DOM 复用错误。应使用唯一稳定的业务 id

3. Fiber 架构(React 16+)

解决旧 React 一次性渲染卡死主线程的问题

两个阶段

  • Render 阶段:可中断、分片、优先级调度,遍历构建 Fiber 树
  • Commit 阶段:不可中断,一次性更新 DOM、布局、绘制

4. React 更新流程

触发 setState → 生成更新任务 → Fiber 调和(可中断)→ 收集 DOM 变更 → Commit 一次性渲染 → 浏览器绘制

5. 合成事件

React 自己实现了一套事件系统(事件委托到 root 节点),性能高,与原生事件混用时,原生先执行 → 合成后执行,阻止冒泡互不生效

从单体到协同:用 LangGraph 构建 Web3 投研的多智能体工作流

前言

在上一篇文章中,我们通过一个单体 Agent 实现了 Polymarket 与新闻数据的初步整合。然而,在面对波诡云谲的实战环境时,单体架构暴露出逻辑深度不足工具调用混乱以及无法自我纠错等硬伤。

为了解决这些痛点,本文将对原有的单智能体系统进行“手术级”重构,引入 LangGraph 实现多角色协同,让你的投研工具从一个“全栈练习生”进化为一支“专业特种部队”。

一、 单体 Agent 的“天花板”

在尝试构建 Web3 投研助手时,我们通常会给一个 Agent 塞进所有工具(搜索、行情、链上监测)。但在实战中,单体 Agent 常遇到三大痛点:

  1. 注意力涣散:Prompt 过长导致模型忽略了关键的风险提示。
  2. 逻辑闭环难:模型容易“自嗨”,拿到错误数据后直接开始推演,没有纠错机制。
  3. 工具冲突:当工具超过 5 个时,模型选择工具的准确率大幅下降。

二、 多智能体的降维打击:分工与制衡

多智能体架构的核心在于 “角色拆解” 。通过将任务分给不同的 Agent,我们模拟了一个专业投研机构的运作流水线:

1. 角色纯粹化(Specialization)

  • 研究员 (Researcher) :只负责“找”。它精通各种搜索语法,不带主观偏见地搬运事实。
  • 分析师 (Analyst) :只负责“想”。它不直接查数据,而是对研究员提供的情报进行逻辑建模、胜率计算和风险评估。

2. 动态博弈与纠错(Feedback Loop)

这是多智能体最强的地方:分析师可以“打回重做” 。如果研究员提供的情报不足以支撑结论,分析师会提出具体的补查要求,迫使系统进入循环直到逻辑闭环。

三、 实战:基于 LangGraph 的投研工作流

1.工具

import * as dotenv from "dotenv";
dotenv.config();
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { TavilySearch } from "@langchain/tavily";
import axios from "axios";
import { HttpsProxyAgent } from "https-proxy-agent";

const agent = new HttpsProxyAgent("http://127.0.0.1:3067");
const axiosConfig = { timeout: 15000, httpsAgent: agent, proxy: false };
const searchInstance = new TavilySearch({ maxResults: 5 }); // 增加搜索结果以捕捉更多套利新闻

// 1. 搜索工具:强化了对套利/异动新闻的搜索描述
export const financialSearchTool = tool(
    async (input) => {
        const query = typeof input === 'string' ? input : (input.query || JSON.stringify(input));
        console.log(`\n[🔍 正在执行深度搜索]: ${query}`);
        try {
            const res = await searchInstance.invoke(query);
            return JSON.stringify(res);
        } catch (e: any) { return `搜索暂时不可用`; }
    },
    {
        name: "financial_market_search",
        description: "搜索最新新闻背景、套利机会或市场异动。请输入关键词对象,例如:{\"query\": \"Polymarket arbitrage opportunities\"}",
        schema: z.object({ query: z.string() }), 
    }
);

// 2. 深度优化的行情工具 (Arbitrage & Trend Ready)
export const marketDataTool = tool(
    async (input) => {
        const userInput = typeof input === 'string' ? input : (input.marketName || JSON.stringify(input));
        
        // 1. 行业语义映射表 (包含最新套利关键词)
        const mapping: Record<string, string[]> = {
            "原油": ["oil", "crude", "energy", "brent", "wti", "gasoline"],
            "油价": ["oil", "crude", "energy"],
            "中东": ["israel", "gaza", "iran", "lebanon", "middle east", "hezbollah", "conflict"],
            "战争": ["war", "military", "strike", "attack", "invasion"],
            "选举": ["election", "trump", "vance", "harris", "walz"],
            "停火": ["ceasefire", "truce", "peace"],
            "核": ["nuclear", "facility", "isfahan"],
            "海峡": ["hormuz", "strait", "shipping"],
            "宏观": ["fed", "rate cut", "inflation", "recession", "gdp"],
            "套利": ["arbitrage", "mispricing", "spread", "basis", "hedging"],
            "时间差": ["deadline", "expiry", "until", "before", "sooner"],
            "美联储": ["powell", "fomc", "interest rate", "basis points"],
            "加密货币": ["bitcoin", "etf", "ethereum", "solana", "ath"]
        };

        // 获取基础关注词
        let baseTerms = [userInput.toLowerCase()];
        for (const [zh, ens] of Object.entries(mapping)) {
            if (userInput.includes(zh)) {
                baseTerms = ens;
                break;
            }
        }

        // --- 优化点:自动生成组合搜索词(捕获最新最热) ---
        const hotSuffixes = ["ceasefire", "rate cut", "ath", "trump", "deadline"];
        const focusTerms = [
            ...baseTerms,
            ...baseTerms.flatMap(term => hotSuffixes.map(suffix => `${term} ${suffix}`))
        ];

        console.log(`\n[📊 正在扫描套利机会]: 领域 -> ${userInput} | 衍生词数 -> ${focusTerms.length}`);

        try {
            // 获取全平台最火的 50 个市场
            const url = `https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=50`;
            const res = await axios.get(url, axiosConfig);
            
            if (!res.data || res.data.length === 0) return "Polymarket 暂无活跃市场。";

            // 2. 精准过滤逻辑
            const relevantMarkets = res.data.filter((m: any) => {
                const title = m.question.toLowerCase();
                // 必须包含正向词,剔除体育等干扰噪音
                const hasFocus = focusTerms.some(term => title.includes(term));
                const isNoise = ["nhl", "nba", "cup", "game", "soccer", "football"].some(noise => title.includes(noise));
                return hasFocus && !isNoise;
            });

            if (relevantMarkets.length > 0) {
                // 按相关度或题目排序,方便 Agent 对比相似市场寻找套利空间
                const marketList = relevantMarkets.map((m: any) => ({
                    question: m.question,
                    price: m.lastTradePrice,
                    endDate: m.endDate
                }));

                return JSON.stringify({
                    status: "success",
                    count: marketList.length,
                    data: marketList,
                    hint: "请分析以上市场之间是否存在隐含概率冲突或定价偏差。"
                });
            }

            return `[提示]:热门榜单中暂无直接对标 "${userInput}" 的套利交易对。`;
        } catch (e: any) { return `行情接口异常: ${e.message}`; }
    },
    {
        name: "get_realtime_market_data",
        description: "获取 Polymarket 实时赔率与套利机会。支持组合搜索。",
        schema: z.object({ marketName: z.string() }),
    }
);

export const tools = [financialSearchTool, marketDataTool];

2.主程

以下是使用 LangChain 最新 LangGraph 框架实现的协作代码片段:

import { ChatOpenAI } from "@langchain/openai";
import { ToolNode } from "@langchain/langgraph/prebuilt";
import { StateGraph, MessagesAnnotation, END } from "@langchain/langgraph";
import { financialSearchTool } from "./tools"; // 复用你之前的工具

// 1. 定义两个不同的 LLM 实例(可以给分析师更高的 Temperature 来激发灵感)
const llm = new ChatOpenAI({
  // 核心修复:显式指定 API Key 和 Base URL
  apiKey: process.env.DEEPSEEK_API_KEY, 
  modelName: "deepseek-chat",
  configuration: {
    baseURL: process.env.DEEPSEEK_API_BASE_URL,
  },
  temperature: 0,
});
// 2. 定义角色逻辑
// 研究员节点:强迫它必须使用工具
async function researcherNode(state: typeof MessagesAnnotation.State) {
  const systemMessage = {
    role: "system",
    content: "你是一名 Web3 研究员。你的任务是利用搜索工具获取关于特定事件的最新事实。获取事实后,直接将其传递给分析师,不要进行深度评论。",
  };
  const response = await llm.bindTools([financialSearchTool]).invoke([systemMessage, ...state.messages]);
  return { messages: [response] };
}

// 分析师节点:负责逻辑推演
async function analystNode(state: typeof MessagesAnnotation.State) {
  const systemMessage = {
    role: "system",
    content: "你是一名顶级高级分析师。你需要审查研究员提供的事实。如果事实太模糊,请要求研究员重新搜索;如果事实充足,请给出最终的套利分析报告。你的回复必须以 '【最终报告】' 开头。",
  };
  const response = await llm.invoke([systemMessage, ...state.messages]);
  return { messages: [response] };
}

// 3. 定义路由逻辑:判断是该继续搜,还是该结束了
function shouldContinue(state: typeof MessagesAnnotation.State) {
  const lastMessage = state.messages[state.messages.length - 1];
  // 如果分析师说了“最终报告”,就结束
  if (typeof lastMessage.content === "string" && lastMessage.content.includes("【最终报告】")) {
    return END;
  }
  // 如果有工具调用,去执行工具
  if (lastMessage.additional_kwargs.tool_calls) {
    return "tools";
  }
  // 否则,让分析师看研究员的结果
  return "analyst";
}

// 4. 构建工作流图 (Graph)
const workflow = new StateGraph(MessagesAnnotation)
  .addNode("researcher", researcherNode)
  .addNode("analyst", analystNode)
  .addNode("tools", new ToolNode([financialSearchTool])) // 专门执行工具的节点
  
  // 连线逻辑
  .addEdge("__start__", "researcher") // 从研究员开始
  .addEdge("tools", "researcher")      // 工具执行完后回到研究员
  .addConditionalEdges("researcher", shouldContinue) // 研究员做完后判断:调工具还是给分析师
  .addConditionalEdges("analyst", shouldContinue);    // 分析师做完后判断:结束还是打回重搜

// 5. 编译并运行
const app = workflow.compile();

async function runMultiAgent() {
  const inputs = { messages: [{ role: "user", content: "分析以伊冲突对 Polymarket 原油预测价格的影响" }] };
  const result = await app.invoke(inputs);
  
  console.log("\n--- 协作过程结束 ---");
  console.log(result.messages[result.messages.length - 1].content);
}

runMultiAgent();

为什么这套代码能跑赢单体模型?

  • 状态可控:你可以清晰看到请求是在“搜索”阶段还是“审计”阶段。
  • 递归深度:通过 recursionLimit 限制,你可以防止 Agent 陷入死循环,同时保证了深度。
  • 模型异构:你可以让研究员用便宜快速的 GPT-4o-mini,而让分析师用逻辑更强的 DeepSeek-V3 或 Claude 3.5。

结语

单体 AI 是工具,多智能体才是“数字员工”。在信息密度极高的 Web3 领域,学会如何编排一群 Agent 协作,将是开发者真正的竞争壁垒。

JS炼化 :(this+new+构造函数)面向对象秘法包——从抽象到落地“焚诀”来了

读完秘法包不会用?焚诀来了

上一篇我们用**剑(this)、剑鞘(new)、剑法(构造函数)**的比喻,理解了三者的本质关系。 本篇目标:把零散的“剑意”合成完整的“焚诀”,从概念落地到运行逻辑,吃透面向对象底层根基

先简单回顾一下秘法包

  • this 是剑:操作对象、访问实例属性的核心工具
  • new 是剑鞘:保证 this 正确指向实例,启动构造函数
  • 构造函数是剑法:定义对象模板,批量创建同类对象

详情看上一篇秘法包基础解析,下面开始修炼焚诀

焚诀导读(焚诀修炼结构)

1. 铸剑基础 • this 常见场景(先认识)

2. 剑之本意• this 面向对象核心(重点练)

3. 剑鞘认主 • new 到底做了什么(核心)

4. 剑法定式 • 构造函数完整用法(核心)

5. 焚诀合一 • 剑·鞘·法三者联动(本篇灵魂)

焚诀修炼开始

第一重 · 铸剑基础(this基础场景)

定位:了解即可 / 说明:this的使用场景较多,先梳理非面向对象的基础场景,建立基础认知即可,无需死抠细节,后续核心内容才是修炼重点。

1. 全局作用域中的this
  • 规则:浏览器环境指向 window ,Node.js环境指向 global ,严格模式下为 undefined 
console.log(this); // 浏览器:window / Node:global
'use strict';
console.log(this); // 严格模式:undefined
2. 普通函数调用中的this
  • 规则:非严格模式默认指向全局对象,严格模式下为 undefined 
function fn() {
  console.log(this);
}
fn(); // 非严格模式:window / 严格模式:undefined
3. 箭头函数中的this(箭头函数很神,但是面向对象不合适)
  • 规则:不绑定自身this,继承外层作用域的this指向,这是与普通函数的核心区别
const obj = {
  name: '张三',
  sayName: () => {
    console.log(this.name); // 继承外层全局this,浏览器下指向window
  }// obj 的 {} 是对象字面量,不产生作用域
};
obj.sayName(); // 输出:undefined
4. call / apply / bind显式绑定this
  • 规则:可手动强制修改this指向,优先级高于默认绑定,提升代码灵活性
function fn() {
  console.log(this.name);
}
const obj = { name: '李四' };
fn.call(obj);   // 手动绑定,输出:李四
fn.apply(obj);  // 效果同call,输出:李四
const boundFn = fn.bind(obj);
boundFn();      // 永久绑定,输出:李四

 

第二重 · 剑之本意(this面向对象核心)

定位:重点掌握 说明:抛开基础场景,面向对象中this的指向逻辑极其清晰,只有两个核心场景,也是后续写代码最常用的部分,必须吃透。

1. 对象方法调用中的this
  • 规则:this指向调用该方法的所属对象,谁调用方法,this就归属谁
const obj = {
  name: '前端学习者',
  sayName() {
    console.log(this.name); // this指向调用方法的obj对象
  }
};
obj.sayName(); // 输出:前端学习者
2. 构造函数 / new调用中的this
  • 规则:this指向new创建的新实例对象,也就是剑认主的核心逻辑,this通过new找到自己的归属
function Person(name) {
  this.name = name; // this指向new创建的新实例,剑找到新主人
}
const p = new Person("张三");
console.log(p.name); // 输出:张三

 

this使用注意事项

  • 对象方法作为回调函数时,this指向易改变→用箭头函数、bind绑定或缓存 const self = this 修正指向。(这个可能有些绕,但是还是this的核心机制:this  的指向 不是由「函数定义的位置」决定的,而是由「函数被调用的方式」决定的,也就是谁调用了这个函数, this  就指向谁(箭头函数除外,它是继承外层作用域的 this) 一句话就是:谁拿起this这把剑来用,剑就听谁的指挥,就为谁服务。你也可以用 call / apply / bind 提前给剑认主,不管谁拿,剑只听你指定的那个人的话。
  • 嵌套函数中this易混乱→借助箭头函数继承外层this,或显式绑定明确指向
  • 严格模式下this默认指向undefined→规范绑定对象,避免无绑定调用

 

第三重 · 剑鞘认主(new完整知识点)

定位:核心重点 说明:new是连接构造函数与this的关键,更是剑认主的核心仪式,没有new,剑法无法施展,剑也没有归属,彻底吃透它的执行逻辑,才算掌握面向对象的核心钥匙。

1. new操作符的核心作用

核心:new就是一场认主仪式,先造主人,再让剑(this)认主,最后按剑法武装出一个完整实例。

本质:是创建构造函数的实例对象,稳定绑定this,执行构造函数逻辑,最终返回可使用的完整实例。

2. new执行的4件事(比喻+实际作用一一对应)
2.1. 造一个新主人(创建空对象)

比喻:打造一个全新的、无属性的主人肉身

实际:创建一个空的JavaScript对象 {} ,这个空对象就是未来的实例载体

2.2. 给主人归入门派(关联构造函数原型)

比喻:给新主人打上剑法的门派标记,继承门派里的公共功法

实际:将空对象的  proto 指向构造函数的 prototype 属性,为后续继承公共方法打下基础,无需重复创建方法

(早期只用  this  和  new  创建对象时,每个实例都会重复携带方法,内存占用大、不好管理。 于是就有了原型(prototype),相当于一个门派,把共用方法统一存放。 实例归入门派,就能共享所有公共方法,节省内存、方便维护。 这一步就是把空对象关联到构造函数原型,更详细的原型机制我们后面再学。)

2.3. 剑认主:把this绑定给新主人(最核心一步)

比喻:剑鞘扣紧,将无主的剑(this)正式交给新主人,完成认主仪式

实际:构造函数里的this原本没有固定归属,new在这一步强制将this绑定给刚创建的空对象,从此this只归属这个实例,完美对应“this通过new认主”

2.4. 按剑法武装主人,再把主人交出来(执行构造、返回实例)

比喻:按照剑法招式,给主人装备专属属性、武器,打造完整的修炼者

实际:运行构造函数内的代码,通过this给实例添加名字、年龄等属性和方法,若构造函数无返回对象,就将这个武装完成的实例返回出去

3. new代码示例+手动模拟实现
// 构造函数(剑法)
function Person(name, age) {
  this.name = name;
  this.age = age;
}
// 门派公共功法(原型方法)
Person.prototype.sayHi = function() {
  console.log(`Hi, I'm ${this.name}`);
};

// 执行认主仪式(new调用)
const p = new Person('张三', 18);
p.sayHi(); // 输出:Hi, I'm 张三

// 手动模拟认主仪式(模拟new)
function myNew(constructor, ...args) {
  // 1. 造新主人肉身
  const obj = {};
  // 2. 归入门派
  obj.__proto__ = constructor.prototype;
  // 3. 剑认主,绑定this并执行剑法
  const result = constructor.call(obj, ...args);
  // 4. 交出完整主人
  return result instanceof Object ? result : obj;
}

// 测试模拟new
const p2 = myNew(Person, '李四', 20);
p2.sayHi(); // 输出:Hi, I'm 李四
 

new使用注意事项

  • 构造函数必须配合new使用,省略new会让构造函数变成普通函数,this指向全局,无法完成认主→构造函数首字母大写,养成配合new调用的习惯
  • 构造函数中手动返回对象,会覆盖new创建的默认实例,认主失效→无特殊需求,不手动返回数据,依靠new自动返回实例

 

第四重 · 剑法定式(构造函数完整知识点)

定位:核心重点 说明:构造函数是剑法的定式,是批量打造主人的模板,掌握规范写法,才能高效创建同类型对象。

1. 构造函数的本质与作用

本质:ES5中JavaScript实现面向对象编程的基础形式,专门用于创建对象

作用:定义对象的通用属性和方法,作为固定剑法模板,通过new调用批量创建实例

2. 构造函数的编写规范
  • 命名规范:首字母大写,与普通函数区分,行业通用约定
  • 赋值方式:通过this为实例添加属性和方法,借助剑认主的逻辑完成属性绑定
  • 返回值:无需手动return,new会自动返回完成认主的完整实例
3. 构造函数基础代码示例
// 基础剑法(构造函数)
function Person(name, age) {
  this.name = name;
  this.age = age;
  this.sayHi = function() {
    console.log(`Hi, ${this.name}`);
  };
}

// 执行认主仪式,创建两个主人
const p1 = new Person('张三', 18);
const p2 = new Person('李四', 20);

// 主人使用自身剑法
p1.sayHi(); // 输出:Hi, 张三
p2.sayHi(); // 输出:Hi, 李四
4. 构造函数与普通函数核心区别
  • 调用方式:构造函数必须用new调用,普通函数直接调用即可
  • this指向:构造函数中this指向new创建的实例,普通函数中this指向全局或undefined
  • 命名规范:构造函数首字母大写,普通函数小写开头
  • 主要作用:构造函数批量创建对象,普通函数执行单一逻辑、返回运算结果
  • 返回值:构造函数自动返回实例,普通函数需手动return结果
5. 抛出思考(引向下一篇)

当前基础写法能实现功能,但存在一个问题:每new一个实例,构造函数内的方法就会重新创建一次,p1和p2的sayHi功能完全一致,却占用多份内存,实例越多浪费越严重?

有没有办法让所有实例共用一套公共方法,不重复创建?这就是下一篇要探究的原型&原型链,以及ES6 class语法糖的优化逻辑。

 

第五重 · 焚诀合一(剑·鞘·法三者联动)

定位:本篇灵魂 说明:三者绝非独立存在,而是一套完整的焚诀功法,协同工作才能发挥最大作用,这是JS面向对象的核心逻辑。

1. 三者协同执行流程

一句话总结执剑鞘(new),运剑法(构造函数),剑(this)认主归位,最终炼成完整主人(实例)。 用new启动构造函数,先创建空实例,再将this绑定到该实例,执行构造函数为实例赋值,最终返回完整可用的实例。

2. 完整联动核心代码
// 剑法(构造函数):定好修炼定式
function Person(name) {
  // 剑(this):等待认主,为主人赋值
  this.name = name;
  this.sayName = function() {
    console.log(this.name);
  };
}

// 剑鞘(new):执行认主仪式,启动剑法
const p = new Person('张三');

// 主人(实例):使用自身的剑施展功法
p.sayName(); // 输出:张三

3. 联动核心总结

  • 构造函数(剑法):定下对象的基础规则,是功法核心
  • this(剑):负责实例数据传递,是操作对象的工具
  • new(剑鞘):完成认主仪式,保障this指向稳定,是功法启动器 三者缺一不可,配合使用才构成JS面向对象的基础,后续所有进阶知识,都围绕这一核心逻辑展开。

到这一步焚诀就大成了,只差修炼和优化了

学习复盘心得小小分享

其实不管学什么,刚开始的路都是又乱又零散。 回想刚学 JS 的那段时间,基础知识点,作用域、函数、闭包、this 一个个学过来, 知识点全都拼不起来,代码也看不懂,感觉知识学历和没有学一样,越学越迷茫,越学越煎熬。

直到今天回头复盘、把整条线梳理清楚才明白: 那些曾经看似无关又折磨的内容,并不是孤立的知识点, 最后拼装成了 JavaScript 最底层、最核心的运行框架。

虽然现在依然离精通很远,未来的学习也不会轻松, 但至少方向彻底清晰了,心里也有底了。 地基打牢,后面的路再难,也知道该怎么走,该往哪儿走。 踏踏实实继续往前走,就很好。

学习分享,如果有理解不对的地方,欢迎大家指正,一起学习进步

从“龟速打包”到“秒开体验”:Webpack优化你只需要这6招

项目越写越大,打包一次喝杯咖啡回来还没好?产出的bundle.js比电影还大?今天我们就来一场Webpack“减肥+提速”双修课。不用焦虑,6招下来,你的打包速度会像换了跑车引擎,产物体积瘦成一道闪电。

前言

Webpack就像个勤劳的搬运工,但如果你不告诉它“哪些不用搬”、“怎么抄近道”,它会把你整个项目连同node_modules一起扛上,慢得像蜗牛。今天我们就来调教这个搬运工,让打包速度飞起,让产物体积减半。

本文分两大块:速度优化(开发时等你)和体积优化(上线时快人一步)。

一、速度优化:别让Webpack摸鱼

1. 减少Loader和Plugin的工作范围

Webpack默认会遍历node_modules,但这里面的代码通常已经编译好了。用exclude告诉它:别管node_modules。

module: {
  rules: [
    {
      test: /\.js$/,
      exclude: /node_modules/,  // 跳过node_modules
      use: 'babel-loader'
    }
  ]
}

同理,Plugin里如果不需要在某个环境下执行,可以动态添加。

2. 使用缓存:第二次打包快如闪电

  • babel-loader缓存cacheDirectory: true
use: {
  loader: 'babel-loader',
  options: { cacheDirectory: true }
}
  • cache-loader(Webpack5之前):把结果缓存到硬盘。
  • Webpack5内置缓存:直接开启
module.exports = {
  cache: {
    type: 'filesystem', // 硬盘缓存
  }
};

第一次打包正常,第二次秒开。

3. 多线程/多进程构建

  • thread-loader:把耗时Loader扔到子进程。
use: [
  { loader: 'thread-loader', options: { workers: 3 } },
  'babel-loader'
]
  • HappyPack(不维护了,慎用)。Webpack5里用thread-loader就够了。

4. 使用DLLPlugin(或Webpack5的持久化缓存)

老项目中DLLPlugin可以把第三方库预先打包成动态链接库,现在Webpack5的cache.type: 'filesystem'已够用。如果还想更极致,可以用splitChunks把vendor拆出去。

二、体积优化:让bundle瘦成闪电

1. 代码分割(SplitChunks)

把公共代码抽出来,防止重复打包。Webpack4+内置optimization.splitChunks,默认配置已经很智能:

optimization: {
  splitChunks: {
    chunks: 'all',  // 对所有模块生效
    cacheGroups: {
      vendor: {
        test: /[\\/]node_modules[\\/]/,
        name: 'vendors',
        priority: 10
      }
    }
  }
}

这样会生成一个vendors.js,里面是第三方库,单独缓存。

2. 动态导入(按需加载)

不要一次性加载所有路由的组件。React里用React.lazy + Suspense,Vue里用动态import

// 之前
import Dashboard from './Dashboard';

// 之后
const Dashboard = React.lazy(() => import('./Dashboard'));

Webpack会自动把Dashboard单独打包成一个chunk,只有访问时才加载。

3. Tree Shaking:摇掉死代码

Tree Shaking依赖ES6模块静态结构。确保:

  • 使用import/export,不要用require
  • sideEffects: false 或 指定有副作用的文件。
// package.json
{
  "sideEffects": false  // 告诉Webpack所有文件都没有副作用,可以安全摇掉未引用代码
}

如果你的项目里有import './global.css'这种,需要把CSS文件排除:

"sideEffects": ["*.css", "*.scss"]

4. 压缩代码

  • TerserPlugin(JS压缩):Webpack5自带,无需安装。可以开启并行压缩。
optimization: {
  minimize: true,
  minimizer: [
    new TerserPlugin({
      parallel: true,  // 多线程压缩
      terserOptions: {
        compress: { drop_console: true } // 去除console
      }
    })
  ]
}
  • CssMinimizerPlugin(CSS压缩):压缩抽离的CSS文件。

5. 图片压缩与优化

  • image-webpack-loader:无损压缩图片。
{
  test: /\.(png|jpe?g|gif|svg)$/,
  use: [
    'file-loader',
    {
      loader: 'image-webpack-loader',
      options: { bypassOnDebug: true, disable: process.env.NODE_ENV === 'development' }
    }
  ]
}
  • url-loader:小图片转base64,减少请求数。

6. 分析打包结果

不知道哪里胖?用工具照个X光。

  • webpack-bundle-analyzer:生成交互式饼图。
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
plugins: [new BundleAnalyzerPlugin()]

一看便知哪个库占最大,然后决定替换或按需引入(比如lodash换成lodash-es)。

三、实战:一个优化前后的对比

假设项目原始打包时间30秒,体积2.5MB。

优化措施 时间效果 体积效果
exclude + 缓存 30s → 10s -
thread-loader 10s → 6s -
splitChunks - 2.5MB → 主包1MB,vendors 1.2MB
动态导入 - 首屏只加载主包1MB
压缩+tree shaking - 主包700KB,vendors 900KB
图片压缩 - 再减200KB

最终打包时间6秒,首屏加载1.2MB(原来2.5MB),体验大幅提升。

四、总结:优化口诀

  • 速度:缩小范围、开启缓存、多线程。
  • 体积:代码分割、动态导入、摇树、压缩、图片瘦身。
  • 工具:用webpack-bundle-analyzer看胖在哪,用speed-measure-webpack-plugin测谁慢。

优化不是一次性的事,项目每增加一个依赖,都要留心。但掌握了这6招,你已经能解决90%的性能问题。

如果你觉得今天的“减肥+提速”课够干,点个赞让更多人看到。明天我们将进入Vite原理——比Webpack更快的下一代构建工具。我们明天见!

从 PDP 按钮到订单生成,中间发生了什么?一个前端工程师需要知道的支付链路全貌

这篇文章是我梳理支付这条链路的过程,目标是:读完之后,你能知道怎么在一个已有的商品详情页或课程详情页里,接入一个完整的支付模块。


先建立心智模型:支付链路全貌

在写任何代码之前,先把这张图看懂:

前端(你的页面)      你的后端        支付网关(Stripe)      银行
      |                  |                   |                  |
      | 1. 点击"购买"    |                   |                  |
      |----------------->|                   |                  |
      |                  | 2. 创建支付意向    |                  |
      |                  |------------------>|                  |
      |                  |<-- client_secret --|                  |
      |<-- client_secret-|                   |                  |
      |                  |                   |                  |
      | 3. 拉起支付界面  |                   |                  |
      |------------------------------------ >|                  |
      |                  |                   | 4. 和银行通信     |
      |                  |                   |----------------->|
      |                  |                   |<-- 授权结果 ------|
      |<-- 重定向回页面--|                   |                  |
      |                  |                   |                  |
      |                  |<-- 5. Webhook 通知(payment.succeeded)
      |                  | 6. create order   |                  |
      |                  | 7. 扣款(Capture)|----------------->|

几个关键认知:

前端不碰卡号。 信用卡信息直接进入 Stripe 的表单,你的代码完全接触不到。这不是限制,是设计——如果卡号经过你的服务器,你需要通过 PCI DSS 合规认证,成本极高。Stripe 帮你把这个问题消灭了。

订单在支付成功之后才创建。 不是用户点击"购买"时创建,而是收到 Stripe 的 Webhook 通知后才创建。钱到了,才有订单——订单是交易完成的凭证,不是交易的开始。

授权和扣款是两步。 Stripe 先向银行确认"这张卡有没有这笔钱"(Authorization),真正把钱划走(Capture)可以延迟到发货时。这对实物电商很重要,对数字商品通常合并成一步。


前端只需要做三件事

理解了全貌,前端的工作其实很聚焦:初始化 SDK、拉起支付、处理回调。

第一件事:初始化 Stripe SDK

// 环境:Next.js,需安装 @stripe/stripe-js
// 在应用入口处初始化,避免重复加载

import { loadStripe } from '@stripe/stripe-js'

// NEXT_PUBLIC_ 前缀表示这是可以暴露给前端的公钥
// 私钥只能在后端使用,绝对不能出现在前端代码里
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_KEY!)

loadStripe 会异步加载 Stripe 的 JS 文件,返回一个 Promise。把它定义在模块级别(组件外部),确保整个应用只初始化一次。

第二件事:拉起支付界面

点击"购买"按钮时,需要先告诉后端"我要买什么",后端创建支付意向,前端拿到凭证后拉起 Stripe:

// 环境:React 组件
// 场景:PDP 或课程详情页的购买按钮

async function handlePurchase(productId: string, price: number) {
  try {
    // 第一步:让后端创建支付意向
    // 后端调用 Stripe API,返回 sessionId
    const response = await fetch('/api/checkout/create-session', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ productId, price }),
    })

    if (!response.ok) {
      throw new Error('创建支付会话失败')
    }

    const { sessionId } = await response.json()

    // 第二步:跳转到 Stripe 托管的支付页面
    const stripe = await stripePromise
    const { error } = await stripe!.redirectToCheckout({ sessionId })

    // 只有跳转失败才会执行到这里
    if (error) {
      console.error('跳转支付页面失败:', error.message)
    }
  } catch (err) {
    console.error('支付流程出错:', err)
  }
}

// 在组件里使用
function ProductDetailPage({ product }: { product: Product }) {
  return (
    <div>
      <h1>{product.name}</h1>
      <p>¥{product.price}</p>
      <button onClick={() => handlePurchase(product.id, product.price)}>
        立即购买
      </button>
    </div>
  )
}

第三件事:处理支付回调

用户在 Stripe 完成支付后,Stripe 会把用户重定向回你配置的页面。通常需要两个 URL:

// 环境:Next.js API Route(后端部分)
// 场景:创建 Stripe Checkout Session

import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(request: Request) {
  const { productId, price } = await request.json()

  const session = await stripe.checkout.sessions.create({
    payment_method_types: ['card'],
    line_items: [
      {
        price_data: {
          currency: 'cny',
          product_data: { name: productId },
          unit_amount: price * 100, // Stripe 使用最小货币单位(分)
        },
        quantity: 1,
      },
    ],
    mode: 'payment',
    // 支付成功后跳回的页面
    success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/order/success?session_id={CHECKOUT_SESSION_ID}`,
    // 用户取消支付后跳回的页面
    cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/checkout/cancelled`,
  })

  return Response.json({ sessionId: session.id })
}

成功页面读取 session_id,展示订单确认信息:

// 环境:Next.js,pages/order/success.tsx
// 场景:支付成功回调页面

export default function OrderSuccess() {
  const router = useRouter()
  const { session_id } = router.query

  // 用 session_id 查询订单状态,展示确认信息
  // 真正的订单创建发生在后端 Webhook 里,不在这里
  return (
    <div>
      <h1>支付成功</h1>
      <p>订单正在处理中,稍后会收到确认邮件</p>
    </div>
  )
}

注意:这个页面只用来展示"支付成功"的反馈,不要在这里 create order。原因是用户可能关掉页面、网络中断,导致这个页面根本不会被执行。真正可靠的 order 创建,必须在 Webhook 里做。


Webhook:支付链路里最容易被忽视的环节

很多教程到"跳转成功页"就结束了,但这是不完整的。

Webhook 是 Stripe 主动通知你的后端"支付成功了"的机制。不管用户的浏览器发生了什么,这个通知都会到达:

// 环境:Next.js API Route
// 场景:接收 Stripe Webhook 通知

import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(request: Request) {
  const body = await request.text()
  const signature = request.headers.get('stripe-signature')!

  let event: Stripe.Event

  try {
    // 验证这个请求确实来自 Stripe,而不是伪造的
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return new Response('Webhook 签名验证失败', { status: 400 })
  }

  // 根据事件类型处理
  switch (event.type) {
    case 'checkout.session.completed':
      const session = event.data.object as Stripe.Checkout.Session
      // 在这里 create order,触发库存扣减、发送确认邮件等
      await createOrder(session)
      break

    case 'payment_intent.payment_failed':
      // 支付失败,通知用户
      await handlePaymentFailure(event.data.object)
      break
  }

  return new Response('ok', { status: 200 })
}

两种接入方式:跳转 vs 内嵌

上面的代码用的是 Hosted Checkout——用户跳到 Stripe 的页面填卡。这是集成最简单的方式,适合快速上线。

另一种方式是 Stripe Elements——把卡号表单嵌在你自己的页面里,用户不需要跳出去:

// 环境:React,需安装 @stripe/react-stripe-js
// 场景:在自己页面内嵌入支付表单

import { Elements, CardElement, useStripe, useElements } from '@stripe/react-stripe-js'

function CheckoutForm() {
  const stripe = useStripe()
  const elements = useElements()

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    if (!stripe || !elements) return

    const cardElement = elements.getElement(CardElement)!

    // confirmCardPayment 把卡信息直接发给 Stripe,不经过你的服务器
    const { error, paymentIntent } = await stripe.confirmCardPayment(
      clientSecret, // 从后端获取
      { payment_method: { card: cardElement } }
    )

    if (error) {
      console.error('支付失败:', error.message)
    } else if (paymentIntent.status === 'succeeded') {
      // 支付成功,等待 Webhook 创建订单
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <CardElement />  {/* Stripe 的安全 iframe,你无法读取其中的卡号 */}
      <button type="submit">确认支付</button>
    </form>
  )
}

// 用 Elements Provider 包裹
function PaymentPage({ clientSecret }: { clientSecret: string }) {
  return (
    <Elements stripe={stripePromise} options={{ clientSecret }}>
      <CheckoutForm />
    </Elements>
  )
}

怎么选:

Hosted Checkout Stripe Elements
集成复杂度 低,几行代码 中,需要处理表单状态
用户体验 跳出页面,有跳出感 留在你的页面,体验流畅
样式定制 有限 完全可定制
适合场景 快速上线、MVP 对体验要求高的产品

国内场景:支付宝和微信支付

如果你的用户在国内,流程是一样的,只是 SDK 和 API 名字不同:

Stripe 概念          →   国内对应
─────────────────────────────────────────
loadStripe()         →   wx.config() / AlipayJSBridge.ready()
Payment Intent       →   统一下单接口(prepay_id / trade_no)
redirectToCheckout   →   wx.requestPayment() / 跳转支付宝收银台
Webhook              →   支付结果异步通知(需要验签)
client_secret        →   prepay_id(微信)/ out_trade_no(支付宝)

国内支付比 Stripe 复杂的地方在于多端适配——同一个支付功能,在 PC 浏览器、手机浏览器、微信内 H5、微信小程序里,拉起支付的方式都不同:

// 场景:判断环境,选择对应的支付拉起方式

function launchWechatPay(prepayId: string) {
  const ua = navigator.userAgent.toLowerCase()
  const isWechat = ua.includes('micromessenger')
  const isMiniProgram = window.__wxjs_environment === 'miniprogram'

  if (isMiniProgram) {
    // 小程序环境
    wx.requestPayment({ /* 支付参数 */ })
  } else if (isWechat) {
    // 微信浏览器,使用 JSAPI
    WeixinJSBridge.invoke('getBrandWCPayRequest', { /* 参数 */ })
  } else {
    // 普通浏览器,跳转收银台或展示二维码
    window.location.href = `https://wx.tenpay.com/...`
  }
}

这也是国内电商前端比较有挑战的部分——同一套支付逻辑要处理很多种环境。


小结

从 PDP 按钮到订单生成,前端实际需要理解和处理的只有几个关键点:

  1. 不碰卡号——所有敏感信息交给 SDK 处理
  2. 后端创建支付意向——前端只是发起请求,拿凭证
  3. Webhook 才是可靠的订单创建时机——不要依赖前端回调
  4. 授权和扣款是两步——理解这个,才能处理各种异常情况

支付链路本身不复杂,复杂的是各种异常情况的处理:支付超时、重复支付、退款、对账。这些是下一个层次的话题,也是真实电商项目里花时间最多的地方。


还没想清楚的地方

支付完成的那一刻,是整条电商链路里信息密度最高的时刻——用户真实购买意图第一次被确认,品类偏好、价格敏感度、购买时机全部显现。

这份数据,AI 能做什么?

是在支付成功页推荐关联商品,还是在 Webhook 触发时更新用户画像,还是用来预测下一次复购时机?


参考资料

【Three.js 后期处理进阶】用 Shader 实现自己的滤镜,让画面拥有电影质感

前言

调色预设用腻了?那就自己写一个滤镜。不就是给画面加特效吗,GPU 说它很乐意帮忙。

上一篇文章我们聊了电影级调色,用现成的 Bloom 和 LUT 把画面整得挺像回事。但有读者留言:“这些预设是挺好,但我想要那种赛博朋克的色彩偏移效果,或者复古胶片的感觉,LUT 找不到合适的。”

这确实是个问题。LUT 再丰富也有限,真正的自定义还得靠 Shader。

其实 Three.js 的后期处理流水线本来就是用 Shader 搭起来的。BloomPassFilmPass 这些内置效果,源码里都是一堆 GLSL 代码。既然官方能写,我们也能写。

今天我就带你写三个自定义后期滤镜:一个简单的灰度,一个边缘发光,一个炫酷的色彩偏移(RGB Split)。全部手写 Shader,跑起来的那一刻,你会发现原来自己也能造轮子。


一、后期处理流水线回顾

Three.js 的后期处理核心是 EffectComposer。它就像一条传送带,上面挂着一个个 Pass,每个 Pass 可以对画面做一次加工。

const composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);

const customPass = new ShaderPass({
  uniforms: {},
  vertexShader: '',
  fragmentShader: ''
});
composer.addPass(customPass);

ShaderPass 需要两个关键东西:顶点着色器和片元着色器。顶点着色器几乎不用动,片元着色器里写的就是像素级别的滤镜逻辑。


二、第一个滤镜:灰度

先从最简单的开始。把画面变成黑白的,感受一下 ShaderPass 的工作流程。

import * as THREE from 'three';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';

// 基础场景(随便放几个物体)
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(3, 2, 5);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 添加一些好看的物体
const geometry = new THREE.SphereGeometry(1.2, 64, 64);
const material = new THREE.MeshStandardMaterial({ color: 0xffaa44, roughness: 0.3, metalness: 0.1 });
const sphere = new THREE.Mesh(geometry, material);
sphere.position.set(-1.5, 0.5, 0);
scene.add(sphere);

const boxGeometry = new THREE.BoxGeometry(1.5, 1.5, 1.5);
const boxMaterial = new THREE.MeshStandardMaterial({ color: 0x44aaff, roughness: 0.2, metalness: 0.3 });
const box = new THREE.Mesh(boxGeometry, boxMaterial);
box.position.set(1.5, 0.5, 0);
scene.add(box);

const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(2, 5, 3);
scene.add(light);
scene.add(new THREE.AmbientLight(0x404060));

// 后期合成器
const composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);

// 自定义灰度滤镜
const grayscalePass = new ShaderPass({
  uniforms: {
    tDiffuse: { value: null } // 这是 ShaderPass 约定的输入纹理
  },
  vertexShader: `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    uniform sampler2D tDiffuse;
    varying vec2 vUv;
    void main() {
      vec4 color = texture2D(tDiffuse, vUv);
      float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
      gl_FragColor = vec4(vec3(gray), color.a);
    }
  `
});

composer.addPass(grayscalePass);
// 确保最后一个 Pass 输出到屏幕
grayscalePass.renderToScreen = true;

// 动画循环
function animate() {
  requestAnimationFrame(animate);
  sphere.rotation.y += 0.01;
  box.rotation.x += 0.01;
  composer.render();
}
animate();

运行这段代码,画面就变成黑白的了。关键点:

  • tDiffuse 是 ShaderPass 内置的 uniform,代表上一个 Pass 渲染好的纹理。
  • 顶点着色器只需要传递 UV 坐标。
  • 片元着色器里用 texture2D 采样,然后算灰度值。

三、第二个滤镜:边缘发光

这个效果其实是用 Sobel 算子检测边缘,然后在边缘处叠加发光颜色。

const edgePass = new ShaderPass({
  uniforms: {
    tDiffuse: { value: null },
    resolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) }
  },
  vertexShader: `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    uniform sampler2D tDiffuse;
    uniform vec2 resolution;
    varying vec2 vUv;

    void main() {
      vec2 texel = vec2(1.0 / resolution.x, 1.0 / resolution.y);
      
      // Sobel 算子
      float gx = 0.0;
      float gy = 0.0;
      
      // 采样周围8个点
      for (int i = -1; i <= 1; i++) {
        for (int j = -1; j <= 1; j++) {
          vec2 offset = vec2(float(i), float(j)) * texel;
          vec4 c = texture2D(tDiffuse, vUv + offset);
          float gray = dot(c.rgb, vec3(0.299, 0.587, 0.114));
          
          // Sobel 权重
          float wx = float(j) * (1.0 - abs(float(i)));
          float wy = float(i) * (1.0 - abs(float(j)));
          
          gx += gray * wx;
          gy += gray * wy;
        }
      }
      
      float edge = sqrt(gx * gx + gy * gy);
      edge = clamp(edge * 2.0, 0.0, 1.0);
      
      vec4 original = texture2D(tDiffuse, vUv);
      vec3 edgeColor = vec3(0.2, 0.6, 1.0); // 蓝色边缘
      
      vec3 finalColor = mix(original.rgb, edgeColor, edge);
      gl_FragColor = vec4(finalColor, original.a);
    }
  `
});

把这个 Pass 添加到 composer 里,替换掉灰度滤镜,你会看到物体边缘有一圈蓝色光晕,很有科技感。


四、第三个滤镜:色彩偏移(RGB Split)

故障艺术里常见的 RGB 分裂效果,把红、绿、蓝三个通道稍微错开一点。

const rgbSplitPass = new ShaderPass({
  uniforms: {
    tDiffuse: { value: null },
    amount: { value: 0.01 } // 偏移量
  },
  vertexShader: `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    uniform sampler2D tDiffuse;
    uniform float amount;
    varying vec2 vUv;

    void main() {
      vec2 offset = vec2(amount, 0.0);
      
      float r = texture2D(tDiffuse, vUv + offset).r;
      float g = texture2D(tDiffuse, vUv).g;
      float b = texture2D(tDiffuse, vUv - offset).b;
      
      gl_FragColor = vec4(r, g, b, 1.0);
    }
  `
});

为了让效果更动态,可以在动画里让 amount 随机变化:

let time = 0;
function animate() {
  requestAnimationFrame(animate);
  
  time += 0.01;
  rgbSplitPass.uniforms.amount.value = 0.01 + Math.sin(time * 5) * 0.005;
  
  composer.render();
}

运行起来,画面边缘会出现彩色错位,像老式 CRT 显示器故障的感觉。


五、组合多个滤镜

后期处理的魅力在于可以组合。先做 RGB 分裂,再做边缘检测,最后加一点噪点。

composer.addPass(rgbSplitPass);
composer.addPass(edgePass);
composer.addPass(grayscalePass); // 注意顺序会影响结果
edgePass.renderToScreen = false;
grayscalePass.renderToScreen = true; // 最后一个

不同的顺序会产生完全不同的视觉效果,可以多试试。


六、坑点汇总

  1. 纹理坐标vUv 的范围是 0~1,采样时要小心边缘溢出。可以用 clamp 或者 repeat 模式,但一般默认就是 clampToEdgeWrapping
  2. 分辨率:有些滤镜需要知道纹理的实际像素尺寸(比如边缘检测),需要传入 resolution uniform,并在窗口变化时更新。
  3. 性能:每个 Pass 都是一次全屏渲染,Pass 越多越耗性能。可以用 composer.setSize() 降低内部分辨率来优化。
  4. 最后一个 Pass:必须设置 pass.renderToScreen = true,否则画面会消失。
  5. uniform 更新:记得在动画循环里更新自定义 uniform。
  6. 调试技巧:可以在片元着色器里直接返回固定颜色,比如 gl_FragColor = vec4(1.0,0.0,0.0,1.0); 来确认 Pass 是否生效。

七、总结

今天我们手写了三个自定义后期滤镜:灰度、边缘检测、RGB 分裂。掌握了 ShaderPass 的基本用法,你就可以创造出无限种视觉效果。

Three.js 内置了几十个 Shader 例子,在 examples/jsm/shaders/ 目录下。下次需要什么奇怪的效果,不妨先去看看源码,说不定就能改出一个自己的版本。


以下是全部的效果对比图:

image.png

互动

你打算用自定义 Shader 实现什么效果?或者你在写 Shader 时遇到过什么诡异的问题?评论区分享出来,咱们一起解决 😏

下篇预告:【Three.js 项目复盘】一个智慧工厂监控大屏的踩坑实录

Babel幽灵注释:删节点为何删不掉注释?

为什么代码明明删了,注释却还“赖着不走”?—— Babel幽灵注释背后的真相

在日常使用 Babel 做代码转换、AST 重构与插件开发时,你大概率遇到过这样令人抓狂的场景: 明明已经用 path.remove() 删掉了某个节点,可最终生成的代码里,原本属于该节点的注释却像“幽灵”一样残留原地,甚至漂移到其他节点上。

这种“删不掉、赶不走、乱乱跑”的注释问题,堪称 Babel 开发里最经典的坑之一。今天我们就从底层原理出发,彻底揭开幽灵注释的真面目。

一、核心真相:注释并不“属于”节点本身

很多开发者误以为:注释是节点的属性,删节点就会连带删注释。 但 Babel 的 AST 设计完全不是这样。

在 Babel 中:

  • 注释不是节点的子属性,而是挂在节点上的“附属物”
  • 注释通过 leadingComments / trailingComments 与节点关联
  • 注释自带独立的 start/end/loc 位置信息
  • 删除节点只会移除逻辑节点,不会自动清理注释对象与位置元数据

当你执行 path.remove() 后,节点消失了,但注释对象依然存在于内存中,且保留着原来的代码位置。 Babel 生成器(Generator)在输出代码时,会启动一套**“兜底补全策略”**: 发现某个位置有“无主注释”,为了不丢失用户信息,它会强行把注释“塞”到它认为最合理的位置——于是幽灵注释就诞生了。

二、幽灵注释产生的三大核心原因

1. 引用未切断 → 注释无法被回收

只删节点,不清空注释引用,Babel 无法判断这些注释是否还需要保留,最终会被重复打印、错误挂载。

2. 位置信息残留 → Generator 按旧坐标渲染

Babel 生成代码高度依赖 loc/start/end 位置信息。 节点删了,但位置坐标还在,Generator 会误以为这里依然需要输出内容,直接把注释打印在原位置。

3. Babel 设计哲学:宁可错留,不可丢失

Babel 的核心原则是尽可能保留用户原始代码,包括注释与格式。 这种“善意”在手动删节点时,就变成了注释乱飘、残留的“元凶”。

三、3 种方案,彻底干掉幽灵注释

想要从根源解决问题,不能只删节点,必须同步清理注释与元数据

方案 1:移除前手动清空注释(最推荐、最稳)

在调用 remove() 之前,显式置空注释,告诉 Babel 彻底放弃这些注释。

// 先清空注释
path.node.leadingComments = null;
path.node.trailingComments = null;
// 再删除节点
path.remove();

方案 2:替换为空语句,避免无主注释

如果直接删除容易乱序,可以用空语句占位,给注释一个合法“宿主”。

path.replaceWith(t.emptyStatement());

方案 3:抹除位置坐标,杜绝按旧位置打印

适用于节点移动、复制、迁移的场景:

node.loc = null;
node.start = null;
node.end = null;

写在最后

Babel 的注释机制,本质是基于物理位置的启发式打印,而不是严格的节点绑定。 只要记住它**“宁愿多留、不愿漏掉”**的设计逻辑,你就再也不会被“删不掉的注释”坑到。

下次再遇到 AST 操作后注释残留、漂移、乱挂载: 先清注释,再清位置,最后删节点,三步到位,百试百灵。


如果你也遇到过其他 AST / Babel 奇葩问题,欢迎在评论区一起交流~

更多前端开发、工具实践相关内容,可阅读另一篇文章:Vue转React神器VuReact来了

欢迎交流指正。

地图控件 vs 手势导航:前端实战对比(webgis)

发布于 2026 年 4 月 6 日 | 前端实战 | 地图交互 | MediaPipe 应用

前言:近期 Reddit 上一款开源手势地图控件意外走红——通过摄像头捕捉手部动作,就能实现地图的平移、缩放、旋转,还原《少数派报告》里的科幻交互场景。作为前端开发者,我第一时间克隆源码调试,既被其酷炫效果吸引,也陷入了深思:这种“黑科技”交互,真的能替代我们用了十几年的传统地图控件吗?

本文将从前端开发视角出发,结合真实项目实战经验,详细对比传统地图控件与手势导航的技术实现、优势短板、适用场景,拆解核心代码、避坑指南和优化方案,总字数3000+,干货满满,适合前端开发者、地图交互爱好者收藏学习,也可直接作为项目选型参考。

提示:本文不涉及后端逻辑,全程聚焦前端实现,从基础用法到高级优化,逐步拆解,新手也能轻松看懂,老司机可直接跳至实战优化部分。

一、传统地图控件:久经考验的“前端标配”

做网页地图开发,无论是PC端还是移动端,我们最先想到的大概率是 Leaflet 或 MapLibre GL JS(替代 Mapbox GL JS 的开源方案)。这两款库的交互模式,在过去十五年里几乎没有大的变化,成为前端地图开发的“默认选择”——不是因为没有更好的方案,而是因为它足够稳定、足够兼容、足够符合用户习惯。

1.1 核心交互逻辑(前端视角)

传统地图控件的交互设计,完全贴合“鼠标/触摸”的操作习惯,无需额外学习成本,前端接入也极其简单,核心交互映射如下:

  • PC端:鼠标拖拽 → 地图平移;滚轮滚动 → 地图缩放;右键拖拽 → 地图旋转(部分库支持);双击 → 放大地图
  • 移动端:单指拖拽 → 平移;双指捏合 → 缩放;双指旋转 → 地图旋转;双击 → 放大

这种交互模式的优势的是“原生感”——用户无需任何引导,就能凭本能操作,这也是传统控件能沿用十几年的核心原因。

1.2 前端核心实现(附完整可复用代码)

下面分别给出 Leaflet 和 MapLibre GL JS 的完整初始化代码,包含常用配置、控件自定义、事件监听,可直接复制到项目中使用,注释详细,新手也能快速上手。

1.2.1 Leaflet 实现(轻量首选,适合简单地图场景)

Leaflet 是轻量级开源地图库,体积小(核心文件仅几十KB),兼容性好,支持IE11+,适合PC端后台管理系统、简单移动端地图展示等场景,前端接入成本极低。

// 1. 安装依赖(npm/yarn)
// npm install leaflet
// 引入样式(必须引入,否则地图无样式)
import 'leaflet/dist/leaflet.css';
import L from 'leaflet';

// 2. 初始化地图(DOM容器需提前创建,id为map,设置宽高)
const map = L.map('map', {
  center: [39.9042, 116.4074], // 北京坐标(可替换为自己需要的坐标)
  zoom: 12, // 初始缩放级别(1-18,数字越大越清晰)
  zoomControl: true, // 显示缩放控件(默认在左上角)
  scrollWheelZoom: true, // 开启滚轮缩放(移动端自动适配双指缩放)
  dragging: true, // 开启拖拽平移
  doubleClickZoom: true, // 开启双击放大
  attributionControl: true, // 显示地图版权信息(必须保留,符合开源协议)
  minZoom: 5, // 最小缩放级别(防止缩太小导致地图失真)
  maxZoom: 18, // 最大缩放级别(根据地图瓦片精度设置)
});

// 3. 加载地图瓦片(使用OpenStreetMap开源瓦片,免费可用)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
  attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
  tileSize: 256, // 瓦片大小(默认256x256)
  maxZoom: 18,
  minZoom: 5,
}).addTo(map);

// 4. 自定义缩放控件位置(默认左上角,可调整为右下角)
L.control.zoom({
  position: 'bottomright'
}).addTo(map);

// 5. 监听地图交互事件(前端常用,用于埋点、业务逻辑触发)
// 平移事件
map.on('move', () => {
  const center = map.getCenter(); // 获取当前地图中心点坐标
  console.log('地图平移,当前中心点:', center.lat, center.lng);
  // 这里可添加埋点代码,统计用户平移操作
});

// 缩放事件
map.on('zoomend', () => {
  const zoom = map.getZoom(); // 获取当前缩放级别
  console.log('地图缩放,当前级别:', zoom);
});

// 点击地图事件
map.on('click', (e) => {
  const { lat, lng } = e.latlng; // 获取点击位置坐标
  console.log('点击地图位置:', lat, lng);
  // 可实现点击添加标记点等业务逻辑
  L.marker([lat, lng]).addTo(map)
    .bindPopup(`点击位置:${lat.toFixed(6)}, ${lng.toFixed(6)}`)
    .openPopup();
});

1.2.2 MapLibre GL JS 实现(3D首选,适合复杂交互场景)

MapLibre GL JS 是 Mapbox GL JS 的开源替代方案,支持3D地图、矢量瓦片,交互更流畅,适合需要3D视角、复杂手势控制的场景(如智慧城市、园区管理、导航类应用),前端实现稍复杂,但功能更强大。

// 1. 安装依赖
// npm install maplibregl-gl
// 引入样式
import 'maplibregl-gl/dist/maplibregl.css';
import maplibregl from 'maplibregl-gl';

// 2. 初始化3D地图
const map = new maplibregl.Map({
  container: 'map', // DOM容器id
  style: 'https://demotiles.maplibre.org/style.json', // 矢量瓦片样式(可自定义)
  center: [116.4074, 39.9042], // 注意:MapLibre 坐标是 [经度, 纬度],与Leaflet相反
  zoom: 12,
  pitch: 45, // 3D倾斜角度(0-60,越大越有3D效果)
  bearing: -17.6, // 初始旋转角度(负数值为逆时针旋转)
  dragRotate: true, // 开启右键拖拽旋转
  touchZoomRotate: true, // 移动端开启双指旋转
  scrollZoom: true, // 滚轮缩放
  attributionControl: true,
});

// 3. 添加官方导航控件(包含缩放、旋转功能)
map.addControl(new maplibregl.NavigationControl({
  showCompass: true, // 显示指南针(旋转后有用)
  showZoom: true, // 显示缩放按钮
  visualizePitch: true // 显示倾斜角度指示器
}), 'top-right'); // 控件位置

// 4. 地图加载完成后触发(必须在load事件中操作地图样式、添加图层)
map.on('load', () => {
  console.log('地图加载完成');
  // 示例:添加自定义点图层(业务常用)
  map.addSource('custom-point', {
    type: 'geojson',
    data: {
      type: 'FeatureCollection',
      features: [
        {
          type: 'Feature',
          geometry: {
            type: 'Point',
            coordinates: [116.4074, 39.9042] // 北京坐标
          },
          properties: {
            name: '北京',
            desc: '首都'
          }
        }
      ]
    }
  });

  // 渲染点图层
  map.addLayer({
    id: 'custom-point-layer',
    type: 'circle',
    source: 'custom-point',
    paint: {
      'circle-radius': 8,
      'circle-color': '#ff4d4f',
      'circle-opacity': 0.8
    }
  });

  // 点击自定义图层事件
  map.on('click', 'custom-point-layer', (e) => {
    const properties = e.features[0].properties;
    new maplibregl.Popup()
      .setLngLat(e.lngLat)
      .setHTML(`<h3>${properties.name}</h3><p>${properties.desc}</p>`)
      .addTo(map);
  });
});

// 5. 监听3D相关事件
map.on('rotate', () => {
  const bearing = map.getBearing().toFixed(1); // 获取当前旋转角度
  console.log('地图旋转角度:', bearing);
});

map.on('pitch', () => {
  const pitch = map.getPitch().toFixed(1); // 获取当前倾斜角度
  console.log('地图倾斜角度:', pitch);
});

1.3 传统控件的前端优势(实战总结)

结合我参与的多个地图项目(后台管理系统、移动端导航应用),传统控件的优势完全贴合前端开发的“实用性”需求,总结为4点核心:

  1. 接入成本极低:无论是 Leaflet 还是 MapLibre,几行代码就能完成初始化,无需额外依赖(除了地图瓦片),前端开发效率高,调试成本低。
  2. 用户零学习成本:所有用户都熟悉“拖拽平移、滚轮缩放”的操作,无需添加引导提示,降低产品的用户教育成本,也减少前端的引导逻辑开发。
  3. 全设备兼容:PC端(Chrome、Firefox、Edge、IE11)、移动端(iOS、Android)通吃,无需针对不同设备做额外适配,前端兼容性开发工作量少。
  4. 性能与可访问性双优:交互层代码轻量,几乎不占用CPU/GPU资源,即使在低端设备上也能流畅运行;同时原生支持键盘导航、屏幕阅读器,符合前端可访问性开发规范(A11Y),避免因可访问性问题导致的产品合规风险。

1.4 传统控件的前端短板(实战踩坑)

没有完美的方案,传统控件在实际开发中也有不少痛点,尤其是在复杂场景和新兴需求下,短板逐渐明显,结合我的踩坑经验,总结为3点:

  1. 移动端触摸冲突:这是前端开发中最常见的问题——地图的拖拽平移,很容易与页面的垂直滚动冲突,需要额外写代码处理“触摸边界”(比如手指在地图内拖拽时禁止页面滚动,离开地图后恢复),增加前端开发工作量。
  2. 交互表达能力有限:传统控件的操作的是“离散的”,难以实现连续的、精细的3D操控,比如在智慧城市场景中,需要平滑调整地图的倾斜角度、旋转角度,传统控件的操作体验较差,无法满足高端交互需求。
  3. 视觉体验单一:在展厅、大屏演示、科技类产品中,传统控件显得过于老旧,缺乏“科技感”,无法吸引用户注意力,不符合产品的视觉定位。

二、手势导航:MediaPipe 加持的前端黑科技

手势导航的核心技术,是 Google 开源的 MediaPipe Hands——一款轻量级的手部关键点识别库,能通过摄像头实时捕捉手部的21个关键点,前端开发者只需将这些关键点的变化,映射为地图的交互操作,就能实现“挥手控地图”的效果。

需要强调的是:手势导航并非“替代”传统控件,而是作为“补充”,适合特定场景。下面从前端实现、优势痛点、实战优化三个维度,详细拆解。

2.1 核心原理(前端视角)

手势导航的前端实现逻辑,可分为3个步骤,流程清晰,便于理解和开发:

  1. 摄像头权限获取:前端通过 navigator.mediaDevices.getUserMedia() 获取用户摄像头权限(必须用户手动授权,浏览器默认禁止自动获取)。
  2. 手部关键点识别:通过 MediaPipe Hands 库,实时捕捉手部关键点(如手掌中心、手指尖端、手腕位置),并返回关键点的坐标信息。
  3. 手势映射与地图控制:通过分析关键点的变化(如手掌移动、手指捏合、手腕旋转),判断用户的手势意图,再调用地图库的API(如平移、缩放、旋转),实现手势对地图的控制。

核心手势映射(前端常用,可自定义扩展):

  • 手掌张开(五指伸直):拖拽平移地图(手掌移动方向 = 地图平移方向)。
  • 双指捏合(拇指和食指靠拢/分开):缩放地图(靠拢 = 缩小,分开 = 放大)。
  • 手腕旋转(手掌左右转动):旋转地图(顺时针 = 顺时针旋转,逆时针 = 逆时针旋转)。
  • 单指点击(食指点击摄像头画面):在地图上添加标记点。

2.2 前端完整实现(MediaPipe + MapLibre,可直接复用)

下面给出完整的手势导航前端代码,包含摄像头权限处理、MediaPipe 初始化、手势识别、地图控制、异常处理,注释详细,解决了实战中常见的“手势抖动、权限拒绝、性能优化”等问题,可直接集成到项目中。

// 1. 安装依赖
// npm install @mediapipe/hands maplibregl-gl
import { Hands, HAND_CONNECTIONS } from '@mediapipe/hands';
import maplibregl from 'maplibregl-gl';
import 'maplibregl-gl/dist/maplibregl.css';

// 2. 初始化地图(复用MapLibre 3D地图,与传统控件共用一个地图实例)
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://demotiles.maplibre.org/style.json',
  center: [116.4074, 39.9042],
  zoom: 12,
  pitch: 45,
  bearing: -17.6,
  dragRotate: true,
  scrollZoom: true, // 保留传统控件,与手势导航叠加
});

// 3. 初始化MediaPipe Hands
const hands = new Hands({
  locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`,
});

// 4. 配置MediaPipe参数(前端优化关键,平衡性能与精度)
hands.setOptions({
  maxNumHands: 1, // 只识别一只手(减少性能消耗,避免双手干扰)
  modelComplexity: 0, // 0=轻量版(性能优先,适合移动端),1=完整版(精度优先,适合PC端)
  minDetectionConfidence: 0.7, // 最小检测置信度(低于此值不识别,减少误触发)
  minTrackingConfidence: 0.5, // 最小追踪置信度(低于此值重新检测)
  selfieMode: false, // 关闭自拍模式(默认false,摄像头朝向前方)
});

// 5. 全局变量(用于手势追踪和防抖)
let prevPalmCenter = null; // 上一帧手掌中心坐标
let prevFingerDistance = null; // 上一帧拇指与食指距离(用于缩放)
let prevWristAngle = null; // 上一帧手腕角度(用于旋转)
let isGestureActive = false; // 手势是否激活(避免误操作)
const debounceTime = 16; // 防抖时间(与屏幕刷新率一致,16ms=60fps)
let lastGestureTime = 0; // 上一次手势触发时间

// 6. 手势识别核心逻辑(重点,前端优化关键)
hands.onResults((results) => {
  // 避免频繁触发,添加防抖
  const now = Date.now();
  if (now - lastGestureTime < debounceTime) return;
  lastGestureTime = now;

  // 没有检测到手部,重置状态
  if (!results.multiHandLandmarks || results.multiHandLandmarks.length === 0) {
    prevPalmCenter = null;
    prevFingerDistance = null;
    prevWristAngle = null;
    isGestureActive = false;
    return;
  }

  // 获取第一只手的关键点(默认只识别一只手)
  const landmarks = results.multiHandLandmarks[0];
  // 手掌中心:取中指根部(索引9)、无名指根部(索引13)、小指根部(索引17)的平均值,更稳定
  const palmCenter = {
    x: (landmarks[9].x + landmarks[13].x + landmarks[17].x) / 3,
    y: (landmarks[9].y + landmarks[13].y + landmarks[17].y) / 3,
  };
  // 拇指尖端(索引4)和食指尖端(索引8)坐标(用于缩放)
  const thumbTip = landmarks[4];
  const indexTip = landmarks[8];
  // 手腕位置(索引0)和中指根部(索引9)(用于计算手腕角度,实现旋转)
  const wrist = landmarks[0];
  const middleRoot = landmarks[9];

  // 计算拇指与食指的距离(用于缩放)
  const fingerDistance = Math.hypot(
    thumbTip.x - indexTip.x,
    thumbTip.y - indexTip.y
  );

  // 计算手腕角度(用于旋转):手腕到中指根部的向量与水平方向的夹角
  const wristVector = {
    x: middleRoot.x - wrist.x,
    y: middleRoot.y - wrist.y,
  };
  const wristAngle = Math.atan2(wristVector.y, wristVector.x) * (180 / Math.PI);

  // 1. 平移手势:手掌张开,且手掌移动超过阈值(避免微小抖动)
  if (isPalmOpen(landmarks) && prevPalmCenter) {
    const dx = (palmCenter.x - prevPalmCenter.x) * -800; // 负号:手掌向右移,地图向左移(符合直觉)
    const dy = (palmCenter.y - prevPalmCenter.y) * 800;
    // 设定最小位移阈值(避免抖动)
    if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
      map.panBy([dx, dy], { animate: false });
      isGestureActive = true;
    }
  }

  // 2. 缩放手势:双指捏合/分开,且距离变化超过阈值
  if (prevFingerDistance) {
    const distanceDelta = fingerDistance - prevFingerDistance;
    // 缩放灵敏度(根据实际需求调整)
    const zoomDelta = distanceDelta > 0 ? 0.1 : -0.1;
    if (Math.abs(distanceDelta) > 0.01) { // 阈值,避免微小抖动
      map.zoomTo(map.getZoom() + zoomDelta, { animate: true });
      isGestureActive = true;
    }
  }

  // 3. 旋转手势:手腕旋转,且角度变化超过阈值
  if (prevWristAngle) {
    const angleDelta = wristAngle - prevWristAngle;
    if (Math.abs(angleDelta) > 1) { // 阈值,避免微小抖动
      map.rotateTo(map.getBearing() + angleDelta, { animate: false });
      isGestureActive = true;
    }
  }

  // 更新上一帧数据
  prevPalmCenter = { ...palmCenter };
  prevFingerDistance = fingerDistance;
  prevWristAngle = wristAngle;
});

// 辅助函数:判断手掌是否张开(五指伸直)
function isPalmOpen(landmarks) {
  // 拇指与食指夹角(大于30度视为张开)
  const thumbIndexAngle = getAngle(landmarks[4], landmarks[0], landmarks[8]);
  // 食指与中指夹角(大于30度视为张开)
  const indexMiddleAngle = getAngle(landmarks[8], landmarks[7], landmarks[12]);
  // 中指与无名指夹角
  const middleRingAngle = getAngle(landmarks[12], landmarks[11], landmarks[16]);
  // 无名指与小指夹角
  const ringPinkyAngle = getAngle(landmarks[16], landmarks[15], landmarks[20]);
  // 四个夹角都大于30度,视为手掌张开
  return thumbIndexAngle > 30 && indexMiddleAngle > 30 && middleRingAngle > 30 && ringPinkyAngle > 30;
}

// 辅助函数:计算三个点组成的夹角(单位:度)
function getAngle(p1, p2, p3) {
  const v1 = { x: p1.x - p2.x, y: p1.y - p2.y };
  const v2 = { x: p3.x - p2.x, y: p3.y - p2.y };
  const dotProduct = v1.x * v2.x + v1.y * v2.y;
  const v1Length = Math.hypot(v1.x, v1.y);
  const v2Length = Math.hypot(v2.x, v2.y);
  if (v1Length === 0 || v2Length === 0) return 0;
  const cosAngle = dotProduct / (v1Length * v2Length);
  // 避免数值溢出(cos值范围[-1,1])
  const clampedCos = Math.max(-1, Math.min(1, cosAngle));
  return Math.acos(clampedCos) * (180 / Math.PI);
}

// 7. 获取摄像头权限,启动手势识别
async function startGestureDetection() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      video: {
        width: 640,
        height: 480,
        frameRate: 30, // 降低帧率,减少性能消耗(前端优化关键)
      },
    });
    // 将摄像头流传递给MediaPipe
    await hands.send({ image: stream });
  } catch (error) {
    console.error('摄像头权限获取失败或手势识别异常:', error);
    // 前端容错:权限拒绝时,提示用户,并自动切换到传统控件
    alert('摄像头权限获取失败,请开启权限后重试,当前已切换为传统控件操作');
  }
}

// 8. 地图加载完成后,启动手势识别
map.on('load', () => {
  startGestureDetection();
  // 同时保留传统控件,实现混合模式
  map.addControl(new maplibregl.NavigationControl(), 'top-right');
});

2.3 手势导航的前端优势(实战场景)

手势导航的优势,主要体现在“体验感”和“特殊场景”上,尤其是在需要“科技感”“无接触”的场景中,传统控件无法替代,总结为4点:

  1. 视觉体验炸裂:手势操作自带“科幻感”,在展厅、大屏演示、科技类产品中,能极大吸引用户注意力,提升产品的高端感,适合作为产品的“亮点功能”。
  2. 无接触交互:无需触摸屏幕或鼠标,适合医疗、工业、公共设施等场景(如医院的触控屏,避免交叉感染;工业场景中,工作人员戴手套无法操作触摸屏幕,手势导航可解决)。
  3. 交互表达力更强:能实现连续的、精细的操作,比如平滑旋转地图、精准调整3D倾斜角度,适合智慧城市、园区管理等需要复杂3D交互的场景。
  4. 扩展性强:前端可自定义手势映射,比如添加“三指点击”触发特定业务逻辑、“手掌握拳”重置地图视角等,灵活适配不同产品的需求。

2.4 手势导航的前端痛点(实战踩坑重点)

手势导航虽然酷炫,但在实际前端开发中,痛点非常明显,尤其是在生产级应用中,很多问题难以解决,结合我的踩坑经验,总结为5点核心痛点(前端开发者必看):

  1. 摄像头依赖:必须用户授权摄像头才能使用,而很多用户会拒绝授权(隐私顾虑),导致手势导航无法使用,前端必须做容错处理(如自动切换到传统控件)。
  2. 性能消耗大:MediaPipe 实时识别手部关键点,需要占用大量CPU/GPU资源,在低端PC、移动端上,会出现卡顿、掉帧的情况,甚至影响地图本身的流畅度,前端优化难度大。
  3. 可访问性极差:手势导航依赖摄像头和手部动作,排除了运动障碍用户(如手部残疾、无法做出特定手势的用户),不符合前端可访问性规范,无法用于政府、医疗等需要合规的项目。
  4. 操作精度低,易误触发:手势识别受光线、距离、手部遮挡影响较大,比如光线较暗时,识别精度下降,容易出现误平移、误缩放的情况;用户不经意的手部动作,也可能触发地图操作,影响用户体验。
  5. 用户学习成本高:手势操作需要用户学习(如“手掌张开平移、双指捏合缩放”),前端需要添加引导提示(如手势示意图、文字说明),增加开发工作量;部分用户可能不愿意学习,直接放弃使用手势功能。

三、前端维度:传统控件与手势导航逐项对比(实战选型参考)

结合前面的实现和踩坑经验,从前端开发的核心关注点(接入成本、性能、兼容性、可访问性等)出发,做一个详细的对比表格,方便大家在项目中快速选型,避免踩坑。

对比维度 传统地图控件(Leaflet/MapLibre) 手势导航(MediaPipe + 地图库) 前端选型建议
接入成本 极低,几行代码初始化,无需额外依赖(除地图瓦片) 较高,需要集成MediaPipe,处理摄像头权限、手势识别、防抖优化,开发工作量大 快速开发、简单场景选传统控件;有特殊需求(科技感、无接触)再考虑手势
用户学习成本 零学习成本,用户凭本能操作 高,需要用户学习手势规则,前端需添加引导 面向普通用户的产品(如导航、地图查询)选传统控件;面向演示、高端场景选手势
设备支持 全设备兼容(PC、移动端、低端设备),无需额外硬件 需设备有摄像头,低端设备易卡顿,部分设备(如无摄像头的PC)无法使用 多设备适配场景选传统控件;固定场景(如展厅大屏)选手势
操作精度 高,鼠标/触摸操作精准,无抖动、误触发 中等,受光线、距离影响,易误触发、抖动 需要精准操作(如地图标注、路线规划)选传统控件;演示场景选手势
可访问性 良好,原生支持键盘导航、屏幕阅读器,符合A11Y规范 差,依赖手部动作和摄像头,排除运动障碍用户 政府、医疗、公共产品选传统控件;无合规要求的演示场景选手势
性能消耗 极小,交互层代码轻量,不占用过多CPU/GPU 明显可感知,实时识别手部关键点,消耗大量资源 低端设备、高性能要求场景选传统控件;高性能设备、演示场景选手势
视觉惊艳度 低,样式单一,缺乏科技感 极高,手势操作酷炫,适合打造产品亮点 需要突出产品科技感选手势;注重实用性选传统控件
最佳适用场景 生产级应用(导航、地图查询、后台管理、标注工具) 展示类场景(展厅、大屏演示、科技产品宣传)、特殊场景(无接触交互) 根据场景选型,优先考虑传统控件,手势作为补充
前端维护成本 低,API稳定,几乎无需维护 高,需要维护手势识别逻辑、优化性能、处理兼容性问题 长期维护、迭代的项目选传统控件;短期演示项目选手势

四、前端实战:混合模式(最优方案)

通过前面的对比,我们可以得出一个结论:传统控件和手势导航,不是“非此即彼”的关系,而是“互补”的关系。前端最优实践是:采用“混合模式”,把手势导航作为传统控件的“可选增强功能”,兼顾实用性和体验感。

核心思路:抽象一个统一的地图控制层,让传统控件和手势导航调用同一套控制方法,实现“无缝切换”——默认启用传统控件,用户可手动开启手势导航,关闭手势后自动恢复传统控件的操作逻辑。

4.1 前端封装:统一地图控制层(可复用)

封装一个通用的地图控制器,隔离地图库的API差异,让传统控件和手势导航都通过这个控制器操作地图,降低耦合度,便于后续维护和扩展。

// 统一地图控制层(支持Leaflet、MapLibre,可扩展)
class MapController {
  constructor(map, mapType = 'maplibre') {
    this.map = map; // 地图实例
    this.mapType = mapType; // 地图类型(maplibre/leaflet)
    this.gestureEnabled = false; // 手势是否启用
  }

  // 平移地图
  pan(dx, dy) {
    if (this.mapType === 'maplibre') {
      this.map.panBy([dx, dy], { animate: false });
    } else if (this.mapType === 'leaflet') {
      this.map.panBy([dx, dy]);
    }
  }

  // 缩放地图
  zoom(delta) {
    const currentZoom = this.map.getZoom();
    const newZoom = Math.max(this.map.getMinZoom(), Math.min(this.map.getMaxZoom(), currentZoom + delta));
    if (this.mapType === 'maplibre') {
      this.map.zoomTo(newZoom, { animate: true });
    } else if (this.mapType === 'leaflet') {
      this.map.setZoom(newZoom, { animate: true });
    }
  }

  // 旋转地图(仅MapLibre支持,Leaflet需额外插件)
  rotate(bearing) {
    if (this.mapType === 'maplibre') {
      this.map.rotateTo(bearing, { animate: false });
    }
  }

  // 重置地图视角
  resetView(center, zoom, pitch = 0, bearing = 0) {
    if (this.mapType === 'maplibre') {
      this.map.setCenter(center);
      this.map.setZoom(zoom);
      this.map.setPitch(pitch);
      this.map.setBearing(bearing);
    } else if (this.mapType === 'leaflet') {
      this.map.setView(center, zoom);
    }
  }

  // 启用/禁用手势导航
  toggleGesture(enabled) {
    this.gestureEnabled = enabled;
    // 禁用手势时,重置手势状态
    if (!enabled) {
      prevPalmCenter = null;
      prevFingerDistance = null;
      prevWristAngle = null;
      isGestureActive = false;
    }
  }
}

// 初始化控制器(以MapLibre为例)
const controller = new MapController(map, 'maplibre');

// 传统控件事件绑定(调用控制器方法)
map.on('move', () => {
  // 传统控件操作,无需处理,地图库原生支持
});

// 手势识别事件绑定(调用控制器方法)
hands.onResults((results) => {
  // 只有手势启用时,才执行手势逻辑
  if (!controller.gestureEnabled) return;

  // 复用前面的手势识别逻辑,将地图操作替换为控制器方法
  // ...(省略手势识别代码,与前面一致)
  if (isPalmOpen(landmarks) && prevPalmCenter) {
    const dx = (palmCenter.x - prevPalmCenter.x) * -800;
    const dy = (palmCenter.y - prevPalmCenter.y) * 800;
    if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
      controller.pan(dx, dy); // 调用控制器平移方法
    }
  }

  if (prevFingerDistance) {
    const distanceDelta = fingerDistance - prevFingerDistance;
    const zoomDelta = distanceDelta > 0 ? 0.1 : -0.1;
    if (Math.abs(distanceDelta) > 0.01) {
      controller.zoom(zoomDelta); // 调用控制器缩放方法
    }
  }

  if (prevWristAngle) {
    const angleDelta = wristAngle - prevWristAngle;
    if (Math.abs(angleDelta) > 1) {
      controller.rotate(map.getBearing() + angleDelta); // 调用控制器旋转方法
    }
  }
});

// 前端UI:手势开关按钮(用户可手动切换)
const gestureSwitch = document.getElementById('gesture-switch');
gestureSwitch.addEventListener('change', (e) => {
  const isChecked = e.target.checked;
  controller.toggleGesture(isChecked);
  if (isChecked) {
    // 开启手势,提示用户授权摄像头
    startGestureDetection();
    alert('手势导航已开启,请确保摄像头已授权');
  } else {
    // 关闭手势,提示用户切换到传统控件
    alert('手势导航已关闭,当前使用传统控件操作');
  }
});

4.2 前端优化:手势导航性能与体验优化(实战重点)

手势导航的最大问题是性能和误触发,下面给出5个前端优化技巧,亲测有效,可直接应用到项目中:

  1. 降低MediaPipe性能消耗:

    1. 将 modelComplexity 设为0(轻量版),适合移动端和低端PC;
    2. 降低摄像头帧率(如30fps),减少数据处理量;
    3. 只识别一只手(maxNumHands: 1),避免双手干扰,减少识别压力;
    4. 手势未激活时,暂停MediaPipe识别(如用户长时间无手势操作,自动暂停)。
  2. 添加防抖和阈值过滤:

    1. 设置16ms防抖时间(与屏幕刷新率一致),避免频繁触发手势事件;
    2. 给平移、缩放、旋转设置最小阈值(如平移位移>2px、缩放距离变化>0.01、旋转角度>1度),避免微小抖动导致的误操作。
  3. 优化手势识别逻辑:

    1. 手掌中心取多个关键点的平均值(如中指、无名指、小指根部),提升稳定性;
    2. 完善手势判断条件(如手掌张开的角度阈值),减少误识别。
  4. 容错处理:

    1. 摄像头权限拒绝时,自动切换到传统控件,并给出提示;
    2. 手势识别异常(如光线过暗、手部遮挡)时,暂停手势操作,提示用户调整环境。
  5. 用户引导:

    1. 添加手势引导示意图(如“手掌张开平移、双指捏合缩放”),降低用户学习成本;
    2. 手势开启后,给出简短的操作提示,帮助用户快速上手。

五、前端必做:用户行为埋点与分析

无论采用哪种交互方案,前端都需要添加用户行为埋点,了解用户的真实操作习惯,尤其是手势导航这种“实验性”功能,埋点数据能帮助我们判断其是否有存在的价值,优化交互体验。

下面推荐3款轻量、隐私友好的前端埋点工具(替代Google Analytics),适合地图场景,尤其是自定义事件较多的情况:

5.1 Umami(首选,自托管+开源)

Umami 是一款开源、自托管的前端分析工具,无Cookie、GDPR合规,支持自定义事件埋点,适合地图这类高频自定义事件(如平移、缩放、旋转、手势开关)的场景,不用担心SaaS平台的限额问题。

前端接入简单,只需添加一段脚本,即可实现自定义事件埋点:

// 1. 引入Umami脚本(自托管部署后替换为自己的地址)
<script async src="https://your-umami-domain.com/script.js" data-website-id="your-website-id"></script>

// 2. 地图交互埋点(传统控件+手势导航)
// 传统控件平移埋点
map.on('move', () => {
  // 调用Umami自定义事件埋点
  umami.track('地图平移', {
    交互方式: '传统控件(鼠标/触摸)',
    中心点: `${map.getCenter().lat.toFixed(6)}, ${map.getCenter().lng.toFixed(6)}`,
    缩放级别: map.getZoom()
  });
});

// 手势导航平移埋点
hands.onResults((results) => {
  if (controller.gestureEnabled && isGestureActive && isPalmOpen(landmarks) && prevPalmCenter) {
    umami.track('地图平移', {
      交互方式: '手势导航',
      中心点: `${map.getCenter().lat.toFixed(6)}, ${map.getCenter().lng.toFixed(6)}`,
      缩放级别: map.getZoom()
    });
  }
});

// 手势开关埋点
gestureSwitch.addEventListener('change', (e) => {
  umami.track('手势开关', {
    状态: e.target.checked ? '开启' : '关闭',
    操作时间: new Date().toLocaleString()
  });
});

5.2 Plausible(托管版,开箱即用)

Plausible 是一款托管版的轻量分析工具,界面精致,无需自托管,开箱即用,适合不想部署服务器的小型项目,支持自定义事件埋点,隐私友好,GDPR合规。

5.3 Fathom(付费,极简)

Fathom 是一款付费的极简分析工具,体积极小(仅几KB),加载速度快,适合对性能要求极高的项目,支持自定义事件埋点,操作简单,无需复杂配置。

埋点重点关注指标(前端分析)

  • 传统控件 vs 手势导航的使用占比:判断用户是否愿意使用手势导航;
  • 手势导航的开启/关闭频率:判断手势导航的体验是否符合用户预期;
  • 手势误触发率:通过埋点统计误平移、误缩放的次数,优化手势识别逻辑;
  • 不同设备的手势使用体验:统计不同设备(PC、移动端、高端/低端设备)的手势流畅度,优化性能。

六、前端最终选型建议(实战总结)

结合近一年的地图项目实战经验,以及前面的对比和优化,给前端开发者的最终选型建议,简单直接,避免踩坑:

  1. 生产级应用(如导航、地图查询、后台管理、标注工具):坚守传统控件,优先选择 Leaflet(轻量简单)或 MapLibre GL JS(3D复杂场景),保证稳定性、兼容性和用户体验,手势导航可作为“彩蛋功能”,不建议作为主要交互方式。
  2. 展示类场景(如展厅、大屏演示、科技产品宣传):手势导航是王炸,能极大提升产品的科技感和吸引力,可搭配传统控件作为备用(避免摄像头权限问题导致无法操作)。
  3. 特殊场景(如医疗、工业、无接触交互):手势导航是最佳选择,需做好性能优化和容错处理,确保在特定设备上的流畅度。
  4. 最优架构:混合模式——默认启用传统控件,把手势导航作为可选增强功能,通过埋点数据了解用户偏好,逐步优化交互体验,兼顾实用性和科技感。

最后,分享一个感悟:好的前端交互,不是“越酷炫越好”,而是“越无感越好”。传统控件之所以能沿用十几年,核心就是它让用户“忘记操作方式”,专注于业务本身;而手势导航,虽然酷炫,但目前还没做到“无感”,仍有很多优化空间。

但不可否认,手势导航是未来地图交互的一个方向,随着硬件性能的提升和识别算法的优化,它终将在更多场景中落地。作为前端开发者,我们需要做的,是根据项目需求,理性选型,既要兼顾实用性,也要敢于尝试新技术,打造更好的用户体验。

结语:本文从前端视角,详细对比了传统地图控件与手势导航的实现、优势、痛点,给出了实战代码、优化方案和选型建议,如果觉得有帮助,欢迎点赞、收藏、转发,也可以在评论区交流你的地图交互实战经验~

告别 `any`:TypeScript 中 `try...catch` 的最佳实践

在 TypeScript 项目中,你是否经常为了通过编译而写出这种代码?

try {
  // 某些逻辑
} catch (err: any) { // ❌ 违背了 TS 类型安全的初衷
  console.log(err.message); 
}

随着 TS 配置趋于严格,catch(err: any) 往往会触发 ESLint 警告或编译错误。本文将介绍处理 catch 块中错误对象的几种最佳实践

1. 理解 unknown 的必然性

在现代 TypeScript(4.0+)中,推荐将捕获到的错误声明为 unknown。这是因为在运行时刻,你无法保证捕获到的一定是 Error 实例。

try {
  throw "意外的错误字符串"; // 这里的错误甚至不是一个对象
} catch (err: unknown) {
  // ❌ 报错:'err' is of type 'unknown'
  // console.log(err.message); 
}

2. 方案一:类型守卫(Type Guards)—— 最稳健的方法

这是官方推荐的做法。通过显式的 instanceof 检查,TS 会在代码块内自动收窄(Narrowing)类型。

try {
  await fetchData();
} catch (err: unknown) {
  if (err instanceof Error) {
    // ✅ TS 现在知道 err 是 Error 类型
    console.error(err.message);
    console.error(err.stack);
  } else {
    // 处理非标准错误(如 throw "string")
    console.error("发生了未知类型的错误", err);
  }
}

3. 方案二:自定义工具函数(封装大法)

如果你觉得到处写 if (err instanceof Error) 太麻烦,可以封装一个工具函数。这是目前大型项目中最流行的做法。

编写工具函数

function toError(err: unknown): Error {
  if (err instanceof Error) return err;
  return new Error(String(err));
}

业务中使用

try {
  doSomething();
} catch (err: unknown) {
  const error = toError(err);
  console.log(error.message); // ✅ 永远安全
}

4. 方案三:函数式处理(类似 Rust/Go)

如果你讨厌深层嵌套的 try...catch,可以使用封装好的包装器,将错误作为返回值返回。

async function safeRun<T>(promise: Promise<T>): Promise<[Error | null, T | null]> {
  try {
    const data = await promise;
    return [null, data];
  } catch (err: unknown) {
    return [toError(err), null];
  }
}

// 使用:
const [err, data] = await safeRun(fetchUser(id));
if (err) {
  handle(err);
} else {
  render(data);
}

5. 进阶:处理 Axios 等库的特定错误

如果你在使用 Axios,可以使用它内置的类型守卫:

import axios from 'axios';

try {
  await axios.get('/api/user');
} catch (err: unknown) {
  if (axios.isAxiosError(err)) {
    // 这里可以访问 err.response, err.status 等特有属性
    console.log(err.response?.data);
  }
}

总结:该选哪一个?

场景 推荐做法
临时处理/小型脚本 if (err instanceof Error)
标准大型项目 封装 toError() 工具函数,确保类型安全
追求代码扁平化 采用 safeRun 包装器返回 [err, data]
第三方库请求 优先使用库提供的 isError 判断函数

核心原则: 永远不要相信 catch 捕获到的内容,永远在访问属性前进行类型检查。这不仅是过编译的要求,更是写出健壮代码的基石。

vite 是如何加载解析 vite.config.js 配置文件的?

当我们在终端运行 vite dev,Vite 启动开发服务器的首个关键步骤就是解析配置。本文将深入剖析 Vite 加载配置文件的三种模式。

loadConfigFromFile 的完整流程

loadConfigFromFile 是配置文件加载的核心函数,其完整流程如下:

  1. 确定配置文件路径(自动查找或使用 --config 指定的路径)。
  2. 根据文件后缀和 package.json 中的 type 字段判断模块格式(是否为 ESM)。
  3. 根据 configLoader加载器配置来加载配置文件和转换代码。
    • bundle模式,调用 bundleConfigFile 使用 rolldown 打包配置文件,获取转换后的代码和依赖列表。调用 loadConfigFromBundledFile 将打包后的代码转成配置对象。
    • runner模式,使用 Vite 的 ModuleRunner 动态转换任何文件。它的核心机制是利用 RunnableDevEnvironment 提供的 runner.import 函数,在独立的执行环境中加载并运行模块
    • native模式,利用原生动态引入。
  4. 如果用户导出的是函数,则调用该函数传入 configEnv(包含 commandmode 等参数),获取最终配置对象。
  5. 返回配置对象、配置文件路径以及依赖列表 dependencies
  let { configFile } = config
  if (configFile !== false) {
    // 从文件加载配置
    const loadResult = await loadConfigFromFile(
      configEnv,
      configFile,
      config.root,
      config.logLevel,
      config.customLogger,
      config.configLoader,
    )
    if (loadResult) {
      config = mergeConfig(loadResult.config, config)
      configFile = loadResult.path
      configFileDependencies = loadResult.dependencies
    }
  }

image.png

如果在执行 vite dev 时没有使用 --config 参数指定配置文件,Vite 将按照以下顺序自动查找并加载配置文件。

const DEFAULT_CONFIG_FILES: string[] = [
  'vite.config.js',
  'vite.config.mjs',
  'vite.config.ts',
  'vite.config.cjs',
  'vite.config.mts',
  'vite.config.cts',
]

Vite 提供了三种配置加载机制

当配置文件被定位后,Vite 如何读取并执行它的内容?这取决于 configLoader 配置选项。Vite 提供了三种机制来加载配置文件,默认使用 bundle 模式。

const resolver =
  configLoader === 'bundle'
    ? bundleAndLoadConfigFile // 处理配置文件的预构建
    : configLoader === 'runner'
      ? runnerImportConfigFile // 处理配置文件的运行时导入
      : nativeImportConfigFile // 处理配置文件的原生导入

bundle (默认)

使用打包工具(Rolldown)将配置文件及其依赖打包成一个临时文件,再加载执行。

function bundleAndLoadConfigFile(resolvedPath: string) {
  // 检查是否为 ESM 模块
  const isESM =
    // 在 Deno 环境中运行
    typeof process.versions.deno === 'string' || isFilePathESM(resolvedPath)

  // 配置文件打包
  // 打包过程会处理配置文件的依赖,将其转换为可执行的代码
  const bundled = await bundleConfigFile(resolvedPath, isESM)
  // 配置加载
  const userConfig = await loadConfigFromBundledFile(
    resolvedPath,
    bundled.code,
    isESM,
  )

  return {
    // 加载的用户配置
    configExport: userConfig,
    // 配置文件的依赖项
    dependencies: bundled.dependencies,
  }
}

image.png

image.png

image.png

image.png

image.png

image.png

bundle.code 字符串

import "node:module";
import { defineConfig } from "file:///Users/xxxxx/Documents/code/cloudcode/vue3-vite-cube/node_modules/vite/dist/node/index.js";
import vue from "file:///Users/xxxxx/Documents/code/cloudcode/vue3-vite-cube/node_modules/@vitejs/plugin-vue/dist/index.mjs";
import vueJsx from "file:///Users/xxxxx/Documents/code/cloudcode/vue3-vite-cube/node_modules/@vitejs/plugin-vue-jsx/dist/index.mjs";
import VueRouter from "file:///Users/xxxx/Documents/code/cloudcode/vue3-vite-cube/node_modules/vue-router/dist/unplugin/vite.mjs";
Object.create;
Object.defineProperty;
Object.getOwnPropertyDescriptor;
Object.getOwnPropertyNames;
Object.getPrototypeOf;
Object.prototype.hasOwnProperty;
var vite_config_default = defineConfig({
plugins: [
VueRouter({
routesFolder: "src/pages",
extensions: [".vue"],
dts: "src/typed-router.d.ts",
importMode: "async",
root: process.cwd(),
watch: true
}),
vue(),
vueJsx()
],
resolve: { alias: { "@": "/src" } },
css: { preprocessorOptions: { less: {
additionalData: \`@import "@/styles/variables.less";\`,
javascriptEnabled: true
} } },
mode: "development"
});
//#endregion
export { vite_config_default as default };

//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidml0ZS5jb25maWcuanMiLCJuYW1lcyI6W10sInNvdXJjZXMiOlsiL1VzZXJzL2hhaXlhbi9Eb2N1bWVudHMvY29kZS9jbG91ZGNvZGUvdnVlMy12aXRlLWN1YmUvdml0ZS5jb25maWcudHMiXSwic291cmNlc0NvbnRlbnQiOlsiLy8gaW1wb3J0IHsgZmlsZVVSTFRvUGF0aCwgVVJMIH0gZnJvbSAnbm9kZTp1cmwnXG5pbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tICd2aXRlJ1xuaW1wb3J0IHZ1ZSBmcm9tICdAdml0ZWpzL3BsdWdpbi12dWUnXG5pbXBvcnQgdnVlSnN4IGZyb20gJ0B2aXRlanMvcGx1Z2luLXZ1ZS1qc3gnXG5pbXBvcnQgdnVlRGV2VG9vbHMgZnJvbSAndml0ZS1wbHVnaW4tdnVlLWRldnRvb2xzJ1xuaW1wb3J0IFZ1ZVJvdXRlciBmcm9tICd2dWUtcm91dGVyL3ZpdGUnXG5cblxuLy8gaHR0cHM6Ly92aXRlLmRldi9jb25maWcvXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICBwbHVnaW5zOiBbXG4gICAgLy8g5b+F6aG76KaB5ZyoIHZ1ZSDmj5Lku7bkuYvliY1cbiAgICBWdWVSb3V0ZXIoe1xuICAgICAgcm91dGVzRm9sZGVyOiAnc3JjL3BhZ2VzJywgLy8g6buY6K6kIHBhZ2VzXG4gICAgICBleHRlbnNpb25zOiBbJy52dWUnXSwgLy8g5Yy56YWN5paH5Lu25ZCO57yAXG4gICAgICBkdHM6ICdzcmMvdHlwZWQtcm91dGVyLmQudHMnLCAvLyDnlJ/miJDnsbvlnovmlofku7ZcbiAgICAgIC8vIOWHuueOsCBSYW5nZUVycm9yOiBNYXhpbXVtIGNhbGwgc3RhY2sgc2l6ZSBleGNlZWRlZFxuICAgICAgLy8gZ2V0Um91dGVOYW1lOiAocm91dGUpID0+IHtcbiAgICAgIC8vICAgY29uc29sZS5sb2coJ2dldFJvdXRlTmFtZScscm91dGUpXG4gICAgICAvLyAgIHJldHVybiByb3V0ZS5uYW1lIHx8IHJvdXRlLnBhdGhcbiAgICAgIC8vIH0sXG5cbiAgICAgICAvLyDmt7vliqDosIPor5XpgInpoblcbiAgICAgIC8vIGxvZ3M6IHRydWUsXG5cbiAgICAgIC8vIHJvdXRlQmxvY2tMYW5nOiAnanNvbjUnLCAvLyDot6/nlLHlnZfor63oqIDvvIzpu5jorqQganNvblxuICAgICAgaW1wb3J0TW9kZTogJ2FzeW5jJyxcbiAgICAgIHJvb3Q6IHByb2Nlc3MuY3dkKCksXG5cbiAgICAgIC8vIOWcqOmFjee9ruaWh+S7tuWGmeWFpeWJje+8jOaJi+WKqOS/ruaUuei3r+eUsemFjee9ru+8iOWmgua3u+WKoOWFqOWxgOi3r+eUseWuiOWNq+OAgeiwg+aVtOi3r+eUseWFg+S/oeaBr+OAgei/h+a7pOi3r+eUseetie+8iVxuICAgICAgLy8gYmVmb3JlV3JpdGVGaWxlczogKGVkaXRlZFJvdXRlcykgPT4ge1xuICAgICAgLy8gICBjb25zb2xlLmxvZygnYmVmb3JlV3JpdGVGaWxlcycsIGVkaXRlZFJvdXRlcylcbiAgICAgIC8vIH0sXG4gICAgICB3YXRjaDogdHJ1ZSwgLy8g5byA5ZCv6Lev55Sx5Z2X5paH5Lu255uR5ZCsXG4gICAgICAvLyDlvIDlkK/lrp7pqozmgKflip/og71cbiAgICAgIC8vIGV4cGVyaW1lbnRhbDoge1xuICAgICAgIFxuICAgICAgLy8gfSxcbiAgICB9KSxcbiAgICB2dWUoKSxcbiAgICB2dWVKc3goKSxcbiAgICAvLyB2dWVEZXZUb29scygpLFxuICBdLFxuICByZXNvbHZlOiB7XG4gICAgLy8gYWxpYXM6IHtcbiAgICAvLyAgICdAJzogZmlsZVVSTFRvUGF0aChuZXcgVVJMKCcuL3NyYycsIGltcG9ydC5tZXRhLnVybCkpXG4gICAgLy8gfSxcbiAgICBhbGlhczoge1xuICAgICAgJ0AnOiAnL3NyYycsXG4gICAgfSxcbiAgICAvLyB0c2NvbmZpZ1BhdGhzOiB0cnVlLCAgLy8g6Ieq5Yqo6K+75Y+WIHRzY29uZmlnIHBhdGhzXG4gIH0sXG4gIGNzczoge1xuICAgIHByZXByb2Nlc3Nvck9wdGlvbnM6IHtcbiAgICAgIGxlc3M6IHtcbiAgICAgICAgYWRkaXRpb25hbERhdGE6IGBAaW1wb3J0IFwiQC9zdHlsZXMvdmFyaWFibGVzLmxlc3NcIjtgLFxuICAgICAgICBqYXZhc2NyaXB0RW5hYmxlZDogdHJ1ZVxuICAgICAgfVxuICAgIH1cbiAgfSxcbiAgbW9kZTogJ2RldmVsb3BtZW50JyxcbiAgLy8gc2VydmVyOiB7XG4gIC8vICAgd3M6IGZhbHNlLFxuICAvLyB9LFxuICAvLyBvcHRpbWl6ZURlcHM6IHtcbiAgLy8gICBpbmNsdWRlOiBbJ3ZpcnR1YWw6dnVlLWluc3BlY3Rvci1wYXRoOmxvYWQuanMnXSxcbiAgLy8gfSxcblxufSkiXSwibWFwcGluZ3MiOiI7Ozs7Ozs7Ozs7O0FBU0EsSUFBQSxzQkFBZSxhQUFhO0NBQzFCLFNBQVM7RUFFUCxVQUFVO0dBQ1IsY0FBYztHQUNkLFlBQVksQ0FBQyxPQUFPO0dBQ3BCLEtBQUs7R0FXTCxZQUFZO0dBQ1osTUFBTSxRQUFRLEtBQUs7R0FNbkIsT0FBTztHQUtSLENBQUM7RUFDRixLQUFLO0VBQ0wsUUFBUTtFQUVUO0NBQ0QsU0FBUyxFQUlQLE9BQU8sRUFDTCxLQUFLLFFBQ04sRUFFRjtDQUNELEtBQUssRUFDSCxxQkFBcUIsRUFDbkIsTUFBTTtFQUNKLGdCQUFnQjtFQUNoQixtQkFBbUI7RUFDckIsRUFDRixFQUNEO0NBQ0QsTUFBTTtDQVFQLENBQUEifQ==

dependencies

[
  "/Users/xxxx/Documents/code/cloudcode/vue3-vite-cube/vite.config.ts",
]

临时文件

image.png

image.png

vue3-vite-cube/node_modules/.vite-temp/vite.config.ts.timestamp-1775361732369-f30607f0da0d6.mjs 文件内容如下:

import "node:module";
import { defineConfig } from "file:///Users/xxxx/Documents/code/cloudcode/vue3-vite-cube/node_modules/vite/dist/node/index.js";
import vue from "file:///Users/xxxx/Documents/code/cloudcode/vue3-vite-cube/node_modules/@vitejs/plugin-vue/dist/index.mjs";
import vueJsx from "file:///Users/xxxx/Documents/code/cloudcode/vue3-vite-cube/node_modules/@vitejs/plugin-vue-jsx/dist/index.mjs";
import VueRouter from "file:///Users/xxxx/Documents/code/cloudcode/vue3-vite-cube/node_modules/vue-router/dist/unplugin/vite.mjs";
Object.create;
Object.defineProperty;
Object.getOwnPropertyDescriptor;
Object.getOwnPropertyNames;
Object.getPrototypeOf;
Object.prototype.hasOwnProperty;
var vite_config_default = defineConfig({
plugins: [
VueRouter({
routesFolder: "src/pages",
extensions: [".vue"],
dts: "src/typed-router.d.ts",
importMode: "async",
root: process.cwd(),
watch: true
}),
vue(),
vueJsx()
],
resolve: { alias: { "@": "/src" } },
css: { preprocessorOptions: { less: {
additionalData: `@import "@/styles/variables.less";`,
javascriptEnabled: true
} } },
mode: "development"
});
//#endregion
export { vite_config_default as default };

//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidml0ZS5jb25maWcuanMiLCJuYW1lcyI6W10sInNvdXJjZXMiOlsiL1VzZXJzL2hhaXlhbi9Eb2N1bWVudHMvY29kZS9jbG91ZGNvZGUvdnVlMy12aXRlLWN1YmUvdml0ZS5jb25maWcudHMiXSwic291cmNlc0NvbnRlbnQiOlsiLy8gaW1wb3J0IHsgZmlsZVVSTFRvUGF0aCwgVVJMIH0gZnJvbSAnbm9kZTp1cmwnXG5pbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tICd2aXRlJ1xuaW1wb3J0IHZ1ZSBmcm9tICdAdml0ZWpzL3BsdWdpbi12dWUnXG5pbXBvcnQgdnVlSnN4IGZyb20gJ0B2aXRlanMvcGx1Z2luLXZ1ZS1qc3gnXG5pbXBvcnQgdnVlRGV2VG9vbHMgZnJvbSAndml0ZS1wbHVnaW4tdnVlLWRldnRvb2xzJ1xuaW1wb3J0IFZ1ZVJvdXRlciBmcm9tICd2dWUtcm91dGVyL3ZpdGUnXG5cblxuLy8gaHR0cHM6Ly92aXRlLmRldi9jb25maWcvXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICBwbHVnaW5zOiBbXG4gICAgLy8g5b+F6aG76KaB5ZyoIHZ1ZSDmj5Lku7bkuYvliY1cbiAgICBWdWVSb3V0ZXIoe1xuICAgICAgcm91dGVzRm9sZGVyOiAnc3JjL3BhZ2VzJywgLy8g6buY6K6kIHBhZ2VzXG4gICAgICBleHRlbnNpb25zOiBbJy52dWUnXSwgLy8g5Yy56YWN5paH5Lu25ZCO57yAXG4gICAgICBkdHM6ICdzcmMvdHlwZWQtcm91dGVyLmQudHMnLCAvLyDnlJ/miJDnsbvlnovmlofku7ZcbiAgICAgIC8vIOWHuueOsCBSYW5nZUVycm9yOiBNYXhpbXVtIGNhbGwgc3RhY2sgc2l6ZSBleGNlZWRlZFxuICAgICAgLy8gZ2V0Um91dGVOYW1lOiAocm91dGUpID0+IHtcbiAgICAgIC8vICAgY29uc29sZS5sb2coJ2dldFJvdXRlTmFtZScscm91dGUpXG4gICAgICAvLyAgIHJldHVybiByb3V0ZS5uYW1lIHx8IHJvdXRlLnBhdGhcbiAgICAgIC8vIH0sXG5cbiAgICAgICAvLyDmt7vliqDosIPor5XpgInpoblcbiAgICAgIC8vIGxvZ3M6IHRydWUsXG5cbiAgICAgIC8vIHJvdXRlQmxvY2tMYW5nOiAnanNvbjUnLCAvLyDot6/nlLHlnZfor63oqIDvvIzpu5jorqQganNvblxuICAgICAgaW1wb3J0TW9kZTogJ2FzeW5jJyxcbiAgICAgIHJvb3Q6IHByb2Nlc3MuY3dkKCksXG5cbiAgICAgIC8vIOWcqOmFjee9ruaWh+S7tuWGmeWFpeWJje+8jOaJi+WKqOS/ruaUuei3r+eUsemFjee9ru+8iOWmgua3u+WKoOWFqOWxgOi3r+eUseWuiOWNq+OAgeiwg+aVtOi3r+eUseWFg+S/oeaBr+OAgei/h+a7pOi3r+eUseetie+8iVxuICAgICAgLy8gYmVmb3JlV3JpdGVGaWxlczogKGVkaXRlZFJvdXRlcykgPT4ge1xuICAgICAgLy8gICBjb25zb2xlLmxvZygnYmVmb3JlV3JpdGVGaWxlcycsIGVkaXRlZFJvdXRlcylcbiAgICAgIC8vIH0sXG4gICAgICB3YXRjaDogdHJ1ZSwgLy8g5byA5ZCv6Lev55Sx5Z2X5paH5Lu255uR5ZCsXG4gICAgICAvLyDlvIDlkK/lrp7pqozmgKflip/og71cbiAgICAgIC8vIGV4cGVyaW1lbnRhbDoge1xuICAgICAgIFxuICAgICAgLy8gfSxcbiAgICB9KSxcbiAgICB2dWUoKSxcbiAgICB2dWVKc3goKSxcbiAgICAvLyB2dWVEZXZUb29scygpLFxuICBdLFxuICByZXNvbHZlOiB7XG4gICAgLy8gYWxpYXM6IHtcbiAgICAvLyAgICdAJzogZmlsZVVSTFRvUGF0aChuZXcgVVJMKCcuL3NyYycsIGltcG9ydC5tZXRhLnVybCkpXG4gICAgLy8gfSxcbiAgICBhbGlhczoge1xuICAgICAgJ0AnOiAnL3NyYycsXG4gICAgfSxcbiAgICAvLyB0c2NvbmZpZ1BhdGhzOiB0cnVlLCAgLy8g6Ieq5Yqo6K+75Y+WIHRzY29uZmlnIHBhdGhzXG4gIH0sXG4gIGNzczoge1xuICAgIHByZXByb2Nlc3Nvck9wdGlvbnM6IHtcbiAgICAgIGxlc3M6IHtcbiAgICAgICAgYWRkaXRpb25hbERhdGE6IGBAaW1wb3J0IFwiQC9zdHlsZXMvdmFyaWFibGVzLmxlc3NcIjtgLFxuICAgICAgICBqYXZhc2NyaXB0RW5hYmxlZDogdHJ1ZVxuICAgICAgfVxuICAgIH1cbiAgfSxcbiAgbW9kZTogJ2RldmVsb3BtZW50JyxcbiAgLy8gc2VydmVyOiB7XG4gIC8vICAgd3M6IGZhbHNlLFxuICAvLyB9LFxuICAvLyBvcHRpbWl6ZURlcHM6IHtcbiAgLy8gICBpbmNsdWRlOiBbJ3ZpcnR1YWw6dnVlLWluc3BlY3Rvci1wYXRoOmxvYWQuanMnXSxcbiAgLy8gfSxcblxufSkiXSwibWFwcGluZ3MiOiI7Ozs7Ozs7Ozs7O0FBU0EsSUFBQSxzQkFBZSxhQUFhO0NBQzFCLFNBQVM7RUFFUCxVQUFVO0dBQ1IsY0FBYztHQUNkLFlBQVksQ0FBQyxPQUFPO0dBQ3BCLEtBQUs7R0FXTCxZQUFZO0dBQ1osTUFBTSxRQUFRLEtBQUs7R0FNbkIsT0FBTztHQUtSLENBQUM7RUFDRixLQUFLO0VBQ0wsUUFBUTtFQUVUO0NBQ0QsU0FBUyxFQUlQLE9BQU8sRUFDTCxLQUFLLFFBQ04sRUFFRjtDQUNELEtBQUssRUFDSCxxQkFBcUIsRUFDbkIsTUFBTTtFQUNKLGdCQUFnQjtFQUNoQixtQkFBbUI7RUFDckIsRUFDRixFQUNEO0NBQ0QsTUFBTTtDQVFQLENBQUEifQ==
/**
 * 用于从打包后的代码加载 Vite 配置。
 * 它根据模块类型(ESM 或 CommonJS)采用不同的加载策略,确保配置文件能够被正确执行并返回配置对象
 * @param fileName  文件路径
 * @param bundledCode 打包转换后代码
 * @param isESM 是否为 ESM 格式
 * @returns 
 */
async function loadConfigFromBundledFile(
  fileName: string,
  bundledCode: string,
  isESM: boolean,
): Promise<UserConfigExport> {
  // for esm, before we can register loaders without requiring users to run node
  // with --experimental-loader themselves, we have to do a hack here:
  // write it to disk, load it with native Node ESM, then delete the file.
  if (isESM) {
    // Storing the bundled file in node_modules/ is avoided for Deno
    // because Deno only supports Node.js style modules under node_modules/
    // and configs with `npm:` import statements will fail when executed.
    // 查找最近的 node_modules 目录
    let nodeModulesDir =
      typeof process.versions.deno === 'string'
        ? undefined
        : findNearestNodeModules(path.dirname(fileName))

    if (nodeModulesDir) {
      try {
        // 创建临时目录
        // node_modules/.vite-temp/
        await fsp.mkdir(path.resolve(nodeModulesDir, '.vite-temp/'), {
          recursive: true,
        })
      } catch (e) {
        if (e.code === 'EACCES') {
          // If there is no access permission, a temporary configuration file is created by default.
          nodeModulesDir = undefined
        } else {
          throw e
        }
      }
    }
    // 生成 hash 值
    const hash = `timestamp-${Date.now()}-${Math.random().toString(16).slice(2)}`
    // 生成临时文件名
    const tempFileName = nodeModulesDir
      ? path.resolve(
          nodeModulesDir,
          `.vite-temp/${path.basename(fileName)}.${hash}.mjs`,
        )
      : `${fileName}.${hash}.mjs`
      // 写入临时文件
    await fsp.writeFile(tempFileName, bundledCode)
    try {
      // 将文件系统路径转换为 file:// 协议的 URL 对象
      // 原因:ESM 的 import() 语法要求模块标识符为 URL 格式(对于本地文件),不能直接使用文件系统路径
      // 动态加载 ESM 格式配置文件
      // 执行过程:
      // 1、Node.js 读取并执行 tempFileName 指向的文件
      // 2、执行文件中的代码,构建模块的导出
      // 3、生成包含所有导出的模块命名空间对象
      // 4、Promise 解析为该命名空间对象
      return (await import(pathToFileURL(tempFileName).href)).default
    } finally {
      fs.unlink(tempFileName, () => {}) // Ignore errors
    }
  }
  // for cjs, we can register a custom loader via `_require.extensions`
  else {
    // 获取文件扩展名
    const extension = path.extname(fileName)
    // We don't use fsp.realpath() here because it has the same behaviour as
    // fs.realpath.native. On some Windows systems, it returns uppercase volume
    // letters (e.g. "C:\") while the Node.js loader uses lowercase volume letters.
    // See https://github.com/vitejs/vite/issues/12923
    // 获取文件的真实路径
    // 避免 Windows 系统上的路径大小写问题
    const realFileName = await promisifiedRealpath(fileName)
    // 确定加载器扩展名
    // require.extensions 标记已废弃
    const loaderExt = extension in _require.extensions ? extension : '.js'
    const defaultLoader = _require.extensions[loaderExt]!
    // 注册自定义加载器
    _require.extensions[loaderExt] = (module: NodeModule, filename: string) => {
      if (filename === realFileName) {
        // 执行打包后的代码
        ;(module as NodeModuleWithCompile)._compile(bundledCode, filename)
      } else {
        // 使用默认加载器
        defaultLoader(module, filename)
      }
    }
    // clear cache in case of server restart
    // 清除缓存
    delete _require.cache[_require.resolve(fileName)]
    // 加载配置文件
    const raw = _require(fileName)
    // 恢复默认加载器
    _require.extensions[loaderExt] = defaultLoader
    return raw.__esModule ? raw.default : raw
  }
}

runner (实验性)

runner 模式不会创建临时配置文件,而是使用 Vite 的 ModuleRunner 动态转换任何文件。它的核心机制是利用 RunnableDevEnvironment 提供的 runner.import 函数,在独立的执行环境中加载并运行模块。

{
   "start": "vite --configLoader=runner",
}
/**
 * 用于通过 runner 方式导入配置文件。
 * 它使用 runnerImport 函数动态加载配置文件,提取默认导出作为配置对象,并返回配置对象及其依赖项。
 * @param resolvedPath 配置文件路径
 * @returns 
 */
async function runnerImportConfigFile(resolvedPath: string) {
  const { module, dependencies } = await runnerImport<{
    default: UserConfigExport
  }>(resolvedPath)
  return {
    configExport: module.default,
    dependencies,
  }
}

image.png

async function runnerImport<T>(
  moduleId: string,
  inlineConfig?: InlineConfig,
): Promise<RunnerImportResult<T>> {

  // 模块同步条件检查
  const isModuleSyncConditionEnabled = (await import('#module-sync-enabled'))
    .default

  // 配置解析
  const config = await resolveConfig(
    // 合并配置
    mergeConfig(inlineConfig || {}, {
      configFile: false, // 禁用配置文件解析
      envDir: false, // 禁用环境变量目录解析
      cacheDir: process.cwd(), // 缓存目录设置为当前工作目录
      environments: {
        inline: {
          // 指定环境的消费方为服务器端
          consumer: 'server',
          dev: {
            // 启用模块运行器转换
            moduleRunnerTransform: true,
          },
          // 模块解析配置
          resolve: {
            // 启用外部模块解析,将依赖视为外部模块,不进行打包
            // 影响:减少打包体积,提高模块加载速度
            external: true,
            // 清空主字段数组
            // 不使用 package.json 中的主字段进行模块解析
            // 避免因主字段优先级导致的解析问题,确保一致性
            mainFields: [],
            // 指定模块解析条件
            conditions: [
              'node',
              ...(isModuleSyncConditionEnabled ? ['module-sync'] : []),
            ],
          },
        },
      },
    } satisfies InlineConfig),
    'serve', // 确保是 serve 命令
  )
  // 创建可运行的开发环境
  const environment = createRunnableDevEnvironment('inline', config, {
    runnerOptions: {
      hmr: {
        logger: false, // 禁用 HMR 日志记录
      },
    },
    hot: false, // 禁用 HMR
  })
  // 初始化环境
  // 准备模块运行器,确保能够正确加载模块
  await environment.init()
  try {
    // 使用环境的运行器导入模块
    // 模块加载与执行:
    // 1、ModuleRunner 解析 moduleId,处理路径解析
    // 2、加载模块文件内容
    // 3、应用必要的转换(如 ESM 到 CommonJS 的转换)
    // 4、执行模块代码
    // 5、收集模块的依赖项
    const module = await environment.runner.import(moduleId)

    // 获取所有评估过的模块
    const modules = [
      ...environment.runner.evaluatedModules.urlToIdModuleMap.values(),
    ]
    // 过滤出所有外部化模块和当前模块
    // 这些模块不是依赖项,因为它们是 Vite 内部使用的模块
    const dependencies = modules
      .filter((m) => {
        // ignore all externalized modules
        // 忽略没有meta的模块 或者标记为外部化的模块
        if (!m.meta || 'externalize' in m.meta) {
          return false
        }
        // ignore the current module
        // 忽略当前模块,因为它不是依赖项
        return m.exports !== module
      })
      .map((m) => m.file)

    return {
      module,
      dependencies,
    }
  } finally {
    // 关闭环境
    // 释放所有资源,避免内存泄漏等问题
    await environment.close()
  }
}

image.png

module

{
  default: {
    plugins: [
      {
        name: "vue-router",
        enforce: "pre",
        resolveId: {
          filter: {
            id: {
              include: [
                {
                },
                {
                },
                {
                },
              ],
            },
          },
          handler: function(...args) {
            const [id] = args;
            if (!supportNativeFilter(this, key) && !filter(id)) return;
            return handler.apply(this, args);
          },
        },
        buildStart: async buildStart() {
          await ctx.scanPages(options.watch);
        },
        buildEnd: function() {
          ctx.stopWatcher();
        },
        transform: {
          filter: {
            id: {
              include: [
                "/Users/xxxx/Documents/code/cloudcode/vue3-vite-cube/src/pages/**/*.vue",
                {
                },
              ],
              exclude: [
              ],
            },
          },
          handler: function(...args) {
            const [code, id] = args;
            if (plugin.transformInclude && !plugin.transformInclude(id)) return;
            if (!supportNativeFilter(this, key) && !filter(id, code)) return;
            return handler.apply(this, args);
          },
        },
        load: {
          filter: {
            id: {
              include: [
                {
                },
                {
                },
                {
                },
              ],
            },
          },
          handler: function(...args) {
            const [id] = args;
            if (plugin.loadInclude && !plugin.loadInclude(id)) return;
            if (!supportNativeFilter(this, key) && !filter(id)) return;
            return handler.apply(this, args);
          },
        },
        vite: {
          configureServer: function(server) {
            ctx.setServerContext(createViteContext(server));
          },
        },
        configureServer: function(server) {
          ctx.setServerContext(createViteContext(server));
        },
      },
      {
        name: "vite:vue",
        api: {
          options: {
            isProduction: false,
            compiler: null,
            customElement: {
            },
            root: "/Users/xxxx/Documents/code/cloudcode/vue3-vite-cube",
            sourceMap: true,
            cssDevSourcemap: false,
          },
          include: {
          },
          exclude: undefined,
          version: "6.0.5",
        },
        handleHotUpdate: function(ctx) {
          ctx.server.ws.send({
          type: "custom",
          event: "file-changed",
          data: { file: normalizePath(ctx.file) }
          });
          if (options.value.compiler.invalidateTypeCache) options.value.compiler.invalidateTypeCache(ctx.file);
          let typeDepModules;
          const matchesFilter = filter.value(ctx.file);
          if (typeDepToSFCMap.has(ctx.file)) {
          typeDepModules = handleTypeDepChange(typeDepToSFCMap.get(ctx.file), ctx);
          if (!matchesFilter) return typeDepModules;
          }
          if (matchesFilter) return handleHotUpdate(ctx, options.value, customElementFilter.value(ctx.file), typeDepModules);
        },
        config: function(config) {
          const parseDefine = (v) => {
          try {
          return typeof v === "string" ? JSON.parse(v) : v;
          } catch (err) {
          return v;
          }
          };
          return {
          resolve: { dedupe: config.build?.ssr ? [] : ["vue"] },
          define: {
          __VUE_OPTIONS_API__: options.value.features?.optionsAPI ?? parseDefine(config.define?.__VUE_OPTIONS_API__) ?? true,
          __VUE_PROD_DEVTOOLS__: (options.value.features?.prodDevtools || parseDefine(config.define?.__VUE_PROD_DEVTOOLS__)) ?? false,
          __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: (options.value.features?.prodHydrationMismatchDetails || parseDefine(config.define?.__VUE_PROD_HYDRATION_MISMATCH_DETAILS__)) ?? false
          },
          ssr: { external: config.legacy?.buildSsrCjsExternalHeuristics ? ["vue", "@vue/server-renderer"] : [] }
          };
        },
        configResolved: function(config) {
          options.value = {
          ...options.value,
          root: config.root,
          sourceMap: config.command === "build" ? !!config.build.sourcemap : true,
          cssDevSourcemap: config.css?.devSourcemap ?? false,
          isProduction: config.isProduction,
          devToolsEnabled: !!(options.value.features?.prodDevtools || config.define.__VUE_PROD_DEVTOOLS__ || !config.isProduction)
          };
          const _warn = config.logger.warn;
          config.logger.warn = (...args) => {
          if (args[0].match(/\[lightningcss\] '(deep|slotted|global)' is not recognized as a valid pseudo-/)) return;
          _warn(...args);
          };
          transformCachedModule = config.command === "build" && options.value.sourceMap && config.build.watch != null;
        },
        options: function() {
          optionsHookIsCalled = true;
          plugin.transform.filter = { id: {
          include: [...makeIdFiltersToMatchWithQuery(ensureArray(include.value)), /[?&]vue\b/],
          exclude: exclude.value
          } };
        },
        shouldTransformCachedModule: function({ id }) {
          if (transformCachedModule && parseVueRequest(id).query.vue) return true;
          return false;
        },
        configureServer: function(server) {
          options.value.devServer = server;
        },
        buildStart: function() {
          const compiler = options.value.compiler = options.value.compiler || resolveCompiler(options.value.root);
          if (compiler.invalidateTypeCache) options.value.devServer?.watcher.on("unlink", (file) => {
          compiler.invalidateTypeCache(file);
          });
        },
        resolveId: {
          filter: {
            id: [
              {
              },
              {
              },
            ],
          },
          handler: function(id) {
            if (id === EXPORT_HELPER_ID) return id;
            if (parseVueRequest(id).query.vue) return id;
          },
        },
        load: {
          filter: {
            id: [
              {
              },
              {
              },
            ],
          },
          handler: function(id, opt) {
            if (id === EXPORT_HELPER_ID) return helperCode;
            const ssr = opt?.ssr === true;
            const { filename, query } = parseVueRequest(id);
            if (query.vue) {
            if (query.src) return fs.readFileSync(filename, "utf-8");
            const descriptor = getDescriptor(filename, options.value);
            let block;
            if (query.type === "script") block = resolveScript(descriptor, options.value, ssr, customElementFilter.value(filename));
            else if (query.type === "template") block = descriptor.template;
            else if (query.type === "style") block = descriptor.styles[query.index];
            else if (query.index != null) block = descriptor.customBlocks[query.index];
            if (block) return {
            code: block.content,
            map: block.map
            };
            }
          },
        },
        transform: {
          handler: function(code, id, opt) {
            const ssr = opt?.ssr === true;
            const { filename, query } = parseVueRequest(id);
            if (query.raw || query.url) return;
            if (!filter.value(filename) && !query.vue) return;
            if (!query.vue) return transformMain(code, filename, options.value, this, ssr, customElementFilter.value(filename));
            else {
            const descriptor = query.src ? getSrcDescriptor(filename, query) || getTempSrcDescriptor(filename, query) : getDescriptor(filename, options.value);
            if (query.src) this.addWatchFile(filename);
            if (query.type === "template") return transformTemplateAsModule(code, filename, descriptor, options.value, this, ssr, customElementFilter.value(filename));
            else if (query.type === "style") return transformStyle(code, descriptor, Number(query.index || 0), options.value, this, filename);
            }
          },
        },
      },
      {
        name: "vite:vue-jsx",
        config: function(config) {
          const parseDefine = (v) => {
          try {
          return typeof v === "string" ? JSON.parse(v) : v;
          } catch (err) {
          return v;
          }
          };
          const isRolldownVite = this && "rolldownVersion" in this.meta;
          return {
          [isRolldownVite ? "oxc" : "esbuild"]: tsTransform === "built-in" ? { exclude: /\.jsx?$/ } : { include: /\.ts$/ },
          define: {
          __VUE_OPTIONS_API__: parseDefine(config.define?.__VUE_OPTIONS_API__) ?? true,
          __VUE_PROD_DEVTOOLS__: parseDefine(config.define?.__VUE_PROD_DEVTOOLS__) ?? false,
          __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: parseDefine(config.define?.__VUE_PROD_HYDRATION_MISMATCH_DETAILS__) ?? false
          },
          optimizeDeps: isRolldownVite ? { rolldownOptions: { transform: { jsx: "preserve" } } } : {}
          };
        },
        configResolved: function(config) {
          needHmr = config.command === "serve" && !config.isProduction;
          needSourceMap = config.command === "serve" || !!config.build.sourcemap;
          root = config.root;
        },
        resolveId: {
          filter: {
            id: {
            },
          },
          handler: function(id) {
            if (id === ssrRegisterHelperId) return id;
          },
        },
        load: {
          filter: {
            id: {
            },
          },
          handler: function(id) {
            if (id === ssrRegisterHelperId) return ssrRegisterHelperCode;
          },
        },
        transform: {
          order: undefined,
          filter: {
            id: {
              include: {
              },
              exclude: undefined,
            },
          },
          handler: async handler(code, id, opt) {
            const ssr = opt?.ssr === true;
            const [filepath] = id.split("?");
            if (filter(id) || filter(filepath)) {
            const plugins = [[jsx, babelPluginOptions], ...babelPlugins];
            if (id.endsWith(".tsx") || filepath.endsWith(".tsx")) if (tsTransform === "built-in") plugins.push([await import("@babel/plugin-syntax-typescript").then((r) => r.default), { isTSX: true }]);
            else plugins.push([await import("@babel/plugin-transform-typescript").then((r) => r.default), {
            ...tsPluginOptions,
            isTSX: true,
            allowExtensions: true
            }]);
            if (!ssr && !needHmr) plugins.push(() => {
            return { visitor: { CallExpression: { enter(_path) {
            if (isDefineComponentCall(_path.node, defineComponentName)) {
            const callee = _path.node.callee;
            callee.name = `/* @__PURE__ */ ${callee.name}`;
            }
            } } } };
            });
            else plugins.push(() => {
            return { visitor: { ExportDefaultDeclaration: { enter(_path) {
            const unwrappedDeclaration = unwrapTypeAssertion(_path.node.declaration);
            if (isDefineComponentCall(unwrappedDeclaration, defineComponentName)) {
            const declaration = unwrappedDeclaration;
            const nodesPath = _path.replaceWithMultiple([types.variableDeclaration("const", [types.variableDeclarator(types.identifier("__default__"), types.callExpression(declaration.callee, declaration.arguments))]), types.exportDefaultDeclaration(types.identifier("__default__"))]);
            _path.scope.registerDeclaration(nodesPath[0]);
            }
            } } } };
            });
            const result = babel.transformSync(code, {
            babelrc: false,
            ast: true,
            plugins,
            sourceMaps: needSourceMap,
            sourceFileName: id,
            configFile: false
            });
            if (!ssr && !needHmr) {
            if (!result.code) return;
            return {
            code: result.code,
            map: result.map
            };
            }
            const declaredComponents = [];
            const hotComponents = [];
            for (const node of result.ast.program.body) {
            if (node.type === "VariableDeclaration") {
            const names = parseComponentDecls(node, defineComponentName);
            if (names.length) declaredComponents.push(...names);
            }
            if (node.type === "ExportNamedDeclaration") {
            if (node.declaration && node.declaration.type === "VariableDeclaration") hotComponents.push(...parseComponentDecls(node.declaration, defineComponentName).map((name) => ({
            local: name,
            exported: name,
            id: getHash(id + name)
            })));
            else if (node.specifiers.length) {
            for (const spec of node.specifiers) if (spec.type === "ExportSpecifier" && spec.exported.type === "Identifier") {
            if (declaredComponents.find((name) => name === spec.local.name)) hotComponents.push({
            local: spec.local.name,
            exported: spec.exported.name,
            id: getHash(id + spec.exported.name)
            });
            }
            }
            }
            if (node.type === "ExportDefaultDeclaration") {
            if (node.declaration.type === "Identifier") {
            const _name = node.declaration.name;
            if (declaredComponents.find((name) => name === _name)) hotComponents.push({
            local: _name,
            exported: "default",
            id: getHash(id + "default")
            });
            } else if (isDefineComponentCall(unwrapTypeAssertion(node.declaration), defineComponentName)) hotComponents.push({
            local: "__default__",
            exported: "default",
            id: getHash(id + "default")
            });
            }
            }
            if (hotComponents.length) {
            if (needHmr && !ssr && !/\?vue&type=script/.test(id)) {
            let code = result.code;
            let callbackCode = ``;
            for (const { local, exported, id } of hotComponents) {
            code += `\n${local}.__hmrId = "${id}"\n__VUE_HMR_RUNTIME__.createRecord("${id}", ${local})`;
            callbackCode += `\n__VUE_HMR_RUNTIME__.reload("${id}", __${exported})`;
            }
            const newCompNames = hotComponents.map((c) => `${c.exported}: __${c.exported}`).join(",");
            code += `\nimport.meta.hot.accept(({${newCompNames}}) => {${callbackCode}\n})`;
            result.code = code;
            }
            if (ssr) {
            const normalizedId = normalizePath(path.relative(root, id));
            let ssrInjectCode = `\nimport { ssrRegisterHelper } from "${ssrRegisterHelperId}"\nconst __moduleId = ${JSON.stringify(normalizedId)}`;
            for (const { local } of hotComponents) ssrInjectCode += `\nssrRegisterHelper(${local}, __moduleId)`;
            result.code += ssrInjectCode;
            }
            }
            if (!result.code) return;
            return {
            code: result.code,
            map: result.map
            };
            }
          },
        },
      },
    ],
    resolve: {
      alias: {
        "@": "/src",
      },
    },
    css: {
      preprocessorOptions: {
        less: {
          additionalData: "@import \"@/styles/variables.less\";",
          javascriptEnabled: true,
        },
      },
    },
    mode: "development",
  },
}

导出的内容就是 vite.config.js 中配置信息

image.png

ModuleRunner模块运行器

  public async import<T = any>(url: string): Promise<T> {
    // 获取缓存模块
    const fetchedModule = await this.cachedModule(url)
    // 执行模块请求
    return await this.cachedRequest(url, fetchedModule)
  }

image.png

native (实验性)

native 模式直接通过 Node.js 原生的动态 import() 加载配置文件,不经过任何打包步骤。

只能编写纯 JavaScript,可以指定 --configLoader native 来使用环境的原生运行时加载配置文件。

{
   "start": "vite --configLoader=native",
}
  • 它的优点是简单快速,调试时断点可以直接定位到源码,不受临时文件干扰。
  • 但这种模式有一个重要限制:配置文件导入的模块的更新不会被检测到,因此不会自动重启 Vite 服务器
async function nativeImportConfigFile(resolvedPath: string) {
  const module = await import(
    pathToFileURL(resolvedPath).href + '?t=' + Date.now()
  )
  return {
    configExport: module.default,
    dependencies: [],
  }
}

在 native 模式下,由于没有经过打包工具分析依赖,Vite 无法知道配置文件引入了哪些本地模块。因此依赖列表被硬编码为空数组,意味着当配置文件导入的其他本地文件(如 ./utils.js)发生变化时,Vite 不会自动重启服务器。这是 native 模式的重要限制。

三者的区别

image.png

从零到一:在React前端中集成The Graph查询Uniswap V3池数据实战

从零到一:在React前端中集成The Graph查询Uniswap V3池数据实战

背景

上个月,我接了一个DeFi策略分析面板的前端开发需求。其中一个核心功能是展示Uniswap V3上特定交易对(比如ETH/USDC)的流动性池详情,包括当前价格、流动性总量、手续费率等。我的第一反应是直接用 ethers.jsviem 去读取对应的智能合约。这确实能行,我写了几个 readContract 调用,数据也拿到了。

但问题很快来了。当我想展示这个池子最近24小时的交易量变化,或者想列出这个池子所有大的流动性提供者(LP)时,直接查合约就变得非常笨重和低效。我需要遍历大量历史事件,这在浏览器端几乎不可能实现,而且会消耗天量的RPC请求。项目需要一个既能查询实时状态又能高效检索历史事件的解决方案。这时,我想到了 The Graph——一个专门用于索引和查询区块链数据的去中心化协议。理论上,我可以通过它订阅一个已经索引好的Uniswap V3子图,用GraphQL轻松拿到所有结构化数据。

问题分析

一开始,我以为集成The Graph会很简单:找个现成的Uniswap V3子图,用 fetchaxios 发个GraphQL请求不就完了?但上手后发现,事情没那么直白。

首先,我找到了Uniswap官方在The Graph托管服务上部署的V3子图。但我直接用自己的前端项目去请求它的公开端点时,遇到了CORS(跨域资源共享)错误。浏览器的安全策略阻止了我的本地开发服务器向 https://api.thegraph.com 发起请求。这是第一个拦路虎。

其次,即使CORS问题解决了,GraphQL查询的编写也让我有点懵。子图暴露的数据模式(Schema)和我直接从合约里读到的原始数据格式不一样,它是被索引和加工过的实体(Entities)。我需要搞清楚有哪些实体可用,以及它们之间的关联关系。

最后,我希望能有一个类型安全的开发体验。GraphQL查询返回的 any 类型在TypeScript项目里用着心里发虚,后期维护也容易出错。我需要一种方法能为查询结果生成明确的TypeScript接口。

最初的“简单fetch方案”显然走不通,我需要一个更正式、更健壮的前端集成方案。

核心实现

1. 选择客户端与绕过CORS

直接调用The Graph的公共HTTPS端点遇到CORS限制,这是前端开发中常见的问题。The Graph的托管服务默认可能没有配置允许所有来源。解决这个问题有几种思路:配置自己的代理服务器,或者使用支持自定义端点的Graph客户端库。

我选择了 Apollo Client。它是一个功能强大的GraphQL客户端,不仅帮我管理请求状态、缓存,更重要的是,它通常用于服务端渲染(SSR)或静态生成(SSG)场景,在这些场景中,请求发自Node.js环境而非浏览器,从而天然避开了CORS问题。对于我的纯前端项目,我可以先通过配置一个简单的本地开发代理来解决CORS问题,未来部署时可以考虑使用无服务器函数作为代理。

首先,我安装了必要的依赖:

npm install @apollo/client graphql

然后,我创建了Apollo Client的实例,指向Uniswap V3在以太坊主网的子图端点。

// src/lib/apolloClient.ts
import { ApolloClient, InMemoryCache, HttpLink, from } from '@apollo/client';
import { onError } from '@apollo/client/link/error';

// 注意:在浏览器中直接使用此端点会因CORS失败
// 在开发环境中,我们需要配置代理或使用其他方法
const GRAPHQL_ENDPOINT = 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3';

const httpLink = new HttpLink({
  uri: GRAPHQL_ENDPOINT,
});

const errorLink = onError(({ graphQLErrors, networkError }) => {
  if (graphQLErrors)
    graphQLErrors.forEach(({ message, locations, path }) =>
      console.error(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`)
    );
  if (networkError) console.error(`[Network error]: ${networkError}`);
});

export const apolloClient = new ApolloClient({
  link: from([errorLink, httpLink]),
  cache: new InMemoryCache(),
});

这里有个坑:在本地开发时,如果你在浏览器控制台看到CORS错误,一个快速的解决方案是在 vite.config.tswebpack.config.js 中配置开发服务器代理,将 /subgraph 路径的请求转发到The Graph API。

// vite.config.ts 示例
export default defineConfig({
  // ... 其他配置
  server: {
    proxy: {
      '/subgraph-api': {
        target: 'https://api.thegraph.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/subgraph-api/, ''),
      },
    },
  },
});

然后,将 GRAPHQL_ENDPOINT 改为 ‘/subgraph-api/subgraphs/name/uniswap/uniswap-v3’。这样,浏览器请求的是同源地址,由开发服务器代为转发,就绕过了CORS。

2. 编写并执行GraphQL查询

接下来,我需要编写正确的GraphQL查询。我先去The Graph的Explorer查看了 uniswap-v3 子图的Schema。我找到了几个关键实体:Pool(流动性池)、Token(代币)、Swap(兑换事件)等。

我的目标是查询一个特定池子的基本信息。我知道Uniswap V3池子的合约地址是由两个代币地址和手续费层级(feeTier)共同决定的。但更方便的是,子图已经为每个池子生成了一个唯一的ID,通常是合约地址。所以,我可以直接用池子合约地址来查询。

我在项目中创建了一个GraphQL查询文件:

# src/graphql/queries/poolInfo.graphql
query GetPoolInfo($poolId: ID!) {
  pool(id: $poolId) {
    id
    token0 {
      id
      symbol
      name
      decimals
    }
    token1 {
      id
      symbol
      name
      decimals
    }
    feeTier
    liquidity
    sqrtPrice
    tick
    volumeUSD
    txCount
    # 当前价格,需要根据sqrtPrice和代币精度计算
    # 这里我们先取出来原始数据,在前端转换
  }
}

然后,在React组件中,我使用 @apollo/clientuseQuery hook来执行这个查询。我选择了一个知名的ETH/USDC 0.3%费率的池子地址作为示例。

// src/components/PoolInfo.tsx
import { useQuery, gql } from '@apollo/client';
import React from 'react';

// 使用gql标签定义查询
const GET_POOL_INFO = gql`
  query GetPoolInfo($poolId: ID!) {
    pool(id: $poolId) {
      id
      token0 {
        id
        symbol
        name
        decimals
      }
      token1 {
        id
        symbol
        name
        decimals
      }
      feeTier
      liquidity
      sqrtPrice
      tick
      volumeUSD
      txCount
    }
  }
`;

// 一个已知的ETH/USDC 0.3%池地址
const SAMPLE_POOL_ID = '0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8';

export const PoolInfo: React.FC = () => {
  const { loading, error, data } = useQuery(GET_POOL_INFO, {
    variables: { poolId: SAMPLE_POOL_ID },
  });

  if (loading) return <p>Loading pool data from The Graph...</p>;
  if (error) return <p>Error :( {error.message}</p>;

  const pool = data.pool;
  // 计算当前价格:价格 = (sqrtPrice^2) / 2^(192) * (10^decimals1 / 10^decimals0)
  // 简化处理:这里只展示一个概念
  const token0Decimals = pool.token0.decimals;
  const token1Decimals = pool.token1.decimals;

  return (
    <div>
      <h2>Pool: {pool.token0.symbol} / {pool.token1.symbol}</h2>
      <p>Fee Tier: {pool.feeTier / 10000}%</p>
      <p>Liquidity: {parseFloat(pool.liquidity).toLocaleString()}</p>
      <p>Volume (USD): ${parseFloat(pool.volumeUSD).toLocaleString(undefined, { maximumFractionDigits: 2 })}</p>
      <p>Transaction Count: {pool.txCount}</p>
      <p>Pool Contract: <code>{pool.id}</code></p>
    </div>
  );
};

注意这个细节sqrtPricetick 是Uniswap V3用于表示价格的核心变量。前端需要根据公式将它们转换为人类可读的价格。上面的计算只是示意,实际项目中需要实现精确的转换函数。

3. 实现类型安全(Codegen)

手动为GraphQL查询结果定义TypeScript接口非常繁琐且容易出错。我决定使用 GraphQL Code Generator 来自动完成这项工作。

首先,安装必要的开发依赖:

npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations

然后,创建配置文件 codegen.yml

# codegen.yml
overwrite: true
schema: 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3'
documents: 'src/graphql/**/*.graphql'
generates:
  src/generated/graphql.ts:
    plugins:
      - 'typescript'
      - 'typescript-operations'
    config:
      skipTypename: false
      withHooks: true # 如果使用React,可以生成对应的hooks

package.json 中添加一个脚本:

"scripts": {
  "codegen": "graphql-codegen --config codegen.yml",
  "codegen:watch": "graphql-codegen --config codegen.yml --watch"
}

运行 npm run codegen 后,会在 src/generated/graphql.ts 中自动生成所有类型定义和可能的React Hooks。现在,我可以以完全类型安全的方式重写我的查询:

// src/components/PoolInfoTyped.tsx
import React from 'react';
import { useGetPoolInfoQuery } from '../generated/graphql'; // 自动生成的Hook
import { apolloClient } from '../lib/apolloClient';
import { ApolloProvider } from '@apollo/client';

const SAMPLE_POOL_ID = '0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8';

const PoolInfoInner: React.FC = () => {
  // 现在,`data`、`variables` 的类型都是自动推断的!
  const { loading, error, data } = useGetPoolInfoQuery({
    variables: { poolId: SAMPLE_POOL_ID },
  });

  if (loading) return <p>Loading (with types)...</p>;
  if (error) return <p>Error (with types): {error.message}</p>;
  // TypeScript知道`data.pool`可能为null,因为GraphQL查询可能返回空
  if (!data || !data.pool) return <p>No pool found.</p>;

  const pool = data.pool;
  return (
    <div>
      <h2>Pool: {pool.token0.symbol} / {pool.token1.symbol}</h2>
      <p>Pool ID: <code>{pool.id}</code></p>
      {/* 访问其他属性都有完整的类型提示 */}
    </div>
  );
};

// 需要在外层提供Apollo Client
export const PoolInfoTyped: React.FC = () => (
  <ApolloProvider client={apolloClient}>
    <PoolInfoInner />
  </ApolloProvider>
);

通过Codegen,我获得了完美的开发体验:自动补全、类型检查、以及查询字段变更时的编译时报错,大大提升了代码的可靠性和开发效率。

4. 处理分页与复杂查询

基础信息查询搞定后,我需要实现更复杂的功能,比如列出该池子最近的Swap事件。这类列表查询通常涉及分页。The Graph的子图查询支持经典的 firstskipwhere 过滤参数。

我编写了一个分页查询Swap事件的GraphQL:

# src/graphql/queries/poolSwaps.graphql
query GetPoolSwaps($poolId: ID!, $first: Int!, $skip: Int!) {
  swaps(
    where: { pool: $poolId }
    orderBy: timestamp
    orderDirection: desc
    first: $first
    skip: $skip
  ) {
    id
    timestamp
    transaction {
      id
    }
    sender
    recipient
    amount0
    amount1
    amountUSD
  }
}

在React组件中,我可以结合 useQuery 和分页状态(如当前页码)来动态获取数据。对于无限滚动或加载更多,Apollo Client的 fetchMore 函数非常好用。

// 使用 useQuery 的 fetchMore 示例片段
const { data, loading, fetchMore } = useGetPoolSwapsQuery({
  variables: {
    poolId: SAMPLE_POOL_ID,
    first: 10,
    skip: 0,
  },
});

const loadMore = () => {
  fetchMore({
    variables: {
      skip: data?.swaps.length || 0,
    },
    // 更新查询结果的方式
    updateQuery: (prev, { fetchMoreResult }) => {
      if (!fetchMoreResult) return prev;
      return {
        swaps: [...prev.swaps, ...fetchMoreResult.swaps],
      };
    },
  });
};

完整代码示例

以下是一个简化但可运行的React组件示例,集成了上述所有关键点(假设已配置代理解决CORS,并已运行Codegen生成类型)。

// src/App.tsx
import React from 'react';
import { ApolloProvider } from '@apollo/client';
import { apolloClient } from './lib/apolloClient';
import { PoolDashboard } from './components/PoolDashboard';

function App() {
  return (
    <ApolloProvider client={apolloClient}>
      <div className="App">
        <h1>Uniswap V3 Pool Dashboard (Powered by The Graph)</h1>
        <PoolDashboard />
      </div>
    </ApolloProvider>
  );
}

export default App;
// src/components/PoolDashboard.tsx
import React, { useState } from 'react';
import { useGetPoolInfoQuery, useGetPoolSwapsQuery } from '../generated/graphql';

const ETH_USDC_POOL = '0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8';
const PAGE_SIZE = 5;

export const PoolDashboard: React.FC = () => {
  // 查询池子基本信息
  const { data: poolData, loading: poolLoading, error: poolError } = useGetPoolInfoQuery({
    variables: { poolId: ETH_USDC_POOL },
  });

  // 查询Swap事件,带分页
  const [swapsSkip, setSwapsSkip] = useState(0);
  const {
    data: swapsData,
    loading: swapsLoading,
    error: swapsError,
    fetchMore,
  } = useGetPoolSwapsQuery({
    variables: {
      poolId: ETH_USDC_POOL,
      first: PAGE_SIZE,
      skip: swapsSkip,
    },
  });

  const handleLoadMore = () => {
    const currentLength = swapsData?.swaps.length || 0;
    fetchMore({
      variables: { skip: currentLength },
      updateQuery: (prev, { fetchMoreResult }) => {
        if (!fetchMoreResult) return prev;
        return {
          swaps: [...prev.swaps, ...fetchMoreResult.swaps],
        };
      },
    }).then(() => {
      setSwapsSkip(currentLength);
    });
  };

  if (poolLoading) return <div>Loading pool info...</div>;
  if (poolError) return <div>Error loading pool: {poolError.message}</div>;
  if (!poolData?.pool) return <div>Pool not found.</div>;

  const pool = poolData.pool;

  return (
    <div style={{ padding: '20px' }}>
      <section>
        <h2>
          {pool.token0.symbol} / {pool.token1.symbol} Pool (Fee: {pool.feeTier / 10000}%)
        </h2>
        <p>
          <strong>Liquidity:</strong> {parseInt(pool.liquidity).toLocaleString()}
        </p>
        <p>
          <strong>24h Volume USD:</strong> $
          {parseFloat(pool.volumeUSD).toLocaleString(undefined, {
            maximumFractionDigits: 0,
          })}
        </p>
      </section>

      <section style={{ marginTop: '40px' }}>
        <h3>Recent Swaps</h3>
        {swapsError && <p>Error loading swaps: {swapsError.message}</p>}
        {swapsLoading && <p>Loading swaps...</p>}
        <ul>
          {swapsData?.swaps.map((swap) => (
            <li key={swap.id} style={{ marginBottom: '10px', borderBottom: '1px solid #eee', paddingBottom: '5px' }}>
              <div>Tx: {swap.transaction.id.slice(0, 10)}...</div>
              <div>
                Amounts: {parseFloat(swap.amount0).toFixed(4)} {pool.token0.symbol} /{' '}
                {parseFloat(swap.amount1).toFixed(4)} {pool.token1.symbol}
              </div>
              <div>Value: ${parseFloat(swap.amountUSD).toFixed(2)}</div>
              <div>Time: {new Date(parseInt(swap.timestamp) * 1000).toLocaleString()}</div>
            </li>
          ))}
        </ul>
        <button onClick={handleLoadMore} disabled={swapsLoading}>
          {swapsLoading ? 'Loading...' : 'Load More Swaps'}
        </button>
      </section>
    </div>
  );
};

踩坑记录

  1. CORS错误:如前所述,在浏览器中直接调用The Graph托管服务API会遇到CORS。解决方法:在开发环境配置本地代理(如Vite的 server.proxy),在生产环境可以考虑使用Cloudflare Worker、AWS Lambda等无服务器函数作为代理,或者寻找支持CORS的公共网关(有些社区提供)。
  2. 查询返回 null:我传入一个正确的合约地址,但 pool 查询返回 null原因:子图索引的ID可能不是合约地址本身,而是小写格式。另外,有些池子可能因为索引延迟或尚未被索引而不存在。解决方法:确保ID格式正确(全小写),并检查子图是否已经同步到最新区块。可以在The Graph Explorer中先用相同ID测试查询。
  3. 类型生成失败:运行 graphql-codegen 时失败,报错“无法获取schema”。原因:网络问题或端点URL错误。解决方法:检查 codegen.yml 中的 schema URL是否正确且可访问。有时需要科学上网。也可以先将schema下载到本地文件,然后指向本地文件路径。
  4. 分页性能与 skip 限制:使用 skip 参数进行深度分页(例如 skip: 10000)在The Graph上可能非常慢甚至超时,因为底层数据库查询效率问题。解决方法:尽量避免大数值的 skip。推荐使用基于游标(cursor)的分页,即使用 where: { id_gt: $lastId }orderBy: id。但需要注意的是,这要求子图的Schema设计支持这种模式,并非所有查询都适用。

小结

这次实战让我彻底打通了从前端到链上索引数据的管道。The Graph + Apollo Client + GraphQL Codegen 的组合,为Web3前端提供了一套类型安全、高效且强大的数据查询方案。下一步,我计划深入研究子图的定义和部署,为自己项目的合约定制专属索引,从而解锁更复杂的数据展示和分析功能。

JavaScript设计模式(九):工厂模式实现与应用

在 JavaScript 中,有一个 new 操作符,用于创建对象,经常会写各种“创建对象”的代码,比如:

  • 创建不同类型的消息提示组件,比如 SuccessMessageErrorMessageWarningMessage
  • 创建不同类型的表单项,比如 InputSelectTreeSelectDatePicker
  • 创建不同平台的上传实例,比如本地上传、阿里云上传、七牛云上传。
  • 创建不同环境下的请求实例,比如开发环境的 mock 请求实例、测试环境实例、生产环境实例。

刚开始这些创建逻辑看起来都不复杂,直接 new 一下就完事了。

但项目一旦复杂起来,你很快就会发现一个问题:对象的创建逻辑也会越来越乱

比如你有 successwarningerror 三种消息提示,一开始可能只是在某个地方 new SuccessMessage(),后面随着项目变大,很多地方都开始根据 type 去写 if-elseswitch 来创建对象。再往后如果还要加默认配置、埋点、主题、平台差异,那这些“创建逻辑”就会慢慢变成一团。

这种场景,就很适合用 工厂模式

1、工厂模式定义

工厂模式的核心思想就是:把对象的创建过程封装起来,对外只暴露一个统一的创建入口。

通俗点来讲就是:

  • 你告诉工厂“我要什么对象”。
  • 工厂负责决定“具体该怎么创建”。
  • 外部不用关心里面到底是 new 了哪个类,也不用关心初始化细节。

它的重点不在“对象本身”,而在“把创建对象这件事集中管理起来”。

2、核心思想

  1. 创建逻辑收敛:不要让对象创建逻辑散落在业务代码各处。
  2. 使用者和具体类解耦:使用者只关心“我要什么”,不关心“它具体是谁创建出来的”。
  3. 方便扩展:后面新增一种对象类型时,通常只需要改工厂,而不需要满项目去找 new

3、例子:创建不同类型的消息提示组件

在前端项目里,消息提示组件特别常见,比如:

  • 成功提示 success
  • 失败提示 error
  • 警告提示 warning

看起来只是几个不同样式的小组件,但如果这些对象的创建逻辑散落在业务代码里,后面就很难统一管理。

3.1 不用工厂模式(到处自己 new)

不用工厂模式的代码,大概率会先这么写:

class SuccessMessage {
  constructor(text) {
    this.text = text;
    this.type = 'success';
  }

  show() {
    console.log(`[success]: ${this.text}`);
  }
}

class ErrorMessage {
  constructor(text) {
    this.text = text;
    this.type = 'error';
  }

  show() {
    console.log(`[error]: ${this.text}`);
  }
}

class WarningMessage {
  constructor(text) {
    this.text = text;
    this.type = 'warning';
  }

  show() {
    console.log(`[warning]: ${this.text}`);
  }
}

saveBtn.onclick = () => {
  const message = new SuccessMessage('保存成功');
  message.show();
};

submitBtn.onclick = async () => {
  if (!validateForm()) {
    const message = new WarningMessage('表单校验未通过');
    message.show();
    return;
  }

  try {
    const res = await submitForm();

    if (res.code === 0) {
      const message = new SuccessMessage('提交成功');
      message.show();
    } else {
      const message = new ErrorMessage(res.message || '提交失败');
      message.show();
    }
  } catch (error) {
    const message = new ErrorMessage('网络异常,请稍后重试');
    message.show();
  }
};

uploadBtn.onclick = () => {
  const message = new WarningMessage('文件体积过大');
  message.show();
};

这种写法虽然能跑,但存在两个问题:

  1. 创建逻辑散落在业务代码各处:保存、提交、上传这些地方都在自己 new 对象。
  2. 调用方直接依赖具体类名:业务代码必须知道 SuccessMessageErrorMessageWarningMessage 这些实现细节。
  3. 后面不方便统一改规则:如果消息对象都要增加默认图标、默认时长、主题配置,就得一处处去改。也就是说,如果 new SuccessMessage 需要传一些默认参数,比如 new SuccessMessage('保存成功', { icon: 'xxx.png', duration: 2000, theme: 'dark' }),那么所有 SuccessMessage 都需要改,维护起来就比较麻烦。

3.2 使用工厂模式

更合理一点的做法是,把“创建消息对象”这件事单独收敛到工厂里。

先定义不同类型的消息对象:

class SuccessMessage {
  constructor(text) {
    this.text = text;
    this.type = 'success';
  }

  show() {
    console.log(`[success]: ${this.text}`);
  }
}

class ErrorMessage {
  constructor(text) {
    this.text = text;
    this.type = 'error';
  }

  show() {
    console.log(`[error]: ${this.text}`);
  }
}

class WarningMessage {
  constructor(text) {
    this.text = text;
    this.type = 'warning';
  }

  show() {
    console.log(`[warning]: ${this.text}`);
  }
}

然后定义一个统一的工厂:

class MessageFactory {
  static create(type, text) {
    if (type === 'success') {
      return new SuccessMessage(text);
    }

    if (type === 'error') {
      return new ErrorMessage(text);
    }

    if (type === 'warning') {
      return new WarningMessage(text);
    }

    throw new Error(`Unknown message type: ${type}`);
  }
}

业务代码里就不需要再自己 new 具体类了:

const message1 = MessageFactory.create('success', '保存成功');
message1.show();

const message2 = MessageFactory.create('error', '保存失败');
message2.show();

这样改造之后,代码的职责就清楚很多了:

  • SuccessMessageErrorMessageWarningMessage 只负责各自的展示逻辑。
  • MessageFactory 只负责统一创建对象。
  • 业务代码只负责“使用对象”,而不用关心对象是怎么来的。

这就是工厂模式最核心的价值:把“对象怎么创建”从“对象怎么使用”里拆出来。

3.3 工厂模式真正解决的是什么?

很多同学第一次看工厂模式,会觉得它只是“帮你少写几个 new”。

其实不是。

工厂模式真正解决的是:把对象创建逻辑收敛,让变化尽量集中发生。

比如后面需求变了:

  • success 消息要自动带一个绿色图标。
  • error 消息要统一上报错误日志。
  • 所有消息实例都要带默认展示时长。

如果没有工厂,这些创建细节可能散落在很多地方;但如果有工厂,你只需要改工厂或者改具体产品类,外部调用方式基本不用动。

也就是说,工厂模式的价值从来都不只是“简化创建”,更重要的是“统一管理创建逻辑”。

4、JavaScript 里怎么理解工厂模式

JavaScript 里,工厂模式不一定非得写成 class 形式。

很多时候,我们更常见的是“工厂函数”:

function createMessage(type, text) {
  if (type === 'success') {
    return {
      type,
      text,
      show() {
        console.log(`[success]: ${text}`);
      }
    };
  }

  if (type === 'error') {
    return {
      type,
      text,
      show() {
        console.log(`[error]: ${text}`);
      }
    };
  }

  throw new Error(`Unknown message type: ${type}`);
}

const message = createMessage('success', '操作成功');
message.show();

这种写法本质上也是工厂模式,因为你依然是在做同一件事:

  • 对外提供统一创建入口。
  • 内部封装对象创建细节。
  • 外部只拿结果,不关心内部过程。

所以在 JavaScript 里理解工厂模式,不要把注意力只放在“是不是 class”,更重要的是看:你有没有把对象创建逻辑集中封装起来。

顺手说一个大家其实天天都在用的例子:

const div = document.createElement('div');
const span = document.createElement('span');

document.createElement() 本质上就是一种很典型的工厂思想:

  • 你只告诉它“我要什么标签”。
  • 浏览器负责创建对应的 DOM 对象。
  • 你并不需要自己 new HTMLDivElement()

5、工厂模式和 new / 构造函数的区别

很多同学学到这里,还会有一个很自然的问题:工厂模式和直接 new 有什么区别?

区别就在于关注点不一样:

  • 直接 new:你必须知道具体要实例化哪个类。
  • 工厂模式:你只需要表达“我要什么对象”,至于具体实例化哪个类,交给工厂决定。

比如下面这两种写法:

const message1 = new SuccessMessage('保存成功');
const message2 = MessageFactory.create('success', '保存成功');

看起来都能拿到对象,但它们的含义不一样:

  • new SuccessMessage() 说明调用方知道具体类名,也直接依赖这个类。
  • MessageFactory.create('success') 说明调用方只表达需求,不直接依赖具体实现类。

所以工厂模式不是为了替代 new,而是为了在合适的场景下,把 new 隐藏到更合适的地方去

6、工厂模式和策略模式的区别

工厂模式和策略模式都很容易出现 type 判断,所以很多同学会把这两个模式搞混。

但它们解决的问题并不一样:

  • 工厂模式:更关注“创建什么对象”。
  • 策略模式:更关注“选择哪种算法或行为”。

你可以简单理解为:

  • 工厂模式是在回答:“这次我要造谁?”
  • 策略模式是在回答:“这次我要怎么做?”

举个很直观的例子:

  • 根据 type 创建 SuccessMessageErrorMessageWarningMessage,这是工厂模式
  • 根据 type 选择不同支付逻辑、校验逻辑、请求策略,这是策略模式

所以一个偏“创建”,一个偏“行为选择”。

7、抽象工厂是什么?

前面讲的这种写法,本质上更接近我们平时最常说的简单工厂思路:给你一个统一入口,然后根据条件创建某一个对象。

但工厂模式往上再走一步,其实还有一个很经典的概念,叫做抽象工厂

抽象工厂你可以先不用把它想得太玄乎,它本质上就是:

不只是创建一个对象,而是创建一整组彼此有关联的对象。

如果说前面的 MessageFactory 更像是在回答:

  • “这次我要创建哪一种消息组件?”

那抽象工厂更像是在回答:

  • “这次我要创建哪一整套组件?”

比如在前端项目里,这种场景就特别常见:

  • 你有一套 PC 端组件。
  • 你有一套 Mobile 端组件。
  • 每一套里都包含 ButtonDialogInput 等一组相关组件。

这时候你要解决的,就不是“创建一个按钮”这么简单了,而是“创建一整套同风格、同平台、彼此配套的组件族”。

7.1 一个前端里很常见的例子

假设我们现在有两套 UI 体系:

  • PC 端组件
  • Mobile 端组件

每套体系里,都有两个相关组件:

  • Button
  • Dialog

JavaScript 里虽然没有 abstract class 这种语法,但我们完全可以用普通类来表达“抽象层”,只是在那些不希望被直接使用的方法里,手动 throw new Error

你可以把这种写法理解成:JavaScript 的普通类,去模拟抽象类的约束能力。

可以把它写成这样:

class AbstractButton {
  render() {
    throw new Error('render 方法必须由子类实现');
  }
}

class AbstractDialog {
  render() {
    throw new Error('render 方法必须由子类实现');
  }
}

class AbstractUIFactory {
  createButton() {
    throw new Error('createButton 方法必须由子类实现');
  }

  createDialog() {
    throw new Error('createDialog 方法必须由子类实现');
  }
}

class PcButton extends AbstractButton {
  render() {
    console.log('渲染 PC 按钮');
  }
}

class PcDialog extends AbstractDialog {
  render() {
    console.log('渲染 PC 弹窗');
  }
}

class MobileButton extends AbstractButton {
  render() {
    console.log('渲染 Mobile 按钮');
  }
}

class MobileDialog extends AbstractDialog {
  render() {
    console.log('渲染 Mobile 弹窗');
  }
}

class PcUIFactory extends AbstractUIFactory {
  createButton() {
    return new PcButton();
  }

  createDialog() {
    return new PcDialog();
  }
}

class MobileUIFactory extends AbstractUIFactory {
  createButton() {
    return new MobileButton();
  }

  createDialog() {
    return new MobileDialog();
  }
}

这里的关系其实就很清楚了:

  • AbstractButtonAbstractDialog 是抽象产品。
  • AbstractUIFactory 是抽象工厂。
  • PcUIFactoryMobileUIFactory 是具体工厂。
  • PcButtonPcDialogMobileButtonMobileDialog 是具体产品。

业务代码里只需要选择当前要用哪一套工厂:

function createPage(factory) {
  const button = factory.createButton();
  const dialog = factory.createDialog();

  button.render();
  dialog.render();
}

createPage(new PcUIFactory());
createPage(new MobileUIFactory());

这样做的好处很明显:

  • 你切换的是“一整套产品族”,而不是一个个零散产品。
  • PC 按钮一定搭配 PC 弹窗。
  • Mobile 按钮一定搭配 Mobile 弹窗。
  • 外部不用自己关心这一套里每个对象该怎么创建。

这就是抽象工厂模式最核心的点:它不是在创建单个对象,而是在创建一组相互关联、相互匹配的对象。

7.2 抽象工厂和普通工厂的区别

很多同学第一次学到这里,会觉得:这不还是工厂吗?

是的,它当然还是工厂思想,但它解决的问题比普通工厂更大一层。

你可以这样理解:

  • 普通工厂 / 简单工厂:更像是在创建“一个产品”。
  • 抽象工厂:更像是在创建“一整个产品族”。

比如:

  • 创建一个 success message,这是普通工厂更擅长的事。
  • 创建一整套 PC UIMobile UI 组件,这是抽象工厂更擅长的事。

所以它们的区别,不是“谁更高级”,而是抽象层级不一样

7.3 抽象工厂适合什么场景?

抽象工厂通常适合下面这类场景:

  1. 系统里存在多套彼此对应的产品族。
  2. 这些产品之间有明显的配套关系,不能随便混用。
  3. 你希望切换的是一整套规则,而不是某一个单独对象。

像前端里这些场景,其实都挺适合:

  • PC 端和 Mobile 端组件体系。
  • 浅色主题和深色主题组件体系。
  • 国内环境和海外环境下的一整套服务实例。

所以如果说前面的工厂模式解决的是“对象怎么统一创建”,那抽象工厂解决的就是“一整套对象怎么统一创建”。

8、工厂模式的优缺点

8.1 优点:

  • 解耦性强:使用者不需要依赖具体类名。
  • 创建逻辑集中管理:对象创建规则可以统一收敛。
  • 便于扩展:新增一种对象类型时,通常不需要满项目改调用代码。
  • 代码更清晰:业务层更专注“使用对象”,而不是“创建对象”。

8.2 缺点:

  • 会增加一层抽象:相比直接 new,工厂模式会多一层封装。
  • 简单场景可能显得有点重:如果对象创建本来就非常简单,硬加工厂未必划算。
  • 工厂本身也可能变复杂:如果所有创建逻辑都堆在一个大工厂里,工厂类本身也会越来越臃肿。

9、工厂模式的应用

工厂模式在前端和日常业务开发里其实非常常见,比如:

  1. 根据不同 type 创建不同类型的消息提示组件、弹窗组件。
  2. 根据配置动态创建表单项,比如 InputSelectTreeSelectDatePicker
  3. 根据运行环境创建不同请求实例,比如开发环境的 mock 请求实例、测试环境实例、生产环境实例。
  4. 根据平台创建不同上传器实例,比如本地上传、阿里云上传、七牛云上传。
  5. 浏览器里的 document.createElement(),本质上也带有非常典型的工厂思想。

小结

上面介绍了Javascript中非常经典的工厂模式,它的核心思想就是:把对象创建逻辑封装起来,对外提供统一的创建入口,从而让使用者和具体实现解耦。

对于前端开发来说,工厂模式非常实用,像消息组件创建、表单项创建、上传器创建、请求实例创建这些场景里,都能看到它的影子。它本质上就是帮我们把“怎么创建对象”从“怎么使用对象”里拆开,这样代码会更清晰,后面扩展起来也更从容。

如果系统里不仅仅是创建“一个对象”,而是要创建“一整套相互配套的对象”,那就可以继续往抽象工厂的方向去设计。你可以简单把它理解成:普通工厂负责造单品,抽象工厂负责造整套。

往期回顾

Node.js 从入门到上线实战指南(零基础 → 高手)

覆盖:Node 基础、ES6 模块化、npm、Express、MongoDB、项目实战、部署上线、HTTPS 证书配置


一、什么是 Node.js?

Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境,让 JS 能运行在服务器端。

核心特点

  • 单线程 + 事件循环(Event Loop)
  • 非阻塞 I/O(高并发)
  • npm 生态强大

二、环境准备

1. 安装 Node.js

官网下载安装(LTS版本)

node -v
npm -v

三、Node.js 基础(零基础)

1. 第一个程序

console.log("Hello Node.js");

运行:

node app.js

2. 内置模块

const fs = require("fs");

fs.readFile("test.txt", "utf-8", (err, data) => {
  console.log(data);
});

常用模块:

  • fs(文件)
  • path(路径)
  • http(服务)

3. 创建 HTTP 服务(原生)

const http = require("http");

http.createServer((req, res) => {
  res.end("hello world");
}).listen(3000);

四、ES6 模块化(现代写法)

1. 开启模块化

{
  "type": "module"
}

2. 使用 import/export

// math.js
export const add = (a, b) => a + b;

// app.js
import { add } from "./math.js";
console.log(add(1, 2));

五、npm(包管理)

1. 初始化项目

npm init -y

2. 安装依赖

npm install express
npm install -D nodemon

3. scripts 脚本

"scripts": {
  "dev": "nodemon app.js",
  "start": "node app.js"
}

六、Express 框架(重点)

1. 快速启动服务

import express from "express";

const app = express();

app.get("/", (req, res) => {
  res.send("Hello Express");
});

app.listen(3000);

2. 路由系统

app.get("/user", (req, res) => {
  res.json({ name: "Tom" });
});

3. 静态资源

app.use(express.static("public"));

4. 中间件

app.use(express.json());

app.use((req, res, next) => {
  console.log("请求来了");
  next();
});

七、MongoDB 数据库

1. 启动 MongoDB(Docker 推荐)

docker run -d -p 27017:27017 mongo

2. 使用 mongoose

npm install mongoose

3. 连接数据库

import mongoose from "mongoose";

mongoose.connect("mongodb://localhost:27017/test");

4. 定义模型

const UserSchema = new mongoose.Schema({
  name: String,
  age: Number
});

const User = mongoose.model("User", UserSchema);

5. CRUD

// 新增
await User.create({ name: "Tom", age: 20 });

// 查询
const users = await User.find();

八、项目实战(REST API)

1. 项目结构

project/
├── app.js
├── routes/
├── models/
├── public/

2. 示例 API

app.get("/api/users", async (req, res) => {
  const users = await User.find();
  res.json(users);
});

九、项目部署上线


1. Linux 服务器准备

sudo apt update
sudo apt install nodejs npm

2. 使用 PM2(进程管理)

npm install -g pm2
pm2 start app.js
pm2 save
pm2 startup

3. 使用 Nginx 反向代理

server {
  listen 80;

  location / {
    proxy_pass http://localhost:3000;
  }
}

十、HTTPS 证书配置(重点)

使用 Let's Encrypt 免费证书


1. 安装 certbot

sudo apt install certbot python3-certbot-nginx

2. 申请证书

sudo certbot --nginx

3. 自动续期

certbot renew --dry-run

十一、生产优化

1. 环境变量

NODE_ENV=production

2. 日志管理

使用 winston / morgan


3. 安全

npm install helmet

4. 跨域

import cors from "cors";
app.use(cors());

十二、总结

从零到上线你学会了:

  • Node.js 基础
  • ES6 模块化
  • npm 管理
  • Express 框架
  • MongoDB 数据库
  • REST API
  • PM2 部署
  • Nginx 反向代理
  • HTTPS 证书

🚀 进阶方向

  • 微服务架构
  • Docker 容器化
  • K8s 部署
  • GraphQL
  • WebSocket 实时通信

🚀 进阶方向(从工程到架构)

当你掌握了 Node.js + Express + MongoDB 的基础后,下一步要从“能写接口”进阶到“能做系统架构”。


一、微服务架构(Microservices)

📌 什么是微服务?

把一个大型系统拆分成多个小服务,每个服务独立运行:

用户服务  →  登录 / 注册
订单服务  →  下单 / 支付
商品服务  →  商品管理

🧱 Node.js 实现方式

最简单通信方式(HTTP):

// user-service
app.get("/user", (req, res) => {
  res.json({ id: 1, name: "Tom" });
});

// order-service 调用 user-service
import axios from "axios";

const user = await axios.get("http://localhost:3001/user");

🚀 常用技术

  • API Gateway(网关)
  • 服务注册发现(Consul / Nacos)
  • 消息队列(RabbitMQ / Kafka)

🎯 优点

  • 可独立部署
  • 高扩展性
  • 团队协作友好

二、Docker 容器化

📌 为什么要用 Docker?

解决“我本地能跑,你那跑不了”的问题


🐳 Dockerfile 示例

FROM node:18

WORKDIR /app

COPY . .

RUN npm install

EXPOSE 3000

CMD ["node", "app.js"]

▶️ 构建运行

docker build -t my-node-app .
docker run -p 3000:3000 my-node-app

📦 docker-compose(推荐)

version: "3"
services:
  app:
    build: .
    ports:
      - "3000:3000"
  mongo:
    image: mongo

🎯 优点

  • 环境一致
  • 快速部署
  • 易扩展

三、Kubernetes(K8s)部署

📌 是什么?

容器编排平台,用来管理大量 Docker 容器


🧱 Deployment 示例

apiVersion: apps/v1
kind: Deployment
metadata:
  name: node-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: node
  template:
    metadata:
      labels:
        app: node
    spec:
      containers:
        - name: node-app
          image: my-node-app
          ports:
            - containerPort: 3000

🌐 Service 暴露服务

apiVersion: v1
kind: Service
metadata:
  name: node-service
spec:
  type: NodePort
  selector:
    app: node
  ports:
    - port: 3000
      targetPort: 3000

🎯 能力

  • 自动扩容(HPA)
  • 服务发现
  • 灰度发布

四、GraphQL(替代 REST)

📌 为什么用 GraphQL?

客户端可以“按需获取数据”


🚀 Node 示例

npm install graphql express-graphql
import { graphqlHTTP } from "express-graphql";
import { buildSchema } from "graphql";

const schema = buildSchema(`
  type Query {
    hello: String
  }
`);

const root = {
  hello: () => "Hello GraphQL"
};

app.use("/graphql", graphqlHTTP({
  schema,
  rootValue: root,
  graphiql: true
}));

🎯 优点

  • 减少接口数量
  • 前端更灵活
  • 强类型

五、WebSocket 实时通信

📌 应用场景

  • 聊天系统
  • 实时通知
  • 在线游戏

🚀 Node 实现(ws)

npm install ws
import { WebSocketServer } from "ws";

const wss = new WebSocketServer({ port: 8080 });

wss.on("connection", (ws) => {
  ws.send("连接成功");

  ws.on("message", (msg) => {
    console.log("收到:", msg.toString());
  });
});

🎯 特点

  • 双向通信
  • 实时性强
  • 比 HTTP 更高效

🧠 进阶学习路线总结

Node基础
   ↓
Express + MongoDB
   ↓
REST API
   ↓
Docker(容器化)
   ↓
微服务架构
   ↓
K8s(集群部署)
   ↓
GraphQL / WebSocket(高级能力)

🎯 最终目标

👉 从“写接口”升级为:

  • ✔ 架构设计能力
  • ✔ 分布式系统思维
  • ✔ 可扩展系统构建能力

🚀 一句话总结

真正的 Node.js 高手,不只是会写接口,而是能设计系统。


计算机网络基础知识-WebSocket

WebSocket 协议概述

WebSocket 是 HTML5 提供的一种浏览器与服务器进行全双工通讯的网络技术,属于应用层协议,基于 RFC 6455 标准。

核心工作机制

WebSocket 基于 TCP 传输协议,并复用 HTTP 的握手通道。浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性的连接,并进行双向数据传输。

WebSocket 的出现解决了半双工通信的弊端,其最大特点是:服务器可以向客户端主动推送消息,客户端也可以主动向服务器推送消息。

WebSocket 原理

客户端向 WebSocket 服务器通知一个带有所有接收者 ID 的事件,服务器接收后立即通知所有活跃的客户端,只有 ID 在接收者 ID 序列中的客户端才会处理这个事件。

WebSocket 核心特性

  1. 支持双向通信,实时性更强:客户端和服务器可以同时发送和接收数据。
  2. 可以发送文本,也可以发送二进制数据:支持多种数据类型传输。
  3. 建立在 TCP 协议之上,服务端的实现比较容易:基于成熟的 TCP 协议栈。
  4. 数据格式比较轻量,性能开销小,通信高效:最小头部仅 2 字节,传输效率高。
  5. 没有同源限制,客户端可以与任意服务器通信:跨域通信能力(需服务器允许)。
  6. 协议标识符是 ws(如果加密,则为 wss),服务器网址就是 URL:统一的 URL 标识方案。
  7. 与 HTTP 协议有着良好的兼容性:默认端口也是 80/443,握手阶段使用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。

WebSocket 握手过程

客户端请求

WebSocket 握手基于 HTTP 协议升级机制,客户端发送升级请求:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Version: 13

服务器响应

服务器验证请求后返回协议切换响应:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=

Sec-WebSocket-Accept 的生成算法为:base64(sha1(Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))

WebSocket 数据帧格式

WebSocket 协议的核心是轻量级的数据帧格式:

 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5
+-+-+-+-+-------+-+-------------+
|F|R|R|R| opcode|M| Payload Len |
|I|S|S|S|  (4)  |A|     (7)     |
|N|V|V|V|       |S|             |
| |1|2|3|       |K|             |
+-+-+-+-+-------+-+-------------+

关键字段说明

  • FIN(1bit):标识是否为消息的最后一帧
  • Opcode(4bit):帧类型标识
    • 0x0:延续帧(分片消息的中间帧)
    • 0x1:文本帧(UTF-8编码)
    • 0x2:二进制帧
    • 0x8:关闭连接帧
    • 0x9:Ping帧(心跳检测)
    • 0xA:Pong帧(心跳响应)
  • Mask(1bit):是否使用掩码(客户端到服务器必须为1)
  • Payload Len:数据负载长度

WebSocket 心跳机制

为了保持连接活跃,WebSocket 协议定义了 Ping/Pong 帧用于心跳检测:

// 服务器定时发送心跳
setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) {
        ws.ping('heartbeat');
    }
}, 25000);

// 客户端自动回复
ws.on('pong', (data) => {
    console.log('连接状态正常');
});

推荐的心跳间隔为 25-30 秒,避免与常见的网络超时设置冲突。

WebSocket 客户端实现

基本使用

// 创建WebSocket连接
const ws = new WebSocket('wss://api.example.com/chat');

// 连接建立事件
ws.onopen = (event) => {
    console.log('WebSocket连接已建立');
    ws.send(JSON.stringify({type: 'join', room: 'general'}));
};

// 消息接收事件
ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    console.log('收到消息:', data);
};

// 错误处理
ws.onerror = (error) => {
    console.error('连接错误:', error);
};

// 连接关闭事件
ws.onclose = (event) => {
    console.log(`连接关闭: ${event.code} - ${event.reason}`);
};

连接状态管理

WebSocket 连接有4种状态:

  • CONNECTING(0):连接正在建立
  • OPEN(1):连接已建立,可以通信
  • CLOSING(2):连接正在关闭
  • CLOSED(3):连接已关闭

即时通讯技术对比分析

短轮询(Short Polling)

基本思路:浏览器每隔一段时间向服务器发送 HTTP 请求,服务器在收到请求后,不论是否有数据更新,都直接进行响应。

工作机制:这种方式实现的即时通信,本质上还是浏览器发送请求、服务器接受请求的一个过程,通过让客户端不断地进行请求,使得客户端能够模拟实时地收到服务器端的数据的变化。

优点:实现简单,易于理解,兼容性最好。

缺点:由于需要不断的建立 HTTP 连接,严重浪费了客户端和服务器端的资源。当用户增加时,服务器端的压力就会变大。

长轮询(Long Polling)

基本思路:首先由客户端向服务器发起请求,当服务器收到客户端发来的请求后不会直接进行响应,而是先将这个请求挂起,然后判断数据是否有更新。如果有更新,则进行响应;如果一直没有数据,则到达一定的时间限制才返回。客户端 JavaScript 响应处理函数会在处理完服务器返回的信息后,再次发出请求,重新建立连接。

优点:长轮询和短轮询比起来,明显减少了很多不必要的 HTTP 请求次数,相比之下节约了资源。

缺点:连接挂起也会导致资源的浪费。

服务器发送事件(SSE)

基本思路:服务器使用流信息向客户端推送信息。严格地说,HTTP 协议无法做到服务器主动推送信息。但是,有一种变通方法,就是服务器向客户端声明,接下来要发送的是流信息。也就是说,发送的不是一次性的数据包,而是一个数据流,会连续不断地发送过来。这时,客户端不会关闭连接,会一直等着服务器发过来的新的数据流,视频播放就是这样的例子。SSE 就是利用这种机制,使用流信息向客户端推送信息。

优点:SSE 相对于前面两种方式来说,不需要建立过多的 HTTP 请求,相比之下节约了资源。

限制:SSE 基于 HTTP 协议,目前除了 IE/Edge,其他浏览器都支持。SSE 是单向通信的,只能由服务器向客户端推送信息,如果客户端需要发送信息就是属于下一个 HTTP 请求了。

WebSocket

基本思路:WebSocket 是 HTML5 定义的一个新协议,与传统的 HTTP 协议不同,该协议允许由服务器主动向客户端推送信息。WebSocket 是一个全双工的协议,也就是通信双方是平等的,可以相互发送消息。

优点:真正的实时双向通信,延迟最低,性能最优。

缺点:在服务器端的配置比较复杂,兼容性相对较差。

四种通信协议深度对比

协议基础对比

对比维度 短轮询 长轮询 SSE WebSocket
协议基础 HTTP HTTP HTTP TCP
通信方式 客户端主动查询 服务器挂起响应 服务器单向推送 全双工双向
连接建立 每次请求新建 每次请求新建 一次连接持久 一次握手持久
数据流向 双向(请求-响应) 双向(请求-响应) 单向(服务器→客户端) 双向同时

性能对比分析

性能指标 短轮询 长轮询 SSE WebSocket
实时性 低(取决于轮询间隔) 中(服务器有数据立即响应) 高(服务器主动推送) 最高(真正实时)
带宽效率 低(大量无效请求) 中(减少无效请求) 高(流式传输) 最高(轻量级帧)
服务器负载 高(频繁建立连接) 中(连接挂起消耗) 低(持久连接) 最低(一次连接)
客户端资源 高(频繁请求) 中(连接管理复杂) 低(自动处理) 最低(事件驱动)

兼容性与适用场景

方案 浏览器兼容性 适用场景 不适用场景
短轮询 完美兼容所有浏览器 兼容性要求极高、数据更新频率低 高频实时通信
长轮询 很好兼容 中等实时性要求、无法使用 WebSocket 需要真正实时双向通信
SSE 良好(除IE/Edge) 服务器向客户端单向推送、新闻推送、监控 需要客户端主动发送数据
WebSocket 良好(现代浏览器) 聊天、游戏、实时交易、高频数据交换 兼容性要求极高、服务器不支持

选择依据总结

选择 WebSocket,当

  • 需要真正的实时双向通信
  • 数据交换频率高(每秒多次)
  • 对延迟敏感的应用
  • 需要传输二进制数据

选择其他方案,当

  • 兼容性要求极高 → 短轮询
  • 服务器环境不支持 WebSocket → 长轮询
  • 只需要服务器向客户端的单向推送 → SSE

技术选型指南

从性能的角度来看:WebSocket > 长连接(SSE) > 长轮询 > 短轮询

如果考虑浏览器的兼容性问题:短轮询 > 长轮询 > 长连接(SSE) > WebSocket

要根据具体的使用场景来判断使用哪种方式

  • 对于实时性要求极高的场景(如在线游戏、金融交易),优先选择 WebSocket
  • 对于只需要服务器推送的场景(如新闻推送、监控),SSE 是更好的选择
  • 在兼容性要求极高的环境中,短轮询或长轮询是必要的备选方案

总结

WebSocket 协议通过一次握手建立持久连接,实现了真正的全双工实时通信。其轻量级的数据帧格式和内置的心跳机制,使其在高频数据交换场景中具有显著优势。

在实际应用中,需要根据具体的业务需求、性能要求和兼容性约束选择最合适的实时通信方案。WebSocket 特别适合需要低延迟、高频双向通信的场景,而其他方案在特定场景下也有各自的优势。

通过深入理解各种通信协议的特点和适用场景,可以做出更合理的技术选型决策,为用户提供优质的实时交互体验。

购物车数字怎么更新?一个前端问题的三种架构答案

在做电商项目的时候,有一个看起来很小的问题:用户在商品页加了一件东西进购物车,Header 右上角的数字要 +1。

这个需求本身不复杂,但我在三个不同的项目里,见到了三种完全不同的解法。每一种解法背后,都是一套不同的架构决策——状态到底归谁管?

这篇文章是我在读 Medusa(一个开源电商 SaaS)源码时引发的思考,整理了我对这个问题的理解过程。


先定义清楚这个问题

"Header 购物车数字怎么更新",本质上是一个跨组件状态同步问题:

商品页的"加入购物车"按钮(触发方)
         ↓
    购物车数量变了
         ↓
Header 的数字组件(响应方)

这两个组件在页面上没有直接的父子关系,但需要共享同一份数据。

不同架构对这个问题的回答,决定了整个前端的数据流长什么样。


解法一:客户端拥有状态(Monorepo 单体前端)

方案描述

在一个 Monorepo 单体前端项目里,一种常见的做法是用 useReducer + SessionStorage 来管理跨页面的状态:

// 环境:React + Next.js(Pages Router)
// 场景:用 useReducer 管理 cart 状态,并持久化到 SessionStorage

type CartState = {
  items: CartItem[]
  totalCount: number
}

type CartAction =
  | { type: 'ADD_ITEM'; payload: CartItem }
  | { type: 'REMOVE_ITEM'; payload: string }
  | { type: 'INIT'; payload: CartState }

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM':
      return {
        ...state,
        items: [...state.items, action.payload],
        totalCount: state.totalCount + action.payload.quantity,
      }
    case 'REMOVE_ITEM':
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload),
        totalCount: state.totalCount - 1,
      }
    case 'INIT':
      return action.payload
    default:
      return state
  }
}

// 每次 dispatch 同步写入 SessionStorage
function useCartWithSession() {
  const [state, dispatch] = useReducer(cartReducer, { items: [], totalCount: 0 })

  const persistedDispatch = (action: CartAction) => {
    dispatch(action)
    const nextState = cartReducer(state, action)
    sessionStorage.setItem('cart', JSON.stringify(nextState))
  }

  // 页面初始化时从 SessionStorage 读取
  useEffect(() => {
    const saved = sessionStorage.getItem('cart')
    if (saved) {
      persistedDispatch({ type: 'INIT', payload: JSON.parse(saved) })
    }
  }, [])

  return { state, dispatch: persistedDispatch }
}

数据流:

用户点击"加入购物车"
       ↓
dispatch(ADD_ITEM)
       ↓
reducer 更新内存中的 state
       ↓
同步写入 SessionStorage
       ↓
所有订阅了这个 context 的组件重渲染
       ↓
Header 数字更新

这个方案在做什么

状态存在客户端的内存和 SessionStorage 里。内存保证当前页面的响应速度,SessionStorage 保证页面跳转后状态不丢失。

组件间的同步靠 React Context——谁订阅了这个 context,谁就能感知到 dispatch 触发的变化。

权衡

优势:

  • 直觉清晰,数据流可追踪
  • 不依赖网络,操作响应快
  • 状态变化立即反映在 UI

代价:

  • 需要手动管理"内存状态"和"持久化状态"的同步
  • 如果有多个 tab 打开,状态会不一致
  • 客户端状态和服务端实际数据可能出现偏差(比如库存已售罄但客户端不知道)

解法二:消灭状态(微前端架构)

方案描述

在另一个电商项目里,前端是微前端架构——PDP(商品详情页)、Cart、Checkout 各自是独立部署的应用,挂载在一个 CMS(内容管理系统)的页面上。

这种架构下,"跨组件状态同步"这个问题根本不存在——因为根本没有一个"全局前端"。

CMS Shell(Header 在这里)
  ├── /products/[id]  →  PDP 微前端(只管商品详情)
  ├── /cart           →  Cart 微前端(只管购物车)
  └── /checkout       →  Checkout 微前端(只管结账流程)

跨应用的状态传递靠 URL 参数

// 环境:微前端,PDP 应用
// 场景:加购后跳转到 Cart,通过 URL 传递 cart_id

async function handleAddToCart(variantId: string) {
  // 调用 cart-service 创建或更新 cart
  const response = await fetch('/api/cart', {
    method: 'POST',
    body: JSON.stringify({ variantId, quantity: 1 }),
  })
  const { cartId } = await response.json()

  // 跳转到 Cart 微前端,通过 URL 传递 cart_id
  window.location.href = `/cart?cart_id=${cartId}`
}

// Cart 微前端初始化时从 URL 读取
function CartApp() {
  const cartId = new URLSearchParams(window.location.search).get('cart_id')

  useEffect(() => {
    if (cartId) {
      // 用 cart_id 查询购物车数据,初始化页面
      fetchCartData(cartId)
    }
  }, [cartId])
}

数据流:

用户在 PDP 点击"加入购物车"
       ↓
调用 cart-service API,拿到 cart_id
       ↓
跳转到 /cart?cart_id=xxx
       ↓
Cart 微前端用 cart_id 初始化,fetch 购物车数据

这个方案在做什么

这里没有"全局状态管理",而是用物理边界把问题消灭了。

每个微前端只管自己的数据,应用之间通过 URL 传递"通行证"(cart_id),谁拿到 cart_id 谁去查数据,不需要任何前端间的状态共享。

至于 Header 的购物车数字——那是 CMS 的职责,不在这个微前端的边界内。CMS 自己有机制处理。

权衡

优势:

  • 边界极其清晰,每个应用只关心自己的事
  • 应用间没有状态污染,独立部署,独立维护
  • 技术栈可以不统一

代价:

  • 全局体验难以协调(Header 的状态由谁来维护?)
  • 跨应用通信变复杂,URL 能传递的信息有限
  • 每次跨应用跳转都是完整的页面刷新,体验有损

解法三:服务端拥有状态(单体前端 + Server Cache)

方案描述

读 Medusa 的源码时,我在找 Cart 相关的 Context——搜索 createContext,整个仓库只有两个结果:一个是 modal,一个是 Stripe 支付。Cart 根本没有用 Context。

然后我搜 revalidateTag,在 cart.ts 里找到了答案:

// 环境:Next.js App Router,Server Action
// 来源:Medusa nextjs-starter-medusa/src/lib/data/cart.ts
// 场景:加购操作

"use server"

export async function addToCart({
  variantId,
  quantity,
  countryCode,
}: {
  variantId: string
  quantity: number
  countryCode: string
}) {
  // 确保 cart 存在,没有就创建,并把 cart_id 写入 cookie
  const cart = await getOrSetCart(countryCode)

  if (!cart) {
    throw new Error("Error retrieving or creating cart")
  }

  // 调用 Medusa API 写入数据
  await sdk.store.cart
    .createLineItem(
      cart.id,
      { variant_id: variantId, quantity },
      {},
      headers
    )
    .then(async () => {
      // 操作成功后,让相关缓存失效
      const cartCacheTag = await getCacheTag("carts")
      revalidateTag(cartCacheTag)               // Cart 数据缓存失效

      const fulfillmentCacheTag = await getCacheTag("fulfillment")
      revalidateTag(fulfillmentCacheTag)        // 履约数据缓存失效
    })
  // 注意:没有返回值
}

没有返回值。函数只负责两件事:调 API 写数据,然后让缓存失效。

数据流:

用户点击"加入购物车"
       ↓
Client Component 调用 addToCart()(Server Action)
       ↓
【在服务端执行】
getOrSetCart() — 确保 cart 存在,cart_id 存入 cookie
       ↓
sdk.store.cart.createLineItem() — 调 Medusa API
       ↓
revalidateTag("carts") — 让 cart 相关缓存失效
       ↓
Next.js 自动重新 fetch 所有标记了 "carts" tag 的数据
       ↓
Header 数字、Cart 页面列表,同时自动更新

cart_id 的持久化也值得注意——它存在 cookie 里,而不是 URL 参数:

// 环境:Next.js Server Action
// 场景:创建 cart 后持久化 cart_id

async function getOrSetCart(countryCode: string) {
  // 尝试读取已有的 cart
  let cart = await retrieveCart(undefined, "id,region_id")

  if (!cart) {
    // 创建新 cart
    const cartResp = await sdk.store.cart.create(
      { region_id: region.id },
      {},
      headers
    )
    cart = cartResp.cart

    // cart_id 写入 cookie(不是 URL)
    await setCartId(cart.id)

    // 同时让缓存失效,触发 UI 更新
    const cartCacheTag = await getCacheTag("carts")
    revalidateTag(cartCacheTag)
  }

  return cart
}

这个方案在做什么

状态的真正归属地在服务端。前端不持有 cart 数据,只持有一个 cart_id(存在 cookie 里)。

每次需要数据,就去 fetch——但 Next.js 会自动缓存这个 fetch 的结果,打上 tag。当数据变化时,revalidateTag 让这个 tag 失效,所有依赖这份数据的组件在下次渲染时自动重新 fetch。

组件之间不需要任何显式的"通知"机制,因为它们都从同一个源头取数据,源头失效了大家一起重取。

权衡

优势:

  • 无需手写状态同步逻辑,Next.js 自动处理
  • 服务端数据是真正的 single source of truth
  • 跨组件共享"零成本"——读同一个 cache tag 就够了

代价:

  • 需要理解和信任 Next.js 的缓存机制
  • 实时性强的数据(库存、限时价格)需要绕过缓存直接请求
  • 出了缓存问题比较难调试

三种解法的本质对比

解法一(Reducer + SessionStorage) 解法二(微前端 + URL) 解法三(Server Cache)
状态归属 客户端内存 不存在全局状态 服务端缓存
cart_id 存在哪 SessionStorage URL 参数 Cookie
跨组件同步 React Context + dispatch 物理隔离,无需同步 revalidateTag 自动触发
Header 谁更新 订阅 context 自动更新 CMS 负责,不在前端边界内 和 cart 用同一份 cache
手写同步逻辑 需要 不需要(问题不存在) 不需要(框架处理)
状态一致性风险 客户端 vs 服务端可能偏差 每次跳转重新 fetch,一致 服务端是唯一来源,一致

怎么选?

我的理解是,这三种方案解决的不是同一个层次的问题:

解法二(微前端)适合大型组织,团队边界清晰,每个前端 app 由不同团队维护,宁愿牺牲一些全局体验,换取团队间的独立性。

解法一(Reducer + SessionStorage)适合中小型单体前端,需要快速响应、离线支持,或者还没有引入 Next.js App Router 等新范式的项目。

解法三(Server Cache)适合以 Next.js App Router 为核心的单体前端,服务端数据是可信来源,且对全局状态一致性要求高的场景。

没有绝对的优劣,选择背后是对应用边界、团队结构、实时性要求的权衡。


还没想清楚的地方

这三种方案里,AI 介入之后会发生什么?

addToCart 成功后触发 revalidateTag——如果这个时机要插入一个导购 Agent 的推荐逻辑,它应该在哪里?是 .then() 里同步执行,还是作为一个独立的事件监听,还是需要一个完全独立的"AI 介入层"?

在微前端架构里,AI 的判断结果算谁的状态?它跨越了应用边界,现有的通信机制能承载吗?

这些是我接下来想探索的问题,如果你有想法,欢迎交流。


参考资料

Ajax学习笔记

什么是Ajax

Ajax:异步刷新技术

Ajax作用:实现异步请求的技术

什么是异步请求?

同步请求:就是实时处理,比如服务器已接受客户端请求,马上响应,这样客户端可以在最短的时间内得到结果。

如同打电话,通信双方不能断(我们是同时进行,同步),你一句我一句,好处是对方表达的信息我马上能收到,但是我正在打电话,无法总别的事。

异步请求:就是分时处理,服务器接收到客户端请求后并不是立即处理,而是等待服务器比较空闲的时候加以处理,可以避免拥塞。

异步如同收发短信,对方不用保证此刻我一定在手机旁,同时,我也不用时刻留意手机有没有来短信。

对于服务器,如果客户端的同步请求过多,就会造成阻塞

服务器-请求Ajax->表现层Servlet->业务逻辑层->数据访问层->服务器

服务器<-响应Ajax<-表现层Servlet<-业务逻辑层<-数据访问层<-服务器

我们用AJAX和服务器进行通信,将JSP替换为HTML+AJAX

! Ajax语法

Ajax可以通过原生的XMLHttpRequest对象发出HTTP请求,使用起来比较复杂和繁琐,实际开发中基本不使用,我们使用JQuery发送Ajax请求

$.ajax({name:value, name:value,...})

常用参数

参数 类型 说明
url String 发送请求的地址,默认为当前页地址
type String 请求方式,默认为GET
data PlainObject或String或Array 发送到服务器的数据
data Type String 预期服务器返回的数据类型,包括:XML、HTML、Script、JSON、JSONP、text
timeout Number 设置请求超时时间
global Boolean 表示是否触发全局AJax事件,默认为true

常用函数参数

参数 类型 说明
beforeSend Function(jqXHR jqxhr,PlainObject settings) 发送请求前调用的函数
success Function(任意类型 result,String textStatus,jqXHR jqxhr) 请求成功后调用的函数参数result:可选,由服务器返回的数据
error Function(jqXHR jqxhr,String textStatus,String errorThrown) 请求失败时调用的函数
complete Function(jqXHR jqxhr,String textStatus) 请求完成后(无论成功还是失败)调用的函数

规范示例

$.ajax({
    "url":"url", //要请求后台Servlet的URL路径
    "type":"get", //发送请求的方式
    "data":data, //要发送到服务器的数据
    "dataType":"text", //指定传输的数据格式
    "success":function(result){
        //请求成功后要执行的代码
    },
    "error":function(){
        //请求失败后要执行的代码
    },
});

Ajax快速入门

需求

在完成用户注册时,当用户名输入框失去焦点时,向后台发送请求,校验用户名是否在数据库已存在,如果存在则提示该用户名已存在。

分析

  • 前端完成的逻辑

    1. 给用户名输入框绑定光标失去焦点事件onblur
    2. 发送ajax请求,携带username参数
    3. 处理响应:是否显示提示信息
  • 后端完成的逻辑

    1.接收用户名 2.调用service查询User,判断用户是否存在 3.返回标记

整体流程如下

register.html

  1. 给用户名输入框绑定光标失去焦点事件onblur
  2. 发送ajax请求,携带username参数
  3. 处理响应:是否显示提示信息

------------↓请求-----------↑响应-----------

CheckNameServlet.java

  1. 接收用户名
  2. 调用servlet查询User,判断用户是否存在
  3. 返回标记

代码

register.html

<!DOCTYPE html>

<html>

<head>

<meta charset="UTF-8">

<title>Insert title here</title>

<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.min.js"></script>

</head>

<body>

<div>

<input type="text" class="login_input" id="userName" placeholder="请输入长度为6-10个字符的用户名"/>

<span id="userName_prompt" class="prompt"></span>

</div>

<script>

//给用户名输入框添加失去焦点事件

$("#userName").blur(checkUserName);

//给用户名输入框添加失去焦点事件

function checkUserName(){

var userName =$("#userName").val();

<!--发送ajax请求-->

$.ajax({

"url" :"checkName", //要提交的URL路径

"type" :"GET", //发送请求的方式

"data":"userName="+userName, //要发送到服务器的数据

"success" : function(data){

//响应成功后要执行的代码

if(data=="true"){

$("#userName_prompt").html("该用户名已存在");

}

}

});

$("#userName_prompt").html("");

return true;

}

</script>

  


</body>

</html>

CheckNameServlet.java

package com.servlet;

import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@WebServlet("/checkName")
public class CheckNameServlet extends HttpServlet {
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
response.setContentType("text/html;charset=utf-8");
String userName = request.getParameter("userName");

// 模拟:如果用户名是 admin 就已存在
if("admin".equals(userName)) {
response.getWriter().print("true");
}else {
response.getWriter().print("false");
}
}
}

Ajax简化写法

普通写法

$.ajax({
    "url":"url", //要请求后台Servlet的URL路径
    "type":"get", //发送请求的方式
    "data":data, //要发送到服务器的数据
    "dataType":"text", //指定传输的数据格式
    "success":function(result){
        //请求成功后要执行的代码
    },
    "error":function(){
        //请求失败后要执行的代码
    },
});

发送GET请求

$.get()方式通过HTTP GET,请求成服务器上请求数据

语法

$.get(URL,callback);
//or
$.get(URL,[data],[callback],[dataType])
  • URL:发送请求的URL字符串。
  • data:可选的,发送给服务器的字符串或key/value键值对。
  • calback:可选的,请求成功后执行的回调函数。
  • dataType:可选的,从服务器返回的数据类型。默认:智能猜测(可以是xml.json,script,或html)。

发送Post请求

.post()方法通过HTTPPOST请求,向服务器提交数据,写法上和.post()方法通过HTTP POST请求,向服务器提交数据,写法上和.get()很相似

语法

$.post(URL,callback);
//or
$.post(URL,[data],[callback],[dataType])
  • RUL:发送请求的URL字符串。
  • data:可选的,发送给服务器的字符串或key/value键值对。
  • calback:可选的,请求成功后执行的回调函数
  • dataTYpe:可选的,从服务器返回的数据类型。默认:智能猜测(可以是mxl.json,script,或html)。 注意:后端的CheckNameServlet对应post请求的支持
❌