阅读视图

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

干掉 Virtual DOM?尤雨溪开始"强推" Vapor Mode?

上周 Code Review,我看到同事写了这样一段代码:

const state = reactive({
  user: null,
  loading: false,
  error: '',
  list: []
});

// 后面又单独定义
const currentPage = ref(1);
const pageSize = ref(10);

乍看没问题,但一运行**——页面卡顿、watch 失效、调试器里数据对不上……**

问题出在哪?
不是逻辑错,而是响应式对象的“组合方式”错了

今天,我就用3 条黄金法则 + 2 个实战模板,帮你彻底搞懂 Vue 3 响应式怎么写才高效、安全、可维护。

法则 1:简单值用 ref,复杂对象用 reactive —— 但别混用!

很多教程说:“primitive 用 ref,object 用 reactive”,这没错,但忽略了“解构陷阱”。

错误示范:

const { user, loading } = reactive({ user: null, loading: false });
// 解构后失去响应性!

正确做法:

// 方案 A:全部用 ref(推荐新手)
const user = ref(null);
const loading = ref(false);

// 方案 B:用 toRefs 保持响应性
const state = reactive({ user: null, loading: false });
const { user, loading } = toRefs(state); // ✅ 响应式保留

经验公式:

  • 如果你要频繁解构 or 传递单个属性 → 优先用 ref
  • 如果是完整状态模块(如表单、列表配置)→ 用 reactive + toRefs

法则 2:别把 ref 套进 reactive,除非你真的需要

见过这种写法吗?

const state = reactive({
  count: ref(0), // ❌ 不要!
  name: 'Vue'
});

这会导致:

  • 访问时必须写 state.count.value(破坏一致性)
  • 模板中虽然自动 unwrap,但逻辑层混乱
  • 容易引发“value 嵌套地狱”

正确做法:统一层级

// 要么全 ref
const count = ref(0);
const name = ref('Vue');

// 要么全 reactive(count 直接是 number)
const state = reactive({
  count: 0,
  name: 'Vue'
});

小技巧:在 setup() 返回时,用 ...toRefs(state) 一键暴露所有属性。

法则 3:大型组件,用“状态模块化”代替巨型 reactive

当组件状态超过 5 个字段,别堆在一个 reactive 里!

反面教材:

const state = reactive({
  // 用户信息
  userId, userName, userAvatar,
  // 分页
  page, size, total,
  // 搜索条件
  keyword, status, dateRange,
  // UI 状态
  showDrawer, loading, errorMsg...
});

推荐拆分:

// 按功能拆成多个小状态块
const userState = reactive({ id: '', name: '', avatar: '' });
const pagination = reactive({ page: 1, size: 10, total: 0 });
const uiState = reactive({ loading: false, drawerVisible: false });

// 或封装成 composable
const { userState } = useUserStore();
const { pagination, fetchList } = usePagination();

这样不仅逻辑清晰,还天然支持 逻辑复用(比如分页逻辑抽成 usePagination)。

实战模板:两种主流写法对比

模板 A:全 ref 风格(适合中小型组件)

export default {
  setup() {
    const loading = ref(false);
    const list = ref([]);
    const keyword = ref('');

    const search = async () => {
      loading.value = true;
      list.value = await api.search(keyword.value);
      loading.value = false;
    };

    return { loading, list, keyword, search };
  }
}

优点:直观、无解构风险、TS 类型推导友好
注意:返回时别漏写 .value

模板 B:reactive + toRefs(适合状态密集型组件)

export default {
  setup() {
    const state = reactive({
      loading: false,
      list: [] as Item[],
      keyword: ''
    });

    const search = async () => {
      state.loading = true;
      state.list = await api.search(state.keyword);
      state.loading = false;
    };

    return { ...toRefs(state), search };
  }
}

优点:状态聚合、减少变量声明、模板中直接用 list
注意:内部操作用 state.xxx,别解构!

高阶建议:结合 更清爽

如果你用 Vue 3.3+,直接上 :

import { ref } from 'vue'

const loading = ref(false)
const list = ref([])
const keyword = ref('')

const search = async () => {
  loading.value = true
  list.value = await api.search(keyword.value)
  loading.value = false
}

没有 return,没有 setup(),变量自动暴露——这才是 Vue 3 的终极舒适区。

最后说两句

Vue 3 的响应式系统很强大,但自由也意味着责任。
用对了,代码清爽如诗;用错了,bug 隐蔽如鬼。

记住三句话:

  1. 简单用 ref,复杂用 reactive
  2. 别混用,别嵌套,别解构裸对象
  3. 大组件,拆状态,抽 composable

各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

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

基于 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 自适应引擎,完美解决了传统大屏开发中的分辨率适配难题,为水利防汛指挥提供了强有力的技术支撑。

核心价值:

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

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


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

从高阶函数到 Hooks:React 如何减轻开发者的心智负担(含 Demo + ahooks 推荐)

从高阶函数到 Hooks:React 如何减轻开发者的心智负担(含 Demo + ahooks 推荐)

对比 HOC/render props 与 Hooks,用具体 demo 展示「按功能组织、无 this、复用逻辑」的减负效果,并推荐 ahooks 库。


一、高阶函数时代的心智负担

在 Hooks 之前,React 里复用「带状态的逻辑」主要靠两类手段:高阶组件(HOC)render props。二者本质都是「高阶函数」——接收组件或函数,返回增强后的组件或新的渲染方式。它们能解决问题,但会带来明显的心智负担。

1. 嵌套地狱,难以追踪

多个 HOC 叠加时,组件树会变成一层套一层:withAuth(withTheme(withWindowSize(MyPage)))。DevTools 里看到的是一串 WithAuth(WithTheme(WithWindowSize(...)))数据从哪一层来、props 叫什么,都要一层层往上找,调试和阅读成本都很高。

2. this 与生命周期分散逻辑

Class 组件里,this 的绑定(bind 或类字段)是常见坑;同一块逻辑还经常被拆到 componentDidMountcomponentDidUpdate 两处,「根据 A 同步 B」 的代码散落在不同生命周期里,难以按「功能」理解。

3. 命名与透传的样板代码

HOC 要透传 props({...this.props}),还要小心 refdisplayName;render props 则要多写一层函数和命名(如 render={({ x, y }) => ...})。这些都是在解决「逻辑复用」时多出来的心智开销。

下面先用一个具体 demo 对比「HOC 写法」和「自定义 Hook 写法」,直观感受 Hooks 如何减负。


二、Demo 1:窗口尺寸 —— HOC 与 Hook 对比

需求:多个组件需要用到「当前窗口宽高」,并在 resize 时更新。

用 HOC 实现(心智负担大)

// 高阶组件:包装一层 Class,把 width/height 通过 props 注入
function withWindowSize(WrappedComponent) {
    return class WithWindowSize extends React.Component {
        state = { width: window.innerWidth, height: window.innerHeight };
        componentDidMount() {
            this.handler = () => this.setState({
                width: window.innerWidth,
                height: window.innerHeight,
            });
            window.addEventListener('resize', this.handler);
        }
        componentWillUnmount() {
            window.removeEventListener('resize', this.handler);
        }
        render() {
            return (
                <WrappedComponent
                    width={this.state.width}
                    height={this.state.height}
                    {...this.props}
                />
            );
        }
    };
}

// 使用:组件被包一层,DevTools 里多一个 WithWindowSize
const MyPanel = withWindowSize(function MyPanel({ width, height }) {
    return <div>当前宽度:{width}px,高度:{height}px</div>;
});

你要关心:HOC 的 displayNameref 透传(若需要)、以及「数据从哪个 HOC 来」。多个 HOC 叠加时,问题成倍增加。

用自定义 Hook 实现(减负)

// 自定义 Hook:按「一块逻辑」组织,无 Class、无 this
function useWindowSize() {
    const [size, setSize] = useState({
        width: window.innerWidth,
        height: window.innerHeight,
    });
    useEffect(() => {
        const handler = () => setSize({
            width: window.innerWidth,
            height: window.innerHeight,
        });
        window.addEventListener('resize', handler);
        return () => window.removeEventListener('resize', handler);
    }, []);
    return size;
}

// 使用:直接调用,无包装、无嵌套
function MyPanel() {
    const { width, height } = useWindowSize();
    return <div>当前宽度:{width}px,高度:{height}px</div>;
}

减负体现:逻辑集中在 useWindowSize 里,按「功能」一块块组织;组件树扁平,没有多余的包装组件;没有 this,没有生命周期命名,读代码时「用到什么就调什么 Hook」。


三、Demo 2:请求数据 + loading —— 手写 vs ahooks useRequest

需求:请求用户列表,展示 loading、错误和重试。

手写 useEffect(容易漏依赖、重复逻辑)

function UserList() {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);

    useEffect(() => {
        let cancelled = false;
        setLoading(true);
        setError(null);
        fetch('/api/users')
            .then((res) => res.json())
            .then((json) => {
                if (!cancelled) setData(json);
            })
            .catch((e) => {
                if (!cancelled) setError(e);
            })
            .finally(() => {
                if (!cancelled) setLoading(false);
            });
        return () => { cancelled = true; };
    }, []);

    if (loading) return <div>加载中...</div>;
    if (error) return <div>错误:{error.message}</div>;
    return <ul>{data?.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}

你要自己处理:竞态取消、loading/error 状态、重试逻辑若再加一层,代码更长、心智负担更大。

用 ahooks 的 useRequest(减负)

import { useRequest } from 'ahooks';

function UserList() {
    const { data, loading, error, refresh } = useRequest(() =>
        fetch('/api/users').then((res) => res.json())
    );

    if (loading) return <div>加载中...</div>;
    if (error) return <div>错误:{error.message} <button onClick={refresh}>重试</button></div>;
    return (
        <ul>
            {data?.map((u) => <li key={u.id}>{u.name}</li>)}
            <button onClick={refresh}>刷新</button>
        </ul>
    );
}

减负体现竞态、loading、error、重试 都由 useRequest 管,你只关心「发什么请求」和「怎么渲染」;代码更短,逻辑更清晰,心智负担明显下降。


四、Demo 3:防抖输入 —— 手写 vs ahooks useDebounce

需求:搜索框输入防抖,仅在实际停顿后再请求。

手写(要管定时器、清理、依赖)

function SearchBox() {
    const [keyword, setKeyword] = useState('');
    const [debouncedKeyword, setDebouncedKeyword] = useState('');

    useEffect(() => {
        const timer = setTimeout(() => setDebouncedKeyword(keyword), 300);
        return () => clearTimeout(timer);
    }, [keyword]);

    useEffect(() => {
        if (!debouncedKeyword) return;
        fetch(`/api/search?q=${debouncedKeyword}`).then(/* ... */);
    }, [debouncedKeyword]);

    return <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />;
}

你要自己保证:防抖时间、清理、以及「防抖后的值」和「请求」的依赖关系正确。

用 ahooks 的 useDebounce(减负)

import { useDebounce } from 'ahooks';

function SearchBox() {
    const [keyword, setKeyword] = useState('');
    const debouncedKeyword = useDebounce(keyword, { wait: 300 });

    useEffect(() => {
        if (!debouncedKeyword) return;
        fetch(`/api/search?q=${debouncedKeyword}`).then(/* ... */);
    }, [debouncedKeyword]);

    return <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />;
}

减负体现:防抖逻辑交给 useDebounce,你只关心「用防抖后的值做什么」;少写定时器、少操心清理,心智负担更小。


五、React 如何用 Hooks 减轻心智负担(小结三点)

  1. 按功能组织,而非按生命周期
    同一块逻辑(如「窗口尺寸」「请求用户」)收拢在一个 Hook 里,相关代码在一起,读起来是「这个组件用了哪些能力」,而不是「mount 里干了啥、update 里又干了啥」。

  2. 无 this,闭包清晰
    函数组件 + Hooks 没有 this,state 和更新函数都来自 useState 等 API,依赖关系写在 Hook 的依赖数组里,减少「this 指向错了」「忘了 bind」这类问题。

  3. 复用即「调用 Hook」
    复用带状态的逻辑不再依赖 HOC 或 render props 的层层包装,直接「调用自定义 Hook」即可,组件树扁平、数据来源一目了然。

在此基础上,用好现成的 Hooks 库(如 ahooks)可以进一步减少「自己管请求、防抖、节流、缓存」的心智负担,把精力放在业务 UI 和交互上。


六、推荐 ahooks:为业务而生的 Hooks 库

ahooks 是阿里开源的 React Hooks 库,目标是做 Hooks 领域的「lodash」——稳定、可长期依赖。它用 TypeScript 编写,提供完整类型,且针对闭包、SSR 等做了处理,适合在真实项目里直接使用。

安装

npm install ahooks
# 或 pnpm add ahooks / yarn add ahooks

常用 Hooks 一览

场景 Hook 作用简述
异步请求 useRequest 自动/手动请求、loading、重试、轮询、缓存
防抖 / 节流 useDebounce / useThrottle 值或函数的防抖/节流
状态与存储 useLocalStorageState 持久化到 localStorage
DOM / 尺寸 useSizeuseScroll 元素尺寸、滚动位置
生命周期相关 useUnmountuseUpdateEffect 仅卸载时执行、仅更新时执行

与本文 demo 的对应关系

  • Demo 2 用了 useRequest,可直接替换手写的 useEffect + fetch,并享受重试、轮询、缓存等能力。
  • Demo 3 用了 useDebounce,把「防抖后的值」从状态和定时器里抽离出来,代码更短、更稳。

