普通视图

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

使用Ai从零开发智慧水利态势感知大屏(开源)

作者 柳杉
2026年3月2日 18:09

基于 React + autofit.js 打造的全屏自适应数据可视化大屏系统


📌 系统概述

智慧水利态势感知系统 是一套专为水利防汛设计的实时监控与数据可视化平台。系统采用现代化的前端技术栈,结合智能自适应方案,能够在任意分辨率的大屏设备上完美呈现,为水利防汛指挥提供全方位的数据支撑。

🎯 核心特性

  • 全屏自适应:基于 autofit.js 实现任意屏幕完美适配
  • 实时数据监控:天气、降雨、河道水情实时更新
  • 地理信息可视化:河南省地图 + 区域预警标注
  • 交互式图表:ECharts 驱动的多维度数据展示
  • 响应式布局:左中右三栏式科技感界面
  • 动态视觉效果:渐变、光晕、动画营造沉浸体验

🎨 界面布局设计

ScreenShot_2026-03-02_164325_575.png 系统采用经典的三栏式大屏布局,设计分辨率为 1920×1080px

┌─────────────────────────────────────────────────────────┐
│                    智慧水利态势感知系统                     │  ← 头部导航
├──────────┬──────────────────────────┬──────────┤
│          │                          │          │
│  左侧面板 │      中心地图可视化       │  右侧面板  │
│          │                          │          │
│ 实时天气  │    河南省地图 + 预警面板   │ 河道水情  │
│ 降雨监控  │                          │ 水位变化  │
│ 降雨统计  │    底部模式切换控制       │ 趋势图表  │
│          │                          │          │
└──────────┴──────────────────────────┴──────────┘

区域功能划分

区域 宽度占比 主要功能
左侧面板 25% 实时天气情况、实时降雨情况、降雨统计
中心区域 50% 河南省地图、暴风雨预警、模式切换
右侧面板 25% 河道实时水情、水情变化、水位趋势

🛠️ 技术架构

核心技术栈

{
  "前端框架": "React 19.2.3",
  "构建工具": "Vite 7.2.4",
  "UI框架": "Tailwind CSS 4.1.17",
  "图表库": "ECharts 6.0.0 + echarts-for-react",
  "自适应方案": "autofit.js 3.2.8",
  "时间处理": "Day.js 1.11.19",
  "类型支持": "TypeScript 5.9.3",
  "图标库": "lucide-react 0.575.0"
}

项目结构

dashboard-autofit-setup/
├── src/
│   ├── components/          # 组件目录
│   │   ├── ui/             
│   │   │   └── Panel.tsx   # 通用面板容器
│   │   ├── Header.tsx      # 顶部导航栏
│   │   ├── LeftPanel.tsx   # 左侧数据面板
│   │   ├── CenterMap.tsx   # 中心地图区域
│   │   └── RightPanel.tsx  # 右侧数据面板
│   ├── hooks/              # 自定义 Hooks
│   │   ├── useData.ts      # 数据获取 Hook
│   │   └── useTime.ts      # 实时时间 Hook
│   ├── api/                # API 接口层
│   │   ├── index.ts        # API 统一导出
│   │   └── mock/          
│   │       └── data.ts     # Mock 数据
│   ├── utils/              # 工具函数
│   │   └── cn.ts           # 类名合并工具
│   ├── assets/             # 静态资源
│   ├── App.tsx             # 根组件
│   ├── autofit.d.ts        # autofit.js 类型声明
│   └── index.css           # 全局样式
└── package.json

🎯 核心功能详解

1️⃣ 全屏自适应解决方案

技术原理

系统采用 autofit.js 作为核心自适应引擎,通过 CSS3 Transform Scale 实现等比例缩放:

关键配置代码:

// App.tsx
useEffect(() => {
  autofit.init({
    dw: 1920,        // 设计稿宽度
    dh: 1080,        // 设计稿高度
    el: '.dashboard', // 缩放目标元素
    resize: true,     // 监听窗口变化
  });

  return () => {
    autofit.off();
  };
}, []);

CSS 样式支持:

/* index.css */
html, body {
  width: 100%;
  height: 100%;
  margin: 0;
  overflow: hidden;
}

#root {
  display: flex;
  justify-content: center;
  align-items: center;
}

.dashboard {
  transform-origin: center center;
  width: 1920px;
  height: 1080px;
}

工作流程

1. 页面加载 → autofit.js 获取窗口尺寸
2. 计算缩放比例 = min(窗口宽/1920, 窗口高/1080)
3. 对 .dashboard 应用 transform: scale(比例)
4. 监听窗口 resize 事件,动态调整

适配效果

  • ✅ 支持 1366×7684K 任意分辨率
  • ✅ 保持 16:9 宽高比不变形
  • ✅ 自动居中对齐,始终撑满屏幕
  • ✅ 无需编写媒体查询代码

2️⃣ 实时天气与降雨监控

功能模块

左侧面板 - 实时天气情况

<Panel title="实时天气情况">
  {/* 实时时间 + 天气状态 */}
  <div className="flex justify-between">
    <div>
      <Clock /> 实时时间
      {time} {date}
    </div>
    <div>
      <CloudRain /> 实时天气
      {weather.weather} {weather.temp}
    </div>
  </div>
  
  {/* 降雨概率趋势图 */}
  <ReactECharts option={rainProbOption} />
</Panel>

数据展示:

  • 📍 实时时钟(基于 Day.js 每秒更新)
  • 🌡️ 温度范围:17~28°C
  • ☀️ 天气状态:晴/多云/雨
  • 📊 24小时降雨概率曲线图

左侧面板 - 实时降雨情况

<Panel title="实时降雨情况">
  {/* 累计降雨量数据 */}
  <div className="flex space-x-6">
    <div>当日累计降雨量: {rainfall.daily} mm</div>
    <div>近3日累计降雨量: {rainfall.threeDay} mm</div>
  </div>
  
  {/* 逐小时降雨量柱状图 */}
  <ReactECharts option={rainfallHoursOption} />
</Panel>

技术亮点:

  • 📈 ECharts 渐变色柱状图
  • 🔄 自动刷新数据(通过 useData Hook)
  • 🎨 动态高亮当前时段

左侧面板 - 降雨统计

展示各行政区划的降雨数据表格,支持:

  • 📋 当日/三日/当月降雨量对比
  • 🔀 可切换流域、水库维度
  • 📜 虚拟滚动加载(处理大量数据)

3️⃣ 地理信息可视化

河南省地图

数据来源: DataV.GeoAtlas(阿里云地理数据服务)

// CenterMap.tsx
useEffect(() => {
  fetch('https://geo.datav.aliyun.com/areas_v3/bound/410000_full.json')
    .then(res => res.json())
    .then(data => {
      echarts.registerMap('henan', data);
      setGeoJson(data);
    });
}, []);

地图特性:

  • 🗺️ 支持缩放、拖拽交互
  • 📍 标注重点城市降雨量
  • ✨ 特效散点标记高风险区域
  • 🌊 动态波纹效果(effectScatter)

暴风雨预警面板

叠加在地图左下角的实时预警卡片:

<div className="absolute left-[6%] bottom-[10%] panel-bg">
  <div className="title">
    🌧️ 暴风雨预警
    <span className="orange-alert">Ⅲ级橙色预警</span>
  </div>
  
  <div className="content">
    <div>预警区域: 郑州 · 南阳市</div>
    <div>1小时最大雨强: 48mm</div>
    <div>未来3小时累计: 96mm</div>
    <div>风险上升: ▲ 32%</div>
    
    {/* 微型趋势柱状图 */}
    <div className="mini-chart">
      {[18, 26, 32, 40, 48, 38].map(v => (
        <div className="bar" style={{height: `${v/52*22}px`}} />
      ))}
    </div>
    
    <button>预案详情</button>
  </div>
</div>

设计亮点:

  • 🎯 橙色预警级别标识
  • 📊 实时数据大字号突出
  • 📈 渐变柱状图可视化趋势
  • 💡 操作建议 + 预案链接

4️⃣ 河道水情监控

右侧面板 - 河道实时水情

7列数据表格展示各站点详细信息:

站点 实时水位 实时雨量 设防水位 防洪高水位 警戒水位 保证水位
伊洛河 40m 20m 20m 20m 20m 20m
卫河 60m 20m 20m 20m 20m 20m

颜色标识:

  • 🟢 实时水位:青色加粗
  • 🟡 警戒水位:黄色
  • 🔴 保证水位:红色

右侧面板 - 河道水情变化

对比上一时段的水位变化:

{riverChanges.map(item => (
  <div className="grid-cols-4">
    <div>{item.station}</div>
    <div>{item.realtime}</div>
    <div>{item.previous}</div>
    <div>
      {item.trend === 'up' ? 
        <ArrowUp className="text-red-500" /> : 
        <ArrowDown className="text-green-500" />
      }
      {item.change}
    </div>
  </div>
))}

交互体验:

  • ⬆️ 上升趋势:红色箭头
  • ⬇️ 下降趋势:绿色箭头
  • 🔄 Hover 高亮当前行

右侧面板 - 水位实时变化趋势

折线图可视化:

series: [{
  type: 'line',
  smooth: true,
  data: waterTrends.data,
  markLine: {
    data: [
      { yAxis: waterTrends.safe, label: '保证水位' },
      { yAxis: waterTrends.warning, label: '警戒水位' }
    ]
  }
}]

技术细节:

  • 📏 警戒/保证水位标线
  • 🎨 渐变填充区域
  • 🔍 Tooltip 悬浮提示
  • 📊 支持流域切换(下拉选择器)

5️⃣ 自定义 Hooks 设计

useTime - 实时时钟

// hooks/useTime.ts
export function useTime() {
  const [time, setTime] = useState('');
  const [date, setDate] = useState('');

  useEffect(() => {
    const timer = setInterval(() => {
      const now = dayjs();
      setTime(now.format('HH:mm:ss'));
      setDate(now.format('YYYY-MM-DD dddd'));
    }, 1000);

    return () => clearInterval(timer);
  }, []);

  return { time, date };
}

应用场景:

  • 头部导航栏右侧时间显示
  • 左侧天气面板实时时钟

useData - 数据获取与缓存

// hooks/useData.ts
export function useData<T>(
  fetcher: () => Promise<T>, 
  initialData: T
) {
  const [data, setData] = useState(initialData);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    fetcher()
      .then(setData)
      .finally(() => setLoading(false));
  }, []);

  return { data, loading };
}

使用示例:

const { data: weather } = useData(
  () => getWeatherData(), 
  { temp: '', weather: '', rainProb: [] }
);

6️⃣ 响应式图表处理

ECharts 自适应问题

问题: autofit.js 的 transform: scale() 会导致 ECharts 图表内部不感知真实容器尺寸。

解决方案:

// CenterMap.tsx
useEffect(() => {
  const handleResize = () => {
    setTimeout(() => {
      if (chartRef.current) {
        const chart = chartRef.current.getEchartsInstance();
        chart?.resize();
      }
    }, 100);
  };

  window.addEventListener('resize', handleResize);
  handleResize(); // 初始化时调用

  return () => window.removeEventListener('resize', handleResize);
}, [geoJson]);

关键点:

  • ⏱️ 延迟 100ms 确保 transform 完成
  • 📐 手动调用 chart.resize() 更新尺寸
  • 🔄 监听 window resize 事件

🎨 视觉设计系统

色彩方案

:root {
  --color-primary: #00ffcc;        /* 主题青色 */
  --color-primary-dark: #00b38f;   /* 深青色 */
  --color-bg-dark: #020b18;        /* 深蓝黑背景 */
  --color-bg-panel: rgba(2,16,32,0.7); /* 面板半透明 */
  --color-border: #00e5ff;         /* 边框青色 */
}

视觉特效

1. 面板样式

.panel-bg {
  background: linear-gradient(
    180deg, 
    rgba(3,26,45,0.8) 0%, 
    rgba(2,14,25,0.8) 100%
  );
  border: 1px solid rgba(0,229,255,0.3);
  box-shadow: inset 0 0 20px rgba(0,229,255,0.1);
}

2. 渐变文字

.text-gradient {
  background: linear-gradient(180deg, #ffffff 0%, #00e5ff 100%);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
}

3. 光晕背景

<div className="absolute inset-0">
  <div className="w-[800px] h-[800px] bg-[radial-gradient(
    circle_at_center,
    rgba(0,229,255,0.1)_0,
    transparent_60%
  )]" />
</div>

📦 部署与优化

构建配置

// package.json
{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  }
}

生产构建

# 安装依赖
npm install

# 开发环境运行
npm run dev

# 生产构建(输出到 dist 目录)
npm run build

# 预览生产构建
npm run preview

性能优化建议

  1. 代码分割

    • Vite 自动进行 chunk 分割
    • React 组件懒加载(React.lazy)
  2. 图片资源

    • 使用 WebP 格式
    • 压缩静态资源
  3. ECharts 按需加载

    import { LineChart, BarChart } from 'echarts/charts';
    import { GridComponent } from 'echarts/components';
    echarts.use([LineChart, BarChart, GridComponent]);
    
  4. 数据请求优化

    • 实现请求缓存
    • 增量数据更新
    • WebSocket 实时推送

🚀 扩展方向

功能增强

  • 多屏联动:支持多大屏同步显示
  • 历史数据回放:时间轴拖拽查看历史
  • 告警推送:WebSocket 实时预警通知
  • 3D 地形:Three.js 立体地形可视化
  • AI 预测:机器学习预测降雨趋势

技术升级

  • TypeScript 完善:增强类型安全
  • 单元测试:Vitest 测试覆盖
  • Docker 部署:容器化部署方案
  • 微前端改造:qiankun/Module Federation
  • 性能监控:接入 Sentry/性能埋点

💡 技术亮点总结

技术点 实现方案 优势
屏幕自适应 autofit.js 零配置、高性能、兼容性强
数据可视化 ECharts 功能强大、交互丰富、文档完善
状态管理 React Hooks 轻量级、易维护、TypeScript 友好
样式方案 Tailwind CSS 原子化、响应式、开发效率高
构建工具 Vite 快速启动、热更新、现代化
时间处理 Day.js 轻量级、国际化、链式调用

📚 参考资料


👨‍💻 开发者信息

项目名称: 智慧水利态势感知系统
技术栈: React + TypeScript + Vite + autofit.js
设计分辨率: 1920×1080
开发时间: 2026年


🎉 结语

本系统综合运用了现代前端技术,实现了高性能、强交互、全适配的数据可视化大屏解决方案。通过 autofit.js 自适应引擎,完美解决了传统大屏开发中的分辨率适配难题,为水利防汛指挥提供了强有力的技术支撑。

核心价值:

  • ✅ 开箱即用的自适应方案
  • ✅ 模块化组件设计易于维护
  • ✅ 丰富的视觉效果提升体验
  • ✅ 完整的技术栈可复用性强

希望这套系统能够为智慧水利建设贡献一份力量!🚀


我放在公众号(柳杉前端) 回复 智慧水利态势感知大屏 获取源码

游戏官网前端工具库:海内外案例解析

2026年3月2日 17:43

在游戏行业竞争日趋激烈的当下,游戏官网早已不再是单纯的信息展示页,而是承载品牌叙事、玩家沉浸体验、内容传播的核心载体。本篇文章,基于海内外游戏官网案例(GTA VI、崩:星穹铁道、Elden Ring 等等),从 动画交互实用工具性能优化UI 组件 等多个维度,对各类工具进行梳理和分析,希望能为游戏官网开发者提供参考思路。

一、动画与交互

动画与交互是游戏官网吸引用户的核心要素,涵盖滚动控制、动态效果、手势响应等工具。

1.1 滚动与视差

通过平滑滚动与多层背景差速移动,营造深度感和电影般的叙事节奏。

💠 Lenis

Lenis 是一款 轻量级(4.7kB)的平滑滚动库。保留滚动条、不破坏原生事件,却能给出 惯性阻尼自定义缓动。可以与主流动画库如 GSAP 集成,实现基于滚动进度的动画。非常适合用于追求流畅体验的网站。

地址:github.com/darkroomeng…

案例:劍與遠征:啟程

1.gif

ouseMultiplier: .7:鼠标滚动倍率(0.7 倍原生速度),较慢的滚动速度能让玩家更从容地浏览角色立绘和剧情介绍。smooth: !0 开启平滑滚动,配合 GSAP 控制的渐显动画,增强视觉连贯性。将 UI状态变化(导航固定、右侧悬浮栏显示)放在滚动事件监听器里。

const lenis = new Lenis({
  mouseMultiplier: 0.7,
  smooth: true,
  smoothTouch: false
})

lenis.on('scroll', (e) => {
  document.querySelector('header').classList.toggle('Fixed', e.scroll > 200)
  document.querySelector('.back-top').classList.toggle('show', e.scroll > window.innerHeight / 2)
  if (window.ScrollTrigger) ScrollTrigger.update()
})

function raf(time) {
  lenis.raf(time)
  requestAnimationFrame(raf)
}
requestAnimationFrame(raf)

💠 fullPage

fullPage.js 专门用于快速创建全屏滚动网站(也称为单页滚动网站)。它将浏览器视口分割成多个全屏大小的部分,并通过平滑的垂直或水平滚动在它们之间进行导航。能提供一种沉浸式、滚动翻阅般的浏览体验,适合需要以 “叙事节奏” 引导用户探索的场景。

地址:alvarotrigo.com/fullPage/

案例:最终幻想14

2.gif

平衡多端体验,responsiveWidth: 750 手机/平板直接原生滑动,优先保证浏览流畅性。afterLoad 事件自动播放视频 & 音轨,onLeave 事件暂停节省流量和优化性能。scrollOverflow 对于内容超高的区域允许单屏内部再滚动, 解决长内容的展示问题。

new fullpage('#fullpages', {
  scrollOverflowReset: true,
  scrollOverflow: true,
  scrollBar: false,
  ...
  resize: true,
  responsiveWidth: 750,
  afterLoad: this.afterLoad,
  onLeave: this.onLeave,
});

💠 Locomotive Scroll

相较于 Lenis 的 物理仿真 或 fullPage 的 全屏分段,Locomotive Scroll 的核心优势在于对 微视差 的精准控制,通过精确监测滚动位置并驱动元素应用视差、平滑过渡等效果,配合 GPU 硬件加速确保流畅运行,尤其适合用于追求强视觉冲击力的官网场景。

地址:locomotivemtl.github.io/locomotive-…

案例:Wizardry Variants Daphne

3.gif

协同使用 Locomotive Scroll(负责平滑滚动)和 GSAP ScrollTrigger(负责基于滚动的动画触发)来创建复杂的交互效果。smartphone 和 tablet 的配置确保在各种移动设备上都能保持平滑滚动体验。lerp: .1 让滚动带有轻微阻尼感,配合魔法场景强化世界观的沉浸感,touchMultiplier: 2 则优化移动端体验,触摸滚动灵敏度倍增,让手机玩家滑动时能快速切换场景。

gsap.registerPlugin(ScrollTrigger);
locomotiveScroll = new LocomotiveScroll({
  el: document.querySelector('.js-root'), 
  smooth: true,                          
  smartphone: { smooth: true },        
  table: { smooth: true },            
  touchMultiplier: 2,                
  lerp: 0.1                           
});

1.2 过渡与动效

为页面元素的状态变化添加流畅的过渡效果,优化官网内容切换的节奏感与视觉连贯性。

💠 Animate.css

Animate.css 是一款轻量级纯 CSS 动画库,提供了多达 60 多种 预设的动画效果(如淡入淡出、滑动、弹跳、旋转等),覆盖页面元素加载、交互反馈、场景过渡等需求。只需为 HTML 元素添加相应的 CSS 类名(例如 animate__animated 基础类和 animate__fadeIn 淡入)即可快速添加动画。

地址:animate.style/

案例:重返未来:1999

5.gif

世界板块 视觉内容(游戏图片)animate__fadeInLeft 从左侧淡入,右侧文字描述 animate__fadeInUp 随后浮现。影音板块 视频/图集/音乐 animate__fadeInUp 统一从下方淡入。通过 animate__delay-* 实现 阶梯式延迟(0s, 1s, 2s),形成依次入场的效果。

<!-- 世界 -->
<div class="swiper-slide pc backstory" data-mouse="small" id="slide4">
    <div class="backstory-left animate__animated animate__fadeInLeft" data-mouse="small">
        <div class="swiper mySwiper backstory-border" id="pcbackstory" data-mouse="small">
            <!-- 视觉内容(游戏图片) -->
        </div>
    </div>
    <div class="backstory-right" data-mouse="small">
        <div class="backstory-right-str animate__animated animate__fadeInUp" data-mouse="small" id="backstoryStr">
            1999年最后一天,“暴雨”降临世界:<br>
            地面无故溢起积水,你的指尖碰到飞升的雨滴—— 一场“暴雨”在向天空倾泻。<br>
            行人和墙壁在雨中剥落溶解,世界似乎来到一个崭新的旧时代。<br>
            而除了你之外的所有人,都在“暴雨”侵蚀后不知所踪。<br>
            1999年的秘密,藏在层层雨幕的背后,藏在1999年最后一天。
        </div>
    </div>
</div>

<!-- 影音 -->
<div class="swiper-slide pc gallery" data-mouse="small" id="slide5">
    <div class="gallery-top" data-mouse="small">
        <div class="gallery-top-1 animate__animated animate__fadeInUp" data-mouse="small" onclick="openVideomask()">
            <!--游戏视频-->
        </div>
        <div class="gallery-top-2 animate__animated animate__fadeInUp animate__delay-1.5s" data-mouse="small"
            onclick="openPapermask()"> 
            <!--游戏图集-->
        </div>
        <div class="gallery-top-3 animate__animated animate__fadeInUp animate__delay-2s" data-mouse="small"
            onclick="openMusicmask()"> 
            <!--游戏音乐-->
        </div>
    </div>
</div>

💠 AOS

AOS(Animate On Scroll)是一款专注于 “滚动触发动画” 的轻量级 JavaScript 库,核心功能是监测页面元素滚动至视口范围时,自动触发淡入、缩放、位移、旋转等预定义动效。既以轻量化特性(核心体积仅 15KB)避免性能损耗,又强化了内容浏览的叙事层次感。

地址:michalsnik.github.io/aos/

案例:Counter-Strike 2

6.gif

只需通过简单的 HTML 属性进行配置,即可快速实现动画。例如作为页面最顶部的核心宣传语,长时长(data-aos-duration="2500")的淡入动画(data-aos="fade-up")让文字缓慢浮现,比其他元素稍晚出现(data-aos-delay="600"),创造一种错落有致的入场节奏。

<div class="aos-init aos-animate" data-aos="fade-up" data-aos-delay="600" data-aos-duration="2500">
    “反恐精英历史上最大的技术飞跃。”
</div>
<div class="aos-init aos-animate" data-aos="fade-in" data-aos-delay="500" data-aos-duration="1000">
了解更多
</div>
<div class="aos-init aos-animate" data-aos="fade-right" data-aos-delay="100" data-aos-duration="1500">
反恐精英 预告片
</div>

1.3 动画引擎

当涉及复杂动画时,专业引擎可实现复杂的时间线动画、物理动效与骨骼动画。

💠 Lottie

Lottie.js 是 Airbnb 开源的一个轻量级动画渲染库,核心功能是将 After Effects 导出的 Lottie 格式(JSON 文件)动画在网页端直接渲染,能流畅实现骨骼动画、粒子特效、路径动画等复杂效果,让设计师的创意能还原为前端可交互的沉浸式动画。

地址:github.com/LottieFiles…

案例:逆水寒

4.gif

将设计师在 AE 中制作的图标动画还原到网页,使用 renderer: "svg" SVG 渲染可保证在任何屏幕尺寸下不失真。鼠标悬停触发动画播放,当鼠标移开时,动画立即停止并跳回第一帧,这确保了下次悬停时动画总是从开头播放。

createLottieAnim = function(e) {
  var n = window.lottie.loadAnimation((0, o.default)({
    renderer: "svg", 
    loop: true, 
    autoplay: true
  }, e, {
    path: "https://n.res.netease.com/pc/zt/20210308165742/" + e.path
  }));

  return "hover" === e.event && $(e.hoverElem || e.container).hover(
    function() { n.play() }, 
    function() { n.stop() }  
  ),
  n;
}

💠 GSAP

GSAP(GreenSock Animation Platform)是一款功能强大、性能卓越的专业动画引擎,它能够高效地创建从简单过渡到复杂序列的各类动画,支持驱动 DOM 元素、SVG、Canvas、3D 模型(如 Three.js)、骨骼动画(如 Spine)等多类型载体的动画。

地址:gsap.com/

案例:Honkai: Star Rail – May this journey lead us starward

7.gif

GSAP 在案例中被用来驱动 Three.js 3D 对象 getObjectByName("s_line") 的属性,创建出流畅和富有质感的交互效果。不仅可以实现常规的缩放 scale、旋转 rotation 动画,还可以变化着色器的 uniforms 变量,实现亮度增加的材质动画。

this.focusOver = function(e) {
    var t = e.getObjectByName("s_line");
    gsap.to(t.scale, .8, {
        x: .9 * t.userData.initScl.x,
        y: .9 * t.userData.initScl.y,
        ease: o.Back.easeInOut,
        overwrite: 1
    }),
    gsap.fromTo(t.rotation, .8, {
        z: t.userData.initRot.z
    }, {
        z: t.userData.initRot.z + 1,
        ease: o.Back.easeOut,
        overwrite: 1
    }),
    gsap.to(t.material.uniforms.brightness, .2, {
        value: .2,
        overwrite: 1
    })
}

💠 Three.js

Three.js 是基于 WebGL 的 Web 3D 渲染引擎,提供了简洁易用的 API,使开发者无需掌握深厚的图形学知识,就能在网页浏览器中高效创建和展示交互式的 3D 场景、动画和模型。内置了灯光、阴影、材质、几何体、相机控制等丰富的 3D 图形功能,并支持导入多种格式的 3D 模型。

地址:threejs.org/

案例:第五人格

10.gif

案例展示了经典和实用的 3D 角色展示方案,添加环境光 AmbientLight 提供基础亮度,平行光 DirectionalLight 模拟主光源。加载GLTF格式的3D模型,并通过 AnimationMixerclipAction 播放模型自带的动画,同时支持玩家通过拖拽来旋转模型。

// 环境光
var i = new THREE.AmbientLight(16777215, 1);
// 平行光
var n = new THREE.DirectionalLight(16777215, 1);

// 初始化GLTF模型加载器(游戏常用3D格式,支持模型+动画)
var c = new THREE.GLTFLoader; 
var h = e; // e为模型文件路径(如“survivor_doctor.glb”,角色的3D模型)

c.load(h, function(e) { 
  u = e.scene; 
  // 初始化动画混合器(控制角色动画播放)
  var o = e.animations; 
  m = new THREE.AnimationMixer(u);
  var i = m.clipAction(o[0]); 
  i.play();
  s.add(u); 
})

// 鼠标移动:计算偏移,更新模型旋转
window.addEventListener("mousemove", function(e) {
  v.ex = e.pageX; 
  var o = v.ex - v.sx; 
  u.rotation.y = v.rt + .01 * o; // 更新模型旋转角(0.01为旋转速度,避免过快)
});

💠 PixiJS

PIXI.js 是一款开源、高性能的 2D 渲染引擎,核心优势在于基于 WebGL 硬件加速的高效绘制能力,能以极低的性能损耗渲染大量 2D 元素,同时兼容 Canvas 作为降级方案。支持精灵 Sheet 优化资源加载、骨骼动画驱动角色动作、鼠标 / 触摸交互检测(如点击、拖拽、碰撞)等功能。

地址:pixijs.com/

案例:Crystal of Atlan

案例展示了基于 PixiJS 实现游戏角色的展示,利用 Assets 系统统一预加载、缓存角色资源,提升加载速度。静态角色用轻量 Sprite 节省性能,动态角色用 Spine 骨骼动画增强表现力。并通过 getLocalBounds() 获取骨骼动画的边界框,结合自定义的偏移量来精确调整位置。

11.gif

// 注册角色资源到Pixi的资源管理器
Ni(IK).call(IK, (function(t) {
  t.characterImgUrl && e.Assets.add(t.pinyin, t.characterImgUrl),
  t.spineUrl && e.Assets.add(t.pinyin, t.spineUrl)
}));

// 后台预加载所有注册的角色资源
window.PIXI.Assets.backgroundLoad(
  Vr(IK).call(IK, (function(e) { return e.pinyin })) 
);

Ni(IK).call(IK, (function(t, n) {
  e.Assets.load(t.pinyin).then((function(r) {
    ...
    // 静态角色图片:创建Pixi精灵(Sprite)
    if (t.characterImgUrl) {
      CK[n] = new e.Sprite(r); 
    } 
    // 动态骨骼动画:创建Spine动画对象
    else {
      var a = new e.spine.Spine(r.spineData); 
      a.skeleton.setToSetupPose(); 
      a.update(0); 
      ...
      // 计算骨骼动画的本地边界(用于定位调整)
      var s, u, l = a.getLocalBounds();
      
      // 调整骨骼动画位置(基于边界计算,确保角色锚点正确)
      a.position.set(
        -l.x + (t.skelOption.characteX || 0), // X轴偏移(可自定义微调)
        -l.y + (t.skelOption.characterY || 0)  // Y轴偏移
      );
      ...
    }
  }));
}));

1.4 轮播与滑动

以可交互的滑动组件高效展示预告视频、角色立绘与新闻资讯等内容。

💠 Swiper

Swiper 是一款滑动交互组件库,它提供丰富的配置项(如自动播放、自定义分页器、过渡动画时长),内置淡入淡出、滑动、 cube 3D 等过渡效果,使开发者能够轻松构建响应式的轮播图、画廊、内容滑块及选项卡切换等交互组件,同时兼容框架如 React、Vue 等。

地址:swiperjs.com/

案例:哈利波特:魔法觉醒

8.gif

案例通过 coverflow 3D 效果,结合 centeredSlides(当前卡片居中放大)和 slidesPerView(自动适配数量),打造了一个3D立体翻转的轮播图。其中 rotate 旋转角度创造出卡牌翻转的视觉效果;stretch 拉伸强度影响卡牌之间的间距和变形;depth 深度控制前后堆叠的层次感。

this.featureSwiper = new Swiper('.feature_container', {
    effect: 'coverflow',
    centeredSlides: true,
    slidesPerView: 'auto',
    coverflow: {
        rotate: 50,
        depth: 100,
        stretch: 120,
        slideShadows: false,
    },
})

💠 Flickity

Flickity 专注于创建 流畅的触摸滑动组件(如轮播图、内容滑块),主打模拟真实物理惯性的滑动体验,核心特色是支持 非固定帧自由拖拽(freeScroll 模式)—— 用户可随意滑动浏览内容,无需像传统轮播那样强制切换完整幻灯片,配合自然的惯性衰减动效,还原 “随手翻阅卡片” 的真实触感。

地址:flickity.metafizzy.co/

案例:Baldur's Gate 3

9.gif

案例使用 Flickity 创建了一个用于展示奖项荣誉的自动轮播组件,启用 draggable 意味着在移动设备上用户可以自然地进行触控滑动,而在桌面端也可能支持鼠标拖拽。结合 wrapAround 实现的无限循环,无限循环让玩家可反复浏览,也避免 “滑到尽头后无法继续” 的生硬体验。

const slider = new window.Flickity(document.getElementById("awards"),{
adaptiveHeight: false,
    cellAlign: "left",
    wrapAround: true,
    draggable: true,
    autoPlay: true
})

二、工具类集成

工具库能快速为官网添加复杂功能,降低开发成本,覆盖社媒、支付、安全等全场景需求。

2.1 社交与分享

社交平台组件方便玩家分享内容并展示社区动态,扩大传播范围。

💠 Facebook Widgets

Facebook Widgets 公共主页插件是由 Facebook 官方提供 的工具,允许将 Facebook 公共主页直接嵌入到网站上。用户无需离开当前网站即可查看主页的封面、帖子流、活动信息,并能直接进行 点赞、关注、分享 等互动操作,有效帮助提升粉丝数量和内容曝光度,加强与社交媒体的联动。

地址:developers.facebook.com/docs/plugin…

案例:浮生憶玲瓏

13.gif

案例的核心配置如下:

  • data-tabs="timeline":设置显示 “时间线”(主页动态流)
  • data-adapt-container-width:在手机等窄屏设备上自动缩小宽度(保持比例),避免插件溢出容器导致的布局错乱,确保移动端用户也能正常查看。
  • data-small-header="false":显示完整头部(含主页名称 “浮生憶玲瓏”、粉丝数)。
  • data-hide-cover="false":展示 Facebook 主页的封面图(通常是游戏宣传图、角色插画)。
  • data-show-facepile="true":显示互动粉丝的头像(如点赞、评论过的玩家)。
<div class="facebook_box">
    <div class="facebook">
        <div class="fb-page" 
        data-href="https://www.facebook.com/fsyll.tw" 
        data-tabs="timeline" 
        data-width="500" 
        data-height="270" 
        data-small-header="false" 
        data-adapt-container-width="true" 
        data-hide-cover="false" 
        data-show-facepile="true"
        >
        <blockquote cite="https://www.facebook.com/fsyll.tw" class="fb-xfbml-parse-ignore">
        <a href="https://www.facebook.com/fsyll.tw">浮生憶玲瓏</a>
       </blockquote>
    </div>
    </div>
</div>

2.2 媒体播放

提供跨浏览器的视频播放解决方案,展示游戏预告片、实机演示和直播流等。

💠 YouTube 播放器

以下是嵌入 YouTube 视频的两种主要方式:

<iframe> 嵌入

直接 <iframe> 嵌入是 最简单、最快捷 的方法,只需从 YouTube 分享界面复制现成的 <iframe> 代码并粘贴到 HTML 中即可。这种方法无需编写 JavaScript 代码,适合需要快速、简单地在网页上静态展示视频的场景,但交互控制有限,仅能满足基础播放需求。

地址:www.youtube.com

案例:ELDEN RING NIGHTREIGN

15.gif

案例中的核心参数如下:

  • autoplay=1&mute=1&loop=1&playlist=同一ID:自动播放且静音,现代浏览器默认禁止有声自动播放。loop 配合 playlist 指定同一视频 ID 确保视频持续循环。
  • rel=0:隐藏相关视频推荐,避免用户被其他视频分流。
  • hd=1:优先加载 720p 或更高清 画质,确保画面质感。
  • loading="lazy":首屏懒加载,避免因视频资源过大导致页面卡顿。
  • allow="accelerometer; autoplay; ...; picture-in-picture":声明允许的浏览器功能,支持移动端陀螺仪(增强横屏体验)、自动播放、画中画等,适配多设备交互。
<iframe id="ytplayer" frameBorder="0" allowfullscreen="" loading="lazy" 
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" 
title="ELDEN RING NIGHTREIGN" width="100%" height="100%" 
src="https://www.youtube.com/embed/UABQQ5TyGNU?autoplay=1&amp;loop=1&amp;playlist=UABQQ5TyGNU&amp;mute=1&amp;hd=1&amp;controls=0&amp;rel=0&amp;fs=0&amp;enablejsapi=1&amp;origin=https%3A%2F%2Fbandainamcoent.asia&amp;widgetid=1">
</iframe>

Player API

加载 API 脚本并通过 JavaScript 控制视频,可以实现复杂的交互,例如控制视频的播放、暂停、跳转,监听播放器的状态变化(如开始播放、暂停、缓冲等),以及动态加载播放列表或其他视频。适合需要高度自定义交互和复杂功能的场景。灵活性远高于直接 iframe 嵌入,但需一定开发成本。

地址:developers.google.com/youtube/ifr…

案例:Seven Knights Idle Adventure

16.gif

案例封装了 YouTube 视频播放器组件,onYouTubeIframeAPIReady 回调注册播放状态常量。使用计算属性来定义播放器的各项参数(尺寸、视频ID、按钮列表等),使得播放器组件可复用。并在 beforeDestroy 中调用 player.destroy() 销毁播放器实例防止内存泄漏。

window.onYouTubeIframeAPIReady = function() {
  o.YT = YT;
  var t = YT.PlayerState; 
  
  o.events[t.ENDED] = "ended",
  o.events[t.PLAYING] = "playing",
  o.events[t.PAUSED] = "paused",
  o.events[t.BUFFERING] = "buffering",
  o.events[t.CUED] = "cued";
  
  o.Vue.nextTick((function() {
    o.run()
  }));
};
...
computed: {
  youtube: function() { return this.item.args.youtube || { id: "" } }, 
  youtubeId: function() { return this.youtube.id },
  playerId: function() { return "".concat(this.item.id, "-player-").concat((new Date).getTime()) }, 
  width: function() { 
    return this.youtube.width ? "".concat(parseInt(this.youtube.width), "px") : "pc" === this.device ? "74vw" : "700px"
  },
  height: function() { 
    return this.youtube.height ? "".concat(parseInt(this.youtube.height), "px") : "pc" === this.device ? "".concat(41.625, "vw") : "393px"
  },
  vars: function() { return d({ rel: 0, wmode: "opaque" }, this.youtube.vars) } 
}
...
beforeDestroy: function() {
  "function" == typeof this.player.destroy && this.player.destroy()
}

💠 HLS.js

HLS.js 是一款开源的流媒体播放库,能让不支持 HTTP Live Streaming (HLS) 协议的现代浏览器,将视频流(如 MPEG-TS 片段)转换为浏览器可播放的格式(如 MP4),从而实现在网页中原生、流畅地播放 HLS 直播或点播视频,并支持 自适应码率 (ABR) 等关键功能以提升观看体验。

地址:github.com/video-dev/h…

案例:薩爾達傳說 王國之淚

image.png

案例自动播放、循环播放结合 hls.js 的分片加载能力,让视频在不同网络环境下都能流畅播放(如弱网时自动切换低清晰度分片)。优先使用 hls.js 播放 hls.loadSource(针对 Chrome、Firefox 等),否则降级使用 Safari 等浏览器的原生 HLS 支持,确保了几乎所有现代浏览器都能正常播放视频。

if (video.classList.contains('is_hls_ss')) {
  // 获取HLS源(.m3u8路径)
  var src = video.querySelector('source.active').dataset.src;
  // 初始化hls.js实例
  var hls = new (hls_default())();
  
  // 方案1:浏览器支持hls.js(如Chrome、Firefox)
  if (hls_default().isSupported()) {
    hls.loadSource(src); // 加载HLS源(解析.m3u8索引,获取.ts分片)
    hls.attachMedia(video); // 将HLS流关联到video元素
  } 
  // 方案2:浏览器原生支持HLS(如Safari)
  else if (video.canPlayType('application/vnd.apple.mpegurl')) {
    video.src = src; // 直接设置src为.m3u8,利用原生支持
    video.load();
  }
  
  // 监听视频加载完成事件(确保播放准备就绪)
  video.addEventListener('loadedmetadata', resolve);
  video.addEventListener('canplay', resolve);
}

💠 Video.js

