欧阳城的技术博客

一个前端技术博客

作为一个天天和这两个框架打交道的开发者,这个问题我觉得挺有意思的。简单来说,这其实反映了两个框架在底层设计哲学上的根本差异。让我们来深入聊聊这个话题。

React 的”历史包袱”:从同步更新到异步调度

早期 React 的更新机制

Reactv16 之前的更新方式其实挺暴力的。当你调用 setState 或者 props 发生变化时,React 会:

  1. 从根组件开始,递归遍历整个组件树
  2. 对比新旧 Virtual DOM,找出需要更新的节点
  3. 同步地将所有变更一口气应用到真实 DOM 上

这个过程被称为 Reconciliation(协调过程)。

同步更新的痛点

想象一下你有个包含几千个组件的复杂应用(比如一个数据表格或者复杂的 Dashboard),当数据更新时:

1
2
3
4
// 这种操作在大型应用中可能会卡死页面
this.setState({
massiveDataUpdate: newData // 触发大量组件重新渲染
});

问题来了:

  • 主线程阻塞JavaScript 是单线程的,当 React 在递归遍历和更新时,浏览器无法处理用户交互(点击、输入、滚动等)
  • 掉帧现象:如果更新过程超过 16ms60fps 的一帧时间),用户就会感觉到明显的卡顿
  • 无法中断:一旦开始更新,就得硬着头皮走完整个流程,哪怕有更紧急的任务等着处理

Fiber 架构:化整为零的艺术

React 团队在 v16 中引入了 Fiber 架构,这是一次从底层重写协调算法的大工程。

核心思想:

  • 将原本的递归调用改为链表结构
  • 把大任务拆解成一个个小的 work unit(工作单元)
  • 每个 Fiber 节点代表一个组件,可以独立处理
  • 引入时间切片Time Slicing)和优先级调度

工作流程:

1
2
3
4
5
6
7
8
9
10
11
// 伪代码演示 Fiber 的工作方式
function workLoop(deadline) {
while (nextUnitOfWork && deadline.timeRemaining() > 0) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}

if (nextUnitOfWork) {
// 还有工作没完成,但是时间片用完了,下次继续
requestIdleCallback(workLoop);
}
}

实际效果:

  • 可以在每个小任务完成后检查是否有更高优先级的任务
  • 用户交互等高优先级任务可以”插队”执行
  • 充分利用浏览器的空闲时间,避免长时间占用主线程

Vue 的”天生优势”:响应式系统的精准打击

Vue 的设计哲学

Vue 从一开始就走了完全不同的路线。它采用响应式系统来追踪依赖关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Vue 2 中的响应式原理(简化版)
function defineReactive(obj, key, val) {
const dep = new Dep(); // 依赖收集器

Object.defineProperty(obj, key, {
get() {
if (Dep.target) {
dep.depend(); // 收集依赖
}
return val;
},
set(newVal) {
if (newVal !== val) {
val = newVal;
dep.notify(); // 通知所有依赖者更新
}
}
});
}

Vue 的更新机制

精准的依赖追踪:

  • 每个组件的 render 函数在执行时,会自动收集它用到的响应式数据
  • 当数据变化时,只有真正依赖这个数据的组件才会重新渲染

组件级别的更新粒度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!-- 父组件 -->
<template>
<div>
<child-a :data="dataA" />
<child-b :data="dataB" />
</div>
</template>

<script>
export default {
data() {
return {
dataA: 'hello',
dataB: 'world'
};
},
methods: {
updateA() {
// 只会触发 child-a 重新渲染,child-b 不受影响
this.dataA = 'hi';
}
}
};
</script>

为什么 Vue 不需要 Fiber?

1. 没有”全量 diff”的负担
Vue 知道哪些组件需要更新,不会像 React 早期那样”无脑遍历”整棵树。

2. 天生的异步批处理
Vue 使用 nextTick 机制,将同一轮事件循环中的所有数据变更合并成一次更新:

1
2
3
4
5
// 多次修改数据,只会触发一次重新渲染
this.message = 'hello';
this.message = 'world';
this.message = 'vue';
// 上面三行代码只会在下一个 tick 触发一次更新

3. 更小的更新单元
每次更新通常只涉及少数几个组件,任务本身就比较轻量,不会造成长时间的主线程阻塞。

Vue 3 的进一步优化

虽然 Vue 不需要类似 Fiber 的架构,但在 Vue 3 中还是做了不少优化:

编译时优化:

  • 静态标记PatchFlag):编译时标记哪些节点是动态的
  • 静态提升:将静态节点提升到渲染函数外部,避免重复创建
1
2
3
4
5
6
7
// 编译后的代码(简化版)
function render() {
return createVNode("div", null, [
_hoisted_1, // 静态节点,只创建一次
createVNode("p", null, this.message, 1 /* TEXT */) // 动态文本
]);
}

更精细的响应式系统:
使用 Proxy 替代 Object.defineProperty,支持更多的操作类型和更好的性能。

总结:不同的问题需要不同的解决方案

React Fiber 的本质:

  • 解决早期架构设计中同步更新导致的性能瓶颈
  • 通过时间切片和优先级调度,让应用保持流畅响应

Vue 响应式系统的优势:

  • 从设计之初就避免了”大任务”问题
  • 精准的依赖追踪 + 组件级更新粒度
  • 天生的异步批处理机制

说白了,ReactFiber 架构是为了”修补”早期设计的不足,而 Vue 的响应式系统从一开始就走在了正确的道路上。这就像两个人解决同一个问题:一个是先犯错再改正,另一个是从头就做对了。

不过这也不是说 React 不好,Fiber 架构的引入让 React 在处理复杂交互和动画方面变得更加出色,也为后续的 Concurrent Features 奠定了基础。两个框架各有各的优势,选择哪个主要还是看具体的使用场景和团队偏好。

微信小程序的流畅体验,离不开其独特的技术架构。与传统网页不同,小程序采用了一个双线程架构,将业务逻辑与页面渲染彻底分离。在这套架构中,JsCoreJSBridge 扮演着至关重要的角色,它们是小程序高效运行的幕后英雄。

