阅读视图

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

Vite 开发服务器启动时,如何将 client 注入 HTML?

当执行 vite 命令启动开发服务器,并在浏览器中打开 http://localhost:5173 时,页面会神奇地具备热模块替换(HMR)能力。这一切的起点,就是 Vite 在返回的 HTML 中悄悄注入了一个特殊的脚本:/@vite/client。这个脚本负责建立 WebSocket 连接、监听文件变化并触发模块热更新。

整体流程概览

Vite 将 client 注入 HTML 的过程可以概括为以下几个步骤:

  1. 服务器启动:创建 Vite 开发服务器,初始化插件容器。
  2. 注册插件:内置插件被激活。
  3. 请求拦截:浏览器请求 index.html,Vite 的 HTML 中间件接管。
  4. HTML 转换:调用所有插件的 transformIndexHtml 钩子。
  5. 注入标签clientInjectionsPlugin 在 transformIndexHtml 中返回需要注入的 script 标签。
  6. 模块解析:浏览器解析 HTML 后请求 /@vite/client,经过 resolveId 和 load 钩子返回实际代码。
  7. 代码转换:client 源码中的占位符被替换为当前服务器的实际配置(如 HMR 端口、base 路径等)。
  8. 客户端执行:浏览器执行 client 代码,建立 WebSocket 连接,HMR 就绪。

启动服务器到 client 在浏览器中运行的全过程

image.png

clientInjectionsPlugin

负责在客户端代码中注入配置值和环境变量,确保客户端代码能够正确访问 Vite 配置和环境信息,特别是热模块替换 (HMR) 相关的配置。

客户端核心入口:处理 /@vite/client 和 /@vite/env 文件,先注入配置值,再替换 define 变量。

image.png

buildStart钩子

image.png

image.png

vite/packages/vite/src/node/plugins/clientInjections.ts

function clientInjectionsPlugin(config: ResolvedConfig): Plugin {
  // 存储配置值替换函数,在 buildStart 钩子中初始化
  let injectConfigValues: (code: string) => string

  // 返回一个函数,每个构建环境(如 client 和 ssr)分别创建 define 替换函数
  const getDefineReplacer = perEnvironmentState((environment) => {

    const userDefine: Record<string, any> = {}

    for (const key in environment.config.define) {
      // import.meta.env.* is handled in `importAnalysis` plugin
      // 过滤掉 import.meta.env.* 前缀的变量(这些由 importAnalysis 插件处理
      if (!key.startsWith('import.meta.env.')) {
        userDefine[key] = environment.config.define[key]
      }
    }
    const serializedDefines = serializeDefine(userDefine)
    const definesReplacement = () => serializedDefines
    return (code: string) => code.replace(`__DEFINES__`, definesReplacement)
  })

  return {
    name: 'vite:client-inject',
    // 初始化插件,在 buildStart 钩子中创建配置值替换函数
    async buildStart() {

      // 生成一个函数
      // 用于接收客户端源码字符串,将其中的占位符(如 __BASE__、__HMR_PORT__、__MODE__ 等)替换为实际的值
      injectConfigValues = await createClientConfigValueReplacer(config)
    },
    // 转换客户端代码,注入配置值和环境变量
    async transform(code, id) {
      const ssr = this.environment.config.consumer === 'server'
      const cleanId = cleanUrl(id)

      // 客户端核心入口:/@vite/client 和 /@vite/env
      if (cleanId === normalizedClientEntry || cleanId === normalizedEnvEntry) {
        const defineReplacer = getDefineReplacer(this)
        return defineReplacer(injectConfigValues(code))

        // 其他文件中的 process.env.NODE_ENV 替换
      } else if (!ssr && code.includes('process.env.NODE_ENV')) {
        // replace process.env.NODE_ENV instead of defining a global
        // for it to avoid shimming a `process` object during dev,
        // avoiding inconsistencies between dev and build
        const nodeEnv =
        // 优先使用用户定义的值
          this.environment.config.define?.['process.env.NODE_ENV'] ||
          // 回退到系统环境变量
          // 最终回退到 Vite 模式
          JSON.stringify(process.env.NODE_ENV || config.mode)

        return await replaceDefine(this.environment, code, id, {
          'process.env.NODE_ENV': nodeEnv,
          'global.process.env.NODE_ENV': nodeEnv,
          'globalThis.process.env.NODE_ENV': nodeEnv,
        })
      }
    },
  }
}
function perEnvironmentState<State>(
  initial: (environment: Environment) => State,
): (context: PluginContext) => State {

  const stateMap = new WeakMap<Environment, State>()

  return function (context: PluginContext) {
    const { environment } = context
    // 尝试从 stateMap 中获取当前环境的状
    let state = stateMap.get(environment)
    if (!state) {
      // 调用 initial 函数初始化状态,并将其存储到 stateMap 中
      state = initial(environment)
      stateMap.set(environment, state)
    }
    return state
  }
}

indexHtmlMiddleware 中间件

当浏览器请求 index.html 时,Vite 开发服务器会通过中间件(packages/vite/src/node/server/middlewares/html.ts)处理。

  1. 请求拦截与过滤
  2. 完整打包模式(Full Bundle Dev Environment) 或 普通文件系统模式
  3. 通过 send 发送返回 HTML。

image.png

image.png

image.png

image.png

image.png

全量环境

  1. 文档类请求的 SPA 回退:若请求头的 sec-fetch-dest 为 documentiframe 等类型,并且满足以下任一条件:
    (1)当前 bundle 已过时,会重新生成 bundle);
    (2)或者文件原本不存在(file === undefined);则调用 generateFallbackHtml(server) 生成一个默认的 index.html 作为文件内容。

  2. 最终将文件内容(字符串或 Buffer)通过 send 返回,并携带 etag 用于缓存。

  if (fullBundleEnv) {
    const pathname = decodeURIComponent(url)
    // 打包根目录的文件路径 index.html
    const filePath = pathname.slice(1) // remove first /

    let file = fullBundleEnv.memoryFiles.get(filePath)
    if (!file && fullBundleEnv.memoryFiles.size !== 0) {
      return next()
    }
    const secFetchDest = req.headers['sec-fetch-dest']
    // 处理文档类请求(SPA 回退)
    if (
      [
        'document',
        'iframe',
        'frame',
        'fencedframe',
        '',
        undefined,
      ].includes(secFetchDest) &&
      // 检查当前 bundle 是否过期
      ((await fullBundleEnv.triggerBundleRegenerationIfStale()) ||
        file === undefined)
    ) {
      // 生成一个 fallback HTML 作为文件内容
      // 生成一个默认的 HTML 入口
      file = { source: await generateFallbackHtml(server as ViteDevServer) }
    }
    if (!file) {
      return next()
    }

    const html =
      typeof file.source === 'string'
        ? file.source
        : Buffer.from(file.source)
    const headers = isDev
      ? server.config.server.headers
      : server.config.preview.headers
    return send(req, res, html, 'html', { headers, etag: file.etag })
  }

image.png

image.png

发送

image.png

async function getHmrImplementation(
  config: ResolvedConfig,
): Promise<string> {

  // 读取client脚本文件
  const content = fs.readFileSync(normalizedClientEntry, 'utf-8')
  const replacer = await createClientConfigValueReplacer(config)
  return (
    replacer(content)
      // the rolldown runtime cannot import a module
      .replace(/import\s*['"]@vite\/env['"]/, '')
  )
}

image.png

image.png

 async function importUpdatedModule({
    url, // 补丁文件的 URL,例如 "/hmr_patch_0.js"
    acceptedPath, // 需要热更新的模块路径
    isWithinCircularImport,
  }) {
    const importPromise = import(base + url!).then(() =>
      // 从 Rolldown 运行时中提取模块的导出
      // @ts-expect-error globalThis.__rolldown_runtime__
      globalThis.__rolldown_runtime__.loadExports(acceptedPath),
    )
    if (isWithinCircularImport) {
      importPromise.catch(() => {
        console.info(
          `[hmr] ${acceptedPath} failed to apply HMR as it's within a circular import. Reloading page to reset the execution order. ` +
            `To debug and break the circular import, you can run \`vite --debug hmr\` to log the circular dependency path if a file change triggered it.`,
        )
        pageReload()
      })
    }
    return await importPromise
}

【示例】修改文件,client 的 websocket 收到更新

{
    "type": "update",
    "updates": [
        {
            "type": "js-update",
            "url": "hmr_patch_0.js",
            "path": "src/pages/home/index.vue",
            "acceptedPath": "src/pages/home/index.vue",
            "timestamp": 1775992132341
        }
    ]
}

image.png

image.png

【示例】修改样式变量文件

浏览器客户端收到websocket消息,会加载补丁文件。

{
    "type": "update",
    "updates": [
        {
            "type": "js-update",
            "url": "hmr_patch_3.js",
            "path": "src/pages/home/index@logs.vue?vue&type=style&index=0&scoped=9217440f&lang.less",
            "acceptedPath": "src/pages/home/index@logs.vue?vue&type=style&index=0&scoped=9217440f&lang.less",
            "timestamp": 1775994912031
        },
        {
            "type": "js-update",
            "url": "hmr_patch_3.js",
            "path": "src/pages/home.vue?vue&type=style&index=0&scoped=f940dfa7&lang.less",
            "acceptedPath": "src/pages/home.vue?vue&type=style&index=0&scoped=f940dfa7&lang.less",
            "timestamp": 1775994912031
        },
        {
            "type": "js-update",
            "url": "hmr_patch_3.js",
            "path": "src/pages/home/index.vue?vue&type=style&index=0&scoped=2c5296db&lang.less",
            "acceptedPath": "src/pages/home/index.vue?vue&type=style&index=0&scoped=2c5296db&lang.less",
            "timestamp": 1775994912031
        }
    ]
}

浏览器端成功加载一个模块(包括动态 import())后,客户端会主动发送 vite:module-loaded 事件。

{
    "type": "custom",
    "event": "vite:module-loaded",
    "data": {
        "modules": [
            "src/pages/home/index@logs.vue?vue&type=style&index=0&scoped=9217440f&lang.less",
            "src/pages/home.vue?vue&type=style&index=0&scoped=f940dfa7&lang.less",
            "src/pages/home/index.vue?vue&type=style&index=0&scoped=2c5296db&lang.less"
        ]
    }
}

服务器接收到该事件,提取出本次加载的模块列表(payload.modules),并将其注册到开发引擎(devEngine)中,关联到当前客户端 ID。

this.hot.on('vite:client:disconnect', (_payload, client) => {
  const clientId = this.clients.delete(client)
  if (clientId) {
    this.devEngine.removeClient(clientId)
  }
})

普通文件模式

  1. 解析请求的文件路径
  2. 开发模式下的访问权限检查
  3. 读取 HTML 文件并进行转换,最终通过 send 返回 HTML。
  // 根据请求 URL 确定 HTML 文件的实际文件系统路径
  let filePath: string

  // 如果是开发服务器且 URL 以 FS_PREFIX 开头(表示直接访问文件系统路径)
  if (isDev && url.startsWith(FS_PREFIX)) {
    filePath = decodeURIComponent(fsPathFromId(url))
  } else {
    // 将 URL 与服务器根目录连接,解析为绝对路径
    filePath = normalizePath(
      path.resolve(path.join(root, decodeURIComponent(url))),
    )
  }

  if (isDev) {
    const servingAccessResult = checkLoadingAccess(server.config, filePath)
    // 如果路径被拒绝访问,返回 403 错误
    if (servingAccessResult === 'denied') {
      return respondWithAccessDenied(filePath, server, res)
    }
    // 
    if (servingAccessResult === 'fallback') {
      return next()
    }
    // 确保路径被允许访问
    servingAccessResult satisfies 'allowed'
  } else {
    // `server.fs` options does not apply to the preview server.
    // But we should disallow serving files outside the output directory.
    if (!isParentDirectory(root, filePath)) {
      return next()
    }
  }

  if (fs.existsSync(filePath)) {
    const headers = isDev
      ? server.config.server.headers
      : server.config.preview.headers

    try {
      // 读取 HTML 文件内容
      let html = await fsp.readFile(filePath, 'utf-8')
      if (isDev) {
        // 开发环境下,对 HTML 进行转换
        html = await server.transformIndexHtml(url, html, req.originalUrl)
      }
      // 发送 HTML 内容
      // 这里使用 send() 方法,而不是 res.end(),因为它会自动处理响应头和编码
      return send(req, res, html, 'html', { headers })
    } catch (e) {
      return next(e)
    }
  }

image.png

执行中间件

image.png

image.png

读取html文件内容

image.png

server.transformIndexHtml 其实就是执行 applyHtmlTransforms,之前中间件已经处理 createDevHtmlTransformFn

createDevHtmlTransformFn

  1. plugin.transformIndexHtml 获取具有 transformIndexHtml 钩子的插件,排序。
  2. 构建转换钩子管道,在 applyHtmlTransforms 中按顺序执行。
  3. applyHtmlTransforms 根据插件钩子,生成相关 tag 注入 html 中。

vite/packages/vite/src/node/server/middlewares/indexHtml.ts

function createDevHtmlTransformFn(
  config: ResolvedConfig,
): (
  server: ViteDevServer,
  url: string,
  html: string,
  originalUrl?: string,
) => Promise<string> {

  // 从配置的插件中解析出 HTML 转换钩子
  const [preHooks, normalHooks, postHooks] = resolveHtmlTransforms(
    config.plugins,
  )

  // 构建转换钩子管道
  const transformHooks = [
    preImportMapHook(config), // 处理导入映射的前置钩子
    injectCspNonceMetaTagHook(config), // 注入 CSP nonce 元标签
    ...preHooks,
    htmlEnvHook(config), // 注入环境变量到 HTML 中
    devHtmlHook, // 开发环境特定的 HTML 转换
    ...normalHooks,
    ...postHooks,
    injectNonceAttributeTagHook(config), // 注入 nonce 属性到标签中
    postImportMapHook(), // 处理导入映射的后置钩子
  ]

  // 创建插件上下文
  const pluginContext = new BasicMinimalPluginContext(
    { ...basePluginContextMeta, watchMode: true },
    config.logger,
  )
  return (
    server: ViteDevServer,
    url: string,
    html: string,
    originalUrl?: string,
  ): Promise<string> => {

    // 将所有转换钩子应用到 HTML 内容上
    return applyHtmlTransforms(html, transformHooks, pluginContext, {
      path: url,
      filename: getHtmlFilename(url, server),
      server,
      originalUrl,
    })
  }
}

traverseHtml

server.transformIndexHtml 对html进行转换 ——> createDevHtmlTransformFn confige 解析阶段收集了插件transformIndexHtml钩子 ——〉applyHtmlTransforms 执行上述收集的hook.handler——> injectToHead 将标签插入头部

收集所有插件的 transformIndexHtml 钩子返回的修改(可能是 HTML 字符串的替换,或者要插入的标签数组),然后将其应用到原始 HTML 上。

  const transformHooks = [
    preImportMapHook(config), // 处理导入映射的前置钩子
    injectCspNonceMetaTagHook(config), // 注入 CSP nonce 元标签
    ...preHooks,
    htmlEnvHook(config), // 注入环境变量到 HTML 中
    devHtmlHook, // 开发环境特定的 HTML 转换
    ...normalHooks,
    ...postHooks,
    injectNonceAttributeTagHook(config), // 注入 nonce 属性到标签中
    postImportMapHook(), // 处理导入映射的后置钩子
  ]

【示例】执行devHtmlTransformFn

image.png

【示例】执行 htmlEnvHook

image.png

【示例】 devHtmlHook

image.png

image.png

image.png

处理 html 节点

image.png

处理head节点

image.png

处理 meta 节点

image.png

处理 link 节点

image.png

applyHtmlTransforms

image.png

injectToHead

image.png

vite/packages/vite/src/node/plugins/html.ts

async function applyHtmlTransforms(
  html: string,
  hooks: IndexHtmlTransformHook[],
  pluginContext: MinimalPluginContextWithoutEnvironment,
  ctx: IndexHtmlTransformContext,
): Promise<string> {
  for (const hook of hooks) {
    const res = await hook.call(pluginContext, html, ctx)
    if (!res) {
      continue
    }
    if (typeof res === 'string') {
      html = res
    } else {
      let tags: HtmlTagDescriptor[]
      if (Array.isArray(res)) {
        tags = res
      } else {
        html = res.html || html
        tags = res.tags
      }

      let headTags: HtmlTagDescriptor[] | undefined
      let headPrependTags: HtmlTagDescriptor[] | undefined
      let bodyTags: HtmlTagDescriptor[] | undefined
      let bodyPrependTags: HtmlTagDescriptor[] | undefined

      for (const tag of tags) {
        switch (tag.injectTo) {
          case 'body':
            ;(bodyTags ??= []).push(tag)
            break
          case 'body-prepend':
            ;(bodyPrependTags ??= []).push(tag)
            break
          case 'head':
            ;(headTags ??= []).push(tag)
            break
          default:
            ;(headPrependTags ??= []).push(tag)
        }
      }
      headTagInsertCheck([...(headTags || []), ...(headPrependTags || [])], ctx)
      if (headPrependTags) html = injectToHead(html, headPrependTags, true)
      if (headTags) html = injectToHead(html, headTags)
      if (bodyPrependTags) html = injectToBody(html, bodyPrependTags, true)
      if (bodyTags) html = injectToBody(html, bodyTags)
    }
  }

  return html
}

vite 插件 @vitejs/plugin-vue

@vitejs/plugin-vue 是 Vite 官方提供的 Vue 3 单文件组件(SFC)支持插件,它负责将 .vue 文件转换为浏览器可执行的 JavaScript 模块。

Vue3项目使用 @vitejs/plugin-vue插件

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [
    vue({
      
    }),
    // vueJsx(),
    // vueDevTools(),
  ],
})

