普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月22日首页

顶层元素问题:popover vs. dialog

2025年12月22日 14:21

原文:Top layer troubles: popover vs. dialog 作者:Stephanie Eckles 日期:2025年12月1日 翻译:田八

来源:前端周刊

你是否曾尝试通过设置 z-index: 9999 解决元素层级问题?如果是,那你其实是在与一个基础的CSS概念 ——层叠上下文—— 斗争。

层叠上下文定义了元素在第三维度(即“z轴”)上的排列顺序。你可以把z轴想象成视口中层叠上下文根节点与用户(即通过浏览器视口观察的你)之间的DOM元素的层级。

image.png

一个元素只能在同一层叠上下文中重新调整层级。虽然 z-index 是实现这一点的工具,但失败往往源于层叠上下文的变化。这种变化可能通过多种方式发生,例如使用固定定位(fixed)、粘性定位(sticky)元素,或是将绝对定位(absolute)/相对定位(relative)与 z-index 结合使用等,MDN 上列出了更多原因

现代网页设计有一个“顶层”特性,它保证使其位于所有其他层叠上下文的最顶层。它覆盖整个视口,不过顶层中的元素实际可见尺寸可能更小。

将元素提升到顶层,可使其摆脱原本所在的任何层叠上下文。

虽然顶层直接解决了一个与CSS相关的问题,但目前还没有属性可用于将元素提升到顶层。取而代之的是,某些元素和特定条件可以访问顶层,例如通过 <div> 标签显示的原生对话框 showModal() 和被指定为 Popover 的元素。

Popover API是一项新推出的 HTML 功能,它允许你声明式的创建非模态覆盖元素。通过使用 Popover API 用来摆脱任何层叠上下文,这是它的一个理想特性。然而,在急于选择这种原生能力之前,需要注意一个潜在的问题。

场景设定

想象一下,在2025年的网络世界:你的网页应用包含一个通过“Toast”消息显示通知的服务。你知道的,就是那些通常出现在角落或其他不太可能遮挡其他用户界面(UI)位置的弹出消息。

通常,这些Toast通知通常用于实时提醒,比如保存成功,或者表单提交失败等错误提示。它们有时有时间限制,或者包含如关闭按钮这样的关闭机制。有时它们还包含额外操作,例如“重试”选项,用于重新提交失败的工作流。

既然您的应用紧跟时代潮流,你最近决定将Toast升级为使用Popover API。这样你就可以将Toast组件放置在应用的任何结构中,而无需为了解决层叠上下文问题而采用一些变通方法。毕竟,Toast必须显示在所有其他元素之上,因此通过 Popover 实现顶层访问是明智之举!

你发布了改进版本,并为自己的工作感到自豪。

发布的当周晚些时候,你收到了一份紧急错误报告。不是普通的错误报告,而是一个可访问性违规报告。

Dialog vs. popover

你的应用很新潮,你之前也升级使用了原生HTML对话框。那是一次很棒的升级,因为你用原生 Web 功能取代了对 JavaScript 的依赖。这也是你兴奋地将Toast也升级为使用Popover的另一个原因。

那么,错误是什么呢?一位键盘用户正在使用一个包含对话框的工作流程,对话框打开期间,后台进程触发了一个弹出式通知。该通知提示存在错误,需要用户进行交互。

当这位键盘用户试图将焦点切换到Toast上时,出现了错误。他们虽然在视觉上能看到Toast显示在对话框背景之上,但焦点无法成功进入Toast,而是意外地跳到了浏览器UI上。

你可以在这个CodePen示例中亲自体验这个错误,使用Tab键,你会发现你永远无法访问到Toast。你也可以尝试使用屏幕阅读器,会发现虚拟光标也无法进入Toast。

CodePen

如果你能够点击弹出框,可能会觉得至少点击操作是可行的。但很快我们就会发现,事情并非如此。

为什么Toast弹出框无法访问

虽然顶层可以超越标准的层叠上下文,但顶层中的元素仍然受分层顺序的影响。最近添加到顶层的元素会显示在之前添加的顶层元素之上。这就是为什么Toast在视觉上会显示在对话框背景之上。

如果弹出框在视觉上可用,那为什么通过键盘或屏幕阅读器的虚拟光标却无法访问呢?

原因在于弹出框与 模态 对话框之间存在竞争关系。当通过 showModal()方法启动原生HTML对话框时,对话框外部的页面会变为 惰性状态惰性状态 是一种必要的可访问性行为,它会隔离对话框内容,并阻止通过Tab键和虚拟光标访问背景页面。

这个错误是由于Toast弹出框是背景页面DOM的一部分。这意味着由于它位于对话框DOM边界之外,所以也变成了惰性状态。

但是,由于顶层顺序的原因,因为它是在对话框打开后创建的,所以在视觉上,它被放置在对话框的顶部,这一点可能会让你感到困惑。

如果你以为点击弹出框就能关闭它,实际上并非如此,尽管弹出框确实会消失。真正发生的情况是,你触发了弹出框的 轻触关闭 行为。这意味着它关闭是因为你实际上点击了它的边界之外,因为对话框捕获了点击操作。

所以,虽然弹出框被关闭了,但“重试”按钮实际上并没有被点击,这意味着任何关联的事件监听器都不会被触发。

即使你创建了一个自动化测试来专门检查当对话框打开时Toast的提醒功能,该自动化测试仍可能出现误报,因为它触发了对Toast按钮的编程式点击。这种伪点击错误地绕过了由于对话框导致页面变为惰性状态所引发的问题。

重新获得弹出框访问权限

解决方案有两个方面:

  1. 将弹出框(popover)在DOM中实际放置在对话框(dialog)内部。
  2. 确保使用 popover="manual",以防止对话框内的点击操作过早触发弹出框的轻触关闭。

完成这两步后,弹出框现在既在视觉上可用,又可以通过任何方式完全交互。

Codepan

经验教训与额外考虑

我们了解到,如果你的网站或应用有可能同时显示弹出框和对话框,并且它们有独立的时间线,那么你需要想出一种在对话框内启动弹出框的机制。

或者,您可以选择在对话框关闭之前禁用后台页面弹出窗口。但如果通知需要及时交互,或者对话框内容有可能触发 Toast 提示,则此方法可能并不理想。