Video.js 用于构建功能丰富、兼容性极强的网页视频播放器。不仅支持播放 MP4、WebM 等传统视频格式,还兼容 HLS、DASH 等现代自适应流媒体协议,确保了视频在不同浏览器和设备上的一致性与流畅体验。广泛应用于需要稳定、灵活视频播放解决方案的网站。

地址:videojs.org/

案例:Splatoon™ 3 for Nintendo Switch™

17.gif

案例实现了基于 Cloudinary 流媒体服务和 Video.js 的视频播放器。["hls/h265", "hls/h264"] 优先使用更高效的 H.265 编码,不支持则自动降级至兼容性更广的 H.264。accent: "#E60012" 将控制栏、进度条等元素的主题色设置为品牌色。volumechange 事件 + Cookie 存储监听音量变化,确保用户下次访问时保持之前的音量设置。

this.videoPlayerOptions = function() {
  ...
  return {
    ...
    source: {
      ...
      sourceTypes: e.startsWith("Legacy Videos/") ? ["hls/h264"] : ["hls/h265", "hls/h264"],
      sourceTransformation: {
        "hls/h264": [{ streaming_profile: "full_hd" }],
        "hls/h265": [{ streaming_profile: "h265_full_hd" }]
      }
    },
    ...
    colors: { accent: "#E60012", text: "#FFF" }, 
    ...
  };
};
...
this.volumeChange = function() {
  nclood.Cookie.set("nintendoVideoVolume", this.player.volume(), {
    maxAge: 365 * 24 * 60 * 60, 
    domain: n, path: "/"
  });
};

2.3 音频

控制角色语音、背景音效的播放与 3D 音效效果,增强官网的沉浸感和氛围感。

💠 Howler.js

howler.js 是一款轻量且功能强大的 JavaScript 音频库,它通过封装 Web Audio APIHTML5 Audio,为现代 Web 应用提供了简洁统一的 API。具备音频精灵、空间音效、音量控制、自动缓存、淡入淡出等高级功能,是网站处理音效与背景音乐的理想选择。

地址:github.com/goldfire/ho…

案例:Honkai: Star Rail

18.gif

案例基于 howler.js 构建了一个音频管理器,背景音乐 loop: true + preload: true,确保页面加载后无延迟循环播放。fade(0, initVolume, initFade) 播放时淡入,fade(volume, 0, 300) 暂停/停止时淡出。rate 播放速率默认1,支持变速播放,如特殊音效加速。

sounds[n] = new Howl({
  src: r, 
  volume: o, // 初始音量
  html5: l, 
  loop: f, 
  preload: d, 
  autoplay: p,
  rate: g // 播放速率(默认1,支持变速播放,如特殊音效加速)
}),
sounds[n].initVolume = o, // 存储初始音量(用于静音后恢复)
sounds[n].initFade = b, // 存储淡入淡出时长(统一音效过渡效果)
...
{
  key: "playSound",
  value: function(e) {
    var t = this, n = this.sounds[e];
    if (n) {
      var r = function() {
        !t.muted && n._initFade && n.fade(0, n._initVolume, n._initFade), // 淡入效果
        n.play()
      };
      "loaded" !== n.state() ? (n.once("load", r), n.load()) : r() // 未加载则先加载再播放
    } else console.warn("no sound: " + e)
  }
},
{
  key: "pauseSound",
  value: function(e, t) {
    var n = this.sounds[e];
    n && (t ? (n.fade(n._volume, 0, 300), n.once("fade", (function() {
      n.pause() // 淡出后暂停
    }))) : n.pause())
  }
}

💠 SoundManager

SoundManager 是一款老牌开源的音频管理库,提供了可靠且功能丰富的音频播放能力。简化了音频资源的加载、播放控制(如播放、暂停、音量调节、循环播放)和事件监听,并支持音频的淡入淡出等高级效果,极大地简化了在网页中集成音效、背景音乐等功能的开发流程。

地址:schillmania.com/projects/so…

案例:Genesis Augmented

案例构建了一套相当完善且用户体验良好的音频管理系统。使用 soundManager.createSound 创建音频对象实例。whileplaying 在播放中同步进度条,用户拖拽进度条时调用 setPosition 跳转播放位置。监听 blur/focus 事件实现页面切换时的音频淡入淡出。

this.api = soundManager.createSound({ 
  volume: 100,
  whileplaying: function() { this.step() }, // 播放中更新进度
  onplay: function() { t.addClass("nk-audio-plain-playing") }, 
  onpause: function() { t.removeClass("nk-audio-plain-playing") }, 
  onfinish: function() { this.seek(0); this.step(); ... } 
});
e.prototype = {
  // 进度更新:同步进度条与时间显示
  step: function() {
    var t = this.api.position || 0;
    this.progress = t / this.api.duration;
    this.$timer.html(this.formatTime(Math.round(t))); 
    this.$progress.css("width", "".concat(100 * this.progress || 0, "%"));
  },
  // 拖拽进度条跳转播放位置
  seek: function(t) {
    this.api.setPosition(this.api.duration * t); // t为0-1的比例值
  }
};
// 页面离开(blur)时淡出暂停,返回(focus)时淡入恢复
k.$wnd.on("blur focus", function(n) {
  setTimeout(function() {
    if ("blur" === n.type) {
      !a.paused && a.playState && (i = !0,
      t = a.volume,
      e = 1e3 / Math.abs(+t),
      clearInterval(l),
      l = setInterval(function() { // 淡出到0音量后暂停
        t = 0 < t ? t - 1 : t + 1,
        a.setVolume(t),
        0 === t && (clearInterval(l), a.pause())
      }, e))
    } else i && (i = !1, v()); // 恢复时淡入
  }, 0)
}));

2.4 评论系统

集成第三方服务,快速为官网添加用户评论、互动功能,构建社区氛围。

💠 Disqus

Disqus 是一款广泛应用的第三方嵌入式评论系统,为网站提供便捷的用户互动解决方案。它支持用户通过社交媒体账号(如 Google、Facebook)或 Disqus 账号登录,实现评论、回复、点赞、分享等功能,同时提供跨平台评论同步,提升用户体验的连贯性。

地址:disqus.com/

案例:Stardew Valley

image 1.png

案例展示了如何将 Disqus 评论系统集成到网站中,把 ID、URL、标题等注入到 JavaScript 变量中,Disqus 脚本获取并显示该页面对应的评论。language 配置确保不同地区玩家看到对应语言的评论区界面;sso 单点登录减少玩家评论门槛(无需单独注册 Disqus 账号),提升参与度。

<script type='text/javascript'>
/* <![CDATA[ */
var embedVars = {"disqusConfig":{"integration":"wordpress 3.0.17"},"disqusIdentifier":"1926 https:\/\/www.stardewvalley.net\/?p=1926","disqusShortname":"stardewvalley","disqusTitle":"Stardew Valley 1.5.5 Released on PC","disqusUrl":"https:\/\/www.stardewvalley.net\/stardew-valley-1-5-5-released-on-pc\/","postId":"1926"};
/* ]]> */
</script>
var disqus_config = function () {
  var dsqConfig = embedVars.disqusConfig;
  this.page.integration = dsqConfig.integration; // 声明集成环境为WordPress(Disqus适配其数据交互)
  this.page.remote_auth_s3 = dsqConfig.remote_auth_s3; // 远程认证参数(支持官网用户体系与Disqus联动,如自动登录)
  this.page.api_key = dsqConfig.api_key; // API密钥(用于Disqus高级功能,如数据统计)
  this.sso = dsqConfig.sso; // 单点登录配置(玩家可用官网账号直接登录评论区,无需重复注册)
  this.language = dsqConfig.language; // 评论区语言(默认跟随官网,适配全球玩家)

  if (disqus_config_custom) disqus_config_custom.call(this); // 允许自定义扩展配置(如添加评论过滤规则)
};

(function() {
  var dsq = document.createElement('script');
  dsq.type = 'text/javascript';
  dsq.async = true; // 异步加载(不阻塞官网页面渲染,保证玩家浏览更新内容时不卡顿)
  dsq.src = 'https://' + disqus_shortname + '.disqus.com/embed.js'; // 加载Disqus核心脚本(对应星露谷的专属评论脚本)
  // 将脚本插入页面(head或body,确保能正常执行)
  (document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]).appendChild(dsq);
})();

2.5 客服与反馈

💠 Zendesk

Zendesk 核心产品是一个 全渠道的智能客户服务与互动平台。通过整合网页、邮件、社交媒体、即时聊天、电话等多种沟通渠道,并利用自动化工单系统AI功能(如Answer Bot智能客服和数据分析)以及知识库工具,帮助高效地管理客户查询、优化支持流程并提升客户满意度。

地址:developer.zendesk.com/api-referen…

案例:Splinterlands

24.gif

案例实现了 Zendesk客服组件加载方案。小屏设备不加载插件,大屏动态加载脚本,script.id 确保能正确识别并初始化。通过专属 key 关联游戏定制化客服规则,确保玩家咨询(如链游资产异常、战斗 BUG)能精准分流到对应客服团队,提升问题解决效率。

if (!(window.innerWidth <= 800) && !(window.innerHeight <= 600)) {
var script = document.createElement('script');
script.setAttribute('id', 'ze-snippet');
script.setAttribute('src', 'https://static.zdassets.com/ekr/snippet.js?key=...');
document.head.appendChild(script);
}

三、性能优化与兼容性

确保官网在各种设备与网络环境下都能快速、稳定运行。

3.1 懒加载

延迟加载非关键资源(如图片、视频),提升首屏加载速度。

💠 LazySizes

LazySizes 是一款 图片延迟加载库,它通过智能检测元素是否进入浏览器视口来动态加载图片和 iframe,能显著提升页面加载速度、节省带宽,并因其不会向搜索引擎隐藏内容而保持 SEO 友好性;该库原生支持响应式图像,可自动计算适配屏幕尺寸的图片。

地址:github.com/aFarkas/laz…

案例:Wizardry Variants Daphne

12.gif

案例的只加载 1x1 像素的占位符 data:image/gif;base64,R0l...AA7,使首屏内容可以极速呈现。当滚动即将看到图片时才加载资源,并预设宽高比 data-aspectratio 避免布局抖动。picture 标签优先加载 data-srcset 中的 WebP 图片,否则降级加载 img 标签的 PNG 图片。

<picture>
    <!-- WebP格式图片(优先加载,体积更小) -->
    <source 
        srcset="data:image/gif;base64,R0lGODlhAQABAGAAACH5BAEKAP8ALAAAAAABAAEAAAgEAP8FBAA7" 
        data-srcset="/path/to/button.webp" 
        type="image/webp">
    
    <!-- PNG格式图片(降级方案,兼容不支持WebP的浏览器) -->
    <img 
    class="lazyload" 
    src="data:image/gif;base64,R0lGODlhAQABAGAAACH5BAEKAP8ALAAAAAABAAEAAAgEAP8FBAA7" 
    data-src="/path/to/button.png" alt="...">
</picture>

3.2 兼容性适配

解决旧浏览器对 HTML5/CSS3 的支持问题,适配低配置设备。

💠 Modernizr

Modernizr 的核心功能是检测用户浏览器对 HTML5 和 CSS3 各项特性的支持情况。它通过运行一系列快速的测试来判断浏览器是否支持各项特性,使开发者能够基于浏览器实际能力而非浏览器品牌和版本来编写 CSS 规则和 JavaScript 逻辑,从而更优雅地实现渐进增强优雅降级

地址:modernizr.com/

案例:Wizardry Variants Daphne

image 2.png

案例中 Modernizr 检测浏览器是否支持 WebP 格式,在 <html> 标签上添加 .webp 类(支持)或 .no-webp 类(不支持)。混合宏利用这两个类名,为不同浏览器提供对应的图片格式 —— 支持 WebP 的用体积更小的 WebP,不支持的用兼容性更好的 PNG。

@function replace($url) {
  $substr: '.png';
  $newsubstr: '.webp';
  $pos : str-index($url, $substr);
  $strlen : str-length($substr);
  $start : str-slice($url, 0, $pos - 1);
  $end : str-slice($url, $pos + $strlen);
  $url : $start + $newsubstr + $end;
  @return $url;
}
@mixin webp($url) {
  .no-webp & {
    background-image: url($url);
  }
  .webp & {
    background-image: url(replace($url));
  }
}

还有 更多特性检测,例如 Battery APICSS position: stickydetails Element 等等。

image 3.png

💠 HTML5 Shiv

💡 可忽略,是前端老兵的兼容代表方案。随着现代浏览器普及,项目已基本不需要。

HTML5 Shiv 是早期实现跨浏览器兼容性、推动开发者无顾虑采用 HTML5 新标准的重要工具之一。核心作用是让旧版 Internet Explorer 浏览器(特指 IE6-IE8) 能够识别并正确渲染 HTML5 新增的语义化标签(如 <article><section><nav><header> 和 <footer> 等)。

地址:github.com/aFarkas/htm…

案例:Wizardry Variants Daphne

<!--[if lt IE 9]>
    <script src="https://nie.res.netease.com/comm/html5/html5shiv.js "></script>
<![endif]-->

案例通过 HTML5 Shiv 为旧版浏览器提供一个 Polyfill,确保基础的内容和功能仍然可用,实现了优雅降级。同时,只有IE6、IE7、IE8会加载并执行这个脚本,而现代浏览器则会完全忽略这段代码,避免了不必要的资源消耗。

3.3 隐私合规

管理 Cookie 偏好设置、隐私政策生成,满足 GDPR 等全球合规要求。

💠 Cookiebot

Cookiebot 是一款即插即用式 Cookie 同意管理平台(CMP),其主要功能是通过在网站上嵌入可定制的支持 46 种语言的同意横幅、自动扫描并分类Cookie及追踪技术,并在获得用户明确同意前阻止这些技术的执行,来帮助网站所有者遵守 GDPRCCPA 等全球数据隐私法规。

地址:www.cookiebot.com/

案例:Home of the Cyberpunk 2077 universe

19.gif

案例实现了一个定制化的 Cookie同意管理解决方案,不仅完成了基本的合规要求,还实现了模态框集成和品牌化定制。根据 lang 设置 data-culture 属性智能识别用户语言,监听 CookiebotOnDialogDisplay 事件替换图片为自定义品牌图标。

var s = new n.modal({
  cssClass: ["cookie-declaration-modal"], // 自定义样式类(赛博朋克风格适配)
  onOpen: function() {
    ...
    s.close(), // 临时关闭,等待Cookiebot内容加载
    // Cookiebot声明加载完成后,检查内容是否溢出(确保显示完整)
    window.CookiebotCallback_OnDialogLoad = function() { s.checkOverflow() }
  },
  onClose: function() {
    delete window.CookiebotCallback_OnDialogLoad // 清除回调,避免内存泄漏
  }
});

// 点击“Cookie声明”链接时触发
i.addEventListener("click", (function(t) {
  t.preventDefault(); 
  var e = document.documentElement.lang; // 获取页面当前语言(如pt-br、zh-cn)
  var o = document.querySelector(".cookie-declaration-modal .tingle-modal-box__content");
  // 动态创建Cookiebot声明脚本
  var n = document.createElement("script");
  n.id = "CookieDeclaration",
  n.async = !0,
  // 根据页面语言设置Cookie声明的显示语言(多语言适配)
  n.setAttribute("data-culture", 
    "pt-br" === e || "pt-BR" === e ? "pt" : 
    "zh-cn" === e ? "zh" :
    "zh-tw" === e ? "zu" : 
    e 
  ),
  // 加载Cookiebot的Cookie声明脚本(关联官网的Cookiebot账户ID:acc3ad63-...)
  n.src = "https://consent.cookiebot.com/acc3ad63-2aea-464b-beeb-bd0b8a85bc05/cd.js",
  o.appendChild(n), 
  s.open() 
}));

// 监听Cookiebot同意弹窗显示事件
window.addEventListener('CookiebotOnDialogDisplay', function (e) {
  // 替换Cookiebot默认的“Powered by”图标为游戏自定义图标
  var el = document.getElementById('CybotCookiebotDialogPoweredbyImage');
  if (el) el.src = 'https://cyberpunk-static.qtlglb.com/build/images/cookies-icon-03723b68.png';
}, false);

💠 OneTrust

OneTrust 是一个企业级的隐私合规与数据治理平台,通过 Cookie 自动扫描、动态国家/州特定同意横幅、偏好中心及基于 Cookiepedia 数据库的预分类技术,帮助组织遵守 GDPR、CCPA 等全球隐私法规。可深度定制同意弹窗样式与交互流程,通过自动化工具简化合规流程、降低运营成本。

地址:www.onetrust.com/

案例:ELDEN RING NIGHTREIGN

20.gif

案例采用 React SSR + CSP 安全策略 + 动态脚本注入的方式,cdn-apac 亚太区CDN,加速亚洲用户访问。data-document-language 自动读取 HTML lang 属性切换语言。OptanonWrapper OneTrust 要求的全局函数钩子,用于在同意状态变更时执行自定义逻辑。

<meta http-equiv="Content-Security-Policy" content="script-src * data: https://cdn-apac.onetrust.com/scripttemplates/otSDKStub.js 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com 'unsafe-inline' 'unsafe-eval';"/>

// 加载OneTrust核心SDK脚本
h.jsx)(F.default, {
  src: "https://cdn-apac.onetrust.com/scripttemplates/otSDKStub.js", // 亚太区CDN,加速亚洲用户访问
  "data-document-language": "true", // 自动检测页面语言,适配多语言同意弹窗
  type: "text/javascript",
  charSet: "UTF-8",
  "data-domain-script": "e44e50cd-1032-4426-a8a0-0304ff8a035b-test" // 关联官网的OneTrust配置ID(测试环境)
}),

// 定义OneTrust回调函数(用户同意操作后触发)
h.jsx)(F.default, {
  id: "optanon-wrapper",
  dangerouslySetInnerHTML: {
    __html: "function OptanonWrapper() { }" // 默认空实现,可扩展为同意后启用分析/广告脚本
  }
})

💠 Osano

Osano 通过SaaS模式为企业提供一套完整的隐私合规解决方案,帮助企业自动化遵守全球隐私法规(如GDPR、CCR)。其核心平台集成了同意管理(Cookie横幅)、数据主体权利请求(DSAR)处理、供应商风险监控、数据映射和隐私评估等功能,特色是能够通过单行代码快速部署。

地址:www.osano.com/

案例:VALORANT

21.gif

案例基于 Osano 结合 Google Consent Mode,gtag('consent','default',{ 'ad_storage':'denied', ... }) 获取用户同意前默认拒绝所有广告、分析类的数据存储。点击自定义按钮时,通过window.Osano.cm.showDrawer()打开 Osano 的偏好设置面板,让用户可随时修改同意选项。Cookie 中携带 geo=SGlang=zh-CN,Osano 自动匹配当地法律要求(如 PDPA)。

{/* 加载Osano核心脚本,绑定官网专属合规政策 */}
<script
  id="osano-script"
  src={`https://cmp.osano.com/16BZ95S4qp9Kl2gUA/${page.osanoPolicyId}/osano.js`}
/>

{/* Google Consent脚本:默认禁用所有数据存储,等待用户同意 */}
gtag('consent','default',{
  'ad_storage':'denied',
  'analytics_storage':'denied',
  'ad_user_data':'denied',
  'ad_personalization':'denied',
  'wait_for_update': 500
});
gtag("set", "ads_data_redaction", true);

document.cookie = "osano_consentmanager_uuid=...; geo=SG; lang=zh-CN";

3.4 验证码

通过人机验证等手段,保护官网表单和业务接口免受机器人和恶意程序的攻击。

💠 GeeTest

GeeTest是一家来自中国的 交互安全服务提供商,其核心产品是 基于人工智能与行为式验证技术的智能验证码系统。能有效防御垃圾注册、撞库登录、恶意刷票等自动化攻击。与传统依赖文字扭曲识别的验证码不同,GeeTest 提供了如滑动拼图、图标点选等多种更具用户体验的验证形式。

地址:www.geetest.com/

案例:鸣潮

22.gif

案例集成 GeeTest 4.x 版本人机验证,language 多语言适配验证界面,riskType: "slide" 指定验证类型为 滑动验证,过 onReady/onSuccess/onError 等事件,同步控制加载弹窗的显示 / 隐藏,让用户清晰感知验证流程状态(如 “加载中→验证界面→验证完成”)。

function geetest(D, S) {
  return Ne(this, null, function*() {
    return yield new Promise( (E, x) => {
      // 语言映射:将前端语言标识转为GeeTest支持的格式
      const U = { "zh-Hans": "zho", "zh-Hant": "zho-tw", ja: "jpn", en: "eng" };
      const Y = { lot_number: "", captcha_output: "", pass_token: "", gen_time: "" };

      if (typeof initGeetest4 != "function") { S(Y); E(Y); return }

      Modal.showLoading(), // 显示加载弹窗,提升用户感知
      initGeetest4({
        captchaId: commonIds.captchaId, 
        language: (V = U[D]) != null ? V : "zho", // 适配验证界面语言
        riskType: "slide", // 指定验证类型为“滑动验证”
        product: "bind" 
      }, function(R) {
        // 验证组件加载完成:隐藏加载弹窗,显示验证界面
        R.onReady(function() { Modal.hideLoading(); R.showCaptcha() });
        // 验证成功:获取验证参数并返回(供登录接口使用)
        R.onSuccess(function() { S(R.getValidate()); E(R.getValidate()) });
        // 验证错误/失败/关闭:清理状态,记录日志
        R.onError(function(Z) { Modal.hideLoading(); x(Z); log("onError", Z) });
        R.onFail(function(Z) { Modal.hideLoading(); log("onFail", Z) });
        R.onClose(function() { Modal.hideLoading() });
      })
    })
  })
}

💠 reCAPTCHA

reCAPTCHA 是谷歌提供的验证码服务,旨在通过各种验证方式(如图像识别、滑块验证、点击验证等)区分人类用户和自动化机器人,广泛应用于网站登录、注册、评论等场景,以防止恶意攻击和滥用。同时支持多种语言和自定义样式,帮助网站开发者轻松集成并保护网站安全。

地址:developers.google.com/recaptcha

案例:Cyberpunk: Edgerunners

23.gif

案例用于保护订阅的表单,防止机器人自动提交。data-size="invisible" 使用隐形验证 —— 后台静默分析用户行为,仅当判定为高风险时才弹出验证,极大减少对用户的干扰。data-callback 指定验证成功后的回调函数,grecaptcha.execute() 执行验证。

<!-- 启用“隐形验证”模式 -->
<div id='recaptcha' class="g-recaptcha" 
     data-sitekey="6Lfta6oUAAAAAE5W9wJ12TZ9WBz7gAEANTt3UmoN"  
     data-callback="submitNewsletterForm" 
     data-size="invisible">  
</div>
n.on("submit", (function(t) {
  t.preventDefault(), // 拦截表单默认提交
  !c.prop("disabled") && (s ? grecaptcha.execute() : f(new FormData(n[0])))
}))

window.submitNewsletterForm = function(t) {
  f(new FormData(n[0])) // 验证成功后,提交表单
}

四、UI 组件与样式

构建用户界面的视觉语言、基础构件和设计规范,塑造游戏官网品牌视觉风格。

4.1 UI 框架/库

提供一套预置的样式类和组件,助力开发者快速构建符合游戏风格的界面。

💠 Tailwind CSS

Tailwind CSS 是一款以实用优先(Utility-First) 为核心的开源 CSS 框架,它提供海量原子化 CSS 工具类(如mt-4flexbg-blue-500),开发者可直接在 HTML 标签中组合这些工具类快速构建自定义界面,无需编写大量冗余的自定义 CSS。支持高度可定制的主题系统(自定义颜色、字体、间距),能 显著提升开发效率 并 确保设计一致性

地址:tailwindcss.com/

案例:FINAL FANTASY XVI | SQUARE ENIX

25.gif

案例使用 Tailwind CSS 构建的响应式导航栏组件。全程使用 px-4, mx-5, mb-[1px] 等原子类,消除 magic number。利用 lg/md/2xl 断点,兼顾 PC、平板、手机玩家的导航体验。data-[open=true]:-rotate-180 通过 数据属性状态类,实现下拉菜单展开时箭头旋转 180° 的交互。

<nav class="sticky top-[var(--header-bar-pos)] z-50 flex h-20 w-full items-center justify-center bg-gradient-to-b from-black/80 to-transparent font-bold text-white">
<div class="flex w-full max-w-screen-2xl items-center justify-between px-4 2xl:px-0">
<ul class="mx-5 hidden flex-1 flex-row lg:flex">
<li class="mx-5 cursor-pointer"></li>
...
<li class="mx-5 cursor-pointer">
<div class="relative">
<button type="button" data-open="false" class="flex items-center gap-2 hover:text-hampton data-[open=true]:text-hampton">DLC
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg" data-open="false" class="mb-[2px] duration-200 ease-in-out data-[open=true]:-rotate-180">
<path d="M4 6H11L7.5 10.5L4 6Z" fill="currentColor"></path>
</svg>
</button>
<ul data-open="false" class="absolute left-0 mt-1 w-full md:w-auto data-[open=false]:md:hidden">
<li class="mb-[1px] w-full whitespace-nowrap bg-black/60 p-2 text-base leading-none"><a class="hover:text-hampton" href="https://uk.finalfantasyxvi.com/dlc">Expansion Pass</a></li>
<li class="mb-[1px] w-full whitespace-nowrap bg-black/60 p-2 text-base leading-none"><a class="hover:text-hampton" href="https://uk.finalfantasyxvi.com/dlc/echoes-of-the-fallen">DLC: Echoes of the Fallen</a></li>
<li class="mb-[1px] w-full whitespace-nowrap bg-black/60 p-2 text-base leading-none"><a class="hover:text-hampton" href="https://uk.finalfantasyxvi.com/dlc/the-rising-tide">DLC: The Rising Tide</a></li>
</ul>
</div>
</li>
</ul>
<button type="button" class="block lg:hidden">Menu</button>
</div>
</nav>

💠 Bootstrap

Bootstrap 是由 Twitter 公司设计师开发的一款开源前端框架。它提供了一套丰富的预定义样式、组件(如导航栏、按钮、表单)和 JavaScript 插件,并以其灵活的栅格系统为核心,能够自动适配不同尺寸的屏幕。由于其简洁易用、文档完善且具有高度可定制性,被广泛应用于各类网站开发中。

地址:getbootstrap.com/

案例:Genesis Augmented | Official Website

29.gif

案例基于 Bootstrap 实现 响应式核心布局 开发。container 固定宽度容器,自动居中适配不同屏幕,row no-gutters 行容器,移除列之间的默认间距,让分栏更紧凑,是 Bootstrap 栅格的标准组合。col-md-5 是 Bootstrap 断点类(md 对应 768px),平板 / 桌面 两端分栏;手机 两列自动垂直堆叠,避免窄屏挤兑。

<div class="container">
        <div class="row no-gutters" style="justify-content: space-between; text-align: center;">
            <div class="col-md-5 image-margin-top">
                <img id="promo-title" draggable="false" ondragstart="return false;" oncontextmenu="return false;" loading="lazy" src="../crypto/assets/logo_light_sm.webp">
                <h3>Talk to <span class="avatar-select-name">Emma</span></h3>
                <p>- Ask me anything -</p>
                <div id="hero-loverboy" class="loverboy">
                    <textarea class="loverboy-output" readonly="" placeholder="Use the input box below to ask questions."></textarea>
                    <input id="loverboy-input" type="text" placeholder="Who is XMEG">
                </div>
                <a id="loverboy-transmit"><button class="nk-btn nk-btn-blue nk-btn-lg">Send Message</button></a>
            </div>
            <div id="avatar-profile-container" class="col-md-5 image-margin-top">
                <!-- ipad + desktop style = object-fit: contain; border-radius: 0; max-height: 600px; -->
                <img draggable="false" ondragstart="return false;" oncontextmenu="return false;" loading="lazy" id="avatar-profile-img" alt="Futurstic space elf girl from the Genesis Augmented Reality Trading Card Game" src="../img/emma.webp">
            </div>
        </div>
    </div>

💠 Radix UI

Radix UI是一款面向 React/Vue 生态的 无样式、无障碍优先的开源UI组件库,核心提供对话框、下拉菜单、滑块等基础交互组件,严格遵循 WAI-ARIA 规范,内置键盘导航、焦点管理与屏幕阅读器适配等无障碍能力。是平衡无障碍合规、交互稳定性与视觉个性化的现代前端解决方案。

地址:www.radix-ui.com/

案例:Grand Theft Auto VI - Rockstar Games

28.gif

案例使用了 Radix UI 的 Dialog 组件来构建多个 可访问、交互稳健的弹窗系统Dialog.Content 会自动通过 aria-labelledbyaria-describedby 属性与 Dialog.TitleDialog.Description 关联,这对于屏幕阅读器用户理解弹窗内容至关重要。支持 TAB 切换图片预览,Enter 打开弹窗 和 ESC 键关闭弹窗,这是用户预期的标准行为。

image 4.png

4.2 字体 & 图标

选用符合游戏风格的字体和图标库,是定义产品调性和确保界面清晰易用的基础。

💠 Google Fonts API

Google Fonts API 是一项免费的 Web 字体服务。该 API 支持指定多种字体、样式、粗细,并提供 font-display 控制字体加载行为、subset 参数下载特定语言子集、text 参数实现按需加载字体子集等优化功能,同时兼容国际字符和 UTF-8 编码,适用于各类网站和应用的字体需求。

地址:developers.google.cn/fonts/docs/…

案例:Seven Knights Idle Adventure - Netmarble

26.gif

案例 多语言动态字体加载 实现,采用 Nuxt.js SSR 架构,结合 Google Fonts API,为不同语言环境提供最优字体方案。Nuxt.js head() + 条件式字体加载,display=auto Google Fonts 自动选择最佳 font-display 策略。日语版本额外加载 RocknRoll One 字体,用于游戏标题、活动文案等视觉重点区域,通过个性化字体强化游戏的日系风格。

r = {
  en: "Palanquin+Dark:wght@700",    // 英文:粗体标题字体
  ja: "Noto+Sans+JP:wght@300;500",  // 日语:常规/中等字重,适配日文排版
  sc: "Noto+Sans+SC:wght@400;700;900", // 简体中文:常规/粗体/黑体,覆盖正文+标题
  tc: "Noto+Sans+TC:wght@400;700;900", // 繁体中文:适配繁体字形
  th: "Prompt:wght@400;700"         // 泰语:适配泰文字形
};

head() {
  return {
    link: [
      // 1. 基础 Noto Sans(全局)
      // 2. 条件:非 KO/SC/TC/JA/TH 时加载英文补充字体
      // 3. 条件:SC/TC/JA/TH 时加载对应语言字体
      // 4. 条件:KO/EN/SC/TC/JA 时加载 Netmarble 品牌字体
      // 5. 条件:JA 时额外加载 RocknRoll One(标题用)
      // 6. SEO: canonical + hreflang 多语言标签
      {
          hid: "google-webfont",
          rel: "stylesheet",
          href: "https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=auto"
      }, ["ko", "sc", "tc", "ja", "th"].includes(this.getLang) ? {} : {
          hid: "google-webfont-lang",
          rel: "stylesheet",
          href: "https://fonts.googleapis.com/css2?family=".concat(r.en, "&display=auto")
      }, ["sc", "tc", "ja", "th"].includes(this.getLang) ? {
          hid: "google-webfont-lang",
          rel: "stylesheet",
          href: "https://fonts.googleapis.com/css2?family=".concat(r[this.getLang], "&display=auto")
      } : {}, ["ko", "en", "sc", "tc", "ja"].includes(this.getLang) ? {
          hid: "google-webfont-lang",
          rel: "stylesheet",
          href: "https://sgimage.netmarble.com/font/v2/font.css"
      } : {}, ["ja"].includes(this.getLang) ? {
          hid: "google-webfont-lang",
          rel: "stylesheet",
          href: "https://fonts.googleapis.com/css2?family=RocknRoll+One&display=auto"
      } : {}, {
          hid: "canonical",
          rel: "canonical",
          href: "".concat(this.getDomain).concat(this.$route.fullPath)
      }, {
          hid: "alternate-x",
          rel: "alternate",
          href: "".concat(this.getDomain).concat(e),
          hreflang: "x-default"
      }
    ]
  }
}

💠 Adobe Fonts(Typekit)

Adobe Fonts(前身为 Typekit)是 Adobe Creative Cloud 旗下的专业字体服务,提供超过 20,000 种高质量字体,支持通过简单的 CSS 集成或桌面同步,在网页设计和创意项目中合法、无缝地使用字体,所有字体均已预授权并自动处理 Web 字体托管、优化和跨浏览器兼容性问题。

地址:helpx.adobe.com/cn/fonts/us…

案例:Roberts Space Industries

image 5.png

案例使用了 Adobe Fonts 服务来加载 Univia Pro 字体家族,这是一个完整的字体包,包含多个字重(覆盖100/400/500/600/700)和样式变体(normal/italic)。使用 @import 动态加载字体,每个变体都提供 WOFF2、WOFF 和 OpenType 格式。

@import url("https://p.typekit.net/p.css?s=1&k=dhw3beb&ht=tk&f=28764.28765.28767.28771.28772.28774.28775.28778.28779&a=86281447&app=typekit&e=css"); 
@font-face {
    font-family: "univia-pro";
    src: url("https://use.typekit.net/af/fbc5c1/00000000000000003b9add6d/27/l?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=i6&v=3") format("woff2"),url("https://use.typekit.net/af/fbc5c1/00000000000000003b9add6d/27/d?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=i6&v=3") format("woff"),url("https://use.typekit.net/af/fbc5c1/00000000000000003b9add6d/27/a?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=i6&v=3") format("opentype");
    font-display: auto;
    font-style: italic;
    font-weight: 600;
    font-stretch: normal;
}
/* 后续多个@font-face声明:覆盖100/400/500/600/700字重 + normal/italic样式 */

💠 Twitter Emoji(Twemoji)

Twitter Emoji(Twemoji)是 Twitter 开源的一套 Emoji 图标库,它通过将 Unicode 标准中的 Emoji 字符转换为统一风格的图片(如 SVG 或 PNG),解决了不同操作系统和设备上 Emoji 显示效果不一致的问题,确保在所有平台上呈现 完全相同的视觉效果

地址:github.com/twitter/twe…

案例:Stardew Valley

image 6.png

案例

💠 Font Awesome

Font Awesome 是一款开源、可免费商用 的图标字体库和 CSS 框架,提供数千个可缩放的矢量图标,通过简单的 CSS 类名即可快速集成到网站中,支持多种风格和格式,并可通过 kits 和 API 实现按需加载、自定义图标集和动态图标,是前端开发中提升 UI 效率和一致性的标准解决方案。

地址:developers.google.cn/fonts/docs/…

案例:Hogwarts Legacy - Principal

27.gif

案例集成了 Font Awesome 图标库,实现了 社交媒体导航栏。使用 Vue 专用组件 <font-awesome-icon>,通过 fab 前缀指定品牌图标库,确保图标渲染高效精准。fixed-width 属性强制所有图标保持相同宽度,保证导航栏视觉对齐和美观度。

<ul>
<li>
<a class="nav-link dc"
           href="https://discord.gg/HogwartsLegacy"
           title="Discord"
           target="_blank"
           rel="noopener"
           aria-label="Visit Discord.com"
           data-toggle="tooltip" data-placement="bottom">
            <font-awesome-icon :icon="['fab', 'discord']" fixed-width></font-awesome-icon>
</a>
</li>
...
</ul>

总结

没有“万能”的工具,只有“最适合”的方案。技术选型,是在 视觉表现用户体验开发效率性能指标 之间的平衡。选择社区活跃、文档完备的开源工具或成熟的商业服务,能为项目的长期维护和团队协作降低大量成本。希望本篇文章能为游戏官网开发者提供灵感和参考。

VersionCheck.js - 让前端版本更新变得简单优雅

2026年3月2日 17:12

VersionCheck.js - 让前端版本更新变得简单优雅

在现代Web应用开发中,如何优雅地处理前端版本更新一直是一个重要但容易被忽视的问题。今天我要向大家推荐一款极简但功能强大的前端版本检测工具 —— VersionCheck.js

🎯 解决什么问题?

相信很多开发者都遇到过这样的困扰:

  • 用户访问网站时加载的是旧版本缓存
  • 新功能上线后用户看不到更新内容
  • 手动刷新页面影响用户体验
  • 缺乏有效的版本检测机制

VersionCheck.js 正是为解决这些问题而生!

✨ 核心特性一览

🔄 智能双模式检测

  • ETag模式(默认):通过HTTP响应头自动检测
  • 版本文件模式:通过JSON文件版本字段精确控制

⚡ 自动化轮询

  • 默认每10分钟自动检测一次
  • 页面隐藏时自动暂停,节省资源
  • 支持自定义检测频率

🎨 灵活的交互方式

  • 内置原生confirm弹窗
  • 支持自定义提示文案
  • 可配置更新回调函数

🛡️ 健壮的容错机制

  • localStorage自动降级到内存存储
  • 完善的错误处理和日志记录
  • 多环境兼容(UMD模块规范)

📊 为什么选择VersionCheck.js?

特性 VersionCheck.js 其他方案
配置复杂度 极简配置 需要复杂配置
检测准确性 双模式保障 单一模式
用户体验 无感知检测 影响用户体验
兼容性 多环境支持 环境限制多
维护成本 零依赖 需要持续维护

🌟 项目亮点

  • 零学习成本:API设计简洁直观
  • 高性能:智能暂停机制节省资源
  • 高可靠性:完善的错误处理机制
  • 高扩展性:丰富的配置选项
  • 开源免费:MIT许可证,可商用

📦 安装方式

1. 通过 <script> 标签引入(浏览器环境)

<!-- 生产环境 -->
<script src="dist/index.js"></script>

<!-- 或使用 CDN -->
<script src="https://cdn.jsdelivr.net/npm/version-check-js@latest/dist/index.js"></script>

2. 通过 NPM 安装(Node.js 环境)

# 安装最新版本
npm install version-check-js

# 或使用 yarn
yarn add version-check-js

然后在代码中导入:

// CommonJS 方式
const VersionCheck = require('version-check-js');

// ES6 模块方式
import VersionCheck from 'version-check-js';

3. 通过 unpkg CDN 引入

<script src="https://unpkg.com/version-check-js@latest/dist/index.js"></script>

🚀 快速开始

基础用法

// 实例化 VersionCheck
const versionCheck = new VersionCheck({
  url: '/version.json', // 指定版本文件路径(或默认使用 '/' 进入 ETag 模式)
  interval: 60 * 1000, // 设置检测间隔为 1 分钟
  message: '发现新版本,是否立即刷新?', // 自定义提示文案
});

// 启动自动检测
versionCheck.start();

// 停止自动检测
// versionCheck.stop();

// 销毁实例
// versionCheck.destroy();

// 手动触发一次检测
versionCheck.check().then(hasUpdate => {
  console.log('是否有更新:', hasUpdate);
});

⚙️ 配置项详解

