阅读视图

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

TinyPro v1.4 空降:Spring Boot 集成,后端兄弟也能愉快写前端!

本文由体验技术团队Kagol原创。

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

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

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

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

你可以更新 <span leaf="">@opentiny/tiny-toolkit-pro@1.4.0</span> 进行体验!

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)策略,可限制单账号并发活跃设备数,有助于减少滥用和提高安全性,适配企业安全合规需求。

可通过 <span leaf="">nestJs/.env</span> 中的 <span leaf="">DEVICE_LIMIT</span> 进行配置。

比如配置最多 2 人登录:

DEVICE_LIMIT=2

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

DEVICE_LIMIT=-1

6 演示模式

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

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

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

PREVIEW_MODE=false

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

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

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

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

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

8 社区贡献

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

  • GaoNeng-wWw
  • zhaoxiaofeng876
  • WangWant7
  • zzl12222
  • discreted66

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

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

往期推荐文章

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~

OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue 源码:github.com/opentiny/ti…
TinyEngine 源码: github.com/opentiny/ti…

欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~ 如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

当代码照进生活:一个程序员眼中的欲望陷阱

写代码写了这么多年,我一直觉得编程和生活是两个完全不相干的世界。直到最近读了《模仿欲望》这篇文章,我才恍然大悟:原来我的生活,早就被写进了别人的代码里。

vx公众号:TSing_addiction

01. 我们都在implements一个看不见的接口

做TypeScript开发的人都知道,interface是定义一个对象"应该长什么样"的蓝图。我们常常这样写:

interface IdealLife {
  fancyCar: boolean;
  luxuryWatch: boolean;
  weekendTravel: boolean;
  perfectSkin: boolean;
}

然后我们拼命让自己去"实现"这个接口,买各种东西,去各种地方打卡,仿佛不这样就不是个"合格的对象"。

上周我刷小红书,看到一个和我同龄的博主展示他的"程序员精致生活":苹果全家桶、最新款机械键盘、咖啡店远程工作照。我的第一反应不是"这很酷",而是"我怎么没有这些"。那一刻,我突然意识到:我正在试图implements一个从未明确定义、却无处不在的社会接口。

最可怕的是,这个接口不是写在代码里的,而是通过算法悄悄注入到我们脑海中的。就像TypeScript的类型推断,我们甚至没有意识到自己正在被定义。

02. 闭包里的无限循环

在编程中,闭包是指函数记住并访问它的词法作用域,即使这个函数在其作用域外执行。这听起来很技术,但其实我们每天都在经历:

那天晚上,我本来只想刷5分钟抖音,结果两小时后才放下手机。算法给我推荐了第一个露营视频,我点了赞;然后是第二个、第三个...每一个互动都被记住,用来决定下一个推荐什么。我就这样被困在一个闭包里,不断循环,无法跳出。

就像这段代码:

let timeSpent = 0;
function scrollFeed() {
  timeSpent += 15; // 每次刷15分钟
  if (timeSpent < 120) { // 两小时后才意识到
    scrollFeed();
  } else {
    console.log("天啊,已经凌晨1点了!");
  }
}

我们的注意力就这样被算法捕获,形成一个完美的闭包,而我们甚至不知道自己被困住了。

03. "精致穷"就像危险的类型断言

做前端开发的都知道,TypeScript中有一种操作叫类型断言——你告诉编译器:"相信我,这个变量就是这个类型"。有时候这很危险,特别是当你断言一个不可能的类型时。

// 明知工资不高,却断言自己能过上博主的生活
const myWallet = { balance: 5000 } as LuxuryLifestyleWallet;

在编译阶段(发朋友圈时),一切看起来完美无缺。但到了运行时(月底交房租时),就会抛出一个残酷的错误:Uncaught BalanceError: Insufficient funds for projected lifestyle

我有个朋友就是这样。他月薪1万,却买了3万的相机,理由是"博主都用这个"。结果是接下来三个月天天吃泡面。这不就是把BudgetReality强行断言为InfluencerBudget的后果吗?

04. 职场内卷:递归函数没有退出条件

在编程中,递归是很强大的工具,但必须有明确的退出条件,否则会导致栈溢出。现在想想,职场内卷不就是这样一个没有退出条件的递归函数吗?

function workHarder(colleagues) {
  // 看到同事加班
  const maxOvertime = colleagues.reduce((max, c) => 
    Math.max(max, c.overtimeHours), 0);
  
  // 于是你也加更长时间的班
  myOvertimeHours = maxOvertime + 1;
  
  // 但没有人问:为什么要加班?
  return workHarder(updatedColleaguesList); 
  // 没有退出条件,直到精神崩溃
}

我们团队就有这样的情况。一开始只有一两个人晚走,渐渐地,七点下班变成了常态,然后是八点、九点...没有人明确要求这样,但就像递归函数没有base case,我们陷入了无限循环。

最讽刺的是,项目进度并没有因此加快多少。就像算法复杂度,我们的努力是O(n²),但产出只是O(n)。这种内卷,本质上就是一段写得很烂的代码。

05. 重写人生代码

意识到这些问题后,我开始尝试"重构"自己的生活代码。这不是件容易事,就像重构一个老旧的系统,处处都是隐患。

首先是依赖注入的问题。以前我的消费决策严重依赖外部注入:小红书推荐、博主种草、同事攀比。现在我尝试这样写:

class MyLife {
  private coreValues = ['growth', 'health', 'authenticity'];
  
  decidePurchase(item: any): boolean {
    // 不再依赖外部注入
    return this.coreValues.includes(item.value) && 
           this.budget.allows(item.price);
  }
}

其次是打破那个闭包。我给自己装了个屏幕时间管理app,设置每天刷短视频不超过30分钟。第一次看到自己一天刷了4小时短视频时,我惊呆了。这就像在调试时突然看到一个函数被调用了几百次——你必须找出为什么循环停不下来。

还有很重要的一点:接受自己不是泛型。TypeScript中有泛型,可以适配各种类型。但生活不是代码,我们不必成为"通用模板"。我开始允许自己:

  • 用便宜的键盘写代码(只要它好用)
  • 周末在家休息而不是去网红地点打卡
  • 穿舒适的衣服而不是"程序员该穿"的潮牌

06. 从any到明确类型

TypeScript最强大的功能之一,就是将JavaScript的"any"世界变成明确类型的系统。我们的人生也需要这样的转变——从"别人怎么做我也怎么做"(any)到"这是我真正想要的"(明确类型)。

以前,我的消费决定就像这样:

// 以前的我
let wantToBuy: any = trendingOnXiaohongshu;

现在,我尝试这样:

// 现在的我
let wantToBuy: unknown = trendingOnXiaohongshu;

function isRealNeed(item: unknown): item is GenuineNeed {
  return (
    typeof item === 'object' && 
    'solvesMyActualProblem' in item && 
    item.solvesMyActualProblem
  );
}

if (isRealNeed(wantToBuy)) {
  buy(wantToBuy);
} else {
  ignore(wantToBuy);
}

这种转变并不容易。有时候看到同事换了新手机,我还是会心动;刷到精致生活的内容,依然会感到焦虑。但至少现在,我有了一个"类型守卫"来检查这些欲望是否真实。

最后

作为一个程序员,我习惯了相信代码是理性的、有逻辑的。但我没想到,我们的欲望和行为,也早已被写入了某种看不见的代码中。

这篇文章不是要批判所有消费或所有社交媒体的使用。就像代码本身没有好坏,关键是谁在控制它,以及它服务于什么目的。

当我开始用程序员的眼睛观察生活,我发现自己不再那么容易被算法操控。当我看到小红书推送"必买清单",我会想:"这是callback hell,我不能陷入这个异步循环。"当同事炫耀新买的奢侈品,我会提醒自己:"不要进行不安全的类型断言。"

或许,理解这些"代码"就是夺回控制权的第一步。就像我们重构一段混乱的代码,生活也可以被重构成更真实、更符合自己核心价值观的样子。

下次当你想买一个东西、做一个重要决定,或者感到莫名焦虑时,不妨问自己:这真的是我的需求,还是我在implements别人的接口?我的人生代码,到底是谁在编写?

写完这篇文章,我要去关掉手机通知,好好享受一个没有算法干扰的周末。毕竟,最好的代码,是能服务于人,而不是让人服务于它的代码。

看板必备的丝滑、高端技巧 — 数字滚动

但行好事,莫问前程

前言

最近需要开发看板功能,涉及到给用户展示一些 number 数据的场景。

作为看板页面,我们要 避免突兀的数值变化,让数字的变化更加自然、更有视觉吸引力,提升用户体验。

最后我采用了 数字滚动 动效,并封装为 hooks 方便复用。

效果如下,其中有不少有趣的设计思路值得复盘。

numberscroll.gif

预览:我的后台 -> Editor
源文件:github.com/XIwE1/react…

<NumberScroll value={numberValue} options={{ decimals: 0 }} className="justify-center self-center" />

录屏2025-08-29 16.49.33.gif

文中已附上源代码和思路,如果对你有所帮助,还望点赞、收藏、关注三连😽。

方案

整理一下思路,如果要实现 0 -> 100 或者 100 -> 0,会想到什么方法?

  • 最简单的是直接更新对应 state,但这样一闪而过十分突兀
  • 其次是步进,即value / time = step,例如step = 100 -> 10 -> 20 ... -> 100
    • 这样不错,但这种线性的变化还是略显生硬
  • 最后我们可以利用缓动函数来控制过程
    • 0 -> ... -> 50 -> 70 -> 80 -> 85 -> ... -> 98 -> 99 -> 100

实现

  1. 迅速完成大部分数值变化,视觉上快速地接近最终值 +
  2. 剩余小部分差值平滑变化,平稳、自然减速并抵达终点

变量分为:更新频率 和 更新步幅(数值)

原理是结合变量与场景,使用缓动函数(贝塞尔曲线数学公式)。

程序逻辑

例:
初始值: 0 → 目标值: 1000,
duration = 3500ms,1 / 3 = 1167ms 时间用于快速的线性变化,剩下时间用于平滑滚动
线性阈值 = 1000 * 2 / 3 = 666.67,滑动值 = 1000 / 3 = 333.33
│
├─ determine(): 在不同节点,判断需要变化的数值A和线性阈值B,设置新的数值变化目标C
│  ├─ 第一阶段:剩余变化量A 1000 > 阈值B 666.67(大数值线性变化),0 → 目标C 666.67(线性,3500 / 3 = 1167ms)
│  └─ 第二阶段:剩余变化量A 333.33 < 阈值B 666.67(小数值平滑滚动),666.67 → 目标C 1000(缓动,3500 - 1167 = 2333ms)
│
└─ count() 循环执行:循环改变计数值
   ├─ 第一阶段:
   │  ├─ 线性变化
   │  ├─ 目标数值 = 666.67
   │  ├─ 当前数值 = 0 -> 666.67
   │  ├─ 耗时 = 3500 / 3 = 1167ms
   │  └─ 动画结束,调用 determine() 重新计算
   │
   └─ 第二阶段:
   │  ├─ 平滑滚动
   │  ├─ ...类上
      └─ 动画结束,currentValue = 1000

还有很多场景要考虑,数值为负数,减操作,数字被减为0...

缓动函数

简单来说,是 “通过数学公式来 控制 变化的进度 或者 运动的轨迹”

它使数据和动画摆脱生硬、呆板的线性变化,它让事物的变化 更加符合视觉常识

详情可见 贝塞尔曲线:实现更好的动画效果和图形

动画2.gif

// utils/index.ts
// 生成对应三次贝塞尔曲线的js代码
export function createBezierFunction(p1x: number, p1y: number, p2x: number, p2y: number) {
  return function (t: number) {
    return 3 * Math.pow(1 - t, 2) * t * p1y + 3 * (1 - t) * Math.pow(t, 2) * p2y + Math.pow(t, 3);
  };
}

实践

我们封装成hooks来重复使用

/src/hooks/useNumberDuration.ts

import { useEffect, useRef, useState, useMemo, useCallback } from "react";

// 缓动函数
const easeOutExpo = (t: number) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t));
const linear = (t: number) => t;

export interface UseNumberDurationProps {
  value: number;
  duration?: number;
  decimals?: number;
  split?: string;
}

const useNumberScroll = ({
  value,
  duration = 5500,
  decimals = 2,
  split = ",",
}: UseNumberDurationProps) => {
  const rafRef = useRef<number>();
  const currentRef = useRef<number>(0);
  const durationRef = useRef<number>(duration);

  const startTime = useRef<number>();
  const startValue = useRef<number>(0);
  /** 线性变化的值 */
  const EasingThreshold = useMemo(() => {
    const diff = value - startValue.current;
    const threshold = (Math.abs(diff) * 2) / 3;
    return startValue.current + (diff > 0 ? threshold : -threshold);
  }, [value]);

  /** 滑动变化的值 */
  const EasingAmount = useMemo(() => {
    const amount = value - EasingThreshold;
    return amount;
  }, [value, EasingThreshold]);

  // 当前的实际值
  const [currentValue, setCurrentValue] = useState(0);
  // 格式化后用于展示的值
  const _current = useMemo(
    () =>
      currentValue.toFixed(decimals).replace(/\B(?=(\d{3})+(?!\d))/g, split),
    [currentValue, decimals, split]
  );

  // 当前目标的结束值
  const endValue = useRef<number>(value);
  // 最终值
  const finalValue = useRef<number | null>(null);
  const result = useMemo(
    () => value.toFixed(decimals).replace(/\B(?=(\d{3})+(?!\d))/g, split),
    [value, decimals, split]
  );

  // determine - 判断变化采用线性还是缓动,设置最终值final和当前目标结束值end
  const determine = useCallback(() => {
    const end =
      finalValue.current !== null ? finalValue.current : endValue.current;
    const animateAmount = Math.abs(end - startValue.current);

    if (animateAmount > Math.abs(EasingThreshold)) {
      finalValue.current = end;
      endValue.current = end - EasingAmount;
      // 拿出小部分时间用于线性变化
      durationRef.current = duration / 3;
    } else {
      finalValue.current = null;
      endValue.current = end;
      durationRef.current = duration; // 这样动画滑动更明显一点
      // durationRef.current = (duration * 2) / 3;
    }
  }, [duration, EasingThreshold, EasingAmount]);

  const count = useCallback(
    (timestamp: number) => {
      if (!startTime.current) startTime.current = timestamp;
      // 根据时间差计算当前进度
      const elapsed = timestamp - startTime.current;
      const progress = Math.min(elapsed / durationRef.current, 1);
      const eased =
        finalValue.current !== null ? linear(progress) : easeOutExpo(progress);

      const currentValue =
        startValue.current + (endValue.current - startValue.current) * eased;

      currentRef.current = currentValue;
      setCurrentValue(currentValue);

      if (progress < 1) {
        rafRef.current = requestAnimationFrame(count);
      } else {
        startValue.current = currentValue;
        // 最终值不为空 = 还未到最终值 = 剩下的值要平滑增加
        if (finalValue.current !== null) {
          cancelAnimationFrame(rafRef.current!);
          startTime.current = undefined;
          endValue.current = finalValue.current;
          finalValue.current = null;
          determine();
          rafRef.current = requestAnimationFrame(count);
        }
      }
    },
    [determine]
  );

  useEffect(() => {
    if (rafRef.current) cancelAnimationFrame(rafRef.current);

    // 变化时重置状态
    endValue.current = value;
    finalValue.current = null;
    durationRef.current = duration;
    startValue.current = currentRef.current;
    startTime.current = undefined;
    // 判断当前的变化方式,并开始动画循环
    determine();
    rafRef.current = requestAnimationFrame(count);

    return () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
      startTime.current = undefined;
    };
  }, [value, duration]);

  return { current: _current, result };
};

export default useNumberScroll;

数字滚动

上述的hooks已经实现数字增长的效果了,但还有样式上的问题需要解决,

  1. 数据变化的过程中,元素占宽也在不断变化
  2. 非等宽字体的数字占宽不同

以上因素会导致 数据抖动

我采用的解决方案:

  • if 可以使用等宽字体
    • 预先使用 result 渲染真实元素获取所占宽度 estimateWidth
  • else
    • result 内的数字替换为占宽最大数字来渲染预估值

最后我们封装一个ui组件进行复用

/src/ui/NumberScroll/index.tsx

import React, { useRef, useLayoutEffect, useState } from "react";
import useNumberScroll, { UseNumberDurationProps } from "../hooks/useNumberScroll";

interface NumberScrollProps {
  value: number;
  options?: Omit<UseNumberDurationProps, 'value'>;
  className?: string;
  suffix?: string;
  style?: React.CSSProperties;
}

const NumberScroll: React.FC<NumberScrollProps> = ({
  value,
  options,
  suffix = "",
  className,
  style,
}) => {
  const { current, result } = useNumberScroll({
    value,
    ...options
  });

  const measureRef = useRef<HTMLSpanElement>(null);
  const [fixedWidth, setFixedWidth] = useState<number | undefined>(undefined);

  useLayoutEffect(() => {
    if (measureRef.current) {
      const { width } = measureRef.current.getBoundingClientRect();
      setFixedWidth(width);
    }
  }, [result]);

  return (
    <>
      {/* estimate width */}
      <span
        className={className}
        ref={measureRef}
        style={{
          position: "absolute",
          visibility: "hidden",
          height: "auto",
          width: "auto",
          whiteSpace: "nowrap",
          ...style,
        }}
      >
        {result}
        {suffix}
      </span>

      <span
        className={className}
        style={{
          display: "inline-block",
          width: fixedWidth,
          ...style,
        }}
      >
        {current}
        {suffix}
      </span>
    </>
  );
};

export default NumberScroll;

总结

我们可以看出数字滚动动画的核心:

1. 分阶段策略

  • 大数值快速线性变化:使用 1/3 的时间快速完成 2/3 的数值变化
  • 小数值平滑缓动:使用 2/3 的时间平滑完成剩余的 1/3 变化
  • 关键参数:线性阈值(2/3)、滑动值(1/3)、时间分配(1/3 vs 2/3)

2. 缓动函数选择

  • 第一阶段:使用 linear 线性函数,快速接近目标
  • 第二阶段:使用 easeOutExpo 指数缓出函数,自然减速到达

3. 样式优化

  • 固定宽度:避免动画过程中的布局抖动
  • 等宽字体优先:如果可以使用等宽字体,直接测量最终宽度
  • 预估宽度:非等宽字体时,用最宽数字预估宽度

4. 封装复用

  • 逻辑层:使用 hooks 处理动画逻辑,返回当前值和最终值
  • UI 层:使用组件处理样式和布局,确保视觉稳定

结语

不要光看不实践哦,希望本文能对你有所帮助。

持续更新前端知识,脚踏实地不水文,真的不关注一下吗~

写作不易,如果有收获还望 点赞+收藏 🌹

才疏学浅,如有问题或建议还望指教~

这 5 个冷门 HTML 标签,让我直接删了100 行 JS 代码!

在写前端的时候,我们实现的比较多的一些基础交互,比如折叠面板、弹窗、输入提示、进度条或颜色选择等等,会不得不引入 JavaScript

但其实,HTML 自己也内置了不少功能强大的原生标签,它们开箱即用、语义清晰,还能大幅减少 JS 的代码量。

下面介绍 5 个冷门但实用的 HTML 标签。

1. <details><summary> - 可折叠内容

替代: 手风琴效果、折叠面板、FAQ部分

<details>
  <summary>点击查看详情</summary>
  <p>隐藏的内容,无需JS实现展开/收起</p>
</details>

实现效果:

使用场景

  • FAQ 折叠面板
  • 设置项分组展开
  • 移动端“查看更多”区域

注意事项

  • 默认是关闭状态;添加 open 属性可默认展开:<details open>
  • 可通过 CSS 的 details[open] 选择器定制展开样式
  • 支持键盘操作(Enter/Space 触发),无障碍友好

2. <dialog> - 原生对话框

替代:div模拟模态框 + 背景遮罩 + 关闭逻辑

<dialog id="modal">
  <p>这是原生弹窗</p>
  <button onclick="document.getElementById('modal').close()">关闭</button>
</dialog>
<button onclick="document.getElementById('modal').showModal()">打开弹窗</button>

实现效果:

使用场景

  • 确认提示框
  • 登录/注册弹窗
  • 临时信息展示

注意事项

  • .showModal() 会自动创建半透明遮罩(可通过 ::backdrop 自定义)
  • .show() 是非模态显示(不锁定背景)
  • 聚焦自动管理:打开时聚焦第一个可聚焦元素,关闭后焦点返回触发按钮
  • 兼容性:Chrome/Firefox/Edge 支持良好;Safari 15.4+ 支持;IE 不支持