JsCore:小程序的大脑

JsCore 是微信小程序运行环境中的一个精简版 JavaScript 解释器。它位于小程序的逻辑层(App Service,这个独立的线程专门负责处理所有业务逻辑。JsCore 之所以”精简”,是因为它剔除了浏览器中与 DOM 操作、CSS 渲染等直接相关的部分。

说白了,这就是一个阉割版的 JavaScript 引擎,专门为小程序定制的。它只专注于以下几个任务:

  • 执行业务代码:处理你在 .js 文件中编写的所有函数、数据和 API 调用,比如发起网络请求什么的
  • 管理数据与状态:当你调用 this.setData 改变数据时,JsCore 会生成数据更新指令
  • 控制生命周期:负责触发和执行 onLoadonShow 等生命周期函数

这种设计的好处很明显:让 JsCore 专心搞复杂的逻辑计算,避免了与页面渲染互相打架,大大提升了小程序的运行效率。

JSBridge:连接大脑与页面的桥梁

如果 JsCore 是小程序的”大脑”,那么 JSBridge 就是连接”大脑”与”页面”(视图层)的”桥梁”。由于逻辑层和视图层是相互独立的线程,它们之间没法直接通信,JSBridge 的核心作用就是打破这种隔阂,实现跨线程的数据与事件传递。

需要注意的是,JSBridge 本身并不是纯粹的 JavaScript 代码,而是一个通信机制或者说协议。它的实现依赖于小程序底层的原生代码(Native Code)。

JSBridge 的组成部分

JSBridge 主要包含两大部分:

  1. JavaScript 层的封装:你在小程序代码里看到的 wx.scanCodewx.requestAPI,就是 JSBridgeJavaScript 层的封装。这些函数内部并不直接执行任务,而是向底层发送一条消息

  2. 原生层的实现:这是 JSBridge 的真正核心。这部分代码由微信客户端的原生语言(如 iOS 上的 Objective-C/SwiftAndroid 上的 Java/Kotlin)编写,负责监听来自 JavaScript 层的消息,解析指令,然后调用相应的系统原生 API

协同工作机制:以 wx.scanCode 为例

JsCoreJSBridge 的协同工作构成了一个完整的闭环。以调用 wx.scanCode 方法为例,整个流程是这样的:

  1. 发起调用:当你在小程序逻辑层调用 wx.scanCode 时,JsCore 识别到这是一个需要调用原生能力的 API

  2. 指令传递wx.scanCode 函数通过 JSBridge 将一条指令(包含方法名、参数和回调 ID)打包成 JSON 字符串,发送给原生层

  3. 原生层执行:原生层通过消息队列等机制接收并解析这条指令。它会调用手机系统提供的原生 API,启动摄像头并进行扫码

  4. 结果返回:扫码成功后,原生层将结果打包成一个新的数据包,通过 JSBridge 的通信通道返回给逻辑层

  5. 处理回调JsCore 接收到数据包后,根据其中的回调 ID,找到并执行对应的 successfail JavaScript 回调函数,最终你在小程序中看到扫码结果

整个过程就像是一个消息传递系统,各个组件各司其职,配合得天衣无缝。

总结

JsCore 负责前端业务逻辑,而 JSBridge 则负责让这些逻辑能够与底层的原生能力和视图层进行交互。

作为开发者,你可能会好奇:微信小程序的视图层明明是基于 WebView 渲染的,为什么还能使用原生组件?这不是矛盾吗?

其实这背后有个很有趣的架构设计。让我们来聊聊小程序是怎么做到既有 H5 的开发便利性,又能提供接近原生 App 体验的。

小程序的基础架构

首先,你的理解没错:小程序视图层确实是基于 WebView 渲染的

微信小程序本质上运行在微信 App 这个宿主环境里。为了实现跨平台和快速渲染,团队选择了成熟的 WebView 技术作为基础。但这里的”原生组件”其实有点特殊。

什么是小程序的”原生组件”?

这里说的”原生组件”并不是指像 iOSUIButtonAndroidTextView 那样的完全原生控件。

它们实际上是:

  • 小程序框架通过 JSBridge 调用的组件
  • 由微信客户端实现的、具有类似原生性能的组件
  • 在渲染层面上覆盖在 WebView 之上的真实原生控件

混合渲染的工作原理

你可以把这个过程想象成一个分层渲染的机制:

第一层:WebView 渲染层

  • 负责渲染你的 WXMLWXSS 代码
  • 就像一张画布,处理基础的布局和样式

第二层:原生组件覆盖层

当小程序视图层渲染到 <video><map><canvas> 这类特殊组件时:

  1. 通信阶段:视图层不会在 WebView 的画布上直接绘制这些复杂内容
  2. 指令传递:视图层通过 JSBridge 通知微信客户端:”嘿,我这里需要一个视频播放器,坐标是 (x, y),尺寸是 (w, h)”
  3. 原生渲染:微信客户端收到指令后,在 WebView 上方的屏幕物理层面,直接放置一个真正的原生视频播放器控件
  4. 层叠显示:这个原生组件会盖在 WebView 的内容上面

最终效果

当你看到一个小程序页面时,实际上看到的是一个混合视图

  • 基础组件(如 <view><text>)→ 由 WebView 渲染
  • 复杂组件(如 <video><map><camera>)→ 通过原生组件覆盖渲染

为什么要用混合渲染?

这种架构设计其实是微信团队为了平衡开发效率和性能体验做出的巧妙妥协:

开发效率优势

  • 大部分基础组件用 WebView 渲染
  • 开发者可以使用熟悉的类 HTML/CSS 语法
  • 快速构建界面,天然支持跨平台

性能体验优势

对于那些性能要求高、需要调用底层硬件能力的组件:

  • 地图定位:需要 GPS 和复杂的地图渲染
  • 视频播放:需要硬件解码和流畅的播放体验
  • 相机拍摄:需要直接访问摄像头硬件

如果这些功能都用 WebView 渲染,性能会非常差,用户体验会大打折扣。使用原生组件可以获得接近原生应用的流畅体验。

总结

小程序的”原生组件”并不意味着整个视图层都是原生的。它指的是一种:

  • 由微信客户端渲染
  • 具有原生性能
  • 可以被 WebView 调用的特殊组件

这种混合渲染模式正是小程序能够兼具 H5 开发便捷性和原生 App 流畅体验的关键所在。作为开发者,理解这个架构有助于我们更好地利用各种组件的特性,写出性能更优的小程序。

Vite 项目的开发过程中,你可能经常会在 vite.config.js 文件中看到 server: { host: '0.0.0.0' } 这样的配置。这行看似简单的代码,却蕴含着重要的网络配置原理。本文将深入解析这个配置的作用、背后的网络原理以及相关的代码实现。

1. host: '0.0.0.0' 配置的作用

1.1 核心功能

host: '0.0.0.0' 的主要作用是让 Vite 开发服务器监听所有网络接口,从而实现局域网内跨设备访问。

1.2 默认行为对比

  • 默认配置Vite 服务器只绑定到 localhost127.0.0.1),仅限本机访问
  • 设置 0.0.0.0:服务器监听所有可用的网络接口,支持跨设备访问