除了可见性和交互性之外,您可能还需要考虑另一个问题:弹出窗口是否需要在对话框关闭后继续保持打开状态。也就是说,即使对话框关闭,弹出窗口也需要保持打开状态,例如继续等待用户执行操作。

虽然我非常支持使用原生平台功能,而且我认为弹出框(popover)尤其出色,但有时冲突是无法完全避免的。事实上,您可能已经遇到过类似的问题,即模态对话框的惰性行为。因此,本文的主要目的是提醒您,如果同时显示背景弹出框和模态对话框,可能会出现问题,因此不要完全放弃之前自定义的弹出框架构。

如果这个问题目前或将来会影响到你的工作,请关注这个HTML问题,其中正在讨论解决方案。

关于斯蒂芬妮·埃克尔斯

Stephanie Eckles 是 Adobe Spectrum CSS 的高级设计工程师,也是 CSSWG 的成员,同时还是 ModernCSS.dev 的作者。Steph 拥有超过 15 年的 Web 开发经验,她乐于以作家、研讨会讲师和会议演讲者的身份分享这些经验。她致力于倡导无障碍设计、可扩展 CSS 和 Web 标准。业余时间,她是两个女儿的妈妈,喜欢烘焙和水彩画。

博客:ModernCSS.dev Mastodon:@5t3ph

译者注:

  1. popover:弹出框指的是轻提示的弹出式框,没有过多的交互逻辑
  2. dialog:对话框指的是带有交互逻辑的弹出框,例如存在确认和取消按钮,输入框等

这两个都是新特性,具体内容可参考MDN

React 的新时代已经到来:你需要知道的一切

2025年12月22日 14:20

原文: The next era of React has arrived: Here's what you need to know

翻译: 嘿嘿

来源:前端周刊

构建异步 UI 向来都是一件非常困难的事情。导航操作将内容隐藏在加载指示器之后,搜索框在响应无序到达时会产生竞态条件,表单提交则需要手动管理每一个加载状态标志和错误信息。每个异步操作都迫使你手动进行协调。

image.png

这不是一个性能问题,而是一个协调问题。现在,React 的原语声明式地解决了它。

对于开发团队而言,这标志着我们构建方式的一次根本性转变。React 不再需要每位开发者在每个组件中重新发明异步处理逻辑,而是提供了标准化的原语来自动处理协调。这意味着更少的 Bug、更一致的用户体验,以及更少的调试竞态条件的时间。

React 的异步协调原语

image.png

在 React Conf 2025 上,来自 React 团队的 Ricky Hanlon 演示的 Async React 示例,展示了未来的可能性:一个包含搜索、标签页和状态变更的课程浏览应用,在快速网络下感觉即时,在慢速网络下也能保持流畅。UI 更新自动协调,不会闪烁。

这不是一个新库,而是 React 19 的协调 API 与 React 18 的并发特性 的结合。它们共同构成了 React 团队称之为  “异步 React(Async React)”  的完整系统,通过可组合的原语来构建响应式的异步应用程序:

  • useTransition:跟踪待处理的异步工作。
  • useOptimistic:在状态变更期间提供即时反馈(乐观更新)。
  • Suspense:声明式地处理加载边界。
  • useDeferredValue:在快速更新期间保持稳定的用户体验。
  • use() :使数据获取(和上下文读取)变得声明式。

理解这些部分如何协同工作是关键,它使我们能从命令式的异步代码转向声明式的协调。

问题:手动的异步协调

在这些原语出现之前,开发者必须手动编排每一个异步操作。表单提交需要显式的加载和错误状态:

function SubmitButton() {
    const [isLoading, setIsLoading] = useState(false);
    const [error, setError] = useState(null);

    async function handleSubmit() {
        setIsLoading(true);
        setError(null);
        try {
            await submitToServer();
            setIsLoading(false);
        } catch (e) {
            setError(e.message);
            setIsLoading(false);
        }
    }

    return (
        <div>
            <button onClick={handleSubmit} disabled={isLoading}>
                {isLoading ? '提交中...' : '提交'}
            </button>
            {error && <div>错误:{error}</div>}
        </div>
    );
}

数据获取也遵循类似的命令式模式,使用 useEffect

function UserProfile({ userId }) {
    const [user, setUser] = useState(null);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState(null);

    useEffect(() => {
        setIsLoading(true);
        setError(null);
        fetchUser(userId)
            .then(data => {
                setUser(data);
                setIsLoading(false);
            })
            .catch(e => {
                setError(e.message);
                setIsLoading(false);
            });
    }, [userId]);

    if (isLoading) return <div>加载中...</div>;
    if (error) return <div>错误:{error}</div>;

    return <div>{user.name}</div>;
}

每个异步操作都重复这个模式:跟踪加载状态、处理错误、协调状态更新。当这种模式扩展到几十个组件时,就会导致不一致的加载状态、被遗忘的错误处理,以及难以调试的微妙竞态条件。

原语详解

Actions 自动跟踪异步工作

React 19 引入了 Actions 来声明式地处理异步协调。将一个异步函数包装在 startTransition 中,可以让 React 跟踪整个操作:

const [isPending, startTransition] = useTransition();

function submitAction() {
    startTransition(async () => {
        await submitToServer();
    });
}

isPending 标志在 Promise 解决之前一直为 true。React 会自动处理此状态,并且在 Transition 中抛出的错误会冒泡到错误边界(Error Boundary),而不是在分散的 try/catch 块中处理(你仍需自己处理预期的错误,如验证失败)。

React 将在 Transition 中调用的任何函数被称为 “Action”。命名约定很重要:为函数添加 “Action” 后缀表示它们运行在 Transition 中(例如,submitActiondeleteAction)。

以下是使用 Actions 重写的相同按钮:

function SubmitButton() {
    const [isPending, startTransition] = useTransition();

    function submitAction() {
        startTransition(async () => {
            await submitToServer();
        });
    }

    return (
        <button onClick={submitAction} disabled={isPending}>
            {isPending ? '提交中...' : '提交'}
        </button>
    );
}

