跳到主要内容

RPC 和 Webhook 在架构中的设计考量

· 阅读需 11 分钟
Jason Rong
前端糕手

一、 基础概念:主动出击 vs 坐等通知

1. RPC (Remote Procedure Call):微服务的“内部专线”

RPC 的全称是远程过程调用。它的核心设计哲学是:让分布式系统间的通信,看起来就像调用本地函数一样简单。

在单体应用中,你调用 getUser(id) 是在内存里找数据;在微服务中,这个调用需要跨越网络。RPC 框架(如 gRPC、Dubbo)在底层帮你把网络连接(TCP 长连接)、数据压缩(Protobuf 二进制序列化)、多路复用等“脏活累活”全干了。

  • 形象比喻:RPC 就像是打电话。我主动拨号给你,你在电话那头立刻处理,我在这头拿着话筒(同步或伪同步)等你告诉我结果。
  • 网络特征:通常运行在企业内网(Intranet),追求极致的低延迟和高并发,数据包体积被压缩到极致。

2. Webhook:连接互联网孤岛的“信鸽”

Webhook 通常被称为“反向 API”或“HTTP 回调”。它的核心设计哲学是著名的好莱坞原则:“Don't call us, we'll call you.(别打电话给我们,有事我们会打给你)”

它完全基于标准的 HTTP 协议。当某个事件在系统 A 中发生时,系统 A 会向系统 B 预先提供的一个 URL 发送一个 HTTP POST 请求(通常携带 JSON 数据)。

  • 形象比喻:Webhook 就像是留电话号码。我去办事大厅提交了材料,工作人员说:“你先回去吧,办好了我按你留的号码发短信通知你。”
  • 网络特征:通常运行在公网(Internet),使用标准的 80/443 端口,极易穿透防火墙,天生适合跨越公司边界的通信。

二、 两者在应用架构设计中承担的角色

虽然通信方向(主动 vs 被动)和底层协议截然相反,但在很多业务场景下,RPC 和 Webhook 实际上是同一业务需求的不同解法。它们的核心共性在于:都是让两台计算机互相触发业务逻辑并传递数据。

场景 1:耗时任务的结果获取(异步处理)

假设你的系统 A 需要调用系统 B 进行“4K 视频转码”或“AI 图像生成”,这个过程需要 5 分钟。

  • RPC 的解法(轮询 Polling):系统 A 通过 RPC 提交任务拿到 TaskID,然后写个定时器,每隔 10 秒发起一次 RPC 调用 checkStatus(TaskID)。这会产生大量无效网络请求,浪费资源。
  • Webhook 的解法(事件回调):系统 A 提交任务时附带一个自己的 Webhook URL。然后 A 就可以去干别的事了。5 分钟后,系统 B 转码完成,主动向这个 URL 发送 POST 请求推送结果。
  • 结论:在耗时异步任务中,Webhook 的事件驱动模型更加优雅、零浪费。

场景 2:跨系统的状态同步与事件通知

假设用户在电商平台支付成功,需要通知订单系统“更改状态并准备发货”。

  • 内网环境用 RPC:如果支付系统和订单系统都是你们公司自己写的,部署在同一个机房。支付模块扣款成功后,直接发起内部 RPC 调用 OrderService.updateStatus(Paid)。内网极快,强一致性有保障。
  • 外网环境用 Webhook:如果你对接的是微信支付/支付宝。微信不可能集成你公司的内部 RPC 框架。他们的做法是:让你在后台配置一个“支付成功回调地址(Webhook URL)”。用户付完钱,微信服务器会向你的公网 URL 发送带有签名验证的 HTTP 请求。

三、RPC 在现代全栈框架中重塑新的开发范式

过去,我们总认为 RPC 是后端微服务(Java/Go/C++)的专利,前端和后端只能通过 RESTful API (HTTP/JSON) 或 GraphQL 交互。

但随着 Next.js、Nuxt.js 等全栈框架的崛起,以及 React Server Components (RSC)Server Actions 的出现,RPC 的设计思想正在深刻地重塑前后端交互范式。

1. 避免常规 HTTP 请求带来的繁琐的 API 胶水代码

