阅读视图

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

React 组件封装最佳实践:6 条让你少掉头发的秘诀

大家好!今天我们来聊聊前端开发中一个既让人头疼又让人欲罢不能的话题——React 组件的封装。毕竟,谁不想写出优雅又聪明的代码,让同事们看了都忍不住给你点个赞呢?

在前端开发的江湖中,React 组件封装可是修炼内功的必经之路。一个好的组件能让你的代码像丝绸一样顺滑,而一个差劲的组件...嗯,可能会让你怀疑人生。今天,我们就聊聊 React 组件封装的 6 条最佳实践,助你在前端世界中少踩坑、少掉发!


1. 单一职责:一个组件只干一件事

首先,咱们得明确一点:组件不是超人,它没必要啥都干!一个组件的职责越多,它的复杂度就越高,最后就会变成“代码乱炖”。试想一下,你让一个按钮组件既负责显示文字,又负责管理 API 调用,再顺便管管用户登录状态,这不就是逼着它“过劳死”吗?

最佳实践:

  • 每个组件只做一件事,且做好它。
  • 如果发现某个组件的代码越来越长,那就停下来想想,是不是可以拆分成更小的子组件。

搞笑案例:

// 一个灾难性的“万能组件”
const DisasterComponent = () => {
  // 管理状态
  const [isLoggedIn, setIsLoggedIn] = useState(false);
  const [data, setData] = useState([]);
  
  // 做 API 调用
  useEffect(() => {
    fetch('/api/data').then(res => res.json()).then(setData);
  }, []);
  
  return (
    <div>
      {isLoggedIn ? '欢迎回来!' : '请登录'}
      <button onClick={() => setIsLoggedIn(!isLoggedIn)}>切换登录状态</button>
      <ul>
        {data.map(item => <li key={item.id}>{item.name}</li>)}
      </ul>
    </div>
  );
};

这代码看得头皮发麻吧?拆分成子组件后,世界瞬间清净了!


2. 可复用性:别把组件写得像一次性筷子

写 React 组件时,千万别想着“反正这个功能只用一次”。你以为是一次性筷子,结果下次又有类似需求时,只能再造一个差不多的轮子。这样一来,你的代码仓库很快会变成“轮子博物馆”。

最佳实践:

  • 把通用逻辑抽出来,做成可复用的组件。
  • 如果有特殊需求,可以通过 props 或者 children 来定制化。

搞笑案例:

// 不可复用的“土味按钮”
const RedButton = () => <button style={{ color: 'red' }}>红色按钮</button>;
const BlueButton = () => <button style={{ color: 'blue' }}>蓝色按钮</button>;

改进后:

const Button = ({ color, children }) => (
  <button style={{ color }}>{children}</button>
);

// 用法
<Button color="red">红色按钮</Button>
<Button color="blue">蓝色按钮</Button>

看吧,一个通用的 Button 分分钟解决问题!


3. 状态管理:别把状态塞进每个角落

React 的状态管理就像养宠物,你得好好规划它们住在哪儿、吃什么。如果你随便把状态塞进每个组件,那到最后,整个项目可能就会变成“状态迷宫”,连你自己都找不到北。

最佳实践:

  • 状态应该尽量提升到最近的公共父组件,或用 Context/Redux/Zustand 等进行集中管理。
  • 别让不需要状态的组件也参与管理,保持它们“无状态”的清爽模样。

搞笑案例:

// 状态管理得像一锅粥
const Parent = () => {
  const [value, setValue] = useState('');
  
  return (
    <Child value={value} setValue={setValue} />
  );
};

const Child = ({ value, setValue }) => (
  <input value={value} onChange={e => setValue(e.target.value)} />
);

如果状态特别复杂,用 Context 或 Redux 把它们“拎出去单独养”,这样父子关系会更和谐。


4. Prop 验证:别让你的组件乱吃东西

React 的 props 就像是给组件喂的饭菜。如果你不给它规定饮食,它可能会吃坏肚子(运行错误)。所以啊,Prop 验证非常重要!

最佳实践:

  • 使用 PropTypes 或 TypeScript 来验证 props 的类型和必要性。
  • 如果某个 prop 是必需的,一定要标记出来。

搞笑案例:

// 没有 Prop 验证的灾难现场
const Greeting = ({ name }) => (
  <h1>你好,{name.toUpperCase()}!</h1> // name 万一是 undefined 呢?
);

改进后:

import PropTypes from 'prop-types';

const Greeting = ({ name }) => (
  <h1>你好,{name.toUpperCase()}!</h1>
);

Greeting.propTypes = {
  name: PropTypes.string.isRequired,
};

这样一来,喂错饭菜的时候 React 会直接报警!


5. 避免过度优化:别给自己挖坑

React 的性能优化是一门艺术,但千万别走火入魔。比如,有些人为了省几个毫秒,就疯狂用 React.memouseCallback,结果代码复杂度飙升,debug 的时候哭得像个孩子。

最佳实践:

  • 优化只做必要的,不要为了优化而优化。
  • React.memouseCallback 是好工具,但用之前先问自己:“真的需要吗?”

搞笑案例:

const ExpensiveComponent = React.memo(({ data }) => {
  console.log('渲染了!');
  return <div>{data}</div>;
});

如果 data 每次都是新对象,那这个 memo 就是摆设,还不如直接删掉。


6. 写文档:别让你的组件变成迷宫

最后一条,也是最重要的一条:写!文!档!一个没有文档的组件,就像一个没有说明书的 IKEA 家具,看着简单,用起来却能让人崩溃。

最佳实践:

  • 为每个组件写清楚功能、props 和用法。
  • 如果条件允许,可以用 Storybook 或类似工具为组件生成可视化文档。

搞笑案例:

