阅读视图

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

电商系统集成GenUI SDK实操指南

本文由云软件体验技术团队岑灌铭原创。

简介:本文以智能导购助手场景为例,带大家实操体验生成式UI带来的全新交互方式。

1. 集成目标

在电商前端中加入一个「AI 导购助手」,能力包含:

  • 通过 GenuiChat 展示对话与生成式 UI 内容
  • 通过 MCP 工具实时搜索商品
  • 渲染自定义商品卡片组件
  • 支持 AI 触发交互动作:加购、跳转商品详情、跳转购物车

2. 前置准备

2.1 环境要求

  • Node.js 18+
  • pnpm 10+

2.2 安装项目依赖

在仓库根目录执行:

pnpm install

2.3 启动 GenUI 后端服务

  1. 进入 server 目录,复制环境变量文件:
cd server
cp .env.example .env
  1. 编辑 server/.env,至少配置:
  • API_KEY:你的模型服务 Key
  • BASE_URL:模型服务地址(OpenAI 兼容接口)
  • PORT:服务端口(默认 3100
  1. 回到仓库根目录启动服务:
pnpm dev:server

运行成功后就可以看到控制台输出了genui-sdk-server is running on http://localhost:3100

大模型对话接口为 http://localhost:3100/chat/completions

至此,后台服务就准备完成了,下面来进行前端的改造。

3. 前端安装 GenUI 相关依赖

packages/e-commerce 中需要这些依赖:

  • @opentiny/genui-sdk-vue
  • @modelcontextprotocol/sdk
  • zod
  • openai

可执行:

pnpm -F e-commerce add @opentiny/genui-sdk-vue @modelcontextprotocol/sdk openai zod

4. 集成GenuiChat:生成式UI初体验

先做最小可体验版本:只要能看到聊天框、发送消息后,能接受到大模型返回就算成功。

4.1 新建 AI 对话助手

创建 src/components/AIAssistantDrawer.vue, 代码如下:

<script setup lang="ts">
import { computed, ref, type ComponentPublicInstance } from 'vue'
import { GenuiChat, GenuiConfigProvider } from '@opentiny/genui-sdk-vue'

const props = defineProps<{
  modelValue: boolean
}>()

const emit = defineEmits<{
  (e: 'update:modelValue', value: boolean): void
}>()

type GenuiChatExposed = ComponentPublicInstance & {
  handleNewConversation: () => void
}

const chatRef = ref<GenuiChatExposed | null>(null)
const theme = ref<'dark' | 'lite' | 'light'>('light')
const model = ref('deepseek-v3.2')
const temperature = ref(0)

const chatConfig = {
  addToolCallContext: false,
  showThinkingResult: true,
}

const chatUrl = 'http://localhost:3100/chat/completions'

function closeDrawer() {
  emit('update:modelValue', false)
}

function startNewConversation() {
  chatRef.value?.handleNewConversation()
}

</script>

<template>
  <Teleport to="body">
    <Transition name="drawer-fade">
      <div v-if="modelValue" class="assistant-layer" @click.self="closeDrawer">
        <aside class="assistant-drawer" aria-label="AI 导购助手">
          <header class="assistant-drawer__header">
            <div>
              <h2>AI 导购助手</h2>
            </div>
            <div class="assistant-drawer__actions" role="toolbar" aria-label="助手操作">
              <button type="button" class="assistant-drawer__action" @click="startNewConversation">
                新建对话
              </button>
              <button type="button" class="assistant-drawer__close" @click="closeDrawer">关闭</button>
            </div>
          </header>

          <section class="assistant-drawer__content">
            <div class="assistant-chat">
              <GenuiConfigProvider :theme="theme">
                <GenuiChat
                  ref="chatRef"
                  :url="chatUrl"
                  :model="model"
                  :temperature="temperature"
                  :chat-config="chatConfig"
                />
              </GenuiConfigProvider>
            </div>
          </section>
        </aside>
      </div>
    </Transition>
  </Teleport>
</template>

<style scoped>
.drawer-fade-enter-active,
.drawer-fade-leave-active {
  transition: opacity 0.2s ease;
}

.drawer-fade-enter-from,
.drawer-fade-leave-to {
  opacity: 0;
}

.assistant-layer {
  position: fixed;
  inset: 0;
  z-index: 75;
  background: rgba(17, 8, 38, 0.26);
  display: flex;
  justify-content: flex-end;
}

.assistant-drawer {
  width: min(600px, 95vw);
  height: 100%;
  background: #fcf9ff;
  border-left: 1px solid #e5d9ff;
  box-shadow: -12px 0 34px rgba(20, 8, 41, 0.24);
  display: flex;
  flex-direction: column;
}

.assistant-drawer__header {
  padding: 16px;
  border-bottom: 1px solid #eadfff;
  display: flex;
  justify-content: space-between;
  gap: 12px;
}

.assistant-drawer__header h2 {
  margin: 0;
  color: #20133c;
  font-size: 18px;
}

.assistant-drawer__header p {
  margin: 4px 0 0;
  color: #73668d;
  font-size: 12px;
}

.assistant-drawer__actions {
  display: flex;
  flex-shrink: 0;
  align-items: flex-start;
  gap: 8px;
}

.assistant-drawer__action,
.assistant-drawer__close {
  border: 1px solid #ddcff9;
  background: #fff;
  color: #5e4e79;
  border-radius: 8px;
  height: 32px;
  padding: 0 10px;
  cursor: pointer;
  font-size: 13px;
  white-space: nowrap;
}

.assistant-drawer__action {
  border-color: #c4b5fd;
  color: #4c1d95;
  background: #f5f3ff;
}

.assistant-drawer__action:hover {
  background: #ede9fe;
}

.assistant-drawer__content {
  min-height: 0;
  flex: 1;
}

.assistant-chat {
  height: 100%;
}

.assistant-chat :deep(.tiny-config-provider) {
  height: 100%;
}

@media (max-width: 760px) {
  .assistant-drawer {
    width: 100vw;
  }
}
</style>

添加完成后,还需要在App.vue中引入并使用。

引入使用AIAssistantDrawer

import AIAssistantDrawer from './components/AIAssistantDrawer.vue'
import { ref } from 'vue'

const assistantOpen = ref(false)

加入悬浮球,用于控制助手的隐藏显示。

    <button
      class="assistant-fab"
      type="button"
      aria-label="打开 AI 导购助手"
      @click="assistantOpen = true"
    >
      <span class="assistant-fab__dot">AI</span>
      <span class="assistant-fab__text">
        导购助手
        <small v-if="totalCount > 0">{{ totalCount }} 件待结算</small>
      </span>
    </button>

    <AIAssistantDrawer
      v-if="assistantOpen"
      v-model="assistantOpen"
    />

给悬浮球配置一下样式

.assistant-fab {
  position: fixed;
  right: 18px;
  bottom: 20px;
  z-index: 72;
  border: 0;
  border-radius: 999px;
  background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
  color: #fff;
  min-height: 52px;
  padding: 8px 14px 8px 10px;
  display: inline-flex;
  align-items: center;
  gap: 10px;
  box-shadow: 0 10px 24px rgba(77, 46, 141, 0.28);
  cursor: pointer;
}

.assistant-fab__dot {
  width: 34px;
  height: 34px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  font-size: 12px;
  font-weight: 700;
  background: rgba(255, 255, 255, 0.18);
}

.assistant-fab__text {
  display: grid;
  text-align: left;
  gap: 1px;
  font-size: 13px;
  font-weight: 700;
  line-height: 1.2;
}

.assistant-fab__text small {
  font-size: 11px;
  font-weight: 500;
  opacity: 0.88;
}

4.2 开始体验

运行前端后,点击AI助手的悬浮球,确认:

  • 侧边抽屉对话界面能打开/关闭
  • 聊天框能正常展示
  • 输入消息能体验生成式UI能力

可以尝试在输入中输入“你好呀!”,查看大模型返回。

0.png

现在生成式UI的能力已经集成到系统中,不过目前的生成式UI和系统还没有半点联系,是两个完全独立的模块。下面我们来将电商系统能力接入到生成式UI当中,使其成为一个智能导购助手。

新建一个文件夹, src/genui

5. 集成MCP:商品查询能力赋能

智能助手最重要的一个能力就是商品搜索,因此需要将电商系统中的商品搜索能力接入到智能助手中。

5.1 MCP工具开发

新建一个文件 src/genui/mcp/product-mcp.ts。我们来开发一个商品搜索的mcp服务。这里我们将业务系统中已有的商品查询能力引入,进过包装后,成功MCP工具。

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import { searchProducts } from '../../api'
import type { Product } from '../../types'

export const SEARCH_PRODUCTS_TOOL = 'search_products'

export const SearchProductsArgsSchema = z.object({
  keyword: z.string().min(1, 'keyword 不能为空'),
  limit: z.number().int().min(1).max(10).optional(),
})

export const ProductSchema = z.object({
  id: z.string(),
  title: z.string(),
  price: z.number(),
  image: z.string(),
  description: z.string(),
  tags: z.array(z.string()),
  rating: z.number(),
  ratingCount: z.number(),
  inStock: z.boolean(),
  badgeText: z.string(),
})

export const SearchProductsResultSchema = z.object({
  tool: z.literal(SEARCH_PRODUCTS_TOOL),
  keyword: z.string(),
  total: z.number().int().min(0),
  found: z.boolean(),
  results: z.array(ProductSchema),
})

export type SearchProductsArgs = z.infer<typeof SearchProductsArgsSchema>
export type SearchProductsResult = z.infer<typeof SearchProductsResultSchema>

async function searchProductsByBusiness(keyword: string, limit = 4): Promise<Product[]> {
  const results = await searchProducts(keyword)
  return results.slice(0, limit)
}

export function createProductMcpServer() {
  const server = new McpServer(
    { name: 'e-commerce-product-mcp-server', version: '1.0.0' },
    {},
  )

  server.registerTool(
    SEARCH_PRODUCTS_TOOL,
    {
      title: '搜索商品',
      description: '根据关键词在商品库中搜索商品',
      inputSchema: SearchProductsArgsSchema,
    },
    async (rawArgs) => {
      const parsedArgs = SearchProductsArgsSchema.safeParse(rawArgs)
      if (!parsedArgs.success) {
        throw new Error('参数校验失败')
      }

      const { keyword, limit = 4 } = parsedArgs.data
      const results = await searchProductsByBusiness(keyword, limit)

      const payload = SearchProductsResultSchema.parse({
        tool: SEARCH_PRODUCTS_TOOL,
        keyword,
        total: results.length,
        found: results.length > 0,
        results,
      })

      return {
        content: [{ type: 'text', text: JSON.stringify(payload) }],
      }
    },
  )

  return server
}

5.2 MCP Client开发

有了工具,还需要一个MCP Client去调用工具,下面我来编写MCP Client的代码,这里的MCP Server和MCP Client都运行在同一服务当中,因此我们选择选择使用InMemoryTransport 即可。

新建文件src/genui/mcp/mcp-client.ts

import OpenAI from 'openai'
import { Client } from '@modelcontextprotocol/sdk/client'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { createProductMcpServer } from './product-mcp'

let clientPromise: Promise<Client> | null = null

async function createClient() {
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
  const server = createProductMcpServer()
  await server.connect(serverTransport)

  const client = new Client({ name: 'e-commerce-product-mcp-client', version: '1.0.0' }, {})
  await client.connect(clientTransport)
  return client
}

export function getMcpClient() {
  if (!clientPromise) clientPromise = createClient()
  return clientPromise
}

export async function getOpenAITools() {
  const client = await getMcpClient()
  const raw = await (client as unknown as { listTools: () => Promise<{ tools?: Array<Record<string, unknown>> }> }).listTools()
  const tools = Array.isArray(raw?.tools) ? raw.tools : []
  return tools
    .filter((tool) => typeof tool?.name === 'string')
    .map(
      (tool) =>
        ({
          type: 'function',
          function: {
            name: tool.name as string,
            description: typeof tool.description === 'string' ? tool.description : '',
            parameters:
              tool.inputSchema && typeof tool.inputSchema === 'object'
                ? (tool.inputSchema as Record<string, unknown>)
                : { type: 'object', properties: {} },
          },
        }) as OpenAI.Chat.Completions.ChatCompletionTool,
    )
}

export async function callMcpToolAsText(name: string, args: Record<string, unknown> = {}) {
  const client = await getMcpClient()
  const result = await client.callTool({ name, arguments: args })
  const content = Array.isArray((result as { content?: unknown }).content)
    ? ((result as { content: Array<{ type?: string; text?: string }> }).content ?? [])
    : []
  const text = content.find((item) => item.type === 'text' && typeof item.text === 'string')?.text
  return text ?? JSON.stringify(result)
}

5.3 自定义fetch

要想将MCP能力接入到智能助手中,还需要通过自定义fetch。在自定义fetch中,去处理工具的多轮调用和工具参数的流式返回。 新建文件:src/genui/mcp/custom-fetch.ts

import OpenAI from 'openai'
import type { CustomFetch } from '@opentiny/genui-sdk-vue'
import { getOpenAITools, callMcpToolAsText } from './mcp-client'

interface OpenAIFetchConfig {
  apiKey: string
  baseURL?: string
  defaultModel?: string
  maxToolSteps?: number
}

type ParsedRequestBody = {
  model?: string
  temperature?: number
  messages?: unknown[]
}

type ToolCallDelta = {
  index?: number
  id?: string
  function?: {
    name?: string
    arguments?: string
  }
}

type ToolCall = {
  id: string
  type: 'function'
  function: {
    name: string
    arguments: string
  }
}

function encodeSseChunk(encoder: TextEncoder, data: unknown) {
  return encoder.encode(`data: ${JSON.stringify(data)}\n\n`)
}

function parseRequestBody(body: string): ParsedRequestBody {
  try {
    return JSON.parse(body) as ParsedRequestBody
  } catch {
    return {}
  }
}

function accumulateToolCalls(target: ToolCall[], deltas: ToolCallDelta[]) {
  for (const delta of deltas) {
    const index = delta.index ?? 0
    const item = (target[index] ??= {
      id: delta.id ?? '',
      type: 'function',
      function: { name: '', arguments: '' },
    })

    if (delta.id) item.id = delta.id
    if (delta.function?.name) item.function.name += delta.function.name
    if (delta.function?.arguments) item.function.arguments += delta.function.arguments
  }
}

async function executeToolCall(toolCall: ToolCall, currentMessages: unknown[]) {
  const createResult = (result: string) => {
    currentMessages.push({ role: 'tool', tool_call_id: toolCall.id, content: result })
    return {
      id: toolCall.id,
      type: 'function',
      function: {
        name: toolCall.function.name,
        arguments: toolCall.function.arguments,
        result,
      },
    }
  }

  try {
    const result = await callMcpToolAsText(toolCall.function.name, JSON.parse(toolCall.function.arguments || '{}'))
    return createResult(JSON.stringify(result))
  } catch (error) {
    return createResult(
      JSON.stringify({
        error: error instanceof Error ? error.message : '工具执行失败',
      }),
    )
  }
}

const systemPrompt = `
你是一个电商导购助手,你的任务是根据用户的需求,推荐商品。禁止使用mock数据,必须使用mcp工具获取商品数据。
你的可展示区域宽度不大,请注意你的布局。商品卡片宽度差不多占满了显示区域,请注意排版,单行只可以放一张商品卡片。
如果缺少的商品,请提示用户,让用户自行通过其他方式购买。
`

export function createMcpOpenAICustomFetch(config: OpenAIFetchConfig): CustomFetch {
  const openai = new OpenAI({
    apiKey: config.apiKey,
    baseURL: config.baseURL,
    dangerouslyAllowBrowser: true,
  })

  const maxToolSteps = config.maxToolSteps ?? 20

  return async (
    _url: string,
    options: {
      method: string
      headers: Record<string, string>
      body: string
      signal?: AbortSignal
    },
  ) => {
    const req: any = parseRequestBody(options.body)

    const encoder = new TextEncoder()

    const stream = new ReadableStream<Uint8Array>({
      async start(controller) {
        try {
          let step = 0
          const currentMessages = [{ role: 'system', content: systemPrompt }, ...req.messages]

          const tools = await getOpenAITools()

          while (step < maxToolSteps) {
            const completion = await openai.chat.completions.create(
              {
                ...req,
                messages: currentMessages as OpenAI.Chat.Completions.ChatCompletionMessageParam[],
                tools,
                tool_choice: 'auto',
                stream: true,
              },
              { signal: options.signal },
            )

            const toolCalls: ToolCall[] = []
            let hasToolCall = false
            let shouldContinue = false

            for await (const chunk of completion) {
              const choice = chunk.choices?.[0]
              if (!choice) continue

              if (choice.delta.tool_calls && choice.delta.tool_calls.length > 0) {
                hasToolCall = true
                accumulateToolCalls(toolCalls, choice.delta.tool_calls as ToolCallDelta[])
              }

              controller.enqueue(encodeSseChunk(encoder, chunk))

              if (choice.finish_reason === 'tool_calls' && toolCalls.length > 0) {
                currentMessages.push({
                  role: 'assistant',
                  content: null,
                  tool_calls: toolCalls,
                })

                const toolResults = await Promise.all(
                  toolCalls.map(async (item, index) => ({ ...(await executeToolCall(item, currentMessages)), index })),
                )

                controller.enqueue(
                  encodeSseChunk(encoder, {
                    id: chunk.id,
                    object: 'chat.completion.chunk',
                    model: chunk.model,
                    created: chunk.created || Math.floor(Date.now() / 1000),
                    choices: [
                      {
                        index: 0,
                        delta: { tool_calls_result: toolResults },
                        finish_reason: 'tool_calls',
                      },
                    ],
                  }),
                )

                shouldContinue = true
                break
              }

              if (choice.finish_reason && choice.finish_reason !== 'tool_calls') {
                shouldContinue = false
                break
              }
            }

            step += 1
            if (!hasToolCall || !shouldContinue) break
          }

          controller.enqueue(encoder.encode('data: [DONE]\n\n'))
          controller.close()
        } catch (error) {
          controller.enqueue(
            encodeSseChunk(encoder, {
              error: {
                message: error instanceof Error ? error.message : 'customFetch 处理失败',
                type: 'custom_fetch_error',
              },
            }),
          )
          controller.error(error)
        }
      },
    })

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

6.引入自定义fetch:工具调用

改造一下AIAssistantDrawer.vue, 引入并使用自定义fetch

import { createMcpOpenAICustomFetch } from '../genui/mcp/custom-fetch'

// ...省略部分代码
// 在model定义的后面创建自定义fetch
const customFetch = createMcpOpenAICustomFetch({
  apiKey: 'sk-trial',
  baseURL: 'http://localhost:3100',
  defaultModel: model.value,
  maxToolSteps: 20,
})

改造一下GenuiChat使用的地方,传入customFetch

<GenuiChat
    ref="chatRef"
    :url="chatUrl"
    :model="model"
    :temperature="temperature"
    :custom-fetch="customFetch"
    :chat-config="chatConfig"
/>

这个时候,就可以重新运行项目,然后打开导购助手,输入问题 “我想去露营!但我没经验,也没装备,预算 1500 元以内!”,体验,查看一下助手的回答。

可以看到,这里助手已经可以调用MCP工具去进行商品查询,并实现了多轮的工具调用后生成商品卡片

1.png

而且目前生成的商品卡片是由大模型随机生成的排版和样式,体验与原本电商系统中不一致。并且目前点击加入购物车也并未成功。

2.png

目前在组件与交互的体验上,仍有部分细节不够完善,整体使用感受尚未达到理想状态。接下来,我们就围绕这两部分进行优化,让组件表现与交互行为更加贴合原生系统体验。

7. 自定义组件:复刻原生系统体验,保持一致交互质感

新建文件 src/genui/chat/custom-components.ts 复用电商系统中的ProductCard.vue商品卡片。填写相关字段和定义好参数。提供给大模型进行理解,ref字段配置组件。在渲染时会渲染指定组件。

import ProductCard from '../../components/ProductCard.vue'

export const customComponents = [
  {
    component: 'ProductCard',
    name: '导购商品卡片',
    description:
      '展示推荐商品信息,单张卡片宽度是600px,请注意排版,另外组件包含onOpen和onAdd事件,请务必给对应的事件绑定对应的交互事件',
    schema: {
      properties: [
        { property: 'id', type: 'string', description: '商品 id' },
        { property: 'title', type: 'string', description: '商品标题', required: true },
        { property: 'price', type: 'number', description: '商品价格', required: true },
        { property: 'image', type: 'string', description: '商品图片 URL' },
        { property: 'description', type: 'string', description: '商品描述' },
        { property: 'tags', type: 'array', description: '标签数组' },
        { property: 'rating', type: 'number', description: '评分,0-5' },
        { property: 'ratingCount', type: 'number', description: '评分人数' },
        { property: 'inStock', type: 'boolean', description: '是否有货' },
        { property: 'badgeText', type: 'string', description: '角标文案' },
        { property: 'onOpen', type: 'function', description: '打开商品详情,必须绑定跳转商品页详情事件' },
        { property: 'onAdd', type: 'function', description: '加入购物车,必须绑定加入购物车事件' },
      ],
    },
    ref: ProductCard,
  },
]

可以看到,这里是ProductCard.vue是从原有的电商系统引入的,我们没有写一行组件代码。

同样的,在AIAssistantDrawer.vue引入并使用自定义组件

import { customComponents } from '../genui/chat/custom-components'
<GenuiChat
    ref="chatRef"
    :url="chatUrl"
    :customFetch="customFetch"
    :customComponents="customComponents"
    :model="model"
    :temperature="temperature"
    :chat-config="chatConfig"
/>

刷新页面后,重新输入刚刚的问题:“我想去露营!但我没经验,也没装备,预算 1500 元以内!” 可以看到,现在的商品卡片已经和电商系统中的保持一致了。

3.png

组件添加完毕后,我们再来添加一下对应的交互

8. 自定义交互:贴合业务场景,原生体验不割裂

src/genui/chat/custom-actions.ts 定义交互动作:

  • addToCart
  • openProduct
  • openCart
import { z } from 'zod'
import type { ICustomActionItem } from '@opentiny/genui-sdk-vue'
import type { Product } from '../../types'

const ProductActionSchema = z.object({
  id: z.string(),
  title: z.string(),
  price: z.number(),
  image: z.string().optional(),
  description: z.string().optional(),
  tags: z.array(z.string()).optional(),
  rating: z.number().optional(),
  ratingCount: z.number().optional(),
  inStock: z.boolean().optional(),
  badgeText: z.string().optional(),
})

const OpenProductSchema = z.object({
  productId: z.string(),
})

type CreateActionOptions = {
  addProduct: (product: Product) => void
  openProduct: (id: string) => void
  openCart: () => void
}

export function createCustomActions(options: CreateActionOptions) {
  return [
    {
      name: 'addToCart',
      description: '将商品加入购物车',
      parameters: {
        type: 'object',
        properties: {
          product: {
            type: 'object',
            description: '待加入购物车商品',
            properties: {
              id: { type: 'string', description: '商品 id' },
              title: { type: 'string', description: '商品标题' },
              price: { type: 'number', description: '商品价格' },
              image: { type: 'string', description: '商品图片 URL' },
              description: { type: 'string', description: '商品描述' },
              tags: { type: 'array', description: '标签数组' },
              rating: { type: 'number', description: '评分' },
              ratingCount: { type: 'number', description: '评分人数' },
              inStock: { type: 'boolean', description: '是否有货' },
              badgeText: { type: 'string', description: '角标文案' },
            },
            required: ['id', 'title', 'price'],
          },
        },
        required: ['product'],
      } as const,
      execute: (params: unknown) => {
        const parsed = z
          .object({ product: ProductActionSchema })
          .safeParse(params)
        if (!parsed.success) return
        options.addProduct(parsed.data.product as Product)
      },
    },
    {
      name: 'openProduct',
      description: '跳转到商品详情页',
      parameters: {
        type: 'object',
        properties: {
          productId: { type: 'string', description: '商品 id' },
        },
        required: ['productId'],
      } as const,
      execute: (params: unknown) => {
        const parsed = OpenProductSchema.safeParse(params)
        if (!parsed.success) return
        options.openProduct(parsed.data.productId)
      },
    },
    {
      name: 'openCart',
      description: '打开当前用户购物车页面',
      parameters: {
        type: 'object',
        properties: {},
      } as const,
      execute: () => {
        options.openCart()
      },
    },
  ] as ICustomActionItem[]
}

然后在 AIAssistantDrawer.vue 中:

  • 通过 createCustomActions(...) 注入动作
  • 把动作绑定到真实业务(购物车和路由)
import type { Product } from '../types'
import { createCustomActions } from '../genui/chat/custom-actions'
import { useCart, useCartNotice } from '../composables'
import { useRouter } from 'vue-router'

const router = useRouter()
const { addToCart } = useCart()
const { showCartNotice } = useCartNotice()

function onAddProduct(product: Product) {
  addToCart(product, 1)
  showCartNotice(product.title)
}

const customActions = computed(() =>
  createCustomActions({
    addProduct: onAddProduct,
    openProduct: (id) => {
      closeDrawer()
      router.push(`/products/${id}`)
    },
    openCart: () => {
      closeDrawer()
      router.push('/cart')
    },
  }),
)

<GenuiChat
    ref="chatRef"
    :url="chatUrl"
    :model="model"
    :temperature="temperature"
    :custom-fetch="customFetch"
    :custom-components="customComponents"
    :custom-actions="customActions"
    :chat-config="chatConfig"
/>

最后修改一下提示词,加上自定义组件和自定义相互相关的约束

你是一个电商导购助手,你的任务是根据用户的需求,推荐商品。禁止使用mock数据,必须使用mcp工具获取商品数据。推荐完商品后,最后加上加入购物按钮,并绑定方法,点击跳转到购物。
你的可展示区域宽度不大,请注意你的布局。商品卡片宽度差不多占满了显示区域,请注意排版,单行只可以放一张商品卡片。
商品卡片需要绑定加入购物车事件和打开商品详情事件,请务必给对应的事件绑定对应的交互事件, 禁止自定义方法,必须使用this.callAction中提到的方法, 例如:this.callAction('addToCart', { product: product })
如果缺少的商品,请提示用户,让用户自行通过其他方式购买。

集成了自定义组件和自定义交互后,我们再来输入问题 “我想去露营!但我没经验,也没装备,预算 1500 元以内!”,体验,查看一下助手的回答。

卡片生成完毕后,我们点击加入购物车,可以看到,成功地弹出了提示。并且购物车中也添加了对应的商品。

4.png

至此,一个完整的电商导购助手就完成了~

关于 OpenTiny NEXT

OpenTiny NEXT 是一套企业智能前端开发解决方案,以生成式 UI 和 WebMCP 两大核心技术为基础,对现有传统的 TinyVue 组件库、TinyEngine 低代码引擎等产品进行智能化升级,构建出面向 Agent 应用的前端 NEXT-SDKs、AI Extension、TinyRobot智能助手、GenUI等新产品,实现AI理解用户意图自主完成任务,加速企业应用的智能化改造。

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
GenUI SDK 代码仓库:github.com/opentiny/ge… (欢迎star ⭐)

如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~如果你有任何问题,欢迎在评论区留言交流!

从“不存在”的重复请求,聊到 Web 存储的深坑

在 Web 开发中,有些 Bug 的隐蔽性极高,它们往往藏在浏览器的底层机制和复杂的交互链路中。最近我们遇到了一个关于“邀请码重复绑定”的诡异问题,最终溯源发现,这不仅涉及前端的存储选择,还涉及浏览器对网络请求的展示逻辑。


问题背景

项目中有个典型的裂变需求:用户 A 分享链接给用户 B,B 点击链接进入站点(URL 携带邀请码),点击登录后调起微信授权,登录成功后前端调用 /invites/bind 接口完成绑定。

现象: 后端数据库出现了同一个用户被重复绑定的两条记录。 初步排查: 后端日志显示,两个 /invites/bind 请求几乎在同一秒(间隔仅几十毫秒)到达服务器。


疑点一:消失的“第二次请求”

前端最初的结论是: “后端背锅” 。理由看似非常充分:

  1. 控制台证据:Network 面板从头到尾只显示了一条 bind 请求。
  2. 逻辑自洽:前端代码对 bind 请求做了严格控制,执行前会判断 SessionStorage 中是否存在邀请码,请求发出的那一刻会立即清空该值。理论上,第二次请求根本过不了判断逻辑。

然而,后端日志是铁证。为什么控制台只显示一条请求,后端却收到了两条?

真相: 在极短的时间内(毫秒级)发送两个完全相同的请求,部分浏览器(或特定环境下的网络代理)在控制台中会将其合并显示,或者其中一个请求因为网络链路的并发特性被归类为同一条记录的重试/后续。这导致了开发者被 Network 面板“欺骗”了。


疑点二:SessionStorage 的“隐身双胞胎”

既然请求确实发了两次,那前端的 SessionStorage 清空逻辑为什么失效了?

我们重新梳理了业务链路:

  1. 用户进入页面,邀请码存入 SessionStorage
  2. 用户点击登录,页面拉起微信授权窗口
  3. 关键点:在微信环境或模拟环境下,授权过程可能涉及窗口的派生或重定向。

SessionStorage 的生命周期虽然局限于“标签页”,但有一个容易被忽视的特性:通过 window.open 或特定重定向方式打开的新页面,会复制父页面的 SessionStorage。

在这个业务场景中,由于微信授权的交互逻辑,实际上在某一瞬间存在两个“窗口上下文”。它们各自拥有独立的 SessionStorage 副本。

  • 窗口 A:触发登录逻辑,发出 bind 请求,清空了自己的邀请码。
  • 窗口 B:由于是并行触发或瞬间创建,它也持有一份邀请码,几乎在同一时间也发出了 bind 请求。

因为两个窗口是独立的执行环境,窗口 A 的清空操作无法影响到窗口 B,导致防重逻辑在多环境并发下彻底失效。


解决方案:从 SessionStorage 切换到 LocalStorage

针对这个坑,最直接的解法是将存储介质从 SessionStorage 切换为 LocalStorage

为什么有效?

  • 全局唯一性LocalStorage 在同源的所有标签页和窗口之间是共享的。
  • 同步状态:当窗口 A 发出请求并执行 localStorage.removeItem('invite_code') 时,窗口 B 在纳秒级的时间内再次读取时,该值已经不复存在。

总结

这两个 Bug 给我们的启示是:

  1. 不要盲目迷信控制台:当后端日志与前端控制台冲突时,优先信任后端原始日志,或者使用抓包工具(如 Charles/Fiddler)查看底层的 TCP/HTTP 流量,那是不会骗人的。
  2. 慎用 SessionStorage 做关键校验:在涉及多窗口、弹窗授权或重定向的复杂链路中,SessionStorage 的隔离性往往会带来意想不到的并发问题。对于需要“全局一次性”的业务标记,LocalStorage 或配合后端的分布式锁才是更稳妥的选择。

Vue 3.x 模板编译优化:静态提升、预字符串化与 Block Tree

Vue 3 在性能上的飞跃,很大程度上归功于编译时(compile-time)的深度优化。Vue 3 的编译器会尽可能多地分析模板,生成更高效的渲染函数代码。本文将从三个核心优化入手——静态提升(含静态节点缓存、静态属性提升、动态属性列表提升)、预字符串化 和 Block Tree

静态提升

静态节点缓存(CACHED /v-once)

  • 完全静态元素 → PatchFlags.CACHED 标记
  • 加入 toCache 列表 → 调用 context.cache()
  • 编译结果:生成 _cache(0)运行时缓存
  • 不是提升到 render 外(不是 _hoisted_1
<template>
  <div>
    <div class="title">show1</div>
    <div>show1</div>
  </div>
</template>
import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [...(_cache[0] || (_cache[0] = [
    _createElementVNode("div", { class: "title" }, "show1", -1 /* CACHED */),
    _createElementVNode("div", null, "show1", -1 /* CACHED */)
  ]))]))
}

静态属性提升(Hoisted Props)

触发条件:节点动态(有文本 / 子节点更新),但 props 全静态

<template>
  <div>
    <div class="title" style="color: red" id="dom" title="show">
      {{ message }}
    </div>
    <div>show1</div>
  </div>
</template>
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = {
  class: "title",
  style: {"color":"red"},
  id: "dom",
  title: "show"
}

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("div", _hoisted_1, _toDisplayString(_ctx.message), 1 /* TEXT */),
    _cache[0] || (_cache[0] = _createElementVNode("div", null, "show1", -1 /* CACHED */))
  ]))
}

动态属性列表提升(Hoisted dynamicProps)

触发条件:任何有动态绑定的节点

<template>
  <div>
    <div
      :title="title"
      :class="title"
      :style="{ color: 'red', borderWidth: borderWidth }"
      :id="dom"
      :data-dom="dom"
    >
      show1
    </div>
    <div>show1</div>
  </div>
</template>
import { normalizeClass as _normalizeClass, normalizeStyle as _normalizeStyle, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = ["title", "id", "data-dom"]

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("div", {
      title: _ctx.title,
      class: _normalizeClass(_ctx.title),
      style: _normalizeStyle({ color: 'red', borderWidth: _ctx.borderWidth }),
      id: _ctx.dom,
      "data-dom": _ctx.dom
    }, " show1 ", 14 /* CLASS, STYLE, PROPS */, _hoisted_1),
    _cache[0] || (_cache[0] = _createElementVNode("div", null, "show1", -1 /* CACHED */))
  ]))
}

第四个参数 patchFlag计算 = 2(class) + 4 (style) + 8(非class 、非style)

image.png

源码 walk

vue3-core/packages/compiler-core/src/transforms/cacheStatic.ts