参数名 类型 默认值 描述
url string '/' 检测地址:
- 默认 '/':启用 ETag 模式
- 文件路径(如 /version.json):启用版本文件模式
interval number 10 * 60 * 1000(10 分钟) 轮询检测间隔时间(毫秒),建议不小于 30 秒
message string '检测到新版本,是否立即刷新?' 更新提示文案,仅在未设置 onUpdate 时生效
onUpdate Function null 自定义更新回调函数(优先级高于默认 confirm 弹窗)
onError Function (err) => console.error('版本检测失败:', err) 错误回调函数,接收错误对象作为参数
onLog Function null 操作日志回调函数,用于记录正常操作信息
storage Object null 自定义存储配置(需提供 getsetremove 方法),默认使用 localStorage

配置项最佳实践

const versionCheck = new VersionCheck({
  // 基础配置
  url: '/api/version', // 推荐使用具体的 API 接口
  interval: 5 * 60 * 1000, // 5分钟检测一次(生产环境推荐)

  // 用户体验优化
  message: '发现新版本可用,是否立即更新?',

  // 自定义回调
  onUpdate: () => {
    // 自定义更新逻辑
    console.log('执行更新...');
    // 可以在这里添加动画、提示等
    window.location.reload();
  },

  // 错误处理
  onError: error => {
    // 生产环境可以发送错误日志到监控系统
    console.error('版本检测异常:', error);
  },

  // 操作日志
  onLog: message => {
    // 记录操作日志,便于调试
    console.log('VersionCheck:', message);
  },
});

🔧 完整 API 文档

实例方法

start()

启动自动轮询检测。

versionCheck.start(); // 返回 undefined
stop([isInternal])

停止自动轮询检测。

versionCheck.stop(); // 外部调用,会触发 onLog
versionCheck.stop(true); // 内部调用,不会触发 onLog
check()

手动触发一次检测,返回 Promise。

try {
  const hasUpdate = await versionCheck.check();
  if (hasUpdate) {
    console.log('检测到新版本!');
  }
} catch (error) {
  console.error('检测失败:', error);
}
reload()

强制刷新页面,自动处理 URL 参数去重。

versionCheck.reload(); // 会添加时间戳参数避免缓存
destroy()

销毁实例,清理所有资源(定时器、事件监听器、存储引用等)。

versionCheck.destroy(); // 实例销毁后不可再次使用

静态属性

checkMode

获取当前检测模式。

console.log(versionCheck.checkMode); // 'etag' 或 'file'
isRunning

获取当前检测状态。

console.log(versionCheck.isRunning); // boolean

📝 使用场景和示例

配合 Axios 拦截器使用

// axios 配置
import axios from 'axios';
import VersionCheck from 'version-check-js';

const versionCheck = new VersionCheck({
  url: '/api/version',
  interval: 300000,
});

// 请求拦截器中进行版本检测
axios.interceptors.request.use(
  async config => {
    if (process.env.NODE_ENV === 'production') {
      try {
        await versionCheck.check().then(flag => {
          console.log(flag);
        });
      } catch (error) {
        console.warn('版本检测失败:', error);
      }
    }
    return config;
  },
  error => {
    return Promise.reject(error);
  },
);

versionCheck.start();

🛠️ 高级配置和最佳实践

存储策略配置

// 自定义存储适配器
const customStorage = {
  get: function (key) {
    try {
      return localStorage.getItem(key);
    } catch (e) {
      // 降级到 cookie
      return this._getFromCookie(key);
    }
  },

  set: function (key, value) {
    try {
      localStorage.setItem(key, value);
      return true;
    } catch (e) {
      // 降级到 cookie
      return this._setToCookie(key, value);
    }
  },
  // Cookie 操作方法
  _getFromCookie: function (key) {
    /* ... */
  },
  _setToCookie: function (key, value) {
    /* ... */
  },
};

const versionCheck = new VersionCheck({
  storage: customStorage,
});

错误监控集成

const versionCheck = new VersionCheck({
  onError: error => {
    // 发送到自定义监控接口
    fetch('/api/error-report', {
      method: 'POST',
      body: JSON.stringify({
        error: error.message,
        stack: error.stack,
        timestamp: Date.now(),
      }),
    });
  },

  onLog: message => {
    // 记录操作日志
  },
});

性能优化建议

// 生产环境配置
const prodConfig = {
  url: '/api/version',
  interval: 5 * 60 * 1000, // 5分钟(避免过于频繁)
  onError: error => {
    // 生产环境静默处理,避免影响用户体验
    console.debug('Version check error:', error.message);
  },
};

// 开发环境配置
const devConfig = {
  url: '/api/version',
  interval: 30 * 1000, // 30秒(便于调试)
  onLog: message => {
    // 开发环境详细日志
    console.log('🔧 VersionCheck:', message);
  },
};

const versionCheck = new VersionCheck(process.env.NODE_ENV === 'production' ? prodConfig : devConfig);

🔗 相关链接

📄 许可证

MIT License

💬 结语

VersionCheck.js以其极简的配置强大的功能优雅的设计,成为了前端版本检测领域的优秀解决方案。无论你是个人开发者还是团队项目,都能从中受益。

立即试试VersionCheck.js,让你的应用版本更新变得更加智能和优雅!


如果你觉得这个工具不错,欢迎Star我们的GitHub项目,让更多开发者受益!

整理「祖传」代码,就是在开发脚手架?

作者 codingWhat
2026年3月2日 16:26

前言

  • 每次起新项目,都要复制一整套「祖传」配置、改包名、删示例,稍不留神就漏掉埋点或兼容文件?
  • 团队里 Vue 和 React 混用,项目结构五花八门,新人上手全靠口口相传?
  • 你和我说这就是在开发脚手架?No,No,No,你这是在扒拉项目结构。

脚手架不是框架,而是用命令行把「创建项目、统一规范」自动化的工具。我们天天在用的 npmvue createcreate-react-app,背后都是同一套思路:用 Node.js 写一个 CLI,把最佳实践固化成一条命令。


一、脚手架是什么?一条命令里藏着四样东西

回想一下你敲的每一条脚手架命令,无非四部分:

vue create vue-test-app --force -r https://registry.npmmirror.com
部分 示例 说明
主命令 vue 对应一个可执行文件
子命令 create 具体做什么事
参数 vue-test-app 子命令的输入
选项 --force-r <url> 开关或带值的配置

所以脚手架就是一个「主命令 + 子命令 + 参数 + 选项」的命令行客户端

执行时发生了什么? 终端先根据主命令在 PATH 里找到可执行文件(例如全局的 vue.js),再用 Node 执行它(因为文件头有 #!/usr/bin/env node),脚本里解析子命令和选项后执行对应的逻辑,结束退出。
一句话:脚手架本质是操作系统的客户端,只不过这个「客户端」是一段用 Node 跑的 JS,通过命令行和你交互罢了。


二、为什么值得花费成本自己做一套?

vue-cli、create-react-app 解决的是「从零搭一个标准项目」。但日常团队中会沉淀出一堆自家的东西,比如H5 兼容、接口封装、埋点、公共组件、登录/权限等,甚至整块业务都会被复用。每次起项目都从零复制,既费时又容易出错。

依我看,自己做项目创建脚手架,最起码能带来三件收益:模板沉淀(把「我们团队该怎么起项目」固化成可选模板)、标准化(类型、名称、框架通过交互选择,减少人为差异)、可复用(新人一条命令就和团队站在同一起跑线)。


三、原理:三个问题搞懂脚手架执行的过程

回答三个问题,原理就通了:

  1. 为什么装的是 @vue/cli,敲的却是 vue
    package.json 里有个 bin 字段,例如 "bin": { "vue": "bin/vue.js" }。全局安装时,npm 会在可执行路径下创建一个叫 vue软链接,指向这个 js 文件,所以命令名可以和包名不一样。

  2. 全局安装时到底干了啥?
    把包下到全局 node_modules,再按 bin 配置在系统 PATH 能搜到的地方建好软链接,这样你在任意目录敲 vue 都能找到对应脚本。

  3. 为什么一个 .js 文件能直接当命令执行?
    因为第一行写了 shebang#!/usr/bin/env node。系统看到 #! 就知道要用后面的解释器来跑这个文件,于是用当前环境的 node 去执行。用 env node 而不是写死 /usr/bin/node,换机器、换环境也能用。


四、给我一首歌的时间,从 0 跑通一个最小 的CLI

四步:建项目、写入口、配 bin、本地 link。跑通后你就有了一条「真」命令,再往上加 init、install 只是扩展。

1. 初始化项目

mkdir my-cli && cd my-cli
npm init -y

2. 写入口并加上 shebang

创建 bin/cli.js。第一行 #!/usr/bin/env nodeshebang(希棒):以 #! 开头,告诉系统「用谁」来执行这个文件。这里用当前环境的 node,所以终端里直接敲 my-cli 就会用 Node 跑这段脚本,不用再写 node bin/cli.js

#!/usr/bin/env node

import { program } from 'commander';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(
  readFileSync(join(__dirname, '../package.json'), 'utf-8')
);

program
  .name('my-cli')
  .description('最小 CLI 示例')
  .version(pkg.version);

program
  .command('hello [name]')
  .description('打个招呼')
  .action((name) => {
    console.log('Hello,', name || 'World');
  });

program.parse();

3. 配置 package.json

补上 bintype: "module"(推荐用 ESM,大势所趋):

{
  "name": "my-cli",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "my-cli": "bin/cli.js"
  },
  "dependencies": {
    "commander": "^11.0.0"
  }
}

4. 本地调试

npm install
npm link

在任意目录执行 my-cli --versionmy-cli hello 张三,能输出版本、能打招呼,就说明最小 CLI 已经跑通。后面要支持 init、install 等多条命令,无非是把脚手架拆成多包、抽象出命令基类,在入口里按「一条命令一个子类」挂上去,是不是很简单?


五、来看一个真实项目

拿我们组脚手架为例,采用 commander框架,实现了组内自定义Vue/React模板框架的生成功能。

目录

your-cli/
├── package.json
├── packages/
│   ├── cli/              # 入口、createCLI、注册命令
│   ├── command/          # 命令基类
│   ├── utils/            # 日志、inquirer、npm、Git
│   ├── init/             # 命令 init:模板 → 下载 → 安装
│   └── install/          # 命令 install:搜索 → 选 tag → clone → 装依赖 → 运行

Command 基类说明:项目中,子命令不直接调 Commander,而是继承基类,从而实现 commanddescriptionoptionsaction,基类在构造函数里统一完成了「注册命令 + 绑定 action」。这样新增命令 = 新子类 + 入口挂一行。核心逻辑如下:

// packages/command/lib/index.js(思路示例,已脱敏)
class Command {
  constructor(program) {
    if (!program) throw new Error('command instance must not be null!');
    this.program = program;
    const cmd = this.program.command(this.command);
    cmd.description(this.description);
    if (this.options?.length > 0) {
      this.options.forEach(opt => cmd.option(...opt));
    }
    cmd.action((...params) => this.action(...params));
  }

  get command() {
    throw new Error('command must be implemented');
  }
  get description() {
    throw new Error('description must be implemented');
  }
  get options() {
    return [];
  }
  async action() {
    throw new Error('action must be implemented');
  }
}
export default Command;

入口里只需:通过createCLI() 得到 program
createInitCommand(program)createInstallCommand(program)
最后 program.parse(process.argv)
通常createCLI() 里需要包含 name、version、--debug、Node 版本检查、未知命令提示等功能。


六、来看看init 命令干了啥

init 只做一件事:从模板创建项目。在项目里,InitCommand 的 action 拆成三步,对应三个文件:

步骤 做的事 对应模块
1 选择模板,生成安装信息 createTemplate.js
2 下载模板到缓存目录 downloadTemplate.js
3 拷贝到项目目录并渲染 installTemplate.js

第一步:createTemplate
入参是项目名 name 和命令行 opts--type--template--force)。
没传 type/template 就通过交互收集。
选定后用 getLatestVersion(template.npmName) 从 npm 拉最新版本,并返回 { type, name, template, targetPath },其中targetPath 即缓存目录(如 ~/.your-cli/addTemplate)。

第二步:downloadTemplate
在缓存目录下执行 npm install ${npmName}@${version},把模板包装进 node_modules。示例:

// 思路示例,已脱敏
import { execa } from 'execa';
import ora from 'ora';

async function downloadAddTemplate(targetPath, template) {
  const { npmName, version } = template;
  await execa('npm', ['install', `${npmName}@${version}`], { cwd: targetPath });
}

export default async function downloadTemplate(selectedTemplate) {
  const { targetPath, template } = selectedTemplate;
  ensureDirSync(targetPath);
  const spinner = ora('正在下载模板...').start();
  try {
    await downloadAddTemplate(targetPath, template);
    spinner.stop();
    log.success('下载模板成功');
  } catch (e) {
    spinner.stop();
    printErrorLog(e);
  }
}

第三步:installTemplate
目标目录是当前目录下的 name 文件夹;
从缓存的 node_modules/<npmName>/template 拷贝到目标目录,之后用 ejs 注入 name 后写回。
代码示例如下:

// 思路示例,已脱敏
import fse from 'fs-extra';
import { pathExistsSync } from 'path-exists';
import ejs from 'ejs';
import glob from 'glob';

export default async function installTemplate(selectedTemplate, opts) {
  const { force = false } = opts;
  const { targetPath, name, template } = selectedTemplate;
  const rootDir = process.cwd();
  const installDir = path.resolve(rootDir, name);

  if (pathExistsSync(installDir)) {
    if (!force) {
      log.error(`当前目录下已存在 ${installDir}`);
      return;
    }
    fse.removeSync(installDir);
  }
  fse.ensureDirSync(installDir);

  const originFile = path.resolve(targetPath, 'node_modules', template.npmName, 'template');
  const fileList = fse.readdirSync(originFile);
  fileList.forEach((file) => {
    fse.copySync(path.join(originFile, file), path.join(installDir, file));
  });

  const ejsData = { name, ...customData };
  glob('**', { cwd: installDir, nodir: true, ignore: template.ignore }, (err, files) => {
    files.forEach((file) => {
      const filePath = path.join(installDir, file);
      ejs.renderFile(filePath, ejsData, (err, result) => {
        if (!err) fse.writeFileSync(filePath, result);
      });
    });
  });
}

最终,InitCommand 的 action 就是三步串联:

// 思路示例,已脱敏
async action([name, opts]) {
  const selectedTemplate = await createTemplate(name, opts);
  await downloadTemplate(selectedTemplate);
  await installTemplate(selectedTemplate, opts);
}

ps:为何用 npm 管理模板? 不占服务器、自带版本、用 registry API 查 dist-tags.latest 即可拿到最新版的包。我们内部使用了自己部署的Verdaccio,也推荐给大家!
模板包约定:模板统一放在 template/下,支持多框架(React/Vue)。


七、React/Vue 模板来源有哪些?

「从模板创建项目」时,React/Vue 通常有两种来源,可以同时提供:

来源 命令 场景
npm 模板 init 团队标准化:把 React/Vue 模板打成 npm 包,init 时选模板一步到位
Git 仓库 install 选取目标仓库和 tag 后 clone、装依赖、可选运行

init 做「内部」创建,install 做「任意仓库」拉取,两条能力互补。


八、总结

  • 架构commander框架。
  • 项目模板:npm 托管 + registry API 查版本。
  • 交互:Inquirer进行选择与输入,validate 做必填;ora 做 loading,log做成功/失败统一提示日志 ,debug模式采用log.verbose等。

【大白话前端 02】网页从解析到绘制的全流程

2026年3月2日 16:14

上一章我们讲到,浏览器在网络层面一路跋山涉水,最终把被切碎的数据包拼装成了一份纯文本文件。拿到文件后,便立刻交给了浏览器内部的渲染机器。

这台机器本质上就是一个精密的代码代工厂。纯文本的 HTML、CSS 和 JS 是无法直接变成眼前绚丽的页面,机器必须执行一套严密的 5 步流水线工序,把纯文本拼装成最终的交互画面。

graph TD
    A[HTML 代码] --> B(解析生成 DOM 树)
    C[CSS 代码] --> D(解析生成 CSSOM 树)
    B --> E{合成 Render Tree 渲染树}
    D --> E
    E --> F(Layout 布局计算)
    F --> G(Paint 绘制出图)

第一步:解析图纸,搭起车架子 (DOM 树)

浏览器无法直接阅读 HTML 里的尖括号文本。它做的第一件事,就是把这些嵌套的文本标签,转译成机器能懂的层级结构DOM 树。这棵树就是整个页面的车架子,规定了哪里是车门(<div>),哪里是方向盘(<button>)。

第二步:制备车漆配置单 (CSSOM 树)

骨架搭完后,浏览器紧接着解析 CSS 文本。不管样式写在哪,都会被统一合并梳理成另一棵树CSSOM。它记录了这扇门用什么颜色喷漆,那个轮毂多大尺寸。

致命阻塞:当流水线遇到 JavaScript

核心定律: 由于浏览器里的 JS 是单线程的,机器无法做到一边解析 HTML,一边去执行 JS。

所以一旦遇到了 <script> 标签,整条流水线会立刻强制停止。机器必须先去下载并执行完这段 JS 代码,然后才能继续解析HTML。这会导致网页首屏白屏卡住。

为了不让 JS 阻塞页面的渲染显示,我们有 3 种解法:

解法 1:传统写法 把所有 <script> 标签扔到 HTML 的最底部(也就是 </body> 前面)。这样机器会一口气先把页面画完,最后再去下 JS。

解法 2 和 解法 3:让机器一心二用 如果非把 JS 写在头部(<head>),可以给它加上 asyncdefer 属性。它们都能让机器开启后台并行下载,但之后的行为完全不同:

写法属性 下载时机 (谁在下?) 执行时机 (何时跑?) 适用场景 结论
啥都不加 阻塞流水线,必须下完 马上执行,流水线继续停工 旧时代的默认写法 极慢,已被淘汰
加 async 在后台并行下载 下载完马上霸道插队执行,流水线被迫停工 互相独立、不需要按顺序跑的脚本(如:百度统计代码) 谁先下完谁执行,适合独立模块
加 defer 在后台并行下载 必须等所有 HTML 全解析完,再按顺序排队执行 需要操作 DOM、或有关联顺序的核心业务代码 无脑首选,最稳健防白屏

正确做法: 绝大多数情况下,直接无脑写 <script src='xxx.js' defer></script>。这相当于告诉机器:你先去后台排队下吧,继续画你的 HTML 图纸,等你看完全部图纸了最后再来行这些 JS。

第三步:合成渲染树 (组装核心部件)

DOM 树只讲结构,CSSOM 树只讲样式。到了第三步,流水线把这两份图纸合并,生成真正用于显示的渲染树 (Render Tree)。这就好比按图纸把车架子和车漆拼成了待喷漆的白模车。

常见错误: 渲染树里只保留需要显示的节点。如果一个元素加了 display: none,或者它本身就是 <head> 这种不可见标签,这一步会被直接丢弃,根本不进后续流程。

第四步:布局 Layout (计算尺寸与位置)

进入布局阶段,也叫重排 (Reflow)。在这个阶段,机器精确计算每个零件在屏幕上的确切坐标(距离顶部、左边多少)和尺寸(宽、高)。相当于实地测绘占地面积。

第五步:绘制 Paint (上色与纹理)

最终坐标算清后,最后一步就是调用绘图 API,把真实像素画在屏幕上。这步也叫重绘 (Repaint)。至此,你在屏幕上终于看到了网页。

重排与重绘的性能损失

理解了流水线,你在写代码时就要尽量规避让机器大范围返工的操作。

概念 做了什么骚操作 机器被你逼着怎么返工? 性能开销
重排 (Reflow) 动了位置(如修改 width、margin) 布局 + 绘制这两步推翻重走,重新测绘周边所有元素位置。 极大(极易卡顿掉帧)
重绘 (Repaint) 只是换色(如修改 color、background) 尺寸和占地没变,只需把绘制这一步重走一遍。 较小

避坑指南:做“动画/动态位移”时,用 transform 代替 margin

1. 底层逻辑(为什么加了 transform 动画就不卡了?):

  • 如果你用 margin 做动画: 等于你告诉主流水线:“我要把这个小螺丝往右挪 100 像素”。流水线的刻板反应是:“收到,但螺丝位置变了,旁边的挡板可能要跟着动,车架子的重心可能变了——不行,我得把周边所有相连元素的受力面积和坐标全重新计算一遍(触发大规模重排),然后把它们全员重新上色(触发重绘)。” 这就叫牵一发而动全身。
  • 如果你用 transform 做动画: 浏览器一旦看到 transform 这个词,立刻懂了。它会在之前第 5 步(绘制 Paint)结束后,把这个加了位移的元素单独剪下来,变成一张独立的透明贴纸(前端术语叫:提升为独立图层 Layer)。 发生位移动画时,不仅不动兄弟元素的布局,它自己连颜色都不用重涂。这张画好的贴图直接过继给显卡(GPU),显卡不需要算尺寸,只需要像推玻璃一样,把这张完整的贴纸“滑”到新位置。这最后凌驾于流水线之上滑动图层拼装的工序,叫作合成(Composite)。

2. 终极一问:能无脑全用 transform 替代 margin 布局吗? 绝对不行! 正因为 transform 变成了悬浮的贴纸,它在视觉上飞走后,原地的物理坑位还在,它根本碰不到、也挤不开周围的兄弟元素

  • 结论:搭静态页面基础架构(抢地盘)时,就得砸重排的开销,老老实实用 margin / padding。而在做轮播图、弹窗飞入、元素悬浮变大等频繁动来动去的交互动画时,死磕 transform

了解了浏览器的渲染流水线,下一个要面对的现实是:不同浏览器(如 Chrome、Safari)的流水线标准并不完全一样。如果按个人直觉随意写代码,很容易出现不同设备上样式错位,或是触发刚才讲过的性能卡顿。 为此,整个前端行业制定了一套统一的“图纸底线规范”。

下一章请看:【大白话前端 03】Web 标准与最佳实践,我们将直接拆解这套规范,看看实战中到底该用什么样的标准来编写代码。

写了个 AI 聊天页面,被 5 种流式格式折腾了一整天 😭

作者 ofox
2026年3月2日 15:29

周末想给自己的小项目加个 AI 聊天功能,本来以为流式输出很简单:建个 SSE 连接,解析 data: 开头的行,拼接文本,搞定。

结果...一天下来,我被五种不同的流式响应格式彻底搞麻了。

起因:一个"简单"的需求

需求其实很朴素:做一个聊天页面,让用户选择不同的 AI 模型,支持流式输出(打字机效果)。

我先接了 OpenAI 的 GPT-4o,大概十分钟就写完了:

import httpx
import json

async def stream_chat(messages):
    async with httpx.AsyncClient() as client:
        async with client.stream(
            "POST", "https://api.openai.com/v1/chat/completions",
            headers={"Authorization": f"Bearer {api_key}"},
            json={"model": "gpt-4o", "messages": messages, "stream": True}
        ) as resp:
            async for line in resp.aiter_lines():
                if line.startswith("data: ") and line != "data: [DONE]":
                    chunk = json.loads(line[6:])
                    content = chunk["choices"][0]["delta"].get("content", "")
                    if content:
                        yield content

完美运行,前端效果很丝滑。然后我就想:加几个模型选项呗,Claude、Gemini、DeepSeek、Kimi,让用户自己切换。

于是噩梦开始了。

第一个坑:Claude 的 SSE 完全不一样

Claude 的流式 API 返回格式长这样:

event: message_start
data: {"type": "message_start", "message": {"id": "msg_xxx", ...}}

event: content_block_start
data: {"type": "content_block_start", "index": 0}

event: content_block_delta
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "你好"}}

event: message_stop
data: {"type": "message_stop"}

注意看,和 OpenAI 完全不同:

  • 多了 event: 行,不能直接忽略
  • 文本在 delta.text 里,不是 choices[0].delta.content
  • 结束信号是 message_stop 事件,不是 data: [DONE]

我只好加了一套专门的解析逻辑:

elif model.startswith("claude"):
    async for line in resp.aiter_lines():
        if line.startswith("data: "):
            chunk = json.loads(line[6:])
            if chunk.get("type") == "content_block_delta":
                text = chunk["delta"].get("text", "")
                if text:
                    yield text

行,能用。继续加模型。

第二个坑:Gemini 压根不是 SSE

Google Gemini 的流式 API,用的不是标准 SSE,而是流式 JSON 数组

[{"candidates": [{"content": {"parts": [{"text": "你"}]}, "index": 0}}]}
,{"candidates": [{"content": {"parts": [{"text": "好"}]}, "index": 0}}]}
]

对,它返回的是一个 JSON 数组,通过 chunked transfer 分块传输。每个 chunk 可能包含不完整的 JSON 片段,你得自己攒 buffer 然后尝试解析。

我当时的心情:🤯

elif model.startswith("gemini"):
    buffer = ""
    async for chunk in resp.aiter_text():
        buffer += chunk
        while True:
            try:
                # 尝试找到完整的 JSON 对象
                idx = buffer.index("\n")
                line = buffer[:idx].strip().strip(",[]")
                buffer = buffer[idx+1:]
                if line:
                    obj = json.loads(line)
                    text = obj["candidates"][0]["content"]["parts"][0]["text"]
                    yield text
            except (ValueError, json.JSONDecodeError, KeyError, IndexError):
                break

写完这段代码我就知道,这条路走不通了 😅

第三个坑:DeepSeek 的 99% 兼容

DeepSeek 号称 OpenAI 兼容,实际上确实 99% 是。但那 1% 够你 debug 一下午的:

坑1:reasoning_content 字段

DeepSeek-R1 会返回一个额外的推理过程字段:

{
  "choices": [{
    "delta": {
      "reasoning_content": "让我想想这个问题...",
      "content": ""
    }
  }]
}

如果你只读 content,用户会看到 AI "卡住了"半天,然后突然蹦出一大段回复。得把 reasoning_content 也展示出来,或者至少给个 loading 状态。

坑2:空 choices

偶尔会遇到 choices 是空列表的情况:

content = chunk["choices"][0]["delta"].get("content", "")
# IndexError: list index out of range
# 因为 choices 是 []  😭

第四个坑:Kimi/Moonshot 的小细节

Moonshot API 基本兼容 OpenAI,但有两个小坑:

  1. 结束信号不一定是 data: [DONE],有时候直接断开连接,你的代码要能处理这种情况
  2. 偶尔返回的 JSON 里 choices[0].deltanull 而不是空对象 {}

一个个小问题,每个都得加 defensive code。

最后代码变成了灾难

写完所有模型的适配,我的流式解析函数长这样:

async def stream_chat(model, messages):
    if model.startswith("gpt"):
        # OpenAI 格式
        ...  # 20行
    elif model.startswith("claude"):
        # Anthropic 格式
        ...  # 25行
    elif model.startswith("gemini"):
        # Google 流式 JSON
        ...  # 40行(最复杂)
    elif model.startswith("deepseek"):
        # OpenAI 兼容但要处理 reasoning_content 和空 choices
        ...  # 30行
    elif model.startswith("moonshot"):
        # OpenAI 兼容但结束信号不同
        ...  # 20行

一个 200 多行的函数,5 种 if-else 分支,每种都有自己的 edge case。这代码谁维护谁头疼。

而且最恶心的是:每当某个模型 API 更新格式,你就得重新测一遍所有分支。

转折点:统一成一种格式

写到第二天我实在受不了了。核心痛点其实很清楚:

如果所有模型的流式响应都是 OpenAI 格式,我只需要维护一套代码。

方案有两个:

方案 A:自己写转换代理

起一个中间服务,把各家 API 的响应统一转成 OpenAI SSE 格式再返回给前端。能行,但:

  • 每种格式写一个 adapter,维护成本高
  • 模型 API 更新你得跟着改
  • 还得处理认证、限流、重试...

方案 B:用现成的 API 聚合平台

我后来发现 ofox.ai 这类平台已经把这事干了——所有模型走同一个 /v1/chat/completions endpoint,统一返回 OpenAI 兼容的 SSE 格式。

改完之后的代码:

async def stream_chat(model, messages):
    """一套代码搞定所有模型"""
    async with httpx.AsyncClient() as client:
        async with client.stream(
            "POST", "https://api.ofox.ai/v1/chat/completions",
            headers={"Authorization": f"Bearer {OFOX_KEY}"},
            json={"model": model, "messages": messages, "stream": True}
        ) as resp:
            async for line in resp.aiter_lines():
                if line.startswith("data: ") and line != "data: [DONE]":
                    chunk = json.loads(line[6:])
                    choices = chunk.get("choices", [])
                    if choices and choices[0]["delta"].get("content"):
                        yield choices[0]["delta"]["content"]

200 行 → 15 行。不管前端选什么模型,后端就这一套解析逻辑。

前端也简单了:

const response = await fetch('/api/chat', {
  method: 'POST',
  body: JSON.stringify({ model: selectedModel, messages })
});

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

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

  const text = decoder.decode(value);
  const lines = text.split('\n').filter(l => l.startsWith('data: '));

  for (const line of lines) {
    if (line === 'data: [DONE]') continue;
    const { choices } = JSON.parse(line.slice(6));
    if (choices?.[0]?.delta?.content) {
      appendMessage(choices[0].delta.content);
    }
  }
}

一套代码,所有模型通用。不管是 GPT-4o、Claude 3.5、Gemini、DeepSeek 还是 Kimi,前端不用改一行。

最终效果对比

模型 原生流式格式 统一后
GPT-4o OpenAI SSE ✅ 一致
Claude 3.5 Sonnet Anthropic SSE(event + data) → OpenAI SSE
Gemini 2.0 流式 JSON 数组 → OpenAI SSE
DeepSeek-R1 近似 OpenAI(有 quirks) → 标准 OpenAI SSE
Kimi K2.5 近似 OpenAI(小坑) → 标准 OpenAI SSE
指标 优化前 优化后
解析代码行数 ~200 行 ~15 行
if-else 分支 5 个 0 个
新增模型适配时间 半天起步 改个 model 名就行
API Key 数量 5 个 1 个

给想做多模型应用的同学几点建议

  1. 别假设 "OpenAI 兼容" 就是 100% 兼容:至少留 defensive code
  2. Gemini 的流式格式和其他家完全不同:如果你要支持 Gemini 流式,做好心理准备
  3. DeepSeek-R1 的 reasoning_content 要单独处理:不然用户体验会很奇怪
  4. 一开始就用统一格式的方案:别像我一样先踩坑再回头
  5. 测试时用真实的长对话:很多 edge case 只在长文本输出时出现

如果你也在做多模型 AI 应用,强烈建议一开始就走统一 API 的路线。我现在所有项目的模型调用都走 ofox.ai,一个 key 搞定 50 多个模型,流式格式统一,省了太多事。

踩坑一天,回头一看,大部分坑根本不用踩 🤦‍♂️

Electron主窗口弹框被WebContentView遮挡?独立WebContentView弹框方案详解!

2026年3月2日 14:48

Electron弹框被WebContentView遮挡?独立弹框层解决方案

针对 《Electron 实战全解析:基于 WebContentView 的多视图管理系统》 评论区的问题:子窗口嵌入到主窗口的某个区域,如果主窗口有一个全局弹窗,是在主渲染进程里面打开的,就无法覆盖这个子窗口。

问题根源:为什么DOM弹框会被WebView遮挡?

在Electron应用中,如果你在主窗口内嵌了多个WebContentsView,可能会遇到这样的问题:

// 主窗口中的DOM弹框
<el-dialog v-model="visible" :append-to-body="true">
  <!-- 内容 -->
</el-dialog>

无论你把z-index设得多高,这个弹框都可能被webview遮挡。原因很简单:

WebView在Electron中处于独立的合成层级,不完全遵循DOM的z-index规则。

解决方案:独立窗口覆盖层

既然DOM弹框打不过WebView,我们就换个思路:用一个独立的BrowserWindow作为弹框承载层

核心思想

主窗口 (MainWindow)
├── WebView A (业务页面)
├── WebView B (第三方应用)
└── [问题:DOM弹框被遮挡]

解决方案:
主窗口 (MainWindow)
├── WebView A
├── WebView B
└── 独立弹框窗口 (DialogWindow) ← 永远在最顶层

架构设计

1. DialogWindowManager:透明无框覆盖层

// DialogWindowManager.js
createDialogWindow() {
  const dialogConfig = {
    parent: this.mainWindow,      // 父子窗口关系
    transparent: true,            // 透明背景
    frame: false,                 // 无边框
    skipTaskbar: true,            // 不在任务栏显示
    resizable: false,             // 尺寸由主窗口控制
    webPreferences: {
      nodeIntegration: true,
      contextIsolation: false
    }
  };

  this.dialogWindow = new BrowserWindow(dialogConfig);
}

关键配置说明

  • parent: mainWindow:建立父子关系,弹框窗口随主窗口移动
  • transparent: true + frame: false:完全透明,用户感觉不到是独立窗口
  • skipTaskbar: true:不在任务栏显示,保持"弹框"的错觉

2. 实时位置联动

弹框窗口需要与主窗口保持同步:

resize() {
  // 获取主窗口内容区坐标
  const { x, y } = this.mainWindow.getContentBounds();

  // 设置弹框窗口位置和大小
  this.dialogWindow.setContentBounds({
    x, y,
    width: this.size.width,
    height: this.size.height
  }, true);
}

这样,无论主窗口如何移动、缩放,弹框窗口都能完美对齐。

实现细节

1. 弹框类型驱动

通过URL参数传递弹框类型和参数:

showDialog(dialogType, params) {
  const query = new URLSearchParams({
    dialog: dialogType,
    ...params
  }).toString();

  const url = `${dialogUrl}?${query}`;
  this.dialogWindow.loadURL(url);
}

2. 动态组件加载

弹框窗口使用Vue3动态组件:

<!-- App.vue -->
<script setup>
import { ref, onMounted } from 'vue';

const currentDialogType = ref('');
const loadedComponent = ref('');

// 组件映射表
const dialogComMap = {
  preferences: 'Preferences',
  setting: 'Setting',
  confirm: 'ConfirmDialog'
};

const loadDialogComponent = async () => {
  const componentName = dialogComMap[currentDialogType.value];
  if (componentName) {
    // 动态导入组件
    const module = await import(`../components/${componentName}.vue`);
    loadedComponent.value = componentName;
  }
};

onMounted(() => {
  // 从URL参数获取弹框类型
  const params = new URLSearchParams(window.location.search);
  currentDialogType.value = params.get('dialog') || '';
  loadDialogComponent();
});
</script>

<template>
  <component :is="loadedComponent" />
</template>

3. 双向通信桥接

弹框 → 主窗口
// dialogBridge.js
sendToMain(action, payload) {
  const message = {
    action,
    payload,
    dialogType: this.currentDialogType  // 告知主进程我是谁
  };
  ipcRenderer.send('dialog-to-main', message);
}
主窗口 → 弹框
// mainBridge.js
setupDialogListener() {
  ipcRenderer.on('dialog-message', (event, data) => {
    this.handleDialogMessage(data);
  });
}

handleDialogMessage(data) {
  const { action, payload } = data;
  switch (action) {
    case 'UPDATE_SETTINGS':
      this.updateSettings(payload);
      break;
    case 'CLOSE_DIALOG':
      this.closeDialog(payload.dialogType);
      break;
  }
}

完整工作流程

打开弹框

sequenceDiagram
    participant Main as 主窗口
    participant Process as 主进程
    participant Dialog as 弹框窗口

    Main->>Process: showDialog('preferences', params)
    Process->>Dialog: 创建窗口 + loadURL(?dialog=preferences)
    Dialog->>Dialog: 解析URL,加载Preferences组件
    Dialog-->>Main: 弹框显示完成

关闭弹框

sequenceDiagram
    participant Dialog as 弹框窗口
    participant Process as 主进程
    participant Main as 主窗口

    Dialog->>Process: dialog-to-main(CLOSE_DIALOG)
    Process->>Main: 转发消息
    Main->>Process: close-modal-dialog
    Process->>Dialog: 隐藏/关闭窗口

工程配置

独立构建入口

// webpack.renderer.dialog.config.js
module.exports = {
  entry: './src/renderer/views/dialog/main.js',
  output: {
    path: 'dist/electron/renderer/views/dialog',
    filename: 'dialog.js'
  }
};

主窗口构建配置

// webpack.renderer.main.config.js  
module.exports = {
  entry: './src/renderer/main.js',
  output: {
    path: 'dist/electron/renderer',
    filename: 'main.js'
  }
};

解决的问题 vs 付出的代价

✅ 解决的问题

  1. 彻底解决遮挡问题:独立窗口永远在最顶层
  2. 视觉体验一致:通过位置联动,用户感觉不到是独立窗口
  3. 模块化设计:弹框组件独立打包,不增加主包体积
  4. 类型安全:完整的TypeScript支持

⚠️ 付出的代价

  1. 复杂度增加:需要维护多窗口、多进程通信
  2. 状态同步:弹框窗口需要独立初始化store、i18n等
  3. 调试困难:问题可能出现在三个地方(主进程、主窗口、弹框窗口)

实战代码示例

在主窗口中调用弹框

// 打开设置弹框
import { dialogService } from './services/dialog';

const openSettings = async () => {
  const result = await dialogService.showDialog('preferences', {
    theme: 'dark',
    language: 'zh-CN'
  });

  if (result.confirmed) {
    // 用户点击了确定
    applySettings(result.data);
  }
};

自定义弹框组件

<!-- Preferences.vue -->
<template>
  <div class="preferences-dialog">
    <h3>系统设置</h3>

    <div class="form-item">
      <label>主题</label>
      <select v-model="theme">
        <option value="light">浅色</option>
        <option value="dark">深色</option>
      </select>
    </div>

    <div class="actions">
      <button @click="handleSave">保存</button>
      <button @click="handleCancel">取消</button>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { dialogBridge } from '../utils/dialogBridge';

const theme = ref('light');

const handleSave = () => {
  dialogBridge.sendToMain('UPDATE_SETTINGS', {
    theme: theme.value
  });
  dialogBridge.closeDialog();
};

const handleCancel = () => {
  dialogBridge.closeDialog();
};
</script>

可优化性能建议

  1. 窗口复用:不要频繁创建/销毁窗口,使用show()/hide()
  2. 组件懒加载:弹框组件按需加载,减少初始包体积
  3. 通信优化:使用批量更新,减少IPC调用次数
  4. 内存管理:及时清理不用的弹框组件引用

总结

DialogWindowManager方案的核心价值在于:

用操作系统级的窗口层级,解决渲染层级的限制问题。

这个方案虽然增加了一些复杂度,但对于需要内嵌多个WebView的Electron应用来说,是解决弹框遮挡问题的终极方案。

如果你的应用也遇到了类似问题,不妨试试这个架构。它已经在我的多个生产环境项目中验证,稳定可靠。


相关阅读

有任何问题欢迎在评论区提问。

从异步迷雾到优雅流程:JavaScript异步编程与内存管理的现代化之旅

作者 Lee川
2026年3月2日 13:36