// 没文档的组件
const MysteriousComponent = ({ a, b, c }) => (
  <div>{a + b + c}</div>
);

这三个 prop 是啥?谁知道呢!写上文档后:

// MysteriousComponent 文档
props:
- a: 数字,第一个加数
- b: 数字,第二个加数
- c: 数字,第三个加数

是不是瞬间清晰了?


好了,这就是今天分享的 React 组件封装最佳实践!希望大家看完后不仅学到了东西,还能多笑几声。如果你还在为写组件掉头发,那就赶紧把这些实践用起来吧——毕竟,头发比代码更重要啊!

Node.js 进程是单线程,就可以放心追加日志吗?

在开发 Node.js 服务或 CLI 工具时,日志系统是我们的“眼睛”。很多同学认为: “既然 Node.js 是单线程的,那我用 fs.appendFile 往文件里写日志,肯定不会乱序或者冲突,对吧?”

答案是:对了一半,但忽略了操作系统层面的真相。

今天我们就从单线程逻辑、多进程竞争、原子性写入三个维度,深度拆解 Node.js 日志追加的“正确姿势”。


一、 逻辑上的“绝对安全”:单线程与事件循环

从 Node.js 进程内部看,你的直觉是对的。

由于 Node.js 的 Event Loop(事件循环)  机制,虽然底层的 I/O 是异步非阻塞的,但你在 JavaScript 层发起的日志写入请求会按顺序排队。

javascript

// 即使你连续调用两次,JS 引擎也会保证它们的入队顺序
fs.appendFile(logPath, 'Log Line 1\n', () => {});
fs.appendFile(logPath, 'Log Line 2\n', () => {});

请谨慎使用此类代码。

单进程环境下,你永远不需要担心“第一行日志写到一半,第二行就插进来”这种交错现象。因为在同一时刻,只有一个 V8 实例在处理你的写入逻辑。


二、 物理上的“暗箭难防”:多进程并发竞争

然而,现代应用往往是多进程的(如使用 PM2 开启 Cluster 模式,或手动 fork 子进程)。

当多个进程同时向同一个文件执行 append 时,灾难就可能发生了:

  1. 覆盖风险:如果两个进程同时读取文件末尾指针并写入,后写的可能会覆盖先写的。
  2. 内容交错:在高并发下,进程 A 的数据和进程 B 的数据可能在物理层面上被操作系统“混”在一起,导致日志文件无法解析。

🚀 资深架构的解决方案:一进程一文件(隔离思想)

在我们的讨论中,一种非常高明的方案是:避开竞争,直接物理隔离。

  • 唯一命名:每个进程启动时,利用 crypto.randomUUID() 和时间戳生成唯一的日志文件名(如 20260118-uuid.log)。
  • 父子关联:子进程在日志头部记录父进程的日志 ID。

这种设计通过  “无锁化”  规避了复杂的底层锁竞争,性能最高,且天然支持跨机器的日志归档。


三、 深度细节:日志写入的“原子性”

即使是单进程,还有一个隐蔽的坑:原子性(Atomicity)

POSIX 标准规定,如果写入的数据块超过了系统的 PIPE_BUF(通常是 4KB 或 8KB),系统可能会将其拆分为多次 I/O 操作。

  • 场景:如果你的一条日志由于记录了巨大的 JSON 堆栈而达到了 1MB。
  • 风险:虽然 JS 是单线程,但在操作系统底层,如果此时发生系统中断或内存压力,这 1MB 的数据可能是不连续写入的。

2026 年的最佳实践

为了保证绝对的健壮性,建议在 Node.js 中遵循以下原则:

  1. 放弃外部包,拥抱原生原生

不要再引用 uuid 或 dayjs 来处理基础的日志元数据了。2026 年,Node.js 原生 API 已经足够强大且性能更高:

javascript

import { randomUUID } from 'node:crypto'; // 高性能 UUID
const logTime = new Intl.DateTimeFormat('zh-CN', { ... }).format(new Date()); // 标准时间

请谨慎使用此类代码。

  1. 使用 fs.createWriteStream

对于高频日志,频繁打开/关闭文件句柄(appendFileSync)是有开销的。建议维护一个持久的可写流:

javascript

const logStream = fs.createWriteStream(logPath, { flags: 'a' }); // 'a' 代表追加

export const writeLog = (content) => {
  const line = `[${getLogTime()}] [PID:${process.pid}] ${content}\n`;
  logStream.write(line); // 这里的写入在单进程内是绝对顺序的
};

请谨慎使用此类代码。

  1. 跨进程配置同步

如果你在 Monorepo 或多进程环境中,利用 globalThis(内存单例)配合 process.env(进程快照)来同步日志路径等配置,是目前最稳健的架构设计。


四、 总结

Node.js 是单线程的,这确实为我们提供了逻辑上的追加安全;但真正健壮的日志系统,必须考虑到操作系统层面的进程隔离

核心结论:

  • 单进程场景:放心追加,只需关注单条日志不要过大(建议控制在 8KB 以内)。
  • 多进程场景:不要头铁去争夺同一个文件, “一进程一文件 + 唯一标识 + 逻辑关联”  才是通往资深工程师的进阶之路。

2026 年 Node.js + TS 开发:别再纠结 nodemon 了,聊聊热编译的最优解

在开发 Node.js 服务端时,“修改代码 -> 自动生效”的开发体验(即热编译/热更新)是影响效率的关键。随着 Node.js 23+  原生支持 TS 以及 Vite 5 的普及,我们的工具链已经发生了巨大的更迭。

今天我们深度拆解三种主流的 Node.js TS 开发实现方式,帮你选出最适合 2026 年架构的方案。


一、 方案对比大盘点

