阅读视图

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

LeetCode 274. H 指数:两种高效解法全解析

在科研成果评价领域,H 指数是一个非常经典的指标,而 LeetCode 274 题正是围绕 H 指数的计算展开。这道题看似简单,但背后藏着两种思路迥异的高效解法。今天我们就来深入剖析这道题,把两种解法的逻辑、实现和优劣讲透。

一、题目回顾与 H 指数定义

首先明确题目要求:给定一个整数数组 citations,其中 citations[i] 表示研究者的第 i 篇论文被引用的次数,计算并返回该研究者的 H 指数。

核心是理解 H 指数的定义(划重点):一名科研人员的 H 指数是指他至少发表了 h 篇论文,并且这 h 篇论文每篇的被引用次数都大于等于 h。如果存在多个可能的 h 值,取最大的那个。

举个例子帮助理解:若 citations = [1,3,1],H 指数是 1。因为研究者有 3 篇论文,其中至少 1 篇被引用 ≥1 次,而要达到 h=2 则需要至少 2 篇论文被引用 ≥2 次(实际只有 1 篇3次,不满足),所以最大的 h 是 1。

二、解法一:计数排序思路(时间 O(n),空间 O(n))

先看第一种解法的代码,这是一种基于计数排序的优化方案,适合对时间效率要求较高的场景。


function hIndex_1(citations: number[]): number {
  const ciLen = citations.length;
  const count = new Array(ciLen + 1).fill(0);
  for (let i = 0; i < ciLen; i++) {
    if (citations[i] > ciLen) {
      count[ciLen]++;
    } else {
      count[citations[i]]++;
    }
  }
  let total = 0;
  for (let i = ciLen; i >= 0; i--) {
    total += count[i];
    if (total >= i) {
      return i;
    }
  }
  return 0;
};

2.1 核心思路

H 指数的最大值不可能超过论文总数 n(因为要至少 h 篇论文,h 最多等于论文数)。所以对于引用次数超过 n 的论文,我们可以统一视为引用次数为 n(不影响 H 指数的计算)。

基于这个特点,我们可以用一个计数数组 count 统计每个引用次数(0 到 n)对应的论文数量,然后从后往前累加计数,找到第一个满足「累加总数 ≥ 当前引用次数」的数值,这个数值就是最大的 H 指数。

2.2 步骤拆解(以 citations = [3,0,6,1,5] 为例)

  1. 初始化变量:论文总数 ciLen = 5,计数数组 count 长度为 ciLen + 1 = 6,初始值全为 0(count = [0,0,0,0,0,0])。

  2. 统计引用次数分布:遍历 citations 数组,将每篇论文的引用次数映射到 count 中:

     最终`count` 含义:引用 0 次的 1 篇、1 次的 1 篇、3 次的 1 篇、5 次及以上的 2 篇。
    
    • 3 ≤ 5 → count[3]++ → count = [0,0,0,1,0,0]

    • 0 ≤ 5 → count[0]++ → count = [1,0,0,1,0,0]

    • 6 > 5 → count[5]++ → count = [1,0,0,1,0,1]

    • 1 ≤ 5 → count[1]++ → count = [1,1,0,1,0,1]

    • 5 ≤ 5 → count[5]++ → count = [1,1,0,1,0,2]

  3. 倒序累加找 H 指数:从最大可能的 h(即 ciLen=5)开始,累加 count[i](表示引用次数 ≥i 的论文总数),直到累加和 ≥i:

    • i=5:total = 0 + 2 = 2 → 2 < 5 → 继续

    • i=4:total = 2 + 0 = 2 → 2 < 4 → 继续

    • i=3:total = 2 + 1 = 3 → 3 ≥ 3 → 满足条件,返回 3

最终结果为 3,符合预期(3 篇论文被引用 ≥3 次:3、6、5)。

2.3 优缺点

优点:时间复杂度 O(n),只需要两次遍历数组,效率极高;空间复杂度 O(n),仅需一个固定长度的计数数组。

缺点:需要额外的空间存储计数数组,对于论文数量极少的场景,空间开销不明显,但思路相对排序法更难理解。

三、解法二:排序思路(时间 O(n log n),空间 O(1))

第二种解法是基于排序的思路,逻辑更直观,容易理解,也是很多人首先会想到的方案。


function hIndex(citations: number[]): number {
  // 思路:逆序排序
  citations.sort((a, b) => b - a);
  let res = 0;
  for (let i = 0; i < citations.length; i++) {
    if (citations[i] >= i + 1) {
      res = i + 1;
    }
  }
  return res;
};

3.1 核心思路

将引用次数数组逆序排序(从大到小),此时排序后的数组第 i 个元素(索引从 0 开始)表示第 i+1 篇论文的引用次数。如果该元素 ≥ i+1,说明前 i+1 篇论文的引用次数都 ≥ i+1,此时 H 指数至少为 i+1。遍历完数组后,最大的这个 i+1 就是最终的 H 指数。

3.2 步骤拆解(同样以 citations = [3,0,6,1,5] 为例)

  1. 逆序排序数组:排序后 citations = [6,5,3,1,0]

  2. 遍历数组找最大 h:初始化 res = 0,依次判断每个元素:

    • i=0:citations[0] = 6 ≥ 0+1=1 → res = 1

    • i=1:citations[1] = 5 ≥ 1+1=2 → res = 2

    • i=2:citations[2] = 3 ≥ 2+1=3 → res = 3

    • i=3:citations[3] = 1 ≥ 3+1=4 → 不满足,res 不变

    • i=4:citations[4] = 0 ≥ 4+1=5 → 不满足,res 不变

  3. 返回结果:最终 res = 3,与解法一结果一致。

3.3 优缺点

优点:逻辑直观,容易理解和实现;空间复杂度低,若允许原地排序(如 JavaScript 的 sort 方法),空间复杂度为 O(log n)(排序的递归栈空间),否则为 O(1)。

缺点:时间复杂度由排序决定,为 O(n log n),对于大规模数据(如论文数量极多),效率不如解法一。

四、两种解法对比与适用场景

解法 时间复杂度 空间复杂度 核心优势 适用场景
计数排序法 O(n) O(n) 时间效率极高,两次线性遍历 大规模数据,对时间要求高
逆序排序法 O(n log n) O(1) 逻辑直观,空间开销小 小规模数据,追求代码简洁易读

五、常见易错点提醒

  1. 混淆 H 指数的定义:容易把「至少 h 篇论文 ≥h 次」写成「h 篇论文 exactly h 次」,导致判断条件错误(如之前有同学把解法一的 total ≥ i 写成 total === i)。

  2. 排序方向错误:解法二必须逆序排序(从大到小),若正序排序会导致逻辑混乱,无法正确统计。

  3. 忽略边界情况:如 citations = [0](H 指数 0)、citations = [100](H 指数 1),需确保两种解法都能覆盖这些场景。

六、总结

LeetCode 274 题的两种解法各有优劣:计数排序法以空间换时间,适合大规模数据;逆序排序法逻辑简洁,适合小规模数据。理解这两种解法的核心在于吃透 H 指数的定义——「至少 h 篇论文 ≥h 次引用」,所有的逻辑都是围绕这个定义展开的。

建议大家在练习时,先尝试自己实现逆序排序法(容易上手),再深入理解计数排序法的优化思路,通过对比两种解法的差异,加深对「时间复杂度」和「空间复杂度」权衡的理解。

🎉TinyPro v1.4.0 正式发布:支持 Spring Boot、移动端适配、新增卡片列表和高级表单页面

你好,我是 Kagol,个人公众号:前端开源星球

TinyPro 是一个基于 TinyVue 打造的前后端分离的后台管理系统,支持在线配置菜单、路由、国际化,支持页签模式、多级菜单,支持丰富的模板类型,支持多种构建工具,功能强大、开箱即用!

我们很高兴地宣布,2026年1月10日,TinyPro 正式发布 v1.4.0 版本,本次发布集中在扩展后端模板、增强移动端体验以及对 NestJS 后端功能的实用增强。

本次 v1.4.0 版本主要有以下重大变更:

  • 增加 Spring Boot 后端
  • 增强移动端适配
  • 增加卡片列表和高级表单页面
  • 支持多设备登录
  • 支持配置预览模式

你可以更新 @opentiny/tiny-toolkit-pro@1.4.0 进行体验!

tiny install @opentiny/tiny-toolkit-pro@1.4.0

详细的 Release Notes 请参考:github.com/opentiny/ti…

1 支持 Spring Boot 后端

之前只有 NestJS 后端,有不少开发者提出需要 Java 版本后端,大家的需求必须安排,所以本次版本新增对 Spring Boot 的支持,使得偏 Java / Spring 的团队可以更快速地用熟悉的后端框架搭建 TinyPro 全栈样板。

该支持包括 Docker 化示例、配置覆盖示例(application.yaml 覆写示例)以及针对 deploy 的说明,便于在容器化环境中直接部署或做二次开发。

