阅读视图

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

一次讲清楚 `Promise.finally()`:为什么“无论成功失败都要执行”该用它

在日常业务里,经常有这种需求:

  • 先做一件异步事(请求、弹窗、授权等);
  • 不管结果成功还是失败,后续流程都要继续。

这段代码就是一个典型例子:

this.requestSomeSubscribeMessage().finally(() => {
    this.getSomeData(item.status);
});

1. 这段代码到底是什么语法?

这是 Promise 链式调用:

  1. this.requestSomeSubscribeMessage() 返回一个 Promise;
  2. .finally(...) 注册一个“收尾回调”;
  3. 当前面 Promise 结束(fulfilled 或 rejected)时,finally 里的代码都会执行。

一句话:finally = 不管成败都执行。


2. 和 then / catch 的区别

最核心区别:

  • then:只处理成功
  • catch:只处理失败
  • finally:成功失败都执行(常用于收尾)

示例:

doSomething()
  .then((res) => {
  console.log('成功', res);
})

.catch((err) => {
  console.log('失败', err);
})

.finally(() => {
  console.log('一定会执行');
});

3. 为什么订阅消息这个场景特别适合 finally

你的业务要求是:

  • 先调起订阅弹窗;
  • 用户允许、拒绝、关闭、报错都不阻断;
  • 始终继续办理保险流程。

这正是 finally 的语义:把“不应被阻断的后续逻辑”放进统一出口。

4. 你这段代码可以怎么理解(按执行顺序)

this.requestSomeSubscribeMessage().finally(() => {
  this.getSomeData(item.Status);
});

执行过程:

  1. 调用 requestSomeSubscribeMessage(异步);
  2. 等它结束;
  3. 不管结束状态是什么,都调用 getSomeData(...)

5. 一个容易混淆的点:finally 不是拿结果用的

finally 适合做“收尾动作”,比如:

  • 关闭 loading
  • 释放锁
  • 继续不应中断的流程
  • 埋点/日志(不依赖业务结果时)

如果你要依赖成功结果(如 res.data),应该在 then 里处理。

6. async/await 的等价写法(推荐复习)

你这段逻辑也可以写成:

try {
  await this.requestSomeSubscribeMessage();
} finally {
  this.getSomeData(item.tianCaiInsuranceStatus);
}

这和 Promise 的 finally 语义一致:
try 成功或抛错,finally 都执行。

7. 实战建议(可直接记忆)

  • 看语义选方法:

    • 只成功:then
    • 只失败:catch
    • 都要执行:finally
  • 把“必须执行”的业务放 finally,最不容易漏逻辑。

  • 不要在 finally 里写依赖成功结果的代码(会让代码可读性变差)。

8. 这个案例的一句话总结

this.requestSomeSubscribeMessage().finally(...) 的含义就是:
“订阅流程结束后(不论结果),都继续办理保险。”

LLM 应用开发的底层逻辑:模型只是一个无状态函数

自己接模型开发 AI 应用——底层逻辑全解

写给想搞清楚 LLM 应用开发本质的工程师。不讲玄学,只讲代码和流程。

本文用一个贯穿始终的例子:给博客平台做一个 AI 写作助手,从零到完整功能一步步实现。


核心认知(先记住这一句)

模型是一个无状态函数:f(messages[]) → text

它不认识你,没有记忆,不能主动做任何事。 状态、历史、数据、工具执行——全部由你的代码维护,每次调用都是全量传入。

后面所有内容都是这句话的展开。


Step 1:跑通第一个请求

目标:用户在博客编辑器里输入关键词,AI 返回一个标题建议。

安装依赖

npm install @anthropic-ai/sdk

最简实现

// lib/ai.ts
import Anthropic from '@anthropic-ai/sdk'

const client = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY  // 存到 .env.local,绝不硬编码
})

export async function generateTitle(keyword: string) {
  const response = await client.messages.create({
    model: 'claude-sonnet-4-6',
    max_tokens: 256,
    messages: [
      { role: 'user', content: `根据关键词"${keyword}",给我 3 个吸引人的博客标题` }
    ]
  })

  return response.content[0].text
}

接口层

// app/api/ai/title/route.ts
import { generateTitle } from '@/lib/ai'

export async function POST(req: Request) {
  const { keyword } = await req.json()
  const titles = await generateTitle(keyword)
  return Response.json({ titles })
}

调用测试

curl -X POST http://localhost:3000/api/ai/title \
  -H "Content-Type: application/json" \
  -d '{"keyword": "Next.js 性能优化"}'

# 返回:
# { "titles": "1. 《让你的 Next.js 应用快 3 倍的 10 个技巧》\n2. ..." }

你刚才做了什么:一次 HTTP POST,发文字,收文字。AI 应用的本质就是这个。


Step 2:理解参数,控制模型行为

上面的代码能跑,但不够可控。先把参数搞清楚。

完整参数结构

const response = await client.messages.create({
  // ── 必填 ──────────────────────────────
  model:      'claude-sonnet-4-6',   // 用哪个模型
  max_tokens: 1024,                  // 输出最多多少 token
  messages:   [...],                 // 对话历史

  // ── 控制模型行为 ─────────────────────
  system:      '...',                // 幕后指令,用户看不到
  temperature: 0.7,                  // 随机性/创意度

  // ── 高级功能(用到再开)──────────────
  tools:   [...],                    // 工具定义(Tool Use)
  stream:  true,                     // 流式输出
})

model:怎么选?

模型 定位 适合场景
claude-opus-4-6 最强、最贵 复杂推理、高精度
claude-sonnet-4-6 性价比最高 日常首选
claude-haiku-4-5 最快、最便宜 简单任务、高并发

system:幕后规则

// 没有 system 的问题:模型什么都答,风格不可控
// 加了 system:模型被约束在你规定的范围内工作

system: `你是一个专业的中文博客写作助手。
规则:
- 只输出标题,不解释
- 每个标题不超过 20 个字
- 风格:实用、有数字、有价值感`

temperature:创意 vs 精确

0.0  → 每次输出几乎相同  →  代码生成、数据提取、格式转换
0.7  → 平衡              →  日常对话、内容生成(推荐默认值)
1.0  → 更有创意          →  头脑风暴、创意写作

max_tokens:输出上限

1 token  0.75 个英文单词  0.5 个汉字

256     短回复、标题建议
1024    普通段落
4096    完整文章

超出就截断,不是保证输出这么多

返回值:你需要关心的字段

const response = await client.messages.create({...})

response.content[0].text   // 模型的文字回答,最常用
response.stop_reason       // 'end_turn'(正常) | 'tool_use'(要调工具) | 'max_tokens'(被截断)
response.usage             // { input_tokens: 150, output_tokens: 300 },计费依据

改进后的标题生成

export async function generateTitle(keyword: string) {
  const response = await client.messages.create({
    model: 'claude-sonnet-4-6',
    max_tokens: 256,
    temperature: 0.8,          // 标题需要创意,调高一点
    system: `你是专业博客标题写手。
输出格式:直接输出 3 个标题,每行一个,不加序号和解释。
风格:有数字、有价值感、适合 SEO。`,
    messages: [
      { role: 'user', content: `关键词:${keyword}` }
    ]
  })

  return response.content[0].text.split('\n').filter(Boolean)
  // → ['让你的 Next.js 快 3 倍的 10 个技巧', '...', '...']
}

Step 3:多轮对话——让 AI 记住上下文

目标:用户说"标题太长了",AI 知道是在改哪个标题,而不是重新开始。

模型没有记忆,你来维护历史

1轮发送:[你好]2轮发送:[你好, 好的有什么可以帮你, 帮我写标题]3轮发送:[你好, 好的有什么可以帮你, 帮我写标题, 这是3个标题, 改短一点]

每次请求都把完整历史带上,模型才能"记住"前面说了什么。

实现

// 用数组维护历史
type Message = { role: 'user' | 'assistant'; content: string }

export class BlogAIChat {
  private history: Message[] = []

  async send(userInput: string): Promise<string> {
    // 把用户输入加入历史
    this.history.push({ role: 'user', content: userInput })

    const response = await client.messages.create({
      model: 'claude-sonnet-4-6',
      max_tokens: 1024,
      system: '你是博客写作助手,帮助用户打磨文章标题和内容。',
      messages: this.history  // 每次发送完整历史
    })

    const reply = response.content[0].text

    // 把 AI 回复也加入历史,下轮才能看到
    this.history.push({ role: 'assistant', content: reply })

    return reply
  }

  clear() {
    this.history = []  // 开始新对话时清空
  }
}

对话效果

const chat = new BlogAIChat()

await chat.send('帮我写3个关于 Next.js 的标题')
// → "1. 《Next.js 15 新特性...》\n2. ..."

await chat.send('第一个太长了,控制在 15 字以内')
// → "《Next.js 15 必学新特性》"  ← 知道是在改第一个

await chat.send('换个角度,从性能优化切入')
// → "《Next.js 性能翻倍实战》"   ← 知道还是在改标题

注意:历史越长越贵

对话历史 10 轮 → input_tokens 可能高达 3000+
对话历史 50 轮 → input_tokens 可能高达 15000+

处理方式:
1. 超过 N 轮后,截掉最早的几轮
2. 让模型对历史做摘要,替换掉详细内容
3. 业务上限制每次对话长度

Step 4:流式输出——打字机效果

目标:AI 生成文章时,不是等全部写完才显示,而是实时一字一字出现。

为什么需要流式

不加流式:模型写 500 字的文章 → 用户等 8 秒 → 一次性全部显示
加流式:  模型写 500 字的文章 → 用户立刻看到第一个字 → 字符逐渐出现

用户体验差距极大,生产环境基本都要加流式

后端:用 SSE 推给前端

// app/api/ai/write/route.ts
export async function POST(req: Request) {
  const { prompt } = await req.json()
  const encoder = new TextEncoder()

  const stream = new ReadableStream({
    async start(controller) {
      const aiStream = client.messages.stream({
        model: 'claude-sonnet-4-6',
        max_tokens: 2048,
        messages: [{ role: 'user', content: prompt }]
      })

      for await (const chunk of aiStream) {
        if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
          // SSE 格式:data: 内容\n\n
          controller.enqueue(encoder.encode(`data: ${JSON.stringify({ text: chunk.delta.text })}\n\n`))
        }
      }

      controller.enqueue(encoder.encode('data: [DONE]\n\n'))
      controller.close()
    }
  })

  return new Response(stream, {
    headers: {
      'Content-Type':  'text/event-stream',
      'Cache-Control': 'no-cache',
    }
  })
}

前端:接收并实时展示

// components/AIWriter.tsx
async function startWriting(prompt: string) {
  let content = ''

  const response = await fetch('/api/ai/write', {
    method: 'POST',
    body: JSON.stringify({ prompt })
  })

  const reader = response.body!.getReader()
  const decoder = new TextDecoder()

  while (true) {
    const { done, value } = await reader.read()
    if (done) break

    const lines = decoder.decode(value).split('\n')
    for (const line of lines) {
      if (line.startsWith('data: ')) {
        const data = line.slice(6)
        if (data === '[DONE]') return

        const { text } = JSON.parse(data)
        content += text
        setEditorContent(content)  // 实时更新 UI
      }
    }
  }
}

Step 5:RAG——让模型知道你的私有数据

目标:用户问"帮我分析一下访问量最高的文章有什么共同特点",AI 能基于真实数据回答。

问题根源

模型的知识 = 训练截止日期前的公开数据
           ≠ 你的数据库、用户数据、实时信息

解法:你查数据,把结果告诉模型。

方式一:直接注入 Prompt(适合小数据)

export async function analyzeBlogs(userId: string) {
  // 第一步:你来查数据库
  const blogs = await db.query(`
    SELECT title, views, avg_read_time, bounce_rate
    FROM blogs
    WHERE user_id = ? AND created_at > NOW() - INTERVAL 30 DAY
    ORDER BY views DESC
    LIMIT 10
  `, [userId])

  // 第二步:把数据拼进 prompt,告诉模型
  const response = await client.messages.create({
    model: 'claude-sonnet-4-6',
    max_tokens: 1024,
    messages: [{
      role: 'user',
      content: `
以下是我最近 30 天访问量最高的 10 篇文章数据:

${JSON.stringify(blogs, null, 2)}

请分析这些高访问量文章的共同特点,给出 3 条写作建议。
      `
    }]
  })

  return response.content[0].text
}
// 模型拿到真实数据后的回答:
"根据你的数据分析,高访问量文章有以下共同特点:
1. 标题包含数字('10个'、'3种'),点击率更高
2. 平均阅读时间在 4-6 分钟,说明内容深度合适
3. 跳出率低于 40% 的文章均有清晰的目录结构..."

方式二:向量检索(适合大量文档)

当数据量大(几百篇文章、长文档),不可能全塞进 prompt,用向量检索精准召回相关内容。

原理

文本 → 向量(一串数字) → 相似文本的向量距离近

"苹果手机"  → [0.8, 0.2, 0.1, ...]
"iPhone"    → [0.79, 0.21, 0.09, ...]  ← 语义相似,向量接近
"香蕉"      → [0.1, 0.9, 0.3, ...]    ← 语义不同,向量远

建库阶段(一次性)

import { OpenAI } from 'openai'  // 用 OpenAI 的 embedding API 举例

async function buildIndex(blogs: Blog[]) {
  for (const blog of blogs) {
    // 把文章内容转成向量
    const embedding = await openai.embeddings.create({
      model: 'text-embedding-3-small',
      input: blog.content
    })

    // 存入向量数据库(如 pgvector、Pinecone)
    await vectorDB.insert({
      id:        blog.id,
      vector:    embedding.data[0].embedding,
      metadata:  { title: blog.title, content: blog.content }
    })
  }
}

查询阶段(每次对话)

async function ragQuery(userQuestion: string) {
  // 1. 把用户问题也转成向量
  const questionEmbedding = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: userQuestion
  })

  // 2. 找最相似的 3 篇文章
  const relatedBlogs = await vectorDB.search(
    questionEmbedding.data[0].embedding,
    { topK: 3 }
  )

  // 3. 把召回的文章内容注入 prompt
  const context = relatedBlogs.map(b => b.metadata.content).join('\n---\n')

  const response = await client.messages.create({
    messages: [{
      role: 'user',
      content: `
参考以下文章内容回答问题:

${context}

问题:${userQuestion}
      `
    }]
  })

  return response.content[0].text
}

两种方式怎么选

直接注入 向量检索
数据量 < 50 条 / 文档短 > 50 条 / 文档长
实现难度 简单,直接拼字符串 复杂,需要向量数据库
成本 token 消耗多 token 消耗少
精准度 全量数据,不会漏 依赖检索质量

实践建议:先用直接注入跑通功能,有性能/成本问题再上向量检索。


Step 6:Tool Use——让模型主动调用你的函数

目标:用户说"帮我把访问量低于 100 的草稿文章,标题加上'[待优化]'前缀",AI 自动查数据库、自动更新。

RAG vs Tool Use 的本质区别

RAG:      你主动查数据 → 告诉模型 → 模型分析
Tool Use:  模型决定查什么 → 告诉你去查 → 你执行 → 告诉模型结果 → 模型回答

RAG 是你喂给模型,Tool Use 是模型指挥你执行。

工具调用是模型的能力吗?

是,也不是。

  • 模型:识别什么时候需要工具,输出结构化的调用指令(JSON)
  • 模型不能:真正连接数据库、执行代码、调用 API——这些都是你的代码做的

模型只是"点菜",你来"上菜"。

完整流程(来回两次)

你                                    模型
─────────────────────────────────────────────
① 发请求(带工具定义)           →
                                  ← ② 返回 tool_use(结构化指令,不是文字)
③ 你执行这个工具(查数据库等)
④ 把执行结果发回                 →
                                  ← ⑤ 模型基于结果,返回最终文字回答

Step 6.1:定义工具

// 告诉模型你提供了哪些"能力"
const tools = [
  {
    name: 'get_low_traffic_blogs',
    description: '查询访问量低于指定值的博客文章列表',
    input_schema: {
      type: 'object',
      properties: {
        threshold: { type: 'number', description: '访问量阈值' },
        status:    { type: 'string', enum: ['draft', 'published', 'all'] }
      },
      required: ['threshold']
    }
  },
  {
    name: 'update_blog_title',
    description: '更新指定博客文章的标题',
    input_schema: {
      type: 'object',
      properties: {
        blog_id:   { type: 'string' },
        new_title: { type: 'string' }
      },
      required: ['blog_id', 'new_title']
    }
  }
]

Step 6.2:第一次请求,模型返回工具调用

const res1 = await client.messages.create({
  model: 'claude-sonnet-4-6',
  max_tokens: 1024,
  tools,
  messages: [{
    role: 'user',
    content: '把访问量低于 100 的草稿文章,标题加上 [待优化] 前缀'
  }]
})

console.log(res1.stop_reason)  // 'tool_use'
console.log(res1.content)
// [
//   {
//     type: 'tool_use',
//     id:   'tu_001',
//     name: 'get_low_traffic_blogs',
//     input: { threshold: 100, status: 'draft' }
//   }
// ]

Step 6.3:你执行工具,发回结果

// 根据模型指令执行对应函数
async function executeTool(name: string, input: any) {
  switch (name) {
    case 'get_low_traffic_blogs':
      return await db.query(
        'SELECT id, title, views FROM blogs WHERE views < ? AND status = ?',
        [input.threshold, input.status ?? 'draft']
      )
    case 'update_blog_title':
      await db.query(
        'UPDATE blogs SET title = ? WHERE id = ?',
        [input.new_title, input.blog_id]
      )
      return { success: true, blog_id: input.blog_id }
  }
}

const toolCall = res1.content.find(b => b.type === 'tool_use')
const result   = await executeTool(toolCall.name, toolCall.input)
// result = [{ id: '1', title: '未优化文章', views: 45 }, ...]

// 把结果发回(消息历史必须完整带上)
const res2 = await client.messages.create({
  model: 'claude-sonnet-4-6',
  max_tokens: 1024,
  tools,
  messages: [
    { role: 'user',      content: '把访问量低于 100 的草稿...' },
    { role: 'assistant', content: res1.content },   // 模型上一轮输出
    {
      role: 'user',
      content: [{
        type:        'tool_result',
        tool_use_id: toolCall.id,                   // 必须对应 tu_001
        content:     JSON.stringify(result)
      }]
    }
  ]
})

Step 6.4:模型可能继续调工具

模型拿到文章列表后,会继续调 update_blog_title 逐一更新,直到全部完成,最后返回文字说明。

第一轮:get_low_traffic_blogs → 你查询 → 返回 3 篇文章
第二轮:update_blog_title(blog_id:1) + update_blog_title(blog_id:2) + update_blog_title(blog_id:3)
        ↑ 模型可以一次调用多个工具(并行)
第三轮:end_turn → "已将 3 篇草稿文章标题加上了 [待优化] 前缀"

封装成通用循环(生产代码)

async function runAgent(userMessage: string): Promise<string> {
  const messages: any[] = [{ role: 'user', content: userMessage }]

  while (true) {
    const response = await client.messages.create({
      model: 'claude-sonnet-4-6',
      max_tokens: 1024,
      tools,
      messages
    })

    messages.push({ role: 'assistant', content: response.content })

    if (response.stop_reason === 'end_turn') {
      return response.content.find((b: any) => b.type === 'text').text
    }

    // 并行执行所有工具调用
    const toolResults = await Promise.all(
      response.content
        .filter((b: any) => b.type === 'tool_use')
        .map(async (toolCall: any) => ({
          type:        'tool_result',
          tool_use_id: toolCall.id,
          content:     JSON.stringify(
            await executeTool(toolCall.name, toolCall.input)
          )
        }))
    )

    messages.push({ role: 'user', content: toolResults })
  }
}

整体架构图

用户浏览器(React)
      ↕ SSE 流式 / JSON
Next.js API Route(你的后端)
      ↕ 维护 messages 历史
      ↕ 执行工具(查/写 DB)
      ↕ 向量检索
Claude API(模型)

落地路径(从今天开始)

今天     →  Step 1-2:写第一个接口,接收文本返回 AI 输出
本周     →  Step 3-4:加多轮对话 + 流式输出,体验质的提升
下周     →  Step 5:把真实数据注入 prompt,让 AI 基于业务数据回答
后续     →  Step 6:加 Tool Use,让 AI 能主动操作数据

FAQ

Q:模型真的没有记忆吗?那 ChatGPT 为什么记得我上次说的话?

因为 ChatGPT 的产品层做了历史存储。它在每次对话时,从数据库捞出你的历史消息,拼成 messages[] 发给模型。模型本身仍然是无状态的,"记忆"是产品层实现的。


Q:system prompt 和 user message 有什么区别,分开写有什么好处?

system 是"幕后规则",用户输入的任何内容无法覆盖它(正常情况下)。
user 是每次对话的输入。

分开写的好处:角色设定和约束放 system,不随对话历史增长;用户输入放 messages,保持清晰。如果把 system 混在第一条 user 消息里,每轮对话都要重复发这段文字,浪费 token。


Q:temperature 设成 0 不是更好吗?输出最稳定。

不一定。temperature=0 会让模型倾向于选择概率最高的 token,输出死板、重复。
写标题、写文案等创意任务,0.7-0.9 往往比 0 更好用。
只有代码生成、JSON 提取、分类判断等"有唯一正确答案"的任务,才适合调到 0。


Q:token 是什么?怎么控制成本?

Token 是模型处理文本的最小单位,粗略理解:

1 token ≈ 0.75 个英文单词 ≈ 0.5 个汉字

计费 = input_tokens × 输入单价 + output_tokens × 输出单价(输出通常贵 3-5 倍)。

控制成本的方法:

  1. 选合适的模型(haiku 比 sonnet 便宜约 10 倍)
  2. 精简 system prompt,不写废话
  3. 限制多轮对话历史长度
  4. 生产环境加 prompt cache(重复的 system prompt 只收一次钱)

Q:RAG 和 Fine-tuning 怎么选?

RAG:          把数据在查询时注入 prompt
Fine-tuning:  把数据烧进模型权重(改变模型本身)

用 RAG 的情况:
  - 数据经常更新(博客文章、订单数据)
  - 需要引用来源
  - 成本敏感

用 Fine-tuning 的情况:
  - 需要改变模型的输出风格/格式
  - 有大量标注的输入输出对
  - 任务高度专业化

实践结论:90% 的场景 RAG 够用,Fine-tuning 是优化手段,不是入门必须。

Q:Tool Use 和直接在代码里查数据库有什么区别?

// 直接查:你的逻辑决定查什么
const data = await db.query('SELECT ...')
const response = await ai.ask(`分析这个数据:${data}`)

// Tool Use:模型的逻辑决定查什么
// 用户说"对比一下最近3个月和去年同期的数据"
// 模型自己推断出要调用 get_stats(period:'3m') 和 get_stats(period:'last_year')
// 你只负责实现 get_stats 函数

本质区别:查询逻辑在哪里。直接查是你写死的,Tool Use 是模型动态决定的。
Tool Use 适合让 AI 处理"用户说的话不固定,需要灵活判断调什么接口"的场景。


Q:LangChain / LlamaIndex 这些框架值得学吗?

这些框架帮你封装了:多轮对话历史管理、Tool Use 循环、RAG 流程、向量数据库接入。

什么时候用框架:快速验证想法、不想重复造轮子。
什么时候不用:生产环境需要精细控制、框架版本更新频繁带来不稳定性。

建议:先理解原理,再用框架。本文讲的这些你都懂了,看框架文档就知道它在封装什么,遇到问题才能 debug。反过来,上手框架但不理解底层,一遇到奇怪问题就束手无策。

屎山代码拆不动?微前端来救场:一个应用变“乐高城堡”

前言

想象你有一座巨大的乐高城堡,一开始几个人拼得很开心。后来城堡越拼越大,几百人同时在上面加砖,有人碰倒了塔楼,有人改错了城墙,整个城堡摇摇欲坠。你想拆成几个独立的小城堡,又怕它们之间连不起来。

这就是巨石前端的困境。微前端就是解决方案:把大应用拆成多个小应用(子应用),每个小应用独立开发、独立部署,最后在浏览器里组合成一个完整页面。就像乐高套装里的每个小模块,可以单独拼好,再插到一起。

一、什么时候需要微前端?

  • 项目太大,编译部署一次要10分钟。
  • 团队太多,几十人改同一个仓库,Git冲突到崩溃。
  • 想渐进式升级技术栈(比如老项目用AngularJS,新模块用React)。
  • 不同团队负责不同业务板块,希望独立发布互不干扰。

如果你的项目只有三五个人,别用微前端——杀鸡不用牛刀。

二、微前端三大核心问题

微前端要解决三个问题:

  1. 怎么加载子应用?(路由分发)
  2. 怎么隔离子应用?(JS沙箱、样式隔离)
  3. 怎么通信?(全局状态、事件总线)

三、常见实现方式

1. 路由分发式(Nginx反向代理)

不同路径对应不同子应用,比如/app1 → 应用1,/app2 → 应用2。父页面通过iframe或服务端路由组合。

  • 简单,但切换应用会刷新页面。
  • 不适合需要无缝组合的场景。

2. iframe:最土的“隔离神器”

iframe天然隔离JS和CSS,但缺点明显:通信麻烦、SEO差、弹窗无法覆盖、全局状态不共享。

3. single-spa:微前端的“老大哥”

一个框架,帮你管理子应用的加载、挂载、卸载。你需要自己写如何加载子应用(比如动态script加载),以及子应用暴露的生命周期(bootstrap、mount、unmount)。

  • 灵活,但需要较多配置。
  • 适合自己造轮子。

4. qiankun:蚂蚁开箱即用的方案

基于single-spa,内置了JS沙箱、样式隔离、HTML Entry(自动加载子应用的HTML、JS、CSS)。你只需要改几行代码,就能把一个普通应用变成微前端子应用。

  • 推荐大部分项目用qiankun。
  • 支持Vue、React、Angular等。

5. Webpack 5 Module Federation:去中心化的“共享冰箱”

不需要主应用,任意两个应用可以互相暴露和使用模块。运行时动态加载对方代码,像从冰箱里拿菜一样。

  • 非常适合多个独立部署的微前端应用。
  • 需要Webpack 5支持。

四、qiankun 实战:三步把React应用变成子应用

假设你有一个主应用(基座),一个子应用(React)。

主应用(基座)注册子应用

import { registerMicroApps, start } from 'qiankun';

registerMicroApps([
  {
    name: 'reactApp',
    entry: '//localhost:3001', // 子应用启动的地址
    container: '#subapp-container',
    activeRule: '/react',
  },
]);
start();

子应用(React)改造

src/index.js里暴露生命周期:

function render(props) {
  ReactDOM.render(<App />, document.getElementById('root'));
}

if (!window.__POWERED_BY_QIANKUN__) {
  render(); // 独立运行时直接渲染
}

export async function bootstrap() {}
export async function mount(props) {
  render(props);
}
export async function unmount() {
  ReactDOM.unmountComponentAtNode(document.getElementById('root'));
}

再改webpack配置,让打包成umd格式:

output: {
  library: `${name}-[name]`,
  libraryTarget: 'umd',
  globalObject: 'window',
}

搞定!子应用独立运行时正常访问,被qiankun加载时也能完美嵌入。

