阅读视图

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

春节假期,香港入境处预计约1138万人次进出香港

今年农历新年假期期间(2月14日至2月23日),香港入境事务处(入境处)预计约有1138万人次(包括香港居民及访客)经各海、陆、空管制站进出香港。经香港入境处与深圳出入境边防检查总站(边检总站)等内地部门协商后,预计约有952万人次会经各陆路边境管制站进出香港。预计陆路出境高峰期为2月14日(星期六),出境人次约636000,而入境高峰期为2月22日(星期日),入境人次约663000。(大湾区之声)

联想集团:第三季度营收222亿美元,同比增长18%

36氪获悉,联想集团在港交所公告,截至二零二五年十二月三十一日止第三季度,集团收入同比增长18%,达创历史新高的222亿美元;人工智能相关收入同比增长72%,占集团总收入达32%。第三季度公司权益持有人应占溢利为5.46亿美元。

恒指午间休盘跌0.89%,恒生科技指数跌1.68%

36氪获悉,恒指午间休盘跌0.89%,恒生科技指数跌1.68%;GEO概念大涨,智谱涨近25%,MINIMAX-WP涨超14%;半导体走强,天数智芯涨超10%,兆易创新涨超7%;传媒股走弱,网易云音乐跌近12%,阅文集团、猫眼娱乐跌超3%;南向资金净买入27.91亿港元。

半日主力资金加仓电力设备股,抛售传媒股

主力资金早间净流入电力设备、机械设备、电子等板块,净流出传媒、银行、商贸零售等板块。具体到个股来看,英维克、利欧股份、优刻得获净流入33.37亿元、15.29亿元、15.12亿元。净流出方面,光线传媒、澜起科技、中国中免遭抛售12.17亿元、8.40亿元、6.68亿元。(第一财经)

React性能优化三剑客:useEffect、useMemo与useCallback实战手册

废话不多说,直接上干货,兄弟们,注意了,我要开始装逼了:

一、useEffect:副作用管理

核心作用

处理与渲染无关的副作用(如数据获取、订阅事件、定时器、DOM 操作等),并支持清理逻辑。

基本语法

useEffect(() => {
  // 副作用逻辑
  return () => {
    // 清理逻辑(可选)
  };
}, [依赖项]);

使用场景

  1. 数据获取

    useEffect(() => {
      fetch("/api/data")
        .then(res => setData(res));
    }, []); // 仅在挂载时获取
    
  2. 事件监听

    useEffect(() => {
      const handleResize = () => setWidth(window.innerWidth);
      window.addEventListener("resize", handleResize);
      return () => window.removeEventListener("resize", handleResize);
    }, []); // 清理监听器
    
  3. 定时器

    useEffect(() => {
      const timer = setInterval(() => setCount(c => c + 1), 1000);
      return () => clearInterval(timer);
    }, []); // 清理定时器
    

最佳实践

  • 依赖数组:精确控制执行时机,避免遗漏依赖导致闭包问题。
  • 清理函数:移除订阅、清除定时器,防止内存泄漏。
  • 避免无限循环:若副作用中修改状态,使用函数式更新(如 setCount(prev => prev + 1))。

二、useMemo:计算结果缓存

核心作用

缓存复杂计算结果,避免重复执行高开销操作(如排序、过滤、深拷贝)。

基本语法

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

使用场景

  1. 复杂计算

    const filteredList = useMemo(() => 
      list.filter(item => item.price > 100), 
      [list] // 仅当 list 变化时重新计算
    );
    
  2. 避免重复渲染

    const userInfo = useMemo(() => ({ name, age }), [name, age]);
    return <Child user={userInfo} />; // 稳定引用,避免子组件重渲染
    

最佳实践

  • 依赖项完整:包含所有影响计算结果的变量。
  • 避免滥用:简单计算无需缓存,直接执行即可。
  • **配合 React.memo**:缓存子组件,提升渲染性能。

三、useCallback:函数引用缓存

核心作用

缓存函数引用,避免因父组件重新渲染导致子组件不必要的重渲染。

基本语法

const memoizedFn = useCallback(() => {
  doSomething(a, b);
}, [a, b]);

使用场景

  1. 传递回调函数

    const handleClick = useCallback(() => {
      console.log("Clicked");
    }, []);
    return <Button onClick={handleClick} />;
    
  2. **配合 useEffect**

    const fetchData = useCallback(async () => {
      const data = await fetch("/api");
      setData(data);
    }, []);
    useEffect(() => fetchData(), [fetchData]);
    

最佳实践

  • 依赖项精准:确保函数内部使用的变量均包含在依赖数组中。
  • 避免过度优化:仅在需要稳定引用时使用(如传递给 React.memo 子组件)。

四、三者的核心区别

Hook 核心用途 返回值 典型场景
useEffect 处理副作用(数据获取、订阅) 无(返回清理函数) API 请求、事件监听
useMemo 缓存计算结果 计算后的值 复杂计算、避免重复渲染
useCallback 缓存函数引用 函数 传递回调、优化子组件渲染

五、useEffect 最佳实践与闭包陷阱规避

1. 依赖项管理

  • 核心原则:依赖数组必须包含所有在 effect 中使用的 外部变量(包括 state、props、上下文等)。

    // 错误示例:未包含依赖项,导致闭包捕获旧值
    const [count, setCount] = useState(0);
    useEffect(() => {
      const timer = setInterval(() => {
        console.log(count); // 始终输出初始值 0
      }, 1000);
      return () => clearInterval(timer);
    }, []); // ❌ 依赖数组为空
    
    // 正确做法:将 count 加入依赖数组
    useEffect(() => {
      const timer = setInterval(() => {
        console.log(count); // 实时输出最新值
      }, 1000);
      return () => clearInterval(timer);
    }, [count]); // ✅
    

    问题根源:未声明依赖项时,effect 仅在挂载时执行一次,闭包中捕获的 count 是初始值。

2. 清理函数

  • 场景:定时器、事件监听、DOM 操作等需在组件卸载或依赖变化时清理。

    useEffect(() => {
      const handleResize = () => console.log("窗口大小变化");
      window.addEventListener("resize", handleResize);
      return () => window.removeEventListener("resize", handleResize); // ✅ 清理监听器
    }, []);
    

3. 避免无限循环

  • 问题:effect 中修改状态且依赖该状态。

    // 错误示例:依赖项包含 state,导致循环触发
    const [data, setData] = useState([]);
    useEffect(() => {
      fetchData().then((res) => setData(res)); // 触发重新渲染 → effect 重新执行
    }, [data]); // ❌
    

    解决方案:移除冗余依赖,或使用函数式更新:

    useEffect(() => {
      fetchData().then((res) => setData((prev) => [...prev, ...res])); // ✅ 无依赖
    }, []);
    

4. 复杂异步操作

  • 最佳实践:在 effect 内部定义异步函数,避免直接 async 修饰回调。

    useEffect(() => {
      let isMounted = true; // 防止卸载后更新状态
      const fetchData = async () => {
        const res = await api.getData();
        if (isMounted) setData(res);
      };
      fetchData();
      return () => { isMounted = false; }; // 清理标志位
    }, []);
    

六、useMemo 和 useCallback 的真正使用场景

1. useMemo:缓存计算结果

  • 适用场景

    • 高开销计算:如大数据排序、复杂公式、深拷贝。
    • 派生状态:根据 state/props 生成新对象或数组。
    • DOM 节点引用:如 useRef 存储 DOM 元素(需配合 useLayoutEffect)。
  • 避免滥用

    • 简单计算(如 a + b)无需缓存。
    • 频繁变化的依赖项(如 useMemo 依赖项变化频繁时反而降低性能)。
    // 正确使用:缓存复杂计算
    const filteredData = useMemo(() => {
      return data.filter(item => item.price > 100);
    }, [data]);
    
    // 错误使用:缓存简单值
    const simpleValue = useMemo(() => 42, []); // ❌ 无意义
    

2. useCallback:缓存函数引用

  • 适用场景

    • 传递回调给子组件:子组件使用 React.memo 时,避免因父组件重渲染导致子组件无效更新。
    • 依赖外部变量的函数:如事件处理器中依赖 state/props。
  • 避免滥用

    • 仅在需要稳定引用时使用(如传递给 useEffect 或子组件)。
    • 内部函数无需缓存(如仅在组件内部使用的函数)。
    // 正确使用:缓存事件处理器
    const handleClick = useCallback(() => {
      console.log("Clicked");
    }, []);
    
    // 错误使用:缓存内部函数
    const internalFn = useCallback(() => {
      // 仅在组件内部使用,无需缓存
    }, []);
    

七、避免过度优化的关键原则

1. 性能问题驱动优化

  • 先定位瓶颈:使用 React DevTools 的 Profiler 分析组件渲染开销。
  • 针对性优化:仅在渲染耗时或计算密集型场景使用 useMemo/useCallback

2. 依赖项管理

  • 完整声明:使用 ESLint 的 react-hooks/exhaustive-deps 规则强制检查。
  • 避免冗余依赖:如 useMemo 依赖项中包含未使用的变量。

3. 简单场景优先

  • 优先使用普通变量:如 const value = someData; 而非 useMemo(() => someData, [])
  • 函数内部使用:无需缓存仅在组件内部调用的函数。

八、实战案例对比

场景:商品列表筛选

  • 未优化版本(频繁重渲染):

    const ProductList = ({ products, filter }) => {
      const filtered = products.filter(/* 复杂逻辑 */);
      return <List items={filtered} />;
    };
    
  • 优化后版本

    const ProductList = ({ products, filter }) => {
      const filtered = useMemo(() => {
        return products.filter(/* 复杂逻辑 */);
      }, [products, filter]); // 仅当数据或筛选条件变化时重新计算
      return <List items={filtered} />;
    };
    

九、总结

Hook 核心用途 避坑指南
useEffect 副作用管理(数据获取、订阅) 依赖项完整、清理函数、避免循环
useMemo 缓存计算结果 仅高开销计算、避免简单值缓存
useCallback 缓存函数引用 仅传递给 memo 子组件、避免内部函数

关键口诀
“无必要,不优化;有性能问题,再针对性解决”。过度使用 Hooks 反而会增加代码复杂度和内存开销。

小米开源首代机器人VLA大模型

36氪获悉,2月12日,小米宣布开源首代机器人VLA大模型Xiaomi-Robotics-0。据介绍,这是一个拥有47亿参数、兼具视觉语言理解与高性能实时执行能力的开源VLA模型。

支付宝AI付笔数超1.2亿

36氪获悉,2月12日,支付宝“AI付”一周累计支付笔数已超1.2亿笔,成为全球首个支付笔数破亿的AI原生支付产品。“AI付”是面向AI时代推出的安全便捷支付服务,已在千问、Rokid、瑞幸等多个AI场景上线服务。

MinerU完成10余家国产AI芯片算力适配

36氪获悉,日前,上海人工智能实验室OpenDataLab团队联合DeepLink及多家国产芯片厂商,已完成对昇腾、平头哥、沐曦、太初元碁等10余家国产算力的深度适配。MinerU是上海人工智能实验室推出的AI文档解析工具,可将PDF、网页中的数学公式、复杂表格等元素高精度转化为大模型可读取的结构化数据,准确率达99%,被用于大模型语料生产及政企文档数字化。

模块联邦 2.0 稳定版发布:兼顾开发效率与极致性能

Frame 1912055630@2x (2).png

本文作者为 Web Infra 团队 - 2heal1

一年前,我们将 模块联邦 2.0 预览版 正式开源,收获了社区众多宝贵的反馈与建议。我们深知,一个成熟的微前端解决方案,不仅要能提升协同开发的效率,更需具备驱动极致性能的内核。因此,我们选择用一年的时间进行深度打磨与能力拓展。

今天,我们很高兴地宣布:模块联邦 2.0 稳定版正式发布。它不仅带来了强大的性能优化能力,还覆盖了更全面的部署场景,并对开发者体验进行了深度优化。

全链路性能优化体系

模块联邦 2.0 从 构建、资源加载、渲染到数据获取 四个层面,对微前端应用的性能路径进行了系统性重构。

这些能力并非孤立存在,而是协同工作,形成一套面向模块联邦场景的端到端性能优化体系。

所有能力均支持渐进式接入,你可以按需启用,不需要一次性改造现有架构,也不会影响已有系统的稳定性。

共享依赖 Tree Shaking

传统共享依赖虽然避免了重复加载,但为了保证完整性,shared 库往往会被 整库构建并暴露,即使只用到一小部分,也必须加载全部内容,导致包体积持续膨胀。

在模块联邦 2.0 中,这一问题被解决:

共享依赖也支持 Tree Shaking 了。

只需开启 treeShaking,MF 即可在不破坏模块联邦动态性的前提下,对共享依赖进行按需裁剪,仅保留真正可能被用到的模块,大幅减少 shared bundle 体积,并保持对现有项目的完全兼容。

模块联邦 2.0 提供两种模式来适配不同场景:

  • runtime-infer :零依赖、即开即用。运行时优先复用已加载的 Tree-Shaken 共享包,若能力不足则自动回退加载完整依赖,保证功能完备;可通过 usedExports 提升复用率。

  • server-calc :通过服务端或 CI 统一分析多个应用的依赖使用情况,生成全局最优的共享裁剪结果,适用于大型系统。

同时配套可视化分析页面,让共享依赖的使用情况与体积收益一目了然。

Ant Design 这类大型组件库为例,当应用仅使用 Badge、Button、List 三个组件时,在未启用共享依赖 Tree Shaking 的情况下,共享产物体积约为 1404.2 KB;启用该能力后,实际加载体积降至 344.0 KB,减少了约 75.5% 的非必要代码。

分析结果

服务端渲染(SSR)

SSR 通过在服务器端直接生成完整 HTML 并返回给浏览器,使页面无需等待客户端渲染即可显示,大幅提升首屏加载速度SEO 表现。在中大型 Web 应用中,SSR 已成为事实上的标准能力。

但在微前端架构下,SSR 一直难以落地。早期 MF 并未覆盖这一能力,开发者不得不在架构灵活性性能收益 之间做取舍。

在模块联邦 2.0 中,这一矛盾被消除。

你可以在 Modern.js 中直接使用 MF SSR,让微前端应用同样具备高性能的服务端渲染能力。

同构的数据预取方案

模块联邦 2.0 提供了一套全新的 同构数据获取方案,同时支持 SSR 与 CSR 场景,并内置 prefetchcache API,用于统一数据预取与缓存管理,在不同渲染模式下都能获得稳定、可预测的性能收益。

通过预加载与缓存机制,页面可以在渲染前就准备好关键数据,避免重复请求和瀑布流,从而显著优化首屏与交互响应速度。

能力锈化

在大型项目中,Manifest 生成常常成为构建阶段的性能瓶颈。模块联邦 2.0 将这一核心能力迁移至 Rust 实现,充分利用其高性能与内存安全特性,大幅降低 Manifest 生成的时间开销。

