阅读视图

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

JavaScript 如何准确判断数据类型?5 种方法深度对比

在写js的时候,很容易因为数据类型没判断好而出错,比如这样的代码:

function calculate(a, b) {
    return a + b;
}
// 我以为是 10 + 20 = 30
calculate(10, 20); // 结果 30 对的 

// 实际上用户输入的是字符串和数字
calculate("10", 20); // 结果为 "1020" 

所以为了避免这种情况的出现,我们还是要去判断好数据类型。

JavaScript中,有几种方式来判断数据类型,以下是常用的方法:

1. typeof 操作符

最常用的类型判断方法,但有一些局限性:

typeof 42;           // "number"
typeof "hello";      // "string"
typeof true;         // "boolean"
typeof undefined;    // "undefined"
typeof null;         // "object" (历史遗留问题)
typeof {};           // "object"
typeof [];           // "object"
typeof function(){}; // "function"
typeof Symbol();     // "symbol"
typeof 42n;          // "bigint"

局限性

  • 无法区分数组、对象和 null
  • 函数返回 function
  • typeof 适合判断基本类型,但遇到对象类型就力不从心了

2. instanceof 操作符

用于检测构造函数的 prototype 属性是否出现在对象的原型链中:

[] instanceof Array;           // true
{} instanceof Object;          // true
new Date() instanceof Date;    // true
function(){} instanceof Function; // true

// 继承关系,数组也是对象
[] instanceof Object;          // true

instanceof 的局限性:

// 基本类型用不了
42 instanceof Number;          // false
"hello" instanceof String;     // false

在跨 iframe 或不同 window 环境下可能失效(因为构造函数不同)


3. Object.prototype .toString.call()

这是最准确、最可靠的方法,能识别所有内置类型!

Object.prototype.toString.call(42);           // "[object Number]"
Object.prototype.toString.call("hello");      // "[object String]"
Object.prototype.toString.call(true);         // "[object Boolean]"
Object.prototype.toString.call(null);         // "[object Null]"
Object.prototype.toString.call(undefined);    // "[object Undefined]"
Object.prototype.toString.call([]);           // "[object Array]"
Object.prototype.toString.call({});           // "[object Object]"
Object.prototype.toString.call(function(){}); // "[object Function]"
Object.prototype.toString.call(Symbol());     // "[object Symbol]"
Object.prototype.toString.call(42n);          // "[object BigInt]"

我们封装一个实用的工具函数:

function getRealType(value) {
    return Object.prototype.toString.call(value)
        .slice(8, -1)          // 截取"[object "和"]"之间的内容
        .toLowerCase();         // 转为小写,更友好
}

console.log(getRealType([]));        // "array"
console.log(getRealType(null));      // "null"
console.log(getRealType({}));        // "object"
console.log(getRealType(new Date())); // "date"

4. 专用方法

对于一些特殊类型,JavaScript提供了专门的判断方法:

判断数组:Array.isArray()

Array.isArray([]);     // true
Array.isArray({});     // false
Array.isArray("123");  // false

判断NaN:Number.isNaN()

// 注意区别!
isNaN("hello");        // true  ← 字符串不是数字,但这样判断容易误解
Number.isNaN("hello"); // false ← 更准确:只有真正的NaN才返回true
Number.isNaN(NaN);     // true

判断有限数字:Number.isFinite()

Number.isFinite(42);     // true
Number.isFinite(Infinity); // false  ← 无穷大不是有限数字
Number.isFinite("42");   // false  ← 字符串不是数字

编写健壮的函数

在实际开发中的应用:

场景1:安全的数字相加

function safeAdd(a, b) {
    // 确保两个参数都是数字类型
    if (typeof a !== 'number' || typeof b !== 'number') {
        throw new Error('参数必须是数字');
    }
    return a + b;
}

safeAdd(1, 2);     // 3
safeAdd(1, "2");   // 报错:参数必须是数字

场景2:处理多种数据类型

function processData(data) {
    // getRealType方法在第3点Object.prototype.toString.call()中有写
    const type = getRealType(data); 
    
    switch(type) {
        case 'array':
            return data.map(item => item * 2);
        case 'object':
            return Object.keys(data).length;
        case 'string':
            return data.toUpperCase();
        case 'number':
            return data * 2;
        default:
            return '不支持的数据类型';
    }
}

console.log(processData([1, 2, 3]));    // [2, 4, 6]
console.log(processData("hello"));      // "HELLO"
console.log(processData({a: 1, b: 2})); // 2

总结:选择合适的方法

场景 推荐方法 示例
判断基本类型 typeof typeof "hello" === "string"
判断数组 Array.isArray() Array.isArray([])
判断自定义对象 instanceof obj instanceof MyClass
精确类型判断 Object.prototype.toString.call() 见上文工具函数
特殊值判断 专用方法 Number.isNaN(), Number.isFinite()

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

📌往期精彩

写前端久了,我用 Node.js 给自己造了几个省力小工具

我也是写了很久 TypeScript,才意识到这些写法不对

ThreadLocal 在实际项目中的 6 大用法,原来可以这么简单

重构了20个SpringBoot项目后,总结出这套稳定高效的架构设计

用changeset来管理你的npm包版本

简单介绍下,changeset是一个版本管理和生成更新日志的工具,超级适用于多包仓库,比如monorepo,可以在提交发布时,自动更新所有包的版本号,创建Tag,并且生成更新日志。

一、安装

进入项目之后执行命令,安装changeset,并且初始化

 pnpm add -D @changesets/cli
 pnpm changeset init

执行完之后,在项目中会新增一个.changeset目录,用于存放配置文件和临时的版本变更描述文件

.changeset/
 ├─ config.json
 └─ README.md

二、使用流程

在通用的版本管理流程中,通常会区分为:

  • 预发布版本(如alpha和beta)
  • 正式版本

预发布版本

预发布阶段的两级为alpha和beta,下面是大致区别:

维度 alpha(内测) beta(公测)
代码稳定性 随时可能大改 功能基本锁定,不会有大的调整
测试人群 团队内部 灰度用户
发布频率 每天/每周都能出包 节奏稍慢,某个阶段的版本
版本号示例 1.0.0-alpha.0 ➜ 1.0.0-alpha.1 … 1.0.0-beta.0 ➜ 1.0.0-beta.1 …
退出条件 达到功能完备 → 进入beta 连续几天无阻塞Bug → 发布正式版

相关命令:

pnpm changeset pre enter alpha   # 进入alpha模式,
pnpm changeset version          # 版本变成0.0.1-alpha.0

pnpm changeset pre enter beta   # 进入beta模式
pnpm changeset version          # 版本变成0.0.1-beta.0

# 结束预发布
pnpm changeset pre exit
pnpm changeset version          # 版本变成0.0.1

正式版本

当你完成某个包的开发,准备发版时,执行:

pnpm changeset

如果是多包仓库,终端会出现一个选择框,让你选择改过的包,

  1. 按空格选中你改过的包(有星号就算选中)→ 回车,

  2. 选包的更新级别,会依次出现major和minor,回车到下一步,如果都没选中,就默认为patch,输入本次更新的描述回车

    • patch:修复小bug(1.0.0→1.0.1)
    • minor:添加新功能(1.0.0→1.1.0)
    • major:破坏性的大版本调整,api级别的调整(1.0.0→2.0.0)

单包仓库就直接到了选择更新级别这一步,同样是输入描述,然后回车;

生成版本号,执行:

pnpm changeset version

你会发现.changeset文件夹中刚才生成的md文件都已经不见了,版本号也升好了。

发布

  • 登录npm
npm login
  • 发版
pnpm changeset publish

成功后,会在git创建对应的git tag,终端会给出每个包的版本号和 npm链接。

三、changeset总结

最后总结下,对changeset的整体感觉

优点

  1. 版本语义化,显式标注变更级别
  2. 每一次变更都会新建个文件,方便review
    • 列出受影响的包
    • 变更的级别,patch/minor/major
    • 变更的内容
  3. 自动生成tag、版本号changelog
  4. Monorepo依赖的自动推导,比如修改了a包,b包依赖了a包,那么b包也会更新版本

缺点

  1. 会有额外心智负担,
    • 每次变更都要执行changeset,会增加操作流程(其实我觉得这个不能算)
    • 思考版本类型
    • 流程容易漏
  2. 单包项目有点多余,没完全发挥作用

CSS 特殊符号 / 英文导致换行问题速查表

一、最推荐通用写法(90% 场景)

.text {
  word-break: normal;
  overflow-wrap: break-word;
  white-space: normal;
}

适用:

  • 中文 + 英文混排
  • URL / 特殊符号
  • 不希望英文被拆成字母

二、常见需求对应写法

以下场景覆盖:不拆单词 / 允许拆单词 / 特殊符号提前换行 等真实业务需求

1️⃣ 不希望单词被拆开

.text {
  word-break: normal;
  overflow-wrap: normal;
}

2️⃣ 单词太长允许必要时换行(推荐)

.text {
  overflow-wrap: break-word;
  word-break: normal;
}

3️⃣ 特殊符号(- _ / .)导致断行

.text {
  word-break: keep-all;
  overflow-wrap: break-word;
}

4️⃣ 完全不换行(一行显示)

.text {
  white-space: nowrap;
}

5️⃣ 一行显示,超出省略号

