阅读视图

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

HarmonyOS官方模板集成创新活动-流蓝卡片

HarmonyOS官方模板集成创新活动-流蓝卡片

介绍

流蓝卡片是一款适配了的鸿蒙6-API20的HarmonyOS应用,目的在于给用户提供方便、简单的方式创建好看的卡片,用于将卡片发布到各种社交平台上。

实现过程

目前是AI Codeing的时代,流蓝卡片其实也是基于AI Coding的产物,人工参与代码部分不超过5%。

这款应用用到的亮点技术有:GLM4.6 + Gemini-3-pro + 智谱图片生成 + command line 构建鸿蒙工程。

这套技术组成可以极大方便我开发鸿蒙应用。

集成官方模板

考虑到流蓝卡片目前是一个工具类型的应用,所以需要开发更多的一些相关工具,由于官方模板中提供了众多能力,这里就直接接入官方模板的-通用工具模板了。

1. 寻找合适的官方模板

在该网址中寻找你想要的官方模板,进行下载使用。

官方模板地址:developer.huawei.com/consumer/cn…



2. 下载安装

根据使用文档,点击集成

3. 调整相关依赖

根据自己当前工程需要,来调整引入的方式,如

Entry模块引入 Home模块,Home模块引入所有的工具模块


4. 开发调试以及发布上架

由于工具中需要用到不少硬件资源,所以需要申请相关权限。

这一部分权限需要在AGC平台上进行申请,接着跟着生成最新的调试和发布证书,否则开发调试和上架都会因为权限问题导致失败。

流蓝卡片卡片的技术亮点

  1. V2状态管理 - 全面采用@ObservedV2、@Trace、@ComponentV2等新一代状态管理技术
  2. HAR模块化架构 - 30+独立HAR组件包,可独立开发、测试和发布
  3. ZRouter路由方案 - 基于Navigation的声明式路由管理
  4. 关系型数据库 - 完整的RdbManager + CardTable/InspirationTable/TemplateTable封装
  5. 流畅动画 - 入场阶梯式动画,opacity + translate + delay实现平滑过渡
  6. 浮动TabBar - 自定义底部导航栏,带浮动"+"按钮和阴影效果
  7. @Extend组件扩展 - 提高代码复用性
  8. AppStorage全局状态 - 跨页面状态共享
  9. 多环境构建 - 调试/发布双环境配置
  10. 丰富工具生态 - 30+实用工具(计算、测量、创作、实用类)

总结

鸿蒙模板市场中提供了大量优质的三方组件和模板,提高了开发者开发同类功能的效率,点赞。

如果你也想要了解HarmonyOS开发最新信息,欢迎技术交流

我的博客: blog.zbztb.cn

目前我们上架的作品也已经达到了20+个。

关于青蓝逐码组织

如果你兴趣想要了解更多的鸿蒙应用开发细节和最新资讯甚至你想要做出一款属于自己的应用!欢迎在评论区留言或者私信或者看我个人信息,可以加入技术交流群。

半年一百个页面,重构系统也重构了我对前端工作的理解

为什么想去重构

在现在的部门已经呆了两年多,系统后台使用的是 vben2 的框架搭建的,旧框架版本滞后导致bug频发,重复代码堆积成“屎山”,交互设计割裂影响用户体验,多环境维护成本翻倍……这些问题像藤蔓一样缠绕着日常开发,既消耗着大量时间精力,也让技术价值难以充分释放。

所以我一直盘算着如何去完成整个系统框架的升级,中间我一直关注 vben5 的开源进度,在 vben5 没有完成表单和表格的全部功能实现之前,对于系统的重构就始终搁置。但是中间有两个新项目我用了 vben3 框架搭建,使用下来的感受与 vben2 并没有太大的区别,没有达到我的预期,所以就继续等待 vben5 的进度。

之前组内前端是三个人,后来按业务划分我这条线就是两个前端在维护,上半年的时候与我共事的这位同事产假,于是所有的迭代压力自然而然的压到了我的身上,当时我的第一想法就是,这或许是全量重构系统的契机,原因有几点:

  • 一个人迭代可以准确了解每一个需求的进行情况,在新系统迁移过程中,不容易遗漏迭代中的需求功能。
  • 自己可以把握日常迭代的节奏,每周都能适当留出重构的时间。
  • 最后一点也是最重要的一点,就是 vben5 开源进度已经可以满足生产系统的需求。

如何理解重构

说到重构,有一本书可能大多数程序员都听过,叫《重构:改善既有代码的设计》,书中指出重构的定义为:在不改变软件可观测行为的前提下,调整代码结构,提高软件的可理解性,降低变更成本

书中提到我们要明确一点,重构不是一种道德行为,而是一种经济适用行为,如果它不能让我们更快更好的开发,那么它是毫无意义。具体点说就是代码的写法应该使别人理解它所需要的时间最小化,进而变更代码需要的时间也会最小化。

其次也是书中没有提到但我认为很重要的一点就是:脱离业务设计重构或者优化,最多只能是技术自证,始终是片面的。

作为前端开发者我们用着同样的计算机语言,却身处完全不同的业务场景下,重构代码的同时思考如何提升用户的使用体验,才是我们作为前端研发的价值体现。

总结重构落地的意义可以归结为以下几点:

  • 更快速的定位问题,节省调试时间。
  • 最小化变更风险,提高代码质量,减少修复事故的时间。
  • 提升用户体验。
  • 得到程序员同行的认可,更好的发展机会。

有了理论支持与明确的目标,我们就可以开始着手重构的设计构思。

开源框架本地化

从之前一直维护框架代码的经验来说,这些都是可以交给开源团队去维护,项目开发者只需要定期拉取最新版本的框架代码,更多的精力应当更专注于业务逻辑与功能实现,所以我在开源本地化的时候将 vben5github 地址一直作为基准的远程地址,去定期同步。

微信图片_20251114195651_52_274

其核心维护流程图如下:

image-20251114200450835

受益于 vben5 的架构,框架代码与业务代码做到了完全分离,什么改动是框架的,什么改动是业务的,定义就会十分明确。在日常迭代中遇到需要调整框架的,就去框架项目改,改完同步到所有的项目中去,这样既保证了框架版本的最新,同时迭代了本地化的框架改动,也不会影响到业务代码。

前端部署的良解

之前部门前端项目中的部署方式是通过本地打包上传到静态服务器(我这叫狮子座),然后在生产服务器启一个 node 服务来访问 index.html 文件。具体流程如下展示:

image-20251115002301702.png

总结来说就是哪哪都会出问题,所以这次重构后我采用 服务器打包 + nginx服务 的方式,其实这套方式在很久之前我的另一篇文章【微前端qiankun+docker+nginx配合gitlab-ci/cd的自动化部署的实现】中实现过,所以我也轻车熟路地在公司的运维平台实现了自动化部署,其流程如下:

image-20251115002917639.png

这套部署流程对于日常开发来说是非常省事且多环境的可拓展性也很强。

多环境的解决方案

目前公司项目的环境分为三个大环境,每个大环境有三个小环境,解释起来也很简单,就是公司每在一个地区落地,就要根据政策把服务器部署在本地,所以大环境就是地区服,每个地区服又要分测试环境、预发环境和生产环境,这样算下来一个项目就会有九个环境,目前的运作方式就是三个大环境三个 git ,每个 git 内再用三个环境分支分别用作部署,这样的弊端就是每次完成一个需求都要对另外两个 git 进行代码同步,而且时不时还会有不同环境不同需求的情况。久而久之,每个需求的工作成本基本上都要翻倍,因为要花时间去处理大环境同步及差异。

vben5 在处理环境使用的 dotenv ,在项目中都是通过 import.meta.env.xxx 访问环境变量,所以涉及到不同环境的差异代码写法如下:

<script lang="ts" setup>

const pageTitle = import.meta.env.VITE_PAGE_TITLE;
const pageDesc = import.meta.env.VITE_PAGE_DESC;
const isProduction = import.meta.env.VITE_GLOB_APP_BUILD_TYPE !== 'qa';

</script>

<template>
  <AuthPageLayout
    :is-production="isProduction"
    :page-description="pageDesc"
    :page-title="pageTitle"
  />
</template>

其中 VITE_PAGE_TITLEVITE_PAGE_DESC 在不同环境 env 文件中的定义如下:

#.env.production.yc
VITE_GLOB_APP_BUILD_TYPE=product
# 环境常量
VITE_PAGE_TITLE=医疗后台-银川
VITE_PAGE_DESC=欢迎登录银川医疗后台!

#.env.production.nj
VITE_GLOB_APP_BUILD_TYPE=product
# 环境常量
VITE_PAGE_TITLE=医疗后台-南京
VITE_PAGE_DESC=欢迎登录南京医疗后台!

对应的运行与打包指令如下:

{
  ...
  "scripts": {
    "build:yc": "pnpm vite build --mode production.yc",
    "build:nj": "pnpm vite build --mode production.nj",
    "dev:yc": "pnpm vite --mode development.yc",
    "dev:nj": "pnpm vite --mode development.nj",
  },
  ...
}

结合之前的部署方案,只需要在Dockerfile 中指定对应的 build 指令即可完成对应环境的打包部署。

组件化和模块化

简单来说,模块化“拆逻辑”,将复杂功能拆分为独立的逻辑单元,解决代码复用和依赖管理问题;组件化“拆 UI”,将页面拆分为独立的 UI 单元,解决界面复用和维护问题。 但在实践过程中,会对这两个概念有新的认知与补充。

比如说我现在有一个复杂的表单页面,涉及到两个表单以及多个动态表格,以模块化的思维大量的逻辑函数将会被定义,又因为表单表格具有独特性,不存在复用的场景,所以按理说写到一个vue文件中即可。结果就导致了单个文件的代码量会达到一个让人抓狂的地步,而且后续的维护,大家也会有一种惯性就是,之前都这么写的我再往后面加就行。

另一个场景,有一个业务弹窗组件复用性很高,但是每次都需要对组件引入、注册、状态管理、销毁等等操作,重复的代码好像也会有很多,如果是别人看到这种调用方式,可能还会放弃复用你的组件。

这肯定不是解决问题的方式,所以这时候我们需要对组件化与模块化有更多的理解。

  • 对于有明确UI单元的功能但没有复用场景,也应拆分成独立组件,复用只是自然结果,而非唯一目的。
  • 将具有复用性与需要独立管理生命周期的组件进一步封装,采用 useHook 方式进行调用。

首先,没有复用场景的组件在引入页面组装功能的时候,类似语意化的html标签,对比拆分单个方法的模块化最小单元,这可以说是更高一级的模块化方式,将一堆具有逻辑关联的模块与UI单元内聚到一个文件中,在定位需求或者问题的时候能够快速找到最核心的一块代码,从而提升效率。

<template>
  <div>
    <Card title="基本信息" class="mb-4">
      <BaseInfoForm>
        <template #comboGoods="slotProps">
          <ComboGoodsTable ref="comboGoodsTableRefs" v-bind="slotProps" />
        </template>
      </BaseInfoForm>
    </Card>
    <CouponProductTable ref="couponProductTableRef" @change="generateComboOptions" />
    <VipProductTable ref="vipProductTableRef" @change="generateComboOptions" />
  </div>
</template>

其次,对具有生命周期的组件进行hook式封装是非常实用的一种方式,比如我现在有一个日志弹窗,需要在系统各处实用,那么我们先对弹窗组件进行封装,如下:

<script lang="ts" setup>
...

const [Modal, modalApi] = useVbenModal({
...
  onOpened() {
    logInfo.value = modalApi.getData<Record<string, any>>();
    gridApi.query();
  },
});

const gridOptions: VxeTableGridOptions<LogRowType> = {
  ...
};

const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions,
});
  
defineExpose({ modalApi });
</script>
<template>
  <Modal class="w-[780px]" title="日志">
    <Grid />
  </Modal>
</template>

如果直接对上面的组件进行调用是这样的:

<script lang="ts" setup>
  import Log from '#/components/LogModal/Log.vue';
  
  const log = ref<InstanceType<typeof Log>>();
  
  const openLog = ()=> {
    log.value.modalApi.setData({ logType: 1, objectId: 1 }).open();
  }
</script>
<template>
  <div>
    <Log ref="log" />
    <Button @click="openLog">日志</Button>
  </div>
</template>

其实 vben5 对于弹窗组件已经有了很好的封装,已经能用很简单的方式进行弹窗的调用,虽然组件内部已经管理好弹窗的显隐,但是我们依旧要引入组件并且写到页面结构中去,这时候我们可以写一个 hook 去再次封装 Log 组件。

import type { ExtendedModalApi } from '@vben/common-ui';
import type { VxeGridPropTypes } from '@vben/plugins/vxe-table';
import { h, render } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import Log from './Log.vue';

export function useLog(): {
  LogModalApi: {
    open: (data: {
      /** 日志类型 */
      logType: number;
      /** 日志对象参数 */
      objectId?: number | string;
    }) => void;
  };
} {
  let container: HTMLElement | null = null;
  let currentModalApi: ExtendedModalApi | null = null;

  const createModal = () => {
    if (container?.parentNode) {
      render(null, container);
      container.remove();
    }

    container = document.createElement('div');
    container.setAttribute('id', 'log-modal');
    document.body.append(container);

    const [Component, ModalApi] = useVbenModal({
      connectedComponent: Log,
      onClosed: () => {
        if (container) {
          render(null, container);
          container.remove();
        }
        container = null;
        currentModalApi = null;
      },
    });

    const Node = h(Component, {});
    render(Node, container);

    return ModalApi;
  };

  return {
    LogModalApi: {
      open: (data) => {
        currentModalApi = createModal();
        currentModalApi.setData(data).open();
      },
    },
  };
}

Log 组件二次封装后,调用方式就变成了如下写法:

<script lang="ts" setup>
import { useLog } from '#/components';

const { LogModalApi } = useLog();

const log = () => {
  LogModalApi.open({ logType: 1, objectId: 1 });
};
</script>

<template>
  <div @click="log">日志</div>
</template>

进一步精简了业务页面上的代码量,当然如果有额外需求,只需要对 useLog 进行参数拓展以及 Log 组件业务拓展。

总结

  • 组件化的核心是“边界隔离”,而非 “代码复用”
  • “组件 + Hook”的模式,实现了“能力复用” 而非“代码复制”

数据处理与优化思路

我相信大多数前端开发者和我有同样的感受,就是后端返回的数据结构很多时候不是我们想要的,需要我们重新处理,或者说我们在做数据处理的时候更多的是考虑功能的实现,至于可维护性要么交给注释,要么没有。于是“屎山代码”的雏形慢慢形成。

于是可以归纳的前端项目难以维护的几个点是:

  • 数据结构不匹配是常态:后端往往从业务逻辑、数据库设计、性能角度返回数据(比如嵌套过深、字段命名不统一、冗余字段多、数组结构不符合前端渲染需求),前端为了实现功能,只能在代码里加大量转换逻辑,比如循环遍历重组数组、解构嵌套对象、字段映射(把user_name转成userName)、过滤冗余数据等。
  • 初期只关注功能实现,忽略可维护性:开发时为了赶进度,这些转换逻辑往往写得零散(比如在组件里直接写、在请求回调里硬编码),既没有抽成通用工具函数,也没有统一的处理规范,顶多加几句注释(甚至不加)。
  • 技术债务逐步堆积:当需求迭代(比如后端字段调整、前端渲染逻辑变化),这些零散的转换逻辑会被反复修改、叠加,最终变成改一处崩多处的“屎山代码”——比如 A 组件改了字段映射,B 组件忘了同步,导致页面报错;或者新人接手时,根本看不懂一堆map/filter/reduce到底在处理什么。

其实很多前端性能上的问题都是在这一过程中损失的,即使我们做好了首屏加载、打包文件分割、样式优化等等,有时我们还会觉得“卡卡的”,那些对对象滥用的操作,都在无形中增加对内存的损耗,可以参考我之前的文章【从V8引擎的角度去看看JS对象模型的实现与优化】

所以需要明确的是,前端的核心价值是“交互和渲染”,而非 “数据结构的二次重构”,过度的前端数据处理属于 “无效的技术成本”。前端不是“后端数据的被动加工者”,而是和后端协同定义数据的“参与者”,过度的前端数据加工会埋下可维护性的坑,而高效的前后端协作能从根源减少无意义的前端逻辑堆积。

其实话说到这前端中间层的必要性已经呼之欲出了,把数据结构适配的成本从前端代码层转移到前后端协商层 ,或许才是这个问题最合理的解决方式,既不用后端改变固有设计与逻辑,也不用前端二次数据开发。当然开发中间层是有很大的成本在里面的,是选用低成本的多沟通还是前端花时间自己在中间层处理想要的数据,这就要根据每个人当前工作环境而定了。

当然做中间层我们也要明确中间层的职责:

  1. 避免过度设计:中间层只做数据适配,不做业务逻辑(比如订单计算、权限判断仍由后端核心服务负责),否则会变成新的 “屎山”。
  2. 保持轻量:中间层的接口要一对一适配前端页面 / 组件,避免设计成通用接口(比如一个接口适配多个页面,后期改造成本高)。

总而言之,前端要跳出“被动加工数据”的思维,用协作降低无意义的开发成本

交互复杂度

在AI盛行的当下,作为一名前端开发,如果还是只注重功能实现,那么网上铺天盖地的AI取代前端或许真的不遥远了。

前面提到前端的核心价值是“交互和渲染”,所以我理解前端应当从「功能实现导向」转向「用户体验 + 工程效率双导向」的设计与开发思维。首先说两个我在日常开发或者做重构的基本思维:

  • 交互复杂度是决定用户体验的重要因素。
  • 表单设计应当以降低交互复杂度为主要考量,复杂逻辑简化交互,简单交互高度组件化。

说到这有人可能会觉得这应该是设计或者交互该考虑的事,但是实际情况是前端有时候必须考虑一些除技术外的东西,且不说很多公司是没有交互这一个专门的职位,大多数后台是没有交互设计的,就是拿组件去堆,设计很多时候也只是根据产品原型图去实现,能给到开发的图其实是很平面化的,开发处理不好好各个页面或者组件之间的衔接交互,是直接影响用户体验的。

之前看过一个统计,说每多一个弹窗用户留存率降低70%,看到这个数据我的第一感觉是比我预想的要多很多,这也更加确立了我在处理任何带有弹窗交互需求的时候都会考虑一下是否真的有必要。

我上面也单独把表单设计拎了出来,因为表单是前端最常见但也最易出体验问题的场景,其核心痛点就是「交互复杂度失控」:

  • 要么把复杂业务逻辑直接暴露给用户(比如多步骤、多校验、多关联字段的表单,让用户反复填、反复改)
  • 要么把简单交互过度工程化(比如一个单行输入框,拆成多层组件、加一堆冗余逻辑,导致开发效率低、维护成本高)

所以我的解决思路就是:

表单场景 核心问题 解法核心 最终目标
复杂逻辑的表单 交互复杂度高→用户易出错 / 放弃 简化交互(把复杂藏在底层) 降低用户认知 / 操作成本,提升完成率
简单交互的表单 开发效率低→重复造轮子 高度组件化(封装通用能力) 提升开发效率,保证交互一致性

表单的本质是「用户完成信息录入的工具」,而非 “展示技术能力的载体”:

  • 用户对表单的核心诉求是快速、准确、无阻碍地完成提交,而非体验炫酷的交互。
  • 交互复杂度越高,用户的认知负荷和操作成本就越高,最终表现为表单完成率低、错误率高。
  • 反之,即使业务逻辑复杂(比如电商商品编辑、优惠券编辑),只要把交互拆解得足够简单(比如分步引导、字段联动自动填充、错误提示精准),用户也能顺畅完成。

这一点也印证了 UX 领域的经典原则:“把复杂留给自己,把简单留给用户” —— 表单体验的好坏,本质是 “开发者能否把复杂的业务逻辑,转化为用户可感知的简单交互”。

针对以上问题,C端通常有业务、产品、设计多个部门在把关,相对来说前端的实现上的主观性会弱很多,而B端则没有这么多要求,也就通常是表单设计的重灾区。虽然B端不存在用户留存率,但是难用的操作逻辑确实增加了用户对研发的不满情绪。

有一次业务和我吐槽配了一些营销策略到系统中,本来逻辑不复杂,但是配置花了将近一下午。从那之后,我在做表单类的需求时,潜意识都会考虑这个表单除了完成功能之外到底好不好用,所以我后来在做系统重构时,将所有涉及到复杂表单的场景全部进行了彻彻底底的交互上的重构,原来需要两个、三个弹窗处理的配置,全部设计成最多一个弹窗完成。当然重新设计的过程中也要时刻与产品业务做好沟通与同步,询问他们的意见,了解用户的真实需求。

正确理解需求迭代

相信大家都玩过俄罗斯方块,我觉得每一个需求就是一个下落的方块,把它落在哪个地方,如何和已有的方块密切贴合,或者给未来落下的方块腾空间,决定了这个游戏我们能玩多久。

需求迭代不应是功能的简单堆砌,而是功能的不断整合。 要想维护好前端的工程化,我们必须从「被动接需求」变为「主动做治理」,需求迭代的本质不是 “加一行代码、加一个功能”,而是通过整合(收敛冗余)、优化(提效降本)、重构(解耦升级),让代码体系随需求增长更有序而非更臃肿,所以需求迭代就成了持续重构系统边界、收敛重复逻辑、提升整体内聚性的过程。

为什么 “功能堆砌” 是前端的致命伤?

前端最容易陷入 “需求来了就加代码” 的堆砌式迭代,最终导致:

  • 逻辑冗余:相似功能写多套(比如 3 个页面都有 “状态筛选”,却写了 3 套筛选逻辑,原因就是每次封装的组件业务性强,最后复用性变差)
  • 交互碎片化:同一类操作(比如 “弹窗关闭”)在不同页面有不同交互,用户体验割裂(长期迭代下没有对之前过时的交互进行整合)
  • 维护成本指数级上升:改一个基础逻辑(比如 “手机号校验规则”),要改 10 个地方
  • 技术债务堆积:旧代码不敢动,新需求只能绕着写,最终形成 “屎山”

那么,前端如何落地 “整合 + 优化 + 重构” 的迭代思维?

整合

整合是我在做系统重构的过程中最早规划的,核心是把分散、重复的功能逻辑收敛到统一的模块 / 组件中。要想把整合做好,最重要的是熟知业务,如果没有完整便捷的文档,想要完整了解业务最好是通过阅读代码逻辑抽丝剥茧,总结流程图、逻辑图。

1、识别 “相似功能”,收敛到通用层

正如前文中提到的 Log 组件,在重构的视角下,能从全局角度去总结所有场景下的通用性,不重复写弹窗逻辑,而是抽象 通用日志弹窗组件 + useLog Hook,把 “日志类型、数据结构” 等作为参数,让所有日志弹窗复用同一套逻辑;核心思维就是从 “为单个需求写功能” 转向 “为一类需求抽象能力”

2、整合 “碎片化交互”,统一交互规范

在公司之前的系统中,涉及到表格内编辑时,有时需要点击编辑切换整行可编辑状态,有的需要弹出一个表单再编辑,有的则可以直接在行内进行编辑,碎片化的交互随处可见。整合之后,所有的交互变为单元格点击编辑的方式,不再自定义交互,避免体验碎片化。

3、整合 “数据请求 / 处理逻辑”,收敛到中间层 / Hook

因为使用 vben5 框架,对于请求数据处理的封装已经全部收敛到对应的模块中去,不需要再去重复造轮子。实践来说比如全局对用户信息的调用频繁,那我们可以把用户信息的请求、数据转换、缓存逻辑封装到 useUser Hook 中,所有页面通过 Hook 获取数据,迭代时只需维护 Hook 内的逻辑。

优化

“优化” 是在整合基础上,对现有逻辑做 “减法” 和 “提效”,核心是「消除冗余代码、提升性能 / 开发效率」,而非 “加新功能”。

1、代码层面:剔除冗余逻辑,简化实现

冗余逻辑都是从哪来的?根据我的开发经验来说,所有类似“这段逻辑先留着,以后可能会用到”、“老的逻辑/组件/页面别删,加一个新的就好”这些话术,其本质就是开发过程中的偷懒,因为不想多花点时间去了解前后业务逻辑,将所有的不确定留在项目代码中,技术债务成本随之而来。

即使未来真的可能需要用到,一是可以从提交记录中恢复,二是即使能用到大概率也需要调整,在理解老代码的逻辑过程中,或许都已经自己实现了。

2、性能层面:明确优化方案,准确突破性能瓶颈