在现代Web开发的宇宙中,数据流动如同生命线,而处理这些数据的异步请求和内存管理,则构成了这个生态系统的底层法则。从经典的AJAX到现代的Fetch,从混乱的回调到Promise的秩序,再到对内存的精打细算,这是一段关于开发者不断追求“优雅”与“高效”的进化史。本文将通过代码实例,带你领略这一技术演变的精妙之处。

第一章:从AJAX到Fetch——API请求的“换代”

早期的Web开发者对 XMLHttpRequest这个略显冗长的构造函数一定不陌生。它是AJAX(Asynchronous JavaScript and XML)的核心,让网页无需刷新即可与服务器通信。如您在 文档1 所见,它的使用模式充满了仪式感:

const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.github.com/users/shunwuyu', true);
xhr.send();
xhr.onreadystatechange = function () {
    if (xhr.readyState === 4 && xhr.status === 200) {
        const data = JSON.parse(xhr.responseText);
        console.log(data);
    }
}

在这里,我们需要手动检查 readyStatestatus,并通过回调函数处理响应。尽管功能强大,但这种“回调地狱”的阴影始终挥之不去。

fetch()API的到来,如一股清流,改变了这一切(文档1):

fetch('https://api.github.com/users/shunwuyu')
    .then(res => res.json())
    .then(data => console.log(data));

fetch天生基于 Promise,设计简洁,链式调用的美感替代了嵌套回调的混乱。正如 文档6 中总结的:fetch简单易用,基于Promise实现,无需回调函数。它代表了浏览器原生API的现代化方向。

第二章:承前启后——封装基于Promise的AJAX工具

尽管 fetch是未来,但理解其底层思想,尤其是Promise的运用,至关重要。这就引出了经典的封装练习:如何将一个基于回调的 XMLHttpRequest封装成返回Promise的 getJSON函数?您提供的 文档2 给出了一个教科书般的答案:

const getJSON = url => {
    return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open('GET', url, true);
        xhr.send();
        xhr.onreadystatechange = function() {
            if(xhr.readyState === 4 && xhr.status === 200){
                resolve(JSON.parse(xhr.responseText)); // 成功时解析数据
            }
        }
        xhr.onerror = function() {
            reject('出错了'); // 网络错误时拒绝
        }
    });
};

// 使用起来,已然是Promise的优雅世界
getJSON('https://api.github.com/users/shunwuyu')
    .then(data => console.log(data))
    .catch(err => console.log(err))
    .finally(() => console.log('请求完成'));

这个封装完美诠释了Promise的契约精神:执行器函数 (resolve, reject) => {}中包裹着异步操作,成功时调用 resolve()传递结果,失败时调用 reject()传递原因。外部则通过 .then().catch().finally()这些清晰的生命周期钩子来处理不同状态,实现了逻辑与控制的分离。

第三章:Promise的抽象与具象——以“Sleep函数”为例

Promise的强大不仅限于网络请求,它是一种通用的异步流程控制方案。文档3文档4 通过手写一个sleep函数,生动地展示了Promise如何将任何异步操作(如setTimeout)纳入其统一管理范式。

文档3 展示了一个带有调试信息的版本,让我们看清Promise状态的变化:

function sleep(n) {
    let p;
    p = new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log(p); // 在setTimeout回调中查看状态
            reject(); // 此版本主动调用了reject
        }, n);
    });
    return p;
}

文档4 则给出了这个模式最精简、最实用的工业级封装:

const sleep = n => new Promise(resolve => setTimeout(resolve, n));

sleep(3000).then(() => console.log('三秒后执行'));

这行代码堪称艺术品。它抽象出一个通用的“等待”概念,使得异步流程可以像搭积木一样组合。new Promise(resolve => setTimeout(resolve, n))是理解Promise的绝佳切入点:创建一个Promise,在 n毫秒后,通过 resolve()将其状态从“pending”(等待)变为“fulfilled”(已完成),从而触发后续的 .then()

第四章:优雅背后的基石——内存管理中的浅拷贝与深拷贝

当我们流畅地处理数据时,对内存的操作必须是谨慎而高效的。文档5文档6 触及了JavaScript中一个微妙而重要的话题:引用式拷贝 与如何避免副作用。

JavaScript变量存储在栈内存(简单数据类型和对象引用)与堆内存(复杂对象本身)中。直接赋值对象或数组,传递的只是引用地址。这意味着,修改新变量会影响原数据,引发难以追踪的bug。

文档5 演示了两种关键的拷贝策略:

  1. 浅拷贝:仅复制第一层。[].concat(arr)是一个经典的快速数组浅拷贝技巧,成本低廉。

    const arr = [1,2,3];
    const arr3 = [].concat(arr); // 浅拷贝
    arr3[0] = 4; // 修改arr3不会影响arr
    console.log(arr); // [1,2,3]
    
  2. 深拷贝:递归复制所有层级。JSON.parse(JSON.stringify(obj))是一个广为人知的“快捷方式”,但它有局限性(如无法处理函数、undefined、循环引用)。

    const arr2 = JSON.parse(JSON.stringify(arr)); // 深拷贝
    arr2[0] = 10; // arr2与arr完全独立
    

正如 文档6 指出的,深拷贝“重新申请一块空间,开销大”。因此,在实际开发中,我们必须根据数据结构(是否嵌套)和性能要求,明智地在浅拷贝与深拷贝之间做出选择。

结语:秩序之美

纵观这些文档,我们看到的不仅是一段段代码,更是一部微缩的JavaScript开发思想进化史:从直面复杂回调的 XMLHttpRequest,到使用Promise进行优雅封装的 getJSON,再到原生集成的 fetch;从手写 sleep理解异步抽象,到审视 [].concat()JSON.parse(JSON.stringify())背后的内存哲学。

技术的发展,始终朝向同一个目标:用更清晰的语法表达逻辑,用更可控的方式管理状态,用更高效的手段操作资源。 掌握这些从实践中来的模式与思想,将使你在构建现代Web应用时,不仅能让代码跑起来,更能让代码“优雅”地运行。

代码生成:从AST到render函数

作者 wuhen_n
2026年3月2日 13:27

在前几篇文章中,我们学习了代码编译--转成--生成的过程。今天,我们将聚焦于指令系统——这个 Vue 中强大的声明式功能。从内置指令(v-if、v-for、v-model)到自定义指令,我们将深入它们的编译原理和运行时实现。

前言:指令的本质

指令是 Vue 模板中带有 v- 前缀的特殊属性。它本质上是一种声明式的语法糖,让我们能够在模板中直接操作 DOM 元素。

<!-- 使用指令 -->
<input v-model="message" />
<div v-if="visible">条件渲染</div>
<div v-custom:arg.modifier="value">自定义指令</div>

指令的注册方式

全局注册

const app = createApp(App);

app.directive('focus', {
  mounted(el) {
    el.focus();
  }
});

app.directive('color', {
  mounted(el, binding) {
    el.style.color = binding.value;
  },
  updated(el, binding) {
    el.style.color = binding.value;
  }
});

局部注册

export default {
  directives: {
    focus: {
      mounted(el) {
        el.focus();
      }
    },
    color: {
      mounted(el, binding) {
        el.style.color = binding.value;
      },
      updated(el, binding) {
        el.style.color = binding.value;
      }
    }
  }
}

import { directive } from 'vue';

export default {
  setup() {
    const vFocus = {
      mounted(el) {
        el.focus();
      }
    };
    
    return { vFocus };
  }
}

组件注册原理

// 指令注册的内部实现
function createDirective(name, definition) {
  // 规范化指令定义
  if (typeof definition === 'function') {
    // 函数简写形式
    definition = {
      mounted: definition,
      updated: definition
    };
  }
  
  return {
    name,
    ...definition
  };
}

// 全局注册表
const globalDirectives = new Map();

app.directive = function(name, definition) {
  if (definition === undefined) {
    // 获取指令
    return globalDirectives.get(name);
  } else {
    // 注册指令
    globalDirectives.set(name, createDirective(name, definition));
    return this;
  }
};

指令生命周期钩子

完整的钩子函数

const myDirective = {
  // 在绑定元素的 attribute 前
  // 或事件监听器应用前调用
  created(el, binding, vnode) {
    console.log('created', binding);
  },
  
  // 在元素被插入到 DOM 前调用
  beforeMount(el, binding, vnode) {
    console.log('beforeMount', binding);
  },
  
  // 在绑定元素的父组件
  // 及他自己的所有子节点都挂载完成后调用
  mounted(el, binding, vnode) {
    console.log('mounted', binding);
    el.focus();
  },
  
  // 在包含组件的 VNode 更新前调用
  beforeUpdate(el, binding, vnode, prevVnode) {
    console.log('beforeUpdate', binding);
  },
  
  // 在包含组件的 VNode 及其子组件的 VNode 更新后调用
  updated(el, binding, vnode, prevVnode) {
    console.log('updated', binding);
  },
  
  // 在绑定元素的父组件卸载前调用
  beforeUnmount(el, binding, vnode) {
    console.log('beforeUnmount', binding);
  },
  
  // 在绑定元素的父组件卸载后调用
  unmounted(el, binding, vnode) {
    console.log('unmounted', binding);
  }
};

binding 对象的属性

const binding = {
  value: 'directive value',        // 指令绑定的值
  oldValue: 'old value',            // 更新前的值
  arg: 'argName',                   // 指令参数
  modifiers: {                       // 修饰符对象
    prevent: true,
    stop: true
  },
  instance: componentInstance,       // 组件实例
  dir: directiveDefinition,          // 指令定义对象
  // 在 Vue 3.4+ 中新增
  modifiersKeys: ['prevent', 'stop'] // 修饰符数组
};

组件钩子函数的调用时机

组件钩子函数的调用时机

编译阶段的指令处理

指令的 AST 表示

我们来看一个比较复杂的自定义指令的例子:

<div v-custom:arg.mod1.mod2="value"></div>

这个例子对应的 AST 节点如下:

const elementNode = {
  type: 'Element',
  tag: 'div',
  props: [
    // 普通属性
    { name: 'class', value: 'container' },
    // 指令
    {
      type: 'Directive',
      name: 'custom',
      arg: 'arg',
      modifiers: ['mod1', 'mod2'],
      value: 'value',
      exp: {
        type: 'Expression',
        content: 'value'
      }
    }
  ]
};

指令的编译转换

/**
 * 指令转换插件
 */
const transformDirective = (node, context) => {
  if (node.type !== 'Element') return;
  
  if (!node.props) node.props = [];
  
  // 收集指令
  const directives = [];
  
  for (let i = node.props.length - 1; i >= 0; i--) {
    const prop = node.props[i];
    
    if (prop.type === 'Directive') {
      directives.push(prop);
      node.props.splice(i, 1); // 从props中移除
    }
  }
  
  if (directives.length === 0) return;
  
  // 为节点添加指令信息
  node.directives = directives.map(dir => ({
    name: dir.name,
    arg: dir.arg,
    modifiers: dir.modifiers,
    value: dir.value,
    exp: dir.exp
  }));
};

/**
 * 内置指令的转换
 */
const transformBuiltInDirectives = (node, context) => {
  if (!node.directives) return;
  
  for (const dir of node.directives) {
    switch (dir.name) {
      case 'if':
        transformVIf(node, dir, context);
        break;
      case 'for':
        transformVFor(node, dir, context);
        break;
      case 'model':
        transformVModel(node, dir, context);
        break;
      case 'show':
        transformVShow(node, dir, context);
        break;
      case 'on':
        transformVOn(node, dir, context);
        break;
      case 'bind':
        transformVBind(node, dir, context);
        break;
      // 自定义指令会保留,运行时处理
    }
  }
};

指令的代码生成

/**
 * 生成指令的运行时代码
 */
const genDirective = (dir, context) => {
  const { name, arg, modifiers, value } = dir;
  
  // 处理参数
  const argStr = arg ? `'${arg}'` : 'null';
  
  // 处理修饰符
  const modifiersObj = {};
  if (modifiers) {
    for (const mod of modifiers) {
      modifiersObj[mod] = true;
    }
  }
  
  // 生成指令对象
  return {
    name: `'${name}'`,
    value: `() => ${value}`,
    arg: argStr,
    modifiers: JSON.stringify(modifiersObj)
  };
};

/**
 * 生成节点上的所有指令
 */
const genDirectives = (node, context) => {
  if (!node.directives || node.directives.length === 0) return '';
  
  const dirs = node.directives.map(dir => genDirective(dir, context));
  
  return `directives: [${dirs.map(d => `{${Object.entries(d).map(([k, v]) => `${k}: ${v}`).join(', ')}}`).join(', ')}]`;
};

运行时的指令调用

指令调度器

/**
 * 运行时指令管理器
 */
class DirectiveManager {
  constructor() {
    this.directives = new Map(); // 全局指令
    this.instances = new WeakMap(); // 元素上的指令实例
  }
  
  /**
   * 注册指令
   */
  register(name, definition) {
    this.directives.set(name, definition);
  }
  
  /**
   * 获取指令定义
   */
  get(name) {
    return this.directives.get(name);
  }
  
  /**
   * 在元素上应用指令
   */
  applyDirectives(el, vnode) {
    const { directives } = vnode;
    if (!directives) return;
    
    const instances = [];
    
    for (const dir of directives) {
      const definition = this.get(dir.name);
      if (!definition) {
        console.warn(`指令 ${dir.name} 未注册`);
        continue;
      }
      
      // 创建指令实例
      const instance = {
        dir: definition,
        binding: this.createBinding(dir, vnode),
        vnode
      };
      
      instances.push(instance);
      
      // 调用 created 钩子
      if (definition.created) {
        definition.created(el, instance.binding, vnode);
      }
    }
    
    this.instances.set(el, instances);
  }
  
  /**
   * 创建 binding 对象
   */
  createBinding(dir, vnode) {
    return {
      value: dir.value ? dir.value() : undefined,
      oldValue: undefined,
      arg: dir.arg,
      modifiers: dir.modifiers || {},
      instance: vnode.component,
      dir: this.get(dir.name)
    };
  }
  
  /**
   * 更新指令
   */
  updateDirectives(oldVNode, newVNode) {
    const el = newVNode.el;
    const oldInstances = this.instances.get(el) || [];
    const newDirectives = newVNode.directives || [];
    
    // 创建新实例的映射
    const newInstances = [];
    const newDirMap = new Map();
    
    for (const dir of newDirectives) {
      newDirMap.set(dir.name, dir);
    }
    
    // 更新现有指令
    for (const oldInstance of oldInstances) {
      const newDir = newDirMap.get(oldInstance.dir.name);
      
      if (newDir) {
        // 指令仍然存在,更新 binding
        const oldBinding = oldInstance.binding;
        const newBinding = this.createBinding(newDir, newVNode);
        newBinding.oldValue = oldBinding.value;
        
        // 调用 beforeUpdate
        if (oldInstance.dir.beforeUpdate) {
          oldInstance.dir.beforeUpdate(el, newBinding, newVNode, oldInstance.vnode);
        }
        
        // 更新实例
        oldInstance.binding = newBinding;
        oldInstance.vnode = newVNode;
        newInstances.push(oldInstance);
        
        newDirMap.delete(oldInstance.dir.name);
      } else {
        // 指令被移除,调用 beforeUnmount
        if (oldInstance.dir.beforeUnmount) {
          oldInstance.dir.beforeUnmount(el, oldInstance.binding, oldInstance.vnode);
        }
      }
    }
    
    // 添加新指令
    for (const [name, dir] of newDirMap) {
      const definition = this.get(name);
      if (!definition) continue;
      
      const instance = {
        dir: definition,
        binding: this.createBinding(dir, newVNode),
        vnode: newVNode
      };
      
      // 调用 created
      if (definition.created) {
        definition.created(el, instance.binding, newVNode);
      }
      
      newInstances.push(instance);
    }
    
    this.instances.set(el, newInstances);
  }
  
  /**
   * 触发指令钩子
   */
  invokeHook(el, hookName, ...args) {
    const instances = this.instances.get(el);
    if (!instances) return;
    
    for (const instance of instances) {
      const hook = instance.dir[hookName];
      if (hook) {
        hook(el, instance.binding, ...args);
      }
    }
  }
}

// 创建全局指令管理器
const directiveManager = new DirectiveManager();

与渲染器的集成

/**
 * 在渲染器中集成指令
 */
class Renderer {
  patch(oldVNode, newVNode, container) {
    // ... 其他patch逻辑
    
    if (oldVNode && newVNode && oldVNode.el === newVNode.el) {
      // 更新指令
      directiveManager.updateDirectives(oldVNode, newVNode);
    }
  }
  
  mountElement(vnode, container, anchor) {
    const el = document.createElement(vnode.type);
    vnode.el = el;
    
    // 在挂载前调用指令钩子
    directiveManager.applyDirectives(el, vnode);
    
    // ... 其他挂载逻辑
    
    // 挂载后调用 mounted
    directiveManager.invokeHook(el, 'mounted');
  }
  
  unmount(vnode) {
    const el = vnode.el;
    
    // 调用 beforeUnmount
    directiveManager.invokeHook(el, 'beforeUnmount', vnode);
    
    // ... 卸载逻辑
    
    // 调用 unmounted
    directiveManager.invokeHook(el, 'unmounted');
  }
}

内置指令的编译实现

常见内置指令

内置指令 编译处理 运行时 示例
v-if 转为条件表达式 条件渲染 <div v-if="show">
v-for 转为renderList 循环渲染 <li v-for="item in list">
v-model 拆分为value+事件 双向绑定 <input v-model="text">
v-show 转为style控制 切换display <div v-show="visible">
v-on 转为事件绑定 事件监听 <button @click="fn">
v-bind 转为属性绑定 属性更新 <div :class="cls">
自定义指令 保留指令信息 调用钩子 <div v-custom>

v-if 的编译

function transformVIf(node, dir, context) {
  // 将元素转换为条件节点
  node.type = 'Conditional';
  node.condition = dir.value;
  node.consequent = node;
  
  // 查找相邻的 v-else-if 和 v-else
  let current = node;
  while (current.next) {
    const nextNode = current.next;
    const elseDir = nextNode.directives?.find(d => d.name === 'else-if' || d.name === 'else');
    
    if (elseDir) {
      if (elseDir.name === 'else-if') {
        // 转换为条件分支
        current.alternate = {
          type: 'Conditional',
          condition: elseDir.value,
          consequent: nextNode
        };
        current = current.alternate;
      } else {
        // v-else
        current.alternate = nextNode;
      }
      
      // 移除指令标记
      nextNode.directives = nextNode.directives?.filter(d => d.name !== 'else-if' && d.name !== 'else');
    } else {
      break;
    }
  }
}

/**
 * 生成 v-if 代码
 */
function genVIf(node) {
  if (node.type !== 'Conditional') return;
  
  let code = `ctx.${node.condition} ? `;
  code += genNode(node.consequent);
  code += ' : ';
  
  if (node.alternate) {
    if (node.alternate.type === 'Conditional') {
      code += genVIf(node.alternate);
    } else {
      code += genNode(node.alternate);
    }
  } else {
    code += 'null';
  }
  
  return code;
}

v-show 的编译

function transformVShow(node, dir, context) {
  // v-show 只是添加 style 控制
  if (!node.props) node.props = [];
  
  const styleProp = node.props.find(p => p.name === 'style');
  
  if (styleProp) {
    // 合并现有 style
    styleProp.value = `[${styleProp.value}, ctx.${dir.value} ? null : { display: 'none' }]`;
  } else {
    // 添加 style 属性
    node.props.push({
      name: 'style',
      value: `ctx.${dir.value} ? null : { display: 'none' }`
    });
  }
  
  // 移除 v-show 指令
  node.directives = node.directives?.filter(d => d.name !== 'show');
}

/**
 * 生成 v-show 代码(在 props 中体现)
 */
function genVShow(node) {
  // v-show 已经在 props 中处理,这里不需要额外生成
  return genNode(node);
}

v-model 的编译

function transformVModel(node, dir, context) {
  const value = dir.value;
  const modifiers = dir.modifiers || [];
  
  // 根据元素类型生成不同的事件和属性
  let propName = 'modelValue';
  let eventName = 'onUpdate:modelValue';
  
  if (node.tag === 'input') {
    if (modifiers.includes('number')) {
      // v-model.number
      return genNumberModel(value);
    } else if (modifiers.includes('trim')) {
      // v-model.trim
      return genTrimModel(value);
    }
  } else if (node.tag === 'select') {
    propName = 'modelValue';
    eventName = 'onUpdate:modelValue';
  } else if (node.tag === 'textarea') {
    propName = 'modelValue';
    eventName = 'onUpdate:modelValue';
  }
  
  // 添加 props
  if (!node.props) node.props = [];
  
  // 添加 value 绑定
  node.props.push({
    name: propName,
    value: `ctx.${value}`
  });
  
  // 添加事件绑定
  node.props.push({
    name: eventName,
    value: genUpdateHandler(value, modifiers)
  });
}

/**
 * 生成更新处理器
 */
function genUpdateHandler(value, modifiers) {
  let handler = `$event => ctx.${value} = $event`;
  
  if (modifiers.includes('number')) {
    handler = `$event => ctx.${value} = parseFloat($event)`;
  } else if (modifiers.includes('trim')) {
    handler = `$event => ctx.${value} = $event.trim()`;
  }
  
  if (modifiers.includes('lazy')) {
    handler = handler.replace('$event', '$event.target.value');
  }
  
  return handler;
}

/**
 * 生成数字输入模型
 */
function genNumberModel(value) {
  return {
    type: 'Directive',
    name: 'bind',
    arg: 'value',
    value: `ctx.${value}`
  }, {
    type: 'Directive',
    name: 'on',
    arg: 'input',
    value: `$event => ctx.${value} = $event.target.value ? parseFloat($event.target.value) : ''`
  };
}

/**
 * 生成修剪模型
 */
function genTrimModel(value) {
  return {
    type: 'Directive',
    name: 'bind',
    arg: 'value',
    value: `ctx.${value}`
  }, {
    type: 'Directive',
    name: 'on',
    arg: 'blur',
    value: `$event => ctx.${value} = $event.target.value.trim()`
  };
}

v-for 的编译

function transformVFor(node, dir, context) {
  // 解析 v-for 表达式 "item in list"
  const match = dir.value.match(/(.*?) in (.*)/);
  if (!match) return;
  
  const [, alias, source] = match;
  
  // 转换为 For 节点
  node.type = 'For';
  node.source = source.trim();
  node.alias = alias.trim();
  node.children = node.children || [];
  
  // 添加 key 处理
  const keyProp = node.props?.find(p => p.name === 'key' || p.name === ':key');
  if (!keyProp) {
    // 自动添加 key 建议
    console.warn('v-for 应该提供 key 属性');
  }
  
  // 移除 v-for 指令
  node.directives = node.directives?.filter(d => d.name !== 'for');
}

/**
 * 生成 v-for 代码
 */
function genVFor(node) {
  if (node.type !== 'For') return;
  
  const { source, alias, children } = node;
  
  return `renderList(ctx.${source}, (${alias}, index) => {
    return ${genNode(children[0])}
  })`;
}

自定义指令的编译处理

自定义指令的保留

/**
 * 处理自定义指令
 */
function transformCustomDirective(node, context) {
  if (!node.directives) return;
  
  // 保留自定义指令,运行时处理
  node.customDirectives = node.directives.filter(dir => {
    return !['if', 'for', 'model', 'show', 'on', 'bind'].includes(dir.name);
  });
  
  // 移除已处理的指令
  node.directives = node.directives.filter(dir => {
    return ['if', 'for', 'model', 'show', 'on', 'bind'].includes(dir.name);
  });
}

/**
 * 生成自定义指令代码
 */
function genCustomDirectives(node, context) {
  if (!node.customDirectives?.length) return '';
  
  const dirs = node.customDirectives.map(dir => {
    const { name, arg, modifiers, value } = dir;
    
    return {
      name: `'${name}'`,
      value: `() => ${value}`,
      arg: arg ? `'${arg}'` : 'null',
      modifiers: JSON.stringify(modifiers || {})
    };
  });
  
  return `directives: [${dirs.map(d => 
    `{${Object.entries(d).map(([k, v]) => `${k}: ${v}`).join(', ')}}`
  ).join(', ')}]`;
}

指令的参数和修饰符

/**
 * 解析指令参数和修饰符
 */
function parseDirective(name) {
  // 例如:v-on:click.prevent.stop
  const parts = name.split(':');
  const dirName = parts[0];
  
  let arg = parts[1] || '';
  let modifiers = [];
  
  // 解析修饰符
  if (arg.includes('.')) {
    const argParts = arg.split('.');
    arg = argParts[0];
    modifiers = argParts.slice(1);
  }
  
  return {
    name: dirName,
    arg,
    modifiers
  };
}

/**
 * 生成修饰符处理代码
 */
function genModifiers(modifiers) {
  const obj = {};
  for (const mod of modifiers) {
    obj[mod] = true;
  }
  return JSON.stringify(obj);
}

事件修饰符的实现

常用事件修饰符

通用事件修饰符

修饰符 作用 典型使用场景
.stop 阻止事件冒泡。 防止点击一个内部的按钮意外触发了外层容器的点击事件。
.prevent 阻止事件的默认行为。 自定义表单提交逻辑,或自定义链接行为。
.capture 使用事件捕获模式。 当你希望父元素能比子元素更早地捕获到事件时使用。
.self 只有当 event.target 是当前元素自身时,才触发事件处理函数。 严格区分是点击了元素本身还是其内部子元素的场景。
.once 事件将只会触发一次。 一次性操作,如首次点击的引导、支付按钮等,防止重复提交。
.passive 告诉浏览器你不想阻止事件的默认行为,从而提升性能。尤其适用于移动端的滚动事件(touchmove),能让滚动更流畅。 提升滚动性能,通常用于改善移动端设备的滚屏体验。

注:修饰符可以串联使用,比如 @click.stop.prevent 会同时阻止冒泡和默认行为。但需要注意顺序,因为相关代码会按顺序生成。

按键修饰符

按键修饰符专门用于监听键盘事件,方便监听按下了哪个键。Vue 为最常用的按键提供了别名,我们可以直接使用:

  • .enter (回车键)
  • .tab (制表键)
  • .delete (捕获“删除”和“退格”键)
  • .esc (退出键)
  • .space (空格键)
  • .up / .down / .left / .right (方向键)

鼠标按键修饰符

指定由特定鼠标按键触发的事件:

  • .left (鼠标左键)
  • .right (鼠标右键)
  • .middle (鼠标滚轮键)

运行时的事件处理

/**
 * 运行时事件绑定处理
 */
class EventManager {
  constructor() {
    this.eventHandlers = new WeakMap();
  }
  
  /**
   * 绑定事件
   */
  addEventListener(el, eventName, handler, options) {
    // 解析事件选项
    let useCapture = false;
    let isPassive = false;
    
    if (eventName.includes('!')) {
      useCapture = true;
      eventName = eventName.replace('!', '');
    }
    
    if (eventName.includes('~')) {
      isPassive = true;
      eventName = eventName.replace('~', '');
    }
    
    const eventOptions = {
      capture: useCapture,
      passive: isPassive
    };
    
    // 存储事件处理器
    if (!this.eventHandlers.has(el)) {
      this.eventHandlers.set(el, new Map());
    }
    
    const handlers = this.eventHandlers.get(el);
    handlers.set(eventName, { handler, options: eventOptions });
    
    // 绑定事件
    el.addEventListener(eventName, handler, eventOptions);
  }
  
  /**
   * 更新事件
   */
  updateEventListener(el, eventName, newHandler) {
    const handlers = this.eventHandlers.get(el);
    if (!handlers) return;
    
    const old = handlers.get(eventName);
    if (old) {
      el.removeEventListener(eventName, old.handler, old.options);
    }
    
    if (newHandler) {
      this.addEventListener(el, eventName, newHandler.handler, newHandler.options);
    }
  }
}

手写实现:完整指令系统

/**
 * 完整指令编译器
 */
class DirectiveCompiler {
  constructor() {
    this.builtInDirectives = new Set(['if', 'for', 'model', 'show', 'on', 'bind']);
  }
  
  /**
   * 编译模板中的指令
   */
  compile(template) {
    // 1. 解析AST
    const ast = this.parse(template);
    
    // 2. 转换AST
    this.transform(ast);
    
    // 3. 生成代码
    const code = this.generate(ast);
    
    return code;
  }
  
  /**
   * 解析模板
   */
  parse(template) {
    // 简化的解析逻辑
    const ast = {
      type: 'Root',
      children: []
    };
    
    // 解析元素和指令
    const elementRegex = /<(\w+)([^>]*)>/g;
    const directiveRegex = /v-(\w+)(?::(\w+))?(?:\.(\w+))?="([^"]*)"/g;
    
    // ... 解析逻辑
    
    return ast;
  }
  
  /**
   * 转换AST
   */
  transform(node) {
    if (node.type === 'Element') {
      // 提取指令
      const directives = [];
      
      if (node.attributes) {
        for (const attr of node.attributes) {
          const match = attr.name.match(/^v-(\w+)(?::(\w+))?(?:\.([\w.]+))?$/);
          if (match) {
            const [_, name, arg, modifiersStr] = match;
            const modifiers = modifiersStr ? modifiersStr.split('.') : [];
            
            directives.push({
              name,
              arg,
              modifiers,
              value: attr.value,
              exp: {
                type: 'Expression',
                content: attr.value
              }
            });
            
            // 移除原始属性
            node.attributes = node.attributes.filter(a => a !== attr);
          }
        }
      }
      
      if (directives.length > 0) {
        node.directives = directives;
        
        // 处理内置指令
        for (const dir of directives) {
          if (this.builtInDirectives.has(dir.name)) {
            this.processBuiltInDirective(node, dir);
          }
        }
        
        // 保留自定义指令
        node.customDirectives = directives.filter(
          dir => !this.builtInDirectives.has(dir.name)
        );
      }
      
      // 递归处理子节点
      if (node.children) {
        for (const child of node.children) {
          this.transform(child);
        }
      }
    }
  }
  
  /**
   * 处理内置指令
   */
  processBuiltInDirective(node, dir) {
    switch (dir.name) {
      case 'if':
        this.processVIf(node, dir);
        break;
      case 'for':
        this.processVFor(node, dir);
        break;
      case 'model':
        this.processVModel(node, dir);
        break;
      case 'show':
        this.processVShow(node, dir);
        break;
      case 'on':
        this.processVOn(node, dir);
        break;
      case 'bind':
        this.processVBind(node, dir);
        break;
    }
  }
  
  /**
   * 处理 v-if
   */
  processVIf(node, dir) {
    node.type = 'Conditional';
    node.condition = dir.value;
    node.consequent = { ...node };
    delete node.consequent.directives;
    delete node.consequent.customDirectives;
  }
  
  /**
   * 处理 v-for
   */
  processVFor(node, dir) {
    const match = dir.value.match(/(.*?) in (.*)/);
    if (match) {
      node.type = 'For';
      node.alias = match[1].trim();
      node.source = match[2].trim();
      node.iterator = node;
      delete node.iterator.directives;
      delete node.iterator.customDirectives;
    }
  }
  
  /**
   * 处理 v-model
   */
  processVModel(node, dir) {
    if (!node.props) node.props = [];
    
    node.props.push({
      name: 'modelValue',
      value: `ctx.${dir.value}`
    });
    
    node.props.push({
      name: 'onUpdate:modelValue',
      value: this.genUpdateHandler(dir)
    });
  }
  
  /**
   * 处理 v-show
   */
  processVShow(node, dir) {
    if (!node.props) node.props = [];
    
    const styleProp = node.props.find(p => p.name === 'style');
    if (styleProp) {
      styleProp.value = `[${styleProp.value}, ctx.${dir.value} ? null : { display: 'none' }]`;
    } else {
      node.props.push({
        name: 'style',
        value: `ctx.${dir.value} ? null : { display: 'none' }`
      });
    }
  }
  
  /**
   * 处理 v-on
   */
  processVOn(node, dir) {
    if (!node.props) node.props = [];
    
    const eventName = dir.arg;
    let handler = `ctx.${dir.value}`;
    
    // 应用修饰符
    if (dir.modifiers) {
      handler = this.applyModifiers(handler, dir.modifiers);
    }
    
    node.props.push({
      name: `on${eventName.charAt(0).toUpperCase() + eventName.slice(1)}`,
      value: handler
    });
  }
  
  /**
   * 处理 v-bind
   */
  processVBind(node, dir) {
    if (!node.props) node.props = [];
    
    node.props.push({
      name: dir.arg,
      value: `ctx.${dir.value}`
    });
  }
  
  /**
   * 应用修饰符
   */
  applyModifiers(handler, modifiers) {
    for (const mod of modifiers) {
      switch (mod) {
        case 'stop':
          handler = `$event => { $event.stopPropagation(); ${handler}($event) }`;
          break;
        case 'prevent':
          handler = `$event => { $event.preventDefault(); ${handler}($event) }`;
          break;
        case 'once':
          handler = `once(${handler})`;
          break;
      }
    }
    return handler;
  }
  
  /**
   * 生成更新处理器
   */
  genUpdateHandler(dir) {
    let handler = `$event => ctx.${dir.value} = $event`;
    
    if (dir.modifiers) {
      if (dir.modifiers.includes('number')) {
        handler = `$event => ctx.${dir.value} = parseFloat($event)`;
      }
      if (dir.modifiers.includes('trim')) {
        handler = `$event => ctx.${dir.value} = $event.trim()`;
      }
      if (dir.modifiers.includes('lazy')) {
        handler = handler.replace('$event', '$event.target.value');
      }
    }
    
    return handler;
  }
  
  /**
   * 生成代码
   */
  generate(node) {
    if (!node) return 'null';
    
    switch (node.type) {
      case 'Root':
        return this.generateRoot(node);
      case 'Element':
        return this.generateElement(node);
      case 'Conditional':
        return this.generateConditional(node);
      case 'For':
        return this.generateFor(node);
      default:
        return 'null';
    }
  }
  
  /**
   * 生成元素代码
   */
  generateElement(node) {
    const parts = ['createVNode'];
    
    // 标签
    parts.push(`'${node.tag}'`);
    
    // 属性
    if (node.props) {
      const propsObj = {};
      for (const prop of node.props) {
        propsObj[prop.name] = prop.value;
      }
      parts.push(JSON.stringify(propsObj));
    } else {
      parts.push('null');
    }
    
    // 子节点
    if (node.children) {
      const children = node.children.map(child => this.generate(child));
      if (children.length === 1) {
        parts.push(children[0]);
      } else {
        parts.push(`[${children.join(', ')}]`);
      }
    } else {
      parts.push('null');
    }
    
    // 自定义指令
    if (node.customDirectives?.length) {
      const dirs = node.customDirectives.map(dir => ({
        name: `'${dir.name}'`,
        value: `() => ${dir.value}`,
        arg: dir.arg ? `'${dir.arg}'` : 'null',
        modifiers: JSON.stringify(dir.modifiers || {})
      }));
      
      parts.push(JSON.stringify({
        directives: dirs
      }));
    }
    
    return `createVNode(${parts.join(', ')})`;
  }
  
  /**
   * 生成条件节点
   */
  generateConditional(node) {
    return `${node.condition} ? ${this.generate(node.consequent)} : null`;
  }
  
  /**
   * 生成循环节点
   */
  generateFor(node) {
    return `renderList(ctx.${node.source}, (${node.alias}, index) => ${this.generate(node.iterator)})`;
  }
}

结语

理解指令系统,不仅帮助我们更好地使用内置指令,也能创建强大的自定义指令,提升开发效率。指令系统是 Vue 声明式编程的重要体现,它将 DOM 操作封装成声明式的语法,让开发者可以专注于业务逻辑。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

AST转换:静态提升与补丁标志

作者 wuhen_n
2026年3月2日 13:25

在上一篇文章中,我们学习了模板编译的三个阶段。今天,我们将深入AST转换阶段的核心优:静态提升补丁标志。这两个优化是 Vue3 性能大幅提升的关键,它们让 Vue 在运行时能够跳过大量不必要的比较,实现精准更新。

前言:从一次渲染说起

想象一下,我们正在读一本电子书:这其中 99% 的内容是固定的,只有 1% 的页码会变化,这时候我们会怎么做:

  • 普通方式:每次变化时,重读整本书(Vue2的方式)
  • 优化方式:只重新读变化的页码(Vue3的方式)

这就是静态提升和补丁标志的核心思想:标记不变的内容,跳过重复工作。

静态节点标记(PatchFlags)

什么是补丁标志?

补丁标志是一个位掩码,用来标记节点的动态内容类型。它告诉渲染器:这个节点哪些部分是需要关注的变化点。

Vue3 中定义了丰富的补丁标志:

const PatchFlags = {
  TEXT: 1,                    // 动态文本内容
  CLASS: 1 << 1,              // 动态 class
  STYLE: 1 << 2,              // 动态 style
  PROPS: 1 << 3,              // 动态属性
  FULL_PROPS: 1 << 4,         // 全量比较
  HYDRATE_EVENTS: 1 << 5,     // 事件监听
  STABLE_FRAGMENT: 1 << 6,    // 稳定 Fragment
  KEYED_FRAGMENT: 1 << 7,     // 带 key 的 Fragment
  UNKEYED_FRAGMENT: 1 << 8,   // 无 key 的 Fragment
  NEED_PATCH: 1 << 9,         // 需要非 props 比较
  DYNAMIC_SLOTS: 1 << 10,     // 动态插槽
  
  HOISTED: -1,                // 静态提升节点
  BAIL: -2                    // 退出优化
};

位掩码的作用

位掩码可以用一个数字表示多个标记,以上述补丁标志为例,如果一个节点既有动态 class,又有动态 style,该怎么处理:

// 组合标记:class和style都是动态的
const combined = CLASS | STYLE;  // 110 = 6

动态内容的识别

编译器是如何识别哪些内容是动态的?其实编译器也是根据补丁标志来进行判断处理的,例如以下模板示例:

<div 
  class="static" 
  :class="dynamicClass"
  :style="dynamicStyle"
  id="static-id"
>
  <h1>静态标题</h1>
  <p>{{ dynamicText }}</p>
  <button @click="handler">点击</button>
</div>

通过编译后的标记:

// 编译后的标记
function render(ctx) {
  return createVNode('div', {
    class: ['static', ctx.dynamicClass],  // class部分是动态的
    style: ctx.dynamicStyle,               // style是动态的
    id: 'static-id'                        // id是静态的
  }, [
    createVNode('h1', null, '静态标题'),    // 完全静态
    createVNode('p', null, ctx.dynamicText, PatchFlags.TEXT),  // 只有文本动态
    createVNode('button', { 
      onClick: ctx.handler 
    }, '点击', PatchFlags.EVENTS)           // 只有事件动态
  ], PatchFlags.CLASS | PatchFlags.STYLE);  // div的class和style动态
}

如果没有标记,说明是静态节点,什么都不用做。

静态提升(HoistStatic)

静态提升的原理

静态提升是将完全静态的节点提取到渲染函数之外,避免每次渲染都重新创建,还是以上一节的代码为例:

const _hoisted_1 = createVNode('h1', null, '静态标题', PatchFlags.HOISTED);

function render(ctx) {
  return createVNode('div', null, [
    _hoisted_1,  // 直接复用
    createVNode('p', null, ctx.dynamicText, PatchFlags.TEXT)
  ]);
}

静态节点的判定规则

当一个节点同时满足以下条件时,这时我们就判定它为静态节点

  1. 没有动态绑定:不存在双向绑定 v-model(简写:)、v-bindv-on
  2. 没有指令:不存在 v-ifv-forv-slot 等指令
  3. 没有插值:不存在 {{ }} 等插值语句
  4. 所有子节点也都是静态的

静态提升的深度

Vue3 不仅提升顶层静态节点,还会提升深层静态节点:

<template>
  <div>
    <div>  <!-- 这个div不是静态的,因为它有动态子节点 -->
      <span>完全静态</span>  <!-- 但这个span是静态的,会被提升 -->
      <span>{{ text }}</span>
    </div>
    <div class="static">  <!-- 这个div是静态的,会被提升 -->
      <span>静态1</span>
      <span>静态2</span>
    </div>
  </div>
</template>

动态节点收集

Block的概念

Block 是Vue3中一个重要的优化概念,它会收集当前模板中的所有动态节点。通常情况下,我们会约定组件模版的根节点作为 Block 角色,从根节点开始,所有动态子代节点都会被收集到根节点的 dynamicChildren 数组中,以此来形成一颗 Block Tree

到了这里,也许会有人问:如果我的 Vue 组件模板中,都是静态节点,不存在动态节点呢? 这种情况也是存在的,这种情况下,就只存在根节点一个 Block,无法形成树,因此也不用额外处理。

Block Tree

Block 会收集所有后代动态节点,形成动态节点树 Block Tree。我们来看下面一个模板代码示例:

<div>  <!-- 这是Block -->
  <span>静态</span>
  <p :class="dynamic">动态1</p>
  <div>
    <span>静态</span>
    <span>{{ text }}</span>  <!-- 动态2 -->
  </div>
</div>

这段代码完整转成树形结构应该是这样的: 完整树形结构 只收集动态节点,形成的动态节点树: 动态节点树结构

更新时的优化

有了动态节点树,更新时只需要遍历 dynamicChildren

function patchChildren(oldNode, newNode, container) {
  if (newNode.dynamicChildren) {
    // 只更新动态节点
    for (let i = 0; i < newNode.dynamicChildren.length; i++) {
      patch(
        oldNode.dynamicChildren[i],
        newNode.dynamicChildren[i],
        container
      );
    }
  } else {
    // 没有动态节点,说明是完全静态,什么都不用做
  }
}

节点转换器的设计

转换器的整体架构

/**
 * AST转换器
 */
class ASTTransformer {
  constructor(ast, options = {}) {
    this.ast = ast;
    this.options = options;
    this.context = {
      currentNode: null,
      parent: null,
      staticNodes: new Set(),
      dynamicNodes: new Set(),
      patchFlags: new Map(),
      hoisted: [],        // 提升的静态节点
      replaceNode: (node) => {
        // 替换当前节点
      },
      removeNode: () => {
        // 删除当前节点
      }
    };
  }
  
  /**
   * 执行转换
   */
  transform() {
    // 1. 遍历AST,标记静态节点
    this.traverse(this.ast);
    
    // 2. 计算补丁标志
    this.computePatchFlags();
    
    // 3. 提取静态节点
    this.hoistStatic();
    
    return this.ast;
  }
  
  /**
   * 遍历AST
   */
  traverse(node, parent = null) {
    if (!node) return;
    
    this.context.currentNode = node;
    this.context.parent = parent;
    
    // 应用所有转换插件
    for (const plugin of this.plugins) {
      plugin(node, this.context);
    }
    
    // 递归处理子节点
    if (node.children) {
      for (const child of node.children) {
        this.traverse(child, node);
      }
    }
  }
}

静态节点检测插件

/**
 * 静态节点检测插件
 */
const detectStaticPlugin = (node, context) => {
  if (node.type === 'Element') {
    // 检查是否有动态绑定
    const hasDynamic = checkDynamic(node);
    
    if (!hasDynamic) {
      // 检查所有子节点
      const childrenStatic = node.children?.every(child => 
        context.staticNodes.has(child) || child.type === 'Text'
      ) ?? true;
      
      if (childrenStatic) {
        context.staticNodes.add(node);
        node.isStatic = true;
      }
    }
  } else if (node.type === 'Text') {
    // 文本节点默认是静态的
    node.isStatic = true;
  }
};

/**
 * 检查节点是否包含动态内容
 */
function checkDynamic(node) {
  if (!node.props) return false;
  
  for (const prop of node.props) {
    // 检查指令
    if (prop.name.startsWith('v-') || prop.name.startsWith('@') || prop.name.startsWith(':')) {
      return true;
    }
    
    // 检查动态属性值
    if (prop.value && prop.value.includes('{{')) {
      return true;
    }
  }
  
  return false;
}

补丁标志计算插件

/**
 * 补丁标志计算插件
 */
const patchFlagPlugin = (node, context) => {
  if (node.type !== 'Element' || node.isStatic) return;
  
  let patchFlag = 0;
  const dynamicProps = [];
  
  if (node.props) {
    for (const prop of node.props) {
      if (prop.name === 'class' && isDynamic(prop)) {
        patchFlag |= PatchFlags.CLASS;
        dynamicProps.push('class');
      } else if (prop.name === 'style' && isDynamic(prop)) {
        patchFlag |= PatchFlags.STYLE;
        dynamicProps.push('style');
      } else if (prop.name.startsWith('@')) {
        patchFlag |= PatchFlags.EVENTS;
        dynamicProps.push(prop.name.slice(1));
      } else if (prop.name.startsWith(':')) {
        patchFlag |= PatchFlags.PROPS;
        dynamicProps.push(prop.name.slice(1));
      }
    }
  }
  
  // 检查文本内容
  if (node.children) {
    for (const child of node.children) {
      if (child.type === 'Interpolation') {
        patchFlag |= PatchFlags.TEXT;
        break;
      }
    }
  }
  
  if (patchFlag) {
    node.patchFlag = patchFlag;
    node.dynamicProps = dynamicProps;
    context.dynamicNodes.add(node);
  }
};

/**
 * 判断属性是否为动态
 */
function isDynamic(prop) {
  return prop.value && (
    prop.value.includes('{{') ||
    prop.value.startsWith('_ctx.') ||
    prop.value.includes('$event')
  );
}

静态提升插件

/**
 * 静态提升插件
 */
const hoistStaticPlugin = (node, context) => {
  if (node.type === 'Element' && node.isStatic) {
    // 生成唯一的变量名
    const hoistName = `_hoisted_${context.hoisted.length + 1}`;
    
    // 存储到提升列表
    context.hoisted.push({
      name: hoistName,
      node: node
    });
    
    // 替换为变量引用
    const replacement = {
      type: 'HoistReference',
      name: hoistName,
      original: node
    };
    
    context.replaceNode(replacement);
  }
};

/**
 * 生成提升的代码
 */
function generateHoisted(hoisted) {
  let code = '';
  
  for (const { name, node } of hoisted) {
    code += `\nconst ${name} = createVNode(`;
    code += `'${node.tag}', `;
    code += generateProps(node.props);
    code += `, ${generateChildren(node.children)}`;
    code += `, PatchFlags.HOISTED);\n`;
  }
  
  return code;
}

常量提升原理

常量的识别

除了静态节点外,常量表达式也会被提升,我们来看下面一个模板示例:

<div>
  <p>{{ 1 + 2 }}</p>  <!-- 常量表达式 -->
</div>

{{ 1 + 2 }} 是一个常量表达式,它在编译时,也会提升:

const _hoisted_1 = 1 + 2;  // 常量表达式提升

function render(ctx) {
  return createVNode('div', null, [
    createVNode('p', null, _hoisted_1, PatchFlags.TEXT),
    createVNode('p', null, ctx.message, PatchFlags.TEXT)
  ]);
}

常量检测的实现

/**
 * 常量检测插件
 */
const constantDetectPlugin = (node, context) => {
  if (node.type === 'Interpolation') {
    // 检查表达式是否为常量
    if (isConstantExpression(node.content)) {
      node.isConstant = true;
      
      // 生成常量名
      const constantName = `_constant_${context.constants.length + 1}`;
      context.constants.push({
        name: constantName,
        value: node.content
      });
      
      // 替换为常量引用
      context.replaceNode({
        type: 'ConstantReference',
        name: constantName
      });
    }
  }
};

/**
 * 判断表达式是否为常量
 */
function isConstantExpression(expr) {
  // 简单判断:只包含字面量和算术运算符
  const constantPattern = /^[\d\s\+\-\*\/\(\)]+$/;
  return constantPattern.test(expr);
}

缓存内联事件处理函数

事件处理函数的问题

在 JavaScript 中,每次重新渲染都会创建新的函数,如以下模板示例:

<template>
  <button @click="() => count++">点击</button>
</template>

在每次渲染时,都会创建新函数:

function render(ctx) {
  return createVNode('button', {
    onClick: () => ctx.count++  // 每次都不同
  }, '点击');
}

这么处理会有什么问题呢?在每次渲染时,都会为 button 创建一个全新的事件处理对象,里面的 onClick 也会是一个全新的函数。这就会导致渲染器每次渲染都会进行一次更新,造成额外的性能浪费。

事件缓存机制

为了解决上述问题,Vue3 采用了事件缓存机制,对内联事件处理函数进行缓存:

function render(ctx, _cache) {
  return createVNode('button', {
    onClick: _cache[0] || (_cache[0] = ($event) => ctx.count++)
  }, '点击');
}

缓存插件的实现

/**
 * 事件缓存插件
 */
const cacheEventHandlerPlugin = (node, context) => {
  if (node.type === 'Element' && node.props) {
    let cacheIndex = 0;
    
    for (let i = 0; i < node.props.length; i++) {
      const prop = node.props[i];
      
      if (prop.name.startsWith('@') || prop.name === 'onClick') {
        // 生成缓存代码
        const eventName = prop.name.replace(/^@|^on/, '').toLowerCase();
        const handler = prop.value;
        
        prop.cached = true;
        prop.cacheIndex = cacheIndex++;
        prop.cachedCode = `_cache[${prop.cacheIndex}] || (_cache[${prop.cacheIndex}] = $event => ${handler})`;
      }
    }
  }
};

/**
 * 生成事件缓存代码
 */
function generateEventCode(node, context) {
  if (!node.props) return 'null';
  
  const propsObj = {};
  
  for (const prop of node.props) {
    if (prop.cached) {
      // 使用缓存
      propsObj[prop.name] = prop.cachedCode;
    } else {
      // 普通属性
      propsObj[prop.name] = prop.value;
    }
  }
  
  return JSON.stringify(propsObj);
}

结语

静态提升和补丁标志是 Vue3 性能优化的两大法宝,它们让 Vue 能够在运行时精准地只更新变化的部分。理解这些优化,不仅帮助我们写出更高效的代码,也让我们对 Vue 的设计哲学有更深的理解。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

性能优化之实战指南:让你的 Vue 应⽤跑得飞起

作者 destinying
2026年3月2日 13:27

Vue 性能优化实战指南:让你的 Vue 应⽤跑得飞起

1. 列表项 key 属性:被你误解最深的 Vue 知识点

兄弟们,key 这个属性估计是 Vue 里被误解最多的东⻄了。很多同学以为随便给个 index 就完事了,结果性能炸裂还不知道为啥。

1.1 key 的作⽤到底是什么?

Vue 的虚拟 DOM diff 算法通过 key 来判断节点是否可以复用。没有 key 或者 key 重复,Vue 会强制复用 DOM,导致性能下降甚至状态混乱。

<!-- ❌ 错误:用 index 做 key -->
<template>
  <div>
    <div v-for="(item, index) in list" :key="index">
      {{ item.name }}
      <input v-model="item.value" />
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      list: [
        { name: '张三', value: '' },
        { name: '李四', value: '' },
        { name: '王五', value: '' }
      ]
    }
  }
}
</script>

问题: 当你删除第一个元素时,Vue 会"以为"后面的元素只是变了位置,于是把第二个元素的 DOM 复用给第一个,第三个复用给第二个...结果输入框里的值全乱了!

<!-- ✅ 正确:用唯一标识做 key -->
<template>
  <div>
    <div v-for="item in list" :key="item.id">
      {{ item.name }}
      <input v-model="item.value" />
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      list: [
        { id: 1, name: '张三', value: '' },
        { id: 2, name: '李四', value: '' },
        { id: 3, name: '王五', value: '' }
      ]
    }
  }
}
</script>

1.2 什么时候必须用 key?

<!-- 1. v-for 必须用 -->
<template>
  <div v-for="item in list" :key="item.id">{{ item.name }}</div>
</template>

<!-- 2. 条件渲染多个元素时建议用 -->
<template>
  <div v-if="showForm" :key="1">表单A</div>
  <div v-else :key="2">表单B</div>
</template>

1.3 key 选择指南

// ✅ 好的 key
:key="item.id"              // 唯一标识,最佳选择
:key="item.uuid"            // 如果有 UUID 更好
:key="`${item.type}_${item.id}`"  // 组合唯一标识

// ❌ 不好的 key
:key="index"                // 列表会出问题
:key="Math.random()"        // 每次都变,失去复用意义
:key="item.name"            // 可能重复

1.4 小贴士

  • 列表只有渲染,不会增删改查,用 index 也问题不大
  • 列表会动态变化,必须用唯一标识
  • 表格、聊天、购物车这种场景,key 选错了会出大问题
  • 调试时可以用 Vue DevTools 看 diff 结果,key 对不对一目了然

2. 架构级优化:从源头解决性能问题

前面讲的都是"术",现在讲"道"。架构级优化能让你的应用从根本上快起来。

2.1 代码分割:把大蛋糕切成小块

现代打包工具(Webpack、Vite)都支持代码分割,把代码拆成多个小块,按需加载。

2.1.1 路由级别代码分割

这是最常见的优化方式,每个路由一个 chunk。

// ❌ 一次性加载所有路由组件
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
import Profile from '@/views/Profile.vue'
import Settings from '@/views/Settings.vue'

const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About },
  { path: '/profile', component: Profile },
  { path: '/settings', component: Settings }
]
// ✅ 路由懒加载
const routes = [
  {
    path: '/',
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/about',
    component: () => import('@/views/About.vue')
  },
  {
    path: '/profile',
    component: () => import('@/views/Profile.vue')
  },
  {
    path: '/settings',
    component: () => import('@/views/Settings.vue')
  }
]

打包效果:

  • 首屏只加载 home.js
  • 用户访问 /about 时才加载 about.js
  • 首屏体积从 2MB 降到 300KB,首屏时间缩短 60%+
2.1.2 组件级别代码分割

某些大型组件(如富文本编辑器、图表库)可以按需加载。

<template>
  <div>
    <button @click="showEditor = true">打开编辑器</button>

    <!-- 条件加载大型组件 -->
    <Editor v-if="showEditor" @close="showEditor = false" />
  </div>
</template>

<script>
export default {
  components: {
    Editor: () => import('@/components/Editor.vue')
  },
  data() {
    return {
      showEditor: false
    }
  }
}
</script>
2.1.3 动态导入

更灵活的按需加载方式。

// 点击按钮时才加载某个模块
async function loadFeature() {
  if (needsAdvancedFeatures) {
    const { default: AdvancedModule } = await import('@/features/advanced')
    AdvancedModule.init()
  }
}

// 根据条件加载不同的实现
async function getChartLibrary() {
  if (useECharts) {
    const echarts = await import('echarts')
    return echarts
  } else {
    const chartjs = await import('chart.js')
    return chartjs
  }
}
2.1.4 第三方库分割

某些第三方库可以单独打包。

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          priority: 10
        },
        elementUI: {
          test: /[\\/]node_modules[\\/]element-ui[\\/]/,
          name: 'elementUI',
          priority: 20
        },
        commons: {
          name: 'commons',
          minChunks: 2,
          priority: 5,
          reuseExistingChunk: true
        }
      }
    }
  }
}

2.2 路由级别优化

除了代码分割,路由本身也有优化空间。

2.2.1 路由懒加载 + 预加载
// 路由配置
const routes = [
  {
    path: '/',
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/about',
    component: () => import(/* webpackPrefetch: true */ '@/views/About.vue')
  },
  {
    path: '/profile',
    component: () => import(/* webpackPreload: true */ '@/views/Profile.vue')
  }
]

区别:

  • webpackPrefetch:空闲时预加载,适合"可能访问"的路由
  • webpackPreload:立即预加载,适合"即将访问"的路由
2.2.2 路由组件缓存

使用 keep-alive 缓存路由组件,避免重复渲染。

<template>
  <div id="app">
    <!-- 缓存所有路由组件 -->
    <keep-alive>
      <router-view />
    </keep-alive>

    <!-- 或者只缓存特定路由 -->
    <keep-alive :include="['Home', 'Profile']">
      <router-view />
    </keep-alive>

    <!-- 排除某些路由 -->
    <keep-alive :exclude="['Login', 'Register']">
      <router-view />
    </keep-alive>
  </div>
</template>
// 组件内配合使用
export default {
  name: 'Home',  // 必须有 name 才能被 include/exclude 匹配
  data() {
    return {
      list: []
    }
  },
  activated() {
    // 从缓存恢复时调用
    console.log('组件被激活')
    this.fetchData()
  },
  deactivated() {
    // 组件被缓存时调用
    console.log('组件被停用')
  }
}
2.2.3 路由守卫优化
// ❌ 重复获取数据
router.beforeEach(async (to, from, next) => {
  // 每次导航都获取用户信息
  const user = await fetchUser()
  next()
})

// ✅ 缓存用户信息
let cachedUser = null
let lastFetchTime = 0
const CACHE_DURATION = 5 * 60 * 1000 // 5分钟

router.beforeEach(async (to, from, next) => {
  const now = Date.now()

  if (!cachedUser || now - lastFetchTime > CACHE_DURATION) {
    cachedUser = await fetchUser()
    lastFetchTime = now
  }

  next()
})

2.3 状态管理优化

2.3.1 Vuex 模块化
// ❌ 所有的 state 都在一个大对象里
const store = new Vuex.Store({
  state: {
    user: {},
    products: [],
    cart: [],
    orders: [],
    settings: {},
    // ... 越来越多
  }
})
// ✅ 模块化管理
const user = {
  namespaced: true,
  state: () => ({ currentUser: null }),
  mutations: { SET_USER(state, user) { state.currentUser = user } },
  actions: { async fetchUser({ commit }) { /* ... */ } }
}

const products = {
  namespaced: true,
  state: () => ({ list: [] }),
  mutations: { SET_PRODUCTS(state, list) { state.list = list } }
}

const store = new Vuex.Store({
  modules: { user, products, cart, orders }
})
2.3.2 按需注册模块
// 动态注册模块
router.beforeEach(async (to, from, next) => {
  if (to.matched.some(record => record.meta.requiresAdmin)) {
    await store.registerModule('admin', adminModule)
  }
  next()
})

// 离开时卸载模块
router.afterEach((to, from) => {
  if (!to.matched.some(record => record.meta.requiresAdmin)) {
    if (store.hasModule('admin')) {
      store.unregisterModule('admin')
    }
  }
})

2.4 组件设计原则

2.4.1 组件粒度
<!-- ❌ 组件太大,职责不清 -->
<template>
  <div class="user-list">
    <div v-for="user in users" :key="user.id">
      <img :src="user.avatar">
      <div>{{ user.name }}</div>
      <div>{{ user.email }}</div>
      <button @click="follow(user)">关注</button>
      <button @click="block(user)">拉黑</button>
      <button @click="sendMessage(user)">发消息</button>
    </div>
  </div>
</template>
<!-- ✅ 拆分成多个小组件 -->
<template>
  <UserList :users="users">
    <template #default="{ user }">
      <UserCard :user="user">
        <template #actions>
          <UserActions :user="user" />
        </template>
      </UserCard>
    </template>
  </UserList>
</template>

<!-- UserCard.vue -->
<template>
  <div class="user-card">
    <Avatar :src="user.avatar" />
    <UserInfo :name="user.name" :email="user.email" />
    <slot name="actions" />
  </div>
</template>

<!-- UserActions.vue -->
<template>
  <div class="actions">
    <button @click="$emit('follow')">关注</button>
    <button @click="$emit('block')">拉黑</button>
    <button @click="$emit('message')">发消息</button>
  </div>
</template>
2.4.2 避免不必要的渲染
<template>
  <div>
    <!-- ❌ 每次父组件更新都会重新渲染 -->
    <ExpensiveComponent :data="heavyData" />

    <!-- ✅ 使用计算属性缓存 -->
    <ExpensiveComponent :data="processedData" />

    <!-- ✅ 使用 v-once 只渲染一次 -->
    <div v-once>{{ staticContent }}</div>

    <!-- ✅ 使用 shouldComponentUpdate(Vue 2)或 computed(Vue 3) -->
    <ExpensiveComponent v-if="shouldRender" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      heavyData: largeData,
      someOtherData: []
    }
  },
  computed: {
    processedData() {
      return this.heavyData.map(item => ({
        ...item,
        formatted: this.format(item)
      }))
    },
    shouldRender() {
      return this.heavyData.length > 0
    }
  },
  methods: {
    format(item) {
      // 昂贵的计算
      return item.value.toFixed(2)
    }
  }
}
</script>

3. 服务端渲染 SSR:SEO 和首屏性能的双刃剑

SSR(Server-Side Rendering)能在服务器端渲染 Vue 组件,直接返回 HTML,对 SEO 和首屏加载都有巨大提升。

3.1 SSR vs CSR

对比项 CSR(客户端渲染) SSR(服务端渲染)
SEO ❌ 搜索引擎爬虫难以抓取 ✅ 直接返回 HTML,SEO 友好
首屏时间 ⚠️ 需要加载 JS 后才能渲染 ✅ 首屏直接显示 HTML
服务器压力 ✅ 低,只提供静态资源 ⚠️ 高,需要渲染页面
开发复杂度 ✅ 简单 ⚠️ 复杂,需要考虑同构
交互响应 ✅ 客户端即时响应 ⚠️ 需要注水(hydration)

3.2 Nuxt.js 快速上手

Nuxt.js 是 Vue 的 SSR 框架,开箱即用。

# 创建 Nuxt 项目
npx create-nuxt-app my-app

cd my-app
npm run dev
3.2.1 页面自动路由
pages/
├── index.vue          # / 路由
├── about.vue          # /about 路由
└── users/
    ├── index.vue      # /users 路由
    └── _id.vue       # /users/:id 路由
3.2.2 数据获取
<!-- pages/index.vue -->
<template>
  <div>
    <div v-if="loading">加载中...</div>
    <div v-else>
      <h1>{{ post.title }}</h1>
      <p>{{ post.content }}</p>
    </div>
  </div>
</template>

<script>
export default {
  // 服务器端渲染前获取数据
  async asyncData({ params, $axios }) {
    const post = await $axios.$get(`/api/posts/${params.id}`)
    return { post }
  },

  // 或者在客户端获取数据
  async fetch({ store, $axios }) {
    const posts = await $axios.$get('/api/posts')
    store.commit('posts/SET_POSTS', posts)
  },

  data() {
    return {
      loading: false,
      post: {}
    }
  }
}
</script>
3.2.3 SEO 优化
<template>
  <div>
    <h1>{{ post.title }}</h1>
  </div>
</template>

<script>
export default {
  async asyncData({ $axios, params }) {
    const post = await $axios.$get(`/api/posts/${params.id}`)
    return { post }
  },

  head() {
    return {
      title: this.post.title,
      meta: [
        { hid: 'description', name: 'description', content: this.post.excerpt },
        { hid: 'og:title', property: 'og:title', content: this.post.title },
        { hid: 'og:image', property: 'og:image', content: this.post.image }
      ]
    }
  }
}
</script>

3.3 Vue SSR 手动配置

如果你不想用 Nuxt,可以手动配置 Vue SSR。

3.3.1 服务端入口
// server.js
const express = require('express')
const { createSSRApp } = require('vue')
const { renderToString } = require('@vue/server-renderer')

const server = express()

server.get('*', async (req, res) => {
  const app = createSSRApp({
    data: () => ({ url: req.url }),
    template: `<div>访问的 URL 是:{{ url }}</div>`
  })

  const appContent = await renderToString(app)

  const html = `
    <!DOCTYPE html>
    <html>
      <head><title>Vue SSR</title></head>
      <body>
        <div id="app">${appContent}</div>
      </body>
    </html>
  `

  res.end(html)
})

server.listen(3000)
3.3.2 客户端入口
// client.js
import { createSSRApp } from 'vue'
import { createApp } from 'vue'

const app = createSSRApp({
  data: () => ({ url: window.location.pathname }),
  template: `<div>访问的 URL 是:{{ url }}</div>`
})

app.mount('#app')

3.4 静态站点生成(SSG)

如果你的内容是静态的,可以用静态站点生成,比 SSR 更简单。

// nuxt.config.js
export default {
  // 启用静态生成
  generate: {
    routes: ['/post/1', '/post/2', '/post/3']
  }
}

// 或者动态生成
export default {
  generate: {
    async routes() {
      const posts = await fetchPosts()
      return posts.map(post => `/post/${post.id}`)
    }
  }
}

3.5 SSR 性能优化

3.5.1 缓存渲染结果
const LRU = require('lru-cache')
const ssrCache = new LRU({
  max: 1000,
  maxAge: 1000 * 60 * 15 // 15分钟
})

async function renderPage(url) {
  // 检查缓存
  const cached = ssrCache.get(url)
  if (cached) {
    return cached
  }

  // 渲染页面
  const html = await renderToString(app)

  // 缓存结果
  ssrCache.set(url, html)

  return html
}
3.5.2 流式渲染
const { renderToStream } = require('@vue/server-renderer')

server.get('*', async (req, res) => {
  const stream = renderToStream(app)

  res.write('<!DOCTYPE html><html><head>...')

  // 流式输出
  stream.pipe(res, { end: false })

  stream.on('end', () => {
    res.end('</html>')
  })
})
3.5.3 避免在服务端执行客户端代码
<template>
  <div>
    <!-- ❌ 服务端没有 window -->
    <div>{{ window.innerWidth }}</div>

    <!-- ✅ 使用 process.client 判断 -->
    <div v-if="process.client">{{ window.innerWidth }}</div>
    <div v-else>服务端渲染</div>

    <!-- ✅ 或者在 mounted 中获取 -->
    <div>{{ screenWidth }}</div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      screenWidth: 0
    }
  },
  mounted() {
    // mounted 只在客户端执行
    this.screenWidth = window.innerWidth
  }
}
</script>

3.6 SSR 踩过的坑

3.6.1 状态同步问题
// ❌ 服务端和客户端状态不一致
export default {
  async asyncData() {
    // 服务端获取数据
    const data = await fetchData()
    return { data }
  },
  mounted() {
    // 客户端又获取一次,可能导致冲突
    this.fetchData()
  }
}

// ✅ 统一状态管理
export default {
  async asyncData({ store }) {
    await store.dispatch('fetchData')
    return { data: store.state.data }
  },
  computed: {
    data() {
      return this.$store.state.data
    }
  }
}
3.6.2 Cookie 处理
// ❌ 服务端访问不到 document.cookie
async function fetchUser() {
  const cookie = document.cookie // 报错
}

// ✅ 通过上下文传递 cookie
async function fetchUser(context) {
  const cookie = context.req.headers.cookie
  // 使用 cookie 发送请求
}
3.6.3 异步组件处理
<template>
  <div>
    <!-- SSR 时异步组件不会渲染 -->
    <AsyncComponent />
  </div>
</template>

<script>
export default {
  components: {
    // ✅ 使用 SSR 友好的异步组件
    AsyncComponent: defineAsyncComponent({
      loader: () => import('./AsyncComponent.vue'),
      loadingComponent: LoadingComponent,
      errorComponent: ErrorComponent,
      delay: 200,
      timeout: 3000
    })
  }
}
</script>

3.7 是否需要 SSR?

需要 SSR 的情况:

  • 内容需要 SEO(博客、新闻、电商)
  • 首屏加载时间要求极高
  • 社交媒体分享需要预览卡片

不需要 SSR 的情况:

  • 内部管理系统
  • 社交媒体应用(如 Twitter)
  • 游戏或富交互应用

总结

Vue 性能优化是一个系统工程,需要从多个层面入手:

  1. key 属性要选对,用唯一标识,别用 index
  2. 代码分割是标配,路由懒加载、组件按需加载
  3. 架构设计要合理,模块化、职责单一、避免过度渲染
  4. SSR 看场景使用,SEO 和首屏是刚需就上,否则别自找麻烦
  5. 监控要跟上,用 Vue DevTools、Lighthouse、Web Vitals 持续优化

最后,如果你觉得这篇⽂章对你有帮助,点个赞呗!如果觉得有问题,评论区喷我,我抗揍。

揭秘JavaScript中那些“不冒泡”的DOM事件

作者 大知闲闲i
2026年3月2日 11:51

在前端开发中,DOM事件流(捕获阶段→目标阶段→冒泡阶段)是核心基础之一。我们熟知的clickkeydownmouseover等事件,都会在触发后从目标元素向上冒泡至父元素、document甚至window,这也是事件委托的核心原理。

但并非所有事件都遵循这一规则——有些事件仅在触发的目标元素上生效,不会向上传播,也就是所谓的**“不冒泡的事件”**。本文将系统梳理这类事件,解析其特性与应用场景,帮你避开开发中的常见坑。

一、为什么有些事件不冒泡?

事件是否冒泡,本质是由其设计初衷决定的:

  • 冒泡事件的核心是“通用交互”(如点击、鼠标移动),允许父元素统一处理子元素的同类事件,便于实现事件委托;

  • 不冒泡事件多与“元素专属状态/资源加载”相关(如焦点、资源加载),这类事件的影响范围仅局限于目标元素本身,向上传播无实际意义。

例如focus(获取焦点)事件,仅对输入框、按钮等可交互元素有意义。若允许冒泡,父元素会无差别接收到所有子元素的焦点变化,反而增加不必要的性能开销和逻辑混乱。

二、常见的不冒泡事件及解析

1. focus / blur:焦点相关事件(核心不冒泡事件)

  • focus:元素获得焦点时触发(如点击输入框、按Tab键切换焦点);

  • blur:元素失去焦点时触发(如点击输入框外区域、切换到其他元素)。

这两个是最典型的不冒泡事件,也是前端开发中最常接触的“非冒泡事件”。

开发提醒:若想监听子元素的焦点变化,不能依赖focus/blur的冒泡,可使用其“冒泡版替代事件”——focusin(对应focus)和focusout(对应blur),这两个事件会正常冒泡,是focus/blur的官方替代方案。

示例代码:

// ❌ 错误:父元素无法捕获子元素的focus事件(不冒泡)
document.querySelector('.parent').addEventListener('focus', () => {
  console.log('父元素捕获focus'); // 不会执行
});

// ✅ 正确:用focusin监听(冒泡)
document.querySelector('.parent').addEventListener('focusin', () => {
  console.log('子元素获取焦点'); // 正常执行
});

2. load / unload:资源加载相关事件

  • load:资源加载完成时触发,常见于imgscriptaudiovideo等元素,仅在目标元素上触发,不会冒泡

  • unload:页面/资源即将被卸载时触发(如关闭标签页、导航到其他页面),仅绑定在window或目标元素上生效,无冒泡行为

注意window.onload是页面所有资源加载完成的事件,虽绑定在window上,但本质也不属于“冒泡事件”——它是全局事件,没有传播对象。

示例代码:

// img加载完成事件(仅在img本身触发)
const img = document.querySelector('img');
img.addEventListener('load', () => {
  console.log('图片加载完成'); // 正常执行
});

// ❌ 父元素无法捕获img的load事件(不冒泡)
document.body.addEventListener('load', () => {
  console.log('捕获图片load'); // 不会执行
});

3. stop:媒体播放相关事件

stop事件仅在audio/video等媒体元素上触发,当媒体播放被主动停止时生效。该事件仅作用于触发的媒体元素,无冒泡行为。例如点击视频的“停止”按钮,仅该视频元素触发stop,其父容器不会接收到该事件。

4. readystatechange:文档状态变化事件

该事件在document.readyState改变时触发(如从"loading""interactive"再到"complete"),仅绑定在documentXMLHttpRequest等对象上生效,不会向父元素传播。常用于监听页面DOM加载完成(作为DOMContentLoaded的补充方案)。

5. scroll:特殊的“条件冒泡”事件

scroll事件是特例:标准规范中scroll不冒泡,但部分浏览器(如Chrome)对其做了“冒泡兼容”——元素的scroll事件不会冒泡,而windowscroll事件是全局事件。

开发建议:若想监听滚动,建议直接绑定到滚动元素本身,而非依赖父元素捕获。

三、如何处理不冒泡事件?

面对不冒泡事件,核心解决思路有三个:

1. 直接绑定到目标元素

针对focus/blur/load等事件,直接在触发的元素上绑定监听函数,是最直接、最可靠的方式。

2. 使用冒泡版替代事件

focusin替代focusfocusout替代blur,利用冒泡特性实现父元素统一监听。

3. 利用事件捕获阶段处理

所有事件(包括不冒泡事件)都会经过**“捕获阶段”**,可在捕获阶段监听不冒泡事件:

// 捕获阶段监听focus事件(即使不冒泡,也能被父元素捕获)
document.querySelector('.parent').addEventListener('focus', () => {
  console.log('捕获阶段捕获focus'); // 正常执行
}, true); // 第三个参数为true,代表在捕获阶段触发

四、总结

不冒泡事件是DOM事件体系的重要组成部分,其设计符合**“事件影响范围最小化”**的原则。核心要点可总结如下:

掌握这些特性,能帮你在事件委托、资源监听、焦点管理等场景中避开陷阱,写出更健壮的前端代码。

写个添加注释的vscode插件

2026年3月2日 10:59

写注释真的好烦,每次都得/**……*/的形式才有jsDoc的效果,真的不想浪费时间了,于是写个vscode插件,添加一下jsDoc注释,提升点效率

1.vscode插件开发脚手架

使用Yeoman脚手架工具和generator-codeVS Code 扩展生成器来生成一个vscode插件开发项目

# 安装
npm install -g yo generator-code
# 执行脚手架,生产项目
yo code

image.png

可以看到有不同的类型

  • New Extension (TypeScript):基础ts插件开发项目
  • New Extension (JavaScript):基础js插件开发项目
  • New Color Theme:主题颜色配置插件开发项目
  • New Language Support:程序语义支持插件开发
  • New Code Snippets:代码片段插件开发
  • New Keymap:快捷键插件开发
  • New Extension Pack:插件包开发
  • New Language Pack (Localization):语言包插件
  • New Web Extension (TypeScript):网页插件开发,打开一个新页面,如图片预览
  • New Notebook Renderer (TypeScript):笔记本渲染插件开发,如代码和Markdown的格式化,交互小程序

小试牛刀的话只需要选择简单的New Extension (TypeScript)+esbuild

接下来只需要根据自己的情况填写插件项目名称,插件标识,插件描述等

2.运行调试第一个项目

选择最基础的Typescript模板,建议使用yarn管理包,后面打包成vscode插件包的时候pnpm会因为文件找不到而失败。

image.png

package.json配置命令,可以通过Ctrl+Shift+P唤起vscode命令栏搜索命令名称Hello World

 "contributes": {
    "commands": [
      {
        "command": "vscode-xcomment.helloWorld",
        "title": "Hello World"
      }
    ]
  },

src/extension.ts文件对应注册命令的操作

import * as vscode from "vscode";

//安装的时候
export function activate(context: vscode.ExtensionContext) {
 
  console.log('Congratulations, your extension "vscode-hello" is now active!');
    //注册命令
  const disposable = vscode.commands.registerCommand("vscode-hello.helloWorld", () => {
  //触发命令后执行
  
    //右下角弹出信息框
    vscode.window.showInformationMessage("Hello World from vscode-hello!");
    
    cconsole.log("hello", {name: "vscode", say: "hello world", age: 123});
  });   
  context.subscriptions.push(disposable);
}

//卸载的时候
export function deactivate() {}

首次运行Debug vscode插件会提示有问题,原因是因为launch.json配置了预运行的任务"preLaunchTask": "${defaultBuildTask}",即在tasks.json里面配置的预运行任务,其中有个npm: watch:esbuild的任务有问题。

  • 解决方案1:安装插件esbuild Problem Matchers,重新打开在debug
  • 解决方案2:把preLaunchTask去掉,手动执行命令npm run watch监听代码改变并编译成js,在运行debug

image.png

image.png

image.png

Debug时会弹出一个新的vscode窗口,通过Ctrl+Shift+P快捷键唤起vscode命令栏或者右下角设置里面打开命令栏,可以搜索到命令名称Hello World,点击执行就可以看到弹出信息框的内容。

image.pngimage.png

image.png

同时我们也能在vscode-hello项目的DEBUG CONSOLE调试控制台看到相关的输出打印

image.png

3.添加快捷键和右击菜单

在package.json添加contributes.keybindings配置快捷键

 "contributes": {
  "keybindings": [
      {
        "command": "vscode-hello.helloWorld",
        "key": "alt+H"
      }
    ]
 }

在package.json添加contributes.menus.editor/context配置编辑器中右击菜单

  "contributes": {
   "editor/context": [
        {
          "command": "vscode-hello.helloWorld",          
          "group": "1_modification@100",
          "alt": "vscode-hello.helloWorld",
          "key": "alt+H"
        }
      ]
  }
  

image.png

可以直接通过右击菜单的Hello World或者Alt+H快捷键触发helloWorld命令

如果想将菜单放在别的地方或者别的分组group,可以查看官方文档contributes.menus的配置 vscode.js.cn/api/referen…

image.png

当然可以添加一些快捷键和菜单生效的条件设置,比如当前打开的代码是ts/js/vue文件才出现或生效

 "keybindings": [
     {
        "command": "vscode-xcomment.add",
        "when": "editorTextFocus && resourceFilename =~ /.(js|ts|vue|jsx|tsx)$/",
        "key": "alt+/"
      },
 ],
   "menus": {
      "editor/context": [
      {
          "command": "vscode-xcomment.add",
          "when": "editorTextFocus && resourceFilename =~ /.(js|ts|vue|jsx|tsx)$/",
          "group": "1_modification@102",
          "alt": "vscode-xcomment.comment",
          "key": "alt+/"
        }
      ]
  }

具体的when子句上下文配置请看官方文档vscode.js.cn/api/referen…

4.给ts/js/vue文件添加注释

vscode获取当前打开文档的代码

   const editor = vscode.window.activeTextEditor;
  const doc = editor.document;
  const fileName = doc.fileName;//文件绝对路径
  const code = doc.getText();//代码内容

vscode检查是否有语法错误

执行命令前先进行语法错误判断,如果没有错误再执行