.text {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

6️⃣ URL / 长链接优雅断行

.text {
  word-break: normal;
  overflow-wrap: anywhere;
}

7️⃣ 代码 / hash / token 强制断行

.code {
  word-break: break-all;
  font-family: monospace;
}

8️⃣ 明确接受单词被拆开(强制任何位置换行)

.text {
  word-break: break-all;
}

适用:

  • 日志内容
  • 长 hash / token / ID
  • 空间极窄但必须完整展示

⚠️ 英文会被拆成字母,属于主动选择的行为


9️⃣ 特殊符号导致“提前换行”(如 - / _ .

.text {
  word-break: normal;
  overflow-wrap: normal;
}

说明:

  • 禁止在符号处断行
  • 让浏览器只在真正需要时换行

🔟 允许在特殊符号处优先换行(比拆字母更友好)

.text {
  overflow-wrap: anywhere;
  word-break: normal;
}

适用:

  • URL / 路径
  • aaa-bbb-ccc-ddd
  • 希望优先在符号处分行,而不是字母中间

---

## 三、Flex / Table 常见坑

### flex 子项内容被异常换行

```css
.item {
  min-width: 0;
  overflow-wrap: break-word;
}

四、不推荐写法(慎用)

⚠️ 以下写法不是不能用,而是必须明确知道后果

word-break: break-all;

❌ 会把英文拆成字母,仅适合日志 / code 场景


五、属性速记表

属性 作用
word-break 是否允许在单词内部断行
overflow-wrap 单词太长时是否允许换行(⭐推荐)
white-space 是否允许换行

📌 记住一句话:

优先用 overflow-wrap: break-word,避免 word-break: break-all

PowerShell 启动卡顿?内存飙升?原来是 800MB 的历史记录在作祟!

PowerShell 启动卡顿?内存飙升?原来是 800MB 的历史记录在作祟!

最近在开发过程中遇到一个非常诡异的问题:PowerShell 一启动,电脑就像被施了定身法一样卡顿。 打开任务管理器一看,好家伙,PowerShell 进程的内存占用像坐火箭一样蹭蹭往上涨,甚至在没有任何手动操作的情况下也是如此。

经过一番侦探式的排查,终于揪出了幕后真凶。今天就把这个排查过程和解决方案分享给大家,如果你的终端也经常卡顿,不妨检查一下这个问题。

🧐 问题现场

现象非常简单粗暴:

  • 双击启动 PowerShell(或者在 VS Code 中打开终端)。
  • 系统明显卡顿,鼠标移动都变得迟缓。
  • 查看任务管理器,PowerShell 进程占用内存极高(甚至达到 GB 级别)。
  • 等待许久后,终端才勉强可以输入命令。

🕵️‍♂️ 抽丝剥茧:排查过程

为了找到原因,我并没有急着重装系统(虽然这是万能大法),而是决定深入系统内部看一看。

1. 进程分析

首先,我使用 Get-Process 命令查看了 PowerShell 进程的状态。果不其然,WorkingSet(工作集内存)数值异常的高。

2. 环境检查

我怀疑是不是某个 Profile 配置文件或者加载的模块有问题。检查了 $PROFILE,发现并没有什么特殊的启动脚本。接着检查已加载的模块,一切似乎都很正常。

3. 关键发现

在排查 PowerShell 的常用模块 PSReadLine 时,我注意到了一个细节。这个模块负责管理我们的命令行历史记录、语法高亮等功能。它会把我们敲过的所有命令都保存在一个文本文件里。

我顺藤摸瓜找到了这个文件,结果让我大吃一惊:

文件路径: %APPDATA%\Microsoft\Windows\PowerShell\PSReadLine\ConsoleHost_history.txt 文件大小: 832 MB

没错,你没看错,一个纯文本的历史记录文件,竟然有 800多兆!这意味着里面可能保存了数百万行的命令历史。

真相大白: PowerShell 在启动时,PSReadLine 模块会尝试加载这个巨大的历史记录文件,以便提供“向上箭头”查找历史命令的功能。这就好比让你一口气背诵一本字典,不卡才怪!

🛠️ 一键解决

既然找到了病灶,治疗方案就非常简单了:让 PowerShell 放弃加载这个文件。

为了保险起见(万一里面有重要的历史命令呢),我没有直接删除它,而是选择了重命名。

操作步骤:

打开你的文件资源管理器,或者直接在命令行执行以下操作:

# 进入 PSReadLine 目录
cd "$env:APPDATA\Microsoft\Windows\PowerShell\PSReadLine"

# 将巨大的历史文件重命名备份
Rename-Item .\ConsoleHost_history.txt -NewName ConsoleHost_history.txt.bak

效果立竿见影: 再次启动 PowerShell,秒开!内存占用恢复到了几十 MB 的正常水平。系统瞬间恢复了丝般顺滑。

💡 避坑指南

问题解决了,但为什么会有这么大的历史文件呢?通常有以下几个原因:

  1. 日积月累: 从来没有清理过,数年的操作记录都在里面。
  2. 自动化脚本: 某些自动化脚本如果在 PowerShell 环境下疯狂循环执行命令,这些命令也会被记录下来。

建议:

  • 定期检查一下 %APPDATA%\Microsoft\Windows\PowerShell\PSReadLine\ 目录下的文件大小。
  • 如果你发现自己不需要保留那么久远的历史记录,可以考虑定期清理。
  • 如果那个 800MB 的备份文件里没有你需要的“传家宝”代码,果断删掉它释放空间吧!

希望这篇文章能帮到遇到同样问题的你。Happy Coding! 🚀

Typescript之类型总结大全

TypeScript 中的基本类型

TypeScript 的基本类型涵盖了 JavaScript 的原始类型,并添加了一些 TypeScript 特有的类型。


1. JavaScript 原始类型(Primitive Types)

这些是 JavaScript 原有的基本数据类型,TypeScript 为它们提供了类型注解。

boolean - 布尔值

let isDone: boolean = true;
let isLoading: boolean = false;

number - 数字

TypeScript 中的所有数字都是浮点数,支持十进制、十六进制、二进制和八进制。

let decimal: number = 6;
let hex: number = 0xf00d;      // 十六进制
let binary: number = 0b1010;   // 二进制
let octal: number = 0o744;     // 八进制
let float: number = 3.14;
let infinity: number = Infinity;
let notANumber: number = NaN;

string - 字符串

let name: string = "张三";
let sentence: string = `你好,${name}!`;  // 模板字符串

bigint - 大整数(ES2020+)

表示大于 2^53 - 1 的整数。

let big: bigint = 9007199254740991n;
let big2: bigint = BigInt(9007199254740991);

symbol - 符号(ES2015+)

创建唯一的标识符。

typescript

let sym1: symbol = Symbol();
let sym2: symbol = Symbol("description");

2. 特殊原始类型

null - 空值

let n: null = null;

undefined - 未定义

let u: undefined = undefined;

注意:在 strictNullChecks 模式下,null 和 undefined 只能赋值给它们自己或 any 类型。


3. TypeScript 特有类型

any - 任意类型

关闭类型检查,兼容所有类型。

let notSure: any = 4;
notSure = "可能是字符串";
notSure = false;  // 没问题

unknown - 未知类型

比 any 更安全的类型,使用时需要类型检查或断言。

let value: unknown;

// 需要类型检查后才能使用
if (typeof value === "string") {
    console.log(value.length);
}

// 或使用类型断言
let str: string = (value as string);

void - 空类型

表示函数没有返回值。

function warnUser(): void {
    console.log("警告信息");
    // 没有 return 语句
}

never - 永不存在的值

表示永远不会发生的类型,用于总是抛出异常或无限循环的函数。

// 抛出错误
function error(message: string): never {
    throw new Error(message);
}

// 无限循环
function infiniteLoop(): never {
    while (true) {}
}

object - 非原始类型

表示非原始类型的值(不是 number, string, boolean, symbol, null, undefined)。

let obj: object = {};
let arr: object = [];
let func: object = function() {};

4. 数组类型

有两种表示方式:

类型 + 方括号

let list: number[] = [1, 2, 3];
let strings: string[] = ["a", "b", "c"];

泛型 Array<类型>

let list: Array<number> = [1, 2, 3];
let strings: Array<string> = ["a", "b", "c"];

只读数组

let readonlyArr: ReadonlyArray<number> = [1, 2, 3];
// readonlyArr.push(4);  // ❌ 错误:只读数组不能修改

5. 元组(Tuple)

表示已知元素数量和类型的数组。

// 定义元组类型
let tuple: [string, number];
tuple = ["hello", 10];  // ✅ 正确
// tuple = [10, "hello"];  // ❌ 错误:类型不匹配

// 访问元组元素
console.log(tuple[0].substring(1));  // "ello"
console.log(tuple[1].toFixed(2));    // "10.00"

// 可选元素(3.0+)
let optionalTuple: [string, number?];
optionalTuple = ["hello"];           // ✅ 正确
optionalTuple = ["hello", 42];       // ✅ 正确

// 剩余元素
let restTuple: [string, ...number[]];
restTuple = ["hello", 1, 2, 3];      // ✅ 正确

6. 枚举(Enum)

数字枚举

enum Direction {
    Up = 1,      // 从 1 开始
    Down,        // 自动递增为 2
    Left,        // 3
    Right        // 4
}

let dir: Direction = Direction.Up;

字符串枚举

enum Direction {
    Up = "UP",
    Down = "DOWN",
    Left = "LEFT",
    Right = "RIGHT"
}

常量枚举(编译时完全删除)

const enum Colors {
    Red,
    Green,
    Blue
}

let color = Colors.Red;  // 编译后:let color = 0;

7. 字面量类型

字符串字面量

type EventType = "click" | "scroll" | "mousemove";
let event: EventType = "click";  // ✅
// event = "hover";  // ❌ 错误

数字字面量

type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
let roll: DiceRoll = 3;  // ✅
// roll = 7;  // ❌ 错误

布尔字面量

type Truthy = true;
let isTrue: Truthy = true;
// isTrue = false;  // ❌ 错误

8. 类型推断与联合类型

类型推断

let x = 3;            // x 被推断为 number
let y = "hello";      // y 被推断为 string

联合类型

let id: string | number;
id = "abc123";  // ✅
id = 123;       // ✅
// id = true;   // ❌ 错误

// 类型守卫
if (typeof id === "string") {
    console.log(id.toUpperCase());
} else {
    console.log(id.toFixed(2));
}

9. 类型别名

// 基本类型别名
type ID = string | number;
type Point = {
    x: number;
    y: number;
};

let userId: ID = "user-123";
let position: Point = { x: 10, y: 20 };

类型总结表

类型 示例 描述
boolean let isDone: boolean = true; 布尔值
number let count: number = 10; 所有数字类型
string let name: string = "John"; 字符串
bigint let big: bigint = 100n; 大整数
symbol let sym: symbol = Symbol(); 唯一标识符
null let n: null = null; 空值
undefined let u: undefined = undefined; 未定义
any let anything: any = 4; 任意类型
unknown let unsure: unknown = 30; 未知类型
void function(): void {} 无返回值
never function error(): never {} 永不存在的值
object let obj: object = {}; 非原始类型
Array<T> let list: number[] = [1, 2, 3]; 数组
[T1, T2] let tuple: [string, number]; 元组
enum enum Color { Red, Green } 枚举

实用示例

// 完整示例
function processInput(input: string | number | boolean): string {
    if (typeof input === "string") {
        return `字符串: ${input.toUpperCase()}`;
    } else if (typeof input === "number") {
        return `数字: ${input.toFixed(2)}`;
    } else {
        return `布尔值: ${input}`;
    }
}

// 严格空值检查
function greet(name: string | null): string {
    if (name === null) {
        return "你好,访客!";
    }
    return `你好,${name}!`;
}

// 使用 never 进行穷尽检查
type Shape = "circle" | "square" | "triangle";

function getArea(shape: Shape): number {
    switch (shape) {
        case "circle":
            return Math.PI;
        case "square":
            return 1;
        case "triangle":
            return 0.5;
        default:
            // 确保处理了所有情况
            const _exhaustiveCheck: never = shape;
            return _exhaustiveCheck;
    }
}

TypeScript 的基本类型系统提供了强大的类型安全保证,帮助开发者在编译时捕获错误,提高代码质量。

Web 仔用 Node 像 Java 一样写后端服务

当Node.js遇上Java的优雅架构,会碰撞出怎样的火花?

作为一名前端开发者,我们常常被Java开发者"鄙视":"Node.js就是个玩具,写写前端还行,做后端?算了吧!" 但事实真的如此吗?通过一个完整的manage-system-server项目实践,我将向大家展示如何用Node.js构建出媲美Java Spring的企业级后端服务,Node.js也可以写得这么"Java范儿"!今天就来跟大家分享这个基于 Koa + TypeScript + TypeORM 的现代化后端架构。

🏗️ 架构设计:Spring Boot的Node.js版

这个项目采用了典型的分层架构,让我想起了Java Spring Boot的优雅设计:

src/
├── app/
│   ├── controllers/    # 控制器层 - 类似Spring的@Controller
│   ├── entity/         # 数据实体 - 类似JPA Entity
│   ├── service/        # 业务服务层 - 类似@Service
│   └── req-validate/   # 请求验证 - 类似DTO验证
├── config/             # 配置管理
├── decorator/          # 装饰器 - 类似Spring注解
├── middles/            # 中间件 - 类似Spring拦截器
└── tools/              # 工具类

🎯 核心特性:Java范儿的Node.js实现

1. 依赖注入:告别require的混乱

项目使用 typedi 实现依赖注入,让代码组织更加清晰:

@Service()
class UserService {
  constructor(private readonly roleService: RoleService) {}
  
  public async userList(req: IListReq) {
    // 业务逻辑
  }
}

2. 声明式控制器:注解驱动的API设计

基于 routing-controllers 的控制器设计,让API定义变得优雅:

@JsonController()
@Service()
class UserController {
  constructor(private readonly userService: UserService) {}

  @Post(Api.USER_LIST)
  @ApiAuth(ModuleEnum.USER, OperationEnum.QUERY)
  public async userList(@Body({ validate: true }) body: IListReq) {
    return await this.userService.userList(body);
  }
}

3. 数据实体:TypeORM的ORM魔法

实体类设计借鉴了JPA的思想,支持数据库字段映射和生命周期钩子:

@Entity('user')
class UserEntity {
  @PrimaryGeneratedColumn({ name: 'id' })
  id?: number;

  @Column({ name: 'login_name' })
  loginName: string;

  @Column({ name: 'password', select: false })
  password?: string;

  @BeforeInsert()
  @BeforeUpdate()
  private encryptFields() {
    // 插入/更新前自动加密密码
    this.password = AesTools.encryptData(this.password);
  }
}

4. 数据验证:类级别的参数校验

使用 class-validator 实现请求参数验证,类似Spring的@Valid:

class IUserAddReq {
  @IsString({ message: 'loginName接收类型为string' })
  @IsNotEmpty({ message: 'loginName不能为空' })
  loginName: string;

  @IsString({ message: 'password接收类型为string' })
  @IsNotEmpty({ message: 'password不能为空' })
  @IsDecryptPwd({ message: 'password密码错误,请确认加密方式' })
  password: string;
}

5. 权限控制:基于装饰器的细粒度权限

自定义权限装饰器实现方法级别的权限控制:

function ApiAuth(module: ModuleEnum, operation: OperationEnum) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    Authorized(`${module}_${operation}`)(target, propertyKey, descriptor);
  };
}

🔧 技术栈深度解析

核心依赖分析

package.json 可以看出项目的技术选型思路:

{
  "dependencies": {
    "koa": "^2.15.0",           // 轻量级Web框架
    "typeorm": "0.3.20",         // 强大ORM框架
    "routing-controllers": "^0.10.4", // 注解式路由
    "class-validator": "^0.14.1", // 数据验证
    "typedi": "^0.10.0",         // 依赖注入容器
    "reflect-metadata": "^0.1.13" // 反射元数据支持
  }
}

开发工具链

项目配备了完整的开发工具链:

  • TypeScript 5.7.2: 类型安全的JavaScript超集
  • ESLint + Prettier: 代码规范和格式化
  • TypeORM迁移: 数据库版本管理
  • 热重载开发: nodemon实时监控文件变化

🚀 实际应用示例

完整的用户管理流程

让我们看一个完整的用户添加流程:

1. 控制器层接收请求

@Post(Api.USER_ADD)
@ApiAuth(ModuleEnum.USER, OperationEnum.ADD)
public async userAdd(
  @CurrentLoginName() loginName: string,
  @Body({ validate: true }) body: IUserAddReq,
) {
  return await this.userService.addUser(loginName, body);
}

2. 服务层业务逻辑

public async addUser(creatorName: string, userInfo: IUserAddReq) {
  // 密码解密
  const decryptPwd = AesTools.decryptData(userInfo.password);
  
  // 检查登录名重复
  const hasUser = await this.checkDuplicateLoginName(userInfo.loginName);
  if (hasUser) {
    return CommonTools.returnError(CodeEnum.USER_LOGIN_NAME_SAME);
  }
  
  // 创建用户实体
  const insertUser = new UserEntity({
    password: decryptPwd,
    username: userInfo.username,
    loginName: userInfo.loginName,
    creator: creatorName,
  });
  
  // 保存到数据库
  const resp = await getRepository(UserEntity).insert(insertUser);
  return CommonTools.returnData({ id: resp.generatedMaps[0].id });
}

3. 统一的错误处理

export class ErrorMiddleware implements KoaMiddlewareInterface {
  async use(ctx: ICtxRouterContent, next: Next): Promise<void> {
    try {
      await next();
    } finally {
      // 统一处理各种错误类型
      if (ctx.status === HttpCode.BAD_REQUEST) {
        // 参数校验错误处理
        ctx.body = CommonTools.returnData(errors, CodeEnum.COMMON_PARAMS_ERROR);
      }
    }
  }
}

💡 架构优势总结

1. 代码可维护性

  • 分层清晰: Controller-Service-Entity明确分工
  • 类型安全: TypeScript全程保驾护航
  • 依赖注入: 松耦合的组件关系
  • 共享类型定义:前后端共享DTO和接口定义

2. 开发效率

  • 注解驱动: 减少样板代码
  • 热重载: 快速开发调试
  • 迁移工具: 数据库版本化管理
  • 统一技术栈:前后端都使用TypeScript,减少学习成本
  • 快速迭代:前端开发者可以直接参与后端开发
  • 问题定位:前后端问题定位更加高效

3. 企业级特性

  • 权限控制: 细粒度的API权限管理
  • 数据加密: 自动的敏感数据加密
  • 错误处理: 统一的异常处理机制

4. 扩展性

  • 微服务就绪: 模块化架构支持微服务拆分
  • 多租户支持: 内置数据隔离机制
  • 缓存集成: Redis缓存提升性能

🎉 结语

写完这个项目后让我深刻体会到,Node.js生态已经足够成熟,完全可以胜任复杂的企业级应用开发。通过借鉴Java生态的优秀设计模式,我们可以在保持JavaScript灵活性的同时,获得Java级别的工程化能力。

作为"Web仔",我们不再需要羡慕Java程序员的那套"重型装备"。在Node.js的世界里,我们同样可以写出优雅、健壮、可维护的后端代码!

技术不分贵贱,优雅的代码才是王道!


项目地址:github.com/chencjfeng/…
作者:ChenJF
邮箱:chencjfeng@163.com

本文基于实际项目代码分析,所有示例代码均可运行。欢迎Star和贡献!

React useState 原理和异步更新

useState 的基本原理

useState 是 React 的一个 Hook,它的核心原理基于以下几点:

1. 闭包和链表结构

React 内部使用链表来存储组件的所有 Hook 状态。每次组件渲染时,React 会按照 Hook 调用的顺序遍历这个链表:

// 简化的内部实现概念
let hooks = [];
let currentHook = 0;

function useState(initialValue) {
  const hookIndex = currentHook;
  
  // 初始化或获取已有状态
  if (hooks[hookIndex] === undefined) {
    hooks[hookIndex] = initialValue;
  }
  
  const setState = (newValue) => {
    hooks[hookIndex] = newValue;
    render(); // 触发重新渲染
  };
  
  currentHook++;
  return [hooks[hookIndex], setState];
}

这就是为什么 Hook 必须在组件顶层调用,不能在条件语句或循环中使用 - 因为 React 依赖调用顺序来匹配状态。

异步更新机制

2. 批量更新(Batching)

React 不会立即更新状态,而是将多个 setState 调用合并成一次更新:

function Counter() {
  const [count, setCount] = useState(0);
  
  const handleClick = () => {
    setCount(count + 1); // count = 0 + 1
    setCount(count + 1); // count = 0 + 1 (还是读取的旧值)
    setCount(count + 1); // count = 0 + 1
    // 最终 count = 1,而不是 3
  };
  
  return <button onClick={handleClick}>{count}</button>;
}

为什么这样设计?

  • 性能优化:避免不必要的重复渲染
  • 保持一致性:确保在一次事件处理中看到的状态是一致的

3. 函数式更新

如果需要基于前一个状态更新,使用函数形式:

const handleClick = () => {
  setCount(prev => prev + 1); // 1
  setCount(prev => prev + 1); // 2
  setCount(prev => prev + 1); // 3
  // 最终 count = 3
};

4. React 18 的自动批处理

在 React 18 之前,只有在事件处理器中才会批处理。React 18 扩展到了所有场景:

// React 18 中,这些也会被批处理
setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // 只触发一次重新渲染
}, 1000);

fetch('/api').then(() => {
  setData(newData);
  setLoading(false);
  // 只触发一次重新渲染
});

实际应用场景

场景 1: 需要立即读取更新后的值

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

const handleClick = () => {
  setCount(count + 1);
  console.log(count); // 还是旧值 0
  
  // 解决方案:使用 useEffect
  useEffect(() => {
    console.log(count); // 新值 1
  }, [count]);
};

场景 2: 依赖多个状态更新

const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);

const fetchUser = async () => {
  setLoading(true);
  const data = await api.getUser();
  setUser(data);
  setLoading(false);
  // React 会批量处理这些更新,只渲染一次
};

场景 3: 复杂状态管理

对于复杂的状态逻辑,考虑使用 useReducer:

const [state, dispatch] = useReducer(reducer, initialState);

// 一次 dispatch 可以更新多个相关状态
dispatch({ type: 'FETCH_SUCCESS', payload: data });

关键要点

  1. 状态更新是异步的 - 不要期望 setState 后立即读取新值
  2. 使用函数式更新 - 当新状态依赖旧状态时
  3. 批量更新提升性能 - React 会自动优化多次 setState
  4. 保持 Hook 调用顺序 - 不要在条件语句中使用 Hook
  5. 状态是不可变的 - 更新对象或数组时要创建新的引用

这些机制让 React 能够高效地管理组件状态,同时保持 UI 的一致性和可预测性。

用 Electron 写了一个 macOS 版本的 wallpaper(附源码、下载地址)

Mac 上一直未能找到免费且好用的类似 Wallpaper Engine 的动态壁纸软件。所以直接想着自己搞一个。

本文主要记录核心技术点的实现,包括“将窗口置于桌面图标下层”、“多显示器支持”、“系统托盘常驻”。

效果展示:

output.gif

功能支持

  • 控制中心 (Dashboard):提供独立的控制面板,可为每个显示器单独管理壁纸。
  • 丰富的媒体支持
    • 图片:支持 JPG, PNG, GIF, WebP 等常见格式。
    • 视频:支持 MP4, WebM, MKV, MOV (自动静音循环播放)。
    • HTML:支持将任意本地 HTML 文件设置为交互式壁纸。
  • 自动保存状态:应用重启后,自动恢复上次设置的壁纸配置。
  • 历史记录:自动记录最近使用的壁纸,方便快速切换回之前的喜爱内容。
  • 多显示器支持:自动检测接入的显示器,并支持多屏独立设置。
  • 在线画廊:内置在线资源库,可一键下载包括《绝区零》、《原神》、《鸣潮》等热门游戏的高清静态与动态壁纸。

核心技术实现

1. 将窗口置于桌面图标下层

这是最核心的功能。Electron 的 BrowserWindow 提供了一个 type: 'desktop' 属性,在 macOS 上会将窗口置于最底层。

const win = new BrowserWindow({
  type: 'desktop', // 关键配置:设置为桌面类型
  enableLargerThanScreen: true,
  frame: false,    // 无边框
  show: false,     // 先隐藏,加载完再显示
  webPreferences: {
    nodeIntegration: true,
    contextIsolation: false,
    webSecurity: false // 允许加载本地资源
  }
});

仅仅这样还不够,为了保证交互体验,我们需要让这个窗口无法被聚焦和移动,像真正的壁纸一样。

2. 多显示器支持

现在的开发环境通常都有多个屏幕。通过 Electron 的 screen 模块获取所有显示器,并为每个显示器创建一个独立的 BrowserWindow 实例。

const { screen } = require('electron');

const displays = screen.getAllDisplays();

displays.forEach((display) => {
  createWallpaperWindow(display);
});

function createWallpaperWindow(display) {
  const { x, y, width, height } = display.bounds;
  const win = new BrowserWindow({
    x, y, width, height, // 填满当前屏幕
    // ... 其他配置
  });
  
  // 加载资源
  win.loadFile('path/to/wallpaper.html');
}

3. 资源获取 (爬虫)

为了解决壁纸来源问题,内置了一个简单的爬虫(基于 Node.js fetch),抓取米游社等平台的同人图和官方壁纸。

主要难点在于处理接口的 Referer 校验和数据分页。

4. 甚至支持关闭主窗口后常驻后台

为了不让应用在关闭设置面板(主窗口)时直接退出,需要利用 Tray 模块和拦截 window-all-closed 事件。

// 拦截主窗口关闭事件,改为隐藏
mainWindow.on('close', (event) => {
  if (!app.isQuitting) {
    event.preventDefault();
    mainWindow.hide();
  }
});

// 实现托盘菜单
const tray = new Tray(iconPath);
const contextMenu = Menu.buildFromTemplate([
  { label: '打开面板', click: () => showMainWindow() },
  { label: '退出', click: () => {
      app.isQuitting = true;
      app.quit();
    } 
  }
]);
tray.setContextMenu(contextMenu);

构建与分发

1. 双架构打包

为了同时支持 M1/M2/M3 (Apple Silicon) 和旧款 Intel Mac,配置了 electron-builder 的 universal 构建或分别构建。

package.json 配置:

"mac": {
  "target": {
    "target": "default",
    "arch": ["x64", "arm64"]
  }
}

2. "应用已损坏,无法打开" 的解决

因为没有购买 Apple 昂贵的开发者证书进行签名,编译出的 .app 在别人的电脑上运行时会被 macOS Gatekeeper 拦截,提示“应用已损坏”。

这是一个常见问题,解决方法是移除苹果的隔离属性。用户需要执行一次终端命令:

sudo xattr -rd com.apple.quarantine /Applications/Wallpaper-Mac.app

源码与下载

项目已开源,虽然功能简单,但足以满足日常动态壁纸需求(支持视频、图片)。

欢迎 Star 和 PR。

鸿蒙开发:那些让我熬秃头的“灵异事件”

Hello,兄弟们,我是 V 哥!

咱们干鸿蒙开发的,平时是不是觉得自己像个法师?特别是刚从 Android 或者 Vue 转过来的兄弟,面对 ArkTS 这一套声明式 UI,有时候真觉得自己是在做法术。

代码写得行云流水,点个运行——啪! 白屏了。 再点一下——啪! 崩溃了。 最气人的是,有时候逻辑明明看着没问题,它就是跟你玩“薛定谔的猫”。

今天,V 哥我就把这几个月积攒的**“鸿蒙开发 Bug 悬案卷宗”**给大伙儿抖搂抖搂。这几个 Bug,当初可是折磨了 V 哥整整三天三夜,红喝了半箱,头发掉了好几把。咱们复盘一下,看看你有没有踩过这几个坑!


悬案一:人间蒸发的 UI 更新(@State 的失忆症)

📃 案发现场

那是一个月黑风高的夜晚,V 哥我在写一个列表页。数据从后端拿回来,是个 JSON 数组。我把它存到了 @State 装饰的变量里。

@State dataList: UserModel[] = [];

// 网络请求回来后
this.dataList = response.data;

逻辑没毛病吧?我看着日志,数据确实赋值进去了,长度也变了。但是!界面上死活不刷新! 就像死机了一样,哪怕我把手机屏幕戳个洞,它也不动一下。

🔍 侦破过程

V 哥我当时就懵了,难道是 ArkUI 抽风了?我开始疯狂打 Log,发现 dataList 的内存地址确实变了。

这时候,V 哥我突然想起了一句老话:鸿蒙的观察机制,有时候比前女友还难伺候。

原来啊,我在别的地方,为了图省事,直接操作了数组内部的某个属性,比如: this.dataList[0].name = 'V哥最帅';

或者我在赋值前,对数组做了一些深拷贝的操作,但拷贝得不够“彻底”。在 ArkTS 里,如果你只是修改了对象的深层属性,而没有触发对象本身的引用变化,或者嵌套对象没加 @Observed,UI 渲染引擎就会选择性失明:“哦,还是那个对象,不用动,懒得刷。”

✅ 终极解决方案

兄弟们记住了,遇到对象数组刷新,要么老老实实地整体替换引用,要么就用对装饰器!

  1. 简单粗暴法: 每次都 new 一个新数组,或者用展开符 [..., newData] 强制换个地址。
  2. 专业治本法(推荐): 你的 Model 类必须用 @Observed 装饰,然后在组件里用 @ObjectLink 去接!
@Observed
export class UserModel {
  name: string = '';
  age: number = 0;
}

// 组件里
@Component
struct UserItem {
  @ObjectLink user: UserModel; // 注意这里!
  
  build() {
    Text(this.user.name)
  }
}

用了 @ObjectLink,那叫一个丝滑,对象里哪怕改了个标点符号,界面立马跟着变!


悬案二:真机上的“幽灵点击”(事件冒泡的背刺)

📃 案发现场

为了赶进度,V 哥我写了一个复杂的列表,每个 Item 里面有个“删除”按钮,外面整个 Item 也是可以点击进入详情页的。

在模拟器上跑得好好的,点删除,删除;点 Item,跳转。V 哥我美滋滋地装到真机上测试。

结果,诡异的事情发生了:我点“删除”按钮,它不仅把数据删了,还特么给我跳到了详情页!

我都想把手机吃了,明明点的是按钮,为什么会触发父容器的点击事件?

🔍 侦破过程

刚开始以为是手机屏幕坏了,或者手指太粗。后来发现,这是典型的事件冒泡问题。

在鸿蒙的 ArkUI 里,点击事件的传递机制有时候会跟你“捉迷藏”。在模拟器上可能因为响应速度快或者渲染机制不同,不明显。但在真机上,特别是如果你手抖了一下,点击事件就会像坐火箭一样,从子组件(按钮)直接冒泡传到了父组件(ListItem),触发两次点击行为。

✅ 终极解决方案

给可能触发冲突的子组件事件里,加一句咒语,把它截胡!

Button('删除')
  .onClick((event: ClickEvent) => {
    // 你的删除逻辑...
    console.info('执行删除');
  })
  // 重点来了!加上这一句,告诉父组件:到此为止,别往上传了!
  .hitTestBehavior(HitTestMode.None) 

或者,在 onClick 的回调里根据业务逻辑判断,但在 UI 声明里,hitTestBehavior 是最物理、最有效的“结界”。加上这一行代码,世界瞬间清净了。


悬案三:模拟器是亲儿子,真机是捡来的?(资源加载的时差)

📃 案发现场

这个 Bug 简直让我怀疑人生。我在 DevEco Studio 的 Previewer 里预览,图片显示完美,动画流畅。装到华为真机上一跑——图片全是裂开的默认图!

我检查了路径,common/images/xxx.png,没错啊!权限也给了,网络也通了。为什么真机上就是加载不出来?

🔍 侦破过程

V 哥我当时盯着屏幕看了半小时,突然灵光一闪:是不是加载时机的问题?

在模拟器里,因为电脑性能强,IO 读写快,图片往往在界面渲染出来之前就已经加载好了。但在真机上,也就是个移动设备,读取本地资源文件是需要时间的。

我的代码逻辑是: Image(this.imagePath)

this.imagePath 是在 aboutToAppear() 生命周期里异步去获取并赋值的。真机渲染组件的时候,这个变量还是空的或者是初始值,等它拿到值了,Image 组件已经摆烂不渲染了。

✅ 终极解决方案

这叫“异步竞态问题”。解决方法有两个,V 哥推荐第二种。

  1. 加 Loading 占位: 用个 if 判断,数据没回来前显示个转圈圈的 Loading。
  2. 给 Image 组件加个 Key(绝招):
Image(this.imagePath)
  .objectFit(ImageFit.Cover)
  // 加上这个 key!每次 imagePath 变了,强制 Image 组件销毁重绘!
  .key(this.imagePath) 

一旦你加了 .key(this.imagePath),这就相当于告诉系统:“兄弟,路径变了,这已经不是刚才那张图了,你赶紧重新加载一下!” 这一招,对解决真机资源加载滞后、不刷新的问题,百试百灵


悬案四:WebViewController 的“黑屏诅咒”

📃 案发现场

在鸿蒙里嵌入 H5 页面很常见吧?V 哥我当时用 Web 组件加载一个第三方的 URL。

开发阶段一切正常。结果到了测试环境,页面偶尔一进去就是黑屏,啥也没有,控制台还不报错! 简直就是见了鬼。

🔍 侦破过程

这种不报错的 Bug 最难搞。后来 V 哥我发现,这跟 H5 页面的加载速度和 Web 组件的初始化有关。

当 Web 组件还没完全准备好,或者 H5 页面内部 JS 执行出错卡住了,鸿蒙这边的 Web 内核有时候就会“死机”,呈现一片死寂的黑色。

✅ 终极解决方案

咱们得像带孩子一样,盯着它!

  1. 监听生命周期: 必须配合 onPageEndonError 事件。
  2. 注入诊断脚本: 在 H5 加载前,注入一段 JS 去探活。
Web({ src: this.url, controller: this.controller })
  .onPageEnd(() => {
    // 页面加载结束了,如果还是黑屏,说明出问题了
    console.info("页面加载结束");
  })
  .onErrorReceive((event) => {
    // 捕获错误
    console.error("Web加载失败: " + event.getError().toString());
    // 这里可以弹个窗,或者加载一个本地错误的 HTML
    this.controller.loadUrl('resource:///rawfile/error.html');
  })
  .domStorageAccess(true)

最关键的一招:不要在 Controller 没初始化完成的时候就急着 loadUrl。如果你是在 aboutToAppear 里初始化 Web 组件,最好延时个几百毫秒,或者确保 Controller 实例化完毕再操作。给它一点喘息的时间,黑屏就消失了。


V 哥总结陈词

兄弟们,这就是 V 哥亲测的鸿蒙开发四大“悬案”。

其实总结下来,鸿蒙开发虽然新,但万变不离其宗:

  1. 状态管理要搞清引用关系(Observed/ObjectLink 用起来)。
  2. 事件传递要防冒泡(hitTestBehavior 设起来)。
  3. 真机性能要考虑时差(Key 和 Loading 加起来)。
  4. 混合开发要做好容错(生命周期监听起来)。

遇到 Bug 别慌,别砸键盘,更别怀疑人生。把这些坑踩平了,你就是鸿蒙圈里的老司机!

*我是V哥,关注我,一起搞鸿蒙呀!手搓了三本鸿蒙教材,学完即可体系化掌握鸿蒙开发。 *

从vue3 watch开始理解Vue的响应式原理

从vue3 watch开始理解Vue的响应式原理

前言

vue源代码的内容非常之多,包括模板解析(把vue文件的写法解析成一个函数用来插入dom或者调整dom),依赖收集,响应式,事件处理,插槽,组件,指令,等等等。刚开始看时不知道从何看起,这里分享一个个人的学习思路,我们可以从一些关键的api开始逐步学习,这里推荐 reactive,watch。明白了这两个api时如何工作的,那vue的响应式系统就基本理解了。

let`s start

// demo.js
import { reactive, watch } from './vue-core.js'
const user = reactive({
  name: 'Alice',
  age: 25
})
watch(user, (newValue, oldValue) => {
  console.log(
    `User changed from ${oldValue.name},${oldValue.age} to ${newValue.name},${newValue.age}`
  )
})
setTimeout(() => {
  user.name = 'Bob'
  user.age = 30
}, 1000)

朴素的示例,如果user发生了变化。那么就会触发watch的回调。脱离vue框架,我们需要如何设计呢,首先想到的简单逻辑就是拦截user的set和get。无论是通过Proxy还是Object的set,原理是一样的。

let activeSub = null // 当前活跃的订阅者
const targetMap = new WeakMap()

export function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      track(target, key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      const res = Reflect.set(target, key, value, receiver)
      trigger(target, key)
      return res
    }
  })
}
// 简化版watch实现,只考虑监听一个reactive对象(类似上面的user例子)的变化
export function watch(source, cb) {
  const getter = () => {
    for (const key in source) {
      source[key]
    }
    return source
  }
  let oldValue
  const effect = new ReactiveEffect(getter)
  const job = () => {
    const newValue = effect.run()
    //todo 对比newValue和oldValue是否有变化
    cb(newValue, oldValue)
    oldValue = newValue
  }
  effect.schedule = job
  oldValue = effect.run()
  console.log('old', oldValue)
}

