普通视图

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

AI 纪元 3 年,2025 论前端程序员自救

作者 德莱厄斯
2025年12月31日 13:58

前言

2023 年是公认的 AI 元年,2022年底OpenAI发布ChatGPT(基于GPT-3.5),2023年初迅速爆火,上线仅2个月用户突破1亿,成为历史上增长最快的消费级应用。短短三年内,AI 已经从遥不可及,进化到走进千家万户了,从只能聊天的纯语言大模型(LLMs),到今天能解决实际问题的多模态大模型(MLLMs/LMMs)、智能体(AI Agent),我们的生活正发生着不易察觉但又天翻地覆的变化。

这可能是你 2025 年看到的最真实,最中肯,也最能让你安心的文章,先说结论:程序员岗位都不会死,且未来必须具备的能力大部分人都有。

怎么看 AI 对程序员岗位的冲击

AI 的井喷式爆发,对程序员的冲击确实很大。程序员中,前端程序员最甚,在网络上,它三天一小死,五天一大死,但是结果我们也看到了,前端并没有死,甚至活得比以前更繁荣了,很多其他岗位程序员也试着用 Gemini 3 Pro 搭建了属于自己的 three.js 应用。

可能大家跟我的感觉一样,对网上前端已死的焦虑卖家并不感冒,因为在工作中,AI 给我们的感觉更像是一个老司机坐在副驾驶,我们遇到不懂的直接问它,而它也会把毕生所学毫无保留的交代出来,甚至在 VSCode Copilot 或者 Cursor 、Trae 中,我们根本不需要自己动手写代码了,80% 的时间都在 vibe coding,我们没有因为 AI 丢掉工作,反而 AI 让我们觉得:哇,太爽了,工作效率翻了三倍!

如果你有这种感觉,那么恭喜你,你是最适合在未来工作的前端开发者

目前 AI 的局限性表现的很明显,AI 生成的代码往往 平均化、缺乏深度优化或者有隐蔽的 bug。还经常改到我们不希望变动的部分,这里祭出这张火爆全网的梗图

image.png

当然这些和 AI 还依旧不够成熟有一定关系,但是还有一个最根本的原因使得 AI 无法取代人类,它注定只能作为人类的工具,但是这个原因很哲学,请看下文。

不用怕,欲望是创造的原初动力

人类创造的起点往往是 “想做”、“喜欢”、“不爽”、“好奇”、“想证明自己” 这些内在冲动。比如 一个程序员半夜写出一个新框架,是因为“觉得现有工具太烂了,我就是要搞一个更好玩的”,而 AI 没有这些。它只有“根据训练数据预测下一个词”或者“最大化奖励函数”。我们给它一个目标,它就全力优化,但它自己永远不会“突然想”去做一件没人要求的事。

所以,AI 的“创造”其实是重组+优化过去的知识,而不是从零生出全新的渴望。

AI 很擅长在已知框架里做到极致(比如写出完美的前端组件、优化算法到最快),但人类擅长打破框架、发明新框架。这种突破往往来自 “无用” 的欲望

  • 想玩 → 发明游戏
  • 想偷懒 → 发明自动化工具(发明了 AI 😂)
  • 想被认可 → 开源一个项目

历史上所有重大创新(互联网、智能手机、开源运动)都源于这种自发欲望。

总的来说,AI 只是现有知识最好的运用者,它无法自发的想去创造新事物(如果有的话也太可怕了,这 TM 直接天网)

要让AI拥有“真正自发的欲望”,需要解决哲学级难题:意识(consciousness) 和 主观体验(qualia)。目前科学界连“意识是什么”都没搞清楚,更别说在硅基系统里实现它了。

所以这是 AI 为什么无法代替人类的原因。

但是,从直觉上来看,AI 似乎可以替代一部分基础工作,而且现在正在发生,有些公司正在招聘高级开发者来替代多个初中级岗位...

危机真正的来源

初中级岗位确实正在慢慢消失,但这并不意味着我们在岗程序员一定会掉队,相反,我们在行业内属于 “老人”,也是最先吃到 AI 这块蛋糕的人。既然总要有人做事,所谓近水楼台先得月,最先有机会转换到 融合职责岗位 的,也会是我们这一批人,而不是其他人。

在我们这个行业,各个岗位正在发生融合,产品经理、UI、前后端的界限越来越模糊,最近还出现了所谓的 “一人公司”、“超级个体”。

初中级岗位的慢慢消失和岗位职责的融合,是 AI 时代,对普通程序员最大的挑战,我们应该顺应这一潮流,转变以往的思维,勇敢的加入到这场洪流中。

那么,该怎么做?

有解,且比以前更简单!

众所周知,T-shaped 技能模型推荐我们 先建立一个领域的深度(垂直杠),再扩展广度(水平杠),形成T形。我们很多人也是这么干的,前端同学在深刻学习 JS 语言基础、vue、react 源码,传统面试中,这些知识也是重点考察内容。

但是 AI 时代,这些技能变得非常廉价,AI已经把“深度纯技术钻研”的门槛大幅降低:它能快速生成代码、优化实现,甚至帮你读源码、总结关键。

你可以完全没读过 vue 源码,而仅用一句:“帮我实现一个 vue 的响应式系统” 实现这个功能。

T 的竖线表示领域深耕,既然深耕到一半,发现不太有竞争力了,怎么办?很简单,发展横轴,横轴代表了我们的广度(全栈狂喜),但这里说的不是纯技术广度,而是多方面多系统的融会贯通。

不过好在这些技能是我们聪明的程序员天生就具备的能力。

未来优秀程序员不再是“写代码最快的人”,而是系统思考者 + 需求翻译者 + AI指挥官 + 复杂问题解决者 + 高效沟通者。

  1. System Thinking 系统思维 解释:能够从整体视角设计复杂、可扩展、可维护的系统,权衡性能、成本、安全、演化等多个维度。AI擅长局部,人类必须负责全局架构。
  2. Requirement Mastery 需求洞察 解释:精准理解模糊、矛盾的业务需求,把商业目标转化为可验证的技术方案。未来程序员更像“业务翻译者”,这是AI最弱的一环。
  3. Integration Ability 融合贯通 解释:快速整合不同技术栈、AI工具、第三方服务,从0到1构建完整产品。广度+快速学习能力,让你能驾驭AI实现跨领域创新。
  4. Debugging & Reasoning 复杂调试与推理 解释:快速定位根因,需要强大的因果推理、假设检验能力。AI会减少简单bug,但复杂问题会更多、更隐蔽。
  5. Communication & Expression 语言组织能力 沟通与表达 解释:用清晰、结构化的语言(书面最重要)把复杂技术想法表达出来,包括写 AI 提示词 (目前,将来可能弱化)、文档、技术方案、跨团队沟通等。脑子里再懂,不说出来就等于没用——这会直接影响你的影响力、晋升和协作效率。

看起来需要具备这么多能力,但是仔细想想,其实每个人都已经基本具备。每个人只需在自己的薄弱领域稍加练习即可,这比啃框架源码可简单多了,至少都是可以 “勤能补拙” 的技能。

结语

AI 不是洪水猛兽,它是我们最可靠的副驾驶,我们应该勇敢投入潮流,转变自身态度,提升视野,才能在未来的 AI 大融合时代占住自己的一席之地。

还有,2026,别感冒!

番外

LogicFlow 插件魔改实录:手把手教你重写动态分组(DynamicGroup)🛠️

作者 橙某人
2025年12月31日 13:40

写在开头

嘿嘿,大家好!👋

今是2025年12月31日上午 8 时 40 分,也是2025年最后一天了,小编做到今天,今年就不做了,感觉这行还是不适应,自己每天不知道在做些什么,天天都忙忙忙,很累很崩溃,钱也没赚到,一点热情也没了。😋

最近,在项目中重度使用了 LogicFlow,不得不说,它的扩展性是真的强。💪💪💪

但是(注意,我要说但是了😄),在使用官方提供的 动态分组DynamicGroup)插件时,由于小编的业务场景比较特殊,遇到了一些让人头秃的问题。

本着 "开源不满足就魔改" 的极客精神,小编决定对 DynamicGroup 插件进行一次深度改造。

所以呢,今天要分享的是 DynamicGroup 插件的改造的完整过程、源码分析以及避坑指南,请诸君按需食用哈。

需求背景与痛点

最近在项目中,小编需要实现一个 "打组与拆组" 功能,不仅要能把部分节点框在一起组成一个整体分组,也需要能随时进行拆组,把子节点放出来。

基于这个需求背景,小编在 LogicFlow 官方文档上找到了两个插件:框选(SelectionSelect)与 动态分组 (DynamicGroup)。

  • SelectionSelect:负责画框框选节点。
  • DynamicGroup:负责把选中的节点 "打包" 成一个组。

这两个插件组合使用理论上能满足小编的业务需求,天作之合属于!🤔

不过,在实际集成过程中,小编发现它们虽然功能强大,但细节上还是有点 "水土不服",无法完全满足细腻的业务需求。于是,小编决定把这两个插件都重写了!

其中,框选插件的改动比较简单,主要是调整了一下样式和微小的交互逻辑,就不在今天展开细说了。我们重点要聊的,动态分组插件的改动过程。

下面,简述小编遇到的几个主要问题:

  • "连坐"机制:官方插件默认的逻辑是,删除分组时,会强制删除分组内的所有子节点。但在小编的"拆组"业务里,用户只是想解散分组,保留里面的子节点。
  • "越狱"现象:虽然插件提供了 isRestrict: true 来限制节点拖出分组,但在快速拖拽时,节点经常能"穿墙"而出,这个问题其实是和下面的问题应该是同个问题。
  • "误触"跳出:如果分组内的子节点包含输入框,点击输入框聚焦时,插件偶尔会误判为"拖拽结束(node:drop)",导致子节点莫名其妙地被移出分组。

为了解决这些症状,小编决定给 DynamicGroup 插件开点药食食。😁

DynamicGroup插件源码分析

在动手之前,咱们先来看看 DynamicGroup 插件的构造,这个插件由以下几个核心文件组成,咱们这次改造也沿用了这个结构:

  • index.js (核心逻辑):插件的入口,负责注册插件和监听全局事件(如 node:add, node:drop)。它维护了一个映射表,决定了节点什么时候进分组,什么时候出分组。

🔉插播一下,插件源码维护得挺好,有非常清晰的代码注释,再结合AI的协助,让人非常容易理解。👏

  • model.js (数据模型):定义分组的数据属性。比如 isRestrict(是否限制拖拽)、isCollapsed(是否折叠)等状态都是在这里管理的。
  • node.js (视图渲染):负责画出分组的样子(SVG)。这里也是离用户最近的地方,处理具体的鼠标交互事件(如 mousemove)。
  • utils.js (工具函数):提供一些计算几何关系的辅助方法,比如判断"这个点是不是在那个框里"。

搞清楚了这些,咱们就可以对症下药了!

改造一:解决"连坐"机制

痛点描述

官方默认的逻辑是:分组被删除 = 分组内的所有子节点一起被删除

如下:

123102.gif

但在很多业务场景下,用户点击删除分组,可能只是想"解散"这个组,而不是把里面的业务子节点也删了。

源码定位

打开 index.js,找到 removeNodeFromGroup 方法:

// 官方源码逻辑
removeNodeFromGroup = ({ data: node, model }) => {
  if (model.isGroup && node.children) {
    node.children.forEach((childId) => {
      this.nodeGroupMap.delete(childId);
      this.lf.deleteNode(childId); // 👈 在这里!直接把子节点干掉了
    });
  }
  // ...
};

改造方案

咱们需要引入一个配置项 retainChildren(保留子节点)。在删除前,检查一下这个属性,如果为 true,就只解除关系,不删节点。

为什么要如此做❓

因为小编还有另外的业务需求是右键删除节点功能,这个操作就需要把子节点也一起删除了,所以增加配置控制的形式更加灵活一些。

修改 index.js 文件:

// 改造后代码逻辑
removeNodeFromGroup = ({ data: node, model }) => {
  // 获取配置属性
  const retainChildren = model.properties && model.properties.retainChildren;
  if (model.isGroup && node.children) {
    node.children.forEach((childId) => {
      this.nodeGroupMap.delete(childId); // 解除映射关系
      // 关键判断:只有不保留时,才删除子节点
      if (!retainChildren) {
        this.lf.deleteNode(childId);
      }
    });
  }
  // ... 后续逻辑不变
};

这样,我们在创建分组时,只要加上 properties: { retainChildren: true },就能实现"拆组"效果了。

123101.gif

改造二:拒绝"误触"跳出

痛点描述

如果你的节点里包含输入框(HTML 节点),当你点击输入框聚焦时,LogicFlow 可能会触发 node:drop 事件(因为它认为你完成了一次交互)。

官方插件在监听 node:drop 时,会重新计算节点应该属于哪个分组。结果就是:点了一下输入框,插件误判你把节点移走了,直接把它踢出了分组。

123103.gif

源码定位

打开 index.js 文件,找到 addNodeToGroup 方法:

// 官方源码逻辑
addNodeToGroup = (node) => {
  // ... 计算节点当前的位置 bounds
  
  const preGroupId = this.nodeGroupMap.get(node.id); // 原来的组
  const targetGroup = this.getGroupByBounds(bounds, node); // 现在位置对应的组
  
  if (preGroupId) {
    // 👈 问题在这里:只要有原分组,它就默认先移除,再看要不要加入新组
    // 如果计算有些许误差,或者逻辑不够严谨,节点就“丢”了
    const group = this.lf.getNodeModelById(preGroupId);
    group.removeChild(node.id); 
    this.nodeGroupMap.delete(node.id);
  }
  
  // ...
};

这个方法会在 node:drop 事件被触发时被调用。

改造方案

逻辑很简单:如果节点原来的组和现在的组是同一个,那就啥也别动! 稳住别浪!🌊

修改 index.js 文件:

// 改造后代码逻辑
addNodeToGroup = (node) => {
  // ... 前面获取 bounds 逻辑不变

  const preGroupId = this.nodeGroupMap.get(node.id);
  const targetGroup = this.getGroupByBounds(bounds, node);
  const targetGroupId = targetGroup ? targetGroup.id : undefined;

  // 新增核心判断,直接 return 掉!
  if (preGroupId === targetGroupId) {
    return;
  }

  // ... 下面才是真正的移动逻辑
  if (preGroupId) {
    // ... 移除旧组逻辑
  }
  if (targetGroup) {
    // ... 加入新组逻辑
  }
};

这样子就搞定了:

123104.gif

改造三:"越狱"现象之绝对防御

痛点描述

DynamicGroup 插件支持设置 isRestrict: true 来限制子节点不能拖出分组。

image.png

这个问题挺奇怪的,小编在群里反馈给过官方维护人员,但是他们好像没有定位到这个问题,在小编本地也确实比较难复现,只会偶尔出现。但是呢,小编的测试同学却能一次一次的复现给小编看,这种问题最难受了。😭

面对铁证,小编也很无奈,只能请AI来协助了,我让AI大致分析了整个源码情况。

限制子节点的拖动范围原理大概是在 graphModel.addNodeMoveRules 里加规则。

但是!这个规则校验是基于"下一次位置是否合法"来拦截的。如果你鼠标甩得特别快(比如高 DPI 鼠标),deltaX/deltaY 很大,直接跳过了边界检测,节点就会"穿墙"而出。

源码定位

这次咱们要去视图层 node.js 文件。因为规则拦截(Model 层)已经防不住了,我们必须在渲染层(View 层)做最后的兜底。

我们需要监听 node:mousemove 事件,这是节点移动最频繁触发的地方。

改造方案

node.js 文件中,我们重写 onNodeMouseMove 方法,并在组件挂载时(componentDidMount)监听它。

逻辑核心

  1. 实时计算节点的新位置。
  2. 判断是否超出了分组的边界。
  3. 如果超出,强制将坐标修正回边界内(暴力修正)。
  4. 同步更新连线(这一点很容易漏,如果不更连线,节点回去了,线还在外面)。

修改 node.js 文件:

// 改造后代码逻辑

// 1. 在 component 绑定事件
componentDidMount() {
  super.componentDidMount();
  // 监听更底层的 mousemove
  this.props.graphModel.eventCenter.on("node:mousemove", this.onNodeMouseMove);
}

// 2. 核心处理逻辑
onNodeMouseMove({ data }) {
  const { model: curGroup, graphModel } = this.props;
  const model = graphModel.getNodeModelById(data.id); // 当前拖动的节点

  // 只有开启了限制,且是自家孩子,才管
  if (curGroup.children.has(model.id) && curGroup.isRestrict) {
    const groupBounds = curGroup.getBounds();
    const nodeBounds = model.getBounds();
    const padding = 10; // 内边距,别贴得太死

    let newX = model.x;
    let newY = model.y;

    // X轴 暴力修正
    if (nodeBounds.minX < groupBounds.minX + padding) {
      newX = groupBounds.minX + padding + model.width / 2;
    } else if (nodeBounds.maxX > groupBounds.maxX - padding) {
      newX = groupBounds.maxX - padding - model.width / 2;
    }

    // Y轴 暴力修正
    if (nodeBounds.minY < groupBounds.minY + padding) {
      newY = groupBounds.minY + padding + model.height / 2;
    } else if (nodeBounds.maxY > groupBounds.maxY - padding) {
      newY = groupBounds.maxY - padding - model.height / 2;
    }

    // 如果位置被我们强行修正了
    if (newX !== model.x || newY !== model.y) {
      // 移动节点
      model.moveTo(newX, newY);
      
      // 关键:手动更新连线!
      // 否则会出现节点被墙挡住了,但连线跟着鼠标飞出去了的诡异画面,非常神奇💥
      this.updateRelatedEdges(model, graphModel);
    }
  }
}

// 辅助方法:更新连线
updateRelatedEdges(model, graphModel) {
  const edges = graphModel.getNodeEdges(model.id);
  edges.forEach((edge) => {
    // 重新计算并设置连线的起点/终点
    if (edge.sourceNodeId === model.id) {
       // ... updateStartPoint 逻辑
    }
    if (edge.targetNodeId === model.id) {
       // ... updateEndPoint 逻辑
    }
  });
}

这里的改造大部分是AI在帮我写的,我只是最终确定一下代码逻辑的合理性,与没有太离谱和边界把控,还需要在页面进行测试验证,最终,确定基本没有什么问题,才交由小编的测试同学去验证,最终也顺利通过验证。😀

这里还是得表扬AI一番啊,这个问题前后大概花了十几二十分钟就搞定了,要是没有AI,靠人工来解决这个问题,时间上应该不敢想象吧。🤔

总结

通过对 DynamicGroup 插件的这番改造,咱们不仅解决了一系列交互 Bug,更重要的是深入理解了 LogicFlow 插件的运行机制。

  • Model 层 负责数据准确性(如删除逻辑)。
  • Logic 层(index.js)负责业务流转(如分组进出判断)。
  • View 层 负责极致的交互体验(如拖拽边界修正)。

希望这篇踩坑实录能给正在使用 LogicFlow 的小伙伴们一些灵感,嘿嘿。😉





至此,本篇文章就写完啦,撒花撒花。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。

React hooks 之 一篇文章掌握 useState 和 useEffect 的核心机制

2025年12月31日 13:19

引言:React Hooks 的核心价值

在 React 16.8 版本引入 Hooks 之前,React 函数组件是 “无状态” 的,开发者无法直接管理内部状态或执行副作用操作。如果需要使用这些能力,就必须使用类组件。然而,类组件语法冗长、逻辑复用困难、生命周期方法割裂业务逻辑,导致代码难以维护。

React Hooks 的出现彻底改变了这一局面。它允许开发者在不编写类的前提下,使用状态(state)和副作用(effects)等原本仅限于类组件的能力。

在众多内置的 Hooks 中,useStateuseEffect 是最基础、最常用的两个,堪称函数式 React 开发的两大“基石”:

  • useState 赋予函数组件管理本地状态的能力,是构建交互式 UI 的起点;
  • useEffect 统一处理各种副作用逻辑,实现了声明式副作用管理。

因此,深入理解 useStateuseEffect,不仅是掌握 React 函数组件开发的关键,更是走向现代 React 工程开发的第一步。无论是构建简单计数器,还是复杂的数据驱动应用,这两个 Hooks 都扮演着不可或缺的角色。

第一部分:探索 useState

什么是 useState

useState 是 React 提供的一个 Hook(钩子) ,它让函数组件拥有了“记忆”能力

函数内的普通变量在函数执行后就会被销毁,如果我们下次再执行这个函数,中间的过程就需要重新执行一遍。

而利用 useState 机制"钩住"数据,此时数据就变成状态(state)了,即使重新运行函数,数据也状态也不会重置,除非卸载组件。

例如:

import { useState } from 'react';

export default function CounterDemo() {
  // 普通变量:每次函数执行都会重置
  let normalCount = 0;

  // 状态变量:被 React "钩住",不会重置
  const [hookCount, setHookCount] = useState(0);

  // 每次点击,两个计数都尝试 +1
  const handleClick = () => {
    normalCount = normalCount + 1;        // 修改普通变量
    setHookCount(hookCount + 1);          // 更新状态
    console.log('普通变量:', normalCount);
    console.log('状态变量:', hookCount + 1); // 注意:这里还没重新渲染
  };

  return (
    <div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
      <h2>普通变量 vs useState 状态</h2>
      <p>普通变量(每次渲染重置为 0): {normalCount}</p>
      <p>状态变量(持久保存): {hookCount}</p>
      <button onClick={handleClick}>点击 +1</button>
    </div>
  );
}

效果如下:

动画.gif

每次执行都会重新初始化所有变量,无法保留用户交互或数据变化的结果。但是通过 useState,我们可以:

  • 声明一个持久化的状态变量
  • 获取一个专门用于更新该状态的函数
  • 实现响应式 UI:当状态改变时,React 自动重新渲染组件,使视图与数据保持同步

核心价值useState 将函数组件从“一次性快照”转变为能随用户交互动态变化的活组件,状态(state)是变化的数据,也是组件的核心。


2. 基本使用方法

useState 的语法: const [num, setNum] = useState(初始值)

  • 状态变量 num:当前状态值

  • 更新变量的函数 setNum:更新状态的函数

import { useState } from 'react';

function MyComponent() {
  // 解构赋值:[当前状态, 更新函数] = useState(初始值)
  const [num, setNum] = useState(1);
  
  return (
    <div onClick={() => setNum(num + 1)}>
      当前值: {num}
    </div>
  );
}

useState 支持两种初始化方式:

方式一:直接传入初始值 适用于简单、静态的初始值

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

方式二:传入初始化函数 当状态初始值需要经过复杂计算,就可以配置函数来计算

const [num, setNum] = useState(() => {
  const num1 = 1 + 2;
  const num2 = 2 + 3; 
  return num1 + num2; // 返回 6
});

关键要求

  1. 函数必须为同步函数,异步的函数结果不确定,而状态一定要是确定的。
  2. 函数必须是纯函数:指每次传入相同的输入始终返回相同的输出,且无副作用的函数(不修改外部状态,不依赖外部状态,不改变传入的状态)

3. 状态更新机制

利用setState()函数进行状态更新不单单是“修改某个变量”,并且触发了 React 的响应式更新循环

方式 代码示例 适用场景
直接传值 setNum(num + 1) 简单更新,不依赖旧状态
函数式更新 setNum(prev => prev + 1) 确保基于最新状态更新,避免闭包陷阱

React 的核心思想:数据驱动视图

React 的设计哲学可以用一句话精准概括:“视图是状态的映射,交互是改变状态的手段” —— View = f(State)

而这就意味着:UI 界面完全由当前应用的状态(State)决定。只要状态相同,渲染出的界面就一定相同。这种“数据驱动视图”的模式,构成了 React 响应式更新机制的基础。


并且在 React 应用中,一切交互与渲染都围绕以下三个基本要素展开:

State(状态/数据)

  • 是应用的“心脏”,存储着当前的数据(例如 num = 1)。
  • 在函数组件中,通常通过 useStateuseReducer 或状态管理库来管理。
  • State 必须被视为不可变 ——不能直接修改,只能通过 React 提供的 set 函数请求更新。

View(视图/UI)

  • 用户看到的界面,由 JSX 描述并最终转化为 DOM。
  • 组件本质上是一个函数:输入是 props 和 state,输出是 UI
  • 每次 state 改变,React 会自动重新执行组件函数,生成新的 View。

Event(事件/交互)

  • 用户对界面的操作,如点击按钮(onClick)、输入文本(onChange)等。
  • 事件处理函数是连接用户行为与状态更新的桥梁。

而基于这三个基本要素,形成了数据驱动的闭环流程

State → View:数据驱动显示

  • 含义:状态决定界面长什么样。
  • 机制:React 根据当前的 State 自动计算并渲染出对应的 View。

这是“声明式编程”的体现——你只需描述“UI 应该是什么”,而不是“如何一步步操作 DOM”。

View → Event:视图产生交互

  • 含义:用户在界面上进行操作。
  • 机制:用户在 View 上交互(比如按钮)触发了 Event。

Event → State:事件改变数据

  • 含义:交互导致状态更新。
  • 机制:事件处理函数调用 setState,向 React 提出状态变更请求。

⚠️ 注意:setState 是异步的,不会立即改变当前作用域中的 state 值,而是安排下一次重新渲染时使用新值。

最后闭环形成:自动重渲染(Re-render) :用户交互 → 触发事件 → 更新状态 → 驱动视图刷新

而这个闭环让我们无需手动操作 DOM,只需关注“状态如何变化”,UI 便会自动同步。


4. 实践案例分析:点击计数器

export default function App() {
  const [num, setNum] = useState(() => {
    const num1 = 1 + 2; 
    return num1; // 初始值 = 3
  });

  return (
    <div onClick={() => setNum((prevNum) => {
      console.log(prevNum); // 打印当前状态
      return prevNum + 1;   // 返回新状态
    })}>
      {num}
    </div>
  );
}

关键设计亮点

  • 函数式更新setNum(prev => ...) 确保即使多次快速点击,也能基于最新状态计算。
  • 纯函数初始化:初始值通过纯函数计算,符合 React 状态确定性原则。
  • 响应式核心:完美体现 View = f(State) —— 视图是状态的纯函数映射。

第二部分:解析useEffect

useEffect的作用

在React中,useEffect钩子用于处理副作用。而副作用指的是那些 影响外部世界或依赖于外部世界的操作,例如数据获取、订阅或者手动修改DOM等。

对于组件而言,理想情况下,输入参数应直接决定输出的JSX结构,而副作用则通过useEffect来处理(纯函数 <--对立--> 副作用)

基本使用方法

useEffect 的语法:useEffect(() => { return }, [])

useEffect的第一个参数是一个普通函数,通常使用简洁的箭头函数。

  • 包含需要执行的副作用逻辑(监听事件、启动定时器等),并且这个函数会在组件渲染到屏幕之后异步执行(不会阻塞浏览器绘制)
  • return函数:清理函数,常用于清理上一次副作用(类似于Vue中的onUnmounted生命周期钩子)
useEffect(() => {
  console.log('组件已渲染');
  const timer = setInterval(() => console.log('tick'), 1000);
  
  // 返回清理函数
  return () => {
    clearInterval(timer);
  };
});

useEffect的第二个参数是一个数组,称为依赖数组。决定了useEffect何时执行:

  • 空数组 [] :当没有提供依赖项时,useEffect仅在组件首次渲染后运行一次,并且不会随着后续的更新而重新触发(类似于Vue中的onMounted生命周期钩子)
  • 包含特定变量的数组[var] 或 [var1, var2...] :每当数组中的任何一个变量值改变时,都会触发useEffect的执行
  • 无数组(省略第二个参数) :如果省略了依赖数组,那么useEffect将在每次渲染之后都运行,包括初次挂载以及任何后续更新(类似于Vue中的onUpdated钩子)。

副作用清理

清理副作用是useEffect的一个十分重要的机制,如果操作不当很容易造成内存泄漏!!!

例如一个经典的定时器泄漏:

import { useEffect, useState } from "react";

export default function App() {
  const [num, setNum] = useState(0);

  useEffect(() => {
    // 定时器副作用
    // 每次执行useEffect都在新建定时器
    setInterval(() => {
      console.log(num);
    }, 1000);
  }, [num]);
  return (
    <>
      <div onClick={() => setNum((prevNum) => prevNum + 1)}>
        {num}
      </div>
    </>
  );
}

结果如图(我稍微更改了一下样式,看的更清楚)

动画1.gif

在这里,每次num发生变化时,都会创建一个新的定时器,并且在下次num变化前没有及时清除旧的定时器,导致了内存泄漏。

useEffect内的函数进行修改,增加清理函数,在组件卸载或下一次执行useEffect前就会对副作用进行清理

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(num);
    }, 1000);
    
    return () => {
      clearInterval(timer);// 清理资源
    }
  }, [num]);

只要你在 useEffect 中创建了“外部资源”或“长期运行的任务”,就必须提供清理函数

结合实战:

在 React 中使用 useEffect + useState 实现异步数据获取并更新状态的标准模式

核心目的:让 useState 能响应异步数据

根据上文我们知道,useState 本身是同步的,不能直接“等待”异步操作。而 useEffect 允许我们在组件挂载后(或依赖变化时)执行副作用,包括调用异步函数

通过在 useEffect 中:

  1. 调用异步函数
  2. 在其 .then() 回调中调用 setNum(data)
  3. React 会自动触发重新渲染,使 UI 显示最新数据

这就实现了: “异步获取数据 → 更新状态 → 刷新视图” 的完整流程。


标准开发流程:异步数据获取 + 状态更新

1、使用 useState定义状态

用于存储异步获取的数据,以及可选的加载/错误状态

const [num, setNum] = useState(0);          // 存储数据

2、封装异步数据获取函数

将数据请求逻辑抽离为独立函数

async function queryData() {
  // 模拟网络请求
  const data = await new Promise(resolve => {
    // 异步执行后调用 resolve(666)
    setTimeout(() => resolve(666), 2000);
  });
  // 提取 Promise 的内部值 666 后,执行return返回数据
  return data;
}

3、在 useEffect 中调用异步函数(挂载时执行)

使用 useEffect 在组件挂载后发起请求,并在结果返回后更新状态。

useEffect(() => {
  queryData().then(data => {
    // setNum(data) 触发重新渲染,使得组件函数再次执行,UI显示666
    setNum(data);
  });
}, []); // 空依赖数组:仅在挂载时执行一次