同时,AsyncStartUp 也已完成 Rust 化升级。你不再需要配置异步入口即可获得同等能力,相关的首屏性能隐患也被彻底消除,使应用启动路径更加简洁、稳定且高效。

更强大的调试体系

模块联邦 2.0 围绕可观测性与可调试性 构建了一套完整的调试体系,使共享依赖、模块关系以及潜在副作用都可以被 直观查看、精确定位与提前发现,大幅降低动态应用在开发与接入过程中的不确定性。

副作用检测工具

在接入远程模块前,消费者往往需要评估其是否会污染全局变量、注册事件监听或影响样式作用域,这些不确定性是微前端接入成本的重要来源。

模块联邦 2.0 提供 Side Effect Scanner,对产物进行静态分析并识别以下副作用:

  • 全局变量
  • 事件监听
  • CSS 选择器影响范围

配套的 CLI 工具 可自动完成扫描并输出结果,使消费者在接入前即可掌握风险,从而显著降低联邦模块的集成成本。

Chrome 插件依赖可视化

我们对原有 Chrome 插件进行了全面升级,提供了全新的 UI 与更强的调试能力,让模块联邦的运行状态 可见、可查、可验证

  • 智能侧边栏同步 插件面板会随当前页面自动切换,无需手动刷新或重连,调试体验更加流畅。

  • 共享依赖可视化 新增 Shared 面板,可直观查看当前页面的共享依赖加载情况,例如:- React 是否被成功共享 - 实际生效的共享版本 - 帮助你快速确认共享策略是否生效。

  • 依赖关系快速定位

支持在依赖图谱中搜索任意模块,并以清晰的层级结构展示其上下游关系,让复杂依赖一目了然。

依赖图谱

  • 数据裁剪模式 在模块数量较多时,可开启裁剪模式以减少代理数据体积,避免因 localStorage 限制导致的注入失败。 被裁剪的数据仅影响预加载分析,不影响实际功能,可安全开启。

更丰富的生态

MF 已覆盖主流的 构建工具、应用框架与 UI 技术栈,可以在不同技术体系下稳定运行,帮助团队在不改变既有技术选型的前提下引入模块联邦能力。

  • Bundler:Webpack / Rspack / Rollup / Rolldown

  • Build Tool:Rsbuild / Vite / Metro

  • Framework/Tool:Modern.js / Next.js / Rspress / Rslib / Storybook

  • UI Library:React / Vue / React Native

模块联邦 2.0 生态

在此基础上,模块联邦 2.0 进一步将模块联邦能力延伸到更多关键的开发与交付场景中:

Node.js 场景

模块联邦现已可以在 Node.js 运行时中直接使用,使远程模块不仅可以被浏览器加载,也可以在 SSR、BFF 以及 Node 服务层被统一消费,实现前后端一致的模块交付模型。

Rspress 文档分治场景

Rspress 中,MF 支持按文档或路由维度拆分并加载远程模块,使大型文档站点可以通过 模块联邦进行分治与独立发布,非常适合多团队协作的文档与知识平台。

Rstest

Rstest 测试框架中,模块联邦可以以真实运行时方式加载远程模块,使微前端与模块联邦应用能够进行 更贴近生产环境的集成测试与端到端测试,避免测试与实际运行环境割裂。

变更项

版本变更

我们注重 稳定性与兼容性,因此本次升级不含破坏性变更,仅包含以下微小变更,以下是核心变更说明:

微小变更

  • library.type 的默认值由 var 变更为 global
  • runtimePlugins 支持配置参数。

What's Next?

React Server Component

RSC 是 React 生态的革命性演进。相比 MF x SSR,MF x RSC 组合将带来更极致的性能(体积大幅减少)和更安全的数据操作。我们已在基础 Demo 中跑通该方案,并将在 Modern.js 中提供更好的适配与支持。

AI-friendly Design

我们正在为 MF 逐步补齐 AI 使用组件与模块所需的上下文与元数据,包括能力边界、使用约束、运行环境与依赖关系,并引入可量化的评分与可信度机制,让组件资产能够被 AI 理解、评估与选择。

退出英伟达HBM4供应名单?美光否认传闻:已量产出货

据报道,此前有传言称美光可能被排除在英伟达的HBM4供应商名单之外,美光首席财务官马克·墨菲(Mark Murphy)回应称:“关于第六代高带宽内存(HBM4)有一些不准确的报道,但美光已经开始量产HBM4。”他强调,美光已经开始向客户交付HBM4,预计第一季度出货量将大幅提升。(财联社)

优化使用 Nuxt3 开发的官网首页,秒开!

一年前接了个官网的开发的活(这个文章其实也写了一年了!),使用 nuxt3 进行开发,使用 SSG 模式即执行 nuxt generate 后将产物进行部署

测试环境: mac m1 16、 谷歌 136

开发部署完成后客户反应说打开速度有点慢,实际测试了下发现确实有点慢,直接看图

image.png

在无痕模式禁用缓存的情况需要近 20 多秒才能加载完成,它不正常!

再来看下优化后的加载情况

image.png

一秒内!几乎是秒开了,那我都干了什么呢?

开启 http 2.0

让 gpt 先做个总结

特性 HTTP/1.1 HTTP/2.0
协议格式 文本格式 二进制格式
多路复用 不支持,一个连接一次只处理一个请求 支持,一个连接可同时处理多个请求
头部压缩 无(头部冗余较多) 使用 HPACK 进行头部压缩
服务器推送 不支持 支持,服务器可主动推送资源
连接复用 多个请求需多个 TCP 连接或排队 所有请求共享一个 TCP 连接
队头阻塞(HOL) 存在,阻塞严重 解决,通过帧分片并异步处理
加密(TLS) 可选 可选(多数实现默认启用)

还有一个很重要的点是 同一域名下的并发连接数限制 http 1.1 是最多 6个, http 2.0 则是所有请求复用单一连接,通过多路复用并发处理,效率更高

开启 gzip

nuxt3 貌似并没有提供 gzip 压缩的相关操作,这个项目使用 docker 部署在镜像 nginx 中配置了 gzip 压缩

image.png

调试面板这样显示表示 gzip 配置成功!

使用 NuxtPicture 优化图片加载

<NuxtPicture
  format="avif,webp"
  :src="getImg('/about/banner.png')"
  :alt="$t('about.title')"
  :img-attrs="{ style: 'width: 100%' }"
  width="1920"
  height="578"
  placeholder
  loading="lazy"
/>

loading img 原生属性图片可见时加载

format 响应式图片加载,单从图片压缩后大小来看 avif < webp < jpg < png 这里是如果浏览器支持 avif or webp 就使用 否则使用原始图片也就是 src 设置的图片

响应式图片加载

这里以首页封面图为例

<picture>
  <source type="image/avif" srcset="home/banner.avif">
  <source type="image/webp" srcset="home/banner.webp">
  <img src="home/banner.png" alt="用中文链接世界"class="w-full">
</picture>
  1. <picture> 元素:用于为不同的设备或浏览器条件提供不同的图片资源。
  2. <source> 标签:指定不同格式的图片(如 AVIF、WebP)。
  3. 降级加载(Fallback) :如果浏览器不支持 AVIF,会加载 WebP;都不支持则加载 PNG。
格式 压缩类型 浏览器兼容性 文件大小(相同质量) 加载速度 适用场景
AVIF 有损/无损 较新浏览器支持良好 最小(高压缩率) 现代 Web,体积敏感场景
WebP 有损/无损 主流浏览器广泛支持 较小 Web 图片优化
PNG 无损 全面支持 图标、透明图、高质量图像
JPG 有损 全面支持 摄影、背景图等色彩丰富图片

通过上边的表格可以看出来同样质量的图片 AVIF、WebP 体积要小于 PNG、JPG 下边来对比一下

AVIF 440KB -> 156KB 减少了 65%

image.png

WebP 440KB -> 156KB 减少了 65%

image.png

看到这里你觉得也许不过如此!但是如果把图片质量下降到 70 AVIF 图片的体积可以减少到 35KB 减少 92% 的体积

image.png

AVIF 与 PNG 加载速度对比

AVIF- 34ms

image.png

PNG 3.36秒

image.png

1. 字体文件放到 cdn 2. 延迟字体加载

另一个拖慢加载的元凶是字体加载

image.png

最初的方式是创建一个 font.scss 文件

@font-face {
  font-family: 'AlibabaPuHuiTi-3-55-Regular';
  src: url('/public/fonts/AlibabaPuHuiTi/AlibabaPuHuiTi-3-55-Regular.woff2') format('woff2');
  font-weight: normal;
  font-style: normal;
  font-display: swap;
}

index.scss 中引入

@use './font.scss';

然后配置 nuxt.config.ts

css: [
    '~/assets/style/index.scss',
],

这样做会导致字体文件参与打包并和其他js、css 等文件同时加载

image.png

可以这样改把 font.scss 改为 font.css 放到 public/fonts/font.css

然后修改 nuxt.config.ts

  app: {
    head: {
      link: [
        {
          rel: 'stylesheet',
          href: '/fonts/font.css',
          media: 'print',
          onload: 'this.media=\'all\'',
        },
      ],
    },
  },

这样 font.css 就会延迟加载从而延迟加载字体文件且字体文件不会参与打包

image.png

合理优化图片可以让官网加载速度提升

首页打开加载完成时间从 26 秒 减少到 2 秒,性能提升了约 92.31% ,即 13 倍

image.png

image.png

总结

通过使用 http2.0 、gzip、图片懒加载、响应式图片加载、延迟字体加载等操作可以让首页达到秒开的效果

就这么简单的几步就可以让首页加载速度提升一个级别

这还是无痕禁用缓存下的数据,如果不禁用缓存还会更快

图片压缩工具是 squoosh.app

A股三大指数午间休盘集体上涨,CPO概念大涨

36氪获悉,A股三大指数午间休盘集体上涨,沪指涨0.12%,深成指涨0.81%,创业板涨1.18%;CPO概念大涨,太辰光涨近15%,天孚通信、长芯博创涨超11%;海运板块走强,中远海能、招商轮船涨停;传媒股走弱,欢瑞世纪、横店影视跌停,光线传媒跌超10%。

学习Three.js--柱状图

学习Three.js--柱状图

前置核心说明

开发目标

基于Three.js实现ECharts标准风格的3D柱状图,还原ECharts的视觉特征(配色、标签样式、坐标轴风格),同时具备3D场景的交互性与真实光影效果,核心能力包括:

  1. 视觉风格还原:精准复刻ECharts柱状图的配色、坐标轴样式、标签样式(轴刻度、轴名称、柱顶数值),兼顾3D立体感与2D可视化的简洁性;
  2. 3D场景构建:通过几何体、材质、光照系统打造真实的3D视觉效果,阴影系统增强立体感,避免3D场景的平面化;
  3. 2D标签渲染:借助CSS2D渲染器实现灵活的文字标签,解决Three.js原生文字渲染样式受限的问题,贴合ECharts的标签风格;
  4. 流畅交互体验:通过轨道控制器实现360°拖拽旋转、滚轮缩放,且启用阻尼效果提升交互顺滑度;
  5. 响应式适配:适配不同屏幕尺寸,保证柱状图在PC/平板等设备上无拉伸变形;
  6. 循环动画驱动:通过帧动画循环维持交互阻尼与场景渲染的流畅性。

c01cf879-a892-4478-b422-08456d7cbcf8.png

核心技术栈(关键知识点)

技术点 作用
THREE.Scene/Camera/Renderer 搭建3D场景基础框架,定义视角、渲染尺寸、抗锯齿等核心属性,是所有3D效果的载体
THREE.OrbitControls 实现3D场景的轨道交互(拖拽旋转、滚轮缩放),启用阻尼让交互更自然,适配3D柱状图的查看需求
CSS2DRenderer/CSS2DObject 将HTML/CSS创建的文字标签(轴刻度、柱顶数值)绑定到3D空间坐标,实现灵活的样式控制,还原ECharts标签风格
THREE.MeshStandardMaterial/LineBasicMaterial 分别实现几何体(柱子、地面)的物理材质(支持光影)和线条(坐标轴)的基础材质,保证视觉质感
THREE.BoxGeometry/CircleGeometry/BufferGeometry 构建柱子(立方体)、地面(圆形)、坐标轴(线条)的几何形态,是3D物体的形状基础
THREE.DirectionalLight/AmbientLight/PointLight 组合环境光、方向光、点光源,打造分层级的光照效果,增强3D柱子的立体感与真实感
THREE.ShadowMap(阴影系统) 开启软阴影并配置阴影参数,让柱子投射自然阴影到地面,提升场景的真实度
响应式窗口监听(resize事件) 动态更新相机比例与渲染器尺寸,保证柱状图在不同屏幕下无拉伸变形
requestAnimationFrame 驱动动画循环,维持轨道控制器阻尼更新与场景渲染的流畅性(60帧/秒)

核心开发流程

graph TD
    A["初始化场景/相机/渲染器/轨道控制器"] --> B["初始化CSS2D渲染器(标签渲染)"]
    B --> C["构建光照系统(环境光+主光源+辅助光)"]
    C --> D["定义图表数据与视觉配置参数"]
    D --> E["构建地面与网格辅助线(视觉锚点)"]
    E --> F["构建坐标轴系统(X/Y轴+刻度+名称标签)"]
    F --> G["构建柱状图主体(几何体+材质+柱顶数值标签)"]
    G --> H["添加辅助线/装饰元素(Z轴短线、原点装饰)"]
    H --> I["绑定窗口resize事件(响应式适配)"]
    I --> J["启动动画循环(更新控制器+渲染场景)"]

分步开发详解

步骤1:基础环境搭建(场景/相机/渲染器/控制器)

搭建Three.js 3D场景的核心框架,为柱状图提供基础的展示载体与交互能力,兼顾视角合理性与渲染清晰度。

1.1 核心代码(对应原代码1)
// 初始化场景、相机、渲染器
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf5f7fa); // 柔和灰白背景,贴合ECharts容器

const camera = new THREE.PerspectiveCamera(
    45, // 更小视角,减少畸变,更接近2D感
    window.innerWidth / window.innerHeight,
    0.1,
    1000
);
camera.position.set(9, 7, 14);
camera.lookAt(4, 3, 0);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;        // 开启阴影,更真实
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);