方案 核心原理 优点 缺点 适用场景
tsx (Watch Mode) 基于 esbuild 的极速重启 零配置、性能强、生态位替代 nodemon 每次修改重启整个进程,状态丢失 小型服务、工具脚本
vite-node 基于 Vite 的模块加载器 完美继承 Vite 配置、支持模块级 HMR 配置相对复杂,需手动处理 HMR 逻辑 中大型 Vite 全栈项目
Node.js 原生 Node 23+ Type Stripping 无需第三方依赖,官方标准 需高版本 Node,功能相对单一 追求极简、前瞻性实验

二、 方案详解

  1. 现代替代者:tsx —— 告别 nodemon + ts-node

过去我们常用 nodemon --exec ts-node,但在 ESM 时代,这套组合经常报 ERR_UNKNOWN_FILE_EXTENSION 错误。

tsx 内部集成了 esbuild,它是目前 Node 18+ 环境下最稳健的方案。

  • 实现热编译:

    bash

    npx tsx --watch src/index.ts
    

    请谨慎使用此类代码。

  • 为什么选它:  它不需要额外的加载器配置(--loader),且 watch 模式非常智能,重启速度在毫秒级。

  1. 开发者体验天花板:vite-node —— 真正的 HMR

如果你已经在项目中使用 Vite 5,那么 vite-node 是不二之选。它不仅是“重启”,而是“热替换”。

  • 核心优势:

    • 共享配置:直接复用 vite.config.ts 中的 alias 和插件。
    • 按需编译:只编译当前运行到的模块,项目越大优势越明显。
  • 实现热更新(不重启进程):

    typescript

    // src/index.ts
    import { app } from './app';
    let server = app.listen(3000);
    
    if (import.meta.hot) {
      import.meta.hot.accept('./app', (newModule) => {
        server.close(); // 优雅关闭旧服务
        server = newModule.app.listen(3000); // 启动新逻辑,DB连接可复用
      });
    }
    

    请谨慎使用此类代码。

  1. 官方正统:Node.js 原生支持

如果你能使用 Node.js 23.6+ ,那么可以摆脱所有构建工具。

  • 运行:  node --watch src/index.ts
  • 点评:  这是未来的趋势,但在 2026 年,由于生产环境往往还停留在 Node 18/20 LTS,该方案目前更多用于本地轻量级开发。

三、 避坑指南:Vite 5 打包 Node 服务的报错

在实现热编译的过程中,如果你尝试用 Vite 打包 Node 服务,可能会遇到:

Invalid value for option "preserveEntrySignatures" - setting this option to false is not supported for "output.preserveModules"

原因:  当你开启 preserveModules: true 想保持源码目录结构输出时,Rollup 无法在“强制保留模块”的同时又“摇树优化(Tree Shaking)”掉入口导出。

修复方案:
在 vite.config.ts 中明确设置:

typescript

build: {
  rollupOptions: {
    preserveEntrySignatures: 'exports-only', // 显式声明保留导出
    output: {
      preserveModules: true
    }
  }
}

请谨慎使用此类代码。


四、 总结:我该选哪个?

  1. 如果你只想快速写个接口,不想折腾配置:请直接使用 tsx。它是 2026 年 nodemon 的完美继承者。
  2. 如果你在做复杂全栈项目,或者有大量的路径别名:请使用 vite-node。它能让你在 Node 端获得跟前端 React/Vue 编写时一样丝滑的 HMR 体验。
  3. 如果是为了部署生产环境:无论开发环境用什么,生产环境请务必通过 vite build 产出纯净的 JS,并使用 node dist/index.js 运行。

TypeScript:更加安全规范的JavaScript

前言

曾经有一份真挚的 JavaScript 代码摆在我面前,我没有珍惜。直到 Uncaught TypeError: Cannot read property of undefined 这种红色报错占满屏幕,我才后悔莫及。如果上天能够给我一个再来一次的机会,我会对那个变量说三个字:“定类型!”。

如果非要在这份类型上加一个期限,我希望是——TypeScript

今天我们不聊高大上的架构,只聊怎么让你写代码时“心里有底”。我们要从最基础的弱类型痛点讲起,一路杀到 TypeScript 的核心——泛型,最后用 React 撸一个 实战小组件 TodoList。

系好安全带,我们要发车了!


第一章:JS 的温柔陷阱与 TS 的铁血秩序

1.1 弱类型的“二义性”之痛

JavaScript 是一个非常“随和”的语言,随和到什么程度?它允许你胡作非为。

看看这段代码

function add(a, b) {
  // js 是弱类型的优势:好学,易上手
  // 也就是这种“随和”,让你在大型项目中痛不欲生
  return a + b; // 二义性:是加法?还是字符串拼接?
}

const res = add("1", "2"); // 结果是 "12",而不是 3
console.log(res);

在你写下 add 的那一刻,你心里想的是数学加法。但 JS 运行时心想:“嘿,大哥给我俩字符串,那我给你拼起来呗。”

这就是 动态语言 的特点:Bug 只有在运行的时候才会发生。在大型项目中,这就像在排雷,你永远不知道哪一行代码会在用户点击按钮时爆炸。要保证 99.999% 不出问题,靠人脑去记 a 是数字还是字符串,简直是天方夜谭。

1.2 TypeScript:给JS穿上外骨骼

TypeScript 是什么?官方说它是 JS 的超集。

在集合论中,如果集合 A 包含了集合 B 的所有元素,并且集合 A 还有 B 没有的东西,那么 A 就是 B 的超集。通俗点说,它是 JS 的亲爹,专门负责管教这个熊孩子。

TS 是 强类型静态语言。它在代码编译阶段(运行前)就对其进行检查。