关键点

  • 依赖数组为 [],确保只执行一次(类似 Vue 的 onMounted
  • 不要直接在组件顶层写 await(组件函数必须是同步的!)

5、渲染 UI(使用状态值)

直接在 JSX 中使用状态变量:

return (
  <div onClick={() => setNum(prev => prev + 1)}>
    {num} {/* 自动响应状态变化 */}
  </div>
);

setNum 被调用(无论是异步还是点击),React 会自动重新渲染组件。


完整标准代码示例

import {
    useState,
    useEffect
} from 'react';

async function queryData() {
    const data = await new Promise(resolve => {
        setTimeout(() => {
            resolve(666);
        }, 2000);
    });
    return data;
}

export default function App() { 
    const [num, setNum] = useState(0);
    // 增加打印可视化代码执行时机
    console.log('yyy')

    useEffect(() => {
        // 增加打印可视化代码执行时机
        console.log('xxx')
        queryData().then(data => {
            setNum(data);
        })
    }, [])
    return (
        <>
            <div onClick={() => setNum(prevNum => prevNum + 1)}>
                {num}
            </div> 
        </>
    )
}

效果如下:

动画2.gif

执行输出两次"yyy"和一次"xxx"的原因:

  • 首次页面挂载渲染,执行组件函数打印yyy,UI显示0
  • 当setNum()触发状态改变时重渲染,组件函数再次执行打印yyy,UI显示666

但是由于useEffect没有添加依赖项,所以只会在页面初次挂载时执行一次,所以打印一次xxx


执行流程时间线

时间 事件
T=0ms 组件挂载 → App() 执行 → num=0 → 渲染 0
T=0ms+ useEffect 执行 → 调用 queryData()
T=2000ms setTimeout 触发 → resolve(666)
T=2000ms+ .then 执行 → setNum(666)
T=2000ms++ 组件重新渲染 → 显示 666

支付宝 KJS Compose 动态化方案与架构设计

2025年12月31日 10:56

编者按:在 KMP + Compose 成为主流原生 UI 技术栈的背景下,业务对“动态化”的诉求正从依赖 WebView 或独立渲染体系,转向在不破坏现有渲染链路、不新增 DSL、且不影响核心页面性能的前提下,实现更细粒度、可控的动态交付能力。
本文由支付宝终端技术团队潘云逸(法慧)编写,结合工程实践,提出了一种基于 Kotlin/JS + Compose Runtime + Native Skia 的局部动态化方案:由 JS 侧负责 UI 计算,Native 侧复用既有 Skia 渲染栈完成最终上屏,在原生 Compose 页面中实现区块级、脚本驱动的动态 UI 嵌入。

背景与目标

1.1 业务背景:为什么又要谈“动态化”

大家对“动态化”的直觉基本等同于:“JS + WebView/Native 渲染引擎 + 一套新 DSL/框架”。

端内也有 Cube、H5/小程序、Bundle2.5 等等,但在 MYKMP 的技术体系下,有一些可见的痛点:

  • 我们已经有一套成熟、统一的 Compose 布局体系,业务习惯用 Compose DSL 写 UI,对于非复用资产,写一套卡片成本较高,同层嵌入下会有很多兼容性问题需要处理。

  • 首页、Tab 等核心场景对性能非常敏感,不希望在这些关键页面内耦合 webview;

同时,业务侧的诉求又很明确:希望在已有 KMP 原生页面中,局部“插一块”动态区域,这块区域可以通过下发脚本快速更新,而不需要上架发版。

所以,我们需要的是这样一种能力:

  • UI 代码依然是 Compose DSL,甚至可以通过注解决定当前组件是否为 Remote 组件

  • 动态化的载体是 JS 脚本(易分发、易管理);

  • 可以在原生 Compose 页面里“局部嵌入”动态 UI;

  • 不依赖 WebView,也不破坏现有 compose Native 侧渲染栈。

1.2 我们的目标:Compose 逻辑动线 JS 闭环,Native 只负责上屏渲染

在不破坏现有 Native 渲染链路的前提下,我们给自己设定了三层目标:

  1. 体验目标:业务侧“像写普通 Compose 一样写动态 UI”

    • 业务同学继续用 Compose DSL 写 UI,不需要学习新的 DSL 或框架;

    • 动态与静态页面在代码结构上尽量一致,只是“跑的地方”不同:

      • 静态 UI:即原生 KMP Compose 代码,直接在 Native Compose Runtime 中执行;

      • 动态 UI:即编译生成的 JS 代码,在 JS Runtime 里执行 Compose 逻辑。

  2. 架构目标:拆分“计算 UI”与“渲染 UI”

我们把整个 UI 渲染链路拆成两段:

换句话说:

JS 世界只负责“算 UI、出图纸”,Native 世界负责“照图纸画”。

  • JS 侧:

    • 搭起一套 JS 版本的 Kotlin Runtime + Compose Runtime;

    • 负责状态管理、重组、布局、测量;

    • 把最终需要绘制的内容,归约为一组“Canvas 绘制指令”(落在 CanvasDrawScope 上);

    • 不做真正的像素绘制,也不依赖 Web Canvas 或 Skiko Wasm。

  • Native 侧:

    • 继续使用现有的 Kotlin/Native + Skia 渲染栈;

    • 负责接收 JS 侧通过 JS Binding 传来的绘制指令;

    • Skia PictureRecorder 做“录制”,形成一份可重放的 Picture

    • 在需要上屏时,把这份 Picture 绘制到对应的 Compose 容器区域。

  1. 能力目标:先支持“局部动态化嵌入”,后续再拓展到整个页面。 我们并不追求“一上来就把整页交给脚本”,而是聚焦在更具体、可控的能力:

这样做的好处是:

  • 在一个原生 Compose 页面中,某个区域由动态脚本驱动;

  • 这个区域可以独立更新、独立灰度;

  • 其余部分仍然是正常的原生 Compose 代码;

  • 支持后续扩展为多块动态区域、甚至组合嵌入。

  • 对现有业务页面改造成本小(添加一个动态容器 Composable 即可);

  • 动态能力可以从简单场景逐步扩展到复杂场景;

  • 一旦出现问题,可以快速降级为静态实现或关闭某个动态区域。

1.3 最终希望达到的状态

从业务视角来看,我们希望最后呈现出来的是这样一种体验:

  • 写 UI 的方式不变:依旧是熟悉的 @ComposableColumnRowText 等;

  • 多了一种“部署方式”:

    • 可以把这段 Compose 编译到 Native,随 App 发版;

    • 也可以把这段 Compose 编译到 JS,通过脚本下发,在 JS Runtime 中运行;

  • 在页面里用起来,大致是这样的:

    @Composable fun HomePage() { Column { Header() // 原生静态区域

        RemoteView(
            // scriptId 只是个唯一标识符,可以是业务自定义的 bizId,也可以是模块 artifactId
            scriptId = "home_feed_dynamic_v1", // 对应某个下发脚本
            modifier = Modifier.fillMaxWidth().height(200.dp)
        )
    
        Footer() // 原生静态区域
    }
    

    }

  • 而对于这块 RemoteView

    • 首次进入时:在后台创建一个 JSContext,加载业务下发的 JS(里面是 Compose + Kotlin Runtime),执行初始绘制,生成一份 Skia Picture

    • 业务状态变化时:JS 侧触发重组,重新生成绘制指令,Native 录制新的 Picture,局部刷新该区域;

    • 业务无感知整个渲染细节,只关注“这块区域的 UI 和状态”。

从架构视角来看,这套方案在动态化能力与现有原生栈之间搭了一座桥:

  • 上层统一使用 Compose DSL;

  • 中间用 Kotlin/JS Runtime + Compose Runtime 实现“跨语言的 UI 计算”;

  • 底层统一落在 Skia 渲染,沿用现有性能优化和渲染流水线。

这就是我们这次方案设计背后的背景与目标:
在不引入 WebView、不重复引入一整套 Wasm Skia 渲染栈的前提下,将 Compose 的动态化能力“搬到 JS + Native Binding”之上,让业务在最小心智负担下获得扎实可控的局部动态化能力。

现有方案分析

2.1 Kuikly 动态化

Kuikly 是腾讯开源的一套跨平台 UI 渲染解决方案,它的技术路线是保留 Kotlin 侧的 UI 树/组件体系,复用 ComposeDSL,在 Kotlin 内部内化 Build -> Measure -> Layout,Native 侧仅保留了原子化的 View 能力和通用的视图树增删改接口的映射实现(创建控件、挂载子控件、设置属性、设置布局结果等),Kotlin 侧最终通过渲染接口,直接把布局和属性同步变更到 Native View 上,再由 Native View 上屏渲染。

在这个技术架构体系里,提供动态化能力就相对比较简单了,只需要适配 ComposeDSL,完成以下路径即可:

  1. 下发的 Compose DSL 解析-> Kuikly 组件树🌲

  2. Native 侧 Kotlin 完成测量布局

  3. 通过渲染接口更新原生控件

2.2 KJS For Web

Kotlin/JS(简称 KJS)其实已经提供了一套比较完整的动态化能力:
把 Kotlin 代码编译成 JS,并带上一整套 Kotlin Runtime,在浏览器环境里跑。
它典型的产物结构是三件套:

  • composeApp.js:业务代码 + Kotlin/JS Runtime + Compose Runtime;

  • skiko.js:JS 与 Skia(Wasm)的胶水层;

  • skiko.wasm:真正的 Skia 图形引擎(Wasm 版)。

在 Web 场景下,这套方案是合理的:浏览器提供了 JS 引擎和 Canvas,KJS 提供了 Kotlin/JS Runtime 和 Compose 逻辑,Skiko/Skia 则负责最终的像素级绘制。

但如果我们把这套设计直接搬到客户端原生环境,会遇到几个核心问题:

  1. 强依赖 Web 环境
  • KJS for Web 默认存在 document、Canvas、事件模型等浏览器能力;

  • 这意味着要在我们的场景中落地,通常需要引入 WebView,这是首页 / Tab 等关键场景无法接受的。

  1. 重复图形栈:多一套 Wasm Skia 没意义
  • 客户端 Native 侧已经有一整套围绕 Skia 的渲染 pipeline;

  • 再加载一个 skiko.wasm,等于在 App 里用 Wasm 再跑一份 Skia —— 包体、内存、初始化成本都非常高,而且和现有渲染栈重复。

  1. iOS 等平台的环境限制
  • iOS 不允许应用自行 JIT,WebAssembly 在非 Web 环境要靠解释或 AOT 运行;

  • 在这种约束下,再塞进一套重型 Wasm 渲染引擎,性价比很低。

总体来看,KJS for Web 是为“在浏览器里跑 Compose”设计的,而我们现在要解决的是“在原生环境里跑动态 Compose”,两者的前提条件完全不同。

2.3 RemoteCompose

其本质是用“可序列化的绘制文档”+“通用 Player”,把 Compose UI 从 App 本地编译时,解耦成运行时可远程下发和播放的形态。

1.声明式文档序列化

  • 把 Compose UI 的绘制过程“捕获”为一份二进制文档(类似“绘制指令的录像带”);

  • 文档里不是 JSON 组件树,而是很底层的绘图操作、布局信息、状态、交互定义。

2.平台无关的文档回放(渲染)

  • 客户端拿到这份文档,不需要原来的 @Composable 代码,也不需要业务 ViewModel;

  • 只用一个 RemoteDocumentPlayer 组件按指令在 Canvas 上画出来;

  • 同一文档可以在不同 Android 设备(手机 / 平板 / 折叠屏 / Wear)以原生方式渲染。

RemoteCompose的潜在问题是

  1. 官方能力仅支持 Android,目前为 Snapshot 版本

  2. 需要维护一个服务端应用,用来做状态同步,驱动服务端 compose 重组和渲染指令重录

  3. 交互场景可能会因为网络抖动发生延迟

KJS 动态化方案和架构设计

为了满足业务对于 Compose 动态化的诉求,我们也评估了一些目前现有的技术路线,例如 KJS for Web、RemoteCompose 等方案:

  • KJS for Web 强依赖 Web 环境 + Wasm Skia,不适用于纯 Native 页面;

  • RemoteCompose 更偏“后端生成文档 + Android 客户端播放”,且目前主要定位在 Android 生态。

最终,我们基于 Kotlin/JS(KJS)和现有 Native Skia 渲染栈,设计了一套 “KJS Native”的局部动态化方案。

方案核心思路:JS 做重组布局,Native 做渲染重放。

用 KJS 在 JS Runtime 中跑一套 Compose Runtime,只负责重组 / 布局 / 测量,并生成“绘制指令”;
再通过 JS Binding 把这些指令下发到 Native,用 Skia 的 Picture 录制与重放完成真实渲染。

这其中有三个关键点:

  1. KJS 只承载逻辑侧的 Compose Runtime

    • JS 侧不引入 skiko.wasm,也不跑 Wasm 版 Skia;

    • 只保留 Kotlin/JS Runtime + Compose Runtime(重组、布局、测量);

    • CanvasDrawScope 这一层统一收口所有绘制动作。

  2. 通过 JS Binding 把 Canvas 绘制指令映射到 Native

    • 在 JS 侧改造 compose-ui / ui-graphics / ui-text 的 JS target;

    • 剥离对 Skiko 的依赖,把最终的 drawRect/drawPath/drawText/... 变成一组“跨语言绘制指令”;

    • 通过 JSI/NativeBinding 将这些指令传递到 Native。

  3. Native 使用 Skia PictureRecorder 做录制与重放

    • Native 收到绘制指令后,用 Skia PictureRecorder 开始一次录制:

      • beginRecording():接收并执行指令,真实调用 Skia API;

      • endRecording():得到一份 Picture

    • 页面实际渲染时,只需要在对应区域 Canvas.drawPicture(picture) 即可;

    • 动态化组件设置成 graphicLayer 做层级提升,降低重绘的开销

下面以一个动态容器 RemoteView 为例,串一下实际执行链路。

3.1 整体流程介绍

Step 1:业务声明动态容器

在原生 Compose 页面中,业务只需要用一个容器承接动态区域:

@Composable
fun HomePage() {
    Column {
        Header() // 原生静态区域
        // scriptId 只是个唯一标识符,可以是业务自定义的 bizId,也可以是模块 artifactId
        RemoteView(pageId =pageId, 
                   viewId = viewId, 
                   scriptUrl = "https://kmp_demo/composeApp.js", 
                   modifier =  Modifier.height(
                   height = 150.dp).fillMaxWidth())

        Footer() // 原生静态区域
    }
}

RemoteView 内部做的事情:

  • 根据 scriptUrl,异步从本地 / 网络获取对应的 JS 产物;

  • 通过 KJSEngine 创建一个 JSContext

  • 在这个 JSContext 里加载并执行业务的 Compose JS 代码(KJS 编译产物)

Step 2:JS Runtime 中首次 Composition 与绘制指令生成

在 JSContext 中:

  1. 启动 Kotlin/JS Runtime + Compose Runtime;

  2. 运行动态区域对应的 @Composable 函数;

  3. 完成第一次重组 / 布局 / 测量;

  4. 进入改造后的 CanvasDrawScope,生成一组绘制指令(逻辑上类似:drawRectdrawTextdrawImage 等)。

此时,所有绘制动作都不直接画,而是以指令的形式,通过 JS Binding 送到 Native。

Step 3:Native 录制 Skia Picture

Native 侧:

  1. 收到“开始录制”的信号,创建一个 Skia PictureRecorder

  2. 遍历 JS 传来的绘制指令,逐条调用 Skia 对应 API,比如:

    • canvas.drawRect(...)

    • canvas.drawPath(...)

    • paragraph.paint(canvas)(文字链路我们通过封装进行适配,最终产出的也是);

  3. 结束录制,得到一份 Picture 对象;

  4. 通过 callback 或状态更新,将这份 Picture 提交给业务层。

Step 4:业务 Compose 把 Picture 上屏

RemoteView 内部维护一个 State<Picture?>

  • 当 Native 录制完成,更新 Picture;

  • Compose 检测到状态变化,重新执行 RemoteViewdraw 逻辑;

  • 在对应的 DrawScope 中调用 drawPicture(picture),将 Picture 绘制到该容器区域。

到此为止,动态区域的首次展示完成。

Step 5:后续状态变化与局部重绘

当 JS 侧状态发生变化(例如:数据返回、用户点击、内部动画触发):

  1. JS 中的 Compose Runtime 触发重组 → 布局 → 再次进入绘制阶段;

  2. 新一轮绘制指令通过 Binding 下发到 Native;

  3. Native 重新录制一份新的 Picture

  4. 业务层再次更新 Picture 状态,触发该区域的局部重绘。

整个过程对业务来说是透明的,状态在 KJS 内部流转,最终呈现的是像素变化。这种解决方案的一个可见的好处是:动态化区块的渲染结果是通过原生画布承接的,天然的不存在任何同层问题,在 KJS 实现中,通过剥离实体 Surface,使得驱动页面渲染绘制成为可能。同样的,在适配多平台时,因为最终的 js 产物多端一致,只需要实现一些原子化的 JS Binding 指令即可,迁移到其他平台的成本相对较低。

3.2 状态与交互

动态 UI 不可能只有“画一张静态图”,交互是必须的。我们在两端都做了设计:

JS 侧:保留 Compose 的状态模型

在 KJS 侧,我们依然使用 Compose 的状态模型,例如 remembermutableStateOf 等,用来管理组件内部状态:

@Composable
fun DynamicButton() {
    var clicked by remember { mutableStateOf(false) }
    Button(onClick = { clicked = !clicked }) {
        Text(if (clicked) "Clicked" else "Click Me")
    }
}

这些状态变化会自然驱动 KJS 侧重组和重新绘制,从而生成新的绘制指令。

Native 侧:事件分发与同步

为了让用户的点击 / 滑动事件能反馈到 JS 侧,我们提供了一条事件通路:

  1. Native Compose 页面捕获点击 / 滑动等原生事件;

  2. 将相关信息(坐标、类型等)通过 Native Binding 传给 JS 侧的 composeScene;

  3. 在 JS 侧,映射到 Compose 的输入事件系统(如点击、滚动事件),触发对应的处理逻辑;

  4. 状态变化后,重新生成绘制指令 → 新 Picture → 局部重绘。

大部分图形绘制指令可以比较直接映射为 Skia 的 API(直线、矩形、圆形、路径、图片等)。
文字渲染则相对复杂,需要一些额外的工作:

3.3 文字渲染链路

在 Kotlin Native 侧,Compose 的主要工作是:

  1. 把声明式样式翻译成 Skia 能识别的字体资源和段落样式(读取、注册、解析、fallback);

  2. 拿到 Skia 算好的尺寸并把它画到 Skia Canvas 上
    真正的「断行、字形选择、光栅化」全部由 Skia 完成。

我们先来看看一次正常的文字渲染流程是啥样的:

阶段

输入

动作(关键源码)

输出

1 入口

TextLayoutInput(text, style, constraints, maxLines, overflow…)

layout(textLayoutInput)被调用

开始布局流程

2 预排版

同上

MultiParagraphIntrinsics(...) 构造

得到段落骨架 nonNullIntrinsics,含每段 intrinsics、字体、占位符等,但还没真布局

3 决定排版宽度

constraints + 骨架.maxIntrinsicWidth

val width = if (min==max) max else maxIntrinsicWidth.coerceIn(min, max)

最终用于布局的 width(像素值)

4 修正 maxLines

softWrap、overflow

val overwrite = !softWrap && overflow.isEllipsis val finalMaxLines = if(overwrite) 1 else maxLines

修正后的行数限制

5 创建 MultiParagraph

骨架、修正后的 width、maxLines、overflow

MultiParagraph(...)``init{} 内: ① 逐段创建 Paragraph ② 累加 height/lineCount ③ 提前 break 若已超限

得到真正的排版实体: - 总宽 = constraints.maxWidth.toFloat() - 总高 = currentHeight - lineCount - didExceedMaxLines - paragraphInfoList(含每段 y/行号/字符区间) - placeholderRects

6 包装成结果

原始 TextLayoutInput + MultiParagraph

TextLayoutResult(layoutInput = ..., multiParagraph = ..., size = ...)

返回的 TextLayoutResult 实例,所有查询/绘制都依赖它

总结下来其实是:

  • Kotlin/Native 侧的文字渲染依赖 Skia 的排版能力,向上层 kotlin 侧透出 SKParagraph,通过拿到 TextLayoutResult 来融合进 Compose 整体的布局测量流程,真正绘制时会调用 paragraph.paint(canvas)

  • 原生 KMP Compose 中,Skiko 把这部分封装好直接用,现在我们只能在 KJS 里封装了一套完整对应能力:

    • JS 侧描述文本内容、样式、约束(宽度、高度等);

    • Native 根据描述构建 Skia Paragraph,对文本布局与绘制进行处理,返回 TextLayoutResult,融合进已有的 Compose 布局体系。

    • 在录制阶段,把文字绘制透过 RecordingCanvas 纳入 Picture。

方案对比

4.1 与 KJS For Web 的对比

  • KJS For Web:

    • 强依赖 Web 环境(DOM、Canvas、WebAssembly);

    • 引入 skiko.wasm,在 JS + Wasm 中跑 Skia;

    • 通常需要 WebView,在首页/Tab 等原生核心场景难以接受。

  • 我们:

    • 不依赖 WebView,不使用 Wasm;

    • 完整渲染权留在 Native Skia;

    • KJS 只做逻辑层的 Compose Runtime,不启动第二套图形栈。

4.2 与 RemoteCompose 的对比

  • RemoteCompose:

    • 服务端生成“绘制文档”;客户端通过 Remote Player 解释执行;

    • 更偏“服务端驱动 UI”,客户端仅负责播放和行为回调;

    • 文档协议、操作模型、状态系统都是单独的一套。

  • 我们:

    • 动态产物是 KJS 编译后的 JS + Kotlin/JS Runtime;

    • 没有引入额外通用文档协议,也没有 Remote Player 黑盒;

    • 直接对接现有 Compose 框架与 Skia 渲染栈,掌控力更强,贴合 KMP 架构。

从业务视角看,我们的方案更接近于:

在原生 Compose 页面中,插入一块由 JS 驱动的 Compose 子树,这棵子树负责自己重组和“画什么”,
而“怎么画”仍然交给原生 Skia。

性能基线

5.1 产物包大小

目前线下产物采用单产物方便快速验证,单产物 DCE + 混淆后,大小为 1.52 MiB(包含 kotlin runtime + compose foundation/ui/ui-graphics/ui-text 等必要的基础库)。

5.2 线下效果对比

此处为对比视频,见公众号文章

左:KJS Native

右:原生 KMP

5.3 线下数据对比

以上述简单的图文视图为例:

  1. KJS Native 最终的渲染指令为 113 条,从端侧创建 composeWindow 为起点,首帧耗时为 256 ms。

  2. 在 KMP 原生中,同样的视图首帧耗时为 180 ms。

总结与展望

从当前在鸿蒙端完成的端到端 POC 结果来看,KJS x Native 方案能够以相对低的改造成本,满足 KMP 业务对“动态化交付与快速迭代”的核心诉求:在不切换现有技术栈、不引入新的渲染体系、且不显著增加工程复杂度的前提下,把动态能力收敛在 KMP 技术栈内部,形成“产物生成—下发—加载—执行/渲染—监控回收”的闭环。这意味着业务仍可以沿用既有的 KMP 工程组织方式、研发协作方式与质量保障体系。

当然,要将 POC 推进到可规模化落地的工程能力,后续仍有不少工作亟需完善与产品化沉淀,包括但不限于:

  • 组件体系适配与体验对齐:推动 AntUI/Tecla 等组件库的接入与一致性适配,确保动态化场景下的样式、交互、无障碍、主题(深色/浅色)与多端一致性体验,尽可能做到业务侧“用起来像原生 KMP 开发”。

  • 工程化与链路完善:沉淀标准化的产物构建、版本管理、依赖治理与灰度发布策略,降低发布与运维成本。

  • 能力边界拓展与约束:在安全与性能可控的前提下,持续扩大可动态化的 UI/交互/数据能力,同时明确“哪些可以动态、哪些必须静态”的红线与最佳实践,形成可复用的业务接入规范。

业务侧引入该方案后,能直接获得更细粒度的“区块级动态化”能力:页面可以按区块拆分为可独立更新的单元,通过下发 JS 产物完成局部替换、样式/布局调整、交互逻辑调整等,避免传统整包发布的高成本与长周期。在此基础上,还可以进一步实现:

  • 区块级动态修复:线上出现问题时,可针对特定区块进行快速修复与回滚,把影响面控制在最小范围内,缩短故障处理链路。

  • 更灵活的逻辑/视图更新:在不触发完整发版的情况下,进行轻量的交互优化、文案与布局调整、策略实验等。

  • 快速 AB 与灰度验证:在性能与稳定性可接受的范围内,支持更高频的实验迭代,通过精细化人群与版本控制,加速验证效率与业务决策闭环。

从更前沿的业务形态看,方案也为 LLM 生成式 UI 打开了落地通路:可以由 KMP Agent 基于上下文与策略生成 UI/逻辑对应的 JS 产物,并结合风控与审核机制进行下发,再通过 KJS x Native 在端上渲染展示,实现“按需生成、按需更新”的交互体验。这不仅能扩展动态化的应用场景(如个性化内容编排、运营位智能生成、任务引导动态拼装等),也为后续探索“端侧智能 + 可控动态渲染”的组合提供了工程基础。

随着方案逐步完善并达到可规模化上线标准,我们将进一步补齐文档、示例、接入规范与基建能力(调试工具、性能监控、异常治理与安全策略等),在内部稳定运行一段时间后推动对外开源,促进社区共建与生态扩展。

2025 年末 TypeScript 趋势洞察:AI Agent 与 TS 7.0 的原生化革命

2025年12月31日 10:16

【技术日报】2025 年末 TypeScript 趋势洞察:AI Agent 与 TS 7.0 的原生化革命

作者:Manus AI

引言

在 2025 年的最后一个月,TypeScript 已经巩固了其作为 GitHub 上最受欢迎编程语言的地位 [5]。这种强劲的增长并非偶然,它反映了软件开发领域正在经历的深刻变革。今日(2025 年 12 月 31 日)的 GitHub Trending TypeScript 榜单 [1] 再次印证了这一趋势:AI Agent 基础设施语言自身性能优化是推动 TypeScript 生态向前发展的两大核心动力。本文将深入分析今日热门项目,并探讨 TypeScript 在 AI 时代的关键技术趋势。

一、GitHub 热门项目概览与 AI 驱动

今日 TypeScript 热门项目集中在 AI 基础设施、自动化工作流和高性能工具领域。下表列出了部分最具代表性的项目,它们共同描绘了 TypeScript 在全栈应用中的新角色:

项目名称 Stars 核心功能 技术趋势
sst/opencode 44,499 开源 AI 编程代理 AI 辅助开发、Agent 闭环
activepieces/activepieces 20,136 AI Agent & MCP 工作流 模型上下文协议 (MCP)、工作流自动化
upstash/context7 40,512 Context7 MCP 服务器 LLM 上下文管理、数据安全
bytedance/UI-TARS-desktop 20,300 多模态 AI Agent 栈 跨模态交互、大型模型应用
n8n-io/n8n 165,643 工作流自动化平台 AI 原生集成、低代码/可扩展性
cjpais/Handy 9,480 离线语音转文字应用 隐私计算、高性能桌面应用

这些项目表明,TypeScript 已不再局限于前端开发,而是成为构建复杂、高性能、且深度集成 AI 能力的全栈基础设施的首选语言。

二、核心技术趋势深度分析

1. AI Agent 基础设施的类型安全基石

在今日的榜单中,超过一半的项目直接或间接服务于 AI Agent 生态。从 sst/opencode 这样的编程代理到 activepieces 这样的工作流引擎,TypeScript 的静态类型系统在其中发挥了至关重要的作用。

在 Agent 驱动的开发模式中,代码的复杂性和不确定性急剧增加。TypeScript 提供的类型安全保障,能够有效减少运行时错误,提高 Agent 编写代码的可靠性,并加速开发者对 Agent 生成代码的审查和集成过程。这使得 TypeScript 成为构建 Agent 框架和工具的类型安全基石

2. MCP 协议:AI 模型的“工具箱”

模型上下文协议 (Model Context Protocol, MCP) 正在成为连接 LLM(大型语言模型)与外部工具和数据的关键标准。activepiecesupstash/context7 的流行,反映了开发者对标准化 Agent 交互接口的迫切需求。

MCP 旨在解决 AI Agent 在执行任务时面临的上下文限制工具调用不一致的问题。通过 TypeScript 构建的 MCP 服务器,能够以类型安全的方式,为 Agent 提供最新的代码文档、API 访问权限和数据源,极大地提升了 Agent 的能力和可靠性。这一趋势预示着未来 Agent 间的协作将更加依赖于这种标准化的、基于 TypeScript 的协议实现。

3. TypeScript 7.0 的“原生化”性能革命

除了应用层面的趋势,TypeScript 语言本身也在经历一场重大的变革。根据微软 TypeScript 团队在 2025 年 12 月的最新进展 [2] [3],TypeScript 7.0 的核心编译器和语言服务正在使用 Go 语言进行重写(即“原生化”)。

这一“原生化”的战略目标是彻底解决 TypeScript 在大型项目中的性能瓶颈:

“TypeScript 7.0 旨在通过将语言服务和编译器移植到 Go 语言,显著提升性能、优化内存使用,并利用 Go 的并发特性实现更快的并行处理 [3] [4]。”

预计 TypeScript 7.0 将带来高达 10 倍的编译速度提升,并大幅降低内存消耗。这将进一步巩固 TypeScript 在大型企业级应用和复杂工具链中的地位,使其在性能上更具竞争力。

结论

2025 年末的 TypeScript 生态呈现出清晰的**“AI 优先”战略。从 GitHub 热门项目来看,TypeScript 已经从一门“前端增强语言”进化为“AI Agent 基础设施语言”**。AI Agent 的普及推动了 MCP 等新协议的诞生,而 TypeScript 7.0 的原生化重写则为整个生态系统提供了前所未有的性能保障。对于开发者而言,掌握 TypeScript 在 AI Agent、MCP 和高性能工具链中的应用,将是迎接 2026 年技术浪潮的关键。

参考文献

[1] GitHub Trending TypeScript Today: https://github.com/trending/typescript?since=daily

[2] Progress on TypeScript 7 - December 2025: https://devblogs.microsoft.com/typescript/progress-on-typescript-7-december-2025/

[3] Microsoft steers native port of TypeScript to early 2026: https://www.infoworld.com/article/4100582/microsoft-steers-native-port-of-typescript-to-early-2026-release.html

[4] TypeScript 7 (Go Rewrite) - Current Status and Progress: https://typescriptpro.com/blog/typescript-version-7-current-status-2025-12-22

[5] Octoverse: A new developer joins GitHub every second as AI leads TypeScript to #1: https://github.blog/news-insights/octoverse/octoverse-a-new-developer-joins-github-every-second-as-ai-leads-typescript-to-1/

TypeScript 类型断言和类型注解的区别

2025年12月31日 10:12

1. 类型注解(Type Annotation)- useState<string | null>(null)

const [msg, setMsg] = useState<string | null>(null)

作用: 告诉 TypeScript 这个变量的类型应该是什么

特点:

  • 声明类型:定义变量应该是什么类型
  • 编译时检查:TypeScript 会检查赋值是否符合类型
  • 类型推导:如果类型不匹配会报错

示例:

const [msg, setMsg] = useState<string | null>(null)
// msg 的类型是 string | null(只读)
// setMsg 的类型是 (value: string | null) => void

// ✅ 正确:使用 setter 函数
setMsg("hello")  // ✅ 可以
setMsg(null)     // ✅ 可以
setMsg(123)      // ❌ 错误:不能传入 number

2. 类型断言(Type Assertion)- as HTMLInputElement

(input as HTMLInputElement).value = ''

作用: 告诉 TypeScript "相信我,这个值就是这个类型"

特点:

  • 强制转换:不改变运行时值,只改变编译时的类型
  • 绕过检查:告诉编译器"我知道我在做什么"
  • 风险:如果类型不对,运行时可能出错

示例:

const input: HTMLElement | null = document.querySelector('input')
// input 的类型是 HTMLElement | null
// HTMLElement 没有 .value 属性

// 使用类型断言
(input as HTMLInputElement).value = ''  // ✅ 编译通过
// 告诉 TypeScript:我知道 input 是 HTMLInputElement

对比表格

特性 类型注解 useState<string> 类型断言 as HTMLInputElement
时机 声明变量时 使用变量时
作用 定义类型 强制转换类型
检查 严格检查,类型不匹配会报错 绕过检查,相信开发者
风险 低(编译时检查) 高(运行时可能出错)
使用场景 定义新变量 已知类型但 TypeScript 不知道

实际例子

// 类型注解 - 定义类型
const [count, setCount] = useState<number>(0)
// count 的类型是 number,setCount 只能接收 number

// 类型断言 - 强制转换类型
const element = document.querySelector('.my-input')
// element 的类型是 Element | null
const input = element as HTMLInputElement
// 现在 input 的类型是 HTMLInputElement,可以访问 .value

总结

  • 类型注解:定义类型,让 TypeScript 帮你检查
  • 类型断言:强制转换,告诉 TypeScript "相信我"

最佳实践:

  • 优先使用类型注解(更安全)
  • 只在确实知道类型时才使用类型断言
  • 避免过度使用类型断言(会失去类型检查的好处)

前端性能优化之性能指标篇

2025年12月31日 09:55

1、常见性能指标

  • FP(First Paint - 首次绘制):页面首次绘制的时间点,即第一个像素绘制到屏幕上的时间点。
  • FCP(First Contentful Paint - 首次内容绘制):页面首个内容绘制到屏幕上的时间点,这里的内容包括文本、图片等。
  • LCP(Largest Contentful Paint - 最大内容绘制):可视区域内最大的元素加载的时间点。这里的“最大”指的是从几何属性(可视尺寸)来计算,绘制面积最大的那个元素。
  • FID(First Input Delay - 首次输入延迟) 与 INP(Interaction to Next Paint):页面首次交互(点击链接、按钮等)到浏览器实际响应的延迟。以前该指标用 FID 来衡量,但目前已经被 INP 取代,它们之间的区别是,FID 只衡量“第一次”交互,而 INP 会观察整个页面所有交互事件,并报告一个较差或最高的数值,这样更能反应出页面的整体响应性。
  • CLS(Cumulative Layout Shift - 累积布局偏移):在页面生命周期当中,累计的意外布局偏移总分数。
  • TTFB(Time to First Byte):浏览器从发起请求到接收到服务器响应数据的首字节所花费的时间。

2、需要关注什么样的指标?

上面列出了那么多指标,那么在实际衡量前端项目性能时,我们究竟应该关注些什么指标呢?

我们可以从这几方面入手:

  • 可衡量。就是可以通过代码计算拿到的指标,即可以用代码来衡量,无法衡量也就无法优化。
  • 用户关心的关键信息。比如用户进入了一个商品详情页,他最关心的是这个商品是怎样的,包括商品头图、商品描述、商品价格、购买按钮等关键信息。
  • 用户的真实体验。比如用户在列表中滑着滑着,突然弹出一个广告弹窗,用户就会觉得体验很差。

3、加载性能指标

3.1 白屏时间

白屏时间是指用户输入内容按下回车(也包括刷新、跳转等方式)后到页面出现第一个字符的时间,其标准是 300 ms

影响白屏时间的因素如下

  • 在客户端请求阶段,DNS 查询时间长,没利用好本地缓存,HTTP 请求阻塞等。
  • 在服务端处理请求阶段,服务器处理请求速度太慢,包括数据库查询慢、没做数据缓存、没开启 Gzip 压缩等。
  • 在客户端渲染阶段,HTML 解析慢、script 阻塞、资源加载慢、渲染时间长等。
  • 用户本地网络慢,以及缺乏本地离线化处理等。

3.2 首屏时间

首屏时间指的是用户看到当前页面(第一屏)被完全加载出来的时间点。首屏时间 = 白屏时间 + 首屏渲染时间

首屏时间的标准不是定死的,而是根据当前系统对时间是否敏感来决定的,比如 C 端系统 PC官网、H5 页面等,对首屏要求较高,而像 B 端管理后台,对首屏时间就没那么敏感,而且一般企业内部用的系统,其访问的网络环境一般都比较稳定,所以对首屏时间要求会低一些。

具体时间可以参考如下表

类别 较快 很慢 指标示例
时间敏感 <1s 1s ~ 1.5s 1.5s ~ 2.5s >2.5s 首屏、白屏
时间不敏感 <2s 2s ~ 4s 4s ~ 8s >8s onload
最佳:白屏 < 1s,首屏 < 1.5s,onload < 3s。

一般来说,首屏时间在 1s 内,给用户的感觉就是非常流畅,在 1s 内的话,无论是 300ms 还是 500ms,其实用户感觉不出来太大的差别,但是如果首屏时间超过 2.5s,用户感觉就会很慢,体验很差,用户流失率也会嘎嘎往上升。

3.3 秒开率

我们可以用首屏时间来衡量单个用户的首屏体验,但是如果把很多用户的首屏时间都收集上来了,如何衡量整个系统的首屏体验呢?

  • 平均值:平均值就是所有用户首屏时间的总和除以用户数,但是平均值有个致命的缺点,就是容易被极端值影响,比如有 10 个用户,其中 9 个用户首屏时间都在 1s 内,而第 10 个用户首屏时间在 10s 内,那么平均值就会变成 5s,这个结果显然是不合理的。
  • 中位数。可以将所有数据做正态分布,看分位值统计。比如 P50(50分位值)、P90(90分位值)、P99(99分位值),这个值是怎么计算出来的呢?以 P99 为例,先把所有首屏数据先排好序,排在第 99 位的数据就是 P99,但这样还是比较麻烦。于是引入了秒开率的概念。
  • 秒开率:指的是 1s 内打开的用户占比。比如 10 个用户,1s 内能加载完首屏的用户有 8 个,那秒开率就是 80%。

按照秒开率的概念,我们可以建立如下的首屏时间标准

类型 首屏时间 秒开率 1.5秒开率 2秒开率
SSR(服务端渲染) 1000ms 80% 95% 98%
端内(Hybrid 环境) 1200ms 65% 85% 90%
端外(浏览器环境) 1500ms 40% 60% 80%

4、交互性能指标

交互指的就是人机交互,比如用户点击了按钮后,网站立马给予一定的回应,包括跳转、弹窗、动画等,那就是用户体验好,但如果用户点击了按钮后,网站半天没有任何反应,那给用户的体验就很差。

衡量交互指标的方式主要用两种

  • FID 指标(First Input Delay,首次输入延迟),指标必须尽量小于 100 ms。但文章开头也介绍过,现在 FID 指标已经被 INP 取代。
  • PSI(Perceptual Speed Index,视觉变化率),衡量标准是小于 20%。

5、视觉稳定性指标

视觉稳定性用 CLS(Cumulative Layout Shift) 来衡量,CLS 也就是布局偏移量,它是指页面从一帧切换到另外一帧时,视线中不稳定元素的偏移情况。

比如在一个商品详情页中,用户在想点击商品图片放大仔细查看图片内容,但是突然图片上方渲染出了一条广告,把图片顶到页面底部去了,这个就是不稳定元素,给用户的体验非常差。

小结

常用的性能指标包括 FPFCPLCPFIDINPCLSTTFB 等,而在实际前端项目优化中,我们往往比较关注加载性能指标、交互性能指标和视觉稳定性指标,加载性能指标包括白屏时间和首屏时间,而为了更好的衡量首屏时间对于用户的整体效果,我们引入了秒开率的概念,也就是 1s 内打开的用户占比,秒开率越高,整体用户体验越好。

往期回顾

Vue 3 插件系统重构实战:从过度设计到精简高效

2025年12月31日 09:29

一次基于 YAGNI 原则的架构优化之旅,代码减少 26.5%,启动速度提升 35%

前言

在维护一个大型 Vue 3 企业级项目时,我发现应用启动越来越慢,插件系统的代码也越来越臃肿。经过深入分析,我意识到问题的根源在于过度设计——我们实现了很多"可能会用到"的功能,但实际上从未使用过。

这篇文章将分享我如何通过应用 YAGNI(You Aren't Gonna Need It)原则,将一个 837 行的插件系统重构为 615 行,同时将启动时间从 1500ms 优化到 980ms 的完整过程。

一、问题诊断:过度设计的代价

1.1 症状表现

在开始重构前,我们的插件系统存在以下问题:

性能问题

  • 首次渲染时间:~1200ms
  • 插件安装耗时:~300ms
  • 初始内存占用:~45MB

代码问题

  • 插件管理器 425 行,职责不清
  • 类型定义 150 行,充斥着 any 类型
  • 初始化流程混乱,同步异步混杂

1.2 深层原因分析

通过代码审查和使用率统计,我发现了几个关键问题:

问题一:实现了完整的插件生命周期,但使用率极低

// 旧架构:定义了 5 个生命周期钩子
interface PluginLifecycle {
  register?: () => void    // 使用率: 0%
  install: () => void      // 使用率: 100%
  enable?: () => void      // 使用率: 0%
  disable?: () => void     // 使用率: 0%
  uninstall?: () => void   // 使用率: 0%
}

统计显示,除了 install 方法,其他生命周期钩子的使用率为 0%。这意味着我们维护了大量永远不会被调用的代码。

问题二:EventEmitter 增加了复杂度,但收益有限

// 旧架构:使用 EventEmitter 管理插件事件
class PluginManager extends EventEmitter {
  async install(name: string) {
    this.emit('before:install', name)
    // ... 安装逻辑
    this.emit('after:install', name)
  }
}

// 实际使用:没有任何地方监听这些事件
// 搜索结果:0 个 .on('before:install') 调用

EventEmitter 带来了额外的内存开销和复杂度,但没有任何实际价值。

问题三:类型安全性差

// 旧架构:大量使用 any 类型
interface PluginDefinition {
  install: (app: App, options?: any) => void  // ❌ any 类型
  defaultOptions?: any                         // ❌ any 类型
}

// 导致的问题:
pluginManager.install('myPlugin', { 
  typoInOptionName: true  // 编译时无法发现错误
})

1.3 性能瓶颈定位

使用 Chrome DevTools Performance 分析,我发现了几个关键瓶颈:

  1. 插件管理器初始化:创建 EventEmitter、初始化状态管理 → 80ms
  2. 依赖检查逻辑:遍历插件依赖树(实际没有依赖) → 45ms
  3. 生命周期钩子调用:触发空的事件监听器 → 30ms

这些都是可以避免的开销。

二、设计原则:YAGNI 与职责分离

2.1 YAGNI 原则的应用

YAGNI(You Aren't Gonna Need It)是极限编程的核心原则之一,意思是"你不会需要它"。在重构中,我严格遵循这个原则:

删除决策矩阵

功能 当前使用率 未来可能性 决策
install 生命周期 100% 必需 ✅ 保留
enable/disable 0% ❌ 删除
uninstall 0% ❌ 删除
EventEmitter 0% ❌ 删除
依赖检查 0% ❌ 删除(用 priority 替代)
状态管理 20% ✅ 简化(只保留 installed 集合)

2.2 职责分离原则

旧架构的 PluginManager 承担了太多职责:

// 旧架构:PluginManager 做了太多事情
class PluginManager {
  // 职责1: 插件注册
  register(plugin: PluginDefinition) { }
  
  // 职责2: 插件安装
  install(name: string) { }
  
  // 职责3: 生命周期管理
  enable(name: string) { }
  disable(name: string) { }
  
  // 职责4: 依赖管理
  checkDependencies(name: string) { }
  
  // 职责5: 事件管理
  emit(event: string) { }
  on(event: string, handler: Function) { }
  
  // 职责6: 状态查询
  isInstalled(name: string) { }
  getStatus(name: string) { }
}

新架构将职责清晰分离:

// 新架构:职责清晰分离

// 1. 类型定义(src/plugins/core/types.ts)
export interface PluginDefinition<T = any> {
  metadata: PluginMetadata
  defaultOptions?: T
  install: PluginInstallFn<T>
}

// 2. 插件安装器(src/plugins/core/installer.ts)
export class PluginInstaller {
  register(plugin: PluginDefinition): void
  async installOne(app: App, name: string): Promise<PluginInstallResult>
  async installAll(app: App, config: PluginConfigMap): Promise<PluginInstallResult[]>
}

// 3. 插件注册中心(src/plugins/registry.ts)
export function getAllPlugins(): PluginDefinition[]
export function getDefaultPluginConfig(): PluginConfigMap

// 4. 应用初始化器(src/bootstrap/app-initializer.ts)
export class AppInitializer {
  async initPlugins(app: App): Promise<InitResult>
  async loadStyles(): Promise<InitResult>
  async initFeatures(app: App, router: Router): Promise<InitResult>
}

每个模块只做一件事,职责清晰,易于测试和维护。

2.3 类型安全优先

新架构使用 TypeScript 泛型实现强类型约束:

// 新架构:强类型插件定义
export interface PluginDefinition<T = any> {
  readonly metadata: PluginMetadata
  readonly defaultOptions?: T
  install: PluginInstallFn<T>
}

// 使用示例:编译时类型检查
interface PiniaOptions {
  enablePersistence: boolean
  enableDevtools: boolean
}

const piniaPlugin: PluginDefinition<PiniaOptions> = {
  metadata: { name: 'pinia', version: '1.0.0' },
  defaultOptions: {
    enablePersistence: true,
    enableDevtools: false,
  },
  install(app, options) {
    // options 类型为 PiniaOptions,有完整的智能提示
    if (options.enablePersistence) { }
  }
}

三、架构设计:分层与分阶段

3.1 新架构分层设计

src/
├── plugins/
│   ├── core/                    # 核心层
│   │   ├── types.ts            # 类型定义(80行)
│   │   └── installer.ts        # 插件安装器(180行)
│   ├── registry.ts             # 注册层(45行)
│   ├── piniaStateManager.ts    # 具体插件
│   ├── i18nSystem.ts
│   └── consoleInterceptor.ts
├── bootstrap/
│   └── app-initializer.ts      # 初始化层(250行)
└── main.ts                      # 入口层(140行)

分层职责

  1. 核心层(Core):提供插件系统的基础能力

    • 类型定义:定义插件接口和配置类型
    • 插件安装器:负责插件的注册和安装
  2. 注册层(Registry):管理插件清单和默认配置

    • 插件列表:返回所有可用插件
    • 默认配置:提供插件的默认选项
  3. 初始化层(Bootstrap):协调应用启动流程

    • 分阶段初始化:核心 → 插件 → 样式 → 功能
    • 错误处理:统一的错误捕获和降级策略
  4. 入口层(Main):应用启动入口

    • 核心依赖初始化:Vue、Router、Pinia、i18n
    • 调用初始化器:启动应用

3.2 分阶段初始化流程

新架构采用清晰的分阶段初始化策略:

// 初始化阶段定义
export enum InitPhase {
  CORE = 'core',           // 核心依赖(阻塞渲染)
  PLUGINS = 'plugins',     // 插件系统(阻塞渲染)
  STYLES = 'styles',       // 样式资源(不阻塞交互)
  FEATURES = 'features',   // 非关键功能(不阻塞交互)
}

启动时序图

时间轴 ────────────────────────────────────────────────────>
       │
       ├─ 1. 初始化核心依赖(同步,阻塞)
       │    ├─ 创建 Vue 应用
       │    ├─ 安装 Router、Pinia、i18n
       │    └─ 注册全局指令和组件
       │
       ├─ 2. 挂载应用到 DOM(同步,阻塞)
       │    └─ app.mount('#app')  ← 用户看到界面
       │
       ├─ 3. 初始化插件系统(异步,不阻塞渲染)
       │    ├─ 注册所有插件
       │    └─ 按优先级安装插件
       │
       ├─ 4. 加载样式资源(异步,不阻塞交互)
       │    ├─ 加载 SCSS 样式
       │    └─ 加载图标字体
       │
       └─ 5. 初始化非关键功能(异步,不阻塞交互)
            ├─ 错误处理系统
            ├─ 资源预加载器
            └─ 性能监控

关键优化点

  1. 尽早挂载:核心依赖初始化后立即挂载,让用户尽快看到界面
  2. 异步加载:插件、样式、功能模块异步加载,不阻塞首次渲染
  3. 降级启动:如果插件安装失败,仍能启动核心功能

3.3 错误处理与降级策略

async function bootstrap() {
  try {
    // 1. 初始化核心依赖(必须成功)
    const app = initCore()
    
    // 2. 挂载应用(必须成功)
    app.mount('#app')
    
    // 3. 初始化插件(失败则降级)
    const initializer = new AppInitializer()
    await initializer.initPlugins(app)
    
    // 4. 加载样式(失败不影响功能)
    initializer.loadStyles().catch(error => {
      logger.warn('样式加载失败', error)
    })
    
    // 5. 初始化功能(失败不影响核心)
    initializer.initFeatures(app, router).catch(error => {
      logger.warn('功能初始化失败', error)
    })
  } catch (error) {
    // 降级启动:只加载核心功能
    logger.warn('尝试降级启动')
    const app = initCore()
    app.mount('#app')
  }
}

四、核心实现:从理论到代码

4.1 类型定义:强类型约束

// src/plugins/core/types.ts

/**
 * 插件安装函数
 */
export type PluginInstallFn<T = any> = (
  app: App, 
  options?: T
) => void | Promise<void>

/**
 * 插件元数据
 */
export interface PluginMetadata {
  readonly name: string           // 插件唯一标识
  readonly version: string         // 插件版本
  readonly description?: string    // 插件描述
  readonly core?: boolean          // 是否为核心插件
  readonly priority?: number       // 安装优先级(越小越先安装)
}

/**
 * 插件定义
 */
export interface PluginDefinition<T = any> {
  readonly metadata: PluginMetadata
  readonly defaultOptions?: T
  install: PluginInstallFn<T>
}

/**
 * 插件安装结果
 */
export interface PluginInstallResult {
  name: string
  success: boolean
  duration: number
  error?: Error
}

设计亮点

  1. 泛型约束PluginDefinition<T> 使用泛型约束配置类型
  2. 只读属性readonly 防止元数据被意外修改
  3. 可选属性? 标记非必需字段,减少样板代码
  4. 结果类型PluginInstallResult 提供详细的安装反馈

4.2 插件安装器:轻量高效

// src/plugins/core/installer.ts

export class PluginInstaller {
  private readonly plugins = new Map<string, PluginDefinition>()
  private readonly installed = new Set<string>()
  private readonly options: Required<InstallerOptions>

  constructor(options: InstallerOptions = {}) {
    this.options = {
      debug: import.meta.env.DEV,
      timeout: 10000,
      ...options,
    }
  }

  /**
   * 注册插件
   */
  register(plugin: PluginDefinition): void {
    if (this.plugins.has(plugin.metadata.name)) {
      logger.warn(`插件 ${plugin.metadata.name} 已注册,将被覆盖`)
    }
    this.plugins.set(plugin.metadata.name, plugin)
  }

  /**
   * 安装单个插件(带超时保护)
   */
  async installOne(
    app: App,
    name: string,
    config?: any,
  ): Promise<PluginInstallResult> {
    const startTime = performance.now()
    const plugin = this.plugins.get(name)

    if (!plugin) {
      return {
        name,
        success: false,
        duration: performance.now() - startTime,
        error: new Error(`插件 ${name} 未注册`),
      }
    }

    try {
      const options = { ...plugin.defaultOptions, ...config }
      
      // 关键:带超时的安装,防止插件卡死
      await Promise.race([
        plugin.install(app, options),
        new Promise((_, reject) =>
          setTimeout(() => reject(new Error('安装超时')), this.options.timeout),
        ),
      ])

      this.installed.add(name)
      return { 
        name, 
        success: true, 
        duration: performance.now() - startTime 
      }
    } catch (error) {
      return {
        name,
        success: false,
        duration: performance.now() - startTime,
        error: error as Error,
      }
    }
  }
}

设计亮点

  1. Map + Set 数据结构

    • Map<string, PluginDefinition> 存储插件定义,O(1) 查找
    • Set<string> 存储已安装插件,O(1) 去重检查
  2. 超时保护:使用 Promise.race 防止插件安装卡死

  3. 详细的结果反馈:返回 PluginInstallResult 包含成功状态、耗时、错误信息

  4. 核心插件保护

// 核心插件安装失败则抛出错误,阻止应用启动
if (!result.success && plugin.metadata.core) {
  throw new Error(`核心插件 ${plugin.metadata.name} 安装失败`)
}

4.3 应用初始化器:分阶段协调

// src/bootstrap/app-initializer.ts

export class AppInitializer {
  private readonly results: InitResult[] = []
  public readonly pluginInstaller: PluginInstaller

  /**
   * 初始化插件系统
   */
  async initPlugins(app: App): Promise<InitResult> {
    const startTime = performance.now()

    try {
      // 1. 注册所有插件
      const plugins = getAllPlugins()
      this.pluginInstaller.registerAll(plugins)

      // 2. 安装所有插件(按优先级排序)
      const config = getDefaultPluginConfig()
      await this.pluginInstaller.installAll(app, config)

      const duration = performance.now() - startTime
      logger.info(`✓ 插件系统初始化完成 (${duration.toFixed(2)}ms)`)

      return { phase: InitPhase.PLUGINS, success: true, duration }
    } catch (error) {
      logger.error('✗ 插件系统初始化失败', error)
      throw error
    }
  }

  /**
   * 加载样式资源(允许部分失败)
   */
  async loadStyles(): Promise<InitResult> {
    const styleModules = [
      () => import('@/assets/styles/var.scss'),
      () => import('@/assets/styles/index.scss'),
      () => import('@/assets/iconfont/iconfont.css'),
    ]

    // 并行加载,使用 Promise.allSettled 允许部分失败
    const results = await Promise.allSettled(
      styleModules.map(fn => this.retryImport(fn, 3)),
    )

    const failed = results.filter(r => r.status === 'rejected').length
    if (failed > 0) {
      logger.warn(`${failed}/${styleModules.length} 个样式资源加载失败`)
    }

    return { 
      phase: InitPhase.STYLES, 
      success: failed === 0, 
      duration: performance.now() - startTime 
    }
  }
}

设计亮点

  1. 结果收集:每个阶段的初始化结果都被记录,便于调试和监控

  2. 容错设计

    • 核心插件失败 → 抛出错误,阻止启动
    • 样式加载失败 → 记录警告,继续启动
    • 功能初始化失败 → 记录警告,继续启动
  3. 重试机制

private async retryImport<T>(
  importFn: () => Promise<T>,
  retries = 3,
  delay = 1000,
): Promise<T> {
  for (let i = 0; i <= retries; i++) {
    try {
      return await importFn()
    } catch (error) {
      if (i === retries) throw error
      await new Promise(resolve => setTimeout(resolve, delay * (i + 1)))
    }
  }
}

4.4 入口文件:清晰的启动流程

// src/main.ts

async function bootstrap() {
  const totalStartTime = performance.now()

  try {
    // 1. 初始化核心依赖(同步,阻塞)
    const app = initCore()

    // 2. 挂载应用(尽早显示界面)
    app.mount('#app')
    logger.info('✓ 应用已挂载到 DOM')

    // 3. 初始化插件系统(异步,不阻塞渲染)
    const initializer = new AppInitializer()
    await initializer.initPlugins(app)

    // 4. 加载样式资源(异步,不阻塞交互)
    initializer.loadStyles().catch((error) => {
      logger.warn('样式资源加载失败(不影响功能)', error)
    })

    // 5. 初始化非关键功能(异步,不阻塞交互)
    initializer.initFeatures(app, router).catch((error) => {
      logger.warn('非关键功能初始化失败(不影响核心功能)', error)
    })

    // 输出初始化摘要
    const summary = initializer.getSummary()
    logger.info('🎉 应用启动完成', summary)

    // 暴露调试工具(仅开发环境)
    if (import.meta.env.DEV) {
      window.__APP_INITIALIZER__ = initializer
      window.__PLUGIN_INSTALLER__ = initializer.pluginInstaller
    }
  } catch (error) {
    // 降级启动:只加载核心功能
    logger.warn('⚠️ 尝试降级启动(仅核心功能)')
    const app = initCore()
    app.mount('#app')
  }
}

五、性能优化:数据说话

5.1 性能测试方法

使用 Chrome DevTools Performance 和自定义性能监控进行测试:

// 性能监控代码
const startTime = performance.now()

// ... 执行操作

const duration = performance.now() - startTime
console.log(`操作耗时: ${duration.toFixed(2)}ms`)

测试环境:

  • CPU: Intel i7-10700K
  • 内存: 32GB
  • 浏览器: Chrome 120
  • 网络: Fast 3G(模拟真实网络环境)

5.2 性能对比数据

启动时间对比

指标 旧架构 新架构 改进
核心依赖初始化 280ms 250ms ↓ 11%
插件系统初始化 300ms 180ms ↓ 40%
首次内容绘制(FCP) 850ms 600ms ↓ 29%
首次渲染(FMP) 1200ms 800ms ↓ 33%
可交互时间(TTI) 1500ms 980ms ↓ 35%

内存占用对比

指标 旧架构 新架构 改进
初始堆内存 45MB 38MB ↓ 16%
插件系统内存 8MB 5MB ↓ 38%
运行时峰值内存 120MB 105MB ↓ 13%

代码体积对比

模块 旧架构 新架构 改进
类型定义 150行 80行 ↓ 47%
插件管理器 425行 180行 ↓ 58%
注册中心 93行 45行 ↓ 52%
初始化逻辑 169行 390行 +131%
总计 837行 615行 ↓ 26.5%

注:初始化逻辑增加是因为职责分离,将原本混在 main.ts 中的逻辑提取到 app-initializer.ts。

5.3 性能提升的关键因素

1. 移除 EventEmitter 开销

// 旧架构:每次操作都触发事件
class PluginManager extends EventEmitter {
  async install(name: string) {
    this.emit('before:install', name)  // 遍历监听器
    await plugin.install(app)
    this.emit('after:install', name)   // 遍历监听器
  }
}

// 新架构:直接执行,无额外开销
class PluginInstaller {
  async installOne(app: App, name: string) {
    await plugin.install(app, options)
    this.installed.add(name)
  }
}

节省时间:每个插件节省 ~15ms,3 个插件共节省 ~45ms

2. 优化数据结构

// 旧架构:数组查找 O(n)
private plugins: PluginDefinition[] = []
private installed: string[] = []

isInstalled(name: string) {
  return this.installed.includes(name)  // O(n)
}

// 新架构:Map/Set 查找 O(1)
private plugins = new Map<string, PluginDefinition>()
private installed = new Set<string>()

isInstalled(name: string) {
  return this.installed.has(name)  // O(1)
}

节省时间:查找操作从 O(n) 降到 O(1),大量调用时效果显著

3. 移除无用的依赖检查

// 旧架构:每次安装都检查依赖(实际没有依赖)
async install(name: string) {
  await this.checkDependencies(name)  // 遍历依赖树,耗时 ~45ms
  await plugin.install(app)
}

// 新架构:使用 priority 控制顺序,无需依赖检查
const sortedPlugins = plugins.sort(
  (a, b) => (a.metadata.priority ?? 100) - (b.metadata.priority ?? 100)
)

节省时间:移除依赖检查,节省 ~45ms

4. 分阶段异步加载

// 旧架构:所有资源串行加载,阻塞渲染
async function bootstrap() {
  await initCore()
  await initPlugins()
  await loadStyles()
  await initFeatures()
  app.mount('#app')  // 最后才挂载
}

// 新架构:尽早挂载,异步加载非关键资源
async function bootstrap() {
  const app = initCore()
  app.mount('#app')  // 尽早挂载,用户看到界面
  
  await initPlugins()  // 关键插件
  loadStyles()         // 异步,不阻塞
  initFeatures()       // 异步,不阻塞
}

用户体验提升

  • 旧架构:用户等待 1500ms 才看到界面
  • 新架构:用户等待 800ms 就看到界面,提升 47%

六、迁移过程:渐进式重构

6.1 并行运行策略

为了降低风险,我采用了并行运行策略:

步骤 1:创建 V2 版本文件

src/plugins/
├── core/                    # 新架构
│   ├── types.ts
│   └── installer.ts
├── registry-v2.ts           # 新架构
├── PluginManager.ts         # 旧架构(保留)
├── types.ts                 # 旧架构(保留)
└── registry.ts              # 旧架构(保留)

src/
├── main.ts                  # 旧入口(保留)
├── main-v2.ts               # 新入口
└── bootstrap/
    └── app-initializer.ts   # 新架构

步骤 2:配置双入口

// vite.config.ts
export default defineConfig({
  server: { port: 5173 },  // 旧架构端口
  // ...
})

// vite.config.v2.ts
export default defineConfig({
  server: { port: 5174 },  // 新架构端口
  // ...
})
// package.json
{
  "scripts": {
    "dev": "vite",           // 旧架构
    "dev:v2": "vite --config vite.config.v2.ts"  // 新架构
  }
}

步骤 3:对比测试

# 终端 1:运行旧架构
npm run dev

# 终端 2:运行新架构
npm run dev:v2

# 对比测试:
# - 功能完整性
# - 性能指标
# - 错误率
# - 内存占用

测试清单

  • 所有页面正常渲染
  • 路由跳转正常
  • 状态管理正常
  • 国际化切换正常
  • 错误处理正常
  • 性能指标达标
  • 无控制台错误

6.2 完全迁移

经过 2 周的并行测试,确认新架构稳定后,执行完全迁移:

步骤 1:备份旧文件

mkdir src/plugins/legacy
mv src/plugins/PluginManager.ts src/plugins/legacy/
mv src/plugins/types.ts src/plugins/legacy/
mv src/plugins/registry.ts src/plugins/legacy/
mv src/plugins/index.ts src/plugins/legacy/

步骤 2:重命名新文件

# 插件系统
mv src/plugins/registry-v2.ts src/plugins/registry.ts

# 入口文件
mv src/main.ts src/main-legacy.ts
mv src/main-v2.ts src/main.ts

# Vite 配置
mv vite.config.ts vite.config-legacy.ts
mv vite.config.v2.ts vite.config.ts

# HTML 入口
mv index.html index-legacy.html
mv index-v2.html index.html

步骤 3:更新配置

// vite.config.ts - 改回默认端口
export default defineConfig({
  server: { port: 5173 },  // 改回 5173
  // ...
})
// package.json - 保留回滚选项
{
  "scripts": {
    "dev": "vite",                              // 新架构(默认)
    "dev:legacy": "vite --config vite.config-legacy.ts"  // 旧架构(回滚)
  }
}

步骤 4:创建新的导出文件

// src/plugins/index.ts - 新的统一导出
export * from './core/types'
export * from './core/installer'
export * from './registry'

6.3 回滚方案

如果新架构出现问题,可以快速回滚:

# 方案 1:使用 legacy 脚本
npm run dev:legacy

# 方案 2:恢复旧文件(如果已删除)
cp src/plugins/legacy/* src/plugins/
cp src/main-legacy.ts src/main.ts
cp vite.config-legacy.ts vite.config.ts

七、踩坑与经验

7.1 踩过的坑

坑 1:过早优化

最初我想实现插件的懒加载、热重载等高级功能,但后来发现:

// ❌ 过度设计:实现了懒加载,但从未使用
class PluginManager {
  async lazyLoad(name: string) {
    const plugin = await import(`./plugins/${name}`)
    this.register(plugin.default)
  }
}

// ✅ 实际需求:所有插件都在启动时加载
const plugins = [
  piniaStateManagerPlugin,
  i18nSystemPlugin,
  consoleInterceptorPlugin,
]

教训:先实现核心功能,等真正需要时再优化。

坑 2:类型定义过于宽泛

// ❌ 旧架构:any 类型导致运行时错误
interface PluginDefinition {
  install: (app: App, options?: any) => void
}

// 使用时没有类型检查
pluginManager.install('pinia', { 
  enableDevtoolz: true  // 拼写错误,运行时才发现
})

// ✅ 新架构:强类型约束
interface PiniaOptions {
  enableDevtools: boolean  // 明确的类型
}

const piniaPlugin: PluginDefinition<PiniaOptions> = {
  install(app, options) {
    options.enableDevtoolz  // 编译时报错:属性不存在
  }
}

教训:充分利用 TypeScript 的类型系统,在编译时发现错误。

坑 3:忽略降级策略

最初的实现中,如果插件安装失败,整个应用就无法启动:

// ❌ 旧实现:插件失败导致应用崩溃
async function bootstrap() {
  await initPlugins()  // 失败则抛出错误
  app.mount('#app')    // 永远不会执行
}

// ✅ 新实现:降级启动
async function bootstrap() {
  try {
    const app = initCore()
    app.mount('#app')  // 先挂载,保证基本可用
    await initPlugins()
  } catch (error) {
    logger.warn('插件初始化失败,使用降级模式')
    // 应用仍然可用,只是缺少部分功能
  }
}

教训:关键系统必须有降级方案,保证核心功能可用。

坑 4:性能测试不充分

最初只在开发环境测试,生产环境发现性能问题:

// 问题:开发环境的日志输出影响性能
if (this.options.debug) {
  console.log('Installing plugin:', name)  // 开发环境
}

// 解决:使用专业的日志系统
logger.debug('Installing plugin:', name)  // 生产环境自动禁用

教训:在接近生产环境的条件下测试性能。

7.2 最佳实践总结

1. 遵循 YAGNI 原则

// ❌ 不要实现"可能会用到"的功能
class PluginManager {
  enable() { }    // 从未使用
  disable() { }   // 从未使用
  uninstall() { } // 从未使用
}

// ✅ 只实现当前需要的功能
class PluginInstaller {
  register() { }  // 需要
  install() { }   // 需要
}

2. 职责单一

// ❌ 一个类做太多事情
class PluginManager {
  register() { }      // 注册
  install() { }       // 安装
  checkDeps() { }     // 依赖检查
  emit() { }          // 事件管理
}

// ✅ 每个类只做一件事
class PluginInstaller { install() { } }
function getAllPlugins() { }
class AppInitializer { init() { } }

3. 类型安全优先

// ❌ 使用 any 类型
function install(options?: any) { }

// ✅ 使用泛型约束
function install<T>(options?: T) { }

// ✅✅ 更好:明确的类型定义
interface PluginDefinition<T = any> {
  install: (app: App, options?: T) => void
}

4. 性能监控

// 在关键路径添加性能监控
async function install(name: string) {
  const startTime = performance.now()
  
  await plugin.install(app)
  
  const duration = performance.now() - startTime
  logger.info(`Plugin ${name} installed in ${duration.toFixed(2)}ms`)
}

5. 错误处理分级

// 核心功能:失败则抛出错误
async initCore() {
  if (!app) throw new Error('Failed to create app')
}

// 非关键功能:失败则记录警告
async loadStyles() {
  try {
    await import('./styles.css')
  } catch (error) {
    logger.warn('Style loading failed', error)
    // 不抛出错误,应用继续运行
  }
}

6. 渐进式迁移

阶段 1: 创建 V2 版本(并行运行)
  
阶段 2: 对比测试(2 周)
  
阶段 3: 灰度发布(10%  50%  100%)
  
阶段 4: 完全迁移
  
阶段 5: 清理旧代码

7.3 调试技巧

1. 暴露调试工具到全局

// 开发环境暴露调试工具
if (import.meta.env.DEV) {
  window.__APP_INITIALIZER__ = initializer
  window.__PLUGIN_INSTALLER__ = initializer.pluginInstaller
}

// 在控制台调试
window.__PLUGIN_INSTALLER__.getInstalled()
// ['pinia', 'i18n', 'consoleInterceptor']

2. 详细的日志输出

// 使用结构化日志
logger.info('🎉 应用启动完成', {
  总耗时: `${totalDuration.toFixed(2)}ms`,
  初始化阶段: summary.phases,
  插件列表: installer.getInstalled(),
})

// 控制台输出:
// 🎉 应用启动完成
// {
//   总耗时: "980.23ms",
//   初始化阶段: [
//     { phase: "plugins", success: true, duration: 180.45 },
//     { phase: "styles", success: true, duration: 120.33 },
//     { phase: "features", success: true, duration: 95.12 }
//   ],
//   插件列表: ["pinia", "i18n", "consoleInterceptor"]
// }

3. Chrome DevTools Performance

使用 Performance 面板分析启动流程:

  1. 打开 DevTools → Performance
  2. 点击录制按钮
  3. 刷新页面
  4. 停止录制
  5. 分析火焰图,找出耗时操作

八、实际效果与收益

8.1 量化收益

开发体验提升

  • 代码可读性:从 6/10 提升到 9/10(团队评分)
  • 调试效率:定位问题时间从 30 分钟降到 10 分钟
  • 新人上手时间:从 2 天降到 0.5 天

性能提升

  • 首次渲染时间:1200ms → 800ms(↓ 33%)
  • 内存占用:45MB → 38MB(↓ 16%)
  • 代码体积:837 行 → 615 行(↓ 26.5%)

维护成本降低

  • 单元测试覆盖率:从 45% 提升到 85%
  • Bug 修复时间:从平均 2 小时降到 0.5 小时
  • 代码审查时间:从 30 分钟降到 15 分钟

8.2 团队反馈

开发者 A

"新架构太清晰了!我第一次看代码就能理解整个启动流程,不像以前要跳来跳去。"

开发者 B

"类型提示太棒了!以前配置插件经常拼写错误,现在编译时就能发现。"

技术负责人

"性能提升很明显,用户反馈页面加载速度快了很多。代码量减少也让维护更轻松。"

8.3 用户体验改善

加载速度对比(用户感知):

网络环境 旧架构 新架构 改善
Fast 3G 2.5s 1.6s ↓ 36%
Slow 3G 4.8s 3.2s ↓ 33%
WiFi 0.9s 0.6s ↓ 33%

用户满意度(问卷调查,N=50):

  • 加载速度满意度:72% → 91%
  • 页面响应速度:68% → 88%
  • 整体体验:75% → 90%

九、经验总结与建议

9.1 何时应该重构

重构信号

  1. ✅ 代码使用率低于 50%(大量未使用的功能)
  2. ✅ 性能问题明显(启动时间 > 2s)
  3. ✅ 维护成本高(修改一个功能需要改多个文件)
  4. ✅ 新人上手困难(理解代码需要 > 1 天)
  5. ✅ 类型安全性差(大量 any 类型)

不应该重构的情况

  1. ❌ 代码运行良好,只是"看起来不够优雅"
  2. ❌ 没有明确的性能问题
  3. ❌ 团队没有足够的测试覆盖
  4. ❌ 项目处于紧急交付期

9.2 重构的关键原则

1. YAGNI(You Aren't Gonna Need It)

不要实现"可能会用到"的功能,只实现当前需要的。

2. KISS(Keep It Simple, Stupid)

简单的解决方案往往是最好的。

3. 职责单一(Single Responsibility)

每个模块只做一件事,做好一件事。

4. 渐进式迁移(Progressive Migration)

不要一次性重写所有代码,采用并行运行、灰度发布的策略。

5. 数据驱动(Data-Driven)

用性能数据、使用率数据指导重构决策,不要凭感觉。

9.3 给其他开发者的建议

1. 先分析,再动手

分析阶段(1-2 天):
├─ 统计代码使用率
├─ 分析性能瓶颈
├─ 识别设计问题
└─ 制定重构方案

实施阶段(1-2 周):
├─ 创建 V2 版本
├─ 并行测试
├─ 灰度发布
└─ 完全迁移

2. 保持向后兼容

// 提供兼容层,让旧代码平滑迁移
export class PluginManager {
  // 标记为废弃,但仍然可用
  /** @deprecated 使用 PluginInstaller 替代 */
  async install(name: string) {
    console.warn('PluginManager.install is deprecated')
    return this.installer.installOne(app, name)
  }
}

3. 充分测试

// 测试覆盖关键路径
describe('PluginInstaller', () => {
  it('应该成功安装插件', async () => { })
  it('应该处理安装失败', async () => { })
  it('应该防止重复安装', async () => { })
  it('应该按优先级排序', async () => { })
  it('应该处理超时', async () => { })
})

4. 文档先行

在重构前写好文档:

  • 为什么要重构(问题分析)
  • 怎么重构(设计方案)
  • 如何迁移(迁移指南)
  • 如何回滚(应急方案)

5. 团队沟通

  • 重构前:与团队讨论方案,达成共识
  • 重构中:定期同步进度,及时调整
  • 重构后:分享经验,总结教训

9.4 常见陷阱

陷阱 1:完美主义

// ❌ 追求完美,永远无法完成
class PluginInstaller {
  // 实现了 20 个方法,但只用到 3 个
}

// ✅ 先实现核心功能,后续迭代
class PluginInstaller {
  register() { }
  install() { }
  // 其他功能等需要时再加
}

陷阱 2:过度抽象

// ❌ 过度抽象,增加复杂度
abstract class BaseInstaller<T> {
  abstract install(item: T): void
}
class PluginInstaller extends BaseInstaller<Plugin> { }
class ModuleInstaller extends BaseInstaller<Module> { }

// ✅ 简单直接
class PluginInstaller {
  install(plugin: Plugin) { }
}

陷阱 3:忽略边界情况

// ❌ 只考虑正常流程
async install(name: string) {
  const plugin = this.plugins.get(name)
  await plugin.install(app)
}

// ✅ 处理边界情况
async install(name: string) {
  // 1. 插件不存在
  if (!this.plugins.has(name)) {
    throw new Error(`Plugin ${name} not found`)
  }
  
  // 2. 已经安装
  if (this.installed.has(name)) {
    return
  }
  
  // 3. 安装超时
  await Promise.race([
    plugin.install(app),
    timeout(10000)
  ])
}

陷阱 4:缺少监控

// ❌ 没有性能监控
async install(name: string) {
  await plugin.install(app)
}

// ✅ 添加性能监控
async install(name: string) {
  const startTime = performance.now()
  await plugin.install(app)
  const duration = performance.now() - startTime
  
  // 记录性能数据
  logger.info(`Plugin ${name} installed in ${duration}ms`)
  
  // 性能告警
  if (duration > 1000) {
    logger.warn(`Plugin ${name} installation is slow`)
  }
}

十、总结与展望

10.1 核心收获

这次重构让我深刻体会到:

  1. YAGNI 原则的威力:删除 80% 的未使用代码,性能提升 35%
  2. 职责分离的重要性:清晰的模块划分让代码易于理解和维护
  3. 类型安全的价值:TypeScript 的强类型约束在编译时就能发现大量错误
  4. 渐进式迁移的必要性:并行运行、灰度发布大大降低了风险
  5. 性能监控的关键性:数据驱动的优化比凭感觉更有效

10.2 架构对比总结

维度 旧架构 新架构 改进
代码行数 837 行 615 行 ↓ 26.5%
启动时间 1500ms 980ms ↓ 35%
内存占用 45MB 38MB ↓ 16%
类型安全 大量 any 强类型
可维护性 6/10 9/10 ↑ 50%
测试覆盖率 45% 85% ↑ 89%

10.3 未来优化方向

虽然新架构已经很好,但仍有优化空间:

1. 插件预加载

// 在空闲时预加载非关键插件
if ('requestIdleCallback' in window) {
  requestIdleCallback(() => {
    import('./plugins/analytics')
    import('./plugins/monitoring')
  })
}

2. 插件懒加载

// 按需加载插件
const lazyPlugins = {
  analytics: () => import('./plugins/analytics'),
  monitoring: () => import('./plugins/monitoring'),
}

// 用户触发某个功能时才加载对应插件
async function enableAnalytics() {
  const plugin = await lazyPlugins.analytics()
  installer.register(plugin.default)
  await installer.installOne(app, 'analytics')
}

3. 性能预算

// 设置性能预算,超出则告警
const PERFORMANCE_BUDGET = {
  pluginInstall: 200,  // 单个插件安装不超过 200ms
  totalStartup: 1000,  // 总启动时间不超过 1000ms
}

if (duration > PERFORMANCE_BUDGET.pluginInstall) {
  logger.error(`Performance budget exceeded: ${name} took ${duration}ms`)
}

4. 插件依赖管理

// 如果未来真的需要依赖管理,可以这样实现
interface PluginMetadata {
  name: string
  version: string
  dependencies?: string[]  // 依赖的插件名称
}

// 拓扑排序,确保依赖顺序
function sortPluginsByDependencies(plugins: PluginDefinition[]) {
  // 实现拓扑排序算法
}

10.4 写在最后

这次重构历时 3 周,从问题分析到完全迁移,每一步都很谨慎。最终的结果证明,简单的设计往往是最好的设计

如果你的项目也面临类似问题,希望这篇文章能给你一些启发。记住:

  • 🎯 先分析问题,再动手重构
  • 🧹 删除不需要的代码,比添加新功能更重要
  • 🔒 类型安全能在编译时发现大量错误
  • 🚀 性能优化要基于数据,不要凭感觉
  • 🛡️ 渐进式迁移能大大降低风险

最后,感谢团队成员的支持和配合,感谢用户的耐心等待。希望这次重构的经验能帮助到更多开发者!


附录:完整代码示例

A. 插件定义示例

// src/plugins/my-plugin.ts
import type { PluginDefinition } from './core/types'

export interface MyPluginOptions {
  apiKey: string
  timeout?: number
  debug?: boolean
}

const myPlugin: PluginDefinition<MyPluginOptions> = {
  metadata: {
    name: 'myPlugin',
    version: '1.0.0',
    description: '我的自定义插件',
    core: false,
    priority: 50,
  },
  defaultOptions: {
    timeout: 5000,
    debug: false,
  },
  async install(app, options) {
    // 验证配置
    if (!options.apiKey) {
      throw new Error('API key is required')
    }

    // 初始化插件
    const api = createAPI(options)
    
    // 注入到 Vue 应用
    app.provide('myPlugin', api)
    
    // 添加全局属性
    app.config.globalProperties.$myPlugin = api
    
    if (options.debug) {
      console.log('MyPlugin initialized with options:', options)
    }
  },
}

export default myPlugin

B. 使用插件示例

// 在组件中使用插件
<script setup lang="ts">
import { inject } from 'vue'
import type { MyPluginAPI } from '@/plugins/my-plugin'

// 方式 1:使用 inject
const myPlugin = inject<MyPluginAPI>('myPlugin')

// 方式 2:使用全局属性
import { getCurrentInstance } from 'vue'
const instance = getCurrentInstance()
const myPlugin = instance?.appContext.config.globalProperties.$myPlugin

// 使用插件功能
async function fetchData() {
  const data = await myPlugin?.fetch('/api/data')
  console.log(data)
}
</script>

C. 测试示例

// src/plugins/core/installer.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { createApp } from 'vue'
import { PluginInstaller } from './installer'
import type { PluginDefinition } from './types'

describe('PluginInstaller', () => {
  let installer: PluginInstaller
  let app: ReturnType<typeof createApp>

  beforeEach(() => {
    installer = new PluginInstaller({ debug: false })
    app = createApp({})
  })

  it('应该成功注册插件', () => {
    const plugin: PluginDefinition = {
      metadata: { name: 'test', version: '1.0.0' },
      install: () => {},
    }

    installer.register(plugin)
    expect(installer['plugins'].has('test')).toBe(true)
  })

  it('应该成功安装插件', async () => {
    let installed = false
    const plugin: PluginDefinition = {
      metadata: { name: 'test', version: '1.0.0' },
      install: () => { installed = true },
    }

    installer.register(plugin)
    const result = await installer.installOne(app, 'test')

    expect(result.success).toBe(true)
    expect(installed).toBe(true)
    expect(installer.isInstalled('test')).toBe(true)
  })

  it('应该处理安装失败', async () => {
    const plugin: PluginDefinition = {
      metadata: { name: 'test', version: '1.0.0' },
      install: () => { throw new Error('Install failed') },
    }

    installer.register(plugin)
    const result = await installer.installOne(app, 'test')

    expect(result.success).toBe(false)
    expect(result.error?.message).toBe('Install failed')
  })

  it('应该防止重复安装', async () => {
    let installCount = 0
    const plugin: PluginDefinition = {
      metadata: { name: 'test', version: '1.0.0' },
      install: () => { installCount++ },
    }

    installer.register(plugin)
    await installer.installOne(app, 'test')
    await installer.installOne(app, 'test')

    expect(installCount).toBe(1)
  })
})

相关资源

如果这篇文章对你有帮助,欢迎点赞、收藏、分享!有任何问题也欢迎在评论区讨论。

高德地图与Three.js结合实现3D大屏可视化

作者 孙_华鹏
2025年12月31日 09:17

高德地图与Three.js结合实现3D大屏可视化

文末源码地址及视频演示

前言

在智慧城市安全管理场景中,如何将真实的地理信息与3D模型完美结合,实现沉浸式的可视化监控体验?本文将以巡逻犬管理系统的大屏预览功能为例,详细介绍如何通过高德地图API与Three.js深度结合,实现3D机械狗模型在地图上的实时巡逻展示。

1 整体效果 全屏展示.png

该系统实现了以下核心功能:

  • 在高德地图上加载并渲染3D机械狗模型
  • 实现模型沿预设路线的自动巡逻动画
  • 镜头自动跟随模型移动,提供沉浸式监控体验
  • 实时显示巡逻进度、告警信息等业务数据

技术栈

  • 高德地图 JS API 2.0:提供地图底图和空间定位能力
  • Three.js r157:3D模型渲染和动画控制
  • Loca 2.0:高德地图数据可视化API,用于镜头跟随
  • React + TypeScript:前端框架和类型支持
  • TWEEN.js:补间动画库,用于平滑的模型移动

一、高德地图初始化

1.1 地图配置

首先需要配置高德地图的加载参数,包括API Key、版本号等:

// src/utils/amapConfig.ts
export const mapConfig = {
  key: 'your-amap-key',
  version: '2.0',
  Loca: {
    version: '2.0.0',  // Loca版本需与地图版本一致
  },
};

// 初始化安全配置(必须在AMapLoader.load之前调用)
export const initAmapSecurity = () => {
  if (typeof window !== 'undefined') {
    (window as any)._AMapSecurityConfig = {
      securityJsCode: 'your-security-code',
    };
  }
};

1.2 创建地图实例

使用AMapLoader.load加载地图API,然后创建地图实例:

// 设置安全密钥
initAmapSecurity();

// 加载高德地图
const AMap = await AMapLoader.load(mapConfig);

// 创建地图实例,开启3D视图模式
const mapInstance = new AMap.Map(mapContainerRef.current, {
  zoom: 13,
  center: defaultCenter,
  viewMode: '3D',  // 关键:必须开启3D模式
  resizeEnable: true,
});

2 渲染高德地图日志.png

关键点

  • viewMode: '3D' 必须设置,否则无法使用3D相关功能
  • 需要提前设置安全密钥,否则会报错

1.3 初始化Loca容器

Loca是高德地图的数据可视化容器,用于实现镜头跟随等功能:

const loca = new (window as any).Loca.Container({
  map: mapInstance,
  zIndex: 9
});

二、创建GLCustomLayer自定义图层

GLCustomLayer是高德地图提供的WebGL自定义图层,允许我们在地图上渲染Three.js内容。

2.1 图层结构

const customLayer = new AMap.GLCustomLayer({
  zIndex: 200,  // 图层层级,确保模型在最上层
  init: async (gl: any) => {
    // 在这里初始化Three.js场景、相机、渲染器等
  },
  render: () => {
    // 在这里执行每帧的渲染逻辑
  },
});

mapInstance.add(customLayer);

2.2 初始化Three.js场景

init方法中创建Three.js的核心组件:

init: async (gl: any) => {
  // 1. 创建透视相机
  const camera = new THREE.PerspectiveCamera(
    60,  // 视野角度
    window.innerWidth / window.innerHeight,  // 宽高比
    100,  // 近裁剪面
    1 << 30  // 远裁剪面(使用位运算表示大数值)
  );
  
  // 2. 创建WebGL渲染器
  const renderer = new THREE.WebGLRenderer({
    context: gl,  // 使用地图提供的WebGL上下文
    antialias: false,  // 禁用抗锯齿,减少WebGL扩展需求
    powerPreference: 'default',
  });
  renderer.autoClear = false;  // 必须设置为false,否则地图底图无法显示
  renderer.shadowMap.enabled = false;  // 禁用阴影,避免WebGL扩展问题
  
  // 3. 创建场景
  const scene = new THREE.Scene();
  
  // 4. 添加光源
  const ambientLight = new THREE.AmbientLight(0xffffff, 1.0);
  scene.add(ambientLight);
  
  const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
  directionalLight.position.set(1000, -100, 900);
  scene.add(directionalLight);
}

关键点

  • renderer.autoClear = false 必须设置,否则会清除地图底图
  • 使用地图提供的gl上下文创建渲染器,实现资源共享

3 坐标轴辅助线.png

三、坐标系统转换

高德地图使用经纬度坐标(WGS84),而Three.js使用3D世界坐标,两者之间的转换是关键。

3.1 获取自定义坐标系统

地图实例提供了customCoords工具,用于坐标转换:

// 获取自定义坐标系统
const customCoords = mapInstance.customCoords;

// 设置坐标系统中心点(重要:必须在设置模型位置前设置)
const center = mapInstance.getCenter();
customCoords.setCenter([center.lng, center.lat]);

3.2 经纬度转3D坐标

使用lngLatsToCoords方法将经纬度转换为Three.js坐标:

// 将经纬度 [lng, lat] 转换为Three.js坐标 [x, z, y?]
const position = customCoords.lngLatsToCoords([
  [120.188767, 30.193832]
])[0];

// 注意:返回的数组格式为 [x, z, y?]
// position[0] 对应 Three.js 的 z 轴(纬度)
// position[1] 对应 Three.js 的 x 轴(经度)
// position[2] 对应 Three.js 的 y 轴(高度,可选)

robotGroup.position.setX(position[1]);  // x坐标(经度)
robotGroup.position.setZ(position[0]);  // z坐标(纬度)
robotGroup.position.setY(position.length > 2 ? position[2] : 0);  // y坐标(高度)

坐标轴对应关系

  • 高德地图:X轴(经度),Y轴(纬度),Z轴(高度)
  • Three.js:X轴(右),Y轴(上),Z轴(前)
  • 转换后:position[1] → Three.js X轴,position[0] → Three.js Z轴

3.3 同步相机参数

render方法中,需要同步高德地图的相机参数到Three.js相机:

render: () => {
  const { near, far, fov, up, lookAt, position } = customCoords.getCameraParams();
  
  // 同步相机参数
  camera.near = near;
  camera.far = far;
  camera.fov = fov;
  camera.position.set(position[0], position[1], position[2]);
  camera.up.set(up[0], up[1], up[2]);
  camera.lookAt(lookAt[0], lookAt[1], lookAt[2]);
  camera.updateProjectionMatrix();
  
  // 渲染场景
  renderer.render(scene, camera);
  
  // 必须执行:重新设置three的gl上下文状态
  renderer.resetState();
}

四、加载3D模型

4.1 使用GLTFLoader加载模型

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';

const loader = new GLTFLoader();
const modelPath = '/assets/modules/robot_dog/scene.gltf';

const gltf = await new Promise<any>((resolve, reject) => {
  loader.load(
    modelPath,
    (gltf: any) => resolve(gltf),
    (progress: any) => {
      if (progress.total > 0) {
        const percent = (progress.loaded / progress.total) * 100;
        console.log('模型加载进度:', percent.toFixed(2) + '%');
      }
    },
    reject
  );
});

const robotModel = gltf.scene;

4.2 模型预处理

加载模型后需要进行预处理,包括材质优化、位置调整等:

// 遍历模型所有子对象
robotModel.traverse((child: THREE.Object3D) => {
  if (child instanceof THREE.Mesh) {
    // 禁用阴影相关功能
    child.castShadow = false;
    child.receiveShadow = false;
    
    // 简化材质,避免使用需要WebGL扩展的高级特性
    if (child.material) {
      const materials = Array.isArray(child.material) 
        ? child.material 
        : [child.material];
      
      materials.forEach((mat: any) => {
        // 禁用transmission等高级特性
        if (mat.transmission !== undefined) {
          mat.transmission = 0;
        }
      });
    }
  }
});

// 计算模型边界框并居中
const box = new THREE.Box3().setFromObject(robotModel);
const center = box.getCenter(new THREE.Vector3());

// 将模型居中(X和Z轴)
robotModel.position.x = -center.x;
robotModel.position.z = -center.z;
// 将模型底部放在y=0
robotModel.position.y = -box.min.y;

// 设置模型缩放
const scale = 15;
robotModel.scale.set(scale, scale, scale);

4.3 创建模型组并设置初始旋转

由于高德地图和Three.js的坐标系差异,需要调整模型的初始旋转:

// 创建外层Group用于位置和旋转控制
const robotGroup = new THREE.Group();
robotGroup.add(robotModel);

// 设置初始旋转(90, 90, 0)度转换为弧度
const initialRotationX = (Math.PI / 180) * 90;
const initialRotationY = (Math.PI / 180) * 90;
const initialRotationZ = (Math.PI / 180) * 0;
robotGroup.rotation.set(initialRotationX, initialRotationY, initialRotationZ);

scene.add(robotGroup);

五、实现镜头跟随

5.1 使用Loca实现镜头跟随

高德地图的Loca API提供了viewControl.addTrackAnimate方法,可以实现镜头自动跟随路径移动:

// 计算路径总距离
let totalDistance = 0;
for (let i = 0; i < paths.length - 1; i++) {
  totalDistance += AMap.GeometryUtil.distance(paths[i], paths[i + 1]);
}

// 假设速度是 1.5 m/s
const speed = 1.5;
const duration = (totalDistance / speed) * 1000;  // 转换为毫秒

loca.viewControl.addTrackAnimate({
  path: paths,  // 镜头轨迹,二维数组
  duration: duration,  // 时长(毫秒)
  timing: [[0, 0.3], [1, 0.7]],  // 速率控制器
  rotationSpeed: 180,  // 每秒旋转多少度
}, function () {
  console.log('单程巡逻完成');
  // 可以在这里处理往返逻辑
});

loca.animate.start();  // 启动动画

5.2 模型位置同步

render方法中,根据地图中心点实时更新模型位置:

render: () => {
  // ... 同步相机参数代码 ...
  
  if (robotGroup && mapInstance && !patrolFinishedRef.current) {
    // 获取当前地图中心(镜头跟随会改变地图中心)
    const center = mapInstance.getCenter();
    if (center) {
      // 更新坐标系统中心点为地图中心点
      customCoords.setCenter([center.lng, center.lat]);
      
      // 将地图中心转换为Three.js坐标
      const position = customCoords.lngLatsToCoords([
        [center.lng, center.lat]
      ])[0];
      
      // 更新模型位置
      robotGroup.position.setX(position[1]);
      robotGroup.position.setZ(position[0]);
      robotGroup.position.setY(position.length > 2 ? position[2] : 0);
      
      // 更新模型旋转(根据地图旋转)
      const rotation = mapInstance.getRotation();
      if (rotation !== undefined) {
        const initialRotationY = (Math.PI / 180) * 90;
        robotGroup.rotation.y = initialRotationY + (rotation * Math.PI / 180);
      }
    }
  }
  
  // 渲染场景
  renderer.render(scene, camera);
  renderer.resetState();
}

关键点

  • 使用地图中心点作为模型位置,实现精确跟随
  • 在每次render中更新坐标系统中心点,确保坐标转换准确
  • 同步地图旋转角度到模型Y轴旋转

2025-12-21 11.55.23.gif

六、巡逻动画实现

6.1 启动巡逻

当模型加载完成并设置好初始位置后,可以启动巡逻动画:

const startPatrol = (paths: number[][], mapInstance: any, AMap: any) => {
  // 停止之前的巡逻
  TWEEN.removeAll();
  patrolFinishedRef.current = false;
  
  // 保存路径
  patrolPathsRef.current = paths;
  patrolIndexRef.current = 0;
  
  // 播放前进动画
  playAnimation('1LYP');  // 播放行走动画
  
  // 设置坐标系统中心点为路径起点
  const firstPoint = paths[0];
  customCoordsRef.current.setCenter([firstPoint[0], firstPoint[1]]);
  
  // 使用Loca实现镜头跟随
  const loca = locaRef.current;
  if (loca) {
    // ... addTrackAnimate 代码 ...
  }
  
  // 启动模型移动动画
  changeObject();
};

6.2 模型移动动画

使用TWEEN.js实现模型在路径点之间的平滑移动:

const changeObject = () => {
  if (patrolFinishedRef.current || patrolIndexRef.current >= patrolPathsRef.current.length - 1) {
    return;
  }

  const sp = patrolPathsRef.current[patrolIndexRef.current];
  const ep = patrolPathsRef.current[patrolIndexRef.current + 1];
  const s = new THREE.Vector2(sp[0], sp[1]);
  const e = new THREE.Vector2(ep[0], ep[1]);

  const speed = 0.03;
  const dis = AMap.GeometryUtil.distance(sp, ep);
  
  if (dis <= 0) {
    patrolIndexRef.current++;
    changeObject();
    return;
  }

  // 使用TWEEN实现平滑移动
  new TWEEN.Tween(s)
    .to(e.clone(), dis / speed / speedFactor)
    .start()
    .onUpdate((v) => {
      // 更新模型经纬度引用
      modelLngLatRef.current = [v.x, v.y];
      
      // 节流更新状态(每100ms更新一次)
      const now = Date.now();
      if (now - lastUpdateTimeRef.current > 100) {
        setCurrentLngLat([v.x, v.y]);
        checkSamplePoint([v.x, v.y], AMap);  // 检测取样点
        // 计算已巡逻长度
        updatePatrolledLength(v);
        lastUpdateTimeRef.current = now;
      }
    })
    .onComplete(() => {
      accumulatedLengthRef.current += dis;
      
      if (patrolIndexRef.current < patrolPathsRef.current.length - 2) {
        patrolIndexRef.current++;
        changeObject();  // 继续下一段
      } else {
        // 单程完成
        if (patrolMode !== '往返') {
          patrolFinishedRef.current = true;
          playAnimation('1Idle');  // 播放静止动画
        }
      }
    });
};

6.3 动画系统

模型支持多种动画(行走、静止、跳舞等),使用AnimationMixer管理:

// 设置动画系统
if (gltf.animations && gltf.animations.length > 0) {
  const mixer = new THREE.AnimationMixer(robotModel);
  
  // 创建所有动画动作
  const actions = new Map<string, THREE.AnimationAction>();
  gltf.animations.forEach((clip: THREE.AnimationClip) => {
    const action = mixer.clipAction(clip);
    action.setLoop(THREE.LoopRepeat);  // 循环播放
    actions.set(clip.name, action);
  });
  
  // 播放默认静止动画
  const defaultAction = actions.get('1Idle');
  if (defaultAction) {
    defaultAction.setEffectiveTimeScale(0.6);  // 设置播放速度
    defaultAction.fadeIn(0.3);
    defaultAction.play();
  }
}

// 在render循环中更新动画
const render = () => {
  requestAnimationFrame(() => {
    render();
  });
  
  // 更新动画混合器
  if (mixer) {
    const currentTime = performance.now();
    const delta = (currentTime - lastAnimationTime) / 1000;
    mixer.update(delta);
    lastAnimationTime = currentTime;
  }
  
  // 更新TWEEN动画
  TWEEN.update();
  
  // 渲染地图
  mapInstance.render();
};

图片略大,耐心等候

5 动画切换.gif

七、AI安全隐患自动检测与告警

系统集成了Coze AI大模型,实现了巡逻过程中的自动安全隐患检测和告警功能。当机械狗沿路线巡逻时,系统会在预设的取样点自动触发AI分析,识别潜在的安全隐患。

7.1 取样点计算

系统支持基于路线间隔的自动取样点计算,根据巡逻犬配置的取样间隔(如每50米、100米等),在路线上均匀分布取样点:

// 计算取样点(基于路线间隔)
const calculateSamplePoints = (
  paths: number[][], 
  sampleInterval: number, 
  AMap: any
): Array<{ lng: number; lat: number; distance: number }> => {
  const samplePoints: Array<{ lng: number; lat: number; distance: number }> = [];
  let accumulatedDistance = 0;
  
  // 从第一个点开始(0米处)
  samplePoints.push({
    lng: paths[0][0],
    lat: paths[0][1],
    distance: 0,
  });
  
  // 遍历路径,计算每个取样点
  for (let i = 0; i < paths.length - 1; i++) {
    const currentPoint = paths[i];
    const nextPoint = paths[i + 1];
    const segmentDistance = AMap.GeometryUtil.distance(currentPoint, nextPoint);
    
    // 检查当前段是否包含取样点
    while (accumulatedDistance + segmentDistance >= (samplePoints.length * sampleInterval)) {
      const targetDistance = samplePoints.length * sampleInterval;
      const distanceInSegment = targetDistance - accumulatedDistance;
      
      // 计算取样点在当前段中的位置(线性插值)
      const ratio = distanceInSegment / segmentDistance;
      const sampleLng = currentPoint[0] + (nextPoint[0] - currentPoint[0]) * ratio;
      const sampleLat = currentPoint[1] + (nextPoint[1] - currentPoint[1]) * ratio;
      
      samplePoints.push({
        lng: sampleLng,
        lat: sampleLat,
        distance: targetDistance,
      });
    }
    
    accumulatedDistance += segmentDistance;
  }
  
  return samplePoints;
};

关键点

  • 使用高德地图的GeometryUtil.distance计算路径段距离
  • 通过线性插值计算取样点的精确位置
  • 取样点从路线起点开始,按固定间隔均匀分布

7.2 自动触发检测

在巡逻过程中,系统实时检测模型位置是否到达取样点附近(±10米范围内):

// 检测是否到达取样点
const checkSamplePoint = (currentLngLat: [number, number], AMap: any) => {
  const patrolDog = currentPatrolDogRef.current;
  const route = currentRouteRefForSample.current;
  const area = currentAreaRefForSample.current;
  
  if (!patrolDog || !route || !patrolDog.cameraDeviceId) {
    return; // 没有绑定摄像头,不进行取样
  }

  // 检查取样方式(必须是"路线间隔"模式)
  if (patrolDog.sampleMode !== '路线间隔' || !patrolDog.sampleInterval) {
    return;
  }

  // 检查是否在取样点附近(±10米范围内)
  for (let i = 0; i < samplePointsRef.current.length; i++) {
    if (processedSamplePointsRef.current.has(i)) {
      continue; // 已处理过,跳过
    }

    const samplePoint = samplePointsRef.current[i];
    const distance = AMap.GeometryUtil.distance(
      [currentLngLat[0], currentLngLat[1]],
      [samplePoint.lng, samplePoint.lat]
    );

    // 在 ±10 米范围内,触发取样
    if (distance <= 10) {
      console.log(`✅ 到达取样点 ${i + 1}/${samplePointsRef.current.length}`);
      processedSamplePointsRef.current.add(i);
      
      // 异步调用 Coze API(不阻塞巡逻)
      analyzeSecurity(
        patrolDog,
        route,
        area,
        currentLngLat,
        AMap
      ).catch(error => {
        console.error('安全隐患分析失败:', error);
      });
      
      break; // 一次只处理一个取样点
    }
  }
};

关键点

  • 使用距离判断,避免重复触发
  • 异步调用AI分析,不阻塞巡逻动画
  • 使用Set记录已处理的取样点,确保每个点只处理一次

7.3 调用Coze API进行安全隐患分析

系统使用Coze平台的大模型工作流进行图像安全隐患分析:

// 调用 Coze API 进行安全隐患分析
const analyzeSecurity = async (
  patrolDog: PatrolDog,
  route: Route,
  area: Area | null,
  currentLngLat: [number, number],
  AMap: any
): Promise<void> => {
  try {
    // 1. 获取默认令牌
    await initDB();
    const tokens = await db.token.getAll();
    const validTokens = tokens.filter(token => Date.now() <= token.expireDate);
    if (validTokens.length === 0) {
      console.warn('没有可用的令牌,跳过安全隐患分析');
      return;
    }

    const defaultToken = validTokens.find(t => t.isDefault) || validTokens[0];
    
    // 2. 准备分析数据
    // 随机选择一张测试图片(实际应用中应使用摄像头实时抓拍)
    const randomImageUrl = imageUrlr[Math.floor(Math.random() * imageUrlr.length)];
    
    // 构建输入文本,描述当前巡逻场景
    const inputText = `${patrolDog.name}当前在${area?.name || '未知'}区域${route.name}巡逻时抓拍了一张照片。分析是否存在安全隐患`;
    
    // 3. 创建 Coze API 客户端
    const apiClient = new CozeAPI({
      token: defaultToken.token,
      baseURL: 'https://api.coze.cn',
      allowPersonalAccessTokenInBrowser: true,
    });

    // 4. 调用工作流
    const workflow_id = '7585585625312034858';
    const res = await apiClient.workflows.runs.create({
      workflow_id: workflow_id,
      parameters: {
        input: inputText,
        mediaUrl: randomImageUrl,
      },
    });

    // 5. 解析返回结果
    let analysisResult: { securityType: number; score: number; desc: string } | null = null;
    
    if (res.data) {
      const dataObj = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
      
      if (dataObj.output && typeof dataObj.output === 'string') {
        // 提取 markdown 代码块中的 JSON
        const jsonMatch = dataObj.output.match(/```json\s*([\s\S]*?)\s*```/) || 
                         dataObj.output.match(/```\s*([\s\S]*?)\s*```/);
        
        if (jsonMatch && jsonMatch[1]) {
          analysisResult = JSON.parse(jsonMatch[1].trim());
        } else {
          // 尝试直接解析 output 为 JSON
          analysisResult = JSON.parse(dataObj.output);
        }
      } else {
        analysisResult = dataObj;
      }
    }

    // 6. 判断是否是报警(securityType !== 0 且 score !== 0)
    if (analysisResult && analysisResult.securityType !== 0 && analysisResult.score !== 0) {
      // 保存到分析报警表
      const analysisAlert: Omit<AnalysisAlert, 'id' | 'createTime' | 'updateTime'> = {
        alertTime: Date.now(),
        patrolDogId: patrolDog.id!,
        patrolDogName: patrolDog.name,
        cameraDeviceId: patrolDog.cameraDeviceId,
        cameraDeviceName: patrolDog.cameraDeviceName,
        routeId: route.id!,
        routeName: route.name,
        areaId: area?.id,
        areaName: area?.name,
        securityType: analysisResult.securityType as 0 | 1 | 2 | 3 | 4 | 5,
        score: analysisResult.score,
        desc: analysisResult.desc,
        mediaUrl: randomImageUrl,
        input: inputText,
        status: '未处理',
      };

      await db.analysisAlert.add(analysisAlert);
      console.log('✅ 安全隐患告警已保存');
      
      // 更新告警列表(实时显示在大屏右侧)
      updateAlertList(patrolDog.id!, route.id!, area?.id);
    } else {
      console.log('未发现安全隐患,不保存报警');
    }
  } catch (error) {
    console.error('调用 Coze API 失败:', error);
  }
};

API返回结果格式

{
  "securityType": 1,  // 0=无隐患, 1=明火燃烟, 2=打架斗殴, 3=违章停车, 4=杂物堆放, 5=私搭乱建
  "score": 85,        // 严重程度评分 (0-100)
  "desc": "检测到明火,存在严重安全隐患"  // 详细描述
}

关键点

  • 使用@coze/api官方SDK调用工作流API
  • 支持多种安全隐患类型识别(明火燃烟、打架斗殴、违章停车等)
  • 自动保存告警记录,支持后续查询和处理
  • 告警信息实时显示在大屏右侧告警列表中

6 报警.png

7.4 Coze测试页面

系统提供了专门的Coze测试页面,方便开发者测试和调试AI分析功能。在Coze测试页面中,可以:

  1. 选择令牌:从已配置的Coze API令牌中选择(支持多个令牌管理)
  2. 输入分析文本:描述需要分析的场景
  3. 上传图片URL:提供需要分析的图片地址
  4. 自动填充功能:点击"自动填充"按钮,快速填充默认的测试数据
  5. 查看完整响应:显示Coze API的完整返回结果,包括解析后的JSON和原始响应
// Coze测试页面核心功能
const handleTest = async () => {
  const values = await form.validateFields();
  
  // 创建 Coze API 客户端
  const apiClient = new CozeAPI({
    token: values.token,
    baseURL: 'https://api.coze.cn',
    allowPersonalAccessTokenInBrowser: true,
  });

  // 调用工作流
  const workflow_id = '7585585625312034858';
  const res = await apiClient.workflows.runs.create({
    workflow_id: workflow_id,
    parameters: {
      input: values.input,
      mediaUrl: values.mediaUrl,
    },
  });

  // 解析并显示结果
  // ... 解析逻辑 ...
};

测试页面特性

  • 自动填充数据:提供默认的测试图片和文本,方便快速测试
  • 图片预览:实时预览输入的图片URL
  • 完整响应展示:显示API的完整响应,便于调试
  • 错误处理:友好的错误提示,帮助定位问题

请截图 Coze测试页面 自动填充功能 测试结果展示

使用场景

  • 测试新的安全隐患识别算法
  • 验证Coze API令牌是否有效
  • 调试API返回结果格式
  • 验证图片URL是否可被Coze解析

image.png

八、性能优化建议

7.1 渲染优化

  • 禁用不必要的WebGL扩展(如阴影、抗锯齿)
  • 使用requestAnimationFrame统一管理渲染循环
  • 合理设置模型LOD(细节层次)

7.2 内存管理

  • 及时清理不需要的TWEEN动画:TWEEN.removeAll()
  • 组件卸载时销毁Three.js资源
  • 模型加载后缓存,避免重复加载

7.3 坐标转换优化

  • 坐标系统中心点跟随地图中心,减少转换误差
  • 使用节流控制状态更新频率
  • 避免在render中进行复杂计算

九、常见问题解决

8.1 模型不显示

问题:模型加载成功但在地图上不可见

解决方案

  • 检查renderer.autoClear是否设置为false
  • 确认坐标转换是否正确(注意数组索引对应关系)
  • 检查模型缩放是否合适(可能太小或太大)

8.2 模型位置偏移

问题:模型位置与预期不符

解决方案

  • 确保在设置模型位置前调用customCoords.setCenter()
  • 检查坐标轴对应关系(position[1]对应X轴,position[0]对应Z轴)
  • 使用AxesHelper辅助调试坐标轴方向

8.3 镜头跟随不流畅

问题:镜头跟随有延迟或卡顿

解决方案

  • 调整rotationSpeed参数,控制旋转速度
  • 优化timing速率控制器,实现更平滑的加速减速
  • 检查render循环是否正常执行

十、总结

通过高德地图与Three.js的深度结合,我们成功实现了3D模型在地图上的实时展示和动画效果,并集成了AI大模型实现智能安全隐患检测。核心要点包括:

  1. GLCustomLayer是关键桥梁:通过自定义图层实现Three.js与高德地图的融合
  2. 坐标转换是核心:正确理解和使用customCoords进行坐标转换
  3. 镜头跟随提升体验:使用Loca API实现平滑的镜头跟随效果
  4. AI智能检测增强功能:集成Coze大模型实现自动安全隐患识别和告警
  5. 性能优化不可忽视:合理配置渲染参数,避免不必要的WebGL扩展

技术亮点

  • 虚实结合:真实地理信息与3D模型的完美融合
  • 智能检测:基于AI大模型的自动安全隐患识别
  • 实时告警:巡逻过程中的实时检测和告警推送
  • 可视化展示:沉浸式大屏监控体验

这种技术方案不仅适用于巡逻犬管理系统,还可以扩展到智慧城市、物流追踪、车辆监控、园区安防等多个场景,为空间数据可视化提供了强大的技术支撑。通过AI能力的集成,系统从传统的可视化展示升级为智能化的安全监控平台,实现了"看得见、管得住、能预警"的完整闭环。

参考资源

www.bilibili.com/video/BV18c…

Vue3 中 Lottie 动画库的使用指南

2025年12月31日 09:11

Lottie 是 Airbnb 开源的一款跨平台动画渲染库,能够将 AE(After Effects)制作的动画导出为 JSON 格式,并在 Web、iOS、Android 等平台无缝渲染,完美还原设计师的动画效果。在 Vue3 项目中集成 Lottie,既能提升页面交互体验,又能避免传统 GIF / 视频动画的性能问题和体积冗余。本文将详细讲解 Vue3 中 Lottie 的安装、基础使用、高级配置及实战技巧。

一、核心优势

在开始集成前,先了解 Lottie 适配 Vue3 项目的核心价值:

  1. 轻量化:JSON 动画文件体积远小于 GIF / 视频,且支持按需加载;
  2. 可交互:可通过代码控制动画播放、暂停、跳转、循环等,支持自定义交互逻辑;
  3. 矢量渲染:动画基于矢量,适配不同分辨率设备无模糊;
  4. Vue3 友好:支持组合式 API(Setup),可封装为通用组件,复用性强。

二、环境准备与安装

1. 依赖安装

Vue3 项目中推荐使用 lottie-web(官方 Web 端实现),通过 npm/yarn/pnpm 安装:

# npm
npm install lottie-web --save

# yarn
yarn add lottie-web

# pnpm
pnpm add lottie-web

2. 动画资源准备

Lottie 依赖 AE 导出的 JSON 动画文件,获取方式:

  • 设计师使用 AE 制作动画,通过 Bodymovin 插件导出 JSON 文件;
  • 从 Lottie 官方素材库获取免费动画:LottieFiles

将下载的 JSON 动画文件放入 Vue3 项目的 public 或 src/assets 目录(推荐 public,避免打包路径问题)。

三、基础使用:封装通用 Lottie 组件

为了在项目中复用,我们先封装一个通用的 Lottie 组件(支持 Vue3 组合式 API)。

1. 创建 Lottie 通用组件

在 src/components 目录下新建 LottieAnimation.vue

<template>
  <!-- 动画容器,需指定宽高 -->
  <div ref="lottieContainer" class="lottie-container" :style="{ width, height }"></div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';
import lottie from 'lottie-web';

// 定义 Props
const props = defineProps({
  // 动画 JSON 文件路径
  animationData: {
    type: Object,
    required: false,
    default: null
  },
  path: {
    type: String,
    required: false,
    default: ''
  },
  // 动画宽高
  width: {
    type: String,
    default: '300px'
  },
  height: {
    type: String,
    default: '300px'
  },
  // 是否自动播放
  autoplay: {
    type: Boolean,
    default: true
  },
  // 是否循环播放
  loop: {
    type: Boolean,
    default: true
  },
  // 动画速度(1 为正常速度)
  speed: {
    type: Number,
    default: 1
  },
  // 渲染方式(svg/canvas/html),优先 svg(矢量)
  renderer: {
    type: String,
    default: 'svg',
    validator: (val) => ['svg', 'canvas', 'html'].includes(val)
  }
});

// 定义 Emits:暴露动画状态事件
const emit = defineEmits(['complete', 'loopComplete', 'enterFrame']);

// 动画容器 Ref
const lottieContainer = ref(null);
// Lottie 实例
let lottieInstance = null;

// 初始化动画
const initLottie = () => {
  if (!lottieContainer.value) return;

  // 销毁旧实例(避免重复渲染)
  if (lottieInstance) {
    lottieInstance.destroy();
  }

  // 创建 Lottie 实例
  lottieInstance = lottie.loadAnimation({
    container: lottieContainer.value, // 动画容器
    animationData: props.animationData, // 动画 JSON 数据(本地导入)
    path: props.path, // 动画 JSON 文件路径(远程/ public 目录)
    renderer: props.renderer, // 渲染方式
    loop: props.loop, // 循环播放
    autoplay: props.autoplay, // 自动播放
    name: 'lottie-animation' // 动画名称(可选)
  });

  // 设置动画速度
  lottieInstance.setSpeed(props.speed);

  // 监听动画事件
  lottieInstance.addEventListener('complete', () => {
    emit('complete'); // 动画播放完成
  });
  lottieInstance.addEventListener('loopComplete', () => {
    emit('loopComplete'); // 动画循环完成
  });
  lottieInstance.addEventListener('enterFrame', (e) => {
    emit('enterFrame', e); // 动画每一帧
  });
};

// 监听 Props 变化,重新初始化
watch(
  [() => props.path, () => props.animationData, () => props.loop, () => props.speed],
  () => {
    initLottie();
  },
  { immediate: true }
);

// 组件卸载时销毁实例
onUnmounted(() => {
  if (lottieInstance) {
    lottieInstance.destroy();
    lottieInstance = null;
  }
});
</script>

<style scoped>
.lottie-container {
  display: inline-block;
  overflow: hidden;
}
</style>

2. 基础使用示例

在页面组件中引入并使用封装好的 LottieAnimation 组件,支持两种加载方式:

方式 1:加载 public 目录下的 JSON 文件

将动画文件 animation.json 放入 public/lottie/ 目录,使用 path 传入路径:

<template>
  <div class="demo-page">
    <h2>Lottie 基础使用示例</h2>
    <LottieAnimation
      path="/lottie/animation.json"
      width="200px"
      height="200px"
      :loop="false"
      :speed="1.2"
      @complete="handleAnimationComplete"
    />
  </div>
</template>

<script setup>
import LottieAnimation from '@/components/LottieAnimation.vue';

// 动画播放完成回调
const handleAnimationComplete = () => {
  console.log('动画播放完成!');
};
</script>

方式 2:本地导入 JSON 文件(需配置 loader)

如果将动画文件放在 src/assets 目录,需先导入 JSON 文件(Vue3 + Vite 无需额外配置,Webpack 需确保支持 JSON 导入):

<template>
  <div class="demo-page">
    <LottieAnimation
      :animation-data="animationData"
      width="200px"
      height="200px"
      :autoplay="false"
      ref="lottieRef"
    />
    <button @click="playAnimation">播放动画</button>
    <button @click="pauseAnimation">暂停动画</button>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import LottieAnimation from '@/components/LottieAnimation.vue';
// 导入本地 JSON 动画文件
import animationData from '@/assets/lottie/animation.json';

const animationData = ref(animationData);
const lottieRef = ref(null);

// 播放动画
const playAnimation = () => {
  lottieRef.value.lottieInstance.play();
};

// 暂停动画
const pauseAnimation = () => {
  lottieRef.value.lottieInstance.pause();
};
</script>

四、高级配置与交互控制

Lottie 提供了丰富的 API 用于控制动画,以下是常用的交互场景:

1. 手动控制播放 / 暂停 / 停止

通过获取 Lottie 实例,调用内置方法:

// 播放动画
lottieInstance.play();

// 暂停动画
lottieInstance.pause();

// 停止动画(重置到第一帧)
lottieInstance.stop();

// 跳转到指定帧(frameNum 为帧编号)
lottieInstance.goToAndStop(frameNum, true);

// 跳转到指定帧并播放
lottieInstance.goToAndPlay(frameNum, true);

2. 动态修改动画速度

javascript

运行

// 设置速度(0.5 为慢放,2 为快放)
lottieInstance.setSpeed(0.5);

// 获取当前速度
const currentSpeed = lottieInstance.playSpeed;

3. 控制循环模式

// 设置循环次数(0 为无限循环,1 为播放 1 次)
lottieInstance.loop = 0;

// 单独设置循环(立即生效)
lottieInstance.setLoop(true); // 无限循环
lottieInstance.setLoop(3); // 循环 3 次

4. 监听动画进度

通过 enterFrame 事件监听动画进度,实现进度条联动:

<template>
  <LottieAnimation
    path="/lottie/animation.json"
    @enterFrame="handleEnterFrame"
  />
  <input
    type="range"
    min="0"
    max="100"
    v-model="progress"
    @input="handleProgressChange"
  />
</template>

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

const progress = ref(0);
let totalFrames = 0;

// 监听每一帧,更新进度
const handleEnterFrame = (e) => {
  totalFrames = e.totalFrames;
  progress.value = Math.floor((e.currentFrame / totalFrames) * 100);
};

// 拖动进度条,跳转动画
const handleProgressChange = () => {
  const targetFrame = (progress.value / 100) * totalFrames;
  lottieInstance.goToAndPlay(targetFrame, true);
};
</script>

五、性能优化与注意事项

1. 性能优化

  • 懒加载:非首屏动画使用 v-if 或动态导入,按需初始化;
  • 销毁实例:组件卸载时务必调用 destroy() 销毁实例,避免内存泄漏;
  • 选择渲染方式:优先使用 svg 渲染(矢量、轻量),复杂动画可使用 canvas
  • 压缩 JSON 文件:使用 LottieFiles 在线工具压缩动画 JSON,减少体积。

2. 常见问题解决

  • 动画不显示:检查容器宽高是否设置、JSON 文件路径是否正确(path 以 / 开头表示 public 根目录);
  • 动画卡顿:减少动画层数和复杂路径,避免同时播放多个大型动画;
  • 跨域问题:远程加载 JSON 文件需确保服务端开启 CORS;
  • Vue3 打包路径问题animationData 导入本地 JSON 时,Vite 需确保 assetsInclude 包含 .json(默认已支持)。

六、总结

Lottie 是 Vue3 项目中实现高品质动画的最佳选择之一,通过封装通用组件可快速集成到项目中,结合其丰富的 API 能实现灵活的交互控制。本文从基础安装、组件封装、高级交互到性能优化,覆盖了 Lottie 在 Vue3 中的核心使用场景。合理使用 Lottie 可显著提升页面交互体验,同时兼顾性能与兼容性。

如果需要更复杂的场景(如动画分段播放、结合 Vuex/Pinia 控制动画状态),可基于本文的通用组件扩展,结合业务需求定制化开发。

Vue3 的设计目标是什么?相比 Vue2 做了哪些关键优化?

作者 刘大华
2025年12月31日 08:45

刚开始用 Vue2 的时候,感觉就像拿到了一把顺手的新工具。简单、直观,做东西也特别快。

但随着项目越来越复杂,项目渐渐的也会遇到一些烦恼。组件的代码越写越长,相关的逻辑分散在data、methods、computed各种不同的地方,想复用一段功能也挺费劲的。同时打包后的文件也越来越大。

后来用了Vue3,可以说是完美的解决了我上面的几个痛点。

下面我们就来看下 vue3 到底做了哪些优化。

Vue 3.0 的四大设计目标

任何一次技术重构都有其核心目标,Vue 3 主要围绕以下四点展开:

  1. 更小:通过 Tree-shaking 等技术,让打包体积比 Vue 2 更小。
  2. 更快:优化虚拟 DOM,提升渲染和更新性能。
  3. 更易维护:采用 TypeScript 重写,代码结构更清晰模块化。
  4. 更好的扩展性:提供组合式 API 等新特性,应对复杂应用场景。

下面我们就来看看这些目标是如何具体实现的。


性能优化:底层引擎的重构

1. 响应式系统:Proxy 替代 Object.defineProperty

这是 Vue3 最核心的改进之一。

Vue2的响应式原理:

// Vue2 使用 Object.defineProperty
const data = {};
Object.defineProperty(data, 'name', {
    get() {
        console.log('读取name');
        return '张三';
    },
    set(newVal) {
        console.log('设置name:', newVal);
    }
});

Vue2 的局限性:

  • 无法检测属性的添加和删除
  • 对数组的支持需要特殊处理
  • 初始化时需要递归遍历整个对象

Vue3 的解决方案:

// Vue 3 使用 Proxy
const data = { name: '张三' };
const proxyData = new Proxy(data, {
    get(target, key) {
        console.log(`读取${key}`);
        return target[key];
    },
    set(target, key, value) {
        console.log(`设置${key}:`, value);
        target[key] = value;
        return true;
    },
    deleteProperty(target, key) {
        console.log(`删除${key}`);
        delete target[key];
        return true;
    }
});

Proxy 的优势:

  • 可以监听动态添加的属性
  • 支持数组索引修改、length 修改
  • 支持 Map、Set 等数据结构
  • 性能更好

2. 编译时优化:模板编译

Vue3 的编译器会分析模板,为动态内容添加标记。

// 模板
<template>
  <div class="header">
    <img src="./logo.png" />
    <h1>{{ title }}</h1>
  </div>
</template>

Vue2:每次渲染都重新创建 <img> 节点(虽然是静态的)。

Vue3:编译时发现 <img> 永远不变,就把它缓存起来,只创建一次!

// 编译后伪代码(简化)
const staticImg = createVNode('img', { src: './logo.png' })

function render() {
  return createVNode('div', { class: 'header' }, [
    staticImg, // 直接复用!
    createTextVNode(ctx.title) // 只有这里动态更新
  ])
}

3. Tree-shaking:按需引入

Vue3 的模块化设计使得未使用的功能不会被打包:

import { createApp, h } from 'vue'; // 只引入需要的API

如果你不使用 transition 组件,它的代码就不会出现在最终打包文件中


开发体验的升级:组合式 API

这是 Vue3 在代码组织方式上的重大改进!

Vue2 Options API 的问题:

export default {
    data() {
        return {
            users: [],
            searchQuery: '',
            loading: false
        }
    },
    methods: {
        fetchUsers() {
            // 获取用户数据
        },
        searchUsers() {
            // 搜索用户
        }
    },
    computed: {
        filteredUsers() {
            // 过滤用户
        }
    },
    mounted() {
        this.fetchUsers();
    }
}

同一个功能(用户管理)的逻辑被拆分到不同的选项中,组件复杂后难以维护。

Vue3 Composition API 的解决方案:

<template>
  <div>
    <input v-model="searchQuery" placeholder="搜索用户">
    <div v-if="loading">加载中...</div>
    <UserList :users="filteredUsers" />
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue'

// 用户管理功能 - 所有相关逻辑在一起
const users = ref([])
const searchQuery = ref('')
const loading = ref(false)

const filteredUsers = computed(() => {
  return users.value.filter(user => 
    user.name.includes(searchQuery.value)
  )
})

async function fetchUsers() {
  loading.value = true
  try {
    users.value = await api.getUsers()
  } finally {
    loading.value = false
  }
}

onMounted(() => {
  fetchUsers()
})

// 其他功能也可以这样组织...
// const posts = ref([])
// const fetchPosts = async () => { ... }
</script>

vue3 组合式函数:

// composables/useUserManagement.js
import { ref, computed, onMounted } from 'vue'

export function useUserManagement() {
    const users = ref([])
    const searchQuery = ref('')
    const loading = ref(false)

    const filteredUsers = computed(() => {
        return users.value.filter(user => 
            user.name.includes(searchQuery.value)
        )
    })

    async function fetchUsers() {
        loading.value = true
        try {
            users.value = await api.getUsers()
        } finally {
            loading.value = false
        }
    }

    onMounted(fetchUsers)

    return {
        users,
        searchQuery,
        loading,
        filteredUsers,
        fetchUsers
    }
}
<script setup>
// 在组件中使用
import { useUserManagement } from './composables/useUserManagement'

const { 
    users, 
    searchQuery, 
    loading, 
    filteredUsers,
    fetchUsers 
} = useUserManagement()
</script>

Vue3 同时支持 Options API 和 Composition API,你可以根据项目复杂度和个人偏好选择,甚至混合使用。


其他重要新特性

1. Teleport:任意传送

<template>
  <div class="app">
    <button @click="showModal = true">打开弹窗</button>
    
    <!-- 将弹窗内容传送到 body 下 -->
    <Teleport to="body">
      <div v-if="showModal" class="modal">
        <h2>我是弹窗</h2>
        <button @click="showModal = false">关闭</button>
      </div>
    </Teleport>
  </div>
</template>

2. Fragments:多根节点支持

<template>
  <!-- Vue 3 支持多个根节点 -->
  <header>头部</header>
  <main>主要内容</main>
  <footer>底部</footer>
</template>

3. Suspense:异步组件处理

<template>
  <Suspense>
    <template #default>
      <AsyncComponent />
    </template>
    <template #fallback>
      <div>加载中...</div>
    </template>
  </Suspense>
</template>

<script setup>
import { defineAsyncComponent } from 'vue'
// 异步组件
const AsyncComponent = defineAsyncComponent(() => 
  import('./AsyncComponent.vue')
)
</script>

总结

特性对比 Vue 2 Vue 3 优势
响应式系统 Object.defineProperty Proxy 功能更完善,性能更好
代码组织 Options API Composition API 逻辑复用和组织更灵活
TypeScript 需要额外配置 原生支持 开发体验更好
包大小 全量引入 Tree-shaking 打包体积更小
新功能 有限 Teleport、Suspense等 开发能力更强

当然 Vue3 并没有抛弃过去,而是把选择权交给了我们:小项目可以用熟悉的 Options API 快速上手,大项目则可以借助 Composition API 和 TypeScript 更从容的组织代码。

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《重构了20个SpringBoot项目后,总结出这套稳定高效的架构设计》

《代码里全是 new 对象,真的很 Low 吗?我认真想了一晚》

《这 5 个冷门 HTML 标签,让我直接删了100 行 JS 代码》

《这 10 个 Vue3 性能优化技巧很实用,但很多项目都没用上》

亿元Cocos小游戏实战合集

2025年12月31日 08:42

历史截图,实际以合集内容为准

引言

哈喽大家好,我是亿元程序员,一位有着8年游戏行业经验的主程。

笔者Slogan

在游戏开发中,希望能给到小伙伴们帮助, 也希望通过小伙伴们能帮助到大家。

相信一直有关注笔者文章的小伙伴都知道,笔者一直持续更新的文章合集**《100个Cocos实例》**:

100个Cocos实例

如今该合集已经更新了超过70例,已经过去了三分之二,收获6万阅读量,感谢小伙伴们一直以来的支持与鼓励。

随着该合集逐渐进入尾声,我也在思考如何继续为大家提供更聚焦、更实用的内容。

因此,我正式启动了一个全新的实战系列——《亿元Cocos小游戏实战合集》

这个新合集将围绕过去和现在热门的买量小游戏,进行拆解、分析、思考和实战

合集配套源码可在文末获取,小伙伴们自行前往。

1. 什么是买量小游戏

买量小游戏是游戏行业中一个特定细分领域的俗称,特指那些主要通过大规模广告投放(即买量)来获取用户,并以快速变现为核心目标的轻度或超轻度游戏。

举个例子,小伙伴们在朋友圈刷到的“别笑,你试你也过不了第二关”,就是非常成功的一个买量广告。

朋友圈截图

除去朋友圈广告,在刷短视频的时候,也会经常刷到一些有趣而且吸引人的游戏玩法为素材制作的广告。

甚至还有一些内容实际上并不是游戏的主要玩法,例如割绳子、数字比大小、画线解谜、修桥、跑酷选择等等。

这类小游戏的主要目的就是吸收泛用户,扩大游戏盘子。

2. 该合集的节奏

合集的整体思路按照拆解、分析、实战去展开。

拆解、分析、实战

1.拆解

通过体验小游戏,拆解出来游戏包含的关键元素,对元素进行逐项分析,提取核心的玩法和元素。

2.分析

核心玩法和各元素进行分析,思考和解析它们的实现原理。

3.实战

通过分析出来的理论内容,进行详细的实战,对核心源码进行详细解析。

3. 适合阅读人群

适合阅读人群

  • 小游戏开发入门、想要当大佬的小伙伴。
  • 小游戏二次开发、节约用时的小伙伴。
  • 喜欢看热闹、看笔者整活的小伙伴。
  • 喜欢看笔者丢人的小伙伴。

总的来说,笔者想要你看,就算是苦的,也会告诉你很甜!

4.其他说明

该合集预计更新10篇,一定会更完,想想笔者70篇都更新下来了,区区10篇又奈我如何?

更新时间不定,为保证质量,更新间隔至少一周,如果有热门游戏火了,会第一时间给大家拆解(为了流量)。

合集体验地址(加载需要点时间,请耐心等待):

yiyuangamecases.pages.dev/

结语

既然都看到这里了,那你一定应该是真爱粉了,感谢你一直以来的支持!

合集配套源码可通过阅读原文获取。


我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。

AD:笔者线上的小游戏《打螺丝闯关》《贪吃蛇掌机经典》《重力迷宫球》《填色之旅》《方块掌机经典》大家可以自行点击搜索体验。

实不相瞒,想要个爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!

推荐文章:

亿元Cocos小游戏实战合集

Cocos游戏如何接入安卓穿山甲广告变现?

你知道和不知道的微信小游戏常用API整理,赶紧收藏用起来~

Cocos游戏如何快速接入抖音小游戏广告变现?

如何在CocosCreator3.8中实现割绳子游戏效果

如何在CocosCreator3.8中实现动态切割模型?

Cocos游戏开发中的贴花效果

🚀 这个 ElDialog 封装方案,让我的代码量减少了 80%

作者 jenemy
2025年12月31日 00:37

你是否也遇到过这样的场景:每次使用 ElDialog 都要写一个 visible 变量,打开对话框要 visible = true,关闭要 visible = false,确认逻辑和对话框组件还得分开放?一个简单的确认对话框,动辄 30-50 行代码,嵌套对话框更是需要多层状态管理,代码变得臃肿难维护。

直到我遇到了 BusDialog,一个基于 Promise + Portal-vue 的 ElDialog 封装方案,彻底改变了我的开发方式。现在打开对话框就像调用函数一样简单:await openDialog({ content: '确认删除?' }),5 行代码搞定之前需要 50 行才能实现的功能。更重要的是,它支持跨组件内容组合、原生支持嵌套对话框,还自动处理状态清理,真正做到了零状态管理。

今天就来分享这个让我代码量减少 80% 的对话框封装方案,看看它是如何用 Promise 化 API 和 Portal-vue 重新定义 ElDialog 封装的。

为什么需要重新封装 ElDialog?

ElDialog 在管理系统中使用频率很高,但每次使用都要写大量样板代码:定义 visible、绑定 v-model、处理打开/关闭逻辑,一个简单的确认对话框动辄 30-50 行。更麻烦的是,每个对话框都需要一个独立的 visible 变量,嵌套对话框时状态管理更复杂。

如果能像 ElMessageBox 那样,一行代码就能打开对话框,用 async/await 处理确认和取消,那该多好?BusDialog 正是基于这个思路设计的封装方案。

传统封装的痛点

大多数开发者对 ElDialog 的使用和封装都是这样的:

<template>
  <el-dialog v-model="visible" title="提示">
    <div>确认删除?</div>
    <template #footer>
      <el-button @click="visible = false">取消</el-button>
      <el-button type="primary" @click="handleConfirm">确认</el-button>
    </template>
  </el-dialog>
</template>

<script setup>
const visible = ref(false);
const handleConfirm = () => {
  // 处理确认逻辑
  visible.value = false;
};
</script>

问题来了:

  1. ❌ 状态管理繁琐:每个对话框都需要一个 visible 状态
  2. ❌ 代码分散:打开对话框的逻辑和对话框组件分离
  3. ❌ 难以复用:每个对话框都要单独写模板
  4. ❌ 嵌套困难:在对话框内打开另一个对话框需要多层状态管理
  5. ❌ 无法获取返回值:无法像函数调用一样获取用户的选择结果

理想中的对话框应该是什么样的?

// 理想:像函数调用一样简单
try {
  await openDialog({ content: '确认删除?' });
  // 用户点击了确认
  console.log('删除成功');
} catch {
  // 用户点击了取消或关闭
  console.log('取消删除');
}

这就是 BusDialog 的设计理念!


BusDialog 的核心优势

1. 🎯 Promise 化 API:告别状态管理

传统方式:

<script setup>
const visible = ref(false);
const handleDelete = () => {
  visible.value = true;
};
const handleConfirm = async () => {
  await deleteApi();
  visible.value = false;
};
</script>

BusDialog 方式:

const { openDialog } = useBusDialog();

const handleDelete = async () => {
  try {
    await openDialog({ content: '确认删除?' });
    await deleteApi();
    ElMessage.success('删除成功');
  } catch {
    ElMessage.info('取消删除');
  }
};

优势:

  • ✅ 零状态管理:不需要 visible、loading 等状态
  • ✅ 代码更简洁:打开和确认逻辑在一起
  • ✅ 错误处理清晰:使用 try/catch 处理取消操作

2. 🌐 Portal-vue 集成:跨组件内容组合

这是 BusDialog 最独特的功能!可以将来自不同组件的内容组合到同一个对话框中。

<!-- 组件 A -->
<template>
  <Portal :to="dialog.contentPortalName" :order="1">
    <ProductSelector />
  </Portal>
</template>

<!-- 组件 B -->
<template>
  <Portal :to="dialog.contentPortalName" :order="2">
    <PriceCalculator />
  </Portal>
</template>

<!-- 使用 -->
<script setup>
const dialog = useBusDialog();

async function openComplexDialog() {
  await dialog.openDialog({
    title: '复杂表单',
    // 内容由多个组件通过 Portal 注入
  });
}
</script>

优势:

  • ✅ 组件解耦:对话框内容可以来自多个独立组件
  • ✅ 灵活组合:通过 order 控制渲染顺序
  • ✅ 动态注入:可以在运行时动态添加内容

3. 🎨 三种内容设置方式,优先级清晰

BusDialog 提供了三种设置内容的方式,满足不同场景需求:

// 方式 1:简单文本(最简单)
await openDialog({ 
  content: '确认删除?' 
});

// 方式 2:渲染函数(灵活)
await openDialog({
  render: (resolve, reject) => (
    <ComplexForm 
      onConfirm={resolve} 
      onCancel={reject} 
    />
  )
});

// 方式 3:Portal-vue(跨组件)
const dialog = useBusDialog();
<Portal :to="dialog.contentPortalName">
  <MyComponent />
</Portal>
await dialog.openDialog();

优先级:渲染函数 > Portal-vue > 文本属性

4. 🔄 自动清理机制:零内存泄漏

传统封装需要手动管理对话框状态,容易造成内存泄漏。BusDialog 在关闭时自动清理所有 Portal 状态:

// 自动清理,无需手动管理
const wrappedResolve = (value) => {
  Wormhole.close({
    to: 'bus-dialog',
    from: sender,
  });
  originalResolve(value);
};

5. 🎭 完美兼容 Element Plus

通过 dialogConfig 可以访问 ElDialog 的所有原生功能:

await openDialog({
  title: '自定义对话框',
  width: 800,
  dialogConfig: {
    // 所有 ElDialog 的原生属性都可以使用
    draggable: true,
    closeOnClickModal: false,
    // ... 更多配置
  }
});

6. 🎪 支持嵌套对话框

在对话框内打开另一个对话框?没问题!

const handleNested = async () => {
  try {
    await openDialog({
      content: '第一个对话框',
      render: (resolve, reject) => (
        <div>
          <button onClick={async () => {
            try {
              await openDialog({ content: '嵌套对话框' });
              resolve();
            } catch {
              reject();
            }
          }}>打开嵌套对话框</button>
        </div>
      )
    });
  } catch {}
};

技术实现解析

核心技术栈

  1. VueUse createTemplatePromise:将组件转换为 Promise
  2. Portal-vue:实现跨组件内容传输
  3. ulid:生成唯一标识符

核心实现思路

export function useBusDialog() {
  // 1. 生成唯一标识
  const sender = ulid();
  
  // 2. 创建 Promise 化的模板
  const InnerDialog = createTemplatePromise<
    BusDialogResult,
    [BusDialogProps?]
  >();
  
  // 3. 包装 resolve/reject,自动清理 Portal
  const createCloseHandler = (resolve, reject) => {
    const wrappedResolve = (value) => {
      Wormhole.close({ to: 'bus-dialog', from: sender });
      resolve(value);
    };
    return { wrappedResolve, wrappedReject };
  };
  
  // 4. 返回 Promise 化的打开方法
  return {
    openDialog: async (args) => {
      Wormhole.open({
        to: 'bus-dialog',
        from: sender,
        content: BusDialog,
      });
      return await InnerDialog.start(args);
    }
  };
}

关键设计点

  1. 唯一标识符:每个 useBusDialog() 实例都有唯一的 sender,避免 Portal 冲突
  2. 自动清理:在 resolve/reject 时自动关闭对应的 Portal
  3. 优先级机制:渲染函数 > Portal-vue > 文本属性,确保灵活性

实际应用场景

场景 1:简单的确认对话框

// 删除确认
const handleDelete = async (id: number) => {
  try {
    await openDialog({ 
      content: '确认删除这条记录?',
      title: '删除确认'
    });
    await deleteRecord(id);
    ElMessage.success('删除成功');
  } catch {
    // 用户取消
  }
};

场景 2:复杂表单对话框

// 编辑用户信息
const handleEdit = async (user: User) => {
  try {
    const result = await openDialog({
      title: '编辑用户',
      width: 600,
      render: (resolve, reject) => (
        <UserForm 
          initialData={user}
          onSubmit={async (data) => {
            await updateUser(data);
            resolve({ reason: 'ok', data });
          }}
          onCancel={reject}
        />
      )
    });
    ElMessage.success('更新成功');
  } catch {}
};

场景 3:跨组件内容组合

<!-- 产品选择器组件 -->
<template>
  <Portal :to="dialog.contentPortalName" :order="1">
    <ProductSelector v-model="selectedProducts" />
  </Portal>
</template>

<!-- 价格计算器组件 -->
<template>
  <Portal :to="dialog.contentPortalName" :order="2">
    <PriceCalculator :products="selectedProducts" />
  </Portal>
</template>

<!-- 使用 -->
<script setup>
const dialog = useBusDialog();

async function openOrderDialog() {
  try {
    await dialog.openDialog({
      title: '创建订单',
      width: 1000,
      // 内容由多个组件通过 Portal 注入
    });
  } catch {}
}
</script>

总结

BusDialog 通过 Promise 化 API 和 Portal-vue 集成,重新定义了 ElDialog 的封装方式:

核心优势对比

特性 传统封装 BusDialog
状态管理 ❌ 需要 visible 等状态 ✅ 零状态管理
代码组织 ❌ 打开和确认逻辑分离 ✅ 逻辑集中,代码简洁
内容设置 ❌ 只能在模板中写死 ✅ 三种方式,灵活组合
跨组件 ❌ 难以实现 ✅ Portal-vue 轻松实现
嵌套支持 ❌ 需要多层状态管理 ✅ 原生支持
内存管理 ⚠️ 需要手动清理 ✅ 自动清理

适用场景

  • ✅ 需要频繁使用对话框的场景
  • ✅ 需要跨组件组合内容的复杂对话框
  • ✅ 需要嵌套对话框的复杂交互
  • ✅ 追求代码简洁和可维护性的项目

不适用场景

  • ❌ 只需要简单展示的静态对话框(直接用 el-dialog 即可)
  • ❌ 不需要获取用户选择结果的场景

如果你也在为对话框的状态管理而烦恼,不妨试试 BusDialog,让对话框像函数调用一样简单!

📦 GitHub: vben-business-components

📖 组件文档: BusDialog 详细文档地址

本文档内容由 AI 根据实际组件代码及官方文档自动生成,仅供学习和参考。如果你也有不错的业务组件封装案例,也可以联系 jenemy_xl 共同分享和学习。

面试官: “ 请你讲一下 package.json 文件 ? ”

作者 千寻girling
2025年12月30日 23:49

1. package.json 的作用

package.json 是 Node.js/npm 项目的核心配置文件,位于项目根目录,它的作用包括:

  • 描述项目信息:名称、版本、作者、许可证等。
  • 声明依赖:项目运行所需的包(dependencies)和开发所需的包(devDependencies)。
  • 定义脚本命令:通过 scripts 字段,让你可以用 npm run 执行自定义任务(如启动、测试、构建)。
  • 指定元数据:比如入口文件、浏览器兼容性等。

2. 基本结构示例

一个典型的 package.json 可能如下:

{
  "name": "my-project",
  "version": "1.0.0",
  "description": "A sample Node.js project",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "test": "jest",
    "build": "webpack"
  },
  "dependencies": {
    "express": "^4.18.2"
  },
  "devDependencies": {
    "jest": "^29.7.0",
    "webpack": "^5.89.0"
  },
  "author": "Your Name",
  "license": "MIT",
  "keywords": ["node", "express", "example"]
}

3. 核心字段说明

3.1 项目信息字段

  • name:项目名称(必须小写,无空格)。
  • version:项目版本,遵循 SemVer(语义化版本),格式为 x.y.z(主版本。次版本。补丁版本)。
  • description:项目的简短描述。
  • author:作者信息,可以是字符串或对象(如 {"name": "xxx", "email": "xxx"})。
  • license:开源许可证类型(如 MITISCGPL)。
  • keywords:项目关键字数组,方便在 npm 上搜索。

3.2 入口与配置字段

  • main:指定项目的入口文件(默认是 index.js)。

  • type:指定模块系统类型:

    • "commonjs"(默认):使用 require() 导入。
    • "module":使用 import/export 语法。
  • files:发布到 npm 时需要包含的文件或目录。

  • repository:项目代码仓库地址。


3.3 依赖字段

  • dependencies:生产环境依赖(项目运行时必需的包),例如:

    "dependencies": {
      "react": "^18.2.0"
    }
    

    版本号前的 ^ 表示兼容当前版本的次版本更新。

  • devDependencies:开发环境依赖(仅开发时使用,比如测试、构建工具),例如:

    "devDependencies": {
      "eslint": "^8.55.0"
    }
    
  • peerDependencies:声明项目运行时需要的外部依赖版本(常用于插件或库)。

  • optionalDependencies:可选依赖,即使安装失败也不会影响项目。


3.4 脚本字段

  • scripts:定义可执行的命令,例如:

    "scripts": {
      "start": "node index.js",
      "dev": "nodemon index.js"
    }
    

    执行方法:

    npm run start
    npm run dev
    

4. package.json 的生成方式

  • 手动创建:直接新建 package.json 文件并写入内容。

  • 使用命令:

    npm init
    

    会通过交互方式生成。

  • 使用默认配置:

    npm init -y
    

    直接生成一个默认的 package.json


5. 与 package-lock.json 的关系

  • package.json:声明依赖的版本范围
  • package-lock.json:锁定安装时的具体版本,确保每次安装的依赖版本一致。

✅ 总结package.json 是项目的 “身份证” 和 “说明书”,它定义了项目的基本信息、依赖关系、可执行脚本等。掌握它的结构和字段,是使用 npm 和 Node.js 开发的基础。

解决深拷贝循环引用痛点:一篇看懂 WeakMap 实现方案

作者 如果你好
2025年12月30日 23:36

解决深拷贝循环引用痛点:一篇看懂 WeakMap 实现方案

在 JavaScript 开发中,深拷贝是高频需求——无论是处理复杂的业务数据,还是封装工具函数,我们都需要确保拷贝后的对象与原对象完全独立,不会互相干扰。但深拷贝藏着一个容易踩坑的痛点:循环引用

比如一个对象的属性引用了自身,或者引用了父级对象,用普通的递归深拷贝实现会直接触发死循环,最终导致栈溢出错误。今天就带大家彻底搞懂循环引用的问题根源,以及如何用 WeakMap 优雅解决,最终实现一个健壮的深拷贝函数。

一、先搞懂:什么是循环引用?

循环引用就是对象内部存在“自引用”或“互引用”的情况,简单说就是对象自己指向了自己(或关联对象形成闭环)。举个最直观的例子:

// 自引用:obj 的 self 属性引用了自身
const obj = { a: 1 };
obj.self = obj; 

// 互引用:obj1 和 obj2 互相引用
const obj1 = { b: 2 };
const obj2 = { c: 3 };
obj1.target = obj2;
obj2.target = obj1;

这种结构很常见,比如 DOM 元素的 parentNode 属性(子元素引用父元素,父元素又包含子元素)、链表数据结构等。如果用普通递归深拷贝处理这类对象,递归会无限循环下去,直到浏览器触发 Maximum call stack size exceeded 栈溢出错误。

二、普通递归深拷贝的问题所在

先看一个简单的递归深拷贝实现(未处理循环引用):

// 普通递归深拷贝(存在循环引用漏洞)
function simpleDeepClone(obj) {
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }
  const cloneObj = Array.isArray(obj) ? [] : {};
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      // 递归拷贝属性
      cloneObj[key] = simpleDeepClone(obj[key]);
    }
  }
  return cloneObj;
}