// 示例解释:搜集user中每个key的下游依赖,当user的name或age变化时,触发对应的依赖更新,从而调用watch的回调函数。
function track(target, key) {
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = new Dep()))
  }
  dep.track()
}
function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  const dep = depsMap.get(key)
  if (dep) {
    dep.trigger()
  }
}

function Dep() {
  this.subs = new Set()
  this.track = () => {
    // 借助全局变量activeSub记录当前活跃的订阅者
    if (activeSub) {
      this.subs.add(activeSub)
    }
  }
  this.trigger = () => {
    this.subs.forEach((sub) => {
      sub.notify()
    })
  }
}

function ReactiveEffect(fn) {
  this.fn = fn
  this.notify = null // 回调函数, 可类比于watch的cb用来辅助理解
  this.schedule = null
  this.run = () => {
    // 标记当前活跃的订阅者为this 通过fn触发每个ref或者reactive对象的getter
    // 从而触发track函数,将当前活跃的订阅者this添加到dep的subs中
    const preActiveSub = activeSub
    activeSub = this
    const value = this.fn()
    activeSub = preActiveSub
    return value
  }
  // 上游的ref或者reactive对象触发setter时,会通过收集者调用trigger函数,从而触发schedule函数,在watch中就是cb函数
  this.trigger = () => {
    this.schedule?.()
  }
  this.notify = () => {
    this.trigger()
  }
}


