普通视图

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

别把语音 Agent 当成“接两个 API”——用 NestJS 搭一套 ASR + LLM + 流式 TTS 的实时语音助手

作者 swipe
2026年5月5日 16:17

我们现在看到的大多数 AI 助手,已经默认具备语音能力:你说一句话,它先把语音转成文字;大模型理解问题后,边生成文字边输出答案;最后,再把这段答案用自然语音朗读出来。

从表面看,这件事像是把三个能力串起来:

  • ASR(Automatic Speech Recognition,语音识别)
  • LLM(大模型推理)
  • TTS(Text To Speech,语音合成)

但真正做过这类系统,你会发现问题根本不在“有没有接上接口”,而在链路能不能协同工作

很多 Demo 的问题不是不能跑,而是体验不对:

  • 录音能识别,但只能整段上传,交互很生硬
  • 大模型能流式返回,但语音要等整段文本结束后才开始播放
  • 前端能显示文字,但音频播放一顿一顿
  • 文本和语音各走各的,最后很容易出现“字已经出完了,音频还没开始”
  • 一旦中间某条连接断掉,整条语音链路就会失去同步

所以,这篇文章我不打算把它写成“如何分别调用腾讯云 ASR、腾讯云 TTS 和大模型 API”的资料拼盘。我想讲清楚一个更关键的结论:

语音版 AI 助手真正的难点,不是单独把 ASR、LLM、TTS 跑通,而是把“上传式语音识别、SSE 文本流、WebSocket 二进制音频流、服务端事件桥接、前端流式播放”组织成一条低耦合、可持续输出的实时链路。

本文基于一个真实可运行的 NestJS 项目来展开,项目里已经具备这几部分能力:

  • 浏览器录音并上传到 /speech/asr
  • 服务端调用腾讯云 ASR 做语音转文字
  • 文本问题进入 /ai/chat/stream,以 SSE 形式流式输出
  • 服务端将大模型输出通过事件桥接给流式 TTS
  • 腾讯云流式 TTS 返回二进制音频,通过 WebSocket 推给前端
  • 前端用 MediaSource + SourceBuffer 做边收边播

这套设计不一定是生产级语音系统的终点,但非常适合作为一个工程上讲得通、链路上闭得上、博客里讲得清的默认方案。


一、先别急着写代码:语音 AI 助手其实是三条链路

如果你一开始就把语音助手理解成“录音之后调一次接口”,你大概率会把结构做歪。

从工程上看,至少要先拆出三条职责不同的链路:

  1. 输入链路:浏览器录音 -> 上传音频 -> ASR -> 文本
  2. 推理链路:文本问题 -> LLM -> 流式文本输出
  3. 播报链路:流式文本 -> TTS -> 二进制音频 -> 边收边播

这三条链路的通信方式、时序要求、数据形态都不一样:

  • 录音上传是文件型请求,适合 multipart/form-data
  • 大模型文本输出是连续文本流,适合 SSE
  • 音频是二进制数据流,更适合 WebSocket

也就是说,这不是“一个接口做三件事”的问题,而是“多条流如何协作”的问题。

如果把这三段混成一个大接口,通常会出现两个后果:

  • 业务代码耦合严重,后面任何一段升级都很痛苦
  • 文本和音频时序失控,体验会非常差

所以我建议你先把这件事理解成一个多流协同系统,再去看具体实现。


二、这套项目的核心架构是什么

先看整条链路的全貌。本文分析的项目里,NestJS 并不是简单的 API 网关,而是把三种协议和两种外部能力组织起来的中枢。

flowchart LR
    subgraph Browser[浏览器端]
        A[录音采集<br/>MediaRecorder]
        B[上传音频<br/>POST /speech/asr]
        O[文字逐字显示]
        M[语音通道<br/>GET /speech/tts/ws]
        N[流式播放<br/>MediaSource + SourceBuffer]
        P[边收边播]
    end

    subgraph Server[NestJS 服务端]
        C[SpeechController / SpeechService]
        F[AiController<br/>SSE /ai/chat/stream]
        G[AiService + LangChain]
        I[事件桥接<br/>AI_TTS_STREAM_EVENT]
        J[TtsRelayService]
    end

    subgraph Cloud[外部云服务]
        D[腾讯云 ASR]
        K[腾讯云流式 TTS WebSocket]
    end

    A --> B
    B --> C
    C --> D
    D --> E[识别文本]
    E --> F
    F --> G
    G --> H[大模型流式文本]
    H --> O
    H --> I
    I --> J
    J --> K
    K --> L[二进制 MP3 音频帧]
    L --> M
    M --> N
    N --> P

这张图里最值得注意的,不是腾讯云,也不是模型,而是中间这几个“看起来不起眼”的节点:

  • SSE
  • WebSocket
  • AI_TTS_STREAM_EVENT
  • MediaSource
  • SourceBuffer

这几个点决定了语音链路到底是“实时协同”,还是“能跑但体验别扭”。

再看一次时序,你会更直观一些:

sequenceDiagram
    participant U as 用户
    participant FE as 浏览器前端
    participant ASR as NestJS /speech/asr
    participant TCASR as 腾讯云 ASR
    participant AI as NestJS /ai/chat/stream
    participant LLM as 大模型
    participant RELAY as TtsRelayService
    participant TCTTS as 腾讯云流式TTS

    U->>FE: 录音并停止
    FE->>ASR: 上传音频文件
    ASR->>TCASR: SentenceRecognition
    TCASR-->>ASR: 返回识别文本
    ASR-->>FE: text
    FE->>RELAY: 建立 /speech/tts/ws
    FE->>AI: 发起 /ai/chat/stream?ttsSessionId=xxx
    AI->>LLM: 流式生成回答
    LLM-->>AI: 文本 chunk
    AI-->>FE: SSE 文本 chunk
    AI->>RELAY: 发出 chunk 事件
    RELAY->>TCTTS: ACTION_SYNTHESIS 分段文本
    TCTTS-->>RELAY: 二进制音频帧
    RELAY-->>FE: WebSocket 音频数据
    FE->>FE: SourceBuffer appendBuffer
    FE-->>U: 边显示文字边播放语音

理解了这张图,后面的代码就不再是“API 堆砌”,而是各自承担某个链路角色。


三、先看项目结构:这个仓库为什么这么拆

这个项目的 README 仍然是 NestJS 默认模板,真正的信息都在源码里。核心结构大致如下:

src/
  ai/
    ai.config.ts
    ai.controller.ts
    ai.module.ts
    ai.service.ts
  speech/
    speech.config.ts
    speech.controller.ts
    speech.module.ts
    speech.service.ts
    tts-relay.service.ts
    tts-text-segmentation.ts
  common/
    stream-events.ts
  main.ts
public/
  asr.html
  asr-stream.html

这套拆分是合理的:

  • ai/:只关心文本问答链路
  • speech/:只关心语音输入、语音输出以及第三方语音服务接入
  • common/stream-events.ts:作为事件约定,让 AI 和 TTS 解耦
  • public/asr.html:单独验证 ASR 上传链路
  • public/asr-stream.html:完整验证“录音 -> 识别 -> AI -> TTS”链路

四、为什么 ASR 这里我更推荐“录完再识别”而不是一上来就做流式识别

很多人一聊语音系统,就默认“必须实时流式 ASR”。这其实是个常见误区。

要不要流式识别,不应该由技术潮流决定,而应该由交互目标决定。

在这个项目里,语音输入的目标不是做电话机器人,也不是做毫秒级打断对话,而是做一个像豆包那样的单轮语音提问

  1. 用户说完一段话
  2. 系统识别成文本
  3. 大模型开始回答
  4. 回答边生成边播报

在这种场景里,先录完再识别,其实是非常合理的默认方案:

  • 实现复杂度明显更低
  • 浏览器端更容易兼容
  • 后端不需要先处理麦克风实时分片上送
  • 对“问一句 -> 回一句”的交互已经够用

换句话说,流式 ASR 不是默认最优,而是更高阶、更高成本的能力。

如果你的目标只是做一个能交互、可演示、可扩展的语音 AI 助手,把复杂度优先放在“输出链路流式化”上,通常是更划算的。


五、ASR 后端是怎么接的:/speech/asr 这条链路的职责非常清晰

先看控制器:

// src/speech/speech.controller.ts
@Controller('speech')
export class SpeechController {
  constructor(private readonly speechService: SpeechService) {}

  @Post('asr')
  @UseInterceptors(FileInterceptor('audio'))
  async recognize(
    @UploadedFile()
    file?: {
      buffer: Buffer;
      originalname: string;
      mimetype: string;
      size: number;
    },
  ) {
    if (!file?.buffer?.length) {
      throw new BadRequestException(
        '请通过 FormData 的 audio 字段上传音频文件',
      );
    }

    const text = await this.speechService.recognizeBySentence(file);
    return { text };
  }
}

这个接口做得很干净:

  • 它不负责录音
  • 不负责转码
  • 不负责前端 UI
  • 只负责接收 audio 文件并转交给 SpeechService

这就是好接口的样子:边界清楚,职责单一。

再看识别逻辑:

// src/speech/speech.service.ts
@Injectable()
export class SpeechService {
  constructor(@Inject('ASR_CLIENT') private readonly asrClient: AsrClient) {}

  async recognizeBySentence(file: UploadedAudio): Promise<string> {
    const audioBase64 = file.buffer.toString('base64');

    const result = await this.asrClient.SentenceRecognition({
      EngSerViceType: '16k_zh',
      SourceType: 1,
      Data: audioBase64,
      DataLen: file.buffer.length,
      VoiceFormat: 'ogg-opus',
    });

    return result.Result ?? '';
  }
}

这里有几个参数值得讲透,而不是只说“它们怎么填”:

1)为什么要 buffer -> base64

因为这里调用的是云厂商 SDK 的句子识别接口,音频数据需要以指定格式进入请求体。浏览器上传到服务端后,Nest 拿到的是内存中的二进制 Buffer,而腾讯云接口在这个模式下要吃的是 Base64 文本。

也就是说,这一步不是“多余的转换”,而是协议适配。

2)EngSerViceType: '16k_zh' 在表达什么

这个参数不是“中文模式”这么简单,它实际上约束了:

  • 识别语种
  • 采样率预期
  • 模型适配方向

如果你音频本身和服务类型不匹配,识别结果就容易变差,甚至直接报错。很多人觉得“ASR 效果不好”,其实不是模型差,而是输入格式、采样率、编码方案就没对齐。

3)为什么 VoiceFormat 这里用 ogg-opus

因为前端 MediaRecorder 优先录的是:

const preferredMimeType = "audio/ogg;codecs=opus";

前后端格式是对齐的。这个细节非常关键。

如果你前端录出来的是 webm/opus,服务端却告诉云厂商它是 ogg-opus,结果要么识别失败,要么内容异常。音频链路里,格式匹配远比“我觉得差不多”更重要。


六、前端录音链路的设计,为什么比“点一下录音按钮”复杂

public/asr.html 里,项目专门做了一个 ASR 验证页。这个页面的意义并不只是演示,而是把“录音 -> 上传 -> 识别”单独拆出来验证。

核心代码是这段:

mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
chunks = [];

const mimeType = MediaRecorder.isTypeSupported(preferredMimeType)
  ? preferredMimeType
  : fallbackMimeType;

mediaRecorder = new MediaRecorder(mediaStream, { mimeType });

mediaRecorder.ondataavailable = (event) => {
  if (event.data && event.data.size > 0) {
    chunks.push(event.data);
  }
};

mediaRecorder.onstop = async () => {
  const blob = new Blob(chunks, {
    type: mediaRecorder.mimeType || fallbackMimeType,
  });
  const data = await uploadRecording(blob);
  resultEl.textContent = data.text || '(空结果)';
};

mediaRecorder.start(250);

这段代码在整条链路里的位置,是语音输入采集层。它解决的不是识别,而是三个更基础的问题:

  1. 如何向浏览器申请麦克风权限
  2. 如何把录音分片收集起来
  3. 如何在停止录音后整合成可上传的 Blob

注意这里 start(250) 的意义:虽然当前链路是“录完再识别”,但录音过程中仍然是按 250ms 分片收集的。这么做的好处是:

  • 浏览器侧更平滑
  • 后续如果要升级成更实时的链路,基础采集方式不用推翻
  • 可以更容易做波形、计时、录音中状态等 UI

也就是说,这个前端实现虽然现在走的是上传式 ASR,但它没有把自己写死在“纯离线式”的思路里。


七、为什么大模型输出要走 SSE,而不是普通 HTTP 返回一整段文本

语音助手如果只返回整段文本,问题不止是“慢”,而是整个系统没法形成实时反馈。

用户体验上的关键差别在这里:

  • 普通 HTTP:用户必须等待全部生成完成
  • SSE:前端可以随着 chunk 逐步展示答案

在语音场景里,这个差异会进一步放大。因为 TTS 的输入来源就是大模型流式文本。如果文本不流,语音就没法流。

也就是说,TTS 能不能边播,根上取决于 LLM 文本能不能边出。

项目里的 SSE 接口非常简洁:

// src/ai/ai.controller.ts
@Controller('ai')
export class AiController {
  constructor(
    private readonly aiService: AiService,
    private readonly eventEmitter: EventEmitter2,
  ) {}

  @Sse('chat/stream')
  chatStream(
    @Query('query') query: string,
    @Query('ttsSessionId') ttsSessionId?: string,
  ): Observable<{ data: string }> {
    const sessionId = ttsSessionId?.trim();
    if (sessionId) {
      const startEvent: AiTtsStreamEvent = { type: 'start', sessionId, query };
      this.eventEmitter.emit(AI_TTS_STREAM_EVENT, startEvent);
    }

    return from(this.aiService.streamChain(query, sessionId)).pipe(
      map((chunk) => ({ data: chunk })),
    );
  }
}

这里有两个关键点:

第一,接口同时服务了两种消费方

  • 前端通过 SSE 消费文本
  • TTS 服务通过事件总线消费同一份文本流

这意味着这不是一个“只给前端看的接口”,而是整个问答输出链路的上游。

第二,ttsSessionId 把文本流和音频流关联起来了

为什么这里要额外传 ttsSessionId

因为前端和后端之间其实维护着两条通道:

  • 一条是 EventSource 文本流
  • 一条是 WebSocket 音频流

如果没有一个会话 ID 把二者绑定起来,你根本没法知道“这一段文本应该送到哪条 TTS WebSocket 会话里去”。

这就是语音系统和普通文本聊天系统的本质差别之一:你必须处理跨协议、跨通道的会话一致性。


八、AI 模块看起来简单,但其实承担的是“文本流标准化”的职责

再看 AiService

// src/ai/ai.service.ts
@Injectable()
export class AiService {
  private readonly chain: Runnable;

  constructor(
    @Inject('CHAT_MODEL') model: ChatOpenAI,
    private readonly eventEmitter: EventEmitter2,
  ) {
    const prompt = PromptTemplate.fromTemplate('请回答以下问题:\n\n{query}');
    this.chain = prompt.pipe(model).pipe(new StringOutputParser());
  }

  async *streamChain(
    query: string,
    ttsSessionId?: string,
  ): AsyncGenerator<string> {
    try {
      const stream = (await this.chain.stream({ query })) as AsyncIterable<unknown>;
      for await (const rawChunk of stream) {
        let chunk = '';
        if (typeof rawChunk === 'string') {
          chunk = rawChunk;
        } else if (
          typeof rawChunk === 'number' ||
          typeof rawChunk === 'boolean' ||
          typeof rawChunk === 'bigint'
        ) {
          chunk = String(rawChunk);
        }
        if (!chunk) continue;

        if (ttsSessionId) {
          this.eventEmitter.emit(AI_TTS_STREAM_EVENT, {
            type: 'chunk',
            sessionId: ttsSessionId,
            chunk,
          });
        }
        yield chunk;
      }

      if (ttsSessionId) {
        this.eventEmitter.emit(AI_TTS_STREAM_EVENT, {
          type: 'end',
          sessionId: ttsSessionId,
        });
      }
    } catch (error) {
      if (ttsSessionId) {
        this.eventEmitter.emit(AI_TTS_STREAM_EVENT, {
          type: 'error',
          sessionId: ttsSessionId,
          error: error instanceof Error ? error.message : String(error),
        });
      }
      throw error;
    }
  }
}

很多文章写到这里,就会开始说“看,这里用了 LangChain”。但真正有价值的,不是它用了哪个框架,而是这个 Service 做了什么抽象。

我认为这段代码承担的是三层职责:

1)把模型输出统一成字符串流

模型底层返回的 chunk 未必永远是字符串,代码里显式对 number / boolean / bigint 做了兼容转换。这是很实在的工程写法:不要假设上游永远完美,尽量把消费层看到的输出标准化。

2)把文本流同时暴露给两类消费者

  • yield chunk 给 SSE
  • eventEmitter.emit(...) 给 TTS 侧

注意,这里不是“生成两次”,而是一份文本流,多方消费。这在实时系统里非常重要,否则你很容易出现“前端看到的内容”和“语音朗读的内容”不一致。

3)在流的生命周期上补全事件

除了 chunk 之外,它还显式发出了:

  • start
  • end
  • error

这意味着 TTS 侧不只是“收到一点字就说一点字”,而是知道:

  • 什么时候会话开始
  • 什么时候应该收尾
  • 什么时候要终止并清理资源

这就是完整流生命周期管理,而不是简单回调。


九、为什么流式 TTS 不能和 SSE 混在一起

很多第一次做这个系统的人会问:既然已经有 SSE 了,为什么不直接在 SSE 里把音频也发回来?

原因很简单:SSE 适合文本,不适合二进制音频。

如果你强行用 SSE 传音频,一般只有两条路:

  1. 把音频转 Base64 再发
  2. 伪装成文本分块传输

这两种路都不太好:

  • Base64 体积会膨胀
  • 前端要自己解码
  • 时序会更难控制
  • 对播放器非常不友好

而 WebSocket 天然适合持续传二进制帧,所以这里单独开一条 /speech/tts/ws 通道,是一个非常明确的工程判断:

文本输出归 SSE,音频输出归 WebSocket。

这不是“多开一个接口显得复杂”,而是“按数据类型选协议”。


十、真正决定这套系统可扩展性的,是事件桥接而不是 API 调用

这套项目里,我最认可的一点是没有把 TTS 逻辑硬塞进 AiControllerAiService 里,而是通过事件来桥接。

事件定义很简单:

// src/common/stream-events.ts
export const AI_TTS_STREAM_EVENT = 'ai.tts.stream';

export type AiTtsStreamEvent =
  | { type: 'start'; sessionId: string; query: string }
  | { type: 'chunk'; sessionId: string; chunk: string }
  | { type: 'end'; sessionId: string }
  | { type: 'error'; sessionId: string; error: string };

这段代码看似不复杂,但它非常值钱。因为它明确告诉你:AI 模块不关心 TTS 怎么连腾讯云、怎么推前端、怎么关连接,它只负责把“文本流生命周期”以事件方式发出去。

这带来两个工程收益:

1)低耦合

未来你要把腾讯云 TTS 换成别家,实现新的 listener 就行,AI 侧不用改。

2)可演进

今天事件被 TtsRelayService 消费,明天也可以被日志系统、审计系统、字幕系统、消息持久化系统消费。

这就是为什么我说,语音系统的核心不在“调 API”,而在“如何组织流”。


十一、TtsRelayService 才是这套方案最核心的后端实现

如果让我选一个最值得反复讲的文件,那一定是:

  • src/speech/tts-relay.service.ts

它的职责不是“做 TTS”这么简单,而是做了三层中继:

  1. 管理浏览器和服务端之间的 TTS 会话
  2. 管理服务端和腾讯云流式 TTS 之间的 WebSocket 连接
  3. 在文本分段、发送节奏、二进制转发之间做协调

先看客户端会话注册:

registerClient(clientWs: WebSocket, wantedSessionId?: string): string {
  const sessionId = wantedSessionId?.trim() || randomUUID();
  const existing = this.sessions.get(sessionId);
  if (existing) {
    this.closeSession(sessionId, 'client reconnected');
  }

  this.sessions.set(sessionId, {
    sessionId,
    clientWs,
    ready: false,
    pendingChunks: [],
    textBuffer: '',
    closed: false,
  });
  this.sendClientJson(clientWs, { type: 'session', sessionId });
  return sessionId;
}

这里不是简单“建立一个 ws 就完事”,而是创建了一个完整的 session 对象。里面几个字段都非常有用:

  • ready:腾讯云流式 TTS 是否已经可以收文本
  • pendingChunks:还没来得及送出去的待合成文本
  • textBuffer:当前累计但尚未完成分段的文本
  • closed:避免已关闭 session 继续写数据

这说明作者不是把 WebSocket 当成“收发消息”的黑盒,而是把它当成一个有状态流会话来管理。

再看事件消费:

@OnEvent(AI_TTS_STREAM_EVENT)
handleAiStreamEvent(event: AiTtsStreamEvent): void {
  const session = this.sessions.get(event.sessionId);
  if (!session) return;

  switch (event.type) {
    case 'start': {
      this.ensureTencentConnection(session);
      this.sendClientJson(session.clientWs, {
        type: 'tts_started',
        sessionId: session.sessionId,
        query: event.query,
      });
      break;
    }
    case 'chunk': {
      const chunk = event.chunk;
      if (!chunk) return;
      this.queueSpeakableSegments(session, chunk);
      break;
    }
    case 'end': {
      this.queueSpeakableSegments(session, '', true);
      this.flushPendingChunks(session);
      if (session.tencentWs && session.tencentWs.readyState === WebSocket.OPEN) {
        session.tencentWs.send(JSON.stringify({
          session_id: session.sessionId,
          action: 'ACTION_COMPLETE',
        }));
      }
      break;
    }
    case 'error': {
      this.sendClientJson(session.clientWs, {
        type: 'tts_error',
        message: event.error,
      });
      this.closeSession(session.sessionId, 'ai stream error');
      break;
    }
  }
}

这段逻辑的价值在于:它把 TTS 的行为建立在流生命周期事件之上,而不是建立在“文本一下子全来了”之上。

尤其是 end 分支非常重要:

  • 先强制把剩余文本分段刷出去
  • 再把待发送队列 flush
  • 最后给腾讯云发 ACTION_COMPLETE

这表示“输入流结束”的协议语义被显式处理了。如果少了这一步,常见后果就是最后一段语音永远不出。


十二、流式 TTS 最大的坑,不是连接,而是文本分段

很多人第一次做流式 TTS,会把模型每次吐出的 chunk 原封不动地送去合成。结果通常很糟糕:

  • 语音频繁断句
  • 一两个字就触发一次合成
  • 朗读节奏极不自然
  • 网络与云服务调用次数暴涨

所以,这个项目专门做了一个分段器:src/speech/tts-text-segmentation.ts

核心逻辑是:

const SENTENCE_END_RE = /[。!?!?;;\n]/;
const FORCE_SPLIT_RE = /[,,、::\s]/g;
const MIN_FORCE_SPLIT_LENGTH = 18;
const MAX_BUFFER_LENGTH = 48;

export function extractTtsSegments(
  input: string,
  forceFlush = false,
): { segments: string[]; rest: string } {
  let rest = input;
  const segments: string[] = [];

  while (rest) {
    const sentenceMatch = rest.match(SENTENCE_END_RE);
    if (sentenceMatch?.index !== undefined) {
      const endIndex = sentenceMatch.index + sentenceMatch[0].length;
      const segment = finalizeSegment(rest.slice(0, endIndex));
      if (segment) segments.push(segment);
      rest = rest.slice(endIndex).trimStart();
      continue;
    }

    const forcedSplitIndex = findForcedSplitIndex(rest);
    if (forcedSplitIndex > 0) {
      const segment = finalizeSegment(rest.slice(0, forcedSplitIndex));
      if (segment) segments.push(segment);
      rest = rest.slice(forcedSplitIndex).trimStart();
      continue;
    }

    break;
  }

  if (forceFlush) {
    const finalSegment = finalizeSegment(rest);
    if (finalSegment) segments.push(finalSegment);
    rest = '';
  }

  return { segments, rest };
}

这里体现了一个很重要的工程判断:

流式 TTS 追求的不是“字一出来马上说”,而是“在足够低延迟的前提下,让语音仍然像人在说话”。

这段策略基本分三层:

1)优先按句末标点切分

。!?; 这种天然句边界,最适合合成。因为朗读的停顿也会更自然。

2)句末标点迟迟不来时,允许在逗号、顿号、空格附近强制切分

这是为了控制首包延迟。否则模型一直生成长句,但迟迟不出句号,用户就会觉得“怎么还不说话”。

3)最后在流结束时强制 flush

如果最后一段没等到标点,也不能丢,必须在 forceFlush 时兜底发出去。

很多系统之所以语音体验差,不是模型不行,而是文本分段策略太粗糙


十三、腾讯云流式 TTS 的真正接入点,不是 SDK,而是 WebSocket 协议管理

TTS 中继服务还有一个核心职责:维护服务端到腾讯云的流式 WebSocket 连接。

例如这个签名 URL 构建:

private buildTencentTtsWsUrl(sessionId: string): string {
  const now = Math.floor(Date.now() / 1000);
  const params: Record<string, string | number> = {
    Action: 'TextToStreamAudioWSv2',
    AppId: this.appId,
    Codec: 'mp3',
    Expired: now + 3600,
    SampleRate: 16000,
    SecretId: this.secretId,
    SessionId: sessionId,
    Speed: 0,
    Timestamp: now,
    VoiceType: this.voiceType,
    Volume: 5,
  };

  const signStr = Object.keys(params)
    .sort()
    .map((k) => `${k}=${params[k]}`)
    .join('&');
  const rawStr = `GETtts.cloud.tencent.com/stream_wsv2?${signStr}`;
  const signature = createHmac('sha1', this.secretKey)
    .update(rawStr)
    .digest('base64');

  return `wss://tts.cloud.tencent.com/stream_wsv2?...`;
}

这段代码告诉你两件事:

  1. 流式 TTS 本质上是一个 WebSocket 协议接入问题
  2. 真正的复杂度不在“调某个函数”,而在“参数、签名、时序、收尾”这些协议细节

再比如文本发送逻辑:

private sendTencentChunk(session: ClientSession, text: string): void {
  if (!session.tencentWs || session.tencentWs.readyState !== WebSocket.OPEN) {
    session.pendingChunks.push(text);
    return;
  }

  session.tencentWs.send(
    JSON.stringify({
      session_id: session.sessionId,
      message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
      action: 'ACTION_SYNTHESIS',
      data: text,
    }),
  );
}

为什么这里不是“收到文本就立刻发”?因为发送之前必须确认:

  • 连接是否建立
  • 会话是否 ready
  • 上游是否还有待合成文本未处理

这就是为什么 pendingChunks 队列存在。它解决的是流的生产速度和消费速度不一致的问题。


十四、前端为什么要用 MediaSource + SourceBuffer,而不是直接拿到 MP3 再播

如果你只是做“整段 TTS 合成后再播放”,那确实可以直接把一个完整音频文件地址塞进 <audio>

但这个项目要解决的是:后端持续推音频二进制,前端边收到边播放。

这时,普通 audio 标签就不够了,你需要一个可以持续追加媒体数据的机制。这就是 MediaSource

看前端核心代码:

function prepareStreamingAudio () {
  if (ttsMediaSource && ttsSourceBuffer) return true;
  if (!window.MediaSource || !MediaSource.isTypeSupported("audio/mpeg")) {
    return false;
  }

  resetTtsPlayer();
  ttsMediaSource = new MediaSource();
  ttsObjectUrl = URL.createObjectURL(ttsMediaSource);
  ttsAudioEl.src = ttsObjectUrl;

  ttsMediaSource.addEventListener("sourceopen", () => {
    ttsSourceBuffer = ttsMediaSource.addSourceBuffer("audio/mpeg");
    ttsSourceBuffer.mode = "sequence";
    ttsSourceBuffer.addEventListener("updateend", flushTtsBufferQueue);
    flushTtsBufferQueue();
  }, { once: true });

  return true;
}

这段代码的含义是:

  • MediaSource 提供一个可动态追加媒体内容的容器
  • SourceBuffer 负责真正追加二进制音频片段
  • sequence 模式意味着按顺序拼接音频流

接下来是关键的队列刷新逻辑:

function flushTtsBufferQueue () {
  if (!ttsSourceBuffer || !ttsMediaSource) return;
  if (ttsSourceBuffer.updating) return;

  if (ttsPendingBuffers.length > 0) {
    const next = ttsPendingBuffers.shift();
    if (next) {
      ttsSourceBuffer.appendBuffer(next);
      if (ttsAudioEl.paused) {
        ttsAudioEl.play().catch(() => {
          setStatus("语音已就绪,请点击播放器开始播报");
        });
      }
    }
    return;
  }

  if (ttsStreamFinal && ttsMediaSource.readyState === "open") {
    try {
      ttsMediaSource.endOfStream();
    } catch {
      // ignore
    }
  }
}

这一段是前端流式音频播放的关键:

  • 如果 SourceBuffer 还在更新,就不能继续 append
  • 如果还有待处理音频帧,就一帧一帧追加
  • 如果流结束了并且队列空了,再 endOfStream