当我们用这个函数拷贝前面的循环引用对象 obj 时:

const obj = { a: 1 };
obj.self = obj;
simpleDeepClone(obj); // 直接栈溢出!

原因很简单:递归过程中,会不断解析 obj.self,而 obj.self 又指向 obj,导致递归永远无法终止,栈内存被持续占用直到溢出。

三、核心思路:用“缓存”记录已拷贝对象

要解决循环引用,关键在于避免对同一个对象重复递归拷贝。我们需要一个“缓存容器”,在每次拷贝对象前,先检查这个对象是否已经被拷贝过:

  1. 如果没拷贝过,就正常拷贝,同时把“原对象”和“拷贝后的新对象”存入缓存;
  2. 如果已经拷贝过,直接从缓存中取出新对象返回,不再重复递归。

这里的核心是「缓存键值对」——键存原对象,值存对应的拷贝对象,这样才能精准找到已拷贝的结果。那用什么数据结构做缓存最合适呢?

推荐用 WeakMap,而不是 SetMap

  • WeakMap 的键只能是对象,正好匹配我们“存原对象”的需求;
  • WeakMap 是弱引用,当原对象没有其他引用时,会被垃圾回收机制回收,不会造成内存泄漏(如果用 Map 或 Set,会一直强引用原对象,导致内存无法释放)。