如果你或团队偏向 Java 技术栈,这次更新显著降低了启动成本与集成难度。

详细使用指南请参考文档:Spring Boot 后端开发指南

2 移动端响应式与布局优化

本次引入移动端适配方案,包含布局调整、样式优化和若干移动交互逻辑改进。配套增加了端到端测试(E2E),保证常见移动场景(小屏导航、侧边栏收起、页签/页面切换)行为稳定。

适配覆盖了常见断点,页面在手机端的易用性和可读性有明显提升,适合需要同时兼顾桌面与移动管理后台的项目。

效果如下:

移动端效果.png

详细介绍请参考文档:TinyPro 响应式适配指南

3 增加卡片列表页面

之前列表页仅提供单一的查询表格形式,功能相对有限,难以满足日益多样化、复杂化的业务需求。为了提升用户体验、增强系统的灵活性,我们在原有基础上新增了一个卡片列表页面,以更直观、灵活的方式展示数据,满足不同场景下的使用需求。

体验地址:opentiny.design/vue-pro/pag…

效果如下:

卡片列表.png

4 增加高级表单页面

表单页增加了高级表单,在普通表单基础上增加了表格整行输入功能。

体验地址:opentiny.design/vue-pro/pag…

效果如下:

高级表单.png

5 支持多设备登录

之前只能同时一个设备登录,后面登录的用户会“挤”掉前面登录的用户,本次版本为账号登录引入设备限制(Device Limit)策略,可限制单账号并发活跃设备数,有助于减少滥用和提高安全性,适配企业安全合规需求。

可通过 nestJs/.env 中的 DEVICE_LIMIT 进行配置。

比如配置最多 2 人登录:

DEVICE_LIMIT=2

如果不想限制登录设备数,可以设置为 -1:

DEVICE_LIMIT=-1

6 演示模式

由于配置了 RejectRequestGuard,默认情况下,所有接口都只能读,不能写,本次版本增加了演示模式(PREVIEW_MODE),要修改 NestJS 后端代码才能改成可写的模式(nestJs/src/app.module.ts)。

本次版本增加了演示模式的配置,可通过 nestJs/.env 中的 PREVIEW_MODE 进行配置。

PREVIEW_MODE 默认为 true, 会拒绝所有的增加、修改、删除操作,设置为 false,则变成可写模式。

PREVIEW_MODE=false

7 Redis 引入应用安装锁(redis app install lock)

主要用于避免重复安装或初始化时的竞态问题。

默认情况下,第一次运行 NestJS 后端,会生成 Redis 锁,后续重新运行 NestJS 后端,不会再更新 MySQL 数据库的数据。

如果你修改了默认的菜单配置(nestJs/src/menu/init/menuData.ts)或者国际化词条(nestJs/locales.json),希望重新初始化数据库,可以在开发机器 Redis 中运行 FLUSHDB 进行解锁,这样重新运行 NestJS 后端时,会重新初始化 MySQL 数据库的数据。

更多更新,请参考 Release Notes:github.com/opentiny/ti…

8 社区贡献

感谢所有为 v1.4.0 做出贡献的开发者!你们的辛勤付出让 TinyPro 变得更好!

注:排名不分先后,按名字首字母排序。

如果你有任何建议或反馈,欢迎通过 GitHub Issues 与我们联系,也欢迎你一起参与 TinyPro 贡献。

往期推荐文章

联系我们

GitHub:github.com/opentiny/ti…(欢迎 Star ⭐)

官网:opentiny.github.io/tiny-pro

个人博客:kagol.github.io/blogs/

小助手微信:opentiny-official

公众号:OpenTiny

深入浅出 TinyEditor 富文本编辑器系列2:快速开始

你好,我是 Kagol,个人公众号:前端开源星球

这是《深入浅出 TinyEditor 富文本编辑器系列》的第2篇,完整的系列文章:

欢迎使用 TinyEditor - 一款基于 Quill 2.0 构建的强大富文本编辑器,提供了开箱即用的丰富模块和格式。本指南将帮助你快速高效地开始使用 TinyEditor。

架构概述

TinyEditor 采用模块化架构,通过自定义模块、格式和主题扩展了 Quill 的功能。核心结构包括:

模块架构.png

安装

基础设置

使用 npm 安装 TinyEditor:

npm install @opentiny/fluent-editor

该包以 ES 模块形式提供,包含所有必要的依赖,包括作为基础的 Quill 2.0。

项目结构

项目结构.png

基本用法

最小示例

创建一个具有最小配置的基础编辑器实例:

import FluentEditor from '@opentiny/fluent-editor'
 
const editor = new FluentEditor('#editor', {
  theme: 'snow'
})

包含多个模块的示例

这是一个展示配置多个模块的综合设置:

import FluentEditor, { CollaborationModule } from '@opentiny/fluent-editor'

// 引入协同编辑相关依赖
import * as Y from 'yjs'
import { Awareness } from 'y-protocols/awareness'
import { QuillBinding } from 'y-quill'
import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb'
import QuillCursors from 'quill-cursors'

// 注册协同编辑模块
FluentEditor.register(
  'modules/collaborative-editing',
  CollaborationModule,
  true,
)

const editor = new FluentEditor('#editor', {
  theme: 'snow',
  modules: {
    toolbar: [
      [{ 'header': [1, 2, 3, false] }],
      ['bold', 'italic', 'underline', 'strike'],
      [{ 'color': [] }, { 'background': [] }],
      [{ 'list': 'ordered'}, { 'list': 'bullet' }],
      ['link', 'image', 'video'],
      ['clean']
    ],
    // 配置协同编辑模块
    'collaborative-editing': {
        deps: {
          Y,
          Awareness,
          QuillBinding,
          QuillCursors,
          WebsocketProvider,
          IndexeddbPersistence,
        },
        provider: {
          type: 'websocket',
          options: {
            serverUrl: 'wss://ai.opentiny.design/tiny-editor/',
            roomName: 'tiny-editor-document-demo-roomName',
          },
        },
        awareness: {
          state: {
            name: `userId:${Math.random().toString(36).substring(2, 15)}`,
            color: `rgb(${Math.floor(Math.random() * 255)},${Math.floor(Math.random() * 255)},${Math.floor(Math.random() * 255)})`,
          },
        },
    },
    'mathlive': true, // 需要引入 mathlive 相关依赖
    'syntax': true // 需要引入 highlight.js 相关依赖
  }
})

详细配置请参考文档:opentiny.github.io/tiny-editor…

可用模块

TinyEditor 提供了丰富的预注册模块集:

模块 描述 用法
toolbar 带有自定义处理器的增强工具栏 toolbar: { container: TOOLBAR_CONFIG }
image 支持格式化的高级图片处理 image: true
collaborative-editing 实时协作 参见上面的协作示例
mathlive LaTeX 数学公式 mathlive: true
syntax 代码语法高亮 syntax: true
emoji 表情选择器和支持 emoji: true
mention @提及功能 mention: true

配置选项

编辑器接受扩展了 Quill 选项的综合配置对象:

选项 类型 默认值 描述
modules IEditorModules {} 模块配置
scrollingContainer HTMLElement | string | null body 自定义滚动容器
autoProtocol boolean | string false 自动为链接添加协议
editorPaste any undefined 自定义粘贴处理
screenshot Partial<ScreenShotOptions> undefined 截图配置

快速开始模板

这是一个可用于快速原型设计的即用型 HTML 模板(可直接复制到 HTML 文件中运行):

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>TinyEditor Quick Start</title>
  <style>
    #editor { 
      height: 400px; 
      border: 1px solid #ccc;
      padding: 10px;
    }
  </style>
  <!-- 引入 @opentiny/fluent-editor -->
  <script type="importmap">
    {
      "imports": {
        "@opentiny/fluent-editor": "https://unpkg.com/@opentiny/fluent-editor@3.18.3/index.es.js"
      }
    }
  </script>
  <!-- 引入 @opentiny/fluent-editor 样式 -->
  <link rel="stylesheet" href="https://unpkg.com/@opentiny/fluent-editor@3.18.3/style.css" />
</head>
<body>
  <div id="editor"></div>
  
  <script type="module">
    import FluentEditor from '@opentiny/fluent-editor'
    
    const editor = new FluentEditor('#editor', {
      theme: 'snow',
      modules: {
        toolbar: [
          ['bold', 'italic', 'underline'],
          [{ 'list': 'ordered'}, { 'list': 'bullet' }],
          ['link', 'image'],
          ['clean']
        ]
      }
    })
  </script>
</body>
</html>

效果图:

项目效果图.png

TinyEditor 类扩展了 Quill 的核心功能,同时保持与现有 Quill 配置的兼容性。这确保了现有 Quill 用户的平滑迁移路径,同时提供了对 TinyEditor 增强功能集的访问。

