onClick 直接报错,我当时就懵了
说实话,接触 Next.js 的第一天,我是带着"这不就是 React 换了个脚手架吗"的心态开始的。
用 Vite 写了快两年 React,什么路由配置、状态管理、异步请求,不敢说多熟,至少写个带交互的页面不会出啥问题。结果 npx create-next-app@latest 跑起来之后,我照着老习惯写了个计数器:
tsx
// app/page.tsx
import { useState } from 'react';
export default function Home() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
点了 {count} 次
</button>
);
}
npm run dev,打开浏览器,我等着看那个熟悉的按钮。结果页面直接白屏,控制台一片红:
控制台报错信息,提示不能在 Server Component 中使用 event handlers转存失败
大概意思是:你不能在一个服务器组件里写 onClick 这种事件处理函数。
我盯着屏幕愣了好几秒。什么服务器组件?我不就在写 React 吗?React 组件不都是在浏览器里跑的吗?什么时候多了个"服务器组件"?
我第一反应是:是不是我 React 版本不对?或者 create-next-app 给我装了什么奇怪的依赖?去 package.json 看了一眼,react 19.2.8,next 16.3.0,都是最新的,没毛病。
然后我又想,是不是要像 Vite 那样在 main.tsx 里把 App 挂载到 DOM 上?找了一圈,项目里根本没有 main.tsx,也没有 createRoot,甚至连 index.html 都只有一个很简洁的架子。这时候我才意识到,这玩意儿跟 Vite + React 的开发模式,可能根本不是一回事。
翻完文档我才明白,组件不一定在浏览器里跑
坐下来翻了半小时文档,又去看了 Next.js 官方的那个 App Router 介绍,我才慢慢回过味来。
以前我用 Vite 写 React,整个流程是这样的:浏览器请求服务器,服务器返回一个 index.html,里面有个空的 <div id="root">,然后加载一坨 JS 文件(main.js、App.jsx、各种 chunk),JS 在浏览器里执行,把组件渲染到 DOM 上,再挂上各种事件监听。用户看到的页面,全靠浏览器自己拼出来。这叫 CSR,客户端渲染。
这种模式做后台管理系统、做内部工具、做那种用户登录之后才能看到内容的应用,体验确实好,切页面不用刷新,路由切换丝滑。但是有个致命问题:搜索引擎的爬虫来访问你的网站的时候,它拿到的就是一个空的 #root 和一堆 JS 引用,它不会帮你执行 JS 去渲染内容。爬虫看不到你页面上有什么文字、有什么链接,自然就不会收录你的页面,SEO 直接拉胯。
我之前在公司一直写内部系统,从来没觉得这是个问题。直到自己想搭个博客,部署上去之后用搜索引擎搜站点标题,啥都搜不到,才体会到那种"写了一堆东西但互联网上没人能找到你"的感觉。
Next.js 干的事情,说白了就是把"渲染"这件事从浏览器搬到了服务器上。你写的 JSX 组件,在服务器上就被执行了,执行完直接产出 HTML 字符串,浏览器拿到的就是一桌子做好的菜,不是一个空盘子和食材让你自己炒。这叫 SSR,服务端渲染。
打个比方:CSR 就像你点外卖,商家给你寄了个空餐盒、一袋子生的菜肉、一个小炉子和一本菜谱,你得自己在家炒熟了才能吃。SSR 则是菜在后厨做好了,装盘子里给你端上来,你直接动筷子就行。搜索引擎爬虫就像个只看不动手的美食评审,你给它生食材它看不懂,你给它做好的菜它才知道你这是啥味道。