四、最终实现:带循环引用检测的深拷贝

结合上面的思路,实现代码如下,每一步都加了详细注释,新手也能看懂:

function deepClone(obj, seen = new WeakMap()) {
  // 1. 基本类型或函数直接返回(基本类型按值传递,函数一般不需要拷贝)
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }

  // 2. 检查缓存:如果已拷贝过该对象,直接返回缓存中的新对象
  if (seen.has(obj)) {
    return seen.get(obj);
  }

  // 3. 创建新容器:根据原对象类型(数组/对象)创建对应的空容器
  const cloneObj = Array.isArray(obj) ? [] : {};

  // 4. 存入缓存:将原对象和新对象的映射关系存入 WeakMap
  seen.set(obj, cloneObj);

  // 5. 递归拷贝属性:遍历原对象的自有属性,递归拷贝到新对象
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      cloneObj[key] = deepClone(obj[key], seen); // 传递缓存容器
    }
  }

  // 6. 返回拷贝后的新对象
  return cloneObj;
}

五、测试验证:循环引用是否被正确处理?

我们用前面的循环引用案例测试一下,看看效果:

// 测试1:自引用对象
const obj1 = { a: 1, b: [1, 2, 3] };
obj1.self = obj1; // 自引用

const cloned1 = deepClone(obj1);
console.log(cloned1); 
// 输出:{ a: 1, b: [1,2,3], self: [Circular] }([Circular] 表示循环引用)
console.log(cloned1.self === cloned1); // true(新对象的 self 引用自身,正确)
console.log(cloned1.b === obj1.b); // false(数组被深拷贝,完全独立)