// 强类型可以杜绝 90% 的低级错误
// 其中,前两个:number规定的是参数的数据类型,最后一个:number规定的是函数返回值的数据类型
function addTs(a: number, b: number): number {
  return a + b;
}
// const result = addTs("1", "2"); // 报错!编译都不让你过!
const result = addTs(1, 2);
console.log(result);

这就是 TS 的核心价值:把错误扼杀在摇篮里。它不仅是类型约束,更是你免费的“结对编程伙伴”,时刻提醒你:“兄弟,这里不能传字符串。”

1. 安装编译器 (TSC)
打开终端,运行:

npm install -g typescript
  • 验证是否安装成功:输入 tsc -v,看到版本号即成功。

2. 编译(翻译)
在终端运行:

tsc index.ts

这时你会发现文件夹里多了一个 index.js 文件。这就是“翻译”后的结果。

3. 运行
运行生成的 JS 文件:

node index.js

第二章:TS 基础武器库 —— 不仅仅是加个冒号

在进入实战前,我们需要清点一下武器库。很多新手把 TS 写成了 AnyScript,遇见报错就加 any,这不仅违背了初衷,甚至让代码比原生 JS 更难维护。

TypeScript 的类型系统其实非常庞大,为了方便记忆,我们把它们分为五大类:基本底座、特殊兵种、对象建模、集合容器、以及逻辑运算

2.1 基本底座:JS 的老朋友与新面孔

基本数据类型: boolean, number, string, null, undefined, symbol, bigint

这部分大家最熟悉,它们直接对应 JavaScript 的原始类型。但在 TS 中,它们变得更加“铁面无私”。

// 1. 老三样:一板一眼
let isDone: boolean = false;
let age: number = 18;       // 支持十进制、十六进制等
let name: string = "Tom";

// 2. 只有在 ES2020+ 才有的新贵
// bigint: 处理超大整数,记得在 tsconfig 中开启 ES2020
let bigNumber: bigint = 100n; 
// symbol: 独一无二的标识
let sym: symbol = Symbol("key"); 

// 3. 让人头疼的空值:null 和 undefined
// 在 strictNullChecks: true (严格模式) 下,它们不能赋值给 number 等其他类型
let u: undefined = undefined;
let n: null = null;
// let num: number = undefined; // ❌ 报错!别想蒙混过关

2.2 特殊兵种:虚空与黑洞

这是 TS 特有的概念,理解它们是脱离新手村的标志。

1. Any vs Unknown:放纵与克制

新手最爱用 any,但资深工程师偏爱 unknown。

// any: 放弃治疗,跳过检查 (逃生舱)
let aa: any = 1; 
aa = "111"; 
aa.hello(); // ✅ 编译通过,但运行爆炸!这是 JS 的原罪

// unknown: 未知类型 (更安全的 Any)
let bb: unknown = 1;
bb = "hello";
// bb.hello(); // ❌ 报错!TS 说:我不确定它是啥,你不许乱动
// 必须先“验身” (类型收窄) 才能用
if (typeof bb === 'string') {
  console.log(bb.toUpperCase()); // ✅ 现在安全了
}

2. Void vs Never:空无一物与万劫不复

// void: 空。通常用于函数没有返回值
function logMessage(): void {
  console.log("只是打印一下,不返回东西");
}

// never: 绝不。表示永远不会有结果的类型 (黑洞)
// 场景1: 抛出错误,函数提前终结,执行不到结尾
function error(message: string): never {
  throw new Error(message);
}
// 场景2: 死循环
function loop(): never {
  while (true) {}
}

2.3 对象建模:描述世界的形状

在 TS 中,我们主要用两种方式描述对象:接口 (interface) 和 类型别名 (type)。

// 1. 字面量类型 (Literal Types)
// 只有 "male" 或 "female" 才是合法值,其他字符串不行
type Gender = "male" | "female"; 

// 2. 接口 (Interface):就像签订契约,适合定义对象形状
interface User {
  name: string;
  age: number;
  gender: Gender;
  readonly id: number; // 只读属性,不可篡改
  hobby?: string;      // 可选属性,有了更好,没有也行
  [key: string]: any;  // 索引签名:允许有额外的任意属性
}

const u: User = {
  name: "李四",
  age: 18,
  gender: "female",
  id: 1,
  school: "Qinghua" // ✅ 匹配索引签名
};

// 3. 小写的 object
// 代表非原始类型 (即不是 number/string/boolean...)
// 很少直接用,因为它太宽泛了,你无法访问里面的属性
function create(o: object | null): void {}
create({ prop: 0 }); // OK
// create(42); // Error

2.4 集合容器:数组与元组

// 1. 数组:两种写法
let list1: number[] = [1, 2, 3]; // 写法一:简洁(推荐)
let list2: Array<string> = ["a", "b"]; // 写法二:泛型写法(逼格高,且 foreshadow 了后面的泛型章节)

// 2. 元组 (Tuple):一种特殊的数组
// 它是定长、定类型的。React 的 useState 就是返回一个元组
let x: [string, number];
x = ["hello", 10]; // OK
// x = [10, "hello"]; // Error,顺序不对

2.5 高级逻辑:组合与枚举

最后,我们需要一些工具来处理复杂的类型关系。

1. 枚举 (Enum):让魔法数字滚出代码
不要在代码里写 if (status === 2),鬼知道 2 是什么。

enum Status {
  Pending = 0,
  Success = 1,
  Failed = 2,
}
let s: Status = Status.Pending; 
// 可读性爆炸:Status.Success 比 s = 1 强一万倍

2. 联合 (Union) 与 交叉 (Intersection)
这是类型的“逻辑或”与“逻辑与”。

// 联合类型 (|):是 A 或者 B
// 就像 ID,可能是数字 ID,也可能是字符串 UUID
type ID = string | number; 