// 轨道控制器,带阻尼
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.06;
controls.autoRotate = false;
controls.enableZoom = true;
controls.target.set(4, 3, 0);
controls.minDistance = 8;
controls.maxDistance = 30;
1.2 关键说明
  • 相机参数优化
    • 视角45°:相比默认的75°,更小的视角减少3D畸变,让柱状图更接近ECharts的2D视觉风格,同时保留3D纵深;
    • 位置(9,7,14)+注视点(4,3,0):采用“斜上方俯视”视角,既可以清晰看到柱子的高度与X轴分类,又能体现3D立体感,避免视角过平/过陡导致的视觉失衡。
  • 渲染器核心配置
    • antialias: true:开启抗锯齿,让柱子边缘、坐标轴线条更细腻,避免“锯齿边”;
    • shadowMap.type = PCFSoftShadowMap:启用软阴影,让柱子投射的阴影边缘更柔和,贴合真实物理效果,避免硬阴影的生硬感;
    • setPixelRatio:适配Retina屏幕,保证高清渲染,标签文字与柱子细节无模糊。
  • 轨道控制器优化
    • 阻尼系数0.06:拖拽旋转场景后,控制器会自然减速,交互更顺滑;
    • 缩放范围8~30:限制最小/最大缩放距离,避免缩放过近导致柱子遮挡、过远导致细节丢失;
    • autoRotate: false:关闭自动旋转,让用户自主控制查看视角,更贴合数据可视化的使用场景。

步骤2:CSS2D渲染器初始化(标签渲染核心)

初始化CSS2D渲染器,为坐标轴标签、柱顶数值标签提供渲染载体,解决Three.js原生文字渲染样式不灵活的问题。

2.1 核心代码(对应原代码2)
const labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(window.innerWidth, window.innerHeight);
labelRenderer.domElement.style.position = 'absolute';
labelRenderer.domElement.style.top = '0px';
labelRenderer.domElement.style.left = '0px';
labelRenderer.domElement.style.pointerEvents = 'none'; // 不阻挡点击
document.body.appendChild(labelRenderer.domElement);
2.2 关键说明
  • CSS2D渲染器的核心价值:将HTML元素(<div>标签)映射到3D空间坐标,既能利用CSS灵活控制文字样式(颜色、字体、背景、圆角等),又能跟随3D场景的旋转/缩放同步更新位置,完美还原ECharts的标签风格。
  • pointerEvents: none:关闭标签的鼠标事件响应,避免标签遮挡轨道控制器的交互(如拖拽旋转时点击到标签无反应)。

步骤3:光照系统构建(光影质感核心)

组合环境光、方向光、点光源,打造分层级的光照效果,增强柱子的立体感,同时避免光线过亮/过暗导致的视觉失衡。

3.1 核心代码(对应原代码3)
// 环境光提供基础照明
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);

// 主光源 - 产生阴影
const mainLight = new THREE.DirectionalLight(0xfff5e6, 1.0);
mainLight.position.set(8, 12, 8);
mainLight.castShadow = true;
mainLight.receiveShadow = true;
mainLight.shadow.mapSize.width = 1024;
mainLight.shadow.mapSize.height = 1024;
mainLight.shadow.camera.near = 0.5;
mainLight.shadow.camera.far = 30;
mainLight.shadow.camera.left = -15;
mainLight.shadow.camera.right = 15;
mainLight.shadow.camera.top = 15;
mainLight.shadow.camera.bottom = -15;
scene.add(mainLight);

// 辅助背光,增加柱子暗部细节
const backLight = new THREE.DirectionalLight(0xe0f0ff, 0.5);
backLight.position.set(-5, 0, 10);
scene.add(backLight);

// 补充侧光
const fillLight = new THREE.PointLight(0xccddff, 0.3);
fillLight.position.set(2, 5, 12);
scene.add(fillLight);
3.2 关键说明
  • 光照分层逻辑
    • 环境光(强度0.6):提供基础照明,避免场景暗部全黑,保证所有元素的基础可见性;
    • 主方向光(强度1.0):模拟主光源(如自然光),同时开启阴影,是柱子立体感的核心;
    • 背光(强度0.5):补充柱子暗部细节,避免暗部“死黑”,提升光影层次感;
    • 点光源(强度0.3):柔和填充侧光,进一步优化光影过渡,让柱子的材质质感更真实。
  • 阴影参数优化
    • shadow.mapSize = 1024x1024:设置阴影贴图分辨率,数值越高阴影越清晰(但性能开销越大),1024是“清晰度+性能”的平衡值;
    • 阴影相机范围(-15~15):覆盖整个柱状图场景,保证所有柱子的阴影都能被正确渲染。

步骤4:图表数据与配置参数定义

定义柱状图的业务数据与视觉配置参数,实现“数据与样式解耦”,便于后续调整视觉风格。

4.1 核心代码(对应原代码4)
// 图表数据(ECharts 标准:一月到六月,数值清晰)
const chartData = [
    { label: '一月', value: 2 },
    { label: '二月', value: 3 },
    { label: '三月', value: 4 },
    { label: '四月', value: 5 },
    { label: '五月', value: 6 },
    { label: '六月', value: 7 }
];

// 配置参数 —— 完全符合柱状图审美
const config = {
    columnWidth: 0.9,          // 柱子宽一些,更饱满
    columnDepth: 0.9,          // Z轴厚度
    yMax: 10,                 // Y轴最大值固定为10,留白更ECharts(原数据最大7,顶部留空间显示数值)
    xStep: 2.1,              // 柱间距适中
    yStep: 2,                // Y轴刻度步长
    axisColor: 0x5d6d7e,     // 轴体深蓝灰,专业感
    axisLineWidth: 2.2,      // 轴线条加粗
    columnColors: [0x5470c6, 0x91cc75, 0xfac858, 0xee6666, 0x73c0de, 0x3ba272], // ECharts 经典配色
    columnXOffset: 1.8,      // 柱子起点与Y轴留白,避免拥挤(ECharts风格)
    groundOpacity: 0.15,      // 地面极浅,仅作视觉参考
    shadowOpacity: 0.3
};
4.2 关键说明
  • 数据结构设计chartData采用“标签+数值”的对象数组,贴合ECharts的数据格式,便于后续对接真实业务数据;
  • 配置参数的ECharts适配
    • yMax: 10:预留顶部空间(原数据最大7),避免柱顶数值标签与柱子顶部重叠,符合ECharts的留白审美;
    • columnColors:使用ECharts经典配色数组,按柱子循环使用,保证视觉风格一致;
    • xStep: 2.1/columnXOffset: 1.8:控制柱子间距与X轴偏移,避免柱子与Y轴拥挤,贴合ECharts的轴边距风格。

步骤5:地面与网格辅助线构建

构建地面几何体与网格辅助线,为3D场景提供视觉锚点,增强坐标感,同时接收柱子的阴影。

5.1 核心代码(对应原代码5)
// 地面(接收阴影,视觉锚点)
const groundGeometry = new THREE.CircleGeometry(30, 32); // 圆形地面更柔和
const groundMaterial = new THREE.MeshStandardMaterial({
    color: 0xe9ecef,
    roughness: 0.8,
    metalness: 0.1,
    transparent: true,
    opacity: config.groundOpacity,
    side: THREE.DoubleSide
});
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.1;
ground.receiveShadow = true;
scene.add(ground);

// 添加一个极细的网格辅助线(可选,增强坐标感,但不抢眼)
const gridHelper = new THREE.GridHelper(26, 20, 0x9aa6b2, 0xcbd5e0);
gridHelper.position.y = -0.09;
scene.add(gridHelper);
5.2 关键说明
  • 地面设计优化
    • 圆形地面(CircleGeometry):相比方形地面更柔和,避免直角的生硬感;
    • 透明度0.15:地面仅作视觉锚点,不抢夺柱状图的视觉焦点;
    • receiveShadow: true:开启阴影接收,让柱子的阴影投射到地面,增强3D真实感。
  • 网格辅助线
    • 位置y=-0.09:略高于地面,避免与地面重叠导致的视觉混乱;
    • 浅灰色调:增强坐标感的同时,不干扰主视觉,符合数据可视化“辅助元素不抢戏”的原则。

步骤6:坐标轴系统构建(X/Y轴+刻度+标签)

构建符合ECharts风格的X/Y坐标轴,包括轴线、刻度线、分类/数值标签、轴名称,是数据可视化的核心骨架。

6.1 核心代码(对应原代码6,以X轴为例)
// 坐标轴材质
const axisMaterial = new THREE.LineBasicMaterial({ 
    color: config.axisColor,
    linewidth: config.axisLineWidth
});

// ===== X轴 =====
const xAxisStart = -0.5;
const xAxisEnd = (chartData.length - 1) * config.xStep + config.columnXOffset + 1.2;
const xAxisPoints = [new THREE.Vector3(xAxisStart, 0, 0), new THREE.Vector3(xAxisEnd, 0, 0)];
const xAxisGeo = new THREE.BufferGeometry().setFromPoints(xAxisPoints);
const xAxisLine = new THREE.Line(xAxisGeo, axisMaterial);
scene.add(xAxisLine);

// X轴刻度与分类标签
chartData.forEach((item, i) => {
    const xPos = i * config.xStep + config.columnXOffset;
    
    // 刻度线(向下,长度0.6,清晰可见)
    const tickPoints = [
        new THREE.Vector3(xPos, 0, 0),
        new THREE.Vector3(xPos, -0.6, 0)
    ];
    const tickGeo = new THREE.BufferGeometry().setFromPoints(tickPoints);
    const tick = new THREE.Line(tickGeo, axisMaterial);
    scene.add(tick);

    // 分类标签 (CSS2D) —— ECharts风格:居中,字体适中
    const labelDiv = document.createElement('div');
    labelDiv.className = 'axis-tick-label';
    labelDiv.textContent = item.label;
    labelDiv.style.transform = 'translate(-50%, 0)';
    labelDiv.style.fontWeight = '500';
    labelDiv.style.color = '#2e4053';
    const labelObj = new CSS2DObject(labelDiv);
    labelObj.position.set(xPos, -1.1, 0);
    scene.add(labelObj);
});

// X轴名称 “月份” (ECharts标准:置于轴末端附近,加粗)
const xNameDiv = document.createElement('div');
xNameDiv.className = 'axis-name-label';
xNameDiv.textContent = '月份';
xNameDiv.style.transform = 'translate(-50%, 0)';
const xNameLabel = new CSS2DObject(xNameDiv);
xNameLabel.position.set(xAxisEnd - 0.3, -1.8, 0);
scene.add(xNameLabel);
6.2 关键说明
  • 轴线长度自适应xAxisEnd通过chartData.length动态计算,保证X轴长度适配数据条数,避免硬编码导致的适配问题;
  • 刻度线与标签对齐
    • 刻度线长度0.6:清晰可见且不突兀,符合ECharts的刻度线尺寸;
    • 标签位置y=-1.1:位于刻度线下方,避免与轴线/刻度线重叠;
    • transform: translate(-50%, 0):让标签水平居中对齐刻度线,保证视觉整齐;
  • 轴名称样式:采用加粗、深色、末端放置的样式,完全复刻ECharts的轴名称风格,增强数据可读性。

步骤7:柱状图主体构建(几何体+材质+标签)

构建3D柱子几何体,配置贴合ECharts风格的材质,同时添加柱顶数值标签,是数据可视化的核心展示层。

7.1 核心代码(对应原代码7)
chartData.forEach((item, i) => {
    const xPos = i * config.xStep + config.columnXOffset;
    const height = item.value;     // 实际数值
    const color = config.columnColors[i % config.columnColors.length];

    // 柱子材质:轻微光泽,略带透明感但清晰
    const columnMaterial = new THREE.MeshStandardMaterial({
        color: color,
        roughness: 0.45,
        metalness: 0.2,
        emissive: new THREE.Color(color).multiplyScalar(0.1),
        emissiveIntensity: 0.2,
        transparent: true,
        opacity: 0.95
    });

    const columnGeo = new THREE.BoxGeometry(
        config.columnWidth,
        height,
        config.columnDepth
    );
    const column = new THREE.Mesh(columnGeo, columnMaterial);
    column.castShadow = true;
    column.receiveShadow = true;
    column.position.set(xPos, height / 2, 0);
    scene.add(column);

    // 柱顶数值标签(核心要求:每一个柱子顶部显示相应的值,ECharts 风格)
    const valueDiv = document.createElement('div');
    valueDiv.className = 'bar-value-label';
    valueDiv.textContent = item.value;   // 简洁显示数值
    // 仿ECharts: 白色底框+红褐色字,小圆角
    valueDiv.style.background = 'rgba(255, 255, 255, 0.8)';
    valueDiv.style.border = '1px solid ' + new THREE.Color(color).getStyle();
    valueDiv.style.color = new THREE.Color(color).multiplyScalar(0.6).getStyle();
    
    const valueLabel = new CSS2DObject(valueDiv);
    // 标签置于柱子顶部上方0.8处,醒目不重叠
    valueLabel.position.set(xPos, height + 0.8, 0);
    scene.add(valueLabel);

    // 柱顶圆点装饰
    const topDotGeo = new THREE.SphereGeometry(0.1);
    const topDotMat = new THREE.MeshStandardMaterial({ color: color, emissive: color, emissiveIntensity: 0.2 });
    const topDot = new THREE.Mesh(topDotGeo, topDotMat);
    topDot.position.set(xPos, height, 0);
    topDot.castShadow = false;
    scene.add(topDot);
});
7.2 关键说明
  • 柱子位置计算position.set(xPos, height / 2, 0),Three.js的立方体几何体锚点在中心,因此Y轴位置需设置为height/2,保证柱子底部对齐X轴(y=0);
  • 材质风格优化
    • roughness: 0.45/metalness: 0.2:轻微的金属光泽,让柱子有质感但不刺眼;
    • emissive:轻微自发光,增强柱子的视觉层次感,贴合ECharts的高亮风格;
    • 透明度0.95:略带透明感,避免柱子过于厚重,符合现代数据可视化的轻盈风格;
  • 柱顶标签优化
    • 位置height + 0.8:位于柱子顶部上方,避免与柱子重叠;
    • 背景半透+圆角:复刻ECharts的数值标签样式,增强可读性;
    • 颜色适配:标签边框/文字颜色与柱子颜色联动,保证视觉统一性。

步骤8:辅助线与装饰元素构建

添加Z轴短线、背面辅助线等装饰元素,增强3D场景的方位感,同时不干扰主视觉。

8.1 核心代码(对应原代码8)
// Z轴短线示意(虽然2D柱状图,但3D空间给出方位)
const zAxisPoints = [new THREE.Vector3(0, 0, -2), new THREE.Vector3(0, 0, 2)];
const zAxisGeo = new THREE.BufferGeometry().setFromPoints(zAxisPoints);
const zAxisLine = new THREE.Line(zAxisGeo, new THREE.LineBasicMaterial({ color: 0xb0bec5, linewidth: 1 }));
scene.add(zAxisLine);

// 背面辅助线,增加空间感
const helperExtent = 14;
const axisHelperMaterial = new THREE.LineBasicMaterial({ color: 0xd0d8e0, linewidth: 1 });
const backLine1 = [new THREE.Vector3(-1, 0, -1.5), new THREE.Vector3(xAxisEnd, 0, -1.5)];
const backLineGeo1 = new THREE.BufferGeometry().setFromPoints(backLine1);
const backLineObj1 = new THREE.Line(backLineGeo1, axisHelperMaterial);
scene.add(backLineObj1);
8.2 关键说明
  • Z轴短线:提示3D场景的纵深方向,让用户感知柱子的厚度(Z轴),避免误以为是纯2D图表;
  • 背面辅助线:浅灰色细线条,增强场景的空间层次感,同时不抢夺柱状图的视觉焦点。