从上面的demo中我们初步实现了user变动时调用一个回调,deps表示“依赖”,通俗的理解就是有多少个下游正在依赖user,例如computed,watch,render函数等等这些的最终结果都是根据上游来变动的。

3a7131ea44dd9bfb4726e7f3af8f988ce8045f8de77b904d636c9d9b0ff74207.png

这里面其实就两个概念需要理解,一个是依赖收集者 Dep,一个是响应副作用管理器 ReactiveEffect。借助例子,Dep 理解为用来管理user中每个key都有哪些watch正在监听。而 ReactiveEffect 理解为当user的某个key发生变化时,需要调用哪些watch的回调函数。

调用watch的过程其实关注的就是 oldValue = effect.run() 这一段代码。在创建watch时,会立即调用effect.run(),从而触发user的getter,从而触发track函数,将当前活跃的订阅者effect添加到user.name和user.age的dep中。这样,当user.name或user.age发生变化时,就会触发trigger函数,从而调用effect的notify函数,从而调用watch的回调函数。

组件渲染其实就是一个特殊的watch,watch的回调函数就是渲染函数。当user的name或age发生变化时,就会触发渲染函数,从而更新dom。

🚀 React Router 7 + Vercel 部署全指南

一、 开发环境准备 (本地)

  1. 推荐包管理器:使用 pnpm 替代 npm 以获得更快的构建速度和更清晰的依赖管理。