更多 API 和用法见官网:ahooks.js.org/zh-CN


总结

  • 高阶函数(HOC/render props) 能复用逻辑,但带来嵌套、this、生命周期分散等心智负担。
  • Hooks 通过「按功能组织、无 this、复用即调 Hook」减轻负担;用 自定义 Hook 替代 HOC,组件树更扁平、数据流更清晰。
  • 文中用 窗口尺寸、请求数据、防抖输入 三个 demo 对比手写/HOC 与 Hook/ahooks 的写法,直观看到 Hooks 的优势。
  • ahooks 提供 useRequestuseDebounce 等常用能力,建议在项目中直接使用,进一步减少重复逻辑与心智负担。

若对你有用,欢迎点赞、收藏;有更好的 Hooks 实践或 ahooks 用法也欢迎在评论区分享。

前端老哥的救命稻草:用 Obsidian 搞定 Claude Code 的「金鱼记忆」

写在前面

前端开发中常见这些问题:

  • 每次写代码都要翻一遍同样的规范
  • 踩过的坑,过段时间又忘了
  • 项目规范写在文档里,但开发时根本想不起来
  • 想沉淀经验,但不知道从哪下手
  • Claude Code 有时候不按规范写(上下文丢失)

这篇文章讲讲我们怎么用 Obsidian + Claude Code 来解决这些问题。

整体方案

三层结构:

Memory 文件(200行左右)→ Smart Context Skill → Obsidian docs/

工作流程很简单:

  1. 你给 Claude Code 一个编程任务
  2. Smart Context 自动触发
  3. 自动查 Obsidian 里的相关规范和踩坑记录
  4. 带着上下文开始写代码

步骤一:创建 Obsidian 文档结构

在项目根目录建 docs/ 文件夹:

docs/
├── 00-索引.md
├── 01-快速开始.md
├── 02-开发规范/
│   ├── index.md
│   ├── API规范.md
│   ├── 组件使用.md
│   ├── 命名约定.md
│   └── 页面开发.md
├── 03-架构设计/
│   ├── index.md
│   ├── 目录结构.md
│   └── 分包策略.md
├── 04-开发笔记/
│   ├── index.md
│   └── 踩坑记录.md
└── 05-Claude相关/
    ├── index.md
    └── 规则文件说明.md

示例:踩坑记录

docs/04-开发笔记/踩坑记录.md

# 踩坑记录

## Taro 相关

### scroll-view 下拉加载不触发
**问题**: @scrolltolower 事件不触发
**原因**: scroll-view 高度未设置
**解决**: 设置 scroll-y 和 height: 100vh

### 内联 SVG 不支持
**问题**: svg 标签不渲染
**解决**: 使用图片 URL 或 IconFont

示例:API 规范

docs/02-开发规范/API规范.md

# API 开发规范

## 函数命名
- query: 查询/获取
- add: 新增
- edit: 编辑
- delete: 删除
- toggle: 切换状态
- do: 执行操作

## 标准模式
1. try/catch 包裹
2. 检查 code === EResponseCode.Succeed
3. 从 context 提取数据
4. catch 中使用 getHttpErrorMessage

步骤二:Memory 文件

这个是claudecode自带的,/memory去开启即可

文件位置:

~/.claude/projects/-项目名-/memory/MEMORY.md

内容首次微调到精简到 50 行以内 (因为后续cc会自动往里面加记忆):

# Project Memory

## 知识库架构

> Memory(200行核心)→ Obsidian docs/(完整知识)

| 层级 | 存储 | 用途 |
|------|------|------|
| Memory | 核心规范摘要 | 始终加载 |
| Obsidian | 完整文档/踩坑记录 | 检索使用 |

## 核心规范

- **页面**: SafeLayout 根容器,列表 graybg/详情 whitebg
- **API**: query/add/edit/delete/toggle/do + try/catch
- **组件**: 优先 src/components/,禁 SVG 用 IconFont

## 常见避坑

1. scroll-view: 设 scroll-y + height
2. 小程序禁 SVG: 用图片 URL
3. NutUI 样式: 查 auto-import
4. ref template 不需 .value

## Obsidian 检索

```bash
obsidian search query=页面开发 # 搜索
grep -r "xxx" docs/ # 失败时才用 Grep
知识 文件
踩坑 docs/04-开发笔记/踩坑记录.md
API docs/02-开发规范/API规范.md
页面 docs/02-开发规范/页面开发.md

触发条件

编程任务自动检索:新增/修复/重构/询问"怎么做"/业务模块

## 步骤三:创建 Smart Context Skill

在项目 `.claude/skills/` 目录下创建:

.claude/skills/smart-context/
└── skill.md

内容:

---
name: smart-context
description: |
  智能上下文增强技能。自动检索本项目 Obsidian 知识库(docs/)中的项目规范、踩坑记录。
  触发条件:(1) 实现新功能/创建页面/添加API (2) 修复bug/解决报错 (3) 重构代码
  (4) 询问"怎么做" (5) 提到业务模块(提货/结算/销售/会员等)。
  优先使用 obsidian-cli 搜索 Obsidian 文档,失败才使用 Grep。
---

# Smart Context - 智能上下文增强

## 核心原则

1. 以 Obsidian 为知识库,obsidian-cli 为检索工具
2. 当用户说"把这个加入知识库"时,优先使用 obsidian-cli 增加

✅ obsidian search query=文档关键词 ❌ grep -r "scroll-view" docs/


## 工作流程

### 第一步:分析任务意图
- 任务类型:新增/修改/修复/查询
- 业务模块:提货/结算/销售/会员
- 技术领域:API/页面/组件

### 第二步:检索 Obsidian
```bash
obsidian search query="{关键词}"

第三步:按需读取

obsidian read path=docs/04-开发笔记/踩坑记录.md

第四步:注入上下文执行

检索关键词

任务 搜索词
创建页面 页面开发、SafeLayout、列表页
添加 API API规范、query、try catch
修复报错 踩坑、{报错关键词}
提货相关 提货、pickup

示例

用户输入:帮我创建一个退款订单列表页面

自动执行:

  1. 分析:创建页面,销售/退款
  2. 搜索:obsidian search query=页面开发
  3. 搜索:obsidian search query=列表页
  4. 读取踩坑记录
  5. 注入上下文,开始实现

注意事项

  • 必须使用 obsidian-cli,禁止 Grep 搜索 docs/
  • 按需读取,不要整个文件加载
  • 实现前先查踩坑记录,避免重复踩坑

编码实测

配置完成后,实际效果长这样:

左边 Claude Code 正在工作,右边 Obsidian 里的搜索结果同步显示。它自动检索到了相关规范,比如页面开发、SafeLayout 这些关键信息。

再看另一个角度:

左边继续从 Obsidian 拉取踩坑记录,右边代码已经写上了。Smart Context 把规范和避坑信息注入上下文,Claude 直接沿着正确方向写,不需要你中途打断去纠正。


步骤四:验证和使用

验证配置

重启 Claude Code,然后测试:

帮我创建一个订单列表页面

观察 Claude 的行为:

  1. 自动触发 Smart Context
  2. 使用 obsidian search 搜索相关文档
  3. 读取关键片段
  4. 注入上下文后开始实现

添加新知识

遇到新踩坑时,直接告诉 Claude:

把这个加入知识库:Taro 项目上传图片时,如果使用本地路径不显示,
需要使用 require() 或者用 COS 托管的图片 URL

Claude 会自动:

  1. 使用 obsidian-cli 找到踩坑记录文件
  2. 追加新的踩坑内容
  3. 可选的,同步更新 Memory 文件

常见问题

Q0: 如何启用obsidian-cli

在obsidian软件设置->关于-> 打开"允许命令行和obsidian交互“, 然后重启cc会话即可。

同时安装一下mcp服务:

mcp-obsidian.org/install/

再安装obsidian skills

请打开:obsidian skills

Q1:为什么要用 obsidian-cli 而不是 Grep?

  • obsidian-cli 是 Obsidian MCP 工具,专门用于搜索和操作 Obsidian 文档,速度极快
  • Grep 搜索会破坏 Obsidian 的双向链接和知识图谱
  • obsidian-cli 支持更智能的搜索

Q2:Memory 文件太长怎么办?

  • 减少memory篇幅,只放核心规范(约 50-200 行)
  • 详细内容通过双向链接指向 Obsidian 文档
  • 用表格和列表,减少段落

Q3:Obsidian 需要手动打开吗?

不需要。Claude Code 通过 obsidian-cli MCP 工具直接操作,Obsidian 可以关闭,只是一个存储软件,实际cc调用效果如下图:

比如我让他修改文档


总结

配置完成后,你得到一个自动化的知识增强系统:

功能 实现方式
记忆增强 Obsidian 持久存储 + Memory 始终加载
规范约束 Smart Context 自动检索
避坑提醒 踩坑记录 + 自动查询
知识更新 自然语言告诉 Claude "加入知识库"

核心思路:让 Claude Code 在每次编程时自动检索相关规范,而不是靠人工记忆。

关于obsidian更多用法,请关注后续写新的文章~

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

在游戏行业竞争日趋激烈的当下,游戏官网早已不再是单纯的信息展示页,而是承载品牌叙事、玩家沉浸体验、内容传播的核心载体。本篇文章,基于海内外游戏官网案例(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>

总结

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

干掉 Virtual DOM?尤雨溪开始"强推" Vapor Mode?

前端这两年有一个明显趋势:

用编译优化彻底消灭运行时开销。

从 Rust 重写工具链,到服务端组件,底层正在被全面 "静态化"

而这一次,轮到了 Vue。

一个正在快速演进的技术 ——Vapor Mode,正在尝试用 Vue 3.5 重构整套 Vue 渲染体系。

它不仅仅是"再快一点",而是想把 Vue 的响应式系统、组件渲染、模板编译、更新机制全部重写为编译时优化

Vapor Mode 到底是什么?

Vapor Mode = 用编译时优化重写 Vue 渲染器。

它是一个全新的渲染模式(非默认),覆盖:

  • 无 Virtual DOM 渲染(细粒度响应式绑定)
  • 编译时依赖追踪(自动依赖收集)
  • 零运行时开销(无 diff 算法)
  • 原生 DOM 操作(直接更新,无代理)
  • 完整生态兼容(Vue Router、Pinia 无缝支持)

你没看错——它不是一个渐进升级,而是一整套"Vue 渲染器重构计划"。

它和普通模式是什么关系?

很多人第一反应:

那它是不是要干掉 Virtual DOM?

答案:不是同一个层级

  • 普通 Vue 模式 = Virtual DOM + 响应式运行时
  • Vapor Mode = 细粒度响应式 + 编译时优化

更准确理解:

  • 普通模式:data 变化 → 触发 setter → 通知依赖 → Virtual DOM diff → 更新真实 DOM
  • Vapor Mode:data 变化 → 直接触发关联 DOM 节点更新

这更像是:

给 Vue 换一颗"零开销引擎"。

快速上手体验

传统模式:Virtual DOM(经典但昂贵)

你以前写 Vue 组件,大概是这样的:

<template>
  <div>
    <h1>{{ title }}</h1>
    <p>{{ count }}</p>
    <button @click="increment">+1</button>
  </div>
</template>

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

const title = ref('Hello Vue');
const count = ref(0);

const increment = () => {
  count.value++;
};
</script>

运行时发生了什么?

// 简化后的执行流程
1. 组件初始化:创建 Proxy(title, count)
2. 响应式收集:渲染时追踪依赖(title → h1, count → p)
3. 状态更新:count.value++ 触发 setter
4. 依赖通知:通知所有订阅 count 的组件
5. Virtual DOM diff:对比新旧 VNode6. DOM 更新:真实 DOM 仅更新 p 文本

痛点分析:

  • 每次更新都要运行 Virtual DOM diff(即使只改一个数字)
  • Proxy 开销(内存 + CPU)
  • 响应式系统运行时收集依赖
  • 大型应用下 diff 成本显著

Vapor Mode 方式:零运行时开销

<template>
  <div>
    <h1>{{ title }}</h1>
    <p>{{ count }}</p>
    <button @click="increment">+1</button>
  </div>
</template>

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

const title = ref('Hello Vue');
const count = ref(0);

const increment = () => {
  count.value++;
};
</script>

唯一的区别:

<script setup> 中添加 vapor 指令

编译后生成什么?

// 简化后的编译输出(伪代码)
export function render(_ctx) {
  // 1. 直接 DOM 引用(无 VNode)
  const h1 = document.querySelector('h1');
  const p = document.querySelector('p');
  const button = document.querySelector('button');

  // 2. 细粒度绑定(无 Proxy)
  _ctx.title = reactiveValue('Hello Vue', (val) => {
    h1.textContent = val; // 直接更新 DOM
  });

  _ctx.count = reactiveValue(0, (val) => {
    p.textContent = val; // 直接更新 DOM
  });

  button.onclick = () => {
    _ctx.count.value++; // 直接触发更新
  };
}

核心差异:

  • 无 Virtual DOM diff
  • 无 Proxy 开销
  • 编译时依赖追踪
  • 直接 DOM 操作
  • 零运行时响应式系统

架构设计:为什么不只是"更快一点"?

传统 Vue 渲染器(3.x)

  Template Compiler
         ↓
  Render Functions
         ↓
  Virtual DOM Tree
         ↓
  Reconciliation (diff)
         ↓
  Real DOM Updates

特点:

  • 运行时依赖收集(Proxy + Effect)
  • Virtual DOM diff 算法(O(n) 复杂度)
  • 组件级更新(粒度较粗)

Vapor Mode 渲染器

Template Compiler (Vapor)
↓
Dependency Analysis
↓
Fine-grained Binding
↓
Direct DOM Updates

特点:

  • 编译时依赖分析
  • 细粒度绑定(表达式级别)
  • 原生 DOM 操作(无 diff)

对比总结

维度 普通 Vue 模式 Vapor Mode
渲染机制 Virtual DOM diff 直接 DOM 操作
依赖追踪 运行时 Proxy 编译时静态分析
更新粒度 组件级 表达式级
运行时开销 高(diff + Proxy) 极低(仅执行更新逻辑)
编译时优化 有限 极致

性能对比:不是优化,是碾压

官方基准测试(10,000 个简单组件):

场景 普通 Vue 3.4 Vapor Mode 提升
初始渲染 125ms 32ms 3.9×
单个属性更新 8ms 0.8ms 10×
10% 组件更新 45ms 3ms 15×
50% 组件更新 220ms 12ms 18.3×
列表重排序 180ms 5ms 36×

为什么这么快?

1. 无 Virtual DOM diff

// 普通 Vue:每次更新都要 diff
function update() {
  const oldVNode = currentVNode;
  const newVNode = render(); // 重新生成 VNode 树
  const patches = diff(oldVNode, newVNode); // O(n) diff
  applyPatches(patches); // 应用补丁
}

// Vapor Mode:直接更新
function update() {
  textContent.value = newValue; // 直接修改 DOM 文本
}

2. 编译时依赖追踪

<template>
  <div>{{ count }}</div>
</template>
// 普通 Vue:运行时收集
const count = ref(0);
effect(() => {
  div.textContent = count.value; // 运行时追踪依赖
});

// Vapor Mode:编译时已知
const count = reactiveValue(0, (val) => {
  div.textContent = val; // 编译时生成更新逻辑
});

3. 细粒度更新

<template>
  <div>
    <p>Name: {{ user.name }}</p>
    <p>Age: {{ user.age }}</p>
    <p>Email: {{ user.email }}</p>
  </div>
</template>
// 普通 Vue:user 变化 → 整个组件重新渲染
watch(() => user.value, () => {
  render(); // 重渲染整个组件
});

// Vapor Mode:user.name 变化 → 只更新第一个 <p>
user.name.onUpdate((val) => {
  p1.textContent = val; // 只更新对应 DOM
});

user.age.onUpdate((val) => {
  p2.textContent = val;
});

user.email.onUpdate((val) => {
  p3.textContent = val;
});

更疯狂的是:完整生态兼容

Vapor Mode 不是重写所有 Vue,而是无缝集成:

1. Vue Router 兼容

<!-- app.vue -->
<script setup vapor>
import { RouterView } from 'vue-router';
</script>

<template>
  <RouterView /> <!-- Vapor 组件可以渲染普通组件 -->
</template>

2. Pinia 兼容

// stores/counter.ts
import { defineStore } from 'pinia';

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0
  }),
  actions: {
    increment() {
      this.count++;
    }
  }
});
<!-- components/Counter.vue -->
<script setup vapor>
import { useCounterStore } from '@/stores/counter';