参数选项有哪些?

interface Options {
  // 指定哪些文件需要被插件转换
  include?: string | RegExp | (string | RegExp)[]
  // 指定哪些文件不需要被插件转换
  exclude?: string | RegExp | (string | RegExp)[]

  /**
   * In Vite, this option follows Vite's config.
   */
  isProduction?: boolean

  // options to pass on to vue/compiler-sfc
  // 传递给 @vue/compiler-sfc 中 compileScript 的选项
  script?: Partial<
    Omit<
      SFCScriptCompileOptions,
      | 'id'
      | 'isProd'
      | 'inlineTemplate'
      | 'templateOptions'
      | 'sourceMap'
      | 'genDefaultAs'
      | 'customElement'
      | 'defineModel'
      | 'propsDestructure'
    >
  > & {
    /**
     * @deprecated defineModel is now a stable feature and always enabled if
     * using Vue 3.4 or above.
     */
    defineModel?: boolean
    /**
     * @deprecated moved to `features.propsDestructure`.
     */
    propsDestructure?: boolean
  }

  // 传递给 @vue/compiler-sfc 中 compileTemplate 的选项
  template?: Partial<
    Omit<
      SFCTemplateCompileOptions,
      | 'id'
      | 'source'
      | 'ast'
      | 'filename'
      | 'scoped'
      | 'slotted'
      | 'isProd'
      | 'inMap'
      | 'ssr'
      | 'ssrCssVars'
      | 'preprocessLang'
    >
  >
  // 传递给 @vue/compiler-sfc 中 compileStyle 的选项
  style?: Partial<
    Omit<
      SFCStyleCompileOptions,
      | 'filename'
      | 'id'
      | 'isProd'
      | 'source'
      | 'scoped'
      | 'cssDevSourcemap'
      | 'postcssOptions'
      | 'map'
      | 'postcssPlugins'
      | 'preprocessCustomRequire'
      | 'preprocessLang'
      | 'preprocessOptions'
    >
  >

  /**
   * Use custom compiler-sfc instance. Can be used to force a specific version.
   */
  compiler?: typeof _compiler

  /**
   * Requires @vitejs/plugin-vue@^5.1.0
   */
  features?: {
    /**
     * Enable reactive destructure for `defineProps`.
     * - Available in Vue 3.4 and later.
     * - **default:** `false` in Vue 3.4 (**experimental**), `true` in Vue 3.5+
     * 启动后,`defineProps` 中的响应式解构将支持 Vue 3.4 中的语法。
     */
    propsDestructure?: boolean
    /**
     * Transform Vue SFCs into custom elements.
     * - `true`: all `*.vue` imports are converted into custom elements
     * - `string | RegExp`: matched files are converted into custom elements
     * - **default:** /\.ce\.vue$/
     * 启动后,所有匹配的文件都将被转换为自定义元素。
     */
    customElement?: boolean | string | RegExp | (string | RegExp)[]
    /**
     * Set to `false` to disable Options API support and allow related code in
     * Vue core to be dropped via dead-code elimination in production builds,
     * resulting in smaller bundles.
     * - **default:** `true`
     * 启动后,Vue 核心代码中的 Options API 将被移除,从而减小 bundle 大小。
     */
    optionsAPI?: boolean
    /**
     * Set to `true` to enable devtools support in production builds.
     * Results in slightly larger bundles.
     * - **default:** `false`
     * 启动后,生产环境下的 devtools 将被启用,从而增加 bundle 大小。
     */
    prodDevtools?: boolean
    /**
     * Set to `true` to enable detailed information for hydration mismatch
     * errors in production builds. Results in slightly larger bundles.
     * - **default:** `false`
     * 启动后,生产环境下的 hydration mismatch 错误将包含详细的调试信息,从而增加 bundle 大小。
     */
    prodHydrationMismatchDetails?: boolean
    /**
     * Customize the component ID generation strategy.
     * - `'filepath'`: hash the file path (relative to the project root)
     * - `'filepath-source'`: hash the file path and the source code
     * - `function`: custom function that takes the file path, source code,
     *   whether in production mode, and the default hash function as arguments
     * - **default:** `'filepath'` in development, `'filepath-source'` in production
     * 启动后,组件 ID 将根据文件路径和源代码进行哈希处理,从而增加 bundle 大小。
     */
    componentIdGenerator?:
      | 'filepath'
      | 'filepath-source'
      | ((
          filepath: string,
          source: string,
          isProduction: boolean | undefined,
          getHash: (text: string) => string,
        ) => string)
  }

  /**
   * @deprecated moved to `features.customElement`.
   * 已废弃,移至 feature中
   */
  customElement?: boolean | string | RegExp | (string | RegExp)[]
}

SFC 编译流程

当 Vite 遇到一个 .vue 文件时,插件会执行以下编译流程:

  1. 解析 SFC:使用 @vue/compiler-sfc 将 .vue 文件解析为 descriptor 对象,其中包含 templatescriptstyle 等部分的解析结果
  2. 脚本编译:处理 <script> 块,包括 <script setup> 语法糖和 TypeScript 支持
  3. 模板编译:将 <template> 块编译为 render 函数
  4. 样式处理:处理 <style> 块,包括 CSS 预处理器的支持

生命周期

@vitejs/plugin-vue 中钩子执行顺序遵循 Vite 插件生命周期,分为服务器启动/构建准备阶段模块请求处理阶段

配置阶段(一次)

  • config:最早执行,用于修改 Vite 配置。
  • configResolved:配置解析完成后调用,可获取最终配置。
  • options:Rollup 选项钩子,在构建开始前修改输入选项(较少用)。

服务器启动 / 构建开始

  • 开发模式configureServer 在开发服务器创建时调用,用于添加中间件。
  • 生产构建buildStart 在构建开始时调用。

模块请求处理(每次请求/每个文件)

  • resolveId:解析模块 ID(将路径转换为绝对路径或虚拟 ID)。
  • load:加载模块内容(读取文件或生成源码)。
  • shouldTransformCachedModule(Rollup 钩子):决定是否使用缓存转换结果(在 load 后、transform 前调用,仅构建时)。
  • transform:转换模块内容(核心编译逻辑,例如将 .vue 文件转为 JS)。

image.png

transform 钩子

transform 钩子是整个插件的核心编译入口,它的职责是拦截 .vue 文件或相关子模块的请求,根据请求参数的不同,调用相应的编译函数,将 SFC 转换为浏览器可执行的 JavaScript 代码。

handler 接收的参数

  • code vue 文件源码
  • id 文件在系统的绝对路径
  • opt 配置项

image.png

image.png

 主请求(!query.vue

这是浏览器或构建工具直接请求 .vue 文件(例如 import App from './App.vue')。

transformMain 是 @vitejs/plugin-vue 中处理 .vue 文件主请求的核心编译函数。它负责将整个单文件组件(SFC)转换为可在浏览器或服务端运行的 JavaScript 模块。

  1. 解析与校验:创建 SFC 描述符,检查编译错误。
  2. 分块编译:分别生成脚本、模板、样式、自定义块的代码。
  3. 组装导出:合并各部分代码,生成最终组件对象。
  4. HMR 与 SSR 增强:注入热更新逻辑或服务端模块注册。
  5. Source Map 合并:如果存在模板,将模板的 source map 偏移后合并到脚本 map。
  6. TypeScript 转译:对最终代码进行 TS 转译(优先使用 Oxc,降级为 esbuild)。
创建描述符信息 compiler.parse

createDescriptor

image.png

getDescriptor 获取描述符。有缓存则从缓存取,否则创建。

image.png

image.png

描述符id生成策略

描述符id生成策略(依据 features?.componentIdGenerator 配置)

  • filepath 文件路径
  • filepath-source 文件路径 +源码
  • function 自定义实现
  • 默认策略(生产环境:文件路径+源码;非生产环境:文件路径)

image.png

import crypto from 'node:crypto'

function getHash(text: string): string {
  // 计算哈希值,采用 sha256 哈希算法对输入文本进行计算
  // 将哈希结果转换为十六进制 (hex) 格式
  // 取前8位,用于组件ID的唯一性
  return crypto.hash('sha256', text, 'hex').substring(0, 8)
}
生成脚本代码

genScriptCode 通过 resolveScript(@vue/compiler-dom ) 获取脚本,然后根据 <script> 块的存在性、内容来源(内联或外部)以及 Vue 编译器版本,产出最终用于构建组件对象的脚本部分。

参数信息

image.png

脚本代码 resolve

image.png

image.png

resolved.content

image.png

image.png

resolevd.map

image.png

生成 template 代码

针对 内联模板(无预处理器且无外部 src)

genTemplateCode 调用 transformTemplateInMain 函数,该函数会:

  1. 使用 @vue/compiler-dom 将模板内容编译为 render 函数。
  2. 处理 scoped 样式、指令转换等。
  3. 返回包含 render 函数声明的代码(例如 const _sfc_render = () => {...})。
  4. 直接内联到主模块中,避免额外的网络请求,提升开发环境性能。

image.png

transformTemplateInMain(template.content, descriptor, options, pluginContext, ssr, customElement)

image.png

result.code

image.png

result.ast

image.png

// 重命名模板编译后的渲染函数
// $1 引用第一个捕获组,即 function 或 const
// $2 引用第二个捕获组,即 render 或 ssrRender
result.code.replace(
      /\nexport (function|const) (render|ssrRender)/,
      '\n$1 _sfc_$2',
    )

image.png

image.png

生成style 代码

genStyleCode 是 @vitejs/plugin-vue 中专门处理 Vue 单文件组件(SFC)样式块的核心函数。它的主要职责是:为每个 <style> 块生成相应的导入语句,并处理 CSS Modules 和自定义元素模式下的样式收集

生成 自定义块 代码

genCustomBlockCode 用于生成 Vue 单文件组件 (SFC) 中自定义块的处理代码,它会为每个自定义块生成导入语句和执行代码,确保自定义块能够被正确处理和集成到组件中。

添加热更新相关代码

image.png

image.png

import.meta.hot.on("file-changed", ({ file }) => {
__VUE_HMR_RUNTIME__.CHANGED_FILE = file;
});

Vue HMR(热模块替换)运行时的一部分,用于监听 Vite 开发服务器的 file-changed 事件,并记录被修改的文件路径。

// Vite 提供的 HMR API,用于接受模块自身的更新。当该模块(即 .vue 文件)被修改时,回调函数会收到新的模块内容 mod。
import.meta.hot.accept((mod) => {
if (!mod) return;
const { default: updated, _rerender_only } = mod;
if (_rerender_only) {
  __VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render);
} else {
  __VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated);
}
});
  • updated:更新后的组件默认导出(即组件对象)。
  • _rerender_only:Vue 编译器生成的一个标志,用于指示本次变更是否仅影响模板(而不影响 <script> 逻辑)。如果是 true,则只需重新渲染视图;否则需要完全重载组件实例。
  • __VUE_HMR_RUNTIME__ :Vue 在开发环境注入的全局 HMR 运行时对象。
    • rerender:仅更新组件的渲染函数,保留组件实例状态(如 datacomputed 等)。通常用于仅修改 <template> 的场景。
    • reload:完全销毁并重新创建组件实例,会丢失内部状态。用于 <script> 逻辑发生变化时。

core/packages/runtime-core/src/hmr.ts

if (__DEV__) {
  getGlobalThis().__VUE_HMR_RUNTIME__ = {
    createRecord: tryWrap(createRecord),
    rerender: tryWrap(rerender),
    reload: tryWrap(reload),
  } as HMRRuntime
}
收集附加属性 (attachedProps)

添加 attachedProps 的导出代码并转为字符串resolvedCode

image.png

转译 Typescript

根据条件 判断利用 transformWithOxctransformWithEsbuild 来转译 Tyscript 代码。

优先尝试使用 Oxc(一个高性能的 JavaScript/TypeScript 编译器)进行转译,如果不可用,则回退到使用 esbuild

image.png