3. <datalist> - 输入建议列表

替代:监听input事件 + 动态生成下拉列表

<input list="browsers" placeholder="选择或输入浏览器">
<datalist id="browsers">
  <option value="Chrome">
  <option value="Firefox">
  <option value="Safari">
</datalist>

实现效果:

使用场景

  • 搜索建议(非强制选项)
  • 表单字段预填(如城市、产品名)
  • 快速输入辅助

注意事项

  • 用户仍可输入不在列表中的值(与 <select> 不同)
  • 浏览器会自动根据输入过滤匹配项
  • 移动端会调出带建议的软键盘(部分浏览器支持)

4. <meter> & <progress> - 进度指示器

替代:div模拟进度条 + JS更新宽度

<!-- 已知范围内的标量值(如磁盘使用率) -->
<meter min="0" max="100" value="70">70%</meter>

<!-- 任务完成进度(如文件上传) -->
<progress value="50" max="100">50%</progress>

实现效果:

使用场景

  • 搜索建议(非强制选项)
  • 表单字段预填(如城市、产品名)
  • 快速输入辅助

注意事项

  • 用户仍可输入不在列表中的值(与 <select> 不同)
  • 浏览器会自动根据输入过滤匹配项
  • 移动端会调出带建议的软键盘(部分浏览器支持)

5. <input type="color"> - 颜色选择器

替代:自定义颜色选择器UI + 色值转换逻辑

<input type="color" value="#ff0000">

实现效果:

使用场景

  • 主题配色设置
  • 图表颜色配置
  • 设计工具中的拾色功能

注意事项

  • 返回值始终为 小写 7 位十六进制(如 #ff5733
  • 移动端会调出系统级颜色选择器
  • 无法自定义 UI,但可通过 ::-webkit-color-swatch 微调样式(有限)

总结

  • <details> / <summary>:实现折叠内容
  • <dialog>:原生弹窗,自带遮罩和焦点管理
  • <datalist>:输入建议选择
  • <meter> / <progress>:进度展示无需手动计算宽度
  • <input type="color">:系统级颜色选择器开箱即用

这些原生 HTML 标签虽然不太起眼,但用好它们,不仅能省去大量 JavaScript 逻辑,还能让页面更语义化、更友好。

本文首发于公众号:程序员大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

qiankun两种加载模式registerMicroApps和loadMicroApp对比分析

概述

qiankun 提供了两种加载子应用的方式:

  1. registerMicroApps + start 模式:声明式,由 qiankun 自动管理子应用生命周期
  2. loadMicroApp 模式:命令式,由开发者手动控制子应用加载/卸载

本文将深入分析两种模式的技术区别、适用场景、可能遇到的问题及解决方案。


一、技术原理对比

1.1 registerMicroApps + start 模式

import { registerMicroApps, start } from "qiankun";

// 注册子应用
registerMicroApps([
  {
    name: "sub-app-1",
    entry: "//localhost:3000",
    container: "#sub-app-container",
    activeRule: "/sub-app-1",
  },
]);

// 启动 qiankun
start();

工作原理:

  • start() 会启动 qiankun 的路由监听(基于 single-spa)
  • qiankun 监听 popstatehashchange 事件
  • 当 URL 匹配 activeRule 时,自动加载对应子应用
  • 当 URL 不再匹配时,自动卸载子应用

1.2 loadMicroApp 模式

import { loadMicroApp } from "qiankun";

// 在需要的时机手动加载
const microApp = loadMicroApp({
  name: "sub-app-1",
  entry: "//localhost:3000",
  container: "#sub-app-container",
});

// 在需要的时机手动卸载
microApp.unmount();

工作原理:

  • 不需要调用 start()
  • 开发者完全控制加载和卸载时机
  • 通常配合主应用的路由组件生命周期使用

二、核心区别对比表

特性 registerMicroApps + start loadMicroApp
路由控制 qiankun 自动控制 开发者手动控制
是否需要 start() ✅ 必须调用 ❌ 不需要
子应用容器 必须始终存在于 DOM 可动态创建/销毁
主应用路由定义 通过 activeRule 匹配 不依赖路由,可在任意场景使用
多子应用同时加载 ❌ 默认路由互斥,难以实现 ✅ 天然支持,核心优势
生命周期控制 自动 手动
适合场景 简单的路由切换 复杂的动态加载需求
仪表盘/工作台 ❌ 不适合 ✅ 最佳选择
弹窗/Tab 中加载 ❌ 不适合 ✅ 天然支持

三、registerMicroApps + start 模式详解

3.1 优点

  1. 配置简单:只需注册一次,qiankun 自动处理
  2. 开箱即用:不需要额外的路由配置
  3. 统一管理:所有子应用配置集中在一处

3.2 缺点与问题

问题 1:容器必须始终存在

<!-- ❌ 错误:容器随路由销毁 -->
<template>
  <router-view />
</template>

<!-- 子应用页面组件 -->
<template>
  <div id="sub-app-container"></div>
</template>

当路由切换时,容器被销毁,qiankun 会报错:

[qiankun]: Target container with #sub-app-container not existed

解决方案:

<!-- ✅ 正确:容器始终存在,用 v-show 控制显示 -->
<template>
  <div>
    <!-- 主应用内容 -->
    <router-view v-show="!isSubApp" />

    <!-- 子应用容器始终存在 -->
    <div id="sub-app-1-container" v-show="currentApp === 'sub-app-1'"></div>
    <div id="sub-app-2-container" v-show="currentApp === 'sub-app-2'"></div>
  </div>
</template>

<script setup>
import { computed } from "vue";
import { useRoute } from "vue-router";

const route = useRoute();
const isSubApp = computed(() => route.path.startsWith("/sub-app"));
const currentApp = computed(() => {
  if (route.path.startsWith("/sub-app-1")) return "sub-app-1";
  if (route.path.startsWith("/sub-app-2")) return "sub-app-2";
  return "";
});
</script>

问题 2:主子应用路由冲突

当主应用和子应用都使用 createWebHistory 时,两个路由实例都会监听 popstate 事件,导致:

  • 浏览器返回按钮行为异常
  • 历史记录跳转混乱

解决方案:

  • 子应用使用 createMemoryHistory(推荐)
  • 或者在子应用卸载时销毁路由监听

3.3 完整示例

// main.ts
import { createApp } from "vue";
import { registerMicroApps, start } from "qiankun";

const app = createApp(App);
app.mount("#app");

registerMicroApps([
  {
    name: "sub-app-1",
    entry: "//localhost:3000",
    container: "#sub-app-1-container",
    activeRule: "/sub-app-1",
    props: {
      /* 传递给子应用的数据 */
    },
  },
]);

start({
  sandbox: {
    experimentalStyleIsolation: true,
  },
});

四、loadMicroApp 模式详解

4.1 优点

  1. 灵活控制:完全掌控加载/卸载时机
  2. 动态容器:容器可以随组件动态创建销毁
  3. 多实例支持:可以同时加载多个子应用实例
  4. 与主应用路由解耦:不依赖 qiankun 的路由监听
  5. 多子应用并行加载:这是 loadMicroApp 的核心优势,可以在同一页面同时加载多个子应用

4.2 核心优势:多子应用并行加载

这是 loadMicroApp 相比 registerMicroApps + start 模式最重要的差异化能力。

为什么 registerMicroApps + start 难以实现多子应用并行?

registerMicroApps + start 模式基于 single-spa 的路由监听机制,其设计理念是:

  • 一个 URL 对应一个激活的子应用
  • 当 URL 变化时,自动卸载当前子应用,加载新子应用
  • 子应用之间是路由互斥的关系

虽然可以通过配置多个 activeRule 让多个子应用同时激活,但这需要:

  1. 复杂的 activeRule 配置
  2. 多个容器必须始终存在于 DOM
  3. 难以实现灵活的布局控制

loadMicroApp 如何实现多子应用并行?

// 在同一个页面组件中,同时加载多个子应用
import { loadMicroApp } from "qiankun";
import { onMounted, onUnmounted } from "vue";

let microApp1 = null;
let microApp3 = null;

onMounted(() => {
  // 加载第一个子应用
  microApp1 = loadMicroApp({
    name: "sub-app-1",
    entry: "//localhost:3000",
    container: "#dashboard-app-1",
    props: { dashboardMode: true },
  });

  // 加载第二个子应用
  microApp3 = loadMicroApp({
    name: "sub-app-3",
    entry: "//localhost:3002",
    container: "#dashboard-app-3",
    props: { dashboardMode: true },
  });
});

onUnmounted(() => {
  microApp1?.unmount();
  microApp3?.unmount();
});

实际业务场景

场景 描述 实现方式
运维仪表盘 同时展示多个监控子系统(日志、指标、告警) 多个子应用并排显示
工作台页面 同时加载邮件、日历、任务等多个微应用 网格布局展示多个子应用
对比分析页 同时展示不同数据源的分析结果 左右对比布局
多租户管理 同时管理多个租户的配置 Tab 或分栏布局

仪表盘模式的特殊处理

在仪表盘模式下,子应用需要运行在「小部件模式」:

// 主应用传递 dashboardMode 标识
loadMicroApp({
  name: "sub-app-1",
  entry: "//localhost:3000",
  container: "#dashboard-app-1",
  props: {
    dashboardMode: true, // 关键标识
    // 其他通信方法...
  },
});

子应用根据 dashboardMode 调整行为:

功能 单实例模式 仪表盘模式
URL 同步 ✅ 启用 ❌ 禁用
跨应用导航 ✅ 启用 ❌ 禁用
内部路由 ✅ 启用 ✅ 启用
状态通信 ✅ 启用 ✅ 启用
// 子应用中根据 dashboardMode 控制行为
router.afterEach((to) => {
  // 仪表盘模式下不同步 URL
  if (props.dashboardMode) return;
  props.syncRoute?.(to.path);
});

4.3 需要处理的问题

问题 1:需要手动管理生命周期

<script setup>
import { loadMicroApp } from "qiankun";
import { onMounted, onUnmounted } from "vue";

let microApp = null;

onMounted(() => {
  microApp = loadMicroApp({
    name: "sub-app-1",
    entry: "//localhost:3000",
    container: "#sub-app-container",
  });
});

onUnmounted(() => {
  if (microApp) {
    microApp.unmount();
    microApp = null;
  }
});
</script>

问题 2:子应用路由与浏览器 URL 同步

使用 createMemoryHistory 后,子应用路由不会反映在浏览器地址栏。需要:

  1. 主应用传递初始路径给子应用
  2. 子应用内部路由变化时同步到浏览器 URL

主应用传递初始路径:

// 从路由参数提取子路径
const subpath = route.params.subpath;
const initialPath = subpath
  ? `/${Array.isArray(subpath) ? subpath.join("/") : subpath}`
  : "/";

loadMicroApp({
  // ...
  props: {
    initialPath,
  },
});

子应用处理初始路径并同步路由:

function render(props) {
  const { initialPath } = props;

  router = createRouter({
    history: window.__POWERED_BY_QIANKUN__
      ? createMemoryHistory()
      : createWebHistory("/"),
    routes,
  });

  // 注册路由同步(跳过初始路由避免覆盖 URL)
  if (window.__POWERED_BY_QIANKUN__) {
    let isInitialNavigation = true;
    router.afterEach((to) => {
      if (isInitialNavigation) {
        isInitialNavigation = false;
        return;
      }
      globalStore.syncRoute(to.path);
    });
  }

  // 如果有初始路径,先跳转再挂载
  if (window.__POWERED_BY_QIANKUN__ && initialPath && initialPath !== "/") {
    router.replace(initialPath).then(() => {
      instance.mount(container ? container.querySelector("#app") : "#app");
    });
  } else {
    instance.mount(container ? container.querySelector("#app") : "#app");
  }
}

问题 3:主应用路由配置(可选)

如果希望子应用有独立的 URL 路径(如 /sub-app-1/about),可以配置路由:

// 主应用路由配置(可选,用于 URL 同步场景)
const routes = [
  {
    // 匹配子应用所有路径
    path: "/sub-app-1/:subpath(.*)*",
    component: () => import("@/views/SubApp1.vue"),
  },
];

注意:这不是 loadMicroApp 的必需配置。loadMicroApp 可以在任何场景使用,包括:

  • 弹窗中嵌入子应用
  • Tab 页签中加载子应用
  • 侧边栏小部件
  • 任意 DOM 容器中

4.4 完整示例

<!-- SubApp1.vue -->
<template>
  <div id="sub-app-1-container"></div>
</template>

<script setup>
import { loadMicroApp } from "qiankun";
import { onMounted, onUnmounted } from "vue";
import { useRoute } from "vue-router";
import { microAppConfigs } from "@/main";

const route = useRoute();
let microApp = null;

onMounted(() => {
  const subpath = route.params.subpath;
  const initialPath = subpath
    ? `/${Array.isArray(subpath) ? subpath.join("/") : subpath}`
    : "/";

  const config = microAppConfigs["sub-app-1"];
  microApp = loadMicroApp(
    {
      ...config,
      props: {
        ...config.props,
        initialPath,
      },
    },
    {
      sandbox: {
        experimentalStyleIsolation: true,
      },
    }
  );
});

onUnmounted(() => {
  microApp?.unmount();
  microApp = null;
});
</script>

五、如何选择?

5.1 选择 registerMicroApps + start 的场景

  • ✅ 子应用数量固定,不需要动态加载
  • ✅ 子应用之间互斥,同一时间只显示一个
  • ✅ 主应用布局简单,容器可以始终存在
  • ✅ 希望快速集成,减少代码量
  • ✅ 不需要精细控制子应用生命周期

5.2 选择 loadMicroApp 的场景

  • ✅ 子应用容器需要随路由动态创建/销毁
  • 需要同时加载多个子应用(仪表盘、工作台场景)
  • ✅ 需要精细控制加载时机(如懒加载、条件加载)
  • ✅ 主应用有复杂的布局结构
  • ✅ 需要在非路由场景加载子应用(如弹窗、Tab)
  • ✅ 子应用可能被多次加载/卸载
  • 需要子应用运行在「小部件模式」(禁用 URL 同步)

5.3 决策流程图

开始
  │
  ▼
需要同时显示多个子应用?(仪表盘/工作台场景)
  │
  ├─ 是 → 推荐 loadMicroApp(唯一合理选择)
  │
  └─ 否 → 子应用容器能否始终存在于 DOM?
            │
            ├─ 是 → 子应用之间是否互斥?
            │         │
            │         ├─ 是 → 推荐 registerMicroApps + start
            │         │
            │         └─ 否 → 推荐 loadMicroApp
            │
            └─ 否 → 推荐 loadMicroApp

六、两种模式能否共存?

答案:可以共存,但需要注意。

6.1 共存场景

  • 部分子应用使用 registerMicroApps 自动加载
  • 部分子应用使用 loadMicroApp 手动加载(如弹窗中的子应用)

6.2 共存示例

import { registerMicroApps, start, loadMicroApp } from "qiankun";

// 自动加载的子应用
registerMicroApps([
  {
    name: "main-sub-app",
    entry: "//localhost:3000",
    container: "#main-container",
    activeRule: "/main-sub-app",
  },
]);

start();

// 手动加载的子应用(如在弹窗中)
function openSubAppModal() {
  const microApp = loadMicroApp({
    name: "modal-sub-app",
    entry: "//localhost:3001",
    container: "#modal-container",
  });

  // 关闭弹窗时卸载
  onModalClose(() => microApp.unmount());
}

6.3 注意事项

  1. 避免同名子应用:两种方式加载的子应用 name 不能重复
  2. 容器隔离:确保容器 ID 不冲突
  3. 路由冲突registerMicroAppsactiveRule 不要与 loadMicroApp 的触发路由重叠

七、我们项目的选择

7.1 为什么选择 loadMicroApp?

  1. 布局需求:我们的子应用页面有独立的控制面板,容器随路由组件创建/销毁
  2. 路由控制:需要精确控制子应用的加载时机
  3. 历史记录:通过 createMemoryHistory 避免主子应用路由冲突
  4. 仪表盘场景:需要在同一页面同时加载多个子应用(sub-app-1 和 sub-app-3)

7.2 仪表盘页面实现

我们实现了一个仪表盘页面(/dashboard),同时加载 sub-app-1 和 sub-app-3 两个子应用:

<!-- DashboardView.vue -->
<template>
  <div class="dashboard">
    <h1>多子应用仪表盘</h1>

    <!-- 统一控制面板 -->
    <div class="control-panel">
      <button @click="broadcastToAll">向所有子应用发送数据</button>
    </div>

    <!-- 子应用并排显示 -->
    <div class="apps-container">
      <div class="app-wrapper">
        <h3>Sub-App-1</h3>
        <div id="dashboard-app-1"></div>
      </div>
      <div class="app-wrapper">
        <h3>Sub-App-3</h3>
        <div id="dashboard-app-3"></div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { loadMicroApp } from "qiankun";
import { onMounted, onUnmounted } from "vue";

let microApp1 = null;
let microApp3 = null;

onMounted(() => {
  // 同时加载两个子应用,传递 dashboardMode: true
  microApp1 = loadMicroApp({
    name: "sub-app-1",
    entry: "//localhost:3000",
    container: "#dashboard-app-1",
    props: {
      dashboardMode: true,
      // 其他通信方法...
    },
  });

  microApp3 = loadMicroApp({
    name: "sub-app-3",
    entry: "//localhost:3002",
    container: "#dashboard-app-3",
    props: {
      dashboardMode: true,
      // 其他通信方法...
    },
  });
});

onUnmounted(() => {
  microApp1?.unmount();
  microApp3?.unmount();
});
</script>

7.3 我们做的兼容处理

问题 解决方案
容器动态创建 在 Vue 组件 onMounted 中调用 loadMicroApp
路由冲突 子应用使用 createMemoryHistory
初始路径同步 主应用提取 subpath 参数传递给子应用
子应用内部路由同步 通过 syncRoute 使用 history.replaceState 更新浏览器 URL
跨应用导航 统一通过 navigateTo 由主应用 router 处理
子应用返回按钮 微前端环境下使用 router.push("/") 而非 router.back()
仪表盘模式 通过 dashboardMode: true 禁用 URL 同步和跨应用导航
多子应用状态同步 主应用 GlobalStore 统一管理,变化时通知所有子应用

八、总结

维度 registerMicroApps + start loadMicroApp
复杂度
灵活性
容器要求 必须始终存在 可动态创建
多实例 ❌ 默认路由互斥 ✅ 天然支持
仪表盘场景 ❌ 不适合 ✅ 最佳选择
适合场景 简单路由切换 复杂动态加载

核心原则:

  • 简单场景用 registerMicroApps + start
  • 复杂场景用 loadMicroApp
  • 需要多子应用并行加载时,必须使用 loadMicroApp
  • 可以根据不同子应用的需求混合使用

loadMicroApp 的核心优势:

  1. 多子应用并行加载:这是 registerMicroApps + start 难以实现的场景
  2. 灵活的运行模式:通过 dashboardMode 等参数控制子应用行为
  3. 完全的生命周期控制:主应用完全掌控子应用的加载和卸载时机

使用InterSection进行页面图片加载优化思路

讲讲业务场景

当页面上出现大量的图片/视频/音频 在页面的加载时进行同步请求会造成页面卡顿

页面卡顿原因

1.网络请求的拥堵与等待

浏览器从服务器获取资源的第一步,就容易出现“堵车”。

  • 资源过多,带宽竞争:浏览器对同一域名的并发请求数有上限(通常6-8个)。当页面上有几十甚至上百张图片时,它们需要排队等待加载。这会直接拖慢后续关键资源(如CSS、JavaScript文件)的下载,从而阻塞整个页面的渲染。使用HTTP/1.1时,队头阻塞(Head-of-Line blocking)问题会更明显,即一个响应慢的资源会阻塞后续所有资源。
  • 单个文件体积过大:一张未经压缩的高清图片或一段视频可能达到几MB甚至几十MB。在带宽有限的情况下,加载一个10MB的文件可能需要8秒以上,这会显著增加用户看到完整页面的时间。
2.浏览器解析、渲染与解码的压力

资源下载后,浏览器需要进行一系列处理才能将其呈现出来,这个阶段同样压力重重。

  • 渲染阻塞:虽然图片、视频、音频本身不阻塞DOM树的构建,但它们依赖的CSS和JavaScript可能会。如果页面脚本需要等待这些多媒体资源的尺寸信息,或者CSS文件过大,浏览器就可能延迟渲染,导致白屏时间变长。
  • 主线程占用与回流重绘:浏览器的渲染、布局、绘制以及JavaScript运行主要都在主线程上完成,这相当于浏览器的“大脑”。大量多媒体资源需要主线程进行解码和渲染,尤其是当图片或视频尺寸发生变化时,会触发昂贵的回流(重排)和重绘,进一步占用CPU资源,导致页面响应迟钝。
  • 昂贵的解码成本:图片和视频文件需要被解码成浏览器能够直接处理的位图格式,这个过程非常消耗CPU资源。同时渲染大量图片或播放高清视频时,解码压力会陡增,尤其在处理GIF或自动播放的视频时更为明显。
3.内存与存储的持续占用

资源被加载和解码后,并不会马上消失,而是会持续占用系统资源。

  • 内存占用激增:每张图片、每个视频帧解码后都会占用一定的内存。当用户快速滑动页面,大量图片涌入又来不及被垃圾回收机制回收时,内存占用会急剧上升。在内存有限的移动设备上,这可能导致浏览器崩溃或页面被强行重新加载。
  • 存储I/O压力:大体积的多媒体文件会增加服务器磁盘的I/O压力。服务器需要花更多时间从磁盘读取数据再返回给浏览器,这可能间接影响服务器响应其他请求的速度

代码实现(React+TS):

前情提要:SongList组件中封装了SongMenu组件,并传参给子组件item(包含imgUrl)每个SongMenu中展示自己的imageUrl

NO.1 在每个Song-Menu实现一个观察者 与SongMenu的组件生命周期相关联

import React, { memo, FC, useRef, useEffect, useState } from 'react';
import { SongsMenuWrapper } from './style';
import { formatCount,getImageSize } from '../../utils/formar';
import { useIntersectionObserver } from '../../hooks/useInterSectionObserver';
import { isVisible } from '@testing-library/user-event/dist/utils';


interface IProps{
    children?:React.ReactNode
    itemData?:any
    isVisible?:boolean
}

const SongsMenu: FC<IProps>= (props)=> {
    const { itemData, isVisible = false } = props;
    const [isLoaded, setIsLoaded] = useState(false);

    const [imgRef, isIntersecting] = useIntersectionObserver<HTMLImageElement>({
        once: true,
        rootMargin: '0px 0px 200px 0px',
        threshold: 0.1,
    });

    useEffect(() => {
        if(isIntersecting && itemData?.picUrl && imgRef?.current && !isLoaded){
            setIsLoaded(true);
            imgRef.current!.src = getImageSize(itemData.picUrl, 100, 100);
        }
    }, [itemData, isIntersecting])


    return (
        <SongsMenuWrapper>
            <div className="cover_top">
                {/* 初始时src为空,避免提前加载 */}
                <img 
                    ref={imgRef} 
                    alt={itemData?.name} 
                />
                <div className="cover sprite_cover">
                    <div className="info sprite_cover">
                        <span>
                            <i className='headset sprite_icon'>
                                { formatCount(itemData?.playCount || 0) }
                            </i>
                        </span>
                        <i className='play sprite_icon'></i>
                    </div>
                </div>
            </div>

            <div className="cover_bottom">
                {itemData?.name}
            </div>
        </SongsMenuWrapper>
    )
}

export default memo(SongsMenu);

IntserSectionObserver的实现原理

  • 异步检测与更新队列浏览器渲染引擎有一个专门的“更新相交观察步骤”(Update Intersection Observations Steps)。这个步骤被集成在渲染帧的“更新渲染”(Update the rendering)阶段,通常在执行完 requestAnimationFrame回调之后进行。这意味着,浏览器会利用自身的布局信息来高效计算相交状态,而不是响应高频率的滚动事件。检测到的变化会被放入一个队列,异步地通知给观察者。
  • 相交区域的计算算法当计算一个目标元素与根元素的相交情况时,浏览器会遵循一个特定的算法:
  • 获取目标元素矩形:首先,通过类似 getBoundingClientRect()的方法获取目标元素完整的边界矩形。
  • 遍历祖先元素应用裁剪:接着,从目标元素的直接父级开始,向上遍历直到根元素。如果路径上的祖先元素设置了非 visibleoverflow属性或者是类似 <iframe>的浏览上下文,则会根据这些元素的裁剪区域(clipping area)对第一步得到的矩形进行逐级裁剪
  • 映射与求交:最终,将裁剪后得到的矩形映射到根元素的坐标空间,并与根元素的边界(可被 rootMargin扩展或收缩)求交,得到最终的 intersectionRect(交叉区域)。相交比例则由 intersectionRect面积与目标元素完整矩形面积的比值得出。
  • 阈值(Threshold)的触发机制你设置的 threshold数组(如 [0, 0.25, 0.5, 1])决定了回调函数在哪些关键点触发。浏览器会跟踪当前的 intersectionRatio和之前的状态。只有当相交比例穿过你设置的阈值点时,才会将相应的条目加入回调的 entries数组。例如,从 0.2 滚动到 0.4,如果设置了 0.25 和 0.5 两个阈值,则只会在穿过 0.25 时触发一次回调
InterSectionObserver的底层原理
1.观察者实例与目标管理

在底层,每个 IntersectionObserver实例内部确实维护着关键数据:

  • 观察目标列表:每个观察者实例都持有一个它正在观察的DOM元素列表。当你调用 observe(element)时,这个元素就会被添加到内部列表中进行追踪 。
  • 配置信息:实例化时传入的 root, rootMargin, threshold等选项也被存储在实例内部,用于后续的交叉计算 。

浏览器内核(如Blink)会维护一个全局的注册表(Registry) ,所有活跃的 IntersectionObserver实例都会在此注册,这确保了只要观察者还在工作,它和其观察的目标就不会被垃圾回收机制错误回收 。

2.异步检测与线程协作

IntersectionObserver的高性能秘诀并非在于为每个观察者“新开一个独立的异步线程”,而是利用了浏览器现有的渲染引擎的工作机制

  1. 集成于渲染流水线:交叉检测并非在独立的线程中循环不断计算,而是巧妙地“挂载”在浏览器的渲染过程中。核心计算发生在渲染帧(Frame)的某个特定阶段,通常是在样式计算(Style)和布局(Layout)之后。此时,浏览器已经为了绘制页面而计算出了每个元素的精确几何信息(位置、大小),IntersectionObserver可以直接利用这些现成的布局数据,避免了重复计算带来的性能损耗 。
  2. 批量异步处理:正因为与渲染流程绑定,所有观察者的交叉状态计算是批量(Batched) 进行的。浏览器会在一个渲染帧内,集中处理所有需要检查的观察目标,计算它们的交叉状态。这个过程对JavaScript主线程是异步的。计算完成后,如果发现某个目标的交叉状态发生了变化(例如,穿过了你设置的阈值),才会将对应的回调函数放入JavaScript的消息队列,等待主线程空闲时执行 。这种机制避免了在密集滚动等场景下,频繁的同步计算阻塞主线程,从而解决了传统 scroll事件监听带来的性能问题 。规范中提到,其实现优先级较低,甚至会采用类似 requestIdleCallback的机制,在浏览器空闲时才执行回调,进一步减少对用户交互的影响

NO.2 封装通用的hooks,便于组件间通用

import { useEffect, useRef, useState, RefObject } from 'react';

interface UseIntersectionObserverOptions {
  root?: Element | null;
  rootMargin?: string;
  threshold?: number | number[];
  once?: boolean;
  defaultVisible?: boolean;
}

export function useIntersectionObserver<T extends Element>(
  options: UseIntersectionObserverOptions = {}
): [RefObject<T> | null, boolean] {
  const { 
    root = null, 
    rootMargin = '0px', 
    threshold = 0, 
    once = false,
    defaultVisible = false
  } = options;

  const targetRef = useRef<T>(null) as RefObject<T>;
  const [isIntersecting, setIsIntersecting] = useState(defaultVisible);
  const observerRef = useRef<IntersectionObserver | null>(null);

  useEffect(() => {
    // 如果没有目标元素,不执行观察
    if (!targetRef.current) return;

    // 清除之前的观察器
    if (observerRef.current) {
      observerRef.current.disconnect();
    }

    // 创建新的观察器
    observerRef.current = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          setIsIntersecting(entry.isIntersecting);
          
          // 如果设置了once且元素可见,则停止观察
          if (once && entry.isIntersecting) {
            observerRef.current?.disconnect();
          }
        });
      },
      { root, rootMargin, threshold }
    );

    // 开始观察目标元素
    observerRef.current.observe(targetRef.current);

    // 清理函数
    return () => {
      if (observerRef.current) {
        observerRef.current.disconnect();
      }
    };
  }, [root, rootMargin, threshold, once]);

  return [targetRef, isIntersecting];
}