我面试过很多人,问到前端优化方案,无非都是首屏加载、打包文件分割、样式优化这些,再问一下具体场景的分析与实现,意为你通过什么样的分析判断页面需要进行什么样的优化时,大多数人又磕磕巴巴说不出个所以然来。

其实我们完全可以通过浏览器内置的分析工具,去分析页面加载、页面渲染、页面节点数、页面事件数、内存占用等等各种指标,去发现问题所在,那么我们再去优化时就会明确自己的优化预期是什么样的,而不是随便从网上找一堆优化大杂烩堆到项目中去。

3、开发层面:优化交互方式,降低接入成本

多考虑弹窗交互是否可以优化,通过合理的交互设计,避免使用多弹窗交互。还有我之前提到的 useLog 组件的实现,进一步的封装不仅极致简化组件的调用代码,也让 “整合后的能力” 更易用,避免 “整合后反而增加开发成本”。

当然除了组件复用的优化还有接口复用,最常见的就是字典类型的接口,用于各种需要调用后端接口的下拉框,这时候我们可以合理利用 vue-query 这种缓存库,对接口返回信息进行缓存,降低接口的重复调用率,不仅节省了服务器资源,也让前端的体验更优。

重构

“重构” 是在整合 / 优化的基础上,对 “不合理的架构 / 边界” 做调整,核心是「让代码体系适配长期需求,而非仅满足当下」—— 重构不是 “推翻重写”,而是 “小步迭代式升级”。重构需要我们在某一段时间花费大量时间去思考架构/模块的设计,不仅是为了找出当下最优解,也要为未来可能的升级或改变留下空间。

1、边界重构:拆分 “职责混乱” 的模块

其实我在做系统重构的时候经常会推翻之前某一类代码的写法,比如我写一个表格内的单元格编辑省缺值组件,一开始可能只是在某个编辑场景中使用,所以写法也没太多的封装。

<script lang="ts" setup>
import { isEmpty } from '@vben/utils';

const props = defineProps<{
  placeholder?: string;
  prefix?: string;
  value: any;
}>();

const {
  value,
  placeholder = '点击编辑',
  prefix = '',
} = props;
</script>
<template>
  <div>
    <span class="cursor-pointer" v-if="!isEmpty(value)">
      {{ prefix }}{{ value }}
    </span>
    <span class="cursor-pointer text-gray-400" v-else>
      {{ placeholder }}
    </span>
  </div>
</template>

再后来的重构过程中,我发现处理表格内编辑省缺值的情况很多,而且每次遇到写在表格的 slot 中很麻烦,于是将代码中所有适用的情况都找了一下,几次完善后,组件最终如下:

<script lang="ts" setup>
import { isEmpty, isNumber } from '@vben/utils';

const props = defineProps<{
  booleanMap?: {
    falseText: string;
    falseValue?: any;
    trueText: string;
    trueValue?: any;
  };
  multiple?: boolean;
  options?: Array<{ label: string; value: any }>;
  placeholder?: string;
  prefix?: string;
  type?: 'boolean' | 'number' | 'select' | 'string';
  value: any;
}>();

const {
  type = 'string',
  value,
  booleanMap = {
    trueText: '是',
    falseText: '否',
    falseValue: false,
    trueValue: true,
  },
  options = [],
  multiple = false,
  placeholder = '点击编辑',
  prefix = '',
} = props;

const formatSelected = (val: any) => {
  if (multiple) {
    return options
      .filter((item) => val.includes(item.value))
      .map((item) => item.label)
      .join(',');
  }
  return options.find((item) => item.value === val)?.label ?? val;
};
</script>
<template>
  <div>
    <template v-if="!isEmpty(value)">
      <span class="cursor-pointer" v-if="type === 'string'">
        {{ prefix }}{{ value }}
      </span>
      <span
        class="cursor-pointer"
        v-else-if="type === 'number' && isNumber(Number(value))"
      >
        {{ prefix }}{{ value }}
      </span>
      <span class="cursor-pointer" v-else-if="type === 'boolean'">
        {{
          value === booleanMap.trueValue
            ? booleanMap.trueText
            : booleanMap.falseText
        }}
      </span>
      <span class="cursor-pointer" v-else-if="type === 'select'">
        {{ formatSelected(value) }}
      </span>
    </template>
    <span class="cursor-pointer text-gray-400" v-else>
      {{ placeholder }}
    </span>
  </div>
</template>

边界重构的核心实现还是组件化,在开发中不断回头发现可以整合优化的地方,就像玩俄罗斯方块一样把它们放在一排,然后就可以安心消除(安心调用)了。

2、架构重构:升级通用层,分离框架与业务

我最喜欢 vben5 的地方在于它很好的实现了 monorepo 架构,场景化拆分解决了中后台项目重复封装通用能力的痛点。所有非业务的代码都可以放在 packages 中,项目代码对通用能力的调用就如同引用一个三方库,这让我在重构业务代码和学习框架代码之间切换明确,也让我在重构的过程中有了更多的思考,如:如何将一个业务通用能力抽象为一个框架通用能力。

3、小步重构:避免 “大重构风险”

做好这点原则是每次迭代只重构 “当前需求涉及的不合理逻辑”,而非一次性重构整个模块。其实我在做系统重构就是一整个系统的大重构,在重构落地的过程中会发现各种风险无法规避,不管是测试压力,还是需求同步,都是越到后面越难推进,这也是大重构最大的副作用。所以想要规避,就要明确重构的边界,确保测试可以完全冒烟。重构不是一个人的事,是协调产品、测试这些上下游对口部门的统一规划。

注释

当你感觉需要写注释时,请先尝试重构,试着让所有注释都变得多余。

如果你不知道该做什么,这才是注释的良好运用时机。除了用来记述将来的打算之外,注释还可以用来标记你并无十足把握的区域。你可以在注释里写下自己“为什么做某某事”。这类信息可以帮助将来的修改者或者健忘的自己。

研发价值

我不知道在AI盛行的当下,大家有没有考虑过现在的研发价值到底是怎么体现的。根据 Linux 基金会发布的《2025 年全球科技人才状况报告》,AI 正在重塑工作,而非消灭工作,这种 "重构革命" 深刻改变着科技行业的人才需求格局,推动研发人员的价值认知转型。

很多人认为做业务开发显得没那么有挑战性,但其实正好相反。最难解决的bug是无法重现的bug,最难处理的问题域是不确定性的问题域。业务往往是最复杂的,面向不确定性设计才是最复杂的设计。软件工程学科最难的事情是抽象,因为它没有标准、没有方法、甚至没有对错。如何在软件固有的复杂性上找到一条既不过度也不缺失的路,是软件工程师的终身课题,或许永远也无法达到,或许我们已经在路上了。

综上所述,我理解的研发价值分为三个阶段:

  1. 技术知识的掌握、技术选型的判断力,以及用技术解决实际问题的能力
  2. 对业务逻辑的深度理解、日常业务问题的快速解决,以及通过系统整合 / 优化提升业务效率与用户体验的能力
  3. 准确理解各方(产品、业务、测试)意图,在沟通中降低协作摩擦、提供情绪价值,给予最优实现方案

一阶段

作为技术研发,技术知识的掌握一定是我们立身的根本,一方面是技术的深度,另一方面是技术的广度。技术深度决定了我们的技术能力和解决问题能力,技术广度决定了我们如何理解不同技术栈之间的协作关系,能够根据项目需求进行合理的技术选型。

企业研发的技术能力,不只是 “会用技术”,更重要的是 “选对技术”。比如:能用 Vue3 写页面是运用能力,但能判断这个后台项目用 Vue3+Element Plus,还是React+Ant Design,能解决 “大数据量表格卡顿” 这类实际问题,才是企业需要的技术价值。

基于AI发展迅速的大环境下,基础技术的价值正被快速剥离,技术人的价值重心正在悄然发生改变。生成式 AI 在代码领域的应用已进入成熟期,基础代码编写效率提升数倍,一个能够用好 AI 的研发人必然有了额外的价值。或许在不远的未来,传统的 "代码编写" 能力将不再是核心竞争力,研发人员能够定义 AI 工作边界,掌握 AI 工具的使用和优化,学会与 AI 协作完成技术工作或许才是最终目标。

二阶段

有了技术基础,我们开始逐渐融入不同的业务中去,正如业内专家所言,"理解业务的深刻程度决定了发展上限"。这种理解不是简单的需求翻译,而是能够穿透业务表象,理解功能背后的用户痛点、商业逻辑和战略目标。

所以处理日常问题只是业务能力的基础,通过深度理解业务,将业务问题转化为技术问题,用技术手段实现业务目标。 同时也要求我们具备技术前瞻性,能够预判技术趋势对业务的影响,提出具有创新性的技术解决方案。所以一阶段的基础越扎实,二阶段的业务整合就会更游刃有余。

三阶段

这一阶段本质上是有了前两阶段沉淀后的协作共赢,其核心价值在于通过高效协作放大个人和团队价值。最优实现方案在企业中往往不是“技术最优”,而是 “业务价值、研发成本、时间周期”三者平衡的全局最优方案 ;同时,“情绪价值” 的核心是对齐预期、降低协作摩擦,比如:产品提了一个复杂需求,研发不是直接说 “做不了”,而是说 “这个需求可以拆成两步,第一步先实现核心功能,成本低、见效快,第二步再优化体验”—— 这既提供了情绪价值,又给出了可行方案。

在沟通协调能力方面,研发人员需要具备跨职能团队协作能力,能够与产品、运营、测试、设计等团队有效沟通,协调各方利益,推动项目顺利进行。这种能力不仅体现在技术沟通上,更体现在能够用非技术语言向业务人员解释技术方案,用业务语言向技术人员传达需求。要想做好这些,前两阶段的积累必不可少。

最后

因为在重构过程中不断向vben5 github提交issuespull request,我也有幸成为了开源共建者。

在着手这次重构时,我不会想到自己最后会有这么多思考与感悟,可能有人会觉得系统能用就行,为什么要去折腾,我想用《人月神话》中我最喜欢的一句话作答。

这个行业需要我们掌握更先进的开发要素和工具,经论证的管理方法和最佳应用,良好判断的自由发挥,以及保持谦卑。

相关引用

系统困境与软件复杂度,为什么我们的系统会如此复杂

《重构:改善既有代码的设计》

2025 前端开源三年,npm 发包卡我半天

大家好,我是不如摸鱼去,欢迎来到我的分享专栏,今天我们来聊聊 npm 发包。

各位最近在 npm 上有没有见到这个这个提示?没错,经典 token 被废了!我也差点被废了,想发个新的包,死活发不出来啊,唉,都怪我不看文档啊😂。

起因

近期给 uni-mini-router 重构了一下,使用 Gemini3 Pro 给它把打包器从 rollup 迁移到了 tsdown,并且使用 wot-starter作为模板重构了其演示代码结构、文档和演示 demo。

美滋滋的上线准备发包,结果直接报错 404?

过程

发包报错后,上网一搜,哇擦,经典 token 被废了!看了下文档,是永久的经典 token 已经被废弃,推荐使用 trusted publisher 发包,于是马不停蹄的开始配置。然后就出现这一幕!

仓库,我配上了,workflow 文件,我填好了,俺老孙啥功名不要,只求把这包发上去。npm 的天王老子信不过我,我懂,让你小子整些 trusted publisher 唬我,我也懂。我不懂的是…你特不让我发包上去!

何老师,掌管 Trusted publishing 发包的神。对唔住,刚刚是我太大声,是我把 GitHub 仓库名写成了仓库地址😂。

攻略

正经 npm trusted publisher 发包攻略来了,文档见:docs.npmjs.com/trusted-pub…

如果你是首次发包,需要先使用存在有效期的 token 进行手动发包,随后进行以下配置。

首先我们打开设置,我们使用 GitHub Action 发包,所以选择配置 GitHub Action,

按照要求填写用户名、包名、工作流文件等

调整 GitHub Action 工作流文件,删除 npm 相关 token,也可以参考上方文档进行配置。

最后,需要注意: Trusted publishing 需要 npm CLI 版本 11.5.1 或以上.

总结

一定要看文档!

参考资料

GIS 数据转换:使用 GDAL 将 Shp 转换为 GeoJSON 数据

前言

GeoJSON 作为一种通用的地理数据格式,可以很方便地用于共享交换。在 GIS 开发中,经常需要进行数据的转换处理,其中常见的便是将 Shp 转换为 GeoJSON 数据进行展示。

有关GeoJSON数据的详细介绍,请参考往期文章:GeoJSON 数据简介

在之前的文章中讲了如何使用GDAL或者ogr2ogr工具将txt以及csv文本数据转换为Shp格式,本篇教程在之前一系列文章的基础上讲解如何使用GDALShp转换为GeoJSON数据。

  • GDAL 简介
  • GDAL 下载安装
  • GDAL 开发起步

如果你还没有看过,建议从以上内容开始。

1. 开发环境

本文使用如下开发环境,以供参考。

时间:2025年

系统:Windows 11

Python:3.11.7

GDAL:3.11.1

2. 数据准备

如下是本文选取的世界边界范围的Shp数据结构:

3. 导入依赖

Shp作为一种矢量数据格式,可以使用矢量库OGR进行处理,以实现Shp数据转换为GeoJSON格式。还需要引入osr模块用于坐标定义以及json模块用于几何数据转换。

from osgeo import ogr,osr
import os
import json

4. 数据读取与转换

定义一个方法Shp2GeoJSON(shpPath,jsonPath)用于将Shp数据转换为GeoJSON数据。

"""
说明:将 GeoJSON 文件转换为 Shapfile 文件
参数:
    -shpPath:Shp 文件路径
    -jsonPath:GeoJSON 文件路径
"""
def Shp2GeoJSON(shpPath,jsonPath):

在进行GeoJSON数据格式转换之前,需要检查Shp数据路径是否存在。

# 检查文件是否存在
if os.path.exists(shpPath):
    print("shp 文件存在。")
else:
    print("shp 文件不存在,请检查数据路径!")
    return

打开Shp数据源。

# 读取Shp文件
shpDataSource = ogr.Open(shpPath)
shpLayer = shpDataSource.GetLayer()

首先构造一个GeoJSON空数据结构,用于填充Shp属性数据。

# 构造GeoJSON对象
geoJSON = {
    "type":"FeatureCollection",
    "features":[]
}

读取并遍历Shp图层,将几何对象以及属性对象复制到GeoJSON对象中。使用ExportToJson方法将几何数据转换为JSON格式,然后使用json.loads方法进行加载,属性读取完成之后,将要素添加到要素集合中。

# 遍历所有要素
for feature in shpLayer:
    # 几何对象
    geom = feature.GetGeometryRef()
    # 构造GeoJSON Feature对象
    featureJSON = {
        "type":"Feature",
        "geometry":json.loads(geom.ExportToJson()),
        "property":{}
    }

    # 获取属性
    fieldCount = feature.GetFieldCount()
    for i in range(fieldCount):
        fieldName = feature.GetFieldDefnRef(i).GetName()
        fieldValue = feature.GetField(i)
        featureJSON["property"][fieldName] = fieldValue

    # 添加要素
    geoJSON["features"].append(featureJSON)

Shp数据读取完成之后,将其保存到GeoJSON文件中,并关闭数据源。

# 写入文件
with open(jsonPath,"w",encoding="UTF-8") as f:
    json.dump(geoJSON,f,ensure_ascii=False, indent=2)

# 关闭数据源
shpDataSource = None

以浏览器多进程的角度解构页面渲染的整个流程

一、前言

页面在浏览器上的渲染并不是一个一条线的过程,而是多进程架构下协同的结果,本文从浏览器多进程的角度解构从url输入到页面渲染的整个流程的解析。

二、浏览器的多进程架构

1.进程与线程之间的关系

在操作系统中,进程是资源分配的最小单位,而线程是 CPU 调度的最小单位。

为了更好地理解浏览器架构,我们可以从以下三个维度来拆解它们的关系:

A. 包含与归属:工厂与工人

进程是容器:一个进程好比一个工厂车间,它拥有独立的内存空间、数据集和系统资源(如网络句柄、文件描述符)。

线程是执行者:线程是车间里的工人。一个进程可以包含多个线程,它们协同完成复杂的任务

B. 共享与隔离:内存的边界

进程间相互隔离:为了保证系统的稳定性,进程与进程之间的内存是完全隔离的如果一个渲染进程(Tab页)因为代码崩溃了,它不会影响到浏览器主进程或其他 Tab 页

线程间资源共享:同一个进程内的所有线程都可以访问该进程的内存空间。这意味着 JS 引擎线程可以轻松地读取到由网络线程下载并存放在内存中的数据

C. 协同与竞争:互斥锁的由来 ★★★

这是理解“为什么 JS 会阻塞渲染”的关键点:

在同一个渲染进程内,GUI 渲染线程和 JS 引擎线程是互斥的。

原因:因为 JavaScript 脚本具有修改 DOM 的能力。如果两者同时运行,GUI 线程正在绘制一个 DOM 节点,而 JS 线程同时把它删除了,就会导致渲染结果不可预期。因此,浏览器规定:当 JS 引擎工作时,GUI 渲染线程会被挂起。

2. 为什么浏览器要采用“多进程”而非“单进程多线程”?

在早期的浏览器中,所有功能都运行在一个进程里。现代浏览器演进为多进程架构,主要是为了解决三个核心痛点:

稳定性: 在单进程下,任何一个线程的崩溃(如 Flash 插件卡死或复杂的脚本死循环)都会导致整个浏览器瘫痪。多进程架构下,“一个 Tab 一个进程” 实现了故障隔离。

安全性: 浏览器通过沙箱 机制将渲染进程锁起来。由于渲染进程运行的是不受信任的第三方脚本,沙箱让它无法直接读写本地文件或调用系统 API。所有的敏感操作必须通过 IPC(进程间通信) 告知浏览器主进程,由主进程进行权限审查后代为执行。

流畅性: 多进程可以更充分地利用多核 CPU 的并行计算能力。同时,某些耗时的操作(如网络下载、插件运行)被抽离到独立进程中,不会占用渲染进程的主线程

3.浏览器的主要进程及其进程下的线程(4 个核心进程 和 2 个辅助/动态进程)

① 浏览器主进程

它是浏览器的核心,也是所有进程的父进程。

职责:

界面显示:负责地址栏、前进后退按钮、书签栏等浏览器“外壳”的 UI。

用户交互:监听鼠标点击、键盘输入

进程管理:负责创建、销毁和协调其他子进程

存储功能:负责管理 Cookie、本地存储(LocalStorage)等磁盘读写

核心线程:

  • UI 线程:负责绘制浏览器外壳(地址栏、书签栏、窗口控制按钮)。
  • I/O 线程:负责与其他进程进行 IPC 通信。(进程管理)

它负责管理子进程。当你点击关闭按钮,是 UI 线程捕捉到信号,通知浏览器进程去销毁对应的渲染进程

② 网络进程

原本是浏览器主进程中的一个线程,为了提升稳定性和安全性,现代 Chrome 将其独立为进程,专门负责处理与外部世界的资源交换。

职责:

资源加载:负责发起所有的网络请求(HTTP/HTTPS),并接收服务器返回的数据

协议解析:解析 HTTP 响应头、状态码,处理 301/302 重定向

DNS 解析:将域名映射为 IP 地址

缓存管理:根据 HTTP 头部信息(如 Cache-Control)判断并管理磁盘/内存中的网络资源缓存

安全校验:拦截恶意 URL 访问(如 Safe Browsing 安全检查)。

Cookie 处理:负责 HTTP 响应中 Set-Cookie 的解析,以及请求时 Cookie 字段的自动注入

核心线程:

网络协议栈线程:这是最忙碌的“工人”,负责处理 TCP 握手、TLS 加密解密、以及 HTTP/1.1、H2、H3 协议的处理。

DNS 解析线程:专门负责寻找域名背后的 IP 地址,并维护 DNS 缓存。

Socket 管理线程:管理与不同服务器之间的长连接(Connection Pool)。

③ 渲染进程

这是前端代码真正运行的“车间”,也是浏览器安全机制的核心。每个 Tab 标签页通常拥有一个独立的渲染进程,它运行在“沙箱”中,无法直接访问系统资源。

核心职责:

解析与构建:将 HTML、CSS 字节流转化为浏览器能理解的 DOM 树和 CSSOM 树

脚本执行:运行 JavaScript 代码,处理用户交互逻辑

布局与绘制:计算元素的大小位置,并生成最终的像素图像传给 GPU 进程

五大核心线程:

A.GUI 渲染线程

职责:负责解析 HTML、CSS,构建 DOM 树、CSSOM 树、布局树和绘制。

特点:当界面需要重绘(Repaint)或由于某些操作引发回流(Reflow)时,该线程就会执行

B.JS 引擎线程

职责:负责解析 JavaScript 脚本,运行代码

核心痛点(互斥机制):JS 引擎线程与 GUI 渲染线程是互斥的。如果 JS 执行时间过长,就会导致页面渲染加载阻塞,出现掉帧或卡顿。

面试亮点:为什么互斥?因为 JS 拥有修改 DOM 的权限。如果两者并行,可能会出现“GUI 正在画背景,而 JS 删除了该节点”的竞态问题。

C.事件触发线程

职责:归属于浏览器而不是 JS 引擎,用来控制事件循环(Event Loop)。

工作机制:当事件被触发(如点击、AJAX 完成)时,该线程会将对应的回调任务加入到任务队列的末尾,等待 JS 引擎空闲时处理。

D.定时器触发线程

职责:负责 setTimeout 与 setInterval 的计时

存在的意义:因为 JS 引擎是单线程的,如果处于阻塞状态就无法计时。因此需要独立线程计时,计时完毕后再通知事件触发线程将回调推入队列。

注意:W3C 标准规定,setTimeout 的间隔时间低于 4ms 会被自动设为 4ms。

E.异步 HTTP 请求线程

职责:在请求发起后,通过浏览器分配一个线程专门负责监控网络状态

工作机制:当请求状态变更(如成功返回)时,如果设有回调函数,该线程就会通知“事件触发线程”将回调放入任务队列。

F. 合成线程 专门负责处理页面的分层和图像合成,不占用主线程(GUI渲染线程和JS引擎线程)

职责:

接收指令:主线程完成布局和绘制列表后,将这些信息提交给合成线程

图层切片:将页面图层划分为大小固定的图块(Tiles),优先栅格化视口内的内容

调度栅格化:配合 GPU 进程将图块转换为位图(像素点)

响应交互:直接处理页面的滚动 (Scroll) 和 缩放 (Zoom),而无需经过主线程

核心优势(独立性)

非阻塞交互:由于合成线程与 JS 引擎线程、GUI 渲染线程不互斥。这意味着即使 JS 引擎正在运行一个死循环导致页面卡死,你依然可以流畅地滚动页面。

硬件加速:它是利用 GPU 资源的核心入口,通过处理 transform、opacity 等属性,实现无需重排重绘的高性能动画。

④ GPU 进程 (GPU Process)

最初仅用于处理 3D 图形,但随着现代网页对流畅度要求的提高,它已成为网页“排版合成”与“像素上色”的物理支柱。

职责:

硬件加速:将合成线程提交的图块由逻辑指令转换为显卡可识别的位图

复合渲染:负责将来自不同进程(如浏览器进程的 UI、渲染进程的网页内容)的位图进行混合,最终绘制到显示器屏幕上。

核心线程:

GPU 渲染线程:与显卡驱动直接通信,执行真正的绘制操作

为什么独立?

浏览器将 GPU 独立为进程,主要是因为图形处理涉及到复杂的操作系统底层调用和硬件驱动。驱动程序通常不如系统核心稳定,一旦 GPU 任务崩溃,浏览器只需重启该进程即可,而不会导致整个浏览器或所有标签页“黑屏”或死机。

⑤ 插件进程 (Plugin Process)

专门用于运行如 Flash、Silverlight 等第三方插件的进程。

职责:

隔离风险:插件往往由第三方开发,稳定性差且极易存在安全漏洞

物理隔离:确保即便插件崩溃或被劫持,其破坏力也仅限在该进程内部,不会波及渲染进程(你的网页)或主进程。

随着 Chrome 彻底停止对 Flash 的支持,现代网页中插件进程已较少出现。注意不要将“插件”与“扩展”混淆。

⑥ 扩展进程 (Extension Process)

地位:为你安装的浏览器扩展程序(如 Vue Devtools, AdBlock, 翻译插件)提供独立的运行空间。

职责:

独立运行:确保扩展程序的 JS 逻辑不会占用网页渲染进程的 CPU 资源

权限管控:浏览器进程会根据扩展申明的权限严格控制扩展进程对网页内容(DOM)或系统 API 的访问