子块请求(query.vue 为 true

首先获取缓存的 SFC 描述符(descriptor)。

根据 query.type 进一步分流:

  1. type === 'template' :调用 transformTemplateAsModule,将 <template> 块编译为独立的 render 函数模块。
  2. type === 'style' :调用 transformStyle,将 CSS 内容交给 Vite 的 CSS 处理管道(例如注入到页面或提取为独立文件)。

处理 template

async function transformTemplateAsModule(
  code: string, 
  filename: string,
  descriptor: SFCDescriptor,
  options: ResolvedOptions,
  pluginContext: Rollup.TransformPluginContext,
  ssr: boolean,
  customElement: boolean,
): Promise<{
  code: string
  map: any
}> {
  // 调用 compile 函数编译模板代码
  // 返回包含编译后代码和 source map 的结果
  const result = compile(
    code,
    filename,
    descriptor,
    options,
    pluginContext,
    ssr,
    customElement,
  )

  let returnCode = result.code

  // 处理热更新
  if (
    options.devServer && //开发服务器
    options.devServer.config.server.hmr !== false && // 开启热更新
    !ssr && // 不是服务器端渲染
    !options.isProduction // 不是生产环境
  ) {
      // 重新渲染组件
      // 传递组件 ID 和新的渲染函数
    returnCode += `\nimport.meta.hot.accept(({ render }) => {
      __VUE_HMR_RUNTIME__.rerender(${JSON.stringify(descriptor.id)}, render)
    })`
  }

  return {
    code: returnCode,
    map: result.map,
  }
}

处理style

async function transformStyle(
  code: string,
  descriptor: ExtendedSFCDescriptor,
  index: number,
  options: ResolvedOptions,
  pluginContext: Rollup.TransformPluginContext,
  filename: string,
) {
  const block = descriptor.styles[index]
  // vite already handles pre-processors and CSS module so this is only
  // applying SFC-specific transforms like scoped mode and CSS vars rewrite (v-bind(var))
  const result = await options.compiler.compileStyleAsync({
    ...options.style,
    filename: descriptor.filename, // 样式文件路径
    id: `data-v-${descriptor.id}`,// 组件 ID(用于 scoped 样式)
    isProd: options.isProduction,
    source: code, // 原始样式代码
    scoped: block.scoped, // 是否为 scoped 样式
    ...(options.cssDevSourcemap
      ? {
          postcssOptions: {
            map: {
              from: filename, // 设置源文件路径
              inline: false, // 不内联 Source Map,Source Map 会作为单独的文件生成
              annotation: false, // 不在 CSS 文件中添加 Source Map 注释
            },
          },
        }
      : {}),
  })

  if (result.errors.length) {
    result.errors.forEach((error: any) => {
      if (error.line && error.column) {
        error.loc = {
          file: descriptor.filename,
          line: error.line + block.loc.start.line,
          column: error.column,
        }
      }
      pluginContext.error(error)
    })
    return null
  }

  const map = result.map
    ? await formatPostcssSourceMap(
        // version property of result.map is declared as string
        // but actually it is a number
        result.map as Omit<
          RawSourceMap,
          'version'
        > as Rollup.ExistingRawSourceMap,
        filename,
      )
    : ({ mappings: '' } as any)

  return {
    code: result.code,
    map: map,
    meta:
    // 当样式为 scoped 且描述符不是临时的时,添加 cssScopeTo 元数据
    // 用于 Vite 处理 CSS 作用域
      block.scoped && !descriptor.isTemp
        ? {
            vite: {
              cssScopeTo: [descriptor.filename, 'default'] as const,
            },
          }
        : undefined,
  }
}

handleHotUpdate 热更新

handleHotUpdate:当文件变化触发 HMR 时调用,自定义热更新行为。

执行过程?

  1. 获取旧描述符:从缓存中读取文件修改前的 SFC 描述符(prevDescriptor)。
  2. 读取最新内容并生成新描述符:通过 read() 获取文件当前内容,再调用 createDescriptor 生成新的 SFC 描述符。
  3. 比对差异:依次比较 scripttemplatestylecustomBlocks 等块是否发生变化。
  4. 收集受影响的模块:根据变化类型,将对应的 Vite 模块(ModuleNode)加入到 affectedModules 集合中。
  5. 返回模块列表:将 affectedModules 返回给 Vite,Vite 会重新转换这些模块,并通过 WebSocket 通知浏览器进行热更新。

vue 文件热更新变化

  1. 脚本变化导致组件完全重载(添加主模块);
  2. 模板变化且脚本未变时仅重新渲染(保留组件状态,添加模板模块);
  3. 样式变化时仅更新对应样式模块(若无独立模块则回退重载主模块);
  4. CSS 变量或 scoped 状态变化以及自定义块变化均会强制重载主模块

vite-plugin-vue-6.0.4/packages/plugin-vue/src/handleHotUpdate.ts

async function handleHotUpdate(
  { file, modules, read }: HmrContext,
  options: ResolvedOptions,
  customElement: boolean,
  typeDepModules?: ModuleNode[],
): Promise<ModuleNode[] | void> {
  
  const prevDescriptor = getDescriptor(file, options, false, true)
  if (!prevDescriptor) {
    // file hasn't been requested yet (e.g. async component)
    return
  }

  // 取文件的最新内容
  const content = await read()
  // 基于最新内容创建新的组件描述符
  const { descriptor } = createDescriptor(file, content, options, true)

  let needRerender = false
  // 将模块分为非 JS 模块和 JS 模块
  const nonJsModules = modules.filter((m) => m.type !== 'js')
  const jsModules = modules.filter((m) => m.type === 'js')

  // 受影响的模块集合
  const affectedModules = new Set<ModuleNode | undefined>(
    nonJsModules, // this plugin does not handle non-js modules
  )
  // 找到组件的主模块
  const mainModule = getMainModule(jsModules)
  // 找到模板相关的模块
  const templateModule = jsModules.find((m) => /type=template/.test(m.url))

  /** 1、检测脚本块的变化并确定受影响的模块 */
  // trigger resolveScript for descriptor so that we'll have the AST ready
  // resolveScript 会触发对 <script> 或 <script setup> 的解析(生成 AST 等),确保新描述符中的脚本信息可用
  resolveScript(descriptor, options, false, customElement)
  const scriptChanged = hasScriptChanged(prevDescriptor, descriptor)
  if (scriptChanged) {
    affectedModules.add(getScriptModule(jsModules) || mainModule)
  }

  /** 2、检测模板块的变化并确定受影响的模块 */
  // 模板变化
  if (!isEqualBlock(descriptor.template, prevDescriptor.template)) {
    // when a <script setup> component's template changes, it will need correct
    // binding metadata. However, when reloading the template alone the binding
    // metadata will not be available since the script part isn't loaded.
    // in this case, reuse the compiled script from previous descriptor.
    // 如果脚本没有改变,直接使用之前的编译后的脚本
    if (!scriptChanged) {
      setResolvedScript(
        descriptor,
        getResolvedScript(prevDescriptor, false)!,
        false,
      )
    }
    affectedModules.add(templateModule)
    needRerender = true // 标记需要重渲染
  }

  /** 3、检查 CSS 变量注入的变化并确定受影响的模块 */
  let didUpdateStyle = false
  const prevStyles = prevDescriptor.styles || []
  const nextStyles = descriptor.styles || []

  // force reload if CSS vars injection changed
  // 如果 CSS 变量注入发生变化,强制重新加载
  if (prevDescriptor.cssVars.join('') !== descriptor.cssVars.join('')) {
    affectedModules.add(mainModule)
  }

  /** 4、检查 scoped 状态的变化并确定受影响的模块 */
  // force reload if scoped status has changed
  // 如果 scoped 状态变化,强制重新加载
  if (prevStyles.some((s) => s.scoped) !== nextStyles.some((s) => s.scoped)) {
    // template needs to be invalidated as well
    affectedModules.add(templateModule)
    affectedModules.add(mainModule)
  }

  /** 5、检测样式块的变化并确定受影响的模块 */
  // only need to update styles if not reloading, since reload forces
  // style updates as well.
  for (let i = 0; i < nextStyles.length; i++) {
    const prev = prevStyles[i]
    const next = nextStyles[i]

    // 如果旧样式块不存在(新添加的样式块)
    // 或者旧样式块与新样式块不相等(样式内容发生变化
    if (!prev || !isEqualBlock(prev, next)) {
      didUpdateStyle = true // 标记样式发生变化
      const mod = jsModules.find(
        (m) =>
          m.url.includes(`type=style&index=${i}`) &&
          m.url.endsWith(`.${next.lang || 'css'}`),
      )
      if (mod) {
        affectedModules.add(mod)

        // 如果样式内联,添加主模块到受影响模块集合
        if (mod.url.includes('&inline')) {
          affectedModules.add(mainModule)
        }
      } else {
        // 如果没有找到对应的模块(新添加的样式块)
        // new style block - force reload
        affectedModules.add(mainModule)
      }
    }
  }
  if (prevStyles.length > nextStyles.length) {
    // 如果旧样式块数量大于新样式块数量(说明有样式块被移)
    // 强制重新加载
    // style block removed - force reload
    affectedModules.add(mainModule)
  }

  /**  6、检测自定义块的变化并确定受影响的模块 */
  const prevCustoms = prevDescriptor.customBlocks || []
  const nextCustoms = descriptor.customBlocks || []

  // custom blocks update causes a reload
  // because the custom block contents is changed and it may be used in JS.
  // 如果数量变化,强制重新加载
  if (prevCustoms.length !== nextCustoms.length) {
    // block removed/added, force reload
    affectedModules.add(mainModule)
  } else {
    for (let i = 0; i < nextCustoms.length; i++) {
      const prev = prevCustoms[i]
      const next = nextCustoms[i]

      // 
      if (!prev || !isEqualBlock(prev, next)) {
        const mod = jsModules.find((m) =>
          m.url.includes(`type=${prev.type}&index=${i}`),
        )
        if (mod) {
          affectedModules.add(mod)
        } else {
          affectedModules.add(mainModule)
        }
      }
    }
  }

  const updateType = []
  // 需要重渲染
  if (needRerender) {
    // 记录更新类型。将 'template' 添加到 updateType 数组中
    updateType.push(`template`)
    // template is inlined into main, add main module instead
    // 无模板情况,说明模板被内联到主模块中
    if (!templateModule) {
      // 添加 mainModule 到受影响模块集合
      affectedModules.add(mainModule)

      // 有模板的情况,且 mainModule 未被添加到受影响模块
    } else if (mainModule && !affectedModules.has(mainModule)) {

      // 找到 mainModule 的所有样式导入模块
      const styleImporters = [...mainModule.importers].filter((m) =>
        isCSSRequest(m.url),
      )
      // 将样式导入模块添加到受影响模块集合
      styleImporters.forEach((m) => affectedModules.add(m))
    }
  }

  // 样式发送变化,将 'style' 添加到 updateType 数组中
  if (didUpdateStyle) {
    updateType.push(`style`)
  }
  if (updateType.length) {
    // 针对vue文件,使描述符缓存失效
    if (file.endsWith('.vue')) {
      // invalidate the descriptor cache so that the next transform will
      // re-analyze the file and pick up the changes.
      invalidateDescriptor(file)
    } else {
      // https://github.com/vuejs/vitepress/issues/3129
      // For non-vue files, e.g. .md files in VitePress, invalidating the
      // descriptor will cause the main `load()` hook to attempt to read and
      // parse a descriptor from a non-vue source file, leading to errors.
      // To fix that we need to provide the descriptor we parsed here in the
      // main cache. This assumes no other plugin is applying pre-transform to
      // the file type - not impossible, but should be extremely unlikely.
      // 将解析的描述符设置到主缓存中
      cache.set(file, descriptor)
    }
    debug(`[vue:update(${updateType.join('&')})] ${file}`)
  }
  return [...affectedModules, ...(typeDepModules || [])].filter(
    Boolean,
  ) as ModuleNode[]
}

使用 Claude Code 将 Google Stitch 设计稿转换为代码

使用 Claude Code 将 Google Stitch 设计稿转换为代码

概述

Google Stitch 是 Google Labs 推出的 AI UI 设计工具,能够通过自然语言生成高保真 UI 设计,并支持导出 HTML/CSS 代码或复制到 Figma 。配合 Claude Code,你可以将 Stitch 生成的设计进一步精细化转换为生产级前端代码。

整体工作流

Stitch 生成设计 → 导出设计稿/代码 → Claude Code 代码转换/优化 → 集成到项目

方法一:Stitch + Figma + Claude Code(推荐)

Step 1:在 Stitch 中生成设计

  1. 访问 Stitch 官网 并使用 Google 账号登录
  2. 选择 Standard Mode(基于 Gemini 2.5 Flash,支持导出到 Figma)
  3. 输入设计提示词,选择界面类型(移动端/网页端)

提示词示例:

Design a modern and user-friendly fitness mobile app interface. The app should feature a bottom navigation bar with four tabs: Home, Workouts, Shop, and Profile.

Step 2:导出设计到 Figma

  1. 生成满意设计后,点击页面顶部的 Figma 按钮
  2. 在 Figma 画布中 Ctrl+V 粘贴,所有组件、文字、图片会自动分层并保留 Auto Layout
  3. 在 Figma 中精修设计细节(可选)

Step 3:配置 Figma MCP 连接 Claude Code

Figma MCP 能让 Claude Code 直接读取 Figma 设计稿的结构化数据,生成高保真代码 。

启动 Figma MCP Server:

  1. 打开 Figma 桌面客户端(非网页版)
  2. 进入 Preferences → Enable Dev Mode MCP Server

配置 Claude Code 的 MCP: 在项目根目录的 .mcp.json 中添加:

{
  "mcpServers": {
    "Figma": {
      "url": "http://127.0.0.1:3845/sse"
    }
  }
}

或通过命令行添加:

claude mcp add --transport sse Figma http://127.0.0.1:3845/sse

添加 Figma 自定义规则到 CLAUDE.md

<user_custom_rules>
# Figma Dev Mode MCP Rules
- IMPORTANT: If Figma MCP returns a localhost source for an image/SVG, use that source directly
- IMPORTANT: DO NOT import/add new icon packages, all assets should come from Figma
- IMPORTANT: DO NOT use or create placeholders if a localhost source is provided
</user_custom_rules>

Step 4:使用 Claude Code 转换设计为代码

在 Figma 中选中要转换的设计画板,然后在 Claude Code 中输入:

请根据 Figma 当前选中的设计稿,将其转换为代码实现。

要求:
1. 使用 get_code 方法读取设计稿的 UI 细节
2. 使用 [React/Vue/HTML/CSS] 框架
3. 保持设计稿的间距、颜色、字体完全一致
4. 优先使用设计系统的 token/变量
5. 组件需要具备响应式能力

Claude Code 会调用 Figma MCP 的 get_code 工具读取设计稿细节,并生成对应代码 。

Step 5:预览和迭代

Claude Code Desktop 提供内置预览功能:

  • 点击 Preview 按钮可实时查看生成的页面
  • 如需微调,直接描述修改需求:"那个按钮的圆角改成 8px"
  • Claude 会即时更新代码

方法二:直接使用 Stitch 导出的 HTML + Claude Code 优化

Step 1:从 Stitch 导出 HTML 代码

Stitch 支持直接导出 HTML/CSS 代码 :

  1. 在 Stitch 中生成满意的设计
  2. 导出 HTML 代码包

Step 2:让 Claude Code 优化和重构代码

将导出的代码放入项目目录,然后使用 Claude Code 优化:

请分析并优化这个 Stitch 导出的 HTML/CSS 代码:

1. 将样式重构为更可维护的 CSS 模块/组件结构
2. 将静态 HTML 转换为 [React/Vue] 组件
3. 优化语义化 HTML 标签和无障碍访问
4. 添加响应式断点适配移动端/平板/桌面
5. 使用 CSS 变量统一管理设计 token

Step 3:集成到现有项目

请将这些重构后的组件集成到我的现有项目结构中:
- 组件放在 src/components/[功能名]/
- 样式文件使用 [CSS Modules/Tailwind/Styled Components]
- 路由配置添加到 src/router/index

高级技巧:使用 Intuition 工作流进行系统化开发

对于复杂项目,推荐使用 Intuition 工作流系统,它提供 trunk-and-branch 的规范开发流程 。

安装 Intuition

npm install -g @tgoodington/intuition

完整工作流

/intuition-initialize          # 初始化项目记忆(仅首次)
/intuition-start               # 检查状态,获取下一步路由
/intuition-prompt              # 描述设计需求,生成结构化简报
/intuition-outline             # 创建策略蓝图,分解任务
/intuition-assemble            # 匹配任务到领域专家
/intuition-detail              # 生成详细代码规格
/intuition-build               # 执行实现并验证
/intuition-test                # 质量检验

结合 Stitch 设计的使用方式:

  1. 在 Stitch 中生成页面设计
  2. 截图或导出设计稿作为视觉参考
  3. /intuition-prompt 阶段附上设计稿截图,描述设计意图
  4. 后续阶段 Intuition 会生成结构化的代码方案并指导实现

常用 Claude Code 命令速查

命令 功能
/init 初始化项目记忆文件,扫描代码库生成架构说明
/review 执行代码审查,检查语法错误和最佳实践
/compact 压缩对话历史,释放上下文 token
/export 导出完整对话记录为 Markdown
Ctrl+R 搜索历史 Prompt
!命令 直接执行终端命令(如 ! npm run dev

注意事项与最佳实践

  1. 设计还原度:Figma MCP 能最大程度保留设计稿的精确度,优先使用此方式
  2. Token 管理:复杂对话后使用 /compact 释放上下文,避免超出 token 限制
  3. 安全第一:使用 Plan Mode 让 Claude 先规划再执行,避免误修改
  4. 环境变量:如需连接数据库或 API,向开发同事获取 .env 文件,切勿在对话中粘贴密钥
  5. 预览调试:如内置预览失败,可让 Claude 启动本地服务器并提供 localhost 链接

快速上手示例:从 Stitch 设计到 React 组件

# 1. 启动 Figma MCP(Figma 桌面端 → Preferences → Enable Dev Mode MCP)

# 2. 在 Claude Code 中配置
claude mcp add --transport sse Figma http://127.0.0.1:3845/sse

# 3. 启动 Claude Code
claude

# 4. 在对话中输入
> 请根据 Figma 当前选中的 Stitch 设计稿,生成一个 React + TypeScript 组件。
> 使用 Tailwind CSS 处理样式,组件放在 src/components/Dashboard。
> 确保响应式布局和暗色模式支持。

Claude Code 会读取 Figma 中的设计稿结构,生成可直接使用的 React 组件代码,包括完整的 props 类型定义和样式。

详细讲解下 for...of vs for await...of 区别

for...of 和 for await...of 都是 ES6 之后引入的迭代语句,用于遍历可迭代对象(Iterable)中的值。它们的核心区别在于处理的迭代协议不同:一个是同步迭代,另一个是异步迭代。下面从协议基础、行为差异、适用场景和底层细节展开对比。


1. 迭代协议基础

特性 for...of for await...of
依赖的迭代协议 Symbol.iterator Symbol.asyncIterator
迭代器返回的方法 next() 直接返回 { value, done } next() 返回一个 Promise<{ value, done }>
适用的可迭代对象 数组、字符串、Map、Set、arguments、NodeList、生成器对象(同步)等 异步生成器、ReadableStream、实现了 Symbol.asyncIterator 的对象、同步可迭代对象(会被自动适配)
是否可在非 async 函数内使用 ✅ 任何地方 ✅ 任何地方(但循环体内若使用 await 则必须包裹在 async 函数中)

2. 核心区别详解

2.1 迭代值的获取方式

  • for...of:同步获取下一个值,若迭代器返回的是 Promise 则不会被等待,直接将 Promise 对象作为值赋给循环变量。
const iterable = {
  [Symbol.iterator]() {
    let i = 0;
    return {
      next() {
        if (i++ < 2) {
          return { value: Promise.resolve(i), done: false };
        }
        return { done: true };
      }
    };
  }
};

for (const x of iterable) {
  console.log(x); // Promise { <resolved>: 1 } 两次
}
  • for await...of:会等待迭代器的 next() 返回的 Promise 完成,并将 fulfilled 的值赋给循环变量。
const asyncIterable = {
  [Symbol.asyncIterator]() {
    let i = 0;
    return {
      async next() {
        if (i++ < 2) {
          return { value: i, done: false };
        }
        return { done: true };
      }
    };
  }
};

for await (const x of asyncIterable) {
  console.log(x); // 1, 2(数字,非 Promise)
}

2.2 对同步可迭代对象的兼容性

  • for...of 只能直接遍历同步可迭代对象。
  • for await...of 也能遍历同步可迭代对象(如数组、Set),此时它会将同步返回的 { value, done } 中的 value 自动包装成已解决的 Promise,然后等待其结果(其实瞬间完成)。
const arr = [1, 2, 3];

// 正常打印 1, 2, 3
for (const v of arr) console.log(v);

// 同样正常打印 1, 2, 3,但有细微的异步开销
for await (const v of arr) console.log(v);

因此 for await...of 是一个更宽松的循环,它既能处理异步迭代器,也能处理同步迭代器(只不过在同步迭代器上会多一次 Promise 转换和 microtask 调度)。

2.3 循环体内的异步操作支持

  • for...of 循环体内部可以使用 await,但前提是整个循环必须位于 async 函数内,且 await 并不会影响迭代器获取下一个值——循环仍然同步推进,只是循环体内的异步操作被挂起。
async function demo() {
  for (const url of urls) {
    const data = await fetch(url); // 每次迭代等待 fetch 完成,但迭代本身是同步推进的
  }
}
  • for await...of 会在每次迭代获取下一个值时自动等待(即等待 next() 的 Promise),循环体内部可以继续使用 await 做其他异步操作。

2.4 错误处理

  • 若 for...of 的迭代器抛出同步异常,会被 try...catch 捕获。
  • 若 for await...of 的迭代器 next() 返回的 Promise 被拒绝,或异步生成器内部抛出异常,该异常也会在循环内被捕获(因为循环本身会 await 该 Promise)。

3. 适用场景对比

✅ 使用 for...of 的场景

  • 遍历同步数据结构:数组、字符串、Map、Set、TypedArray、arguments、DOM 集合等。
  • 处理同步生成器函数返回的生成器对象。
  • 循环体需要执行异步操作,但获取下一个迭代值的时机不依赖异步结果

✅ 使用 for await...of 的场景

  • 遍历异步数据流

    • ReadableStream(浏览器流 API、Node.js 流通过 stream[Symbol.asyncIterator]
    • 异步生成器函数(async function*)返回的对象
    • 分页 API 数据,需要逐页等待获取下一页
  • 处理以 Promise 形式逐个提供值的数据源。

  • 需要顺序处理异步任务,且每个任务的触发依赖于上一个任务完成后的状态(例如数据库游标逐条读取)。


4. 底层协议深入

Symbol.iterator vs Symbol.asyncIterator

协议 方法名 next() 返回值 return() / throw() 返回值
同步迭代器 [Symbol.iterator] { value, done } { value, done } 或抛出同步异常
异步迭代器 [Symbol.asyncIterator] Promise<{ value, done }> Promise<{ value, done }>

for await...of 实际上在引擎内部会调用对象的 [Symbol.asyncIterator] 方法,若该方法不存在,则会回退到 [Symbol.iterator],并将同步返回的值包装成 Promise.resolve(value)。这也是为什么 for await...of 能遍历同步可迭代对象。


5. 示例对比

示例 1:遍历异步生成器

async function* generateNumbers() {
  yield 1;
  yield 2;
  yield 3;
}

// ❌ 错误:TypeError: generateNumbers() is not iterable
for (const num of generateNumbers()) { }

// ✅ 正确
for await (const num of generateNumbers()) {
  console.log(num); // 1, 2, 3
}

示例 2:遍历数组(对比异步等待行为)

const urls = ['/api/1', '/api/2', '/api/3'];

// 在 async 函数中用 for...of
async function fetchAll() {
  for (const url of urls) {
    const res = await fetch(url); // 循环等待每次 fetch 完成才进入下一次迭代
    console.log(await res.json());
  }
}

// 用 for await...of 遍历同一个数组(没有必要但合法)
async function fetchAll2() {
  for await (const url of urls) {
    const res = await fetch(url);
    console.log(await res.json());
  }
}
// 两者行为相同,但 fetchAll2 每次迭代会额外创建一个 Promise 包装 url 字符串

示例 3:读取流数据(Node.js 可读流)

import { createReadStream } from 'fs';

const stream = createReadStream('./file.txt', { encoding: 'utf8' });

for await (const chunk of stream) {
  console.log(chunk);
}

6. 性能与注意事项

  • 性能差异for await...of 遍历同步可迭代对象时,由于需要将每个值包装成 Promise 并在 microtask 中展开,会带来额外开销。不建议将 for await...of 用于纯同步数据的遍历。
  • 不能在普通对象上使用:两者都要求对象实现对应的迭代器协议,普通对象 {} 既没有 [Symbol.iterator] 也没有 [Symbol.asyncIterator],因此不能直接使用 for...of 或 for await...of
  • for await...of 与循环内 await 的关系:即使没有在循环体内显式写 awaitfor await...of 本身也会在每次迭代时对 next() 的结果进行 await,因此整体执行是异步的。这意味着循环后的代码会在循环完全结束后才执行。

7. 总结对照表

维度 for...of for await...of
迭代协议 Symbol.iterator Symbol.asyncIterator(优先)
处理异步值 不等待,直接得到 Promise 对象 等待 Promise 解析后得到值
遍历同步可迭代对象 ✅ 原生支持 ✅ 支持(有额外 Promise 包装)
遍历异步可迭代对象 ❌ 报错或得到 Promise 对象 ✅ 原生支持
主要用途 同步数据遍历 异步数据流、异步生成器遍历
循环体内 await 的影响 仅暂停循环体,不改变迭代推进时机 既等待迭代器 Promise,也可在循环体内 await
错误传播 同步异常直接抛出 异步拒绝被捕获为异常

一句话总结:
for...of 用于同步迭代值;for await...of 用于异步迭代值,它会在每次迭代时自动等待 Promise 解析,适合处理流式数据、异步生成器以及需要按顺序等待异步结果的场景。

Vue3 KeepAlive 深度揭秘:组件缓存的魔法是如何实现的?

Vue3 KeepAlive 深度揭秘:组件缓存的魔法是如何实现的?

本文将带你深入 Vue3 内核,从源码层面彻底搞懂 KeepAlive 组件的缓存机制、LRU 淘汰策略以及组件"失活"与"激活"的底层实现原理。

📋 文章导航


1. 为什么需要 KeepAlive?

1.1 实际业务场景

在开发后台管理系统或多标签页应用时,我们经常会遇到这样的需求:

  • 表单页面:用户填写了一半的表单,切换到其他页面查看资料,返回时期望表单数据还在
  • 列表页面:滚动到第 N 页,查看详情后返回,期望回到原来的滚动位置
  • 地图应用:地图已经缩放和平移到特定位置,切换页面后返回保持原状

1.2 没有 KeepAlive 的问题

<template>
  <button @click="currentView = 'A'">页面A</button>
  <button @click="currentView = 'B'">页面B</button>

  <!-- 普通动态组件切换 -->
  <component :is="currentView" />
</template>

<script setup>
import { ref } from "vue";
import ViewA from "./ViewA.vue";
import ViewB from "./ViewB.vue";

const currentView = ref("ViewA");
</script>

问题:当从 A 切换到 B 时,A 组件会被完全销毁(触发 onUnmounted),状态全部丢失。再切回 A 时,组件重新创建,所有数据重置。

1.3 KeepAlive 的解决方案

KeepAlive 通过组件级缓存完美解决这个问题:

  • 组件切换时不会销毁,而是进入"失活"状态
  • 组件实例、响应式数据、DOM 状态全部保留
  • 切换回来时"激活",瞬间恢复,无需重新渲染

2. KeepAlive 基础使用

2.1 基本用法

<template>
  <button
    v-for="tab in tabs"
    :key="tab"
    @click="currentTab = tab"
    :class="{ active: currentTab === tab }"
  >
    {{ tab }}
  </button>

  <!-- 使用 KeepAlive 包裹动态组件 -->
  <KeepAlive>
    <component :is="currentTab" />
  </KeepAlive>
</template>

<script setup>
import { ref } from "vue";
import Home from "./Home.vue";
import Posts from "./Posts.vue";
import Archive from "./Archive.vue";

const currentTab = ref("Home");
const tabs = ["Home", "Posts", "Archive"];
</script>

2.2 重要限制

⚠️ KeepAlive 只能缓存单个直接子节点

<!-- ❌ 错误:多个根节点 -->
<KeepAlive>
  <CompA />
  <CompB />
</KeepAlive>

<!-- ✅ 正确:使用动态组件包裹 -->
<KeepAlive>
  <component :is="activeComponent" />
</KeepAlive>

<!-- ✅ 正确:使用 v-if 切换单个组件 -->
<KeepAlive>
  <CompA v-if="showA" />
  <CompB v-else />
</KeepAlive>

3. 核心属性详解

3.1 属性一览表

属性 类型 说明
include string | RegExp | Array 只有名称匹配的组件会被缓存
exclude string | RegExp | Array 任何名称匹配的组件都不会被缓存
max number | string 最多可以缓存多少组件实例

3.2 include - 白名单缓存

<!-- 字符串形式(逗号分隔) -->
<KeepAlive include="Home,Posts">
  <component :is="currentTab" />
</KeepAlive>

<!-- 数组形式 -->
<KeepAlive :include="['Home', 'Posts']">
  <component :is="currentTab" />
</KeepAlive>

<!-- 正则表达式 -->
<KeepAlive :include="/^User/">
  <component :is="currentTab" />
</KeepAlive>

匹配规则:与组件的 name 选项进行匹配

<script>
export default {
  name: "Home", // 这个名字用于 include/exclude 匹配
  // ...
};
</script>

<!-- 或者使用 script setup -->
<script setup>
defineOptions({
  name: "Home",
});
</script>

3.3 exclude - 黑名单排除

<!-- 不缓存 Archive 组件 -->
<KeepAlive exclude="Archive">
  <component :is="currentTab" />
</KeepAlive>

<!-- 排除多个 -->
<KeepAlive :exclude="['Archive', 'Settings']">
  <component :is="currentTab" />
</KeepAlive>

3.4 max - LRU 缓存淘汰

<KeepAlive :max="5">
  <component :is="currentTab" />
</KeepAlive>

LRU (Least Recently Used) 算法

  1. 设置最大缓存数为 5
  2. 依次访问 A → B → C → D → E,全部缓存
  3. 访问 F 时,缓存已满,淘汰最久未使用的 A
  4. 访问 B,B 变为最近使用
  5. 访问 G,淘汰 C(现在 C 是最久未使用的)
缓存状态变化示意:

初始: []
访问A: [A]
访问B: [A, B]
访问C: [A, B, C]
访问D: [A, B, C, D]
访问E: [A, B, C, D, E]  ← 达到 max
访问F: [B, C, D, E, F]A 被淘汰
访问B: [C, D, E, F, B]B 移到最近使用
访问G: [D, E, F, B, G]C 被淘汰

4. 专属生命周期钩子

被 KeepAlive 缓存的组件会新增两个生命周期钩子:

4.1 生命周期对比

普通组件:          KeepAlive 缓存组件:
   onMounted           onMounted (首次)
       ↓                   ↓
   onUnmounted      onActivated (每次激活)
                          ↓
                     onDeactivated (失活)
                          ↓
                     onActivated (再次激活)
                          ↓
                     onDeactivated
                          ↓
                     onUnmounted (真正销毁时)

4.2 钩子函数详解

<script setup>
import { onMounted, onUnmounted, onActivated, onDeactivated } from "vue";

// 首次挂载时触发(仅一次)
onMounted(() => {
  console.log("组件首次挂载");
  // 适合执行一次性初始化:建立 WebSocket 连接、获取基础配置等
});

// 每次从缓存激活时触发
onActivated(() => {
  console.log("组件被激活");
  // 适合执行:恢复定时器、重新获取最新数据、恢复滚动位置等
});

// 组件被缓存时触发
onDeactivated(() => {
  console.log("组件被失活(进入缓存)");
  // 适合执行:暂停定时器、保存临时状态等
});

// 组件真正被销毁时触发(仅一次)
onUnmounted(() => {
  console.log("组件被销毁");
  // 清理工作:关闭 WebSocket、清除全局事件监听等
});
</script>

4.3 实际应用示例

<script setup>
import { ref, onActivated, onDeactivated } from "vue";

const scrollTop = ref(0);
const timer = ref(null);
const listData = ref([]);

// 激活时恢复状态
onActivated(() => {
  // 恢复滚动位置
  const container = document.querySelector(".list-container");
  if (container) {
    container.scrollTop = scrollTop.value;
  }

  // 重启定时刷新
  timer.value = setInterval(fetchLatestData, 5000);

  // 重新获取最新数据(可选)
  fetchLatestData();
});

// 失活时保存状态
onDeactivated(() => {
  // 保存滚动位置
  const container = document.querySelector(".list-container");
  if (container) {
    scrollTop.value = container.scrollTop;
  }

  // 暂停定时刷新
  if (timer.value) {
    clearInterval(timer.value);
    timer.value = null;
  }
});

async function fetchLatestData() {
  // 获取最新数据...
}
</script>

5. 底层实现原理

5.1 核心问题拆解

KeepAlive 要实现组件缓存,必须解决三个核心问题:

问题 解决方案
如何保存组件状态? 使用 Map 缓存组件的 VNode
如何识别缓存组件? 通过 shapeFlag 标记组件状态
如何让组件"隐藏"而不是销毁? 使用 move 函数将 DOM 移入隐藏容器

5.2 组件状态标记

Vue3 使用 shapeFlag 来标记 VNode 的类型和状态:

// 组件需要被缓存(进入缓存流程)
const COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8; // 256

// 组件已被缓存(从缓存恢复)
const COMPONENT_KEPT_ALIVE = 1 << 9; // 512

标记的作用

  1. COMPONENT_SHOULD_KEEP_ALIVE:告诉渲染器这个组件不应该被销毁,而是执行失活流程
  2. COMPONENT_KEPT_ALIVE:告诉渲染器这个组件来自缓存,不需要重新创建实例

5.3 缓存与隐藏机制

┌─────────────────────────────────────────────────────────────┐
│                      KeepAlive 组件                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   ┌─────────────────┐      ┌──────────────────────────┐    │
│   │   cache (Map)   │      │   storageContainer       │    │
│   │                 │      │   (隐藏的 div 容器)       │    │
│   │  key → VNode    │      │                          │    │
│   │  key → VNode    │      │  ┌──────────────────┐    │    │
│   │  key → VNode    │      │  │  被缓存的 DOM    │    │    │
│   │                 │      │  │  ┌──┐ ┌──┐ ┌──┐  │    │    │
│   └─────────────────┘      │  │  │A │ │B │ │C │  │    │    │
│                            │  │  └──┘ └──┘ └──┘  │    │    │
│   ┌─────────────────┐      │  └──────────────────┘    │    │
│   │   keys (Set)    │      │                          │    │
│   │                 │      └──────────────────────────┘    │
│   │  [A, B, C]      │                                      │
│   │  ↑  LRU 顺序    │                                      │
│   └─────────────────┘                                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

5.4 流程图解

首次渲染组件 A:
    │
    ▼
┌─────────────────┐
│  检查 cache     │
│  是否已有 A?   │
└────────┬────────┘
         │ 否
         ▼
┌─────────────────┐
│  正常创建组件 A  │
│  渲染 DOM        │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  存入 cache     │
│  A → VNode      │
│  keys.add(A)    │
└─────────────────┘

切换到组件 B:
    │
    ▼
┌─────────────────┐
│  组件 A 失活     │
│  (不是销毁!)    │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  调用 _deActivate│
│  将 A 的 DOM    │
│  移入隐藏容器    │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  渲染组件 B      │
└─────────────────┘

切回组件 A:
    │
    ▼
┌─────────────────┐
│  检查 cache     │
│  是否已有 A?   │
└────────┬────────┘
         │ 是
         ▼
┌─────────────────┐
│  命中缓存!      │
│  复用 VNode     │
│  复用组件实例    │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  调用 _activate │
│  将 A 的 DOM    │
│  从隐藏容器移出  │
│  插入页面        │
└─────────────────┘
         │
         ▼
┌─────────────────┐
│  触发 onActivated│
└─────────────────┘

6. 源码深度解析

6.1 完整源码注释版

// packages/runtime-core/src/components/KeepAlive.ts

import {
  type VNode,
  type ComponentInternalInstance,
  type SetupContext,
  type RendererInternals,
  type RendererElement,
  type RendererNode,
  ShapeFlags,
  currentInstance,
  unmountComponent,
  callWithAsyncErrorHandling,
  onBeforeUnmount,
  type Slots,
  type FunctionalComponent,
  type Component,
  type ComponentOptions,
  type VNodeNormalizedChildren,
  type VNodeChild,
  setTransitionHooks,
  type TransitionHooks,
} from "@vue/runtime-core";

export interface KeepAliveProps {
  include?: MatchPattern;
  exclude?: MatchPattern;
  max?: number | string;
}

type MatchPattern = string | RegExp | (string | RegExp)[];

export const KeepAliveImpl: ComponentOptions = {
  name: `KeepAlive`,

  // 标记这是一个 KeepAlive 组件
  __isKeepAlive: true,

  props: {
    include: [String, RegExp, Array] as PropType<MatchPattern>,
    exclude: [String, RegExp, Array] as PropType<MatchPattern>,
    max: [String, Number],
  },

  setup(props: KeepAliveProps, { slots }: SetupContext) {
    // ==================== 1. 获取组件实例和渲染器方法 ====================
    const instance = currentInstance!;

    // 从组件实例中获取渲染器注入的方法
    // move: 移动 DOM 节点
    // createElement: 创建 DOM 元素
    const { move, createElement } = instance.ctx.renderer as RendererInternals<
      RendererNode,
      RendererElement
    >;

    // ==================== 2. 创建存储容器 ====================
    // storageContainer 是一个普通的 div,用于存放被失活的组件 DOM
    const storageContainer = createElement("div");

    // ==================== 3. 定义激活/失活方法 ====================

    /**
     * 失活组件:将组件的 DOM 移动到隐藏容器
     * @param vnode 被失活的组件 VNode
     * @param container 当前容器(未使用,保持一致性)
     * @param anchor 锚点(未使用)
     */
    instance.ctx.deactivate = (vnode: VNode) => {
      move(vnode, storageContainer, null, MoveType.LEAVE);
    };

    /**
     * 激活组件:将组件的 DOM 从隐藏容器移回页面
     * @param vnode 被激活的组件 VNode
     * @param container 目标容器
     * @param anchor 锚点位置
     * @param isSVG 是否是 SVG
     * @param optimized 是否优化模式
     */
    instance.ctx.activate = (
      vnode: VNode,
      container: RendererElement,
      anchor: RendererNode | null,
      isSVG: boolean,
      optimized: boolean,
    ) => {
      const vnodeComponent = vnode.component!;

      // 将 DOM 移回页面
      move(vnode, container, anchor, MoveType.ENTER, isSVG);

      // 处理过渡动画
      if (vnodeComponent.da) {
        // 延迟激活(等待延迟显示动画完成)
        queuePostRenderEffect(() => {
          vnodeComponent.da!(vnodeComponent.vnode);
        }, instance.suspense);
      }
    };

    // ==================== 4. 缓存相关变量 ====================
    const cache: Map<string, VNode> = new Map(); // 缓存容器:key -> VNode
    const keys: Set<string> = new Set(); // 记录缓存顺序,用于 LRU
    let current: VNode | null = null; // 当前正在渲染的组件
    let pendingCacheKey: string | null = null; // 待缓存的 key

    // ==================== 5. 缓存清理函数 ====================

    /**
     * 根据 key 淘汰缓存条目
     * 当缓存超过 max 时,淘汰最久未使用的组件
     */
    function pruneCacheEntry(key: string) {
      const cached = cache.get(key);
      if (!cached) return;

      // 如果当前正在渲染的组件不是要淘汰的,触发 deactivated 钩子
      if (current !== cached) {
        const comp = cached.component!;
        if (!comp.isDeactivated) {
          // 调用 deactivated 生命周期钩子
          callWithAsyncErrorHandling(
            comp.type.deactivated,
            comp,
            ErrorCodes.COMPONENT_DEACTIVATED,
          );
          comp.isDeactivated = true;
        }
      }

      // 从缓存中移除
      cache.delete(key);
      keys.delete(key);
    }

    /**
     * 清空所有缓存
     */
    function pruneCache() {
      cache.forEach((cached, key) => {
        pruneCacheEntry(key);
      });
    }

    // ==================== 6. 监听 props 变化 ====================

    // 当 include/exclude 变化时,清理不再匹配的缓存
    watch(
      () => [props.include, props.exclude],
      ([include, exclude]) => {
        // 清理不再满足 include/exclude 条件的缓存
        cache.forEach((vnode, key) => {
          const name = getName(vnode);
          if (
            name &&
            (!include || !matches(include, name)) &&
            exclude &&
            matches(exclude, name)
          ) {
            pruneCacheEntry(key);
          }
        });
      },
      { flush: "post", deep: true },
    );

    // ==================== 7. 组件卸载时清理 ====================

    onBeforeUnmount(() => {
      cache.forEach((vnode) => {
        const { shapeFlag, component } = vnode;
        // 如果组件还在激活状态,需要手动卸载
        if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
          unmountComponent(component!);
        }
      });
    });

    // ==================== 8. 核心渲染逻辑 ====================

    return () => {
      // 获取默认插槽的第一个子节点
      const rawVNode = slots.default && slots.default();

      // 如果没有子节点,直接返回
      if (!rawVNode || rawVNode.length !== 1) {
        if (__DEV__ && rawVNode && rawVNode.length > 1) {
          warn(`KeepAlive should contain exactly one component child.`);
        }
        current = null;
        return rawVNode;
      }

      // 获取内部真实组件(处理 Teleport 等包裹情况)
      const vnode = getInnerChild(rawVNode[0]);
      const comp = vnode.type as Component;

      // 获取组件名称用于 include/exclude 匹配
      const name = getName(vnode);

      // 检查是否应该缓存
      const shouldCache = !(
        name &&
        ((props.include && !matches(props.include, name)) ||
          (props.exclude && matches(props.exclude, name)))
      );

      // 获取缓存 key
      const key = vnode.key == null ? comp : vnode.key;
      const cachedVNode = cache.get(key);

      // ==================== 8.1 命中缓存 ====================
      if (cachedVNode) {
        // 复用缓存的组件实例
        vnode.component = cachedVNode.component;
        vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE;

        // 更新 LRU 顺序:先删除再添加,确保在 Set 末尾(最近使用)
        keys.delete(key);
        keys.add(key);
      }
      // ==================== 8.2 未命中缓存 ====================
      else if (shouldCache) {
        // 存入新缓存
        cache.set(key, vnode);
        keys.add(key);

        // LRU 淘汰:如果超过 max,删除最久未使用的
        if (props.max && keys.size > parseInt(props.max as string, 10)) {
          pruneCacheEntry(keys.values().next().value);
        }
      }

      // 标记组件需要被缓存(影响卸载流程)
      vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE;
      current = vnode;

      return rawVNode;
    };
  },
};

// 辅助函数:获取组件名称
function getName(vnode: VNode): string | undefined {
  return (
    (vnode.type as ComponentOptions).name ||
    (vnode.type as ComponentOptions).__name ||
    (typeof vnode.type === "function" &&
      (vnode.type as FunctionalComponent).name)
  );
}

// 辅助函数:匹配模式
function matches(pattern: MatchPattern, name: string): boolean {
  if (isArray(pattern)) {
    return pattern.some((p) => matches(p, name));
  } else if (isString(pattern)) {
    return pattern.split(",").includes(name);
  } else if (isRegExp(pattern)) {
    return pattern.test(name);
  }
  return false;
}

6.2 关键逻辑解析

6.2.1 为什么使用 Map 和 Set?
const cache: Map<string, VNode> = new Map(); // 快速查找:O(1)
const keys: Set<string> = new Set(); // 保持插入顺序,支持 LRU
  • Map:提供 O(1) 的查找效率,适合频繁读取缓存
  • Set:保持插入顺序,且可以方便地获取"第一个"元素(最久未使用)
6.2.2 LRU 淘汰实现
// 更新 LRU 顺序
keys.delete(key); // 先删除旧位置
keys.add(key); // 再添加到末尾(最近使用)

// 淘汰最久未使用的
if (max && keys.size > max) {
  pruneCacheEntry(keys.values().next().value); // 获取并删除第一个
}
6.2.3 渲染器如何配合 KeepAlive?
// packages/runtime-core/src/renderer.ts

// 在组件卸载流程中
function unmountComponent(instance) {
  const { shapeFlag } = instance.vnode;

  // 检查是否是 KeepAlive 缓存的组件
  if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
    // 不销毁,而是调用 deactivate
    const { deactivate } = instance.parent?.ctx || {};
    if (deactivate) {
      deactivate(instance.vnode);
    }
    return;
  }

  // 普通组件:正常销毁流程
  // ...
}

// 在组件挂载流程中
function mountComponent(vnode, container, anchor) {
  // 检查是否来自缓存
  if (vnode.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
    // 复用已有实例,不需要重新创建
    const instance = vnode.component;

    // 调用 activate 将 DOM 移回页面
    const { activate } = instance.parent?.ctx || {};
    if (activate) {
      activate(vnode, container, anchor);
    }
    return;
  }

  // 普通组件:正常创建流程
  // ...
}

7. 实战应用场景

7.1 多标签页缓存

<template>
  <div class="tabs">
    <div
      v-for="tab in tabs"
      :key="tab.name"
      class="tab-item"
      :class="{ active: currentTab === tab.name }"
      @click="currentTab = tab.name"
    >
      {{ tab.label }}
      <span class="close" @click.stop="closeTab(tab.name)">×</span>
    </div>
  </div>

  <div class="tab-content">
    <KeepAlive :include="cachedTabs" :max="10">
      <component :is="currentTabComponent" :key="currentTab" />
    </KeepAlive>
  </div>
</template>

<script setup>
import { ref, computed, watch } from "vue";
import UserList from "./UserList.vue";
import OrderList from "./OrderList.vue";
import Settings from "./Settings.vue";

const tabs = ref([
  { name: "UserList", label: "用户管理", component: UserList },
  { name: "OrderList", label: "订单管理", component: OrderList },
  { name: "Settings", label: "系统设置", component: Settings },
]);

const currentTab = ref("UserList");
const cachedTabs = ref(["UserList", "OrderList"]); // 只缓存特定标签

const currentTabComponent = computed(() => {
  const tab = tabs.value.find((t) => t.name === currentTab.value);
  return tab?.component;
});

function closeTab(tabName) {
  // 关闭标签时从缓存列表移除
  const index = cachedTabs.value.indexOf(tabName);
  if (index > -1) {
    cachedTabs.value.splice(index, 1);
  }
  // 切换到其他标签...
}
</script>

7.2 表单数据保持

<template>
  <KeepAlive :include="['UserForm']">
    <UserForm v-if="showForm" @submit="handleSubmit" />
    <UserDetail v-else :user="currentUser" @edit="showForm = true" />
  </KeepAlive>
</template>

<script setup>
import { ref } from "vue";
import UserForm from "./UserForm.vue";
import UserDetail from "./UserDetail.vue";

const showForm = ref(true);
const currentUser = ref(null);

function handleSubmit(userData) {
  // 提交表单后切换到详情页
  currentUser.value = userData;
  showForm.value = false;
}
</script>

7.3 列表页状态保持

<!-- ListPage.vue -->
<template>
  <div class="list-page">
    <!-- 搜索条件 -->
    <SearchForm v-model="searchParams" @search="handleSearch" />

    <!-- 列表 -->
    <div class="list-container" ref="listRef">
      <div
        v-for="item in listData"
        :key="item.id"
        class="list-item"
        @click="goToDetail(item)"
      >
        {{ item.name }}
      </div>
    </div>

    <!-- 分页 -->
    <Pagination v-model:page="page" v-model:size="pageSize" :total="total" />
  </div>
</template>

<script setup>
import { ref, onActivated, onDeactivated } from "vue";
import { useRouter } from "vue-router";

const router = useRouter();
const listRef = ref(null);

// 状态数据
const searchParams = ref({});
const listData = ref([]);
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
const scrollTop = ref(0);

// 激活时恢复状态
onActivated(() => {
  // 恢复滚动位置
  if (listRef.value) {
    listRef.value.scrollTop = scrollTop.value;
  }

  // 可选:刷新数据(如果需要保持最新)
  // fetchData()
});

// 失活时保存状态
onDeactivated(() => {
  if (listRef.value) {
    scrollTop.value = listRef.value.scrollTop;
  }
});

function goToDetail(item) {
  router.push(`/detail/${item.id}`);
}

async function handleSearch() {
  // 搜索逻辑...
}
</script>

8. 性能优化建议

8.1 合理设置 max

<!-- ❌ 不设置 max,可能无限增长导致内存泄漏 -->
<KeepAlive>
  <component :is="currentTab" />
</KeepAlive>

<!-- ✅ 根据业务场景设置合理的 max -->
<KeepAlive :max="5">
  <component :is="currentTab" />
</KeepAlive>

8.2 使用 include/exclude 精确控制

<!-- 只缓存必要的组件,减少内存占用 -->
<KeepAlive :include="['UserList', 'OrderList']" :max="5">
  <component :is="currentTab" />
</KeepAlive>

8.3 避免缓存大型组件

<script setup>
// 对于包含大量数据或复杂图表的组件,考虑不缓存
defineOptions({
  name: "HeavyDataChart", // 在 exclude 中排除
});
</script>

8.4 及时清理缓存

<script setup>
import { ref, nextTick } from "vue";

const includeList = ref(["TabA", "TabB", "TabC"]);
const currentTab = ref("TabA");
const keepAliveRef = ref(null);

// 方法1:通过修改 include 排除特定组件
function clearCache(componentName) {
  const index = includeList.value.indexOf(componentName);
  if (index > -1) {
    includeList.value.splice(index, 1);
  }
}

// 方法2:使用 v-if 强制重新创建 KeepAlive(清空所有缓存)
async function clearAllCache() {
  keepAliveRef.value = false;
  await nextTick();
  keepAliveRef.value = true;
}
</script>

<template>
  <KeepAlive v-if="keepAliveRef" :include="includeList">
    <component :is="currentTab" />
  </KeepAlive>
</template>

9. 常见问题与避坑指南

9.1 组件 name 未设置导致缓存失效

<script setup>
// ❌ 错误:没有设置 name,include/exclude 无法匹配
// 组件会被缓存,但无法通过 include/exclude 控制

// ✅ 正确:显式设置 name
defineOptions({
  name: "MyComponent",
});
</script>

9.2 动态组件 key 问题

<template>
  <!-- ❌ 错误:key 变化会导致缓存失效 -->
  <KeepAlive>
    <component :is="currentTab" :key="Date.now()" />
  </KeepAlive>

  <!-- ✅ 正确:使用稳定的 key 或组件名作为 key -->
  <KeepAlive>
    <component :is="currentTab" :key="currentTab" />
  </KeepAlive>
</template>

9.3 异步组件的缓存

<script setup>
import { defineAsyncComponent } from "vue";

const AsyncComp = defineAsyncComponent(() => import("./AsyncComp.vue"));
</script>

<template>
  <!-- ✅ 异步组件也可以被缓存 -->
  <KeepAlive>
    <AsyncComp />
  </KeepAlive>
</template>

9.4 与 Transition 一起使用

<template>
  <!-- ✅ KeepAlive 应该包裹在 Transition 内部 -->
  <Transition name="fade" mode="out-in">
    <KeepAlive>
      <component :is="currentTab" />
    </KeepAlive>
  </Transition>

  <!-- ❌ 不要这样:KeepAlive 包裹 Transition -->
</template>

9.5 缓存后数据不更新问题

<script setup>
import { onActivated, ref } from "vue";

const data = ref([]);

// ✅ 在 onActivated 中刷新数据
onActivated(() => {
  // 组件从缓存激活时,重新获取最新数据
  fetchLatestData();
});

// 或者使用 watch 监听路由参数变化
import { watch } from "vue";
import { useRoute } from "vue-router";

const route = useRoute();

watch(
  () => route.params.id,
  (newId) => {
    if (newId) {
      fetchData(newId);
    }
  },
  { immediate: true },
);
</script>

10. 总结与思考

10.1 核心要点回顾

要点 说明
缓存机制 使用 Map 存储 VNode,Set 管理 LRU 顺序
状态标记 COMPONENT_SHOULD_KEEP_ALIVECOMPONENT_KEPT_ALIVE shapeFlag
隐藏实现 通过 move 函数将 DOM 移入隐藏的 div 容器
生命周期 onActivated / onDeactivated 用于状态恢复和保存
淘汰策略 LRU 算法,当缓存超过 max 时淘汰最久未使用的组件

10.2 设计思想

KeepAlive 的设计体现了 Vue3 的几个重要思想:

  1. 声明式编程:开发者只需声明要缓存的组件,无需关心实现细节
  2. 可组合性:与动态组件、Transition、异步组件无缝配合
  3. 性能优先:LRU 策略防止内存无限增长,DOM 移动而非重建保证性能
  4. 扩展性:通过 include / exclude 提供精细的控制能力

10.3 思考题

  1. 为什么 KeepAlive 使用 DOM 移动而不是 display: none

    • 提示:考虑 CSS 样式继承、布局计算、内存占用等因素
  2. 如何实现一个自定义的缓存策略(如 FIFO)?

    • 提示:研究 KeepAlive 的源码结构,尝试扩展
  3. KeepAlive 与 Pinia/Vuex 状态管理如何配合?

    • 思考:什么时候用 KeepAlive 缓存状态,什么时候用全局状态管理?
  4. 在 SSR 场景下,KeepAlive 会有什么问题?

    • 提示:服务端没有 DOM,组件如何"失活"?

📚 扩展阅读

  1. Vue3 官方文档 - KeepAlive
  2. Vue3 源码解读 - KeepAlive 实现
  3. LRU 缓存算法详解
  4. Vue3 渲染器原理

💡 如果本文对你有帮助,欢迎点赞、收藏、转发!有任何问题可以在评论区留言讨论。

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

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

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

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

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

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

这是 Promise 链式调用:

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

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


2. 和 then / catch 的区别

最核心区别:

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

示例:

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

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

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

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

你的业务要求是:

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

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

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

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

执行过程:

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

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

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

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

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

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

你这段逻辑也可以写成:

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

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

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

  • 看语义选方法:

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

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

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

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

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

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

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

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


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

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

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

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


Step 1:跑通第一个请求

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

安装依赖

npm install @anthropic-ai/sdk

最简实现

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

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

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

  return response.content[0].text
}