const store = useCounterStore();
</script>

<template>
  <p>{{ store.count }}</p>
  <button @click="store.increment">+1</button>
</template>

3. 渐进式采用

<!-- 混合使用:Vapor 组件 + 普通组件 -->
<script setup vapor>
import OrdinaryComponent from './OrdinaryComponent.vue';
</script>

<template>
  <OrdinaryComponent /> <!-- 普通组件在 Vapor 组件中正常工作 -->
</template>

现在能生产使用吗?

部分可用。

Vapor Mode 当前状态:

  • 核心功能稳定(Vue 3.5+)
  • 完整生态兼容
  • TypeScript 支持
  • 部分指令仍在完善(v-for、v-if 复杂场景)
  • 调试工具仍在改进

建议采用场景:

  • 性能敏感型应用(高频更新列表)
  • 移动端应用(低性能设备)
  • 数据可视化(实时图表)
  • 简单 CRUD 应用(收益不明显)

总结一句话

如果说:

  • Vue 2 用 Virtual DOM 解决了 "跨浏览器兼容性"
  • Vue 3 用 Composition API 解决了 "代码复用性"

那 Vapor Mode 正在解决 "极致性能"

它可能不会明天取代所有 Vue 模式(渐进升级策略),

但它已经说明了一件事:

Vue 的未来,不止是框架升级,而是渲染器升级。

如果你是:

  • Vue 深度使用者
  • 性能优化爱好者
  • 编译原理探索者
  • 或对前端性能有极致追求

这个技术值得关注。

官方资源:

扩展阅读:

  • SolidJS 的细粒度响应式系统
  • Svelte 的编译时优化
  • Qwik 的 Resumability 架构

各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

04 | 别再写几十个参数的构造函数了——建造者模式

不知道你有没有接手过那种“祖传代码”,里面有一个极其庞大的类,初始化的时候需要传十几个参数。每次调用它,你都得小心翼翼地数逗号:new User('张三', null, true, 18, null, 'admin', ...)

一旦中间少传了一个 null,或者把第 5 个参数和第 6 个参数搞反了,整个程序直接报错,查都查不出来。 我以前写这种代码的时候,自己都觉得心虚,生怕哪天把自己给坑了。

今天咱们聊的这个建造者模式(Builder Pattern),就是专门为了解决这种“参数地狱”而生的。

为什么我们总是被“参数列表”搞得晕头转向?

说白了,这种长参数列表的问题,在于我们试图一口吃成个胖子。 我们想在实例化的一瞬间,把所有属性都塞进去。

但这违背了人类的认知习惯。 想象一下你去赛百味(Subway)买三明治。 你不会一进门就冲着店员喊一串代码:“我要全麦面包加火腿加生菜去洋葱加蛋黄酱烤热带走!” 店员肯定懵圈。

正确的流程是分步骤: 先选面包,再选肉,然后选配菜,最后选酱料。 每一步都是独立的,你可以选,也可以不选。

建造者模式的底层逻辑就是:把一个复杂对象的“构建过程”和它的“部件”分离。 不再是一次性 new 出来,而是通过一个专门的“建造者”,一步一步地把对象组装起来。

怎么把代码写得像“点菜”一样优雅?

在 JavaScript 里,我们可以利用链式调用(Chaining),把这个模式实现得非常漂亮。

假设我们要创建一个复杂的 Request 对象,用来发网络请求。

如果不适用模式,代码是这样的:

// 参数太多,根本记不住哪个位置是干啥的
// 第三个参数是 timeout 还是 headers?完全靠猜
const req = new HttpRequest('https://api.com', 'POST', null, 5000, { 'Content-Type': 'json' });

现在,我们用建造者模式改造一下:

class RequestBuilder {
  constructor(url) {
    this.url = url;
    this.method = 'GET'; // 默认值
    this.headers = {};
    this.body = null;
  }

  setMethod(method) {
    this.method = method;
    return this; // 关键:返回 this,实现链式调用
  }

  setHeader(key, value) {
    this.headers[key] = value;
    return this;
  }

  setBody(data) {
    this.body = JSON.stringify(data);
    return this;
  }

  // 最后一步:产出真正的对象
  build() {
    // 这里还可以加校验逻辑,比如:如果是 POST,必须有 body
    if (this.method === 'POST' && !this.body) {
      throw new Error('POST 请求必须有 Body');
    }
    return {
      url: this.url,
      method: this.method,
      headers: this.headers,
      body: this.body
    };
  }
}

// 使用起来就像写文章一样流畅
const request = new RequestBuilder('https://api.com')
  .setMethod('POST')
  .setHeader('Authorization', 'Bearer xxx')
  .setBody({ name: '小美' })
  .build();

两种写法的直观对比

容易出问题的写法: new Class(a, b, c, d, e...) 后果:代码可读性极差,维护者必须对着文档数参数位置。如果中间要插入一个新参数,所有调用方都得改。

更稳健的建造者写法: .setA().setB().build() 后果:代码本身就是文档,读起来像英语句子。参数顺序无所谓,不想传的参数直接跳过,用默认值即可。

给你的 3 条行动建议

  1. 参数超过 4 个就该警惕了:如果你的构造函数参数超过 4 个,或者有好几个参数是可选的(经常传 null),别犹豫,马上换成建造者模式,或者至少用“配置对象”传参。

  2. 把校验逻辑放在 build 里:这是建造者模式最大的隐藏红利。你可以在 build() 方法里统一检查“A 属性存在时 B 属性是否也存在”,保证产出的对象永远是合法的。

  3. JS 的“配置对象”其实是简化版:在 JS 里,我们经常直接传一个对象 { url: '...', method: '...' }。这其实是建造者模式的一种“变体”。但如果你需要复杂的构建逻辑(比如根据 A 参数自动计算 B 参数),标准的 Builder 类还是更清晰。

我以前总觉得多写一个 Builder 类是增加代码量。 后来在一次重构中,我把一个 12 个参数的初始化函数改成了 Builder,那天下午我看着那段清晰的代码,心里那个舒坦。

代码是写给人看的,顺便给机器运行。 让调用者用得舒服,是你作为 API 设计者的温柔。

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

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项目,让更多开发者受益!

How to Install TeamViewer on Ubuntu 24.04

TeamViewer is a cross-platform remote access and support solution that allows you to connect to and control remote computers over the internet. It supports remote control, file transfer, desktop sharing, and online meetings across Windows, macOS, and Linux.

This guide explains how to install TeamViewer on Ubuntu 24.04.

Quick Reference

Task Command
Download package wget https://download.teamviewer.com/download/linux/teamviewer_amd64.deb
Install sudo apt install ./teamviewer_amd64.deb
Launch teamviewer
Update sudo apt update && sudo apt upgrade teamviewer
Remove sudo apt remove teamviewer

Prerequisites

You need to be logged in as root or as a user with sudo privileges .

Installing TeamViewer on Ubuntu 24.04

TeamViewer is proprietary software and is not available in the standard Ubuntu repositories. You will download and install the official .deb package directly from TeamViewer.

Open your terminal and download the latest TeamViewer .deb package using wget :

Terminal
wget https://download.teamviewer.com/download/linux/teamviewer_amd64.deb

Once the download is complete, update the package index and install the package:

Terminal
sudo apt update
sudo apt install ./teamviewer_amd64.deb

When prompted Do you want to continue? [Y/n], type Y to confirm.

Info
TeamViewer depends on Qt libraries. The installation pulls in several Qt packages automatically. During the process, the official TeamViewer APT repository is also added to your system, which keeps TeamViewer up to date through the standard package manager.

TeamViewer is now installed on your Ubuntu 24.04 system.

Starting TeamViewer

You can launch TeamViewer from the command line:

Terminal
teamviewer

Alternatively, open the Activities menu, search for “TeamViewer”, and click its icon.

When TeamViewer starts for the first time, it will prompt you to accept the license agreement. Click “Accept License Agreement” to continue.

After accepting, the main TeamViewer window opens and displays your ID and Password. Share these with anyone who needs to connect to your machine, or enter a remote computer’s ID to initiate a connection yourself.

TeamViewer main window showing ID and password on Ubuntu 24.04

Updating TeamViewer

During installation, the official TeamViewer repository is added to your system. You can verify it with the cat command :

Terminal
cat /etc/apt/sources.list.d/teamviewer.list
output
deb https://linux.teamviewer.com/deb stable main

Because the repository is configured, you can update TeamViewer the same way you update any other package:

Terminal
sudo apt update
sudo apt upgrade teamviewer

You can also update through the Software Updater application in the GNOME desktop.

Uninstalling TeamViewer

To remove TeamViewer while keeping its configuration files:

Terminal
sudo apt remove teamviewer

To remove TeamViewer along with all its configuration files:

Terminal
sudo apt purge teamviewer

To also remove the TeamViewer APT repository so it no longer appears in your package sources:

Terminal
sudo rm /etc/apt/sources.list.d/teamviewer.list
sudo apt update

Troubleshooting

teamviewer: command not found after installation Close and reopen your terminal, or log out and back in so the new binary is picked up by your shell’s PATH. If the issue persists, verify the installation with which teamviewer or dpkg -l teamviewer.

Qt library errors during installation Run sudo apt install -f to resolve broken or missing dependencies, then retry the installation.

dpkg error when installing the .deb file Run sudo dpkg --configure -a to fix any interrupted package operations, then run sudo apt install ./teamviewer_amd64.deb again.

TeamViewer shows “Not ready. Please check your connection.” This usually means the TeamViewer daemon is not running or cannot reach the internet. Try restarting the service:

Terminal
sudo systemctl restart teamviewerd

Verify the service is active:

Terminal
sudo systemctl status teamviewerd

Wrong architecture — running an ARM system The teamviewer_amd64.deb package is for 64-bit x86 systems. For ARM-based systems such as a Raspberry Pi running Ubuntu, download the ARM package from the TeamViewer Linux downloads page .

FAQ

Is TeamViewer free on Linux? TeamViewer is free for personal and non-commercial use. Commercial use requires a paid license. See the TeamViewer pricing page for details.

Does the same installation method work on Ubuntu 22.04 and Debian? Yes. The same .deb package and apt install steps work on Ubuntu 22.04 and other Debian-based distributions. For Ubuntu 22.04 specific instructions, see How to Install TeamViewer on Ubuntu 22.04 .

How do I connect to a remote computer? Open TeamViewer, enter the remote computer’s ID in the “Partner ID” field, and click “Connect”. Enter the password when prompted. The remote session starts immediately if the correct credentials are provided.