思考题

渲染进程有GUI线程负责对html css js的解析和渲染,主进程的UI线程和gpu进程的gup加速线程也有类似功能,为什么要这样设计?

A. 渲染进程:逻辑计算的核心

虽然它叫“渲染进程”,但它大部分时间在做逻辑转换

GUI 线程的工作:它把代码字节流变成 DOM/CSSOM。最重要的是,它计算出 Layout(布局)。 它告诉浏览器:“这里有一个 100x100 的红色方块”。

它不直接画图:GUI 线程并不直接控制显示器像素,它生成的只是“绘制指令(Paint Records)”。

B. GPU 进程:硬件加速的真相

以前浏览器确实靠 CPU 画图(软件渲染),但 CPU 处理图形太慢了。

GPU 加速线程:它接收来自合成线程的指令。因为 GPU 擅长并行处理大量像素,它把渲染进程算好的“图块”直接转为屏幕上的像素。

独立性:把 GPU 独立出来是为了防崩溃。图形驱动非常脆弱,如果 GPU 线程在渲染进程里,一个复杂的 3D 效果挂了,你的网页就崩了。

C. 主进程(UI 线程):窗口的守护者

为什么主进程也要参与“显示”?

非网页区域的渲染:网页之外的区域(地址栏、书签栏、前进后退按钮)不受渲染进程控制。

最终合成:这是一个关键点。屏幕上显示的内容 = 浏览器外壳位图 + 网页内容位图

协作流程:GPU 进程会把画好的网页内容位图交给主进程,主进程把自己的 UI 位图叠上去,最后由主进程指挥显示器把这整张图显示出来。

一个具体的场景:改变 background-color

渲染进程 (JS/GUI 线程):JS 修改了 CSS,主线程重新计算样式,发现颜色变了,生成一份新的“绘制列表”。

渲染进程 (合成线程):拿到列表,把任务分块,发给 GPU 进程。

GPU 进程 (GPU 线程):调用显卡硬件,把受影响的像素点重新喷色,生成位图。

浏览器主进程 (UI 线程):把这张新的位图放在浏览器窗口的“白板”区域显示。

三、从url输入到页面渲染的全流程(结合浏览器多进程架构)

整个流程实质上是多个独立进程在浏览器主进程的调度下,通过 Mojo IPC(进程间通信) 进行的一场数据与控制权的接力。

1. 导航触发:浏览器主进程的调度与拦截

输入预处理:UI 线程拦截地址栏输入。若为非 URL 字符串,调用搜索引擎封装 URL;若为合法 URL,则直接进入导航逻辑

BeforeUnload 拦截:如果当前已存在页面,主进程通过 IPC 向当前渲染进程发出信号。渲染进程执行 JS 逻辑并返回结果。为了防止渲染进程无响应导致导航卡死,主进程会对这一过程设置 Timeout 阈值。

启动网络指令:UI 线程发起一个指向 网络进程 的 IPC 请求

这里详细解释一下BeforeUnload和启动网络指令的过程及优化——

① BeforeUnload 拦截:给旧页面“交代遗言”的机会

当你点击一个链接或在地址栏回车时,当前的网页(旧页面)还没销毁。浏览器必须先询问它:“你还有没处理完的事吗?”

IPC 信号是什么? 浏览器主进程(管理窗口的)发现你要跳走了,它会发一个 IPC(进程间通信)消息 给当前网页所在的渲染进程

渲染进程在做什么? 渲染进程接收信号后,会检查 JS 代码里有没有监听 beforeunload 事件。比如你在写博客,还没保存,JS 就会弹出一个对话框:“系统可能不会保存您所做的更改。确定要离开吗?”

为什么需要 Timeout(超时)阈值? 这是为了防死锁。如果旧页面的渲染进程崩了,或者 JS 写了个死循环(例如 while(true){}),它就无法回复主进程。如果没有超时机制,你的浏览器地址栏就会永远卡在那里。

底层逻辑: 主进程会启动一个定时器(比如几秒钟)。如果渲染进程在规定时间内没回话,主进程会认为这个渲染进程“挂了”,直接强行掐断它的生命周期,强制开始加载新页面。

②启动网络指令:外交部正式出航

一旦旧页面被处理完(或者超时了),浏览器主进程就要去互联网上拿新页面的数据了。

UI 线程发起请求: 此时,主进程里的 UI 线程(负责处理地址栏、按钮点击的那个工人)会整理好目标 URL、Cookie、请求头等信息。

指向网络进程的 IPC 请求: 在现代 Chrome 中,主进程自己不负责下载。它会把刚才整理好的“请求包”通过 IPC 扔给 网络进程

形象点说: 主进程(CEO)给网络进程(外交部)打了个电话:“喂,去帮我把 github.com 的 HTML 字节流取回来。”

③为什么这两步是“并行的优化点”?

这里有一个非常硬核的亮点:现代浏览器并不会等 beforeunload 彻底结束才去发起网络请求。

为了快,浏览器通常会采取 并行策略:

一边让主进程询问旧页面是否要离开。

一边同步通知网络进程去进行 DNS 解析 和 建立连接。

如果用户最后点击了“取消离开”,浏览器就把刚发起的网络请求掐断。如果用户确定离开,此时网络连接可能已经建好了,网页秒开。这就是所谓的 “导航预加载”思想。

2. 网络资源获取:网络进程的“外交”与“初筛”

当网络进程接到主进程的指令后,它开始在互联网上为网页寻找材料。

A. 物理链路的打通(建立连接)

浏览器缓存:首先检查网络进程内存中存储的 DNS 记录(通常缓存 1 分钟)

DNS 与握手如果浏览器缓存未命中网络进程会去查 IP 地址(DNS),然后进行 TCP 三次握手。如果是 HTTPS,还要进行 TLS 加密握手。

亮点:为了快,浏览器会维护一个 连接池。如果最近刚访问过这个域名,它会直接复用之前的“管道”,省去握手时间。

B. 响应头的解析与“重定向”黑箱

内部消化重定向如果服务器返回 301/302(重定向),网络进程不会跑回去告诉主进程,而是自己在内部重新发起新的请求。

逻辑意义:对主进程和渲染进程来说,它们只关心最终拿到的结果,中间转了几次弯(重定向),网络进程在底层偷偷帮你处理好了。

C. 核心:响应体的“首包”嗅探

这是全流程中最精妙的地方。当网络进程收到服务器返回的第一份数据包(通常是前几个字节)时

确定身份:网络进程会查看 Content-Type。

如果是 text/html,它就知道:“正主来了,准备通知渲染进程干活”。

如果是 application/octet-stream,它会意识到:“这是一个下载任务”,于是把请求转交给下载管理器,导航流程在此终止。

建立数据管道:

核心机制:一旦确认是 HTML,网络进程不会等整个网页下载完。

它会建立一条“数据长管”。管子的这头在网络进程(继续下载后续字节),管子的那头直接插进未来的渲染进程。

它的意义:实现“边下载边解析”,极大地缩短了白屏时间。

3. 提交导航:控制权从主进程移交给渲染进程

这是导航阶段最核心的“状态切换”点,标志着页面正式从旧地址切换到新地址。

进程分配:主进程根据 Site Isolation 策略分配渲染进程。如果是同站跳转,可能复用原有进程;否则启动新进程。

Commit 指令:主进程发送 CommitNavigation 消息给目标 渲染进程。

数据交接:主进程会将网络进程中那个 Data Pipe 的句柄随指令发送给渲染进程。

确认反馈:渲染进程收到句柄后,直接从管道读取数据流。一旦开始解析,渲染进程向主进程发送 DidCommitProvisionalLoad。

状态切换:主进程收到反馈后,执行 UI 状态更新(更新地址栏、重置历史记录、刷新前进按钮)。此时旧页面正式被销毁。

4. 渲染流水线:渲染进程与 GPU 的像素产出

在渲染进程接收到“数据管道”的句柄后,内部的主线程、合成线程 与 GPU 进程 开始高度协同。

A. 解析与构建:将字节转化为结构

流式解析:主线程无需等待 HTML 下载完成。通过 Data Pipe,每接收到一个数据包,GUI 渲染线程就会立即启动解析,边下载边构建 DOM 树。

样式计算(CSSOM):主线程解析 CSS 样式,计算出每个 DOM 节点的最终样式

亮点(互斥机制):此阶段若遇到 <script>,主线程会挂起 GUI 渲染线程,切换到 JS 引擎线程。这种互斥确保了 JS 在修改 DOM 时不会产生渲染竞态。

B. 几何计算:确定空间坐标

布局树(Layout Tree)构建:主线程将 DOM 与 CSSOM 合并。它会过滤掉 display: none 的节点,仅保留可见元素。

几何量算:主线程精确计算每个元素在三维空间中的 (x, y) 坐标、宽高及层级

产物:一棵包含所有几何信息的布局树。

C. 记录与图层化:生成施工图纸

分层:为了处理 3D 转换(transform)或滚动,主线程会根据属性将页面拆分为多个图层。

绘制记录(Paint):主线程并不直接画图,而是将每个图层的绘制逻辑拆解为一个个指令(如:“在此处画正方形”,“在彼处填充红色”)。

产物:一份名为 绘制记录(Paint Records) 的逻辑清单。

D. 栅格化与合成:像素的工业产出

任务此时从主线程移交给合成线程,进入真正的硬件加速阶段。

切片(Tiling):合成线程将巨大图层划分为固定大小的 图块(Tiles),优先处理视口(用户肉眼可见区域)内的内容。

栅格化:

合成线程通过 IPC 向 GPU 进程 发出指令。

GPU 进程 利用显卡的并行计算能力,将图块指令转化为显存中的位图。

复合与上屏:

合成线程收集所有图块位图,生成一份“指引(Compositor Frame)”。 浏览器主进程 接收该指引,将网页位图与浏览器外壳(地址栏等)进行叠加,最终由 GPU 刷新到屏幕上。

为什么要把“合成”独立出来?

非阻塞滚动:当主线程因为运行复杂的 JavaScript 而卡死时,合成线程 依然可以独立工作。它能直接利用 GPU 显存里已有的位图进行位移偏移,这就是为什么即便网页脚本卡顿,你依然能流畅滑动(Scroll)页面的原因。

硬件加速:通过 transform 或 opacity 做的动画,直接在合成阶段完成,不触发主线程的“重排”或“重绘”,实现了真正的性能最优。

四、流水线视角的重排、重绘与合成

理解了多进程协作的渲染流水线后,我们就能从底层逻辑解释:为什么有的代码会让页面卡顿,而有的代码却能实现 60fps 的丝滑动画? 关键在于你的操作强迫流水线“回溯”到了哪一步。

1. 重排

触发原因:修改了影响几何空间的属性(如 width, height, margin, padding, border, display 等),或调整浏览器窗口大小。

流水线回溯:

主线程:必须重新经历 样式计算 -> 布局 -> 图层分层 -> 生成绘制列表 。

合成线程:重新进行图块划分。

GPU 进程:重新进行栅格化和位图上传。

这是开销最大的操作,因为它触发了全量流水线,且深度依赖主线程的计算压力。

2. 重绘

触发原因:修改了不影响布局、仅影响视觉外观的属性(如 color, background-color, visibility, box-shadow 等)。

流水线回溯:

主线程:跳过布局和分层,直接重新生成 绘制记录。

合成/GPU 进程:重新进行栅格化。

开销中等。虽然避开了几何几何计算,但依然需要主线程生成指令并触发 GPU 重新喷色。

3. 合成 (Composite):硬件加速的“超车道”

触发原因:使用 CSS 的 transform(位移、缩放、旋转)或 opacity。

流水线表现:

主线程完全不参与。

合成线程:直接接收指令,在 GPU 中利用已有的图块位图进行矩阵变换。

开销极低。这是多进程架构带来的最大红利——动画直接在合成线程与 GPU 进程间通讯,即使此时 JS 引擎在主线程里跑死循环,合成动画依然能流畅运行。

五、总结

通过对浏览器多进程架构及渲染流水线的深度解构,我们可以发现,从输入 URL 到页面呈现,本质上是一场多进程间的“接力赛”与流水线上的“精密加工”。

1. 核心链路回顾

我们可以将整个漫长的流程浓缩为四个关键的瞬间:

主进程:拦截输入,启动导航,指挥网络部出航。

网络进程:打通链路,嗅探内容,并建立指向未来的数据管道。

渲染进程-主线程:将字节流转化为 DOM/CSSOM,并在几何计算中确定每一个像素的坐标。

合成线程 & GPU:利用硬件加速,将逻辑指令转化为位图,实现最终的像素上屏。

2. 给前端开发的性能启示

理解了这套底层机制,我们对“性能优化”的理解便不再流于表面,而是进化为一种流水线思维:

保护主线程:GUI 渲染与 JS 执行的互斥性告诉我们,长任务是掉帧的元凶。我们应当利用 Web Workers 或时间切片来释放主线程。

善用合成器:优先使用 transform 和 opacity 实现动画,本质上是在利用多进程架构的红利,绕过拥挤的主线程,走 GPU 加速的“超车道”。

尊重重排规律:减少对 offsetWidth 等属性的频繁读取,实质上是在保护流水线不被“强制同步布局”打断,避免昂贵的重复计算。

3. 写在最后

浏览器作为现代最复杂的软件之一,其多进程架构是稳定性、安全性和高性能巴巴博弈后的终极方案。

作为开发者,理解底层是为了更自由地构建上层。 当你再次打开浏览器的 Performance 面板,看到那些交织的进程与线程曲线时,你看到的不再是枯燥的数据,而是一场由数万行 C++ 代码支撑、毫秒必争的协作交响乐。

Tailwind CSS:顺风CSS

前言

最近在折腾 React 项目的时候,深深被 Tailwind CSS 迷住了。以前写 CSS 总觉得乱糟糟的,类名起得头疼,样式复用率低,改个颜色还得全局搜索。现在用上 Tailwind,感觉整个人都轻松了,直接在 HTML(或者 JSX)里堆类名,就能快速搭出好看的界面。这篇文章就分享一下我的学习过程,从传统 CSS 的痛点,到原子 CSS 的概念,再到 Tailwind 的实战应用。希望能帮到同样在纠结样式问题的朋友。

传统 CSS 的痛点:为什么我觉得它不够用了

拿我最早写的代码来说,看这个简单的按钮例子:

<!DOCTYPE html>
<html lang="en">
<head>
    <style>
        /* 坏例子:每个按钮一个独立的类,样式几乎不复用 */
        .primary-btn {
            padding: 8px 16px;
            background: blue;
            color: white;
            border-radius: 6px;
        }
        .default-btn {
            padding: 8px 16px;
            background: #ccc;
            color: #000;
            border-radius: 6px;
        }
    </style>
</head>
<body>
    <button class="primary-btn">提交</button>
    <button class="default-btn">默认</button>
</body>
</html>

看起来简单吧?但如果项目里有几十种按钮变体呢?每个都写一个类,CSS 文件很快就膨胀了。而且这些类带着强烈的“业务属性”,比如 primary、default,复用性很差。换个项目,这些类名基本废了。

后来我学到“面向对象 CSS”(OOCSS)的思路:把样式拆分成可复用的基类,然后组合起来。

改成这样:

.btn {
    padding: 8px 16px;
    border-radius: 6px;
    cursor: pointer;
}
.btn-primary {
    background: blue;
    color: white;
}
.btn-default {
    background: #ccc;
    color: #000;
}

HTML 里:

<button class="btn btn-primary">提交</button>
<button class="btn btn-default">默认</button>

这就好多了!基类 .btn 处理通用样式,变体类处理差异。通过组合,复用率高了很多。这其实就是原子 CSS 的雏形:把样式拆成一个个小的、不可分的“原子”单元。

原子 CSS:样式世界的乐高积木

原子 CSS(Atomic CSS)就是把 CSS 规则拆成极小的单一职责类,比如:

  • p-4:padding: 1rem;
  • bg-blue-600:background-color: #2563eb;
  • text-white:color: white;
  • rounded-md:border-radius: 0.375rem;

这些类就像乐高积木,单个没什么用,但组合起来能搭出任何形状。好处显而易见:

  • 高度复用:一个类可以在无数地方用,不用担心命名冲突。
  • 不用跳文件:样式直接写在 HTML 里,开发时上下文切换少,效率高。
  • 一致性强:所有间距、颜色都来自统一的设计系统,不会乱七八糟。
  • 生产包小:Tailwind 会自动移除未使用的类,最终 CSS 文件超级精简(往往只有几十 KB)。

Tailwind CSS 就是原子 CSS 的代表作。它不提供现成的组件(像 Bootstrap 的卡片、导航),而是给你一堆 utility 类,让你自己拼。很多人刚看觉得“类名好长好乱”,但用习惯了就回不去了——因为它让你专注于布局和设计,而不是纠结类名。

相比传统 CSS,Tailwind 的优势:

  • 开发速度快:不用写自定义 CSS,90% 的样式直接用 utility 类搞定。
  • 维护容易:改样式直接改类名,不用找 CSS 文件。
  • 响应式友好:内置 mobile-first 设计(后面详说)。
  • 自定义强:通过配置文件,能轻松调整颜色、间距等设计 token。

当然,不是完美:HTML 看起来类名多(“class soup”),初学曲线陡。但多练几次,就爱上了。

在 React + Vite 项目中上手 Tailwind CSS

2025 年了,Tailwind 已经到 v4 了,性能更强,内置 Vite 支持超级方便。我的 React 项目是用 Vite 创建的,安装步骤超级简单。

  1. 创建项目:
npm create vite@latest my-tailwind-project -- --template react
cd my-tailwind-project
npm install
  1. 安装 Tailwind(v4 方式):
npm install tailwindcss @tailwindcss/vite
  1. 配置 vite.config.js:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [react(), tailwindcss()],
})
  1. 导入:

在你的 CSS 文件中添加一个@import导入 Tailwind CSS 的语句。

@import "tailwindcss";

就这么简单!不用 postcss.config.js 了,v4 原生支持 Vite。

启动 npm run dev,你就可以在 JSX 里用 Tailwind 类了。

看我第一个组件:

function App() {
  return (
    <>
      <h1 className="text-4xl font-bold text-center my-8">Hello Tailwind!</h1>
      <button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
        提交
      </button>
      <button className="px-4 py-2 bg-gray-300 text-black rounded-md hover:bg-gray-400 ml-4">
        默认
      </button>
    </>
  )
}

直接在 className 里堆类:padding 用 px/py,背景 bg-xxx,文字 text-xxx,hover 状态直接 hover: 前缀。太爽了!

对应样式:

image.png

我还做了个小卡片组件:

const ArticleCard = () => {
  return (
    <div className="p-4 bg-white rounded-xl shadow hover:shadow-lg transition-shadow">
      <h2 className="text-lg font-bold">Tailwind CSS</h2>
      <p className="text-gray-500 mt-2">用 utility class 快速构建 UI</p>
    </div>
  );
};

hover 的时候阴影变大,transition 平滑过渡,全是 Tailwind 内置的。

image.png

TailWindCss的痛点

1. HTML/JSX 里类名超级长,看起来乱糟糟的(Class Soup 现象)

这是最多人吐槽的点。一个组件的 className 动不动就一长串

className="flex flex-col md:flex-row gap-4 p-6 bg-white rounded-lg shadow hover:shadow-xl transition-all"。

看起来像“类名汤”,阅读性差,尤其是新手或别人接手代码时,得花时间解析这些类到底干了啥。相比传统 CSS 一个语义化的类名(如 .card),Tailwind 把样式全暴露在 markup 里,违反了“关注点分离”的原则——HTML 本该管结构,CSS 管样式,现在全混一起了。

image.png

解决办法:提取组件、用 @apply 自定义类,或者工具如 tailwind-merge 处理条件类。但一开始还是挺别扭的。

image.png

效果展示:

image.png

2. 学习曲线陡峭,得记一大堆类名

Tailwind 的类名不是随便起的,得熟悉它的命名规则:padding 用 p-,margin 用 m-,响应式前缀 md: lg:,hover: focus: 之类的变体。

刚上手时,总得翻文档或靠记忆。很多人说“用一周就上手了”,但对 CSS 不熟的新人来说,可能得一个月才能流畅。

还有,复杂样式(如嵌套选择器、父子状态)用纯 Tailwind 写起来麻烦,得靠 group- 或插件。

解决方法:VS Code 的 IntelliSense 插件提示

image.png

效果展示:

image.png

3. 维护和重构时麻烦,尤其是大项目

想全局改个间距(如所有 padding 从 4 改成 6)?得搜索替换一堆类名。传统 CSS 改一个变量或类就行。

条件样式(比如根据 props 动态类)需要额外工具如 clsx 或 tailwind-merge,否则容易冲突。

调试时,浏览器 DevTools 里一堆原子类,虽然能点开看,但不如语义类直观。

大项目里,如果不严格规范,容易样式重复或不一致。

Tailwind 的响应式设计:Mobile First 是王道

Tailwind 默认是 mobile-first:先写移动端样式,再用前缀(如 md: lg:)覆盖大屏。

比如我做的布局:

export default function App2() {
  return (
    <div className="flex flex-col gap-4 p-4 md:flex-row">
      <main className="bg-blue-100 p-4 rounded-lg md:w-2/3">主内容区</main>
      <aside className="bg-green-100 p-4 rounded-lg md:w-1/3">侧边栏</aside>
    </div>
  );
}
  • 默认(手机):flex-col,垂直栈。
  • md 及以上:flex-row,横排;main 占 2/3,aside 占 1/3。

为什么 mobile-first 好?因为手机流量占一半以上,先保证小屏体验好,再逐步增强大屏。传统 desktop-first 容易忽略手机。

Tailwind 断点默认:

  • sm: 640px
  • md: 768px
  • lg: 1024px
  • xl: 1280px
  • 2xl: 1536px

想只在某个范围生效?组合 max- 前缀,比如 md:max-lg: 只在中屏。

进阶小技巧和最佳实践

用 Tailwind 久了,有些心得:

  1. 类名顺序有讲究:我喜欢按“同心圆”顺序:布局 → 盒模型 → 背景 → 文字 → 其他。这样读起来顺眼。
  2. 重复类太多?提取组件:别在每个按钮都写一长串,封装成 < Button variant="primary" >。
  3. 用 @apply 自定义类:如果某些组合常用,在 CSS 里:
.btn-primary {
  @apply px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700;
}
  1. 暗黑模式超简单:加 class="dark" 到 html,utility 用 dark: 前缀。
  2. 性能优化:Tailwind 自动 purge 未用类,生产包很小。
  3. 和大模型结合:Tailwind 类名语义强,用 AI 生成 UI 特别准。描述“一个蓝色大按钮,hover 变深”,直接吐类名。

React 中的 Fragment

React 组件为什么只能返回一个根元素(没有并列的多个根元素)?

你在学 React 时,肯定碰到过这个经典报错:

image.png 错误信息大概是:“JSX expressions must have one parent element” 或 “Adjacent JSX elements must be wrapped in an enclosing tag”。

简单说,就是 React 函数组件的 return 里,只能返回一个单一的根元素,不能并列多个顶级元素

错的例子:

function App() {
  return (
    <h1>标题</h1>
    <p>段落</p>  // 报错!两个并列
  );
}

对的例子(传统方式):

function App() {
  return (
    <div>  // 用 div 包裹成一个根
      <h1>标题</h1>
      <p>段落</p>
    </div>
  );
}

现在很多人用 Fragment:

function App() {
  return (
    <>  // 空标签,就是 Fragment
      <h1>标题</h1>
      <p>段落</p>
    </>
  );
}

那为什么 React 要强制这个规则呢?不能直接支持并列多个根吗?

核心原因:JSX 本质上是 React.createElement 的语法糖

React 组件其实就是一个函数,这个函数必须返回 一个值(单个 React Element)。

JSX 看起来像 HTML,但它会被 Babel 编译成 JavaScript 的 React.createElement 调用。

比如这个 JSX:

<div>
  <h1>标题</h1>
  <p>段落</p>
</div>

编译后大致是:

React.createElement("div", null,
  React.createElement("h1", null, "标题"),
  React.createElement("p", null, "段落")
);

createElement 返回的是 一个单一的 React Element 对象,它可以有多个 children(子元素数组),但本身必须是单个对象。

如果你写并列两个:

<h1>标题</h1>
<p>段落</p>

编译后相当于:

React.createElement("h1", null, "标题");
React.createElement("p", null, "段落");  // 两个独立的调用

这就像函数里有两条 return 语句:

function bad() {
  return 1;
  return 2;  // 第二个永远执行不到,语法无效
}