NO.3参考事件委托思想 在Song-list 实现一个观察者 观察n个SongMenu子组件(节省性能)

import { useEffect, useRef, useState, RefObject } from 'react';

interface UseIntersectionObserverOptions {
  root?: Element | null;
  rootMargin?: string;
  threshold?: number | number[];
  once?: boolean;
  defaultVisible?: boolean;
}

interface BatchIntersectionResult<T extends Element> {
  ref: (el: T | null, index: number) => void;
  isIntersecting: (index: number) => boolean;
  visibleIndices: number[];
}

// 单个元素版本
export function useIntersectionObserver<T extends Element>(
  options: UseIntersectionObserverOptions = {}
): [RefObject<T>, boolean] {
  const { 
    root = null, 
    rootMargin = '0px', 
    threshold = 0, 
    once = false,
    defaultVisible = false
  } = options;

  // 使用类型断言解决初始值为null的类型问题
  const targetRef = useRef<T>(null) as RefObject<T>;
  const [isIntersecting, setIsIntersecting] = useState(defaultVisible);
  const observerRef = useRef<IntersectionObserver | null>(null);

  useEffect(() => {
    // 如果没有目标元素,不执行观察
    if (!targetRef.current) return;

    // 清除之前的观察器
    if (observerRef.current) {
      observerRef.current.disconnect();
    }

    // 创建新的观察器
    observerRef.current = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          setIsIntersecting(entry.isIntersecting);
          
          // 如果设置了once且元素可见,则停止观察
          if (once && entry.isIntersecting) {
            observerRef.current?.disconnect();
          }
        });
      },
      { root, rootMargin, threshold }
    );

    // 开始观察目标元素
    observerRef.current.observe(targetRef.current);

    // 清理函数
    return () => {
      if (observerRef.current) {
        observerRef.current.disconnect();
      }
    };
  }, [root, rootMargin, threshold, once]);

  return [targetRef, isIntersecting];
}

// 批量元素版本
export function useBatchIntersectionObserver<T extends Element>(
  count: number,
  options: UseIntersectionObserverOptions = {}
): BatchIntersectionResult<T> {
  const { 
    root = null, 
    rootMargin = '0px', 
    threshold = 0, 
    once = false,
    defaultVisible = false
  } = options;

  const targetsRef = useRef<(T | null)[]>(new Array(count).fill(null));
  const [isIntersectingMap, setIsIntersectingMap] = useState<Map<number, boolean>>(
    new Map(Array.from({ length: count }, (_, i) => [i, defaultVisible]))
  );
  const observerRef = useRef<IntersectionObserver | null>(null);

  // 获取可见的索引
  const visibleIndices = Array.from(isIntersectingMap.entries())
    .filter(([_, visible]) => visible)
    .map(([index]) => index);

  // 设置ref的回调函数
  const ref = (el: T | null, index: number) => {
    if (index >= 0 && index < count) {
      targetsRef.current[index] = el;
    }
  };

  // 检查指定索引的元素是否可见
  const isIntersecting = (index: number): boolean => {
    return isIntersectingMap.get(index) || false;
  };

  useEffect(() => {
    // 清除之前的观察器
    if (observerRef.current) {
      observerRef.current.disconnect();
    }

    // 创建新的观察器
    observerRef.current = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          // 找到当前元素的索引
          const index = targetsRef.current.findIndex(el => el === entry.target);
          if (index !== -1) {
            setIsIntersectingMap(prev => {
              const newMap = new Map(prev);
              newMap.set(index, entry.isIntersecting);
              return newMap;
            });
            
            // 如果设置了once且元素可见,则停止观察
            if (once && entry.isIntersecting) {
              observerRef.current?.unobserve(entry.target);
            }
          }
        });
      },
      { root, rootMargin, threshold }
    );

    // 开始观察所有目标元素
    targetsRef.current.forEach((el) => {
      if (el) {
        observerRef.current?.observe(el);
      }
    });

    // 清理函数
    return () => {
      if (observerRef.current) {
        observerRef.current.disconnect();
      }
    };
  }, [root, rootMargin, threshold, once, count]);

  return { ref, isIntersecting, visibleIndices };
}

Polyfill 的实现思路

在不支持该 API 的旧版浏览器中,Polyfill 会模拟这一功能,其实现方式恰恰反衬了原生 API 的优雅。Polyfill 通常需要:

  • 监听 scrollresize事件,并进行节流。
  • 使用 MutationObserver来监听 DOM 结构变化,因为这些变化可能影响元素位置。
  • 在事件处理程序中,循环遍历所有被观察的元素,使用 getBoundingClientRect()进行手动计算,这本身就会引发性能损耗。

这种模拟方式在性能和精度上都无法与原生实现相提并论,这也正是为什么应该尽可能使用原生 IntersectionObserver的原因

进阶指南:彻底理解 TypeScript 模块共享与隔离机制

一、 核心现象:模块是“天生共享”的

在 ES Modules(ESM)规范下,模块具有单例特性。无论一个模块被导入多少次,它内部的代码只会在第一次被加载时运行一次,结果会被缓存。

1. 代码实验

假设我们有一个状态管理模块:

// state.ts
export let count = 0;
export const dataObj = { name: "Initial" };

export function increment() { count++; }

当我们在不同文件中引用它:

  • 文件 A:调用 increment()
  • 文件 B:读取 count
  • 结果:文件 B 看到的 count1dataObj 的修改也是全局可见的。

2. 底层原理

  1. 加载与执行:引擎首次遇到 import './state',执行该文件并在内存创建作用域。
  2. 模块缓存(Module Registry):引擎会将导出结果存入缓存表。
  3. 引用传递(Live Bindings):后续所有的 import 都是从缓存中获取指向该内存地址的引用。

注意:导入的变量是只读的。你不能在外部直接写 count = 10,必须通过模块内部提供的函数(如 increment)来修改。


二、 为什么要防止变量共享?

虽然全局共享在做“配置管理”或“全局状态”时很方便,但在以下场景则是灾难:

  1. 单元测试隔离:测试用例 A 修改了状态,导致测试用例 B 运行失败,产生干扰。
  2. 多实例需求:例如页面上有三个独立的“计数器组件”,如果共用一个模块变量,它们会同步跳动。
  3. 服务端渲染(SSR):在 Node.js 中,如果模块变量存储了用户信息,不同用户的请求可能会互相污染,造成严重的隐私泄露。

三、 防止共享的四种高级方案

如果你的目标是让每个引入者拥有“独立的代码副本”,请尝试以下方法:

1. 导出类(Class)而非实例

这是最推荐的 OOP(面向对象)方案。每次调用者 new 一个实例,都会开辟独立的内存空间。

// Counter.ts
export class Counter {
  count = 0;
  increment() { this.count++; }
}

// 使用:const c1 = new Counter();

2. 使用工厂函数(Factory Function)

函数式编程的最佳实践。通过闭包产生私有作用域,每次执行函数都返回全新对象。

// state.ts
export const createStore = () => {
  let count = 0; // 闭包私有变量
  return {
    add: () => ++count,
    get: () => count
  };
};

// 使用:const storeA = createStore();

3. 依赖注入与传参

不要在模块顶层存储状态,而是让函数接受状态作为参数。将状态的“生命周期”交给调用者管理。

// logic.ts
export function processData(context: UserContext, data: any) {
  context.history.push(data); // 状态由外部传入的 context 决定
}

4. 框架层面的隔离(Context/Scoped)

在 React 或 Vue 中,利用 Context APIProvide/Inject。数据不再挂载在模块上,而是挂载在 UI 组件树的节点上,实现“局部单例”。


四、 总结与最佳实践

需求场景 推荐策略 核心优势
全局配置、常量 直接导出变量/对象 简单、高效、全应用统一
数据库连接、缓存 默认单例共享 节省资源,避免重复初始化
业务组件状态 导出 Class 或工厂函数 互不干扰,支持多实例
纯逻辑处理 传参/纯函数 极易进行单元测试,无副作用

💡 独家技巧:

如果你确实需要全局单例,但又想方便测试,记得导出一个 reset 函数:

let state = { ... };
export const resetStateForTest = () => { state = { ... }; };

结语: 理解 TypeScript 模块的共享机制是走向中高级开发的必经之路。记住:默认共享是为了效率,主动隔离是为了安全。 根据业务场景选择合适的导出方式,才能写出既健壮又易于维护的代码。

深入浅出 TypeScript 模块系统:从语法到构建原理

在现代前端开发中,模块化是组织大规模代码库的基石。TypeScript 不仅完全支持 ES6 模块标准,还在此基础上增加了类型安全的保障。本文将从语法使用、编译器原理、构建行为三个维度,深度拆解 TS 的模块系统。


一、 核心语法:Import 与 Export

在 TS 中,一个文件就是一个模块。它具有独立的作用域,外部无法访问其内部变量,除非显式导出。

1. 导出 (Export)

  • 命名导出:一个文件可导出多个,导入时需名称匹配。
    export const PI = 3.14;
    export function add(a: number, b: number) { return a + b; }
    
  • 默认导出:一个文件仅限一个,通常用于模块的核心功能。
    export default class Logger { ... }
    

2. 导入 (Import)

  • 常用导入import { PI } from './math'
  • 重命名import { PI as MathPI } from './math'
  • 全量导入import * as MathTools from './math'

3. TypeScript 特色:类型导入 (Type-Only Imports)

这是 TS 独有的语法,用于明确告诉编译器:我只想要类型,不想要任何运行时代码。

import type { UserInterface } from './types';
// 或者
import { add, type Point } from './math';
  • 优点:极致的构建优化,避免类型定义在 JS 中产生冗余,且能防止某些循环引用导致的运行时错误。

二、 编译器原理:当你写下 Import 时发生了什么?

当我们写下 import { user } from "../../../models/user" 时,TS 编译器(tsc)会经历以下过程:

  1. 路径解析 (Module Resolution)
    • 编译器根据 tsconfig.json 中的 moduleResolution 策略寻找文件。
    • 它会按顺序尝试 .ts -> .tsx -> .d.ts 后缀,甚至进入 node_modules 查找 package.json 中的类型声明。
  2. 符号链接 (Symbol Linking)
    • 编译器读取目标文件,确认其是否真的 exportuser
    • 建立链接,此时你在当前文件中对 user 的所有操作都将受到 user.ts 中定义的类型约束。
  3. 构建依赖图
    • 编译器建立起整个项目的树状引用关系,用于增量编译和错误追踪。

三、 编译 vs 打包:代码最后去哪了?

这是一个常见的误区:TS 编译并不等于打包。

1. 编译阶段 (tsc)

  • 不合并代码tsc 只是把 .ts 翻译成 .js
  • 转换语法:把 import 翻译成 require (CommonJS) 或保留 (ESM)。
  • 文件独立A.js 依然是 A.jsuser.js 依然是 user.js,代码没有合在一起。

2. 打包阶段 (Vite / Webpack)

  • 合并代码:打包工具会将所有依赖的文件“缝合”成一个或几个 bundle.js
  • Tree Shaking:如果 user.ts 导出了很多函数但你只用了一个,打包工具会把没用的代码删掉,减小体积。

四、 深度思考:多文件引入同一份数据会怎样?

如果文件 A 和文件 B 都 import { config } from "./data",打包后会产生多份 data 副本吗?

答案是:不会。

  1. 模块单例模式:在运行时,模块代码只会在第一次被引用时执行一次
  2. 缓存机制:执行结果会被缓存在内存中。之后所有引用该模块的地方,拿到的都是同一个引用(内存地址)
  3. 构建优化
    • 如果是单文件打包,data 代码只会出现一次。
    • 如果是多页面应用,打包工具会自动提取“公共依赖”为一个独立文件(如 vendor.js),实现浏览器端的跨页面缓存。