How do I allow unattended access? In the TeamViewer main window, go to Extras → Options → Security and set a personal password. This allows connections to your machine without requiring someone to be present to share the one-time password.

Conclusion

TeamViewer can be installed on Ubuntu 24.04 in two commands — download the .deb package and install it with apt. The TeamViewer repository added during installation keeps the application up to date through the standard Ubuntu update process.

If you have any questions, feel free to leave a comment below.

Flutter 实现 AI 聊天页面 —— 记一次 Markdown 数学公式显示的踩坑之旅

Flutter 实现 AI 聊天页面 —— 记一次 Markdown 数学公式显示的踩坑之旅

最近在做一个 Flutter 智能体聊天组件库,AI 返回的内容里如果经常夹着数学公式,结果页面上全是 $E=mc^2$ 这种原始字符串,如果是比较复杂的数学公式完全看不懂写的是啥, 完全渲染不出来正确的公式格式。折腾了一天,终于找到了一套比较完整的解决方案,记录一下。


先说说背景

项目是一个 Flutter 插件库,对外暴露两个组件:

  • AiChatPage:独立聊天页面,直接 push 进去就能用
  • AiChatWidget:可嵌入任意页面的聊天 Widget

AI 的回复走流式输出(SSE),内容是 Markdown 格式。用 flutter_markdown 渲染普通内容没问题,但一碰到公式就原样显示了,因为标准 Markdown 压根不认识 $...$ 这个语法。


问题在哪

flutter_markdown 底层依赖 markdown 包做解析。整个渲染流程分两步:

  1. 解析:把 Markdown 文本解析成 AST 节点树
  2. 渲染:遍历 AST,把每个节点转成 Flutter Widget

公式渲染挂的点在第一步——markdown 包不认识 $...$,直接把它当普通文本处理了,后面的渲染器根本没有机会介入。

所以光加一个渲染器是不够的,解析层和渲染层都要动


解法:在解析层注入自定义语法

markdown 包提供了 InlineSyntax 接口,可以用正则表达式匹配任意行内语法,命中后生成自定义 AST 节点。

我写了两个解析器:

/// 行内公式:$...$
class InlineMathSyntax extends md.InlineSyntax {
  InlineMathSyntax() : super(r'\$([^\$]+)\$');

  @override
  bool onMatch(md.InlineParser parser, Match match) {
    final element = md.Element.text('math', match[1]!);
    element.attributes['inline'] = 'true';
    parser.addNode(element);
    return true;
  }
}

/// 块级公式:$$...$$
class BlockMathSyntax extends md.InlineSyntax {
  BlockMathSyntax() : super(r'\$\$([^\$]+)\$\$');

  @override
  bool onMatch(md.InlineParser parser, Match match) {
    final element = md.Element.text('math', match[1]!);
    element.attributes['inline'] = 'false';
    parser.addNode(element);
    return true;
  }
}

这里有个小细节:用 element.attributes['inline'] 把"行内/块级"信息带到后续渲染阶段,不然渲染器不知道该怎么处理。

然后把这两个解析器注册进去:

extensionSet: md.ExtensionSet(
  md.ExtensionSet.gitHubFlavored.blockSyntaxes,
  <md.InlineSyntax>[
    ...md.ExtensionSet.gitHubFlavored.inlineSyntaxes,
    InlineMathSyntax(),
    BlockMathSyntax(),
  ],
),

解法:在渲染层用 flutter_math_fork 渲染

解析层把公式内容提取成 math 标签节点了,接下来写一个 MarkdownElementBuilder 来消费它:

class MathElementBuilder extends MarkdownElementBuilder {
  final ChatTheme theme;

  @override
  Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) {
    final mathContent = element.textContent;
    final isInline = element.attributes['inline'] == 'true';
    return _buildMathWidget(mathContent, isInline);
  }

  Widget _buildMathWidget(String mathContent, bool isInline) {
    try {
      if (isInline) {
        return Math.tex(
          mathContent,
          mathStyle: MathStyle.text,
          options: MathOptions(fontSize: theme.fontSize, color: theme.aiTextColor),
        );
      } else {
        // 块级公式加水平滚动,防止长公式撑出屏幕
        return SingleChildScrollView(
          scrollDirection: Axis.horizontal,
          child: Math.tex(
            mathContent,
            mathStyle: MathStyle.display,
            options: MathOptions(fontSize: theme.fontSize + 2, color: theme.aiTextColor),
          ),
        );
      }
    } catch (e) {
      // 渲染失败就降级,把原始文本显示出来,总比白屏强
      return Text(
        isInline ? '\$$mathContent\$' : '\$\$$mathContent\$\$',
        style: TextStyle(color: Colors.red, fontFamily: 'monospace'),
      );
    }
  }
}

最后把渲染器挂上:

builders: {
  'code': OptimizedCodeElementBuilder(theme: theme),
  'math': MathElementBuilder(theme: theme),
},

又踩了一个坑:表格里的行内公式会溢出

以为公式能显示就搞定了,结果发现公式出现在表格单元格里时,因为父容器宽度受限,长公式直接溢出报 overflow 错误。

最开始想直接给所有行内公式套一个 SingleChildScrollView,但这样又会影响在普通段落里的布局。

后来用 LayoutBuilder 判断了一下:

class _InlineMathWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final mathWidget = Math.tex(mathContent, mathStyle: MathStyle.text, ...);

    return LayoutBuilder(
      builder: (context, constraints) {
        // 父容器宽度有限(比如表格单元格)才加滚动
        if (constraints.maxWidth.isFinite && constraints.maxWidth < double.infinity) {
          return SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            physics: const ClampingScrollPhysics(),
            child: mathWidget,
          );
        }
        // 普通段落里直接渲染就好
        return mathWidget;
      },
    );
  }
}

这样既解决了溢出,又不影响正常场景的性能。


流式输出导致的性能问题

AI 是流式回复的,每来一个 chunk 就更新一次消息内容,MarkdownRenderer 会跟着重建。如果每次都重新渲染一遍所有公式,当内容稍微长一点,肉眼就能看到卡顿。

我加了一个基于 LinkedHashMap 实现的 LRU 缓存,把渲染好的公式 Widget 缓存起来:

class LRUCache<K, V> {
  final int capacity;
  final LinkedHashMap<K, V> _cache = LinkedHashMap();

  V? operator [](K key) {
    if (!_cache.containsKey(key)) return null;
    final value = _cache.remove(key)!;
    _cache[key] = value; // 移到末尾(最近使用)
    return value;
  }

  void operator []=(K key, V value) {
    if (_cache.containsKey(key)) _cache.remove(key);
    else if (_cache.length >= capacity) _cache.remove(_cache.keys.first);
    _cache[key] = value;
  }
}

缓存 key 用 公式内容 hashCode + 是否行内 + 主题 hashCode 组合,数学公式缓存上限设 100 个,代码块 50 个,基本够用了。

另外每个公式都包了一层 RepaintBoundary,流式更新时只重绘变化部分,不会连带整个消息列表重绘。


最终用法

依赖这几个包:

dependencies:
  flutter_markdown: ^0.7.x
  markdown: ^7.x
  flutter_math_fork: ^0.7.x
  flutter_highlight: ^0.7.x

使用的时候配置好 ChatConfig 和主题,传入控制器就行:

final controller = ChatStreamController(
  config: ChatConfig(
    apiProviders: {'default': myApiService},
    enableMarkdown: true,
  ),
);

// 嵌入页面
AiChatWidget(
  controller: controller,
  theme: ChatTheme.light(),
)

// 或者独立页面
Navigator.push(context, MaterialPageRoute(
  builder: (_) => AiChatPage(
    controller: controller,
    theme: ChatTheme.light(),
    title: 'AI 助手',
  ),
));

总结

整个问题其实不复杂,捋清楚之后就是两步:

  1. 解析层:继承 InlineSyntax,用正则把 $...$$$...$$ 识别出来,转成自定义 AST 节点
  2. 渲染层:继承 MarkdownElementBuilder,用 flutter_math_fork 把节点渲染成 Widget

额外需要注意的是:

  • 块级公式要加横向滚动,防止溢出
  • 行内公式在有宽度限制的容器里也要加滚动(用 LayoutBuilder 判断)
  • 流式场景下务必加缓存,不然会卡
  • 公式解析失败要有降级处理,不能白屏

flutter_markdown 的扩展机制其实相当灵活,这套解析器 + 渲染器的模式可以推广到任何自定义 Markdown 元素,不只是数学公式。

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

前言

  • 每次起新项目,都要复制一整套「祖传」配置、改包名、删示例,稍不留神就漏掉埋点或兼容文件?
  • 团队里 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】网页从解析到绘制的全流程

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

这台机器本质上就是一个精密的代码代工厂。纯文本的 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 标准与最佳实践,我们将直接拆解这套规范,看看实战中到底该用什么样的标准来编写代码。

微信小程序实现随机撒花效果

微信小程序实现随机撒花效果的完整指南

效果展示

撒花效果是一种常见的页面装饰动画,特别是在活动页面、节日庆祝等场景中非常受欢迎。本文将详细介绍如何在微信小程序中实现一个随机撒花效果,包括随机位置、随机图片、随机动画时间等特性。

实现原理

实现撒花效果主要分为三个部分:

  1. 数据生成:通过 JavaScript 生成随机的撒花数据
  2. 页面渲染:使用 WXML 循环渲染撒花元素
  3. 动画效果:通过 CSS 定义撒花的下落和旋转动画

核心代码实现

1. 数据生成(JavaScript)

首先,我们需要创建一个函数来生成随机的撒花数据:

// 随机生成撒花
generateFlowerEffectArray(numFlowers){
  const images = Array.from({ length: 12 }, (_, i) => i + 1); // 生成1到12的图片编号数组  
  const maxTop = 100; // 撒花效果在视窗内的顶部范围
  const maxLeft = 750; // 撒花效果在视窗内的左侧范围
  const minTime = 2000; // 最小显示时间(毫秒)
  const maxTime = 7000; // 最大显示时间(毫秒)

  let flowerArray = [];  
  for (let i = 0; i < numFlowers; i++) {  
      const image = images[Math.floor(Math.random() * images.length)]; // 随机选择图片编号  
      const top = Math.random() * maxTop; // 随机顶部位置  
      const left = Math.random() * maxLeft; // 随机左侧位置  
      const time = (minTime + Math.random() * (maxTime - minTime))/1000; // 随机显示时间  

      flowerArray.push({  
          image,  
          top: -Math.round(top), // 负数表示从屏幕顶部外开始下落
          left: Math.round(left), // 四舍五入取整
          time: `${time}s`
      });  
  }  
  this.setData({
    ribbon: flowerArray
  })
}

2. 页面渲染(WXML)

在 WXML 文件中,我们使用 wx:for 循环渲染撒花元素:

<!-- 撒花效果 -->
<view>
  <block wx:for="{{ribbon}}" wx:key="{{index}}">
    <view class="ribbon" style="width:32rpx;height:32rpx;top:{{item.top}}rpx;left:{{item.left}}rpx;animation-duration:{{item.time}};animation-delay:{{index<80 ? '0s':'1.5s'}}">
            <image src="/path/to/ribbon{{item.image}}.png" style="width:32rpx;height:32rpx;animation-name: {{index%2==0 ? 'clockwiseSpin':'counterclockwiseSpinAndFlip'}};animation-duration:{{item.time}};animation-delay:{{index<80 ? '0s':'1.5s'}}"/>
    </view>
  </block>
</view>

3. 动画效果(CSS/SCSS)

在 SCSS 文件中,我们定义撒花的动画效果:

/* 撒花容器样式 */
.ribbon {
  position: absolute;
  animation-name: fade, drop;
  animation-delay: 0s, 0s;
  animation-iteration-count: infinite, infinite;
  animation-direction: normal, normal;
  animation-timing-function: linear, ease-in;

  image {
    animation-iteration-count: infinite;
    animation-direction: alternate;
    animation-timing-function: ease-in-out;
    transform-origin: 50% -100%;
  }
}

/* 淡入淡出动画 */
@keyframes fade {
  0% {
    opacity: 1;
  }

  95% {
    opacity: 1;
  }

  100% {
    opacity: 0;
  }
}

/* 下落动画 */
@keyframes drop {
  0% {
    -webkit-transform: translate(0px, -50px);
  }

  100% {
    -webkit-transform: translate(0px, 1344rpx);
  }
}

/* 顺时针旋转动画 */
@keyframes clockwiseSpin {
  0% {
    -webkit-transform: rotate(-80deg);
  }

  100% {
    -webkit-transform: rotate(80deg);
  }
}

/* 逆时针旋转并翻转动画 */
@keyframes counterclockwiseSpinAndFlip {
  0% {
    -webkit-transform: scale(-1, 1) rotate(50deg);
  }

  100% {
    -webkit-transform: scale(-1, 1) rotate(-50deg);
  }
}

完整实现步骤

1. 初始化撒花效果

在页面加载时调用 generateFlowerEffectArray 函数,生成指定数量的撒花:

onLoad(options) {
  // 其他初始化代码...
  this.generateFlowerEffectArray(50); // 生成50个撒花元素
  // 其他初始化代码...
}

2. 图片资源准备

准备 12 张不同的撒花图片,命名为 ribbon1.pngribbon12.png,并放置在合适的目录中。

3. 样式调整

根据实际页面布局,调整以下参数:

  • maxTop:撒花开始下落的顶部范围
  • maxLeft:撒花的水平分布范围
  • minTimemaxTime:撒花下落的时间范围
  • animation-duration:动画持续时间
  • animation-delay:动画延迟时间

