普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月13日技术

从“死了么”到“活着记”:用Gmeek在数字世界留下思想印记

作者 修己xj
2026年1月13日 20:47

本文从近期热议的“死了么”App入手,探讨现代人对数字安全与思想存续的双重需求,详细介绍基于GitHub的极简博客框架Gmeek,阐述在数字时代通过博客记录思想、对抗遗忘的重要意义,鼓励读者建立个人数字思想家园。

github_gmeek.png

github_gmeek.png

引言

一款名为“死了么”的App近期引发广泛讨论。它以直白甚至略显生硬的名字,精准地切中了当代社会一个真实且规模庞大的群体需求。

可以说,“死了么”的火爆,是产品创意、社会情绪与网络传播共同作用的结果。它如同一面棱镜,折射出当代独居生活的潜在隐忧。

如今,独居者人数众多。他们往往独自奋斗,习惯“一个人扛下所有”,最担心的莫过于在突发疾病或意外时无人知晓。这款App之所以能够走红,恰恰在于它敏锐地捕捉到了现代人一种微妙而普遍的心理——“不愿日常打扰他人,却渴望在异常状况下被关注”。用户无需复杂的社交,仅通过简单的每日打卡,就能为自己构筑一道最低成本的“安全防线”。其付费下载量的快速增长,也印证了这一需求真实而强烈。

然而,人终有一死。那么,我们该如何留下生活过的痕迹?如何在日常中安顿内心、与自己对话?活着,不仅仅意味着没有死去,而是要有思想、有记录、有回响地活着。博客,作为一种数字化的表达方式,正成为越来越多人记录自我、分享见解、沉淀思想的平台。

最近读到一篇文章,颇受启发。People Die, but Long Live GitHub

people-die-but-long-live-github.png

people-die-but-long-live-github.png

最近,我还在 GitHub 上发现了一个开源项目——Gmeek。它是一个超轻量级的个人博客框架,完全基于 GitHub Pages、GitHub Issues 与 GitHub Actions 构建,可谓“All in GitHub”。无需本地部署,从搭建到写作,整个过程只需三步:前两步用 18 秒完成博客搭建,第三步即可开始书写。今天我们就来介绍下如何使用这款开源项目构建个人github博客。

Gmeek:三步构建你的数字思想家园

一个博客框架,超轻量级个人博客模板。完全基于Github PagesGithub IssuesGithub Actions。不需要本地部署,从搭建到写作,只需要18秒,2步搭建好博客,第3步就是写作。

github地址:github.com/Meekdai/Gme…

文档博客:blog.meekdai.com/tag.html#Gm…

gmeek_star.png

gmeek_star.png

gmeek_doc_blog.png

gmeek_doc_blog.png

快速开始

  1. 【创建仓库】点击通过模板创建仓库,建议仓库名称为XXX.github.io,其中XXX为你的github用户名。

xiuji_github_blog.png

xiuji_github_blog.png

  1. 【启用Pages】在仓库的SettingsPages->Build and deployment->Source下面选择Github Actions
  2. 【开始写作】打开一篇issue,开始写作,并且必须添加一个标签Label(至少添加一个),再保存issue后会自动创建博客内容,片刻后可通过XXX.github.io 访问(可进入Actions页面查看构建进度)。
  3. 【手动全局生成】这个步骤只有在修改config.json文件或者出现奇怪问题的时候,需要执行。
通过Actions->build Gmeek->Run workflow->里面的按钮全局重新生成一次

[!NOTE] issue必须添加一个标签Label(至少添加一个)

到此,提交完issue之后Actions页面构建完成之后就可以看到我们的博客了。

博主的github博客地址:xiuji008.github.io/

配置及使用

config.json 文件就是配置文件,在创建的仓库内可以找到,对应修改为自己的即可。

配置可参考Gmeek作者博文:blog.meekdai.com/post/Gmeek-…

static文件夹使用

  1. 在自己的仓库根目录下新建一个文件夹,名称必须是static。
  2. 然后在static文件内上传一些自己的文件,比如博客图片、插件js等。
  3. 通过手动全局生成一次成功后,你就可以通过 xxx.github.io/your.png 访问了

插件功能的使用

为了使得Gmeek的功能更加的丰富,Gmeek作者添加了插件的功能,目前已经有几个插件可以使用。大家可以直接复制文章中的配置代码使用,也可以把对应的插件文件拷贝到自己的static文件夹下使用。

计数工具 Vercount

  1. 全站添加计数工具Vercount,只需要在config.json文件内添加配置
"allHead":"<script src='https://blog.meekdai.com/Gmeek/plugins/GmeekVercount.js'></script>",

2. 单个文章页添加Vercount,只需要在文章最后一行添加如下

<!-- ##{"script":"<script src='https://blog.meekdai.com/Gmeek/plugins/GmeekVercount.js'></script>"}## -->

gmeek_plugin_vercount.png

gmeek_plugin_vercount.png

TOC目录

  1. 所有文章页添加TOC目录,只需要在config.json文件内添加配置
"script":"<script src='https://blog.meekdai.com/Gmeek/plugins/GmeekTOC.js'></script>",

2. 单个文章页添加TOC目录,只需要在文章最后一行添加如下

<!-- ##{"script":"<script src='https://blog.meekdai.com/Gmeek/plugins/GmeekTOC.js'></script>"}## -->

gmeek_plugins_toc.png

gmeek_plugins_toc.png

灯箱插件

[!TIP] 此插件由Tiengming编写,可以放大浏览文章中的图片,适合一些图片较多的文章。

  1. 所有文章页添加lightbox,只需要在config.json文件内添加配置
"script":"<script src='https://blog.meekdai.com/Gmeek/plugins/lightbox.js'></script>",

2. 单个文章页添加lightbox,只需要在文章最后一行添加如下

<!-- ##{"script":"<script src='https://blog.meekdai.com/Gmeek/plugins/lightbox.js'></script>"}## -->

看板娘(花里胡哨)

[!TIP] 此插件从github开源项目live2d-widget引入,纯属页面展示

  1. 所有文章页添加lightbox,只需要在config.json文件内添加配置
"script":"<script src='https://fastly.jsdelivr.net/npm/live2d-widgets@1.0.0/dist/autoload.js'></script>",

gmeek_plugins_live2d-widget.png

gmeek_plugins_live2d-widget.png

对看板娘项目感兴趣的伙伴也可以研究下

看板娘项目github地址:github.com/stevenjoezh…

github_live2d_widget.png

github_live2d_widget.png

多插件使用

同时在所有文章页使用TOC目录、灯箱插件及其它插件,需要这样添加配置文件:

    "allHead":"<script src='https://xiuji008.github.io/plugins/gmeekVercount.js'></script><script src='https://xiuji008.github.io/plugins/lightbox.js'></script><script src='https://xiuji008.github.io/plugins/gmeekTOC.js'></script><script src='https://fastly.jsdelivr.net/npm/live2d-widgets@1.0.0/dist/autoload.js'></script>",

其它使用说明

issue添加中文标签

  1. 点击 issue页签, 点击右侧 Labels 后边的设置按钮,点击Edit labels

issues_labels_setting.png

issues_labels_setting.png

  1. Labels 页面则可以新增或修改标签

issues_labels_edit.png

issues_labels_edit.png

置顶博客文章

只需要Pin issue后,手动全局生成一次即可。

issues_pin.png

issues_pin.png

评论 utteranc报错

如果在评论里面登录后评论报错,可直接按照提示安装utteranc app即可

Error: utterances is not installed on xxx/xxx.github.io. If you own this repo, install the app. Read more about this change in the PR.

删除文章

只需要Close issue或者Delete issue后,再手动全局生成一次即可。

结语:在数字时代留下有温度的痕迹

“死了么”关注的是物理存在的安全,而Gmeek这样的工具关注的是思想存在的延续。两者看似无关,实则都回应了现代人对存在感的深层渴望。

在这个算法主导、注意力碎片化的时代,拥有一个属于自己的数字角落,定期记录、整理、输出,不仅是对抗遗忘的方式,更是一种积极的生活态度——主动塑造自己的数字身份,而非被动地被平台定义。

从担心“无人知晓的离去”到主动“留下有思想的痕迹”,或许正是数字时代给予我们的一种平衡:既通过工具获得安全感,也通过表达实现自我确认。

你的思想值得被记录,你的声音值得被听见。现在,只需18秒,就可以开始在GitHub上建造你的数字思想家园。

HarmonyOS 多模块项目中的公共库治理与最佳实践

作者 90后晨仔
2026年1月13日 20:43

鸿蒙(HarmonyOS)多模块项目 中,如果你希望 避免在每个模块(Module)中重复集成同一个三方库或公共库,可以将该库 提升到项目级别(Project-level)进行统一管理。以下是标准做法,适用于 Stage 模型 + ArkTS + DevEco Studio 的工程结构。


✅ 目标

将公共库(如 @ohos/utils、自研工具库、第三方 npm 包等)只声明一次,供多个模块(entry、feature、service 等)共享使用


📁 鸿蒙项目结构回顾

MyHarmonyProject/
├── build-profile.json5        ← 项目级构建配置
├── oh-package.json5           ← 项目级依赖(关键!)
├── modules/
│   ├── entry/                 ← 主模块
│   ├── feature_news/          ← 功能模块1
│   └── feature_ebook/         ← 功能模块2
└── libs/                      ← (可选)本地 aar/har 公共库

✅ 正确做法:在 项目根目录的 oh-package.json5 中声明依赖

步骤 1:在项目根目录的 oh-package.json5 中添加依赖

{
  "devDependencies": {
    // 开发依赖(如 types)
  },
  "dependencies": {
    // 👇 把公共库放在这里(项目级)
    "@ohos/utils": "1.0.0",
    "some-third-party-lib": "^2.3.0"
  }
}

✅ 这样,所有子模块都可以继承使用这些依赖,无需在每个 module/xxx/oh-package.json5 中重复声明。


步骤 2:删除各子模块中的重复依赖

确保 modules/entry/oh-package.json5modules/feature_news/oh-package.json5不再包含 已提升到项目级的依赖。

例如,不要entry/oh-package.json5 中再写:

{
  "dependencies": {
    "@ohos/utils": "1.0.0"  // ❌ 删除这行!
  }
}

步骤 3:在子模块代码中正常 import 使用

// 在 entry 或 feature_news 模块中
import { ZGJYBAppearanceColorUtil } from '@ohos/utils';

// ✅ 可以正常使用,因为依赖已由项目级提供

⚠️ 注意事项

1. 仅适用于 npm 类型的包(通过 ohpm 安装)

  • 如果你是通过 ohpm install @ohos/utils 安装的库,它会被记录在 oh-package.json5
  • 这种方式支持 依赖提升(hoisting) ,类似 npm/yarn 的 workspace。

2. 本地 .har.hap 库不能这样共享

  • 如果你的“库”是一个 本地开发的 .har(HarmonyOS Archive)模块,则需要:

    • 将其放在 libs/ 目录下;
    • 每个需要使用的模块module.json5 中声明 deps 引用;
    • 或者将其发布为私有 ohpm 包,再通过 oh-package.json5 引入。

示例:引用本地 har(仍需逐模块配置)

// modules/entry/module.json5
{
  "deps": [
    "../libs/my-common-utils.har"
  ]
}

❌ 这种情况无法完全避免重复声明,但你可以通过脚本或模板减少工作量。


3. 确保 DevEco Studio 同步了依赖

  • 修改 oh-package.json5 后,点击 “Sync Now” 或运行:

    ohpm install
    

    在项目根目录执行,会安装所有模块共享的依赖。


✅ 最佳实践总结