后续将全面介绍 TinyEditor 如何使用、设计架构、实现原理、二次开发等内容,点个关注,不迷路。

联系我们

GitHub:github.com/opentiny/ti…(欢迎 Star ⭐)

官网:opentiny.github.io/tiny-editor

个人博客:kagol.github.io/blogs/

小助手微信:opentiny-official

公众号:OpenTiny

🎉历时1年,TinyEditor v4.0 正式发布!

你好,我是 Kagol,个人公众号:前端开源星球

TinyEditor 是一个基于 Quill 2.0 的富文本编辑器,在 Quill 基础上扩展了丰富的模块和格式,框架无关、功能强大、开箱即用。

去年1月2日,我们发布了 v3.25 版本,功能基本已经完备,之后 v3.x 版本进入了维护期,同时开启了漫长的 v4.0 版本的开发,v4.0 的核心目标是体验优化和稳定性提升,并支持多人协同编辑。

在长达1年的开发和打磨后,我们荣幸地宣布 TinyEditor v4.0 正式发布!这个版本汇聚了团队的心血,带来了激动人心多人协同编辑新功能、以及大量体验优化和稳定性改进。

重点特性:

  • 支持多人协同编辑:一起在编辑器写(玩)文档(贪吃蛇游戏摸鱼)🐶
  • 基于 quill-table-up 的新表格方案:表格操作体验++⚡️
  • 基于 emoji-mart 的 Emoji 表情:表情党最爱😍
  • 支持斜杆菜单和丰富的快捷键:键盘流的福音😄
  • 图片/视频/文件上传体验优化🌄

详细的 Release Notes 请参考:github.com/opentiny/ti…

欢迎安装 v4.0 版本体验:

npm i @opentiny/fluent-editor@4.0.0

1 亮点特性

1.1 多人协作编辑

v4.0 最重磅的功能之一是引入了完整的协作编辑能力。我们集成了 quill-cursor 模块,支持多人实时协作编辑,并提供了独立的 npm 包供开发者集成。无论是需要离线支持还是云端协作,TinyEditor 都能胜任。

你可以在我们的演示项目中进行体验:opentiny.github.io/tiny-editor…

效果如下:

TinyEditor 协同编辑效果

关于协同编辑更详细的介绍,参考:如何使用 TinyEditor 快速部署一个多人协同富文本编辑器?

1.2 表格能力升级

集成了 table-up 模块,大幅提升了表格编辑和操作能力,支持更复杂的表格场景。

体验地址:opentiny.github.io/tiny-editor…

效果如下:

TinyEditor 表格模块效果

详细介绍可以参考之前的文章:更强大的表格

1.3 更丰富的 Emoji 表情😘

  • 集成 emoji-mart,提供丰富的表情选择
  • 修复了插入表情后的光标位置问题
  • 完善了表情插入的交互体验

体验地址:opentiny.github.io/tiny-editor…

效果如下:

TinyEditor Emoji 表情

详细介绍可以参考之前的文章:更丰富的表情

1.4 快捷键和快速菜单

新增了强大的快捷键系统和快速菜单功能,让高级用户能够更高效地操作编辑器。

体验地址:opentiny.github.io/tiny-editor…

效果如下:

快捷键.gif

1.5 颜色选择器升级

自定义颜色选择器现在能保存当前选择,并支持添加更多颜色。

效果如下:

image.png

1.6 文本模板与国际化

  • 支持 i18n 文本模板替换
  • 完善了国际化翻译(header、picker 等组件)
  • 更好的多语言支持体验

1.7 图片和文件增强

  • 图片工具栏:选中图片时显示专门的操作工具栏
  • 自定义上传:增加 allowInvalidUrl 选项,支持 Electron 等特定场景
  • 改进的上传逻辑:优化了失败状态的处理

2 技术改进

2.1 构建和工程化

  • 修复了 SSR 构建问题
  • 优化了 Vite 配置,解决了 PostCSS 和 Tailwind 的兼容性问题
  • 改进了 SCSS 文件引入方式
  • 输出文件名称优化

2.2 依赖管理

  • 外部化 emoji-mart 和 floating-ui 依赖,减少包体积
  • 移除了 better-table 和 lodash-es,优化依赖树

2.3 代码质量

  • 完整的测试覆盖率提升
  • 重构优化:移除冗余代码
  • API 标准化:scrollIntoView → scrollSelectionIntoView
  • 示例代码 async/await 改造,代码现代化

2.4 类型安全

  • 修复了因 TypeScript 类型导致的编译错误
  • 改进了类型定义

2.5 API 导出增强

v4.0 导出了工具栏配置常量,方便开发者定制:

  • DEFAULT_TOOLBAR:默认工具栏配置
  • FULL_TOOLBAR:完整工具栏配置

2.6 增加自动发包工作流

  • 增加 auto-publish / auto-deploy 等自动化工作流,支持打 tag 之后自动发版本、生成 Release Notes
  • PR 门禁在单元测试基础上增加 npm 包和网站构建,确保合入 PR 之前,npm 包构建和网站构建是正常的,通过自动化方式保障版本质量。

3 问题修复

v4.0 修复了大量已知问题,包括:

  • 工具栏选择器不跟随光标变化的问题
  • 行高作用域问题
  • 列表样式显示不正确
  • 背景色 SVG 图标问题
  • VitePress 默认样式影响的问题
  • 自定义上传失败时表格数据结构破坏的问题
  • 多项文档和国际化翻译问题

4 社区贡献

感谢所有为 v4.0 做出贡献的开发者!你们的辛勤付出让 TinyEditor 变得更好!

注:排名不分先后,按名字首字母排序。

如果你有任何建议或反馈,欢迎通过 GitHub Issues 与我们联系。

往期推荐文章

联系我们

GitHub:github.com/opentiny/ti…(欢迎 Star ⭐)

官网:opentiny.github.io/tiny-editor

个人博客:kagol.github.io/blogs/

小助手微信:opentiny-official

公众号:OpenTiny

提升工作效率的Utils

总结一些工作中常用到的utils,希望能帮助到大家,增加摸鱼时间

getHidePhone

获取脱敏号码

/**
 * @description 隐藏手机号
 * @param {String} content 内容
 * @param {Number} hideLen 要隐藏的长度,默认为4
 * @param {String} symbol 符号,默认为*
 * @param {String} padStartOrEnd 如果平分的长度为奇数,多出的一位填充的位置,默认为end
 * @param {Boolean} removeNan 是否移除非数字,默认为true
 * @returns {String} 隐藏后的内容
 */
export const getHidePhone = (
  content: string,
  hideLen = 4,
  symbol = '*',
  padStartOrEnd: 'start' | 'end' = 'end',
  removeNan = true,
) => {
  // 如果需要先移除非数字
  if (removeNan) {
    content = content.replace(/[^\d]/g, '')
  }

  const contentLen = content.length

  // 不是字符串、空字符串、要隐藏的长度为0直接返回原始字符串
  if (getTypeOf(content) !== 'String' || !contentLen || !hideLen) return content
  // 隐藏长度大于等于内容长度,直接返回原始字符串长度的符号
  if (contentLen <= hideLen)
    return content.replace(new RegExp(`\\d{1}`, 'g'), '*')

  const remainingLen = contentLen - hideLen
  const splitLen = Math.floor(remainingLen / 2)
  let start = splitLen
  let end = splitLen
  if (remainingLen % 2 === 1) {
    if (padStartOrEnd === 'start') {
      start += 1
    } else {
      end += 1
    }
  }

  return content.replace(
    new RegExp(`^(\\d{${start}})\\d{${hideLen}}(\\d{${end}})$`),
    `$1${symbol.repeat(hideLen)}$2`,
  )
}

console.log(getHidePhone('15108324289')) // 151****4289
console.log(getHidePhone('151')) // ***
console.log(getHidePhone('1510')) // ***0
console.log(getHidePhone('')) // ''
console.log(getHidePhone('15108324289', 6)) // 15******289
console.log(getHidePhone('15108324289', 40)) // ***********
console.log(getHidePhone('15108324289', undefined, '-')) // 151----4289
console.log(getHidePhone('15108324289', undefined, undefined, 'start')) // 1510****289
console.log(getHidePhone('15108324289', undefined, undefined, 'end')) // 151****4289
console.log(getHidePhone('151-083%#2  4289', undefined, undefined)) // 151****4289
console.log(
getHidePhone('151-083%#2  4289', undefined, undefined, undefined, false),
) // 151-083%#2  4289

formateContentBySymbol

格式化内容,根据符号进行格式化,常用于千分位分割

/**
 * @description 格式化内容,根据符号进行格式化
 * @param {String} content 内容
 * @param {String} symbol 符号
 * @param {Number} gap 符号之间的间距,默认3个空格
 * @returns {String} 格式化后的内容
 */