五、 最佳实践建议

  1. 优先使用命名导出:比默认导出更利于 IDE 自动补全和 Tree Shaking。
  2. 显式使用 import type:当你只需要接口或类型声明时,养成这个习惯可以提升编译性能。
  3. 配置路径别名:在 tsconfig.json 中配置 paths(如 @/*),告别 ../../../../ 的痛苦。
  4. 关注模块规范:在 Node.js 环境优先考虑 CommonJS,在浏览器/Vite 环境优先考虑 ESNext

总结:TypeScript 的模块系统是静态类型检查与现代 JS 模块标准的完美结合。理解它在“编译时”和“打包时”的不同表现,能帮助我们写出更健壮、性能更好的前端代码。

深度解析Vue3响应式原理:Proxy + Reflect + effect 三叉戟

响应式系统是Vue框架的核心基石,它实现了“数据驱动视图”的核心思想——当数据发生变化时,依赖该数据的视图会自动更新,无需手动操作DOM。Vue3相较于Vue2,彻底重构了响应式系统,放弃了Object.defineProperty,转而采用Proxy + Reflect + effect的组合方案,解决了Vue2响应式的诸多缺陷(如无法监听对象新增属性、数组索引变化等)。本文将从核心概念入手,层层拆解三者的协作机制,深入剖析Vue3响应式系统的实现原理与核心细节。

一、核心目标:什么是“响应式”?

在Vue中,“响应式”的核心目标可概括为:建立数据与依赖(如组件渲染函数、watch回调)之间的关联,当数据发生变化时,自动触发所有依赖的重新执行

举个直观的例子:

<script setup>
import { ref } from 'vue';
const count = ref(0); // 响应式数据

// 依赖count的逻辑(组件渲染函数)
const render = () => {
  document.body.innerHTML = `count: ${count.value}`;
};

// 初始执行渲染
render();

// 1秒后修改数据,视图自动更新
setTimeout(() => {
  count.value = 1;
}, 1000);
</script>

上述代码中,count是响应式数据,render函数是依赖count的“副作用”。当count.value修改时,render函数会自动重新执行,视图随之更新。Vue3响应式系统的核心任务,就是自动完成“依赖收集”(识别render依赖count)和“依赖触发”(count变化时触发render重新执行)。

二、核心三要素:Proxy + Reflect + effect 各司其职

Vue3响应式系统的实现依赖三个核心要素,它们分工明确、协同工作:

  • Proxy:作为响应式数据的“代理层”,拦截数据的读取(get)、修改(set)等操作,为依赖收集和依赖触发提供“钩子”。
  • Reflect:配合Proxy完成数据操作的“反射层”,确保在拦截操作时,能正确保留原对象的行为(如原型链、属性描述符等),同时简化拦截逻辑。
  • effect:封装“副作用”逻辑(如组件渲染函数、watch回调),负责触发依赖收集(记录数据与副作用的关联)和在数据变化时重新执行副作用。

三者的协作流程可简化为:

  1. effect执行副作用函数,触发数据的读取操作。
  2. Proxy拦截数据读取,通过Reflect完成原始读取操作,同时触发依赖收集(将当前effect与数据关联)。
  3. 当数据被修改时,Proxy拦截数据修改,通过Reflect完成原始修改操作,同时触发依赖触发(找到所有关联的effect并重新执行)。

三、逐个拆解:核心要素的作用与实现

3.1 Proxy:响应式数据的“拦截器”

Proxy是ES6新增的对象,用于创建一个对象的代理,从而实现对目标对象的属性读取、修改、删除等操作的拦截和自定义处理。Vue3正是利用Proxy的拦截能力,为响应式数据提供了“监听”机制。

3.1.1 Proxy的核心优势(对比Vue2的Object.defineProperty)

  • 支持监听对象新增属性:Object.defineProperty只能监听已存在的属性,无法监听新增属性;Proxy的set拦截可以捕获对象新增属性的操作。
  • 支持监听数组索引/长度变化:Object.defineProperty难以监听数组通过索引修改元素、修改length属性的操作;Proxy可以轻松拦截数组的这些变化。
  • 支持监听对象删除操作:Proxy的deleteProperty拦截可以捕获属性删除操作。
  • 非侵入式拦截:Proxy无需像Object.defineProperty那样遍历对象属性并重新定义,直接代理目标对象,更高效、更简洁。

3.1.2 Proxy在响应式中的核心拦截操作

在Vue3响应式系统中,主要拦截以下两个核心操作:

  1. get拦截:当读取响应式对象的属性时触发,核心作用是“依赖收集”——记录当前正在执行的effect与该属性的关联。
  2. set拦截:当修改响应式对象的属性时触发,核心作用是“依赖触发”——找到所有与该属性关联的effect,重新执行它们。

简单实现一个基础的响应式Proxy:

// 目标对象
const target = { count: 0 };

// 创建Proxy代理
const reactiveTarget = new Proxy(target, {
  // 拦截属性读取操作
  get(target, key, receiver) {
    console.log(`读取属性 ${key}${target[key]}`);
    // 此处会触发依赖收集逻辑(后续补充)
    return target[key];
  },
  // 拦截属性修改/新增操作
  set(target, key, value, receiver) {
    console.log(`修改属性 ${key}${value}`);
    target[key] = value;
    // 此处会触发依赖触发逻辑(后续补充)
    return true; // 表示修改成功
  }
});

// 测试拦截效果
reactiveTarget.count; // 输出:读取属性 count:0
reactiveTarget.count = 1; // 输出:修改属性 count:1
reactiveTarget.name = "Vue3"; // 输出:修改属性 name:Vue3(支持新增属性拦截)

3.2 Reflect:拦截操作的“反射器”

Reflect也是ES6新增的内置对象,它提供了一系列方法,用于执行对象的原始操作(如读取属性、修改属性、删除属性等),这些方法与Proxy的拦截方法一一对应。Vue3在Proxy的拦截器中,通过Reflect执行原始数据操作,而非直接操作目标对象。

3.2.1 为什么需要Reflect?

  • 确保原始操作的正确性:Reflect的方法会严格遵循ECMAScript规范,正确处理对象的原型链、属性描述符等细节。例如,当目标对象的属性不可写时,Reflect.set会返回false,而直接赋值会抛出错误。
  • 简化拦截逻辑:Reflect的方法会自动传递receiver(Proxy实例),确保在操作中正确绑定this。例如,当目标对象的属性是访问器属性(getter/setter)时,receiver可以确保this指向Proxy实例,而非目标对象。
  • 统一的返回值逻辑:Reflect的方法都会返回一个布尔值,表示操作是否成功,便于拦截器中判断操作结果。

3.2.2 Reflect在响应式中的应用

修改上述Proxy示例,使用Reflect执行原始操作:

const target = { count: 0 };

const reactiveTarget = new Proxy(target, {
  get(target, key, receiver) {
    console.log(`读取属性 ${key}`);
    // 使用Reflect.get执行原始读取操作,传递receiver
    return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver) {
    console.log(`修改属性 ${key}${value}`);
    // 使用Reflect.set执行原始修改操作,返回操作结果
    const success = Reflect.set(target, key, value, receiver);
    if (success) {
      // 操作成功后触发依赖
      console.log("依赖触发成功");
    }
    return success;
  }
});

reactiveTarget.count; // 输出:读取属性 count
reactiveTarget.count = 1; // 输出:修改属性 count:1 → 依赖触发成功

3.3 effect:副作用的“管理器”

effect是Vue3响应式系统中封装“副作用”的核心函数。所谓“副作用”,是指会依赖响应式数据、且当响应式数据变化时需要重新执行的逻辑(如组件渲染函数、watch回调函数、computed计算函数等)。

3.3.1 effect的核心作用

  • 触发依赖收集:当effect执行时,会将自身设为“当前活跃的effect”,然后执行副作用函数。副作用函数中读取响应式数据时,会触发Proxy的get拦截,此时将“当前活跃的effect”与该数据属性关联起来(依赖收集)。
  • 响应数据变化:当响应式数据变化时,会触发Proxy的set拦截,此时找到所有与该数据属性关联的effect,重新执行它们(依赖触发)。

3.3.2 effect的简单实现

要实现effect,需要解决两个核心问题:

  1. 如何记录“当前活跃的effect”?
  2. 如何存储“数据属性与effect的关联关系”?

解决方案:

  • 用一个全局变量(如activeEffect)存储当前正在执行的effect。
  • 用一个“依赖映射表”(如targetMap)存储关联关系,结构为:targetMap → target → key → effects(Set集合)。

具体实现代码:

// 1. 全局变量:存储当前活跃的effect
let activeEffect = null;

// 2. 依赖映射表:target → key → effects
const targetMap = new WeakMap();

// 3. 依赖收集函数:建立数据属性与effect的关联
function track(target, key) {
  // 若没有活跃的effect,无需收集依赖
  if (!activeEffect) return;

  // 从targetMap中获取当前target的依赖表(没有则创建)
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()));
  }

  // 从depsMap中获取当前key的effect集合(没有则创建)
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }

  // 将当前活跃的effect添加到集合中(Set自动去重)
  deps.add(activeEffect);
}

// 4. 依赖触发函数:数据变化时,执行关联的effect
function trigger(target, key) {
  // 从targetMap中获取当前target的依赖表
  const depsMap = targetMap.get(target);
  if (!depsMap) return;

  // 从depsMap中获取当前key的effect集合
  const deps = depsMap.get(key);
  if (deps) {
    // 执行所有关联的effect
    deps.forEach(effect => effect());
  }
}

// 5. effect核心函数:封装副作用
function effect(callback) {
  // 定义effect函数
  const effectFn = () => {
    // 执行副作用前,先清除当前effect的关联(避免重复收集)
    cleanup(effectFn);
    // 将当前effect设为活跃状态
    activeEffect = effectFn;
    // 执行副作用函数(会触发响应式数据的get拦截,进而触发track收集依赖)
    callback();
    // 副作用执行完毕,重置活跃effect
    activeEffect = null;
  };

  // 存储当前effect关联的依赖集合(用于cleanup清除)
  effectFn.deps = [];

  // 初始执行一次effect,触发依赖收集
  effectFn();
}

// 6. 清除依赖函数:避免effect重复执行
function cleanup(effectFn) {
  // 遍历effect关联的所有依赖集合,移除当前effect
  for (const deps of effectFn.deps) {
    deps.delete(effectFn);
  }
  // 清空deps数组
  effectFn.deps.length = 0;
}

// 7. 响应式函数:创建Proxy代理
function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      // 执行原始读取操作
      const result = Reflect.get(target, key, receiver);
      // 触发依赖收集
      track(target, key);
      return result;
    },
    set(target, key, value, receiver) {
      // 执行原始修改操作
      const success = Reflect.set(target, key, value, receiver);
      // 触发依赖触发
      trigger(target, key);
      return success;
    }
  });
}

3.3.3 effect的工作流程演示

结合上述实现,演示effect与响应式数据的协作流程:

// 1. 创建响应式数据
const state = reactive({ count: 0 });

// 2. 定义副作用(组件渲染逻辑模拟)
effect(() => {
  console.log(`count: ${state.count}`);
});
// 初始执行effect,输出:count: 0
// 执行过程中读取state.count,触发get拦截 → 调用track收集依赖(effect与state.count关联)

// 3. 修改响应式数据
state.count = 1;
// 触发set拦截 → 调用trigger → 执行关联的effect → 输出:count: 1

// 4. 新增属性(Proxy支持)
state.name = "Vue3";
// 触发set拦截 → 调用trigger(无关联effect,无输出)

// 5. 定义依赖name的副作用
effect(() => {
  console.log(`name: ${state.name}`);
});
// 初始执行effect,输出:name: Vue3
// 收集name与该effect的关联

// 6. 修改name
state.name = "Vue3 Reactivity";
// 触发set拦截 → 执行关联的effect → 输出:name: Vue3 Reactivity

四、核心协作流程:完整响应式链路拆解

结合上述实现,我们可以梳理出Vue3响应式系统的完整协作流程,分为“依赖收集阶段”和“依赖触发阶段”两个核心环节。

4.1 依赖收集阶段(数据与effect关联)

  1. 调用effect函数,传入副作用回调(如渲染函数)。
  2. effect函数内部创建effectFn,执行effectFn。
  3. effectFn中先执行cleanup清除旧依赖,再将自身设为activeEffect(当前活跃effect)。
  4. 执行副作用回调,回调中读取响应式数据的属性(如state.count)。
  5. 触发响应式数据的Proxy.get拦截。
  6. get拦截中调用Reflect.get执行原始读取操作。
  7. 调用track函数,在targetMap中建立“target(state)→ key(count)→ effectFn”的关联。
  8. 副作用回调执行完毕,重置activeEffect为null。

4.2 依赖触发阶段(数据变化触发effect重新执行)

  1. 修改响应式数据的属性(如state.count = 1)。
  2. 触发响应式数据的Proxy.set拦截。
  3. set拦截中调用Reflect.set执行原始修改操作。
  4. 调用trigger函数,从targetMap中查找“target(state)→ key(count)”关联的所有effectFn。
  5. 遍历执行所有关联的effectFn,副作用逻辑(如渲染函数)重新执行,视图更新。

五、进阶细节:Vue3响应式系统的优化与扩展

5.1 对Ref的支持:基本类型的响应式

Proxy只能代理对象类型,无法直接代理基本类型(string、number、boolean等)。Vue3通过Ref解决了基本类型的响应式问题:

  • Ref将基本类型包装成一个“具有value属性的对象”(如{ value: 0 })。
  • 对Ref对象的value属性进行Proxy代理,从而实现基本类型的响应式。
  • 在模板中使用Ref时,Vue3会自动解包(无需手动写.value),在组合式API的setup中则需要手动使用.value。

5.2 对computed的支持:缓存型副作用

computed本质是一个“缓存型effect”,它具有以下特性:

  • computed的回调函数是一个副作用,依赖响应式数据。
  • computed会缓存计算结果,只有当依赖的响应式数据变化时,才会重新计算。
  • computed内部通过effect的调度器(scheduler)实现缓存逻辑:当依赖变化时,不立即执行effect,而是标记为“脏数据”,等到下次读取computed值时再重新计算。

5.3 对watch的支持:监听数据变化的副作用

watch的核心是“监听指定响应式数据的变化,触发自定义副作用”,其实现基于effect:

  • watch内部创建一个effect,副作用函数中读取要监听的响应式数据(触发依赖收集)。
  • 当监听的数据变化时,触发effect重新执行,此时调用watch的回调函数,并传入新旧值。
  • watch支持“深度监听”(通过deep选项)和“立即执行”(通过immediate选项),本质是通过调整effect的执行时机和依赖收集范围实现。

5.4 调度器(scheduler):控制effect的执行时机

Vue3的effect支持传入调度器函数(scheduler),用于控制effect的执行时机和方式。调度器是实现computed缓存、watch延迟执行、批量更新的核心:

  • 当effect触发时,若存在调度器,会执行调度器而非直接执行effect。
  • 例如,Vue3的批量更新机制:将多个effect的执行延迟到下一个微任务中,避免多次DOM更新,提升性能。

六、实战避坑:响应式系统的常见问题

6.1 响应式数据的“丢失”问题

问题描述:将响应式对象的属性解构赋值给普通变量,普通变量会失去响应式。

import { reactive } from 'vue';

const state = reactive({ count: 0 });
const { count } = state; // 解构出普通变量count,失去响应式

count = 1; // 不会触发响应式更新

解决方案:

  • 避免直接解构响应式对象,若需解构,可使用toRefs将响应式对象的属性转为Ref。
  • 使用Ref包裹基本类型,避免解构导致的响应式丢失。
import { reactive, toRefs } from 'vue';

const state = reactive({ count: 0 });
const { count } = toRefs(state); // count是Ref对象,保留响应式

count.value = 1; // 触发响应式更新

6.2 数组响应式的特殊情况

问题描述:通过数组的某些方法(如push、pop)修改数组时,Vue3能正常监听,但直接修改数组索引或length时,需注意响应式触发。

import { reactive } from 'vue';

const arr = reactive([1, 2, 3]);

arr[0] = 10; // 能触发响应式更新
arr.length = 0; // 能触发响应式更新
arr.push(4); // 能触发响应式更新

注意:Vue3对数组的响应式支持已非常完善,大部分数组操作都能正常触发响应式,但仍建议优先使用数组的内置方法(push、splice等)修改数组,更符合直觉。

6.3 深层对象的响应式问题

问题描述:响应式对象的深层属性变化时,是否能正常触发响应式?

答案:能。因为Proxy的get拦截会递归触发深层属性的依赖收集。例如:

import { reactive } from 'vue';

const state = reactive({ a: { b: 1 } });

effect(() => {
  console.log(state.a.b); // 读取深层属性,收集依赖
});

state.a.b = 2; // 能触发响应式更新,输出2

注意:若深层对象是后来新增的,需确保新增的对象也是响应式的(Vue3的reactive会自动处理新增属性的响应式)。

七、总结:Vue3响应式系统的核心价值

Vue3响应式系统通过Proxy + Reflect + effect的组合,构建了一个高效、灵活、功能完善的响应式机制,其核心价值在于:

  • 彻底解决了Vue2响应式的缺陷:支持对象新增属性、数组索引/长度变化、属性删除等操作的监听。
  • 非侵入式设计:通过Proxy代理目标对象,无需修改原始对象的结构,更符合JavaScript的语言特性。
  • 灵活的扩展能力:通过effect的调度器、Ref、computed、watch等扩展,支持各种复杂的业务场景。
  • 高效的性能:通过批量更新、缓存机制(computed)等优化,减少不必要的副作用执行,提升应用性能。

理解Vue3响应式原理,不仅能帮助我们更好地使用Vue3的API(如reactive、ref、computed、watch),还能让我们在遇到响应式相关问题时快速定位并解决。Proxy + Reflect + effect的组合设计,也为我们编写高效的JavaScript代码提供了优秀的思路借鉴。

Git 提交AI神器:用大模型帮你写出规范的 Commit Message

Git 提交AI神器:用大模型帮你写出规范的 Commit Message

在软件开发中,规范的 Git 提交信息不仅是团队协作的基础,更是项目可维护性、可追溯性的关键。无论是用于生成清晰的 CHANGELOG,还是帮助 Leader 审核你的工作成果,甚至让新手也能像资深工程师一样提交高质量代码——一个好用的 Commit Message 工具都不可或缺。

今天,我们来介绍一款基于 本地开源大模型 + 全栈技术栈 打造的 Git 提交辅助神器:Git Commit AI Assistant。它能自动分析你的 git diff,并生成符合 Conventional Commits 规范的专业级提交信息。


🌟 项目亮点

  • 本地部署,数据安全:使用 Ollama 在本地运行 deepseek-r1:8b 开源大模型,无需联网,隐私无忧。
  • 前后端分离架构:前端 React + TailwindCSS,后端 Node.js + Express,结构清晰,易于扩展。
  • 开箱即用:只需复制粘贴 git diff 内容,AI 自动为你生成语义清晰、格式规范的 commit message。
  • 开发者友好:支持热重载(nodemon)、API 调试(Apifox)、跨域处理(CORS),开发体验丝滑。

🛠 技术栈一览

层级 技术
前端 React 18 + Vite + Tailwind CSS + Axios
后端 Node.js + Express
AI 引擎 Ollama + deepseek-r1:8b(8B 参数推理模型)
开发工具 nodemon(热更新)、Apifox(API 测试)

🧠 核心原理

  1. 用户在前端粘贴 git diff 输出内容。
  2. 前端通过 Axios 将 diff 文本发送到后端 /chat 接口。
  3. 后端调用本地 Ollama 服务(http://localhost:11434),使用 LangChain 构建提示词链。
  4. 大模型根据预设的系统角色(如“你是一个专业的 Git 提交信息生成助手”)生成规范的 commit message。
  5. 结果返回前端,用户一键复制即可使用。

🚀 快速启动指南

1. 启动 AI 模型(Ollama)

确保已安装 Ollama,然后拉取并运行模型:

ollama pull deepseek-r1:8b
ollama run deepseek-r1:8b  # 可选,验证是否正常

Ollama 默认提供兼容 OpenAI 的 API 接口,监听 http://localhost:11434


2. 启动后端服务(Express)

cd server
npm install
npm install express cors @langchain/ollama @langchain/core
npx nodemon index.js

服务将在 http://localhost:3000 启动,并提供以下接口:

  • GET /hello → 测试连通性
  • POST /chat → 接收用户输入,返回 AI 生成的 commit message

✅ 已内置 JSON 解析中间件和 CORS 跨域支持,前端可直接调用。


3. 启动前端(React + Vite)

cd frontend
npm install
npm run dev

前端运行于 http://localhost:5173,界面简洁,支持实时加载与错误提示。


💡 示例:AI 如何生成 Commit Message?

输入(git diff 片段)

+ export const formatDate = (date) => {
+   return new Date(date).toLocaleDateString();
+ };

AI 输出

feat(utils): add formatDate utility function

完全符合 Conventional Commits 规范!


📦 后端核心代码(Express + LangChain)

import express from 'express';
import cors from 'cors';
import { ChatOllama } from "@langchain/ollama";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from '@langchain/core/output_parsers';

const model = new ChatOllama({
  baseUrl: 'http://localhost:11434',
  model: 'deepseek-r1:8b',
  temperature: 0.1 // 降低随机性,提高一致性
});

const app = express();
app.use(express.json());
app.use(cors());

app.post('/chat', async (req, res) => {
  const { message } = req.body;

  if (!message || typeof message !== 'string') {
    return res.status(400).json({
      error: "message 必填,必须是字符串"
    });
  }

  try {
    const prompt = ChatPromptTemplate.fromMessages([
      ['system', '你是一个专业的 Git 提交信息生成助手。请根据用户提供的 git diff 内容,生成一条符合 Conventional Commits 规范的 commit message。只输出 commit message,不要解释。'],
      ['human', '{input}']
    ]);

    const chain = prompt.pipe(model).pipe(new StringOutputParser());
    const result = await chain.invoke({ input: message });

    res.json({ reply: result.trim() });
  } catch (e) {
    console.error(e);
    res.status(500).json({ error: "调用大模型失败" });
  }
});

app.listen(3000, () => {
  console.log('🚀 Git Commit AI Server running on http://localhost:3000');
});

🧪 前端 Hook 封装(React)

// hooks/useGitDiff.js
import { useState, useEffect } from 'react';
import { chat } from '../api';

export const useGitDiff = (diffText) => {
  const [content, setContent] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!diffText) return;
    
    const generateCommit = async () => {
      setLoading(true);
      setError(null);
      try {
        const { data } = await chat(diffText);
        setContent(data.reply);
      } catch (err) {
        setError('生成失败,请检查后端服务');
      } finally {
        setLoading(false);
      }
    };

    generateCommit();
  }, [diffText]);

  return { loading, content, error };
};

🔒 为什么选择本地大模型?

  • 无网络依赖:公司内网、离线环境也能用。
  • 零成本:无需支付 OpenAI 或其他云 API 费用。
  • 高性能deepseek-r1:8b 在消费级 GPU 上推理流畅。
  • 可定制:可微调提示词或更换模型(如 codegemma, phi3 等)。

📌 结语

规范的 Git 提交不是负担,而是专业性的体现。借助 AI,我们可以把重复性工作交给机器,专注于更有价值的编码与设计。

这个 Git 提交AI神器 不仅是一个工具,更是一种工程文化的倡导者。现在就把它集成到你的开发流程中,让你的每一次 git commit 都闪闪发光 ✨!

GitHub 仓库即将开源,关注我们获取最新进展!
本地部署 · 隐私安全 · 极简体验

拒绝繁琐!Redux Toolkit (RTK) 极简拟人化入门指南

前言:还在为 Redux 繁琐的样板代码(Boilerplate)头秃吗?还在写无休止的 switch-caseaction types 吗?

大人,时代变了。官方现在强烈推荐使用 Redux Toolkit (RTK) 。它不仅是 Redux 的官方工具集,更是为了简化逻辑而生。今天我们不讲晦涩的源码,我们用一个**“现代化超级仓库”**的故事,带你十分钟上手 RTK。

一、 核心概念:拟人化图解 📦

想象你的 React 应用是一个巨大的 “超级物流园”

1. Store(仓库)

这是整个物流园的总基地。所有的数据(货物)都存放在这里,严禁外人随意进出拿取,必须按规矩办事。

2. Slice(片区/部门)

仓库太大,必须分区分片管理。比如“计数器区”、“用户信息区”。每个片区都有自己的**“管理员手册”“初始库存”**。

3. State(库存)

片区里存放的具体数据。比如计数器区的当前数字是 0,这就是库存。

4. Reducer(管理员/规则制定者)

这是片区里的执行规则

  • 拟人化:管理员手里拿着一本手册,上面写着:“如果收到‘加一’的指令,就把库存 +1”。
  • RTK的魔法:在 RTK 里,管理员可以直接“修改”库存(底层由 Immer 库处理不可变性),你感觉你在直接改数据,其实 RTK 帮你处理了复杂的脏活累活。

5. Dispatch(传令兵)

组件(页面)想修改数据,不能自己动手,必须派传令兵把指令(Action)送给管理员。

6. Selector(监控探头)

组件想看数据,不需要把整个仓库搬走,只需要通过监控探头看一眼自己关心的那个数据。

二、 代码实战:三步搭建“计数器部门” 🛠️

我们要实现的功能很简单:一个计数器,能加、能减、能重置。

第一步:建立片区与制定规则 (counterSlice.js)

我们需要创建一个“计数器部门”,并制定几条铁律。

import { createSlice } from '@reduxjs/toolkit';

// 1. 初始库存:最开始是 0
const initialState = {
  value: 0,
};

// 2. 创建片区 (createSlice)
export const counterSlice = createSlice({
  name: 'counter', // 片区名字:计数器部
  initialState,
  // 3. 管理员手册 (Reducers):制定规则
  reducers: {
    // 规则一:增量
    increment: (state) => {
      // 拟人化:管理员直接把库存改了 (RTK 允许这样写,不用写 return { ...state })
      state.value += 1; 
    },
    // 规则二:减量
    decrement: (state) => {
      state.value -= 1;
    },
    // 规则三:自定义数量 (接收一个指令包 action)
    incrementByAmount: (state, action) => {
      // action.payload 就是传令兵带来的具体数字
      state.value += action.payload;
    },
  },
});

// 4. 导出指令 (Actions)
// RTK 自动帮我们把 increment 等规则生成了对应的指令牌,组件直接拿去用
export const { increment, decrement, incrementByAmount } = counterSlice.actions;

// 5. 导出这个片区的管理员 (Reducer),总仓库要用
export default counterSlice.reducer;

第二步:组建总仓库 (store.js)

现在把“计数器部门”接入到“总基地”里。

import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';

// 创建总仓库
export const store = configureStore({
  reducer: {
    // 这里的 key ('counter') 就是在总仓库里的片区门牌号
    counter: counterReducer,
  },
});

第三步:让应用接入仓库 (index.jsmain.jsx)

在应用的入口,把仓库的大门打开,让所有组件都能连接进来。

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { store } from './store'; // 引入总仓库
import { Provider } from 'react-redux'; // 引入连接器

ReactDOM.createRoot(document.getElementById('root')).render(
  // 用 Provider 包裹 App,把 store 传进去
  <Provider store={store}>
    <App />
  </Provider>
);

三、 组件使用:派单与查看 📱

现在来到 React 组件内部,作为用户(User),我们如何与仓库交互?

  • useSelector: 查看库存(读数据)。
  • useDispatch: 雇佣传令兵(改数据)。
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
// 引入我们刚才生成的指令牌
import { increment, decrement, incrementByAmount } from './counterSlice';

export function Counter() {
  // 1. 查看监控:只关心 counter 片区的 value 值
  const count = useSelector((state) => state.counter.value);
  
  // 2. 雇佣传令兵
  const dispatch = useDispatch();

  return (
    <div>
      <h1>当前库存: {count}</h1>
      
      <div className="card">
        {/* 派发“增加”指令 */}
        <button onClick={() => dispatch(increment())}>
          进货 (+1)
        </button>

        {/* 派发“减少”指令 */}
        <button onClick={() => dispatch(decrement())}>
          出货 (-1)
        </button>

        {/* 派发“自定义”指令,带上参数 10 */}
        <button onClick={() => dispatch(incrementByAmount(10))}>
           大批量进货 (+10)
        </button>
      </div>
    </div>
  );
}