很多人流式播放做不顺,问题就出在这里:不是收不到数据,而是 append 时序没管好。


十五、为什么前端要先建立 TTS WebSocket,再发起 AI SSE 请求

public/asr-stream.html 里,有一个很重要的设计顺序:

await ensureTtsConnection();
await streamAiReply(trimmed);

这个顺序不是随便写的。

原因是:SSE 一旦启动,大模型文本可能马上就开始输出,而文本一输出,服务端就会尝试把 chunk 转发给 TTS。如果这时候前端的 TTS WebSocket 还没准备好,就会出现:

  • 文字已经开始显示
  • 语音通道 session 还没拿到
  • 结果最前面几段音频可能丢失或延迟很大

所以更稳妥的做法是:

  1. 先准备好 TTS WS 通道和 ttsSessionId
  2. 再发起 EventSource 文本流请求
  3. 文本流和音频流用同一个 session 关联

这就是实时系统里的经典原则:

先准备消费端,再启动生产端。


十六、配置项不是“填上就行”,它们决定了系统的行为边界

这个项目里有两组配置文件:

  • src/ai/ai.config.ts
  • src/speech/speech.config.ts

比如模型配置:

const apiKey =
  configService.get<string>('DASHSCOPE_API_KEY') ??
  configService.get<string>('OPENAI_API_KEY');
const baseURL =
  configService.get<string>('DASHSCOPE_BASE_URL') ??
  configService.get<string>('OPENAI_BASE_URL') ??
  'https://dashscope.aliyuncs.com/compatible-mode/v1';
const model = configService.get<string>('MODEL_NAME') ?? 'qwen-plus';

这段写法的工程意义是:

  • 兼容 OpenAI 风格 SDK
  • 允许底层实际使用通义千问兼容接口
  • 模型供应商可替换,但上层调用保持稳定

这比把云厂商调用细节直接写死在业务逻辑里要强得多。

再比如语音配置:

const secretId =
  configService.get<string>('TENCENT_SECRET_ID') ??
  configService.get<string>('SECRET_ID');
const secretKey =
  configService.get<string>('TENCENT_SECRET_KEY') ??
  configService.get<string>('SECRET_KEY');
const appId =
  configService.get<string>('TENCENT_APP_ID') ??
  configService.get<string>('APP_ID');

这个 fallback 设计也挺实用:既兼容语音模块自己的命名,又兼容已有环境变量命名,方便从脚本实验迁移到 NestJS 服务。

此外,这些参数背后都有实际含义:

  • MODEL_NAME:决定回答质量、速度与成本
  • TTS_VOICE_TYPE:决定音色,不只是“换个声音”这么简单,也会影响风格一致性
  • SampleRate:影响音频兼容性与传输成本
  • Codec: mp3:直接决定浏览器流式播放的适配路线

参数不是配置表,而是系统行为控制面。


十七、这套方案做对了什么

如果从技术博客的视角总结,这个项目最值得肯定的地方有五个。

1. 先把 ASR 和完整语音链路分开验证

asr.html 专注于验证录音上传与识别,asr-stream.html 才负责完整交互。这样调试效率非常高。

2. 给文本和音频分别选了合适的协议

  • 文本:SSE
  • 音频:WebSocket

这是正确的边界划分。

3. 用事件总线把 AI 和 TTS 解耦

这一步让整个系统有了继续演化的空间。

4. 文本分段策略考虑了“可听性”

这意味着作者不是只想“跑通”,而是在意真实体验。

5. 前端流式播放没有偷懒

很多 Demo 到 TTS 就退回“整段音频播放”,而这个项目真的做了 MediaSource + SourceBuffer,这是它最接近真实产品体验的地方。


十八、但如果你要把它推进到真实业务,还差哪些东西

这套方案已经很适合作为教程和演示项目,但离生产级还有明显距离。

1. 目前 ASR 仍然是上传式,不是流式

这意味着:

  • 用户必须说完再等识别
  • 无法做边说边识别
  • 无法做更自然的打断式交互

如果你的场景是客服、通话机器人、实时陪练,这会成为瓶颈。

2. 缺少 VAD(静音检测)

目前录音停止主要靠用户点击按钮。真实产品里通常会结合静音检测、自动截断、超时策略,减少用户操作成本。

3. 缺少更完整的错误恢复

比如:

  • 腾讯云 TTS 中途断开如何重连
  • SSE 断流后是否要补偿
  • session 超时如何清理
  • 客户端页面刷新后旧连接如何回收

4. 缺少鉴权与限流

语音系统很容易被滥用,因为它天然涉及高成本外部服务。如果不加认证、配额和频率限制,线上风险会很高。

5. 缺少可观测性

真正的语音体验优化,一定离不开下面这些指标:

  • 录音时长
  • ASR 耗时
  • LLM 首 token 延迟
  • TTS 首包延迟
  • 前端开始播放时间
  • 整体对话完成耗时

如果没有这些指标,你只能凭感觉优化,最后会越改越盲。


十九、这套方案最容易踩的坑,我建议你提前避开

坑 1:前端录音格式和云端识别格式不一致

这是最常见的问题之一。一定要确认:

  • 浏览器录了什么格式
  • Blob.type 是什么
  • 服务端告诉 ASR 的 VoiceFormat 是什么

这三者必须对齐。

坑 2:把每个 token 都立即送进 TTS

这样会让语音像卡壳一样,一两个字就停一次。分段策略一定要做。

坑 3:没有处理浏览器自动播放限制

前端已经做了:

ttsAudioEl.play().catch(() => {
  setStatus("语音已就绪,请点击播放器开始播报");
});

这就是在兜 autoplay 被拦截的情况。很多人本地测得好好的,上线后却发现“没声音”,原因就在这。

坑 4:文本流和音频流没有共享 session

如果没有 ttsSessionId 这层关联,多人并发时非常容易串音。

坑 5:流结束时没有明确收尾

不管是 SSE、TTS 还是前端 MediaSource,你都不能只处理“进行中”,还必须处理:

  • 何时 flush 剩余数据
  • 何时发送 complete
  • 何时 endOfStream
  • 何时 close session

实时系统最怕的不是报错,而是没报错但尾巴没收干净


二十、如果是我继续演进这套系统,我会怎么做

如果你的目标不是只做 Demo,而是把它向更真实的产品推进,我会建议按下面的顺序演进。

第一步:补观测,而不是先改架构

先量化链路延迟,知道瓶颈在哪。

第二步:把上传式 ASR 升级成流式 ASR

这样可以减少等待感,让语音输入更像自然对话。

第三步:增加打断能力

比如:

  • 用户说新问题时,当前 TTS 立刻中断
  • 前端停止播放并清空后续 buffer
  • 服务端结束当前 session

第四步:把 TTS Relay 独立成可复用的语音网关

当前它是项目内 service,但未来完全可以变成一个独立语音中继层,为多个 AI 应用复用。

第五步:把知识库/RAG 接进来

当语音链路稳定后,真正决定业务价值的就不再是“会不会说话”,而是“回答是否可靠”。

到那一步,系统的重点就会从“多流协同”继续延伸到“检索增强 + 语音交互”的组合能力。


二十一、我的最终判断:这类语音 Agent 的默认方案,应该怎么选

如果你的目标是:

  • 做一个可演示、可讲解、可扩展的语音 AI 助手
  • 让用户获得“能说、能看、能听”的闭环体验
  • 不想一上来就被全双工实时语音系统的复杂度拖死

那么我认为这套方案是非常合适的默认起点:

  • 输入:上传式 ASR
  • 文本输出:SSE
  • 语音输出:WebSocket 流式 TTS
  • 服务端协同:事件桥接
  • 前端播放:MediaSource + SourceBuffer

它不是终极架构,但它在复杂度、教学价值和可演进性之间取得了很好的平衡。

反过来说,如果你的目标是:

  • 电话机器人
  • 实时会议同传
  • 全双工强交互语音陪练
  • 极低延迟语音中断

那这套方案就只是过渡阶段,你迟早会走向:

  • 流式 ASR
  • 更强的状态机
  • 更复杂的音频管线
  • 更严格的会话与时延控制

所以,不要把“是否先进”当成选型标准,而要把“当前场景最需要解决什么问题”当成标准。


总结

回到文章开头的那个结论。

很多人做语音 AI 助手时,会把注意力放在:

  • 接哪家 ASR
  • 接哪家大模型
  • 接哪家 TTS

这些当然重要,但它们不是核心矛盾。

真正决定体验和工程质量的,是你能不能把下面这几件事组织成一个协同系统:

  • 录音上传与格式适配
  • 文本流式生成
  • 文本到语音的分段策略
  • SSE 与 WebSocket 的职责分离
  • 服务端事件桥接
  • 前端流式音频播放
  • 生命周期、会话和收尾处理

换句话说,语音 Agent 的本质不是“多接两个接口”,而是“多条流如何在正确的时机,用正确的协议,完成一次完整协作”。

而这,正是这份 NestJS 项目最值得学习的地方。

如果你后面还想继续扩展,我建议下一篇就顺着这条线往下写:

  1. 把上传式 ASR 升级成流式 ASR
  2. 增加打断、重说与会话中止能力
  3. 接入 RAG,让它从“会说话”升级成“会回答业务问题”

到那时,这就不只是一个语音 Demo,而会成为一个真正有业务形态的 AI 应用底座。

Taro小程序生成分享海报解决方案

作者 朱良
2026年5月5日 15:27

场景:在Taro开发中,在商品/职位/文章的详情页需要转发生成一个png海报,如果在Web开发中,直接html+css写海报样式,Vue/Teact中通过Props传入详情信息就可以,然后定位到屏幕视口外,通过html2canvas生成和导出海报样式,但是在小程序中,无法使用html2canvas

尝试寻找第三方库

wxml2canvas/taro-wxml2canvas可以吗,我使用过,证明是不可行的,且不说taro导入第三方原生组件十分麻烦,在Taro4中,和Taro3,Taro2基本经历过多次更新,wxml2canvas/taro-wxml2canvas库已经很久没有维护,实测不可行。

taro-plugin-canvas和taro3-canvas之类的库长时间不更新无法使用;

taro-html-parser和wxParse库也是长时间不更新无法使用;

mp-html这个还在维护库只支持uniapp和原生小程序;

似乎Taro被市场抛弃了,一个纯前端解决生成分享海报的库都没有吗?

尝试Canvas绘制

其实海报就是一个png图片,canvas一样可以绘制,Taro是支持canvas的,但canvas绘制的海报在多文字处理上非常麻烦,样式调整费劲,其实先写html,让AI转为canvas代码,也是还原度低,对于复杂海报样式很难实现,更难二次调整;

尝试SVG绘制

SVG是在浏览器上可行的,UI设计师出稿一个SVG模板,详情页信息(文字/图片)直接拼接/导出到SVG中,生成海报图片,但是小程序不支持SVG,所以依然不可行;

最终解决方案

使用Taro原生的Snapshot组件,只需要写原生的Taro代码,会被截图为画布,渲染结果导出成图片,需要局部开启Skyline模式,但是总算是实现了浏览器中Html2Canvas的效果,参考文档 docs.taro.zone/docs/apis/s…

参考代码

sharePoster.tsx

import { useRef, useCallback } from 'react'
import Taro from '@tarojs/taro'
import { View, Text, Button, Image } from '@tarojs/components'
import './poster.css'

export default function PosterPage() {
  // 1. 创建 ref 获取海报容器节点(Skyline 节点)
  const posterRef = useRef<View>(null)
  // 2. 声明 Snapshot 实例
  let snapshotInstance: Taro.Snapshot | null = null

  // 🔥 核心:生成海报(截图)
  const createPoster = useCallback(async () => {
    try {
      // 校验节点
      if (!posterRef.current) {
        Taro.showToast({ title: '节点不存在', icon: 'none' })
        return
      }

      // 1. 初始化 Snapshot 实例(官方标准用法)
      snapshotInstance = Taro.createSnapshot()

      // 2. 获取 Skyline 节点的 ID(必须通过 ref 获取)
      const nodeId = posterRef.current._nodeId

      // 3. 执行截图(核心 API)
      const res = await snapshotInstance.takeSnapshot({
        nodeId, // 要截图的 Skyline 节点 ID
        // 可选:自定义截图尺寸/质量
        quality: 1,
        type: 'png'
      })

      // 4. 截图成功:res.tempFilePath 是海报临时路径
      const posterPath = res.tempFilePath
      console.log('✅ 海报生成成功:', posterPath)

      // 可选:预览海报 / 保存到相册
      Taro.showToast({ title: '生成成功', icon: 'success' })
      Taro.previewImage({ urls: [posterPath] })

    } catch (err) {
      console.error('❌ 海报生成失败:', err)
      Taro.showToast({ title: '生成失败', icon: 'none' })
    }
  }, [])

  return (
    <View className='page-container'>
      {/* 🔥 海报容器:Skyline 渲染节点,ref 绑定 */}
      <View ref={posterRef} className='poster-box'>
        {/* 这里写Taro原生代码,海报内容(可自定义:图片、文字、头像等) */}
      </View>

      {/* 生成按钮 */}
      <Button className='create-btn' onClick={createPoster}>
        生成海报
      </Button>
    </View>
  )
}

需要在专门的分享页开启skyline xxx.config.ts

export default {
  navigationBarTitleText: '生成分享海报',
  skyline: { enable: true },
  defaultRenderEngine: 'skyline',
  disableScroll: true
}

建议在详情页点击分享->生成分享海报按钮时,跳转该页面,生成完成后携带res.tempFilePath临时路径 返回详情页,然后展示和下载海报图片。因为skyline模式的无法使用第三方UI组件,建议单独独立为一个局部页面。

HarmonyOS AbilityStage 实战:别把启动参数散落在每个页面里

作者 李游Leo
2026年5月5日 15:07

鸿蒙应用做到后面,真正让人头疼的,往往不是某个页面写得丑,也不是某个按钮样式没调好,而是入口越来越多以后,启动逻辑开始乱。

桌面图标能进来,服务卡片能进来,通知能进来,Deep Link 能进来,应用内部还可能用 Want 拉起另一个 UIAbility。第一版代码一般都挺朴素:在首页 aboutToAppear 里读一下参数,判断要不要跳详情页。刚开始没问题,甚至看起来挺清爽。

但需求一多,首页就很容易变成“入口垃圾桶”。

这里判断通知来源,那里判断服务卡片参数,后面又补一个外部链接解析。冷启动的时候还能凑合,二次拉起、回前台、横竖屏切换、任务栈恢复一来,问题就开始变得有点玄学了。

我之前见过一个挺典型的线上问题:用户从服务卡片点进来,本来应该打开某个订单详情页,结果偶尔落到首页;用户从外部链接再次拉起应用,页面没刷新;还有更隐蔽的,应用已经在后台了,新的 Want 进来以后,全局初始化又跑了一遍,监听注册了两次,后面同一个事件回调两遍。

查日志的时候也挺难受。每个页面都觉得自己只是“顺手处理一下入口参数”,最后谁也说不清这次启动到底是桌面启动、卡片启动,还是二次拉起。

这种问题不能靠再加几个 if 硬顶。Stage 模型下,AbilityStage、Want、UIAbility 这条链路本来就应该承担启动治理的职责。只是很多项目写着写着,把它们当成了“系统自动生成的模板文件”,真正的业务入口反而全塞到页面里了。

image.png

AbilityStage / Want / UIAbility,别分开看

单独看这几个概念,其实都不复杂。

AbilityStage 是 Module 级别的组件管理器,HAP 首次加载时会创建它。UIAbility 是带界面的应用组件,负责创建、销毁、前后台切换这些生命周期。Want 是组件之间传递信息的载体,启动目标、参数、action、uri 这些东西都可以从里面拿。

但工程里真正容易出问题的地方,不是“某个回调怎么写”,而是边界没划清。

我一般会这么分:

  • AbilityStage 管进程级、模块级的东西,比如轻量初始化、Specified 启动模式分流、全局依赖准备。
  • Want 只当入口信息,进来以后尽快转成业务能理解的结构。
  • UIAbility 管窗口、生命周期和启动载荷注入。
  • ArkUI 页面只消费归一后的业务参数,不直接解析原始 Want。

这几条边界看着有点啰嗦,但真到项目里很有用。

后面新增通知入口、服务卡片入口、Deep Link 入口时,不需要每个页面跟着改。入口逻辑集中在入口层,页面只关心“我要展示什么业务状态”。这才比较像一个能长期维护的结构。

先把原始 Want 收敛成 LaunchPayload

很多启动混乱,根源就是页面直接读 Want。

页面一旦开始知道太多入口细节,就会慢慢变成半个路由中心。今天读 scene,明天读 from,后天再补一个 uri,最后首页里一堆参数判断,谁也不敢动。

我更习惯定义一个中间结构,叫 LaunchPayload。它不追求把 Want 的所有字段都复刻一遍,只留下业务真正需要的东西。

// common/launch/LaunchPayload.ets
export enum LaunchScene {
  NORMAL = 'normal',
  CARD = 'card',
  NOTIFICATION = 'notification',
  DEEP_LINK = 'deep_link',
  INTERNAL = 'internal'
}

export interface LaunchPayload {
  scene: LaunchScene
  targetPage: string
  bizId?: string
  uri?: string
  from?: string
  rawAction?: string
  extras: Record<string, string>
  receivedAt: number
}

这里有个小取舍:extras 我只放字符串。

不是说 Want 里不能带别的类型,而是启动参数最好别变成一个“万能对象”。入口传来的东西越杂,页面兜底越麻烦。真要复杂对象,建议传 id,再让业务层去查详情。

启动参数要负责的是“把用户带到哪”,不是“把整个业务现场都搬进来”。这句话挺重要,很多入口混乱都是从这里开始的。

写一个 Want 解析器,别让页面自己猜

下面这个 LaunchPayloadParser 就是专门干脏活的。

它负责把不同来源的 Want 参数,统一整理成业务可读的结构。页面拿到的不是一坨原始参数,而是一份已经归一过的启动载荷。

// common/launch/LaunchPayloadParser.ets
import { Want } from '@kit.AbilityKit'
import { LaunchPayload, LaunchScene } from './LaunchPayload'

export class LaunchPayloadParser {
  static parse(want: Want | undefined): LaunchPayload {
    const params = want?.parameters ?? {}
    const uri = want?.uri ?? ''
    const action = want?.action ?? ''

    const scene = this.parseScene(params, uri, action)
    const bizId = this.readString(params, 'bizId')
    const from = this.readString(params, 'from')

    return {
      scene,
      targetPage: this.resolveTargetPage(scene, bizId, uri),
      bizId,
      uri,
      from,
      rawAction: action,
      extras: this.pickSafeExtras(params),
      receivedAt: Date.now()
    }
  }

  private static parseScene(params: Record<string, Object>, uri: string, action: string): LaunchScene {
    const scene = this.readString(params, 'scene')

    if (scene === 'card') {
      return LaunchScene.CARD
    }

    if (scene === 'notification') {
      return LaunchScene.NOTIFICATION
    }

    if (uri.length > 0) {
      return LaunchScene.DEEP_LINK
    }

    if (action.length > 0) {
      return LaunchScene.INTERNAL
    }

    return LaunchScene.NORMAL
  }

  private static resolveTargetPage(scene: LaunchScene, bizId?: string, uri?: string): string {
    if (scene === LaunchScene.DEEP_LINK && uri) {
      return this.resolveDeepLink(uri)
    }

    if (bizId && bizId.length > 0) {
      return 'pages/Detail'
    }

    return 'pages/Home'
  }

  private static resolveDeepLink(uri: string): string {
    // 这里只做简单示例。
    // 真实项目里建议做白名单解析,别让外部 uri 任意指定页面路径。
    if (uri.includes('/detail')) {
      return 'pages/Detail'
    }

    if (uri.includes('/search')) {
      return 'pages/Search'
    }

    return 'pages/Home'
  }

  private static pickSafeExtras(params: Record<string, Object>): Record<string, string> {
    const allowList: string[] = ['tab', 'keyword', 'source']
    const extras: Record<string, string> = {}

    allowList.forEach((key: string) => {
      const value = this.readString(params, key)
      if (value !== undefined) {
        extras[key] = value
      }
    })

    return extras
  }

  private static readString(params: Record<string, Object>, key: string): string | undefined {
    const value = params[key]
    return typeof value === 'string' ? value : undefined
  }
}

这段代码看起来确实有点啰嗦,但它救命的地方也就在这。

所有入口先过一层白名单,不允许外部参数直接控制内部页面路径;所有参数先转成可控结构,不让页面到处写 want.parameters?.xxx。项目越大,这种“看起来多一层”的代码越值钱。

我自己比较怕那种“先凑合一下”的入口代码。因为入口一旦散了,后面不是不好重构,是没人敢重构。用户从哪里进来、带了什么参数、应该落到哪个页面,全都藏在几个页面生命周期里,查一次问题能把人查麻。

AbilityStage:只做进程级初始化和启动分流

AbilityStage 很容易被误用。

有些项目会把一堆业务初始化都丢进去:数据库、网络、埋点、用户信息、远程配置,全塞上。冷启动一慢,大家又开始怀疑系统回调慢,或者怀疑首屏性能不行。其实很多时候,是自己把太重的东西放错地方了。

我的建议比较保守:AbilityStage 只做轻量、必要、进程级的事情。

比如日志初始化、依赖容器准备、Specified 启动模式的 key 分流。需要 IO、需要用户态、需要网络的初始化,不要一股脑压在这里。

// entry/src/main/ets/entryability/MyAbilityStage.ets
import { AbilityStage, Want } from '@kit.AbilityKit'
import { hilog } from '@kit.PerformanceAnalysisKit'
import { LaunchPayloadParser } from '../common/launch/LaunchPayloadParser'

export default class MyAbilityStage extends AbilityStage {
  onCreate(): void {
    // 这里适合做轻量级、进程级准备。
    // 不建议在这里做耗时网络请求,也别依赖页面上下文。
    hilog.info(0x0000, 'AppStage', 'AbilityStage onCreate')
  }

  onAcceptWant(want: Want): string {
    // Specified 启动模式下,系统会通过这个 key 决定复用哪个 UIAbility 实例。
    // key 设计要稳定,不要把时间戳这种随机值塞进去。
    const payload = LaunchPayloadParser.parse(want)

    if (payload.bizId && payload.bizId.length > 0) {
      return `detail_${payload.bizId}`
    }

    return 'main'
  }
}

onAcceptWant 这块很容易写错。

我见过有人为了“保证每次都是新的”,直接返回时间戳。短期看,好像解决了页面不刷新的问题;长期看,其实是把实例复用搞乱了。

Specified 模式要的不是“每次都新开”,而是“同一类业务复用同一个目标实例”。key 的粒度要跟业务场景一致。比如详情页按 id 分流,主入口统一回到 main。别为了省事,把 key 写成一个随机数,那后面任务栈和实例管理都会跟着乱。

UIAbility:冷启动和二次拉起要走同一套逻辑

UIAbility 的生命周期更贴近业务。

用户冷启动时会走 onCreate,窗口创建时走 onWindowStageCreate;应用已有实例再次被拉起时,常见场景会走 onNewWant。如果只在 onCreate 里处理参数,二次拉起就很容易漏。

我一般会在 UIAbility 里保留一个当前启动载荷,然后用 LocalStorage 注入页面。页面不直接碰 Want。

// entry/src/main/ets/entryability/EntryAbility.ets
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'
import { window } from '@kit.ArkUI'
import { hilog } from '@kit.PerformanceAnalysisKit'
import { LaunchPayload } from '../common/launch/LaunchPayload'
import { LaunchPayloadParser } from '../common/launch/LaunchPayloadParser'

export default class EntryAbility extends UIAbility {
  private storage: LocalStorage = new LocalStorage()
  private latestPayload?: LaunchPayload

  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    this.latestPayload = LaunchPayloadParser.parse(want)
    this.storage.setOrCreate('launchPayload', this.latestPayload)

    hilog.info(0x0000, 'EntryAbility',
      `onCreate scene=${this.latestPayload.scene}, target=${this.latestPayload.targetPage}`)
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    // 页面首次加载时,把归一后的启动载荷注入进去。
    windowStage.loadContent('pages/Home', this.storage, (err) => {
      if (err.code) {
        hilog.error(0x0000, 'EntryAbility',
          `loadContent failed, code=${err.code}, message=${err.message}`)
        return
      }

      hilog.info(0x0000, 'EntryAbility', 'loadContent success')
    })
  }

  onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    // 已有实例被再次拉起时,不要重复跑全局初始化。
    // 这里只更新启动载荷,让页面或路由层消费。
    this.latestPayload = LaunchPayloadParser.parse(want)
    this.storage.setOrCreate('launchPayload', this.latestPayload)

    hilog.info(0x0000, 'EntryAbility',
      `onNewWant scene=${this.latestPayload.scene}, target=${this.latestPayload.targetPage}`)
  }

  onForeground(): void {
    // 恢复轻量资源,例如刷新当前会话状态。
    // 不建议在这里重复解析启动参数。
    hilog.info(0x0000, 'EntryAbility', 'onForeground')
  }

  onBackground(): void {
    // 暂停耗时任务、保存必要状态。
    hilog.info(0x0000, 'EntryAbility', 'onBackground')
  }

  onDestroy(): void {
    // 取消监听、释放 UIAbility 级资源。
    hilog.info(0x0000, 'EntryAbility', 'onDestroy')
  }
}

这段的重点不是 loadContent,而是 onCreateonNewWant 共用同一个解析器。

冷启动和二次拉起不应该分裂成两套业务规则。你今天忘了在 onNewWant 补一个参数,明天就会遇到那种特别烦的现象:应用杀掉后正常,后台唤起异常;从桌面进来正常,从通知进来异常。

这类问题通常不好测,因为测试同学一旦把应用杀掉重进,问题就消失了。

页面只消费 LaunchPayload,不碰原始 Want

页面里可以用 @LocalStorageProp 拿到启动载荷。至于它来自桌面、卡片还是 Deep Link,页面不需要知道太多。

// entry/src/main/ets/pages/Home.ets
import { LaunchPayload, LaunchScene } from '../common/launch/LaunchPayload'

@Entry
@Component
struct Home {
  @LocalStorageProp('launchPayload') launchPayload?: LaunchPayload

  @State tip: string = '正常进入首页'

  aboutToAppear(): void {
    this.consumeLaunchPayload(this.launchPayload)
  }

  onPageShow(): void {
    // 从后台回到前台时,页面可做轻量刷新。
    // 不建议在这里重新猜测启动来源。
  }

  private consumeLaunchPayload(payload?: LaunchPayload): void {
    if (!payload) {
      return
    }

    if (payload.scene === LaunchScene.CARD) {
      this.tip = `从服务卡片进入,业务ID:${payload.bizId ?? '无'}`
      return
    }

    if (payload.scene === LaunchScene.DEEP_LINK) {
      this.tip = `从外部链接进入:${payload.uri ?? ''}`
      return
    }

    if (payload.bizId) {
      this.tip = `准备打开详情:${payload.bizId}`
      return
    }

    this.tip = '正常进入首页'
  }

  build() {
    Column({ space: 16 }) {
      Text('HarmonyOS 启动治理示例')
        .fontSize(22)
        .fontWeight(FontWeight.Bold)

      Text(this.tip)
        .fontSize(15)
        .fontColor('#666666')

      if (this.launchPayload?.targetPage === 'pages/Detail') {
        Button('进入详情')
          .onClick(() => {
            // 项目里可以交给统一 RouterService,
            // 不建议在每个页面散落路由拼接。
          })
      }
    }
    .padding(24)
    .width('100%')
  }
}

这里有个问题经常有人问:为什么不在 UIAbility 里直接路由到详情页?

可以,但要看项目的路由方案。

如果你的登录态、弹窗恢复、页面栈管理、Tab 状态都在页面层或者 RouterService 里,UIAbility 直接跳详情页,有时候反而会绕过业务状态。我的做法是,UIAbility 负责把“启动意图”送到页面,真正的业务路由交给应用内部的 RouterService。

这样页面栈归页面,入口归入口,边界比较清楚。后面要改路由策略,也不用去生命周期回调里翻一堆代码。

生命周期不是背回调顺序,而是定职责

UIAbility 启动到前台时,会触发 onCreate()onWindowStageCreate()onForeground() 这一类生命周期回调。文档顺序看懂不难,难的是每个回调里该放什么、不该放什么。

image.png

我通常按下面这个口径拆:

onCreate:读取 Want,生成 LaunchPayload,准备 UIAbility 级状态。别在这里直接操作还没创建的窗口。

onWindowStageCreate:加载页面,注入 LocalStorage,绑定窗口相关逻辑。窗口级别的东西放这里,不要提前。

onNewWant:已有实例再次被拉起时更新启动意图。这里不要重复初始化全局服务,也不要重复注册监听。

onForeground:应用回到前台,恢复轻量资源,比如刷新会话、恢复播放按钮状态。不要把它当第二个 onCreate

onBackground:暂停耗时任务,保存必要状态。能停的就停,尤其是轮询、定位、长连接这类逻辑。

onDestroy:取消监听、释放资源、打点收尾。不要假设它每次都一定按你期望的时机触发,但该写的清理还是要写。