实现效果解析

  1. 随机性:通过 Math.random() 实现撒花的随机位置、随机图片和随机下落时间
  2. 层次感:通过 animation-delay 实现分批下落,增强视觉层次感
  3. 动态效果:结合 drop 下落动画和 clockwiseSpin/counterclockwiseSpinAndFlip 旋转动画,使撒花效果更加生动
  4. 性能优化:通过合理控制撒花数量,避免过多元素导致性能问题

代码优化建议

  1. 图片资源优化

    • 压缩撒花图片,减少加载时间
    • 考虑使用精灵图(Sprite)减少请求次数
  2. 动画性能优化

    • 使用 transformopacity 进行动画,避免重排
    • 考虑使用 will-change 属性提示浏览器优化动画
  3. 可配置性增强

    • 将撒花参数(数量、范围、速度等)提取为配置项
    • 支持不同场景的撒花效果定制

完整示例代码

JavaScript 部分

Page({
  data: {
    ribbon: []
  },

  onLoad() {
    this.generateFlowerEffectArray(50);
  },

  // 随机生成撒花
  generateFlowerEffectArray(numFlowers){
    const images = Array.from({ length: 12 }, (_, i) => i + 1);
    const maxTop = 100;
    const maxLeft = 750;
    const minTime = 2000;
    const maxTime = 7000;

    let flowerArray = [];
    for (let i = 0; i < numFlowers; i++) {
      const image = images[Math.floor(Math.random() * images.length)];
      const top = Math.random() * maxTop;
      const left = Math.random() * maxLeft;
      const time = (minTime + Math.random() * (maxTime - minTime))/1000;

      flowerArray.push({
        image,
        top: -Math.round(top),
        left: Math.round(left),
        time: `${time}s`
      });
    }
    this.setData({
      ribbon: flowerArray
    })
  }
})

WXML 部分

<view class="container">
  <!-- 撒花效果 -->
  <view>
    <block wx:for="{{ribbon}}" wx:key="{{index}}">
      <view class="ribbon" style="width:32rpx;height:32rpx;top:{{item.top}}rpx;left:{{item.left}}rpx;animation-duration:{{item.time}};animation-delay:{{index<80 ? '0s':'1.5s'}}">
              <image src="/path/to/ribbon{{item.image}}.png" style="width:32rpx;height:32rpx;animation-name: {{index%2==0 ? 'clockwiseSpin':'counterclockwiseSpinAndFlip'}};animation-duration:{{item.time}};animation-delay:{{index<80 ? '0s':'1.5s'}}"/>
      </view>
    </block>
  </view>
</view>

CSS 部分

.container {
  position: relative;
  overflow: hidden;
  height: 100vh;
}

.ribbon {
  position: absolute;
  animation-name: fade, drop;
  animation-delay: 0s, 0s;
  animation-iteration-count: infinite, infinite;
  animation-direction: normal, normal;
  animation-timing-function: linear, ease-in;
}

.ribbon image {
  animation-iteration-count: infinite;
  animation-direction: alternate;
  animation-timing-function: ease-in-out;
  transform-origin: 50% -100%;
}

@keyframes fade {
  0% { opacity: 1; }
  95% { opacity: 1; }
  100% { opacity: 0; }
}

@keyframes drop {
  0% { transform: translate(0px, -50px); }
  100% { transform: translate(0px, 1344rpx); }
}

@keyframes clockwiseSpin {
  0% { transform: rotate(-80deg); }
  100% { transform: rotate(80deg); }
}

@keyframes counterclockwiseSpinAndFlip {
  0% { transform: scale(-1, 1) rotate(50deg); }
  100% { transform: scale(-1, 1) rotate(-50deg); }
}

总结

通过以上实现,我们可以在微信小程序中创建一个视觉效果丰富的随机撒花动画。这种效果不仅可以增强页面的视觉吸引力,还能为用户带来愉悦的交互体验。

实现过程中,我们通过 JavaScript 生成随机数据,WXML 循环渲染元素,CSS 定义动画效果,三部分紧密配合,共同构成了一个完整的撒花效果。

你可以根据实际需求调整参数,创造出不同风格的撒花效果,为你的小程序增添更多活力和趣味性。

模块化和组件化的区别

1. 模块化:代码层面的 “零件拆分”

模块化是把单一功能的代码从整体中抽离成独立文件,核心是 “功能解耦”,关注 “代码怎么写”。

  • 比如:

    • 把日期格式化函数封装成 date-utils.js 模块
    • 把接口请求逻辑封装成 api/user.js 模块
    • 把通用样式变量封装成 variables.scss 模块
  • 示例代码(JS 模块化):

    javascript

    运行

    // 模块化:utils/date.js(纯功能逻辑)
    export function formatDate(date) {
      return new Intl.DateTimeFormat('zh-CN').format(new Date(date));
    }
    
    // 其他文件中引入使用
    import { formatDate } from './utils/date.js';
    console.log(formatDate('2026-03-02')); // 2026/3/2
    

2. 组件化:界面层面的 “积木拼装”

组件化是把可独立复用的 UI 单元封装成独立模块,核心是 “UI 解耦”,关注 “界面怎么拼”。一个组件通常包含:模板(HTML)+ 逻辑(JS)+ 样式(CSS),是 “完整的功能单元”。

  • 比如:

    • Vue 中的 Button.vue 组件(包含按钮的样式、点击逻辑、显示文本)
    • React 中的 Card.jsx 组件(包含卡片布局、内容渲染、交互事件)
  • 示例代码(Vue 组件化):

    vue

    <!-- 组件化:components/Button.vue(UI+逻辑+样式) -->
    <template>
      <button class="my-btn" @click="handleClick">{{ text }}</button>
    </template>
    
    <script>
    // 引入模块化的工具函数(组件化依赖模块化)
    import { log } from '../utils/logger.js';
    
    export default {
      props: {
        text: { type: String, default: '按钮' }
      },
      methods: {
        handleClick() {
          log('按钮被点击');
          this.$emit('click');
        }
      }
    };
    </script>
    
    <style scoped>
    .my-btn {
      padding: 8px 16px;
      border: none;
      background: #007bff;
      color: white;
    }
    </style>
    

3、两者的关联

  1. 组件化依赖模块化:一个组件内部会拆分多个模块(比如引入工具函数模块、接口请求模块);
  2. 模块化是组件化的基础:先有代码层面的模块化拆分,才能更好地构建高内聚的组件;
  3. 最终目标一致:都是为了降低耦合、提高复用性、便于维护,只是关注的层面不同。

总结

  1. 模块化聚焦功能逻辑的拆分,是 “代码级” 的复用,解决 “代码怎么组织” 的问题;
  2. 组件化聚焦UI 单元的封装,是 “界面级” 的复用,解决 “页面怎么拼装” 的问题;
  3. 组件化是模块化思想在 UI 层的具体落地,模块化的范围更广,组件化是模块化的一种特殊形式(UI 模块)。

# Three.js 进阶:如何绘制"像素大小固定"的箭头?三种方案全解析

🎯 一句话总结:在 3D 场景中绘制 2D UI 标记,既要「像素恒定」又要「性能可控」,选对方案比写对代码更重要。

在 WebGL / Three.js 开发中,我们经常遇到这样的需求:

在 3D 场景中绘制标记(如箭头、图标),但要求它们在屏幕上保持固定的像素大小,不随相机距离缩放。

比如高德/百度地图上的 POI 图标,无论你怎么缩放地图,图标永远是 32px 大小。在 Three.js 中,普通的 Mesh 会遵循透视投影(近大远小),想要实现「像素固定」,我们需要一些特殊的技巧。

本文将分享我在智驾标注工具项目中实际使用的 3 种方案,分别基于 SpritePoints (Shader)Points (Texture),并附上完整的 TypeScript + Vue3 源码。


📋 方案速览(先选再看)

方案 核心技术 ✅ 推荐场景 ❌ 避坑场景 复杂度
Sprite + Shader Clip Space 像素偏移 • 箭头 < 50 个
• 需亚像素级对齐尖端
• 形状/动画复杂
• 海量数据(DrawCall 爆炸) ⭐⭐⭐
Points + Shader gl_PointSize + Attribute • 箭头 > 1000 个
• 批量轨迹/风场可视化
• 需 per-point 属性控制
• 未合并 Geometry(性能归零) ⭐⭐⭐⭐
Points + Texture sizeAttenuation:false + CanvasTexture • 快速原型/调试
• 所有箭头方向相同
• UI 覆盖层
• 大量不同方向箭头 + 未用 attribute
✨ 混合方案 CanvasTexture + attribute + 合并 Geometry 生产环境首选
• 50~5000 箭头
• 需灵活样式 + 批量渲染
• 极端海量(>10w 点需考虑 instancing) ⭐⭐⭐⭐

方案一:Sprite + 自定义 Shader(Clip Space 偏移)

🎯 最灵活、精度最高的方案,适合对对齐要求严苛的场景(如标注工具中的车辆朝向箭头)。

🔬 核心原理

  1. 使用 THREE.Sprite,因为它始终面向相机(Billboard 效果)
  2. 在 Vertex Shader 中,先计算中心点的 Clip Space 坐标
  3. 直接在 Clip Space(裁剪空间)中应用像素级偏移
  4. 关键公式
    Offset_Clip = Offset_Pixel / Viewport_Size * 2.0 * glPos.w
    

    乘以 glPos.w 是为了抵消后续的透视除法,确保大小恒定

✅ 优点

  • 完美像素控制:不受 gl_PointSize 限制,想画多大画多大
  • 形状自由:Fragment Shader 里可以画任意形状(箭头、圆点、圆环、动画)
  • 对齐精确:通过 anchor uniform 轻松调整锚点,让箭头尖端精确对准目标点

⚠️ 注意事项

  • 每个 Sprite 是独立对象 → DrawCall = 箭头数量
  • 建议箭头数量控制在 50 个以内,否则性能下降明显

📦 完整源码 (SpriteArrow.ts)

import * as THREE from 'three'
import { ShallowRef } from 'vue'

// 屏幕分辨率 Uniform (共享)
// 注意:使用 getDrawingBufferSize 适配 renderer 的 pixelRatio
const uResolution = { value: new THREE.Vector2() }

export function setupResolution(renderer: THREE.WebGLRenderer) {
  const update = () => {
    renderer.getDrawingBufferSize(uResolution.value)
  }
  update()
  renderer.on('resize', update)
  return () => renderer.off('resize', update) // 返回清理函数
}

// 创建方向三角形 Shader 材质
const createDirectionTriangleMaterial = (color: THREE.ColorRepresentation = 0x00ff88) => {
  return new THREE.ShaderMaterial({
    transparent: true,
    side: THREE.DoubleSide,
    uniforms: {
      color: { value: new THREE.Color(color) },
      opacity: { value: 1.0 },
      rotation: { value: 0.0 },      // 旋转角度(弧度)
      resolution: uResolution,       // 屏幕分辨率
      size: { value: 16.0 },         // 目标像素大小
      anchor: { value: new THREE.Vector2(0.5, 1.0) } // 锚点:(0.5,1.0)=顶部中心
    },
    vertexShader: `
      uniform float rotation;
      uniform vec2 resolution;
      uniform float size;
      uniform vec2 anchor;
      varying vec2 vUv;
      
      void main() {
        vUv = uv;
        
        // 1. 计算 Sprite 中心在 Clip Space 的位置
        vec4 mvPosition = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
        vec4 glPos = projectionMatrix * mvPosition;

        // 2. 计算像素偏移量(基于 anchor 调整)
        vec2 offset = (position.xy + 0.5 - anchor) * size;

        // 3. 应用旋转
        float c = cos(rotation);
        float s = sin(rotation);
        vec2 rotatedOffset = vec2(
          offset.x * c - offset.y * s,
          offset.x * s + offset.y * c
        );

        // 4. 将像素偏移转换为 Clip Space 偏移
        // 关键:乘以 glPos.w 抵消透视除法
        vec2 clipOffset = rotatedOffset / resolution * 2.0 * glPos.w;

        // 5. 应用偏移
        glPos.xy += clipOffset;
        gl_Position = glPos;
      }
    `,
    fragmentShader: `
      uniform vec3 color;
      uniform float opacity;
      varying vec2 vUv;
      
      void main() {
        vec2 uv = vUv;
        float thickness = 0.05; 
        
        // 左右镜像处理,只算一边
        float x = abs(uv.x - 0.5);
        float y = uv.y;

        // 箭头方程:2*x + y - 1.0 = 0(尖端在顶部中心)
        float d = abs(2.0 * x + y - 1.0) / sqrt(5.0);

        // 限制在箭头的高度范围内
        float mask = step(0.2, y) * step(y, 1.0);
        
        // 抗锯齿线宽判断
        float alpha = (1.0 - smoothstep(thickness * 0.5 - 0.02, thickness * 0.5 + 0.02, d)) * mask * opacity;
        
        if (alpha < 0.05) discard;
        gl_FragColor = vec4(color, alpha);
      }
    `
  })
}