// 测试2:互引用对象
const obj2 = { x: 'hello' };
const obj3 = { y: 'world' };
obj2.target = obj3;
obj3.target = obj2; // 互引用

const cloned2 = deepClone(obj2);
const cloned3 = deepClone(obj3);
console.log(cloned2.target === cloned3); // true(拷贝后的互引用关系正确)
console.log(cloned3.target === cloned2); // true

从测试结果可以看出:

  • 循环引用被正确识别,没有出现栈溢出;
  • 拷贝后的对象保持了原有的循环引用关系;
  • 普通属性和数组都被深拷贝,与原对象完全独立。

六、总结

深拷贝的核心难点是循环引用,解决的关键是「缓存已拷贝对象」,而 WeakMap 是实现缓存的最优选择(弱引用+键为对象)。

Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(十)

2025年12月30日 21:30

Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(十)

Flutter: 3.35.7

前面将基础的功能完成了,现在开始完善辅助功能,常规的辅助功能有网格辅助线,元素层级调整,撤销还原等等,下面我们就简单来实现这些功能。

对于部分辅助功能,我们抽取单独的结构来实现功能,底部工具栏专门用于新增元素,抽取顶部功能区域来实现其他功能。

首先我们来实现网格辅助线,应用网格辅助线可以快速让用户定位到辅助线的位置。要实现这个功能,首先我们得绘制网格线(因为不是绘制相关的知识,所以直接上代码,后续空了会单独写绘制相关的):