另一种选择是使用 React 19 的 <form> 组件,它可以通过接受一个 action 属性并将其自动包装在 Transition 中来为你处理:

async function submitAction(formData) {
    await submitToServer(formData);
}

<form action={submitAction}>
    <input name='username' />
    <button>提交</button>
</form>;

与手动 Action 一样,错误仍会冒泡到错误边界。当你希望在 UI 中反映表单状态时,React 19 提供了表单实用程序:useFormStatus 让子组件可以访问表单的待处理状态,而 useActionState 则允许你根据 Action 的结果更新组件状态(例如显示验证错误或“点赞”计数)。

相同的模式也适用于按钮、输入框和标签页等可复用组件。你的设计组件可以暴露 actionsubmitAction 或 changeAction 等 Action 属性,并在内部使用 Transitions 来管理待处理状态和其他异步行为。我们稍后将回到这个模式。

乐观更新提供即时反馈

Actions 提供了待处理状态,但“待处理”并不总是正确的反馈。当你点击复选框来标记任务完成时,它应该立即切换。等待服务器的响应很可能会破坏流程导致竟态问题。

useOptimistic() 在 Transitions 内部工作,用于在异步 Action 在后台运行时显示即时更新:

function CompleteButton({ complete }) {
    const [optimisticComplete, setOptimisticComplete] = useOptimistic(complete);
    const [isPending, startTransition] = useTransition();

    function completeAction() {
        startTransition(async () => {
            setOptimisticComplete(!optimisticComplete);
            await updateCompletion(!optimisticComplete);
        });
    }

    return (
        <button onClick={completeAction} className={isPending ? 'opacity-50' : ''}>
            {optimisticComplete ? <CheckIcon /> : <div></div>}
        </button>
    );
}

复选框会立即切换。如果请求成功,服务器状态将与乐观更新匹配。如果失败,服务器状态保持旧值,因此复选框会自动恢复其原始状态。

与 useState(它会延迟 Transition 内部的更新)不同,useOptimistic 会立即更新。Transition 边界定义了乐观状态的生命周期:它仅在异步 Action 处于待处理状态时持续存在,一旦 Transition 完成,就会自动“落定”到事实来源(props 或服务器状态)。(注:简单说就是当 transition 为 pending 时 optimisticComplete 为 startTransition 中设定的值,而一旦 transition 完成即 pending 为 false 时,optimisticComplete 会放弃 startTransition 的状态而使用传入的值及为例子中的 complete)

Suspense 声明式地协调加载状态

乐观更新处理了状态变更,但初始数据加载呢?useEffect 模式迫使我们手动管理 isLoading 状态。Suspense 通过允许我们声明式地定义加载边界来解决这个问题。我们需要控制显示什么后备 UI 以及如何分割加载,因此应用的独立部分可以并行加载。

Suspense 与“支持 Suspense”的数据源协同工作:异步服务器组件、使用 use() API 读取的 Promise(我们接下来会介绍),以及像 TanStack Query 这样的库(它提供了用于缓存和去重的 useSuspenseQuery)。

以下是 Suspense 如何协调多个独立数据流:

function App() {
    return (
        <div>
            <h1>仪表板</h1>
            <Suspense fallback={<ProfileSkeleton />}>
                <UserProfile />
            </Suspense>
            <Suspense fallback={<PostsSkeleton />}>
                <UserPosts />
            </Suspense>
        </div>
    );
}

每个组件都可以通过自己的后备方案独立挂起。父组件通过 Suspense 边界处理加载状态,而不是协调多个 useEffect 调用。但有个问题:当你触发导致组件重新获取数据的更新时(如切换标签页或导航),加载后备方案会再次显示,隐藏你已经看到的内容,并产生突兀的加载状态。

结合 Transition 与 Suspense

将 Transition 与 Suspense 结合可以解决这个问题,它告诉 React 保持现有内容可见,而不是立即再次显示后备方案。以下是一个针对标签页切换的适配示例:

function App() {
    const [tab, setTab] = useState('profile');
    const [isPending, startTransition] = useTransition();

    function handleTabChange(newTab) {
        startTransition(() => setTab(newTab));
    }

    return (
        <div>
            <nav>
                <button onClick={() => handleTabChange('profile')}>个人资料</button>
                <button onClick={() => handleTabChange('posts')}>帖子</button>
            </nav>
            <Suspense fallback={<LoadingSkeleton />}>
                <div style={{ opacity: isPending ? 0.7 : 1 }}>{tab === 'profile' ? <UserProfile /> : <UserPosts />}</div>
            </Suspense>
        </div>
    );
}

现在,加载后备方案仅在初始加载时显示。当你切换标签页时,Transition 会在新数据在后台加载时保持当前内容可见。不透明度样式使其变暗,以表示更新正在进行。一旦就绪,React 会自动无缝地换入新内容。没有突兀的加载状态,没有卡顿。

关键在于:Transitions 会“暂缓”UI 更新,直到异步工作完成,从而防止 Suspense 边界在导航期间回退到后备状态。像 Next.js 这样的框架使用此功能在新路由加载时保持页面可见。

use() 直接读取异步数据

早些时候,我们看到了 Suspense 如何与“支持 Suspense”的数据源协同工作。use() API 就是这样的数据源之一:它为数据获取提供了 useEffect 的替代方案,允许你在渲染期间读取 Promise。

以下是用 Suspense 和 use() 重写的最初的 useEffect 示例:

function UserProfile({ userId }) {
    const user = use(fetchUser(userId));
    return <div>{user.name}</div>;
}

function App({ userId }) {
    return (
        <ErrorBoundary fallback={<div>加载用户时出错</div>}>
            <Suspense fallback={<div>加载中...</div>}>
                <UserProfile userId={userId} />
            </Suspense>
        </ErrorBoundary>
    );
}

组件在读取 Promise 时挂起,触发最近的 Suspense 边界,然后在 Promise 解决时带着数据重新渲染。错误被错误边界捕获。与 Hooks 不同,use() 可以条件调用。

一个注意事项:Promise 需要被缓存。否则,每次渲染都会重新创建它。在实践中,你可以使用像 Next.js 这样处理缓存和去重的框架。

延迟值防止 UI 过载