如果已经使用npm构建,那么先删掉node_modules文件夹,安装pnpm

pnpm install
  1. Node.js 版本限制

package.json 中显式指定:

    • JSON
"engines": { "node": "20.x" }
  1. 静态模式配置 (SPA)

若不需要 SSR(服务端渲染),在 react-router.config.ts 中设置:

    • TypeScript
export default { ssr: false } satisfies Config;
  1. 关联github及vercel

本地代码提交到github,同时登录vercel平台,将vercel与github关联上,后续你在本地改动了代码并提交到github后,访问vercel平台的项目域名就能看到改动的内容了


二、 静态资源与图标 (Icon)

  1. 文件存放:所有的图片、SVG 图标必须放在根目录的 public/ 文件夹下。
  2. 页面引入

app/root.tsxlinks 函数中配置:

    • TypeScript
export const links: LinksFunction = () => [
  { rel: "icon", type: "image/svg+xml", href: "/logo.svg" },
];

三、 Vercel 部署关键点 (最重要)

如果你遇到 now-phpFunction Runtimes 报错,请检查以下配置:

vercel.json 配置(强制静态重写,防止 404):

  1. JSON
{
  "framework": "vite",
  "rewrites": [{ "source": "/(.*)", "destination": "/" }]
}
  1. Vercel 后台设置
    • Framework Preset: 优先选择 Vite;如果被锁定为 React Router,请确保手动开启 Build and Output Settings
    • Build Command: pnpm build
    • Output Directory: dist(如果在脚本中手动移动了产物)或 build/client
    • Install Command: pnpm install

四、 自定义域名

  1. 修改 Vercel 默认域名
    • Settings -> Domains -> Edit,可以直接修改 xxx.vercel.app 的前缀。
  1. 绑定独立域名
    • Domains 页面输入你购买的域名。
    • DNS 配置
      • A 记录:指向 76.76.21.21
      • CNAME 记录:指向 cname.vercel-dns.com

💡 核心避坑心得

  • 清理缓存:部署报错时,尝试删除 Vercel 项目重新导入,并确保 pnpm-lock.yaml 是最新的。
  • 路径大小写:Vercel 的 Linux 环境对文件名大小写敏感,确保 import 路径与文件名完全一致。

GitHub Fork 协作完整流程

GitHub Fork 协作完整流程

适用于:开源项目二次开发、插件扩展、给上游项目提交 PR(Pull Request)


一、Fork 是什么?

Fork 是把别人的 GitHub 仓库复制一份到你自己的账号下:

原仓库(upstream) → 你的仓库(origin)

你在自己的仓库里可以随意修改代码,而不会影响原项目。


二、整体协作流程

flowchart TD
    A[原项目 GitHub 仓库] -->|Fork| B[你自己的 GitHub 仓库]
    B -->|git clone| C[你的本地仓库]
    A -->|git fetch upstream| C
    C -->|git merge upstream/main| C
    C -->|git push origin| B
    B -->|Pull Request| A

三、Fork 项目(网页操作)

  1. 打开你要 Fork 的仓库,例如:

    https://github.com/vercel/next.js
    
  2. 点击右上角 Fork

  3. 选择你的 GitHub 账号

  4. 得到你的仓库:

    https://github.com/你的用户名/next.js
    

四、Clone 到本地

git clone https://github.com/你的用户名/next.js.git
cd next.js

五、配置 upstream(非常重要)

查看当前远程仓库:

git remote -v

你会看到:

origin https://github.com/你的用户名/next.js.git

添加原仓库:

git remote add upstream https://github.com/vercel/next.js.git

再确认:

git remote -v

应该是:

origin   你自己的仓库
upstream 原项目仓库

六、同步原项目最新代码(标准流程)

git checkout main
git fetch upstream
git merge upstream/main
git push origin main

含义:

用原作者的 main 更新你的 main,并推送到你自己的 GitHub


七、开发你的功能

git checkout -b my-feature
# 修改代码
git add .
git commit -m "Add my feature"
git push origin my-feature

八、提交 Pull Request

  1. 打开你的 GitHub 仓库

  2. GitHub 会提示:

    Compare & Pull Request
    
  3. 点击并填写说明

  4. 提交 → 原项目维护者审核


九、为什么必须使用 upstream?

远程名 作用
origin 你自己的 GitHub 仓库
upstream 原作者的 GitHub 仓库

你只向 origin push,但要从 upstream 获取最新版本。


十、fetch vs merge 的正确用法

sequenceDiagram
    participant U as GitHub upstream
    participant L as Local Repo
    participant O as GitHub origin

    U->>L: git fetch upstream
    L->>L: upstream/main 更新
    L->>L: git merge upstream/main
    L->>O: git push origin main
  • fetch = 下载真实世界
  • merge = 使用真实世界

永远不要跳过 fetch。


十一、错误示例(不要这样)

git merge upstream/main   # ❌ 如果你没先 fetch

你合并的可能是 几个月前缓存的 upstream,非常危险。


十二、最佳实践总结

Fork 项目的生存法则

只向 origin 写代码  
只从 upstream 读更新  
fetch 在前,merge 在后

Vite+Antd+Micro-app中iframe模式下样式闪烁的问题

Vite中使用Micro-app进行微前端开发,做的过程中发现在iframe模式下,无论是首屏加载还是中间的路由切换都会出现antd样式闪烁;具体如下:

  1. 按钮的样式从最初始状态变为AntdButton样式,有颜色改变时,最为明显
  2. 输入框由input初始样式变为Antd的样式
  3. 所有的Antd组件都是从浏览器的默认样式加载为Antd的样式

解决历程

  1. 既然是样式后加载,那么是否可以进行样式预设,当Antd样式加载时,就不会出现明显的闪烁
// index.html
<!-- iframe 模式:预设关键样式,避免 FOUC -->
    <style>
      /* 预设主题色,避免按钮颜色闪烁 */
      .ant-btn-primary {
        background-color: #7470e9;
        border-color: #7470e9;
        color: #fff;
      }

      .ant-input {
        height: 40px;
        font-size: 16px;
        padding: 6.5px 11px;
        border-width: 1px;
        border-style: solid;
        border-color: #d9d9d9;
        border-radius: 6px;
      }

      .ant-btn {
        border-width: 1px;
      }
    </style>

解决效果:肉眼可见的闪烁没有了,但是缺点也很明显,就是需要把用到的所有的Antd组件的基础样式或者叫引起明显抖动的样式预设,避免明显抖动

  1. 仿照AntdNext.js做的@ant-design/nextjs-registry库,来解决提前加载样式的问题

@ant-design/nextjs-registry是如何解决在Next.js中首屏加载时闪烁的问题,是在Next.js渲染组件时,使用@ant-design/cssinjs提供的const styles = extractStyle(cache, true)能力

'use client'
const AntdRegistry = ({ children }) => {
  useServerInsertedHTML(() => {
    // 在服务端渲染时执行
    const styleText = extractStyle(cache, true);
    return <style dangerouslySetInnerHTML={{ __html: styleText }} />;
  });

  return <StyleProvider cache={cache}>{children}</StyleProvider>;
};

将含AntdHTML代码全部发给浏览器,这样渲染的时候就不会出现样式闪烁,但是这个是Next.js专属能力CSR(客户端渲染)的做不到,因为Antd的组件样式是在加载组件时才会放到cache中,也就是说在App.tsx中使用下面的代码包括,是没有用的

useEffect(() => {
    // 在客户端挂载时执行
    const styleText = extractStyle(cache, true);
    const styleElement = document.createElement('style');
    styleElement.textContent = styleText;
    document.head.insertBefore(styleElement, document.head.firstChild);
  }, []);

这个代码在执行时styleText啥也没有,加载组件在useEffect之后,此时我们已经很接近真相了

  1. 我发现一个现象,在SPA页面中,样式只会在首屏时闪烁,进入其他页面后,并没有闪烁,已加载的样式被缓存了,查询之后,发现是Antdcache在做缓存判断,然后我查看了子应用的样式发现Micro-app给每个样式否加了前缀micro-app[name=hospital-gateway]
micro-app[name=hospital-gateway] ._hospital-search-wrapper_v077x_1 ._search-operate_v077x_5 button {
    width: 100px;
    margin-left: 8px;
}

这个前缀导致了Antdcache失效,而且每次路由切换时,Micro-app都会将Antd原来的样式重写为有micro-app[name=hospital-gateway]前缀的样式,这就是导致每次样式抖动的根本原因。

  1. 找到根源后,就看如何去掉这个前缀,iframe模式下,样式是天然隔离的,是不需要类似with模式下的样式前缀的,而且去掉前缀,也可以启用Antd的样式缓存,避免切换路由时的样式抖动,最后发现在添加disable-scopecss属性就可以去掉自动添加的前缀
<micro-app name={name} url={url} baseroute keep-alive disable-scopecss />

保姆级教程:让 Cursor 编辑器突破地区限制,正常调用大模型(附配置 + 截图)

Cursor 的大模型功能目前暂不支持中国地区直接访问,导致很多开发者打开后显示 “模型加载失败”。今天分享亲测有效的配置方法,帮你突破地区限制,丝滑使用 Opus、GPT 等模型~

一、准备工作

先确保你的代理工具(如 Clash、V2ray 等)已正常运行,且本地代理端口为 7897(若你的代理端口不同,后续配置需对应修改)。

二、配置 Cursor 代理(带步骤图)

步骤 1:打开 Cursor 设置界面

  • 点击 Cursor 左上角「文件 (F)」菜单
  • 鼠标移到「选项」,在子菜单中选择「设置」(快捷键:Ctrl+,)(对应步骤图 1 的「文件」→「选项」→「设置」)

也可以直接点右上角「...」按钮,选「打开设置 (json)」(对应步骤图 2)

image.png

步骤图1

img_v3_02tp_919fb41f-2117-4098-b521-a64df7ce741g.jpg

步骤图2

步骤 2:添加地区解锁配置

settings.json中粘贴以下代码(直接复制即可,对应步骤图 3 红框区域,注意 JSON 格式的逗号分隔)

"http.proxy": "http://127.0.0.1:7897",
"http.proxySupport": "override",
"http.noProxy": [],
"cursor.general.disableHttp2": true,
"http.proxyStrictSSL": false

image.png

步骤图3

保存配置,此时点击模型选择菜单(步骤图 4),就能正常加载 Opus、GPT-4 等模型了~

img_v3_02tp_2e44b001-fe13-41cb-8ca6-b4321679ac5g.jpg

什么是MessageChannel

什么是MessageChannel

MessageChannel允许我们在不同的浏览上下文,比如window.open()打开的窗口或者iframe等之间建立通信管道,并通过两端的端口(port1和port2)发送消息。MessageChannel以DOM Event的形式发送消息,所以它属于异步的宏任务。


以下是 MessageChannel(端口通信) 的完整示例代码,清晰展示 port1port2 的双向通信特性,可直接复制到在线 HTML 编辑器(如 CodePen、JSFiddle)中执行:

核心:

  • port1.start(); port2.start();
  • port1.postMessage(...)port2.addEventListener('message', (e) => {...});
  • port2.postMessage(...)port1.addEventListener('message', (e) => {...});

核心概念与代码说明

1. MessageChannel 本质