function printId(id: ID) {
  // 这里需要注意,只能访问 string 和 number 共有的方法
  // 或者通过 typeof 判断类型
}

// 交叉类型 (&):是 A 并且也是 B
// 常用于对象合并
interface A { name: string }
interface B { age: number }
type C = A & B; // C 必须同时拥有 name 和 age

const person: C = {
  name: "Tony",
  age: 35
};

老司机总结

  • 能用 unknown 别用 any。
  • 能用 interface 描述对象就先用 interface。
  • 看到 | 竖线是“或者”,看到 & 符号是“合体”。
  • 基础打牢,后面讲泛型才不会晕车。

第三章:TS 的核武器 —— 泛型 (Generics)

好,前面的都是开胃菜。接下来我们要讲 TS 中最难理解但也最强大的特性:泛型

很多同学看泛型就像看天书,看到  就头大。其实,泛型就是类型的“传参”

3.1 为什么需要泛型?

想象一下,你要写一个函数,把传入的内容原样返回。
如果不这用泛型:

function echo(arg: number): number { return arg; } // 只能处理数字
function echoString(arg: string): string { return arg; } // 只能处理字符串
function echoAny(arg: any): any { return arg; } // 丧失了类型信息,传入 string 返回 any

我们希望:我传入什么类型,你就自动识别为什么类型,并保证返回值也是那个类型。

3.2 泛型实战:能够“变形”的容器

让我们看看项目中的 storages.ts,这是泛型最经典的应用场景:

// T 是一个占位符,就像函数的参数一样
// 当你调用 getStorage<User> 时,所有的 T 都会变成 User
export function getStorage<T>(key: string, defaultValue: T): T {
  const value = localStorage.getItem(key);
  return value ? JSON.parse(value) : defaultValue;
}

代码解析:

  1. getStorage:告诉 TS,这个函数有一个“类型变量”叫 T。

  2. defaultValue: T:默认值的类型必须是 T。

  3. : T (返回值):函数返回的类型也是 T。

优势:
当我们存储 Todo 列表时,我们可以这样用:

// 哪怕 localStorage 本质存储的是字符串
// 通过泛型,res 自动获得了 Todo[] 的类型提示!
const res = getStorage<Todo[]>("todos", []); 
// res.map... // 这里的 map 里面会自动提示 Todo 的属性!

如果你不用泛型,JSON.parse 返回的是 any,你后续对数据的操作将失去所有类型保护。泛型,让你的通用工具函数不仅通用,而且安全


第四章:React + TS 全栈实战 —— TodoList 架构解析

现在,我们把 TS 的知识应用到 React 项目中。不要小看一个 TodoList,麻雀虽小,五脏俱全。我们会按照企业级的代码规范来组织结构。

4.1 项目结构:井井有条

观察我们的文件树,这是一个非常标准的分层结构:

src
├── components  // 纯展示组件 (UI)
├── hooks       // 自定义 Hooks (逻辑核心)
├── types       // 类型定义 (契约)
├── utils       // 工具函数 (泛型的高发地)
├── App.tsx     // 根组件
└── assets

为什么要这样分?

  • 关注点分离 :UI 归 UI,逻辑归逻辑,类型归类型。
  • 可维护性 :当你想修改数据结构时,去 types;当你想修改业务逻辑时,去 hooks。

4.2 Step 1: 定义灵魂 —— Model (types/Todo.ts)

一切开发,先定数据结构。这是 TS 开发者的直觉。

// types/Todo.ts
// 接口用来约定对象必须实现的属性和方法
// export 导出,供全项目使用
export interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

有了这个 Todo 接口,全项目凡是涉及到 todo item 的地方,都有了标准。

4.3 Step 2: 逻辑抽离 —— Custom Hook (hooks/useTodos.ts)

在 App.tsx 里写一堆 useState 和 useEffect 是新手的做法。资深工程师会把业务逻辑抽离。

这里我们用到了刚才讲的 泛型 和 接口

import { useState, useEffect } from "react";
import type { Todo } from "../types/Todo"; // 显式引入 type
import { getStorage, setStorage } from "../utils/storages";

export default function useTodos() {
  // 泛型应用:useState<Todo[]>
  // 告诉 React,这个状态是一个 Todo 类型的数组
  const [todos, setTodos] = useState<Todo[]>(() =>
    // 泛型应用:getStorage<Todo[]>
    // 从本地存储取出来的一定是 Todo[]
    getStorage<Todo[]>("todos", [])
  );

  useEffect(() => {
    // 泛型应用:setStorage<Todo[]>
    setStorage<Todo[]>("todos", todos);
  }, [todos]);

  const addTodo = (title: string) => {
    const newTodo: Todo = {
      id: +new Date(),
      title,
      completed: false,
    };
    // 这里如果写 newTodo.xxx = 123,TS 马上会报错,因为 Todo 接口里没定义 xxx
    setTodos([...todos, newTodo]);
  };

  // ... toggleTodo, removeTodo 省略
  return { todos, addTodo, toggleTodo, removeTodo };
}

亮点分析:

  • useState<Todo[]>:这保证了 todos 变量在使用数组方法(如 .map, .filter)时,回调函数里的参数自动推断为 Todo 类型。
  • 逻辑复用:如果以后要把 TodoList 移植到别的页面,直接引入这个 Hook 即可。