JavaScript 函数只能返回一个值,所以 React 组件也只能返回一个 React Element。

另一个重要原因:React 的虚拟 DOM 和 Reconciliation(调和)算法需要树状结构

React 的核心是虚拟 DOM:一个 JavaScript 对象树。

每个组件渲染后,必须对应虚拟 DOM 树上的 一个节点(根节点),这个节点可以有任意多个子节点,但组件本身只能代表一个节点。

如果允许并列多个根,React 就不知道这个组件在虚拟 DOM 树里该怎么挂载、怎么 diff(比较新旧虚拟 DOM)、怎么更新。

React 的 diff 算法依赖严格的树结构:父子关系清晰,便于高效比较和更新。

多根的话,树就乱了,算法复杂度会爆炸,性能变差。

历史背景:早期只能用 div 包裹,后来引入 Fragment

React 早期(v16 之前),大家只能用无意义的 < div> 包裹。

问题:

  • 多一层 DOM 节点,影响 CSS 布局(比如 flex、grid)
  • 在表格 里返回多个 ,包 div 会导致无效 HTML
  • DOM 树更深,略微影响性能

React v16(2017 年)引入 Fragment,专门解决这个痛点。

Fragment 就是一个“透明包裹”,不渲染成真实 DOM 节点,只在虚拟 DOM 里占位。

写法:

import { Fragment } from 'react';
// 或直接用短语法(推荐)
return (
  <Fragment>
    <h1>标题</h1>
    <p>段落</p>
  </Fragment>
);

// 最常用:
return (
  <>
    <h1>标题</h1>
    <p>段落</p>
  </>
);

注意:短语法 <></> 不支持 key 属性,如果在 map 里需要 key,就用 < Fragment key=...>。

总结

  • 技术原因:JSX → React.createElement 只能返回单个元素,函数只能 return 一个值。
  • 算法原因:虚拟 DOM 需要严格树结构,便于高效 diff 和更新。
  • 解决方案:用 Fragment(<>...</>)包裹,既满足规则,又不污染真实 DOM。

这个规则虽然一开始让人觉得麻烦,但它保证了 React 的高效和可预测性。用习惯 Fragment 后,你会觉得超级自然。

为什么叫TailWind

“Tailwind” 在英文里是“顺风”的意思,听起来挺诗意的,跟 CSS 框架有啥关系?

其实,名字的来源超级随意,来自它的创始人 Adam Wathan 本人的一次脑洞大开。

Adam 在一次访谈中亲口说过(大概 2022 年的播客):

他当时在 brainstorm 项目名字,从 “tail” 这个词开始联想——先想到 “tail tail”(重复),然后 “white tail”(白尾?可能是白尾鹿),突然蹦出 Tailwind

他说:“That's a cool name. 那名字挺酷的,而且有点道理,因为它能帮你做事更快(do stuff faster)。”

“Tailwind” 在英语里确实有“顺风”的含义,比如飞机起飞时如果有 tailwind,就能飞得更快、更顺畅。Adam 觉得这个框架能让开发者写样式更快、更高效,就像顺风助力一样,所以名字就这么定了。

他说这是他想到的第一个名字,就觉得特别合适,直接敲定了。

Tailwind CSS 最早是 Adam 在 2017 年做一个侧边项目时,顺手搞出来的一个内部工具(用 Less 写的 utility 类)。后来直播开发时,观众老问“这 CSS 框架叫啥”,他才决定开源。名字就是这么随便取的,没有什么深奥的典故,就是觉得“酷 + 贴合加速开发的理念”。

有趣的是,Tailwind 的 slogan 也是 “Rapidly build modern websites without ever leaving your HTML”(快速构建现代网站,不用离开 HTML),完美呼应了“顺风加速”的感觉。

所以,Tailwind 这个名字本质上就是:

  • 随意脑暴而来:从 “tail” 联想到的词。
  • 寓意开发加速:像顺风一样,让你写 UI 更快、更顺手。

写在最后:为什么我推荐你试试 Tailwind

从传统 CSS 到 Tailwind,我的感觉是:从“手艺人”变成了“建筑师”。以前抠每一个像素写规则,现在直接用现成积木搭,专注在产品逻辑和用户体验上。项目迭代快了,代码干净了,心情也好了。

如果你还在用传统 CSS 纠结,不妨在新项目里试试 Tailwind + React。刚开始可能不适应类名多,但坚持一周,你会爱上这种自由。

🚀 从零开始:如何从 GPTsAPI.net 申请 API Key 并打造自己的 AI 服务

在国内开发 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 节流:从百度搜索到京东电商,看前端性能优化的“节奏哲学”


🔍引言

在现代 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


深入防抖与节流:从闭包原理到性能优化实战

前言

在前端开发中,防抖(Debounce)节流(Throttle) 是两种经典的性能优化技术,广泛应用于搜索建议、滚动加载、窗口缩放等高频事件场景。它们能有效减少不必要的函数调用,避免页面卡顿或请求爆炸。

要深入理解其实现原理,你需要掌握以下核心知识点:

闭包(Closure) :用于在函数返回后仍能“记住”并访问内部变量(如定时器 ID 或时间戳)

对于闭包,我写了这两篇文章

柯里化:用闭包编织参数的函数流水线

JavaScript 词法作用域与闭包:从底层原理到实战理解

this 与参数的正确传递:确保被包装的函数在正确上下文中运行。

对于this,有不懂的可以参考这篇文章:

this 不是你想的 this:从作用域迷失到调用栈掌控

本文将结合生活类比、代码实现与真实场景,带你一步步拆解防抖与节流的机制、差异与应用之道。即使你曾觉得它们“有点绕”,读完也会豁然开朗。

一、问题背景:输入框频繁触发事件

全部代码在后面的附录

在 Web 开发中,用户在输入框中打字时,常会绑定 keyup 事件来实时响应输入内容。例如:

// 1.html Lines 17-19
function ajax(content) {
  console.log('ajax request', content);
}
// 1.html Lines 64-66
inputa.addEventListener('keyup', function(e) {
  ajax(e.target.value); // 复杂操作
});

问题:每当用户输入一个字符,就会触发一次 ajax() 调用。若用户输入 “hello”,将产生 5 次请求,造成不必要的网络开销和性能浪费。

image.png


二、防抖(Debounce)机制

想象你站在电梯里,正等着门关上。

可就在这时,一个路人匆匆跑进来,门立刻重新打开;还没等它合拢,又一个人冲了进来……只要不断有人进入,电梯就会一直“耐心”地等下去。

我站在里面心想:“这门到底什么时候才关啊?”

直到最后,整整几秒钟没人再进来——终于,“叮”一声,门缓缓合上,电梯开始运行。

这就像防抖:只要事件还在频繁触发,函数就一直“等”;只有当触发停歇了一段时间,它才真正执行。

这种“按节奏执行”的思想,不仅存在于游戏中,也广泛应用于 Web 交互。

一些AI编辑器 ( 比如Trae Cursor )就是这样

当你在代码框里飞快敲字时,它并不会每按一个键就立刻分析整段逻辑或发起智能补全请求。

那样做不仅浪费资源,还会拖慢输入体验。

相反,它会默默“观察”你的输入节奏:

只要你还在连续打字,它就耐心等待;一旦你停顿半秒,它才迅速介入,给出精准建议

代码实现

// 1.html Lines 21-30
function debounce(fn, delay) {
  let id; // 闭包中的自由变量,用于保存定时器 ID
  return function(...args) {
    if (id) clearTimeout(id); // 清除上一次的定时器
    const that = this;
    id = setTimeout(() => {
      fn.apply(that, args);
    }, delay);
  };
}

关键点解析

防抖函数通过闭包维护一个共享的定时器标识 id,使得多次事件触发都能访问并操作同一个状态。

每当用户触发事件(如键盘输入),函数会先清除之前尚未执行的定时器(如果存在),然后重新启动一个延迟为 delay 毫秒的新定时器

这意味着只要用户持续操作,计时就会不断重置,真实逻辑始终被推迟;只有当用户停止操作并经过指定的等待时间后,目标函数才会真正执行。

delay = 500ms 为例,若用户在 200ms 内快速输入 “hello”,每次按键都会打断之前的倒计时,最终仅在最后一次输入结束 500ms 后调用一次 ajax("hello")。整个过程将原本可能触发 5 次的请求压缩为 1 次,在保证响应合理性的同时,显著降低了系统开销。

image.png

使用示例

// 1.html Lines 58-69
const debounceAjax = debounce(ajax, 500);

inputb.addEventListener('keyup', function(e) {
  debounceAjax(e.target.value);
});

三、节流(Throttle)机制

核心思想

在固定时间间隔内,最多执行一次函数。

我正在玩一款FPS游戏,手指死死按住鼠标左键疯狂扫射——

可游戏里的枪根本没跟着我的节奏“突突突”到底。明明我一秒点了十下,它却稳稳地“哒、哒、哒”,每隔固定时间才射出一发子弹。

后来我才明白:这不是卡顿,而是射速限制在起作用。无论我多着急、按得多快,系统都会冷静地按自己的节奏来,既不让火力过猛破坏平衡,也不让我白白浪费弹药。

这就像节流:不管事件触发得多密集,函数都坚持“定时打卡”,不多不少,稳稳执行。

这种设计哲学,同样被现代开发工具所采纳

比如京东等电商平台:鼠标滚动时,页面需要不断判断是否已滑动到商品列表底部,从而决定是否自动加载下一页商品。

如果对每一次滚动事件都立即响应,浏览器会因频繁计算和发起网络请求而卡顿,尤其在低端设备上体验更差。

于是,开发者会使用节流机制——将滚动处理函数限制为每 200~300 毫秒最多执行一次。这样,即使用户快速拖动滚动条,系统也只会在固定间隔“抽样”检查位置,既保证了加载的及时性,又避免了性能过载。

换句话说:我不在乎你滚得多快,我只按自己的节奏干活——这正是节流在真实场景中的价值。

代码实现

// 1.html Lines 32-52
function throttle(fn, delay) {
  let last = 0;       // 上次执行的时间戳
  let deferTimer = null;

  return function(...args) {
    const now = Date.now();
    const that = this;

    if (last && now < last + delay) {
      // 还未到下次执行时间:延迟执行,并确保最后一次能触发
      clearTimeout(deferTimer);
      deferTimer = setTimeout(() => {
        last = now;
        fn.apply(that, args);
      }, delay - (now - last));
    } else {
      // 可立即执行
      last = now;
      fn.apply(that, args);
    }
  };
}

关键点解析

节流函数通过闭包维护两个关键状态:

last 记录上一次实际执行的时间戳,deferTimer 则用于管理可能的延迟执行任务。

每当事件被触发,函数会先获取当前时间,并判断距离上次执行是否已超过设定的间隔 delay

如果尚未到冷却期(即 now < last + delay),它不会立即执行,而是清除之前安排的延迟任务,并根据剩余时间重新设置一个定时器,确保在当前周期结束时至少执行一次;

如果已经过了冷却期,则直接执行函数并更新 last。这种机制既实现了“固定频率执行”的节奏控制,又巧妙地保证了在连续高频触发的末尾仍能响应最后一次操作。

例如,在 delay = 500ms 的配置下,无论用户在短时间内触发多少次事件,函数都会在 0ms、500ms、1000ms 等时间点稳定执行,既避免了过度调用,又不丢失关键的最终状态。

使用示例

// 1.html Lines 59-62
const throttleAjax = throttle(ajax, 500);

inputc.addEventListener('keyup', function(e) {
  throttleAjax(e.target.value);
});

四、典型应用场景

防抖适用场景

防抖最适合那些“只关心最终结果”的交互场景。

例如,在百度或淘宝的搜索框中,用户一边输入一边期待建议词,但如果每敲一个字母就立刻发起请求,不仅会制造大量无意义的网络调用,还可能因中间态(如拼音未完成)返回错误结果。

通过防抖,系统会耐心等到用户停顿片刻(比如 300 毫秒),再以最终输入内容发起一次精准查询。

类似的逻辑也适用于表单字段的验证——只有当用户真正输完并稍作停顿,才触发校验,避免在输入过程中不断弹出错误提示干扰操作。

简言之,防抖在“太快导致资源浪费”和“太慢影响体验”之间找到了最佳平衡点。

节流适用场景

相比之下,节流则适用于需要“持续响应但必须限频”的场景。

比如在京东、掘金等电商或内容平台,用户快速滚动页面时,系统需判断是否已滑到底部以加载更多商品或帖子。若对每一次滚动都立即响应,浏览器将不堪重负。

而通过节流(如每 300 毫秒最多执行一次检查),既能及时感知滚动行为,又避免过度计算。

同样,鼠标移动或元素拖拽过程中,实时更新坐标若不加限制,极易造成界面卡顿;节流能确保 UI 以稳定帧率更新,保持流畅感。甚至在某些对 resize 事件要求实时反馈的场景(如动态调整画布或视频比例),也会采用节流而非防抖,以兼顾响应性与性能。


防抖与节流,看似简单,却是前端性能优化的基石。掌握它们,就掌握了在“响应速度”与“系统负担”之间优雅平衡的艺术。


五、完整示例代码

上面的代码

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>防抖</title>
</head>
<body>
  <div>
    <input type="text" id="undebounce" />
    <br>
    <input type="text" id="debounce" />
    <br>
    <input type="text" id="throttle" />
  </div>
  <script>
  function ajax(content) {
    console.log('ajax request', content);
  }
  // 高阶函数 参数或返回值(闭包)是函数(函数就是对象) 
  function debounce(fn, delay) {
    var id; // 自由变量 
    return function(args) {
      if(id) clearTimeout(id);
      var that = this;
      id = setTimeout(function(){
        fn.call(that, args)
      }, delay);
    }
  }
  // 节流 fn 执行的任务 
  function throttle(fn, delay) {
    let 
      last, 
      deferTimer;
    return function() {
      let that = this; // this 丢失
      let _args = arguments // 类数组对象
      let now = + new Date(); // 类型转换, 毫秒数
      // 上次执行过 还没到执行时间
      if(last && now < last + delay) {
        clearTimeout(deferTimer);
        deferTimer = setTimeout(function(){
          last = now;
          fn.apply(that, _args);
        }, delay - (now - last));
      } else {
        last = now;
        fn.apply(that, _args);
      }
    }
  }
  
  const inputa = document.getElementById('undebounce');
  const inputb = document.getElementById('debounce');
  const inputc = document.getElementById('throttle');

  let debounceAjax = debounce(ajax, 500);
  let throttleAjax = throttle(ajax, 500);
  inputc.addEventListener('keyup', function(e) {
    throttleAjax(e.target.value)
  })
  // 频繁触发
  inputa.addEventListener('keyup', function(e) {
    ajax(e.target.value) // 蛮复杂
  })
  inputb.addEventListener('keyup', function(e) {
    debounceAjax(e.target.value)
  })
  </script>
</body>
</html>

网站/接口可用性拨测最佳实践

简介

可用性监测是观测云提供的综合性在线服务监控方案。它通过创建无需编写代码的 API,利用全球分布的监测点模拟真实用户在不同地区和网络环境下的访问体验。这种监测不仅涵盖网络质量、网站性能、关键端点等关键业务场景,还提供了对用户使用体验等多维度性能指标的周期性监控。

应用场景

  • 多协议支持:基于 HTTP、TCP、ICMP、WEBSOCKET 协议创建拨测任务,多方面主动监控在线业务的可用性和性能;
  • 全球网络监控:利用观测云遍布全球的监测点,即时监测网络性能,保障全球服务的可用性和性能表现;
  • 网络站点访问性能分析:从地理纬度和可用性趋势两个方面,分析网络站点的可用性性能;
  • 实时告警通知:基于拨测任务产生的数据配置告警规则,当业务出现异常,会基于规则以邮件、钉钉机器人等方式发送告警通知。

实践步骤

1、创建拨测任务

  • 观测云的「可用性监测」功能中,新建拨测任务,这里以 API 拨测为例。

  • 选择拨测类型,填写目标 URL 和判断条件。

  • 按需选择发送拨测的节点,以及拨测频率,点击保存即可。

2、查看效果

等拨测频率触发后,即可在「可用性监测」的概览和查看器中,即可查看到详细的结果。

3、设置告警监控

当我们希望拨测结果有异常时,能主动告警通知到相关的负责人;我们可以设置监控器来解决这个问题。

3.1 新建可用性数据检测

在观测云的「监控」功能中,新建监控器,选择“可用性数据检测”。

3.2 填写检测配置

按需填写检测频率、检测区间、以及触发的规则。这里表示响应时间大于 100ms 就告警。更多详情,可参考规则配置

3.3 自定义通知内容

观测云支持自定义告警通知的标题和内容,并且可以使用预置的模板变量

3.4 选择告警策略

监控满足触发条件后,支持将告警消息发送给指定的通知对象。通知对象包括但不限于:钉钉机器人、企业微信机器人、飞书机器人、Webhook 自定义、短信组、简单 HTTP 请求、Slack、Teams、电话、IM 消息发送等等

3.5 查看告警结果

告警触发后,相关通知对象就会收到告警信息,以下是钉钉机器人的告警信息:

手把手封装Iframe父子单向双向通讯功能

手把手封装Iframe父子单向双向通讯功能

导言

最近在研究多系统集成到一个主系统中,也就是所谓“微前端”,在研究了用微前端框架 micro-appqiankun来搭建测试项目,发现似乎有一点麻烦。

因为我的项目不需要复杂的路由跳转,只有简单的数据通讯,似乎用Iframe更加符合我当前的业务场景。

业务场景分析

image.png

如上图,我将业务场景使用最小demo展示出来

我们使用vue3+hook封装工具函数

目前实现的功能

我需要父子页面能够单向双向的互相通讯

下面是实现的代码片段功能

单向数据传输
  1. 父级向子级主动发送数据,子级接收父级发来的数据。

    •   // 父级向子级主动发送数据
        send('parent_message', {
            action: 'update',
            value: 'Hello from parent'
        });
      
    •   // 子级接收父级发来的数据
        on('parent_message', data => {
            parentData.value = data;
            console.log('收到父应用消息', data);
        });
        // 收到父应用消息 {action: 'update', value: 'Hello from parent'}
      
  2. 子级向父级主送发送数据,父级接收子级发来的数据。

    •   // 子级向父级主送发送数据
        send('child_message', {
            message: 'Hello from child',
            time: new Date().toISOString()
        });
      
        // 父级接收子级发来的数据。
        on('child_message', data => {
          receivedData.value = data;
          console.log('收到子应用消息', data);
        });
        // 收到子应用消息 {message: 'Hello from child', time: '2025-12-30T08:23:38.850Z'}
      
双向数据传输
  1. 父级向子级发起数据获取请求并等待,子级收到请求并响应

    •   // 父级向子级发起数据获取请求并等待
        try {
            const response = await sendWithResponse('get_data', {
                query: 'some data'// 发给子级的数据
            });
            console.log('收到子级响应数据', response);
        } catch (error) {
            console.error('请求失败', error);
        }
        // 收到子级响应数据 {result: 'data from child', query: 'some data', _responseType: 'get_data_response_1767082999194'}
      
    •   // 子级收到请求并响应
        handleRequest('get_data', async data => {
            // 处理数据
            return {
                result: 'data from child',
                ...data
            };
        });
      
  2. 子级向父级发起数据获取请求并等待,父级收到请求并响应

    •   // 子级向父级发起数据获取请求并等待
          try {
            const response = await sendWithResponse('get_data', {
              query: 'some data'
            });
            console.log('收到响应数据', response);
          } catch (error) {
            console.error('请求失败', error);
          }
          收到响应数据 {result: 'data from parent', query: 'some data', _responseType: 'get_data_response_1767083018851'}
      
    •   // 父级收到请求并响应
          handleRequest('get_data', async data => {
            // 处理数据
            return {
              result: 'data from parent',
              ...data
            };
          });
      

Iframe通讯原理解析

判断是否在iframe嵌套中

这是最简单且常用的方法。

// window.self 表示当前窗口对象,而 window.top 表示最顶层窗口对象。
// 如果两者不相等,则说明当前页面被嵌套在 iframe 中。
if (window.self !== window.top) {
console.log("当前页面被嵌套在 iframe 中");
} else {
console.log("当前页面未被嵌套");
}

核心发送消息逻辑

常使用postMessage来进行消息发送

otherWindow.postMessage(message, targetOrigin, [transfer]);
  • otherWindow

    • 其他窗口的一个引用,比如 iframe 的 contentWindow 属性。
  • message

    • 将要发送到其他 window 的数据。可以是字符串或者对象类型。
  • targetOrigin

    • 通过窗口的 origin 属性来指定哪些窗口能接收到消息事件,其值可以是字符串"*"(表示无限制)或者一个 URI。
  • transfer(一般不传)

    • 是一串和 message 同时传递的 Transferable 对象。这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。

核心接收消息逻辑

父子元素通过 监听 message事件来获取接收到的数据。我们将根据这个函数来封装自己的工具函数

window.addEventListener('message', e => {
    // 通过origin对消息进行过滤,避免遭到XSS攻击
    if (e.origin === 'http://xxxx.com') {
        // 判断来源是否和要通讯的地址来源一致
        console.log(e.data)  // 子页面发送的消息, hello, parent!
    }
}, false);

项目搭建

src/
├── utils/
│   ├── iframe-comm.js        # 父应用通信类
│   └── iframe-comm-child.js  # 子应用通信类
├── composables/
│   └── useIframeComm.js      # Vue3 组合式函数
├── App.vue                   # 父应用主组件
└── main.js

使用vite创建两个vue3项目

pnpm create vite

项目名分别是ParentChild,分别代表父级应用和子级应用,除了App.vue不一样其他代码都是相同的

image-20251230164134789

源码分析

核心逻辑分析

我们首先实现两个工具函数,iframe-comm.jsiframe-comm-child.js,分别作为父级和子级的工具函数,他们的逻辑大致一样,核心逻辑是:

  • constructor

    • 初始化配置信息,包括连接目标地址,是父级还是子级,事件对象处理合集等
  • initMessageListener

    • 初始化message事件消息监听
  • connect

    • 传入iframe元素,为全局对象设置iframe
  • send

    • 通过postMessage发送消息
  • on

    • 监听消息,通过new Map(事件类别,[事件回调]),可以实现对多个不同事件监听多个回调函数,后续监听顺序触发回调函数,获取接收到的消息。
  • off

    • 取消监听消息
  • sendWithResponse

    • 发送消息并等待响应,发生消息之前,设置一个回调函数,当消息发送成功后,回调函数会被触发,触发后回调函数清楚。
  • handleRequest

    • 配合sendWithResponse使用,主要监听sendWithResponse事件类别所发来的数据,处理完成并返回结果数据,返回的结果会触发sendWithResponse中设置的回调函数
  • destroy

    • 销毁实例

然后是hook函数useIframeComm.js,这个函数主要是封装了上面两个工具方法,方便vue3项目集成使用

如果是react框架可以自行封装hook函数,原生JS项目的话,可以直接使用上面的工具函数

然后就是App.vue的实现了,可以直接参照源码