1.3 实际应用场景

当配置 host: '0.0.0.0' 后,你可以:

  1. 在同一局域网的其他设备(手机、平板、其他电脑)上访问开发中的应用
  2. 进行跨设备的实时调试和测试
  3. 演示应用时无需部署到线上环境

2. 原理解析

2.1 IP 地址绑定机制

在网络编程中,服务器启动时需要绑定到特定的 IP 地址和端口。不同的 IP 地址有不同的监听范围:

  • 127.0.0.1:本地回环地址(loopback),只接受来自本机的连接
  • 0.0.0.0:通配符地址(wildcard address),监听所有可用的网络接口
  • 具体局域网 IP:只监听特定网络接口

2.2 套接字(Socket)绑定过程

当服务器绑定到 0.0.0.0 时,操作系统会:

  1. 在所有活动的网络接口上监听指定端口
  2. 接受来自本地回环接口(127.0.0.1)的连接
  3. 接受来自局域网接口(如 192.168.x.x)的连接
  4. 接受来自其他网络接口的连接

简而言之,0.0.0.0 是一个通配符地址,它代表“这台电脑上所有可用的 IPv4 地址”。它是一种方便的设置,能让服务器在各种网络环境下自动适应,而无需手动指定每个 IP 地址。

2.3 网络接口类型

典型的网络接口包括:

  • 本地回环接口127.0.0.1,用于本机进程间通信
  • 以太网接口:有线网络连接的 IP 地址
  • 无线网络接口Wi-Fi 连接的 IP 地址
  • 虚拟网络接口Docker、虚拟机等创建的接口

问题背景

在日常开发过程中,我们经常会遇到一些类型定义,它们既有内置的可穷举的字面量值,又要支持开发者自己输入。比如 CSScolor 属性,既可以用 redgreen 这些内置的颜色值,也可以用 #ff0000 这样的十六进制值。

传统联合类型的局限性

这种情况下,我们可能会首先想到使用 TypeScript 中的联合类型 (Union Type) 来定义这个类型:

1
type Color = 'red' | 'green' | 'blue' | string

但是这样定义存在一个问题:最终 Color 的类型其实是 string,编写代码时编辑器不会给出任何特定的颜色提示。

可扩展联合类型的解决方案

为了解决这个问题,我们可以使用可扩展联合类型:

1
type Color = 'red' | 'green' | 'blue' | (string & {})

原理与作用

type Color = 'red' | 'green' | 'blue' | (string & {}) 这段代码创建了一个联合类型,它包含了两部分:

  1. 'red' | 'green' | 'blue':一个字面量类型,表示精确匹配字符串 'red''green''blue'
  2. (string & {}):这部分是核心,它创建了一个特殊的可扩展类型

为什么 (string & {}) 是可扩展的?

TypeScript 中,{} 代表一个空对象类型,它可以匹配任何非 nullundefined 的值。当一个类型与 {} 进行交叉(&)时,结果会是原类型,但它会告诉 TypeScript 编译器这个类型可以被扩展。

所以,string & {} 的结果就是 string 类型,但它带上了一个特殊的”标记”。

最终效果

最终,type Color 的完整类型是 string,但它包含了一个额外的”字面量提示”。这意味着当你使用 Color 类型时,IDE 会优先提示 'red' 这个值,但同时允许你传入任何其他字符串。

核心优势

'red' | 'green' | 'blue' | (string & {}) 这种语法是一种巧妙的组合,它的作用是:

  • 提供类型安全:它确保传入的值必须是字符串
  • 改善开发者体验:它通过字面量提示,让开发者知道哪些是常用的、推荐的值
  • 保持灵活性:它允许你传入任何其他字符串,而不仅仅局限于预设的几个值

应用场景

这种模式在构建组件库时尤其有用,因为它能在提供友好提示的同时,不限制用户的自定义能力。例如:

1
2
3
4
5
6
7
8
// UI 组件的尺寸定义
type Size = 'small' | 'medium' | 'large' | (string & {})

// 主题颜色定义
type ThemeColor = 'primary' | 'secondary' | 'danger' | (string & {})

// 动画类型定义
type Animation = 'fade' | 'slide' | 'bounce' | (string & {})

通过这种方式,我们既能为常用场景提供良好的开发体验,又能保持 API 的灵活性和扩展性。

TypeScript 作为 JavaScript 的静态类型超集,通过其强大的类型系统帮助开发者构建更加健壮和可维护的代码。然而,即使是这样优秀的工具,在某些常用的内置 API 中仍存在类型定义不够精确的”类型陷阱”。这些陷阱如果不加以注意,往往会成为潜在的运行时错误源头。

本文将深入分析三个最常见的 TypeScript 类型陷阱,并提供相应的解决方案:

  1. JSON.parse() 返回 any 类型的问题
  2. try...catch 语句中 error 参数的 any 类型问题
  3. Object.keys()Object.entries() 的类型泛化问题

1. JSON.parse() 的类型黑洞陷阱