export const formateContentBySymbol = (
  content: string,
  symbol: string,
  gap = 3,
) => {
  return content.replace(new RegExp(`(\\d{${gap}})(?=\\d)`, 'g'), `$1${symbol}`)
}

/**
 * @description 格式化内容,根据符号进行格式化
 * @param {String} content 内容
 * @param {String} symbol 符号
 * @param {Number} gap 符号之间的间距,默认3个空格
 * @returns {String} 格式化后的内容
 */
export const formateContentBySymbol2 = (
  content: string,
  symbol: string,
  gap = 3,
) => {
  return content.replace(new RegExp(`\\B(?=(\\d{${gap}})+$)`, 'g'), symbol)
}

/**
 * @description 格式化内容,根据符号进行格式化
 * @param {String} content 内容
 * @param {String} symbol 符号
 * @param {Number} gap 符号之间的间距,默认3个空格
 * @returns {String} 格式化后的内容
 */
export const formateContentBySymbol3 = (
  content: string,
  symbol: string,
  gap = 3,
) => {
  return content.replace(
    new RegExp(`(\\d)(?=(\\d{${gap}})+$)`, 'g'),
    `$1${symbol}`,
  )
}

console.log(formateContentBySymbol('235789075433254321', ',')) // 235,789,075,433,254,321
  console.log(formateContentBySymbol2('235789075433254321', ',', 7)) // 2357,8907543,3254321
  console.log(formateContentBySymbol3('235789075433254321', ',', 2)) // 23,57,89,07,54,33,25,43,21

zeroNDigitMDecimalReg

0或者n位的数字,最多m位小数正则

/**
 * @desc 0或者n位的数字,最多m位小数
 * @param n n位的数字
 * @param m 最多m位小数
 * @returns {RegExp} 返回正则表达式
 */
export const zeroNDigitMDecimalReg = (n = 4, m = 2): RegExp => {
  if (!m) {
    // 没有小数位的情况
    return new RegExp(`^(0|([1-9][0-9]{0,${n - 1}}))$`)
  }

  // 一位小数位的情况
  if (m === 1) {
    return new RegExp(`^(0(\\.[1-9])?|([1-9][0-9]{0,${n - 1}}(\\.[1-9])?))$`)
  }

  // 二位小数位的情况
  if (m === 2) {
    return new RegExp(
      `^(0(\\.(([1-9]{1,2})|0[1-9]))?|([1-9][0-9]{0,${
        n - 1
      }}(\\.(([1-9]{1,2})|0[1-9]))?))$`,
    )
  }

  // 二位以上小数位的情况
  return new RegExp(
    `^(0(\\.(([1-9]{1,${m - 1}})|([0-9]{1,${m - 1}}[1-9]?)))?|([1-9][0-9]{0,${
      n - 1
    }}(\\.(([1-9]{1,${m - 1}})|([0-9]{1,${m - 1}}[1-9]?)))?))$`,
  )
}

console.log(zeroNDigitMDecimalReg(4, 2).test('123456789.123456789')) // false
console.log(zeroNDigitMDecimalReg(4, 10).test('1232.123456789')) // true
console.log(zeroNDigitMDecimalReg(4, 2).test('1234.1')) // true
console.log(zeroNDigitMDecimalReg(8, 6).test('13323234.000001')) // true
console.log(zeroNDigitMDecimalReg(8, 6).test('13323234.0000001')) // false

nDigitReg

0或者n位的整数正则

/**
 * @desc 0或者n位的整数正则
 * @param n 最多n位的数字
 * @param with0 是否包含0
 * @returns {RegExp} 返回正则表达式
 */
export const nDigitReg = (n = 4, with0?: boolean) => {
  if (with0) {
    // 包含0的情况
    return new RegExp(`^(0|([1-9][0-9]{0,${n - 1}}))$`)
  }

  // 不包含0的情况
  return new RegExp(`^[1-9][0-9]{0,${n - 1}}$`)
}

console.log(nDigitReg(4).test('1234')) // true
console.log(nDigitReg(6).test('1234')) // true
console.log(nDigitReg(6).test('123456789')) // false
console.log(nDigitReg().test('1234.56789')) // false
console.log(nDigitReg(undefined, true).test('0')) // true
console.log(nDigitReg(undefined).test('0')) // false

onetonnine

1-9xxxx n个9

/**
 * @desc 1-9xxxx  n个9
 * @param n n位,一共多少位数字
 */
export const onetonnine = (n = 3) => {
  return new RegExp(`^[1-9][0-9]{0,${n - 1}}$`)
}

console.log(onetonnine(4).test('1234')) // true
console.log(onetonnine(4).test('123')) // true
console.log(onetonnine(4).test('12343')) // false

zerotonnine

0-9xxxx n个9

/**
 * @desc 0-9xxxx  n个9
 * @param n n位,一共多少位数字
 */
export const zerotonnine = (n = 3) => {
  return new RegExp(`^(0|([1-9][0-9]{0,${n - 1}}))$`)
}

console.log(zerotonnine(4).test('1234')) // true
console.log(zerotonnine(4).test('123')) // true
console.log(zerotonnine(4).test('12343')) // false
console.log(zerotonnine().test('0')) // true

zerotonnine2Decimal

0-9xxxx n个9, 最多两位小数

/**
 * @description 0-9xxxx n个9, 最多两位小数
 * @param {Number} n n位,一共多少位数字,默认4位数
 * @returns {RegExp} 正则
 */
export const zerotonnine2Decimal = (n = 4) => {
  return new RegExp(
    `^(0(\\.(([1-9]{1,2})|0[1-9]))?|([1-9][0-9]{0,${
      n - 1
    }}(\\.(([1-9]{1,2})|0[1-9]))?))$`,
  )
}

console.log(zerotonnine2Decimal(4).test('1234')) // true
console.log(zerotonnine2Decimal().test('1234')) // true
console.log(zerotonnine2Decimal().test('1234.00')) // false
console.log(zerotonnine2Decimal().test('1234.01')) // true
console.log(zerotonnine2Decimal().test('1234.1')) // true
console.log(zerotonnine2Decimal().test('1234.10')) // false
console.log(zerotonnine2Decimal().test('1234.001')) // false
console.log(zerotonnine2Decimal(6).test('123456789')) // false
console.log(zerotonnine2Decimal(6).test('0')) // true
console.log(zerotonnine2Decimal(6).test('0.01')) // true

onetonnine2Decimal

1-9xxxx n个9, 最多两位小数

/**
 * @description 1-9xxxx n个9, 最多两位小数
 * @param {Number} n n位,一共多少位数字,默认4位数
 * @returns {RegExp} 正则
 */
export const onetonnine2Decimal = (n = 4) => {
  return new RegExp(`^[1-9][0-9]{0,${n - 1}}(\\.(([1-9]{1,2})|0[1-9]))?$`)
}

console.log(onetonnine2Decimal(4).test('1234')) // true
console.log(onetonnine2Decimal().test('1234')) // true
console.log(onetonnine2Decimal().test('1234.00')) // false
console.log(onetonnine2Decimal().test('1234.01')) // true
console.log(onetonnine2Decimal().test('1234.1')) // true
console.log(onetonnine2Decimal().test('1234.10')) // false
console.log(onetonnine2Decimal().test('1234.001')) // false
console.log(onetonnine2Decimal(6).test('123456789')) // false
console.log(onetonnine2Decimal(6).test('0')) // false
console.log(onetonnine2Decimal(6).test('0.01')) // false

setCliboardContent

复制文本的通用函数

/**
 * @description 复制文本的通用函数
 * @param {String} content 要复制的内容
 */
export function setCliboardContent(content?: string) {
  if (!content) return

  const selection = window.getSelection()
  if (selection?.rangeCount) {
    selection?.removeAllRanges()
  }
  const el = document.createElement('textarea')
  el.value = content || ''
  el.setAttribute('readonly', '')
  el.style.position = 'absolute'
  el.style.left = '-9999px'
  document.body.appendChild(el)
  el.select()
  document.execCommand('copy')
  el.remove()
}

getCliboardValue

获取剪切板中的内容

/**
 * @description 获取剪切板中的内容
 * @returns 剪切板内容
 */
export function getCliboardValue() {
  const el = document.createElement('input')
  el.style.position = 'absolute'
  el.style.left = '-9999px'
  document.body.appendChild(el)
  el.select()
  document.execCommand('paste')
  // 获取文本输入框中的值
  const clipboardValue = el.value
  el.remove()
  return clipboardValue
}

delay

延迟执行

/**
 * @description 延迟执行
 * @param wait 延迟时间
 * @returns
 */
export function delay(wait = 1000) {
  return new Promise((resolve) => setTimeout(resolve, wait))
}

getTypeOf

获取数据类型

/**
 * @description 获取数据类型
 * @param data
 * @returns {String} 获取到的数据类型
 */
export function getTypeOf(data: any) {
  return Object.prototype.toString.call(data).slice(8, -1)
}