4.4 Step 3: 组件开发与 Props 约束 (components/*.tsx)

在 React + TS 中,组件最重要的就是定义 Props 的接口。

TodoInput.tsx:

import * as React from "react";

// 定义 Props 接口
// 清晰地告诉调用者:你要用我,必须给我一个 onAdd 函数,参数是 string,没返回值
interface Props {
  onAdd: (title: string) => void;
}

// React.FC<Props>:
// FC = Function Component。泛型 P = Props。
// 这让 TS 知道 TodoInput 是一个组件,且接受符合 Props 接口的参数
const TodoInput: React.FC<Props> = ({ onAdd }) => {
  const [value, setValue] = React.useState<string>(""); 
  // ... 逻辑
};

TodoList.tsx:

import type { Todo } from "../types/Todo";
import TodoItem from "./TodoItem";
import * as React from "react";

interface Props {
  todos: Todo[]; // 核心数据
  onToggle: (id: number) => void; // 回调
  onRemove: (id: number) => void;
}

const TodoList: React.FC<Props> = ({ todos, onToggle, onRemove }) => {
  return (
    <ul>
      {/* 因为 todos 被定义为 Todo[],这里的 map 里 todo 自动识别为 Todo 类型 */}
      {todos.map((todo) => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={onToggle}
          onRemove={onRemove}
        />
      ))}
    </ul>
  );
};

注意看:
在 TodoList 组件中,当我们在 map 里面渲染 TodoItem 时,如果忘记传 onRemove,IDE 会立刻划红线报 错。这就叫编译时检查。这比在浏览器里跑半天发现按钮没反应要强一万倍。

4.5 Step 4: 拼装 (App.tsx)

最后,我们在 App.tsx 里把所有积木搭起来。

import useTodos from "./hooks/useTodos";
import TodoList from "./components/TodoList";
import TodoInput from "./components/TodoInput";

export default function App() {
  // 就像去超市买东西,直接拿想要的逻辑
  const { todos, addTodo, toggleTodo, removeTodo } = useTodos();

  return (
    <div>
      <h1>TodoList</h1>
      {/* TS 检查:addTodo 的类型匹配 TodoInput 的 props 要求吗?匹配! */}
      <TodoInput onAdd={addTodo} />
      <TodoList todos={todos} onToggle={toggleTodo} onRemove={removeTodo} />
    </div>
  );
}

第五章:细节拆分

一、 FC

在 React + TypeScript 的开发语境下,FC 是 Function Component(函数式组件)的缩写。

它是 React 官方类型库(@types/react)提供的一个泛型接口,用来专门定义“函数组件”的类型。

简单来说,它的作用就是告诉 TypeScript:“嘿,这个变量不仅仅是一个普通的函数,它是一个 React 组件。

1. 它的全貌

在代码中,你通常这样看到它:

// React.FC<Props>
//       ^^     ^^
//       ||     ||
//   接口名称   泛型(传入组件的Props类型)

const TodoInput: React.FC<Props> = ({ onAdd }) => { ... }

2. FC 到底帮我们做了什么?

当你把一个组件标注为 React.FC 时,TypeScript 会自动帮你做这几件事:

A. 约定返回值

它强制要求这个函数的返回值必须是 JSX 元素(或者 null)。如果你不小心返回了一个对象或者 undefined,TS 会立刻报错。

B. 泛型传参 (最重要的功能)

它接受一个泛型参数(就是尖括号 <> 里的东西)。
比如 React.FC< Props>,这意味着:

  1. 这个组件接收的 props 参数,必须符合 Props 接口的定义。
  2. 你在组件内部使用 props 时,会有自动补全提示。
  3. 父组件在使用这个组件时,必须传递 Props 里规定的属性,少传或错传都会报错。
C. 提供静态属性类型 (相对少用)

它还包含了组件的一些静态属性定义,比如 displayName、propTypes、defaultProps(注:defaultProps 在函数组件中已不推荐使用)。


3. 一个需要注意的“坑”:Children (React 18 的变化)

这是面试或实战中常遇到的知识点。

  • 在 React 17 及以前
    React.FC 实际上自带了一个隐含的属性 children。也就是说,即使你的 Props 接口里是空的,你也可以在组件里写 {props.children}。
    但这被认为是不安全的,因为有些组件本来就不该包含子元素。

  • 在 React 18 (现在)
    React.FC 移除了 隐式的 children。
    如果你的组件需要包含子元素(比如一个 ... 组件),你需要显式地在接口里定义它:

    // React 18+ 的正确姿势
    interface Props {
      title: string;
      children?: React.ReactNode; // 必须手动加上这一行,否则报错
    }
    
    const Layout: React.FC<Props> = ({ title, children }) => {
      return (
        <div>
          <h1>{title}</h1>
          {children}
        </div>
      );
    }
    

二、 storage.js中的 T 是什么?

在 storages.ts 那个文件中,T 代表 Type (类型)

它是 TypeScript 中 泛型 (Generics)  的标准占位符。

你可以把 T 看作是一个  “类型的变量”  或者  “类型的占位符” 。就像你在数学函数 

f(x)=x+1f(x)=x+1

 中,x 代表任意数字一样;在 TS 中,T 代表任意类型。

我们来深入剖析一下 getStorage 这个函数:

// 1. 定义泛型变量 <T>
export function getStorage<T>(key: string, defaultValue: T): T {
  // ...
}

1. 把它拆解来看

这里的 T 出现了三次,分别代表不同的含义:

  1. getStorage  (声明):

    这是在告诉 TypeScript:“嘿,老兄,我现在定义一个函数。我不确定用户将来要存取什么类型的数据,可能是数字,可能是字符串,也可能是 Todo 对象。所以我先用 T 占个坑。” 在这里的T就相当于一个声明,方便后续读取使用

  2. defaultValue: T (参数约束):
    这表示:“传入的默认值,必须和 T 是同一种类型。” 你不能一边说 T 是数字,一边传个字符串做默认值。

  3. : T (返回值约束):
    这表示:“这个函数运行结束吐出来的数据,一定也是 T 类型。”

2. 它是如何“变身”的?

泛型的神奇之处在于,当你调用函数的时候,T 才会确定它到底是什么。