步骤9:响应式窗口适配

监听窗口尺寸变化,动态更新相机比例、渲染器尺寸,保证柱状图在不同屏幕下无拉伸变形。

9.1 核心代码(对应原代码9)
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
    labelRenderer.setSize(window.innerWidth, window.innerHeight);
}
9.2 关键说明
  • camera.updateProjectionMatrix():窗口尺寸变化后,相机的宽高比会修改,必须调用该方法更新投影矩阵,否则柱状图会出现拉伸变形;
  • 同步更新labelRenderer尺寸:保证CSS2D标签与3D场景同步适配,避免标签位置偏移。

步骤10:动画循环驱动

通过requestAnimationFrame驱动动画循环,维持轨道控制器阻尼更新与场景渲染的流畅性。

10.1 核心代码(对应原代码10)
function animate() {
    requestAnimationFrame(animate);
    controls.update(); // 启用阻尼后需要每帧更新
    renderer.render(scene, camera);
    labelRenderer.render(scene, camera);
}
animate();
10.2 关键说明
  • controls.update():轨道控制器启用阻尼后,必须在每帧动画中调用该方法,否则阻尼效果失效;
  • 双渲染器调用:同时渲染3D场景(renderer)和CSS2D标签(labelRenderer),保证标签与3D场景同步显示。

核心技术深度解析

1. CSS2D标签渲染的核心逻辑

CSS2D标签是还原ECharts风格的关键,其核心渲染逻辑如下:

graph LR
    A["创建HTML div标签(设置样式/文字)"] --> B["封装为CSS2DObject(绑定3D坐标)"]
    B --> C["添加到Three.js场景"] --> D["动画循环中调用labelRenderer.render"]
    D --> E["标签随3D场景旋转/缩放同步更新位置"]
  • 核心优势:相比Three.js原生的TextGeometry,CSS2D标签支持任意CSS样式(背景、圆角、文字阴影、毛玻璃等),完全复刻ECharts的标签风格,且渲染性能更高。

2. 光影系统的层次感实现

柱状图的3D立体感核心来自分层光照设计,其逻辑如下:

graph LR
    A["环境光(基础照明,0.6强度)"] --> B["主方向光(核心光影+阴影,1.0强度)"]
    B --> C["背光(补充暗部细节,0.5强度)"] --> D["点光源(柔和填充侧光,0.3强度)"]
    D --> E["柱子材质(roughness/metalness/emissive)"] --> F["自然的3D光影质感"]
  • 核心亮点:主光源负责阴影和主要光照,背光和点光源补充细节,避免暗部死黑,让柱子的材质质感更真实。

3. ECharts风格视觉还原的核心

代码通过三大维度精准复刻ECharts风格:

  1. 配色维度:使用ECharts经典配色数组,轴体采用深蓝灰(专业感),标签采用分级配色(刻度浅灰、轴名深灰、数值醒目色);
  2. 布局维度:Y轴预留顶部空间、柱子与Y轴留白、刻度线长度统一、标签位置对齐,完全贴合ECharts的布局审美;
  3. 样式维度:标签的文字阴影、半透背景、圆角,柱子的轻微光泽/透明感,轴线条加粗,均复刻ECharts的视觉细节。

核心参数速查表(快速调整视觉/交互效果)

参数分类 参数名 当前取值 核心作用 修改建议
场景配置 相机视角 camera.fov 45 控制3D场景的视角,越小畸变越少 改为30:更接近2D视觉;改为60:3D纵深感更强
场景配置 相机位置 camera.position (9,7,14) 控制观察视角(斜上方俯视) 改为(0,10,15):正面视角;改为(15,5,10):侧方视角
视觉配置 柱子尺寸 columnWidth/columnDepth 0.9/0.9 控制柱子的宽度/厚度 改为0.7/0.7:柱子更纤细;改为1.2/1.2:柱子更粗壮
视觉配置 柱间距 xStep 2.1 控制X轴上柱子的间距 改为1.5:柱子更密集;改为3.0:柱子更稀疏
视觉配置 Y轴最大值 yMax 10 控制Y轴高度(预留标签空间) 改为8:Y轴更紧凑;改为15:Y轴更宽松
视觉配置 轴线条宽度 axisLineWidth 2.2 控制坐标轴线的粗细 改为1.5:轴线更细;改为3.0:轴线更粗
数据配置 图表数据 chartData 6组(一月-六月) 柱状图的业务数据 新增/删除数组元素:适配更多/更少分类;修改value:调整柱子高度
光照配置 主光源强度 mainLight.intensity 1.0 控制主光源的亮度 改为0.7:光线更柔和;改为1.5:光线更明亮
交互配置 控制器阻尼 dampingFactor 0.06 控制拖拽旋转的顺滑度 改为0.03:阻尼更弱(旋转更快);改为0.1:阻尼更强(旋转更慢)
交互配置 缩放范围 minDistance/maxDistance 8/30 控制滚轮缩放的最小/最大距离 改为5/20:缩放范围更小;改为10/40:缩放范围更大

完整代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Three.js 柱状图 · ECharts 标准风格</title>
    <style>
        body {
            margin: 0;
            overflow: hidden;
            font-family: "Microsoft YaHei", sans-serif;
        }
        canvas {
            display: block;
        }
        /* 坐标轴标签:清爽灰色,无干扰 */
        .axis-tick-label {
            color: #4a4a4a;
            font-size: 13px;
            font-weight: normal;
            white-space: nowrap;
            pointer-events: none;
            text-shadow: 0 0 2px rgba(255,255,255,0.8);
        }
        /* 轴名称标签 (ECharts 风格:加粗,深色) */
        .axis-name-label {
            color: #2c3e50;
            font-size: 16px;
            font-weight: 600;
            white-space: nowrap;
            pointer-events: none;
            text-shadow: 0 0 3px rgba(255,255,255,0.9);
        }
        /* 柱顶数值标签:醒目红褐色,类似ECharts 强调 */
        .bar-value-label {
            color: #c0392b;
            font-size: 14px;
            font-weight: 600;
            white-space: nowrap;
            pointer-events: none;
            text-shadow: 0 0 5px rgba(255,255,255,0.8);
            background: rgba(255, 255, 255, 0.6);
            padding: 2px 6px;
            border-radius: 10px;
            border: 1px solid rgba(192, 57, 43, 0.3);
            backdrop-filter: blur(2px);
        }
        /* 简单辅助:去掉滚动条,干净视图 */
    </style>
</head>
<body>
    <script type="module">
        import * as THREE from 'https://esm.sh/three@0.174.0';
        import { OrbitControls } from 'https://esm.sh/three@0.174.0/examples/jsm/controls/OrbitControls.js';
        import { CSS2DRenderer, CSS2DObject } from 'https://esm.sh/three@0.174.0/examples/jsm/renderers/CSS2DRenderer.js';

        // ---------- 1. 初始化场景、相机、渲染器(增强抗锯齿与性能)----------
        const scene = new THREE.Scene();
        scene.background = new THREE.Color(0xf5f7fa); // 柔和灰白背景,类似ECharts容器

        const camera = new THREE.PerspectiveCamera(
            45, // 更小视角,减少畸变,更接近2D感
            window.innerWidth / window.innerHeight,
            0.1,
            1000
        );
        camera.position.set(9, 7, 14);
        camera.lookAt(4, 3, 0);

        const renderer = new THREE.WebGLRenderer({ antialias: true });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.shadowMap.enabled = true;        // 开启阴影,更真实
        renderer.shadowMap.type = THREE.PCFSoftShadowMap;
        renderer.setPixelRatio(window.devicePixelRatio);
        document.body.appendChild(renderer.domElement);

        // 轨道控制器,带阻尼
        const controls = new OrbitControls(camera, renderer.domElement);
        controls.enableDamping = true;
        controls.dampingFactor = 0.06;
        controls.autoRotate = false;
        controls.enableZoom = true;
        controls.target.set(4, 3, 0);
        controls.minDistance = 8;
        controls.maxDistance = 30;

        // ---------- 2. CSS2渲染器(用于所有文字标签:轴刻度、轴名称、柱顶数值)----------
        const labelRenderer = new CSS2DRenderer();
        labelRenderer.setSize(window.innerWidth, window.innerHeight);
        labelRenderer.domElement.style.position = 'absolute';
        labelRenderer.domElement.style.top = '0px';
        labelRenderer.domElement.style.left = '0px';
        labelRenderer.domElement.style.pointerEvents = 'none'; // 不阻挡点击
        document.body.appendChild(labelRenderer.domElement);

        // ---------- 3. 灯光系统(呈现立体感,但不刺眼)----------
        // 环境光提供基础照明
        const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
        scene.add(ambientLight);

        // 主光源 - 产生阴影
        const mainLight = new THREE.DirectionalLight(0xfff5e6, 1.0);
        mainLight.position.set(8, 12, 8);
        mainLight.castShadow = true;
        mainLight.receiveShadow = true;
        mainLight.shadow.mapSize.width = 1024;
        mainLight.shadow.mapSize.height = 1024;
        mainLight.shadow.camera.near = 0.5;
        mainLight.shadow.camera.far = 30;
        mainLight.shadow.camera.left = -15;
        mainLight.shadow.camera.right = 15;
        mainLight.shadow.camera.top = 15;
        mainLight.shadow.camera.bottom = -15;
        scene.add(mainLight);

        // 辅助背光,增加柱子暗部细节
        const backLight = new THREE.DirectionalLight(0xe0f0ff, 0.5);
        backLight.position.set(-5, 0, 10);
        scene.add(backLight);

        // 补充侧光
        const fillLight = new THREE.PointLight(0xccddff, 0.3);
        fillLight.position.set(2, 5, 12);
        scene.add(fillLight);

        // ---------- 4. 图表数据(ECharts 标准:一月到六月,数值清晰)----------
        const chartData = [
            { label: '一月', value: 2 },
            { label: '二月', value: 3 },
            { label: '三月', value: 4 },
            { label: '四月', value: 5 },
            { label: '五月', value: 6 },
            { label: '六月', value: 7 }
        ];

        // 配置参数 —— 完全符合柱状图审美
        const config = {
            columnWidth: 0.9,          // 柱子宽一些,更饱满
            columnDepth: 0.9,          // Z轴厚度
            yMax: 10,                 // Y轴最大值固定为10,留白更ECharts(原数据最大7,顶部留空间显示数值)
            xStep: 2.1,              // 柱间距适中
            yStep: 2,                // Y轴刻度步长
            axisColor: 0x5d6d7e,     // 轴体深蓝灰,专业感
            axisLineWidth: 2.2,      // 轴线条加粗
            columnColors: [0x5470c6, 0x91cc75, 0xfac858, 0xee6666, 0x73c0de, 0x3ba272], // ECharts 经典配色
            columnXOffset: 1.8,      // 柱子起点与Y轴留白,避免拥挤(ECharts风格)
            groundOpacity: 0.15,      // 地面极浅,仅作视觉参考
            shadowOpacity: 0.3
        };

        // ---------- 5. 地面(接收阴影,视觉锚点)----------
        const groundGeometry = new THREE.CircleGeometry(30, 32); // 圆形地面更柔和
        const groundMaterial = new THREE.MeshStandardMaterial({
            color: 0xe9ecef,
            roughness: 0.8,
            metalness: 0.1,
            transparent: true,
            opacity: config.groundOpacity,
            side: THREE.DoubleSide
        });
        const ground = new THREE.Mesh(groundGeometry, groundMaterial);
        ground.rotation.x = -Math.PI / 2;
        ground.position.y = -0.1;
        ground.receiveShadow = true;
        scene.add(ground);

        // 添加一个极细的网格辅助线(可选,增强坐标感,但不抢眼)
        const gridHelper = new THREE.GridHelper(26, 20, 0x9aa6b2, 0xcbd5e0);
        gridHelper.position.y = -0.09;
        scene.add(gridHelper);

        // ---------- 6. 坐标轴系统(完全参照ECharts:轴线明显,刻度清晰,轴标签明确)----------
        const axisMaterial = new THREE.LineBasicMaterial({ 
            color: config.axisColor,
            linewidth: config.axisLineWidth
        });

        // ===== X轴 =====
        const xAxisStart = -0.5;
        const xAxisEnd = (chartData.length - 1) * config.xStep + config.columnXOffset + 1.2;
        const xAxisPoints = [new THREE.Vector3(xAxisStart, 0, 0), new THREE.Vector3(xAxisEnd, 0, 0)];
        const xAxisGeo = new THREE.BufferGeometry().setFromPoints(xAxisPoints);
        const xAxisLine = new THREE.Line(xAxisGeo, axisMaterial);
        scene.add(xAxisLine);

        // X轴刻度与分类标签
        chartData.forEach((item, i) => {
            const xPos = i * config.xStep + config.columnXOffset;
            
            // 刻度线(向下,长度0.6,清晰可见)
            const tickPoints = [
                new THREE.Vector3(xPos, 0, 0),
                new THREE.Vector3(xPos, -0.6, 0)
            ];
            const tickGeo = new THREE.BufferGeometry().setFromPoints(tickPoints);
            const tick = new THREE.Line(tickGeo, axisMaterial);
            scene.add(tick);

            // 分类标签 (CSS2D) —— ECharts风格:居中,字体适中
            const labelDiv = document.createElement('div');
            labelDiv.className = 'axis-tick-label';
            labelDiv.textContent = item.label;
            labelDiv.style.transform = 'translate(-50%, 0)';
            labelDiv.style.fontWeight = '500';
            labelDiv.style.color = '#2e4053';
            const labelObj = new CSS2DObject(labelDiv);
            labelObj.position.set(xPos, -1.1, 0);
            scene.add(labelObj);
        });

        // X轴名称 “月份” (ECharts标准:置于轴末端附近,加粗)
        const xNameDiv = document.createElement('div');
        xNameDiv.className = 'axis-name-label';
        xNameDiv.textContent = '月份';
        xNameDiv.style.transform = 'translate(-50%, 0)';
        const xNameLabel = new CSS2DObject(xNameDiv);
        xNameLabel.position.set(xAxisEnd - 0.3, -1.8, 0);
        scene.add(xNameLabel);

        // ===== Y轴 =====
        const yAxisStart = 0;
        const yAxisEnd = config.yMax;
        const yAxisPoints = [new THREE.Vector3(0, yAxisStart, 0), new THREE.Vector3(0, yAxisEnd, 0)];
        const yAxisGeo = new THREE.BufferGeometry().setFromPoints(yAxisPoints);
        const yAxisLine = new THREE.Line(yAxisGeo, axisMaterial);
        scene.add(yAxisLine);

        // Y轴刻度和数值标签(0到10,步长2,完全展示)
        for (let val = 0; val <= config.yMax; val += config.yStep) {
            // 刻度线(向左,长度0.6)
            const tickPoints = [
                new THREE.Vector3(0, val, 0),
                new THREE.Vector3(-0.6, val, 0)
            ];
            const tickGeo = new THREE.BufferGeometry().setFromPoints(tickPoints);
            const tick = new THREE.Line(tickGeo, axisMaterial);
            scene.add(tick);

            // 数值标签
            const labelDiv = document.createElement('div');
            labelDiv.className = 'axis-tick-label';
            labelDiv.textContent = val;
            labelDiv.style.transform = 'translate(0, -50%)';
            labelDiv.style.fontWeight = '500';
            const labelObj = new CSS2DObject(labelDiv);
            labelObj.position.set(-1.2, val, 0);
            scene.add(labelObj);
        }

        // Y轴名称 “数值” (ECharts风格:垂直居中,加粗)
        const yNameDiv = document.createElement('div');
        yNameDiv.className = 'axis-name-label';
        yNameDiv.textContent = '数值';
        yNameDiv.style.transform = 'translate(0, -50%)';
        const yNameLabel = new CSS2DObject(yNameDiv);
        yNameLabel.position.set(-2.4, config.yMax / 2, 0);
        scene.add(yNameLabel);

        // ===== 原点装饰(加强视觉)=====
        const originDotGeo = new THREE.SphereGeometry(0.08);
        const originDotMat = new THREE.MeshStandardMaterial({ color: 0x2c3e50, emissive: 0x1a2630, emissiveIntensity: 0.2 });
        const originDot = new THREE.Mesh(originDotGeo, originDotMat);
        originDot.position.set(0, 0, 0);
        scene.add(originDot);

        // ---------- 7. 柱状图主体(每个柱子带阴影、柱顶数值标签,严格按ECharts标准)----------
        chartData.forEach((item, i) => {
            const xPos = i * config.xStep + config.columnXOffset;
            const height = item.value;     // 实际数值
            const color = config.columnColors[i % config.columnColors.length];

            // 柱子材质:轻微光泽,略带透明感但清晰
            const columnMaterial = new THREE.MeshStandardMaterial({
                color: color,
                roughness: 0.45,
                metalness: 0.2,
                emissive: new THREE.Color(color).multiplyScalar(0.1),
                emissiveIntensity: 0.2,
                transparent: true,
                opacity: 0.95
            });

            const columnGeo = new THREE.BoxGeometry(
                config.columnWidth,
                height,
                config.columnDepth
            );
            const column = new THREE.Mesh(columnGeo, columnMaterial);
            column.castShadow = true;
            column.receiveShadow = true;
            column.position.set(xPos, height / 2, 0);
            scene.add(column);

            // ----- 柱顶数值标签(核心要求:每一个柱子顶部显示相应的值,ECharts 风格)-----
            const valueDiv = document.createElement('div');
            valueDiv.className = 'bar-value-label';
            valueDiv.textContent = item.value;   // 简洁显示数值
            // 仿ECharts: 白色底框+红褐色字,小圆角
            valueDiv.style.background = 'rgba(255, 255, 255, 0.8)';
            valueDiv.style.border = '1px solid ' + new THREE.Color(color).getStyle();
            valueDiv.style.color = new THREE.Color(color).multiplyScalar(0.6).getStyle();
            
            const valueLabel = new CSS2DObject(valueDiv);
            // 标签置于柱子顶部上方0.8处,醒目不重叠
            valueLabel.position.set(xPos, height + 0.8, 0);
            scene.add(valueLabel);

            // ----- 额外加一个微小的柱顶圆点,提升细节 (ECharts有时会有标记) -----
            const topDotGeo = new THREE.SphereGeometry(0.1);
            const topDotMat = new THREE.MeshStandardMaterial({ color: color, emissive: color, emissiveIntensity: 0.2 });
            const topDot = new THREE.Mesh(topDotGeo, topDotMat);
            topDot.position.set(xPos, height, 0);
            topDot.castShadow = false;
            scene.add(topDot);
        });

        // ---------- 8. 添加一个轻量的Z轴短线示意(虽然2D柱状图,但3D空间给出方位)----------
        const zAxisPoints = [new THREE.Vector3(0, 0, -2), new THREE.Vector3(0, 0, 2)];
        const zAxisGeo = new THREE.BufferGeometry().setFromPoints(zAxisPoints);
        const zAxisLine = new THREE.Line(zAxisGeo, new THREE.LineBasicMaterial({ color: 0xb0bec5, linewidth: 1 }));
        scene.add(zAxisLine);

        // 添加微弱的辅助环境线框,不干扰主视觉
        const helperExtent = 14;
        const axisHelperMaterial = new THREE.LineBasicMaterial({ color: 0xd0d8e0, linewidth: 1 });

        // 简单在背面加两根平行线,增加空间感(可选)
        const backLine1 = [new THREE.Vector3(-1, 0, -1.5), new THREE.Vector3(xAxisEnd, 0, -1.5)];
        const backLineGeo1 = new THREE.BufferGeometry().setFromPoints(backLine1);
        const backLineObj1 = new THREE.Line(backLineGeo1, axisHelperMaterial);
        scene.add(backLineObj1);

        // ---------- 9. 响应式窗口----------
        window.addEventListener('resize', onWindowResize, false);
        function onWindowResize() {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
            labelRenderer.setSize(window.innerWidth, window.innerHeight);
        }

        // ---------- 10. 动画循环----------
        function animate() {
            requestAnimationFrame(animate);
            controls.update(); // 启用阻尼后需要每帧更新
            renderer.render(scene, camera);
            labelRenderer.render(scene, camera);
        }
        animate();

        // 控制台提示
        console.log('ECharts 风格柱状图已加载,坐标轴标签、柱顶数值完整展示');
    </script>