五、JS沙箱:防止子应用污染全局

qiankun提供了两种沙箱:

  • SnapshotSandbox:记录恢复window属性变化(兼容IE)。
  • ProxySandbox:用ES6 Proxy代理对window的读写,每个子应用有自己的fakeWindow。

这样子应用里修改windowdocument都不会影响全局。

六、样式隔离:你的样式别弄脏我的衣服

qiankun默认使用shadowDOM(需要子应用支持),也可以通过配置strictStyleIsolation开启。或者简单约定:子应用所有样式加namespace

七、应用间通信:传递“小纸条”

  • 通过props传递:主应用mount子应用时,可以传入通信函数。
  • 全局状态管理:用qiankuninitGlobalState
  • 自定义事件window.dispatchEvent(但注意沙箱可能隔离window)。

八、常见坑点与建议

  1. 重复依赖:多个子应用都打包了React,体积大。解决方案:用externals或Module Federation共享。
  2. 子应用间路由跳转:用history.pushState前判断是否在微前端环境,调用主应用的路由实例。
  3. 公共样式:主应用提供全局样式,子应用只写局部样式。
  4. 性能:预加载子应用,或使用loadable组件按需加载。

九、Module Federation:不用主应用的“分布式”微前端

如果你的项目没有明确的主应用,每个应用都可以暴露模块给其他应用,用Webpack 5的ModuleFederationPlugin

// 应用A暴露组件
new ModuleFederationPlugin({
  name: 'appA',
  filename: 'remoteEntry.js',
  exposes: {
    './Button': './src/Button',
  },
});

// 应用B消费
new ModuleFederationPlugin({
  name: 'appB',
  remotes: {
    appA: 'appA@http://localhost:3001/remoteEntry.js',
  },
});
// 在B里异步加载:import('appA/Button')

这样两个应用独立部署,运行时动态加载对方组件,超级灵活。

十、总结:微前端不是银弹,但能救急

  • 微前端适合超大项目、多团队、技术栈升级
  • 简单场景用qiankun,复杂场景用Module Federation
  • 注意JS沙箱、样式隔离、通信成本。
  • 如果项目只有几十个页面,别折腾,用组件化就够了。

微前端就像乐高积木:拆开是独立小玩具,拼起来是宏伟城堡。用得好,团队效率翻倍;用不好,调试到你怀疑人生。


如果你觉得今天的“乐高城堡”够形象,点个赞让更多人看到。明天我们将聊聊前端设计模式——单例、观察者、工厂、策略,那些让你代码更优雅的套路。我们明天见!

前端 JavaScript 核心知识点 + 高频踩坑 + 大厂面试题全汇总(开发 / 面试必备)

本文汇总了前端开发中99% 会遇到的 JS 核心知识点、高频踩坑、大厂面试题,每一个知识点都搭配代码示例,踩坑点附落地解决方案,面试题附详细解析,适合前端新手查漏补缺、老手复习巩固,可直接用于开发实战和面试准备~


一、JavaScript 核心基础知识点(必掌握)

1.1 数据类型(原始类型 + 引用类型)

JS 数据类型分为原始值类型引用数据类型,是前端开发的基石。

  • 原始类型(7 种):UndefinedNullBooleanNumberStringSymbolBigInt(ES11新增)
  • 引用类型:Object(包含ArrayFunctionDateRegExp等)

核心区别

  1. 原始类型存栈内存,值不可变;引用类型存堆内存,栈中存储堆地址
  2. 原始类型赋值是值拷贝,引用类型赋值是地址拷贝
  3. 原始类型比较是值比较,引用类型比较是地址比较

代码示例

// 原始类型:值拷贝,互不影响
let a = 10;
let b = a;
b = 20;
console.log(a); // 10

// 引用类型:地址拷贝,修改会相互影响
let obj1 = { name: "掘金" };
let obj2 = obj1;
obj2.name = "前端开发";
console.log(obj1.name); // 前端开发

// 精准类型判断
Object.prototype.toString.call(null); // [object Null]
Object.prototype.toString.call([]); // [object Array]

为什么要加 BigInt?

Number 局限:只能精确表示 ±2⁵³−1 范围内的整数(约 9e15)。

精度丢失问题

9007199254740992 === 9007199254740993; // true(错误)

// 1. 字面量(加 n)
const a = 123n;
const b = -456n;

// 2. 构造函数
const c = BigInt(789);
const d = BigInt("9007199254740992");

// 3. 类型判断
typeof a; // "bigint"

//与 Number 不兼容 不支持小数、Math 方法、JSON.stringify
123n + 123; // TypeError(不能混合运算)
123n === 123; // false

BigInt 解决:支持任意精度整数,适合金融、区块链、大 ID、密码学。

1.2 变量声明:var /let/const

前端最基础的声明规则,也是面试必考、开发必用知识点。

特性 var let const
变量提升 ✅ 存在 ❌ 暂时性死区 ❌ 暂时性死区
块级作用域 ❌ 无 ✅ 有 ✅ 有
重复声明 ✅ 允许 ❌ 不允许 ❌ 不允许
重新赋值 ✅ 允许 ✅ 允许 ❌ 不允许

代码示例

// var:变量提升 + 全局污染
console.log(num); // undefined
if (true) var num = 10;
console.log(num); // 10

// let:块级作用域隔离
let age = 20;
if (true) {
  let age = 30;
}
console.log(age); // 20

// const:必须初始化,引用类型可改属性
const PI = 3.14;
const user = { name: "张三" };
user.name = "李四"; // 合法

1.3 类型转换(显式 + 隐式)

JS 是弱类型语言,类型转换是开发高频操作。

  • 显式转换:Number()String()Boolean()parseInt()
  • 隐式转换:+-==if判断等自动触发

代码示例

// 显式转换
Number("123"); // 123
String(true); // "true"
Boolean(0); // false

// 隐式转换
1 + "2"; // "12"(数字转字符串)
"12" - 0; // 12(字符串转数字)
if (1) {} // 1转true

1.4 运算符核心(== / === / 短路运算 / 空值合并)

// ==:隐式转换后比较;===:严格比较(类型+值)
0 == ""; // true
0 === ""; // false

// 短路运算:&&(一假则假)、||(一真则真)
const name = null || "默认名称";
const age = 18 && "成年";

// 空值合并??:仅null/undefined时取默认值(开发推荐)
const obj = { age: 0 };
obj.age ?? 18; // 0
obj.height ?? 180; // 180

1.5 函数核心(普通函数 / 箭头函数 /this)

箭头函数 vs 普通函数

  1. 箭头函数没有this,继承父级作用域的this
  2. 没有arguments、不能用作构造函数、没有原型
  3. 简写语法,适合回调函数

代码示例

// 普通函数:this指向调用者
function fn() { console.log(this); }
fn(); // window/global

// 箭头函数:this继承外层
const obj = {
  fn: () => console.log(this)
};
obj.fn(); // window

1.6 数组高频方法(开发必备)

const arr = [1,2,3];
// 遍历:forEach、map、filter、find、some、every
arr.map(item => item * 2); // [2,4,6]
arr.filter(item => item > 1); // [2,3]

// 增删改查:push/pop/unshift/shift/splice
arr.push(4); // [1,2,3,4]
arr.splice(1,1); // 删除索引1的元素 → [1,3,4]

// 高阶:reduce(求和、去重、扁平化)
arr.reduce((sum, cur) => sum + cur, 0); // 8

1.7 闭包(核心概念)

定义:函数嵌套函数,内部函数访问外部函数变量,形成闭包。作用:私有化变量、延长变量生命周期、实现柯里化风险:滥用会导致内存泄漏

代码示例

function outer() {
  let num = 10;
  return function inner() {
    console.log(num); // 访问外部变量 → 闭包
  };
}
const fn = outer();
fn(); // 10

1.8 原型与原型链

JS 继承的核心机制,面试必考。

  1. 所有对象都有__proto__,指向构造函数的prototype
  2. 原型链:对象查找属性 / 方法的路径,终点是null

代码示例

function Person(name) {
  this.name = name;
}
// 原型方法
Person.prototype.sayHi = function() {
  console.log(this.name);
};
const p = new Person("张三");
p.sayHi(); // 张三

// 原型链关系
p.__proto__ === Person.prototype;
Person.prototype.__proto__ === Object.prototype;
Object.prototype.__proto__ === null;

1.9 异步编程(回调 / Promise /async-await)

JS 是单线程语言,异步解决阻塞问题。

// Promise 基础
const p = new Promise((resolve, reject) => {
  setTimeout(() => resolve("成功"), 1000);
});
p.then(res => console.log(res));

// async-await(语法糖,开发首选)
async function getData() {
  const res = await p;
  console.log(res);
}
getData();

1.10 事件循环(宏任务 / 微任务)

JS 执行机制,大厂面试必考题:

  1. 执行栈 → 微任务队列 → 宏任务队列
  2. 微任务:Promise.then/catch/finallyMutationObserver
  3. 宏任务:setTimeoutsetIntervalajaxDOM事件

代码示例

console.log(1);
setTimeout(() => console.log(2), 0); // 宏任务
Promise.resolve().then(() => console.log(3)); // 微任务
console.log(4);
// 执行顺序:1 → 4 → 3 → 2

二、JavaScript 开发高频踩坑汇总(99% 开发者都遇到过)

2.1 隐式类型转换踩坑(== 滥用)

错误场景==自动隐式转换,导致逻辑错误

console.log(0 == ''); // true
console.log('' == false); // true

原因==会先转换类型再比较解决方案开发永远优先用 ===,仅判断null/undefined==

let a;
if (a == null) { // 等价于 a === null || a === undefined
  console.log("变量为空");
}

2.2 forEach 中使用 await 失效

错误场景:forEach 不支持异步,无法按顺序执行

const arr = [1,2,3];
arr.forEach(async item => {
  await new Promise(r => setTimeout(r,1000));
  console.log(item); // 1秒后同时输出1、2、3
});

解决方案:用for...of/ 普通 for 循环

(async () => {
  for(let item of arr) {
    await new Promise(r => setTimeout(r,1000));
    console.log(item); // 每隔1秒输出
  }
})();

2.3 引用类型浅拷贝导致数据篡改

错误场景:对象 / 数组直接赋值,修改新变量污染原数据

let obj1 = { name: "张三" };
let obj2 = obj1;
obj2.name = "李四";
console.log(obj1.name); // 李四

解决方案:浅拷贝.../Object.assign,深拷贝JSON.parse/ 手写深拷贝

// 浅拷贝
let obj2 = {...obj1};
// 深拷贝(无函数/undefined时)
let deepObj = JSON.parse(JSON.stringify(obj1));

2.4 this 指向丢失

错误场景:定时器 / 回调函数中 this 指向改变

const obj = {
  name: "张三",
  sayName() {
    setTimeout(function() {
      console.log(this.name); // undefined
    }, 100);
  }
};

解决方案:箭头函数 / 存 this/bind

// 箭头函数
setTimeout(() => console.log(this.name), 100);

2.5 数组空位导致方法异常

错误场景:数组空位(empty)被 forEach/map 跳过

const arr = [1,,3];
arr.forEach(item => console.log(item)); // 只输出1、3

解决方案:初始化数组时避免空位,用fill填充

const arr = [1, undefined, 3];

2.6 闭包导致内存泄漏

错误场景:闭包变量长期占用内存不释放

function leak() {
  let bigData = new Array(1000000).fill("数据");
  return () => bigData;
}
const fn = leak(); // bigData永远不被回收

解决方案:使用完手动置空

fn = null; // 释放内存

2.7 异步同步混淆执行顺序错误

错误场景:直接获取异步函数返回值

function getData() {
  setTimeout(() => return "数据", 1000);
}
const res = getData();
console.log(res); // undefined

解决方案:用 Promise/async-await 接收

2.8 函数默认参数踩坑

错误场景:默认参数仅在undefined时生效

function fn(a = 10) { console.log(a); }
fn(null); // null
fn(undefined); // 10

三、大厂高频 JavaScript 面试题(附答案 + 解析)

3.1 数据类型相关(必考)

题目 1:JS 有哪些数据类型?Symbol 和 BigInt 的特点?

答案:JS 共8 种原始类型 + 引用类型(Object),其中原始类型包含:

  • 7 种原始类型UndefinedNullBooleanNumberStringSymbol(ES2015)、BigInt(ES2020)
  • 1 种引用类型Object(包含ArrayFunctionDateRegExp等子类型)

Symbol 特点

  • 独一无二,不可重复:Symbol('a') !== Symbol('a')
  • 可作为对象属性名,避免属性冲突
  • 不能参与隐式类型转换,Symbol转字符串需手动调用toString()

BigInt 特点

  • 解决Number精度丢失问题(Number仅能精确表示±2^53-1范围内整数)
  • 定义方式:123n / BigInt('456')
  • 不可与Number混合运算,1n + 2会抛错

题目 2:typeof 和 instanceof 的区别?手写 instanceof 原理

答案