export function checkError(editor: vscode.TextEditor) {
  const diagnostics = vscode.languages.getDiagnostics(editor.document.uri);
  const hasSyntaxError = diagnostics.some(
    (d) =>
      d.severity === vscode.DiagnosticSeverity.Error &&
      (/syntax|unexpected|expected/i.test(d.message) ||
        (d.code && typeof d.code === "string" && d.code.toLowerCase().includes("syntax")))
  );
  if (hasSyntaxError) {
    return true;
  }

  return false;
}

vscode判断文件类型

限定执行命令的文件类型

function checkFile(editor: vscode.TextEditor) {
  const doc = editor.document;
  const fileName = doc.fileName;
  if (/\.(ts|js|vue|jsx|tsx)$/.test(fileName)) {
    return true;
  }
  return false;
}

注册命令并提示信息

    const disposable = vscode.commands.registerCommand(PREFIX + "comment", () => {
      //触发命令后执行
      //获取当前打开的编辑页面
      const editor = vscode.window.activeTextEditor;
      if (editor) {
        //检查是否有语法错误
        if (checkError(editor)) {
          //右下角弹出错误提示信息
          vscode.window.showErrorMessage("语法错误是不执行添加注释的命令!");
          return;
        }
        //检查文件类型是否正确
        if (!checkFile(editor)) {
          vscode.window.showErrorMessage("文件必须是js/ts/vue");
          return;
        }
        //添加注释
        const ctrl = new AddCommentController(editor);
        ctrl.doAction();
        ctrl.clearAll();
      }
    });

    context.subscriptions.push(disposable);

获取vscode当前光标所在位置

editor.selection.active

editor.selection.active.line//光标所在行
editor.selection.active.character//光标所在该行的第几个字符的位置

由于vscode按行来记录光标位置,所以为了方便找到具体字符位置,将代码按行进行分割,并进行索引开始结束位置和内容记录

getSourceLines(code: string) {
    const list: Array<[number, number, string]> = [];
    const lines = code.split("\n");
    if (lines.length) {
      let pre = 0;
      lines.forEach((line, idx) => {
        //+idx是因为换行号也算一个字符,需要加上
        list.push([pre + idx, pre + idx + line.length, line]);
        pre += line.length;
      });
    }
    return list;
  }

光标位置

  • 判断是否有光标,即focus聚焦在该代码编辑上了,有时候打开文档但是没有聚焦光标
const doc = this.editor.document;
    const fileName = doc.fileName; //文件绝对路径
    const code = doc.getText(); //代码内容
    this.sourceLines = this.getSourceLines(code);
    //是否有光标
    if (!this.editor.selection.active) {
      return;
    }
    const pos = this.editor.selection.active.line;
    const item = this.sourceLines[pos];
    //判断光标范围在文档代码有效范围内
    if (!item) {
      return;
    }

    //光标具体所在代码的字符索引位置
    const p = item[0] + this.editor.selection.active.character;
  • 如果是vue文件,要判断光标是否定位在vue的js/ts代码范围内,再获取其中的js/ts代码
if (fileName.endsWith(".vue")) {
      let startIndex = code.indexOf("<script");
      let endIndex = code.indexOf("</script>");
      if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) {
        return;
      }
      for (let i = startIndex; i < endIndex; i++) {
        const c = code[i];
        if (c === ">") {
          startIndex = i + 1;
          break;
        }
      }

      if (p < startIndex || p > endIndex) {
        vscode.window.showInformationMessage("vue文件光标位置不在js/ts范围内");
        return;
      }
      //vue文件内js/ts代码
      const script = code.substring(startIndex, endIndex);
   }

ts/js解析代码成AST

我们常用Typescript库校验和编译代码,同时它也能将代码解析成AST

import * as ts from "typescript";
const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS);

node打印出来可能很难清楚其结构,可以到astexplorer查看具体的AST树

image.png

遍历代码根节点下所有节点,查看节点类型和内容

sourceFile.statements.forEach((node) => {
//查看节点类型
  console.log(ts.SyntaxKind[node.kind]);
  console.log(node.getText());
  console.log("------");
});

image.png

查找光标位置的节点

遍历AST根据节点范围判断光标是否在该节点,然后深度遍历该节点,直到找到最终的子节点,即光标所在具体位置,期间可以收集所有父子节点。

因为每类节点的结构都有所差异,推荐使用ts自带的ts.forEachChild遍历子节点的方法

 findNode(file: ts.SourceFile, pos: number) {
    let result: ts.Node[] = [];
    const visitNode = (node: ts.Node) => {
      try {
        ts.forEachChild(node, (child) => {
          if (pos >= child.getStart() && pos < child.getEnd()) {
            result.push(child);
            //深度遍历子节点
            visitNode(child);
            //跳出循环
            throw Error();
          }
        });
      } catch (error) {}
    };

    for (let i = 0; i < file.statements.length; i++) {
      const it = file.statements[i];
      if (pos >= it.getStart() && pos < it.getEnd()) {
        result.push(it);
        //深度遍历子节点
        visitNode(it);
        break;
      }
    }
    return result;
  }

获取当前光标所在的节点

 const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS); 
      const currentNodes = this.findNode(sourceFile, p); 

给不同节点添加注释

  • 判断该节点是否已有jsDoc注释,如果有则不添加注释。
  • 如果是单行注释或者非jsDoc的多行注释则转化为jsDoc注释
  • 如果没有注释则直接添加
checkDocs(node: ts.Node, sourceFile: ts.SourceFile, cb: (msg?: string[]) => void) {
    //@ts-ignore
    if (node.jsDoc && node.jsDoc.length > 0) {
      //有jsDoc就不添加注释
    } else {
      const comments: string[] = [];
      //头部注释
      const leadingComments = ts.getLeadingCommentRanges(sourceFile.text, node.pos);
      if (leadingComments) {
        leadingComments.forEach((comment) => {
          const s = sourceFile.text.substring(comment.pos, comment.end);
          comments.push(s.replace(/[\*\/]+/g, ""));
        });
      }
      //尾部注释
      const tailingComments = ts.getTrailingCommentRanges(sourceFile.text, node.end);
      if (tailingComments) {
        tailingComments.forEach((comment) => {
          const s = sourceFile.text.substring(comment.pos, comment.end);
          comments.push(s.replace(/[\*\/]+/g, ""));
        });
      }

      if (comments && comments.length > 0) {
        //将旧的注释添加到jsDoc内
        cb(comments);
      } else {
        //添加新的注释
        cb();
      }
    }
  }

如果已有注释则延用,如果没有注释则获取节点的名称作为注释内容

getNodeName(stmt: ts.Node, msg?: string[]) {
    const comments: string[] = [];
    if (msg) {
      msg.forEach((a) => {
        if (!/^\s+$/.test(a)) {
          comments.push(" * " + a);
        }
      });
    }
    if (comments.length === 0) {
      //获取父级节点名称
      let current: ts.Node = stmt;
      while (current) {
        //@ts-ignore
        let name = stmt.name;
        if (name) {
          const n = name.getText();
          if (!/^\s+$/.test(n)) {
            comments.push(" * " + n);
            break;
          }
        }
        current = current.parent;
      }
    }
    //如果父级没有名称则添加默认注释
    if (comments.length === 0) {
      comments.push(` * description`);
    }
    return comments;
  }

普通函数与方法

普通函数声明

//对应节点类型 FunctionDeclaration
function sum(a: number, b: number): number {
  return a + b;
}

方法定义

const obj = {
//对应节点类型 MethodDeclaration
  fun(msg: string) {
    console.log(msg);
  }
};
class Person {
//对应节点类型 ConstructorDeclaration
  constructor(aaa: string) {
    console.log(aaa);
  }
  //对应节点类型 MethodDeclaration
  dd(dd: string) {
    console.log(dd);
  }
}

Type和Interface函数定义

interface Shape {
//对应节点类型 MethodSignature
  draw(x: number, y: number): void;
}
type DrawType = {
//对应节点类型 MethodSignature
  draw(x: number, y: number): void;
};

符合此函数结构就添加注释

if (
  ts.isFunctionDeclaration(stmt) ||
  ts.isMethodDeclaration(stmt) ||
  ts.isMethodSignature(stmt) ||
  ts.isConstructorDeclaration(stmt)
) {
  this.checkDocs(stmt, sourceFile, this.addDocFun(stmt));
  return;
}

获取函数参数变量和返回类型的jsDoc注释

getFunComments(
    stmt:
      | ts.FunctionDeclaration
      | ts.MethodDeclaration
      | ts.ArrowFunction
      | ts.FunctionExpression
      | ts.ConstructorDeclaration
      | ts.MethodSignature
  ): string[] {
    const comments: string[] = [];

    //参数
    if (stmt.parameters) {
      stmt.parameters.forEach((param) => {
        comments.push(
          ` * @param ${param.type ? `{${param.type.getText().replace(/\s/g, "")}}` : "{any}"} ${param.name.getText().replace(/\s/g, "")} - description`
        );
      });
    }
    //返回值
    if (!ts.isConstructorDeclaration(stmt) && stmt.type && stmt.type.kind !== ts.SyntaxKind.VoidKeyword) {
      comments.push(` * @returns {${stmt.type.getText().replace(/\s/g, '') || 'any'}} description`);
    }
    return comments;
  }

返回给普通函数添加注释的回调

addDocFun(stmt: ts.FunctionDeclaration | ts.MethodDeclaration | ts.ConstructorDeclaration | ts.MethodSignature) {
    return (msg?: string[]) => {
      const comments = this.getNodeName(stmt, msg);

      comments.push(...this.getFunComments(stmt));
      this.addNodeComment(stmt, comments);
    };
  }

箭头函数与匿名函数

箭头函数

//对应的AST结构 VariableDeclaration.initializer:ArrowFunction
const myFun = (a: number, b: number): number => {
  return a + b;
};

给变量赋值匿名函数

//对应的AST结构 VariableDeclaration.initializer:FunctionExpression
const myFun = function (a: number, b: number): number {
  return a + b;
};

符合此结构就添加注释

if (
  ts.isVariableDeclaration(declaration) &&
  declaration.initializer &&
  (ts.isFunctionExpression(declaration.initializer) || ts.isArrowFunction(declaration.initializer))
) {
  this.checkDocs(declaration, sourceFile, this.addInitializerDoc(stmt, declaration.initializer));
  return;
}

返回给箭头函数和匿名函数添加注释的回调

addInitializerDoc(stmt: ts.Node, initializer: ts.FunctionExpression | ts.ArrowFunction) {
    return (msg?: string[]) => {
      const comments = this.getNodeName(initializer, msg);

      comments.push(...this.getFunComments(initializer));

      this.addNodeComment(stmt, comments);
    };
  }

同理对象或类属性的箭头函数与匿名函数赋值

const obj = {
//对应的AST结构 PropertyAssignment.initializer:ArrowFunction
  aa: (msg: string) => {
    console.log(msg);
  },
  //对应的AST结构 PropertyAssignment.initializer:FunctionExpression
  bb: function (ccc: string) {
    console.log(ccc);
  }
};
class Person {
//对应的AST结构 PropertyDeclaration.initializer:ArrowFunction
  aa = (msg: string) => {
    console.log(msg);
  };
  //对应的AST结构 PropertyDeclaration.initializer:FunctionExpression
  bb = function (ccc: string) {
    console.log(ccc);
  };
}
if (
  (ts.isPropertyAssignment(stmt) || ts.isPropertyDeclaration(stmt)) &&
  stmt.initializer &&
  (ts.isFunctionExpression(stmt.initializer) || ts.isArrowFunction(stmt.initializer))
) {
  this.checkDocs(stmt, sourceFile, this.addInitializerDoc(stmt, stmt.initializer));
  return;
}

给属性等添加注释

//对应节点类型 TypeAliasDeclaration
type AAA={
//对应节点类型 PropertySignature
aaa:string;
}

//对应节点类型 InterfaceDeclaration
interface BBB {
//对应节点类型 PropertySignature
bbb:string;
}
//对应节点类型 ClassDeclaration
class CCC{
//对应节点类型 PropertyDeclaration
ccc:string='hello';
}

符合该节点类型的添加注释

if (ts.isInterfaceDeclaration(stmt) || ts.isClassDeclaration(stmt)) {
  this.checkDocs(stmt, sourceFile, this.addDocProp(stmt));
  return;
} else if (ts.isTypeAliasDeclaration(stmt) && stmt.type && ts.isTypeLiteralNode(stmt.type)) {
  this.checkDocs(stmt, sourceFile, this.addDocProp(stmt));
  return;
} else if (ts.isPropertyDeclaration(stmt) || ts.isPropertySignature(stmt) || ts.isPropertyAssignment(stmt)) {
  this.checkDocs(stmt, sourceFile, this.addDocProp(stmt));
  return;
}

返回属性等节点注释回调

 addDocProp(prop: ts.Node) {
    return (msg?: string[]) => {
      const comments = this.getNodeName(prop, msg);

      this.addNodeComment(prop, comments);
    };
  }

变量等于函数运行结果

//对应的AST结构 VariableDeclaration.initializer=CallExpression
const state = reactive({
  aaa: 1
});
//对应的AST结构 VariableDeclaration.initializer=CallExpression
const valRef = ref("hello");

符合结构添加注释

if (ts.isVariableDeclaration(declaration) && declaration.initializer && ts.isCallExpression(declaration.initializer)) {
  this.checkDocs(stmt, sourceFile, this.addDocProp(stmt));
  return;
}

其他节点添加注释

addComment(sourceFile: ts.SourceFile, nodes: ts.Node[]) {
    if (nodes.length) {
      for (let i = nodes.length - 1; i >= 0; i--) {
        const stmt = nodes[i];
        // if  ...
      }

    //其他节点添加注释
      const stmt = nodes[nodes.length - 1];
      this.checkDocs(stmt, sourceFile, this.addDocProp(stmt));
    }
  }
  
this.addComment(sourceFile, currentNodes);

插入注释内容

使用ts库插入注释

给节点插入头部注释

addNodeComment(node: ts.Node, comments: string[]) {
    ts.addSyntheticLeadingComment(node, ts.SyntaxKind.MultiLineCommentTrivia, "*" + comments.join("\n"), true);
  }

获取新的代码打印内容

 printCode(sourceFile: ts.SourceFile) {
    const printer = ts.createPrinter({newLine: ts.NewLineKind.LineFeed});
    const printed = printer.printFile(sourceFile);
    return printed;
  }

替换ts/js来添加注释

if (fileName.endsWith(".vue")) {
  //...
  const code = this.printCode(sourceFile);
  //vue文件替换js/ts部分
  const newText = text.substring(0, startIndex) + "\n" + code + text.substring(endIndex);
  this.replaceAllText(newText);
} else {
  //...
  const newText = this.printCode(sourceFile);
  this.replaceAllText(newText);
}

替换全文

replaceAllText(printed: string) { 
    const editor = this.editor;
    editor.edit((editBuilder) => {
      const firstLine = editor.document.lineAt(0);
      const lastLine = editor.document.lineAt(editor.document.lineCount - 1);
      const textRange = new vscode.Range(firstLine.range.start, lastLine.range.end);
      editBuilder.replace(textRange, printed);
    });
  }

以上方法不推荐,因为printer会将空格之类的格式去掉,会导致prettier之类的格式化被去掉,git对比出大量代码已修改

vscode文本插入

记录需要插入的注释内容,因为只有一处添加注释,然后就会停止遍历光标所在位置的父子节点

addNodeComment(node: ts.Node, comments: string[]) {
    const c = "/**" + comments.join("\n") + "*/";
    this.comment = c;
  }

插入文本,获取光标所在行的文本,判断是否为空白字符,如果全是空白字符则直接插入,否则按照当行前面空格位置插入

//该行内容
    const linestr = this.sourceLines[pos][2];
    if (/^\s*$/.test(linestr)) {
      //如果全是空白字符则直接插入
      this.editor.edit((editBuilder) => {
        editBuilder.insert(this.editor.selection.active, this.comment);
      });
    } else {
      //非空白字符,按照当行前面空格位置插入
      const spaces: string[] = [];
      for (let i = 0; i < linestr.length; i++) {
        if (/\s/.test(linestr[i])) {
          spaces.push(linestr[i]);
        } else {
          break;
        }
      }
      this.editor.edit((editBuilder) => {
        editBuilder.insert(new vscode.Position(pos, 0), spaces.join("") + this.comment + "\n");
      });
    }

5.运行vscode插件

将光标定位在指定的函数或变量上,然后按快捷键ALt+/或右击菜单选择Add Comment即可添加jsDoc注释 preview.gif

rightmenu.png

6.打包成vscode插件并发布

安装vscode插件打包工具

yarn add -D @vscode/vsce

package.json

  • icon:配置logo
  • extensionKind:插件类型workspace工作台功能或ui打开新的web页面,这里添加注释的功能是workspace
  • main:入口文件
{
 "icon": "xcommentlogo.jpg",
 "extensionKind": [
    "workspace"
  ],
  "main": "./dist/extension.js",
}

注意README上图片文件不可打包在其中,建议放到github上

执行打包命令,打包vsix插件包

npx vsce package

登录Azure DevOps创建个人访问令牌,记得复制令牌token字符串

image.png

image.png

package.json里配置Azure DevOps发布者账号名

{
"publisher": "username",
}

执行命令登录账户,然后粘贴刚才复制的token字符串

npx vsce login <username>

image.png

登录成功后执行发布命令

npx vsce publish

也可以到vscode插件管理页面手动发布 https://marketplace.visualstudio.com/manage

image.png

注意:

  • publisher注册和发布时,要使用谷歌的验证码recaptcha,可能要科学上网才能成功
  • 发布插件的时候不要开启 fastGithub等代理,否则会验证失败
  • 另外,一些临时文件不需要打包到vsix插件包的文件请在.vscodeignore 里面设置为忽略

vscode-xcomment这个注释小功能插件已发布到vscode插件市场,欢迎使用~

marketplace.visualstudio.com/items?itemN…

image.png

7.github地址

https://github.com/xiaolidan00/vscode-xcomment

参考

  • vscode插件开发官方示例https://github.com/microsoft/vscode-extension-samples
  • vscode插件开发教程https://vscode.js.cn/api/get-started/your-first-extension
  • 打包发布vscode插件https://vscode.js.cn/api/working-with-extensions/publishing-extension
  • Github Copilot
  • astexplorer.net/

前端权限校验最佳实践:一个健壮的柯里化工具函数

作者 BER_c
2026年3月2日 10:40

在业务开发中,权限校验是绕不开的常见场景。无论是管理后台的按钮权限控制,还是金融系统的操作权限验证,都需要在业务逻辑执行前进行权限判断。

然而,权限校验的代码往往散落在各处,重复且难以维护。本文分享一个经过多轮评审和实战检验的权限校验工具函数,从设计思路到最佳实践,帮助你在项目中优雅地处理权限校验。

需求背景

典型场景

假设我们在开发一个用户管理模块:

// 场景1:删除用户 - 需要管理员权限
const handleDelete = async (userId: string) => {
  if (!hasPermission('user:delete')) {
    message.error('无删除权限');
    return;
  }
  await deleteUser(userId);
};

// 场景2:编辑用户 - 需要特定角色
const handleEdit = async (user: User) => {
  if (!canEditUser(user)) {
    message.error('无编辑权限');
    return;
  }
  await updateUser(user);
};

// 场景3:异步权限校验 - 需要请求后端接口
const handleExport = async () => {
  const hasPerm = await checkPermissionAsync('user:export');
  if (!hasPerm) {
    message.error('无导出权限');
    return;
  }
  await exportUsers();
};

存在的问题

  1. 代码重复:每个函数都要写相同的校验逻辑
  2. 参数透传麻烦:Antd 等组件的事件处理函数需要传递事件参数
  3. 错误处理不统一:权限错误和业务错误混在一起
  4. 难以维护:权限校验逻辑分散,修改需要改动多处

设计思路

核心目标

  • 复用性:一次配置,多处使用
  • 参数透传:保持原函数参数不变
  • 类型安全:完整的 TypeScript 类型支持
  • 错误隔离:权限错误和运行时错误分开处理

方案选择

方案1:装饰器模式

@checkPermission('user:delete')
async handleDelete(userId: string) {
  await deleteUser(userId);
}

优点:语法优雅
缺点:对箭头函数不友好,React Hooks 场景受限

方案2:高阶函数

const handleDelete = withPermission(
  () => hasPermission('user:delete'),
  '无删除权限'
)((userId: string) => deleteUser(userId));

优点:函数式编程,与 React 兼容
缺点:需要处理参数透传

方案3:柯里化(最终选择)

const handleDelete = withPermissionCheck({
  validate: () => hasPermission('user:delete'),
  errorMessage: '无删除权限'
})(async (userId: string) => {
  await deleteUser(userId);
});

优点:配置清晰、支持柯里化、参数自动透传
缺点:返回值类型需要处理

我们选择柯里化方案,它在灵活性和可读性之间取得了良好平衡。

实现详解

基础实现

export interface PermissionCheckOptions {
  validate: boolean | (() => boolean) | (() => Promise<boolean>);
  errorMessage?: string;
  onForbidden?: (message?: string) => boolean | void;
  onError?: (error: unknown) => void;
  onChecking?: (checking: boolean) => void;
  showMessage?: (message: string) => void;
}

export function withPermissionCheck<T extends (...args: unknown[]) => unknown>(
  options: PermissionCheckOptions
) {
  return (targetFn: T): ((...args: Parameters<T>) => Promise<ReturnType<T>>) => {
    return (async (...args: Parameters<T>): Promise<ReturnType<T>> => {
      try {
        // 校验权限
        let hasPermission: boolean;
        if (typeof options.validate === 'boolean') {
          hasPermission = options.validate;
        } else if (typeof options.validate === 'function') {
          hasPermission = await options.validate();
        } else {
          hasPermission = false;
        }

        // 权限失败处理
        if (!hasPermission) {
          const msg = options.errorMessage || '无操作权限';
          const handled = options.onForbidden?.(msg);

          if (handled !== true) {
            const messageHandler = options.showMessage || defaultMessageHandler;
            messageHandler(msg);
          }

          throw new PermissionDeniedError(msg, { args, handled });
        }

        // 执行目标函数
        return (await targetFn(...args)) as ReturnType<T>;
      } catch (error) {
        if (error instanceof PermissionDeniedError) {
          throw error;
        }
        options.onError?.(error);
        throw error;
      }
    }) as (...args: Parameters<T>) => Promise<ReturnType<T>>;
  };
}

关键设计点

1. 参数透传保证

使用 TypeScript 泛型和 Parameters<T> 实现参数自动透传:

// 原函数签名
type TargetFn = (pagination: TablePagination, filters: Record<string, any>, sorter: Sorter) => void;

// 包装后
const wrappedFn = withPermissionCheck({
  validate: () => hasPermission('view')
})(targetFn);

// 类型自动推断,参数完整透传
wrappedFn({ current: 1, pageSize: 10 }, {}, {});

2. 自定义错误类型

引入 PermissionDeniedError 区分权限错误和运行时错误:

export class PermissionDeniedError extends Error {
  constructor(
    message: string,
    public readonly context?: Record<string, unknown>
  ) {
    super(message);
    this.name = 'PermissionDeniedError';
  }
}

使用场景:

try {
  await handleDelete('user-123');
} catch (error) {
  if (error instanceof PermissionDeniedError) {
    message.warning(error.message);
    return;
  }
  message.error('系统错误');
}

3. 解耦 UI 库

通过 showMessage 配置项实现 UI 库解耦:

// 使用 Ant Design
import { message } from 'antd';
const handleDelete = withPermissionCheck({
  validate: () => hasPermission('delete'),
  showMessage: (msg) => message.error(msg)
})(deleteUser);

// 使用 Naive UI
import { useMessage } from 'naive-ui';
const { error } = useMessage();
const handleDelete = withPermissionCheck({
  validate: () => hasPermission('delete'),
  showMessage: (msg) => error(msg)
})(deleteUser);

// 完全自定义
const handleDelete = withPermissionCheck({
  validate: () => hasPermission('delete'),
  showMessage: (msg) => {
    const div = document.createElement('div');
    div.textContent = msg;
    document.body.appendChild(div);
    setTimeout(() => div.remove(), 3000);
  }
})(deleteUser);

4. 避免 Loading 闪烁

仅在异步校验时触发 onChecking

// 判断是否为异步校验
const isAsyncValidation =
  typeof options.validate === 'function' &&
  (options.validate as () => Promise<boolean>)().then !== undefined;

// 仅异步校验时触发
if (isAsyncValidation) {
  options.onChecking?.(true);
}

// ... 执行逻辑

if (isAsyncValidation) {
  options.onChecking?.(false);
}

使用指南

基础用法

1. 静态权限(boolean)

const handleDelete = withPermissionCheck({
  validate: hasPermission('delete'),
  errorMessage: '无删除权限'
})(async (userId: string) => {
  await deleteUser(userId);
});

// 调用
try {
  await handleDelete('user-123');
} catch (error) {
  if (error instanceof PermissionDeniedError) {
    // 权限不足
  }
}

2. 同步校验函数

const handleClick = withPermissionCheck({
  validate: () => canEdit(),
  errorMessage: '无编辑权限'
})((event: React.MouseEvent) => {
  console.log(event.currentTarget);
});

// 在 React 组件中使用
<Button onClick={handleClick}>编辑</Button>

3. 异步校验函数

const handleExport = withPermissionCheck({
  validate: async () => {
    const result = await checkPermissionAsync('export');
    return result;
  },
  errorMessage: '无导出权限',
  onChecking: (loading) => setLoading(loading)
})(async () => {
  await exportData();
});

高级用法

1. 自定义错误提示

import { Modal } from 'antd';

const handleDelete = withPermissionCheck({
  validate: () => hasPermission('delete'),
  errorMessage: '删除权限不足',
  onForbidden: (msg) => {
    Modal.warning({
      title: '权限提示',
      content: msg,
    });
    return true; // 已自定义处理,不显示默认提示
  }
})(deleteUser);

2. 处理运行时错误

const handleAsync = withPermissionCheck({
  validate: () => true,
  errorMessage: '操作失败',
  onError: (error) => {
    console.error('执行出错:', error);
    message.error('操作失败,请重试');
  }
})(async () => {
  await riskyOperation();
});

3. Antd 组件集成

// Table onChange - 多参数透传
const handleTableChange = withPermissionCheck({
  validate: () => hasPermission('view'),
  errorMessage: '无查看权限'
})((pagination, filters, sorter, extra) => {
  console.log(pagination.current, filters, sorter.field, extra.action);
  fetchData();
});

<Table onChange={handleTableChange} />

// Form onFinish
const handleFormSubmit = withPermissionCheck({
  validate: () => canSubmit(),
  errorMessage: '无提交权限'
})(async (values: FormValues) => {
  await submitForm(values);
});

<Form onFinish={handleFormSubmit}>

最佳实践

1. UI 层预处理

在按钮或入口处判断权限,避免触发校验:

const deleteUser = withPermissionCheck({
  validate: () => hasPermission('delete'),
  errorMessage: '无删除权限'
})(async (userId: string) => {
  await api.delete(userId);
});

// 使用
<DataTable
  rowActions={(record) => [
    <Button
      key="delete"
      disabled={!hasPermission('delete')}
      danger
      onClick={() => deleteUser(record.id)}
    >
      删除
    </Button>
  ]}
/>

2. 错误边界处理

在 React Error Boundary 中统一处理:

class PermissionErrorBoundary extends React.Component<
  { children: React.ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };

  static getDerivedStateFromError(error: Error) {
    if (error instanceof PermissionDeniedError) {
      return { hasError: false }; // 不显示错误边界,由组件自行处理
    }
    return { hasError: true };
  }

  componentDidCatch(error: Error) {
    if (!(error instanceof PermissionDeniedError)) {
      // 记录其他错误
      logError(error);
    }
  }

  render() {
    if (this.state.hasError) {
      return <ErrorFallback />;
    }
    return this.props.children;
  }
}

3. 权限校验与业务逻辑分离

将权限校验逻辑抽离为独立模块:

// permissions.ts
export const UserPermissions = {
  canDelete: () => hasPermission('user:delete'),
  canEdit: (user: User) => user.id === currentUser.id || hasRole('admin'),
  canExport: async () => {
    const { data } = await api.checkPermission('user:export');
    return data.allowed;
  }
};

// 使用
const handleDelete = withPermissionCheck({
  validate: UserPermissions.canDelete,
  errorMessage: '无删除权限'
})(deleteUser);

常见问题

Q1: 为什么权限失败要抛出错误而不是返回 undefined?

答案:类型安全的考虑。

如果返回 undefined

const getUserData = withPermissionCheck({
  validate: false
})(async (id: string): Promise<User> => {
  return await fetchUser(id);
});

// 类型推断为 Promise<User>,实际返回 Promise<User | undefined>
const user = await getUserData('123');
user.name; // 运行时报错!

抛出错误确保类型契约完整:

try {
  const user = await getUserData('123'); // 类型安全
  user.name;
} catch (error) {
  if (error instanceof PermissionDeniedError) {
    // 明确处理权限错误
  }
}

Q2: 为什么包装后的函数总是异步的?

答案:统一行为,减少复杂度。

虽然这会导致同步函数也被包装成异步,但有以下好处:

  1. API 一致性:所有包装函数的调用方式相同
  2. 简化类型:不需要复杂的函数重载
  3. 扩展性:方便后续添加异步权限校验

在文档中明确说明这一点即可。

Q3: 如何在单元测试中使用?

答案:mock 消息提示函数。

import { withPermissionCheck } from '@/utils/permission-check';

describe('权限校验', () => {
  let showMessageMock: jest.Mock;

  beforeEach(() => {
    showMessageMock = jest.fn();
  });

  it('应该调用自定义提示函数', async () => {
    const targetFn = jest.fn();
    const wrappedFn = withPermissionCheck({
      validate: false,
      errorMessage: '无权限',
      showMessage: showMessageMock,
    })(targetFn);

    await expect(wrappedFn()).rejects.toThrow(PermissionDeniedError);
    expect(showMessageMock).toHaveBeenCalledWith('无权限');
  });
});

Q4: 如何处理高频调用的权限校验?

答案:在 validate 函数外部缓存。

// 简单缓存
let permissionCache: Map<string, boolean> = new Map();

const getPermission = async (key: string) => {
  if (permissionCache.has(key)) {
    return permissionCache.get(key);
  }

  const result = await api.checkPermission(key);
  permissionCache.set(key, result);
  return result;
};

// 使用
const handleDelete = withPermissionCheck({
  validate: async () => await getPermission('user:delete')
})(deleteUser);

如果需要更复杂的缓存逻辑(如 TTL),建议使用成熟的缓存库。

性能考虑

开销分析

包装函数的开销主要来自:

  1. 异步函数调用:Promise 包装的开销很小(< 1ms)
  2. 类型检查:仅在编译时,无运行时开销
  3. 条件判断:几个 if/else 判断,开销可忽略

优化建议

  1. 避免重复创建:在组件外或 useMemo 中创建包装函数
// ❌ 每次 render 都创建新函数
function Component() {
  const handleDelete = withPermissionCheck({ ... })(deleteUser);

  return <Button onClick={handleDelete}>删除</Button>;
}

// ✅ 在组件外创建
const handleDelete = withPermissionCheck({ ... })(deleteUser);

function Component() {
  return <Button onClick={handleDelete}>删除</Button>;
}

// ✅ 或使用 useMemo
function Component() {
  const handleDelete = useMemo(() => withPermissionCheck({ ... })(deleteUser), []);

  return <Button onClick={handleDelete}>删除</Button>;
}
  1. 异步校验加缓存:如上文提到的缓存方案

  2. 批量校验:对于需要多次校验的场景,可以批量获取权限

const permissions = await api.batchCheckPermissions([
  'user:delete',
  'user:edit',
  'user:export'
]);

const handleDelete = withPermissionCheck({
  validate: () => permissions['user:delete']
})(deleteUser);

总结

本文介绍的 withPermissionCheck 工具函数,经过多轮实战和评审,在以下方面取得了平衡:

维度 设计决策 权衡
类型安全 抛出 PermissionDeniedError 保持类型契约完整
参数透传 使用 Parameters 灵活性 > 简洁性
UI 解耦 showMessage 配置项 通用性 > 默认行为
错误处理 分离权限错误和运行时错误 清晰度 > 统一性
异步化 统一返回 Promise 一致性 > 适配性

核心设计原则:优先保证类型安全和行为可预期,其次考虑灵活性和易用性

如果你有更好的想法或建议,欢迎交流讨论。

后台权限与菜单渲染:基于路由和后端返回的几种实现方式

作者 SuperEugene
2026年3月2日 10:32

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、先搞清楚:权限到底分几层?

很多人一上来就想"我要做权限控制",但连权限分几层都没理清楚。我们先建立一个清晰的分层认知:

层级 控制什么 典型实现方式
路由级权限 用户能不能访问某个页面 路由守卫 + 动态路由
菜单级权限 侧边栏显示哪些菜单项 后端返回菜单 / 前端根据角色过滤
按钮级权限 页面内某个按钮是否可见/可点 自定义指令 / 组件封装
接口级权限 后端接口是否允许调用 后端网关/中间件拦截(前端兜底)

关键认识:前端权限控制本质上是"体验优化",真正的安全屏障在后端。 前端做的事情是:不该看的别让用户看到,不该点的别让用户点到。但如果有人绕过前端直接调接口,后端必须自己挡住。

二、路由级权限:从静态到动态的三种方案

方案一:最朴素的路由守卫 —— 路由 meta + 全局前置守卫

适用场景:角色简单(比如只有 admin 和 user 两种),页面不多。

思路:所有路由在前端写死,通过 meta 字段标记需要的角色,在全局路由守卫里做判断。

完整示例

路由配置:

// router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Layout from '@/layout/index.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/login',
    component: () => import('@/views/login.vue'),
    meta: { requiresAuth: false }
  },
  {
    path: '/',
    component: Layout,
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        component: () => import('@/views/dashboard.vue'),
        meta: { requiresAuth: true, roles: ['admin', 'user'] }
      },
      {
        path: 'user-manage',
        component: () => import('@/views/user-manage.vue'),
        meta: { requiresAuth: true, roles: ['admin'] }
      },
      {
        path: 'order-list',
        component: () => import('@/views/order-list.vue'),
        meta: { requiresAuth: true, roles: ['admin', 'user'] }
      }
    ]
  },
  {
    path: '/403',
    component: () => import('@/views/403.vue')
  }
]

const router = new VueRouter({ routes })

export default router

全局守卫:

// router/permission.js
import router from './index'
import store from '@/store'

router.beforeEach(async (to, from, next) => {
  const token = store.getters.token

  // 1. 去登录页:有 token 就跳首页,没有就放行
  if (to.path === '/login') {
    token ? next('/') : next()
    return
  }

  // 2. 没有 token,去登录
  if (!token) {
    next(`/login?redirect=${to.path}`)
    return
  }

  // 3. 有 token,但用户信息还没拉取(刷新页面的场景)
  if (!store.getters.userInfo) {
    try {
      await store.dispatch('user/getUserInfo')
    } catch (error) {
      // token 过期或无效,清除后跳登录
      await store.dispatch('user/logout')
      next(`/login?redirect=${to.path}`)
      return
    }
  }

  // 4. 检查角色权限
  if (to.meta.roles) {
    const userRole = store.getters.role
    if (to.meta.roles.includes(userRole)) {
      next()
    } else {
      next('/403')
    }
  } else {
    next()
  }
})

这种方案的优缺点

优点:简单直观,5 分钟就能写完,小项目完全够用。

缺点

  • 所有路由都注册了,只是守卫拦着不让进。用户在浏览器地址栏敲地址虽然会被拦截,但路由本身是存在的。
  • 角色和路由的对应关系写死在前端,改权限就得改代码、重新发版。
  • 菜单渲染还得另外写一套过滤逻辑。

踩坑点

坑 1:刷新页面时 userInfo 丢失。 Vuex 的状态刷新后就没了,所以守卫里必须有"重新获取用户信息"这一步。很多人一开始忘了这一步,导致刷新后直接跳登录页。

坑 2:next() 多次调用。 在一个守卫函数里,next() 只应该被调用一次。如果你的 if-else 分支写得不够严谨,可能会出现 next() 被调用多次的情况,导致诡异的跳转。上面示例里每个分支都 return 了,就是为了避免这个问题。


方案二:动态路由 —— 前端存完整路由表,登录后按角色过滤

适用场景:角色较多,但角色和权限的对应关系前端可以维护。

思路:前端维护一份"完整路由表"和一份"基础路由表"。用户登录后,根据角色从完整路由表中过滤出有权限的路由,通过 router.addRoutes()(Vue Router 3)或 router.addRoute()(Vue Router 4)动态添加。

完整示例

先把路由分成两份:

// router/routes.js

// 基础路由 —— 所有人都能访问
export const constantRoutes = [
  {
    path: '/login',
    component: () => import('@/views/login.vue'),
    hidden: true  // 菜单里不显示
  },
  {
    path: '/403',
    component: () => import('@/views/403.vue'),
    hidden: true
  }
]

// 动态路由 —— 需要根据角色过滤
export const asyncRoutes = [
  {
    path: '/',
    component: () => import('@/layout/index.vue'),
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        component: () => import('@/views/dashboard.vue'),
        meta: { title: '首页', icon: 'home', roles: ['admin', 'user', 'editor'] }
      }
    ]
  },
  {
    path: '/system',
    component: () => import('@/layout/index.vue'),
    meta: { title: '系统管理', icon: 'setting', roles: ['admin'] },
    children: [
      {
        path: 'user',
        component: () => import('@/views/system/user.vue'),
        meta: { title: '用户管理', roles: ['admin'] }
      },
      {
        path: 'role',
        component: () => import('@/views/system/role.vue'),
        meta: { title: '角色管理', roles: ['admin'] }
      }
    ]
  },
  {
    path: '/content',
    component: () => import('@/layout/index.vue'),
    meta: { title: '内容管理', icon: 'document' },
    children: [
      {
        path: 'article',
        component: () => import('@/views/content/article.vue'),
        meta: { title: '文章管理', roles: ['admin', 'editor'] }
      },
      {
        path: 'comment',
        component: () => import('@/views/content/comment.vue'),
        meta: { title: '评论管理', roles: ['admin'] }
      }
    ]
  }
]

过滤函数:

// utils/permission.js

/**
 * 判断用户角色是否匹配路由要求
 */
function hasPermission(route, role) {
  if (route.meta && route.meta.roles) {
    return route.meta.roles.includes(role)
  }
  // 没有设置 roles 的路由,默认所有人可访问
  return true
}

/**
 * 递归过滤路由表
 * 注意:这里要深拷贝,不能污染原始路由表
 */
export function filterAsyncRoutes(routes, role) {
  const result = []

  routes.forEach(route => {
    // 浅拷贝一份,避免修改原对象
    const tmp = { ...route }

    if (hasPermission(tmp, role)) {
      // 如果有子路由,递归过滤
      if (tmp.children) {
        tmp.children = filterAsyncRoutes(tmp.children, role)
      }
      result.push(tmp)
    }
  })

  return result
}