Actions 和 Suspense 处理离散的操作:点击、提交、导航。但快速输入(如搜索)需要不同的方法,因为你希望输入框即使在结果加载时也能保持响应。

一种方法可以是设计一个 SearchInput 组件,通过内部乐观状态保持输入响应,并在 Transition 中调用 changeAction,这样父组件只需传递 value 和 changeAction

当你没有设计组件时,useDeferredValue() 提供了类似的拆分效果。虽然你可以用它来延迟昂贵的 CPU 计算(性能),但此处的目标是稳定的用户体验。

结合 Suspense、use() 和ErrorBoundary,我们可以获得完整的搜索体验:

function SearchApp() {
    const [query, setQuery] = useState('');
    const deferredQuery = useDeferredValue(query);
    const isStale = query !== deferredQuery;

    return (
        <div>
            <input value={query} onChange={e => setQuery(e.target.value)} />
            <ErrorBoundary fallback={<div>加载结果时出错</div>}>
                <Suspense fallback={<div>搜索中...</div>}>
                    <div style={{ opacity: isStale ? 0.5 : 1 }}>
                        <SearchResults query={deferredQuery} />
                    </div>
                </Suspense>
            </ErrorBoundary>
        </div>
    );
}

function SearchResults({ query }) {
    if (!query) return <div>开始输入以搜索</div>;
    const results = use(fetchSearchResults(query));
    return (
        <div>
            {results.map(r => (
                <div key={r.id}>{r.name}</div>
            ))}
        </div>
    );
}

Suspense 后备方案仅在初始加载时显示。在后续搜索期间,useDeferredValue 会在新结果于后台加载时保持旧结果可见(通过 isStale 降低不透明度)。错误边界隔离了失败,即使数据请求失败,搜索输入也能保持功能正常。

综合应用:Async React 示例

到目前为止,我们分别了解了每个原语。Async React 示例 展示了当一个框架将它们整合到路由、数据获取和设计系统中时会发生什么:

gif of async react demo

尝试切换网络速度以查看 UI 如何适应:在快速连接下即时,在慢速连接下流畅。

路由器将导航包装在 Transitions 中:

function searchAction(value) {
    router.setParams('q', value);
}

更新搜索参数是异步的,会更改 URL 并触发数据重新获取,同时 Transition 会跟踪这一切。

数据层将 use() 与缓存的 Promise 结合使用:

function LessonList({ tab, search, completeAction }) {
    const lessons = use(data.getLessons(tab, search));
    return (
        <Design.List>
            {lessons.map(item => (
                <Lesson item={item} completeAction={completeAction} />
            ))}
        </Design.List>
    );
}

当数据加载时,组件会挂起,Suspense 在初始加载时显示后备方案,但在切换标签页和搜索期间,Transitions 会保持旧内容可见。

Design 组件暴露 Action 属性:

<Design.SearchInput value={search} changeAction={searchAction} />

SearchInput 在内部使用 useOptimistic,以便在新的 URL 的 Transition 处于待处理状态时立即更新输入值。TabList 同样乐观地更新选中的标签页。

命名约定(“changeAction”)表示传递的函数将在 Transition 中运行。

状态变更以相同方式工作:

async function completeAction(id) {
    await data.mutateToggle(id);
    router.refresh();
}

这个 completeAction 通过 LessonList 传递给 Design.CompleteButton,该按钮也暴露了一个 action 属性。该按钮在 Action 运行时乐观地更新完成状态。


这是一个简化版的课程应用示例:

export default function Home() {
    const router = useRouter();
    const search = router.search.q || '';
    const tab = router.search.tab || 'all';

    function searchAction(value) {
        router.setParams('q', value);
    }

    function tabAction(value) {
        router.setParams('tab', value);
    }

    async function completeAction(id) {
        await data.mutateToggle(id);
        router.refresh();
    }

    return (
        <>
            <Design.SearchInput value={search} changeAction={searchAction} />
            <Design.TabList activeTab={tab} changeAction={tabAction}>
                <Suspense fallback={<Design.FallbackList />}>
                    <LessonList tab={tab} search={search} completeAction={completeAction} />
                </Suspense>
            </Design.TabList>
        </>
    );
}

协调发生在每个层面:

  • 路由:导航被包装在 Transitions 中。
  • 数据获取:数据层使用 Suspense 和缓存的 Promise。
  • 设计组件:组件暴露“Action”属性以在内部处理乐观更新。

在快速网络上,更新是即时的。在慢速网络上,乐观 UI 和 Transitions 在没有手动逻辑的情况下保持响应性。原语的复杂性由路由器、数据获取设置和设计系统处理。应用代码只需将它们连接起来。


构建自定义异步组件

大多数应用可能会使用已经实现了这些模式的库中的组件。但你也可以自己实现它们来构建自定义异步组件。

这是一个针对 Next.js 的实用示例:一个与 URL 参数同步的可复用选择组件。

这对于过滤器、排序或任何你希望持久化在 URL 中的 UI 状态很有用:

import { useRouter, useSearchParams } from 'next/navigation';

export function RouterSelect({ name, value, options, selectAction }) {
    const [optimisticValue, setOptimisticValue] = useOptimistic(value);
    const [isPending, startTransition] = useTransition();
    const router = useRouter();
    const searchParams = useSearchParams();

    function changeAction(e) {
        const newValue = e.target.value;
        startTransition(async () => {
            setOptimisticValue(newValue);
            await selectAction?.(newValue);

            const params = new URLSearchParams(searchParams);
            params.set(name, newValue);
            router.push(`?${params.toString()}`);
        });
    }

    return (
        <select name={name} value={optimisticValue} onChange={changeAction} style={{ opacity: isPending ? 0.7 : 1 }}>
            {options.map(opt => (
                <option key={opt.value} value={opt.value}>
                    {opt.label}
                </option>
            ))}
        </select>
    );
}

该组件在内部处理协调。父组件可以通过 selectAction 注入副作用:

function Filters() {
    const [progress, setProgress] = useState(0);
    const [optimisticProgress, incrementProgress] = useOptimistic(progress, (prev, increment) => prev + increment);

    return (
        <>
            <LoadingBar progress={optimisticProgress} />
            <RouterSelect
                name='category'
                selected={selectedCategory}
                options={categoryOptions}
                selectAction={items => {
                    incrementProgress(30);
                    setProgress(100);
                }}
            />
        </>
    );
}