src/utils工具函数
  • iframe-comm-child.js 供子级使用的工具函数

    •   // iframe-comm-child.js
        class IframeCommChild {
          constructor(options = {}) {
            this.parentOrigin = options.parentOrigin || window.location.origin;
            this.handlers = new Map();
            this.isParent = false;
        
            // 初始化消息监听
            this.initMessageListener();
          }
        
          /**
           * 向父应用发送消息
           * @param {string} type - 消息类型
           * @param {any} data - 消息数据
           */
          send(type, data) {
            const message = {
              type,
              data,
              source: 'child',
              timestamp: Date.now()
            };
        
            try {
              window.parent.postMessage(message, this.parentOrigin);
              return true;
            } catch (error) {
              console.error('发送消息到父应用失败:', error);
              return false;
            }
          }
        
          /**
           * 监听消息
           * @param {string} type - 消息类型
           * @param {Function} handler - 处理函数
           */
          on(type, handler) {
            if (!this.handlers.has(type)) {
              this.handlers.set(type, []);
            }
            this.handlers.get(type).push(handler);
          }
        
          /**
           * 取消监听消息
           * @param {string} type - 消息类型
           * @param {Function} handler - 处理函数
           */
          off(type, handler) {
            if (!this.handlers.has(type)) return;
        
            if (handler) {
              const handlers = this.handlers.get(type);
              const index = handlers.indexOf(handler);
              if (index > -1) {
                handlers.splice(index, 1);
              }
            } else {
              this.handlers.delete(type);
            }
          }
        
          /**
           * 自动响应请求
           * @param {string} requestType - 请求类型
           * @param {Function} handler - 处理函数,返回响应数据
           */
          handleRequest(requestType, handler) {
            this.on(requestType, async (data, event) => {
              const responseType = data?._responseType;
              if (responseType) {
                try {
                  const responseData = await handler(data, event);
                  this.send(responseType, {
                    success: true,
                    data: responseData
                  });
                } catch (error) {
                  this.send(responseType, {
                    success: false,
                    error: error.message
                  });
                }
              }
            });
          }
        
          /**
           * 发送消息并等待响应
           * @param {string} type - 消息类型
           * @param {any} data - 消息数据
           * @param {number} timeout - 超时时间(毫秒)
           * @returns {Promise}
           */
          sendWithResponse(type, data, timeout = 5000) {
            return new Promise((resolve, reject) => {
              const responseType = `${type}_response_${Date.now()}`;
              let timeoutId;
        
              const responseHandler = (response) => {
                clearTimeout(timeoutId);
                this.off(responseType, responseHandler);
                resolve(response.data);
              };
        
              this.on(responseType, responseHandler);
        
              // 发送请求
              const success = this.send(type, {
                ...data,
                _responseType: responseType
              });
        
              if (!success) {
                this.off(responseType, responseHandler);
                reject(new Error('发送消息失败'));
                return;
              }
        
              // 设置超时
              timeoutId = setTimeout(() => {
                this.off(responseType, responseHandler);
                reject(new Error('等待响应超时'));
              }, timeout);
            });
          }
        
          /**
           * 初始化消息监听器
           */
          initMessageListener() {
            window.addEventListener('message', (event) => {
              // 安全检查
              if (this.parentOrigin !== '*' && event.origin !== this.parentOrigin) {
                return;
              }
        
              const { type, data, source } = event.data;
        
              // 只处理来自父应用的消息
              if (source !== 'parent') return;
        
              if (this.handlers.has(type)) {
                this.handlers.get(type).forEach(handler => {
                  try {
                    handler(data, event);
                  } catch (error) {
                    console.error(`处理消息 ${type} 时出错:`, error);
                  }
                });
              }
            });
          }
        
          /**
           * 销毁实例
           */
          destroy() {
            window.removeEventListener('message', this.messageHandler);
            this.handlers.clear();
          }
        }
        
        export default IframeCommChild;
      
  • iframe-comm.js 供父级使用的工具函数

    •   // iframe-comm.js
        class IframeComm {
          constructor(options = {}) {
            this.origin = options.origin || '*';
            this.targetOrigin = options.targetOrigin || window.location.origin;
            this.handlers = new Map();
            this.iframe = null;
            this.isParent = true;
        
            // 初始化消息监听
            this.initMessageListener();
          }
        
          /**
           * 连接到指定iframe
           * @param {HTMLIFrameElement|string} iframe - iframe元素或选择器
           */
          connect(iframe) {
            if (typeof iframe === 'string') {
              this.iframe = document.querySelector(iframe);
            } else {
              this.iframe = iframe;
            }
        
            if (!this.iframe || !this.iframe.contentWindow) {
              console.error('无效的iframe元素');
              return;
            }
        
            this.isParent = true;
            return this;
          }
        
          /**
           * 向子应用发送消息
           * @param {string} type - 消息类型
           * @param {any} data - 消息数据
           * @param {string} targetOrigin - 目标origin
           */
          send(type, data, targetOrigin = this.targetOrigin) {
            if (!this.iframe?.contentWindow) {
              console.error('未连接到iframe或iframe未加载完成');
              return false;
            }
        
            const message = {
              type,
              data,
              source: 'parent',
              timestamp: Date.now()
            };
        
            try {
              this.iframe.contentWindow.postMessage(message, targetOrigin);
              return true;
            } catch (error) {
              console.error('发送消息失败:', error);
              return false;
            }
          }
        
          /**
           * 监听消息
           * @param {string} type - 消息类型
           * @param {Function} handler - 处理函数
           */
          on(type, handler) {
            if (!this.handlers.has(type)) {
              this.handlers.set(type, []);
            }
            this.handlers.get(type).push(handler);
          }
        
          /**
           * 取消监听消息
           * @param {string} type - 消息类型
           * @param {Function} handler - 处理函数
           */
          off(type, handler) {
            if (!this.handlers.has(type)) return;
        
            if (handler) {
              const handlers = this.handlers.get(type);
              const index = handlers.indexOf(handler);
              if (index > -1) {
                handlers.splice(index, 1);
              }
            } else {
              this.handlers.delete(type);
            }
          }
        
          /**
           * 发送消息并等待响应
           * @param {string} type - 消息类型
           * @param {any} data - 消息数据
           * @param {number} timeout - 超时时间(毫秒)
           * @returns {Promise}
           */
          sendWithResponse(type, data, timeout = 5000) {
            return new Promise((resolve, reject) => {
              const responseType = `${type}_response_${Date.now()}`;
              let timeoutId;
        
              const responseHandler = (response) => {
                clearTimeout(timeoutId);
                this.off(responseType, responseHandler);
                resolve(response.data);
              };
        
              this.on(responseType, responseHandler);
        
              // 发送请求
              const success = this.send(type, {
                ...data,
                _responseType: responseType
              });
        
              if (!success) {
                this.off(responseType, responseHandler);
                reject(new Error('发送消息失败'));
                return;
              }
        
              // 设置超时
              timeoutId = setTimeout(() => {
                this.off(responseType, responseHandler);
                reject(new Error('等待响应超时'));
              }, timeout);
            });
          }
        
          /**
           * 初始化消息监听器
           */
          initMessageListener() {
            window.addEventListener('message', (event) => {
              // 安全检查
              if (this.targetOrigin !== '*' && event.origin !== this.targetOrigin) {
                return;
              }
        
              const { type, data, source } = event.data;
        
              // 只处理来自子应用的消息
              if (source !== 'child') return;
        
              if (this.handlers.has(type)) {
                this.handlers.get(type).forEach(handler => {
                  try {
                    handler(data, event);
                  } catch (error) {
                    console.error(`处理消息 ${type} 时出错:`, error);
                  }
                });
              }
            });
          }
        
          /**
           * 销毁实例
           */
          destroy() {
            window.removeEventListener('message', this.messageHandler);
            this.handlers.clear();
            this.iframe = null;
          }
        }
        
        export default IframeComm;
      
src/composables 钩子函数
  • 这里面是核心hook函数

  • useIframeComm.js

    •   // useIframeComm.js
        import { ref, onUnmounted } from 'vue';
        import IframeComm from '../utils/iframe-comm.js';
        import IframeCommChild from '../utils/iframe-comm-child.js';
        
        /**
         * Vue3组合式函数 - 父应用
         */
        export function useIframeComm(options = {}) {
          const comm = ref(null);
          const isConnected = ref(false);
          const lastMessage = ref(null);
        
          const connect = (iframe) => {
            if (comm.value) {
              comm.value.destroy();
            }
        
            comm.value = new IframeComm(options);
            comm.value.connect(iframe);
            isConnected.value = true;
        
            // 监听所有消息
            comm.value.on('*', (data, event) => {
              lastMessage.value = { data, timestamp: Date.now() };
            });
          };
        
          const send = (type, data) => {
            if (!comm.value) {
              console.error('未连接到iframe');
              return false;
            }
            return comm.value.send(type, data);
          };
        
          const on = (type, handler) => {
            if (comm.value) {
              comm.value.on(type, handler);
            }
          };
        
          const off = (type, handler) => {
            if (comm.value) {
              comm.value.off(type, handler);
            }
          };
        
          const sendWithResponse = async (type, data, timeout) => {
            try {
              if (!comm.value) {
                throw new Error('未连接到iframe');
              }
              return await comm.value.sendWithResponse(type, data, timeout);
            } catch (error) {
              console.error(error);
        
            }
          };
          const handleRequest = (requestType, handler) => {
            if (comm.value) {
              comm.value.handleRequest(requestType, handler);
            }
          };
        
          onUnmounted(() => {
            if (comm.value) {
              comm.value.destroy();
            }
          });
        
          return {
            comm,
            isConnected,
            lastMessage,
            connect,
            send,
            on,
            off,
            sendWithResponse,
            handleRequest
          };
        }
        
        /**
         * Vue3组合式函数 - 子应用
         */
        export function useIframeCommChild(options = {}) {
          const comm = ref(null);
          const isReady = ref(false);
          const lastMessage = ref(null);
        
          const init = () => {
            if (comm.value) {
              comm.value.destroy();
            }
        
            comm.value = new IframeCommChild(options);
            isReady.value = true;
        
            // 监听所有消息
            comm.value.on('*', (data, event) => {
              lastMessage.value = { data, timestamp: Date.now() };
            });
          };
        
          const send = (type, data) => {
            if (!comm.value) {
              console.error('子应用通信未初始化');
              return false;
            }
            return comm.value.send(type, data);
          };
        
          const on = (type, handler) => {
            if (comm.value) {
              comm.value.on(type, handler);
            }
          };
        
          const off = (type, handler) => {
            if (comm.value) {
              comm.value.off(type, handler);
            }
          };
        
          const sendWithResponse = async (type, data, timeout) => {
            try {
              if (!comm.value) {
                throw new Error('子应用通信未初始化');
              }
              return await comm.value.sendWithResponse(type, data, timeout);
            } catch (error) {
              console.error(error);
        
            }
          };
        
          const handleRequest = (requestType, handler) => {
            if (comm.value) {
              comm.value.handleRequest(requestType, handler);
            }
          };
        
          onUnmounted(() => {
            if (comm.value) {
              comm.value.destroy();
            }
          });
        
          return {
            comm,
            isReady,
            lastMessage,
            init,
            send,
            on,
            off,
            sendWithResponse,
            handleRequest
          };
        }
      
src/App.vue 主代码
  • 这是父级的App.vue

    • 父级的端口是5173,所以他要连接到5174

    •   <script setup>
        import { ref, onMounted } from 'vue';
        import { useIframeComm } from './composables/useIframeComm';
        const iframeRef = ref(null);
        const receivedData = ref(null);
        // 初始化通讯
        const { connect, send, on, sendWithResponse, handleRequest, lastMessage } = useIframeComm({
          targetOrigin: 'http://localhost:5174'
        });
        const onIframeLoad = () => {
          connect(iframeRef.value);
          // 监听子应用
          on('child_message', data => {
            receivedData.value = data;
            console.log('收到子应用消息', data);
          });
          on('child_response', data => {
            console.log('收到子应用响应', data);
          });
          // 处理请求并自自动响应
          handleRequest('get_data', async data => {
            // 处理数据
            return {
              result: 'data from parent',
              ...data
            };
          });
        };
        const sendMessage = async () => {
          send('parent_message', {
            action: 'update',
            value: 'Hello from parent'
          });
          // 发送并等待响应
          try {
            const response = await sendWithResponse('get_data', {
              query: 'some data'
            });
            console.log('收到子级响应数据', response);
          } catch (error) {
            console.error('请求失败', error);
          }
        };
        </script>
        
        <template>
          <div class="parent">
            <h1>父应用</h1>
            <iframe ref="iframeRef" src="http://localhost:5174" @load="onIframeLoad"></iframe>
            <div style="display: flex; flex-direction: row">
              <div style="flex: 2; border: 1px solid black; height: 100px">接收到的子级消息:{{ receivedData }}</div>
              <button style="flex: 1" @click="sendMessage">发送消息到子应用</button>
            </div>
          </div>
        </template>
        
        <style scoped>
        .parent {
          width: 100%;
          height: 100%;
          background-color: white;
          display: flex;
          flex-direction: column;
          justify-content: center;
          iframe {
            height: 400px;
          }
          h1 {
            text-align: center;
          }
        }
        </style>
        
      
  • 这是子级的App.vue

    • 子级的端口是5174,所以他要连接到5173

    •   <script setup>
        import { onMounted, ref } from 'vue';
        import { useIframeCommChild } from './composables/useIframeComm';
        const parentData = ref(null);
        const { init, send, on, handleRequest, sendWithResponse } = useIframeCommChild({
          parentOrigin: 'http://localhost:5173'
        });
        onMounted(() => {
          init();
          on('parent_message', data => {
            parentData.value = data;
            console.log('收到父应用消息', data);
          });
          // 处理请求并自自动响应
          handleRequest('get_data', async data => {
            // 处理数据
            return {
              result: 'data from child',
              ...data
            };
          });
        });
        // 发送消息到父级
        const sendToParent = async () => {
          send('child_message', {
            message: 'Hello from child',
            time: new Date().toISOString()
          });
          // // 发送响应信息
          // send('child_response', { status: 'success' });
          // 发送并等待响应
          try {
            const response = await sendWithResponse('get_data', {
              query: 'some data'
            });
            console.log('收到响应数据', response);
          } catch (error) {
            console.error('请求失败', error);
          }
        };
        </script>
        
        <template>
          <div class="child">
            <h1>子应用</h1>
            <div style="display: flex; flex-direction: row">
              <div style="flex: 2; border: 1px solid black; height: 100px">接收到的父级消息:{{ parentData }}</div>
              <button @click="sendToParent">发送到父应用</button>
            </div>
            <div></div>
          </div>
        </template>
        
        <style scoped>
        .child {
          width: 100%;
          height: 100%;
          border: 5px dashed black;
          display: flex;
          flex-direction: column;
          justify-content: center;
          h1 {
            text-align: center;
          }
        }
        </style>
        
      

结尾

这个封装方案提供了一个完整、可靠的 Iframe 通信解决方案,适用于各种微前端集成场景。

防抖与节流:前端性能优化的“双子星”,让你的网页丝滑如德芙!

防抖与节流:前端性能优化的“双子星”,让你的网页丝滑如德芙!

在现代 Web 开发中,用户交互越来越丰富,事件触发也越来越频繁。无论是搜索框的实时建议、页面滚动加载,还是窗口尺寸调整,这些看似简单的操作背后,都可能隐藏着性能陷阱。如果不加以控制,高频事件会像洪水一样冲垮你的应用——导致卡顿、内存泄漏,甚至服务器崩溃。

幸运的是,前端工程师早已找到了两大利器:防抖(Debounce)节流(Throttle) 。它们如同性能优化领域的“双子星”,一个专注“等你停手”,一个坚持“按节奏来”。今天,我们就深入剖析这两位高手的原理、区别与实战用法,助你写出更高效、更流畅的代码!


一、问题根源:为什么我们需要防抖和节流?

想象一下你在百度搜索框输入“React教程”:

  • 每按下一个键(R → e → a → c → t …),浏览器都会触发一次 keyup 事件;
  • 如果每次事件都立即发送 AJAX 请求,那么短短 6 个字就会发出 6 次网络请求
  • 而实际上,你只关心最终的关键词 “React教程”。

这就是典型的 “高频事件 + 复杂任务” 组合:

  • 事件太密集keyupscrollresize 等事件每秒可触发数十次;
  • 任务太复杂:AJAX 请求、DOM 操作、复杂计算等消耗大量资源。

若不加限制,后果严重:

  • 浪费带宽和服务器资源;
  • 页面卡顿,用户体验差;
  • 可能因请求顺序错乱导致 UI 显示错误(竞态条件)。

于是,防抖节流 应运而生。


二、防抖(Debounce):只执行最后一次

✅ 核心思想

“别急,等用户彻底停手再说!”

防抖的逻辑非常简单:在连续触发事件的过程中,不执行任务;只有当事件停止触发超过指定时间后,才执行一次。

🏠 生活类比:电梯关门

  • 电梯门打开后,等待 5 秒再关闭;
  • 如果第 3 秒有人进来,就重新计时 5 秒
  • 只有连续 5 秒没人进入,门才真正关闭。

💻 代码实现(闭包 + 定时器)

function debounce(fn, delay) {
  let timer; // 闭包变量,保存定时器 ID
  return function (...args) {
    clearTimeout(timer); // 清除上一个定时器
    timer = setTimeout(() => {
      fn.apply(this, args); // 执行原函数
    }, delay);
  };
}
关键点解析:
  • timer 是自由变量,被内部函数通过闭包“记住”;
  • 每次调用返回的函数,都会先 clearTimeout,再 setTimeout
  • 结果:只有最后一次触发后的 delay 毫秒内无新触发,才会执行

🌟 典型应用场景

场景 说明
搜索建议 用户打字时,等他停手再发请求,避免无效搜索
表单校验 输入邮箱/密码后,延迟验证,减少干扰
窗口 resize 保存布局 用户调整完窗口大小再保存,而非过程中反复保存

✅ 一句话总结:防抖适用于“有明确结束点”的操作,关注最终状态。


三、节流(Throttle):固定间隔执行

✅ 核心思想

“别慌,按我的节奏来!”

节流的逻辑是:无论事件触发多频繁,我保证每隔 X 毫秒最多执行一次任务。

🏠 生活类比:FPS 游戏射速

  • 即使你一直按住鼠标左键,枪也只会按照设定的射速(如每秒 10 发)射击;
  • 多余的点击会被忽略。

💻 代码实现(时间戳版)

function throttle(fn, delay) {
  let last = 0; // 上次执行时间
  return function (...args) {
    const now = Date.now();
    if (now - last >= delay) {
      fn.apply(this, args);
      last = now;
    }
  };
}

但你提供的代码更智能——它结合了尾部补偿

function throttle(fn, delay) {
  let last, deferTimer;
  return function () {
    let that = this;
    let _args = arguments;
    let now = +new Date();

    if (last && now < last + delay) {
      // 还在冷却期:清除旧定时器,安排新尾部任务
      clearTimeout(deferTimer);
      deferTimer = setTimeout(() => {
        last = now;
        fn.apply(that, _args);
      }, delay);
    } else {
      // 冷却期结束:立即执行
      last = now;
      fn.apply(that, _args);
    }
  };
}
工作流程:
  1. 第一次调用 → 立即执行;
  2. 高频调用期间 → 忽略中间操作,但记录最后一次
  3. 停止触发后 → 在 delay 毫秒后执行最后一次。

⚠️ 注意:这种实现确保了尾部操作不丢失,适合需要“收尾”的场景。

🌟 典型应用场景

场景 说明
页面滚动(scroll) 每 200ms 记录一次滚动位置,避免卡顿
鼠标移动(mousemove) 控制动画或绘图频率
按钮防连点 提交订单后 1 秒内禁止再次点击
无限滚动加载 用户滚动到底部时,定期检查是否需加载新数据

✅ 一句话总结:节流适用于“持续高频”的操作,关注过程节奏。


四、防抖 vs 节流:关键区别一目了然

对比项 防抖(Debounce) 节流(Throttle)
执行时机 停止触发后延迟执行 固定间隔执行
执行次数 N 次触发 → 1 次执行 N 次触发 → ≈ N/delay 次执行
是否保留尾部 是(天然保留) 基础版否,增强版可保留
核心机制 clearTimeout + setTimeout 时间戳判断 或 setTimeout 控制
适用事件 inputkeyup scrollresizemousemove
用户感知 “打完字才响应” “滚动时定期响应”

🔥 记住这个口诀:
“防抖等停手,节流控节奏。”


五、闭包:防抖与节流的“幕后英雄”

你可能注意到,无论是 debounce 还是 throttle,都用到了 闭包

function debounce(fn, delay) {
  let timer; // ← 这个变量被内部函数“记住”
  return function() {
    clearTimeout(timer); // ← 能访问外部的 timer
    // ...
  };
}

为什么必须用闭包?

  • timerlast 等状态需要在多次函数调用之间保持
  • 普通局部变量在函数执行完就销毁;
  • 而闭包让内部函数持续持有对外部变量的引用,形成“私有记忆”。

💡 闭包 = 函数 + 其词法环境。它是实现状态管理的基石。


六、实战建议:如何选择?

你的需求 推荐方案
用户输入搜索词 ✅ 防抖(500ms)
监听窗口 resize ✅ 节流(200ms)
滚动加载更多 ✅ 节流(300ms)
表单自动保存草稿 ✅ 防抖(1000ms)
鼠标拖拽元素 ✅ 节流(16ms ≈ 60fps)

📌 小技巧:

  • 防抖延迟通常 300~500ms(平衡响应与性能);
  • 节流间隔通常 100~300ms(根据场景调整)。

七、结语:优雅地控制频率,是专业前端的标志

防抖与节流,看似只是几行代码,却体现了对用户体验和系统性能的深刻理解。它们不是炫技,而是工程实践中不可或缺的“安全阀”。

下次当你面对高频事件时,不妨问问自己:

  • 我需要的是最终结果,还是过程采样
  • 用户是否希望立刻响应,还是可以稍等片刻

答案将指引你选择防抖或节流。掌握这“双子星”,你的代码将不再“颤抖”,而是如丝般顺滑——这才是真正的前端艺术!

vue3 KeepAlive 核心原理和渲染更新流程

vue3 KeepAlive 核心原理和渲染更新流程

KeepAlive 是 Vue 3 的内置组件,用于缓存动态组件,避免重复创建和销毁组件实例。 当组件被切换时,KeepAlive 会将组件实例存储在内存中,而不是完全销毁它,从而保留组件状态并提升性能。

1. 挂载

将子组件vnode进行缓存,并且设置vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE,供运行时在卸载时特殊处理

2. 停用 deactivate

当组件需要隐藏时, 根据COMPONENT_SHOULD_KEEP_ALIVE 和 renderer的逻辑

  1. 将组件移动到 storageContainer(一个不可见的 DOM 容器)
  2. 触发组件的 deactivated 生命周期钩子
  3. 组件实例和状态得以保留

3. 激活 activate

当组件再次激活时, 根据COMPONENT_KEPT_ALIVE 和 renderer的逻辑

  1. 新的 vnode.el 使用 cachedVNode.el
  2. 新的 vnode.component 使用 cachedVNode.component,这个是已经挂载的 组件了,里面的subTree都是有el的
  3. 将 vnode 移回目标容器
  4. 执行 patch 更新(处理 props 变化)
  5. 触发组件的 activated 生命周期钩子

4. 相关源码(只保留关于KeepAlive相关的核心逻辑)

const KeepAliveImpl: ComponentOptions = {
  name: `KeepAlive`,
  __isKeepAlive: true,
  setup(_, { slots }: SetupContext) {
    const instance = getCurrentInstance()!
    const sharedContext = instance.ctx as KeepAliveContext
    const cache: Cache = new Map()
    const keys: Keys = new Set()

    const {
      renderer: {
        p: patch,
        m: move,
        um: _unmount,
        o: { createElement },
      },
    } = sharedContext
    const storageContainer = createElement('div')

    // vnode 缓存的子组件, 结合runtime patch
    sharedContext.activate = (
      vnode,
      container,
      anchor,
      namespace,
      optimized
    ) => {
      // instance 是子组件实例
      const instance = vnode.component!
      // 移回来
      move(vnode, container, anchor, MoveType.ENTER, parentSuspense)
      // in case props have changed
      patch(instance.vnode, vnode, container, anchor, instance,...)
      queuePostRenderEffect(() => {
        instance.isDeactivated = false
        if (instance.a) {
          invokeArrayFns(instance.a)
        }
      }, parentSuspense)
    }

    // vnode 缓存的子组件,里面的缓存的组件除了这两个钩子,其他都是常规流程
    sharedContext.deactivate = (vnode: VNode) => {
      const instance = vnode.component!
      // 移到缓存容器
      move(vnode, storageContainer, null, MoveType.LEAVE, parentSuspense)
      queuePostRenderEffect(() => {
        if (instance.da) {
          invokeArrayFns(instance.da)
        }
      }, parentSuspense)
    }

    // 当缓存失效,就需要真正的卸载
    function unmount(vnode: VNode) {
      // reset the shapeFlag so it can be properly unmounted
      resetShapeFlag(vnode)
      _unmount(vnode, instance, parentSuspense, true)
    }

    let pendingCacheKey: CacheKey | null = null
    const cacheSubtree = () => {
      // fix #1621, the pendingCacheKey could be 0
      if (pendingCacheKey != null) {
        cache.set(pendingCacheKey, getInnerChild(instance.subTree))
      }
    }
    onMounted(cacheSubtree)
    onUpdated(cacheSubtree)

    onBeforeUnmount(() => {
      cache.forEach(unmount)
    })

    // 渲染函数
    return () => {
      pendingCacheKey = null

      const children = slots.default()
      const rawVNode = children[0]
      const vnode = children[0]
      // 这里的vnode 就是指 缓存的组件
      // warn(`KeepAlive should contain exactly one component child.`)

      const comp = vnode.type as ConcreteComponent

      const name = getComponentName(comp)

      const { include, exclude, max } = props

      if (
        (include && (!name || !matches(include, name))) ||
        (exclude && name && matches(exclude, name))
      ) {
        // #11717 // 我写的pr!!!!
        vnode.shapeFlag &= ~ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
        return rawVNode
      }

      const key = vnode.key == null ? comp : vnode.key
      const cachedVNode = cache.get(key)

      pendingCacheKey = key

      if (cachedVNode) {
        // 使用缓存的el,缓存的component tree,所以就不用走mount
        // copy over mounted state
        vnode.el = cachedVNode.el
        vnode.component = cachedVNode.component
        // 结合runtime patch 流程 当激活时就不走mount
        vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
      } else {
        keys.add(key)
      }
      // avoid vnode being unmounted
      // 结合runtime patch 流程 当卸载时就不走unmount
      vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE

      return vnode
    }
  },
}
// renderer 中关于 KeepAlive的逻辑
function baseCreateRenderer() {
  const processComponent = (
    n1: VNode | null,
    n2: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null
  ) => {
    // parentComponent 就是 keepalive
    if (n1 == null) {
      if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
        ;(parentComponent!.ctx as KeepAliveContext).activate(
          n2,
          container,
          anchor,
          namespace,
          optimized
        )
      } else {
        // 正常mount mountComponent
      }
    } else {
      // 正常更新 updateComponent
    }
  }

  const mountComponent: MountComponentFn = (initialVNode) => {
    // initialVNode 是keepalive的vnode时,把对应的render传入进去,这逻辑其实不重要,只是为了封装复用
    // inject renderer internals for keepAlive
    if (isKeepAlive(initialVNode)) {
      ;(instance.ctx as KeepAliveContext).renderer = internals
    }
  }

  const unmount: UnmountFn = (vnode, parentComponent) => {
    // parentComponent 就是 keepalive
    const { shapeFlag } = vnode
    if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
      ;(parentComponent!.ctx as KeepAliveContext).deactivate(vnode)
      return
    }
  }
}

