普通视图
红旗连锁:商投投资受让22.09%公司股份
北汽蓝谷:子公司前4月销量4.67万辆,同比增长22.87%
亚马逊全面开放旗下物流网络
威龙股份:控股股东筹划控制权变更 股票停牌
金利华电:筹划发行股份及支付现金购买中科西光股权并募集配套资金 股票停牌
东阳光:控股子公司签署算力服务采购框架合同 预计总额160亿元至190亿元
别把语音 Agent 当成“接两个 API”——用 NestJS 搭一套 ASR + LLM + 流式 TTS 的实时语音助手
我们现在看到的大多数 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 助手其实是三条链路
如果你一开始就把语音助手理解成“录音之后调一次接口”,你大概率会把结构做歪。
从工程上看,至少要先拆出三条职责不同的链路:
- 输入链路:浏览器录音 -> 上传音频 -> ASR -> 文本
- 推理链路:文本问题 -> LLM -> 流式文本输出
- 播报链路:流式文本 -> 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
这张图里最值得注意的,不是腾讯云,也不是模型,而是中间这几个“看起来不起眼”的节点:
SSEWebSocketAI_TTS_STREAM_EVENTMediaSourceSourceBuffer
这几个点决定了语音链路到底是“实时协同”,还是“能跑但体验别扭”。
再看一次时序,你会更直观一些:
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”。这其实是个常见误区。
要不要流式识别,不应该由技术潮流决定,而应该由交互目标决定。
在这个项目里,语音输入的目标不是做电话机器人,也不是做毫秒级打断对话,而是做一个像豆包那样的单轮语音提问:
- 用户说完一段话
- 系统识别成文本
- 大模型开始回答
- 回答边生成边播报
在这种场景里,先录完再识别,其实是非常合理的默认方案:
- 实现复杂度明显更低
- 浏览器端更容易兼容
- 后端不需要先处理麦克风实时分片上送
- 对“问一句 -> 回一句”的交互已经够用
换句话说,流式 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);
这段代码在整条链路里的位置,是语音输入采集层。它解决的不是识别,而是三个更基础的问题:
- 如何向浏览器申请麦克风权限
- 如何把录音分片收集起来
- 如何在停止录音后整合成可上传的 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 之外,它还显式发出了:
startenderror
这意味着 TTS 侧不只是“收到一点字就说一点字”,而是知道:
- 什么时候会话开始
- 什么时候应该收尾
- 什么时候要终止并清理资源
这就是完整流生命周期管理,而不是简单回调。
九、为什么流式 TTS 不能和 SSE 混在一起
很多第一次做这个系统的人会问:既然已经有 SSE 了,为什么不直接在 SSE 里把音频也发回来?
原因很简单:SSE 适合文本,不适合二进制音频。
如果你强行用 SSE 传音频,一般只有两条路:
- 把音频转 Base64 再发
- 伪装成文本分块传输
这两种路都不太好:
- Base64 体积会膨胀
- 前端要自己解码
- 时序会更难控制
- 对播放器非常不友好
而 WebSocket 天然适合持续传二进制帧,所以这里单独开一条 /speech/tts/ws 通道,是一个非常明确的工程判断:
文本输出归 SSE,音频输出归 WebSocket。
这不是“多开一个接口显得复杂”,而是“按数据类型选协议”。
十、真正决定这套系统可扩展性的,是事件桥接而不是 API 调用
这套项目里,我最认可的一点是没有把 TTS 逻辑硬塞进 AiController 或 AiService 里,而是通过事件来桥接。
事件定义很简单:
// 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”这么简单,而是做了三层中继:
- 管理浏览器和服务端之间的 TTS 会话
- 管理服务端和腾讯云流式 TTS 之间的 WebSocket 连接
- 在文本分段、发送节奏、二进制转发之间做协调
先看客户端会话注册:
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?...`;
}
这段代码告诉你两件事:
- 流式 TTS 本质上是一个 WebSocket 协议接入问题
- 真正的复杂度不在“调某个函数”,而在“参数、签名、时序、收尾”这些协议细节
再比如文本发送逻辑:
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 还没拿到
- 结果最前面几段音频可能丢失或延迟很大
所以更稳妥的做法是:
- 先准备好 TTS WS 通道和
ttsSessionId - 再发起
EventSource文本流请求 - 文本流和音频流用同一个 session 关联
这就是实时系统里的经典原则:
先准备消费端,再启动生产端。
十六、配置项不是“填上就行”,它们决定了系统的行为边界
这个项目里有两组配置文件:
src/ai/ai.config.tssrc/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 项目最值得学习的地方。
如果你后面还想继续扩展,我建议下一篇就顺着这条线往下写:
- 把上传式 ASR 升级成流式 ASR
- 增加打断、重说与会话中止能力
- 接入 RAG,让它从“会说话”升级成“会回答业务问题”
到那时,这就不只是一个语音 Demo,而会成为一个真正有业务形态的 AI 应用底座。
长和将终止确认其于VodafoneThree的投资
欧盟官员:能源价格飙升推高欧盟通胀
Taro小程序生成分享海报解决方案
场景:在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 实战:别把启动参数散落在每个页面里
鸿蒙应用做到后面,真正让人头疼的,往往不是某个页面写得丑,也不是某个按钮样式没调好,而是入口越来越多以后,启动逻辑开始乱。
桌面图标能进来,服务卡片能进来,通知能进来,Deep Link 能进来,应用内部还可能用 Want 拉起另一个 UIAbility。第一版代码一般都挺朴素:在首页 aboutToAppear 里读一下参数,判断要不要跳详情页。刚开始没问题,甚至看起来挺清爽。
但需求一多,首页就很容易变成“入口垃圾桶”。
这里判断通知来源,那里判断服务卡片参数,后面又补一个外部链接解析。冷启动的时候还能凑合,二次拉起、回前台、横竖屏切换、任务栈恢复一来,问题就开始变得有点玄学了。
我之前见过一个挺典型的线上问题:用户从服务卡片点进来,本来应该打开某个订单详情页,结果偶尔落到首页;用户从外部链接再次拉起应用,页面没刷新;还有更隐蔽的,应用已经在后台了,新的 Want 进来以后,全局初始化又跑了一遍,监听注册了两次,后面同一个事件回调两遍。
查日志的时候也挺难受。每个页面都觉得自己只是“顺手处理一下入口参数”,最后谁也说不清这次启动到底是桌面启动、卡片启动,还是二次拉起。
这种问题不能靠再加几个 if 硬顶。Stage 模型下,AbilityStage、Want、UIAbility 这条链路本来就应该承担启动治理的职责。只是很多项目写着写着,把它们当成了“系统自动生成的模板文件”,真正的业务入口反而全塞到页面里了。
![]()
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,而是 onCreate 和 onNewWant 共用同一个解析器。
冷启动和二次拉起不应该分裂成两套业务规则。你今天忘了在 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() 这一类生命周期回调。文档顺序看懂不难,难的是每个回调里该放什么、不该放什么。
![]()
我通常按下面这个口径拆:
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这个网站
动机
我有一款 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状态管理更加方便!
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 上挂载了 getState 和 setState,可以直接在 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. 进阶学习
- zustand官方文档:zustand.nodejs.cn/docs/gettin…
- Redux Toolkit(另一种较大型的状态管理工具):cn.redux.js.org/redux-toolk…
啥子都能看懂的TypeScript快速入门
TypeScript 从小白到快速上手
说明:大部分笔记参考B站尚硅谷TS课程
https://www.bilibili.com/video/BV1YS411w7Bf/?spm_id_from=333.337.search-card.all.click&vd_source=4de192a0ec1fe7b5fc788e72acc90efa
前置知识:JavaScript 基础和 ES6+ 新特性
一、TypeScript 简介
- TypeScript 包含了 JavaScript 的所有内容,即:TypeScript 是 JavaScript 的超集。
- TypeScript 增强了静态类型检查、接口编程和面向对象特性,更适合大型项目的开发。
- 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
- 工程中会生成一个
tsconfig.json配置文件,其中包含着很多编译时的配置。 - 观察发现,默认编译的 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 新增类型
- 上述所有 JavaScript 类型
- 六个新类型:① any ② unknown ③ never ④ void ⑤ tuple ⑥ enum
- 两个用于自定义类型的方式:① type ② interface
注意点! 在 JavaScript 中的这些内置构造函数(Number、String、Boolean)用于创建对应的包装对象,在日常开发时很少使用,在 TypeScript 中也是同理,所以在 TypeScript 中进行类型声明时,通常都是用小写的 number、string、boolean。例如:
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 的含义是:任何值都不是,即不能有值,例如 undefined、null、""、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 的。简单记:undefined 是 void 可以接受的一种“空”。
- 以下写法均符合规范:
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则是这种“空”的具体实现。 - 因此可以说
undefined是void能接受的一种“空”的状态。 - 也可以理解为:
void包含undefined,但void所表达的语义超越了undefined,void是一种意图上的约定,而不仅仅是特定值的限制。
总结:如果一个函数返回类型为 void,那么:
- 从语法上讲:函数是可以返回
undefined的,至于显式返回,还是隐式返回,这无所谓! - 从语义上讲:函数调用者不应关心函数返回的值,也不应依赖返回值进行任何操作!即使我们知道它返回了
undefined。
5. object(了解即可)
关于 object 与 Object,实际开发中用的相对较少,因为范围太大了。
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
}
}
使用抽象类的场景:
- 定义通用接口:为一组相关的类定义通用的行为(方法或属性)时。
- 提供基础实现:在抽象类中提供某些方法或为其提供基础实现,这样派生类就可以继承这些实现。
- 确保关键实现:强制派生类实现一些关键行为。
- 共享代码和逻辑:当多个类需要共享部分代码时,抽象类可以避免代码重复。
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++代码在浏览器里跑起来
你的网页有个计算密集的任务(比如视频转码、图像滤镜、物理模拟),用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.js 和 add.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._malloc和Module.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)
一. 架构目标
构建一个渲染引擎,通过解析 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通用渲染模板:接收option和data。
任务 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必须返回data和loading。 -
useGlobalReactive必须接受config和callback。
-
-
保持 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。
-
安装工具:
npm install typescript-json-schema -g -
创建入口文件 (
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)共享这个状态。
- 定义 Tab 内部状态结构
每个 Tab 页签维护一个独立的 state 对象,结构如下:
{
"activeTab": "overview",
"tabStates": {
"overview": { "region": "sh", "timeRange": [] }, // A Tab 的过滤参数
"detail": { "keyword": "" } // B Tab 的过滤参数
}
}
- 前端组件实现逻辑
第一步:在 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>
状态管理的三个关键细节
- 状态隔离(Namespace) 🔒
- 问题:A Tab 的日期筛选不应该影响 B Tab 的图表。
-
解决:在
tabFilterData中以tabKey作为命名空间。只有当用户切换回该 Tab 时,对应的状态才生效。
- 参数合并优先级 ⚡
渲染引擎在发起 API 请求前,会按以下顺序合并参数:
-
静态参数:数据源配置里写死的
url?type=1。 -
Tab 局部参数:控制组件选中的
region=sh。 - 全局参数:如用户信息、当前选中的部门 ID。
-
代码实现:
const finalParams = { ...staticParams, ...globalParams, ...tabParams };
- 强势方的特殊需求:联动全局
- 需求:在 A Tab 选了日期,希望切换到 B Tab 时日期也是选好的。
-
解决:在 JSON 配置中增加一个标识
syncToGlobal: true。如果有此标识,状态变更时同步写入 Pinia 的全局状态池,其他 Tab 初始化时优先读取全局值。
从0开始搭建Vue3前端骨架(附登录接口案例)
从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 或点击下方链接 进入中文官网
![]()
点击 下载安装
![]()
选择匹配的操作系统,点击下载
![]()
下载完成
![]()
1.1.2 下载其他版本的 Node.js (可以跳过该步骤,有版本需求可选择观看)
如果需要用到其他版本的 Node.js ,可以按下面的方式操作
点击 全部安装包
![]()
路径末尾处就是版本号,需要什么版本就在末尾输入对应的版本号,下面以 v22.10.0 为例
![]()
输入 v20.10.0 后,即可得到下面的页面
![]()
- Windows 用户选择
.msi安装包 - macOS 用户选择
.pkg - Linux 用户使用包管理器或二进制文件。
windows 64位选择如下
![]()
下面提供另一种查找方法
去除路径末尾的版本号
![]()
回车
![]()
得到下面的界面
![]()
向下拖动(会很长),就能找到我们需要的版本了,点击该版本
![]()
进入下面的页面
![]()
- Windows 用户选择
.msi安装包 - macOS 用户选择
.pkg - Linux 用户使用包管理器或二进制文件。
windows 64位选择如下
![]()
1.1.3 安装
直接双击即可,接下来 一路 next 即可
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
1.1.4 验证
win + r 进入运行界面,输入 cmd ,回车
![]()
输入 node -v 查看 node 的版本
node -v
![]()
输入 npm -v 查看 npm 的版本
npm -v
![]()
能看到版本就说明安装成功了 ^ v ^
1.1.5 配置全局路径 (可以跳过该步骤,推荐完成)
作用:存放 全局安装的模块(通过 npm install -g 安装的包)
在 Node 安装目录下新建 node_global 文件夹
node_global
![]()
进入 node_global 文件夹,并复制其路径
![]()
在此处输入如下指令,设置 npm 全局安装的模块 的存放位置
npm config set prefix
![]()
路径为我们之前复制的 node_global 文件夹的路径,将路径复制在后面,回车
![]()
1.1.6 配置缓存路径(可以跳过该步骤,推荐完成)
作用:存放 npm 下载包时的缓存文件 。当执行 npm install 时,npm 会先将下载的包缓存到这里,下次再安装相同版本时直接从缓存读取,提高安装速度
在 Node 安装目录下新建 node_cache 文件夹
![]()
进入 node_cache 文件夹,并复制其路径
![]()
在此处输入如下指令, 设置 npm 下载包时的临时缓存目录
npm config set cache
![]()
路径为我们之前复制的 node_cache 文件夹的路径,将路径复制在后面,回车
![]()
1.1.7 配置国内的镜像
(2026.5.3 该镜像还能使用)
npm config set registry https://registry.npmmirror.com
![]()
1.2 包管理器选择(npm / pnpm)
包管理器常用的有以下几种:
- npm(Node Package Manager):Node.js 自带,无需安装,命令简洁,生态最广。
- pnpm:快速的、省空间的替代品,通过硬链接共享依赖,适合大型项目或多项目开发。
- yarn:历史选择,目前已较少用于新项目。
1.2.1 本教程的选择
我们使用 npm 作为包管理器,因为:
- 安装 Node.js 后即可使用,无需额外步骤。
- 绝大多数 Vue 3 官方文档和示例都采用 npm。
- 对于本项目的规模,npm 的性能完全够用。
如果你希望尝试 pnpm,可以后续自行替换命令(例如 pnpm install 代替 npm install),不影响项目运行。
二、创建 Vue3 项目(含 TypeScript)
2.1 创建项目
2.1.1 创建文件夹
新建前端文件夹
frontend
![]()
2.1.2 idea 打开该文件夹
选择刚才创建的文件夹打开
![]()
界面如下
![]()
2.1.3 创建 Vue3 项目
打开终端
npm create vue@latest
这是 终端权限问题 ,解决方法请看 附录:常见问题(FAQ) -> 终端问题解决
![]()
第一次创建会有提示,输入 y 回车
![]()
输入项目名称,这个可以自己定义,回车
book-trading-frontend
![]()
这里选择 Yes
![]()
↑/↓ 箭头切换,空格 选中
![]()
这两个都不用选,直接回车
![]()
移动上下箭头,选择 Yes
![]()
效果,这个文件夹就是我们创建的 vue3项目(项目名称可能不同)
![]()
2.1.4 安装所有依赖
路径切换到我们创建的 vue3 项目
![]()
为 vue3 项目安装所有依赖(过程可能会有点久,我这里安装了5min)
npm i
![]()
2.1.5 启动项目(ctrl + c 停止项目)
npm run dev
![]()
点击链接,出现如下界面说明成功了 ^ v ^
![]()
2.2 项目结构解读
![]()
这里可以简单了解一下
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 目录
![]()
在 api 目录下创建 services 和 types 目录
- services:存放API请求函数
- types:存放TypeScript类型定义
![]()
3.2 assets
作用:存放需要经过构建工具处理的静态资源 ,例如图片、字体、全局样式文件等
在 src 目录下创建 assets 目录
![]()
3.3 layouts
作用:存放布局组件,例如整个后台管理系统的公共框架(侧边栏菜单、顶部导航栏、底部版权信息、内容区域等)
在 src 目录下创建 layouts 文件夹
![]()
3.4 stores
作用:存放 Pinia 定义的 store 文件 (类似于java中的全局变量、全局方法等)
counter.ts 是演示示例,可以保留也可以删除,这里选择删除示例
![]()
3.5 views
作用:存放具体的页面
在 src 目录下创建 views 目录
![]()
四、集成 Element Plus 与 Tailwind CSS
4.1 Element Plus
4.1.1 安装
安装指令:
npm i element-plus --save
![]()
安装成功(7min):
![]()
4.1.2 在 main.ts 中引入
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
![]()
接下来我们导入后,还要交给 app 使用(注意在挂载 mount 之前)
app.use(ElementPlus)
![]()
4.1.3 验证
在 App.vue 中创建一个按钮组件(因为是 element-plus 的组件,所以一般都是 el-xxx )
能看见提示说明成功了
![]()
我们可以随便写点
<el-button>Button</el-button>
![]()
启动项目
npm run dev
![]()
点开地址可以看到如下效果
![]()
4.1.4 Element Plus 官网
A Vue 3 UI Framework | Element Plus
官网上介绍了如何安装使用,这里可以简单看一下,和我们的安装步骤是一样的
右上角可以切换中文
![]()
点击指南
![]()
左侧找到快速开始
![]()
![]()
npm的安装指令也有
npm install element-plus --save
![]()
4.2 Tailwind CSS
4.2.1 安装
安装指令:
npm install -D tailwindcss
![]()
安装成功(3s):
![]()
初始化指令:
(此处出错的可以前往 附录:常见问题(FAQ) -> Tailwind CSS初始化错误)
npx tailwindcss init -p
打开我们初始化得到的 tailwind.config.js
![]()
为 content 添加如下内容
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html", // 扫描根目录的 HTML 文件
"./src/**/*.{vue,js,ts,jsx,tsx}", // 扫描 src 下所有 Vue/JS/TS 等文件
],
theme: {
extend: {},
},
plugins: [],
}
![]()
4.2.2 在 main.css 中引入
在 assets 目录下创建 main.css 用来存放全局样式
![]()
将 tailwind 引入
main.css
@tailwind base;
@tailwind components;
@tailwind utilities;
![]()
4.2.3 在 main.ts 中引入
import './assets/main.css'
![]()
4.2.4 验证
在 App.vue 中使用,添加 class 样式,可以看到有如下 tailwind css 提示
![]()
启动项目
npm run dev
可以看到如下效果:
![]()
4.2.5 Tailwind CSS 官网
安装 - TailwindCSS中文文档 | TailwindCSS中文网
这里有安装步骤,略有不同
![]()
往下滑可以查看这些样式,ctrl + k 可以搜索样式进行查看
![]()
例如:对 背景色 并不熟悉,可以通过下面的方法查看
-
ctrl + k 打开搜索

-
输入关键词 background color ,选择第一个进行查看

-
左侧的是 tailwind ,右侧的是其对应的 纯css 样式

-
直接搜索不懂的样式也是可以的

五、封装 Axios 请求模块(核心)
5.1 安装
安装指令:
npm i axios
![]()
安装成功(18s):
![]()
5.2 创建 request.ts 并配置拦截器
request.ts
![]()
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。
只要 协议、域名、端口 有任何一项不同,就属于“跨域”,我们的前端和后端的端口不同,前端去请求后端就会产生跨域问题
我们需要将这一段配置写到 vite.config.ts 中
![]()
也就是这一段,target 我改成我们以后的后端端口,这样就能和后端连接了
server: {
proxy: {
// 统一代理所有以 /api 开头的请求
'/api': {
target: 'http://localhost:8080', // 1. 目标:转发到后端服务器
changeOrigin: true, // 2. 关键:改变请求头中的Origin为目标地址,防止某些后端校验失败
// 3. 重写路径:用''空字符串代替`/api`,/是转移,^是以什么开头
rewrite: (path) => path.replace(/^/api/, ''),
}
}
}
![]()
整体代码如下
![]()
六、登录与布局页面设计
6.1 login.vue 登录页面
6.1.1 创建页面
在 views 目录下如下配置
![]()
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
![]()
修改 App.vue 让它能够通过 路由 来展示 login/index.vue 页面
-
<router-view></router-view>:展示路由中的页面
<script setup lang="ts"></script>
<template>
<router-view></router-view>
</template>
<style scoped></style>
![]()
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,可以看到如下效果
![]()
6.2 Layout.vue 布局组件
6.2.1 创建页面
![]()
6.2.2 路由注册
路由注册,将 Layout.vue 页面交给 index.ts 管理
{ path: '/layout', component: () => import('@/layouts/Layout.vue')},
![]()
6.2.3 页面实现
前往 Element Plus 官网
我们使用这种布局
![]()
<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>
![]()
修改 login/index.vue 中的 login 函数,使其跳转至 Layout.vue 页面
// 获得路由器实例
const router = useRouter()
// 登录
const login = () => {
router.push('/layout')
}
![]()
启动项目 npm run dev 效果如下:
![]()
稍加改造
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>
启动项目,效果如下:
![]()
6.3 Layout.vue 的子页面
![]()
6.3.1 创建页面
在 views 目录下创建两个 vue 页面
![]()
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
![]()
6.4 实现菜单
我们要实现的效果是,点击 侧边栏 的 菜单项 ,右边展示对应菜单项的页面
![]()
在 Element Plus 官网上找到我们需要的代码
![]()
![]()
我将源码简化处理了,保留了两个菜单项
- 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> ,也就是侧边区域
![]()
在 el-main 区域设置 <router-view></router-view> ,相当于将 “展台” 设置在 el-main 区域,页面就可以在站台上展示了。App.vue 中也设置了这个
<router-view></router-view>
![]()
效果如下:
![]()
6.5 404 页面
6.5.1 创建页面
![]()
6.5.2 路由注册
{ path: '/:pathMatch(.*)', component: () => import('@/views/NotFound/index.vue') },
![]()
6.5.3 页面实现
![]()
我将官网的代码修改一下
<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>
将代码复制到此处
![]()
输入一个错误的路径,可以看到自定义的404页面
http://localhost:5173/layout/workbenchgew
![]()
代码解释
![]()
到这里,前端的骨架差不多就搭建完成了,如果想知道具体的用法,可以看看后续的代码 ^ 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
![]()
在 types 目录下创建 common.types.ts
code 必须,其他两个非必须
// API 通用响应类型
export interface ApiResponse<T> {
code: number
data?: T
msg?: string
}
7.3.2 DTO 与 VO 数据格式
对标后端的 EmployeeLoginDTO 和 EmployeeLoginVO
![]()
在 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>
页面效果如下:
![]()
7.4.2 表单校验实现
获取表单实例
// 获取表单实例
const ruleFormRef = ref<FormInstance>()
![]()
实例获取对象为 表单中的数据
ref="ruleFormRef"
![]()
这一步的作用是拿到表单中数据,且 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'}
]
})
![]()
获取表单中的数据,也就是获取 username 和 password 两个输入框中的内容
数据类型为 EmployeeLoginDTO ,也就是我们要提交给后端的数据
![]()
将 from 和 rules 绑定给表单
:model="form"
:rules="rules"
![]()
from 中的元素需要通过 prop 绑定给表单项
![]()
效果如下:
![]()
7.5 登录逻辑
7.5.1 登录接口 api 实现
在 api/services 目录下创建 employee.ts ,用来存放员工相关的 api
![]()
employee.ts
// 员工登录
export const loginApi = () => {
}
接下来通过 接口文档 分析 参数 和 返回值
-
参数:
EmployeeLoginDTO类型的参数,即usernamepassword
-
返回值:
data中存储的是EmployeeLoginVO类型的返回值,即-
code -
dataidusernamenametoken
-
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('请按照表单规则填写')
}
})
}
![]()
登录效果如下(我有后端程序,因此能够成功得到反馈)
![]()
7.6 创建 Pinia Store 管理用户状态(stores/user.ts)
因为用户登录后的信息后期会经常用到,像 token 、 username 这些,因此,可以将这些方法存储在仓库,要用到的时候就可以随时调用
创建 user.ts
![]()
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 中的方法
删除当前选中的代码
![]()
// 拿到仓库实例
const userStore = useUserStore()
![]()
// 用户仓库保存登录信息
userStore.setLogin(res.data)
![]()
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()
}
})
![]()
7.8 完善请求拦截器:自动添加 Token
请求拦截器:所有向后端发送的请求都会经过请求拦截器
除了登录操作,其他任何操作都需要将 token 交给后端进行判断。我们通过 request.ts 中的 请求拦截器 ,统一设置 token ,就可以减少很多操作
后端 .yml 文件中明确提到—— 前端传递过来的令牌名称 为 token
![]()
(该图为后端 application.yml 中的配置,具体情况请参考自己的后端相关配置)
因此,请求头 中也要叫 token ,即
config.headers.token
// 从仓库中获取 token
const userStore = useUserStore()
if (userStore.token) {
config.headers.token = userStore.token
}
![]()
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'))
}
![]()
login/index.vue 中可以去除关于 code 的判断
![]()
7.10 退出登录逻辑
点击 退出 按钮,需要退回到 登录页面 ,并且 将用户信息清除
![]()
// 拿到仓库实例
const userStore = useUserStore()
// 退出登录逻辑
const logout = () => {
// 调用用户仓库的 logout 方法退出登录,会清除用户信息
userStore.logout()
router.push('/login')
}
![]()
附录:常见问题(FAQ)
终端权限问题
快速解决
-
关闭 idea
-
在桌面 右键 ,选择 以管理员身份运行

Tailwind CSS 初始化错误
![]()
# 先卸载
npm uninstall tailwindcss
# 重新安装(明确指定版本)
npm install -D tailwindcss@3 postcss autoprefixer
# 然后用相对路径执行初始化
.\node_modules.bin\tailwindcss init -p
2026-05-05 更新:修正了代码块语法高亮
14_React 中的更新队列 updateQueue
一、概述
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