我以为 TanStack 只是前端,直到我看到它直接查 PostgreSQL
最近在看一个基于 TanStack 的项目时,我遇到了一个很奇怪的地方。
目录明明是:
apps/tanstack-app/src/routes/
按照我过去的经验,我下意识会把这里理解成“前端”。
结果继续往下看,却出现了:
const { order } =
await import('@libs/database/schema/order')
然后直接:
const [lastMonthOrders] = await db
.select({ count: count() })
.from(order)
我当时第一个反应是:
TanStack 不是前端吗?怎么直接查数据库了?
这个疑问对我来说其实挺有意思。
因为我的第一份工作,接触的正是 Node.js、Express、Koa,以及那个时代非常典型的前后端分离架构。
一、我的第一份工作:从 Express 换到 Koa
我第一份工作接触 Node.js 时,项目最开始使用的是 Express。
那个时候对后端的理解非常直接:
Request
│
▼
Express
│
├── Middleware
├── Router
├── Controller
└── Business Logic
│
▼
Database
Express 很简单。
一个接口可能就是:
app.get('/api/orders', async (req, res) => {
const orders = await getOrders()
res.json(orders)
})
对于刚接触 Node.js 的我来说,这种模型很好理解:
请求进来
↓
处理业务
↓
查数据库
↓
返回 JSON
但随着业务量上来,我们开始发现 Express 已经无法很好地满足当时项目的需求。
团队中的工程师们最终项目选择迁移到 Koa。
我当时对这件事情最直观的理解其实很简单:
我们需要一个更轻、更现代,也更适合当时 Node 异步模型的框架。
Koa 的 middleware 洋葱模型尤其漂亮:
app.use(async (ctx, next) => {
console.log('before')
await next()
console.log('after')
})
请求经过:
Request
│
▼
┌── Middleware A ──┐
│ │
│ Middleware B │
│ │ │
│ ▼ │
│ Router │
│ │ │
│ ▼ │
│ Middleware B │
│ │
└── Middleware A ──┘
│
▼
Response
当然,Express 换 Koa 本身并不会神奇地解决所有性能问题。
真正决定系统吞吐量的还有业务逻辑、数据库、I/O、缓存、Node 版本、部署架构等很多东西。
但在当时那个项目里,Koa 确实成为我们重新组织 Node 服务的重要一步。
二、那个年代我们坚信一件事情:前后端必须分离
我们的整体架构是典型的:
Internet
│
▼
┌─────────┐
│ Nginx │
│ / LB │
└────┬────┘
│
┌───────────┼───────────┐
│ │ │
▼ ▼ ▼
Node Node Node
Koa Koa Koa
│ │ │
└───────────┼───────────┘
│
▼
Database / Cache
前端是前端。
后端是后端。
双方通过 HTTP API 通信:
React / Vue
│
│ HTTP + JSON
▼
API
│
▼
Koa
│
▼
Database
如果后端扛不住怎么办?
答案也非常符合那个年代互联网架构的直觉:
横向扩容。
一台 Node 不够:
Node
那就两台:
Node
Node
还不够?
继续加:
Node
Node
Node
Node
Node
...
理论上,只要应用服务保持尽量无状态,就可以不断复制 Node 实例。
然后在前面放一层负载均衡:
User
│
▼
Load Balancer
/ Nginx
│
┌───────────┼───────────┐
▼ ▼ ▼
Node-01 Node-02 Node-03
│ │ │
└───────────┼───────────┘
│
▼
Redis / Database
流量继续增加:
3 Nodes
↓
10 Nodes
↓
30 Nodes
↓
100 Nodes
甚至配合监控指标做动态扩容:
Traffic ↑
│
CPU / Load ↑
│
▼
Auto Scaling
│
├── Node
├── Node
├── Node
└── Node
流量下来,再缩回去。
现在看,这是很标准的 Cloud Native 思想。
但那个时候我们未必会用今天这么多术语描述它。
关心的是一件很朴素的事情:
单台机器有极限,那就不要依赖单台机器。
能参与这样的架构,让我激动不已。
三、为什么 Node 特别适合这种玩法?
这其实也是当年 Node.js 非常吸引人的地方。
Node 的服务可以做得很轻。
应用实例本身尽可能不保存状态:
Node Instance
│
├── 接收请求
├── 执行业务
├── 查 Redis
├── 查数据库
└── 返回 JSON
用户 Session 不要死死存在某个 Node 进程里。
文件不要只放本机。
关键状态交给:
Redis
Database
Object Storage
于是 Node 本身就变成一个相对容易复制的计算单元:
┌── Node
├── Node
Request ──┼── Node
├── Node
├── Node
└── Node
哪台挂了?
摘掉。
流量大了?
加机器。
流量小了?
减少实例。
这种架构给我留下了很深的印象。
因为它让我形成了一个持续很多年的 Web 架构直觉:
Frontend
│
│ API
▼
Backend Cluster
│
▼
Data Layer
三个世界应该明确分开。
四、前后端分离当时并不是“潮流”,而是真的解决问题
今天很多人看到传统项目:
Controller
Service
DAO
DTO
REST API
会觉得繁琐。
但如果经历过那个阶段,就会知道它为什么会流行。
因为它解决的是非常现实的问题。
前端可以独立开发:
Web
iOS
Android
小程序
它们全部消费同一套 API:
Web
│
iOS
│
Android ─── REST API ─── Backend
│
小程序
后端也可以独立扩容:
Frontend
│
▼
Gateway / LB
│
├── Backend
├── Backend
├── Backend
└── Backend
甚至组织架构也可以跟着拆:
Frontend Team
│
│ API Contract
▼
Backend Team
│
▼
Infrastructure Team
这套架构真正厉害的地方不是“代码漂亮”。
而是:
每一层都可以独立变化。
五、但后来发现:不是所有系统都需要这么重
问题出现在接触到更多内部业务/SaaS/数据密集型业务越来越多以后。
比如今天只想在后台 Dashboard 显示一个数字:
最近一个月订单数量。
数据库其实只需要:
SELECT COUNT(*)
FROM orders
WHERE created_at >= ?
但是按照经典前后端分离思路,整个调用链可能变成:
React Component
│
▼
React Query
│
▼
HTTP Client
│
▼
Nginx
│
▼
API Router
│
▼
Controller
│
▼
Service
│
▼
Repository
│
▼
ORM
│
▼
PostgreSQL
返回的时候再原路走回来。
对于复杂系统,这是合理的。
但如果只是一个几个人的小团队,在做:
SaaS
AI 产品
后台管理系统
内部工具
创业 MVP
就会开始产生另一个问题:
我到底是在实现业务,还是在搬运 JSON?
我的一位创业公司朋友,要得很明确:开展业务。不仅仅是前后端分离,还要考虑到人工成本。技术复杂度,要取舍。 说实话当时没太理解,后知后觉,技术终究还是要落地导向。
六、与此同时,React 自己也遇到了问题
React 最初非常擅长:
State
│
▼
UI
但后来大家发现,Web App 大部分重要数据其实根本不属于浏览器。
比如:
订单
用户
商品
支付
通知
评论
这些东西属于 Server。
于是代码里出现大量:
useEffect(() => {
fetch('/api/orders')
.then(res => res.json())
.then(setOrders)
}, [])
然后很快开始处理:
loading
error
retry
cache
stale
refetch
dedupe
pagination
mutation
invalidation
大家逐渐意识到:
sidebarOpen = true
和:
orders = getOrders()
根本不是一种状态。
前者是:
Client State
后者是:
Server State
React Query 正是在这里爆发。
它开始把:
fetch
cache
retry
refetch
stale
invalidation
统一到:
useQuery()
useMutation()
里面。
这也是后来 TanStack 故事真正开始的地方。
七、然后 Router 也开始进入类型系统
随着 TypeScript 普及,又出现了另一个问题。
比如:
/users/123/orders?page=2&status=paid
实际上这里包含很多应用状态:
123 → userId
page=2 → pagination
status=paid → filter
但传统 Router 很长时间本质还是:
URL = string
于是:
navigate('/users/' + id)
TypeScript 根本不知道:
Route 存不存在
参数对不对
Search Params 合不合法
Loader 返回什么
Component 最终拿到什么
TanStack Router 做的事情,就是把:
Route
也拉进类型系统:
Route Tree
│
├── Params
├── Search
├── Loader
├── Context
└── Component
│
▼
TypeScript
到这里,事情已经开始发生变化了。
八、大人,时代变了,TanStack Start
现在看到的代码已经变成:
const { order } =
await import('@libs/database/schema/order')
const result = await db
.select()
.from(order)
十年前的我看到这种代码,大概会立即问:
前后端边界呢?
但今天真正的结构其实是:
TanStack Start
┌───────────────────────────┐
│ │
Browser│ React │
│ TanStack Router │
│ TanStack Query │
│ │
───────┼──── Server Boundary ──────│
│ │
Server │ Server Function │
│ Business Logic │
│ Drizzle │
│ │
└─────────────┬─────────────┘
│
▼
PostgreSQL
前后端边界并没有消失。
变化的是:
以前边界由两个项目 + HTTP API 表达,现在可以由 Framework + Bundler + Runtime 表达。
这对我来说是 TanStack 最有意思的地方。
九、于是我突然发现:Web 架构似乎绕了一圈
我第一份工作里的世界是:
Nginx
│
┌───────────┼───────────┐
▼ ▼ ▼
Koa Koa Koa
│ │ │
└───────────┼───────────┘
│
▼
Database
整个公司围绕着这样一个灵活先进的架构展开工作。 我们不断强调:
Frontend ≠ Backend
今天 TanStack Start 的世界却越来越像:
Full-stack App
┌─────────────────────┐
│ React │
│ Router │
│ Query │
│─────────────────────│
│ Server Function │
│ ORM │
└──────────┬──────────┘
│
▼
Database
第一眼看起来:
怎么又把前后端写到一起去了?
但仔细想,其实不是历史倒退。
十、第一次“放在一起”和今天“放在一起”完全不同
早期 Web:
Server
├── Template
├── Business Logic
└── Database
很多时候是因为技术能力有限。
后来前后端分离:
Frontend
│
│ REST API
▼
Backend
解决了:
团队解耦
多客户端
独立部署
独立扩容
API 标准化
今天 Full-stack TypeScript 又开始把代码整合:
React
Router
Query
────────────
Server Function
ORM
是因为我们已经有:
TypeScript
Bundler
Vite
SSR
Server Functions
Type-safe ORM
能够在:
代码靠得很近
的同时保持:
运行时仍然分离。
所以真正的变化不是:
分离 → 不分离
而更像:
物理分离
│
▼
逻辑分离
十一、那我第一份工作那套 Node 横向扩容过时了吗?
完全没有。
这是我认为讨论 TanStack 时特别容易忽略的一点。
TanStack Start 改变的是:
Application Programming Model
它没有改变服务器的物理规律。
如果今天一个 TanStack Start 服务流量越来越大,一样可以:
LB
│
┌───────────┼───────────┐
▼ ▼ ▼
TanStack TanStack TanStack
Node-01 Node-02 Node-03
│ │ │
└───────────┼───────────┘
│
▼
Redis / DB
继续增长:
3 instances
↓
10 instances
↓
50 instances
今天可能不再是我当年手工配置服务器。
而变成:
Docker
Kubernetes
ECS
Cloud Run
Serverless
Edge Runtime
但核心思想没有变:
应用层尽量无状态,然后横向复制计算节点。
所以 Express、Koa 那个时代积累下来的架构思想没有消失。
只是 Infrastructure 抽象得越来越高。
十二、最后再谈一个问题:TanStack 性能真的更好吗?
第一次看到:
Component
↓
Server Function
↓
Database
很容易产生一种感觉:
少了 REST、Controller、Service,所以一定更快。
其实不一定。
因为 Server Function 并没有消灭网络。
浏览器访问服务器仍然要经历:
Network
↓
Serialization
↓
Authentication
↓
Server Execution
↓
Database
↓
Serialization
↓
Network
以前写:
POST /api/orders
现在写:
createOrder()
开发体验可能完全不同。
但物理世界并没有改变。
十三、TanStack 真正可能改善的,是“数据什么时候加载”
传统 SPA 很容易出现:
HTML
↓
JavaScript
↓
React
↓
Router
↓
Component Mount
↓
fetch()
↓
API
↓
Database
↓
Render
这会产生典型 Waterfall:
JS
────────>
API A
────────>
API B
────────>
Render
TanStack Router + Query + SSR 的一个价值,就是更早知道:
这个 Route 到底需要什么数据?
于是可以:
┌── Query A ─────────┐
Route ───────┼── Query B ─────────┼── Render
└── Query C ─────────┘
能并行就并行。
再结合:
Prefetch
Cache
SSR
Streaming
staleTime
Route-level Code Splitting
最终改善的是用户感知性能。
这和:
Koa 比 Express 每秒多处理多少请求
已经是两个完全不同层面的性能问题了。
十四、而 TanStack 同样可以被写得很慢
比如:
Route
↓
Loader
↓
Server Function
↓
Component
↓
Query
↓
Server Function
↓
Child Component
↓
Query
一样可以制造严重的 Request Waterfall。
或者:
SSR 请求一次
↓
Hydration
↓
客户端又请求一次
↓
Mutation
↓
invalidateQueries('*')
↓
全部重新请求
于是:
一次页面访问
│
▼
十几个请求
再先进的 Framework 也救不了错误的数据模型。
十五、所以今天看性能,我已经不会只看 Framework Benchmark
第一份工作时,我可能更关心:
Express
VS
Koa
QPS 谁高?
今天再看一个 TanStack 项目,我反而会看:
Performance
│
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
Server Browser Data
│ │ │
TTFB Bundle Waterfall
CPU Hydration Cache Hit
Memory LCP DB Query
DB Pool FCP Duplicate
尤其是:
TTFB
Bundle Size
Hydration Cost
LCP
Request Waterfall
Cache Hit Rate
Database Query Time
这些指标放在一起,才是真正的 Web 性能。
十六、从我的第一份工作到今天
回头看,这条路线非常有意思:
我的第一份工作
│
▼
Express
│
│ 业务需求增长
▼
Koa
│
▼
前后端分离
│
▼
Nginx / Load Balancer
│
▼
Node 横向复制
│
▼
动态扩缩容
│
│
│ 这些年 Web 继续发展
▼
React / SPA
│
▼
React Query
│
▼
TanStack Query
│
▼
TanStack Router
│
▼
TanStack Start
│
▼
Full-stack TypeScript
当年我学习的是:
怎么把前端和后端拆得足够干净。
今天 TanStack 给我的感觉却是:
我们是不是有些东西拆得太远了?
这并不意味着当年的架构错了。
恰恰相反。
如果没有前后端分离、无状态服务、负载均衡、横向扩容这些思想,就不会有今天的大规模 Web 系统。
TanStack 做的不是推翻它们。
而是在另一个维度重新划分边界。
结语
从 Express 到 Koa,再到今天的 TanStack,我最大的感受其实不是:
哪个 Framework 更先进?
而是 Web 工程一直在做同一件事情:
移动复杂度。
Express/Koa 时代,我们把复杂度拆到:
Frontend
API
Backend
Infrastructure
通过 Nginx、负载均衡和大量无状态 Node 实例解决扩展问题:
Traffic
│
▼
Nginx / LB
│
┌───────────┼───────────┐
▼ ▼ ▼
Node Node Node
│ │ │
└───────────┼───────────┘
▼
Data
今天 TanStack 又尝试把 Application Layer 重新拉近:
TypeScript
│
┌─────────┴─────────┐
│ │
Client Server
│ │
React Server Function
│ │
Router ORM
│ │
Query Database
但底层那些老问题一个都没有消失:
网络延迟
并发
数据库瓶颈
缓存
无状态
负载均衡
横向扩容
故障恢复
所以十几年以后再看 TanStack,我反而更能理解它为什么会出现。
它不是证明“前后端分离错了”。
它真正提出的问题是:
当 TypeScript、Bundler、SSR、Server Function 和 ORM 都已经成熟以后,我们还有必要为了保持边界,而支付过去那么高的工程成本吗?
答案最终不会由 Framework 的宣传页决定。
而会由真实项目回答:
开发更快了吗?
│
系统更简单了吗?
│
性能更好了吗?
│
扩容还容易吗?
│
出了问题还能定位吗?
│
团队变大以后还能维护吗?
如果这些问题都能得到好的答案,那么 TanStack 代表的 Full-stack TypeScript 才真正完成了一次架构进化。
否则,我们只是把当年清清楚楚写在:
Nginx
API
Controller
Service
里的复杂度,
藏进了一个更加现代、更加漂亮的框架里。
技术很少真正消灭复杂度。
它更多时候,只是在决定我们下一次会在哪里遇见复杂度。