MessageChannel 是浏览器提供的 双向通信机制,创建后会自动生成两个关联的端口:

  • port1port2成对存在 的,相互绑定
  • 数据通过 postMessage 发送,通过 message 事件接收
  • 通信是 双向的:port1 可给 port2 发,port2 也可给 port1 发

2. 关键 API 解析

API 作用
new MessageChannel() 创建通信通道,生成 port1port2
port.postMessage(data) 发送数据(data 可是字符串、对象等)
port.addEventListener('message', e => {}) 监听接收消息(e.data 是收到的数据)
port.start() 启用端口(必须调用,否则无法接收消息)

3. 运行效果

  • 在左侧 Port1 输入框输入内容,点击“发送到 port2”,右侧 Port2 会收到消息
  • 在右侧 Port2 输入框输入内容,点击“发送到 port1”,左侧 Port1 会收到消息
  • 日志区域会实时显示发送/接收记录,包含时间戳

4. 实际应用场景

  • iframe 跨域通信:父页面和子 iframe 可通过 port 传递数据(无需处理跨域限制)
  • Web Worker 通信:主线程和 Worker 线程的双向数据交互(比 postMessage 原生用法更清晰)
  • 组件间通信:复杂应用中,无直接关联的组件可通过 MessageChannel 解耦通信

注意事项

  1. port1port2一一对应 的,不能混用其他端口
  2. 必须调用 port.start() 启用通信(Worker 环境中可省略,因为 onmessage 会自动启用)
  3. 发送的数据会被结构化克隆算法序列化,支持大部分数据类型(如对象、数组、日期等),但不支持函数、DOM 元素等

在 Web Worker 通信中使用 MessageChannel

可以实现更灵活的双向通信,尤其适合需要在多个上下文(主线程与 Worker 或多个 Worker 之间)建立独立通信通道的场景。以下是具体使用方法:

核心原理

MessageChannel 会创建两个相互关联的 MessagePort(端口):port1port2。发送到 port1 的消息会被 port2 接收,反之亦然。通过将其中一个端口传递给 Worker,即可建立主线程与 Worker 之间的专属通信通道。

步骤示例

1. 主线程代码(main.js)

// 创建消息通道
const channel = new MessageChannel();
const { port1, port2 } = channel;

// 创建 Worker
const worker = new Worker('./worker.js', [port2]);

// 初始化消息发送, 向 Worker 发送 port2(需通过 postMessage 传递,且标记为可转移)
worker.postMessage({ type: 'init' }, [port2]);
// worker.onmessage = (msg) => { console.log('[worker -> msg: ', msg); };
// worker.onerror = (error) => { console.error('[worker -> error:', error); };

// 端口消息处理, 监听 port1 接收的消息(来自 Worker 的 port2)
port1.onmessage = (msg) => {
  console.log('[主线程收到: port2 + worker] -> port1 信息: ', msg.data);
  if (msg && msg?.data !== '') {
    // 可通过 port1 向 Worker 发送消息
    port1.postMessage({ type: 'info', data: 'hello friend!' });
  }
};

port1.onmessageerror = (error) => {
  console.error(error);
};

// 关键:页面卸载清理:在 window.onbeforeunload 中触发资源清理,确保页面关闭时释放资源
function cleanupResources() {
  console.log('------ cleanupResources -----')
  
  // 1. 先向 Worker 发送终止指令,触发其内部的 terminate 处理
  port1.postMessage({ type: 'terminate' });

  // 2. 关闭端口,移除事件监听器
  port1.close(); // 端口关闭:通过 port.close() 释放 MessageChannel 端口,避免事件监听器残留
  port2.close();

  // // 事件解绑:显式将 onmessage、onerror 设为 null,移除引用
  port1.onmessage = null;
  port1.onmessageerror = null;

  port2.onmessage = null;
  port2.onmessageerror = null;

  // 3. 终止 Worker 线程
  if (worker) {
    // Worker 终止:使用 worker.terminate()(主线程)或 self.close()(Worker 内部)终止线程
    // 终止 Worker 线程(浏览器环境中 terminate 是同步方法,无返回值)
    if (worker) {
      worker.terminate(); // 直接调用,无需 catch
    }
  }
}

// 页面卸载前清理
window.onbeforeunload = () => {
  cleanupResources();
};

// 如需主动终止(例如按钮点击),可调用 cleanupResources()

2. Worker 线程代码(worker.js)

let workerPort;

self.onmessage = function(event) {
  const { data, ports } = event;
  if (data.type === 'init') {
    workerPort = ports[0];
    workerPort.onmessage = handlePortMessage;
    workerPort.onerror = (error) => {
      console.error('worker port2: ', error);
    };

    workerPort.postMessage('ok port2 与 worker 握手了🤝');
  }
};

// 处理端口消息
function handlePortMessage(event) {
  const { type, data } = event.data;
  console.log('type = ', type, ', workerPort = ' ,workerPort);
  switch (type) {
    case 'info':
      if (data) {
        console.log('port1.postMessage -> [port2 in worker] 消息: ', data);
      }
      break;

    // 可添加终止指令处理
    case 'terminate':
      if (workerPort) {
        workerPort.close(); // 关闭端口
        workerPort.onmessage = null;
        workerPort.onerror = null;
      }

      self.close(); // 关闭 Worker 自身
      break;
  }
}

// 监听 Worker 错误
self.onerror = (err) => {
  console.error('Worker 错误:', err);
};

3. 测试页面代码 (test.html)

<!DOCTYPE html>
<html>
  <head>
    <title>Test MessageChannel + Worker + html</title>
    <script type="text/javascript" src="main.js"></script>
    <script type="text/javascript">
      window.onload = function(){
        console.log(typeof cleanupResources, '~~~~');
      }
    </script>
  </head>
  <body>
    <button onclick="cleanupResources()">CLEAR</button>
  </body>
</html>

关键说明

  1. 端口传递
    必须通过 postMessage 的第二个参数(transferList)传递 MessagePort,且传递后原上下文将失去该端口的控制权(端口被转移)。

  2. 通信方向

    • 主线程通过 port1 发送消息,Worker 通过 port2 接收;
    • Worker 通过 port2 发送消息,主线程通过 port1 接收。
  3. 多通道支持
    可创建多个 MessageChannel 实现并行通信(如不同功能模块使用独立通道)。

  4. 关闭通道
    通信结束后可调用 port1.close()port2.close() 释放资源。

优势

相比 Worker 自带的 postMessage 通信,MessageChannel 更适合:

  • 分离不同类型的通信(如业务数据、控制指令);
  • 实现多对多通信(多个 Worker 之间通过端口转发消息);
  • 避免消息混杂导致的逻辑混乱。

通过上述方式,即可利用 MessageChannel 在 Web Worker 中实现高效、隔离的双向通信。

addEventListener('message') 方式示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>MessageChannel port1 & port2 示例</title>
  <style>
    .container {
      display: flex;
      gap: 20px;
      margin: 20px;
    }
    .panel {
      flex: 1;
      padding: 20px;
      border: 1px solid #ddd;
      border-radius: 8px;
    }
    h3 {
      margin-top: 0;
      color: #333;
    }
    button {
      padding: 8px 16px;
      margin-top: 10px;
      cursor: pointer;
    }
    .log {
      margin-top: 15px;
      padding: 10px;
      background: #f5f5f5;
      border-radius: 4px;
      height: 150px;
      overflow-y: auto;
      font-size: 14px;
    }
  </style>
</head>
<body>
  <h2>MessageChannel 双向通信演示</h2>
  <div class="container">
    <!-- Port1 通信面板 -->
    <div class="panel">
      <h3>Port1 发送区</h3>
      <input type="text" id="port1Input" placeholder="输入要发送给 port2 的内容">
      <button id="port1SendBtn">发送到 port2</button>
      <div class="log" id="port1Log">
        <p>📥 Port1 接收日志:</p>
      </div>
    </div>

    <!-- Port2 通信面板 -->
    <div class="panel">
      <h3>Port2 发送区</h3>
      <input type="text" id="port2Input" placeholder="输入要发送给 port1 的内容">
      <button id="port2SendBtn">发送到 port1</button>
      <div class="log" id="port2Log">
        <p>📥 Port2 接收日志:</p>
      </div>
    </div>
  </div>

  <script>
    // 1. 创建 MessageChannel 实例(自动生成 port1 和 port2 两个端口)
    const channel = new MessageChannel();
    const { port1, port2 } = channel; // 解构出两个端口

    // 2. 工具函数:添加日志到指定面板
    function addLog(logElement, content) {
      const p = document.createElement('p');
      p.textContent = `[${new Date().toLocaleTimeString()}] ${content}`;
      logElement.appendChild(p);
      logElement.scrollTop = logElement.scrollHeight; // 自动滚动到底部
    }

    // 3. Port1 接收消息(监听 port1 的 message 事件)
    port1.addEventListener('message', (e) => {
      addLog(document.getElementById('port1Log'), `收到 port2 的消息:${e.data}`);
    });

    // 4. Port2 接收消息(监听 port2 的 message 事件)
    port2.addEventListener('message', (e) => {
      addLog(document.getElementById('port2Log'), `收到 port1 的消息:${e.data}`);
    });

    // 5. 关键:启用端口通信(必须调用 start(),否则无法接收消息)
    port1.start();
    port2.start();

    // 6. Port1 发送消息按钮事件
    document.getElementById('port1SendBtn').addEventListener('click', () => {
      const input = document.getElementById('port1Input');
      const message = input.value.trim();
      if (message) {
        port1.postMessage(message); // 通过 port1 发送消息(port2 接收)
        addLog(document.getElementById('port1Log'), `发送到 port2:${message}`);
        input.value = ''; // 清空输入框
      }
    });

    // 7. Port2 发送消息按钮事件
    document.getElementById('port2SendBtn').addEventListener('click', () => {
      const input = document.getElementById('port2Input');
      const message = input.value.trim();
      if (message) {
        port2.postMessage(message); // 通过 port2 发送消息(port1 接收)
        addLog(document.getElementById('port2Log'), `发送到 port1:${message}`);
        input.value = ''; // 清空输入框
      }
    });

    // 可选:支持按 Enter 键发送
    document.getElementById('port1Input').addEventListener('keydown', (e) => {
      if (e.key === 'Enter') document.getElementById('port1SendBtn').click();
    });
    document.getElementById('port2Input').addEventListener('keydown', (e) => {
      if (e.key === 'Enter') document.getElementById('port2SendBtn').click();
    });
  </script>
</body>
</html>

第10章 SSE魔改

SSE(Server-Sent Events,服务器推送事件) 是一种基于标准HTTP协议的服务器到客户端的单向数据流技术。它允许服务器在建立初始连接后,通过一个持久的HTTP连接主动、连续地向客户端推送数据更新,而无需客户端重复发起请求。其核心机制是客户端使用 EventSource API 连接到指定端点后,服务器以 text/event-stream 格式持续发送事件流,每个事件由标识类型(event:)、数据(data:)和可选ID组成,客户端通过监听事件类型来实时处理数据,连接中断时还会借助最后接收的ID自动尝试重连。

与需要双向通信的WebSocket相比,SSE的典型优势在于协议轻量、天然支持自动重连与断点续传,且无需额外协议升级。它非常适合服务器主导的实时数据分发场景,如股市行情推送、新闻直播、社交媒体动态、任务进度通知等,浏览器兼容性广泛。但需要注意的是,SSE是单向通道(服务器→客户端),且主流实现中传输格式限于文本(二进制数据需编码),若需双向实时交互则仍需选择WebSocket。

通过以上对SSE的解释,我们可以想到现如今非常经典的例子,AI网站中输出文字的打字机效果(例如DeepSeek),一个字一个字的往外输出,这也是一种SSE。前端给后端发送一次消息,而后端可以给前端一直发消息。

10.1 初始化项目

我们采用Express来模拟SSE,因此需要如下3个步骤来初始化项目:

(1)创建index.html和index.ts文件。

(2)安装express和对应的声明文件,并引入index.ts文件中。

(3)安装cors和对应的声明文件,并引入index.ts文件中。

两个index文件用于展示效果以及编写SSE逻辑。

// 安装express
npm i express
// 安装 CORS(跨域资源共享中间件)
npm install cors
// 安装 Express 的 TypeScript 类型定义
npm install --save-dev @types/express
// 安装 CORS 的 TypeScript 类型定义
npm install --save-dev @types/cors