场景 推荐方案
公共 npm/ohpm 库(如 @ohos/utils ✅ 在 项目根目录 oh-package.json5 中声明一次
自研公共逻辑(TS 工具函数) ✅ 创建一个 shared 模块,发布为 ohpm 私有包,再在项目级引入
本地 .har ⚠️ 需在每个模块的 module.json5 中引用,但可统一放在 libs/ 目录管理
避免重复代码 ✅ 抽象公共组件/工具到独立模块,通过依赖注入使用

🔧 附加建议:创建 shared 模块(高级)

  1. 新建模块:File > New > Module > Static Library (HAR)

    • 命名为 shared
  2. 在其中放置公共工具类、常量、网络封装等

  3. shared/oh-package.json5 中定义包名:

    { "name": "@myorg/shared", "version": "1.0.0" }
    
  4. 在项目根目录运行:

    ohpm install ./modules/shared --save
    
  5. 然后在 oh-package.json5 中就会出现:

    "dependencies": {
      "@myorg/shared": "file:./modules/shared"
    }
    
  6. 所有模块即可通过 import { xxx } from '@myorg/shared' 使用。

✅ 这是最接近“项目级公共库”的鸿蒙官方推荐方案。


✅ 结论

把公共库写在项目根目录的 oh-package.json5dependencies 中,即可实现“一次集成,多模块共享”

只要你的库是通过 ohpm 管理的(包括本地 file: 引用),就支持这种共享机制。这是 HarmonyOS 多模块项目的标准依赖管理方式。

Vue插槽

作者 yyt_
2026年1月13日 20:15

一、先明确核心概念

  1. 具名插槽:给 <slot> 标签添加 name 属性,用于区分不同位置的插槽,让父组件可以精准地将内容插入到子组件的指定位置,解决「默认插槽只能插入一处内容」的问题。
  2. 默认插槽:没有 name 属性的 <slot>,是具名插槽的特殊形式(默认名称为 default),父组件中未指定插槽名称的内容,会默认插入到这里。
  3. 插槽默认内容:在子组件的 <slot> 标签内部写入内容,当父组件未给该插槽传递任何内容时,会显示这份默认内容;若父组件传递了内容,会覆盖默认内容,提升组件的复用性和容错性。
  4. 作用域插槽:子组件通过「属性绑定」的方式给 <slot> 传递内部私有数据,父组件在使用插槽时可以接收这些数据并自定义渲染,解决「父组件无法访问子组件内部数据」的问题,实现「子组件供数、父组件定制渲染」。

二、分步实例演示

第一步:实现最基础的「具名插槽 + 默认插槽」

核心需求:创建一个通用的「页面容器组件」,包含「页头」「页面内容」「页脚」三个部分,其中「页面内容」用默认插槽,「页头」「页脚」用具名插槽。

1. 子组件:定义插槽(文件名:PageContainer.vue

<template>
  <!-- 通用页面容器样式(简单美化,方便查看效果) -->
  <div class="page-container" style="border: 1px solid #e0e0e0; border-radius: 8px; padding: 20px; margin: 20px 0;">
    <!-- 具名插槽:页头(name="header") -->
    <div class="page-header" style="border-bottom: 1px dashed #e0e0e0; padding-bottom: 10px; margin-bottom: 10px;">
      <slot name="header" />
    </div>

    <!-- 默认插槽:页面核心内容(无name属性,对应default) -->
    <div class="page-content" style="margin: 20px 0; min-height: 100px;">
      <slot />
    </div>

    <!-- 具名插槽:页脚(name="footer") -->
    <div class="page-footer" style="border-top: 1px dashed #e0e0e0; padding-top: 10px; margin-top: 10px; text-align: right;">
      <slot name="footer" />
    </div>
  </div>
</template>

<script setup>
// 子组件无需额外逻辑,仅定义插槽结构即可
</script>

2. 父组件:使用插槽(传递内容,文件名:App.vue

父组件通过 v-slot:插槽名(简写:#插槽名)指定内容要插入的具名插槽,未指定的内容默认插入到默认插槽。

<template>
  <h2>基础具名插槽 + 默认插槽演示</h2>

  <!-- 使用子组件 PageContainer -->
  <PageContainer>
    <!-- 给具名插槽 header 传递内容(简写 #header,完整写法 v-slot:header) -->
    <template #header>
      <h3>这是文章详情页的页头</h3>
      <nav>首页 > 文章 > Vue 插槽教程</nav>
    </template>

    <!-- 未指定插槽名,默认插入到子组件的默认插槽 -->
    <div>
      <p>1. 具名插槽可以让父组件精准控制内容插入位置。</p>
      <p>2. 默认插槽用于承载组件的核心内容,使用更简洁。</p>
      <p>3. 这部分内容会显示在页头和页脚之间。</p>
    </div>

    <!-- 给具名插槽 footer 传递内容(简写 #footer) -->
    <template #footer>
      <span>发布时间:2026-01-13</span>
      <button style="margin-left: 20px; padding: 4px 12px;">收藏文章</button>
    </template>
  </PageContainer>
</template>

<script setup>
// 导入子组件
import PageContainer from './PageContainer.vue';
</script>

3. 运行效果与说明

  • 页头区域显示「文章详情页标题 + 面包屑导航」(对应 #header 插槽内容)。
  • 中间区域显示核心正文(对应默认插槽内容)。
  • 页脚区域显示「发布时间 + 收藏按钮」(对应 #footer 插槽内容)。
  • 关键:父组件的 <template> 标签包裹插槽内容,通过 #插槽名 绑定子组件的具名插槽,结构清晰,互不干扰。

第二步:实现「带默认内容的插槽」

核心需求:优化上面的 PageContainer.vue,给「页脚插槽」添加默认内容(默认显示「返回顶部」按钮),当父组件未给 footer 插槽传递内容时,显示默认按钮;若传递了内容,覆盖默认内容。

1. 修改子组件:给插槽添加默认内容(PageContainer.vue

仅修改 footer 插槽部分,在 <slot name="footer"> 内部写入默认内容:

<template>
  <div class="page-container" style="border: 1px solid #e0e0e0; border-radius: 8px; padding: 20px; margin: 20px 0;">
    <!-- 具名插槽:页头 -->
    <div class="page-header" style="border-bottom: 1px dashed #e0e0e0; padding-bottom: 10px; margin-bottom: 10px;">
      <slot name="header" />
    </div>

    <!-- 默认插槽:页面核心内容 -->
    <div class="page-content" style="margin: 20px 0; min-height: 100px;">
      <slot />
    </div>

    <!-- 具名插槽:页脚(带默认内容) -->
    <div class="page-footer" style="border-top: 1px dashed #e0e0e0; padding-top: 10px; margin-top: 10px; text-align: right;">
      <slot name="footer">
        <!-- 插槽默认内容:父组件未传递footer内容时,显示该按钮 -->
        <button style="padding: 4px 12px;" @click="backToTop">返回顶部</button>
      </slot>
    </div>
  </div>
</template>

<script setup>
// 定义默认内容的点击事件(返回顶部)
const backToTop = () => {
  window.scrollTo({
    top: 0,
    behavior: 'smooth' // 平滑滚动
  });
};
</script>

2. 父组件演示两种场景(App.vue

分别演示「不传递 footer 内容」和「传递 footer 内容」的效果:

<template>
  <h2>带默认内容的插槽演示</h2>

  <!-- 场景1:父组件不传递 footer 插槽内容,显示子组件的默认「返回顶部」按钮 -->
  <h4>场景1:未传递页脚内容(显示默认按钮)</h4>
  <PageContainer>
    <template #header>
      <h3>这是未传递页脚的页面</h3>
    </template>
    <p>该页面父组件没有给 footer 插槽传递内容,所以页脚会显示子组件默认的「返回顶部」按钮。</p>
  </PageContainer>

  <!-- 场景2:父组件传递 footer 插槽内容,覆盖默认按钮 -->
  <h4 style="margin-top: 40px;">场景2:传递页脚内容(覆盖默认按钮)</h4>
  <PageContainer>
    <template #header>
      <h3>这是传递了页脚的页面</h3>
    </template>
    <p>该页面父组件给 footer 插槽传递了自定义内容,会覆盖子组件的默认「返回顶部」按钮。</p>
    <template #footer>
      <span>作者:Vue 小白教程</span>
      <button style="margin-left: 20px; padding: 4px 12px;">点赞</button>
      <button style="margin-left: 10px; padding: 4px 12px;">评论</button>
    </template>
  </PageContainer>
</template>

<script setup>
import PageContainer from './PageContainer.vue';
</script>

3. 运行效果与说明

  • 场景1:页脚显示「返回顶部」按钮,点击可实现平滑滚动到页面顶部(默认内容生效)。
  • 场景2:页脚显示「作者 + 点赞 + 评论」,默认的「返回顶部」按钮被覆盖(自定义内容生效)。
  • 核心价值:插槽默认内容让组件更「健壮」,无需父组件每次都传递所有插槽内容,减少冗余代码,提升组件复用性。

第三步:实际业务场景综合应用(卡片组件)

核心需求:创建一个通用的「商品卡片组件」,使用具名插槽实现「商品图片」「商品标题」「商品价格」「操作按钮」的自定义配置,其中「操作按钮」插槽带默认内容(默认「加入购物车」按钮)。

1. 子组件:商品卡片(GoodsCard.vue

<template>
  <div class="goods-card" style="width: 280px; border: 1px solid #f0f0f0; border-radius: 12px; padding: 16px; margin: 16px; float: left; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
    <!-- 具名插槽:商品图片 -->
    <div class="goods-img" style="width: 100%; height: 180px; margin-bottom: 12px; text-align: center;">
      <slot name="image" />
    </div>

    <!-- 具名插槽:商品标题 -->
    <div class="goods-title" style="font-size: 16px; font-weight: 500; margin-bottom: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
      <slot name="title" />
    </div>

    <!-- 具名插槽:商品价格 -->
    <div class="goods-price" style="font-size: 18px; color: #ff4400; margin-bottom: 16px;">
      <slot name="price" />
    </div>

    <!-- 具名插槽:操作按钮(带默认内容) -->
    <div class="goods-actions" style="text-align: center;">
      <slot name="action">
        <!-- 默认内容:加入购物车按钮 -->
        <button style="width: 100%; padding: 8px 0; background: #ff4400; color: #fff; border: none; border-radius: 8px; cursor: pointer;">
          加入购物车
        </button>
      </slot>
    </div>
  </div>
</template>

<script setup>
// 无需额外逻辑,仅提供插槽结构和默认内容
</script>

2. 父组件:使用商品卡片组件(App.vue

自定义不同商品的内容,演示插槽的灵活性:

<template>
  <h2>实际业务场景:商品卡片组件</h2>
  <div style="overflow: hidden; clear: both;">
    <!-- 商品1:使用默认操作按钮(加入购物车) -->
    <GoodsCard>
      <template #image>
        <img src="https://picsum.photos/240/180?random=1" alt="商品图片" style="width: 240px; height: 180px; object-fit: cover; border-radius: 8px;">
      </template>
      <template #title>
        小米手机 14 旗舰智能手机
      </template>
      <template #price>
        ¥ 4999
      </template>
      <!-- 未传递 #action 插槽,显示默认「加入购物车」按钮 -->
    </GoodsCard>

    <!-- 商品2:自定义操作按钮(立即购买 + 收藏) -->
    <GoodsCard>
      <template #image>
        <img src="https://picsum.photos/240/180?random=2" alt="商品图片" style="width: 240px; height: 180px; object-fit: cover; border-radius: 8px;">
      </template>
      <template #title>
        苹果 iPad Pro 平板电脑
      </template>
      <template #price>
        ¥ 7999
      </template>
      <!-- 自定义 #action 插槽内容,覆盖默认按钮 -->
      <template #action>
        <button style="width: 48%; padding: 8px 0; background: #0071e3; color: #fff; border: none; border-radius: 8px; cursor: pointer; margin-right: 4%;">
          立即购买
        </button>
        <button style="width: 48%; padding: 8px 0; background: #f0f0f0; color: #333; border: none; border-radius: 8px; cursor: pointer;">
          收藏
        </button>
      </template>
    </GoodsCard>
  </div>
</template>

<script setup>
import GoodsCard from './GoodsCard.vue';
</script>

3. 运行效果与说明

  • 商品1:操作按钮显示默认的「加入购物车」,快速实现基础功能。
  • 商品2:操作按钮显示「立即购买 + 收藏」,满足自定义需求。
  • 业务价值:通过具名插槽,打造了「通用可复用」的商品卡片组件,父组件可以根据不同商品场景,灵活配置各个区域的内容,既减少了重复代码,又保证了灵活性。

第四步:实现「作用域插槽」

核心需求:基于现有商品卡片组件优化,让子组件持有私有商品数据,通过作用域插槽传递给父组件,父组件自定义渲染格式(如给高价商品加「高端」标识、显示商品优惠信息)。

1. 修改子组件:定义作用域插槽,传递内部数据(GoodsCard.vue

子组件新增内部私有数据,通过「属性绑定」给插槽传递数据(:数据名="子组件内部数据"):

vue

<template>
  <div class="goods-card" style="width: 280px; border: 1px solid #f0f0f0; border-radius: 12px; padding: 16px; margin: 16px; float: left; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
    <!-- 作用域插槽:商品图片(暴露商品id和图片地址) -->
    <div class="goods-img" style="width: 100%; height: 180px; margin-bottom: 12px; text-align: center;">
      <slot name="image" :goodsId="goods.id" :imgUrl="goods.imgUrl" />
    </div>

    <!-- 作用域插槽:商品标题(暴露商品名称和价格) -->
    <div class="goods-title" style="font-size: 16px; font-weight: 500; margin-bottom: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
      <slot name="title" :goodsName="goods.name" :goodsPrice="goods.price" />
    </div>

    <!-- 作用域插槽:商品价格(暴露价格和优惠信息) -->
    <div class="goods-price" style="font-size: 18px; color: #ff4400; margin-bottom: 16px;">
      <slot name="price" :price="goods.price" :discount="goods.discount" />
    </div>

    <!-- 具名插槽:操作按钮(带默认内容) -->
    <div class="goods-actions" style="text-align: center;">
      <slot name="action">
        <!-- 默认内容:加入购物车按钮 -->
        <button style="width: 100%; padding: 8px 0; background: #ff4400; color: #fff; border: none; border-radius: 8px; cursor: pointer;">
          加入购物车
        </button>
      </slot>
    </div>
  </div>
</template>

<script setup>
// 子组件内部私有数据(模拟接口返回,父组件无法直接访问)
const goods = {
  id: 1001,
  name: "小米手机 14 旗舰智能手机",
  price: 4999,
  imgUrl: "https://picsum.photos/240/180?random=1",
  discount: "立减200元,支持分期免息"
};
</script>

2. 父组件:接收并使用作用域插槽数据(App.vue

父组件通过 template #插槽名="插槽数据对象" 接收子组件暴露的数据,支持解构赋值简化代码,自定义渲染逻辑:

vue

<template>
  <h2>进阶:作用域插槽演示(子组件供数,父组件定制渲染)</h2>
  <div style="overflow: hidden; clear: both; margin-top: 40px;">
    <GoodsCard>
      <!-- 接收图片插槽的作用域数据:slotProps(自定义名称,包含goodsId、imgUrl) -->
      <template #image="slotProps">
        <img :src="slotProps.imgUrl" :alt="'商品' + slotProps.goodsId" style="width: 240px; height: 180px; object-fit: cover; border-radius: 8px;">
        <!-- 利用子组件传递的goodsId,添加自定义标识 -->
        <span style="position: absolute; top: 8px; left: 8px; background: red; color: #fff; padding: 2px 8px; border-radius: 4px; z-index: 10;">
          编号:{{ slotProps.goodsId }}
        </span>
      </template>

      <!-- 接收标题插槽的作用域数据:解构赋值(更简洁,推荐) -->
      <template #title="{ goodsName, goodsPrice }">
        {{ goodsName }}
        <!-- 父组件自定义逻辑:价格高于4000加「高端」标识 -->
        <span v-if="goodsPrice > 4000" style="color: #ff4400; font-size: 12px; margin-left: 8px;">
          高端
        </span>
      </template>

      <!-- 接收价格插槽的作用域数据:结合优惠信息渲染 -->
      <template #price="{ price, discount }">
        <span>¥ {{ price }}</span>
        <!-- 渲染子组件传递的优惠信息,自定义样式 -->
        <p style="font-size: 12px; color: #999; margin-top: 4px; text-align: left;">
          {{ discount }}
        </p>
      </template>
    </GoodsCard>
  </div>
</template>

<script setup>
import GoodsCard from './components/GoodsCard.vue';
</script>

3. 运行效果与说明

  • 父组件成功获取子组件私有数据(goodsIddiscount 等),并实现自定义渲染(商品编号、高端标识、优惠信息);
  • 核心语法:子组件「属性绑定传数据」,父组件「插槽数据对象接收」,支持解构赋值简化代码;
  • 核心价值:通用组件(列表、卡片、表格)既保留内部数据逻辑,又开放渲染格式定制权,极大提升组件灵活性和复用性;
  • 注意:作用域插槽本质仍是具名 / 默认插槽,只是增加了「子向父」的数据传递能力。

三、总结(核心知识点回顾,加深记忆)

  1. 使用步骤
  • 子组件:用 <slot name="xxx"> 定义具名插槽(内部可写默认内容),用 :数据名="内部数据" 给插槽传递数据(作用域插槽);
  • 父组件:用 <template #xxx> 给指定具名插槽传内容,用 <template #xxx="slotProps"> 接收作用域插槽数据,未指定插槽名的内容默认插入到 <slot>(默认插槽)。
  1. 核心语法
  • v-slot:插槽名 可简写为 #插槽名,仅能用于 <template> 标签或组件标签上;
  • 作用域插槽数据支持解构赋值,可设置默认值(如 #title="{ goodsName = '默认商品', goodsPrice = 0 }")避免报错。
  1. 插槽体系
  • 基础层:默认插槽(单一区域)、具名插槽(多区域精准定制);
  • 增强层:插槽默认内容(提升健壮性)、作用域插槽(子供数 + 父定制,进阶核心)。

分割正方形 I

2026年1月8日 12:04

方法一:二分查找

思路与算法

设数组 $\textit{squares}$ 的长度为 $n$,数组中的每个元素为 $(x_i,y_i,l_i)$,此时所有正方形的面积之和则为:

$$
\textit{totalArea} = \sum_{i=0}^{n-1} l_i^2
$$

根据题目要求,我们需要找到一个分割线 $y$,使得所有在 $y$ 以上的正方形的面积之和等于所有在 $y$ 以下的正方形的面积之和。设 $y$ 以下的正方形面积为 $\textit{area}_y$,此时需要满足:

$$
\textit{area}_y \cdot 2= \textit{totalArea}
$$

随着分割线 $y$ 的增大,$\textit{area}_y$ 单调不减,因此可以使用二分查找。具体地,我们可以二分查找找到最小的 $y$ 的值,使得在 $y$ 以下的正方形的面积满足:

$$
\textit{area}_y \cdot 2 \ge \textit{totalArea}
$$

假设给定的 $y$ 的值,如果给定正方形 $(x_i,y_i,l_i)$,满足 $y_i < y$,那么这个正方形在 $y$ 以下,否则在 $y$ 以上。此时该正方形在 $y$ 以下的面积则为:

$$
\textit{area} = l_i \cdot \min(y - y_i, l_i)
$$

我们可以根据这个性质来计算在 $y$ 以下的所有正方形的面积之和:

$$
\textit{area}y = \sum{i=0}^{n-1} l_i \cdot \max(0,\min(y - y_i, l_i))
$$

由于计算面积时存在精度问题,题目要求与实际答案的误差在 $10^{-5}$ 以内。我们需要在二分查找时使用 $10^{-5}$ 作为精度,即可以使用上限与下限的差距不超过 $\text{10}^{-5}$ 作为二分查找的终止条件:

$$
\textit{hi} - \textit{lo} \le 10^{-5}
$$

我们通过二分查找找到最小的 $y$ 值即为答案。

细节

我们可以分析二分查找的次数上限,设初始二分区间长度为 $L$,每二分一次,二分区间长度减半。要至少减半到 $10^{-5}$ 才能满足题目的误差要求。设循环次数为 $k$,我们有:

$$
\dfrac{L}{2^k} \le 10^{-5}
$$

解得:

$$
k \ge \log_2 (L \cdot 10^5)
$$

在本题的数据范围下,$0 \le L \le 10^9$,此时 $k \ge \log_2 (L \cdot 10^5) \ge \log_2 (10^{14}) = 14 \log_2 (10) \approx 46.506993328423076$。二分查找的次数上限为 $47$ 次。

代码

###C++

class Solution {
public:
    double separateSquares(vector<vector<int>>& squares) {
        double max_y = 0, total_area = 0;
        for (auto& sq : squares) {
            int y = sq[1], l = sq[2];
            total_area += (double)l * l;
            max_y = max(max_y, (double)(y + l));
        }
        
        auto check = [&](double limit_y) -> bool {
            double area = 0;
            for (auto& sq : squares) {
                int y = sq[1], l = sq[2];
                if (y < limit_y) {
                    area += l * min(limit_y - y, (double)l);
                }
            }
            return area >= total_area / 2;
        };
        
        double lo = 0, hi = max_y;
        double eps = 1e-5;
        while (abs(hi - lo) > eps) {
            double mid = (hi + lo) / 2;
            if (check(mid)) {
                hi = mid;
            } else {
                lo = mid;
            }
        }
        return hi;
    }
};

###Java

class Solution {
    public double separateSquares(int[][] squares) {
        double max_y = 0, total_area = 0;
        for (int[] sq : squares) {
            int y = sq[1], l = sq[2];
            total_area += (double)l * l;
            max_y = Math.max(max_y, (double)(y + l));
        }
        
        double lo = 0, hi = max_y;
        double eps = 1e-5;
        while (Math.abs(hi - lo) > eps) {
            double mid = (hi + lo) / 2;
            if (check(mid, squares, total_area)) {
                hi = mid;
            } else {
                lo = mid;
            }
        }

        return hi;
    }

    private Boolean check(double limit_y, int[][] squares, double total_area) {
        double area = 0;
        for (int[] sq : squares) {
            int y = sq[1], l = sq[2];
            if (y < limit_y) {
                area += (double)l * Math.min(limit_y - y, (double)l);
            }
        }
        return area >= total_area / 2;
    }
}

###C#

public class Solution {
    public double SeparateSquares(int[][] squares) {
        double max_y = 0, total_area = 0;
        foreach (int[] sq in squares) {
            int y = sq[1], l = sq[2];
            total_area += (double)l * l;
            max_y = Math.Max(max_y, (double)(y + l));
        }
        
        double lo = 0, hi = max_y;
        double eps = 1e-5;
        while (Math.Abs(hi - lo) > eps) {
            double mid = (hi + lo) / 2;
            if (Check(mid, squares, total_area)) {
                hi = mid;
            } else {
                lo = mid;
            }
        }

        return hi;
    }

    private bool Check(double limit_y, int[][] squares, double total_area) {
        double area = 0;
        foreach (int[] sq in squares) {
            int y = sq[1], l = sq[2];
            if (y < limit_y) {
                area += (double)l * Math.Min(limit_y - y, (double)l);
            }
        }
        return area >= total_area / 2;
    }
}

###Go

func separateSquares(squares [][]int) float64 {
    max_y, total_area := 0.0, 0.0
    for _, sq := range squares {
        y, l := sq[1], sq[2]
        total_area += float64(l * l)
        if float64(y + l) > max_y {
            max_y = float64(y + l)
        }
    }
    
    check := func(limit_y float64) bool {
        area := 0.0
        for _, sq := range squares {
            y, l := sq[1], sq[2]
            if float64(y) < limit_y {
                overlap := math.Min(limit_y-float64(y), float64(l))
                area += float64(l) * overlap
            }
        }
        
        return area >= total_area / 2.0
    }
    
    lo, hi := 0.0, max_y
    eps := 1e-5
    for math.Abs(hi-lo) > eps {
        mid := (hi + lo) / 2.0
        if check(mid) {
            hi = mid
        } else {
            lo = mid
        }
    }
    return hi
}

###Python

class Solution:
    def separateSquares(self, squares: List[List[int]]) -> float:
        max_y, total_area = 0, 0
        for x, y, l in squares:
            total_area += l ** 2
            max_y = max(max_y, y + l)
        
        def check(limit_y):
            area = 0
            for x, y, l in squares:
                if y < limit_y:
                    area += l * min(limit_y - y, l)
            return area >= total_area / 2
        
        lo, hi = 0, max_y
        eps = 1e-5
        while abs(hi - lo) > eps:
            mid = (hi + lo) / 2
            if check(mid):
                hi = mid
            else:
                lo = mid

        return hi

###C

bool check(double limit_y, int** squares, int squaresSize, double total_area) {
    double area = 0.0;

    for (int i = 0; i < squaresSize; i++) {
        int y = squares[i][1];
        int l = squares[i][2];
        if (y < limit_y) {
            area += (double)l * fmin(l, limit_y - y);
        }
    }

    return area >= total_area / 2.0;
}

double separateSquares(int** squares, int squaresSize, int* squaresColSize) {
    double max_y = 0.0, total_area = 0.0;
    for (int i = 0; i < squaresSize; i++) {
        int y = squares[i][1];
        int l = squares[i][2];
        total_area += (double)l * l;
        if (y + l > max_y) {
            max_y = y + l;
        }
    }
    
    double lo = 0.0, hi = max_y;
    double eps = 1e-5;
    while (fabs(hi - lo) > eps) {
        double mid = (hi + lo) / 2.0;
        if (check(mid, squares, squaresSize, total_area)) {
            hi = mid;
        } else {
            lo = mid;
        }
    }
    return hi;
}

###JavaScript

var separateSquares = function(squares) {
    let max_y = 0, total_area = 0;
    for (const [x, y, l] of squares) {
        total_area += l * l;
        max_y = Math.max(max_y, y + l);
    }
    
    const check = (limit_y) => {
        let area = 0;
        for (const [x, y, l] of squares) {
            if (y < limit_y) {
                area += l * Math.min(limit_y - y, l);
            }
        }
        return area >= total_area / 2;
    };
    
    let lo = 0, hi = max_y;
    const eps = 1e-5;
    while (Math.abs(hi - lo) > eps) {
        const mid = (hi + lo) / 2;
        if (check(mid)) {
            hi = mid;
        } else {
            lo = mid;
        }
    }
    return hi;
};

###TypeScript

function separateSquares(squares: number[][]): number {
    let max_y = 0, total_area = 0;
    for (const [x, y, l] of squares) {
        total_area += l * l;
        max_y = Math.max(max_y, y + l);
    }
    
    const check = (limit_y: number): boolean => {
        let area = 0;
        for (const [x, y, l] of squares) {
            if (y < limit_y) {
                area += l * Math.min(limit_y - y, l);
            }
        }
        return area >= total_area / 2;
    };
    
    let lo = 0, hi = max_y;
    const eps = 1e-5;
    while (Math.abs(hi - lo) > eps) {
        const mid = (hi + lo) / 2;
        if (check(mid)) {
            hi = mid;
        } else {
            lo = mid;
        }
    }
    return hi;
}

###Rust

impl Solution {
    pub fn separate_squares(squares: Vec<Vec<i32>>) -> f64 {
        let mut max_y: f64 = 0.0;
        let mut total_area: f64 = 0.0;
        
        for sq in &squares {
            let l = sq[2] as f64;
            total_area += l * l;
            max_y = max_y.max((sq[1] + sq[2]) as f64);
        }
        
        let mut lo = 0.0;
        let mut hi = max_y;
        let eps = 1e-5;
        while (hi - lo).abs() > eps {
            let mid = (hi + lo) / 2.0;
            if Self::check(mid, &squares, total_area) {
                hi = mid;
            } else {
                lo = mid;
            }
        }
        
        hi
    }
    
    fn check(limit_y: f64, squares: &Vec<Vec<i32>>, total_area: f64) -> bool {
        let mut area = 0.0;
        
        for sq in squares {
            let y = sq[1] as f64;
            let l = sq[2] as f64;
            if y < limit_y {
                let overlap = (limit_y - y).min(l);
                area += l * overlap;
            }
        }
        
        area >= total_area / 2.0
    }
}

复杂度分析

  • 时间复杂度:$O(n \log (LU))$,其中 $n$ 是数组 $\textit{squares}$ 的长度,设数组中的每个元素为 $(x_i,y_i,l_i)$,此时 $U = \max(y_i + l_i)$,$L = 10^5$。二分查找每次校验的时间复杂度度为 $O(n)$,二分查找的次数为 $O(\log (LU))$,因此总时间复杂度为 $O(n \log (LU))$。

  • 空间复杂度:$O(1)$。

方法二:扫描线

思路与算法

我们可以参考「扫描线」的解法。首先可以计算出所有正方形的总面积 $\textit{totalArea}$,接着我们从下往上进行扫描,设扫描线 $y = y^{'}$ 下方的覆盖的面积和为 $\textit{area}$,那么扫描线上方的面积和为 $\textit{totalArea}−\textit{area}$。

题目要求 $y = y^{'}$ 下面的面积与上方的面积相等,即:

$$
\textit{area} = \textit{totalArea}− \textit{area}
$$

即:

$$
\textit{area} = \dfrac{\textit{totalArea}}{2}
$$

设当前经过正方形上/下边界的扫描线为 $y = y^{'}$,此时扫面线以下的覆盖面积为 $\textit{area}$;向上移动时下一个需要经过的正方形上/下边界的扫描线为 $y = y^{''}$,此时被正方形覆盖的底边长之和为 $\textit{width}$,则此时在扫面线 $y = y^{''}$ 以下覆盖的面积之和为:

$$
\textit{area} + \textit{width} \cdot (y^{''} - y^{'})
$$

此时当满足:

$$
\textit{area} < \dfrac{\textit{totalArea}}{2} \
\textit{area} + \textit{width} \cdot (y^{''} - y^{'}) \ge \dfrac{\textit{totalArea}}{2}
$$

时,则可以知道目标值 $y$ 一定处于区间 $[y^{'},y^{''}]$。
由于两个扫面线之间的被覆盖区域中所有的矩形的高度相同,扫面线在区间 $[y^{'},y^{''}]$ 移动长度为 $\Delta$ 时,此时被覆盖区域的面积变化即为 $\Delta \cdot \textit{width}$,此时被覆盖的面积只需增加 $\dfrac{\textit{totalArea}}{2} - \textit{area}$,即可满足上下面积相等,此时我们可以直接求出目标值 $y$ 即为:

$$
y = y^{'} + \dfrac{\dfrac{\textit{totalArea}}{2} - \textit{area}}{\textit{width}} = y^{'} + \dfrac{\textit{totalArea} - 2\cdot \textit{area}}{2\cdot\textit{width}}
$$

我们依次遍历每个正方形上/下边界的扫面线,找到目标值返回即可。

代码

###C++

class Solution {
public:
    double separateSquares(vector<vector<int>>& squares) {
        long long total_area = 0;
        vector<tuple<int, int, int>> events; 
        for (const auto& sq : squares) {
            int y = sq[1], l = sq[2];
            total_area += (long long)l * l;
            events.emplace_back(y, l, 1);
            events.emplace_back(y + l, l, -1);
        }
        // 按照 y 坐标进行排序
        sort(events.begin(), events.end(), [](const auto& a, const auto& b) {
            return get<0>(a) < get<0>(b);
        });
        
        double covered_width = 0;  // 当前扫描线下所有底边之和
        double curr_area = 0;       // 当前累计面积
        double prev_height = 0;     // 前一个扫描线的高度
        for (const auto &[y, l, delta] : events) {
            int diff = y - prev_height;
            // 两条扫面线之间新增的面积
            double area = covered_width * diff;
            // 如果加上这部分面积超过总面积的一半
            if (2LL * (curr_area + area) >= total_area) {
                return prev_height + (total_area - 2.0 * curr_area) / (2.0 * covered_width);
            }
            // 更新宽度:开始事件加宽度,结束事件减宽度
            covered_width += delta * l;
            curr_area += area;
            prev_height = y;
        }
        
        return 0.0;
    }
};

###Java

class Solution {
    public double separateSquares(int[][] squares) {
        long totalArea = 0;
        List<int[]> events = new ArrayList<>();
        
        for (int[] sq : squares) {
            int y = sq[1], l = sq[2];
            totalArea += (long) l * l;
            events.add(new int[]{y, l, 1});
            events.add(new int[]{y + l, l, -1});
        }
        
        // 按y坐标排序
        events.sort((a, b) -> Integer.compare(a[0], b[0]));
        double coveredWidth = 0;  // 当前扫描线下所有底边之和
        double currArea = 0;      // 当前累计面积
        double prevHeight = 0;    // 前一个扫描线的高度
        
        for (int[] event : events) {
            int y = event[0];
            int l = event[1];
            int delta = event[2];
            
            int diff = y - (int) prevHeight;
            // 两条扫描线之间新增的面积
            double area = coveredWidth * diff;
            // 如果加上这部分面积超过总面积的一半
            if (2L * (currArea + area) >= totalArea) {
                return prevHeight + (totalArea - 2.0 * currArea) / (2.0 * coveredWidth);
            }
            // 更新宽度:开始事件加宽度,结束事件减宽度
            coveredWidth += delta * l;
            currArea += area;
            prevHeight = y;
        }
        
        return 0.0;
    }
}

###C#

public class Solution {
    public double SeparateSquares(int[][] squares) {
        long totalArea = 0;
        List<int[]> events = new List<int[]>();
        
        foreach (var sq in squares) {
            int y = sq[1], l = sq[2];
            totalArea += (long)l * l;
            events.Add(new int[] { y, l, 1 });
            events.Add(new int[] { y + l, l, -1 });
        }
        
        // 按y坐标排序
        events.Sort((a, b) => a[0].CompareTo(b[0]));
        
        double coveredWidth = 0;  // 当前扫描线下所有底边之和
        double currArea = 0;      // 当前累计面积
        double prevHeight = 0;    // 前一个扫描线的高度
        
        foreach (var eventItem in events) {
            int y = eventItem[0];
            int l = eventItem[1];
            int delta = eventItem[2];
            
            int diff = y - (int)prevHeight;
            // 两条扫描线之间新增的面积
            double area = coveredWidth * diff;
            // 如果加上这部分面积超过总面积的一半
            if (2L * (currArea + area) >= totalArea) {
                return prevHeight + (totalArea - 2.0 * currArea) / (2.0 * coveredWidth);
            }
            // 更新宽度:开始事件加宽度,结束事件减宽度
            coveredWidth += delta * l;
            currArea += area;
            prevHeight = y;
        }
        
        return 0.0;
    }
}

###Go

func separateSquares(squares [][]int) float64 {
    var totalArea int64 = 0
    type Event struct {
        y     int
        l     int
        delta int
    }
    events := make([]Event, 0, len(squares)*2)
    
    for _, sq := range squares {
        y, l := sq[1], sq[2]
        totalArea += int64(l) * int64(l)
        events = append(events, Event{y, l, 1})
        events = append(events, Event{y + l, l, -1})
    }
    
    // 按y坐标排序
    sort.Slice(events, func(i, j int) bool {
        return events[i].y < events[j].y
    })
    
    coveredWidth := 0.0  // 当前扫描线下所有底边之和
    currArea := 0.0      // 当前累计面积
    prevHeight := 0.0    // 前一个扫描线的高度
    
    for _, event := range events {
        y, l, delta := event.y, event.l, event.delta
        diff := float64(y) - prevHeight
        // 两条扫描线之间新增的面积
        area := coveredWidth * diff
        // 如果加上这部分面积超过总面积的一半
        if 2.0*(currArea+area) >= float64(totalArea) {
            return prevHeight + (float64(totalArea) - 2.0*currArea) / (2.0 * coveredWidth)
        }
        // 更新宽度:开始事件加宽度,结束事件减宽度
        coveredWidth += float64(delta * l)
        currArea += area
        prevHeight = float64(y)
    }
    
    return 0.0
}

###Python

class Solution:
    def separateSquares(self, squares: List[List[int]]) -> float:
        total_area = 0
        events = []
        
        for sq in squares:
            y, l = sq[1], sq[2]
            total_area += l * l
            events.append((y, l, 1))
            events.append((y + l, l, -1))
        
        # 按y坐标排序
        events.sort(key=lambda x: x[0])
        
        covered_width = 0.0  # 当前扫描线下所有底边之和
        curr_area = 0.0      # 当前累计面积
        prev_height = 0.0    # 前一个扫描线的高度
        
        for y, l, delta in events:
            diff = y - prev_height
            # 两条扫描线之间新增的面积
            area = covered_width * diff
            # 如果加上这部分面积超过总面积的一半
            if 2 * (curr_area + area) >= total_area:
                return prev_height + (total_area - 2 * curr_area) / (2 * covered_width)
            # 更新宽度:开始事件加宽度,结束事件减宽度
            covered_width += delta * l
            curr_area += area
            prev_height = y
        
        return 0.0

###C

typedef struct {
    int y;
    int l;
    int delta;
} Event;

int compareEvents(const void* a, const void* b) {
    Event* e1 = (Event*)a;
    Event* e2 = (Event*)b;
    return e1->y - e2->y;
}

double separateSquares(int** squares, int squaresSize, int* squaresColSize) {
    long long totalArea = 0;
    Event* events = malloc(2 * squaresSize * sizeof(Event));
    int eventCount = 0;
    
    for (int i = 0; i < squaresSize; i++) {
        int y = squares[i][1];
        int l = squares[i][2];
        totalArea += (long long)l * l;
        events[eventCount++] = (Event){y, l, 1};
        events[eventCount++] = (Event){y + l, l, -1};
    }
    
    // 按y坐标排序
    qsort(events, eventCount, sizeof(Event), compareEvents);
    
    double coveredWidth = 0.0;  // 当前扫描线下所有底边之和
    double currArea = 0.0;      // 当前累计面积
    double prevHeight = 0.0;    // 前一个扫描线的高度
    
    for (int i = 0; i < eventCount; i++) {
        int y = events[i].y;
        int l = events[i].l;
        int delta = events[i].delta;
        
        int diff = y - (int)prevHeight;
        // 两条扫描线之间新增的面积
        double area = coveredWidth * diff;
        // 如果加上这部分面积超过总面积的一半
        if (2LL * (currArea + area) >= totalArea) {
            double result = prevHeight + (totalArea - 2.0 * currArea) / (2.0 * coveredWidth);
            free(events);
            return result;
        }
        // 更新宽度:开始事件加宽度,结束事件减宽度
        coveredWidth += delta * l;
        currArea += area;
        prevHeight = y;
    }
    
    free(events);
    return 0.0;
}

###JavaScript

var separateSquares = function(squares) {
    let totalArea = 0n;
    const events = [];
    
    for (const sq of squares) {
        const y = sq[1], l = sq[2];
        totalArea += BigInt(l) * BigInt(l);
        events.push([y, l, 1]);
        events.push([y + l, l, -1]);
    }
    
    // 按y坐标排序
    events.sort((a, b) => a[0] - b[0]);
    
    let coveredWidth = 0;  // 当前扫描线下所有底边之和
    let currArea = 0;      // 当前累计面积
    let prevHeight = 0;    // 前一个扫描线的高度
    
    for (const [y, l, delta] of events) {
        const diff = y - prevHeight;
        // 两条扫描线之间新增的面积
        const area = coveredWidth * diff;
        // 如果加上这部分面积超过总面积的一半
        if (2n * BigInt(Math.ceil(currArea + area)) >= totalArea) {
            return prevHeight + (Number(totalArea) - 2.0 * currArea) / (2.0 * coveredWidth);
        }
        // 更新宽度:开始事件加宽度,结束事件减宽度
        coveredWidth += delta * l;
        currArea += area;
        prevHeight = y;
    }
    
    return 0.0;
};

###TypeScript

function separateSquares(squares: number[][]): number {
    let totalArea: bigint = 0n;
    const events: [number, number, number][] = [];
    
    for (const sq of squares) {
        const y = sq[1], l = sq[2];
        totalArea += BigInt(l) * BigInt(l);
        events.push([y, l, 1]);
        events.push([y + l, l, -1]);
    }
    
    // 按y坐标排序
    events.sort((a, b) => a[0] - b[0]);
    
    let coveredWidth: number = 0;  // 当前扫描线下所有底边之和
    let currArea: number = 0;      // 当前累计面积
    let prevHeight: number = 0;    // 前一个扫描线的高度
    
    for (const [y, l, delta] of events) {
        const diff: number = y - prevHeight;
        // 两条扫描线之间新增的面积
        const area: number = coveredWidth * diff;
        // 如果加上这部分面积超过总面积的一半
        if (2n * BigInt(Math.ceil(currArea + area)) >= totalArea) {
            return prevHeight + (Number(totalArea) - 2.0 * currArea) / (2.0 * coveredWidth);
        }
        // 更新宽度:开始事件加宽度,结束事件减宽度
        coveredWidth += delta * l;
        currArea += area;
        prevHeight = y;
    }
    
    return 0.0;
}

###Rust

impl Solution {
    pub fn separate_squares(squares: Vec<Vec<i32>>) -> f64 {
        let mut total_area: i64 = 0;
        let mut events: Vec<(i32, i32, i32)> = Vec::new();
        
        for sq in &squares {
            let y = sq[1];
            let l = sq[2];
            total_area += l as i64 * l as i64;
            events.push((y, l, 1));
            events.push((y + l, l, -1));
        }
        
        // 按y坐标排序
        events.sort_by_key(|&(y, _, _)| y);
        
        let mut covered_width: f64 = 0.0;  // 当前扫描线下所有底边之和
        let mut curr_area: f64 = 0.0;      // 当前累计面积
        let mut prev_height: f64 = 0.0;    // 前一个扫描线的高度
        
        for (y, l, delta) in events {
            let diff = y as f64 - prev_height;
            // 两条扫描线之间新增的面积
            let area = covered_width * diff;
            // 如果加上这部分面积超过总面积的一半
            if 2.0 * (curr_area + area) >= total_area as f64 {
                return prev_height + (total_area as f64 - 2.0 * curr_area) / (2.0 * covered_width);
            }
            // 更新宽度:开始事件加宽度,结束事件减宽度
            covered_width += (delta * l) as f64;
            curr_area += area;
            prev_height = y as f64;
        }
        
        0.0
    }
}

复杂度分析

  • 时间复杂度:$O(n \log n)$,其中 $n$ 是数组 $\textit{squares}$ 的长度。排序需要的时间复杂度为 $O(n \log n)$。

  • 空间复杂度:$O(n)$,其中 $n$ 是数组 $\textit{squares}$ 的长度。存储扫面线高度,需要的空间为 $O(n)$。

从零实现 React Native(2): 跨平台支持

作者 zerosrat
2026年1月13日 19:21

上一回:从零实现 React Native(1): 桥通信的原理与实现

平台支持的取舍

在上一篇《从零实现 React Native(1): 桥通信的原理与实现》中,基于 macos 平台实现了 JS 与 Native 的双向桥通信,在本篇中将对其他平台进行支持,实现「write once,run anywhere」这个理念。

接下来便来进行 iOS 和 Android 平台的支持工作。

iOS 进展顺利

在支持了 macos 端后,支持 iOS 是很容易的,可以马上着手来搞这个事情。得益于 Apple 生态带来的:macOS 和 iOS 都内置了 JavaScriptCore.framework,这意味着无需额外的引擎移植工作;且编程 API 很相似,这意味着差异化实现较少,大多可复用或类比实现。

事实上,我只花了半天时间就完成了 iOS 端的支持工作,其中主要的时间花在了构建配置的修改、测试示例的新增和调整,少部分时间花在了差异化的 DeviceInfo 模块实现。

得益于 Apple 生态,iOS 的支持工作中大部分代码都是复用的,复用率 90%。因为 macos 和 iOS 的 JSC API 一致,以及 C++ 语言的优势,可以用于跨端复用。复用的内容包含:

  • JSCExector
  • Bridge 通信逻辑
  • 模块注册逻辑

Android 滑铁卢

在顺利支持了 iOS 后,预想是 Android 的支持也不会太难,但实际做起来发现没这么简单。

记得是周末的午后的轻松下午,我先把 Android 的相关环境搭建好(包括 Android Studio、Java SDK 及其环境变量、NDK 等),然后进入 JSC 的移植工作。Why JSC 移植?因为不同于 Apple 生态,Android 系统是没有内置 JSC 引擎的。而正是这一步让我陷入泥潭。

我首先尝试了三方编译的版本,但是要么遇到了 libjsc.so(JSC 编译后的二进制文件,可供 Android 平台运行,可类比理解为前端的 wasm) 不支持 arm64(由于是 MBP 机器,安卓模拟器必须用 arm64 而非 x86 架构的),要么是遇到了 libjsc.so 和 NDK 版本不兼容。然后尝试了从社区提供的 jsc-android-buildscripts 自行编译,也遇到了各种问题编译失败,考虑到每次编译时间:2-3 小时,这也是一个艰难的旅程。

就算解决了 JavaScriptCore,还有 JNI 在等着我。Java 和 C++ 之间的桥梁不是简单的函数调用。我要处理诸如:类型转换、线程同步等问题。前方有很多新的坑在等着我。

舍与得

Maybe it's not the right time. 先理解核心,再扩展边界。先放下 Android 的支持,或许未来的某一天再回头来看这件事。

这个决定让我想起了 MVP(最小可行产品)的原则:先让核心功能跑通,再逐步完善。在学习项目中,这个原则同样适用——先掌握本质,再扩展边界。

既然决定专注于 iOS 和 macOS 双平台,那么接下来就需要一套优雅的构建系统来支撑跨平台开发。一个好的构建系统不仅能让开发者轻松切换平台,更重要的是,它能为后续的代码复用奠定基础。

构建系统的演进

在上一篇博客中,受制于篇幅的限制,跳过了对构建系统的讲解。而在跨平台支持中,天然需要迭代构建系统,也正是对其展开讲讲的一个好时机。

Make 是什么

Make 是一个诞生于 1976 年的构建工具,它的工作原理很简单:描述文件之间的依赖关系,然后只重新编译"变化过的"文件。

Make 适合于 需要多步骤构建流程 的项目,本项目的构建流程较为复杂:JS 产物打包 -> CMake 配置 -> C++ 产物编译 -> 运行 test 代码,很适合引入 Make 进行任务流程的编排。

Make 工具的配套 Makefile 文件是一个文本配置文件,它定义了构建规则、依赖关系和执行命令,可以将其理解为 npm 和 package.json 的关系。

以下是基于 macos 编译和测试的 Makefile 文件摘要代码,核心步骤包含了 js-build, configure, test

# ============================================
# 变量定义 (Makefile 第 10-13 行)
# ============================================
BUILD_DIR = build
CMAKE_BUILD_TYPE ?= Debug
CORES = $(shell sysctl -n hw.ncpu)  # 动态检测 CPU 核心数

# ============================================
# 主要构建目标 - 依赖链设计
# ============================================

# 默认目标:make 等价于 make build
.PHONY: all
all: build

# 核心构建流程:js-build → configure → 实际编译
.PHONY: build
build: js-build configure
    @echo "🔨 Building Mini React Native..."
    @cd $(BUILD_DIR) && make -j$(CORES)
    @echo "✅ Build complete"

# ============================================
# 步骤 1:JavaScript 构建 (第 29-33 行)
# ============================================
.PHONY: js-build
js-build:
    @echo "📦 Building JavaScript bundle..."
    @npm run build    # 执行 rollup -c,生成 dist/bundle.js
    @echo "✅ JavaScript bundle built"

# ============================================
# 步骤 2:CMake 配置 (第 22-26 行)
# ============================================
.PHONY: configure
configure:
    @echo "🔧 Configuring build system..."
    @mkdir -p $(BUILD_DIR)
    @cd $(BUILD_DIR) && cmake -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) -DCMAKE_EXPORT_COMPILE_COMMANDS=ON ..
    @echo "✅ Configuration complete"


# ============================================
# 步骤 4:分层测试流程 (第 91-130 行)
# ============================================

# 完整测试:build → 4 个测试依次执行
.PHONY: test
test: build
    @echo "🧪 Running all tests..."
    @echo "\n📝 Test 1: Basic functionality test"
    @./$(BUILD_DIR)/mini_rn_test
    @echo "\n📝 Test 2: Module framework test"
    @./$(BUILD_DIR)/test_module_framework
    @echo "\n📝 Test 3: Integration test"
    @./$(BUILD_DIR)/test_integration
    @echo "\n📝 Test 4: Performance test"
    @./$(BUILD_DIR)/test_performance
    @echo "\n✅ All tests complete"

# 单独的测试目标 - 允许细粒度测试
.PHONY: test-basic
test-basic: build
    @echo "🧪 Running basic functionality test..."
    @./$(BUILD_DIR)/mini_rn_test

.PHONY: test-performance
test-performance: build
    @echo "🧪 Running performance test..."
    @./$(BUILD_DIR)/test_performance

在引入了 make 后,可以很方便的进行复杂流程的编排,例如我们想要运行测试代码时,实际的发生的事情如下所示。

用户命令: make test
    ↓
test: build
    ↓
build: js-build configure
        ↓             ↓
    js-build          configure
        ↓                   ↓
    npm run build       cmake ..
        ↓                   ↓
    dist/bundle.js      build/Makefile
                            ↓
                        make -j8 (CMake 管理的依赖)
                            ↓
                        libmini_react_native.a
                            ↓
                        mini_rn_test (等 4 个可执行文件)

Before 引入 Make:想象一下,如果没有 Make,每次修改代码后你需要手动执行

# 步骤1:构建 JavaScript
npm run build

# 步骤2:配置 CMake
mkdir -p build
cd build && cmake ..

# 步骤3:编译 C++
cd build && make -j8

# 步骤4:运行测试
./build/mini_rn_test
./build/test_module_framework
./build/test_integration
./build/test_performance

每次都要记住这么多命令,还要确保执行顺序正确。更糟糕的是,如果某个步骤失败了,你需要手动判断从哪里重新开始。

After 引入 Makemake test 一条命令搞定所有事情

CMake 是什么

在把 C++ 代码编译成二进制文件这一步之前,其实构建系统提前引入了 CMake 进行管理。CMake 不是“构建工具”,而是“构建系统的构建系统”,在这个场景中 CMake 实际上生成了编译代码的工具 Makefile 文件。CMake 会读取 CMakeLists.txt,然后生成原生的构建文件。

Why CMake?因为 mini-rn 项目开始之初就是要考虑多平台支持的,为了实现这个 feature,便会遇到 多平台构建的复杂性 这个问题。

问题 1:平台特定源文件管理

不同平台需要不同的实现:

  • macOS:使用 IOKit 获取硬件信息
  • iOS:使用 UIDevice 获取设备信息

没有 CMake 需要维护两套构建脚本,引入 CMake 后可通过条件编译一套配置搞定。

问题 2:系统框架动态链接

不同平台需要链接不同框架:macOS 需要 IOKit,iOS 需要 UIKit

引入 CMake 后可自动检测并链接正确的框架。

解决效果

引入 CMake 前:需要维护多套构建脚本,手动管理复杂配置,容易出错。

引入 CMake 后:一套 CMakeLists.txt 支持所有平台,自动处理平台差异,大幅降低维护成本。

CMake 关键语法解释

  • CMAKE_SYSTEM_NAME:CMake 内置变量,表示目标系统名称(iOS、Darwin等)
  • find_library():在系统中查找指定的库文件
  • target_link_libraries():将库文件链接到目标可执行文件
  • set():设置变量的值
  • if(MATCHES):条件判断,支持正则表达式匹配

改动一:Makefile 新增 iOS 构建目标

在 macOS 的可扩展构建系统配置就绪后,接下来看看如何改动以支持 iOS。

改动一实现了什么?

核心目标:在现有 Makefile 基础上,新增 iOS 平台的完整构建流程,实现"一套 Makefile,双平台支持"。

具体实现

  1. 新增 4 个 iOS 专用目标ios-configureios-buildios-testios-test-deviceinfo
  2. 建立 iOS 构建流程:js-build → ios-configure → ios-build → ios-test
  3. 实现平台隔离:iOS 使用独立的 build_ios/ 目录,与 macOS 的 build/ 目录完全分离
  4. 自动化 Xcode 环境配置:自动检测 SDK 路径、设置开发者目录、配置模拟器架构

新增的 4 个 iOS 目标

原本基于 macOS 的构建路径是:js-build → configure → build → test,现在为 iOS 新增了对应的平行路径:js-build → ios-configure → ios-build → ios-test。

# iOS 构建配置(模拟器)
.PHONY: ios-configure
ios-configure:
    @mkdir -p $(BUILD_DIR)_ios
    @cd $(BUILD_DIR)_ios && DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer cmake \
        -DCMAKE_SYSTEM_NAME=iOS \
        -DCMAKE_OSX_ARCHITECTURES=$$(uname -m) \
        -DCMAKE_OSX_SYSROOT=$$(DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcrun --sdk iphonesimulator --show-sdk-path) \
        -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \
        -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
        ..

# 构建 iOS 版本(模拟器)
.PHONY: ios-build
ios-build: js-build ios-configure
    @cd $(BUILD_DIR)_ios && make -j$(CORES)

# iOS 测试目标
.PHONY: ios-test
ios-test: ios-build
    @./test_ios.sh all

# iOS DeviceInfo 测试
.PHONY: ios-test-deviceinfo
ios-test-deviceinfo: ios-build
    @./test_ios.sh deviceinfo

关键设计决策

1. 独立构建目录

macOS 用 build/,iOS 用 build_ios/,互不干扰:

@mkdir -p $(BUILD_DIR)_ios   # iOS 构建目录

2. 仅支持 iOS 模拟器

为什么不支持真机?因为:

  • 真机需要开发者证书和配置文件
  • 模拟器足够验证 Bridge 通信机制
  • 降低环境配置复杂度
-DCMAKE_OSX_SYSROOT=$$(xcrun --sdk iphonesimulator --show-sdk-path)

3. 语义化命令

make ios-configure 比写一长串 CMake 命令简洁太多。这就是 Makefile 作为用户接口的价值。

改动二:CMake 平台条件编译

改动二实现了什么?

核心目标:让 CMake 能够智能识别目标平台,并自动选择正确的源文件和系统框架,实现"一套 CMakeLists.txt,智能适配双平台"。

具体实现

  1. 平台检测机制:通过 CMAKE_SYSTEM_NAME 变量自动识别是 macOS 还是 iOS
  2. 源文件智能选择:根据平台自动选择对应的 .mm 实现文件
  3. 框架动态链接:iOS 链接 UIKit,macOS 链接 IOKit,共享 JavaScriptCore 和 Foundation
  4. 编译标志自动设置:为 Objective-C++ 文件自动设置 ARC 标志
  5. 部署目标配置:iOS 设为 12.0+,macOS 设为 10.15+

设计精髓:编译时确定,运行时无开销。最终的 iOS 二进制文件中完全没有 macOS 代码,反之亦然。

原来的代码(仅 macOS)

# 原始版本 - 仅支持 macOS
if(APPLE)
    set(PLATFORM_SOURCES
        src/macos/modules/deviceinfo/DeviceInfoModule.mm
    )
    find_library(IOKIT_FRAMEWORK IOKit)
endif()

target_link_libraries(mini_react_native
    ${JAVASCRIPTCORE_FRAMEWORK}
    ${IOKIT_FRAMEWORK}
)

演进后的代码(macOS + iOS)

# 演进版本 - 支持 macOS + iOS
if(APPLE)
    # 根据具体平台选择源文件
    if(${CMAKE_SYSTEM_NAME} MATCHES "iOS")
        set(PLATFORM_SOURCES
            src/ios/modules/deviceinfo/DeviceInfoModule.mm
        )
    else()
        # macOS
        set(PLATFORM_SOURCES
            src/macos/modules/deviceinfo/DeviceInfoModule.mm
        )
    endif()

    # 平台特定框架
    if(${CMAKE_SYSTEM_NAME} MATCHES "iOS")
        find_library(UIKIT_FRAMEWORK UIKit)
        set(PLATFORM_FRAMEWORKS ${UIKIT_FRAMEWORK})
    else()
        find_library(IOKIT_FRAMEWORK IOKit)
        set(PLATFORM_FRAMEWORKS ${IOKIT_FRAMEWORK})
    endif()

    # 统一链接
    target_link_libraries(mini_react_native
        ${JAVASCRIPTCORE_FRAMEWORK}
        ${FOUNDATION_FRAMEWORK}
        ${PLATFORM_FRAMEWORKS}
    )
endif()

三个关键变化

1. 源文件分离

src/
├── macos/modules/deviceinfo/DeviceInfoModule.mm
└── ios/modules/deviceinfo/DeviceInfoModule.mm

两个文件虽然文件名相同,但实现不同:

  • macOS 版本:用 IOKit 获取硬件信息
  • iOS 版本:用 UIDevice 获取设备信息

2. 框架动态链接

平台 共享框架 平台特定框架
macOS JavaScriptCore, Foundation IOKit
iOS JavaScriptCore, Foundation UIKit

3. 部署目标设置

if(${CMAKE_SYSTEM_NAME} MATCHES "iOS")
    set(CMAKE_OSX_DEPLOYMENT_TARGET "12.0")
elseif(${CMAKE_SYSTEM_NAME} MATCHES "Darwin")
    set(CMAKE_OSX_DEPLOYMENT_TARGET "10.15")
endif()

两个改动的协同作用

改动一 + 改动二 = 完美的跨平台构建系统

这两个改动巧妙地分工合作:

  • Makefile(改动一):作为用户接口层,提供简单统一的命令,隐藏平台配置的复杂性
  • CMake(改动二):作为构建逻辑层,智能处理平台差异,自动选择正确的源文件和框架

协同效果

  1. 开发者体验make build vs make ios-build,命令接口一致
  2. 构建隔离:两个平台使用独立目录,可以并行构建,切换无需清理
  3. 智能适配:CMake 根据 Makefile 传入的平台信息,自动配置所有细节
  4. 零运行时开销:编译时就确定了平台,最终二进制文件纯净无冗余

这种设计让跨平台支持变得既强大又优雅:开发者只需要记住两个命令,背后的所有复杂性都被自动化处理了。

DeviceInfo - 变与不变

在构建系统演进完成后,我们来深入分析 DeviceInfo 模块的双平台实现。这个模块展示了跨平台架构设计的智慧:如何在保持接口统一的同时,让每个平台发挥自身优势。

90% 复用率是怎么做到的?

关键洞察:大部分逻辑其实是平台无关的

仔细分析 DeviceInfo 模块,你会发现一个惊人的事实:

// 这些逻辑在任何平台都一样
std::string DeviceInfoModule::getName() const {
    return "DeviceInfo";
}

std::vector<std::string> DeviceInfoModule::getMethods() const {
    return {
        "getUniqueId",       // methodId = 0
        "getSystemVersion",  // methodId = 1
        "getDeviceId"        // methodId = 2
    };
}

void DeviceInfoModule::invoke(const std::string& methodName,
                             const std::string& args, int callId) {
    try {
        if (methodName == "getUniqueId") {
            std::string uniqueId = getUniqueIdImpl();  // 只是调用,不关心具体实现
            sendSuccessCallback(callId, uniqueId);
        } else {
            sendErrorCallback(callId, "Unknown method: " + methodName);
        }
    } catch (const std::exception& e) {
        sendErrorCallback(callId, "Method invocation failed: " + std::string(e.what()));
    }
}

**Bridge 通信协议、方法注册机制、消息分发逻辑,完全都是可以复用的!**真正不同的,只是那几个 xxxImpl() 方法的底层实现。

复用的边界

但这里有个更深层的问题:为什么有些代码能 100% 复用,有些却完全不能?

让我们看看实际的复用率统计:

代码类型 复用率 为什么?
Bridge 通信逻辑 100% 协议标准化
模块注册机制 100% 框架层抽象
错误处理机制 100% 异常处理逻辑相同
设备唯一标识 0% 平台理念完全不同
系统版本获取 95% 只有注释不同
设备型号获取 85% 都用 sysctlbyname,iOS多了模拟器判断

100% 复用:协议的力量

为什么 Bridge 通信能 100% 复用?

因为这是协议层,不管底层平台怎么变,JavaScript 和 Native 之间的通信协议是固定的。方法名、参数、回调 ID、错误处理这些都是标准化的。就像 HTTP 协议,不管服务器是 Linux 还是 Windows,浏览器都用同样的方式发请求。

0% 复用:平台的鸿沟

为什么设备唯一标识完全不能复用?

macOS 追求真正的硬件级别唯一性,有复杂的降级机制;iOS 在 MVP 阶段采用了简化策略,每次启动生成新ID。这不是技术问题,而是:

  1. 平台哲学的差异:桌面 vs 移动的隐私理念
  2. 开发策略的差异:完整实现 vs MVP验证

复用边界的哲学

通过 DeviceInfo 模块,我们发现了跨平台复用的三个层次:

  1. 协议层:100% 复用,因为标准统一
  2. API 层:看运气,苹果生态有优势
  3. 实现层:看平台差异,移动端更复杂

这揭示了一个残酷的真相:跨平台的成本永远存在,只是被转移了。

可以用抽象基类隐藏差异,但差异本身不会消失。关键是找到合适的边界,让复用最大化,让差异最小化。

头文件的魔法

解决方案其实就是基于 面向对象 的:

// common/modules/DeviceInfoModule.h
class DeviceInfoModule : public NativeModule {
public:
    DeviceInfoModule();
    ~DeviceInfoModule() override = default;

    // NativeModule 接口实现 - 所有平台共享
    std::string getName() const override;
    std::vector<std::string> getMethods() const override;
    void invoke(const std::string& methodName, const std::string& args,
                int callId) override;

    // 平台特定的实现接口 - 让各平台去填这些"洞"
    std::string getUniqueIdImpl() const;
    std::string getSystemVersionImpl() const;
    std::string getDeviceIdImpl() const;

private:
    // 工具方法
    std::string createSuccessResponse(const std::string& data) const;
    std::string createErrorResponse(const std::string& error) const;
};

注意这里没有用虚函数,因为已经引入了 CMake 在编译时确定了对应平台的文件,不需要运行时多态,结果是 同一个头文件,不同的实现文件。每个平台都有自己的 .mm 文件来实现这些方法,编译时自动选择对应的实现。

基类定义了 what(做什么),各平台实现 how (怎么做)。Bridge 通信、方法注册、消息分发等这些复杂的逻辑只写一遍,所有平台自动继承。

分平台实现

// macOS 实现 - src/macos/modules/deviceinfo/DeviceInfoModule.mm
std::string DeviceInfoModule::getUniqueIdImpl() const {
    @autoreleasepool {
        // 尝试获取硬件 UUID
        io_registry_entry_t ioRegistryRoot =
            IORegistryEntryFromPath(kIOMasterPortDefault, "IOService:/");
        CFStringRef uuidCf = (CFStringRef)IORegistryEntryCreateCFProperty(
            ioRegistryRoot, CFSTR(kIOPlatformUUIDKey), kCFAllocatorDefault, 0);

        if (uuidCf) {
            NSString* uuid = (__bridge NSString*)uuidCf;
            std::string result = [uuid UTF8String];
            CFRelease(uuidCf);
            return result;
        }
        // 多层降级机制...
        return "macOS-" + getDeviceIdImpl() + "-" + getSystemVersionImpl();
    }
}

// iOS 实现 - src/ios/modules/deviceinfo/DeviceInfoModule.mm
std::string DeviceInfoModule::getUniqueIdImpl() const {
    @autoreleasepool {
        // iOS 简化实现:使用 NSUUID 生成唯一标识
        // 注意:这个实现每次启动都会生成新的ID,适用于MVP测试
        NSUUID* uuid = [NSUUID UUID];
        NSString* uuidString = [uuid UUIDString];
        return [uuidString UTF8String];
    }
}

Objective-C++ 关键字解释

  • @autoreleasepool:自动释放池,管理 Objective-C 对象的内存,确保及时释放
  • __bridge:ARC(自动引用计数)中的桥接转换,在 C/C++ 指针和 Objective-C 对象间转换
  • [object method]:Objective-C 的方法调用语法
  • .mm 文件扩展名:表示 Objective-C++ 文件,可以混合使用 C++、C 和 Objective-C 代码

两个平台的实现文件自动拥有了完整的 Bridge 通信能力,现在只需要实现平台差异部分即可~

应自动化尽自动化

DeviceInfo 模块的自动化实现揭示了一个重要原则:

好的跨平台架构不是让代码在所有平台都能跑,而是让正确的代码在正确的平台上跑。

通过这个项目的三层自动化体系:

  1. Makefile 自动化:统一的命令接口,隐藏平台配置复杂性
  2. CMake 自动化:智能的源文件选择和框架链接
  3. 编译器自动化:平台特定的二进制生成

这样的架构让开发者专注于业务逻辑,而把平台适配的复杂性交给了工具链。

真正的自动化不是写一份代码到处跑,而是:

  • 开发体验统一make build vs make ios-build,命令接口一致
  • 实现策略分离:每个平台有最适合的实现方式
  • 构建过程透明:开发者不需要关心 Xcode SDK 路径、编译标志等细节

这种设计在面对更复杂的系统时依然有效:只要保持接口统一、实现分离、构建自动化,就能优雅地扩展到视图渲染、事件处理等更复杂的场景。

彩蛋

项目地址: github.com/zerosrat/mi…

当前项目中包含了本篇文章中的全部内容:

  • ✅ iOS 构建系统适配
  • ✅ iOS 跨平台的差异化实现(DeviceInfo)

完成本阶段后,项目已经具备了进入第三阶段的基础:视图渲染系统


📝 本文首发于个人博客: zerosrat.dev/n/2025/mini…

useEffect 空依赖 + 定时器 = 闭包陷阱?count 永远停在 1 的坑我踩透了

2026年1月13日 18:55

写 React 时,你有没有遇到过「定时器里的 state 永远不更新」的诡异情况?比如明明写了setCount(count + 1),页面上的count却永远停在 1—— 这其实是 ** 闭包陷阱(Stale Closure)** 在搞鬼。

今天用一个极简示例,拆解这个坑的本质,再给你 2 个一劳永逸的解决方案。

一、先看复现:count 为什么永远停在 1?

先看这段 “看似没问题” 的代码:

carbon.png

运行结果:页面上的count从 0 变成 1 后,就再也不涨了。

二、核心原因:闭包 “定格” 了初始 state

问题出在 2 个关键点的叠加:

  1. useEffect 的空依赖[] :空依赖意味着useEffect只在组件挂载时执行 1 次,后续组件更新不会重新运行这个 effect。
  2. 闭包捕获了 “快照” 值useEffect执行时,内部的setInterval函数形成了闭包 —— 它 “抓住” 了当时的count(值为 0)。后续count虽然被更新,但因为useEffect没重新执行,这个闭包永远拿着初始值 0,所以setCount(count + 1)永远是0 + 1 = 1

三、2 个解决方案:从根源避开闭包陷阱

针对这个场景,推荐 2 种既简单又安全的写法:

方案 1:函数式更新(推荐)

setState函数式写法,直接获取最新的 state 值,绕开闭包的旧值:

carbon (1).png

原理setCount(c => c + 1)会从 React 内部获取当前最新的count值,不管闭包抓的是旧值,都能拿到最新数据。

方案 2:补全依赖数组

count加入useEffect的依赖数组,让useEffectcount变化时重新执行,生成新的闭包:

carbon (2).png

注意:这个方案会频繁创建 / 清理定时器(每次count变化都重新执行 effect),性能不如方案 1,仅推荐在 “必须依赖外部变量” 的场景使用。

四、避坑总结:useEffect + 定时器的正确姿势

  1. 优先用函数式更新setState(prev => prev + 1)是避开闭包陷阱的 “万能钥匙”;
  2. 空依赖要谨慎:空依赖的useEffect里,尽量避免直接引用 state/props,改用函数式更新;
  3. 依赖数组要写全:如果必须依赖外部变量,一定要把变量加入依赖数组(配合 ESLint 的react-hooks/exhaustive-deps规则)。

React + Ts eslint配置

作者 SsunmdayKT
2026年1月13日 18:32

你现在需要的是 React + TypeScript 项目中配置 ESLint 所需的 npm 包,以及对应的安装和配置方法,我会按照 Vue3+TS 相同的清晰逻辑为你讲解。

一、核心依赖包(分基础和 React/TS 适配)

React + TS 项目的 ESLint 依赖同样分为基础核心包适配 React/TS 的插件包,以下是完整列表及作用:

包名 作用
eslint ESLint 核心库,提供代码检查基础能力
@typescript-eslint/eslint-plugin TypeScript 专属 ESLint 规则插件
@typescript-eslint/parser ESLint 解析 TS 代码的解析器
eslint-plugin-react React 专属 ESLint 插件(支持 React 18+)
eslint-plugin-react-hooks 检查 React Hooks 使用规范(如依赖项、规则 Hooks 调用)
eslint-plugin-react-refresh(可选) 检查 React 组件热更新相关规范(Vite 项目推荐)
eslint-config-prettier(可选) 禁用 ESLint 中与 Prettier 冲突的规则
eslint-plugin-prettier(可选) 将 Prettier 规则集成到 ESLint 中

二、安装命令

1. 基础安装(仅 ESLint + React + TS)

bash

运行

# npm 安装
npm install eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-react eslint-plugin-react-hooks --save-dev

# yarn 安装
yarn add eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-react eslint-plugin-react-hooks -D

# pnpm 安装(推荐)
pnpm add eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-react eslint-plugin-react-hooks -D

2. 包含 Prettier + 热更新检查(推荐)

如果需要 Prettier 格式化 + React 热更新检查,补充安装:

bash

运行

pnpm add prettier eslint-config-prettier eslint-plugin-prettier eslint-plugin-react-refresh --save-dev

三、核心配置(.eslintrc.js)

在项目根目录创建 .eslintrc.js 文件,这是 React + TS 最常用的配置模板:

javascript

运行

module.exports = {
  // 指定代码运行环境,启用对应全局变量
  env: {
    browser: true, // 浏览器环境(React 运行环境)
    es2021: true,  // 支持 ES2021 语法
    node: true     // Node.js 环境(如配置文件、脚本)
  },
  // 继承已有规则集,减少重复配置
  extends: [
    'eslint:recommended', // ESLint 官方推荐规则
    'plugin:@typescript-eslint/recommended', // TS 推荐规则
    'plugin:react/recommended', // React 推荐规则
    'plugin:react/jsx-runtime', // 适配 React 17+ 的 JSX 自动导入(无需手动 import React)
    'plugin:react-hooks/recommended', // React Hooks 强制规则
    'eslint-config-prettier' // 禁用与 Prettier 冲突的规则(装了 Prettier 才加)
    // 'plugin:prettier/recommended' // 开启 Prettier 作为 ESLint 规则(装了 eslint-plugin-prettier 才加)
  ],
  // 指定解析器(TS 解析器)
  parser: '@typescript-eslint/parser',
  // 解析器选项
  parserOptions: {
    ecmaVersion: 'latest', // 支持最新 ES 版本
    sourceType: 'module',  // 模块化代码(ES Module)
    ecmaFeatures: {
      jsx: true // 支持 JSX 语法(React 核心)
    }
  },
  // 启用的插件
  plugins: [
    '@typescript-eslint',
    'react',
    'react-hooks',
    'react-refresh' // 可选,热更新检查
  ],
  // 自定义规则(按需调整)
  rules: {
    // 关闭 TS any 类型禁止规则(新手项目可临时关闭)
    '@typescript-eslint/no-explicit-any': 'off',
    // React Hooks 必选规则(强制检查依赖项)
    'react-hooks/rules-of-hooks': 'error',
    'react-hooks/exhaustive-deps': 'warn',
    // 禁用 React 组件文件名必须 PascalCase 的检查(可选)
    'react/filename-rules': 'off',
    // 关闭 React 必须声明 props 类型的检查(TS 已做类型检查,无需重复)
    'react/prop-types': 'off',
    // 热更新检查:禁止默认导出(React 组件推荐命名导出)
    'react-refresh/only-export-components': [
      'warn',
      { allowConstantExport: true }
    ]
  },
  // 针对 React 版本的配置(自动检测)
  settings: {
    react: {
      version: 'detect'
    }
  }
}

四、补充配置(可选但推荐)

  1. 忽略文件(.eslintignore) :指定 ESLint 不检查的文件 / 目录

plaintext

node_modules/
dist/
build/
*.d.ts
.vscode/
  1. package.json 脚本:添加检查 / 修复命令

json

{
  "scripts": {
    "lint": "eslint . --ext .js,.jsx,.ts,.tsx", // 检查所有 React/TS 文件
    "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix" // 自动修复可修复问题
  }
}

五、特殊说明(针对 Create React App 项目)

如果你的 React 项目是通过 create-react-app 创建的(内置了 ESLint),无需手动安装核心包,只需:

  1. 安装缺失的适配包:

bash

运行

pnpm add eslint-plugin-react-hooks @typescript-eslint/eslint-plugin @typescript-eslint/parser --save-dev
  1. 在项目根目录创建 .eslintrc.js 覆盖默认配置即可。

总结

  1. 核心必装包eslint + @typescript-eslint/*(解析器 + 插件) + eslint-plugin-react + eslint-plugin-react-hooks
  2. 可选扩展eslint-plugin-react-refresh(热更新)、eslint-config-prettier/eslint-plugin-prettier(兼容 Prettier);
  3. 关键配置.eslintrc.js 中需启用 jsx: true 支持 React 语法,通过 settings 自动检测 React 版本,同时开启 React Hooks 核心规则。

安装完成后运行 npm run lint 即可检查代码,npm run lint:fix 可自动修复缩进、空格等格式问题。

每组件(Per-Component)与集中式(Centralized)i18n

2026年1月13日 18:20

每组件(per-component)方法并非新概念。例如,在 Vue 生态系统中,vue-i18n 支持 SFC i18n(单文件组件)。Nuxt 也提供 按组件翻译,Angular 通过其 Feature Modules 采用类似的模式。

即使在 Flutter 应用中,我们也常能发现这样的模式:

lib/
└── features/
    └── login/
        ├── login_screen.dart
        └── login_screen.i18n.dart  # <- 翻译存放在这里
import 'package:i18n_extension/i18n_extension.dart';

extension Localization on String {
  static var _t = Translations.byText("en") +
      {
        "Hello": {
          "en": "Hello",
          "fr": "Bonjour",
        },
      };

  String get i18n => localize(this, _t);
}

然而在 React 领域,我们主要看到不同的做法,我会将它们分为三类:

集中式方法(i18next、next-intl、react-intl、lingui)

  • (无命名空间)将内容视为单一来源进行检索。默认情况下,当应用加载时,会从所有页面加载内容。

细粒度方法 (intlayer, inlang)

  • 按键或按组件对内容检索进行细化。

在本博文中,我不会专注于基于编译器的解决方案,我已经在这里覆盖过:Compiler vs Declarative i18n. 注意,基于编译器的 i18n(例如 Lingui)只是自动化了内容的提取和加载。在底层,它们通常与其他方法共享相同的限制。

注意,你越细化内容的检索方式,就越有可能将额外的 state 和逻辑插入到组件中。

细粒度方法比集中式方法更灵活,但这通常是一种权衡。即使这些 libraries 宣称支持 "tree shaking",在实际中,你通常仍会以每种语言加载整个页面。

所以,概括来说,决策大致可以分为:

  • 如果你的应用页面数量多于语言数量,应优先采用细粒度方法。
  • 如果语言数量多于页面数量,则应倾向于集中式方法。

当然,library 的作者们也意识到这些限制并提供了解决方案。 其中包括:将内容拆分为命名空间、动态加载 JSON 文件(await import()),或在构建时剔除内容。

与此同时,你应该知道,当你动态加载内容时,会向服务器引入额外的请求。每多一个 useState 或 hook,就意味着一次额外的服务器请求。

为了解决这一点,Intlayer 建议将多个内容定义分组到同一个键下,Intlayer 会合并这些内容。

但综观这些解决方案,最流行的方法显然是集中式的。

那么为什么集中式方法如此受欢迎?

  • 首先,i18next 是第一个被广泛采用的解决方案,其理念受 PHP 和 Java 架构(MVC)的启发,依赖于严格的关注点分离(将内容与代码分离)。它于 2011 年出现,在向基于组件的架构(如 React)大规模转变之前就确立了其标准。
  • 此外,一旦某个库被广泛采用,就很难将生态系统转向其他模式。
  • 在 Crowdin、Phrase 或 Localized 等翻译管理系统中,使用集中式方法也更为方便。
  • 按组件(per-component)方法背后的逻辑比集中式更复杂,开发需要更多时间,尤其是在需要解决诸如识别内容位置等问题时。

好的,但为什么不直接坚持集中式方法?

让我告诉你这对你的应用可能会带来哪些问题:

  • 未使用的数据: 当一个页面加载时,你通常会加载来自所有其他页面的内容。(在一个 10 页的应用中,那就是 90% 未使用的内容被加载)。你懒加载一个 modal?i18n 库并不在意,它反正会先加载这些字符串。
  • 性能: 每次重新渲染时,你的每个组件都会被一个巨大的 JSON payload 进行 hydrate,这会随着应用增长影响其响应性。
  • 维护: 维护大型 JSON 文件很痛苦。你必须在文件之间来回跳转以插入翻译,确保没有翻译缺失且没有孤立的 key 留下。
  • 设计系统: 这会导致与设计系统不兼容(例如,LoginForm 组件),并限制在不同应用之间复制组件的能力。

“但我们发明了 Namespaces!”

当然,这确实是一个巨大的进步。下面对比一下在 Vite + React + React Router v7 + Intlayer 配置下主 bundle 大小的差异。我们模拟了一个 20 页的应用。

第一个示例没有为每个 locale 进行懒加载翻译,也没有进行命名空间拆分。第二个示例则包含内容清理(purging)和翻译的动态加载。

已优化的 bundle 未优化的 bundle
bundle_no_optimization.png

bundle.png

因此,多亏了 namespaces,我们将结构从以下形式迁移:

locale/
├── en.json
├── fr.json
└── es.json

到这个结构:

locale/
├── en/
│   ├── common.json
│   ├── navbar.json
│   ├── footer.json
│   ├── home.json
│   └── about.json
├── fr/
│   └── ...
└── es/
    └── ...

现在你必须精细地管理应用的哪些内容应该被加载,以及在何处加载它们。总之,由于复杂性,绝大多数项目都会跳过这一步(例如参见 [next-i18next 指南](intlayer.org/zh/blog/nex…) 来了解仅仅遵循良好实践也会带来哪些挑战)。因此,这些项目最终会遇到前面解释的庞大 JSON 加载问题。

注意,这个问题并非 i18next 所特有,而是上述所有集中式方法共有的问题。

然而,我想提醒你,并非所有细粒度方法都能解决这个问题。例如,vue-i18n SFCinlang 的做法并不会本质上为每个语言环境按需懒加载翻译,因此你只是将捆绑包大小的问题换成了另一个问题。

此外,如果没有适当的关注点分离,就更难将翻译内容提取并提供给译者进行审核。

Intlayer 的按组件方法如何解决这个问题

Intlayer 通过以下几个步骤来处理:

  1. 声明: 在代码库的任何位置使用 *.content.{ts|jsx|cjs|json|json5|...} 文件声明你的内容。这既确保了关注点分离,又保持内容与组件同处一处。内容文件可以是针对单一语言的,也可以是多语言的。
  2. Processing: Intlayer 在构建步骤中运行,用于处理 JS 逻辑、处理缺失的翻译回退、生成 TypeScript 类型、管理重复内容、从你的 CMS 获取内容,等等。
  3. Purging: 当你的应用构建时,Intlayer 会清除未使用的内容(有点像 Tailwind 管理你的类的方式),通过如下方式替换内容:

Declaration:

// src/MyComponent.tsx
export const MyComponent = () => {
  const content = useIntlayer("my-key");
  return <h1>{content.title}</h1>;
};
// src/myComponent.content.ts
export const {
  key: "my-key",
  content: t({
    zh: { title: "我的标题" },
    en: { title: "My title" },
    fr: { title: "Mon titre" }
  })
}

Processing: Intlayer builds the dictionary based on the .content file and generates:

// .intlayer/dynamic_dictionary/zh/my-key.json(翻译后的 JSON 文件示例)
{
  "key": "my-key",
  "content": { "title": "我的标题" },
}

替换: Intlayer 在应用构建期间转换你的组件。

- 静态导入模式:

// 在类 JSX 语法中的组件表示
export const MyComponent = () => {
  const content = useDictionary({
    key: "my-key",
    content: {
      nodeType: "translation",
      translation: {
        zh: { title: "我的标题" },
        en: { title: "My title" },
        fr: { title: "Mon titre" },
      },
    },
  });

  return <h1>{content.title}</h1>;
};

- 动态导入模式:

// 在类 JSX 语法中的组件表示
export const MyComponent = () => {
  const content = useDictionaryAsync({
    en: () =>
      import(".intlayer/dynamic_dictionary/en/my-key.json", {
        with: { type: "json" },
      }).then((mod) => mod.default),
    // Same for other languages
  });

  return <h1>{content.title}</h1>;
};

useDictionaryAsync 使用类似 Suspense 的机制,仅在需要时加载本地化的 JSON。

此按组件方法的主要优点:

  • 将内容声明与组件放在一起可以提高可维护性(例如:将组件移动到另一个应用或 design system。删除组件文件夹时也会同时移除相关内容,就像你通常对 .test.stories 所做的那样)

  • 以组件为单位的方法可以防止 AI 代理需要在你所有不同的文件之间来回跳转。它将所有翻译集中在一个地方,限制了任务的复杂性以及使用的 tokens 数量。

限制

当然,这种方法有其权衡:

  • 更难与其他 l10n 系统和额外的工具链对接。
  • 会产生锁定(lock-in)问题(这在任何 i18n 解决方案中基本都存在,因为它们有特定的语法)。

这就是 Intlayer 试图为 i18n 提供完整工具集(100% 免费且 OSS)的原因,包括使用你自己的 AI Provider 和 API 密钥进行 AI 翻译的功能。Intlayer 还提供用于同步你的 JSON 的工具,类似于 ICU / vue-i18n / i18next 的消息格式化器,用以将内容映射到它们的特定格式。

我很乐意听到你对它的真实反馈。你的反对意见真的有助于打造一个更好的产品。这是不是有点过度设计了?还是它会成为下一个 Tailwind?

Generator 函数

作者 秋子aria
2026年1月13日 18:19

 1.核心知识点 总结

  1. Generator 是「分段执行的函数」,function* 声明,yield 暂停,next() 恢复执行
  2. yield 是暂停标记 + 返回值,yield* 是遍历器委托,用于调用其他生成器 / 可遍历结构
  3. next(参数) 可以给「上一个 yield」传值,首次传参无效
  4. Generator 返回遍历器,可被 for...of 遍历,return() 强制终止遍历
  5. 核心优势:无全局变量污染、保存执行状态、外部灵活控制内部逻辑,是 ES6 异步编程的重要方案

2.什么是 Generator 函数

在Javascript中,一个函数一旦开始执行,就会运行到最后或遇到return时结束,运行期间不会有其它代码能够打断它,也不能从外部再传入值到函数体内

Generator函数(生成器)的出现使得打破函数的完整运行成为了可能,其语法行为与传统函数完全不同

Generator函数是ES6提供的一种异步编程解决方案,形式上也是一个普通函数,但有几个显著的特征:

-- function关键字与函数名之间有一个星号 "*" (推荐紧挨着function关键字)
-- 函数体内使用 yield 表达式,定义不同的内部状态 (可以有多个yield)
-- 直接调用 Generator函数并不会执行,也不会返回运行结果,而是返回一个遍历器对象(Iterator Object)
-- 依次调用遍历器对象的next方法,遍历 Generator函数内部的每一个状态

2.1 传统函数和Generator函数区别

{
  // 传统函数
  function foo() {
    return 'hello world'
  }

  foo()   // 'hello world',一旦调用立即执行


  // Generator函数
  function* generator() {
    yield 'status one'         // yield 表达式是暂停执行的标记  
    return 'hello world'
  }

  let iterator = generator()   
  // 调用 Generator函数,函数并没有执行,返回的是一个Iterator对象
  iterator.next()              
  // {value: "status one", done: false},value 表示返回值,done 表示遍历还没有结束
  iterator.next()              
  // {value: "hello world", done: true},value 表示返回值,done 表示遍历结束
}

2.2 Generator函数详解

{
  function* gen() {
    //定义了一个 Generator函数,其中包含两个 yield 表达式和一个 return 语句(即产生了三个状态)
    yield 'hello'
    yield 'world'
    return 'ending'
  }

  let it = gen()

  it.next()   // {value: "hello", done: false}
  it.next()   // {value: "world", done: false}
  it.next()   // {value: "ending", done: true}
  it.next()   // {value: undefined, done: true}
}

每次调用Iterator对象的next方法时,内部的指针就会从函数的头部或上一次停下来的地方开始执行,直到遇到下一个 yield 表达式或return语句暂停。换句话说,Generator 函数是分段执行的,yield表达式是暂停执行的标记,而 next方法可以恢复执行

执行过程如下:

第一次调用next方法时,内部指针从函数头部开始执行,遇到第一个 yield 表达式暂停,并返回当前状态的值 'hello'

第二次调用next方法时,内部指针从上一个(即第一个) yield 表达式开始,遇到第二个 yield 表达式暂停,返回当前状态的值 'world'

第三次调用next方法时,内部指针从第二个 yield 表达式开始,遇到return语句暂停,返回当前状态的值 'ending',同时所有状态遍历完毕,done 属性的值变为true

第四次调用next方法时,由于函数已经遍历运行完毕,不再有其它状态,因此返回 {value: undefined, done: true}。如果继续调用next方法,返回的也都是这个值

3.yield 表达式

(1)、yield 表达式只能用在 Generator 函数里面,用在其它地方都会报错

{
  (function (){
    yield 1;
  })()

  // SyntaxError: Unexpected number
  // 在一个普通函数中使用yield表达式,结果产生一个句法错误
}

(2)、yield 表达式如果用在另一个表达式中,必须放在圆括号里面

{
  function* demo() {
    console.log('Hello' + yield); // SyntaxError
    console.log('Hello' + yield 123); // SyntaxError

    console.log('Hello' + (yield)); // OK
    console.log('Hello' + (yield 123)); // OK
  }
}

(3)、yield 表达式用作参数或放在赋值表达式的右边,可以不加括号

{
  function* demo() {
    foo(yield 'a', yield 'b'); // OK
    let input = yield; // OK
  }
}

(4)、yield 表达式和return语句的区别

相似:都能返回紧跟在语句后面的那个表达式的值

区别:
-- 每次遇到 yield,函数就暂停执行,下一次再从该位置继续向后执行;而 return 语句不具备记忆位置的功能
-- 一个函数只能执行一次 return 语句,而在 Generator 函数中可以有任意多个 yield

4. return()throw() 方法

Generator 对象除了 next(),还有两个方法用于主动控制执行:

return(value)

  • 作用:立即终止 Generator 函数,返回 { value: 传入值, done: true }
  • 后续再调用 next(),仅返回 { value: undefined, done: true }
function* gen() {
  yield 1;
  yield 2;
}
const g = gen();
console.log(g.next()); // { value:1, done:false }
console.log(g.return('终止')); // { value:'终止', done:true }
console.log(g.next()); // { value:undefined, done:true }

转存失败,建议直接上传图片文件

throw(error)

  • 作用:在当前暂停点抛出一个错误,若函数内未捕获,错误会向外传播;
  • 若函数内用 try/catch 捕获错误,函数会继续执行,直到下一个 yield
function* gen() {
  try {
    yield 1;
  } catch (e) {
    console.log('捕获错误:', e); // 捕获 throw() 抛出的错误
  }
  yield 2;
}
const g = gen();
console.log(g.next()); // { value:1, done:false }
g.throw(new Error('手动抛错')); // 输出:捕获错误:Error: 手动抛错
console.log(g.next()); // { value:2, done:false }

转存失败,建议直接上传图片文件

5.yield* 表达式

如果在 Generator 函数里面调用另一个 Generator 函数,默认情况下是没有效果的

{
  function* foo() {
    yield 'aaa'
    yield 'bbb'
  }

  function* bar() {
    foo()
    yield 'ccc'
    yield 'ddd'
  }

  let iterator = bar()

  for(let value of iterator) {
    console.log(value)
  }

  // ccc
  // ddd

}

转存失败,建议直接上传图片文件

上例中,使用 for...of 来遍历函数bar的生成的遍历器对象时,只返回了bar自身的两个状态值。此时,如果想要正确的在bar 里调用foo,就需要用到 yield* 表达式

yield 表达式用来在一个 Generator 函数里面 执行 另一个 Generator 函数*

{
  function* foo() {
    yield 'aaa'
    yield 'bbb'
  }

  function* bar() {
    yield* foo()      // 在bar函数中 **执行** foo函数
    yield 'ccc'
    yield 'ddd'
  }

  let iterator = bar()

  for(let value of iterator) {
    console.log(value)
  }

  // aaa
  // bbb
  // ccc
  // ddd
}

6.next() 方法的参数

yield表达式本身没有返回值,或者说总是返回undefined。next方法可以带一个参数,该参数就会被当作上一个yield表达式的返回值

 [rv] = yield [expression]

expression:定义通过遍历器从生成器函数返回的值,如果省略,则返回 undefined
rv:接收从下一个 next() 方法传递来的参数

例子,并尝试解析遍历生成器函数的执行过程

{
  function* gen() {
    let result = yield 3 + 5 + 6
    console.log(result)
    yield result
  }

  let it = gen()
  console.log(it.next())      // {value: 14, done: false}
  console.log(it.next())      // undefined    {value: undefined, done: false}
}

第一次调用遍历器对象的next方法,函数从头部开始执行,遇到第一个 yield 暂停,在这个过程中其实是分了三步:

(1)、声明了一个变量result,并将声明提前,默认值为 undefined
(2)、由于 Generator函数是 “惰性求值”,执行到第一个 yield 时才会计算求和,并加计算结果返回给遍历器对象 {value: 14, done: false},函数暂停运行
(3)、理论上应该要把等号右边的 [yield 3 + 5 + 6] 赋值给变量result,但是, 由于函数执行到 yield 时暂定了,这一步就被挂起了

第二次调用next方法,函数从上一次 yield 停下的地方开始执行,也就是给result赋值的地方开始,由于next()并没有传参,就相当于传参为undefined

基于以上分析,就不难理解为什么说 yield表达式本身的返回值(特指 [rv])总是undefined了。现在把上面的代码稍作修改,第二次调用 next() 方法传一个参数3,按照上图分析可以很快得出输出结果

{
  function* gen() {
    let result = yield 3 + 5 + 6
    console.log(result)
    yield result
  }

  let it = gen()
  console.log(it.next())      // {value: 14, done: false}
  console.log(it.next(3))      // 3    {value: 3, done: false}
}

如果第一次调用next()的时候也传了一个参数呢?这个当然是无效的,next方法的参数表示上一个yield表达式的返回值,所以在第一次使用next方法时,传递参数是无效的。

从语义上讲,第一个next方法用来启动遍历器对象,所以不用带有参数。

{
  function* gen() {
    let result = yield 3 + 5 + 6
    console.log(result)
    yield result
  }

  let it = gen()
  console.log(it.next(10))      // {value: 14, done: false}
  console.log(it.next(3))      // 3    {value: 3, done: false}
}

Generator 函数从暂停状态到恢复运行,它的上下文状态(context)是不变的。通过next方法的参数,就有办法在 Generator 函数开始运行之后,继续向函数体内部注入值。 也就是说,可以在 Generator 函数运行的不同阶段,从外部向内部注入不同的值,从而调整函数行为。

{
  function* gen(x) {
    let y = 2 * (yield (x + 1))   
    // 注意:yield 表达式如果用在另一个表达式中,必须放在圆括号里面
    let z = yield (y / 3)
    return x + y + z
  }

  let it = gen(5)
  /* 通过前面的介绍就知道这部分输出结果是错误的啦
    
    console.log(it.next())  // {value: 6, done: false}
    console.log(it.next())  // {value: 2, done: false}
    console.log(it.next())  // {value: 13, done: false}
  */

  /*** 正确的结果在这里 ***/
  console.log(it.next())  
  // 首次调用next,函数只会执行到 “yield(5+1)” 暂停,并返回 {value: 6, done: false}
  console.log(it.next())  
  // 第二次调用next,没有传递参数,
  //所以 y的值是undefined,那么 y/3 当然是一个NaN,所以应该返回 {value: NaN, done: false}
  console.log(it.next())  
  // 同样的道理,z也是undefined,6 + undefined + undefined = NaN,
  //返回 {value: NaN, done: true}
}

如果向next方法提供参数,返回结果就完全不一样了

{
  function* gen(x) {
    let y = 2 * (yield (x + 1))   
    // 注意:yield 表达式如果用在另一个表达式中,必须放在圆括号里面
    let z = yield (y / 3)
    return x + y + z
  }

  let it = gen(5)

  console.log(it.next())  
  // 正常的运算应该是先执行圆括号内的计算,再去乘以2,
  //由于圆括号内被 yield 返回 5 + 1 的结果并暂停,所以返回{value: 6, done: false}
  console.log(it.next(9))  
  // 上次是在圆括号内部暂停的,所以第二次调用 next方法应该从圆括号里面开始,
  //就变成了 let y = 2 * (9),y被赋值为18,
  //所以第二次返回的应该是 18/3的结果 {value: 6, done: false}
  console.log(it.next(2))  
  // 参数2被赋值给了 z,最终 x + y + z = 5 + 18 + 2 = 25,返回 {value: 25, done: true}
}



{
  function* gen(x) {
    let y = 2 * (yield (x + 1))   
    let z = yield (y / 3)
    z = 88    // 注意看这里
    return x + y + z
  }

  let it = gen(5)

  console.log(it.next())   // {value: 6, done: false}
  console.log(it.next(9))  // {value: 6, done: false}
  console.log(it.next(2))  // 这里其实也很容易理解,参数2被赋值给了 z,但是函数体内又给 z 重新赋值为88, 最终 x + y + z = 5 + 18 + 88 = 111,返回 {value: 111, done: true}
}

7.Generator函数与 Iterator 接口的关系

7.1Generator 函数的核心用途之一是简化迭代器的创建

  • Generator 对象本身就是一个迭代器(实现了 Symbol.iterator 方法,且返回自身);
  • 普通迭代器需要手动实现 next() 方法和状态管理,而 Generator 用 yield 即可自动实现迭代逻辑。

(1). 手动实现迭代器(繁琐)

// 手动创建一个迭代器,生成 1~3 的数字
const iterator = {
  count: 1,
  next() {
    if (this.count <= 3) {
      return { value: this.count++, done: false };
    } else {
      return { value: undefined, done: true };
    }
  },
  [Symbol.iterator]() { return this; } // 实现可迭代协议
};

// 迭代
for (const val of iterator) {
  console.log(val); // 1, 2, 3
}

(2). Generator 实现迭代器(简洁)

// Generator 自动生成迭代器
function* numberGenerator() {
  yield 1;
  yield 2;
  yield 3;
}

// 迭代(支持 for...of,因为 Generator 对象是可迭代的)
for (const val of numberGenerator()) {
  console.log(val); // 1, 2, 3
}

7.2 Iterator(迭代器)

JavaScript原有的表示集合的数据结构有数组(Array)和对象(Object),ES6又添加了Map和Set。这样就有了4种数据集合,此时便需要****一种统一的接口机制来处理不同的数据结构

ES6 规定,默认的 Iterator 接口部署在数据结构的Symbol.iterator属性,或者说,一个数据结构只要具有Symbol.iterator属性,就可以认为是“可遍历的”(iterable)。

Symbol.iterator属性本身是一个函数,就是当前数据结构默认的遍历器生成函数。执行这个函数,就会返回一个遍历器。

传统对象没有原生部署 Iterator接口,不能使用 for...of 和 扩展运算符,现在通过给对象添加 Symbol.iterator 属性和对应的遍历器生成函数,就可以使用了

Iterator对象

  1. Iterator就是这样一个统一的接口。任何数据结构,主要部署Iterator接口,就可以完成遍历
  2. Iterator接口主要供for...of使用(ES6创造的新的遍历命令),当使用for...of循环时,该循环会自动寻找Iterator接口
  3. Iterator对象本质上是一个指针对象。(创建时指向数据结构头部,依次调用next()方法后指针会移动,依次指向第1,2,3...个成员,最后指向结束位置)

默认迭代器

const obj = {//obj具有Symbol.iterator(它是一个方法),因此是可遍历的
  [Symbol.iterator]:function(){
    return {
      next:function(){
        return {
          value:1,
          done:true
        }
      }
    }
  }
}

ES6的有些数据结构(数组)原生部署了Symbol.iterator属性(称为部署了 遍历器接口 ),即不用任何处理就可以被for...of循环。另外一些数据结构(对象)没有。
以下数据结构原生部署Iterator接口:也就是说这些都可以使用for...of。除了这些,其他数据结构(如对象)的Iterator接口需要自己在Symbol.iterator属性上面部署,才会被for...of遍历。

  • Map
  • Set
//NodeList对象//数组的默认迭代器:
let color = ['red','yellow','blue']
let arrIt = colorSymbol.iterator;//返回一个迭代器
arrIt.next()//{value:'red',done:false}
//类数组arguments的默认迭代器:
function fn(){
let argsIt = argumentsSymbol.iterator;
argsIt.next()
}
//类数组dom节点的默认迭代器:
let myP = document.getElementsByTagName('li');
let pIt = myPSymbol.iterator;
pIt.next();
//字符串的默认迭代器:
let str = 'dhakjda';
let strIt = strSymbol.iterator;
strIt.next();
//对象没有默认(即内置)迭代器:obj[Symbol.iterator] is not a function

8.for...of 循环

由于 Generator 函数运行时生成的是一个 Iterator 对象,因此,可以直接使用 for...of 循环遍历,且此时无需再调用 next() 方法

这里需要注意,一旦 next() 方法的返回对象的 done 属性为 true,for...of 循环就会终止,且不包含该返回对象

{
  function* gen() {
    yield 1
    yield 2
    yield 3
    yield 4
    return 5
  }

  for(let item of gen()) {
    console.log(item)
  }

  // 1 2 3 4
}

9.Generator 的典型应用场景

9.1 异步编程(ES6 时代的方案)

Generator 是 async/await 的 “前身” ,通过 yield 暂停异步操作,next() 恢复执行,解决了回调地狱问题。需配合自动执行器(如 co 库)使用:

// 模拟异步请求
function fetchData(url) {
  return new Promise(resolve => {
    setTimeout(() => resolve(`数据:${url}`), 1000);
  });
}

// Generator 函数封装异步逻辑
function* asyncGenerator() {
  const data1 = yield fetchData('url1');
  console.log(data1); // 1秒后输出:数据:url1
  const data2 = yield fetchData('url2');
  console.log(data2); // 再1秒后输出:数据:url2
}

// 手动执行(实际用 co 库自动执行)
const gen = asyncGenerator();
gen.next().value.then(data1 => {
  gen.next(data1).value.then(data2 => {
    gen.next(data2);
  });
});
//.then(data1 => {}) → 是 Promise 的异步回调,等 1 秒后,异步请求完成,
//Promise 的 resolve 值是 数据:url1,这个值会被自动传给回调函数的形参 data1;

注:ES7 引入的 async/await 是 Generator + Promise 的语法糖,更简洁易用,现在已替代 Generator 成为主流异步方案

9.2 生成无限序列(惰性求值)

Generator 支持 “按需生成” 数据,不会一次性创建所有数据,适合处理无限序列(如斐波那契数列)或大数据集:

// 生成无限斐波那契数列
function* fibonacci() {
  let a = 0, b = 1;
  while (true) {
    yield a;
    [a, b] = [b, a + b];
  }
}

const fib = fibonacci();
console.log(fib.next().value); // 0
console.log(fib.next().value); // 1
console.log(fib.next().value); // 1
console.log(fib.next().value); // 2
console.log(fib.next().value); // 3
// 按需获取,不会占用大量内存

9.3 控制流管理

通过 yield 可以灵活控制函数执行顺序,适合复杂的流程控制(如分步执行、条件分支):

function* taskFlow() {
  console.log('任务1');
  yield; // 暂停,等待外部触发下一步
  console.log('任务2');
  const flag = yield '是否执行任务3?'; // 产出询问,接收外部决策
  if (flag) {
    console.log('任务3执行');
  } else {
    console.log('任务3跳过');
  }
}

const flow = taskFlow();
flow.next(); // 任务1 → { value: undefined, done: false }
const res = flow.next(); // 任务2 → { value: '是否执行任务3?', done: false }
flow.next(true); // 任务3执行 → { value: undefined, done: true }

最后

这是《JavaScript系列》第8篇,将持续更新。

小伙伴如果喜欢我的分享,可以动动您发财的手关注下我,我会持续更新的!!!
您对我的关注、点赞和收藏,是对我最大的支持!欢迎关注、评论、讨论和指正!

🔥 Vue 3 项目深度优化之旅:从 787KB 到极致性能

2026年1月13日 18:14

当你以为优化已经结束,真正的挑战才刚刚开始

🎬 序章:优化永无止境

还记得上次我们把构建时间从 35 秒优化到 21 秒,把 vendor 包从 227 KB 压缩到 157 KB 的故事吗?

那时候我以为优化工作已经完成了,直到我看到了这个数字:

element-plus-jMvik2ez.js    787.16 KB  (Gzip: 241.53 KB)

787 KB! 一个 UI 库就占了整个项目 40% 的体积!

这就像你辛辛苦苦减肥成功,结果发现衣柜里还藏着一堆 XXL 的衣服。是时候来一次"断舍离"了。

🔍 第一步:侦探工作 - 找出真凶

工具准备

# 生成包体积分析报告
VITE_ANALYZE=true npm run build:dev

# 打开 dist/stats.html
open dist/stats.html

打开报告的那一刻,我惊呆了:

📦 包体积分布
├─ element-plus (787 KB) 👈 占比 40.8% 🔴
├─ vendor (157 KB)       👈 占比 8.1%  🟢
├─ framework (180 KB)    👈 占比 9.4%  🟢
├─ main (153 KB)         👈 占比 7.9%  🟢
└─ others (651 KB)       👈 占比 33.8% 🟡

Element Plus 一家独大,比其他所有第三方库加起来还要大!

深入调查

让我们看看项目到底用了哪些 Element Plus 组件:

# 搜索所有 Element Plus 组件的使用
grep -r "from 'element-plus'" src/

结果让人意外:

// 实际使用的组件(15 个)
ElMessage          // 消息提示
ElNotification     // 通知
ElMessageBox       // 确认框
ElDialog           // 对话框
ElButton           // 按钮
ElTable            // 表格
ElCheckbox         // 复选框
ElUpload           // 上传
ElIcon             // 图标
ElPopover          // 弹出框
ElScrollbar        // 滚动条
ElCollapseTransition // 折叠动画
ElTour, ElTourStep // 引导
ElTag              // 标签
ElConfigProvider   // 全局配置

// Element Plus 提供的组件(80+ 个)
ElCalendar         // ❌ 未使用
ElDatePicker       // ❌ 未使用
ElTimePicker       // ❌ 未使用
ElCascader         // ❌ 未使用
ElTree             // ❌ 未使用
ElTransfer         // ❌ 未使用
// ... 还有 60+ 个未使用的组件

真相大白: 我们只用了 15 个组件,却打包了 80+ 个组件!

这就像去超市买一瓶水,结果收银员说:"不好意思,我们只卖整箱。"

💡 第二步:制定作战计划

方案 A:手术刀式精准切除

思路: 手动导入需要的组件,排除不需要的

// build/plugins.ts
Components({
  resolvers: [
    ElementPlusResolver({
      importStyle: 'sass',
      directives: false,
      // 排除未使用的大型组件
      exclude: /^El(Calendar|DatePicker|TimePicker|Cascader|Tree|Transfer)$/,
    }),
  ],
})

优点:

  • 精准控制
  • 风险可控
  • 易于维护

缺点:

  • 需要手动维护排除列表
  • 可能遗漏某些组件

预期效果: 减少 100-150 KB

方案 B:CSS 瘦身计划

问题: Element Plus CSS 也有 211 KB

element-plus.css    210.92 KB  (Gzip: 26.43 KB)

思路: 使用更高效的 CSS 压缩工具

// vite.config.ts
export default defineConfig({
  build: {
    cssMinify: 'lightningcss',  // 比 esbuild 更快更小
  },
})

lightningcss vs esbuild:

指标 esbuild lightningcss 提升
压缩率 87.5% 90.2% ↑ 3.1%
速度 更快 ↑ 20%
兼容性 更好

预期效果: 减少 30-50 KB

方案 C:图片"减肥"大作战

发现问题:

ls -lh dist/assets/webp/

-rw-r--r--  login-bg-line.webp     5.37 KB  ✅ 合理
-rw-r--r--  empty.webp             8.50 KB  ✅ 合理
-rw-r--r--  cargo-ship.webp       13.78 KB  ✅ 合理
-rw-r--r--  logo.webp             14.46 KB  ✅ 合理
-rw-r--r--  login-bg2.webp       267.07 KB  🔴 过大!

267 KB 的背景图! 这相当于 1.7 个 lodash 库的大小!

优化方案:

# 方案 1:压缩图片
npx sharp-cli \
  --input src/assets/images/login-bg2.webp \
  --output src/assets/images/login-bg2-optimized.webp \
  --webp-quality 80

# 结果:267 KB → 120 KB (减少 55%)
<!-- 方案 2:懒加载 -->
<template>
  <img
    v-lazy="loginBg"
    alt="Login Background"
    class="login-bg"
  />
</template>

<script setup lang="ts">
// 只在需要时加载
const loginBg = new URL('@/assets/images/login-bg2.webp', import.meta.url).href
</script>
// 方案 3:使用 CDN
// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      external: [/\.(png|jpe?g|gif|svg|webp)$/],
    },
  },
})

预期效果: 减少 200-300 KB

🎯 第三步:实战演练

优化 1:Element Plus 精准打击

实施前的准备

// 1. 创建组件使用清单
const usedComponents = [
  'ElMessage',
  'ElNotification',
  'ElMessageBox',
  'ElDialog',
  'ElButton',
  'ElTable',
  'ElCheckbox',
  'ElUpload',
  'ElIcon',
  'ElPopover',
  'ElScrollbar',
  'ElCollapseTransition',
  'ElTour',
  'ElTourStep',
  'ElTag',
  'ElConfigProvider',
]

// 2. 创建排除清单
const excludedComponents = [
  'ElCalendar',
  'ElDatePicker',
  'ElTimePicker',
  'ElCascader',
  'ElTree',
  'ElTransfer',
  'ElColorPicker',
  'ElRate',
  'ElSlider',
  'ElSwitch',
  // ... 更多未使用的组件
]

配置优化

// build/plugins.ts
AutoImport({
  resolvers: [
    ElementPlusResolver({
      // 只自动导入使用的 API
      exclude: /^El(Calendar|DatePicker|TimePicker)$/,
    }),
  ],
})

Components({
  resolvers: [
    ElementPlusResolver({
      importStyle: 'sass',
      directives: false,
      // 排除未使用的组件
      exclude: /^El(Calendar|DatePicker|TimePicker|Cascader|Tree|Transfer)$/,
    }),
  ],
})

验证效果

# 构建并分析
VITE_ANALYZE=true npm run build:dev

# 对比结果
Before: element-plus-xxx.js  787.16 KB (Gzip: 241.53 KB)
After:  element-plus-xxx.js  650.00 KB (Gzip: 195.00 KB)

# 减少:137 KB (17.4%)  🎉

优化 2:CSS 压缩升级

// vite.config.ts
export default defineConfig({
  build: {
    cssMinify: 'lightningcss',
  },
})
# 构建并对比
Before: element-plus.css  210.92 KB (Gzip: 26.43 KB)
After:  element-plus.css  210.92 KB (Gzip: 24.50 KB)

# 减少:1.93 KB (7.3%)  ✨

优化 3:图片压缩

# 压缩背景图
npx sharp-cli \
  --input src/assets/images/login-bg2.webp \
  --output src/assets/images/login-bg2.webp \
  --webp-quality 80

# 结果
Before: 267.07 KB
After:  120.00 KB

# 减少:147 KB (55%)  🚀

📊 第四步:战果统计

优化前后对比

指标 优化前 优化后 减少
Element Plus JS 787 KB 650 KB ↓ 137 KB (17%) 🎉
Element Plus CSS 211 KB 211 KB -
CSS (Gzip) 26.43 KB 24.50 KB ↓ 1.93 KB (7%)
背景图片 267 KB 120 KB ↓ 147 KB (55%) 🚀
总计减少 - - ↓ 286 KB 🎊

性能提升

指标 优化前 优化后 提升
首次加载 2.8s 2.2s ↓ 21% 👍
二次访问 0.8s 0.6s ↓ 25% 🚀
FCP 1.8s 1.4s ↓ 22%
LCP 2.5s 2.0s ↓ 20% 💨

用户体验提升

优化前的用户体验:
[========== 加载中 ==========] 2.8s
"怎么这么慢?" 😤

优化后的用户体验:
[====== 加载中 ======] 2.2s
"还不错!" 😊

🎓 第五步:经验总结

踩过的坑

坑 1:过度排除组件

问题:

// ❌ 错误:排除了实际使用的组件
exclude: /^El(Dialog|Button|Table)$/

结果: 页面报错,组件无法加载

解决:

// ✅ 正确:只排除确认未使用的组件
exclude: /^El(Calendar|DatePicker|TimePicker)$/

教训: 充分测试所有功能,确保没有遗漏

坑 2:CSS 压缩导致样式丢失

问题:

// ❌ 错误:使用 PurgeCSS 过度清理
new PurgeCSS().purge({
  content: ['./src/**/*.vue'],
  css: ['./node_modules/element-plus/dist/index.css'],
})

结果: 动态生成的样式被移除

解决:

// ✅ 正确:配置 safelist
new PurgeCSS().purge({
  content: ['./src/**/*.vue'],
  css: ['./node_modules/element-plus/dist/index.css'],
  safelist: {
    standard: [/^el-/],
    deep: [/^el-.*__/],
  },
})

教训: 保守优化,充分测试

坑 3:图片压缩过度

问题:

# ❌ 错误:质量设置过低
--webp-quality 50

结果: 图片模糊,用户体验差

解决:

# ✅ 正确:平衡质量和大小
--webp-quality 80

教训: 在质量和大小之间找平衡

最佳实践

1. 组件使用分析

// 创建组件使用清单
const componentUsage = {
  used: [
    'ElMessage',
    'ElDialog',
    // ...
  ],
  unused: [
    'ElCalendar',
    'ElDatePicker',
    // ...
  ],
}

// 定期审查
npm run analyze:components

2. 渐进式优化

第一阶段:低风险优化
├─ CSS 压缩 ✅
├─ 图片压缩 ✅
└─ 代码分割 ✅

第二阶段:中风险优化
├─ 组件排除 ⚠️
├─ CSS 清理 ⚠️
└─ 动态导入 ⚠️

第三阶段:高风险优化
├─ 替换大型库 🔴
├─ 自定义组件 🔴
└─ 深度定制 🔴

3. 持续监控

// package.json
{
  "scripts": {
    "analyze": "VITE_ANALYZE=true npm run build:dev",
    "size-limit": "size-limit",
    "lighthouse": "lighthouse https://your-domain.com --view"
  },
  "size-limit": [
    {
      "path": "dist/assets/js/element-plus-*.js",
      "limit": "200 KB"  // 设置预算
    }
  ]
}

🚀 第六步:展望未来

下一步优化方向

1. 考虑替代方案

Element Plus 的轻量级替代:

大小 组件数 优势
Element Plus 787 KB 80+ 功能完整
Naive UI 450 KB 80+ 更轻量
Arco Design 380 KB 60+ 性能好
自定义组件 100 KB 15 完全可控

权衡:

  • 迁移成本 vs 性能收益
  • 功能完整性 vs 包体积
  • 团队熟悉度 vs 学习成本

2. 微前端架构

// 按需加载子应用
const loadSubApp = async (name: string) => {
  const app = await import(`./apps/${name}/index.js`)
  return app.mount('#app')
}

// 只加载当前需要的功能
if (route.path.startsWith('/user')) {
  await loadSubApp('user-management')
}

优势:

  • 更细粒度的代码分割
  • 独立部署和更新
  • 更好的缓存策略

3. 边缘计算

// 使用 CDN 边缘节点
const CDN_BASE = 'https://cdn.example.com'

// 静态资源从 CDN 加载
const loadAsset = (path: string) => {
  return `${CDN_BASE}${path}`
}

优势:

  • 更快的加载速度
  • 减轻服务器压力
  • 全球加速

💰 ROI 分析

投入产出比

投入:

  • 分析时间:2 小时
  • 优化时间:3 小时
  • 测试时间:2 小时
  • 总计:7 小时

产出:

1. 性能提升

  • 包体积减少:286 KB
  • 加载速度提升:21-25%
  • 用户体验提升:显著

2. 成本节省

  • 带宽节省:286 KB × 10000 用户/月 = 2.8 GB/月
  • 服务器成本:约 $50/月
  • 年度节省:$600

3. 用户留存

  • 加载速度提升 → 跳出率降低 15%
  • 用户体验提升 → 留存率提升 10%
  • 潜在价值:难以估量

ROI = (600 + 无形价值) / (7 × 时薪) > 1000%

🎬 尾声:优化是一场马拉松

经过这次深度优化,我们实现了:

  1. Element Plus 瘦身 17%:从 787 KB 到 650 KB
  2. CSS 优化 7%:更高效的压缩
  3. 图片减肥 55%:从 267 KB 到 120 KB
  4. 总体减少 286 KB:约 15% 的体积优化

但更重要的是,我们学会了:

  • 🔍 如何分析:使用工具找出真正的瓶颈
  • 💡 如何决策:权衡收益和风险
  • 🛠️ 如何实施:渐进式优化,充分测试
  • 📊 如何验证:用数据说话
  • 🔄 如何持续:建立监控和预算

记住:

  • 优化不是一次性的工作,而是持续的过程
  • 不要为了优化而优化,要关注用户体验
  • 数据驱动决策,不要凭感觉
  • 保持代码可维护性,不要过度优化

下一站: 微前端架构?边缘计算?还是自定义组件库?

敬请期待下一篇: 《从 Element Plus 到自定义组件库:一次大胆的尝试》


如果这篇文章对你有帮助,别忘了点赞👍、收藏⭐️、关注➕三连!

有任何问题欢迎在评论区讨论,我会尽快回复!


关键词: Vue 3、Vite、性能优化、Element Plus、包体积优化、深度优化

标签: #Vue3 #Vite #性能优化 #ElementPlus #前端工程化 #深度优化

Dayjs跨年获取周获取错误

作者 爆浆麻花
2026年1月13日 18:11

众所周知,前端可以通过Dayjs和Momentjs对时间进行格式化和计算等操作。最近恰逢26年跨年我在使用这两个库获取周的时候发现了下面的问题。

dayjs.locale('en')
moment.locale('en')

dayjs('2025-12-31').format('YYYY年w周') // 2025年1周
moment('2025-12-31').format('YYYY年w周') // 2025年1周

dayjs.locale('en-gb')
dayjs('2025-12-28').format('YYYY年w周') // 2025年1周
dayjs('2025-12-31').format('YYYY年w周') // 2025年1周

我发现25年最后这几天格式化出来周是25年第一周,那么问题来了正确获取到的年/周应该是多少呢?我去小查了一下资料,下面是我的一些总结。

下面的总结是我通过查询资料的出来的一些感受,具体应该获取为第几周还需要根据业务来确定。

时间计算周主要分为两种<font style="color:rgb(10, 10, 10);">ISO 8601 标准</font><font style="color:rgb(10, 10, 10);">北美通用习惯</font>,且在计算时主要注意两个问题:<font style="color:rgb(10, 10, 10);">年第一周怎么算</font>/<font style="color:rgb(10, 10, 10);">每周的起始日</font>

ISO 8601 标准

  • 本年度第一个星期四所在的星期;
  • 1月4日所在的星期;
  • 本年度第一个至少有4天在 同一星期内的星期;
  • 星期一在去年12月29日至今年1月4日以内的星期;
  • 每周周一为起始日

北美通用习惯

  • 1月1日所在的周就是第一周
  • 每周周天为起始日

根据上面的规则,可以得到下面的答案

// ISO 8601
dayjs('2025-12-28').format('YYYY年w周') // 2025年52周
dayjs('2025-12-31').format('YYYY年w周') // 2026年1周
// 北美
dayjs('2025-12-28').format('YYYY年w周') // 2026年1周
dayjs('2025-12-31').format('YYYY年w周') // 2026年1周

我在dayjs的github上我提了一个issuepr,因为是第一次提pr不太熟悉规则,小弟有什么犯错的地方欢迎大佬们指教。

Vue 3 项目包体积优化实战:从 227KB 到精细化分包

2026年1月13日 18:02

通过精细化分包策略,优化缓存效率,提升加载性能

🎯 优化目标

在完成构建速度优化后,我们发现包体积也有优化空间:

  • Element Plus 占 787 KB(40.8%)- 过大
  • Vendor 包 227 KB - 包含多个库,缓存效率低
  • 总体积 1.93 MB - 需要优化

📊 优化前后对比

分包策略对比

包名 优化前 优化后 变化
element-plus 787.16 KB 787.19 KB ≈ 0
framework 180.42 KB 180.42 KB ≈ 0
vendor 226.66 KB 157.37 KB ↓ 30.6% 🎉
lodash - 27.61 KB 新增
axios - 38.96 KB 新增
dayjs - 18.25 KB 新增
crypto - 69.90 KB 新增

关键改进

  1. Vendor 包瘦身:从 227 KB 减少到 157 KB(减少 69 KB
  2. 精细化分包:将常用库独立打包,提升缓存效率
  3. 并行加载:多个小包可以并行下载,提升加载速度

🔧 优化实施

优化 1:精细化分包策略

问题分析

原来的配置将所有工具库打包到一个 utils chunk:

// ❌ 优化前:粗粒度分包
if (normalized.includes('/lodash') || 
    normalized.includes('/dayjs') || 
    normalized.includes('/axios')) {
  return 'utils'  // 所有工具库打包在一起
}

问题:

  • 单个文件过大(包含 lodash + dayjs + axios)
  • 任何一个库更新,整个 chunk 缓存失效
  • 不常用的库也会被加载

优化方案

// ✅ 优化后:细粒度分包
// 工具库细分 - 提升缓存效率
if (normalized.includes('/lodash')) {
  return 'lodash'  // lodash 单独打包
}
if (normalized.includes('/dayjs')) {
  return 'dayjs'   // dayjs 单独打包
}
if (normalized.includes('/axios')) {
  return 'axios'   // axios 单独打包
}

// 大型库单独打包
if (normalized.includes('/xlsx')) {
  return 'xlsx'
}
if (normalized.includes('/crypto-js')) {
  return 'crypto'
}
if (normalized.includes('/dompurify')) {
  return 'dompurify'
}

优化效果

缓存效率提升:

  • 场景 1:只更新业务代码

    • 优化前:vendor (227 KB) 缓存失效
    • 优化后:只有 vendor (157 KB) 缓存失效,lodash/axios/dayjs 仍然有效
  • 场景 2:升级 axios

    • 优化前:整个 utils chunk 缓存失效
    • 优化后:只有 axios (39 KB) 缓存失效

并行加载:

  • 浏览器可以同时下载多个小文件
  • HTTP/2 多路复用,并行下载更高效

优化 2:Element Plus 自动导入优化

问题分析

Element Plus 占 787 KB,虽然已经使用了按需导入,但仍然很大。

优化方案

// 1. 在 AutoImport 中也添加 Element Plus resolver
AutoImport({
  imports: ["vue", "vue-router", "pinia", "vue-i18n"],
  resolvers: [
    ElementPlusResolver(),  // 自动导入 Element Plus API
  ],
})

// 2. 在 Components 中配置
Components({
  resolvers: [
    ElementPlusResolver({
      importStyle: "sass",
      directives: false,  // 不自动导入指令,减少体积
    }),
  ],
})

预期效果

  • 更精确的按需导入
  • 避免导入未使用的 API 和指令
  • 预计可减少 10-15% 的 Element Plus 体积

📈 性能提升分析

1. 缓存命中率提升

场景模拟:

假设每月发版 4 次,每次更新:

  • 业务代码更新:100%
  • 依赖库更新:10%

优化前:

  • 每次发版,用户需要重新下载 vendor (227 KB)
  • 月流量:227 KB × 4 = 908 KB

优化后:

  • 业务代码更新:vendor (157 KB)
  • 依赖更新(10% 概率):lodash/axios/dayjs 之一 (约 30 KB)
  • 月流量:157 KB × 4 + 30 KB × 0.4 = 640 KB

节省流量: 268 KB/月/用户(减少 29.5%

2. 首屏加载优化

并行下载优势:

优化前(串行):
[====== vendor 227KB ======] 2.27s (假设 100KB/s)

优化后(并行):
[== vendor 157KB ==] 1.57s
[= lodash 28KB =] 0.28s
[= axios 39KB ==] 0.39s
[= dayjs 18KB ==] 0.18s
总时间:max(1.57, 0.28, 0.39, 0.18) = 1.57s

加载时间减少: 0.7s(提升 30.8%

3. 用户体验提升

指标 优化前 优化后 提升
首次加载 ~3.5s ~2.8s ↓ 20%
二次访问 ~1.2s ~0.8s ↓ 33%
更新后访问 ~2.0s ~1.4s ↓ 30%

🎓 深度解析:为什么这样优化有效?

1. HTTP/2 的多路复用

现代浏览器支持 HTTP/2,可以:

  • 在单个连接上并行传输多个文件
  • 避免队头阻塞
  • 更高效的资源利用

最佳实践:

  • 单个文件大小:20-100 KB
  • 文件数量:5-15 个
  • 避免过度分割(< 10 KB 的文件)

2. 浏览器缓存策略

浏览器缓存基于文件名(包含 hash):

  • 文件内容不变 → hash 不变 → 使用缓存
  • 文件内容改变 → hash 改变 → 重新下载

精细化分包的优势:

  • 减少缓存失效的范围
  • 提高缓存命中率
  • 降低用户流量消耗

3. 关键渲染路径优化

首屏渲染需要:
1. HTML
2. 关键 CSS
3. 关键 JS(framework + main)
4. 非关键 JS(vendor + 其他库)

优化策略:
- 关键资源:内联或优先加载
- 非关键资源:延迟加载或并行加载

🛠️ 实战技巧

技巧 1:分析包体积

# 生成可视化报告
VITE_ANALYZE=true npm run build:dev

# 查看 stats.html
open dist/stats.html

关注指标:

  • 单个 chunk 大小(建议 < 200 KB)
  • 重复依赖(应该为 0)
  • 未使用的代码(通过 Tree Shaking 移除)

技巧 2:合理的分包粒度

// 🎯 最佳实践
const chunkSizeMap = {
  'element-plus': 787,  // 大型 UI 库,单独打包
  'framework': 180,     // 核心框架,单独打包
  'vendor': 157,        // 其他依赖,合并打包
  'lodash': 28,         // 常用工具库,单独打包
  'axios': 39,          // HTTP 库,单独打包
  'dayjs': 18,          // 日期库,单独打包
  'crypto': 70,         // 加密库,单独打包
}

// ❌ 过度分割
const chunkSizeMap = {
  'lodash-debounce': 2,    // 太小,不值得单独打包
  'lodash-throttle': 2,    // 太小,不值得单独打包
  'lodash-cloneDeep': 3,   // 太小,不值得单独打包
}

技巧 3:监控包体积变化

// package.json
{
  "scripts": {
    "build:analyze": "VITE_ANALYZE=true npm run build:dev",
    "size-limit": "size-limit",
    "size-limit:check": "size-limit --why"
  },
  "size-limit": [
    {
      "path": "dist/assets/js/element-plus-*.js",
      "limit": "250 KB"
    },
    {
      "path": "dist/assets/js/vendor-*.js",
      "limit": "160 KB"
    }
  ]
}

📋 优化检查清单

分包策略

  • 大型库(> 100 KB)单独打包
  • 常用库(20-100 KB)单独打包
  • 小型库(< 20 KB)合并打包
  • 避免过度分割(< 10 KB)

缓存策略

  • 使用 contenthash 命名
  • 稳定的 chunk 名称
  • 合理的缓存时间
  • CDN 配置正确

性能监控

  • 定期生成包体积报告
  • 设置体积预算
  • 监控首屏加载时间
  • 跟踪缓存命中率

🎯 下一步优化方向

1. Element Plus 深度优化

当前状态: 787 KB(Gzip: 242 KB)

优化方向:

  • 分析实际使用的组件
  • 移除未使用的组件
  • 考虑使用更轻量的替代方案

预期收益: 减少 150-200 KB

2. 动态导入优化

当前状态: 所有路由组件都在首屏加载

优化方向:

// 路由懒加载
const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/dashboard/index.vue'),
  },
  {
    path: '/settings',
    component: () => import('@/views/settings/index.vue'),
  },
]

预期收益: 首屏减少 30-40%

3. Tree Shaking 优化

当前状态: 可能存在未使用的代码

优化方向:

  • 检查 lodash-es 导入方式
  • 使用具名导入
  • 配置 sideEffects

预期收益: 减少 50-100 KB

📊 ROI 分析

投入时间: 2 小时

收益:

  • 包体积优化:69 KB(vendor)
  • 缓存效率提升:29.5%
  • 加载时间减少:30.8%
  • 用户体验提升:20-33%

长期收益:

  • 每月节省流量:268 KB × 用户数
  • 提升用户留存率
  • 降低服务器带宽成本

🎬 总结

通过精细化分包策略,我们实现了:

  1. Vendor 包瘦身:从 227 KB 减少到 157 KB
  2. 缓存效率提升:29.5% 的流量节省
  3. 加载速度提升:30.8% 的时间减少
  4. 更好的可维护性:清晰的依赖关系

核心原则

  1. 合理分包:根据更新频率和大小分包
  2. 提升缓存:减少缓存失效范围
  3. 并行加载:利用 HTTP/2 多路复用
  4. 持续监控:定期检查包体积变化

最后的建议

  • DO:定期分析包体积
  • DO:设置体积预算
  • DO:监控性能指标
  • DON'T:过度分割
  • DON'T:忽视缓存策略
  • DON'T:盲目追求极致

关键词: Vite 包体积优化、代码分割、缓存策略、性能优化、Vue 3

标签: #Vite #包体积优化 #性能优化 #前端工程化


如果这篇文章对你有帮助,别忘了点赞👍、收藏⭐️、关注➕三连!

更多前端性能优化技巧,请关注我的专栏《前端性能优化实战》

React 那么多状态管理库,到底选哪个?如果非要焊死一个呢?这篇文章解决你的选择困难症

2026年1月13日 17:55

前言

各位 React 开发者们,是不是还在为状态管理头疼?在我的这篇文章中:

🚀别再卷 Redux 了!Zustand 才是 React 状态管理的躺平神器

有掘友问到:React那么多状态库,能不能直接焊死一个?

image.png

那就简单聊下我的看法(仅供参考)。篇幅比较长,中间的代码示例大家可以跳着阅读。

📊 主流状态管理库分类

1. 客户端状态管理

  • Redux Toolkit (RTK) - 最成熟,企业级首选
  • Zustand - 轻量简洁,API 友好
  • Jotai - 原子化状态,React 风格
  • Recoil - Facebook 出品,实验性
  • Valtio - 代理基础,可变语法

2. 服务端状态管理

  • TanStack Query (React Query) - 异步数据王者
  • SWR - Vercel 出品,轻量
  • RTK Query - Redux 生态内

3. 全栈/框架集成

  • Next.js - 内置多种方案
  • Remix - 基于 loader/action
  • Nuxt (Vue)- 类比参考

🎯 我的建议:焊死这个组合

对于大多数项目,如果非要焊死一个的话,我推荐:Zustand + TanStack Query。React 太多状态管理库了,如果非要焊死一个,我目前推荐这个王炸组合。

🌈 为什么选择这个组合

一、先搞懂:为什么要分开处理两种状态?

在开始安利组合之前,我们得先明确一个核心认知:React 项目中的状态,从来都不是一锅炖的,而是分为两种截然不同的类型,需要区别对待。

  1. 客户端本地状态(UI 状态) 比如:按钮的禁用状态、侧边栏的展开 / 折叠、导航栏的当前选中项、用户的本地偏好设置等。这类状态的特点是:同步更新、无需缓存、仅在客户端生效、数据量较小
  2. 服务端异步状态(接口数据) 比如:从后端获取的用户列表、文章数据、商品信息等。这类状态的特点是:异步获取、需要处理 loading/error 状态、需要缓存、可能需要后台刷新、支持分页 / 无限加载

过去我们总想着用一套方案搞定所有状态,结果就是既要又要还要,最后搞得不伦不类。而 Zustand + TanStack Query 的组合,正是精准切中了这两种状态的需求,各司其职、强强联合。

二、 Zustand:客户端状态管理的「极简天花板」

Zustand 是一款轻量、简洁、API 友好的客户端状态管理库,它的核心理念就是少即是多—— 没有繁琐的概念,没有多余的模板代码,甚至不需要 Provider 包裹整个应用,上手即用。

1. 核心优势:为什么放弃 Redux 选择 Zustand?

  • 🚀 代码量减少 70% :无需写 Action、Reducer,无需配置 Provider,直接创建 Store 即可使用。
  • 🎉 无需 Provider 包裹:告别顶层嵌套的 Provider 地狱,尤其是在大型项目中,能极大简化组件树结构。
  • 🔒 TypeScript 支持完美:内置 TypeScript 类型推导,无需额外写大量类型声明,写起来丝滑流畅。
  • JavaScript 无缝兼容:无需额外配置类型,原生 JS 写起来丝滑流畅,新手也能快速上手。
  • 💪 足够应对 95% 的客户端状态需求:支持中间件、持久化、状态切片,扩展性拉满,小型项目和中型项目都能 hold 住。
  • 📦 超小体积:核心体积不到 1KB,对项目打包体积几乎没有影响,堪称「轻量王者」。

2. 代码示例:5 分钟上手 Zustand

第一步:安装依赖
npm install zustand 
# 或 yarn add zustand 
# 或 pnpm add zustand
第二步:创建第一个 Store

我们来写一个最简单的计数器,感受一下 Zustand 的简洁:

// src/store/count.store.js
import { create } from 'zustand';

// 创建计数器 Store
const useCountStore = create((set) => ({
    // 定义状态数据
    count: 0,
    
    // 定义修改状态的方法(无需 Action,直接修改)
    increase: () => set((state) => ({ count: state.count + 1 })),
    decrease: () => set((state) => ({ count: state.count - 1 })),
    reset: () => set({ count: 0 }),
    
    // 支持传入参数修改状态
    setCount: (num) => set({ count: num }),
}));

export default useCountStore;
// scr/App.jsx
import useCountStore from './store/count.store.js'
export default function App() {
    return (
        <div>
            <h1>Count: {useCountStore((state) => state.count)}</h1>
        </div>
    )
}

image.png

第三步:在组件中使用 Store

无需任何额外配置,直接导入使用,就是这么简单:

// src/components/CountComponent.jsx
import useCountStore from '../store/count.store';

const CountComponent = () => {
    // 按需获取状态和方法(支持解构,不会触发不必要的重渲染)
    const count = useCountStore((state) => state.count);
    const { increase, decrease, reset } = useCountStore();

    return (
        <div style={{ padding: '20px', border: '1px solid #eee', borderRadius: '8px' }}>
            <h3 style={{ color: '#1890ff' }}>Zustand 计数器示例</h3>
            <p style={{ fontSize: '24px', margin: '20px 0' }}>当前计数:{count}</p>
            <div>
                <button
                    onClick={increase}
                    style={{ marginRight: '10px', padding: '8px 16px', cursor: 'pointer' }}
                >
                    +1
                </button>
                <button
                    onClick={decrease}
                    style={{ marginRight: '10px', padding: '8px 16px', cursor: 'pointer' }}
                >
                    -1
                </button>
                <button
                    onClick={reset}
                    style={{ padding: '8px 16px', cursor: 'pointer', backgroundColor: '#f5f5f5' }}
                >
                    重置
                </button>
            </div>
        </div>
    );
};

export default CountComponent;

image.png

进阶示例: Zustand 持久化(本地存储用户偏好)

如果需要将状态持久化到 localStorage(比如用户的侧边栏偏好),Zustand 也能轻松实现,只需借助内置的中间件:

// src/store/ui.store.js
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

// 创建 UI 状态 Store,并开启持久化
const useUiStore = create(
    persist(
        (set) => ({
            // 侧边栏展开状态
            sidebarCollapsed: false,
            // 切换侧边栏状态
            toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
        }),
        {
            // 持久化的 key(用于 localStorage 中存储的键名)
            name: 'ui-preferences',
        }
    )
);

export default useUiStore;

使用起来和普通 Store 毫无区别,但是状态会自动同步到 localStorage,页面刷新后也不会丢失,个人觉得还是挺方便的。

三、 TanStack Query:服务端状态管理的「异步王者」

如果说 Zustand 是客户端状态的极简天花板,那么 TanStack Query(原 React Query)就是服务端状态的异步王者

它的核心作用是:帮你封装了所有服务端数据处理的繁琐逻辑,让你像使用本地状态一样使用异步接口数据。你再也不用手动处理 loading、error、缓存、重试这些问题,只需专注于编写接口请求函数即可。

1. 核心优势:为什么选择 TanStack Query?

  • 🚀 自动缓存:请求的数据会自动缓存,相同的请求不会重复发送,极大减少接口请求次数。
  • 🎉 自动处理 loading/error 状态:内置 loading、error、data 状态,无需手动声明和更新。
  • 💪 后台数据同步:支持后台刷新数据,页面在前台时自动更新最新数据,无需用户手动刷新。
  • 📦 内置分页 / 无限加载 / 乐观更新:提供丰富的 Hooks 支持复杂的异步数据场景,无需自己造轮子。
  • 🔄 自动重试:请求失败时可以配置自动重试,提高接口的容错性。
  • 🧰 强大的 DevTools:配套的开发者工具,能清晰看到请求的缓存、状态、历史记录,调试更方便。

2. 代码示例:5 分钟上手 TanStack Query

第一步:安装依赖
npm install @tanstack/react-query @tanstack/react-query-devtools
# 或 yarn add @tanstack/react-query @tanstack/react-query-devtools
# 或 pnpm add @tanstack/react-query @tanstack/react-query-devtools
第二步:全局配置 TanStack Query

首先需要在项目入口文件中配置 QueryClientQueryClientProvider,这是唯一需要全局配置的步骤:

// src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

// 创建 QueryClient 实例
const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            // 默认开启缓存,5 分钟内不重复请求
            staleTime: 5 * 60 * 1000,
            // 请求失败时自动重试 3 次
            retry: 3,
            // 关闭无限加载(可选)
            refetchOnWindowFocus: false,
        },
    },
});

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
    <QueryClientProvider client={queryClient}>
        <App />
        {/* 挂载 DevTools(开发环境开启,生产环境可移除) */}
        <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
);
第三步:封装接口请求 Hook

我们来封装一个获取待办事项的 Hook,感受一下 TanStack Query 的强大:

// src/api/todos.api.js
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

// 1. 定义接口请求函数
const fetchTodos = async () => {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos');
    if (!response.ok) {
        throw new Error('获取待办事项失败');
    }
    return response.json();
};

// 2. 定义新增待办事项的函数(纯 JavaScript)
const addTodo = async (newTodo) => {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify(newTodo),
    });
    if (!response.ok) {
        throw new Error('新增待办事项失败');
    }
    return response.json();
};

// 3. 封装获取待办事项的 Hook(使用 useQuery,纯 JavaScript)
export const useTodosQuery = () => {
    return useQuery({
        // queryKey:缓存的唯一标识,必须是数组类型(支持依赖项传递)
        queryKey: ['todos'],
        // queryFn:接口请求函数
        queryFn: fetchTodos,
    });
};

// 4. 封装新增待办事项的 Hook(使用 useMutation,处理 POST/PUT/DELETE 请求)
export const useAddTodoMutation = () => {
    const queryClient = useQueryClient();

    return useMutation({
        mutationFn: addTodo,
        // 新增成功后,自动刷新待办事项列表(乐观更新)
        onSuccess: () => {
            queryClient.invalidateQueries({ queryKey: ['todos'] });
        },
    });
};
第四步:在组件中使用接口 Hook

无需手动处理 loading 和 error,直接解构使用即可,代码简洁到飞起:

// src/components/TodoComponent.jsx
import React, { useState } from 'react';
import { useTodosQuery, useAddTodoMutation } from '../api/todos.api';

const TodoComponent = () => {
    const [title, setTitle] = useState('');
    // 获取待办事项数据
    const { data: todos, isLoading, isError, error } = useTodosQuery();
    // 新增待办事项
    const { mutate: addTodo, isPending: isAdding } = useAddTodoMutation();

    // 处理新增待办事项提交
    const handleSubmit = (e) => {
        e.preventDefault();
        if (!title.trim()) return;
        addTodo({ title, completed: false });
        setTitle('');
    };

    // 加载中状态
    if (isLoading) {
        return <div style={{ padding: '20px' }}>正在获取待办事项...</div>;
    }

    // 错误状态
    if (isError) {
        return <div style={{ padding: '20px', color: '#ff4d4f' }}>获取失败:{error.message}</div>;
    }

    return (
        <div style={{ padding: '20px', border: '1px solid #eee', borderRadius: '8px', marginTop: '20px' }}>
            <h3 style={{ color: '#1890ff' }}>TanStack Query 待办事项示例(JSX)</h3>

            {/* 新增待办事项表单 */}
            <form onSubmit={handleSubmit} style={{ marginBottom: '20px' }}>
                <input
                    type="text"
                    value={title}
                    onChange={(e) => setTitle(e.target.value)}
                    placeholder="请输入待办事项"
                    style={{ padding: '8px', width: '300px', marginRight: '10px' }}
                />
                <button
                    type="submit"
                    disabled={isAdding}
                    style={{ padding: '8px 16px', cursor: 'pointer', backgroundColor: '#1890ff', color: '#fff', border: 'none', borderRadius: '4px' }}
                >
                    {isAdding ? '新增中...' : '新增待办'}
                </button>
            </form>

            {/* 待办事项列表 */}
            <div>
                <h4>待办列表(前 10 条)</h4>
                <ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
                    {todos?.slice(0, 10).map((todo) => (
                        <li
                            key={todo.id}
                            style={{
                                padding: '10px',
                                borderBottom: '1px solid #f5f5f5',
                                textDecoration: todo.completed ? 'line-through' : 'none',
                                color: todo.completed ? '#999' : '#333',
                            }}
                        >
                            {todo.title}
                        </li>
                    ))}
                </ul>
            </div>
        </div>
    );
};

export default TodoComponent;

运行代码后,你会发现:请求自动发送、加载状态自动处理、新增数据后列表自动刷新、相同请求不会重复发送 —— 这一切,都是 TanStack Query 帮你做好的,你只需要专注于业务逻辑即可。

四、 王炸组合落地:项目结构最佳实践

看完了两个库的单独使用,我们再来看看如何在实际项目中整合 Zustand + TanStack Query,打造一个清晰、可维护的项目结构。

src/
├── store/           # Zustand 客户端状态存储目录
│   ├── auth.store.js  # 认证相关状态(登录状态、用户信息)
│   ├── ui.store.js    # UI 相关状态(侧边栏、主题、导航)
│   └── index.js       # Store 导出汇总(方便组件导入)
├── api/            # TanStack Query 接口 Hook 目录
│   ├── todos.js       # 待办事项相关接口
│   ├── users.js       # 用户相关接口
│   └── index.js       # 接口 Hook 导出汇总
├── components/     # 公共组件目录
├── pages/          # 页面组件目录
└── App.jsx         # 根组件

🚀 快速决策指南

可能有人会问:我的项目很小,需要用这套组合吗?我的项目是大型企业级项目,这套组合够用吗?

别急,我让DeepSeek给大家整理了一份懒人快速决策指南,对应不同场景选择最合适的方案:

  1. 超简单状态(单个组件内、无需共享):直接用 useState 即可,无需引入任何状态库,简单直接。
  2. 小型项目 / 简单共享状态(少量组件共享状态):可以用 React Context + useReducer,或者直接用 Zustand(上手更快,代码更简洁)。
  3. 中型项目(推荐,90% 的项目场景):直接焊死 Zustand + TanStack Query,开发体验最佳,覆盖 99% 的场景,后期维护成本低。
  4. 大型企业级项目(需要强架构、可追溯、团队协作):可以选择 Redux Toolkit + RTK Query,支持时间旅行调试、丰富的中间件生态,适合对架构有严格要求的大型项目。
  5. 超极简需求(只需要原子化状态):可以选择 Jotainanostores,原子化状态管理,按需更新,体积更小。

📝 具体落地建议

// 1. 安装核心依赖
"dependencies": {
  "zustand": "^4.0.0",
  "@tanstack/react-query": "^5.0.0",
  "@tanstack/react-query-devtools": "^5.0.0"
}

// 2. 项目结构
src/
├── store/           # Zustand stores
│   ├── auth.store.ts
│   ├── ui.store.ts
│   └── index.ts
├── api/            # TanStack Query hooks
│   ├── todos.ts
│   └── users.ts
└── components/

🔄 迁移策略

如果你现在用 Redux,逐步迁移:

  1. 新功能用 Zustand
  2. 旧功能保持 Redux
  3. 两者可以共存

💡 黄金法则

  1. 先判断状态类型

    • 服务器数据?→ TanStack Query
    • 客户端 UI 状态?→ Zustand
    • 表单状态?→ React Hook Form + Zustand
  2. 避免过度设计

    • 能用 useState 就别用状态库
    • 组件内状态优先
    • 共享状态才提升
  3. 技术选型标准

    • 团队熟悉度
    • 维护活跃度
    • TypeScript 支持
    • Bundle 大小

🎖️ 最终答案

非要焊死的话,那我推荐这个组合:Zustand + TanStack Query

这个组合能覆盖:

  • ✅ 客户端状态(Zustand)
  • ✅ 服务端状态(TanStack Query)
  • ✅ 表单状态(React Hook Form)
  • ✅ URL 状态(React Router)

对于 90% 的 React 项目,这套组合是最佳实践。除非你有特殊需求(如需要 Redux 中间件生态或时间旅行调试)。

结语

到这里,相信大家已经对 Zustand + TanStack Query 这套王炸组合有了全面的了解。

这套组合的核心魅力就在于:简洁、高效、各司其职。Zustand 搞定客户端本地状态,让你告别繁琐的 Provider 和模板代码;TanStack Query 搞定服务端异步数据,让你告别手动处理 loading/error/ 缓存的烦恼。

祝大家编码愉快,少写 bug,多摸鱼~ 🚀

团队协作新范式:用Cursor构建智能前端工作流

作者 Mr_chiu
2026年1月13日 17:54

当AI编程助手从个人工具升级为团队基础设施,前端开发的协作模式正在发生根本性变革

前言:从“个人加速器”到“团队增强器”

在前两篇文章中,我们已经探索了Cursor如何改变个人开发体验和重构工作流。然而,真正的生产力革命发生在团队层面——当每个人都使用AI助手时,如何确保协作的一致性、代码质量的统一性和知识的有效传递?

一家中型电商团队的经历颇具代表性:最初只是几位工程师尝试使用Cursor,很快发现各自生成的代码风格迥异,缺乏统一的模式和规范。两个月后,他们建立了一套共享的Cursor配置和团队规范,代码审查时间减少了40%,新成员上手速度提升了60%。

本篇将深入探讨如何将Cursor从个人生产力工具,转变为团队协作的基础设施,打造真正智能化的前端工作流。

一、建立团队统一的Cursor配置系统

1.1 团队级.cursorrules配置规范

与个人使用不同,团队协作需要一套共享的“AI编程规范”。这不仅仅是编码风格,更是团队技术决策的体现。

团队配置文件示例

# .cursorrules/team-guidelines.md
# ===============================
# 团队AI协作规范 v2.1
# 适用于所有使用Cursor的团队成员

## 架构决策记录(ADR)
- 状态管理:统一使用Zustand,禁止新增Redux代码
- 样式方案:Tailwind CSS + CSS Modules组合方案
- 组件库:内部组件库前缀统一为 `App` (如AppButton)
- API客户端:统一使用基于axios封装的httpClient

## 代码生成约束
### 禁止生成的模式
- 避免生成内联样式对象,除非是动态计算值
- 禁止使用`any`类型,必须显式定义接口
- 避免生成超过100行的单个组件文件

### 推荐模式
- 优先生成函数组件而非类组件
- 使用TypeScript严格模式
- 遵循React Hooks最佳实践

## 项目特定规则
### 电商模块
- 价格计算统一使用`formatPrice`工具函数
- 商品SKU验证逻辑必须通过`validateSKU`函数
- 购物车状态必须与用户会话绑定

### 用户系统
- 权限检查使用`usePermissions`自定义Hook
- 用户数据流必须经过清理和验证

配置同步策略

# 将团队配置纳入版本控制
git add .cursorrules/
git commit -m "chore: 更新团队Cursor规范v2.1"

# 使用Husky钩子确保配置同步
# 在.husky/pre-commit中添加
if [ -f ".cursorrules/team-guidelines.md" ]; then
  echo "检查Cursor配置版本..."
  # 验证本地配置与远程一致
fi

1.2 智能代码片段库:团队的“集体智慧”

Cursor的强大之处在于能够学习团队的代码模式。建立一个共享的智能代码片段库,可以确保最佳实践在团队中传播。

创建团队片段库的方法

// .cursor/snippets/README.md
// 团队共享代码片段库

// 1. 常用业务模式
// =================
// 电商价格展示组件模式
/**
 * @snippet price-display
 * @desc 统一的价格展示组件,支持折扣、原价显示
 * @tags 电商,价格,组件
 */
const PriceDisplay: React.FC<PriceDisplayProps> = ({ 
  price, 
  originalPrice,
  currency = 'CNY'
}) => {
  // 团队统一的格式化逻辑
  const formattedPrice = formatPrice(price, currency);
  const hasDiscount = originalPrice && originalPrice > price;
  
  return (
    <div className="price-container">
      <span className="current-price">{formattedPrice}</span>
      {hasDiscount && (
        <span className="original-price">
          {formatPrice(originalPrice, currency)}
        </span>
      )}
    </div>
  );
};

// 2. API请求模式
/**
 * @snippet api-hook-pattern
 * @desc 标准的API请求Hook模式
 * @tags api,hook,请求
 */
export const useApiResource = <T,>(endpoint: string, initialData: T) => {
  const [data, setData] = useState<T>(initialData);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  
  const fetchData = useCallback(async (params?: Record<string, any>) => {
    setLoading(true);
    try {
      const response = await httpClient.get(endpoint, { params });
      setData(response.data);
      setError(null);
    } catch (err) {
      setError('请求失败');
      console.error(`API错误 [${endpoint}]:`, err);
    } finally {
      setLoading(false);
    }
  }, [endpoint]);
  
  return { data, loading, error, fetchData, setData };
};

二、智能化代码审查与质量保证

2.1 Cursor辅助的代码审查工作流

传统的代码审查往往关注语法细节,而有了Cursor,审查可以更专注于架构和业务逻辑。

智能审查指令集

# 代码提交前的自动审查
# 在package.json中配置
"scripts": {
  "cursor-review": "cursor --review-changes --rules .cursorrules/team-guidelines.md",
  "precommit-review": "cursor --staged --output review-report.md"
}

# 常用的审查指令
指令:"审查这段代码,重点关注:
1. 是否符合团队的状态管理规范
2. 是否有潜在的性能问题
3. 错误处理是否完整
4. 可访问性是否达标"

# 生成审查报告
指令:"生成详细的代码审查报告,包括:
- 架构符合度评分
- 潜在风险列表
- 具体改进建议
- 重构优先级"

集成到现有工作流

# .github/workflows/cursor-review.yml
name: AI-Assisted Code Review
on: [pull_request]

jobs:
  cursor-review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: 设置Cursor环境
        uses: actions/setup-node@v3
        
      - name: 运行AI辅助审查
        run: |
          npx cursor-review@latest \
            --github-token ${{ secrets.GITHUB_TOKEN }} \
            --pr-number ${{ github.event.pull_request.number }} \
            --rules .cursorrules/team-guidelines.md
          
      - name: 发布审查报告
        uses: actions/github-script@v6
        with:
          script: |
            // 将审查结果发布到PR评论

2.2 自定义审查规则的进阶应用

Cursor允许团队定义自己的审查规则,这些规则可以捕捉团队特定的问题模式。

// .cursor/custom-rules/performance-rules.js
// 自定义性能审查规则

module.exports = {
  rules: {
    '避免大组件': {
      pattern: /const\s+\w+\s*=\s*\([^)]*\)\s*=>\s*{[^}]{200,}}/gs,
      message: '组件超过200行,建议拆分为更小的子组件',
      severity: 'warning'
    },
    
    'useEffect依赖项检查': {
      pattern: /useEffect\s*\(\s*\(\)\s*=>\s*\{[^}]+?\}\s*,\s*\[\s*\]\s*\)/g,
      message: 'useEffect缺少依赖项,可能导致过时闭包问题',
      severity: 'error'
    },
    
    '图片优化提醒': {
      pattern: /<img[^>]*src=["']([^"']+)["'][^>]*>/g,
      check: async (match, filePath) => {
        const src = match[1];
        // 检查是否为WebP格式,是否设置合适尺寸
        if (!src.includes('.webp') && !src.startsWith('data:')) {
          return '建议使用WebP格式图片以提高性能';
        }
        return null;
      }
    }
  }
};

三、团队知识管理与智能文档

3.1 项目文档的自动生成与维护

文档与代码脱节是团队常见问题。Cursor可以基于代码和注释,自动生成和更新文档。

自动化文档工作流

// 1. 智能组件文档生成
/**
 * @component UserProfile
 * @description 用户个人资料展示组件
 * @prop {User} user - 用户数据对象
 * @prop {boolean} editable - 是否可编辑模式
 * @prop {Function} onUpdate - 资料更新回调
 * @example
 * <UserProfile 
 *   user={currentUser}
 *   editable={true}
 *   onUpdate={handleUserUpdate}
 * />
 */
const UserProfile: React.FC<UserProfileProps> = ({ user, editable, onUpdate }) => {
  // 组件实现...
};

// 让Cursor基于组件生成Markdown文档
指令:"基于以上组件代码和注释,生成完整的组件文档,包括:
- Props详细说明表格
- 使用示例(包括TypeScript示例)
- 常见问题解答
- 相关组件链接"

// 2. API文档自动同步
指令:"扫描所有API service文件,生成统一的API文档:
- 每个端点的URL和方法
- 请求参数类型和示例
- 响应数据格式
- 错误码说明"

3.2 团队知识库的AI增强

建立一个“活”的知识库,可以回答团队的技术问题。

# 团队知识库:AI增强版

## 架构决策查询
问:“我们为什么选择Zustand而不是Redux Toolkit?”
AI回答:“根据2023年10月的架构决策记录(ADR-003),选择Zustand的主要原因是:
1. 更简单的API,学习成本降低60%
2. 包体积减少42%
3. 与React 18并发特性兼容更好
4. 具体性能对比数据见 `/docs/decisions/state-management-choice.md`## 代码模式查询
问:“如何处理表单提交的loading状态和错误?”
AI回答:“团队标准模式是使用 `useSubmitHandler` Hook:
```typescript
// 示例代码
const { submit, isLoading, error } = useSubmitHandler(
  formData => api.post('/endpoint', formData),
  { successMessage: '提交成功' }
);

完整示例见 /examples/form-handling/advanced-form.tsx

故障排除指南

问:“页面首次加载白屏3秒,如何排查?” AI回答:“按照团队性能排查清单:

  1. 检查Bundle大小:npm run analyze
  2. 检查代码分割:确保路由使用React.lazy
  3. 检查API响应时间:使用Performance面板
  4. 查看已知问题:/docs/troubleshooting/white-screen.md 请运行诊断脚本:npm run diagnose:performance

## 四、智能化的团队协作工作流

### 4.1 统一的任务分解与估算

Cursor可以帮助团队更准确地进行任务分解和工作量估算。

**智能任务拆解模板**:
```markdown
# 功能开发任务卡

## 任务描述
{{任务描述}}

## AI辅助拆解

指令:“将以下功能需求拆解为具体的开发任务: {{粘贴需求文档}}

要求:

  1. 按前端组件粒度拆解
  2. 估算每个任务的理想人时(考虑团队平均速度)
  3. 识别技术风险和依赖项
  4. 推荐开发顺序

## 拆解结果
### 阶段1:基础架构(预计:8人时)
- [ ] 创建数据模型和TypeScript接口(2人时)
- [ ] 设置API service层(3人时)
- [ ] 配置状态管理store(3人时)

### 阶段2:核心组件(预计:12人时)
- [ ] 主列表组件(4人时)
- [ ] 详情弹窗组件(4人时)
- [ ] 搜索筛选组件(4人时)

### 阶段3:集成与优化(预计:6人时)
- [ ] 路由集成(2人时)
- [ ] 性能优化(2人时)
- [ ] 错误处理(2人时)

## 技术风险
1. API响应格式可能与预期不符
2. 大数据量下的列表性能需要关注

4.2 新人入职的AI加速

为新成员配置专门的Cursor规则,可以大幅缩短上手时间。

# .cursorrules/onboarding-guide.md
# 新人专属配置

## 学习路径引导
欢迎使用团队AI助手!以下是你第一个月应该关注的内容:

### 第一周:了解基础
- 运行 `npm run explore:architecture` 查看项目结构
- 使用指令:“解释项目的状态管理架构”
- 完成交互式教程:`npm run tutorial:core-concepts`

### 第二周:动手实践
- 使用代码生成模板创建你的第一个组件
- 指令:“创建一个商品卡片组件,参考 `components/product/ProductCard.tsx` 的模式”
- 运行自动代码审查了解团队标准

### 第三周:深度参与
- 尝试重构一个小模块
- 使用指令:“优化这个组件,使其更容易测试”
- 查看团队的代码审查记录学习最佳实践

### 第四周:独立贡献
- 认领一个简单的功能需求
- 使用任务拆解功能规划工作
- 提交你的第一个Pull Request

## 新人常见问题快速通道
问:“如何开始开发新功能?”
答:运行 `npm run create:feature feature-name` 使用标准模板

问:“遇到问题应该问谁?”
答:1. 首先问AI助手 2. 查看知识库 3. 在团队频道提问

问:“如何确保我的代码符合规范?”
答:每次提交前运行 `npm run precommit-check`

五、挑战与解决方案:团队引入Cursor的实战经验

5.1 常见挑战与应对策略

挑战 现象 解决方案
代码风格碎片化 不同人生成的代码风格迥异 建立团队统一的.cursorrules配置,定期同步更新
过度依赖 成员不加思考地接受AI建议 设置“AI生成代码必须标注出处”规则,定期进行代码审查会
知识孤岛 AI学习个人习惯而非团队模式 建立共享的代码片段库和审查规则库
审查负担加重 AI生成大量代码增加审查难度 实现自动化的预审查,只将关键问题提交人工审查

5.2 实施路线图建议

graph LR
    A[第一阶段:试点] --> B[第二阶段:标准化]
    B --> C[第三阶段:集成化]
    C --> D[第四阶段:智能化]
    
    subgraph A
        A1[2-3名早期使用者]
        A2[个人规则摸索]
        A3[收集使用场景]
    end
    
    subgraph B
        B1[团队基础规范]
        B2[共享配置库]
        B3[基础代码审查规则]
    end
    
    subgraph C
        C1[CI/CD集成]
        C2[自动化文档]
        C3[知识库增强]
    end
    
    subgraph D
        D1[预测性建议]
        D2[智能任务分配]
        D3[自适应学习系统]
    end

5.3 关键成功指标

团队引入Cursor后,应该跟踪以下指标:

interface TeamAIMetrics {
  // 开发效率
  featureLeadTime: number; // 功能从开始到交付的时间
  codeReviewCycleTime: number; // 代码审查周期
  
  // 代码质量
  bugRate: number; // 每千行代码的bug数
  technicalDebtIndex: number; // 技术债务指数
  
  // 团队协作
  onboardingTime: number; // 新成员上手时间
  knowledgeSharingScore: number; // 知识共享评分
  
  // AI使用效果
  aiAdoptionRate: number; // AI建议采纳率
  aiGeneratedCodeQuality: number; // AI生成代码质量评分
}

// 月度检查点示例
const checkCursorAdoption = (metrics: TeamAIMetrics) => {
  console.log(`AI采用报告:
  1. 开发效率提升: ${((1 - metrics.featureLeadTime / baseline) * 100).toFixed(1)}%
  2. 代码审查时间减少: ${((1 - metrics.codeReviewCycleTime / baseline) * 100).toFixed(1)}%
  3. 新人上手速度提升: ${((baseline / metrics.onboardingTime - 1) * 100).toFixed(1)}%
  4. AI代码质量评分: ${metrics.aiGeneratedCodeQuality}/10`);
};

六、未来展望:AI增强团队的进化路径

6.1 下一阶段:预测性协作

未来的团队AI助手将不仅响应指令,还能主动提出建议:

// 预测性建议示例
interface PredictiveSuggestion {
  type: 'refactor' | 'optimization' | 'documentation' | 'testing';
  priority: 'high' | 'medium' | 'low';
  description: string;
  estimatedImpact: {
    timeSaved: string; // 预计节省时间
    qualityImprovement: string; // 质量提升
    riskReduction: string; // 风险降低
  };
  action: {
    command: string; // 执行的命令
    autoApply: boolean; // 是否自动应用
  };
}

// AI可能主动建议:
{
  type: 'optimization',
  priority: 'high',
  description: '检测到商品列表组件在移动端有性能问题,建议虚拟滚动',
  estimatedImpact: {
    timeSaved: '首次加载减少1.2秒',
    qualityImprovement: '移动端FCP提升40%',
    riskReduction: '低内存设备崩溃率降低'
  },
  action: {
    command: 'cursor --optimize ProductList --strategy virtual-scroll',
    autoApply: false
  }
}

6.2 团队AI文化培育

最终,Cursor不仅是一个工具,更是团队文化的一部分:

  1. 透明化AI决策:记录重要的AI建议和采纳原因
  2. 集体学习机制:定期分享AI使用技巧和发现的最佳实践
  3. 伦理与责任框架:明确AI生成代码的责任归属和质量标准
  4. 持续进化心态:随着AI能力提升,不断调整团队工作方式

结语:重新定义“团队智慧”

Cursor等AI编程助手的出现,正在重新定义“团队智慧”的含义。传统意义上的团队智慧依赖于资深成员的指导和知识传递,而现在,这种智慧可以被编码、共享和增强。

真正的团队AI协作不是让人像机器一样工作,而是让机器像最佳团队成员一样辅助人工作。

当每个团队成员都拥有一个理解项目上下文、掌握团队规范、记得所有历史决策的AI搭档时,团队的集体智慧将被放大到前所未有的程度。


下篇预告:在第四篇中,我们将探索Cursor与现代前端技术栈的深度结合,包括Next.js 14应用架构、React Server Components、边缘计算等前沿领域的实战应用,展示AI如何帮助团队保持在技术浪潮的前沿。

团队实践挑战:在你们团队中选择一个小的协作痛点(如代码审查、知识传递、新人培训),尝试用本文的方法设计一个AI增强的解决方案,并在评论区分享你的设计和实施结果!

vue3,TypeScript---eslint配置

作者 SsunmdayKT
2026年1月13日 17:27

你想要知道在 Vue3 + TypeScript 项目中配置 ESLint 需要安装哪些 npm 包,以及对应的安装和配置方法,我会详细为你说明。

一、核心依赖包(分基础和 Vue/TS 适配)

在 Vue3 + TS 项目中,ESLint 核心依赖分为基础包适配 Vue/TS 的插件包,以下是完整的依赖列表及作用:

包名 作用
eslint ESLint 核心库,提供代码检查的基础能力
@typescript-eslint/eslint-plugin 为 TypeScript 提供 ESLint 规则的插件
@typescript-eslint/parser ESLint 的 TypeScript 解析器,让 ESLint 能识别 TS 语法
eslint-plugin-vue 专为 Vue 设计的 ESLint 插件,提供 Vue 代码的检查规则(支持 Vue3)
vue-eslint-parser 解析 Vue SFC(单文件组件)的解析器,让 ESLint 能识别 Vue 模板和脚本
@vue/eslint-config-typescript Vue 官方提供的 TS 适配配置,简化 Vue+TS 的 ESLint 配置
eslint-config-prettier(可选) 禁用 ESLint 中与 Prettier 冲突的规则(如果同时用 Prettier)

二、安装命令

1. 基础安装(仅 ESLint + Vue3 + TS)

bash

运行

# npm 安装
npm install eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-vue vue-eslint-parser @vue/eslint-config-typescript --save-dev

# yarn 安装
yarn add eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-vue vue-eslint-parser @vue/eslint-config-typescript -D

# pnpm 安装(推荐,速度更快)
pnpm add eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-vue vue-eslint-parser @vue/eslint-config-typescript -D

三、核心配置(.eslintrc.js)

安装完成后,需要在项目根目录创建 .eslintrc.js 文件,配置适配 Vue3 + TS 的规则:

javascript

运行

module.exports = {
  // 环境:指定代码运行的环境,启用对应环境的全局变量
  env: {
    browser: true, // 浏览器环境
    es2021: true,  // ES2021 语法
    node: true     // Node.js 环境
  },
  // 扩展配置:继承已有的规则集,减少重复配置
  extends: [
    'eslint:recommended', // ESLint 官方推荐规则
    'plugin:vue/vue3-essential', // Vue3 核心规则(必选)
    'plugin:@typescript-eslint/recommended', // TS 推荐规则
    '@vue/eslint-config-typescript', // Vue+TS 适配配置
    'eslint-config-prettier' // 禁用与 Prettier 冲突的规则(如果装了 Prettier)
  ],
  // 解析器:指定解析代码的解析器
  parser: 'vue-eslint-parser', // 解析 Vue SFC
  // 解析器选项:传给解析器的配置
  parserOptions: {
    ecmaVersion: 'latest', // 支持最新 ES 版本
    parser: '@typescript-eslint/parser', // 解析 TS 代码的子解析器
    sourceType: 'module' // 模块化代码(ES Module)
  },
  // 插件:启用安装的 ESLint 插件
  plugins: [
    'vue', // Vue 插件
    '@typescript-eslint' // TS 插件
  ],
  // 自定义规则:覆盖或新增规则,优先级最高
  rules: {
    // 示例:关闭 TS 的 "any 类型禁止" 规则(根据项目需求调整)
    '@typescript-eslint/no-explicit-any': 'off',
    // 关闭 Vue 的 "组件名多单词" 规则(小型项目可临时关闭)
    'vue/multi-word-component-names': 'off'
  }
}

四、补充配置(可选)

  1. 忽略文件(.eslintignore) :指定 ESLint 不检查的文件 / 目录

plaintext

node_modules/
dist/
*.d.ts

五、自动化修复

1.安装插件

image.png

2.添加自动配置项

image.png

image.png

添加

image.png

  "editor.codeActionsOnSave":{
    "source.fixAll.eslint": "explicit"
  },

服务端返回的二进制流excel文件,前端实现下载

2026年1月13日 17:06

近期有个excel的下载功能,服务端返回的是二进制的文件流,前端实现excel文件下载。

简易axios:

// utils/request.ts
import axios, { AxiosRequestConfig, AxiosRequestHeaders } from 'axios'
axios.interceptors.request.use(config => {
    ……
    return config
})
axios.interceptors.response.use(
    response => {
        return new Promise((resolve, reject) => {
            ……
        })
    }
)
    
 export const getBlobFile = (url: string, params = {}) =>
    axios({
        data: params,
        responseType: 'blob',
        method: 'POST',
        url
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
    }) as Promise<any>

下面是工具函数文件的方法:

// utils/index.ts
export function useState<T>(initData: T): [Ref<T>, (val?: T) => void] {
    const data = ref(initData) as Ref<T>
    function setData(newVal?: T) {
        data.value = newVal || initData
    }
    return [data, setData]
}

/**
 * 下载二进制文件
 * @param file
 * @param fn
 */
import { ref, Ref } from 'vue'

export function downloadFile(file: File, fn?: () => void) {
    if ('msSaveOrOpenBlob' in navigator) {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const nav = navigator as any
        nav.msSaveOrOpenBlob(file, file.name)
    } else {
        const url = URL.createObjectURL(file)
        const event = new MouseEvent('click')
        const link = document.createElement('a')
        link.href = url
        link.download = file.name
        file.type && (link.type = file.type)
        link.dispatchEvent(event)
        URL.revokeObjectURL(url)
        fn && fn()
    }
}

实现下载相关逻辑的hooks如下:

// hooks.ts
import { ref } from 'vue'
import { ElLoading } from 'element-plus'
import { EXCEL_EXPORT_URL } from '@/const/url'
import { useState, downloadFile } from '@/utils'
import { getBlobFile } from '@/utils/request'

export const OK_STATUS_CODE = '200'

export function useExportExcelFile() {
    const [exportData, handleExport] = useState([] as Array<ApiRes.ExcelListItem>)

    const exportLoading = ref(false)

    async function exportExcelFile(params = {}) {
        const text = '正在导出当前数据,请稍等~~ '
        const loading = ElLoading.service({
            lock: true,
            text,
            background: 'rgba(0, 0, 0, 0.7)',
            customClass: 'export-loading-class'
        })
        try {
            queryBlobExcelFile(params).then((res) => {
                exportExcelFileByPost(res)
            }).finally(() => {
                loading.close()
                exportLoading.value = false
            })
        } catch (error) {
            console.error('导出失败:', error)
        }
    }

    async function queryBlobExcelFile (params = {}) {
        return new Promise((resolve, reject) => {
            getBlobFile(EXCEL_EXPORT_URL, params)
                .then(res => {
                    if (res && res?.status === OK_STATUS_CODE) {
                        resolve(res)
                    }
                })
                .catch(err => reject(err))
        })
    }

    async function exportExcelFileByPost(res: {
        type: string
        data: Blob
    }) {
        const fileName =  `Excel文件-${+new Date()}.xlsx`
        downloadFile(new File([res.data], fileName))
    }

    return {
        exportData,
        handleExport,
        exportLoading,
        exportExcelFile,
    }
}

在页面中的使用

</template>
    <el-button
        type="primary"
        :disabled="false"
        :export-loading="exportLoading"
        @click="doExport"
    >
        导出
    </el-button>
</template>

import { ElMessageBox } from 'element-plus'
import { useExportExcelFile } from './hooks'

// 导出
const { exportLoading, exportExcelFile } = useExportExcelFile()
function doExport() {
    ElMessageBox.confirm('确定导出excel数据吗?', '导出', {
        cancelButtonText: '取消',
        confirmButtonText: '确认',
        showClose: true
    }).then(() => {
        exportLoading.value = true
        exportExcelFile(formData)
    })
}

TypeScript 类型推导还可以这么用?

2026年1月13日 17:03

一、为什么需要TypeScript类型推导

肯定是为了节省我们的代码,减少冗余,下面举一个例子,写一个递减函数

下面代码中,写了三个类型都是number,那么我们是不是可以思考,如何减少冗余呢,ts给咱们提供了泛型

正常书写

const desc = (a: number): number => {
    return --a
}
desc(2) // 1

泛型

const desc = <T>(a: T): number => {
    return --a
}
desc(3) // 2

这个例子在项目中的应用场景可谓之少之又少,也可以说,压根不可能出现。
那么衍生出一个问题:有没有更好的方法,自动推导我特定的入参返回特定类型。

有的兄弟,有的!!

我先把公共代码抽出来

// 公共属性
type Pet = {
    name: string
    age: number
}

// 映射类型
type OptionsMap = {
    dog?: {
        bark: string
        barkVolume: number
    } & Pet

    cat?: {
        favoriteToy: string
        furLength: 'short' | 'medium' | 'long'
    } & Pet
}

场景:

  • 组件封装,表单、表单组件类型封装,例如通过表单项type来确定是input、select...就可以明确props类型了
  • 业务逻辑封装,在重复的业务逻辑中我们通常会抽离,通过key去调用,那么对应的参数不同,也会用到泛型、自动推导

二、映射 + 类型推导

我们可以通过映射、类型推导相互配合就能得到特定的类型了,废话少说,上代码

2.1. 先来一个函数案例,通过参数1推导出参数2


const fn = <T extends keyof OptionsMap>(type: T, props: OptionsMap[T]) => {}

fn('cat', {}) 
/*
类型“{}”的参数不能赋给类型“{ favoriteToy: string; furLength: "short" | "medium" | "long"; } & Pet”的参数。  
类型“{}”缺少类型“{ favoriteToy: string; furLength: "short" | "medium" | "long"; }”中的以下属性: favoriteToy, furLengthts-plugin(2345)
*/ 

  • 上面代码声明了宠物映射属性,通过函数的第一个参数去映射中自动匹配第二个参数
  • 恭喜你现在已经掌握类型推导的基础了

2.2. 下面我们再来一个写一个生成集合,里面数据项都是通过推导出来的

type SinglePet<T extends keyof OptionsMap> = {
    type: keyof OptionsMap
    options: OptionsMap[T]
}

type Pets = SinglePet<keyof OptionsMap>[]

const petHome: Pets = [
    {
        type: 'dog',
        options: {
            name: '旺财',
            age: 2,
            bark: '汪汪汪',
            barkVolume: 2,
        },
    },
    {
        type: 'cat',
        options: {
            name: '小白',
            age: 1,
            favoriteToy: '玩具鸟',
            furLength: 'short',
        },
    },
]

数组里的每一项options,我们都是通过type推导出来的。


三、extends + 类型推导

extends和映射作用都是为了自动推导出另一个属性或者另一个类型,作用是一样的,具体使用哪种,可以根据项目而定


type PetKeys = 'dog' | 'cat' | 'fish' | 'bird'

type PetOptions<T extends PetKeys> = T extends 'dog'
    ? OptionsMap['dog']
    : T extends 'cat'
      ? OptionsMap['cat']
      : {
            text: number
        }

type OptionItem<T extends PetKeys> = PetOptions<T>

type Item<T extends PetKeys = PetKeys> = {
    type: T
    options: OptionItem<T>
}

type PetHome = Item[]

const petHome: PetHome = [
    {
        type: 'dog',
        options: {
            name: '旺财',
            age: 2,
            bark: '汪汪汪',
            barkVolume: 10,
        },
    },
    {
        type: 'bird',
        options: {
            text: 1,
        },
    },
    {
        type: 'cat',
        options: {
            name: '小白',
            age: 2,
            favoriteToy: '玩具鸟',
            barkVolume: 5,
            furLength: 'long',
        },
    },
]

上面这段代码主要是使用extend判断对应的类型,在项目中封装很常见,也利于后期拓展,其中核心代码为

type Item<T extends PetKeys = PetKeys> = { 
    type: T options: OptionItem<T> 
}

四、总结

类型推导可以不用,不能不会,有ts的项目必出现这种场景,当然也可以用AI 😂

最后告诉大家几个泛型、推导的注意事项

泛型参数,也就是本文中的T,如果一个使用项中的T已经明确类型了,其他地方也会跟着明确。
泛型参数一旦确定就不会再变,所有咱们如果使用默认值时,引用的类型必须要传递,否则就是按默认值算了。

最后祝大家日入过万,给俺点点关注、点点赞

uno.css.config.ts相关配置

2026年1月13日 16:40

UnoCSS配置项:

1. rules(自定义规则)定义新的类名规则,生成对应的css。
2. shortcuts(快捷方式)组合多个类名成一个简短的类名,提高效率。形式:字符串,数组,函数。
3. presets(预设)提供基础工具类功能扩展
presets工具1:persetWind3
  • 提供 Tailwind CSS 兼容的工具类
  • 支持响应式设计
  • 支持暗色模式
  • 提供颜色、间距、字体等预设
presets工具2:presetAttributify(属性化模式)
  • 支持将类名作为属性使用
  • 更符合 Vue/React 的写法习惯
presets工具3:presetIcons(图标预设)
presets工具4:presetTypography(排版预设)
  • 提供文章排版样式
  • 优化可读性
4.transformers - 转换器:处理特殊语法和指令
  • 1.transformerstransformerDirectives(指令转换器)
    • 支持 @apply 指令
    • 支持 @screen 指令
    • 支持 @variants 指令
  • 2.transformerstransformerVariantGroup(变体组转换器)
    • 支持变体组语法
    • 减少重复代码
5.theme - 主题配置(自定义主题变量,统一管理颜色、字体、间距等。)
  • 颜色配置
  • 断点配置
  • 字体配置
  • 间距配置
  • 圆角配置
6.safelist - 安全列表(强制包含某些类名,即使代码中未使用也会生成)
  • 字符串形式
  • 正则表达式形式
  • 函数形式
7.blocklist - 阻止列表(阻止某些类名被生成,即使代码中使用了也不会生成 CSS)
  • 阻止某些不安全的类名
  • 阻止已弃用的类名
  • 阻止特定模式的类名
8.preflights - 预设样式(添加全局 CSS 重置或基础样式。)
9.variants - 变体(自定义响应式、悬停等变体。)
10.layers - 图层(控制 CSS 的生成顺序,影响样式优先级。)
import { defineConfig,presetWind3,presetAttributify,presetIcons,presetTypography,transformerDirectives,transformerVariantGroup} from 'unocss'
export default defineConfig({
  // 所有配置项都在这里
  rules:[
      //静态规则
      [ly-mg,{margin:'10px'}],
      [ly-flex-center,{
          display:'flex'
      }],
      //动态规则(正则表达式)
      [/^ly-m-(\d+)$,([,d])=>({margin:`${d}px`})],
      [/^ly-p-(\d+)$/, ([, d]) => ({ padding: `${d}px` })],
      //css变量
      ['ly-primary',{
          'background-color':'var(--color-primary)',
          color:'var(--color-text)'
      }]
  ],
  shortcuts:{
      //组合多个类名
      "ly-common-boder":"ly-border-1px ly-border-solid ly-border-#D6DCE1",
      "ly-common-border-b": "ly-border-b-1px ly-border-b-solid ly-border-b-#D6DCE1",
      //组合布局类
       "ly-flex-center": "ly-flex ly-items-center ly-justify-center",
       "ly-flex-between": "ly-flex ly-items-center ly-justify-between",
       //组合卡片样式
       "ly-card": "ly-bg-white ly-rounded-8px ly-p-4 ly-shadow-md",
       // 组合按钮样式
       "ly-btn-primary": "ly-bg-blue-500 ly-text-white ly-px-4 ly-py-2 
        ly-rounded-4px hover:ly-bg-blue-600",
       // 数组形式:可以组合类名和对象
        "ly-card": [
          "ly-bg-white ly-rounded-8px ly-p-4",
          {
            'box-shadow': '0 2px 8px rgba(0,0,0,0.1)',
            'transition': 'all 0.3s ease'
          }
        ],

        "ly-btn": [
          "ly-px-4 ly-py-2 ly-rounded-4px",
          {
            'font-weight': '500',
            'cursor': 'pointer',
            'transition': 'all 0.2s'
          }
        ],
         // 函数形式:可以根据参数动态生成
         "ly-btn": (_, { theme }) => {
           return {
             'padding': '8px 16px',
             'border-radius': theme.borderRadius?.md || '4px',
             'background-color': theme.colors?.primary || '#409eff',
             'color': '#ffffff',
             'font-weight': '500',
             'cursor': 'pointer',
             'transition': 'all 0.2s',
             '&:hover': {
               'opacity': '0.8'
             }
           }
         },
     presets:[
         presetWind3({
             prefix:'ly-',//类名前缀
             dark:'class',//暗色模式:''class'| 'media''
             variablePrefix:'ly-',//css变量前缀
         }),
         presetAttributify({ 
             prefix: 'ly-',//类名前缀
             prefixedOnly: false,     // 是否只使用前缀
             nonValuedAttribute: true, // 支持无值属性
         }),
         presetIcons:({
              collections: {
                   // 使用 Iconify 图标集
                   carbon: () => import('@iconify-json/carbon/icons.json').then(i => i.
                default),
                   mdi: () => import('@iconify-json/mdi/icons.json').then(i => i.default),
                 },
                 extraProperties: {
                   'display': 'inline-block',
                   'vertical-align': 'middle',
                 }
             }),
         presetTypography(),
        ],
        transformers: [
          transformerDirectives(),
          transformerVariantGroup(),
        ],
        theme:{
            colors: {
              // 基础颜色
              primary: '#409eff',
              secondary: '#67c23a',
              danger: '#f56c6c',
              warning: '#e6a23c',
              info: '#909399',
              success: '#67c23a',

              // 嵌套颜色(支持色阶)
              brand: {
                50: '#f0f9ff',
                100: '#e0f2fe',
                200: '#bae6fd',
                300: '#7dd3fc',
                400: '#38bdf8',
                500: '#0ea5e9',
                600: '#0284c7',
                700: '#0369a1',
                800: '#075985',
                900: '#0c4a6e',
              },

              // 使用 CSS 变量
              primary: 'var(--color-primary)',
              secondary: 'var(--color-secondary)',
            },
            //断点
             breakpoints: {
               xs: '480px',   // 超小屏幕:小手机(竖屏)
               sm: '640px',   // 小屏幕:大手机(横屏)、小平板
               md: '768px',   // 中等屏幕:平板(竖屏)
               lg: '1024px',  // 大屏幕:平板(横屏)、小笔记本
               xl: '1280px',  // 超大屏幕:桌面显示器
               '2xl': '1536px', // 超超大屏幕:大桌面显示器
             },
           },
           fontFamily: {
               sans: ['Inter', 'system-ui', 'sans-serif'],
               mono: ['Fira Code', 'monospace'],
               serif: ['Georgia', 'serif'],
             },

             fontSize: {
               xs: ['12px', { lineHeight: '16px' }],
               sm: ['14px', { lineHeight: '20px' }],
               base: ['16px', { lineHeight: '24px' }],
               lg: ['18px', { lineHeight: '28px' }],
               xl: ['20px', { lineHeight: '30px' }],
               '2xl': ['24px', { lineHeight: '32px' }],
             },
           spacing: {
              xs: '4px',
              sm: '8px',
              md: '16px',
              lg: '24px',
              xl: '32px',
              '2xl': '48px',
              '3xl': '64px',
           },  
           borderRadius: {
              none: '0',
              sm: '2px',
              md: '4px',
              lg: '8px',
              xl: '12px',
              '2xl': '16px',
              full: '9999px',
           },
        },
        safelist: [
        'ly-flex',
        'ly-grid',
        'ly-hidden',
        'ly-block',
        // 匹配所有颜色变体
        /^ly-bg-(red|green|blue|yellow|purple)-\d+$/,
        /^ly-text-(xs|sm|base|lg|xl|2xl)$/,

        // 匹配所有间距
        /^ly-(p|m|px|py|mx|my)-(\d+)$/,
        //函数
        (matcher) => {
              // 包含特定前缀的类名
              if (matcher.startsWith('ly-'))
                return matcher

              // 动态生成的类名
              if (matcher.includes('dynamic'))
                return matcher
            },
        ],
        //阻止列表
        blocklist: [
          // 字符串
          'ly-hidden',
          'ly-block',

          // 正则表达式
          /^ly-bg-red-\d+$/,  // 阻止所有红色背景
          /^ly-text-danger$/,  // 阻止危险色文字

          // 函数
          (matcher) => {
            if (matcher.includes('deprecated'))
              return true
          },
        ],
        preflights: [
          {
            getCSS: () => `
              * {
                box-sizing: border-box;
                margin: 0;
                padding: 0;
              }

              body {
                font-family: -apple-system, BlinkMacSystemF
                line-height: 1.5;
                -webkit-font-smoothing: antialiased;
                -moz-osx-font-smoothing: grayscale;
              }

              html {
                scroll-behavior: smooth;
              }
            `
          },
          {
            getCSS: ({ theme }) => `
              :root {
                --color-primary: ${theme.colors?.primary ||
                --color-secondary: ${theme.colors?.secondar
                --spacing-base: ${theme.spacing?.md || '16p
              }

              [data-theme="dark"] {
                --color-primary: #5dade2;
                --color-secondary: #7dcea0;
              }
            `
          }
        ],
        variants: [
          // 自定义 data 属性变体
          {
            name: 'data-active',
            match(matcher) {
              const prefix = 'data-active:'
              if (!matcher.startsWith(prefix))
                return
              return {
                matcher: matcher.slice(prefix.length),
                selector: s => `[data-active] ${s}`,
              }
            },
          },

          // 自定义响应式断点
          {
            name: 'mobile',
            match(matcher) {
              if (!matcher.startsWith('mobile:'))
                return
              return {
                matcher: matcher.slice(7),
                handle: (input, { theme }) => {
                  const breakpoint = theme.breakpoints?.sm || '640px'
                  return {
                    parent: `@media (max-width: ${breakpoint})`,
                    ...input,
                  }
                },
              }
            },
          },
        ],
        layers: {
          components: -1,    // 组件层(最低优先级)
          utilities: 0,       // 工具类层(默认)
          shortcuts: 1,      // 快捷方式层
          preflights: 2,     // 预设样式层(最高优先级)
        }
     }
})
<template>
    <div>
        <div class="ly-m-50">rules正则表达式用法unocss</div>
        <div class="ly-mg">rules自定义静态用法</div>
        <div class="ly-primary">rulesCss的变量</div>
        <div class="ly-common-border">边框容器</div>
        <div class="ly-flex-center">居中布局</div>
        <div class="ly-card">卡片样式</div>
        <button class="ly-btn-primary">主要按钮</button>
        <div></div>
        <div class="ly-flex ly-items-center ly-p-4">
           presetAttributify标准方式
        </div>
        <div lyFlex lyItemsCenter lyP="4">
          presetAttributify属性化方式
        </div>
        <div class="ly-flex" lyItemsCenter lyP="4">
          presetAttributify混合使用
        </div>
         <div class="i-carbon-home">使用presetIcons图标</div>
         <div class="i-mdi-account">使用presetIcons图标</div>
        <div>
             <div>presetTypography(排版预设)</div>
             <article class="prose">
               <h1>标题</h1>
               <p>段落内容</p>
             </article>
        </div>
        <div>
             <div class="custom-card">卡片</div>
        </div>
        <div class="hover:ly-bg-blue-500 hover:ly-text-white hover:ly-shadow-md">
          transformerVariantGroup标准悬停效果
        </div>
        <div class="hover:(ly-bg-blue-500 ly-text-white ly-shadow-md)">
          transformerVariantGroup标准悬停效果变体悬停效果
        </div>
        <div class="hover:(ly-bg-blue-500 ly-text-white) focus:(ly-border-2 
        ly-border-blue-500)">
          transformerVariantGroup多个变体
        </div>
         <div class="ly-bg-primary">主色调背景</div>
         <div class="ly-text-danger">危险色文字</div>
         <div class="ly-border-warning">警告色边框</div>

         <!-- 使用色阶 -->
         <div class="ly-bg-brand-500">品牌色 500</div>
         <div class="ly-bg-brand-600">品牌色 600</div>
          <!-- 响应式宽度 -->
         <div class="ly-w-full md:ly-w-1/2 lg:ly-w-1/3">
           响应式布局
         </div>

         <!-- 响应式字体 -->
         <div class="ly-text-sm md:ly-text-base lg:ly-text-lg">
           响应式字体
         </div>
          <div class="ly-font-sans">无衬线字体</div>
         <div class="ly-font-mono">等宽字体</div>
         <div class="ly-text-xs">小号字体</div>
         <div class="ly-text-base">基础字体</div>
         <div class="ly-p-xs">小内边距</div>
        <div class="ly-p-md">中等内边距</div>
        <div class="ly-m-lg">大外边距</div>
    </div>
</template>
<style scoped>
.custom-card {
  @apply ly-bg-white ly-rounded-8px ly-p-4;
  @apply ly-shadow-md hover:ly-shadow-lg;
  @apply transition-all duration-300;
}
/* 响应式 */
@media (min-width: 768px) 
  .custom-card {
    @apply ly-p-6;
  }
}

/* 使用 @screen */
.custom-card {
  @screen md {
    @apply ly-p-6;
  }
}
</style>
总结格式:
import { defineConfig, presetWind3, transformerDirectives, transformerVariantGroup,presetIcons,presetTypography } from "unocss";

export default defineConfig({
  //组合式(快捷方式)
  shortcuts: {
  },
  //自定义(样式)
  rules: [
  ],
   //预设(工具与功能)
  presets: [
    //响应设计
    presetWind3({ 
      prefix: "ly-",//类名前缀
      dark: "class",//暗色模式:'class' | 'media'
      variablePrefix: "--ly-",//变量前缀
    }),
    presetIcons(), //图标预设
    presetTypography(),//排版预设
  ],
  //转换器(转换器)
  transformers: [
    transformerDirectives(), //指令转换器
    transformerVariantGroup() //变体组转换器
  ],
  // 配置项
  theme: {
    // 颜色
    colors: {
      // 配置项
    },
    //断点配置
    breakpoints: {
      // 配置项
      xs: '480px',   // 超小屏幕:小手机(竖屏)
      sm: '640px',   // 小屏幕:大手机(横屏)、小平板
      md: '768px',   // 中等屏幕:平板(竖屏)
      lg: '1024px',  // 大屏幕:平板(横屏)、小笔记本
      xl: '1280px',  // 超大屏幕:桌面显示器
     '2xl': '1536px', // 超超大屏幕:大桌面显示器
    },

    //间距
    spacing: {
      // 配置项
    },
    //字体
    fontFamily: {
      // 配置项
    },
    fontSize: {
      // 配置项
    },
    //圆角
    borderRadius: {
      // 配置项
    },
    //阴影
    boxShadow: {
      // 配置项
    },
  },
//安全列表
safelist: [
  // 配置项
],
// 阻止列表
blocklist: [
  // 配置项
],
//预设样式
preflights: [
  // 配置项
],
//变体
variants: [
  // 配置项
],
//layers 图层配置,三个不可少
layers: {
  base: 1,
  components: 2,
  utilities: 3
}
});


❌
❌