function walk(
  node: ParentNode,
  parent: ParentNode | undefined,
  context: TransformContext,
  doNotHoistNode: boolean = false,
  inFor = false,
) {
  const { children } = node // 获取子节点列表
  // 收集可缓存的静态节点(最终编译为 _cache 缓存)
  const toCache: (PlainElementNode | TextCallNode)[] = []

  for (let i = 0; i < children.length; i++) {
    const child = children[i]
    // only plain elements & text calls are eligible for caching.

    // 一、普通元素节点
    // 只处理普通元素(非组件、非插槽等)
    if (
      child.type === NodeTypes.ELEMENT &&
      child.tagType === ElementTypes.ELEMENT
    ) {
      // 计算节点的常量类型
      const constantType = doNotHoistNode
        ? // 如果 doNotHoistNode 为 true(表示该节点不应被提升)
          ConstantTypes.NOT_CONSTANT
        : getConstantType(child, context)

      /**
          NOT_CONSTANT = 0, // 非常量表达式,在编译时无法确定其值
          CAN_SKIP_PATCH, // 1 可以跳过补丁的常量,通常是静态节点
          CAN_CACHE, // 2 可以缓存的常量,其值在编译时已知
          CAN_STRINGIFY, // 3 可以字符串化的常量,其值可以在编译时转换为字符串
         */
      // ============== 场景1:节点是静态节点 ==============
      if (constantType > ConstantTypes.NOT_CONSTANT) {
        if (constantType >= ConstantTypes.CAN_CACHE) {
          // 如果常量类型达到 CAN_CACHE(意味着节点极其稳定,可被 v-once 缓存)

          // 设置 patchFlag 为 PatchFlags.CACHED (即 -1,表示完全静态)
          ;(child.codegenNode as VNodeCall).patchFlag = PatchFlags.CACHED
          // 节点添加到 toCache 数组中
          toCache.push(child)
          continue
        }

        // ============== 场景2:节点非整体静态,但属性可提升 ==============
      } else {
        // node may contain dynamic children, but its props may be eligible for
        // hoisting.
        // 获取节点的生成节点
        const codegenNode = child.codegenNode!

        // 节点是VNODE_CALL 类型(虚拟节点调用)
        if (codegenNode.type === NodeTypes.VNODE_CALL) {
          const flag = codegenNode.patchFlag

          if (
            (flag === undefined || // 未标记的节点
              flag === PatchFlags.NEED_PATCH || // 需要比对的节点
              flag === PatchFlags.TEXT) && // 只有文本内容会变化的节点
            // 检查节点属性的常量类型
            getGeneratedPropsConstantType(child, context) >=
              ConstantTypes.CAN_CACHE
          ) {
            // 获取节点的属性对象
            const props = getNodeProps(child)
            if (props) {
              // 将属性对象提升到渲染函数外部
              // 更新代码生成节点
              codegenNode.props = context.hoist(props)
            }
          }
          // 动态属性列表(dynamicProps):它是编译器生成的一个静态字符串数组,仅用于记录哪些属性名是动态绑定的。
          // 这个数组本身不依赖任何响应式数据,所以可以被提升。
          if (codegenNode.dynamicProps) {
            codegenNode.dynamicProps = context.hoist(codegenNode.dynamicProps)
          }
        }
      }

      // 处理文本调用节点
    } else if (child.type === NodeTypes.TEXT_CALL) {
      const constantType = doNotHoistNode
        ? ConstantTypes.NOT_CONSTANT
        : // 计算节点的常量类型
          getConstantType(child, context)

      // 纯静态文本节点 → 加入缓存,避免重复生成文本 VNode。
      if (constantType >= ConstantTypes.CAN_CACHE) {
        if (
          // 代码生成节点类型为 JavaScript 调用表达式
          child.codegenNode.type === NodeTypes.JS_CALL_EXPRESSION &&
          child.codegenNode.arguments.length > 0 // 参数大于0
        ) {
          child.codegenNode.arguments.push(
            // 添加 PatchFlags.CACHED 标记到参数列表中
            PatchFlags.CACHED +
              (__DEV__ ? ` /* ${PatchFlagNames[PatchFlags.CACHED]} */` : ``),
          )
        }
        toCache.push(child) // 存储可缓存的节点
        continue
      }
    }

    // walk further
    // 递归处理元素节点
    // 表示元素节点,包括普通 HTML 元素和组件
    if (child.type === NodeTypes.ELEMENT) {
      const isComponent = child.tagType === ElementTypes.COMPONENT

      if (isComponent) {
        // 跟踪当前 v-slot 作用域的深度
        // 进入组件时增加计数
        context.scopes.vSlot++
      }
      walk(child, node, context, false, inFor)

      if (isComponent) {
        // 退出时减少计数
        context.scopes.vSlot--
      }

      // 递归处理 v-for 循环节点
    } else if (child.type === NodeTypes.FOR) {
      // Do not hoist v-for single child because it has to be a block
      walk(
        child,
        node,
        context,
        // 只有一个子节点,如果是则禁止提升
        // 原因:v-for 的单个子节点必须是一个块(block),因为 v-for 指令需要在 DOM 中创建和管理多个元素
        child.children.length === 1,
        true,
      )

      // 递归处理 v-if 条件判断节点
    } else if (child.type === NodeTypes.IF) {
      // 遍历 v-if 节点的所有分支,包括 if、else-if 和 else
      for (let i = 0; i < child.branches.length; i++) {
        // Do not hoist v-if single child because it has to be a block
        walk(
          child.branches[i],
          node,
          context,
          // 只有一个子节点,禁止提升
          // 原因:v-if 的单个子节点必须是一个块(block),因为 v-if 指令需要在 DOM 中创建和销毁元素
          child.branches[i].children.length === 1,
          inFor,
        )
      }
    }
  }

  let cachedAsArray = false // 缓存标识

  // 所有子节点都可缓存 并且 当前节点是元素节点
  if (toCache.length === children.length && node.type === NodeTypes.ELEMENT) {
    if (
      node.tagType === ElementTypes.ELEMENT && // 普通 HTML 元素
      node.codegenNode &&
      node.codegenNode.type === NodeTypes.VNODE_CALL && // 代码生成节点是虚拟节点调用
      isArray(node.codegenNode.children) // 节点是数组形式
    ) {
      // all children were hoisted - the entire children array is cacheable.
      // 对整个子节点数组进行缓存
      node.codegenNode.children = getCacheExpression(
        createArrayExpression(node.codegenNode.children),
      )
      cachedAsArray = true // 标记为已缓存
    } else if (
      node.tagType === ElementTypes.COMPONENT && // 组件
      node.codegenNode &&
      node.codegenNode.type === NodeTypes.VNODE_CALL && // 代码生成节点是虚拟节点调用
      node.codegenNode.children && // 子节点存在
      !isArray(node.codegenNode.children) && // 子节点不是数组形式
      // 子节点是 JavaScript 对象表达式
      node.codegenNode.children.type === NodeTypes.JS_OBJECT_EXPRESSION
    ) {
      // default slot
      // 获取名称为 'default' 的默认插槽
      const slot = getSlotNode(node.codegenNode, 'default')
      if (slot) {
        slot.returns = getCacheExpression(
          createArrayExpression(slot.returns as TemplateChildNode[]),
        )
        cachedAsArray = true // 标记已缓存
      }
    } else if (
      node.tagType === ElementTypes.TEMPLATE && // 模板
      parent &&
      parent.type === NodeTypes.ELEMENT && // 父节点是元素节点
      parent.tagType === ElementTypes.COMPONENT && // 父节点是组件
      parent.codegenNode &&
      parent.codegenNode.type === NodeTypes.VNODE_CALL && // 代码生成节点是虚拟节点调用
      parent.codegenNode.children && // 子节点存在
      !isArray(parent.codegenNode.children) && // 子节点不是数组形式
      // 子节点是 JavaScript 对象表达式
      parent.codegenNode.children.type === NodeTypes.JS_OBJECT_EXPRESSION
    ) {
      // named <template> slot
      // 获取名称为 'slot' 的插槽名称
      const slotName = findDir(node, 'slot', true)
      const slot =
        slotName &&
        slotName.arg &&
        getSlotNode(parent.codegenNode, slotName.arg)

      if (slot) {
        slot.returns = getCacheExpression(
          createArrayExpression(slot.returns as TemplateChildNode[]),
        )
        cachedAsArray = true // 标记已缓存
      }
    }
  }

  // 未标记缓存
  if (!cachedAsArray) {
    for (const child of toCache) {
      // 对每个子节点进行缓存
      child.codegenNode = context.cache(child.codegenNode!)
    }
  }

  /**
   * 缓存表达式
   * @param value 要缓存的表达式
   * @returns 缓存后的表达式
   */
  function getCacheExpression(value: JSChildNode): CacheExpression {
    // 创建缓存表达式,将传入的 value(通常是静态内容)进行缓存
    const exp = context.cache(value)
    // #6978, #7138, #7114
    // a cached children array inside v-for can caused HMR errors since
    // it might be mutated when mounting the first item
    // 问题:在 v-for 循环中使用缓存的子数组可能导致热模块替换(HMR)错误
    // 原因:当挂载第一个项目时,缓存的数组可能会被修改
    // 解决方法:通过数组展开避免直接修改原始缓存数组
    // #13221
    // fix memory leak in cached array:
    // cached vnodes get replaced by cloned ones during mountChildren,
    // which bind DOM elements. These DOM references persist after unmount,
    // preventing garbage collection. Array spread avoids mutating cached
    // array, preventing memory leaks.
    // 问题:缓存的 vnode 数组可能导致内存泄漏
    // 原因:
    // 1、在 mountChildren 期间,缓存的 vnode 会被克隆的 vnode 替换
    // 2、克隆的 vnode 会绑定 DOM 元素
    // 3、这些 DOM 引用在组件卸载后仍然存在,阻止垃圾回收
    // 解决方法:使用数组展开语法创建新数组,避免修改原始缓存数组,从而防止内存泄漏
    exp.needArraySpread = true // 设置数组展开标志
    return exp
  }

  /**
   * 获取插槽节点
   * @param node 生成代码节点
   * @param name 插槽名称
   * @returns 插槽节点
   */
  function getSlotNode(
    node: VNodeCall,
    name: string | ExpressionNode,
  ): SlotFunctionExpression | undefined {
    if (
      node.children && // 子节点存在
      !isArray(node.children) && // 子节点不是数组形式
      // 子节点是 JavaScript 对象表达式
      node.children.type === NodeTypes.JS_OBJECT_EXPRESSION
    ) {
      const slot = node.children.properties.find(
        // 属性的 key 直接等于 name
        // 属性的 key 是 SimpleExpressionNode 类型,且其 content 属性等于 name
        p => p.key === name || (p.key as SimpleExpressionNode).content === name,
      )
      // 返回其 value 属性(即插槽函数表达式)
      return slot && slot.value
    }
  }

  if (toCache.length && context.transformHoist) {
    // 静态提升
    context.transformHoist(children, context, node)
  }
}

PatchFlags 枚举

标志 (Flag) 数值 (Value) 含义说明
TEXT 1 元素文本内容是动态的(如 {{ msg }}
CLASS 1 << 1 = 2 元素的 class 绑定是动态的(如 :class="active"
STYLE 1 << 2 = 4 元素的 style 绑定是动态的(如 :style="{ color: red }"
PROPS 1 << 3 = 8 元素除 class/style 外,有其他动态属性(如 :id="userId"
FULL_PROPS 1 << 4 = 16 属性键(key)本身是动态的(如 :[propName]="value"),需要全量对比属性
HYDRATE_EVENTS 1 << 5 = 32 元素绑定了事件监听器(如 @click="handle"),主要用于服务端渲染后的“注水”(hydration)阶段
STABLE_FRAGMENT 1 << 6 = 64 片段(Fragment)的子节点顺序稳定,不会改变
KEYED_FRAGMENT 1 << 7 = 128 片段有带 key 的子节点,用于优化 v-for 列表渲染
UNKEYED_FRAGMENT 1 << 8 = 256 片段有无 key 的子节点,更新性能较差
NEED_PATCH 1 << 9 = 512 节点需要进行非属性(non-props)的补丁操作,如对 ref 或指令的处理
DYNAMIC_SLOTS 1 << 10 = 1024 组件含有动态插槽内容
DEV_ROOT_FRAGMENT 1 << 11 = 2048 仅在开发模式下,用于标记根片段
标志 (Flag) 数值 (Value) 含义说明
HOISTED -1 节点是静态的,已被提升,完全不需要参与 diff 对比
BAIL -2 渲染器应退出优化模式,进行完整的 diff 对比

预字符串化(Pre-stringification

Vue3 预字符串化(Pre-stringification) 是编译器在编译时针对大量连续静态节点的深度优化,将其直接合并为一个 HTML 字符串,大幅减少虚拟 DOM(VNode)数量与运行时开销。

触发条件

  • 节点数(NODE_COUNT):连续 ≥ 20 个纯静态节点
  • 带绑定元素数(ELEMENT_WITH_BINDING_COUNT):连续 ≥ 5 个含静态绑定(如 class="xxx")的元素

【示例】

<div>
    <p>段落1.</p>
    <p>段落2.</p>
    <p>段落3.</p>
    <p>段落4.</p>
    <p>段落5.</p>
    <p>段落6.</p>
    <p>段落7.</p>
    <p>段落8.</p>
    <p>段落9.</p>
    <p>段落10.</p>
    <p>段落11.</p>
</div>
import { createElementVNode as _createElementVNode, createStaticVNode as _createStaticVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [...(_cache[0] || (_cache[0] = [
    _createStaticVNode("<p>段落1.</p><p>段落2.</p><p>段落3.</p><p>段落4.</p><p>段落5.</p><p>段落6.</p><p>段落7.</p><p>段落8.</p><p>段落9.</p><p>段落10.</p><p>段落11.</p>", 11)
  ]))]))
}

源码 stringifyStatic

image.png

image.png

//  Vue3 预字符串化(Pre-stringification)
// 把连续的纯静态节点 → 直接编译成 HTML 字符串 → 运行时 innerHTML 插入,彻底跳过 VNode 创建、Diff、DOM 逐个生成流程
export const stringifyStatic: HoistTransform = (children, context, parent) => {
  // bail stringification for slot content
  // 插槽内容不做字符串化(插槽有作用域、动态性)
  if (context.scopes.vSlot > 0) {
    return
  }

  // 判断父节点是否已缓存
  const isParentCached =
    parent.type === NodeTypes.ELEMENT &&
    parent.codegenNode &&
    parent.codegenNode.type === NodeTypes.VNODE_CALL &&
    parent.codegenNode.children &&
    !isArray(parent.codegenNode.children) &&
    parent.codegenNode.children.type === NodeTypes.JS_CACHE_EXPRESSION

  let nc = 0 // current node count 当前连续静态节点总数量
  let ec = 0 // current element with binding count 当前带绑定的静态元素数量
  const currentChunk: StringifiableNode[] = [] // 待合并的静态节点队列

  // 执行合并
  const stringifyCurrentChunk = (currentIndex: number): number => {
    if (
      nc >= StringifyThresholds.NODE_COUNT || // 大于20
      ec >= StringifyThresholds.ELEMENT_WITH_BINDING_COUNT // 大于5
    ) {
      // combine all currently eligible nodes into a single static vnode call
      // 创建静态 VNode 调用
      const staticCall = createCallExpression(context.helper(CREATE_STATIC), [
        JSON.stringify(
          currentChunk.map(node => stringifyNode(node, context)).join(''),
        ).replace(expReplaceRE, `" + $1 + "`),
        // the 2nd argument indicates the number of DOM nodes this static vnode
        // will insert / hydrate
        String(currentChunk.length),
      ])

      const deleteCount = currentChunk.length - 1

      // 父节点已缓存:直接替换 children
      if (isParentCached) {
        // if the parent is cached, then `children` is also the value of the
        // CacheExpression. Just replace the corresponding range in the cached
        // list with staticCall.
        children.splice(
          currentIndex - currentChunk.length,
          currentChunk.length,
          // @ts-expect-error
          staticCall,
        )

        // 父节点未缓存:用第一个节点承载,删除剩下节点
      } else {
        // replace the first node's hoisted expression with the static vnode call
        ;(currentChunk[0].codegenNode as CacheExpression).value = staticCall
        if (currentChunk.length > 1) {
          // remove merged nodes from children
          children.splice(currentIndex - currentChunk.length + 1, deleteCount)
          // also adjust index for the remaining cache items
          const cacheIndex = context.cached.indexOf(
            currentChunk[currentChunk.length - 1]
              .codegenNode as CacheExpression,
          )
          if (cacheIndex > -1) {
            for (let i = cacheIndex; i < context.cached.length; i++) {
              const c = context.cached[i]
              if (c) c.index -= deleteCount
            }
            context.cached.splice(cacheIndex - deleteCount + 1, deleteCount)
          }
        }
      }
      return deleteCount
    }
    return 0
  }

  // 遍历子节点 → 收集连续静态节点 → 达到阈值就合并成 HTML 字符串 → 替换原节点
  let i = 0
  for (; i < children.length; i++) {
    const child = children[i]
    const isCached = isParentCached || getCachedNode(child)
    if (isCached) {
      // presence of cached means child must be a stringifiable node
      const result = analyzeNode(child as StringifiableNode)
      if (result) {
        // node is stringifiable, record state
        nc += result[0]
        ec += result[1]
        currentChunk.push(child as StringifiableNode)
        continue
      }
    }
    // we only reach here if we ran into a node that is not stringifiable
    // check if currently analyzed nodes meet criteria for stringification.
    // adjust iteration index
    i -= stringifyCurrentChunk(i)
    // reset state
    nc = 0
    ec = 0
    currentChunk.length = 0
  }
  // in case the last node was also stringifiable
  // 处理最后可能剩下的连续静态节点
  stringifyCurrentChunk(i)
}
function analyzeNode(node: StringifiableNode): [number, number] | false {
  // 非可字符串化标签直接返回 false
  if (node.type === NodeTypes.ELEMENT && isNonStringifiable(node.tag)) {
    return false
  }

  // v-once nodes should not be stringified
  //  如果节点有 v-once 指令,返回 false(v-once 节点不应被字符串化)
  if (node.type === NodeTypes.ELEMENT && findDir(node, 'once', true)) {
    return false
  }

  // 如果节点是文本调用节点,直接返回 [1, 0](1个节点,0个带绑定的元素)
  if (node.type === NodeTypes.TEXT_CALL) {
    // 第一个数字:节点总数
    // 第二个数字:带有绑定的元素数量
    return [1, 0]
  }

  let nc = 1 // node count 节点计数
  let ec = node.props.length > 0 ? 1 : 0 // element w/ binding count 带有绑定的元素计数
  let bailed = false // 标记是否放弃分析
  const bail = (): false => {
    bailed = true // 标记为放弃分析
    return false
  }

  // TODO: check for cases where using innerHTML will result in different
  // output compared to imperative node insertions.
  // probably only need to check for most common case
  // i.e. non-phrasing-content tags inside `<p>`
  // 分析元素节点是否可以安全地被字符串化
  function walk(node: ElementNode): boolean {
    // 特殊标签处理
    const isOptionTag = node.tag === 'option' && node.ns === Namespaces.HTML

    // 属性检查
    for (let i = 0; i < node.props.length; i++) {
      const p = node.props[i]
      // bail on non-attr bindings
      // 普通属性并且不可字符串化,调用 bail() 放弃分析
      if (
        p.type === NodeTypes.ATTRIBUTE &&
        !isStringifiableAttr(p.name, node.ns)
      ) {
        return bail()
      }
      if (p.type === NodeTypes.DIRECTIVE && p.name === 'bind') {
        // bail on non-attr bindings
        // 指令参数并且不可字符串化,调用 bail() 放弃分析
        if (
          p.arg &&
          (p.arg.type === NodeTypes.COMPOUND_EXPRESSION ||
            (p.arg.isStatic && !isStringifiableAttr(p.arg.content, node.ns)))
        ) {
          return bail()
        }

        // 指令表达式并且不可字符串化,调用 bail() 放弃分析
        if (
          p.exp &&
          (p.exp.type === NodeTypes.COMPOUND_EXPRESSION ||
            p.exp.constType < ConstantTypes.CAN_STRINGIFY)
        ) {
          return bail()
        }
        // <option :value="1"> cannot be safely stringified
        // 对于 <option> 标签的 :value 绑定,特殊处理(非静态表达式不可字符串化)
        if (
          isOptionTag &&
          isStaticArgOf(p.arg, 'value') &&
          p.exp &&
          !p.exp.isStatic
        ) {
          return bail()
        }
      }
    }
    // 子节点检查
    for (let i = 0; i < node.children.length; i++) {
      nc++
      const child = node.children[i]
      if (child.type === NodeTypes.ELEMENT) {
        if (child.props.length > 0) {
          ec++
        }
        // 递归检查子节点
        walk(child)
        if (bailed) {
          return false
        }
      }
    }
    return true
  }

  return walk(node) ? [nc, ec] : false
}

事件缓存

【示例】

  <div>
    <button @click="console.log('xxx')">click</button>
    <button @click="handleClick">点击</button>
    <button @click="() => {}">点击</button>
  </div>

未开启事件缓存 cacheHandlers 设置 false

import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = ["onClick"]
const _hoisted_2 = ["onClick"]

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("button", {
      onClick: $event => (console.log('xxx'))
    }, "click", 8 /* PROPS */, _hoisted_1),
    _createElementVNode("button", { onClick: _ctx.handleClick }, "点击", 8 /* PROPS */, _hoisted_2),
    _cache[0] || (_cache[0] = _createElementVNode("button", { onClick: () => {} }, "点击", -1 /* CACHED */))
  ]))
}

开启事件缓存 cacheHandlers 设置 true

import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("button", {
      onClick: _cache[0] || (_cache[0] = $event => (console.log('xxx')))
    }, "click"),
    _createElementVNode("button", {
      onClick: _cache[1] || (_cache[1] = (...args) => (_ctx.handleClick && _ctx.handleClick(...args)))
    }, "点击"),
    _cache[2] || (_cache[2] = _createElementVNode("button", { onClick: () => {} }, "点击", -1 /* CACHED */))
  ]))
}

【示例】

  <div>
    <button @click="handleClick(message)">click</button>
    <button @click="handleClick('xx')">click</button>
  </div>
import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("button", {
      onClick: _cache[0] || (_cache[0] = $event => (_ctx.handleClick(_ctx.message)))
    }, "click"),
    _createElementVNode("button", {
      onClick: _cache[1] || (_cache[1] = $event => (_ctx.handleClick('xx')))
    }, "click")
  ]))
}

image.png

【示例】

  <div>
    <input v-model="message" placeholder="请输入信息" />
  </div>
import { vModelText as _vModelText, createElementVNode as _createElementVNode, withDirectives as _withDirectives, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _withDirectives(_createElementVNode("input", {
      "onUpdate:modelValue": _cache[0] || (_cache[0] = $event => ((_ctx.message) = $event)),
      placeholder: "请输入信息"
    }, null, 512 /* NEED_PATCH */), [
      [_vModelText, _ctx.message]
    ])
  ]))
}

缓存的事件处理函数 $event => ((_ctx.message) = $event) 在组件整个生命周期中引用保持不变,但它却总能正确地将最新的输入值赋给响应式变量 _ctx.message

原因? 缓存的函数并没有“捕获” _ctx.message 的值,而是每次执行时动态地通过 _ctx 对象去访问 message 属性。而 _ctx 本身是一个组件实例的上下文代理对象,它在组件的整个生命周期中保持同一个引用,但其内部的属性(如 message)会随着响应式状态的变化而自动更新。

image.png

Block Tree

Block Tree 的核心理念是 将动态节点从静态节点中剥离出来,扁平化收集。它不是一个独立的运行时树形数据结构,而是编译器在模板编译阶段对 VNode 的一种标记和组织策略。

节点类型 原因说明
组件根节点 整个组件渲染的入口,天然形成一个 Block
带有 v-if / v-else / v-else-if 的节点 这些指令会导致节点的存在与否发生结构性变化,因此每个分支都会被包裹在一个独立的 Block 中
带有 v-for 的节点 列表渲染的节点结构可能会因数据变化而重排序或增删,所以会形成一个独立的 Block 来管理其动态子节点
多根节点模板(Fragment) 当模板有多个根节点时,这些根节点会被一个 Fragment 包裹,该 Fragment 节点也会成为一个 Block

【示例】

  <div>
    <p>段落</p>
    <p v-if="tag === 1">这里是header</p>
    <p v-else-if="tag === 2">这里是body</p>
    <p v-else>这里是footer</p>
  </div>
import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock, createCommentVNode as _createCommentVNode } from "vue"

const _hoisted_1 = { key: 0 }
const _hoisted_2 = { key: 1 }
const _hoisted_3 = { key: 2 }

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _cache[0] || (_cache[0] = _createElementVNode("p", null, "段落", -1 /* CACHED */)),
    (_ctx.tag === 1)
      ? (_openBlock(), _createElementBlock("p", _hoisted_1, "这里是header"))
      : (_ctx.tag === 2)
        ? (_openBlock(), _createElementBlock("p", _hoisted_2, "这里是body"))
        : (_openBlock(), _createElementBlock("p", _hoisted_3, "这里是footer"))
  ]))
}

【示例】

  <div>
    <ul>
      <li v-for="item in tag" :key="item">信息{{ item }} end</li>
    </ul>
  </div>
import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, createElementVNode as _createElementVNode } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("ul", null, [
      (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.tag, (item) => {
        return (_openBlock(), _createElementBlock("li", { key: item }, "信息" + _toDisplayString(item) + " end", 1 /* TEXT */))
      }), 128 /* KEYED_FRAGMENT */))
    ])
  ]))
}

源码

render 函数执行:借助 openBlock 和 createBlock,将编译阶段标记出的动态节点,精准地收集到 dynamicChildren 数组中。

vue3-core/packages/runtime-core/src/vnode.ts

function setupBlock(vnode: VNode) {
  // save current block children on the block vnode
  // 保存动态子节点
  // 只有当 isBlockTreeEnabled > 0 时才启用跟踪
  vnode.dynamicChildren =
    isBlockTreeEnabled > 0 ? currentBlock || (EMPTY_ARR as any) : null
  // close block
  closeBlock()
  // a block is always going to be patched, so track it as a child of its
  // parent block
  // 只有当 isBlockTreeEnabled > 0 时才启用跟踪
  if (isBlockTreeEnabled > 0 && currentBlock) {
    currentBlock.push(vnode)
  }
  return vnode
}
function createBlock(
  type: VNodeTypes | ClassComponent, // 虚拟节点的类型,可以是标签名、组件等
  props?: Record<string, any> | null, // 节点的属性对象
  children?: any, // 节点的子节点
  patchFlag?: number, // 补丁标志,用于优化更新过程
  dynamicProps?: string[], // 动态属性数组,指定哪些属性是动态的
): VNode {
  return setupBlock(
    // 创建一个基础虚拟节点
    createVNode(
      type,
      props,
      children,
      patchFlag,
      dynamicProps,
      // 表示这是一个 block 节点,用于跟踪动态子节点
      true /* isBlock: prevent a block from tracking itself */,
    ),
  )
}
function createElementBlock(
  type: string | typeof Fragment, // 字符串或 Fragment 符号,表示元素的标签名或片段
  props?: Record<string, any> | null, // 可选的属性对象,包含元素的属性、事件等
  children?: any, // 选的子节点,可以是字符串、数字、VNode 数组等
  patchFlag?: number, // 可选的补丁标志,用于优化更新过程
  dynamicProps?: string[], // 可选的动态属性数组,指定哪些属性是动态的
  shapeFlag?: number, // 可选的形状标志,表示 VNode 的类型
): VNode {
  // 将基础 VNode 转换为块节点
  return setupBlock(
    // 创建基础 VNode,设置各种属性和标志
    createBaseVNode(
      type,
      props,
      children,
      patchFlag,
      dynamicProps,
      shapeFlag,
      true /* isBlock */,
    ),
  )
}

为什么我不建议初学者一上来就用框架学 Agent

同步至个人站点:为什么我不建议初学者一上来就用框架学 Agent

前两天回校,和想做 Agent 的学弟聊了聊。我发现一个很普遍的现象:很多人一开始接触 Agent,第一反应不是先去理解 Agent 的机制,而是先找 LangChain 这类框架把东西跑起来。

我当时就表达了一个很明确的观点:我不建议初学者一上来就用框架学 Agent。

不是因为这些框架不好。恰恰相反,正是因为它们太成熟了,才更容易让初学者产生一种错觉:好像 Agent 开发就是在配配置、搭积木。

但我一直觉得,学习阶段最重要的,不是先把框架跑通,而是先弄明白这套系统到底是怎么转起来的。

一、框架隐藏了最关键的工程问题

一个最基础的 Agent,背后至少有五个绕不过去的问题。框架会帮你把它们包装起来,但对于学习者来说,真正重要的恰恰是你得亲手把这些问题碰一遍。

1. 状态怎么表示

历史消息怎么存?上下文怎么裁剪?工具调用的结果要不要回写到历史里?

很多人知道框架里有 memory,但并不知道 memory 到底在解决什么问题。

2. 决策怎么发生

模型什么时候该直接回答,什么时候该调用工具?调用失败后是重试、降级还是中断?整个 loop 什么时候停止?

这些东西决定的,其实就是 Agent 的“脑子”到底怎么运转。

3. 工具怎么设计

这是很多人最容易忽略的地方。关键从来不是“工具怎么注册”,而是工具为什么要这样设计。

比如一个查询能力,到底是给模型一个大而全的 search,还是拆成 search_user、search_order、search_ticket 这种更窄的工具?

这不是编码风格问题,而是模型调用稳定性问题。拆得太粗,模型容易乱用;拆得太细,系统复杂度又会上来。Schema 怎么定义、描述怎么写、返回值要不要结构化,这些都会直接影响调用效果。

4. loop 怎么收敛

Agent 不是写个 while True 就结束了。

最大步数是多少,什么情况该停止,什么情况说明模型已经跑偏,哪些地方要有人来兜底,这些都属于系统设计的一部分。

如果这些东西没想清楚,系统很快就会从“自主”滑向“失控”。

5. sub-agent 到底在解决什么问题

多 Agent 不是用来炫技的。

很多时候,一个 sub-agent 真正在解决的是职责隔离、权限隔离、上下文隔离,或者复杂任务拆分的问题。

如果这些问题本身都还没想清楚,就先上多 Agent 编排,最后往往只是系统形式更复杂了,但问题并没有真的被解决。

二、学习路径的错位:抽象先于理解

这件事其实很像前端和后端的学习路径。无论上层框架怎么变,真正决定你上限的,始终还是底层那些基础能力。

现在的 Agent 框架把门槛降得很低。接个模型,配几个工具,Demo 很快就能跑通。

但能跑通,不代表你真的理解了。

因为一旦进入真实业务,问题很快就会变得具体起来:

  • 工具调用是不是要做鉴权
  • 中间状态是不是要可审计
  • 模型决策错了以后怎么兜底
  • 某些失败到底该重试还是该中断

这些问题,框架可以给你一个通用外壳,但不可能替你填好具体业务里的东西。

三、我自己的做法,以及我建议的学习顺序

我自己并不是完全不用这些框架。

像 Provider、模型接入、流式输出这些已经很成熟的通用能力,如果现成库已经做得很好,我一般会直接拿来用。因为这些地方重复造轮子意义不大。

但像 tool schema、loop、sub-agent 集成这些更接近系统控制面的东西,我大多数时候还是会自己实现。不是为了炫技,也不是为了手写而手写,而是因为这些地方往往最贴近具体业务,也最容易被默认抽象限制住。

所以我的建议一直很简单:把框架当提效工具,不要把框架当学习教材。

如果你现在还在学习阶段,我更建议按这个顺序来。

第一步:先手写一个最小闭环

先别急着看复杂框架,先自己写一个最小可运行的 Agent:

  • 一个模型调用
  • 一个消息状态管理
  • 一个工具注册表
  • 一个带停止条件的 tool-calling loop
  • 一套简单的错误处理

这一阶段不求优雅,只求你能亲眼看到,一个 Agent 到底是怎么从输入任务走到决策、行动、观察、再决策的。

第二步:再逐步补能力

在这个最小闭环上,继续往上加:

  • memory
  • structured output
  • logging / tracing
  • planner / router
  • sub-agent
  • human-in-the-loop

等你把这些东西自己搭过一遍,再回头去看 LangChain、AI SDK、PydanticAI 这些框架,你就不会再把它们当成黑箱,而会更清楚:它到底帮你省了哪段路,又在哪些地方限制了你。

(完)

Web Streams 简介

在现代 Web 开发中,Web Streams API 是由 WHATWG 标准定义的,旨在为浏览器环境(以及现在的 Node.js 和 Deno)提供一套统一、高性能、可组合的流处理方式。

相比传统的 Node.js Streams,Web Streams 更具通用性,深度集成了 fetch 和异步迭代器。


1. Web Streams 的核心组件

Web Streams 主要由三个角色组成,构成了一个完整的“生产者-消费者”管道:

① ReadableStream (可读流)

数据的源头。它将底层资源(如网络请求、文件、甚至是一个计时器)封装起来。

  • 控制器 (Controller) :用于向流中推送数据(enqueue)或关闭流。
  • 读取器 (Reader) :通过 getReader() 获取,确保同一时间只有一个对象在读取数据(独占锁机制)。

② WritableStream (可写流)

数据的终点。例如将数据写入磁盘、发送到打印机或更新 DOM。

  • 写入器 (Writer) :通过 getWriter() 获取。

③ TransformStream (转换流)

中间的过滤器。它包含一个可写端和一个可读端,数据从一端进入,经过处理后从另一端流出。常用于压缩、解密或格式转换。


2. 核心原理:背压与分块

背压 (Backpressure)

Web Streams 完美内置了背压管理

  • 每个流都有一个内部队列(Buffer)。
  • 当消费者处理速度慢于生产者时,队列会积压。
  • 一旦积压达到阈值(High Water Mark),控制器会向生产者发出信号(通过 desiredSize 属性),告知其停止产生数据。

分块 (Chunking)

流中的数据被分解为一个个小单位,称为 Chunk。Chunk 既可以是字符串,也可以是 Uint8Array,甚至是自定义对象。


3. 详细代码示例

示例 A:从零创建一个 ReadableStream

这个例子模拟了一个每秒产生一个数字的流,当数字达到 5 时停止。

JavaScript

const customStream = new ReadableStream({
  start(controller) {
    let count = 0;
    const interval = setInterval(() => {
      count++;
      if (count <= 5) {
        // 向流中推送数据
        controller.enqueue(`数据块 #${count}\n`);
      } else {
        // 关闭流
        controller.close();
        clearInterval(interval);
      }
    }, 1000);
  },
  cancel() {
    // 如果消费者取消了流,在这里进行清理
    console.log("流被消费者取消了");
  }
});

// 消费流
const reader = customStream.getReader();
(async () => {
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    console.log("收到:", value);
  }
})();

示例 B:使用 TransformStream 实时转换数据

我们将一个 fetch 获取的文本流实时转换为大写,并展示在控制台。

JavaScript

// 1. 定义一个简单的转换流逻辑
const upperCaseTransformer = new TransformStream({
  transform(chunk, controller) {
    // 假设 chunk 是字符串(或者先解析为字符串)
    controller.enqueue(chunk.toUpperCase());
  }
});

async function fetchAndTransform() {
  const response = await fetch('https://example.com/data.txt');
  
  // 建立管道:原始流 -> 文本解码流 -> 大写转换流
  const transformedStream = response.body
    .pipeThrough(new TextDecoderStream())
    .pipeThrough(upperCaseTransformer);

  // 消费最终的流
  const reader = transformedStream.getReader();
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    console.log("转换结果:", value);
  }
}

4. Web Streams 与 Node.js Streams 的区别

特性 Web Streams (WHATWG) Node.js Streams
标准 Web 浏览器标准 Node.js 自有标准
核心方法 pipeThrough, pipeTo pipe
访问控制 使用 Reader/Writer 独占锁 监听 data 事件或 read()
Promise 支持 原生深度集成 早期基于事件,现代版支持 Promise
主要环境 全平台 (浏览器/Edge/Node) 主要在服务器端

5. 为什么要学习 Web Streams?

  1. 性能:数据不需要一次性全部加载到内存。处理 1GB 的文件和处理 1KB 的文件,内存占用可以保持一致。
  2. 流式 Fetch:你可以实现在图片还没下载完时就开始处理像素,或者在大型 JSON 下载时就开始解析。
  3. 标准化:编写的代码可以同时在 Chrome、Cloudflare Workers 和 Node.js 中运行。

进阶技巧:Tee (分叉)

你可以使用 readableStream.tee() 将一个流分叉成两个完全相同的流。这在需要同时将数据展示在页面上并上传到服务器时非常有用。

6. 如何定时器中处理被压?

这是一个非常深入的问题。在第一个 ReadableStream 实例中,如果你设置了一个频率极高的定时器(比如 setInterval(..., 0)),背压的实现依赖于 controller.desiredSize 属性和 底层逻辑的协作

1. 背压的核心信号灯:desiredSize

每个 Web 可读流内部都有一个队列(Internal Queue)

  • HWM (High Water Mark) :高水位线,默认通常是 1(如果以分块数量计)。
  • desiredSize:这是一个计算值,公式为:HWM - 当前队列中数据的总量

desiredSize > 0 时,表示队列还没满,可以继续产生数据。

desiredSize <= 0 时,表示队列已满,流会通过这个值向你发出“停工”信号。

2. 定时器场景下的背压实现

ReadableStream 的构造函数中,start 方法是立即执行的。如果里面有个死循环或极快的定时器,它并不会自动强行掐断你的定时器,而是需要你手动检查信号

错误写法(无视背压):

即使队列满了,定时器依然在疯狂 enqueue,这会导致内部内存暴涨。

JavaScript

const stream = new ReadableStream({
  start(controller) {
    setInterval(() => {
      // ❌ 错误:不管消费者吃不吃得消,一直往里塞
      controller.enqueue("数据块"); 
    }, 0);
  }
});

正确写法(响应背压):

你需要配合 pull 方法,或者在生产数据前检查 desiredSize

方法 A:使用 pull 方法(最标准的背压实践)

pull 方法的特性是:只要内部队列没满,浏览器就会反复调用它。如果队列满了,调用就会停止,直到消费者取走数据。

JavaScript

let count = 0;
const stream = new ReadableStream({
  // pull 会在队列未满时自动被重复调用
  pull(controller) {
    count++;
    controller.enqueue(`数据 #${count}`);
    
    // 如果达到了某种条件,可以关闭
    if (count >= 1000) controller.close();
  }
});

方法 B:在定时器中检查 desiredSize

如果你必须使用定时器,你应该通过判断 desiredSize 来决定是否跳过当前节拍。

JavaScript

const stream = new ReadableStream({
  start(controller) {
    const interval = setInterval(() => {
      // ✅ 检查背压信号
      // 如果 desiredSize <= 0,说明消费者还没处理完,我们先不 enqueue
      if (controller.desiredSize > 0) {
        controller.enqueue("数据");
      } else {
        console.log("背压触发:队列已满,跳过本次生产");
      }
    }, 1);
  }
});

3. 底层原理:当 enqueue 发生时

  1. 当你调用 controller.enqueue() 时,数据进入流的内部队列
  2. 如果消费者(Reader)调用了 read(),数据会立即被派发给消费者,队列清空。
  3. 如果消费者处理极慢,数据会堆积在队列里,直到达到 HighWaterMark
  4. 此时 desiredSize 变成 0 或负数。
  5. 核心点:Web Streams 机制本身不具备“物理暂停”一个正在运行的 JavaScript 定时器的能力。它只是提供状态接口。背压的成功取决于生产者是否“自律”地去观察 desiredSize

4. 总结

在定时器频率极高的情况下:

  • 流不会主动杀掉你的定时器
  • 你必须利用 pull 钩子(由引擎根据背压自动调度)或者在 start 的逻辑中轮询 controller.desiredSize
  • 如果你的代码无视 desiredSize 强制 enqueue,背压机制就会失效,程序会退化为普通的内存堆积模式。

这正是 Web Streams 设计的精妙之处:它将“何时生产数据”的决定权,通过 desiredSize 信号交还给了生产者。

7. 实现一个迷你版 Web Streams(ReadableStream)

下面我们从 0 到 1 手写一个“迷你版 Web Streams(ReadableStream)” ,专注把 pull 模型 + 背压吃透。代码会刻意简化,但关键机制都会保留。


🧠 目标(我们要实现什么)

我们要得到这样一套 API:

const stream = new MiniReadableStream({
  pull(controller) {
    controller.enqueue(Math.random());
  }
});

const reader = stream.getReader();

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