npm/yarn/pnpm 原理与选型指南

npm/yarn/pnpm 深度对比:包管理工具的底层原理与选型

从 node_modules 的黑洞说起,剖析 npm、yarn、pnpm 的依赖解析算法、安装策略、锁文件机制,搞懂为什么 pnpm 能省 50% 磁盘空间。

一、包管理器演进史

2010 ────────────────────────────────────────────────► 2025
  
  ├─ 2010: npm 诞生(随 Node.js 一起发布)
           └─ 嵌套依赖,node_modules 黑洞的开始
  
  ├─ 2016: yarn 发布(Facebook)
           └─ 扁平化 + lockfile,解决依赖地狱
  
  ├─ 2017: npm 5.0
           └─ 引入 package-lock.json,追赶 yarn
  
  ├─ 2017: pnpm 发布
           └─ 硬链接 + 符号链接,革命性架构
  
  ├─ 2020: yarn 2 (Berry)
           └─ Plug'n'Play,零 node_modules
  
  └─ 2024: npm/yarn/pnpm 三足鼎立
            └─ pnpm 市场份额快速增长

二、node_modules 结构演进

2.1 npm v2:嵌套地狱

node_modules/
├── A@1.0.0/
│   └── node_modules/
│       └── B@1.0.0/
│           └── node_modules/
│               └── C@1.0.0/
├── D@1.0.0/
│   └── node_modules/
│       └── B@1.0.0/        ← 重复!
│           └── node_modules/
│               └── C@1.0.0/  ← 重复!
└── E@1.0.0/
    └── node_modules/
        └── B@2.0.0/        ← 不同版本
            └── node_modules/
                └── C@1.0.0/  ← 又重复!

问题

  • 🔴 路径过长(Windows 260 字符限制)
  • 🔴 大量重复依赖,磁盘爆炸
  • 🔴 安装速度慢

2.2 npm v3+ / yarn:扁平化

node_modules/
├── A@1.0.0/
├── B@1.0.0/          ← 提升到顶层
├── C@1.0.0/          ← 提升到顶层
├── D@1.0.0/
├── E@1.0.0/
│   └── node_modules/
│       └── B@2.0.0/  ← 版本冲突,保留嵌套
└── ...

解决了:路径过长、部分重复

新问题

  • 🔴 幽灵依赖:可以 require 未声明的包
  • 🔴 依赖分身:同一个包可能有多个副本
  • 🔴 不确定性:安装顺序影响结构

2.3 pnpm:内容寻址 + 符号链接

~/.pnpm-store/                    ← 全局存储(硬链接源)
└── v3/
    └── files/
        ├── 00/
        │   └── abc123...         ← 按内容哈希存储
        ├── 01/
        └── ...

node_modules/
├── .pnpm/                        ← 真实依赖(硬链接)
│   ├── A@1.0.0/
│   │   └── node_modules/
│   │       ├── A → <store>/A     ← 硬链接到 store
│   │       └── B → ../../B@1.0.0/node_modules/B  ← 符号链接
│   ├── B@1.0.0/
│   │   └── node_modules/
│   │       └── B → <store>/B
│   └── B@2.0.0/
│       └── node_modules/
│           └── B → <store>/B
├── A.pnpm/A@1.0.0/node_modules/A    ← 符号链接
├── D.pnpm/D@1.0.0/node_modules/D
└── E.pnpm/E@1.0.0/node_modules/E

核心原理

┌─────────────────────────────────────────────────────────────────┐
│                    pnpm 的三层结构                               │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   项目 node_modules/          只有直接依赖的符号链接             │
│            │                                                     │
│            ▼                                                     │
│   .pnpm/ 虚拟存储             所有依赖的扁平结构(符号链接)     │
│            │                                                     │
│            ▼                                                     │
│   ~/.pnpm-store/              全局存储(硬链接,真实文件)       │
│                                                                  │
│   💡 同一个包版本,全局只存一份                                  │
│   💡 不同项目通过硬链接共享                                      │
│   💡 项目只能访问声明的依赖(解决幽灵依赖)                      │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

三、幽灵依赖问题详解

3.1 什么是幽灵依赖?

// package.json 只声明了 express
{
  "dependencies": {
    "express": "^4.18.0"
  }
}

// 但你可以这样写(npm/yarn 扁平化后)
const debug = require('debug');  // 😱 未声明,但能用!
const qs = require('qs');        // 😱 express 的依赖

// 问题:
// 1. express 升级后可能不再依赖 debug → 你的代码挂了
// 2. 换台机器安装顺序不同 → 可能找不到
// 3. 代码审查看不出真实依赖

3.2 pnpm 如何解决?

node_modules/
├── express → .pnpm/express@4.18.0/...  ← 只有 express
└── .pnpm/
    └── express@4.18.0/
        └── node_modules/
            ├── express/
            ├── debug/      ← debug 在这里,外面访问不到
            └── qs/
// pnpm 项目中
const debug = require('debug');  
// ❌ Error: Cannot find module 'debug'

// 必须显式声明
// package.json: "debug": "^4.0.0"
// 然后才能用

四、依赖解析算法

4.1 npm/yarn 的依赖提升

// 依赖关系
A@1.0 → B@1.0
C@1.0 → B@2.0

// npm/yarn 解析结果(取决于安装顺序)
// 情况1:先安装 A
node_modules/
├── A@1.0/
├── B@1.0/          ← B@1.0 被提升
└── C@1.0/
    └── node_modules/
        └── B@2.0/  ← B@2.0 嵌套

// 情况2:先安装 C
node_modules/
├── A@1.0/
│   └── node_modules/
│       └── B@1.0/  ← B@1.0 嵌套
├── B@2.0/          ← B@2.0 被提升
└── C@1.0/

这就是为什么需要 lockfile!

4.2 pnpm 的确定性解析

// pnpm 不做提升,结构永远确定
node_modules/
├── A → .pnpm/A@1.0.0/node_modules/A
├── C → .pnpm/C@1.0.0/node_modules/C
└── .pnpm/
    ├── A@1.0.0/node_modules/
    │   ├── A/
    │   └── B → ../../B@1.0.0/node_modules/B
    ├── B@1.0.0/node_modules/B/
    ├── B@2.0.0/node_modules/B/
    └── C@1.0.0/node_modules/
        ├── C/
        └── B → ../../B@2.0.0/node_modules/B

// 💡 每个包都能找到正确版本的依赖
// 💡 不受安装顺序影响

五、Lockfile 机制对比

5.1 三种 lockfile 格式

# ==================== package-lock.json (npm) ====================
{
  "name": "my-app",
  "lockfileVersion": 3,
  "packages": {
    "node_modules/lodash": {
      "version": "4.17.21",
      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
      "integrity": "sha512-v2kDE..."
    }
  }
}

# ==================== yarn.lock (yarn) ====================
lodash@^4.17.0:
  version "4.17.21"
  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz"
  integrity sha512-v2kDE...

# ==================== pnpm-lock.yaml (pnpm) ====================
lockfileVersion: '9.0'
packages:
  lodash@4.17.21:
    resolution: {integrity: sha512-v2kDE...}
    engines: {node: '>=0.10.0'}

5.2 Lockfile 作用

┌─────────────────────────────────────────────────────────────────┐
│                    Lockfile 解决的问题                           │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   package.json: "lodash": "^4.17.0"                             │
│                                                                  │
│   没有 lockfile:                                                │
│   • 今天安装:lodash@4.17.20                                    │
│   • 明天安装:lodash@4.17.21(新版本发布了)                    │
│   • 😱 不同时间/机器安装结果不同                                │
│                                                                  │
│   有 lockfile:                                                  │
│   • 锁定 lodash@4.17.20                                         │
│   • 任何时间/机器安装结果相同                                   │
│   • ✅ 可复现的构建                                             │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

5.3 Lockfile 对比

特性 package-lock.json yarn.lock pnpm-lock.yaml
格式 JSON 自定义 YAML
可读性 差(嵌套深)
合并冲突 难解决 较易 较易
包含信息 完整树结构 扁平列表 扁平 + 依赖关系
文件大小

六、性能对比实测

6.1 安装速度

┌─────────────────────────────────────────────────────────────────┐
                    安装速度对比(中型项目,约 500 依赖)          
├─────────────────────────────────────────────────────────────────┤
                                                                  
   首次安装(无缓存)                                             
   npm:     ████████████████████████████████  65s                
   yarn:    ██████████████████████████        52s                
   pnpm:    ████████████████████              40s   🔥 最快     
                                                                  
   重复安装(有缓存)                                             
   npm:     ████████████████████              38s                
   yarn:    ██████████████                    28s                
   pnpm:    ████████                          15s   🔥  2.5x  
                                                                  
   CI 环境(有 lockfile,无 node_modules)                        
   npm ci:  ████████████████████████          48s                
   yarn:    ██████████████████                35s                
   pnpm:    ████████████                      22s   🔥  2x    
                                                                  
└─────────────────────────────────────────────────────────────────┘

6.2 磁盘占用

┌─────────────────────────────────────────────────────────────────┐
                    磁盘占用对比                                  
├─────────────────────────────────────────────────────────────────┤
                                                                  
   单项目 node_modules                                            
   npm:     ████████████████████████████████  850MB              
   yarn:    ████████████████████████████████  850MB              
   pnpm:    ████████████████████████████████  850MB(首次)      
                                                                  
   10 个相似项目(共享依赖 80%)                                  
   npm:     ████████████████████████████████  8.5GB              
   yarn:    ████████████████████████████████  8.5GB              
   pnpm:    ████████████                      2.1GB   🔥  75% 
                                                                  
   💡 pnpm 通过硬链接共享,相同文件只存一份                      
                                                                  
└─────────────────────────────────────────────────────────────────┘

6.3 性能对比表

指标 npm yarn pnpm
首次安装
重复安装 最快
磁盘占用 低(共享)
内存占用
并行下载
离线模式

七、Monorepo 支持对比

7.1 Workspace 配置

# ==================== npm (v7+) ====================
# package.json
{
  "workspaces": ["packages/*"]
}

# ==================== yarn ====================
# package.json
{
  "workspaces": ["packages/*"]
}

# ==================== pnpm ====================
# pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'
  - '!**/test/**'  # 排除

7.2 Monorepo 命令对比

# 在所有包中执行命令
npm exec --workspaces -- npm run build
yarn workspaces run build
pnpm -r run build                    # 🔥 最简洁

# 在指定包中执行
npm exec --workspace=@my/pkg -- npm run build
yarn workspace @my/pkg run build
pnpm --filter @my/pkg run build      # 🔥 filter 更强大

# 添加依赖到指定包
npm install lodash --workspace=@my/pkg
yarn workspace @my/pkg add lodash
pnpm add lodash --filter @my/pkg

# pnpm filter 高级用法
pnpm --filter "@my/*" run build           # 匹配模式
pnpm --filter "...@my/app" run build      # 包含依赖
pnpm --filter "@my/app..." run build      # 包含被依赖
pnpm --filter "...[origin/main]" run build # Git 变更的包

7.3 Monorepo 对比表

特性 npm yarn pnpm
Workspace 支持 v7+ v1+
依赖提升 默认提升 默认提升 不提升(严格)
Filter 语法 基础 基础 强大
并行执行
拓扑排序
变更检测 ✅ (--filter)

八、特殊场景处理

8.1 Peer Dependencies

// 包 A 声明
{
  "peerDependencies": {
    "react": "^17.0.0 || ^18.0.0"
  }
}

// npm 7+:自动安装 peer deps(可能导致冲突)
// yarn:警告但不自动安装
// pnpm:严格模式,必须显式安装
# pnpm 处理 peer deps 警告
pnpm install --strict-peer-dependencies=false

# 或在 .npmrc 配置
strict-peer-dependencies=false
auto-install-peers=true

8.2 可选依赖失败

// package.json
{
  "optionalDependencies": {
    "fsevents": "^2.3.0"  // macOS only
  }
}

// npm/yarn:失败时静默跳过
// pnpm:同样静默跳过,但日志更清晰

8.3 私有仓库配置

# .npmrc(三者通用)

# 指定 registry
registry=https://registry.npmmirror.com

# 私有包使用私有仓库
@mycompany:registry=https://npm.mycompany.com

# 认证
//npm.mycompany.com/:_authToken=${NPM_TOKEN}

九、安全性对比

9.1 安全审计

# npm
npm audit
npm audit fix
npm audit fix --force  # 强制升级(可能破坏性)

# yarn
yarn audit
yarn audit --json

# pnpm
pnpm audit
pnpm audit --fix

9.2 安全特性对比

特性 npm yarn pnpm
安全审计
自动修复
幽灵依赖防护
完整性校验
签名验证 ✅ (v8.6+)

9.3 pnpm 的安全优势

┌─────────────────────────────────────────────────────────────────┐
│                    pnpm 安全优势                                 │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   1. 防止幽灵依赖攻击                                            │
│      • 恶意包无法被意外引入                                     │
│      • 只能访问显式声明的依赖                                   │
│                                                                  │
│   2. 内容寻址存储                                                │
│      • 文件按哈希存储                                           │
│      • 篡改会导致哈希不匹配                                     │
│                                                                  │
│   3. 严格的依赖解析                                              │
│      • 不会意外使用错误版本                                     │
│      • 依赖关系更清晰                                           │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

十、迁移指南

10.1 从 npm 迁移到 pnpm

# 1. 安装 pnpm
npm install -g pnpm

# 2. 删除 node_modules 和 lockfile
rm -rf node_modules package-lock.json

# 3. 导入(自动生成 pnpm-lock.yaml)
pnpm import  # 可以从 package-lock.json 导入

# 4. 安装
pnpm install

# 5. 更新 CI 脚本
# npm ci → pnpm install --frozen-lockfile
# npm install → pnpm install
# npm run → pnpm run

10.2 从 yarn 迁移到 pnpm

# 1. 删除 yarn 相关文件
rm -rf node_modules yarn.lock .yarnrc.yml

# 2. 导入
pnpm import  # 可以从 yarn.lock 导入

# 3. 安装
pnpm install

# 4. 处理可能的幽灵依赖问题
# pnpm 会报错,按提示添加缺失的依赖
pnpm add <missing-package>

10.3 常见迁移问题

# 问题1:幽灵依赖报错
# Error: Cannot find module 'xxx'
# 解决:显式添加依赖
pnpm add xxx

# 问题2:peer deps 警告
# 解决:配置 .npmrc
echo "auto-install-peers=true" >> .npmrc

# 问题3:某些包不兼容符号链接
# 解决:配置 shamefully-hoist
echo "shamefully-hoist=true" >> .npmrc  # 不推荐,最后手段

# 问题4:postinstall 脚本路径问题
# 解决:使用相对路径或 pnpm 的 hooks

十一、最佳实践

11.1 项目配置建议

# .npmrc(推荐配置)

# 使用国内镜像
registry=https://registry.npmmirror.com

# 自动安装 peer deps
auto-install-peers=true

# 严格模式(推荐)
strict-peer-dependencies=false

# 提升特定包(兼容性问题时使用)
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*

11.2 CI/CD 配置

# GitHub Actions 示例
- name: Setup pnpm
  uses: pnpm/action-setup@v2
  with:
    version: 8

- name: Setup Node.js
  uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'pnpm'  # 🔥 缓存 pnpm store

- name: Install dependencies
  run: pnpm install --frozen-lockfile  # 🔥 CI 必须用 frozen

- name: Build
  run: pnpm run build

11.3 团队协作规范

// package.json
{
  "packageManager": "pnpm@8.15.0",  // 🔥 锁定包管理器版本
  "engines": {
    "node": ">=18",
    "pnpm": ">=8"
  },
  "scripts": {
    "preinstall": "npx only-allow pnpm"  // 🔥 强制使用 pnpm
  }
}

十二、选型建议

12.1 决策树

┌─────────────────────────────────────────────────────────────────┐
│                    包管理器选型决策                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   你的场景是?                                                   │
│        │                                                         │
│        ├─ 新项目 ──────────────────────► pnpm(推荐)           │
│        │                                                         │
│        ├─ 老项目迁移成本高 ────────────► 保持现状               │
│        │                                                         │
│        ├─ Monorepo ────────────────────► pnpm(filter 强大)    │
│        │                                                         │
│        ├─ 磁盘空间紧张 ────────────────► pnpm(省 50%+)        │
│        │                                                         │
│        ├─ CI 速度敏感 ─────────────────► pnpm(快 2x)          │
│        │                                                         │
│        └─ 团队不想学新工具 ────────────► npm(零学习成本)      │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

12.2 场景推荐

场景 推荐 原因
新项目 pnpm 性能好、安全、现代
Monorepo pnpm filter 语法强大
老项目维护 保持现状 迁移有成本
开源项目 npm/pnpm npm 兼容性最好
企业项目 pnpm 磁盘省、速度快
学习/教程 npm 文档最多

12.3 总结对比

维度 npm yarn pnpm
安装速度 ⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐
磁盘占用 ⭐⭐ ⭐⭐ ⭐⭐⭐⭐⭐
安全性 ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐
Monorepo ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐
兼容性 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
学习成本 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
社区生态 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐

最终建议

┌─────────────────────────────────────────────────────────────────┐
│                    2025 年推荐                                   │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   🥇 pnpm:新项目首选                                           │
│      • 性能最好,磁盘最省                                       │
│      • 解决幽灵依赖,更安全                                     │
│      • Monorepo 支持最强                                        │
│      • Vue、Vite 等主流项目都在用                               │
│                                                                  │
│   🥈 npm:兼容性优先                                            │
│      • Node.js 自带,零配置                                     │
│      • 文档最全,问题最好搜                                     │
│      • 开源项目贡献者友好                                       │
│                                                                  │
│   🥉 yarn:特定场景                                             │
│      • 已有 yarn 的老项目                                       │
│      • 需要 Plug'n'Play 的场景                                  │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

如果这篇文章对你有帮助,欢迎点赞收藏!有问题评论区见 🎉

当AI成为你的前端搭子:零门槛用Cursor开启高效开发新时代

从项目初始化到代码优化,一个AI助手如何彻底改变前端开发的工作流

前言:为什么前端开发者需要Cursor?

前端开发正经历一场静悄悄的革命。过去几年,我们见证了从jQuery到三大框架的变迁,从手动配置Webpack到Vite的零配置体验,而现在,AI编程助手正在重新定义我们编写代码的方式。

作为一名前端开发者,你是否也曾面临这些痛点:

  • 纠结于某个复杂组件的实现方案
  • 花费大量时间调试诡异的样式兼容性问题
  • 在重复的业务代码中消耗创造力
  • 面对新技术栈的学习曲线感到焦虑

Cursor的出现,就像是为每个前端开发者配备了一个24小时在线的资深搭档。但不同于普通的代码补全工具,Cursor基于GPT-4,能够理解上下文、分析整个项目结构,并给出智能的解决方案。

一、Cursor初体验:从安装到第一个智能组件

1.1 安装与环境配置

Cursor的安装过程简单到令人惊讶:

# 访问官网下载对应版本
https://www.cursor.so/

# 或者通过Homebrew(macOS)
brew install --cask cursor

安装完成后,你会看到一个与VS Code极其相似的界面——实际上,Cursor正是基于VS Code的开源版本开发,这意味着所有你熟悉的VS Code快捷键和扩展生态基本都能无缝迁移。

重要设置:首次启动后,建议在设置中开启“自动应用建议”功能,这将大幅提升编码效率。

1.2 你的第一个AI辅助组件

让我们从一个实际的前端场景开始:创建一个可复用的表单输入组件。

传统的开发流程:

  1. 创建文件 InputField.jsx
  2. 编写基础结构
  3. 逐步添加功能
  4. 调试和优化

使用Cursor的流程发生了本质变化:

步骤1:用自然语言描述需求 直接在编辑器中输入:

创建一个React表单输入组件,要求:
1. 支持标签、占位符、错误状态
2. 包含输入验证功能
3. 有动画过渡效果
4. 支持自定义样式
5. 使用TypeScript

步骤2:让Cursor生成代码 按下 Cmd/Ctrl + K,Cursor会分析你的需求并生成完整代码:

import React, { useState, useEffect } from 'react';
import './InputField.css';

interface InputFieldProps {
  label: string;
  placeholder?: string;
  type?: 'text' | 'email' | 'password' | 'number';
  required?: boolean;
  error?: string;
  value: string;
  onChange: (value: string, isValid: boolean) => void;
  customClassName?: string;
  validationRules?: {
    pattern?: RegExp;
    minLength?: number;
    maxLength?: number;
    customValidator?: (value: string) => boolean;
  };
}

const InputField: React.FC<InputFieldProps> = ({
  label,
  placeholder = '',
  type = 'text',
  required = false,
  error: externalError,
  value,
  onChange,
  customClassName = '',
  validationRules = {}
}) => {
  const [internalError, setInternalError] = useState<string>('');
  const [isTouched, setIsTouched] = useState(false);
  const [isAnimating, setIsAnimating] = useState(false);

  const validateInput = (inputValue: string): string => {
    if (required && !inputValue.trim()) {
      return '此字段为必填项';
    }

    if (validationRules.pattern && !validationRules.pattern.test(inputValue)) {
      return '格式不正确';
    }

    if (validationRules.minLength && inputValue.length < validationRules.minLength) {
      return `长度不能少于${validationRules.minLength}个字符`;
    }

    if (validationRules.maxLength && inputValue.length > validationRules.maxLength) {
      return `长度不能超过${validationRules.maxLength}个字符`;
    }

    if (validationRules.customValidator && !validationRules.customValidator(inputValue)) {
      return '自定义验证失败';
    }

    return '';
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newValue = e.target.value;
    const errorMsg = validateInput(newValue);
    setInternalError(errorMsg);
    
    // 触发动画
    if (errorMsg && !isAnimating) {
      setIsAnimating(true);
      setTimeout(() => setIsAnimating(false), 300);
    }
    
    onChange(newValue, !errorMsg);
  };

  const handleBlur = () => {
    setIsTouched(true);
    if (value) {
      const errorMsg = validateInput(value);
      setInternalError(errorMsg);
    }
  };

  const error = externalError || internalError;
  const showError = isTouched && error;

  return (
    <div className={`input-field ${customClassName} ${showError ? 'error' : ''} ${isAnimating ? 'shake' : ''}`}>
      <label className="input-label">
        {label}
        {required && <span className="required-asterisk">*</span>}
      </label>
      <input
        type={type}
        value={value}
        onChange={handleChange}
        onBlur={handleBlur}
        placeholder={placeholder}
        className={`input-element ${showError ? 'input-error' : ''}`}
        aria-invalid={!!showError}
        aria-describedby={showError ? `error-${label}` : undefined}
      />
      {showError && (
        <div 
          id={`error-${label}`}
          className="error-message animate-in"
          role="alert"
        >
          {error}
        </div>
      )}
    </div>
  );
};