在 Vuex 中集成(也可以用 Pinia,思路一样):

// store/modules/permission.js
import { constantRoutes, asyncRoutes } from '@/router/routes'
import { filterAsyncRoutes } from '@/utils/permission'

const state = {
  routes: [],        // 最终的完整路由(用于渲染菜单)
  addedRoutes: []    // 动态添加的部分
}

const mutations = {
  SET_ROUTES(state, routes) {
    state.addedRoutes = routes
    state.routes = constantRoutes.concat(routes)
  }
}

const actions = {
  generateRoutes({ commit }, role) {
    return new Promise(resolve => {
      let accessedRoutes

      // admin 拥有全部权限,直接用完整路由表
      if (role === 'admin') {
        accessedRoutes = asyncRoutes
      } else {
        accessedRoutes = filterAsyncRoutes(asyncRoutes, role)
      }

      commit('SET_ROUTES', accessedRoutes)
      resolve(accessedRoutes)
    })
  }
}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}

在路由守卫里动态添加:

// router/permission.js
import router from './index'
import store from '@/store'

const whiteList = ['/login', '/403']

router.beforeEach(async (to, from, next) => {
  const token = store.getters.token

  if (token) {
    if (to.path === '/login') {
      next('/')
      return
    }

    // 判断是否已经生成过动态路由
    const hasRoutes = store.getters.addedRoutes && store.getters.addedRoutes.length > 0

    if (hasRoutes) {
      next()
    } else {
      try {
        // 获取用户信息(含角色)
        const { role } = await store.dispatch('user/getUserInfo')

        // 根据角色生成可访问路由
        const accessRoutes = await store.dispatch('permission/generateRoutes', role)

        // 动态添加路由(Vue Router 3 用 addRoutes,4 用 addRoute)
        // Vue Router 3:
        router.addRoutes(accessRoutes)

        // Vue Router 4 的写法:
        // accessRoutes.forEach(route => {
        //   router.addRoute(route)
        // })

        // 用 replace 确保 addRoutes 生效后再跳转
        // hack:{ ...to } 会重新解析路由,确保新加的路由能匹配到
        next({ ...to, replace: true })
      } catch (error) {
        await store.dispatch('user/logout')
        next(`/login?redirect=${to.path}`)
      }
    }
  } else {
    if (whiteList.includes(to.path)) {
      next()
    } else {
      next(`/login?redirect=${to.path}`)
    }
  }
})

踩坑点

坑 1:next({ ...to, replace: true }) 是必须的。 这一行容易被忽略。addRoutes 是异步生效的,如果你直接 next(),此时新路由可能还没注册完,就会匹配到 404。next({ ...to, replace: true }) 相当于"用当前目标地址重新走一次路由匹配",此时新路由已经注册好了。

坑 2:刷新页面后动态路由丢失。 addRoutes 添加的路由在刷新后就没了(因为是运行时加的,不是写死在 router 实例化时的)。所以守卫里用 hasRoutes 标志位来判断,如果没了就重新走一遍 generateRoutes → addRoutes 的流程。

坑 3:过滤路由时污染原始数据。 filterAsyncRoutes 一定要拷贝一份再操作。如果你直接改 asyncRoutes 里的对象,下次退出登录换个角色重新登录,过滤就乱了——因为原始路由表已经被改过了。


方案三:完全由后端控制路由表 —— 前端动态生成路由

适用场景:大型后台系统、权限管理非常灵活、角色和菜单由运营/管理员后台配置。

思路:后端返回当前用户有权限的菜单/路由数据(JSON),前端拿到后转换成 Vue Router 能识别的路由对象,然后动态添加。

后端返回的数据长什么样(典型格式)

[
  {
    "id": 1,
    "parentId": 0,
    "path": "/dashboard",
    "component": "views/dashboard",
    "name": "Dashboard",
    "meta": { "title": "首页", "icon": "home" }
  },
  {
    "id": 2,
    "parentId": 0,
    "path": "/system",
    "component": "layout/index",
    "name": "System",
    "meta": { "title": "系统管理", "icon": "setting" },
    "children": [
      {
        "id": 3,
        "parentId": 2,
        "path": "user",
        "component": "views/system/user",
        "name": "UserManage",
        "meta": { "title": "用户管理" }
      },
      {
        "id": 4,
        "parentId": 2,
        "path": "role",
        "component": "views/system/role",
        "name": "RoleManage",
        "meta": { "title": "角色管理" }
      }
    ]
  }
]

注意:后端返回的 component 是一个字符串路径,不是真正的组件。前端需要自己把这个字符串映射成 () => import(...) 的动态导入。

核心:字符串转组件的映射函数

// utils/route-helper.js

// 方式一:用 import() 的动态拼接
// 注意:Webpack 的 import() 不支持完全动态的字符串,必须有一部分是静态的
function loadComponent(componentPath) {
  // 这里 '@/' 是静态前缀,后面拼动态部分,Webpack 才能正确分析
  return () => import(`@/${componentPath}.vue`)
}

// 方式二(更推荐):维护一个显式映射表,更可控
const componentMap = {
  'layout/index': () => import('@/layout/index.vue'),
  'views/dashboard': () => import('@/views/dashboard.vue'),
  'views/system/user': () => import('@/views/system/user.vue'),
  'views/system/role': () => import('@/views/system/role.vue'),
  'views/content/article': () => import('@/views/content/article.vue'),
  // ...根据项目页面逐步维护
}

function loadComponentByMap(componentPath) {
  const loader = componentMap[componentPath]
  if (!loader) {
    console.warn(`[路由警告] 找不到组件: ${componentPath},将渲染 404 页面`)
    return () => import('@/views/404.vue')
  }
  return loader
}

/**
 * 把后端返回的路由数据转换成 Vue Router 格式
 */
export function transformRoutes(backendRoutes) {
  return backendRoutes.map(route => {
    const tmp = { ...route }

    // 字符串组件路径 → 真实组件
    if (tmp.component) {
      tmp.component = loadComponentByMap(tmp.component)
    }

    // 递归处理子路由
    if (tmp.children && tmp.children.length > 0) {
      tmp.children = transformRoutes(tmp.children)
    }

    return tmp
  })
}

在权限 store 中使用:

// store/modules/permission.js
import { constantRoutes } from '@/router/routes'
import { transformRoutes } from '@/utils/route-helper'
import { getUserMenus } from '@/api/user'

const actions = {
  async generateRoutes({ commit }) {
    // 从后端获取当前用户的菜单/路由数据
    const { data: backendRoutes } = await getUserMenus()

    // 将后端数据转换成 Vue Router 路由对象
    const accessedRoutes = transformRoutes(backendRoutes)

    commit('SET_ROUTES', accessedRoutes)
    return accessedRoutes
  }
}

路由守卫的写法和方案二基本一样,只是 generateRoutes 不再需要传角色了——后端已经帮你过滤好了。

踩坑点

坑 1:Webpack 的 import() 不能用完全动态的变量。 比如 import(componentPath) 这样写是不行的,Webpack 需要至少一个静态的目录前缀来确定搜索范围。所以要么写成 import(`@/views/${componentPath}.vue`),要么像上面那样用映射表。Vite 的场景下可以用 import.meta.glob 来实现更优雅的批量导入,后面会提到。

坑 2:后端返回的树形结构可能是扁平的。 有些后端返回的不是嵌套好的 tree,而是一个带 parentId 的扁平数组。这时候你需要先在前端组装成树形结构:

/**
 * 扁平数组 → 树形结构
 */
export function buildTree(flatList) {
  const map = {}
  const tree = []

  // 第一遍:建立 id → item 的映射
  flatList.forEach(item => {
    map[item.id] = { ...item, children: [] }
  })

  // 第二遍:根据 parentId 挂到父节点的 children 下
  flatList.forEach(item => {
    const node = map[item.id]
    if (item.parentId === 0) {
      tree.push(node)
    } else {
      const parent = map[item.parentId]
      if (parent) {
        parent.children.push(node)
      }
    }
  })

  return tree
}

坑 3:Vite 环境下 import() 的写法不同。 如果你用的是 Vite(Vue 3 项目大概率是),可以用 import.meta.glob 来做组件映射:

// Vite 专用写法
const modules = import.meta.glob('@/views/**/*.vue')

function loadComponent(componentPath) {
  const key = `/src/${componentPath}.vue`
  const loader = modules[key]
  if (!loader) {
    console.warn(`[路由警告] 找不到组件: ${componentPath}`)
    return modules['/src/views/404.vue']
  }
  return loader
}

import.meta.glob 返回的本身就是 { 路径: () => import(...) } 的映射对象,天然适合做这个事情,而且不需要手动维护映射表。


三种方案对比总结

维度 方案一:meta 守卫 方案二:前端过滤 方案三:后端返回
复杂度 ⭐⭐ ⭐⭐⭐
灵活度 低,改权限要发版 中,角色固定时够用 高,运营后台可动态配置
安全性 路由全暴露 路由全暴露(只是不添加) 前端只有有权限的路由
菜单渲染 需另外过滤 过滤后的路由即菜单 后端数据即菜单
适合场景 内部小工具 中型项目 大型后台 / SaaS

我的建议:如果你的项目超过 10 个菜单项,或者权限角色超过 3 种,直接上方案三。前期多花半天时间,后期能省几周的维护成本。

三、菜单渲染:路由即菜单 vs 菜单和路由分离

方式一:路由即菜单

这是最常见的做法——侧边栏菜单直接根据路由表渲染。方案二和方案三天然支持这种方式:过滤后的路由表就是菜单数据。

<!-- layout/Sidebar.vue -->
<template>
  <div class="sidebar">
    <template v-for="route in menuRoutes">
      <!-- 只有一个子菜单或没有子菜单:直接渲染为菜单项 -->
      <router-link
        v-if="!route.children || route.children.length <= 1"
        :key="route.path"
        :to="route.children ? route.children[0].path : route.path"
        class="menu-item"
      >
        <i :class="route.meta?.icon" />
        <span>{{ route.meta?.title || route.children?.[0]?.meta?.title }}</span>
      </router-link>

      <!-- 多个子菜单:渲染为可展开的菜单组 -->
      <div v-else :key="route.path" class="submenu">
        <div class="submenu-title">
          <i :class="route.meta?.icon" />
          <span>{{ route.meta?.title }}</span>
        </div>
        <router-link
          v-for="child in route.children.filter(c => !c.hidden)"
          :key="child.path"
          :to="`${route.path}/${child.path}`"
          class="menu-item"
        >
          <span>{{ child.meta?.title }}</span>
        </router-link>
      </div>
    </template>
  </div>
</template>

<script>
export default {
  computed: {
    menuRoutes() {
      // 从 store 拿过滤后的路由,排除 hidden 的
      return this.$store.getters.routes.filter(r => !r.hidden)
    }
  }
}
</script>

优点:菜单和路由保持一致,不会出现"菜单有但页面 404"或"页面有但菜单没显示"的错位问题。

缺点:菜单的层级、排序完全受路由结构限制。如果产品经理说"这个页面属于 A 模块,但菜单要放在 B 模块下面",你就麻烦了。

方式二:菜单和路由分离

后端分别返回两套数据:一套是菜单数据(控制侧边栏显示),一套是权限标识(控制路由注册和按钮权限)。

// 后端返回的菜单数据(只关心展示)
const menus = [
  {
    title: '首页',
    icon: 'home',
    path: '/dashboard'
  },
  {
    title: '运营中心',    // 这是一个虚拟的分组,不对应任何路由
    icon: 'operation',
    children: [
      { title: '文章管理', path: '/content/article' },
      { title: '订单列表', path: '/order/list' }   // 注意:订单本来在"订单模块",但菜单放在了"运营中心"
    ]
  }
]

// 后端返回的权限标识(控制路由和按钮)
const permissions = [
  'dashboard',
  'content:article',
  'content:article:edit',
  'content:article:delete',
  'order:list',
  'order:detail'
]

优点:菜单的展示结构完全灵活,不受路由层级约束。

缺点:要同时维护菜单和路由两套东西,且必须保证菜单的 path 和路由的 path 对得上,否则会出现点菜单跳 404 的情况。

我的建议:除非产品对菜单的展示结构有特殊要求,否则优先用"路由即菜单"。简单就是美。

四、按钮级权限:自定义指令 vs 组件封装

这是权限控制里最细粒度的一层。典型场景:同一个页面,管理员能看到"编辑"和"删除"按钮,普通用户只能看到"查看"。

方式一:自定义指令 v-permission

思路:写一个自定义指令,绑定在按钮上。指令内部检查当前用户的权限列表,如果没权限就把这个 DOM 元素移除。

// directives/permission.js
import store from '@/store'

export default {
  // Vue 2 写法
  inserted(el, binding) {
    const { value: requiredPermission } = binding
    const permissions = store.getters.permissions  // 用户的权限标识列表

    if (!requiredPermission) return

    // 支持传单个字符串或数组
    const requiredList = Array.isArray(requiredPermission)
      ? requiredPermission
      : [requiredPermission]

    // 检查用户是否拥有所需权限中的至少一个
    const hasPermission = requiredList.some(p => permissions.includes(p))

    if (!hasPermission) {
      // 没权限:移除 DOM 元素
      el.parentNode && el.parentNode.removeChild(el)
    }
  }

  // Vue 3 写法(钩子名不同):
  // mounted(el, binding) { ... }  // 对应 Vue 2 的 inserted
}

全局注册:

// main.js
import permissionDirective from '@/directives/permission'

// Vue 2
Vue.directive('permission', permissionDirective)

// Vue 3
app.directive('permission', permissionDirective)

使用:

<template>
  <div>
    <button v-permission="'content:article:edit'" @click="handleEdit">
      编辑
    </button>
    
    <button v-permission="'content:article:delete'" @click="handleDelete">
      删除
    </button>

    <!-- 也支持传数组:拥有其中任意一个权限即可 -->
    <button v-permission="['content:article:edit', 'content:article:publish']">
      编辑或发布
    </button>
  </div>
</template>

踩坑点

坑 1(非常重要):用 v-if 还是操作 DOM? 很多人觉得指令里直接 removeChild 太粗暴了。确实,这种方式有个问题:一旦移除了就不会再回来。如果你的权限数据是异步获取的,指令执行时权限还没拿到,按钮就被误删了。

解决方案有两种:

  1. 确保权限数据一定在组件渲染前就位(在路由守卫里获取完用户信息再放行)。
  2. 不用 removeChild,改成 el.style.display = 'none',然后在 update 钩子里重新检查。

坑 2:指令方式无法与 v-if / v-show 配合。 如果你在同一个元素上同时用了 v-permissionv-if,逻辑会变得混乱。建议二选一。

方式二:组件封装 <Permission>

思路:封装一个函数式组件,通过插槽来控制内容的渲染。

<!-- components/Permission.vue -->
<script>
export default {
  name: 'Permission',
  functional: true,   // Vue 2 函数式组件,性能更好
  props: {
    value: {
      type: [String, Array],
      required: true
    }
  },
  render(h, context) {
    const { value } = context.props
    const permissions = context.parent.$store.getters.permissions

    const requiredList = Array.isArray(value) ? value : [value]
    const hasPermission = requiredList.some(p => permissions.includes(p))

    // 有权限则渲染插槽内容,否则渲染空
    return hasPermission ? context.children : null
  }
}
</script>

Vue 3 的 Composition API 写法:

<!-- components/Permission.vue (Vue 3) -->
<template>
  <slot v-if="hasPermission" />
</template>

<script setup>
import { computed } from 'vue'
import { useStore } from 'vuex'  // 或者 import { usePermissionStore } from '@/stores/permission'

const props = defineProps({
  value: {
    type: [String, Array],
    required: true
  }
})

const store = useStore()

const hasPermission = computed(() => {
  const permissions = store.getters.permissions
  const requiredList = Array.isArray(props.value) ? props.value : [props.value]
  return requiredList.some(p => permissions.includes(p))
})
</script>

使用:

<template>
  <div>
    <Permission value="content:article:edit">
      <button @click="handleEdit">编辑</button>
    </Permission>

    <Permission :value="['content:article:delete']">
      <button @click="handleDelete">删除</button>
    </Permission>
  </div>
</template>

方式三(补充):直接用函数判断

有时候权限逻辑比较复杂(比如同时要判断角色 + 数据归属),指令和组件都不太方便。这时候最朴素的 v-if + 工具函数反而最好用:

// utils/permission.js
import store from '@/store'

export function hasPermission(permission) {
  const permissions = store.getters.permissions
  const requiredList = Array.isArray(permission) ? permission : [permission]
  return requiredList.some(p => permissions.includes(p))
}

export function hasRole(role) {
  return store.getters.role === role
}
<template>
  <div>
    <!-- 简单场景 -->
    <button v-if="hasPermission('content:article:edit')" @click="handleEdit">
      编辑
    </button>

    <!-- 复杂场景:不仅要有权限,还要是自己的文章 -->
    <button
      v-if="hasPermission('content:article:edit') && article.authorId === userId"
      @click="handleEdit"
    >
      编辑
    </button>
  </div>
</template>

<script>
import { hasPermission } from '@/utils/permission'

export default {
  methods: {
    hasPermission
  }
}
</script>

三种方式对比

维度 自定义指令 组件封装 函数 + v-if
简洁性 ⭐⭐⭐ 一行搞定 ⭐⭐ 需要包一层 ⭐⭐ 需要导入函数
灵活性 低,只能控制显隐 ⭐⭐⭐ 可组合复杂逻辑
响应式 需手动处理 天然响应式 天然响应式
推荐场景 纯显隐控制 团队规范统一 复杂业务逻辑

我的实战建议:项目中三种可以并存。简单的用指令,需要统一规范的用组件,复杂条件的用函数。别非要"只用一种"——工具是为业务服务的。

五、完整的权限流程串联

最后,我们把上面所有内容串起来,看一个完整的权限控制流程是怎么跑的:

用户打开浏览器,访问 /dashboard
        │
        ▼
  路由守卫拦截,检查 token
        │
    ┌───┴───┐
    │ 无token │──────→ 跳转 /login
    └───┬───┘
        │ 有token
        ▼
  是否已拉取用户信息?
        │
    ┌───┴───┐
    │  还没有  │──────→ 调接口获取 userInfo + permissions
    └───┬───┘
        │ 已有
        ▼
  是否已生成动态路由?
        │
    ┌───┴───┐
    │  还没有  │──────→ 调接口获取菜单数据
    └───┬───┘         → transformRoutes 转换
        │             → router.addRoute 注册
        │             → next({ ...to, replace: true })
        │ 已有
        ▼
  正常进入页面
        │
        ▼
  侧边栏根据 store 里的 routes 渲染菜单
        │
        ▼
  页面内按钮根据 permissions 做显隐控制

在代码层面,一个典型项目的文件组织大概是这样的:

src/
├── router/
│   ├── index.js          # 创建 router 实例,只注册 constantRoutes
│   ├── routes.js         # constantRoutes 和 asyncRoutes(方案二用)
│   └── permission.js     # 全局路由守卫
├── store/
│   └── modules/
│       ├── user.js       # 用户信息、token、登录/登出
│       └── permission.js # 路由/权限数据、generateRoutes
├── api/
│   └── user.js           # getUserInfo、getUserMenus 等接口
├── directives/
│   └── permission.js     # v-permission 自定义指令
├── components/
│   └── Permission.vue    # 权限组件(可选)
├── utils/
│   ├── permission.js     # hasPermission 工具函数
│   └── route-helper.js   # transformRoutes、buildTree
└── layout/
    ├── index.vue         # 整体布局
    └── Sidebar.vue       # 侧边栏菜单

六、常见问题 FAQ

Q1:退出登录后需要做什么清理?

// store/modules/user.js
async logout({ commit, dispatch }) {
  await logoutApi()               // 调后端登出接口
  commit('SET_TOKEN', '')         // 清 token
  commit('SET_USER_INFO', null)   // 清用户信息

  // 重点:重置路由!
  // Vue Router 3 没有 removeRoute,通常的做法是重新创建 router 实例
  resetRouter()

  // 清除 permission store
  dispatch('permission/resetRoutes', null, { root: true })
}

resetRouter 的实现(Vue Router 3 的经典 hack):

// router/index.js
const createRouter = () => new VueRouter({
  routes: constantRoutes
})

const router = createRouter()

export function resetRouter() {
  const newRouter = createRouter()
  router.matcher = newRouter.matcher  // 用新 matcher 替换旧的,相当于清除了动态路由
}

export default router

Vue Router 4 就优雅多了,有 router.removeRoute() 可以用。

Q2:Token 过期怎么处理?

建议在 axios 响应拦截器里统一处理:

// utils/request.js
service.interceptors.response.use(
  response => response,
  error => {
    if (error.response?.status === 401) {
      // token 过期或无效
      // 避免多个请求同时触发多次弹窗
      if (!isRefreshing) {
        isRefreshing = true
        MessageBox.confirm('登录已过期,请重新登录', '提示', {
          confirmButtonText: '重新登录',
          cancelButtonText: '取消',
          type: 'warning'
        }).then(() => {
          store.dispatch('user/logout').then(() => {
            location.reload()   // 简单粗暴但有效:刷新页面让路由守卫重新走流程
          })
        }).finally(() => {
          isRefreshing = false
        })
      }
    }
    return Promise.reject(error)
  }
)

Q3:同一个页面需要根据权限展示不同的布局怎么办?

不要用 v-permission(它是非此即彼的),用函数方式更灵活:

<template>
  <div>
    <!-- 管理员看到完整表单 -->
    <FullForm v-if="hasRole('admin')" />
    <!-- 普通用户看到精简表单 -->
    <SimpleForm v-else />
  </div>
</template>

总结

  1. 权限分层:路由级、菜单级、按钮级、接口级,各有各的实现方式,别混在一起。
  2. 路由方案选型:小项目用 meta 守卫,中项目用前端过滤,大项目让后端返回路由表。
  3. 菜单渲染:优先"路由即菜单",除非有特殊展示需求才分离。
  4. 按钮权限:指令、组件、函数三种方式可以并存,按场景选择。
  5. 前端权限只是体验优化,后端一定要有自己的鉴权,不要把安全寄托在前端。

权限这块东西不难,但坑很多,而且大多数坑只有在刷新页面、切换角色、token 过期这些"非正常路径"才会暴露出来。所以写完权限逻辑后,一定要多测这几个场景:

  • 刷新页面后,菜单和路由是否正常
  • 直接输入 URL 访问无权限页面,是否正确拦截
  • 退出登录 → 换角色登录,菜单是否正确更新
  • Token 过期后的操作,是否平滑跳转登录页

学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

手写Promise,从测试用例的角度理解

作者 KKKK
2026年3月2日 09:58

最近在补基础,发现Promise里面有挺多东西需要理解的,函数绕来绕去的

先来一个都可看懂的代码框架,剩余的慢慢补充

const PENDING = "pending";
const FULFILLED = "fulfilled";
const REJECTED = "rejected";

class MyPromise {
  constructor(executor) {
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];
    this.status = PENDING;
    this.value = undefined;
    this.reason = undefined;

    const resolve = (value) => {
      if (this.status === PENDING) {
        this.status = FULFILLED;
        this.value = value;
        this.onFulfilledCallbacks.forEach((fn) => fn());
      }
    };
    const reject = (reason) => {
      if (this.status === PENDING) {
        this.status = REJECTED;
        this.reason = reason;
        this.onRejectedCallbacks.forEach((fn) => fn());
      }
    };
    executor(resolve, reject);
  }
 }

首先补充then方法,由于需要链式调用,所以返回的同样是Promise对象

then(onFulfilled, onRejected) {

    const promise2 = new MyPromise((resolve, reject) => {
      const handleCallback = (callback, value, resolve, reject) => {
        queueMicrotask(() => {
          try {
            const x = callback(value);
            resolvePromise(promise2, x, resolve, reject);
          } catch (error) {
            reject(error);
          }
        });
      };

      if (this.status === FULFILLED) {
        handleCallback(onFulfilled, this.value, resolve, reject);
      } else if (this.status === REJECTED) {
        handleCallback(onRejected, this.reason, resolve, reject);
      } else if (this.status === PENDING) {
        this.onFulfilledCallbacks.push(() =>
          handleCallback(onFulfilled, this.value, resolve, reject),
        );
        this.onRejectedCallbacks.push(() =>
          handleCallback(onRejected, this.reason, resolve, reject),
        );
      }
    });

    return promise2;
  }

handleCallback是一个工具函数,用于将用于传进来的函数进行包装,这里在then中的回调加了queueMicrotask包装了下,使它变成一个异步的任务,为什么呢 考虑两种情况

情况1: new MyPromise中立刻resolve,也就是同步的情况

const myPromise = new MyPromise((resolve, reject) => {
  console.log("状态pending")
  resolve('成功调用resolve')
})

myPromise.then(res => {
  console.log(res);
}, err => {
  console.log(err);
})

对于以上例子,resolve中会立刻执行回调队列中的函数,但是实例对象的then方法这个时候还没调用呢,里面是空的。

然后执行then,这个时候状态已经确定,立即执行handleCallback。

情况2:

const myPromise = new MyPromise((resolve, reject) => {
  console.log("状态pending")
  setTimeout(() => { resolve("成功"); // 【异步执行】主线程空闲后才调用 resolve }, 0)
})

myPromise.then(res => {
  console.log(res);
}, err => {
  console.log(err);
})

这个时候,resolve没有立即调用,因此会先调用then,将任务放到队列里,等待resolve后执行handleCallback。

queueMicrotask保证了用户的回调不会阻塞同步代码的执行。

then中还有一个情况,就是then中什么也没有传,最后值还需要默认传递

const promise = new MyPromise((resolve, reject) => {
  resolve("success");
});
promise
  .then()
  .then()
  .then()
  .then(
    (value) => console.log(value),
    (err) => console.log(err)
  );

只需要加一个默认回调即可

onFulfilled =
      typeof onFulfilled === "function" ? onFulfilled : (value) => value;
onRejected =
  typeof onRejected === "function"
    ? onRejected
    : (reason) => {
        throw reason;
      };

分析一下调用,一旦顶部的promise的resolve调用,不管是resolve同步还是异步的被调用了,都会导致handleCallback被调用,最终onFulfilled被调用,并将值返回。

对于上面的例子, 一共有p p1 p2 p3 p4

P的resolve后,p1是一个新的promise,内部调用(value) => value,resolve后返回值为value; p2进来后发现p1是resolve(value)的敲定状态,也调用(value) => value,将值传递下去。

还剩下一个核心函数resolvePromise,用来处理返回值不是普通值的情况。

用例1:

// 使用 thenable 对象
const myThenable = {
  then: function (resolve, reject) {
    setTimeout(() => {
      resolve("success myThenable");
      reject("fail myThenable");
    }, 1000);
  },
};
new MyPromise((resolve, reject) => {
  resolve("success");
})
  .then((value) => {
    console.log(value);
    return myThenable;
  })
  .then((value) => console.log(value));

这个用例中返回了一个对象,该对象有then方法,

用例2:

// 使用 thenable 对象
new MyPromise((resolve, reject) => {
  resolve("success");
})
  .then((value) => {
    console.log(value);
    return new MyPromise((resolve) => setTimeout(() => resolve(num + 5), 1000));;
  })
  .then((value) => console.log(value));

这个用例返回了一个Promise对象。

当然还有更复杂的,返回嵌套的情况,可以使用递归解决,直到遇到一个普通值再结束。

上面两个用例有点难以理解,先来看普通的Promise对象:

new Promise((resolve, reject) => {
  // 这个函数就是 executor,它会被立即同步执行
  setTimeout(() => {
    resolve('done'); // 调用 resolve 改变 Promise 状态
  }, 1000);
});
  • 作用executor 负责启动异步操作,并在操作完成时调用 resolve 或 reject 来改变 Promise 的状态。
  • 特点executor 是立即执行的,并且由 Promise 构造函数传入 resolve 和 reject 两个函数。

再来看then方法:

promise.then(
  value => console.log(value), // 成功回调
  error => console.error(error) // 失败回调
);
  • 作用:注册当 Promise 状态变为 fulfilled 或 rejected 时执行的回调。
  • 特点then 方法不会主动调用 resolve 或 reject,它只是注册监听。它返回一个新的 Promise,用于链式调用。

Thenable 对象中的 then 方法

const myThenable = {
  then: function (resolve, reject) {
    // 这个 then 方法类似于 executor
    setTimeout(() => {
      resolve("success myThenable"); // 主动调用 resolve
      reject("fail myThenable");      // 也可以调用 reject
    }, 1000);
  },
};
  • 作用:当 Promise 机制(例如 Promise.resolve(myThenable))遇到 thenable 对象时,会自动调用其 then 方法,并传入两个回调(resolvePromise 和 rejectPromise 的包装函数)。thenable 内部的 then 方法可以像 executor 一样启动异步操作,并在适当时候调用传入的 resolve 或 reject 来通知结果。

  • 特点

    • 这个 then 方法承担了启动异步操作并触发状态改变的责任,与 Promise 构造函数的 executor 角色一致。
    • 它和 Promise 的 then 方法名称相同,但语义完全不同:前者是操作发起者,后者是结果监听者

其实写法上也可以看出来,自定义对象的then方法,相当于构造方法了,也是立即执行的,因为两者都叫 then,而且在 ES6 之前,许多 Promise 库(如 Q、Bluebird)的 thenable 对象确实用 then 方法来包装异步操作。但在原生 Promise 中,这两个角色被清晰地分开:

  • 构造函数中的 executor:启动操作 + 触发完成。
  • 原型上的 then 方法:注册回调 + 返回新 Promise。

而 thenable 将“启动操作”和“接收回调”合并到了同一个 then 方法中。当 Promise 处理 thenable 时,它相当于把 thenable 的 then 方法当作一个 executor 来使用,传入的 resolve 和 reject 就是用来改变最终 Promise 状态的函数。

原生 Promise 流程

  1. new Promise(executor) → executor 立即执行,启动异步任务。
  2. 异步任务完成 → 调用 resolve(或 reject)→ Promise 状态改变。
  3. 后续调用 then 注册回调 → 回调会在状态改变后被调用。

Thenable 被 Promise 处理时的流程

  1. Promise.resolve(myThenable) → 检测到 thenable。
  2. Promise 内部调用 myThenable.then(onFulfilled, onRejected),其中 onFulfilled 和 onRejected 是 Promise 提供的包装函数。
  3. myThenable.then 方法内部可以启动异步操作,并在适当时调用 onFulfilled(即传入的 resolve 函数)或 onRejected(即传入的 reject 函数)。
  4. 调用 onFulfilled 或 onRejected 会最终改变由 Promise.resolve 返回的那个 Promise 的状态。

所以,myThenable.then 相当于 Promise 构造函数的 executor,而 Promise.resolve(myThenable) 相当于 new Promise(executor)

根据上面的分析,可以来写一下resolvePromise这个函数了,首先是MYPromise 实例:

if (x instanceof MYPromise) {
    // 根据 x 的状态调用 resolve 或 reject
    x.then(
      y => {
        resolvePromise(promise2, y, resolve, reject);
      },
      reason => {
        reject(reason);
      }
    );
  }

递归调用返回值的then方法,直到返回值是一个普通值,我们再resolve掉。

对于myThenable

    // 获取 x 的 then 方法
      const then = x.then;
      if (typeof then === 'function') { // 如果 then 是函数
        // 使用 x 作为上下文调用 then 方法
        then.call(
          x,
          y => { // 成功回调
            if (called) return; // 如果已经调用过,直接返回
            called = true;
            // 递归处理 y
            resolvePromise(promise2, y, resolve, reject);
          },
          reason => { // 失败回调
            if (called) return; // 如果已经调用过,直接返回
            called = true;
            reject(reason);
          }
        );
      }

有几个需要注意的点,第一这里也是调用了then方法,并且写法上和原生的有点类似,都是传入了一个回调。为什么呢,上面说了myThenable的then有点像构造方法,接收的是resolve,Promise的then接收了onFulfilled回调,对于这俩回调,resolvePromise 在处理的时候都调用了resolvePromise。

resolve 与 onFulfilled 的区别

角色 来源 作用 被谁调用
resolve Promise 构造函数(executor 的第一个参数) 将 Promise 状态从 pending 变为 fulfilled,并设置内部 value。 由用户(或异步任务完成时)主动调用。
onFulfilled then 方法的第一个参数 当 Promise 变为 fulfilled 时被自动调用,接收该 Promise 的 value 作为参数,用于处理结果。 由 Promise 内部机制在状态变更后调用。

简言之,resolve 是“写”操作(触发状态变更),onFulfilled 是“读”操作(响应状态变更)

当 Promise 引擎遇到一个 thenable 对象(如 myThenable)时,它会调用该对象的 then 方法,并传入两个包装函数:

then.call(x,
  (y) => { /* 类似 resolve 的角色 */ },
  (r) => { /* 类似 reject 的角色 */ }
);

这个第一个包装函数(通常记为 resolvePromise 的包装)确实在语义上类似于 executor 中的 resolve——它被 thenable 内部的异步操作调用,用来传递成功值。但区别在于:

  • 并不直接改变最终 Promise 的状态,而是先经过 resolvePromise 的递归解析,最终才可能调用最外层的 resolve
  • 它的任务是接收 thenable 产生的成功值 y,然后启动递归解析过程。

所以,在 thenable 处理中,我们传入的成功回调模拟了 resolve 的行为,但实际上是解析流程的起点

resolve拿到值,处理then中回调,该回调返回不是普通值,递归处理该值。

变成了,resolve拿到值,该值不是普通值,递归处理该值。

迈向全栈新时代:SSR/SSG 原理、Next.js 架构与 React Server Components (RSC) 实战

作者 QLuckyStar
2026年3月2日 09:53

随着 React 19 的发布和 Next.js 15 的成熟,React 生态正经历着从“纯客户端渲染(CSR)”向“服务端组件(RSC)”的范式转移。传统的 SSR(服务端渲染)和 SSG(静态站点生成)正在与 RSC 融合,形成一种全新的混合渲染架构。

本文将深入解析 SSR/SSG 的核心原理,对比 Next.js 的演进路线,并重点探讨 React Server Components 如何重构前后端边界,带来性能与开发体验的双重飞跃。

一、渲染模式演进:从 CSR 到 RSC

1.1 传统模式回顾

  • CSR (Client-Side Rendering) :首屏加载慢,SEO 不友好,但交互流畅。
  • SSR (Server-Side Rendering) :首屏快,SEO 好,但每次请求都需要服务端重新渲染,服务器压力大,且存在“注水(Hydration)”时的交互卡顿。
  • SSG (Static Site Generation) :构建时生成 HTML,速度最快,但无法处理实时数据,构建时间长。

1.2 React Server Components (RSC) 的突破

RSC 不是简单的 SSR 升级,而是一种组件传输协议的革新

  • 零 Bundle 体积:服务端组件的代码完全不在客户端打包,只在服务器运行。这意味着你可以直接在组件中导入庞大的第三方库(如 Markdown 解析器、日期处理库),而不会增加客户端 JS 体积。
  • 直接访问后端资源:服务端组件可以直接查询数据库、读取文件系统,无需经过 API 层。
  • 流式传输(Streaming) :页面可以分块加载,用户无需等待整个页面生成即可看到部分内容。

二、Next.js 架构:RSC 的最佳实践载体

Next.js 是目前实现 RSC 最成熟的框架。在 Next.js 13/14/15 中,渲染模型发生了根本性变化。

2.1 客户端组件 vs 服务端组件

特性 Server Components (默认) Client Components ('use client')
运行环境 仅服务端 服务端 (预渲染) + 客户端 (交互)
数据访问 直接连接 DB/API 通过 fetch 或 Props 获取
Bundle 大小 0 KB 包含在 JS Bundle 中
交互能力 无 (onClick 等无效) 完整支持 (State, Effects, Listeners)
指令 无 (默认) 顶部添加 'use client'

2.2 实战:构建一个博客详情页

// app/blog/[slug]/page.jsx (Server Component)
import { db } from '@/lib/db';
import Comments from './comments'; // 可能是 Client Component

export default async function BlogPost({ params }) {
  const { slug } = await params;
  // 直接在后端查询数据库,无需 API 接口
  const post = await db.post.findUnique({ where: { slug } });

  if (!post) return <div>Not Found</div>;

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
      
      {/* 评论区需要交互,交给客户端组件 */}
      <Comments postId={post.id} />
    </article>
  );
}
// app/blog/[slug]/comments.jsx (Client Component)
'use client';

import { useState } from 'react';

export default function Comments({ postId }) {
  const [comments, setComments] = useState([]);
  
  // 客户端发起数据请求或使用 SWR/React Query
  // ...

  return (
    <section>
      <h3>Comments</h3>
      {/* 渲染评论列表和输入框 */}
    </section>
  );
}

三、关键概念:流式 SSR 与 选择性注水

3.1 流式 SSR (Streaming SSR)

在传统 SSR 中,用户必须等待整个 HTML 生成完毕才能看到页面。而在 Next.js + RSC 中,HTML 可以以流(Stream) 的形式发送。

  • 先发送骨架屏或静态部分。
  • 异步数据加载完成后,再发送剩余部分。
  • 配合 <Suspense> 组件,实现局部加载状态。
// 使用 Suspense 包裹异步组件
<Suspense fallback={<LoadingSkeleton />}>
  <HeavyDataComponent />
</Suspense>

3.2 选择性注水 (Selective Hydration)

React 18+ 允许优先注水用户正在交互的区域。如果用户在一个尚未完全注水的页面上点击了按钮,React 会优先处理该按钮的注水和事件,而不是按顺序等待整个树完成注水。这极大地提升了感知性能。

四、未来展望:React 19 与 Actions

React 19 引入了 Actions,进一步模糊了前后端界限。你可以在 Server Component 中直接定义表单提交逻辑,并通过 useFormStatus 在客户端获取提交状态,无需手动编写 fetch 和处理 loading 状态。

// Server Action
async function updateItem(formData) {
  'use server';
  const id = formData.get('id');
  await db.update(id, { status: 'done' });
  revalidatePath('/dashboard'); // 自动重新验证数据
}

// Client Component
function UpdateButton({ id }) {
  const { pending } = useFormStatus();
  return (
    <button disabled={pending} formAction={updateItem.bind(null, { id })}>
      {pending ? 'Updating...' : 'Update'}
    </button>
  );
}

结语

RSC 和 Next.js 的结合标志着 React 进入了全栈开发的新纪元。通过将重型逻辑移至服务端,我们不仅减少了客户端负担,还简化了数据流。对于现代前端工程师而言,掌握“何时使用 Server Component,何时使用 Client Component”的边界判断能力,将是构建高性能应用的关键。

透视 React 内核:Diff 算法、合成事件与并发特性的深度解析

作者 QLuckyStar
2026年3月2日 09:18

很多开发者能够熟练使用 React API,但当面对性能瓶颈或奇怪的 Bug 时,往往束手无策。究其原因,是对 React 底层机制缺乏深入理解。本文将从三个核心维度——Diff 算法的演进合成事件系统的原理并发模式(Concurrent Features)的实现,带你透视 React 的内核,掌握性能优化的“上帝视角”。

一、Diff 算法:从 O(n³) 到 O(n) 的智慧

React 之所以快,核心在于其高效的 Diff 算法。它通过启发式策略,将传统的 O(n³) 复杂度降低到了 O(n)。

1.1 三大核心策略

  1. Tree Diff(层级策略)
    React 假设 DOM 节点跨层级的移动非常少见。因此,它只比较同一层级的节点。如果节点类型不同(如 div 变 span),直接销毁旧树,重建新树,不再深入比较子节点。

  2. Component Diff(组件策略)
    同一类型的组件,认为其生成的 DOM 结构相似,继续递归比较子节点。如果组件类型不同(如 <Header> 变 <Footer>),则直接替换整个组件树。
    优化点:可以通过 shouldComponentUpdate 或 React.memo 手动跳过不必要的组件 Diff。

  3. Element Diff(列表策略)
    这是最容易出问题的地方。React 通过 key 来标识列表中的节点。

    • 无 Key 或 Index 为 Key:当列表顺序变化时,React 会误以为节点内容变了,导致大量不必要的 DOM 操作(销毁 + 重建),甚至导致输入框焦点丢失。
    • 稳定唯一的 Key:React 能精准识别节点的移动、插入和删除,仅进行最小化的 DOM 操作。
// ❌ 错误示范:使用 index 作为 key
{items.map((item, index) => (
  <ListItem key={index} data={item} /> 
))}
// 当 items 排序或删除时,会导致组件状态错乱和不必要的重渲染

// ✅ 正确示范:使用唯一 ID
{items.map((item) => (
  <ListItem key={item.id} data={item} />
))}

1.2 Fiber 架构带来的中断与恢复

在 React 16+ 引入 Fiber 后,Diff 过程不再是同步递归完成的,而是可以被中断恢复的。这使得 React 能够将长任务拆分成小的时间片,避免阻塞主线程,为并发渲染奠定了基础。

二、合成事件系统(SyntheticEvent):跨浏览器的统一抽象

React 并没有直接将事件监听器绑定到具体的 DOM 节点上,而是实现了一套自己的事件系统。

2.1 事件委托(Event Delegation)

React 将所有事件监听器绑定在根节点(React 17 之前是 document,17+ 是 root 容器)。当事件发生时,通过冒泡机制传播到根节点,React 再根据事件目标找到对应的组件并执行回调。

优势:

  • 内存优化:无论有多少个按钮,只需要在根节点注册一次监听器。
  • 统一行为:抹平了不同浏览器的事件差异(如 event.preventDefault 的兼容性)。

2.2 事件池(Event Pooling)的历史与现状

在 React 16 及以前,为了性能,React 会复用事件对象(Event Pooling)。这意味着你在异步回调中访问 event 属性时会得到 null,必须调用 event.persist()

React 17+ 的重大变更:移除了事件池。现在的事件对象是原生的,可以在异步回调中安全访问,无需 persist()。这大大降低了心智负担。

// React 16 (旧)
function handleClick(e) {
  e.persist(); 
  setTimeout(() => console.log(e.target), 1000);
}

// React 17+ (新)
function handleClick(e) {
  // 直接使用,无需 persist
  setTimeout(() => console.log(e.target), 1000);
}

三、并发特性(Concurrent Features):用户体验的革命

React 18 正式推出的并发模式,核心目标是保持 UI 响应灵敏,即使在执行重型渲染任务时。

3.1 可中断渲染

传统渲染一旦开始就无法中断,直到完成(阻塞主线程)。并发渲染允许 React 在渲染过程中暂停,去处理更高优先级的任务(如用户输入),然后再回来继续渲染。

3.2 useTransition:标记非紧急更新

当你需要执行一个耗时操作(如过滤一个大列表),但不希望它阻塞输入框的响应时,可以使用 useTransition

import { useTransition, useState } from 'react';

function SearchPage() {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();
  const [results, setResults] = useState([]);

  const handleChange = (e) => {
    const value = e.target.value;
    setQuery(value); // 紧急更新:立即响应输入框

    // 非紧急更新:过滤列表可以稍后执行
    startTransition(() => {
      const filtered = heavyFilter(value); 
      setResults(filtered);
    });
  };

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <Spinner />}
      <List items={results} />
    </>
  );
}