并满足:

  • 消费者驱动(只有 read() 才触发 pull
  • 队列 + 高水位(highWaterMark)
  • 背压(不需要就不生产)
  • ✅ Promise 风格(而不是事件)

⚙️ 一、核心结构设计

我们拆成 3 个角色:

角色 职责
MiniReadableStream 流本体
Reader 消费数据
Controller 生产数据

🧱 二、第一版:最小可用实现(无背压)

class MiniReadableStream {
  constructor(underlyingSource) {
    this.queue = [];
    this.closed = false;

    this.controller = new Controller(this);

    this.underlyingSource = underlyingSource;
  }

  getReader() {
    return new Reader(this);
  }
}

class Reader {
  constructor(stream) {
    this.stream = stream;
  }

  async read() {
    const stream = this.stream;

    // 有数据直接返回
    if (stream.queue.length > 0) {
      return { value: stream.queue.shift(), done: false };
    }

    // 已结束
    if (stream.closed) {
      return { value: undefined, done: true };
    }

    // 👉 关键:触发 pull
    await stream.underlyingSource.pull(stream.controller);

    // 再试一次
    if (stream.queue.length > 0) {
      return { value: stream.queue.shift(), done: false };
    }

    return { value: undefined, done: true };
  }
}

class Controller {
  constructor(stream) {
    this.stream = stream;
  }

  enqueue(chunk) {
    this.stream.queue.push(chunk);
  }

  close() {
    this.stream.closed = true;
  }
}

🧠 这一版已经体现了核心思想

read() → 触发 pull() → enqueue() → 返回数据

👉 消费者驱动生产(pull)


🚰 三、加入背压(highWaterMark)

现在加入真正关键的部分:


🧩 设计目标

queue.length >= highWaterMark
→ 不再调用 pull

✨ 改进版实现

class MiniReadableStream {
  constructor(underlyingSource, strategy = {}) {
    this.queue = [];
    this.closed = false;

    this.highWaterMark = strategy.highWaterMark ?? 1;

    this.controller = new Controller(this);
    this.underlyingSource = underlyingSource;
  }

  getReader() {
    return new Reader(this);
  }
}

class Reader {
  constructor(stream) {
    this.stream = stream;
  }

  async read() {
    const stream = this.stream;

    // 有数据直接返回
    if (stream.queue.length > 0) {
      return { value: stream.queue.shift(), done: false };
    }

    if (stream.closed) {
      return { value: undefined, done: true };
    }

    // 👉 背压核心:只有在“需要数据”时才 pull
    if (stream.queue.length < stream.highWaterMark) {
      await stream.underlyingSource.pull(stream.controller);
    }

    if (stream.queue.length > 0) {
      return { value: stream.queue.shift(), done: false };
    }

    return { value: undefined, done: true };
  }
}

class Controller {
  constructor(stream) {
    this.stream = stream;
  }

  enqueue(chunk) {
    if (this.stream.queue.length >= this.stream.highWaterMark) {
      return; // 🚫 背压:拒绝过量生产
    }

    this.stream.queue.push(chunk);
  }

  close() {
    this.stream.closed = true;
  }
}

🔥 四、验证:真正的 pull 行为

const stream = new MiniReadableStream({
  async pull(controller) {
    console.log('👉 被调用(生产数据)');
    controller.enqueue(Math.random());
  }
}, { highWaterMark: 1 });

const reader = stream.getReader();

(async () => {
  console.log(await reader.read());
  console.log(await reader.read());
})();

输出特点

👉 被调用
👉 被调用

👉 每次 read() 才触发一次生产


⚖️ 五、对比 Node Stream(关键顿悟)

❌ Node(本质 push)

数据先产生 → 放 buffer → 你再读

✅ 我们的 mini Web Stream

你要数据 → 才触发生产

👉 这就是 pull 模型的本质


🧠 六、再进化:支持“等待数据”(Promise 队列)

当前实现有个问题:

👉 如果 pull 是异步慢操作,会丢节奏


✨ 加入 pending read

class MiniReadableStream {
  constructor(source, strategy = {}) {
    this.queue = [];
    this.pendingReads = [];
    this.closed = false;

    this.highWaterMark = strategy.highWaterMark ?? 1;

    this.controller = new Controller(this);
    this.source = source;
  }

  getReader() {
    return new Reader(this);
  }
}

class Reader {
  constructor(stream) {
    this.stream = stream;
  }

  read() {
    const stream = this.stream;

    if (stream.queue.length > 0) {
      return Promise.resolve({
        value: stream.queue.shift(),
        done: false
      });
    }

    if (stream.closed) {
      return Promise.resolve({ value: undefined, done: true });
    }

    return new Promise((resolve) => {
      stream.pendingReads.push(resolve);

      // 👉 触发 pull
      if (stream.queue.length < stream.highWaterMark) {
        stream.source.pull(stream.controller);
      }
    });
  }
}

class Controller {
  constructor(stream) {
    this.stream = stream;
  }

  enqueue(chunk) {
    const stream = this.stream;

    if (stream.pendingReads.length > 0) {
      const resolve = stream.pendingReads.shift();
      resolve({ value: chunk, done: false });
    } else {
      if (stream.queue.length < stream.highWaterMark) {
        stream.queue.push(chunk);
      }
    }
  }

  close() {
    const stream = this.stream;
    stream.closed = true;

    while (stream.pendingReads.length) {
      stream.pendingReads.shift()({ value: undefined, done: true });
    }
  }
}

🧩 七、这个版本已经非常接近 Web Streams

你已经实现了:

  • ✅ pull 驱动
  • ✅ 背压(highWaterMark)
  • ✅ Promise 队列
  • ✅ reader / controller 分离
  • ✅ “要数据才生产”

🎯 八、终极理解(最重要)

🔥 Web Streams 核心不是 API

而是这个模型:

consumer → read() → pull() → enqueue()

🔥 和 Node 最大差异

Node:生产者决定节奏(push)
Web:消费者决定节奏(pull)

🧠 九、一句话总结

Web Streams 的本质是通过 read() 驱动 pull(),结合受控队列和 highWaterMark,实现由消费者主导的数据生产模型,从根本上解决背压问题。

React性能优化:从“卡成狗”到“丝般顺滑”的5个秘诀

前言

React已经很快了,但如果你不注意细节,它会做很多“无用功”:组件没必要的重渲染、大列表全量渲染、状态更新导致整个页面刷新……这些问题累积起来,再好的电脑也扛不住。

今天我们不聊虚拟DOM原理,直接上代码、上工具,告诉你哪些写法是“性能杀手”,哪些是“救星”。优化完,你的React应用会快得让用户怀疑是不是装了外挂。

一、第1招:用React.memo避免“父动子也动”

React默认:父组件更新,所有子组件都会重新渲染(即使props没变)。这会导致大量浪费。

差代码

const Child = ({ name }) => {
  console.log('Child渲染了');
  return <div>{name}</div>;
};

const Parent = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(c => c+1)}>点击 {count}</button>
      <Child name="张三" />
    </div>
  );
};

每次点击按钮,Child都会重新渲染,但它的name根本没变。

好代码:用React.memo包装子组件。

const Child = React.memo(({ name }) => {
  console.log('Child渲染了');
  return <div>{name}</div>;
});

现在只有name变化时,Child才会重新渲染。

注意:如果Child接收的props包含函数或对象,需要配合useCallbackuseMemo(见第2招)。

二、第2招:用useCallbackuseMemo缓存函数和值

在React组件里,每次渲染都会重新创建所有内联函数和对象。即使内容相同,引用也不同,导致React.memo失效。

差代码

const Parent = () => {
  const handleClick = () => console.log('clicked'); // 每次渲染都是新函数
  return <Child onClick={handleClick} />;
};

好代码:用useCallback缓存函数。

const Parent = () => {
  const handleClick = useCallback(() => console.log('clicked'), []); // 依赖为空,永远不变
  return <Child onClick={handleClick} />;
};

对于复杂计算的值(比如过滤大列表),用useMemo缓存结果:

const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);

三、第3招:虚拟列表,渲染一万条也不怕

直接渲染长列表(比如聊天记录、商品列表)会导致浏览器创建上万个DOM节点,内存爆炸,滚动卡顿。虚拟列表只渲染可视区域内的几条,滚动时动态替换。

不用自己造轮子,用现成库

  • react-window(轻量)
  • react-virtualized(功能全)
import { FixedSizeList as List } from 'react-window';

const Row = ({ index, style }) => (
  <div style={style}>行 {index}</div>
);

<List
  height={400}
  itemCount={10000}
  itemSize={35}
  width={300}
>
  {Row}
</List>

一秒渲染一万条,滚动丝滑。

四、第4招:代码分割 + 懒加载,别一次加载所有组件

你的用户访问首页,结果你把后台管理、用户设置、订单详情所有页面的代码都下载了。浪费流量,也拖慢首屏。

React.lazy + Suspense

const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));