在这个例子中,进度条的乐观更新和路由器导航被协调在一起。传递给 selectAction 的任何内容都受益于相同的异步协调。命名约定(“Action”)表示它在 Transition 中运行,并且我们可以在内部调用乐观更新。

这就是 Async React 示例中设计组件使用的模式。SearchInputTabList 和 CompleteButton 都暴露了 Action 属性,在内部处理 Transitions、乐观更新和待处理状态。

使用 ViewTransition(Canary)实现平滑动画

原语解决了更新 何时 发生的问题,而 ViewTransition 则解决了它们 看起来如何 的问题。它包装了浏览器的 View Transition API,并专门在 React Transition(由 useTransitionuseDeferredValue 或 Suspense 触发)内部更新组件时激活。

默认情况下,它在状态之间进行交叉淡入淡出,你也可以使用 CSS 自定义动画。

以下是 Async React 示例如何使用它为课程列表添加动画:

return (
    <ViewTransition key='results' default='none' enter='auto' exit='auto'>
        <Design.List>
            {lessons.map(item => (
                <ViewTransition key={item.id}>
                    <Lesson item={item} completeAction={completeAction} />
                </ViewTransition>
            ))}
        </Design.List>
    </ViewTransition>
);

外层的 ViewTransition 在 Suspense 解析或在状态之间切换时(如显示“无结果”)为整个列表添加动画。每个项目上的内层 ViewTransition 为单个课程添加动画:搜索时,现有项目滑动到新位置,而新项目淡入,移除的项目淡出。

注意:  ViewTransition 目前仅在 React 的 canary 版本中可用。

实际权衡

采用这些模式通常比它们所替代掉的手动逻辑更简单。你并没有增加复杂性;而是将协调工作丢给了 React。话虽如此,以 Transitions、乐观更新和 Suspense 边界的方式思考确实需要思维转变。

何时适用

这些原语在具有丰富交互性的应用中表现出色:仪表板、管理面板和搜索界面。它们消除了整类的 Bug。竞态条件消失了。导航感觉无缝。你可以用更少的样板代码获得“原生应用”的感觉。

不要修复未损坏的东西

如果 useState 和 useEffect 对你来说工作可靠,就没有必要拆除它们。如果你没有在处理竞态条件、突兀的加载状态或输入延迟,你就不需要解决不存在的问题。

迁移路径

你可以选择渐进式的采用。下次构建具有复杂异步状态的功能时,可以尝试用 Transition 代替另一个 isLoading 标识。在即时反馈重要的地方添加乐观 UI。这些工具与现有代码共存,因此你可以逐个功能地采用它们。

结论:向声明式异步的转变

异步 React(Async React)是并发渲染和协调原语的结合,形成了一个用于处理异步工作的完整系统,而这在过去需要手动编排。

随着这些原语在整个生态系统中被采用,这种转变变得切实可行。在 React Conf 2025 上宣布的 Async React 工作组 正在积极致力于在路由器、数据获取库和设计组件中标准化这些模式。

我们已经看到它的实际应用:

  • 路由器(如 Next.js)默认将导航包装在 Transitions 中。
  • 数据库(如 TanStack Query 和 SWR)深度集成了对 Suspense 的支持。
  • 设计系统预计将跟进,暴露 Action 属性以在内部处理待处理状态和乐观更新。

最终,这将异步处理的复杂性从应用代码转移到了框架。你描述 应该发生什么(Action、状态变更、导航),而 React 协调 它如何发生(待处理状态、乐观更新、加载边界)。React 的下一个时代不仅是关于新功能;更是关于让无缝的异步协调成为应用功能的默认方式。

React 已经改变了,你的 Hooks 也应该改变

2025年12月22日 14:16

原文: React has changed, your Hooks should too

翻译: 嘿嘿

来源:前端周刊

React Hooks 已经问世多年,但大多数代码库仍然以同样的方式使用它们:用点 useState,过度使用 useEffect,以及大量不经思考就复制粘贴的模式。我们都经历过。

但 Hooks 从来就不是生命周期方法的简单重写。它们是用于构建更具表现力、更模块化架构的设计系统。

随着并发式 React(React 18/19 时代)的到来,React 处理数据(尤其是异步数据)的方式已经改变。我们现在有了服务器组件、use()、服务器操作、基于框架的数据加载……甚至根据你的设置,在客户端组件中也具备了一些异步能力。

那么,让我们来看看现代 Hook 模式如今是什么样子,React 在引导开发者走向何方,以及生态系统不断陷入的陷阱。

useEffect 陷阱:做得太多、太频繁

useEffect 仍然是最常被滥用的 Hook。它常常成为堆放不应属于那里的逻辑的“垃圾场”,例如数据获取、衍生值,甚至简单的状态转换。这通常就是组件开始感觉“诡异”的时候:它们在不恰当的时间重新运行,或者运行得过于频繁。

useEffect(() => {
  // 每次查询变化时都会重新运行,即使新值实际上相同
  fetchData();
}, [query]);

这种痛苦大部分源于将衍生状态副作用混在一起,而 React 对这两者的处理方式截然不同。

以 React 预期的方式使用副作用

React 在这里的规则出奇地简单:

只在真正有必要时才使用副作用。

其他一切都应该在渲染过程中衍生出来。

const filteredData = useMemo(() => {
  return data.filter(item => item.includes(query));
}, [data, query]);

当你确实需要一个副作用时,React 的 useEffectEvent 会是你的好帮手。它让你能在副作用内部访问最新的 props/状态,而不必扰乱你的依赖数组。

const handleSave = useEffectEvent(async () => {
  await saveToServer(formData);
});

在使用 useEffect 之前,先问问自己:

  • 这是由外部因素(网络、DOM、订阅)驱动的吗?
  • 还是我可以在渲染过程中计算这个?

如果是后者,像 useMemouseCallback 或框架提供的基础构建块这样的工具,会让你的组件健壮得多。

🙋🏻‍♂️ 小贴士