让我们看看在 useTodos.ts 是怎么用的:

// 场景一:获取 Todo 列表
getStorage<Todo[]>("todos", []);

当你写下 <Todo[]> 的那一瞬间,TypeScript 会在后台自动把所有的 T 替换掉:

  • function getStorage 变成 -> function getStorage<Todo[]>

  • defaultValue: T 变成 -> defaultValue: Todo[] (所以第二个参数必须传数组 [])

  • 返回值 : T 变成 -> : Todo[]

如果换个场景:

// 场景二:获取一个计数器
getStorage<number>("count", 0);

此时,所有的 T 瞬间变成了 number。

3. 为什么要用 T?(不用行不行?)

如果你不用泛型,你只能面临两个糟糕的选择:

糟糕选择 A:写死类型

function getStorage(key: string, val: number): number { ... }

这样这个函数就废了,只能存取数字,存取 Todo 列表还得再写一个函数。

糟糕选择 B:使用 any

function getStorage(key: string, val: any): any { ... }

这是最常见的错误。虽然函数通用了,但当你拿到返回值时,它是 any。你敲代码时,IDE 无法提示你有 todo.title 还是 todo.name。你失去了 TS 所有的保护。


第六章:总结与思考

6.1 为什么这一套流程是“高质量”的?

  1. 类型即文档:你看一眼 interface Props 或 interface Todo,就知道数据长什么样,不用去猜后端返回的 JSON 到底有没有 id 字段。

  2. 泛型的妙用:在 utils/storages.ts 和 hooks/useTodos.ts 中,泛型极大地提高了代码的复用性和安全性。它让我们可以写出既通用又类型严格的代码。

  3. 开发体验 (DX) :智能提示(IntelliSense)让你敲代码如飞,重构代码时也不用担心漏改了哪个文件。

6.2 给初学者的建议

  • 不要害怕报错:TS 的红色波浪线不是在骂你,而是在救你。

  • 多用 Interface:养成先定义数据结构,再写业务逻辑的习惯。

  • 理解泛型:把泛型想象成一个“类型插槽”,它是 TS 进阶的分水岭。

  • 拒绝 Any:如果实在不知道写什么类型,先写 unknown,或者去查文档,不要轻易妥协用 any。

6.3 结语

从 JavaScript 到 TypeScript,是一次思维的升级。它让你从“大概也许可能是这样”变成了“肯定是这样”。在 AI 全栈的时代,代码的健壮性尤为重要。

希望这篇文章能帮你推开 TypeScript 的大门。记住,类型不是枷锁,而是你的铠甲。

现在,打开你的 IDE,把那个 .js 后缀改成 .ts,开始你的重构之旅吧!

Sass 模块化革命:告别 @import,拥抱 @use 和 @forward

为什么你的 Sass 代码突然开始报错?是时候彻底理解 Sass 的模块化系统了!

最近很多前端开发者突然发现自己的 Sass 代码开始报出各种警告和错误:

  • @import rules are deprecated
  • There's already a module with namespace "math"
  • Using / for division is deprecated

这一切都源于 Dart Sass 的模块化革命。如果你还在使用传统的 @import,那么这篇文章将带你彻底理解新的模块系统,并手把手教你如何迁移。

为什么要弃用 @import?

传统 @import 的问题

让我们先回顾一下 @import 的常见用法:

// variables.scss
$primary-color: #1890ff;
$font-size: 14px;

// main.scss
@import "variables";
@import "mixins";
@import "components/button";

.button {
  color: $primary-color;
  font-size: $font-size;
}

看起来很简单对吧?但 @import 有几个致命缺陷:

  1. 全局污染:所有变量、mixin、函数都混入全局作用域
  2. 无法避免冲突:同名变量会被覆盖,且很难追踪来源
  3. 无法控制可见性:无法隐藏私有变量
  4. 性能问题:每次 @import 都会重新计算
  5. 依赖混乱:无法知道模块间的依赖关系

新系统的优势

@use@forward 组成的模块系统解决了所有这些问题:

  • 命名空间隔离:每个模块有自己的作用域
  • 明确的依赖关系:清晰知道每个变量来自哪里
  • 更好的封装:可以隐藏私有成员
  • 更快的编译:模块只被计算一次

核心概念:@use vs @forward

@use:使用模块

@use 用于在当前文件中使用其他模块的功能。

// 基本用法
@use "sass:math";
@use "variables";

// 通过命名空间访问
.element {
  width: math.div(100%, 3);
  color: variables.$primary-color;
}

// 使用通配符(类似旧版行为)
@use "variables" as *;
.element {
  color: $primary-color; // 直接使用,无需前缀
}

// 自定义命名空间
@use "variables" as vars;
.element {
  color: vars.$primary-color;
}

@forward:转发模块

@forward 用于转发模块的成员,但不直接使用它们。常见于库的入口文件。

// 转发整个模块
@forward "variables";

// 选择性转发
@forward "sass:math" show div, ceil, floor;
@forward "components/button" hide _private-mixin;

// 重命名转发
@forward "sass:math" as math-*;
// 使用时会变成:math-div(), math-ceil()

实战迁移指南

场景1:基础变量和工具迁移

迁移前(@import):

// styles/variables.scss
$primary-color: #1890ff;
$border-radius: 4px;

// styles/mixins.scss
@mixin rounded-corners($radius: $border-radius) {
  border-radius: $radius;
}

// main.scss
@import "styles/variables";
@import "styles/mixins";

.button {
  color: $primary-color;
  @include rounded-corners;
}

迁移方案A:直接使用

// main.scss
@use "styles/variables" as vars;
@use "styles/mixins";

.button {
  color: vars.$primary-color;
  @include mixins.rounded-corners;
}