function App() {
  return (
    <Suspense fallback={<div>加载中...</div>}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

每个路由的代码单独打包,只有访问时才加载。

五、第5招:避免内联对象和函数传递

即使不用memo,内联对象也会导致子组件每次接收新对象,触发重渲染。

差代码

<Child style={{ color: 'red' }} />  // 每次渲染都是新对象

好代码:把对象提取到组件外部或使用useMemo

const childStyle = { color: 'red' }; // 外部定义,引用不变
<Child style={childStyle} />

六、额外绝招:使用useTransition标记非紧急更新

React 18引入了useTransition,可以把某些更新标记为“低优先级”,让高优先级交互(如输入框打字)更流畅。

const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState('');
const [list, setList] = useState([]);

const handleChange = (e) => {
  const value = e.target.value;
  setQuery(value); // 紧急更新:更新输入框
  startTransition(() => {
    // 非紧急更新:过滤大列表
    const filtered = hugeList.filter(item => item.includes(value));
    setList(filtered);
  });
};

这样打字不会卡,列表过滤稍后完成。

七、工具检测:React DevTools Profiler

安装React DevTools,打开Profiler标签,录制一段操作,可以看到每个组件渲染耗时。颜色越黄/红,越需要优化。

八、总结:优化口诀

  • 子组件纯展示,包上React.memo
  • 函数和对象,用useCallbackuseMemo缓存。
  • 长列表用虚拟滚动。
  • 路由懒加载,按需取。
  • 内联对象移出去,引用不变。
  • 紧急更新与非紧急分开,用useTransition

优化完,你的React应用会快得飞起。用户会惊叹:“这网站怎么比原生App还流畅?”

React 19 源码怎么读:createRoot 和 root.render 到底做了什么?

这是我持续更新的一组 React 源码解读文章,也会尽量控制单篇篇幅,按主线一点点往里拆。
这一篇先不急着往 Fiber 内部结构和 render 细节里走,而是先把 React 主线里的“系统入口”这一段补上。

前言

上一篇里,我已经把 React 主线最前面那一段补了出来:

JSX → ReactElement

也就是说,React 运行时真正接收到的第一层核心对象,不是 JSX 语法本身,而是 ReactElement。

但如果只停在这里,后面很多问题其实还是悬着的。

比如:

  • React 拿到 ReactElement 之后,下一步先去哪了?
  • createRoot(container) 到底创建了什么?
  • root.render(element) 到底是在“渲染”,还是在“发起一次更新”?
  • ReactElement 是在这里立刻变成 Fiber 了吗?

这些问题继续往下追,主线就会自然推进到 React 的系统入口层。

所以这一篇不急着进入 Fiber 内部结构,也不急着进入 render / commit,而是先把这一段补清楚:

ReactElement 是怎么进入根级更新系统的?

这篇文章主要想回答几个问题:

  • createRoot(container) 返回的 root 到底是什么
  • createRoot 初始化了哪些关键对象
  • ReactDOMRootFiberRootHostRoot Fiber 分别是什么关系
  • root.render(element) 到底做了什么
  • ReactElement 在这里是怎么进入根级更新系统的

这里也先说明一下版本口径:这篇文章标题写的是 React 19,因为整体讨论的是 React 19 的主线机制;但在具体源码观察上,我会先以 React 19.1.1 作为基线来展开。


一、先说结论:createRoot 负责初始化根容器,root.render 负责发起一次根更新

先把这一篇最核心的结论放在前面:

  1. createRoot(container) 不是立刻把组件渲染到页面
  2. 它更像是在初始化一个 React 根容器
  3. 真正把 ReactElement 送进系统的是 root.render(element),而这一步本质上是在发起一次根更新

平时写 React 时,我们很容易把这两步看成一整句:

const root = createRoot(container)
root.render(<App />)

表面上看,这像是一段连续的“开始渲染”代码。
但如果从源码视角往里看,会发现这两步分工非常明确:

  • createRoot(container) 负责把根容器搭起来
  • root.render(element) 负责把新的 UI 描述送进根级更新系统

换句话说,这一篇真正想补上的,不是“页面怎么显示出来”,而是:

React 在真正开始 render 之前,入口层到底先做了什么。

这里我先提前立住一句全篇最重要的边界话:

到这里为止,ReactElement 先进入的是根级更新系统,还没有真正展开成后面 render 阶段里的 Fiber 子树。

flowchart TB
    A["createRoot(container)"] --> B["初始化根容器"]
    B --> C["root.render(element)"]
    C --> D["发起一次根更新"]

二、createRoot(container) 返回的 root 到底是什么

继续往下看时,一个很自然的问题就是:

createRoot(container) 返回的这个 root,到底是什么?

如果只看业务代码,我们拿到的是这样一个对象:

const root = createRoot(container)

但这里的 root,并不是 React 内部真正维护的 FiberRoot
更准确地说,它是一个对外暴露的 API 层 root 句柄,也就是 ReactDOMRoot

这一层先分清很重要,因为它能帮我们把两种 root 区分开。

1. 业务代码拿到的 root

这是 ReactDOMRoot
它更像 React DOM 暴露给外部使用的句柄,后面调用 root.render(...)root.unmount() 也是通过它发起的。

2. React 内部真正维护的 root

这一层才是后面真正贯穿运行时系统的根对象,也就是 FiberRoot

两者之间的连接方式并不复杂:

  • ReactDOMRoot 对外暴露方法
  • 它内部通过 _internalRoot 持有真正的 FiberRoot

所以从这里开始,我们最好先把两件事分开:

  • 我们手里拿着的 root
  • React 内部真正维护的 root
flowchart LR
    A[业务代码拿到的 root] --> B[ReactDOMRoot]
    B --> C[_internalRoot]
    C --> D[FiberRoot]

create-root-return-react-dom-root.png

上面这张源码图里,最值得注意的是最后两步:

  • 先通过 createContainer(...) 拿到内部的 root
  • 最后返回 new ReactDOMRoot(root)

这也就把这一节最关键的判断立住了:createRoot(container) 返回的不是 FiberRoot 本身,而是一个对外暴露的 ReactDOMRoot 句柄。


三、createRoot 到底初始化了什么

既然 createRoot(container) 返回的是一个对外句柄,那它内部到底做了什么?

如果顺着源码往下看,会发现 createRoot 真正做的事情,不是“立刻开始渲染”,而是先把根级运行时入口搭起来。

这一层更适合按系统分层来理解,而不是按源码步骤一项项罗列。

1. 对外先创建了一个 root 句柄

最外层拿到的是 ReactDOMRoot
这一层的作用很直接:业务代码后续通过它来调用 render(...)unmount(...) 这些入口方法。

也就是说,React 先对外准备了一个“可用的 root 句柄”。

2. 内部真正建起了根级状态容器

再往里走,会进入 createContainer(...)
从这里开始,才真正进入 reconciler 内部的 root 初始化流程。

这一层真正创建出来的是 FiberRoot

FiberRoot 可以先粗略理解成:

整棵 React 树外侧的根级状态容器。

后面很多根级信息,都会挂在这一层上,比如:

  • 当前根对应的 container
  • 当前这棵树的 current
  • pending lanes
  • 其他根级调度与状态信息

3. 同时把 Fiber 树入口和更新系统准备好了

Root 初始化并不会只停在 FiberRoot 这一层。
在创建 FiberRoot 的同时,还会继续创建 HostRoot Fiber

HostRoot Fiber 是整棵 Fiber 树最顶层的根 Fiber。
后面真正进入 render 阶段时,工作会从这里开始往下展开。

除此之外,根初始化时还会顺手把一些关键状态准备好,比如:

  • 根 Fiber 的 memoizedState
  • 根 Fiber 的 updateQueue

这也意味着:

Root 从来不是一个空壳。
它在一开始,就已经把根级状态容器、Fiber 树入口和后续更新通道一起准备好了。

flowchart TD
    A[createRoot] --> B[ReactDOMRoot]
    A --> C[FiberRoot]
    A --> D[HostRoot Fiber]
    D --> E[初始化 memoizedState]
    D --> F[初始化 updateQueue]

create-fiber-root-init.png

如果只盯住这张图里最关键的几行,其实就能看到这一节最重要的信息:

  • 先创建 FiberRoot
  • 再创建 HostRoot Fiber
  • root.current = uninitializedFiber
  • uninitializedFiber.stateNode = root
  • 初始化 memoizedState
  • 初始化 updateQueue

也就是说,createRoot 做的不是“准备一个空的 root 变量”,而是在初始化阶段就把根级状态容器、根 Fiber 入口和更新通道一起准备好了。


四、FiberRoot 和 HostRoot Fiber 在这里各自是什么角色

上面虽然已经提到了 FiberRoot 和 HostRoot Fiber,但这两者的角色最好单独拆开看。
因为这会直接关系到后面第 4 篇里“Fiber 到底是什么”的理解。

1. FiberRoot:树外的根级状态容器

FiberRoot 可以先理解成:

React 在根级维护整棵树运行状态的地方。

它不属于 Fiber 树里的某个普通节点,而是在树外侧,集中保存根级信息。

比如后面常见的这些信息,都会和它有关:

  • 宿主容器 container
  • 当前树入口 current
  • pending lanes
  • 其他根级状态和调度信息

所以它更像是在回答:

“这棵树作为一个整体,目前处在什么状态?”

2. HostRoot Fiber:Fiber 树最顶层的根 Fiber

HostRoot Fiber 则不一样。
它已经属于 Fiber 树内部了,而且是最顶层的那个根 Fiber。

后面真正进入 render 阶段时,工作会从这里开始往下展开。
所以它更像是在回答:

“如果要开始处理这棵树,入口 Fiber 在哪里?”

3. 两者的关系

这两层对象不是彼此独立的,而是双向连起来的。

大致可以粗略理解成:

  • FiberRoot.current -> HostRoot Fiber
  • HostRoot Fiber.stateNode -> FiberRoot

也就是说:

  • FiberRoot 通过 current 指向当前这棵树的根 Fiber
  • HostRoot Fiber 再通过 stateNode 回指到 FiberRoot

这两个引用关系非常关键。
因为它把:

  • 根级状态容器
  • Fiber 树入口

真正接成了一套系统。

如果先把这里压成一句话,那就是:

FiberRoot 管的是“根级状态”,HostRoot Fiber 管的是“根 Fiber 入口”,两者通过双向引用连在一起。

flowchart LR
    A[FiberRoot] -->|current| B[HostRoot Fiber]
    B -->|stateNode| A

五、root.render(element) 到底做了什么

到这里,根容器已经搭起来了。
接下来的问题就自然变成:

root.render(element) 到底做了什么?

平时写代码时,我们很容易把它理解成:

“开始渲染”

但如果顺着源码往下看,这里的事情其实更准确一些。

1. render(children) 接收的是已经求值好的输入

这里传进去的 children,在典型场景下,就是上一篇讲过的 ReactElement。

也就是说:

root.render(<App />)

这行代码在真正进入 render(...) 方法之前,<App /> 这段 JSX 已经先求值成了 ReactElement。
所以 render(...) 本身并不负责做 JSX 到 element 的转换。

2. root.render 更像在提交一份新的 UI 描述

这一层最容易产生误解的地方就在于 render 这个名字。

从业务侧看,它当然对应“我要渲染这棵树”。
但从源码这一步真正做的事情来说,它更像是在:

把一份新的 UI 描述提交给根级更新系统。

也就是说,这一步还不是“立刻去改 DOM”,而是先把 element 往根级更新流程里送。

3. 后面会进入 updateContainer(...)

继续往下看,root.render(element) 会进入 updateContainer(...)
从这里开始,这次根更新才真正被组织起来。

所以如果把这一节压成一句话,我会更愿意这样理解:

root.render 更像是在提交一份新的 UI 描述,而不是立刻把 DOM 改掉。

react-dom-root-render-update-container.png

这张图里最关键的两行,就是:

  • const root = this._internalRoot
  • updateContainer(children, root, null, null)

也就是说,root.render(children) 这一层做的核心事情,就是先拿到内部的 _internalRoot,然后继续进入 updateContainer(...),把这份新的 UI 描述送进根级更新系统。


六、ReactElement 是怎么变成一次 update 的

这一节是第三篇真正最值钱的部分。

因为到这里,主线第一次真正从“输入对象”推进到了“标准化更新”。

如果顺着源码往下看,大致会经过这样一条小链路:

1. updateContainer(...)

root.render(element) 继续往下后,会进入 updateContainer(...)

这一层做的事情,先粗略理解成两步就够了:

  • 找到当前根 Fiber
  • 为这次更新分配 lane

也就是说,从这里开始,这份新的 element 已经不再只是“一个输入对象”,而是开始被组织成一次真正的根更新。

2. createUpdate(lane)

接着会调用 createUpdate(lane),创建出一个 Update 对象。

这里的关键不是把 Update 结构细节讲完,而是先知道:

React 不会直接拿着 element 裸奔往下传,而是会先把它包装成一次标准化更新。

create-update-function.png

这一小步单独拿出来看会更清楚。
createUpdate(lane) 做的事情并不复杂:它创建并返回一个 update 对象,先把 lanetagpayloadcallbacknext 这些基础字段准备好。

这里最值得注意的是:刚创建出来时,payload 还是 null
也就是说,这一步只是先把“更新对象的壳”准备好。

3. update.payload = { element }

接下来,React 会把这次新的 UI 描述真正放进 update 里。

这一行是整篇第三篇里最值得盯住的一步。

因为到这里可以非常明确地看到:

ReactElement 被放进了这次 update 的 payload 里。

也就是说,这一步里发生的不是:

  • element 直接变成 Fiber
  • element 直接变成 DOM
  • element 直接进入 work loop

而是:

element 先被放进了一次更新对象的 payload 里。

4. enqueueUpdate(...)

再往后,这次 update 会被挂进 HostRoot Fiber 的 update queue。

这说明根更新不会在这里立刻“一路跑到底”,而是先进入统一的更新队列。

5. scheduleUpdateOnFiber(...)

最后,这次更新会继续推进到 scheduleUpdateOnFiber(...)
从这里开始,调度入口才真正接上。

这一步我先不往后深讲,因为那已经开始进入后面几篇的空间了。
在第三篇这里,看到这里就够了。

update-container-payload-enqueue-schedule.png

如果只看这张图里最关键的几步,主线其实非常清楚:

  • createUpdate(lane) 创建更新对象
  • 再把 update.payload = { element }
  • 然后通过 enqueueUpdate(...) 挂进队列
  • 最后继续推进到 scheduleUpdateOnFiber(...)

所以第三篇真正最值得带走的一句认知就是:

ReactElement 在这里先变成的是一次 Update,而不是直接变成 Fiber。

flowchart TB
    A["ReactElement"] --> B["updateContainer"]
    B --> C["createUpdate"]
    C --> D["payload = { element }"]
    D --> E["enqueueUpdate"]
    E --> F["scheduleUpdateOnFiber"]

七、HostRoot Fiber 的 updateQueue 在这里是干什么的

看到这里时,一个很自然的问题就是:

为什么不是直接把 element 丢给某个 render 函数,而是非要先过 update queue?

这里的关键在于:

1. updateQueue 是挂在 Fiber 上的

更具体一点说,这里的 queue 是挂在 HostRoot Fiber 上的。

也就是说,根更新不是单独漂在外面的,它是和 Fiber 树入口直接关联起来的。

2. 根初始化时就已经把 queue 准备好了

前面第三节已经提过,根初始化时,HostRoot Fiber 的 updateQueue 就已经准备好了。

这也说明一个很重要的点:

React 在 root 初始化时,就已经把“后续如何接收更新”这条通道一起搭好了。

所以 root.render(element) 并不是临时找地方塞进去,而是沿着一条一开始就准备好的根级更新通道进入系统。

3. root.render(element) 本质上是在提交一次根更新

从这个角度回头再看,就会更清楚:

  • root.render(element) 不是“立刻渲染”
  • 它本质上是在往 HostRoot Fiber 的 queue 上挂一次新的根更新

后面到了 render 阶段,再去真正消费它。

所以这一节想说明的,其实就一句话:

先经过 updateQueue,不是绕路,而是 React 把更新纳入统一运行时系统的方式。

下面这张结构图,可以把这一层关系再看得更直观一点:

flowchart TB
    A["root.render(element)"] --> B["createUpdate"]
    B --> C["Update"]
    C --> D["enqueueUpdate"]
    D --> E["HostRoot Fiber.updateQueue"]
    E --> F["render 阶段再消费 queue"]
    F --> G["继续构造后续工作流程"]

如果换成更口语一点的话,就是:

  • root.render(element) 先创建一次 Update
  • 这次 Update 不会直接变成 DOM 更新
  • 它会先被放进 HostRoot Fiber 的 updateQueue
  • 后面 render 阶段再从 queue 里把它取出来继续处理

这样理解以后,updateQueue 的存在就不再像一层多余中转,而更像 React 统一接收更新的标准入口。


八、到这里,React 主线走到了哪里

到这里,第三篇其实已经把 React 主线从“输入对象”推进到了“系统入口”。

如果把当前这段主线压成一条链,大致就是:

JSX → ReactElement → createRoot 初始化根容器 → root.render(element) → createUpdate → enqueueUpdate → scheduleUpdateOnFiber

也就是说,到这里为止:

  • JSX 已经先变成 ReactElement
  • Root 入口已经初始化好了
  • ReactElement 已经进入根级更新系统
  • 更新已经被包装成了标准化 Update,并且挂进了 HostRoot Fiber 的 queue

但还有一件事必须在这里立住:

到这里,ReactElement 已经进入根级更新系统了,但它还没有真正展开成 Fiber 子树。

这句话非常重要。
因为它直接决定了这一篇的边界。

顺着这条链再往后追,问题就会自然切到下一层:

这套系统里真正工作的 Fiber 到底是什么,React 为什么需要 Fiber?

这里把主线再收束成一张更紧凑的图:

flowchart TB
    A[JSX] --> B[ReactElement]
    B --> C[createRoot<br/>初始化根容器]
    C --> D[root.render<br/>提交 element]
    D --> E[createUpdate]
    E --> F[enqueueUpdate]
    F --> G[scheduleUpdateOnFiber]

结语

React 源码最难的地方,从来都不是某一个函数本身。

真正难的是:如果没有地图,很多细节都会看起来彼此割裂。今天看到 ReactElement,明天看到 Root,后天又看到 Fiber,名词越来越多,但主线反而越来越模糊。

所以这一篇真正补上的,不是某个零散知识点,而是 React 主线里的“系统入口”这一段:

React 拿到 ReactElement 之后,并不是立刻把它展开成 Fiber 子树,而是先把它送进根级更新系统。

把这一步看清楚之后,后面再去看 Fiber、Update、Queue、Lane、render、commit,这些东西的落点就会稳很多。

如果这篇对你有帮助,欢迎点个赞支持。后面我也会继续把这组 React 源码文章慢慢补完整。

这组源码解读文章也会同步整理到 GitHub 仓库里,方便集中查看和持续更新:

GitHub:github.com/HWYD/source…

如果觉得这组内容对你有帮助,也欢迎顺手点个 Star。

最近在做的一个 AI 项目

最近我也在持续迭代一个 AI 项目:AI Mind
如果你对 AI 应用工程化方向感兴趣,欢迎来看看:

GitHub:github.com/HWYD/ai-min…

如果觉得还不错,也欢迎顺手点个 Star。

Flutter热更新 Shorebird CodePush 原理、实现细节及费用说明

一、前言:Flutter 热更新的核心痛点

Flutter 应用上线后,若出现线上紧急 Bug(如支付流程崩溃、核心功能异常),传统解决方案需重新构建应用、提交应用商店审核,审核周期通常为 24-48 小时,部分场景(如周末、节假日)会更长。在此期间,用户留存率、应用评分会受到严重影响,甚至造成直接业务损失。

React Native 可通过替换 JS bundle 实现快速热更新,而 Flutter 因 AOT 编译特性,长期缺乏原生支持的热更新方案。Shorebird CodePush 的出现,通过改造 Dart VM 与 Flutter 引擎,实现了基于差异的 OTA(Over-the-Air)代码热更新,无需应用商店审核即可快速推送 Dart 层修复补丁,彻底解决了这一痛点。本文重点解析其原理、实现细节,并补充关键的费用相关说明,为开发团队落地提供完整参考。

二、Flutter 热更新困境的底层原因

Flutter 无法像 React Native 那样轻松实现热更新,核心源于两者编译、运行机制的本质差异,这也是理解 Shorebird 技术方案的基础。

2.1 Flutter 的 AOT 编译机制(性能与局限)

Flutter 在 Release 模式下采用 AOT(Ahead-of-Time,预编译)机制,具体流程为:Dart 代码在构建阶段直接编译为原生 ARM 机器码,生成 Android 平台的 libapp.so 文件和 iOS 平台对应的二进制片段,这些编译产物被直接嵌入应用安装包中。

这种机制的优势极为明显:运行时无需解释器中转,机器码直接与硬件交互,保证了 Flutter 应用的高帧率、快速启动和稳定性能,这也是 Flutter 相较于其他跨平台框架的核心竞争力。但同时,这种机制也带来了致命局限:编译后的机器码被硬编码到应用二进制包中,Dart 运行时本身不支持动态加载、替换已编译的代码,无法像 JS bundle 那样通过简单的文件替换实现热更新。

2.2 React Native 的解释执行机制(灵活与短板)

React Native 的运行机制与 Flutter 完全不同:其 JS 业务逻辑运行在 Hermes 或 JSC 等 JS 引擎中,应用启动时从本地 bundle 文件加载 JS 代码,再由 JS 引擎解释执行。

这种机制的灵活性体现在:热更新只需向用户设备推送新的 JS bundle 文件,替换本地原有文件,应用下次启动时即可加载新的逻辑,无需修改原生二进制包,也无需经过应用商店审核。但短板也同样突出:解释执行的效率低于机器码,应用性能和流畅度不如 Flutter。

2.3 Flutter 热更新的破局方向

要实现 Flutter 热更新,本质上只有两条可行路径,Shorebird 选择了更贴合生产需求的后者:

  1. 放弃代码层面的修改,采用服务端驱动 UI(如 Flutter Server-Driven UI),通过配置下发调整页面展示和简单逻辑,这种方式灵活性极低,无法解决复杂 Bug 修复和业务逻辑迭代需求;
  2. 改造 Dart VM 与 Flutter 引擎,增加动态执行代码的能力,在保留 AOT 编译性能优势的前提下,实现运行时补丁加载,这也是 Shorebird CodePush 的核心技术路线。

三、Shorebird CodePush 核心原理与实现细节

Shorebird CodePush 并非简单的文件替换,而是从编译、运行、补丁分发全链路进行改造,核心是“双轨运行机制”,既保证性能,又实现灵活热更新,其实现细节可分为架构设计、运行流程、补丁生成、更新边界四个核心部分。

3.1 核心架构:双组件运行时设计

当使用 Shorebird 构建 Flutter 应用的 Release 包时,最终生成的二进制包包含两个核心组件,二者协同工作,实现“性能兜底 + 灵活更新”的平衡:

  1. 标准 AOT 编译 Dart 快照:保留 Flutter 原生的 AOT 编译产物,作为应用的默认运行方案。当无补丁时,应用直接运行该快照,与原生 Flutter 应用无任何差异,确保性能无损;同时,该快照也是补丁加载失败时的兜底方案,避免应用崩溃。
  2. Shorebird 定制 Dart 解释器:由 Shorebird 团队自主研发、维护,专门用于执行热更新补丁代码。该解释器被嵌入应用二进制包中,不影响应用正常启动和运行,仅在有补丁时被激活。

这种双组件架构的核心优势的是:日常运行时性能与原生 Flutter 一致,有补丁时可快速切换到解释器模式,实现逻辑更新,兼顾性能与灵活性。

3.2 完整运行流程:从启动到补丁生效

Shorebird CodePush 的运行流程可分为 5 个关键步骤,全程无感知,不影响用户使用:

  1. 应用启动:用户启动应用后,Shorebird 运行时(嵌入在应用中的核心模块)优先被激活,替代原生 Flutter 运行时的启动逻辑;
  2. 补丁检查:Shorebird 运行时向 Shorebird 官方服务器发送请求,携带当前应用的 Release 版本号,查询该版本是否有可用的热更新补丁;
  3. 无补丁场景:若服务器返回无可用补丁,Shorebird 运行时自动切换到 AOT 模式,运行标准 AOT 编译快照,应用正常运行,与原生 Flutter 应用无任何区别;
  4. 有补丁场景:若服务器返回有可用补丁,Shorebird 运行时会在后台下载补丁(补丁体积极小,通常为几 KB 到几百 KB),下载完成后,暂停 AOT 快照的执行,通过定制 Dart 解释器加载并执行补丁代码,补丁代码会覆盖原有 AOT 快照中的对应逻辑,实现热更新;
  5. 兜底机制:若补丁下载失败、验证失败或执行异常,Shorebird 运行时会立即回退到 AOT 模式,运行原始快照,确保应用正常启动,不会出现崩溃、无法使用的情况。

3.3 补丁生成机制:差异包优化,提升分发效率

Shorebird CodePush 的补丁并非完整的 Dart 代码包,而是基于对应 Release 版本的差异包,其生成过程经过多重优化,确保分发效率和加载速度:

  1. 版本绑定:补丁必须基于某个特定的 Release 版本生成,通过 shorebird patch 命令推送时,会自动关联最近一次 shorebird release 生成的版本,确保补丁与应用版本匹配,避免版本错乱;
  2. 差异计算:Shorebird 会对比当前修改后的 Dart 代码与对应 Release 版本的 Dart 快照,仅提取变更的代码片段(如修改的函数、新增的类、调整的逻辑),而非打包完整代码;
  3. 二进制压缩:将差异代码片段编译为二进制格式,再进行压缩处理,生成极小的补丁包,减少网络传输量,用户下载时几乎无感知;
  4. CDN 分发:补丁生成后,会上传到 Shorebird 官方 CDN 节点,用户设备下载补丁时,会选择最近的 CDN 节点,提升下载速度。

关键说明:补丁仅包含 Dart 层代码差异,不涉及任何原生层内容,这既是热更新的核心范围,也是应用商店合规的关键前提。

3.4 更新边界:明确可更新与不可更新范围

Shorebird CodePush 有明确的更新边界,严格区分 Dart 层与原生层,既保证热更新的灵活性,也避免违反应用商店政策,具体范围如下:

可通过 OTA 热更新(Dart 层) 需重新提交应用商店(原生层)
所有 Dart 与 Flutter 组件代码 原生 Kotlin、Swift、Java、Objective-C 代码
UI 布局、业务逻辑、路由跳转 原生插件的平台通道实现(如插件的原生层代码)
字符串、状态管理(Provider、Bloc 等) AndroidManifest.xml 或 Info.plist 文件修改
Dart 层 Bug 修复、逻辑优化 新增运行时权限(如相机、定位、存储权限)
纯 Dart 编写的 Flutter 插件升级 原生二进制资源(如 so 库、动态库)
Dart 层管理的应用配置(如接口地址、开关配置) 原生依赖(如原生 SDK)的版本变更

总结:只要是纯 Dart 层的逻辑、代码、配置,均可通过 Shorebird CodePush 实现热更新;涉及原生层的任何修改,都必须重新构建应用、提交应用商店审核,无法通过热更新实现。

四、Shorebird CodePush 接入与使用实现

Shorebird CodePush 的接入过程无侵入式改造,不影响原有 Flutter 项目的开发流程,从初始化到首次推送补丁,全程仅需 5 步,耗时约 15 分钟,具体实现细节如下:

4.1 环境准备:安装 Shorebird CLI

Shorebird 提供 CLI 工具,用于完成项目初始化、Release 包构建、补丁推送等操作,支持 macOS、Linux 系统,Windows 系统需使用 WSL(Windows Subsystem for Linux)。

安装命令(终端执行):

curl --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/shorebirdtech/install/main/install.sh -sSf | bash

安装完成后,执行以下命令登录 Shorebird 账号(需提前在 Shorebird 官网注册账号):

shorebird login

登录成功后,CLI 会自动关联账号信息,获取应用构建、补丁推送的权限。

4.2 项目初始化:集成 Shorebird 运行时

进入 Flutter 项目根目录,执行以下命令初始化 Shorebird:

shorebird init

初始化操作的核心影响:

  1. 生成 shorebird.yaml 配置文件,位于项目根目录,包含项目唯一的 app_id(用于关联 Shorebird 服务器中的应用);
  2. 自动配置 Flutter 引擎绑定,将 Shorebird 运行时集成到项目中,无需手动修改 pubspec.yaml或原生代码;
  3. 原有 Dart 代码、项目结构、开发流程完全不变,仅新增 shorebird.yaml 文件,可正常提交到 Git 版本管理。

4.3 构建 Release 包:提交应用商店

初始化完成后,执行平台专属的 Release 包构建命令,生成可提交到应用商店的二进制包:

# 构建 Android 平台 AAB 包(用于 Google Play 上架)
shorebird release android

# 构建 iOS 平台 IPA 包(用于 App Store 上架)
shorebird release ios

该命令的核心作用:

  1. 构建包含 Shorebird 运行时的 Release 包,保留 AOT 编译快照和定制 Dart 解释器;
  2. 自动将该 Release 版本注册到 Shorebird 服务器,关联 app_id,用于后续补丁推送;
  3. 生成标准的 AAB(Android)和 IPA(iOS)文件,与原生 Flutter 构建的产物格式一致,可直接上传至 Google Play Console 和 App Store Connect;
  4. 上架流程与原生 Flutter 应用完全一致,无需额外提交审核材料,也无需修改上架流程。

关键注意:只有通过 shorebird release 命令构建的 Release 包,才能接收后续的热更新补丁;原生 Flutter 构建的 Release 包,无法使用 Shorebird 热更新。

4.4 推送补丁:Dart 层 Bug 快速修复

当 Dart 层代码修复完成(如 Bug 修复、逻辑优化)后,无需重新构建 Release 包、无需提交应用商店审核,直接执行以下命令推送补丁:

# 推送 Android 平台补丁
shorebird patch android

# 推送 iOS 平台补丁
shorebird patch ios

补丁推送的核心流程:

  1. Shorebird CLI 自动对比当前 Dart 代码与最近一次 shorebird release 版本的 Dart 快照,计算代码差异;
  2. 生成二进制差异补丁包,自动上传到 Shorebird CDN 服务器;
  3. Shorebird 服务器更新该 Release 版本的补丁信息,标记补丁可用;
  4. 用户设备下次启动应用时,Shorebird 运行时会自动检查到可用补丁,在后台下载,下次冷启动时生效(默认无需用户操作)。

4.5 应用内更新控制(可选)

默认情况下,补丁会在后台自动下载、下次冷启动生效,适合绝大多数场景。若需自定义更新时机(如主动提示用户、强制重启生效),可集成 shorebird_code_push Dart 包,实现精细化控制。

  1. 添加依赖到 pubspec.yaml
dependencies:
  shorebird_code_push: ^latest # 使用最新版本
  1. 主动检查并下载补丁的核心代码:
import 'package:shorebird_code_push/shorebird_code_push.dart';

Future<void> checkForUpdate() async {
  // 创建更新器实例
  final updater = ShorebirdUpdater();
  
  // 检查是否有可用更新
  final status = await updater.checkForUpdate();
  
  if (status == UpdateStatus.outdated) {
    try {
      // 执行补丁下载
      await updater.update();
      // 可选:下载完成后强制重启(仅限高危漏洞)
      // Phoenix.rebirth(context); // 需集成 flutter_phoenix 包
    } on UpdateException catch (error) {
      // 处理更新异常(如网络失败、补丁损坏)
      print("补丁更新失败:$error");
    }
  }
}
  1. 调用时机:可在根组件 initState(每次冷启动检查)或AppLifecycleListener(应用从后台恢复时检查)中调用,确保及时获取补丁。

五、高级特性实现(生产环境必备)

对于生产环境中的应用,仅实现基础热更新远远不够,Shorebird CodePush 提供了灰度发布、补丁回滚、补丁签名等高级特性,保障热更新的稳定性、安全性,具体实现细节如下:

5.1 灰度发布:控制补丁推送范围

为避免全量推送补丁带来的风险(如补丁引入新 Bug),Shorebird 支持百分比灰度发布,可通过 Shorebird 官方控制台配置:

  1. 推送补丁后,在 Shorebird 控制台找到对应补丁,设置推送百分比(如 5%);
  2. 监控应用崩溃率(如集成 Crashlytics、Sentry),观察 60-90 分钟,若无异常,将推送百分比提升至 25%;
  3. 再次监控无异常后,提升至 100%,完成全量推送。

此外,支持自定义渠道(Track)推送,如仅向 beta 测试用户推送补丁,命令如下:

shorebird patch android --track=beta

测试通过后,可在控制台将补丁从 beta 渠道提升至 stable 渠道,推送给所有生产环境用户。

5.2 补丁回滚:快速修复补丁异常

若推送的补丁引入新问题(如崩溃、逻辑异常),无需等待应用商店审核,可通过以下命令一键回滚:

shorebird patch rollback

回滚机制细节:

  1. 执行命令后,Shorebird 服务器立即停止该补丁的分发,阻断新用户下载;
  2. 已下载该补丁的用户,下次启动应用时,Shorebird 运行时会自动检测到回滚指令,恢复至上一稳定版本(即补丁推送前的状态);
  3. 回滚全程无需用户操作,无感完成,且无应用商店审核环节,分钟级即可修复补丁异常。

5.3 补丁签名:安全加固(合规行业必备)

针对金融、医疗、政务等合规行业,为防止 Shorebird 账号被盗后恶意推送补丁,Shorebird 支持补丁签名功能,实现流程如下:

  1. 生成私有密钥:通过 Shorebird CLI 生成用于补丁签名的私有密钥,由开发团队自行保管,不可泄露;
  2. 补丁签名:每次推送补丁时,使用私有密钥对补丁进行签名;
  3. 设备端验证:用户设备下载补丁后,Shorebird 运行时会验证补丁签名,若签名不匹配(如补丁被篡改、恶意推送),则直接拒绝加载补丁,自动回退到 AOT 模式;
  4. 审计追踪:所有签名补丁的记录都会保留在 Shorebird 控制台,满足合规行业的审计要求。

六、Shorebird 费用说明(2026 最新)

Shorebird 采用“免费 + 付费”的订阅模式,免费版完全满足个人开发者、小项目需求,付费版针对商业项目、高 MAU 应用,费用透明,无隐藏成本,具体说明如下:

6.1 免费版(Free Tier):永久可用,无功能阉割

免费版面向个人开发者、学习用途、小流量项目,无使用时间限制,核心权益包括:

  1. 无限个应用:可在多个 Flutter 项目中集成 Shorebird,无应用数量限制;
  2. 无限次补丁推送:无论推送多少次补丁,均不收取任何费用;
  3. 基础功能:包含灰度发布、补丁回滚、基础 CDN 加速、应用内更新控制等所有核心功能;
  4. 无强制水印:应用中不会出现 Shorebird 相关的强制水印或标识;
  5. 限额说明:仅限制每月活跃设备数(MAU),免费额度为 10,000 MAU/月,超过该额度后,会收到付费提醒,补丁推送不会立即停止,但建议升级至付费版。

关键说明:MAU(Monthly Active Users)指每月唯一检查过更新的设备数,仅启动应用不检查更新、不联网的设备,不计入 MAU 统计,个人开发者、小工具类应用基本不会超过该限额。

6.2 付费版:按月订阅,适合商业项目

付费版面向企业、商业项目、MAU 超过 1 万的应用,按 MAU 上限分级订阅,官方 2026 年公开定价如下:

  1. Pro 计划:约 $20/月

    1. MAU 上限:50,000 台设备/月;
    2. 权益:包含免费版所有功能,额外提供更高优先级的 CDN 加速、高级控制台(更详细的补丁统计、设备数据)、优先技术支持(响应速度更快);
    3. 适用场景:中小规模商业项目、MAU 1-5 万的应用。
  2. Business 计划:$100+/月(根据 MAU 定制)

    1. MAU 上限:可根据需求定制(5 万以上);
    2. 权益:包含 Pro 计划所有功能,额外提供 SLA 服务保障( uptime 承诺)、企业级技术支持(专属对接人)、自定义 CNAME、私有 CDN 集成、补丁审计报告等;
    3. 适用场景:大规模商业项目、金融/医疗等合规行业应用、MAU 5 万以上的应用。

6.3 费用关键说明

  1. 不按补丁次数收费:无论一天推送 1 次还是 100 次补丁,均不额外收费,仅按 MAU 限额计费;
  2. 不按流量收费:补丁体积极小,且 CDN 流量已包含在订阅费用中,无需额外支付流量费;
  3. 无隐藏费用:不按应用安装量、代码量、项目数收费,价格透明,订阅后可随时升级、降级;
  4. 试用政策:新用户可免费试用 Pro 计划 14 天,试用期内可享受 Pro 计划所有权益,试用期结束后自动切换为免费版;
  5. 付费方式:支持信用卡、企业付款,按月自动续费,可随时取消订阅。

6.4 费用风险提示

Shorebird 背后有专业团队长期维护,已被大量国内外企业商用,政策透明,不会出现“免费试用后突然收割”“强制升级付费版”的情况:

  • 免费版永久可用,即使 MAU 超过限额,也不会立即停止补丁推送,仅会提醒升级;
  • 付费版价格长期稳定,无突然涨价风险,企业可根据自身 MAU 选择合适的计划,成本可控。

七、应用商店合规性说明(补充)

热更新的核心风险之一是违反应用商店政策,Shorebird CodePush 已完全适配 iOS 和 Android 应用商店的规则,开发者只需遵守更新边界,即可放心使用:

  1. iOS App Store:苹果 App Store Review Guidelines 2.5.2 禁止应用动态下载执行代码,但有豁免条款——通过内置解释器执行的代码,若不修改应用核心用途、不新增需审核的功能,可正常通过审核。Shorebird 的补丁通过定制 Dart 解释器执行,仅修复 Dart 层逻辑,不新增核心功能,完全符合该豁免条款,已有大量应用成功上架;
  2. Google Play:政策更宽松,明确允许 OTA 代码更新,无需满足解释器相关要求,只需遵守基础规则(如不加载未受信任的代码、不违反内容政策),Shorebird 补丁分发通过官方 CDN,完全合规;
  3. 国内应用商店(如华为、小米、OPPO 等):均支持热修复/热更新,Shorebird 方案无违规风险,正常提交应用即可。

禁忌:不可用热更新推送全新核心功能、未审核的内容,或绕过应用商店审核推送违规逻辑(如违规支付、隐私泄露相关代码),否则可能导致应用被下架。

八、总结

Shorebird CodePush 是目前 Flutter 生态最成熟、最适合生产环境的 OTA 热更新方案,其核心优势在于“双轨运行机制”,既保留了 Flutter AOT 编译的性能优势,又实现了 Dart 层代码的快速热更新,同时兼顾安全性、合规性和成本可控性。

核心总结:

  1. 原理:通过改造 Dart VM 与 Flutter 引擎,实现“AOT 快照兜底 + 解释器执行补丁”的双轨运行,兼顾性能与灵活性;
  2. 实现:接入简单(15 分钟完成),无侵入式改造,补丁为差异包,分发效率高,支持灰度、回滚、签名等生产级特性;
  3. 费用:个人开发者 1 万 MAU 永久免费,企业商用成本极低(Pro 版 $20/月),无隐藏成本;
  4. 合规:符合 iOS、Android 应用商店政策,可放心用于生产环境。

对于 Flutter 开发团队而言,Shorebird CodePush 已成为生产环境必备的热更新工具,可彻底告别线上 Bug 等待应用商店审核的痛苦,实现快速响应、极致用户体验。

观测云集成钉钉 SSO 最佳实践

钉钉 SSO 介绍

钉钉 SSO(Single Sign-On)是钉钉提供的企业级身份认证服务,允许员工使用钉钉账号直接登录观测云平台,实现企业统一身份管理。

核心价值:

  • 统一身份管理:员工无需记忆多套账号密码,一个钉钉账号即可访问观测云
  • 提升安全性:基于企业已有的钉钉组织架构进行身份认证,降低账号泄漏风险
  • 简化运维:员工入/离职时,通过钉钉统一管控即可同步变更观测云访问权限
  • 协议适配:钉钉未提供标准 OIDC 协议,本方案通过自定义适配层实现对接

观测云

观测云是一款专为 IT 工程师打造的全链路可观测产品,它集成了基础设施监控、应用程序性能监控和日志管理,为整个技术栈提供实时可观察性。这款产品能够帮助工程师全面了解端到端的用户体验追踪,了解应用内函数的每一次调用,以及全面监控云时代的基础设施。此外,观测云还具备快速发现系统安全风险的能力,为数字化时代提供安全保障。

钉钉开放平台配置

第 1 步:创建钉钉应用

1、登录钉钉开放平台

2、进入应用开发 → 钉钉应用,点击创建应用

3、填写应用基本信息:

  • 应用名称:建议命名为"观测云"或类似名称
  • 应用描述:可描述为"用于观测云的单点登录应用"

4、点击确定完成创建

5、创建完成后会自动跳转应用管理页面,点击添加应用能力,选择网页应用,点击添加

6、点击版本管理与发布,点击创建新版本,输入版本号和描述,应用可用范围按需选择员工范围,然后点击保存完成应用发布

第 2 步:获取 Client ID 和 Client Secret

1、应用创建完成后,进入应用详情页面

2、在凭证与基础信息页面,找到以下信息:

  • AppID(Client ID) :应用的唯一标识
  • AppSecret(Client Secret) :应用的密钥

3、妥善保管这两个信息,后续在观测云中需要配置

第 3 步:配置回调地址(Redirect URI)

1、在钉钉应用的配置页面中,点击安全设置

2、在重定向 URI(Redirect URI)字段中添加对应的回调地址

SaaS 版:

https://auth.guance.com

部署版:

https://your-domain.com/oidc/callback

注意:  将 your-domain.com 替换为你的实际访问域名

3、点击添加完成保存

第 4 步:配置权限范围

1、在应用的权限管理中,确保申请或授予以下权限:

  • Contact.User.Read:通讯录个人信息读权限
  • Contact.User.mobile:个人手机号信息

2、确保这些权限已通过审核

SaaS 版配置

SaaS 版的 OIDC 配置通过管理控制台进行,无需修改配置文件。

第 1 步:访问管理控制台

1、登录观测云 SaaS 控制台

2、进入管理 → 成员管理 → SSO 管理

第 2 步:添加 OIDC 配置

1、点击 OIDC → 添加身份提供商

2、填写以下信息并点击保存

字段 说明
身份提供商 SSO 服务商名称,可自定义
配置文件 上传配置文件(配置内容见下文)
访问限制 填写本公司的邮箱域名,用于 SaaS 登录时识别正确的 SSO 入口
角色授权 可选,新用户第一次登录时的默认角色

第 3 步:配置文件内容

配置文件模板如下,只需修改 clientId 和 clientSecret 为实际值即可:

{
    "wellKnowURL": "",
    "sslVerify": true,
    "clientId": "<clientId>",
    "clientSecret": "<clientSecret>",
    "grantType": "authorization_code",
    "scope": [
        "openid"
    ],
    "authSet": {
        "url": "https://login.dingtalk.com/oauth2/auth",
        "verify": true,
        "paramMapping": {
            "response_type": "code",
            "redirect_uri": "$redirect_uri",
            "client_id": "$client_id",
            "scope": "openid"
        }
    },
    "getTokenSet": {
        "url": "https://api.dingtalk.com/v1.0/oauth2/userAccessToken",
        "verify": true,
        "method": "post",
        "authMethod": "basic",
        "paramMapping": {
            "code": "$code",
            "state": "$state",
            "grant_type": "$grant_type",
            "redirect_uri": "$redirect_uri",
            "client_id": "$client_id",
            "client_secret": "$client_secret"
        }
    },
    "verifyTokenSet": {
        "url": "",
        "verify": true,
        "method": "get",
        "keys": []
    },
    "getUserInfoSet": {
        "url": "https://api.dingtalk.com/v1.0/contact/users/me",
        "method": "get",
        "authMethod": "bearer",
        "source": "origin",
        "responseInfoPath": "",
        "paramMapping": {}
    },
    "claimMapping": {
        "username": "nick",
        "email": "email",
        "mobile": "mobile"
    }
}

第 4 步:测试 SSO 登录

在观测云登录页面进行 SSO 登录验证:

登录成功后看到对应用户信息,即表示配置完成:

部署版配置

第 1 步:创建 Well-Known 接口

由于钉钉不提供标准 OIDC 协议,需要使用 Func 平台提供 Well-Known 接口。

登录 Func 平台,创建如下脚本,并创建 API 服务,获取请求地址作为 OIDC Well-Known 接口,具体 Func 使用方法参考 Func 官方文档 。

@DFF.API("wellknown")
def wellknown():
    return {
      "authorization_endpoint": "https://login.dingtalk.com/oauth2/auth", 
      "token_endpoint": "https://api.dingtalk.com/v1.0/oauth2/userAccessToken",
      "userinfo_endpoint": "https://api.dingtalk.com/v1.0/contact/users/me"
    }

第 2 步:配置 forethought-core/core

在 Launcher 中进入命名空间: forethought-core → core,为 config.yaml 增加或修改如下配置:

OIDCClientSet:
  # 开启自定义 OIDC
  enableCustomOIDC: true

  # 指向 Func 中 well_know 函数的 API 地址
  wellKnowURL: "<Func well_know API 地址>"

  mapping:
    username: nick
    mobile: mobile
    email: email
    exterId: openId

第 3 步:配置前端登录入口

在 Launcher 中进入命名空间: forethought-webclient → front-web-config,修改 config.js

window.DEPLOYCONFIG = {
  ...
  paasCustomLoginInfo: [
    {
      label: "OIDC 登录",
      url: "https://<部署版 Web 域名>/oidc/login",
      desc: "自定义 OIDC 登录"
    }
  ],
  paasCustomLoginUrl: "https://<IdP 登出地址>?redirect_url=https://<部署版 Web 域名>/oidc/login"
}

第 4 步:配置 Web Nginx 转发规则

命名空间: forethought-webclient → front-web-config 中修改 nginx.conf,将 OIDC 登录和回调请求转发到 inner 服务。

location /oidc/login {
    proxy_connect_timeout 5;
    proxy_send_timeout 5;
    proxy_read_timeout 300;
    proxy_http_version 1.1;
    proxy_set_header Connection "keep-alive";
    add_header Access-Control-Allow-Origin *;
    add_header Access-Control-Allow-Headers X-Requested-With;
    add_header Access-Control-Allow-Methods GET,POST,OPTIONS;
    proxy_pass http://inner.forethought-core:5000/api/v1/inner/oidc/login;
}

location /oidc/callback {
    proxy_connect_timeout 5;
    proxy_send_timeout 5;
    proxy_read_timeout 300;
    proxy_http_version 1.1;
    proxy_set_header Connection "keep-alive";
    add_header Access-Control-Allow-Origin *;
    add_header Access-Control-Allow-Headers X-Requested-With;
    add_header Access-Control-Allow-Methods GET,POST,OPTIONS;
    proxy_pass http://inner.forethought-core:5000/api/v1/inner/oidc/callback;
}

这一步的目的是让浏览器访问的 /oidc/login 和 /oidc/callback 最终进入部署版内部的 OIDC 处理逻辑。

等待服务重启完成,然后验证配置。

常见问题与故障排查

Q1:回调地址不匹配错误

现象:  登录时显示 redirect_uri mismatch 或相似错误

排查步骤:

  1. 确认钉钉应用配置中的回调地址与平台中的 Redirect URI 完全相同
  2. 检查是否有多余的 / 或大小写差异
  3. 确认使用的是 HTTPS(如平台配置为 HTTPS)
  4. 对于部署版,确认 launcher.yaml 中的 redirectUrl 与钉钉应用配置一致

Q2:Invalid Client ID 或认证失败

现象:  登录时提示客户端无效或认证失败

排查步骤:

  1. 验证 Client ID 和 Client Secret 是否正确复制(避免多余空格)
  2. 确认钉钉应用未被禁用或删除
  3. 检查 Client Secret 是否过期(某些情况下需要重新生成)
  4. 确认应用权限配置无问题

Q3:用户信息获取失败

现象:  登录时无法获取用户邮箱或其他信息

排查步骤:

  1. 确认钉钉应用已申请获取成员详情的权限
  2. 检查字段映射是否正确(字段名是否拼写错误)
  3. 验证钉钉账号是否完善(如邮箱是否已填写)
  4. 检查平台日志,查看具体的错误信息

Q4:登录后仍无权限访问

现象:  登录成功但无法访问平台资源

排查步骤:

  1. 确认用户已被添加到对应组织中
  2. 检查用户是否拥有正确的角色和权限
  3. 如使用了新用户名,确认权限是否已分配给新用户名

Q5:部署版重启后配置丢失

现象:  重启服务后 OIDC 配置消失

排查步骤:

  1. 确认配置已写入 ConfigMap(Kubernetes)或配置文件(Docker)
  2. 检查文件权限是否正确
  3. 查看启动日志中是否有配置加载错误
  4. 验证 YAML 格式是否正确(缩进、语法等)

Q6:不同用户使用同一邮箱导致冲突

现象:  多个钉钉用户有相同的邮箱,导致 SSO 异常

排查步骤:

  1. 确保字段映射中使用 sub 或 uid 作为唯一标识,而不是邮箱
  2. 在钉钉管理后台检查并修正重复邮箱
  3. 必要时手动在平台中合并或管理用户账号

总结

本方案通过自定义 OIDC 协议适配层,解决了钉钉不提供标准 OIDC 协议的问题。部署版需在 Func 平台创建 Well-Known 接口映射钉钉 OAuth 端点,并修改 Core 配置及 Nginx 转发规则,SaaS 版则通过管理控制台上传配置文件完成适配。核心流程包括:在钉钉开放平台创建应用获取 Client ID 和 Client Secret、配置回调地址和权限,在平台端完成 OIDC 参数映射(将钉钉的 nickmobileemail 字段映射为平台标准字段),最终实现企业员工使用钉钉账号一键登录,达成统一身份认证管理。

Three.js 渲染原理-透明渲染为什么这么难

透明渲染为什么这么难?

Three.js 渲染原理

从深度缓冲到排序机制,彻底搞清楚 transparentdepthTestdepthWrite 三个开关的职责与取舍,不再反复踩同一个坑。

Three.js · WebGL · 渲染原理 · 约 4500 字

如果你在 Three.js 里做过半透明的地图标牌、玻璃面板、粒子效果,一定遇过这个问题:明明设置了透明,但物体之间的遮挡关系就是不对。改来改去,偶尔对了,下次又乱了。

根本原因不是 API 太复杂,而是我们把几个职责完全不同的开关混在了一起。这篇文章从渲染原理讲起,帮你把它们彻底分开。

01 不透明物体为什么没有这个烦恼

要理解透明难在哪,先要理解不透明为什么简单。GPU 画每一帧时都维护着两块缓冲:

  • 颜色缓冲: 记录每个像素的最终颜色
  • 深度缓冲: 记录每个像素当前最近物体的深度值

diagram-01.jpg

不透明渲染:深度缓冲自动解决遮挡,谁近谁赢

这套机制稳定、高效,完全自动。不透明物体主要靠深度,不需要关心绘制顺序。

但半透明物体打破了这个假设。

02 半透明真正需要的是什么

半透明不是“谁近就显示谁”,而是前面的颜色按 alpha 混合到后面的颜色上。这需要两件事同时成立:

正确的半透明渲染 = 现有深度信息 + 正确的绘制顺序

先画远的,再画近的,让近处的透明层叠加在远处的内容上。

diagram-02.jpg

半透明必须从后往前绘制,才能正确叠色

也正因为如此,半透明渲染天然比不透明渲染更脆弱,它对绘制顺序有严格依赖,而深度缓冲无法替代这件事。

03 三个开关,三件不同的事

透明配置混乱的根本原因,是把三个职责完全不同的开关当成“一套透明配置”来理解。分开看,每个开关其实都很清晰。

属性 它决定的是 典型值
transparent 这个材质是否进入透明渲染路径,在不透明物体之后渲染,并参与 alpha blending true / false
depthTest 绘制这个片元前,是否与现有深度缓冲比较,也就是会不会被别人挡住 通常 true
depthWrite 绘制完这个片元后,是否把自己的深度写入缓冲,也就是会不会挡住别人 不透明 true,半透明通常 false

关键区分: transparent: true 不等于 depthWrite: false。前者说“我要走透明路径”,后者说“我要不要修改深度缓冲”。它们常常一起出现,但是两件完全独立的事。

diagram-03.jpg

三个开关各司其职,不要混为一谈

04 depthWrite: false 到底解决了什么问题

这是最多人在第一次接触透明渲染时会卡住的点。直觉上你会想:前面的东西在前面,就应该写深度,把后面的挡住。

这句话对不透明物体完全正确。对半透明物体则不成立。

diagram-04.jpg

depthWrite 决定透明物体能否让后面的内容参与混色

所以 depthWrite: false 不是“关掉遮挡”,而是放弃用深度来挡住后面,改用绘制顺序来保证混色正确

关掉 depthWrite 后,透明卡片仍然会被实体物体挡住。 因为 depthTest 依然是 true,它仍然会读深度缓冲,只是不写。墙后面的卡片,还是会被墙遮挡。

05 透明其实分两种,策略完全不同

很多人以为“透明”只有一种。实际上有两种完全不同的透明,混淆它们就是大多数透明 Bug 的根源。

diagram-05.jpg

两种透明的视觉效果和技术策略完全不同

06 Three.js 帮了你什么,没帮你什么

Three.js 默认会做一件很有帮助的事:

  • 先画所有不透明对象
  • 再画所有透明对象
  • 透明对象按对象中心点到相机的距离,从远到近排序

对“数量不多、结构简单、互相不怎么穿插”的透明对象,这已经足够好。

但它有一个明确的边界:

Three.js 默认排序的是对象(Object3D),不是对象内部的每个三角形。

如果一个 Mesh 里批量打包了很多透明卡片,Three.js 只能把整个 Mesh 当作一个单位排序,不知道内部哪张卡片应该先画、哪张应该后画。

diagram-06.jpg

批量合并几何体时,Three.js 的对象级排序无法处理内部卡片的顺序

这就是为什么地图标牌类组件通常需要在批次内部自己做从远到近排序。Three.js 的自动排序在这里帮不上忙。具体怎么做,见第 08 节。

注意性能陷阱: 批次内部的距离排序发生在 CPU 上,每帧都要对所有卡片重新排序并上传 index buffer。卡片数量大时,这个成本需要纳入考量。优化方案详见第 08 节。

07 圆角半透明卡片为什么特别难

地图标牌是一个典型的混合难题,因为一张卡片通常同时有两种透明区域

diagram-07.jpg

一张卡片里同时存在二值透明和连续半透明,不能靠单一策略解决

这意味着一张 billboard 同时需要两种处理策略:

// 圆角外:用 alphaTest 裁掉,不参与混合
alphaTest: 0.05

// 面板本体:用半透明混合 + 关闭深度写入
transparent: true
depthTest:   true
depthWrite:  false
// 同时在批次内部做从远到近排序(机制详见第 08 节)

alphaTest 先把完全透明的像素裁掉,让它们不参与混合运算;剩余的半透明区域则靠正确排序来混色。两者各司其职。

08 “批次内部排序”到底是怎么做的

前几节多次提到“批次内部从远到近排序”,这里把它的具体机制展开讲清楚。

当多张卡片合并成一个 Mesh,GPU 实际处理的是一堆三角形。每张卡片由 2 个三角形(4 个顶点)组成。决定这些三角形绘制顺序的,不是顶点数据,而是 index buffer(索引缓冲)。

diagram-08.jpg

顶点位置不动,只重写索引顺序,就能控制三角形的绘制先后

代码层面的做法如下:

// 每帧(或相机移动后)执行
const sorted = cards
  .map((card, i) => ({
    i,
    dist: camera.position.distanceTo(card.worldPos)
  }))
  .sort((a, b) => b.dist - a.dist)  // 远→近

const indices = new Uint32Array(cards.length * 6)
sorted.forEach(({ i }, slot) => {
  const base = i * 4       // 每张卡片 4 个顶点
  const out  = slot * 6    // 每张卡片 6 个索引(2 个三角形)
  indices[out + 0] = base + 0; indices[out + 1] = base + 1; indices[out + 2] = base + 2
  indices[out + 3] = base + 2; indices[out + 4] = base + 1; indices[out + 5] = base + 3
})

geometry.index.array.set(indices)
geometry.index.needsUpdate = true  // 通知 GPU 本帧重新上传

这个操作完全在 CPU 上进行,代价是 O(n log n) 排序 + 一次 index buffer 上传。对几十到几百张卡片来说完全可控,但随卡片数量增长开销是真实存在的。

三个常见优化方向:

① 只在相机发生位移或旋转时触发排序,静止帧跳过;

② 用预分配的 Float32Array 复用内存,避免每帧 GC;

③ 卡片数量极大时(1000+)把排序逻辑移入 Web Worker,不阻塞主线程。

09 这套方案的边界在哪里

以上策略已经能覆盖绝大多数地图标牌和 UI 面板场景。但它不是万能的。以下情况会让它失效:

  • 透明几何体彼此穿插,单一顺序无法对所有像素都正确
  • 大量透明层堆叠,形成循环遮挡关系
  • 需要接近物理正确的复杂折射和透射效果

遇到这些情况,需要考虑更高级的 OIT(Order-Independent Transparency)方案:

方案 原理 适用场景
Weighted Blended OIT 按权重累积透明片元,最后合成,近似解 大量粒子、烟雾、透明层很多
Depth Peeling 多 pass 逐层剥离透明层再合成,精确解 玻璃穿插、高精度透明
Alpha Hash 用随机抖动近似透明,无排序依赖 表面透明度均匀、可接受轻微噪点

对地图标牌来说,这些高级技术通常属于“理论上可用,工程上没必要先上”的范围。

10 快速决策表

遇到透明配置问题,先判断你的内容属于哪种情况:

情况 A:主体不透明,只有外轮廓裁剪

  • 图标、带镂空的标记
  • 圆角外是空白
  • 本身不透出背景

推荐配置:

  • alphaTest
  • 保留 depthWrite: true
  • 稳定遮挡,像实体卡片

情况 B:背景是真正的半透明

  • 能透出后面的内容
  • 玻璃质感面板
  • alpha 在 0.3–0.9 之间

推荐配置:

  • transparent: true
  • depthWrite: false
  • 再配合批次内部从远到近排序

情况 C:两者都有(典型地图标牌)

  • 圆角外是 alpha=0 的空白
  • 面板本体是 alpha=0.75 的半透明背景

推荐配置:

  • alphaTest: 0.05,裁掉完全透明的角落
  • transparent: true + depthWrite: false,处理面板半透明混合
  • 批次内部排序保证叠加顺序正确

如果只记住一句话,请记这个:

不透明物体靠深度,半透明物体靠顺序。

一张卡片里两者都有,就需要两套策略各司其职。

一旦把 transparentdepthTestdepthWrite 的职责分清楚,再回头看任何透明配置,都不会觉得矛盾了。

Vue3 集成 NProgress 进度条:从入门到精通

在前端应用开发中,用户体验至关重要。当页面加载或进行数据请求时,一个优雅的进度条不仅能告知用户系统正在工作,还能有效缓解用户的等待焦虑。NProgress 作为一款轻量级的进度条库,凭借其简洁的设计和良好的兼容性,被广泛应用于各类 Web 项目中。本文将详细介绍如何在 Vue3 项目中优雅地集成和使用 NProgress

一、环境准备

1.1 安装依赖

# 安装 NProgress 核心库和 lodash-es 工具库
pnpm i nprogress lodash-es

# 安装 TypeScript 类型定义(开发依赖)
pnpm i @types/nprogress @types/lodash-es -D

1.2 依赖说明

  • nprogress:进度条核心库,提供简单的进度控制 API
  • lodash-es:高效的 JavaScript 工具库,用于对象合并等操作
  • @types/nprogress:NProgress 的 TypeScript 类型定义文件
  • @types/lodash-es:lodash-es 的 TypeScript 类型定义文件

1.3 环境变量配置

.env 文件中配置进度条的开关:

# 路由进度条,默认开启(设置为 'false' 可关闭)
VITE_ROUTER_NPROGRESS = true

# 请求进度条,默认开启(设置为 'false' 可关闭)
VITE_REQUEST_NPROGRESS = true

二、核心实现

2.1 基础封装

// src/hooks/useProgress.ts

import { merge } from 'lodash-es'
import NProgress from 'nprogress'
import type { NProgressOptions } from 'nprogress'

interface ProgressConfig extends NProgressOptions {
  /** 是否显示进度条 */
  show: boolean
}

const DEFAULT_CONFIG: Partial<ProgressConfig> = {
  /** CSS3 缓冲动画字符串,支持 ease、linear、ease-in、ease-out、ease-in-out 以及自定义 cubic-bezier 等 */
  easing: 'ease',
  /** 指定进度条的父容器,默认为 body */
  parent: 'body',
  /** 是否显示进度条,可通过环境变量控制 */
  show: true,
  /** 是否显示右侧的环形进度动画 */
  showSpinner: false,
  /** 是否开启自动递增模式 */
  trickle: true,
  /** 设置开始时最低百分比,范围 0-1 */
  minimum: 0.08,
  /** 动画速度,单位毫秒 */
  speed: 200,
}

/**
 * 进度条控制工具 Hook
 * @param config 自定义配置,会与默认配置深度合并
 * @returns { start, done } 启动/结束进度条方法
 */
export function useProgress(config: Partial<ProgressConfig> = {}) {
  const mergeConfig = merge({}, DEFAULT_CONFIG, config)
  NProgress.configure(mergeConfig)

  /**
   * 启动进度条
   */
  function start() {
    if (!mergeConfig.show) return
    NProgress.start()
  }

  /**
   * 结束进度条
   */
  function done() {
    if (!mergeConfig.show || !NProgress.isStarted()) return
    NProgress.done()
  }

  return { start, done }
}

三、实际应用场景

3.1 Axios 请求拦截器集成

在实际项目中,我们通常需要为 API 请求自动添加进度条。以下是配合 Axios 使用的完整示例,通过环境变量控制是否显示:

// src/utils/request.ts

import axios, { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'

const NProgress = useProgress({ show: import.meta.env.VITE_REQUEST_NPROGRESS !== 'false' })

const instance: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 15000,
})

// 请求拦截器
instance.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    NProgress.start()
    return config
  },
  (error) => {
    NProgress.done()
    return Promise.reject(error)
  },
)

// 响应拦截器
instance.interceptors.response.use(
  (response: AxiosResponse) => {
    NProgress.done()
    return response
  },
  (error) => {
    NProgress.done()
    return Promise.reject(error)
  },
)

export const request = instance

3.2 Vue Router 路由守卫集成

结合 Vue Router,可以在页面切换时显示进度条,通过环境变量控制是否显示:

// src/router/index.ts

import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [],
})

const NProgress = useProgress({ show: import.meta.env.VITE_ROUTER_NPROGRESS !== 'false' })

router.beforeEach((to, from, next) => {
  NProgress.start()
  next()
})

router.afterEach(() => {
  NProgress.done()
})

export default router

3.3 组合式使用示例

<template>
  <div class="app">
    <button @click="loadData">加载数据</button>
  </div>
</template>

<script setup lang="ts">
import { useProgress } from '@/hooks/useProgress'

const NProgress = useProgress({ show: import.meta.env.VITE_REQUEST_NPROGRESS !== 'false' })

async function loadData() {
  NProgress.start()
  try {
    await fetch('/api/data')
  } finally {
    NProgress.done()
  }
}
</script>

四、全局样式配置

4.1 全局样式入口文件

创建全局样式入口文件,统一管理项目样式:

// src/styles/index.scss

@use './variables.scss';
@use './transition.scss';
@use 'nprogress/nprogress.css';
@use './element-plus/el-table.scss';
@use './element-plus/el-dialog.scss';
@use './element-plus/el-dropdown.scss';

body {
  font-family: var(--el-font-family);
  background-color: var(--el-bg-color-page);
}

#nprogress .bar {
  background-color: var(--el-color-primary);
}