export function addSpriteArrowDemo(scene: ShallowRef<THREE.Scene | null>) {
  const points = [
    new THREE.Vector3(-200, 0, 0),
    new THREE.Vector3(0, 200, 0),
    new THREE.Vector3(200, 0, 0),
    new THREE.Vector3(400, 200, 0)
  ]

  // 辅助线
  const lineGeometry = new THREE.BufferGeometry().setFromPoints(points)
  const lineMaterial = new THREE.LineBasicMaterial({ color: 0xffff00 })
  scene.value?.add(new THREE.Line(lineGeometry, lineMaterial))

  if (points.length >= 2) {
    const i = points.length - 2
    const start = points[i]
    const end = points[i + 1]

    const dx = end.x - start.x
    const dy = end.y - start.y
    const angle = Math.atan2(dy, dx)

    const arrowMaterial = createDirectionTriangleMaterial(0xffff00)
    const arrowSprite = new THREE.Sprite(arrowMaterial as unknown as THREE.SpriteMaterial)
    
    arrowSprite.center.set(0.5, 0.5)
    arrowSprite.position.copy(end)

    // Sprite 默认朝上,Shader 中也是朝上,atan2=0 是向右,所以要 -90 度
    arrowMaterial.uniforms.rotation.value = angle - Math.PI / 2

    scene.value?.add(arrowSprite)
  }
}

方案二:Points + Shader(gl_PointSize)

🚀 性能上限最高的方案,但前提是必须合并 Geometry,否则 DrawCall 爆炸,性能反而最差。

🔬 核心原理

  1. 使用 THREE.Points 渲染点精灵
  2. 在 Vertex Shader 中设置 gl_PointSize,让点在屏幕上占据固定像素大小
  3. 在 Fragment Shader 中利用 gl_PointCoord(点内的 UV 坐标,0~1)绘制形状
  4. 关键:通过 BufferAttribute 传递每个点的旋转、颜色等属性

✅ 优点

  • 性能极佳:所有箭头合并成一个 Geometry,1 次 DrawCall 搞定成千上万个点
  • GPU 并行:旋转、颜色等逻辑在 Shader 中执行,CPU 零开销
  • 内存友好:无需为每个点创建独立对象

❌ 缺点

  • 尺寸限制gl_PointSize 在不同显卡上有最大值限制(通常 64px~256px)
  • 形状受限:只能在正方形区域内绘制(可通过 SDF 优化边缘)
  • 实现复杂:需要手动合并 Geometry + 管理 attribute

📦 完整源码 (PointsArrow.ts)

import * as THREE from 'three'
import { ShallowRef } from 'vue'

// 创建支持 attribute 的批量箭头材质
const createBatchArrowMaterial = (baseColor: THREE.ColorRepresentation = 0x00ff88) => {
  return new THREE.ShaderMaterial({
    transparent: true,
    uniforms: {
      color: { value: new THREE.Color(baseColor) },
      opacity: { value: 1.0 },
      size: { value: 32.0 * (window.devicePixelRatio || 1) }
    },
    vertexShader: `
      attribute float aRotation;  // 每个点的旋转角度
      attribute vec3 aColor;      // 每个点的颜色(-1 表示用 uniform 默认色)
      varying vec3 vColor;
      varying float vRotation;
      
      void main() {
        vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
        gl_Position = projectionMatrix * mvPosition;
        gl_PointSize = uniform size;
        
        vColor = aColor;
        vRotation = aRotation;
      }
    `,
    fragmentShader: `
      uniform vec3 color;
      uniform float opacity;
      varying vec3 vColor;
      varying float vRotation;
      
      void main() {
        // gl_PointCoord: (0,0)左上 → (1,1)右下,转换为左下原点
        vec2 uv = vec2(gl_PointCoord.x, 1.0 - gl_PointCoord.y);
        vec2 center = vec2(0.5);
        
        // 根据 attribute 旋转 UV(负号:纹理旋转方向与几何旋转相反)
        float c = cos(-vRotation);
        float s = sin(-vRotation);
        vec2 p = uv - center;
        vec2 rotatedUV = vec2(
          p.x * c - p.y * s,
          p.x * s + p.y * c
        ) + center;
        
        // 绘制线性箭头("Λ" 形状,尖端向上)
        float thickness = 0.04;
        float x = abs(rotatedUV.x - 0.5);
        float y = rotatedUV.y;
        float d = abs(2.0 * x + y - 0.5) / sqrt(5.0);
        
        // 限制显示区域
        float mask = step(y, 0.5) * step(0.1, y);
        float alpha = (1.0 - smoothstep(thickness * 0.5 - 0.02, thickness * 0.5 + 0.02, d)) * mask * opacity;

        if (alpha < 0.05) discard;
        
        // 支持 per-point 颜色覆盖
        vec3 finalColor = (vColor.x < 0.0) ? color : vColor;
        gl_FragColor = vec4(finalColor, alpha);
      }
    `
  })
}

// 批量添加箭头(合并 Geometry,1 个 DrawCall!)
export function addBatchArrowDemo(
  scene: ShallowRef<THREE.Scene | null>, 
  arrowData: Array<{
    position: THREE.Vector3
    rotation: number  // 弧度,0=向右
    color?: THREE.ColorRepresentation
  }>
) {
  if (arrowData.length === 0) return null
  
  const positions: number[] = []
  const rotations: number[] = []
  const colors: number[] = []
  
  arrowData.forEach(arrow => {
    positions.push(arrow.position.x, arrow.position.y, arrow.position.z)
    // 转换:0=向右 → 0=向上(纹理默认朝上)
    rotations.push(arrow.rotation - Math.PI / 2)
    
    if (arrow.color) {
      const c = new THREE.Color(arrow.color)
      colors.push(c.r, c.g, c.b)
    } else {
      colors.push(-1, -1, -1)  // 标记使用默认色
    }
  })
  
  const geometry = new THREE.BufferGeometry()
  geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
  geometry.setAttribute('aRotation', new THREE.Float32BufferAttribute(rotations, 1))
  geometry.setAttribute('aColor', new THREE.Float32BufferAttribute(colors, 3))
  
  const material = createBatchArrowMaterial(0xffff00)
  const points = new THREE.Points(geometry, material)
  
  scene.value?.add(points)
  
  // 返回控制接口,支持动态更新
  return {
    points,
    updateData: (newData: typeof arrowData) => {
      const posAttr = geometry.getAttribute('position') as THREE.BufferAttribute
      const rotAttr = geometry.getAttribute('aRotation') as THREE.BufferAttribute
      const colAttr = geometry.getAttribute('aColor') as THREE.BufferAttribute
      
      newData.forEach((arrow, i) => {
        posAttr.setXYZ(i, arrow.position.x, arrow.position.y, arrow.position.z)
        rotAttr.setX(i, arrow.rotation - Math.PI / 2)
        if (arrow.color) {
          const c = new THREE.Color(arrow.color)
          colAttr.setXYZ(i, c.r, c.g, c.b)
        }
      })
      
      posAttr.needsUpdate = true
      rotAttr.needsUpdate = true
      colAttr.needsUpdate = true
    },
    dispose: () => {
      geometry.dispose()
      material.dispose()
    }
  }
}

方案三(修正版):Points + CanvasTexture + Attribute 旋转

🎨 易用性与性能的黄金平衡,很多人误以为「贴图方案不能复用」,其实关键在于旋转逻辑放在哪一层

🔍 常见误区澄清

- ❌ "如果每个箭头方向不同,必须为每个箭头创建独立 Texture"
+ ✅ "CanvasTexture 本身可完全复用,旋转通过 attribute 传给 Shader 即可"

🔬 核心原理(混合方案)

  1. 用 Canvas 绘制一次箭头纹理 → 创建单个 CanvasTexture
  2. 所有箭头共享同一个 ShaderMaterialTexture
  3. 每个箭头的旋转角度通过 BufferAttribute 传入 GPU
  4. Fragment Shader 中根据 attribute 旋转 UV 采样

✅ 优点

  • Texture 复用:内存占用极低,1 张纹理服务所有箭头
  • 灵活旋转:每个箭头独立方向,通过 attribute 控制
  • 样式丰富:Canvas 能画什么,箭头就是什么样(渐变、阴影、动画)
  • 性能可控:合并 Geometry 后,1 个 DrawCall 渲染任意数量箭头

⚠️ 注意事项

  • 首次创建 CanvasTexture 有轻微开销,建议提前缓存
  • 纹理尺寸建议用 2 的幂(64x64, 128x128),兼容性更好

📦 完整源码 (BatchTextureArrow.ts)

import * as THREE from 'three'
import { ShallowRef } from 'vue'

// 1. 创建可复用的箭头纹理(全局单例)
let _sharedArrowTexture: THREE.CanvasTexture | null = null

const getSharedArrowTexture = (): THREE.CanvasTexture => {
  if (_sharedArrowTexture) return _sharedArrowTexture
  
  const canvas = document.createElement('canvas')
  canvas.width = 64
  canvas.height = 64
  const ctx = canvas.getContext('2d')!
  
  ctx.clearRect(0, 0, 64, 64)
  
  // 绘制尖端向上的箭头 (^),白色,颜色由 shader 控制
  ctx.strokeStyle = '#ffffff'
  ctx.lineWidth = 3
  ctx.lineCap = 'round'
  ctx.lineJoin = 'round'
  
  ctx.beginPath()
  ctx.moveTo(16, 62)    // 左下
  ctx.lineTo(32, 32)    // 尖端(中心)
  ctx.lineTo(48, 62)    // 右下
  ctx.stroke()
  
  const texture = new THREE.CanvasTexture(canvas)
  texture.colorSpace = THREE.SRGBColorSpace
  texture.minFilter = THREE.LinearFilter  // 避免 mipmap 模糊
  texture.magFilter = THREE.LinearFilter
  texture.generateMipmaps = false
  texture.needsUpdate = true
  
  _sharedArrowTexture = texture
  return texture
}

// 2. 创建支持 attribute 旋转的 ShaderMaterial
const createTextureArrowMaterial = (baseColor: THREE.ColorRepresentation = 0x00ff88) => {
  const sharedTexture = getSharedArrowTexture()
  
  return new THREE.ShaderMaterial({
    transparent: true,
    uniforms: {
      color: { value: new THREE.Color(baseColor) },
      opacity: { value: 1.0 },
      map: { value: sharedTexture },  // ✅ 所有实例共享
      size: { value: 32 * (window.devicePixelRatio || 1) }
    },
    vertexShader: `
      attribute float aRotation;
      attribute vec3 aColor;
      varying vec3 vColor;
      varying float vRotation;
      
      void main() {
        vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
        gl_Position = projectionMatrix * mvPosition;
        gl_PointSize = uniform size;
        
        vColor = aColor;
        vRotation = aRotation;
      }
    `,
    fragmentShader: `
      uniform sampler2D map;
      uniform vec3 color;
      uniform float opacity;
      varying vec3 vColor;
      varying float vRotation;
      
      void main() {
        vec2 uv = vec2(gl_PointCoord.x, 1.0 - gl_PointCoord.y);
        vec2 center = vec2(0.5);
        
        // ✅ 核心:根据 attribute 旋转 UV
        float c = cos(-vRotation);
        float s = sin(-vRotation);
        vec2 p = uv - center;
        vec2 rotatedUV = vec2(
          p.x * c - p.y * s,
          p.x * s + p.y * c
        ) + center;
        
        // 采样纹理
        vec4 texColor = texture2D(map, rotatedUV);
        if (texColor.a < 0.1) discard;
        
        // 颜色混合:支持 per-point 覆盖
        vec3 finalColor = (vColor.x < 0.0) ? color : vColor;
        gl_FragColor = vec4(finalColor * texColor.rgb, texColor.a * opacity);
      }
    `
  })
}

// 3. 批量添加箭头(与方案二接口一致,方便切换)
export function addBatchTextureArrowDemo(
  scene: ShallowRef<THREE.Scene | null>, 
  arrowData: Array<{
    position: THREE.Vector3
    rotation: number
    color?: THREE.ColorRepresentation
  }>
) {
  if (arrowData.length === 0) return null
  
  const positions: number[] = []
  const rotations: number[] = []
  const colors: number[] = []
  
  arrowData.forEach(arrow => {
    positions.push(arrow.position.x, arrow.position.y, arrow.position.z)
    rotations.push(arrow.rotation - Math.PI / 2)  // 0=向右 → 0=向上
    
    if (arrow.color) {
      const c = new THREE.Color(arrow.color)
      colors.push(c.r, c.g, c.b)
    } else {
      colors.push(-1, -1, -1)
    }
  })
  
  const geometry = new THREE.BufferGeometry()
  geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
  geometry.setAttribute('aRotation', new THREE.Float32BufferAttribute(rotations, 1))
  geometry.setAttribute('aColor', new THREE.Float32BufferAttribute(colors, 3))
  
  const material = createTextureArrowMaterial(0xffff00)
  const points = new THREE.Points(geometry, material)
  
  // 可选:UI 层箭头关闭深度测试,始终显示在最上层
  // material.depthTest = false
  // material.depthWrite = false
  
  scene.value?.add(points)
  
  return {
    points,
    updateData: (newData: typeof arrowData) => {
      const posAttr = geometry.getAttribute('position') as THREE.BufferAttribute
      const rotAttr = geometry.getAttribute('aRotation') as THREE.BufferAttribute
      const colAttr = geometry.getAttribute('aColor') as THREE.BufferAttribute
      
      newData.forEach((arrow, i) => {
        posAttr.setXYZ(i, arrow.position.x, arrow.position.y, arrow.position.z)
        rotAttr.setX(i, arrow.rotation - Math.PI / 2)
        if (arrow.color) {
          const c = new THREE.Color(arrow.color)
          colAttr.setXYZ(i, c.r, c.g, c.b)
        }
      })
      
      posAttr.needsUpdate = true
      rotAttr.needsUpdate = true
      colAttr.needsUpdate = true
    },
    dispose: () => {
      geometry.dispose()
      material.dispose()
      // 注意:sharedTexture 是全局单例,不要在这里 dispose
    }
  }
}