接口层

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

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

调用测试

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

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

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


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

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

完整参数结构

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

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

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

model:怎么选?

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

system:幕后规则

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

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

temperature:创意 vs 精确

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

max_tokens:输出上限

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

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

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

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

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

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

改进后的标题生成

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

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

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

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

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

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

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

实现

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

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

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

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

    const reply = response.content[0].text

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

    return reply
  }

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

对话效果

const chat = new BlogAIChat()

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

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

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

注意:历史越长越贵

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

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

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

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

为什么需要流式

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

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

后端:用 SSE 推给前端

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

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

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

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

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

前端:接收并实时展示

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

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

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

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

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

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

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

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

问题根源

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

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

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

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

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

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

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

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

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

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

原理

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

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

建库阶段(一次性)

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

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

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

查询阶段(每次对话)

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

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

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

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

${context}

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

  return response.content[0].text
}

两种方式怎么选

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

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


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

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

RAG vs Tool Use 的本质区别

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

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

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

是,也不是。

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

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

完整流程(来回两次)

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

Step 6.1:定义工具

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

整体架构图

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

落地路径(从今天开始)

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

FAQ

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

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


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

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

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


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

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


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

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

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

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

控制成本的方法:

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

Q:RAG 和 Fine-tuning 怎么选?

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

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

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

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

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

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

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

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


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

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

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

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

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