</body>
</html>

总结与扩展建议

核心总结

  1. 视觉还原核心:通过CSS2D标签、ECharts经典配色、轴样式/布局优化,精准复刻ECharts柱状图的视觉风格,同时保留3D立体感;
  2. 光影构建核心:分层光照(环境光+主方向光+背光+点光源)+软阴影系统,打造真实的3D光影质感,避免场景平面化;
  3. 交互适配核心:轨道控制器阻尼优化+响应式窗口适配,保证3D交互的顺滑性与多设备的兼容性;
  4. 性能核心:通过合理的几何体/材质配置、阴影分辨率平衡,保证在普通设备上60帧流畅渲染。

扩展建议

  1. 动态数据更新
    • 新增updateData函数,修改chartData后重新构建柱子几何体,实现数据动态刷新(如实时监控数据);
    • 为柱子添加高度过渡动画,让数据更新时柱子平滑升降,提升视觉体验。
  2. 多系列柱状图
    • 在Z轴方向扩展,为每个分类添加多个柱子(如每月的“销量”“利润”),实现多系列对比;
    • 新增图例组件(CSS2D标签),标注不同系列的颜色与含义,贴合ECharts多系列图表风格。
  3. 交互增强
    • 添加tooltip交互:监听鼠标点击/悬浮事件,显示柱子的详细信息(如“一月:2(同比增长10%)”);
    • 柱子高亮效果:鼠标悬浮时修改柱子材质(如提高emissiveIntensity),增强交互反馈。
  4. 视觉主题切换
    • 新增主题配置(如浅色/深色主题),修改scene.backgroundaxisColorcolumnColors等参数,实现一键切换;
    • 适配ECharts的官方主题(如dark、macarons),提升视觉多样性。
  5. 性能优化
    • 使用InstancedMesh替代普通Mesh,批量渲染相同样式的柱子,减少DrawCall,支持更多分类数据;
    • 视锥体裁剪:剔除屏幕外的柱子/标签,减少渲染开销,适配大量数据场景。

《汽车行业价格行为合规指南》发布,规范虚假促销等突出问题

市场监管总局今天(12日)发布《汽车行业价格行为合规指南》。当前,汽车行业存在不按规定明码标价、价格欺诈等违法行为,损害消费者和经营者利益,破坏公平竞争市场秩序,不利于行业高质量发展。《指南》立足当前汽车行业发展实际,进一步明晰行为边界,统一监管规则,引导汽车生产和销售企业依法合规经营,推动形成优质优价、良性竞争的市场秩序。《指南》明确从整车到零部件生产、从定价策略到销售行为各环节的价格合规要求。实行全流程价格管理,强化公平定价约束,规范促销与定价行为,依法打击不正当价格行为。聚焦汽车新车销售环节,着力规范不按规定明码标价、虚假促销等突出问题。建立风险提示机制,鼓励平台对显著低价行为进行经营风险和消费风险双向提示。(央视新闻)

🚀 图片与点云数据缓存全攻略:从内存到文件系统的性能优化实践

摘要:在智驾、机器人标注工具等可视化场景中,图片和点云数据的缓存策略直接影响应用性能。本文深入剖析各种缓存模式的原理、性能差异,并提供针对 Canvas 2D 和 WebGL 的最佳实践方案。


📋 目录

  1. 引言:为什么缓存策略如此重要?
  2. 图片缓存的五种模式
  3. 存储层:内存、IndexedDB、FileSystem API
  4. Canvas 2D 中的性能对比
  5. WebGL 中的性能对比
  6. 点云数据的缓存策略
  7. 实战:混合缓存架构设计
  8. 性能测试数据
  9. 最佳实践总结

引言:为什么缓存策略如此重要?

在开发智驾标注工具、机器人可视化平台时,我们经常面临以下挑战:

  • 🖼️ 海量图片:单个项目可能包含数千张高清图像
  • 📦 点云数据:单帧点云可达数百万个点,体积庞大
  • 实时交互:标注、缩放、旋转等操作要求流畅响应
  • 💾 离线支持:野外作业场景需要离线缓存能力

错误的缓存策略会导致:

  • ❌ 首屏加载慢(3-5秒)
  • ❌ 内存溢出(OOM)
  • ❌ 标注卡顿(FPS < 30)
  • ❌ 存储空间浪费(体积膨胀 5-10 倍)

本文将带你深入理解各种缓存模式,找到最适合你场景的方案。


图片缓存的五种模式

1. HTMLImageElement(传统 DOM Image)

const img = new Image();
img.src = 'image.jpg';
img.onload = () => {
  // 使用 img...
};

特点:

  • ✅ 浏览器原生支持,使用简单
  • ✅ 内存占用较小(保留压缩格式)
  • ❌ 首次绘制时才解码,可能卡顿
  • ❌ 不支持 Worker 环境

适用场景: 静态展示、简单应用


2. ImageBitmap(现代高性能方案)

const response = await fetch('image.jpg');
const blob = await response.blob();
const bitmap = await createImageBitmap(blob, {
  resizeQuality: 'medium',
  colorSpaceConversion: 'none'
});

特点:

  • ✅ 创建时即解码,绘制零延迟
  • ✅ 性能比 DOM Image 快 15-30%
  • ✅ 支持 Worker 和 Transfer(零拷贝)
  • ✅ 支持裁剪、缩放等预处理

适用场景: 高性能渲染、Worker 处理、视频帧


3. ImageData(像素级操作)

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
const imageData = ctx.createImageData(width, height);

// 直接操作像素
const data = imageData.data;
data[0] = 255; // R
data[1] = 0;   // G
data[2] = 0;   // B
data[3] = 255; // A

特点:

  • ✅ 直接访问像素数据,修改极快
  • ✅ 适合频繁修改的场景
  • ❌ 内存占用大(未压缩)
  • ❌ 绘制性能较差(putImageData 慢)

适用场景: 标注框绘制、滤镜处理、像素级编辑


4. Blob(压缩格式存储)

const response = await fetch('image.jpg');
const blob = await response.blob();

// 存储到 IndexedDB 或 FileSystem
await cache.store(url, blob);

特点:

  • ✅ 体积小(保留压缩格式)
  • ✅ 适合长期缓存
  • ✅ 可直接用于 createImageBitmap
  • ❌ 读取时需要解码

适用场景: 离线缓存、网络资源缓存


5. ArrayBuffer / TypedArray(原始二进制)

const arrayBuffer = await blob.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);

// 直接操作二进制数据
uint8Array[0] = 0xFF;

特点:

  • ✅ 最底层的数据表示
  • ✅ 无额外封装开销
  • ✅ 适合自定义格式
  • ❌ 需要手动解析

适用场景: 自定义文件格式、点云数据、二进制协议


存储层:内存、IndexedDB、FileSystem API

1. 内存缓存(最快)

class MemoryCache {
  private cache = new Map<string, ImageBitmap>();
  private maxSize = 10; // 最多缓存 10 个

  set(key: string, value: ImageBitmap) {
    if (this.cache.size >= this.maxSize) {
      const oldestKey = this.cache.keys().next().value;
      this.cache.get(oldestKey)?.close();
      this.cache.delete(oldestKey);
    }
    this.cache.set(key, value);
  }

  get(key: string): ImageBitmap | undefined {
    return this.cache.get(key);
  }
}

性能: ~1ms 读取
限制: 刷新即丢失,内存有限
适用: 热点数据、频繁访问


2. IndexedDB(结构化存储)

class IDBCache {
  async store(key: string, blob: Blob) {
    const db = await this.openDB();
    const tx = db.transaction('images', 'readwrite');
    const store = tx.objectStore('images');
    
    await store.put({ id: key, blob, timestamp: Date.now() });
  }

  async get(key: string): Promise<ImageBitmap | null> {
    const db = await this.openDB();
    const tx = db.transaction('images', 'readonly');
    const store = tx.objectStore('images');
    
    const record = await new Promise(resolve => {
      const req = store.get(key);
      req.onsuccess = () => resolve(req.result);
    });

    if (!record) return null;
    return await createImageBitmap(record.blob);
  }
}

性能: ~50-100ms 读取(含反序列化)
限制: 有存储配额(通常 50-80% 磁盘空间)
特点: ✅ 有结构化克隆开销
适用: 中期缓存、结构化数据


3. File System Access API(文件系统)

class FileSystemCache {
  private dirHandle: FileSystemDirectoryHandle | null = null;

  async init() {
    this.dirHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
  }

  async store(filename: string, bitmap: ImageBitmap) {
    if (!this.dirHandle) throw new Error('Not initialized');

    // 转换为 Blob
    const offscreen = new OffscreenCanvas(bitmap.width, bitmap.height);
    const ctx = offscreen.getContext('2d')!;
    ctx.drawImage(bitmap, 0, 0);
    const blob = await offscreen.convertToBlob({ type: 'image/webp', quality: 0.9 });

    // 写入文件(无序列化开销)
    const fileHandle = await this.dirHandle.getFileHandle(filename, { create: true });
    const writable = await fileHandle.createWritable();
    await writable.write(blob); // ✅ 直接写入,无序列化
    await writable.close();
  }