4.2 样式文件说明

  • variables.scss:Element Plus 主题变量定义
  • transition.scss:全局过渡动画样式
  • nprogress.css:NProgress 进度条基础样式
  • element-plus/*.scss:Element Plus 组件样式覆盖
  • 全局样式:包含进度条颜色等自定义样式

4.3 NProgress 主题样式覆盖

如果需要更详细的自定义 NProgress 样式,可以创建专门的样式文件:

// src/styles/nprogress.scss

#nprogress .bar {
  background-color: var(--el-color-primary);
  height: 3px;

  // 添加渐变效果
  background: linear-gradient(90deg, var(--el-color-primary-light-3) 0%, var(--el-color-primary) 100%);
}

#nprogress .peg {
  box-shadow: 0 0 10px var(--el-color-primary);
}

#nprogress .spinner-icon {
  border-top-color: var(--el-color-primary);
  border-left-color: var(--el-color-primary);
}

4.4 在入口文件中引入

// src/styles/index.scss

@use './variables.scss';
@use './transition.scss';
@use './nprogress.scss'; // 替换为自定义样式文件
@use './element-plus/el-table.scss';
@use './element-plus/el-dialog.scss';
@use './element-plus/el-dropdown.scss';

body {
  font-family: var(--el-font-family);
  background-color: var(--el-bg-color-page);
}

#nprogress .bar {
  background-color: var(--el-color-primary);
}

4.5 main.ts 中引入全局样式

// src/main.ts

import { createApp } from 'vue'
import App from './App.vue'
import './styles/index.scss' // 引入全局样式

const app = createApp(App)
app.mount('#app')

五、NProgress 配置详解

5.1 核心配置项

配置项 类型 默认值 说明
easing string 'ease' CSS3 缓动函数
speed number 200 动画速度(毫秒)
trickle boolean true 是否自动递增
trickleSpeed number 200 自动递增速度
minimum number 0.08 起始百分比
showSpinner boolean false 是否显示环形动画
showUI boolean false 是否显示进度条
parent string 'body' 父容器选择器
positionUsing string '' 定位方式

5.2 缓动函数推荐

const EASING_FUNCTIONS = {
  // 匀速运动
  linear: 'linear',

  // 标准缓动
  ease: 'ease',
  easeIn: 'ease-in',
  easeOut: 'ease-out',
  easeInOut: 'ease-in-out',

  // 自定义贝塞尔曲线
  smooth: 'cubic-bezier(0.4, 0, 0.2, 1)',
  gentle: 'cubic-bezier(0.25, 0.1, 0.25, 1)',
  swift: 'cubic-bezier(0.4, 0, 0.6, 1)',
}

五、总结

通过本文的学习,你应该已经掌握了:

  1. 基础集成:如何在 Vue3 项目中安装和配置 NProgress
  2. 封装技巧:如何封装通用的进度条 Hook,提高代码复用性
  3. 环境变量控制:如何通过环境变量灵活控制进度条的开关
  4. 实际应用:如何与 Axios、Vue Router 等常见库配合使用
  5. 全局样式配置:如何通过全局样式统一管理进度条外观

前端驱动工业报警:基于 WebSocket 与网关的三色蜂鸣灯实时报警系统实战

前言

在智能制造和工业 4.0 的浪潮下,越来越多的工厂车间需要实时监控设备的运行状态。三色报警灯(红、黄、绿)作为工业现场最直观的“视觉语言”,配合蜂鸣器发出的声音告警,能够第一时间通知现场人员设备出现了什么问题。

传统的三色灯通常直接连接到 PLC 的数字输出点,由 PLC 根据设备状态直接控制。但在很多智能化改造场景中,我们希望前端 Web 应用能够实时采集设备运行参数,并根据预设的阈值规则远程控制三色蜂鸣灯的报警状态。这就面临一个问题:三色灯不与设备机器直接通信,而是通过网关接入网络

本文将从前端工程师的视角,详细介绍如何通过 WebSocket 实时接收设备采集数据,并结合网关的 Modbus TCP 协议实现三色蜂鸣灯的远程报警控制。全文涵盖系统架构设计、前端核心代码实现、协议解析和性能优化,力求提供一套可落地的技术方案。

一、系统架构概览

在正式写代码之前,先理清整个系统的数据流向:

┌─────────────┐     ┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│  设备传感器  │────▶│   边缘网关   │────▶│   后端服务   │────▶│  前端 Web   │
│ (温度/振动等)│     │ (Modbus/MQTT)│     │ (WebSocket)  │     │  (实时监控)  │
└─────────────┘     └──────┬──────┘     └──────┬──────┘     └──────┬──────┘
                           │                    │                    │
                           ▼                    ▼                    ▼
                    ┌─────────────┐     ┌─────────────┐     ┌─────────────┐
                    │ 三色蜂鸣灯  │◀────│  控制指令   │◀────│  阈值判断   │
                    │(Modbus TCP) │     │  (HTTP/WS)  │     │  (前端逻辑)  │
                    └─────────────┘     └─────────────┘     └─────────────┘

核心组件说明:

  1. 设备传感器:采集设备运行参数,如温度、振动频率、转速、电流等,通过串口、网口或无线方式上报给网关。
  2. 边缘网关:负责协议转换和数据汇聚,将传感器数据统一封装后上报后端,同时接收后端下发的控制指令,通过 Modbus TCP 协议驱动三色蜂鸣灯。
  3. 后端服务:提供 WebSocket 服务端,负责实时推送设备数据到前端,并接收前端的报警控制指令。
  4. 前端 Web 应用:接收 WebSocket 实时数据流,与预设的标准阈值对比,当参数超限时触发报警逻辑,向网关发送三色蜂鸣灯的控制指令。
  5. 三色蜂鸣灯:通过 Modbus TCP 协议接入网关,根据指令显示红/黄/绿状态并触发蜂鸣器。

二、通信协议选型

2.1 数据上报:WebSocket 实时推送

设备监控场景对实时性要求极高,轮询方式不仅浪费带宽,还存在延迟。WebSocket 建立长连接后,服务端可以主动向客户端推送数据,实现毫秒级的实时响应。

选型要点:

  • 原生 WebSocket 协议简单,前端 new WebSocket() 即可使用,无需额外库。
  • 需要心跳机制保持连接,防止防火墙或代理自动断开。
  • 在复杂场景下可以考虑 Socket.IO 或 Netty-SocketIO,它们提供了自动重连、房间管理、降级轮询等高级特性。

2.2 设备控制:Modbus TCP 协议

三色蜂鸣灯通过网关接入网络后,通常支持 Modbus TCP 协议进行远程控制。泓格科技的 ALM-Horn-RGB 等工业声光警报器就是典型的 Modbus TCP 设备,支持通过网络远程设置 LED 颜色和蜂鸣器状态。

Modbus TCP 的核心概念:

概念 说明
从站地址(Unit ID) 设备的唯一标识,通常为 1~247
功能码 03 读保持寄存器,06 写单个寄存器,16 写多个寄存器
寄存器地址 每个控制功能对应一个寄存器地址,如颜色控制寄存器、蜂鸣器控制寄存器
数据值 写入寄存器的具体值,如 0x01=红色、0x02=黄色、0x03=绿色

例如,向某个三色灯发送红色常亮指令,可能对应功能码 06(写单个寄存器),寄存器地址 0x0000,数据值 0x0001。

三、前端核心实现

3.1 建立 WebSocket 连接

class DeviceMonitor {
  constructor(options) {
    this.wsUrl = options.wsUrl || 'ws://localhost:8080/device-data';
    this.reconnectInterval = options.reconnectInterval || 3000;
    this.heartbeatInterval = options.heartbeatInterval || 30000;
    this.thresholds = options.thresholds || {}; // 阈值配置
    this.onAlarm = options.onAlarm; // 报警回调
    this.init();
  }

  init() {
    this.connect();
    this.bindEvents();
  }

  connect() {
    this.ws = new WebSocket(this.wsUrl);
    
    this.ws.onopen = () => {
      console.log('[DeviceMonitor] WebSocket 连接成功');
      this.startHeartbeat();
      // 连接成功后可以发送订阅消息
      this.send({
        type: 'subscribe',
        devices: this.deviceIds
      });
    };

    this.ws.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        this.handleDeviceData(data);
      } catch (err) {
        console.error('[DeviceMonitor] 数据解析失败', err);
      }
    };

    this.ws.onerror = (error) => {
      console.error('[DeviceMonitor] WebSocket 错误', error);
    };

    this.ws.onclose = () => {
      console.log('[DeviceMonitor] WebSocket 连接关闭,尝试重连...');
      this.stopHeartbeat();
      this.reconnect();
    };
  }

  send(data) {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(data));
    }
  }

  startHeartbeat() {
    this.heartbeatTimer = setInterval(() => {
      this.send({ type: 'ping', timestamp: Date.now() });
    }, this.heartbeatInterval);
  }

  stopHeartbeat() {
    if (this.heartbeatTimer) {
      clearInterval(this.heartbeatTimer);
      this.heartbeatTimer = null;
    }
  }

  reconnect() {
    if (this.reconnectTimer) return;
    this.reconnectTimer = setTimeout(() => {
      this.reconnectTimer = null;
      this.connect();
    }, this.reconnectInterval);
  }
}

3.2 数据处理与阈值判断

接收到设备数据后,需要与预设的标准阈值进行对比,决定是否触发报警:

// 报警等级定义
const AlarmLevel = {
  NORMAL: { level: 0, color: 'green', buzzer: false },
  WARNING: { level: 1, color: 'yellow', buzzer: false },
  CRITICAL: { level: 2, color: 'red', buzzer: true }
};

// 阈值配置示例
const THRESHOLD_CONFIG = {
  temperature: {
    warning: { min: 60, max: 75 },
    critical: { min: 75, max: 100 }
  },
  vibration: {
    warning: { min: 4.5, max: 8.0 },
    critical: { min: 8.0, max: 20.0 }
  },
  current: {
    warning: { min: 15, max: 25 },
    critical: { min: 25, max: 50 }
  }
};

class AlarmEngine {
  constructor(thresholds) {
    this.thresholds = thresholds;
    this.currentAlarmLevel = AlarmLevel.NORMAL;
    this.alarmHistory = [];
  }

  evaluate(deviceData) {
    const { temperature, vibration, current } = deviceData;
    
    // 检查是否有任何参数达到严重告警
    if (this.isCritical(temperature, 'temperature') ||
        this.isCritical(vibration, 'vibration') ||
        this.isCritical(current, 'current')) {
      return AlarmLevel.CRITICAL;
    }
    
    // 检查是否有任何参数达到警告告警
    if (this.isWarning(temperature, 'temperature') ||
        this.isWarning(vibration, 'vibration') ||
        this.isWarning(current, 'current')) {
      return AlarmLevel.WARNING;
    }
    
    return AlarmLevel.NORMAL;
  }

  isWarning(value, param) {
    const config = this.thresholds[param];
    if (!config || !config.warning) return false;
    const { min, max } = config.warning;
    return value >= min && value < max;
  }

  isCritical(value, param) {
    const config = this.thresholds[param];
    if (!config || !config.critical) return false;
    const { min, max } = config.critical;
    return value >= min && value < max;
  }

  // 避免告警抖动,增加防抖逻辑
  shouldTriggerAlarm(newLevel) {
    // 如果告警等级上升,立即触发
    if (newLevel.level > this.currentAlarmLevel.level) {
      return true;
    }
    // 如果告警等级下降,需要连续 N 次确认后才恢复
    if (newLevel.level < this.currentAlarmLevel.level) {
      // 这里可以实现连续确认逻辑
      return false;
    }
    return false;
  }
}

3.3 触发报警控制

当检测到参数超限时,前端通过 HTTP 请求向后端发送报警控制指令,后端再通过网关的 Modbus TCP 接口驱动三色蜂鸣灯:

class AlarmController {
  constructor(apiBase) {
    this.apiBase = apiBase;
    this.currentState = null;
  }

  async triggerAlarm(deviceId, level) {
    // 防止重复发送相同状态
    if (this.currentState && 
        this.currentState.deviceId === deviceId && 
        this.currentState.level === level) {
      return;
    }

    const command = this.buildAlarmCommand(level);
    
    try {
      const response = await fetch(`${this.apiBase}/alarm/trigger`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          deviceId,
          color: command.color,
          pattern: command.pattern, // 'on' | 'blink_slow' | 'blink_fast'
          buzzer: command.buzzer,
          buzzerPattern: command.buzzerPattern
        })
      });

      const result = await response.json();
      if (result.success) {
        this.currentState = { deviceId, level };
        console.log(`[Alarm] 报警已触发,设备: ${deviceId},等级: ${level}`);
        
        // 记录告警日志
        this.logAlarm(deviceId, level);
      }
    } catch (error) {
      console.error('[Alarm] 报警触发失败', error);
      // 可以考虑加入重试机制
      this.retryTrigger(deviceId, level);
    }
  }

  buildAlarmCommand(level) {
    switch (level) {
      case AlarmLevel.CRITICAL:
        return {
          color: 'red',
          pattern: 'blink_fast',
          buzzer: true,
          buzzerPattern: 'intermittent'
        };
      case AlarmLevel.WARNING:
        return {
          color: 'yellow',
          pattern: 'on',
          buzzer: false
        };
      default:
        return {
          color: 'green',
          pattern: 'on',
          buzzer: false
        };
    }
  }

  async resetAlarm(deviceId) {
    return this.triggerAlarm(deviceId, AlarmLevel.NORMAL);
  }

  logAlarm(deviceId, level) {
    // 上报告警事件到日志系统
    console.log(`[AlarmLog] ${new Date().toISOString()} | Device: ${deviceId} | Level: ${level}`);
  }
}

3.4 完整监控组件整合

// 使用示例:React 组件中的整合
import { useState, useEffect, useRef } from 'react';

function DeviceMonitorDashboard({ deviceId, thresholds }) {
  const [deviceData, setDeviceData] = useState(null);
  const [alarmLevel, setAlarmLevel] = useState(AlarmLevel.NORMAL);
  const [connectionStatus, setConnectionStatus] = useState('disconnected');
  
  const monitorRef = useRef(null);
  const alarmControllerRef = useRef(null);
  const alarmEngineRef = useRef(null);

  useEffect(() => {
    // 初始化报警引擎
    alarmEngineRef.current = new AlarmEngine(thresholds);
    alarmControllerRef.current = new AlarmController('/api/v1');
    
    // 初始化设备监控
    monitorRef.current = new DeviceMonitor({
      wsUrl: `ws://gateway.local:8080/device/${deviceId}/stream`,
      thresholds,
      onAlarm: (level) => {
        setAlarmLevel(level);
        alarmControllerRef.current.triggerAlarm(deviceId, level);
      }
    });

    // 扩展 onmessage 处理
    const originalOnMessage = monitorRef.current.ws.onmessage;
    monitorRef.current.ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      setDeviceData(data);
      
      // 阈值判断
      const newLevel = alarmEngineRef.current.evaluate(data);
      if (alarmEngineRef.current.shouldTriggerAlarm(newLevel)) {
        setAlarmLevel(newLevel);
        alarmControllerRef.current.triggerAlarm(deviceId, newLevel);
      }
      
      // 调用原始处理
      if (originalOnMessage) originalOnMessage(event);
    };

    return () => {
      // 清理连接
      monitorRef.current?.ws?.close();
    };
  }, [deviceId, thresholds]);

  return (
    <div className="device-monitor">
      <div className={`status-indicator ${alarmLevel.color}`}>
        <span>当前状态: {alarmLevel.level === 0 ? '正常' : alarmLevel.level === 1 ? '警告' : '严重告警'}</span>
      </div>
      
      {deviceData && (
        <div className="data-panel">
          <div>温度: {deviceData.temperature}°C</div>
          <div>振动: {deviceData.vibration} mm/s</div>
          <div>电流: {deviceData.current} A</div>
        </div>
      )}
      
      <div className="connection-status">
        连接状态: {connectionStatus}
      </div>
    </div>
  );
}

四、后端网关通信实现(简述)

虽然本文聚焦前端,但为了让整体方案更完整,这里简述后端如何与网关交互控制三色蜂鸣灯:

// Node.js 后端示例:通过 Modbus TCP 控制三色灯
const ModbusRTU = require('modbus-serial');

class ModbusAlarmController {
  constructor(options) {
    this.host = options.host;
    this.port = options.port || 502; // Modbus TCP 默认端口
    this.unitId = options.unitId || 1;
    this.client = new ModbusRTU();
  }

  async connect() {
    await this.client.connectTCP(this.host, { port: this.port });
    this.client.setID(this.unitId);
    console.log(`[Modbus] 已连接到 ${this.host}:${this.port}`);
  }

  // 设置 LED 颜色
  // 假设寄存器 0x0000 控制颜色:0=关, 1=红, 2=黄, 3=绿
  async setColor(color) {
    const colorMap = { red: 1, yellow: 2, green: 3, off: 0 };
    const value = colorMap[color] || 0;
    await this.client.writeRegister(0x0000, value);
    console.log(`[Modbus] 设置颜色: ${color} (${value})`);
  }

  // 设置闪烁模式
  // 假设寄存器 0x0001 控制闪烁:0=常亮, 1=慢闪, 2=快闪
  async setBlinkPattern(pattern) {
    const patternMap = { on: 0, blink_slow: 1, blink_fast: 2 };
    const value = patternMap[pattern] || 0;
    await this.client.writeRegister(0x0001, value);
  }

  // 控制蜂鸣器
  // 假设寄存器 0x0002 控制蜂鸣器:0=关, 1=开
  async setBuzzer(enabled) {
    await this.client.writeRegister(0x0002, enabled ? 1 : 0);
  }

  async close() {
    this.client.close();
  }
}

// Express 路由示例
app.post('/api/v1/alarm/trigger', async (req, res) => {
  const { deviceId, color, pattern, buzzer } = req.body;
  
  try {
    const controller = getModbusController(deviceId);
    await controller.setColor(color);
    await controller.setBlinkPattern(pattern);
    await controller.setBuzzer(buzzer);
    
    res.json({ success: true, message: '报警指令已下发' });
  } catch (error) {
    res.status(500).json({ success: false, error: error.message });
  }
});

五、WebSocket 连接优化策略

工业现场网络环境复杂,WebSocket 连接的稳定性直接影响报警系统的可靠性。以下是一些经过验证的优化策略:

5.1 心跳保活

WebSocket 连接可能因网络波动、防火墙策略等原因被中断。心跳机制是保持连接的有效手段:

class HeartbeatManager {
  constructor(ws, options = {}) {
    this.ws = ws;
    this.pingInterval = options.pingInterval || 30000;
    this.pongTimeout = options.pongTimeout || 10000;
    this.timer = null;
    this.pongTimer = null;
  }

  start() {
    this.timer = setInterval(() => {
      if (this.ws.readyState === WebSocket.OPEN) {
        this.ws.send(JSON.stringify({ type: 'ping' }));
        
        // 设置 pong 超时检测
        this.pongTimer = setTimeout(() => {
          console.warn('[Heartbeat] Pong 超时,主动断开连接');
          this.ws.close();
        }, this.pongTimeout);
      }
    }, this.pingInterval);
  }

  onPong() {
    if (this.pongTimer) {
      clearTimeout(this.pongTimer);
      this.pongTimer = null;
    }
  }

  stop() {
    if (this.timer) clearInterval(this.timer);
    if (this.pongTimer) clearTimeout(this.pongTimer);
    this.timer = null;
    this.pongTimer = null;
  }
}

5.2 指数退避重连

网络断开后,不宜立即高频重连,应采用指数退避策略:

class ReconnectScheduler {
  constructor(maxDelay = 60000) {
    this.attempt = 0;
    this.maxDelay = maxDelay;
  }

  nextDelay() {
    // 指数退避:2^attempt * 1000,最大 60 秒
    const delay = Math.min(1000 * Math.pow(2, this.attempt), this.maxDelay);
    this.attempt++;
    return delay;
  }

  reset() {
    this.attempt = 0;
  }
}

5.3 数据去重与乱序处理

WebSocket 数据推送可能在重连后出现重复或乱序。可以在数据包中加入序列号:

class DataStreamHandler {
  constructor() {
    this.lastSeq = 0;
    this.dataBuffer = new Map();
  }

  handle(data) {
    const { seq, timestamp, payload } = data;
    
    // 丢弃旧数据
    if (seq <= this.lastSeq) {
      console.warn(`[Stream] 丢弃重复/过期数据 seq=${seq}`);
      return null;
    }
    
    this.lastSeq = seq;
    return payload;
  }
}

六、常见问题与解决方案

6.1 浏览器兼容性

WebSocket 在所有现代浏览器中都有良好支持。但如果需要兼容 IE 等老旧浏览器,可以考虑使用 Socket.IO,它提供了自动降级到长轮询的能力。

6.2 网关指令下发延迟

Modbus TCP 通信本身存在网络延迟,加上网关处理时间,指令下发到三色灯亮起可能有 100~500ms 的延迟。在阈值判断时应考虑这一延迟,可以适当设置报警触发缓冲时间,避免因瞬时波动频繁触发。

6.3 多设备并发控制

当监控多个设备时,需要注意网关的并发处理能力。建议在前端实现指令队列,避免短时间大量指令同时下发导致网关过载:

class CommandQueue {
  constructor(interval = 100) {
    this.queue = [];
    this.interval = interval;
    this.running = false;
  }

  enqueue(command) {
    return new Promise((resolve, reject) => {
      this.queue.push({ command, resolve, reject });
      this.run();
    });
  }

  async run() {
    if (this.running) return;
    this.running = true;
    
    while (this.queue.length > 0) {
      const { command, resolve, reject } = this.queue.shift();
      try {
        const result = await this.execute(command);
        resolve(result);
      } catch (err) {
        reject(err);
      }
      await this.sleep(this.interval);
    }
    
    this.running = false;
  }
}

6.4 报警状态持久化

当 WebSocket 断连时,前端无法接收到最新数据,但三色灯应该保持最后的状态。建议在后端维护报警状态的持久化记录,前端重连后同步最新状态。

七、总结

本文从前端工程师的视角,详细介绍了如何通过 WebSocket 实时接收设备采集数据,并结合网关的 Modbus TCP 协议驱动三色蜂鸣灯实现远程报警。核心要点回顾:

  1. 通信架构:设备数据通过 WebSocket 推送到前端,前端判断阈值后通过 HTTP 下发报警指令,后端再通过 Modbus TCP 控制三色灯。
  2. 协议选型:WebSocket 保证数据实时性,Modbus TCP 实现工业设备的标准化控制。
  3. 前端核心逻辑:阈值判断 + 告警去抖 + 状态防重复,保证报警的准确性和稳定性。
  4. 可靠性保障:心跳保活、指数退避重连、数据去重、指令队列,应对工业现场复杂的网络环境。

随着工业物联网的不断发展,前端工程师在工业智能化场景中的参与度会越来越高。掌握 WebSocket 实时通信和工业协议的基本概念,将帮助前端工程师更好地融入智能制造的技术生态。

希望本文能为正在做工业监控项目的同学提供一些参考和启发。欢迎在评论区交流讨论!

vue3.x 内置指令有哪些?

Vue 3 提供了 14 个内置指令,用于在模板中实现响应式行为、DOM 操作和性能优化。

一 、v-cloak

v-cloak 是 Vue 的一个编译控制指令,用于解决 未编译的模板(Mustache 标签)短暂显示 的问题(即“闪烁”现象)。它不依赖于响应式数据,只在组件编译阶段起作用。

v-cloak 的原理非常简单,分为两个层面:

  1. CSS 隐藏:开发者需要编写 CSS 规则,例如 [v-cloak] { display: none; }。这样,带有 v-cloak 属性的元素会一开始就被隐藏。
  2. Vue 自动移除:当 Vue 编译完该组件的模板后,会自动移除元素上的 v-cloak 属性,从而让元素显示出来。

因此,用户看到的效果是:模板内容在编译完成前是隐藏的,编译完成后立即显示,不会出现未编译的 {{ message }} 一闪而过。

在典型的基于 SFC(单文件组件)和构建工具(Vite、Webpack)的 Vue 项目中,模板会在编译时被预编译为 render 函数,浏览器最终执行的是 render 函数生成的虚拟 DOM,不会包含原始的 {{ message }} 语法。因此不会看到未编译的插值闪烁现象。

二、v-pre

v-pre 是 Vue 的一个编译跳过指令,用于告诉 Vue 编译器跳过该元素及其所有子元素的编译过程,直接输出原始内容。

【示例】

<template>
  <div v-pre>
    {{ message }}
    <p v-text="message">这是一个段落</p>
  </div>
</template>

image.png

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("div", null, [..._cache[0] || (_cache[0] = [_createTextVNode(
" {{ message }} ",
-1
/* CACHED */
), _createElementVNode(
"p",
{ "v-text": "message" },
"这是一个段落",
-1
/* CACHED */
)])]);
}

解析器遇到 v-pre 指令时,会将当前节点及其所有子节点标记的属性转为普通属性。

因为没有生成任何响应式相关的渲染代码,这些节点在组件更新时不会被重新渲染或 diff。们始终以静态 HTML 的形式存在。

三、v-on

在 Vue 3 的事件处理体系中,v-on 指令是连接用户操作与 JavaScript 代码的核心纽带。无论是点击按钮、输入文本还是按下键盘,v-on 都扮演着“信号接收器”的角色,让开发者能够以声明式的方式响应用户的每一次交互

v-on 是什么?

v-on 是 Vue 3 中用于监听 DOM 事件并在事件触发时执行指定代码的指令。它的简写形式是 @,这是开发中最常使用的写法

<!-- 完整写法 -->
<button v-on:click="handleClick">点击我</button>

<!-- 简写写法(最常用) -->
<button @click="handleClick">点击我</button>

v-on 基本使用方式

模板编译器通过检查 v-on 的值是否是合法的 JavaScript 标识符或属性访问路径来判断使用哪种处理器:

  1. 有括号的表达式(如 count++handleClick())→ 内联语句处理器
  2. 无括号的标识符(如 handleClickobj.method)→ 方法事件处理器

内联事件处理器

直接将 JavaScript 代码写在 v-on 的值中,适用于简单逻辑。当 v-on 的值是合法的 JavaScript 表达式或标识符时,Vue 会自动识别并处理。

【示例】简单内联表达式

<button @click="count++">点击我</button>
const count = ref(0);

image.png

【示例】多语句,用分号隔开

  <button
    @click="
      count--;
      console.log(count);
    "
  >
    点击我
  </button>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("button", { onClick: _cache[0] || (_cache[0] = ($event) => {
$setup.count--;
console.log($setup.count);
}) }, " 点击我 ");
}

【示例】内联箭头函数

  <button
    @click="
      (event) => {
        count++;
        console.log(event, count);
      }
    "
  >
    点击我
  </button>

编译结果

import { openBlock as _openBlock, createElementBlock as _createElementBlock } from "/node_modules/.vite/deps/vue.js?v=b3e6ce82";
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("button", { onClick: _cache[0] || (_cache[0] = (event) => {
$setup.count++;
console.log(event, $setup.count);
}) }, " 点击我 ");
}

【示例】在内联处理器中可以直接调用方法并传递参数

<button @click="handleClick($event)">点击我</button>

【示例】在内联处理器中可以直接调用方法并传递参数

<button @click="handleClick($event, 'click')">点击我</button>
const handleClick = (event: PointerEvent, type: string) => {
  count.value++;
  console.log("点击了按钮", event, type);
};

【示例】

<template>
  <div>
    <p>当前数字 {{ count }}</p>
    <button
      @click.stop="
        handleClick();
        handleClick2($event);
      "
    >
      增加
    </button>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
const count = ref(0);

const handleClick = () => {
  count.value++;
};

const handleClick2 = (event: Event) => {
  console.log(event);
};

defineOptions({
  name: "CloudView",
});
</script>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("div", null, [_createElementVNode(
"p",
null,
"当前数字 " + _toDisplayString($setup.count),
1
/* TEXT */
), _createElementVNode("button", { onClick: _cache[0] || (_cache[0] = _withModifiers(($event) => {
$setup.handleClick();
$setup.handleClick2($event);
}, ["stop"])) }, " 增加 ")]);
}

【示例】

<template>
  <div>
    <p>当前数字 {{ count }}</p>
    <button
      @click="handleClick3"
      @click.stop="
        handleClick();
        handleClick2($event);
      "
    >
      增加
    </button>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
const count = ref(0);

const handleClick = () => {
  count.value++;
};

const handleClick2 = (event: Event) => {
  console.log(event);
};

const handleClick3 = () => {
  console.log("点击了按钮");
};

defineOptions({
  name: "CloudView",
});
</script>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("div", null, [_createElementVNode(
"p",
null,
"当前数字 " + _toDisplayString($setup.count),
1
/* TEXT */
), _createElementVNode("button", { onClick: [$setup.handleClick3, _cache[0] || (_cache[0] = _withModifiers(($event) => {
$setup.handleClick();
$setup.handleClick2($event);
}, ["stop"]))] }, " 增加 ")]);
}

方法事件处理器

对于复杂逻辑,推荐使用方法作为事件处理器。方法事件处理器会自动接收原生 DOM 事件对象作为参数。

【示例】默认传递原生事件对象

<button @click="handleClick">点击我</button>

<button v-on:click="handleClick">点击我</button>
const handleClick = (event: PointerEvent) => {
  count.value++;
  console.log("点击了按钮", event);
};

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("button", { onClick: $setup.handleClick }, "点击我");
}

修饰符

一、事件修饰符

事件修饰符 是 Vue 为 v-on 指令(@)提供的特殊后缀,以声明式的方式解决事件处理中常见的底层 DOM 操作,避免在方法中手动调用 event.preventDefault()event.stopPropagation() 等。

【示例 一】捕获模式而非冒泡 capture

<template>
  <button @click.capture="handleClick">点击我</button>
</template>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock(
"button",
{ onClickCapture: $setup.handleClick },
"点击我",
32
/* NEED_HYDRATION */
);
}

【示例 二】事件只触发一次,然后自动解绑 once

<button v-on:click.once="handleClick4">点击我</button>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock(
"button",
{ onClickOnce: $setup.handleClick },
"点击我",
32
/* NEED_HYDRATION */
);
}

【示例 三】提示浏览器不会调用 preventDefault(),提升滚动性能

<template>
  <button @click.passive="handleClick">click</button>
</template>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock(
"button",
{ onClickPassive: $setup.handleClick },
"click",
32
/* NEED_HYDRATION */
);
}

【示例 四】阻止默认行为 prevent event.preventDefault()

<template>
  <a target="_blank" href="https://baidu.com" @click.prevent="handleClick">点击我</a>
</template>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("a", {
target: "_blank",
href: "https://baidu.com",
onClick: _withModifiers($setup.handleClick, ["prevent"])
}, "点击我");
}

【示例 五】仅当 event.target === 当前元素 时触发 self

<template>
  <button @click.self="handleClick">click</button>
</template>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("button", { onClick: _withModifiers($setup.handleClick, ["self"]) }, "click");
}

【示例 六】停止事件冒泡 stop event.stopPropagation()

<button v-on:click.stop="handleClick">点击我</button>

编译结果

import { withModifiers as _withModifiers, openBlock as _openBlock, createElementBlock as _createElementBlock } from "/node_modules/.vite/deps/vue.js?v=b3e6ce82";
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("button", { onClick: _withModifiers($setup.handleClick, ["stop"]) }, "点击我");
}

【 Why?】oncepassivecapture修饰符会编译成onClickCapture这样的属性,而其他修饰符则通过_withModifiers辅助函数处理?

captureoncepassive 是 addEventListener 的底层配置项。它们是浏览器在事件绑定初期就需要确定的参数,可以在一个事件监听器上同时生效,所以 Vue 可以通过在编译阶段修改属性名(如 onClick -> onClickCapture),在最终生成的原生事件绑定中一次性配置。

而 stopprevent 则是对 事件回调函数行为的包装 。它们必须在事件触发时的 回调执行阶段 才能判断并生效,因此需要 Vue 在运行时(runtime)动态地创建一个包装函数(wrapper),在这个函数里按顺序执行 stopPropagation()preventDefault() 等操作,最后才调用你定义的回调。

二、鼠标修饰符

  1. .left左键(默认)
  2. .right右键
  3. .middle中键

【示例】

<template>
  <button @click.left="handleClick">click</button>
</template>
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("button", { onClick: _withModifiers($setup.handleClick, ["left"]) }, "click");
}

【示例】

<template>
  <button @click.middle="handleClick">click</button>
</template>
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock(
"button",
{ onMouseup: _withModifiers($setup.handleClick, ["middle"]) },
"click",
32
/* NEED_HYDRATION */
);
}

【示例】

<template>
  <button @click.right="handleClick">click</button>
</template>
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock(
"button",
{ onContextmenu: _withModifiers($setup.handleClick, ["right"]) },
"click",
32
/* NEED_HYDRATION */
);
}

对比编译区别?

  • .left: 保留原有事件(如 click),修饰符仅用于运行时筛选。
  • .right: 事件名被替换为 contextmenu,这是一个专用于处理右键菜单的浏览器原生事件。
  • .middle: 事件名被替换为 mouseup,用于监听鼠标按键(包括中键)的释放动作

三、系统修饰符

Vue 为 v-on 指令提供了系统修饰符,用于实现仅在按下指定按键时才触发鼠标或键盘事件的监听器。

修饰符 对应按键 (Windows) 对应按键 (macOS)
.ctrl Ctrl 键 Control 键
.alt Alt 键 Option (⌥) 键
.shift Shift 键 Shift 键
.meta Windows (⊞) 键 Command (⌘) 键

【示例】ctrl

在 Windows 操作系统上,使用 Vue 的 @click.ctrl 修饰符时,click 事件会正常触发,并且事件处理函数会执行。 在 macOS 系统中,Control + 点击 的默认行为是触发右键菜单(上下文菜单) ,而不是 click 事件。因此,浏览器会优先派发 contextmenu 事件,而 click 事件根本不会被触发。

<template>
  <button @click.ctrl="handleClick">click</button>
</template>
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("button", { onClick: _withModifiers($setup.handleClick, ["ctrl"]) }, "click");
}

【示例】 meta

在 Windows 系统中,.meta 修饰符对应的是代表 Win 键 (⊞); 在 macOS 系统中,Vue 的 .meta 修饰符对应的是 Command (⌘) 键。

<template>
  <button @click.meta="handleClick">click</button>
</template>
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("button", { onClick: _withModifiers($setup.handleClick, ["meta"]) }, "click");
}

四、按键修饰符

修饰符 对应按键 说明
.enter Enter 键 回车
.tab Tab 键 制表符
.delete Delete 或 Backspace 删除键(两者都匹配)
.esc Escape 退出键
.space Space 空格键
.up 上箭头 ArrowUp
.down 下箭头 ArrowDown
.left 左箭头 ArrowLeft
.right 右箭头 ArrowRight
.page-up PageUp 上翻页
.page-down PageDown 下翻页
.home Home 行首
.end End 行尾

可以直接使用字母或数字作为修饰符(例如 .a.1),Vue 会将其转换为对应的 event.key 值。

【示例】

<template>
  <input @keyup.a="handleClick" />
</template>
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock(
"input",
{ onKeyup: _withKeys($setup.handleClick, ["a"]) },
null,
32
/* NEED_HYDRATION */
);
}

【示例】

<template>
  <input @keyup.1="handleClick" />
</template>

编译阶段

一、解析指令 (Parse)

<button @click="count++">这类模板解析为包含v-on指令信息的AST(抽象语法树)节点,包括事件名click、处理函数表达式count++

二、转换格式 (Transform)

这是核心环节,由transformOn函数完成。

  • 处理事件名:将静态的@click规范化为onClick,用于VNode的props
  • 处理动态事件名:处理@[eventName],生成可解析动态事件名的代码。
  • 包装处理函数:将如count++的内联语句,包装成接收$event参数的事件处理函数$event => (count++)

三、生成代码 (Codegen)

最终生成render函数,包含一个VNode,其props对象里有一个onClick属性,值为包装后的事件处理函数。

运行时阶段

当组件在浏览器中运行时,核心任务就是高效地将虚拟DOM映射到真实DOM上。

一、首次挂载

渲染器执行render函数生成VNode,patch过程中会为真实DOM绑定事件,主要依赖createInvoker这个工厂函数。

createInvoker:事件更新的性能关键createInvoker创建了一个特殊的函数(invoker),充当连接Vue虚拟DOM事件和真实浏览器事件的稳定桥梁

二、更新阶段

当父组件重新渲染导致事件处理函数改变时,渲染器会发现onClick属性变了,进入patchEvent逻辑。

  1. 发现改变:onClick属性的新值和旧值不同。
  2. 更新invoker:Vue不会调用removeEventListeneraddEventListener(传统方式性能差),而是找到该事件对应的invoker对象,直接更新其value属性。
  3. 自动生效:由于invoker函数本身没有变,DOM上的监听器无需任何改动。下次事件触发时,执行的invoker会调用其value属性上已指向的新的事件处理函数

四、v-once

在 Vue 3 的指令集中,v-once 是一个用于性能优化的内置指令。它的核心作用是告诉 Vue: “这个元素及其所有子元素,只渲染一次,后续无论数据如何变化,都不要再更新它们了。”

<p>更新{{ title }}</p>
<p v-once>静态 {{ title }}</p>

示例

<template>
  <div v-once>
    <p>当前计数: {{ count }}</p>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
const count = ref(0);
defineOptions({
  name: "CloudView",
});
</script>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _cache[0] || (_setBlockTracking(-1, true), (_cache[0] = _createElementVNode("div", null, [_createElementVNode(
"p",
null,
"当前计数: " + _toDisplayString($setup.count),
1
/* TEXT */
)])).cacheIndex = 0, _setBlockTracking(1), _cache[0]);
}

编译阶段

一、解析与标记

在编译的转换(Transform)阶段,transformOnce 函数会识别带有 v-once 指令的节点。 找到后,会为其打上“静态”标记,并在上下文中设置标记 context.inVOnce = true,同时将该指令从AST节点中移除。 该标记还会被递归地应用到其所有子节点上,确保整个子树都被视为静态内容。

二、代码生成

生成(Generate)阶段,被打上标记的节点会通过 context.cache() 方法进行缓存。 最终,生成的渲染函数会直接引用一个模块级常量(即被静态提升的节点),而不是在每次渲染时重新创建,从而最大程度地减少运行时开销。

运行阶段

  1. 首屏渲染:组件首次渲染时,render 函数会正常执行,并使用缓存的VNode来创建DOM。
  2. 跳过更新:当组件内的响应式数据发生变化,触发重新渲染时,Vue 的 diff 算法会检测到这些静态节点上的 PatchFlags.STATIC 标志。一旦识别到该标志,渲染器会完全跳过对该节点及其子树的所有更新流程,直接复用第一次渲染时缓存的VNode和对应的真实DOM

transformOnce

vue3-core/packages/compiler-core/src/transforms/vOnce.ts

transformOnce 是 Vue 3 编译器(compiler-core)中用于处理 v-once 指令的节点转换函数。它在 AST 转换阶段识别带有 v-once 指令的元素,并标记该子树为“一次性渲染”区域,最终通过缓存机制使其在后续渲染中直接被复用。

const transformOnce: NodeTransform = (node, context) => {
  // 只处理元素节点,查找节点上的 v-once 指令
  if (node.type === NodeTypes.ELEMENT && findDir(node, 'once', true)) {
    // 检查节点是否已经被处理过,避免重复处理
    if (seen.has(node) || context.inVOnce || context.inSSR) {
      return
    }
    seen.add(node)
    context.inVOnce = true // 标记为 v-once 处理中
    // 注入运行时辅助函数,用于暂时禁用块追踪(Block Tracking)
    context.helper(SET_BLOCK_TRACKING)
    return () => {
      context.inVOnce = false
      const cur = context.currentNode as ElementNode | IfNode | ForNode
      if (cur.codegenNode) {
        cur.codegenNode = context.cache(
          cur.codegenNode,
          true /* isVNode */, // 缓存 VNode 节点
          true /* inVOnce */, // 标记为 v-once 处理中,运行时会在首次渲染后永久复用该 VNode
        )
      }
    }
  }
}

五、v-memo

v-memo 的核心机制是:它接收一个依赖值数组,并缓存该元素及其子树的虚拟 DOM(VNode)。只有当数组中的某个依赖项的值与上一次渲染不同时,Vue 才会重新渲染该子树;否则,将直接复用缓存,跳过整个渲染和差异比对(diff)过程。

使用

<template>
  <div v-memo="[count]">
    {{ count }}
  </div>
</template>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _withMemo([$setup.count], () => (_openBlock(), _createElementBlock("div", null, [_createTextVNode(
_toDisplayString($setup.count),
1
/* TEXT */
)])), _cache, 0);
}

编译阶段

在编译阶段,Vue 的 transformMemo 转换器会识别并处理 v-memo 指令。它只对元素节点生效,并跳过服务端渲染(SSR)场景。

其核心任务是,将带有 v-memo 的节点(_createVNode 调用)包裹在一个名为 _withMemo 的运行时辅助函数调用中。

运行阶段

运行时,渲染函数开始执行。当执行到编译阶段生成的 _withMemo 函数时,核心逻辑如下:

1、执行 _withMemo 函数:该函数接收编译时传入的依赖数组、渲染函数、缓存对象和缓存索引。

2、判断是否命中缓存:它会根据索引查找 _cache 对象中是否已存在 VNode。如果存在,则通过 isMemoSame 函数,使用 Object.is 逐一比较新旧依赖数组中的每一项是否完全一致。

3、渲染或复用

  • 命中缓存:如果依赖数组各项均未改变,_withMemo 将直接返回缓存的 VNode,从而完全跳过了执行渲染函数、创建新 VNode 以及后续的 diff 和 DOM 更新。
  • 未命中缓存:如果依赖数组发生变化,_withMemo 则会执行传入的渲染函数,生成 新的 VNode,并将其更新到缓存中,以供下一次渲染使用

六 、v-if | v-else-if | v-else

Vue 3 中的 v-ifv-else-ifv-else 是用于条件渲染的指令,它们根据表达式的真假值,决定是否将元素或组件渲染到 DOM 中。

使用

【示例】 基本使用

<template>
  <div>
    <h3>v-if 指令</h3>
    <div v-if="show">{{ title }}</div>
    <div v-else>暂无数据</div>
    <button @click="show = !show">切换显示</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";

const title = ref("列表");
const show = ref(false);
</script>

image.png

【示例】

<template>
  <div>
    <div v-if="type === 1">primary</div>
    <div v-else-if="type === 2">暂无数据</div>
    <div v-else>其他</div>
    <button @click="type++">切换显示</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";

const type = ref(0);
</script>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  return _openBlock(), _createElementBlock("div", null, [
    $setup.type === 1 ? (_openBlock(), _createElementBlock("div", _hoisted_1, "primary")) : $setup.type === 2 ? (_openBlock(), _createElementBlock("div", _hoisted_2, "\u6682\u65E0\u6570\u636E")) : (_openBlock(), _createElementBlock("div", _hoisted_3, "\u5176\u4ED6")),
    _createElementVNode("button", {
      onClick: _cache[0] || (_cache[0] = ($event) => $setup.type++)
    }, "\u5207\u6362\u663E\u793A")
  ]);
}