不要把 useEffectEvent 当作一种用来逃避编写依赖数组(dependency arrays)的‘作弊码’。它是专门针对 Effect 内部的操作逻辑进行优化的。”

自定义 Hooks:不仅仅是复用,更是真正的封装

自定义 Hooks 不仅仅是为了减少重复代码。它们关乎将领域逻辑从组件中抽离出来,让你的 UI 专注于……嗯,UI。

例如,与其用这样的设置代码来污染组件:

useEffect(() => {
  const listener = () => setWidth(window.innerWidth);
  window.addEventListener('resize', listener);
  return () => window.removeEventListener('resize', listener);
}, []);

不如将其移入一个 Hook:

function useWindowWidth() {
  const [width, setWidth] = useState(
    typeof window !== 'undefined' ? window.innerWidth : 0
  );

  useEffect(() => {
    const listener = () => setWidth(window.innerWidth);
    window.addEventListener('resize', listener);
    // 注意:原文为 'change',但通常 resize 事件应配对 'resize',这里保持原文但应该是笔误
    return () => window.removeEventListener('change', listener);
  }, []);

  return width;
}

这样就干净多了。也更容易测试。你的组件不再泄露实现细节。

SSR 小提示

总是从确定的回退值开始,以避免水合不匹配报错。

基于订阅的状态与 useSyncExternalStore

React 18 引入了 useSyncExternalStore,它悄无声息地解决了一大类与订阅、撕裂效应和高频更新相关的 Bug。

如果你曾经与 matchMedia、滚动位置或跨渲染行为不一致的第三方存储库斗争过,这就是 React 希望你使用的 API。

它适用于:

  • 浏览器 API(matchMedia、页面可见性、滚动位置)
  • 外部存储(Redux、Zustand、自定义订阅系统)
  • 任何对性能敏感或事件驱动的事物
function useMediaQuery(query) {
  return useSyncExternalStore(
    (callback) => {
      const mql = window.matchMedia(query);
      mql.addEventListener('change', callback);
      return () => mql.removeEventListener('change', callback);
    },
    () => window.matchMedia(query).matches,
    () => false // SSR 回退值
  );
}

使用过渡和延迟值实现更流畅的 UI

如果你的应用在用户输入或筛选时感觉卡顿,React 的并发工具可以提供帮助。这些并非魔法,但它们能帮助 React 将紧急更新置于高开销更新之前。

const [searchTerm, setSearchTerm] = useState('');
const deferredSearchTerm = useDeferredValue(searchTerm);

const filtered = useMemo(() => {
  return data.filter(item => item.includes(deferredSearchTerm));
}, [data, deferredSearchTerm]);

输入保持响应,而繁重的筛选工作被延后处理。

快速心智模型:

  • startTransition(() => setState()) → 延迟状态更新
  • useDeferredValue(value) → 延迟衍生值

需要时可以一起使用,但不要过度使用。它们不适用于琐碎的计算。

可测试和可调试的 Hooks

现代 React DevTools 让检查自定义 Hooks 变得极其简单。如果你能良好地组织你的 Hooks,大部分逻辑无需渲染实际组件就能测试。

  • 将领域逻辑与 UI 分离
  • 尽可能直接测试 Hooks
  • 为了清晰,将提供者逻辑提取到其自身的 Hook 中
function useAuthProvider() {
  const [user, setUser] = useState(null);
  const login = async (credentials) => { /* ... */ };
  const logout = () => { /* ... */ };
  return { user, login, logout };
}

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const value = useAuthProvider();
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  return useContext(AuthContext);
}

下次调试时,你会感谢自己这么做。

超越 Hooks:迈向数据优先的 React 应用

React 正朝着数据优先的渲染流程转变,特别是现在服务器组件和基于操作的模式正在成熟。它并非追求像 Solid.js 那样的细粒度响应式,但 React 正大力投入异步数据和服务器驱动的 UI。

值得了解的 API:

  • use() 用于在渲染期间处理异步资源(主要用于服务器组件;通过服务器操作在客户端组件中支持有限)
  • useEffectEvent 用于稳定的副作用回调
  • useActionState 用于类似工作流的异步状态
  • 框架级别的缓存和数据原语
  • 更好的并发渲染工具和 DevTools

方向很明确:React 希望我们减少对“瑞士军刀”式 useEffect 的依赖,更多地依赖简洁、由渲染驱动的数据流。

围绕衍生状态和服务器/客户端边界来设计你的 Hooks,能让你的应用天然地面向未来。

Hooks 即架构,而非语法

Hooks 不仅仅是比类组件更友好的 API,它们是一种架构模式。

  • 将衍生状态放在渲染过程中
  • 只将副作用用于真正的副作用
  • 通过小而专注的 Hooks 组合逻辑
  • 让并发工具平滑处理异步流程
  • 同时考虑客户端服务器边界

React 在进化,我们的 Hooks 也应随之进化。

如果你仍然在用 2020 年的方式写 Hooks,那也没关系。我们大多数人都是如此。但 React 18+ 给了我们一个强大得多的工具箱,熟悉这些模式会很快带来回报。

TypeScript 严格性是非单调的:strict-null-checks 和 no-implicit-any 的相互影响

2025年12月22日 14:15

原文: TypeScript strictness is non-monotonic: strict-null-checks and no-implicit-any interact

翻译: 嘿嘿

来源:前端周刊

TypeScript 编译器选项 strictNullChecksnoImplicitAny 以一种奇怪的方式相互作用:仅启用 strictNullChecks 会导致类型错误,而在同时启用 noImplicitAny 后这些错误却消失了。这意味着更严格的设置反而导致更少的错误!

这虽然是一个影响不大的奇闻异事,但我在实际工作中确实遇到了它,当时我正在将一些模块更新为更严格的设置。

背景

TypeScript 是驯服 JavaScript 代码库的强大工具,但要获得最大的保障,需要在“严格”模式下使用它。

在现有的 JavaScript 代码库中采用 TypeScript 可以逐步完成:逐个打开每个严格的子设置,并逐一处理出现的错误。这种渐进式方法使得采用变得可行:不要在一次大爆炸中修复整个世界,而是进行多次较小的更改,直到最终世界被修复。