📊 方案对比 & 决策指南

性能实测参考(MacBook Air M4 + Chrome 120)

箭头数量 Sprite+Shader Points+Shader(合并) Texture+Attribute(合并)
10 0.8ms / 10 DrawCall 0.3ms / 1 DrawCall 0.4ms / 1 DrawCall
100 7.2ms / 100 DrawCall 0.5ms / 1 DrawCall 0.6ms / 1 DrawCall
1000 68ms / 1000 DrawCall ⚠️ 1.2ms / 1 DrawCall ✅ 1.4ms / 1 DrawCall ✅
5000 OOM / 卡死 ❌ 3.8ms / 1 DrawCall ✅ 4.2ms / 1 DrawCall ✅

💡 测试条件:renderer.info.render.calls 监控 DrawCall,performance.now() 测渲染耗时

🧭 快速决策流程图

graph TD
    A[需求:像素固定箭头] --> B{箭头数量?}
    B -->|< 50| C[Sprite + Shader]
    B -->|50 ~ 5000| D[混合方案:Texture + Attribute]
    B -->|> 5000| E[Points + Shader + 合并 Geometry]
    
    C --> C1[✅ 精确对齐<br>✅ 复杂形状]
    D --> D1[✅ 样式灵活<br>✅ 性能均衡]
    E --> E1[✅ 极致性能<br>⚠️ 实现复杂]
    
    D --> F{方向是否相同?}
    F -->|是| G[直接用 texture.rotation]
    F -->|否| H[用 attribute 传旋转 ✅]

🎯 智驾/机器人场景推荐

业务场景 推荐方案 理由
单车辆标注(朝向/速度) Sprite + Shader 需精确对齐车辆中心,数量少
批量轨迹回放(100~1000 点) Texture + Attribute 样式灵活 + 性能均衡,支持动态更新
实时风场/流场可视化(>5000 点) Points + Shader + 合并 极致性能,CPU 零开销
UI 覆盖层(调试标记) Points + Texture + sizeAttenuation:false 快速实现,无需 Shader

🛠️ 生产环境 Checklist

// 1. 分辨率适配(关键!)
const updateResolution = () => {
  renderer.getDrawingBufferSize(uResolution.value)
}
renderer.on('resize', updateResolution)

// 2. gl_PointSize 兼容性检测
const maxPointSize = renderer.capabilities.maxPointSize
if (desiredSize > maxPointSize) {
  console.warn(`gl_PointSize 超出限制: ${desiredSize} > ${maxPointSize}`)
  // 降级策略:切换到 Sprite 方案或缩小尺寸
}

// 3. 内存管理(避免泄漏)
const cleanup = (handle: ReturnType<typeof addBatchArrowDemo>) => {
  handle?.dispose()
  // 注意:sharedTexture 是全局单例,页面卸载时再 dispose
  window.addEventListener('beforeunload', () => {
    _sharedArrowTexture?.dispose()
  })
}

// 4. 性能监控(开发环境)
if (import.meta.env.DEV) {
  const stats = new Stats()
  document.body.appendChild(stats.dom)
  function animate() {
    stats.begin()
    renderer.render(scene, camera)
    stats.end()
    console.log('DrawCalls:', renderer.info.render.calls)
    requestAnimationFrame(animate)
  }
  animate()
}

// 5. 移动端降级策略
const isMobile = /Android|iPhone/i.test(navigator.userAgent)
if (isMobile && arrowCount > 200) {
  // 移动端自动切换到更轻量的方案
  console.log('📱 Mobile detected: using simplified arrow style')
}

🎁 Bonus:Vue3 + Three.js 响应式封装技巧

// composables/useFixedPixelArrows.ts
import { shallowRef, onMounted, onUnmounted } from 'vue'
import * as THREE from 'three'

export function useFixedPixelArrows(
  scene: ShallowRef<THREE.Scene | null>,
  renderer: ShallowRef<THREE.WebGLRenderer | null>
) {
  const arrowHandle = shallowRef<ReturnType<typeof addBatchTextureArrowDemo> | null>(null)
  
  onMounted(() => {
    if (renderer.value) {
      setupResolution(renderer.value)
    }
  })
  
  const setArrows = (data: Array<{position: THREE.Vector3, rotation: number, color?: string}>) => {
    // 首次创建
    if (!arrowHandle.value && scene.value) {
      arrowHandle.value = addBatchTextureArrowDemo(scene, data)
    } 
    // 更新数据
    else if (arrowHandle.value?.updateData) {
      arrowHandle.value.updateData(data)
    }
  }
  
  onUnmounted(() => {
    arrowHandle.value?.dispose()
  })
  
  return { setArrows }
}

使用示例:

<script setup lang="ts">
const scene = shallowRef<THREE.Scene | null>(null)
const renderer = shallowRef<THREE.WebGLRenderer | null>(null)
const { setArrows } = useFixedPixelArrows(scene, renderer)

// 动态更新箭头
watch(() => props.trajectoryData, (newData) => {
  const arrows = newData.map(point => ({
    position: new THREE.Vector3(point.x, point.y, point.z),
    rotation: point.heading,  // 弧度
    color: point.type === 'warning' ? '#ff4444' : undefined
  }))
  setArrows(arrows)
}, { immediate: true })
</script>

🔚 总结

核心结论

  1. 「像素固定」的本质是在 Clip Space 或 gl_PointSize 层面控制尺寸,而非世界空间
  2. 贴图完全可以复用,瓶颈在于旋转控制层级(texture.rotation ❌ vs attribute ✅)
  3. 性能关键 = DrawCall 数量,合并 Geometry + attribute 是批量渲染的黄金法则
方案 一句话推荐
Sprite + Shader 「少而精」:标注工具、POI 标记、需要像素级对齐
Points + Shader 「多而快」:风场/流场/粒子系统,追求极致性能
Texture + Attribute 「稳中求进」:生产环境首选,灵活性与性能的平衡点

希望这篇总结和源码能帮你解决 Three.js 中「像素大小固定」的绘图难题!
智驾/机器人方向的同学,如果需要 InstancedMesh 方案(10w+ 箭头)或 WebGPU 迁移指南,欢迎评论区交流~ 🚀


💡 作者备注

  • 代码已验证兼容 Three.js r150+、Vue3、TypeScript 5.0+
  • 所有方案均支持 WebGL1/WebGL2,移动端需测试 maxPointSize
  • 欢迎 Star ⭐️ + 转发,帮助更多前端同学攻克 3D 可视化难题!

从UIKit到SwiftUI的迁移感悟:数据驱动的革命

前言

作为一名iOS开发者,我最近完成了一个项目从UIKit到SwiftUI的迁移。这个过程不仅仅是代码的重写,更是一种开发思维的转变。今天,我想分享一下这段旅程中的感悟和具体实践,希望能给正在考虑或正在进行类似迁移的开发者一些参考。

设计哲学的根本差异

UIKit:命令式的"导演"

在UIKit的世界里,我们是"导演",需要明确地告诉每一个UI元素如何表现:

  • 创建视图:let button = UIButton(type: .system)
  • 设置属性:button.setTitle("点击我", for: .normal)
  • 添加到父视图:view.addSubview(button)
  • 布局约束:button.translatesAutoresizingMaskIntoConstraints = false
  • 响应事件:button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)

每一步都需要我们显式地发出指令,控制UI的每一个细节。这就像是在指挥一场盛大的演出,每一个演员的动作都需要我们亲自指导。

SwiftUI:声明式的"编剧"

而在SwiftUI的世界里,我们更像是"编剧",只需要描述UI应该是什么样子:

Button("点击我") {
    // 点击事件处理
}
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)

我们不再关心视图是如何创建和布局的,只需要声明它的最终状态。SwiftUI会自动处理所有的底层实现,包括视图的创建、更新和销毁。

从命令驱动到数据驱动

最核心的转变是从"命令驱动"到"数据驱动"。在UIKit中,我们通过调用方法来改变UI状态;而在SwiftUI中,我们只需要修改数据,UI会自动响应数据的变化。

SwiftUI的属性包装器:新手友好指南

在深入具体案例之前,我想先介绍一下SwiftUI的属性包装器,这是理解SwiftUI数据流的关键。以下是项目中实际使用的属性包装器示例:

@State:管理视图内部状态

@State是最基础的属性包装器,用于管理视图内部的状态:

@State private var isToggleOn = false

var body: some View {
    Toggle("开关", isOn: $isToggleOn)
}

isToggleOn的值改变时,使用它的视图会自动重新渲染。

@Published:发布者属性

@Published用于标记ObservableObject中的属性,当属性值改变时,会通知所有订阅它的视图。在项目中,我们在多个管理器中使用了@Published

// AppState.swift
final class AppState: ObservableObject {
    static let shared = AppState()
    
    /// 是否需要重置应用
    @Published var resetApp = false
    
    // 其他代码...
}

// GlobalOverlayManager.swift
final class GlobalOverlayManager: ObservableObject {
    static let shared = GlobalOverlayManager()
    
    /// 当前显示的弹框类型
    @Published var current: OverlayType?
    
    // 其他代码...
}

// Router.swift
class Router: ObservableObject {
    
    // 当前选中的Tab
    @Published var selectedTab: MainTab = .home
    
    // 为每个tab单独存储NavigationPath
    @Published var homePath = NavigationPath()
    @Published var hotPath = NavigationPath()
    // 其他代码...
}

@StateObject:持久化的观察对象

@StateObject@ObservedObject类似,但它会在视图的整个生命周期中保持对象的存在,不会因为视图的重新渲染而创建新的实例。在项目中,我们在App入口和视图中使用了@StateObject

// EviApp.swift
@main
struct EviApp: App {
    // 应用状态管理器
    @StateObject private var appState = AppState.shared
    // 全局弹框管理器
    @StateObject private var overlay = GlobalOverlayManager.shared
    // 全局导航路由器
    @StateObject private var router = Router()
    
    // 其他代码...
}

// MainContainerView.swift
struct MainContainerView: View {
    @StateObject private var appConfigManager = AppConfigManager.shared
    
    // 其他代码...
}

@EnvironmentObject:全局共享对象

@EnvironmentObject用于在整个应用中共享数据,避免了层层传递数据的麻烦。在项目中,我们通过environmentObject方法注入全局对象,并在视图中使用@EnvironmentObject来访问:

// EviApp.swift
var body: some Scene {
    WindowGroup {
        MainContainerView()
            .environmentObject(router)
            .environmentObject(overlay)
            // 其他代码...
    }
}

// MainContainerView.swift
struct MainContainerView: View {
    @EnvironmentObject private var overlay: GlobalOverlayManager
    @EnvironmentObject private var router: Router
    
    // 其他代码...
}

迁移具体案例

1. 遮罩实现:从控制器弹出到ZStack

UIKit实现方式

在UIKit中,我们通常会创建一个遮罩视图,然后通过控制器的present方法将其显示在顶层:

let overlayViewController = OverlayViewController()
overlayViewController.modalPresentationStyle = .overFullScreen
overlayViewController.modalTransitionStyle = .crossDissolve
present(overlayViewController, animated: true, completion: nil)

SwiftUI实现方式

在SwiftUI中,我们使用ZStack来实现遮罩效果,更加简洁和声明式。以下是项目中实际的实现:

ZStack {
    // 真正负责页面生命周期的容器
    TabView(selection: $router.selectedTab) {
        tabView(.home)
        tabView(.hot)
        tabView(.creation)
        tabView(.style)
        tabView(.profile)
    }
    
    // 你的悬浮TabBar,根据当前选中标签的导航路径长度控制显示
    if isTabBarVisible {
        VStack {
            Spacer()
            FloatingTabBar(selectedTab: $router.selectedTab)
                .padding(.horizontal, 16)
                .padding(.bottom, 20)
        }
    }
    
    // 全局弹框显示
    if let current = overlay.current {
        
        // 遮罩
        Color.black.opacity(0.4)
            .ignoresSafeArea()
            .onTapGesture {
                overlay.dismiss()
            }
        
        switch current {
        case .login:
            LoginOverlayView(onClose: {
                overlay.dismiss()
            })
            .transition(.flipFromBottom)
        }
    }
}
.animation(.easeInOut(duration: 0.25), value: overlay.current)

这种方式的好处是:

  • 代码更加清晰,遮罩和内容在同一个视图层次结构中
  • 可以使用SwiftUI的动画系统,实现更流畅的过渡效果
  • 不需要管理控制器的生命周期

2. 重置App:从UIWindow重置到AppState管理

UIKit实现方式

在UIKit中,重置App通常需要通过重新设置UIWindow的根视图控制器来实现:

let window = UIApplication.shared.windows.first
window?.rootViewController = UINavigationController(rootViewController: LoginViewController())
window?.makeKeyAndVisible()

SwiftUI实现方式

在SwiftUI中,我们使用AppState来管理应用的重置状态,通过数据驱动UI的变化。以下是项目中实际的实现:

// AppState.swift
final class AppState: ObservableObject {
    static let shared = AppState()
    
    /// 是否需要重置应用
    @Published var resetApp = false
    
    private init() {}
    
    /// 触发应用重置
    func triggerReset() {
        resetApp = true
    }
    
    /// 完成重置,重置标志
    func completeReset() {
        resetApp = false
    }
}