对比项 typeof instanceof
作用 判断原始类型(除 null)和引用类型 判断引用类型的继承关系
返回值 字符串(如'number''object' 布尔值(true/false
特殊点 typeof null === 'object'(历史 bug) 无法判断原始类型(如1 instanceof Number === false

手写 instanceof 原理

/**
 * 手写instanceof
 * @param {*} left 待检测对象 
 * @param {*} right 构造函数 
 * @returns {boolean}
 */
function myInstanceof(left, right) {
  // 原始类型直接返回false
  if (typeof left !== 'object' || left === null) return false;
  // 获取右构造函数的原型对象
  let prototype = right.prototype;
  // 获取左对象的隐式原型
  left = left.__proto__;
  // 遍历原型链
  while (true) {
    // 原型链终点为null
    if (left === null) return false;
    // 原型匹配
    if (left === prototype) return true;
    // 向上遍历原型链
    left = left.__proto__;
  }
}

// 测试
console.log(myInstanceof([], Array)); // true
console.log(myInstanceof({}, Object)); // true
console.log(myInstanceof(123, Number)); // false

3.2 变量声明(var/let/const)

题目:var、let、const 的区别?暂时性死区是什么?

答案:核心差异体现在变量提升、块级作用域、重复声明、重新赋值四个维度:

  1. var:存在变量提升,无块级作用域,可重复声明,可重新赋值
  2. let:无变量提升(存在暂时性死区),有块级作用域,不可重复声明,可重新赋值
  3. const:无变量提升,有块级作用域,不可重复声明,不可重新赋值(引用类型属性可改)

暂时性死区(TDZ) :在代码块内,使用let/const声明变量前,变量处于 “不可访问” 状态,称为暂时性死区。

console.log(a); // 报错:Cannot access 'a' before initialization
let a = 10;

3.3 作用域与作用域链

题目 1:JS 的作用域有哪些?作用域链的作用?

答案:JS 采用词法作用域(静态作用域) ,作用域分为 3 类:

  1. 全局作用域:代码最外层,全局可访问
  2. 函数作用域:函数内部定义,仅函数内可访问
  3. 块级作用域{}包裹(let/const生效),如if/for/switch

作用域链:当访问变量时,会从当前作用域向上查找,直到全局作用域,这条查找链条就是作用域链。作用域链决定了变量的访问权限优先级

题目 2:手写实现块级作用域(用 var 模拟 let)

答案:利用 ** 立即执行函数(IIFE)** 的函数作用域模拟块级作用域:

// 原代码
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
} // 输出 0 1 2

// 用var模拟
for (var i = 0; i < 3; i++) {
  (function(j) {
    setTimeout(() => console.log(j), 100);
  })(i);
} // 输出 0 1 2

3.4 闭包(核心难点)

题目 1:什么是闭包?闭包的应用场景?优缺点?

答案闭包定义:内部函数访问外部函数的变量 / 参数,且内部函数被外部引用,形成闭包。

应用场景

  1. 私有化变量:隐藏内部属性,仅暴露接口(如 JS 模块、单例模式)
  2. 防抖 / 节流:缓存定时器标识
  3. 柯里化函数:参数复用、延迟执行
  4. 模块模式:实现单例、封装私有属性

优缺点

  • 优点:私有化变量、延长变量生命周期、实现函数柯里化
  • 缺点:闭包会占用内存,若未及时释放易导致内存泄漏(大量闭包 + 大对象)

题目 2:手写闭包实现私有属性

答案

/**
 * 闭包实现私有属性
 */
function Person(name) {
  // 私有属性
  let _age = 0;
  // 公有方法(闭包访问私有属性)
  this.getName = function() {
    return name;
  };
  this.getAge = function() {
    return _age;
  };
  this.setAge = function(val) {
    if (val >= 0) _age = val;
  };
}

// 测试
const p = new Person('张三');
console.log(p.getName()); // 张三
console.log(p.getAge()); // 0
p.setAge(20);
console.log(p.getAge()); // 20
console.log(p._age); // undefined(私有属性无法直接访问)

题目 3:闭包导致的内存泄漏如何解决?

答案

  1. 及时解除引用:闭包函数不再使用时,将其赋值为null,释放对内部变量的引用
  2. 避免滥用闭包:减少闭包嵌套层级,避免缓存大对象
  3. 使用弱引用:ES6 的WeakMap/WeakSet存储闭包数据,垃圾回收时自动释放(无引用限制)

3.5 原型基础

题目 1:原型、原型对象、构造函数的关系?

答案

  1. 构造函数:通过new创建实例的函数(如function Person() {}
  2. 原型对象:每个函数都有prototype属性,指向原型对象;每个实例都有__proto__属性,指向构造函数的原型对象
  3. 关系实例.__proto__ === 构造函数.prototype,原型对象的constructor属性指向构造函数

题目 2:JS 的继承方式有哪些?手写 ES6 类继承

答案:JS 常见继承方式:原型链继承、构造函数继承、组合继承、原型式继承、寄生式继承、寄生组合式继承(最优)、ES6 class 继承

手写 ES6 class 继承

/**
 * ES6 class继承
 */
class Parent {
  constructor(name) {
    this.name = name;
  }
  // 原型方法
  sayHi() {
    console.log(`Hello, ${this.name}`);
  }
  // 静态方法
  static create() {
    return new Parent('Static');
  }
}

class Child extends Parent {
  constructor(name, age) {
    // 必须调用super,初始化父类构造函数
    super(name);
    this.age = age;
  }
  // 重写原型方法
  sayHi() {
    // 调用父类方法
    super.sayHi();
    console.log(`I'm ${this.age} years old`);
  }
}

// 测试
const c = new Child('李四', 18);
c.sayHi(); // Hello, 李四 → I'm 18 years old
console.log(Child.create()); // Parent { name: 'Static' }

3.6 原型链深入

题目:手写实现寄生组合式继承(最优继承方式)

答案:寄生组合式继承解决了组合继承(调用两次父类构造函数)的效率问题,是 ES6 之前的最优方案:

/**
 * 寄生组合式继承
 * @param {Function} Child 子类 
 * @param {Function} Parent 父类 
 */
function inheritPrototype(Child, Parent) {
  // 创建父类原型的浅拷贝,避免修改父类原型
  const prototype = Object.create(Parent.prototype);
  // 修正constructor指向
  prototype.constructor = Child;
  // 子类原型指向拷贝的父类原型
  Child.prototype = prototype;
}

// 父类
function Parent(name) {
  this.name = name;
}
Parent.prototype.sayHi = function() {
  console.log(`Hi, ${this.name}`);
};

// 子类
function Child(name, age) {
  // 调用父类构造函数,初始化属性
  Parent.call(this, name);
  this.age = age;
}

// 实现继承
inheritPrototype(Child, Parent);

// 子类添加方法
Child.prototype.sayAge = function() {
  console.log(`Age: ${this.age}`);
};

// 测试
const c = new Child('王五', 20);
c.sayHi(); // Hi, 王五
c.sayAge(); // Age: 20
console.log(c instanceof Child); // true
console.log(c instanceof Parent); // true

3.7 Promise(核心)

题目 1:Promise 的三种状态?状态能否逆转?then 方法的执行机制?

答案

  1. 三种状态

    • pending:初始状态,未完成
    • fulfilled(resolved):成功状态
    • rejected:失败状态
  2. 状态逆转:状态一旦改变,不可逆转pendingfulfilledpendingrejected,不可逆)

  3. then 执行机制

    • then是微任务(异步执行),返回新的 Promise,支持链式调用
    • then回调返回非 Promise 值,会包装为resolved状态的 Promise;若返回 Promise,会等待其状态改变

题目 2:手写实现 Promise(简易版,含 resolve/reject/then)

答案

/**
 * 简易版Promise实现
 */
class MyPromise {
  // 状态
  #state = 'pending';
  #value = undefined;
  #reason = undefined;
  // 回调队列(处理异步resolve/reject)
  #onFulfilledCallbacks = [];
  #onRejectedCallbacks = [];

  constructor(executor) {
    // 绑定this,避免执行时this丢失
    const resolve = (value) => {
      if (this.#state === 'pending') {
        this.#state = 'fulfilled';
        this.#value = value;
        // 执行成功回调
        this.#onFulfilledCallbacks.forEach(cb => cb());
      }
    };

    const reject = (reason) => {
      if (this.#state === 'pending') {
        this.#state = 'rejected';
        this.#reason = reason;
        // 执行失败回调
        this.#onRejectedCallbacks.forEach(cb => cb());
      }
    };

    try {
      // 执行执行器
      executor(resolve, reject);
    } catch (err) {
      // 执行器抛错,触发reject
      reject(err);
    }
  }

  // then方法
  then(onFulfilled, onRejected) {
    // 处理参数默认值(值穿透)
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : (v) => v;
    onRejected = typeof onRejected === 'function' ? onRejected : (r) => { throw r };

    // 返回新的Promise,实现链式调用
    return new MyPromise((resolve, reject) => {
      // 执行成功回调
      const handleFulfilled = () => {
        try {
          const result = onFulfilled(this.#value);
          // 处理返回Promise的情况
          if (result instanceof MyPromise) {
            result.then(resolve, reject);
          } else {
            resolve(result);
          }
        } catch (err) {
          reject(err);
        }
      };

      // 执行失败回调
      const handleRejected = () => {
        try {
          const result = onRejected(this.#reason);
          if (result instanceof MyPromise) {
            result.then(resolve, reject);
          } else {
            resolve(result);
          }
        } catch (err) {
          reject(err);
        }
      };

      // 同步状态时直接执行
      if (this.#state === 'fulfilled') {
        handleFulfilled();
      } else if (this.#state === 'rejected') {
        handleRejected();
      } else {
        // 异步状态时,存入回调队列
        this.#onFulfilledCallbacks.push(handleFulfilled);
        this.#onRejectedCallbacks.push(handleRejected);
      }
    });
  }

  // catch方法(等价于then(null, onRejected))
  catch(onRejected) {
    return this.then(null, onRejected);
  }

  // 静态方法:resolve
  static resolve(value) {
    return new MyPromise(resolve => resolve(value));
  }

  // 静态方法:reject
  static reject(reason) {
    return new MyPromise((resolve, reject) => reject(reason));
  }
}

// 测试
new MyPromise((resolve) => {
  setTimeout(() => resolve('Promise测试'), 1000);
}).then(res => {
  console.log(res); // 1秒后输出 Promise测试
  return 123;
}).then(res => {
  console.log(res); // 输出 123
});

3.8 事件循环(Event Loop)

题目 1:JS 的事件循环机制?宏任务与微任务的区别?执行顺序?

答案:JS 是单线程语言,事件循环是解决异步操作的核心机制,流程如下:

  1. 执行栈:先执行同步代码
  2. 微任务队列:同步代码执行完,清空所有微任务
  3. 宏任务队列:微任务清空后,取一个宏任务执行
  4. 循环往复:微任务→宏任务→微任务→宏任务

宏任务script整体代码、setTimeoutsetIntervalAJAX请求DOM事件UI渲染微任务Promise.then/catch/finallyMutationObserverqueueMicrotaskprocess.nextTick(Node.js)

题目 2:分析以下代码的执行顺序(大厂经典题)

console.log('1');
setTimeout(() => {
  console.log('2');
  Promise.resolve().then(() => {
    console.log('3');
  });
}, 0);
Promise.resolve().then(() => {
  console.log('4');
  setTimeout(() => {
    console.log('5');
  }, 0);
});
console.log('6');

答案:执行顺序:1 → 6 → 4 → 2 → 3 → 5

四、总结

本文覆盖了JS 核心基础、开发 99% 高频踩坑、大厂必考面试题,所有知识点都搭配了可直接运行的代码示例,踩坑点提供了落地解决方案,手写题是面试高频考点。

如果本文对你有所帮助,欢迎点赞、收藏、转发,一起成长!

被低估的 HTML 原生表单元素:dialog、datalist、meter、progress

被低估的 HTML 原生表单元素:dialog、datalist、meter、progress

在追求「无依赖」的今天,这些原生元素值得你重新审视。

引言:为什么关注这些原生元素?

前端开发中,我们习惯了引入第三方库来处理模态框、自动补全、进度条等常见需求。但 HTML 规范早就为我们准备好了这些内置元素——它们:

  • 零依赖:无需 npm install,无体积开销
  • 语义化:机器可读,利于 SEO 和无障碍
  • 功能完善:覆盖 90% 的常见场景
  • 浏览器优化:GPU 加速,性能有保障

本文将深入讲解四个被低估的表单元素,带你解锁原生能力。


一、<dialog>:原生模态框的核心

<dialog> 是 HTML5 新增的对话框元素,支持模态和非模态两种模式,是替代第三方模态库的最佳选择。

1.1 核心 API

const dialog = document.getElementById('myDialog');

// 显示模态框(带遮罩层,阻塞背景交互)
dialog.showModal();

// 显示非模态框(不阻塞背景交互)
dialog.show();

// 关闭对话框
dialog.close();

// 获取关闭按钮的返回值
console.log(dialog.returnValue); // 'confirm' | 'cancel' | ''

// 监听关闭事件
dialog.addEventListener('close', () => {
  console.log('对话框已关闭,返回值:', dialog.returnValue);
});

1.2 基础使用示例

<button id="openBtn">打开对话框</button>

<dialog id="myDialog">
  <h2 id="dialogTitle">确认操作</h2>
  <p>确定要执行这个操作吗?</p>
  <form method="dialog">
    <button type="button" id="cancelBtn">取消</button>
    <button type="submit" value="confirm">确认</button>
  </form>
</dialog>

<script>
  const dialog = document.getElementById('myDialog');
  const openBtn = document.getElementById('openBtn');
  const cancelBtn = document.getElementById('cancelBtn');

  // 打开模态框
  openBtn.addEventListener('click', () => {
    dialog.showModal();
  });

  // 取消按钮
  cancelBtn.addEventListener('click', () => {
    dialog.close();
  });

  // 监听关闭事件
  dialog.addEventListener('close', () => {
    if (dialog.returnValue === 'confirm') {
      console.log('用户点击了确认');
    }
  });
</script>

1.3 样式定制

/* 基础样式 */
dialog {
  border: none;
  border-radius: 12px;
  padding: 24px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
  max-width: 90vw;
  max-height: 80vh;
}

/* 模态框专用伪类 */
dialog:modal {
  /* 只匹配通过 showModal() 打开的对话框 */
}

/* 打开状态伪类 */
dialog:open {
  /* 兼容不支持 :modal 的浏览器 */
}

/* 遮罩层样式 */
dialog::backdrop {
  background: rgba(0, 0, 0, 0.6);
  backdrop-filter: blur(4px);
}

/* 动画效果 */
dialog {
  opacity: 0;
  transform: scale(0.9) translateY(20px);
  transition: opacity 0.3s, transform 0.3s, display 0.3s allow-discrete;
}

dialog:open {
  opacity: 1;
  transform: scale(1) translateY(0);
}

@starting-style {
  dialog:open {
    opacity: 0;
    transform: scale(0.9) translateY(20px);
  }
}

1.4 表单集成

<dialog id="userDialog">
  <form method="dialog" id="userForm">
    <label>
      用户名
      <input type="text" name="username" required>
    </label>
    <label>
      邮箱
      <input type="email" name="email" required>
    </label>
    <menu>
      <button type="reset" value="cancel">取消</button>
      <button type="submit" value="save">保存</button>
    </menu>
  </form>
</dialog>

<script>
  const dialog = document.getElementById('userDialog');
  const form = document.getElementById('userForm');

  // form 提交后自动关闭,返回 value
  dialog.addEventListener('close', () => {
    const formData = new FormData(form);
    console.log(Object.fromEntries(formData));
  });
</script>

1.5 closedby 属性(现代浏览器)

<!-- any: 任意方式关闭 -->
<dialog id="demo1" closedby="any">
  <p>点击外部、按 Esc 或按钮都能关闭</p>
</dialog>

<!-- closerequest: 按 Esc 或按钮关闭 -->
<dialog id="demo2" closedby="closerequest">
  <p>按 Esc 或点击按钮关闭</p>
</dialog>

<!-- none: 只能通过按钮关闭 -->
<dialog id="demo3" closedby="none">
  <p>只能通过按钮关闭</p>
</dialog>

1.6 无障碍支持

<!-- 推荐结构 -->
<dialog aria-labelledby="dialogTitle" aria-modal="true">
  <h2 id="dialogTitle">对话框标题</h2>
  <p>内容...</p>
  <!-- 焦点应自动落到这个按钮 -->
  <button autofocus>关闭</button>
</dialog>

无障碍特性(浏览器自动处理):

  • 自动设置 aria-modal="true"
  • 自动将背景元素设为 inert
  • 自动管理焦点陷阱
  • Esc 键自动关闭模态框

1.7 实际应用场景

场景 1:图片预览灯箱

<dialog id="lightbox">
  <img src="" alt="预览图片" id="previewImg">
  <button onclick="this.closest('dialog').close()">×</button>
</dialog>

<script>
  document.querySelectorAll('.gallery img').forEach(img => {
    img.addEventListener('click', () => {
      document.getElementById('previewImg').src = img.src;
      document.getElementById('lightbox').showModal();
    });
  });
</script>

场景 2:确认删除弹窗

async function confirmDelete(itemName) {
  const dialog = document.getElementById('confirmDialog');
  dialog.querySelector('.item-name').textContent = itemName;
  
  dialog.showModal();
  
  return new Promise(resolve => {
    dialog.addEventListener('close', () => {
      resolve(dialog.returnValue === 'delete');
    }, { once: true });
  });
}

1.8 常见坑点

坑点 说明 解决方案
放在定位容器内 dialog 会被父容器截断 直接放在 <body>
open 属性 vs JS API open 属性无法触发 close 事件 始终用 .close() 方法
Safari 早期版本 close() 事件支持不完整 open = false 做降级
动画闪烁 首次打开无过渡效果 使用 @starting-style

1.9 兼容性

浏览器 支持版本
Chrome 33+
Edge 79+
Firefox 98+
Safari 15.4+ (完整支持 16.4+)
IE 不支持
// 特性检测
const supportDialog = typeof HTMLDialogElement !== 'undefined';

二、<datalist>:输入建议的原生方案

<datalist> 为输入框提供可选值列表,兼容所有现代浏览器,是实现自动补全的零成本方案。

2.1 基础用法

<!-- 定义数据列表 -->
<datalist id="techStack">
  <option value="JavaScript">
  <option value="TypeScript">
  <option value="Python">
  <option value="Rust">
  <option value="Go">
</datalist>

<!-- 绑定到输入框 -->
<input type="text" list="techStack" placeholder="选择或输入技术栈">

2.2 支持的 input 类型

<!-- 文本类型 -->
<input type="text" list="suggestions">

<!-- 搜索框 -->
<input type="search" list="searchHistory">

<!-- URL 输入 -->
<input type="url" list="bookmarks">

<!-- 电话号码 -->
<input type="tel" list="contacts">

<!-- 邮箱 -->
<input type="email" list="recentEmails">

<!-- 数字 + datalist (显示刻度标记) -->
<input type="range" min="0" max="100" list="tickmarks">

<!-- 颜色选择器 -->
<input type="color" list="presetColors">

2.3 高级用法:动态数据

// 动态填充 datalist
const languages = ['JavaScript', 'TypeScript', 'Python', 'Rust', 'Go', 'Java'];
const datalist = document.getElementById('languageList');

languages.forEach(lang => {
  const option = document.createElement('option');
  option.value = lang;
  datalist.appendChild(option);
});

// 或清空后重新填充
function updateDatalist(options) {
  datalist.innerHTML = '';
  options.forEach(opt => {
    const option = document.createElement('option');
    option.value = opt.value || opt; // 支持 {value, label} 或直接字符串
    option.label = opt.label || opt.value || opt;
    datalist.appendChild(option);
  });
}

2.4 带分组的数据列表(降级方案)

<!-- 不支持 datalist 的浏览器:显示为下拉选择 -->
<input type="text" list="fallbackList" placeholder="选择语言">
<datalist id="fallbackList">
  <label>或从列表选择:</label>
  <select>
    <option value="JavaScript">JavaScript</option>
    <option value="Python">Python</option>
    <option value="Go">Go</option>
  </select>
</datalist>

2.5 实际应用场景

场景 1:搜索历史自动补全

<datalist id="searchHistory"></datalist>
<input type="search" list="searchHistory" placeholder="搜索...">

<script>
  const input = document.querySelector('input[type="search"]');
  const datalist = document.getElementById('searchHistory');

  input.addEventListener('change', () => {
    // 添加到历史
    const option = document.createElement('option');
    option.value = input.value;
    datalist.appendChild(option);
    
    // 限制历史数量
    while (datalist.children.length > 10) {
      datalist.removeChild(datalist.firstChild);
    }
  });
</script>

场景 2:URL 快速输入

<datalist id="urlList">
  <option value="https://github.com/">
  <option value="https://stackoverflow.com/">
  <option value="https://developer.mozilla.org/">
</datalist>

<input type="url" list="urlList" required pattern="https://.*">

2.6 与 <select> 的区别

特性 <datalist> <select>
用户可输入任意值 ✅ 可以 ❌ 不能
候选值是否必须 ❌ 否(可自由输入) ✅ 是
样式定制 ❌ 受限 ✅ 可完全定制
键盘交互 更好(支持模糊匹配) 较差
适用场景 建议、搜索、补全 固定选项选择

2.7 常见坑点

// 坑 1:option 必须有 value 属性
// ❌ 错误
<option>只显示文字</option>

// ✅ 正确
<option value="somevalue">只显示文字</option>

// 坑 2:实时过滤取决于浏览器
// 部分浏览器会根据输入实时过滤,部分只显示匹配项

// 坑 3:Safari 早期版本支持不完整
// 建议配合 input 事件做降级
input.addEventListener('input', (e) => {
  if (!window.HTMLDataListElement) {
    // 降级:手动实现过滤
  }
});

2.8 兼容性

浏览器 支持版本
Chrome 20+
Firefox 4+
Safari 12.1+
Edge 12+
IE 10+

三、<meter>:标量值仪表盘

<meter> 用于显示已知范围内的标量值(如磁盘用量、评分、电池电量),与进度条有本质区别。

3.1 核心属性

<!-- 基本用法 -->
<meter value="70" min="0" max="100">70%</meter>

<!-- 颜色区间示意 -->
<meter value="0.3" low="0.25" high="0.75" optimum="0.5" min="0" max="1">
  当前 30%
</meter>
属性 说明 默认值
value 当前值 0
min 最小值 0
max 最大值 1
low 低值阈值 等于 min
high 高值阈值 等于 max
optimum 最优值 介于 low 和 high 之间时,该区域显示绿色

3.2 颜色区间逻辑

<!-- 
  假设:min=0, max=100, low=30, high=70, optimum=50
  
  值 < 30  → 低值区(黄色/红色)
  30-50   → 最优区(绿色)optimum 在此
  50-70   → 正常区(黄色)
  值 > 70 → 高值区(黄色/红色)
-->
<meter value="20" min="0" max="100" low="30" high="70" optimum="50">
  偏低
</meter>
<meter value="50" min="0" max="100" low="30" high="70" optimum="50">
  正常
</meter>
<meter value="85" min="0" max="100" low="30" high="70" optimum="50">
  偏高
</meter>

3.3 基础示例

<!-- 磁盘使用量 -->
<div>
  <label>磁盘使用量</label>
  <meter value="250" min="0" max="500" low="350" high="450" optimum="400">
    250GB / 500GB
  </meter>
  <span>250 GB / 500 GB (50%)</span>
</div>

<!-- 评分显示 -->
<div>
  <label>用户评分</label>
  <meter value="4.2" min="0" max="5" low="2" high="4" optimum="5">
    4.2 / 5
  </meter>
  <span>4.2 / 5.0</span>
</div>

<!-- 电池电量 -->
<div>
  <label>电池电量</label>
  <meter value="0.3" low="0.2" high="0.8" optimum="1" min="0" max="1">
    30%
  </meter>
  <span>低电量警告</span>
</div>

3.4 样式定制(有限支持)

/* 部分浏览器支持自定义样式 */

/* Firefox/Chrome */
meter::-webkit-meter-bar {
  height: 12px;
  border-radius: 6px;
  background: #e0e0e0;
}

meter::-webkit-meter-optimum-value {
  background: linear-gradient(to right, #4caf50, #8bc34a);
}

/* Firefox 专用 */
meter::-moz-meter-bar {
  background: linear-gradient(to bottom, #4caf50, #8bc34a);
}

3.5 实际应用场景

场景 1:库存预警系统

<div class="inventory">
  <span>商品 A 库存</span>
  <meter value="15" min="0" max="100" 
         low="30" high="70" optimum="50"
         title="库存: 15件">
  </meter>
  <span class="warning">库存不足</span>
</div>

<style>
meter {
  width: 200px;
  height: 20px;
}
.warning { color: #f44336; }
</style>

场景 2:文本相似度对比

<div class="comparison">
  <p>相似度</p>
  <meter value="0.87" min="0" max="1" 
         low="0.5" high="0.8" optimum="0.95">
  </meter>
  <span>87% 匹配</span>
</div>

3.6 与 <progress> 的核心区别

特性 <meter> <progress>
语义 已知范围的静态测量值 任务完成的进度
值范围 任意 min/max 始终从 0 开始
颜色区间 支持 low/high/optimum 不支持
indeterminate 不支持 支持
典型场景 温度、评分、库存 文件上传、加载进度

3.7 常见坑点

// 坑 1:value 必须介于 min 和 max 之间
// ❌ 错误:value 不在范围内
<meter value="150" min="0" max="100">

// ✅ 正确
<meter value="80" min="0" max="100">

// 坑 2:样式定制能力有限
// 建议:用 CSS 变量或自定义元素包装

// 坑 3:Safari 对 low/high/optimum 颜色支持不一致
// 建议依赖浏览器默认颜色,或使用 div + CSS 模拟

3.8 兼容性

浏览器 支持版本
Chrome 8+
Firefox 16+
Safari 6+
Edge 12+
IE 不支持

四、<progress>:任务进度条

<progress> 用于显示任务完成进度,是文件上传、加载状态的标准实现。

4.1 核心属性

<!-- 有明确值的进度 -->
<progress value="30" max="100">30%</progress>

<!-- 最大值默认 1 -->
<progress value="0.6"></progress>

<!-- 不确定状态(无 value 属性) -->
<progress max="100"></progress>
属性 说明 默认值
value 当前进度 无(indeterminate)
max 总工作量 1

4.2 确定 vs 不确定状态

<!-- 确定状态:显示具体进度 -->
<progress value="45" max="100">45%</progress>

<!-- 不确定状态:动画效果,表示进行中但时长未知 -->
<progress max="100"></progress>

<script>
  const progress = document.querySelector('progress');
  
  // 变为不确定状态
  progress.removeAttribute('value');
  
  // 恢复确定状态
  progress.value = 50;
</script>

4.3 基础示例

<!-- 文件上传进度 -->
<div class="upload-progress">
  <label for="fileProgress">上传进度</label>
  <progress id="fileProgress" value="0" max="100"></progress>
  <span class="percentage">0%</span>
</div>

<script>
  const progress = document.getElementById('fileProgress');
  const percentage = document.querySelector('.percentage');
  
  // 模拟上传
  function updateProgress(percent) {
    progress.value = percent;
    percentage.textContent = percent + '%';
  }
  
  // 设为不确定状态(上传进行中,时长未知)
  progress.removeAttribute('value');
</script>

4.4 动态更新示例

// 文件上传模拟
async function simulateUpload(file) {
  const progress = document.getElementById('uploadProgress');
  const status = document.getElementById('uploadStatus');
  
  // 阶段 1:准备(不确定状态)
  progress.removeAttribute('value');
  status.textContent = '正在准备上传...';
  
  await delay(1000);
  
  // 阶段 2:上传中(确定状态)
  const chunkSize = 1024 * 1024; // 1MB
  const totalChunks = Math.ceil(file.size / chunkSize);
  
  for (let i = 0; i <= totalChunks; i++) {
    const percent = Math.round((i / totalChunks) * 100);
    progress.value = percent;
    status.textContent = `上传中... ${percent}%`;
    await delay(100);
  }
  
  // 阶段 3:完成
  progress.value = 100;
  status.textContent = '上传完成!';
}

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

4.5 样式定制

/* 通用样式(现代浏览器) */
progress {
  width: 300px;
  height: 20px;
  border-radius: 10px;
  overflow: hidden;
}

/* Chrome/Safari */
progress::-webkit-progress-bar {
  background: #e0e0e0;
  border-radius: 10px;
}

progress::-webkit-progress-value {
  background: linear-gradient(90deg, #4caf50, #8bc34a);
  border-radius: 10px;
}

/* Firefox */
progress::-moz-progress-bar {
  background: linear-gradient(90deg, #4caf50, #8bc34a);
  border-radius: 10px;
}

/* 不确定状态动画 */
progress:indeterminate {
  animation: indeterminate 1.5s infinite linear;
}

@keyframes indeterminate {
  0% { transform: translateX(-100%); }
  100% { transform: translateX(100%); }
}

4.6 实际应用场景

场景 1:多文件队列上传

<div class="upload-queue">
  <div class="file-item">
    <span>document.pdf</span>
    <progress value="75" max="100"></progress>
    <span>75%</span>
  </div>
  <div class="file-item">
    <span>image.png</span>
    <progress></progress>  <!-- 等待中 -->
    <span>等待中</span>
  </div>
</div>

场景 2:页面加载进度

// 预加载资源
const resources = ['/api/data', '/api/config', '/assets/bundle.js'];
const progress = document.getElementById('pageProgress');

let loaded = 0;
for (const url of resources) {
  await fetch(url);
  loaded++;
  progress.value = (loaded / resources.length) * 100;
}

// 加载完成
progress.removeAttribute('value'); // 变为不确定状态
document.body.classList.add('loaded');

4.7 常见坑点

// 坑 1:设为不确定再恢复需用 removeAttribute
// ❌ 错误
progress.value = null;

// ✅ 正确
progress.removeAttribute('value');

// 坑 2:value 超出 max 会被截断
// ❌ 错误
progress.value = 150; // max = 100

// 坑 3:默认 max=1,所以小数进度直接赋值
progress.value = 0.75; // 等同于 75%

// 坑 4::indeterminate 伪类
// 只能匹配不确定状态,无法强制进入该状态

4.8 兼容性

浏览器 支持版本
Chrome 所有版本
Firefox 所有版本
Safari 所有版本
Edge 所有版本
IE 10+

五、实战对比:原生 vs 第三方库

场景 原生方案 第三方库 建议
简单模态框 <dialog> bootstrap modal ✅ 推荐原生
复杂模态(拖拽、嵌套) 需大量自定义 ✅ 使用库 视情况
输入自动补全 <datalist> Select2/Awesomeplete ✅ 推荐原生
评分组件 <meter> + CSS StarRating.js 视样式需求
文件上传进度 <progress> Uppy/Dropzone 视功能需求
复杂进度可视化 div + CSS NProgress 视复杂度

何时用原生?

  • ✅ 需求简单,不追求炫酷效果
  • ✅ 需要更好的无障碍支持
  • ✅ 追求极小 bundle 体积
  • ✅ 项目不依赖任何 UI 框架

何时用库?

  • ❌ 需要复杂交互(拖拽、嵌套层级)
  • ❌ 需要统一的设计语言
  • ❌ 项目已有成熟的 UI 组件库
  • ❌ 需要 IE 等旧浏览器支持

六、兼容性总结与降级方案

兼容性速查表

元素 Chrome Firefox Safari Edge IE
<dialog> 33+ 98+ 16.4+ 79+
<datalist> 20+ 4+ 12.1+ 12+ 10+
<meter> 8+ 16+ 6+ 12+
<progress> 所有 所有 所有 所有 10+

降级策略

// dialog 降级
if (typeof HTMLDialogElement !== 'undefined') {
  dialog.showModal();
} else {
  // 使用自定义实现或 modal 库
}

// datalist 降级
if ('list' in document.createElement('input')) {
  // 支持 datalist
} else {
  // 使用 select 替代
}

// meter 降级
if (typeof HTMLElement !== 'undefined' && 'range' in document.createElement('meter')) {
  // 支持 meter
} else {
  // 使用 div + CSS 模拟
}

// progress 降级
// 几乎所有浏览器都支持,可直接使用

特性检测推荐

// 检测 dialog 完整支持(包括 close 事件)
const dialogSupported = 
  typeof HTMLDialogElement !== 'undefined' && 
  'close' in document.createElement('dialog');

// 检测 datalist
const datalistSupported = 'list' in document.createElement('input');

// 检测 meter
const meterSupported = 'valueAsNumber' in document.createElement('meter');

结语

这四个表单元素覆盖了现代 Web 开发中的高频场景:模态框自动补全标量仪表任务进度。它们虽然不像 <div> 那样耳熟能详,但熟练运用能显著减少你对第三方库的依赖,让代码更简洁、更具语义、更易于维护。

下次遇到这些场景时,不妨先问问自己:原生方案够用吗?


参考资料:MDN dialog | MDN datalist | MDN meter | MDN progress

从单 Chat 到多 Agent 系统:AI 应用的架构演进路线

从单 Chat 到多 Agent 系统:AI 应用的架构演进路线

本文是【高级前端的 AI 架构升级之路】系列第 06 篇。 上一篇:AI Streaming 架构:从浏览器到服务端的全链路流式设计 | 下一篇:AI 应用的安全架构:Prompt 注入、数据泄露、权限边界


引言

前五篇搞定了 AI 应用的基础架构——网关、状态管理、流式链路。但这些都建立在一个假设上:一个 AI 做一件事

现实场景远比这复杂。一个"智能项目助手"可能需要:

  • 代码 Agent 负责生成和审查代码
  • 文档 Agent 负责检索和更新知识库
  • 运维 Agent 负责查询部署状态和日志
  • 协调 Agent 决定什么时候调哪个 Agent

这就是 Multi-Agent 系统——多个 AI 各司其职、协作完成任务。对前端架构来说,这意味着一系列全新的挑战。


AI 应用的五层演进

L0: 单次调用     → callAI(prompt) → 返回结果
L1: 多轮对话     → 维护 messages 数组,上下文管理
L2: RAG 增强     → 检索相关文档 → 注入上下文 → 调 AI
L3: Tool Use     → AI 可以调用工具(Function Calling)
L4: Multi-Agent  → 多个 AI 各有角色,协作完成复杂任务

每一层对前端的架构影响

层级 后端变化 前端架构影响
L0 无状态 简单请求-响应
L1 会话管理 对话历史 UI、上下文指示器
L2 向量检索 + 上下文拼接 引用来源展示、知识库关联
L3 工具调用循环 工具调用过程可视化、确认弹窗
L4 Agent 编排、并行/串行调度 多 Agent 状态展示、思考过程、冲突处理

L4 的前端复杂度是指数级增长——因为你不再展示"一个 AI 在说话",而是展示"一群 AI 在协作"。


Agent 编排模式

模式一:串行(Pipeline)

用户输入 → Agent A → Agent B → Agent C → 最终输出

示例:智能写作
用户输入主题 → 大纲Agent生成大纲 → 写作Agent撰写正文 → 审校Agent校对润色

前端:展示为步骤条 / 进度条,每个 Agent 完成一步点亮一个节点。

interface PipelineStep {
  agent: string
  status: 'pending' | 'running' | 'done' | 'error'
  input?: string
  output?: string
  startTime?: number
  endTime?: number
}

// SSE 事件类型
type PipelineEvent =
  | { type: 'step_start'; agent: string }
  | { type: 'step_stream'; agent: string; content: string }
  | { type: 'step_done'; agent: string; output: string }
  | { type: 'pipeline_done'; finalOutput: string }

模式二:并行(Fan-out / Fan-in)

                ┌→ Agent A(搜索技术文档)→┐
用户输入 → 分发 ├→ Agent B(搜索 Stack Overflow)→├→ 汇总 → 最终输出
                └→ Agent C(搜索 GitHub Issues)→┘

示例:智能搜索
用户问一个技术问题 → 同时搜三个来源 → 汇总最相关的答案

前端:多列并排展示,每列一个 Agent 的实时输出,最后合并。

interface ParallelAgents {
  agents: {
    [agentId: string]: {
      name: string
      status: 'running' | 'done'
      streamContent: string
    }
  }
  mergedResult?: string
}

模式三:路由(Router)

                 ┌→ 代码Agent(代码相关)
用户输入 → 路由Agent ├→ 文档Agent(文档相关)
                 └→ 通用Agent(其他)

示例:全能助手
用户输入先经过分类,路由到最合适的专家Agent

前端:展示路由决策——"AI 判断这是一个代码问题,已转给代码专家"。

模式四:监督者(Supervisor)

              ┌→ 研究Agent ←┐
Supervisor ←──┼→ 写作Agent ←┤ ← 协调、分配、审核
              └→ 审校Agent ←┘

示例:报告生成
Supervisor 把任务拆分,分配给不同Agent,审核结果,不满意就退回重做

前端:最复杂——展示 Supervisor 的决策树、各 Agent 的任务分配、重试过程。


前端架构:多 Agent 状态管理

状态模型

interface MultiAgentState {
  sessionId: string
  mode: 'pipeline' | 'parallel' | 'router' | 'supervisor'

  // 全局状态
  status: 'idle' | 'running' | 'done' | 'error'
  userInput: string
  finalOutput?: string

  // 各 Agent 状态
  agents: Record<string, AgentState>

  // Agent 间通信记录
  messages: AgentMessage[]

  // 执行轨迹(用于可视化)
  trace: TraceEvent[]
}

interface AgentState {
  id: string
  name: string
  role: string
  status: 'idle' | 'thinking' | 'tool_calling' | 'streaming' | 'done' | 'error'
  currentTask?: string
  streamContent: string
  toolCalls: ToolCallRecord[]
  output?: string
  tokenUsage: { input: number; output: number }
}

interface AgentMessage {
  from: string  // agentId 或 'user' 或 'supervisor'
  to: string
  content: string
  timestamp: number
}

interface TraceEvent {
  type: 'agent_start' | 'agent_end' | 'tool_call' | 'handoff' | 'decision'
  agentId: string
  detail: any
  timestamp: number
}

SSE 协议设计

后端通过 SSE 推送多 Agent 的事件流:

// 后端推送的事件类型
type ServerEvent =
  // Agent 生命周期
  | { type: 'agent_start'; agentId: string; task: string }
  | { type: 'agent_thinking'; agentId: string; thought: string }
  | { type: 'agent_stream'; agentId: string; content: string }
  | { type: 'agent_done'; agentId: string; output: string }
  | { type: 'agent_error'; agentId: string; error: string }

  // 工具调用
  | { type: 'tool_call'; agentId: string; tool: string; args: any }
  | { type: 'tool_result'; agentId: string; tool: string; result: any }

  // Agent 间协作
  | { type: 'handoff'; from: string; to: string; message: string }
  | { type: 'supervisor_decision'; decision: string; assignments: any }

  // 全局
  | { type: 'final_output'; content: string }
  | { type: 'done'; tokenUsage: any }

前端统一消费事件流,更新对应 Agent 的状态:

function handleServerEvent(event: ServerEvent, state: MultiAgentState) {
  switch (event.type) {
    case 'agent_start':
      state.agents[event.agentId].status = 'thinking'
      state.agents[event.agentId].currentTask = event.task
      break

    case 'agent_stream':
      state.agents[event.agentId].status = 'streaming'
      state.agents[event.agentId].streamContent += event.content
      break

    case 'agent_done':
      state.agents[event.agentId].status = 'done'
      state.agents[event.agentId].output = event.output
      break

    case 'handoff':
      state.messages.push({
        from: event.from,
        to: event.to,
        content: event.message,
        timestamp: Date.now(),
      })
      break

    case 'final_output':
      state.finalOutput = event.content
      state.status = 'done'
      break
  }

  state.trace.push({
    type: event.type as any,
    agentId: (event as any).agentId || 'system',
    detail: event,
    timestamp: Date.now(),
  })
}

Thinking UI:展示 Agent 的思考过程

Multi-Agent 系统最重要的 UX 设计——让用户看到 AI 在干什么

设计原则

  1. 透明度——用户知道哪些 Agent 在工作、各自在做什么
  2. 进度感——即使 AI 还没出最终结果,也能看到中间进展
  3. 可控性——用户可以中断、跳过某个 Agent、手动干预

UI 方案

┌────────────────────────────────────────────────┐
│  用户: "帮我重构这个模块并更新文档"                    │
├────────────────────────────────────────────────┤
│                                                │
│  🤖 Supervisor                                  │
│  └─ 已拆分为 2 个子任务                            │
│                                                │
│  ┌─ 🔧 代码Agent ──────┐  ┌─ 📝 文档Agent ────┐  │
│  │ ✅ 分析现有代码结构     │  │ ⏳ 等待代码Agent...  │  │
│  │ ✅ 生成重构方案        │  │                    │  │
│  │ 🔄 正在重写代码...     │  │                    │  │
│  │ ▌                    │  │                    │  │
│  └─────────────────────┘  └─────────────────── ┘  │
│                                                │
│  📊 Token 消耗: 1,234 input / 567 output          │
│  ⏱ 已用时: 12s                                    │
│                                                │
│  [停止] [跳过当前Agent]                             │
└────────────────────────────────────────────────┘

关键组件

// AgentCard 组件
interface AgentCardProps {
  agent: AgentState
  onSkip?: () => void
  onRetry?: () => void
}

// ThinkingIndicator - 展示 Agent 的思考步骤
interface ThinkingStep {
  label: string
  status: 'done' | 'running' | 'pending'
  detail?: string
}

// TraceTimeline - 执行轨迹时间线
interface TraceTimelineProps {
  events: TraceEvent[]
  agents: Record<string, AgentState>
}

冲突处理

当多个 Agent 同时操作时可能产生冲突。

场景

  • 代码 Agent 修改了 utils.ts,同时文档 Agent 也在引用 utils.ts 的旧版本
  • 两个 Agent 对同一问题给出了矛盾的建议

策略

interface ConflictResolution {
  strategy: 'supervisor_decides' | 'user_decides' | 'last_write_wins' | 'merge'
}

// Supervisor 决策
async function resolveConflict(conflicts: Conflict[]): Promise<Resolution> {
  // 方案1:交给 Supervisor Agent 仲裁
  const resolution = await supervisorAgent.resolve(conflicts)

  // 方案2:展示给用户选择
  if (resolution.confidence < 0.8) {
    return { strategy: 'user_decides', options: resolution.options }
  }

  return resolution
}

前端:当检测到冲突时,弹出对比视图让用户选择,类似 Git merge conflict 的 UI。


性能考量

渲染优化

多个 Agent 同时流式输出 = 高频 DOM 更新。

// 方案:合并更新 + RAF 节流
class MultiAgentRenderer {
  private pendingUpdates = new Map<string, string>()
  private rafId: number | null = null

  queueUpdate(agentId: string, content: string) {
    const existing = this.pendingUpdates.get(agentId) || ''
    this.pendingUpdates.set(agentId, existing + content)

    if (!this.rafId) {
      this.rafId = requestAnimationFrame(() => this.flush())
    }
  }

  private flush() {
    this.pendingUpdates.forEach((content, agentId) => {
      // 批量更新 DOM
      updateAgentContent(agentId, content)
    })
    this.pendingUpdates.clear()
    this.rafId = null
  }
}

WebSocket vs SSE

多 Agent 场景更适合 WebSocket——因为需要双向通信(用户可能要中途干预某个 Agent)。

const ws = new WebSocket('/ws/multi-agent')

ws.onmessage = (event) => {
  const serverEvent = JSON.parse(event.data) as ServerEvent
  handleServerEvent(serverEvent, state)
}

// 用户干预
function skipAgent(agentId: string) {
  ws.send(JSON.stringify({ type: 'skip_agent', agentId }))
}

function retryAgent(agentId: string) {
  ws.send(JSON.stringify({ type: 'retry_agent', agentId }))
}

实战:多 Agent 协作的任务执行界面

后端编排(Python 伪代码)

async def multi_agent_task(user_input: str, websocket: WebSocket):
    supervisor = SupervisorAgent()
    agents = {
        "coder": CoderAgent(),
        "reviewer": ReviewerAgent(),
        "documenter": DocumenterAgent(),
    }

    # Supervisor 拆分任务
    plan = await supervisor.plan(user_input)
    await websocket.send_json({
        "type": "supervisor_decision",
        "decision": plan.summary,
        "assignments": plan.assignments,
    })

    # 按依赖关系执行
    for step in plan.execution_order:
        if step.parallel:
            # 并行执行
            tasks = [
                run_agent(agents[a], step.tasks[a], websocket)
                for a in step.agent_ids
            ]
            await asyncio.gather(*tasks)
        else:
            # 串行执行
            await run_agent(agents[step.agent_id], step.task, websocket)

    # 汇总最终结果
    final = await supervisor.summarize(
        {a: agents[a].output for a in agents}
    )
    await websocket.send_json({"type": "final_output", "content": final})

前端核心布局

┌──────────────────────────────────────────┐
│  Multi-Agent Task View                    │
├──────────┬───────────────────────────────┤
│          │                               │
│  Agent   │   主内容区                      │
│  列表     │   (当前选中 Agent 的详细输出)    │
│          │                               │
│  🟢 Coder │                               │
│  🟡 Reviewer│                              │
│  ⚪ Docs   │                               │
│          │                               │
├──────────┴───────────────────────────────┤
│  执行轨迹时间线                              │
│  ●──●──●──●──○──○                         │
└──────────────────────────────────────────┘

总结

  1. AI 应用五层演进:L0 单次调用 → L1 多轮 → L2 RAG → L3 Tool Use → L4 Multi-Agent,每层前端复杂度递增。
  2. 四种编排模式:串行(Pipeline)、并行(Fan-out/Fan-in)、路由(Router)、监督者(Supervisor)。
  3. 前端核心挑战:多 Agent 状态管理、SSE/WebSocket 协议设计、Thinking UI、冲突处理。
  4. Thinking UI 是关键——用户需要看到每个 Agent 在做什么,才有信任感和控制感。
  5. 性能:多路流式输出用 RAF 合并渲染,WebSocket 支持双向干预。

下一篇预告07 | AI 应用的安全架构:Prompt 注入、数据泄露、权限边界


架构讨论:你在做 Multi-Agent 系统时,选的是哪种编排模式?前端怎么展示多 Agent 协作过程?评论区聊聊。

Node.js 子进程 fork 完全指南:从入门到踩坑全记录

前言

在 Node.js 开发中,我们总会遇到两类棘手问题:

  • CPU 密集型任务(如大数运算、图片处理)会阻塞事件循环,让整个应用卡死。
  • 单进程无法充分利用多核 CPU,造成服务器资源浪费。

child_process.fork() 就是为解决这些问题而生。它通过创建独立的 Node.js 子进程,既能“卸载”重计算任务,又能水平扩展充分利用多核。但 fork 用不好也会带来各种坑——内存暴涨、进程僵尸、模块找不到……

本文将用最直白的语言和大量示例,带你彻底掌握 fork 的正确姿势。

一、fork 是什么?和其他方法的区别

child_process 模块提供了四种创建子进程的方法,它们的定位完全不同:

方法 用途 通信方式 典型场景
fork 创建 Node.js 子进程 执行 JS 文件 内置 IPC 通道,send() / on('message') CPU 密集任务、微服务拆分
spawn 执行任意系统命令,以流形式返回数据 stdout / stderr 流 处理海量日志、音视频转换
exec 执行任意系统命令,缓冲后一次性返回 回调函数的 stdout / stderr 字符串 简单系统命令(注意 200KB 缓冲上限)
execFile 直接执行可执行文件(不通过 shell) 同 exec,但更安全 运行编译后的二进制文件

一句话总结:fork 是 spawn 的特化版本,专为 Node.js 进程间通信而生。

二、基础使用:父子进程如何对话

父进程代码 (parent.js)

javascript

const { fork } = require('child_process');
const path = require('path');

// 创建子进程
const child = fork(path.join(__dirname, 'child.js'), ['hello', 'world'], {
  env: { NODE_ENV: 'production' },
  silent: true  // 让子进程独立输出,避免干扰父进程日志
});

// 监听子进程发来的消息
child.on('message', (msg) => {
  console.log('父进程收到:', msg);
});

// 发送消息给子进程(JSON 对象)
child.send({ command: 'start', data: [1, 2, 3] });

// 必须监听错误事件
child.on('error', (err) => {
  console.error('子进程启动失败:', err);
});

// 监听退出事件,防止僵尸进程
child.on('exit', (code, signal) => {
  console.log(`子进程退出,退出码: ${code}, 信号: ${signal}`);
});

子进程代码 (child.js)

javascript

// 接收父进程消息
process.on('message', (msg) => {
  console.log('子进程收到:', msg);
  
  // 模拟耗时计算
  let sum = 0;
  for (let i = 0; i < 1e9; i++) {
    sum += i;
  }
  
  // 返回计算结果
  process.send({ result: sum });
});

// 获取启动参数(后面会详解)
console.log('启动参数:', process.argv.slice(2));

三、深入理解 args 参数和 process.argv

3.1 父进程如何传递启动参数?

fork 的第二个参数 args 是数组,用于向子进程传递启动时参数

javascript

fork('./child.js', ['compute', 'task-42', '3']);

3.2 子进程如何接收参数?

子进程中通过 process.argv 获取,但要注意前两个元素是固定的:

javascript

// 在 child.js 中打印 process.argv
console.log(process.argv);
// 输出类似:
// [
//   '/usr/local/bin/node',           // 索引 0: Node 可执行文件路径
//   '/Users/me/project/child.js',    // 索引 1: 脚本自身路径
//   'compute',                       // 索引 2: 第一个自定义参数
//   'task-42',                       // 索引 3
//   '3'                              // 索引 4
// ]

// 因此必须 slice(2) 才能拿到真正的业务参数
const args = process.argv.slice(2);
console.log(args);  // ['compute', 'task-42', '3']

// 使用解构赋值快速获取
const [taskType, taskId, retries] = args;
console.log(`任务类型: ${taskType}, ID: ${taskId}, 重试次数: ${retries}`);

3.3 为什么是 slice(2)?

这是 Node.js 遵循 Unix 命令行约定的结果:

  • argv[0] 永远是解释器路径
  • argv[1] 永远是脚本路径
  • 真正的参数从 argv[2] 开始

无论你是直接运行 node script.js arg1 arg2 还是通过 fork,子进程内获取参数的方式完全一致,这保证了代码的可复用性。

3.4 args 与 send() 的本质区别

方式 时机 用途 子进程获取方式
args 参数 进程启动瞬间 传递初始配置、模式选择 process.argv.slice(2)
process.send() 进程运行期间 传递动态数据、任务指令 process.on('message')

最佳实践:静态配置用 args,动态任务用 send()

3.5 args 和 execArgv 的区别(重要!)

fork 的第三个参数 options 中有一个容易混淆的字段 execArgv

javascript

fork('./child.js', ['--port=3000'], {
  execArgv: ['--inspect=9229', '--max-old-space-size=512']
});
  • args:传给脚本的业务参数,子进程通过 process.argv 获取。
  • execArgv:传给 Node.js 运行时的执行参数,子进程通过 process.execArgv 获取。

四、实战示例:参数化计算子进程

下面是一个完整的参数化子进程示例,父进程根据任务类型传递不同参数:

父进程 (main.js)

javascript

const { fork } = require('child_process');

const jobs = [
  { type: 'fib', input: 40 },
  { type: 'prime', input: 1000000 }
];

jobs.forEach(job => {
  const child = fork('./compute.js', [job.type, job.input]);
  
  child.on('message', result => {
    console.log(`${job.type}(${job.input}) 结果:`, result);
  });
});

子进程 (compute.js)

javascript

const [type, inputStr] = process.argv.slice(2);
const input = parseInt(inputStr, 10);

let result;
if (type === 'fib') {
  result = fibonacci(input);
} else if (type === 'prime') {
  result = findPrimes(input);
}

process.send({ type, input, result });

function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

function findPrimes(limit) {
  // 简单的质数查找逻辑
  const primes = [];
  for (let i = 2; i <= limit; i++) {
    let isPrime = true;
    for (let j = 2; j <= Math.sqrt(i); j++) {
      if (i % j === 0) { isPrime = false; break; }
    }
    if (isPrime) primes.push(i);
  }
  return primes.length;
}

五、高级特性一览

5.1 传递 Socket / 服务器句柄

fork 支持传递 TCP 服务器句柄,实现多进程监听同一端口(类似 cluster 模块原理):

javascript

// 父进程
const server = require('net').createServer();
server.listen(3000);

const child = fork('./worker.js');
child.send('server', server);  // 第二个参数是句柄

// 子进程
process.on('message', (msg, server) => {
  if (msg === 'server') {
    server.on('connection', (socket) => {
      socket.end('由子进程处理');
    });
  }
});

5.2 环境变量传递

通过 options.env 可以给子进程指定独立的环境变量:

javascript

fork('./child.js', [], {
  env: { ...process.env, CUSTOM_VAR: 'child-only' }
});

5.3 独立日志输出

当子进程输出大量日志时,建议设置 silent: true 并手动处理 stdio

javascript

const fs = require('fs');
const out = fs.openSync('./child.log', 'a');
const err = fs.openSync('./child-err.log', 'a');

const child = fork('./child.js', [], {
  silent: true,
  stdio: ['pipe', out, err, 'ipc']  // 将 stdout 和 stderr 重定向到文件
});

六、常见陷阱与解决方案

6.1 资源开销巨大

每个 fork 子进程都是一个独立的 V8 实例,内存占用约 30MB 起步。

解决方案

  • 使用进程池限制并发数(通常设为 CPU 核心数)
  • 任务完成后及时 child.kill()

javascript

const os = require('os');
const maxWorkers = os.cpus().length;
let activeWorkers = 0;

function createWorker(task) {
  if (activeWorkers >= maxWorkers) {
    // 加入队列等待
    return;
  }
  activeWorkers++;
  const child = fork('./worker.js');
  child.on('exit', () => activeWorkers--);
  // ...
}

6.2 "MODULE_NOT_FOUND" 错误

fork 找不到脚本文件,通常是因为相对路径问题。

解决方案:始终使用 path.join(__dirname, 'relative/path')

javascript

const path = require('path');
fork(path.join(__dirname, 'child.js'));  // ✅ 绝对路径
fork('./child.js');                       // ❌ 相对路径可能出错

6.3 TypeScript / ESM 项目中的坑

直接 fork .ts 文件或使用 ts-node 时,可能因 execArgv 污染导致无限循环启动。

解决方案

  • 使用 tsx 或 ts-node 的 --transpile-only 模式
  • 在 fork 时过滤掉 TypeScript 相关的 execArgv

javascript

const execArgv = process.execArgv.filter(arg => !arg.includes('ts-node'));
fork('./child.ts', [], { execArgv });

6.4 僵尸进程问题

未监听 exit 事件或未正确清理子进程,会导致系统进程表泄漏。

解决方案:务必监听 exit 事件并做清理,必要时实现守护逻辑。

javascript

child.on('exit', (code, signal) => {
  if (code !== 0) {
    console.error('子进程异常退出,尝试重启...');
    setTimeout(() => fork('./child.js'), 1000);
  }
});

6.5 调试困难

子进程有独立 PID,调试时需分别附加调试器。

技巧:通过 execArgv 传递不同的 --inspect 端口。

javascript

fork('./child.js', [], {
  execArgv: ['--inspect=9230']
});

七、生产环境最佳实践

7.1 限制并发数(进程池)

根据 CPU 核心数动态控制:

javascript

const cpus = require('os').cpus().length;
const pool = new Set();

function addWorker() {
  if (pool.size >= cpus) return;
  const worker = fork('./worker.js');
  pool.add(worker);
  worker.on('exit', () => {
    pool.delete(worker);
    // 自动补充
    addWorker();
  });
}

7.2 优雅退出

监听主进程的 SIGTERM 信号,通知子进程完成手头任务后再退出:

javascript

process.on('SIGTERM', () => {
  child.send({ command: 'shutdown' });
  
  const timeout = setTimeout(() => child.kill('SIGKILL'), 5000);
  child.on('exit', () => {
    clearTimeout(timeout);
    process.exit(0);
  });
});

7.3 错误处理三板斧

javascript

child.on('error', (err) => {
  // 启动失败、IPC 通道断开等
  console.error('子进程错误:', err);
});

child.on('exit', (code) => {
  if (code !== 0) {
    // 非正常退出,记录告警
  }
});

child.on('disconnect', () => {
  // IPC 通道关闭,可能子进程已死亡
});

7.4 结构化消息

使用消息类型字段,方便子进程路由:

javascript

child.send({
  type: 'TASK_START',
  payload: { id: 123, data: 'xxx' }
});

// 子进程
process.on('message', (msg) => {
  switch (msg.type) {
    case 'TASK_START':
      handleTask(msg.payload);
      break;
    case 'SHUTDOWN':
      gracefulShutdown();
      break;
  }
});

八、总结

child_process.fork() 是 Node.js 应对 CPU 密集任务和多核利用的瑞士军刀。本文从基础使用到生产级实践,覆盖了你需要知道的一切:

  • 核心概念:fork 是专门衍生 Node.js 子进程的方法,通过内置 IPC 通道通信
  • 参数传递args 是启动参数(通过 process.argv.slice(2) 获取),send() 是运行时消息
  • 常见坑点:资源开销、路径问题、TypeScript 兼容、僵尸进程
  • 最佳实践:进程池、优雅退出、错误处理、结构化消息

掌握这些知识,你就能在项目中游刃有余地使用 fork,构建高性能、可扩展的 Node.js 应用。


如果觉得本文有帮助,欢迎点赞收藏,也欢迎在评论区交流你使用 fork 时遇到的坑和解决方案!

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

【Unity Shader Graph 使用与特效实现】专栏-直达

DDX 节点是 Unity URP Shader Graph 中一个重要的数学计算节点,它提供了在像素着色器中计算屏幕空间 X 方向偏导数的功能。这个节点在实现各种高级渲染效果中扮演着关键角色,特别是在需要基于像素变化率进行计算的场景中。理解 DDX 节点的原理和应用对于掌握现代实时渲染技术至关重要。

在计算机图形学中,偏导数计算是许多高级着色技术的基础。DDX 节点通过利用 GPU 的硬件特性,能够高效地计算相邻像素之间的数值差异,这种差异信息可以被用于边缘检测、纹理过滤、法线贴图增强、视差效果等多种图形效果。由于现代 GPU 的并行架构特性,像素着色器中的偏导数计算变得异常高效,这使得 DDX 节点成为高性能实时渲染的重要工具。

DDX 节点的核心价值在于它能够捕捉到屏幕空间中像素值的变化趋势。在光栅化过程中,三角形被分解为多个像素,每个像素在屏幕空间中都有其特定的位置。DDX 节点正是利用了这一特性,通过比较当前像素与其右侧相邻像素的数值差异,计算出在 X 方向上的变化率。这种变化率信息对于许多基于局部特征的图形算法来说是不可或缺的输入数据。

描述

DDX 节点返回输入值相对于屏幕空间 X 坐标的偏导数。从数学角度理解,偏导数描述的是函数在某一点处沿某一坐标轴方向的变化率。在着色器的上下文中,DDX 节点计算的是当前处理的像素与其在屏幕空间 X 方向上相邻像素之间的数值差异。这种差异计算是基于像素着色器中的片段着色阶段执行的,因此能够提供精确的每像素变化信息。

偏导数计算在实时渲染中具有广泛的应用场景。在纹理映射中,它可以帮助确定适当的 mipmap 级别,避免纹理闪烁和摩尔纹现象。在法线贴图渲染中,偏导数可以用于计算切空间向量,确保凹凸效果的正确显示。在边缘检测和轮廓渲染中,偏导数能够识别表面法线或深度的突变区域,为卡通渲染等风格化效果提供支持。

DDX 节点的一个关键限制是它只能在像素着色器阶段使用。这是因为偏导数计算依赖于像素在屏幕空间中的相对位置关系,而这种关系只有在光栅化后的像素着色阶段才变得明确。在顶点着色器或其他早期着色阶段,几何体还没有被分解为像素,因此无法进行有效的屏幕空间偏导数计算。这一限制要求开发者在设计着色器时需要仔细考虑计算阶段的选择。

偏导数计算的精度和性能是开发者需要关注的另一个重要方面。现代 GPU 通常使用专门的硬件单元来执行偏导数计算,这些单元能够并行处理多个像素,确保高性能的同时保持足够的计算精度。然而,在某些边缘情况下,如像素位于几何体边缘或遮挡边界时,偏导数计算可能会出现异常值,开发者需要在这些情况下添加适当的边界处理逻辑。

端口

DDX 节点的端口设计体现了其功能的简洁性和灵活性。节点包含一个输入端口和一个输出端口,两者都支持动态矢量类型,这意味着它们可以处理从标量到四维向量的各种数据类型。这种设计使得 DDX 节点能够适应多样化的着色需求,从简单的浮点数处理到复杂的矢量运算。

输入端口

输入端口标记为 "In",是 DDX 节点接收待处理数据的入口。这个端口接受动态矢量类型的输入,具体支持的数据类型包括:

  • float:单精度浮点数,适用于处理高度、强度等单值参数
  • float2:二维浮点矢量,可用于处理 UV 坐标等二维数据
  • float3:三维浮点矢量,适用于颜色、位置等三维数据的处理
  • float4:四维浮点矢量,可用于包含透明度等四维数据的处理

输入值的性质直接影响偏导数计算的结果。当输入是标量值时,DDX 节点计算的是该标量在屏幕空间 X 方向的变化率。当输入是矢量时,DDX 节点会分别计算每个分量在 X 方向的变化率,并返回一个相同维度的结果矢量。这种分量独立计算的特性使得 DDX 节点能够高效处理复杂的多维度数据。

输入数据的取值范围和特性对结果有重要影响。连续平滑的输入值会产生相对稳定的偏导数输出,而突变或不连续的输入值则会导致较大的偏导数波动。理解这种关系对于正确使用 DDX 节点至关重要,开发者需要根据预期的视觉效果选择合适的输入数据和后续处理方式。

输出端口

输出端口标记为 "Out",负责输出计算得到的偏导数值。输出数据的类型和维度与输入保持一致,这使得 DDX 节点能够无缝集成到现有的着色器连接中。输出值代表了输入在屏幕空间 X 方向上的变化率,其数值大小反映了变化的剧烈程度,符号则指示了变化的方向。

输出值的解读需要结合具体的应用场景。在纹理坐标的偏导数计算中,较大的输出值可能表示纹理在屏幕空间中被拉伸或存在高频率细节。在颜色值的偏导数计算中,较大的输出值可能对应于颜色边界或阴影边缘。理解这些模式有助于开发者正确解释和使用 DDX 节点的输出结果。

输出值的范围通常取决于输入数据的特性和屏幕空间中的变化程度。在平坦着色的区域,偏导数接近于零;在边缘或高细节区域,偏导数的绝对值可能较大。开发者通常需要对输出值进行适当的缩放或钳位处理,以确保其在后续计算中的可用性和稳定性。

生成的代码示例

DDX 节点在 Shader Graph 中生成的底层代码揭示了其实现机制和与 HLSL 着色语言的对应关系。生成的代码示例展示了节点如何将高级的图形化编程概念映射到底层的着色器指令,这种映射关系对于理解着色器的执行效率和优化可能性具有重要意义。

以下示例代码表示此节点的一种可能结果:

void Unity_DDX_float4(float4 In, out float4 Out)
{
    Out = ddx(In);
}

这段生成的代码体现了几个重要的设计特点。函数名称 "Unity_DDX_float4" 表明了这是针对 float4 类型的专门实现,Unity Shader Graph 会根据实际连接的输入类型生成相应数据类型的函数版本。这种类型特定的代码生成确保了最佳的性能和内存使用效率。

函数参数结构采用了 HLSL 中常见的输入-输出模式,输入参数 "In" 接收待处理的数据,输出参数 "Out" 通过引用方式返回计算结果。这种参数传递方式符合 HLSL 的函数设计惯例,确保了与现有着色器代码的兼容性。

核心计算语句 "Out = ddx(In)" 调用了 HLSL 内置的 ddx 函数,这是实现屏幕空间偏导数计算的关键。ddx 函数是 HLSL 语言的标准组成部分,由 GPU 硬件直接支持,能够以极高的效率执行偏导数计算。这种硬件加速的实现方式确保了 DDX 节点在实时渲染中的实用性。

代码的简洁性反映了偏导数计算在硬件层面的高度优化。单行的函数实现背后是复杂的 GPU 架构支持,包括像素着色器的并行执行模型、屏幕空间的坐标系统以及专门的导数计算单元。这种抽象层次使得开发者能够专注于视觉效果的设计,而无需关心底层的实现细节。

理解生成的代码对于高级着色器开发具有重要意义。当需要进行自定义的偏导数相关计算或性能优化时,开发者可以直接在 HLSL 代码中使用 ddx 函数,或者基于生成的代码模式进行扩展和修改。这种灵活性确保了 DDX 节点既适用于可视化的图形编程,也满足代码级定制需求。

实际应用案例

DDX 节点在真实项目中的应用展示了其在实际渲染问题解决中的价值。通过具体的应用案例,开发者可以更好地理解如何将偏导数计算集成到自己的着色器设计中,以及如何根据不同的渲染需求调整和优化 DDX 节点的使用方式。

边缘检测与轮廓渲染

在非真实感渲染中,边缘检测是创建卡通风格、素描效果等艺术化渲染的关键技术。DDX 节点可以用于检测表面属性在屏幕空间中的突变区域,这些区域通常对应于物体的轮廓或特征边缘。

实现边缘检测的基本方法是计算表面法线或深度的偏导数:

// 使用DDX节点进行法线-based边缘检测
float3 normalWS = NormalWorldSpace;
float3 ddx_normal = ddx(normalWS);
float3 ddy_normal = ddy(normalWS);
float edgeStrength = length(float2(ddx_normal, ddy_normal));

在这个例子中,我们同时使用了 DDX 和 DDY 节点来计算法线在屏幕空间两个方向上的变化率。通过计算变化率的矢量长度,我们可以得到一个表示边缘强度的标量值。较大的 edgeStrength 值对应于法线方向快速变化的区域,这些区域通常就是需要突出显示的边缘。

对于深度-based边缘检测,可以采用类似的方法:

// 使用DDX节点进行深度-based边缘检测
float depth = LinearEyeDepth(RAW_DEPTH, _ZBufferParams);
float ddx_depth = ddx(depth);
float ddy_depth = ddy(depth);
float depthEdge = length(float2(ddx_depth, ddy_depth));

深度边缘检测特别适用于识别物体之间的遮挡边界,这些边界在法线-based方法中可能无法被正确检测。结合多种边缘检测方法可以创建更加完整和视觉上令人满意的轮廓效果。

纹理细节增强

DDX 节点在纹理映射和质量控制中发挥着重要作用。通过分析纹理坐标的偏导数,我们可以了解纹理在屏幕空间中的拉伸程度,从而实施适当的细节增强或优化策略。

计算纹理坐标的偏导数可以帮助确定合适的 mipmap 级别:

// 使用DDX节点计算纹理细节级别
float2 uv = TEXCOORD0;
float2 ddx_uv = ddx(uv);
float2 ddy_uv = ddy(uv);
float texelDensity = max(length(ddx_uv), length(ddy_uv));
float mipLevel = 0.5 * log2(texelDensity * _TextureSize);

在这个例子中,我们通过计算 UV 坐标在屏幕空间中的变化率来估计纹理的拉伸程度。较大的偏导数值表示纹理被严重拉伸,可能需要使用更高层级的 mipmap 来避免锯齿现象;较小的偏导数值则表示纹理被压缩,可以使用更详细的 mipmap 层级来保留高频细节。

基于偏导数的纹理细节增强技术可以显著提升渲染质量:

// 基于偏导数的细节增强
float2 uv = TEXCOORD0;
float2 ddx_uv = ddx(uv);
float2 ddy_uv = ddy(uv);
float detailScale = clamp(1.0 / length(ddx_uv + ddy_uv), 0.1, 10.0);
float3 detail = tex2D(_DetailMap, uv * detailScale).rgb;

这种方法根据纹理在屏幕空间中的显示比例动态调整细节纹理的缩放,确保细节元素在不同观看距离和角度下都能保持适当的视觉比例。

法线贴图与凹凸映射

在基于物理的渲染中,法线贴图是增加表面细节的关键技术。DDX 节点在法线贴图的正确应用中起到关键作用,特别是在构建切空间基向量的计算中。

切空间向量的计算需要屏幕空间偏导数信息:

// 使用DDX节点构建切空间基向量
float3 worldPos = WORLD_POSITION;
float2 uv = TEXCOORD0;

float3 dp1 = ddx(worldPos);
float3 dp2 = ddy(worldPos);
float2 duv1 = ddx(uv);
float2 duv2 = ddy(uv);

float3 normal = normalize(cross(dp2, dp1));
float3 tangent = normalize(dp1 * duv2.y - dp2 * duv1.y);
float3 bitangent = normalize(cross(normal, tangent));

这个计算过程利用了屏幕空间位置和 UV 坐标的偏导数来重建每个像素的切空间坐标系。得到的切空间基向量可以用于将法线贴图中的向量从切空间转换到世界空间,确保凹凸细节在不同视角下都能正确显示。

对于视差映射等高级凹凸效果,DDX 节点同样不可或缺:

// 视差映射中的深度计算
float2 uv = TEXCOORD0;
float height = tex2D(_HeightMap, uv).r;
float2 ddx_uv = ddx(uv);
float2 ddy_uv = ddy(uv);
float2 parallaxOffset = height * _ParallaxStrength * normalize(float3(ddx_uv, ddy_uv)).xy;

视差映射通过根据表面高度和视角偏移纹理坐标来创建深度幻觉。偏导数在这里用于确保偏移量的计算考虑了纹理在屏幕空间中的朝向和比例,避免不自然的拉伸或扭曲。

屏幕空间特效

DDX 节点在屏幕空间后处理特效中也有广泛应用。许多全屏特效需要了解像素值在屏幕空间中的变化趋势,以实现更加自然和高效的视觉效果。

屏幕空间环境光遮蔽通常利用深度信息的偏导数:

// 屏幕空间环境光遮蔽中的边缘感知
float depth = SampleDepth(uv);
float ddx_depth = ddx(depth);
float ddy_depth = ddy(depth);
float depthThreshold = length(float2(ddx_depth, ddy_depth)) * _EdgeSensitivity;

通过分析深度值的屏幕空间变化率,SSAO 算法可以识别并避免在深度不连续的区域产生不正确的遮蔽效果,这有助于保持物体边缘的清晰度并减少视觉瑕疵。

屏幕空间反射同样受益于偏导数计算:

// 屏幕空间反射的射线步进优化
float2 ddx_uv = ddx(uv);
float2 ddy_uv = ddy(uv);
float2 ddx_ray = ddx(reflectDir);
float2 ddy_ray = ddy(reflectDir);

在射线步进过程中使用偏导数信息可以帮助动态调整步长和采样位置,提高反射效果的精度和性能。较大的偏导数值表示反射方向变化剧烈,可能需要更密集的采样;较小的值则允许使用更宽松的采样策略。

性能考虑与最佳实践

DDX 节点的性能特征和最佳使用方式对于创建高效的实时着色器至关重要。理解偏导数计算的开销和优化机会可以帮助开发者在视觉效果和渲染性能之间找到最佳平衡。

性能特征分析

DDX 节点的计算开销相对较低,这得益于现代 GPU 的硬件加速支持。偏导数计算通常作为像素着色器指令集的一部分,由专用的硬件单元执行,不会明显增加着色器的整体执行时间。

然而,在某些情况下,DDX 节点的使用可能会间接影响性能:

  • 当输入值依赖于复杂的前期计算时,偏导数计算可能会强制重复这些计算
  • 在分支密集的着色器中使用 DDX 节点可能导致导数计算不一致问题
  • 在计算密集型效果中过度使用偏导数可能累积为显著的性能开销

偏导数计算在 GPU 的着色器核心中以高度并行的方式执行。现代 GPU 通常以 2x2 像素的四边形为单位处理像素着色器,这使得计算相邻像素间的差异变得非常高效。这种执行模型也解释了为什么 DDX 节点只能在像素着色器阶段使用——只有在像素四边形已知的情况下,偏导数计算才有意义。

优化策略

合理使用 DDX 节点可以显著提升着色器的性能和视觉效果质量。以下是一些经过验证的优化策略:

适当的选择计算时机和频率:

  • 避免在每帧不变的数值上计算偏导数
  • 对多个相关计算复用相同的偏导数值
  • 在低频变化的输入上预计算偏导数

精度与质量的平衡:

  • 在视觉效果要求不高的场景中使用近似计算
  • 根据最终显示分辨率调整偏导数计算的详细程度
  • 对远距离物体使用简化的偏导数计算

分支和流控制的合理使用:

  • 避免在动态分支内部使用 DDX 节点
  • 将偏导数计算移到条件判断之外
  • 使用静态分支而非动态分支组织偏导数相关代码

常见问题与解决方案

DDX 节点在使用过程中可能会遇到一些典型问题和挑战,了解这些问题及其解决方案有助于创建更加稳定和可靠的着色器。

导数计算不一致问题:

  • 问题描述:在动态分支或循环中使用 DDX 节点可能导致不可预测的结果
  • 解决方案:确保所有执行路径都计算相同的偏导数,或将计算移到控制流之外

高频率输入导致的噪声问题:

  • 问题描述:对高频率变化的输入计算偏导数可能产生噪声结果
  • 解决方案:对输入进行适当的预处理滤波,或使用基于多个像素的平均偏导数

屏幕边缘异常值:

  • 问题描述:在屏幕边缘或几何体边界处,偏导数计算可能出现异常值
  • 解决方案:添加边界检查逻辑,对异常情况使用回退值或特殊处理

调试与验证

正确调试和验证 DDX 节点的计算结果对于着色器开发至关重要。以下是一些有效的调试技术:

可视化偏导数结果:

  • 将偏导数值映射到颜色空间直接查看
  • 使用不同的颜色通道表示不同方向的偏导数
  • 通过阈值处理突出显示特定的偏导数范围

比较分析与参考实现:

  • 与已知正确的参考着色器比较偏导数计算结果
  • 使用数值方法验证偏导数计算的准确性
  • 在不同分辨率和硬件平台上测试一致性

性能分析与优化验证:

  • 使用 GPU 性能分析工具监测偏导数计算的开销
  • 对比不同实现方式的性能差异
  • 验证优化措施的实际效果

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

AI 打字跟随优化

前文提到通过API去监听滚动容器或容器尺寸去触发打字跟随,监听用户的滚动去读取造成重排的属性来实现用户是否跟随打字实际会造成浏览器多次重排。

重排

浏览器重排(回流)是浏览器对DOM元素计算位置、尺寸、布局的过程。 浏览器解析HTML合成DOM树,解析CSS合成CSSOM树,它们会一步步合成渲染树,进行布局。页面、结构、尺寸发生变化就会再走一遍布局的计算流程,称为重排。

为什么重排会造成性能开销?

  • 当一个元素变化后,可能影响父元素、子元素、兄弟元素等,浏览器需要进行递归遍历。
  • 重排需要在浏览器主线程发生,阻塞JS、渲染。
  • 频繁重排会造成浏览器卡顿,浏览器的刷新率是60fps,每帧近16ms,一次重排就会占据一部分时间;多次重排会导致掉帧。

哪些操作会导致重排?

  • 元素几何属性发生变化。
  • 增删、移动DOM。
  • 窗口变化(resize、scroll页面等)
  • 获取布局相关属性:
    • offsetTop / offsetLeft / offsetWidth / offsetHeight

    • scrollTop / scrollHeight

    • clientTop / clientWidth

    • getComputedStyle()

    • getBoundingClientRect()

IntersectionObserver 哨兵模式

这里直接取消滚动事件的监听,在容器的最底部放一个哨兵容器,通过 IntersectionObserver 去监听哨兵在监听的父元素在可视区域的交叉值来判断用户是否滚动。让哨兵通过 scrollIntoView 直接暴露在可视区域,实现打字跟随。

<div class="chat-scroll-container" ref="scrollContainerRef">
    <div class="chat-container" id="messagesRef"></div>
    <div class="scroll-sentinel" ref="sentinelRef"></div> // 哨兵
</div>

threshold: 1 // 1 :表示全部进入,0 :露头就秒

onMounted(() => {
  const ro = new ResizeObserver(() => {
    if (enableAutoScroll.value) {
      scrollToBottom();
    }
  });
  ro.observe(chatMessagesRef.value);

  observer = new IntersectionObserver(
    (entries) => {
      const isIntersecting = entries[0].isIntersecting;
      enableAutoScroll.value = isIntersecting; 
    },
    {
      root: scrollContainerRef.value, // 监听父元素,默认为 root
      threshold: 1,       // 1表示全部进入,0 :露头就秒
      rootMargin: '10px', // 提前10px开始生效
    }
  );

  if (sentinelRef.value) observer.observe(sentinelRef.value);
});

  
  const scrollToBottom = () => {
  nextTick(() => {
    const el = sentinelRef.value;
    if (!el) return;
    if (enableAutoScroll.value) {
      sentinelRef.value.scrollIntoView({ behavior: 'instant' });
    }
  });
};

在实践过程中,如果使用 behavior: 'smooth' ,浏览器在触发 scrollToBottom 时发生的动画会频繁抖动,将哨兵挤到父容器的可视区域外,导致 IntersectionObserver 频繁触发,可能产生 bug,使用 instant 取消浏览器动画抖动,直接抵达底部,避免该情况产生。

Vue 3 defineOptions 宏,用 VuReact 编译成 React 长什么样?

VuReact 是一个语义感知、约定驱动、支持渐进迁移的编译器,能把 Vue 3 代码一键转成标准可维护的 React 18+ 代码。

今天我们继续拆解核心 API:Vue 3 <script setup> 里的 defineOptions 宏,经过 VuReact 编译后在 React 中如何呈现?

前置约定

为了示例清爽、理解无歧义,先统一两个规则:

  1. 只保留核心逻辑,省略外层包裹与无关配置;
  2. 默认你已熟悉 Vue 3 defineOptions 的用法与语义。

编译对照:Vue defineOptions → React

1. Vue defineOptions({ name }) → React 组件命名

defineOptions 是 Vue 3 用于组件额外配置的宏,最常用就是指定组件 name。 在 React 中没有完全对应的宏,VuReact 会把 name 直接映射为组件函数名,保持语义一致。

Vue 代码

<script setup lang="ts">
  defineOptions({
    name: 'MyComponent'
  })
</script>

VuReact 编译后 React 代码

const MyComponent = () => {
  return <></>
}

export default MyComponent

defineOptions({ name }) 不会生成任何运行时 Hook,仅作为编译期信息,用来给 React 组件“起名字”,让 DevTools、调用栈保持和 Vue 一致。


2. Vue defineOptions 其他配置 → React 忽略/编译提示

defineOptions 还支持 inheritAttrscustomOptions 等配置。 由于 React 组件机制与 Vue 不同,无法直接映射,VuReact 会做保守处理:

  • inheritAttrs:React 无对应概念,直接忽略
  • customOptions:非标准配置,忽略并可在编译期提示
  • 其他扩展选项:统一忽略

Vue 代码

<script setup lang="ts">
  defineOptions({
    name: 'MyComponent',
    inheritAttrs: false
  })
</script>

VuReact 编译后 React 代码

const MyComponent = () => {
  return <></>
}

export default MyComponent
// inheritAttrs 在 React 中无直接对应,已忽略

这样处理的好处:不向 React 注入无用运行时代码,保持产物干净、符合 React 最佳实践。


3. 最佳实践:用 @vr-name 显式指定组件名

如果你希望100% 保留组件名语义,推荐使用 VuReact 官方推荐的注释约定:

<script setup lang="ts">
// @vr-name: MyComponent
</script>

编译后会稳定生成对应名称的 React 组件,比 defineOptions({ name }) 更可靠、更符合编译约定。

核心总结

  • defineOptions({ name }) → 编译为 React 组件名,无运行时开销
  • inheritAttrs 等 → React 无对应,直接安全忽略
  • 推荐用 // @vr-name: 组件名 替代,更稳定、更标准

VuReact 始终遵循:保留语义、不造多余运行时、符合 React 规范

相关资源


✨ 对你有帮助的话,欢迎 点赞 + 收藏 + 关注,持续更新 VuReact 编译原理实战~

邪修!让显示器支持AI、远程、手势三种控制方式

大家好,我是石小石~


解锁明基RD270Q的新玩法

前不久,明基发布了最新款式的编程系列显示器 RD270Q,很荣幸我获得了优先体验资格。刚开箱,我就被它出众的颜值所吸引。

这款显示器保留了RD系列最核心也是我最喜欢的「编程模式」,而且它还升级到144Hz 高刷 并增加了彩纸模式。这使得在长时间编码下,它能极大缓解眼部疲劳,体验感非常舒适。

接下来,我会分享借助RD270Q配套的DisplayPilot2软件,结合AI与编码,如何玩转显示器的特色功能:

  • 用 Claude code 切换显示器编程模式

  • 用手机远程操控显示器锁屏

  • 用手势实现显示屏亮度调节 (动图帧率问题,图片效果不是很明显)

同时,我会结合长时间的编码体验,验证它是否能成为程序员必备的专业显示器。

显示器控制的核心——Display Pilot 2

无论是通过 AI、手机远程还是手势来控制显示器,核心本质都是依靠电脑上运行的 “脚本” 去操控显示器硬件。借助一些键鼠模拟脚本(如 Node 的robotjs、nut-js,或Python的keyboard),我们可以通过模拟鼠标事件来间接操控软件实现功能,如通过 Node.js 脚本实现自动移动鼠标,并双击启动软件的自动化操作:

对应核心代码如下:

const { mouse,straightTo,Point,Button} = require("@nut-tree-fork/nut-js");
(async () => {
  // 移动鼠标到指定位置
  await mouse.move(straightTo(new Point(10, 10)));
  console.log("鼠标移动完成!");
  // 点击鼠标
  await mouse.doubleClick(Button.LEFT);
  console.log("执行完成!");
})();

可以看出,一些复杂的软件操作,通过模拟鼠标实现还是非常麻烦的,最重要的是脚本几乎无法控制硬件。

幸运的是,明基 RD270Q 自带了配套软件 Display Pilot 2,它可以直接通过软件快速调用显示器的硬件级操作能力,以满足我们编程中的个性化控制需求。参考软件截图,它拥有非常多的显示屏操作功能,且基本都支持通过快捷键操作。

思路到这里就很清晰了:我们完全可以编写脚本,模拟键盘事件触发 Display Pilot 2 的快捷操作,从而间接实现对显示器的控制。

使用Clade code+skills控制显示屏

编程模式切换效果演示

编程模式是明基 RD 系列显示器的特色功能,在深色模式下,显示器会通过硬件级算法强化语法高亮效果,以提升长期编程的舒适度;RD270Q新增的彩纸模式,则能让界面产生类纸感的细腻色彩,满足深度护眼需求。如下图,在黑暗模式下,明基对代码的显示优化非常明显,代码对比更加鲜明,不刺眼。

它还搭载了莱茵认证的抗反射抗面板,即便在强光环境下使用,屏幕也不会刺眼、不产生明显眩光,长时间观看依旧舒适。

在配套软件的基础上,我们能否借助 AI 实现这些显示模式的一键自动切换呢?答案是完全可以。 比如,直接通过 AI 对话下达指令,让显示器自动切换至电子书模式

或是通过指令让 AI 精准调节屏幕亮度、音频大小等参数

原理分析——RD270Q-Opera-skills

Claude Code 为例,我们来实现这一效果。需要明确的是:AI 本身并不能直接操控显示器硬件,即便它能生成脚本,也不知道如何与显示器交互。因此,我们可以通过自定义技能(Skills) —— 比如创建一个 RD270Q-operation-skills,来为 AI 扩展控制显示器的能力。

如果你不了解 Skills,请自行百度。

该技能的项目结构如下:

RD270Q-operation-skills/
├── SKILL.md              # 元数据与指令定义
├── index.js              # 主入口:命令解析与分发
├── package.json          # 项目依赖配置
├── test.js               # 功能测试脚本
├── scripts/              # 底层操作模块
│   ├── keyboard.js       # 键盘快捷键封装
│   └── mouse.js          # 鼠标操作封装
└── references/           # 参考文档
    └── 快捷键表.md        # Display Pilot 2 完整快捷键

整个技能的核心逻辑非常简单: 将 Display Pilot 2 的快捷键功能在代码中做映射,让 AI 可以通过函数调用触发。

示例核心代码(scripts/keyboard.js):

// 键盘快捷键模块 - 封装 Display Pilot 2 所有控制功能
const { keyboard, Key } = require("@computer-use/nut-js");

// 执行快捷键组合
async function executeShortcut(...keys) {
  await keyboard.pressKey(...keys);
  await new Promise(resolve => setTimeout(resolve, 100));
  await keyboard.releaseKey(...keys);
}

// ==================== 色彩模式 ====================
// 循环切换色彩模式 Ctrl+Alt+C
async function cycleColorModes() {
  await executeShortcut(Key.LeftControl, Key.LeftAlt, Key.C);
}
// 编程亮模式 Ctrl+Alt+1
async function setCodingLight() {
  await executeShortcut(Key.LeftControl, Key.LeftAlt, Key.Num1);
}
// 编程暗模式 Ctrl+Alt+2
async function setCodingDark() {
  await executeShortcut(Key.LeftControl, Key.LeftAlt, Key.Num2);
}
// 编程纸张模式 Ctrl+Alt+0
async function setCodingPaper() {
  await executeShortcut(Key.LeftControl, Key.LeftAlt, Key.Num0);
}
// ..... 其他快捷操作


// 导出所有方法
module.exports = {
  executeShortcut,
  cycleColorModes,
  setCodingLight,
  setCodingDark,
  setMBook,
  // ...
};

我们只需要在 SKILL.md 中规范好 AI 的调用方式与指令规则,完成整套技能开发后,Claude Code 就拥有了直接操控显示器模式的能力,使用体验直接拉满。

除了编程模式的切换,凡是 Display Pilot 2 能通过快捷键实现的显示器操控功能,这个skills都能完美胜任,甚至像Display Pilot 2屏幕分区这样的高级功能,也能通过控制鼠标来模拟实现。

使用手机远程控制显示屏

很多时候,我们可能临时有事需要离开工位,如果我们突然想锁屏或者想远程控制一下鼠标执行某个简单操作就必须立刻回到工位才行。基于这中场景,实现手机远程控制显示器就非常有意义。

如下图,就是根据明基RD270Q支持的快捷键开发的一个移动端操作界面,并增加了鼠标触摸移动控制功能。

远程锁屏、鼠标控制演示

如果外出忘记锁屏,通过手机实现这个功能非常方便实用。

此外,通过移动端界面的触控区域,我们还能远程操控鼠标移动、直接打开 VSCode 等软件。是不是有点Todesk青春版的感觉?

除此之外,其他快捷操作,如编程模式、亮度调节、夜间保护调节等功能都是支持的,这里也就不一一展示了。

原理分析——websoket+node控制快捷键

远程控制的方案其实非常简单:核心就是跑在本地的一个 Node 脚本,用来模拟键盘、鼠标操作,间接通过 Display Pilot 2 控制显示器。同时启动一个 Web 服务提供移动端操作界面,借助 WebSocket 实现手机与 Node 服务实时通信,最终完成远程控制。简单涞水,就是Web 端通过WebSocket 控制本地端Node服务模拟系统快捷键操作

前端就是一个普通的 Vue 项目 , 页面上放几个控制按钮,点击时通过 WebSocket 向 Node 服务发送对应指令:

function createWebSocketServer(server) {
  const wss = new WebSocket.Server({ server, path: "/ws" });
  wss.on("connection", (ws) => {
    console.log("移动端已连接");
    ws.on("message", async (msg) => {
        const { type, action, params } = JSON.parse(msg);
        // 鼠标操作
        if (type === "mouse") {
          if (action === "move") {
            // 鼠标移动
            await mouse.move(params.x, params.y);
          } else if (action === "click") {
            // 鼠标点击
            await mouse.click(params.button);
          }
        }
        // 键盘操作
        if (type === "keyboard"){
          
        }
    });
  });
}

Node 端主要搭建 WebSocket 服务,接收移动端指令并执行系统操作。

const app = express();
const server = http.createServer(app);

// 初始化 WebSocket 服务
createWebSocketServer(server);

server.listen(PORT, () => {
  console.log(`WS 服务已启动:ws://localhost:${PORT}/ws`);
});;

具体的鼠标移动、键盘快捷键等逻辑,统一封装在 mouse.jskeyboard.js 中,底层依赖node第三方库nut-js实现鼠标和快捷键控制。

使用手势控制显示屏

RD270Q 还有个我觉得特别实用的功能 ——Visual Optimizer 视觉优化。它通过内置光传感器,能根据环境光智能同步调节屏幕亮度与色温,降低屏幕与环境的明暗反差,配合编码深色模式,长时间看代码也更柔和护眼。

不仅如此,我们还可以通过Display Pilot 2进一步调整屏幕亮度,实现个性化需求。基于Display Pilot 2,我们还能实现通过手势控制实现显示器的隔空操作,作为技术创意尝鲜、趣味交互玩具,还是得研究和尝试的。

桌面版的手势识别存在一定技术难度,恰好之前我有写过类似的技术文章:油猴+手势识别:我实现了任意网页隔空控制!索性偷个懒,在网页上实现手势识别用来控制显示器。先看看Demo效果:

  • 左手张开 + 右手滑动,即可调低屏幕亮度(左手握拳 + 右手滑动,即可调高屏幕亮度)

  • 右手握拳,可以实现一键锁屏功能

它的核心实现是基于MediaPipe,这是一个是谷歌开源的跨平台、实时轻量级多媒体机器学习框架,支持 Python、JS 等多种编程语言,借助它能轻松实现桌面级的手势识别功能。

如果你对相关技术感兴趣,可以看看这个实现

Demo:油猴+手势识别:我实现了任意网页隔空控制!

代码:《有趣的手势识别、人脸识别脚本》

Flow 智能工作流

本来我还在琢磨,能不能通过 AI 指令或远程控制,自己搭一套编码时的专属显示方案,比如打开 VS Code 就自动切换到我习惯的亮度、护眼参数等。结果发现 RD270Q 早已自带了 Flow 智能工作流,在 Display Pilot 2 里提前预设好编程、文档、设计等场景后,打开对应软件就能自动切换显示参数,省去反复调节的麻烦,真正实现了 “打开即用” 的智能个性化体验。

结语

从借助 AI 指令、移动端远程控制显示器,到创意十足的手势隔空控制,这篇文章我通过三种个性化玩法,把RD270Q显示器的自定义操控能力发挥到了极致。这些功能实现的核心,离不开Display Pilot 2对显示器本身的 稳定操控能力。

当然,即便不借助这款软件,文中的思路也可以延伸到电脑本身的快捷操作、系统级功能调用上,大家不妨顺着这个方向自行尝试拓展。

写完这篇文章已是凌晨,144Hz 高刷屏搭配显示器的深色编码模式,长时间使用眼部依然舒适,没有出现干涩、疲劳感。实际体验下来,RD270Q 的护眼技术确实做得不错,整体感受很好。

总而言之,新款 RD270Q 不仅保留了核心优势,价格也很有诚意,三千出头,上市期间会更优惠!兄弟们,不用犹豫,这次可以放心冲了。当然,要是追求极致编程体验 RD280URD280UGRD320U也也都是非常不错的选择。

最后, 附上一张深夜codding的图,希望这篇分享能为大家带来一些实用参考。

微前端入门:qiankun + Vue 3 + Vite 从0搭建第一个微前端应用

前言

随着业务规模不断扩大,前端应用体积越来越大,单一巨石应用的开发维护成本越来越高:

  • 多人协作开发,代码冲突频繁
  • 整体编译打包时间越来越长
  • 技术栈陈旧,无法引入新技术
  • 发布一个小改动需要整个应用重新上线

微前端架构应运而生,通过将巨石应用拆分为多个独立可交付的微应用,实现团队自治、独立开发、独立部署,从架构层面解决这些问题。

目前社区中比较成熟的微前端方案里,qiankun 是蚂蚁集团开源的企业级方案,经过大量业务验证,生态成熟,api 友好,是落地微前端最稳妥的选择。

本文带你一步步从 0 搭建第一个 qiankun 微前端应用,基于最新的 Vue 3 + Vite 技术栈,解决了网上大部分教程配置错误的问题。读完本文你就能跑通一个可工作的微前端应用。

技术选型

层级 选型 理由
微前端框架 qiankun 成熟稳定,社区案例多,坑少
前端框架 Vue 3 主流稳定,性能优秀,生态完善
构建工具 Vite 开发启动快,热更新体验好,是当前主流趋势
路由 Vue Router 4 官方标准,配合 qiankun 路由联动方案成熟

整体架构

graph TD
    A[主应用/MainApp] --> B[微应用1/App1]
    A --> C[微应用2/App2]
    style A fill:#f9f,stroke:#333,stroke-width:2px
    style B fill:#bbf,stroke:#333,stroke-width:1px
    style C fill:#bbf,stroke:#333,stroke-width:1px
  • 主应用(基座): 负责微应用注册与生命周期管理、全局导航布局、公共依赖加载。
  • 微应用: 各个业务模块独立开发、独立运行、独立部署。
  • 最终效果:用户在浏览器中切换不同业务模块,感觉就像在同一个应用里,实际上每个模块都是独立的。

项目初始化

我们需要创建三个项目:一个主应用,两个微应用。

# 创建主应用
npm create vite@latest main-app -- --template vue

# 创建微应用一
npm create vite@latest micro-app-vue1 -- --template vue

# 创建微应用二
npm create vite@latest micro-app-vue2 -- --template vue

最终目录结构:

micro-front/
├── main-app/          # 主应用(基座)
├── micro-app-vue1/    # 微应用1
├── micro-app-vue2/    # 微应用2
└── README.md

本文档对应的完整代码已经开源在 github.com/wenbiyou/mi…,你可以直接克隆运行。

第一步:主应用配置 qiankun

安装依赖

cd main-app
npm install qiankun vue-router@4

qiankun 注册与启动

修改 src/main.js

import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { registerMicroApps, start } from 'qiankun'
import App from './App.vue'

const app = createApp(App)
const router = createRouter({
  history: createWebHistory('/'),
  routes: [
    { path: '/', redirect: '/app1' },
    { path: '/:pathMatch(.*)*', component: () => import('./views/NotFound.vue') }
  ]
})

app.use(router)
app.mount('#app')

// 微应用配置
const microApps = [
  {
    name: 'micro-app-vue1',
    entry: '//localhost:7100', // 开发环境入口
    activeRule: '/app1', // 激活规则:路径以 /app1 开头时激活
    container: '#micro-container' // 挂载容器
  },
  {
    name: 'micro-app-vue2',
    entry: '//localhost:7101',
    activeRule: '/app2',
    container: '#micro-container'
  }
]

// 注册微应用
registerMicroApps(microApps, {
  beforeLoad: [app => console.log('before load', app.name)],
  beforeMount: [app => console.log('before mount', app.name)],
  afterMount: [app => console.log('after mount', app.name)],
  afterUnmount: [app => console.log('after unmount', app.name)]
})

// 启动 qiankun
start({
  sandbox: {
    strictStyleIsolation: false,
    experimentalStyleIsolation: true // 开启实验性样式隔离,兼容性更佳
  },
  prefetch: 'all' // 预加载微应用静态资源
})

添加主应用布局

修改 src/App.vue

<template>
  <div id="main-app">
    <header class="main-header">
      <div class="logo">
        <h1>微前端主应用</h1>
      </div>
      <nav class="nav">
        <router-link to="/app1">应用一</router-link>
        <router-link to="/app2">应用二</router-link>
      </nav>
    </header>
    <!-- qiankun 挂载容器 -->
    <div id="micro-container" class="micro-container"></div>
  </div>
</template>

<style scoped>
.main-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0 20px;
  background: #2c3e50;
  color: white;
}
.nav {
  display: flex;
  gap: 20px;
}
.nav a {
  color: white;
  text-decoration: none;
}
.nav a.router-link-exact-active {
  color: #42b983;
  font-weight: bold;
}
.micro-container {
  padding: 20px;
}
</style>

Vite 配置

修改 vite.config.js

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    port: 7099, // 主应用端口,避免和微应用冲突
    cors: true // 开启跨域,允许加载微应用资源
  }
})

主应用配置完成!接下来配置微应用。

第二步:微应用适配 qiankun

micro-app-vue1 为例,micro-app-vue2 只需要修改端口和名称即可。

安装依赖

cd micro-app-vue1
npm install vite-plugin-qiankun

修改入口文件适配生命周期

修改 src/main.js

import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { renderWithQiankun, qiankunWindow } from 'vite-plugin-qiankun/es/helper'
import App from './App.vue'
import './style.css'

let app = null

function render(props = {}) {
  const { container } = props
  const mountNode = container ? container.querySelector('#app') : '#app'

  // 每次渲染新建 router,避免状态污染
  const router = createRouter({
    history: createWebHistory(qiankunWindow.__POWERED_BY_QIANKUN__ ? '/app1' : '/'),
    routes: [
      { path: '/', component: () => import('./views/Home.vue') },
      { path: '/about', component: () => import('./views/About.vue') }
    ]
  })

  app = createApp(App)
  app.use(router)
  app.mount(mountNode)
}

// 使用 renderWithQiankun 包裹生命周期
renderWithQiankun({
  bootstrap() {
    console.log('[micro-app-vue1] bootstrap')
  },
  mount(props) {
    console.log('[micro-app-vue1] mount', props)
    render(props)
  },
  unmount() {
    console.log('[micro-app-vue1] unmount')
    app?.unmount()
    app = null
  }
})

// 独立运行
if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
  render()
}

关键 Vite 配置

修改 vite.config.js

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import qiankun from 'vite-plugin-qiankun'

export default defineConfig({
  base: '/app1/', //  开发环境基路径,生产环境请根据部署路径动态设置
  plugins: [
    vue(),
    qiankun('micro-app-vue1', {
      useDevMode: true // 开发模式必须开启
    })
  ],
  server: {
    port: 7100, // 微应用端口
    cors: true, // 必须开启跨域,主应用才能访问
    headers: {
      'Access-Control-Allow-Origin': '*'
    },
    origin: 'http://localhost:7100' // 必须配置完整的 origin,qiankun 才能正确获取资源
  },
  build: {
    // 生产构建必须配置 UMD 格式供 qiankun 加载
    lib: {
      entry: './src/main.js',
      name: 'micro-app-vue1',
      formats: ['umd'],
      fileName: () => 'index.js'
    }
  }
})

关键提示: 这是网上大多数教程配置错误的地方!

  1. server.origin 必须是完整 URL,否则 qiankun 无法正确加载微应用资源
  2. base 必须和 activeRule 保持一致
  3. 生产构建必须配置 build.lib 输出 UMD 格式

第二个微应用 micro-app-vue2 配置类似,只需要修改:

  • 端口改为 7101
  • base 改为 /app2/
  • name 改为 micro-app-vue2

本地运行验证

我们需要三个终端分别启动:

# 终端一:主应用
cd main-app && npm install && npm run dev
# 访问 http://localhost:7099

# 终端二:微应用一
cd micro-app-vue1 && npm install && npm run dev
# 监听 http://localhost:7100

# 终端三:微应用二
cd micro-app-vue2 && npm install && npm run dev
# 监听 http://localhost:7101

打开浏览器访问 http://localhost:7099,你应该能看到:

  1. 主应用头部导航显示
  2. 微应用一正常加载3.点击导航切换到微应用二,微应用二正常加载
  3. 切换回微应用一,微应用一再次正常加载

如果看到这个效果,恭喜你!你的第一个 qiankun 微前端应用已经跑通了!

FAQ

Q: 按照步骤配置后,微应用还是加载不出来,怎么办?

A: 先检查这几点:

  1. micro-app-vue1vite.config.jsserver.origin 是否配置了完整 URL (http://localhost:7100)
  2. 端口是否被占用,三个服务都正常启动了吗?
  3. 浏览器控制台有没有报错?常见报错我们在第三篇文章会详细讲解。

Q: 微应用可以独立运行吗?

A: 可以!直接访问 http://localhost:7100 就能独立运行微应用一,这是最佳实践——每个微应用必须能够独立运行,方便开发调试。

Q: 为什么每次 mount 都要重新创建路由?

A: 为了避免状态污染。如果路由只创建一次重复使用,上次的状态会残留到下次挂载,重新创建可以保证每次挂载都是干净的状态。

Q: 生产环境部署和开发环境有什么不同?

A: 打包优化、部署方案、常见坑点我们放在第三篇文章详细讲解。第二篇我们先把核心概念讲清楚。

本章小结

你已经完成了:

  1. 创建了主应用和两个微应用的项目结构
  2. 主应用注册并启动了 qiankun
  3. 微应用适配了 qiankun 生命周期
  4. 配置了正确的 Vite 配置(解决了大多数教程的错误)
  5. 本地运行验证可以正常切换

下一篇我们深入讲解 qiankun 核心概念:路由联动、样式隔离、跨应用通信

一文讲清楚 npm 包里的 `dependencies` 和 `devDependencies`

刚接触 npm 包管理时,很多人都会被两个字段绕住:

  • dependencies
  • devDependencies

表面看只差了一个 dev,但实际背后是两种完全不同的角色。

很多人会先形成一个直觉:

devDependencies 是开发时依赖,dependencies 是运行时依赖。

这个理解方向没错,但如果只停在这里,实际写项目时还是容易分错。

这篇文章就把这个问题彻底掰开讲明白。


一、先看结论

最简单的判断标准是这一句:

包在真正运行时还需要它,就放 dependencies
只在开发、测试、构建、打包阶段需要它,就放 devDependencies

换句话说:

dependencies

表示运行时依赖

也就是:

  • 项目启动时需要
  • 代码执行时需要
  • 用户真正使用功能时需要

devDependencies

表示开发时依赖

也就是:

  • 本地开发时需要
  • 测试时需要
  • 构建时需要
  • 打包发布时需要
  • 代码检查时需要

二、为什么这个问题总让人混淆

因为“开发时会用到”这句话,范围太大了。

你写代码的时候,当然什么都在开发时用到了:

  • 你会用 axios
  • 你会用 lodash
  • 你会用 typescript
  • 你会用 eslint

但它们的性质并不一样。

这里真正该问的,不是:

我开发时有没有用到它?

而是:

我把包发布出去后,别人安装并运行这个包时,还需不需要它?

这才是判断关键。

三、先用一个生活化的比喻理解

可以把开发 npm 包想成开一家小餐馆。

dependencies 是什么

像端上桌的食材:

  • 调料

没有这些,顾客吃不到东西。

devDependencies 是什么

像后厨工具:

  • 菜刀
  • 烤箱
  • 清洁工具

它们对做菜很重要,但顾客不需要把这些工具一起买走。

所以:

  • 跟着“成品”一起发挥作用的,是 dependencies
  • 只在“制作过程”中发挥作用的,是 devDependencies

四、最常见的理解方式

很多文章会这样解释:

dependencies

项目运行时要用的依赖

devDependencies

开发这个项目时要用的依赖

这句话没错,但还不够完整。

更准确一点,应该改成:

dependencies

项目在实际运行时必须存在的依赖

devDependencies

项目在开发、测试、构建、打包、发布时使用的依赖

注意这里多出来几个关键词:

  • 测试
  • 构建
  • 打包
  • 发布

这几个词很重要,因为很多初学者只理解了“开发”,没理解“构建”。

五、什么叫“构建时需要”?

很多人第一次看到这句话会疑惑:

TypeScript、Babel 明明不参与业务运行,为什么它们很重要?

原因很简单:

你平时写的源码,不一定是最终发布给别人运行的代码。

比如你写的是:

const add = (a: number, b: number) => a + b
export default add

这里有 TypeScript 类型标注,运行环境并不能直接拿这些类型来执行。

所以发布前,通常会经过一轮处理,变成:

const add = (a, b) => a + b
export default add

或者再进一步转成更兼容旧环境的版本。

这个“把源码处理成可发布产物”的过程,就是构建或编译。

所以:

  • typescript
  • babel
  • rollup
  • webpack
  • vite

这些通常属于 devDependencies

因为它们负责的是“做菜过程”,不是“上桌后的食物”。

六、最容易分清的一种办法

每次遇到一个依赖,不妨问自己一句:

如果把项目构建完、发布出去,用户真正使用功能时,还要不要这个依赖?

如果要

放进 dependencies

如果不要,只是帮助你开发和打包

放进 devDependencies

这个方法很稳。

七、通过例子理解

例子 1:工具函数库里用了 dayjs

import dayjs from 'dayjs'

export function formatDate(date) {
  return dayjs(date).format('YYYY-MM-DD')
}

这里 dayjs 应该放哪?

答案是:dependencies

因为你的函数真正运行时,需要调用 dayjs

不是你开发时用了它一次就结束了,而是你包的使用者在调用 formatDate 时,底层还要依赖 dayjs

例子 2:用了 eslint 做代码检查

开发时你装了:

npm install eslint -D

它是用来做什么的?

  • 检查代码规范
  • 提示潜在问题
  • 统一团队风格

项目真正运行时,需要 eslint 吗?

不需要。

所以它应该放进 devDependencies

例子 3:用了 jestvitest 做测试

测试工具只在测试阶段执行。

用户安装你的包,并不会因为要调用某个功能而去运行 jest

所以这类依赖一般都属于 devDependencies

例子 4:用了 typescript 写源码

你源码可能是 .ts 文件,但最终发布的包往往是已经编译好的 .js 文件。

也就是说,用户使用你包时,用到的是产物,不是你本地的 TypeScript 编译器。

所以 typescript 一般属于 devDependencies

例子 5:项目里使用 axios 发请求

如果你的业务代码里直接这样写:

import axios from 'axios'

export function getUser() {
  return axios.get('/api/user')
}

axios 是实际运行逻辑的一部分。

因此它通常属于 dependencies

八、为什么很多人会把依赖放错

常见原因主要有三个。

1)只从“我开发时有没有用过”来判断

这会导致判断范围过大。

因为你开发时什么都用到了,但不是所有东西都会进入运行阶段。


2)没有区分“源码”和“发布产物”

这是个很常见的坑。

很多工具只作用在源码阶段,比如:

  • 类型检查
  • 代码转换
  • 打包压缩
  • 生成声明文件

这些工具非常重要,但它们的重要性停留在“生产过程”,不是“运行过程”。

3)把“业务依赖”误认为“开发依赖”

比如平时有的产品业务里明确用到了:

  • axios
  • lodash
  • dayjs

这些都不应该因为“开发时也在用”就被扔进 devDependencies

它们是运行逻辑本身的一部分。

九、从包作者和包使用者两个视角看,会更清楚

这个问题最容易绕,是因为视角没切换。

站在包作者视角

你会觉得:

  • 我开发时用了 typescript
  • 我开发时也用了 axios
  • 我开发时也用了 eslint

感觉它们都是“开发中会用到的东西”。

没错,但这不是 npm 关心的重点。

站在包使用者视角

npm 更关心的是:

  • 用户安装你的包后,哪些依赖必须存在,代码才能跑
  • 哪些依赖只是你内部研发流程要用

这样一看,边界就清楚了:

  • 运行必须的 → dependencies
  • 研发辅助的 → devDependencies

十、一个最小的项目例子

假设你写了一个日期格式化工具包。

目录可能是这样:

src/
  index.ts
package.json
tsconfig.json

源码:

import dayjs from 'dayjs'

export function formatDate(date: string): string {
  return dayjs(date).format('YYYY-MM-DD')
}

你在开发时可能安装了这些包:

  • dayjs
  • typescript
  • rollup
  • vitest
  • eslint

那该怎么分?

放到 dependencies

  • dayjs

因为真正运行 formatDate 时要用它。

放到 devDependencies

  • typescript
  • rollup
  • vitest
  • eslint

因为它们只是帮助你:

  • 写 TypeScript
  • 打包代码
  • 测试功能
  • 检查规范

用户运行 formatDate 本身,不需要这些工具参与。

十一、一个典型的 package.json 示例

{
  "name": "demo-utils",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "dependencies": {
    "dayjs": "^1.11.0"
  },
  "devDependencies": {
    "typescript": "^5.0.0",
    "rollup": "^4.0.0",
    "eslint": "^9.0.0",
    "vitest": "^1.0.0"
  }
}

看到这个配置,可以这样理解:

  • dayjs 是成品运行时要吃的“食材”
  • typescriptrollupeslintvitest 是制作和检查过程中的“工具”

十二、再说几个常见包,方便形成直觉

常见的 dependencies

这类包通常会直接出现在业务代码执行路径里:

  • axios
  • lodash
  • dayjs
  • uuid
  • react(很多应用项目里是这样)
  • vue(很多应用项目里是这样)

常见的 devDependencies

这类包通常服务于开发流程:

  • typescript
  • eslint
  • prettier
  • jest
  • vitest
  • webpack
  • vite
  • rollup
  • babel
  • sass(很多情况下)
  • 各类构建插件、测试插件、lint 插件

十三、一个很实用的口诀

可以记一句很接地气的话:

跟着产物跑的,放 dependencies
只陪你开发的,放 devDependencies

或者再换一种说法:

用户运行时要用的,是 dependencies
你写代码时要用的工具,是 devDependencies

十四、容易踩坑的地方

坑 1:把运行依赖误放进 devDependencies

结果可能是:

  • 本地开发一切正常
  • 发布后别人安装使用时报错
  • 因为真正运行时缺依赖

这就像你做了一碗面,结果把“面”放进了工具箱,而不是放进食材清单里。

坑 2:把开发工具误放进 dependencies

结果通常不会立刻炸,但会带来一些问题:

  • 安装体积变大
  • 用户下载了不必要的包
  • 依赖树更复杂
  • 包管理更混乱

这就像你卖一碗面,还强行把菜刀、案板、烤箱一起塞给顾客。

十五、最后总结

把这件事说到最本质,其实就一句话:

dependencies 是项目运行时真正要依赖的库;
devDependencies 是项目开发、测试、构建、打包时使用的工具依赖。

判断时别问:

我开发时有没有用到它?

而要问:

用户真正运行这份代码时,还需不需要它?

需要,就是 dependencies
不需要,就是 devDependencies

十六、结尾

刚学 npm 时,dependenciesdevDependencies 看起来只是两个字段的区别;
但理解透了之后,你会发现它本质上是在帮你区分:

  • 哪些是“产品的一部分”
  • 哪些是“生产产品的工具”

这个边界一旦建立起来,很多工程化问题都会顺。

因为写代码这件事,说到底也很像开店:

食材要分清,工具也要分清。
不然厨房会乱,顾客也吃不好。


如果喜欢这篇文章,请给我点个赞:)

祝大家:码上有钱 --Larry

Zustand 完全指南:从入门到实战

Zustand 完全指南:从入门到实战

一、为什么需要 Zustand?

1.1 状态管理的痛点

在 React 开发中,我们经常会遇到这样的场景:

// 组件 A
const [count, setCount] = useState(0);

// 组件 B 也需要使用 count
// 组件 C 也需要修改 count

传统的解决方案:

  • Props Drilling:一层层传递 props,代码冗长难维护
  • Context API:需要创建 Provider,包裹组件树,样板代码多
  • Redux:功能强大但配置复杂,需要定义 action、reducer 等

1.2 Zustand 的优势

Zustand(德语,意为"状态")是一个轻量级的状态管理库,它的核心理念是:简单即美

核心优势

  • 🎯 极简 API:只需几行代码就能创建 store
  • 📦 零依赖:不依赖任何框架,可用于 React、Vue 等
  • 🚀 性能优秀:基于订阅机制,组件只在自己需要的状态变化时重新渲染
  • 🔧 TypeScript 友好:完整的类型推断支持
  • 💾 内置持久化:通过中间件轻松实现数据持久化

二、Zustand 核心概念

2.1 基本结构

Zustand 的核心就是 create 函数,它接收一个函数并返回一个 hook:

import { create } from 'zustand';

const useStore = create((set) => ({
  // 状态
  count: 0,
  
  // 修改状态的方法
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

2.2 使用方式

在组件中使用 store:

function Counter() {
  // 从 store 中获取状态和方法
  const { count, increment } = useStore();
  
  return (
    <div>
      <p>计数:{count}</p>
      <button onClick={increment}>增加</button>
    </div>
  );
}

三、实战项目解析

让我们通过一个完整的 zustand-demo 项目,深入理解 Zustand 的实际应用。

3.1 项目结构

zustand-demo/
├── src/
│   ├── store/
│   │   ├── counter.ts    # 计数器 store
│   │   ├── todo.ts       # 待办事项 store
│   │   └── user.ts       # 用户状态 store
│   ├── type/
│   │   └── index.ts      # TypeScript 类型定义
│   └── App.tsx           # 主组件

3.2 类型定义(type/index.ts)

首先定义数据结构,这是 TypeScript 项目的最佳实践:

export interface Todo {
    id: number;
    text: string;
    completed: boolean;
}

export interface User {
    id: number;
    username: string;
    avatar?: string;
}

技术要点

  • 使用 interface 定义对象结构
  • avatar?: string 表示可选属性
  • 为每个实体定义清晰的类型

3.3 计数器 Store(store/counter.ts)

这是最简单的状态管理示例:

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface CounterState {
    count: number;
    increment: () => void;
    decrement: () => void;
    reset: () => void;
}

const useCounterStore = create<CounterState>()(
    persist(
        (set) => ({
            count: 0,
            increment: () => set((state) => ({ count: state.count + 1 })),
            decrement: () => set((state) => ({ count: state.count - 1 })),
            reset: () => set({ count: 0 }),
        }),
        { name: 'counter' }
    )
);

export default useCounterStore;

代码解析

  1. 状态接口定义

    interface CounterState {
        count: number;           // 状态
        increment: () => void;   // 方法
        decrement: () => void;
        reset: () => void;
    }
    
  2. create 函数

    • create<CounterState>():创建类型化的 store
    • 返回一个 hook:useCounterStore
  3. persist 中间件

    • 自动将状态持久化到 localStorage
    • { name: 'counter' }:存储的键名
    • 刷新页面后状态不会丢失
  4. set 函数

    • 直接修改:set({ count: 0 })
    • 基于当前状态修改:set((state) => ({ count: state.count + 1 }))

3.4 待办事项 Store(store/todo.ts)

更复杂的状态管理示例:

import { create } from 'zustand';
import type { Todo } from '../type/index';
import { persist } from 'zustand/middleware';

export interface TodoState {
    todos: Todo[];
    addTodo: (text: string) => void;
    toggleTodo: (id: number) => void;
    removeTodo: (id: number) => void;
}

const useTodoStore = create<TodoState>()(
    persist(
        (set, get) => ({
            todos: [],
            addTodo: (text: string) =>
                set((state) => ({
                    todos: [...state.todos, {
                        id: Date.now(),
                        text,
                        completed: false,
                    }]
                })),
            toggleTodo: (id: number) => 
                set((state) => ({
                    todos: state.todos.map((todo) => 
                        todo.id === id ? 
                        { ...todo, completed: !todo.completed } 
                        : todo
                    )    
                })),
            removeTodo: (id: number) => 
                set((state) => ({
                    todos: state.todos.filter(todo => todo.id !== id)
                })),
        }),
        { name: 'todo' }
    )
);

export default useTodoStore;

技术要点

  1. 数组状态管理

    • 使用不可变数据模式
    • 添加:[...state.todos, newTodo]
    • 更新:state.todos.map(...)
    • 删除:state.todos.filter(...)
  2. get 函数的使用

    • set:修改状态
    • get:获取当前状态(不触发重新渲染)
    • 示例:console.log(get().todos)
  3. ID 生成策略

    • 使用 Date.now() 生成唯一 ID
    • 简单场景适用,生产环境建议使用 UUID

3.5 用户状态 Store(store/user.ts)

登录状态管理示例:

import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { User } from '../type/index';

interface UserState {
    isLogin: boolean;
    login: (user: { username: string; password: string }) => void;
    logout: () => void;
    user: User | null;
}

export const useUserStore = create<UserState>()(
    persist(
        (set) => ({
            isLogin: false,
            login: (user: User) => set({ isLogin: true, user: user }),
            logout: () => set({ isLogin: false, user: null }),
            user: null,
        }),
        { name: 'user' }
    )
);

设计思路

  1. 布尔状态isLogin 表示登录状态
  2. 对象状态user 存储用户信息,初始为 null
  3. 登录方法:接收用户信息,更新状态
  4. 登出方法:重置为初始状态

3.6 主组件(App.tsx)

整合所有 store:

import { useState } from 'react';
import useCounterStore from './store/counter';
import useTodoStore from './store/todo';

function App() {
  const [inputValue, setInputValue] = useState<string>('');

  // 使用计数器 store
  const { count, increment, decrement, reset } = useCounterStore();
  
  // 使用待办事项 store
  const { todos, addTodo, toggleTodo, removeTodo } = useTodoStore();
  
  const handleAdd = () => {
    if (inputValue.trim() === '') return;
    addTodo(inputValue);
    setInputValue('');
  };

  return (
    <>
      {/* 计数器部分 */}
      <button onClick={increment}>Count = {count}</button>
      <button onClick={reset}>reset</button>
      <button onClick={decrement}>-1</button>

      {/* 待办事项部分 */}
      <section>
        <h2>Todos ({todos.length})</h2>
        <input 
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
        />
        <button onClick={handleAdd}>Add</button>
        
        <ul>
          {todos.map(todo => (
            <li key={todo.id}>
              <input 
                checked={todo.completed}
                onChange={() => toggleTodo(todo.id)}
              />
              <span style={{
                textDecoration: todo.completed ? 'line-through' : 'none'
              }}>
                {todo.text}
              </span>
              <button onClick={() => removeTodo(todo.id)}>删除</button>
            </li>
          ))}
        </ul>
      </section>
    </>
  );
}

export default App;

使用要点

  1. 解构获取:从 store hook 中解构需要的状态和方法
  2. 直接使用:获取的方法和状态可以直接使用
  3. 自动更新:状态变化时,组件自动重新渲染
  4. 本地状态结合:可以与 useState 等本地状态结合使用

四、Zustand 进阶技巧

4.1 选择器优化

只订阅需要的状态,避免不必要的重新渲染:

// 只订阅 count,其他状态变化不会触发重新渲染
const count = useCounterStore((state) => state.count);

// 只订阅 todos 数组
const todos = useTodoStore((state) => state.todos);

4.2 跨 Store 调用

在一个 store 中使用另一个 store 的数据:

addTodo: (text: string) => {
    const userId = useUserStore.getState().user?.id;
    set((state) => ({
        todos: [...state.todos, {
            id: Date.now(),
            text,
            userId, // 关联用户 ID
            completed: false,
        }]
    }));
}

4.3 批量更新

一次性更新多个状态:

const updateProfile = (newData: Partial<User>) => {
    set((state) => ({
        user: { ...state.user, ...newData },
        isLogin: true,
    }));
};

4.4 异步操作

处理异步请求:

fetchTodos: async () => {
    const response = await fetch('/api/todos');
    const data = await response.json();
    set({ todos: data });
}

五、最佳实践

5.1 类型安全

始终使用 TypeScript 定义状态接口:

interface TodoState {
    todos: Todo[];
    addTodo: (text: string) => void;
    // ...
}

5.2 单一职责

每个 store 只负责一个功能模块:

// ✅ 好的设计
useCounterStore    // 计数器
useTodoStore       // 待办事项
useUserStore       // 用户状态

// ❌ 不好的设计
useAppStore        // 包含所有状态

5.3 持久化策略

只对需要持久化的数据使用 persist 中间件:

// 需要持久化
useUserStore       // 登录状态
useTodoStore       // 待办事项

// 不需要持久化
useCounterStore    // 临时计数

5.4 性能优化

使用选择器避免不必要的重新渲染:

// ✅ 优化后
const todos = useTodoStore((state) => state.todos);

// ❌ 未优化
const { todos } = useTodoStore();

六、总结

6.1 Zustand 的核心优势

  1. 简单:API 简洁,学习成本低
  2. 灵活:可用于各种规模的项目
  3. 高效:基于订阅机制,性能优秀
  4. 类型安全:完整的 TypeScript 支持

6.2 适用场景

  • 中小型项目的首选状态管理方案
  • 需要快速原型开发的项目
  • 对 bundle size 敏感的项目
  • 需要持久化的场景

6.3 学习路线

  1. 基础:掌握 createset 的使用
  2. 进阶:学习中间件(persist、devtools 等)
  3. 优化:理解选择器和性能优化
  4. 实战:在实际项目中应用和总结

Zustand 以其简洁的 API 和强大的功能,正在成为 React 状态管理的新宠。通过本指南的学习,相信你已经掌握了 Zustand 的核心概念和实战技巧。现在,开始在你的项目中使用 Zustand,体验简单高效的状

Vibe Coding 测试体系:API 测试、单元测试与 e2e 测试实战指南

前言:测试模块是 vibe coding 过程中不可忽视的一环,能够显著提升代码的稳定性与可维护性。本文系统介绍三种主流测试方式——API 测试单元测试e2e 测试,并结合 MuseMVP 项目的真实集成方式逐一演示,帮助你在快速迭代的同时建立可靠的质量防线。

项目集成

  • 安装@playwright/testvitest@vitest/ui三个依赖
  • vitest配置
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";

export default defineConfig({
  resolve: {
    alias: {
      "@": fileURLToPath(new URL("./src", import.meta.url)),
    },
  },
  test: {
    environment: "node",
    globals: true,
    include: ["tests/unit/**/*.test.ts"],
  },
});
  • playwright配置
import { defineConfig, devices } from "@playwright/test";

const baseURL =
  process.env.E2E_BASE_URL ||
  process.env.NEXT_PUBLIC_SITE_URL ||
  "http://localhost:3000";

export default defineConfig({
  testDir: "./journeys",
  fullyParallel: false,
  forbidOnly: true,
  retries: 0,
  workers: 1,
  reporter: [
    ["list"],
    [
      "html",
      { open: "never", outputFolder: "../../.test-artifacts/browser-report" },
    ],
  ],
  outputDir: "../../.test-artifacts/browser-output",
  globalTeardown: "./cleanup.ts",
  use: {
    baseURL,
    trace: "on-first-retry",
    screenshot: "only-on-failure",
    video: "retain-on-failure",
  },
  projects: [
    {
      name: "desktop-chromium",
      use: {
        ...devices["Desktop Chrome"],
      },
    },
  ],
});

单元测试

用于验证纯逻辑层的行为,例如促销码的格式处理。以下示例测试了账单模块中 schema 对输入的修剪与空值转换逻辑,无需启动完整应用即可快速执行。

import { describe, expect, test } from "vitest";
import {
  createCustomerHubSchema,
  createLaunchSchema,
  museBillingGatewayParamSchema,
} from "@/backend/api/routes/muse-billing/types";

describe("muse-billing route schemas", () => {
  test("trims discount codes and converts blanks to undefined", () => {
    const parsed = createLaunchSchema.parse({
      planProductId: "plan_pro_monthly",
      discountCode: " SAVE20 ",
    });
    const blankParsed = createLaunchSchema.parse({
      planProductId: "plan_pro_monthly",
      discountCode: "   ",
    });

    expect(parsed.discountCode).toBe("SAVE20");
    expect(blankParsed.discountCode).toBeUndefined();
  });
});

e2e测试

使用Playwright框架测试真实浏览器环境下的公共页、认证页和受保护页面

playwright添加--ui参数进行可视化验证

{
  "scripts": {
    "test:e2e": "playwright test --config=tests/e2e/playwright.muse.config.ts",
    "test:e2e:ui": "playwright test --config=tests/e2e/playwright.muse.config.ts --ui",
  }
}

落地页可见性

对公开页面进行基础冒烟测试,验证页面能够正常打开、响应状态正常、DOM 可见。

import { expect, test } from "@playwright/test";
import { ROUTE_BOOK } from "../utils/routes";

test.describe("guest-facing route smoke", () => {
  test("home page renders", async ({ page }) => {
    const response = await page.goto(ROUTE_BOOK.home);

    expect(response?.ok()).toBeTruthy();
    await expect(page.locator("body")).toBeVisible();
  });
});

埋点测试

对于需要精确验证渲染结果的页面(例如定价页),可在前端组件中添加data-testid属性作为测试锚点,无需侵入业务逻辑,即可实现精准断言。

import { expect, test } from "@playwright/test";
import { ROUTE_BOOK } from "../utils/routes";

test.describe("guest-facing route smoke", () => {
  test("pricing page renders critical pricing UI", async ({ page }) => {
    const response = await page.goto(ROUTE_BOOK.pricing);

    expect(response?.ok()).toBeTruthy();
    await expect(page.getByTestId("pricing-section")).toBeVisible();
    await expect(
      page.locator('[data-testid^="pricing-plan-cta-"]').first(),
    ).toBeVisible();
  });
});

export function PricingSection() {

 // ··· 

  return (
    <section
      id="pricing"
      className={cn("bg-muted/40", className)}
      data-testid="pricing-section"
    >
      {/* ··· */}
    section>
  );
}

后台页面

对于需要身份验证的后台页面,测试流程分两步:首先通过辅助工具创建测试用户并完成登录,再像操作真实浏览器一样验证页面内容。后续断言逻辑与公共落地页测试保持一致,复用性强。

import { expect, test } from "@playwright/test";
import { ROUTE_BOOK } from "../utils/routes";
import { establishSession, makePasswordUser } from "../utils/session";

test.describe("entry and account shell smoke", () => {
  test("unauthenticated users are redirected to login", async ({ page }) => {
    await page.goto(ROUTE_BOOK.app);

    await expect(page).toHaveURL(/\/auth\/login/);
  });

  test("password login reaches the app home page", async ({ page }) => {
    const user = await makePasswordUser({
      prefix: "e2e-login-ui",
      role: "user",
    });

    await page.goto(ROUTE_BOOK.login);
    await page.getByTestId("login-email").fill(user.email);
    await page.getByTestId("login-password").fill(user.password);
    await page.getByTestId("login-submit").click();

    await expect(page).toHaveURL(/\/app(?:\?.*)?$/);
    await expect(page.getByTestId("app-home-ready")).toBeVisible();
  });

  test("authenticated users can open general settings", async ({ page }) => {
    const user = await makePasswordUser({
      prefix: "e2e-settings",
      role: "user",
    });

    await establishSession(page, user);
    await page.goto(ROUTE_BOOK.settingsGeneral);

    await expect(page).toHaveURL(/\/app\/settings\/general(?:\?.*)?$/);
    await expect(page.getByTestId("settings-general-page")).toBeVisible();
  });
});

以上均为带 UI 的交互式测试。去掉--ui参数后,测试将在headless模式下静默运行,适合在CI环境中使用,结果如下:

PS D:\project\___test> pnpm test:e2e
[dotenv@17.2.4] injecting env (20) from .env -- tip: 📡 add observability to secrets: https://dotenvx.com/ops

[browser cleanup] Removed 3 browser scenario user(s).

> musemvp@0.0.0 test:e2e D:\project\___test
> playwright test --config=tests/e2e/playwright.muse.config.ts

[dotenv@17.2.4] injecting env (20) from .env -- tip: 🔑 add access controls to secrets: https://dotenvx.com/ops

Running 7 tests using 1 worker

[dotenv@17.2.4] injecting env (0) from .env -- tip: ✅ audit secrets and track compliance: https://dotenvx.com/ops1 …nt-entry.smoke.spec.ts:6:7 › entry and account shell smoke › unauthenticated users are redirected to login (772ms)  ✓  2 …account-entry.smoke.spec.ts:12:7 › entry and account shell smoke › password login reaches the app home page (1.8s)ℹ [DB] runtime=node source=database_url strategy=database_url_first NODE_ENV=unknown POOL_MAX=53 …t-entry.smoke.spec.ts:27:7 › entry and account shell smoke › authenticated users can open general settings (949ms)  ✓  4 …um] › tests\e2e\journeys\admin-console.smoke.spec.ts:5:5 › admin users can access the users management page (1.2s)  ✓  5 …omium] › tests\e2e\journeys\public-routes.smoke.spec.ts:5:7 › guest-facing route smoke › home page renders (732ms)  ✓  6 …eys\public-routes.smoke.spec.ts:12:7 › guest-facing route smoke › pricing page renders critical pricing UI (789ms)  ✓  7 …] › tests\e2e\journeys\public-routes.smoke.spec.ts:22:7 › guest-facing route smoke › features page renders (694ms)

[browser cleanup] Removed 3 browser scenario user(s).

  7 passed (15.1s)

To open last HTML report run:

  pnpm exec playwright show-report test-results\browser-report

API测试

用于验证 AI chat 对话接口的权限隔离行为。以下示例测试了多用户场景下的会话归属:确保用户 A 创建的会话对用户 B 不可见,同时验证所有者自身的读取权限正常工作。

import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { purgeScenarioUsers } from "../utils/accounts";
import {
  ensureLocalAppReady,
  issueSignedInSession,
  sendContractRequest,
} from "./request-utils";

describe("conversation ownership stays isolated across member sessions", () => {
  beforeAll(async () => {
    await ensureLocalAppReady();
  });

  afterAll(async () => {
    await purgeScenarioUsers();
  });

  test("one user cannot fetch or delete another user's conversation", async () => {
    const userA = await issueSignedInSession({
      prefix: "ownership-a",
    });
    const userB = await issueSignedInSession({
      prefix: "ownership-b",
    });

    const createResponse = await sendContractRequest(
      "POST",
      "/api/aichat/conversations",
      {
        cookies: userA.cookies,
      },
    );

    expect(createResponse.status).toBe(200);

    const createdPayload = (await createResponse.json()) as {
      conversation: { id: string };
    };
    const conversationId = createdPayload.conversation.id;

    const foreignRead = await sendContractRequest(
      "GET",
      `/api/aichat/conversations/${conversationId}`,
      {
        cookies: userB.cookies,
      },
    );
    expect(foreignRead.status).toBe(404);

    const foreignDelete = await sendContractRequest(
      "DELETE",
      `/api/aichat/conversations/${conversationId}`,
      {
        cookies: userB.cookies,
      },
    );
    expect(foreignDelete.status).toBe(404);

    const ownerRead = await sendContractRequest(
      "GET",
      `/api/aichat/conversations/${conversationId}`,
      {
        cookies: userA.cookies,
      },
    );
    expect(ownerRead.status).toBe(200);
  });
});

测试是 vibe coding 稳定运行的最后一道保障。

API 测试、单元测试、e2e 测试三层覆盖,缺一不可。希望本文的实战示例能直接用进你的项目中。

MuseMVP SaaS 模板已将上述三种测试模块完整集成,开箱即用,无需从零搭建。

公众号:尼采般地抒情

我用三道防线封死了 AI 幻觉与人类健忘:Next.js 工程化免疫闭环实战

拒绝 AI 幻觉与人类健忘:Next.js 三重免疫系统实战

前端圈有个巨大的错觉:以为写好了 ESLint 规则、配好了 Prettier,代码质量就有保障了。但当你引入 Cursor、Claude 这些 AI 编程助手后,你会发现一个全新的威胁维度——AI 写代码很快,但它经常悄悄吃掉你最关键的防御逻辑。 一个 <div className="min-h-screen bg-zinc-50"> 被它换成空 Fragment <>,你的移动端背景直接消失,SEO 标签跟着蒸发。等你发现的时候,Google 爬虫已经把废墟抓走了。靠 Code Review 和人类记忆来防这种事?反人类的。必须靠系统防御。

事故现场:静悄悄的线上灾难

事情发生在一个平平无奇的周二下午。

我让 AI 帮我重构两篇新博客文章的页面组件。本地跑得很欢,pnpm dev 一切正常。部署到线上后随手用手机打开一看——背景色没了,页面高度塌陷,和之前几篇文章的风格完全不一致。

排查原因只花了一分钟:

// ❌ AI 生成的版本 — 外层是空 Fragment
return (
  <>
    <Header />
    <article>...</article>
  </>
)

// ✅ 正确版本 — 有完整容器包裹
return (
  <div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
    <Header />
    <article>...</article>
  </div>
)

AI 把最外层的 <div> 容器"优化"成了空 Fragment <>。从 JSX 语法上看完全合法,功能上也不报错。但在视觉层面,min-h-screen(最小视口高度)丢了,bg-zinc-50 dark:bg-zinc-950(亮/暗模式背景色)也丢了。移动端一打开就是白底白字或者灰底灰字的灾难现场。

这还不是最可怕的。更可怕的是——如果我当时没拿手机检查,这篇文章就会带着残缺的布局一直躺在生产环境里。 SEO 爬虫抓到的是一个没有规范链接、没有结构化数据的半成品页面。

这不是 Bug,这是一个系统性漏洞。只要还依赖人工检查,迟早会漏。唯一的解法是:把正确的事情变容易,把错误的事情变不可能。

第一道防线:源头清洗(脚手架约束)

问题的根源是什么?手动创建文件。

当你手动新建一个 page.tsx 时,你有两个选择:

  1. 从旧文件复制粘贴 → 可能复制了过时的模板,也可能漏掉新增的规范
  2. 从头手写 → 必然会忘记某些字段

无论哪种选择,都在依赖人类的短期记忆。而短期记忆是最不可靠的东西。

我的解决方案是一个 CLI 脚手架:

pnpm post:new

运行后会交互式提示输入 slug、标题、描述、关键词,然后自动生成一个满配状态page.tsx 骨架:

$ pnpm post:new

🚀 创建新文章(强制 SEO 规范)

文章 slug: triple-immune-system
文章标题: 拒绝 AI 幻觉与人类健忘
文章描述: Next.js 工程化免疫闭环实战
关键词: Next.js, SEO, 工程化, CI/CD, AI编程

✅ 文章创建成功!
📂 路径: apps/web/src/app/blog/triple-immune-system/page.tsx
👉 已自动注入 Canonical, JSON-LD, OpenGraph 等极致 SEO 标签!

生成的模板长这样(核心骨架):

import { Header } from '@/components/Header'
import type { Metadata } from 'next'
import { generateBlogPostingJsonLd } from '@/lib/jsonld'

export const metadata: Metadata = {
  title: '文章标题 - DiffServ Lab',
  description: '文章描述',
  keywords: ['关键词1', '关键词2'],
  alternates: { canonical: '/blog/slug' },          // ← 规范链接
  openGraph: {                                       // ← 社交媒体卡片
    title: '文章标题',
    description: '文章描述',
    url: 'https://diffserv.xyz/blog/slug',
    siteName: 'DiffServ Lab',
    type: 'article',
  },
  twitter: {                                        // ← Twitter Card
    card: 'summary_large_image',
    title: '文章标题',
    description: '文章描述',
  },
}

export default function BlogPost() {
  const jsonLd = generateBlogPostingJsonLd({        // ← 结构化数据
    title: '文章标题',
    description: '文章描述',
    url: 'https://diffserv.xyz/blog/slug',
    datePublished: '2026-04-15',
  })

  return (
    <div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">  // ← 容器包裹
      <script type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
      <Header />
      <article className="max-w-3xl mx-auto ... overflow-x-hidden">
        {/* 正文 */}
      </article>
    </div>
  )
}

注意这个模板一次性注入了 5 个关键防御层

防御层 作用 缺失后果
alternates.canonical 告诉搜索引擎这是原始地址 被视为重复内容,权重分散
generateBlogPostingJsonLd BlogPosting 结构化数据 Google 富文本搜索结果出不来
openGraph Facebook/Telegram 分享卡片 分享出去只有光秃秃的链接
twitter Twitter/X 分享卡片 推文没有预览图
min-h-screen + bg-* 移动端容器 高度塌陷、背景断层

核心思想:把正确的事情变容易,把错误的事情变困难。 当你运行一条命令就能拿到满配模板时,没有人会选择从头手写。

但这还不够。脚手架只能保证"新建时正确",无法防止后续修改时的退化。比如某个开发者(或 AI)觉得 Fragment 更简洁,顺手就把外层 div 删掉了。这时候需要第二道防线。

第二道防线:构建时铁闸(正则 + 结构强制拦截)

这是整套系统里最具暴力美学的一环。

我在 package.json 的 build 命令上挂了一个前置钩子:

{
  "scripts": {
    "build": "pnpm post:verify && pnpm --filter './apps/*' build"
  }
}

每次执行 pnpm build(无论是本地打包还是服务器 Docker 构建),都会先跑一遍 verify-seo.js。任何不符合规范的文章,构建直接崩溃

这个校验脚本做了什么?它扫描 blog/ 目录下所有文章的 page.tsx,做 3 类检查

SEO 完整性检查

// === SEO 检查 ===
if (!content.includes('generateBlogPostingJsonLd')) {
  errors.push('[SEO] 缺少 JSON-LD 结构化数据');
}
if (!content.includes('alternates: { canonical:')) {
  errors.push('[SEO] 缺少规范链接 (canonical)');
}
if (!content.includes('openGraph:')) {
  errors.push('[SEO] 缺少 OpenGraph 协议标签');
}

布局结构检查

// === 布局结构检查 ===
if (!content.includes('min-h-screen')) {
  errors.push('[布局] 缺少 min-h-screen 容器,移动端背景/高度会异常');
}
if (!content.includes('bg-zinc-50') || !content.includes('dark:bg-zinc-950')) {
  errors.push('[布局] 缺少 bg-zinc-50 dark:bg-zinc-950 背景色');
}

// 最狠的一条:检测外层是否是空 Fragment <>
const returnMatch = content.match(/return\s*(\s*(<[^>]+>)/);
if (returnMatch && returnMatch[1] === '<>') {
  errors.push('[布局] 外层使用了空 Fragment <>,应使用 <div>');
}

最后这条是精准打击。用正则匹配 return ( 后面的第一个 JSX 标签,如果是 <> 就直接报错。这就是导致那次移动端布局异常的元凶。

你可能会问:为什么不用 AST 解析?因为 Next.js 的构建管线已经有 TypeScript 编译器做语法校验了,我们这道防线的定位是结构约束而非语法分析。正则足够精准地捕获这个高频反模式,零依赖,插入任何项目即生效。AST 解析器引入几十个依赖包,为这一个检查杀鸡用牛刀。

执行效果

当有文章违规时,终端输出极具压迫感:

🔍 开始校验博客规范...

❌ [some-post] 规范检查未通过:
   [SEO] 缺少 JSON-LD 结构化数据 (generateBlogPostingJsonLd)
   [布局] 外层使用了空 Fragment <>,应使用 <div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">

✅ [other-post] 全部检查通过

🚫 校验失败!请修复后重试。建议使用 pnpm post:new 创建文章。

然后 process.exit(1) 直接终止进程。Docker 构建失败,CI/CD 流水线变红,部署被无情阻断。

不是 Warning,不是 Hint,是直接枪毙。在生产环境里,宽容就是对用户的残忍。

第三道防线:赛博紧箍咒(CLAUDE.md 规则引擎)

前两道防线已经很强了,但还有一个盲区:AI 编程助手。

现在的主流开发流程是"人机协同"。你用 Cursor、Claude Code、Copilot 写代码,它们很聪明,但也极其狡猾。它们会"自作主张"地帮你"优化"代码——比如把你精心设计的防御性 div 替换成一个更"简洁"的 Fragment。

ESLint 拦不住这种事,因为 Fragment 在语法上是完全合法的。TypeScript 也拦不住,因为类型推导不受影响。唯一能拦住的是告诉 AI 不要这么做

这就是 CLAUDE.md 的作用。它是项目根目录下的规则文件,所有主流 AI 编程助手在接手项目时都会优先读取它:

## 发布文章 SEO 铁律

为了保证博客网站的 SEO 永远维持在"超越 Astra"级别,
**严禁手动复制粘贴创建新文章的 `page.tsx`**1. **新建文章必须使用脚手架**:
   运行 `pnpm post:new`,脚本会自动生成包含 Canonical、
   JSON-LD (BlogPosting)、OpenGraph 和 Twitter Card 的完美骨架。

2. **构建前的自动化拦截**:
   `pnpm build` 已集成 `pnpm post:verify` 卡点。
   任何缺少核心 SEO 配置的文章都会直接阻断构建。

这看起来像是一份普通的文档,但实际上它是一个针对 AI 的 Prompt 注入攻击——正向的。

你在告诉 AI:"在这个项目里,这些是不可触碰的铁律。" AI 助手读取这份文件后,会在生成代码时主动遵守这些规则。相当于给 AI 戴了一个紧箍咒。

三道防线的协同

单独看每一道防线都有绕过的可能:

防线 能拦截 绕过方式
脚手架 新建时的错误模板 手动创建文件
构建卡点 所有已知的违规模式 不走 pnpm build 直接改服务器
CLAUDE.md AI 的自作主张 AI 忽略规则文件

但当三者组合在一起时,绕过的路径被彻底堵死:

手动创建 page.tsx → 忘记加 min-h-screen 容器
    ↓
git commit → git push → CI/CD 触发 docker build
    ↓
docker build 执行 pnpm build → 先跑 verify-seo.js
    ↓
检测到外层是 <> → process.exit(1)
    ↓
❌ 构建崩溃,部署被阻断

即使有人想绕过构建直接改服务器,CLAUDE.md 里还有另一条铁律:

禁止使用 rsync 同步代码到服务器。 所有代码变更必须通过 git push → 服务器 git pull → docker compose build 流程部署。

rsync 绕路会被 Git 历史脱节、构建产物残留等一系列问题反噬。唯一正确的路径就是那条会被 verify-seo.js 审查的路径。

这就是防御纵深(Defense in Depth) 的思想:不依赖单一检查点,而是用多层独立机制互相兜底。每一层都可能失效,但所有层同时失效的概率趋近于零。

为什么这件事值得写

你可能觉得:"这不就是加了几个检查脚本吗?有什么好写的?"

但你换个角度想:

2026 年,AI 编程助手已经成为标准配置。 Cursor 估值冲到 billions 级别,Claude Code、GitHub Copilot Workspace 成了开发者的日常工具。但同时, "AI 悄悄吃掉关键代码" 已经成为团队里最高频的隐形故障来源。

市面上的讨论几乎全停留在理念层面:"我们要 Review AI 的输出"、"我们要建立 AI Code Review 流程"。说得好听,但没有落地的工程化解法。

而我这套方案,是可以用 git clone 跑起来的完整闭环。三个文件,零外部依赖,插入任何 Next.js 项目即可生效。不需要配置 ESLint 插件,不需要买 CI/CD 付费套餐,不需要说服团队改变工作流。

真正的架构师能力,不只是写出高性能的底层代码(那是单兵作战),而是能把从代码生成、到构建拦截、再到 AI 协同的整条流水线打造得绝对防弹(Bulletproof)

前者让你成为一个优秀的工程师,后者让你成为一个能交付靠谱系统的架构师。


开源仓库 → github.com/hlng2002/ne…

三条命令接入你的项目:

# 1. 复制脚本到你的项目
cp create-post.js your-project/scripts/create-post.js
cp verify-seo.js your-project/scripts/verify-seo.js

# 2. 添加 npm scripts
# "post:new": "node scripts/create-post.js",
# "post:verify": "node scripts/verify-seo.js",
# "build": "pnpm post:verify && pnpm --filter './apps/*' build"

# 3. 写入 CLAUDE.md 铁律(见上文)

打开 Live Lab → diffserv.xyz

🥚 彩蛋:就在写这篇文章时,AI 当着我的面绕过了第一道防线

你可能觉得"AI 绕过防御"是理论推演,但它就发生在我部署这篇文章的时候。

我的 AI 助手在生成 page.tsx 时,直接手写了整个文件,完全跳过了 pnpm post:new 脚手架。理由极其嚣张:"你的脚手架是交互式的,我直接写更快。"

是的,我设计的第一个防线,被我的 AI 助手当面绕过了。

但这就是第二道防线存在的意义——就算 AI 绕过了脚手架,构建时校验依然死死守着底线。如果它手写的文件漏了 canonicalgenerateBlogPostingJsonLdpnpm build 会直接报错,部署阻断。第一道防线被破,第二道防线兜底。

这不是理论,这是实战。 三重防御不是为了防一个完美的世界,是为了在 AI 叛逆的时候,还有底线活着。

2026-04-15 · 工程化免疫闭环 · diffserv.xyz

2026 年前端技术的真实处境:从追捧到回归理性

image.png

如果你关注前端已经超过三年,可能有一个感觉:每年年初都会有人喊"今年是 XX 之年",然后年中发现什么都没发生。

2026 年的前端有一点不一样。AI 编码工具的落地速度比所有人预期的都快,但与此同时,行业也在从"追逐新概念"回归到"把已有的东西用好"。

这篇文章不写"十大趋势"。我想聊的是 2026 年上半年正在发生的几个真实变化——不是预测,而是已经能看到结果的事情。


变化一:TanStack 正在成为前端的基础设施

如果你只关注 React 生态,TanStack 这个名字在 2025-2026 年的分量已经接近当年的 Redux。

TanStack Query 解决了数据获取和缓存的问题,这已经广为人知。但 2025 年 TanStack 做了一件更重要的事:把生态扩展到了数据获取之外

TanStack Router 提供了类型安全的路由方案,支持客户端和全栈两种模式。TanStack Start 试图成为 Next.js 之外的另一个全栈 React 框架选择。TanStack Form 和 TanStack Table 分别切入了表单管理和表格渲染这两个每个项目都要做的脏活。

一个值得注意的信号是,越来越多的 React 开发者在描述自己的技术栈时,不再说"React + Redux + React Router",而是说"React + TanStack 全家桶"。

TanStack 的成功逻辑和 Redux 完全不同。Redux 是一个大而全的状态管理方案,TanStack 是一组高度解耦的工具,你可以只用 Query,也可以只用 Router,互不依赖。这种"瑞士军刀"式的模块化设计,恰好契合了 2026 年前端开发者对轻量方案的偏好。


变化二:React Compiler 的采纳速度超出预期

React Compiler 在 2025 年底发布 Stable 版本时,社区的反应是"观望"。到了 2026 年 4 月,反应变成了"真香"。

关键节点是 Next.js 在底层通过 swc 直接集成了 Compiler,开发者不需要额外配置 Babel 插件。这意味着任何使用 Next.js 15.3+ 的项目,都已经自动享受了自动 memoization 的优化。

社区反馈集中在两点。一是代码量确实减少了——删掉手动的 useMemo 和 useCallback 后,组件代码平均缩短了 20-30%。二是性能提升可感知,特别是在有大量列表渲染和复杂表单的场景下,不必要的重渲染被编译器自动消除。

但也不是没有问题。一些代码库中存在违反 React Rules 的写法(比如在渲染期间修改外部可变状态),这些在 Compiler 开启后会暴露为编译错误。团队需要花时间修复这些历史债务。

官方已经明确表示,未来部分 React 特性将必须依赖 Compiler 才能运行。如果你现在还在犹豫要不要接入,建议是:新项目直接开,老项目先在开发环境试跑看看报错。


变化三:CSS 正在重新变得重要

2026 年的前端圈有一个容易被忽视的变化:CSS 重新回到了讨论桌上。

这不是说 Tailwind 不香了。而是说,浏览器原生 CSS 能力的进步,让"手写 CSS"这件事重新变得有吸引力。

几个关键特性在 2026 年已经获得了广泛的浏览器支持:

容器查询(Container Queries)让组件可以根据父容器的尺寸自适应布局,不再依赖 JavaScript 测量。这对组件库开发者来说是一个巨大的解放——你不需要再写 resize observer 逻辑了。

层叠层(Cascade Layers)解决了 CSS 优先级管理的长期痛点。过去靠 BEM 命名规范或者 CSS Modules 来避免样式冲突,现在浏览器原生就提供了层级控制能力。

View Transitions API 让页面之间的过渡动画不再需要第三方动画库。从路由 A 到路由 B 的淡入淡出、元素共享动画,现在几行 CSS 就能搞定。

设计令牌(Design Tokens)正在从"团队内部约定"变成 CSS 自定义属性的标准实践。用 --color-primary 代替硬编码色值,配合 Tailwind 的 theme 配置,实现了设计系统和代码的自动同步。

一个有意思的趋势是:2026 年的前端开发者不再需要在"Tailwind 还是手写 CSS"之间二选一。最好的方案是两者混合——用 Tailwind 做快速开发,用原生 CSS 新特性处理组件级和页面级的精细控制。


变化四:前端安全从"后端的事"变成"你的事"

随着 React Server ComponentsServer Actions 的普及,前端开发者接触服务端逻辑的程度前所未有地深。这也带来了新的安全问题。

Server Functions 暴露了新的攻击面。如果一个 "use server" 函数没有做好输入校验和权限检查,攻击者可以直接从客户端调用它。这和传统的 API 安全不同——Server Function 的调用路径隐藏在组件代码里,更容易被忽视。

框架层面开始加入"防御性默认值"。Next.js 已经在逐步收紧 Server Actions 的权限控制,要求显式声明函数是否可被客户端调用。React 团队也在开发静态分析工具,帮助开发者在编译阶段识别不安全的服务端函数。

对前端开发者来说,2026 年需要建立的安全意识包括:

  • 每个 Server Function 都应该做输入校验,不能假设调用方是可信的
  • 敏感操作(删除、支付、权限变更)需要额外的鉴权层
  • 服务端渲染中的数据不应该包含用户隐私,除非你明确知道谁会看到

安全不再是后端团队的事。当你的前端代码可以直接调用数据库查询时,你就是那个需要负责的人。


变化五:边缘计算正在成为前端技能

边缘计算(Edge Computing) 在 2025 年还更多是后端和运维团队关注的领域。到了 2026 年,它正在成为前端开发者需要理解的基础设施概念。

Vercel Edge FunctionsCloudflare WorkersDeno Deploy 等平台让前端开发者可以用 JavaScript 或 TypeScript 在边缘节点运行代码。典型场景包括:

服务端渲染放在离用户最近的边缘节点,减少首屏延迟。A/B 测试逻辑在边缘层执行,不需要把流量送到源站再做决策。个性化内容在边缘节点动态注入,减少回源请求。

一个实际的趋势是:越来越多的前端岗位 JD 中出现了"了解边缘计算"或者"有 Serverless/Edge 部署经验"的要求。这不是说要你去写运维脚本,而是要理解"代码跑在哪里"以及"不同部署位置的延迟差异"。

对前端开发者来说,理解边缘计算的价值在于:当你知道 SSR 可以跑在全球 200+ 个节点上时,你会对架构设计有完全不同的思考。


变化六:AI 编码工具正在从"辅助"走向"主导"

2025 年我们还在讨论"AI 会不会取代程序员"。2026 年上半年的实际数据已经给出了更精细的答案:AI 没有取代程序员,但它改变了程序员的工作方式。

最新的情况是,AI 编码工具正在从"你写一行它补一行"的辅助模式,进化到"你描述需求它交付功能"的主导模式。

Cursor 的 Agent 模式可以理解整个项目上下文,自主完成多文件修改。V0.dev 可以从文字描述直接生成可运行的 React 组件。GitHub Copilot 的 workspace 感知让它能够跨文件提供上下文感知的建议。

但硬币的另一面同样重要:AI 生成代码的可维护性债务正在暴露。有团队报告称,AI 生成的代码在生产环境中出现了意料不到的边界情况,而审查这些代码所花的时间,超过了不用 AI 时手写代码的时间。

一个值得关注的 METR 研究发现:使用 AI 工具的开发者完成任务的时间比不使用的慢了 19%,但他们主观感觉自己快了 20%。这说明 AI 确实让"打字"环节变快了,但审查和调试的时间可能被低估了。

2026 年的最佳实践正在形成共识:让 AI 做它擅长的事(生成标准化代码、重构、写测试),人类做人类擅长的事(架构设计、边界情况判断、代码审查)。


变化七:微前端正在退潮

image.png 这是一个和"追捧"反向的变化。

2022-2024 年,微前端(Micro-Frontends)是一个非常热的概念。Module Federation、qiankun、single-spa 等方案层出不穷,每个技术大会都有微前端的分享。

到了 2026 年,热度明显降温了。原因有几个:

微前端引入的复杂度超过了它带来的收益。跨应用通信、样式隔离、版本管理——这些问题的解决成本很高。

元框架的成熟让单体前端应用也能做到团队级别的代码组织。Next.js 的 App Router 支持按路由分割和独立部署,很多原本需要微前端的场景用元框架就解决了。

AI 编码工具降低了单体代码库的维护成本。当 AI 能帮你快速理解和修改大型代码库时,"拆成多个小应用"的动力就没那么强了。

这不是说微前端没有价值。对于超大型组织(千人以上前端团队)的场景,拆分仍然是必要的。但对绝大多数中小型团队来说,2026 年的答案是:先把单体做好,再考虑拆分


写在最后

2026 年上半年的前端,最大的特征不是某个新技术的爆发,而是从兴奋期进入成熟期

AI 编码工具过了炒作阶段,大家在讨论怎么用好它,而不是它会不会取代你。React Compiler 过了观望阶段,社区反馈集中在实际落地经验上。TanStack 从一个好用的库变成了基础设施。CSS 原生能力的进步让"回归原生"成为合理选择。

行业的成熟意味着门槛的提高。这既是挑战,也是机会——当潮水退去,真正有价值的技能和经验才会显现。

nextjs接入AI实现流式输出

前言

这几天我在尝试实现给我的项目接入的ai实现流式输出,也是遇到不少问题;所以在这里总计一下我整个方案设计和实现的过程,希望有大佬来批评指正

技术分析

为什么要做流式

传统请求

来认识流式之前先来回顾一下传统请求的流程: 请求 → 服务端处理完 → 一次性返回 → 前端渲染

传统流程一次性返回数据,适用于普通请求;

但在 AI 场景下,这种模式完全不可行:

  • AI 生成时间长(3~10 秒)
  • 用户长时间白屏
  • 无实时反馈,体验极差

流式渲染

流式渲染完美解决 AI 场景的痛点:

请求 → 服务端边生成边返回 → 前端边收边渲染

核心思想是:让数据一边生成,一边展示,而不是等全部完成再渲染。

效果就像 ChatGPT:字是一点点蹦出来的,不是一次性出现。

实现原理

流式输出依赖两个底层技术:

1️⃣ HTTP 分块传输(Chunked)

  • 响应头:Transfer-Encoding: chunked
  • 不需要 Content-Length
  • 服务端生成一点,发送一点

2️⃣ HTTP 长连接 keep-alive

请求建立后不断开,持续推送数据直到结束。

实现流式的方式

1. SSE(Server-Sent Events)

标准、轻量的服务端主动推送方案。

  • 单向:服务器 → 客户端
  • 基于 HTTP,原生支持重连
  • 简单、轻量

缺点:单向通信、仅支持文本、不适合复杂流控

适合:AI 打字机、通知、日志实时推送

工作流程:

  1. 浏览器发请求
  2. 服务端返回 text/event-stream
  3. 持续推送 data: xxx
  4. 前端 onmessage 接收

示例:

const es = new EventSource('/api/sse');
// 接收消息
es.onmessage = (e) => {
  console.log(e.data);
};

// 错误
evtSource.onerror = (err) => {
  console.error("SSE 错误", err);
};

// 关闭
// evtSource.close()

服务端响应头

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

2. HTTP Streaming(fetch + ReadableStream)

目前 AI 对话、AI 生成 最主流、最可控的方案。

特点:

  1. 基于 fetch + ReadableStream,
  2. 双向:客户端先发请求 → 服务端流式回包
  3. 支持 POST、header、body
  4. 灵活,支持文本 / JSON / 二进制流
  5. 支持中断(AbortController)
  6. 现代浏览器全支持(兼容性好)
  7. 前端完全可控

缺点:

  • 不能自动重连
  • 代码比 SSE 稍多
  • 需要手动处理流

适用:自定义流协议、AI 流式输出

代码示例:

const res = await fetch('/api/stream');

const reader = res.body.getReader();

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  console.log(new TextDecoder().decode(value));
}

3. WebSocket(全双工通信)

更强但更复杂

特点:

  • 基于 ws 协议
  • 双向实时通信(client ↔ server)
  • 长连接、低延迟

缺点:

  • 比 SSE 重
  • 服务端需要单独支持 WS 协议
  • 无原生重连(需自己写)

适用于: 聊天、协同编辑、直播、游戏、高频交互等需要实时反馈场景

const ws = new WebSocket("ws://localhost:3000/ws");

// 连接成功
ws.onopen = () => {
  ws.send("客户端发送消息");
};

// 接收消息
ws.onmessage = (e) => {
  console.log("收到:", e.data);
};

// 关闭
ws.onclose = () => {};

nextjs内置流式

由于我的项目用的是nextjs,内置有两种不同层面的流式,感觉容易搞混,这里特意整理一下

Route Handler 流式(API 流式)

Next.js 提供的底层流能力:

  1. 创建 ReadableStream
  2. 分块返回数据
  3. 前端 fetch → 一段一段读

本质:底层传输能力,不是业务级解决方案。

// app/api/ai/route.ts
export async function GET() {
  const encoder = new TextEncoder()

  const stream = new ReadableStream({
    async start(controller) {
      controller.enqueue(encoder.encode('{"a":1}'))
      
      await sleep(1000)
      controller.enqueue(encoder.encode('{"b":2}'))

      controller.close()
    }
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'application/json'
    }
  })
}
const res = await fetch('/api/ai')

const reader = res.body.getReader()
const decoder = new TextDecoder()

let buffer = ''

while (true) {
  const { done, value } = await reader.read()
  if (done) break

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

  console.log('当前buffer:', buffer)
}

React Streaming SSR(页面流式)

这是页面加载优化,基于 <Suspense>,与今天的ai流式本质不同,无关,只做区分

用途:

  • 优化页面加载速度
  • 先出导航、骨架→ 再出慢数据
  • SSR 分段渲染 HTML
<Suspense fallback={<Loading />}>
  <SlowComponent />
</Suspense>

补充:数据格式

⚠️ 需要注意,DeepSeek 返回的并不是“纯文本流”,而是类似 SSE 的结构:

data: {"choices":[{"delta":{"content":"字段"}}]}
data: {"choices":[{"delta":{"content":"继续"}}]}
data: [DONE]

因此前端不能直接拼接 chunk,而需要:

1. 去掉 data: 前缀
2. 过滤 [DONE]
3. 逐行解析 JSON
4. 提取 delta.content 字段

否则会导致 JSON.parse 失败或数据污染。

实现

方案设计

1.方案对比

输出(后端)
- nextjs流式

虽然 Next.js 已经提供了 Route Handler 流式能力(ReadableStream),但在我的项目场景下( AI 返回 JSON 结构 → 解析 → 渲染组件 → 预览 → 确认 → 进入画布) 涉及: JSON 增量解析、 结构容错、 异常兜底、 可中断生成、 预览

直接使用存在明显问题:

  1. AI 返回的不是纯文本,而是半完整 JSON 片段

    例如:{"type": "input", "lab这时JSON.parse(chunk) // ❌ 直接报错

  2. 场景不是文本流,组件可能跨多个 chunk,无法直接渲染

    导致:UI 抖动 / 渲染错误 / 状态污染

  3. 无渲染控制,频繁 setState 导致卡顿

  4. AI 输出不稳定(核心问题)

    导致:JSON 结构错误 字段缺失 顺序不固定 甚至中途断流

    Next.js 不会帮我兜底

因此:Next.js 负责“流的传输”,但业务必须自己实现“流的消费策略”

接收
  • SSE❌

    • 只支持文本流,我是JSON(结构化数据)
    • 无法chunk 级别控制(更底层);
  • WebSocket 太重,维护复杂,不适合单向生成场景 成本高、没必要。❌

  • axios 不支持读取 response.body❌

    • 无法获取 ReadableStream,只能拿到最终完整数据。无法做流式分段读取。
  • fetch + ReadableStream✅

    • 基于 HTTP,兼容性好
    • 可以完全控制 chunk 的读取、拼接和解析逻辑
    • 可配合 AbortController 中断
    • 非常适合 AI 返回的非稳定结构化数据流
流式渲染方案
  • 组件级渲染:
    • 边解析边渲染组件,
    • 复杂、性能差、不稳定
  • 结束后一次性渲染:简单、稳定、符合项目轻量化定位

考虑到成本,复杂程度,项目轻量级;最终选择第二种:

流式仅展示生成过程,不实时渲染组件;点击预览再统一解析渲染

避免组件级流式渲染带来的复杂度和不稳定问题

2. 最终方案

根据上面总结,暂时设计出了以下方案:

选型
  1. 传输:nextjs内置输出+ (fetch + ReadableStream)
  2. 解析:TextDecoder + 安全 JSON 解析
  3. 展示:(打字机过程展示 → 结束统一渲染)
    • 流 → 仅用于“生成过程展示”
    • 最终 JSON → 一次性解析 → 渲染组件

实现功能:

  • 取消请求功能
  • json容错
  • Schema 校验
  • 数据兜底
  • 节流优化
  • 错误提示
  • ...
错误处理策略
  1. 流异常处理
    • 网络中断 → try/catch 捕获
    • 用户取消 → AbortController
  2. JSON 解析容错
    • safeParseJSON
    • 正则提取 JSON
  3. Schema 校验
    • 防止非法结构进入渲染层
  4. 默认值兜底 type: item.type || 'input'
  5. UI 兜底
    • loading 状态

    • skeleton 占位

    • 错误提示

3. 具体流程

流 → 过程展示

最终 JSON → 一次性渲染组件

用户输入 → 前端发起请求
   ↓
Next 路由转发 → AI 流式返回
   ↓
前端读取流 → 缓冲区拼接 → 打字机展示
   ↓
流结束 → 获取完整 JSON
   ↓
安全解析 → 校验 → 数据标准化
   ↓
用户预览 → 确认 → dispatch修改状态

方案实现

1. 后端实现(Next.js + DeepSeek 流式)

流程:接收前端请求 → 转发给 DeepSeek → 把 AI 的流原样传回前端

// app/api/ai/route.ts

// 系统提示词(强约束)
const systemPrompt = `你是专业的问卷生成器,必须。。。`;

export async function POST(req: Request) {
  // 1.拿到主题
  const { topic } = await req.json();

  try {
    // 2. 请求 DeepSeek 大模型,开启流式输出 stream: true
    const response = await fetch("https://api.deepseek.com/chat/completions", {
      method: "POST",
      headers: {
        Authorization: "Bearer " + process.env.DEEPSEEK_KEY, // API密钥
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        model: "deepseek-chat",
        stream: true, // ⚠️开启流式输出(关键)
        messages: [
          { role: "system", content: systemPrompt }, // 强约束格式
          { role: "user", content: `生成【${topic}】问卷JSON` },
        ],
      }),
    });

    // 3. 创建 ReadableStream,把 AI 的流 原封不动 传给前端
    const stream = new ReadableStream({
      async start(controller) {
        // 获取DeepSeek返回的流读取器
        const reader = response.body!.getReader();

        while (true) {
          // 循环读取流
          const { done, value } = await reader.read();
          if (done) break; // 流结束则退出

          // 把数据块直接塞给前端(透传,不修改)
          controller.enqueue(value);
        }

        // 流关闭
        controller.close();
      },
    });

    // 4. 返回流给前端
    // 虽然使用 fetch 流读取,但仍采用 SSE 规范的响应头,保证流式传输的兼容性和稳定性。
    return new Response(stream, {
      headers: {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
        Connection: "keep-alive",
      },
    });
  } catch (error) {
    return new Response("服务异常", { status: 500 });
  }
}

2. 前端实现(流读取 + 打字机展示)

只接收流、拼字符串、实时显示 → 不解析、不渲染

// 全局存储当前请求的控制器,用于取消生成
let currentController = null;

async function generate(topic) {
  // 1. 创建新的中断控制器,管理本次AI请求
  const controller = new AbortController();
  currentController = controller;

  try {
    // 2. 发起流式请求,绑定中断信号
    const res = await fetch('/api/ai', {
      method: 'POST',
      body: JSON.stringify({ topic }),
      signal: controller.signal,
    });

    // 3. 获取流读取器 + 文本解码器(处理中文)
    const reader = res.body.getReader();
    const decoder = new TextDecoder();

    // buffer:临时存储不完整行,防止解析报错
    let buffer = '';
    // fullText:最终拼接完整的JSON字符串
    let fullText = '';

    // 4. 循环读取流数据块
    while (true) {
      const { done, value } = await reader.read();
      if (done) break; // 流结束,退出循环

      // 5. 解码当前数据块
      const chunk = decoder.decode(value, { stream: true });
      buffer += chunk;

      // 6. 按换行符分割(SSE/大模型流式标准格式)
      const lines = buffer.split('\n');
      // 最后一行可能不完整,保留到下一轮继续拼接
      buffer = lines.pop() || '';

      // 7. 遍历每一行完整数据
      for (const line of lines) {
        // 去掉大模型返回的 data: 前缀
        const clean = line.replace(/^data:\s*/, '').trim();

        // 空行 或 结束标记 [DONE] 跳过
        if (!clean || clean === '[DONE]') continue;

        try {
          // 8. 解析流式JSON
          const parsed = JSON.parse(clean);
          // 提取AI返回的内容
          const content = parsed.choices?.[0]?.delta?.content;

          if (content) {
            // 拼接到完整文本
            fullText += content;
            // 实时更新UI → 打字机效果
            setGeneratingText(fullText);
          }
        } catch {}
      }
    }

    // 9. 流结束 → 解析最终完整JSON并渲染
    handleFinalResult(fullText);
  } catch (err) {
    console.log("请求已取消或异常:", err);
  }
}

3. JSON 解析 + 容错(防止 AI 输出异常)

由于ai输出可能包含额外文本或格式污染,因此不能直接 JSON.parse,需要增加容错解析。

// 安全解析 JSON(核心容错)
function safeParseJSON(text) {
  try {
    // 1. 直接解析(理想情况)
    return JSON.parse(text);
  } catch {
    // 2. 尝试提取最外层 JSON
    const match = text.match(/\{[\s\S]*\}/);

    if (match) {
      try {
        return JSON.parse(match[0]);
      } catch {}
    }

    // 3. 彻底失败
    return null;
  }
}

4. Schema 校验 + 数据兜底(保证页面不崩)

作用:把 AI 返回的脏数据 → 变成标准、安全的组件结构

// 校验数据格式是否合法
function validateSchema(data: any) {
  // 必须是对象
  if (!data || typeof data !== 'object') return false;
  // 必须包含 components
  if (!data.components || typeof data.components !== 'object') return false;

  return true;
}

// 数据标准化:缺什么补什么,保证页面不崩溃
function normalizeSchema(schema: any) {
  const result: any = {};

  // 遍历组件,补全默认值
  Object.entries(schema.components || {}).forEach(([id, item]: any) => {
    result[id] = {
      type: item.type || 'input',      // 缺类型 → 默认输入框
      props: item.props || {},         // 缺配置 → 空对象
      style: item.style || {},         // 缺样式 → 空对象
      children: item.children || [],   // 缺子元素 → 空数组
    };
  });

  return {
    components: result,                // 标准化后的组件
    order: schema.order || Object.keys(result), // 渲染顺序
  };
}

5.取消生成

let currentController = null;

// 取消生成
function cancelGenerate() {
  if (currentController) {
    currentController.abort();
    console.log('用户取消生成');
  }
}

6. 最终结果处理(预览 + 确认)

/**
 * 流结束后处理最终 JSON
 */
function handleFinalResult(fullText) {
  // 1. 安全解析
  const parsed = safeParseJSON(fullText);

  // 2. 格式校验
  if (!parsed || !validateSchema(parsed)) {
    console.error('AI 返回数据格式错误');
    return;
  }

  // 3. 数据标准化(兜底)
  const standardSchema = normalizeSchema(parsed);

  // 4. 存入预览状态
  setPreviewSchema(standardSchema);
}

// 点击预览
function handlePreview() {
  setIsPreview(true);
}

// 确认加入画布
function handleConfirm() {
  dispatch({
    type: 'ADD_COMPONENTS',
    payload: previewSchema,
  });
}

后续进阶方向

  • 实现增量组件流式渲染,支持批处理调度,每 3~5 个组件批量渲染
  • 增加超时/失败重试
  • 请求缓存(相同 topic 复用结果)
  • 支持生成历史、重新生成

总结

总之在此次方案设计与实现中,我没有采用组件级流式渲染,而是综合进行了工程化取舍,将流式能力用于“生成过程可视化”,最终基于完整 JSON 一次性渲染组件。

同时设计并实现了一套完整的AI输出稳定机制,包括:

  • Prompt 强约束
  • 流式数据解析
  • JSON 容错处理
  • Schema 校验与数据兜底

通过这种方式,实现了生成与渲染的解耦,在保证用户体验的同时,也降低了系统复杂度与不确定性。

学习 Redux Toolkit :从 Context 误区到 createSlice 实践

本文说明:本文是基于 Redux Toolkit 官方文档及其 maintainer 发布的博文做的整理,双语对照以防止与原文有歧义。文末有完整的原文链接可供详细学习。

希望这份整理对你有帮助。

一、开篇:Context 不是状态管理系统

“Should I use Context or should I use Redux?” “我应该用上下文还是用 Redux?”

And they seem to think that Context itself is a state management system. It’s not. 他们似乎认为 Context 本身就是一个状态管理系统。 其实不是

It’s a dependency injection mechanism, and you can put whatever value you want in Context, and most often you are the one managing that state in a React component, with the useState hook or the useReducer hook. And you’re the one deciding where the state lives, handling how to update it, and then putting the value into Context for distribution. 它是一种依赖注入机制,你可以在上下文中输入任何你想要的值,通常你会在 React 组件中管理该状态,使用 useState 钩子或 useReducer 钩子。你负责决定状态的所在位置,处理如何更新,然后把这个值放进 Context 进行分发。

So yeah, useReducer plus useContext together kind of make up a state management system. And that one is more equivalent to what Redux does with React, but Context by itself is not a state management system. 所以,是的,useReducer 加上 useContext 一起构成了一个状态管理系统。这个更类似于 Redux 对 React 的处理,但 Context 本身并不是一个状态管理系统 。

既然 Context 本身不是状态管理方案,那么 Redux Toolkit 提供了怎样的替代方案?我们先从它的 API 全景看起。

二、Redux Toolkit 工具箱里有什么?

Redux Toolkit 包含以下 API

  • configureStore(): 包装 createStore 以提供简化的配置选项和良好的默认值。它可以自动组合您的切片 reducer,添加您提供的任何 Redux 中间件,默认情况下包含 redux-thunk,并启用 Redux DevTools Extension 的使用。
  • createReducer(): 允许您提供操作类型到 case reducer 函数的查找表,而不是编写 switch 语句。此外,它会自动使用 immer,让您可以使用正常的可变代码编写更简单的不可变更新,例如 state.todos[3].completed = true
  • createAction(): 为给定的操作类型字符串生成一个操作创建器函数。
  • createSlice(): 接受 reducer 函数对象、切片名称和初始状态值,并自动生成具有相应操作创建器和操作类型的切片 reducer。
  • combineSlices(): 将多个切片组合成一个 reducer,并允许在初始化后“延迟加载”切片。
  • createAsyncThunk: 接受一个动作类型字符串和一个返回 Promise 的函数,并生成一个 thunk,根据该 Promise 分发 pending/fulfilled/rejected 动作类型。
  • createEntityAdapter: 生成一组可重用的 reducer 和 selector,用于管理存储中的规范化数据。
  • 来自 Reselect 库的 createSelector 实用程序,为了方便使用而重新导出。

注意到上面多次提到 Immer 了吗?它正是 RTK 让你能“直接修改 state”的秘密武器。

三、Immer:为什么你能直接“修改”state?

Immer(德语为:always)是一个小型包,可让您以更方便的方式使用不可变状态。

Immer 简化了不可变数据结构的处理

Immer 可以在需要使用不可变数据结构的任何上下文中使用。例如与 React state、React 或 Redux reducers 或者 configuration management 结合使用。不可变的数据结构允许(高效)的变化检测:如果对对象的引用没有改变,那么对象本身也没有改变。此外,它使克隆对象相对便宜:数据树的未更改部分不需要复制,并且在内存中与相同状态的旧版本共享

一般来说,这些好处可以通过确保您永远不会更改对象、数组或映射的任何属性来实现,而是始终创建一个更改后的副本。在实践中,这可能会导致代码编写起来非常麻烦,并且很容易意外违反这些约束。 Immer 将通过解决以下痛点来帮助您遵循不可变数据范式:

  1. Immer 将检测到意外 mutations 并抛出错误。
  2. Immer 将不再需要创建对不可变对象进行深度更新时所需的典型样板代码:如果没有 Immer,则需要在每个级别手动制作对象副本。通常通过使用大量 ... 展开操作。使用 Immer 时,会对 draft 对象进行更改,该对象会记录更改并负责创建必要的副本,而不会影响原始对象。
  3. 使用 Immer 时,您无需学习专用 API 或数据结构即可从范例中受益。使用 Immer,您将使用纯 JavaScript 数据结构,并使用众所周知的安全地可变 JavaScript API。

代码对比:不使用 Immer vs 使用 Immer

不使用 Immer

如果没有 Immer,我们将不得不小心地浅拷贝每层受我们更改影响的 state 结构

const nextState = baseState.slice() // 浅拷贝数组
nextState[1] = {
    // 替换第一层元素
    ...nextState[1], // 浅拷贝第一层元素
    done: true // 期望的更新
}
// 因为 nextState 是新拷贝的, 所以使用 push 方法是安全的,
// 但是在未来的任意时间做相同的事情会违反不变性原则并且导致 bug!
nextState.push({title: "Tweet about it"})

使用 Immer

使用 Immer,这个过程更加简单。我们可以利用 produce 函数,它将我们要更改的 state 作为第一个参数,对于第二个参数,我们传递一个名为 recipe 的函数,该函数传递一个 draft 参数,我们可以对其应用直接的 mutations。一旦 recipe 执行完成,这些 mutations 被记录并用于产生下一个状态。 produce 将负责所有必要的复制,并通过冻结数据来防止未来的意外修改。

import {produce} from "immer"

const nextState = produce(baseState, draft => {
    draft[1].done = true
    draft.push({title: "Tweet about it"})
})

Immer 核心要点总结

简单说应该就是state是不可以变的,但immer提供了state这种不可变数据的更改?

  1. 最终结果仍遵循不可变原则
    • 原始 state 不会被修改
    • 修改后产生一个全新的对象
    • 未变化的部分共享引用(结构共享)
  2. 但编写体验是“可变”的
    • 你直接对 draft 赋值:draft[1].done = true
    • 你直接 push:draft.push(...)
    • 看起来就像修改了原对象

Immer 工作原理图示

基本思想是,使用 Immer,您会将所有更改应用到临时 draft,它是 currentState 的代理。一旦你完成了所有的 mutations,Immer 将根据对 draft statemutations 生成 nextState。这意味着您可以通过简单地修改数据来与数据交互,同时保留不可变数据的所有好处。

immer-hd.png

理解了 Immer 的原理,我们来看 RTK 中最核心的抽象——Slice(切片)。

四、Slice:Redux 开发的模块化核心

一、什么是 Slice? 在 Redux Toolkit 中,Slice(切片) 是核心概念之一,用于简化 Redux 开发流程。 核心特性

  • 自动生成 Action:无需手动定义 ACTION_TYPE 常量和 action creator 函数

  • 不可变更新:内部集成 Immer 库,允许直接"修改"状态,同时保证生成新的不可变对象

  • 模块化管理:将应用状态拆分为独立模块(用户模块、酒店模块、审核模块等)

    本 Slice 专门负责管理酒店审核模块的状态逻辑,包括列表数据、筛选条件、加载状态及审核操作结果。

Slice 处理的是同步更新,那异步请求呢?RTK 专门提供了 createAsyncThunk。

五、createAsyncThunk:异步请求的标准方案

概述

一个接受 Redux action 类型字符串和回调函数的函数,该回调函数应返回一个 promise。它根据您传入的动作类型前缀生成 promise 生命周期动作类型,并返回一个 thunk 动作创建者,该创建者将运行 promise 回调并根据返回的 promise 分派生命周期动作。

本节概述了处理异步请求生命周期的标准推荐方法。

它不会生成任何 reducer 函数,因为它不知道您要获取什么数据、如何跟踪加载状态,以及如何处理返回的数据。您应该编写自己的 reducer 逻辑来处理这些操作,并使用适合您应用程序的加载状态和处理逻辑。

掌握了这些核心概念,你已经可以开始使用 RTK 了。更多细节可以参考以下资源。

六、延伸学习

createAsyncThunk | Redux Toolkit 中文

createSlice | Redux Toolkit 中文

什么时候(以及什么时候不该)入手 Redux --- When (and when not) to reach for Redux

❌