前言

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

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

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

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

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

二、微前端三大核心问题

微前端要解决三个问题:

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

三、常见实现方式

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

import { registerMicroApps, start } from 'qiankun';

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

子应用(React)改造

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

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

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

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

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

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

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

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

qiankun提供了两种沙箱:

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

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

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

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

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

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

八、常见坑点与建议

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

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

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

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

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

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

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

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

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


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

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

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


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

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

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

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

核心区别

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

代码示例

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

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

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

为什么要加 BigInt?

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

精度丢失问题

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

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

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

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

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

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

1.2 变量声明:var /let/const

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

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

代码示例

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

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

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

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

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

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

代码示例

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

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

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

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

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

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

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

箭头函数 vs 普通函数

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

代码示例

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

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

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

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

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

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

1.7 闭包(核心概念)

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

代码示例

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

1.8 原型与原型链

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

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

代码示例

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

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

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

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

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

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

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

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

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

代码示例

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

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

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

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

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

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

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

2.2 forEach 中使用 await 失效

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

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

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

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

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

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

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

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

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

2.4 this 指向丢失

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

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

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

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

2.5 数组空位导致方法异常

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

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

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

const arr = [1, undefined, 3];

2.6 闭包导致内存泄漏

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

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

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

fn = null; // 释放内存

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

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

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

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