  async get(filename: string): Promise<ImageBitmap | null> {
    if (!this.dirHandle) return null;

    try {
      const fileHandle = await this.dirHandle.getFileHandle(filename);
      const file = await fileHandle.getFile();
      return await createImageBitmap(file); // ✅ 直接读取,无反序列化
    } catch {
      return null;
    }
  }
}

性能: ~80-120ms 读取(纯 I/O,无序列化)
限制: 需要用户授权
特点: ❌ 无序列化/反序列化开销
适用: 大文件、离线项目、长期存储


Canvas 2D 中的性能对比

绘制性能测试

class Canvas2DPerformanceTest {
  async run() {
    const canvas = document.createElement('canvas');
    canvas.width = 1920;
    canvas.height = 1080;
    const ctx = canvas.getContext('2d')!;

    // 准备测试数据
    const img = new Image();
    img.src = 'test.jpg';
    await img.decode();

    const response = await fetch('test.jpg');
    const blob = await response.blob();
    const bitmap = await createImageBitmap(blob);

    const imageData = ctx.createImageData(img.width, img.height);

    console.log('=== Canvas 2D 绘制性能 (1000次) ===\n');

    // 测试1:drawImage (DOM Image)
    console.time('drawImage (DOM Image)');
    for (let i = 0; i < 1000; i++) {
      ctx.drawImage(img, 0, 0);
    }
    console.timeEnd('drawImage (DOM Image)'); // ~18ms ⭐

    // 测试2:drawImage (ImageBitmap)
    console.time('drawImage (ImageBitmap)');
    for (let i = 0; i < 1000; i++) {
      ctx.drawImage(bitmap, 0, 0);
    }
    console.timeEnd('drawImage (ImageBitmap)'); // ~12ms ⭐⭐

    // 测试3:putImageData
    console.time('putImageData');
    for (let i = 0; i < 1000; i++) {
      ctx.putImageData(imageData, 0, 0);
    }
    console.timeEnd('putImageData'); // ~55ms ❌

    bitmap.close();
  }
}

Canvas 2D 性能排名

方法 耗时 (1000次) 性能 适用场景
drawImage(ImageBitmap) ~12ms ⭐⭐⭐ 高性能渲染
drawImage(HTMLImageElement) ~18ms ⭐⭐ 普通渲染
drawImage(OffscreenCanvas) ~15ms ⭐⭐ Worker 渲染
putImageData(ImageData) ~55ms 像素级操作

结论:

  • ✅ 优先使用 ImageBitmap + drawImage
  • ✅ 避免频繁使用 putImageData
  • ✅ 使用离屏 Canvas 批量绘制

WebGL 中的性能对比

纹理上传性能

class WebGLPerformanceTest {
  async run() {
    const renderer = new THREE.WebGLRenderer();
    const gl = renderer.getContext();

    const img = new Image();
    img.src = 'test.jpg';
    await img.decode();

    const response = await fetch('test.jpg');
    const blob = await response.blob();
    const bitmap = await createImageBitmap(blob);

    const canvas = document.createElement('canvas');
    canvas.width = img.width;
    canvas.height = img.height;
    const ctx = canvas.getContext('2d')!;
    ctx.drawImage(img, 0, 0);
    const imageData = ctx.getImageData(0, 0, img.width, img.height);
    const data = new Uint8Array(imageData.data);

    console.log('=== WebGL 纹理上传性能 ===\n');

    // 测试1:HTMLImageElement
    console.time('WebGL - HTMLImageElement');
    const tex1 = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, tex1);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
    console.timeEnd('WebGL - HTMLImageElement'); // ~3-5ms

    // 测试2:ImageBitmap
    console.time('WebGL - ImageBitmap');
    const tex2 = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, tex2);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap);
    console.timeEnd('WebGL - ImageBitmap'); // ~2-3ms ⭐

    // 测试3:ImageData (首次)
    console.time('WebGL - ImageData (first)');
    const tex3 = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, tex3);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, img.width, img.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
    console.timeEnd('WebGL - ImageData (first)'); // ~2-3ms

    // 测试4:ImageData (更新)
    console.time('WebGL - ImageData (update)');
    gl.bindTexture(gl.TEXTURE_2D, tex3);
    gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, img.width, img.height, gl.RGBA, gl.UNSIGNED_BYTE, data);
    console.timeEnd('WebGL - ImageData (update)'); // ~0.5-1ms ⭐⭐

    bitmap.close();
  }
}

WebGL 纹理更新策略

// 频繁更新场景:使用 DataTexture + texSubImage2D
class DynamicTexture {
  private texture: THREE.DataTexture;
  private needsUpdate = false;

  constructor(width: number, height: number) {
    const data = new Uint8Array(width * height * 4);
    this.texture = new THREE.DataTexture(data, width, height);
  }

  // 局部更新(性能最优)
  updateRegion(x: number, y: number, width: number, height: number, newData: Uint8Array) {
    const gl = renderer.getContext();
    const texture = this.texture.__webglTexture;

    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texSubImage2D(
      gl.TEXTURE_2D,
      0,
      x, y,
      width, height,
      gl.RGBA,
      gl.UNSIGNED_BYTE,
      newData
    );

    this.needsUpdate = true;
  }

  // 标记更新
  markUpdate() {
    if (this.needsUpdate) {
      this.texture.needsUpdate = true;
      this.needsUpdate = false;
    }
  }
}

WebGL 性能排名

操作 耗时 性能 适用场景
texSubImage2D (局部更新) ~0.5-1ms ⭐⭐⭐ 频繁更新
texImage2D (ImageData) ~2-3ms ⭐⭐ 首次上传
texImage2D (ImageBitmap) ~2-3ms ⭐⭐ 首次上传
texImage2D (HTMLImageElement) ~3-5ms 普通上传

结论:

  • ✅ 首次上传:ImageBitmapImageData 均可
  • ✅ 频繁更新:ImageData + texSubImage2D
  • ✅ 避免重复上传整个纹理

点云数据的缓存策略

点云数据与图片不同,具有以下特点:

  • 📊 数据量大:单帧可达 10-100 万点
  • 🎯 结构化:每个点包含位置、颜色、法向量等
  • 🔧 需要处理:滤波、分割、配准等

1. 点云数据结构

interface PointCloudData {
  positions: Float32Array;  // [x, y, z, x, y, z, ...]
  colors?: Uint8Array;       // [r, g, b, a, r, g, b, a, ...]
  normals?: Float32Array;    // [nx, ny, nz, nx, ny, nz, ...]
  intensities?: Float32Array;
  count: number;             // 点数量
}

// 内存占用估算
// positions: count * 3 * 4 bytes
// colors: count * 4 * 1 bytes
// 100万点 ≈ 16MB

2. 点云缓存模式

方案1:ArrayBuffer(推荐)

class PointCloudCache {
  // 存储为 ArrayBuffer(无额外开销)
  async store(key: string, pointCloud: PointCloudData) {
    // 序列化为 ArrayBuffer
    const metadata = new Uint32Array([pointCloud.count]);
    const positions = new Float32Array(pointCloud.positions);
    const colors = pointCloud.colors ? new Uint8Array(pointCloud.colors) : null;

    // 合并为单个 ArrayBuffer
    const totalSize = 4 + positions.byteLength + (colors?.byteLength || 0);
    const buffer = new ArrayBuffer(totalSize);
    const view = new DataView(buffer);

    // 写入元数据
    view.setUint32(0, pointCloud.count, true);

    // 写入位置
    new Float32Array(buffer, 4).set(positions);

    // 写入颜色(如果有)
    if (colors) {
      new Uint8Array(buffer, 4 + positions.byteLength).set(colors);
    }

    // 存储到 IndexedDB 或 FileSystem
    await this.storage.set(key, buffer);
  }

  // 读取(快速反序列化)
  async get(key: string): Promise<PointCloudData | null> {
    const buffer = await this.storage.get(key);
    if (!buffer) return null;

    const view = new DataView(buffer);
    const count = view.getUint32(0, true);

    const positions = new Float32Array(buffer, 4, count * 3);
    const colors = new Uint8Array(buffer, 4 + count * 3 * 4, count * 4);

    return {
      positions,
      colors,
      count
    };
  }
}

优势:

  • ✅ 无额外序列化开销
  • ✅ 内存布局紧凑
  • ✅ 直接用于 WebGL(BufferAttribute)

方案2:压缩存储(节省空间)

class CompressedPointCloudCache {
  // 使用 Draco 压缩
  async storeCompressed(key: string, pointCloud: PointCloudData) {
    // 引入 Draco 编码器
    const DracoEncoder = (await import('draco3dgltf')).DracoEncoder;
    const encoder = new DracoEncoder();

    // 配置压缩参数
    encoder.SetSpeed(5); // 0-10, 越高速度越快,压缩率越低
    encoder.SetAttributeQuantization(
      DracoEncoder.POSITION,
      14 // 位置精度
    );

    // 编码
    const encodedBuffer = encoder.Encode(pointCloud.positions, pointCloud.colors);

    // 存储压缩后的数据
    await this.storage.set(key, encodedBuffer);
  }

  // 读取并解压
  async getCompressed(key: string): Promise<PointCloudData | null> {
    const compressedBuffer = await this.storage.get(key);
    if (!compressedBuffer) return null;

    const DracoDecoder = (await import('draco3dgltf')).DracoDecoder;
    const decoder = new DracoDecoder();

    // 解码
    const pointCloud = decoder.Decode(compressedBuffer);

    return pointCloud;
  }
}

压缩效果:

  • 原始:100万点 ≈ 16MB
  • Draco 压缩:100万点 ≈ 2-4MB(压缩比 4-8x)
  • 解压时间:~50-100ms

方案3:分块加载(流式渲染)

class ChunkedPointCloudLoader {
  private chunkSize = 100000; // 每块 10 万点

  // 分块存储
  async store(key: string, pointCloud: PointCloudData) {
    const chunks = Math.ceil(pointCloud.count / this.chunkSize);

    for (let i = 0; i < chunks; i++) {
      const start = i * this.chunkSize;
      const end = Math.min(start + this.chunkSize, pointCloud.count);

      const chunkPositions = pointCloud.positions.slice(start * 3, end * 3);
      const chunkColors = pointCloud.colors?.slice(start * 4, end * 4);

      const chunkBuffer = this.serializeChunk(chunkPositions, chunkColors);
      await this.storage.set(`${key}_chunk_${i}`, chunkBuffer);
    }

    // 存储元数据
    await this.storage.set(`${key}_metadata`, {
      count: pointCloud.count,
      chunks,
      chunkSize: this.chunkSize
    });
  }

  // 按需加载(视锥体裁剪)
  async loadInView(camera: THREE.Camera, frustum: THREE.Frustum) {
    const metadata = await this.storage.get(`${key}_metadata`);
    const chunksToLoad: number[] = [];

    // 视锥体裁剪,确定需要加载的块
    for (let i = 0; i < metadata.chunks; i++) {
      const chunkBounds = await this.getChunkBounds(`${key}_chunk_${i}`);
      if (frustum.intersectsBox(chunkBounds)) {
        chunksToLoad.push(i);
      }
    }

    // 并发加载可见块
    const chunks = await Promise.all(
      chunksToLoad.map(i => this.loadChunk(`${key}_chunk_${i}`))
    );

    return this.mergeChunks(chunks);
  }
}

优势:

  • ✅ 减少初始加载时间
  • ✅ 支持超大点云(千万级)
  • ✅ 节省内存

3. 点云在 WebGL 中的优化

class OptimizedPointCloudRenderer {
  private geometry: THREE.BufferGeometry;
  private material: THREE.PointsMaterial;
  private points: THREE.Points;

  constructor() {
    this.geometry = new THREE.BufferGeometry();
    this.material = new THREE.PointsMaterial({
      size: 0.01,
      vertexColors: true,
      sizeAttenuation: true
    });
    this.points = new THREE.Points(this.geometry, this.material);
  }

  // 使用 BufferAttribute(避免重复创建)
  updatePointCloud(pointCloud: PointCloudData) {
    // 位置属性
    let positionAttribute = this.geometry.getAttribute('position') as THREE.BufferAttribute;
    if (!positionAttribute) {
      positionAttribute = new THREE.BufferAttribute(pointCloud.positions, 3);
      this.geometry.setAttribute('position', positionAttribute);
    } else {
      // 局部更新(性能最优)
      positionAttribute.copyArray(pointCloud.positions);
      positionAttribute.needsUpdate = true;
    }

    // 颜色属性
    if (pointCloud.colors) {
      let colorAttribute = this.geometry.getAttribute('color') as THREE.BufferAttribute;
      if (!colorAttribute) {
        colorAttribute = new THREE.BufferAttribute(pointCloud.colors, 4);
        this.geometry.setAttribute('color', colorAttribute);
      } else {
        colorAttribute.copyArray(pointCloud.colors);
        colorAttribute.needsUpdate = true;
      }
    }

    // 更新包围盒
    this.geometry.computeBoundingBox();
    this.geometry.computeBoundingSphere();
  }

  // 渐进式加载
  async progressiveLoad(pointCloudCache: PointCloudCache, key: string) {
    const metadata = await pointCloudCache.getMetadata(key);
    const totalChunks = metadata.chunks;

    for (let i = 0; i < totalChunks; i++) {
      const chunk = await pointCloudCache.loadChunk(key, i);
      this.mergeChunk(chunk);

      // 每加载一块,渲染一帧
      renderer.render(scene, camera);

      // 让出主线程
      await new Promise(resolve => setTimeout(resolve, 0));
    }
  }
}

实战:混合缓存架构设计

针对标注工具的完整缓存架构:

class HybridCacheSystem {
  // 三层缓存
  private memoryCache = new MemoryCache<ImageBitmap>();      // L1: 内存
  private idbCache = new IDBCache();                         // L2: IndexedDB
  private fsCache = new FileSystemCache();                   // L3: FileSystem

  private pointCloudCache = new PointCloudCache();           // 点云专用

  // 智能获取图片
  async getImage(url: string): Promise<ImageBitmap> {
    const cacheKey = this.generateKey(url);

    // L1: 内存缓存(最快)
    const memoryHit = this.memoryCache.get(cacheKey);
    if (memoryHit) {
      console.log('L1 Cache Hit (Memory)');
      return memoryHit;
    }

    // L2: IndexedDB(中速)
    const idbBitmap = await this.idbCache.get(url);
    if (idbBitmap) {
      console.log('L2 Cache Hit (IndexedDB)');
      this.memoryCache.set(cacheKey, idbBitmap);
      return idbBitmap;
    }

    // L3: FileSystem(慢速但容量大)
    if (this.fsCache.initialized) {
      const fsBitmap = await this.fsCache.get(`${cacheKey}.webp`);
      if (fsBitmap) {
        console.log('L3 Cache Hit (FileSystem)');
        this.memoryCache.set(cacheKey, fsBitmap);
        await this.idbCache.store(url, await this.bitmapToBlob(fsBitmap));
        return fsBitmap;
      }
    }

    // 网络加载
    console.log('Cache Miss, Loading from Network');
    return await this.loadFromNetwork(url);
  }

