普通视图

发现新文章,点击刷新页面。
今天 — 2025年11月21日掘金 前端

Next.js第九章(AI)

作者 小满zs
2025年11月21日 21:24

AI

Vercel提供了AI SDK,可以让我们在Next.js中轻松集成AI功能。AI SDK 官网

安装AI-SDK

npm i ai @ai-sdk/deepseek @ai-sdk/react

这儿我们使用deepseek作为AI模型,@ai-sdk/react封装了流式输出和上下文管理hook,可以让我们在Next.js中轻松集成AI功能。如果你要安装其他模型,只需要将deepseek替换为其他模型即可。

例如:安装openai模型

npm i ai @ai-sdk/openai @ai-sdk/react

为什么使用deepseek模型?因为deepseek比较便宜,充10元可以测试很久(非广告)。

获取deepSeek API Key

image.png

image.png

然后把生成的API Key复制一下保存起来。

编写API接口

src/app/api/chat/route.ts

import { NextRequest } from "next/server";
import { streamText,convertToModelMessages } from 'ai'
import { createDeepSeek } from "@ai-sdk/deepseek";
import { DEEPSEEK_API_KEY } from "./key";
const deepSeek = createDeepSeek({
    apiKey: DEEPSEEK_API_KEY, //设置API密钥
});
export async function POST(req: NextRequest) {
    const { messages } = await req.json(); //获取请求体
    //这里为什么接受messages 因为我们使用前端的useChat 他会自动注入这个参数,所有可以直接读取
    const result = streamText({
        model: deepSeek('deepseek-chat'), //使用deepseek-chat模型
        messages:convertToModelMessages(messages), //转换为模型消息
        //前端传过来的额messages不符合sdk格式所以需要convertToModelMessages转换一下
        //转换之后的格式:
        //[
            //{ role: 'user', content: [ [Object] ] },
            //{ role: 'assistant', content: [ [Object] ] },
            //{ role: 'user', content: [ [Object] ] },
            //{ role: 'assistant', content: [ [Object] ] },
            //{ role: 'user', content: [ [Object] ] },
            //{ role: 'assistant', content: [ [Object] ] },
            //{ role: 'user', content: [ [Object] ] }
        //]
        system: '你是一个高级程序员,请根据用户的问题给出回答', //系统提示词
    });
   
    return result.toUIMessageStreamResponse() //返回流式响应
}

src/app/page.tsx

我们在前端使用useChat组件来实现AI对话,这个组件内部封装了流式响应,默认会向/api/chat发送请求。

  • messages: 消息列表,包含用户和AI的对话内容
  • sendMessage: 发送消息的函数,参数为消息内容
  • onFinish: 消息发送完成后回调函数,可以在这里进行一些操作,例如清空输入框

messages:数据结构解析