trimStart

去除字符串开头的空格

/**
 * @description 去除字符串开头的空格
 * @param {String} str 要处理的字符串
 * @returns {String} 去除空格后的字符串
 */
export const trimStart = (str: string) => {
  if (getTypeOf(str) !== 'String') {
    return str
  }
  return str?.replace(/^(\s+)(.*)$/g, '$2')
}

trimEnd

去除字符串结尾的空格

/**
 * @description 去除字符串结尾的空格
 * @param {String} str 要处理的字符串
 * @returns {String} 去除空格后的字符串
 */
export const trimEnd = (str: string) => {
  if (getTypeOf(str) !== 'String') {
    return str
  }
  return str?.replace(/^(.*)(\s+)$/g, '$1')
}

trimAll

去除字符串中的所有空格

/**
 * @description 去除字符串中的所有空格
 * @param {String} str 要处理的字符串
 * @returns {String} 去除空格后的字符串
 */
export const trimAll = (str: string) => {
  if (getTypeOf(str) !== 'String') {
    return str
  }
  return str?.replace(/\s+/g, '')
}

compressPic

压缩图片

/**
 * @description 压缩图片
 * @param {File} file 要处理的图片文件
 * @param {Number} quality 压缩质量
 * @returns {Promise<File | Blob>} 压缩后的图片文件
 */
export async function compressPic(
  file: File,
  quality = 0.6,
): Promise<File | Blob> {
  return new Promise((resolve) => {
    try {
      const reads = new FileReader()
      reads.readAsDataURL(file)
      reads.onload = ({ target }) => {
        // 这里quality的范围是(0-1)
        const canvas = document.createElement('canvas')
        const ctx = canvas.getContext('2d')!
        const img = new Image()
        img.src = (target as any)?.result
        img.onload = function () {
          const width = img.width
          const height = img.height
          canvas.width = width
          canvas.height = height
          ctx.drawImage(img, 0, 0, width, height)
          // 转换成base64格式 quality为图片压缩质量 0-1之间  值越小压缩的越大 图片质量越差
          canvas.toBlob(
            (blob) => {
              resolve(blob!)
            },
            file.type,
            quality,
          )
        }
      }
      reads.onerror = () => {
        resolve(file)
      }
    } catch {
      resolve(file)
    }
  })
}

randomString

生成指定长度的随机字符串

/**
 * @description 生成指定长度的随机字符串
 * @param {Number} length 输出字符串的长度
 * @param {Number} radix 字符串的基数,默认为36(包括0-36)
 * @returns 返回指定长度的随机字符串,全部为大写
 */
export const randomString = (length: number, radix = 36) => {
  // 生成一个随机字符串,基数为radix,并去除前两位"0."
  let str = Math.random().toString(radix).substring(2)
  // 如果生成的字符串长度大于等于所需长度,则截取前length个字符并转为大写
  if (str.length >= length) {
    return str.substring(0, length).toLocaleUpperCase()
  }
  // 如果字符串长度不足,递归调用自身以生成剩余长度的字符串,并拼接到原字符串上
  str += randomString(length - str.length, radix)
  // 将最终字符串转为大写并返回
  return str.toLocaleUpperCase()
}

scrollToBottom

滚动到底部

/**
 * @description 滚动到底部
 * @param {String} selector 类名
 */
export const scrollToBottom = (selector?: string) => {
  const domWrapper = selector
    ? document.querySelector(selector)
    : document.documentElement || document.body // 外层容器 出现滚动条的dom
  if (domWrapper) {
    domWrapper.scrollTo({ top: domWrapper.scrollHeight, behavior: 'smooth' })
  }
}

scrollToTop

滚动到顶部

/**
 * @description 滚动到顶部
 * @param {String} selector 类名
 */
export const scrollToTop = (selector?: string) => {
  const domWrapper = selector
    ? document.querySelector(selector)
    : document.documentElement || document.body // 外层容器 出现滚动条的dom
  if (domWrapper) {
    domWrapper.scrollTo({ top: 0, behavior: 'smooth' })
  }
}

isJSON

判断是否为JSON字符串

/**
 * @description 判断是否为JSON字符串
 * @param {String} str 字符串
 * @returns {Boolean} 是否为JSON字符串
 */
export function isJSON(str: string) {
  if (typeof str !== 'string') {
    // 1、传入值必须是 字符串
    return false
  }

  try {
    const obj = JSON.parse(str) // 2、仅仅通过 JSON.parse(str),不能完全检验一个字符串是JSON格式的字符串
    if (typeof obj === 'object' && obj) {
      //3、还必须是 object 类型
      return true
    }
    return false
  } catch {
    return false
  }
}

getRandomIntInclusive

生成指定范围内的随机整数(包含最小值和最大值)

/**
 * 生成指定范围内的随机整数(包含最小值和最大值)
 * @param min 最小值(包含)
 * @param max 最大值(包含)
 * @returns 指定范围内的随机整数
 */
export function getRandomIntInclusive(
  min: number = 1,
  max: number = 100,
): number {
  return Math.floor(Math.random() * (max - min + 1)) + min
}

🔥Angular高效开发秘籍:掌握这些新特性,项目交付速度翻倍

感谢贡献者 12点睡觉 提供的优质好文!

一、为什么要学习 Angular 新特性