2.8 函数默认参数踩坑

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

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

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

3.1 数据类型相关(必考)

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

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

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

Symbol 特点

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

BigInt 特点

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

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

答案

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

手写 instanceof 原理

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

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

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

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

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

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

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

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

3.3 作用域与作用域链

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

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

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

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

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

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

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

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

3.4 闭包(核心难点)

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

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

应用场景

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

优缺点

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

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

答案

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

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

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

答案

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

3.5 原型基础

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

答案

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

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

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

手写 ES6 class 继承

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

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

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

3.6 原型链深入

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

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

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

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

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

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

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

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

3.7 Promise(核心)

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

答案

  1. 三种状态

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

  3. then 执行机制

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

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

答案

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

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

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

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

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

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

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

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

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

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

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

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

3.8 事件循环(Event Loop)

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

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

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

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

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

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

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

四、总结

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

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

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

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

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

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

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

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

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


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

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

1.1 核心 API

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

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

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

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

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

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

1.2 基础使用示例

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

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

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

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

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

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

1.3 样式定制

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

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

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

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

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

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

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

1.4 表单集成

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

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

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

1.5 closedby 属性(现代浏览器)

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

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

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

1.6 无障碍支持

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

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

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

1.7 实际应用场景

场景 1:图片预览灯箱

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

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

场景 2:确认删除弹窗

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

1.8 常见坑点

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

1.9 兼容性

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

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

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

2.1 基础用法

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

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

2.2 支持的 input 类型

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

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

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

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

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

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

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

2.3 高级用法:动态数据

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

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

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

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

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

2.5 实际应用场景

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

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

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

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

场景 2:URL 快速输入

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

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

2.6 与 <select> 的区别

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

2.7 常见坑点

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

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

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

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

2.8 兼容性

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

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

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

3.1 核心属性

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

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

3.2 颜色区间逻辑

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

3.3 基础示例

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

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

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

3.4 样式定制(有限支持)

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

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

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

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

3.5 实际应用场景

场景 1:库存预警系统

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

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

场景 2:文本相似度对比

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

3.6 与 <progress> 的核心区别

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

3.7 常见坑点

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

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

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

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

3.8 兼容性

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

四、<progress>:任务进度条

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

4.1 核心属性

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

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

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

4.2 确定 vs 不确定状态

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

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

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

4.3 基础示例

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

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

4.4 动态更新示例

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

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

4.5 样式定制

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

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

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

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

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

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

4.6 实际应用场景

场景 1:多文件队列上传

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

场景 2:页面加载进度

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

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

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

4.7 常见坑点

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

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

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

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

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

4.8 兼容性

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

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

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

何时用原生?

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

何时用库?

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

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

兼容性速查表

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

降级策略

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

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

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

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

特性检测推荐

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

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

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

结语

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

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


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

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

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

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


引言

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

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

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

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


AI 应用的五层演进

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

每一层对前端的架构影响

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

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


Agent 编排模式

模式一:串行(Pipeline)

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

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

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

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

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

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

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

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

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

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

模式三:路由(Router)

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

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

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

模式四:监督者(Supervisor)

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

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

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


前端架构:多 Agent 状态管理

状态模型

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

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

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

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

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

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

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

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

SSE 协议设计

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

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

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

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

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

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

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

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

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

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

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

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

Thinking UI:展示 Agent 的思考过程

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

设计原则

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

UI 方案

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

关键组件

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

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

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

冲突处理

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

场景

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

策略

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

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

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

  return resolution
}

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


性能考量

渲染优化

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

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

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

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

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

WebSocket vs SSE

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

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

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

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

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

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

后端编排(Python 伪代码)

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

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

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

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

前端核心布局

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

总结

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

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


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

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

前言

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

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

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

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

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

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

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

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

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

父进程代码 (parent.js)

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

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

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

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

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

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

子进程代码 (child.js)

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

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

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

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

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

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

3.2 子进程如何接收参数?

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

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

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

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

3.3 为什么是 slice(2)?

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

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

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

3.4 args 与 send() 的本质区别

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

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

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

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

javascript

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

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

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

父进程 (main.js)

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

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

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

子进程 (compute.js)

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

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

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

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

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

五、高级特性一览

5.1 传递 Socket / 服务器句柄

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

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

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

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

5.2 环境变量传递

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

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

5.3 独立日志输出

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

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

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

六、常见陷阱与解决方案

6.1 资源开销巨大

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

解决方案

  • 使用进程池限制并发数(通常设为 CPU 核心数)
  • 任务完成后及时 child.kill()
const os = require('os');
const maxWorkers = os.cpus().length;
let activeWorkers = 0;

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

6.2 "MODULE_NOT_FOUND" 错误

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

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

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

6.3 TypeScript / ESM 项目中的坑

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

解决方案

  • 使用 tsx 或 ts-node 的 --transpile-only 模式
  • 在 fork 时过滤掉 TypeScript 相关的 execArgv
const execArgv = process.execArgv.filter(arg => !arg.includes('ts-node'));
fork('./child.ts', [], { execArgv });

6.4 僵尸进程问题

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

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

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

6.5 调试困难

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

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

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

七、生产环境最佳实践

7.1 限制并发数(进程池)

根据 CPU 核心数动态控制:

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

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

7.2 优雅退出

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

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

7.3 错误处理三板斧

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

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

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

7.4 结构化消息

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

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

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

八、总结

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

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

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


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

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

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

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

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

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

描述

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

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

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

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

端口

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

输入端口

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

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

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

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

输出端口

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

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

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

生成的代码示例

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

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

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

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

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

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

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

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

实际应用案例

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

边缘检测与轮廓渲染

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

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

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

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

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

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

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

纹理细节增强

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

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

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

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

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

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

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

法线贴图与凹凸映射

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

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

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

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

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

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

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

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

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

屏幕空间特效

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

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

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

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

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

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

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

性能考虑与最佳实践

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

性能特征分析

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

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

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

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

优化策略

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

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

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

精度与质量的平衡:

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

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

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

常见问题与解决方案

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

导数计算不一致问题:

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

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

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

屏幕边缘异常值:

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

调试与验证

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

可视化偏导数结果:

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

比较分析与参考实现:

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

性能分析与优化验证:

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

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

AI 打字跟随优化

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

重排

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

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

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

哪些操作会导致重排?

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

    • scrollTop / scrollHeight

    • clientTop / clientWidth

    • getComputedStyle()

    • getBoundingClientRect()

IntersectionObserver 哨兵模式

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

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

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

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

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

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

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

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

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

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

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

前置约定

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

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

编译对照:Vue defineOptions → React

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

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

Vue 代码

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

VuReact 编译后 React 代码

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

export default MyComponent

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


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

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

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

Vue 代码

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

VuReact 编译后 React 代码

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

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

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


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

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

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

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

核心总结

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

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

相关资源


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

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

大家好,我是石小石~


解锁明基RD270Q的新玩法

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

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

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

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

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

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

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

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

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

对应核心代码如下:

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

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

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

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

使用Clade code+skills控制显示屏

编程模式切换效果演示

编程模式是明基 RD 系列显示器的特色功能,在深色模式下,显示器会通过硬件级算法强化语法高亮效果,以提升长期编程的舒适度;RD270Q新增的彩纸模式,则能让界面产生类纸感的细腻色彩,满足深度护眼需求。如下图,在黑暗模式下,明基对代码的显示优化非常明显,代码对比更加鲜明,不刺眼。

它还搭载了莱茵认证的抗反射抗面板,即便在强光环境下使用,屏幕也不会刺眼、不产生明显眩光,长时间观看依旧舒适。

在配套软件的基础上,我们能否借助 AI 实现这些显示模式的一键自动切换呢?答案是完全可以。 比如,直接通过 AI 对话下达指令,让显示器自动切换至电子书模式

或是通过指令让 AI 精准调节屏幕亮度、音频大小等参数

原理分析——RD270Q-Opera-skills

Claude Code 为例,我们来实现这一效果。需要明确的是:AI 本身并不能直接操控显示器硬件,即便它能生成脚本,也不知道如何与显示器交互。因此,我们可以通过自定义技能(Skills) —— 比如创建一个 RD270Q-operation-skills,来为 AI 扩展控制显示器的能力。

如果你不了解 Skills,请自行百度。

该技能的项目结构如下:

RD270Q-operation-skills/
├── SKILL.md              # 元数据与指令定义
├── index.js              # 主入口:命令解析与分发
├── package.json          # 项目依赖配置
├── test.js               # 功能测试脚本
├── scripts/              # 底层操作模块
│   ├── keyboard.js       # 键盘快捷键封装
│   └── mouse.js          # 鼠标操作封装
└── references/           # 参考文档
    └── 快捷键表.md        # Display Pilot 2 完整快捷键

整个技能的核心逻辑非常简单: 将 Display Pilot 2 的快捷键功能在代码中做映射,让 AI 可以通过函数调用触发。

示例核心代码(scripts/keyboard.js):

// 键盘快捷键模块 - 封装 Display Pilot 2 所有控制功能
const { keyboard, Key } = require("@computer-use/nut-js");

// 执行快捷键组合
async function executeShortcut(...keys) {
  await keyboard.pressKey(...keys);
  await new Promise(resolve => setTimeout(resolve, 100));
  await keyboard.releaseKey(...keys);
}

// ==================== 色彩模式 ====================
// 循环切换色彩模式 Ctrl+Alt+C
async function cycleColorModes() {
  await executeShortcut(Key.LeftControl, Key.LeftAlt, Key.C);
}
// 编程亮模式 Ctrl+Alt+1
async function setCodingLight() {
  await executeShortcut(Key.LeftControl, Key.LeftAlt, Key.Num1);
}
// 编程暗模式 Ctrl+Alt+2
async function setCodingDark() {
  await executeShortcut(Key.LeftControl, Key.LeftAlt, Key.Num2);
}
// 编程纸张模式 Ctrl+Alt+0
async function setCodingPaper() {
  await executeShortcut(Key.LeftControl, Key.LeftAlt, Key.Num0);
}
// ..... 其他快捷操作


// 导出所有方法
module.exports = {
  executeShortcut,
  cycleColorModes,
  setCodingLight,
  setCodingDark,
  setMBook,
  // ...
};

我们只需要在 SKILL.md 中规范好 AI 的调用方式与指令规则,完成整套技能开发后,Claude Code 就拥有了直接操控显示器模式的能力,使用体验直接拉满。

除了编程模式的切换,凡是 Display Pilot 2 能通过快捷键实现的显示器操控功能,这个skills都能完美胜任,甚至像Display Pilot 2屏幕分区这样的高级功能,也能通过控制鼠标来模拟实现。

使用手机远程控制显示屏

很多时候,我们可能临时有事需要离开工位,如果我们突然想锁屏或者想远程控制一下鼠标执行某个简单操作就必须立刻回到工位才行。基于这中场景,实现手机远程控制显示器就非常有意义。

如下图,就是根据明基RD270Q支持的快捷键开发的一个移动端操作界面,并增加了鼠标触摸移动控制功能。

远程锁屏、鼠标控制演示

如果外出忘记锁屏,通过手机实现这个功能非常方便实用。

此外,通过移动端界面的触控区域,我们还能远程操控鼠标移动、直接打开 VSCode 等软件。是不是有点Todesk青春版的感觉?

除此之外,其他快捷操作,如编程模式、亮度调节、夜间保护调节等功能都是支持的,这里也就不一一展示了。

原理分析——websoket+node控制快捷键

远程控制的方案其实非常简单:核心就是跑在本地的一个 Node 脚本,用来模拟键盘、鼠标操作,间接通过 Display Pilot 2 控制显示器。同时启动一个 Web 服务提供移动端操作界面,借助 WebSocket 实现手机与 Node 服务实时通信,最终完成远程控制。简单涞水,就是Web 端通过WebSocket 控制本地端Node服务模拟系统快捷键操作

前端就是一个普通的 Vue 项目 , 页面上放几个控制按钮,点击时通过 WebSocket 向 Node 服务发送对应指令:

function createWebSocketServer(server) {
  const wss = new WebSocket.Server({ server, path: "/ws" });
  wss.on("connection", (ws) => {
    console.log("移动端已连接");
    ws.on("message", async (msg) => {
        const { type, action, params } = JSON.parse(msg);
        // 鼠标操作
        if (type === "mouse") {
          if (action === "move") {
            // 鼠标移动
            await mouse.move(params.x, params.y);
          } else if (action === "click") {
            // 鼠标点击
            await mouse.click(params.button);
          }
        }
        // 键盘操作
        if (type === "keyboard"){
          
        }
    });
  });
}