四、 总结:RTK 到底爽在哪? 🎉

回顾一下,使用 Redux Toolkit 相比老旧的 Redux,我们省去了什么:

  1. 不再需要 手动定义 ACTION_TYPES 常量字符串(比如 'INCREMENT')。
  2. 不再需要 写冗长的 switch...case 语句。
  3. 不再需要 手动做不可变更新(return { ...state, value: state.value + 1 }),直接 state.value += 1 即可,Immer 库在底层为你保驾护航。
  4. 不再需要 复杂的 combineReducers 配置,configureStore 一键搞定。

一句话总结: RTK 就是把复杂的仓库管理变成了**“定义片区 -> 制定简单规则 -> 组件直接调用”**的流水线模式。

快去你的项目里试试吧!

不懂鸿蒙权限?看这篇就够了(鸿蒙权限获取最佳实践,附完整代码)

鸿蒙权限管理不踩坑指南:做个“懂分寸”的合规好应用

在鸿蒙(HarmonyOS)的世界里,权限管理就像应用的“社交礼仪”——懂分寸、不越界,才能赢得用户好感和系统“青睐”;要是乱要权限、硬闯隐私,轻则被用户无情卸载,重则过不了应用市场审核。今天就用接地气的吐槽+干货,把鸿蒙权限使用的核心玩法说清楚,让你开发路上少踩雷!

一、鸿蒙权限使用基本原则:做个“不贪心、不霸道”的好应用

鸿蒙对权限的要求,本质就是让应用“守规矩”,这几个原则记牢,能少走99%的弯路:

  1. 最小权限原则:别当“伸手党”
    应用需要啥权限就拿啥,多余的一概不碰!比如一个看小说的APP,非要申请相机、麦克风权限,这不是“没事找事”吗?用户看到直接黑人问号脸,卸载按钮都要按出火星子。
  2. 必要性原则:按需“点菜”,别一次性“包场”
    权限要在用户用到对应功能时再申请,别一打开APP就弹窗“轰炸”:“要位置!要存储!要通讯录!” 这跟刚见面就问人要银行卡密码一样离谱,用户不拒绝你才怪。
  3. 透明化原则:坦诚相待,别“玩套路”
    申请权限前,得跟用户说清楚“要这玩意儿干啥”。比如“要存储权限是为了保存你下载的小说”,别只说“需要存储权限”,用户哪知道你是不是要偷偷存人家照片?坦诚才是必杀技!
  4. 尊重用户选择原则:拒绝就体面点,别死缠烂打
    用户拒绝权限后,别反复弹窗“骚扰”,更不能搞“不授权就用不了核心功能”的霸道操作。人家不想给相机权限,你还不让人看小说了?格局打开,给个替代方案不香吗?
  5. 合规性原则:别“走歪路”,系统爸爸会“打屁股”
    别想着绕过系统授权、隐藏权限用途这些“骚操作”,鸿蒙的审核机制可不是吃素的,违规的话,应用上架直接“原地凉凉”,前期开发全白费。

二、权限分类:鸿蒙的“权限等级表”,别认错“大佬”和“路人”

鸿蒙把权限分了三六九等,就像职场里的“普通员工”“核心骨干”“大老板”,待遇和申请难度完全不一样,千万别搞混了:

(一)按权限等级分类

  1. system_grant(系统授权):“路人甲”级别的小透明
    不涉及隐私、不影响系统安全,比如网络访问、查看蓝牙的配置这些基础操作。系统会自动“放行”,不用麻烦用户手动授权,只要在配置文件里打个“报告”就行,省心又省力。
  2. user_grant(用户授权):“高危操作选手”,需用户点头
    涉及用户隐私(位置、相册)或核心功能(相机、麦克风)的权限,都是“高危分子”。想使用这些权限,必须让用户明确“点头”同意,系统还会特意弹窗提醒,相当于给用户一个“反悔的机会”。
  3. manual_settings(手动设置授权):“大佬级”权限,申请门槛拉满
    比如拦截键盘输入、无需弹窗录制屏幕、无需弹窗访问用户公共路径这些“核心操作”,属于权限里的“天花板”。想拿到它们可不容易,应用没法直接申请,得引导用户手动去系统设置里开启,相当于要去“大佬办公室”亲自报备。

另外,在system_grant和user_grant类型权限中,还藏着一些特殊的 “受限开放权限”,比如悬浮窗、读取联系人等等。这些权限可不是声明就能用的,必须提前单独申请,否则应用上架时直接会被审核老师打回,连辩解的机会都没有。 受限开放权限申请步骤(直接抄官方流程不踩坑)

(二)按功能权限组分类:权限也爱“抱团取暖”

鸿蒙特别贴心地把功能相关的权限分成了 “小组”,既方便开发者管理,也方便用户理解。这里有个小知识点要划重点:应用请求权限时,同一权限组内的权限会在一个弹窗内统一请求用户授权,用户一旦同意,整个权限组内的权限就会被批量授予。不过有例外 —— 位置信息、通讯录、日历这三个权限组,不遵循这个 “抱团授权” 规则,得单独留意。

以位置信息权限组和相机权限组举个例子帮你快速理解,一看就懂:。

当应用只申请权限ohos.permission.APPROXIMATELY_LOCATION(属于位置信息权限组)时,用户将收到一个请求位置信息的弹窗,包含单个权限的申请。 当应用同时申请权限ohos.permission.APPROXIMATELY_LOCATION和ohos.permission.LOCATION(均属于位置信息权限组)时,用户将收到一个请求位置信息的弹窗,包含两个权限的申请。 当应用同时申请权限ohos.permission.APPROXIMATELY_LOCATION(属于位置信息权限组)和ohos.permission.CAMERA(属于相机权限组)时,用户将收到请求位置信息、请求使用相机的两个弹窗。 权限组完整使用说明(官方清单,按需查阅)

三、权限申请方法:三步搞定,不做“尴尬申请者”

鸿蒙权限申请讲究“先报备、再申请、看结果”,不同权限有不同玩法,一步步来准没错:

(一)静态声明:先给系统“打报告”,不报备可不行

所有权限都得先在项目的 module.json5 文件里“登记备案”,相当于告诉系统“我要用这些权限啦”,这是基础操作,少了这步直接翻车。

  1. 找到 module.json5 文件,在 requestPermissions 节点里添加权限信息;
  2. 正确示范(别瞎写,权限名称要跟官方一致):
"requestPermissions": [
  {
    "name": "ohos.permission.CAMERA", // 相机权限,官方名称不能改
    "reason": "$string:camera_permission_reason", // 用途说明,比如“拍照上传头像”
    "usedScene": {
      "abilities": ["MainAbility"], // 哪个功能要用
      "when": "inuse" // 只有用的时候才申请,别一直要
    }
  }
]
  1. 避坑提醒:用途说明别写“需要权限”这种废话,要写清楚“用权限干嘛”,不然审核老师会给你打回重写!

(二)动态申请:看准时机“表白”,别盲目冲锋

危险权限光报备不够,还得在用户用对应功能时“趁热打铁”申请,流程就像“表白”——先探探口风,再正式出击:

  1. 先查状态:别做“舔狗”
    PermissionManager 查一查权限有没有被授权,已经授权了就直接用,别反复申请,不然用户会烦:“都给你了还问!”
  2. 再发起申请:真诚最重要
    没授权就调用 requestPermissionsFromUser 申请,系统会弹出弹窗,把用途告诉用户,让用户心甘情愿点头。
  3. 处理结果:成了就用,不成别纠缠
    用户同意了就开开心心用功能;拒绝了就好好说:“要开启相机权限才能拍照哦,可去系统设置里打开~” 别逼用户,不然会被反感。
  4. 代码示例(核心思路,看懂不踩坑):
import { abilityAccessCtrl, common, Permissions } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

const permissions: Permissions[] = ['ohos.permission.LOCATION', 'ohos.permission.APPROXIMATELY_LOCATION'];

function reqPermissionsFromUser(permissions: Array<Permissions>, context: common.UIAbilityContext): void {
  let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
  // requestPermissionsFromUser会判断权限的授权状态来决定是否唤起弹窗
  atManager.requestPermissionsFromUser(context, permissions).then((data) => {
    let grantStatus: number[] = data.authResults;
    let length: number = grantStatus.length;
    for (let i = 0; i < length; i++) {
      if (grantStatus[i] === 0) {
        // 用户授权,可以继续访问目标操作
        console.info(`${permissions[i]} is granted by user.`);
      } else {
        // 用户拒绝授权,提示用户必须授权才能访问当前页面的功能,并引导用户到系统设置中打开相应的权限
        return;
      }
    }
    // 授权成功
  }).catch((err: BusinessError) => {
    console.error(`Failed to request permissions from user, code: ${err.code}, message: ${err.message}`);
  })
}

@Entry
@Component
struct Index {
  aboutToAppear() {
    const context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
    reqPermissionsFromUser(permissions, context);
  }

  build() {
    // ...
  }
}

官方申请权限开发步骤

(三)用户拒绝授权了怎么办?别慌,有备选方案

这里有个关键知识点:当应用通过requestPermissionsFromUser()拉起弹窗请求用户授权时,如果用户明确拒绝,后续应用就无法再通过这个方法拉起同款弹窗了,只能引导用户去系统设置里手动授权。

在“设置”应用中的路径如下:

路径一:设置 > 隐私与安全 > 权限类型(如位置信息) > 具体应用 路径二:设置 > 应用和元服务 > 某个应用

当然,你也可以更贴心一点,通过调用 requestPermissionOnSetting(),直接拉起权限设置弹窗,帮用户省去手动找路径的麻烦,好感度直接拉满:

import { abilityAccessCtrl, Context, common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

// ···
   let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
   let context: Context = this.getUIContext().getHostContext() as common.UIAbilityContext;
   atManager.requestPermissionOnSetting(context, ['ohos.permission.APPROXIMATELY_LOCATION']).then((data: Array<abilityAccessCtrl.GrantStatus>) => {
     console.info(`requestPermissionOnSetting success, result: ${data}`);
   }).catch((err: BusinessError) => {
     console.error(`requestPermissionOnSetting fail, code: ${err.code}, message: ${err.message}`);
   });

授权最佳实践(直接 ctrl c/ctrl v 就能用,抄作业就完了)

下面给大家整理了一套权限管理工具类,核心逻辑已经帮你梳理清晰,无需理解太深,直接复制到项目里就能用,堪称 “懒人福音”。 工具类核心逻辑说明

  1. 状态检查阶段:先筛选,再申请,不做无用功 ・通过应用tokenId获取真实身份标识,确保权限检查的准确性; ・采用异步方式批量校验每个权限的当前状态,效率更高; ・动态构建待申请权限列表,只处理未授权权限,避免重复弹窗打扰用户。
  2. 分级申请策略:首次、二次分开处理,更懂用户心理 ・首次申请:直接弹窗请求所有未授权权限,一步到位,不折腾用户; ・二次申请:当检测到dialogShownResults存在false值时(说明用户已拒绝过一次)→ 自动转入设置页引导模式,不反复弹窗惹人烦;→ 逐个发起权限申请(因系统限制,二次申请不同权限组无法批量操作),提高授权成功率。

具体调用方式(注意:权限需要先在module.json5中声明)

checkAndRequestPermissions(['ohos.permission.MICROPHONE', 'ohos.permission.CAMERA'], "hello world")

权限管理工具类(完整可复制)

import abilityAccessCtrl, { Permissions } from '@ohos.abilityAccessCtrl';
import bundleManager from '@ohos.bundle.bundleManager';
import common from '@ohos.app.ability.common';
import promptAction from '@ohos.promptAction';
import { BusinessError } from '@ohos.base';


/**
 * 单个权限授权状态检查
 * @param permission 待检查的单个权限
 * @returns 权限授权状态
 */
async function checkPermissionGrant(permission: Permissions): Promise<abilityAccessCtrl.GrantStatus> {
  let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
  let grantStatus: abilityAccessCtrl.GrantStatus = abilityAccessCtrl.GrantStatus.PERMISSION_DENIED;

  // 获取应用程序的accessTokenID
  let tokenId: number = 0;
  try {
    let bundleInfo: bundleManager.BundleInfo =
      await bundleManager.getBundleInfoForSelf(bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION);
    let appInfo: bundleManager.ApplicationInfo = bundleInfo.appInfo;
    tokenId = appInfo.accessTokenId;
  } catch (error) {
    const err: BusinessError = error as BusinessError;
    console.error(`获取应用Bundle信息失败,错误码:${err.code},错误信息:${err.message}`);
    return grantStatus;
  }

  // 校验应用是否被授予该权限
  try {
    grantStatus = await atManager.checkAccessToken(tokenId, permission);
  } catch (error) {
    const err: BusinessError = error as BusinessError;
    console.error(`检查权限授权状态失败,错误码:${err.code},错误信息:${err.message}`);
  }

  return grantStatus;
}

/**
 * 权限申请辅助方法(处理首次拒绝与二次引导设置)
 * @param permissions 待申请的权限数组
 * @param refuseStr 权限申请被拒绝后的提示语
 * @returns 最终是否授权成功
 */
async function requestPermissionHelper(permissions: Array<Permissions>, refuseStr: string): Promise<boolean> {
  // 判断首次申请是否全部授权成功
  let userGrant = true;
  try {
    let context = getContext() as common.UIAbilityContext;
    let isFirstTime: boolean = true;
    let atManager = abilityAccessCtrl.createAtManager();
    let grantStatus = await atManager.requestPermissionsFromUser(context, permissions);
    for (let element of grantStatus.authResults) {
      if (element !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
        userGrant = false;
        break;
      }
    }

    // 判断是否为非首次申请(弹窗未显示说明已被用户拒绝过一次)
    if (grantStatus.dialogShownResults) {
      for (let element of grantStatus.dialogShownResults) {
        if (!element) {
          isFirstTime = false;
          break;
        }
      }
    }

    // 非首次申请且授权失败:引导用户去设置页面开启
    if (!isFirstTime && !userGrant) {
      // 不同权限组不能同时申请,逐个申请并校验
      for (let permission of permissions) {
        const data: Array<abilityAccessCtrl.GrantStatus> =
          await atManager.requestPermissionOnSetting(context, [permission]);
        userGrant = true;
        for (let element of data) {
          if (element !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
            userGrant = false;
          }
        }
        if (!userGrant) {
          promptAction.showToast({
            message: refuseStr,
            duration: 5000
          });
        }
      }
    } else if (isFirstTime && !userGrant) {
      // 首次申请且授权失败:仅提示用户
      promptAction.showToast({
        message: refuseStr,
        duration: 5000
      });
    }
  }catch ( err){
    console.error(`Request permissions failed, code: ${(err as BusinessError).code}, message: ${(err as BusinessError).message}`);
    userGrant =  false;
  }

  return userGrant;
}

/**
 * 核心封装函数:先批量检查所有权限,仅对未授权权限发起申请
 * @param permissions 待校验/申请的权限数组
 * @param refuseStr 权限申请被拒绝后的提示语
 * @returns 所有权限是否最终授权成功(true:全部授权;false:存在未授权权限)
 */
export async function checkAndRequestPermissions(permissions: Array<Permissions>, refuseStr: string): Promise<boolean> {
  // 边界处理:空权限数组直接返回授权成功
  if (!permissions || permissions.length === 0) {
    console.warn("待申请权限数组为空,无需处理");
    return true;
  }

  // 第一步:批量检查所有权限的授权状态,筛选出未授权的权限
  const unGrantedPermissions: Array<Permissions> = [];
  for (const permission of permissions) {
    const grantStatus = await checkPermissionGrant(permission);
    if (grantStatus !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
      unGrantedPermissions.push(permission);
      console.log(`权限【${permission}】未授权,加入申请队列`);
    } else {
      console.log(`权限【${permission}】已授权,无需重复申请`);
    }
  }

  // 第二步:如果所有权限都已授权,直接返回true
  if (unGrantedPermissions.length === 0) {
    console.log("所有权限均已授权,无需发起申请");
    return true;
  }

  // 第三步:仅对未授权权限发起申请,返回最终申请结果
  console.log(`开始发起未授权权限申请,待申请权限:${unGrantedPermissions.join(', ')}`);
  const finalGrantResult = await requestPermissionHelper(unGrantedPermissions, refuseStr);

  return finalGrantResult;
}

一、核心调用流程

  1. 入口方法:checkAndRequestPermissions(permissions, refuseStr)

image.png 2. 权限申请辅助方法:requestPermissionHelper(unGrantedPermissions, refuseStr)

image.png

结语

其实鸿蒙权限管理并没有想象中复杂,核心就是 “懂分寸、不贪心、够坦诚”。遵循本文的原则、分类标准和申请方法,再直接抄用现成的权限管理工具类,你的应用既能顺利实现功能,又能让用户觉得 “这 APP 真懂事”,好感度拉满! 如果想了解更详细的权限列表、API 用法,可直接查阅华为开发者联盟官方文档,合规开发才是在鸿蒙生态里走得更远的关键哦!

langchainjs&langgraphjs入门(二)格式化输出

格式化输出

zod

zod是一个ts的类型校验库,langchain官方推荐使用zod来定义ai输出的schema,例如:

import {z} from 'zod'
// 期望ai返回对象,其中包含name,age,skills
const schema = z.object({
    name:z.string().describe('姓名'),
    age: z.number().init().describe('年龄'),
    skills: z.array(z.string()).describe('技能')
})

安装:

npm i zod

速查表:

API 用途 示例
z.string() 字符串 z.string()
z.number() 数字 z.number().int()
z.boolean() 布尔值 z.boolean()
z.object() 对象 z.object({ name: z.string() })
z.array() 数组 z.array(z.string())
z.enum() 枚举 z.enum(["A", "B"])
z.union() 联合类型 z.string().or(z.number())
z.optional() 可选字段 z.string().optional()
z.nullable() 允许 null z.string().nullable()
z.default() 默认值 z.number().default(0)
z.literal() 字面量 z.literal("on")
z.record() 键值映射 z.record(z.string())
z.tuple() 元组 z.tuple([z.string(), z.number()])
.refine() 自定义校验 .refine(s => s.length > 3)
.transform() 转换输出 .transform(s => s.toUpperCase())
.describe() 描述 z.string().describe('姓名')

withStructuredOutput

上一章节的例子可以看到模型的输出只是普通的字符串,并没有格式化.无法直接使用.要想让模型输出格式化的内容可以使用官方推荐的zod,使用他来定义数据结构并验证数据结构是否正确,从而帮助langchain实现输出的格式化和验证.

  1. 首先使用zod定义类型
  2. 然后通过langchain提供的.withStructuredOutput接口使用类型,调用这个方法传入zod定义的类型.模型将添加所需的所有模型参数和输出解析器

示例

import model from './1调用模型.mjs'
import { z } from 'zod'

const schema = z.object({ isCool: z.boolean() }) // 定义输出类型
const structuredLlm = model.withStructuredOutput(schema)

const res = await structuredLlm.invoke('我帅不帅')
console.log("res:",res); // res: { isCool: true }输出了结构化的内容,可以看到模型也知道我很帅

实际上withStructuredOutput在背后会根据schema自动生成严格的提示词,并自动解析验证模型输出,然后将结果返回给开发者

withStructuredOutputlangchain封装后的便捷api,如果想深入理解背后做了什么可以查看这里,后面我们也会详细讲解

StringOutputParser

StringOutoutParser可以从LLM回复的消息中直接提取文本内容,使得我们获取的不再是AIMessage对象而是纯文本

用法

实例化的方式进行创建

import { StringOutputParser } from "@langchain/core/output_parsers";

// 创建实例(无需传递任何参数)
const outputParser = new StringOutputParser();
// 使用
const res = await llm.invoke('你好')
const str = outputParser.invoke(res)

// 简便使用
const chain = llm.pipe(outputParser)
const res = chain.invoke('你好')

对于格式化输出,为便于记忆暂时先了解这么多.知道langchain提供了这么个功能,当上述能力不满足实际开发场景时,再去翻阅官方文档即可.

关注我,该专栏会持续更新!

Angular + html2canvas 实现【页面指定区域截图】(推荐)

核心原理

html2canvas 是一个前端开源库,它的核心逻辑不是「真的截图」,而是:遍历你指定的 DOM 节点,解析这个节点下的所有 HTML、CSS、图片等内容,在浏览器中用 Canvas 重新绘制一份一模一样的内容,最终导出为图片,视觉上和截图完全一致,完美满足页面内截图需求。

使用步骤

步骤 1:安装依赖包

通过 npm 安装即可,无需额外配置 webpack:

# 安装核心依赖 html2canvas
npm install html2canvas --save
# 安装其类型声明(Angular是TS项目,必须装,否则报错找不到模块)
npm install @types/html2canvas --save-dev

步骤 2:完整可运行的组件代码(复制即用)

创建一个截图组件,比如 screenshot.component.ts + screenshot.component.html,包含「指定区域截图」「整个页面截图」「下载截图」3 个核心功能 :

组件 TS 代码 (screenshot.component.ts)

import { Component, ElementRef, ViewChild } from '@angular/core';
import html2canvas from 'html2canvas';

@Component({
  selector: 'app-screenshot',
  templateUrl: './screenshot.component.html',
  styleUrls: ['./screenshot.component.scss']
})
export class ScreenshotComponent {
  // 1. 通过ViewChild获取【需要截图的指定DOM区域】的DOM对象
  @ViewChild('screenshotDom') screenshotDom!: ElementRef<HTMLDivElement>;
  // 存储生成的截图base64地址,用于页面预览
  screenshotImgUrl: string = '';

  /**
   * 功能1:截取 指定DOM区域 的内容(最常用)
   */
  async captureDomArea() {
    try {
      // 获取目标DOM元素
      const targetDom = this.screenshotDom.nativeElement;
      // 核心:调用html2canvas绘制DOM,返回canvas对象
      const canvas = await html2canvas(targetDom, {
        scale: window.devicePixelRatio * 2, // 放大2倍,解决截图模糊问题
        useCORS: true, // 允许跨域图片(比如页面中有外链图片也能正常绘制)
        logging: false, // 关闭控制台日志,生产环境建议开启
        backgroundColor: '#ffffff' // 截图背景色,默认透明
      });
      // 将canvas转为base64格式的图片地址,支持png/jpeg/webp
      this.screenshotImgUrl = canvas.toDataURL('image/png');
      alert('指定区域截图成功!');
    } catch (error) {
      console.error('指定区域截图失败:', error);
      alert('截图失败,请重试!');
    }
  }

  /**
   * 功能2:截取 整个页面 的内容
   */
  async captureWholePage() {
    try {
      const canvas = await html2canvas(document.body, {
        scale: window.devicePixelRatio * 2,
        useCORS: true,
        logging: false
      });
      this.screenshotImgUrl = canvas.toDataURL('image/png');
      alert('整个页面截图成功!');
    } catch (error) {
      console.error('整页截图失败:', error);
      alert('截图失败,请重试!');
    }
  }

  /**
   * 功能3:下载截图(传入图片地址,自动生成下载文件)
   */
  downloadScreenshot(imgUrl: string, fileName: string = '页面截图') {
    if (!imgUrl) {
      alert('暂无截图可下载!');
      return;
    }
    // 创建a标签,通过href下载,下载完成后移除a标签
    const link = document.createElement('a');
    link.href = imgUrl;
    link.download = `${fileName}_${new Date().getTime()}.png`; // 文件名+时间戳,避免重复
    link.click();
    link.remove();
  }
}

组件 HTML 代码 (screenshot.component.html)

<!-- 👇 这是【需要截图的指定DOM区域】,用#screenshotDom标记,可自定义内容 -->
<div #screenshotDom style="width: 800px; height: 400px; border: 1px solid #ccc; padding: 20px; margin: 20px 0;">
  <h2>这是要截图的指定区域</h2>
  <p>Angular + html2canvas 截图测试内容</p>
  <img src="https://angular.io/assets/images/logos/angular/angular.svg" alt="angular图标" width="100">
  <div>任意HTML内容都可以被截图:表格、表单、图片、文字等</div>
</div>

<!-- 操作按钮组 -->
<button (click)="captureDomArea()" style="margin-right: 10px;">截取指定区域</button>
<button (click)="captureWholePage()" style="margin-right: 10px;">截取整个页面</button>
<button (click)="downloadScreenshot(screenshotImgUrl)" [disabled]="!screenshotImgUrl">下载截图</button>

<!-- 截图预览区域 -->
<div *ngIf="screenshotImgUrl" style="margin-top: 20px;">
  <h3>截图预览:</h3>
  <img [src]="screenshotImgUrl" style="width: 100%; max-width: 800px; border: 1px solid #eee;">
</div>

✨ 关键配置点(必看,解决常见问题)

  1. 截图模糊问题:配置 scale: window.devicePixelRatio * 2,因为高清屏(Retina)的像素比大于 1,放大绘制倍数后,截图清晰度拉满。
  2. 跨域图片不显示:配置 useCORS: true,解决页面中引用外链图片时截图空白的问题。
  3. 背景透明问题:配置 backgroundColor: '#ffffff',默认截图背景是透明的,手动指定白色更符合业务需求

学了TypeScript却用不起来?用JSDoc在JavaScript中立即学以致用

告别"学完就忘",今天学今天用,真正掌握类型思维

一个真实的故事

上周和一位工作4年的前端朋友吃饭,他一脸沮丧地说:

"去年花了两周学TypeScript,每个概念都懂了。但公司项目全是JavaScript,现在只记得interfacetype这两个词,写代码时完全用不上那些类型思维。"

这不是他一个人的困境。我见过太多JavaScript开发者陷入这个循环:

学习TypeScript → 没有项目可以实践 → 知识逐渐遗忘 → 需要时重新学习

更扎心的是,即使你跳槽到了用TypeScript的公司,面对真实的复杂业务逻辑,你还是不知道怎么把类型系统用好——因为你没有在真实项目中培养过类型思维

根本问题:学习与实践脱节

传统的TypeScript学习路径有个致命缺陷:它需要你有一个TypeScript项目才能实践

但现实是:

  1. 你维护的老项目是JavaScript,重构成TypeScript风险太大
  2. 新项目不敢轻易用TypeScript,怕团队不适应
  3. 练习项目太简单,学不到真实业务中的类型设计

所以,你学到的泛型联合类型类型守卫这些概念,都成了"屠龙技"——理论完美,无处施展。

突破口:在JavaScript中实践TypeScript思维

如果我告诉你,不需要改变任何项目配置,不需要说服团队,今天就可以在你现有的JavaScript项目中实践TypeScript的核心思想,你相信吗?

秘密武器就是:JSDoc注释

这不是"写注释",这是"类型编程"

看一个简单的例子:

// 之前:模糊的函数,调用时得猜参数
function calculateDiscount(price, discount) {
  return price * (1 - discount);
}

// 之后:明确的契约,IDE提供完整智能提示
/**
 * 计算商品折扣后价格
 * @param {number} price - 原价(必须大于0)
 * @param {number} discount - 折扣率(0-1之间的小数)
 * @returns {number} 折扣后价格
 * @throws {Error} 当价格小于等于0或折扣率不在0-1之间时抛出错误
 */
function calculateDiscount(price, discount) {
  if (price <= 0) throw new Error('价格必须大于0');
  if (discount < 0 || discount > 1) throw new Error('折扣率必须在0-1之间');
  return price * (1 - discount);
}

你获得了什么?

  1. 智能提示:调用时看到参数名、类型、描述
  2. 错误预防:传入错误类型时IDE立即提醒
  3. 代码即文档:不需要另外写API文档
  4. 重构安全网:修改函数时,类型不匹配会报警

你付出了什么?

  • 写注释的30秒时间
  • 零配置、零构建更改、零团队沟通成本

为什么这是最佳学习路径?

1. 即时反馈的学习闭环

传统的学习:

看书/视频 → 做练习题 → 等待真实项目 → 半年后忘了

用JSDoc的学习:

学习一个类型概念 → 在现有JavaScript项目中使用 → 立即获得IDE反馈 → 巩固理解

今天学了泛型,今天就在工具函数中用@template实践,这才是有效的学习。

2. 培养类型思维,而非记忆语法

TypeScript的核心价值不是记住typeinterface的区别,而是培养类型思维

  • 契约思维:函数/模块的输入输出要明确
  • 边界思维:数据在不同模块间流动时的类型变化
  • 安全思维:如何在编码阶段预防undefined is not a function

JSDoc让你在真实业务代码中培养这些思维,而不是在练习题里。

3. 为未来打下真正基础

当你通过JSDoc熟悉了类型系统后:

  • 接手TypeScript项目时,你能快速理解设计思路
  • 设计新系统时,你能自然想到类型约束
  • 面试被问类型系统时,你有真实项目经验可讲

这才是"学以致用"的真正含义。

立即开始:3个层次实践指南

层次1:基础函数类型(今天就能用)

从你正在写的下一个工具函数开始:

// 你的旧函数
function formatName(firstName, lastName, middleName) {
  if (middleName) {
    return `${lastName} ${firstName} ${middleName}`;
  }
  return `${lastName} ${firstName}`;
}

// 加上JSDoc后
/**
 * 格式化中文姓名
 * @param {string} firstName - 名
 * @param {string} lastName - 姓
 * @param {string} [middleName] - 中间名(可选)
 * @returns {string} 格式化后的姓名
 */
function formatName(firstName, lastName, middleName) {
  if (middleName) {
    return `${lastName} ${firstName} ${middleName}`;
  }
  return `${lastName} ${firstName}`;
}

// 调用时获得智能提示
// 输入 formatName( 会提示:
// firstName: string, lastName: string, middleName?: string
const name = formatName('三', '张', '小'); // ✅ 正确
const error = formatName('三', 123); // ❌ IDE会提示类型错误

层次2:复杂数据类型(一周内掌握)

当你需要处理复杂对象时,使用@typedef

/**
 * 用户信息类型
 * @typedef {Object} User
 * @property {string} id - 用户ID
 * @property {string} name - 用户名
 * @property {number} [age] - 年龄(可选)
 * @property {'active'|'inactive'|'suspended'} status - 用户状态
 */

/**
 * 用户列表响应类型
 * @typedef {Object} UserListResponse
 * @property {User[]} data - 用户列表
 * @property {number} total - 总数
 * @property {number} page - 当前页码
 */

/**
 * 获取用户列表
 * @param {Object} params - 查询参数
 * @param {number} params.page - 页码
 * @param {number} params.pageSize - 每页数量
 * @param {string} [params.keyword] - 搜索关键词
 * @returns {Promise<UserListResponse>}
 */
async function fetchUsers(params) {
  // 这里 params 有完整的类型提示
  const { page, pageSize, keyword } = params;
  
  // 模拟API调用
  const response = await fetch('/api/users', {
    method: 'POST',
    body: JSON.stringify(params)
  });
  
  /** @type {UserListResponse} */
  const result = await response.json();
  return result;
}

// 使用时获得完整类型支持
const response = await fetchUsers({ page: 1, pageSize: 20 });
// response.data 是 User[] 类型,有完整智能提示
const firstUser = response.data[0];
console.log(firstUser.id);   // ✅ 正确
console.log(firstUser.email); // ❌ IDE提示:User类型没有email属性

层次3:泛型实践(一个月内精通)

当你需要编写灵活的工具函数时,尝试泛型:

/**
 * 从数组中查找符合条件的元素
 * @template T
 * @param {T[]} array - 要搜索的数组
 * @param {(item: T) => boolean} predicate - 判断函数
 * @returns {T | undefined} 找到的元素或undefined
 */
function findItem(array, predicate) {
  return array.find(predicate);
}

// 使用示例
const users = [
  { id: '1', name: '张三', age: 25 },
  { id: '2', name: '李四', age: 30 }
];

const result = findItem(users, (user) => user.age > 28);
// result 被推断为 { id: string, name: string, age: number } | undefined

// 泛型也能用在类中
/**
 * 简单的数据存储类
 * @template T
 */
class SimpleStore {
  /**
   * @param {string} key - 存储键名
   * @param {T} initialValue - 初始值
   */
  constructor(key, initialValue) {
    this.key = key;
    /** @private @type {T} */
    this._value = initialValue;
  }
  
  /**
   * 获取当前值
   * @returns {T}
   */
  get() {
    return this._value;
  }
  
  /**
   * 设置新值
   * @param {T} newValue
   */
  set(newValue) {
    this._value = newValue;
  }
}

// 使用时获得类型安全
/** @type {SimpleStore<User>} */
const userStore = new SimpleStore('currentUser', { id: '1', name: '张三' });

const user = userStore.get(); // User类型
userStore.set({ id: '2', name: '李四' }); // ✅ 正确
userStore.set({ id: '3' }); // ❌ 缺少name属性,IDE会提示错误

配置你的开发环境

要让JSDoc发挥最大作用,只需要一个简单的配置文件:

// 在项目根目录创建 jsconfig.json
{
  "compilerOptions": {
    "checkJs": true,     // 启用对.js文件的类型检查
    "module": "ESNext",   // 根据你的项目选择
    "target": "ES2020",   // 根据你的项目选择
    "lib": ["ES2020", "DOM"] // 添加需要的库类型
  },
  "include": ["src/**/*"], // 检查哪些文件
  "exclude": ["node_modules", "dist"] // 排除哪些文件
}

保存这个文件后,VSCode或WebStorm会自动开始对你的JavaScript文件进行类型检查。

常见疑问解答

Q:这和我直接用TypeScript有什么区别? A:最大的区别是零迁移成本。你可以继续使用现有构建工具、保持现有文件结构、不需要团队统一意见。这是纯粹的"个人效率提升工具"。

Q:学习JSDoc会影响我学TypeScript吗? A:正好相反。JSDoc的类型系统是TypeScript的子集,你学的每个概念(泛型、联合类型、接口等)都能直接对应到TypeScript。这是在JavaScript环境中学习TypeScript思想。

Q:我需要记住很多JSDoc标签吗? A:不需要。从最常用的三个开始:@param@returns@typedef。80%的场景这三个就够了。其他标签用到时查一下就行。

Q:团队其他人不用JSDoc,会影响我吗? A:完全不影响。JSDoc是注释,其他人不写,你的代码依然能运行。只是调用他们的函数时没有类型提示而已。

今日行动:开始你的第一个JSDoc实践

任务:找到你今天或昨天写的3个JavaScript函数,为它们添加JSDoc注释。

步骤

  1. 打开你的项目
  2. 找一个工具函数或工具类
  3. 花1分钟添加@param@returns
  4. 保存文件,看看IDE的智能提示变化

示例起点

// 找一个像这样的函数开始
function processOrder(order, options) {
  // 你的代码...
}

// 加上:
/**
 * 处理订单
 * @param {Object} order - 订单对象
 * @param {string} order.id - 订单ID
 * @param {number} order.amount - 订单金额
 * @param {Object} [options] - 处理选项
 * @returns {Promise<boolean>} 处理是否成功
 */

下篇预告

在下一篇文章中,我将从技术Leader的视角分享:

  • 如何在不引发团队抵触的情况下推广类型思维
  • JSDoc如何作为团队代码质量的"隐形护栏"
  • 为什么"培养类型思维"比"强制迁移TypeScript"更有长期价值

今日挑战:尝试为你最复杂的一个业务函数添加完整的JSDoc注释,感受类型思维如何帮你理清函数边界。

你有过"学了TypeScript却无处用"的经历吗?或者已经尝试过JSDoc注释?欢迎在评论区分享你的故事!

如果觉得这篇文章有帮助,请点赞收藏,让更多需要的人看到。

Lodash 源码解读与原理分析 - Lodash 原型链的完整结构

Lodash 的原型链体系以多构造函数分层设计为核心,通过原型继承串联起不同功能的包装器,形成清晰的层级依赖关系。这一设计既保证了方法的复用性,又实现了不同包装器的功能差异化,是链式调用和惰性求值的基础。

核心构造函数与原型对象的关系

Lodash 围绕“包装器”核心设计了多个构造函数,每个构造函数对应专属原型对象,通过原型继承实现方法复用与功能扩展。各构造函数与原型对象的关联的关系、职责划分如下表所示,涵盖了从基础包装到惰性求值、模板处理的全场景需求。

构造函数 原型对象 继承自 主要职责 原型上的核心方法示例 设计初衷
baseLodash baseLodash.prototype Object.prototype 基础构造函数,所有包装器的原型链起点 value()、chain()、toString() 抽离通用方法,实现复用
lodash baseLodash.prototype Object.prototype 主函数(即 _ 函数),实现链式调用的入口 无(复用 baseLodash.prototype 方法) 统一入口,简化调用
LodashWrapper LodashWrapper.prototype baseLodash.prototype 具体的包装器实现,处理普通链式调用 map ()、filter ()、take ()、value ()(重写) 封装非惰性操作队列
LazyWrapper LazyWrapper.prototype baseLodash.prototype 惰性求值包装器,优化大数据集操作性能 map ()、filter ()、take ()、value ()(重写) 延迟执行,减少遍历次数
Template Template.prototype Function.prototype 模板函数构造器,处理模板字符串编译 render()、source() 封装模板解析逻辑

详细的原型链结构:不同包装器实例的原型链遵循“子类实例→子类原型→父类原型→Object.prototype→null”的规则,确保方法查找和属性继承的正确性。以下是三种核心实例的完整原型链拆解:

// 普通 LodashWrapper 实例的原型链
LodashWrapper 实例
  ↑ __proto__
LodashWrapper.prototype
  ↑ __proto__
baseLodash.prototype
  ↑ __proto__
Object.prototype
  ↑ __proto__
null

// LazyWrapper 实例的原型链
LazyWrapper 实例
  ↑ __proto__
LazyWrapper.prototype
  ↑ __proto__
baseLodash.prototype
  ↑ __proto__
Object.prototype
  ↑ __proto__
null

// Template 实例的原型链
Template 实例 (函数)
  ↑ __proto__
Template.prototype
  ↑ __proto__
Function.prototype
  ↑ __proto__
Object.prototype
  ↑ __proto__
null

关键实现细节:Lodash 采用 baseCreate 函数实现原型继承(兼容 ES5 之前环境,等效于 Object.create),确保原型链的正确挂载,同时重置构造函数指向,避免原型继承导致的构造函数混乱。核心源码如下:

关键实现细节(补充逐行注释 + 执行示例)

原文代码保留,补充逐行注释属性说明,让新手也能理解每一行的作用:

// 1. 实现 baseCreate 函数,用于基于原型创建新对象(兼容 IE8 及以下低版本浏览器(这类环境不支持 `Object.create`),同时通过空构造函数 `Ctor` 避免原型对象上的构造逻辑被意外执行,提升继承安全性。)
// 核心作用:替代原生 Object.create(),实现“原型继承”的底层能力
var baseCreate = (function() {
  // 定义空构造函数,用于临时挂载原型
  function object() {}
  return function(proto) {
    // 边界处理:如果传入的原型不是对象,返回空对象
    if (!isObject(proto)) {
      return {};
    }
    // 优先使用 ES5 原生 Object.create()(性能更好)
    if (objectCreate) {
      return objectCreate(proto);
    }
    // 兼容低版本环境:通过构造函数模拟 Object.create()
    object.prototype = proto; // 将空构造函数的原型指向目标原型
    var result = new object;  // 创建实例,实例的 __proto__ 指向 proto
    object.prototype = undefined; // 重置原型,避免污染
    return result;
  };
}());

// 2. 创建 baseLodash 构造函数和原型(所有包装器的根原型)
function baseLodash() {
  // No operation performed. 空函数:仅作为原型挂载的载体,无需执行逻辑
}

// 3. 确保 lodash 主函数(即 _ 函数)的原型指向 baseLodash.prototype
// 作用:让 _ 函数的实例能继承 baseLodash.prototype 上的通用方法(如 value())
lodash.prototype = baseLodash.prototype;
lodash.prototype.constructor = lodash; // 修正构造函数指向,避免原型链混乱

// 4. 创建 LodashWrapper.prototype,继承自 baseLodash.prototype
// 构造函数作用:封装普通链式调用的状态和操作队列
function LodashWrapper(value, chainAll) {
  this.__wrapped__ = value;    // 核心:存储被包装的原始值(如数组、对象)
  this.__actions__ = [];       // 存储待执行的操作队列(如 map、filter)
  this.__chain__ = !!chainAll; // 标记是否开启链式调用(true 则返回实例,false 则返回结果)
  this.__index__ = 0;          // 辅助:遍历操作队列时的索引
  this.__values__ = undefined; // 辅助:缓存操作执行后的结果
}
// 核心:让 LodashWrapper.prototype 继承 baseLodash.prototype
LodashWrapper.prototype = baseCreate(baseLodash.prototype);
LodashWrapper.prototype.constructor = LodashWrapper; // 修正构造函数指向

// 5. 创建 LazyWrapper.prototype,继承自 baseLodash.prototype
// 构造函数作用:封装惰性求值的状态和操作队列,优化大数据集性能
function LazyWrapper(value) {
  this.__wrapped__ = value;       // 存储被包装的原始值(通常是数组)
  this.__actions__ = [];          // 存储待执行的惰性操作队列
  this.__dir__ = 1;               // 遍历方向:1 正向(从0开始),-1 反向(从末尾开始)
  this.__filtered__ = false;      // 标记是否执行过 filter 操作,优化遍历逻辑
  this.__iteratees__ = [];        // 存储迭代器函数(如 map 的回调、filter 的断言)
  this.__takeCount__ = MAX_ARRAY_LENGTH; // take() 方法的限制数量,默认最大数组长度
  this.__views__ = [];            // 辅助:存储数组视图,避免重复创建
}
// 核心:让 LazyWrapper.prototype 继承 baseLodash.prototype
LazyWrapper.prototype = baseCreate(baseLodash.prototype);
LazyWrapper.prototype.constructor = LazyWrapper; // 修正构造函数指向

baseCreate 执行示例

// 调用 baseCreate 创建继承自 baseLodash.prototype 的对象
var testProto = baseCreate(baseLodash.prototype);
console.log(Object.getPrototypeOf(testProto) === baseLodash.prototype); // true
console.log(testProto.constructor === baseLodash); // true(未修正前)

原型方法的挂载与继承

Lodash 采用“分层挂载、按需重写”的策略管理原型方法:基础方法挂载在顶层原型,子类原型按需重写或扩展方法,既保证代码复用,又实现功能差异化。这种设计让不同包装器既能共享通用逻辑,又能拥有专属核心能力。

原型方法的挂载流程

  • 基础方法挂载:Lodash 初始化时,先在 baseLodash.prototype 上挂载最基础的方法(如 value()chain()toString()),这些方法是所有包装器的通用能力,无需重复实现。

  • 扩展方法挂载:在 LodashWrapper.prototype 上挂载具体的操作方法(如 map()filter()take()),这些方法针对普通链式调用设计,执行时会将操作加入队列。

  • 重写方法:在 LazyWrapper.prototype 上重写部分方法(如 map()filter()value()),实现惰性求值逻辑 —— 不立即执行操作,仅存储操作队列,直到调用 value() 才批量执行。

  • 特殊方法挂载Template.prototype 单独挂载模板相关方法(如 render()),因继承自 Function.prototype,可直接作为函数执行,兼具模板渲染能力。

value() 示例: 方法的继承与重写value()是 Lodash 包装器的核心方法,用于触发操作执行并返回结果,其实现随原型链层级逐步重写,适配不同包装器的功能需求,是“多态设计”的典型体现。

// baseLodash.prototype.value - 基础实现:仅返回包装的原始值
baseLodash.prototype.value = function() {
  return this.__wrapped__;
};

// LodashWrapper.prototype.value - 重写实现,执行操作队列
// 逻辑:遍历操作队列,依次执行每个操作,返回最终结果
LodashWrapper.prototype.value = function() {
  var value = this.__wrapped__;
  for (var i = 0, length = this.__actions__.length; i < length; i++) {
    var action = this.__actions__[i];
    // 执行操作:将原始值作为第一个参数,拼接 action.args 作为后续参数
    value = action.func.apply(action.thisArg, [value].concat(action.args));
  }
  return value;
};

// LazyWrapper.prototype.value - 再次重写,实现惰性求值
// 逻辑:单次遍历原始数组,批量执行所有操作,支持短路终止(take() 触发)
LazyWrapper.prototype.value = function() {
  var array = this.__wrapped__,
      length = array.length,
      actions = this.__actions__,
      iteratees = this.__iteratees__,
      dir = this.__dir__,
      index = dir > 0 ? -1 : length, // 根据遍历方向初始化索引
      result = [];

  // 按方向遍历数组(正向/反向)
  while ((dir > 0 ? ++index < length : --index >= 0)) {
    var value = array[index],
        // 核心:对当前元素执行所有操作(map/filter 等)
        computed = this.__compute(value, index, array, actions, iteratees);
    
    // filter 筛选通过(computed 不为 undefined)则加入结果
    if (computed !== undefined) {
      result.push(computed);
      // 短路优化:达到 takeCount 则终止遍历
      if (this.__takeCount__ != null && result.length >= this.__takeCount__) {
        break;
      }
    }
  }

  return result;
};

value () 方法执行示例

// 1. baseLodash.prototype.value 执行示例
var baseInstance = new baseLodash();
baseInstance.__wrapped__ = [1,2,3];
console.log(baseInstance.value()); // 输出:[1,2,3]

// 2. LodashWrapper.prototype.value 执行示例
var normalWrapper = new LodashWrapper([1,2,3], true);
normalWrapper.__actions__.push({
  func: _.map,
  args: [n => n*2],
  thisArg: undefined
});
console.log(normalWrapper.value()); // 输出:[2,4,6]

// 3. LazyWrapper.prototype.value 执行示例
var lazyWrapper = new LazyWrapper([1,2,3,4]);
lazyWrapper.__actions__.push({ func: arrayMap, args: [n => n*2] });
lazyWrapper.__actions__.push({ func: arrayFilter, args: [n => n>4] });
lazyWrapper.__takeCount__ = 2;
console.log(lazyWrapper.value()); // 输出:[6,8]

方法查找与执行机制

当调用包装器实例的方法时(如 wrapper.map(iteratee)),JavaScript 会遵循“原型链查找规则”定位方法,找到后根据包装器类型执行对应逻辑。这一机制确保了不同包装器能复用同名方法,同时执行差异化逻辑。

wrapper.map(iteratee) 当调用 时的详细执行流程

第一步:方法查找(原型链向上遍历)

  • 首先在 wrapper 实例自身查找 map 方法(不存在)。

  • 然后沿着 __proto__ 链向上查找:

    • 如果 wrapperLazyWrapper 实例,在 LazyWrapper.prototype 中查找 map 方法。
    • 如果 LazyWrapper.prototype 中没有,继续在 baseLodash.prototype 中查找。
    • 如果 wrapperLodashWrapper 实例,在 LodashWrapper.prototype 中查找 map 方法。
    • 如果 LodashWrapper.prototype 中没有,继续在 baseLodash.prototype 中查找。
    • 如果 baseLodash.prototype 中没有,继续在 Object.prototype 中查找(通常不会到这一步)。

第二步:方法执行(根据包装器类型差异化执行)

找到 map 方法后,根据包装器类型执行对应逻辑,核心差异在于是否开启惰性求值:

执行惰性版本 map 方法,核心逻辑是“创建新包装器、添加操作到队列、返回新实例”,不立即执行遍历操作。具体实现:

对于 LazyWrapper 实例

LazyWrapper.prototype.map = function(iteratee) {
  // 创建新的 LazyWrapper 实例(immutable 设计,不修改原实例)
  var wrapper = new LazyWrapper(this.__wrapped__);
  // 复制原实例的所有属性,保证状态一致
  wrapper.__actions__ = this.__actions__.concat({
    'func': arrayMap,  // 底层执行函数(数组原生 map 优化版)
    'args': [iteratee], // 传入用户定义的迭代器
    'thisArg': undefined // 不绑定 this,使用默认上下文
  });
  // 复制其他惰性相关属性
  wrapper.__dir__ = this.__dir__;
  wrapper.__filtered__ = this.__filtered__;
  wrapper.__iteratees__ = this.__iteratees__;
  wrapper.__takeCount__ = this.__takeCount__;
  // 返回新实例,支持链式调用(惰性模式默认开启链式)
  return wrapper;
};

对于 LodashWrapper 实例

执行非惰性版本 map 方法,核心逻辑是“添加操作到队列、根据链式标记返回结果”,非链式模式下立即执行操作。具体实现:

LodashWrapper.prototype.map = function(iteratee) {
  // 创建操作对象,封装执行函数、参数和上下文
  var action = {
    'func': _.map,         // 复用静态方法 _.map 的核心逻辑
    'args': [iteratee],    // 迭代器参数
    'thisArg': undefined   // 上下文绑定
  };
  // 将操作加入队列,延迟执行
  this.__actions__.push(action);
  // 链式模式返回 this(继续链式调用),非链式模式立即执行并返回结果
  return this.__chain__ ? this : this.value();
};

当经典扫雷遇上 Vim 语法:我是如何为开发者写一款“摸鱼”神器的

一、 为什么又是扫雷?

扫雷是每个程序员的“Hello World”进阶版,但市面上大多数扫雷都高度依赖鼠标。作为一个长期沉浸在终端、习惯 hjkl 的开发者,我一直在想:能不能有一款扫雷,能让我的手指不离开键盘,甚至能玩出 Vim 的“丝滑感”?

于是,zsweep 诞生了。它不仅是一个游戏,更是一个实验场,用来验证如何将 Vim 的操作逻辑(Grammar)完美平移到 Web 交互中。

二、 技术选型:为什么是 Svelte 5?

在开发 zsweep 时,我果断选择了最新的 Svelte 5

  • Runes 的魔力:使用 $state$derived 彻底简化了复杂的棋盘状态管理。在扫雷这种需要频繁触发邻近格子更新的场景下,Svelte 5 的细粒度更新让性能表现极佳。
  • 零负载感:极简的 API 让代码量大幅减少,非常契合项目“极简主义”的初衷。

三、 核心挑战:在浏览器里复现 Vim 体验

为了让 zsweep 拥有真正的“Vim 魂”,我实现了以下特性:

  • 基础移动hjkl 映射。
  • 高效跳转:实现 w/b 跳转(自动跳过已打开的安全区,直达未标记区域)。
// src/lib/game/input/vim.ts

export function handleVimKey(key: string, buffer: string) {
    // 处理数字前缀(用于 5j, 10l 等组合键)
    if (/^[1-9]$/.test(key)) {
        return { type: 'DIGIT', value: key };
    }

    // 核心动作映射
    switch (key) {
        case 'h': return { type: 'MOVE_CURSOR', dx: -1, dy: 0 };
        case 'j': return { type: 'MOVE_CURSOR', dx: 0, dy: 1 };
        case 'k': return { type: 'MOVE_CURSOR', dx: 0, dy: -1 };
        case 'l': return { type: 'MOVE_CURSOR', dx: 1, dy: 0 };
        
        // 单词级跳转:跳过已打开区域,直达下一个未开块
        case 'w': return { type: 'WORD_FORWARD' };
        case 'b': return { type: 'WORD_BACKWARD' };

        // 搜索模式:按 / 进入,跳转至匹配数字的格子
        case '/': return { type: 'START_SEARCH' };
        
        // 游戏核心操作:空格标记/双击效果,回车揭开
        case ' ': return { type: 'SMART_ACTION' }; // 自动判断是 Flag 还是 Chord
        case 'Enter': return { type: 'REVEAL' };
        
        default: return null;
    }
}

在 Svelte 5 组件中,通过 $state 驱动的游标配合上述逻辑,可以实现极其平滑的响应:

<script lang="ts">
    let cursor = $state({ r: 0, c: 0 });
    let vimBuffer = $state("");

    function onKeyDown(e: KeyboardEvent) {
        const action = handleVimKey(e.key, vimBuffer);
        if (!action) return;

        if (action.type === 'MOVE_CURSOR') {
            const mult = parseInt(vimBuffer) || 1;
            // 计算新位置并更新 cursor
            updateCursor(action.dx * mult, action.dy * mult);
            vimBuffer = ""; // 执行完动作清空缓冲
        }
    }
</script>
  • 数字检索:支持按 / 后接数字,快速定位到棋盘上的特定提示数。
  • 组合技:支持数字前缀(如 5j 向下移动五格)。

为了保证操作的即时性,我构建了一套基于状态机的输入处理引擎,确保每一帧输入都能获得 0 延迟反馈。

四、 细节打磨:从 1k+ 活跃用户中学到的

项目上线后,迅速吸引了超过 1000 名活跃用户。在与社区(如 Hacker News, Reddit)的交流中,我不断优化细节:

  1. 数据精度陷阱:早期使用 Supabase 统计全局时间时,遇到了经典的 1000 行分页限制问题,导致统计“漂移”。后来通过 RPC 服务端聚合彻底解决了这一统计 Bug。
  2. UI 的“呼吸感” :参考了 Monkeytype 的审美,加入了极简的主题切换系统,让“摸鱼”也能摸出高级感。
  3. 键盘辅助:在底部加入了实时的按键提示,帮助非 Vim 重度用户也能快速上手。

五、 开源与未来

目前 zsweep 已在 GitHub 完全开源。

目前项目正在推进 Chording(双键消除)移动端手势支持 的特性,欢迎各位掘友来提 PR 或 Issue,一起探讨 Svelte 5 的更多可能性!

2026 年,只会写 div 和 css 的前端将彻底失业

引言:当“手写”成为一种昂贵的低效

如果把时间拨回2023年,听到“只会写 HTML 和 CSS 的前端要失业”这种话,大多数人可能只会把它当作制造焦虑的标题党,甚至会嗤之以鼻地反驳:“AI 懂什么叫像素级还原吗?”

但在 2026 年的今天,站在新年的路口,我们必须诚实地面对现状:这不再是一个预测,而是正在发生的残酷事实。

现在的开发环境是怎样的?打开 IDE,你用自然语言描述一个“带有毛玻璃效果、响应式布局、暗黑模式切换的 Dashboard 侧边栏”,AI Copilot 在 3 秒内生成的代码,不仅符合 Tailwind CSS 最佳实践,甚至连 Accessibility(无障碍访问)属性都配齐了。Figma 的设计稿一键转出的 React/Vue 代码,其质量已经超过了 3 年经验的中级工程师。

在这种生产力下,如果你所谓的工作产出仅仅是“把设计图转换成代码”,那么你的价值已经被压缩到了无限接近于零。

并不是前端死了,而是“切图(Slicing)”这个曾养活了无数人的工种,彻底完成了它的历史使命,退出了舞台。


一、 认清现实:UI 层的“去技能化”

在 2026 年,UI 构建的门槛已经发生了本质的变化。我们必须接受一个现实:基础 UI 构建已经不再是核心竞争力,而是基础设施。

  • 从 Write 到 Generate: 过去我们以“手写 CSS 选择器熟练度”为荣,现在这变成了 AI 的基本功。对于静态布局,AI 的准确率和速度是人类的百倍。
  • Design-to-Code 的闭环: 设计工具与代码仓库的壁垒已被打通。中间不再需要一个人类作为“翻译官”。
  • 组件库的极端成熟: 各类 Headless UI 配合 AI,让构建复杂交互组件变得像搭积木一样简单。

结论很残酷: 如果你的技能树依然停留在 display: flexv-if/v-else 的排列组合上,那么你面对的竞争对手不是更便宜的实习生,而是成本几乎为零的算力。


二、 幸存者偏差:2026 年,什么样的人依然不可替代?

既然 div + css 甚至基础的业务逻辑都能被自动生成,那么现在的企业到底愿意花高薪聘请什么样的前端工程师?答案在于 AI 目前无法轻易跨越的深水区

真正的护城河,建立在架构设计、底层原理与工程化之上。

1. 复杂状态管理与业务架构师

AI 擅长写片段(Snippets),擅长解决局部问题,但在处理几十万行代码的巨型应用时,它依然缺乏全局观,甚至会产生严重的“幻觉”。

  • 你需要做的: 不是纠结用 Pinia 还是 Redux,而是**领域驱动设计(DDD)**在前端的落地。如何设计一个高内聚、低耦合的 Store?如何在微前端(Micro-frontends)架构下保证子应用间的通信而不导致内存泄漏?
  • 核心价值: 你是设计“骨架”的人,AI 只是帮你填充“血肉”。

2. 性能优化的深层专家

AI 可以写出跑得通的代码,但很难写出跑得“极快”的代码。在 2026 年,用户对体验的阈值被无限拔高,卡顿零容忍。

  • 你需要做的: 深入浏览器渲染原理。
  • • 如何利用 OffscreenCanvasWeb Worker 将繁重的计算(如图像处理、大屏数据清洗)移出主线程?
  • • 深入理解 Chrome Performance 面板,解决由大量 DOM 操作引起的 Layout Thrashing(强制重排)。
  • • 精通 HTTP/3 协议与边缘缓存策略。
  • 核心价值: 当应用卡顿影响用户留存时,你是那个能切开血管(底层代码)做精密手术的人,而不是只会问 AI “怎么优化 Vue” 的人。

3. 图形学与互动技术的掌控者

随着 WebGPU 的普及和空间计算设备的迭代,Web 不再局限于 2D 平面。

  • 你需要做的: 掌握 WebGL / WebGPU。只会写 div 是不够的,你需要理解着色器(Shaders)、矩阵变换、光照模型。利用 Three.js 构建 3D 场景,甚至利用 WASM 将 C++ 图形引擎搬到浏览器。
  • 核心价值: 创造 AI 难以凭空想象的、具有沉浸感的交互体验。

4. AI 工程化(AI Engineering)

这是 2026 年最新的“前端”分支。前端不再只是面向用户,而是面向模型。

  • 你需要做的: 探索如何在浏览器端运行小模型(Small Language Models)以保护隐私?如何利用 RAG 技术在前端处理向量数据?如何设计适应流式输出(Streaming UI)的新一代交互界面?
  • 核心价值: 你是连接虽然强大但不可控的 LLM 与最终用户体验之间的桥梁。

三、 生存指南:从“搬砖”到“设计图纸”

对于现在的开发者,我的建议非常直接:

    1. 放弃对“语法记忆”的执念: 以前我们背诵 CSS 属性,现在请把这些外包给 AI。不要因为 AI 写出了代码而感到羞耻,要学会 Review AI 的代码,你需要比 AI 更懂代码的好坏
    1. 深入计算机科学基础: 算法、数据结构、编译原理、网络协议。这些是 AI 经常犯错的地方,也是你能体现 Debug 能力的地方。
    1. 拥抱全栈思维: 2026 年的前端不再局限于浏览器。Server Component 早已成为主流,你必须懂数据库、懂 Serverless、懂后端逻辑。只有打通前后端,你才能设计出完整的系统。
    1. 培养“产品力”: 当技术实现的门槛降低,决定产品生死的往往是对用户需求的洞察。能不能用现有的技术栈最快地解决业务痛点?这才是王道。

结语

“只会写 div 和 css 的前端彻底失业”这句话,本质上不是一种诅咒,而是一种解放

它意味着我们终于可以从繁琐、重复的体力劳动中解脱出来,去思考架构、去优化体验、去创造真正的价值。在这个时代, “前端”的定义正在被重写。 我们不再是浏览器的排版工,我们是数字体验的架构师,是连接算力与人心的工程师。

如果你还在担心失业,请停止焦虑,开始学习那些 AI 此刻还看不懂的“复杂系统”吧。


💬 互动时刻

看到这里,我想邀请大家做一个名为**“断网测试”**的小实验:

打开你最近负责的一个项目代码库,找一段你认为最复杂的逻辑。
如果现在切断所有 AI 辅助工具(Copilot、ChatGPT 等),只给你官方文档:

    1. 你还能独立理解并重构这段代码吗?
    1. 其中的性能瓶颈和边界情况,你能凭直觉发现吗?
    1. 如果它是 AI 生成的,你能确信它 100% 没有隐患吗?

欢迎在评论区留下你的答案。是“毫无压力”,还是“冷汗直流”?

让我们聊聊,剥离了 AI 的外衣后,作为工程师的我们,到底还剩下什么。

🚀 AI 全栈项目第一天:解锁 React 路由的“时空穿梭”术

大家好!欢迎来到 AI 全栈项目实战 的第一天。

在现代前端开发中,如果我们把 React 组件比作一个个独立的“平行宇宙”(页面),那么 React Router 就是连接这些宇宙的“虫洞”。没有它,我们只能在一个孤岛上打转;有了它,用户才能在不同的功能模块间自由穿梭。

今天,我们就结合项目代码,像剥洋葱一样,一层层揭开 React Router 的神秘面纱。准备好了吗?我们要发车了!🚗


⏳ 一、 前端路由的前世今生:从“切图仔”到“架构师”

在很久很久以前(其实也就十几年前),Web 开发的世界还是一片蛮荒之地。

1. 后端路由时代

那时候,路由的大权掌握在后端手里(PHP, JSP, ASP)。

  • 用户点击一个链接 -> 浏览器向服务器发送请求 -> 服务器拼接好完整的 HTML -> 返回给浏览器 -> 页面白屏刷新 -> 显示新内容。
  • 缺点:每次跳转页面都要刷新,体验就像看 PPT 时每翻一页都要黑屏一秒,非常“卡顿”。那时候的前端主要负责写 HTML/CSS,被戏称为“切图仔”。

2. 前端路由时代 (SPA)

随着 Ajax 的普及和 React/Vue 的崛起,单页应用 (SPA - Single Page Application) 诞生了。

  • 核心魔法:页面初始化时加载一次 HTML,之后的跳转不再请求整个页面,而是通过 JS 感知 URL 的变化,动态地把原本的 DOM 树“拆掉”,换上新的组件。
  • 体验:丝般顺滑,像原生 App 一样流畅。

正如项目文档 readme.md 中所说:

前后端分离,前端有独立的 (html5) 路由,实现页面切换。前端会收到一个事件,将匹配的新路由显示在页面上。


⚔️ 二、 路由界的“红白玫瑰”:BrowserRouter vs HashRouter

在 React Router 中,有两种最常见的路由模式,它们就像两兄弟,性格迥异但各有所长。

1. BrowserRouter (HTML5 History API) 🌹

  • 长相http://example.com/product/123
  • 性格:优雅、漂亮、现代。它利用 HTML5 的 history.pushState API 来改变 URL 而不刷新页面。
  • 缺点:它比较“娇气”。如果你在二级页面刷新浏览器,服务器会以为你要请求这个路径的资源,结果找不到(404)。这需要后端(Nginx/Apache)配合,把所有请求都重定向回 index.html

2. HashRouter (Hash模式) 🏳️

  • 长相http://example.com/#/product/123
  • 性格:老实、可靠、兼容性强。URL 里带个 # 号(锚点)。
  • 优点# 后面的内容不会发送给服务器,所以随便刷新都不会 404。非常适合放在 GitHub Pages 或者没有后端配置权限的场景。
  • 缺点:URL 稍微丑了那么一点点。

💡 一个提升逼格的小技巧

观察下面代码:

import {
  BrowserRouter as Router, // ✨ 这里的重命名是点睛之笔
} from 'react-router-dom';

export default function App() {
  return (
    // 以后想换成 HashRouter,只需要改上面的 import,这里不用动
    <Router>
      {/* ... */}
    </Router>
  )
}

使用 as Router 进行重命名,不仅让代码语义更通顺(我们在使用“路由”,而不是具体的“浏览器路由”),还方便未来在两种模式间无缝切换。


🛠️ 三、 路由配置初体验:搭建骨架

在这个阶段,我们通常会将所有的逻辑写在 App.jsx 里(虽然我们后面会重构它,但先理解原理)。

一个最基础的路由配置流程如下:

  1. 编写页面组件:在 src/pages 下写好 Home.jsx, About.jsx 等。
  2. 引入组件import Home from './pages/Home'
  3. 配置路径:使用 <Routes><Route>
  4. 跳转页面:使用<Link to "/**"> 或者 <Navigate to "/**">
// 伪代码演示初级阶段
<Router>
 <Link to="/">Home</Link>
<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/about" element={<About />} />
</Routes>
<Router>

注意Link,Routes与Route等一定要包裹在Router里面才能运行,因为它们都要依赖Router的Context机制。

🤔Link or? Navigate

<Link>navigate(通过 useNavigate() 获取)都用于实现页面导航,但使用方式和场景有所不同。<Link> 是一个声明式的 JSX 组件,通常用于页面中的可点击链接(如导航栏),其行为类似于 HTML 的 <a> 标签;而 navigate 是一个编程式导航函数,适用于在事件处理、表单提交、权限校验或副作用逻辑中动态跳转页面(例如登录成功后自动跳转)。两者都依赖于 <Router> 提供的上下文环境,并支持传递状态、替换历史记录等高级功能,其中 navigate 还能实现返回上一页(如 navigate(-1))等操作。简言之,<Link> 适合用户主动触发的静态跳转,navigate 则更适合由代码逻辑驱动的动态跳转。

但是!随着项目变大,比如我们这个 AI 全栈项目,包含了 UserProfile, Product, Login, Pay 等等十几个页面。如果我们都在文件顶部 import 进来...

🛑 问题出现了: 用户只是想打开首页看一眼,结果浏览器把“支付页”、“后台管理页”的代码全下载下来了。首屏加载时间直接爆炸,用户体验极差。


⚡ 四、 性能救星:懒加载 (Lazy Loading)

为了解决上面的问题,React Router 配合 React 官方推出了“懒路由”方案。只有当用户真正点击了某个路由,才去加载对应的代码文件。

我们来看看如何优雅地处理这个问题:

1. 引入两兄弟:lazy 和 Suspense

import {
  lazy, // 😴 懒加载函数
  Suspense // ⏳ 悬念/等待组件
} from 'react';

2. 改造 Import 方式

不再是静态引入,而是动态引入:

// ❌ 以前:import Home from '../pages/Home'
// ✅ 现在:
const Home = lazy(() => import('../pages/Home')); 
const About = lazy(() => import('../pages/About'));
const Product = lazy(() => import('../pages/product'));
// ... 其他组件同理

3.包裹路由路径配置

lazy 依赖 Suspense 是因为懒加载本质上是异步的,而 React 需要 Suspense 来优雅地处理加载中的状态,避免白屏或崩溃,并提供良好的用户体验。两者配合,实现了代码分割与平滑加载的现代前端优化模式。

 <Suspense fallback={<LoadingFallback />}>
    <Routes>
      <Route path="/" element={<Home />} />
    </Routes>
 </Suspense>

这样,Webpack/Vite 打包时,会把每个页面拆分成独立的 chunk.js 文件,实现按需加载


📸 五、 路由“全家福”:五种路由形态解析

接下来是本文的硬核部分。在 src/router/index.jsx 中,我们几乎涵盖了 React Router 的所有用法。让我们结合 readme.md 里的知识点一一解析。

1. 普通路由

最简单的映射关系,一一对应。

<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />

2. 动态路由 (Dynamic Routing) 🆔

当路径中有一部分是变化的,比如用户 ID、商品 ID。

{/* http://domain/user/12345 */}
<Route path="/user/:id" element={<UserProfile />} />

UserProfile 组件内部,我们可以通过 useParams() 钩子拿到这个 id

3. 嵌套路由 (Nested Routing) 👨‍👦

这是 React Router 最强大的功能之一。比如商品模块,有列表页,有详情页,有新增页,它们可能共享一套布局(比如侧边栏)。

知识点

<Outlet> 是 React Router DOM 中的组件,用于在父路由元素中渲染其子路由匹配到的内容。

代码实战

<Route path="/products" element={<Product/>}>
  {/* 当访问 /products/new 时,渲染 NewProduct */}
  <Route path="new" element={<NewProduct />}/>
  
  {/* 当访问 /products/123 时,渲染 ProductDetail */}
  <Route path=":productId" element={<ProductDetail />}/>
</Route>

在父组件 Product 中,必须写上 <Outlet />,子路由的内容就会填入那个位置。

4. 鉴权路由 (Protected Route) 🛡️

有些页面(如支付页)是不能随便进的,必须登录。我们需要一个“保安”。

const ProtectRoute = lazy(() => import('../components/ProtectRoute'));

// ...

<Route path="/pay" element={
  {/* 💡 这里的逻辑是想看 Pay先过 ProtectRoute 这一关 */}
  <ProtectRoute>
    <Pay />
  </ProtectRoute>
}>
</Route>

ProtectRoute组件代码:

import { 
    Navigate
 } from 'react-router-dom';

export default function ProtectRoute({ children }) {// 组件包裹的内容就是children
    const isLoggedIn = localStorage.getItem('isLogin') === 'true';// 本地存储了登录状态
    if (!isLoggedIn) {
        return <Navigate to="/login" />
    }
    return (
        <>
            {children}
        </>
    )
}

ProtectRoute 组件内部通常会检查 Token,如果没有登录,直接用 <Navigate to="/login" /> 把用户踢到登录页。

5. 重定向路由 (Redirect) ➡️

随着版本迭代,旧的路径可能废弃了,但不能让老用户迷路。

{/* 访问 /old-path 自动跳转到 /new-path,replace 表示替换当前历史记录 */}
<Route path="/old-path" element={<Navigate replace to="/new-path" />}/>

6. 通配路由 (Wildcard) 4️⃣0️⃣4️⃣

兜底方案,当上面的路由都没匹配上时,显示 404。

<Route path="*" element={<NotFound />} />

🎨 六、 极致的用户体验:LoadingFallback

既然用了懒加载,网络请求是需要时间的。在组件下载下来之前,页面不仅不能白屏,还得给用户一点反馈。

这就轮到 Suspense 出场了,它包裹在 <Routes> 外层:

<Suspense fallback={<LoadingFallback />}>
  <Routes>
     {/* ...路由配置... */}
  </Routes>
</Suspense>

我们可以写一个炫酷的 CSS 动画转圈圈。这一个小小的细节,能让应用的质感提升一个档次。 LoadingFallback代码

关于module_css可以看🎨 CSS 这种“烂大街”的技术,怎么在 React 和 Vue 里玩出花来?

import styles from './index.module.css'

export default function LoadingFallback() {
    return (
        <div className={styles.container}>
            <div className={styles.spinner}>
                <div className={styles.circle}></div>
                <div className={`${styles.circle} ${styles.inner}`}></div>//设置两个className
            </div>
            <p className={styles.text}>Loading...</p>
        </div>
    )
}
.container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100vh;
  background-color: rgba(255, 255, 255, 0.9);
}
.spinner {
  position: relative;
  width: 60px;
  height: 60px;
}
.circle {
  position: absolute;
  width: 100%;
  height: 100%;
  border: 4px solid transparent;
  border-top-color: #3498db;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}
.circle.inner {
  width: 70%;
  height: 70%;
  top: 15%;
  left: 15%;
  border-top-color: #e74c3c;
  animation: spin 0.8s linear infinite reverse;
}
/* 关键帧动画 */
@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

.text {
  margin-top: 20px;
  color: #2c3e50;
  font-size: 18px;
  font-weight: 500;
  animation: pulse 1.5s ease-in-out infinite;
}

@keyframes pulse {
  0% {
    opacity: 0.6;
  }
  50% {
    opacity: 1;
  }
  100% {
    opacity: 0.6;
  }
}

🧹 七、 代码重构:各司其职

最开始我们可能把所有代码都堆在 App.jsx 里。现在为了项目结构清晰,我们进行了分离:

  1. 路由配置独立:所有的 Route 定义移到了 src/router/index.jsxRouterConfig 组件中。
  2. 导航菜单独立:菜单链接移到了 src/components/Navigation.jsx

现在的 App.jsx 简直清爽得令人感动:

// src/App.jsx
import { BrowserRouter as Router } from 'react-router-dom';
import Navigation from './components/Navigation';
import RouterConfig from './router';

export default function App() {
  return (
    <Router>
      <Navigation />   {/* 顶部导航 */}
      <RouterConfig /> {/* 路由内容渲染区 */}
    </Router>
  )
}

这就是关注点分离(Separation of Concerns)的美学!


🎯 八、 锦上添花:高亮当前菜单

最后,我们还要解决一个痛点:用户怎么知道自己当前在哪个页面? 导航栏对应的菜单项应该高亮显示(变红)。

我们来实现一个高级的 isActive 判断逻辑:

import { useResolvedPath, useMatch } from 'react-router-dom';

const isActive = (to) => {
    // 1. 解析目标路径,处理相对路径等情况,得到标准的 location 对象
    const resolvedPath = useResolvedPath(to); 
    
    // 2. 使用 useMatch 进行严格匹配
    // path: 当前浏览器地址栏的 pathname
    // end: true 表示精确匹配(比如 /about 不会匹配 /about/me)
    const match = useMatch({
        path: resolvedPath.pathname,
        end: true
    })
    
    // 3. 匹配上了就返回 'active' 类名
    return match ? 'active' : '';
}

为什么不用简单的字符串比较? 因为路由可能是复杂的(比如带有查询参数、Hash),或者使用了相对路径。useResolvedPathuseMatch 是 React Router 提供的专业工具,能处理各种边缘情况,比手写 location.pathname === to 健壮得多。


📝 总结

今天我们从路由的历史讲起,深入分析了 React Router 的配置、懒加载优化、各种路由类型的实战应用,最后还做了一波代码重构和体验优化。

但这只是 AI 全栈项目的冰山一角!在 readme.md 的技能树中,我们还有:

  • Zustand (状态管理)
  • NestJS (后端开发)
  • LangChain (AI 集成)
  • ...

前端路由只是我们构建复杂应用的第一块基石。掌握了它,你就拥有了构建多页面复杂应用骨架的能力。

课后作业:尝试在项目中添加一个新的页面 /dashboard,并为其配置懒加载和路由守卫,看看你能不能独立完成?

我们在下一章见!👋


本文基于 AI Fullstack 课程实战代码编写,不仅是教程,更是实战记录。

❌