  // 预加载策略
  async preload(urls: string[], priority: 'high' | 'low' = 'low') {
    const batchSize = priority === 'high' ? 10 : 5;

    for (let i = 0; i < urls.length; i += batchSize) {
      const batch = urls.slice(i, i + batchSize);

      // 并发加载
      await Promise.all(batch.map(async (url) => {
        const bitmap = await this.getImage(url);
        
        // 高优先级:预热到内存
        if (priority === 'high') {
          this.memoryCache.set(this.generateKey(url), bitmap);
        }
      }));

      // 让出主线程
      await new Promise(resolve => setTimeout(resolve, 50));
    }
  }

  // 点云加载
  async getPointCloud(key: string): Promise<PointCloudData> {
    // 优先从内存加载
    const memoryPCD = this.memoryCache.get(`pcd_${key}`);
    if (memoryPCD) return memoryPCD;

    // 从专用缓存加载
    const pointCloud = await this.pointCloudCache.get(key);
    if (pointCloud) {
      this.memoryCache.set(`pcd_${key}`, pointCloud);
      return pointCloud;
    }

    throw new Error(`PointCloud not found: ${key}`);
  }

  private async loadFromNetwork(url: string): Promise<ImageBitmap> {
    const response = await fetch(url);
    const blob = await response.blob();
    const bitmap = await createImageBitmap(blob);

    const cacheKey = this.generateKey(url);

    // 并行写入多层缓存
    await Promise.all([
      this.idbCache.store(url, blob),
      this.fsCache.initialized 
        ? this.fsCache.store(`${cacheKey}.webp`, bitmap) 
        : Promise.resolve(),
      Promise.resolve().then(() => {
        this.memoryCache.set(cacheKey, bitmap);
      })
    ]);

    return bitmap;
  }

  private generateKey(url: string): string {
    return url.replace(/[^a-zA-Z0-9]/g, '_');
  }

  private async bitmapToBlob(bitmap: ImageBitmap): Promise<Blob> {
    const offscreen = new OffscreenCanvas(bitmap.width, bitmap.height);
    const ctx = offscreen.getContext('2d')!;
    ctx.drawImage(bitmap, 0, 0);
    return await offscreen.convertToBlob({ type: 'image/webp', quality: 0.9 });
  }
}

性能测试数据

图片缓存性能对比(1920x1080,100张)

操作 内存缓存 IndexedDB FileSystem API
写入 100 张 ~10ms ~800ms ~600ms
读取 100 张 ~20ms ~500ms ~400ms
序列化开销 有(~30%)
存储体积 ~800MB ~50MB ~50MB
持久化

Canvas 2D 绘制性能(1000次)

方法 耗时 相对性能
drawImage(ImageBitmap) 12ms 100% ⭐
drawImage(HTMLImageElement) 18ms 67%
drawImage(OffscreenCanvas) 15ms 80%
putImageData(ImageData) 55ms 22%

WebGL 纹理上传性能

操作 耗时 适用场景
texSubImage2D (局部更新) 0.5-1ms 频繁更新 ⭐
texImage2D (ImageData) 2-3ms 首次上传
texImage2D (ImageBitmap) 2-3ms 首次上传 ⭐
texImage2D (HTMLImageElement) 3-5ms 普通上传

点云数据性能(100万点)

操作 原始格式 Draco 压缩
存储体积 16MB 2-4MB (4-8x)
加载时间 ~50ms ~100ms
解压时间 ~50ms
内存占用 16MB 16MB

最佳实践总结

📌 图片缓存策略

场景 推荐方案 理由
静态展示 ImageBitmap + 内存缓存 零延迟绘制
频繁更新 ImageData + texSubImage2D 局部更新最快
离线缓存 FileSystem API + Blob 无序列化开销
网络资源 IndexedDB + Blob 结构化查询
Worker 处理 ImageBitmap + Transfer 零拷贝

📌 点云缓存策略

场景 推荐方案 理由
小规模点云 (< 100万点) ArrayBuffer + 内存 快速访问
大规模点云 (> 100万点) Draco 压缩 + 分块加载 节省空间
实时更新 BufferAttribute 局部更新 避免重建
离线项目 FileSystem API 大容量存储

📌 通用优化技巧

  1. 使用 ImageBitmap 替代 HTMLImageElement

    // ❌ 不推荐
    const img = new Image();
    img.src = url;
    
    // ✅ 推荐
    const bitmap = await createImageBitmap(await fetch(url).then(r => r.blob()));
    
  2. 避免频繁的 putImageData

    // ❌ 不推荐
    for (let i = 0; i < 100; i++) {
      ctx.putImageData(imageData, 0, 0);
    }
    
    // ✅ 推荐
    const offscreen = new OffscreenCanvas(width, height);
    const offCtx = offscreen.getContext('2d')!;
    offCtx.putImageData(imageData, 0, 0);
    const bitmap = offscreen.transferToImageBitmap();
    
    for (let i = 0; i < 100; i++) {
      ctx.drawImage(bitmap, 0, 0);
    }
    
  3. WebGL 纹理局部更新

    // ❌ 不推荐
    texture.needsUpdate = true; // 重新上传整个纹理
    
    // ✅ 推荐
    gl.texSubImage2D(...); // 只更新变化的部分
    
  4. 分层缓存架构

    L1: Memory Cache (10 items)       - 1ms
    L2: IndexedDB Cache (100 items)   - 50ms
    L3: FileSystem Cache (unlimited)  - 100ms
    Network Fallback                  - 500ms+
    
  5. 预加载策略

    // 用户空闲时预加载
    if ('requestIdleCallback' in window) {
      requestIdleCallback(() => {
        cache.preload(nextBatchUrls);
      }, { timeout: 2000 });
    }
    

🎯 总结

在智驾、机器人标注工具等可视化场景中,选择正确的缓存策略至关重要:

核心要点

  1. 图片缓存:

    • ✅ 优先使用 ImageBitmap(性能最优)
    • ✅ 长期存储用 FileSystem API(无序列化开销)
    • ✅ 频繁更新用 ImageData + texSubImage2D
  2. 点云缓存:

    • ✅ 小规模:ArrayBuffer + 内存
    • ✅ 大规模:Draco 压缩 + 分块加载
    • ✅ 实时更新:BufferAttribute 局部更新
  3. 存储层选择:

    • 内存缓存:热点数据(最快,易失)
    • IndexedDB:中期缓存(有查询能力)
    • FileSystem API:长期存储(无序列化开销)
  4. 性能优化:

    • 避免重复解码
    • 使用局部更新
    • 分块加载大文件
    • 预加载策略

最终建议

对于标注工具这类应用,推荐采用混合缓存架构

// 三层缓存 + 智能预加载
const cache = new HybridCacheSystem();

// 图片:ImageBitmap + FileSystem API
const bitmap = await cache.getImage('image.jpg');

// 点云:Draco 压缩 + 分块加载
const pointCloud = await cache.getPointCloud('scan_001');

通过合理的缓存策略,可以将首屏加载时间从 3-5 秒优化到 0.5-1 秒,标注操作的帧率从 20-30 FPS 提升到 60 FPS,大幅提升用户体验!


📚 参考资料:

💬 互动: 你在项目中遇到过哪些缓存相关的性能问题?欢迎在评论区分享你的经验和解决方案!


本文首发于掘金,转载请注明出处。关注我,获取更多前端可视化、WebGL、性能优化干货! 🚀

快手智能云科技公司注册资本增至19.5亿元

36氪获悉,爱企查App显示,近日,快手智能云(乌兰察布)科技有限公司发生工商变更,注册资本由2.5亿元人民币增至19.5亿元人民币,增幅680%。该公司成立于2020年5月,法定代表人为杨远熙,经营范围包括从事电信业务经营、互联网文化活动经营、互联网平台、互联网安全服务、互联网数据服务、软件和信息技术服务、智能化设计咨询及改造、企业管理咨询等。股东信息显示,该公司由北京快手科技有限公司全资持股。

Vue3文本差异对比器实现方案

Vue3文本差异对比器实现方案

本文将介绍本项目中 文本差异对比器 (Text Diff Checker) 工具的技术实现细节。该工具基于 Vue 3 框架开发,核心对比逻辑采用原生的 JavaScript 实现,通过动态加载的方式与 Vue 组件进行交互。

在线工具网址:see-tool.com/diff-checke…
工具截图:
在这里插入图片描述

1. 架构设计

为了保证核心算法的独立性和复用性,我们将 Diff 算法逻辑封装在 public/js/diff-checker.js 中,而 Vue 组件 pages/diff-checker.vue 仅负责 UI 交互和数据展示。

  • 数据层 (Core JS): 负责文本的预处理、Diff 算法计算、HTML 渲染字符串生成以及统计信息计算。
  • 视图层 (Vue): 负责用户输入、选项配置、调用核心方法并展示结果。

2. 核心算法实现 (diff-checker.js)

核心逻辑是一个基于 最长公共子序列 (LCS, Longest Common Subsequence) 的 Diff 算法。

2.1 文本预处理与并在

根据用户选择的“对比模式”,我们将输入文本分割成不同的单元:

  • 行模式 (Line): 使用 split('\n') 按换行符分割。
  • 词模式 (Word): 使用 split(/\s+/) 按空白字符分割。
  • 字符模式 (Char): 使用 split('') 逐字符分割。

同时,根据配置选项处理“忽略空格”和“忽略大小写”:

if (ignoreWhitespace) {
    processedText1 = processedText1.replace(/\s+/g, ' ').trim();
    processedText2 = processedText2.replace(/\s+/g, ' ').trim();
}
// 忽略大小写则统一转为小写

2.2 LCS 算法与回溯

使用动态规划构建 DP 表,计算最长公共子序列的长度:

// DP 表构建
for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
        if (arr1[i - 1] === arr2[j - 1]) {
            dp[i][j] = dp[i - 1][j - 1] + 1;
        } else {
            dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
        }
    }
}

构建完成后,通过回溯 (Backtrack) 找出具体的 LCS 路径。

2.3 构建 Diff 结果

根据 LCS 路径,遍历原始序列,确定哪些部分是“新增 (added)”、“删除 (removed)”或“未变 (unchanged)”。

  • 如果当前元素在 LCS 中,标记为 unchanged
  • 如果原序列中有但 LCS 中没有,标记为 removed
  • 如果新序列中有但 LCS 中没有,标记为 added

2.4 结果渲染

为了提高性能,Diff 的结果直接由 JS 生成 HTML 字符串,而不是在 Vue 中使用 v-for 渲染成千上万个 DOM 节点。生成的 HTML 包含了行号、差异标识(+/-)以及高亮样式类。

/* 生成的 HTML 结构示例 */
<div class="diff-line diff-line-removed">
  <span class="diff-line-number">1</span>
  <span class="diff-line-number"></span>
  <span class="mr-2">-</span>
  Content
</div>

3. Vue 组件实现 (diff-checker.vue)

3.1 动态加载脚本

Vue 组件在挂载或需要使用时,通过创建 <script> 标签动态加载核心 JS 文件。为了防止重复加载,我们通过检查 window.DiffChecker 是否存在来判断。

const loadDiffCheckerScript = () => {
  if (window.DiffChecker) return Promise.resolve();
  // 创建 script 标签加载 /js/diff-checker.js
  // 监听 onload 和 onerror 事件
}

3.2 调用对比

当用户点击“开始对比”时,组件收集 leftTextrightText 以及 compareModeignoreWhitespace 等选项,调用核心对象的 compare 方法:

const result = window.DiffChecker.compare(leftText.value, rightText.value, compareMode.value, {
  ignoreWhitespace: ignoreWhitespace.value,
  ignoreCase: ignoreCase.value,
  showLineNumbers: showLineNumbers.value
})

3.3 结果展示

核心方法返回的 result 对象中包含了 diffHtml(差异内容的 HTML)和 statisticsHtml(统计信息的 HTML)。Vue 组件直接使用 v-html 指令将其渲染到页面上:

<div v-if="statisticsHtml" v-html="statisticsHtml"></div>
<div ref="diffOutput" v-html="diffOutputHtml"></div>

通过这种 Vue 处理交互 + 原生 JS 处理计算密集任务的分离模式,我们既保持了前端框架的开发效率,又保证了对比功能的性能与灵活性。

iframe → wujie 迁移收益分析与子应用集成方案

一、背景

当前 CMCLink 平台存在两种微前端集成方案并行:

  • 旧方案:原生 iframe 嵌入 + postMessage 通信(mkt、doc、ibs-manage 等子应用在用)
  • 新方案:wujie iframe 沙箱 + bus 通信(template 子应用模板已验证,ibs-manage 已完成迁移试点)

本文档从决策层面工程层面两个维度分析:

  1. 为什么要从 iframe 迁移到 wujie?投入产出比如何?
  2. 如何将子应用集成复杂度降到最低,让不同部门愿意迁移?

二、旧 iframe 方案的真实痛点

以下痛点均来自 ibs-manage 子应用迁移前的实际代码,非理论推演。

2.1 通信机制:postMessage 的脆弱性

旧方案代码(子应用 App.vue):

// 子应用监听主应用消息
window.addEventListener('message', (event: MessageEvent) => {
  if (typeof event.data?.type === 'string') {
    if (event.data.type === 'router-change') {
      router.push({ path: event.data.payload.route })
    } else if (event.data.type === 'close-all-tab') {
      tagsViewStore.delAllViews()
    }
  }
})

// 主应用通知子应用
window.parent.postMessage({ type: 'router-change', payload: { route } }, origin)

问题

问题 影响
消息类型是字符串魔法值,无 TypeScript 类型约束 拼写错误不会编译报错,只能运行时排查
数据需要序列化(不支持函数、循环引用) 复杂数据传递受限
跨域时 origin 校验容易出错 安全隐患或消息丢失
每个子应用独立实现消息协议 协议不统一,新增事件需要双端同步修改
无法追踪消息链路 调试困难,console.log 满天飞

wujie 方案

// 统一的 EventEmitter 模式,有类型约束
bus.$emit('CHILD_ROUTE_CHANGE', { appName, path, name, query })
bus.$on('ROUTE_CHANGE_TO_CHILD', (data) => { router.push(data.path) })

2.2 路由同步:hack 堆叠

旧方案代码(子应用 App.vue):

// 路由恢复:3 层 fallback
const restoreRoute = () => {
  let targetPath = ''
  // 1. 尝试从父页面 URL 参数获取
  try {
    if (window.parent !== window && window.parent.location.hostname === window.location.hostname) {
      targetPath = new URLSearchParams(window.parent.location.search).get('childPath') || ''
    }
  } catch { /* 跨域失败 */ }
  // 2. 回退到 localStorage
  if (!targetPath) {
    targetPath = localStorage.getItem('ibs-manage-latest-path') || ''
  }
  // 3. 兜底空路径
  router.replace(targetPath || '')
}