想明白这一点之后,前面那个报错就好理解了。onClick 是浏览器里才有的东西------点击事件是 DOM 事件,服务器上根本没有浏览器环境,没有 DOM,没有 window,没有 document,你让服务器执行 onClick,它上哪儿给你找 click 事件去?
所以 Next.js 里所有组件默认都是服务器组件,在 Node.js 环境里跑,直接生成 HTML 返回给浏览器。那我想写按钮点击、想写 useState、想用 useEffect 发请求怎么办?这时候需要在文件最顶上加一行:
tsx
'use client';
就这一行,相当于告诉 Next.js:这个组件我要在客户端也跑一份,我需要浏览器 API。加完之后,计数器终于能点了。当时以为这就完了,结果第二天又踩了个坑。
console.log 打了两次?我以为代码写了两遍
搞定计数器之后我开始写一个 Todo List,跟之前写过的 CRUD 没什么区别:useState 存数据,useEffect 里 fetch 接口,页面上一个输入框一个按钮一个列表。
写完之后我习惯性地在组件顶部加了个 console.log 想看看渲染情况:
tsx
'use client';
import { useState, useEffect } from 'react';
export default function Todos() {
const [todos, setTodos] = useState([]);
console.log('组件渲染了'); // 就这行,坑了我半小时
// ...
}
刷新页面,打开控制台,看到打印了一次"组件渲染了",正常。但我无意间瞟了一眼跑 next dev 的终端,发现终端里也打印了一次"组件渲染了"。
我当时以为自己眼花了,刷新一次,浏览器打一次,终端也打一次。也就是说,这个组件函数被执行了两次------一次在服务器,一次在浏览器?
我的第一反应是:代码重复执行了?是不是 React Strict Mode 在开发环境下的双调用?我特意去 next.config.ts 里把 reactStrictMode 关了,结果还是两次。这时候我有点慌了,以为是自己代码写得有问题导致渲染了两次,就开始排查是不是哪里多套了一层组件,是不是 useEffect 依赖写错了导致重渲染。折腾了快半小时,把组件删得只剩一个 h1,终端里还是会打印。
后来去搜了一下,才搞明白这叫 hydration,水和。
这个翻译我一直觉得挺奇怪的,但笔记里记的那个类比倒是很形象:服务器端渲染就像包好了速冻饺子,皮和馅都给你捏好了,形状有了,但是不能吃。浏览器拿到这份 HTML 之后,还得"下水煮"------把客户端的 JS 加载进来,把事件监听器绑上去,把 useState 的状态和 DOM 对应起来,这一整套激活交互的过程就是 hydration。所以 'use client' 标记的组件,在服务器端会先执行一次,产出初始 HTML;等这份 HTML 送到浏览器,JS 加载完,组件函数在客户端又执行一次,把交互能力"水和"上去。
两次执行不是 bug,是它本来就该这样。想通这一点之后,我在 useEffect 里写 console.log 验证了一下,果然 useEffect 里的代码只在浏览器端执行,终端里不会出现,因为副作用是水和之后才跑的。
在 'use client' 组件里,组件函数体会在服务端和客户端各执行一次,别在函数体顶层写有副作用的代码,也别在顶层直接访问 window/document,否则服务端执行时会报错。
文件即路由,连 react-router 都不用装
适应了服务器组件和客户端组件这两个概念之后,我开始感受到 App Router 的爽点了。
以前用 Vite + React,我得装个 react-router-dom,然后在一个路由配置文件里写一堆 <Route path="/about" element={<About />} />,页面多了之后这个配置文件能写几十行,嵌套路由还得用 children 套来套去,看着就头疼。
Next.js 直接省了这一步。app 目录下,文件夹就是路由,文件夹里放个 page.tsx 就是页面。我建了个 app/about 文件夹,里面写 page.tsx,访问 /about 就直接能看到:
tsx
// app/about/page.tsx
export default function About() {
return <h1>About</h1>;
}
想做嵌套路由?比如 /dashboard 和 /dashboard/settings,就建 app/dashboard 文件夹,里面放 page.tsx 对应 /dashboard,再建个 settings 子文件夹放 page.tsx 对应 /dashboard/settings。更舒服的是 layout.tsx------每个文件夹下都可以放一个 layout.tsx,它会自动包住当前目录和所有子目录的 page。
我在 dashboard 下写了个 layout:
tsx
// app/dashboard/layout.tsx
import Link from "next/link";
export default function DashboardLayout({ children }) {
return (
<div>
<nav>
<Link href="/dashboard/settings">Settings</Link>
</nav>
{children}
</div>
);
}
访问 /dashboard 和 /dashboard/settings 时,这个导航栏都会自动出现在页面上,children 就是对应 page.tsx 的内容。根目录的 layout.tsx 包住所有页面,所以我把全局导航写在根 layout 里,所有页面都共用一份导航,不用每个页面手动引入。
这种"约定大于配置"的思路,刚开始我还觉得自由度不够,写着写着发现确实省脑子。不用想路由怎么配、不用想布局怎么嵌套、不用想 404 页面放哪,照着约定建文件就行,框架帮你处理剩下的。
连后端都不用单独起了
写 Todo List 的时候需要接口,我第一反应是:用 Express 起个服务?还是直接 mock 数据?
翻了一下发现 Next.js 连这个都帮你做了。在 app/api 目录下建文件夹,里面放个 route.ts,导出 GET、POST、PUT、DELETE 这些函数,它就是个标准的 RESTful 接口了。
ts
// app/api/todos/route.ts
import { type Todo } from '../../todos/types';
let todos: Todo[] = [
{ id: 1, title: '学习 App Router', completed: false },
{ id: 2, title: '学习 React', completed: true },
{ id: 3, title: '学习 Next.js', completed: false },
];
export async function GET() {
return Response.json(todos);
}
export async function POST(req: Request) {
const data = await req.json();
const newTodo: Todo = {
id: +Date.now(),
title: data.title,
completed: false,
};
todos.push(newTodo);
return Response.json(newTodo);
}
前端直接 fetch('/api/todos') 就能拿到数据,不需要配置代理,不需要处理跨域,不需要单独起一个后端服务。前端代码和后端代码在同一个项目里,部署的时候一起部署。这就是全栈框架的意思吧------以前前后端分离是因为技术栈不同,现在用 JS/TS 一把梭,前后端的边界真的越来越模糊了。
说到 fetch 我又想起来一个坑,最开始我写的是:
tsx
// 错误写法
const res = await fetch('api/todos');
没有前面那个斜杠。结果请求 404 了。在页面上看着路径没问题,打开 Network 一看,请求的是 /todos/api/todos,拼到当前页面路径后面去了。因为这是相对路径,浏览器会基于当前 URL 来解析。改成 /api/todos 就正常了。POST 请求我写的是 './api/todos',也有同样的问题,都得改成从根路径开始的绝对路径。
这几天踩过的坑,凑一块记一下
写这个 demo 的三天里踩了不少小坑,集中列一下,省得以后再犯。
第一个坑:忘了加 'use client'
只要你的组件里用了 useState、useEffect、useRef 这些 hooks,或者写了 onClick、onChange 这些事件处理,或者访问了 window、document、localStorage,都得在文件最顶部加 'use client'。不加就报那种"你不能在服务器组件里用 xxx"的错。这个 directive 必须在文件的第一行,import 之前,不能写在组件里面,也不能写在中间。
tsx
// 错误:写在 import 后面
import { useState } from 'react';
'use client'; // 没用了
// 正确:第一行就写
'use client';
import { useState } from 'react';
第二个坑:服务端组件里用浏览器 API
最开始我在根 layout.tsx 里想拿 window.innerWidth 做响应式,直接报错"window is not defined"。这才反应过来 layout.tsx 默认是服务器组件,在 Node 里跑,Node 里哪来的 window。要么加 'use client',要么用 CSS 媒体查询来做响应式,别在服务端组件里碰浏览器 API。
第三个坑:metadata 不要手动写
做 SEO 要写 title 和 meta description,我最开始想手动在 layout.tsx 里写 <head><title>xxx</title></head>,结果 Next.js 提供了更简单的方式------直接 export 一个 metadata 对象:
tsx
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "我的待办应用",
description: "一个用 Next.js 写的 Todo List",
};
Next.js 会自动把这些东西插到 HTML 的 head 里,还能针对每个页面单独 export metadata 来覆盖,比手动写 head 标签方便多了,也不会出现重复标签的问题。
第四个坑:fetch 路径要写绝对路径
前面提到过,客户端组件里 fetch 接口,路径一定以 / 开头,不然会拼成相对路径:
tsx
// 错误:相对路径,会拼到当前路由后面
fetch('api/todos');
fetch('./api/todos');
// 正确:从根路径开始
fetch('/api/todos');
不是所有项目都适合用 Next.js
这三天写下来,我最大的感受就是:Next.js 确实改变了我对 React 开发的认知,但它也不是万能的。
如果你在做一个面向公众的内容网站------博客、文档站、产品官网、电商前台------SSR 带来的 SEO 收益是实打实的,用户打开页面的首屏速度也更快,Next.js 非常合适。现在看到那么多 AI 产品的官网用 Next.js 做,不是没有原因的。
但如果你在做一个纯内部的后台管理系统,用户必须登录才能看到内容,搜索引擎根本不需要收录它,那 SSR 反而有点重了。组件在服务器跑一遍再在客户端水和一遍,多了一层复杂度,内部系统用 Vite + React 写 CSR 完全够用,开发体验还更简单直接。
另外 'use client' 这个东西,刚开始写的时候会觉得烦,老是忘了加,加了之后脑子里还得时刻想着"这个组件到底在哪边跑"。但写了几天之后我慢慢觉得这个边界反而挺好的------哪些逻辑该在服务器上跑(查数据库、调内部接口、读写文件),哪些必须在浏览器上跑(事件交互、访问 DOM、localStorage),逼着你想清楚。以前写 React 从来不考虑这些,所有代码都往浏览器里塞,反而糊里糊涂的。
反正我现在写 Next.js 组件,第一反应就是先想:这个组件需不需要交互?需要就加 'use client',不需要就老老实实当服务器组件写。这个思维习惯一旦养成,写起来反而挺顺的。
你们刚开始接触 Next.js 的时候有没有踩过什么哭笑不得的坑?那个 "use client" 到底该加在哪个文件上,我头两天基本上是靠报错来判断的。