迁移方案B:创建库入口

// styles/_index.scss (库入口)
@forward "variables";
@forward "mixins";

// main.scss
@use "styles" as *; // 所有成员直接可用

.button {
  color: $primary-color;
  @include rounded-corners;
}

场景2:处理第三方库冲突

问题场景: 第三方库和你的代码都需要 sass:math

// ❌ 可能冲突的情况
// element-plus 内部已使用: @use "sass:math" as math;
// 你的代码中也使用: @use "sass:math" as math;

// ✅ 解决方案1:使用不同命名空间
@use "sass:math" as original-math;

.element {
  width: original-math.div(100%, 3);
}

// ✅ 解决方案2:创建包装函数
// utils/_math-utils.scss
@use "sass:math" as sass-math;

@function divide($a, $b) {
  @return sass-math.div($a, $b);
}

// 使用
@use "utils/math-utils" as math;
.element {
  width: math.divide(100%, 3);
}

场景3:构建组件库

项目结构:

ui-library/
├── foundation/
│   ├── _variables.scss
│   ├── _colors.scss
│   └── _index.scss
├── components/
│   ├── _button.scss
│   ├── _card.scss
│   └── _index.scss
└── index.scss

配置入口文件:

// ui-library/foundation/_index.scss
@forward "variables";
@forward "colors";
@forward "typography";

// ui-library/components/_index.scss
@forward "button" show button, button-variants;
@forward "card" show card;
// 隐藏私有成员
@forward "modal" hide _private-styles;

// ui-library/index.scss
@forward "foundation";
@forward "components";

// 业务代码中使用
@use "ui-library" as ui;

.custom-button {
  @extend ui.button;
  background-color: ui.$primary-color;
}

常见陷阱和解决方案

陷阱1:命名空间冲突

// ❌ 错误:相同的命名空间
@use "module1" as utils;
@use "module2" as utils; // 错误:命名空间 "utils" 重复

// ✅ 正确:使用不同的命名空间
@use "module1" as utils1;
@use "module2" as utils2;

陷阱2:@use 和 @forward 顺序错误

// ❌ 错误:@forward 必须在 @use 之前
@use "sass:color";
@forward "sass:math"; // 错误!

// ✅ 正确:正确的顺序
@forward "sass:math"; // 先转发
@use "sass:color";    // 后使用

陷阱3:忽略除法运算迁移

// ⚠️ 警告:传统除法将弃用
$ratio: 16/9; // 警告:Using / for division is deprecated

// ✅ 正确:使用 math.div()
@use "sass:math";
$ratio: math.div(16, 9);

陷阱4:在 @forward 文件中直接使用转发的成员

// utils/_index.scss
@forward "math-tools";

// ❌ 错误:不能在转发文件中直接使用转发的成员
$value: math.div(100, 2); // 错误!math 不可用

// ✅ 正确:需要单独 @use
@use "sass:math" as math;
$value: math.div(100, 2);
@forward "math-tools";

自动化迁移工具

Sass 官方提供了强大的迁移工具:

# 安装迁移工具
npm install -g sass-migrator

# 1. 迁移 @import 到 @use
sass-migrator import-to-use **/*.scss

# 2. 迁移除法运算
sass-migrator division **/*.scss

# 3. 同时处理多种文件类型
sass-migrator import-to-use --recursive "**/*.{scss,sass,vue}"

# 4. 带参数的迁移
sass-migrator import-to-use --namespace=lib "src/**/*.scss"

最佳实践总结

1. 命名策略

// 基础变量 → 通配符导入(方便使用)
@use "variables" as *;

// 工具函数 → 命名空间导入(避免冲突)
@use "utils/math" as math;

// 第三方库 → 使用短命名空间
@use "element-plus" as ep;

2. 文件组织

// 库/框架:使用 @forward 构建清晰的API
// _index.scss
@forward "foundation" show $colors, $typography;
@forward "components" hide _private-*;
@forward "utilities" as utils-*;

// 业务代码:使用 @use 明确依赖
@use "ui-library" as ui;
@use "project/utils" as utils;

3. 处理依赖关系

// 依赖图:A → B → C
// c.scss
$value: red;

// b.scss
@use "c" as *;
$color: $value;

// a.scss
@use "b" as *;
.element { color: $color; }

性能优化建议

  1. 减少重复计算:模块只计算一次,即使被多次 @use
  2. 合理使用缓存:构建工具通常会缓存编译结果
  3. 避免深层嵌套:过深的 @forward 链可能影响性能
  4. 按需导入:使用 show/hide 只导入需要的成员

版本兼容性

// package.json 版本建议
{
  "devDependencies": {
    "sass": "^1.58.0",     // 支持完整模块系统
    "sass-loader": "^13.2.0"
  }
}

写在最后

迁移到新的 Sass 模块系统看起来有些挑战,但带来的好处是实实在在的:

🎯 代码更清晰:明确的依赖关系和命名空间
🔧 维护更容易:模块化的结构便于重构
性能更好:智能的缓存和编译优化
🚀 面向未来:符合现代前端开发的最佳实践

迁移不是一次性的痛苦,而是一次性的投资。现在花时间迁移,未来将节省大量的调试和维护时间。

记住这个简单的决策流程:

  1. 构建库/框架 → 优先使用 @forward
  2. 编写业务代码 → 主要使用 @use
  3. 基础变量/配置 → 考虑 @use ... as *
  4. 工具函数 → 使用命名空间避免冲突

行动起来吧! 从今天开始,逐步将你的项目迁移到新的模块系统。你的未来代码库会感谢你现在做出的努力!


你的项目开始迁移了吗? 在迁移过程中遇到了什么有趣的问题或挑战?欢迎在评论区分享你的经验!

❌