职责分清以后,很多“偶发问题”就不再玄学了。

比如二次拉起没刷新,就去看 onNewWant;前后台切换重复初始化,就查 onForeground;窗口相关状态异常,就去看 onWindowStageCreate。至少排查方向是明确的,不至于在首页、详情页、路由工具类之间来回翻。

常见坑位:这些地方真的容易埋雷

1. 首页承担了太多入口职责

首页读 Want、首页解析 URI、首页判断通知、首页处理卡片参数,短期确实快,长期基本一定乱。首页是 UI,不是入口网关。

这个坑很多项目都会踩,因为第一版最方便的地方就是首页。但方便不是没有代价,只是代价晚点来。

2. onNewWant 忘了处理

这类 bug 很烦:冷启动正常,后台再次拉起异常;杀掉应用再试,又正常了。

原因往往是只在 onCreate 解析了 Want,已有实例二次拉起时没有更新业务载荷。开发自测时如果习惯每次都杀进程,很容易漏掉。

3. Specified key 设计太随意

onAcceptWant 返回的 key 应该稳定、有业务含义。

随机 key 会让实例复用不可控;key 粒度太粗,会导致不同业务入口抢同一个实例。这个地方别偷懒,最好一开始就按业务场景定规则。

4. 外部参数直接控制页面路径

Deep Link 或外部 Want 里带一个 path,然后你直接 router 到对应页面,这个写法很危险。

至少要做白名单映射。外部参数只能表达意图,不能拿到内部路由的完全控制权。尤其是对外开放的链接入口,更不能相信传进来的每一个字段。

5. 前后台切换重复初始化

onForeground 不是重启。

回前台时做轻量恢复可以,别把登录初始化、数据库初始化、全局监听注册再跑一遍。重复初始化这种问题前期不明显,后面会变成重复请求、重复回调、状态错乱。

6. 页面销毁后,异步回调还在改状态

启动之后经常伴随异步动作,比如查详情、拉配置、校验登录。页面销毁后回调还更新状态,就会出现偶现闪跳或者日志报错。

建议给异步任务加 taskId,或者在页面消失时取消。别让旧任务回来覆盖新页面。

稳定性优化:给启动链路加一个任务号

如果启动入口多,建议给每次 LaunchPayload 分配一个自增序号。后到的启动意图优先级更高,旧任务回来不能覆盖新状态。

这个设计不复杂,但很管用。

// common/launch/LaunchSession.ets
import { LaunchPayload } from './LaunchPayload'

export class LaunchSession {
  private currentSeq: number = 0
  private latest?: LaunchPayload

  next(payload: LaunchPayload): number {
    this.currentSeq += 1
    this.latest = payload
    return this.currentSeq
  }

  isLatest(seq: number): boolean {
    return seq === this.currentSeq
  }

  getLatest(): LaunchPayload | undefined {
    return this.latest
  }
}

页面或 RouterService 使用时:

const seq = launchSession.next(payload)

this.loadBizData(payload).then(() => {
  if (!launchSession.isLatest(seq)) {
    // 旧入口触发的异步结果,不允许覆盖新入口状态。
    return
  }

  // 更新页面或路由状态
})

用户从通知点进来,半秒后又从服务卡片点进来,两次入口都可能触发异步加载。没有序号保护,旧请求后回来就能把新页面状态盖掉。

很多“偶尔跳错详情”的问题,本质就是旧任务覆盖了新任务。这个问题不加日志很难看出来,加了任务号以后,一眼就能看出是谁回来晚了。

哪些场景更适合这么做

这套启动治理不是所有 demo 都需要。一个只有首页和设置页的小工具,没必要上来就搞一堆入口层封装。

但下面几类应用,我建议早点做:

  • 内容类应用:从通知、搜索、外部链接进入文章或视频详情。
  • 办公类应用:服务卡片进入审批、待办、日程详情。
  • 电商和本地生活:活动链接、订单通知、桌面快捷入口都要落到不同业务页。
  • 工具类应用:从分享、文件打开、Deep Link 进入不同编辑模式。
  • 多 UIAbility 应用:主界面、独立编辑器、沉浸式展示页需要不同启动实例策略。

只要入口超过两个,就建议尽早把 Want 解析收敛掉。别等首页堆到几百行再重构,到那时候你已经分不清哪段逻辑是给哪个入口补的了。

结尾:入口治理写早一点,后面少还很多债

HarmonyOS 的 AbilityStage、Want、UIAbility,不只是应用模板里那几个默认文件。它们更像应用的入口骨架。

骨架稳了,页面和业务路由才不会到处补洞。

我的习惯是:AbilityStage 只做轻量进程级准备和分流;Want 进入应用后马上转成 LaunchPayload;UIAbility 统一处理冷启动和二次拉起;页面只消费归一后的业务参数。

看着多了几个类,但后面加入口、查问题、做灰度、做埋点,都会轻松很多。

别把启动参数散落在每个页面里。页面一多,谁都不愿意碰;入口一多,问题就开始像玄学。启动链路这种东西,越早工程化,越不容易在上线后给自己挖坑。

用时7天,花费30元,我vibe coding这个网站

作者 Hooray
2026年5月5日 14:54

动机

我有一款 2 年没更新的产品 One-step-admin ,开发初衷是为了更高效的进行跨业务模块操作,但当时设计的交互方式如今回看并不理想。

四月初的时候,我突然有了一个新的交互思路,但那会更多精力集中在发布 Fantastic-admin v6.0 这个全新版本上。但是我让 gemini 先产出了一份原型,验证了一下交互方案的可行性。

原型

相信已经有人看出端倪了,是的,交互这块参考了 MacOS 上的台前调度。

执行

直到五一节前,我准备开始开发这款产品,并且我希望体验一下完全 vibe coding 是什么感觉。

我暂定名称为 One-step Console,中文叫一步控制台,它算是 One-step-admin 的迭代产品,因为目标其实是一致的,还是为了更高效的进行跨业务模块操作。

目前产品还没有对外发布,我先截取了一些图片,方便你了解产品的核心交互设计。

切换舞台

所有功能模块视为一个独立的窗口,每个舞台默认承载一个窗口,所以可以通过切换舞台来实现快速切换窗口。

这样设计的优势在于,传统的网站一个功能模块就是一个独立的路由,如果需要在多个模块间操作,就需要频繁跳转路由,在这前提下,开发中还需要考虑页面保活(KeepAlive)。但现在只需要切换激活舞台即可。

每个舞台支持多窗口布局

通过拖拽窗口到指定舞台,可以实现多窗口的舞台,每个舞台最多支持 4 个窗口,舞台内的窗口可以进行排序和布局的调整。

收藏舞台

可以将多窗口的舞台添加到收藏,方便下次一键打开预设舞台。

拖拽预览

未激活的舞台会缩小尺寸并停靠在侧边栏上,除了可以点击切换舞台外,也支持通过拖拽进行预览。

侧边栏定位和宽度

可以根据使用习惯,调整侧边栏的位置和宽度,并且也支持控制侧边栏显示舞台数。并且超出数量限制的舞台,并不会直接销毁,所以不用担心再次唤起舞台时,窗口会被重新渲染,这部分设计遵循了 LRU (Least Recently Used) 缓存淘汰算法。

最后

以上就是我用了 7 天时间,总计花费了 30 元,几乎纯靠 vibe coding 开发的一款产品,接下来我会完善后续工作,尽快将它发布出来。

一文教你五分钟学会Zustand,React状态管理更加方便!

作者 Yue168
2026年5月5日 14:16

Zustand 快速入门

说明:本笔记源于我学习B站up"一线柏拉图"的视频
www.bilibili.com/video/BV1Tr…
前置知识:基本掌握React语法,包括useState,useReducer,useContext,状态提升,组件间共享状态等

Zustand(德语"状态")是一个轻量、快速、无模板的 React 状态管理库。它基于 Hook,API 设计极简。


为什么需要 Zustand?

  • React 本身已经提供了状态管理,但随着应用变复杂,内置方案会暴露出各种痛点。
  • 多个组件需要共享用户信息时Context和Provider繁琐
  • 更新逻辑散落各组件,状态修改到处都有,难以追踪
  • 无法在组件外部读取/修改,灵活性差。

1. 安装

npm install zustand

2. 创建存储器 Store

  • Store 就是一个自定义 Hook,用 create 函数创建,用来存储定义好的状态和更新函数。
  • 状态和更新函数都定义在一起,方便维护和测试。
  • 在实际项目中往往会有一个专门的文件夹用于存放你定义的所有的Stores。
import { create } from 'zustand'

//例如定义熊bear
const useBearStore = create((set) => ({
  bears: 0,//定义状态,可以定义多个状态
  owner: "Yue",
  //定义更新函数
  increase: () => set((state) => ({ bears: state.bears + 1 })),
  decrease: () => set((state) => ({ bears: state.bears - 1 })),
  reset: () => set({ bears: 0 }),
}))

set 是创建 store 时,create 函数自动注入的第一个参数,专门用来更新状态。你可以把它理解为 Zustand 版的 setState。它接收一个对象或函数。函数可接收当前状态 state,用于基于旧值更新。


3. 在组件中使用

直接调用自定义 Hook 即可获取状态和操作方法,无需useContext的Provider包裹

import useBearStore from './store/bearStore'//导入定义好的Store, 导入的useBearStore叫做Store选择器

function BearCounter() {
  // 订阅多个字段
  const bears = useBearStore((state) => state.bears)
   const owner = useBearStore((state) => state.owner)
  return <h1>🐻 {bears} 只熊</h1>
}

function Controls() {
  // 订阅方法(选择器提取多个值)
  const increase = useBearStore((state) => state.increase)
  const decrease = useBearStore((state) => state.decrease)
  const reset = useBearStore((state) => state.reset)

  return (
    <div>
      <button onClick={increase}>+1</button>
      <button onClick={decrease}>-1</button>
      <button onClick={reset}>重置</button>
    </div>
  )
}

// 合并在一个组件中
function App() {
  return (
    <>
      <BearCounter />
      <Controls />
    </>
  )
}

解构赋值(简化)

 const { bears, owner, increase, decrease, reset } = useBearStore(
    {
      bears: state.bears,
      owner: state.owner,
      increase: state.increase,
      decrease: state.decrease,
      reset: state.reset,
    })

4. 更新状态的几种方式

  • 直接传对象(合并,非覆盖)
  • 传入函数(接收 state,返回部分状态)
  • 使用 set 的第二个参数:设为 true 会替换整个状态(慎用)
set({ bears: 5 })                     // 合并
set((state) => ({ bears: state.bears + 1 })) // 函数更新
set({ bears: 0 }, true)               // 替换整个状态(其他字段会丢失)

5. 异步操作

直接在 set 中编写异步逻辑即可,就像普通函数一样。

//例如发送网络请求
const useFishStore = create((set) => ({
  fish: [],
  loading: false,
  fetchFish: async () => {
    set({ loading: true })
    const res = await fetch('/api/fish')
    const fish = await res.json()
    set({ fish, loading: false })
  },
}))

6. 选择器与性能优化

通过选择器只订阅需要的部分,避免不必要的重渲染。

// 只有 bears 变了才重渲染
const bears = useBearStore((state) => state.bears)

// 取多个值用 shallow 比较(需额外导入)
import { useShallow } from 'zustand/shallow'

const { bears, owner, increase, decrease, reset } = useBearStore(
    useShallow((state) => ({
      bears: state.bears,
      owner: state.owner,
      increase: state.increase,
      decrease: state.decrease,
      reset: state.reset,
    }))
  )
  • 当你用选择器返回一个对象或数组时,每次渲染都会创建新的引用,导致组件即使数据没变也会重渲染;
  • useShallow 是 Zustand 提供的一个辅助 Hook,用于浅比较选择器的返回值,避免不必要的重渲染。

7. 中间件

  • 中间件是包装 store 创建过程的函数,通过中间件函数包裹 store 定义来给 store 添加额外功能(如持久化、日志、调试等),而不修改业务逻辑。
  • 可以理解为:在 create 和你的 store 定义之间加一层拦截/增强。
  • Zustand 提供了常用中间件,组合使用时注意顺序(一般从外到内:immer > devtools> subscribeWithSelector > persist > store)。

下面是这四个 Zustand 中间件的详细讲解,从作用、使用场景到如何组合使用,一步步说明。


7.1 immer

作用

让你的状态更新方式更简单: 让你用 "直接修改" 的写法来处理不可变数据,Zustand 内部会自动转为不可变更新。

传统写法 vs immer 写法
// ❌ 传统 immutable 写法(一堆展开运算符)
set((state) => ({
  user: {
    ...state.user,
    profile: {
      ...state.user.profile,
      name: '小明'
    }
  }
}))

// ✅ immer 写法:直接修改, 本质是传统方式的封装
set((state) => {
  state.user.profile.name = '小明'
})
安装与使用
npm install immer
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'

const useStore = create(
  immer((set) => ({
    user: { name: '', age: 0 },
    updateName: (name) =>
      set((state) => {
        state.user.name = name // 直接修改
      }),
  }))
)
适用场景
  • 状态嵌套较深(对象/数组)
  • 烦透了各种 ... 展开运算符
  • 更新逻辑复杂且集中在某个深层字段

7.2 devtools

作用

连接 Redux DevTools 浏览器插件,可视化调试状态变化(历史回放、时间旅行)。

Redux DevTools 浏览器插件:这是一个专门用于调试状态管理的浏览器扩展,虽然名字带 Redux,但 Zustand、MobX、Recoil 等都能用它。

安装

无需额外安装,Zustand 内置。

基础用法
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'

const useStore = create(
  devtools(
    (set) => ({
      count: 0,
      inc: () => set((s) => ({ count: s.count + 1 }), false, 'increment'),
    }),
    { name: 'CounterStore' } // DevTools 中显示的名字
  )
)

set 的第三个参数是 action 名称,方便 DevTools 里查看。

适用场景
  • 开发和调试阶段排查状态变更
  • 想知道"什么时候、谁(哪个 action)改了状态"
  • 需要回放时间、查看历史

7.3 subscribe(原生方法)和 subscribeWithSelector(中间件)

7.3.1 原生 subscribe

Zustand 的每个 store 默认自带 subscribe 方法,用于监听整个 store 的变化

作用

subscribe 允许你在组件外部(普通 JS 模块、工具函数、WebSocket 连接等)也能监听状态变化,用于副作用管理,而useEffect 只能在组件内使用。

const useStore = create((set) => ({
  count: 0,
  name: 'Zustand'
}))

// 原生 subscribe:任何字段变化都会触发
const unsub = useStore.subscribe((state, prevState) => {
  console.log('有状态变了')
  console.log('旧状态:', prevState)
  console.log('新状态:', state)
})

特点

  • 只能监听整个 store,无法指定只监听某个字段
  • 回调参数是 (newState, prevState)
  • 适合"只要变化就执行"的场景

7.3.2 subscribeWithSelector 中间件
作用

原生 subscribe 无法选择性地监听特定字段。加上这个中间件后,subscribe 方法被增强,可以传入选择器函数,只在选定数据变化时才触发。

import { subscribeWithSelector } from 'zustand/middleware'

const useStore = create(
  subscribeWithSelector((set) => ({
    count: 0,
    name: 'Zustand'
  }))
)

// 增强后的 subscribe:只监听 count
useStore.subscribe(
  (state) => state.count,          // 选择器
  (newCount, prevCount) => {       // 回调只接收选定的值,新值与旧值
    console.log(`count: ${prevCount}${newCount}`)
  }
)

特点

  • 第一个参数是选择器,第二个是回调函数
  • 回调参数是 (selectedValue, prevSelectedValue),更精准
  • 使用 Object.is 比较选择器返回值,只有真正变化才触发
安装

也是 Zustand 内置。

具体适用场景
  • React 组件外部需要监听特定字段变化
  • 实现自定义事件/副作用(如播放声音、发送分析)
  • WebSocket 状态绑定

7.3.3 核心区别对比
特性 原生 subscribe subscribeWithSelector
需要中间件 ❌ 不需要 ✅ 需要显式添加
监听范围 整个 store 可选择特定字段
回调参数 (state, prevState) (selected, prevSelected)
触发频率 任何字段变化都触发 只有选择的字段变化才触发
性能 粗粒度 精细控制

7.3.4 使用场景例举对比
// 场景1:任何状态变化都要记录(原生 subscribe 就够)
useStore.subscribe((state) => {
  console.log('快照日志:', state)
})

// 场景2:只在 token 变化时重连 WebSocket
useStore.subscribe(
  (s) => s.token,        // 需要 subscribeWithSelector
  (token) => {
    if (token) connectWS(token)
    else disconnectWS()
  }
)

// 场景3:只在 theme 变化时操作 DOM
useStore.subscribe(
  (s) => s.theme,        // 需要 subscribeWithSelector
  (theme) => {
    document.documentElement.dataset.theme = theme
  }
)

7.3.5 联系:作用相同,能力不同(了解即可)
  • 本质相同:都是监听状态变化的底层 API,都返回取消订阅函数
  • 能力递进subscribeWithSelector 是原生 subscribe增强版
  • 内部关系:Zustand 内部实现中,subscribeWithSelector 包装了原生 subscribe,用 Object.is 做比较
// 概念上的伪代码
function subscribeWithSelector(store) {
  return (selector, callback) => {
    let prevValue = selector(store.getState())
    
    // 内部还是调用原生 subscribe
    store.subscribe((state) => {
      const newValue = selector(state)
      if (!Object.is(newValue, prevValue)) {
        callback(newValue, prevValue)
        prevValue = newValue
      }
    })
  }
}

7.3.6 组件外监听 vs 组件内选择器
环境 用什么
React 组件内 useStore(selector)
组件外,监听整个 store 原生 subscribe
组件外,监听特定字段 subscribeWithSelector

总结

原生 subscribe 是基础款,粗粒度监听全部变化。subscribeWithSelector 是通过中间件增强的精准版,让你能选择性监听特定字段,避免无用的回调触发。想要细粒度控制在组件外监听,用后者就对了。


7.4 persist

作用

自动将状态保存到 localStorage / sessionStorage 等,刷新页面后自动恢复。

安装

内置,无需安装。

基础用法
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

const useStore = create(
  persist(
    (set) => ({
      count: 0,
      inc: () => set((s) => ({ count: s.count + 1 })),
    }),
    {
      name: 'my-counter',//localStorage 的 key(键名)
      storage: createJSONStorage(() => localStorage), // 默认就是这个,可换sessionStorage等,修改Store存储在浏览器的位置
    }
  )
)
进阶配置(感兴趣可学)
persist(store, {
  name: 'app-store',
  storage: createJSONStorage(() => sessionStorage), // 用 sessionStorage
  partialize: (state) => ({ token: state.token }), // 只持久化 token
  version: 1,
  migrate: (persistedState, version) => {
    // 版本迁移逻辑
    return persistedState
  },
})
适用场景
  • 用户偏好(主题、语言)
  • 购物车、草稿
  • JWT token

7.5 中间件组合顺序

import { create } from 'zustand'
import { persist, devtools, subscribeWithSelector } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

const useStore = create(
  devtools(                              // 1. 最外:调试
    subscribeWithSelector(               // 2. 允许选择性订阅
      persist(                           // 3. 持久化
        immer((set) => ({                // 4. 深层更新 + store
          user: { name: '小明', age: 18 },
          incAge: () =>
            set((s) => {
              s.user.age += 1
            }),
        })),
        { name: 'my-store' }
      )
    ),
    { name: 'AppStore' }
  )
)

// 在组件外选择性监听
useStore.subscribe(
  (s) => s.user.age,
  (newAge) => console.log('年龄变成:', newAge)
)

8. 在 React 之外使用

Store 返回的 Hook 上挂载了 getStatesetState,可以直接在 React 外部读取/修改状态。

import { create } from 'zustand'

const useStore = create((set) => ({
  count: 0,
  user: { name: '小明', age: 18 },
  inc: () => set((s) => ({ count: s.count + 1 })),
}))

// ✅ 在任何地方获取当前状态,不再只限于组件内
const currentState = useStore.getState()
console.log(currentState.count)  // 0
console.log(currentState.user.name)  // '小明'

//setState不用受已定义的更新函数的影响,做到随用随定义
// ✅ 在组件外直接更新
useStore.setState({ count: 10 })

// ✅ 传入函数(基于旧状态的更新)
useStore.setState((prev) => ({ count: prev.count + 1 }))

// ✅ 替换整个 state(第二个参数 true)
useStore.setState({ count: 0 }, true)

9. 进阶学习

啥子都能看懂的TypeScript快速入门

作者 Yue168
2026年5月5日 13:54

TypeScript 从小白到快速上手

说明:大部分笔记参考B站尚硅谷TS课程
https://www.bilibili.com/video/BV1YS411w7Bf/?spm_id_from=333.337.search-card.all.click&vd_source=4de192a0ec1fe7b5fc788e72acc90efa
前置知识:JavaScript 基础和 ES6+ 新特性

一、TypeScript 简介

  1. TypeScript 包含了 JavaScript 的所有内容,即:TypeScript 是 JavaScript 的超集
  2. TypeScript 增强了静态类型检查、接口编程和面向对象特性,更适合大型项目的开发。
  3. TypeScript 需要编译为 JavaScript,然后交给浏览器或其他 JavaScript 运行环境执行。

二、为何需要 TypeScript

1. 如今成神的JavaScript其实先天废材(了解)

  • JavaScript 当年诞生时的定位是浏览器脚本语言,用于在网页中嵌入简单的逻辑,且代码量很少。
  • 随着时间的推移,JavaScript 变得越来越流行,如今的 JavaScript 已经可以全栈编程了。
  • 现如今的 JavaScript 应用场景比当年丰富的多,代码量也比当年大很多,随便一个 JavaScript 项目的代码量,可以轻松的达到几万行,甚至十几万行!
  • 然而 JavaScript 当年“出生简陋”,没考虑到如今的应用场景和代码量,逐渐就出现了很多困扰。

2. 成也萧何,败也萧何 ---- 静态类型检查的缺失

  • JavaScript 的弱类型带来了灵活性,但也牺牲了类型安全性。

不清楚的数据类型

let welcome = 'hello'
welcome() // 此行报错: TypeError: welcome is not a function

有漏洞的逻辑

const str = Date.now() % 2 ? '奇数' : '偶数'
if (str !== '奇数') {
    alert('hello')
} else if (str == '偶数') {
    alert('world')
}

访问不存在的属性

const obj = { width: 10, height: 15 }
const area = obj.width * obj.height

低级的拼写错误

const message = 'hello,world'
message.toUpperCase()

3. 静态类型检查

  • 在代码运行前进行检查,发现代码的错误或不合理之处,减小运行时出现异常的几率,此种检查叫『静态类型检查』,TypeScript 的核心就是『静态类型检查』,简言之就是把运行时的错误前置。
  • 同样的功能,TypeScript 的代码量要大于 JavaScript,但由于 TypeScript 的代码结构更加清晰,在后期代码的维护中 TypeScript 却胜于 JavaScript。

三、TypeScript 的编译(了解,没啥用)

浏览器不能直接运行 TypeScript 代码,需要编译为 JavaScript 再交由浏览器解析器执行。

1. 命令行编译

  • 第一步:创建一个 demo.ts 文件,例如:
const person = { name: '李四', age: 18 }
console.log(`我叫${person.name},我今年${person.age}岁了`)
  • 第二步:全局安装 TypeScript
npm i typescript -g
  • 第三步:使用命令编译 .ts 文件
tsc demo.ts

2. 自动化编译

  • 第一步:创建 TypeScript 编译控制文件
tsc --init
  1. 工程中会生成一个 tsconfig.json 配置文件,其中包含着很多编译时的配置。
  2. 观察发现,默认编译的 JS 版本是 ES7,我们可以手动调整为其他版本。
  • 第二步:监视目录中的 .ts 文件变化
tsc --watch  或  tsc -w
  • 第三步:小优化,当编译出错时不生成 .js 文件(也可以修改 tsconfig.json 中的 noEmitOnError 配置)

四、类型声明

使用 : 来对变量或函数形参进行类型声明:

let a: string   // 变量a只能存储字符串
let b: number   // 变量b只能存储数值
let c: boolean  // 变量c只能存储布尔值

a = 'hello'
a = 100         // 警告:不能将类型"number"分配给类型"string"

b = 666
b = '你好'      // 警告:不能将类型"string"分配给类型"number"

c = true
c = 666         // 警告:不能将类型"number"分配给类型"boolean"

// 参数x必须是数字,参数y也必须是数字,函数返回值也必须是数字
function demo(x: number, y: number): number {
    return x + y
}

demo(100, 200)
demo(100, '200')     // 警告:类型"string"的参数不能赋给类型"number"的参数
demo(100, 200, 300)  // 警告:应有2个参数,但获得3个
demo(100)            // 警告:应有2个参数,但获得1个

: 后也可以写字面量类型,不过实际开发中用的不多。

let a: '你好'   // a的值只能为字符串"你好"
let b: 100      // b的值只能为数字100

a = '欢迎'      // 警告:不能将类型"欢迎"分配给类型"你好"
b = 200         // 警告:不能将类型"200"分配给类型"100"

五、类型自动推断

TypeScript 会根据我们的代码进行类型推导,例如下面代码中的变量 d 只能存储数字:

let d = -99   // TypeScript会推断出变量d的类型是数字
d = false     // 警告:不能将类型“boolean”分配给类型“number”

但要注意,类型推断不是万能的,面对复杂类型时推断容易出问题,所以尽量还是明确的编写类型声明!

六、类型总概

JavaScript 中的数据类型

① string ② number ③ boolean ④ null ⑤ undefined ⑥ bigint ⑦ symbol ⑧ object

备注:其中 object 包含:Array、Function、Date、Error 等...

TypeScript 新增类型

  1. 上述所有 JavaScript 类型
  2. 六个新类型:① any ② unknown ③ never ④ void ⑤ tuple ⑥ enum
  3. 两个用于自定义类型的方式:① type ② interface

注意点! 在 JavaScript 中的这些内置构造函数(Number、String、Boolean)用于创建对应的包装对象,在日常开发时很少使用,在 TypeScript 中也是同理,所以在 TypeScript 中进行类型声明时,通常都是用小写的 numberstringboolean。例如:

let str1: string
str1 = 'hello'
str1 = new String('hello')   // 报错

let str2: String
str2 = 'hello'
str2 = new String('hello')

七、常用类型与语法

1. any(了解即可)

any 的含义是:任意类型,一旦将变量类型限制为 any,那就意味着放弃了对该变量的类型检查。注意:any 类型的变量可以赋值给任意类型的变量。

// 明确的表示 a 的类型是 any —— 【显式的 any】
let a: any
a = 100
a = '你好'
a = false

// 没有明确的表示 b 的类型是 any,但 TS 主动推断出来 b 是 any —— 隐式的 any
let b
b = 100
b = '你好'
b = false

let c: any
c = 9
let x: string
x = c   // 无警告

2. unknown(了解即可)

unknown 的含义是:未知类型,适用于起初不确定数据的具体类型,要后期才能确定。

  • unknown 可以理解为一个类型安全的 any
let a: unknown
a = 100
a = false
a = '你好'

let x: string
x = a   // 警告:不能将类型"unknown"分配给类型"string"
  • unknown 会强制开发者在使用之前进行类型检查,从而提供更强的类型安全性。
let a: unknown
a = 'hello'

// 第一种方式:加类型判断
if (typeof a == 'string') {
    x = a
    console.log(x)
}

// 第二种方式:加断言
x = a as string

// 第三种方式:加断言
x = <string>a
  • 读取 any 类型数据的任何属性都不会报错,而 unknown 正好与之相反。
let str1: string
str1 = 'hello'
str1.toUpperCase()   // 无警告

let str2: any
str2 = 'hello'
str2.toUpperCase()   // 无警告

let str3: unknown
str3 = 'hello'
str3.toUpperCase()   // 警告:“str3”的类型为“未知”

// 使用断言强制指定 str3 的类型为 string
(str3 as string).toUpperCase()   // 无警告

3. never(了解即可)

never 的含义是:任何值都不是,即不能有值,例如 undefinednull""0 都不行!

  • 几乎不用 never 去直接限制变量,因为没有意义。
// 指定 a 的类型为 never,那就意味着 a 以后不能存任何的数据了
let a: never
a = 1          // 警告
a = true       // 警告
a = undefined  // 警告
a = null       // 警告
  • never 一般是 TypeScript 主动推断出来的。
  • never 也可用于限制函数的返回值。
// 限制 throwError 函数不需要有任何返回值,任何值都不行
function throwError(str: string): never {
    throw new Error('程序异常退出:' + str)
}

4. void

void 的含义是空,即:函数不返回任何值,调用者也不应依赖其返回值进行任何操作!

  • void 通常用于函数返回值声明。
function logMessage(msg: string): void {
    console.log(msg)
}
logMessage('你好')

注意:编码者没有编写 return 指定函数返回值,所以 logMessage 函数是没有显式返回值的,但会有一个隐式返回值,是 undefined,虽然函数返回类型为 void,但也是可以接受 undefined 的。简单记:undefinedvoid 可以接受的一种“空”。

  • 以下写法均符合规范:
function logMessage(msg: string): void {
    console.log(msg)
}

function logMessage(msg: string): void {
    console.log(msg)
    return
}

function logMessage(msg: string): void {
    console.log(msg)
    return undefined
}

但是有区别:返回值类型为 void 的函数,调用者不应依赖其返回值进行任何操作!

function logMessage(msg: string): void {
    console.log(msg)
}
let result = logMessage('你好')
if (result) {   // 此行报错:无法测试 "void" 类型的表达式的真实性
    console.log('logMessage有返回值')
}

function logMessage(msg: string): undefined {
    console.log(msg)
}
let result = logMessage('你好')
if (result) {   // 此行无警告
    console.log('logMessage有返回值')
}

理解 void 与 undefined(这里看不懂就别管他)

  • void 是一个广泛的概念,用来表达“空”,而 undefined 则是这种“空”的具体实现。
  • 因此可以说 undefinedvoid 能接受的一种“空”的状态。
  • 也可以理解为:void 包含 undefined,但 void 所表达的语义超越了 undefinedvoid 是一种意图上的约定,而不仅仅是特定值的限制。

总结:如果一个函数返回类型为 void,那么:

  1. 从语法上讲:函数是可以返回 undefined 的,至于显式返回,还是隐式返回,这无所谓!
  2. 从语义上讲:函数调用者不应关心函数返回的值,也不应依赖返回值进行任何操作!即使我们知道它返回了 undefined

5. object(了解即可)

关于 objectObject,实际开发中用的相对较少,因为范围太大了。

let a: object   // a的值可以是任何【非原始类型】,包括:对象、函数、数组等
a = {}
a = { name: '张三' }
a = [1, 3, 5, 7, 9]
a = function() {}
a = new String('123')
class Person {}
a = new Person()

// 以下代码,是将【原始类型】赋给 a,有警告
a = 1
a = true
a = '你好'
a = null
a = undefined
let b: Object   // b的值必须是 object 的实例对象(除去 undefined 和 null 的任何值)
b = {}
b = { name: '张三' }
b = [1, 3, 5, 7, 9]
b = function() {}
b = new String('123')
b = 1            // 1 不是 object 的实例对象,但其包装对象是 object 的实例
b = true         // true 不是 object 的实例对象,但其包装对象是 object 的实例
b = '你好'       // 同样可
b = null         // 警告
b = undefined    // 警告

声明对象类型

实际开发中,限制一般对象,通常使用以下形式:

// 限制 person1 对象必须有 name 属性,age 为可选属性
let person1: { name: string, age?: number }
let person2: { name: string; age?: number }
let person3: {
    name: string
    age?: number
}

person1 = { name: '李四', age: 18 }
person2 = { name: '张三' }
person3 = { name: '王五' }
// person3 = { name: '王五', gender: '男' }   // 不合法

索引签名:允许定义对象可以具有任意数量的属性,这些属性的键和类型是可变的。

let person: {
    name: string
    age?: number
    [key: string]: any   // 索引签名
}
person = { name: '张三', age: 18, gender: '男' }

声明函数类型

let count: (a: number, b: number) => number
count = function(x, y) {
    return x + y
}

备注:

  • TypeScript 中的 => 在函数类型声明时表示函数类型,描述其参数类型和返回类型。
  • JavaScript 中的 => 是一种定义函数的语法,是具体的函数实现。

声明数组类型

let arr1: string[]
let arr2: Array<string>
arr1 = ['a', 'b', 'c']
arr2 = ['hello', 'world']

6. tuple(tuple不是关键字!)

元组(Tuple)是一种特殊的数组类型,可以存储固定数量的元素,并且每个元素的类型是已知的且可以不同。元组用于精确描述一组值的类型,? 表示可选元素。

// 第一个元素必须是 string 类型,第二个元素必须是 number 类型
let arr1: [string, number]

// 第一个元素必须是 number 类型,第二个元素是可选的,如果存在,必须是 boolean 类型
let arr2: [number, boolean?]

// 第一个元素必须是 number 类型,后面的元素可以是任意数量的 string 类型
let arr3: [number, ...string[]]

arr1 = ['hello', 123]
arr2 = [100, false]
arr2 = [200]
arr3 = [100, 'hello', 'world']
arr3 = [100]

// arr1 = ['hello', 123, false]   // 不可以

7. enum

枚举(enum)可以定义一组命名常量,它能增强代码的可读性,也让代码更好维护。

数字枚举

enum Direction {
    Up,
    Down,
    Left,
    Right
}

function walk(n: Direction) {
    if (n === Direction.Up) {
        console.log("向【上】走")
    } else if (n === Direction.Down) {
        console.log("向【下】走")
    } else if (n === Direction.Left) {
        console.log("向【左】走")
    } else if (n === Direction.Right) {
        console.log("向【右】走")
    } else {
        console.log("未知方向")
    }
}

walk(Direction.Up)
walk(Direction.Down)

数字枚举的成员的值会自动递增,且具备反向映射的特点。

字符串枚举(了解即可)

enum Direction {
    Up = "up",
    Down = "down",
    Left = "left",
    Right = "right"
}
let dir: Direction = Direction.Up
console.log(dir)   // 输出: "up"

常量枚举

常量枚举使用 const 关键字定义,在编译时会被内联,避免生成额外的代码,减少生成的 JavaScript 代码量并提高运行时性能。

const enum Directions {
    Up,
    Down,
    Left,
    Right
}
let x = Directions.Up

编译后生成的 JavaScript 代码量较小:

"use strict";
let x = 0 /* Directions.Up */;

8. type(了解即可)

type 可以为任意类型创建别名,让代码更简洁、可读性更强,同时能更方便地进行类型复用和扩展。

基本用法

type num = number
let price: num
price = 100

联合类型

联合类型表示一个值可以是几种不同类型之一。

type Status = number | string
type Gender = '男' | '女'

function printStatus(status: Status) {
    console.log(status)
}

function logGender(str: Gender) {
    console.log(str)
}

printStatus(404)
printStatus('200')
logGender('男')
logGender('女')

交叉类型

交叉类型允许将多个类型合并为一个类型。合并后的类型将拥有所有被合并类型的成员。

// 面积
type Area = {
    height: number   // 高
    width: number    // 宽
}

// 地址
type Address = {
    num: number     // 楼号
    cell: number    // 单元号
    room: string    // 房间号
}

// 定义类型 House,是 Area 和 Address 组成的交叉类型
type House = Area & Address

const house: House = {
    height: 180,
    width: 75,
    num: 6,
    cell: 3,
    room: '702'
}

9.类class

  • TypeScript 的类是在 JavaScript ES6 类的基础上,增加了类型声明和面向对象增强特性。
class Animal {
    constructor(public name: string) {}
    
    move(distance: number): void {
        console.log(`${this.name}移动了${distance}米`)
    }
}

class Dog extends Animal {
    constructor(name: string, public breed: string) {
        super(name)  // 必须调用super()
    }
    
    bark(): void {
        console.log(`${this.name}汪汪叫`)
    }
    
    //明确标注这是一个重写方法(推荐)
    override move(distance: number): void {
        console.log(`小狗${this.name}跑了${distance}米`)
        super.move(distance)  // 可选调用父类方法
    }
}

const dog = new Dog('旺财', '金毛')
dog.bark()   // 旺财汪汪叫
dog.move(10) // 小狗旺财跑了10米 \n 旺财移动了10米\

10. 属性修饰符(和Java的语法同理)

  • 前置知识:JS的类class
修饰符 含义 具体规则
public 公开的 可以被:类内部、子类、类外部访问
protected 受保护的 可以被:类内部、子类访问
private 私有的 可以被:类内部访问
readonly 只读 属性无法修改

示例:

class Person {
    public name: string
    age: number   // 未写修饰符,默认 public
    constructor(name: string, age: number) {
        this.name = name
        this.age = age
    }
    speak() {
        console.log(`我叫:${this.name},今年${this.age}岁`)
    }
}

const p1 = new Person('张三', 18)
console.log(p1.name)   // 外部可访问

class Student extends Person {
    constructor(name: string, age: number) {
        super(name, age)
    }
    study() {
        console.log(`${this.age}岁的${this.name}正在努力学习`)
    }
}

属性的简写形式

class Person {
    constructor(
        public name: string,
        public age: number
    ) {}
}

protected 修饰符

class Person {
    constructor(
        protected name: string,
        protected age: number
    ) {}
    protected getDetails(): string {
        return `我叫:${this.name},年龄是:${this.age}`
    }
    introduce() {
        console.log(this.getDetails())
    }
}

const p1 = new Person('杨超越', 18)
p1.introduce()
// p1.getDetails()   // 报错
// p1.name           // 报错

class Student extends Person {
    constructor(name: string, age: number) {
        super(name, age)
    }
    study() {
        this.introduce()
        console.log(`${this.name}正在努力学习`)
    }
}

private 修饰符

class Person {
    constructor(
        public name: string,
        public age: number,
        private IDCard: string
    ) {}
    private getPrivateInfo() {
        return `身份证号码为:${this.IDCard}`
    }
    getInfo() {
        return `我叫:${this.name},今年刚满${this.age}岁`
    }
    getFullInfo() {
        return this.getInfo() + ',' + this.getPrivateInfo()
    }
}

const p1 = new Person('张三', 18, '110114198702034432')
console.log(p1.getFullInfo())
// p1.name          // 报错
// p1.IDCard        // 报错

readonly 修饰符

class Car {
    constructor(
        public readonly vin: string,   // 车辆识别码,只读
        public readonly year: number,  // 出厂年份,只读
        public color: string,
        public sound: string
    ) {}
    displayInfo() {
        console.log(`
            识别码:${this.vin},
            出厂年份:${this.year},
            颜色:${this.color},
            音响:${this.sound}
        `)
    }
}

const car = new Car('1HGCM82633A123456', 2018, '黑色', 'Bose音响')
car.displayInfo()
// car.vin = 'new'   // 错误:只读属性不可修改

11. 抽象类

抽象类是一种无法被实例化的类,专门用来定义类的结构和行为。类中可以写抽象方法,也可以写具体实现。抽象类主要用来为其派生类提供一个基础结构,要求其派生类必须实现其中的抽象方法。

abstract class Package {
    constructor(public weight: number) {}
    // 抽象方法:用来计算运费
    abstract calculate(): number
    // 通用方法
    printPackage() {
        console.log(`包裹重量为:${this.weight}kg,运费为:${this.calculate()}元`)
    }
}

// 标准包裹
class StandardPackage extends Package {
    constructor(
        weight: number,
        public unitPrice: number   // 每公斤的固定费率
    ) {
        super(weight)
    }
    calculate(): number {
        return this.weight * this.unitPrice
    }
}

const s1 = new StandardPackage(10, 5)
s1.printPackage()

// 特快专递
class ExpressPackage extends Package {
    constructor(
        weight: number,
        public unitPrice: number,
        public extraFee: number
    ) {
        super(weight)
    }
    calculate(): number {
        return this.weight * this.unitPrice + this.extraFee
    }
}

使用抽象类的场景

  1. 定义通用接口:为一组相关的类定义通用的行为(方法或属性)时。
  2. 提供基础实现:在抽象类中提供某些方法或为其提供基础实现,这样派生类就可以继承这些实现。
  3. 确保关键实现:强制派生类实现一些关键行为。
  4. 共享代码和逻辑:当多个类需要共享部分代码时,抽象类可以避免代码重复。

12. interface(接口)

interface 是一种定义结构的方式,主要作用是为类、对象、函数等规定一种契约,确保代码的一致性和类型安全。interface 只能定义格式,不能包含任何实现。

定义类结构

interface PersonInterface {
    name: string
    age: number
    speak(n: number): void
}

class Person implements PersonInterface {
    constructor(
        public name: string,
        public age: number
    ) {}
    speak(n: number): void {
        for (let i = 0; i < n; i++) {
            console.log(`你好,我叫${this.name},我的年龄是${this.age}`)
        }
    }
}

const p1 = new Person('tom', 18)
p1.speak(3)

定义对象结构

interface UserInterface {
    name: string
    readonly gender: string   // 只读属性
    age?: number              // 可选属性
    run: (n: number) => void
}

const user: UserInterface = {
    name: "张三",
    gender: '男',
    age: 18,
    run(n) {
        console.log(`奔跑了${n}米`)
    }
}

定义函数结构

interface CountInterface {
    (a: number, b: number): number
}

const count: CountInterface = (x, y) => {
    return x + y
}

接口之间的继承

interface PersonInterface {
    name: string
    age: number
}

interface StudentInterface extends PersonInterface {
    grade: string
}

const stu: StudentInterface = {
    name: "张三",
    age: 25,
    grade: '高三'
}

13. 一些相似概念的区别

13.1 interface 与 type 的区别

相同点:都可以用于定义对象结构,在定义对象结构时两者可以互换。

不同点

  • interface:更专注于定义对象和类的结构,支持继承、合并。
  • type:可以定义类型别名、联合类型、交叉类型,但不支持继承和自动合并。

interface 示例(支持继承和声明合并):

interface PersonInterface {
    name: string
    age: number
}
interface PersonInterface {
    speak: () => void
}
interface StudentInterface extends PersonInterface {
    grade: string
}
const student: StudentInterface = {
    name: '李四',
    age: 18,
    grade: '高二',
    speak() {
        console.log(this.name, this.age, this.grade)
    }
}

type 示例(通过交叉类型实现类似继承):

type PersonType = {
    name: string
    age: number
} & {
    speak: () => void
}
type StudentType = PersonType & {
    grade: string
}
const student: StudentType = {
    name: '李四',
    age: 18,
    grade: '高二',
    speak() {
        console.log(this.name, this.age, this.grade)
    }
}
13.2 interface 与抽象类的区别

相同点:都能定义一个类的格式(定义类应遵循的契约)。

不同点

  • 接口:只能描述结构,不能有任何实现代码,一个类可以实现多个接口。
  • 抽象类:既可以包含抽象方法,也可以包含具体方法,一个类只能继承一个抽象类。

一个类实现多个接口

interface FlyInterface {
    fly(): void
}
interface SwimInterface {
    swim(): void
}

class Duck implements FlyInterface, SwimInterface {
    fly(): void {
        console.log('鸭子可以飞')
    }
    swim(): void {
        console.log('鸭子可以游泳')
    }
}

const duck = new Duck()
duck.fly()
duck.swim()

八、泛型

泛型允许我们在定义函数、类或接口时,使用类型参数来表示未指定的类型,这些参数在具体使用时才被指定具体的类型。泛型能让同一段代码适用于多种类型,同时仍然保持类型的安全性。

泛型函数

function logData<T>(data: T): T {
    console.log(data)
    return data
}
logData<number>(100)
logData<string>('hello')

泛型可以有多个

function logData<T, U>(data1: T, data2: U): T | U {
    console.log(data1, data2)
    return Date.now() % 2 ? data1 : data2
}
logData<number, string>(100, 'hello')
logData<string, boolean>('ok', false)

泛型接口

interface PersonInterface<T> {
    name: string
    age: number
    extraInfo: T
}

let p1: PersonInterface<string>
let p2: PersonInterface<number>
p1 = { name: '张三', age: 18, extraInfo: '一个好人' }
p2 = { name: '李四', age: 18, extraInfo: 250 }

泛型约束

  • 泛型约束用于限制泛型类型参数的范围,确保传入的类型满足特定条件。
interface LengthInterface {
    length: number
}
// 约束规则是:传入的类型 T 必须具有 length 属性
function logPerson<T extends LengthInterface>(data: T): void {
    console.log(data.length)
}
logPerson<string>("hello")
// logPerson<number>(100)   // 报错,因为 number 不具备 length 属性

泛型类

class Person<T> {
    constructor(
        public name: string,
        public age: number,
        public extraInfo: T
    ) {}
    speak() {
        console.log(`我叫${this.name}今年${this.age}岁了`)
        console.log(this.extraInfo)
    }
}

// 测试代码1
const p1 = new Person<number>("tom", 30, 250)

// 测试代码2
type JobInfo = {
    title: string
    company: string
}
const p2 = new Person<JobInfo>("tom", 30, { title: '研发总监', company: '发发发科技公司' })

九、类型声明文件(了解即可)

  • 类型声明文件是 TypeScript 中以 .d.ts 结尾的文件,用于描述 JavaScript 代码的类型信息,让 TypeScript 能够理解和使用普通 JavaScript 库。
  • 很多的第三方工具库都是用JS写的,想要在TS项目里使用它们就必须要有类型声明文件,一般Vite等工具在创建项目时已经自动配置好引入了,不用我们管。

十. 一些进阶的东西(感兴趣的可以深入学习)

WebAssembly:前端界的“外挂”,让C++代码在浏览器里跑起来

作者 kyriewen
2026年5月5日 12:21

你的网页有个计算密集的任务(比如视频转码、图像滤镜、物理模拟),用JS写慢得像乌龟。你想:“要是能用C++写,然后在浏览器里跑就好了。” 今天的主角 WebAssembly 就是干这个的——它让你把C++、Rust等语言编译成一种近乎二进制的格式(.wasm),让浏览器以接近原生的速度执行。前端从此不只是JS的天下。

前言

JS 是解释型语言,哪怕 V8 再快,在处理大量计算时还是力不从心。而 WebAssembly(简称 Wasm)是一种低级的汇编式语言,浏览器可以极快地解析和执行。它不是要取代 JS,而是作为 JS 的“高性能搭档”:你用 JS 写业务逻辑,用 Wasm 写计算密集型模块。

今天我们就从零了解 Wasm:它是什么鬼?怎么用?性能真的翻倍吗?以及一个最经典的例子——用 C++ 写一个斐波那契数列,编译成 Wasm,然后在浏览器里调用。

一、WebAssembly 到底是什么?

你可以把它理解为一种中间码。你用 C++、Rust、Go 等语言写代码,然后编译成 .wasm 文件。浏览器下载这个文件,实例化后,JS 就可以调用其中的函数。

它和 JS 的区别:

  • JS:文本格式,需要解析、JIT 编译,性能好但不够稳定。
  • Wasm:二进制格式,体积小,解码快,执行效率接近原生(比 JS 快 1-10 倍,视任务而定)。

注意:Wasm 不能直接操作 DOM、调用浏览器 API,它只能做纯计算。它需要通过 JS 来输入数据、接收结果,并让 JS 更新界面。

二、为什么需要 Wasm?一个例子让你秒懂

假设你要对一张 4K 图片做高斯模糊。纯 JS 实现,需要遍历每个像素,三层循环,可能卡死浏览器。用 C++ 写同样的算法,编译成 Wasm,速度可能提升 5-10 倍。这就是 Wasm 的价值:把计算密集型任务交给“专业选手”

适用场景:

  • 视频/音频编解码
  • 图像处理(滤镜、识别)
  • 物理模拟(游戏、数据可视化)
  • 加密算法
  • 大型数学计算(如金融建模)

三、上手:把 C++ 编译成 Wasm

我们需要一个工具链:Emscripten。它能把 C/C++ 编译成 Wasm,并生成 JS 胶水代码。

1. 安装 Emscripten

git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest
./emsdk activate latest
source ./emsdk_env.sh  # 设置环境变量

2. 写一个简单的 C++ 函数

add.cpp:

#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE
int add(int a, int b) {
  return a + b;
}

EMSCRIPTEN_KEEPALIVE 告诉编译器不要优化掉这个函数(否则会被 tree-shaking 移除)。

3. 编译成 Wasm

emcc add.cpp -o add.js -s WASM=1 -s EXPORTED_FUNCTIONS='["_add"]' -s EXPORTED_RUNTIME_METHODS='["cwrap"]'

参数解释:

  • -o add.js:输出 JS 和 Wasm 文件。
  • -s WASM=1:生成 Wasm。
  • -s EXPORTED_FUNCTIONS:指定要导出的 C 函数(注意前缀下划线)。
  • -s EXPORTED_RUNTIME_METHODS='["cwrap"]':导出 cwrap 工具函数(方便包装)。

输出:add.jsadd.wasm

4. 在 HTML 中调用

<script src="add.js"></script>
<script>
  Module.onRuntimeInitialized = () => {
    const add = Module.cwrap('add', 'number', ['number', 'number']);
    console.log(add(2, 3)); // 5
  };
</script>

注意:必须等待 Module.onRuntimeInitialized,因为 Wasm 是异步加载的。

四、性能实测:斐波那契递归

C++ 版(递归,效率低,放大差距):

#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE
int fib(int n) {
  if (n <= 1) return n;
  return fib(n-1) + fib(n-2);
}

编译后用 JS 实现相同递归。测试 n=45:

  • JS: ~8 秒
  • Wasm: ~3 秒

提升明显。对于非递归计算(循环),差距可能缩小,但 Wasm 依然有优势。

五、在 Rust 中写 Wasm(更现代的选择)

Rust 对 Wasm 支持极好,工具链更简单。安装 wasm-pack

cargo install wasm-pack

创建 lib:

// lib.rs
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

构建:

wasm-pack build --target web

会在 pkg 目录生成 JS 和 Wasm。使用:

import init, { add } from './pkg/my_wasm.js';
async function run() {
  await init();
  console.log(add(2, 3));
}
run();

Rust 方式比 Emscripten 更现代,体积更小。

六、注意事项和坑

  • Wasm 无法直接操作 DOM:你需要把数据计算结果传回 JS,由 JS 更新界面。
  • 文件体积:简单 Wasm 可能只有几十 KB,但引入 Emscripten 的 JS 胶水代码可能上百 KB。Rust 的 wasm-bindgen 生成的胶水代码较小。
  • 数据传输开销:每次调用 Wasm 函数,需要把参数从 JS 拷贝到 Wasm 线性内存,结果再拷贝回来。对于大量数据(如图像),可以用 Module._mallocModule.HEAPU8 共享内存,避免拷贝。
  • 浏览器支持:所有现代浏览器(Chrome、Firefox、Safari、Edge)都支持 Wasm。IE 不支持。

七、实际应用案例

  • Figma:用 Wasm 运行 C++ 图形引擎,实现流畅在线设计。
  • Google Earth:用 Wasm 跑 C++ 3D 渲染。
  • Zoom:网页版使用 Wasm 进行音视频编解码。
  • AutoCAD Web:用 Wasm 把桌面端代码搬到浏览器。

八、总结:Wasm 不是银弹,但是一把“瑞士军刀”

  • 当你遇到 JS 性能瓶颈时,可以考虑 Wasm。
  • 它适合计算密集型,不适合 IO 密集或 DOM 操作。
  • C++ 和 Rust 是最常用的两种源语言,推荐 Rust(安全、工具链友好)。
  • 学习曲线有,但值得投入。

前端开发的未来是多语言协作:JS 负责交互,Wasm 负责计算。两者取长补短,让你的网页应用飞起来。

核心 SDK 详细设计文档 (Visual-Render-SDK)

2026年5月5日 12:11

一. 架构目标

构建一个渲染引擎,通过解析 JSON 协议,实现布局嵌套、图表渲染、数据源绑定及交互控制。支持通过“逃生通道”挂载业务定制组件。

二. 核心数据结构 (The Protocol)

所有的渲染都基于一份 config 对象。

字段 类型 说明
type String 节点类型:container(布局) 或widget(组件)
component String 渲染标识,如FlexLayout,EChartsRender,TabContainer
children Array container有效,存放子节点配置
content Object widget有效,包含templateId(样式) 和sourceId(数据)
customComponent String 逃生通道:若有值,则忽略content,直接加载此业务组件
reactiveParams Array<String> 声明需要监听的全局变量名,如['date']

示例:

{
    "type": "widget",
    "key":"chart-01",
    "comp": "ChartWidget",
    "echartsOptions": {},
    "formOptions": {},
    "actions":{
        "type": "click",
        "execute": "openDialog",
        "controls":[
            {
                "comp":"ExportButton",
                "type": "action",
                "field": "name",
                "props":[

                ]
            }
        ],
        "config": {
            "datasourceKey": "data-source-1",
            "params": {
                "category": "{{data.name}}", 
                "date": "{{global.selectedDate}}"
            }
        }
    }
}

{
    "type": "datasouce",
    "key": "datasource-1",
    "url": "",
    "params":[
        {
            "field":"x",
            "to":"replaceTextMap",
            "value": ""
        }
    ],
    "templateKey":"chart-01"
}

{
    "type": "layout",
    "comp": "NineGridLayout",
    "deptId": "",
    "children": [
        {
            "id":"",
            "title":"xxx指标",
            "controls":[
                {
                    "comp":"FilterSelect",
                    "type": "filter",
                    "field": "name",
                    "props":[

                    ]
                }
            ],
            "datasourceKey": "data-source-1"
        }
    ]
}

复杂示例

|--------------------------------------------------------------------|
| |-----------|                                                      |
| |   卡片     |                                                      |
| |-----------|                                                      |
|                                                                    |
|-------------|-------------|                                        |
|     选项卡1  |     选项卡2  |                                        | 
|--------------------------------------------------------------------|
|                         选项卡内容                                   |
|--------------------------------------------------------------------|
{
  "pageId": "dept_dashboard_001",
  "pageTitle": "部门综合管理看板",
  "type": "container",
  "component": "FlexLayout",
  "props": { "direction": "column", "gap": "20px", "padding": "20px" },
  
  "children": [
    {
      "id": "top_row",
      "type": "container",
      "component": "FlexLayout",
      "props": { "direction": "row", "gap": "15px" },
      "style": { "height": "120px" },
      "children": [
        {
          "id": "kpi_1",
          "type": "widget",
          "style": { "flex": 1 },
          "content": {
            "templateId": "tpl_kpi_card",
            "sourceId": "ds_total_sales",
            "override": { "title": "当日销售总额", "color": "#1890ff" }
          }
        },
        {
          "id": "kpi_2",
          "type": "widget",
          "style": { "flex": 1 },
          "content": {
            "templateId": "tpl_kpi_card",
            "sourceId": "ds_active_users",
            "override": { "title": "活跃用户数", "color": "#52c41a" }
          }
        }
      ]
    },

    {
      "id": "bottom_tabs",
      "type": "container",
      "component": "TabContainer",
      "props": { "type": "card" },
      "children": [
        {
          "tabKey": "overview",
          "label": "业务概览",
          "authKey": "VIEW_OVERVIEW",
          "controls": [
            { "id": "c1", "type": "Select", "field": "region", "label": "区域", "props": { "options": [{ "label": "华东", "value": "sh" }, { "label": "华南", "value": "gd" }] } },
            { "id": "c2", "type": "DateRange", "field": "timeRange", "label": "周期" }
          ],
          "content": {
            "type": "container",
            "component": "GridLayout",
            "props": { "columns": 3, "gap": "15px" },
            "children": [
              {
                "id": "grid_item_1",
                "type": "widget",
                "content": {
                  "templateId": "tpl_bar_chart",
                  "sourceId": "ds_dept_perf",
                  "override": { "title": "各组业绩对比" }
                },
                "interactions": [
                  {
                    "trigger": "click",
                    "action": "openDialog",
                    "config": {
                      "title": "业绩明细列表",
                      "content": {
                        "type": "widget",
                        "component": "TableRender",
                        "content": { "templateId": "tpl_detail_table", "sourceId": "ds_perf_detail" }
                      }
                    }
                  }
                ]
              }
            ]
          }
        },
        {
          "tabKey": "detail",
          "label": "明细数据",
          "controls": [
            { "id": "c3", "type": "SearchInput", "field": "keyword", "props": { "placeholder": "搜索项目名称..." } }
          ],
          "content": {
            "type": "widget",
            "component": "TableRender",
            "content": {
              "templateId": "tpl_main_list",
              "sourceId": "ds_project_list"
            }
          }
        }
      ]
    }
  ]
}

三. 渲染逻辑实现 (Recursive Renderer)

1. 递归分发器 (VisualRenderer.vue)

这是 SDK 的入口。

  • 逻辑:根据 type 判断。如果是 container,渲染布局组件并把 children 传给它;如果是 widget,渲染加载器。
  • 伪代码实现
<component :is="config.type === 'container' ? LayoutResolver : WidgetLoader" :config="config" />

2. 布局解析器 (LayoutResolver.vue)

  • 职责:实现 FlexLayout, GridLayout, TabContainer
  • 逻辑:如果是 TabContainer,需维护一个 activeKey。每个布局组件内部必须包含:
<VisualRenderer v-for="child in config.children" :config="child" />

四. 数据与交互中心 (Data & State)

1. 宿主注入 (Dependency Injection)

SDK 不得直接访问 Pinia。必须通过 provide/inject 获取宿主提供的能力。

  • request: 执行 API 的函数 (sourceId, params) => Promise
  • globalContext: 包含全局状态的对象,如 { date: '2023-10' }
  • bizComponents: 一个对象映射表,用于“逃生通道”查找业务组件

2. 响应式监听 (useGlobalReactive.js)

  • 目标:实现 A 项目需要日期联动,B 项目不需要。

  • 逻辑

    • 使用 watch 监听 globalContext
    • 判断当前 config.reactiveParams 是否包含改变的变量。
    • 若包含,调用 fetchData 刷新数据。

五. 逃生通道实现 (The Escape Hatch)