Node 端主要搭建 WebSocket 服务,接收移动端指令并执行系统操作。

const app = express();
const server = http.createServer(app);

// 初始化 WebSocket 服务
createWebSocketServer(server);

server.listen(PORT, () => {
  console.log(`WS 服务已启动:ws://localhost:${PORT}/ws`);
});;

具体的鼠标移动、键盘快捷键等逻辑,统一封装在 mouse.jskeyboard.js 中,底层依赖node第三方库nut-js实现鼠标和快捷键控制。

使用手势控制显示屏

RD270Q 还有个我觉得特别实用的功能 ——Visual Optimizer 视觉优化。它通过内置光传感器,能根据环境光智能同步调节屏幕亮度与色温,降低屏幕与环境的明暗反差,配合编码深色模式,长时间看代码也更柔和护眼。

不仅如此,我们还可以通过Display Pilot 2进一步调整屏幕亮度,实现个性化需求。基于Display Pilot 2,我们还能实现通过手势控制实现显示器的隔空操作,作为技术创意尝鲜、趣味交互玩具,还是得研究和尝试的。

桌面版的手势识别存在一定技术难度,恰好之前我有写过类似的技术文章:油猴+手势识别:我实现了任意网页隔空控制!索性偷个懒,在网页上实现手势识别用来控制显示器。先看看Demo效果:

  • 左手张开 + 右手滑动,即可调低屏幕亮度(左手握拳 + 右手滑动,即可调高屏幕亮度)

  • 右手握拳,可以实现一键锁屏功能

它的核心实现是基于MediaPipe,这是一个是谷歌开源的跨平台、实时轻量级多媒体机器学习框架,支持 Python、JS 等多种编程语言,借助它能轻松实现桌面级的手势识别功能。

如果你对相关技术感兴趣,可以看看这个实现

Demo:油猴+手势识别:我实现了任意网页隔空控制!

代码:《有趣的手势识别、人脸识别脚本》

Flow 智能工作流

本来我还在琢磨,能不能通过 AI 指令或远程控制,自己搭一套编码时的专属显示方案,比如打开 VS Code 就自动切换到我习惯的亮度、护眼参数等。结果发现 RD270Q 早已自带了 Flow 智能工作流,在 Display Pilot 2 里提前预设好编程、文档、设计等场景后,打开对应软件就能自动切换显示参数,省去反复调节的麻烦,真正实现了 “打开即用” 的智能个性化体验。

结语

从借助 AI 指令、移动端远程控制显示器,到创意十足的手势隔空控制,这篇文章我通过三种个性化玩法,把RD270Q显示器的自定义操控能力发挥到了极致。这些功能实现的核心,离不开Display Pilot 2对显示器本身的 稳定操控能力。

当然,即便不借助这款软件,文中的思路也可以延伸到电脑本身的快捷操作、系统级功能调用上,大家不妨顺着这个方向自行尝试拓展。

写完这篇文章已是凌晨,144Hz 高刷屏搭配显示器的深色编码模式,长时间使用眼部依然舒适,没有出现干涩、疲劳感。实际体验下来,RD270Q 的护眼技术确实做得不错,整体感受很好。

总而言之,新款 RD270Q 不仅保留了核心优势,价格也很有诚意,三千出头,上市期间会更优惠!兄弟们,不用犹豫,这次可以放心冲了。当然,要是追求极致编程体验 RD280URD280UGRD320U也也都是非常不错的选择。

最后, 附上一张深夜codding的图,希望这篇分享能为大家带来一些实用参考。

微前端入门:qiankun + Vue 3 + Vite 从0搭建第一个微前端应用

前言

随着业务规模不断扩大,前端应用体积越来越大,单一巨石应用的开发维护成本越来越高:

  • 多人协作开发,代码冲突频繁
  • 整体编译打包时间越来越长
  • 技术栈陈旧,无法引入新技术
  • 发布一个小改动需要整个应用重新上线

微前端架构应运而生,通过将巨石应用拆分为多个独立可交付的微应用,实现团队自治、独立开发、独立部署,从架构层面解决这些问题。

目前社区中比较成熟的微前端方案里,qiankun 是蚂蚁集团开源的企业级方案,经过大量业务验证,生态成熟,api 友好,是落地微前端最稳妥的选择。

本文带你一步步从 0 搭建第一个 qiankun 微前端应用,基于最新的 Vue 3 + Vite 技术栈,解决了网上大部分教程配置错误的问题。读完本文你就能跑通一个可工作的微前端应用。

技术选型

层级 选型 理由
微前端框架 qiankun 成熟稳定,社区案例多,坑少
前端框架 Vue 3 主流稳定,性能优秀,生态完善
构建工具 Vite 开发启动快,热更新体验好,是当前主流趋势
路由 Vue Router 4 官方标准,配合 qiankun 路由联动方案成熟

整体架构

graph TD
    A[主应用/MainApp] --> B[微应用1/App1]
    A --> C[微应用2/App2]
    style A fill:#f9f,stroke:#333,stroke-width:2px
    style B fill:#bbf,stroke:#333,stroke-width:1px
    style C fill:#bbf,stroke:#333,stroke-width:1px
  • 主应用(基座): 负责微应用注册与生命周期管理、全局导航布局、公共依赖加载。
  • 微应用: 各个业务模块独立开发、独立运行、独立部署。
  • 最终效果:用户在浏览器中切换不同业务模块,感觉就像在同一个应用里,实际上每个模块都是独立的。

项目初始化

我们需要创建三个项目:一个主应用,两个微应用。

# 创建主应用
npm create vite@latest main-app -- --template vue

# 创建微应用一
npm create vite@latest micro-app-vue1 -- --template vue

# 创建微应用二
npm create vite@latest micro-app-vue2 -- --template vue

最终目录结构:

micro-front/
├── main-app/          # 主应用(基座)
├── micro-app-vue1/    # 微应用1
├── micro-app-vue2/    # 微应用2
└── README.md

本文档对应的完整代码已经开源在 github.com/wenbiyou/mi…,你可以直接克隆运行。

第一步:主应用配置 qiankun

安装依赖

cd main-app
npm install qiankun vue-router@4

qiankun 注册与启动

修改 src/main.js

import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { registerMicroApps, start } from 'qiankun'
import App from './App.vue'

const app = createApp(App)
const router = createRouter({
  history: createWebHistory('/'),
  routes: [
    { path: '/', redirect: '/app1' },
    { path: '/:pathMatch(.*)*', component: () => import('./views/NotFound.vue') }
  ]
})

app.use(router)
app.mount('#app')

// 微应用配置
const microApps = [
  {
    name: 'micro-app-vue1',
    entry: '//localhost:7100', // 开发环境入口
    activeRule: '/app1', // 激活规则:路径以 /app1 开头时激活
    container: '#micro-container' // 挂载容器
  },
  {
    name: 'micro-app-vue2',
    entry: '//localhost:7101',
    activeRule: '/app2',
    container: '#micro-container'
  }
]

// 注册微应用
registerMicroApps(microApps, {
  beforeLoad: [app => console.log('before load', app.name)],
  beforeMount: [app => console.log('before mount', app.name)],
  afterMount: [app => console.log('after mount', app.name)],
  afterUnmount: [app => console.log('after unmount', app.name)]
})

// 启动 qiankun
start({
  sandbox: {
    strictStyleIsolation: false,
    experimentalStyleIsolation: true // 开启实验性样式隔离,兼容性更佳
  },
  prefetch: 'all' // 预加载微应用静态资源
})

添加主应用布局

修改 src/App.vue

<template>
  <div id="main-app">
    <header class="main-header">
      <div class="logo">
        <h1>微前端主应用</h1>
      </div>
      <nav class="nav">
        <router-link to="/app1">应用一</router-link>
        <router-link to="/app2">应用二</router-link>
      </nav>
    </header>
    <!-- qiankun 挂载容器 -->
    <div id="micro-container" class="micro-container"></div>
  </div>
</template>

<style scoped>
.main-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0 20px;
  background: #2c3e50;
  color: white;
}
.nav {
  display: flex;
  gap: 20px;
}
.nav a {
  color: white;
  text-decoration: none;
}
.nav a.router-link-exact-active {
  color: #42b983;
  font-weight: bold;
}
.micro-container {
  padding: 20px;
}
</style>

Vite 配置

修改 vite.config.js

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    port: 7099, // 主应用端口,避免和微应用冲突
    cors: true // 开启跨域,允许加载微应用资源
  }
})

主应用配置完成!接下来配置微应用。

第二步:微应用适配 qiankun

micro-app-vue1 为例,micro-app-vue2 只需要修改端口和名称即可。

安装依赖

cd micro-app-vue1
npm install vite-plugin-qiankun

修改入口文件适配生命周期

修改 src/main.js

import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { renderWithQiankun, qiankunWindow } from 'vite-plugin-qiankun/es/helper'
import App from './App.vue'
import './style.css'

let app = null

function render(props = {}) {
  const { container } = props
  const mountNode = container ? container.querySelector('#app') : '#app'

  // 每次渲染新建 router,避免状态污染
  const router = createRouter({
    history: createWebHistory(qiankunWindow.__POWERED_BY_QIANKUN__ ? '/app1' : '/'),
    routes: [
      { path: '/', component: () => import('./views/Home.vue') },
      { path: '/about', component: () => import('./views/About.vue') }
    ]
  })

  app = createApp(App)
  app.use(router)
  app.mount(mountNode)
}

// 使用 renderWithQiankun 包裹生命周期
renderWithQiankun({
  bootstrap() {
    console.log('[micro-app-vue1] bootstrap')
  },
  mount(props) {
    console.log('[micro-app-vue1] mount', props)
    render(props)
  },
  unmount() {
    console.log('[micro-app-vue1] unmount')
    app?.unmount()
    app = null
  }
})

// 独立运行
if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
  render()
}

关键 Vite 配置

修改 vite.config.js

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import qiankun from 'vite-plugin-qiankun'

export default defineConfig({
  base: '/app1/', //  开发环境基路径,生产环境请根据部署路径动态设置
  plugins: [
    vue(),
    qiankun('micro-app-vue1', {
      useDevMode: true // 开发模式必须开启
    })
  ],
  server: {
    port: 7100, // 微应用端口
    cors: true, // 必须开启跨域,主应用才能访问
    headers: {
      'Access-Control-Allow-Origin': '*'
    },
    origin: 'http://localhost:7100' // 必须配置完整的 origin,qiankun 才能正确获取资源
  },
  build: {
    // 生产构建必须配置 UMD 格式供 qiankun 加载
    lib: {
      entry: './src/main.js',
      name: 'micro-app-vue1',
      formats: ['umd'],
      fileName: () => 'index.js'
    }
  }
})

关键提示: 这是网上大多数教程配置错误的地方!

  1. server.origin 必须是完整 URL,否则 qiankun 无法正确加载微应用资源
  2. base 必须和 activeRule 保持一致
  3. 生产构建必须配置 build.lib 输出 UMD 格式

第二个微应用 micro-app-vue2 配置类似,只需要修改:

  • 端口改为 7101
  • base 改为 /app2/
  • name 改为 micro-app-vue2

本地运行验证

我们需要三个终端分别启动:

# 终端一:主应用
cd main-app && npm install && npm run dev
# 访问 http://localhost:7099

# 终端二:微应用一
cd micro-app-vue1 && npm install && npm run dev
# 监听 http://localhost:7100

# 终端三:微应用二
cd micro-app-vue2 && npm install && npm run dev
# 监听 http://localhost:7101

打开浏览器访问 http://localhost:7099,你应该能看到:

  1. 主应用头部导航显示
  2. 微应用一正常加载3.点击导航切换到微应用二,微应用二正常加载
  3. 切换回微应用一,微应用一再次正常加载

如果看到这个效果,恭喜你!你的第一个 qiankun 微前端应用已经跑通了!

FAQ

Q: 按照步骤配置后,微应用还是加载不出来,怎么办?

A: 先检查这几点:

  1. micro-app-vue1vite.config.jsserver.origin 是否配置了完整 URL (http://localhost:7100)
  2. 端口是否被占用,三个服务都正常启动了吗?
  3. 浏览器控制台有没有报错?常见报错我们在第三篇文章会详细讲解。

Q: 微应用可以独立运行吗?

A: 可以!直接访问 http://localhost:7100 就能独立运行微应用一,这是最佳实践——每个微应用必须能够独立运行,方便开发调试。

Q: 为什么每次 mount 都要重新创建路由?

A: 为了避免状态污染。如果路由只创建一次重复使用,上次的状态会残留到下次挂载,重新创建可以保证每次挂载都是干净的状态。

Q: 生产环境部署和开发环境有什么不同?

A: 打包优化、部署方案、常见坑点我们放在第三篇文章详细讲解。第二篇我们先把核心概念讲清楚。

本章小结

你已经完成了:

  1. 创建了主应用和两个微应用的项目结构
  2. 主应用注册并启动了 qiankun
  3. 微应用适配了 qiankun 生命周期
  4. 配置了正确的 Vite 配置(解决了大多数教程的错误)
  5. 本地运行验证可以正常切换

下一篇我们深入讲解 qiankun 核心概念:路由联动、样式隔离、跨应用通信

一文讲清楚 npm 包里的 `dependencies` 和 `devDependencies`

刚接触 npm 包管理时,很多人都会被两个字段绕住:

  • dependencies
  • devDependencies

表面看只差了一个 dev,但实际背后是两种完全不同的角色。

很多人会先形成一个直觉:

devDependencies 是开发时依赖,dependencies 是运行时依赖。

这个理解方向没错,但如果只停在这里,实际写项目时还是容易分错。

这篇文章就把这个问题彻底掰开讲明白。


一、先看结论

最简单的判断标准是这一句:

包在真正运行时还需要它,就放 dependencies
只在开发、测试、构建、打包阶段需要它,就放 devDependencies

换句话说:

dependencies

表示运行时依赖

也就是:

  • 项目启动时需要
  • 代码执行时需要
  • 用户真正使用功能时需要

devDependencies

表示开发时依赖

也就是:

  • 本地开发时需要
  • 测试时需要
  • 构建时需要
  • 打包发布时需要
  • 代码检查时需要

二、为什么这个问题总让人混淆

因为“开发时会用到”这句话,范围太大了。

你写代码的时候,当然什么都在开发时用到了:

  • 你会用 axios
  • 你会用 lodash
  • 你会用 typescript
  • 你会用 eslint

但它们的性质并不一样。

这里真正该问的,不是:

我开发时有没有用到它?

而是:

我把包发布出去后,别人安装并运行这个包时,还需不需要它?

这才是判断关键。

三、先用一个生活化的比喻理解

可以把开发 npm 包想成开一家小餐馆。

dependencies 是什么

像端上桌的食材:

  • 调料

没有这些,顾客吃不到东西。

devDependencies 是什么

像后厨工具:

  • 菜刀
  • 烤箱
  • 清洁工具

它们对做菜很重要,但顾客不需要把这些工具一起买走。

所以:

  • 跟着“成品”一起发挥作用的,是 dependencies
  • 只在“制作过程”中发挥作用的,是 devDependencies

四、最常见的理解方式

很多文章会这样解释:

dependencies

项目运行时要用的依赖

devDependencies

开发这个项目时要用的依赖

这句话没错,但还不够完整。

更准确一点,应该改成:

dependencies

项目在实际运行时必须存在的依赖

devDependencies

项目在开发、测试、构建、打包、发布时使用的依赖

注意这里多出来几个关键词:

  • 测试
  • 构建
  • 打包
  • 发布

这几个词很重要,因为很多初学者只理解了“开发”,没理解“构建”。

五、什么叫“构建时需要”?

很多人第一次看到这句话会疑惑:

TypeScript、Babel 明明不参与业务运行,为什么它们很重要?

原因很简单:

你平时写的源码,不一定是最终发布给别人运行的代码。

比如你写的是:

const add = (a: number, b: number) => a + b
export default add

这里有 TypeScript 类型标注,运行环境并不能直接拿这些类型来执行。

所以发布前,通常会经过一轮处理,变成:

const add = (a, b) => a + b
export default add

或者再进一步转成更兼容旧环境的版本。

这个“把源码处理成可发布产物”的过程,就是构建或编译。

所以:

  • typescript
  • babel
  • rollup
  • webpack
  • vite

这些通常属于 devDependencies

因为它们负责的是“做菜过程”,不是“上桌后的食物”。

六、最容易分清的一种办法

每次遇到一个依赖,不妨问自己一句:

如果把项目构建完、发布出去,用户真正使用功能时,还要不要这个依赖?

如果要

放进 dependencies

如果不要,只是帮助你开发和打包

放进 devDependencies

这个方法很稳。

七、通过例子理解

例子 1:工具函数库里用了 dayjs

import dayjs from 'dayjs'

export function formatDate(date) {
  return dayjs(date).format('YYYY-MM-DD')
}

这里 dayjs 应该放哪?

答案是:dependencies

因为你的函数真正运行时,需要调用 dayjs

不是你开发时用了它一次就结束了,而是你包的使用者在调用 formatDate 时,底层还要依赖 dayjs

例子 2:用了 eslint 做代码检查

开发时你装了:

npm install eslint -D

它是用来做什么的?

  • 检查代码规范
  • 提示潜在问题
  • 统一团队风格

项目真正运行时,需要 eslint 吗?

不需要。

所以它应该放进 devDependencies

例子 3:用了 jestvitest 做测试

测试工具只在测试阶段执行。

用户安装你的包,并不会因为要调用某个功能而去运行 jest

所以这类依赖一般都属于 devDependencies

例子 4:用了 typescript 写源码

你源码可能是 .ts 文件,但最终发布的包往往是已经编译好的 .js 文件。

也就是说,用户使用你包时,用到的是产物,不是你本地的 TypeScript 编译器。

所以 typescript 一般属于 devDependencies

例子 5:项目里使用 axios 发请求

如果你的业务代码里直接这样写:

import axios from 'axios'

export function getUser() {
  return axios.get('/api/user')
}

axios 是实际运行逻辑的一部分。

因此它通常属于 dependencies

八、为什么很多人会把依赖放错

常见原因主要有三个。

1)只从“我开发时有没有用过”来判断