在传统的 SPA(单页应用)开发中,前端要获取数据,必须经历痛苦的步骤:

  1. 后端写一个 /api/getUser 的接口。
  2. 前端用 fetchaxios 发送 HTTP 请求。
  3. 前端处理 Loading 状态、解析 JSON、处理错误。

这本质上是在手动处理网络通信的细节。

2. Next.js Server Actions:前端的“隐形 RPC”

在 Next.js 14+ 中引入的 Server Actions,完美诠释了 RPC 的核心理念——“像调用本地函数一样调用远程代码”

// app/actions.js (运行在服务器端)
'use server'
export async function updateUser(id, data) {
await db.user.update({ where: { id }, data })
return { success: true }
}

// app/page.jsx (运行在客户端浏览器)
import { updateUser } from './actions'

export default function Profile() {
return (
<form action={async (formData) => {
// 看起来像是在调用一个普通的本地异步函数!
const result = await updateUser(123, formData.get('name'))
console.log(result)
}}>
<input name="name" />
<button type="submit">更新</button>
</form>
)
}

前端代码直接 import 了一个只在服务器端运行的函数 updateUser,并在表单提交时直接调用了它。

Next.js 框架在编译时,会自动把这个函数调用转换成一个底层的 HTTP POST 请求。它帮你处理了序列化、网络传输、反序列化。开发者完全不需要写任何 fetch 代码,也不需要定义 API 路由。前端和后端共享了 TypeScript 的类型定义(端到端类型安全),体验极其丝滑。

3. tRPC:进一步降低全栈开发的心智负担

除了框架自带的 Server Actions,社区中爆火的 tRPC 也是前端 RPC 化的典型代表。它允许你在不需要代码生成(像 gRPC 那样)的情况下,直接在前后端共享 TypeScript 类型,实现强类型的 RPC 调用,极大地提升了全栈开发效率。

结论:RPC 的思想已经突破了后端内网的限制。在现代全栈开发中,RPC 正在取代一部分传统的 RESTful API,成为前后端无缝连接的新桥梁。


四、 架构选型:到底该用谁?

在现代架构设计中,Webhook 和 RPC 绝不是竞争关系,而是完美的互补关系。我们可以通过一张表来清晰界定它们的适用边界:

维度RPC (gRPC, Dubbo, Server Actions)Webhook
核心定位系统内部/前后端直连的“隐形管道”跨越互联网孤岛的“信鸽”
网络边界内网主导(或同构框架内的前后端通信)公网主导,标准 HTTP 轻松穿透防火墙
开发体验极佳。像调用本地函数,强类型约束一般。需手动解析 JSON,处理重试逻辑
耦合度强耦合。需共享接口定义或类型系统极度松耦合。只要能解析 HTTP 即可对接
适用场景微服务内部通信、Next.js 全栈数据交互支付回调、GitHub 触发 CI/CD、SaaS 集成

总结建议

  1. 如果你在做微服务拆分,或使用 Next.js 进行全栈开发:只要系统属于同一个组织,或者前后端代码在同一个代码库(Monorepo)中,毫不犹豫地拥抱 RPC(无论是 gRPC 还是 tRPC/Server Actions)。它能让你体验到极致的开发效率和性能。
  2. 如果你在做开放平台 (Open API):需要与外部第三方平台(如 SaaS、支付网关、代码仓库)集成,或者需要提供事件订阅功能给你的客户, Webhook 是唯一的选择。

一句话概括: RPC 让你在庞大的分布式系统(甚至跨越前后端)中,依然能体验到写单机代码的丝滑;而 Webhook 则赋予了你的系统与全世界任何其他互联网应用“握手联动”的能力。

本文部分内容由 AI 辅助生成

useSyncExternalStore

· 阅读需 5 分钟
Jason Rong
前端糕手

前言

在一个逻辑相对比较复杂的模块中,尤其是 React 项目中,我不会选择将一些涉及复杂计算的逻辑内联到 Hook 中,而是根据最佳实践方式去运用不同的设计模式去进行封装,尤其是 单例模式

