水合时想返回 0 个或 2 个 DOM 节点,Preact 10 一直做不到、只认刚好 1 个——Preact 11 的 Hydration 2.0 终于把它放宽到 0 或 2+,最低支持线抬到 Chrome 71 / Safari 12.1
Preact 11 正式版 9 月 29 日发布,npm 上 latest 已经走到 11.0.1,官方博客开头一句「The wait is finally over」——距离他们开出 Road to Preact 11 那个 issue 已经六年多(preactjs/preact#2621)。这次大版本动刀的地方不是多几个 API,而是把六年攒下的 breaking change 一次打包:水合模型重写成 Hydration 2.0、ref 默认转发、hooks 判等换成 Object.is、核心只发 ESM、最低浏览器线整体抬高。拿 Preact 做 SSR / 流式渲染的团队,先看第一条就够了,因为它直接改了「页面什么时候能点」。
## 水合:从「必须正好 1 个节点」到「0 个或 2 个以上」
Preact X 有条硬限制:每个异步边界在水合时必须刚好对应 1 个 DOM 节点。你返回 0 个,或者用 Fragment 吐出 2 个,服务端 HTML 和客户端 VNode 就对不上,水合被判 mismatch 中断——页面停在「看得见、点不动」的状态。
“`jsx
// Preact 10:下面两种返回值都会让异步边界水合对不上
function InitAnalytics() {
// 场景:埋点初始化只在浏览器里跑,服务端渲染时不想输出任何节点
return null; // 0 个节点 → mismatch
}
function TwoParagraphs() {
// 场景:一个 lazy 边界里要渲染两段独立内容
return (
Foo
Bar
// 2 个节点 → mismatch
);
}
const LazyBlock = lazy(() => import(‘./TwoParagraphs’));
“`
Preact 11 里这两个写法都是合法的:Hydration 2.0 允许异步边界返回 0 个或 2 个以上 DOM 节点,并用稳定的注释标记(comment markers)记录每个 Suspense 边界拥有的哪块 DOM。配合流式 SSR,恢复水合时对着「最新到达的 DOM」继续,而不是对着已经过时的 fallback 节点(RFC:preactjs/preact#4442、preactjs/preact#5034)。
为什么这算性能问题而不是语法糖:维护者 Jovi De Croock 在博客里把账算得很直白——水合是一整趟同步活,没跑完之前整棵树都点不动;树一大,这一趟能把主线程占到点击石沉大海,用户盯着一个「看起来好了」的页面干等。Preact 其实早就有暂停/继续式水合(renderToStringAsync + Suspense 边界独立恢复),Hydration 2.0 改的是「怎么记录这块 DOM 属于谁」,让边界在流式输出下不再错位,水合从一次长跑拆成几段短活。
## ref 默认转发:升级后 myRef.current 换了东西
“`jsx
function MyComponent({ ref }) {
return
Hello, world!
;
}
;
// Preact 10:myRef.current 是 MyComponent 实例
// Preact 11:myRef.current 是
这个 DOM 元素
“`
以前必须从 preact/compat 引 forwardRef 包一层,现在 ref 就是个普通 prop。这是升级最容易踩的静默行为变化——类型和运行时都不报错,但依赖 `ref.current` 是组件实例的代码会拿到 DOM。要保留旧行为,官方给了个 options.vnode 钩子,8 行代码把 ref 从 props 里挪回 vnode。
## hooks 判等换 Object.is:NaN 不再每次点击都重渲染
“`jsx
const [count, setCount] = useState(0);
// Preact 10:宽松相等下 NaN !== NaN,每次点击都触发重渲染
// Preact 11:Object.is(NaN, NaN) 为 true,点了不再重渲染
“`
顺带对齐了 React 的语义,useEffect / useMemo / useCallback 的依赖数组里终于能安全地放 NaN。
## useEffect 清理时机:卸载后延到绘制完成
Preact 10 在卸载时同步跑 cleanup,Preact 11 把它推迟到浏览器绘制之后(对齐 React)。绝大多数应用无感,但那些「卸载后立刻断言 cleanup 已调用」的测试会开始失败:要么先 flush 一下,要么需要同步执行的场景改用 useLayoutEffect。
## 分发与环境:ESM-only 才是真正会炸的那条
– 核心只发 ESM(.mjs),.module.js、CommonJS、UMD 全部删除;支持 require(esm) 的 Node 依然可以 `require(‘preact’)`,UMD 全局和 `preact/dist/preact.js` 直引要换成标准 ESM 或 ESM CDN
– 不带 polyfill 的最低支持线:Chrome ≥ 71、Safari ≥ 12.1、Firefox ≥ 69、Edge ≥ 79,IE11 正式出局
– TypeScript 最低 5.1,JSX 命名空间瘦身,useRef 必须给初始值
– 数字型 style 的自动 px 后缀挪进 preact/compat:`style={{ height: 500 }}` 在 Preact 10 输出 `height:500px`,在 Preact 11 core 里输出 `height:500`
– defaultProps 进 compat;render() 第三参 replaceNode 删除(需要就装 preact-root-fragment);Component.base、SuspenseList 删除
– createPortal 进核心;preact/compat 补上 use()、useEffectEvent()、getServerSnapshot、captureOwnerStack()
第一方包(@preact/signals、preact-render-to-string、preact-iso、prefresh、@preact/preset-vite)从几个月前的预发布起就支持 11,依赖树大概率已经兼容。社区侧,JavaScript Weekly #805(10 月 6 日)把它列进本周发布,标题就是「六年之后」;官方 10 月 1 日还为庆祝发布开了个官网设计比赛。
## 升级前先做这三件事
1. 全局搜 `preact/dist`、`require(‘preact’)`、UMD 全局变量——ESM-only 是唯一会让构建直接挂掉的一项,其余都是行为和类型差异
2. 检查 ref.current 的类型断言(组件实例 → DOM 元素)和写死的数字型 style 属性(core 不再自动补 px,需要就装 preact/compat)
3. 在 staging 跑一遍流式 SSR:renderToStringAsync + Suspense 边界,确认控制台没有 mismatch,同时把「卸载后同步断言 useEffect cleanup」的测试改成 flush 或 useLayoutEffect,再合主干
评论区
登录后可评论。