在工作中,我们最近一直在以这种方式逐步提高代码的严格性,然后我遇到了这种相互作用。

示例

下面这段代码中,array 的类型是什么?

const array = [];
array.push(123);

作为一个独立的代码片段,它看起来奇怪且毫无意义(“为什么不直接用 const array = [123];?”),但它是真实代码的最小化版本。

const featureFlags = [];

if (enableRocket()) {
  featureFlags.push("rocket");
}
if (enableParachute()) {
  featureFlags.push("parachute");
}

prepareForLandSpeedRecord(featureFlags);

这里没有显式的类型注解,所以 TypeScript 需要推断它。这种推断有点巧妙,因为它需要“时间旅行”(指需要运行后续语句后回头去修改推断的类型,类似正则回溯):const array = [] 这个声明并没有说明数组中可能包含什么,这个信息只来自代码后面出现的 push

考虑到所有这些,推断出的确切类型依赖于两个 TypeScript 语言选项也就不足为奇了:

strictNullChecks noImplicitAny 推断类型
最不严格 any[]
number[]
never[]
最严格 number[]

选项说明

这里影响推断类型的两个选项是:

  • strictNullChecks:正确强制处理可选/可为空的值。例如,启用后,一个可为空的字符串变量(类型为 string | null)不能直接用在期望普通 string 值的地方。
  • noImplicitAny:避免在一些模棱两可的情况下推断出“全能”的 any 类型。

最好同时启用它们:strictNullChecks 解决了“十亿美元的错误”,而 noImplicitAny 减少了感染代码库的容易出错的 any 的数量。

问题所在

我们上表中第三种配置,即启用 strictNullChecks 但禁用 noImplicitAny 时,推断出 array: never[]。因此,代码片段无效并被报错(在线示例):

array.push(123);
//         ^^^ 错误:类型“123”的参数不能赋给类型“never”的参数。

没有任何东西(既不是字面量 123,也不是任何其他 number,也不是任何其他东西)是 never 的“子类型”,所以,是的,这段代码无效是合理的。

奇怪之处

“启用一些更严格的要求,然后得到一个错误”并不令人惊讶,也不值得注意……但让我们再仔细看看表格:

strictNullChecks noImplicitAny 推断类型
最不严格 any[]
number[]
报错! never[]
最严格 number[]

所以,如果我们从一个宽松的代码库开始,并希望使其变得严格,我们可能会:

  1. 启用 strictNullChecks,然后遇到一个新错误(不奇怪),然后
  2. 解决这个错误,无需更改代码,只需启用 noImplicitAny(奇怪!)。

当我们朝着完全严格的方向前进时,逐个启用严格选项可能会导致一些“虚假的”错误短暂出现,仅仅出现在中间的半严格状态。随着我们打开设置,错误数量会先上升后下降!

我个人期望启用严格选项是单调的:启用的选项越多 = 报错越多。但这一对选项违反了这种期望。

解决方案

在尝试使 TypeScript 代码库变得严格时,有几种方法可以“解决”这种奇怪现象:

  1. 直接用显式注解修复错误,例如 const array: number[] = []
  2. 使用不同的逐个启用顺序:先启用 noImplicitAny,然后再启用 strictNullChecks。如上表所示,按照这个顺序,两个步骤的推断结果都是 array: number[],因此没有错误。
  3. 同时启用它们:不要试图完全渐进,而是将这两个选项作为一步启用。

解释

为什么启用 strictNullChecks 并禁用 noImplicitAny 会导致一个在其他地方不出现的错误?jcalz 在 StackOverflow 上解释得很好,其核心是:

  • 这种有问题的组合是一个为了向后兼容而留下的边缘情况,其中 array 的类型在其声明处被推断为 never[],并在后续代码中被锁定。
  • 启用 noImplicitAny 会使编译器在模棱两可的位置(在没有 noImplicitAny 时会推断为 any 的地方)使用“演化”类型(evolving types,可理解为先推断为 any/never 然后后续追加推断的类型):因此,array 的类型不会在其声明行被确定,并且可以结合来自 push 的信息进行推断。

评论

这感觉像是一个有趣的脑筋急转弯,而不是一个重大问题:

  • 修复这些虚假错误并不是一个重大的负担或显著的浪费时间,而且可以说,添加注解可能使这类代码更清晰。
  • 半严格状态可能有奇怪的行为是可以理解的:我想 TypeScript 开发者更关心完全严格模式下的良好体验,希望中间状态只是垫脚石,而不是长期状态。

总结

TypeScript 选项 strictNullChecksnoImplicitAny 以一种奇怪的方式相互作用:以“错误”的顺序逐个启用它们会导致错误出现然后又消失,违反了单调性的期望(启用的严格选项越多 = 错误越多)。这可能发生在真实代码中,但影响极小,因为很容易解决和/或规避。

⏰前端周刊第445期(2025年12月15日–12月21日)

2025年12月22日 11:19

📢 宣言每周更新国外论坛的前端热门文章,推荐大家阅读/翻译,紧跟时事,掌握前端技术动态,也为写作或突破新领域提供灵感~

欢迎大家访问:github.com/TUARAN/fron… 顺手点个 ⭐ star 支持,是我们持续输出的续航电池🔋✨!

在线网址:fwdc.pages.dev/

前端周刊封面


💬 推荐语

本期内容涵盖 HTML 现状、React2Shell 安全事件复盘与防护、可访问性与性能指标实践、现代 CSS 新能力,以及框架与工具链的最新趋势。


🗂 本期精选目录

🧭 Web 开发

🛠 工具

⚡ 性能

🧪 Demo

🎨 CSS

💡 JavaScript

⚛️ React

🧾 前端周刊 · 上周内容「价值判断大表」