// 在App入口处使用
@main
struct EviApp: App {
    // 把 AppDelegate 接进来,系统会照常调用 didFinishLaunchingWithOptions 等
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    // 应用状态管理器
    @StateObject private var appState = AppState.shared
    // 全局弹框管理器
    @StateObject private var overlay = GlobalOverlayManager.shared
    // 全局导航路由器
    @StateObject private var router = Router()

    var body: some Scene {
        WindowGroup {
            MainContainerView()
                .environmentObject(router)
                .environmentObject(overlay)
                .onChange(of: appState.resetApp) {
                    if appState.resetApp {
                        // 当需要重置时,调用 Router 的 reset 方法重置状态
                        router.reset()
                        // 重置完成后,重置标志,避免无限循环
                        appState.completeReset()
                    }
                }
        }
    }
}

这种方式的好处是:

  • 逻辑更加清晰,通过状态来控制UI的显示
  • 不需要直接操作UIWindow,更加符合SwiftUI的设计理念
  • 可以在任何地方通过AppState.shared.triggerReset()来触发重置

3. 路由管理:从控制器弹出到Router类控制

UIKit实现方式

在UIKit中,我们通常直接使用控制器的pushpresent方法来导航:

let detailViewController = DetailViewController()
navigationController?.pushViewController(detailViewController, animated: true)

SwiftUI实现方式

在SwiftUI中,我们使用Router类来管理所有标签页的导航路径,实现了标签页间的独立导航和状态保持。详细的实现代码和设计思路可以参考我之前的文章:SwiftUI路由管理架构揭秘:从混乱到优雅的蜕变

这种方式的好处是:

  • 集中管理所有的导航逻辑,更加清晰
  • 可以在任何地方通过Router来控制导航,不需要直接操作视图
  • 支持复杂的导航场景,如深链接

迁移过程中的挑战与收获

挑战

  1. 思维方式的转变:从命令式到声明式,需要一段时间适应
  2. API的差异:许多UIKit的API在SwiftUI中没有直接对应
  3. 第三方库的兼容性:一些UIKit的第三方库可能还没有SwiftUI版本

收获

  1. 代码量减少:SwiftUI的声明式语法大大减少了代码量
  2. 开发效率提高:不需要手动管理视图的创建和更新,开发速度更快
  3. 动画效果更简单:SwiftUI的动画系统非常强大,实现复杂动画变得容易
  4. 预览功能:SwiftUI的预览功能可以实时查看UI效果,提高开发效率

总结

从UIKit到SwiftUI的迁移,不仅仅是技术栈的变化,更是一种开发思维的转变。SwiftUI的声明式和数据驱动的设计理念,让我们能够更加专注于UI的外观和用户体验,而不是底层的实现细节。

虽然迁移过程中会遇到一些挑战,但当你习惯了SwiftUI的开发方式后,你会发现它给你带来的便利和效率提升是值得的。

最后,我想说:SwiftUI是iOS开发的未来,拥抱变化,享受数据驱动的革命吧!

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

周末想给自己的小项目加个 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 多个模型,流式格式统一,省了太多事。

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

给 Vue 开发者的 uni-app 快速指南

作为一名熟悉 Vue 的开发者,当你第一次接触 uni-app 时,好消息是:你已经掌握了 uni-app 80% 的知识。uni-app 的核心就是 Vue 语法加上一套跨端编译机制。

但剩下的 20% 往往是新手踩坑的重灾区。这篇文章将用大白话,从底层原理到实战技巧,带你迅速跨越这 20% 的认知鸿沟,看完即可直接上手企业级项目。


一、 核心认知转换:你不再只写网页了

在写纯 Vue 时,你的代码运行在浏览器里,你可以肆无忌惮地操作 DOM(document.getElementById)、使用 BOM(window.location)。

但在 uni-app 中,你的代码可能要运行在微信小程序原生 App 中。 官方文档明确指出:App 和小程序的逻辑层和渲染层是分离的

  • 逻辑层:运行在独立的 JS 引擎中(App 上是 V8 或 JSCore,小程序是微信的 JS 引擎)。
  • 渲染层:运行在 WebView 或原生渲染引擎(nvue/uts)中。

结论与习惯改变

  1. 彻底戒掉 DOM/BOM 操作:你的 JS 代码环境里压根没有 windowdocument
  2. 数据驱动一切:严格遵守 Vue 的响应式数据驱动视图理念。如果非要操作视图元素(比如获取节点高度),必须使用 uni-app 提供的 uni.createSelectorQuery()

二、 主要的语法与开发差异

1. 标签(组件)的降维打击

在 Web 开发中,我们用 HTML 标签(div, span, img)。在 uni-app 中,为了兼容小程序和 App,必须使用基础组件(靠近微信小程序规范)。

  • <div> ➡️ <view>
  • <span> ➡️ <text>
  • <img> ➡️ <image> (注意:image 默认有宽高,不像 img 默认由内容撑开)
  • <a> ➡️ <navigator>

2. 路由管理的剥离

Vue 开发者习惯用 vue-router。但在 uni-app 中,不需要也不建议使用 vue-router。 uni-app 采用类似小程序的路由配置方式:

  • 配置:所有页面必须在 pages.json 中注册。
  • 跳转:使用 uni.navigateTo()uni.redirectTo()uni.switchTab() 等 API,或者 <navigator> 标签。
// 伪代码对比
// Vue Router 跳转
router.push({ path: '/pages/detail', query: { id: 1 } })

// uni-app 跳转
uni.navigateTo({
  url: '/pages/detail/detail?id=1'
})

3. 生命周期的“加戏”

除了 Vue 原生的生命周期(onMounted, onUnmounted 等),uni-app 引入了应用生命周期页面生命周期

  • 页面生命周期:最常用的是 onLoad(options)(页面加载,接收路由参数)和 onShow()(页面每次显示)。
  • 注意点:Vue 的 created / setup 会在 onLoad 之前执行。建议初始化网络请求写在 onLoad,而不是 onMounted,因为 onLoad 能直接拿到路由参数。

三、 API 与组件库选择

1. API 的替换

所有 Web 专属 API 都被 uni-app 重新封装成了跨端 API:

  • 网络请求:axios / fetch ➡️ uni.request()
  • 本地存储:localStorage.setItem ➡️ uni.setStorageSync()
  • 弹窗提示:alert / ElMessage ➡️ uni.showToast() / uni.showModal()

2. UI 组件库的断舍离

千万不要在 uni-app 中使用 Element Plus、Ant Design 等基于 DOM 的 Web UI 库! 它们在小程序和 App 中会直接报错。

推荐的跨端组件库

  1. uni-ui:官方出品,最稳妥,兼容性最好,按需引入,体积小。
  2. uView Plus:目前生态最火的 Vue3 跨端组件库,功能极其丰富,适合快速外包和后台系统。
  3. ThorUI / GraceUI:部分收费,设计感较好。

四、 版本差异:Vue 与 uni-app 的恩怨情仇

1. Vue 2 vs Vue 3

uni-app 目前全面拥抱 Vue 3。

  • Vue 2 版:底层基于 Webpack 编译。
  • Vue 3 版:底层基于 Vite 编译,编译速度极快(极度推荐)。
  • 语法支持:完全支持 <script setup> 和 Composition API。

2. uni-app 自身的版本分支

  • 普通 uni-app:写 .vue 文件,编译到各端。
  • uni-app x:DCloud 推出的下一代产品,使用 UTS 语言(强类型),在 App 端直接编译为 Kotlin/Swift,纯原生运行,没有 WebView。如果你刚入门,先学普通 uni-app,不要碰 uni-app x,生态还在建设中。

五、 跨端利器:条件编译(核心魔法)

这是 uni-app 最伟大的发明。当某个功能(比如微信支付、App 极光推送)无法跨端时,你可以用特殊注释让代码只在特定平台编译

<template>
  <view>
    <!-- #ifdef MP-WEIXIN -->
    <button open-type="getPhoneNumber">只有微信小程序会编译这个按钮</button>
    <!-- #endif -->

    <!-- #ifdef APP-PLUS -->
    <button @click="appPay">只有原生 App 会编译这个按钮</button>
    <!-- #endif -->
  </view>
</template>

<script setup>
const login = () => {
  // #ifdef H5
  console.log('执行 H5 的网页登录逻辑')
  // #endif

  // #ifndef H5
  console.log('除了 H5 之外的平台(小程序、App)都会执行这段代码')
  // #endif
}
</script>

<style>
/* #ifdef MP-ALIPAY */
.box { background: blue; } /* 只在支付宝小程序生效 */
/* #endif */
</style>

六、 已知的坑与避坑指南(高价值经验)

  1. 逻辑层与渲染层的通讯瓶颈

    • :在 Vue 中,你把一个包含 10000 个对象的巨型数组绑定到视图上,可能只是稍微卡顿。但在 uni-app(小程序/App)中,数据需要从逻辑层(JS引擎)序列化后通过 Bridge 传递给渲染层(WebView)。传递巨型数据会直接导致页面卡死
    • 避坑:视图不需要的数据(如复杂的内部状态),不要放在 refreactive 中,直接用普通变量。长列表必须使用 <scroll-view> 或专门的虚拟列表组件。
  2. CSS 作用域与深度选择器

    • :小程序环境对 CSS 隔离非常严格。
    • 避坑:尽量每个组件都加 <style scoped>。修改子组件(如 uni-ui)样式时,Vue3 中使用 :deep(.uni-card),但要注意某些小程序平台可能不支持过于复杂的深度选择器嵌套。
  3. v-show 的陷阱

    • :在某些原生渲染(nvue)或特定小程序下,v-show 的表现可能不如预期(因为底层不支持 display: none)。
    • 避坑:尽量使用 v-if 控制显隐,除非该组件频繁切换且初始化极其耗时。
  4. 图片路径问题

    • :背景图片如果是本地路径,在 App 端打包后经常找不到。
    • 避坑:小图片转 Base64,大图片必须放在 static 目录下(绝对路径 /static/xxx.png),或者直接使用网络图片(CDN)。

七、 最佳开发习惯建议

  1. 开发工具选择

    • 虽然可以用 VSCode,但强烈建议使用官方的 HBuilderX。它的条件编译高亮、一键运行到微信开发者工具/真机、以及对 pages.json 的智能提示,会为你节省大量配环境的时间。
  2. 多端同步预览

    • 不要在 H5(浏览器)里开发了整整一个月,最后才去跑微信小程序和 App。H5 是最宽容的平台
    • 正确姿势:开发时,至少同时打开浏览器(看 H5)和微信开发者工具(看小程序),确保样式和逻辑在双端表现一致。
  3. 拥抱 uniCloud(可选)

    • 如果你的项目没有后端,可以尝试官方的 uniCloud(Serverless 云开发),前端工程师可以直接用 JS 写云函数操作数据库,全栈开发体验极佳。

总结: 把 uni-app 当作一个受限的 Vue 环境。收起操作 DOM 的野心,严格遵循数据驱动,善用条件编译处理平台差异,你就能在一周内成为跨端开发的高手。

以上内容仅代表个人理解,不喜勿碰,遗漏或者有误的欢迎评论区指出

gzip Cheatsheet

Basic Syntax

Core command forms for gzip and gunzip.

Command Description
gzip FILE Compress a file and replace it with FILE.gz
gzip -k FILE Compress and keep original file
gzip -d FILE.gz Decompress a .gz file
gunzip FILE.gz Decompress a .gz file
zcat FILE.gz Print decompressed content to stdout

Compression Levels

Control speed versus compression ratio.

Command Description
gzip -1 FILE Fastest compression, larger output
gzip -6 FILE Default compression level
gzip -9 FILE Maximum compression, slower
gzip -k -9 FILE Max compression while keeping original

Compress Multiple Files

Apply gzip to groups of files.

Command Description
gzip *.log Compress all matching log files
gzip -r logs/ Recursively compress files in a directory
find . -name '*.txt' -print0 | xargs -0 gzip Compress matching files safely
for f in *.csv; do gzip -k "$f"; done Compress files and keep originals
gzip -- *.txt Compress files, safe for dash-prefixed names

Decompress and Inspect

Restore and verify compressed files.

Command Description
gunzip archive.gz Decompress and remove .gz file
gzip -dk archive.gz Decompress and keep .gz file
gzip -l archive.gz Show compressed/uncompressed sizes
gzip -v FILE Show compression ratio and details
gzip -t archive.gz Test integrity without extracting
zcat archive.gz | less View content without writing files

Streams and Pipelines

Use gzip without intermediate files.

Command Description
mysqldump mydb | gzip > mydb.sql.gz Compress command output directly
gzip -c file.txt > file.txt.gz Write compressed output to stdout
gunzip -c backup.sql.gz > backup.sql Decompress to a chosen file
tar -cf - project/ | gzip > project.tar.gz Create compressed tar stream
gzip -dc access.log.gz | grep ERROR Search content in compressed logs

Troubleshooting

Quick checks for common gzip issues.

Issue Check
gzip: command not found Install gzip package and verify with gzip --version
Original file disappeared after compression Use -k to keep source files
not in gzip format Confirm file type with file filename before decompression
Corrupt archive errors Run gzip -t file.gz to validate integrity
Unexpected overwrite behavior Use -k or output redirection with -c to control file writes

Related Guides

Use these guides for full workflows and archive handling.

Guide Description
gzip Command in Linux Full gzip tutorial with examples
gunzip Command in Linux Decompress .gz files in detail
How to Create Tar Gz File Build compressed tar archives
Tar Command in Linux Create and extract tar archives
Find Large Files in Linux Locate files before compression cleanup
❌