当 config.customComponent 有值时:

  • 从 inject('bizComponents') 中寻找对应的 Vue 组件
  • 使用 <component :is="foundComponent" /> 进行渲染
  • 将 config.props 透传给该组件

六. 交互面板 (Action Bar)

用于实现 Tab 右侧差异化控制按钮。

  • 分类

    • Filter: 下拉框/搜索框。修改 TabContainer 内部的 localState
    • Action: 按钮。点击时读取 localState,调用 request 执行下载或接口调用。

七. 前端开发任务清单 (Implementation Checklist)

任务 1:核心骨架 (基础)

  • 实现 VisualRenderer 递归递归组件。
  • 实现 FlexLayout (支持 row/column) 和 GridLayout (支持 columns 配置)。

任务 2:物料加载器 (数据)

  • 实现 WidgetLoader:根据 sourceId 调用注入的 request 方法。
  • 实现 ECharts 通用渲染模板:接收 optiondata

任务 3:交互与权限 (进阶)

  • 实现 AuthGuard:在渲染每个节点前判断 config.acl
  • 实现 TabContainer 及其右侧动态 ControlRenderer

任务 4:逃生机制 (定制)

  • 实现动态组件查找逻辑,确保能挂载宿主项目传入的 .vue 文件。

八. 开发提示

  • 样式:统一使用 CSS Variables (如 --primary-color),不要写死颜色。

  • 错误处理:若 sourceId 请求失败,组件应渲染 ErrorPlaceholder 而不是白屏。

  • 性能:ECharts 实例必须在 onUnmounted 时调用 dispose()

  • sdk升级问题

    • 当 A 项目由于时间紧,无法整体重构但又要新功能时:在 SDK 1.0 中紧急发布一个补丁版本(v1.1.0),仅把 2.0 中某些核心功能组件(比如那个新的九宫格或导出按钮)以“独立插件”的形式回填。(A 项目仍然用 1.0 的框架,但局部引用了 2.0 的功能。)
    • 多版本协议兼容层: 转换函数的作用是:抹平字段差异,填充默认值。

核心渲染分发器 (VisualRenderer.vue)

它是 SDK 的唯一出口。它不负责具体的渲染,只负责根据版本号进行路由分发

<template>
  <!-- 根据版本号动态切换渲染器 -->
  <component 
    :is="currentRenderer" 
    v-bind="$attrs" 
    :config="processedConfig" 
  />
</template>

<script setup>
import { computed } from 'vue';
import RendererV1 from './v1/RendererV1.vue';
import RendererV2 from './v2/RendererV2.vue';
import { transformV1ToV2 } from './adapters/v1ToV2';

const props = defineProps({
  config: { type: Object, required: true }
});

// 1. 自动转换逻辑:如果是旧版本,且我们希望用新引擎跑,可以先转换
const processedConfig = computed(() => {
  const version = props.config.version || '1.0';
  // 如果是 1.0 的配置,但在 2.0 环境下运行,执行转换
  if (version === '1.0') {
    return transformV1ToV2(props.config);
  }
  return props.config;
});

// 2. 分发逻辑:决定使用哪个版本的 UI 渲染器
const currentRenderer = computed(() => {
  const version = processedConfig.value.version;
  if (version >= '2.0') return RendererV2;
  return RendererV1;
});
</script>
/**
 * 将 V1 版本的协议转换为 V2 版本的标准协议
 * 场景示例:V1 中图表配置在 renderOptions,V2 中统一到了 content.style
 */
export function transformV1ToV2(oldConfig) {
  // 深度克隆,避免污染原始数据
  const newConfig = JSON.parse(JSON.stringify(oldConfig));
  
  // 1. 升级版本标识
  newConfig.version = '2.0-compat'; 

  // 2. 递归处理组件
  const walk = (node) => {
    if (!node) return;

    // 示例:V1 使用 'chartType', V2 统一映射到 'component'
    if (node.type === 'chart' && node.chartType) {
      node.component = node.chartType === 'line' ? 'EChartsLine' : 'EChartsBar';
      delete node.chartType;
    }

    // 示例:V1 的数据源配置在 dataSource,V2 要求在 content 目录下
    if (node.dataSource && !node.content) {
      node.content = {
        sourceId: node.dataSource.id || node.dataSource.url,
        params: node.dataSource.params || {}
      };
      delete node.dataSource;
    }

    // 递归处理子节点
    if (node.children && Array.isArray(node.children)) {
      node.children.forEach(walk);
    }
  };

  walk(newConfig);
  return newConfig;
}

目录结构建议

src/
├── sdk/                # 核心 SDK (多项目共用)
│   ├── renderer/       # 递归渲染引擎
│   └── components/     # 基础物料库 (图表/表格/容器)
├── designer/           # 配置界面 (仅管理员可见)
│   ├── setters/        # 各类属性编辑器 (JSON Schema Form)
│   └── canvas/         # 拖拽画布/结构树
└── views/              # 各项目具体的业务页面 (引用 renderer)

九. 插件化架构

插件化架构的核心是将 SDK 从一个“巨型功能库”转变为一个“轻量级调度中心”。

在 SDK 内部,我们不再通过 if (config.xxx) 来堆砌逻辑,而是建立一套生命周期钩子(Hooks) ,将功能逻辑像“外挂”一样按需挂载。

插件化架构的三个维度

逻辑插件化:基于 Hook 的“功能注入”

不要在 WidgetLoader.vue 中写业务,它只负责调用。

// WidgetLoader.vue 核心逻辑
import { useDataFetcher } from './hooks/useDataFetcher';
import { useGlobalReactive } from './plugins/globalReactive';
import { useLocalInteraction } from './plugins/localInteraction';

export default {
  setup(props) {
    // 1. 核心功能:取数逻辑(每个组件必有)
    const { data, refresh } = useDataFetcher(props.config);

    // 2. 插件功能:全局响应(只有配置了 reactiveParams 才会真正生效)
    // 逻辑在独立文件中,SDK 主逻辑不关心它是如何监听 date 的
    useGlobalReactive(props.config, refresh);

    // 3. 插件功能:组件联动(如点击 A 刷新 B)
    useLocalInteraction(props.config, refresh);

    return { data };
  }
}

物料插件化:基于“组件映射表”的解耦

SDK 内部不 import 具体的业务图表,只维护一个 ComponentMap

  • SDK 内部:只有一个 BaseChart.vue
  • 宿主项目:在 app.use(SDK, { components: { MySpecialChart } }) 时注入。
  • 效果:如果 A 项目要一个“极其复杂的 3D 飞线图”,你直接在 A 项目里写,SDK 根本不需要知道它的存在。

协议插件化:自定义字段解析

允许 SDK 注册“协议处理器”。

// 比如你想增加一个“水印”功能,但不想改 SDK 源码
VisualSDK.use({
  name: 'watermark-plugin',
  // 当解析到包含 'watermark': true 的 JSON 节点时触发
  onRender(node, el) {
    if (node.watermark) {
      applyWatermark(el);
    }
  }
});

为什么要这么做?

特性 传统模式 (硬编码) 插件化模式 (解耦)
功能隔离 改了日期联动,可能会把权限逻辑改坏。 日期联动逻辑在globalReactive.js,权限在auth.js,互不干扰。
代码体积 所有功能都打进包里,B 项目不需要也要加载。 可以实现 Tree-shaking,没用到的插件逻辑不打包。
多人协作 大家都在WidgetLoader里改代码,冲突不断。 每个人负责不同的 Hook 文件。
应对强势方 “这个功能 SDK 不支持,我得改源码”。 “我在项目里写个插件/组件注入进去就行了”。

💡 针对你的场景:如何落地插件化?

  • 抽离 Hook:把 API 请求全局状态监听事件广播 分别写成 src/sdk/hooks 下的独立文件。

  • 定义 Contract(契约)

    • useDataFetcher 必须返回 dataloading
    • useGlobalReactive 必须接受 configcallback
  • 保持 Renderer 纯净RecursiveRenderer.vue 里的代码不应超过 100 行,它只负责递归,不处理任何业务。

插件化架构是你对抗强势需求方的“盾牌”。
当他们提出奇葩需求时,你的回复从 “我要改 SDK 核心逻辑(风险大)” 变成了 “我给这个项目单独注入一个逻辑钩子(风险受控)”

十. 关键代码伪码实现

SDK拿到宿主项目提供的全局状态

如果 SDK 也要修改宿主的狀態?

可以在 app.use 的配置項中再注入一個 Action 回調函數

  • SDK 内部使用 getGlobalField 的组件实现
import { inject, computed } from 'vue';

// 定義一個 Hook 方便 SDK 內部組件獲取宿主狀態
export function useVisualContext() {
  // 注入宿主提供的全局上下文
  const context = inject('VISUAL_GLOBAL_CONTEXT', {});

  // 提供輔助方法,確保數據獲取時有默認值,防止崩潰
  const getGlobalField = (field, defaultValue = null) => {
    return computed(() => {
      // 宿主傳入的可能是個函數 (Getter),也可能是個響應式對象
      const data = typeof context === 'function' ? context() : context;
      return data[field] ?? defaultValue;
    });
  };

  return { getGlobalField };
}
<!-- WidgetLoader.vue -->
<script setup>
import { watch, onMounted } from 'vue';
import { useVisualContext } from '../hooks/useVisualContext';
import { useVisualApi } from '../hooks/useVisualApi';

const props = defineProps(['config']); // 包含 sourceId 等信息
const { getGlobalField } = useVisualContext();
const { fetchData } = useVisualApi();

// 1. 获取响应式的全局 date 状态
// getGlobalField 返回的是一个 computedRef
const globalDate = getGlobalField('date', '2023-01-01');

// 2. 封装请求逻辑
const refreshData = async () => {
  const params = {
    date: globalDate.value, // 自动获取最新的 date 值
    ...props.config.params  // 合并组件自身的参数
  };
  
  console.log(`🚀 正在为组件 ${props.config.id} 请求数据, 日期为: ${params.date}`);
  const data = await fetchData(props.config.sourceId, params);
  // ... 处理返回的数据并渲染图表
};

// 3. 监听 globalDate 的变化
// 当宿主项目中的 Pinia 状态改变时,这里会自动触发
watch(globalDate, (newDate, oldDate) => {
  if (newDate !== oldDate) {
    refreshData();
  }
});

// 4. 初始化加载
onMounted(() => {
  refreshData();
});
</script>
  • 宿主项目(Host Project)的配合
// 宿主项目 main.js
import { useDateStore } from '@/stores/date';
import VisualSDK from '@company/visual-sdk';

const app = createApp(App);
const pinia = createPinia();
app.use(pinia);

app.use(VisualSDK, {
  globalContext: () => {
    const dateStore = useDateStore();
    const userStore = useUserStore();
    return {
  token: userStore.token,
      deptId: userStore.userInfo.deptId,
      role: userStore.role,
      theme: userStore.currentTheme,
      date: dateStore.currentDate // 返回 Pinia 中的日期
    };
  }
});

SDK 内部的“条件监听”实现

WidgetLoader.vue 中,利用 Vue 3 的 watch 动态判断是否需要执行刷新逻辑。

// WidgetLoader.vue
import { watch } from 'vue';
import { useVisualContext } from '../hooks/useVisualContext';

const props = defineProps(['config']);
const { getGlobalField } = useVisualContext();

// 1. 依然获取响应式的 globalDate
const globalDate = getGlobalField('date');

// 2. 监听逻辑
watch(globalDate, (newVal) => {
  // 核心判断:检查当前组件配置中是否包含了 'date'
  const reactiveParams = props.config.content.reactiveParams || [];
  
  if (reactiveParams.includes('date')) {
    console.log('A项目:监测到 date 变化,开始刷新数据...');
    refreshData();
  } else {
    // B项目:虽然 globalDate 变了,但配置里没说要理它
    console.log('B项目:监测到 date 变化,但配置要求忽略。');
  }
});

实现一个“局部逃生”的动态组件加载器

動態組件分發 (WidgetLoader.vue)

<template>
  <div class="widget-container">
    <!-- 方案 A: 逃生通道 - 如果配置了 customComponent,直接渲染手寫組件 -->
    <component 
      :is="customComponentInstance" 
      v-if="customComponentInstance"
      v-bind="config.props" 
      :context="globalContext"
    />

    <!-- 方案 B: 標準通道 - 走 ECharts/Table 等預設模板 -->
    <StandardChartRender 
      v-else-if="config.type === 'chart'" 
      :config="config" 
    />
    
    <StandardTableRender 
      v-else-if="config.type === 'table'" 
      :config="config" 
    />
  </div>
</template>

<script setup>
import { computed, inject } from 'vue';

const props = defineProps(['config']);
// 注入宿主項目註冊的所有自定義組件表
const bizComponents = inject('BIZ_COMPONENTS', {});
const globalContext = inject('GLOBAL_CONTEXT', {});

const customComponentInstance = computed(() => {
  const name = props.config.customComponent;
  if (!name) return null;
  
  // 從注入的組件庫中尋找,找不到則報錯提示
  const comp = bizComponents[name];
  if (!comp) {
    console.error(`[SDK] 找不到逃生組件: ${name},請確認是否已在主項目註冊。`);
  }
  return comp;
});
</script>

宿主項目(業務層)如何「遞刀子」

在業務項目中,你寫好那個「逆反需求」的組件,然後在初始化 SDK 時傳進去。

步驟 1:寫一個手寫組件 UrgentRequirement.vue

<template>
  <div class="my-crazy-css">
    <h3>需求方非要的奇葩功能</h3>
    <button @click="doSomethingCrazy">點擊執行逆反邏輯</button>
  </div>
</template>

步驟 2:在 main.js 中註冊給 SDK

javascript

import UrgentRequirement from '@/biz-custom/UrgentRequirement.vue';

app.use(VisualSDK, {
  // 這裡就是「逃生艙」名單
  customComponents: {
    UrgentRequirement // 鍵名與 JSON 中的 customComponent 對應
  }
});

步骤3: JSON 配置如何調用

當遇到搞不定的需求,JSON 直接這麼寫:

{
  "id": "widget_001",
  "type": "custom", 
  "customComponent": "UrgentRequirement", // 指定逃生組件名
  "props": {
    "someData": "可以傳入自定義參數"
  }
}

将 echarts 的 option 配置 生成 json schema

ECharts 官方提供了完善的 TypeScript 类型定义。你可以通过工具将 EChartsOption 类型直接转为 JSON Schema。

  1. 安装工具npm install typescript-json-schema -g
  2. 创建入口文件 (chart.ts):

import { EChartsOption } from 'echarts';
export interface MyChartOption extends EChartsOption {}

命令行生成

typescript-json-schema chart.ts MyChartOption --out echarts-schema.json

布局组件中的局部状态如何实现?

实现 Tab 级别的局部状态管理,核心在于 “数据向上汇聚,状态向下分发”

你需要为每个 TabContainer 实例建立一个响应式的 context 对象,并通过 Vue 的 provide / inject 机制或 Props 穿透,让内部所有的控制组件(Filters)和内容组件(Charts/Tables)共享这个状态。

  1. 定义 Tab 内部状态结构

每个 Tab 页签维护一个独立的 state 对象,结构如下:

{
  "activeTab": "overview",
  "tabStates": {
    "overview": { "region": "sh", "timeRange": [] }, // A Tab 的过滤参数
    "detail": { "keyword": "" }                      // B Tab 的过滤参数
  }
}
  1. 前端组件实现逻辑

第一步:在 TabContainer.vue 中建立状态中心
使用 reactive 初始化各 Tab 的默认参数。

<!-- TabContainer.vue -->
<script setup>
import { reactive, provide, watch } from 'vue';

const props = defineProps(['config']); // 传入的 JSON 配置

// 1. 初始化每个 Tab 的状态池
const tabFilterData = reactive({});
props.config.children.forEach(tab => {
  tabFilterData[tab.tabKey] = {};
  // 注入初始默认值
  tab.controls?.forEach(ctrl => {
    tabFilterData[tab.tabKey][ctrl.field] = ctrl.defaultValue || null;
  });
});

// 2. 将当前活跃 Tab 的状态“提供”给下级所有组件
provide('tabContext', {
  filterData: tabFilterData,
  activeKey: props.activeKey 
});
</script>

第二步:控制组件(Filters)修改状态

<!-- ControlRenderer.vue (过滤栏) -->
<template>
  <component 
    :is="componentMap[ctrl.type]"
    v-model="tabContext.filterData[activeKey][ctrl.field]"
    @change="onFilterChange"
  />
</template>

<script setup>
const { filterData, activeKey } = inject('tabContext');
</script>

第三步:内容组件(Charts)监听状态并刷新
图表组件深层注入这个状态,一旦发现属于自己 Tab 的参数变了,自动触发接口请求。

<!-- WidgetLoader.vue (内容加载器) -->
<script setup>
const { filterData, activeKey } = inject('tabContext');
const props = defineProps(['config']); // 包含 sourceId

// 监听属于当前 Tab 的过滤数据变化
watch(
  () => filterData[activeKey], 
  (newParams) => {
    // 重新组合参数并发起请求
    loadData(props.config.sourceId, newParams);
  }, 
  { deep: true }
);
</script>

状态管理的三个关键细节

  1. 状态隔离(Namespace) 🔒
  • 问题:A Tab 的日期筛选不应该影响 B Tab 的图表。
  • 解决:在 tabFilterData 中以 tabKey 作为命名空间。只有当用户切换回该 Tab 时,对应的状态才生效。
  1. 参数合并优先级 ⚡

渲染引擎在发起 API 请求前,会按以下顺序合并参数:

  1. 静态参数:数据源配置里写死的 url?type=1
  2. Tab 局部参数:控制组件选中的 region=sh
  3. 全局参数:如用户信息、当前选中的部门 ID。
  • 代码实现const finalParams = { ...staticParams, ...globalParams, ...tabParams };
  1. 强势方的特殊需求:联动全局
  • 需求:在 A Tab 选了日期,希望切换到 B Tab 时日期也是选好的。
  • 解决:在 JSON 配置中增加一个标识 syncToGlobal: true。如果有此标识,状态变更时同步写入 Pinia 的全局状态池,其他 Tab 初始化时优先读取全局值。

从0开始搭建Vue3前端骨架(附登录接口案例)

作者 MoXC
2026年5月5日 11:53

从0开始搭建Vue3前端骨架(附登录接口案例)

本文将带你从零搭建一个企业级 Vue 3 后台管理骨架,包含登录、路由守卫、状态管理、Axios 封装等核心模块。

::⚠️: 如果修改了 npm 全局路径,升级 Node.js 前最好备份并清理这些目录,避免权限问题

  • 使用

🛠 技术栈

说明:以上版本号为本文撰写时(2026.05)项目所依赖的实际版本,后续升级请以 package.json 为准。

技术 版本 用途
Vue ^3.5.33 前端框架
TypeScript ^6.0.3 类型安全
Vite ^8.0.10 构建工具
Pinia ^3.0.4 状态管理
Vue Router ^5.0.6 路由
Element Plus ^2.13.7 UI 组件库
Tailwind CSS ^3.4.19 原子化 CSS
Axios ^1.16.0 HTTP 客户端

🛠 开发环境与工具

  • IDE:IntelliJ IDEA(推荐) / VS Code
  • Node.js:20.19.0 或更高版本(本教程使用 v24.15.0)
  • 包管理器:npm(Node.js 自带)

一、环境准备

1.1 Node.js 安装与配置

1.1.1 下载 Node.js

在浏览器中输入 nodejs.cn 或点击下方链接 进入中文官网

Node.js 中文网

1777801221714

点击 下载安装

1777801333393

选择匹配的操作系统,点击下载

1777808298374

下载完成

1777808955660

1.1.2 下载其他版本的 Node.js (可以跳过该步骤,有版本需求可选择观看)

如果需要用到其他版本的 Node.js ,可以按下面的方式操作

点击 全部安装包

1777808566469

路径末尾处就是版本号,需要什么版本就在末尾输入对应的版本号,下面以 v22.10.0 为例

1777808643346

输入 v20.10.0 后,即可得到下面的页面

1777809106652

  • Windows 用户选择 .msi 安装包
  • macOS 用户选择 .pkg
  • Linux 用户使用包管理器或二进制文件。

windows 64位选择如下

1777809580369

下面提供另一种查找方法

去除路径末尾的版本号

1777808779699

回车

1777808805306

得到下面的界面

1777808842287

向下拖动(会很长),就能找到我们需要的版本了,点击该版本

1777808910668

进入下面的页面

1777809029725

  • Windows 用户选择 .msi 安装包
  • macOS 用户选择 .pkg
  • Linux 用户使用包管理器或二进制文件。

windows 64位选择如下

1777809580369

1.1.3 安装

直接双击即可,接下来 一路 next 即可

1777809720677

1777809750525

1777809771920

1777809819072

1777809890613

1777809903446

1777809915025

1777810006701

1.1.4 验证

win + r 进入运行界面,输入 cmd ,回车

1777810664459

输入 node -v 查看 node 的版本

node -v

1777810727613

输入 npm -v 查看 npm 的版本

npm -v

1777810784587

能看到版本就说明安装成功了 ^ v ^

1.1.5 配置全局路径 (可以跳过该步骤,推荐完成)

作用:存放 全局安装的模块(通过 npm install -g 安装的包)

Node 安装目录下新建 node_global 文件夹

node_global

1777811415447

进入 node_global 文件夹,并复制其路径

1777811541827

在此处输入如下指令,设置 npm 全局安装的模块 的存放位置

npm config set prefix

1777811771037

路径为我们之前复制的 node_global 文件夹的路径,将路径复制在后面,回车

1777811901195

1.1.6 配置缓存路径(可以跳过该步骤,推荐完成)

作用:存放 npm 下载包时的缓存文件 。当执行 npm install 时,npm 会先将下载的包缓存到这里,下次再安装相同版本时直接从缓存读取,提高安装速度

Node 安装目录下新建 node_cache 文件夹

1777812137791

进入 node_cache 文件夹,并复制其路径

1777812215494

在此处输入如下指令, 设置 npm 下载包时的临时缓存目录

npm config set cache

1777812422335

路径为我们之前复制的 node_cache 文件夹的路径,将路径复制在后面,回车

1777812511615

1.1.7 配置国内的镜像

(2026.5.3 该镜像还能使用)

npm config set registry https://registry.npmmirror.com

1777812673937

1.2 包管理器选择(npm / pnpm)

包管理器常用的有以下几种:

  • npm(Node Package Manager):Node.js 自带,无需安装,命令简洁,生态最广。
  • pnpm:快速的、省空间的替代品,通过硬链接共享依赖,适合大型项目或多项目开发。
  • yarn:历史选择,目前已较少用于新项目。
1.2.1 本教程的选择

我们使用 npm 作为包管理器,因为:

  1. 安装 Node.js 后即可使用,无需额外步骤。
  2. 绝大多数 Vue 3 官方文档和示例都采用 npm。
  3. 对于本项目的规模,npm 的性能完全够用。

如果你希望尝试 pnpm,可以后续自行替换命令(例如 pnpm install 代替 npm install),不影响项目运行。

二、创建 Vue3 项目(含 TypeScript)

2.1 创建项目

2.1.1 创建文件夹

新建前端文件夹

frontend

1777814012394

2.1.2 idea 打开该文件夹

选择刚才创建的文件夹打开

1777814386663

界面如下

1777814503969

2.1.3 创建 Vue3 项目

打开终端

npm create vue@latest

这是 终端权限问题 ,解决方法请看 附录:常见问题(FAQ) -> 终端问题解决

1777815523989

第一次创建会有提示,输入 y 回车

1777818139368

输入项目名称,这个可以自己定义,回车

book-trading-frontend

1777818335395

这里选择 Yes

1777818395321

↑/↓ 箭头切换,空格 选中

1777818521674

这两个都不用选,直接回车

1777818563448

移动上下箭头,选择 Yes

1777818615090

效果,这个文件夹就是我们创建的 vue3项目(项目名称可能不同)

1777818658346

2.1.4 安装所有依赖

路径切换到我们创建的 vue3 项目

1777820552931

为 vue3 项目安装所有依赖(过程可能会有点久,我这里安装了5min)

npm i

1777821529413

2.1.5 启动项目(ctrl + c 停止项目)
npm run dev

1777821717401

点击链接,出现如下界面说明成功了 ^ v ^

1777821769327

2.2 项目结构解读

1777822516579

这里可以简单了解一下

book-trading-frontend/
├── .vscode/                 # VS Code 编辑器配置文件夹(推荐设置)
├── node_modules/            # 项目依赖包目录(自动生成,无需手动修改)
├── public/                  # 公共静态资源(不会经过构建,直接复制到 dist)
│   └── favicon.ico          # 浏览器标签页图标
├── src/                     # 源代码目录(核心开发区域)
│   ├── router/
│   │   └── index.ts         # 路由配置文件(已自动配置基础路由)
│   ├── stores/
│   │   └── counter.ts       # Pinia 示例 store(演示计数功能,可删除)
│   ├── App.vue              # 根组件(整个应用的容器)
│   └── main.ts              # 应用入口文件(挂载 Vue、Router、Pinia)
├── .editorconfig            # 编辑器统一风格配置(缩进、字符集等)
├── .gitattributes           # Git 属性配置(如换行符处理)
├── .gitignore               # Git 忽略文件列表(已预设 node_modules 等)
├── .oxlintrc.json           # Oxlint 代码检查工具配置文件
├── .prettierrc.json         # Prettier 代码格式化规则(如单引号、无分号)
├── env.d.ts                 # TypeScript 环境类型声明(如 Vite 的 import.meta.env)
├── eslint.config.ts         # ESLint 配置文件(TypeScript 格式)
├── index.html               # 宿主 HTML 文件,Vue 应用将挂载到其中的 <div id="app">
├── package.json             # 项目依赖与脚本定义(如 dev、build、preview)
├── package-lock.json        # 依赖锁定文件(保证安装版本一致)
├── README.md                # 项目说明文件(可自行补充内容)
├── tsconfig.app.json        # TypeScript 配置(针对 src 目录)
├── tsconfig.json            # TypeScript 根配置文件(引用其他 tsconfig)
├── tsconfig.node.json       # TypeScript 配置(针对 vite.config.ts 等 Node 环境文件)
└── vite.config.ts           # Vite 构建配置文件(定义插件、别名、代理等)

三、整理目录结构与文件

3.1 api

作用:管理前端向后端发送的请求

src 目录下创建 api 目录

1777860273858

api 目录下创建 servicestypes 目录

  • services:存放API请求函数
  • types:存放TypeScript类型定义

1777861082772

3.2 assets

作用:存放需要经过构建工具处理的静态资源 ,例如图片、字体、全局样式文件等

src 目录下创建 assets 目录

1777864306324

3.3 layouts

作用:存放布局组件,例如整个后台管理系统的公共框架(侧边栏菜单、顶部导航栏、底部版权信息、内容区域等)

src 目录下创建 layouts 文件夹

1777862140320

3.4 stores

作用:存放 Pinia 定义的 store 文件 (类似于java中的全局变量、全局方法等)

counter.ts 是演示示例,可以保留也可以删除,这里选择删除示例

1777861217847

3.5 views

作用:存放具体的页面

src 目录下创建 views 目录

1777862253451

四、集成 Element Plus 与 Tailwind CSS

4.1 Element Plus

4.1.1 安装

安装指令:

npm i element-plus --save

1777862597481

安装成功(7min):

1777863055087

4.1.2 在 main.ts 中引入
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'

1777863333394

接下来我们导入后,还要交给 app 使用(注意在挂载 mount 之前)

app.use(ElementPlus)

1777863488110

4.1.3 验证