// 一次性安装所有依赖
npm install express cors @types/express @types/cors

对应的package.json文件中,将type字段设置为module,从而可以使用import引入写法。

// package.json
{
  "type": "module",
  "dependencies": {
    "@types/cors": "^2.8.19",
    "@types/express": "^5.0.6",
    "cors": "^2.8.5",
    "express": "^5.2.1"
  }
}

在index.ts文件使用ES模块语法引入依赖模块express和cors后,创建Express应用实例的app对象,然后在app对象中,通过use()方法挂载必要的全局中间件cors()和express.json(),用于处理跨域资源共享以及解析请求体中格式为JSON的数据。

// index.ts
import express from "express";
import cors from 'cors'

const app = express()
// 处理跨域
app.use(cors())
// 解析请求体中格式为JSON的数据
app.use(express.json())

最后,启动Express服务器,监听3000端口,完成SSE的项目初始化。

app.listen(3000, () => {
    console.log("Server is running on port 3000");
});

10.2 SSE逻辑实现

SSE要求接口必须是一个get请求,因此我们来定义一个get请求。

SSE的核心代码只有一行,将Content-Type设置为text/event-stream。通过将HTTP响应的内容类型明确声明为事件流格式,通知客户端(通常是浏览器)本次连接并非普通的请求-响应交互,而是一个需要保持开启、持续接收服务器推送事件的长连接通道。浏览器接收到这个特定头部后,会启动其内建的SSE处理机制(EventSource API),自动保持连接活性并准备以流式方式解析后续传入的数据。

返回数据的格式一定要遵循data: {实际数据}\n\n的形式。

// index.ts
app.get('/chat', (req, res) => {
  res.setHeader("Content-Type", "text/event-stream"); // 返回SSE
  // 不缓存
  res.setHeader("Cache-Control", "no-cache");
  // 持久化连接
  res.setHeader("Connection", "keep-alive");
  // 定时器,每秒返回一次时间,模拟后端连续地向客户端推送数据更新
  setInterval(() => {
    res.write(`data: ${new Date().toISOString()}\n\n`);
  }, 1000);
});

完成后端SSE的逻辑之后,前端需要如何接受后端传递过来的数据?通过浏览器内置的 EventSource API 来建立连接并接收后端SSE事件流数据就可以。

// index.html
const sse = new EventSource("http://localhost:3000/chat");
sse.onmessage = (event) => {
console.log(event.data);
};

此时启动后端服务器,打开index.html页面的控制台看流式输出时间,即前端可以实时接收后端返回的数据,如图10-1所示。

image-20251219033233783

图10-1 流式输出时间

此时打开网络选项卡,输出效果如图10-2所示。chat接口的EventStream会不断的接收message类型的消息,并展现对应的数据。

image-20251219033452742

图10-2 网络选项卡展示输出效果

10.3 SSE设置post请求

但一般在实际的项目中,是不会使用EventSource的,因为它必须是一个get请求,而在工作中经常使用的是post请求。那面对这种冲突的情况,应该如何去做?

如果我们只是在后端简单的将get请求直接改成post请求,然后重启服务去看效果的话,是无法生效的。

// index.ts
app.post('/chat', (req, res) => {
  //  省略...
});

chat接口修改成post请求如图10-3所示。请求方法依然为GET,网络状态则是404。

image-20251219034035386

图10-3 chat接口修改成post请求

面对后端chat接口修改为post请求不起效果的情况,我们只能在前端去魔改方案,不使用EventSource API来建立连接并接收后端SSE事件流数据。

我们在前端使用fetch()去接收chat接口返回的数据,此时从浏览器的的网络选项卡可以看到接通了,并且从响应选项中会不断打印出时间数据。

fetch("http://localhost:3000/chat", {
  headers: {
    "Content-Type": "application/json",
  },
  method: "POST",
  body: JSON.stringify({ message: "Hello, world!" }),
})
  .then(async response => {
    const reader = response.body.getReader(); // 获取流
    const decoder = new TextDecoder(); // 解码ASCII码值
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      console.log(value) // value是ASCII码
      const text = decoder.decode(value, { stream: true });
      console.log(text);
    }
  })
let a = [1]
let b = a[Symbol.iterator]();
console.log(b.next());
console.log(b.next());

现如今基本上都是通过fetch去魔改实现,将get请求修改成post请求也能传输数据。目前为止,没有更好的解决方法了。

在这段魔改的代码中,我们做了什么?

首先是设置请求头接收的内容类型以及请求方式。当接收到数据时,通过getReader()方法获取流,获取流得到的是一个Promise,因此需要通过await操作符去等待Promise兑现并获取它兑现之后值,通过read()方法去读取数据中的每一个流。

此时每一个流返回的是一个迭代器,迭代器是一个对象,内部有一个next()方法,该方法返回具有两个属性的对象:

(1)value:迭代序列的下一个值。

(2)done:如果已经迭代到序列中的最后一个值,则它为 true。如果 value 和 done 一起出现,则它就是迭代器的返回值。

所以迭代器对象可以通过重复调用next()方法显式地迭代。在while循环中持续调用reader.read()方法,这个方法返回的Promise在每次兑现时都提供一个类似迭代器next()方法的对象——包含value(当前数据块)和done(流是否结束)两个属性。通过循环判断done是否为false,我们可以持续读取Uint8Array格式的数据块,然后使用TextDecoder将其解码为可读文本,实现了对服务器推送数据流的实时逐块处理。

这种显式迭代的核心优势在于按需、增量地处理数据,避免了等待整个响应体完全到达才能开始处理。每次调用reader.read()都明确请求下一个数据块,直到done变为true表示流已结束。这与传统的一次性接收完整响应形成对比,特别适合处理SSE这种持续、长时间的数据流连接,确保了在处理服务器实时推送时内存使用的高效性和响应的即时性。

Vue3响应式API-reactive的原理

这段代码实现了一个简化版的响应式系统(类似 Vue 3 的 reactive),包含依赖收集和触发更新的核心机制。让我详细讲解每个部分:

一、核心结构

1. reactive 函数

function reactive<T extends object>(target: T) {
  return createReactiveObject(target);
}
  • 入口函数,接收一个普通对象
  • 返回该对象的响应式代理
  • 使用泛型 T extends object 确保只能处理对象类型

2. createReactiveObject 函数

创建 Proxy 代理对象的核心函数:

function createReactiveObject<T extends object>(target: T) {
  const handler = {
    // get 拦截器
    get(target: object, key: keyof object, receiver: () => void) {
      const result = Reflect.get(target, key, receiver);
      track(target, key);  // 依赖收集
      if (typeof result === "object" && result !== null) {
        return createReactiveObject(result);  // 深度响应式
      }
      return result;
    },
    
    // set 拦截器
    set(target: object, key: keyof object, value: unknown, receiver: () => void) {
      const oldValue = target[key];
      const result = Reflect.set(target, key, value, receiver);
      if (oldValue !== value) {
        trigger(target, key);  // 触发更新
      }
      return result;
    },
  };
  return new Proxy(target, handler);
}

关键点:

  • 深度响应式:当访问的属性值是对象时,递归创建响应式代理
  • 惰性转换:只有在访问嵌套对象时才进行响应式转换
  • Reflect API:使用 Reflect.get/set 确保正确的 this 绑定

二、依赖管理系统

1. 存储结构

const targetMap = new WeakMap<object, Map<string, Set<Function>>>();
let activeEffect: null | Function = null;

结构说明:

WeakMap
  key: 原始对象 (target)
  value: Map
    key: 属性名 (string)
    value: Set<Function>  // 依赖该属性的 effect 集合

为什么用 WeakMap?

  • 键是对象,不影响垃圾回收
  • 当原始对象不再使用时,对应的依赖关系会自动清除

2. track - 依赖收集

function track(target: object, key: string) {
  if (!activeEffect) return;  // 没有 activeEffect 时不收集
  
  // 获取或创建 target 对应的 depsMap
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()));
  }
  
  // 获取或创建 key 对应的 dep(effect 集合)
  let dep = depsMap.get(key);
  if (!dep) {
    depsMap.set(key, (dep = new Set()));
  }
  
  dep.add(activeEffect);  // 将当前 effect 加入依赖集合
}

3. trigger - 触发更新

function trigger(target: object, key: keyof object) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  
  const dep = depsMap.get(key);
  if (!dep) return;
  
  // 创建副本避免无限循环
  const effects = new Set(dep);
  effects.forEach((effect) => {
    if (effect !== activeEffect) {  // 避免当前 effect 触发自身
      effect();
    }
  });
}

三、Effect 系统

export function effect(fn: Function) {
  const effectFn = () => {
    const prevEffect = activeEffect;
    activeEffect = effectFn;  // 设置当前活跃的 effect

    try {
      return fn();
    } finally {
      activeEffect = prevEffect;  // 恢复之前的 effect
    }
  };
  
  effectFn();  // 立即执行一次,进行初始依赖收集
  return effectFn;
}

执行流程:

  1. 创建 effectFn 包装函数
  2. 执行时设置 activeEffect = effectFn
  3. 执行用户传入的 fn()
  4. 在 fn() 执行期间,所有对响应式属性的访问都会调用 track
  5. track 将当前 activeEffect 收集为依赖
  6. 执行完成后恢复之前的 activeEffect

四、使用示例

const state = reactive<{ todos: string[] }>({ todos: [] });

// 创建一个 effect
effect(() => {
  console.log('todos 改变了:', state.todos);
  // 首次执行时,访问 state.todos,触发 get
  // track 收集当前 effect 作为 todos 的依赖
});

// 修改状态
state.todos.push('学习响应式原理');  // 触发 set -> trigger -> 执行 effect

五、完整工作流程

  1. 初始化响应式对象

    const state = reactive({ todos: [] });
    // 创建 Proxy 代理
    
  2. 创建 effect

    effect(() => console.log(state.todos));
    // 1. 设置 activeEffect = 当前 effect
    // 2. 执行回调,访问 state.todos
    // 3. Proxy.get 触发,track 收集依赖
    
  3. 数据变更

    state.todos = ['新任务'];
    // 1. Proxy.set 触发
    // 2. trigger 查找依赖的 effects
    // 3. 执行所有依赖的 effect 函数
    

react路由配置相关

一、路由配置接口设计

  • 通过统一的接口定义,确保路由配置的类型安全,便于维护和扩展。
export interface RouteConfig {
  path: string;                     // 路由路径
  element?: React.ReactNode;        // 渲染组件
  children?: RouteConfig[];         // 子路由
  redirect?: string;                // 重定向路径
  meta?: {                          // 路由元信息
    title: string;                  // 页面标题
    requiresAuth?: boolean;         // 是否需要登录
    roles?: string[];               // 允许访问的角色
  };
}

二、路由分离策略

  • 将路由分为公共路由和私有路由
// 公共路由(无需登录)
const publicRoutes: RouteConfig[] = [
  {
    path: '/login',
    element: <Login />,
    meta: { title: '登录', requiresAuth: false }
  }
];

// 私有路由(需要登录)
const privateRoutes: RouteConfig[] = [
  {
    path: '/dashboard',
    element: <Dashboard />,
    meta: { title: '仪表板', requiresAuth: true }
  }
];

三、懒加载与代码分割

  • React.lazy 是 React 中实现组件级代码分割的核心 API,它允许你将组件代码延迟加载,从而优化应用性能。
  • React.lazy必须在 Suspense 内使用
  • Suspense 是 React 中用于处理异步操作的组件,它让组件可以"等待"某些操作完成,在等待期间显示回退 UI。
// 懒加载组件定义
const LazyAbout = React.lazy(() => import('../pages/About'));
const LazyUserList = React.lazy(() => import('../pages/UserList'));

// 封装Suspense包装器组件
const LazyComponent = ({ component: Component }) => (
  <Suspense fallback={<LoadingFallback />}>
    <Component />
  </Suspense>
);
  • 页面加载时的等待状态
//此处仅示例,实际可放置全局的未加载完成时的等待样式
const LoadingFallback = () => (
  <div>
    <p>页面加载中...</p>
  </div>
);

四、路由守卫与权限控制

  • 认证守卫组件:基于token的认证检查自动重定向、replace防止回退到受保护的页面