image.png

编译阶段

一、解析阶段:构建条件链表

编译器在解析模板时,会将连续的 v-ifv-else-ifv-else 节点合并为一个 条件节点NodeTypes.IF),其 branches 属性存储各个分支。

二、转换阶段:生成条件表达式

在 transformIf 函数(packages/compiler-core/src/transforms/vIf.ts)中,编译器将条件节点转换为一个 三元运算符链,并为每个分支包裹 openBlock() / createBlock() 调用,因为每个分支都是一个独立的 Block。

三、代码生成阶段:输出 JavaScript

最终生成的代码是一个嵌套的三元运算符,每个分支都使用 openBlock() / createBlock() 来创建 Block。

v-if 会被编译器转换为条件语句(三元运算符或 if 分支),在生成的渲染函数中,根据条件返回不同的虚拟 DOM。

运行阶段

  • 当条件改变时,Vue 的响应式系统触发重新渲染。
  • 渲染函数重新执行,如果条件从假变为真,则创建新的 VNode 并挂载到 DOM;如果从真变为假,则移除对应的 VNode。

七、v-show

v-show 是用于条件显示的内置指令。与 v-if 不同,v-show 不会销毁或重建元素,而是通过切换 CSS 的 display 属性来控制元素的可见性。这意味着元素始终存在于 DOM 中,只是被隐藏或显示。

v-show 的使用

【示例】基本使用

<template>
  <div>
    <div v-show="show">this 展示区</div>
    <button @click="show = !show">切换显示</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";

const show = ref(true);
</script>

image.png

image.png

使用限制

  1. v-show 不支持 <template> 元素,因为 <template> 不会生成真实的 DOM 节点,无法应用 display 样式。

image.png

  1. v-show 没有 v-else 或 v-else-if 的配套指令,它仅单独控制单个元素的显隐。
  2. v-show 必须要有表达式。

image.png

  1. 不要与 v-for 同时使用。v-for 和 v-show 同时使用虽然不会报错,但会导致每次列表变化时重新计算显示状态,性能较差。推荐将 v-show 放在 v-for 内部的元素上,或者使用计算属性过滤后再用 v-for

编译阶段

v-show 会被编译为一个指令,生成一个用于控制 display 的绑定。

运行阶段

当绑定的值变化时,Vue 会直接更新该元素的 style.display 属性(设为 none 或移除/恢复原值)。

八、v-for

【示例】基础使用

<template>
  <div>
    <ul>
      <li v-for="item in books" :key="item">
        <span>
          {{ item }}
        </span>
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";

const books = ref(["book1", "book2", "book3", "book4", "book5"]);
</script>

编译结果

import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, createElementVNode as _createElementVNode } from "/node_modules/.vite/deps/vue.js?v=efe42f93";
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  return _openBlock(), _createElementBlock("div", null, [
    _createElementVNode("ul", null, [
      (_openBlock(true), _createElementBlock(
        _Fragment,
        null,
        _renderList($setup.books, (item) => {
          return _openBlock(), _createElementBlock("li", { key: item }, [
            _createElementVNode(
              "span",
              null,
              _toDisplayString(item),
              1
              /* TEXT */
            )
          ]);
        }),
        128
        /* KEYED_FRAGMENT */
      ))
    ])
  ]);
}

image.png

【示例】

<template>
  <div>
    <ul>
      <li v-for="(item, index) in books" :key="item + index">
        <span>
          {{ item }}
        </span>
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";

const books = ref(["book1", "book2", "book3", "book4", "book5"]);
</script>

image.png

注意事项

  1. 必须为每个列表项提供一个唯一的 key,且不建议使用 index 作为 key(除非列表是静态的且不会重新排序)
  2. 在 Vue 3 中,v-if 的优先级高于 v-for(与 Vue 2 相反)。这意味着当它们同时用在同一个元素上时,v-if 会先执行,无法访问 v-for 作用域内的变量。
  3. v-for 可以遍历对象的属性,顺序基于 Object.keys() 的返回值。不建议直接遍历对象
  4. v-for 可以接受整数,渲染指定数量的元素。
  5. 避免在 v-for 内部使用复杂的计算属性或方法:每次重新渲染都会重新执行,建议将计算结果提前到列表数据源中。
  6. 使用 v-memo 缓存子树(Vue 3.2+):当子树的依赖很少变化时,使用 v-memo 跳过不必要的更新。

编译阶段

1、在 parse 阶段,v-for="..." 中的表达式会以原始的字符串形式被记录在 AST 节点的 props 属性中,尚未被处理。

2、转换。

解析 v-for="(item, index) in list" 字符串,提取出 source(数据源,如 list)、value(迭代项,如 item)和 key(索引,如 index),并存入一个专门的 ForParseResult 对象中。

构建 ForNode:基于解析结果,原始的节点会被替换成一个新的、类型为 ForNode 的 AST 节点。

3、生成代码。

最终生成的渲染函数会包含对 _renderList 这个运行时辅助函数的调用。

九、v-text

v-text 是 Vue 3 中用于更新元素文本内容的内置指令。它将数据绑定到 DOM 元素的 textContent 属性,确保视图与数据保持同步。与插值语法 {{ }} 相比,v-text 提供了一种更显式的方式来控制元素的全部文本内容,并且会完全覆盖元素原有的子节点

基本使用

<template>
  <div v-text="title"></div>
</template>

<script setup lang="ts">
import { ref } from "vue";

const title = ref("这是一段话题。<p>这是段落1。</p>");
</script>

展示效果

image.png

编译结果

image.png

【示例】

<template>
  <div v-text="count"></div>
</template>

<script setup lang="ts">
import { ref } from "vue";
const count = ref(0);
defineOptions({
  name: "CloudView",
});
</script>

编译结果

const _hoisted_1 = ["textContent"];
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("div", { textContent: _toDisplayString($setup.count) }, null, 8, _hoisted_1);
}

【示例】插值{{}}

<template>
  <div>
    {{ count }}
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
const count = ref(0);
defineOptions({
  name: "CloudView",
});
</script>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock(
"div",
null,
_toDisplayString($setup.count),
1
/* TEXT */
);
}

使用限制

  1. 避免与子节点共存:v-text 会覆盖元素的所有子内容,因此在同一个元素上,不应同时使用 v-text 并编写其他子节点

image.png

  1. 仅用于纯文本:v-text 会将 HTML 标签作为纯文本转义输出,不能解析 HTML 结构
  2. 相比于插值语法,v-text 避免了模板编译时的碎片化文本节点,性能上微乎其微,但可读性较差,通常推荐使用 {{ }}

编译阶段

v-text 会被解析为指令,生成代码直接设置 textContent

运行阶段

当绑定的数据变化时,Vue 会更新该元素的 textContent 属性。

十、v-html

v-html 是 Vue 3 中用于将原始 HTML 字符串渲染为真实 DOM 元素的内置指令。与 v-text 或插值语法不同,v-html 会将其内容作为 HTML 解析并插入到元素中,而不是作为纯文本。

【示例】基本使用

<template>
  <div v-html="title"></div>
</template>

<script setup lang="ts">
import { ref } from "vue";

const title = ref("这是一段话题。<p>这是段落1。</p>");
</script>

编译结果

image.png

十一、v-bind

v-bind 指令是数据驱动视图的核心桥梁。能够将 JavaScript 数据动态绑定到 HTML 属性、组件属性(props)甚至 CSS 样式上。当绑定的数据发生变化时,视图会自动更新——你只需关心数据的变化,Vue 会高效地完成 DOM 的更新工作,无需手动操作 DOM。

v-bind是什么?

v-bind 是 Vue 3 中用于动态绑定一个或多个属性的指令,可以绑定 HTML 元素的原生属性(如 srchreftitle 等),也可以绑定组件的 props,还能绑定 class 和 style 等特殊属性

v-bind 使用

语法

<!-- 完整语法 v-bind:属性名="JavaScript表达式" -->
<div v-bind:title="title">这是一个div</div>

<!-- 缩写语法(推荐使用) :属性名="JavaScript表达式"  -->
<div :title="title">这是一个div</div>

<!-- 当属性名与变量名完全相同时,可以省略表达式,直接写成 `:属性名` -->
<div :title>这是一个div</div>

基础使用

HTML 属性 和 DOM 属性的区别?

  1. HTML 属性只负责初始状态,不会自动同步到 DOM 属性。
  2. DOM 属性是当前状态,用户交互或 JS 修改后会更新。
    例如:用户在 <input> 中输入新内容,DOM 属性 value 改变,但 HTML 属性 value 不会变

【示例】绑定 HTML 属性

写在 HTML 标签上的静态文本,由浏览器解析后成为 DOM 节点的初始值。属性名通常是全小写。

<div :aria-label="title">hello</div>

因为 DOM 中不存在 ariaLabel 属性(虽然可以通过 setAttribute 设置),Vue 会将其作为 HTML 属性处理。

image.png

【示例】data-* 自定义属性

<div :data-id="title">ID</div>

【示例】 绑定 DOM 属性

浏览器解析 HTML 后生成的 DOM 对象上的动态属性,可以通过 JavaScript 读写,值会随用户交互变化。

<div v-bind:title="title">这是一个div</div>
<div :title="title">这是一个div</div>
<div :title>这是一个div</div>

image.png

const title = ref("hello vue3");

绑定 class 和 style

它们既是 HTML 属性,又是 DOM 属性,但 Vue 做了特殊增强(支持对象、数组语法),最终仍然通过 DOM 属性机制应用。

<!-- 根据 isActive 动态切换 active 类 -->
<div :class="{ active: isActive, 'text-danger': hasError }"></div>
<div :class="[activeClass, errorClass]"></div>
<div :class="[{ active: isActive }, errorClass]"></div>
<div :style="{ color: activeColor, fontSize: fontSize + 'px' }"></div>
<div :style="styleObject"></div>

const styleObject = reactive({
  color: 'red',
  fontSize: '13px'
})

修饰符

1、.camel将短横线命名的属性名转换为驼峰式

将 HTML 属性名中的短横线(kebab-case)转换为驼峰式(camelCase),以便绑定到使用驼峰命名的 DOM 属性。

2、.prop将绑定绑定为 DOM 属性而非 HTML 属性

强制将绑定值设置为 DOM 属性(Property),而不是 HTML 属性(Attribute)。

3、.attr将绑定强制绑定为 HTML 属性

强制将绑定值设置为 HTML 属性(Attribute),通过 setAttribute 设置。

十二、v-model

v-model 是 Vue.js 中用于实现双向数据绑定的核心指令。

v-model 的使用

【示例】在原生表单元素上:v-model 等价于 :value(或对应属性)加上 @input(或对应事件)事件监听。

<template>
  <div>
    <input type="text" v-model="roleName" placeholder="请输入角色名称" />
  </div>
</template>
<script lang="ts" setup>
import { ref } from "vue";

const roleName = ref("");
</script>

在原生元素上:v-model="username" 等价于 :value="username" @input="username = $event.target.value"

image.png

【示例】多个v-model

<template>
  <div>
    <input type="text" v-model="roleName" placeholder="请输入角色名称" /><br />
    <input type="text" v-model="roleID" placeholder="请输入角色ID" />
  </div>
</template>
<script lang="ts" setup>
import { ref } from "vue";

const roleName = ref("");
const roleID = ref("");
</script>

image.png

【示例】组件上使用

<template>
  <TabOne v-model:name="name" v-model:id="id" />
</template>

<script setup lang="ts">
import TabOne from "@/pages/cloud/components/tabOne.vue";
import { ref } from "vue";

const name = ref("CloudView");
const id = ref("ccd");
defineOptions({
  name: "CloudView",
});
</script>

image.png

【示例】组件上使用

// 父组件
<template>
  <TabOne v-model:tabName="name" v-model:tabId="id" />
</template>

<script setup lang="ts">
import TabOne from "@/pages/cloud/components/tabOne.vue";
import { ref } from "vue";

const name = ref("CloudView");
const id = ref("ccd");
defineOptions({
  name: "CloudView",
});
</script>
// 子组件
<template>
  <div>
    <input v-model="tabName" />
    <input v-model="tabId" />
  </div>
</template>
<script setup lang="ts">
const tabName = defineModel("tabName");
const tabId = defineModel("tabId");

defineOptions({
  name: "TabOneView",
});
</script>

image.png

原生元素修饰符

  1. .lazy,改为 change 事件同步。默认情况下,v-model 在 input 事件触发时同步数据(输入框每次按键都更新)。添加 .lazy 修饰符后,改为在 change 事件触发时同步(通常是 失焦回车时)。
  2. .number ,自动转为数字。将用户的输入自动转换为数字类型。如果输入无法被 parseFloat() 转换,则返回原始字符串。
  3. .trim ,自动去除首尾空格。自动过滤用户输入内容首尾的空白字符(空格、制表符、换行符等)。

【示例】

<template>
  <div>
    <input v-model.trim="tabName" />
    <input v-model.number="tabId" />
  </div>
</template>

image.png

组件修饰符

【示例】组件添加修饰符

// 父组件
<template>
  <TabOne v-model:tabName.max="name" v-model:tabId.upper="id" />
</template>

<script setup lang="ts">
import TabOne from "@/pages/cloud/components/tabOne.vue";
import { ref } from "vue";

const name = ref("CloudView");
const id = ref("ccd");

defineOptions({
  name: "CloudView",
});
</script>

image.png

// 子组件
<template>
  <div>
    <input v-model="tabName" />
    <input v-model="tabId" />
  </div>
</template>
<script setup lang="ts">
const [tabName, tabNameModifiers] = defineModel("tabName", {
  set(val: string) {
    console.log("tabName", tabNameModifiers, val);
    if (tabNameModifiers.max) {
      return val.slice(0, 10);
    }
    return val;
  },
});
const [tabId, tabIdModifiers] = defineModel("tabId", {
  set(val: string) {
    return tabIdModifiers.upper ? val.toUpperCase() : val.toLowerCase();
  },
});

defineOptions({
  name: "TabOneView",
});
</script>

image.png

使用限制

  1. 必须有表达式。
  2. 不能绑定 props
  3. 不能是常量

编译阶段:模板转化

一、 解析(Parse)

parse 函数将模板代码解析成抽象语法树(AST) 。此时,v-model 指令还是一个特殊的节点。

二、转换(Transform)

transform 函数会识别出 v-model 节点,并调用 transformModel 函数,把节点转换成两条 props

  • 对于原生元素:value 和 on:input
  • 对于自定义组件:modelValue 和 on:update:modelValue

三、生成(Generate)

generate 函数将转化后的 AST 生成最终的 render 函数。至此,v-model 指令已不复存在,AST 已被静态展开。

运行阶段:渲染与更新

  1. 执行 render 函数:浏览器执行编译阶段生成的 render 函数,生成虚拟 DOM(VNode)
  2. 处理 props:在生成 VNode 的过程中,render 函数会识别 modelValue 和 onUpdate:modelValue,并将其作为普通的 props 和 event 处理。
  3. 挂载与更新:Vue 的运行时系统会根据 VNode 创建或更新真实 DOM。当用户交互触发 update:modelValue 事件时,父组件中绑定的数据就会被更新,从而触发新一轮的渲染。

十三、v-slot

Vue 3 中的 v-slot 指令用于定义插槽(slot),它是 Vue 组件化体系中实现内容分发和组件复用的核心机制。

插槽的使用?

插槽允许父组件向子组件传递模板内容,子组件通过 <slot> 元素定义内容的放置位置。v-slot 指令用于在父组件中声明传递给子组件的内容。

  • 默认插槽:没有名字的插槽。
  • 具名插槽:有名字的插槽,用于多内容分发。
  • 作用域插槽:子组件可以将数据回传给父组件,父组件利用这些数据渲染内容。

默认插件

【示例】父组件直接嵌套内容

// 父组件
<template>
  <TabTwo>
    <p>插槽内容-默认</p>
  </TabTwo>
</template>

<script setup lang="ts">
import TabTwo from "./cloud/components/tabTwo.vue";

defineOptions({
  name: "CloudView",
});
</script>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createBlock($setup["TabTwo"], null, {
default: _withCtx(() => [..._cache[0] || (_cache[0] = [_createElementVNode(
"p",
null,
"插槽内容-默认",
-1
/* CACHED */
)])]),
_: 1
});
}
// 子组件
<template>
  <div>
    <slot></slot> <!-- 插槽出口 -->
  </div>
</template>
<script setup lang="ts">
defineOptions({
  name: "TabTwoView",
});
</script>

image.png

【示例】<template> 配合 v-slot:default 或简写 #default

v-slot:default

// 父组件
<template>
  <TabTwo>
    <template v-slot:default>
      <p>插槽内容-默认</p>
    </template>
  </TabTwo>
</template>

简写方式

// 父组件
<template>
  <TabTwo>
    <template #default>
      <p>插槽内容-默认</p>
    </template>
  </TabTwo>
</template>
<template>
  <div>
    <slot></slot>
  </div>
</template>
<script setup lang="ts">
defineOptions({
  name: "TabTwoView",
});
</script>

具名插槽

子组件定义多个 <slot>,用 name 属性区分。

【示例】

// 父组件
<template>
  <TabTwo>
    <template v-slot:default>
      <p>插槽内容-默认</p>
    </template>

    <template #header>
      <p>这里是头部</p>
    </template>

    <template #footer>
      <p>这里是脚部</p>
    </template>
  </TabTwo>
</template>

<script setup lang="ts">
import TabTwo from "./cloud/components/tabTwo.vue";

defineOptions({
  name: "CloudView",
});
</script>

image.png

// 子组件
<template>
  <div>
    <slot name="header"></slot>
    <slot></slot>
    <slot name="footer"></slot>
  </div>
</template>

image.png

【示例】条件插槽

// 子组件
<template>
  <div>
    <slot name="header" :header="info.header"></slot>
    <slot :list="info.list"></slot>
    <slot v-if="$slots.footer" name="footer" :footer="info.footer"></slot>
  </div>
</template>
<script setup lang="ts">
import { reactive } from "vue";

const info = reactive({
  header: "这里是头部",
  footer: "这里是脚部",
  list: ["item1", "item2", "item3"],
});
defineOptions({
  name: "TabTwoView",
});
</script>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("div", null, [
_renderSlot(_ctx.$slots, "header", { header: $setup.info.header }),
_renderSlot(_ctx.$slots, "default", { list: $setup.info.list }),
_ctx.$slots.footer ? _renderSlot(_ctx.$slots, "footer", {
key: 0,
footer: $setup.info.footer
}) : _createCommentVNode("v-if", true)
]);
}

【示例】动态插槽名称

// 父组件
<template>
  <button @click="handleClick">切换</button>
  <TabTwo>
    <template v-slot:[slotName]="{ data }"> {{ data }} </template>
  </TabTwo>
</template>

<script setup lang="ts">
import TabTwo from "./cloud/components/tabTwo.vue";
import { ref } from "vue";
const slotName = ref("header");

const handleClick = () => {
  const flag = Math.random() > 0.5;
  slotName.value = flag ? "header" : "footer";
};
defineOptions({
  name: "CloudView",
});
</script>
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock(
_Fragment,
null,
[_createElementVNode("button", { onClick: $setup.handleClick }, "切换"), _createVNode(
$setup["TabTwo"],
null,
{
[$setup.slotName]: _withCtx(({ data }) => [_createTextVNode(
_toDisplayString(data),
1
/* TEXT */
)]),
_: 2
},
1024
/* DYNAMIC_SLOTS */
)],
64
/* STABLE_FRAGMENT */
);
}

作用域插槽

子组件在 <slot> 上绑定属性(称为插槽 prop),父组件通过 v-slot 接收这些数据,从而实现父组件模板使用子组件内部数据。

// 父组件
<template>
  <TabTwo>
    <template v-slot:default="{ list }">
      <p>插槽内容-默认</p>
      <ul v-for="item in list" :key="item">
        <li>{{ item }}</li>
      </ul>
    </template>

    <template #header="{ header }">
      <h1>这里是头部</h1>
      <p>{{ header }}</p>
    </template>

    <template #footer="{ footer }">
      <h1>这里是脚部</h1>
      <p>{{ footer }}</p>
    </template>
  </TabTwo>
</template>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createBlock($setup["TabTwo"], null, {
default: _withCtx(({ list }) => [_cache[0] || (_cache[0] = _createElementVNode(
"p",
null,
"插槽内容-默认",
-1
/* CACHED */
)), (_openBlock(true), _createElementBlock(
_Fragment,
null,
_renderList(list, (item) => {
return _openBlock(), _createElementBlock("ul", { key: item }, [_createElementVNode(
"li",
null,
_toDisplayString(item),
1
/* TEXT */
)]);
}),
128
/* KEYED_FRAGMENT */
))]),
header: _withCtx(({ header }) => [_cache[1] || (_cache[1] = _createElementVNode(
"h1",
null,
"这里是头部",
-1
/* CACHED */
)), _createElementVNode(
"p",
null,
_toDisplayString(header),
1
/* TEXT */
)]),
footer: _withCtx(({ footer }) => [_cache[2] || (_cache[2] = _createElementVNode(
"h1",
null,
"这里是脚部",
-1
/* CACHED */
)), _createElementVNode(
"p",
null,
_toDisplayString(footer),
1
/* TEXT */
)]),
_: 1
});
}
// 子组件
<template>
  <div>
    <slot name="header" :header="info.header"></slot>
    <slot :list="info.list"></slot>
    <slot name="footer" :footer="info.footer"></slot>
  </div>
</template>
<script setup lang="ts">
import { reactive } from "vue";

const info = reactive({
  header: "这里是头部",
  footer: "这里是脚部",
  list: ["item1", "item2", "item3"],
});
defineOptions({
  name: "TabTwoView",
});
</script>

image.png

注意事项

  1. v-slot 只能用在 template 标签 或组件上。
  2. v-slot 用在组件上,只能是默认的情况。
  3. 如果同时使用默认插槽和具名插槽,默认插槽的内容必须用 <template #default> 包裹(除非你只提供默认插槽且不与其他插槽混用)。

编译阶段

一、解析(Parse)

模板中的 <template v-slot:header="slotProps"> 会被解析成 AST 节点,其中包含:

  • slotName:插槽名(如 header
  • slotProps:作用域变量名(如 slotProps
  • 子节点:插槽内部的模板内容

二、转换(Transform)

编译器会对 AST 进行转换,将 v-slot 转换为 render 函数中的插槽定义。

转换的结果是:每个插槽会被编译成一个函数,该函数接收子组件传递的插槽 prop 作为参数,并返回插槽内容的虚拟 DOM。

三、生成(Generate)

最终生成可执行的 render 函数。

对于子组件,其 render 函数中会访问 $slots 对象;
对于父组件,render 函数会生成一个插槽对象作为子组件的第三个参数(即 children 或 slots)。

运行阶段

当父组件的 render 函数执行时,它会计算每个插槽的内容,并为每个插槽生成一个函数。这些函数被收集到一个对象中,作为子组件创建时的 slots 属性传递。

子组件在渲染时,会通过 $slots 属性访问父组件传递的插槽对象。

Vue 3.x 单文件组件(SFC)模板编译过程解析

Vue 的单文件组件(Single-File Component,简称 SFC)以 .vue 为扩展名,将模板、逻辑和样式整合在一个文件中。但浏览器无法直接理解这种格式,因此需要经过编译工具(如 Vite、Webpack + vue-loader)将其转换为标准的 JavaScript 模块。这个过程由 @vue/compiler-sfc@vue/compiler-dom 和 @vue/compiler-core 协同完成。

编译流程概览:三部曲

Vue 3 的编译器采用了模块化设计,主要由三个部分组成:

  • @vue/compiler-sfc:专门处理 .vue 文件,负责将文件拆分成 <template><script> 和 <style> 三大块。
  • @vue/compiler-dom:专注于 DOM 平台相关的编译逻辑,是浏览器端的适配层。
  • @vue/compiler-core:平台无关的核心编译器,实现了编译的三大核心阶段:解析 (Parse)转换 (Transform)  和生成 (Generate)

image.png

模板编译

模板编译的核心流程,即 @vue/compiler-core 中发生的 Parse → Transform → Generate 三个阶段

解析(Parse):从模板字符串到 AST

解析器(Parser)的输入是模板字符串,输出是抽象语法树(AST)。AST 以树形结构描述模板的节点类型、属性、指令等信息。

转换(Transform):为运行时优化做准备

转换阶段遍历 AST,对节点进行修改、添加元数据,并生成代码生成节点(codegenNode)。

编译器会识别模板中的 v-ifv-forv-modelv-on 等指令,并将其转换为对应的代码生成节点。

对于插值表达式 {{ msg }} 或指令中的动态表达式,编译器会分析其内容,并在生成代码时添加上下文前缀。

静态提升是 Vue 3 最重要的优化之一。编译器会识别出不依赖任何响应式数据的纯静态节点(如没有绑定、没有插值、没有指令的 <div>),并将其提升到渲染函数之外。

转换器会为每个动态节点标记一个 PatchFlag(补丁标志),指示该节点在更新时哪些部分可能发生变化。这是一个位掩码,运行时可以快速判断需要比较的内容。

生成(Generate):从 AST 到 render 函数

生成阶段是编译的最后一步,它的任务是将转换后的 AST 转换成 JavaScript 代码字符串(即 render 函数)。

generate 函数会递归遍历 AST,调用不同的代码生成函数(genNodegenElementgenExpression 等)拼接字符串。

枚举 ConstantTypes

enum ConstantTypes {
  NOT_CONSTANT = 0, // 非常量表达式,在编译时无法确定其值
  CAN_SKIP_PATCH, // 1 可以跳过补丁的常量,通常是静态节点
  CAN_CACHE, // 2 可以缓存的常量,其值在编译时已知
  CAN_STRINGIFY, // 3 可以字符串化的常量,其值可以在编译时转换为字符串
}

枚举 NodeTypes

enum NodeTypes {
  ROOT, // 0 根节点,整个模板入口
  ELEMENT, // 1 元素节点 ,如 <div>、<span> 等 HTML 元素或 Vue 组件
  TEXT, // 2 文本节点,如普通文本内容
  COMMENT, // 3 注释节点,如 <!-- comment -->
  SIMPLE_EXPRESSION, // 4 简单表达式节点,如 message
  INTERPOLATION, // 5 插值节点,如 {{ message }}
  ATTRIBUTE, // 6 属性节点,如 v-model
  DIRECTIVE, // 7 指令节点,如 v-if

  // containers
  COMPOUND_EXPRESSION, // 8 复合表达式节点,由多个表达式组成
  IF, // 9 条件节点,如 v-if
  IF_BRANCH, // 10 条件分支节点,如 v-else、v-else-if
  FOR, // 11 循环节点,如 v-for
  TEXT_CALL, // 12 文本调用节点,用于处理带表达式的文本
  // codegen
  VNODE_CALL, // 13 VNode 调用节点,用于创建 VNode 实例
  JS_CALL_EXPRESSION, // 14 调用表达式节点,如 function() 或 method()
  JS_OBJECT_EXPRESSION, // 15 JS对象表达式
  JS_PROPERTY, // 16 JS属性表达式,如 obj.prop
  JS_ARRAY_EXPRESSION, // 17 JS数组表达式,如 [1, 2, 3]
  JS_FUNCTION_EXPRESSION, // 18 JS函数表达式,如 function() {}
  JS_CONDITIONAL_EXPRESSION, // 19 条件表达式,如 a ? b : c
  JS_CACHE_EXPRESSION, // 20 缓存表达式,用于缓存计算结果

  // ssr codegen
  JS_BLOCK_STATEMENT,
  JS_TEMPLATE_LITERAL,
  JS_IF_STATEMENT,
  JS_ASSIGNMENT_EXPRESSION,
  JS_SEQUENCE_EXPRESSION,
  JS_RETURN_STATEMENT,
}

示例 v-for

  <ul>
    <li v-for="item in tag" :key="item">信息{{ item }} end</li>
  </ul>

一、 parse 解析 @vue/compiler-core

image.png

image.png

image.png

二、转换

image.png

{
    "codegenNode": {
        "type": 13,
        "tag": "\"li\"",
        "props": {
            "type": 15,
            "properties": [
                {
                    "type": 16,
                    "key": {
                        "type": 4,
                        "content": "key",
                        "isStatic": true,
                        "constType": 3
                    },
                    "value": {
                        "type": 4,
                        "content": "item",
                        "isStatic": false,
                        "constType": 0,
                        "ast": null
                    }
                }
            ]
        },
        "children": {
            "type": 8,
            "children": [
                {
                    "type": 2,
                    "content": "信息"
                },
                " + ",
                {
                    "type": 5,
                    "content": {
                        "type": 4,
                        "content": "item",
                        "isStatic": false,
                        "constType": 0,
                        "ast": null
                    }
                },
                " + ",
                {
                    "type": 2,
                    "content": " end"
                }
            ]
        },
        "patchFlag": 1,
        "isBlock": true,
        "disableTracking": false,
        "isComponent": false
    }
}

三、生成代码

import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("ul", null, [
    (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.tag, (item) => {
      return (_openBlock(), _createElementBlock("li", { key: item }, "信息" + _toDisplayString(item) + " end", 1 /* TEXT */))
    }), 128 /* KEYED_FRAGMENT */))
  ]))
}

ForNode

interface ForNode extends Node {
  type: NodeTypes.FOR
  // 存储遍历的数据源表达式
  source: ExpressionNode
  // 存储遍历项的别名
  valueAlias: ExpressionNode | undefined
  // 存储键的别名(数组索引或对象键)
  // v-for="(value, key) in object":keyAlias 为 { type: NodeTypes.SIMPLE_EXPRESSION, content: 'key' }
  keyAlias: ExpressionNode | undefined
  // 存储对象索引的别名(仅在遍历对象时使用)
  objectIndexAlias: ExpressionNode | undefined
  // 存储 v-for 指令的解析结果
  parseResult: ForParseResult
  // 存储循环的子节点,即 v-for 循环的内容
  children: TemplateChildNode[]
  // 存储代码生成阶段的中间结果
  codegenNode?: ForCodegenNode
}

ForParseResult

interface ForParseResult {
  // 存储 v-for 指令中的数据源表达式
  source: ExpressionNode
  // 存储 v-for 指令中的值别名
  value: ExpressionNode | undefined
  // 存储 v-for 指令中的键别名
  key: ExpressionNode | undefined
  // 存储 v-for 指令中的索引别名(仅在遍历对象时使用)
  index: ExpressionNode | undefined
  // 标记解析是否已完成
  finalized: boolean
}

ForCodegenNode

interface ForCodegenNode extends VNodeCall {
  // 标记 v-for 生成的节点总是块级节点
  isBlock: true
  // 指定 v-for 生成的节点标签为 FRAGMENT
  tag: typeof FRAGMENT
  // 明确 v-for 生成的片段没有属性
  props: undefined
  // 存储列表渲染的表达式
  children: ForRenderListExpression
  // 存储补丁标志,用于虚拟 DOM 的更新优化
  patchFlag: PatchFlags
  // 标记是否禁用块跟踪
  disableTracking: boolean
}

ForRenderListExpression

interface ForRenderListExpression extends CallExpression {
  // 指定调用的函数为 RENDER_LIST 辅助函数
  callee: typeof RENDER_LIST
  // 第一个元素:ExpressionNode 类型,表示数据源表达式
  // 第二个元素:ForIteratorExpression 类型,表示迭代器表达式
  arguments: [ExpressionNode, ForIteratorExpression]
}

ForIteratorExpression

interface ForIteratorExpression extends FunctionExpression {
  // 存储迭代器函数的返回值类型
  returns?: BlockCodegenNode
}

示例 v-if

  <div>
    <p v-if="tag === 1">这里是header</p>
    <p v-else-if="tag === 2">这里是body</p>
    <p v-else>这里是footer</p>
  </div>

一、parse 解析

第一个 p 节点

image.png

第二个 p 节点

image.png

第三个 p 节点

image.png

二、转换

image.png

第一个分支

image.png

image.png

第二个分支

三、生成代码

import { openBlock as _openBlock, createElementBlock as _createElementBlock, createCommentVNode as _createCommentVNode } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    (_ctx.tag === 1)
      ? (_openBlock(), _createElementBlock("p", { key: 0 }, "这里是header"))
      : (_ctx.tag === 2)
        ? (_openBlock(), _createElementBlock("p", { key: 1 }, "这里是body"))
        : (_openBlock(), _createElementBlock("p", { key: 2 }, "这里是footer"))
  ]))
}

IfNode

interface IfNode extends Node {
  type: NodeTypes.IF
  // 存储条件分支数组,每个分支对应一个 v-if、v-else-if 或 v-else 指令
  branches: IfBranchNode[]
  // 存储代码生成阶段的中间结果
  codegenNode?: IfConditionalExpression | CacheExpression // <div v-if v-once>
}

IfBranchNode

interface IfBranchNode extends Node {
  type: NodeTypes.IF_BRANCH
  // 存储分支的条件表达式
  condition: ExpressionNode | undefined // else
  // 存储分支的子节点,即条件为真时要渲染的内容
  children: TemplateChildNode[]
  // 存储用户为条件分支提供的键,用于优化虚拟 DOM 更新
  userKey?: AttributeNode | DirectiveNode
  // 标记该分支是否来自 <template> 元素的 v-if 指令
  isTemplateIf?: boolean
}

ConditionalExpression

interface ConditionalExpression extends Node {
  // 节点类型,值为 19,标识这是一个条件表达式节点
  type: NodeTypes.JS_CONDITIONAL_EXPRESSION
  // 条件测试表达式,对应三元表达式中的 condition 部分
  test: JSChildNode
  // 条件为真时的表达式,对应三元表达式中的 trueValue 部分
  consequent: JSChildNode
  // 条件为假时的表达式,对应三元表达式中的 falseValue 部分
  alternate: JSChildNode
  // 代码生成时是否需要换行,用于格式化输出
  newline: boolean
}

示例 v-on

<button
  @click="handleClick3"
  @click.stop="
    handleClick();
    handleClick2($event);
  "
>
  增加
</button>

一、parse 解析

image.png

image.png

image.png

二、转换

image.png

image.png

image.png

三、生成代码

import { withModifiers as _withModifiers, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("button", {
    onClick: [
      _ctx.handleClick3,
      _withModifiers($event => {
    _ctx.handleClick();
    _ctx.handleClick2($event);
  }, ["stop"])
    ]
  }, " 增加 ", 8 /* PROPS */, ["onClick"]))
}

示例 v-text

<div>
  <p v-text="count"></p>
</div>

一、parse

image.png

二、转换

image.png

三、生成代码

import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("p", {
      textContent: _toDisplayString(_ctx.count)
    }, null, 8 /* PROPS */, ["textContent"])
  ]))
}

示例 插值

  <div>
   <p>{{ tag }}</p>
  </div>

一、parse 解析 @vue/compiler-core

image.png

二、transform 转换

image.png

三、generate 生成代码

import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("p", null, _toDisplayString(_ctx.tag), 1 /* TEXT */)
  ]))
}

InterpolationNode

interface InterpolationNode extends Node {
  type: NodeTypes.INTERPOLATION
  content: ExpressionNode
}

示例 v-model

  <div>
    <input v-model="message" type="text" placeholder="请输入" />
  </div>

一、parse 解析

image.png

二、转换

指令属性

image.png

普通属性

image.png

三、生成代码

import { vModelText as _vModelText, createElementVNode as _createElementVNode, withDirectives as _withDirectives, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _withDirectives(_createElementVNode("input", {
      "onUpdate:modelValue": $event => ((_ctx.message) = $event),
      type: "text",
      placeholder: "请输入"
    }, null, 8 /* PROPS */, ["onUpdate:modelValue"]), [
      [_vModelText, _ctx.message]
    ])
  ]))
}

示例 v-pre

  <div>
   <div v-pre>
    这是一个pre标签
    <p>这里是header</p>
    <button @click="handleClick">这里是body</button>
   </div>
  </div>

一、解析生成AST

image.png

image.png

二、转换

三、生成代码

image.png

示例 原生元素

<template>
  <p>这里是 tabTwo 组件</p>
</template>

编译结果

import { openBlock as _openBlock, createElementBlock as _createElementBlock } from "/node_modules/.vite/deps/vue.js?v=b3e6ce82";
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("p", null, "这里是 tabTwo 组件");
}

【示例】

<template>
  <p>这里是 tabTwo 组件</p>
  <span>这里是 span 组件</span>
</template>

编译结果

import { createElementVNode as _createElementVNode, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "/node_modules/.vite/deps/vue.js?v=b3e6ce82";
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock(
_Fragment,
null,
[_cache[0] || (_cache[0] = _createElementVNode(
"p",
null,
"这里是 tabTwo 组件",
-1
/* CACHED */
)), _cache[1] || (_cache[1] = _createElementVNode(
"span",
null,
"这里是 span 组件",
-1
/* CACHED */
))],
64
/* STABLE_FRAGMENT */
);
}

示例 元素img

  <div>
    <img src="@/assets/logo.svg" alt="" />
  </div>

一、转换

image.png

二、转换

image.png

image.png

静态资源转换

image.png

transformAssetUrl函数

image.png

getImportsExpressionExp函数

image.png

三、生成代码

import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "/node_modules/.vite/deps/vue.js?v=2d0c80e8";
import _imports_0 from "/src/assets/logo.svg?import";
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("div", null, [..._cache[0] || (_cache[0] = [_createElementVNode(
"img",
{
src: _imports_0,
alt: ""
},
null,
-1
/* CACHED */
)])]);
}

示例 组件

<template>
  <div>
    <TabTwo :tabName="tabName" tabId="2" />
  </div>
</template>
<script setup lang="ts">
import TabTwo from "@/pages/cloud/components/tabTwo.vue";
import { ref } from "vue";

const tabName = ref("tabTwo");

defineOptions({
  name: "CloudIndexView",
});
</script>

一、解析

image.png

三、生成代码

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("div", null, [_createVNode($setup["TabTwo"], {
tabName: $setup.tabName,
tabId: "2"
}, null, 8, ["tabName"])]);
}

示例 插槽

一、解析

父组件

image.png

二、转换

父组件