这会导致判断范围过大。

因为你开发时什么都用到了,但不是所有东西都会进入运行阶段。


2)没有区分“源码”和“发布产物”

这是个很常见的坑。

很多工具只作用在源码阶段,比如:

  • 类型检查
  • 代码转换
  • 打包压缩
  • 生成声明文件

这些工具非常重要,但它们的重要性停留在“生产过程”,不是“运行过程”。

3)把“业务依赖”误认为“开发依赖”

比如平时有的产品业务里明确用到了:

  • axios
  • lodash
  • dayjs

这些都不应该因为“开发时也在用”就被扔进 devDependencies

它们是运行逻辑本身的一部分。

九、从包作者和包使用者两个视角看,会更清楚

这个问题最容易绕,是因为视角没切换。

站在包作者视角

你会觉得:

  • 我开发时用了 typescript
  • 我开发时也用了 axios
  • 我开发时也用了 eslint

感觉它们都是“开发中会用到的东西”。

没错,但这不是 npm 关心的重点。

站在包使用者视角

npm 更关心的是:

  • 用户安装你的包后,哪些依赖必须存在,代码才能跑
  • 哪些依赖只是你内部研发流程要用

这样一看,边界就清楚了:

  • 运行必须的 → dependencies
  • 研发辅助的 → devDependencies

十、一个最小的项目例子

假设你写了一个日期格式化工具包。

目录可能是这样:

src/
  index.ts
package.json
tsconfig.json

源码:

import dayjs from 'dayjs'

export function formatDate(date: string): string {
  return dayjs(date).format('YYYY-MM-DD')
}

你在开发时可能安装了这些包:

  • dayjs
  • typescript
  • rollup
  • vitest
  • eslint

那该怎么分?

放到 dependencies

  • dayjs

因为真正运行 formatDate 时要用它。

放到 devDependencies

  • typescript
  • rollup
  • vitest
  • eslint

因为它们只是帮助你:

  • 写 TypeScript
  • 打包代码
  • 测试功能
  • 检查规范

用户运行 formatDate 本身,不需要这些工具参与。

十一、一个典型的 package.json 示例

{
  "name": "demo-utils",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "dependencies": {
    "dayjs": "^1.11.0"
  },
  "devDependencies": {
    "typescript": "^5.0.0",
    "rollup": "^4.0.0",
    "eslint": "^9.0.0",
    "vitest": "^1.0.0"
  }
}

看到这个配置,可以这样理解:

  • dayjs 是成品运行时要吃的“食材”
  • typescriptrollupeslintvitest 是制作和检查过程中的“工具”

十二、再说几个常见包,方便形成直觉

常见的 dependencies

这类包通常会直接出现在业务代码执行路径里:

  • axios
  • lodash
  • dayjs
  • uuid
  • react(很多应用项目里是这样)
  • vue(很多应用项目里是这样)

常见的 devDependencies

这类包通常服务于开发流程:

  • typescript
  • eslint
  • prettier
  • jest
  • vitest
  • webpack
  • vite
  • rollup
  • babel
  • sass(很多情况下)
  • 各类构建插件、测试插件、lint 插件

十三、一个很实用的口诀

可以记一句很接地气的话:

跟着产物跑的,放 dependencies
只陪你开发的,放 devDependencies

或者再换一种说法:

用户运行时要用的,是 dependencies
你写代码时要用的工具,是 devDependencies

十四、容易踩坑的地方

坑 1:把运行依赖误放进 devDependencies

结果可能是:

  • 本地开发一切正常
  • 发布后别人安装使用时报错
  • 因为真正运行时缺依赖

这就像你做了一碗面,结果把“面”放进了工具箱,而不是放进食材清单里。

坑 2:把开发工具误放进 dependencies

结果通常不会立刻炸,但会带来一些问题:

  • 安装体积变大
  • 用户下载了不必要的包
  • 依赖树更复杂
  • 包管理更混乱

这就像你卖一碗面,还强行把菜刀、案板、烤箱一起塞给顾客。

十五、最后总结

把这件事说到最本质,其实就一句话:

dependencies 是项目运行时真正要依赖的库;
devDependencies 是项目开发、测试、构建、打包时使用的工具依赖。

判断时别问:

我开发时有没有用到它?

而要问:

用户真正运行这份代码时,还需不需要它?

需要,就是 dependencies
不需要,就是 devDependencies

十六、结尾

刚学 npm 时,dependenciesdevDependencies 看起来只是两个字段的区别;
但理解透了之后,你会发现它本质上是在帮你区分:

  • 哪些是“产品的一部分”
  • 哪些是“生产产品的工具”

这个边界一旦建立起来,很多工程化问题都会顺。

因为写代码这件事,说到底也很像开店:

食材要分清,工具也要分清。
不然厨房会乱,顾客也吃不好。


如果喜欢这篇文章,请给我点个赞:)

祝大家:码上有钱 --Larry

Zustand 完全指南:从入门到实战

Zustand 完全指南:从入门到实战

一、为什么需要 Zustand?

1.1 状态管理的痛点

在 React 开发中,我们经常会遇到这样的场景:

// 组件 A
const [count, setCount] = useState(0);

// 组件 B 也需要使用 count
// 组件 C 也需要修改 count

传统的解决方案:

  • Props Drilling:一层层传递 props,代码冗长难维护
  • Context API:需要创建 Provider,包裹组件树,样板代码多
  • Redux:功能强大但配置复杂,需要定义 action、reducer 等

1.2 Zustand 的优势

Zustand(德语,意为"状态")是一个轻量级的状态管理库,它的核心理念是:简单即美

核心优势

  • 🎯 极简 API:只需几行代码就能创建 store
  • 📦 零依赖:不依赖任何框架,可用于 React、Vue 等
  • 🚀 性能优秀:基于订阅机制,组件只在自己需要的状态变化时重新渲染
  • 🔧 TypeScript 友好:完整的类型推断支持
  • 💾 内置持久化:通过中间件轻松实现数据持久化

二、Zustand 核心概念

2.1 基本结构

Zustand 的核心就是 create 函数,它接收一个函数并返回一个 hook:

import { create } from 'zustand';

const useStore = create((set) => ({
  // 状态
  count: 0,
  
  // 修改状态的方法
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

2.2 使用方式

在组件中使用 store:

function Counter() {
  // 从 store 中获取状态和方法
  const { count, increment } = useStore();
  
  return (
    <div>
      <p>计数:{count}</p>
      <button onClick={increment}>增加</button>
    </div>
  );
}

三、实战项目解析

让我们通过一个完整的 zustand-demo 项目,深入理解 Zustand 的实际应用。

3.1 项目结构

zustand-demo/
├── src/
│   ├── store/
│   │   ├── counter.ts    # 计数器 store
│   │   ├── todo.ts       # 待办事项 store
│   │   └── user.ts       # 用户状态 store
│   ├── type/
│   │   └── index.ts      # TypeScript 类型定义
│   └── App.tsx           # 主组件

3.2 类型定义(type/index.ts)

首先定义数据结构,这是 TypeScript 项目的最佳实践:

export interface Todo {
    id: number;
    text: string;
    completed: boolean;
}

export interface User {
    id: number;
    username: string;
    avatar?: string;
}

技术要点

  • 使用 interface 定义对象结构
  • avatar?: string 表示可选属性
  • 为每个实体定义清晰的类型

3.3 计数器 Store(store/counter.ts)

这是最简单的状态管理示例:

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface CounterState {
    count: number;
    increment: () => void;
    decrement: () => void;
    reset: () => void;
}

const useCounterStore = create<CounterState>()(
    persist(
        (set) => ({
            count: 0,
            increment: () => set((state) => ({ count: state.count + 1 })),
            decrement: () => set((state) => ({ count: state.count - 1 })),
            reset: () => set({ count: 0 }),
        }),
        { name: 'counter' }
    )
);

export default useCounterStore;

代码解析

  1. 状态接口定义

    interface CounterState {
        count: number;           // 状态
        increment: () => void;   // 方法
        decrement: () => void;
        reset: () => void;
    }
    
  2. create 函数

    • create<CounterState>():创建类型化的 store
    • 返回一个 hook:useCounterStore
  3. persist 中间件

    • 自动将状态持久化到 localStorage
    • { name: 'counter' }:存储的键名
    • 刷新页面后状态不会丢失
  4. set 函数

    • 直接修改:set({ count: 0 })
    • 基于当前状态修改:set((state) => ({ count: state.count + 1 }))

3.4 待办事项 Store(store/todo.ts)

更复杂的状态管理示例:

import { create } from 'zustand';
import type { Todo } from '../type/index';
import { persist } from 'zustand/middleware';

export interface TodoState {
    todos: Todo[];
    addTodo: (text: string) => void;
    toggleTodo: (id: number) => void;
    removeTodo: (id: number) => void;
}

const useTodoStore = create<TodoState>()(
    persist(
        (set, get) => ({
            todos: [],
            addTodo: (text: string) =>
                set((state) => ({
                    todos: [...state.todos, {
                        id: Date.now(),
                        text,
                        completed: false,
                    }]
                })),
            toggleTodo: (id: number) => 
                set((state) => ({
                    todos: state.todos.map((todo) => 
                        todo.id === id ? 
                        { ...todo, completed: !todo.completed } 
                        : todo
                    )    
                })),
            removeTodo: (id: number) => 
                set((state) => ({
                    todos: state.todos.filter(todo => todo.id !== id)
                })),
        }),
        { name: 'todo' }
    )
);

export default useTodoStore;

技术要点

  1. 数组状态管理

    • 使用不可变数据模式
    • 添加:[...state.todos, newTodo]
    • 更新:state.todos.map(...)
    • 删除:state.todos.filter(...)
  2. get 函数的使用

    • set:修改状态
    • get:获取当前状态(不触发重新渲染)
    • 示例:console.log(get().todos)
  3. ID 生成策略

    • 使用 Date.now() 生成唯一 ID
    • 简单场景适用,生产环境建议使用 UUID

3.5 用户状态 Store(store/user.ts)

登录状态管理示例:

import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { User } from '../type/index';

interface UserState {
    isLogin: boolean;
    login: (user: { username: string; password: string }) => void;
    logout: () => void;
    user: User | null;
}

export const useUserStore = create<UserState>()(
    persist(
        (set) => ({
            isLogin: false,
            login: (user: User) => set({ isLogin: true, user: user }),
            logout: () => set({ isLogin: false, user: null }),
            user: null,
        }),
        { name: 'user' }
    )
);

设计思路

  1. 布尔状态isLogin 表示登录状态
  2. 对象状态user 存储用户信息,初始为 null
  3. 登录方法:接收用户信息,更新状态
  4. 登出方法:重置为初始状态

3.6 主组件(App.tsx)

整合所有 store:

import { useState } from 'react';
import useCounterStore from './store/counter';
import useTodoStore from './store/todo';

function App() {
  const [inputValue, setInputValue] = useState<string>('');

  // 使用计数器 store
  const { count, increment, decrement, reset } = useCounterStore();
  
  // 使用待办事项 store
  const { todos, addTodo, toggleTodo, removeTodo } = useTodoStore();
  
  const handleAdd = () => {
    if (inputValue.trim() === '') return;
    addTodo(inputValue);
    setInputValue('');
  };

  return (
    <>
      {/* 计数器部分 */}
      <button onClick={increment}>Count = {count}</button>
      <button onClick={reset}>reset</button>
      <button onClick={decrement}>-1</button>

      {/* 待办事项部分 */}
      <section>
        <h2>Todos ({todos.length})</h2>
        <input 
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
        />
        <button onClick={handleAdd}>Add</button>
        
        <ul>
          {todos.map(todo => (
            <li key={todo.id}>
              <input 
                checked={todo.completed}
                onChange={() => toggleTodo(todo.id)}
              />
              <span style={{
                textDecoration: todo.completed ? 'line-through' : 'none'
              }}>
                {todo.text}
              </span>
              <button onClick={() => removeTodo(todo.id)}>删除</button>
            </li>
          ))}
        </ul>
      </section>
    </>
  );
}

export default App;

使用要点

  1. 解构获取:从 store hook 中解构需要的状态和方法
  2. 直接使用:获取的方法和状态可以直接使用
  3. 自动更新:状态变化时,组件自动重新渲染
  4. 本地状态结合:可以与 useState 等本地状态结合使用

四、Zustand 进阶技巧

4.1 选择器优化

只订阅需要的状态,避免不必要的重新渲染:

// 只订阅 count,其他状态变化不会触发重新渲染
const count = useCounterStore((state) => state.count);

// 只订阅 todos 数组
const todos = useTodoStore((state) => state.todos);

4.2 跨 Store 调用

在一个 store 中使用另一个 store 的数据:

addTodo: (text: string) => {
    const userId = useUserStore.getState().user?.id;
    set((state) => ({
        todos: [...state.todos, {
            id: Date.now(),
            text,
            userId, // 关联用户 ID
            completed: false,
        }]
    }));
}

4.3 批量更新

一次性更新多个状态:

const updateProfile = (newData: Partial<User>) => {
    set((state) => ({
        user: { ...state.user, ...newData },
        isLogin: true,
    }));
};

4.4 异步操作

处理异步请求:

fetchTodos: async () => {
    const response = await fetch('/api/todos');
    const data = await response.json();
    set({ todos: data });
}

五、最佳实践

5.1 类型安全

始终使用 TypeScript 定义状态接口:

interface TodoState {
    todos: Todo[];
    addTodo: (text: string) => void;
    // ...
}

5.2 单一职责

每个 store 只负责一个功能模块:

// ✅ 好的设计
useCounterStore    // 计数器
useTodoStore       // 待办事项
useUserStore       // 用户状态

// ❌ 不好的设计
useAppStore        // 包含所有状态

5.3 持久化策略

只对需要持久化的数据使用 persist 中间件:

// 需要持久化
useUserStore       // 登录状态
useTodoStore       // 待办事项

// 不需要持久化
useCounterStore    // 临时计数

5.4 性能优化

使用选择器避免不必要的重新渲染:

// ✅ 优化后
const todos = useTodoStore((state) => state.todos);

// ❌ 未优化
const { todos } = useTodoStore();

六、总结

6.1 Zustand 的核心优势

  1. 简单:API 简洁,学习成本低
  2. 灵活:可用于各种规模的项目
  3. 高效:基于订阅机制,性能优秀
  4. 类型安全:完整的 TypeScript 支持

6.2 适用场景

  • 中小型项目的首选状态管理方案
  • 需要快速原型开发的项目
  • 对 bundle size 敏感的项目
  • 需要持久化的场景

6.3 学习路线

  1. 基础:掌握 createset 的使用
  2. 进阶:学习中间件(persist、devtools 等)
  3. 优化:理解选择器和性能优化
  4. 实战:在实际项目中应用和总结

Zustand 以其简洁的 API 和强大的功能,正在成为 React 状态管理的新宠。通过本指南的学习,相信你已经掌握了 Zustand 的核心概念和实战技巧。现在,开始在你的项目中使用 Zustand,体验简单高效的状

❌