但是我也需要考虑将其数据状态暴露给 React 组件,实现这个需求,有许多种方式

  1. 使用 useState 并借助 useEffect 完成数据订阅,借助监听回调完成数据更新

  2. 直接使用 useRef 实时获取当前最新数据,不过需要注意使用场景,仅仅依靠 useRef 不能触发视图更新。

  3. 使用 useSyncExternalStore完成外部数据快照订阅,部分场景下与第一种方案类似。

而本文主要就是围绕 useSyncExternalStore 展开

概念抽象

  • store 外部数据源,脱离 React 的数据源

  • subscribe 订阅数据更新, store 内部实现的订阅机制,用于其数据更新时,通知其订阅者

  • getSnapshot 获取数据快照, store 内部实现的数据获取机制,用于获取当前数据状态

  • updateAction 更新数据操作, store 内部实现的数据更新机制,用于更新数据状态

  • immutableData 不可突变数据,即不可突变自身的数据。简单来说就是数据若需要更新,需要创建一个全新的数据来替代以视作一个全新的个体,而不是修改自身的属性。

实践

import { useSyncExternalStore } from 'react';

interface StudentState {
name: string;
age: number;
friends: Student[];
}

class Student {
private snapshot: StudentState;
private listeners: (() => void)[] = [];

constructor(name: string, age: number) {
this.subscribe = this.subscribe.bind(this);
this.getSnapshot = this.getSnapshot.bind(this);

this.snapshot = {
name,
age,
friends: []
};
}

get getName() {
return this.snapshot.name;
}

get getAge() {
return this.snapshot.age;
}

get getFriends() {
return this.snapshot.friends;
}

public setName(name: string) {
if (this.snapshot.name === name) {
return;
}

this.snapshot = { ...this.snapshot, name };
this._notify();
}

public setAge(age: number) {
if (this.snapshot.age === age){
return;
}

this.snapshot = { ...this.snapshot, age };
this._notify();
}

public addFriend(newFriend: Student) {
this.snapshot = {
...this.snapshot,
friends: [...this.snapshot.friends, newFriend]
};
this._notify();
}

public removeFriend(student: Student) {
const newFriends = this.snapshot.friends.filter(f => f !== student);

this.snapshot = {
...this.snapshot,
friends: newFriends
};
this._notify();
}

public subscribe(listener: () => void) {
this.listeners.push(listener);

return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}

public getSnapshot() {
return this.snapshot;
}

private _notify() {
this.listeners.forEach(listener => listener());
}
}

const studentInstance = new Student('John', 20);

function useStudent() {
const studentState = useSyncExternalStore(studentInstance.subscribe, studentInstance.getSnapshot);

return {
student: studentState,
actions: studentInstance
};
}

updateAction

在示例中,updateAction 就是 studentInstancesetNamesetAgeaddFriendremoveFriend 等方法。在有效更新数据后都触发 notify,通知订阅者数据更新。

subscribe

在示例中,subscribe 就是 studentInstancesubscribe 方法,添加一个监听回调函数,当数据更新时,触发该回调函数。并且返回一个取消订阅的函数以支持订阅者取消订阅。

getSnapshot

在示例中,getSnapshot 就是 studentInstancegetSnapshot 方法,获取 studentInstance 当前数据快照。

核心注意事项

getSnapshot 返回的数据应保持不可突变;当 store 状态未变化时,应返回同一份快照引用;当状态发生变化时,再返回新的快照引用。

在示例中每个 updateAction 都是通过创建全新的数据快照来实现的,而不是修改自身属性。

// addFriend
this.snapshot = {
...this.snapshot,
friends: [...this.snapshot.friends, newFriend]
};
// updateName
this.snapshot = { ...this.snapshot, name };
// removeFriend
const newFriends = this.snapshot.friends.filter(f => f !== student);

this.snapshot = {
...this.snapshot,
friends: newFriends
};
  1. store 的数据没有发生更新之前,无论调用多少次 getSnapshot,返回的数据快照都是相同的。且使用 Object.is 判断为相等

要避免出现下面的情况,每一次调用 getSnapshot 返回的数据都是全新的对象,会让 useSyncExternalStore 每次都认为数据发生了变化,从而触发不必要的重新渲染。

public getSnapshot() {
return {
...this.snapshot
};
}

自定义主题色

· 阅读需 5 分钟
Jason Rong
前端糕手

前言