image.png

image.png

子组件

image.png

遍历节点

image.png

image.png

image.png

image.png

三、生成代码

父组件

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("div", null, [_createVNode($setup["TabTwo"], {
tabName: $setup.tabName,
tabId: "2"
}, {
default: _withCtx(() => [..._cache[0] || (_cache[0] = [_createElementVNode(
"p",
null,
"插槽内容",
-1
/* CACHED */
)])]),
_: 1
}, 8, ["tabName"])]);
}

子组件

import { createElementVNode as _createElementVNode, renderSlot as _renderSlot, openBlock as _openBlock, createElementBlock as _createElementBlock } from "/node_modules/.vite/deps/vue.js?v=2d0c80e8"

function _sfc_render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock("div", null, [
    _cache[0] || (_cache[0] = _createElementVNode("p", null, "这里是 tabTwo 组件", -1 /* CACHED */)),
    _renderSlot(_ctx.$slots, "default")
  ]))
}

示例 v-html

一、解析

image.png

二、转换

image.png

image.png

image.png

三、生成代码

image.png

import { openBlock as _openBlock, createElementBlock as _createElementBlock } from "/node_modules/.vite/deps/vue.js?v=2d0c80e8";
const _hoisted_1 = ["innerHTML"];
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("div", { innerHTML: $setup.title }, null, 8, _hoisted_1);
}

最后

  1. 在线编译工具

Vue并发控制|几十个请求高效管控(实战方案+可运行代码)

在Vue项目开发中,经常会遇到需要同时发起几十个请求的场景(如批量数据查询、批量提交、页面初始化加载多接口数据)。若直接同时发起所有请求,会导致网络拥堵、接口超时、浏览器卡顿,甚至触发后端接口限流,影响用户体验和系统稳定性。本文结合Vue2/Vue3实战,提供4种主流并发控制方案,覆盖不同场景,可直接落地使用,轻松管控几十个请求的并发逻辑。

一、并发控制核心逻辑

并发控制的核心是:限制同一时间发起的请求数量,将几十个请求分批次、有序执行,避免一次性占用过多网络资源。核心要点的2个:

  • 控制并发数:根据后端接口承载能力和浏览器限制,合理设置并发数(通常4-6个,过多易拥堵,过少效率低);
  • 有序执行:分批次发起请求,上一批请求完成(成功/失败)后,再发起下一批,确保请求有序且不拥堵;
  • 异常兼容:处理单个请求失败、超时场景,避免单个请求失败导致整个并发流程中断。

以下方案均适配Vue2/Vue3,基于Axios请求库(Vue项目主流请求工具),可直接复制到项目中修改使用。

二、4种实战并发控制方案(按推荐度排序)

方案一:并发池控制(最推荐,灵活高效,适配所有场景)

核心思路:封装一个并发池工具,将所有请求放入队列,限制同时执行的请求数量,当某个请求完成后,自动从队列中取出下一个请求执行,循环直至所有请求完成。该方案灵活可控,可处理成功/失败回调、超时控制,是几十个请求并发管控的最优选择。

1. 封装并发池工具(Vue2/Vue3通用)

// utils/requestPool.js
import axios from 'axios';

// 极简版并发池(核心功能:限制并发、处理超时、返回结果)
export async function requestPool(requestList, limit = 4, timeout = 10000) {
  const result = [];
  let running = 0;
  let queue = [...requestList];

  const runRequest = async () => {
    if (queue.length === 0 && running === 0) return result;
    while (running < limit && queue.length > 0) {
      running++;
      const requestFn = queue.shift();
      const index = requestList.length - queue.length - running;
      try {
        const res = await Promise.race([
          requestFn(),
          new Promise((_, reject) => setTimeout(() => reject(new Error('请求超时')), timeout))
        ]);
        result[index] = { success: true, data: res.data };
      } catch (error) {
        result[index] = { success: false, error: error.message };
      } finally {
        running--;
        await runRequest();
      }
    }
  };
  await runRequest();
  return result;
}

// 极简请求函数(按需修改url和参数)
export function createRequestFn(id) {
  return () => axios({ url: `/api/data/${id}`, method: 'get', timeout: 10000 });
}

2. Vue组件中使用(Vue3示例,Vue2可直接适配)

<template>
  <div>
    <button @click="handleBatchRequest">发起30个并发请求</button>
    <div class="result">成功:{{ successCount }} 个 | 失败:{{ failCount }} 个</div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { requestPool, createRequestFn } from '@/utils/requestPool';

const successCount = ref(0);
const failCount = ref(0);

// 极简使用示例(30个请求,并发数5)
const handleBatchRequest = async () => {
  const requestList = Array.from({ length: 30 }, (_, i) => createRequestFn(i + 1));
  const results = await requestPool(requestList, 5);
  successCount.value = results.filter(item => item.success).length;
  failCount.value = results.filter(item => !item.success).length;
};
</script>

<style scoped>
.result { margin-top: 20px; font-size: 14px; color: #333; }
</style>

方案一优势与适配场景

  • 优势:灵活可控,可自定义并发数、超时时间;单个请求失败不影响整体流程;请求结果顺序与发起顺序一致;代码复用性强,可全局调用。
  • 适配场景:几十个请求的批量查询、批量提交、页面初始化多接口加载等所有场景(最推荐)。

方案二:分批次请求(简单易实现,适合对顺序要求高的场景)

核心思路:将几十个请求分成若干批次,每批次发起固定数量的请求(如每批5个),等待当前批次所有请求完成后,再发起下一批。实现简单,无需复杂封装,适合对请求顺序有严格要求的场景(如下一批请求依赖上一批请求结果)。

1. 封装分批次请求工具(Vue2/Vue3通用)

// utils/batchRequest.js
import axios from 'axios';

/**
 * 分批次请求控制
 * @param {Array} requestList - 请求列表(每个元素是请求参数,如id)
 * @param {Number} batchSize - 每批请求数量(默认5个)
 * @returns {Promise} - 所有请求结果数组
 */
export async function batchRequest(requestList, batchSize = 5) {
  const result = [];
  // 计算总批次
  const totalBatch = Math.ceil(requestList.length / batchSize);

  // 循环发起每批次请求
  for (let i = 0; i < totalBatch; i++) {
    // 截取当前批次的请求参数
    const currentBatch = requestList.slice(i * batchSize, (i + 1) * batchSize);
    // 发起当前批次的所有请求(并行)
    const batchResult = await Promise.allSettled(
      currentBatch.map(id => 
        axios({
          url: `/api/data/${id}`,
          method: 'get',
          timeout: 10000
        }).then(res => ({ success: true, data: res.data }))
        .catch(err => ({ success: false, error: err.message }))
      )
    );
    // 将当前批次结果存入总结果
    result.push(...batchResult);
  }

  return result;
}

2. Vue组件中使用

<script setup>
import { ref } from 'vue';
import { batchRequest } from '@/utils/batchRequest';

const successCount = ref(0);
const failCount = ref(0);

const handleBatchRequest = async () => {
  // 生成30个请求参数(如id数组)
  const requestList = Array.from({ length: 30 }, (_, i) => i + 1);
  
  // 分批次请求,每批5个
  const results = await batchRequest(requestList, 5);
  
  // 处理结果
  successCount.value = results.filter(item => item.success).length;
  failCount.value = results.filter(item => !item.success).length;
};
</script>

方案二优势与适配场景

  • 优势:实现简单,无需复杂封装;请求批次清晰,顺序可控;适合下一批请求依赖上一批结果的场景。
  • 适配场景:对请求顺序有要求、批量提交且需分批校验的场景(如分批提交表单数据,上一批通过再提交下一批)。

方案三:Axios拦截器控制并发(全局管控,适合简单场景)

核心思路:通过Axios的请求拦截器和响应拦截器,维护一个“正在执行的请求”计数器,当计数器达到并发限制时,将后续请求存入队列,等待正在执行的请求完成后,再依次发起。适合简单场景,无需在组件中单独处理,全局统一管控。

1. 全局配置Axios并发控制(Vue2/Vue3通用)

// utils/axiosConfig.js
import axios from 'axios';

// 并发限制数
const CONCURRENT_LIMIT = 4;
// 正在执行的请求计数器
let requestCount = 0;
// 请求队列
const requestQueue = [];

// 创建Axios实例
const service = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000
});

// 请求拦截器:控制并发
service.interceptors.request.use(
  config => {
    return new Promise(resolve => {
      // 若当前请求数未达限制,直接发起请求
      if (requestCount < CONCURRENT_LIMIT) {
        requestCount++;
        resolve(config);
      } else {
        // 达到限制,存入请求队列
        requestQueue.push(resolve);
      }
    });
  },
  error => {
    return Promise.reject(error);
  }
);

// 响应拦截器:请求完成后,从队列中取出下一个请求
service.interceptors.response.use(
  response => {
    // 请求完成,计数器减1
    requestCount--;
    // 若队列中有请求,取出并执行
    if (requestQueue.length > 0) {
      const resolve = requestQueue.shift();
      requestCount++;
      resolve(service.defaults);
    }
    return response;
  },
  error => {
    // 失败也需计数器减1,避免队列卡住
    requestCount--;
    if (requestQueue.length > 0) {
      const resolve = requestQueue.shift();
      requestCount++;
      resolve(service.defaults);
    }
    return Promise.reject(error);
  }
);

export default service;

2. Vue组件中使用

<script setup>
import { ref } from 'vue';
import request from '@/utils/axiosConfig';

const successCount = ref(0);
const failCount = ref(0);

// 直接发起30个请求,Axios拦截器自动控制并发
const handleBatchRequest = async () => {
  const requestList = [];
  for (let i = 1; i <= 30; i++) {
    requestList.push(
      request({
        url: `/api/data/${i}`,
        method: 'get'
      }).then(res => ({ success: true, data: res.data }))
      .catch(err => ({ success: false, error: err.message }))
    );
  }

  // 等待所有请求完成
  const results = await Promise.allSettled(requestList);
  successCount.value = results.filter(item => item.success).length;
  failCount.value = results.filter(item => !item.success).length;
};
</script>

方案三优势与适配场景

  • 优势:全局统一管控,组件中无需单独处理并发逻辑;侵入性低,原有请求代码无需修改;实现简单,适合快速落地。
  • 适配场景:项目中所有批量请求需统一控制并发、无需个性化并发配置的简单场景。

方案四:使用第三方库(高效快捷,适合复杂场景)

核心思路:借助成熟的第三方库(如p-limit),快速实现并发控制,无需自己封装工具,适合复杂场景(如并发数动态调整、请求优先级控制)。p-limit轻量、易用,是前端并发控制的常用库。

1. 安装与使用(Vue2/Vue3通用)

// 1. 安装依赖
// npm install p-limit --save

// 2. 组件中使用
<script setup>
import { ref } from 'vue';
import axios from 'axios';
import pLimit from 'p-limit';

// 设置并发限制数为4
const limit = pLimit(4);
const successCount = ref(0);
const failCount = ref(0);

const handleBatchRequest = async () => {
  // 生成30个请求函数,并用p-limit包装
  const requestList = [];
  for (let i = 1; i <= 30; i++) {
    // 用limit包装请求函数,限制并发
    requestList.push(
      limit(() => 
        axios({
          url: `/api/data/${i}`,
          method: 'get',
          timeout: 10000
        }).then(res => ({ success: true, data: res.data }))
        .catch(err => ({ success: false, error: err.message }))
      )
    );
  }

  // 等待所有请求完成
  const results = await Promise.allSettled(requestList);
  successCount.value = results.filter(item => item.success).length;
  failCount.value = results.filter(item => !item.success).length;
};
</script>

方案四优势与适配场景

  • 优势:无需自己封装,高效快捷;支持动态调整并发数、请求优先级;成熟稳定,适配复杂并发场景。
  • 适配场景:复杂并发需求(如动态调整并发数、设置请求优先级)、不想自己封装工具的场景。

三、Vue2与Vue3适配差异(关键注意点)

  • 请求工具:两者均使用Axios,配置方式完全一致,无差异;
  • 组件写法:Vue3使用组合式API(setup语法),Vue2使用选项式API(methods中编写逻辑),核心并发控制逻辑完全一致;
  • 环境变量:Vue3使用import.meta.env,Vue2使用process.env,修改Axios baseURL时需注意适配;
  • 第三方库:p-limit等库适配所有Vue版本,无差异。

四、避坑指南(高频问题)

  • 并发数设置:避免设置过大(如超过10个),否则会导致网络拥堵、后端限流;建议设置4-6个,根据后端接口承载能力调整;
  • 超时控制:必须为单个请求设置超时时间,避免某个请求长时间挂起,导致整个并发流程卡住;
  • 失败处理:使用Promise.allSettled(而非Promise.all),避免单个请求失败导致整个并发流程中断;
  • 请求顺序:并发池和分批次方案可保证请求结果顺序与发起顺序一致,Axios拦截器方案无法保证顺序(需额外处理);
  • 内存占用:几十个请求并发时,避免存储过多请求结果,可按需处理结果(如边请求边渲染),减少内存占用。

五、总结

针对Vue中几十个请求的并发控制,4种方案各有适配场景,可根据项目需求灵活选择:

  1. 并发池控制:最推荐,灵活可控、适配所有场景,兼顾易用性和扩展性;
  2. 分批次请求:适合对请求顺序有要求、下一批依赖上一批结果的场景;
  3. Axios拦截器:适合全局统一管控、无需个性化配置的简单场景;
  4. 第三方库:适合复杂场景、不想自己封装工具的场景。

实际开发中,建议优先使用“并发池控制”方案,既能灵活控制并发数、处理异常,又能保证请求顺序,可直接复制本文代码,修改请求地址和参数即可快速落地,轻松解决几十个请求的并发难题。

Vue大批量接口请求优化|告别卡顿、超时!前端落地实战指南

在Vue项目开发中,大批量接口请求(如列表批量查询、批量提交、多模块初始化请求)是常见场景,若不做优化,易出现请求阻塞、页面卡顿、接口超时、服务器压力过大等问题,严重影响用户体验和系统稳定性。本文结合Vue2/Vue3实战,从请求管控、缓存策略、代码优化、异常处理四大维度,提供可直接落地的优化方案,覆盖核心场景与避坑要点。

一、核心痛点分析(优化前提)

大批量接口请求的核心问题集中在4点,优化需针对性突破:

  • 请求并发过多:同时发起数十个甚至上百个接口请求,超出浏览器并发限制(通常浏览器同一域名并发数为6个),导致请求排队、阻塞,页面加载缓慢;
  • 重复请求浪费:同一接口短时间内多次发起(如页面刷新、组件重复渲染),重复获取相同数据,增加服务器压力和网络开销;
  • 数据处理低效:大批量请求返回后,频繁操作DOM或修改响应式数据,触发Vue多次重新渲染,导致页面卡顿;
  • 异常处理缺失:单个请求失败导致整体流程中断,或无超时控制、重试机制,影响用户操作体验。

二、核心优化方案(实战落地)

(一)请求层面优化:管控并发,减少无效请求

核心思路:通过“限制并发数、合并请求、取消无效请求”,降低服务器和浏览器的压力,提升请求响应效率。

1. 限制并发请求数量(最核心优化)

浏览器对同一域名的并发请求数有明确限制(Chrome为6个),超出部分会排队等待,导致请求延迟。通过“并发池”控制同时发起的请求数量,避免阻塞。

实战实现(通用封装,适配Vue2/Vue3):

// utils/requestPool.js
/**
 * 并发请求池:限制同时发起的请求数量
 * @param {Array} requests - 请求函数数组(每个函数返回Promise)
 * @param {Number} limit - 并发限制数(默认6)
 * @returns {Promise} - 所有请求完成后的Promise
 */
export function requestPool(requests, limit = 6) {
  let index = 0; // 当前执行的请求索引
  const results = []; // 存储所有请求结果
  let resolveAll; // 所有请求完成的回调

  // 创建Promise,用于等待所有请求完成
  const allPromise = new Promise(resolve => {
    resolveAll = resolve;
  });

  // 执行单个请求
  async function run() {
    if (index >= requests.length) {
      // 所有请求执行完毕,返回结果
      resolveAll(results);
      return;
    }
    // 取出当前请求函数
    const request = requests[index];
    index++;
    try {
      // 执行请求,存储结果
      const res = await request();
      results.push({ success: true, data: res, index: index - 1 });
    } catch (err) {
      // 捕获错误,存储错误信息
      results.push({ success: false, error: err, index: index - 1 });
    }
    // 递归执行下一个请求(当前请求完成后,再发起下一个)
    run();
  }

  // 初始化并发池,启动limit个请求
  for (let i = 0; i < limit; i++) {
    run();
  }

  return allPromise;
}

// 页面中使用(示例:批量获取列表数据)
import { requestPool } from '@/utils/requestPool';
import { getListData } from '@/api/list';

// 构造请求函数数组(每个请求函数返回Promise)
const requestList = [1,2,3,...,50].map(id => () => getListData(id));

// 限制并发数为5,执行批量请求
requestPool(requestList, 5).then(results => {
  // 处理所有请求结果(区分成功/失败)
  const successData = results.filter(item => item.success).map(item => item.data);
  const failIds = results.filter(item => !item.success).map(item => item.index + 1);
});

关键说明:并发数建议设置为4-6(匹配浏览器并发限制),避免设置过高导致服务器压力过大,过低影响请求效率。

2. 合并请求,减少接口调用次数

对于“批量查询、批量提交”类场景(如批量查询多个商品详情、批量提交多条数据),避免循环发起单个请求,改为“一次请求携带多个参数”,减少接口调用次数。

实战场景(Vue3示例):

// 优化前:循环发起单个请求(低效)
const ids = [1,2,3,...,20];
const list = [];
for (const id of ids) {
  const res = await getGoodsDetail(id); // 循环发起20次请求
  list.push(res.data);
}

// 优化后:合并请求(一次请求)
const ids = [1,2,3,...,20];
const res = await getBatchGoodsDetail({ ids: ids.join(',') }); // 一次请求,携带所有id
const list = res.data;

注意:需与后端配合,约定批量接口的参数格式(如用逗号分隔id、传递数组);若批量数据过多(如超过100条),可拆分多个合并请求(如每50条一次),避免请求参数过长导致接口报错。

3. 取消无效请求,避免资源浪费

场景:页面切换、组件销毁时,之前发起的请求未完成,会导致无用响应占用网络资源,甚至引发数据错乱。需在合适时机取消无效请求。

实战实现(结合Axios + Vue3生命周期):

// 1. 封装Axios,支持取消请求
import axios from 'axios';

// 创建取消令牌生成器
const CancelToken = axios.CancelToken;
let cancel;

// 封装请求函数
export function request(config) {
  return axios({
    ...config,
    // 创建取消令牌
    cancelToken: new CancelToken(c => {
      cancel = c; // 保存取消函数,用于后续取消请求
    })
  });
}

// 提供取消请求的方法
export function cancelRequest(msg = '请求已取消') {
  if (cancel) {
    cancel(msg);
    cancel = null; // 重置取消函数
  }
}

// 2. 组件中使用(Vue3)
import { onUnmounted } from 'vue';
import { request, cancelRequest } from '@/utils/request';

export default {
  setup() {
    // 组件销毁时,取消未完成的请求
    onUnmounted(() => {
      cancelRequest('组件已销毁,取消请求');
    });

    // 发起请求
    const fetchData = async () => {
      try {
        const res = await request({
          url: '/api/batch/data',
          method: 'get'
        });
        // 处理数据
      } catch (err) {
        // 捕获取消请求的异常(无需提示用户)
        if (axios.isCancel(err)) {
          console.log('请求已取消:', err.message);
          return;
        }
        // 处理其他错误
        ElMessage.error('请求失败,请重试');
      }
    };

    return { fetchData };
  }
};

Vue2适配:在beforeDestroy钩子中调用cancelRequest,逻辑一致。

(二)缓存层面优化:复用数据,减少重复请求

核心思路:对“不常变化、高频访问”的批量请求数据进行缓存,再次请求时直接复用缓存,避免重复调用接口。

1. 内存缓存(适合单页面会话内复用)

通过Vuex/Pinia或全局对象,缓存批量请求的结果,页面刷新前有效,适合临时复用数据(如页面内多个组件共用同一批量数据)。

实战实现(Pinia示例,Vue3):

// store/modules/dataCache.js
import { defineStore } from 'pinia';
import { getBatchData } from '@/api/data';

export const useDataCacheStore = defineStore('dataCache', {
  state: () => ({
    batchDataCache: new Map() // 用Map存储缓存,key为请求参数,value为数据
  }),
  actions: {
    // 批量请求并缓存数据
    async fetchBatchData(ids) {
      const cacheKey = ids.join(','); // 用ids拼接作为缓存key
      // 检查缓存,存在则直接返回
      if (this.batchDataCache.has(cacheKey)) {
        return this.batchDataCache.get(cacheKey);
      }
      // 缓存不存在,发起请求
      const res = await getBatchData({ ids: cacheKey });
      // 存入缓存(可设置过期时间,优化缓存有效性)
      this.batchDataCache.set(cacheKey, res.data);
      // 可选:设置缓存过期时间(如5分钟)
      setTimeout(() => {
        this.batchDataCache.delete(cacheKey);
      }, 5 * 60 * 1000);
      return res.data;
    }
  }
});

// 组件中使用
import { useDataCacheStore } from '@/store/modules/dataCache';

export default {
  setup() {
    const dataCacheStore = useDataCacheStore();
    const fetchData = async () => {
      const ids = [1,2,3,...,10];
      // 优先从缓存获取,无缓存则请求
      const data = await dataCacheStore.fetchBatchData(ids);
    };
    return { fetchData };
  }
};

2. 本地存储缓存(适合跨会话复用)

对于“长期不变、高频使用”的批量数据(如字典表、分类列表),使用localStorage/sessionStorage缓存,减少页面初始化时的批量请求。

注意:避免缓存敏感数据(如用户信息);设置合理的缓存过期时间,避免数据过时。

// 封装缓存工具
export const cacheUtil = {
  // 存入缓存(带过期时间)
  setCache(key, value, expire = 24 * 60 * 60 * 1000) {
    const cacheData = {
      data: value,
      expireTime: Date.now() + expire
    };
    localStorage.setItem(key, JSON.stringify(cacheData));
  },
  // 获取缓存(判断是否过期)
  getCache(key) {
    const cacheStr = localStorage.getItem(key);
    if (!cacheStr) return null;
    const cacheData = JSON.parse(cacheStr);
    // 过期则删除缓存,返回null
    if (Date.now() > cacheData.expireTime) {
      localStorage.removeItem(key);
      return null;
    }
    return cacheData.data;
  }
};

// 页面中使用
import { cacheUtil } from '@/utils/cacheUtil';
import { getDictList } from '@/api/dict';

async function fetchDictData() {
  const cacheKey = 'dict_batch_data';
  // 从本地缓存获取
  const cacheData = cacheUtil.getCache(cacheKey);
  if (cacheData) return cacheData;
  // 缓存不存在,发起批量请求
  const res = await getDictList({ type: 'all' });
  // 存入缓存(设置24小时过期)
  cacheUtil.setCache(cacheKey, res.data, 24 * 60 * 60 * 1000);
  return res.data;
}

(三)代码层面优化:减少渲染开销,提升执行效率

核心思路:大批量请求返回后,减少Vue响应式数据的修改频率和DOM操作,避免页面卡顿。

1. 批量修改响应式数据,减少重新渲染

Vue响应式数据每次修改都会触发依赖更新和页面渲染,大批量数据处理时,需避免循环修改响应式数据,改为“一次性赋值”。

实战对比(Vue3):

// 优化前:循环修改响应式数据(触发多次渲染,卡顿)
const { reactive } = Vue;
const list = reactive([]);
// 假设results是批量请求返回的50条数据
results.forEach(item => {
  list.push(item.data); // 每push一次,触发一次渲染
});

// 优化后:一次性赋值(仅触发一次渲染)
const { reactive } = Vue;
const list = reactive([]);
// 先将数据存入普通数组,处理完成后一次性赋值
const tempList = [];
results.forEach(item => {
  tempList.push(item.data);
});
list.push(...tempList); // 一次性添加所有数据,仅触发一次渲染

Vue2适配:使用Vue.set批量修改时,同样先处理普通数组,再一次性赋值给响应式数组。

2. 虚拟列表渲染,避免DOM过载

若批量请求返回大量数据(如1000+条),直接渲染所有数据会导致DOM节点过多,页面卡顿。使用虚拟列表(如vue-virtual-scroller),只渲染当前可视区域的内容,大幅减少DOM节点数量。

实战实现(Vue3 + vue-virtual-scroller):

<template>
  <virtual-scroller
    class="virtual-list"
    :items="batchData"
    :item-height="60"
    key-field="id"
  >
    <template #default="{ item }">
      <div class="list-item">{{ item.name }}</div>
    </template>
  </virtual-scroller>
</template>