const AuthGuard = ({ children }) => {
  const isAuthenticated = !!localStorage.getItem('token');
  
  // 未认证重定向到登录页
  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }
  
  return <>{children}</>;
};
  • 元数据驱动的权限控制
{
  path: '/user-center',
  element: <UserCenter />,
  meta: {
    title: '用户中心',
    requiresAuth: true
    //权限控制
    roles: ['admin']
  }
}

五、递归渲染与嵌套路由

  • 支持无限层级嵌套
  • 统一的认证处理逻辑
const renderRoutes = (routes: RouteConfig[]) => {
  return routes.map((route) => {
    const { path, element, children, redirect, meta } = route;

    // 处理重定向
    if (redirect) {
      return (
        <Route
          key={path}
          path={path}
          element={<Navigate to={redirect} replace />}
        />
      );
    }

    return (
      <Route
        key={path}
        path={path}
        element={
          // 根据元数据决定是否包裹AuthGuard
          meta?.requiresAuth ? (
            <AuthGuard>{element}</AuthGuard>
          ) : (
            element
          )
        }
      >
        {/* 递归渲染子路由 */}
        {children && renderRoutes(children)}
      </Route>
    );
  });
};

  • 嵌套路由示例
{
  path: '/father',
  children: [
    {
      path: '',  // 访问 /father
      redirect: 'son'  // 重定向到 /father/son
    },
    {
      path: 'son',  // 访问 /father/son
      element: <Son />
    }
  ]
}

六、辅助工具函数

1. 面包屑导航生成

  • 显示当前页面
  • 快速导航
export const generateBreadcrumbs = (pathname) => {
  const segments = pathname.split('/').filter(Boolean);
  const breadcrumbs = [];
  let currentPath = '';

  for (const segment of segments) {
    currentPath += `/${segment}`;
    const route = findRouteByPath(currentPath);

    if (route?.meta?.title) {
      breadcrumbs.push({
        title: route.meta.title,
        path: route.path !== '/404' ? currentPath : undefined,
      });
    }
  }

  return breadcrumbs;
};

2. 路由查找功能

export const findRouteByPath = (pathname, routes = getAllRoutes()) => {
  for (const route of routes) {
    if (route.path === pathname) return route;
    
    if (route.children) {
      const found = findRouteByPath(pathname, route.children);
      if (found) return found;
    }
  }
  
  return null;
};

七、汇总使用

// AppRouter.jsx
import React, { Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import App from '../pages/App';
import Father from '../pages/Father';
import Son from '../pages/Son';
import Login from '../pages/Login';
import NotFound from '../pages/NotFound';

/**
 * ============================================
 * 定义路由配置接口
 * ============================================
 */
export interface RouteConfig {
  path: string;                     // 路由路径
  element?: React.ReactNode;        // 渲染组件
  children?: RouteConfig[];         // 子路由
  redirect?: string;                // 重定向路径
  meta?: {                          // 路由元信息
    title: string;                  // 页面标题
    requiresAuth?: boolean;         // 是否需要登录
    roles?: string[];               // 允许访问的角色
  };
}

/**
 * 懒加载配置区域
 * 使用 React.lazy 实现代码分割
 */
const LazyApp = React.lazy(() => import('../pages/App'));
const LazyFather = React.lazy(() => import('../pages/Father'));
const LazySon = React.lazy(() => import('../pages/Son'));

/**
 * 加载中组件
 */
const LoadingFallback = () => (
  <div>
    <div>页面加载中...</div>
  </div>
);

/**
 * 懒加载组件包装器
 */
const LazyComponent = ({ component: Component }) => (
  <Suspense fallback={<LoadingFallback />}>
    <Component />
  </Suspense>
);

/**
 * 权限守卫组件
 */
const AuthGuard = ({ children }) => {
  const isAuthenticated = !!localStorage.getItem('token');
  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }
  return <>{children}</>;
};

/**
 * 公共路由配置(不需要登录)
 */
const publicRoutes: RouteConfig[] = [
  {
    path: '/login',
    element: <Login />,
    meta: {
      title: '登录',
      requiresAuth: false,
    },
  },
  {
    path: '/404',
    element: <NotFound />,
    meta: {
      title: '页面不存在',
      requiresAuth: false,
    },
  },
];

/**
 * 私有路由配置(需要登录)
 */
const privateRoutes: RouteConfig[] = [
  {
    path: '/',
    element: (
      <AuthGuard>
        <div>主布局</div> {/* 这里可以根据需要添加布局组件 */}
      </AuthGuard>
    ),
    meta: {
      title: '首页',
      requiresAuth: true,
    },
    children: [
      // 默认重定向到 App 页面
      {
        path: '',
        redirect: '/app',
      },
      // App 页面
      {
        path: 'app',
        element: <LazyComponent component={LazyApp} />,
        meta: {
          title: 'App 页面',
          requiresAuth: true,
        },
      },
      // Father 页面
      {
        path: 'father',
        element: <LazyComponent component={LazyFather} />,
        meta: {
          title: 'Father 页面',
          requiresAuth: true,
        },
        children: [
          // 访问 /father 时重定向到 /father/son
          {
            path: '',
            redirect: 'son',
          },
          // Son 页面作为 Father 的子页面
          {
            path: 'son',
            element: <LazyComponent component={LazySon} />,
            meta: {
              title: 'Son 页面',
              requiresAuth: true,
            },
          },
        ],
      },
    ],
  },
];

/**
 * 递归渲染路由配置
 */
const renderRoutes = (routes: RouteConfig[]) => {
  return routes.map((route) => {
    const { path, element, children, redirect, meta } = route;

    // 如果有重定向,直接返回重定向路由
    if (redirect) {
      return (
        <Route
          key={path}
          path={path}
          element={<Navigate to={redirect} replace />}
        />
      );
    }

    return (
      <Route
        key={path}
        path={path}
        element={
          <Suspense fallback={<LoadingFallback />}>
            {meta?.requiresAuth ? (
              <AuthGuard>
                {element}
              </AuthGuard>
            ) : (
              element
            )}
          </Suspense>
        }
      >
        {/* 递归渲染子路由 */}
        {children && renderRoutes(children)}
      </Route>
    );
  });
};

/**
 * 主路由组件
 */
const AppRouter = () => {
  return (
    <Routes>
      {/* 渲染所有路由 */}
      {renderRoutes([...publicRoutes, ...privateRoutes])}
      {/* 处理未匹配的路由 */}
      <Route path="*" element={<Navigate to="/404" replace />} />
    </Routes>
  );
};

/**
 * 获取所有路由配置
 */
export const getAllRoutes = () => {
  return [...publicRoutes, ...privateRoutes];
};

/**
 * 根据路径查找路由信息
 */
export const findRouteByPath = (
  pathname: string,
  routes = getAllRoutes()
) => {
  for (const route of routes) {
    if (route.path === pathname) {
      return route;
    }

    if (route.children) {
      const found = findRouteByPath(pathname, route.children);
      if (found) {
        return found;
      }
    }
  }

  return null;
};

/**
 * 生成面包屑导航数据
 */
export const generateBreadcrumbs = (pathname) => {
  const segments = pathname.split('/').filter(Boolean);
  const breadcrumbs = [];
  let currentPath = '';

  for (const segment of segments) {
    currentPath += `/${segment}`;
    const route = findRouteByPath(currentPath);

    if (route?.meta?.title) {
      breadcrumbs.push({
        title: route.meta.title,
        path: route.path !== '/404' ? currentPath : undefined,
      });
    }
  }
  return breadcrumbs;
};

export default AppRouter;

解析ElementPlus打包源码(五、copyFiles)

还有最后一个copyFiles,虽然有点水,还是记录下

image.png

copyTypesDefinitions 我们之前打包类型已经写过。这里我们只看copyFiles

export const copyFiles = () =>
  Promise.all([
    copyFile(epPackage, path.join(epOutput, 'package.json')),
    copyFile(
      path.resolve(projRoot, 'README.md'),
      path.resolve(epOutput, 'README.md')
    ),
    copyFile(
      path.resolve(projRoot, 'typings', 'global.d.ts'),
      path.resolve(epOutput, 'global.d.ts')
    ),
  ])

这里复制了三个文件

packages/element-plus/package.json

packages/element-plus/package.json这个文件复制到打包后的项目下当作打包后的package.json

package.json里面涉及了一些package.json的基础信息,例如:

{
  "name": "element-plus",        // 包名,安装时使用 npm install element-plus
  "version": "0.0.0-dev.1",     // 版本号
  "description": "A Component Library for Vue 3", // 包的描述
  "keywords": ["element-plus", "vue", ...], // 关键词,方便 npm 搜索
  "homepage": "https://element-plus.org/", // 官网地址
  "bugs": { "url": "..." },      // 问题反馈地址
  "license": "MIT",              // 开源协议
  "repository": { ... }         // 代码仓库地址
  "publishConfig": { ... }    // 发布配置:`access: public` 表示该包是公开发布的(npm 私有包需付费,此配置确保包公开可访问)
}

还有一些模块导出规则的信息,main/module/types这三个是早期 npm 包的 “基础配置”,只能定义 “根路径” 的单一入口,无法精细化控制子路径,现在的exports优先级更高

"main": "lib/index.js",
"module": "es/index.mjs",
"types": "es/index.d.ts",
"exports": {
  // 1. 根路径引入:import 'element-plus' / require('element-plus')
  ".": {
    "types": "./es/index.d.ts",  // TS 类型文件(优先匹配)
    "import": "./es/index.mjs",  // ESM 引入(import 语法)加载 es 目录的 mjs 文件(ESM 模块)
    "require": "./lib/index.js"  // CommonJS 引入(require 语法)加载 lib 目录的 js 文件(CJS 模块)
  },
  // 2. 全局类型引入:import 'element-plus/global'
  "./global": {
    "types": "./global.d.ts"     // 仅导出全局类型(无 js 代码,用于全局类型声明)
  },
  // 3. 直接引入 es 目录:import 'element-plus/es'
  "./es": {
    "types": "./es/index.d.ts",
    "import": "./es/index.mjs"   // 仅支持 ESM 引入(es 目录只存 ESM 模块)
  },
  // 4. 直接引入 lib 目录:require('element-plus/lib')
  "./lib": {
    "types": "./lib/index.d.ts",
    "require": "./lib/index.js"  // 仅支持 CommonJS 引入(lib 目录只存 CJS 模块)
  },
  // 5. 按需引入 es 目录下的 mjs 文件:import 'element-plus/es/button.mjs'
  "./es/*.mjs": {
    "types": "./es/*.d.ts",      // 匹配对应 ts 类型文件(如 button.d.ts)
    "import": "./es/*.mjs"       // 加载对应 mjs 文件(ESM 按需引入)
  },
  // 6. 按需引入 es 目录下的模块:import 'element-plus/es/button'(无后缀)
  "./es/*": {
    "types": ["./es/*.d.ts", "./es/*/index.d.ts"], // 类型匹配优先级:先找 button.d.ts,再找 button/index.d.ts
    "import": "./es/*.mjs"       // 自动补全 mjs 后缀,适配开发者省略后缀的习惯
  },
  // 7. 按需引入 lib 目录下的 js 文件:require('element-plus/lib/button.js')
  "./lib/*.js": {
    "types": "./lib/*.d.ts",
    "require": "./lib/*.js"      // CJS 按需引入(带后缀)
  },
  // 8. 按需引入 lib 目录下的模块:require('element-plus/lib/button')(无后缀)
  "./lib/*": {
    "types": ["./lib/*.d.ts", "./lib/*/index.d.ts"], // 类型匹配规则同 es 目录
    "require": "./lib/*.js"      // 自动补全 js 后缀
  },
  // 9. 兜底规则:匹配所有未定义的路径(如 import 'element-plus/package.json'"./*": "./*"
}

package.json中还有相关的peerDependencies(要求项目中安装符合版本的依赖,否则会进行提醒)、dependencies、devDependencies

还有其他的相关配置可自行查看文档

README.md

根路径下的README文档,复制到打包后的包中

global.d.ts

这个文件就是Element Plus 的全局 TypeScript 类型声明文件

主要就是全局引入组件的时候,方便通过引入这个类型文件,在整个项目中都有提示

可见文档说明 ElementPlus快速开始

❌