在日常开发中,处理来自网络请求或本地存储的 JSON 字符串是常见需求。JSON.parse() 作为标准解析方法,其 TypeScript 类型定义却存在明显缺陷:

1
2
// TypeScript 内置的 JSON.parse 类型定义
function JSON.parse(text: string, reviver?: (this: any, key: string, value: any) => any): any;

为什么返回 any 类型?

这是 TypeScript 早期为兼容 JavaScript 动态特性而做出的设计妥协。由于编译器无法在编译时确定运行时传入的 JSON 字符串的具体结构(可能是对象、数组、字符串、数字或 null),返回 any 类型成为了最通用的解决方案。

潜在风险

any 类型会完全关闭 TypeScript 的类型检查机制,导致以下问题:

1
2
3
4
5
const jsonString = '{"name": "Alice"}';
const data = JSON.parse(jsonString); // data: any

// 编译时通过,但运行时会抛出 TypeError
console.log(data.age.toFixed()); // Cannot read property 'toFixed' of undefined

2. try...catch 中的 error 类型陷阱

JavaScript/TypeScript 中,catch 块捕获的 error 参数默认为 any 类型:

1
2
3
4
5
6
7
try {
// 可能抛出异常的代码
throw new Error("Something went wrong");
} catch (error) {
// error: any
console.log(error.message); // 编译通过,但存在潜在风险
}

潜在风险

由于 JavaScript 中可以抛出任何类型的值(不仅仅是 Error 对象),error 参数的 any 类型会带来类型安全隐患:

1
2
3
4
5
try {
throw "This is a string error";
} catch (error) {
console.log(error.message); // 运行时错误:undefined 没有 message 属性
}

3. Object.keys()Object.entries() 的类型泛化问题

问题分析

Object.keys()Object.entries() 的返回类型过于宽泛,在部分场景存在类型信息丢失:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface UserData {
name: string;
age: number;
}

const user: UserData = { name: 'Bob', age: 25 };

// Object.keys 返回 string[],而非更精确的 ('name' | 'age')[]
const keys = Object.keys(user);

keys.forEach((key) => {
// key: string,导致 user[key] 类型为 any
const value = user[key]; // TypeScript 错误:元素隐式具有 'any' 类型
});

4. 解决方案

JSON.parse()/try…catch 返回值的类型问题为 any

启用 useUnknownInCatchVariables

tsconfig.json 中启用此选项(TypeScript 4.4+):

1
2
3
4
5
{
"compilerOptions": {
"useUnknownInCatchVariables": true
}
}

启用后,error 参数类型变为 unknown,强制进行类型守卫:

1
2
3
4
5
6
7
8
9
10
11
12
try {
throw new Error("Something went wrong");
} catch (error) {
// error: unknown
if (error instanceof Error) {
console.log(error.message); // 类型安全
} else if (typeof error === 'string') {
console.log(error);
} else {
console.log('Unknown error occurred');
}
}

使用 @total-typescript/ts-reset

@total-typescript/ts-reset 是一个社区库,通过扩展全局类型定义来修正 TypeScript 的默认行为,让代码更加类型安全。

引入后,以下 API 的类型行为会被自动修正:

  1. JSON.parse() 返回 unknown 而非 any
  2. catch 变量 类型变为 unknown
1
2
3
4
5
6
7
8
9
10
11
// 引入 ts-reset 后
const data = JSON.parse('{"name": "Alice"}'); // data: unknown

try {
throw new Error("test");
} catch (error) {
// error: unknown
if (error instanceof Error) {
console.log(error.message); // 类型安全
}
}

Object.keys()Object.entries() 的类型泛化

通过泛型约束,我们可以为 Object.keys()Object.entries() 提供更精确的类型定义:

1
2
3
4
5
6
7
function typedKeys: <K extends keyof any, V>(record: Record<K, V>) => K[] = Object.keys

function typedEntries: <K extends keyof any, V>(record: KVMap<K, V>) => Array<[K, V]> = Object.entries

// 使用
const keys = typedKeys(user); // (keyof UserData)[]
const entries = typedEntries(user); // Array<[keyof UserData, string | number]>

处理 unknown 类型的策略

unknown 的处理可以参考:拥抱 unknown,告别 any - TypeScript 类型安全最佳实践

总结

TypeScript 的类型系统虽然强大,但在某些内置 API 的设计上仍存在历史包袱。通过理解这些类型陷阱的根本原因,并采用适当的解决方案,我们可以显著提高代码的类型安全性:

  1. 对于 JSON.parse():结合运行时验证库如 Zod 使用
  2. 对于 try...catch:启用 useUnknownInCatchVariables 或使用 ts-reset
  3. 对于 Object.keys/entries:使用自定义工具函数或 ts-reset
  4. 一键解决:使用 @total-typescript/ts-reset

通过这些实践,我们可以在享受 TypeScript 类型安全带来的开发体验提升的同时,避免常见的类型陷阱,构建更加健壮的应用程序。

浏览器的每个页面都有一个单线程的 JavaScript 引擎(可参考我的另一篇文章:浏览器是单线程的?),这意味着它只有一个主线程来处理所有任务。这个主线程身兼数职,既要渲染页面、执行 JavaScript 代码,又要处理用户交互。

如果一个任务需要很长时间才能完成,比如网络请求、文件读写或者数据库查询,它就会阻塞主线程,导致程序在这段时间内没有任何响应,用户界面也会卡住。

事件循环机制就是为了解决这个问题而设计的。它将耗时的 I/O 操作变成了异步的,避免了因等待耗时操作而导致的“卡顿”和无响应,从而大大提高了程序的性能和用户体验。

事件循环的核心组成

事件循环主要由三个核心部分构成:

1. 调用栈(Call Stack

这是一个后进先出的数据结构,用来追踪正在执行的函数。每当一个函数被调用时,它就会被推入栈中;当函数执行完毕,它就会被弹出。

2. Web APIs

浏览器提供的一系列非主线程的异步功能,比如:

  • setTimeoutsetInterval
  • AJAX 请求
  • DOM 事件监听
  • requestAnimationFrame

当主线程遇到这些异步任务时,它会将它们交给 Web APIs 处理,然后立即继续执行栈中的下一个任务,而不会等待异步任务完成。

3. 任务队列(Task Queue)

Web APIs 中的异步任务执行完成后,比如一个定时器时间到了,或者一个网络请求返回了数据,它会把对应的回调函数放入任务队列中等待。

任务队列的分类

宏任务队列

代表一个相对完整的、独立的代码块

包括:

  • script(整体代码块)
  • setTimeoutsetInterval
  • I/O 操作(如文件读写、网络请求)
  • UI 渲染、DOM 事件
  • requestAnimationFrame(浏览器环境)
  • MessageChannelReact 实现时间切片)

微任务队列

代表优先级更高任务

包括:

  • Promise.then().catch().finally()
  • MutationObserver
  • queueMicrotask()

事件循环的执行规则

核心规则:执行完一个宏任务后,立即清空所有微任务,然后再进行 UI 渲染(如有必要),最后再开启下一个宏任务。

详细执行流程:

  1. 首先,整个 <script> 标签里的代码作为一个宏任务开始执行
  2. 在执行过程中,同步代码直接在调用栈上运行
  3. 如果遇到一个宏任务(如 setTimeout),就把它交给对应的 Web API,并在指定时间后将其回调函数放入宏任务队列
  4. 如果遇到一个微任务(如 Promise.then),就将其回调函数放入微任务队列
  5. 当前这个宏任务(即主脚本)执行完毕,调用栈变空
  6. 关键时刻来了!事件循环会立即检查微任务队列,将里面所有的微任务依次取出并执行,直到微任务队列被清空
  7. 微任务清空后,浏览器会进行一次 UI 渲染(如果页面有变化的话)
  8. 渲染完成后,会继续执行下一个宏任务
  9. 重复以上步骤,直到任务队列中没有宏任务为止

简单来说,宏任务是“大块”的任务,每次循环只执行一个;而微任务是“小而急”的任务,会在当前宏任务执行完后被立即“插队”全部执行完,然后再开始下一个宏任务。

实际应用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 示例:事件循环执行顺序
console.log('1'); // 同步代码,立即执行

setTimeout(() => {
console.log('2'); // 宏任务
}, 0);

Promise.resolve().then(() => {
console.log('3'); // 微任务,当前宏任务执行完成后执行所有的微任务
});

console.log('4'); // 同步代码,立即执行

// 输出顺序:1 → 4 → 3 → 2

这个示例展示了事件循环的执行顺序:

  1. 先执行同步代码(1, 4)
  2. 然后执行所有微任务(3)
  3. 最后执行宏任务(2)

复杂示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
console.log('start');

setTimeout(() => {
console.log('setTimeout1');
Promise.resolve().then(() => {
console.log('promise1');
});
}, 0);

Promise.resolve().then(() => {
console.log('promise2');
setTimeout(() => {
console.log('setTimeout2');
}, 0);
});

console.log('end');

// 输出顺序:start → end → promise2 → setTimeout1 → promise1 → setTimeout2

扩展

React 时间切片为什么用宏任务实现而不用微任务实现

首先时间切片的目标不是尽快完成任务,而是在不阻塞主线程的前提下,将一个大的计算任务分解成小的、可中断的单元。

如果 React 使用微任务来实现时间切片,它会把所有待处理的更新任务都放入微任务队列。由于微任务会在当前宏任务执行完后全部执行,React 就会一次性霸占主线程,直到所有更新计算完毕。这和“时间切片”的初衷——让出主线程——完全背道而驰。UI 依然会因为长时间的计算而卡死,用户无法进行任何交互。

使用宏任务,React 可以在执行完一小部分更新后,主动暂停,将剩余的工作放入一个新的宏任务(通过 MessageChannelsetTimeout)。这样,事件循环就可以在两次宏任务之间运行,浏览器就能在中间插入执行 UI 渲染任务和响应用户的点击、键盘输入。这种“暂停-恢复”的机制正是时间切片的核心所在。

TypeScript 的类型系统中,unknown 类型被誉为比 any 更安全的顶级类型。它的设计初衷是为了处理那些在编译时无法确定具体类型的数据,例如:

  • 网络 API 的响应数据
  • 部分第三方库的返回值
  • JSON.parse() 解析的结果
  • 用户输入或外部数据源

any 类型允许任意操作不同,unknown 强制开发者在使用其值之前进行类型检查和验证。这种设计哲学体现了 TypeScript 的核心价值:在编译时尽可能多地捕获潜在错误,同时保持运行时的类型安全。

为什么 unknown 优于 any

unknown 的核心优势在于其强制类型检查机制

  • 编译时保护:任何对 unknown 值的操作都需要先进行类型收窄
  • 运行时安全:避免了因类型假设错误而导致的运行时异常
  • 代码可维护性:明确表达了”此处类型不确定”的语义,提醒后续维护者注意

然而,在实际开发中,许多开发者面对 unknown 时会采用一些反模式,这些做法不仅削弱了类型安全性,还可能引入潜在的运行时风险。

反模式:危险的类型断言

问题分析

最常见但最危险的处理方式是使用类型断言(as)绕过类型检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 假设这是来自 API 的响应数据
const apiResponse: unknown = { id: 123, name: "Alice" };

// ❌ 反模式:直接进行类型断言
const user = apiResponse as { id: number; name: string };

// 表面上看起来正常
console.log(user.name); // "Alice"

// 但如果实际数据结构不符预期...
const malformedResponse: unknown = { id: 123, username: "Alice" }; // 注意:是 username 而不是 name
const malformedUser = malformedResponse as { id: number; name: string };

console.log(malformedUser.name); // undefined - 潜在的运行时错误!
// 后续操作可能导致 TypeError: Cannot read property of undefined

根本问题

类型断言本质上是在告诉 TypeScript 编译器:”相信我,我知道这个值的确切类型”。这种做法:

  1. 绕过了编译时检查TypeScript 不再验证类型的正确性
  2. 将风险转移到运行时:一旦数据结构不符合断言,程序就会出现不可预测的行为
  3. 失去了使用 unknown 的初衷:本质上和使用 any 没有区别