分类 标题 核心主题 影响面 趋势性 可行动性 综合优先级 编辑部判断
Web State of HTML 2025 HTML 原生能力演进 ⭐⭐⭐⭐☆ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐☆ S 前端回归平台能力,必读
Web React2Shell 百万美元挑战 前端安全 / RSC ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐☆ S+ 架构级安全信号
Web React2Shell 事件复盘 前端攻击面 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐☆ S+ 企业前端必看
Web <time> 元素语义讨论 HTML 语义 ⭐⭐☆☆☆ ⭐⭐⭐☆☆ ⭐⭐☆☆☆ B 思想有趣,实用性一般
Web Dynamic Datalist API + 原生组件 ⭐⭐☆☆☆ ⭐⭐⭐☆☆ ⭐⭐⭐☆☆ B 可写 Demo
Web 衡量功能真实影响 产品 × 工程 ⭐⭐⭐⭐☆ ⭐⭐⭐☆☆ ⭐⭐⭐⭐☆ A 很适合写方法论
A11y WCAG 3.3.9 指南 可访问性 ⭐⭐⭐☆☆ ⭐⭐⭐⭐☆ ⭐⭐⭐☆☆ A- 企业项目价值高
工具 JS Bundler Grand Prix 构建工具对比 ⭐⭐⭐⭐☆ ⭐⭐⭐⭐☆ ⭐⭐⭐⭐☆ A 选型 & 分享利器
工具 Vitest 4 迁移指南 测试体系 ⭐⭐⭐☆☆ ⭐⭐⭐⭐☆ ⭐⭐⭐⭐☆ A 团队升级必备
性能 LCP 深度拆解 Core Web Vitals ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐☆ ⭐⭐⭐⭐☆ S 性能工程核心
性能 无限滚动 CLS 优化 页面稳定性 ⭐⭐⭐⭐☆ ⭐⭐⭐⭐☆ ⭐⭐⭐⭐☆ S- 实战价值极高
性能 屏幕尺寸懒加载 图片性能 ⭐⭐⭐☆☆ ⭐⭐⭐☆☆ ⭐⭐⭐☆☆ B 技巧型
Demo GSAP 曲线路径动画 动效 ⭐⭐☆☆☆ ⭐⭐⭐☆☆ ⭐⭐⭐☆☆ C+ 灵感向
Demo 页面过渡策略 UX ⭐⭐⭐☆☆ ⭐⭐⭐☆☆ ⭐⭐⭐☆☆ B 可做设计分享
Demo Toon Text CSS 创意 ⭐⭐☆☆☆ ⭐⭐⭐☆☆ ⭐⭐☆☆☆ C 收藏即可
CSS scroll-state(scrolled) 新 CSS API ⭐⭐⭐⭐☆ ⭐⭐⭐⭐☆ ⭐⭐⭐☆☆ A 值得跟踪
CSS Anchor Positioning 布局能力 ⭐⭐⭐⭐☆ ⭐⭐⭐⭐☆ ⭐⭐⭐☆☆ A 未来组件基础
CSS Masonry → grid-lanes 布局演进 ⭐⭐⭐⭐☆ ⭐⭐⭐⭐⭐ ⭐⭐⭐☆☆ A+ CSS 重大进展
CSS Dialog View Transitions 原生过渡 ⭐⭐⭐☆☆ ⭐⭐⭐⭐☆ ⭐⭐⭐☆☆ A- 框架替代信号
CSS VoxCSS CSS 引擎 ⭐⭐☆☆☆ ⭐⭐⭐☆☆ ⭐⭐☆☆☆ C 实验性
JS JS 内存效率 性能工程 ⭐⭐⭐⭐☆ ⭐⭐⭐☆☆ ⭐⭐⭐⭐☆ A 工程向干货
JS WebAssembly 使用时机 WASM ⭐⭐⭐☆☆ ⭐⭐⭐☆☆ ⭐⭐⭐☆☆ B 知道即可
JS 多品牌 Token 系统(Vue) 设计系统 ⭐⭐⭐⭐☆ ⭐⭐⭐⭐☆ ⭐⭐⭐⭐☆ A 企业前端价值高
JS 三大框架性能对比(2026) 框架选型 ⭐⭐⭐☆☆ ⭐⭐⭐☆☆ ⭐⭐☆☆☆ B- 参考,不迷信
React RSC Explorer RSC 可视化 ⭐⭐⭐⭐☆ ⭐⭐⭐⭐⭐ ⭐⭐⭐☆☆ A+ 理解 RSC 必备
React Next.js 国际化 工程实践 ⭐⭐⭐☆☆ ⭐⭐⭐☆☆ ⭐⭐⭐⭐☆ A- 实用指南
React Vite vs Webpack 构建体系 ⭐⭐⭐⭐☆ ⭐⭐⭐⭐☆ ⭐⭐⭐⭐☆ A 架构选型好文

⏰前端周刊第444期(2025年12月8日–12月14日)

2025年12月22日 11:10

📢 宣言

每周更新国外论坛的前端热门文章,推荐大家阅读/翻译,紧跟时事,掌握前端技术动态,也为写作或突破新领域提供灵感~

欢迎大家访问:github.com/TUARAN/fron… 顺手点个 ⭐ star 支持,是我们持续输出的续航电池🔋✨!

在线网址:fwdc.pages.dev/

bannerv2.png


💬 推荐语

本期周刊聚焦前端技术的性能与创新:从 React Server Components 的安全性提升到 CSS 滚动触发动画的原生实现,从 TypeScript 7 原生编译器的影响到 Deno 工具链的持续演进。CSS Wrapped 2025 回顾了 CSS 的重大进展,性能优化深入到复杂 Web 应用的底层机制,前端开发正向着更安全、更高效、更原生的方向迈进。


🗂 本期精选目录

🧭 Web 开发

⚡ 性能

🛠 工具

🎨 CSS

💡 JavaScript

TypeScript

React

Angular


📌 小结

从 React Server Components 的安全性修复,到 CSS 原生滚动动画的突破;从 TypeScript 7 原生编译器的影响分析,到 ES2026 对 JavaScript 痛点的解决,这一周的前端技术展现出"性能优化 + 安全加固 + 原生能力增强"的多维度发展。CSS Wrapped 2025 总结了 CSS 在状态管理和逻辑处理方面的巨大进步,前端开发正在获得更强大的原生能力,同时也需要更加关注安全性和性能优化。


✅ OK,以上就是本次分享,欢迎加我们威 atar24,备注「前端周刊」,我们会邀请你进交流群👇

🚀 每周分享技术干货 🎁 不定期抽奖福利 💬 有问必答,群友互助

❌
❌