App.vue 中创建一个按钮组件(因为是 element-plus 的组件,所以一般都是 el-xxx

能看见提示说明成功了

1777863639979

我们可以随便写点

<el-button>Button</el-button>

1777863700040

启动项目

npm run dev

1777863795093

点开地址可以看到如下效果

1777863834630

4.1.4 Element Plus 官网

A Vue 3 UI Framework | Element Plus

官网上介绍了如何安装使用,这里可以简单看一下,和我们的安装步骤是一样的

右上角可以切换中文

1767149910079

点击指南

1767150016853

左侧找到快速开始

1767150047195

1767150116755

npm的安装指令也有

npm install element-plus --save

1767150189598

4.2 Tailwind CSS

4.2.1 安装

安装指令:

npm install -D tailwindcss

1777864404732

安装成功(3s):

1777864441133

初始化指令:

(此处出错的可以前往 附录:常见问题(FAQ) -> Tailwind CSS初始化错误

npx tailwindcss init -p

打开我们初始化得到的 tailwind.config.js

1777865184605

content 添加如下内容

/** @type {import('tailwindcss').Config} */
export default {
  content: [
    "./index.html",         // 扫描根目录的 HTML 文件
    "./src/**/*.{vue,js,ts,jsx,tsx}", // 扫描 src 下所有 Vue/JS/TS 等文件
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

1777865269090

4.2.2 在 main.css 中引入

assets 目录下创建 main.css 用来存放全局样式

1777865396032

tailwind 引入

main.css

@tailwind base;
@tailwind components;
@tailwind utilities;

1777865463197

4.2.3 在 main.ts 中引入
import './assets/main.css'

1777865598632

4.2.4 验证

App.vue 中使用,添加 class 样式,可以看到有如下 tailwind css 提示

1777865814254

启动项目

npm run dev

可以看到如下效果:

1777865925463

4.2.5 Tailwind CSS 官网

安装 - TailwindCSS中文文档 | TailwindCSS中文网

这里有安装步骤,略有不同

1767156327306

往下滑可以查看这些样式,ctrl + k 可以搜索样式进行查看

1767156445008

例如:对 背景色 并不熟悉,可以通过下面的方法查看

  • ctrl + k 打开搜索

    1767156445008

  • 输入关键词 background color ,选择第一个进行查看

    1767156445008

  • 左侧的是 tailwind ,右侧的是其对应的 纯css 样式

    1767156445008

  • 直接搜索不懂的样式也是可以的

    1767156445008

五、封装 Axios 请求模块(核心)

5.1 安装

安装指令:

npm i axios

1777866273711

安装成功(18s):

1777866293061

5.2 创建 request.ts 并配置拦截器

request.ts

1777866427488

request.ts

type仅用作类型注解,不会在运行时代码中出现

import axios, { type AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'

// 创建 axios 实例,配置基础 URL
const service = axios.create({
  baseURL: '/api'
})

// 请求拦截器:拦截前端向后端发送的所有请求
service.interceptors.request.use(
  (config) => {
    // 后续登录功能实现后,可在此处添加 token
    return config
  },
  (error) => Promise.reject(error)
)

// 响应拦截器:拦截后端向前端返回的所有响应
service.interceptors.response.use(
  (response: AxiosResponse) => {
    return response.data
  },
  (error) => Promise.reject(error)
)

export default service

5.3 解决跨域问题

跨域问题描述:

我们的前端是http://localhost:5173,后端假设是http://localhost:8080

只要 协议、域名、端口 有任何一项不同,就属于“跨域”,我们的前端和后端的端口不同,前端去请求后端就会产生跨域问题

Home | Vite中文网

我们需要将这一段配置写到 vite.config.ts

1767688199140

也就是这一段,target 我改成我们以后的后端端口,这样就能和后端连接了

server: {
    proxy: {
      // 统一代理所有以 /api 开头的请求
      '/api': {
        target: 'http://localhost:8080',  // 1. 目标:转发到后端服务器
        changeOrigin: true, // 2. 关键:改变请求头中的Origin为目标地址,防止某些后端校验失败
         // 3. 重写路径:用''空字符串代替`/api`,/是转移,^是以什么开头
        rewrite: (path) => path.replace(/^/api/, ''),
      }
    }
  }

1777867094288

整体代码如下

1777867125703

六、登录与布局页面设计

6.1 login.vue 登录页面

6.1.1 创建页面

views 目录下如下配置

1777872708153

6.1.2 注册路由

路由注册,将 login/index.vue 页面交给 index.ts 管理

顺便将根路径 /redirect (重定向)到 /login

同时改造一下 router

index.ts

import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  { path: '/', redirect: '/login'},
  { path: '/login', component: () => import('@/views/login/index.vue') },
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

1777873143266

修改 App.vue 让它能够通过 路由 来展示 login/index.vue 页面

  • <router-view></router-view> :展示路由中的页面
<script setup lang="ts"></script>

<template>
  <router-view></router-view>
</template>

<style scoped></style>

1777874634946

6.1.3 页面实现

简单编写一下 index.vue

login/index.vue

<script setup lang="ts">

// 登录
const login = () => {

}
</script>

<template>
  <el-button class="text-pink-500" @click="login">员工登录</el-button>
</template>

<style scoped>

</style>

启动项目 npm run dev,可以看到如下效果

1777875535264

6.2 Layout.vue 布局组件

6.2.1 创建页面

1777872335306

6.2.2 路由注册

路由注册,将 Layout.vue 页面交给 index.ts 管理

{ path: '/layout', component: () => import('@/layouts/Layout.vue')},

1777875381871

6.2.3 页面实现

前往 Element Plus 官网

Container 布局容器 | Element Plus

我们使用这种布局

1777876089909

<template>
  <div class="common-layout">
    <el-container>
      <el-header>Header</el-header>
      <el-container>
        <el-aside width="200px">Aside</el-aside>
        <el-main>Main</el-main>
      </el-container>
    </el-container>
  </div>
</template>

1777881550656

修改 login/index.vue 中的 login 函数,使其跳转至 Layout.vue 页面

// 获得路由器实例
const router = useRouter()

// 登录
const login = () => {
  router.push('/layout')
}

1777875787061

启动项目 npm run dev 效果如下:

1777881581806

稍加改造

Layout.vue

<script setup lang="ts">
import {useRouter} from "vue-router";
import { SwitchButton } from '@element-plus/icons-vue'

const router = useRouter()

const logout = () => {
  router.push('/login')
}
</script>

<template>
  <div class="common-layout">
    <el-container>
      <el-header class="header">
        <div class="brand">
          <div class="h-left">
            <div class="font-bold">员工管理</div>
          </div>
          <div class="h-right">
            <a href="" @click="logout">
              <el-icon><SwitchButton /></el-icon> 退出
            </a>
          </div>
        </div>
      </el-header>
      <el-container>
        <el-aside class="h-screen w-40 bg-pink-50">Aside</el-aside>
        <el-main class="p-5">
          hello
        </el-main>
      </el-container>
    </el-container>
  </div>
</template>

<style scoped>
.header {
  @apply border-b border-gray-200
  shadow-sm p-8
}
.brand {
  @apply flex items-center justify-between
  h-full
}
</style>

启动项目,效果如下:

1777881640491

6.3 Layout.vue 的子页面

1777881734207

6.3.1 创建页面

views 目录下创建两个 vue 页面

1777881930945

workbench/index.vue

<script setup lang="ts">

</script>

<template>
  <p>控制台页面</p>
</template>

<style scoped>

</style>

data/index.vue

<script setup lang="ts">

</script>

<template>
  <p>数据管理页面</p>
</template>

<style scoped>

</style>

6.3.2 配置路由

index.ts 如下:

import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  { path: '/', redirect: '/login'},
  { path: '/login', component: () => import('@/views/login/index.vue') },
  {
    path: '/layout',
    component: () => import('@/layouts/Layout.vue'),
    redirect: '/layout/workbench',
    children: [
      { path: 'workbench', component: () => import('@/views/workbench/index.vue') },
      { path: 'data', component: () => import('@/views/data/index.vue')},
    ]
  },
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

1777882318174

6.4 实现菜单

我们要实现的效果是,点击 侧边栏菜单项 ,右边展示对应菜单项的页面

1777882580801

Element Plus 官网上找到我们需要的代码

Menu 菜单 | Element Plus

1777882758502

1777882813560

我将源码简化处理了,保留了两个菜单项

  • router:使用路由,就可以进行页面的跳转了
  • index:指定跳转的路径
<el-menu
         class="h-screen"
         router
         >
    <el-menu-item index="/layout/workbench">工作台</el-menu-item>
    <el-menu-item index="/layout/data">数据统计</el-menu-item>
</el-menu>

将这段代码放到 <el-aside> ,也就是侧边区域

1777883068487

el-main 区域设置 <router-view></router-view> ,相当于将 “展台” 设置在 el-main 区域,页面就可以在站台上展示了。App.vue 中也设置了这个

<router-view></router-view>

1777883244852

效果如下:

1777883293429

6.5 404 页面

6.5.1 创建页面

1777884173882

6.5.2 路由注册
{ path: '/:pathMatch(.*)', component: () => import('@/views/NotFound/index.vue') },

1777884246497

6.5.3 页面实现

Result 结果 | Element Plus

1777884402867

我将官网的代码修改一下

<script setup lang="ts">

import router from "@/router";
import { CircleCloseFilled } from '@element-plus/icons-vue'
</script>

<template>
  <div class="flex justify-center items-center min-h-screen">
    <el-result>
      <template #icon>
        <CircleCloseFilled style="width: 100px; height: 100px; color: red"/>
      </template>
      <template #title>
        <div class="text-3xl text-gray-700">这个页面不存在</div>
      </template>
      <template #sub-title>
        <div class="text-xl text-gray-600">请检查您输入的网址是否正确</div>
      </template>
      <template #extra>
        <el-button
          type="primary"
          @click="router.push('/layout/workbench')"
          :size="'large'"
          style="width: 100px; height: 45px"
          class="text-lg"
          round
        >
          返回首页
        </el-button>
      </template>
    </el-result>
  </div>
</template>

<style scoped>

</style>

将代码复制到此处

1777884779293

输入一个错误的路径,可以看到自定义的404页面

http://localhost:5173/layout/workbenchgew

1777884839089

代码解释

1777884727727

到这里,前端的骨架差不多就搭建完成了,如果想知道具体的用法,可以看看后续的代码 ^ v ^

七、实现登录功能(前后端联调)

本章的后端接口及数据结构 仅为演示 ,实际开发请根据项目真实接口调整。

7.1 员工登录接口文档(仅供演示参考)

7.1.1 基本信息

请求路径:/admin/employee/login

请求方式:POST

接口描述:员工登录,验证用户名和密码,成功后返回 JWT 令牌及员工基本信息

7.1.2 请求参数

参数格式:application/json

参数说明:

参数名 类型 是否必须 备注
username string 必须 用户名
password string 必须 密码

请求示例:

{
    "username": "admin",
    "password": "123456"
}
7.1.3 响应数据

参数格式:application/json

参数说明:

参数名 类型 是否必须 备注
code number 必须 响应码,1 代表成功,0 代表失败
msg string 非必须 提示信息
data object 非必须 返回的数据
- id number 非必须 主键值
- userName string 非必须 用户名
- name string 非必须 姓名
- token string 非必须 JWT 令牌

响应数据样例(成功):

json

{
    "code": 1,
    "data": {
        "id": 1,
        "userName": "admin",
        "name": "管理员",
        "token": "eyJhbGciOiJIUzI1NiJ9.eyJlbXBJZCI6MSwiZXhwIjoxNzc3NDQyNjU2fQ.8zKmwKV2L6tOLejQPGH49p1Kb8cYvwdmbZmRwQ2HfF4"
    },
    "msg": null
}

7.2 后端配置(仅供演示参考)

7.2.1 Result 后端统一返回类
import lombok.Data;

import java.io.Serializable;

/**
 * 后端统一返回结果
 * @param <T>
 */
@Data
public class Result<T> implements Serializable {

    private Integer code; // 编码:1成功,0和其它数字为失败
    private String msg; // 错误信息
    private T data; // 数据

    public static <T> Result<T> success() {
        Result<T> result = new Result<T>();
        result.code = 1;
        return result;
    }

    public static <T> Result<T> success(T object) {
        Result<T> result = new Result<T>();
        result.data = object;
        result.code = 1;
        return result;
    }

    public static <T> Result<T> error(String msg) {
        Result result = new Result();
        result.msg = msg;
        result.code = 0;
        return result;
    }

}

7.2.2 EmployeeLoginDTO
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import java.io.Serializable;

@Data
@ApiModel(description = "员工登录时传递的数据模型")
public class EmployeeLoginDTO implements Serializable {

    @ApiModelProperty("用户名")
    private String username;

    @ApiModelProperty("密码")
    private String password;

}
7.2.3 EmployeeLoginVO
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ApiModel(description = "员工登录返回的数据格式")
public class EmployeeLoginVO implements Serializable {

    @ApiModelProperty("主键值")
    private Long id;

    @ApiModelProperty("用户名")
    private String userName;

    @ApiModelProperty("姓名")
    private String name;

    @ApiModelProperty("jwt令牌")
    private String token;

}
7.2.4 EmployeeController
@RestController
@RequestMapping("/admin/employee")
public class EmployeeController {
    @PostMapping("/login")
    public Result<EmployeeLoginVO> login(@RequestBody EmployeeLoginDTO employeeLoginDTO) {
        // 略...
        return Result.success(employeeLoginVO);
    }
}

7.3 前端创建与后端一致的配置

7.3.1 ApiResponse 通用响应类型

对标后端的 Result

1777873689190

types 目录下创建 common.types.ts

code 必须,其他两个非必须

// API 通用响应类型
export interface ApiResponse<T> {
  code: number
  data?: T
  msg?: string
}
7.3.2 DTO 与 VO 数据格式

对标后端的 EmployeeLoginDTOEmployeeLoginVO

1777874139168

types 目录下创建 employee.types.ts

// 员工登录请求数据格式
export interface EmployeeLoginDTO {
  username: string
  password: string
}

// 员工登录返回的数据格式
export interface EmployeeLoginVO {
  id: number
  userName: string
  name: string
  token: string
}

7.4 login 页面实现

7.4.1 登录界面实现

这是一个简单的,只包含 用户名密码 的表单

login/index.vue

<script setup lang="ts">
import { reactive } from 'vue'
import type {EmployeeLoginDTO} from "@/api/types/employee.types.ts";

const form = reactive<EmployeeLoginDTO>({
  username: '',
  password: ''
})

// 登录
const handleLogin = () => {

}
</script>

<template>
  <div class="flex justify-center items-center min-h-screen">
    <div class="login-card">
      <h2>登录</h2>
      <el-form>
        <el-form-item>
          <el-input
            v-model="form.username"
            placeholder="用户名"
            clearable
          />
        </el-form-item>
        <el-form-item>
          <el-input
            v-model="form.password"
            type="password"
            placeholder="密码"
            show-password
            clearable
          />
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleLogin">登录</el-button>
        </el-form-item>
      </el-form>
    </div>
  </div>
</template>

<style scoped>
.login-card {
  @apply w-80 p-4 rounded-lg bg-white border;
}
.login-card h2 {
  @apply text-center mb-6;
}
.el-form-item {
  @apply mb-4;
}
.el-button {
  @apply w-full;
}
</style>

页面效果如下:

1777886684174

7.4.2 表单校验实现

Form 表单 | Element Plus 1777886789732

获取表单实例

// 获取表单实例
const ruleFormRef = ref<FormInstance>()

1777888267538

实例获取对象为 表单中的数据

ref="ruleFormRef"

1777887886565

这一步的作用是拿到表单中数据,且 ruleFormRef 中会包含一些特殊的方法,更加方便我们进行表单校验

表单验证内容

关于 username 数据的验证讲解

  • required: true :必填项
  • message: '请输入用户名' :不符合要求(没有填写),会展示 “请输入用户名”
  • trigger: 'blur':判断触发时机,这里是 blur 即失去聚焦
// 表单验证
const rules = reactive<FormRules<EmployeeLoginDTO>>({
  username: [
    {required: true, message: '请输入用户名', trigger: 'blur'},
    {min: 3, max: 10, message: '长度在 3 到 10 个字符', trigger: 'blur'}
  ],
  password: [
    {required: true, message: '请输入密码', trigger: 'blur'},
    {min: 6, max: 20, message: '长度在 6 到 20 个字符', trigger: 'blur'}
  ]
})

1777888401471

获取表单中的数据,也就是获取 usernamepassword 两个输入框中的内容

数据类型为 EmployeeLoginDTO ,也就是我们要提交给后端的数据

1777888902884

fromrules 绑定给表单

:model="form"
:rules="rules"

1777895007927

from 中的元素需要通过 prop 绑定给表单项

1777895091357

效果如下:

1777895110191

7.5 登录逻辑

7.5.1 登录接口 api 实现

api/services 目录下创建 employee.ts ,用来存放员工相关的 api

1777895237748

employee.ts

// 员工登录
export const loginApi = () => {

}

接下来通过 接口文档 分析 参数返回值

  • 参数EmployeeLoginDTO 类型的参数,即

    • username
    • password
  • 返回值data 中存储的是 EmployeeLoginVO 类型的返回值,即

    • code

    • data

      • id
      • username
      • name
      • token
    • msg

请求方式为 Post,路径为 /admin/employee/login

因此,api 设计如下:

employee.ts

// 员工登录
import type {EmployeeLoginDTO, EmployeeLoginVO} from "@/api/types/employee.types.ts";
import type {ApiResponse} from "@/api/types/common.types.ts";
import request from "@/api/request.ts";

export const loginApi = (employeeLoginDTO: EmployeeLoginDTO): Promise<ApiResponse<EmployeeLoginVO>> => {
  return request.post('/admin/employee/login', employeeLoginDTO)
}
7.5.2 登录页面调用 api
const router = useRouter()

// 登录
const handleLogin = () => {
// 没有填写值,直接返回
  if (!ruleFormRef.value) return
  // ruleFormRef 是表单实例,它里面有 validate 方法,可以验证表单
  // 若验证通过,则返回 true,并赋值给valid;否则返回 false,并赋值给valid
  ruleFormRef.value.validate(async (valid) => {
    if (valid) { // valid 表示验证是否通过
      // 调用登录接口,将 form 值传递给接口
      const res = await loginApi(form)

      // code 为 1 表示登录成功
      if (res.code === 1 && res.data) {
        ElMessage.success("登录成功!")

        // 存储当前员工信息到浏览器中
        localStorage.setItem('token', res.data.token)
        // JSON.stringify (这里是将对象)转换成字符串
        localStorage.setItem('loginUser', JSON.stringify(res.data))

        router.push('/layout')
      } else {
        ElMessage.error(res.msg)
      }
    } else {
      console.log('请按照表单规则填写')
    }
  })
}

1777896629925

登录效果如下(我有后端程序,因此能够成功得到反馈)

1777896542098

7.6 创建 Pinia Store 管理用户状态(stores/user.ts

因为用户登录后的信息后期会经常用到,像 tokenusername 这些,因此,可以将这些方法存储在仓库,要用到的时候就可以随时调用

创建 user.ts

1777897082225

user.ts

import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { EmployeeLoginVO } from '@/api/types/employee.types'

// 创建用户仓库
export const useUserStore = defineStore('user', () => {
  // 存储 token
  const token = ref(localStorage.getItem('token') || '')
  // 存储 用户信息
  const userInfo = ref<EmployeeLoginVO | null>(
    (() => {
      const stored = localStorage.getItem('loginUser')
      return stored ? JSON.parse(stored) : null
    })() // () 作用为立即执行函数,返还值作为ref的初始值;如果没有它,ref得到的就是一个函数体
  )

  // 用户登录 存储 token 和 用户信息
  function setLogin(data: EmployeeLoginVO) {
    token.value = data.token
    userInfo.value = data
    localStorage.setItem('token', data.token)
    localStorage.setItem('loginUser', JSON.stringify(data))
  }

  // 用户登出 删除 token 和 用户信息
  function logout() {
    token.value = ''
    userInfo.value = null
    localStorage.removeItem('token')
    localStorage.removeItem('loginUser')
  }

  // 返回用户仓库
  return { token, userInfo, setLogin, logout }
})

修改 login/index.vue 中的登录存储逻辑

将存储功能改为 调用 user.ts 中的方法

删除当前选中的代码

1777897950513

// 拿到仓库实例
const userStore = useUserStore()

1777898085056

// 用户仓库保存登录信息
userStore.setLogin(res.data)

1777898189506

7.7 路由守卫

为了防止用户未登录就访问到内部页面,需要设置路由守卫,没有登录就跳转到登录界面

  • to:即将要进入的目标路由对象(包含路径、参数、查询参数等完整信息),也就是 哪个路径发出的请求

  • from:当前导航正要离开的路由对象,也就是 这个请求希望跳转的路径

  • next:能够决定当前请求如何执行

    • next():放行,允许进入目标路由。
    • next('/login'):中断当前导航,重定向到指定路径。
    • next(false):中断导航,停留在当前页面。
// 路由守卫:检查是否登录
router.beforeEach((to, from, next) => {
  const userStore = useUserStore()
  // 通过用户仓库的 token 判断,如果没有 token,则跳转到登录页面
  if (to.path !== '/login' && !userStore.token) {
    next('/login')
  } else {
    next()
  }
})

1777898806379

7.8 完善请求拦截器:自动添加 Token

请求拦截器:所有向后端发送的请求都会经过请求拦截器

除了登录操作,其他任何操作都需要将 token 交给后端进行判断。我们通过 request.ts 中的 请求拦截器 ,统一设置 token ,就可以减少很多操作

后端 .yml 文件中明确提到—— 前端传递过来的令牌名称token

1777899306032

(该图为后端 application.yml 中的配置,具体情况请参考自己的后端相关配置)

因此,请求头 中也要叫 token ,即

config.headers.token

// 从仓库中获取 token
const userStore = useUserStore()
if (userStore.token) {
    config.headers.token = userStore.token
}

1777899431859

7.9 完善响应拦截器:统一处理业务状态码

响应拦截器:所有后端返回的信息都会经过响应拦截器

后端返回的数据固定为:

  • code:状态码。1为成功,0为失败
  • data:具体的数据
  • msg:错误信息

可以看到,code 为 0 就没必要进行后续的页面代码了

因此,我们可以在响应拦截器提前处理

    const res = response.data
    if (res.code !== 1) {
      ElMessage.error(res.msg || '请求失败')
      return Promise.reject(new Error(res.msg || 'Error'))
    }

1777899899165

login/index.vue 中可以去除关于 code 的判断

1777900029905

7.10 退出登录逻辑

点击 退出 按钮,需要退回到 登录页面 ,并且 将用户信息清除

1777900338844

// 拿到仓库实例
const userStore = useUserStore()

// 退出登录逻辑
const logout = () => {
  // 调用用户仓库的 logout 方法退出登录,会清除用户信息
  userStore.logout()
  router.push('/login')
}

1777900404020

附录:常见问题(FAQ)

终端权限问题

快速解决
  1. 关闭 idea

  2. 在桌面 右键 ,选择 以管理员身份运行

    1777815095720

Tailwind CSS 初始化错误

1777865086112

# 先卸载
npm uninstall tailwindcss
# 重新安装(明确指定版本)
npm install -D tailwindcss@3 postcss autoprefixer
# 然后用相对路径执行初始化
.\node_modules.bin\tailwindcss init -p

2026-05-05 更新:修正了代码块语法高亮

14_React 中的更新队列 updateQueue

2026年5月5日 09:47

一、概述

updateQueue 是挂在 Fiber / Hook 上的更新队列(链表),用于缓存 setState 产生的 update,并在 render 阶段按优先级(lane)依次计算出新的 state。

在 React 中,有许多触发状态更新的方法,比如:

  • ReactDOM.createRoot
  • setState
  • useState dispatcher
  • useReducer dispatcher

这些方法使用相同的更新流程,因为它们都使用 updateQueue 这个数据结构。

二、两套 updateQueue

React 里有两套队列:

1️⃣ 类组件

fiber.updateQueue = {
  baseState, // 初始 state,update 基于该 state 计算新的 state
  firstBaseUpdate, // 更新前该 FiberNode 中已保存的 update 链表,表头为 firstBaseUpdate
  lastBaseUpdate, // 链表尾部为 lastBaseUpdate
  // 触发更新后,产生的 update 会保存在 shared.pending 中形成单向环状链表
  // 计算 state 时,该环状链表会被拆分并拼接在 lastBaseUpdate 后面。
  shared: {
    pending
  }
}

2️⃣ 函数组件 Hooks

每个 useState / useReducer 都有一个 queue:

hook.queue = {
  pending: Update | null, // 环形链表
  dispatch: Function
}

三、Update 数据结构

Update 节点

type Update = {
  lane: Lane;        // 优先级
  action: any;       // setState 传入的值/函数
  next: Update | null;
}

队列结构(环形链表)

pending
   ↓
update1 → update2 → update3
   ↑                 ↓
   ← ← ← ← ← ← ← ← ←

为什么是环形?

  • O(1) 插入
  • 不需要区分头尾

四、dispatch(setState)发生了什么?

setCount(c => c + 1)
function dispatchSetState(fiber, queue, action) {
  const update = {
    lane: requestUpdateLane(), // 分配优先级
    action,
    next: null
  };

  enqueueUpdate(queue, update);

  scheduleUpdateOnFiber(fiber);
}

enqueueUpdate

function enqueueUpdate(queue, update) {
  const pending = queue.pending;

  if (pending === null) {
    update.next = update;
  } else {
    update.next = pending.next;
    pending.next = update;
  }

  queue.pending = update;
}

插入效果:永远插在“尾部”,但保持环形。

五、render 阶段:如何消费 updateQueue?

processUpdateQueue()

执行流程

let newState = baseState;

let update = firstUpdate;

do {
  if (lane 满足当前优先级) {
    newState = reducer(newState, update.action);
  } else {
    // 跳过(并发关键)
    // 执行在下一次 render 开始的时候,和下一 render 的 updates 组成新的链表
  }

  update = update.next;
} while (update !== null);

reducer 本质

function reducer(state, action) {
  return typeof action === 'function'
    ? action(state)
    : action;
}

六、baseState & baseQueue(并发核心)

React 19 支持并发:低优先级更新可能被跳过

hook.memoizedState // 当前 state

hook.baseState     // 上一次稳定 state

hook.baseQueue     // 未处理的 update

执行逻辑

高优先级 → 先执行
低优先级 → 留在 baseQueue

示例

setCount(1)        // 高优先级
startTransition(() => {
  setCount(2)      // 低优先级
})

render 结果:

先执行 1
2 留在队列,下次再算

七、lane(优先级系统)

React 19 的核心:

每个 update 都有 lane(优先级)

判断逻辑

if ((update.lane & renderLanes) !== 0) {
  // 执行
} else {
  // 跳过
}

不同的 update 是有不同的优先级,高优先级的 update 能够中断低优先级的 update,当高优先级的 update 完成更新之后,后续的低优先级更新会在高优先级 update 更新后的 state 的基础上再来进行更新。

八、批处理(Batching)

React 18+ 自动 batching

setCount(1)
setCount(2)

结果:

只触发一次 render

因为 多个 update 进入同一个 queue。

执行顺序:

1 → 2 → 最终 state = 2

错误写法:

setCount(count + 1)
setCount(count + 1)

结果:+1(不是 +2)

正确写法:

setCount(c => c + 1)
setCount(c => c + 1)

原因:每个 update 都基于“上一个结果”

九、和 effectQueue 的区别

本质:

updateQueue → render 阶段
effectQueue → commit 阶段

十、完整流程总结

setState
   ↓
创建 update(带 lane)
   ↓
加入 updateQueue(环形链表)
   ↓
scheduleUpdateOnFiber
   ↓
render 阶段:
   ↓
processUpdateQueue(计算 state)
   ↓
commit 阶段:
   ↓
更新 DOM + 执行 effect

react函数组件、类组件、纯组件、受控/非受控组件

作者 光影少年
2026年5月5日 09:26

一、函数组件 vs 类组件

1️⃣ 函数组件(Function Component)

本质:就是一个函数,接收 props,返回 JSX

function MyComponent(props) {
  return <div>{props.title}</div>;
}

👉 现在主流写法(配合 Hooks)

特点:

  • 更简洁
  • 使用 useStateuseEffect 等 Hooks 管理状态和副作用
  • 没有 this
import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}

2️⃣ 类组件(Class Component)

本质:继承 React.Component 的类

class MyComponent extends React.Component {
  render() {
    return <div>{this.props.title}</div>;
  }
}

带状态写法:

class Counter extends React.Component {
  state = { count: 0 };

  render() {
    return (
      <button onClick={() => this.setState({ count: this.state.count + 1 })}>
        {this.state.count}
      </button>
    );
  }
}

特点:

  • 有生命周期(componentDidMount 等)
  • 使用 this
  • 写法相对冗长

✅ 总结

对比 函数组件 类组件
写法 简洁 冗长
状态 Hooks this.state
生命周期 useEffect 生命周期方法
推荐 ✅ 主流 ❌ 已逐渐淘汰

👉 现在基本都用:函数组件 + Hooks


二、普通组件 vs 纯组件(PureComponent)

1️⃣ 普通组件

默认:父组件更新 → 子组件也会重新 render


2️⃣ 纯组件(PureComponent)

类组件写法:

class MyComponent extends React.PureComponent {
  render() {
    return <div>{this.props.name}</div>;
  }
}

👉 自动做 浅比较(shallow compare)

只有 props/state 变化才重新渲染


👉 函数组件对应写法:React.memo

const MyComponent = React.memo(function ({ name }) {
  return <div>{name}</div>;
});

⚠️ 注意点

浅比较意味着:

const obj = { a: 1 };

// ❌ 每次都是新对象,会触发更新
<MyComponent data={{ a: 1 }} />

// ✅ 推荐
const data = useMemo(() => ({ a: 1 }), []);

✅ 总结

类型 作用
PureComponent 类组件性能优化
React.memo 函数组件性能优化

三、受控组件 vs 非受控组件

👉 这个是表单相关最重要的概念


1️⃣ 受控组件(Controlled Component)

👉 数据由 React 控制(state)

function Input() {
  const [value, setValue] = useState("");

  return (
    <input
      value={value}
      onChange={(e) => setValue(e.target.value)}
    />
  );
}

特点:

  • 数据来源:React state
  • 输入变化 → 更新 state → UI 更新
  • 单向数据流

👉 推荐使用 ✅


2️⃣ 非受控组件(Uncontrolled Component)

👉 数据由 DOM 自己管理

import { useRef } from "react";

function Input() {
  const inputRef = useRef();

  const handleClick = () => {
    console.log(inputRef.current.value);
  };

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>获取值</button>
    </>
  );
}

特点:

  • 使用 ref 获取值
  • 类似原生 JS 操作 DOM

✅ 总结

对比 受控组件 非受控组件
数据来源 React state DOM
可控性
推荐 ⚠️ 少用

四、一句话理解(面试版)

👉 你可以这样说:

  • 函数组件 vs 类组件:函数组件更简洁,配合 Hooks 替代类组件,是当前主流
  • 纯组件:通过浅比较 props/state 来减少不必要渲染(React.memo / PureComponent)
  • 受控组件:表单数据由 React state 管理
  • 非受控组件:表单数据由 DOM 管理,通过 ref 获取

五、给你一个实际开发建议(结合你现在做的项目)

你现在做 React + Ant Design:

👉 推荐组合:

  • 全部用 函数组件
  • 状态管理:Hooks(useState / useReducer)
  • 性能优化:React.memo + useMemo
  • 表单:优先用 受控组件(或 AntD Form 内部已封装)

Next.js精通SEO第四章(JSON-LD + web vitals)

作者 小满zs
2026年5月5日 06:29

JSON-LD

JSON-LD(JSON for Linked Data)是一种用于表达结构化数据的 JSON 格式。
它能帮助搜索引擎和 AI 更准确理解页面内容(例如商品、文章、组织、人物、活动等实体),从而提升页面在检索系统中的可理解性。

在 Next.js(App Router)里,推荐在 layout.tsxpage.tsx 中,直接输出一个原生 <script type="application/ld+json"> 标签来注入 JSON-LD。

JSON-LD 的基础结构

一个最小可用示例如下:

{
  "@context": "https://schema.org",
  "@type": "Person",
  "@id": "https://example.com/people/zhangsan",
  "name": "张三",
  "age": 25
}

字段说明:

  • @context:通常使用 https://schema.org
  • @type:实体类型(如 ProductArticleOrganization)更多类型请查看文档https://schema.org/docs/full.html
  • @id:唯一标识符,通常是实体的URL
  • 其他字段:请根据文档填写例如Person https://schema.org/Person 你的网站是什么type你就把链接后面的值换成你对应的type就行了

image.png

在 Next.js 中添加 JSON-LD

下面是一个页面级示例(以商品页为例):

// app/products/[id]/page.tsx
export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await getProduct(id);

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: product.name,
    image: product.image,
    description: product.description,
  };

  return (
    <section>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
        }}
      />
      <h1>{product.name}</h1>
    </section>
  );
}