问题

问题 影响
跨域部署时 parent.location 不可访问 路由恢复完全失效
localStorage 在多 Tab 场景下互相覆盖 Tab A 刷新可能恢复到 Tab B 的路由
主应用 URL 不反映子应用当前路由 无法通过 URL 分享/收藏具体页面
每个子应用独立实现恢复逻辑 代码重复,bug 各异

wujie 方案

# 主应用 URL 自动同步子应用路由(sync 模式)
http://localhost:3000/ibs-manage/operation/xxx?ibs-manage={~}/operation/xxx

# F5 刷新时 wujie 自动从 URL query 恢复子应用路由,零代码

2.3 状态共享:Token 传递的安全隐患

旧方案

  • Token 通过 URL 参数传递给 iframe → 明文暴露在浏览器历史记录和服务器日志中
  • 或依赖同域 Cookie → 跨域部署时失效
  • 或通过 postMessage 传递 → 需要手动管理刷新/过期同步

wujie 方案

// 主应用通过 props 注入,子应用通过 __WUJIE.props 读取
// 内存传递,不经过 URL/Cookie,不序列化
const sharedAuth = (window as any).__WUJIE?.props?.$shared?.auth
// token、userInfo、permissions、menus 一次性获取

2.4 性能:首屏加载体验

指标 iframe 方案 wujie 方案
首次打开子应用 2-5 秒白屏(iframe 从零加载 HTML + JS + CSS) <500ms(preloadApp 预加载 + alive 保活)
切换已访问子应用 1-3 秒(iframe 重新加载或从 bfcache 恢复) 瞬切(alive 模式保持 Vue 实例不销毁)
子应用内部路由切换 正常 正常
keep-alive 页面缓存 ❌ 不支持(iframe 销毁即丢失) ✅ 支持(alive 模式 + 自动缓存同步)

2.5 开发体验

维度 iframe 方案 wujie 方案
DevTools 调试 需切换 iframe context 同一 DevTools 窗口
HMR 热更新 正常 正常
独立运行 ✅ 支持 ✅ 支持
联调成本 高(需同时启动主应用 + 子应用,调试 postMessage 链路) 低(bus 事件可在 DevTools 中直接观察)

三、迁移投入成本

3.1 一次性投入(已完成)

项目 工时 状态
@cmclink/micro-bootstrap 子应用启动器 2 人天 ✅ 已完成
@cmclink/micro-bridge 通信桥接 + 注册表 2 人天 ✅ 已完成
@cmclink/vite-config 统一构建配置 1 人天 ✅ 已完成
主应用 AuthenticatedLayout WujieVue 容器 1 人天 ✅ 已完成
主应用 shared-provider 状态广播 1 人天 ✅ 已完成
template 子应用模板验证 0.5 人天 ✅ 已完成
ibs-manage 迁移试点 1 人天 ✅ 已完成
合计 ~8.5 人天 已完成

3.2 单个子应用迁移成本

基于 ibs-manage 实际迁移数据:

步骤 工时 说明
改 vite.config.ts 15 分钟 替换为 createChildAppConfig
改 src/main.ts 30 分钟 替换为 bootstrapMicroApp
改 src/App.vue 2-4 小时 wujie 适配(共享数据注入 + 路由恢复)
改 src/router/index.ts 15 分钟 删除旧通知逻辑
改 env + package.json 15 分钟 端口 + 依赖
主应用配置 15 分钟 env + 路由 + 注册表
联调验证 2-4 小时 路由 + 状态 + Tab + 刷新
合计 0.5-1.5 人天 视子应用复杂度而定

3.3 长期维护成本对比

场景 iframe 方案 wujie 方案
新增通信事件 双端各加 postMessage 处理(~30 行/事件) 注册表加类型 + bus.$on(~5 行/事件)
新增子应用 复制粘贴 ~150 行通信代码 + 调试适配 bootstrapMicroApp 一行启动
修复路由同步 bug 每个子应用独立排查 统一在 micro-bridge 修复,所有子应用受益
升级 Vue/Router 版本 每个子应用独立处理 micro-bootstrap 统一兼容

四、风险评估

4.1 迁移风险

风险 概率 影响 缓解措施
wujie 框架停止维护 低(GitHub 活跃,腾讯开源) wujie 核心代码量小(~3000 行),可 fork 自维护
子应用版本不兼容 micro-bootstrap 已放宽类型约束,支持 vue@3.4/3.5 共存
样式隔离不完美 WebComponent shadowDOM 隔离 + teleported=false 弹窗隔离
迁移期间两套方案并存 确定 App.vue 中 isWujie / isInIframe 分支兼容,可渐进迁移

4.2 不迁移的风险

风险 概率 影响
postMessage 协议碎片化加剧 高 — 新子应用接入成本持续增加
路由同步 bug 反复出现 中 — 用户体验差,开发疲于修补
无法实现 keep-alive 缓存 确定 中 — 表单填写中途切换 Tab 数据丢失
首屏性能无法优化 确定 中 — 每次切换子应用白屏 2-5 秒

五、子应用集成简化方案(v2 提案)

5.1 问题:当前集成仍然太重

ibs-manage 迁移后,App.vue 中仍有 ~80 行 wujie 适配代码

injectSharedDataFromWujie()     — 25 行(从 props.$shared 注入 token/user/menus)
wujie 路由恢复                   — 15 行(await generateRoutes + router.replace)
isWujie 环境检测                 — 3document.title / localStorage 分支 — 10 行
旧 iframe 兼容逻辑               — 30

这些代码对每个子应用都是几乎相同的模板代码。如果其他部门(mkt、doc、finance 等)迁移时都要手动写这些,集成意愿度会很低。

5.2 目标:子应用 App.vue 零 wujie 感知

理想状态:子应用开发者完全不需要知道 wujie 的存在。App.vue 只写业务逻辑,所有微前端适配在 bootstrapMicroApp 中自动完成。

5.3 方案:micro-bootstrap 新增 sharedData 配置

BootstrapOptions 中新增一个配置项,让 micro-bootstrap 自动完成共享数据注入:

// ===== 子应用 main.ts(简化后)=====
bootstrapMicroApp({
  app: App,
  router,
  pinia: store,
  appId: '#ibs-manage',
  appName: 'ibs-manage',
  tagsViewStore: () => useTagsViewStore(store),
  plugins: [setupI18n, setupElementPlus, setupGlobCom],

  // 🆕 共享数据注入配置(micro-bootstrap 自动处理 wujie props → 本地缓存)
  sharedData: {
    // 缓存适配器:告诉 bootstrap 如何读写子应用的本地缓存
    cache: {
      get: (key: string) => wsCache.get(key),
      set: (key: string, value: any) => wsCache.set(key, value),
    },
    // 缓存 key 映射
    keys: {
      accessToken: 'ACCESS_TOKEN',
      refreshToken: 'REFRESH_TOKEN',
      user: 'USER',  // 对应 CACHE_KEY.USER
    },
  },

  // 🆕 动态路由注册回调(可选,有动态路由的子应用才需要)
  onBeforeMount: async ({ router, cache }) => {
    const userInfo = cache.get('USER')
    if (userInfo?.menus) {
      userStore.menus = userInfo.menus
      await generateRoutes()
    }
  },
})

子应用 App.vue 变化

  // ========== 应用初始化 ==========
  const init = async () => {
-   // wujie 环境:从主应用共享数据注入 token 和用户信息
-   if (isWujie) {
-     injectSharedDataFromWujie()
-   }
-
-   // 从缓存加载用户信息并生成动态路由
-   const userInfo = wsCache.get(CACHE_KEY.USER)
-   if (userInfo) {
-     if (userInfo.menus) {
-       userStore.menus = userInfo.menus
-       await generateRoutes()
-     }
-     if (userInfo.user) {
-       userInfoRef.value = userInfo.user
-     }
-   }
+   // 共享数据注入 + 动态路由注册已由 micro-bootstrap 自动处理
+   // 此处只需读取缓存中的用户信息用于 UI 显示
+   const userInfo = wsCache.get(CACHE_KEY.USER)
+   if (userInfo?.user) {
+     userInfoRef.value = userInfo.user
+   }

    if (getAccessToken()) {
      userStore.setUserInfoAction()
    }

-   // wujie 环境:动态路由注册后重新匹配当前路径
-   if (isWujie) {
-     const currentPath = router.currentRoute.value.fullPath
-     if (currentPath && currentPath !== '/') {
-       await router.replace(currentPath)
-     }
-   }
-
-   // 非 wujie 环境:从 localStorage / 父页面恢复路由
-   restoreRouteForLegacyMode()
+   // 路由恢复已由 micro-bootstrap 自动处理(wujie sync / localStorage fallback)
  }

减少 ~50 行 wujie 适配代码,App.vue 只剩纯业务逻辑。

5.4 micro-bootstrap 内部实现要点

// micro-bootstrap 内部(伪代码)
async function bootstrapMicroApp(options: BootstrapOptions) {
  const isWujie = !!(window as any).__WUJIE

  // ... 创建 app、安装插件 ...

  // 🆕 自动注入共享数据(mount 之前)
  if (isWujie && options.sharedData) {
    injectSharedData(options.sharedData)
  }

  // 🆕 执行用户自定义的 mount 前回调(动态路由注册等)
  if (options.onBeforeMount) {
    await options.onBeforeMount({
      router,
      cache: options.sharedData?.cache,
    })
  }

  // 挂载应用
  mount()

  // 🆕 wujie 环境:动态路由注册后自动恢复路由
  if (isWujie) {
    const currentPath = router.currentRoute.value.fullPath
    if (currentPath && currentPath !== '/') {
      await router.replace(currentPath)
    }
  }
}

function injectSharedData(config: SharedDataConfig) {
  const sharedAuth = (window as any).__WUJIE?.props?.$shared?.auth
  if (!sharedAuth) return

  const { cache, keys } = config
  if (sharedAuth.token) cache.set(keys.accessToken, sharedAuth.token)
  if (sharedAuth.refreshToken) cache.set(keys.refreshToken, sharedAuth.refreshToken)
  if (sharedAuth.userInfo) {
    const cachedUser = cache.get(keys.user) || {}
    cachedUser.user = sharedAuth.userInfo
    cachedUser.permissions = sharedAuth.permissions || []
    cachedUser.roles = sharedAuth.roles || []
    if (sharedAuth.menus) cachedUser.menus = sharedAuth.menus
    cache.set(keys.user, cachedUser)
  }
}

5.5 集成复杂度对比

维度 旧 iframe 方案 wujie 当前(v1) wujie 简化后(v2 提案)
main.ts ~185 行(手动生命周期) ~53 行(bootstrapMicroApp) ~53 行(不变)
App.vue wujie 代码 0(但有 ~150 行 postMessage 代码) ~80 行 ~10 行(仅读缓存用于 UI)
router/index.ts ~71 行(含旧通知) ~52 行 ~52 行(不变)
子应用需要理解的概念 postMessage 协议、origin 校验、序列化 wujie props、bus、sync、prefix 只需知道 bootstrapMicroApp 的配置项
新增子应用工时 1-2 人天 0.5-1.5 人天 2-4 小时

5.6 对不同子应用类型的适配

子应用类型 sharedData onBeforeMount 说明
新子应用(无动态路由) ✅ 配置 不需要 最简单,2 小时搞定
存量子应用(有动态路由) ✅ 配置 ✅ 提供回调 需要在回调中注册动态路由
存量子应用(有旧 iframe 兼容) ✅ 配置 ✅ 提供回调 App.vue 保留 isInIframe 分支,渐进清理

六、推荐迁移策略

6.1 渐进式迁移路线

阶段一(已完成):基础设施 + ibs-manage 试点
    ↓
阶段二(当前):实现 v2 简化方案 + 迁移 doc 子应用验证
    ↓
阶段三:推广到 mkt、finance 等子应用(各部门自行迁移,提供文档 + 模板)
    ↓
阶段四:清理旧 iframe 兼容代码 + 统一 vue/vue-router 版本

6.2 并行兼容期

迁移期间,子应用 App.vue 通过 isWujie / isInIframe 分支同时支持两种模式:

const isWujie = !!(window as any).__WUJIE
const isInIframe = !isWujie && window.parent !== window

// wujie 环境:由 micro-bootstrap 自动处理
// 旧 iframe 环境:保留原有 postMessage 逻辑
// 独立运行:正常启动

旧 iframe 方案不需要立即下线,可以在所有子应用迁移完成后统一清理。

6.3 各部门迁移支持

支持项 内容
迁移文档 docs/migration/ibs-manage-wujie-集成迁移指南.md(已产出)
子应用模板 apps/template/(可直接 copy 作为新子应用骨架)
排错指南 迁移文档第 7 章(8 个常见问题 + 排查步骤)
培训文档 docs/training/(5 章,覆盖 L1-L3 三个梯队)
Code Review 首个迁移子应用由架构组 review,后续自行迁移

七、决策建议

迁移的核心论点

基础设施投入(8.5 人天)已完成且沉没。单个子应用迁移成本仅 0.5-1.5 人天(v2 简化后降至 2-4 小时),但能获得:

  1. 首屏性能提升 5-10 倍(预加载 + alive 保活)
  2. 消除 ~150 行/子应用的重复通信代码
  3. F5 刷新可靠恢复(wujie sync 机制,零代码)
  4. keep-alive 页面缓存(iframe 方案无法实现)
  5. 统一的通信协议(有类型约束,bug 减少)

不迁移的隐性成本(每次新功能都要在 postMessage 协议上打补丁、路由同步 bug 反复出现、无法实现缓存)远大于一次性迁移成本。

建议行动项

  1. 评审本方案,确认 v2 简化方案的 API 设计
  2. 实现 v2 简化,在 micro-bootstrap 中落地 sharedData + onBeforeMount
  3. 用 doc 子应用验证 v2 简化方案的实际效果
  4. 发布迁移通知,各部门按优先级排期迁移
  5. 设定清理时间线,在所有子应用迁移完成后统一清理旧 iframe 代码

附录:术语表

术语 说明
wujie 腾讯开源的微前端框架,基于 iframe 沙箱 + WebComponent 容器
alive 模式 wujie 保活模式,切换子应用时不销毁 Vue 实例
sync 模式 wujie 路由同步模式,子应用路由写入主应用 URL query
prefix wujie sync 短路径映射,压缩 URL query 长度
micro-bootstrap CMCLink 子应用统一启动器,封装 wujie 生命周期
micro-bridge CMCLink 通信桥接层,封装 wujie bus + 子应用注册表
shared-provider 主应用状态广播器,将 Pinia store 数据广播给子应用
❌