[
    {
        "parts": [
            {
                "type": "text", //文本类型
                "text": "你知道 api router 吗"
            }
        ],
        "id": "FPHwY1udRrkEoYgR", //消息ID
        "role": "user" //用户角色
    },
    {
        "id": "qno6vcWcwFM4Yc8J", //消息ID
        "role": "assistant", //AI角色
        "parts": [
            {
                "type": "step-start" //步骤开始 
            },
            {
                "type": "text", //文本类型
                "text": "是的,我知道 **API Router**。", //文本内容
                "state": "done" //步骤完成
            }
        ]
    }
]
'use client';
import { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { useChat } from '@ai-sdk/react';

export default function HomePage() {
    const [input, setInput] = useState(''); //输入框的值
    const messagesEndRef = useRef<HTMLDivElement>(null); //获取消息结束的ref
    //useChat 内部封装了流式响应 默认会向/api/chat 发送请求
    const { messages, sendMessage } = useChat({
        onFinish: () => {
            setInput('');
        }
    });

    // 自动滚动到底部
    useEffect(() => {
        messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
    }, [messages]);
    //回车发送消息
    const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
        if (e.key === 'Enter' && !e.shiftKey) {
            e.preventDefault();
            if (input.trim()) {
                sendMessage({ text: input });
            }
        }
    };

    return (
        <div className='flex flex-col h-screen bg-linear-to-br from-blue-50 via-white to-purple-50'>
            {/* 头部标题 */}
            <div className='bg-white/80 backdrop-blur-sm shadow-sm border-b border-gray-200'>
                <div className='max-w-4xl mx-auto px-6 py-4'>
                    <h1 className='text-2xl font-bold bg-linear-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent'>
                        AI 智能助手
                    </h1>
                    <p className='text-sm text-gray-500 mt-1'>随时为您解答问题</p>
                </div>
            </div>

            {/* 消息区域 */}
            <div className='flex-1 overflow-y-auto px-4 py-6'>
                <div className='max-w-4xl mx-auto space-y-4'>
                    {messages.length === 0 ? (
                        <div className='flex flex-col items-center justify-center h-full text-center py-20'>
                            <div className='bg-linear-to-br from-blue-100 to-purple-100 rounded-full p-6 mb-4'>
                                <svg className='w-12 h-12 text-blue-600' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
                                    <path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z' />
                                </svg>
                            </div>
                            <h2 className='text-xl font-semibold text-gray-700 mb-2'>开始对话</h2>
                            <p className='text-gray-500'>输入您的问题,我会尽力帮助您</p>
                        </div>
                    ) : (
                        messages.map((message) => (
                            <div
                                key={message.id}
                                className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'} animate-in fade-in slide-in-from-bottom-4 duration-500`}
                            >
                                <div className={`flex gap-3 max-w-[80%] ${message.role === 'user' ? 'flex-row-reverse' : 'flex-row'}`}>
                                    {/* 头像 */}
                                    <div className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-white font-semibold ${
                                        message.role === 'user' 
                                            ? 'bg-linear-to-br from-blue-500 to-blue-600' 
                                            : 'bg-linear-to-br from-purple-500 to-purple-600'
                                    }`}>
                                        {message.role === 'user' ? '你' : 'AI'}
                                    </div>
                                    
                                    {/* 消息内容 */}
                                    <div className={`flex flex-col ${message.role === 'user' ? 'items-end' : 'items-start'}`}>
                                        <div className={`rounded-2xl px-4 py-3 shadow-sm ${
                                            message.role === 'user'
                                                ? 'bg-linear-to-br from-blue-500 to-blue-600 text-white'
                                                : 'bg-white border border-gray-200 text-gray-800'
                                        }`}>
                                            {message.parts.map((part, index) => {
                                                switch (part.type) {
                                                    case 'text':
                                                        return (
                                                            <div key={message.id + index} className='whitespace-pre-wrap wrap-break-word'>
                                                                {part.text}
                                                            </div>
                                                        );
                                                }
                                            })}
                                        </div>
                                    </div>
                                </div>
                            </div>
                        ))
                    )}
                    <div ref={messagesEndRef} />
                </div>
            </div>

            {/* 输入区域 */}
            <div className='bg-white/80 backdrop-blur-sm border-t border-gray-200 shadow-lg'>
                <div className='max-w-4xl mx-auto px-4 py-4'>
                    <div className='flex gap-3 items-end'>
                        <div className='flex-1 relative'>
                            <Textarea
                                value={input}
                                onChange={(e) => setInput(e.target.value)}
                                onKeyDown={handleKeyDown}
                                placeholder='请输入你的问题... (按 Enter 发送,Shift + Enter 换行)'
                                className='min-h-[60px] max-h-[200px] resize-none rounded-xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 transition-all shadow-sm'
                            />
                        </div>
                        <Button
                            onClick={() => {
                                if (input.trim()) {
                                    sendMessage({ text: input });
                                }
                            }}
                            disabled={!input.trim()}
                            className='h-[60px] px-6 rounded-xl bg-linear-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 transition-all shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed'
                        >
                            <svg className='w-5 h-5' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
                                <path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M12 19l9 2-9-18-9 18 9-2zm0 0v-8' />
                            </svg>
                        </Button>
                    </div>
                </div>
            </div>
        </div>
    );
}

5.gif

钉钉小程序直传文件到 阿里云OSS

作者 广白
2025年11月21日 18:13

举个🌰,用钉钉小程序将一段视频在 前端 直接传到阿里云上。

由于 js-base64 这个npm包在钉钉小程序使用不了,而目前钉钉小程序又没有base64ToArrayBuffer这类方法,故只能曲线救国,使用 dd.writeFiledd.readFile来做base64的转换。

首先,我们得先处理OSS配置,具体如下:

import crypto from "crypto-js"

const OSSConfig = {
  AccessKeyId: "xxxx",
  AccessKeySecret: "xxxxxx",
  Host: "xxxxx", // 具体某个账号下,
  SecurityToken: "xx", // 可选,使用STS签名时必传。
}

// 计算签名。
const computeSignature = (accessKeySecret, canonicalString) => {
  return crypto.enc.Base64.stringify(
    crypto.HmacSHA1(canonicalString, accessKeySecret)
  )
}

let ossData = null
const getOssConfig = () => {
  const date = new Date()
  date.setHours(date.getHours() + 24) //加 1个小时
  const policyText = {
    expiration: date.toISOString(), // 设置policy过期时间。
    conditions: [
      // 限制上传大小。
      ["content-length-range", 0, 1024 * 1024 * 1024],
    ],
  }
  let fileManager = dd.getFileSystemManager()
  fileManager.writeFile({
    filePath: `${dd.env.USER_DATA_PATH}/test.txt`,
    data: JSON.stringify(policyText),
    success: () => {
      fileManager.readFile({
        filePath: `${dd.env.USER_DATA_PATH}/test.txt`,
        encoding: "base64",
        success: (res) => {
          console.log(res.data, "readFile")
          const signature = computeSignature(
            OSSConfig.AccessKeySecret,
            res.data
          )
          ossData = {
            OSSAccessKeyId: OSSConfig.AccessKeyId,
            signature,
            policy: res.data,
            // "x-oss-security-token": OSSConfig.SecurityToken,
          }
        },
        fail: (err) => {
          console.log(err)
        },
      })
    },
    fail: (err) => {
      console.log(err)
    },
  })
}

这个ossData就是我们处理好后的配置项,接下来将其填充到 dd.uploadFile 里就大功告成了!

dd.chooseVideo({
        sourceType: ["album", "camera"],
        maxDuration: 60,
        success: (res) => {
          let uniqueId = `${dirPath}xcx-${new Date().getTime()}.mp4` // dirPath为存储的文件夹路径, 比如"dev/front-end/video/"
          uni.showLoading({
            title: "视频上传中...",
          })
          dd.uploadFile({
            url: OSSConfig.Host,
            fileType: "video",
            fileName: "file",
            formData: {
              ...ossData, // 上述getOssConfig方法得到的结果
              key: uniqueId, // 该值为你存在在oss上的位置  后面上传成功之后拼接得到链接需要使用
              success_action_status: "200", // 默认上传成功状态码为204,此处被success_action_status设置为200。
            },
            filePath: res.tempFilePath,
            success: (res) => {
              uni.hideLoading()
              console.log("视频上传成功,地址为:",`${OSSConfig.Host}/${uniqueId}`)
            },
            fail: (err) => {
              uni.hideLoading()
            },
          })
        },
        fail: (err) => {
          console.log(err)
        },
      })



欢迎小伙伴留言讨论,互相学习!

❤❤❤ 如果对你有帮助,记得点赞收藏哦!❤❤❤

uni-app D4 实战(小兔鲜)

2025年11月21日 17:55

1. 首页-封装通用轮播组件提高复用

image.png

1.1 轮播图的静态结构(直接复制就好)

<script setup lang="ts">
import { ref } from 'vue'

const activeIndex = ref(0)
</script>

<template>
  <view class="carousel">
    <swiper :circular="true" :autoplay="false" :interval="3000">
      <swiper-item>
        <navigator url="/pages/index/index" hover-class="none" class="navigator">
          <image
            mode="aspectFill"
            class="image"
            src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/slider_1.jpg"
          ></image>
        </navigator>
      </swiper-item>
      <swiper-item>
        <navigator url="/pages/index/index" hover-class="none" class="navigator">
          <image
            mode="aspectFill"
            class="image"
            src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/slider_2.jpg"
          ></image>
        </navigator>
      </swiper-item>
      <swiper-item>
        <navigator url="/pages/index/index" hover-class="none" class="navigator">
          <image
            mode="aspectFill"
            class="image"
            src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/slider_3.jpg"
          ></image>
        </navigator>
      </swiper-item>
    </swiper>
    <!-- 指示点 -->
    <view class="indicator">
      <text
        v-for="(item, index) in 3"
        :key="item"
        class="dot"
        :class="{ active: index === activeIndex }"
      ></text>
    </view>
  </view>
</template>

<style lang="scss">
/* 轮播图 */
.carousel {
  height: 280rpx;
  position: relative;
  overflow: hidden;
  transform: translateY(0);
  background-color: #efefef;
  .indicator {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 16rpx;
    display: flex;
    justify-content: center;
    .dot {
      width: 30rpx;
      height: 6rpx;
      margin: 0 8rpx;
      border-radius: 6rpx;
      background-color: rgba(255, 255, 255, 0.4);
    }
    .active {
      background-color: #fff;
    }
  }
  .navigator,
  .image {
    width: 100%;
    height: 100%;
  }
}
</style>

1.2 修改一下组件引入规则(pages.json)

image.png

1.2.1 添加组件类型声明

image.png

image.png

1.3 轮播图的dots实现动态

image.png

image.png 结果:

image.png

image.png

image.png **本次练习重点知识点: ** image.png

1.4 首页-获取轮播图数据

image.png

文件下载:后端配置、前端方式与进度监控

2025年11月21日 17:05

一、后端核心配置:决定下载的 “基础规则”

后端通过 HTTP 响应头控制文件的传输行为,这是所有下载逻辑的起点,关键配置有 3 个:

1. Content-Disposition:控制 “下载” 或 “预览”

  • 配置 1(强制下载)

    Content-Disposition: attachment; filename="file.xlsx"
    
    • 作用:告诉浏览器 “这是一个需要下载的附件”,无论文件类型是什么(图片 / PDF/Excel),都会直接触发下载弹窗。
    • 细节:filename 指定默认下载文件名,支持 UTF-8 编码(需处理中文:filename*=UTF-8''%E6%88%91%E7%9A%84%E6%96%87%E4%BB%B6.xlsx)。
  • 配置 2(在线预览)

    Content-Disposition: inline
    
    • 作用:告诉浏览器 “直接在页面内预览文件”,比如图片直接显示、PDF 在浏览器打开、TXT 直接渲染。
    • 细节:若不设置该字段,浏览器会根据 Content-Type 自动判断(图片 / PDF 默认预览,Excel / 压缩包默认下载)。

2. Content-Type:定义文件的 “类型标识”

  • 作用:告诉浏览器文件的 MIME 类型,决定浏览器如何解析文件(即使配置了 attachment,浏览器也会根据类型处理下载)。

  • 常见配置: # Excel 文件 Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet # PDF 文件 Content-Type: application/pdf # 图片 Content-Type: image/jpeg # 二进制流(通用,推荐大文件) Content-Type: application/octet-stream

  • 关键:若后端返回 application/octet-stream(二进制流),浏览器会直接判定为 “可下载文件”,优先级高于 inline

3. Content-Length:提供文件的 “总大小”

  • 配置:

    Content-Length: 10485760  # 表示文件大小为 10MB
    
  • 作用:告诉前端文件的总字节数,是前端计算下载进度的必要条件(没有这个头,前端无法知道 “总大小”,只能显示 “已下载字节”,无法算百分比)。

  • 注意:若后端用 “分块传输”(Transfer-Encoding: chunked),则不会返回 Content-Length,前端无法计算进度。

4. 其他辅助配置

  • Accept-Ranges: bytes:支持断点续传,前端可请求文件的某一部分(Range: bytes=0-1023),适合大文件分段下载。
  • Access-Control-Expose-Headers: Content-Length:跨域场景下,允许前端读取 Content-Length 等响应头(否则前端拿不到总大小)。

二、前端下载方式:根据需求选择 “策略”

前端有 3 种主流下载方式,对应不同场景,和后端配置直接关联:

方式 1:原生 <a> 标签(最简单,无进度)

  • 用法

    <a href="/api/download/file" download="自定义文件名.xlsx">下载</a>
    
  • 适用场景:后端已配置 Content-Disposition: attachment,且无需监控进度(小文件、对进度无要求)。

  • 和后端配置的关联

    • 若后端配了 attachment:点击直接下载,download 属性可自定义文件名(优先级高于后端的 filename)。
    • 若后端配了 inline:浏览器会预览文件,此时加 download 属性可强制触发下载(部分浏览器支持)。
  • 局限性:无法监控进度、无法处理复杂逻辑(如权限验证、Token 携带)。

方式 2:window.open()(和 <a> 标签类似)

  • 用法

    window.open('/api/download/file?token=xxx');
    
  • 适用场景:需要携带参数(如 Token)的下载,或需要新开窗口处理。

  • 缺点:同样无法监控进度,且可能被浏览器拦截(弹窗拦截)。

方式 3:AJAX/fetch + Blob(支持进度,最灵活)

  • 核心逻辑:前端主动请求文件二进制流,监控传输过程,最后模拟下载。

  • 和后端配置的关联

    • 后端需返回二进制流(Content-Type: application/octet-stream 或对应文件类型)。
    • 若后端配了 Content-Length,可计算精确进度;否则只能监控 “已下载字节”。
  • 完整代码示例(带进度)

    // 1. 创建请求对象
    const xhr = new XMLHttpRequest();
    xhr.open('GET', '/api/download/large-file.zip', true);
    xhr.responseType = 'blob'; // 关键:以 Blob 接收响应
    
    // 2. 监控进度
    xhr.addEventListener('progress', (e) => {
      if (e.lengthComputable) {
        const progress = (e.loaded / e.total) * 100; // 进度百分比
        console.log(`下载进度:${progress.toFixed(2)}%`);
        // 更新 UI:比如进度条宽度、文字提示
        document.getElementById('progress-bar').style.width = `${progress}%`;
      } else {
        console.log(`已下载:${e.loaded} 字节`); // 无总大小,只能显示已下载
      }
    });
    
    // 3. 请求成功:生成下载链接
    xhr.addEventListener('load', () => {
      if (xhr.status === 200) {
        const blob = xhr.response;
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = '大文件.zip'; // 自定义文件名
        a.click();
        URL.revokeObjectURL(url); // 释放内存
      }
    });
    
    // 4. 发送请求(可携带 Token)
    xhr.setRequestHeader('Authorization', 'Bearer ' + token);
    xhr.send();
    
  • 优势

    • 可监控实时进度;
    • 可携带自定义请求头(如 Token),处理权限验证;
    • 可中断请求(xhr.abort());
    • 可处理后端返回的错误(如权限不足时,后端返回 JSON 而非文件,前端可捕获)。

三、下载进度的关联逻辑:为什么只有 Blob 方式能监控?

进度监控的核心是前端能感知 “数据传输的每一步” ,这和请求方式直接相关:

1. <a> 标签 /window.open():进度 “不可见”

  • 这两种方式是浏览器直接和后端通信,前端 JS 被排除在传输过程之外:

    • 浏览器接收到后端的响应头后,直接启动下载进程,JS 无法获取 “已下载多少字节”“总字节数”。
    • 相当于 “把下载交给浏览器,前端只能等结果”,自然无法监控进度。

2. AJAX/fetch + Blob:进度 “可感知”

  • 这种方式是前端 JS 主动和后端通信,数据先流经 JS,再交给浏览器:

    • 后端以 “流” 的形式分段返回数据,每返回一段,就触发 progress 事件;
    • 事件对象 e 包含 loaded(已接收字节)和 total(总字节,来自 Content-Length),前端可实时计算进度;
    • 所有数据接收完成后,JS 将二进制流封装成 Blob,再模拟 <a> 标签下载。

3. 进度监控的关键前提

  • 后端必须返回 Content-Length(否则 e.lengthComputable 为 false,无法算百分比);
  • 后端不能用 “分块传输”(Transfer-Encoding: chunked),否则无 Content-Length
  • 跨域时,后端需配置 Access-Control-Expose-Headers: Content-Length,否则前端拿不到 total

四、完整关联链总结

image.png

核心关键点回顾

  1. 后端是基础Content-Disposition 决定默认行为,Content-Length 决定能否算进度,Content-Type 决定文件解析方式。
  2. 前端选方式:小文件 / 无进度需求用 <a> 标签,大文件 / 需进度用 AJAX+Blob。
  3. 进度靠流控:只有让文件以二进制流形式流经前端 JS,才能捕获传输进度,<a> 标签做不到这一点。

简单说:后端定 “规则”,前端选 “策略”,进度靠 “流控”—— 这就是三者的核心关联!

听说vite要一统江湖了,我看看怎么个事

作者 sean聊前端
2025年11月21日 16:57

抛几个问题大家先聊聊

1.  大家心目中的vite是个什么样子

2.  vite快在哪

3.  vite 开发环境和生成环境都用什么打包 为啥不能统一

一、Vite 产生背景

 

1.  传统构建工具的核心痛点(webpack有啥痛点)

 

以 Webpack 为代表的传统工具,因 “全量打包” 模式难以适配复杂项目,主要痛点集中在四点:

● 🚫 开发体验差:冷启动达分钟级,热更新延迟随模块量增长,常丢失开发状态

● ⚡ 性能瓶颈明显:JS 编写的工具占用 CPU / 内存高,多项目并行时设备压力大

● 🔧 适配成本高:需兼容多模块格式(CJS/UMD/ESM),配置 TypeScript 等功能步骤繁琐

● 🕸️ 未利用浏览器新能力:2018 年后浏览器原生支持 ESM,但传统工具未借力优化

 

2. Vite 诞生的 3 大技术基石(vite有啥优势)

 

Vite 的出现依赖前端生态三大关键进展,缺一不可:

1.  🌐 浏览器原生 ESM 普及

支持

2.  🔨 编译工具语言革新

2020 年 Go 语言编写的 esbuild 发布,依赖预构建速度比 JS 工具快 10-100 倍,解决传统工具性能瓶颈。

3.  🧩 构建理念升级

开发环境借鉴 Snowpack “依赖预构建” 思路,生产环境依托 Rollup 成熟插件生态,实现 “按需编译” 的新型构建模式。

 

3. Vite 诞生历程与关键决策(vite时间线)

 

Vite 从解决 Vue 生态痛点起步,逐步发展为跨框架通用工具:

● 📅 初始动机:2019 年 Vue 3 开发中,模块激增导致 Webpack 启动慢,亟需适配现代框架的轻量工具。

● 🚩 关键时间节点:

● 2020.04:Vite 1.0 发布,仅支持 Vue 项目,验证 ESM 开发模式可行性;

● 2021.02:Vite 2.0 重构,成为跨框架工具,引入 Rollup 负责生产构建;

● 2022-2023:3.0/4.0/5.0 版本持续优化性能,完善生态兼容(如支持 React、Svelte 等)。

● 🎯 核心决策:分模块差异化处理

依赖用 esbuild 预构建 + 源码开发时按需编译 + 全流程缓存,平衡速度与兼容性。

 

4. 构建模式对比流程图(webpack vs vite)

 

通过流程图直观对比传统工具与 Vite 的核心差异,重点关注 “是否全量打包”“更新方式” 两个维度:

 

4.1 传统 Bundle 模式(以 Webpack 为例)

image.png

 

4.2 Vite 构建模式(开发 + 生产分离)

image.png

 

5. Vite 核心价值总结 (总结)

Vite 之所以能成为前端构建工具新选择,核心价值体现在四方面:

1.  🔄 范式革新:开发 / 生产分离优化(ESM 按需编译 + Rollup 生产打包),兼顾速度与产物质量;

2.  ⚡ 性能突破:冷启动时间从分钟级压缩至秒级,大型项目开发效率提升显著;

3.  🚀 体验升级:零配置开箱即用,HMR 保持应用状态,减少开发流程中断;

4.  🌍 生态兼容:支持 Vue/React/Svelte 等多框架,兼容 Rollup 插件,降低迁移成本。

 

二. Vite 核心特性解析

 

Vite 颠覆传统构建工具的核心在于利用现代浏览器能力与分层优化策略,其三大核心特性共同实现了 “开发极速响应、生产高效输出” 的体验升级。

2.1 特性一:极速冷启动(毫秒级启动)

 

传统工具需全量打包后启动服务,而 Vite 通过 “依赖预构建 + 源码按需加载” 实现极速启动,启动时间不受项目体积线性影响。

 

2.1.1 实现原理

1.  模块分层处理

首次启动时将项目模块拆分为两类,针对性优化:

● 依赖模块:开发中不变的纯 JS 依赖(如 Lodash、Vue 核心库),多含 CJS/UMD 格式,需统一转换为 ESM 并合并减少请求量。

image.png

● 源码模块:频繁编辑的业务代码(含 JSX、Vue 组件等),直接以原生 ESM 格式提供,浏览器请求时才按需编译。

image.png

2.  esbuild 预构建依赖

采用 Go 语言编写的 esbuild 处理依赖,速度比 JS 工具快 10-100 倍,预构建结果存入缓存(node_modules/.vite),二次启动直接复用。

3.  浏览器接管部分打包工作

通过

 

2.1.2 冷启动流程可视化

image.png

 

2.2 特性二:精准热模块替换(HMR)

 

Vite 的 HMR 基于原生 ESM 实现,更新速度不随项目体积增长而下降,且能保持应用运行状态。

 

2.2.1 核心机制

1.  模块依赖图追踪

启动时构建 moduleGraph 记录模块间依赖关系(如 A 依赖 B、B 依赖 C),每个模块对应唯一 ModuleNode 对象,包含转换结果与依赖链信息。

packages/vite/src/server/moduleGraph.ts

2.  精准失效范围

文件修改后,仅使变更模块及其最近的 HMR 边界(如 Vue 组件的

3.  WebSocket 实时通信

● 用户修改文件后被 server 端的监听器监听到,监听器遍历文件对应的模块,计算出热更新边界

● server 端通过 websocket 向 client 发送热更新信号

● client 对旧模块进行失活,向 server 请求最新的模块资源

● server 收到请求后将模块代码转换为 js,并将转换后的代码返回给 client

● client 执行返回后的代码,调用更新函数更新页面内容

4.  缓存加速

源码模块通过 304 Not Modified 协商缓存,依赖模块通过 immutable 强缓存,避免重复请求。

 

2.2.2 HMR 工作流程可视化

 

image.png

 

2.3 特性三:开发与生产双环境优化

Vite 采用 “开发按需编译、生产优化打包” 的差异化策略,兼顾开发效率与生产性能。

 

2.3.1 双环境设计逻辑
维度 开发环境(dev) 生产环境(build)
核心目标 极速启动、实时反馈 产物体积小、加载快、兼容性强
实现方式 原生 ESM 按需编译 + esbuild 预构建 Rollup 优化打包 + 多维度性能优化
关键操作 模块缓存、HMR 局部更新 Tree-shaking、代码分割、压缩、预加载注入
工具依赖 Koa 服务器、WebSocket、chokidar Rollup、Terser、CSSNano
 
2.3.2 生产环境优化细节

1.  Rollup 打包核心

未采用 esbuild 生产打包的原因:Rollup 拥有更成熟的插件生态与更优的代码分割策略,能实现更精细的 Tree-shaking(剔除无用代码)。未来计划迁移至 Rust 编写的 Rolldown,进一步提升性能。

2.  智能代码分割

自动拆分公共依赖(如 Vue 核心库)与业务代码,生成独立 chunk,利用浏览器缓存提升二次加载速度。

3.  资源优化

● 压缩:JS 用 Terser 压缩,CSS 用 CSSNano 处理;

● 预加载:自动生成 ,提前加载关键模块;

● 兼容性:通过 @vitejs/plugin-legacy 生成 ES5 代码,适配旧浏览器。

为啥不用esbuild在生产环境

● 代码分割(Code Splitting)能力较弱:esbuild 的代码分割逻辑相对简单,对动态导入(import())的处理、公共模块提取(splitChunks)等高级需求支持不足,而现代前端项目(尤其是大型应用)依赖灵活的代码分割来优化加载性能。

● 生态兼容性有限:esbuild 的插件系统不如 Rollup 成熟,许多前端生态中常用的工具(如处理 CSS 模块化、静态资源、特定框架特性的插件)对 esbuild 的适配不够完善,而 Rollup 拥有丰富的插件生态,能更好地兼容前端工程化的复杂需求。

● 对非 ESM 模块的处理能力较弱:虽然 esbuild 支持转换 CommonJS 模块,但在处理复杂依赖关系(如循环依赖、动态 require)时,可能出现与 Webpack/Rollup 不一致的行为,存在兼容性风险。

 

2.3.3 双环境流程对比

image.png

 

2.4 特性总结:为何 Vite 能实现 “快且优”?

1.  理念革新:让浏览器参与模块解析,将传统打包工具的 “预打包” 改为 “按需编译”,从根源上提升启动速度。

2.  技术选型精准:esbuild 处理依赖、Rollup 负责生产打包、WebSocket 实现 HMR,每一步都采用当前最优工具链。

3.  分层优化思维:针对 “开发 - 生产”“依赖 - 源码” 的不同特性设计差异化策略,既满足开发效率又保证生产性能。

 

三、  Vite 各版本对比及 Demo 展示

 

3.1版本演进核心脉络与定位

Vite 的版本迭代围绕「性能突破」与「架构统一」两大主线展开,可划分为三个关键阶段:

1.  基础奠基期(V4.x):验证「非打包开发」核心模式,奠定极速启动基础

2.  生态拓展期(V5.x-V6.x):完善框架适配与工具链集成,暴露混合架构瓶颈

3.  架构重构期(V7.x-V8 Beta):引入 Rust 工具链,实现开发 / 生产流程统一

 

3.2关键版本核心特性对比

维度 V4.x(奠基期) V5.x-V6.x(拓展期) V7.x(转型期) V8 Beta(重构期)
核心架构 esbuild 预打包 + Rollup 生产构建 保留双工具架构,优化协作逻辑 引入 Rolldown 试验性支持 全 Rolldown 驱动,彻底替代双工具
Node 支持 Node.js 14.18+ Node.js 16.14+ Node.js 20.19+/22.12+(弃用 18) 同 V7,兼容 LTS 版本
框架适配 Vue/React/Svelte 核心支持 新增 Marko 模板,RedwoodSDK 整合 完善 Vue 3.5/React 19 适配 全框架兼容,支持微前端联邦架构
TypeScript 能力 基础 TS 转译,依赖 esbuild 内置 TS 5.8,支持常量枚举内联 集成 Oxc TS 解析,类型检查提速 30% 原生 TS 类型优化,支持增量编译
性能优化点 冷启动 161ms(对比 Webpack 快 11.7 倍) 解析逻辑优化,冷启动比 V4.2 提升 70% 生产构建速度提升 30%,热更新 < 50ms 跨块优化提速 15 倍,打包体积降 20%
关键新特性 HMR 模块依赖图追踪 模块联邦支持,barrel 文件优化 新增 buildApp 钩子,Vite DevTools 开发 全捆绑模式,原生 Importmaps 支持

 

3.3. 核心架构迭代解析

版本阶段 架构示意图 核心痛点解决
V4.x-V6.x 红色虚线框:开发用 esbuild、生产用 Rollup,工具链割裂导致环境差异黄色感叹号:Rollup 单线程处理大型项目时,构建速度瓶颈明显 
V7.x 过渡特性标注:绿色模块:新增的 Rolldown 试验性功能,主打性能提升黄色模块:优化后的 Dev Server,内存占用降低 30%分支逻辑:保留双工具链选项,平衡兼容性与性能 
V8 Beta 蓝色粗框:Rolldown 统一工具链,消除环境差异绿色模块:多线程 + 增量打包,性能核心产物体积比 V6.x 降 20%,无需手动配置 vendor 

 

3.4. 大型项目性能实测(复杂多应用工程)

指标 V4.x V6.x V7.x V8 Beta
开发冷启动时间 2.8s 1.5s 0.9s 0.3s
生产构建时间 3m12s 2m0s 1m15s 8s
热更新响应时间 120ms 80ms 45ms 20ms
内存占用 180MB 120MB 90MB 42MB

数据来源:Vite 官方 benchmark 及 PayFit、掘金社区实测案例综合整理

 

3.5. 各版本适用场景

● V4.x:维护旧项目,依赖 Node.js 18 及以下环境

● V6.x:中型项目稳定运行,需 Marko/Redwood 生态支持

● V7.x:追求性能提升,可接受 Rust 工具链过渡适配

● V8 Beta:大型项目 / 微前端架构,需极致构建性能

 

3.6. 迁移成本与收益对比

迁移路径 核心改动点 预期收益 潜在风险
V4→V6 升级 Node.js 至 16+,适配 TS 5.8 冷启动提速 46%,生态工具更丰富 部分旧插件兼容性问题
V6→V7 升级 Node.js 至 20+,适配 Rolldown 试验模式 生产构建提速 40%,热更新延迟减半 少数第三方库导入顺序问题
V7→V8 Beta 移除 Rollup 配置,启用全捆绑模式 构建速度提升 15 倍,打包体积降 20% 部分插件需迁移至 Rust 原生实现

 

3.7. 7+版本演进核心优势

1.  性能质变节点:V7.x 引入 Rolldown 标志着性能提升从「优化迭代」进入「架构重构」阶段,V8 Beta 实现质的飞跃

2.  架构统一价值:全 Rolldown 驱动解决了 Vite 诞生以来的「开发 / 生产环境不一致」核心痛点

3.  生态适配节奏:每个大版本均保持对主流框架的兼容性,V8 将完成从工具到生态的全面升级

参考 Vite 官方路线图:2025 年底 V8 正式版将实现 Rolldown 全量启用,届时 V4-V7 版本将逐步进入维护期

 

3.8. 快速搭建各版本项目demo

# Vite 3(需指定版本)
npm create vite@3 my-v3-app -- --template vue
# Vite 4
npm create vite@4 my-v4-app -- --template vue
# Vite 5
npm create vite@5 my-v5-app -- --template vue
# Vite 6
npm create vite@6 my-v6-app -- --template vue
# Vite 7
npm create vite@7 my-v7-app -- --template vue

 

四、vite4核心源码解析

 

4.1、Vite 4 核心架构与源码组织

Vite 4 采用 monorepo 结构(基于 pnpm workspace),核心代码集中于 packages/vite 目录,整体架构可划分为五大核心模块,各模块职责明确且协同联动。

 

4.2. 核心模块概览

模块路径 核心职责 关键依赖 / 工具
src/node/cli.ts 命令行入口,解析参数分发命令 cac(命令行解析工具)
src/node/config/ 配置解析与合并,支持多环境配置 -
src/node/server/ 开发服务器实现,集成 HMR 与中间件 connect(中间件框架)、chokidar(文件监听)
src/node/build/ 生产构建流程,基于 Rollup 实现 Rollup 3、esbuild
src/node/plugin/ 插件系统核心,定义钩子与容器 -

 

4.3. 核心数据结构

●  ModuleGraph(src/node/server/moduleGraph.ts):维护模块依赖关系的核心数据结构,记录 URL 与文件路径映射、模块依赖链及 HMR 状态,是按需编译与热更新的基础。

●  PluginContainer(src/node/pluginContainer.ts):插件容器,统一调度插件钩子执行,实现模块解析、转换等流程的可扩展性。

 

 

4.4、核心流程源码解析

Vite 4 的核心能力集中体现在开发环境启动、模块按需编译、热更新(HMR) 与生产构建四大流程,以下结合源码片段深度拆解。

 

4.4.1. 开发服务器启动流程(vite dev

启动流程是 Vite 4 极速体验的起点,核心是初始化配置、构建中间件链与启动 HMR 服务,源码入口为 src/node/cli.ts,关键逻辑在 createServer 函数中实现。

 

关键步骤与源码

  1. 命令行解析与配置合并
   // src/node/cli.ts 简化逻辑
   async function createServer(inlineConfig = {}) {
     // 1. 解析配置:合并默认配置、用户配置、环境变量
     const config = await resolveConfig(inlineConfig, 'serve')
        
     // 2. 创建中间件容器与 HTTP 服务器
     const middlewares = connect()
     const httpServer = await resolveHttpServer(config.server, middlewares)
     
     // 3. 初始化 WebSocket 服务(HMR 通信通道)
     const ws = createWebSocketServer(httpServer, config)
     
     // 4. 创建文件监听器(监控源码与配置变化)
     const watcher = chokidar.watch(root, resolvedWatchOptions)
 
     // 5. 初始化模块依赖图
     const moduleGraph = new ModuleGraph((url) => pluginContainer.resolveId(url))
     
     // 6. 注册核心中间件(按顺序执行)
     middlewares.use(timeMiddleware) // 响应时间统计
     middlewares.use(corsMiddleware(config)) // 跨域处理
     middlewares.use(proxyMiddleware(config)) // 代理配置
     middlewares.use(transformMiddleware(config, moduleGraph, ws)) // 模块转换核心
     middlewares.use(indexHtmlMiddleware(config, moduleGraph)) // HTML 处理
     middlewares.use(errorMiddleware()) // 错误捕获
     
     // 7. 初始化插件容器
     await pluginContainer.buildStart({})
     
  // 8. 初始化依赖预构建器(后台启动,不阻塞服务器启动)
     await initDepsOptimizer(config, options.force, true);
     
     return { server, moduleGraph, ws }
   }

 

  1. 依赖预构建优化

Vite 4 默认启用 esbuild 处理依赖预构建,将 CommonJS 格式的依赖转换为 ESM 并缓存,避免浏览器兼容性问题,源码位于 src/node/depOptimizer/。预构建产物存储于 node_modules/.vite/deps,首次启动后会缓存,二次启动直接复用。

 

启动流程可视化

image.png

4.4.2. 模块按需编译流程(核心性能点)

Vite 4 区别于传统打包工具的核心是按需编译:浏览器请求模块时才触发编译,而非全量预打包,核心实现依赖 transformMiddleware 中间件。

 

关键逻辑与源码

  1. 请求拦截与模块解析
   // src/node/server/middlewares/transform.ts 简化逻辑
   async function transformMiddleware(req, res, next) {
     const url = req.url
     // 1. 忽略静态资源与非模块请求
     if (isStaticAsset(url) || !req.headers.accept?.includes('text/javascript')) {
       return next()
     }
     
     // 2. 从模块图获取或创建模块实例
     let module = moduleGraph.getModuleByUrl(url)
     if (!module) {
       const resolved = await pluginContainer.resolveId(url)
       module = await moduleGraph.createModule(resolved.id, url)
     }
     
     // 3. 执行插件转换(如 Vue SFC 解析、TS 转译)
     const transformResult = await pluginContainer.transform(code, module.file)
     
     // 4. 注入 HMR 客户端代码(开发环境)
     if (!isProduction) {
       transformResult.code += injectHmrClientCode(url)
     }
     
     // 5. 返回编译结果给浏览器
     res.setHeader('Content-Type', 'application/javascript')
     res.end(transformResult.code)
   }

拦截模块请求浏览器的请求先经过 Vite 服务器的中间件链,transformMiddleware 会识别出 “需要编译的模块请求”(如 .vue、.ts、.jsx 等非原生 ESM 模块,或需要转换的 JS 模块)。

定位模块文件

通过 ModuleGraph(之前提到的模块依赖图)将请求的 URL(如 /src/App.vue)映射到本地文件系统的路径(如 ./src/App.vue),确认模块的物理位置。

 

调用插件处理(编译)

借助 PluginContainer(插件容器)调用对应插件的转换钩子(如 transform),对模块内容进行实时编译:

例如,.vue 文件会被 @vitejs/plugin-vue 解析为模板、脚本、样式三部分,分别编译为浏览器可执行的 JS 代码;

例如,.ts 文件会被 @vitejs/plugin-typescript 转换为 JS 代码。

 

处理依赖关系

编译过程中,若模块依赖其他模块(如 App.vue 中 import Button from './Button.vue'),transformMiddleware 会通过 ModuleGraph 记录这些依赖关系,为后续的热更新做准备。

 

返回编译结果

将编译后的代码(符合 ESM 规范)作为 HTTP 响应返回给浏览器,浏览器直接执行该模块。

 

  1. 插件转换机制

以 Vue 单文件组件(SFC)为例,@vitejs/plugin-vue 插件通过 transform 钩子拦截 .vue 文件请求,将其拆分为模板、脚本、样式三部分分别处理,再合并为浏览器可识别的 ESM 模块。

 

4.4.3. 热更新(HMR)流程

Vite 4 升级了 HMR 引擎,大型项目热更新延迟从 1200ms 降至 500ms 内,核心是"精确模块更新"而非全页刷新,依赖文件监听、模块依赖分析与 WebSocket 通信实现。

 

关键步骤与源码

  1. 文件变化监听与依赖分析
// vite/src/node/server/hmr.ts 核心逻辑简化版
async function handleHMRUpdate(file: string, server: ViteDevServer) {
  const { ws, config, moduleGraph } = server
  
  // 1. 确定受影响的模块
  const mods = moduleGraph.getModulesByFile(file)
  
  // 2. 根据文件类型执行不同更新策略
  if (isCSSRequest(file)) {
    // CSS热更新逻辑
    await Promise.all(
      Array.from(mods).map((mod) => {
        return moduleGraph.invalidateModule(mod)
      })
    )
    ws.send({
      type: 'update',
      updates: [{
        type: 'css-update',
        path: publicPath,
        timestamp: Date.now()
      }]
    })
  } else {
    // JS/HTML等热更新逻辑
    const update = await generateUpdate(mods, file, server)
    ws.send({
      type: 'update',
      updates: update
    })
  }
}
 

 

  1. 客户端更新处理

浏览器端通过 import.meta.hot API 接收更新通知,替换模块并执行自定义更新逻辑(如 Vue 组件重新渲染):

   // 客户端 HMR 逻辑(src/client/client.ts)
   import.meta.hot.accept('./component.js', (newComponent) => {
     // 替换组件并重新挂载
     replaceComponent(newComponent.default)
   })

 

HMR 流程可视化

image.png

4.4.4. 生产构建流程(vite build

生产环境下,Vite 4 采用 Rollup 3 进行打包优化,核心是代码分割、压缩与兼容性处理,源码入口为 src/node/build/index.ts

 

关键步骤

  1. 配置解析与构建准备:合并生产环境配置,确定目标浏览器与输出格式。

  2. Rollup 配置生成:根据 Vite 配置自动生成 Rollup 配置,支持 build.rollupOptions 深度定制。

  3. 插件适配与执行:将 Vite 插件转换为 Rollup 插件格式,执行模块转换与优化。

  4. 产物优化:默认启用 Terser 压缩 JS,CSS 压缩通过 cssnano 实现,支持自定义压缩工具。

 

核心配置示例

// Vite 4 生产构建配置
export default {
  build: {
    target: 'es2015', // 目标浏览器兼容性
    minify: 'terser', // 启用 Terser 压缩
    rollupOptions: {
      // 自定义代码分割策略
      output: {
        manualChunks: {
          vendor: ['vue', 'vue-router'], // 第三方依赖单独打包
          utils: ['lodash', 'dayjs']
        }
      }
    }
  }
}

 

 

4.5、Vite 4 核心技术亮点源码解析

4.5.1. HMR 性能优化(V4 核心升级点)

Vite 4 对 HMR 引擎进行了重构,通过差分更新与模块依赖缓存减少重复计算,源码关键优化点在 ModuleGraphinvalidateModule 方法中:

// src/node/server/moduleGraph.ts
function invalidateModule(module) {
  // 仅标记变化模块,不清除整个依赖链缓存
  module.invalidated = true
  // 递归标记依赖模块,但跳过已缓存的无变化模块
  for (const importer of module.importers) {
    if (!importer.invalidated) invalidateModule(importer)
  }
}

 

4.5.2. 中间件链设计(可扩展性核心)

Vite 4 中间件按固定顺序执行,确保请求处理的正确性,核心中间件功能如下表:

中间件 核心功能 源码路径
transformMiddleware 模块实时编译(TS/Vue 转译) src/node/server/middlewares/transform.ts
indexHtmlMiddleware HTML 入口处理与资源注入 src/node/server/middlewares/indexHtml.ts
proxyMiddleware 跨域代理与请求转发 src/node/server/middlewares/proxy.ts
errorMiddleware 全局错误捕获与格式化 src/node/server/middlewares/error.ts

 

 

4.6、核心流程总览流程图

image.png

 

Vite 4 的源码设计核心是"扬长避短":用 esbuild 处理快但不灵活的步骤(预构建、转译),用 Rollup 处理灵活但慢的生产打包,用中间件与插件系统保证可扩展性,最终实现"极速开发+优化产物"的双重目标。

五、Vite 7 核心源码解析

5.1、Vite 7 核心架构与源码组织

Vite 7 延续 monorepo 结构(基于 pnpm workspace),核心代码集中于 packages/vite 目录,在保留“开发服务器+生产构建”双核心的基础上,新增 Rust 引擎适配层与环境抽象层,形成“四层架构”体系。

5.2. 核心模块概览

模块路径 核心职责 关键技术 / 工具
src/node/cli.ts 命令行入口,解析参数并分发命令 cac(命令行解析)
src/node/config/ 配置解析与合并,支持多环境配置隔离 环境抽象 API
src/node/server/ 开发服务器实现,集成 HMR 与中间件 connect、chokidar
src/node/build/ 生产构建核心,支持 Rolldown/Rollup 双引擎 Rolldown(Rust)、Rollup 3
src/node/plugin/ 插件系统,兼容 Rollup 插件并扩展新钩子 插件过滤 API(withFilter)

5.3. 核心数据结构升级

●  ModuleGraphsrc/node/server/moduleGraph.ts):新增 Rust 引擎依赖追踪能力,支持 Rolldown 模块解析结果与 JS 模块图的双向同步,解决双引擎依赖分析不一致问题。

●  BuilderContextsrc/node/build/builderContext.ts):统一构建上下文,封装引擎选择、产物优化等核心逻辑,屏蔽 Rolldown 与 Rollup 的调用差异。

●  Environmentsrc/node/env/index.ts):环境描述对象,定义目标运行时(浏览器/Node/边缘)、兼容性标准等属性,为多环境构建提供基础。

 

5.4、核心流程源码解析

Vite 7 的核心突破集中于 Rolldown 构建流程、多环境适配场景,以下结合源码片段深度拆解。

5.4.1Rolldown 预构建优化

Vite 7 支持通过 Rolldown 处理依赖预构建,替代部分 esbuild 功能,尤其在 CJS 转 ESM 场景下性能提升显著。预构建逻辑位于 src/node/optimizer/rolldownDepPlugin.ts 调用 Rust 接口处理依赖转换,产物存储于 node_modules/.vite/deps,并生成引擎兼容的缓存元数据。

 

启动流程可视化

image.png

5.4.2. 生产构建流程(vite build)—— Rolldown 核心适配

生产构建是 Vite 7 性能革新的核心场景,默认提供 Rolldown 引擎选项(通过 rolldown-vite 包集成),构建速度较 Rollup 提升 3-7 倍,内存占用减少 40% 以上。

 

关键步骤与源码

  1. rolldown打包逻辑 part3
   // src/node/build.ts 简化逻辑
   async function buildEnvironment(environment) {
    // 1. 创建一个新的ChunkMetadataMap实例,用于存储和管理构建过程中的chunk元数据
const chunkMetadataMap = new ChunkMetadataMap()
 
// 2. 解析Rolldown配置选项,将环境配置和chunk元数据映射传递给解析函数
// 注意变量名虽然是rollupOptions,但实际返回的是Rolldown的配置
const rollupOptions = resolveRolldownOptions(environment, chunkMetadataMap)
 
// 3. 检查是否处于监视模式(watch mode)
if (options.watch) {
  // 4. 在控制台输出信息,告知用户正在监视文件变化
  logger.info(colors.cyan(`\nwatching for file changes...`))
 
  // 5. 解析输出目录,获取构建产物将被写入的目录路径
  const resolvedOutDirs = getResolvedOutDirs(
    root,
    options.outDir,
    options.rollupOptions.output,  // 使用rollupOptions.output作为输出配置
  )
  
  // 6. 解析是否需要清空输出目录的设置
  const emptyOutDir = resolveEmptyOutDir(
    options.emptyOutDir,  // 用户配置的emptyOutDir选项
    root,
    resolvedOutDirs,      // 解析后的输出目录
    logger,
  )
  
  // 7. 解析文件监视选项(Chokidar配置)
  const resolvedChokidarOptions = resolveChokidarOptions(
    {
      // 8. 合并配置中的chokidar选项(虽然Rolldown没有这个选项,但为了向后兼容保留)
      // @ts-expect-error 标记:chokidar选项在rolldown中不存在,但为了向后兼容而使用
      ...(rollupOptions.watch || {}).chokidar,
      // @ts-expect-error 同样标记:用户配置的watch.chokidar选项
      ...options.watch.chokidar,
    },
    resolvedOutDirs,      // 输出目录
    emptyOutDir,          // 是否清空输出目录
    environment.config.cacheDir,  // 缓存目录
  )
 
  // 9. 动态导入rolldown的watch函数
  const { watch } = await import('rolldown')
  
  // 10. 创建Rolldown的watcher实例,传入配置选项
  const watcher = watch({
    ...rollupOptions,  // 基础构建配置
    watch: {           // 监视模式特定配置
      ...rollupOptions.watch,  // 合并原有watch配置
      ...options.watch,        // 合并用户提供的watch配置
      // 11. 将Chokidar配置转换为Rolldown的notify选项
      notify: convertToNotifyOptions(resolvedChokidarOptions),
    },
  })
 
  // 12. 监听watcher事件
  watcher.on('event', (event) => {
    // 13. 当构建开始时的处理
    if (event.code === 'BUNDLE_START') {
      logger.info(colors.cyan(`\nbuild started...`))  // 输出构建开始信息
      chunkMetadataMap.clearResetChunks()  // 清除并重置chunk元数据
    } 
    // 14. 当构建结束时的处理
    else if (event.code === 'BUNDLE_END') {
      event.result.close()  // 关闭构建结果,释放资源
      logger.info(colors.cyan(`built in ${event.duration}ms.`))  // 输出构建耗时
    } 
    // 15. 当发生错误时的处理
    else if (event.code === 'ERROR') {
      const e = event.error
      enhanceRollupError(e)  // 增强错误信息,使其更有用
      clearLine()  // 清除控制台当前行
      logger.error(e.message, { error: e })  // 输出错误信息
    }
  })
 
  // 16. 返回watcher实例,允许外部控制监视过程
  return watcher
}
 
// 17. 非监视模式:使用rolldown进行单次构建
// write or generate files with rolldown
const { rolldown } = await import('rolldown')  // 动态导入rolldown函数
startTime = Date.now()  // 记录构建开始时间
 
// 18. 使用配置创建rolldown实例
bundle = await rolldown(rollupOptions)
 
// 19. 创建一个数组来存储构建输出结果
const res: RolldownOutput[] = []
 
// 20. 遍历所有输出配置(可能有多个输出配置)
for (const output of arraify(rollupOptions.output!)) {
  // 21. 根据options.write决定是写入文件还是仅生成输出内容
  // 如果options.write为true则调用bundle.write(),否则调用bundle.generate()
  res.push(await bundle[options.write ? 'write' : 'generate'](output))
}
   }

3. 性能优化关键点

●  单一引擎架构:开发与生产环境统一使用 Rolldown 处理依赖解析与模块转换,避免双工具链的数据重复序列化/反序列化开销。

●  Oxc 工具集集成:替代 esbuild 处理代码解析与转译,内存使用效率提升显著,大型项目构建内存占用可减少 100 倍。

 

5.4.3. 多环境适配流程(核心功能升级)

Vite 7 稳定了多环境 API,支持浏览器、Node、边缘服务器等多运行时目标

 

关键逻辑与源码通过代理将rollup转发到rolldown处理

export function setupRollupOptionCompat<
  T extends Pick<BuildEnvironmentOptions, 'rollupOptions' | 'rolldownOptions'>,
>(
  buildConfig: T,
): asserts buildConfig is T & {
  rolldownOptions: Exclude<T['rolldownOptions'], undefined>
} {
  // if both rollupOptions and rolldownOptions are present,
  // ignore rollupOptions and use rolldownOptions
   // 如果同时存在rollupOptions和rolldownOptions,忽略rollupOptions
  buildConfig.rolldownOptions ??= buildConfig.rollupOptions
 
  // proxy rolldownOptions to rollupOptions
  // 通过代理将rollupOptions的访问转发到rolldownOptions
  Object.defineProperty(buildConfig, 'rollupOptions', {
    get() {
      return buildConfig.rolldownOptions
    },
    set(newValue) {
      buildConfig.rolldownOptions = newValue
    },
    configurable: true,
    enumerable: true,
  })
}
 

 

5.4、核心流程总览流程图

image.png

5.5、源码学习建议

  1. 入门路径:从 vite dev 启动流程切入,先理解 createServer 函数的整体逻辑,再深入中间件与模块图实现。

  2. 核心突破点:重点分析 transformMiddleware 如何实现按需编译,以及 ModuleGraph 如何维护依赖关系。

  3. 调试技巧:通过 DEBUG=vite:* vite dev 打印调试日志,追踪请求处理与 HMR 触发流程。

4 . 拥抱ai 配合aiide做源码解析梳理核心流程细节

5 . 先脉络后细节

前端实现 Server-Sent Events 全解析:从代码到调试的实战指南

作者 Amy_yang
2025年11月21日 16:51

什么是 Server-Sent Events

当你在使用 ChatGPT 等 AI 对话产品时,是否注意到回答内容会逐字出现在屏幕上?这种"打字机"效果背后,很可能就是 Server-Sent Events(SSE)技术在发挥作用。与传统的 AJAX 请求不同,SSE 允许服务器在建立一次连接后持续向客户端推送数据,特别适合需要实时更新的场景。

SSE 本质上是一种基于 HTTP 的 server push 技术,它通过特殊的 text/event-stream 响应类型,让服务器能够随时向客户端发送数据。与 WebSocket 相比,SSE 具有实现简单轻量级自动重连等优势,非常适合单向的实时数据推送场景。

image.png

SSE 前端实现核心代码解析

API 封装层设计

我们先来看最上层的 API 封装。下面这段代码定义了一个 questionAPI 函数,它是与 SSE 服务端交互的入口:

// 智能问答 API 封装
export const questionAPI = (data, { onMessage, onComplete }) => {
  return request({
    method: "post",
    url: "/chats",
    data: data,
    isSSE: true,
    onMessage,  // 传递消息回调
    onComplete  // 传递完成回调
  });
};

这个封装有几个关键点:

  • 通过 isSSE: true 标记这是一个 SSE 请求
  • 接收 onMessage 和 onComplete 两个回调函数
  • 与普通 API 调用方式保持一致,降低使用门槛

请求层实现

接下来是底层的 request 函数实现,这是处理 SSE 的核心部分:

该部分封装用的是uni.request,这部分根据你的实际开发环境来替换。逻辑是不变得

return new Promise((resolve, reject) => {
  if (options.isSSE) {
    let buffer = "";  // 缓存未完整的数据块
    const requestTask = uni.request({
        url: "xxx" + options.url,
      method: options.method,
      data: options.data || options.params,
      header: {
        "Content-Type": "application/json",
        "Accept": "text/event-stream",  // SSE 必需请求头
        ...(token && { token }),
      },
      enableChunked: true,  // 开启分块传输
      responseType: "stream",  // 流类型响应
      success: (res) => {
        console.log("SSE 请求完成", res);
      },
      fail: (err) => {
        console.error("SSE 请求失败", err);
        uni.hideLoading();
        reject(err);
      },
      complete: () => {
        console.log("SSE 请求结束");
        uni.hideLoading();
        // 处理缓冲区剩余数据
        if (buffer.trim()) {
          if (options.onMessage) {
            options.onMessage(buffer);
          }
        }
        if (options.onComplete) {
          options.onComplete();
        }
        resolve();
      },
    });

    // 监听分块数据接收
    requestTask.onChunkReceived((res) => {
      uni.hideLoading();
      try {
        // 将二进制数据解码为文本
        const chunk = new TextDecoder().decode(new Uint8Array(res.data));
        buffer += chunk;  // 追加到缓冲区

        // 按 SSE 格式分割数据(双换行符分隔)
        const messages = buffer.split("\n\n");
        // 保留最后一个可能不完整的消息
        buffer = messages.pop() || "";

        // 处理完整的消息
        messages.forEach((message) => {
          if (message.trim()) {
            // 触发回调函数
            if (options.onMessage) {
              options.onMessage(message);
            }
          }
        });
      } catch (error) {
        console.error("解析 SSE 数据失败:", error);
      }
    });
  } else {
    // 普通请求处理逻辑...
  }
});

这段代码实现了 SSE 的核心功能,主要包括:

  1. 设置正确的请求头:Accept: "text/event-stream" 是 SSE 的标志性请求头
  2. 开启分块传输:enableChunked: true 确保能够接收流式数据
  3. 分块数据处理:通过 onChunkReceived 事件监听数据块到达
  4. 数据缓冲区:使用 buffer 变量处理可能被分割的不完整数据块
  5. SSE 格式解析:按双换行符 \n\n 分割完整的 SSE 消息

业务逻辑层调用

最后是在 Vue 组件中如何使用这个 API:

questionAPI(
  {
    user: user,
    session_id: questionContent.value.session_id,
    messages: [
      {
        role: "user",
        content: type === "otherQuestions" ? item : sendMessage.value,
      },
    ]
  },
  {
    onMessage: async (message) => {
      console.log("SSE 流式数据:", message);
      await handleSSEMessage(message, fullContentRef, messageIndex);
    },
    onComplete: async () => {
      console.log("SSE 流式传输完成");
      // 处理最终内容渲染
      await immediateRender(async () => {
        if (questionContent.value.messages[messageIndex]) {
          questionContent.value.messages[messageIndex].content =
            await renderMarkdown(fullContentRef.value);
          scrollToBottom(questionContent.value.messages);
        }
      });
      sendMessage.value = "";
      anserLoading.value = false;
    },
  }
).catch((e) => {
  console.log("AI报错", e);
  messageDialog.value?.showMessage(
    e.message || "AI 请求失败,请重试",
    "error"
  );
  anserLoading.value = false;
});

在业务层,我们主要关注:

  • 传递请求参数(用户信息、问题内容等)
  • 实现 onMessage 回调处理流式数据
  • 实现 onComplete 回调处理流结束逻辑
  • 错误处理和加载状态管理

Markdown渲染集成

renderMarkdown函数实现

import marked from "marked";

const renderMarkdown = async (item) => {
  try {
    const html = await marked.parse(item, {
      gfm: true,
      breaks: false,
      pedantic: false,
    });
    return html;
  } catch (err) {
    console.error("Markdown 渲染失败:", err);
    return `渲染错误: ${err.message}`;
  }
};
  

为什么 onMessage 回调不执行

许多开发者在实现 SSE 时都会遇到 onMessage 回调不执行的问题。结合上述代码,我们来分析可能的原因和解决方案。

1. 请求头设置不正确

问题:缺少 Accept: "text/event-stream" 请求头,或设置了错误的 Content-Type。

解决:确保请求头包含:

header: {
  "Content-Type": "application/json",
  "Accept": "text/event-stream", // 这个请求头至关重要
}

2. 分块传输未启用

问题:未设置 enableChunked: true,导致无法接收流式数据。

解决:在 uni.request 中显式开启分块传输:

enableChunked: true,  // 必须开启分块传输
responseType: "stream",  // 流类型响应

3. 数据解析逻辑错误

问题:缓冲区处理不当,导致消息无法正确分割。

解决:检查缓冲区处理逻辑:

// 正确的消息分割逻辑
const messages = buffer.split("\n\n");
buffer = messages.pop() || "";
messages.forEach((message) => {
  if (message.trim()) {
    options.onMessage(message);
  }
});

4. 服务端数据格式错误

问题:服务端返回的不是标准的 SSE 格式数据。

解决:使用浏览器开发者工具的 Network 面板检查响应:

  • 确认响应头 Content-Type 为 text/event-stream
  • 确认响应体格式符合 SSE 规范
  • 检查是否有跨域等网络问题

5. 回调函数传递错误

问题:API 封装或调用时,回调函数传递路径不正确。

解决:跟踪回调函数的传递路径,确保:

// API封装时正确接收回调
export const questionAPI = (data, { onMessage, onComplete }) => {
  return request({
    // ...其他参数
    onMessage,  // 正确传递回调
    onComplete
  });
};

SSE 调试技巧与工具

浏览器开发者工具

现代浏览器的开发者工具提供了对 SSE 的良好支持:

  1. Network 面板

    • 找到类型为 event-stream 的请求
    • 查看 "Response" 标签可实时看到 SSE 数据流
    • "Headers" 标签可检查请求头和响应头是否正确
  2. Console 面板

    • 使用 console.log 打印原始数据块
    • 记录缓冲区状态变化

实用调试代码片段

在 onChunkReceived 回调中添加详细日志:

// 调试用:打印接收到的原始数据
console.log("原始数据块:", chunk);
// 调试用:打印缓冲区状态
console.log("缓冲区状态:", buffer);
// 调试用:打印分割后的消息数量
console.log("分割出的消息数:", messages.length);

常见问题排查清单

  1. 网络层面

    • 确认服务端是否支持 CORS
    • 检查请求是否成功建立连接
    • 查看响应状态码是否为 200
  2. 数据层面

    • 确认服务端是否持续发送数据
    • 检查数据格式是否符合 SSE 规范
    • 验证消息分隔符是否正确
  3. 代码层面

    • 检查回调函数是否正确传递
    • 确认分块处理逻辑是否正确
    • 验证错误处理是否覆盖所有情况

SSE vs WebSocket:如何选择

SSE 和 WebSocket 都可以实现实时通信,但它们各有适用场景:

表格

复制

特性 Server-Sent Events WebSocket
协议 HTTP 独立的 WebSocket 协议
连接 单向(服务器到客户端) 双向
开销
实现复杂度 简单 复杂
自动重连 内置支持 需要手动实现
数据格式 文本(UTF-8) 二进制/文本

选择建议

  • 如果你需要单向实时更新(如股票行情、新闻推送、AI 对话),选择 SSE
  • 如果你需要双向实时通信(如在线游戏、即时通讯),选择 WebSocket
  • 如果你希望快速开发兼容性好,选择 SSE
  • 如果你需要全双工通信高性能,选择 WebSocket

总结与展望

Server-Sent Events 是一种简单而强大的实时通信技术,特别适合需要服务器向客户端单向推送数据的场景。通过本文的代码解析,我们了解了如何在 Vue 项目中实现 SSE,包括 API 封装、分块处理和回调机制等核心要点。

随着 AI 应用的普及,SSE 技术将在更多场景中发挥重要作用。掌握 SSE 的前端实现,不仅能帮助我们构建更好的用户体验,也能为理解更复杂的实时通信技术打下基础。

希望本文能帮助你解决 SSE 实现中的困惑,如果你有其他问题或更好的实践经验,欢迎在评论区分享!

手把手打通 H5 多支付通道(Apple pay、Google pay、第三方卡支付)

2025年11月21日 16:51

前言

本文只聚焦支付逻辑:在一个 Vue 组件里同时兼容 Apple Pay、Google Pay 与钱海信用卡(Oceanpayment)。核心思路是“分三条链路获取 quickpayId,最后统一交给 processPayment”。


代码讲解

Apple Pay

const createApplePaySession = () => {
  const request = {
    countryCode: PAYMENT_CONFIG.applePay.countryCode,
    currencyCode: PAYMENT_CONFIG.applePay.currencyCode,
    supportedNetworks: PAYMENT_CONFIG.applePay.supportedNetworks,
    total: {
      label: PAYMENT_CONFIG.applePay.displayName,
      amount: parseFloat(currentPrice.value).toFixed(2),
      type: "final"
    }
  };

  return new Promise((resolve, reject) => {
    const session = new ApplePaySession(3, request);

    session.onvalidatemerchant = async () => {
      const resp = await getApplePayMerchantValidation({
        initiative: "web",
        initiativeContext: "h5.tanlinkerp.cn",
        merchantIdentifier: PAYMENT_CONFIG.applePay.merchantId
      });
      session.completeMerchantValidation(
        typeof resp.data === "string" ? JSON.parse(resp.data) : resp.data
      );
    };

    session.onpaymentauthorized = event => {
      const token = event.payment?.token?.paymentData;
      session.completePayment(token ? ApplePaySession.STATUS_SUCCESS : ApplePaySession.STATUS_FAILURE);
      token ? resolve(token) : reject(new Error("Invalid payment data"));
    };

    session.oncancel = () => reject(new Error("User cancelled"));
    session.onerror = err => reject(new Error(err.message));

    session.begin();
  });
};

图示说明:这段代码做两件事——1)在 onvalidatemerchant 里向后端要 merchantSession;2)在 onpaymentauthorized 中拿到 paymentData 并返回。

  • 拿到 paymentData 后,调用 getGoogleOrAppleBindCard({ methods: "ApplePay", payAccountNumber: token })
  • 接口返回 quickpayId,再执行 processPayment(quickpayId, "Apple Pay")

Google Pay

const createGooglePaySession = async () => {
  const paymentsClient = new google.payments.api.PaymentsClient({
    environment: PAYMENT_CONFIG.googlePay.environment
  });

  const paymentDataRequest = {
    apiVersion: 2,
    allowedPaymentMethods: [{
      type: "CARD",
      parameters: {
        allowedAuthMethods: ["PAN_ONLY", "CRYPTOGRAM_3DS"],
        allowedCardNetworks: ["MASTERCARD", "VISA"]
      },
      tokenizationSpecification: {
        type: "PAYMENT_GATEWAY",
        parameters: { gateway: "example", gatewayMerchantId: "exampleGatewayMerchantId" }
      }
    }],
    transactionInfo: {
      totalPriceStatus: "FINAL",
      totalPrice: parseFloat(currentPrice.value).toFixed(2),
      currencyCode: "USD"
    },
    merchantInfo: PAYMENT_CONFIG.googlePay
  };

  const paymentData = await paymentsClient.loadPaymentData(paymentDataRequest);
  return paymentData.paymentMethodData.tokenizationData.token;
};
  • Google Pay 只需要 loadPaymentData,但在展示入口之前最好调用 paymentsClient.isReadyToPay() 过滤不可用设备。
  • 获取的 tokengetGoogleOrAppleBindCard({ methods: "GooglePay" }),返回 quickpayId 后仍旧调用 processPayment(quickpayId, "Google Pay")

钱海信用卡

const fetchUserCards = async () => {
  const response = await getUserCards({ country: userCountry.value });
  if (response.code === 200) {
    userCards.value = response.data;
    selectedCard.value = 0;
    selectedPayment.value = "credit";
  }
};

const handleSubmit = async () => {
  if (selectedPayment.value === "credit") {
    const quickpayId = userCards.value[selectedCard.value]?.quickPayId;
    await processPayment(quickpayId, "Credit Card");
  }
};
  • 信用卡 quickpayId 来自绑卡页(Oceanpayment SDK),支付页只负责取值并回传后端。
  • processPayment 内部会拼 payDetailIdbackUrlpayMethod,调用 getSubAndZf。返回 payUrl 时直接 window.location.href = payUrl,否则跳 paymentStatus

关键知识点

  1. 统一出口:所有方式都必须拿到 quickpayId 才能进入 processPayment,便于维护和埋点。
  2. 安全检查:Apple/Google Pay 均需 HTTPS 或安全上下文,并提前检测 ApplePaySessiongoogle.payments 是否存在。
  3. 倒计时 + 状态恢复startCountdown 限制 15 分钟窗口,sessionStorage.paymentData 保存 payDetailId 与金额,用于刷新/跳转后的恢复。
  4. SDK 加载:Google Pay 通过 loadSDKs 按需注入脚本;Apple Pay 无需额外脚本,但要处理商户验证超时。

最佳实践 / 扩展方案

  • 错误分类:针对“用户取消”“商户验证失败”“绑定失败”分别提示并上报,方便排查。
  • 动态排序:根据设备或 userCountry 调整支付方式顺序,例如 iOS 优先展示 Apple Pay。
  • 离线兜底:倒计时结束时提示用户联系人工,避免订单遗失。
  • 监控指标:在 selectPaymenthandleSubmitprocessPayment 打点,分析各通道转化。

小结

整合多支付的关键是把 Apple Pay、Google Pay、钱海信用卡看作三条“获取 quickpayId 的管道”。一旦拿到 quickpayId,就能统一交给 processPayment,并享受相同的倒计时、状态恢复和跳转逻辑。需要扩展新的支付方式时,遵循这套模式即可快速接入。

续集:Vite 字体插件重构之路 —— 从“能用”到“生产级稳定”

2025年11月21日 16:45

续集:Vite 字体插件重构之路 —— 从“能用”到“生产级稳定”

前言: 在上一篇文章《把 16MB 中文字体压到 400KB:我写了一个 Vite 字体子集插件》中,我分享了如何通过自研插件 @fe-fast/vite-plugin-font-subset 解决中文字体体积过大的问题。

文章发出后,不少朋友试用并反馈了意见。更重要的是,在实际生产环境的复杂构建链路中,我发现 v0.1 版本存在一些设计上的“硬伤”。

开源不仅仅是发布代码,更是一个持续迭代的过程。今天这篇“续集”,就来聊聊这次 v0.3.x 重构背后的故事:如何解决路径 404 问题、生命周期竞态,以及如何更优雅地接入 Vite 构建流。

一、遇到的问题:本地跑得欢,上线 404

在 v0.1 版本中,我的实现逻辑非常简单粗暴:

  1. 插件运行,调用 subset-font 生成 .woff2 文件。
  2. 使用 fs.writeFileSync 直接把文件写到 src/assets/fonts/subset 目录下。
  3. 生成一个 font.css,里面写着 src: url('./subset/xxx.woff2')
  4. 用户手动引入这个 CSS。

看起来没问题?但在真实项目中,这个方案有两个致命缺陷:

1. 路径引用的“相对论”陷阱

当我们在开发环境(vite dev)时,文件都在本地磁盘上,相对路径 url('./subset/...') 能正常工作。 但在 vite build 生产构建时,Vite 会把 CSS 内联到 HTML 中,或者打包成独立的 CSS 文件放在 assets 目录深处。 一旦目录结构发生变化(例如设置了 base: '/app/'),原本写死的相对路径就会失效,导致浏览器报 404 Not Found

2. “脏”文件与构建副作用

直接用 fs 模块往 src 目录写文件,是一种“副作用”很强的做法:

  • 这些生成的临时文件会被 Git 识别,污染工作区。
  • 它们没有经过 Vite 的资源处理管道(Asset Pipeline),导致文件名没有 Hash(缓存问题),也不会出现在 manifest.json 中。

二、重构核心:拥抱 Vite/Rollup 标准流

为了解决上述问题,我决定对插件进行彻底重构。核心目标是:不再手动写磁盘,而是把资源“交给” Vite 处理。

1. 弃用 fs.write,改用 emitFile

Vite(基于 Rollup)提供了一个标准的 API this.emitFile,专门用于在构建过程中发射文件。

Before (v0.1):

// ❌ 坏味道:直接操作磁盘,脱离构建流
fs.writeFileSync(outputPath, subsetBuffer);

After (v0.3):

// ✅ 最佳实践:告诉构建工具“我有一个文件要打包”
this.emitFile({
  type: 'asset',
  fileName: 'assets/fonts/my-font.woff2',
  source: subsetBuffer
});

这样做的好处是,Vite 会自动处理这些文件,把它们放到正确的 dist 目录,并且我们可以利用 Vite 的机制来处理路径。

2. 解决生命周期的“竞态问题”

在重构过程中,我踩了一个深坑:生命周期执行顺序

我最初想在 transformIndexHtml 钩子(处理 HTML 时)去注入 CSS 标签。但是,字体子集化是一个耗时操作(CPU 密集型),如果放在错误的钩子执行,会导致 HTML 已经处理完了,字体还没生成好。

最终的架构方案:

  • buildStart 阶段: 这是构建开始的最早阶段。我在这里执行最耗时的“字体子集化”和“字符扫描”工作。计算出所有的 Hash 文件名,准备好要发射的数据。 为什么在这里? 确保后续任何钩子执行时,数据都已经准备就绪。

  • generateBundle 阶段: 在这里统一调用 emitFile 发射所有字体文件和生成的 CSS 文件。

  • transformIndexHtml 阶段: 因为在 buildStart 阶段我们已经计算好了最终的文件名(包含 Hash),所以在这里可以直接生成 <link rel="stylesheet"> 标签并注入到 HTML 的 <head> 中。

3. 自动注入:从“手动挡”变“自动挡”

v0.1 版本需要用户手动在 main.tsimport './subset/font.css'。 现在,得益于对 transformIndexHtml 的利用,插件默认开启 injectCss: true

用户什么都不用做,构建完成后,index.html 里会自动多出一行:

<link rel="stylesheet" href="/assets/fonts/font-a1b2c3d4.css">

这不仅省事,更重要的是路径绝对正确。插件会根据 Vite 配置的 base 自动拼接路径,无论你部署在根目录还是子目录,都能完美加载。

三、代码细节:一个小 Bug 的教训

在 v0.3.1 的迭代中,我还修复了一个变量作用域的小 Bug。

Bug 现场:

// ❌ 错误代码
const result = await processFont(...)
// 这里试图解构 fontPath,但 processFont 返回值里漏传了这个字段
const { fontPath } = result 
console.log(path.basename(fontPath)) // -> undefined

修复与优化: 在 v0.3.3 中,我不仅修复了变量传递,还反思了一下:为什么需要传递这个变量? 其实 cssEntry 对象里已经包含了 relativePath,完全可以通过它推导出文件名。于是我删除了冗余的 fontPath 返回值,让数据流更清晰。

// ✅ 优化后:减少冗余数据传递
const { cssEntry } = result
// 直接从已有数据推导
const fileName = path.basename(cssEntry.relativePath)

这提醒我们:重构不仅是改架构,更是清理冗余逻辑的好时机。

四、v0.3.x 版本的新特性总结

经过这次“伤筋动骨”的重构,@fe-fast/vite-plugin-font-subset 现在具备了生产级的能力:

  1. 零配置自动注入:默认自动生成 CSS 并注入 HTML,无需手动 import。
  2. 构建产物纯净:不再污染 src 源码目录,所有产物直接进入 dist
  3. 路径安全:完美支持 Vite 的 base 配置,支持 CDN 部署路径。
  4. Hash 缓存友好:生成的文件名带有内容 Hash,利于浏览器长效缓存。

五、写在最后

从 v0.1 到 v0.3,代码量增加了不少,但使用者的心智负担却降低了。

做开源项目往往就是这样:把复杂留给自己,把简单留给用户。 最初只是为了解决自己项目的 16MB 字体问题,现在它已经变成了一个更加健壮的通用解决方案。

如果你也在使用 Vite 开发中文项目,深受字体体积困扰,欢迎尝试一下这个插件,也欢迎在 GitHub 上提 Issue 或 PR,我们一起把它打磨得更好。

GitHub: github.com/william-xue… NPM: npm install @fe-fast/vite-plugin-font-subset -D


下一阶段计划: 目前插件主要针对 Build 阶段优化。接下来我可能会探索一下如何在 Dev 开发阶段提供更好的体验(比如利用缓存避免重复构建),敬请期待!

初识 ACP (Agent Client Protocol)

作者 魁首
2025年11月21日 16:41

初识 ACP 协议:AI 编码助手的标准化通信协议

从 MCP 到 ACP,探索 AI Agent 生态的标准化之路

目录


一、引言:AI Agent 生态的标准化挑战

1.1 当前 AI 开发工具面临的问题

在 AI 辅助编程工具快速发展的今天,我们看到了各种强大的 AI 编码助手:

  • GitHub Copilot:微软的 AI 代码补全工具
  • Cursor:AI 驱动的代码编辑器
  • Claude Code:Anthropic 的智能编码助手
  • Codex CLI:OpenAI 的命令行编码工具
  • Gemini Code Assist:Google 的编码助手

然而,这些工具之间存在严重的互操作性问题

graph TB
    subgraph "现状:信息孤岛"
        VSCode[VS Code] -->|锁定| Copilot[Copilot]
        Zed[Zed] -->|锁定| Agent[Agent]

        Claude[Claude]
        Gemini[Gemini]

        VSCode -.X.- Claude
        VSCode -.X.- Gemini
        Zed -.X.- Claude
        Zed -.X.- Copilot

        style VSCode fill:#e1f5ff
        style Zed fill:#e1f5ff
        style Copilot fill:#fff4e6
        style Agent fill:#fff4e6
        style Claude fill:#f3e5f5
        style Gemini fill:#f3e5f5
    end

    Note["每个编辑器只能用特定的 Agent<br/>用户无法自由选择和切换"]

    style Note fill:#ffebee,stroke:#c62828

核心挑战:

  1. 编辑器锁定:用户必须为特定 AI Agent 切换编辑器
  2. 重复开发:每个编辑器都要为每个 Agent 单独开发集成
  3. 用户体验割裂:不同 Agent 的交互方式完全不同
  4. 生态碎片化:难以形成统一的开发者社区

1.2 标准化协议的价值

正如 Language Server Protocol (LSP) 将语言智能从单一 IDE 中解放出来,我们需要一个类似的标准来解决 AI Agent 的互操作性问题。

这就是 Agent Client Protocol (ACP) 诞生的背景。


二、从 MCP 说起:理解 AI 协议的演进

2.1 什么是 MCP?

在介绍 ACP 之前,我们需要先了解 MCP (Model Context Protocol)

MCP 是 Anthropic 推出的开源标准协议,用于连接 AI 模型外部系统(数据源、工具、API 等)。

graph TD
    AIModel["AI Model<br/>(Claude, GPT, etc.)"]

    AIModel -->|MCP Protocol| MCPServers

    subgraph MCPServers["MCP Servers"]
        DB[Database Server]
        FS[File System]
        API[API Services]
        KB[Knowledge Base]
    end

    style AIModel fill:#e3f2fd
    style MCPServers fill:#f3e5f5
    style DB fill:#fff3e0
    style FS fill:#fff3e0
    style API fill:#fff3e0
    style KB fill:#fff3e0

MCP 的三大核心原语:

2.1.1 Resources(资源)

类似文件系统的只读数据源,供 AI 模型读取上下文。

// MCP Resource 示例
{
  "uri": "file:///workspace/README.md",
  "name": "项目文档",
  "mimeType": "text/markdown",
  "description": "项目需求和架构文档"
}
2.1.2 Tools(工具)

AI 模型可调用的可执行函数。

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("code-tools")

@mcp.tool()
async def run_tests(test_file: str) -> str:
    """运行指定的测试文件"""
    result = subprocess.run(['pytest', test_file], capture_output=True)
    return result.stdout.decode()
2.1.3 Prompts(提示模板)

预编写的任务模板,标准化常见操作。

{
  "name": "code_review",
  "description": "代码审查提示模板",
  "arguments": [
    {
      "name": "language",
      "description": "编程语言",
      "required": true
    }
  ]
}

2.2 MCP 的局限性

虽然 MCP 解决了 AI 模型与工具的连接问题,但它并不解决编辑器与 AI Agent 的通信问题

graph LR
    Editor["编辑器<br/>(Zed)"] -.->|"❓ 没有标准协议"| Agent["AI Agent<br/>(Claude)"]
    Agent -->|"✓ MCP 协议"| Tools["工具<br/>(DB/API)"]

    style Editor fill:#ffebee
    style Agent fill:#e8f5e9
    style Tools fill:#e8f5e9

这就是 ACP 要解决的问题。


三、ACP 是什么?

3.1 定义

Agent Client Protocol (ACP) 是一个开放标准协议,用于规范代码编辑器与 **AI 编码助手(Coding Agent)**之间的通信。

graph LR
    Editor["Editor<br/>(Zed)"]
    Agent["Agent<br/>(Claude)"]
    Tools["Tools & Resources"]

    Editor <-->|ACP Protocol| Agent
    Agent -->|MCP Protocol| Tools

    style Editor fill:#e3f2fd
    style Agent fill:#fff3e0
    style Tools fill:#e8f5e9

核心理念:

就像 USB-C 接口可以连接任何设备,ACP 让任何编辑器都能使用任何 AI Agent。

3.2 设计目标

目标 说明
通用性 任何编辑器都能集成任何符合 ACP 的 Agent
隐私优先 本地通信,不经过第三方服务器
开源开放 Apache 2.0 许可证,任何人都可以实现
可扩展性 支持未来的新功能和新场景

3.3 与 LSP 的类比

如果你熟悉 Language Server Protocol (LSP),可以这样理解 ACP:

LSP 之于语言智能 = ACP 之于 AI 编码助手

graph LR
    subgraph LSP["LSP 模式"]
        E1[VS Code] <-->|LSP| L1[TypeScript]
        E2[Vim] <-->|LSP| L2[Python]
        E3[Emacs] <-->|LSP| L3[Go]
    end

    subgraph ACP["ACP 模式"]
        E4[Zed] <-->|ACP| A1[Claude Code]
        E5[Neovim] <-->|ACP| A2[Gemini]
        E6[JetBrains] <-->|ACP| A3[Codex]
    end

    style LSP fill:#e3f2fd
    style ACP fill:#fff3e0

四、ACP 核心架构设计

4.1 通信模型

ACP 采用 JSON-RPC 2.0 协议,基于 **stdio(标准输入输出)**进行通信。

graph TD
    Editor["Editor<br/>(主进程)"]
    Agent["Agent<br/>(子进程)"]

    Editor -->|"spawn()"| Agent
    Editor -->|"写入 stdin<br/>(JSON-RPC 2.0)"| Agent
    Agent -->|"写入 stdout<br/>(JSON-RPC 2.0)"| Editor

    style Editor fill:#e3f2fd
    style Agent fill:#fff3e0

    Note["通信方式:<br/>• Editor 写入 Agent 的 stdin<br/>• Agent 写入 stdout 返回给 Editor<br/>• 消息格式:JSON-RPC 2.0"]
    style Note fill:#e8f5e9

优势:

  1. 简单高效:无需网络层,直接进程间通信
  2. 隐私安全:所有数据都在本地,不经过外部服务器
  3. 跨平台:stdin/stdout 是所有操作系统的标准

4.2 协议层次

ACP 分为两个核心层:

graph TD
    subgraph Application["应用层 (Application Layer)"]
        SM[Session Management]
        TC[Tool Calls]
        PR[Permission Requests]
        FO[File Operations]
    end

    subgraph Protocol["协议层 (Protocol Layer)"]
        JSON[JSON-RPC 2.0]
        RR[Request/Response]
        NT[Notifications]
        EH[Error Handling]
    end

    subgraph Transport["传输层 (Transport Layer)"]
        STDIO[stdio - stdin/stdout]
    end

    Application --> Protocol
    Protocol --> Transport

    style Application fill:#e3f2fd
    style Protocol fill:#fff3e0
    style Transport fill:#e8f5e9

4.3 消息类型

ACP 支持三种消息类型:

4.3.1. Request(请求)

客户端向服务器发送请求,期待响应。

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "0.1.0",
    "clientInfo": {
      "name": "Zed",
      "version": "0.158.0"
    }
  }
}
4.3.2. Response(响应)

服务器对请求的响应。

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "0.1.0",
    "serverInfo": {
      "name": "Claude Code",
      "version": "1.0.0"
    },
    "capabilities": {
      "tools": true,
      "resources": true
    }
  }
}
4.3.3. Notification(通知)

单向消息,不期待响应。

{
  "jsonrpc": "2.0",
  "method": "session/update",
  "params": {
    "sessionId": "session-123",
    "update": {
      "sessionUpdate": "agent_message_chunk",
      "content": {
        "type": "text",
        "text": "正在分析代码..."
      }
    }
  }
}

五、ACP 协议详解

5.1 核心方法

ACP 定义了一系列标准方法:

方法 类型 说明
initialize Request 初始化连接,交换能力信息
authenticate Request 身份验证(可选)
session/new Request 创建新的对话会话
session/prompt Request 向 Agent 发送用户消息
session/update Notification Agent 推送会话更新
session/request_permission Notification Agent 请求用户权限
fs/read_text_file Request 读取文件内容
fs/write_text_file Request 写入文件内容
end_turn Notification Agent 完成一轮响应

5.2 初始化流程

sequenceDiagram
    participant Editor
    participant Agent

    Editor->>Agent: spawn(agent-cli)
    Note over Editor,Agent: 启动 Agent 子进程

    Editor->>Agent: initialize request
    Note right of Editor: {clientInfo, version}

    Agent-->>Editor: initialize response
    Note left of Agent: {serverInfo, capabilities}

    Editor->>Agent: authenticate (optional)
    Agent-->>Editor: auth response

    Editor->>Agent: session/new
    Note right of Editor: {cwd, mcpServers}

    Agent-->>Editor: session created
    Note left of Agent: {sessionId}

    Note over Editor,Agent: ● 连接建立完成,可以开始对话

详细说明:

步骤 1:启动 Agent 进程
// AionUi 项目中的实际代码
// src/agent/acp/AcpConnection.ts

async connect(backend: AcpBackend, cliPath?: string, workingDir?: string) {
  const command = cliPath || this.getDefaultCliPath(backend);

  // 使用 spawn 启动 Agent 子进程
  this.agentProcess = spawn(command, [], {
    cwd: workingDir,
    env: process.env,
  });

  // 监听 stdout(Agent 的输出)
  this.agentProcess.stdout.on('data', this.handleStdout.bind(this));

  // 监听 stderr(Agent 的日志)
  this.agentProcess.stderr.on('data', this.handleStderr.bind(this));

  // 初始化协议
  await this.initialize();
}
步骤 2:发送初始化请求
private async initialize(): Promise<AcpResponse> {
  return await this.sendRequest('initialize', {
    protocolVersion: '0.1.0',
    clientInfo: {
      name: 'AionUi',
      version: '1.0.0',
    },
  });
}
步骤 3:接收能力信息
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "0.1.0",
    "serverInfo": {
      "name": "Claude Code",
      "version": "1.0.128"
    },
    "capabilities": {
      "tools": true,
      "resources": true,
      "streaming": false
    }
  }
}

5.3 会话更新类型

ACP 定义了丰富的会话更新类型,让编辑器能实时显示 Agent 的思考和操作过程。

// AionUi 项目中的类型定义
// src/types/acpTypes.ts

export type AcpSessionUpdate =
  | AgentMessageChunkUpdate // Agent 消息块
  | AgentThoughtChunkUpdate // Agent 思考过程
  | ToolCallUpdate // 工具调用
  | ToolCallUpdateStatus // 工具状态更新
  | PlanUpdate // 任务计划
  | AvailableCommandsUpdate // 可用命令列表
  | UserMessageChunkUpdate; // 用户消息块
5.3.1. Agent 消息块(AgentMessageChunkUpdate)

Agent 向用户发送的普通消息。

{
  "method": "session/update",
  "params": {
    "sessionId": "sess-123",
    "update": {
      "sessionUpdate": "agent_message_chunk",
      "content": {
        "type": "text",
        "text": "我已经分析了你的代码,发现了以下问题..."
      }
    }
  }
}
5.3.2. Agent 思考过程(AgentThoughtChunkUpdate)

Agent 的内部思考过程,类似 "思维链"。

{
  "method": "session/update",
  "params": {
    "sessionId": "sess-123",
    "update": {
      "sessionUpdate": "agent_thought_chunk",
      "content": {
        "type": "text",
        "text": "首先,我需要检查 package.json 中的依赖版本..."
      }
    }
  }
}
5.3.3. 工具调用(ToolCallUpdate)

最重要的更新类型,表示 Agent 要执行某个操作。

interface ToolCallUpdate {
  sessionUpdate: 'tool_call';
  toolCallId: string; // 工具调用唯一 ID
  status: 'pending' | 'in_progress' | 'completed' | 'failed';
  title: string; // 操作描述
  kind: 'read' | 'edit' | 'execute'; // 操作类型
  rawInput?: any; // 原始输入参数
  content?: Array<{
    type: 'content' | 'diff';
    // ... 内容详情
  }>;
  locations?: Array<{
    path: string; // 受影响的文件路径
  }>;
}

示例:读取文件

{
  "method": "session/update",
  "params": {
    "sessionId": "sess-123",
    "update": {
      "sessionUpdate": "tool_call",
      "toolCallId": "tool-001",
      "status": "pending",
      "title": "读取 src/index.ts",
      "kind": "read",
      "locations": [{ "path": "/workspace/src/index.ts" }]
    }
  }
}

示例:编辑文件

{
  "method": "session/update",
  "params": {
    "sessionId": "sess-123",
    "update": {
      "sessionUpdate": "tool_call",
      "toolCallId": "tool-002",
      "status": "in_progress",
      "title": "修复 TypeScript 类型错误",
      "kind": "edit",
      "content": [
        {
          "type": "diff",
          "diff": "--- a/src/index.ts\n+++ b/src/index.ts\n@@ -10,7 +10,7 @@\n-function add(a, b) {\n+function add(a: number, b: number): number {\n   return a + b;\n }"
        }
      ],
      "locations": [{ "path": "/workspace/src/index.ts" }]
    }
  }
}
5.3.4. 计划更新(PlanUpdate)

Agent 的任务执行计划。

{
  "method": "session/update",
  "params": {
    "sessionId": "sess-123",
    "update": {
      "sessionUpdate": "plan",
      "entries": [
        {
          "content": "分析现有代码结构",
          "status": "completed"
        },
        {
          "content": "识别类型错误位置",
          "status": "in_progress"
        },
        {
          "content": "修复类型定义",
          "status": "pending",
          "priority": "high"
        },
        {
          "content": "运行 TypeScript 编译检查",
          "status": "pending"
        }
      ]
    }
  }
}

5.4 权限请求机制

ACP 的一个重要安全特性是权限请求机制。Agent 在执行敏感操作前必须获得用户许可。

sequenceDiagram
    participant Agent
    participant Editor
    participant User

    Agent->>Editor: session/request_permission
    Note right of Agent: {toolCall, options}

    Editor->>User: 显示对话框
    Note right of Editor: 用户选择:<br/>• 仅此一次允许<br/>• 始终允许<br/>• 拒绝

    User-->>Editor: 选择权限选项
    Editor-->>Agent: permission response
    Note left of Editor: {optionId: "allow_once"}

权限请求消息格式:

interface AcpPermissionRequest {
  sessionId: string;
  options: Array<{
    optionId: string;
    name: string;
    kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always';
  }>;
  toolCall: {
    toolCallId: string;
    title: string;
    kind: 'read' | 'edit' | 'execute';
    content?: Array<any>;
    locations?: Array<{ path: string }>;
  };
}

示例:请求写入文件权限

{
  "method": "session/request_permission",
  "params": {
    "sessionId": "sess-123",
    "options": [
      {
        "optionId": "allow_once",
        "name": "仅此一次允许",
        "kind": "allow_once"
      },
      {
        "optionId": "allow_always",
        "name": "始终允许对此文件的写入",
        "kind": "allow_always"
      },
      {
        "optionId": "reject",
        "name": "拒绝",
        "kind": "reject_once"
      }
    ],
    "toolCall": {
      "toolCallId": "tool-003",
      "title": "写入文件 src/config.ts",
      "kind": "edit",
      "locations": [{ "path": "/workspace/src/config.ts" }],
      "content": [
        {
          "type": "diff",
          "diff": "... (修改内容) ..."
        }
      ]
    }
  }
}

AionUi 中的权限 UI 实现:

// src/renderer/messages/acp/MessageAcpPermission.tsx

const MessageAcpPermission: React.FC<Props> = ({ message }) => {
  const handleConfirm = async (optionId: string) => {
    // 调用 IPC Bridge 确认权限
    await ipcBridge.acpConversation.confirmMessage.invoke({
      confirmKey: message.confirmKey,
      msg_id: message.msg_id,
      conversation_id: message.conversation_id,
      callId: message.toolCall.toolCallId,
    });
  };

  return (
    <div className="permission-dialog">
      <h3>{message.toolCall.title}</h3>
      <div className="options">
        {message.options.map(option => (
          <button key={option.optionId} onClick={() => handleConfirm(option.optionId)}>
            {option.name}
          </button>
        ))}
      </div>
    </div>
  );
};

六、ACP 实战:从代码看实现

让我们通过 AionUi 项目的实际代码,深入理解 ACP 的实现细节。

6.1 AcpConnection 类:协议通信层

这是 ACP 客户端的核心实现,负责与 Agent 进程的通信。

完整实现流程:

// src/agent/acp/AcpConnection.ts (605 行)

export class AcpConnection {
  private agentProcess: ChildProcess | null = null;
  private pendingRequests: Map<number, PendingRequest> = new Map();
  private requestIdCounter = 0;

  // 事件回调
  public onSessionUpdate?: (data: AcpSessionUpdate) => void;
  public onPermissionRequest?: (data: AcpPermissionRequest) => Promise<{ optionId: string }>;
  public onEndTurn?: () => void;
  public onFileOperation?: (operation: any) => void;

  /**
   * 连接到 Agent
   */
  async connect(backend: AcpBackend, cliPath?: string, workingDir?: string) {
    const command = cliPath || this.getDefaultCliPath(backend);

    // 启动 Agent 子进程
    this.agentProcess = spawn(command, [], {
      cwd: workingDir,
      env: process.env,
      stdio: ['pipe', 'pipe', 'pipe'], // stdin, stdout, stderr
    });

    // 监听输出
    this.agentProcess.stdout.on('data', this.handleStdout.bind(this));
    this.agentProcess.stderr.on('data', this.handleStderr.bind(this));

    // 初始化协议
    await this.initialize();
  }

  /**
   * 发送 JSON-RPC 请求
   */
  private async sendRequest(method: string, params: any, timeout = 60000): Promise<AcpResponse> {
    const id = ++this.requestIdCounter;

    const request: AcpRequest = {
      jsonrpc: JSONRPC_VERSION,
      id,
      method,
      params,
    };

    // 创建 Promise,等待响应
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        this.pendingRequests.delete(id);
        reject(new Error(`Request ${method} timeout after ${timeout}ms`));
      }, timeout);

      this.pendingRequests.set(id, { resolve, reject, timer });

      // 写入 Agent 的 stdin
      const message = JSON.stringify(request) + '\n';
      this.agentProcess.stdin.write(message);
    });
  }

  /**
   * 处理 Agent 的输出
   */
  private handleStdout(data: Buffer) {
    const lines = data
      .toString()
      .split('\n')
      .filter((line) => line.trim());

    for (const line of lines) {
      try {
        const message = JSON.parse(line);

        if ('id' in message && 'result' in message) {
          // Response: 匹配请求并 resolve
          const pending = this.pendingRequests.get(message.id);
          if (pending) {
            clearTimeout(pending.timer);
            pending.resolve(message);
            this.pendingRequests.delete(message.id);
          }
        } else if ('method' in message) {
          // Notification: 触发回调
          this.handleNotification(message);
        }
      } catch (error) {
        console.error('Failed to parse message:', line, error);
      }
    }
  }

  /**
   * 处理通知消息
   */
  private handleNotification(notification: AcpNotification) {
    const { method, params } = notification;

    switch (method) {
      case 'session/update':
        if (this.onSessionUpdate) {
          this.onSessionUpdate(params.update);
        }
        break;

      case 'session/request_permission':
        if (this.onPermissionRequest) {
          this.handlePermissionRequest(params);
        }
        break;

      case 'end_turn':
        if (this.onEndTurn) {
          this.onEndTurn();
        }
        break;

      case 'fs/read_text_file':
        this.handleReadOperation(params);
        break;

      case 'fs/write_text_file':
        this.handleWriteOperation(params);
        break;
    }
  }

  /**
   * 创建新会话
   */
  async newSession(cwd: string): Promise<AcpResponse> {
    return await this.sendRequest(
      'session/new',
      {
        cwd,
        mcpServers: [], // 可配置 MCP 服务器
      },
      120000
    ); // 120 秒超时
  }

  /**
   * 发送用户消息
   */
  async sendPrompt(prompt: string): Promise<AcpResponse> {
    return await this.sendRequest(
      'session/prompt',
      {
        prompt,
      },
      120000
    );
  }

  /**
   * 断开连接
   */
  async disconnect() {
    if (this.agentProcess) {
      this.agentProcess.kill();
      this.agentProcess = null;
    }
    this.pendingRequests.clear();
  }
}

6.2 AcpAgent 类:业务逻辑层

// src/agent/acp/index.ts (607 行)

export class AcpAgent {
  private connection: AcpConnection;
  private sessionId: string | null = null;
  private onStreamEvent: (event: any) => void;

  constructor(options: { id: string; backend: AcpBackend; cliPath?: string; workingDir?: string; onStreamEvent: (event: any) => void }) {
    this.onStreamEvent = options.onStreamEvent;

    // 创建连接
    this.connection = new AcpConnection();

    // 注册回调
    this.connection.onSessionUpdate = this.handleSessionUpdate.bind(this);
    this.connection.onPermissionRequest = this.handlePermissionRequest.bind(this);
    this.connection.onEndTurn = this.handleEndTurn.bind(this);
    this.connection.onFileOperation = this.handleFileOperation.bind(this);
  }

  /**
   * 启动 Agent
   */
  async start() {
    await this.connection.connect(this.backend, this.cliPath, this.workingDir);

    // 创建会话
    const response = await this.connection.newSession(this.workingDir);
    this.sessionId = response.result.sessionId;
  }

  /**
   * 发送消息
   */
  async sendMessage(data: { content: string; files?: string[]; msg_id?: string }): Promise<AcpResult> {
    try {
      await this.connection.sendPrompt(data.content);
      return { success: true };
    } catch (error) {
      return { success: false, error: error.message };
    }
  }

  /**
   * 处理会话更新
   */
  private handleSessionUpdate(update: AcpSessionUpdate) {
    // 使用适配器转换为统一格式
    const event = AcpAdapter.convertUpdate(update);

    // 触发流事件
    this.onStreamEvent(event);
  }

  /**
   * 处理权限请求
   */
  private async handlePermissionRequest(params: AcpPermissionRequest): Promise<{ optionId: string }> {
    // 创建权限消息,显示给用户
    const permissionMessage = {
      role: 'permission_request',
      options: params.options,
      toolCall: params.toolCall,
      confirmKey: generateConfirmKey(),
    };

    this.onStreamEvent(permissionMessage);

    // 等待用户响应(通过 confirmMessage 方法)
    return new Promise((resolve) => {
      this.pendingPermissionResolve = resolve;
    });
  }

  /**
   * 确认权限(用户选择后调用)
   */
  async confirmMessage(data: { confirmKey: string; msg_id: string; callId: string }) {
    // 将用户选择的 optionId 返回给 Agent
    if (this.pendingPermissionResolve) {
      this.pendingPermissionResolve({ optionId: data.confirmKey });
      this.pendingPermissionResolve = null;
    }
  }
}

6.3 前端 UI 集成

聊天界面:

// src/renderer/pages/conversation/acp/AcpChat.tsx

const AcpChat: React.FC<{
  conversation_id: string;
  workspace?: string;
  backend: AcpBackend;
}> = ({ conversation_id, workspace, backend }) => {
  return (
    <ConversationProvider value={{
      conversationId: conversation_id,
      workspace,
      type: 'acp',
    }}>
      {/* 消息列表 */}
      <MessageList />

      {/* 输入框 */}
      <AcpSendBox conversation_id={conversation_id} backend={backend} />
    </ConversationProvider>
  );
};

工具调用 UI:

// src/renderer/messages/acp/MessageAcpToolCall.tsx

const MessageAcpToolCall: React.FC<{ toolCall: ToolCallUpdate }> = ({ toolCall }) => {
  return (
    <div className="tool-call">
      {/* 工具图标 */}
      <div className="tool-icon">
        {toolCall.kind === 'read' && <FileIcon />}
        {toolCall.kind === 'edit' && <EditIcon />}
        {toolCall.kind === 'execute' && <TerminalIcon />}
      </div>

      {/* 工具标题 */}
      <div className="tool-title">{toolCall.title}</div>

      {/* 状态指示器 */}
      <div className={`tool-status tool-status-${toolCall.status}`}>
        {toolCall.status === 'pending' && <ClockIcon />}
        {toolCall.status === 'in_progress' && <SpinnerIcon />}
        {toolCall.status === 'completed' && <CheckIcon />}
        {toolCall.status === 'failed' && <XIcon />}
      </div>

      {/* 文件路径 */}
      {toolCall.locations && (
        <div className="tool-locations">
          {toolCall.locations.map(loc => (
            <span key={loc.path}>{loc.path}</span>
          ))}
        </div>
      )}

      {/* Diff 内容 */}
      {toolCall.content && toolCall.content[0]?.type === 'diff' && (
        <DiffViewer diff={toolCall.content[0].diff} />
      )}
    </div>
  );
};

6.4 IPC Bridge 集成

// src/process/bridge/acpConversationBridge.ts

export function initAcpConversationBridge() {
  // 确认权限
  ipcBridge.acpConversation.confirmMessage.provider(async ({ confirmKey, msg_id, conversation_id, callId }) => {
    const task = WorkerManage.getTaskById(conversation_id) as AcpAgentManager;
    await task.confirmMessage({ confirmKey, msg_id, callId });
    return { success: true };
  });

  // 检测可用的 Agent
  ipcBridge.acpConversation.getAvailableAgents.provider(async () => {
    const agents = acpDetector.getDetectedAgents();
    return { success: true, data: agents };
  });

  // 检测 CLI 路径
  ipcBridge.acpConversation.detectCliPath.provider(async ({ backend }) => {
    const agents = acpDetector.getDetectedAgents();
    const agent = agents.find((a) => a.backend === backend);
    return {
      success: !!agent?.cliPath,
      data: { path: agent?.cliPath },
    };
  });
}

七、ACP vs MCP:两个协议的对比与互补

7.1 核心区别

graph TB
    subgraph ACP["ACP (Agent Client Protocol)"]
        A1["作用:编辑器 ←→ AI Agent"]
        A2["场景:代码编辑、重构、调试"]
        A3["通信:双向(请求/响应 + 通知)"]
        A4["传输:stdio (本地进程通信)"]
        A5["创建者:Zed Industries"]
    end

    subgraph MCP["MCP (Model Context Protocol)"]
        M1["作用:AI 模型 ←→ 工具/资源"]
        M2["场景:数据库查询、API调用、文件系统操作"]
        M3["通信:主要是单向(模型调用工具)"]
        M4["传输:stdio / HTTP with SSE"]
        M5["创建者:Anthropic"]
    end

    style ACP fill:#e3f2fd
    style MCP fill:#fff3e0

7.2 详细对比表

维度 ACP MCP
完整名称 Agent Client Protocol Model Context Protocol
主要用途 编辑器与 AI 编码助手的通信 AI 模型与外部工具/资源的通信
通信方向 双向(编辑器 ↔ Agent) 主要单向(Model → Tools)
协议基础 JSON-RPC 2.0 over stdio JSON-RPC 2.0 over stdio/HTTP
传输方式 stdio(标准输入输出) stdio / HTTP with SSE
生命周期 编辑器启动 Agent 子进程 Host 启动 MCP Server
状态管理 有状态(会话持久化) 有状态(连接生命周期)
权限控制 内置权限请求机制 依赖 Host 实现
典型场景 代码生成、重构、调试 数据库查询、API 调用
作者 Zed Industries Anthropic
发布时间 2025 年 2024 年
开源协议 Apache 2.0 MIT

7.3 协作关系

ACP 和 MCP 不是竞争关系,而是互补关系

graph TD
    Editor["Editor<br/>(Zed)"]
    Agent["AI Agent<br/>(Claude Code)"]

    subgraph MCPServers["MCP Servers"]
        DB[Database Server]
        FS[File System]
        Git[Git Server]
        Web[Web Fetch]
        Mem[Memory/Knowledge]
    end

    Editor <-->|ACP Protocol| Agent
    Agent -->|MCP Protocol| MCPServers

    style Editor fill:#e3f2fd
    style Agent fill:#fff3e0
    style MCPServers fill:#e8f5e9

实际工作流程:

  1. 用户 在 Zed 编辑器中输入:"帮我重构这个函数,并将结果保存到数据库"
  2. Zed 通过 ACP 将消息发送给 Claude Code Agent
  3. Claude 分析代码,生成重构后的代码
  4. Claude 通过 MCP 调用 Database Server 将结果保存
  5. Claude 通过 ACP 将结果返回给 Zed
  6. Zed 在编辑器中显示重构后的代码和执行结果

7.4 在 AionUi 中的集成

AionUi 项目同时支持 ACP 和 MCP:

// src/agent/acp/AcpConnection.ts

async newSession(cwd: string): Promise<AcpResponse> {
  return await this.sendRequest('session/new', {
    cwd,
    // 可以在创建 ACP 会话时配置 MCP 服务器
    mcpServers: [
      {
        name: 'database',
        command: 'node',
        args: ['./mcp-servers/database-server.js'],
      },
      {
        name: 'git',
        command: 'node',
        args: ['./mcp-servers/git-server.js'],
      },
    ],
  });
}

这样,Claude Code Agent 就可以同时:

  • 通过 ACP 与编辑器通信
  • 通过 MCP 访问数据库、Git 等工具

八、生态系统与实际应用

8.1 支持 ACP 的编辑器

mindmap
  root((ACP 编辑器生态))
    已支持
      Zed
        官方支持
        原生集成
      Neovim
        社区插件
      JetBrains IDEs
        官方合作
      Marimo
        数据科学笔记本
    计划中
      VS Code
      Emacs
        社区开发中

Zed 的 ACP 配置示例:

// ~/.config/zed/settings.json
{
  "agents": {
    "claude": {
      "command": "claude-code",
      "args": [],
      "env": {
        "ANTHROPIC_API_KEY": "sk-ant-..."
      }
    },
    "gemini": {
      "command": "gemini-cli",
      "args": ["--model", "gemini-1.5-pro"]
    },
    "codex": {
      "command": "codex",
      "args": ["--api-key", "sk-..."]
    }
  }
}

8.2 支持 ACP 的 AI Agent

Agent 厂商 模型 特性
Claude Code Anthropic Claude 3.5 Sonnet 长上下文、思维链、代码理解强
Gemini CLI Google Gemini 1.5 Pro 多模态、快速响应
Codex CLI OpenAI GPT-4 广泛的编程语言支持
Qwen Code 阿里云 Qwen Coder 中文编程、本地化
goose Block 多模型支持 开源、可定制

8.3 实际应用场景

场景 1:代码重构

用户输入:

"将这个组件从 Class 组件重构为 Function 组件,使用 Hooks"

ACP 工作流程:

1. [Agent Message] "我会帮你重构这个组件..."

2. [Tool Call - Read]
   读取 src/components/UserList.tsx

3. [Agent Thought]
   "这是一个 Class 组件,有三个生命周期方法和一个状态..."

4. [Tool Call - Edit]
   生成重构后的代码(使用 useState、useEffect)

5. [Permission Request]
   "是否允许修改 UserList.tsx?"

6. [User] 点击"允许"

7. [Tool Call - Execute]
   运行 `npm run lint` 检查语法

8. [Agent Message]
   "重构完成!代码已通过 lint 检查。"
场景 2:Bug 修复

用户输入:

"修复这个 TypeScript 类型错误"

ACP 工作流程:

1. [Tool Call - Read]
   读取当前文件

2. [Agent Thought]
   "类型错误是因为函数返回值类型不匹配..."

3. [Plan Update]
   - [✓] 分析类型错误
   - [→] 修改函数签名
   - [ ] 添加类型注解
   - [ ] 运行 tsc 检查

4. [Tool Call - Edit]
   修改函数类型定义

5. [Tool Call - Execute]
   运行 `tsc --noEmit`

6. [Agent Message]
   "类型错误已修复!TypeScript 编译通过。"
场景 3:集成 MCP 的复杂场景

用户输入:

"从数据库中查询用户列表,生成一个 React 表格组件"

ACP + MCP 协作流程:

1. [ACP] Agent 收到请求

2. [MCP] Agent 调用 Database Server
   Tool: query_users()

3. [MCP] Database Server 返回数据
   Result: [{id: 1, name: "Alice"}, ...]

4. [ACP] Agent 思考如何生成组件

5. [ACP] Agent 创建新文件
   Tool Call: write_file("UserTable.tsx")

6. [ACP] Agent 生成组件代码
   基于数据库结构生成 TypeScript 类型

7. [ACP] Agent 运行测试
   Tool Call: execute("npm test UserTable")

8. [ACP] Agent 返回结果
   "组件已创建,所有测试通过!"

九、最佳实践与安全考量

9.1 实现 ACP Agent 的最佳实践

9.1.1. 日志处理

❌ 错误做法:

// 不要写入 stdout!
console.log('Agent is processing...');

✅ 正确做法:

// 使用 stderr 或文件日志
import fs from 'fs';

const logFile = fs.createWriteStream('/tmp/agent.log');

function log(message: string) {
  logFile.write(`[${new Date().toISOString()}] ${message}\n`);
}

log('Agent is processing...');

原因: ACP 使用 stdout 传输 JSON-RPC 消息,任何非 JSON 输出都会破坏协议。

9.1.2. 错误处理
try {
  await executeToolCall(toolCall);
} catch (error) {
  // 返回标准错误格式
  return {
    jsonrpc: '2.0',
    id: requestId,
    error: {
      code: -32603,
      message: error.message,
      data: {
        stack: error.stack,
      },
    },
  };
}
9.1.3. 超时管理
const TOOL_CALL_TIMEOUT = 30000; // 30 秒

async function executeToolCallWithTimeout(toolCall: ToolCall) {
  return Promise.race([executeToolCall(toolCall), new Promise((_, reject) => setTimeout(() => reject(new Error('Tool call timeout')), TOOL_CALL_TIMEOUT))]);
}
9.1.4. 流式响应

对于长时间的操作,使用流式更新:

async function generateCode(prompt: string) {
  // 发送进度更新
  sendNotification('session/update', {
    update: {
      sessionUpdate: 'agent_thought_chunk',
      content: { type: 'text', text: '正在分析需求...' },
    },
  });

  // 生成代码
  const code = await llm.generate(prompt);

  // 发送代码块
  sendNotification('session/update', {
    update: {
      sessionUpdate: 'agent_message_chunk',
      content: { type: 'text', text: code },
    },
  });
}

9.2 安全考量

9.2.1. 文件系统访问控制
// 定义允许的工作目录
const ALLOWED_WORKSPACE = process.env.WORKSPACE_DIR;

function validateFilePath(path: string): boolean {
  const resolvedPath = path.resolve(path);

  // 检查路径是否在允许的工作目录内
  if (!resolvedPath.startsWith(ALLOWED_WORKSPACE)) {
    throw new Error('Access denied: path outside workspace');
  }

  // 检查是否访问敏感文件
  const sensitivePatterns = ['.env', '.git/config', 'id_rsa'];
  if (sensitivePatterns.some((pattern) => resolvedPath.includes(pattern))) {
    throw new Error('Access denied: sensitive file');
  }

  return true;
}
9.2.2. 命令执行安全
// 白名单机制
const ALLOWED_COMMANDS = ['npm test', 'npm run lint', 'tsc --noEmit', 'git status'];

function validateCommand(command: string): boolean {
  return ALLOWED_COMMANDS.some((allowed) => command.startsWith(allowed));
}

async function executeCommand(command: string) {
  if (!validateCommand(command)) {
    throw new Error('Command not allowed');
  }

  // 执行命令
  return execAsync(command, {
    timeout: 30000,
    cwd: WORKSPACE_DIR,
  });
}
9.2.3. 权限请求实现
async function requestPermission(toolCall: ToolCall): Promise<boolean> {
  // 发送权限请求
  sendNotification('session/request_permission', {
    sessionId: currentSessionId,
    options: [
      { optionId: 'allow_once', name: '仅此一次允许', kind: 'allow_once' },
      { optionId: 'allow_always', name: '始终允许', kind: 'allow_always' },
      { optionId: 'reject', name: '拒绝', kind: 'reject_once' },
    ],
    toolCall,
  });

  // 等待用户响应
  const response = await waitForPermissionResponse();

  // 缓存权限决策
  if (response.kind === 'allow_always') {
    permissionCache.set(toolCall.kind, true);
  } else if (response.kind === 'reject_always') {
    permissionCache.set(toolCall.kind, false);
  }

  return response.kind.startsWith('allow');
}
9.2.4. 敏感信息处理
// 过滤敏感信息
function sanitizeContent(content: string): string {
  // 移除 API 密钥
  content = content.replace(/sk-[a-zA-Z0-9]{48}/g, '***API_KEY***');

  // 移除密码
  content = content.replace(/password\s*=\s*['"][^'"]+['"]/gi, 'password=***');

  // 移除 Token
  content = content.replace(/Bearer\s+[a-zA-Z0-9._-]+/g, 'Bearer ***');

  return content;
}

9.3 性能优化

9.3.1. 批量操作
// 批量读取文件
async function readMultipleFiles(paths: string[]): Promise<Map<string, string>> {
  const results = new Map();

  await Promise.all(
    paths.map(async (path) => {
      const content = await fs.readFile(path, 'utf-8');
      results.set(path, content);
    })
  );

  return results;
}
9.3.2. 增量更新
// 只发送变化的内容
let lastContent = '';

function sendDiffUpdate(newContent: string) {
  const diff = computeDiff(lastContent, newContent);

  if (diff) {
    sendNotification('session/update', {
      update: {
        sessionUpdate: 'agent_message_chunk',
        content: { type: 'diff', diff },
      },
    });
  }

  lastContent = newContent;
}
9.3.3. 缓存机制
// 缓存文件内容
const fileCache = new LRU<string, string>({
  max: 100,
  maxAge: 5 * 60 * 1000, // 5 分钟
});

async function readFileWithCache(path: string): Promise<string> {
  const cached = fileCache.get(path);
  if (cached) return cached;

  const content = await fs.readFile(path, 'utf-8');
  fileCache.set(path, content);

  return content;
}

十、未来展望

10.1 协议演进方向

10.1.1. 更丰富的内容类型
// 未来可能支持的内容类型
interface FutureContent {
  type: 'text' | 'image' | 'video' | 'audio' | 'diagram' | '3d-model';
  // ...
}

// 示例:Agent 生成架构图
{
  sessionUpdate: 'agent_message_chunk',
  content: {
    type: 'diagram',
    format: 'mermaid',
    data: `
      graph TD
        A[Client] -->|HTTP| B[Server]
        B --> C[Database]
    `
  }
}
10.1.2. 多 Agent 协作
graph TD
    Editor[Editor]

    CodeAgent[Code Agent<br/>负责代码生成]
    TestAgent[Test Agent<br/>负责测试]
    ReviewAgent[Review Agent<br/>负责代码审查]
    DeployAgent[Deploy Agent<br/>负责部署]

    Editor --> CodeAgent
    Editor --> TestAgent
    Editor --> ReviewAgent
    Editor --> DeployAgent

    CodeAgent <-.协作.-> TestAgent
    TestAgent <-.协作.-> ReviewAgent
    ReviewAgent <-.协作.-> DeployAgent

    style Editor fill:#e3f2fd
    style CodeAgent fill:#fff3e0
    style TestAgent fill:#e8f5e9
    style ReviewAgent fill:#f3e5f5
    style DeployAgent fill:#fce4ec

    Note["Agent 之间可以互相通信和协作"]
    style Note fill:#fffde7
10.1.3. 增强的上下文管理
// 未来的会话上下文
interface EnhancedSessionContext {
  // 项目元数据
  project: {
    name: string;
    language: string[];
    framework: string[];
    dependencies: Record<string, string>;
  };

  // 代码图谱
  codeGraph: {
    files: FileNode[];
    imports: ImportEdge[];
    exports: ExportEdge[];
  };

  // 历史操作
  history: Operation[];

  // 用户偏好
  preferences: {
    codingStyle: string;
    testFramework: string;
    // ...
  };
}

10.2 生态系统建设

10.2.1. ACP Agent 市场
mindmap
  root((ACP Agent 市场))
    Agent类型
      官方Agent
        Claude
        Gemini
        Codex
      社区Agent
        开源
        定制化
      企业Agent
        私有部署
        定制模型
    用户功能
      浏览和搜索Agent
      查看评分和评论
      一键安装和配置
      分享自己的Agent
10.2.2. 标准化的 Agent 能力声明
// agent-manifest.json
{
  "name": "my-custom-agent",
  "version": "1.0.0",
  "description": "A custom coding agent for Rust projects",
  "author": "Your Name",
  "capabilities": {
    "languages": ["rust", "toml"],
    "frameworks": ["tokio", "actix"],
    "tools": {
      "code_generation": true,
      "refactoring": true,
      "testing": true,
      "debugging": false
    }
  },
  "requirements": {
    "model": "gpt-4",
    "apiKey": "required",
    "minEditorVersion": "0.158.0"
  }
}
10.2.3. 跨编辑器同步
// 未来的跨编辑器配置同步
interface AgentConfig {
  agents: {
    [name: string]: {
      command: string;
      args: string[];
      env: Record<string, string>;
      settings: any;
    };
  };
  preferences: {
    defaultAgent: string;
    autoStart: boolean;
    // ...
  };
}

// 同步到云端
await syncConfigToCloud(config);

// 在另一台设备上
const config = await loadConfigFromCloud();

10.3 与其他标准的集成

10.3.1. 与 LSP 的深度集成
graph TD
    Editor[Editor]

    subgraph LSP["LSP (提供语言智能)"]
        L1[自动补全]
        L2[类型检查]
        L3[重构建议]
    end

    subgraph ACP["ACP (提供 AI 能力)"]
        A1[理解 LSP 的诊断信息]
        A2[生成符合项目规范的代码]
        A3[自动修复 LSP 报告的错误]
    end

    Editor --> LSP
    Editor --> ACP
    ACP -.读取诊断.-> LSP

    style Editor fill:#e3f2fd
    style LSP fill:#fff3e0
    style ACP fill:#e8f5e9
10.3.2. 与 Debug Adapter Protocol (DAP) 集成
// Agent 可以理解调试信息
{
  sessionUpdate: 'debug_analysis',
  breakpoint: {
    file: 'src/index.ts',
    line: 42,
    variables: {
      user: { id: 123, name: 'Alice' },
      error: new Error('Invalid token')
    }
  },
  suggestion: "错误是因为 token 已过期,建议添加 token 刷新逻辑"
}

10.4 AI 原生编程范式

ACP 正在推动一种新的编程范式:AI 原生编程(AI-Native Programming)

graph LR
    subgraph Traditional["传统编程"]
        T1[人类] --> T2[手写代码] --> T3[编译器] --> T4[可执行程序]
    end

    subgraph Current["AI 辅助编程(现在)"]
        C1[人类] --> C2[AI 生成代码片段] --> C3[人类修改] --> C4[编译器] --> C5[可执行程序]
    end

    subgraph Future["AI 原生编程(未来)"]
        F1[人类描述需求] --> F2[AI Agent 理解] --> F3[AI 设计架构]
        F3 --> F4[AI 实现] --> F5[AI 测试] --> F6[AI 部署]
        F6 -.人类审查和指导.-> F1
    end

    style Traditional fill:#ffebee
    style Current fill:#fff3e0
    style Future fill:#e8f5e9

关键特征:

  1. 声明式编程:人类只需描述"要什么",而不是"怎么做"
  2. 持续对话:编程变成与 AI 的持续对话过程
  3. 多层抽象:从需求 → 架构 → 实现 → 优化,每层都有 AI 辅助
  4. 自动化流程:测试、部署、监控都由 AI 自动化

十一、总结

11.1 ACP 的核心价值

标准化:统一的协议规范,消除编辑器与 Agent 的互操作壁垒

开放性:开源协议,任何人都可以实现和扩展

隐私优先:本地通信,不经过第三方服务器

可组合性:与 MCP 等协议协同工作,构建完整的 AI 生态

易用性:基于成熟的 JSON-RPC 2.0,简单高效

11.2 适用场景

  • 代码编辑器开发者:希望集成多个 AI 编码助手
  • AI Agent 开发者:希望让自己的 Agent 被更多编辑器支持
  • 企业开发团队:需要在统一的编辑器环境中使用不同的 AI 工具
  • 开源社区:构建开放、协作的 AI 辅助编程生态

11.3 开始使用 ACP

11.3.1. 作为编辑器开发者
# 安装 ACP SDK
npm install @agentclientprotocol/sdk

# 参考 Zed 的实现
git clone https://github.com/zed-industries/zed
11.3.2. 作为 Agent 开发者
# 选择你喜欢的语言 SDK
npm install @agentclientprotocol/sdk       # TypeScript
pip install agent-client-protocol          # Python
cargo add agent-client-protocol            # Rust
11.3.3. 作为用户
# 下载支持 ACP 的编辑器
# Zed
curl https://zed.dev/install.sh | sh

# 配置你喜欢的 Agent
zed --config agents.claude.command="claude-code"

11.4 学习资源


附录:ACP 与 MCP 协议对比速查表

特性 ACP MCP
完整名称 Agent Client Protocol Model Context Protocol
主要作用 编辑器 ↔ AI Agent AI Model ↔ Tools
协议基础 JSON-RPC 2.0 JSON-RPC 2.0
传输方式 stdio stdio / HTTP+SSE
通信方向 双向 主要单向
生命周期管理 编辑器控制 Host 控制
权限控制 内置机制 依赖 Host
流式响应 支持 支持
作者 Zed Industries Anthropic
开源协议 Apache 2.0 MIT
发布时间 2025 2024
典型场景 代码生成、重构 数据库、API 调用

关于本文

本文结合了:

  • MCP 官方文档和社区实践
  • ACP 官方规范和 Zed 实现
  • AionUi 项目的实际代码
  • 行业最佳实践和未来趋势

希望能帮助你深入理解 ACP 协议,并在实际项目中应用。

作者:基于掘金文章《MCP 深度解析》、ACP 官方文档、以及 AionUi 开源项目分析,感兴趣可以关注我!

扫码_搜索联合传播样式-白色版.png

相关阅读

徽标(Badge)的实现与优化铁壁猿版(简易版)

2025年11月21日 16:35

本文基于vue3+ts+tailwindcss,从逻辑拆解到代码细节带你实现一个简易版的右上角提示徽标组件

一、需求分析与TS类型定义

在实现组件前我们先明确Badge的能力范围,并通过TS将其规范化,这个步骤是我们实现所有组件都必须要有的,根据这一步骤再像拼积木一样一点一点“搭起来”。

首先在badge组件文件夹下创建一个types.ts,导出组件的各个api来控制每一个细节

export interface BadgeProps{
    value?:number|string      //显示的值(数字或文本)
    max?:number               //最大显示值,超过显示max+
    type?:'primary'|'success'|'warning'|'danger'|'info' //其实就是默认可选集中常见颜色
    color?:string             //自定义文本颜色
    backgroundColor?:string   //自定义背景色
    isDot?:boolean            //是否为小圆点(简洁版不显示文字)
    hidden?:boolean           //是否隐藏(比如已读情况)
    showZero?:boolean         //这个主要用于处理需要显示0的情况,少见但是还是要有
}

特别说明:这里的types是一个典型的联合类型(ts),保证types只能是这五种,既限制了错误,又给予编译器完美的提示

type BadgeType='primary'|'success'|'warning'|'danger'|'info'

二、组件实现总体思路

要写好一个Badge,需要处理以下核心部分:

1.内容逻辑

  • isDot和显示数字要做出那些判断?
  • 需要实现哪些部分的计算?
  • 需要考虑哪些类型特殊判断(检查如非0、null等)?

2.显示条件

  • hidden是否隐藏
  • 内容为空时是否显示

3.样式逻辑

  • type类型切换颜色
  • 自定义颜色与默认的优先级
  • 根据内容长度决定尺寸
  • 默认位置为右上角

4.动画体验

  • 显示/隐藏使用缩放过渡

拆解完问题后,下面按模块实现

三、内容逻辑:displayValue

内容逻辑决定当前Badge应该显示什么内容。

const displayValue=computed(()=>{
    if(props.isDot) return ''   //点状不需要考虑数字逻辑
    const val=props.value       //获取值
    if(val===''||val===null||val===undefined) return ''//特殊值返回空
    if(typeof val==='number'){
        if(val<0) return '0'                           //负值(特殊值)返回0
        if(val===0&&!props.showZero) return ''         //是否显示0
        if(val>props.max) return `${props.max}+`       //超出正常值显示max+
        return val.toString()                          //正常值正常返回
    }
    return val.toString()
})

这一块逻辑清晰地处理了 Badge 最常见的展示需求。

四、显示控制:shouldShow

Badge有一些特殊场景需要隐藏,例如消息已读、需要动画时先不显示等。

const shouldShow=computed(()=>{
    if(props.hidden) return false
    if(props.isDot) return true
    return displayValue.value!==''
})

这一层的逻辑避免了空内容闪烁问题。

五、样式处理:类型+自定义颜色+动态大小

1.类型class(typeClasses)
const typeClasses=computed(()=>{
//自定义优先级高于默认
    if(props.color||props.backgroundColor){
        return ''
    }
//默认就根据types来获取对应颜色
    const types={
        primary:'bg-blue-500 text-white',
        success: 'bg-green-500 text-white',
        warning: 'bg-yellow-500 text-white',
        danger: 'bg-red-500 text-white',
        info: 'bg-gray-500 text-white',
    }
    return types[props.type]
})
2.自定义样式
const customStyle=computed(()=>{
    const style:Record<string,string>={}
    if(props.color) style.color=props.color
    if(props.backgroundColor) style.backgroundColor=props.backgroundColor
    return style
})
3.尺寸逻辑(sizeClasses)

Badge的宽应随内容长度自动变化,比如9+、99+长度或者是点。这个逻辑保证badge不会因为数字长度不好看。

const sizeClasses=computed(()=>{
    //设置属于点的小盒子宽度
    if(props.isDot){
        return 'w-2 h-2 min-w-0'
    }
    //设置字长不同的盒子自适应
    const content=displayValue.value
    if(content.length===1){
        return 'w-5 h-5 min-w-[20px]'
    }else if(content.length===2){
        return 'w-6 h-5 min-w-[24px]'
    }else{
        return 'h-5 min-w-[24px] px-1'
    }
    
})

六、Badge模板

Badge会自动叠加在插槽内容的右上角,并带有平滑缩放动画

<template>
  <div class="relative inline-block">
    <slot></slot>

    <Transition
      enter-active-class="transition-all duration-200 ease-out"
      enter-from-class="opacity-0 transform scale-0"
      enter-to-class="opacity-100 transform scale-100"
      leave-active-class="transition-all duration-200 ease-in"
      leave-from-class="opacity-100 transform scale-100"
      leave-to-class="opacity-0 transform scale-0"
    >
      <span
        v-if="shouldShow"
        :class="[
          'text-white',
          'absolute top-0 right-0 transform translate-x-1/2 -translate-y-1/2 flex items-center justify-center rounded-full text-xs font-medium leading-none z-10',
          typeClasses,
          sizeClasses,
          props.isDot ? '' : 'border-2 border-white',
        ]"
        :style="customStyle"
      >
        {{ displayValue }}
      </span>
    </Transition>
  </div>
</template>

七、细节优化与思考

1. 负数显示为 0:避免出现 -1 条消息 这种不符合直觉的结果。

2. 超出最大值显示 max+:限制宽度,防止 UI 崩坏:

99+  ✔
12999  ✘

3. 自定义颜色优先级更高:使 Badge 可高度定制:

<Badge backgroundColor="#ff8800" color="#fff" />

4. 小圆点模式省略所有内容检查:非常适合在线状态指示。

5. 过渡动画提升体验:从 "出现/消失" 变为 "缩放展开",更柔和自然。

八、使用实例

这样我们就完成一个简易的徽标组件,下面在外面的父组件这样使用就可以啦!

<Badge :value="3">
  <button class="relative px-3 py-1 bg-gray-200 rounded">消息</button>
</Badge>

<Badge :isDot="true" type="success">
  <div class="w-8 h-8 bg-gray-300 rounded-full"></div>
</Badge>

<Badge :value="120" :max="99" type="danger"></Badge>

<Badge value="New" backgroundColor="#f60" color="#fff"></Badge>

虚拟列表从入门到出门

作者 王大宇_
2025年11月21日 16:31

虚拟列表

目标:让页面只渲染可见的少量节点,其余都是空白高度

固定高度

🌰来喽

每一项高度固定(假设50px)

若滚动区域高度500px,则可显示 (500 / 50 = 10项)

此时快速滚动到了2000项

真实渲染的列表内容:

只渲染 10~15 个 DOM 节点(pool)

把它们整体 transform 下移 offset 像素

在它们里面显示第 2000~2015 条数据

  • html版

    • <!DOCTYPE html>
      <html lang="en">
        <head>
          <meta charset="UTF-8" />
          <meta name="viewport" content="width=device-width, initial-scale=1.0" />
          <title>Document</title>
          <style>
            .virtual-list {
              height: 300px;
              overflow-y: auto;
              border: 1px solid #ccc;
              position: relative;
            }
          </style>
        </head>
        <body>
          <!-- 页面滚动容器 -->
          <div id="app" class="virtual-list"></div>
      
          <script>
            // 列表数据总数
            const total = 10000;
            // 列表项高度
            const itemHeight = 30;
            // 可视区域渲染数量 +2 作为缓冲 避免闪烁
            const visibleCount = Math.ceil(300 / itemHeight) + 2;
            const data = Array.from({ length: total }, (_, i) => `Item ${i}`);
      
            // 容器
            const container = document.getElementById('app');
      
            // 列表容器 需要撑开总高度
            const wrap = document.createElement('div');
            // 计算容器总高度
            wrap.style.height = total * itemHeight + 'px';
            // 列表项使用absolute定位
            wrap.style.position = 'relative';
      
            // 列表项
            const list = document.createElement('div');
            // 使用absolute定位 使listItem 在list内偏移而不改变list高度
            list.style.position = 'absolute';
            list.style.top = 0;
            list.style.left = 0;
            list.style.right = 0;
      
            wrap.appendChild(list);
            container.appendChild(wrap);
      
            function render() {
              // 读取容器当前垂直滚动偏移
              const scrollTop = container.scrollTop;
              // 计算起始位置
              const start = Math.floor(scrollTop / itemHeight);
              // 计算结束为止
              const end = Math.min(start + visibleCount, total);
      
              /**
               * 设置偏移 使内容位置正确
               * 使用transform 将list 整体下移 start * itemHeight
               * transform性能较直接设置top更优
               * */
              list.style.transform = `translateY(${start * itemHeight}px)`;
      
              // 渲染可视区域数据,重置innerHTML 清空之前渲染的内容,优化见下文
              list.innerHTML = '';
      
              // 渲染数据
              for (let i = start; i < end; i++) {
                const div = document.createElement('div');
                div.style.height = `${itemHeight}px`;
                div.style.lineHeight = `${itemHeight}px`;
                div.style.borderBottom = '1px solid #eee';
                div.textContent = data[i];
                list.appendChild(div);
              }
            }
      
            render();
            // 滚动监听
            container.addEventListener('scroll', render);
          </script>
        </body>
      </html>
      
  • React版

    • import VirtualList from './pages/virtual-list/fixed-height-virtual-list';
      
      function App() {
        const data = Array.from({ length: 100000 }, (_, i) => `Item ${i}`);
      
        return (
          <div style={{ padding: 20 }}>
            <h2>React 虚拟列表示例</h2>
            <VirtualList itemHeight={30} height={400} data={data} />
          </div>
        );
      }
      
      export default App;
      
    • import { useRef, useState } from 'react';
      
      interface VirtualListProps {
        itemHeight: number;
        height: number;
        data: string[];
      }
      const FixHeightVirtualList = ({
        itemHeight = 30,
        height = 300,
        data = [],
      }: VirtualListProps) => {
        const containerRef = useRef<HTMLDivElement>(null);
      
        const [scrollTop, setScrollTop] = useState(0);
      
        const total = data.length;
        const visible = Math.ceil(height / itemHeight) + 2;
        const start = Math.floor(scrollTop / itemHeight);
        const end = Math.min(start + visible, total);
      
        const onScroll = () => {
          if (containerRef.current) {
            const top = containerRef.current.scrollTop;
            setScrollTop(top);
          }
        };
      
        return (
          <div
            ref={containerRef}
            onScroll={onScroll}
            style={{
              height,
              overflowY: 'auto',
              border: '1px solid #ccc',
              position: 'relative',
            }}
          >
            <div style={{ height: total * itemHeight, position: 'relative' }}>
              <div
                style={{
                  position: 'absolute',
                  top: '0',
                  left: '0',
                  right: '0',
                  transform: `translateY(${start * itemHeight}px)`,
                }}
              >
                {data.slice(start, end).map((item, index) => (
                  <div
                    key={start + index}
                    style={{
                      height: itemHeight,
                      lineHeight: `${itemHeight}px`,
                      borderBottom: '1px solid #eee',
                      paddingLeft: '10px',
                    }}
                  >
                    {item}
                  </div>
                ))}
              </div>
            </div>
          </div>
        );
      };
      
      export default FixHeightVirtualList;
      
  • React优化版

    • 方向

      •   data.slice(start, end).map()
        
      • 每次滚动都会生成新数组,会导致对象频繁创建
      • 数组改变后,React会diff整个可视区域
      • key每次新增/删除导致卸载挂载
    • 优化后

      • DOM数量保持不变
      • DOM完全复用
      • transform: translateY(offset)不触发回流
    • 去掉不必要的diff 去掉不必要的DOM创建/删除 去掉不必要的布局 去掉不必要的渲染

    • import React, { useCallback, useMemo, useRef, useState } from 'react';
      
      interface VirtualListProps {
        itemHeight: number;
        height: number;
        data: string[];
        buffer: number;
        renderItem?: (item: string, index: number) => React.ReactNode;
      }
      const FixHeightVirtualListV2 = ({
        itemHeight = 50,
        height = 400,
        data = [],
        buffer = 2,
        renderItem,
      }: VirtualListProps) => {
        // 存DOM引用,读取scrollTop
        const containerRef = useRef<HTMLDivElement>(null);
      
        // 保存滚动实时值,不触发渲染,避免频繁setState
        const scrollTopRef = useRef(0);
      
        //标识是否已经有rAF回调
        const tickingRef = useRef(false);
      
        const [scrollTop, setScrollTop] = useState(0);
      
        const total = data.length;
      
        // 容器可见行数
        const visible = Math.ceil(height / itemHeight);
        // 真正创建的DOM节点数 = 可视区域 + 缓冲区 缓冲越多滚动越平滑但DOM更多
        const poolCount = visible + buffer;
        // 起始位置
        const start = Math.max(0, Math.floor(scrollTop / itemHeight));
      
        const onScroll = useCallback(() => {
          // 每次滚动把最新的scrollTop存入ref
          const top = containerRef.current?.scrollTop || 0;
          scrollTopRef.current = top;
      
          // 如果没有排队的rAF,就排一个
          if (!tickingRef.current) {
            tickingRef.current = true;
            requestAnimationFrame(() => {
              setScrollTop(scrollTopRef.current);
              tickingRef.current = false;
            });
          }
        }, []);
      
        const containerStyle = useMemo<React.CSSProperties>(
          () => ({
            overflowY: 'auto',
            height: height,
            border: '1px solid #ddd',
            position: 'relative',
            WebkitOverflowScrolling: 'touch',
          }),
          [height]
        );
      
        const spacerStyle = useMemo<React.CSSProperties>(
          () => ({
            height: total * itemHeight,
            position: 'relative',
          }),
          [total, itemHeight]
        );
      
        const innerStyle = useMemo<React.CSSProperties>(
          () => ({
            position: 'absolute',
            top: 0,
            left: 0,
            right: 0,
            transform: `translateY(${start * itemHeight}px)`,
            willChange: 'transform',
          }),
          [start, itemHeight]
        );
      
        const itemBaseStyle = useMemo<React.CSSProperties>(
          () => ({
            height: itemHeight,
            lineHeight: `${itemHeight}px`,
            borderBottom: '1px solid #eee',
            padding: '0 12px',
            boxSizing: 'border-box',
            overflow: 'hidden',
            whiteSpace: 'nowrap',
            textOverflow: 'ellipsis',
          }),
          [itemHeight]
        );
      
        return (
          <div ref={containerRef} style={containerStyle} onScroll={onScroll}>
            <div style={spacerStyle}>
              <div style={innerStyle}>
                {/* key使用index做索引而非dataIndex  在diff的时候不会将节点当作新节点创建或删除,而是复用DOM只替换文本*/}
                {Array.from({ length: poolCount }).map((_, index) => {
                  const dataIndex = start + index;
                  const item = dataIndex < total ? data[dataIndex] : null;
      
                  const content =
                    item === null
                      ? null
                      : renderItem
                      ? renderItem(item, dataIndex)
                      : item ?? String(item);
      
                  return (
                    <div
                      key={index}
                      style={{ ...itemBaseStyle }}
                      data-index={dataIndex}
                    >
                      {content}
                    </div>
                  );
                })}
              </div>
            </div>
          </div>
        );
      };
      
      export default FixHeightVirtualListV2;
      

不定高度

  • React版

    • 相比于定长列表,我们首先需要获取每个item的高度并将其存起来

    • import { useState } from 'react';
      import VariableHeightVirtualList from './pages/virtual-list/variable-height-virtual-list';
      
      
      function App() {
      
        const [dataVariable] = useState(() =>
          new Array(1000).fill(0).map((_, i) => ({
            id: i,
            text: `Row ${i}`,
            height: 20 + Math.round(Math.random() * 80),
          }))
        );
      
        return (
          <VariableHeightVirtualList
            data={dataVariable}
            poolCount={15}
            estimatedItemHeight={50}
            containerHeight={500}
            renderItem={(item: any) => (
              <div
                style={{
                  padding: '10px',
                  borderBottom: '1px solid #eee',
                  background: '#fafafa',
                  height: item.height, // 不定高
                }}
              >
                {item.text} — height: {item.height}
              </div>
            )}
          />
        );
      }
      
      export default App;
      
    • import {
        useCallback,
        useEffect,
        useLayoutEffect,
        useMemo,
        useRef,
        useState,
      } from 'react';
      
      interface VirtualListProps {
        data: string[];
        renderItem: (item: string, index: number) => React.ReactNode;
        poolCount?: number;
        estimatedItemHeight?: number;
        containerHeight?: number;
      }
      const VariableHeightVirtualList = ({
        data,
        renderItem,
        poolCount = 15,
        estimatedItemHeight = 40,
        containerHeight = 400,
      }: VirtualListProps) => {
        const total = data.length;
      
        // heightMap存储单项高度,prefixHeight存储累计高度
        // 用来存储已测量的真实高度
        const heightMap = useRef<Record<number, number>>({});
      
        // 前缀和数组 prefixHeight[i] 表示 0~i 项的总高度 用于快速用二分查找scrollTop对应的startIndex
        const prefixHeight = useRef<number[]>([]);
      
        // 驱动视图更新
        const [scrollTop, setScrollTop] = useState(0);
      
        // 保持池内每个DOM节点的引用
        const itemRefs = useRef<Array<React.RefObject<HTMLDivElement>>>([]);
      
        // 计算前缀和 在不定高情况下,无法直接计算startIndex,需要通过前缀和与二分查找来定位scrollTop对应的item
        const calcPrefix = useCallback(() => {
          const arr = new Array(total);
          let sum = 0;
          for (let i = 0; i < total; i++) {
            const h = heightMap.current[i] || estimatedItemHeight;
            sum += h;
            arr[i] = sum;
          }
          prefixHeight.current = arr;
        }, [total, estimatedItemHeight]);
      
        // 首次挂载时执行
        useEffect(() => {
          calcPrefix();
        }, [calcPrefix]);
      
        // 二分查找
        // 给定当前scrollTOp,在prefixHeight中通过二分查找找到最小的k,使prefixHeight[k] >= scrollTop,也就是scrollTop所在Item的索引
        const findStartIndex = useCallback(() => {
          const arr = prefixHeight.current;
          const target = scrollTop;
      
          let left = 0;
          let right = arr.length - 1;
      
          while (left < right) {
            const mid = (left + right) >> 1;
            if (arr[mid] < target) left = mid + 1;
            else right = mid;
          }
      
          return left;
        }, [scrollTop]);
      
        // 得到起始索引
        const [startIndex, setStartIndex] = useState(0);
      
        useEffect(() => {
          setStartIndex(findStartIndex());
        }, [scrollTop, findStartIndex]);
      
        // 把DOM池视觉定位到startIndex位置
        const offset = useMemo(
          () => (startIndex === 0 ? 0 : prefixHeight.current[startIndex - 1]),
          [startIndex]
        );
      
        const scrollLock = useRef(false);
        const onScroll = (e: React.UIEvent<HTMLDivElement>) => {
          const nextTop = (e.target as HTMLDivElement)?.scrollTop;
      
          if (!scrollLock.current) {
            scrollLock.current = true;
            requestAnimationFrame(() => {
              setScrollTop(nextTop);
              scrollLock.current = false;
            });
          }
        };
      
        // 在DOM更新并在浏览器绘制前,测量池中每个已渲染节点的真实offsetHeight,把测量结果写入heightMap
        // useLayoutEffect比useEffect执行更早,
        useLayoutEffect(() => {
          let changed = false;
      
          for (let i = 0; i < poolCount; i++) {
            const realIndex = startIndex + i;
            if (realIndex >= total) break;
      
            const dom = itemRefs.current[i];
            if (dom) {
              const h = dom.current?.offsetHeight || 0;
              if (heightMap.current[realIndex] !== h) {
                heightMap.current[realIndex] = h;
                changed = true;
              }
            }
          }
      
          if (changed) calcPrefix();
        });
      
        return (
          // 最外层滚动容器
          <div
            style={{
              height: containerHeight,
              overflow: 'auto',
              position: 'relative',
              border: '1px solid #ddd',
            }}
            onScroll={onScroll}
          >
            <div
              style={{
                height: prefixHeight.current[total - 1] || 0,
                position: 'relative',
              }}
            >
              <div
                style={{
                  transform: `translateY(${offset}px`,
                  position: 'absolute',
                  left: 0,
                  right: 0,
                  color: 'black',
                }}
              >
                {/* 构建DOM池,固定长度为poolCount的数组并map出池内一个个槽位 */}
                {Array.from({ length: poolCount }).map((_, i) => {
                  const dataIndex = startIndex + i;
                  if (dataIndex >= total) return null;
      
                  return (
                    <div
                      key={i}
                      ref={el => (itemRefs.current[i] = el)}
                      style={{ boxSizing: 'border-box', width: '100%' }}
                      data-index={dataIndex}
                    >
                      {renderItem(data[dataIndex], dataIndex)}
                    </div>
                  );
                })}
              </div>
            </div>
          </div>
        );
      };
      
      export default VariableHeightVirtualList;
      
    • 整体经历以下几个阶段

      • 用户滚动
      • 更新scrollTop
      • 通过前缀和prefixHeight 二分查找 startIndex
      • 计算offset
      • 渲染DOM池
      • useLayoutEffect测量真实高度
      • 写入heightMap
      • 重新计算prefixHeight
      • 视图稳定,等待下一次更新
  • Vue版

    • <template>
        <div style="padding: 20px">
          <h3>Variable Height Virtual List</h3>
          <VariableHeightVirtualList
            :items="data"
            :containerHeight="600"
            :estimatedItemHeight="72"
            v-slot="{ item, index }"
            ref="vlist"
          >
            <div @click="toggleExpand(index)" :style="itemStyle(item, index)">
              <strong>#{{ index }}</strong> - {{ item.text }}
              <div v-if="expanded[index]" style="margin-top: 8px">
                额外内容:{{ item.largeText }}
              </div>
            </div>
          </VariableHeightVirtualList>
        </div>
      </template>
      
      <script lang="ts" setup>
      import { ref } from 'vue';
      import VariableHeightVirtualList from '../component/VariableHeightVirtualList.vue';
      const vlist = ref<any>(null);
      
      const data = new Array(2000).fill(0).map((_, i) => ({
        id: i,
        text: 'Item ' + i,
        largeText: '详细内容 '.repeat((i % 6) + 1),
        hasImage: i % 10 === 0,
      }));
      
      const expanded = ref<Record<number, boolean>>({});
      
      function toggleExpand(idx: number) {
        expanded.value = { ...expanded.value, [idx]: !expanded.value[idx] };
      }
      
      function itemStyle(item: any, idx: number) {
        return {
          padding: '12px',
          background: idx % 2 === 0 ? '#fff' : '#fafafa',
          borderBottom: '1px solid #eee',
          cursor: 'pointer',
        };
      }
      </script>
      
    • <script lang="ts" setup>
      import {
        ref,
        computed,
        watch,
        nextTick,
        onMounted,
        onBeforeUnmount,
      } from 'vue';
      
      /**
       * Props
       * - items: 数据数组
       * - containerHeight: 可视区高度 (px)
       * - estimatedItemHeight: 估算高度(用于初始 prefix)
       * - poolCount: 可选,覆盖计算得到的池大小
       * - render-slot: 默认插槽 renderItem(item, index)
       */
      interface Props<T = any> {
        items: T[];
        containerHeight: number;
        estimatedItemHeight?: number;
        poolCount?: number;
        itemKey?: (item: any, index: number) => string | number;
      }
      
      const props = withDefaults(defineProps<Props>(), {
        estimatedItemHeight: 56,
        poolCount: undefined,
        itemKey: undefined,
      });
      
      const emit = defineEmits<{
        (e: 'measure', info: { index: number; height: number }): void;
      }>();
      
      // 容器元素引用
      const containerRef = ref<HTMLElement | null>(null);
      // 池子
      const poolRefs = ref<Array<HTMLElement | null>>([]);
      const ro = ref<ResizeObserver | null>(null);
      
      const items = computed(() => props.items);
      const total = computed(() => items.value.length);
      
      const estimatedItemHeight = computed(() => props.estimatedItemHeight!);
      
      // 存储item高度 <索引,高度>
      const heightMap = ref<Record<number, number>>({});
      
      // 前缀和数组
      const prefix = ref<number[]>([]);
      
      const pendingScroll = ref<number | null>(null);
      // 当前滚动位置
      const scrollTop = ref(0);
      
      // 计算可视数量 & 池大小
      const visibleEstimate = computed(() =>
        Math.max(1, Math.ceil(props.containerHeight / estimatedItemHeight.value))
      );
      const pool = computed(() => props.poolCount ?? visibleEstimate.value + 3);
      
      // 起始索引与偏移
      const startIndex = ref(0);
      const offset = ref(0);
      
      // 构建前缀和数组
      function calcPrefix() {
        const n = total.value;
        const arr: number[] = new Array(n);
        let s = 0;
        for (let i = 0; i < n; i++) {
          s += heightMap.value[i] ?? estimatedItemHeight.value;
          arr[i] = s;
        }
        prefix.value = arr;
      }
      
      // 二分法查找起始item
      function binaryFindStart(target: number) {
        const arr = prefix.value;
        if (!arr.length) return 0;
        let l = 0,
          r = arr.length - 1;
        while (l < r) {
          const m = (l + r) >> 1;
          if (arr[m] < target) l = m + 1;
          else r = m;
        }
        return l;
      }
      
      // 整个列表总高度
      const totalHeight = computed(() => {
        const last = prefix.value[prefix.value.length - 1];
        if (last != null) return last;
        return total.value * estimatedItemHeight.value;
      });
      
      // 滚动时处理
      function onScroll(e: Event) {
        const el = e.target as HTMLElement;
        const top = el.scrollTop;
        if (pendingScroll.value === null) {
          pendingScroll.value = top;
          requestAnimationFrame(() => {
            scrollTop.value = pendingScroll.value as number;
            pendingScroll.value = null;
          });
        } else {
          pendingScroll.value = top;
        }
      }
      
      // 监听滚动位置变化,更新起始索引与偏移
      watch(scrollTop, top => {
        if (!prefix.value.length) {
          startIndex.value = 0;
          offset.value = 0;
          return;
        }
        const s = binaryFindStart(top);
        startIndex.value = s;
        offset.value = s === 0 ? 0 : prefix.value[s - 1] ?? 0;
      });
      
      // 观察元素高度变化
      function observeEl(el: HTMLElement | null) {
        if (!el || !ro.value) return;
        ro.value.observe(el);
      }
      
      // 测量池中所有项目实际高度,并更新
      async function measurePool() {
        await nextTick();
        let changed = false;
        for (let i = 0; i < pool.value; i++) {
          const realIndex = startIndex.value + i;
          if (realIndex >= total.value) break;
          const el = poolRefs.value[i];
          if (!el) continue;
          const h = Math.round(el.offsetHeight);
          if (heightMap.value[realIndex] !== h) {
            heightMap.value = { ...heightMap.value, [realIndex]: h };
            emit('measure', { index: realIndex, height: h });
            changed = true;
          }
          observeEl(el);
        }
        if (changed) {
          requestAnimationFrame(() => calcPrefix());
        }
      }
      
      // 组件挂载时创建 监听元素尺寸变化并更新
      onMounted(() => {
        ro.value = new ResizeObserver(entries => {
          let changed = false;
          for (const ent of entries) {
            const el = ent.target as HTMLElement;
            const idxAttr = el.dataset.vIndex;
            if (!idxAttr) continue;
            const idx = Number(idxAttr);
            const newH = Math.round(ent.contentRect.height);
            if (heightMap.value[idx] !== newH) {
              heightMap.value = { ...heightMap.value, [idx]: newH };
              emit('measure', { index: idx, height: newH });
              changed = true;
            }
          }
          if (changed) requestAnimationFrame(() => calcPrefix());
        });
        calcPrefix();
      });
      
      // 组件卸载前断开观察
      onBeforeUnmount(() => {
        ro.value?.disconnect();
        ro.value = null;
      });
      
      // 监听 items 变化,重建 prefix 并测量池
      watch([startIndex, () => items.value.length], () => {
        measurePool();
      });
      
      // 组件挂载后初始化
      onMounted(() => {
        nextTick(() => {
          calcPrefix();
          measurePool();
        });
      });
      </script>
      
      <template>
        <!-- 滚动容器 -->
        <div
          :style="{
            height: props.containerHeight + 'px',
            overflowY: 'auto',
            position: 'relative',
          }"
          ref="containerRef"
          @scroll="onScroll"
        >
          <!-- 实际元素容器 -->
          <div :style="{ height: totalHeight + 'px', position: 'relative' }">
            <div
              :style="{
                transform: `translateY(${offset}px)`,
                position: 'absolute',
                left: 0,
                right: 0,
              }"
            >
              <template v-for="i in pool">
                <div
                  v-if="startIndex + (i - 1) < total"
                  :key="i - 1"
                  :ref="el => (poolRefs[i - 1] = el)"
                  :data-v-index="startIndex + (i - 1)"
                  class="vhvl-item"
                  style="width: 100%; box-sizing: border-box"
                >
                  <slot
                    :item="items[startIndex + (i - 1)]"
                    :index="startIndex + (i - 1)"
                  >
                    <div style="padding: 8px; border-bottom: 1px solid #eee">
                      {{ items[startIndex + (i - 1)] }}
                    </div>
                  </slot>
                </div>
              </template>
            </div>
          </div>
        </div>
      </template>
      
      <style scoped></style>
      

思路

虚拟列表本质就是:用极少数DOM,模拟海量的内容渲染,并保持页面流畅

JavaScript → Style → Layout → Paint → Composite
  • 减少DOM数量

    • DOM越少,Layout和Paint成本越低
  • DOM池复用

    • 不创建/销毁DOM,减少渲染

    • 在React升级版中,我们会看到如下写法:

      • 无论ItemIndex怎么变化,DOM是不变的,React只会将其内容改变,而不是删除/创建
    •   <div key={i}>{text}</div>
      
  • transform位移

  • 使用useLayoutEffect

    • height测量在绘制前完成

    •   render
        ↓
        useLayoutEffect(DOM 已存在,但尚未绘制)
        ↓
        浏览器绘制
        ↓
        useEffect
      

前缀和相关

一维前缀和

leetcode.cn/problems/ra…

当计算数组区间和时,可以通过前缀和的方式,本文在计算不定高度的item和时使用

二维前缀和

leetcode.cn/problems/ra…

用户登录后,Token 到底该存哪里?从懵圈到精通的全方位解析

作者 大知闲闲i
2025年11月21日 14:35

面试官的一个简单问题,却让我陷入了深思。这不仅是前端问题,更是全栈工程师必须掌握的 security 基础。

“说说看,用户登录后拿到的 Token,你会存在哪里?”

记得我第一次被问到这个问题时,信心满满地回答:“localStorage 呗,简单方便。”然后,空气突然安静了...

有后端小伙伴可能会问,这种前端存储问题后端也需要关心吗?答案是:绝对需要! 安全是一个全链路问题,任何一环的疏忽都会导致整个系统的崩溃。

初探:天真的 localStorage 方案

很多前端开发者的第一反应都是 localStorage,因为它确实简单直观:

// 登录成功后
localStorage.setItem('token', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...');

// 请求时自动携带
axios.interceptors.request.use(config => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

优点很明显:

  • 简单直观,上手快速

  • 持久化存储,页面刷新不影响用户体验

  • API 友好,操作方便

但致命问题在于:

  • 一旦遭遇 XSS 攻击,攻击者可以直接通过 JavaScript 读取你的 Token

  • 相当于把家门钥匙放在门口的垫子下面

  • 几乎无法有效防御 XSS 窃取

深入:真正的解决方案

方案一:HttpOnly Cookie - 传统的智慧

这是最经典的解决方案,通过服务端设置 HttpOnly 标志来保护 Token:

// 服务端设置 Cookie(Node.js/Express 示例)
res.cookie('token', 'eyJhbGci...', {
  httpOnly: true,      // 禁止 JavaScript 访问
  secure: process.env.NODE_ENV === 'production', // 仅 HTTPS
  sameSite: 'strict',  // 防御 CSRF
  maxAge: 24 * 60 * 60 * 1000 // 1天有效期
});

前端无需特殊处理:

// 浏览器会自动在每次请求中携带 Cookie
// 前端 JavaScript 无法读取,彻底防御 XSS

配套的 CSRF 防护方案:

// 方案1:CSRF Token
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
axios.defaults.headers.common['X-CSRF-Token'] = csrfToken;

// 方案2:双重提交 Cookie 验证
// 服务端同时验证 Cookie 和 Header 中的 Token

适用场景:

  • 传统多页面应用

  • SSR 服务端渲染项目

  • 对 SPA 单页应用也完全可行

方案二:内存存储 - 极致的安全追求

对于安全性要求极高的场景,内存存储是最安全的选择:

let memoryToken = null;

// 登录后存储
const login = async (credentials) => {
  const response = await axios.post('/api/login', credentials);
  memoryToken = response.data.token;
  return response;
};

// 请求拦截器
axios.interceptors.request.use(config => {
  if (memoryToken) {
    config.headers.Authorization = `Bearer ${memoryToken}`;
  }
  return config;
});

// 登出或页面关闭时清理
const logout = () => {
  memoryToken = null;
};

优势:

  • 完全不持久化,免疫 XSS 攻击

  • 页面关闭即失效,安全性最高

  • 实现简单,无需复杂配置

缺点:

  • 页面刷新就需要重新登录,用户体验较差

  • 移动端应用切换时可能丢失状态

适用场景:

  • 银行、金融等高安全要求应用

  • 内部管控系统

  • 敏感操作的身份验证

方案三:现代 SPA 的黄金标准 - 双 Token 机制

这才是现代 Web 应用在安全与体验间的完美平衡:

Token 类型

存储位置

有效期

用途

Access Token

内存

短(15分钟-2小时)

API 调用身份验证

Refresh Token

HttpOnly Cookie

长(7天-30天)

刷新 Access Token

实现方案:

// 登录处理
const handleLogin = async (credentials) => {
  const response = await axios.post('/api/login', credentials);
  const { accessToken } = response.data;
  
  // Access Token 存内存
  setAccessToken(accessToken);
  // Refresh Token 由服务端设置为 HttpOnly Cookie
  
  return response;
};

// 请求拦截器 - 自动携带 Access Token
axios.interceptors.request.use(config => {
  const token = getAccessToken();
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// 响应拦截器 - 自动刷新 Token
axios.interceptors.response.use(
  response => response,
  async error => {
    if (error.response?.status === 401) {
      // Access Token 过期,尝试刷新
      try {
        const newToken = await refreshToken();
        setAccessToken(newToken);
        // 重试原始请求
        error.config.headers.Authorization = `Bearer ${newToken}`;
        return axios.request(error.config);
      } catch (refreshError) {
        // 刷新失败,跳转登录页
        logout();
        window.location.href = '/login';
      }
    }
    return Promise.reject(error);
  }
);

刷新 Token 的服务端实现:

app.post('/api/refresh', async (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  
  if (!refreshToken) {
    return res.status(401).json({ message: 'Refresh token required' });
  }
  
  try {
    const decoded = verifyRefreshToken(refreshToken);
    const newAccessToken = generateAccessToken({ userId: decoded.userId });
    
    res.json({ accessToken: newAccessToken });
  } catch (error) {
    res.clearCookie('refreshToken');
    res.status(401).json({ message: 'Invalid refresh token' });
  }
});

全方位方案对比

存储方案

安全性

用户体验

实现复杂度

适用场景

localStorage

❌ 低

✅ 好

✅ 简单

内部工具、演示项目

HttpOnly Cookie

✅ 高

✅ 好

✅ 中等

传统 Web 应用、SSR

内存存储

✅ 极高

❌ 差

✅ 简单

高安全要求系统

双 Token 机制

✅ 很高

✅ 好

❌ 复杂

现代 SPA 应用

面试官的真正期待

初级回答:

"localStorage,因为简单方便。"

中级回答:

"用 HttpOnly Cookie,因为能防 XSS,但要配合 CSRF 防护。"

高级回答:

"要看具体场景。如果是内部低风险系统,localStorage 的简洁性也有价值。如果是传统 Web 应用,HttpOnly Cookie + CSRF Token 是久经考验的方案。如果是现代 SPA,我推荐 Access Token + Refresh Token 的组合,在安全和体验间取得最佳平衡。同时要考虑业务的安全要求、用户的使用习惯和技术团队的维护能力。"

这才是面试官想听到的:

  • 理解不同方案的权衡取舍

  • 能够根据业务场景做出合理选择

  • 清楚每种方案的安全边界和风险点

  • 具备全链路的安全思维

安全的核心是平衡,不是绝对

回头看我当初那个 naive 的 "localStorage" 回答,问题不在于技术本身,而在于思考方式。

真正的安全专家不是追求绝对安全,而是懂得:

  • 在什么业务场景下选择什么技术方案

  • 每种方案的风险边界和应对措施

  • 如何用合适的成本解决合适的风险

  • 如何在安全、体验、开发效率间找到平衡点

现在当面试官再问我 "Token 该存哪里" 时,我会先反问:

"咱们的业务场景是什么?安全要求等级多高?目标用户的使用习惯怎样?技术团队的维护能力如何?"

因为,没有最好的方案,只有最合适的方案。安全之路,需要的是持续学习和深度思考。

受够了 create-xxx?我写了一个聚合主流框架的脚手架

2025年11月21日 14:35

“不想自己写脚手架的前端不是好前端”,这句话我一直记在心里回响。每次开启一个新项目,我总觉得缺了点什么。在做了大量的调研和思考之后,我决定,是时候动手打造一个属于自己的前端脚手架了。

我个人感觉目前主要面临两个痛点:

  1. 市面上的脚手架太多了,记不住! 说实话,create-react-appcreate-vitecreate-next-app... 每次用都要先去搜索文档,找到那个准确的命令。我常常想,能不能有一个统一的入口,让我不再为记这些名字而烦恼?

  2. 脚手架不够灵活,不能自由组合。 官方的脚手架通常只提供最基础的模板。如果我想加入 React RouterZustand,或者配置好 ESLintPrettier,就得在项目创建后手动安装、配置一大堆东西。这个过程繁琐、重复,且容易出错。

那么,我能不能写一个既能汇总主流脚手架,又能让我在创建时就自由组合所需配置的工具呢?这不仅能解决我的痛点,也是一个绝佳的学习机会。

于是,我开始疯狂撸代码,create-web-app 的雏形就此诞生

create-web-app

img_v3_02s8_1765d6d6-082d-4324-9264-032a80a4667g.jpg

img_v3_02s8_66e644e0-dd5f-4047-ae24-989237297bbg.jpg

支持多引擎选择

为了解决“记不住命令”的问题,我首先做了一个“引擎选择”功能。我汇总了目前社区最主流的十几种脚手架,包括:

  • Vite (React, Vue, Svelte...)
  • Umi
  • Create React App
  • Next.js
  • Nuxt
  • Astro
  • SvelteKit
  • Angular
  • Remix
  • ...等等

现在,你只需要记住一个命令 pnpm create-web-app,就能启动任何一个主流框架的创建流程。它就像一个超级启动器,将所有官方 CLI 的能力都聚合在了一起

自由组合配置

除了代理外部工具,我做了一套create-web-app的自己的创建模版,可以在创建项目时,就完成所有基础配置的自由组合。

框架、语言、状态管理、路由、规范... 一步到位!

在create-web-app模式下,可以通过交互式的问答,一步步进行:

  1. 选择框架:React 还是 Vue?
  2. 选择语言:JavaScript 还是 TypeScript?
  3. 选择插件
    • 路由:需要 React RouterVue Router 吗?
    • 状态管理ReduxZustand 还是 Pinia
    • 代码规范ESLintPrettierHusky 要不要安排上?

当你确认完所有选项后,一个已经配置齐全、装好依赖、甚至连入口文件都帮你改好的项目就诞生了!你不再需要手动 pnpm install 一堆包,再小心翼翼地去 main.jsapp.use(router)

实现

这种灵活性背后,是一套精心设计的声明式插件系统基于 AST 的代码注入机制。

  • 插件注册中心 (plugin-registry.js):我把每一个可选功能(如 Zustand、ESLint)都抽象成一个插件。每个插件清晰地定义了它需要:

    • 哪些 npm 依赖 (pkg)
    • 哪些配置文件 (files)
    • 需要对哪个文件的代码做什么修改 (transforms)
  • AST 代码变换器 (transforms/):当你说需要 React Router 时,脚手架不会用粗暴的字符串替换来修改你的入口文件。它会把 main.jsx 解析成一个抽象语法树(AST),精准地找到 <App />,然后用 <RouterProvider /> 将其包裹,最后再把 AST 安全地转换回代码

总结

从一个想法,到一个可用的工具,create-web-app 解决了我作为前端开发者最真实的痛点。它通过“双引擎”模式,既拥抱了社区的强大生态,又通过“自研插件”模式提供了极致的灵活性和效率

当然,它目前还只是一个初版,未来还有很多可以做的事情:

  • 支持 CLI 直传参数:实现 pnpm create-web-app my-app --framework react --plugins router,zustand,跳过交互,一键生成。

  • 扩充插件生态:加入更多测试框架、组件库、国际化等插件。

  • 企业级规范注入:一键集成团队的 CI/CD 配置、Git Hooks 规范等。

  • 目前仅仅支持pnpm包管理工具

  • 目前仅仅支持vite打包

  • 等等

深度解析 JavaScript 作用域与作用域链

2025年11月21日 14:18

概述

作用域(Scope)与作用域链(Scope Chain)是 JavaScript 的核心概念,它们决定了变量的可访问范围、生命周期,以及代码运行时变量查找的规则,理解这两个概念,可以回答我们 “变量在这里为什么能访问”,“为什么这里访问到的变量值是 undefined” 等诸多疑问,同时还能帮助我们在开发过程中规避变量污染、提升代码的可维护性,对于 ES Module 等模块方案也可以有更好的理解。

作用域类型

全局作用域(Global Scope)

全局作用域是最顶层的作用域,代码中未被任何函数或块级结构包裹的变量 / 函数,都属于全局作用域。

  • 生命周期:与程序运行周期一致,页面加载时创建,页面关闭时销毁;
  • 浏览器环境:全局作用域的变量会挂载到 window 对象上(Node.js 环境挂载到 global 对象);
  • 访问范围:代码中的任何位置都能访问;
// 全局变量:属于全局作用域
const globalVar = "我是全局变量";

function bar() {
  // 可访问全局变量
  console.log(globalVar); // 输出:我是全局变量
}

bar();
console.log(window.globalVar); // 浏览器环境输出:我是全局变量

函数作用域(Function Scope)

函数作用域是指变量 / 函数仅在定义它们的函数内部可访问,函数外部无法直接访问。

  • 生命周期:函数调用时创建,函数执行结束后销毁(闭包除外);
  • 访问范围:仅函数内部及嵌套的子函数可访问;
  • 核心特性:函数参数也属于函数作用域的变量。
function foo() {
  // 函数作用域变量:仅foo内部可访问
  const funcVar = "我是函数作用域变量";
  
  // 嵌套函数可访问外层函数作用域的变量
  function inner() {
    console.log(funcVar); // 输出:我是函数作用域变量
  }
  
  inner();
}

foo();
console.log(funcVar); // 报错:funcVar is not defined(外部无法访问)

块级作用域(Block Scope)

块级作用域是 ES6 引入的特性,由 { } 包裹的代码块(如 ifforwhiletry/catch,以及直接用 { } 定义的块)形成,仅 let/const 声明的变量会绑定到块级作用域。

  • 生命周期:代码块执行时创建,执行结束后销毁;
  • 访问范围:仅块内部可访问;
  • 核心特性:不存在变量提升(或称为暂时性死区),避免变量泄露。
if (true) {
  // let声明的变量:绑定到块级作用域
  let blockVar = "我是块级作用域变量";
  const blockConst = "块级常量";
  
  // var声明的变量:不绑定块级作用域,属于外层作用域(如全局)
  var nonBlockVar = "我不属于块级作用域";
}

console.log(blockVar); // 报错:blockVar is not defined
console.log(blockConst); // 报错:blockConst is not defined
console.log(nonBlockVar); // 输出:我不属于块级作用域(全局变量)

虽然我这里划分了三种类型的作用域,但其实是两个大类型:全局和局部,函数作用域和块级作用域就属于是局部的作用域。

作用域的本质

作用域本质上是定义了一套变量的访问规则,用于确定在代码执行过程中,某个变量何时被创建、何时被销毁,在何处可以被访问、修改。

JavaScript 的作用域是静态作用域(通常也称为词法作用域), 静态作用域是在代码定义阶段而非运行阶段确定的,通俗的说就是,你把变量写在代码的哪里,它的作用域就在哪个范围内,举个例子:

function outer() {
  const a = 1; // 定义在 outer 作用域中的变量
  
  function inner() {
    console.log(a); // inner函数定义时,嵌套在outer内部,所以可以访问这个 a 变量
  }
  
  return inner;
}

console.log(a); // 这个 log 是在全局作用域下,是在 outer 作用域外的,所以访问不到 outer 中的变量
// 但这里有一个注意点,a 虽然访问不到 outer 中的变量 a,但是他可以访问到全局作用域的 a,由于未定义,所以输出是 undefined (非严格模式下)

const fn = outer();
fn(); // 输出:1(即使 fn 在 outer 外部执行,仍能访问 outer 的 a,这就是经典的闭包)

一般和作用域同时提到的还有执行上下文,这是两个概念,需要注意区分:

  • 作用域是静态的,代码定义时确定,不关注代码的执行
  • 执行上下文是动态的,在代码执行的过程中动态的创建,包含 this ,变量对象,作用域链等信息,在每次调用函数时都会创建新的执行上下文。

作用域链

作用域链,顾名思义,就是一个链表,是由当前作用域和外层作用域组成的链表,用于解析变量引用。当代码在某个作用域访问一个变量时,会从当前作用域出发逐级向外层作用域去寻找变量,举个例子:

// 全局作用域
const globalVar = "全局变量";

function outer() {
  // 外层函数作用域
  const outerVar = "外层变量";
  
  function inner() {
    // 内层函数作用域
    const innerVar = "内层变量";
    console.log(innerVar); // 查找链:【找到】inner 作用域
    console.log(outerVar); // 查找链:inner 作用域 -> 【找到】outer 作用域
    console.log(globalVar); // 查找链:innter 作用域 -> outer 作用域 -> 【找到】全局作用域
  }
  
  inner();
}

outer();

inner 函数中访问 globalVar 变量时就是沿着作用域链逐级查找的。

链的构建过程

  1. 函数定义时:JavaScript 引擎会为函数关联一个 [[Scopes]] 内部属性,存储函数定义时所处的所有外层作用域;

  2. 函数调用时:创建该函数的执行上下文,此时作用域链会被初始化:

    • 链的第一个元素是当前执行上下文的变量对象(存储当前作用域的变量、函数);
    • 后续元素是函数 [[Scopes]] 属性中的外层作用域变量对象,按从内到外的顺序排列;
  3. 作用域链固化:作用域链在执行上下文创建时确定,后续不会因代码执行而改变。

以本节开始的代码为例:

  • inner 函数定义时,引擎会为其添加一个 [[Scopes]] 属性,其中包含:外层函数作用域(outer)、全局作用域;
  • inner 调用时,会创建执行上下文,该上下文的作用域链为:[inner 变量对象({innerVar: "内层变量"})→ outer 变量对象({outerVar: "外层变量"})→ 全局变量对象({globalVar: "全局变量"})],箭头表示链表的方向及连接。

变量查找规则

当访问一个变量时,JavaScript 引擎的查找步骤为:

  1. 从作用域链的第一个元素(当前作用域)开始查找
  2. 若找到变量,直接返回其值(或引用),停止查找
  3. 若未找到,继续查找作用域链的下一个元素(外层作用域)
  4. 依次类推,直到找到变量或遍历完整个作用域链
  5. 若遍历完所有作用域仍未找到,抛出 ReferenceError(变量未定义)、

需要注意的是,修改是直接作用到这个查找到的变量上的,比如:

const x = 1; // 全局变量

function foo() {
  x = 2; // 修改的是全局变量x,而非创建局部变量
  console.log(x); // 输出:2
}

foo();
console.log(x); // 输出:2(全局变量被修改)

在过去不默认声明严格模式的时候,我们在 foo 函数里面 x =2 会隐式的创建一个全局变量,在编码的时候是一个很大的坑,但是现在的项目基本都默认严格模式了,这种使用方式就会报错,提前为我们规避一些问题。

应用

闭包

闭包应该是作用域链的一个最典型、广泛的应用了,他是由函数和定义时的词法作用域组合成的,他允许函数在外部作用域执行时,依旧能够访问到该函数定义时的局部变量(函数作用域内的变量),举个例子:

function createCounter() {
  let count = 0; // 外层函数作用域变量
  
  // 内部函数引用了count,且被返回(导出到外部)
  return function increment() {
    count++;
    console.log(count);
  };
}

// increment在createCounter作用域之外执行
const counter = createCounter();
counter(); // 输出:1
counter(); // 输出:2
counter(); // 输出:3

从作用域的视角上看:

  • increment 函数定义的时候,其对应的 [[Scopes]] 属性存储的是 createCounter 的作用域和一个最外层的全局作用域,也就是说这时候相关的作用域就已经被这个 increment 函数持有了。
  • createCounter 执行结束后,该函数的上下文环境被销毁,但是由于 increment 依旧持有这个函数的作用域,而且 increment 被外部的 counter 变量所引用,不能被 GC,所以 increment 这时候也还存在着,并且此时有个别名 counter
  • counter 被调用的时创建的执行上下文的作用域链为:[increment 变量对象 → createCounter 作用域变量对象 → 全局变量对象]。

由于这样的链式关系和引用的持有,最终形成了闭包。

变量遮蔽

内存作用域和外层作用域存在同名变量时,内层的变量会遮蔽外层变量,在查找时优先访问内层变量。

const x = 10; // 外层变量

function bar() {
  const x = 20; // 内层变量,遮蔽外层x
  console.log(x); // 输出:20(访问内层x)
}

bar();
console.log(x); // 输出:10(访问外层x)

模块化方案

ES Module 的核心就是模块级作用域:

  1. 每个模块都是一个独立的词法作用域,这意味着顶层的变量不会再自动挂载到 window 对象上了,模块内的变量/函数仅在模块内可以访问。

  2. 需要通过export 导出变量/函数,其他模块通过 import 导入。

    这里有没有感觉很像闭包,必须将函数作用域的内容 return 后,才可以在外层作用域使用。

一些问题

变量提升

在代码执行前,JavaScript 引擎会将 var 声明的变量提升到作用域顶部,提升后的值为undefined,将函数声明提升到作用域顶部,值为函数本身。

值得注意的是,变量提升仅在当前作用域上生效,不会出现跨作用域提升的情况,如下:

console.log(a); // 输出:undefined(var声明的变量提升)
var a = 1;

function foo() {
  console.log(b); // 输出:undefined(函数作用域内的变量提升)
  var b = 2;
}

foo();
console.log(b); // 报错:b is not defined(b的提升仅在foo作用域内)

自 ES6 后,很少会在代码中再使用 var 关键字了,基本用的是 const/let 来声明变量,因为let/const声明的变量不会被提升,而是存在暂时性死区,即从作用域开始到变量声明前,访问该变量会报错。这是块级作用域的特性,避免了变量提升导致的逻辑混乱。

全局作用域污染

function badFunc() {
  // 未声明直接赋值,隐式创建全局变量
  unDeclaredVar = "我是污染的全局变量";
}

badFunc();
console.log(window.unDeclaredVar); // 输出:我是污染的全局变量

对于这种没有声明就直接赋值的写法,在非严格模式下,会隐式的创建一个全局变量,导致全局作用域被污染。

总结

理解了 JavaScript 的作用域和作用域链对我们理解闭包、模块化、高阶函数等特性能够有更好的帮助,也能让我们在实际开发中,更合理运用块级作用域(let/const)、模块化(ES Module),规避变量污染、逻辑混乱等问题,写出更健壮、可扩展的 JavaScript 代码。

作用域(Scope)与作用域链(Scope Chain)是 JavaScript 的核心概念,它们决定了变量的可访问范围、生命周期,以及代码运行时变量查找的规则,理解这两个概念,可以回答我们 “变量在这里为什么能访问”,“为什么这里的变量是 undefined” 等诸多疑问,还能帮助我们在开发过程中规避变量污染、提升代码的可维护性,对于 ES Module 等模块方案有更好的理解。

作用域的本质

很多开发者会把作用域简单理解为 “变量的存储位置”,但这只说对了一半。作用域的核心是一套 “变量访问规则” —— 它定义了在代码的哪个位置可以访问哪些变量,同时也隐含了变量的 “生存空间”(即变量何时被创建、何时被销毁)。

Vue页面渲染流程

作者 WebGirl
2025年11月20日 16:53

在 Vue 核心中除了响应式原理外,视图渲染也是重中之重。我们都知道每次更新数据,都会走视图渲染的逻辑,而这当中牵扯的逻辑也是十分繁琐。

本文主要解析的是初始化视图渲染流程,你将会了解到从挂载组件开始,Vue 是如何构建 VNode,又是如何将 VNode 转为真实节点并挂载到页面。

Vue2 的页面渲染流程可分为初始化渲染更新渲染两大阶段,核心围绕「VNode 构建」和「真实 DOM 生成」展开。以下是详细流程解析:

一、初始化渲染流程(首次渲染)

实例化与初始化(new Vue())

  • 通过 new Vue(options) 创建组件实例,触发 _init 方法(src/core/instance/init.js)。
  • _init 方法初始化组件选项(合并配置、初始化生命周期、事件、状态等),并判断是否存在 el 选项,若存在则调用 $mount 开始挂载。

Vue 是一个构造函数,通过 new 关键字进行实例化。

代码块 1(src/core/instance/index.js)

// src/core/instance/index.js
function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}

在实例化时,会调用 _init 进行初始化。

代码块 2(src/core/instance/init.js)

// src/core/instance/init.js
Vue.prototype._init = function (options?: Object) {
  // ... component = this
  if (vm.$options.el) {
    vm.$mount(vm.$options.el)
  }
}

挂载组件($mount)

  • $mount 方法最终调用 mountComponentsrc/core/instance/lifecycle.js),负责将组件挂载到 DOM。
// src/core/instance/lifecycle.js
export function mountComponent (
  vm: Component,
  el: Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  callHook(vm, 'beforeMount')

  let updateComponent
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    // ...
  } else {
    updateComponent = () => {
      vm._update(vm._render(), hydrating) // 渲染页面函数
    }
  }

  // we set this to vm._watcher inside the watcher's constructor
  // since the watcher's initial patch may call $forceUpdate (e.g. inside child
  // component's mounted hook), which relies on vm._watcher being already defined
  new Watcher(vm, updateComponent, noop, { // 渲染watcher
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false

  // manually mounted instance, call mounted on self
  // mounted is called for render-created child components in its inserted hook
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

mountComponent 除了调用一些生命周期的钩子函数外,最主要是 updateComponent,它就是负责渲染视图的核心方法,其只有一行核心代码:

vm._update(vm._render(), hydrating)
  • vm._render 创建并返回 VNode,vm._update 接收 VNode 将其转为真实节点。
  • 其中 vm._render() 生成 VNode,vm._update() 将 VNode 转为真实 DOM。
  • 创建「渲染 Watcher」,监听数据变化,首次执行 updateComponent 触发初始化渲染。
    • updateComponent 会被传入渲染 Watcher,每当数据变化触发 Watcher 更新就会执行该函数,重新渲染视图。
    • updateComponent 传入渲染 Watcher 后会被执行一次进行初始化页面渲染。

所以我们着重分析的是 vm._render 和 vm._update 两个方法,这也是本文主要了解的原理 ——Vue 视图渲染流程。

构建VNode(_render)

首先是 _render 方法,它用来构建组件的 VNode。

  • vm._render() 调用组件的 render 函数(模板编译生成或用户自定义),返回根 VNode(虚拟节点)。
  • render 函数通过 vm.$createElement(或编译生成的 vm._c)创建 VNode,内部调用 createElement 方法。

代码块 1(src/core/instance/render.js)

// src/core/instance/render.js
Vue.prototype._render = function () {
  const { render, _parentVnode } = vm.$options
  vnode = render.call(vm._renderProxy, vm.$createElement)
  return vnode
}

_render 内部会执行 render 方法并返回构建好的 VNode,render 一般是模板编译后生成的方法,也有可能是用户自定义。

代码块 2(src/core/instance/render.js)

// src/core/instance/render.js
export function initRender (vm) {
  vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
  vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
}
  • initRender 在初始化就会执行为实例上绑定两个方法,分别是 vm._c 和 vm.$createElement。它们两者都是调用 createElement 方法,它是创建 VNode 的核心方法,最后一个参数用于区别是否为用户自定义。
  • vm._c 应用场景是在编译生成的 render 函数中调用,vm.$createElement 则用于用户自定义 render 函数的场景。就像上面 render 在调用时会传入参数 vm.$createElement,我们在自定义 render 函数接收到的参数就是它。

createElement

  • createElement 封装 _createElement,处理参数并规范化子节点(children):
    • 若为编译生成的 render,调用 simpleNormalizeChildren 扁平化子节点数组。
    • 若为用户自定义 render,调用 normalizeChildren 将子节点转为 VNode 数组。
  • 根据 tag 类型创建对应 VNode:
    • 内置标签(如 div):直接创建普通 VNode。
    • 组件:调用 createComponent 创建组件类型 VNode。

代码块 3(src/core/vdom/create-element.js)

// src/core/vdom/create-element.js
export function createElement (
  context: Component,
  tag: any,
  data: any,
  children: any,
  normalizationType: any,
  alwaysNormalize: boolean
): VNode | Array<VNode> {
  if (Array.isArray(data) || isPrimitive(data)) {
    normalizationType = children
    children = data
    data = undefined
  }
  if (isTrue(alwaysNormalize)) {
    normalizationType = ALWAYS_NORMALIZE
  }
  return _createElement(context, tag, data, children, normalizationType)
}

createElement 方法实际上是对 _createElement 方法的封装,它允许传入的参数更加灵活。

export function _createElement (
  context: Component,
  tag: string | Class<Component> | Function | Object,
  data: VNodeData,
  children: any,
  normalizationType: number
): VNode | Array<VNode> {
  if (isDef(data) && isDef((data: any).is)) {
    tag = data.is
  }
  if (!tag) {
    // in case of component :is set to falsy value
    return createEmptyVNode()
  }
  // support single function children as default scoped slot
  if (Array.isArray(children) &&
    typeof children[0] === 'function'
  ) {
    data = data || {}
    data.scopedSlots = { default: children[0] }
    data.children = []
  }
  if (normalizationType === ALWAYS_NORMALIZE) {
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    children = simpleNormalizeChildren(children)
  }
  let vnode, ns
  // 根据tag类型创建VNode
  if (typeof tag === 'string') {
    let Ctor
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    if (config.isReservedTag(tag)) {
      // platform built-in elements
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
    } else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // unknown or unlisted namespaced elements
      // check at runtime because it may get assigned a namespace when its
      // parent normalizes children
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    }
  } else {
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
  }
  if (Array.isArray(vnode)) {
    return vnode
  } else if (isDef(vnode)) {
    if (isDef(ns)) applyNS(vnode, ns)
    if (isDef(data)) registerDeepBindings(data)
    return vnode
  } else {
    return createEmptyVNode()
  }
}

_createElement 参数中会接收 children,它表示当前 VNode 的子节点,因为它是任意类型的,所以接下来需要将其规范为标准的 VNode 数组;

// 这里规范化 children
if (normalizationType === ALWAYS_NORMALIZE) {
  children = normalizeChildren(children)
} else if (normalizationType === SIMPLE_NORMALIZE) {
  children = simpleNormalizeChildren(children)
}

simpleNormalizeChildren 和 normalizeChildren 均用于规范化 children。由 normalizationType 判断 render 函数是编译生成的还是用户自定义的。

// 1. when the children contains components - because a functional component
// may return an Array instead of a single root. In this case, just a simple
// normalization is needed. If the child is an Array, we flatten the whole
// thing with Array.prototype.concat. It is guaranteed to be only 1-level deep
// because functional components already normalize their own children.
export function simpleNormalizeChildren (children: any) {
  for (let i = 0; i < children.length; i++) {
    if (Array.isArray(children[i])) {
      return Array.prototype.concat.apply([], children)
    }
  }
  return children
}

// 2. when the children contains constructs that always generated nested Arrays,
// e.g. <template>, <slot>, v-for, or when the children is provided by user
// with hand-written render functions / JSX. In such cases a full normalization
// is needed to cater to all possible types of children values.
export function normalizeChildren (children: any): Array<VNode> {
  return isPrimitive(children)
    ? [createTextVNode(children)]
    : Array.isArray(children)
      ? normalizeArrayChildren(children)
      : undefined
}

simpleNormalizeChildren 方法调用场景是 render 函数当函数是编译生成的。normalizeChildren 方法的调用场景主要是 render 函数是用户手写的。

经过对 children 的规范化,children 变成了一个类型为 VNode 的数组。之后就是创建 VNode 的逻辑。

// src/core/vdom/patch.js
let vnode, ns
if (typeof tag === 'string') {
  let Ctor
  ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
  if (config.isReservedTag(tag)) {
    // platform built-in elements
    vnode = new VNode(
      config.parsePlatformTagName(tag), data, children,
      undefined, undefined, context
    )
  } else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
    // component
    vnode = createComponent(Ctor, data, context, children, tag)
  } else {
    // unknown or unlisted namespaced elements
    // check at runtime because it may get assigned a namespace when its
    // parent normalizes children
    vnode = new VNode(
      tag, data, children,
      undefined, undefined, context
    )
  }
} else {
  // direct component options / constructor
  vnode = createComponent(tag, data, context, children)
}

如果 tag 是 string 类型,则接着判断:如果是内置的一些节点,创建一个普通 VNode;如果是已注册的组件名,则通过 createComponent 创建一个组件类型的 VNode;否则创建一个未知的标签的 VNode。

如果 tag 不是 string 类型,那就是 Component 类型,则直接调用 createComponent 创建一个组件类型的 VNode 节点。

最后 _createElement 会返回一个 VNode,也就是调用 vm._render 时创建得到的 VNode。之后 VNode 会传递给 vm._update 函数,用于生成真实 DOM。

生成真实dom(_update 与 patch)

  • vm._update(vnode) 调用 vm.__patch__ 方法(平台相关实现,Web 端对应 patch 函数)。
  • patch 函数核心逻辑:
    1. 首次渲染时,oldVnode 为真实 DOM(如 #app),先通过 emptyNodeAt 转为空 VNode。
    2. 调用 createElm 将 VNode 递归转为真实 DOM 并插入父节点。
    3. 移除旧节点,触发 insert 钩子,完成渲染。
// 核心代码简化
Vue.prototype._update = function(vnode) {
  const vm = this;
  const prevVnode = vm._vnode;
  vm._vnode = vnode;
  if (!prevVnode) {
    // 首次渲染:将 VNode 转为真实 DOM 并挂载
    vm.$el = vm.__patch__(vm.$el, vnode);
  }
};

// patch 核心逻辑
function patch(oldVnode, vnode) {
  if (isRealElement(oldVnode)) {
    oldVnode = emptyNodeAt(oldVnode); // 真实 DOM 转空 VNode
  }
  createElm(vnode, parentElm); // 创建真实 DOM 并插入
  removeVnodes([oldVnode]); // 移除旧节点
}

代码块 1(src/core/instance/lifecycle.js)

// src/core/instance/lifecycle.js
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
  const vm: Component = this
  const prevEl = vm.$el
  const prevVnode = vm._vnode
  const prevActiveInstance = activeInstance
  activeInstance = vm
  vm._vnode = vnode
  // Vue.prototype.__patch__ is injected in entry points
  // based on the rendering backend used.
  if (!prevVnode) {
    // initial render
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    // updates
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
  activeInstance = prevActiveInstance
  // update __vue__ reference
  if (prevEl) {
    prevEl.__vue__ = null
  }
  if (vm.$el) {
    vm.$el.__vue__ = vm
  }
  // if parent is an HOC, update its $el as well
  if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
    vm.$parent.$el = vm.$el
  }
  // updated hook is called by the scheduler to ensure that children are
  // updated in a parent's updated hook.
}

_update 里最核心的方法就是 vm.__patch__ 方法,不同平台的 patch 方法的定义稍有不同,在 web 平台中它是这样定义的:

代码块 2(src/platforms/web/runtime/index.js)

// src/platforms/web/runtime/index.js
import { patch } from './patch'
// install platform patch function
Vue.prototype.__patch__ = inBrowser ? patch : noop

可以看到 __patch__ 实际调用的是 patch 方法。

代码块 3(src/platforms/web/runtime/patch.js)

// src/platforms/web/runtime/patch.js
import * as nodeOps from 'web/runtime/node-ops'
import { createPatchFunction } from 'core/vdom/patch'
import baseModules from 'core/vdom/modules/index'
import platformModules from 'web/runtime/modules/index'

// the directive module should be applied last, after all
// built-in modules have been applied.
const modules = platformModules.concat(baseModules)

export const patch: Function = createPatchFunction({ nodeOps, modules })

而patch方法是由createPatchFunction方法创建返回出来的函数。

// src/core/vdom/patch.js
const hooks = ['create', 'activate', 'update', 'remove', 'destroy']

export function createPatchFunction (backend) {
  let i, j
  const cbs = {}
  const { modules, nodeOps } = backend

  for (i = 0; i < hooks.length; i++) {
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; j++) {
      if (isDef(modules[j][hooks[i]])) {
        cbs[hooks[i]].push(modules[j][hooks[i]])
      }
    }
  }

  // ...

  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // ...
  }
}

这里有两个比较重要的对象 nodeOps 和 modules

  • nodeOps 是封装的原生 dom 操作方法,在生成真实节点树的过程中,dom 相关操作都是调用 nodeOps 内的方法。
  • modules 是待执行的钩子函数,在进入函数时,会将不同模块的钩子函数分类放置到 cbs 中,其中包括自定义指令钩子函数、ref 钩子函数。在 patch 阶段,会根据操作节点的行为取出对应类型进行调用。

Patch

 // initial render
2 vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)

在首次渲染时,vm.$el 对应的是根节点 dom 对象,也就是我们熟知的 id 为 app 的 div。它作为 oldVnode 参数传入 patch

return function patch(oldVnode, vnode, hydrating, removeOnly) {
  if (isUndef(vnode)) {
    if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
    return
  }

  let isInitialPatch = false
  const insertedVnodeQueue = []

  if (isUndef(oldVnode)) {
    // empty mount (likely as component), create new root element
    isInitialPatch = true
    createElm(vnode, insertedVnodeQueue)
  } else {
    const isRealElement = isDef(oldVnode.nodeType)
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // patch existing root node
      patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
    } else {
      if (isRealElement) {
        // mounting to a real element
        // check if this is server-rendered content and if we can perform
        // a successful hydration.
        if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
          oldVnode.removeAttribute(SSR_ATTR)
          hydrating = true
        }
        if (isTrue(hydrating)) {
          if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
            invokeInsertHook(vnode, insertedVnodeQueue, true)
            return oldVnode
          } else if (process.env.NODE_ENV !== 'production') {
            warn(
              'The client-side rendered virtual DOM tree is not matching ' +
              'server-rendered content. This is likely caused by incorrect ' +
              'HTML markup, for example nesting block-level elements inside ' +
              '<p>, or missing <tbody>. Bailing hydration and performing ' +
              'full client-side render.'
            )
          }
        }
        // either not server-rendered, or hydration failed.
        // create an empty node and replace it
        oldVnode = emptyNodeAt(oldVnode)
      }

      // replacing existing element
      const oldElm = oldVnode.elm
      const parentElm = nodeOps.parentNode(oldElm)

      // create new node
      createElm(
        vnode,
        insertedVnodeQueue,
        // extremely rare edge case: do not insert if old element is in a
        // leaving transition. Only happens when combining transition +
        // keep-alive + HOCs. (#4590)
        oldElm._leaveCb ? null : parentElm,
        nodeOps.nextSibling(oldElm)
      )

      // update parent placeholder node element, recursively
      if (isDef(vnode.parent)) {
        let ancestor = vnode.parent
        const patchable = isPatchable(vnode)
        while (ancestor) {
          for (let i = 0; i < cbs.destroy.length; ++i) {
            cbs.destroy[i](ancestor)
          }
          ancestor.elm = vnode.elm
          if (patchable) {
            for (let i = 0; i < cbs.create.length; ++i) {
              cbs.create[i](emptyNode, ancestor)
            }
            // #6513
            // invoke insert hooks that may have been merged by create hooks.
            // e.g. for directives that uses the "inserted" hook.
            const insert = ancestor.data.hook.insert
            if (insert.merged) {
              // start at index 1 to avoid re-invoking component mounted hook
              for (let i = 1; i < insert.fns.length; i++) {
                insert.fns[i]()
              }
            }
          } else {
            registerRef(ancestor)
          }
          ancestor = ancestor.parent
        }
      }

      // destroy old node
      if (isDef(parentElm)) {
        removeVnodes([oldVnode], 0, 0)
      } else if (isDef(oldVnode.tag)) {
        invokeDestroyHook(oldVnode)
      }
    }
  }

  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
  return vnode.elm
}

通过检查属性 nodeType(真实节点才有的属性),判断 oldVnode 是否为真实节点。

const isRealElement = isDef(oldVnode.nodeType)
if (isRealElement) {
  // ...
  oldVnode = emptyNodeAt(oldVnode)
}

很明显第一次的 isRealElement 是为 true,因此会调用 emptyNodeAt 将其转为 VNode:

function emptyNodeAt (elm) {
  return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
}

接着会调用 createElm 方法,它就是将 VNode 转为真实 dom 的核心方法:

递归创建DOM节点(createElm)

createElm 是 VNode 转真实 DOM 的核心方法:

  1. 若为组件 VNode,调用 createComponent 实例化组件并递归挂载。
  2. 若为普通元素,通过 nodeOps.createElement 创建真实节点,赋值给 vnode.elm
  3. 递归调用 createChildren 处理子节点,将子节点插入当前节点(先子后父的插入顺序)。
  4. 调用 insert 方法将节点插入父容器(使用 appendChild 或 insertBefore)。
function createElm (
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  if (isDef(vnode.elm) && isDef(ownerArray)) {
    // This vnode was used in a previous render!
    // now it's used as a new node, overwriting its elm would cause
    // potential patch errors down the road when it's used as an insertion
    // reference node. Instead, we clone the node on-demand before creating
    // associated DOM element for it.
    vnode = ownerArray[index] = cloneVNode(vnode)
  }

  vnode.isRootInsert = !nested // for transition enter check
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
  }

  const data = vnode.data
  const children = vnode.children
  const tag = vnode.tag
  if (isDef(tag)) {
    vnode.elm = vnode.ns
      ? nodeOps.createElementNS(vnode.ns, tag)
      : nodeOps.createElement(tag, vnode)
    setScope(vnode)

    /* istanbul ignore if */
    if (__WEEX__) {
      // ...
    } else {
      createChildren(vnode, children, insertedVnodeQueue) // 递归创建子节点
      if (isDef(data)) {
        invokeCreateHooks(vnode, insertedVnodeQueue)
      }
      insert(parentElm, vnode.elm, refElm)
    }
  } else if (process.env.NODE_ENV !== 'production' && data && data.pre) {
    creatingElmInPre--
  } else if (isTrue(vnode.isComment)) {
    vnode.elm = nodeOps.createComment(vnode.text)
    insert(parentElm, vnode.elm, refElm) // 插入父节点
  } else {
    vnode.elm = nodeOps.createTextNode(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  }
}

一开始会调用 createComponent 尝试创建组件类型的节点,如果成功会返回 true。在创建过程中也会调用 $mount 进行组件范围内的挂载,所以走的还是 patch 这套流程。

if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
  return
}

如果没有完成创建,代表该 VNode 对应的是真实节点,往下继续创建真实节点的逻辑。

vnode.elm = vnode.ns
  ? nodeOps.createElementNS(vnode.ns, tag)
  : nodeOps.createElement(tag, vnode)

根据 tag 创建对应类型真实节点,赋值给 vnode.elm,它作为父节点容器,创建的子节点会被放到里面。

然后调用 createChildren 创建子节点:

function createChildren (vnode, children, insertedVnodeQueue) {
  if (Array.isArray(children)) {
    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(children)
    }
    for (let i = 0; i < children.length; ++i) {
      createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
    }
  } else if (isPrimitive(vnode.text)) {
    nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
  }
}

内部进行遍历子节点数组,再次调用 createElm 创建节点,而上面创建的 vnode.elm 作为父节点传入。如此循环,直到没有子节点,就会创建文本节点插入到 vnode.elm 中。

执行完成后出来,会调用 invokeCreateHooks,它负责执行 dom 操作时的 create 钩子函数,同时将 VNode 加入到 insertedVnodeQueue 中:

function invokeCreateHooks (vnode, insertedVnodeQueue) {
  for (let i = 0; i < cbs.create.length; ++i) {
    cbs.create[i](emptyNode, vnode)
  }
  i = vnode.data.hook // Reuse variable
  if (isDef(i)) {
    if (isDef(i.create)) i.create(emptyNode, vnode)
    if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
  }
}

插入到父节点

最后一步就是调用 insert 方法将节点插入到父节点:

function insert (parent, elm, ref) {
  if (isDef(parent)) {
    if (isDef(ref)) {
      if (nodeOps.parentNode(ref) === parent) {
        nodeOps.insertBefore(parent, elm, ref)
      }
    } else {
      nodeOps.appendChild(parent, elm)
    }
  }
}

可以看到 Vue 是通过递归调用 createElm 来创建节点树的。同时也说明最深的子节点会先调用 insert 插入节点。所以整个节点树的插入顺序是 “先子后父”。插入节点方法就是原生 dom 的方法 insertBefore 和 appendChild

if (isDef(parentElm)) {
  removeVnodes([oldVnode], 0, 0)
}

createElm 流程走完后,构建完成的节点树已经插入到页面上了。其实 Vue 在初始化渲染页面时,并不是把原来的根节点 app 给真正替换掉,而是在其后面插入一个新的节点,接着再把旧节点给移除掉。

所以在 createElm 之后会调用 removeVnodes 来移除旧节点,它里面同样是调用的原生 dom 方法 removeChild

invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
function invokeInsertHook (vnode, queue, initial) {
  // delay insert hooks for component root nodes, invoke them after the
  // element is really inserted
  if (isTrue(initial) && isDef(vnode.parent)) {
    vnode.parent.data.pendingInsert = queue
  } else {
    for (let i = 0; i < queue.length; ++i) {
      queue[i].data.hook.insert(queue[i])
    }
  }
}

在 patch 的最后就是调用 invokeInsertHook 方法,触发节点插入的钩子函数。

至此整个页面渲染的流程完毕。

完成渲染

  • 触发 mounted 生命周期钩子(所有子组件挂载完成后),页面渲染完毕。

二、更新渲染流程(数据变化时)

当响应式数据变化时,触发渲染 Watcher 的更新,重新执行 updateComponent,流程如下:

  1. 触发 beforeUpdate 生命周期钩子。
  2. 重新调用 vm._render() 生成新 VNode。
  3. 调用 vm._update(),通过 patch 对比新旧 VNode(sameVnode 检查):
    • 若为同一节点,调用 patchVnode 对比并更新差异(属性、文本、子节点等)。
    • 若为不同节点,直接创建新节点并替换旧节点。
  4. 触发 updated 生命周期钩子,完成更新。

三、总结

image.png

  • 初始化调用$mount挂载组件。 - _render开始构建VNode,核心方法为createElement,一般会创建普通的VNode,遇到组件就创建组件类型的VNode,否则就是未知标签的VNode,构建完成传递给_update
  • patch阶段根据VNode创建真实节点树,核心方法为createElm,首选遇到组件类型的VNode,内部会执行$mount,再走一遍相同的流程。普通节点类型则创建一个真实的节点,如果它有子节点开始递归调用createElm,使用insert插入子节点,直到没有子节点就填充内容节点。
  • 最后递归完成后,同样也是使用insert将整个节点树插入到页面中,再将旧的根节点移除。
  1. 初始化渲染new Vue() → _init → $mount → mountComponent → _render(生成 VNode) → _updatepatch 生成真实 DOM)。
  2. 更新渲染:数据变化 → 渲染 Watcher 触发 → 重新生成 VNode → patch 对比更新 DOM。
  3. 核心思想:通过 VNode 抽象 DOM,减少直接操作 DOM 的开销,通过 Diff 算法高效更新差异。

TypeScript 进阶知识总结

2025年11月20日 16:47

本文深入探讨 TypeScript 类型系统的图灵完备性,涵盖从高级泛型、条件类型、模板字面量类型到元编程(装饰器)和 React 高级模式。

内容分为几个核心模块,每个模块都包含详细的理论解释和生产级代码示例。


模块一:高级泛型与类型体操基础

在高级开发中,泛型不仅仅是 <T>,它涉及到约束(Constraints)、默认值和递归。

1.1 深度递归类型与对象属性修饰

我们需要处理复杂的不可变数据结构或深度可选配置。

/**
 * 模块一:高级泛型工具类型
 * 场景:复杂状态管理、配置对象处理
 */

// 1. DeepPartial: 递归地将对象所有属性变为可选
// 这在处理大型配置对象(如 Webpack 配置或图表库配置)时非常有用
type DeepPartial<T> = {
    [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

// 2. DeepReadonly: 递归地将对象所有属性变为只读
// 用于 Redux 的 State 或任何不可变数据结构
type DeepReadonly<T> = {
    readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};

// 3. DeepRequired: 递归移除可选属性
type DeepRequired<T> = {
    [P in keyof T]-?: T[P] extends object ? DeepRequired<T[P]> : T[P];
};

// 4. Mutable: 移除只读属性
type Mutable<T> = {
    -readonly [P in keyof T]: T[P];
};

// --- 测试案例 ---

interface UserSettings {
    theme: {
        mode: 'dark' | 'light';
        palette: {
            primary: string;
            secondary?: string;
        };
    };
    notifications: {
        email: boolean;
        sms?: boolean;
    };
}

// 使用 DeepPartial,允许只配置部分层级
const initialConfig: DeepPartial<UserSettings> = {
    theme: {
        palette: {
            primary: '#007bff'
        }
    }
};

// 使用 DeepReadonly 保护状态
const state: DeepReadonly<UserSettings> = {
    theme: {
        mode: 'dark',
        palette: { primary: '#000', secondary: '#fff' }
    },
    notifications: { email: true, sms: false }
};

// state.theme.mode = 'light'; // 报错:无法分配到 "mode" ,因为它是只读属性。

// --- 进阶:复杂键值过滤 ---

// 获取类型中所有非函数属性的 Key
type NonFunctionKeys<T> = {
    [K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];

// 获取类型中所有函数属性的 Key
type FunctionKeys<T> = {
    [K in keyof T]: T[K] extends Function ? K : never;
}[keyof T];

// 仅提取对象中的数据部分
type PickData<T> = Pick<T, NonFunctionKeys<T>>;
// 仅提取对象中的方法部分
type PickMethods<T> = Pick<T, FunctionKeys<T>>;

class UserService {
    id: number = 1;
    name: string = "Admin";
    
    login(): void { console.log("Logging in..."); }
    logout(): void { console.log("Logging out..."); }
    updateProfile(data: PickData<UserService>): void { 
        this.name = data.name;
    }
}

const service = new UserService();
const dataOnly: PickData<UserService> = { id: 2, name: "User" };
// const methodOnly: PickMethods<UserService> = { login: () => {} }; // 正确

模块二:条件类型(Conditional Types)与 infer 关键字

这是 TypeScript 类型编程的核心。通过 infer,我们可以“解包”类型,例如获取 Promise 的返回值、数组的元素类型或函数的参数类型。

2.1 类型推断与解包工具

/**
 * 模块二:条件类型与 infer
 * 场景:API 响应处理、函数包装器、Vue/React 类型推导
 */

// 1. Unpacked: 解包数组、Promise 或基本类型
type Unpacked<T> = T extends (infer U)[] 
    ? U 
    : T extends Promise<infer U>
    ? U
    : T;

// 测试 Unpacked
type T0 = Unpacked<string>;  // string
type T1 = Unpacked<string[]>; // string
type T2 = Unpacked<Promise<string>>; // string
type T3 = Unpacked<Promise<string>[]>; // Promise<string>

// 2. GetReturnType: 获取函数返回类型 (内置 ReturnType 的实现原理)
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

// 3. GetParameters: 获取函数参数类型 (内置 Parameters 的实现原理)
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;

// --- 实战:类型安全的 API 请求封装 ---

interface ApiResponse<TData> {
    code: number;
    message: string;
    data: TData;
}

// 模拟后端 API 函数
function getUserInfo(userId: string): Promise<ApiResponse<{ name: string; age: number }>> {
    return Promise.resolve({
        code: 200,
        message: "success",
        data: { name: "Alice", age: 30 }
    });
}

function getPosts(): Promise<ApiResponse<{ title: string }[]>> {
    return Promise.resolve({
        code: 200,
        message: "success",
        data: [{ title: "TS is awesome" }]
    });
}

// 高级工具:提取 API 函数 Promise 解析后的 Data 部分
// 逻辑:
// 1. T 必须是一个函数
// 2. 函数返回 Promise<ApiResponse<Data>>
// 3. 提取 Data
type ExtractApiData<T extends (...args: any) => any> = 
    ReturnType<T> extends Promise<ApiResponse<infer Data>> ? Data : never;

// 自动推导出的类型
type UserData = ExtractApiData<typeof getUserInfo>; // { name: string; age: number }
type PostsData = ExtractApiData<typeof getPosts>;   // { title: string }[]

// 通用处理函数,自动保持类型
async function apiHandler<TFunc extends (...args: any) => any>(
    apiFunc: TFunc, 
    ...args: Parameters<TFunc>
): Promise<ExtractApiData<TFunc>> {
    const response = await apiFunc(...args);
    // 这里假设 response 结构符合 ApiResponse
    // 在实际项目中通常会有拦截器处理
    if ((response as any).code === 200) {
        return (response as any).data;
    }
    throw new Error((response as any).message);
}

// 使用示例
async function main() {
    // user 自动被推断为 { name: string; age: number }
    const user = await apiHandler(getUserInfo, "123");
    console.log(user.name);

    // posts 自动被推断为 { title: string }[]
    const posts = await apiHandler(getPosts);
    console.log(posts[0].title);
}

模块三:模板字面量类型(Template Literal Types)

TS 4.1 引入的特性,允许对字符串进行模式匹配和操作。这在处理 CSS 类名、事件名称或路由路径时非常强大。

/**
 * 模块三:模板字面量类型
 * 场景:CSS-in-JS、事件系统、国际化键值生成
 */

// 1. 简单的字符串拼接
type Color = "red" | "blue";
type Quantity = "100" | "200";
type ItemCssClass = `bg-${Color}-${Quantity}`; 
// "bg-red-100" | "bg-red-200" | "bg-blue-100" | "bg-blue-200"

// 2. 事件名称生成器
type Entity = "User" | "Product" | "Order";
type Operation = "Create" | "Update" | "Delete";

// 自动生成所有可能的事件名
type EventName = `${Entity}${Operation}`; 
// "UserCreate" | "UserUpdate" | ... | "OrderDelete"

// --- 实战:类型安全的事件总线 (Event Bus) ---

// 定义事件负载映射
type EventPayloads = {
    "user:login": { userId: string; time: number };
    "user:logout": void;
    "modal:open": { title: string; content: string };
    "modal:close": void;
};

// 字符串操作工具类型
type Split<S extends string, D extends string> = 
    string extends S ? string[] :
    S extends '' ? [] :
    S extends `${infer T}${D}${infer U}` ? [T, ...Split<U, D>] : [S];

// 测试 Split
type Parts = Split<"user:login:success", ":">; // ["user", "login", "success"]

class TypedEventBus {
    private listeners: { [K in keyof EventPayloads]?: ((payload: EventPayloads[K]) => void)[] } = {};

    // on 方法:EventName 必须是 EventPayloads 的键,cb 参数自动推导
    on<K extends keyof EventPayloads>(eventName: K, cb: (payload: EventPayloads[K]) => void) {
        if (!this.listeners[eventName]) {
            this.listeners[eventName] = [];
        }
        this.listeners[eventName]!.push(cb);
    }

    // emit 方法:payload 类型必须匹配
    emit<K extends keyof EventPayloads>(eventName: K, payload: EventPayloads[K]) {
        const callbacks = this.listeners[eventName];
        if (callbacks) {
            callbacks.forEach(cb => cb(payload));
        }
    }
}

const bus = new TypedEventBus();

// 类型安全:payload 自动推断为 { userId: string; time: number }
bus.on("user:login", (payload) => {
    console.log(`User ${payload.userId} logged in at ${payload.time}`);
});

// 类型报错演示
// bus.emit("user:login", { userId: 123 }); // Error: Type 'number' is not assignable to type 'string'.
bus.emit("user:login", { userId: "u_001", time: Date.now() }); // OK

// --- 进阶:CSS Padding/Margin 简写类型检查 ---

type SizeUnit = "px" | "em" | "rem" | "%";
type SizeValue = `${number}${SizeUnit}` | "0" | "auto";

// 允许 1 到 4 个值
type CSSPadding = 
    | SizeValue
    | `${SizeValue} ${SizeValue}`
    | `${SizeValue} ${SizeValue} ${SizeValue}`
    | `${SizeValue} ${SizeValue} ${SizeValue} ${SizeValue}`;

function setPadding(el: HTMLElement, padding: CSSPadding) {
    el.style.padding = padding;
}

// setPadding(document.body, "10px"); // OK
// setPadding(document.body, "10px 20px"); // OK
// setPadding(document.body, "10px 20px 0 5%"); // OK
// setPadding(document.body, "10"); // Error: 缺少单位
// setPadding(document.body, "red"); // Error

模块四:装饰器(Decorators)与元编程

虽然装饰器在 ECMAScript 中仍处于演进阶段,但在 TypeScript(尤其是 Angular 和 NestJS 生态)中应用广泛。这里展示基于 TypeScript 实验性装饰器的高级用法。

/**
 * 模块四:装饰器与 AOP (面向切面编程)
 * 注意:需要在 tsconfig.json 中开启 "experimentalDecorators": true
 */

// 1. 方法装饰器:日志记录与性能监控
function LogPerformance(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;

    descriptor.value = async function (...args: any[]) {
        const start = performance.now();
        console.log(`[Starting] ${propertyKey} with args:`, JSON.stringify(args));
        
        try {
            const result = await originalMethod.apply(this, args);
            const end = performance.now();
            console.log(`[Finished] ${propertyKey} in ${(end - start).toFixed(2)}ms`);
            return result;
        } catch (error) {
            console.error(`[Error] ${propertyKey} failed:`, error);
            throw error;
        }
    };
    return descriptor;
}

// 2. 参数装饰器与方法装饰器配合:参数校验
import "reflect-metadata"; // 需要 npm install reflect-metadata

const REQUIRED_METADATA_KEY = Symbol("required");

// 参数装饰器:标记哪个参数是必填的
function Required(target: Object, propertyKey: string | symbol, parameterIndex: number) {
    let existingRequiredParameters: number[] = Reflect.getOwnMetadata(REQUIRED_METADATA_KEY, target, propertyKey) || [];
    existingRequiredParameters.push(parameterIndex);
    Reflect.defineMetadata(REQUIRED_METADATA_KEY, existingRequiredParameters, target, propertyKey);
}

// 方法装饰器:执行校验逻辑
function Validate(target: any, propertyName: string, descriptor: PropertyDescriptor) {
    let method = descriptor.value;
    descriptor.value = function () {
        let requiredParameters: number[] = Reflect.getOwnMetadata(REQUIRED_METADATA_KEY, target, propertyName);
        if (requiredParameters) {
            for (let parameterIndex of requiredParameters) {
                if (parameterIndex >= arguments.length || arguments[parameterIndex] === undefined || arguments[parameterIndex] === null) {
                    throw new Error(`Missing required argument at index ${parameterIndex} for method ${propertyName}`);
                }
            }
        }
        return method.apply(this, arguments);
    }
}

// 3. 类装饰器:依赖注入 (简易版 IOC 容器)
type Constructor<T = any> = new (...args: any[]) => T;

const ServiceMap = new Map<string, any>();

function Service(name: string) {
    return function(constructor: Constructor) {
        console.log(`Registering service: ${name}`);
        ServiceMap.set(name, new constructor());
    }
}

function Inject(serviceName: string) {
    return function(target: any, propertyKey: string) {
        // 在属性被访问时懒加载注入
        Object.defineProperty(target, propertyKey, {
            get: () => {
                const service = ServiceMap.get(serviceName);
                if (!service) throw new Error(`Service ${serviceName} not found`);
                return service;
            },
            enumerable: true,
            configurable: true
        });
    }
}

// --- 综合应用 ---

@Service("Logger")
class LoggerService {
    log(msg: string) { console.log(`[LOG SERVICE]: ${msg}`); }
}

class UserController {
    @Inject("Logger")
    private logger!: LoggerService;

    @Validate
    @LogPerformance
    async createUser(@Required name: string, @Required email: string, age?: number) {
        // 模拟异步操作
        await new Promise(resolve => setTimeout(resolve, 100));
        
        this.logger.log(`Creating user: ${name} (${email})`);
        return { id: Math.floor(Math.random() * 1000), name, email };
    }
}

// 测试运行
async function runDecorators() {
    const controller = new UserController();
    
    try {
        await controller.createUser("Bob", "bob@example.com"); // 成功
        // await controller.createUser("Alice", null as any); // 抛出 Error: Missing required argument
    } catch (e) {
        console.error(e);
    }
}
// runDecorators();

模块五:React 高级模式与多态组件

在前端开发中,React 的类型定义尤为重要。我们将构建一个多态组件(Polymorphic Component),它可以根据 as 属性改变渲染的 HTML 标签,同时保持类型安全。

/**
 * 模块五:React 高级类型模式
 * 场景:组件库开发、高阶组件 (HOC)
 */

import React from 'react';

// 1. 多态组件 (Polymorphic Components)
// 这是一个非常高级的模式,允许 <Text as="h1"> 或 <Text as="a" href="...">

// 获取元素自身的 Props,排除我们要重写的 'as'
type AsProp<C extends React.ElementType> = {
    as?: C;
};

type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);

// 这是一个复杂的类型定义:
// 1. 接收泛型 C (组件类型) 和 Props
// 2. 合并传入的 Props 和 as 属性
// 3. 合并 HTML 原生属性 (Omit 掉重复的)
type PolymorphicComponentProps<C extends React.ElementType, Props = {}> = 
    React.PropsWithChildren<Props & AsProp<C>> &
    Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;

// 定义组件类型
type PolymorphicRef<C extends React.ElementType> = React.ComponentPropsWithRef<C>["ref"];

type PolymorphicComponentPropWithRef<C extends React.ElementType, Props = {}> = 
    PolymorphicComponentProps<C, Props> & { ref?: PolymorphicRef<C> };

// 实现组件
type TextProps = {
    color?: 'primary' | 'secondary' | 'danger';
    size?: 'sm' | 'md' | 'lg';
};

// 使用 React.forwardRef 实现
// 注意:由于泛型组件和 forwardRef 的结合在 TS 中比较棘手,通常需要类型断言
export const Text = React.forwardRef(
    <C extends React.ElementType = "span">(
        { as, color = 'primary', size = 'md', children, ...rest }: PolymorphicComponentProps<C, TextProps>,
        ref: PolymorphicRef<C>
    ) => {
        const Component = as || "span";
        
        const style = {
            color: color === 'danger' ? 'red' : 'black',
            fontSize: size === 'lg' ? '20px' : '14px'
        };

        return (
            <Component ref={ref} style={style} {...rest}>
                {children}
            </Component>
        );
    }
) as <C extends React.ElementType = "span">(
    props: PolymorphicComponentPropWithRef<C, TextProps>
) => React.ReactElement | null;

// --- 使用示例 ---

function App() {
    return (
        <div>
            {/* 渲染为 span (默认) */}
            <Text>Hello World</Text>
            
            {/* 渲染为 h1 */}
            <Text as="h1" size="lg">Title</Text>
            
            {/* 渲染为 a 标签,TS 会自动提示 href 属性是必须的/可用的 */}
            <Text as="a" href="https://google.com" color="danger">
                Link
            </Text>
            
            {/* 错误演示:div 没有 href 属性 */}
            {/* <Text as="div" href="..." /> // Error: Property 'href' does not exist on type... */}
        </div>
    );
}

// 2. 类型安全的 Render Props
interface ListProps<T> {
    items: T[];
    renderItem: (item: T, index: number) => React.ReactNode;
}

// 使用泛型组件定义
function List<T>({ items, renderItem }: ListProps<T>) {
    return (
        <ul>
            {items.map((item, index) => (
                <li key={index}>{renderItem(item, index)}</li>
            ))}
        </ul>
    );
}

// 使用
// <List 
//   items={[{ id: 1, name: 'A' }, { id: 2, name: 'B' }]} 
//   renderItem={(item) => <span>{item.name}</span>} // item 自动推断为对象
// />

模块六:混合(Mixins)与类的组合

TypeScript 支持 Mixin 模式,允许我们将多个类的功能组合到一个类中。这在构建复杂的 UI 组件库(如 Material CDK)时非常常见。

/**
 * 模块六:Mixins
 * 场景:多重继承模拟、功能组合
 */

// 定义构造函数类型
type Constructor<T = {}> = new (...args: any[]) => T;

// 1. Activatable Mixin: 添加激活/未激活状态
function Activatable<TBase extends Constructor>(Base: TBase) {
    return class extends Base {
        isActivated: boolean = false;

        activate() {
            this.isActivated = true;
            console.log("Activated!");
        }

        deactivate() {
            this.isActivated = false;
            console.log("Deactivated!");
        }
    };
}

// 2. Disposable Mixin: 添加资源清理功能
function Disposable<TBase extends Constructor>(Base: TBase) {
    return class extends Base {
        private disposables: (() => void)[] = [];

        addDisposable(cb: () => void) {
            this.disposables.push(cb);
        }

        dispose() {
            this.disposables.forEach(cb => cb());
            this.disposables = [];
            console.log("Resources disposed.");
        }
    };
}

// 3. Timestamped Mixin: 记录创建时间
function Timestamped<TBase extends Constructor>(Base: TBase) {
    return class extends Base {
        timestamp = new Date();
    };
}

// --- 组合使用 ---

class BaseComponent {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    
    render() {
        console.log(`Rendering ${this.name}`);
    }
}

// 创建一个拥有所有能力的类
// 注意:Mixin 的应用顺序会影响类的层级
const SmartComponent = Timestamped(Disposable(Activatable(BaseComponent)));

// 演示
const comp = new SmartComponent("MyButton");

comp.render(); // 来自 BaseComponent
comp.activate(); // 来自 Activatable
console.log(`Created at: ${comp.timestamp}`); // 来自 Timestamped

comp.addDisposable(() => console.log("Cleaning up event listeners..."));
comp.dispose(); // 来自 Disposable

模块七:高级类型守卫与断言

运行时类型检查对于前端应用的健壮性至关重要。

/**
 * 模块七:类型守卫 (Type Guards) 与断言
 * 场景:处理联合类型、运行时数据验证
 */

// 1. 区分联合类型 (Discriminated Unions)
interface Square {
    kind: "square";
    size: number;
}

interface Rectangle {
    kind: "rectangle";
    width: number;
    height: number;
}

interface Circle {
    kind: "circle";
    radius: number;
}

type Shape = Square | Rectangle | Circle;

// 计算面积:利用 switch 的类型收窄
function area(s: Shape): number {
    switch (s.kind) {
        case "square":
            // 在这里 s 被收窄为 Square
            return s.size * s.size;
        case "rectangle":
            // 在这里 s 被收窄为 Rectangle
            return s.width * s.height;
        case "circle":
            return Math.PI * s.radius ** 2;
        default:
            // 穷尽性检查 (Exhaustiveness checking)
            // 如果未来添加了新形状但没处理,这里会报错
            const _exhaustiveCheck: never = s;
            return _exhaustiveCheck;
    }
}

// 2. 用户自定义类型守卫 (User-Defined Type Guards)
// 返回值类型必须是 `arg is Type`

function isString(value: unknown): value is string {
    return typeof value === "string";
}

function isDate(value: unknown): value is Date {
    return value instanceof Date;
}

// 复杂对象的类型守卫
interface ApiError {
    error: true;
    code: number;
    details: string;
}

interface ApiSuccess<T> {
    error: false;
    data: T;
}

type ApiResult<T> = ApiSuccess<T> | ApiError;

function isSuccess<T>(result: ApiResult<T>): result is ApiSuccess<T> {
    return result.error === false;
}

// 使用
function handleApiResult(result: ApiResult<string>) {
    if (isSuccess(result)) {
        // TS 知道这里是 ApiSuccess,可以安全访问 .data
        console.log(result.data.toUpperCase());
    } else {
        // TS 知道这里是 ApiError
        console.error(`Error ${result.code}: ${result.details}`);
    }
}

// 3. 断言签名 (Assertion Signatures) - TS 3.7+
// 类似于 Node.js 的 assert 模块
function assertIsNumber(val: any): asserts val is number {
    if (typeof val !== "number") {
        throw new Error("Not a number!");
    }
}

function double(input: any) {
    assertIsNumber(input);
    // 此行之后,input 被视为 number
    return input * 2;
}

模块八:类型体操挑战(Type Gymnastics)

为了展示 TS 的极限能力,我们实现一些类似 Lodash 函数的类型版本。

/**
 * 模块八:类型体操
 * 场景:库作者、极度复杂的类型推导
 */

// 1. 实现 Join 类型 (Array.join 的类型版)
type Join<T extends any[], U extends string | number> = 
    T extends [infer F, ...infer R]
        ? R['length'] extends 0
            ? `${F & string}`
            : `${F & string}${U}${Join<R, U>}`
        : "";

type Path = Join<['users', 'id', 'posts'], '/'>; // "users/id/posts"

// 2. 实现 DeepValue (根据路径获取深度属性值)
// 类似于 lodash.get(obj, "a.b.c")

type GetPropType<T, P extends string> = 
    P extends keyof T 
        ? T[P] 
        : P extends `${infer K}.${infer R}`
            ? K extends keyof T 
                ? GetPropType<T[K], R>
                : never
            : never;

// 测试 DeepValue
interface Config {
    app: {
        database: {
            host: string;
            port: number;
        }
    }
}

type DBHost = GetPropType<Config, "app.database.host">; // string
type DBPort = GetPropType<Config, "app.database.port">; // number
type Invalid = GetPropType<Config, "app.database.password">; // never

// 3. 元组转对象
const tuple = ['tesla', 'model 3', 'model X', 'model Y'] as const;

type TupleToObject<T extends readonly any[]> = {
    [P in T[number]]: P;
};

type CarModelObj = TupleToObject<typeof tuple>;
// { tesla: "tesla", "model 3": "model 3", ... }

// 4. 柯里化函数的类型推导 (Currying)
type Curry<P extends any[], R> = 
    (arg: Head<P>) => HasTail<P> extends true ? Curry<Tail<P>, R> : R;

type Head<T extends any[]> = T extends [any, ...any[]] ? T[0] : never;
type Tail<T extends any[]> = ((...t: T) => any) extends ((_: any, ...tail: infer TT) => any) ? TT : [];
type HasTail<T extends any[]> = T extends ([] | [any]) ? false : true;

// 这是一个简化的定义,实际柯里化类型非常复杂
declare function curry<P extends any[], R>(f: (...args: P) => R): Curry<P, R>;

const add = (a: number, b: number, c: number) => a + b + c;
const curriedAdd = curry(add);

// const sum = curriedAdd(1)(2)(3); // 理想情况下推导出 number

模块九:声明合并与模块扩展 (Module Augmentation)

在扩展第三方库(如给 Window 对象添加属性,或扩展 React 的 Theme)时必不可少。

/**
 * 模块九:模块扩展
 * 场景:全局变量、第三方库补丁
 */

// 1. 扩展全局 Window 对象
declare global {
    interface Window {
        __REDUX_DEVTOOLS_EXTENSION__: any;
        Analytics: {
            track: (event: string) => void;
        };
    }
    
    // 扩展 String 原型
    interface String {
        toTitleCase(): string;
    }
}

// 实现扩展的方法
String.prototype.toTitleCase = function() {
    return this.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase());
};

// 使用
// window.Analytics.track("Page View");
// const title = "hello world".toTitleCase();

// 2. 扩展第三方库 (以 styled-components 为例)
// 假设这是一个名为 'my-theme-lib' 的库
// import 'my-theme-lib';

// declare module 'my-theme-lib' {
//     export interface DefaultTheme {
//         borderRadius: string;
//         colors: {
//             main: string;
//             secondary: string;
//         };
//     }
// }

总结与最佳实践

建议:

  1. 不要过度设计:虽然泛型和条件类型很强大,但如果一个类型定义超过了 10 行且难以阅读,考虑简化它或添加详细注释。
  2. 利用 unknown 代替 any:在不确定类型时,unknown 强制你在使用前进行类型检查,比 any 安全得多。
  3. 优先使用接口 (Interface) 定义对象:接口支持声明合并,对库的扩展性更好;类型别名 (Type Alias) 更适合联合类型和元组。
  4. 严格模式:始终开启 strict: true,特别是 strictNullChecks,这能避免 80% 的运行时错误。

Vue2 的关于 $set 的反直觉行为:为什么先赋值再 $set 不会触发 watch?

作者 wenps
2025年11月20日 16:43

Vue $set 的反直觉行为:为什么先赋值再 $set 不会触发 watch?

核心问题

watch: {
  c(val) {
    console.log('watch 触发了')
  }
}

// 路径 1:直接 $set
this.$set(this.c, 'b', 1000)
// ✅ 触发 watch

// 路径 2:先赋值再 $set  
this.c.b = 1000
this.$set(this.c, 'b', 1000)
// ❌ 不触发 watch

为什么同样用了 $set,结果却不同?


原因:$set 内部有属性存在性检查

$set 源码简化

function set(target, key, val) {
  // 关键判断:属性是否已存在
  if (key in target && !(key in Object.prototype)) {
    target[key] = val  // ← 只做普通赋值
    return val         // ← 直接返回,不触发通知
  }
  
  // 属性不存在才走这里
  const ob = target.__ob__
  defineReactive(target, key, val)  // 定义响应式
  ob.dep.notify()  // ← 触发 watch!
  return val
}

关键点

$set 只在属性不存在时才调用 dep.notify()


两条路径的详细分析

路径 1:直接 $set(✅ 触发)

// 初始状态
c = { a: 1 }  // b 不存在

// 执行
this.$set(this.c, 'b', 1000)

// 内部逻辑
'b' in c  // → false(属性不存在)
↓
添加响应式属性 b
↓
c.__ob__.dep.notify()  // ← 触发 watch!

路径 2:先赋值再 $set(❌ 不触发)

// 初始状态
c = { a: 1 }  // b 不存在

// 第 1 步:普通赋值
this.c.b = 1000
// 现在:c = { a: 1, b: 1000 }
// 但 b 不是响应式的!

// 第 2 步:$set
this.$set(this.c, 'b', 1000)

// 内部逻辑
'b' in c  // → true(属性已存在!)
↓
只执行:c.b = 1000(普通赋值)
↓
不调用 dep.notify()  // ← 不触发 watch!

Vue 为什么这样设计?

Vue 的设计逻辑:

  1. 新属性 = 对象结构变化 → 需要通知所有观察者
  2. 已存在的属性 = 只是值变化 → 应该通过属性自己的 setter 触发

但问题是:路径 2 中的 b 虽然存在,但不是响应式的(没有 setter),所以:

  • $set 认为它已存在,不调用 notify()
  • 但它没有 setter,值变化不会触发更新
  • 结果就是:既不通知对象的观察者,也不触发属性的 setter

完整验证代码

data() {
  return {
    c: { a: 1 }
  }
},

watch: {
  c(val) {
    console.log('watch 触发了!', val)
  }
},

mounted() {
  console.log('=== 测试 1:直接 $set ===')
  this.$set(this.c, 'b', 1000)
  // ✅ 打印 "watch 触发了!{a: 1, b: 1000}"
  
  setTimeout(() => {
    console.log('=== 测试 2:先赋值再 $set ===')
    this.c.d = 2000  // 先创建普通属性
    this.$set(this.c, 'd', 3000)  // 再用 $set
    // ❌ 不打印任何东西
  }, 1000)
  
  setTimeout(() => {
    console.log('=== 测试 3:$set 两次 ===')
    this.$set(this.c, 'e', 4000)  // 第一次
    // ✅ 打印 "watch 触发了!"
    
    this.$set(this.c, 'e', 5000)  // 第二次
    // ✅ 也会触发(因为 e 已经是响应式的)
  }, 2000)
}

dep.notify() 触发的是什么?

Vue 的两层依赖收集

const c = {
  a: 1,
  __ob__: {
    dep: new Dep()  // ← 对象自己的 dep
  }
}

// 同时,每个响应式属性也有自己的 dep(通过闭包)
Object.defineProperty(c, 'a', {
  get() {
    // 收集依赖到 a 的 dep
  },
  set(val) {
    // 通知 a 的 dep
  }
})

区别

操作 触发的 dep watch: c watch: 'c.a'
c.a = 2 a 的 dep ❌ 不触发 ✅ 触发
$set(c, 'b', 1) c.__ob__.dep ✅ 触发 -
c = {} 对象替换 ✅ 触发 -

为什么不需要 deep 也能触发?

watch: {
  c(val) {  // 没有 deep: true
    console.log('触发了')
  }
}

this.$set(this.c, 'b', 1000)
// ✅ 会触发

原因:

  • $set 触发的是 c.__ob__.dep(对象自己的 dep)
  • 监听 c 的 watcher 收集的就是这个 dep
  • 不需要 deep,因为是对象结构变化

deep 的作用:

watch: {
  c: {
    handler(val) {},
    deep: true  // ← 递归收集所有属性的 dep
  }
}

// 现在修改已有属性也会触发
this.c.a = 2  // ✅ 触发(因为收集了 a 的 dep)

最佳实践

❌ 永远不要这样做

this.c.b = 111
this.$set(this.c, 'b', 1000)  // 不会触发 watch

✅ 正确做法

// 方案 1:只用 $set
this.$set(this.c, 'b', 1000)

// 方案 2:在 data 中预先声明
data() {
  return {
    c: {
      a: 1,
      b: null  // 预先声明
    }
  }
}
// 现在可以直接赋值
this.c.b = 1000  // ✅ 会触发

// 方案 3:整体替换对象
this.c = { ...this.c, b: 1000 }

// 方案 4:分两步(如果需要不同值)
this.$set(this.c, 'b', 111)  // 第一次触发
this.c.b = 1000              // 第二次触发(因为 b 已是响应式)

总结表格

场景 属性状态 $set 行为 是否触发 watch
$set(c, 'b', 1) 不存在 添加响应式 + notify ✅ 触发
c.b = 1; $set(c, 'b', 2) 存在(非响应式) 只赋值,不 notify ❌ 不触发
$set(c, 'b', 1); $set(c, 'b', 2) 存在(响应式) 触发 setter ✅ 触发
$set(c, 'b', 1); c.b = 2 存在(响应式) 触发 setter ✅ 触发

记住一句话

$set 只在属性不存在时才调用 dep.notify()
先赋值再 $set = 属性已存在 = 不会触发 watch


关键点:

  • $set 有属性存在性检查:key in target
  • 普通赋值创建的属性不是响应式的
  • 已存在的属性(即使非响应式)会让 $set 跳过 notify()
  • 永远不要在 $set 前先赋值!

面试问“如何设计可扩展 Button”?像素风格button改造只要 10分钟!

作者 孟祥_成都
2025年11月20日 14:59

前言

面试官:(打着哈欠,眼皮打架)你说你写的组件库拓展性很好,怎么证明呢?

:(自信满满)您直接去我的组件库官网看,这是传统组件库样式,大家都能做:

image.png

地址:上图页面地址

:(切换页面)然后这是我用自己写的 @t-headless-ui/react 中的同一个 Button 组件,只是稍微改了下样式。比如你想要像素风格,请看

image.png

地址:上图页面地址

(如果您觉得不错,请求 github 一个赞,我一定会拓展所有主流组件到其中的!)

面试官:(原本昏昏欲睡,突然瞪大眼睛,从凳子上一跃而起)"卧槽!兄弟!你这...这怎么做到的?!"

:(淡定一笑)无它,headless 组件库而已!

面试官:(扶了扶惊掉的下巴)"headless 组件库是什么意思?"

:"简单说就是——我们只提供JavaScript逻辑和功能,样式你爱咋写咋写!甚至连DOM结构都随便你折腾!"

面试官:(沉默三秒,突然起身握住我的手)"好!我服!明天就来上班!工资你随便开!"

当然,上面只是一个小玩笑,哈哈,今天我们就来介绍一下,如何写一个像素风格的 button 以及为啥我的 headless button 稍微改一下样式,就可以造一个生产级别能用的 button 呢?

一个生产环境要用的 button 需要具备什么样的属性呢?

button 的状态

一个 button 至少需要:

  • 定义 disabled 状态,在 disabled 状态, click 事件是无法生效的
  • 定义 loading 状态,在 loading 状态, click 事件同样是是无法生效的

headless 也就是无样式组件的层面上,做到拦截这两个属性的 click 事件就足够了.

是不是很简单,只是因为 button 的复杂度更多在于定制样式,所以 headless 组件库的 button 很容易实现。

核心代码如下:

  const handleClick: MouseEventHandler = (event): void => {
    if (loading || disabled) {
      // 阻止按钮默认行为,防止在 loading 或 disabled 状态下意外提交表单或触发其他默认事件
      event?.preventDefault?.();
      return;
    }
    onClick?.(event);
  };

好了,接下来我们说样式,样式的难度主要在于状态很多,包括:

  • 各种主题色
    • primary:主色
    • success: 成功色
    • error:失败颜色
    • 。。。 然后每个状态都分为,default 状态,disabled 状态,loading 状态
  • 各种风格
    • 有背景色的风格
    • 只有边框的风格
    • 。。。

然后每个状态都分为,default 状态,disabled 状态,loading 状态

还有需要考虑 focus 上的颜色状态,hover 的颜色状态。

这才是一个 button 真正复杂的点!

如果你想学造组件库,欢迎加入,我们超级可拓展的 headless 组件库交流群(还有包装简历服务哦!)主页

我们现在为了说明这个像素 button 实现的思路,我们化繁为简,假设只有 1 种颜色,1 种风格,如下图:

image.png

说明实现思路是如何,大家可以触类旁通!

如何制作出像素效果

从上面图可以看出,最明显的像素想过就是我们死角都是 transparent(透明色),这个明显是 border 实现不了的。那如何做呢?

秘密就是 box-shadow !

我们先举一个小例子,你看看 box-shadow 如何使用,假设我们需要一个下边框,box-shadow 设置为:

box-shadow: 0 5px black;

其中 0 代表横向的偏移量,我们是 0 ,也就是横轴上什么也不做,5px 代表在竖直方向上向下制造一个 5pxshadow, 然后颜色设置为 black 也就是黑色,请看这个按钮下方多了一条黑色

image.png

接着,我们依葫芦画瓢,加上 上面的 shadow

box-shadow: 0 5px black, 0 -5px black;

效果如下

image.png

接着右边我们也加一下:

box-shadow: 0 5px black, 0 -5px black, 5px 0 black;

image.png

是不是有感觉了,shadowborder 最大的不同就是边角不会填充颜色,然后接着,我们加上又边框:

box-shadow: 0 5px black, 0 -5px black, 5px 0 black, -5px 0 black;

最后我们再润色一下,加一个上阴影,增加立体感,如下图

image.png

box-shadow: 0 5px black, 0 -5px black, 5px 0 black, -5px 0 black, inset 0px 4px rgba(255,255,255,0.21);

其中 inset 的意思是增加内阴影,之前我们都是外阴影,rgba(255,255,255,0.21) 是指白色,然后 0.21 的透明度。

初始化 button

在我们设置 box-shadow 之前,这个 button 背景色,字体这些还没初始化,我们先来补上这个知识

{
    position: relative;
    display: inline-flex;
    justify-content: center;
    align-items: center;
    user-select: none;
    padding-top: .25rem;
    padding-bottom: .25rem;   
    padding-left: 1.25rem;
    padding-right: 1.25rem;
    background-color: rgb(88,199,192);
    border-width: 0;
    border-radius: 0;
}

好了初始化完毕,我们开始处理各种状态:

hover 状态

hover 状态我们用了一个 filter 函数,增加亮度。

filter:brightness(1.05)

但是同时注意,在 disabledloading 状态 hover 无效。在 react 一般都要使用 classnames 库,tailwindcss 还要将 classnamestailwindcss-merge 库结合, vue 好像天然对于 css 的条件判断就支持的不错。

类似代码如下(不同的框架和使用的 css 框架不同,代码肯定有不同,这个很简单,大家看得懂意思即可):

{
   'hover:brightness-105': !disabled && !loading
}

'hover:brightness-105' 是 tailwindcss 中的类名, 对于普通的 css 你只要换成你希望的类名,然后引入对应的 css 文件即可(在 css 文件里实现这个 hover 的 css).

active 状态

active 状态,我们只是把内阴影变黑了,给人一种凹凸下去的感觉。 active 下的 box-shadow 变为

box-shadow: 0px 5px black,0px -5px black,5px 0px black,-5px 0px black,inset 0px 5px #00000038

可以看到只是最后 inset 中的颜色变深了,其它都没有变。

处理 disabled 状态

这个状态其实需要判断,比如 disabled 的时候 cursor ,也就是鼠标显示一个禁止点击的状态,这样有利于告诉用户此时点击无效。

类似的增加对 disabled 状态的处理:

{
    'opacity-50 cursor-not-allowed': disabled, // disabled 时透明度降低,cursor 变为 not-allowed
}

字体更想像素的样子

这个最好是搜索有像素风格的字体样式,我是为了方便,使用系统自带中,类似有像素风格的字体,大家可以参考,兼容macwindows

{
    font-family: 'ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,'Lucida_Console','Courier_New',monospace'
}

dark 模式

我使用的是 tailwindcss 天然对 dark 模式可以自定义颜色,国内很多人不使用 taiwindcss 这里就不赘述了

小总结

这里就结束了,这一种状态看起来代码不多,其实状态和颜色多了之后,css 代码会非常多。最后,大家可以看到 headless 组件的好处就是跟样式没有任何耦合,随便用各种 css 框架自己处理,它的好处我会在后面的文章继续介绍,例如 radio 组件,本质上就是单选,我们看到很多组件库都是一个圆形按钮的样式,其实谁规定了必须是这个样式?我可以是任何只要满足单选的样式即可。

所以后续会有很多有趣的主题加入进来,包括button,也会持续更新更重好玩的样式和动画!

最后你到看到这里了,欢迎各位大侠来组件库交流群交流,也希望各位大侠给个github的赞,嘿嘿

Vue3 响应式原理:手写实现 ref 函数

作者 云枫晖
2025年11月21日 11:59

前言

在 Vue3 的响应式系统中,ref 是一个核心 API,它让我们能够创建响应式的数据引用。虽然日常开发中我们频繁使用它,但你是否曾好奇过它的内部实现原理?今天,就让我们一起来揭开 ref 的神秘面纱,手动实现一个属于自己的 ref 函数!

系列文章

为了帮助你更好地理解 Vue3 响应式系统的完整实现,推荐阅读本系列的其他文章:

  1. Vue3 响应式原理:从零实现 Reactive
  2. Vue3 响应式原理:手写 Computed 和 Watch 的奥秘

💡 提示
本文中涉及的部分函数可能在其他文章中实现,你可以通过上述目录查找完整实现。如果想深入研究,也可以直接查看 Vue3 源码

什么是ref?

在深入实现之前,让我们先回顾一下 ref 的基本用法:

import { ref } from 'vue'
const name = ref('lucy');
console.log(name.value); // 'lucy'
name.value = 'james';
console.log(name.value); // 'james'

ref 接收一个内部值,返回一个响应式的、可更改的 ref 对象,该对象只有一个指向其内部值的属性 .value

手动实现ref

核心架构

当我们打印 ref 函数创建的对象时,会发现它是一个 RefImpl 类型的实例。这表明在 ref 的底层实现中,有一个专门的类负责实现响应式功能。

/**
 * 创建响应式引用
 * @param {any} value - 值
 * @param {boolean} shallow - 是否为浅层引用
 * @returns {RefImpl} ref 实例
 */
export function ref(value, shallow) {
  return createRef(value, shallow);
};
/**
 * 创建 ref 的内部函数
 * @param {any} rawValue - 原始值
 * @param {boolean} shallow - 是否为浅层引用
 * @returns {RefImpl} ref 实例
 */
function createRef(rawValue, shallow) {
  // 如果传入的值已经是 ref,则直接返回该 ref,避免重复包装
  if (isRef(rawValue)) {
    return rawValue;
  }
  return new RefImpl(rawValue, shallow);
}
/**
 * 检查值是否为 ref
 * @param {any} r - 待检查值
 * @returns {boolean} 是否为 ref
 */
export function isRef(r) {
  return r ? r[ReactiveFlags.IS_REF] === true : false;
}

RefImpl类实现

RefImpl 类的实现与我们在手写 Computed 实现中看到的模式有些相似:

/**
 * Ref 实现类
 * 用于创建基本的响应式引用
 */
class RefImpl {
  _value; // 存储响应式值
  __rawValue; // 存储原始值(用于比较)
  dep = new Set(); // 依赖收集集合
  [ReactiveFlags.IS_REF] = true; // 标识为 ref 对象
  [ReactiveFlags.IS_SHALLOW] = false; // 是否为浅层 ref

  /**
   * 构造函数
   * @param {any} value - 初始值
   */
  constructor(value) {
    this.__rawValue = value;
    this._value = toReactive(value); // 如果是对象则转换为响应式
  }

  /**
   * 获取 ref 的值
   * @returns {any} 当前值
   */
  get value() {
    // 如果存在激活的副作用,则收集依赖
    if (activeEffect) {
      trackEffects(this.dep);
    }
    return this._value;
  }

  /**
   * 设置 ref 的值
   * @param {any} newValue - 新值
   */
  set value(newValue) {
    // 只有当新值不等于旧值时才更新
    if (newValue !== this.__rawValue) {
      this._value = toReactive(newValue);
      this.__rawValue = newValue;
      triggerEffects(this.dep); // 触发依赖更新
    }
  }
}

在上述代码中,toReactive 函数负责判断值类型:如果是对象,就通过 reactive 函数转换为响应式对象。

// reactive.js
/**
 * 将值转换为响应式对象(如果是对象的话)
 * @param {any} value - 需要转换的值
 * @returns {Proxy|any} 响应式对象或原始值
 */
export function toReactive(value) {
  // 如果是对象则创建响应式代理,否则返回原始值
  return isObject(value) ? reactive(value) : value;
}

手动实现toRef函数

问题背景

toRef 是开发中常用的一个重要函数,它的主要作用是:为响应式对象上的单个属性创建一个"响应式引用"(Ref)

import { reactive } from 'vue'; 

const state = reactive({ name: '张三', age: 30 }); 

// 尝试解构 
const { name, age } = state; 
// 此时,name 和 age 只是普通的字符串和数字,不再是响应式的 
// 修改它们不会触发 UI 更新 

name = '李四'; 
// 这行代码在严格模式下会报错,因为解构出来的是常量 
// 即使不报错,UI 也不会更新

toRef 函数正是为了解决这个问题而设计的。

基本用法

const state = reactive({ 
  fistName: 'lee',
  lastName: 'mary'
});

// 第一种用法:对象属性转换
const fistName = toRef(state, 'fistName');
const lastName = toRef(state, 'lastName');

// 第二种用法:计算属性转换
const fullName = toRef(() => `${firstName.value} ${lastName.value}`);

setTimeout(() => (state.firstName = "tom"), 1000);
// 一秒钟后:
// toRef 的 firstName 和 lastName 会同步更新
// fullName 也会自动更新

具体实现

/**
 * 将值、对象属性或 getter 函数转换为 ref
 * @param {any} source - 源值、对象或函数
 * @param {string} key - 属性键(可选)
 * @param {any} defaultValue - 默认值(可选)
 * @returns {RefImpl|GetterRefImpl|ObjectRefImpl} 对应的 ref 实例
 */
export function toRef(source, key, defaultValue) {
  // 如果源已经是 ref,直接返回
  if (isRef(source)) {
    return source;
  }
  // 如果源是函数,创建 getter ref
  else if (isFunction(source)) {
    return new GetterRefImpl(source);
  }
  // 如果源是对象且提供了键,创建对象属性 ref
  else if (isObject(source) && arguments.length > 1) {
    return propertyToRef(source, key, defaultValue);
  }
  // 否则创建普通 ref
  else {
    return ref(source);
  }
}

上述代码根据数据源 source 的不同类型进行相应处理:

  • 如果已经是 Ref,直接返回
  • 如果是函数,交给 GetterRefImpl 类处理
  • 如果是对象且有第二个参数,交给 propertyToRef 函数处理
  • 其他情况,生成普通的 RefImpl 实例

GetterRefImpl 类的实现

/**
 * getter 引用实现类
 * 用于将 getter 函数转换为响应式 ref
 */
class GetterRefImpl {
  [ReactiveFlags.IS_REF] = true; // 标识为 ref 对象
  _value = undefined; // 缓存值
  _getter; // getter 函数

  /**
   * 构造函数
   * @param {Function} getter - getter 函数
   */
  constructor(getter) {
    this._getter = getter;
  }

  /**
   * 执行 getter 并返回结果
   * @returns {any} getter 执行结果
   */
  get value() {
    return (this._value = this._getter());
  }
}

对象属性的处理

当 source 是对象时,会交给 propertyToRef 函数处理:

/**
 * 将对象属性转换为 ref 的内部函数
 * @param {object} source - 源对象
 * @param {string|number} key - 属性键
 * @param {any} defaultValue - 默认值
 * @returns {ObjectRefImpl} 对象属性 ref
 */
function propertyToRef(source, key, defaultValue) {
  const val = source[key];
  // 如果属性值已经是 ref,直接返回;否则创建对象属性 ref
  return isRef(val) ? val : new ObjectRefImpl(source, key, defaultValue);
}

如果 source 中属性的值已经是 Ref,则直接返回;否则继续交给 ObjectRefImpl 处理:

/**
 * 对象属性引用实现类
 * 用于将对象的某个属性转换为 ref
 */
class ObjectRefImpl {
  [ReactiveFlags.IS_REF] = true; // 标识为 ref 对象
  _object; // 源对象
  _key; // 属性键
  _defaultValue; // 默认值

  /**
   * 构造函数
   * @param {object} object - 源对象
   * @param {string|number} key - 属性键
   * @param {any} defaultValue - 默认值
   */
  constructor(object, key, defaultValue) {
    this._object = object;
    this._key = key;
    this._defaultValue = defaultValue;
  }

  /**
   * 获取属性值
   * @returns {any} 属性值或默认值
   */
  get value() {
    const val = this._object[this._key];
    return (this._value = val === undefined ? this._defaultValue : val);
  }

  /**
   * 设置属性值
   * @param {any} newValue - 新值
   */
  set value(newValue) {
    this._object[this._key] = newValue;
  }
}

手动实现toRefs函数

toRefs 将一个响应式对象转换为一个普通对象,这个普通对象的每个属性都是指向源对象相应属性的 ref。每个单独的 ref 都是使用 toRef() 创建的。

/**
 * 将响应式对象的所有属性转换为 refs
 * @param {object|Array} object - 响应式对象
 * @returns {object} 包含所有属性 refs 的对象
 */
export function toRefs(object) {
  // 根据源对象类型初始化返回对象
  const ret = isArray(object) ? new Array(object.length) : {};
  // 遍历对象的所有可枚举属性(包括继承的属性)
  for (const key in object) {
    ret[key] = propertyToRef(object, key);
  }
  return ret;
}

总结

通过手动实现 reftoRef 和 toRefs,我们深入理解了 Vue3 响应式系统中引用类型的工作原理。这些实现展示了 Vue3 如何通过巧妙的类设计和依赖收集机制,为开发者提供简洁而强大的响应式 API。

理解这些底层原理不仅有助于我们更好地使用 Vue3,也能在遇到复杂场景时提供解决问题的思路。希望这篇文章能帮助你更深入地掌握 Vue3 的响应式系统!

❌
❌