export default InputField;

步骤3:让Cursor生成配套样式 继续使用 Cmd/Ctrl + K

为上面的组件生成合适的CSS样式,要求:
1. 使用现代CSS设计
2. 包含错误状态的视觉反馈
3. 实现摇动动画效果
4. 确保可访问性

Cursor会生成完整的CSS文件,甚至包括CSS变量和动画关键帧。

1.3 进阶功能:让Cursor重构和优化代码

假设我们需要为组件添加防抖功能,可以这样操作:

选中需要修改的函数,按下 Cmd/Ctrl + L 打开聊天框:

为handleChange函数添加防抖功能,300ms延迟

Cursor会自动重构代码:

import { debounce } from 'lodash-es';

// 在组件内部
const debouncedChangeHandler = useMemo(
  () => debounce((newValue: string, isValid: boolean) => {
    onChange(newValue, isValid);
  }, 300),
  [onChange]
);

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  const newValue = e.target.value;
  const errorMsg = validateInput(newValue);
  setInternalError(errorMsg);
  
  if (errorMsg && !isAnimating) {
    setIsAnimating(true);
    setTimeout(() => setIsAnimating(false), 300);
  }
  
  // 使用防抖版本
  debouncedChangeHandler(newValue, !errorMsg);
};

二、Cursor的核心优势:前端开发的四大场景

2.1 智能代码补全与上下文理解

传统IDE的自动补全只能基于语法,而Cursor能基于整个项目上下文。例如,当你在Redux项目中输入:

// 你输入:创建一个action creator来处理用户登录
// Cursor会自动生成:
export const loginUser = (credentials) => async (dispatch) => {
  try {
    dispatch({ type: 'LOGIN_REQUEST' });
    const response = await api.post('/auth/login', credentials);
    localStorage.setItem('token', response.data.token);
    dispatch({ type: 'LOGIN_SUCCESS', payload: response.data.user });
    return response.data;
  } catch (error) {
    dispatch({ type: 'LOGIN_FAILURE', payload: error.message });
    throw error;
  }
};

2.2 零成本学习新技术栈

想尝试Svelte但不想花时间学习所有细节?直接告诉Cursor:

用Svelte创建一个可拖拽的任务看板组件,支持本地存储

2.3 调试与问题解决

遇到一个棘手的bug?直接把错误信息贴给Cursor:

我在Next.js项目中遇到这个错误:Hydration failed because the initial UI does not match what was rendered on the server
这是相关组件代码:[粘贴代码]
如何修复?

Cursor不仅能解释问题原因,还能给出具体的修复方案。

2.4 文档和测试生成

生成组件文档:

为InputField组件生成Markdown文档,包括props表格和使用示例

生成单元测试:

为InputField组件编写Jest测试用例,覆盖所有验证规则

三、实战演练:用Cursor快速搭建项目骨架

让我们看看如何用30分钟搭建一个现代React应用骨架:

  1. 项目初始化
使用Vite + React + TypeScript创建新项目,配置好ESLint、Prettier、Tailwind CSS和React Router
  1. 布局组件生成
创建一个响应式布局组件,包含导航栏、侧边栏和主内容区域
导航栏在移动端显示汉堡菜单
  1. 页面组件批量创建
创建以下页面组件:
- 首页:展示仪表盘
- 用户列表:带搜索和分页
- 设置页面:选项卡布局
- 404页面
  1. 状态管理配置
配置Zustand作为状态管理,创建用户和主题的store
  1. API层封装
创建统一的API请求工具,包含拦截器、错误处理和Loading状态管理

四、Cursor使用技巧:提升效率的秘籍

4.1 精准提问的艺术

低效提问:

如何做一个按钮?

高效提问:

创建一个React按钮组件,要求:
1. 支持primary、secondary、danger三种类型
2. 有loading状态和禁用状态
3. 支持图标和文本组合
4. 使用CSS-in-JS方案
5. 导出TypeScript类型定义

4.2 利用项目上下文

Cursor可以分析整个项目结构。在提问前,确保:

  1. 已经打开了相关文件
  2. 提到了重要的依赖项
  3. 说明了项目约束条件

4.3 链式调用

复杂任务可以分解:

第一步:创建一个用户模型接口
第二步:基于这个接口创建CRUD API函数
第三步:创建对应的React Hook封装
第四步:生成使用示例

五、潜在陷阱与最佳实践

5.1 需要谨慎对待的场景

  1. 安全性:永远不要让Cursor生成涉及敏感逻辑的代码(如认证、支付)
  2. 性能关键代码:算法优化等需要人工审查
  3. 业务复杂逻辑:AI可能不理解业务上下文

5.2 推荐的开发流程

需求分析 → 人工设计核心架构 → Cursor辅助实现细节 → 人工代码审查 → 测试验证

5.3 版本控制策略

建议:将所有Cursor生成的代码视为"初稿",经过审查修改后再提交。可以使用特殊的提交前缀:

git commit -m "cursor: 生成基础组件框架"
git commit -m "feat: 优化Cursor生成的表单组件"

结语:AI时代的前端开发者定位

Cursor不是要取代前端开发者,而是成为我们的"超能力扩展"。它让我们:

  1. 专注架构设计:从琐碎代码中解放出来
  2. 加速学习曲线:快速掌握新技术
  3. 提升代码质量:获得即时的最佳实践建议
  4. 激发创造力:尝试更多创新方案

记住:最强大的开发者不是会写所有代码的人,而是知道如何让AI写出更好代码的人。


预告:在下一篇中,我们将深入探讨如何用Cursor重构大型前端项目,包括:

  • 遗留代码的智能分析与重构
  • 技术栈迁移的自动化策略
  • 性能优化的AI辅助方案
  • 团队协作中Cursor的最佳实践

如果你对某个特定场景感兴趣,欢迎在评论区留言。让我们共同探索AI时代前端开发的新范式!

思考题:在你的当前项目中,哪个重复性最高的任务最适合让Cursor来协助完成?试着用它解决一个小问题,并在评论区分享你的体验!

超详细 Vue CLI 移动端预览插件实战:支持本地/TPGZ/NPM/Git 多场景使用(小白零基础入门)

摘要

你是否还在为「移动端如何快速预览本地 Vue 开发项目」而烦恼?手动查找局域网 IP、输入端口号繁琐又容易出错?本文将围绕「实现移动端便捷预览本地服务项目」这一核心需求,手把手教你开发一款 Vue CLI 专属二维码插件。插件不仅能自动生成移动端可扫码的二维码,还完美支持本地调试、TGZ 压缩包、NPM 公开包、Git 仓库四种使用场景,同时附带解决插件开发中「二维码输出时机」的常见问题,代码可直接复制,小白也能轻松上手!

一、前言

在 Vue 项目开发过程中,「移动端预览本地服务」是高频刚需:开发者需要在手机上验证页面适配、交互效果等,但传统方式需要手动查询电脑局域网 IP、记录项目端口,再在手机浏览器中输入地址,步骤繁琐且容易出错(如 IP 输入错误、端口冲突等)。

本文的核心目标是:开发一款 Vue CLI 插件,通过「自动生成可视化二维码」的方式,让手机扫码即可快速访问本地 Vue 服务,同时支持本地调试、TGZ 包、NPM 包、Git 包四种使用场景,满足个人开发、团队协作、全网复用等不同需求,同时解决开发过程中「二维码输出时机」的常见问题。

二、核心需求与插件优势

1. 核心需求

  • 核心功能:自动获取电脑局域网 IP + 项目端口,生成移动端可扫码的二维码,扫码即可访问本地 Vue 服务;
  • 多场景使用:支持本地调试(未发布插件)、TGZ 压缩包(私有部署)、NPM 公开包(全网复用)、Git 仓库(团队协作)四种引入方式;
  • 兼容性强:支持 Vue 2/Vue 3 项目,适配 Vue CLI 3+ 所有版本;
  • 无需手动配置:插件自动开启局域网访问,无需用户修改 vue.config.js
  • 细节优化:解决二维码输出时机问题,确保使用体验流畅,避免日志混乱或输出过晚。

2. 插件核心优势

优势点 具体说明
便捷性 扫码访问,无需手动输入 IP + 端口,零出错
多场景兼容 支持本地/TPGZ/NPM/Git 四种使用方式
无侵入性 不修改项目原有代码,卸载后无残留
自动配置 自动开启 host: 0.0.0.0,支持局域网访问
细节优化 解决二维码输出时机问题,日志整洁美观

三、第一步:插件开发(核心功能实现,支持移动端扫码预览)

1. 插件目录规范(Vue CLI 要求,必须遵守)

Vue CLI 插件必须以 vue-cli-plugin-xxx 命名,否则无法被项目自动识别,执行命令创建目录:

# 创建插件目录并进入(小白直接复制终端执行)
mkdir vue-cli-plugin-vue-mobile-preview && cd vue-cli-plugin-vue-mobile-preview

2. 初始化 package.json(配置插件信息与依赖)

在插件目录下执行 npm init -y 快速生成配置文件,再手动替换为以下内容(确保依赖与兼容性):

{
  "name": "vue-cli-plugin-vue-mobile-preview",
  "version": "1.0.0",
  "description": "Vue CLI 3+ 插件:自动生成二维码,支持移动端扫码预览本地Vue服务,兼容本地/TGZ/NPM/Git多场景",
  "main": "index.js",
  "keywords": [
    "vue",
    "vue-cli",
    "mobile preview",
    "qrcode",
    "local service",
    "TGZ",
    "NPM",
    "Git"
  ],
  "dependencies": {
    "qrcode": "^1.5.3" // 核心依赖:用于生成终端可视化二维码
  },
  "peerDependencies": {
    "@vue/cli-service": ">=3.0.0" // 兼容 Vue CLI 3+ 所有版本
  },
  "author": "你的姓名",
  "license": "MIT"
}
  • 关键说明:dependencies 中声明 qrcode 依赖,用户安装插件后无需手动安装;peerDependencies 确保插件与 Vue CLI 版本兼容。

3. 编写核心 index.js(实现移动端扫码预览核心功能)

这是插件的核心文件,实现「自动获取局域网 IP + 项目端口 + 生成二维码」的核心功能,同时解决「二维码输出时机」问题,代码含详细注释,小白可直接复制:

// 引入核心依赖
const QRCode = require('qrcode');
const os = require('os'); // Node.js 原生模块,无需额外安装,用于获取局域网IP

/**
 * 核心功能1:自动获取本机局域网 IPv4 地址(移动端访问需要该 IP)
 * @returns {string} 有效局域网IP | 兜底本地回环地址(127.0.0.1)
 */
function getLocalLanIp() {
  const networkInterfaces = os.networkInterfaces();
  // 遍历所有网卡接口,筛选符合条件的局域网 IP
  for (const ifaceName in networkInterfaces) {
    const ifaceList = networkInterfaces[ifaceName];
    for (const iface of ifaceList) {
      // 过滤条件:IPv4 协议、非本地回环地址、非虚拟内网地址
      if (iface.family === 'IPv4' && !iface.internal && iface.address !== '127.0.0.1') {
        return iface.address; // 返回有效局域网 IP
      }
    }
  }
  // 兜底:未获取到局域网 IP 时,返回本地回环地址(仅本机可访问,移动端无法扫码)
  return '127.0.0.1';
}

/**
 * 核心功能2:生成并打印移动端可扫码的二维码(核心逻辑)
 * @param {string} lanIp 局域网 IP
 * @param {number} port 项目端口
 */
function printMobileQrcode(lanIp, port) {
  // 构造移动端访问地址(核心:局域网 IP + 项目端口,手机扫码即可访问)
  const mobileAccessUrl = `http://${lanIp}:${port}`;
  // 构造本机访问地址(用于提示开发者)
  const localAccessUrl = `http://localhost:${port}`;

  // 打印醒目提示信息,区分日志层级
  console.log('\n=====================================================');
  console.log('🎉 [vue-cli-plugin-vue-mobile-preview] 移动端预览二维码已生成!');
  console.log(`🔧  本机访问地址:${localAccessUrl}`);
  console.log(`🌐  移动端访问地址:${mobileAccessUrl}`);
  console.log('📱  扫码提示:手机与电脑连接同一 WiFi,打开相机/微信扫一扫即可预览!\n');

  // 生成终端可视化二维码
  QRCode.toString(mobileAccessUrl, {
    type: 'terminal', // 输出到终端
    margin: 1, // 二维码边距(紧凑美观)
    small: false // 显示大尺寸二维码,提高手机扫码成功率
  }, (err, qrCodeStr) => {
    if (!err) {
      console.log(qrCodeStr); // 打印二维码
    } else {
      // 异常捕获:二维码生成失败时,输出错误信息但不影响项目运行
      console.error('❌ 二维码生成失败:', err.message);
      console.log('💡 替代方案:手动在手机浏览器输入地址 -> ', mobileAccessUrl);
    }
    console.log('=====================================================\n');
  });
}

/**
 * 辅助变量:标记是否已打印二维码(避免热更新时重复输出)
 */
let hasPrintQrcode = false;

/**
 * Vue CLI 插件核心导出函数(入口逻辑)
 * @param {Object} api Vue CLI 核心 API
 * @param {Object} options 项目的 vue.config.js 配置项
 */
module.exports = (api, options) => {
  api.chainWebpack((config) => {
    // 核心配置:强制设置 host: 0.0.0.0,允许局域网设备(手机)访问本地服务
    // 无需用户手动修改 vue.config.js,插件自动配置
    config.devServer.host('0.0.0.0');

    // 解决二维码输出时机问题:使用 compiler.hooks.done 事件(项目完全就绪后输出,避免日志混乱)
    // 这是插件开发中的细节优化,不影响核心的移动端预览功能
    config.plugin('mobile-preview-qrcode-plugin').use(class MobilePreviewPlugin {
      apply(compiler) {
        // compiler.hooks.done:Webpack 编译(首次构建/热更新)完成后触发
        compiler.hooks.done.tap('MobilePreviewPlugin', () => {
          // 仅在首次构建完成后打印一次二维码,避免热更新时重复输出
          if (hasPrintQrcode) {
            return;
          }

          // 获取项目端口(优先使用用户自定义端口,默认 8080)
          const devServerConfig = options.devServer || {};
          const projectPort = devServerConfig.port || 8080;
          // 获取局域网 IP
          const localLanIp = getLocalLanIp();
          // 生成并打印移动端预览二维码
          printMobileQrcode(localLanIp, projectPort);

          // 标记已打印二维码
          hasPrintQrcode = true;
        });
      }
    });
  });
};

4. 插件最终目录结构(小白核对,确保无误)

插件目录仅需 2 个核心文件,简洁易维护,结构如下:

vue-cli-plugin-vue-mobile-preview/
├── index.js       # 核心逻辑文件(实现移动端扫码预览+时机优化)
└── package.json   # 插件配置文件(依赖+兼容性配置)

四、第二步:多场景使用教程(核心重点,覆盖所有使用场景)

插件开发完成后,支持 4 种使用场景,满足不同开发需求(个人调试、团队协作、全网复用等),步骤详细,小白可按需选择。

场景1:本地调试(个人开发,未发布插件,快速验证功能)

适用于插件开发完成后,个人在本地 Vue 项目中验证功能,无需发布到任何仓库。

前置准备

  • 插件目录与目标 Vue 项目同级(方便关联),目录结构示例:
├── vue-cli-plugin-vue-mobile-preview/  # 插件目录
└── my-vue-project/                # 目标 Vue 项目(需要移动端预览的项目)
    ├── src/
    ├── package.json
    └── ... 其他项目文件

操作步骤

  1. 进入目标 Vue 项目根目录,执行以下命令关联本地插件:

    # npm 命令(兼容所有环境,推荐小白使用)
    npm install file:../vue-cli-plugin-vue-mobile-preview --save-dev
    
    # 若使用 pnpm,执行此命令
    # pnpm add file:../vue-cli-plugin-vue-mobile-preview -D
    
    # 若使用 yarn,执行此命令
    # yarn add file:../vue-cli-plugin-vue-mobile-preview --dev
    
    • 路径说明:../vue-cli-plugin-vue-mobile-preview 是插件的相对路径,若目录层级不同可调整(如 ../../xxx)。
  2. 验证插件安装成功: 查看目标项目的 package.json 文件,若 devDependencies 中出现以下配置,说明关联成功:

    "devDependencies": {
      "vue-cli-plugin-vue-mobile-preview": "file:../vue-cli-plugin-vue-mobile-preview"
    }
    
  3. 启动项目,验证移动端预览功能: 在目标项目根目录执行启动命令:

    # Vue CLI 3+ 通用命令
    npm run serve
    
    # 兼容命令:npm run dev(部分项目配置别名)
    
  4. 移动端扫码预览:

    • 终端最后会输出二维码(带醒目提示);
    • 确保手机与电脑连接同一 WiFi(同一局域网);
    • 打开手机相机/微信/支付宝「扫一扫」,扫描终端二维码,即可快速预览本地 Vue 项目。
  5. 本地插件卸载(若无需使用):

    # npm 命令
    npm uninstall vue-cli-plugin-vue-mobile-preview
    
    # pnpm/yarn 命令对应:pnpm remove / yarn remove
    

场景2:TGZ 压缩包使用(私有部署,团队内部复用,无需发布 NPM)

适用于插件无需公开,仅在公司/团队内部复用,可上传到私有服务器或共享文件夹供团队成员使用。

操作步骤

  1. 插件打包为 TGZ 压缩包: 进入插件目录,执行以下命令打包:

    # 进入插件目录
    cd vue-cli-plugin-vue-mobile-preview
    
    # 打包生成 TGZ 压缩包(npm pack 是 npm 原生命令,无需额外安装工具)
    npm pack
    
    • 打包成功后,插件目录同级会生成 vue-cli-plugin-vue-mobile-preview-1.0.0.tgz 文件(文件名格式:插件名称+版本号);
    • 该压缩包包含插件所有核心文件,可直接用于安装。
  2. 分发 TGZ 压缩包: 将生成的 TGZ 文件分发到团队成员(如上传到公司私有服务器、共享网盘、GitLab 私有仓库附件等),获取可访问的路径/地址(示例):

    • 本地共享:/Users/xxx/Shared/vue-cli-plugin-vue-mobile-preview-1.0.0.tgz
    • 私有服务器:https://xxx.company.com/plugins/vue-cli-plugin-vue-mobile-preview-1.0.0.tgz
  3. 项目中安装 TGZ 插件: 进入目标 Vue 项目根目录,执行以下命令安装(根据 TGZ 地址类型选择):

    # 方式1:安装本地/共享文件夹中的 TGZ 包
    npm install file:/Users/xxx/Shared/vue-cli-plugin-vue-mobile-preview-1.0.0.tgz --save-dev
    
    # 方式2:安装私有服务器上的 TGZ 包(HTTPS 地址)
    npm install https://xxx.company.com/plugins/vue-cli-plugin-vue-mobile-preview-1.0.0.tgz --save-dev
    
    # pnpm/yarn 命令兼容,只需替换 npm install 为 pnpm add / yarn add
    
  4. 验证移动端预览功能: 执行 npm run serve 启动项目,后续步骤同「本地调试」,手机扫码即可预览。

场景3:NPM 公开包使用(全网复用,开源共享)

适用于插件功能成熟,需要公开给全网开发者使用,发布到 npm 公开仓库。

操作步骤

  1. 前置准备:

    • 拥有 npm 账号(未注册可前往 npm 官网 注册);
    • 切换到 npm 官方源(若配置了国内镜像源,小白执行以下命令):
    npm config set registry https://registry.npmjs.org/
    
  2. 登录 npm 账号: 进入插件目录,执行登录命令,按提示输入用户名、密码、邮箱:

    # 进入插件目录
    cd vue-cli-plugin-vue-mobile-preview
    
    # 登录 npm 账号
    npm login
    
  3. 发布插件到 npm 公开仓库: 在插件目录下执行发布命令,无需额外配置:

    npm publish
    
    • 发布失败排查:若提示包名重复,修改 package.json 中的 name 字段;若提示版本重复,修改 version 字段(如从 1.0.0 改为 1.0.1)。
  4. 项目中安装 NPM 插件: 在任意 Vue CLI 3+ 项目根目录,执行以下命令即可安装(全网开发者均可使用):

    # 方式1:npm 命令(推荐)
    npm install vue-cli-plugin-vue-mobile-preview --save-dev
    
    # 方式2:Vue CLI 专属快捷命令(自动识别插件,无需加 --save-dev)
    vue add vue-mobile-preview
    
    # pnpm/yarn 命令兼容
    # pnpm add vue-cli-plugin-vue-mobile-preview -D
    # yarn add vue-cli-plugin-vue-mobile-preview --dev
    
  5. 验证移动端预览功能: 执行 npm run serve 启动项目,手机扫码即可预览本地 Vue 项目。

场景4:Git 仓库使用(团队协作,无需发布 NPM,支持版本控制)

适用于插件托管在 Git 仓库(GitHub/Gitee/GitLab),团队成员可直接通过 Git 地址安装,支持版本控制和代码同步。

操作步骤

  1. 插件目录初始化 Git 仓库: 进入插件目录,执行以下命令初始化并提交代码:

    # 进入插件目录
    cd vue-cli-plugin-vue-mobile-preview
    
    # 初始化 Git 仓库
    git init
    
    # 添加所有文件
    git add .
    
    # 提交代码
    git commit -m "init: 完成移动端预览插件开发,支持扫码访问"
    
  2. 推送插件到线上 Git 仓库: 在 GitHub/Gitee/GitLab 上创建一个新仓库,然后将本地插件代码推送上去(以 GitHub 为例):

    # 关联线上 Git 仓库(替换为你的仓库地址)
    git remote add origin https://github.com/你的用户名/vue-cli-plugin-vue-mobile-preview.git
    
    # 推送代码到 master/main 分支
    git push -u origin master
    
  3. 项目中通过 Git 地址安装插件: 进入目标 Vue 项目根目录,执行以下命令安装(支持 HTTPS/SSH 两种地址格式):

    # 方式1:HTTPS 地址(无需配置密钥,小白推荐使用,公开/私有仓库均可)
    npm install https://github.com/你的用户名/vue-cli-plugin-vue-mobile-preview.git --save-dev
    
    # 方式2:SSH 地址(需配置 Git 密钥,推荐团队私有仓库使用)
    npm install git+ssh://git@github.com/你的用户名/vue-cli-plugin-vue-mobile-preview.git --save-dev
    
    # 方式3:GitHub 专属简化格式(自动识别)
    npm install 你的用户名/vue-cli-plugin-vue-mobile-preview --save-dev
    
    # pnpm/yarn 命令兼容
    
  4. 验证移动端预览功能: 执行 npm run serve 启动项目,手机扫码即可预览本地 Vue 项目,同时可通过 Git 仓库实现插件版本更新和团队同步。

五、插件开发细节优化(解决二维码输出时机问题)

这是插件开发过程中的常见问题,不影响核心的移动端预览功能,但可优化使用体验,小白可直接复用代码,无需深入理解。

1. 问题描述

若使用 devServer.after 钩子输出二维码,会出现「二维码输出后还有 Webpack 编译日志」的问题,导致日志混乱;若提前输出,会被构建日志覆盖,影响扫码体验。

2. 解决方案

使用 compiler.hooks.done 事件,在 Webpack 编译(首次构建)完成后输出二维码,同时添加 hasPrintQrcode 标记,避免热更新时重复输出,代码已集成在插件核心 index.js 中,无需额外修改。

3. 优化效果

  • 二维码在项目完全就绪后输出,日志整洁美观,无多余信息干扰;
  • 仅输出一次二维码,避免热更新时重复打印,提升使用体验。

六、常见问题排查(小白避坑指南)

1. 移动端扫码无法访问本地项目

  • 排查1:手机与电脑是否连接同一 WiFi(同一局域网);
  • 排查2:电脑防火墙是否关闭(防火墙可能拦截手机的访问请求);
  • 排查3:项目端口是否被占用(更换端口后重新启动项目,如在 vue.config.js 中配置 devServer.port: 8081);
  • 排查4:插件是否自动配置 host: 0.0.0.0(本文插件已自动配置,无需手动修改)。

2. 插件安装后无二维码输出

  • 排查1:插件名称是否以 vue-cli-plugin- 开头(Vue CLI 仅自动识别该前缀);
  • 排查2:是否重启项目(插件安装后需重启项目才能生效);
  • 排查3:目标项目 package.json 中是否存在插件依赖(确认安装成功);
  • 排查4:是否安装 qrcode 依赖(插件已声明依赖,若缺失可手动执行 npm install qrcode --save-dev)。