1.1 旧版 Angular 开发痛点

  • 配置冗余:NgModule 套娃,组件复用需导入多个模块
  • 性能瓶颈:首屏加载慢(RTTI 长),非必要资源打包进首屏
  • 开发低效:模板嵌套复杂(*ngIf/*ngFor),响应式编程学习成本高(RxJS)
  • 稳定性差:输入空值 Bug 多,缺乏编译时校验

二、核心新特性介绍

2.1 独立组件(Standalone Components)

2.1.1 特性介绍

独立组件通过在 @Component 装饰器中配置 standalone: true,将「依赖管理能力」从 NgModule 下沉到组件本身,可直接通过 imports 数组导入所需的组件、指令、管道,无需封装到 NgModule 中,本质是简化项目层级、提升组件复用性。

2.1.2 基础实践:独立组件的使用

// 1. 独立组件定义(无需关联 NgModule)
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MyPipe } from './my.pipe'; // 直接导入管道/指令

@Component({
  selector: 'app-demo',
  standalone: true, 
  imports: [CommonModule, MyPipe], // 组件内直接导入依赖
  template: `<div>{{ 'test' | myPipe }}</div>`
})
export class DemoComponent {}

// 传统 NgModule 组件(冗余)
@NgModule({
  declarations: [DemoComponent, MyPipe], // 声明组件/管道
  imports: [CommonModule],
  exports: [DemoComponent]
})
export class DemoModule {}

2.1.3 进阶实践:独立组件共享依赖(避免重复导入)

// shared-directives.ts
import { CommonModule } from '@angular/common';
import { CustomInputDirective } from './custom-input.directive';
import { FormatDatePipe } from './format-date.pipe';

// 导出共享依赖,供其他独立组件批量导入
export const SharedDirectives = [
  CommonModule,
  CustomInputDirective,
  FormatDatePipe
];


// user-form.component.ts
import { Component } from '@angular/core';
import { SharedDirectives } from './shared-directives'; // 批量导入共享依赖

@Component({
  selector: 'app-user-form',
  standalone: true,
  imports: SharedDirectives, // 无需逐个导入指令/管道
  template: `
    <input [appCustomInput]="true" placeholder="用户名">
    <p>注册时间:{{ registerTime | formatDate }}</p>
  `
})
export class UserFormComponent {
  registerTime = new Date();
}

2.1.4 独立组件路由配置

import { Routes } from "@angular/router";
import { HomeComponent } from "./home.component"; // 独立组件

export const routes: Routes = [
  { path: "", component: HomeComponent }, // 直接使用独立组件
  // 懒加载独立组件(无需包装 NgModule)
  {
    path: "user/:id",
    loadComponent: () => import("./user-profile.component"),
  },
  // 独立组件下还存在子路由时,可直接引入路由配置常量
  {
    path: "user",
    loadChildren: () => import("./user.route.ts").then((m) => m.UserRoutes),
  },
];

2.1.5 优缺点分析

优点 缺点
1. 简化项目结构:减少冗余的 NgModule 文件 1. 需手动管理组件间的导入关系,无 NgModule 统一视角
2. 提升组件复用性:独立组件可直接跨项目导入(无需携带关联模块) 2. 旧项目兼容成本:需为旧模块组件补充 imports: [CommonModule] 适配新语法
3. 优化懒加载性能:loadComponent 比 loadChildren 轻量(减少模块包装开销) 3. 部分第三方库适配滞后:少数旧库仍依赖 NgModule,需通过 imports: [LegacyModule] 兼容

2.2 新控制流语法

2.2.1 特性介绍

控制流是一种将流程控制直接写入模板的新声明性语法,从而无须使用 *ngIf*ngFor*ngSwitch 这种基于指令(Directive)的控制流

2.2.2 场景 1:@if 语法(含 as 字符、@error 捕获)

<!-- 1. @if + else if + else(替代嵌套 *ngIf) -->
<div class="order-status">
  @if (order.status === 'pending') {
    <span class="status-pending">待支付</span>
    <button (click)="payOrder()">立即支付</button>
  } @else if (order.status === 'paid') {
    <span class="status-paid">已支付</span>
    <button (click)="viewLogistics()">查看物流</button>
  } @else if (order.status === 'shipped') {
    <span class="status-shipped">已发货</span>
  } @else {
    <span class="status-completed">已完成</span>
  }
</div>

2.2.3 场景 2:@for 语法(含 track、@empty)

<!-- 1. 基础用法:track 配置(替代 trackBy 函数) -->
<ul class="product-list">
  @for (product of products; track product.id;let i = $index, let even = $even) {
    <li class="product-item">
      <img [src]="product.image" alt="{{ product.name }}">
      <h4>{{ product.name }}</h4>
      <p class="price">¥{{ product.price }}</p>
    </li>
  } @empty {
    <!-- 列表为空时显示,替代 *ngIf="products.length === 0" -->
    <li class="empty">暂无商品数据</li>
  }
</ul>

支持原*ngFor中的变量

  • $index 获取当前项的索引
  • $first 当前项是否是第一个
  • $last 当前项是否是最后一项
  • $even 当前项是否处于偶数索引
  • $odd 当前项是否处于奇数索引
  • $count 获取集合的长度

2.2.4 场景 3:@error语法

<!-- @error 仅作用于 @if/@for 结合 async 管道的上下文-->
@if (user$ | async; as user; loading isLoading; error errorInfo) {
  <div>{{ 数据变量名.属性 }}</div>
} @loading {
  <!-- 可选:异步流未完成时的加载状态
  <div>加载中...</div>
} @error {
  <!-- @error 语义:渲染失败时的兜底逻辑 -->
  <div>错误:{{ errorInfo.message }}</div>
}

可监听的错误类型

  • 异步流自身抛出的错误

    HTTP 请求错误 接口返回 404/403/500 状态码、网络中断、CORS 配置错误
    异步操作超时 data$.pipe(timeout(3000)) 超时触发 TimeoutError
    Promise 执行错误 new Promise((_, reject) => reject(new Error('执行失败')))
    业务逻辑主动抛错 data$.pipe(map(res => { if (!res.id) throw new Error('ID缺失'); }))
    流取消 / 终止错误 异步流被手动 unsubscribe 且抛错、流内部未捕获的执行错误
  • 异步数据解析 / 转换错误

    子类型 触发示例
    数据格式不匹配 期望数组却返回对象:list$ = of({ name: 'test' }) + @for 渲染
    JSON 解析错误 data$ = http.get('/api/data').pipe(map(res => JSON.parse(res)))(非 JSON 字符串)
    类型转换错误 异步返回字符串却做数字运算:{{ data * 2 }}data$ = of('abc')
  • 模板渲染异步数据的运行时错误

    子类型 触发示例
    空值 / 未定义访问 data$ = of(null) + 模板中 {{ data.name }}
    数组方法调用错误 data$ = of(123) + 模板中 {{ data.filter(item => item > 0) }}
    管道执行错误 data$ = of('2025-13-01') + 模板中 {{ data | date }}(非法日期)
    模板内函数调用错误 data$ = of('test') + 模板中 {{ formatData(data) }}(formatData 抛错)

2.2.5 场景 4:@switch 语法(替代 *ngSwitch)

<div class="role-container">
  @switch (user.role) {
    @case ('admin') {
      <div class="role-tag admin">
        <span>管理员</span>
        <button (click)="showAdminMenu()">管理菜单</button>
      </div>
    }
    @case ('editor') {
      <div class="role-tag editor">
        <span>编辑</span>
        <button (click)="showEditorTools()">编辑工具</button>
      </div>
    }
    @case ('viewer') {
      <div class="role-tag viewer">
        <span>查看者</span>
      </div>
    }
    @default {
      <div class="role-tag guest">
        <span>访客</span>
        <button (click)="goToLogin()">登录</button>
      </div>
    }
  }
</div>

2.2.6 优缺点分析

优点 缺点
1. 性能提升:编译后减少 DOM 操作次数,比旧指令快 15%-20% 1. 迁移成本:旧项目需批量修改模板,复杂嵌套逻辑需手动适配
2. 语法直观:支持 @else if/@empty,减少嵌套层级(如替代 *ngIf 嵌套) 2. IDE 支持滞后:部分旧版 IDE 语法高亮不完整
3. 减少错误:@for 强制要求 track,避免因忘记 trackBy 导致的列表重渲染性能问题 3. 兼容性限制:仅 Angular17+ 支持,无法降级到旧版本

2.3 信号(Signals)

2.3.1 核心概念与基础 API 总览

Signals 是 Angular17 推出的轻量级响应式状态管理方案,核心解决传统 BehaviorSubject 需手动订阅、变更检测冗余的问题,API 分为三类:

API 类型 具体 API 作用 适用场景
基础信号操作 signal(initialValue) 创建基础信号(初始值不可变) 定义组件内 / Service 内状态
set(newValue) 全量替换信号值(覆盖旧值) 直接赋值(如表单输入、状态重置)
update(prev => new) 基于旧值计算新值(函数式更新) 累加、过滤、修改部分属性
mutate(prev => void) (NG18已废弃删除) 直接修改引用类型内部值(不创建新引用) 数组 push/pop、对象属性修改
计算信号 computed(() => value) 依赖其他信号的衍生值(自动响应变化) 计算总价、筛选列表、格式转换
副作用监听 effect(() => void) 信号变化时执行副作用(如日志、请求) 状态变化后触发 API、更新 DOM

2.3.2 基础 API 实战

import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <div class="counter">
      <h3>当前计数:{{ count() }}</h3>
      <button (click)="resetCount()">重置为 0</button>
      <button (click)="increment()">+1</button>
    </div>
  `,
})
export class CounterComponent {
  // 创建基础信号(初始值为 0)
  count = signal(0);

  // 计算信号
  totalCount = computed(() => this.count ()*2);

  // set:全量替换信号值
  resetCount() {
    this.count.set(0);
  }

  // update:基于旧值累加 1
  increment() {
    this.count.update(prev => prev + 1);
  }
}

2.3.4 effect 副作用清理(避免内存泄漏)

当effect中包含订阅(如定时器、API 订阅)时,需通过清理函数释放资源,避免组件销毁后仍执行:

import { Component, signal, effect, OnDestroy } from '@angular/core';
import { interval } from 'rxjs';

@Component({
  selector: 'app-timer',
  standalone: true,
  template: `<p>当前计数:{{ count() }}</p>`
})
export class TimerComponent implements OnDestroy {
  count = signal(0);
  // 存储effect清理函数(用于组件销毁时调用)
  private effectCleanup?: () => void;

  constructor() {
    // effect返回清理函数,用于释放资源
    this.effectCleanup = effect(() => {
      // 场景:当count>5时,启动定时器(需清理)
      if (this.count() > 5) {
        const timer = interval(1000).subscribe(() => {
          this.count.update(c => c + 1);
        });
        // 清理函数:组件销毁时取消订阅
        return () => timer.unsubscribe();
      }
    });
  }

  // 组件销毁时执行清理
  ngOnDestroy() {
    this.effectCleanup?.();
  }

  // 外部触发count增加
  increment() {
    this.count.update(c => c + 1);
  }
}

2.3.5 signal 与 Observable 互转(适配异步场景)

通过toSignal()(Observable 转信号)和fromSignal()(信号转 Observable),适配既有 RxJS 代码:

import { Component } from '@angular/core';
import { signal, toSignal } from '@angular/core';
import { fromSignal } from '@angular/core/rxjs-interop';
import { interval, switchMap, debounceTime } from 'rxjs';

@Component({
  selector: 'app-signal-observable',
  standalone: true,
  template: `
    <p>当前用户:{{ user()?.name }}</p>
    <input [(ngModel)]="searchInput()" placeholder="搜索...">
    <p>搜索结果:{{ searchResult()?.length || 0 }} 条</p>
  `
})
export class SignalObservableComponent {
  // 1. Observable转信号:适配API请求(如HttpClient返回Observable)
  userId = signal(1);
  // 当userId变化时,自动重新请求用户信息(switchMap切换请求)
  user$ = fromSignal(this.userId).pipe(
    switchMap(id => fetch(`/api/user/${id}`).then(res => res.json()))
  );
  // 将Observable转为信号,供模板使用(initialValue避免undefined)
  user = toSignal(this.user$, { initialValue: { name: '默认用户' } });

  // 2. 信号转Observable:适配RxJS操作符(如debounceTime)
  searchInput = signal('');
  // 将信号转为Observable,添加防抖
  searchResult$ = fromSignal(this.searchInput).pipe(
    debounceTime(300), // 输入停止300ms后执行搜索
    switchMap(keyword => fetch(`/api/search?kw=${keyword}`).then(res => res.json()))
  );
  // 转为信号供模板使用
  searchResult = toSignal(this.searchResult$, { initialValue: [] });
}

2.3.6 信号(Signals)与 Observable 订阅的场景对比

适用场景 选择信号(Signals) 选择 Observable 订阅
组件内简单状态管理 ✅ 优先选择:计数器、弹窗显隐、表单输入状态 ❌ 不推荐:语法繁琐,需手动管理订阅
多依赖衍生值计算 ✅ 优先选择:购物车总价、筛选列表、格式转换 ❌ 不推荐:需用 combineLatest 等操作符,语法复杂
跨组件共享简单状态 ✅ 优先选择:主题切换、登录状态、全局开关 ❌ 不推荐:需用 Subject 封装,内存管理复杂
复杂异步流(多请求合并) ❌ 不推荐:需通过 toSignal() 适配,无原生操作符 ✅ 优先选择:switchMap/forkJoin 等操作符原生支持
高频事件处理(防抖 / 节流) ❌ 不推荐:需转 Observable 后使用操作符 ✅ 优先选择:debounceTime/throttleTime 原生支持
实时数据流(WebSocket) ❌ 不推荐:需转 Observable 处理持续事件流 ✅ 优先选择:原生支持 next/complete 事件

2.4 延迟加载模板(@defer)- 全场景覆盖

2.4.1 语法原理

@defer 是 Angular17 新增的模板级延迟加载语法,核心是 “按需加载非首屏内容”(如弹窗、折叠面板内组件),避免首屏加载冗余的 JS/CSS 资源。支持四大核心能力:

  1. 触发条件:on(用户交互)、when(条件满足);
  2. 状态提示:@placeholder(加载中)、@loading(加载中)、@error(加载失败)、@empty(内容为空);
  3. 预加载:prefetch(提前加载即将用到的资源);
  4. 性能优化:加载完成后自动替换占位内容,无闪烁。

2.4.2 基础用法(默认触发:组件初始化后延迟加载)

适用于 “首屏非关键内容”(如页面底部的推荐列表):

<!-- 首屏优先加载核心内容(用户信息) -->
<div class="user-info">
  <h3>{{ user.name }}</h3>
  <p>{{ user.email }}</p>
</div>

<!-- 延迟加载非关键内容(推荐列表) -->
@defer {
  <app-recommended-list [userId]="user.id"></app-recommended-list>
} @placeholder (minimum 500ms){
  <!-- 加载前状态:占位内容 -->
  <div class="comment-skeleton">
      <div class="skeleton-line"></div>
      <div class="skeleton-line"></div>
      <div class="skeleton-avatar"></div>
    </div>
} @loading {
  <!-- 加载中状态:替代ngIf+loading变量 -->
  <div class="skeleton">推荐内容加载中...</div>
}

2.4.3 on 触发(用户交互时加载)

适用于 “用户主动触发才显示的内容”(如点击按钮显示的详情面板):

<!-- 按钮触发:点击后加载详情组件 -->
<button #detailBtn>查看订单详情</button>

@defer (on click(detailBtn)) { <!-- 点击按钮时触发加载 -->
  <app-order-detail [orderId]="currentOrderId"></app-order-detail>
} @loading {
  <div class="loading-spinner">加载详情中...</div>
} @error {
  <div class="error">详情加载失败,请重试</div>
}

<!-- 其他触发事件:hover/focus -->
@defer (on hover(detailCard)) { <!-- 鼠标悬浮时加载 -->
  <div class="card-tooltip">订单创建时间:{{ order.createTime }}</div>
}

<!-- 其他触发事件:scroll -->
@defer (on scroll(detailCard, 200px)) {
  <div class="loading-more">加载更多商品中...</div>
  <ng-container *ngIf="moreProducts$ | async as more">
    @for (item of more; track item.id) {
      <app-product-card [product]="item"></app-product-card>
    }
  </ng-container>
}

2.4.4 when 触发(条件满足时加载)

适用于 “数据就绪 / 状态变化时加载”(如筛选条件选择后加载表格):

<!-- 条件触发:筛选条件选中后加载表格 -->
<select [(ngModel)]="selectedType" (change)="onTypeChange()">
  <option value="">请选择订单类型</option>
  <option value="all">全部订单</option>
  <option value="paid">已支付</option>
</select>

@defer (when selectedType !== '') { <!-- 当selectedType非空时加载 -->
  <app-order-table [type]="selectedType"></app-order-table>
} @loading {
  <div class="table-skeleton">表格加载中...</div>
} @empty {
  <div>暂无{{ selectedType === 'paid' ? '已支付' : '全部' }}订单</div>
}

2.4.5 prefetch 预加载(提前加载即将用到的内容)

适用于 “即将触发加载” 的场景(如滚动到接近位置时预加载),减少用户等待时间:

<!-- 预加载:当用户滚动到距离组件500px时,提前加载 -->
@defer (on scroll(container, 500px); prefetch on scroll(container, 1000px)) {
  <!-- 滚动到距离组件1000px时预加载资源,滚动到500px时显示 -->
  <app-long-list [page]="currentPage"></app-long-list>
}

<!-- 容器滚动监听:指定scroll的参考容器 -->
<div #container class="scroll-container" style="height: 500px; overflow-y: auto;">
  <!-- 其他内容 -->
</div>

2.4.6 @error语法

专为「defer 懒加载全流程」设计的错误捕获块,仅作用于 defer 块内部,捕获从「懒加载资源下载」到「组件初始化 / 渲染」的全链路错误,属于「资源加载层错误兜底」。

@defer
<!-- 懒加载目标组件 -->
<app-lazy></app-lazy>
} @placeholder {
<!-- 触发前占位:点击按钮加载 -->
<button>点击加载懒组件</button>
} @loading {
<!-- 加载中状态 -->
<div>加载中...</div>
} @error {
<!-- 懒加载错误捕获:$error 是内置错误对象 -->
<div>❌ 懒加载失败:{{ $error.message }}</div>
}

可监听的错误类型

  • 懒加载资源下载错误
类型 触发示例
JS/CSS chunk 404 打包后的懒加载 chunk 路径错误、CDN 缓存失效、文件名哈希变更
网络层错误 下载 chunk 时断网、网络超时、DNS 解析失败
跨域 / 安全限制 chunk 资源违反 CORS/CSP 策略,浏览器拦截下载
混合内容错误 HTTPS 页面加载 HTTP 协议的懒加载 JS chunk(浏览器阻止)
资源大小超限 chunk 体积超过服务器 / 浏览器限制(如 nginx client_max_body_size 限制)
  • 懒加载组件编译 / 解析错误
类型 触发示例
组件代码语法错误 懒加载组件的 TS 代码有语法错误(如少分号、变量未定义)
模板语法错误 懒加载组件的模板有语法错误(如 {{ data..name }} 双点、指令拼写错误)
组件元数据错误 懒加载组件的 @Component 元数据错误(如 selector 重复、imports 漏写)
二进制 chunk 损坏 下载的 JS chunk 二进制数据不完整(如网络中断导致下载一半)
  • 懒加载组件初始化错误
类型 触发示例
生命周期抛错 懒加载组件 ngOnInit 中调用接口抛错、ngAfterViewInit 操作 DOM 抛错
组件状态初始化错误 懒加载组件的 Signal / 变量初始化抛错(如 count = signal(1/0)
模板渲染初始化错误 组件首次渲染时,同步模板表达式错误(如 {{ undefined.name }}
  • 懒加载组件依赖注入错误
类型 触发示例
服务未提供 懒加载组件依赖 UserService,但未在 providers/ 根注入器中配置
依赖循环引用 懒加载组件依赖的服务与其他服务形成循环引用,导致注入失败
管道 / 指令未导入 懒加载组件模板使用 myPipe,但组件 imports 未导入该管道

2.4.7 优缺点分析

优点 缺点
1. 首屏加载提速:减少首屏 JS/CSS 体积(非关键组件延迟加载) 1. 触发时机需谨慎:滥用可能导致用户交互时卡顿(如点击后才加载大组件)
2. 简化状态管理:内置 @loading/@error,无需手动维护 loading/error 变量 2. 调试成本:需通过 Angular DevTools 查看延迟加载状态,无法直接断点调试
3. 预加载优化:prefetch 可平衡加载时机与用户体验,减少等待时间 3. 兼容性限制:仅 Angular17+ 支持,无法降级到旧版本
4. 语法直观:无需手动写 ngIf 控制显隐,模板逻辑更简洁 4. 复杂场景适配难:动态组件加载(如 ComponentFactoryResolver)需额外处理

2.5 指令组合 API

2.5.1 核心定义

组件通过hostDirectives配置,直接 “继承” 其他指令的逻辑(属性、方法、生命周期)

2.5.2 解决痛点

旧版用 mixin(混入)复用逻辑,代码冗余且类型不安全;模板中重复绑定指令

2.5.3 实战代码

// 1. 定义独立指令(封装通用逻辑)
@Directive({ selector: '[appAuth]', standalone: true })
export class AuthDirective {
  @Input() appAuth!: string; // 接收权限标识
  ngOnInit() {
    console.log('校验权限:', this.appAuth); // 通用权限校验逻辑
  }
}

// 2. 组件集成指令(无需模板绑定)
@Component({
  selector: 'app-admin-panel',
  standalone: true,
  hostDirectives: [
    { directive: AuthDirective, inputs: ['appAuth'] } // 集成指令,映射输入
  ],
  template: `<div>管理员面板</div>`
})
export class AdminPanelComponent { }

// 3. 使用组件(直接传递指令输入)
<app-admin-panel appAuth="admin"></app-admin-panel>

2.6 NgOptimizedImage

2.6.1 核心定义

Angular 15 + 稳定的图片优化指令,替代原生img,一站式解决图片加载性能问题

2.6.2 解决痛点

原生图片易导致布局偏移(CLS)、加载慢、格式不优化、缺乏优先级控制

2.6.3 核心能力

强制宽高比(防 CLS)、自动懒加载、自动格式转换(WebP/AVIF)、优先级控制

2.6.4 实战代码

<!-- 1. 首屏核心图片(优先加载,禁用懒加载) -->
<img 
  ngSrc="home-banner.jpg" 
  width="1200" 
  height="400" 
  priority <!-- 核心首屏优先加载 -->
  alt="首页Banner"
>

<!-- 2. 非首屏图片(自动懒加载,优化格式) -->
<img 
  ngSrc="user-avatar.jpg" 
  width="80" 
  height="80" 
  loading="lazy" <!-- 默认懒加载可省略 -->
  alt="用户头像"
>

<!-- 3. 响应式图片(适配不同设备) -->
<img 
  ngSrc="product-{{size}}.jpg" 
  [width]="size === 'sm' ? 300 : 600"
  [height]="size === 'sm' ? 200 : 400"
  [size]="'(max-width: 640px) 300px, 600px'"
  alt="商品图片"
>

2.7 canMatch 路由守卫

2.7.1 核心定义

Angular 15 + 稳定的路由守卫,在 “路由匹配阶段” 筛选路由,决定是否将路由纳入候选

2.7.2 解决痛点

旧版canActivate在路由匹配后执行,失败则拒绝访问,无法实现 “同路径多组件” 动态匹配(如多租户)

2.7.3 核心差异(vs canActivate)

  • canActivate:匹配后准入控制 → 失败 = 拒绝访问
  • canMatch:匹配中筛选 → 失败 = 跳过当前路由,继续匹配下一个

2.7.4 执行顺序

  1. matcher:匹配 URL 规则;
  2. canMatch:校验是否允许匹配该路由;
  3. canLoad:校验是否允许加载懒加载模块;
  4. 加载模块(若 canLoad 通过);
  5. canActivate:校验是否允许激活路由;
  6. 激活路由,渲染组件。

2.7.5 实战代码(多租户场景)

// 1. 定义canMatch守卫
export const tenantMatchGuard: CanMatchFn = (route) => {
  const tenantService = inject(TenantService);
  return tenantService.getCurrentTenant() === route.data.tenantId;
};

// 2. 路由配置(同路径匹配不同租户组件)
const routes: Routes = [
  { 
    path: 'dashboard', 
    canMatch: [tenantMatchGuard], 
    data: { tenantId: 'tenant1' }, 
    component: Tenant1DashboardComponent 
  },
  { 
    path: 'dashboard', 
    canMatch: [tenantMatchGuard], 
    data: { tenantId: 'tenant2' }, 
    component: Tenant2DashboardComponent 
  },
  { path: 'dashboard', component: FallbackDashboardComponent } // 兜底
];

2.8 依赖注入(DI)增强:更灵活的注入方式

2.8.1 核心定义

Angular 16 + 优化的 DI 系统,支持inject函数在构造函数外使用,增强环境区分能力

2.8.2 解决痛点

旧版inject仅能在构造函数 / 工厂函数中使用,静态方法、第三方库回调中无法注入

2.8.3 实战代码(常用场景)

场景 1:类内任意位置注入
@Injectable({ providedIn: 'root' })
export class ConfigService {
  private http = inject(HttpClient); // 类内直接注入,无需构造函数

  loadConfig() {
    return this.http.get('/api/config');
  }
}
场景 2:静态方法中注入
@Injectable({ providedIn: 'root' })
export class TenantService {
  static getCurrentTenant() {
    const configService = inject(ConfigService); // 静态方法注入
    return configService.loadConfig().then(res => res.tenantId);
  }
}

2.9 Required Inputs:编译时校验的必填输入

2.9.1 核心定义

Angular 16 + 稳定的输入属性校验特性,通过@Input({ required: true })标记必填,编译时 + 运行时双校验

2.9.2 解决痛点

旧版需手动在ngOnInit中判空,易遗漏导致空值 Bug;错误反馈滞后(运行时才报错)

2.9.3 实战代码

// 1. 标记必填输入
@Component({
  selector: 'app-user-card',
  standalone: true,
  template: `<div>{{user.name}}</div>`
})
export class UserCardComponent {
  @Input({ required: true }) user!: { name: string; id: string }; // 核心:required: true
}

// 2. 使用组件(未传user时编译报错)
<app-user-card [user]="userData"></app-user-card> <!-- 正确 -->
<app-user-card></app-user-card> <!-- 错误:编译时报错 NG8007 -->

三、优秀实践——懒加载

学完上述 9 个实用特性,我们不妨把它们串联起来落地实践。这一节,我们聚焦性能优化的高频需求 —— 懒加载,详细讲讲如何通过 独立组件与 defer 延迟加载语法的结合,实现高效的按需加载方案。

3.1 必要条件

相信不少开发者刚接触 @defer 延迟加载语法时,都会遇到一个共性问题 —— 明明写了语法,懒加载却始终不生效。其实这背后,是因为我们忽略了几个关键的必要条件。

假设组件 A 是与 @defer 指令处于同一层级的组件,而组件 B 是被 @defer 指令包裹、需要实现懒加载的目标组件。

必要条件:

  • 组件A必须是独立组件:standalone: true;
  • 组件B必须是独立组件:standalone: true;
  • 组件A需显式导入组件B: imports:[RepoEchartComponent];

3.2 实战代码

3.2.1 场景一:评论区懒加载(用户滚动到区域才加载)

<section>
  <h2>商品详情</h2>
  <!-- 其他内容 -->

  @defer (on viewport; prefetch on idle) {
    <app-comments></app-comments>
  } @loading {
    <div>评论加载中...</div>
  }
</section>

3.2.1 场景二:结合 @switch 实现 Tab 懒加载

@switch (activeTab) {
  @case ('profile') {
    @defer (on viewport) {
      <app-profile></app-profile>
    }
  }
  @case ('settings') {
    @defer (on interaction) {
      <app-settings></app-settings>
    }
  }
}

3.3 注意事项

  • @defer不能嵌套(截至 Angular 18)

  • 不要过度使用:每个 defer 块会生成独立 chunk,过多可能导致 HTTP 请求爆炸

  • prefetch 为实验性功能,需启用 deferBlockPrefetching

  • 已在其他场景引用或注册的组件无法被懒加载

  • 继承的父组件类无法被懒加载,需改造成非组件类

    错误示例:

    @Component({
        templateUrl: './basic.component.html',
        styleUrls: ['./basic.component.scss'],
        standalone: true,
        imports: BasicModules,
     })
    //这是一个组件基类
    export class BasicComponent{}
    
    //这是期望懒加载的组件Demo
    export class DemoComponent extends BasicComponent{}
    

    修改方案

    //将组件基类改造为非组件类
    @Injectable({ providedIn: 'any' })
    export class BasicComponent{}
    

以上就是懒加载的实现方案和踩坑经验啦,希望能帮到正在折腾 @defer 的小伙伴~

四、总结

Angular的新特性围绕 “性能优化”“开发效率”“场景适配” 三大核心:延迟加载模版(@defer) 降低首屏加载成本,独立组件与新控制流语法简化代码结构,信号(Signals)完善响应式能力。通过熟练掌握这些新语法,可显著提升项目性能与可维护性。

若想了解Angular新特性底层原理,请参考掘金文章 juejin.cn/post/733968…

五、 加入我们

MateChat 正在快速发展,我们欢迎更多开发者加入:

广纳贤士:AI赋能各行各业,MateChat期待更多感兴趣的小伙伴加入我们~

❌