为什么要做 .replace(/</g, '\\u003c')

JSON.stringify 本身不会自动处理所有潜在注入风险。
当结构化数据里包含不可信字符串时,建议至少将 < 替换为 \u003c,降低 XSS 注入风险。

JSON.stringify(jsonLd).replace(/</g, '\\u003c');

如果你们团队有统一的安全序列化方案,也可以采用社区库(如 serialize-javascript)或公司内部安全工具。

TypeScript 类型约束(推荐)

为避免字段名拼错、类型不匹配,建议使用 schema-dts 做类型提示:

import type { Product, WithContext } from 'schema-dts';

const jsonLd: WithContext<Product> = {
  '@context': 'https://schema.org',
  '@type': 'Product',
  name: 'Next.js Sticker',
  image: 'https://nextjs.org/imgs/sticker.png',
  description: 'Dynamic at the speed of static.',
};

常见问题

1)用 next/script 还是原生 <script>

JSON-LD 不是要执行的脚本代码,而是结构化数据声明。
在这个场景里,官方建议使用原生 <script type="application/ld+json">

2)放在 layout.tsx 还是 page.tsx
  • 放在 layout.tsx:适合站点级、栏目级的通用结构化数据
  • 放在 page.tsx:适合文章、商品详情这类强依赖当前页面数据的实体
3)如何验证配置是否有效?

可使用以下工具进行校验:

  • Google Rich Results Test:检查可用于 Google 富结果的结构化数据
  • Schema Markup Validator:通用 Schema.org 结构校验

实践建议

  • 使用与页面真实内容一致的字段,避免“标注内容”和“页面内容”不一致
  • 动态页面优先在服务端生成 JSON-LD,保证首屏 HTML 可被爬虫读取
  • 关键实体(文章、商品、组织)优先完善,再逐步扩展更多 schema 类型

web vitals

Web Vitals 是 Google 推出的一套以用户为中心的网页性能指标体系,用来衡量真实用户在加载速度、交互响应、页面稳定性三个维度的体验表现,也是 SEO 评估的重要参考项。

核心三项(LCP、INP、CLS)

截至 2026 年,Core Web Vitals 仍由以下三项组成:

LCP(Largest Contentful Paint,最大内容绘制时间)

LCP 衡量的是视口内最大内容元素(通常是大图、视频封面或大段文本)完成渲染所需的时间,反映“主要内容何时可见”。

  • Good:<= 2.5s
  • Needs Improvement:2.5s ~ 4.0s
  • Poor:> 4.0s

image.png

INP(Interaction to Next Paint,交互到下一次绘制)

INP 衡量用户交互(点击、输入、键盘操作)到页面下一次可见更新之间的延迟,反映整体交互流畅度。

  • Good:<= 200ms
  • Needs Improvement:200ms ~ 500ms
  • Poor:> 500ms

image.png

CLS(Cumulative Layout Shift,累积布局偏移)

CLS 衡量页面在生命周期内发生的意外布局位移总量,反映视觉稳定性。比如图片未预留尺寸、异步内容插入导致页面“跳动”。

  • Good:<= 0.1
  • Needs Improvement:0.1 ~ 0.25
  • Poor:> 0.25

image.png

如何测评

可以使用 Chrome DevTools 的 Lighthouse 面板快速进行本地评估:

  1. 打开 DevTools,进入 Lighthouse 面板。
  2. 选择设备(移动端/桌面端)与检测类别(建议勾选 Performance 和 SEO)。
  3. 点击“分析网页加载情况”生成报告。
  4. 在报告中查看 LCP、CLS 等核心指标分数与诊断建议。

image.png

image.png

image.png

image.png

代码示例

npm install web-vitals

下面示例展示在 Next.js 客户端中订阅 Web Vitals 指标并输出到控制台(可替换为埋点上报逻辑):

'use client'

import { useEffect } from 'react'
import { onCLS, onFCP, onINP, onLCP, type Metric } from 'web-vitals'

function reportWebVital(metric: Metric) {
  // 生产环境中建议上报到日志系统或分析平台
  console.log('[WebVitals]', metric.name, metric.value, metric.rating)
}

export default function HomePage() {
  useEffect(() => {
    onCLS(reportWebVital)
    onFCP(reportWebVital)
    onINP(reportWebVital)
    onLCP(reportWebVital)
  }, [])

  return (
    <section>
      <button type="button">点击交互</button>
      <div>你已经进入 Home 页面</div>
    </section>
  )
}

示例输出:

{ name: 'FCP', value: 1164, rating: 'good', delta: 1164, entries: [...] }
{ name: 'INP', value: 78, rating: 'good', delta: 78, entries: [...] }
{ name: 'CLS', value: 0, rating: 'good', delta: 0, entries: [...] }
{ name: 'LCP', value: 1530, rating: 'good', delta: 1530, entries: [...] }

把个人知识库做成了可"对话检索"的 MCP 服务

作者 lxd_派派
2026年5月5日 00:55

五一花了 1 天,我把个人知识库做成了可"对话检索"的 MCP 服务

一句话:pip install 一个包,你的 Markdown 笔记就能被 AI 助手自动检索并引用。

起因

我平时在本地写了大量 Markdown 笔记——前端面试题、CSS 知识、Python 性能优化、学习计划……但每次和 AI 助手(Claude Code)对话时,它根本不知道我有这些笔记。我想问"我笔记里的 CSS 第一题是什么",它只能瞎猜。

于是我给自己定了个五一假期的目标:做一个能把自己的私有笔记喂给 AI 助手的 RAG 系统。而且要简单——装个包、配一行配置就能用。

总体设计思路

整个系统的核心链路只有 7 步:

本地 .md/.txt 文件
  → Markdown 解析器(markdown-it-py)
  → 按标题分块(Chunker)
  → 文本向量化(BGE-small-zh-v1.5)
  → 存入向量数据库(LanceDB)
  → 封装成 MCP 服务(FastMCP)
  → Claude Code 自动调用
  → 检索结果注入上下文,生成回答

其中,MCP(Model Context Protocol)是 AI 助手和外部工具之间的标准通信协议——想象一下,它就像 USB 接口,你的工具只要实现这个协议,任何 AI 助手都能"即插即用"。这就是为什么 Claude Code、Cursor 都能直接调用我的知识库。

为什么选这些技术

环节 选型 理由
Embedding BGE-small-zh-v1.5 本地运行,512 维向量,中英文检索效果好
向量库 LanceDB 嵌入式数据库,无需额外部署,像 SQLite 一样简单
MD 解析 markdown-it-py Token Stream 解析,正确处理嵌套标题和代码块
分块 标题感知 + 代码块原子保护 按 h2/h3 切分,代码块绝对不切割
集成框架 FastMCP MCP 官方 Python SDK,三行代码注册一个 Tool

这些选型有一个共同的考量:零外部依赖部署。除了 Python 本身,用户不需要装 Docker、不需要起数据库、不需要配云服务。

把一篇 Markdown 变成可检索的知识——核心实现

1. 解析(你猜我绕过了哪个坑)

最初我用 markdown-it-py 的 SyntaxTreeNode 构建文档树,调试了好久一直返回 token=None。后来才搞清楚:markdown-it-py 的树形节点会在非叶子节点上把 token 设为 None——所以你直接读 token 属性就崩了。

解决方案是放弃高层 API,直接遍历底层 Token Stream:

# 不靠谱的高层 API
tokens = md.parse(content)
node = SyntaxTreeNode(tokens)  # parent nodes 的 .token 是 None!

# 靠谱的底层实现
for token in md.parse(content):
    if token.type == "heading_open":
        # 手动追踪标题层级,构建 Section 树

踩过这个坑之后,"别信框架给你包装好的结构"成了我写解析器的一条原则。

2. 分块(别把代码块切碎了)

分块的核心矛盾:块太小则丢失上下文,块太大则检索精度下降

我的策略:

  • 按 h2/h3 标题边界切分:一个标题及其内容尽量在同一个 chunk 里
  • 代码块原子保护:用正则 (\``[\s\S]*?```)` 识别代码块,分块时绕开它们,绝不切割
# 核心逻辑
if len(section_content) <= 1000:
    chunks.append(section_content)  # 整个 Section 作为一个 chunk
else:
    # 按段落切分,但代码块保持完整
    parts = re.split(r'(\`\`\`[\s\S]*?\`\`\`)', section_content)
    for part in parts:
        if part.startswith('```'):
            chunks.append(part)  # 代码块原封不动
        else:
            # 长文本按段落补充切分

3. 向量化与增量索引

BGE 模型有一个设计细节很多人不知道:文档向量和查询向量应该用不同的方式生成

  • 文档文本:直接 model.encode(text)
  • 查询文本:加一个指令前缀 "为这个句子生成表示以用于检索相关文章:" 再编码

这个前缀告诉 BGE 模型"接下来的是查询,不是文档",能显著提升中文检索质量。

增量索引的实现很简单——算文件 SHA256,跟库里存的 content_hash 比对,一样就跳过:

file_hash = hashlib.sha256(file_bytes).hexdigest()
if db.has_hash(file_hash):
    return "skipped"  # 文件没变,跳过

MCP 协议——让 AI 助手"看懂"你的工具

我注册了 5 个 Tool:

Tool 功能 标签
knowledge_search 搜索知识库 readOnly
knowledge_index 索引文件/目录 destructive, idempotent
knowledge_list 列出已索引文档 readOnly
knowledge_remove 移除索引 destructive
knowledge_stats 全局统计 readOnly

标签系统是 FastMCP 内置的——readOnlyHint 告诉 AI "这个操作是安全的";destructiveHint 告诉 AI "这个会改数据"。Claude Code 会根据这些标签决定要不要弹出确认框。

一个 MCP Server 注册多个 Tool 的概念,类似于一个 HTTP 服务暴露多个 API 端点——启动的是同一个进程,通过同一根 stdio 管道通信,但 5 个 Tool 各自独立可调用。

为什么装好了 MCP,问问题却不自动检索?

这是上线后遇到的一个有意思的问题。我在新环境装好包后,问了一个知识库相关的问题,Claude Code 直接用自己的训练数据回答了——完全没有调用 knowledge_search

原因有二:

  1. 没索引就没有内容可搜——向量库是空的
  2. AI 不知道什么时候该用你的工具——Tool 描述告诉它"我能做什么",但没告诉它"什么时候该用我"

解决方案是创建一个 CLAUDE.md:

当用户询问与个人笔记、学习记录相关的问题时:
1. 优先使用 knowledge_search 检索知识库
2. 即使用户提到了具体文件名,也先用 knowledge_search 搜索

这个规则文件是给 Claude Code 看的"行为指南",告诉它遇到哪类问题该走知识库路线。

发布到 PyPI——踩过的坑

  1. license = {text = "MIT"} 是新版 setuptools 不支持的旧写法,必须改成 SPDX 表达式 license = "MIT",同时删除旧的 License :: OSI Approved :: MIT License classifier——两者共存直接报 InvalidConfigError

  2. torch/torchvision 版本不匹配——sentence-transformers 会拉 torch 2.11.0,但旧版 torchvision 0.22.1 不兼容,报 RuntimeError: operator torchvision::nms does not existpip install torch torchvision --force-reinstall 解决

  3. 包名和模块名是两回事——改 pyproject.tomlname 字段改了 pip install 的名字,但 Python 模块名永远是 src/ 下的目录名 knowledge_mcp

成果

  • 总代码量:约 640 行 Python(9 个源文件)
  • 安装方式pip install lxd-knowledge-test-mcp
  • 发布在 PyPIlxd-knowledge-test-mcp
  • 支持格式:Markdown + 纯文本(后续可扩展 PDF、DOCX)

从零到发布,五一假期 3 天搞定。核心体感是:MCP 协议把"AI 调用工具"这件事做得极其丝滑——你写完 Tool 的定义,AI 就能自动发现并调用,不用写任何对接代码。

后续方向

  • 支持 PDF、DOCX 文档格式
  • 文件监听自动增量索引
  • 混合检索(向量 + BM25 关键词)
  • 查询重写(HyDE 等策略提升召回率)

Hooks-useEffect

作者 郑生zs
2026年5月4日 23:34

前置知识

1. 为什么我们需要学 useEffect?

在 React 的世界里,我们总是追求组件的纯粹性 ——给定相同的 props,永远返回相同的 UI。但现实往往是骨感的,我们需要处理数据获取、订阅消息、手动修改 DOM 等“脏活累活”。

在编程中,这些与外部世界交互、产生额外影响 的操作,就被称为副作用

为了协调纯粹的 UI 渲染与复杂的现实需求,React 提供了 useEffect Hook。在深入它之前,我们需要先厘清两个核心概念。

2. 保持组件纯粹性与副作用

问题:什么是副作用函数,什么是纯函数?(面试题)

纯函数定义:

  1. 相同的输入永远会得到相同的输出。这意味着函数的行为是可预测的。
  2. 只负责自己的任务,无副作用出现:它只关心自己的内部逻辑(第一点),绝不会去修改函数外部的任何状态(比如全局变量、DOM、文件系统等)。
// 纯函数:只负责计算,不依赖也不修改外部状态
const sum = (x: number, y: number): number => x + y;

sum(1, 2); // 始终返回 3

副作用函数定义

一个函数在执行过程中,除了返回值之外,还对外部环境产生了可观察的影响修改,这个函数就是副作用函数。

常见的副作用行为包括:

  1. 修改外部变量:修改全局变量、修改传入的参数对象(引用类型)。
  2. I/O 操作:发起网络请求(AJAX/Fetch)、读写文件、读写数据库。
  3. DOM 操作:修改网页标题、手动更改 DOM 节点结构。
  4. 系统交互:设置定时器(setTimeout)、订阅事件(addEventListener)、打印日志(console.log)。
  5. 非确定性:依赖随机数(Math.random)或当前时间(new Date),导致相同输入得到不同输出。
let total: number = 0; // 外部变量

// 副作用函数:修改了外部的 'total' 变量
function apple(value: number): number {
  total += value;
  return total;
}

3. React 组件应该是纯函数

React 的核心设计理念之一就是:组件是一个纯函数

核心原则

组件的职责非常单一——根据 propsstate 计算并返回 JSX。在渲染阶段,组件不应包含任何副作用。

注意

这里说的“不能有副作用”,是指渲染过程中不能有。渲染阶段只管教一件事——根据 props 和 state 算出 UI。

// 纯组件:相同的 props 总是渲染出相同的结果
function Greeting({ name }) {
  return <h1>你好,欢迎{name}学习React!</h1>;
}

React 依赖这个纯粹的约定来实现很多优化(比如跳过不必要的重新渲染),如果组件在渲染过程中偷偷做了额外的事情,就会打破这个约定,导致难以追踪的 bug。

// 错误示范:在渲染过程中执行副作用
function UserProfile({ userId }) {
  // 每次渲染都会发起请求,而且无法去控制时机和清理
  fetch(`/api/users/${userId}`).then(...)
  return <div>用户资料</div>;
}

为什么这样做是危险的?

如果在渲染阶段直接写副作用,会引发以下严重后果:

  1. 请求失控(发多少次你说了不算)

    • React 可能会因为并发模式或父组件更新而多次调用组件函数。如果在函数体内直接请求,会导致重复请求,浪费资源。
  2. 内存泄漏(关不掉)

    • 如果在渲染时开启了定时器或订阅,当组件被卸载时,这些任务可能仍在后台运行。这不仅浪费性能,还可能试图更新一个不存在的组件状态,导致报错。
  3. 缺乏控制力

    • 渲染的时机由 React 决定。将副作用放在渲染主体中,意味着你无法控制它何时执行执行几次以及依赖什么条件

React 组件应当保持纯粹,只负责 UI 的映射。而处理副作用(如请求数据、定时器)的任务,需要交给专门的机制——也就是我们接下来要学习的 useEffect

4. useEffect 基本用法

useEffect(setup, dependencies?)

参数

  • setup(必选) :Effect处理函数,可以返回一个清理函数(cleanup)。组件挂载时执行setup,依赖项更新时先执行cleanup再执行setup,组件卸载时执行cleanup。
  • dependencies(可选) :setup中使用到的响应式值列表(props、state等)。必须以数组形式编写如[dep1, dep2]。不传则每次重渲染都执行Effect。

React 内部是用 Object.is(类似 ===)来对比依赖项有没有变化的。

useEffect(() => {
  // 副作用代码
}, [/* 依赖项数组 */]);

返回值

useEffect 返回undefined

let a = useEffect(() => {})
console.log('a', a) //undefined

使用步骤:

先从 React 中导入 useEffect Hook

import { useEffect } from 'react';

再在组件顶部调用, 并在其中加入一些代码:(不要在循环或条件判断中调用 Hook。)

function MyComponent() {
  useEffect(() => {
    // 每次渲染后都会执行此处的代码
    // 这里就是你的“副作用”发生的地方
  });

  return <div>我的组件</div>;
}

每当你的组件渲染时,React 会先更新页面,然后再运行 useEffect 中的代码。换句话说,useEffect 会“延迟”一段代码的运行,直到渲染结果反映在页面上。它是异步执行的,不会阻塞浏览器绘制屏幕

执行时机:什么是“渲染后”?

理解 useEffect 的核心在于理解它的执行时机

React 的工作流程是这样的:

  1. 渲染阶段:React 调用你的组件函数,计算出需要更新的 JSX(虚拟 DOM)。
  2. 提交阶段:React 将变更应用到真实的 DOM 上,屏幕上的画面更新了。
  3. 副作用阶段:React 执行你在 useEffect 中定义的代码。

核心概念useEffect 会“延迟”一段代码的运行,直到渲染结果已经反映在页面上

这意味着,如果你的副作用代码需要获取 DOM 节点的尺寸、位置,或者需要触发一个不阻塞浏览器绘制的网络请求,useEffect 是完美的选择。

4. useEffect 的三种执行模式

useEffect 的行为完全取决于第二个参数(依赖项数组)

1. 不传参数:每次渲染都执行

  • 特点:没有任何限制,组件只要更新(无论是哪个 state 变了),它就会跑。
  • 类比componentDidMount + componentDidUpdate
import { useEffect, useState } from "react";

const App = () => {
  const [count, setCount] = useState(0);
  const [text, setText] = useState("");

  // 没有第二个参数:只要组件重新渲染(count 或 text 变动),这里都会执行
  useEffect(() => {
    console.log("只要渲染,我就执行");
  });

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <input value={text} onChange={(e) => setText(e.target.value)} />
    </div>
  );
};

2. 空数组 []:只在挂载时执行一次

  • 特点:相当于“初始化”操作,只跑一次,后面不管怎么更新都不管了。
  • 类比componentDidMount
import { useEffect, useState } from "react";

const App = () => {
  const [count, setCount] = useState(0);

  // 空数组:只有组件第一次显示在页面上时执行,后续 count 变化不会触发
  useEffect(() => {
    console.log("仅执行一次(适合初始化、请求接口)");
  }, []);

  return <button onClick={() => setCount(count + 1)}>{count}</button>;
};

3. 有依赖项 [value]:数据变了才执行

  • 特点:精确监听。只有数组里的变量发生变化,才会触发。
  • 类比:特定的 componentDidUpdate
import { useEffect, useState } from "react";

const App = () => {
  const [count, setCount] = useState(0);
  const [text, setText] = useState("");

  // 依赖 count:只有 count 变化时才执行。text 变化不会触发。
  useEffect(() => {
    console.log("只有 count 变了,我才执行");
  }, [count]);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <input value={text} onChange={(e) => setText(e.target.value)} />
    </div>
  );
};

清理函数:善后工作

副作用往往需要“打扫战场”(比如清除定时器、移除监听)。useEffect 允许你返回一个函数,这就是清理函数。

  • 执行时机:

    组件卸载时。

    依赖项变化,下一次 Effect 执行前(先清理旧的,再执行新的)。

场景:定时器清理

import { useEffect, useState } from "react";

const App = () => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    // 1. 创建副作用(开启定时器)
    const timer = setInterval(() => {
      setCount((c) => c + 1);
    }, 1000);

    // 2. 返回清理函数(清除定时器)
    // 当组件卸载 或 依赖变化重跑前,React 会自动调用这个函数
    return () => {
      clearInterval(timer);
      console.log("定时器已清理");
    };
  }, []); // 空数组保证只开启一次定时器

  return <div>计时:{count}</div>;
};

场景:解决“请求竞态”问题

当用户快速输入时,我们希望取消上一次还没发完的请求,只保留最后一次的请求。

import { useEffect, useState } from "react";

const Search = () => {
  const [keyword, setKeyword] = useState("");

  useEffect(() => {
    // 1. 模拟开启一个定时器(代表网络请求)
    const timer = setTimeout(() => {
      console.log(`发送请求:${keyword}`);
    }, 500);

    // 2. 清理函数:如果用户又输入了新字,这里会先执行,清除上一次的定时器
    return () => {
      clearTimeout(timer);
      console.log("取消上一次过期的请求");
    };
    
  }, [keyword]); // 依赖 keyword,每次输入变化都会触发

  return <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />;
};

CSS 阴影生成器:从单层到多层叠加的艺术

2026年5月4日 23:16

box-shadow 的六个参数

先来拆解一下语法:

box-shadow: inset x-offset y-offset blur spread color;

六个参数,除了 inset 可选,其他都可以排列组合:

  • inset:内阴影,默认是外阴影
  • x-offset:水平偏移,正值向右
  • y-offset:垂直偏移,正值向下
  • blur:模糊半径,值越大越模糊
  • spread:扩展半径,正值扩大阴影,负值缩小
  • color:颜色,支持 hex、rgba、hsla

最容易被忽略的是 spread。很多人以为阴影只能往外扩,其实负值可以让阴影缩小,这在做一些微妙效果时很有用。

为什么好的阴影都要多层?

看看 Apple 官网的卡片阴影:

/* 看起来是一个阴影,实际是三层 */
box-shadow:
  0 0 0 1px rgba(0,0,0,0.05),    /* 微妙的边框效果 */
  0 2px 4px rgba(0,0,0,0.1),      /* 近处的硬阴影 */
  0 8px 16px rgba(0,0,0,0.1);     /* 远处的软阴影 */

分层的原因是模拟真实光线:

  1. 近处阴影:偏移小、模糊小、颜色深 → 光源近
  2. 远处阴影:偏移大、模糊大、颜色淡 → 光源远
  3. 边缘描边:模糊为 0 的阴影可以当边框用,比 border 更灵活

这种叠加能让卡片看起来"浮"在背景上,而不是贴着。

用 JavaScript 实现多层阴影生成器

核心逻辑其实就是一个数组管理:

interface ShadowLayer {
  id: string
  offsetX: number
  offsetY: number
  blur: number
  spread: number
  color: string
  inset: boolean
}

function generateShadowCSS(layers: ShadowLayer[]): string {
  return layers
    .map(l => `${l.inset ? 'inset ' : ''}${l.offsetX}px ${l.offsetY}px ${l.blur}px ${l.spread}px ${l.color}`)
    .join(', ')
}

用户可以动态添加/删除层,每层独立控制所有参数。最终生成的 CSS 用逗号连接:

box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.06);

几个实用技巧

1. 阴影做边框

当元素有 border-radius 时,border 会显得生硬。用阴影模拟边框更自然:

.card {
  border-radius: 12px;
  box-shadow: 0 0 0 1px rgba(0,0,0,0.1);
}

好处是阴影会跟着圆角走,不会出现直角边框。

2. 内阴影做凹陷效果

.input:focus {
  box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
}

输入框聚焦时加个内阴影,视觉上像"按下去"的感觉。

3. 多色阴影做霓虹效果

.neon {
  box-shadow:
    0 0 10px #ff00de,
    0 0 20px #ff00de,
    0 0 40px #ff00de,
    0 0 80px #ff00de;
}

多层同色阴影叠加,模糊半径递增,就能做出霓虹灯效果。

颜色透明度的重要性

阴影颜色几乎都用 rgba,很少用纯黑色。原因是真实世界没有纯黑的阴影,都是带环境色的。

透明度的选择也有讲究:

  • 淡阴影rgba(0,0,0,0.05) ~ 0.1 → 高光环境、白色背景
  • 中等阴影rgba(0,0,0,0.15) ~ 0.25) → 普通卡片、按钮
  • 深阴影rgba(0,0,0,0.3) ~ 0.5) → 模态框、弹窗

Tailwind CSS 的阴影灰度就是这样设计的,淡灰为主,避免突兀。

性能小坑

box-shadow 会触发重绘,大面积多层阴影可能影响性能。几个建议:

  1. 避免动画阴影:不要在 transition 里动画 box-shadow,用 transform: scale() 模拟
  2. 减少层数:3-4 层足够,再多肉眼也分不清
  3. will-change:对固定阴影元素加 will-change: box-shadow 提前告知浏览器
.card:hover {
  will-change: box-shadow;
  box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}

在这里插入图片描述

工具推荐

基于这些原理做了个在线工具:CSS 阴影生成器

特点:

  • 多层阴影叠加,支持增删层
  • 实时预览效果
  • 一键复制 CSS 代码
  • 支持内阴影模式

不用手写 CSS,拖拖滑块就能调出想要的阴影效果。调好后直接复制代码,粘贴到项目里用。


相关工具:边框生成器 | 按钮生成器

Flexbox 与 Grid 布局

作者 Rkgua
2026年5月4日 21:41

CSS 的 Flexbox(弹性盒子)和 Grid(网格)是现代前端开发中最强大的两大布局系统。它们并不是竞争关系,而是互补的搭档。

想要彻底搞懂它们,只需要抓住一个最本质的区别:一维布局 vs 二维布局

Flexbox 与 Grid 的核心区别

简单来说,Flexbox 像是在一根绳子上串珠子,而 Grid 像是在织一张纵横交错的网。

  • Flexbox(一维布局): 它一次只能处理一个方向的布局——要么是水平的一行(row),要么是垂直的一列(column)。它的核心在于“弹性”,即根据容器剩余空间,自动伸缩子元素的尺寸与排列方式。
  • Grid(二维布局): 它可以同时控制行和列。你就像在画一张 Excel 表格,可以精确地定义每个元素在第几行、第几列,以及跨越几个单元格。

为了更直观地对比,我们可以通过下表来看清它们的差异:

特性维度 Flexbox (弹性盒子) Grid (网格布局)
布局维度 一维(只能同时控制行 列) 二维(可以同时控制行 列)
核心思维 内容优先(根据内容自动伸缩) 布局优先(先定义好网格结构)
对齐能力 擅长单行/单列内的对齐与空间分配 擅长整体布局及单元格内部的对齐
间隙控制 使用 gap 属性(现代浏览器已支持) 原生支持 gaprow-gapcolumn-gap
元素重叠 较难实现,通常需要结合定位 轻松实现,通过让元素占据同一网格区域即可

Flexbox 的常见应用场景