最佳实践:运行时验证与类型收窄

要安全地处理 unknown 类型,我们需要在运行时验证数据的实际结构,并基于验证结果进行类型收窄。以下介绍两种业界推荐的工具和方法。

方案一:使用 Zod 进行 Schema 验证

Zod 是一个功能强大的 TypeScript 优先的模式验证库,它提供了声明式的 Schema 定义运行时验证的双重能力。

核心优势

  • 类型安全的 Schema 定义Schema 本身就是类型信息的来源
  • 自动类型推断:验证通过后自动推断出精确的 TypeScript 类型
  • 详细的错误报告:提供可读性强的验证失败信息
  • 组合式设计:支持复杂数据结构的嵌套验证

实践示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import { z } from 'zod';

// 1. 定义数据的 Schema
const UserSchema = z.object({
id: z.number().positive(), // 正整数
name: z.string().min(1), // 非空字符串
email: z.string().email(), // 有效的邮箱格式
age: z.number().int().min(0).max(120).optional(), // 可选的年龄字段
});

// 2. 自动推断类型(无需手动定义接口)
type User = z.infer<typeof UserSchema>;
// 等价于:
// type User = {
// id: number;
// name: string;
// email: string;
// age?: number;
// }

// 3. 处理 unknown 数据
const processApiResponse = (rawData: unknown) => {
try {
// 使用 parse() 进行严格验证
const user = UserSchema.parse(rawData);

// ✅ 此时 user 的类型已被安全地推断为 User
console.log(`用户 ${user.name} 的邮箱是 ${user.email}`);
return { success: true, data: user };
} catch (error) {
// Zod 提供详细的错误信息
if (error instanceof z.ZodError) {
console.error("数据验证失败:", error.issues);
return { success: false, errors: error.issues };
}
throw error;
}
};

// 4. 非抛出式验证
const safeParsing = (rawData: unknown) => {
const result = UserSchema.safeParse(rawData);

if (result.success) {
// ✅ result.data 的类型为 User
return result.data;
} else {
// ❌ result.error 包含验证错误详情
console.error("解析失败:", result.error.format());
return null;
}
};

方案二:使用 ts-pattern 进行模式匹配

ts-pattern 提供了函数式编程风格的模式匹配能力,特别适合处理**辨识联合类型(Discriminated Union)**的场景。

核心特性

  • 声明式模式匹配:类似于函数式语言的 match 表达式
  • 编译时完整性检查:确保所有可能的模式都被处理
  • 类型自动收窄:在每个匹配分支中自动推断精确类型
  • Exhaustive 检查:防止遗漏任何情况

实践示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { match, P } from 'ts-pattern';

// 定义 API 响应的联合类型
type APIResult =
| { status: 'success'; data: { users: User[]; total: number } }
| { status: 'error'; code: 'NETWORK_ERROR' | 'VALIDATION_ERROR'; message: string }
| { status: 'loading' }
| { status: 'empty' };

// 处理 unknown 类型的 API 响应
const handleAPIResponse = (response: unknown) => {
return match(response)
.with({ status: 'success' }, (res) => {
// ✅ res 的类型被自动收窄为 success 分支的类型
console.log(`成功获取 ${res.data.total} 条用户数据`);
return res.data.users;
})
.with({ status: 'error' }, (res) => {
// ✅ res 的类型被自动收窄为 error 分支的类型
console.error(`请求失败: ${res.code} - ${res.message}`);
throw new Error(res.message);
})
.with({ status: 'loading' }, () => {
console.log("数据加载中...");
return null;
})
.with({ status: 'empty' }, () => {
console.log("暂无数据");
return [];
})
.exhaustive(); // 编译时检查:确保所有情况都被覆盖
};

实际应用场景

场景一:API 响应处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 使用 Zod 处理 REST API 响应
const fetchUserProfile = async (userId: string): Promise<User | null> => {
try {
const response = await fetch(`/api/users/${userId}`);
const rawData: unknown = await response.json();

// 验证响应数据结构
const user = UserSchema.parse(rawData);
return user;
} catch (error) {
if (error instanceof z.ZodError) {
console.error("API 响应格式不正确:", error.issues);
} else {
console.error("网络请求失败:", error);
}
return null;
}
};

场景二:WebSocket 消息处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 使用 ts-pattern 处理 WebSocket 消息
type WebSocketMessage =
| { type: 'chat'; content: string; sender: string }
| { type: 'notification'; title: string; body: string }
| { type: 'system'; action: 'connect' | 'disconnect'; userId: string };

const handleWebSocketMessage = (rawMessage: unknown) => {
match(rawMessage)
.with({ type: 'chat' }, (msg) => {
displayChatMessage(msg.sender, msg.content);
})
.with({ type: 'notification' }, (msg) => {
showNotification(msg.title, msg.body);
})
.with({ type: 'system', action: 'connect' }, (msg) => {
console.log(`用户 ${msg.userId} 已连接`);
})
.with({ type: 'system', action: 'disconnect' }, (msg) => {
console.log(`用户 ${msg.userId} 已断开连接`);
})
.otherwise((msg) => {
console.warn("未知的消息格式:", msg);
})
.exhaustive();
};

更具体的可看我的另一篇文章:告别 if/else 和 switch:模式匹配在 TypeScript 中的新范式

工具选择指南

场景 推荐工具 原因
API 响应验证 Zod 提供详细的验证错误,支持复杂嵌套结构
联合类型处理 ts-pattern 声明式语法,编译时完整性检查

总结

unknown 类型的引入标志着 TypeScript 对类型安全性的进一步重视。它提醒我们:真实世界中的数据是不可预测的,我们需要在运行时验证这些数据的结构和内容

通过采用正确的处理方式:

  1. 拒绝类型断言的诱惑:不要用 as 绕过类型检查
  2. 拥抱运行时验证:使用 Zod 等工具确保数据结构的正确性
  3. 善用模式匹配:通过 ts-pattern 优雅地处理复杂的联合类型
  4. 建立防御性编程思维:始终假设外部数据可能不符合预期

