普通视图

发现新文章,点击刷新页面。
昨天 — 2026年2月7日首页

使用github copliot chat的源码学习之Chromium Compositor

作者 吴仰晖
2026年2月7日 18:24

github推出了 copilot chat,里面比较方便的是它可以很快速地搜索和获取各开源项目的上下文。除了单纯的Ask,其实还有执行任务的Agent(不过要开copilot pro),似乎目前是限制了每个月50次request。

image.png

因此我利用这个来学习源码,让AI回答关于源码的东西。下面的内容是出自copilot的回答(我认为比较好的),只是个人进行了整理。 这个博客的营养价值并不高,最大的价值可能就是告诉你有这个方便的途径去了解和学习源码。可以直接跳到完整示例,前面大抵只是一些铺垫。

Chromium 页面渲染的核心架构

1. Blink 渲染引擎 (核心组件)

根据官方文档,Blink 是 Chromium 的渲染引擎,负责页面内容的最终展示:

## Blink (Rendering Engine)

Blink 实现了所有在浏览器标签页内渲染内容的功能:

* 实现网络平台规范(如HTML标准),包括DOM、CSS和Web IDL
* 嵌入V8引擎并运行JavaScript
* 从底层网络栈请求资源
* **构建DOM树**
* **计算样式和布局**
* **嵌入Chrome Compositor并绘制图形**

2. 多进程渲染管道

Chromium 采用多进程架构:

  • Browser Process (浏览器进程):管理用户交互、网络请求、打印等
  • Renderer Process (渲染进程):在沙箱中运行,执行 Blink 引擎进行页面渲染
  • Compositor Process:负责最终的图形合成和显示

3. 渲染流程的关键步骤

a. 页面加载与DOM构建

// PrintRenderFrameHelper 处理渲染相关的工作
class PrintRenderFrameHelper
    : public blink::WebPrintClient,
      public content::RenderFrameObserver {
  // 为打印准备页面框架
  void OnFramePreparedForPrintPages();
  void PrintPages();
  bool RenderPagesForPrint(blink::WebLocalFrame* frame,
                           const blink::WebNode& node);
};

b. 计算布局与样式

Blink 引擎在 DOM 树构建后,计算每个元素的样式(CSS)并确定其在页面中的位置和大小。

c. 绘制(Paint)与栅格化(Rasterization)

// 页面内容渲染到画布
void RenderPageContent(blink::WebLocalFrame* frame,
                       uint32_t page_index,
                       cc::PaintCanvas* canvas) {
  TRACE_EVENT1("print", "RenderPageContent", "page_index", page_index);
  frame->PrintPage(page_index, canvas);
}

d. 图形合成(Compositing)

Chrome Compositor 负责合成不同的图层,最终生成帧(Frame)。

4. Compositor 的作用

Compositor 是 Chromium 的图形合成器,用于:

  • 合成多个渲染层
  • 处理复杂的变换和效果
  • 优化渲染性能
  • 管理硬件加速渲染(GPU)

5. 渲染周期(Frame Lifecycle)

一个完整的渲染周期包括:

输入事件 → DOM 更新 → 样式计算 → 布局计算 → 绘制操作 → 栅格化 → 合成 → 显示

6. 优化策略

  • 增量渲染:只重新渲染受影响的区域(Dirty Regions)
  • 分层渲染:使用多个渲染层提高效率
  • GPU 加速:利用硬件加速加快渲染速度
  • 异步渲染:多个进程并行处理不同的渲染任务

7. 性能监控

Chromium 内置性能监控:

// PDF 渲染的性能测量
if (!first_paint_metric_reported_ && !client_->IsPrintPreview()) {
  first_paint_metric_reported_ = true;
  base::UmaHistogramMediumTimes("PDF.FirstPaintTime",
                                begin_time - engine_creation_time_);
}

总结

Chromium 的页面渲染是一个复杂的分阶段过程:

  1. 解析阶段:解析 HTML/CSS/JavaScript,构建 DOM 和样式树
  2. 计算阶段:计算布局(Layout)和样式(Style)
  3. 绘制阶段:生成绘制指令(Paint Commands)
  4. 栅格化阶段:将绘制指令转换为像素数据
  5. 合成阶段:合成多个层并送到显示设备
  6. 显示阶段:在屏幕上展示最终画面

Compositor 的完整分层架构和数据流

根据官方文档 life_of_a_frame.md 和代码分析,Compositor 分为以下主要部分:

1. BeginFrame 协调层(Frame Coordination)

输入:来自 Display Compositor 的 BeginFrame 信号
↓
输出:BeginFrameArgs(包含时间戳、帧号等)

职责:

  • 同步主线程和合成线程的帧生成
  • 管理帧率和垂直同步
  • 分发 BeginFrame 通知

2. 主线程阶段(Main Thread Pipeline)

BeginFrame
    ↓
[1] BeginMainFrame - 合成线程请求主线程更新
    ↓
    输入:LayerTreeHost 配置、用户输入、动画状态
    输出:BeginMainFrameAndCommitState
    ↓
[2] Animate - 主线程更新动画
    ↓
    输入:当前时间、动画状态
    输出:更新后的属性值
    ↓
[3] UpdateLayers - Blink 执行布局和绘制
    ↓
    输入:DOM 树、样式树
    输出:DisplayItemList(绘制操作)
    ↓
[4] Commit - 推送改变到合成线程
    ↓
    输入:Layer 树、DisplayItemList、属性
    输出:CommitState(原子性提交状态)

详细步骤:

1. Animate Phase
   输入:LayerAnimationController (cc/animation/*)
   输出:变换、不透明度等属性的新值

2. UpdateLayers Phase  
   输入:Layer 树,由 Blink 通过 LayerTreeHostClient 接口触发
   - client_->UpdateLayerTreeHost()
   - Blink 执行布局计算
   - Blink 执行绘制,产生 DisplayItemList
   输出:更新后的 Layer 树和 DisplayItemList

3. Commit Phase
   - PushPropertiesTo():每个 Layer 推送属性到 LayerImpl
   - 交换 Pending Tree(待定树)和 Active Tree(活动树)
   - 同步动画状态、滚动状态等

3. 合成线程阶段(Impl Thread Pipeline)

这是 Compositor 的核心,分为五个关键阶段:

阶段 1:Commit 完成(Finish Commit)

输入:CommitState
    - Layer 树结构和属性
    - DisplayItemList
    - Scroll 状态
    - 动画数据
    
处理:
    - FinishCommit():合成线程接收提交的状态
    - 更新 LayerImpl 树
    - 更新属性树(Transform Tree、Clip Tree 等)
    
输出:LayerTreeImpl(合成线程的活动树)

阶段 2:Prepare Tiles(准备瓦片)

输入:LayerTreeImpl、显示视口、缩放因子

处理:
    - CalculateRasterScales():计算每个层的栅格化缩放
    - PrepareTiles():
        ├─ CalculateLiveRects():计算可见瓦片范围
        ├─ AssignGpuMemoryToTiles():分配 GPU 内存预算
        └─ ScheduleRasterTasks():安排栅格化任务队列
    
    - TileManager 的职责:
        ├─ 优先级排序(视口内 > 视口外)
        ├─ 管理软件和 GPU 栅格化
        ├─ 管理图像解码
        └─ 管理蛋糕层(cake layers)

输出:Raster Tasks(栅格化任务队列)
    - 每个任务是:(Tile, RasterSource, Priority)

阶段 3:Rasterization(栅格化)

输入:Raster Tasks
    - RasterSource(DisplayItemList 的包装)
    - 目标��片大小
    - 缩放因子
    - 栅格化位置

处理过程(在工作线程执行):

    1. PaintOpBuffer Playback
       输入:DisplayItemList 中的 PaintOps
       处理:
           - GetOffsetsOfOpsToRaster():使用 R-Tree 查询需要的操作
           - 创建 SkCanvas(CPU 或 GPU)
           - 遍历相关 PaintOps,调用 Raster()
       输出:像素数据或 GPU 命令
    
    2. Software Raster(软件栅格化)
       输入:PaintOps,输出大小
       处理:
           - 在内存中创建位图
           - 使用 Skia 的 CPU 后端绘制
           - 使用 SIMD 优化
       输出:SkBitmap(CPU 内存中的像素)
    
    3. GPU Raster(GPU 栅格化)
       输入:PaintOps,GPU 上下文
       处理:
           - 序列化 PaintOps
           - 通过 RasterInterface 发送到 GPU
           - GPU 命令缓冲区执行绘制
           - Skia 的 GPU 后端(Ganesh)处理
       输出:GPU 纹理资源

输出:Rasterized Tiles
    - 包含像素数据(软件)或 GPU 纹理(GPU)

关键类和函数:

// RasterSource:DisplayItemList 的包装,提供栅格化接口
class RasterSource {
    void PlaybackToCanvas(
        SkCanvas* raster_canvas,
        const gfx::Rect& canvas_bitmap_rect,  // 目标位置
        const gfx::AxisTransform2d& raster_transform,  // 缩放
        const PlaybackSettings& settings);
};

// TileManager:协调栅格化
class TileManager {
    void PrepareTiles(const PrepareTilesParams& params);
    void ScheduleRasterTasks();
};

// DisplayItemList:包含 PaintOps
class DisplayItemList {
    void Raster(SkCanvas* canvas, const PlaybackParams& params);
    std::vector<size_t> OffsetsOfOpsToRaster(SkCanvas* canvas);
};

阶段 4:Activation(激活)

输入:Pending Tree(待定树,已栅格化)

处理:
    - WaitForAllTilesToRasterize():等待所有关键瓦片栅格化完成
    - ActivatePendingTree():
        ├─ Pending Tree → Active Tree
        ├─ 更新动画状态
        ├─ 更新页面缩放
        └─ 清空已完成的栅格化队列
    
输出:Active Tree(已激活的树,可用于绘制)

阶段 5:Draw Frame(绘制帧)

输入:Active Tree、BeginFrameArgs

处理:
    1. CalculateDrawProperties()
       输入:Active Tree、视口、设备缩放因子
       计算:
           - 每个层的最终变换矩阵
           - 剪裁区域
           - 可见范围
       输���:DrawProperties(每个 LayerImpl 都有)
    
    2. GenerateCompositorFrame()
       输入:Active Tree with DrawProperties
       处理:
           - 遍历 Layer 树,生成 Quads(四边形)
           - 每个 Quad 包含:
               ├─ 纹理/资源 ID
               ├─ 变换矩阵
               ├─ 剪裁区域
               ├─ 不透明度
               └─ 混合模式
           - 为每个 Quad 创建 RenderPass
           - 设置组合帧元数据
       输出:CompositorFrame
    
    3. CompositorFrame 结构
       viz::CompositorFrame {
           vector<RenderPass>:
               - RenderPass[0]:第一个离屏渲染目标
               - RenderPass[1]:第二个离屏渲染目标
               - ...
               - RenderPass[N]:最后一个输出到显示器的渲染通道
           
           vector<TransferableResource>:引用的 GPU 纹理
           CompositorFrameMetadata:元数据
               - frame_token:帧标识符
               - device_scale_factor:设备像素比
               - latency_info:性能指标
               - presentation_token:展示令牌
       }

输出:CompositorFrame(包含所有绘制指令)

4. Frame Sink & Viz(显示合成器)

输入:CompositorFrame

处理:
    1. SubmitCompositorFrame()
       - 验证资源有效性
       - 同步令牌处理
       - 帧令牌生成
    
    2. Viz 处理(viz/service/display/)
       - AggregateSurfaces():合成多个 Surface
       - ApplyFilters():应用滤镜效果
       - Rasterize():最后栅格化(如果需要)
       - GenerateDamageRect():计算脏区域
    
    3. Display 合成
       - 将多个来源的帧合成到最终输出表面
       - 应用变换和效果

输出:最终的屏幕显示内容

整体数据流图

┌─────────────────────────────────────────────────────────────────┐
│                    MAIN THREAD (主线程)                          │
│                                                                   │
│  Layer Tree    BeginMainFrame                                   │
│      │         Animate                                          │
│      │         UpdateLayers (Blink Layout & Paint)              │
│      │         → DisplayItemList (PaintOps)                     │
│      │                                                           │
│      └──→ Commit (PushPropertiesTo)                            │
│           CommitState ─────┐                                    │
│                            │                                    │
└────────────────────────────┼────────────────────────────────────┘
                             │
                    ┌────────▼──────────┐
                    │  ProxyImpl Bridge  │
                    │  (Main ↔ Impl)    │
                    └────────┬──────────┘
                             │
┌────────────────────────────▼────────────────────────────────────┐
│               COMPOSITOR THREAD (合成线程)                       │
│                                                                   │
│  1. FinishCommit()                                              │
│     CommitState → LayerTreeImpl                                  │
│     更新 LayerImpl 树、属性树                                      │
│                                                                   │
│  2. PrepareTiles()  ◄─ Scheduler 调度                           │
│     ├─ 计算栅格化缩放                                             │
│     ├─ 分配 GPU 内存                                             │
│     └─ 生成 RasterTasks 队列                                    │
│            │                                                    │
│            ▼ (Post to Worker Threads)                          │
│
│  3. Rasterization (Worker Threads)                             │
│     ├─ Software: DisplayItemList → SkBitmap                    │
│     └─ GPU: PaintOps → GPU Textures                            │
│            │                                                    │
│            ▼                                                    │
│
│  4. Activation()  ◄─ Scheduler 调度                            │
│     Pending Tree (Rasterized) → Active Tree                    │
│                                                                   │
│  5. DrawFrame() / GenerateCompositorFrame()                     │
│     ├─ CalculateDrawProperties()                               │
│     ├─ BuildQuads()                                            │
│     ├─ 生成 RenderPass 列表                                     │
│     └─ 输出 CompositorFrame                                    │
│            │                                                    │
│            ▼                                                    │
│
│  SubmitCompositorFrame(frame)                                  │
│            │                                                    │
└────────────┼──────────────────────────────────────────────────┘
             │
    ┌────────▼───────────┐
    │  LayerTreeFrameSink │
    │  (GPU Channel)      │
    └────────┬────────────┘
             │
┌────────────▼──────────────────────────────────────────────────┐
│               VIZ DISPLAY COMPOSITOR (显示合成器)              │
│                                                                  │
│  1. MaybeSubmitCompositorFrame()                               │
│     - 验证帧                                                    │
│     - 应用同步令牌                                               │
│                                                                  │
│  2. AggregateSurfaces()                                        │
│     - 多个 Surface 合成                                         │
│     - Z-order 排序                                             │
│                                                                  │
│  3. Display::Draw()                                            │
│     - GPU 驱动程序指令                                          │
│     - 应用滤镜和效果                                             │
│                                                                  │
│  4. Swap & Present                                            │
│     - 缓冲交换 (Backbuffer → Frontbuffer)                      │
│     - VSync 同步                                               │
│     - 显示器显示                                                 │
│                                                                  │
└──────────────────────────────────────────────────────────────┘

关键概念总结表

阶段 输入 处理器 输出 执行线程
Animate AnimationState LayerAnimationController 属性值 Main
UpdateLayers DOM 树、样式 Blink DisplayItemList Main
Commit Layer 树 LayerTreeHost CommitState Main→Impl
FinishCommit CommitState LayerTreeHostImpl LayerTreeImpl Impl
PrepareTiles LayerTreeImpl TileManager RasterTasks Impl
Rasterization RasterTasks RasterWorkerPool GPU 纹理/位图 Worker
Activation Pending Tree LayerTreeHostImpl Active Tree Impl
DrawFrame Active Tree LayerTreeHostImpl CompositorFrame Impl
Display CompositorFrame DisplayCompositor 屏幕输出 Display

ProxyMain/ProxyImpl 的角色

ProxyMain (主线程)
├─ 负责与主线程通信
├─ 接收 BeginMainFrame 信号
├─ 管理 CommitPipelineStage
└─ 回调主线程结果

ProxyImpl (合成线程)
├─ 负责与合成线程通信
├─ 控制 Scheduler
├─ 管理 LayerTreeHostImpl
└─ 提交 CompositorFrame 到 LayerTreeFrameSink

Compositor 完整示例:绘制一个蓝色正方形

让我们假设要在网页上绘制一个蓝色的 200×200 像素正方形,位置在 (100, 100)

第1步:主线程 - Blink 记录绘制操作

用户在 HTML/CSS 中写了:

<div style="
  width: 200px;
  height: 200px;
  background-color: blue;
  position: absolute;
  top: 100px;
  left: 100px;
"></div>

Blink 布局完成后,知道要绘制:

  • 位置:(100, 100)
  • 大小:200×200
  • 颜色:蓝色 RGB(0, 0, 255)

然后它创建一个 DisplayItemList 来记录这个绘制操作:

// Blink 在主线程上执行
auto display_list = base::MakeRefCounted<cc::DisplayItemList>();

display_list->StartPaint();

// 1. 保存图形状态
display_list->push<cc::SaveOp>();

// 2. 绘制蓝色正方形
cc::PaintFlags blue_flags;
blue_flags.setColor(SK_ColorBLUE);  // RGB(0, 0, 255)
blue_flags.setStyle(SkPaint::kFill_Style);

display_list->push<cc::DrawRectOp>(
    SkRect::MakeXYWH(100, 100, 200, 200),  // x, y, width, height
    blue_flags
);

// 3. 恢复图形状态
display_list->push<cc::RestoreOp>();

display_list->EndPaintOfUnpaired(gfx::Rect(100, 100, 200, 200));

// 完成记录
display_list->Finalize();

// 此时 DisplayItemList 内部包含:
// 
// paint_op_buffer_ = [
//   SaveOp,
//   DrawRectOp(x=100, y=100, w=200, h=200, color=blue),
//   RestoreOp
// ]
//
// visual_rects_ = [
//   {100, 100, 200, 200},  // SaveOp 的可视范围
//   {100, 100, 200, 200},  // DrawRectOp 的可视范围
//   {100, 100, 200, 200}   // RestoreOp 的可视范围
// ]

这个阶段的输出: 一个包含"绘制指令"的对象,说明要在 (100,100) 位置绘制一个 200×200 的蓝色矩形。但这只是指令,还没有真正的像素数据!


第2步:提交到合成线程

主线程把 DisplayItemList 包装在 CommitState 中,发送给合成线程:

// 主线程创建 CommitState
auto commit_state = std::make_unique<CommitState>();

// 把 DisplayItemList 分配给对应的 Layer
// (在实际代码中,DisplayItemList 被存储在 PictureLayerImpl 中)
commit_state->source_frame_number = 120;
commit_state->device_viewport_rect = gfx::Size(1920, 1080);
commit_state->device_scale_factor = 1.0f;  // 假设普通屏幕
commit_state->background_color = SK_ColorWHITE;

// 推送到合成线程
layer_tree_host_->WillCommit(...)
// ...
layer_tree_host_->ActivateCommitState()  // 原子性推送

这个阶段的输出: CommitState 对象,包含所有需要的信息,包括 DisplayItemList。


第3步:合成线程 - 准备栅格化

合成线程收到 CommitState,开始准备栅格化:

// 合成线程上
LayerTreeHostImpl* host_impl = ...;

// 1. 接收 CommitState,更新 LayerTreeImpl
host_impl->FinishCommit(commit_state);

// 2. 准备瓦片(Tiles)
TileManager* tile_manager = host_impl->tile_manager();
tile_manager->PrepareTiles();

// 这会为 PictureLayerImpl 创建 Tiles
// 因为我们的正方形只有 200×200,可能只需要 1 个 Tile
// 假设 Tile 大小是 256×256

struct Tile {
    gfx::Rect rect;           // 瓦片在页面上的位置
    RasterSource* source;     // 指向 DisplayItemList (包装后)
    float scale;              // 栅格化缩放因子
    int x, y;                 // 瓦片的网格坐标
};

// 创建一个瓦片
Tile tile0{
    .rect = gfx::Rect(0, 0, 256, 256),      // 页面坐标
    .source = raster_source,                 // 包含我们的 DisplayItemList
    .scale = 1.0f,
    .x = 0,
    .y = 0
};

// TileManager 会创建栅格化任务
struct RasterTask {
    Tile* tile;
    RasterSource* raster_source;
    gfx::Rect target_rect;  // 在瓦片中的位置
    Priority priority;
};

RasterTask task{
    .tile = &tile0,
    .raster_source = raster_source,
    .target_rect = gfx::Rect(0, 0, 256, 256),
    .priority = PRIORITY_NOW  // 视口内,需要立即栅格化
};

// 提交栅格化任务给工作线程
tile_manager->ScheduleRasterTasks(&task);

这个阶段的输出: 栅格化任务队列,告诉工作线程要栅格化哪些区域。


第4步:工作线程 - 栅格化(最关键的部分!)

这是把绘制指令转换成实际像素的地方:

// 工作线程上执行栅格化任务
void RasterWorkerPool::RasterizeTask(const RasterTask& task) {
    // 创建一个绘制目标(画布)
    // 这是一个 256×256 的缓冲区,用来存放栅格化后的像素
    
    // 方案 A:CPU 栅格化(软件)
    if (use_software_raster) {
        // 创建 CPU 内存中的位图
        SkBitmap bitmap;
        bitmap.allocN32Pixels(256, 256);  // 256×256 的 32 位 RGBA 像素
        
        // 创建 Skia ��布,指向这个位图
        SkCanvas canvas(bitmap);
        
        // 现在我们要回放 DisplayItemList 中的绘制指令
        RasterSource* source = task.raster_source;
        
        // 关键步骤:回放绘制操作!
        source->PlaybackToCanvas(
            &canvas,                    // 目标画布
            gfx::Size(1920, 1080),      // 内容大小
            gfx::Rect(0, 0, 256, 256),  // 瓦片在内容中的位置
            gfx::Rect(0, 0, 256, 256),  // 需要栅格化的区域
            gfx::AxisTransform2d(1.0f), // 无缩放
            settings
        );
        
        // 现在 bitmap 中包含了栅格化后的像素!
        // 具体来说:
        // - 位置 (100, 100) 到 (300, 300) 的像素被设为蓝色
        // - 其他像素是白色(背景色)
        
        // bitmap 的内存布局示意:
        // 
        // 位置 (0,0) ──────────────────────→ (256,0)
        //    │ FFFFFF FFFFFF FFFFFF ...
        //    │ FFFFFF FFFFFF FFFFFF ...
        //    │ ...
        //  (100,100) 开始
        //    │ FFFFFF FFFFFF ...
        //    │ ...
        //    │ FFFFFF 0000FF 0000FF 0000FF ...  ← 蓝色像素!
        //    │ FFFFFF 0000FF 0000FF 0000FF ...
        //    │ FFFFFF 0000FF 0000FF 0000FF ...
        //    │ ...
        //  (300,300) 结束
        //    │ FFFFFF FFFFFF FFFFFF ...
        //    ↓
        // (256,256)
        
        // 上传到 GPU
        UploadBitmapToGPU(&bitmap, tile);
    }
    
    // 方案 B:GPU 栅格化
    else {
        // 创建 GPU 纹理作为渲染目标
        // 尺寸:256×256,格式:RGBA_8888
        GLuint texture = gl::CreateTexture(256, 256, GL_RGBA8);
        
        // 绑定为渲染目标
        glBindFramebuffer(GL_FRAMEBUFFER, fbo);
        glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, 
                               GL_TEXTURE_2D, texture, 0);
        
        // 清除背景(白色)
        glClearColor(1.0f, 1.0f, 1.0f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);
        
        // 创建 Skia 画布(绘制到 GPU)
        SkSurface* surface = SkSurface::MakeFromBackendTexture(...);
        SkCanvas* canvas = surface->getCanvas();
        
        // 回放 DisplayItemList 的绘制操作
        // 这次是发送 GPU 命令
        DisplayItemList::Raster(canvas, ...);
        
        // GPU 执行指令,在纹理中画出蓝色正方形
        // 纹理内容现在是:位置 (100, 100) 到 (300, 300) 是蓝色像素
    }
}

这个阶段发生了什么:

  1. 创建一个 256×256 的缓冲区(可以是 CPU 内存或 GPU 纹理)
  2. 清除背景为白色
  3. 回放 DisplayItemList 中的绘制指令:
    • SaveOp:保存状态
    • DrawRectOp(x=100, y=100, w=200, h=200, color=blue):在像素 (100,100) 到 (300,300) 之间填充蓝色
    • RestoreOp:恢复状态

具体的像素数据示意:

CPU 内存(SkBitmap)或 GPU 纹理的内容
(每个方块代表一个像素,用 16 进制表示 RGBA 颜色值)

y=0    FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF ... (全是白色)
y=1    FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF ...
...
y=99   FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF ...
y=100  FFFFFFFF ... FFFFFFFF 0000FFFF 0000FFFF 0000FFFF ... 0000FFFF FFFFFFFF ...
       ^                      ^ (100,100) 蓝色开始      ^ (300,100) 蓝色结束
y=101  FFFFFFFF ... FFFFFFFF 0000FFFF 0000FFFF 0000FFFF ... 0000FFFF FFFFFFFF ...
y=102  FFFFFFFF ... FFFFFFFF 0000FFFF 0000FFFF 0000FFFF ... 0000FFFF FFFFFFFF ...
...
y=299  FFFFFFFF ... FFFFFFFF 0000FFFF 0000FFFF 0000FFFF ... 0000FFFF FFFFFFFF ...
y=300  FFFFFFFF ... FFFFFFFF 0000FFFF 0000FFFF 0000FFFF ... 0000FFFF FFFFFFFF ...
       ^                      ^ (100,300) 蓝色最后一行  ^ (300,300) 蓝色结束
y=301  FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF ...
...
y=255  FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF ...

其中:
- FFFFFFFF = 白色 (RGBA: 255,255,255,255)
- 0000FFFF = 蓝色 (RGBA: 0,0,255,255)

这个阶段的输出: GPU 纹理或位图,包含 256×256 个像素的实际颜色数据。


第5步:合成线程 - 生成 CompositorFrame

现在我们有了栅格化后的纹理,合成线程创建最终的合成帧:

// 合成线程
void LayerTreeHostImpl::GenerateCompositorFrame() {
    // 创建 CompositorFrame
    viz::CompositorFrame frame;
    
    // 设置元数据
    frame.metadata.frame_token = 0x12345;
    frame.metadata.device_scale_factor = 1.0f;
    frame.metadata.size_in_pixels = gfx::Size(1920, 1080);
    frame.metadata.begin_frame_ack = viz::BeginFrameAck(...);
    
    // 创建渲染通道
    auto render_pass = viz::CompositorRenderPass::Create();
    render_pass->SetNew(
        viz::CompositorRenderPassId(1),
        gfx::Rect(0, 0, 1920, 1080),      // 输出矩形(整个屏幕)
        gfx::Rect(100, 100, 200, 200),    // 脏区域(只有正方形区域需要重绘)
        gfx::Transform()                   // 无变换
    );
    
    // 添加 Quad(纹理四边形)
    auto quad = std::make_unique<viz::TextureDrawQuad>();
    
    // 从栅格化任务中获取纹理 ID
    ResourceId texture_id = 0x9999;  // GPU 纹理的句柄
    
    quad->SetNew(
        nullptr,  // shared_quad_state (共享状态)
        gfx::Rect(0, 0, 256, 256),         // Quad 在屏幕上的位置(注意:这是以瓦片的 (0,0) 开始)
        gfx::Rect(0, 0, 256, 256),         // 可见区域
        false,                              // 不需要混合
        texture_id,                         // 纹理 ID(指向我们栅格化的蓝色正方形纹理)
        true,                               // 预乘 alpha
        gfx::PointF(0, 0),                 // UV 左上角
        gfx::PointF(1, 1),                 // UV 右下角
        SK_ColorWHITE,                      // 背景色
        {1.0f, 1.0f, 1.0f, 1.0f}           // 混合颜色
    );
    
    // 重要!实际位置需要从瓦片坐标转换
    // 瓦片 (0,0) 对应屏幕位置 (0,0)
    // 但我们的正方形在 DisplayItemList 中是 (100, 100)
    // 所以最终 Quad 的 rect 应该是 (100, 100, 200, 200)
    
    // 更正:
    quad->rect = gfx::Rect(100, 100, 200, 200);  // 正确的屏幕位置
    quad->visible_rect = gfx::Rect(100, 100, 200, 200);
    quad->opacity = 1.0f;
    
    render_pass->quad_list.push_back(std::move(quad));
    
    // 添加资源
    viz::TransferableResource resource;
    resource.id = texture_id;
    resource.mailbox_holder = gpu::MailboxHolder(mailbox, sync_token, target);
    frame.resource_list.push_back(resource);
    
    // 添加渲染通道
    frame.render_pass_list.push_back(std::move(render_pass));
    
    return frame;
}

// CompositorFrame 现在包含:
// {
//   metadata: {
//     frame_token: 0x12345,
//     device_scale_factor: 1.0,
//     size_in_pixels: (1920, 1080),
//     ...
//   },
//   render_pass_list: [
//     {
//       output_rect: (0, 0, 1920, 1080),
//       damage_rect: (100, 100, 200, 200),
//       quad_list: [
//         TextureDrawQuad {
//           rect: (100, 100, 200, 200),
//           texture_id: 0x9999,
//           opacity: 1.0,
//           ...
//         }
//       ]
//     }
//   ],
//   resource_list: [
//     {
//       id: 0x9999,
//       mailbox: ...  (指向包含栅格化像素的 GPU 纹理)
//     }
//   ]
// }

这个阶段的输出: CompositorFrame,包含:

  • 要绘制的 Quads(四边形)
  • Quad 的位置和大小
  • 指向纹理的资源 ID
  • 纹理中的实际像素数据

第6步:Viz Display Compositor - 最终合成和显示

// Viz Display Compositor(显示合成器)
void Display::DrawFrame(...) {
    // 1. 接收 CompositorFrame
    viz::CompositorFrame frame = ...;
    
    // 2. 遍历所有 Quads
    for (auto& render_pass : frame.render_pass_list) {
        for (auto& quad : render_pass->quad_list) {
            if (auto texture_quad = quad.As<viz::TextureDrawQuad>()) {
                // 3. 获取纹理(包含栅格化的蓝色正方形像素)
                GLuint texture = GetTextureFromResourceId(texture_quad->resource_id);
                
                // 4. 在屏幕上绘制这个纹理
                // 位置:(100, 100)
                // 大小:200×200
                // 内容:我们栅格化的蓝色正方形纹理
                
                glBindTexture(GL_TEXTURE_2D, texture);
                glUniform2f(position_uniform, 100.0f, 100.0f);
                glUniform2f(size_uniform, 200.0f, 200.0f);
                glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);  // 绘制四边形
            }
        }
    }
    
    // 5. 交换缓冲区
    SwapBuffers();
    // GPU 把缓冲区中的像素显示到屏幕上
}

这个阶段发生了什么:

  1. Display Compositor 收到 CompositorFrame
  2. 读取 Quad 的信息:位置 (100, 100),大小 200×200
  3. 发送 GPU 指令:在屏幕上的 (100, 100) 位置绘制纹理
  4. GPU 执行指令,把纹理中的像素(蓝色正方形)显示到屏幕缓冲区
  5. 交换缓冲区,屏幕显示最终结果

完整的数据变换总结

┌─────────────────────────────────────────────────────────────┐
│ 1. HTML/CSS 描述                                            │
│    <div style="width:200px; height:200px;                  │
│                background-color:blue; top:100px; left:100px;">
└─────────────────────────────────────────────────────────────┘
                           ↓ (Blink 布局)
┌─────────────────────────────────────────────────────────────┐
│ 2. DisplayItemList (绘制指令,不是像素)                      │
│    [SaveOp,                                                 ││     DrawRectOp(x=100, y=100, w=200, h=200, color=blue),   ││     RestoreOp]                                              │
└─────────────────────────────────────────────────────────────┘
                           ↓ (提交)
┌─────────────────────────────────────────────────────────────┐
│ 3. CommitState                                              │
│    (包含 DisplayItemList 和其他属性)                        │
└─────────────────────────────────────────────────────────────┘
                           ↓ (栅格化)
┌─────────────────────────────────────────────────────────────┐
│ 4. GPU 纹理(实际像素)256×256                              │
│    FFFFFF... FFFFFF... 蓝色(0000FF)... 蓝色... FFFFFF...   │
│    (100,100) 到 (300,300) 的像素是蓝色,其他是白色         │
└─────────────────────────────────────────────────────────────┘
                           ↓ (生成帧)
┌─────────────────────────────────────────────────────────────┐
│ 5. CompositorFrame                                          │
│    quad: {                                                  │
│      position: (100, 100),                                  │
│      size: (200, 200),                                      │
│      texture_id: 0x9999 → 指向包含蓝色像素的纹理           │
│    }                                                        │
└─────────────────────────────────────────────────────────────┘
                           ↓ (GPU 绘制)
┌─────────────────────────────────────────────────────────────┐
│ 6. 屏幕帧缓冲(1920×1080)                                   │
│    (100, 100) 到 (300, 300) 的像素现在是蓝色               │
│    其他像素是白色                                            │
│                                                              │
│    ┌──────────────────────────────────┐                     │
│    │ 白白白白白... 白白               │                     │
│    │ 白白白白白... 白白               │                     │
│    │ ...                              │                     │
│    │ 白白蓝蓝蓝... 蓝白               │ ← (100,100)         │
│    │ 白白蓝蓝蓝... 蓝白               │                     │
│    │ 白白蓝蓝蓝... 蓝白               │                     │
│    │ ...                              │                     │
│    │ 白白蓝蓝蓝... 蓝白               │ ← (300,300)         │
│    │ 白白白白白... 白白               │                     │
│    └──────────────────────────────────┘                     │
│                                                              │
│    ✓ 正方形显示在屏幕上!                                  │
└─────────────────────────────────────────────────────────────┘

关键概念总结

  1. DisplayItemList = 绘制指令的列表(文本格式)
  2. 栅格化 = 把绘制指令转换成像素的过程
  3. 纹理 = GPU 内存中的像素缓冲区
  4. CompositorFrame = 包含 Quads(四边形)的合成帧,每个 Quad 指向一个纹理
  5. 最终像素 = 由 GPU 把纹理中的像素通过 Quad 信息绘制到屏幕上

整个过程就像:

  • 指令像素纹理Quads屏幕显示

好问题!栅格化是整个过程中最有趣的部分。让我用最直观的方式解释它。太好了!现在我可以用实际的代码来解释栅格化的原理。让我用一个最简单的蓝色正方形例子详细讲解:

栅格化的本质:将指令转换成像素

核心思想:使用 Skia 库

栅格化的秘密就是:Chromium 使用一个叫 Skia 的图形库,它能把绘制指令转换成像素

想象 Skia 就像一个虚拟画家

  • 你给它一个空白画布(内存缓冲区)
  • 你给它绘制指令("画蓝色矩形")
  • 它就会一条条指令地执行,最后得到一个画满了颜色的画布

完整的栅格化过程(以蓝色正方形为例)

步骤 1:创建空白画布

// 工作线程上执行
// 假设我们要栅格化一个 256×256 的瓦片

// 方案 A:CPU 栅格化(软件方式)
// ====================================

// 创建一个 256×256 的位图(像素缓冲区)
SkBitmap bitmap;
bitmap.allocN32Pixels(256, 256);
// 现在 bitmap 中有 256*256 = 65536 个像素
// 每个像素 4 字节(RGBA),共 262,144 字节的内存

// 内存布局示意:
// bitmap.getPixels() 返回一个指向这块内存的指针
// 内存中的数据:
// [像素(0,0)的RGBA] [像素(1,0)的RGBA] [像素(2,0)的RGBA] ...
// [像素(0,1)的RGBA] [像素(1,1)的RGBA] [像素(2,1)的RGBA] ...
// ...
// [像素(255,255)的RGBA]

// 创建一个 Skia 画布,指向这个位图
SkCanvas canvas(bitmap);

// 方案 B:GPU 栅格化
// ====================================
// 创建一个 GPU 纹理作为渲染目标
GLuint framebuffer = CreateFramebuffer();
GLuint texture = CreateTexture(256, 256, GL_RGBA8);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
                       GL_TEXTURE_2D, texture, 0);

// 创建指向这个 GPU 纹理的 Skia 画布
SkSurface* surface = SkSurface::MakeFromBackendTexture(...);
SkCanvas* canvas = surface->getCanvas();

现在我们有了一个空白画布,所有像素都是未初始化的。


步骤 2:清除背景

// 把所有像素设置为白色(背景色)
canvas->clear(SK_ColorWHITE);

// 底层发生了什么:
// Skia 填充整个 256×256 的像素区域
// 对于每一个像素 (x, y),设置它的值为:
// [R=255, G=255, B=255, A=255]  // 白色 RGBA

// 清除后的内存示意:
// [FFFFFFFF] [FFFFFFFF] [FFFFFFFF] ... (全是 0xFFFFFFFF = 白色)
// 总共 65536 个这样的 4 字节值

现在所有像素都是白色。


步骤 3:遍历 DisplayItemList 中的绘制指令并执行

这是最关键的部分!

// RasterSource::PlaybackDisplayListToCanvas()
void PlaybackDisplayListToCanvas(SkCanvas* raster_canvas,
                                 const PlaybackSettings& settings) {
    // 获取 DisplayItemList
    DisplayItemList* display_list = ...;  // 包含我们的绘制指令
    
    // 创建回放参数
    PlaybackParams params(settings.image_provider, SkM44());
    
    // 关键步骤:回放 DisplayItemList
    display_list->Raster(raster_canvas, params);
}

// DisplayItemList::Raster() 的实现
void DisplayItemList::Raster(SkCanvas* canvas,
                             const PlaybackParams& params) const {
    // 1. 获取需要绘制的操作的偏移量
    //    (使用 R-Tree 优化:只获取与画布相交的操作)
    std::vector<size_t> offsets = OffsetsOfOpsToRaster(canvas);
    
    // offsets = [0, 8, 24]  // SaveOp, DrawRectOp, RestoreOp 的偏移量
    
    // 2. 遍历 paint_op_buffer_ 中的操作,执行它们
    paint_op_buffer_.Playback(canvas, params, true, &offsets);
}

// PaintOpBuffer::Playback() 的实现
void PaintOpBuffer::Playback(SkCanvas* canvas,
                             const PlaybackParams& params,
                             bool local_ctm,
                             const std::vector<size_t>* offsets) const {
    // 遍历所有操作
    for (const PaintOp& op : PaintOpBuffer::OffsetIterator(*this, offsets)) {
        // 对于每一个操作,调用它的 Raster() 函数
        op.Raster(canvas, params);
        
        // 这就是魔法发生的地方!
        // Raster() 是虚函数,每个 PaintOp 子类有自己的实现
    }
}

现在让我们看看具体的操作执行:

// DisplayItemList 中有这些操作(序列化的形式):
// [SaveOp] [DrawRectOp(...)] [RestoreOp]

// ============================================
// 操作 1:SaveOp::Raster()
// ============================================
void SaveOp::Raster(const SaveOp* op,
                    SkCanvas* canvas,
                    const PlaybackParams& params) {
    canvas->save();
    // 这保存了当前的图形状态(颜色、变换、剪裁等)
}

// ============================================
// 操作 2:DrawRectOp::Raster()(最关键!)
// ============================================
void DrawRectOp::RasterWithFlags(const DrawRectOp* op,
                                 const PaintFlags* flags,
                                 SkCanvas* canvas,
                                 const PlaybackParams& params) {
    // op->rect = SkRect{x=100, y=100, w=200, h=200}
    // flags->color = SK_ColorBLUE = 0x0000FFFF (RGBA)
    
    // 这是最关键的调用!
    canvas->drawRect(op->rect, *flags);
    
    // Skia 会在这一刻做什么?
    // 它会遍历要填充的矩形中的每一个像素,设置它们的颜色为蓝色
}

// Skia 内部如何执行 drawRect?
// ====================================
// 伪代码,大致思路:
void SkCanvas::drawRect(const SkRect& rect, const SkPaint& paint) {
    // 1. 将 SkRect 转换为像素坐标
    int x1 = (int)rect.left();    // 100
    int y1 = (int)rect.top();     // 100
    int x2 = (int)rect.right();   // 300
    int y2 = (int)rect.bottom();  // 300
    
    SkColor color = paint.getColor();  // 0x0000FFFF (蓝色)
    
    // 2. 遍历矩形内的所有像素
    for (int y = y1; y < y2; y++) {
        for (int x = x1; x < x2; x++) {
            // 3. 对于每一个像素,写入蓝色颜色值
            uint32_t* pixel_ptr = bitmap.getAddr32(x, y);
            *pixel_ptr = color;  // 设置为 0x0000FFFF
        }
    }
    
    // 实际上 Skia 做的更复杂(处理反锯齿、混合等),
    // 但核心思想就是这样:写入像素值
}

// 栅格化后的内存状态:
// y=0    FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF ... (全是白色)
// y=99   FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF ...
// y=100  FFFFFF... FFFFFF 0000FFFF 0000FFFF ... 0000FFFF FFFFFF... (开始有蓝色!)
//        ^        ^       ^
//        0      100    100-300 是蓝色
// y=101  FFFFFF... FFFFFF 0000FFFF 0000FFFF ... 0000FFFF FFFFFF...
// ...
// y=299  FFFFFF... FFFFFF 0000FFFF 0000FFFF ... 0000FFFF FFFFFF...
// y=300  FFFFFF... FFFFFF 0000FFFF 0000FFFF ... 0000FFFF FFFFFF...
// y=301  FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF ... (又都是白色了)

// ============================================
// 操作 3:RestoreOp::Raster()
// ============================================
void RestoreOp::Raster(const RestoreOp* op,
                       SkCanvas* canvas,
                       const PlaybackParams& params) {
    canvas->restore();
    // 恢复之前保存的图形状态
}

经过这三个操作,我们已经有了一个完整的栅格化结果!


完整的数据变换示意

// 开始栅格化

// 输入 1:DisplayItemList(绘制指令)
DisplayItemList {
    paint_op_buffer: [
        SaveOp,
        DrawRectOp(
            rect={x:100, y:100, w:200, h:200},
            color=0x0000FFFF  // 蓝色
        ),
        RestoreOp
    ],
    visual_rects: [{100,100,200,200}, ...]
}

// 输入 2:空白画布(256×256 的位图)
SkBitmap bitmap {
    width: 256,
    height: 256,
    pixels: [
        0xFFFFFFFF, 0xFFFFFFFF, ..., 0xFFFFFFFF,  // 第 0 行(256 个白色像素)
        0xFFFFFFFF, 0xFFFFFFFF, ..., 0xFFFFFFFF,  // 第 1 行
        ...
        0xFFFFFFFF, 0xFFFFFFFF, ..., 0xFFFFFFFF   // 第 255 行
    ]
}

// ============================================================
// 执行回放
// ============================================================

canvas->save();
canvas->drawRect(SkRect{100,100,200,200}, blue_paint);
canvas->restore();

// 栅格化后的输出:位图内容被修改了
SkBitmap bitmap {
    width: 256,
    height: 256,
    pixels: [
        // y=0 到 y=99:全是白色
        0xFFFFFFFF, 0xFFFFFFFF, ..., 0xFFFFFFFF,  // 第 0 行
        0xFFFFFFFF, 0xFFFFFFFF, ..., 0xFFFFFFFF,  // 第 1 行
        ...
        0xFFFFFFFF, 0xFFFFFFFF, ..., 0xFFFFFFFF,  // 第 99 行
        
        // y=100 到 y=299:中间是蓝色,边缘是白色
        0xFFFFFFFF, ..., 0xFFFFFFFF, 0x0000FFFF, 0x0000FFFF, ..., 0x0000FFFF, 0xFFFFFFFF, ...,
        //         |
        //    x=0 到 99  100 到 299(蓝色区域)  300 到 255
        
        0xFFFFFFFF, ..., 0xFFFFFFFF, 0x0000FFFF, 0x0000FFFF, ..., 0x0000FFFF, 0xFFFFFFFF, ...,
        // ... 重复 200 次(y=100 到 y=299)
        
        // y=300 到 y=255:全是白色
        0xFFFFFFFF, 0xFFFFFFFF, ..., 0xFFFFFFFF,  // 第 300 行
        0xFFFFFFFF, 0xFFFFFFFF, ..., 0xFFFFFFFF,  // 第 301 行
        ...
        0xFFFFFFFF, 0xFFFFFFFF, ..., 0xFFFFFFFF   // 第 255 行
    ]
}

为什么能把指令转换成像素?

关键是 Skia 库的设计:

// Skia 的核心就是提供一个虚拟的 SkCanvas
// 无论你是在 CPU 上还是 GPU 上绘制,接口都是一样的

class SkCanvas {
public:
    // 这些方法有多种实现:
    // - CPU 实现:直接修改像素缓冲区
    // - GPU 实现:生成 GPU 指令
    
    virtual void drawRect(const SkRect& rect, const SkPaint& paint);
    virtual void drawPath(const SkPath& path, const SkPaint& paint);
    virtual void drawImage(const SkImage* image, ...);
    // ... 等等
};

// CPU 版本的 drawRect(简化)
class SkRasterCanvas : public SkCanvas {
    void drawRect(const SkRect& rect, const SkPaint& paint) override {
        // 直接操作 fBitmap 的像素
        uint32_t* pixels = fBitmap.getPixels();
        
        for (int y = rect.top(); y < rect.bottom(); y++) {
            for (int x = rect.left(); x < rect.right(); x++) {
                int index = y * width + x;
                pixels[index] = paint.getColor();  // 设置像素颜色
            }
        }
    }
};

// GPU 版本的 drawRect(简化)
class SkGPUCanvas : public SkCanvas {
    void drawRect(const SkRect& rect, const SkPaint& paint) override {
        // 生成 GPU 指令
        glBindFramebuffer(GL_FRAMEBUFFER, fFramebuffer);
        glClearColor(paint.getColor());
        glClear(GL_COLOR_BUFFER_BIT);
        // ... 等等
    }
};

总结:

  1. DrawRectOp 本质上就是 canvas->drawRect()
  2. drawRect() 遍历矩形内的所有像素
  3. 对于每个像素,设置它的颜色为指定的颜色
  4. 结果是位图中的像素被修改了

这就是为什么能把绘制指令转换成像素!


关键代码片段总结


void RasterSource::PlaybackDisplayListToCanvas(
    SkCanvas* raster_canvas,
    const PlaybackSettings& settings) const {
  CHECK(display_list_);
  
  // 创建回放参数
  PlaybackParams params(settings.image_provider, SkM44());
  params.raster_inducing_scroll_offsets =
      settings.raster_inducing_scroll_offsets;
  params.destination_hdr_headroom = settings.hdr_headroom;
  
  // 核心:回放 DisplayItemList
  // 这会遍历所有 PaintOps 并执行它们
  display_list_->Raster(raster_canvas, params);
}

// 这就是一切!
// display_list_->Raster() 会:
// 1. 遍历 DisplayItemList 中的每个 PaintOp
// 2. 对于每个 PaintOp,调用它的 Raster() 方法
// 3. 每个 Raster() 方法使用 Skia canvas 修改像素
// 4. 最后得到一个填满颜色的位图

github发布pages的几种状态记录

作者 1024小神
2026年2月7日 18:15

大家好,我是1024小神,想进 技术群 / 私活群 / 股票群 或 交朋友都可以私信我,如果你觉得本文有用,一键三连 (点赞、评论、关注),就是对我最大的支持~

发布GitHub pages会有不同的几种状态,可以参考

409:表示已经发布过了

{
    "message": "GitHub Pages is already enabled.",
    "documentation_url": "https://docs.github.com/rest/pages/pages#create-a-apiname-pages-site",
    "status": "409"
}

errored:表示发布失败

通常是发布过后的pages静态文件更新后,会自动重新发布,比如总的更新文件是3个,但是更新了1个之后就会出触发自动更新,但是第二个紧跟着就更新了,就会再次触发更新,这个时候第一个更新状态就会变成errored

{
    "url": "https://api.github.com/repos/xxxxxx/xxxxxxx/pages",
    "status": "errored",
    "cname": null,
    "custom_404": false,
    "html_url": "https://xxxxxxx.github.io/xxxxxxx7/",
    "build_type": "legacy",
    "source": {
        "branch": "main",
        "path": "/docs"
    },
    "public": true,
    "protected_domain_state": null,
    "pending_domain_unverified_at": null,
    "https_enforced": true
}

status:null 说明正在发布pages

{
    "url": "https://api.github.com/repos/1024xiaoshen/PakePlus-Android-v2.1.6/pages",
    "status": null,
    "cname": null,
    "custom_404": false,
    "html_url": "https://1024xiaoshen.github.io/PakePlus-Android-v2.1.6/",
    "build_type": "legacy",
    "source": {
        "branch": "main",
        "path": "/docs"
    },
    "public": true,
    "protected_domain_state": null,
    "pending_domain_unverified_at": null,
    "https_enforced": true
}

built:表示已经发布好了

{
    "url": "https://api.github.com/repos/1024xiaoshen/PakePlus-Android-v2.1.6/pages",
    "status": "built",
    "cname": null,
    "custom_404": false,
    "html_url": "https://1024xiaoshen.github.io/PakePlus-Android-v2.1.6/",
    "build_type": "legacy",
    "source": {
        "branch": "main",
        "path": "/docs"
    },
    "public": true,
    "protected_domain_state": null,
    "pending_domain_unverified_at": null,
    "https_enforced": true
}

如果你有好的想法或需求,都可以私信我,我这里有很多程序员朋友喜欢用代码来创造丰富多彩的计算机世界

越南制造为什么迅速崛起?东亚模式会缔造下一个经济奇迹吗?

2026年2月7日 18:00

有人说投资现在的越南,就好比投资十几年前的中国,越南是全世界最年轻的国家之一。本期视频我们来讲一讲越南,越南近现代的历史是怎样的?为什么会起步晚了几十年?是什么样的契机让它获得了全球资本的青睐?越南未来的发展前景如何?

下载虎嗅APP,第一时间获取深度独到的商业科技资讯,连接更多创新人群与线下活动

被欧盟认定存在上瘾式设计,TikTok回应:调查结果完全错误

2026年2月7日 17:58
欧盟委员会2月6日宣布,经过两年的调查,初步结论显示,TikTok因存在“上瘾式”设计违反了欧盟《数字服务法》。TikTok方面称,调查结果“完全错误”。TikTok发言人表示,欧盟委员会的调查结果对平台的描述“完全错误且毫无依据”,并表示计划提出异议。(澎湃新闻)

从零实现 MCP 客户端:blade-code 的 MCP 集成实战

作者 echoVic
2026年2月7日 17:57

本文是 blade-code 技术深度系列的第 1 篇,深入剖析如何从零实现一个生产级的 MCP(Model Context Protocol)客户端,包括连接管理、OAuth 认证、健康监控、工具注册等核心功能。

目录


什么是 MCP?

MCP(Model Context Protocol)是 Anthropic 推出的开放协议,用于 AI 应用与外部工具/数据源的标准化通信。它解决了以下问题:

  • 工具碎片化:每个 AI 应用都要重新实现工具集成
  • 协议不统一:没有标准的工具调用格式
  • 扩展性差:添加新工具需要修改核心代码

MCP 提供了:

  • 📡 标准传输层:stdio、SSE、HTTP
  • 🔧 工具发现机制:动态获取可用工具列表
  • 🔐 认证支持:OAuth 2.0 集成
  • 📊 资源管理:统一的资源读取接口

架构设计

blade-code 的 MCP 集成采用三层架构:

graph TB
    A[Agent Runtime 调用层] --> B[McpRegistry 管理层]
    B --> C[McpClient 通信层]
    
    A1[工具调用<br/>参数验证] -.-> A
    B1[服务器注册/注销<br/>工具冲突处理<br/>状态监控] -.-> B
    C1[连接管理<br/>协议通信<br/>错误重试<br/>OAuth 认证] -.-> C
    
    style A fill:#e1f5ff
    style B fill:#fff4e1
    style C fill:#ffe1f5

设计原则:

  1. 单一职责:每层只负责自己的核心功能
  2. 事件驱动:通过 EventEmitter 解耦组件
  3. 容错优先:网络错误自动重试,永久错误快速失败
  4. 可观测性:完整的状态机和事件日志

核心实现

1. McpClient:连接与通信

McpClient 是 MCP 集成的核心,负责与单个 MCP 服务器的通信。

1.1 传输层抽象

MCP 支持三种传输方式,blade-code 通过工厂模式统一创建:

private async createTransport(): Promise<Transport> {
  const { type, command, args, env, url, headers } = this.config;

  if (type === 'stdio') {
    // 子进程通信(本地工具)
    return new StdioClientTransport({
      command,
      args: args || [],
      env: { ...process.env, ...env },
      stderr: 'ignore', // 忽略子进程的 stderr 输出
    });
  } else if (type === 'sse') {
    // Server-Sent Events(远程服务)
    return new SSEClientTransport(new URL(url), {
      requestInit: { headers },
    });
  } else if (type === 'http') {
    // HTTP 长轮询
    const { StreamableHTTPClientTransport } = await import(
      '@modelcontextprotocol/sdk/client/streamableHttp.js'
    );
    return new StreamableHTTPClientTransport(new URL(url), {
      requestInit: { headers },
    });
  }

  throw new Error(`不支持的传输类型: ${type}`);
}

关键点:

  • stdio 适合本地工具(如文件系统、数据库)
  • sse 适合远程服务(实时推送)
  • http 适合 RESTful API

1.2 连接管理与重试

生产环境中,网络不稳定是常态。blade-code 实现了智能重试机制:

async connectWithRetry(maxRetries = 3, initialDelay = 1000): Promise<void> {
  let lastError: Error | null = null;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      await this.doConnect();
      this.reconnectAttempts = 0; // 重置重连计数
      return; // 成功连接
    } catch (error) {
      lastError = error as Error;
      const classified = classifyError(error);

      // 如果是永久性错误,不重试
      if (!classified.isRetryable) {
        console.error('[McpClient] 检测到永久性错误,放弃重试:', classified.type);
        throw error;
      }

      // 指数退避
      if (attempt < maxRetries) {
        const delay = initialDelay * Math.pow(2, attempt - 1);
        console.warn(`[McpClient] 连接失败(${attempt}/${maxRetries}),${delay}ms 后重试...`);
        await new Promise((resolve) => setTimeout(resolve, delay));
      }
    }
  }

  throw lastError || new Error('连接失败');
}

错误分类:

enum ErrorType {
  NETWORK_TEMPORARY = 'network_temporary', // 临时网络错误(可重试)
  NETWORK_PERMANENT = 'network_permanent', // 永久网络错误
  CONFIG_ERROR = 'config_error',           // 配置错误
  AUTH_ERROR = 'auth_error',               // 认证错误
  PROTOCOL_ERROR = 'protocol_error',       // 协议错误
  UNKNOWN = 'unknown',                     // 未知错误
}

function classifyError(error: unknown): ClassifiedError {
  const msg = error.message.toLowerCase();

  // 永久性配置错误(不应重试)
  const permanentErrors = [
    'command not found',
    'no such file',
    'permission denied',
    'invalid configuration',
  ];

  if (permanentErrors.some((permanent) => msg.includes(permanent))) {
    return { type: ErrorType.CONFIG_ERROR, isRetryable: false, originalError: error };
  }

  // 临时网络错误(可重试)
  const temporaryErrors = [
    'timeout',
    'connection refused',
    'econnreset',
    'etimedout',
    '503',
    '429',
  ];

  if (temporaryErrors.some((temporary) => msg.includes(temporary))) {
    return { type: ErrorType.NETWORK_TEMPORARY, isRetryable: true, originalError: error };
  }

  // 默认视为临时错误(保守策略:允许重试)
  return { type: ErrorType.UNKNOWN, isRetryable: true, originalError: error };
}

为什么这样设计?

  • 快速失败:配置错误立即抛出,避免无意义的重试
  • 指数退避:避免雪崩效应,给服务器恢复时间
  • 保守策略:未知错误默认可重试,提高容错性

1.3 意外断连处理

MCP 服务器可能随时断开(进程崩溃、网络中断),blade-code 通过监听 onclose 事件自动重连:

this.sdkClient.onclose = () => {
  this.handleUnexpectedClose();
};

private handleUnexpectedClose(): void {
  if (this.isManualDisconnect) {
    return; // 手动断开,不重连
  }

  if (this.status === McpConnectionStatus.CONNECTED) {
    console.warn('[McpClient] 检测到意外断连,准备重连...');
    this.setStatus(McpConnectionStatus.ERROR);
    this.emit('error', new Error('MCP服务器连接意外关闭'));
    this.scheduleReconnect();
  }
}

private scheduleReconnect(): void {
  if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
    console.error('[McpClient] 达到最大重连次数,放弃重连');
    this.emit('reconnectFailed');
    return;
  }

  // 指数退避:1s, 2s, 4s, 8s, 16s(最大30s)
  const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
  this.reconnectAttempts++;

  this.reconnectTimer = setTimeout(async () => {
    try {
      await this.doConnect();
      console.log('[McpClient] 重连成功');
      this.reconnectAttempts = 0;
      this.emit('reconnected');
    } catch (error) {
      const classified = classifyError(error);
      if (classified.isRetryable) {
        this.scheduleReconnect(); // 继续重连
      } else {
        this.emit('reconnectFailed'); // 永久失败
      }
    }
  }, delay);
}

关键点:

  • 区分手动断开和意外断连
  • 最多重连 5 次,避免无限循环
  • 重连成功后重置计数器

2. McpRegistry:服务器管理

McpRegistry 是单例模式的注册中心,管理多个 MCP 服务器。

2.1 服务器注册

async registerServer(name: string, config: McpServerConfig): Promise<void> {
  if (this.servers.has(name)) {
    throw new Error(`MCP服务器 "${name}" 已经注册`);
  }

  const client = new McpClient(config, name, config.healthCheck);
  const serverInfo: McpServerInfo = {
    config,
    client,
    status: McpConnectionStatus.DISCONNECTED,
    tools: [],
  };

  // 设置客户端事件处理器
  this.setupClientEventHandlers(client, serverInfo, name);

  this.servers.set(name, serverInfo);
  this.emit('serverRegistered', name, serverInfo);

  try {
    await this.connectServer(name);
  } catch (error) {
    console.warn(`MCP服务器 "${name}" 连接失败:`, error);
  }
}

2.2 工具冲突处理

多个 MCP 服务器可能提供同名工具,blade-code 通过前缀解决冲突:

async getAvailableTools(): Promise<Tool[]> {
  const tools: Tool[] = [];
  const nameConflicts = new Map<string, number>();

  // 第一遍:检测冲突
  for (const [_serverName, serverInfo] of this.servers) {
    if (serverInfo.status === McpConnectionStatus.CONNECTED) {
      for (const mcpTool of serverInfo.tools) {
        const count = nameConflicts.get(mcpTool.name) || 0;
        nameConflicts.set(mcpTool.name, count + 1);
      }
    }
  }

  // 第二遍:创建工具(冲突时添加前缀)
  for (const [serverName, serverInfo] of this.servers) {
    if (serverInfo.status === McpConnectionStatus.CONNECTED) {
      for (const mcpTool of serverInfo.tools) {
        const hasConflict = (nameConflicts.get(mcpTool.name) || 0) > 1;
        const toolName = hasConflict
          ? `${serverName}__${mcpTool.name}`
          : mcpTool.name;

        const tool = createMcpTool(serverInfo.client, serverName, mcpTool, toolName);
        tools.push(tool);
      }
    }
  }

  return tools;
}

命名策略:

  • 无冲突:toolName
  • 有冲突:serverName__toolName

示例:

服务器 A: read_file
服务器 B: read_file
 最终工具: A__read_file, B__read_file

3. OAuth 认证

MCP 支持 OAuth 2.0 认证,blade-code 实现了完整的 OAuth 流程。

3.1 令牌存储

export class OAuthTokenStorage {
  private readonly tokenFilePath: string;

  constructor() {
    const homeDir = os.homedir();
    const configDir = path.join(homeDir, '.blade');
    this.tokenFilePath = path.join(configDir, 'mcp-oauth-tokens.json');
  }

  async saveToken(
    serverName: string,
    token: OAuthToken,
    clientId?: string,
    tokenUrl?: string
  ): Promise<void> {
    const credentials = await this.loadAllCredentials();

    const credential: OAuthCredentials = {
      serverName,
      token,
      clientId,
      tokenUrl,
      updatedAt: Date.now(),
    };

    credentials.set(serverName, credential);
    await this.saveAllCredentials(credentials);
  }

  isTokenExpired(token: OAuthToken): boolean {
    if (!token.expiresAt) {
      return false; // 没有过期时间,认为不过期
    }

    // 提前 5 分钟视为过期,留出刷新时间
    const buffer = 5 * 60 * 1000;
    return Date.now() >= token.expiresAt - buffer;
  }
}

安全措施:

  • 令牌文件权限设置为 0o600(仅所有者可读写)
  • 提前 5 分钟刷新令牌,避免过期
  • 支持 refresh_token 自动续期

3.2 OAuth 流程

export class OAuthProvider {
  private tokenStorage = new OAuthTokenStorage();

  async getValidToken(
    serverName: string,
    oauthConfig: OAuthConfig
  ): Promise<string | null> {
    const credentials = await this.tokenStorage.getCredentials(serverName);

    if (!credentials) {
      return null; // 没有令牌,需要认证
    }

    // 检查是否过期
    if (this.tokenStorage.isTokenExpired(credentials.token)) {
      // 尝试刷新
      if (credentials.token.refreshToken) {
        try {
          const newToken = await this.refreshToken(credentials, oauthConfig);
          await this.tokenStorage.saveToken(
            serverName,
            newToken,
            credentials.clientId,
            credentials.tokenUrl
          );
          return newToken.accessToken;
        } catch (error) {
          console.error('[OAuthProvider] 刷新令牌失败:', error);
          return null; // 刷新失败,需要重新认证
        }
      }
      return null; // 没有 refresh_token,需要重新认证
    }

    return credentials.token.accessToken;
  }

  async authenticate(
    serverName: string,
    oauthConfig: OAuthConfig
  ): Promise<OAuthToken> {
    // 1. 生成授权 URL
    const authUrl = this.buildAuthUrl(oauthConfig);
    console.log(`请访问以下 URL 进行授权:\n${authUrl}`);

    // 2. 启动本地回调服务器
    const code = await this.startCallbackServer(oauthConfig.redirectUri);

    // 3. 用授权码换取令牌
    const token = await this.exchangeCodeForToken(code, oauthConfig);

    // 4. 保存令牌
    await this.tokenStorage.saveToken(
      serverName,
      token,
      oauthConfig.clientId,
      oauthConfig.tokenUrl
    );

    return token;
  }
}

流程图:

graph TD
    A[用户请求] --> B{检查令牌}
    B -->|有效| C[返回令牌]
    B -->|无效/过期| D{有 refresh_token?}
    D -->|是| E[刷新令牌]
    E --> F[返回新令牌]
    D -->|否| G[启动 OAuth 流程]
    G --> H[返回新令牌]
    
    style C fill:#90EE90
    style F fill:#90EE90
    style H fill:#90EE90

4. 健康监控

生产环境中,MCP 服务器可能"僵死"(连接正常但不响应)。blade-code 实现了主动健康检查:

export class HealthMonitor extends EventEmitter {
  private intervalTimer: NodeJS.Timeout | null = null;
  private consecutiveFailures = 0;

  constructor(
    private client: McpClient,
    private config: HealthCheckConfig
  ) {
    super();
  }

  start(): void {
    if (this.intervalTimer) {
      return; // 已经启动
    }

    this.intervalTimer = setInterval(async () => {
      try {
        await this.performHealthCheck();
        this.consecutiveFailures = 0; // 重置失败计数
      } catch (error) {
        this.consecutiveFailures++;
        console.warn(
          `[HealthMonitor] 健康检查失败 (${this.consecutiveFailures}/${this.config.maxFailures}):`,
          error
        );

        if (this.consecutiveFailures >= this.config.maxFailures) {
          this.emit('unhealthy', this.consecutiveFailures, error);
          await this.attemptReconnect();
        }
      }
    }, this.config.intervalMs);
  }

  private async performHealthCheck(): Promise<void> {
    const timeout = this.config.timeoutMs || 5000;

    await Promise.race([
      this.client.listTools(), // 调用一个轻量级方法
      new Promise((_, reject) =>
        setTimeout(() => reject(new Error('健康检查超时')), timeout)
      ),
    ]);
  }

  private async attemptReconnect(): Promise<void> {
    console.log('[HealthMonitor] 尝试重连...');
    try {
      await this.client.disconnect();
      await this.client.connect();
      this.consecutiveFailures = 0;
      this.emit('reconnected');
    } catch (error) {
      console.error('[HealthMonitor] 重连失败:', error);
    }
  }
}

配置示例:

{
  enabled: true,
  intervalMs: 30000,    // 每 30 秒检查一次
  timeoutMs: 5000,      // 超时时间 5 秒
  maxFailures: 3        // 连续失败 3 次触发重连
}

工具动态注册

MCP 工具需要转换为 blade-code 的 Tool 接口:

export function createMcpTool(
  client: McpClient,
  serverName: string,
  mcpTool: McpToolDefinition,
  toolName?: string
): Tool {
  return {
    name: toolName || mcpTool.name,
    description: mcpTool.description || `MCP工具: ${mcpTool.name}`,
    parameters: mcpTool.inputSchema || { type: 'object', properties: {} },
    metadata: {
      source: 'mcp',
      serverName,
      originalName: mcpTool.name,
    },

    async execute(args: Record<string, unknown>): Promise<ToolResult> {
      try {
        const response = await client.callTool(mcpTool.name, args);

        return {
          success: true,
          output: formatMcpResponse(response),
          metadata: {
            serverName,
            toolName: mcpTool.name,
          },
        };
      } catch (error) {
        return {
          success: false,
          error: error instanceof Error ? error.message : String(error),
          metadata: {
            serverName,
            toolName: mcpTool.name,
          },
        };
      }
    },
  };
}

关键点:

  • 保留原始工具名(originalName)用于调试
  • 统一错误处理格式
  • 支持元数据传递

错误处理与重连

blade-code 的错误处理遵循以下原则:

1. 错误分类

临时错误(可重试):
- 网络超时
- 连接被拒绝
- 速率限制(429)
- 服务不可用(503)

永久错误(不重试):
- 配置错误(命令不存在)
- 认证失败(401)
- 权限不足(403)
- 协议错误(格式错误)

2. 重试策略

指数退避:
- 第 1 次:1 秒后重试
- 第 2 次:2 秒后重试
- 第 3 次:4 秒后重试
- 第 4 次:8 秒后重试
- 第 5 次:16 秒后重试
- 最大延迟:30

3. 状态机

stateDiagram-v2
    [*] --> DISCONNECTED
    DISCONNECTED --> CONNECTING: connect()
    CONNECTING --> CONNECTED: 成功
    CONNECTING --> ERROR: 失败
    ERROR --> CONNECTING: 重试
    CONNECTED --> ERROR: 意外断连
    CONNECTED --> DISCONNECTED: disconnect()

实战案例

案例 1:集成文件系统 MCP 服务器

// 配置文件
{
  "mcpServers": {
    "filesystem": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/workspace"],
      "env": {
        "NODE_ENV": "production"
      }
    }
  }
}

// 使用
const registry = McpRegistry.getInstance();
await registry.registerServer('filesystem', config.mcpServers.filesystem);

const tools = await registry.getAvailableTools();
// 输出: [{ name: 'read_file', ... }, { name: 'write_file', ... }]

案例 2:集成远程 API(带 OAuth)

{
  "mcpServers": {
    "github": {
      "type": "sse",
      "url": "https://api.github.com/mcp",
      "oauth": {
        "enabled": true,
        "authUrl": "https://github.com/login/oauth/authorize",
        "tokenUrl": "https://github.com/login/oauth/access_token",
        "clientId": "your-client-id",
        "clientSecret": "your-client-secret",
        "scopes": ["repo", "user"],
        "redirectUri": "http://localhost:3000/callback"
      }
    }
  }
}

// 首次使用会自动触发 OAuth 流程
await registry.registerServer('github', config.mcpServers.github);
// 输出: 请访问以下 URL 进行授权: https://github.com/login/oauth/authorize?...

案例 3:健康监控与自动恢复

{
  "mcpServers": {
    "database": {
      "type": "stdio",
      "command": "mcp-database-server",
      "healthCheck": {
        "enabled": true,
        "intervalMs": 30000,
        "timeoutMs": 5000,
        "maxFailures": 3
      }
    }
  }
}

// 监听健康事件
registry.on('serverError', (name, error) => {
  console.error(`服务器 ${name} 出错:`, error);
});

registry.on('healthMonitorReconnected', () => {
  console.log('健康监控触发重连成功');
});

总结

blade-code 的 MCP 集成实现了以下核心功能:

已实现

  1. 多传输层支持:stdio、SSE、HTTP
  2. 智能重试:错误分类 + 指数退避
  3. 自动重连:意外断连自动恢复
  4. OAuth 认证:完整的 OAuth 2.0 流程
  5. 健康监控:主动检测僵死连接
  6. 工具冲突处理:自动添加前缀
  7. 事件驱动:完整的状态机和事件系统

设计亮点

  • 容错优先:网络错误不会导致整个系统崩溃
  • 可观测性:丰富的日志和事件,便于调试
  • 扩展性:新增 MCP 服务器只需修改配置文件
  • 安全性:令牌加密存储,权限最小化

相关资源:

讨论:欢迎在 GitHub Issues 或我的博客评论区交流!

追觅CEO俞浩称年终奖最高20个月,总奖金规模将达10亿量级

2026年2月7日 17:45
2月7日,追觅科技创始人兼CEO俞浩发文回应外界质疑,称演唱会几千万的投入,仅仅相当于公司一天的研发费用投入。追觅现在大约2万名研发管理人员,每天的研发投入大概需要是4000万。他还透露,这两天在审批各个事业部递交过来的年终奖方案。主营业务,公司把净利润的18%作为奖金发放。“这是纯现金部分,还没有算平时的任何福利。盈利最高的部门能拿到11个月的奖金,最高的个体预计会有20个月的奖金!”他表示,“年终奖的总奖金规模,会达到10亿量级。”(界面)

从零手写JavaScript继承函数:一场关于"家族传承"的编程之旅

2026年2月7日 17:20

从零手写JavaScript继承函数:一场关于"家族传承"的编程之旅

引言:JavaScript的"与众不同"

在JavaScript的世界里,继承不是简单的复制粘贴,而是一场关于"原型链"的奇妙冒险。想象一下:别的语言继承就像领养孩子,直接给一套新房子和新衣服;而JavaScript的继承更像是家族传承——孩子不仅有自己的家,还能随时去祖辈家里串门拿东西!

今天,就让我们一起揭开JavaScript继承的神秘面纱,亲手打造一个属于自己的"家族传承"系统。

一、原型链继承:直截了当的"家族企业"

让我们先来看看JavaScript中最"朴实"的继承方式。

一个动物王国的故事

假设我们有一个Animal(动物)家族:

function Animal(name, age) {
    this.name = name;   // 名字
    this.age = age;     // 年龄
}
Animal.prototype.species = '动物';  // 所有动物都有的物种属性

现在,Cat(猫)家族想要继承Animal家族的优良传统。最简单的做法是什么?

方法一:直接"认祖归宗"

function Cat(name, age, color) {
    // 先把Animal家族的基本功学过来
    Animal.call(this, name, age);
    this.color = color;  // 猫特有的毛色
}

// 关键一步:成为Animal家族的"亲传弟子"
Cat.prototype = new Animal();
// 但别忘了改个名,不然别人还以为你是Animal
Cat.prototype.constructor = Cat;

const garfield = new Cat('加菲猫', 2, '黄色');
console.log(garfield.species);  // ✅ 输出:动物(成功继承了物种!)

这里发生了什么?

  • Cat.prototype = new Animal():相当于Cat家族把Animal请来当顾问
  • 现在所有Cat都可以通过"顾问"访问Animal家族的资源

但这种做法有个大问题...

场景想象:你想请Animal当顾问,结果人家拖家带口、把全部家当都搬来了!new Animal()创建了一个完整的Animal实例,但我们需要的仅仅是Animal的"知识库"(原型),而不是它的全部身家。

三大痛点

  1. 浪费内存:Animal实例可能很大,但Cat只需要它的原型
  2. 参数尴尬new Animal()时需要参数,但作为原型时不知道传什么
  3. 效率低下:每次继承都要创建一个可能永远用不着的实例

二、走捷径的诱惑:直接"共享家谱"

有人可能想:"既然只是要原型,那直接共享不就行了?"

// 看似聪明的偷懒方法
Cat.prototype = Animal.prototype;
Cat.prototype.constructor = Cat;

危险!这是个陷阱!

// 猫家族想给自己加个技能
Cat.prototype.eatFish = function() {
    console.log('我爱吃鱼!');
};

// 但意外发生了...
const dog = new Animal('旺财', 3);
dog.eatFish();  // 😱 输出:我爱吃鱼!(狗怎么爱吃鱼了?!)

问题所在

  • Cat.prototypeAnimal.prototype指向同一个对象
  • 给Cat添加方法,Animal也会"被学会"
  • 就像两个部门共用同一个印章,一方修改,另一方遭殃

三、终极方案:聪明的"中间人"策略

我们需要一个既能继承知识,又不造成混乱的方法。这就是我们的"空函数中介"模式——一个聪明的"传话筒"。

手写extends函数:打造完美的家族传承

function extend(Parent, Child) {
    // 1. 请一个"中间人"(空函数F)
    // 它就像家族间的专业翻译,只传话,不添乱
    var F = function() {};
    
    // 2. 让中间人学习Parent的知识库
    F.prototype = Parent.prototype;
    
    // 3. 让Child拜中间人为师
    Child.prototype = new F();
    
    // 4. 给Child正名:你姓Child,不是Parent
    Child.prototype.constructor = Child;
}

来看看这个精妙的传承系统如何工作

// 使用我们的extend函数
function Cat(name, age, color) {
    // 继承Animal的"个人能力"
    Animal.apply(this, [name, age]);
    this.color = color;  // 猫的独有特征
}

// 启动传承仪式!
extend(Animal, Cat);

// 猫家族发展自己的特色
Cat.prototype.purr = function() {
    console.log('喵呜~发出呼噜声');
};

// 见证奇迹的时刻
const kitty = new Cat('小橘', 1, '橘色');
console.log(kitty.species);  // ✅ "动物"(继承了Animal的物种)
kitty.purr();               // ✅ "喵呜~发出呼噜声"(猫的独有技能)

const bird = new Animal('小鸟', 0.5);
console.log(bird.purr);     // ✅ undefined(完全没影响到Animal!)

为什么这个方案如此优雅?

三层隔离保护

  1. 第一层:Cat有自己的原型对象
  2. 第二层:通过中间人F访问Animal的原型
  3. 第三层:对Cat原型的修改完全不影响Animal

内存关系图

kitty(猫实例)
    ↓ "我可以找我的家族要东西"
Cat.prototype(猫家族知识库)
    ↓ "我学自中间人F"
F.prototype(= Animal.prototype)
    ↓ "我来自Animal家族"
Animal.prototype(动物家族知识库)
    ↓ "我是所有对象的起点"
Object.prototype

四、完整实战:打造动物世界的继承体系

让我们把理论变成实战代码:

// 增强版extend:更智能的传承系统
function extend(Child, Parent) {
    // 1. 请专业中间人(开销极小)
    var F = function() {};
    
    // 2. 中间人学习Parent的全部知识
    F.prototype = Parent.prototype;
    
    // 3. Child拜师学艺
    Child.prototype = new F();
    Child.prototype.constructor = Child;
    
    // 4. 给Child一个"家谱"(可选但很贴心)
    Child.uber = Parent.prototype;
    
    // 5. 现代JavaScript的额外支持
    if (Object.setPrototypeOf) {
        Object.setPrototypeOf(Child.prototype, Parent.prototype);
    }
}

// 动物家族基类
function Animal(name, age) {
    this.name = name;
    this.age = age;
}
Animal.prototype.breathe = function() {
    return '我在呼吸新鲜空气';
};

// 猫家族
function Cat(name, age, color) {
    // 先学Animal的"生存技能"
    Animal.call(this, name, age);
    this.color = color;
}

// 启动传承
extend(Cat, Animal);

// 猫家族的独门绝技
Cat.prototype.climbTree = function() {
    return '我能爬上最高的树!';
};

// 看看成果
const tom = new Cat('汤姆', 3, '蓝灰色');
console.log(tom.breathe());    // ✅ "我在呼吸新鲜空气"
console.log(tom.climbTree());  // ✅ "我能爬上最高的树!"
console.log(tom.color);        // ✅ "蓝灰色"

五、现代JavaScript:语法糖背后的真相

ES6给了我们更优雅的写法:

class Animal {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
    
    breathe() {
        return '我在呼吸新鲜空气';
    }
}

class Cat extends Animal {
    constructor(name, age, color) {
        super(name, age);  // 这行相当于 Animal.call(this, name, age)
        this.color = color;
    }
    
    climbTree() {
        return '我能爬上最高的树!';
    }
}

重要提醒class只是"语法糖",底层依然是我们的原型继承。理解原型,才能真正掌握JavaScript的继承精髓。

总结:继承的智慧

通过这次探索,我们学到了:

  1. 原型实例化继承 → 简单粗暴但笨重(请整个家族当顾问)
  2. 直接原型继承 → 危险捷径(共用家谱,一损俱损)
  3. 空函数中介模式 → 优雅方案(专业中间人,隔离又高效)

编程就像家族传承

  • 好的继承应该像家训传承:后代学习前辈的智慧,但有自己的发展
  • 坏的继承就像财产纠纷:边界不清,互相影响
  • 我们的extend函数就像是找到了完美的家族信托方案

进阶思考

如果你要继续优化这个extend函数,你会添加哪些功能?

  1. 多重继承:像继承多个家族的优秀基因?
  2. 方法混入:像选择性学习不同师父的绝招?
  3. 静态方法继承:连家族的传统仪式也一起继承?

动手挑战:尝试实现一个支持多重继承的extend函数,让一个类可以同时继承多个父类的特性。把你的代码分享到评论区,看看谁的实现最优雅!

记住:在JavaScript的世界里,理解原型链就像掌握家族的秘密通道。通过这些通道,你可以在不破坏原有结构的前提下,构建出强大而灵活的代码"家族"。现在,你也是掌握这个秘密的开发者了!

深入浅出:手写 new 操作符,彻底理解 JavaScript 的实例化过程

2026年2月7日 17:09

深入浅出:手写 new 操作符,彻底理解 JavaScript 的实例化过程

引言

在 JavaScript 中,new 操作符是我们创建对象实例最常用的方式之一。但你真的了解 new 背后发生了什么吗?今天我们就来深入探讨一下 new 的奥秘,并亲手实现一个自己的 new 函数。

在解释手写new函数的之前,我们先解释一些知识点方便我们后面理解手写new的过程

一、构造函数被实例化的完整过程

什么是构造函数?

构造函数其实就是一个普通的函数,但当我们使用 new 关键字调用它时,它就变成了一个"构造函数"。

function Person(name, age) {
    this.name = name;
    this.age = age;
}

// 作为普通函数调用
Person('张三', 18);  // this 指向全局对象(浏览器中是 window)

// 作为构造函数调用
const person = new Person('张三', 18);  // this 指向新创建的对象

new 实例化的完整步骤

比喻:想象一下工厂生产产品的过程:

  1. 准备原材料(创建空对象)
  2. 按照设计图纸加工(调用构造函数)
  3. 贴上品牌标签(设置原型链)
  4. 出厂检验(返回对象)

具体来说,new 操作符执行以下4个步骤:

步骤1:创建一个空对象
const obj = {};
步骤2:将新对象的 __proto__ 指向构造函数的 prototype
obj.__proto__ = Constructor.prototype;
步骤3:将构造函数的 this 绑定到这个新对象,并执行构造函数
Constructor.apply(obj, args);
步骤4:如果构造函数返回了一个对象,则返回该对象;否则返回新创建的对象
function Person(name) {
    this.name = name;
    // 如果没有显式返回,默认返回 this
}

function Person2(name) {
    this.name = name;
    return { custom: 'object' };  // 如果返回对象,则替代新创建的对象
}

const p1 = new Person('张三');  // Person {name: "张三"}
const p2 = new Person2('李四'); // {custom: "object"}

二、apply、call 和 bind 的区别

这三个方法都用于改变函数执行时的 this 指向,但使用方式略有不同。

比喻说明

想象你是一家公司的CEO(函数),你需要给员工(对象)下达指令:

  • call:直接告诉某个员工该做什么
  • apply:告诉某个员工该做什么,并给他一袋资料(数组参数)
  • bind:预先告诉员工,将来某个时间点需要做什么

1. call 方法

function introduce(greeting, punctuation) {
    console.log(`${greeting}, 我是${this.name}${punctuation}`);
}

const person = { name: '张三' };

// call 接受参数列表
introduce.call(person, '你好', '!');  // "你好, 我是张三!"

2. apply 方法

// apply 接受参数数组
introduce.apply(person, ['你好', '!']);  // "你好, 我是张三!"

3. bind 方法

// bind 返回一个新函数,而不是立即执行
const boundIntroduce = introduce.bind(person, '你好');
boundIntroduce('!');  // "你好, 我是张三!"

总结对比

方法 立即执行 参数形式 返回值
call 参数列表 函数执行结果
apply 数组 函数执行结果
bind 参数列表 新函数

三、arguments 对象详解

什么是 arguments?

arguments 是函数内部的一个特殊对象,它包含了函数调用时传入的所有参数。

function showArgs() {
    console.log(arguments);
    console.log(arguments.length);
    console.log(arguments[0]);
}

showArgs(1, 2, 3);
// 输出:
// Arguments(3) [1, 2, 3]
// 3
// 1

arguments 的特点

1. 类数组对象(Array-like Object)

arguments 看起来像数组,但不是真正的数组:

function checkArguments() {
    console.log('长度:', arguments.length);
    console.log('可索引:', arguments[0], arguments[1]);
    console.log('是数组吗?', Array.isArray(arguments));  // false
    console.log('类型:', Object.prototype.toString.call(arguments)); // [object Arguments]
}

checkArguments('a', 'b', 'c');
2. 不能使用数组的方法
function tryArrayMethods() {
    // 这些会报错
    // arguments.map(item => item * 2);  // ❌ 错误
    // arguments.reduce((sum, num) => sum + num);  // ❌ 错误
    
    // 但可以这样遍历
    for (let i = 0; i < arguments.length; i++) {
        console.log(arguments[i]);
    }
    
    // 或者用 for...of(ES6+)
    for (const arg of arguments) {
        console.log(arg);
    }
}

如何将 arguments 转为真正的数组?

方法1:Array.from (ES6)
function convertArguments1() {
    const argsArray = Array.from(arguments);
    console.log(Array.isArray(argsArray));  // true
    console.log(argsArray.map(x => x * 2));  // 可以正常使用数组方法
}
方法2:扩展运算符 (ES6)
function convertArguments2(...args) {  // 直接在参数中使用
    console.log(Array.isArray(args));  // true
}

function convertArguments3() {
    const argsArray = [...arguments];
    console.log(Array.isArray(argsArray));  // true
}
方法3:Array.prototype.slice.call (ES5)
function convertArguments4() {
    const argsArray = Array.prototype.slice.call(arguments);
    console.log(Array.isArray(argsArray));  // true
}

arguments 的注意事项

  1. 箭头函数没有 arguments
const arrowFunc = () => {
    console.log(arguments);  // ❌ 报错:arguments is not defined
};

// 箭头函数应该这样获取参数
const arrowFunc2 = (...args) => {
    console.log(args);  // ✅ 正确
};
  1. arguments 和参数变量联动(非严格模式)
function linkedArguments(a, b) {
    console.log('a:', a, 'arguments[0]:', arguments[0]);
    
    a = 'changed';
    console.log('修改后 a:', a, 'arguments[0]:', arguments[0]);
    
    arguments[0] = 'changed again';
    console.log('再次修改后 a:', a, 'arguments[0]:', arguments[0]);
}

linkedArguments('original', 2);
// 输出:
// a: original arguments[0]: original
// 修改后 a: changed arguments[0]: changed
// 再次修改后 a: changed again arguments[0]: changed again

四、开始手写实现 new 操作符

现在,让我们结合以上知识点,一步步实现自己的 new 函数。

基础版本实现

function objectFactory(Constructor, ...args) {
    // 1. 创建一个空对象
    const obj = {};
    
    // 2. 将新对象的原型指向构造函数的原型
    obj.__proto__ = Constructor.prototype;
    
    // 3. 将构造函数的 this 绑定到新对象,并执行构造函数
    Constructor.apply(obj, args);
    
    // 4. 返回新对象
    return obj;
}

增强版本(处理构造函数返回值)

function objectFactory(Constructor, ...args) {
    // 1. 创建新对象,并设置原型链
    const obj = Object.create(Constructor.prototype);
    
    // 2. 执行构造函数,绑定 this
    const result = Constructor.apply(obj, args);
    
    // 3. 判断构造函数返回的是否是对象
    // 如果是对象则返回该对象,否则返回新创建的对象
    return typeof result === 'object' && result !== null ? result : obj;
}

完整实现(兼容 ES5)

function objectFactory() {
    // 1. 获取构造函数(第一个参数)
    const Constructor = [].shift.call(arguments);
    
    // 2. 创建空对象,并继承构造函数的原型
    const obj = Object.create(Constructor.prototype);
    
    // 3. 执行构造函数,将 this 指向新对象
    const result = Constructor.apply(obj, arguments);
    
    // 4. 返回结果
    return typeof result === 'object' ? result : obj;
}

使用示例

function Person(name, age) {
    this.name = name;
    this.age = age;
}

Person.prototype.sayHello = function() {
    console.log(`你好,我是${this.name},今年${this.age}岁`);
};

// 使用原生的 new
const person1 = new Person('张三', 18);
person1.sayHello();  // "你好,我是张三,今年18岁"

// 使用我们手写的 objectFactory
const person2 = objectFactory(Person, '李四', 20);
person2.sayHello();  // "你好,我是李四,今年20岁"

console.log(person1 instanceof Person);  // true
console.log(person2 instanceof Person);  // true
console.log(person1.sayHello === person2.sayHello);  // true(共享原型方法)

处理特殊情况

// 1. 构造函数返回对象的情况
function Car(model) {
    this.model = model;
    return { custom: 'special object' };  // 返回对象
}

const car = objectFactory(Car, 'Tesla');
console.log(car);  // {custom: "special object"},而不是 Car 实例

// 2. 构造函数返回基本类型的情况
function Bike(brand) {
    this.brand = brand;
    return 'not an object';  // 返回基本类型,会被忽略
}

const bike = objectFactory(Bike, 'Giant');
console.log(bike);  // Bike {brand: "Giant"},返回新创建的对象

五、实际应用场景

1. 库或框架中的使用

许多库(如早期的 jQuery)会使用类似的技术来创建对象,避免使用 new 关键字:

// jQuery 风格的初始化
function $(selector) {
    return new jQuery(selector);
}

// 或者
function $(selector) {
    return objectFactory(jQuery, selector);
}

2. 创建对象池

function createObjectPool(Constructor, count) {
    const pool = [];
    
    for (let i = 0; i < count; i++) {
        pool.push(objectFactory(Constructor));
    }
    
    return pool;
}

// 创建 10 个默认的 Person 对象
const personPool = createObjectPool(Person, 10);

3. 实现单例模式

function singleton(Constructor, ...args) {
    let instance = null;
    
    return function() {
        if (!instance) {
            instance = objectFactory(Constructor, ...args);
        }
        return instance;
    };
}

const getSingletonPerson = singleton(Person, '单例', 100);
const p1 = getSingletonPerson();
const p2 = getSingletonPerson();
console.log(p1 === p2);  // true

总结

通过手写 new 操作符,我们深入理解了 JavaScript 对象实例化的过程:

  1. 创建空对象:建立对象的"肉身"
  2. 设置原型链:连接对象的"灵魂"(继承)
  3. 执行构造函数:赋予对象"个性"(属性)
  4. 返回对象:决定最终"出厂"的是什么

理解这些底层机制,不仅可以帮助我们更好地使用 JavaScript,还能在面试中脱颖而出。更重要的是,这种"知其然知其所以然"的学习方式,能够让我们在面对复杂问题时,有能力从底层原理出发,找到最优雅的解决方案。

记住,每个看似简单的 new 背后,都隐藏着 JavaScript 原型链、this 绑定、函数执行等多个核心概念的完美协作。掌握了这些,你就真正理解了 JavaScript 面向对象编程的精髓。


茅台全线上涨,今日26年飞天原箱批价报1710元/瓶

2026年2月7日 17:00
“今日酒价”披露的批发参考价显示,2月7日,26年飞天茅台原箱较前一日上涨50元,报1710元/瓶;26年飞天茅台散瓶较前一日上涨20元,报1630元/瓶。25年飞天茅台原箱较前一日上涨70元,报1740元/瓶;25年飞天茅台散瓶较前一日上涨20元,报1640元/瓶。24年飞天茅台原箱较前一日持平,报1780元/瓶;24年飞天茅台散瓶较前一日上涨30元,报1730元/瓶。(财联社)

实战解密:我是如何用Vue 3 + Buffer实现AI“打字机”效果的

2026年2月7日 16:57

实战解密:我是如何用Vue 3 + Buffer实现AI“打字机”效果的

从一行代码到一个完整AI聊天应用

最近我在做一个AI聊天应用时,遇到了一个关键问题:如何让AI的回复像真人打字一样,一个字一个字地出现?  经过一番探索,我发现了流式输出 + Buffer的组合方案。今天,我就用我的实际代码,带你彻底搞懂这个技术!

这个应用是做什么的?

想象你有一个 智能聊天机器人🤖:

  1. 你输入一个问题(比如:"讲一个笑话")
  2. 点击"提交"按钮
  3. 机器人开始思考并回复你
  4. 回复可以一个字一个字出现(流式模式),或者一下子全部出现

第一部分:理解 Vue 3 的基础

1.1 什么是响应式数据?

生活例子📺: 想象你家电视的遥控器:

  • 按"音量+" → 电视音量变大
  • 按"频道+" → 电视换台

这里的 响应式 就是:按遥控器(改变数据),电视立即响应(页面更新)。

// 创建响应式数据就像给数据装上"遥控器"
const question = ref('你好');  // 创建一个能"遥控"的数据

// 在模板中显示
<div>{{ question }}</div>  <!-- 显示:你好 -->

// 如果改变数据
question.value = 'Hello';   // 按下"遥控器"

// 页面自动变成
<div>Hello</div>            <!-- 页面自动更新! -->

1.2 ref 是什么?

ref 就是把普通数据包装成一个特殊的盒子📦:

// 普通数据
let name = "小明";  
// 改变时,Vue不知道,页面不会更新

// 响应式数据
const nameRef = ref("小明");
// 实际上变成了:{ value: "小明" }

// 访问时要加 .value
console.log(nameRef.value);  // "小明"

// 改变数据
nameRef.value = "小红";      // Vue 知道数据变了,会更新页面

第二部分:模板语法

2.1 v-model - 双向绑定

双向绑定 就像 同步的记事本📝:

<!-- 创建一个输入框 -->
<input v-model="question" />

<!-- 这相当于做了两件事:
1. 输入框显示 question 的值
2. 你在输入框打字时,自动更新 question 的值
-->

实际效果:

// 你输入"你好"
question.value = "你好";

// 页面显示
<input value="你好" />

// 你再输入"大家好"
// question.value 自动变成 "大家好"

2.2 @click - 事件监听

就像给按钮装上 门铃🔔:

<button @click="askLLM">提交</button>

<!-- 意思是:点击这个按钮时,执行 askLLM 函数 -->

第三部分:核心功能 - 调用 AI

3.1 基本流程(像点外卖)

const askLLM = async () => {
  // 1. 准备问题(像写菜单)
  if (!question.value) {
    console.log('问题不能为空');
    return;
  }
  
  // 2. 显示"思考中..."(像显示"商家接单中")
  content.value = "思考中...";
  
  // 3. 准备外卖信息
  const endpoint = 'https://api.deepseek.com/chat/completions';  // 外卖平台地址
  const headers = {
    'Authorization': `Bearer ${你的API密钥}`,  // 支付凭证
    'Content-Type': 'application/json',        // 说要送JSON格式
  };
  
  // 4. 下订单
  const response = await fetch(endpoint, {
    method: 'POST',      // 点外卖用POST
    headers,             // 告诉商家信息
    body: JSON.stringify({  // 具体订单内容
      model: 'deepseek-chat',
      stream: stream.value,  // 要不要流式(分批送)
      messages: [{
        role: 'user',
        content: question.value
      }]
    })
  });
  
  // 5. 等外卖送到并处理
  // ... 后面详细讲
}

第四部分:流式响应详细解释

4.1 什么是"流式"?

比喻🎬:

  • 非流式:等电影全部下载完(5GB)才能看
  • 流式:下载一点(10MB)就能开始看,边下载边看

在这个应用中:

  • 非流式:等AI全部生成完文字,一次性显示
  • 流式:AI生成一个字就显示一个字

4.2 流式响应代码详解(逐步讲解)

if (stream.value) {  // 如果用户选了流式模式
  // 第一步:清空上次的回答
  content.value = "";  // 清空显示区域
  
  // 第二步:创建"水管"和"水龙头"
  const reader = response.body?.getReader();  
  // reader 就像水龙头,可以控制水流
  
  const decoder = new TextDecoder();
  // decoder 就像净水器,把脏水(二进制)变成干净水(文字)
  
  let done = false;  // 记录水是否流完了
  let buffer = '';   // 临时水桶,装不完整的水
  
  // 第三步:开始接水(循环读取)
  while (!done) {  // 只要水没流完就一直接
    
    // 接一瓢水(读一块数据)
    const { value, done: doneReading } = await reader?.read();
    // value: 接到的水(二进制数据)
    // doneReading: 这一瓢接完了吗?
    
    done = doneReading;  // 更新是否流完的状态
    
    // 第四步:处理接到的水
    // 把这次的水和上次没处理完的水合在一起
    const chunkValue = buffer + decoder.decode(value);
    buffer = '';  // 清空临时水桶
    
    console.log("收到数据:", chunkValue);
    // 数据格式类似:
    // data: {"delta": {"content": "你"}}
    // data: {"delta": {"content": "好"}}
    // data: [DONE]
    
    // 第五步:把一大块水分成一行一行
    const lines = chunkValue.split('\n')  // 按换行分割
      .filter(line => line.startsWith('data: '));  // 只保留以"data: "开头的行
    
    // 第六步:处理每一行水
    for (const line of lines) {
      const incoming = line.slice(6);  // 去掉开头的"data: "
      // 现在 incoming = '{"delta": {"content": "你"}}'
      
      // 如果是结束标志
      if (incoming === '[DONE]') {
        done = true;  // 停止接水
        break;        // 跳出循环
      }
      
      try {
        // 第七步:解析JSON(把水变成能喝的东西)
        const data = JSON.parse(incoming);
        // data = { delta: { content: "你" } }
        
        const delta = data.choices[0].delta.content;
        // delta = "你"
        
        if (delta) {
          // 第八步:显示出来
          content.value += delta;  // 把"你"加到显示内容里
          // 第一次:content = "你"
          // 第二次:content = "你好"
          // 第三次:content = "你好世"
          // ... 直到完成
        }
      } catch (error) {
        // 如果JSON解析失败(比如收到了不完整的JSON)
        buffer += `data: ${incoming}`;  // 存起来等下一瓢水
      }
    }
  }
}

4.3 为什么需要 buffer

情景模拟: 假设AI要回复"你好世界",但网络传输时可能这样:

第一次收到data: {"delta": {"content": "你 (JSON不完整,少了右括号)

第二次收到好世界"}}

如果直接解析第一次的数据:

JSON.parse('{"delta": {"content": "你');  // 报错!JSON不完整

所以我们需要:

  1. 第一次:buffer = 'data: {"delta": {"content": "你'
  2. 第二次:buffer + 新数据 = 'data: {"delta": {"content": "你好世界"}}'
  3. 现在可以正确解析了

完整工作流程演示

让我用具体的执行过程展示这个系统的精妙:

javascript

// 用户输入:"你好"
// 服务器响应流开始...

// 第1次循环:
收到数据: data: {"delta": {"content": "你"}}\n
分割成行: ['data: {"delta": {"content": "你"}}']
解析成功!→ 显示:"你"

// 第2次循环:
收到数据: data: {"delta": {"content": "好
分割成行: ['data: {"delta": {"content": "好']
JSON解析失败!→ 存入buffer: 'data: {"delta": {"content": "好'

// 第3次循环:
收到数据: "}}\n
当前数据: buffer + 新数据 = 'data: {"delta": {"content": "好"}}'
分割成行: ['data: {"delta": {"content": "好"}}']
解析成功!→ 显示:"你好"

// 第4次循环:
收到数据: data: [DONE]\n
检测到[DONE] → 结束循环

第五部分:完整交互流程

你打开页面
    ↓
看到输入框:[讲一个笑话]
    ↓
点击"提交"
    ↓
Vue调用 askLLM() 函数
    ↓
显示"思考中..."
    ↓
发送请求到DeepSeek
    ↓
AI开始思考
    ↓
【流式模式】
    ↓
收到第一个字:"有"
    ↓
页面显示:有
    ↓
收到第二个字:"个"
    ↓
页面显示:有个
    ↓
收到第三个字:"人"
    ↓
页面显示:有个人
    ↓
...(持续)
    ↓
收到"[DONE]"
    ↓
显示完整:有个人去面试...

第六部分:关键概念总结

概念 比喻 作用
ref() 遥控器📱 让数据变化时页面自动更新
v-model 双向镜子🪞 输入框和数据的双向同步
@click 门铃🔔 点击时执行函数
fetch() 外卖小哥🚴 发送网络请求
getReader() 水龙头🚰 读取流式数据
TextDecoder() 翻译官👨‍💼 把二进制变成文字
JSON.parse() 拆包裹📦 把JSON字符串变成对象

给初学者的建议

  1. 先理解整体:不要一开始就陷入细节
  2. 分块学习
    • 先学会 Vue 基础(ref, v-model)
    • 再学网络请求(fetch)
    • 最后学流式处理
  3. 动手实践:修改代码看看效果
    • stream.value 改成 false 看看区别
    • console.log 里看数据变化
  4. 遇到问题:用 console.log() 打印每一步的结果

这个代码虽然看起来复杂,但每个部分都有明确的作用。就像搭积木一样,每块积木(函数)都有特定的功能,组合起来就实现了强大的AI聊天功能!😊

附录:完整的Vue 3 AI流式输出代码

App.vue 完整代码

<script setup>
import { ref } from 'vue';

const question = ref('讲一个光头强和一个白富美之间的故事,20字');
const stream = ref(true);
const content = ref("");

const askLLM = async () => {
  if (!question.value) {
    console.log('question is empty');
    return;
  }
  
  content.value = "思考中...";
  
  const endpoint = 'https://api.deepseek.com/chat/completions';
  const headers = {
    'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
    'Content-Type': 'application/json',
  };
  
  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: 'deepseek-chat',
      stream: stream.value,
      messages: [{
        role: 'user',
        content: question.value
      }]
    })
  });
  
  if (stream.value) {
    content.value = "";
    const reader = response.body?.getReader();
    const decoder = new TextDecoder();
    let done = false;
    let buffer = '';
    
    while (!done) {
      const { value, done: doneReading } = await reader?.read();
      console.log(value, doneReading);
      done = doneReading;
      
      const chunkValue = buffer + decoder.decode(value);
      console.log(chunkValue);
      buffer = '';
      const lines = chunkValue.split('\n')
        .filter(line => line.startsWith('data: '));
      
      for (const line of lines) {
        const incoming = line.slice(6);
        if (incoming === '[DONE]') {
          done = true;
          break;
        }
        
        try {
          const data = JSON.parse(incoming);
          const delta = data.choices[0].delta.content;
          if (delta) {
            content.value += delta;
          }
        } catch (error) {
          buffer += `data: ${incoming}`;
        }
      }
    }
  } else {
    const data = await response.json();
    console.log(data);
    content.value = data.choices[0].message.content;
  }
}
</script>

<template>
  <div class="container">
    <div>
      <label>输入:</label>
      <input class="input" v-model="question"/>
      <button @click="askLLM">提交</button>
    </div>
   
    <div class="output">
      <div>
        <label>Streaming</label>
        <input type="checkbox" v-model="stream"/>
        <div>{{content}}</div>
      </div>
    </div>
  </div>  
</template>

<style scoped>
* {
  margin: 0;
  padding: 0;
}
.container {
  display: flex;
  flex-direction: column;
  align-items: start;
  justify-content: start;
  height: 100vh;
  font-size: 0.85rem;
}
.input {
  width: 200px;
}
button {
  padding: 0 10px;
  margin-left: 6px;
}
.output {
  margin-top: 10px;
  min-height: 300px;
  width: 100%;
  text-align: left;
}
</style>

美国对Netflix以830亿美元收购华纳兄弟的交易启动反垄断审查

2026年2月7日 16:45
Netflix拟以近830亿美元收购华纳兄弟搜索频道公司,此举正面临美国政府的反垄断审查;与此同时,Paramount仍在针对这家好莱坞传媒巨头发起竞争性收购要约。 据知情人士透露,美国司法部反垄断执法人员正就此项收购向行业相关方展开问询,调查Netflix收购华纳兄弟影视制作及流媒体业务后,是否可能拥有垄断性市场力量,以此评估该交易存在的竞争风险。(新浪财经)

阿里千问APP免单范围扩展至天猫超市

2026年2月7日 16:31
阿里继续投入AI大战。2月7日,千问APP宣布,“免单卡”可以在千问APP里买天猫超市的酒水零食、米面粮油、家居日用、生鲜水果等,一句话下单就能送到家门口。(证券时报)

说点不一样的。GPT-5.3 与 Claude Opus 4.6 同时炸场,前端变天了?

2026年2月6日 12:21

大家好,我是 Sunday。

早上一起来,手机就被刷屏了...各大号主都在写 OpenAI 和 Anthropic 在昨天晚上发布的最新模型 GPT-5.3-CodexClaude Opus 4.6

我看了一眼各大号主的文章,发现大家都在狂欢(真假不重要,至少文章中都在狂欢),都在发跑分截图。有的说 Claude 的 一百万(1M)上下文无敌 了,有的说 GPT-5.3 的 OSWorld 分数太吓人。

Claude 提供百万上下文

GPT-5.3 的 OSWorld 跑分

但是,Sunday 作为一个写了十几年代码、现在天天琢磨怎么用 AI 提效的“老前端”。看了这两篇长达数万字的技术文档之后,却并没有其他博主反应的那么兴奋。

为什么呢?

因为,Sunday 发现在这两篇文章中,顶级 AI 模型目前争论的焦点,已经完全不是“谁写的代码 Bug 少”这么简单了,他们正在重新定义什么是“写代码”?

所以,今天这篇文章,Sunday 就我不带大家像看热闹一样去比那些虚无缥缈的跑分(虽然跑分都很高),我想带大家从另外一个角度,来看看这次更新的核心内容。或许大家看完之后,能有不一样的收获。

一、 Claude Opus 4.6

先说 Anthropic 的 Claude Opus 4.6

大家都在吹 100 万 Token 上下文(1M Context)

很多人对这个数字没概念,觉得不就是:“可以把一个超级大的文档扔给 Claude 让他处理了吗?”

错。大错特错。

想要明白这个,我们需要先知道 AI 写代码到底是怎么去写的!

在 Opus 4.6 之前,我们用 AI 写代码是 “切片式” 的。

什么意思呢?

比如:你的项目可能有 500 个文件,但你每次只能复制粘贴那 3 个相关的文件给 AI。 AI 每次也只是读取其中的部分文件的部分代码。简单理解就是:AI 是看不见的全局架构设计的。它看不见你的隐藏依赖、架构设计方案、更不了解你 CSS 中可能会存在的全局污染问题。

所以,AI 经常会写出那种 “局部完美,全局崩盘” 的代码。

之前我们的处理方式都是:先让 AI 进行逻辑拆解,然后根据具体的步骤在一步步执行,这样会好很多。

但是现在不一样了,100 万 Token 上下文已经足够我们去理解非常大的项目代码。

除此之外,Claude 还提供了一个叫做 Context Compaction(上下文压缩) 的技术,这个技术会会自动总结并替换旧的上下文,让 Claude 能够执行更长时间的任务而不会达到限制。

这意味着什么?

这意味着:你可以把 整个 前端项目(src 目录 + 配置 + 文档)一次性扔给 Claude,他们通过上下文压缩方案,来逐步处理你的所有源代码。

除了这个之外,还有一个叫做 Agent Teams(代理团队) 的东西。这个和 Sunday 之前写的一篇文章有点相似,感兴趣的同学可以看看:我创建了一个全 AI 员工的一人公司

这玩意是啥意思呢?

简单来说就是:以前我们用 Claude Code,是一对一。现在,他可以直接创建出一个 Claude Code 的团队。 团队中每个人(Agent)各司其事,各自独立,并还可以相互写作,相互吵架。

  • Agent A(架构师):负责拆解需求,不写代码。
  • Agent B(开发):负责具体实现。
  • Agent C(测试):负责写单元测试,并试图为了 Agent B 的代码找茬。

就像 claude 的更新公告在结尾所展示的一样:这次更新是解锁了新的长期任务的开始

二、 GPT-5.3 Codex

如果说 Claude 是在横向扩展(容量),那 OpenAI 的 GPT-5.3 Codex 就是在纵向钻孔(深度)。

这篇博客里最让我起鸡皮疙瘩的一句话是:

"GPT-5.3 Codex is the first model to play a key role in its own development." (GPT-5.3 Codex 是第一个在自身开发过程中发挥关键作用的模型。)

兄弟们,这剧本我看过,这就叫“左脚踩右脚上天”。

在 OpenAI 的程序员现在已经开始使用 GPT 来开发新的 GPT 了....

这意味着: 目前的 AI 模型已经完全可以应用在复杂的商业项目中。

而在看完整个 GPT 5.3 的更新公告之后,Sunday 发现其中有两个点是最重要的:

1. “交互式”纠偏(Interactive Collaboration)

以前用 Agent 写代码,大家最害怕的是什么?

最怕的是它 “一条道走到黑”!

你给个需求,它跑了半小时,最后给你一坨跑不通的代码,你还不知道它是哪一步歪的。

GPT-5.3 Codex 引入了 “人机介入” 机制。

当它在构建一个复杂的 Web 游戏(比如博客里展示的那个赛车游戏)时

如果它卡住了,或者方向偏了,你可以实时暂停它,给它喂一句反馈,它能无缝接住你的思路继续干。

这解决了 Agent 落地最大的痛点:不可控性

2. 真正的 GUI 操作能力(OSWorld 64.7%)

OSWorld 是 AI 模型中的一个专业属于,他表示的是 对视觉任务的识别程度

通常情况下,普通人的 OSWorld 大约是在 72% 左右。

但是,这次 GPT 5.3 的更新 OSWorld 分数飙到了 64.7%

这意味着,GPT 现在不只是只能在终端里面跑命令行了。他现在可以像人一样,打开浏览器,打开 Chrome DevTools,点击那个报错的按钮,查看 Network 面板,然后切回编辑器改代码。

这下感觉 “测试要失业了...”。之前咱们还得写个测试代码啥的,现在都已经不需要了。

我们可以直接告诉 GPT-5.3:“去把那个下单流程测一遍,如果支付失败了,截个图发我。”

它现在完全可以做到了!

三、 思考总结

不知道大家看完这些升级之后是什么感觉?

Sunday 个人的感觉是:“前端又要失业了”。毕竟 2025 年前端一年就死了 10 次...

没那么严重!

不过,大家需要注意的是:如果你还是把自己定义为 “切图仔” 或者 “API 调包侠” ,那是真完了

但是,如果你把自己定义为 “产品工程师” !那么属于你的黄金时代才刚刚开始。

为什么这么说?

1. 技术的“平权”

以前,我们要想做一个全栈应用,门槛太高了。你要懂 Docker,要懂 K8s,要懂数据库调优。

现在,GPT-5.3 Codex 连自己的训练集群都能管理。你只要有系统设计的能力,你一个人就是一个团队(AI 虽然不便宜,但是比请人可便宜多了)。

前端开发者,凭借对 用户体验(UX) 的敏感度,加上 AI 强大的后端填补能力,将成为最容易转型为“独立开发者”或“超级个体”的人群。

2. 从 “怎么实现” 到 “如何解决问题”

在以前,我们这些程序员 80% 的时间在研究 "如何去实现某一个功能"(这个效果怎么用 CSS 实现?这个状态怎么管理?)。

但是现在,不需要了。

现在,无论是 Claude 还是 GPT 都可以帮助我们去解决如何实现功能的问题。

因此,我们需要把重点改为:我们要解决什么问题?用户的痛点在哪里?系统的数据流怎么设计才合理?

在这种场景下,对用户和需求的感知力 正变的越来越重要。

最后,我想问大家一个问题:

如果明天,你不需要再写一行具体的业务代码,只需要审核 Agent 提交的 PR,你会把你省下来的这 8 个小时,用来干什么?

是去学新的技术?还是去深入理解业务?或者,去创造一个属于你自己的产品?

这才是 2026 我们思考的事情

别光看着跑分激动了。行动起来,去申请 API,去把你的 IDE 换成 Cursor 或者 Windsurf,去感受一下这种 “与硅基生物结对编程” 的感觉。

我是 Sunday。如果你在用这两个模型的过程中遇到了什么坑,或者发现了什么新玩法,随时在评论区或者群里告诉我。我们一起研究,绝不掉队。

JS-AbortController:优雅中止请求操作

2026年2月6日 11:32

前言

在前端开发中,我们经常遇到需要中途撤回请求的情况(例如:搜索框快速输入、大型文件上传取消、或是 AI 聊天流的即时中断)。传统的 Promise 一旦启动就无法在外部“叫停”,而 AbortController 的出现,完美填补了这一空白。

一、 核心概念与原理

AbortController 是 JavaScript 内置的信号控制对象,它是实现异步操作可控制、可中止的核心。

1. 关键组成部分

  • controller.signal:一个 AbortSignal 对象实例。它充当“监听器”,将其传递给异步操作后,该操作会持续观察信号状态。

  • controller.abort() :触发中止的方法。调用后,signal 上的 abort 事件会被触发,同时将 signal.aborted 设为 true


二、 基础使用模式

1. 实现步骤

  1. 使用 new AbortController() 生成实例。
  2. 将实例中的 signal 属性传递给需要支持中止的异步 API(如 fetch)。
  3. 在合适的时机调用 controller.abort() 即可主动终止。

2. 代码示例

// 1. 创建 AbortController 实例
const controller = new AbortController();
const { signal } = controller;

// 2. 发起请求并绑定信号
fetch("/api/data", { signal })
  .then((response) => response.json())
  .then((data) => console.log("请求成功:", data))
  .catch((err) => {
    // 3. 捕获中止错误
    if (err.name === "AbortError") {
      console.log("主动取消:请求被成功截断");
    } else {
      console.error("请求失败:", err);
    }
  });

// 2 秒后主动取消请求
setTimeout(() => {
  controller.abort(); 
}, 2000);

三、 进阶技巧与场景

1. 批量取消请求

如果想同时取消多个相关的请求,可以给这些请求共享同一个 signal。当调用 abort() 时,所有关联的任务都会收到中止信号。

2. 示例

// 使用同一个 AbortController 取消多个请求
const controller = new AbortController();

// 请求1
const request1 = fetch('url1', {
  signal: controller.signal
});

// 请求2
const request2 = fetch('url2', {
  signal: controller.signal
});

// 请求3
const request3 = fetch('url3', {
  signal: controller.signal
});

// 同时取消所有请求
document.getElementById('cancelBtn').addEventListener('click', () => {
  controller.abort();
  console.log('所有请求已取消');
});

// 等待所有请求
Promise.all([request1, request2, request3])
  .then(responses => Promise.all(responses.map(r => r.json())))
  .then(data => console.log('所有数据:', data))
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('请求被取消');
    }
  });

3. 注意事项

  • 兼容性:并非所有 API 都原生支持。目前 fetchAxios (v0.22+) 模块已提供支持。
  • 幂等性abort() 方法只能生效一次。多次调用虽然不会报错,但只有第一次调用会触发中止逻辑。

四、 总结对比

特性 传统 Promise 带有 AbortController 的 Promise
可控性 开启后无法干预 可随时通过 abort() 中止
异常处理 只有成功/失败 增加 AbortError 类型,方便区分主动取消与网络异常
应用场景 简单的数据获取 复杂交互、流式输出、性能调优

前端向架构突围系列 - 状态数据设计 [8 - 2]:前端框架的“细粒度响应式”原理

2026年2月6日 09:26

写在前面

React 的痛: 在 React 中,一个 State 变了,组件就会重新执行(Re-render)。为了性能,我们不得不搞出 Fiber 架构,搞出时间切片,搞出 useMemo。这就好比:为了能在干草堆里找到一根针,React 发明了一台超级高科技的“干草堆翻找机”。

Signal 的解: 细粒度响应式(Signal)的思路是:在扔针进去的时候,就给针系上一根绳子。要找针的时候,拉绳子就行了。

本篇我们将深入内核,手写一个迷你 Signal 系统,看清它的本质。

image.png


一、 宏观对决:VDOM vs. Fine-Grained (细粒度)

要理解 Signal,首先要理解它想革谁的命。

1.1 VDOM 的“地毯式搜索”

React 的更新模型是 Snapshot(快照) 式的。

  • 流程: 数据变了 -> 运行整个组件函数 -> 生成新的 VDOM 树 -> 对比新旧树 (Diff) -> 找出差异 -> 更新 DOM。
  • 复杂度: 跟组件树的大小成正比。
  • 问题: 哪怕只改了一个文本节点,整个组件(甚至子组件)的逻辑都要重跑一遍。

1.2 Signal 的“点对点狙击”

SolidJS 或 Vue 的更新模型是 Dependency Graph(依赖图) 式的。

  • 流程: 数据变了 -> 直接定位到绑定了该数据的 DOM 节点 -> 更新 DOM。
  • 复杂度: 跟动态节点的数量成正比(通常是 O(1))。
  • 核心: 组件函数只在初始化时运行一次!之后再也不会运行了。

二、 解剖 Signal:发布订阅的进化体

Signal 并不神秘,它本质上就是 “保存值的容器” + “自动依赖追踪” 。 它由两个核心动作组成:Track (追踪/读)Trigger (触发/写)

2.1 核心 API 模拟

以 SolidJS/React 风格为例,我们造一个 Signal:

// 这是一个全局变量,用来记录“当前谁在查我不?”
let activeEffect = null;

function createSignal(initialValue) {
  let value = initialValue;
  const subscribers = new Set(); // 订阅者名单

  // Getter (读)
  const read = () => {
    if (activeEffect) {
      // 1. 依赖收集 (Track):如果有人在关注我,把他记下来
      subscribers.add(activeEffect);
    }
    return value;
  };

  // Setter (写)
  const write = (newValue) => {
    value = newValue;
    // 2. 派发更新 (Trigger):通知名单里所有人干活
    subscribers.forEach(fn => fn());
  };

  return [read, write];
}

2.2 魔法的粘合剂:Effect

光有 Signal 没用,得有人“读”它,订阅关系才能建立。这就需要 createEffect(在 Vue 里叫 watchEffect)。

function createEffect(fn) {
  // 把自己标记为“正在执行的副作用”
  activeEffect = fn;
  
  // 执行一次函数。
  // 注意:函数内部会读取 Signal,从而触发 Signal 的 Getter,
  // 进而把这个 fn 添加到 subscribers 里。
  fn();
  
  // 执行完复原
  activeEffect = null;
}

2.3 跑起来看看

const [count, setCount] = createSignal(0);

createEffect(() => {
  console.log("数字变了:", count()); 
});
// 输出: 数字变了:0 (初始化执行)

setCount(1);
// 输出: 数字变了:1 (自动触发!)

这就是细粒度响应式的最简内核。没有任何 VDOM,没有 Diff,只有精准的函数调用链。


三、 进阶: computed 与依赖图的自动构建

Signal 系统最强大的地方在于它能自动构建依赖图。 在架构设计中,我们经常使用 computed (派生状态)。

computed 既是 消费者(它依赖别的 Signal),又是 生产者(别的 Effect 依赖它)。

3.1 懒计算与缓存 (Memoization)

细粒度框架中的 computed 通常是惰性的(Lazy)。

  • 只有当有人读它时,它才计算。
  • 如果它依赖的 Signal 没变,它直接返回缓存。

3.2 动态依赖收集

这是 React useMemo 永远做不到的。 React 的依赖数组 [a, b] 是手动声明的(静态)。而 Signal 的依赖是运行时动态收集的。

const [show, setShow] = createSignal(true);
const [name, setName] = createSignal("Gemini");
const [age, setAge] = createSignal(18);

createEffect(() => {
  // 动态依赖!
  if (show()) {
    console.log(name()); // 此时依赖是 [show, name]
  } else {
    console.log(age());  // 此时依赖变成 [show, age]
  }
});

架构意义: 这种机制保证了最小化计算。当 show 为 false 时,改变 name 根本不会触发这个 Effect,因为系统知道这一刻 name 不重要。


四、 为什么 React 还在坚持?

既然 Signal 这么好,性能这么高,为什么 React 不把 useState 换成 Signal? 这涉及到底层哲学的冲突。

4.1 UI = f(state) vs. UI = Bind(state)

  • React 哲学: UI 是数据的投影(Snapshot) 。每次渲染都是丢弃旧世界,重建新世界。这符合函数式编程的直觉,心智模型最简单。
  • Signal 哲学: UI 是数据的绑定(Binding) 。初始渲染后,组件就消失了,剩下的只有数据和 DOM 之间的连线。

4.2 代数效应 (Algebraic Effects)

React 团队认为,手动处理 .value 或者 [get, set] 是对开发者心智的负担。他们追求的是 "It just works" 。 React 正在搞的 React Compiler (React Forget) ,其实是一条殊途同归的路:

  • Signal:运行时通过 Proxy 收集依赖,实现细粒度更新。
  • React Compiler:编译时分析代码,自动插入 memoization,模拟细粒度更新的效果。

五、 总结:架构师的选择

理解了原理,我们在架构设计中就能明白:

  1. Vue 3 / Solid: 适合高性能仪表盘、即时通讯、即时编辑类应用。因为它们对 CPU 的利用率极高,没有 VDOM 的 Overhead。

  2. React: 适合大型业务系统、生态依赖重的应用。虽然有一些性能损耗,但其编程模型的一致性(Pure Render)能降低逻辑复杂度。

  3. 趋势: 越来越多的状态管理库(MobX, Valtio, Preact Signals)允许你在 React 中使用 Signal。

    • 架构模式: 使用 Signal 管理频繁变化的局部状态(避免 React 顶层重渲染),使用 React Context 管理低频的全局状态

Next Step: 我们搞懂了前端“怎么存数据”(Redux/Atomic)和“怎么更新数据”(Signal)。 但还有一个最大的麻烦没解决:API 数据。 我们以前总是把后端返回的 JSON 也塞进 Redux 里,导致 Redux 变得臃肿不堪。这真的是对的吗? 下一节,我们将通过 React Query (TanStack Query) 来一场架构大扫除。 请看**《第三篇:分治——把 API 赶出 Redux:服务端状态 (Server State) 与客户端状态的架构分离》**。

Vue 权限控制实战:从前端到全局的精细化管理

作者 LeonGao
2026年2月6日 09:08

引言

在现代前后端分离的 Web 应用中,权限管理(Permission Control) 已不再是“后端的专属问题”。
一个成熟的 Vue 项目,往往需要对 菜单、路由、按钮、接口调用 等多个层面进行前端权限管控。

如果没有完善的权限体系,应用就可能出现以下问题:

  • 普通用户访问管理页面;
  • 按钮级别的功能误操作;
  • 多角色系统(如管理员 / 审核员 / 普通用户)逻辑混乱;
  • 甚至前端被篡改导致安全漏洞。

本文将系统讲解 Vue 项目的权限管理机制,涵盖从设计思路、技术实现到工程最佳实践,帮助你构建一个高扩展、高安全性的权限控制体系


正文

🧩 一、问题定义与背景

1. 什么是前端权限控制?

前端权限控制是指在用户登录成功后,根据其角色或授权信息,动态控制:

  • 页面路由访问;
  • 菜单导航展示;
  • 按钮与组件渲染;
  • 接口请求校验。

典型应用场景:

  • SaaS后台管理系统(多角色权限结构);
  • 企业内部系统(分层审批流);
  • 平台运营端(租户隔离数据控制)。

2. Vue 中常见权限类型

权限类型 控制对象 技术实现
路由权限 页面级访问控制 动态路由 / 路由守卫
菜单权限 导航展示项 过滤菜单树
按钮权限 组件细粒度控制 自定义指令(v-permission)
数据权限 接口或字段访问 请求拦截或后端过滤

⚙️ 二、实现方案与技术细节

1. 后端返回权限结构

后端在用户登录后返回一份权限数据,常见格式为:

{
  "roles": ["admin"],
  "permissions": ["user:view", "user:edit", "order:list"]
}

2. 路由动态加载实现

Vue Router 提供了动态注册路由的能力,我们可以在登录时动态添加用户可访问的路由。

// permission.js

const allRoutes = [
  { path: '/dashboard', name: 'Dashboard', meta: { permission: 'dashboard:view' } },
  { path: '/user', name: 'User', meta: { permission: 'user:view' } },
  { path: '/user/edit', name: 'UserEdit', meta: { permission: 'user:edit' } }
]

export function filterRoutesByPermission(userPerms) {
  return allRoutes.filter(route => userPerms.includes(route.meta.permission))
}

在登录成功后:

import router from '@/router'
import { filterRoutesByPermission } from './permission'

const userPerms = ['dashboard:view', 'user:view']
const accessRoutes = filterRoutesByPermission(userPerms)
accessRoutes.forEach(route => router.addRoute(route))

💡 效果:
只有被授权的用户,才能访问定义在 router 中对应 meta.permission 的页面。


3. 菜单动态渲染

基于相同的权限结构,我们可以将菜单配置与路由信息结合:

// menuConfig.js
export const menuMap = [
  { name: 'Dashboard', path: '/dashboard', permission: 'dashboard:view' },
  { name: '用户列表', path: '/user', permission: 'user:view' },
  { name: '编辑用户', path: '/user/edit', permission: 'user:edit' }
]

// 过滤菜单
export function getVisibleMenus(perms) {
  return menuMap.filter(menu => perms.includes(menu.permission))
}

在模板中动态渲染菜单:

<template>
  <ul>
    <li v-for="item in visibleMenus" :key="item.path">
      <router-link :to="item.path">{{ item.name }}</router-link>
    </li>
  </ul>
</template>

<script setup>
import { getVisibleMenus } from '@/config/menuConfig'
import { useUserStore } from '@/store/user'

const user = useUserStore()
const visibleMenus = getVisibleMenus(user.permissions)
</script>

4. 按钮级权限控制(自定义指令)

在 Vue 3 中,我们可通过自定义指令实现按钮级权限控制。

// directives/permission.js
export default {
  mounted(el, binding) {
    const { value } = binding
    const userPerms = JSON.parse(localStorage.getItem('permissions') || '[]')
    
    if (value && !userPerms.includes(value)) {
      el.parentNode && el.parentNode.removeChild(el)
    }
  }
}

注册指令:

import { createApp } from 'vue'
import App from './App.vue'
import permission from './directives/permission'

const app = createApp(App)
app.directive('permission', permission)
app.mount('#app')

模板使用示例:

<button v-permission="'user:edit'">编辑用户</button>

👉 当用户没有 user:edit 权限时,该按钮将不会被渲染。


5. 接口与数据权限策略

在请求层面控制访问数据安全:

// axios 拦截器
axios.interceptors.request.use((config) => {
  const token = localStorage.getItem('token')
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})

后端验证时再结合角色或租户ID过滤查询结果,形成“前后端协同保护”。


🔍 三、优缺点分析与最佳实践建议

优点 缺点
前端控制 响应快、体验好、可见即所得 安全依赖后端配合,易被篡改
后端控制 数据安全性高 增加接口设计复杂度
前后端协同 安全与体验兼顾 系统架构复杂度提升

💡 实战建议:

  1. 权限粒度由粗到细: 先实现路由级,再扩展到按钮级与数据级;
  2. 前后端统一权限标识码:user:vieworder:delete
  3. 封装统一权限校验函数: hasPermission(perms, code),便于复用;
  4. 在 CI/CD 中加入权限检查脚本,防止新增路由遗漏权限配置。

结论

在 Vue 项目中构建完善的权限体系,是前端架构成熟度的重要标志。
它不仅仅是“限制访问”,更是“清晰定义角色职责”的手段。

通过 动态路由加载 + 指令权限验证 + 后端协同管控,我们可以:

  • 提升系统的安全性与扩展性;
  • 降低维护成本;
  • 优化用户体验与操作感知。

未来,随着 Vue 与服务端协同框架(如 NestJS、GraphQL)的发展,权限管理将朝着**“策略引擎化(Policy Engine)”与“配置即规则化(Config-as-Policy)”**方向演进。


参考资料与拓展阅读

  1. Vue 官方文档:vuejs.org/guide
  2. Vue Router 动态路由指南:router.vuejs.org/
  3. JSON Web Token (JWT) 权限模型:jwt.io/
  4. RBAC 权限管理算法解析 – Martin Fowler
  5. Ant Design Pro + Vue 权限实现范例

💬 一句话总结:

权限系统是 Vue 项目的“安全大脑”。
没有权限控制的前端,就像一个没有门锁的房子 —— 漂亮,但不安全。

为什么ChatGPT能"打字"给你看?从Buffer理解AI流式输出

2026年2月7日 16:18

什么是Buffer?

Buffer(缓冲区)是计算机内存中用于临时存储数据的一块区域。想象一下你正在用杯子接水龙头的水:水龙头直接流到杯子里,如果水流太快,杯子可能会溢出。但如果你在中间放一个水壶(缓冲区),水先流到水壶里,再从水壶倒到杯子里,整个过程就更加可控了。

在JavaScript中,Buffer就是那个"水壶"——它帮助我们在处理二进制数据(如图片、音频、网络传输等)时更加高效和可控。

为什么需要Buffer?

1. 文本 vs 二进制

计算机中一切数据最终都以二进制形式存储,但我们在编程时通常处理的是文本(字符串)。当需要处理非文本数据时,就需要Buffer。

生活比喻:就像快递运输,文本数据就像明信片,内容直接可见;二进制数据就像密封的包裹,你需要专门的工具(Buffer)来查看和处理里面的内容。

2. 效率问题

直接操作二进制数据比操作字符串更高效,特别是在处理大量数据时。

HTML5中的Buffer操作

1. TextEncoder 和 TextDecoder

这是HTML5提供的编码/解码工具:

// 编码:将字符串转换为二进制数据
const encoder = new TextEncoder();
const myBuffer = encoder.encode('你好 HTML5');
console.log(myBuffer); // Uint8Array(10) [228, 189, 160, 229, 165, 189, 32, 72, 84, 77, ...]

// 解码:将二进制数据转换回字符串
const decoder = new TextDecoder();
const originalText = decoder.decode(myBuffer);
console.log(originalText); // "你好 HTML5"

注意:中文字符通常占用3个字节,英文字符占用1个字节,空格也是1个字节。

2. ArrayBuffer - 原始的二进制缓冲区

// 创建一个12字节的缓冲区(就像申请一块12格的内存空间)
const buffer = new ArrayBuffer(12);

// 但ArrayBuffer本身不能直接操作,需要视图(View)来读写

3. 视图(TypedArray)- 操作缓冲区的"眼镜"

ArrayBuffer就像一块空白画布,而TypedArray就是不同颜色的画笔:

const buffer = new ArrayBuffer(16); // 16字节的缓冲区

// 不同的视图类型,用不同的方式"看待"同一块内存
const uint8View = new Uint8Array(buffer);   // 视为8位无符号整数(0-255)
const uint16View = new Uint16Array(buffer); // 视为16位无符号整数
const int32View = new Int32Array(buffer);   // 视为32位有符号整数

// 使用Uint8Array视图操作数据
const view = new Uint8Array(buffer);
const encoder = new TextEncoder();
const data = encoder.encode('Hello');

for(let i = 0; i < data.length; i++) {
    view[i] = data[i]; // 将数据复制到缓冲区
}

实际应用场景

1. 流式数据处理(AI响应示例)

// 模拟AI流式输出
async function simulateAIStreaming() {
    const responses = ["思考", "中", "请", "稍", "候"];
    const buffer = new ArrayBuffer(100);
    const view = new Uint8Array(buffer);
    const decoder = new TextDecoder();
    
    let position = 0;
    
    for (const word of responses) {
        // 模拟网络延迟
        await new Promise(resolve => setTimeout(resolve, 500));
        
        // 将每个词编码并添加到缓冲区
        const encoded = new TextEncoder().encode(word);
        for (let i = 0; i < encoded.length; i++) {
            view[position++] = encoded[i];
        }
        
        // 实时解码已接收的部分
        const receivedSoFar = decoder.decode(view.slice(0, position));
        console.log(`已接收: ${receivedSoFar}`);
    }
}

// 这就是streaming:true的效果——边生成边显示

2. 文件处理

// 读取图片文件并获取其二进制数据
fileInput.addEventListener('change', async (event) => {
    const file = event.target.files[0];
    const buffer = await file.arrayBuffer(); // 获取文件的二进制数据
    
    // 现在可以操作这个buffer
    const view = new Uint8Array(buffer);
    console.log(`文件大小: ${buffer.byteLength} 字节`);
    console.log(`前10个字节: ${view.slice(0, 10)}`);
});

关键概念对比

概念 比喻 作用
ArrayBuffer 空白的内存空间 分配一块原始二进制内存
TypedArray 有刻度的量杯 以特定格式(如整数、浮点数)读取/写入数据
DataView 多功能测量工具 更灵活地读写不同格式的数据
TextEncoder 打包机 将文本打包成二进制
TextDecoder 拆包机 将二进制解包成文本

常见TypedArray类型

// 不同"眼镜"看同一数据的不同效果
const buffer = new ArrayBuffer(16);
const data = [1, 2, 3, 4];

// 使用Uint8Array:每个数字占1字节
const uint8 = new Uint8Array(buffer);
uint8.set(data);
console.log(uint8); // [1, 2, 3, 4, 0, 0, ...]

// 使用Uint16Array:每个数字占2字节
const uint16 = new Uint16Array(buffer);
console.log(uint16); // [513, 1027, 0, 0, ...] 
// 为什么是513?因为1+2*256=513(小端序存储)

性能优化技巧

  1. 复用Buffer:避免频繁创建和销毁Buffer
  2. 批量操作:使用set()方法而不是循环赋值
  3. 适当大小:不要分配过大的Buffer,会浪费内存
// 优化示例:批量操作
const source = new Uint8Array([1, 2, 3, 4, 5]);
const targetBuffer = new ArrayBuffer(10);
const targetView = new Uint8Array(targetBuffer);

// 好:批量复制
targetView.set(source);

// 不好:逐个复制
for (let i = 0; i < source.length; i++) {
    targetView[i] = source[i];
}

总结

Buffer是JavaScript处理二进制数据的核心工具,特别是在:

  • 网络通信(流式传输)
  • 文件操作(图片、音频处理)
  • 加密算法
  • 与WebGL、Web Audio等API交互

记住这个流程: 文本 → TextEncoder → 二进制 → ArrayBuffer → TypedArray操作 → TextDecoder → 文本

就像快递系统:商品(数据)被包装(编码)→ 运输(二进制传输)→ 拆包(解码)→ 使用。

掌握Buffer操作,你就打开了JavaScript处理二进制世界的大门!


延伸学习

  1. Blob对象:文件相关的二进制操作
  2. Streams API:更高级的流式数据处理
  3. WebSocket.binaryType:网络通信中的二进制传输
  4. Canvas图像数据处理:getImageData()返回的就是Uint8ClampedArray
❌
❌