import 'package:flutter/material.dart';

import 'configs/constants_config.dart';

/// 绘制网格线
class GridPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    const double gridSize = ConstantsConfig.gridSize;
    final paint = Paint()
      ..color = Colors.grey.withValues(alpha: 0.3)
      ..strokeWidth = 1.0;

    // 绘制垂直线
    for (double x = 0; x < size.width; x += gridSize) {
      canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
    }

    // 绘制水平线
    for (double y = 0; y < size.height; y += gridSize) {
      canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

效果如下:

image01.png

现在网格线绘制完毕,抽取了网格线的部分属性,后续直接更改属性调整网格间距。现在我们就开始实现功能,当元素拖动到网格线附近的时候,就出现吸附效果,上下左右,谁离得近就吸附谁。当实现吸附的时候,我们可以只需要判断左上和右下的点即可,因为左上和右下分别对应了最小和最大的x和y,通过矩形的特性,这两个点就能够包括所有吸附的情况:

/// 获取开启网格辅助线时低于阈值的x和y
///
/// 通过当前的[x]坐标和[y]坐标计算吸附坐标,如果低于阈值,则不吸附
(double, double) _getUseGridXY({required double x, required double y}) {
  double tempX = x;
  double tempY = y;
  final double gridSize = ConstantsConfig.gridSize;
  // 吸附的阈值
  final double snapThreshold = ConstantsConfig.snapThreshold;

  // 计算最近(左上)的网格点
  double snappedLeftX = (x / gridSize).roundToDouble() * gridSize;
  double snappedLeftY = (y / gridSize).roundToDouble() * gridSize;
  // 计算最近(右下)的网格点
  double snappedRightX = ((x + _currentElement!.elementWidth) / gridSize).roundToDouble() * gridSize;
  double snappedRightY = ((y + _currentElement!.elementHeight) / gridSize).roundToDouble() * gridSize;

  // 检查是否在吸附范围内
  double dxLeftDistance = (x - snappedLeftX).abs();
  double dyLeftDistance = (y - snappedLeftY).abs();
  double dxRightDistance = ((x + _currentElement!.elementWidth) - snappedRightX).abs();
  double dyRightDistance = ((y + _currentElement!.elementHeight) - snappedRightY).abs();

  // 在X轴方向上应用吸附
  if (dxLeftDistance < dxRightDistance && dxLeftDistance < snapThreshold) {
    // 如果靠近左边且小于阈值,则吸附到左边
    tempX = snappedLeftX;
  } else if (dxRightDistance < dxLeftDistance && dxRightDistance < snapThreshold) {
    // 如果靠近右边且小于阈值,则吸附到右边
    tempX = snappedRightX - _currentElement!.elementWidth;
  }

  // 在Y轴方向上应用吸附
  if (dyLeftDistance < dyRightDistance && dyLeftDistance < snapThreshold) {
    // 如果靠近上面且小于阈值,则吸附到上面
    tempY = snappedLeftY;
  } else if (dyRightDistance < dyLeftDistance && dyRightDistance < snapThreshold) {
    // 如果靠近下面且小于阈值,则吸附到下面
    tempY = snappedRightY - _currentElement!.elementHeight;
  }

  return (tempX, tempY);
}

/// 处理元素移动
void _onMove({required double x, required double y}) {
  if (_currentElement == null || _temporary == null) return;

  double tempX = _temporary!.x + x - _startPosition.dx;
  double tempY = _temporary!.y + y - _startPosition.dy;

  // 新增网格线辅助
  if (_useGrid) {
    (tempX, tempY) = _getUseGridXY(x: tempX, y: tempY);
  }

  // 其他省略...
}

运行效果(为了方便看效果,所以网格画得很大):

image02.gif

这样我们就简单实现了网格辅助线,在没有旋转的时候,只需要以左上和右下点为基准即可,那如果我们旋转了元素,此时的辅助线又该怎么吸附呢?之前限制边界值也是如此,并没有考虑旋转的情况,此时一并解决。

我们现在的元素定位是基于初始状态矩形来实现的,当元素发生旋转,其实只是内部发生了旋转,基础的矩形并没有,如下图所示:

image03.gif

可以看到,旋转后原始的矩形并没有发生旋转,所以此时定位的x和y还是原始的x和y。

首先我们来简单的限制移动的边界,通过上面我们知道,移动的点都是通过左上角的坐标定位的,在元素旋转后,判断边界就应该以最接近边界的顶点去计算,我们首先获取四个顶点的坐标,再通过通过顶点坐标的最值判断边界。这样虽然能判断边界,假如有个元素的宽度就是容器的宽度,或者高度就是容器的高度,在旋转过程中,始终会存在超过边界的情况,不至于说边旋转边改变元素的大小。所以为了综合两部分,我们用中心点坐标为基准,只要中心点在容器内就行了,这样即便是超出容器一半,也可以接受。为了部分的灵活性,方便后期自定义,所以抽取比例,中心点比例是长宽的一半,这样后期如果我们想用另外的点来计算边界值也行:

/// 获取移动时的边界
///
/// 通过当前移动的[x]坐标和[y]坐标来计算中心点是否达到边界,
/// 如果达到边界,则中心点坐标应用边界值
(double, double) _getMoveBoundary({required double x, required double y}) {
  final double tempWidth = _currentElement!.elementWidth / ConstantsConfig.boundaryRatio;
  final double tempHeight = _currentElement!.elementHeight / ConstantsConfig.boundaryRatio;
  double centerX = x + tempWidth;
  double centerY = y + tempHeight;

  // 限制左边界
  if (centerX < 0) {
    centerX = 0;
  }
  // 限制右边界
  if (centerX > _transformWidth) {
    centerX = _transformWidth;
  }
  // 限制上边界
  if (centerY < 0) {
    centerY = 0;
  }
  // 限制下边界
  if (centerY > _transformHeight) {
    centerY = _transformHeight;
  }

  return (centerX - tempWidth, centerY - tempHeight);
}

运行效果:

image04.gif

这样就简单重新限制了移动边界,当然,边界的限制可以根据自己的需求来定制,这里主要是为了说明一个情况,不论我们旋转还是移动,虽然元素的顶点坐标发生了变化,但是它的中心点始终没变,下面我们就可以使用这个中心点来计算其他的东西。

现在来实现旋转后的网格辅助线附近的吸附效果,之前我们在未旋转时应用了左上和右下点来确定,现在加入旋转,就无法正确的判断了,上面我们可以看到因为旋转是元素内部的旋转,和它的坐标点不存在直接的联系。我们在未旋转的时候,吸附效果就是基于蓝色矩形实现的。左上和右下分别可以表示元素的最小坐标和最大坐标,只要某个点在辅助线附近,就执行吸附,满足最大和最小的情况。当应用了旋转,此时的最大和最小坐标就不是原来的坐标了,而是要通过原始的点进行计算。前面我们已经给出了计算旋转后顶点坐标的代码,现在抽取为方法:

/// 计算元素的顶点坐标
/// 
/// 计算传入元素[item]的顶点坐标,返回顺序为左上,右上,右下,左下
(Offset, Offset, Offset, Offset) _getElementVertex({
  required ElementModel item
}) {
  // 计算元素的四个顶点坐标
  return (
    _rotatePoint(
      x: item.x,
      y: item.y,
      item: item,
    ),
    _rotatePoint(
      x: item.x + item.elementWidth,
      y: item.y,
      item: item,
    ),
    _rotatePoint(
      x: item.x + item.elementWidth,
      y: item.y + item.elementHeight,
      item: item,
    ),
    _rotatePoint(
      x: item.x,
      y: item.y + item.elementHeight,
      item: item,
    ),
  );
}

为了更加直观的看到吸附的效果,我们先来实现辅助线,当我们有了顶点坐标计算方法后,就可以很好的实现辅助线,其实就是将元素用个矩形框框起来,不论元素怎么旋转,都在这个矩形框内。所以我们只需要知道最大和最小的顶点坐标即可:

/// 获取顶点坐标中的最大xy和最小xy
///
/// 通过顶点坐标列表[vertexList]对比出最大和最小
(double, double, double, double) _getExtremeVertex({
  required List<Offset> vertexList
}) {
  double minDx = vertexList[0].dx;
  double minDy = vertexList[0].dy;
  double maxDx = vertexList[3].dx;
  double maxDy = vertexList[3].dy;

  for (var item in vertexList) {
    if (item.dx < minDx) {
      minDx = item.dx;
    } else if (item.dx > maxDx) {
      maxDx = item.dx;
    }
    if (item.dy < minDy) {
      minDy = item.dy;
    } else if (item.dy > maxDy) {
      maxDy = item.dy;
    }
  }

  return (minDx, minDy, maxDx, maxDy);
}

为了更加方便的使用,我们添加如下逻辑:

/// 快速的获取元素的最小的顶点坐标值和最大的顶点坐标值
(double, double, double, double) get _elementVertex {
  if (_currentElement == null) {
    return (0, _transformHeight, 0, _transformWidth);
  }

  final (leftTop, rightTop, rightBottom, leftBottom) = _getElementVertex(
    item: _currentElement!,
  );
  final List<Offset> vertexList = [
    leftTop,
    leftBottom,
    rightBottom,
    rightTop
  ];

  return _getExtremeVertex(vertexList: vertexList);
}

接下来就是添加辅助线了,添加在变换区域内部,简单来说就是画4条线框住我们的元素即可:

// 辅助线
if (_useAuxiliaryLine) Positioned(
  top: 0,
  left: _elementVertex.$1,
  child: Container(
    width: 1,
    height: _transformHeight,
    color: Colors.blueAccent,
  ),
),
if (_useAuxiliaryLine) Positioned(
  top: _elementVertex.$2,
  left: 0,
  child: Container(
    width: _transformWidth,
    height: 1,
    color: Colors.blueAccent,
  ),
),
if (_useAuxiliaryLine) Positioned(
  top: 0,
  left: _elementVertex.$3,
  child: Container(
    width: 1,
    height: _transformHeight,
    color: Colors.blueAccent,
  ),
),
if (_useAuxiliaryLine) Positioned(
  top: _elementVertex.$4,
  left: 0,
  child: Container(
    width: _transformWidth,
    height: 1,
    color: Colors.blueAccent,
  ),
),

最终效果(为了方便看效果所以隐藏了功能区的渲染):

image05.gif

接下来就是实现吸附效果了,有了辅助线就可以很好的理解吸附的原理。当元素旋转的时候,我们只要辅助线所形成的矩形边框在网格线吸附阈值内即可,所以我们对之前的吸附逻辑进行更改。

之前在未旋转的时候,我么通过左上和右下值来判断的吸附,现在我们旋转了元素,再用之前那个吸附方法,判断的永远也只是初始的蓝色矩形,这样很不和逻辑,当旋转后,理应是旋转后的某个顶点在吸附阈值内就进行吸附,所以我们通过用最值顶点来判断吸附。

吸附的逻辑有了,那么移动的距离呢?旋转后的顶点坐标应用吸附,它所移动的x和y并不代表是原始矩形应该移动的x和y。当然我们可以通过实时计算还原初始的x和y,只需要将最终的x和y通过旋转负数该角度即可得到原始的角度,这样就出现了一个新的问题,我们之前计算旋转角度是通过 atan2 来实现了,它的范围为 -π 到 π(不包括 -π),所以我们要让它覆盖四个象限,则需要转换成 0 到 2π 范围,不然旋转后的还原坐标会出现错误:

double angle = _temporary!.rotationAngle + angleEnd - angleStart;
if (angle < 0) {
  angle += 2 * pi;
}

这种方法实现也不算很复杂,但是我们用更简单的方法,就是之前我们看到了原始的矩形和旋转后的矩形的中心点其实是一个,我们通过这个中心点来计算原始的x和y就非常的方便,旋转后的顶点移动x和y,那么对应的中心点也是移动这个x和y,中心点是一个,再通过中心点加上一半的宽高就能得到原始的x和y了:

/// 获取开启网格辅助线时低于阈值的x和y
///
/// 通过当前的[x]坐标和[y]坐标计算吸附坐标,如果低于阈值,则不吸附
(double, double) _getUseGridXY({required double x, required double y}) {
  double tempX = x;
  double tempY = y;
  final double gridSize = ConstantsConfig.gridSize;
  // 吸附的阈值
  final double snapThreshold = ConstantsConfig.snapThreshold;
  // 当旋转的移动过程中,计算出来的x和y其实就是原始矩形的x和y
  // 所以此时我们将item的x和y改成计算出来的,通过这个来计算真实的顶点
  final (leftTop, leftBottom, rightBottom, rightTop) = _getElementVertex(
    item: _currentElement!.copyWith(x: x, y: y),
  );
  final List<Offset> vertexList = [
    leftTop,
    leftBottom,
    rightBottom,
    rightTop
  ];
  final (minDx, minDy, maxDx, maxDy) = _getExtremeVertex(
    vertexList: vertexList,
  );

  // 计算最近(最小顶点坐标点)的网格点
  double snappedLeftX = (minDx / gridSize).roundToDouble() * gridSize;
  double snappedLeftY = (minDy / gridSize).roundToDouble() * gridSize;
  // 计算最近(最大顶点坐标点)的网格点
  double snappedRightX = (maxDx / gridSize).roundToDouble() * gridSize;
  double snappedRightY = (maxDy / gridSize).roundToDouble() * gridSize;

  // 检查是否在吸附范围内
  double dxLeftDistance = minDx - snappedLeftX;
  double dyLeftDistance = minDy - snappedLeftY;
  double dxRightDistance = maxDx - snappedRightX;
  double dyRightDistance = maxDy - snappedRightY;
  // 计算旋转中心
  double cx = (maxDx - minDx) / 2 + minDx;
  double cy = (maxDy - minDy) / 2 + minDy;
  // 元素的一半宽高
  double halfWidth = _currentElement!.elementWidth / 2;
  double halfHeight = _currentElement!.elementHeight / 2;

  if (!(minDx == snappedLeftX || maxDx == snappedRightX)) {
    // 在X轴方向上应用吸附
    if (dxLeftDistance.abs() < dxRightDistance.abs() && dxLeftDistance.abs() < snapThreshold) {
      // 如果靠近左边且小于阈值,则吸附到左边
      tempX = cx - dxLeftDistance - halfWidth;
    } else if (dxRightDistance.abs() < dxLeftDistance.abs() && dxRightDistance.abs() < snapThreshold) {
      // 如果靠近右边且小于阈值,则吸附到右边
      tempX = cx - dxRightDistance - halfWidth;
    }
  }

  if (!(minDy == snappedLeftY || maxDy == snappedRightY)) {
    // 在Y轴方向上应用吸附
    if (dyLeftDistance.abs() < dyRightDistance.abs() && dyLeftDistance.abs() < snapThreshold) {
      // 如果靠近上面且小于阈值,则吸附到上面
      tempY = cy - dyLeftDistance - halfHeight;
    } else if (dyRightDistance.abs() < dyLeftDistance.abs() && dyRightDistance.abs() < snapThreshold) {
      // 如果靠近下面且小于阈值,则吸附到下面
      tempY = cy - dyRightDistance - halfHeight;
    }
  }

  return (tempX, tempY);
}

效果如下:

image06.gif

这样我们就简单实现了元素辅助线和网格线的辅助功能。再添加一个小的功能,就是旋转的90度这些特殊的值的小范围内,执行回正效果:

// 在特殊角度处
if ((angle - pi / 2).abs() <= angleThreshold) {
  angle = pi / 2;
} else if ((angle - pi).abs() <= angleThreshold) {
  angle = pi;
} else if ((angle - pi * 3 / 2).abs() <= angleThreshold) {
  angle = pi * 3 / 2;
} else if ((angle - pi * 2).abs() <= angleThreshold || angle.abs() <= angleThreshold) {
  angle = 0;
}

效果如下:

image07.gif

一些移动过程中简单的辅助功能就算完成了。下面来看一下完整的效果:

image08.gif

好了,今天的分享就到此结束了,这个系列也快迎来尾声了。

感兴趣的也可以关注我的微信公众号【前端学习小营地】,不定时会分享一些小功能~

感谢阅读~拜拜~

nextjs学习1:回顾服务端渲染SSR

2025年12月30日 19:49

服务端渲染的发展历史

传统的服务端渲染

传统的服务端渲染有 asp, jsp(java), ejs(nodejs)等,服务端语言往往通过这些模板引擎将数据 datahtml 结构在服务端组装,返回一个完整的静态的 html 字符串给客户端,由客户端直接显示。

缺点

  • 前后端不分离,前后端代码混在一个工程目录中,维护不方便,。
  • 用户体验不佳,每次页面有改动都需要重新加载整个页面。比如,一个列表页面,当用户增加一项时,后台需要重新组装数据 datahtml 结构,返回一个新的页面给前端,这样用户才能看到页面的变化。
  • 服务端压力大,不仅要响应静态 html 文件,还要响应数据api接口。

客户端渲染(CSR)

在现代化的前端项目中,客户端渲染的代表性技术栈是 vue/react/angular,我们常常使用它们来构建客户端单页或者多页应用程序。

SPA 构建程序为例,在浏览器端首先渲染的是一套空的 html,然后下载bundle.js并执行,通过 JavaScript 直接进行页面的渲染和路由跳转等操作,所有的数据通过 ajax 请求从服务端获取。

路由的跳转是通过history api 实现的,它最大的作用是能改变url地址,但是不会刷新页面,也就是不会发送请求。这样对用户操作来说就非常的无感,克服了传统服务端渲染每次都要请求服务器的困扰。

缺点

  • 首屏加载慢,因为第一次会请求一个空的html文件,再去加载 bundle.js等打包后的文件。
  • 不利于网站 SEO,因为首次请求回来的是空的 html 文件,爬虫无法获取有效内容信息,其实现在的爬虫也能爬取spa网站了。

现代服务端渲染(同构)

我们现在讲的服务端渲染概念,是指在前端范畴或者说在 Vue/React 等单页面技术范畴内的,基于 Nodejs server 运行环境的服务端渲染方案,这种方案的本质是同构渲染。它的步骤如下:

  1. Nodejs 中运行相同的前端代码,将用Vue/React框架写的代码转化为html 结构,然后返回给浏览器渲染,这样爬虫就能爬取到完整的页面信息。
  2. 客户端获取到服务端返回的页面后,再进行注水(hydrate)化处理,由客户端代码(SPA代码)来接管页面。

为什么要进行注水处理呢?

因为服务端环境毕竟不同于浏览器环境,缺少浏览器环境必要的变量和API。比如,页面中的点击事件就无法在服务端进行注册,因为在服务端环境中是没有DOM节点的概念的,它只是一堆字符串而已,自然无法使用 document.addEventListener 这样的API。也就是如果客户端代码不接管页面,那么页面里面所有的点击事件将不可用。

什么是同构?

同构简单来讲就是服务端和客户端复用同一套代码。比如,页面html结构、store数据存储、router路由都能共享一套代码。这就是所谓的现代服务端渲染:同构

缺点

  1. SSR 的数据获取必须在组件渲染之前;
  2. 组件的 JavaScript 必须先加载到客户端,才能开始水合;
  3. 所有组件必须先水合,然后才能跟其中任意一个组件交互;

可以看出 SSR 这种技术大开大合,加载整个页面的数据,加载整个页面的 JavaScript,水合整个页面,还必须按此顺序串行执行。如果有某些部分慢了,都会导致整体效率降低。

此外,SSR 只用于页面的初始化加载,对于后续的交互、页面更新、数据更改,SSR 并无作用

为什么选择 SSR?

相比于客户端渲染 CRS (单页面应用),SSR 主要的好处是:

更快的内容呈现

尤其是网络连接缓慢或设备运行速度缓慢的时候,服务端标记不需要等待所有的 JavaScript 脚本都被下载并执行之后才显示,所以用户可以更快看到完整的渲染好的内容。这带来了更好的用户体验,同时对于内容呈现时间和转化率呈正相关的应用来说尤为关键

更好的搜索引擎优化 (SEO)

因为后端会一次性的把网站内容返回给前端,所以搜索引擎爬虫会直接读取完整的渲染出来的页面。但如果你的JavaScript 脚本是通过 API 调用获取内容,则爬虫不会等待页面加载完成。这意味着如果你的页面有异步加载的内容且 SEO 很重要,那么你可能需要 SSR。

除了上面两个优点外,这里还有一些点来决定是是否选用SSR:

  • 开发一致性。浏览器特有的 API 只能在特定的生命周期钩子中使用;一些外部的库在服务端渲染应用中可能需要经过特殊处理。
  • 需要更多的构建设定和部署要求。不同于一个完全静态的 SPA 可以部署在任意的静态文件服务器,服务端渲染应用需要一个能够运行 Nodejs 服务器的环境。
  • 更多的服务端负载。在 Nodejs 中渲染一个完整的应用会比仅供应静态文件产生更密集的 CPU 运算。所以如果流量很高,请务必准备好与其负载相对应的服务器,并采取明智的缓存策略。

在应用中使用 SSR 之前,你需要问自己的第一个问题是:你是否真的需要它?

它通常是由内容呈现时间对应用的重要程度决定的

例如,如果你正在搭建一个内部管理系统,几百毫秒的初始化加载时间对它来说无关紧要,这种情况下就没有必要使用 SSR。然而,如果内容呈现时间非常关键,SSR 可以助你实现最佳的初始加载性能。

SSR vs 预渲染

如果你仅希望通过 SSR 来改善一些推广页面 (例如 //about/contact 等) 的 SEO,那么预渲染也许会更合适。和使用动态编译 HTML 的 web 服务器相比,预渲染可以在构建时为指定的路由生成静态 HTML 文件。

如果你正在使用 webpack,你可以通过 prerender-spa-plugin 来支持预渲染。

现代服务端渲染(同构)大致实现原理

通过前面的介绍,服务端渲染就是返回一个带有具体内容的 html 字符串给浏览器,那么这个具体的内容是什么呢?

这个具体的内容就是用 Vue 开发的页面内容,但是如果直接把带有 Vue 语法塞进 html 模板浏览器根本无法识别,因此,服务端渲染也需要使用 Vite 进行编译打包转化为浏览器能识别的javascript语法。

根据同构概念理解,客户端和服务端是共用同一套的页面内容代码的,所以客户端和服务端需要分别打包编译。

首先就是编写通用代码,适用于客户端和服务端。

一. 编写通用代码

由于平台 API 的差异,当运行在不同环境中时,我们写的通用代码将与纯客户端代码不会完全相同。需要注意一下几点:

1. 避免状态单例

在纯客户端应用程序中,每个用户会在他们各自的浏览器中使用新的应用程序实例。对于服务器端渲染,我们也希望如此。

但是,Node.js 服务器是一个长期运行的进程。当我们的代码进入该进程时,它将进行一次取值并留存在内存中。这意味着如果创建一个单例对象,它将在每个传入的请求之间共享。

所以,必须要求每个请求都应该都是全新的、独立的应用程序实例,以便不会有交叉请求造成的状态污染。

因此,我们不应该直接创建一个应用程序实例,而是应该暴露一个可以重复执行的工厂函数,为每个请求创建新的应用程序实例:

对原有客户端代码代码进行改造:

main.js

# 原有代码
const app = createApp(App)
app.config.globalProperties.$message = ElMessage
app.use(router)
app.use(store, key)
app.use(ElementPlus)
app.use(i18n)
app.mount('#app')

# 改造后代码:变为工厂函数
import { createSSRApp } from 'vue'
export function createApp() {
  const app = createSSRApp(App)
  const store = createSSRStore()
  const router = createSSRRouter()
  const i18n = createSSRI18n()
  sync(store, router)
  app.config.globalProperties.$message = ElMessage
  app.use(store, key)
  app.use(router)
  app.use(ElementPlus)
  app.use(i18n)

  return { app, router, store }
}

同理,项目中的数据存储store,路由router等都需要改造成工厂函数的形式,比如路由:

export function createSSRRouter() {
  return createRouter({
    # import.meta.env.SSR是vite提供环境变量
    # 服务端渲染只能用createMemoryHistory
    history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
    routes
  })
}

2. 组件生命周期钩子函数

由于服务端没有动态更新,所有的生命周期钩子函数中,只有 beforeCreate 和 created 会在服务器端渲染 (SSR) 过程中被调用。这就是说任何其他生命周期钩子函数中的代码(例如 beforeMount 或 mounted),只会在客户端执行。

此外还需要注意的是,你应该避免在 beforeCreate 和 created 生命周期时产生全局副作用的代码,例如在其中使用 setInterval 设置 timer。在纯客户端的代码中,我们可以设置一个 timer,然后在 beforeDestroy 或 destroyed 生命周期时将其销毁。但是,由于在 SSR 期间并不会调用销毁钩子函数,所以 timer 将永远保留下来。

3. 访问特定平台API

通用代码不可接受特定平台的 API,因此如果你的代码中,直接使用了像 window 或 document,这种仅浏览器可用的全局变量,则会在 Node.js 中执行时抛出错误,反之也是如此。

对于共享于服务器和客户端,但用于不同平台 API 的任务,建议将平台特定实现包含在通用 API 中,例如,axios是一个 HTTP 客户端,可以向服务器和客户端都暴露相同的 API。

请注意,考虑到如果第三方 library 不是以上面的通用用法编写,则将其集成到服务器渲染的应用程序中,可能会很棘手。

二、 构建步骤

编写好通用代码之后,就需要使用构建工具webpackVite进行打包构建,我们将会采用Vite进行构建,构建过程如下:

image.png

1. 创建客户端入口和服务端入口文件

一个典型的 SSR 应用应该有如下的源文件结构:

- index.html # 模版html文件
- server.ts # main application server
- src/
  - main.js # 公共通用代码,不是入口文件
  - entry-client.ts  # 客户端入口: 将应用挂载到一个 DOM 元素上
  - entry-server.ts  # 服务端入口:使用某框架的 SSR API 渲染该应用

index.html

首先看下index.html文件,它原来长这个样子:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>一个经典的前台项目</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

<script type="module" src="/src/main.js"></script>中的type="module"的含义与带 defer 的普通脚本一致:

  • 不会阻塞 HTML 解析;
  • 会等待 HTML 解析完成后,按脚本在页面中的顺序执行;
  • 普通脚本(无 defer/async)会阻塞解析,执行完才继续解析 HTML。

开发完代码后,执行npm run build进行打包后长这个样子:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>一个经典的前台项目</title>
    <script type="module" crossorigin src="/assets/index-IiCrRs2g.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-NLCfHQLN.css">
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

其中的index-IiCrRs2g.js是整个前端应用的核心入口文件,其核心作用是启动 Vue 应用、挂载根组件,并加载应用运行所需的核心依赖 / 资源。

现在需要改造如下:

index.html 将需要引用 entry-client.ts,而不是原来的main.ts,并包含一个占位标记供给服务端渲染时注入:

<div id="app"><!--ssr-outlet--></div>
<script type="module" src="/src/entry-client.ts"></script>

服务端渲染后的html字符串会替换<!--ssr-outlet-->/src/entry-client.ts代码主要是用于接管页面,使其具备交互能力。

entry-client.ts

import { createApp } from './main'

const { app, router, store } = createApp()

router.isReady().then(() => {
  app.mount('#app')
})

entry-server.ts

import { createApp } from './main'
import { renderToString } from '@vue/server-renderer'

// 服务端渲染核心函数
export async function render(url) {
  const { app, router, store } = createApp() 
  # 根据url来渲染对应的页面
  await router.push(url) 
  await router.isReady() 
  const appHtml = await renderToString(app) 
  return appHtml
}

server.ts

const fs = require('fs')
const path = require('path')
const express = require('express')
const serveStatic = require('serve-static')
const { createServer: createViteServer } = require('vite')

async function createServer() {
  const app = express()

  const vite = await createViteServer({
    server: { middlewareMode: 'ssr' }
  })
 
  app.use(vite.middlewares)

  app.use('*', async (req, res) => {
    const url = req.originalUrl
    # 1. 读取 index.html
    let template = fs.readFileSync(path.resolve(__dirname, 'index.html'),
          'utf-8')
          
    template = await vite.transformIndexHtml(url, template)      
          
    # 2. entry-server.ts 暴露了render方法
    let render = (await vite.ssrLoadModule('/src/entry-server.ts')).render
       
    # 3. 根据url渲染对应的
    const appHtml = await render(url)
    
    # 4. 插入到div中
    const html = template.replace('<!--ssr-outlet-->', appHtml)
    
    res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
   
  })

  app.listen(3000, () => {
    console.log('node server run at:', isProd ? '生产环境' : '开发环境')
  })
}

createServer()

package.json

package.json 中的 dev 脚本也应该相应地改变,使用服务器脚本:

"scripts": {
    // "dev": "vite"
    "dev": "cross-env NODE_ENV=development node server.js",
}

2. 打包客户端和服务端代码

package.json 中的脚本应该看起来像这样:

{
  "scripts": {
    "dev": "node server",
    "build:client": "vite build --outDir dist/client",
    "build:server": "vite build --outDir dist/server --ssr src/entry-server.js "
  }
}

使用 --ssr 标志表明这将会是一个 SSR 构建,同时需要指定 SSR 的入口。

接着,在 server.js 中,通过 process.env.NODE_ENV 条件,需要添加一些用于生产环境的特定逻辑:

  • index.html模版变更:使用 dist/client/index.html 作为模板,而不是根目录的 index.html,因为前者包含了到客户端构建的正确资源链接。
  • 服务端入口文件变更:原来是/src/entry-server.js, 现在要使用 import('./dist/server/entry-server.js')

修改后代码如下:

const fs = require('fs')
const path = require('path')
const express = require('express')
const serveStatic = require('serve-static')
const { createServer: createViteServer } = require('vite')

const isProd = process.env.NODE_ENV === 'production'

async function createServer() {
  const app = express()

  const vite = await createViteServer({
    server: { middlewareMode: 'ssr' }
  })
  
  # 在生产环境需要vite与express进行脱钩
  if (!isProd) {
    # 使用 vite 的 Connect 实例作为中间件,利用这个中间件来起一个静态资源服务器
    app.use(vite.middlewares)
  } else {
    # 在生产环境,利用express框架自带的中间件serve-static,利用这个中间件来起一个静态资源服务器
    # 把dist/client文件夹下的资源都可以访问
    app.use(
      serveStatic(path.resolve(__dirname, 'dist/client'), { index: false })
    )
  }

  app.use('*', async (req, res) => {
    const url = req.originalUrl
    let template
    let render
    try {
      # 在生产环境需要vite与express进行脱钩
      if (!isProd) {
        // 1. 读取 index.html
        template = fs.readFileSync(
          path.resolve(__dirname, 'index.html'),
          'utf-8'
        )
        
        # 2. 应用 Vite 进行 HTML 转换,这将会注入 Vite HMR 客户端,
        template = await vite.transformIndexHtml(url, template)
        
        # 3. 加载服务器入口文件,vite.ssrLoadModule 将自动转换你的 ESM 源码使之可以在 Node.js 中运行。既然是加载文件,肯定是异步的,所以使用await
        render = (await vite.ssrLoadModule('/src/entry-server.ts')).render
      } else {
        # 1. 生产环境需要加载编译后的模版文件index.html
        template = fs.readFileSync(
          path.resolve(__dirname, 'dist/client/index.html'),
          'utf-8'
        )
        # 2. 使用SSR构建后的最终render函数
        render = require('./dist/server/entry-server.js').render
      }

      # 4. 渲染应用的 HTML
      const appHtml = await render(url, manifest)

      # 5. 注入渲染后的应用程序 HTML 到模板中。
      const html = template
        .replace('<!--ssr-outlet-->', appHtml)
       
      // 6. 返回渲染后的 HTML。
      res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
    } catch (e) {
      ...
    }
  })

  app.listen(3000, () => {
    console.log('node server run at:', isProd ? '生产环境' : '开发环境')
  })
}

createServer()

三、数据获取

在服务器端渲染(SSR)期间,我们本质上是在渲染我们应用程序的"快照",所以如果应用程序依赖于一些异步数据,那么在开始渲染过程之前,需要先预取和解析好这些数据

另一个需要关注的问题是在客户端代码接管时,需要获取到与服务器端应用程序完全相同的数据,否则客户端应用程序会因为使用与服务器端应用程序不同的状态,然后导致水合失败。

为了解决这个问题,获取的数据需要位于视图组件之外,即放置在专门的数据预取存储容器(data store)或"状态容器(state container)"中。

为此,我们将使用官方状态管理库 Vuex。

那么,我们在哪里放置dispatch 数据预取 action的代码?

事实上,给定路由所需的数据,也是在该路由上渲染组件时所需的数据。所以在路由组件中放置数据预取逻辑,是很自然的事情。

我们将在路由组件上暴露出一个自定义静态函数 asyncData。注意,由于此函数会在组件实例化之前调用,所以它无法访问 this。需要将 store 和路由信息作为参数传递进去:

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

async function asyncData({ store, route }: any) {
 return store.dispatch('getRoomList')
}

defineExpose({ asyncData })
</script>

1. 服务器端数据预取

在 entry-server.js 中,我们可以通过路由获得相匹配的组件,如果组件暴露出 asyncData,我们就调用这个方法。然后我们需要将解析完成的数据,绑定到到window上。

修改entry-server.ts:

export async function render(url: string, manifest: any) {
  const { app, router, store } = createApp()
  
  await router.push(url)
  await router.isReady()

  const matchedComponents = router.currentRoute.value.matched.flatMap(record =>
    Object.values(record.components)
  )
 
  await Promise.all(
    matchedComponents.map((Component: any) => {
      # 如果组件中定义了asyncData函数,说明这个组件需要去后台获取接口数据
      if (Component.asyncData) {
        return Component.asyncData({
          // 传入store和当前route,store参数用来执行store.dispatch,发起请求
          store,
          route: router.currentRoute
        })
      }
      return []
    })
  )

  const appHtml = await renderToString(app)
  
  # 此时state里面包含了请求接口获取的数据,然后就把这个数据绑定到window某个属性上
  # 这样当同构的时候,客户端的store.state就能用这个值作为初始化化数据,就不用再去调一次接口了
  const state = store.state
 
  return { appHtml, state }
}

修改模板文件index.html:

<div id="app"><!--ssr-outlet--></div>
<script type="module" src="/src/entry-client.ts"></script>
<script>
  window.__INITIAL_STATE__ = '<!--vuex-state-->'
</script>

修改server.ts

const { appHtml, state } = await render(url)
const html = template
    .replace('<!--ssr-outlet-->', appHtml)  
    .replace("'<!--vuex-state-->'", JSON.stringify(state))

修改entry-client.js: 使用__INITIAL_STATE__来初始化store,这样客户端接管时也是带有数据的。

const { app, router, store } = createApp()

if ((window as any).__INITIAL_STATE__) {
  store.replaceState((window as any).__INITIAL_STATE__)
}

2. 客户端数据预取

首先思考下为什么会有客户端数据预取?

当我从服务端请求页面内容(/login)后,客户端代码立即接管页面,此时页面跳转时不会再向服务端发送请求了。

当从/login页面跳转到/home页面,由于组件的部分生命周期在服务端不能使用,我们没有在代码中写onMounted钩子函数,也就无法执行获取接口的函数,那么此时/home页面是没有数据的。所以就需要在客户端进行数据预取,既然组件生命周期钩子不能用,还有什么钩子可以用呢?

答案是路由钩子。

修改entry-client.ts:

router.isReady().then(() => {
  # beforeResolve表示所有的异步组件全部resolve了
  router.beforeResolve((to, from, next) => {
    # 找出两个匹配列表的差异组件。
    # 如果你刷新当前页面,会发送请求到服务器,服务前拼接好数据和html返回前端,但是有了这个路由钩子,它还会再去请求一遍数据,这就相当于前后台都去请求了一次回去,这没有必要。
    # 所以需要做一个判断是否是在刷新页面,也就是to和from是不是一样的,如果一样的,就是刷新操作,那么actived为空,不会执行后面的逻辑,也就是客户端不会再次请求数据接口,用服务端带过来的数据就可以了,这就是防止客户端数据二次预取。
    const toComponents = router
      .resolve(to)
      .matched.flatMap(record => Object.values(record.components))
    const fromComponents = router
      .resolve(from)
      .matched.flatMap(record => Object.values(record.components))

    const actived = toComponents.filter((c, i) => {
      return fromComponents[i] !== c
    })
    # 客户端预取数据有两种方式:
    # 一种是在匹配到路由视图之后就跳转,然后去请求接口,
    # 另一种是在请求接口数据返回后再进行跳转,此时页面是包含数据的
    if (!actived.length) {
      return next()
    } else {
      # 第一种: 匹配路由之后直接跳转,然后去请求接口
      // next()
    }
    // 显示loading
    const loadingInstance = ElLoading.service({
      lock: true,
      text: 'Loading',
      background: 'rgba(0, 0, 0, 0.7)'
    })

    Promise.all(
      actived.map((Component: any) => {
        if (Component.asyncData) {
          return Component.asyncData({
            store,
            route: router.currentRoute
          })
        }
        return []
      })
    ).then(() => {
      // 关闭loading
      loadingInstance.close()
      # 第二种: 等数据请求之后再跳转
      next()
    })
  })

  app.mount('#app')
})

至此,整个服务端渲染就基本完成了。

昨天 — 2025年12月30日掘金 前端

🚀 从零开始:如何从 GPTsAPI.net 申请 API Key 并打造自己的 AI 服务

作者 excel
2025年12月30日 19:18

在国内开发 AI 产品时,直接使用 OpenAI 官方 API 经常会遇到网络、注册和付费等障碍。GPTsAPI.net 提供了一个兼容 OpenAI 协议的 API 服务

本文将从注册、申请 API Key 到后端集成逐步拆解,并结合真实 Nuxt 后端代码解释如何打造一个稳定、可控的 AI 产品。


🧠 一、什么是 GPTsAPI.net?

它的主要特点包括:

  • 支持兼容 OpenAI 的 API 调用方式(如 /v1/chat/completions知乎专栏
  • 在国内访问更稳定
  • 模型资源丰富,且支持按量计费

📍 二、如何在 GPTsAPI.net 上申请 API Key

1. 访问 GPTsAPI.net 官网

首先打开:gptsapi.net 你会看到平台介绍和入口页面。

注:GPTsAPI.net 没有公开 API 文档页面,但其官网提供了注册入口和控制台用于 API Key 管理。


2. 注册账号

  1. 点击 注册 / 登录 按钮
  2. 使用邮箱或手机号完成注册
  3. 进入用户控制台

3. 在控制台中创建 API Key

在控制台界面中你可以看到 API Key 管理界面

  • 点击 “创建 API Key”
  • 系统会生成一个独一无二的 Key

通常你会看到形如:
sk-xxx... 或者由平台生成的一串字符

请务必保管好这个 Key!


4. 充值 / 选择计费计划(如有)

部分 API Key 需要充值才能调用,你可以在控制台中选择计费方式并完成充值。

不同模型的价格可能不同,例如:

模型 价格(示例)
GPT-4 系列 较高定价
Claude 系列 不同版本价格不同
其他轻量模型 特定价格 知乎专栏

🛠️ 三、如何在后端使用 GPTsAPI Key

有了 API Key 之后,你就可以通过标准的 OpenAI API 兼容方式调用 GPTsAPI 了。

基本原则:
✨ 把 API Key 放在后端,不暴露给前端
✨ 使用统一基地址 https://api.gptsapi.net/v1

下面是关键的集成方式(基于你提供的 Nuxt 后端代码)。


🔌 四、后端集成示例代码(Nuxt 3 + OpenAI SDK)

import OpenAI from "openai"
import { Config } from "~/utils/Config"

export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  const {
    model = "gpt-5-nano",
    prompt = "",
    history = [],
    summary = "",
    files = [],
  } = body;

  const client = new OpenAI({
    apiKey: Config.AI_API_KEY(),
    baseURL: Config.AI_API_BASE_URL(),
  })

🔹 Config.AI_API_KEY() 的值需要是你在 GPTsAPI.net 申请的 Key
🔹 Config.AI_API_BASE_URL() 通常填 https://api.gptsapi.net/v1


✂️ 五、清洗历史消息 & 多模态内容构造

为了提升生成质量,你的代码中做了以下优化:

const cleanHistory = (history || []).filter((m) => {
  // 过滤无效历史
})

这样可以避免无效、空内容浪费 token。并支持文本 + 图片输入逻辑:

userContent.push({
  type: "image_url",
  image_url: { url: `data:${f.type};base64,${f.data}` },
})

📡 六、流式响应与产品体验

调用 API 时你采用了流式输出:

const completion = await client.chat.completions.create({
  model,
  stream: true,
  messages,
})

并使用 Nuxt 的 sendStream 将实时输出返回前端,这样:

  • 用户前端可以像 ChatGPT 一样实时看到结果
  • 响应体验更好

💡 七、产品级注意事项

  1. 不要在前端暴露 API Key
  2. 对调用进行限流与监控
  3. 设置最大 token 限制
  4. 做好错误兜底与重试策略

🧾 八、示例请求方式(curl)

当你获取 Key 以后,可以在本地测试:

curl https://api.gptsapi.net/v1/chat/completions \
 -H "Authorization: Bearer YOUR_API_KEY" \
 -H "Content-Type: application/json" \
 -d '{
  "model": "gpt-3.5-turbo",
  "messages": [{"role": "user","content": "Hello"}]
}'

📌 这里使用的就是GPTsAPI 提供的兼容 OpenAI 的 API知乎专栏


✅ 九、总结

通过 GPTsAPI.net 申请 Key 的步骤可以概括为:

  1. 打开 gptsapi.net
  2. 注册账号
  3. 进入控制台创建 API Key
  4. 在后端使用这个 Key 调用模型接口

本文部分内容借助 AI 辅助生成,并由作者整理审核。

防抖 vs 节流:从百度搜索到京东电商,看前端性能优化的“节奏哲学”

作者 栀秋666
2025年12月30日 18:51

🔍引言

在现代 Web 应用中,用户交互越来越频繁——你敲一个字、滑一次屏、点一下按钮,背后可能触发数十次事件回调。如果每个动作都立刻执行复杂逻辑(比如请求接口、重绘 DOM),轻则卡顿,重则页面崩溃。

而真正优秀的用户体验,往往藏在那些你看不见的地方:
👉 百度输入“前端”后不急着搜,而是等你停顿才出建议;
👉 京东滚动加载商品时,不会“刷屏式”疯狂请求数据……

这一切的背后,是两个看似简单却威力巨大的技术——防抖(Debounce)与节流(Throttle)

本文将带你深入剖析它们的实现原理、适用场景与实战差异,结合百度、京东的真实案例,揭示前端性能优化中的“节奏控制艺术”。


🌪️ 一、为什么我们需要“节制”函数?

想象你在餐厅点餐:

  • 如果服务员每听到你说一个菜名就跑去厨房下单 → 厨房炸锅;
  • 正确做法是:等你说完所有菜,再统一提交订单。

前端开发也是如此。以下高频事件若不做处理,极易造成资源浪费:

事件类型 触发频率 潜在问题
input / keyup 每输入一个字符触发一次 多余的 Ajax 请求
scroll 滚动期间持续触发 频繁计算位置导致重排重绘
resize 窗口拖拽时密集触发 布局重算影响渲染性能
click 快速点击多次 表单重复提交、订单创建异常

这些问题的本质是:事件触发频率远高于我们实际需要的执行频率

于是,我们引入两位“节制大师”——

🎯 防抖(Debounce):只响应最后一次操作
⏱️ 节流(Throttle):按固定节奏响应操作

它们不是消灭事件,而是教会函数“何时该说话”。


💡 二、防抖(Debounce)—— 百度搜索的“冷静期智慧”

📍 典型场景:搜索建议延迟显示

当你在百度搜索框输入“JavaScript ”,

image.png

你会发现:

  • 输入过程中,并没有实时发起请求;
  • 只有当你停下来约 300ms 后,才看到下拉建议弹出。

这正是防抖的经典应用:等待用户操作结束后的“静默时刻”,再执行真正逻辑

如果没有防抖?
输入 5 个字 → 发起 5 次请求 → 服务器压力翻倍 + 用户体验混乱(旧结果覆盖新结果)。

用了防抖?
无论你打了多久,最终只发一次请求 —— 干净利落。

✅ 实现原理:闭包 + 定时器 = “重置倒计时”

function debounce(fn,delay){
  var id;  //自由变量
  return function(args){
       if(id) clearTimeout(id);
       var that=this; //用that保存this
        id=setTimeout(function(){
        // fn.call(that); 
        fn.call(that,args);
        },delay);
  }
 }

🔧 关键点解析:

  • clearTimeout(id):每次触发都取消之前的计划,确保只有最后一次生效。
  • setTimeout:设置“冷静期”,期间无新动作则执行。
  • call(this, args):保持原函数调用上下文和参数完整。

🧠 类比理解:电梯关门机制

就像写字楼的电梯——有人进来就暂停关门,直到连续 3 秒没人进出,才自动关闭运行。
防抖就是给函数加了个“智能门禁”,只让最后一个人进去。

🛠️ 实战示例:绑定搜索框

<input type="text" id="searchInput" placeholder="请输入关键词">
const inputEl = document.getElementById('searchInput');

function fetchSuggestions(keyword) {
  console.log('请求后端获取建议:', keyword);
  // 这里可以调用 API
}

// 使用防抖包装请求函数
const debouncedFetch = debounce(fetchSuggestions, 300);

inputEl.addEventListener('input', (e) => {
  debouncedFetch(e.target.value);
});

✅ 效果:快速输入不停止 → 不请求;停止输入 300ms → 请求一次最新值。


⏱️ 三、节流(Throttle)—— 京东滚动加载的“发车节奏”

📍 典型场景:无限滚动商品列表

打开京东首页,向下滚动浏览商品:

image.png

lQLPJxRxFJIgWT_NA7bNBk-wbMl5INfmGnYJLTRxXOrIAA_1615_950.png

  • 即使你飞速滑动鼠标滚轮;
  • 商品也不会瞬间全加载出来;
  • 而是每隔半秒左右“分批”出现新内容。

这不是网络慢,而是节流在工作:控制函数以固定频率执行,防止过度消耗资源

如果没有节流?
滚动一下触发几十次判断 → 频繁请求接口 → 数据错乱、内存飙升。

用了节流?
哪怕你滚得再快,也保证每 500ms 最多加载一次 → 系统稳定、体验流畅。

✅ 实现原理:时间戳 + 定时器 = “节拍器模式”

function throttle(fn, delay) {
  let lastTime = 0;       // 上次执行时间
  let deferTimer = null;  // 延迟执行的定时器

  return function (...args) {
    const context = this;
    const now = Date.now();

    if (now - lastTime > delay) {
      // 时间到了,立即执行
      lastTime = now;
      fn.apply(context, args);
    } else {
      // 时间未到,安排最后一次触发兜底
      clearTimeout(deferTimer);
      deferTimer = setTimeout(() => {
        lastTime = now;
        fn.apply(context, args);
      }, delay);
    }
  };
}

🔧 关键点解析:

  • Date.now() 获取当前时间戳,用于比较间隔;
  • lastTime 记录上次执行时间,决定是否放行;
  • deferTimer 是“补票机制”——防止最后一次触发被遗漏。

🚂 类比理解:地铁发车制度

地铁不管站台人多人少,都是每 5 分钟发一班车。
节流就像这个“准时发车系统”,不管你滚得多猛,我都按我的节奏来。

🛠️ 实战示例:监听页面滚动加载

function checkIfNearBottom() {
  const scrollTop = window.pageYOffset;
  const clientHeight = window.innerHeight;
  const scrollHeight = document.body.scrollHeight;

  if (scrollTop + clientHeight >= scrollHeight - 100) {
    console.log('接近底部,加载下一页商品');
    // loadMoreProducts();
  }
}

// 包装成节流函数
const throttledScroll = throttle(checkIfNearBottom, 500);

window.addEventListener('scroll', throttledScroll);

✅ 效果:快速滚动时,最多每 500ms 检查一次是否到底部,避免无效计算。


🆚 四、防抖 vs 节流:一张表说清所有区别

维度 防抖(Debounce) 节流(Throttle)
核心思想 等待“风平浪静”后再行动 按固定节奏稳步推进
执行次数 只执行最后一次 每个时间间隔至少执行一次
触发时机 延迟结束后执行 间隔开始或结束时执行
典型应用场景 搜索建议、表单验证、窗口 resize 滚动加载、拖拽、高频点击
函数执行频率 极低(可能全程只执行 1 次) 稳定(如 1s 内触发 20 次,仍只执行 2 次)
生活类比 电梯等人上齐再关门 地铁准点发车,不等人满
适合的操作特征 希望“完成后才处理” 希望“过程中定期反馈”

📊 执行行为对比(假设 delay = 300ms)

时间线(ms) 0 100 200 300 400 500 600 700
事件触发
防抖执行 ✅(仅最后一次)
节流执行 ✅(每 ~300ms 一次)

💡 结论:

  • 防抖追求“精简”,牺牲过程保结果;
  • 节流追求“节奏”,平衡效率与负载。

🎯 五、如何选择?三大决策原则

面对高频事件,别再盲目使用 setTimeout 抹黑了。根据业务目标做理性选择:

✅ 原则 1:看“要不要中间反馈”

  • 不需要中间状态?选防抖
    如搜索框输入:中间结果没意义,只要最终关键词。
  • 需要过程反馈?选节流
    如游戏手柄摇杆移动:必须持续响应方向变化。

✅ 原则 2:看“是否允许延迟”

  • 能接受短暂停顿?防抖更省资源
    如用户名唯一性校验,等用户输完再查。
  • 要求即时响应?节流更合适
    如音量调节滑块,必须实时更新 UI。

✅ 原则 3:看“执行成本高低”

  • ✅ 成本极高(如发邮件、下单)→ 优先防抖,防止误操作;
  • ✅ 成本较低但频次高(如监听鼠标位置)→ 优先节流,维持节奏。

🧩 六、进阶技巧 & 最佳实践

1. 支持立即执行的防抖(Leading Edge)

有时我们希望“第一次立刻执行”,后续才防抖:

function debounceImmediate(fn, delay, immediate = false) {
  let timerId;

  return function (...args) {
    const callNow = immediate && !timerId;
    const context = this;

    clearTimeout(timerId);

    if (callNow) {
      fn.apply(context, args);
    }

    timerId = setTimeout(() => {
      timerId = null;
      if (!immediate) fn.apply(context, args);
    }, delay);
  };
}

📌 适用场景:按钮点击防重复提交,首次点击立刻生效。


2. 节流的两种策略:时间戳 vs 定时器

类型 特点 缺点
时间戳版 首次立即执行,末次可能丢失 若停止触发,最后一次不会执行
定时器版 保证每次都能执行,节奏稳定 第一次会有延迟

推荐使用文中提供的“混合模式”:兼顾首次与末次。


3. 实际项目中的配置建议

场景 推荐延迟/间隔 说明
搜索建议 200–300ms 太短易误触,太长影响体验
滚动加载 500–800ms 给浏览器留出渲染时间
窗口 resize 300ms 避免频繁重排
表单实时验证 400ms 用户打字节奏匹配
高频按钮防重复提交 1000ms 提交后需等待接口返回,防止双订单

⚠️ 注意:不要硬编码!建议通过配置项动态调整,便于 A/B 测试优化。


🏁 七、总结:掌握“节奏感”,才是高级前端

防抖与节流,表面是两个工具函数,实则是前端工程师对 用户行为节奏的理解

🔥 真正的性能优化,不只是减少请求,更是学会“等待”与“克制”

  • 百度用防抖告诉我们:有时候慢一点,反而更快
  • 京东用节流提醒我们:再激烈的动作,也要有章法地应对

在高并发、强交互的时代,每一个优雅的交互背后,都有一个默默守候的 setTimeout


❌
❌