Flexbox 非常适合处理组件级别的微观布局,或者任何只需要在一条线上排列元素的场景。

  1. 导航栏(Navbar): 这是 Flexbox 最经典的用法。你可以轻松实现 Logo 在左、菜单在右,并且让所有菜单项垂直居中。
    .navbar {
      display: flex;
      justify-content: space-between; /* 左右两端对齐 */
      align-items: center; /* 垂直居中 */
    }
    
  2. 绝对居中(Centering): 在 Flexbox 出现之前,垂直居中是前端开发的噩梦。现在只需三行代码即可完美解决(例如登录框居中)。
    .container {
      display: flex;
      justify-content: center; /* 水平居中 */
      align-items: center; /* 垂直居中 */
      height: 100vh;
    }
    
  3. 均分列布局与卡片内部: 比如一个包含头像、输入框和按钮的评论区。你可以让输入框自动占满剩余空间(flex: 1),而头像和按钮保持固定宽度。
  4. 粘性页脚(Sticky Footer): 当页面内容不足一屏时,让页脚始终固定在浏览器底部。只需将页面主体设为 flex-direction: column,并给中间的内容区设置 flex: 1 即可。

Grid 的常见应用场景

Grid 布局是处理页面级别的宏观布局,以及任何需要同时顾及行与列的复杂二维场景的终极武器。

  1. 页面整体骨架(圣杯布局): 经典的“头部 + 侧边栏 + 主内容 + 右侧栏 + 底部”布局。用 Grid 的 grid-template-areas 可以像画画一样语义化地定义出来,代码极其清晰。
    .page-layout {
      display: grid;
      grid-template-areas:
        "header header header"
        "nav    main   aside"
        "footer footer footer";
      grid-template-columns: 200px 1fr 200px; /* 左右固定,中间自适应 */
      grid-template-rows: 80px 1fr 60px;
    }
    
  2. 响应式卡片/图片画廊: Grid 拥有超强的响应式能力。仅需一行代码,就能实现卡片随屏幕宽度自动换行、自动调整列数,甚至完全不需要写媒体查询(Media Queries)。
    .gallery {
      display: grid;
      /* 自动填充列,每列最小300px,最大平分剩余空间 */
      grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
      gap: 20px;
    }
    
  3. 复杂的仪表盘(Dashboard): 仪表盘里通常有各种大小不一的组件(比如有的图表占 2x2 的格子,有的占 1x2)。Grid 可以通过 grid-column: span 2 轻松控制元素跨越多个网格,且自动对齐。
  4. 不规则的杂志式排版: 需要打破常规网格,让某些图片占据更大空间形成视觉焦点时,Grid 的二维定位能力是 Flexbox 无法比拟的。

总结与最佳实践:混合双打

在实际的项目开发中,我们通常不会二选一,而是**“混合双打”**:

  • 外层用 Grid:负责搭建整个页面的大框架(如 Header, Sidebar, Main, Footer 的位置)。
  • 内层用 Flexbox:负责具体组件内部的元素排列(如导航栏里的菜单项、卡片里的图文对齐、按钮组等)。

一句话决策指南: 如果你在排一行(或一列),用 Flexbox;如果你在排一张表(有行又有列),用 Grid

CSS动画效果

作者 Rkgua
2026年5月4日 21:40

CSS 动画的实现主要依赖于两大核心体系:Transition(过渡)Animation(关键帧动画)

简单来说,Transition 适合处理简单的“状态切换”(比如鼠标悬停时颜色平滑变化),而 Animation 则能实现复杂的、多步骤的自动动画(比如加载时的旋转图标、弹跳的小球)。

下面为你详细拆解它们的实现方式:

1. 基础过渡:Transition(过渡)

Transition 只能定义开始结束两个状态,当元素的属性发生变化时(比如通过 :hover 触发),浏览器会自动补全中间的过渡过程。

它通常需要配合 4 个子属性使用:

  • transition-property:要过渡的 CSS 属性(如 width, background-color,或 all 表示所有属性)。
  • transition-duration:过渡持续的时间(如 0.5s)。
  • transition-timing-function:过渡的速度曲线(如 ease, linear)。
  • transition-delay:延迟多久后开始过渡。

实现示例(鼠标悬停盒子变宽):

.box {
  width: 100px;
  height: 100px;
  background-color: red;
  /* 当宽度发生变化时,在0.5秒内平滑过渡 */
  transition: width 0.5s ease;
}

.box:hover {
  width: 300px; /* 鼠标放上去,宽度平滑变为300px */
}

2. 核心动画:@keyframes + Animation(关键帧动画)

这是 CSS 动画最强大的部分。它由两部分组成:

  1. @keyframes:相当于动画的“剧本”,定义动画从 0% 到 100% 各个阶段的状态。
  2. animation 属性:将“剧本”应用到元素上,并控制播放时长、次数、方向等。

实现步骤:

第一步:定义关键帧(剧本) 使用 from (0%) 和 to (100%),或者具体的百分比来定义中间状态。

@keyframes moveAndChange {
  0% {
    transform: translateX(0);
    background-color: red;
  }
  50% {
    transform: translateX(200px);
    background-color: blue;
  }
  100% {
    transform: translateX(0);
    background-color: red;
  }
}

第二步:将动画绑定到元素 animation 是一个简写属性,它包含了控制动画的 8 个核心子属性:

属性名 作用 常见取值
animation-name 绑定的关键帧名称 对应 @keyframes 后的名字
animation-duration 动画完成一次所需时间 1s, 500ms
animation-timing-function 速度曲线(节奏感) ease(默认), linear(匀速), ease-in-out
animation-delay 延迟多久开始 0.5s
animation-iteration-count 播放次数 1(默认), infinite(无限循环)
animation-direction 播放方向 normal(正向), alternate(来回交替)
animation-fill-mode 动画结束后的状态 forwards(保持最后一帧), backwards
animation-play-state 播放/暂停 running(默认), paused(暂停)

应用示例:

.box {
  width: 100px;
  height: 100px;
  /* 简写格式:名称 时长 速度曲线 延迟 次数 方向 填充模式 */
  animation: moveAndChange 2s ease-in-out infinite alternate;
}

3. 性能优化与最佳实践

在实现 CSS 动画时,为了保证页面流畅不卡顿,有几点需要特别注意:

  1. 优先使用 transformopacity: 改变元素的 width, height, margin, top/left 等属性会触发浏览器的重排(Reflow/Layout),非常消耗性能。而 transform(位移、旋转、缩放)和 opacity(透明度)通常只会触发合成(Compositing),可以交给 GPU 硬件加速,性能极高。
  2. 善用 will-change: 如果你预知某个元素马上要开始动画,可以给它加上 will-change: transform;。这会提前告诉浏览器:“这个元素要变了,请提前为它创建独立的渲染层”,从而让动画更丝滑。
  3. 避免过度复杂的动画: 动画的目的是提升用户体验,过多的动画反而会分散用户注意力。

AI 情绪陪伴助手:从 0 到 1 的 PWA + 跨端应用实战

作者 人生鹿呀
2026年5月4日 20:58

AI 情绪陪伴助手:从 0 到 1 的 PWA + 跨端应用实战

一个基于 React + Vite + PWA 的 AI 情绪陪伴助手,支持人设自定义、AI 情绪分析、离线使用,并可打包为 Android APK。

一、项目概述

1.1 需求背景

在日常学习和工作中,人们经常面临压力和情绪波动。传统的日记或社交倾诉方式存在隐私顾虑和即时性不足的问题。本项目旨在构建一个AI 个性化情绪陪伴助手,用户可以选择不同的 AI 人设(温柔姐姐、理性导师、快乐伙伴等),向 AI 倾诉心情,获得即时、个性化的情绪分析与建议。

1.2 核心功能

功能模块 说明
人设系统 4 种预设人设 + 自定义人设管理,支持 AI 通过人物图片推导人设
情绪分析 接入火山方舟大模型,输入心情后 AI 返回情绪类型与个性化建议
历史记录 本地存储最近 30 条分析记录,支持单条删除和清空
PWA 离线 Service Worker 缓存,断网可用,支持安装到桌面
跨端打包 Capacitor 生成 Android APK,一套代码多端运行

1.3 技术栈

层级 技术
前端框架 React 18 + Vite 5
路由 原生状态管理(无路由库,单页面切换)
PWA vite-plugin-pwa(Service Worker + Manifest)
跨端 Capacitor + Android Gradle
AI 接口 火山方舟 Responses API(兼容 OpenAI 协议)
样式 纯 CSS,无 UI 框架
存储 localStorage

二、项目结构

ai-mood-companion/
├── public/
│   └── manifest.json          # PWA 配置
├── src/
│   ├── assets/                # 静态资源占位
│   ├── components/
│   │   └── LoadingSpinner.jsx # 加载动画组件
│   ├── pages/
│   │   ├── PersonaSet.jsx     # 人设设置页
│   │   └── Home.jsx           # 情绪分析主页
│   ├── utils/
│   │   ├── storage.js         # localStorage 封装
│   │   └── request.js         # AI 接口封装
│   ├── App.jsx                # 应用入口
│   └── main.jsx               # 项目入口
├── index.html                 # 头部优化(OG/PWA/字体)
├── vite.config.js             # Vite + PWA 配置
├── capacitor.config.json      # 跨端配置
└── package.json

三、核心实现

3.1 前端工程规范

HTML 头部全优化

index.html 中必须包含完整的 SEO 和 PWA 相关标签:

  • meta description + theme-color + viewport-fit=cover
  • OG 标签og:titleog:descriptionog:type
  • 苹果沉浸式apple-mobile-web-app-capableapple-mobile-web-app-status-bar-style
  • 字体非阻塞加载preconnect + preload + media="print" onload 技巧
  • PWA Manifest<link rel="manifest">
资源 CDN 化

所有字体、图标均通过 CDN 加载,减少本地资源体积,提升首屏速度。

3.2 PWA 离线化

使用 vite-plugin-pwa 自动生成 Service Worker:

// vite.config.js
VitePWA({
  registerType: 'autoUpdate',
  manifest: false, // 使用独立的 public/manifest.json
  workbox: {
    globPatterns: ['**/*.{html,js,css,png,svg}'],
    runtimeCaching: [{
      urlPattern: /^https:\/\/fonts\.googleapis\.com/,
      handler: 'CacheFirst'
    }]
  }
})

构建后自动生成 sw.js,离线状态下页面仍可正常打开。

3.3 AI 接口封装

接入火山方舟 Responses API,统一封装在 src/utils/request.js 中:

情绪分析接口
export async function analyzeMood(content, persona) {
  const response = await fetch('https://ark.cn-beijing.volces.com/api/v3/responses', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${API_KEY}`,
    },
    body: JSON.stringify({
      model: 'doubao-seed-2-0-lite-260215',
      input: [
        {
          role: 'system',
          content: [{ type: 'input_text', text: buildSystemPrompt(persona) }]
        },
        {
          role: 'user',
          content: [{ type: 'input_text', text: content }]
        }
      ]
    })
  });
  // 解析返回的 JSON:{ emotion, advice }
}

Prompt 工程:将用户设置的人设名称、语气、描述注入 system prompt,要求模型严格返回 JSON 格式,便于前端解析。

AI 看图推导人设

利用 Responses API 的多模态能力,上传人物图片让 AI 分析气质:

export async function analyzePersonaImage(imageBase64) {
  // 发送图片 + prompt,让 AI 返回 {name, tone, description}
}

图片压缩:前端使用 Canvas 将图片压缩至 400px 宽度、JPEG 质量 70%,避免 localStorage 溢出。

3.4 本地存储统一管理

所有 localStorage 操作集中在 storage.js,避免分散:

const STORAGE_KEY = {
  CUSTOM_PERSONAS: 'ai_mood_custom_personas',
  ACTIVE_PERSONA: 'ai_mood_active_persona',
  HISTORY: 'ai_mood_history'
};
  • 人设:支持多个人设增删改查 + 当前激活人设
  • 历史记录:最多保留 30 条,自动覆盖旧数据

3.5 多个人设管理

预设人设(只读)

代码中硬编码 4 种经典人设:温柔助手、理性导师、快乐伙伴、严格教练。

自定义人设(可增删改)
  • 用户可自由输入名称、语气风格、描述
  • 支持上传人物参考图,AI 自动推导人设参数
  • 编辑时回填历史数据(包括参考图)

四、遇到的问题与解决方案

4.1 CORS 跨域

问题:前端直接请求方舟 API,浏览器可能拦截跨域请求。

方案:目前接口可正常访问。如后续出现 CORS 限制,建议增加简易后端代理层。

4.2 图片存储体积

问题:人物参考图以 base64 存入 localStorage,原图几 MB 容易撑满。

方案:使用 Canvas 前端压缩,400px 宽度 + JPEG 70% 质量,压缩后约 100KB。

4.3 Gradle 下载超时

问题:部分环境网络下载 Gradle 发行版极慢,构建 APK 超时。

方案:在自己电脑本地构建,网络更稳定;或配置国内 Gradle 镜像源。

五、如何运行

# 1. 安装依赖
npm install

# 2. 配置 API Key(复制 .env.example 为 .env)
# VITE_ARK_API_KEY=your-key

# 3. 本地开发
npm run dev

# 4. 生产构建
npm run build

# 5. Android APK 打包
npx cap sync
cd android
.\gradlew.bat assembleDebug

六、总结

本项目从零搭建了一个完整的 PWA + 跨端应用,涵盖:

  • ✅ 前端工程化(Vite + React + PWA)
  • ✅ AI 大模型接入(火山方舟 Responses API)
  • ✅ 多模态交互(文本 + 图片分析)
  • ✅ 本地数据持久化(localStorage 统一封装)
  • ✅ 跨端打包(Capacitor → Android APK)

整个项目代码量精简,功能聚焦,适合作为前端实习/校招的项目展示。


项目地址gitee.com/zhang-huair…

从零打造滑板文化社区平台:React 19 + Node.js + AI 微服务全栈实战

作者 人生鹿呀
2026年5月4日 20:22

从零打造滑板文化社区平台:React 19 + Node.js + AI 微服务全栈实战

本文记录了我们参加 2026 中国大学生计算机设计大赛的作品——Skateboard Hub 滑板文化社区平台的完整技术架构与开发实践。项目涵盖 React 19 前端、Node.js 后端、Python AI 微服务、3D WebGL 定制器以及高德地图智能导览,希望能为全栈开发的同学提供一些参考。


一、项目背景与定位

滑板运动在 2020 年东京奥运会正式入奥,国内滑板文化正处于快速发展期。然而,现有的滑板类平台大多分散在微信公众号、B 站、小红书等不同渠道,缺乏一个集资讯、社区、教学、装备定制、场地导览于一体的综合性平台

我们的目标是打造一款面向滑板爱好者的"一站式数字家园",核心功能包括:

  • 📰 资讯中心:滑板历史、入奥专题、职业滑手故事
  • 💬 社区互动:发帖、评论、点赞、关注、私信
  • 🎨 3D 滑板定制器:WebGL 实时预览,支持板面/轮子/支架自定义
  • 🗺️ 智能地图:高德地图 3D 场景 + AI 场地分析(豆包大模型)
  • 🤖 AI 助手:智能问答与滑板图像生成
  • 🧑‍💼 个人主页与管理后台:完整的用户体系与内容审核

二、整体技术架构

我们采用了前后端分离 + AI 微服务 + Docker 容器化的架构设计:

┌─────────────────────────────────────────────────────────┐
│                      前端层 (React 19)                    │
│  Vite + TypeScript + React Router + Zustand + WebGL     │
└─────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────┐
│                   API 网关层 (Express)                   │
│  JWT 认证 / CORS / 限流 / 错误处理 / 反向代理            │
└─────────────────────────────────────────────────────────┘
                           │
           ┌───────────────┼───────────────┐
           ▼               ▼               ▼
    ┌──────────┐   ┌──────────┐   ┌──────────────────┐
    │ MongoDB  │   │  Redis   │   │ Python Services  │
    │ (主数据)  │   │ (缓存)   │   │ Flask × 3        │
    └──────────┘   └──────────┘   │ · AI 生图        │
                                  │ · 地图 AI 分析   │
                                  │ · 智能聊天助手   │
                                  └──────────────────┘

技术选型理由:

层级 技术 选型理由
前端框架 React 19 + TS 生态成熟、类型安全、并发特性
构建工具 Vite 极速冷启动、原生 ESM、HMR
状态管理 Zustand 轻量、无样板代码、TypeScript 友好
后端 Express + MongoDB 快速开发、Schema 灵活、JSON 原生支持
缓存 Redis 会话存储、热点数据缓存、Rate Limit
AI 服务 Flask Python AI 生态成熟,快速接入大模型 API
部署 Docker Compose 一键启动 6 个容器,环境一致性

三、前端架构设计

3.1 React 19 + TypeScript + Vite 工程化

项目采用 Vite 作为构建工具,相比 CRA 有显著的性能提升。我们配置了路径别名 @/ 指向 src/ 目录,避免相对路径地狱:

// tsconfig.json
"paths": {
  "@/*": ["./src/*"]
}

// 使用示例
import { useAuthStore } from '@/store/authStore'
import { api } from '@/lib/api'

路由采用 react-router-dom v7 的 BrowserRouter,所有页面通过统一的 Layout 组件包裹,实现导航栏和全局状态的共享:

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route element={<Layout />}>
          <Route path="/" element={<HomePage />} />
          <Route path="/community" element={<CommunityPage />} />
          <Route path="/customizer" element={<CustomizerPage />} />
          <Route path="/map" element={<MapPage />} />
          {/* ... 共 17 个路由 */}
        </Route>
      </Routes>
    </BrowserRouter>
  )
}

3.2 Zustand 状态管理实践

我们选择了 Zustand 而非 Redux,因为它足够轻量且无需 Provider 包裹。

以用户认证状态为例,我们实现了一个完整的 AuthStore,包含登录、注册、登出、Token 自动续期等功能:

export const useAuthStore = create<AuthState>((set, get) => ({
  token: localStorage.getItem('token'),
  user: JSON.parse(localStorage.getItem('currentUser') || 'null'),
  isLoggedIn: !!localStorage.getItem('token'),

  async login(username, password) {
    const data = await api.post('/api/auth/login', { username, password })
    if (data.success) {
      get().setToken(data.data.token)
      get().setUser(data.data.user)
    }
    return data
  },

  logout() {
    localStorage.removeItem('token')
    localStorage.removeItem('currentUser')
    set({ token: null, user: null, isLoggedIn: false })
  },

  // ...
}))

亮点设计:头像动态生成。当用户未上传头像时,自动生成基于用户名的 SVG 头像,减少无意义的首屏请求:

function getAvatarUrl(user: User | null): string {
  if (user?.avatar) return user.avatar
  if (user?.defaultAvatar) {
    const { text, color } = user.defaultAvatar
    const svg = `<svg...>${text}</svg>`
    return 'data:image/svg+xml;base64,' + btoa(svg)
  }
  return '/assets/images/avatars/小狗头像.jpg'
}

四、后端架构与安全设计

4.1 Express 工程化结构

后端采用经典的 MVC 分层:

server/
├── app.js                 # 入口:中间件注册 → 路由挂载 → 静态文件 → 全局错误处理
├── routes/                # 路由层:按模块拆分
├── models/                # 数据层:Mongoose Schema
├── middleware/            # 中间件:认证、权限、上传、错误处理
└── utils/                 # 工具层:邮件、缓存、分页、软删除

app.js 的核心逻辑非常清晰:

// 1. 数据库连接
connectDB()

// 2. CORS 白名单(生产环境严格限制来源)
app.use(cors({
  origin: function (origin, callback) {
    if (!origin) return callback(null, true)
    if (ALLOWED_ORIGINS.includes(origin)) return callback(null, true)
    callback(null, false)  // 静默拒绝,不暴露服务器信息
  },
  credentials: true,
}))

// 3. 请求体大小限制,防止 DoS
app.use(express.json({ limit: '1mb' }))

// 4. API 路由
app.use('/api/auth', require('./routes/auth'))
app.use('/api/posts', require('./routes/posts'))
// ...

// 5. SPA 回退:非 API 路由返回 index.html
app.get('*', (req, res) => {
  if (req.path.startsWith('/api')) {
    return res.status(404).json({ success: false, message: 'API 接口不存在' })
  }
  res.sendFile(path.join(staticRoot, 'index.html'))
})

// 6. 全局错误处理
app.use(errorHandler)

4.2 JWT 认证的安全强化

认证是社区类应用的核心,我们在标准 JWT 基础上做了多层加固:

1. 启动时强制校验密钥强度

if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) {
  console.error('[FATAL] JWT_SECRET 未设置或长度不足 32 字符,服务器拒绝启动')
  process.exit(1)
}

2. 强制算法为 HS256

避免 none 算法攻击或算法混淆攻击:

jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] })

3. Token 黑名单机制

用户登出后,将 Token 加入 Redis 黑名单,即使 Token 未过期也无法继续使用:

const token = authHeader.split(' ')[1]
if (await isBlacklisted(token)) {
  return res.status(401).json({ message: 'Token 已失效,请重新登录' })
}

4. 可选认证模式

社区帖子列表允许游客浏览,但点赞/评论需要登录。我们通过 optionalAuth 中间件实现这一需求:

async function optionalAuth(req, res, next) {
  try {
    // 尝试解析 Token,失败也不拦截
    const user = await verifyToken(req)
    if (user) req.user = user
  } catch (e) { /* ignore */ }
  next()
}

4.3 其他安全措施

  • bcrypt 密码哈希:salt rounds = 12,抵抗彩虹表攻击
  • 文件上传校验:MIME 类型 + 扩展名双重检查
  • Admin 搜索转义:正则表达式转义,防止 ReDoS
  • 代理路由统一认证:内部 Python 服务不直接暴露,通过 Express 反向代理并校验 Token

五、AI 微服务设计

我们将 AI 能力拆分为 3 个独立的 Flask 微服务,通过 Docker Compose 编排:

服务 端口 职责
image-generator-web 5000 基于 Stable Diffusion / 火山引擎的滑板图像生成
map-server-docker 5001 接收地图坐标与描述,调用豆包大模型进行场地分析
ai-assistant 5002 通用聊天助手,支持滑板知识问答

为什么拆分为微服务?

  1. 技术异构:Python 在 AI 领域生态更成熟(OpenCV、Pillow、requests)
  2. 独立扩展:AI 服务通常计算密集型,可单独扩容 GPU 实例
  3. 故障隔离:AI 服务崩溃不影响主站 API
  4. 团队并行:前后端 + AI 可独立开发部署

Express 侧通过 express-http-proxy 将请求转发到对应服务:

app.use('/api/ai/*', authMiddleware, proxy('http://ai:5000'))
app.use('/api/map/*', authMiddleware, proxy('http://map:5001'))

六、3D 滑板定制器的技术实现

3D 定制器是项目的亮点功能之一。用户可以在网页上实时更换板面图案、轮子颜色、支架样式,并 360° 旋转查看效果。

技术方案:

  • 模型格式:GLTF/GLB(轻量、支持 PBR 材质)
  • 渲染引擎:Three.js(WebGL 封装,社区生态丰富)
  • 材质替换:通过 TextureLoader 动态加载用户选择的图案,替换 mesh.material.map
  • 交互控制OrbitControls 实现旋转/缩放/平移

核心流程:

// 1. 加载 GLTF 模型
const loader = new GLTFLoader()
loader.load('/assets/models/skateboard.gltf', (gltf) => {
  const skateboard = gltf.scene
  scene.add(skateboard)
})

// 2. 用户选择新图案时,动态替换贴图
const texture = new THREE.TextureLoader().load(newPatternUrl)
texture.flipY = false  // GLTF 使用 OpenGL 坐标系
boardMesh.material.map = texture
boardMesh.material.needsUpdate = true

为了兼顾加载速度,我们使用了 Draco 压缩和 WebP 纹理,将模型体积控制在 3MB 以内。


七、智能地图与 AI 场地分析

地图模块基于高德地图 JS API 2.0,集成了以下能力:

  1. 3D 地图视图AMap.Map 开启 viewMode: '3D',展示城市建筑立体效果
  2. 地点搜索与 POI 检索:输入"滑板公园"自动检索周边场地
  3. 路径规划:集成公交/驾车/步行路线规划
  4. 天气查询:显示目标城市的实时天气,辅助用户决定是否出门滑板
  5. AI 场地分析:用户点击地图上的场地后,前端将坐标和场地名称发送到 map-server-docker,由豆包大模型生成场地评测报告(如"该场地坡度适中,适合练习 Ollie,但周末人流较多")
// 前端调用示例
const response = await fetch('/api/map/analyze', {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${token}` },
  body: JSON.stringify({
    location: [116.397428, 39.90923],
    name: '朝阳滑板公园'
  })
})
const { analysis } = await response.json()
// analysis: "该场地拥有专业的碗池和街式区,地面平整度高..."

八、Docker 容器化部署

为了让评委老师能"一键跑起来",我们编写了完整的 Docker Compose 配置:

services:
  mongo:
    image: mongo:7
    container_name: skateboard-mongo
    command: ["mongod", "--replSet", "rs0", "--auth", "--keyFile", "/data/configdb/keyfile"]
    volumes:
      - mongo_data:/data/db

  redis:
    image: redis:7-alpine
    container_name: skateboard-redis

  web:
    build: ./server
    container_name: skateboard-web
    ports:
      - "3000:3000"
    volumes:
      - ./server:/app
      - /app/node_modules
      - ./server/public/uploads:/app/public/uploads
      - ./client/dist:/app/dist
    depends_on:
      - mongo
      - redis

  ai:
    build: ./image-generator-web
    container_name: skateboard-ai

  map:
    build:
      context: .
      dockerfile: map-server-docker/Dockerfile
    container_name: skateboard-map

  ai-assistant:
    build: ./ai-assistant
    container_name: skateboard-ai-assistant
    ports:
      - "5002:5002"

关键设计点:

  • 数据持久化:MongoDB 使用命名卷 mongo_data,容器重启数据不丢失
  • 代码热更新server 目录挂载到容器,开发时无需重新构建镜像
  • 上传文件持久化public/uploads 挂载到宿主机,防止用户头像丢失
  • 前端产物挂载client/dist 直接挂载到容器,实现前后端一体化部署

启动命令极简:

chmod +x start.sh
./start.sh
# 等价于 docker compose up -d --build

九、开发过程中的挑战与解决方案

挑战 1:前端静态页面与 React SPA 的融合

项目中既有历史遗留的 HTML 静态页面(如教学页面、资讯页面),又有新的 React SPA 路由。我们采取的方案是:

  • React 构建产物 client/dist 作为首选静态资源根目录
  • 原始 HTML 页面放在 server/public/pages/ 下作为降级方案
  • Express 优先查找 dist/index.html,找不到再回退到 public/
  • 原始页面通过 <a href="/pages/xxx.html"> 跳转,保持 URL 统一

挑战 2:CORS 与 Cookie 的跨域问题

开发时前端跑在 localhost:5173,后端在 localhost:3000。我们配置了动态 CORS 白名单,并开启 credentials: true

app.use(cors({
  origin: (origin, callback) => {
    if (!origin || ALLOWED_ORIGINS.includes(origin)) {
      callback(null, true)
    } else {
      callback(null, false)
    }
  },
  credentials: true,
}))

挑战 3:MongoDB 副本集与认证

Docker 中的 MongoDB 需要开启认证,而某些 Mongoose 功能(如事务)需要副本集。我们在 docker-compose.yml 中配置了 --replSet rs0--keyFile,并在启动脚本中自动初始化副本集:

# start.sh 片段
docker compose exec mongo mongosh --eval "rs.initiate()"

十、总结与收获

这次比赛让我们完整走通了"需求分析 → 技术选型 → 架构设计 → 编码实现 → 容器化部署"的全流程。

技术层面的收获:

  1. React 19 的新特性:实际体验了 React 19 的并发渲染和自动批处理,配合 Zustand 状态管理非常流畅
  2. 安全意识的提升:JWT 黑名单、CORS 白名单、bcrypt 哈希、请求体限制,这些"防御性编程"实践让系统更健壮
  3. 微服务通信:通过 Express 反向代理统一暴露 AI 服务,既保持了前端的简单调用,又实现了后端的安全隔离
  4. Docker 工程化:多容器编排、数据卷管理、环境变量配置,让项目真正做到了"开箱即用"

产品层面的思考:

技术最终要服务于用户。我们在设计 3D 定制器时发现,滑板玩家对"个性化"有极强的诉求——每一块板都是滑手自我表达的延伸。因此我们在 UI 上花了大量时间打磨交互细节,确保图案替换的响应时间在 100ms 以内,旋转视角的帧率稳定在 60fps。


附录:项目地址与技术栈

  • 仓库地址gitee.com/zhang-huair…
  • 技术栈:React 19 + TypeScript + Vite + Express + MongoDB + Redis + Python Flask + Docker
  • 部署方式:Docker Compose 一键启动
  • 许可证:MIT

如果您对项目有任何问题,欢迎在评论区留言交流!🛹


作者:张怀睿
赛事:2026 中国大学生计算机设计大赛
标签:React Node.js MongoDB Docker WebGL AI 全栈开发

❌
❌