出于个性化或者是商业上的考虑,很多项目在 UI 设计之初就会考虑多种配色方案,有时候是为了配合节假日切换不同的配色方案,有时候是为了迎合不同的用户群体允许用户自定义配置,还有种情况就是迅速换皮套壳出售相关的项目。

无论出于何种考虑,自定义主题色都是一个很好的选择。不同于明确的 浅色/深色模式,自定义主题需要保持足够的灵活性,想要很好地实现这一点,其实就没办法使用固定的配置在编译时完成这一切。

其实大部分主流方案都是通过一开始就明确规划相应的 CSS 变量,然后在编译时通过不同的配置文件来切换不同的变量值,在CSS样式中访问相应的CSS变量取代硬编码固定值从而实现不同的主题色。在若主题色中有相当多的变量,我们相应需要自定义的 CSS 变量也会增多。但如果你的主题色配色方案中,有相当多的颜色是通过某一个关键色推导而来,比如不同比例的混入白色或者黑色,那么我们也可以考虑不全部采用硬编码的方式定义 CSS 变量,而是通过计算派生值的方式来获取其他值。

实践

首先我们先假设主题配色方案中有三个关键色

:root {
--primary-color:rgb(179, 30, 30);
--secondary-color: #00ff00;
--accent-color: #0000ff;
}

假设我们在项目中,对于 primary-color有多个变种值,比如浅一点又或者深一点,如果采用一一定义的方式,那么还要继续定义另外的CSS变量

:root {
--primary-color-light: #ff0000;
--primary-color-dark:rgb(112, 4, 4);
}

其实这样也没啥问题,可是一旦变种色多达十几个,那么定义的CSS变量也会增多,我们还需要考虑为CSS变量注入值时,值是否符合我们一开始设定混入的色值比例。

而这个情况下我们就可以考虑最近新增的color-mix()函数,

:root {
--primary-color-light: color-mix(in srgb, var(--primary-color) 90%, #FFF);
--primary-color-dark: color-mix(in srgb, var(--primary-color) 90%, #000);
}

让计算机自己去计算派生值,而不是我们自己去计算然后定义注入其中。当然,这会损失一些灵活性,毕竟我们没办法完全掌握所有CSS变量的值了。

另外color-mix()目前还有一个绕不开的问题--- 兼容性

color-mix(can i use)

90.82%的支持率属实算不上高,想要推广使用还是需要考虑其他方案。

如果不采用color-mix(),那么剩下就是 PostCSS 还有SassLess 以及 JS。但我们之前有提到,考虑其灵活性,我们没办法在编译时就确定其值,那么在编译时处理CSS样式的方案就无法满足我们的需求,即PostCSSSassLess 都无法满足我们的需求。

那么剩下的就是JS了,

import { colord, extend } from 'colord';
import mixPlugin from 'colord/plugins/mix';
extend([mixPlugin]);

function generateThemeColors(baseColor) {
const colors = {};
const base = colord(baseColor);
colors['primary-50'] = base.mix('#FFFFFF', 0.95).toHex();
colors['primary-100'] = base.mix('#FFFFFF', 0.9).toHex();
colors['primary-200'] = base.mix('#FFFFFF', 0.75).toHex();
colors['primary-300'] = base.mix('#FFFFFF', 0.5).toHex();
colors['primary-400'] = base.mix('#FFFFFF', 0.25).toHex();
colors['primary-500'] = base.toHex(); // 主色本身
colors['primary-600'] = base.mix('#000000', 0.1).toHex();
colors['primary-700'] = base.mix('#000000', 0.25).toHex();
colors['primary-800'] = base.mix('#000000', 0.5).toHex();
colors['primary-900'] = base.mix('#000000', 0.75).toHex();
colors['primary-950'] = base.mix('#000000', 0.9).toHex();
return colors;
}

这样的处理方式就是暴力了点

模式判断,更具阅读性的条件分支判断

· 阅读需 9 分钟
Jason Rong
前端糕手

面临多分支判断,如果面对结构数据只能 if,else。switch 无法很好地解决。同时,大量的 if,else 判断会让其条件判断分支没有那么直观,尤其是复杂数据中多个属性进行层级、通配符判断。本文提出基于函数链式判断进行条件分支管理。