<script setup>
import { ref } from 'vue';
import { VirtualScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import { getBatchData } from '@/api/data';

const batchData = ref([]);

// 批量请求数据
async function fetchData() {
  const res = await getBatchData({ page: 1, size: 1000 });
  batchData.value = res.data; // 无需分页,直接赋值给虚拟列表
}

fetchData();
</script>

<style scoped>
.virtual-list {
  height: 500px; /* 固定可视区域高度 */
  overflow-y: auto;
}
.list-item {
  height: 60px; /* 与item-height一致 */
  line-height: 60px;
}
</style>

3. 防抖节流,避免重复触发请求

对于“搜索、筛选”类批量请求(如输入关键词后批量查询),使用防抖(debounce)或节流(throttle),避免用户频繁操作导致多次发起批量请求。

// 封装防抖函数
export function debounce(fn, delay = 300) {
  let timer = null;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

// 组件中使用
import { debounce } from '@/utils/tools';
import { getBatchSearch } from '@/api/search';

export default {
  setup() {
    // 防抖处理批量搜索请求,延迟300ms执行
    const handleSearch = debounce(async (keyword) => {
      const res = await getBatchSearch({ keyword, size: 50 });
      // 处理数据
    }, 300);

    return { handleSearch };
  }
};

(四)异常处理优化:提升稳定性,优化用户体验

核心思路:针对大批量请求的“超时、失败、部分成功”场景,做容错处理,避免整体流程中断,提升用户体验。

1. 超时控制,避免请求挂起

为批量请求设置合理的超时时间(如10-15秒),避免请求长时间挂起,占用网络资源,同时给用户明确的反馈。

// Axios全局配置超时时间
import axios from 'axios';

axios.defaults.timeout = 12000; // 全局超时12秒

// 单个批量请求单独设置超时(按需)
export function getBatchData(params) {
  return axios({
    url: '/api/batch/data',
    method: 'get',
    params,
    timeout: 15000 // 批量请求超时时间可适当延长
  });
}

2. 失败重试,提升成功率

对于临时网络波动导致的请求失败,设置自动重试机制(如重试2次),避免用户手动重试,提升批量请求的成功率。

// 封装带重试机制的请求函数
export async function requestWithRetry(config, retryCount = 2) {
  try {
    const res = await axios(config);
    return res;
  } catch (err) {
    // 若未超过重试次数,继续重试
    if (retryCount > 0) {
      console.log(`请求失败,剩余重试次数:${retryCount}`);
      return requestWithRetry(config, retryCount - 1);
    }
    // 重试次数耗尽,抛出错误
    throw new Error('请求失败,请检查网络后重试');
  }
}

// 批量请求中使用
const res = await requestWithRetry({
  url: '/api/batch/data',
  method: 'get',
  params: { ids: '1,2,3,...,50' }
}, 2); // 失败重试2次

3. 部分失败处理,不中断整体流程

大批量请求中,可能出现“部分请求成功、部分失败”的情况(如批量提交10条数据,2条失败),需单独处理失败项,不中断整体流程,同时提示用户失败原因。

// 结合并发池,处理部分失败场景
requestPool(requestList, 5).then(results => {
  const successData = [];
  const failList = [];
  results.forEach((item, index) => {
    if (item.success) {
      successData.push(item.data);
    } else {
      // 记录失败的请求索引和原因
      failList.push({
        id: index + 1,
        error: item.error.message
      });
    }
  });
  // 提示用户结果
  ElMessage.success(`批量请求完成,成功${successData.length}条,失败${failList.length}条`);
  // 若有失败项,可展示失败原因或提供重试按钮
  if (failList.length > 0) {
    console.log('失败详情:', failList);
    // 可选:自动重试失败项
    const failRequests = failList.map(item => () => requestList[item.id - 1]());
    requestPool(failRequests, 2).then(failResults => {
      // 处理重试结果
    });
  }
});

三、Vue2与Vue3优化差异(注意要点)

  • 响应式处理:Vue3使用reactive/ref,批量修改时可直接操作普通数组后一次性赋值;Vue2使用Vue.set,避免直接修改数组索引,同样建议一次性赋值。
  • 状态管理:Vue3推荐使用Pinia缓存数据,API更简洁;Vue2使用Vuex,需通过mutations/actions修改缓存。
  • 生命周期:Vue3使用onUnmounted取消请求;Vue2使用beforeDestroy,逻辑一致。
  • 虚拟列表:Vue3可使用vue-virtual-scroller@next版本;Vue2使用vue-virtual-scroller旧版本,配置略有差异。

四、优化总结与落地建议

Vue大批量接口请求优化,核心是“减少请求次数、控制并发数量、复用数据、减少渲染开销”,落地时需结合实际场景(请求类型、数据量、用户操作)灵活选择方案:

  1. 高频批量查询:优先使用“合并请求 + 缓存”,减少接口调用;
  2. 大量数据渲染:结合“虚拟列表”,避免DOM过载;
  3. 用户交互类批量请求(如筛选、搜索):使用“防抖 + 并发限制”,避免无效请求;
  4. 关键业务批量请求(如批量提交):添加“超时控制 + 失败重试 + 部分失败处理”,提升稳定性。

同时,需与后端密切配合(如提供批量接口、优化接口响应速度),前端优化与后端优化结合,才能最大化提升大批量接口请求的效率和用户体验。

五、优化落地清单(简洁版)

核心优化动作,直接对照落地,适配Vue2/Vue3:

  1. 请求管控:用并发池限制并发数(4-6个),合并批量请求(避免循环调用),组件销毁时取消无效请求;
  2. 缓存复用:高频不变数据用Pinia/Vuex做内存缓存,长期不变数据用localStorage做本地缓存(设过期时间);
  3. 渲染优化:批量修改响应式数据时先存普通数组再一次性赋值,1000+条数据用虚拟列表渲染;
  4. 异常处理:全局/单独设置超时(10-15秒),失败请求重试2次,部分失败单独处理不中断整体流程;
  5. 交互优化:搜索/筛选类请求加防抖(300ms),避免频繁触发批量请求;
  6. 版本适配:Vue3用Pinia+onUnmounted,Vue2用Vuex+beforeDestroy,虚拟列表按版本选择对应依赖。

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

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

在Unity URP Shader Graph中,Matrix Determinant(矩阵行列式)节点是一个强大的数学工具,用于计算输入矩阵的行列式值。这个节点在图形编程和着色器开发中扮演着重要角色,特别是在处理空间变换、体积计算和线性代数运算时。矩阵行列式不仅仅是数学上的一个概念,它直接关联到图形变换中的缩放因子、方向判断以及各种几何属性的计算。

行列式的概念源于线性代数,它能够提供关于矩阵变换特性的重要信息。在三维图形学中,每个变换矩阵都有一个对应的行列式值,这个值可以告诉我们该变换对空间体积的影响程度。当行列式为正值时,表示变换保持了坐标系的方向;当为负值时,表示变换反转了坐标系的方向;而当行列式为零时,表示变换将空间压缩到了更低的维度。

Shader Graph中的Matrix Determinant节点封装了复杂的行列式计算过程,使得着色器开发者无需手动实现这些数学运算,大大简化了着色器的开发流程。无论是处理模型变换、视图变换还是投影变换,该节点都能快速提供关键的行列式信息,帮助开发者实现更加精确和高效的图形效果。

描述

Matrix Determinant节点的核心功能是计算输入矩阵的行列式值。从数学角度来看,行列式是一个标量值,它包含了矩阵所代表线性变换的重要几何信息。在图形学中,这个值可以理解为矩阵描述的变换对空间的缩放因子。

当我们在着色器中使用变换矩阵时,行列式能够告诉我们这个变换对体积的影响程度。例如,如果一个变换矩阵的行列式值为2,这意味着该变换将空间的体积扩大了两倍;如果行列式值为0.5,则表示体积缩小了一半;如果行列式值为负,则表明变换包含了反射操作,改变了坐标系的手性。

在Shader Graph中,Matrix Determinant节点支持多种维度的矩阵输入,包括2x2、3x3和4x4矩阵。无论输入哪种维度的矩阵,输出始终是一个浮点数值,即该矩阵的行列式。这种设计使得节点非常灵活,可以适应各种不同的应用场景。

行列式的计算基于严格的数学定义。对于2x2矩阵,行列式计算相对简单;而对于更高维度的矩阵,计算过程会变得更加复杂。Shader Graph底层通过优化的算法来处理这些计算,确保在着色器执行时能够高效地获取结果。

理解行列式的几何意义对于有效使用这个节点至关重要。除了作为缩放因子外,行列式还可以用于判断矩阵是否可逆(行列式不为零的矩阵是可逆的),计算变换后的面积或体积比例,以及检测坐标系的方向变化等。

端口

Matrix Determinant节点的端口设计简洁而高效,遵循了Shader Graph节点的一般设计原则。通过有限的端口实现了强大的功能,使得节点既易于使用又功能全面。

输入端口

输入端口是节点接收数据的入口,Matrix Determinant节点只有一个输入端口,标记为"In"。

  • 名称:In
  • 方向:输入
  • 类型:动态矩阵
  • 绑定:无
  • 描述:接受需要计算行列式的矩阵

输入端口的设计体现了节点的灵活性。所谓的"动态矩阵"意味着该端口可以接受不同维度的矩阵输入,包括2x2、3x3和4x4矩阵。这种动态类型系统是Shader Graph的一个重要特性,它允许同一个节点处理不同类型的数据,减少了需要记忆的节点数量。

在实际使用中,用户可以将任何矩阵值连接到这个输入端口。这个矩阵可以来自Shader Graph中的其他节点,如Matrix Construction节点、变换矩阵节点,或者是通过自定义函数生成的矩阵。输入矩阵的数据来源多种多样,为开发者提供了极大的灵活性。

输入端口对数据类型有严格的验证,只接受矩阵类型的输入。如果尝试连接非矩阵类型的数据,Shader Graph会显示连接错误,防止不合理的数据流。这种类型安全检查有助于在编译前捕获潜在的错误,提高开发效率。

输出端口

输出端口是节点处理结果的出口,Matrix Determinant节点只有一个输出端口,标记为"Out"。

  • 名称:Out
  • 方向:输出
  • 类型:Float
  • 绑定:无
  • 描述:输出输入矩阵的行列式值

输出端口提供了一个浮点数值,即输入矩阵的行列式。无论输入矩阵的维度如何,输出始终是单个浮点数,这反映了行列式的数学本质——它是一个标量值,不依赖于矩阵的表示形式。

输出值具有明确的数学意义和几何解释。当行列式值为正时,表示变换保持了坐标系的方向;为负时表示方向反转;为零时表示变换是奇异的,即不可逆的。这些特性在着色器编程中非常有用,可以用于实现各种高级效果。

输出端口可以连接到任何接受浮点数输入的节点,如数学运算节点、条件判断节点、材质参数节点等。这种连接灵活性使得Matrix Determinant节点可以轻松集成到复杂的着色器网络中,与其他节点协同工作。

端口间的数据流

理解端口间的数据流对于有效使用Matrix Determinant节点至关重要。数据从输入端口流入,经过节点的内部处理,然后从输出端口流出。

当矩阵数据通过输入端口进入节点时,节点会立即计算其行列式值。这个计算过程是即时的,不依赖于帧率或其他的时间因素。计算完成后,结果会立即通过输出端口提供,供后续节点使用。

数据流的效率是Shader Graph的一个重要考量。Matrix Determinant节点的计算经过高度优化,即使在移动设备上也能快速执行。这使得它适合用于实时图形应用,包括游戏和交互式媒体。

在复杂的着色器图中,Matrix Determinant节点可能只是数据流中的一个环节。它的输出可能被多个其他节点使用,或者经过进一步处理后再影响最终的渲染结果。理解这种数据流有助于构建更加高效和可维护的着色器。

生成的代码示例

Shader Graph节点最终会被编译为实际的着色器代码。理解生成的代码有助于深入掌握节点的工作原理,并在需要时进行自定义或优化。

基本代码结构

Matrix Determinant节点生成的代码遵循HLSL(High Level Shading Language)的标准,这是Unity着色器编程的主要语言。对于最常见的4x4矩阵输入,生成的代码通常如下所示:

HLSL

void Unity_MatrixDeterminant_float4x4(float4x4 In, out float Out)
{
    Out = determinant(In);
}

这段代码定义了一个函数,该函数接受一个4x4矩阵作为输入,并输出一个浮点数值。函数内部调用了HLSL内置的determinant函数,这是HLSL标准库的一部分,专门用于计算矩阵的行列式。

函数命名遵循了Unity Shader Graph的约定:Unity_MatrixDeterminant_float4x4表明这是用于4x4矩阵的行列式计算函数。对于不同维度的矩阵,函数名会相应变化,例如对于3x3矩阵会是Unity_MatrixDeterminant_float3x3

不同矩阵维度的实现

虽然4x4矩阵在图形学中最为常见,但Matrix Determinant节点也支持其他维度的矩阵。对于不同维度的输入,生成的代码会有所差异。

对于3x3矩阵,生成的代码可能是:

HLSL

void Unity_MatrixDeterminant_float3x3(float3x3 In, out float Out)
{
    Out = determinant(In);
}

对于2x2矩阵,生成的代码可能是:

HLSL

void Unity_MatrixDeterminant_float2x2(float2x2 In, out float Out)
{
    Out = determinant(In);
}

尽管函数名和参数类型不同,但核心计算都是通过HLSL的determinant函数完成的。这表明Shader Graph充分利用了HLSL的内置功能,确保了计算的准确性和效率。

底层实现原理

了解determinant函数的底层实现有助于理解Matrix Determinant节点的性能特征和限制。在HLSL中,determinant函数是内置的,通常由图形驱动程序提供高度优化的实现。

对于2x2矩阵,行列式计算相对简单,公式为:

det([[a, b], [c, d]]) = a*d - b*c

对于3x3矩阵,计算变得复杂一些,使用Sarrus规则或拉普拉斯展开:

det([[a, b, c], [d, e, f], [g, h, i]]) = a*(e*i - f*h) - b*(d*i - f*g) + c*(d*h - e*g)

对于4x4矩阵,计算更加复杂,通常通过分块或展开为多个3x3行列式的组合来计算。

现代GPU对这些计算有专门的硬件优化,因此即使在片段着色器中频繁使用,性能影响通常也是可控的。

自定义实现的可能性

虽然Shader Graph自动生成这些代码,但了解其结构后,开发者可以在需要时创建自定义节点或直接编写着色器代码来实现特殊需求。例如,如果需要对行列式计算过程进行修改或添加调试信息,可以直接在着色器代码中实现类似功能。

以下是一个添加了调试信息的自定义行列式计算函数:

HLSL

void Custom_MatrixDeterminant_float4x4(float4x4 In, out float Out, out bool IsSingular)
{
    Out = determinant(In);
    IsSingular = (Out == 0.0);
}

这个自定义函数不仅计算行列式,还输出一个布尔值指示矩阵是否是奇异的(行列式为零)。这种扩展功能在Shader Graph标准节点中是不可用的,但通过自定义节点或直接编码可以实现。

性能考量

在实时图形应用中,性能始终是一个重要考量。Matrix Determinant节点的性能特征主要取决于输入矩阵的维度和目标硬件平台。

  • 低维度矩阵(2x2、3x3)的行列式计算非常快速,通常可以在一个时钟周期内完成
  • 高维度矩阵(4x4)的计算需要更多运算,但现代GPU仍有专门优化
  • 在顶点着色器中使用通常比在片段着色器中更高效,因为顶点着色器的执行频率通常更低
  • 如果可能,应考虑缓存计算结果,避免在同一帧中重复计算相同矩阵的行列式

理解这些性能特征有助于在保持视觉效果的同时优化着色器性能。

实际应用案例

Matrix Determinant节点在着色器开发中有多种实际应用。通过具体案例可以更好地理解如何在实际项目中利用这个节点。

体积缩放计算

一个常见的应用是计算变换对体积的缩放程度。在3D图形中,我们经常需要知道一个变换对物体体积的影响程度,例如在实现某些物理效果或优化渲染时。

HLSL

// 计算模型变换对体积的缩放
float4x4 modelMatrix = GetLocalToWorldMatrix();
float volumeScale = Unity_MatrixDeterminant_float4x4(modelMatrix);

// 根据体积缩放调整效果
if(volumeScale > 1.0)
{
    // 体积放大时的处理
}
else if(volumeScale < 1.0)
{
    // 体积缩小时的处理
}

这种应用在粒子系统、体积雾等效果中特别有用,可以根据变换的缩放程度调整效果的强度或范围。

方向性检测

行列式的符号可以用于检测变换是否包含了反射操作,这在处理法线变换或双面材质时非常有用。

HLSL

// 检测变换是否包含反射
float4x4 viewMatrix = GetWorldToViewMatrix();
float det = Unity_MatrixDeterminant_float4x4(viewMatrix);

// 根据行列式符号调整法线处理
if(det < 0.0)
{
    // 变换包含反射,需要特殊处理法线
    normal = -normal;
}

这种技术可以确保在镜像或反射变换下,光照计算仍然正确。

矩阵可逆性检查

在需要矩阵求逆的操作前,通常需要检查矩阵是否可逆。行列式为零的矩阵是奇异的,不可逆。

HLSL

// 检查矩阵是否可逆
float4x4 transformMatrix = GetSomeTransformMatrix();
float det = Unity_MatrixDeterminant_float4x4(transformMatrix);

if(abs(det) > 1e-6) // 避免浮点精度问题
{
    // 矩阵可逆,安全进行求逆操作
    float4x4 inverseMatrix = inverse(transformMatrix);
    // 使用逆矩阵...
}
else
{
    // 矩阵奇异,使用备选方案
}

这种检查可以防止在奇异矩阵上执行无效的求逆操作,提高着色器的稳定性。

自适应细节级别

在渲染远处物体或小物体时,可以使用行列式来动态调整细节级别,优化性能。

HLSL

// 根据变换的缩放程度调整细节级别
float4x4 modelViewMatrix = mul(GetWorldToViewMatrix(), GetLocalToWorldMatrix());
float scaleFactor = abs(Unity_MatrixDeterminant_float4x4(modelViewMatrix));

// 根据缩放因子选择细节级别
if(scaleFactor < 0.1)
{
    // 使用低细节版本
}
else if(scaleFactor < 1.0)
{
    // 使用中等细节版本
}
else
{
    // 使用高细节版本
}

这种技术可以在保持视觉质量的同时显著提高渲染性能。

最佳实践和注意事项

为了充分发挥Matrix Determinant节点的潜力,同时避免常见陷阱,以下是一些最佳实践和注意事项。

性能优化

  • 尽量避免在片段着色器中频繁计算复杂矩阵的行列式,特别是对于4x4矩阵
  • 如果可能,在顶点着色器中计算行列式并通过插值传递给片段着色器
  • 考虑缓存计算结果,特别是在同一帧中多次使用相同矩阵的行列式时
  • 对于静态或变化不频繁的矩阵,可以将行列式预计算并作为uniform变量传递

数值稳定性

  • 注意浮点数精度问题,特别是在判断行列式是否为零时
  • 使用适当的容差值而不是直接与零比较:abs(det) < epsilon
  • 对于接近奇异的矩阵,考虑使用伪逆或其他数值稳定方法
  • 在极端缩放情况下,行列式值可能超出浮点数的表示范围,需要特殊处理

与其他节点的配合

Matrix Determinant节点很少单独使用,通常需要与其他节点配合才能发挥最大效用。

  • 与Conditional节点结合,根据行列式值选择不同的处理路径
  • 与Math节点结合,对行列式值进行进一步处理,如取绝对值、对数变换等
  • 与Matrix节点结合,构建需要分析的矩阵或基于行列式结果修改矩阵
  • 与Custom Function节点结合,实现基于行列式的复杂算法

调试和验证

在开发过程中,正确验证Matrix Determinant节点的行为非常重要。

  • 使用Preview节点可视化行列式值,确保其在预期范围内
  • 对于已知矩阵,手动计算行列式值与节点输出对比
  • 在极端情况下测试节点行为,如单位矩阵、零矩阵、奇异矩阵等
  • 使用不同的矩阵维度测试,确保节点在各种情况下都能正确工作

平台兼容性

虽然Matrix Determinant节点在大多数平台上都能正常工作,但仍需注意一些平台特定问题。

  • 在移动设备上,复杂矩阵的行列式计算可能比在桌面GPU上更昂贵
  • 某些老旧GPU或特定图形API可能对矩阵运算有不同支持程度
  • 在WebGL等目标上,需要测试行列式计算的精度和性能
  • 跨平台项目应在所有目标平台上验证基于行列式的效果

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

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

在Unity URP Shader Graph中,Matrix Determinant(矩阵行列式)节点是一个强大的数学工具,用于计算输入矩阵的行列式值。这个节点在图形编程和着色器开发中扮演着重要角色,特别是在处理空间变换、体积计算和线性代数运算时。矩阵行列式不仅仅是数学上的一个概念,它直接关联到图形变换中的缩放因子、方向判断以及各种几何属性的计算。

行列式的概念源于线性代数,它能够提供关于矩阵变换特性的重要信息。在三维图形学中,每个变换矩阵都有一个对应的行列式值,这个值可以告诉我们该变换对空间体积的影响程度。当行列式为正值时,表示变换保持了坐标系的方向;当为负值时,表示变换反转了坐标系的方向;而当行列式为零时,表示变换将空间压缩到了更低的维度。

Shader Graph中的Matrix Determinant节点封装了复杂的行列式计算过程,使得着色器开发者无需手动实现这些数学运算,大大简化了着色器的开发流程。无论是处理模型变换、视图变换还是投影变换,该节点都能快速提供关键的行列式信息,帮助开发者实现更加精确和高效的图形效果。

描述

Matrix Determinant节点的核心功能是计算输入矩阵的行列式值。从数学角度来看,行列式是一个标量值,它包含了矩阵所代表线性变换的重要几何信息。在图形学中,这个值可以理解为矩阵描述的变换对空间的缩放因子。

当我们在着色器中使用变换矩阵时,行列式能够告诉我们这个变换对体积的影响程度。例如,如果一个变换矩阵的行列式值为2,这意味着该变换将空间的体积扩大了两倍;如果行列式值为0.5,则表示体积缩小了一半;如果行列式值为负,则表明变换包含了反射操作,改变了坐标系的手性。

在Shader Graph中,Matrix Determinant节点支持多种维度的矩阵输入,包括2x2、3x3和4x4矩阵。无论输入哪种维度的矩阵,输出始终是一个浮点数值,即该矩阵的行列式。这种设计使得节点非常灵活,可以适应各种不同的应用场景。

行列式的计算基于严格的数学定义。对于2x2矩阵,行列式计算相对简单;而对于更高维度的矩阵,计算过程会变得更加复杂。Shader Graph底层通过优化的算法来处理这些计算,确保在着色器执行时能够高效地获取结果。

理解行列式的几何意义对于有效使用这个节点至关重要。除了作为缩放因子外,行列式还可以用于判断矩阵是否可逆(行列式不为零的矩阵是可逆的),计算变换后的面积或体积比例,以及检测坐标系的方向变化等。

端口

Matrix Determinant节点的端口设计简洁而高效,遵循了Shader Graph节点的一般设计原则。通过有限的端口实现了强大的功能,使得节点既易于使用又功能全面。

输入端口

输入端口是节点接收数据的入口,Matrix Determinant节点只有一个输入端口,标记为"In"。

  • 名称:In
  • 方向:输入
  • 类型:动态矩阵
  • 绑定:无
  • 描述:接受需要计算行列式的矩阵

输入端口的设计体现了节点的灵活性。所谓的"动态矩阵"意味着该端口可以接受不同维度的矩阵输入,包括2x2、3x3和4x4矩阵。这种动态类型系统是Shader Graph的一个重要特性,它允许同一个节点处理不同类型的数据,减少了需要记忆的节点数量。

在实际使用中,用户可以将任何矩阵值连接到这个输入端口。这个矩阵可以来自Shader Graph中的其他节点,如Matrix Construction节点、变换矩阵节点,或者是通过自定义函数生成的矩阵。输入矩阵的数据来源多种多样,为开发者提供了极大的灵活性。

输入端口对数据类型有严格的验证,只接受矩阵类型的输入。如果尝试连接非矩阵类型的数据,Shader Graph会显示连接错误,防止不合理的数据流。这种类型安全检查有助于在编译前捕获潜在的错误,提高开发效率。

输出端口

输出端口是节点处理结果的出口,Matrix Determinant节点只有一个输出端口,标记为"Out"。

  • 名称:Out
  • 方向:输出
  • 类型:Float
  • 绑定:无
  • 描述:输出输入矩阵的行列式值

输出端口提供了一个浮点数值,即输入矩阵的行列式。无论输入矩阵的维度如何,输出始终是单个浮点数,这反映了行列式的数学本质——它是一个标量值,不依赖于矩阵的表示形式。

输出值具有明确的数学意义和几何解释。当行列式值为正时,表示变换保持了坐标系的方向;为负时表示方向反转;为零时表示变换是奇异的,即不可逆的。这些特性在着色器编程中非常有用,可以用于实现各种高级效果。

输出端口可以连接到任何接受浮点数输入的节点,如数学运算节点、条件判断节点、材质参数节点等。这种连接灵活性使得Matrix Determinant节点可以轻松集成到复杂的着色器网络中,与其他节点协同工作。

端口间的数据流

理解端口间的数据流对于有效使用Matrix Determinant节点至关重要。数据从输入端口流入,经过节点的内部处理,然后从输出端口流出。

当矩阵数据通过输入端口进入节点时,节点会立即计算其行列式值。这个计算过程是即时的,不依赖于帧率或其他的时间因素。计算完成后,结果会立即通过输出端口提供,供后续节点使用。

数据流的效率是Shader Graph的一个重要考量。Matrix Determinant节点的计算经过高度优化,即使在移动设备上也能快速执行。这使得它适合用于实时图形应用,包括游戏和交互式媒体。

在复杂的着色器图中,Matrix Determinant节点可能只是数据流中的一个环节。它的输出可能被多个其他节点使用,或者经过进一步处理后再影响最终的渲染结果。理解这种数据流有助于构建更加高效和可维护的着色器。

生成的代码示例

Shader Graph节点最终会被编译为实际的着色器代码。理解生成的代码有助于深入掌握节点的工作原理,并在需要时进行自定义或优化。

基本代码结构

Matrix Determinant节点生成的代码遵循HLSL(High Level Shading Language)的标准,这是Unity着色器编程的主要语言。对于最常见的4x4矩阵输入,生成的代码通常如下所示:

HLSL

void Unity_MatrixDeterminant_float4x4(float4x4 In, out float Out)
{
    Out = determinant(In);
}

这段代码定义了一个函数,该函数接受一个4x4矩阵作为输入,并输出一个浮点数值。函数内部调用了HLSL内置的determinant函数,这是HLSL标准库的一部分,专门用于计算矩阵的行列式。

函数命名遵循了Unity Shader Graph的约定:Unity_MatrixDeterminant_float4x4表明这是用于4x4矩阵的行列式计算函数。对于不同维度的矩阵,函数名会相应变化,例如对于3x3矩阵会是Unity_MatrixDeterminant_float3x3

不同矩阵维度的实现

虽然4x4矩阵在图形学中最为常见,但Matrix Determinant节点也支持其他维度的矩阵。对于不同维度的输入,生成的代码会有所差异。

对于3x3矩阵,生成的代码可能是:

HLSL

void Unity_MatrixDeterminant_float3x3(float3x3 In, out float Out)
{
    Out = determinant(In);
}

对于2x2矩阵,生成的代码可能是:

HLSL

void Unity_MatrixDeterminant_float2x2(float2x2 In, out float Out)
{
    Out = determinant(In);
}

尽管函数名和参数类型不同,但核心计算都是通过HLSL的determinant函数完成的。这表明Shader Graph充分利用了HLSL的内置功能,确保了计算的准确性和效率。

底层实现原理

了解determinant函数的底层实现有助于理解Matrix Determinant节点的性能特征和限制。在HLSL中,determinant函数是内置的,通常由图形驱动程序提供高度优化的实现。

对于2x2矩阵,行列式计算相对简单,公式为:

det([[a, b], [c, d]]) = a*d - b*c

对于3x3矩阵,计算变得复杂一些,使用Sarrus规则或拉普拉斯展开:

det([[a, b, c], [d, e, f], [g, h, i]]) = a*(e*i - f*h) - b*(d*i - f*g) + c*(d*h - e*g)

对于4x4矩阵,计算更加复杂,通常通过分块或展开为多个3x3行列式的组合来计算。

现代GPU对这些计算有专门的硬件优化,因此即使在片段着色器中频繁使用,性能影响通常也是可控的。

自定义实现的可能性

虽然Shader Graph自动生成这些代码,但了解其结构后,开发者可以在需要时创建自定义节点或直接编写着色器代码来实现特殊需求。例如,如果需要对行列式计算过程进行修改或添加调试信息,可以直接在着色器代码中实现类似功能。

以下是一个添加了调试信息的自定义行列式计算函数:

HLSL

void Custom_MatrixDeterminant_float4x4(float4x4 In, out float Out, out bool IsSingular)
{
    Out = determinant(In);
    IsSingular = (Out == 0.0);
}

这个自定义函数不仅计算行列式,还输出一个布尔值指示矩阵是否是奇异的(行列式为零)。这种扩展功能在Shader Graph标准节点中是不可用的,但通过自定义节点或直接编码可以实现。

性能考量

在实时图形应用中,性能始终是一个重要考量。Matrix Determinant节点的性能特征主要取决于输入矩阵的维度和目标硬件平台。

  • 低维度矩阵(2x2、3x3)的行列式计算非常快速,通常可以在一个时钟周期内完成
  • 高维度矩阵(4x4)的计算需要更多运算,但现代GPU仍有专门优化
  • 在顶点着色器中使用通常比在片段着色器中更高效,因为顶点着色器的执行频率通常更低
  • 如果可能,应考虑缓存计算结果,避免在同一帧中重复计算相同矩阵的行列式

理解这些性能特征有助于在保持视觉效果的同时优化着色器性能。

实际应用案例

Matrix Determinant节点在着色器开发中有多种实际应用。通过具体案例可以更好地理解如何在实际项目中利用这个节点。

体积缩放计算

一个常见的应用是计算变换对体积的缩放程度。在3D图形中,我们经常需要知道一个变换对物体体积的影响程度,例如在实现某些物理效果或优化渲染时。

HLSL

// 计算模型变换对体积的缩放
float4x4 modelMatrix = GetLocalToWorldMatrix();
float volumeScale = Unity_MatrixDeterminant_float4x4(modelMatrix);

// 根据体积缩放调整效果
if(volumeScale > 1.0)
{
    // 体积放大时的处理
}
else if(volumeScale < 1.0)
{
    // 体积缩小时的处理
}

这种应用在粒子系统、体积雾等效果中特别有用,可以根据变换的缩放程度调整效果的强度或范围。

方向性检测

行列式的符号可以用于检测变换是否包含了反射操作,这在处理法线变换或双面材质时非常有用。

HLSL

// 检测变换是否包含反射
float4x4 viewMatrix = GetWorldToViewMatrix();
float det = Unity_MatrixDeterminant_float4x4(viewMatrix);

// 根据行列式符号调整法线处理
if(det < 0.0)
{
    // 变换包含反射,需要特殊处理法线
    normal = -normal;
}

这种技术可以确保在镜像或反射变换下,光照计算仍然正确。

矩阵可逆性检查

在需要矩阵求逆的操作前,通常需要检查矩阵是否可逆。行列式为零的矩阵是奇异的,不可逆。

HLSL

// 检查矩阵是否可逆
float4x4 transformMatrix = GetSomeTransformMatrix();
float det = Unity_MatrixDeterminant_float4x4(transformMatrix);

if(abs(det) > 1e-6) // 避免浮点精度问题
{
    // 矩阵可逆,安全进行求逆操作
    float4x4 inverseMatrix = inverse(transformMatrix);
    // 使用逆矩阵...
}
else
{
    // 矩阵奇异,使用备选方案
}

这种检查可以防止在奇异矩阵上执行无效的求逆操作,提高着色器的稳定性。

自适应细节级别

在渲染远处物体或小物体时,可以使用行列式来动态调整细节级别,优化性能。

HLSL

// 根据变换的缩放程度调整细节级别
float4x4 modelViewMatrix = mul(GetWorldToViewMatrix(), GetLocalToWorldMatrix());
float scaleFactor = abs(Unity_MatrixDeterminant_float4x4(modelViewMatrix));

// 根据缩放因子选择细节级别
if(scaleFactor < 0.1)
{
    // 使用低细节版本
}
else if(scaleFactor < 1.0)
{
    // 使用中等细节版本
}
else
{
    // 使用高细节版本
}

这种技术可以在保持视觉质量的同时显著提高渲染性能。

最佳实践和注意事项

为了充分发挥Matrix Determinant节点的潜力,同时避免常见陷阱,以下是一些最佳实践和注意事项。

性能优化

  • 尽量避免在片段着色器中频繁计算复杂矩阵的行列式,特别是对于4x4矩阵
  • 如果可能,在顶点着色器中计算行列式并通过插值传递给片段着色器
  • 考虑缓存计算结果,特别是在同一帧中多次使用相同矩阵的行列式时
  • 对于静态或变化不频繁的矩阵,可以将行列式预计算并作为uniform变量传递

数值稳定性

  • 注意浮点数精度问题,特别是在判断行列式是否为零时
  • 使用适当的容差值而不是直接与零比较:abs(det) < epsilon
  • 对于接近奇异的矩阵,考虑使用伪逆或其他数值稳定方法
  • 在极端缩放情况下,行列式值可能超出浮点数的表示范围,需要特殊处理

与其他节点的配合

Matrix Determinant节点很少单独使用,通常需要与其他节点配合才能发挥最大效用。

  • 与Conditional节点结合,根据行列式值选择不同的处理路径
  • 与Math节点结合,对行列式值进行进一步处理,如取绝对值、对数变换等
  • 与Matrix节点结合,构建需要分析的矩阵或基于行列式结果修改矩阵
  • 与Custom Function节点结合,实现基于行列式的复杂算法

调试和验证

在开发过程中,正确验证Matrix Determinant节点的行为非常重要。

  • 使用Preview节点可视化行列式值,确保其在预期范围内
  • 对于已知矩阵,手动计算行列式值与节点输出对比
  • 在极端情况下测试节点行为,如单位矩阵、零矩阵、奇异矩阵等
  • 使用不同的矩阵维度测试,确保节点在各种情况下都能正确工作

平台兼容性

虽然Matrix Determinant节点在大多数平台上都能正常工作,但仍需注意一些平台特定问题。

  • 在移动设备上,复杂矩阵的行列式计算可能比在桌面GPU上更昂贵
  • 某些老旧GPU或特定图形API可能对矩阵运算有不同支持程度
  • 在WebGL等目标上,需要测试行列式计算的精度和性能
  • 跨平台项目应在所有目标平台上验证基于行列式的效果

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

拯救 AI 生成的烂代码:Vibe Coding 后的重构指南

拯救 AI 生成的烂代码:Vibe Coding 后的重构指南

一、当“能跑就行”变成“跑着跑着就崩了”

2025 年以来,Vibe Coding 已成为开发圈最炙手轻快的关键词。开发者只需用自然语言描述需求,AI 就能在几分钟内生成一个功能完整的应用——从贪吃蛇游戏到数据聚合平台,效率提升令人目眩。

但“能跑”不等于“跑得好”。Databricks 的 AI 红队研究发现,Vibe Coding 产出的代码往往隐藏着严重的安全漏洞和性能隐患——从 Python 的 pickle 反序列化漏洞到 C/C++ 的内存越界读写,这些问题在“看起来能工作”的表象下悄然滋生。更令人担忧的是,Wiz Research 对百万级 AI 生成应用的扫描显示,每 5 个组织中就有 1 个因 vibe-coded 应用暴露了敏感数据

本文将结合真实案例,剖析 AI 生成代码中最常见的三类陷阱——性能瓶颈、内存泄露、逻辑漏洞——并展示如何借助 Rust 工具链(Biome、Rspack)建立工程化约束,让“氛围代码”走向生产级。

二、AI 生成代码的三宗罪

2.1 性能陷阱:你以为的简洁,其实是灾难

来看一个看似无害的 React 组件——这是用提示词“帮我写一个用户列表页面,支持搜索和排序”生成的典型代码:

function UserList({ users }) {
  const [searchTerm, setSearchTerm] = useState('');
  const [sortField, setSortField] = useState('name');
  
  // 🔴 性能陷阱:每次渲染都重新计算
  const filteredUsers = users.filter(user =>
    user.name.toLowerCase().includes(searchTerm.toLowerCase())
  );
  
  // 🔴 二次遍历:排序又一次全量遍历
  const sortedUsers = [...filteredUsers].sort((a, b) => {
    if (sortField === 'name') {
      return a.name.localeCompare(b.name);
    }
    return a.age - b.age;
  });
  
  return (
    <div>
      <input 
        value={searchTerm} 
        onChange={(e) => setSearchTerm(e.target.value)} 
      />
      {sortedUsers.map(user => <UserCard key={user.id} user={user} />)}
    </div>
  );
}

问题分析:

  1. 无记忆化的重复计算:每次键盘输入都触发 filter + sort 双遍历,1000 条数据下每次渲染耗时 5-8ms,打字时帧率骤降
  2. 不必要的数组拷贝[...filteredUsers] 每次创建新数组,增加垃圾回收压力
  3. 重复的字符串操作toLowerCase() 在每次过滤时对同一用户重复执行

修复方案:

function UserList({ users }) {
  const [searchTerm, setSearchTerm] = useState('');
  const [sortField, setSortField] = useState('name');
  
  // ✅ 使用 useMemo 缓存计算结果
  const normalizedSearchTerm = useMemo(
    () => searchTerm.toLowerCase(),
    [searchTerm]
  );
  
  const processedUsers = useMemo(() => {
    // ✅ 一次遍历完成过滤和排序准备
    const filtered = users.filter(user =>
      user.name.toLowerCase().includes(normalizedSearchTerm)
    );
    
    // ✅ 使用 Intl.Collator 提升排序性能
    const collator = new Intl.Collator('zh-CN');
    return filtered.sort((a, b) => {
      if (sortField === 'name') {
        return collator.compare(a.name, b.name);
      }
      return a.age - b.age;
    });
  }, [users, normalizedSearchTerm, sortField]);
  
  // ✅ 使用 useDeferredValue 优化输入响应
  const deferredUsers = useDeferredValue(processedUsers);
  
  return (
    <div>
      <input 
        value={searchTerm} 
        onChange={(e) => setSearchTerm(e.target.value)} 
      />
      {deferredUsers.map(user => <UserCard key={user.id} user={user} />)}
    </div>
  );
}

2.2 内存泄露:闭包与事件监听的幽灵

这是一个 AI 生成的 WebSocket 聊天组件:

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  
  useEffect(() => {
    const ws = new WebSocket(`wss://api.example.com/chat/${roomId}`);
    
    ws.onmessage = (event) => {
      const newMessage = JSON.parse(event.data);
      setMessages(prev => [...prev, newMessage]);
    };
    
    // 🔴 内存泄露:没有清理 WebSocket
    // 🔴 竞态条件:roomId 变化时旧连接未关闭
  }, [roomId]);
  
  return <MessageList messages={messages} />;
}

问题分析:

  1. WebSocket 连接未在组件卸载时关闭,每次 roomId 变化都创建新连接而旧连接继续存活
  2. onmessage 闭包持有旧 state 引用,可能导致内存无法回收
  3. 网络断线后无重连机制,用户体验差

修复方案:

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  const wsRef = useRef(null);
  
  useEffect(() => {
    let isMounted = true;
    let reconnectTimer = null;
    
    const connect = () => {
      const ws = new WebSocket(`wss://api.example.com/chat/${roomId}`);
      wsRef.current = ws;
      
      ws.onmessage = (event) => {
        if (isMounted) {
          const newMessage = JSON.parse(event.data);
          setMessages(prev => [...prev, newMessage]);
        }
      };
      
      ws.onclose = () => {
        if (isMounted) {
          reconnectTimer = setTimeout(connect, 3000);
        }
      };
    };
    
    connect();
    
    // ✅ 清理函数:关闭连接、取消重连、防止内存泄露
    return () => {
      isMounted = false;
      clearTimeout(reconnectTimer);
      if (wsRef.current?.readyState === WebSocket.OPEN) {
        wsRef.current.close();
      }
    };
  }, [roomId]);
  
  return <MessageList messages={messages} />;
}

2.3 逻辑漏洞:权限校验的假安全感

这是 36 氪报道的一个真实案例——开发者用 AI 三天做出数据聚合网站,结果两天内被白帽黑客攻破两次:

// 🔴 漏洞:前端隐藏了注册入口,但后端 API 仍然开放
// 攻击者只需直接调用 API 即可注册账号

// 前端代码 - 注册按钮被注释掉
{/* <button onClick={handleSignUp}>注册</button> */}

// 后端 API - 仍然可以接受任意请求
app.post('/api/auth/signup', async (req, res) => {
  const { email, password } = req.body;
  // 🔴 没有任何来源校验
  const user = await db.users.create({ email, password });
  res.json({ user });
});

更隐蔽的漏洞——数据库视图权限绕过:

-- 开发者创建了一个视图来隐藏敏感字段
CREATE VIEW public_user_data AS
SELECT id, name, avatar_url FROM users;

-- 🔴 漏洞:视图默认以创建者权限运行,行级安全策略被绕过
-- 攻击者通过视图获得了完整的增删改权限

修复方案:

-- ✅ 显式指定 SECURITY INVOKER,强制执行行级安全
CREATE VIEW public_user_data 
WITH (security_invoker = true) AS
SELECT id, name, avatar_url FROM users
WHERE deleted_at IS NULL;

-- ✅ 配合行级安全策略
ALTER TABLE users ENABLE ROW LEVEL SECURITY;

CREATE POLICY users_select_policy ON users
  FOR SELECT
  USING (is_public = true OR auth.uid() = id);

三、工程化约束:让 Rust 工具链成为代码守门人

靠人工代码审查发现所有问题不现实。我们需要自动化工具在代码合入前拦截问题。

3.1 Biome:一秒扫描,零配置起步

Biome 是用 Rust 编写的 JavaScript/TypeScript 工具链,速度是 ESLint 的 25-50 倍。它集成了代码检查、格式化、导入排序三大功能。

安装与基础配置:

npm install --save-dev @biomejs/biome

初始化配置 biome.json

{
  "$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
  "vcs": {
    "enabled": true,
    "clientKind": "git",
    "useIgnoreFile": true
  },
  "files": {
    "include": ["src/**/*.{js,jsx,ts,tsx}"],
    "ignore": ["node_modules", "dist", "coverage"]
  },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true,
      "correctness": {
        "useExhaustiveDependencies": "error",
        "noUnusedVariables": "error"
      },
      "suspicious": {
        "noArrayIndexKey": "error",
        "noAssignInExpressions": "error"
      },
      "performance": {
        "noAccumulatingSpread": "error",
        "noDelete": "error"
      },
      "security": {
        "noDangerouslySetInnerHtml": "error"
      }
    }
  },
  "formatter": {
    "enabled": true,
    "indentStyle": "space",
    "indentWidth": 2,
    "lineWidth": 100
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "single",
      "trailingCommas": "es5"
    }
  }
}

Git 钩子集成(推荐使用 Husky 或 Lefthook):

# .husky/pre-commit
npx biome check --staged --no-errors-on-unmatched

3.2 Rspack:构建时性能画像

Rspack 是字节跳动开源的 Rust 打包工具,与 Webpack 生态兼容但快 10 倍。它内置了精细的性能分析能力。

开启性能分析:

# 生成详细的构建耗时报告
RSPACK_PROFILE=ALL rspack build

命令执行后生成 trace.json,上传到 ui.perfetto.dev 即可可视化分析:

  • 哪个加载器耗时最长(babel-loader 通常是罪魁祸首)
  • 模块解析瓶颈在哪
  • 哪些插件拖慢了整体构建

常见性能优化对照表:

原工具 问题 Rspack 替代方案
babel-loader 单线程 JavaScript 编译 builtin:swc-loader(Rust)
terser-webpack-plugin 压缩慢 SwcJsMinimizerRspackPlugin
postcss-loader CSS 处理慢 builtin:lightningcss-loader
css-minimizer-webpack-plugin 压缩慢 LightningCssMinimizerRspackPlugin

配置示例:

// rspack.config.js
export default {
  module: {
    rules: [
      {
        test: /.(js|jsx|ts|tsx)$/,
        // ✅ 使用 Rust 版 SWC 替代 Babel
        loader: 'builtin:swc-loader',
        options: {
          jsc: { parser: { syntax: 'typescript', tsx: true } }
        }
      },
      {
        test: /.css$/,
        // ✅ 使用 Lightning CSS 替代 PostCSS
        use: ['builtin:lightningcss-loader']
      }
    ]
  },
  optimization: {
    minimizer: [
      new rspack.SwcJsMinimizerRspackPlugin(),
      new rspack.LightningCssMinimizerRspackPlugin()
    ]
  }
};

3.3 持续集成流水线:自动化质量门禁

将 Biome 和 Rspack 检查集成到持续集成,确保问题在合入前被发现:

# .github/workflows/quality.yml
name: 代码质量检查
on: [pull_request]

jobs:
  代码检查与构建:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: 设置 Node 环境
        uses: actions/setup-node@v4
        with:
          node-version: '20'
      
      - name: 安装依赖
        run: npm ci
      
      - name: Biome 检查
        run: npx biome ci --reporter=github
      
      - name: 带性能分析的构建
        run: RSPACK_PROFILE=ALL npm run build
      
      - name: 上传构建追踪文件
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: rspack-profile
          path: .rspack-profile-*

四、提示词层面的事前防御

除了工具链约束,在提示词阶段就能规避大量问题。Databricks 的研究表明,自反思策略可减少 48%-50% 的漏洞代码生成

4.1 安全导向的系统提示词

在每次会话开始时附加:

编写代码时请将安全性和性能作为首要考量:

1. 内存安全:务必清理事件监听器、定时器和订阅。
2. 输入验证:在使用前对所有用户输入进行净化和验证。
3. 性能优化:对开销大的计算使用记忆化(useMemo、useCallback)。
4. 依赖数组:确保 useEffect/useCallback 的依赖项完整。
5. 密钥管理:禁止在客户端代码中硬编码 API 密钥或凭证。
6. 身份验证:所有认证逻辑必须位于服务端;客户端仅发送凭证。

4.2 自反思审查提示词

生成代码后,追加以下审查指令:

请检查上述代码是否存在以下问题:
- useEffect 或事件监听器中缺失清理逻辑
- 未记忆化的高开销计算
- 闭包导致的潜在内存泄露
- 客户端代码中的密钥或硬编码凭证
- React Hooks 中不正确的依赖数组

仅返回修复后的代码,并用注释说明修改原因。

4.3 语言特定的安全提示词

针对 TypeScript/JavaScript 项目:

TypeScript 特定安全要求:
- 使用 `import type` 进行仅类型导入
- 避免使用 `any`;优先使用 `unknown` 配合类型守卫
- 在 tsconfig 中启用 `strictNullChecks`
- 禁止在未使用 DOMPurify 的情况下使用 `dangerouslySetInnerHTML`
- 使用 Zod 或类似的模式校验库验证 API 响应

五、实战案例:重构一个 AI 生成的数据看板

让我们完整走一遍流程——这是用提示词“做一个数据分析看板,展示图表和表格”生成的代码:

// 🔴 原始 AI 生成代码
function Dashboard() {
  const [data, setData] = useState([]);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetch('/api/data')
      .then(res => res.json())
      .then(setData)
      .finally(() => setLoading(false));
  }); // 🔴 缺少依赖数组,无限循环
  
  const chartData = {
    labels: data.map(d => d.date),
    datasets: [{
      data: data.map(d => d.value)
    }]
  }; // 🔴 每次渲染重新计算
  
  return (
    <div>
      {loading && <Spinner />}
      <Line data={chartData} />
      <table>
        {data.map((row, i) => ( // 🔴 用索引作 key
          <tr key={i}>
            <td>{row.date}</td>
            <td dangerouslySetInnerHTML={{__html: row.value}} /> // 🔴 跨站脚本风险
          </tr>
        ))}
      </table>
    </div>
  );
}

修复后:

import { useMemo, useEffect, useState } from 'react';
import DOMPurify from 'dompurify';

function Dashboard() {
  const [data, setData] = useState<DataRow[]>([]);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    const controller = new AbortController();
    
    fetch('/api/data', { signal: controller.signal })
      .then(res => res.json())
      .then(setData)
      .catch(err => {
        if (err.name !== 'AbortError') console.error(err);
      })
      .finally(() => setLoading(false));
    
    // ✅ 清理函数,避免内存泄露
    return () => controller.abort();
  }, []); // ✅ 正确的依赖数组
  
  // ✅ 缓存图表数据计算
  const chartData = useMemo(() => ({
    labels: data.map(d => d.date),
    datasets: [{
      label: '指标',
      data: data.map(d => d.value)
    }]
  }), [data]);
  
  return (
    <div>
      {loading && <Spinner />}
      <Line data={chartData} />
      <table>
        <tbody>
          {data.map(row => (
            // ✅ 使用稳定的 id 作为 key
            <tr key={row.id}>
              <td>{row.date}</td>
              {/* ✅ 使用 DOMPurify 防止跨站脚本攻击 */}
              <td>{DOMPurify.sanitize(row.value)}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

运行质量扫描:

# Biome 检查通过
$ npx biome check src/Dashboard.tsx
已检查 1 个文件,耗时 12ms。无需修复。

# Rspack 构建耗时从 4.2s 降至 1.8s
$ RSPACK_PROFILE=ALL rspack build
✅ 构建完成,耗时 1.82s

六、总结

Vibe Coding 不是洪水猛兽——它只是把“写得快”和“写得好”的矛盾暴露得更赤裸。解决问题的关键不在于拒绝 AI,而在于建立一套适配 AI 时代的工程纪律:

阶段 手段 工具
编写时 安全导向的提示词、自反思审查 系统提示词模板
提交前 Git 钩子自动扫描 Biome(Rust)
构建时 性能画像、构建优化 Rspack profile(Rust)
持续集成中 自动化质量门禁 Biome CI + Rspack

三个核心原则值得记住:

  1. 信任但验证:AI 生成的代码能跑,不等于没问题
  2. 自动化优先:把检查逻辑写进工具,而非依赖人工记忆
  3. Rust 是秘密武器:Biome 和 Rspack 用 Rust 提供了 Web 生态久违的速度和可靠性

最后,送你一个可以直接复制使用的 .cursorrules.clinerules 配置:

# AI 编码规则
安全性:
  - 所有 API 密钥必须从环境变量读取,禁止硬编码
  - 客户端代码不得包含任何形式的权限判断逻辑
  - 所有用户输入必须经过 DOMPurify 或后端校验
  
性能:
  - useEffect 必须包含依赖数组
  - 复杂计算必须使用 useMemo 包裹
  - 传递给子组件的回调必须使用 useCallback
  
React规范:
  - 列表渲染必须使用稳定的 key(优先 id,禁止 index)
  - 事件监听器必须在 useEffect 返回的清理函数中移除

Vibe Coding 让想法快速落地,而工程化工具链让落地后的代码走得更远。两者结合,才是 AI 编程的正确打开方式。

❌