3.3 useDeferredValue:延迟更新值

useDeferredValue 是 useTransition 的另一种写法,适用于你已经有一个值,但想延迟它的副作用(如渲染)的场景。它类似于防抖(debounce),但更加智能,会根据设备性能自动调整延迟时间。

function SearchPage() {
  const [query, setQuery] = useState('');
  // deferredQuery 会在 query 变化后“延迟”更新,给紧急渲染让路
  const deferredQuery = useDeferredValue(query);

  const results = heavyFilter(deferredQuery);

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <List items={results} />
    </>
  );
}

结语

深入理解 Diff 算法让我们写出更高效的列表代码;掌握合成事件系统让我们明白事件处理的本质;而并发特性则为构建丝滑的用户体验提供了强大的武器。这些底层知识是区分初级与高级 React 开发者的分水岭。

组合式函数 、 Hooks(Vue2 mixin 、 Vue3 composables)的实战封装

作者 SuperEugene
2026年3月2日 09:10

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、先搞清楚一个问题:我们到底在解决什么?

写 Vue 项目久了,你一定遇到过这些场景:

  • 好几个页面都有表格 + 分页 + 搜索,每个页面都写一遍 currentPagepageSizetotalloadingtableData……
  • 好几个弹窗表单都要做打开/关闭、表单校验、提交、重置,每次都 copy 一坨。
  • 几乎所有接口调用都要处理 loading、error、retry,到处重复 try-catch。

核心问题就一个字:重复。

但重复本身不是最可怕的,可怕的是:

  1. 改一个逻辑要改 N 个地方(漏改一个就是 bug)
  2. 逻辑散落在 datamethodswatchmounted 各处,跟读小说一样要来回翻页
  3. 新人接手看不懂,老人自己过半年也看不懂

所以我们需要一种方式,把可复用的有状态逻辑抽出来,做到:写一次、用 N 次、改一处、全生效。

在 Vue2 时代,官方给的方案叫 Mixin
在 Vue3 时代,官方推荐的方案叫 Composables(组合式函数)

二、Vue2 Mixin:能用,但有"三宗罪"

2.1 Mixin 是什么?

简单说:Mixin 就是一个普通的 Vue 组件选项对象,可以包含 datamethodscomputedwatch、生命周期等任何组件选项。当你把它"混入"到一个组件里时,这些选项会和组件自身的选项合并

2.2 一个典型的例子:分页逻辑复用

假设我们有很多列表页,都需要分页,先看不用 Mixin 时你要在每个页面写的东西:

// PageA.vue
export default {
  data() {
    return {
      tableData: [],
      currentPage: 1,
      pageSize: 10,
      total: 0,
      loading: false
    }
  },
  methods: {
    async fetchList() {
      this.loading = true
      try {
        const res = await api.getListA({
          page: this.currentPage,
          size: this.pageSize
        })
        this.tableData = res.data.list
        this.total = res.data.total
      } finally {
        this.loading = false
      }
    },
    handlePageChange(page) {
      this.currentPage = page
      this.fetchList()
    },
    handleSizeChange(size) {
      this.pageSize = size
      this.currentPage = 1
      this.fetchList()
    }
  },
  created() {
    this.fetchList()
  }
}

PageB、PageC…… 全是这套。唯一不同的就是 api.getListA 换成 api.getListB

用 Mixin 抽出来:

// mixins/pagination.js
export default {
  data() {
    return {
      tableData: [],
      currentPage: 1,
      pageSize: 10,
      total: 0,
      loading: false
    }
  },
  methods: {
    // 子组件必须自己实现这个方法,返回接口调用的 Promise
    fetchList() {
      throw new Error('组件必须实现 fetchList 方法')
    },
    handlePageChange(page) {
      this.currentPage = page
      this.fetchList()
    },
    handleSizeChange(size) {
      this.pageSize = size
      this.currentPage = 1
      this.fetchList()
    }
  },
  created() {
    this.fetchList()
  }
}
// PageA.vue
import paginationMixin from '@/mixins/pagination'

export default {
  mixins: [paginationMixin],
  methods: {
    async fetchList() {
      this.loading = true
      try {
        const res = await api.getListA({
          page: this.currentPage,
          size: this.pageSize
        })
        this.tableData = res.data.list
        this.total = res.data.total
      } finally {
        this.loading = false
      }
    }
  }
}

看起来不错对吧?确实能复用了。但用久了你就会遇到 Mixin 的三宗罪

2.3 Mixin 的三宗罪

第一宗:来源不明("这变量哪来的?")

<template>
  <div>
    <!-- currentPage 是组件自己的?还是 mixin 带来的?还是哪个 mixin? -->
    <span>第 {{ currentPage }} 页,共 {{ total }} 条</span>
    <!-- userName 呢?是另一个 mixin 的? -->
    <span>{{ userName }}</span>
  </div>
</template>

<script>
import paginationMixin from '@/mixins/pagination'
import userMixin from '@/mixins/user'

export default {
  mixins: [paginationMixin, userMixin],
  // 你在 data、methods 里完全看不出 currentPage 和 userName 从哪来
  // IDE 也没法跳转到定义,只能靠人肉去翻 mixin 文件
}
</script>

当你引了 2-3 个 mixin,模板里用的变量来源就成了悬案。新人接手的时候更是一脸懵。

第二宗:命名冲突("我的变量被吞了")

// mixins/pagination.js
export default {
  data() {
    return { loading: false }  // 表格加载状态
  }
}

// mixins/auth.js
export default {
  data() {
    return { loading: false }  // 权限校验加载状态
  }
}

// SomePage.vue
export default {
  mixins: [paginationMixin, authMixin],
  data() {
    return { loading: false }  // 提交按钮加载状态
  }
  // 三个 loading 打架了!
  // Vue2 的合并策略:组件自身的 data 优先,后面的 mixin 覆盖前面的
  // 最终只有一个 loading,另外两个的逻辑全乱了
  // 而且——不会报任何错误或警告!
}

这是 Mixin 最要命的问题。项目小的时候还好,项目大了、mixin 多了,命名冲突几乎是必然的,而且是静默的——不报错、不警告,直接覆盖,等你发现 bug 的时候已经不知道要查到什么时候了。

第三宗:不灵活("我想用两份分页怎么办?")

// 如果一个页面有两个独立的表格,各自有各自的分页呢?
// Mixin 混进来就是一份,没法实例化两份
export default {
  mixins: [paginationMixin], // 只有一份 currentPage、total……
  // 第二个表格的分页数据怎么办?再写一遍?那还要 mixin 干嘛?
}

Mixin 本质上是对象合并,不是函数调用,所以你没法像调函数一样"new 两份出来"。

2.4 小结

能力 Mixin
能复用逻辑吗? ✅ 能
来源清晰吗? ❌ 不清晰,变量来源成谜
能避免冲突吗? ❌ 不能,静默覆盖
能多实例吗? ❌ 不能,混进来就是一份
类型推导友好吗? ❌ TypeScript 几乎没法推导

三、Vue3 Composables:函数的胜利

3.1 核心思想:一切皆函数

Vue3 的 Composition API 给了我们一个极其简单但极其强大的模式:

把有状态的逻辑写成一个普通函数,函数里用 ref/reactive 创建响应式状态,最后 return 出去。

就这么简单。没有什么新 API、新概念,就是函数

// composables/useCounter.js —— 最简单的例子
import { ref } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)

  function increment() {
    count.value++
  }

  function decrement() {
    count.value--
  }

  function reset() {
    count.value = initialValue
  }

  return { count, increment, decrement, reset }
}

使用的时候:

<script setup>
import { useCounter } from '@/composables/useCounter'

// 调一次就是一份独立的状态
const { count: countA, increment: incrementA } = useCounter(0)
const { count: countB, increment: incrementB } = useCounter(100)

// countA 和 countB 完全独立,互不影响
</script>

<template>
  <button @click="incrementA">A: {{ countA }}</button>
  <button @click="incrementB">B: {{ countB }}</button>
</template>

3.2 对比 Mixin,三宗罪全解了

问题 Mixin Composable
来源不明 ❌ 变量凭空出现 ✅ 显式 import + 解构,清清楚楚
命名冲突 ❌ 静默覆盖 ✅ 解构时可以重命名 { count: countA }
不能多实例 ❌ 只有一份 ✅ 调多次就是多份独立状态
TS 支持 ❌ 几乎不可用 ✅ 完美推导,悬停即可看类型

3.3 命名约定

Vue 社区有一个约定俗成的规范(也是 Vue 官方文档推荐的):

  • 文件名:useXxx.jsuseXxx.ts
  • 函数名:useXxx,以 use 开头
  • 放置位置:项目中统一放在 composables/hooks/ 目录下
src/
├── composables/         # 或叫 hooks/
│   ├── useRequest.js    # 通用请求封装
│   ├── useTable.js      # 表格逻辑封装
│   ├── useForm.js       # 表单逻辑封装
│   ├── useLoading.js    # 加载状态封装
│   └── index.js         # 统一导出
├── views/
├── components/
└── ...

四、实战封装一:useRequest —— 一切的基础

4.1 为什么先封装它?

因为 useTable 要请求数据,useForm 要提交数据,几乎所有业务逻辑都绕不开接口调用。把请求逻辑封装好了,后面的封装都会轻松很多。

4.2 先想清楚:一个接口调用需要管理哪些状态?

别急着写代码,先列需求:

1. data    —— 接口返回的数据
2. loading —— 是否正在请求中(控制按钮 loading、骨架屏等)
3. error   —— 请求失败的错误信息
4. 手动触发 / 自动触发 —— 有的接口进页面就要调,有的要点按钮才调
5. 防重复 —— 快速点击不要发 N 个请求

4.3 最小可用版本(V1)

先写一个最简单的版本,能跑起来:

// composables/useRequest.js  V1 - 最小可用版
import { ref } from 'vue'

/**
 * 通用请求封装
 * @param {Function} apiFn - 接口函数,需返回 Promise
 * @param {Object} options - 配置项
 * @param {boolean} options.immediate - 是否立即执行,默认 false
 * @param {any} options.initialData - data 的初始值,默认 null
 */
export function useRequest(apiFn, options = {}) {
  const {
    immediate = false,
    initialData = null
  } = options

  const data = ref(initialData)
  const loading = ref(false)
  const error = ref(null)

  async function run(...args) {
    loading.value = true
    error.value = null
    try {
      const res = await apiFn(...args)
      data.value = res
      return res
    } catch (err) {
      error.value = err
      throw err  // 继续抛出,让调用方可以 catch
    } finally {
      loading.value = false
    }
  }

  // 如果配置了 immediate,创建时就调一次
  if (immediate) {
    run()
  }

  return { data, loading, error, run }
}

使用示例:

<script setup>
import { useRequest } from '@/composables/useRequest'
import { getUserInfo } from '@/api/user'

// 场景1:进页面自动请求
const { data: userInfo, loading } = useRequest(
  () => getUserInfo(userId),
  { immediate: true }
)

// 场景2:点按钮手动触发
const { loading: submitLoading, run: submitForm } = useRequest(
  (formData) => saveUser(formData)
)

function handleSubmit() {
  submitForm({ name: 'Tom', age: 18 })
}
</script>

<template>
  <div v-loading="loading">{{ userInfo?.name }}</div>
  <button :loading="submitLoading" @click="handleSubmit">提交</button>
</template>

核心理解: useRequest 接收一个"接口函数",帮你管理 loadingdataerror 三个状态,并返回一个 run 方法让你手动触发。就这么简单。

4.4 进阶版本(V2):加上防重复和竞态处理

V1 有两个隐患:

  1. 防重复:用户快速点击提交按钮,会同时发出多个请求
  2. 竞态:用户快速切换筛选条件,先发的请求后返回,会覆盖掉后发请求的正确数据
// composables/useRequest.js  V2 - 加防重和竞态处理
import { ref } from 'vue'

export function useRequest(apiFn, options = {}) {
  const {
    immediate = false,
    initialData = null,
    // 新增:是否在 loading 中时阻止重复调用(适用于提交类接口)
    preventRepeat = false
  } = options

  const data = ref(initialData)
  const loading = ref(false)
  const error = ref(null)

  // 用一个自增 id 来处理竞态
  // 每次调用 run 时 id + 1,回调时检查 id 是否是最新的
  // 如果不是最新的,说明在这次请求还没返回时,又发了新的请求
  // 那这次的结果就应该被丢弃
  let requestId = 0

  async function run(...args) {
    // 防重复:如果正在请求中,直接返回
    if (preventRepeat && loading.value) {
      return
    }

    const currentId = ++requestId
    loading.value = true
    error.value = null

    try {
      const res = await apiFn(...args)
      // 竞态处理:只有最新一次请求的结果才会被赋值
      if (currentId === requestId) {
        data.value = res
      }
      return res
    } catch (err) {
      if (currentId === requestId) {
        error.value = err
      }
      throw err
    } finally {
      if (currentId === requestId) {
        loading.value = false
      }
    }
  }

  if (immediate) {
    run()
  }

  return { data, loading, error, run }
}

竞态问题的具体场景,举个例子:

用户操作:选"北京" → 选"上海"(很快切换)

请求时序:
  请求A(北京)发出 ──────────────────> 请求A返回(北京的数据)
  请求B(上海)发出 ────> 请求B返回(上海的数据)

如果不处理竞态:
  页面先显示上海数据(正确),然后被北京数据覆盖(错误!)

处理竞态后:
  请求A返回时发现 currentId !== requestId,丢弃结果
  页面始终显示上海数据(正确)

这个问题在实际开发中出现频率很高,但很多人意识不到。面试也经常问。

4.5 踩坑提醒

坑 1:忘了 finally 重置 loading

// ❌ 错误写法
async function run(...args) {
  loading.value = true
  try {
    const res = await apiFn(...args)
    data.value = res
    loading.value = false  // 如果上面报错了,这行不会执行!
  } catch (err) {
    error.value = err
    // 忘了在这里也重置 loading → 页面永远转圈
  }
}

// ✅ 正确写法:用 finally
async function run(...args) {
  loading.value = true
  try {
    const res = await apiFn(...args)
    data.value = res
  } catch (err) {
    error.value = err
  } finally {
    loading.value = false  // 不管成功失败都会执行
  }
}

坑 2:immediate: true 的时候传参

// ❌ 这样拿不到参数
const { data } = useRequest(
  (id) => getDetail(id),
  { immediate: true }
)
// immediate 调用 run() 时没传 id,接口会报错

// ✅ 用闭包把参数包进去
const { data } = useRequest(
  () => getDetail(route.params.id),
  { immediate: true }
)

五、实战封装二:useTable —— 中后台的半壁江山

5.1 分析需求

中后台项目里,表格页面占了至少一半。一个标准的表格页面需要:

1. 表格数据(tableData)
2. 分页状态(currentPage、pageSize、total)
3. 加载状态(loading)
4. 搜索/筛选参数(searchParams)
5. 查询方法(搜索、重置、翻页、切换每页条数)
6. 进页面自动加载第一页

5.2 完整实现

// composables/useTable.js
import { ref, reactive, onMounted } from 'vue'

/**
 * 表格逻辑封装
 * @param {Function} apiFn - 列表接口函数
 *   接收参数格式:apiFn({ page, size, ...searchParams })
 *   返回格式约定:{ list: [], total: 0 }
 * @param {Object} options - 配置项
 */
export function useTable(apiFn, options = {}) {
  const {
    defaultPageSize = 10,
    immediate = true,
    // 让调用方可以自定义如何从接口返回值中提取 list 和 total
    // 因为每个项目的接口返回格式可能不同
    formatResult = (res) => ({
      list: res.data?.list ?? res.data?.records ?? [],
      total: res.data?.total ?? 0
    })
  } = options

  // ---- 状态定义 ----
  const tableData = ref([])
  const loading = ref(false)
  const pagination = reactive({
    currentPage: 1,
    pageSize: defaultPageSize,
    total: 0
  })

  // 搜索参数,用 reactive 方便直接 v-model 绑定表单
  const searchParams = reactive({})

  // ---- 核心方法 ----

  /** 加载数据 */
  async function fetchData() {
    loading.value = true
    try {
      const params = {
        page: pagination.currentPage,
        size: pagination.pageSize,
        ...searchParams
      }
      const res = await apiFn(params)
      const { list, total } = formatResult(res)
      tableData.value = list
      pagination.total = total
    } catch (err) {
      console.error('[useTable] fetchData error:', err)
      tableData.value = []
      pagination.total = 0
    } finally {
      loading.value = false
    }
  }

  /** 搜索(重置到第一页) */
  function search() {
    pagination.currentPage = 1
    fetchData()
  }

  /** 重置搜索条件并查询 */
  function reset() {
    // 清空 searchParams 的所有字段
    Object.keys(searchParams).forEach(key => {
      searchParams[key] = undefined
    })
    pagination.currentPage = 1
    fetchData()
  }

  /** 翻页 */
  function onPageChange(page) {
    pagination.currentPage = page
    fetchData()
  }

  /** 切换每页条数 */
  function onSizeChange(size) {
    pagination.pageSize = size
    pagination.currentPage = 1  // 切换条数要回到第一页
    fetchData()
  }

  /** 刷新当前页(不改变任何条件) */
  function refresh() {
    fetchData()
  }

  // ---- 初始化 ----
  if (immediate) {
    onMounted(() => {
      fetchData()
    })
  }

  // ---- 返回 ----
  return {
    tableData,
    loading,
    pagination,
    searchParams,
    search,
    reset,
    refresh,
    onPageChange,
    onSizeChange,
    fetchData
  }
}

5.3 使用示例(完整页面)

<!-- views/UserList.vue -->
<template>
  <div class="page-container">
    <!-- 搜索区域 -->
    <el-form inline @submit.prevent="search">
      <el-form-item label="用户名">
        <el-input v-model="searchParams.username" placeholder="请输入用户名" clearable />
      </el-form-item>
      <el-form-item label="状态">
        <el-select v-model="searchParams.status" placeholder="请选择" clearable>
          <el-option label="启用" :value="1" />
          <el-option label="禁用" :value="0" />
        </el-select>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="search">查询</el-button>
        <el-button @click="reset">重置</el-button>
      </el-form-item>
    </el-form>

    <!-- 表格 -->
    <el-table :data="tableData" v-loading="loading" border>
      <el-table-column prop="username" label="用户名" />
      <el-table-column prop="email" label="邮箱" />
      <el-table-column prop="status" label="状态">
        <template #default="{ row }">
          <el-tag :type="row.status === 1 ? 'success' : 'danger'">
            {{ row.status === 1 ? '启用' : '禁用' }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column label="操作" width="200">
        <template #default="{ row }">
          <el-button size="small" @click="handleEdit(row)">编辑</el-button>
          <el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>

    <!-- 分页 -->
    <el-pagination
      v-model:current-page="pagination.currentPage"
      v-model:page-size="pagination.pageSize"
      :total="pagination.total"
      :page-sizes="[10, 20, 50, 100]"
      layout="total, sizes, prev, pager, next, jumper"
      @current-change="onPageChange"
      @size-change="onSizeChange"
      style="margin-top: 16px; justify-content: flex-end;"
    />
  </div>
</template>

<script setup>
import { getUserList } from '@/api/user'
import { useTable } from '@/composables/useTable'

const {
  tableData,
  loading,
  pagination,
  searchParams,
  search,
  reset,
  refresh,
  onPageChange,
  onSizeChange
} = useTable(getUserList)

// 页面自身的业务逻辑
function handleEdit(row) {
  // 打开编辑弹窗...
}

async function handleDelete(row) {
  await ElMessageBox.confirm('确认删除?')
  await deleteUser(row.id)
  ElMessage.success('删除成功')
  refresh()  // 删除后刷新当前页
}
</script>

对比一下:如果不用 useTable,这个页面的 <script> 部分至少要 80+ 行。现在核心逻辑只有 20 行左右,而且每个列表页都是同样的模式

5.4 踩坑提醒

坑 1:searchParamsreactive 还是 ref

// 方案A:reactive(推荐)
const searchParams = reactive({})
// ✅ 优点:模板里直接 v-model="searchParams.xxx",不用 .value
// ✅ 优点:新增字段时直接 searchParams.newField = 'xxx' 就行
// ⚠️ 注意:不能整个替换 searchParams = {},要逐个清字段

// 方案B:ref
const searchParams = ref({})
// 模板里要写 searchParams.xxx(Vue 自动解 ref),看起来一样
// 但重置时可以直接 searchParams.value = {}
// 缺点:如果传给子组件,需要注意 .value 的问题

我推荐用 reactive,因为搜索参数一般不会整个替换,而是逐字段修改。

坑 2:onMounted 还是直接调用?

// ❌ 直接调用
if (immediate) {
  fetchData()  // 此时组件可能还没挂载,某些情况下会有问题
}

// ✅ 放在 onMounted 里
if (immediate) {
  onMounted(() => {
    fetchData()
  })
}

useTable 一般在 setup 阶段调用,如果你在 fetchData 里有用到 DOM 相关的东西(比如获取表格容器高度来做自适应),直接调用就会出问题。养成好习惯,用 onMounted

坑 3:切换 pageSize 时忘了重置页码

// ❌ 错误
function onSizeChange(size) {
  pagination.pageSize = size
  fetchData()
  // 比如当前在第 5 页,每页 10 条,共 45 条
  // 切换成每页 50 条后,第 5 页已经不存在了
  // 接口可能返回空数据甚至报错
}

// ✅ 正确
function onSizeChange(size) {
  pagination.pageSize = size
  pagination.currentPage = 1  // 一定要回到第一页!
  fetchData()
}

六、实战封装三:useForm —— 弹窗表单的终结者

6.1 分析需求

中后台的另一个高频场景:弹窗表单(新增/编辑共用一个弹窗)。需要管理:

1. 弹窗显隐(visible)
2. 弹窗标题(根据新增/编辑动态变化)
3. 表单数据(formData)
4. 表单校验(rules + validate)
5. 提交逻辑(loading + 调接口 + 关弹窗 + 刷新列表)
6. 重置逻辑(关弹窗时清空表单 + 清除校验状态)

6.2 完整实现

// composables/useForm.js
import { ref, reactive, toRaw } from 'vue'

/**
 * 弹窗表单逻辑封装
 * @param {Object} options
 * @param {Function} options.getInitialData - 返回表单初始值的函数(必须是函数,避免引用污染)
 * @param {Function} options.submitApi - 提交接口函数,接收 formData 参数
 * @param {Function} options.onSuccess - 提交成功后的回调
 */
export function useForm(options = {}) {
  const {
    getInitialData = () => ({}),
    submitApi,
    onSuccess
  } = options

  // ---- 状态 ----
  const visible = ref(false)
  const isEdit = ref(false)
  const title = ref('')
  const formData = reactive(getInitialData())
  const formRef = ref(null)  // el-form 的 ref
  const submitLoading = ref(false)

  // ---- 方法 ----

  /** 打开弹窗 - 新增模式 */
  function openAdd() {
    isEdit.value = false
    title.value = '新增'
    resetFields()
    visible.value = true
  }

  /**
   * 打开弹窗 - 编辑模式
   * @param {Object} row - 当前行数据,用于回填表单
   */
  function openEdit(row) {
    isEdit.value = true
    title.value = '编辑'
    resetFields()
    // 回填数据:只填 formData 中存在的字段,避免多余字段
    Object.keys(getInitialData()).forEach(key => {
      if (row[key] !== undefined) {
        formData[key] = row[key]
      }
    })
    visible.value = true
  }

  /** 关闭弹窗 */
  function close() {
    visible.value = false
    // 延迟重置,等弹窗关闭动画结束后再清空,避免用户看到闪烁
    setTimeout(() => {
      resetFields()
    }, 300)
  }

  /** 重置表单字段到初始值 */
  function resetFields() {
    const initial = getInitialData()
    Object.keys(initial).forEach(key => {
      formData[key] = initial[key]
    })
    // 清除 el-form 的校验状态
    formRef.value?.clearValidate?.()
  }

  /** 提交表单 */
  async function submit() {
    if (!submitApi) {
      console.warn('[useForm] submitApi is not provided')
      return
    }
    // 先校验
    try {
      await formRef.value?.validate()
    } catch {
      return  // 校验不通过,直接返回
    }

    submitLoading.value = true
    try {
      // toRaw:把 reactive 对象转成普通对象再传给接口
      // 避免接口层不小心修改了响应式对象
      await submitApi(toRaw(formData))
      close()
      onSuccess?.()
    } catch (err) {
      console.error('[useForm] submit error:', err)
      // 提交失败不关弹窗,让用户可以修改后重试
    } finally {
      submitLoading.value = false
    }
  }

  return {
    visible,
    isEdit,
    title,
    formData,
    formRef,
    submitLoading,
    openAdd,
    openEdit,
    close,
    submit,
    resetFields
  }
}

6.3 使用示例(完整页面)

<!-- views/UserList.vue(在前面 useTable 的基础上加入 useForm) -->
<template>
  <div class="page-container">
    <!-- 搜索区域(省略,同前面 useTable 示例) -->

    <!-- 新增按钮 -->
    <el-button type="primary" @click="openAdd" style="margin-bottom: 16px;">
      新增用户
    </el-button>

    <!-- 表格(省略,同前面 useTable 示例,编辑按钮绑定 openEdit) -->
    <el-table :data="tableData" v-loading="tableLoading" border>
      <!-- ...其他列... -->
      <el-table-column label="操作" width="200">
        <template #default="{ row }">
          <el-button size="small" @click="openEdit(row)">编辑</el-button>
        </template>
      </el-table-column>
    </el-table>

    <!-- 新增/编辑弹窗 -->
    <el-dialog v-model="visible" :title="title" width="500px" @close="close">
      <el-form
        ref="formRef"
        :model="formData"
        :rules="rules"
        label-width="80px"
      >
        <el-form-item label="用户名" prop="username">
          <el-input v-model="formData.username" placeholder="请输入用户名" />
        </el-form-item>
        <el-form-item label="邮箱" prop="email">
          <el-input v-model="formData.email" placeholder="请输入邮箱" />
        </el-form-item>
        <el-form-item label="状态" prop="status">
          <el-select v-model="formData.status" placeholder="请选择状态">
            <el-option label="启用" :value="1" />
            <el-option label="禁用" :value="0" />
          </el-select>
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="close">取消</el-button>
        <el-button type="primary" :loading="submitLoading" @click="submit">
          确定
        </el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script setup>
import { useTable } from '@/composables/useTable'
import { useForm } from '@/composables/useForm'
import { getUserList, createUser, updateUser } from '@/api/user'

// ---- 表格逻辑 ----
const {
  tableData,
  loading: tableLoading,
  pagination,
  searchParams,
  search,
  reset,
  refresh,
  onPageChange,
  onSizeChange
} = useTable(getUserList)

// ---- 表单逻辑 ----
const {
  visible,
  isEdit,
  title,
  formData,
  formRef,
  submitLoading,
  openAdd,
  openEdit,
  close,
  submit
} = useForm({
  getInitialData: () => ({
    id: undefined,
    username: '',
    email: '',
    status: 1
  }),
  submitApi: (data) => {
    // 根据 isEdit 判断调新增还是编辑接口
    return isEdit.value ? updateUser(data) : createUser(data)
  },
  onSuccess: () => {
    ElMessage.success(isEdit.value ? '编辑成功' : '新增成功')
    refresh()  // 提交成功后刷新表格
  }
})

// 表单校验规则
const rules = {
  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  email: [
    { required: true, message: '请输入邮箱', trigger: 'blur' },
    { type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
  ],
  status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
</script>

你看,整个页面的 <script> 部分非常清晰:

  1. useTable 管表格和分页
  2. useForm 管弹窗和表单
  3. 页面自身只需要定义校验规则和业务相关的 UI

逻辑分离、职责清晰、代码量大幅减少。

6.4 踩坑提醒

坑 1:getInitialData 为什么必须是函数?

// ❌ 错误:直接传对象
const initialData = { username: '', email: '', status: 1 }
useForm({ getInitialData: initialData })

// 问题:每次 resetFields 重置时,拿到的都是同一个对象引用
// 如果 initialData 被修改过(比如编辑回填时),重置就不是真正的"初始值"了

// ✅ 正确:传一个函数,每次调用都返回一个全新的对象
useForm({
  getInitialData: () => ({ username: '', email: '', status: 1 })
})

这和 Vue2 里 data 必须是函数是同一个道理——避免引用污染。

坑 2:编辑回填时直接赋值整个对象

// ❌ 错误
function openEdit(row) {
  Object.assign(formData, row)
  // 问题:row 里可能有 createdAt、updatedAt 等表单不需要的字段
  // 提交时这些多余字段会被传给接口,可能导致后端报错
}

// ✅ 正确:只回填 formData 中定义过的字段
function openEdit(row) {
  Object.keys(getInitialData()).forEach(key => {
    if (row[key] !== undefined) {
      formData[key] = row[key]
    }
  })
}

坑 3:弹窗关闭时的视觉闪烁

// ❌ 立即重置
function close() {
  visible.value = false
  resetFields()  // 弹窗还在做关闭动画,用户会看到表单内容突然清空

// ✅ 延迟重置,等动画结束
function close() {
  visible.value = false
  setTimeout(() => {
    resetFields()
  }, 300)  // Element Plus 弹窗动画时长大约 300ms
}

七、封装的设计原则与规范

写了三个实战 composable 之后,我们总结一下通用的设计原则:

7.1 命名规范

// ✅ 文件名和函数名保持一致
// composables/useRequest.js → export function useRequest()
// composables/useTable.js   → export function useTable()

// ✅ 返回值命名清晰
return {
  data,          // 名词:状态数据
  loading,       // 形容词:状态标记
  error,         // 名词:错误信息
  run,           // 动词:操作方法
  search,        // 动词:操作方法
  reset,         // 动词:操作方法
}

// ❌ 避免模糊命名
return {
  result,   // result 是什么?请求结果?搜索结果?
  flag,     // flag 是什么?
  handle,   // handle 什么?
  doIt,     // do what?
}

7.2 参数设计

// ✅ 推荐:必选参数放前面,可选配置用 options 对象
export function useTable(apiFn, options = {}) {}

// ❌ 不推荐:一堆位置参数,调用时要记顺序
export function useTable(apiFn, pageSize, immediate, formatFn) {}

// ✅ 提供合理的默认值,让最简单的用法零配置
const { tableData, loading } = useTable(getUserList)
// 不传 options 也能正常工作

7.3 单一职责

// ✅ 一个 composable 做一件事
useRequest  → 只管请求状态
useTable    → 只管表格 + 分页
useForm     → 只管表单 + 弹窗

// ❌ 不要做一个"万能"composable
usePageHelper → 又管表格、又管表单、又管权限、又管路由……
// 这种东西最终会变成新的"屎山"

7.4 组合优于继承

Composable 之间可以互相组合。比如 useTable 可以内部使用 useRequest

// composables/useTable.js(组合版)
import { useRequest } from './useRequest'

export function useTable(apiFn, options = {}) {
  const { data, loading, run } = useRequest(apiFn)

  async function fetchData() {
    const params = { page: pagination.currentPage, size: pagination.pageSize }
    const res = await run(params)
    // 处理 res...
  }

  // ...
}

这就是组合的威力:小函数组成大函数,每一层都清晰可控。

7.5 统一导出

// composables/index.js
export { useRequest } from './useRequest'
export { useTable } from './useTable'
export { useForm } from './useForm'
export { useLoading } from './useLoading'
// ...

使用时一行搞定:

import { useTable, useForm } from '@/composables'

八、常见问题 FAQ

Q1:Composable 里能用生命周期钩子吗?

可以。 在 composable 内部调用 onMountedonUnmounted 等是完全合法的,前提是这个 composable 是在 setup 阶段被调用的(而不是在某个异步回调里调用)。

// ✅ 合法
export function useWindowResize() {
  const width = ref(window.innerWidth)

  function handler() {
    width.value = window.innerWidth
  }

  onMounted(() => window.addEventListener('resize', handler))
  onUnmounted(() => window.removeEventListener('resize', handler))

  return { width }
}

Q2:Composable 之间怎么共享状态?

如果你需要在多个组件之间共享同一份状态(比如全局用户信息),有两种方式:

// 方式1:把状态定义在函数外面(模块级别的单例)
const globalUser = ref(null)

export function useUser() {
  async function fetchUser() {
    globalUser.value = await getUserInfo()
  }
  return { user: globalUser, fetchUser }
}
// 所有组件拿到的都是同一个 globalUser

// 方式2:更复杂的全局状态,建议用 Pinia
// composable 适合组件级的有状态逻辑
// Pinia 适合跨组件/跨页面的全局状态

Q3:和 React Hooks 有什么区别?

最核心的区别:Vue composable 只在 setup 时执行一次,React Hook 每次渲染都会执行。

// Vue:setup 只跑一次,后续数据变化靠响应式系统自动追踪
export function useCounter() {
  const count = ref(0)         // 只创建一次
  const double = computed(() => count.value * 2)  // 自动追踪
  return { count, double }
}

// React:每次渲染都会重新执行,需要 useMemo/useCallback 优化
function useCounter() {
  const [count, setCount] = useState(0)        // 每次渲染都执行
  const double = useMemo(() => count * 2, [count])  // 手动声明依赖
  return { count, double }
}

所以 Vue 的 composable 不需要担心"闭包陷阱"和"依赖数组"这些 React 特有的问题,心智负担更小。

九、总结

维度 Vue2 Mixin Vue3 Composable
来源透明性 ❌ 变量来源不明 ✅ 显式导入解构
命名冲突 ❌ 静默覆盖 ✅ 解构重命名
多实例 ❌ 不支持 ✅ 调多次即多份
TypeScript ❌ 几乎无法推导 ✅ 完美支持
组合能力 ❌ 难以互相调用 ✅ 函数随意组合
调试体验 ❌ 不知道值从哪来 ✅ 断点直接跟进函数

三个核心封装的适用场景速查:

  • useRequest:任何需要调接口的地方(基础设施,其他 composable 的地基)
  • useTable:所有列表/表格页面(中后台的半壁江山)
  • useForm:所有弹窗表单场景(新增/编辑/详情)

封装心法:

  1. 先想清楚要管理哪些状态、暴露哪些方法
  2. 参数设计:必选在前,可选用 options 对象 + 合理默认值
  3. 单一职责,小函数组合成大函数
  4. 统一命名(useXxx),统一目录(composables/),统一导出(index.js

最后想说的是:Composable 不是什么高深技术,它就是"把逻辑写成函数"——这是编程最古老、最朴素、最强大的抽象方式。

Vue3 的 Composition API 只是给了我们一个在 Vue 框架里优雅地使用这种方式的能力。把它用好,你的代码会变得更干净、更可维护、更有生命力。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

❌
❌