这些实践不仅能够显著提高代码的健壮性和可维护性,还能帮助我们构建出真正类型安全的 TypeScript 应用程序。

PS类型安全不是编译时的一次性检查,而是贯穿整个应用生命周期的持续保障。只有将编译时检查与运行时验证相结合,我们才能充分发挥 TypeScript 的威力,构建出可靠、安全的现代应用。

在现代 TypeScript 开发中,处理复杂的数据结构和状态流是日常开发的重要组成部分。然而,传统的 if/elseswitch 语句往往使代码变得冗长、脆弱且难以维护。ts-pattern 库将强大的模式匹配概念引入 TypeScript 生态系统,从根本上改变了我们处理复杂逻辑的方式,不仅解决了传统控制流语句的痛点,还为代码带来了前所未有的类型安全性和可读性。

传统方案的核心问题

1. 冗长且类型不安全的 if/else

在处理**可辨识联合类型(Discriminated Union Types)**时,开发者通常需要编写冗长的 if/else 判断链来进行类型区分。这种方法存在严重的类型安全隐患:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type ApiResponse = 
| { type: 'success'; data: any }
| { type: 'error'; message: string }
| { type: 'loading' }
| { type: 'empty' };

// 传统 if/else 方式的问题
function handleResponse(response: ApiResponse) {
if (response.type === 'success') {
return response.data;
} else if (response.type === 'error') {
throw new Error(response.message);
} else if (response.type === 'loading') {
return 'Loading...';
}
// 如果新增了 'empty' 类型但忘记处理,TypeScript 不会报错
// 这会在运行时导致 undefined 返回值
}

核心问题

  • 缺乏穷举检查TypeScript 无法保证所有联合类型分支都被处理
  • 维护困难:新增类型时容易遗漏对应的处理逻辑
  • 运行时风险:未处理的分支可能导致意外的运行时行为

2. switch 语句的结构性局限

switch 语句虽然在语法上比 if/else 更简洁,但其功能局限性显著:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// switch 只能进行简单的值匹配
function processData(data: unknown) {
switch (typeof data) {
case 'string':
return data.toUpperCase();
case 'number':
return data * 2;
default:
// 无法进一步判断对象的具体结构
return null;
}
}

// 无法处理复杂的结构匹配需求
type ComplexData =
| { kind: 'user'; profile: { name: string; age: number } }
| { kind: 'admin'; permissions: string[] };

// switch 无法优雅地处理嵌套结构的匹配

核心限制

  • 仅支持原始值匹配:无法处理对象结构、数组模式等复杂匹配
  • 结构检查困难:不能直接判断对象是否包含特定属性或符合特定结构
  • 类型收窄有限:在复杂数据结构中类型收窄效果不佳

3. unknown 类型处理的复杂性

处理来自 API 或外部数据源的 unknown 类型数据时,需要大量的类型守卫和嵌套检查(unknown 的详细介绍可查看:拥抱 unknown,告别 any - TypeScript 类型安全最佳实践):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 传统的 unknown 类型处理方式
function processApiResponse(response: unknown) {
if (typeof response === 'object' && response !== null) {
const obj = response as Record<string, unknown>;

if ('user' in obj && typeof obj.user === 'object' && obj.user !== null) {
const user = obj.user as Record<string, unknown>;

if ('name' in user && typeof user.name === 'string') {
// 经过多层检查后才能安全使用
console.log(`User: ${user.name}`);
return;
}
}

if ('error' in obj && typeof obj.error === 'string') {
console.error(`Error: ${obj.error}`);
return;
}
}

console.warn('Unknown response format');
}

核心痛点

  • 嵌套层次深:多层类型检查导致代码可读性差
  • 样板代码多:大量重复的类型守卫逻辑
  • 维护成本高:结构变更时需要修改多个检查点

ts-pattern 带来的革命性改进

1. 强制穷举检查与类型安全

ts-pattern 的核心优势在于其强制的**穷举检查(Exhaustiveness Checking)**机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { match } from 'ts-pattern';

type ApiResponse =
| { type: 'success'; data: any }
| { type: 'error'; message: string }
| { type: 'loading' }
| { type: 'empty' };

function handleResponse(response: ApiResponse) {
return match(response)
.with({ type: 'success' }, (res) => res.data)
.with({ type: 'error' }, (res) => {
throw new Error(res.message);
})
.with({ type: 'loading' }, () => 'Loading...')
.with({ type: 'empty' }, () => 'No data available')
.exhaustive(); // 强制穷举检查
}

// 如果新增了类型分支但未处理,TypeScript 会在编译时报错
type ExtendedResponse = ApiResponse | { type: 'timeout'; duration: number };

function handleExtendedResponse(response: ExtendedResponse) {
return match(response)
.with({ type: 'success' }, (res) => res.data)
.with({ type: 'error' }, (res) => {
throw new Error(res.message);
})
.with({ type: 'loading' }, () => 'Loading...')
.with({ type: 'empty' }, () => 'No data available')
// .with({ type: 'timeout' }, ...) // 遗漏这行会导致 TypeScript 编译错误
.exhaustive();
}

2. 丰富的模式匹配能力

ts-pattern 通过 P 模块提供了强大的模式守卫系统,支持多种匹配模式:

对象结构匹配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { match, P } from 'ts-pattern';

type UserAction =
| { type: 'login'; user: { id: number; name: string } }
| { type: 'logout' }
| { type: 'update'; user: { id: number; changes: Record<string, any> } };

function handleUserAction(action: UserAction) {
return match(action)
.with(
{ type: 'login', user: { name: P.string } },
(action) => `Welcome, ${action.user.name}!`
)
.with(
{ type: 'update', user: { id: P.number } },
(action) => `Updating user ${action.user.id}`
)
.with({ type: 'logout' }, () => 'Goodbye!')
.exhaustive();
}

数组模式匹配