3. 二维码重复输出(热更新时)

  • 排查:是否添加 hasPrintQrcode 标记(本文插件已添加,无需额外修改)。

4. TGZ/Git 插件安装失败

  • 排查1:地址是否正确(TGZ 地址需指向 .tgz 文件,Git 地址需带 .git 后缀);
  • 排查2:网络是否通畅(私有服务器/Git 仓库是否可正常访问);
  • 排查3:包管理器版本是否兼容(建议使用 npm 8+ / pnpm 6+ / yarn 1+)。

七、总结

  1. 核心目标达成:本文围绕「移动端便捷预览本地 Vue 服务项目」这一核心需求,开发了一款 Vue CLI 插件,实现了「自动生成二维码 + 扫码快速访问」的核心功能,解决了手动输入 IP + 端口的繁琐问题;
  2. 多场景全覆盖:详细讲解了插件的 4 种使用场景(本地调试、TGZ 压缩包、NPM 公开包、Git 仓库),满足个人开发、团队协作、全网复用等不同需求;
  3. 小白友好:所有代码可直接复制,步骤详细无遗漏,无需复杂配置,同时提供常见问题排查指南,降低上手门槛;
  4. 细节优化到位:附带解决插件开发中「二维码输出时机」的问题,优化使用体验,确保日志整洁、无重复输出;
  5. 兼容性强:支持 Vue 2/Vue 3 项目,适配 Vue CLI 3+ 所有版本,无侵入性,卸载后无残留。

通过本文,小白也能轻松开发并使用 Vue CLI 移动端预览插件,实现多场景下的移动端便捷预览,提升 Vue 项目开发效率!

react hook注意事项

写代码出错了,很少深入了解 hook。让ai给总结了一下。

这确实是 React 中非常核心且容易被忽视的“铁律”。React 的很多设计理念与传统的命令式编程(如 jQuery)或基于类的编程(Vue Options API)不太一样,因此有很多类似的“潜规则”和陷阱。

以下为您整理了 React 开发中必须知道的 6 个核心注意事项,掌握这些可以帮您避开 90% 的 Bug:

1. useEffect 依赖数组不能“撒谎” (Stale Closure / 闭包陷阱)

这是 Hook 最容易出 Bug 的地方。如果你在 useEffectuseCallback 内部使用了某个变量,但没有把它加到依赖数组 [] 中,Hook 内部就会一直引用旧的变量值

  • ❌ 错误写法:

    const [count, setCount] = useState(0);
    
    useEffect(() => {
      const timer = setInterval(() => {
        console.log(count); // 永远打印 0!因为这里的 count 是闭包捕获的初始值
      }, 1000);
      return () => clearInterval(timer);
    }, []); // 依赖数组是空的,导致 effect 只运行一次,内部永远拿着旧的 count
    
  • ✅ 正确写法:

    useEffect(() => {
      const timer = setInterval(() => {
        console.log(count);
      }, 1000);
      return () => clearInterval(timer);
    }, [count]); // 依赖 count,每次 count 变化,effect 会销毁旧的并重新运行
    

    或者使用函数式更新(如果不依赖外部变量触发 Effect):

    setCount(prevCount => prevCount + 1); // 这种写法不需要依赖 count
    

2. State 更新是“异步”的(快照特性)

调用 setState 后,状态不会立即改变。React 会把更新放入队列,等到下一次渲染时才会生效。

  • ❌ 常见误区:

    const handleClick = () => {
      setCount(count + 1);
      console.log(count); // 打印的仍然是旧值!不是 +1 后的值
    
      // 如果此时发请求,发出去的也是旧值
      fetchData(count); 
    };
    
  • ✅ 正确理解:

    如果你需要使用更新后的值,应该使用 useEffect 监听该值的变化,或者在 setState 中使用回调函数(仅用于计算新值)。

    useEffect(() => {
      console.log(count); // 这里才能拿到更新后的值
      fetchData(count);
    }, [count]);
    

3. 永远不要直接修改 State (Immutability / 不可变性)

React 比较状态是否变化是基于引用比较(Shallow Compare)。如果你直接修改对象属性,引用地址没变,React 就不知道数据变了,页面就不会刷新。

  • ❌ 错误写法:

    const [user, setUser] = useState({ name: 'Alice', age: 18 });
    
    const updateName = () => {
      user.name = 'Bob'; // 修改了内容,但对象引用没变
      setUser(user);     // React 认为前后是同一个对象,不触发重新渲染
    };
    
  • ✅ 正确写法:

    const updateName = () => {
      // 创建一个新对象(复制旧属性 + 覆盖新属性)
      setUser({ ...user, name: 'Bob' }); 
    };
    

4. 列表渲染必须有唯一的 key(且尽量不要用 index)

key 是 React 识别 DOM 节点的身份证。如果使用数组索引(index)作为 key,当列表顺序发生变化(插入、删除、排序)时,React 会复用错误的 DOM 节点,导致状态错乱。

  • ❌ 风险写法:

    // 如果列表顺序会变,千万别用 index
    {items.map((item, index) => <li key={index}>{item.name}</li>)}
    
  • ✅ 正确写法:

    // 使用数据中的唯一 ID
    {items.map((item) => <li key={item.id}>{item.name}</li>)}
    

5. useEffect 的清理函数 (Cleanup Function)

如果在 useEffect 中订阅了事件、设置了定时器或发起了请求,必须返回一个清理函数,否则会导致内存泄漏或逻辑错误(比如组件卸载了但定时器还在跑)。

  • ✅ 标准模板:

    useEffect(() => {
      // 1. 设置副作用
      const handleResize = () => console.log(window.innerWidth);
      window.addEventListener('resize', handleResize);
    
      // 2. 返回清理函数
      return () => {
        window.removeEventListener('resize', handleResize);
      };
    }, []);
    

6. React Strict Mode 下的“重复执行”

在开发环境(Development)中,为了帮你检查不纯的副作用,React 的 <StrictMode> 会故意将你的组件渲染两次useEffect 也会执行两次。

  • 现象:你看控制台日志,发现 API 请求发了两次,或者 console.log 打印了两次。
  • 注意:这不是 Bug,生产环境(Production)不会发生。不要为了解决这个问题去写奇怪的代码,而应该确保你的 Effect 是可以重复执行且无害的(做好清理工作)。

总结建议

您刚遇到的 “Hook 顺序与数量必须一致” 是 React 运行机制的物理限制(React 内部是靠数组索引来记录每个 Hook 的状态的,如果顺序乱了,状态就张冠李戴了)。

而上面提到的这 6 点,更多是逻辑和模式上的注意事项。掌握了这些,您写 React 就会非常有底气了。

JavaScript 闭包实战:手写防抖与节流函数,优化高频事件性能

在前端开发中,我们经常会遇到高频触发的事件,比如:

  • 输入框 keyup 时的搜索建议(类似百度、VS Code 的智能补全)
  • 窗口 resize 时的布局重新计算
  • 页面滚动时的懒加载或返回顶部按钮显示
  • 鼠标 mousemove 时的拖拽预览

这些事件往往在短时间内被触发数百甚至上千次,如果每次都直接执行复杂的逻辑(如发 AJAX 请求、操作 DOM、计算布局),会严重消耗浏览器资源,导致页面卡顿、掉帧,甚至崩溃。

解决这类问题的核心方案就是函数防抖(debounce)函数节流(throttle) ,而它们的实现都离不开 JavaScript 的灵魂特性——闭包

本文将从实际场景出发,详细讲解防抖和节流的原理、区别、手动实现,并提供完整可运行的 HTML 示例,帮助你彻底掌握这一前端性能优化的必备技能。

什么是闭包?为什么能用于防抖节流?

闭包是指函数能够“记住”并访问其词法作用域中的变量,即使函数在外部作用域之外执行。

在防抖和节流中,我们需要:

  • 保存定时器 ID(用于清除或判断时间)
  • 记住上一次执行的时间戳
  • 保留正确的 this 指向和参数

这些变量必须在多次事件触发间“存活”下来,而不能每次都重新创建——这正是闭包的用武之地。

场景一:搜索输入框的 AJAX 请求优化

用户在搜索框输入关键词时,我们希望实时显示搜索建议(如百度输入“react”时下方出现的建议列表)。

如果不做任何处理,每次 keyup 都立即发送请求:

  • 用户输入“react”五个字符 → 触发 5 次请求
  • 网络开销大、服务器压力大
  • 用户体验差(快速输入时建议闪烁)

理想效果是:用户停止输入 500ms 后,才发送一次请求。

这正是防抖的典型应用场景。

函数防抖(debounce)原理与实现

防抖的核心思想:不管事件触发多少次,我只关心最后一次。在最后一次触发后的 delay 时间内如果没有新触发,才真正执行函数。

JavaScript

function debounce(fn, delay) {
  let timer = null; // 闭包中保存定时器 ID

  return function(...args) {
    const context = this;

    // 每次触发时,先清除上一次的定时器
    if (timer) {
      clearTimeout(timer);
    }

    // 重新设置定时器
    timer = setTimeout(() => {
      fn.apply(context, args);
      timer = null; // 执行完可可选清理
    }, delay);
  };
}

关键点解析:

  • timer 变量定义在 debounce 函数作用域中,被返回的函数“记住”(闭包)。
  • 每次事件触发都清除旧定时器,重新开始倒计时。
  • 只有在 delay 时间内没有新触发时,定时器才会执行 fn。
  • 使用 apply 保留正确的 this 和参数。

函数节流(throttle)原理与实现

节流的核心思想:在规定时间内,无论触发多少次,只执行一次。常用于限制执行频率。

典型场景:页面滚动时加载更多内容(scroll 事件),我们希望每 500ms 最多检查一次是否到达底部。

JavaScript

function throttle(fn, delay) {
  let last = 0; // 闭包中记录上次执行时间

  return function(...args) {
    const context = this;
    const now = Date.now();

    // 如果距离上次执行不足 delay,则不执行
    if (now - last < delay) {
      return;
    }

    // 执行并更新 last
    last = now;
    fn.apply(context, args);
  };
}

更常见的时间戳 + 定时器混合版(支持尾部执行):

JavaScript

function throttle(fn, delay) {
  let last = 0;
  let timer = null;

  return function(...args) {
    const context = this;
    const now = Date.now();

    // 如果还在冷却期,且没有定时器(避免重复设置)
    if (now - last < delay) {
      clearTimeout(timer);
      timer = setTimeout(() => {
        last = now;
        fn.apply(context, args);
      }, delay);
    } else {
      // 立即执行(领先执行)
      last = now;
      fn.apply(context, args);
    }
  };
}

这种实现兼顾了“固定频率执行”和“停止触发后仍能执行最后一次”。

防抖 vs 节流:如何选择?

特性 防抖 (debounce) 节流 (throttle)
执行时机 事件停止触发后 delay 时间执行一次 每隔 delay 时间执行一次
典型场景 搜索输入、表单提交验证 滚动加载、鼠标跟随、射击游戏射速
用户体验 等待用户“想好了”再响应 持续操作时保持流畅响应
实现复杂度 相对简单(setTimeout) 稍复杂(时间戳或定时器混合)

记忆口诀:

  • 需要“最后一次”执行 → 用防抖(如搜索)
  • 需要“持续但限频”执行 → 用节流(如滚动)

完整可运行示例

下面是一个完整的 HTML 文件,包含三个输入框:

  • 第一个:无优化,每次 keyup 都发请求
  • 第二个:防抖优化,停止输入 500ms 后发一次请求
  • 第三个:节流优化,每 500ms 最多发一次请求

HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>防抖与节流演示</title>
  <style>
    body { font-family: Arial, sans-serif; padding: 20px; }
    input { display: block; margin: 20px 0; padding: 10px; width: 300px; font-size: 16px; }
    label { font-weight: bold; }
  </style>
</head>
<body>
  <div>
    <label>无优化(每次输入都请求)</label>
    <input type="text" id="undebounce" placeholder="快速输入观察控制台" />

    <label>防抖(停止输入500ms后请求)</label>
    <input type="text" id="debounce" placeholder="输入完成后才会请求" />

    <label>节流(每500ms最多请求一次)</label>
    <input type="text" id="throttle" placeholder="持续输入时会定期请求" />
  </div>

  <script>
    function ajax(content) {
      console.log('ajax request:', content);
    }

    // 防抖实现
    function debounce(fn, delay) {
      let timer = null;
      return function(...args) {
        const context = this;
        if (timer) clearTimeout(timer);
        timer = setTimeout(() => {
          fn.apply(context, args);
        }, delay);
      };
    }

    // 节流实现(时间戳 + 定时器混合版)
    function throttle(fn, delay) {
      let last = 0;
      let timer = null;
      return function(...args) {
        const context = this;
        const now = Date.now();
        if (now - last < delay) {
          clearTimeout(timer);
          timer = setTimeout(() => {
            last = now;
            fn.apply(context, args);
          }, delay);
        } else {
          last = now;
          fn.apply(context, args);
        }
      };
    }

    const inputA = document.getElementById('undebounce');
    const inputB = document.getElementById('debounce');
    const inputC = document.getElementById('throttle');

    const debouncedAjax = debounce(ajax, 500);
    const throttledAjax = throttle(ajax, 500);

    inputA.addEventListener('keyup', function(e) {
      ajax(e.target.value);
    });

    inputB.addEventListener('keyup', function(e) {
      debouncedAjax(e.target.value);
    });

    inputC.addEventListener('keyup', function(e) {
      throttledAjax(e.target.value);
    });
  </script>
</body>
</html>

打开浏览器控制台,分别在三个输入框快速输入,你会清晰看到三者的巨大差异。

现代框架中的应用

虽然原生 JS 需要手写,但现代框架/库已内置:

  • Lodash:_.debounce(fn, wait) 和 _.throttle(fn, wait)
  • Vue:@input.debounce="500ms"
  • React:可配合 useCallback + useRef 实现,或使用第三方如 use-debounce

但理解底层原理,能让你在复杂场景下自定义行为(如立即执行选项、取消功能等)。

最佳实践建议

  1. 搜索输入 → 防抖(节约资源,用户输入完成后响应)
  2. 滚动事件 → 节流(保持流畅)
  3. 按钮防止重复点击 → 防抖(delay 设为 1000ms)
  4. resize/scroll 计算复杂布局 → 节流
  5. 拖拽过程中实时预览 → 节流

结语

闭包是 JavaScript 最强大的特性之一,而防抖和节流则是它在性能优化领域最经典的应用体现。

通过合理使用防抖和节流,我们可以:

  • 大幅减少不必要的网络请求和计算
  • 提升页面响应速度和流畅度
  • 改善用户体验
  • 降低服务器压力

无论你是面试被问“手写防抖节流”,还是实际项目中遇到卡顿问题,这两个函数都是你工具箱中不可或缺的利器。

建议立即复制上面的完整示例到本地运行,亲身体验三者的差异——理论结合实践,你才能真正掌握。

前端性能优化,从理解闭包开始,从手写防抖节流起步。愿你的页面永远丝滑流畅!

react学习15:基于 React Router 实现 keepalive

当路由切换的时候,react router 会销毁之前路由的组件,然后渲染新的路由对应的组件。

在一些场景下,这样是有问题的。

比如移动端很多长列表,用户划了很久之后,点击某个列表项跳到详情页,之后又跳回来,但是这时候列表页的组件销毁重新创建,又回到了最上面。

比如移动端填写了某个表单,有的表单需要跳到别的页面获取数据,然后跳回来,跳回来发现组件销毁重新创建,之前填的都没了。

类似这种场景,就需要路由切换的时候不销毁组件,也就是 keepalive。

我们先复现下这个场景:

npx create-vite

选择 react + typescript 创建项目。

安装 react-router:

npm i --save react-router-dom

在 App.tsx 写下路由:

import { useState } from 'react';
import {  Link, useLocation, RouterProvider, createBrowserRouter, Outlet } from 'react-router-dom';

const Layout = () => {
    const { pathname } = useLocation();

    return (
        <div>
            <div>当前路由: {pathname}</div>
            <Outlet/>
        </div>
    )
}

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

    return <div>
      <p>{count}</p>
      <p>
        <button onClick={() => setCount(count => count + 1)}>加一</button>
      </p>
      <Link to='/bbb'>去 Bbb 页面</Link><br/>
      <Link to='/ccc'>去 Ccc 页面</Link>
    </div>
};

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

    return <div>
      <p>{count}</p>
      <p><button onClick={() => setCount(count => count + 1)}>加一</button></p>
      <Link to='/'>去首页</Link>
    </div>
};

const Ccc = () => {
    return <div>
      <p>ccc</p>
      <Link to='/'>去首页</Link>
    </div>
};

const routes = [
  {
    path: "/",
    element: <Layout></Layout>,
    children: [
      {
        path: "/",
        element: <Aaa></Aaa>,
      },
      {
        path: "/bbb",
        element: <Bbb></Bbb>
      },
      {
        path: "/ccc",
        element: <Ccc></Ccc>
      }
    ]
  }
];

export const router = createBrowserRouter(routes);

const App = () => {
    return <RouterProvider router={router}/>
}

export default App;

这里有 /、/bbb、/ccc 这三个路由。

一级路由渲染 Layout 组件,里面通过 Outlet 指定渲染二级路由的地方。

二级路由 / 渲染 Aaa 组件,/bbb 渲染 Bbb 组件,/ccc 渲染 Ccc 组件。

这里的 Outlet 组件,也可以换成 useOutlet,效果一样:

image.png

image.png

默认路由切换,对应的组件就会销毁。

我们有时候不希望切换路由时销毁页面组件,也就是希望能实现 keepalive。

怎么做呢?

其实很容易想到,我们把所有需要 keepalive 的组件保存到一个全局对象。

然后渲染的时候把它们都渲染出来,路由切换只是改变显示隐藏。

按照这个思路来写一下:

新建 KeepAliveLayout.tsx:

import React, { createContext, useContext } from 'react';
import { useOutlet, useLocation, matchPath } from 'react-router-dom'
import type { FC, PropsWithChildren, ReactNode } from 'react';

interface KeepAliveLayoutProps extends PropsWithChildren{
    keepPaths: Array<string | RegExp>;
    keepElements?: Record<string, ReactNode>;
    dropByPath?: (path: string) => void;
}

type KeepAliveContextType = Omit<Required<KeepAliveLayoutProps>, 'children'>;

const keepElements: KeepAliveContextType['keepElements'] = {};

export const KeepAliveContext = createContext<KeepAliveContextType>({
    keepPaths: [],
    keepElements,
    dropByPath(path: string) {
        keepElements[path] = null;
    }
});

const isKeepPath = (keepPaths: Array<string | RegExp>, path: string) => {
    let isKeep = false;
    for(let i = 0; i< keepPaths.length; i++) {
        let item = keepPaths[i];
        if (item === path) {
            isKeep = true;
        }
        if (item instanceof RegExp && item.test(path)) {
            isKeep = true;
        }
        if (typeof item === 'string' && item.toLowerCase() === path) {
            isKeep = true;
        }
    }
    return isKeep;
}

export function useKeepOutlet() {
    const location = useLocation();
    const element = useOutlet();

    const { keepElements, keepPaths } = useContext(KeepAliveContext);
    const isKeep = isKeepPath(keepPaths, location.pathname);

    if (isKeep) {
        keepElements![location.pathname] = element;
    }

    return <>
        {
            Object.entries(keepElements).map(([pathname, element]) => (
                <div 
                    key={pathname}
                    style={{ height: '100%', width: '100%', position: 'relative', overflow: 'hidden auto' }}
                    className="keep-alive-page"
                    hidden={!matchPath(location.pathname, pathname)}
                >
                    {element}
                </div>
            ))
        }
        {!isKeep && element}
    </>
}

const KeepAliveLayout: FC<KeepAliveLayoutProps> = (props) => {
    const { keepPaths, ...other } = props;

    const { keepElements, dropByPath } = useContext(KeepAliveContext);

    return (
        <KeepAliveContext.Provider value={{ keepPaths, keepElements, dropByPath }} {...other} />
    )
}

export default KeepAliveLayout;

ts 相关知识点

PropsWithChildren

在 React 中,PropsWithChildren 是 TypeScript 提供的一个工具类型,用于为组件的 props 类型添加 children 属性的类型定义。

当你定义组件的 props 类型时,如果组件需要接收 children(子元素),可以使用 PropsWithChildren 来自动包含 children 的类型,无需手动声明。

它的本质是一个泛型类型,定义如下(简化版):

type PropsWithChildren<P = {}> = P & { children?: React.ReactNode };
import { type PropsWithChildren } from 'react';

// 自定义 props 类型
type CardProps = {
  title: string;
  className?: string;
};

// 使用 PropsWithChildren 包含 children
const Card = ({ title, className, children }: PropsWithChildren<CardProps>) => {
  return (
    <div className={className}>
      <h2>{title}</h2>
      <div>{children}</div>
    </div>
  );
};

// 使用组件
const App = () => {
  return (
    <Card title="卡片标题" className="card">
      <p>这是卡片内容</p>
    </Card>
  );
};

Record、 Require、 Omit

  • Record 是创建一个 key value 的对象类型:
 keepElements?: Record<string, ReactNode>;
  • Requried 是去掉可选 -?

  • Omit 是删掉其中的部分属性:

type KeepAliveContextType = Omit<Required<KeepAliveLayoutProps>, 'children'>;

如果要知道某个属性的类型呢? 如下代码:

const keepElements: KeepAliveContextType['keepElements'] = {};

KeepAliveContextType['keepElements'] 就返回了 keepElements 属性的类型。

是不是感觉ts跟编程一样。

继续往下看:

const KeepAliveLayout: FC<KeepAliveLayoutProps> = (props) => {
    const { keepPaths, ...other } = props;

    const { keepElements, dropByPath } = useContext(KeepAliveContext);

    return (
        <KeepAliveContext.Provider value={{ keepPaths, keepElements, dropByPath }} {...other} />
    )
}

export default KeepAliveLayout;

首先从父组件中传入props,其中包括定义的 keepPaths, 然后从useContext 取出其他值,然后通过KeepAliveContext.Provider 的value 进行设置,这样子组件就能获取到这些值。

然后暴露一个 useKeepOutlet 的 hook:

export function useKeepOutlet() {
    const location = useLocation();
    const element = useOutlet();

    const { keepElements, keepPaths } = useContext(KeepAliveContext);
    const isKeep = isKeepPath(keepPaths, location.pathname);

    if (isKeep) {
        keepElements![location.pathname] = element;
    }

    return <>
        {
            Object.entries(keepElements).map(([pathname, element]) => (
                <div 
                    key={pathname}
                    style={{ height: '100%', width: '100%', position: 'relative', overflow: 'hidden auto' }}
                    className="keep-alive-page"
                    hidden={!matchPath(location.pathname, pathname)}
                >
                    {element}
                </div>
            ))
        }
        {!isKeep && element}
    </>
}

用 useLocation 拿到当前路由,用 useOutlet 拿到对应的组件。

判断下当前路由是否在需要 keepalive 的路由内,是的话就保存到 keepElements。

然后渲染所有的 keepElements,如果不匹配 matchPath 就隐藏。

并且如果当前路由不在 keepPaths 内,就直接渲染对应的组件: {!isKeep && element} 。

其实原理比较容易看懂:在 context 中保存所有需要 keepalive 的组件,全部渲染出来,通过路由是否匹配来切换对应组件的显示隐藏。

在 App.tsx 里引入测试下:

在外面包一层 KeepAliveLayout 组件:

const App = () => {
    return (
    <KeepAliveLayout keepPaths={['/bbb', '/']}>
      <RouterProvider router={router}/>
    </KeepAliveLayout>
    )
}

<RouterProvider router={router}/>会作为children传递到KeepAliveLayout组件中。

然后把 useOutlet 换成 useKeepOutlet:

const Layout = () => {
    const { pathname } = useLocation();

    const element = useKeepOutlet()

    return (
        <div>
            <div>当前路由: {pathname}</div>
            { element }
            {/* <Outlet/> */}
        </div>
    )
}

总结

路由切换会销毁对应的组件,但很多场景我们希望路由切换组件不销毁,也就是 keepalive。

react router 并没有实现这个功能,需要我们自己做。

我们在 context 中保存所有需要 keepalive 的组件,然后渲染的时候全部渲染出来,通过路由是否匹配来切换显示隐藏。

这样实现了 keepalive。

这个功能是依赖 React Router 的 useLocation、useOutlet、matchPath 等 api 实现的,和路由功能密不可分。

❌