1
2
3
4
5
6
7
8
function processArray(arr: unknown[]) {
return match(arr)
.with([], () => 'Empty array')
.with([P.string], (arr) => `Single string: ${arr[0]}`)
.with([P.string, P.number], (arr) => `String and number: ${arr[0]}, ${arr[1]}`)
.with(P.array(P.string), (arr) => `Array of strings: ${arr.join(', ')}`)
.otherwise(() => 'Other array pattern');
}

自定义守卫模式

1
2
3
4
5
6
7
8
function processNumber(value: number) {
return match(value)
.with(P.when(n => n > 100), (n) => `Large number: ${n}`)
.with(P.when(n => n > 0), (n) => `Positive number: ${n}`)
.with(P.when(n => n < 0), (n) => `Negative number: ${n}`)
.with(0, () => 'Zero')
.exhaustive();
}

3. 优雅的 unknown 类型处理

ts-pattern 将复杂的类型检查转化为声明式的模式声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { match, P } from 'ts-pattern';

function processApiResponse(response: unknown) {
return match(response)
.with(
{ user: { name: P.string, age: P.number } },
(res) => {
// res 自动收窄为 { user: { name: string; age: number } }
console.log(`User: ${res.user.name}, Age: ${res.user.age}`);
return 'user-processed';
}
)
.with(
{ users: P.array({ name: P.string }) },
(res) => {
// res 自动收窄为 { users: { name: string }[] }
console.log(`Users: ${res.users.map(u => u.name).join(', ')}`);
return 'users-processed';
}
)
.with(
{ error: P.string },
(res) => {
console.error(`API Error: ${res.error}`);
return 'error-handled';
}
)
.with(
{ status: P.union('loading', 'pending') },
() => {
console.log('Request in progress...');
return 'loading';
}
)
.otherwise(() => {
console.warn('Unknown response format');
return 'unknown';
});
}

4. 声明式编程范式

模式匹配将命令式的”如何检查”转变为声明式的”期望什么”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 传统命令式方法
function processState(state: AppState) {
if (state.loading) {
if (state.error) {
return showErrorWithLoading();
} else {
return showLoading();
}
} else {
if (state.error) {
return showError();
} else if (state.data) {
return showData(state.data);
} else {
return showEmpty();
}
}
}

// ts-pattern 声明式方法
function processState(state: AppState) {
return match(state)
.with({ loading: true, error: P.not(P.nullish) }, showErrorWithLoading)
.with({ loading: true }, showLoading)
.with({ error: P.not(P.nullish) }, showError)
.with({ data: P.not(P.nullish) }, (state) => showData(state.data))
.otherwise(showEmpty);
}

总结

ts-pattern 将函数式编程中成熟的模式匹配概念成功引入 TypeScript 生态系统,从根本上解决了传统控制流语句在类型安全、代码可读性和维护性方面的痛点。通过提供强制穷举检查、丰富的模式匹配能力和优雅的类型收窄机制,ts-patternTypeScript 开发者提供了一种更安全、更简洁、更具表达力的方式来处理复杂的数据结构和业务逻辑。

作为开发者,你可能经常在代码里写 RGB 或者 CMYK,但你有没有想过为什么会有两套不同的三原色系统?其实这背后涉及到两种完全不同的色彩混合原理。

简单来说,光色三原色是因为你在混合光线(加色),而颜料三原色是因为你在混合物质(减色)

光色三原色:基于加色混合 (Additive Color Mixing)

光色三原色是红(Red)、绿(Green)、蓝(Blue),也就是我们常说的 RGB

工作原理

  • 加法原理:通过叠加不同颜色的光线来产生新颜色
  • 起始点:没有光的时候是黑色,你加的光越多,颜色就越亮
  • 混合结果:三种原色光等量混合会产生白光

为什么是红、绿、蓝?

这其实跟人眼的生理结构有关。我们视网膜上有三种锥状细胞,分别对红光、绿光和蓝光最敏感。通过不同强度的 RGB 光来刺激这三种细胞,大脑就能感知到几乎所有可见颜色了。

应用场景

所有发光的设备都用 RGB 模型:

  • 显示器、手机屏幕
  • 投影仪
  • LED
  • 前端开发中的 rgb()#RRGGBB 颜色值

颜料三原色:基于减色混合 (Subtractive Color Mixing)

颜料三原色是青(Cyan)、品红(Magenta)、黄(Yellow),简称 CMY。印刷行业通常会加上黑色(Key/Black),成为 CMYK

工作原理

  • 减法原理:颜料通过吸收和反射光线来产生颜色,本质上是”减去”了部分白光
  • 起始点:白纸反射全部白光,你加颜料的时候,颜料会吸收部分光线,只反射剩下的光
  • 混合结果:颜料混得越多,吸收的光越多,最终趋向黑色(实际上通常是深灰色,所以印刷需要单独加黑色墨水)

为什么是青、品红、黄?

这三个颜色其实是光色三原色中任意两种光叠加的结果:

  • 青色颜料:吸收红光,反射绿光和蓝光
  • 品红颜料:吸收绿光,反射红光和蓝光
  • 黄色颜料:吸收蓝光,反射红光和绿光

当你混合两种颜料时,它们会共同吸收光线。比如:

  • 品红(减去绿光)+ 黄(减去蓝光)= 只剩红光被反射,所以看到红色

应用场景

所有通过物质呈现颜色的领域都用 CMY/CMYK 模型:

  • 打印机、印刷厂
  • 绘画、染料
  • 平面设计中的印刷色彩模式

两者对比

特点 光色三原色 (RGB) 颜料三原色 (CMY)
原理 加色混合 (Additive) 减色混合 (Subtractive)
原色 红、绿、蓝 青、品红、黄
混合结果 越混越亮,最终得到白色 越混越暗,最终得到黑色/深灰
载体 光线(光源) 颜料、墨水(物质)
应用 显示器、投影仪、LED 印刷、绘画、染料

开发中的实际意义

这就是为什么你在写前端代码时用 RGB,但设计师给你的印刷文件是 CMYK 格式。如果不做色彩空间转换,屏幕上看着鲜艳的颜色打印出来可能会偏暗或偏色。所以做印刷相关项目的时候,记得跟设计师确认好色彩模式!

0%