客户端导航是一种允许在不重新加载整个页面的情况下在页面之间进行导航的技术。浏览器不再从服务器获取全新的 HTML 文档,而是客户端导航会获取新页面的内容,并仅更新 DOM 中发生变化的部分。这带来了更快、更流畅的页面过渡以及更具应用感的用户体验。

Interactivity API 通过 @wordpress/interactivity-router 包提供客户端导航功能。其核心概念是路由器区域(router region):这是路由器知道如何在导航期间更新的页面部分。你可以使用 data-wp-router-region 指令标记这些部分,当用户导航到新 URL 时,路由器会获取目标页面,并仅替换匹配区域内的内容——页面上的其他所有内容保持不变。

Interactivity API 支持两种导航模式:

  • 基于区域的客户端导航 — 这是在 WordPress 中实现客户端导航的推荐方法。
  • 全页客户端导航 (实验性) — 将整个 <body> 元素视为单个区域,从而在没有传统重新加载的情况下更新整个页面内容。在本指南末尾的全页客户端导航 (实验性)部分有介绍。
要了解如何确保你的区块和交互元素与客户端导航兼容,请参阅客户端导航兼容性指南。

客户端导航的工作原理

当用户触发导航时,例如点击带有 data-wp-on--click 指令并调用 actions.navigate() 的链接,Interactivity Router 会执行以下操作:

  1. 获取新页面:路由器请求目标 URL 的 HTML。
  2. 解析响应:它从获取的 HTML 中提取相关区域、样式、脚本和服务器渲染的数据。
  3. 更新 DOM:仅用新内容替换指定“路由器区域”内的内容。
  4. 更新浏览器历史记录:向浏览器的会话历史记录中添加一个新条目(如果指定,则替换当前条目)。
  5. 加载必要的资源:在新页面渲染之前,加载新页面所需的新样式或脚本模块。
  6. 处理无障碍访问:发出屏幕阅读器公告以指示导航进度。

这种方法提供了多项优势:

  • 性能提升:仅更新页面更改的部分,减少数据传输和 DOM 操作。
  • 状态保留:客户端状态(全局状态、本地上下文)在导航过程中得以保留。
  • 平滑过渡:页面之间没有白屏闪烁;过渡感觉即时且具有应用感。
  • 对 SEO 友好:由于服务器仍会渲染完整的 HTML 页面,搜索引擎可以正常抓取你的站点。

开始使用 Interactivity Router

自版本 6.5 起,@wordpress/interactivity-router 包已捆绑在 WordPress Core 中。如果你正在启动一个新项目,最简单的设置方法是使用@wordpress/create-block-interactive-template脚手架工具。它提供了一个专用的 client-side-navigation 变体,该变体会生成一个完全可用的区块,其中已配置好客户端导航——包括路由器区域、上/下导航、加载指示器以及跨导航持久化的秒表,用于演示状态持久化:

npx @wordpress/create-block@latest my-interactive-block --template @wordpress/create-block-interactive-template --variant client-side-navigation

你也可以生成默认变体并自行添加客户端导航:

npx @wordpress/create-block@latest my-interactive-block --template @wordpress/create-block-interactive-template

无论你是处理区块还是经典主题,添加客户端导航都涉及相同的步骤:

  1. 添加路由器依赖项:将 @wordpress/interactivity-router 作为脚本模块的依赖项添加。
  2. 确保脚本模块在导航期间加载:标记你的脚本模块,以便路由器知道在新页面上加载它。
  3. 定义路由器区域:使用 data-wp-router-region 属性标记应在导航期间更新的 HTML 元素。
  4. 触发导航:使用路由器的 actions.navigate() 函数以编程方式导航。

步骤 1 和 2 取决于你是在处理区块还是经典主题,将在下面立即介绍。无论你的设置如何,步骤 3 和 4 都是相同的。

添加路由器依赖项

应将 @wordpress/interactivity-router 模块作为动态依赖项添加,以便仅在需要时获取它。

对于区块,这是通过在 view.js 文件中动态导入包来完成的。区块构建工具(wp-scripts)会检测动态导入并自动注册 PHP 端的依赖项:

const { actions } = yield import( '@wordpress/interactivity-router' );
yield actions.navigate( url );

对于经典主题,不依赖区块的 block.json,而是在 PHP 中手动注册并入队你的脚本模块,将 @wordpress/interactivity-router 列为动态依赖项。你还需要直接在主题的模板文件中添加 Interactivity API 指令,并使用 wp_interactivity_process_directives() 处理它们,如服务器端渲染指南中所解释的那样。

// functions.php
add_action( 'wp_enqueue_scripts', function () {
    wp_register_script_module(
        'my-theme/navigation',
        get_template_directory_uri() . '/assets/navigation.js',
        array(
            '@wordpress/interactivity',
            array(
                'id'     => '@wordpress/interactivity-router',
                'import' => 'dynamic',
            ),
        )
    );
    wp_enqueue_script_module( 'my-theme/navigation' );
} );

确保脚本模块在导航期间加载

在客户端导航期间,路由器需要知道哪些脚本模块应在新页面上加载。它通过在带有 loadOnClientNavigation 设置为 true 的 <script> 标签上查找 data-wp-router-options 属性来识别它们。如果没有此属性,路由器将不会在客户端导航期间加载脚本模块,区块的交互功能将在新页面上无法工作。

对于区块,当区块在其 block.json 中声明交互支持时,会自动添加此属性。以下任一配置均可工作:

{
    "supports": {
        "interactivity": true
    }
}
{
    "supports": {
        "interactivity": {
            "clientNavigation": true
        }
    }
}

如果你的区块的 block.json 已经包含其中之一,则无需额外设置——WordPress 会处理其余部分。

对于经典 PHP 主题和其他在 block.json 之外注册的脚本模块,该属性不会自动添加。你必须使用 add_client_navigation_support_to_script_module() 显式地将你的脚本模块注册为支持客户端导航:

wp_interactivity()->add_client_navigation_support_to_script_module(
    'my-theme/navigation'
);
要了解是什么使区块(或经典 PHP 主题中的交互元素)与客户端导航兼容,请参阅客户端导航兼容性指南。

如果没有这样做,路由器在导航到需要它的页面时将不会加载你的脚本模块。

设置路由器区域

路由器区域是路由器在客户端导航期间更新的页面部分。你可以通过在同一元素上同时添加 data-wp-router-region 和 data-wp-interactive 来定义一个区域——目前这两个指令都是必需的。

data-wp-router-region 指令以其唯一 ID 作为值。当发生导航时,路由器按其 ID 匹配当前页面上的区域与目标页面上的区域,并替换它们的内容——保留路由器区域之外的所有内容不变。每个区域 ID 必须在页面内唯一;如果两个区域共享相同的 ID,路由器将不知道要更新哪一个。

这是一个基本的路由器区域:

<div
    data-wp-interactive="myPlugin"
    data-wp-router-region="myPlugin/posts-list"
>
    <?php foreach ( $posts as $post ) : ?>
        <article>
            <h2><?php echo esc_html( $post->post_title ); ?></h2>
            <p><?php echo esc_html( $post->post_excerpt ); ?></p>
        </article>
    <?php endforeach; ?>
</div>

路由器区域放置位置

路由器区域可以放置在页面的任何位置。其行为取决于它们与其他交互元素和其他路由器区域的相对位置:

  • 作为独立元素 — 当路由器区域不在任何现有的 data-wp-interactive 元素内部时,它具有双重角色:它既是交互边界(因为它也包含 data-wp-interactive)又其内容在导航期间会更新:
    <div
        data-wp-interactive="myPlugin"
        data-wp-router-region="myPlugin/content"
    >
        <!-- 交互边界 + 可导航区域 -->
        <p data-wp-text="state.message">Hello</p>
    </div>
    
  • 在交互元素内部 — 当路由器区域嵌套在已经具有 data-wp-interactive 的元素内时,该区域成为该元素交互性的一部分。父级交互元素在导航期间保持不变,但区域的内容会更新:
    <div data-wp-interactive="myPlugin">
        <h1>此标题在导航期间永远不会更新</h1>
    
        <div
            data-wp-interactive="myPlugin"
            data-wp-router-region="myPlugin/posts"
        >
            <!-- 此内容在导航期间会更新 -->
        </div>
    </div>
    

    请注意,即使路由器区域已经位于某个交互元素内部,它仍然需要自己的 data-wp-interactive 指令。

  • 在另一个路由器区域内部 — 当路由器区域嵌套在另一个路由器区域内时,它成为父级区域的一部分。父级区域在导航期间作为一个整体进行更新;嵌套区域不会独立处理:

    <div data-wp-interactive="myPlugin" data-wp-router-region="myPlugin/main">
        <!-- 此内部区域是 "myPlugin/main" 的一部分 -->
        <div
            data-wp-interactive="myPlugin"
            data-wp-router-region="myPlugin/sidebar"
        >
            <!-- 与父级区域一起更新 -->
        </div>
    </div>
    

实现导航

要触发现场侧导航,你需要在 store 中定义一个action,并使用 Interactivity API 指令将其连接到 DOM 事件。Actions 是在 store() 内定义的函数,用于处理用户交互。当通过如 data-wp-on--click 之类的指令连接到元素时,只要触发该事件,action 就会运行。

以下是实现链接以进行现场侧导航的方法。首先,HTML 将链接的点击事件连接到 navigateTo action:

<a data-wp-on--click="actions.navigateTo" href="/page-2/"> Go to Page 2 </a>

然后,在你的脚本模块中,定义 navigateTo action。它会阻止浏览器的默认全页导航,转而使用路由器的 navigate() 函数:

// view.js
import { store, withSyncEvent } from '@wordpress/interactivity';

store( 'myPlugin', {
    actions: {
        navigateTo: withSyncEvent( function* ( event ) {
            event.preventDefault();

            const { actions } = yield import(
                '@wordpress/interactivity-router'
            );
            yield actions.navigate( event.target.href );
        } ),
    },
} );
withSyncEvent() 包装器对于需要调用同步事件方法(如 event.preventDefault())的 action 是必需的。有关详细信息,请参阅 withSyncEvent() 文档。

实现预取

路由器还提供了 prefetch() 函数,该函数获取页面并将其存储在内部内存缓存中,而不执行导航。通过在用户点击之前预取页面,后续导航感觉是即时的,因为内容已经可用。

一种常见的模式是当用户悬停在链接上时预取页面,并在点击时导航。你可以使用两个指令在同一元素上组合这两种行为——data-wp-on--mouseenter 用于预取,data-wp-on--click 用于导航:

<a
    data-wp-on--mouseenter="actions.prefetchPage"
    data-wp-on--click="actions.navigateTo"
    href="/page-2/"
>
    Hover to prefetch, click to navigate
</a>

脚本模块中相应的 action 处理每个事件:

// view.js
import { store, withSyncEvent } from '@wordpress/interactivity';

store( 'myPlugin', {
    actions: {
        prefetchPage: function* ( event ) {
            const { actions } = yield import(
                '@wordpress/interactivity-router'
            );
            yield actions.prefetch( event.target.href );
        },

        navigateTo: withSyncEvent( function* ( event ) {
            event.preventDefault();

            const { actions } = yield import(
                '@wordpress/interactivity-router'
            );
            yield actions.navigate( event.target.href );
        } ),
    },
} );

完整示例:分页

本示例结合了路由器区域、导航和预取功能,为文章列表实现现场侧分页。

PHP 模板查询当前页面的文章,并将它们渲染在路由器区域内。底部的分页链接允许用户在页面之间移动。当用户悬停在“上一页”或“下一页”链接上时,目标页面会被预取。当他们点击时,路由器在现场侧进行导航——仅替换路由器区域内的内容,而无需重新加载整个页面。导航后,页面平滑滚动到顶部。

PHP:

<?php
$current_page = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
$query = new WP_Query( array(
    'paged'          => $current_page,
    'posts_per_page' => 5,
) );
?>

<div
    data-wp-interactive="myPagination"
    data-wp-router-region="myPagination/posts"
>
    <ul class="posts-list">
        <?php while ( $query->have_posts() ) : $query->the_post(); ?>
            <li>
                <a href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
            </li>
        <?php endwhile; wp_reset_postdata(); ?>
    </ul>

    <nav class="pagination">
        <?php if ( $current_page > 1 ) : ?>
            <a
                data-wp-on--mouseenter="actions.prefetch"
                data-wp-on--click="actions.navigate"
                href="?paged=<?php echo $current_page - 1; ?>"
            >
                &larr; Previous
            </a>
        <?php endif; ?>

        <span>Page <?php echo $current_page; ?></span>

        <?php if ( $query->max_num_pages > $current_page ) : ?>
            <a
                data-wp-on--mouseenter="actions.prefetch"
                data-wp-on--click="actions.navigate"
                href="?paged=<?php echo $current_page + 1; ?>"
            >
                Next &rarr;
            </a>
        <?php endif; ?>
    </nav>
</div>

JavaScript:

import { store, withSyncEvent } from '@wordpress/interactivity';

store( 'myPagination', {
    actions: {
        prefetch: function* ( event ) {
            const { actions } = yield import(
                '@wordpress/interactivity-router'
            );
            yield actions.prefetch( event.target.href );
        },

        navigate: withSyncEvent( function* ( event ) {
            event.preventDefault();

            const { actions } = yield import(
                '@wordpress/interactivity-router'
            );
            yield actions.navigate( event.target.href );

            // Scroll to top after navigation.
            window.scrollTo( { top: 0, behavior: 'smooth' } );
        } ),
    },
} );

更高级的用例

处理滚动和焦点

路由器不会自动管理导航后的滚动位置或焦点——这是调用 actions.navigate() 的 action 的责任。现场侧导航完成后,页面将保持在当前的滚动位置,焦点将停留在触发导航的元素上(如果该元素在区域更新期间被移除,则焦点可能会丢失)。

你应该在导航 action 中显式处理滚动和焦点。例如,为了在导航后滚动到顶部:

store( 'myPlugin', {
    actions: {
        navigateTo: withSyncEvent( function* ( event ) {
            event.preventDefault();

            const { actions } = yield import(
                '@wordpress/interactivity-router'
            );
            yield actions.navigate( event.target.href );

            // 导航后滚动到顶部。
            window.scrollTo( { top: 0, behavior: 'smooth' } );
        } ),
    },
} );

为了无障碍访问,建议在导航后将焦点移动到有意义的元素上,例如主内容区域或标题,以便键盘和屏幕阅读器用户知道他们在新页面的位置。

上述模式仅适用于该操作启动的导航。要涵盖它未启动的导航——同一区域内的其他链接,或前进/后退遍历——请改为对导航结束做出反应,使用路由器 state.navigating 属性的监听器。此类监听器的运行时间略晚于上面 yield 之后的代码,因为 state.navigating 是在比 actions.navigate() 解析所在的帧更晚的一帧中更新的。有关监听器,请参阅 导航后的区域范围焦点;有关它读取的属性,请参阅 响应导航生命周期。

在导航时添加新区域

有时您需要仅在特定页面上出现的 UI 元素——如模态框、侧边栏或通知面板。使用常规的路由器区域时,区域必须已经存在于当前页面上才能在导航期间被更新。attachTo 选项通过允许您定义动态创建并插入 DOM 的区域来解决此问题,这些区域在导航到包含它们的页面时会被插入,即使它们在原始页面上不存在。

使用 attachTo 定义区域:

<div
    data-wp-interactive="myPlugin"
    data-wp-router-region='{ "id": "myPlugin/modal", "attachTo": "body" }'
>
    <div class="modal-overlay">
        <div class="modal-content">
            <h2>Modal Title</h2>
            <p>Modal content here...</p>
        </div>
    </div>
</div>

attachTo 的值是一个 CSS 选择器。当从没有此区域的页面导航到此页面时,区域将被创建并附加到匹配选择器的元素上。

示例:导航时出现的模态框

没有模态框的页面 (page-1.php):

<div
    data-wp-interactive="myPlugin"
    data-wp-router-region="myPlugin/content"
>
    <h1>Page 1</h1>
    <a
        data-wp-on--click="actions.navigate"
        href="/page-with-modal/"
    >
        Open page with modal
    </a>
</div>

带有模态框的页面 (page-2.php):

<div
    data-wp-interactive="myPlugin"
    data-wp-router-region="myPlugin/content"
>
    <h1>Page 2</h1>
    <a
        data-wp-on--click="actions.navigate"
        href="/page-without-modal/"
    >
        Close modal
    </a>
</div>

<div
    data-wp-interactive="myPlugin"
    data-wp-router-region='{ "id": "myPlugin/modal", "attachTo": "body" }'
>
    <div class="modal-overlay">
        <div class="modal-content">
            <h2>I'm a modal!</h2>
        </div>
    </div>
</div>

当从第 1 页导航到第 2 页时,模态框区域被创建并附加到 <body>。当导航回第 1 页时,模态框会自动移除。

使用 data-wp-key 保留元素

在客户端导航期间,路由器使用虚拟 DOM 差异算法来更新路由器区域内的内容。该算法依赖于启发式方法,以有效地匹配当前页面和目标页面之间的元素。这些启发式方法在大多数情况下效果良好,但在某些条件下可能会失败——例如,当两个元素在不同页面上具有相同的类型和位置但使用不同的指令时。在这种情况下,算法可能会错误地将它们视为同一个元素,导致状态损坏或行为异常。

为了防止这种情况,您可以使用 data-wp-key 指令为元素提供稳定且明确的身份标识。当协调算法遇到带键的元素时,它会按键进行匹配,而不是依赖启发式方法。具有匹配键的元素会在原地更新,从而保留其内部状态:焦点、滚动位置、CSS 动画、表单输入值以及对 DOM 节点的任何 JavaScript 引用。未匹配的元素将根据需要干净地移除或创建。

键在以下两种场景中尤为重要:

  1. 跨页面变化的列表——例如分页帖子、过滤结果或排序表格。
  2. 结构在页面之间不同的区域——例如,一个区域在一页包含侧边栏而在另一页不包含,或者在同一区域渲染不同块的页面。

如果没有键,协调启发式方法可能会错误地匹配偶然共享相同类型和位置的不相关元素。最好的情况是造成不必要的 DOM 重新创建;最坏的情况是损坏元素状态或产生错误的标记——例如,将一个元素的指令应用到完全不同的元素上。

使用基于稳定标识符的键,算法可以通过身份匹配元素,而不是依赖启发式方法。这确保了每个元素在导航过程中都能被正确识别。

PHP:

<div
    data-wp-interactive="myPagination"
    data-wp-router-region="myPagination/posts"
>
    <ul>
        <?php while ( $query->have_posts() ) : $query->the_post(); ?>
            <li data-wp-key="post-<?php echo get_the_ID(); ?>">
                <a href="<?php the_permalink(); ?>">
                    <?php the_title(); ?>
                </a>
            </li>
        <?php endwhile; wp_reset_postdata(); ?>
    </ul>
</div>

每个 <li> 都通过帖子 ID 进行键控。如果用户从一个结果页导航到另一个结果页,并且某篇帖子同时出现在两个页面上,路由器会重用该帖子的现有 DOM 节点,而不是销毁并重新创建它。

键对于非列表元素同样有用。如果路由器区域在不同页面上渲染结构不同的内容,对顶层部分进行键控有助于算法区分它们:

<div
    data-wp-interactive="myPlugin"
    data-wp-router-region="myPlugin/content"
>
    <?php if ( is_product_page() ) : ?>
        <section data-wp-key="product-detail">
            <!-- Product detail layout -->
        </section>
    <?php else : ?>
        <section data-wp-key="product-list">
            <!-- Product list layout -->
        </section>
    <?php endif; ?>
</div>

如果没有键,在这两个页面之间导航会导致算法按位置将产品详情 <section> 修补到产品列表 <section>(反之亦然),从而可能损坏其内部状态。通过不同的键,算法可以识别它们是不同元素,并干净地用一个替换另一个。

选择合适的键值

键应当满足以下条件:

  • 稳定:无论项目在列表中的位置如何,同一项目应始终生成相同的键。
  • 兄弟元素间唯一:没有两个兄弟元素应共享相同的键。键只需在其父级内唯一,无需全局唯一。

尽可能使用数据派生的标识符——如帖子 ID、术语 ID 或任何能唯一标识项目的值。避免使用数组索引作为键,因为当项目重新排序、添加或删除时,索引会发生变化,这将使键控的目的落空。

<!-- Good: stable, data-derived key -->
<li data-wp-key="post-42">...</li>

<!-- Bad: index-based key (changes when items shift) -->
<li data-wp-key="item-0">...</li>

处理服务器状态更新

在客户端导航期间,客户端状态保持不变,而服务器为目标页面提供新状态。在某些情况下,您可能希望客户端状态的某些部分与服务器为每个页面提供的状态保持同步——例如,更新跨页面变化的产品计数,或根据新页面的上下文重置“展开”标志。

这种同步不会自动发生。当路由器加载页面时,它会将该页面的服务器数据合并到客户端状态中,而不覆盖:已存在的键会保留其值,仅添加新键。这允许首次出现在新页面上的区块从服务器初始化,而不会丢弃访客在当前页面上所做的更改,但也意味着,除非您自行重新同步,否则读取服务器种子键的绑定将在每次后续导航中继续显示第一页种子的值。

使用 getServerState() 和 getServerContext() 专门响应服务器提供的值,并在回调中有选择地更新客户端状态。与它们更新的客户端状态不同,这两个函数始终返回当前页面的服务器数据:

import {
    store,
    getContext,
    getServerState,
    getServerContext,
} from '@wordpress/interactivity';

const { state } = store( 'myPlugin', {
    callbacks: {
        syncWithServer() {
            const serverState = getServerState();
            const serverContext = getServerContext();
            const context = getContext();

            // Keep the product count in sync with the server across navigations.
            if ( serverState.productCount !== undefined ) {
                state.productCount = serverState.productCount;
            }

            // Reset the expanded state based on the new page's context.
            if ( serverContext.isExpanded !== undefined ) {
                context.isExpanded = serverContext.isExpanded;
            }
        },
    },
} );

有关合并的完整说明,包括上下文和状态发生的变化,请参阅 导航期间服务器上下文和状态合并的工作原理。有关这两个函数本身的更多信息,请参阅 理解全局状态、局部上下文和派生状态 指南。

覆盖路由器的内部内存缓存页面

默认情况下,一旦页面存储在路由器的内部内存缓存中,后续的导航将使用缓存版本,而不会发起新的网络请求。使用 force 选项绕过路由器的内部内存缓存,并从服务器重新获取页面:

// 强制使用 navigate() 重新获取。
yield actions.navigate( '/products/', { force: true } );

// 强制使用 prefetch() 重新获取。
yield actions.prefetch( '/products/', { force: true } );
如果您正在使用 force: true 在修改(POST、PUT、DELETE 请求)后刷新页面,请确保在导航之前修改已完成:
store( 'myPlugin', {
    actions: {
        deleteAndRefresh: function* () {
            // 等待删除完成。
            yield fetch( '/wp-json/wp/v2/posts/123', { method: 'DELETE' } );

            // 现在刷新页面以显示更新后的数据。
            const { actions } = yield import(
                '@wordpress/interactivity-router'
            );
            yield actions.navigate( window.location.href, { force: true } );
        },
    },
} );

使用自定义 HTML

您可以不使用从 URL 获取页面,而是使用 html 选项直接提供 HTML:

// 使用自定义 HTML 进行导航。
yield actions.navigate( '/custom-page/', {
    html: `
        <div data-wp-interactive="myPlugin" data-wp-router-region="myPlugin/content">
            <h1>Custom Content</h1>
            <p>This HTML was provided directly, not fetched.</p>
        </div>
    `,
} );

// 使用自定义 HTML 预取。
yield actions.prefetch( '/custom-page/', {
    html: customHtmlString,
} );

当您需要自己控制 fetch 请求时,这非常有用。

管理浏览器历史记录

默认情况下,navigate() 使用 pushState 向浏览器的会话历史中添加一个新条目。改用 replace 选项来替换当前的历史记录条目:

// 默认行为:添加新的历史记录条目(pushState)。
yield actions.navigate( '/page-2/' );

// 替换当前历史记录条目(replaceState)。
yield actions.navigate( '/page-2/', { replace: true } );

在以下情况请使用 replace: true:

  • 更新用于过滤/排序的查询参数,其中每次更改都不应成为单独的历史记录条目。
  • 实现无限滚动,在此处您更新 URL 但不希望每个页面都成为单独的历史记录条目。

更改超时时间

如果导航耗时过长,路由器将回退到传统的整页加载。默认超时时间为 10 秒。使用 timeout 选项更改此设置:

// 较短的超时时间以实现更快的失败检测。
yield actions.navigate( '/page/', { timeout: 5000 } );

// 较长的超时时间以适应慢速连接。
yield actions.navigate( '/page/', { timeout: 30000 } );

处理 fetch 错误

当导航失败(网络错误、超时或服务器错误)时,路由器会自动回退到整页重载。这意味着您无法直接从 navigate() 捕获 fetch 错误——在您的代码有机会处理它们之前,浏览器就已经接管了。

如果您需要自定义错误处理(例如,显示错误消息而不是重新加载),您可以手动获取页面,自行处理任何错误,然后使用 html 选项将获取到的 HTML 传递给 navigate():

store( 'myPlugin', {
    actions: {
        navigateWithCustomErrorHandling: withSyncEvent( function* ( event ) {
            event.preventDefault();
            const url = event.target.href;

            try {
                // 手动获取页面。
                const response = yield fetch( url );

                if ( ! response.ok ) {
                    // 处理 HTTP 错误。
                    state.error = `Error: ${ response.status }`;
                    return;
                }

                const html = yield response.text();

                // 使用获取到的 HTML 进行导航。
                const { actions } = yield import(
                    '@wordpress/interactivity-router'
                );
                yield actions.navigate( url, { html } );
            } catch ( error ) {
                state.error = 'Network error. Please check your connection.';
            }
        } ),
    },
} );

在某些页面上禁用客户端导航

某些页面可能需要整页重载而不是客户端导航。使用 wp_interactivity_config() 禁用客户端导航:

// 在主题的 functions.php 或插件中。
add_action( 'wp', function() {
    // 在特定页面模板上禁用。
    if ( is_page_template( 'template-complex.php' ) ) {
        wp_interactivity_config(
            'core/router',
            array( 'clientNavigationDisabled' => true )
        );
    }
} );

当 clientNavigationDisabled 为 true 时:

  • actions.navigate() 会触发整页重载。
  • actions.prefetch() 不执行任何操作。
  • 从其他页面导航到此页面会强制重载。

禁用导航反馈

Interactivity API 路由器内置了导航期间的反馈:

  • 加载动画:在导航期间出现在页面顶部的进度条。如果导航尚未完成,进度条会在短暂延迟(400ms)后出现。引入这 400ms 的延迟是为了避免在页面已成功预取或连接速度非常快时显示动画。
  • 屏幕阅读器公告:用于导航进度的无障碍公告。

在某些情况下,您可能希望禁用这些功能:

// 禁用加载动画(为了感觉像即时更新)。
yield actions.navigate( '/page/', { loadingAnimation: false } );

// 禁用屏幕阅读器公告(当提供自定义公告时)。
yield actions.navigate( '/page/', { screenReaderAnnouncement: false } );

// 同时禁用两者。
yield actions.navigate( '/page/', {
    loadingAnimation: false,
    screenReaderAnnouncement: false,
} );

禁用反馈的用例:

  • 静默更新:后台刷新,您不希望引起注意。
  • 自定义加载 UI:当您实现自己的加载指示器时。
  • 自定义无障碍支持:当您提供自己的屏幕阅读器公告时。

订阅页面更改

core/router 商店暴露了一个响应式的 state.url 属性,该属性在每次发生客户端导航时都会更新。通过在 data-wp-watch 或 watch 回调中读取此值,您可以创建一个响应式订阅,每当 URL 更改时都会重新运行。

// view.js
import { watch, store } from '@wordpress/interactivity';

const { state: routerState } = store( 'core/router' );

// 商店范围的订阅。
watch( () => {
    sendAnalyticsPageView( routerState.url );
} );

// 基于元素的订阅:<div data-wp-watch="callbacks.sendPageView">
store( 'myPlugin', {
    callbacks: {
        sendPageView() {
            sendAnalyticsPageView( routerState.url );
        },
    },
} );
core/router 商店和 state.url 在页面加载时即可用并填充,因此无需导入 @wordpress/interactivity-router 包即可访问它们。

响应导航生命周期

state.url 告诉您导航结束的位置。同一 core/router 商店上的另外两个响应式属性告诉您导航是否正在进行以及是谁发起的。它们共同允许区块显示自己的加载指示器,在由它未发起的导航后移动焦点,或忽略后退/前进遍历。

导航生命周期键

  • state.navigating — 在客户端导航进行中时为真值。
  • state.initiator — 标识谁启动了当前导航的字符串,如果无法识别启动者则为 null。

当路由器注册其 store 时,并未声明这些属性,因此在首次客户端导航之前,读取它们都会得到 undefined。如果路由器模块未加载或从未加载,你在页面上也会得到相同的 undefined,这意味着询问“是否有导航正在进行?”的代码能在每个页面上获得正确答案。

通过真值性来检查这些属性:编写 if ( state.navigating ) 和 state.initiator === myRegionId,而不是 state.navigating === false 或 state.initiator === null。每个属性都有两个空闲值——路由器首次运行之前的 undefined,以及之后写入的 false 或 null。

有关确切类型和边缘情况,请参阅 core/router state reference。

键何时变化

当导航开始时,路由器会在等待目标页面之前,在一个批次中同时设置 state.navigating 和 state.initiator。这适用于所有客户端导航,无论速度多快:禁用导航反馈中描述的 400ms 延迟仅适用于内置加载动画和屏幕阅读器公告,且 loadingAnimation 和 screenReaderAnnouncement 选项也不会影响这些属性。即使导航到当前所在的 URL(例如 navigate( window.location.href, { force: true } ) 刷新模式),也算作一次导航。

当导航结束时,路由器将 state.navigating 重置为 false。此时新内容通常已在 DOM 中,state.url 也已包含目标 URL;下面 导航结束但未获取新内容时 描述了两种例外情况。此更新发生在 DOM 提交(即新内容应用到真实 DOM 的时刻)之后的帧上,因此即使是极快的导航也会产生两次单独的更新,而不是一次仅显示完成状态的更新。具体晚多少帧并未指定,因此应将其视为“在内容提交后的某段时间”。特别是,actions.navigate() 返回的承诺(promise)在此更新之前就已解析,因此像 yield actions.navigate( url ); if ( state.navigating ) { … } 这样的代码仍然会看到 state.navigating 为真值。要响应导航的结束,请使用 watcher。

state.initiator 在导航结束时不会被清除。它会保留最后一次导航的值,直到下一次导航开始,因此响应结束的 watcher 仍然可以读取是谁启动了它。(唯一的例外是重新加载文档的后退/前进遍历,这在 哪些导航会更新键 中描述。)这也意味着像 ! state.navigating && state.initiator === myRegionId 这样的条件在页面随后处于空闲状态期间一直为真,而不仅仅是在导航结束的那一刻。应响应变化本身,而不是条件为真的状态;下面的 焦点配方 展示了如何做。

导航结束但未获取新内容时

通常情况下,当 state.navigating 变为 false 时,新内容已经在 DOM 中。有两种情况打破了这一规则:

  • 路由器在渲染新页面时抛出异常。 state.navigating 在稍后的帧上仍被设置为 false,因此它不会永远保持真值。state.url 已包含目标 URL,但区域可能仅部分更新。
  • 导航回退到了整页加载。 获取失败、非 200 响应、无法解析的响应、导航超时,或者配置禁用了客户端导航的目标页面,都会使路由器将导航交给浏览器,由浏览器替换文档。没有渲染任何内容,state.url 仍然保留你所在页面的 URL。在浏览器替换文档期间,state.navigating 保持真值。如果回退开始后 10 秒文档仍然存在——例如访客拒绝了卸载提示,或者重载非常慢——路由器将停止等待并将 state.navigating 设置为 false。这不是错误报告:它仅表示没有任何操作正在进行。这 10 秒是根据页面自身的计时器测量的,因此后台标签页可能会稍后执行重置,并且这与 navigate() 的 timeout 选项无关,尽管该选项默认也是 10 秒。

在这两种情况下,state.initiator 都会保留导航的值,就像在其他任何导航之后一样。

因此,当导航结束时接触 DOM 的回调应该在回调内部查找元素并检查它们是否存在,而不是假设目标页面已经存在。仅读取 store 状态的回调不受影响。

哪些导航会更新键

以下情况会更新这两个属性:

  • 导致客户端导航的每次 actions.navigate() 调用,无论来自哪个调用点以及使用什么选项:绑定到指令的动作、导入路由器本身的代码,或全页模式的文档级点击监听器。
  • 路由器从其内存缓存中服务的每次后退/前进遍历。

以下情况不会启动导航:

  • 在禁用了客户端导航的页面上的 navigate() 调用。路由器在执行任何客户端工作之前就将导航交给浏览器。
  • 访问不在内存缓存中的页面,或其缓存的获取失败的页面,从而导致整页重载。从空闲状态开始,它不写入任何内容。如果访客按下后退时仍有导航正在进行,或早期导航的发起者仍处于设置状态,则遍历会在触发重载时将 state.navigating 设置为 false,并将 state.initiator 设置为 null,因此陈旧的值不会保留到重载中。
  • actions.prefetch()。预取不是导航。

失败的导航永远不会报告为成功。 当导航在客户端启动然后回退到整页加载时,state.navigating 变为真值,没有渲染任何内容,state.url 保留你所在页面的 URL。在浏览器替换文档期间,state.navigating 保持真值,只有当 10 秒后文档仍然存在时才设置为 false,如 导航结束但未获取新内容时 所述。在这种情况下,navigate() 返回的承诺也永远不会解析。

重叠的导航

路由器遵循最后胜出原则(参见 竞态条件保护),这些属性也是如此。如果在第一个导航结束之前第二个导航开始,state.navigating 将从第一次开始保持真值,直到获胜的导航结束,中间没有空闲间隙,放弃的导航完成时也没有第二次“结束”。当获胜者结束时,state.navigating 仅变为 false 一次。导航结束但未获取新内容时 中描述的 10 秒限制也适用于此处:如果获胜的导航是后退/前进遍历,且其缓存的页面获取在 10 秒后仍未完成,路由器会将 state.navigating 设置为 false,而不是让它无限期地保持真值。如果该获取稍后完成,遍历则会像普通导航一样更新这两个属性。

state.initiator 始终标识最近的导航。当第二个导航开始时,它会立即替换第一个导航的值,包括当第二个导航没有可识别的发起者时用 null 替换。在后继导航进行时到达的后退/前进遍历正是如此:它接管并清除发起者。

对于每个区域的加载指示器,这是你想要的行为,无需你自己进行簿记:其导航被另一个区域取代的区域在该时刻不再显示为发起者,来自同一区域的两个重叠导航会保持该区域显示为发起者,直到最后一个导航结束。

谁启动了导航

actions.navigate() 接受一个 initiator 选项,它可以接受三种类型的值:

// 根据调用位置推导(默认值)。
yield actions.navigate( url );

// 显式设置。
yield actions.navigate( url, { initiator: 'myPlugin/cart' } );

// 留空。
yield actions.navigate( url, { initiator: null } );
  • 非空字符串 会按原样存储在 state.initiator 中,且不进行任何区域查找。使用此方法可为区块赋予其自身的标识,以便其他代码读取,包括那些不拥有导航操作的存储中的代码。
  • null 会使整个导航过程中的 state.initiator 保持为 null,即使调用发生在路由器区域内部。
  • 省略时 会从调用操作的位置派生发起者:包含调用操作的元素的最近带有 data-wp-router-region 的元素(包括该元素本身)。当区域嵌套时,最近的区域优先:路由器更新的仍然是外部区域,但启动导航的是内部区域。

派生的发起者是您在代码中编写的区域 ID,而非原始属性文本:对于在 在导航中添加新区域 中使用的 JSON 对象形式,它是 id 的值,并且会剥离 namespace:: 前缀。任何其他类型的 initiator 值都被视为 null。请参阅 initiator 选项参考 以获取确切规则。

没有发起者的导航

当出现以下情况时,state.initiator 为 null:

  • 访问者使用了浏览器的后退或前进按钮。 没有任何区块启动导航,这使得区块能够区分遍历和它自己启动的导航。
  • 链接由全页模式的文档级监听器处理,无论链接位于何处。区块区域内的普通链接并非由该区块启动。
  • navigate() 在没有指令作用域的情况下被调用,例如从导入路由器并直接从其自身代码调用而不是从绑定到元素的操作中调用的模块中调用。
  • navigate() 是从响应 state.navigating、state.initiator 或 state.url 的 watch() 内部调用的。 该回调函数在没有指令作用域的情况下运行,因此调用不会继承它所响应的导航的发起者。响应相同更改的 data-wp-watch 会在下一帧运行,并按常规派生其自己的区域。

在全页模式下,<body> 元素本身携带 data-wp-router-region="core/body",因此从每个区块的路由器区域之外调用 navigate() 的带有指令的元素会派生出 core/body,而在基于区域的模式中相同的标记会派生出 null。请参阅 全页客户端侧导航。

判断哪个区域启动了导航

没有每区域标志;请结合这两个属性:

const startedHere = state.navigating && state.initiator === myRegionId;

派生的发起者标识的是区域,而不是元素:同一路由器区域内的两个区块获得相同的发起者,仅凭它无法区分它们。当您有需要时,为每个实例提供自己的路由器区域或传递明确的 initiator。已经为每个实例渲染一个路由器区域的区块,例如 myPlugin/gallery-1 和 myPlugin/gallery-2,则不需要这样做:每个实例都会派生出自己的 ID,因此页面上的两个副本已经是可区分的。

选择在哪里读取键

您读取这两个属性的位置决定了您的代码何时运行以及它能看到什么:

  • data-wp-watch 在元素的作用域内运行,因此 getContext() 和 getElement() 在其中可用。它会被推迟一帧,并合并落在同一帧内的更改,而响应导航结束的回调可以看到新提交的 DOM。通过指令评估的派生状态获取器也在此元素的作用域内运行。
  • watch(),来自 @wordpress/interactivity 的工具,在值更改时同步运行,并且没有指令作用域——其中不可用 getContext() 和 getElement()。将其用于仅触及存储状态的页面级工作。因为它同步运行,所以基于 state.url 的 watch() 在新区域内容渲染之前运行,并观察*旧* DOM;基于导航结束的关键字使其在提交后运行并观察新的 DOM。请参阅 watch() 参考。
  • data-wp-init 在每个元素的作用域内运行一次,这使其成为属于水合而非导航的设置所在的位置。

上面的“新 DOM”假设导航渲染了其内容。在 当导航在没有新内容的情况下结束时 描述的两种情况下,使用任一原始类型响应结束的回调可能会找到上一页或部分更新的页面,因此在回调中查找元素并检查它们是否存在。

配方

每区块加载指示器

要显示特定区块正在加载,请在派生状态获取器中组合这两个属性,并通过获取器绑定它。与您自己的操作在其 actions.navigate() 调用周围设置的标志不同,这涵盖了区域内任何链接或操作启动的导航,可以限制为一个区域而不是区块的每个实例,并且可以从任何存储中读取,而不仅仅是拥有该操作的存储。

// view.js
import { store } from '@wordpress/interactivity';

// `core/router` 存储可在不导入路由器包的情况下使用。
const { state: routerState } = store( 'core/router' );

store( 'myPlugin', {
    state: {
        get isLoading() {
            return (
                !! routerState.navigating &&
                routerState.initiator === 'myPlugin/quote'
            );
        },
    },
} );
<div data-wp-interactive="myPlugin" data-wp-router-region="myPlugin/quote">
    <span data-wp-class--is-loading="state.isLoading"></span>
</div>

每个实例一个区域,并在上下文中包含其 ID。 当同一个区块在页面上出现多次且每个副本需要自己的指示器时,为每个副本提供自己的路由器区域,并在渲染时将那个 ID 写入区块的上下文:

<?php
$region_id = "myPlugin/gallery-{$instance_id}";
?>
<div
    data-wp-interactive="myPlugin"
    data-wp-router-region="<?php echo esc_attr( $region_id ); ?>"
    <?php echo wp_interactivity_data_wp_context( array( 'regionId' => $region_id ) ); ?>
>
    <span data-wp-class--is-loading="state.isLoading"></span>
</div>

获取器使用 getContext() 将其读回,因此一个获取器服务于每个实例:

// view.js
import { getContext, store } from '@wordpress/interactivity';

const { state: routerState } = store( 'core/router' );

store( 'myPlugin', {
    state: {
        get isLoading() {
            const { regionId } = getContext();
            return (
                !! routerState.navigating && routerState.initiator === regionId
            );
        },
    },
} );

您也可以从 DOM 内部在获取器中找到 ID,使用 getElement().ref.closest( '[data-wp-router-region]' ),但从上下文中读取会将区块在渲染时已知的值交给获取器,无需在每次评估时进行 DOM 遍历,也无需重新实现上述的 ID 规范化。

在初始页面加载时,两个属性都读取为 undefined,因此获取器返回 false,指示器保持隐藏。在后退/前进遍历时,state.initiator 永远不会是您的区域的 ID,因此指示器也保持隐藏:遍历不是您的区块启动的内容。

导航后的区域范围焦点

处理滚动和焦点 在导航操作内、yield 之后移动焦点,这只涵盖该操作启动的导航。要在从区域内部启动的任何导航之后移动焦点,并明确忽略后退/前进遍历,请在区域内放置一个 data-wp-watch,当 state.navigating 从真值变为假值时执行操作,并且仅当 state.initiator 是该区域自己的 ID 时执行:

// view.js
import { getContext, getElement, store } from '@wordpress/interactivity';

const { state: routerState } = store( 'core/router' );

// 每个区域的 `navigating` 旧值。关于为什么它既不在 `context` 中也不在区域元素上的原因,请参阅下面的两个说明。
const wasNavigating = {};

store( 'myPlugin', {
    callbacks: {
        focusAfterNavigation() {
            const { regionId } = getContext();
            const navigating = !! routerState.navigating;

            if (
                wasNavigating[ regionId ] &&
                ! navigating &&
                routerState.initiator === regionId
            ) {
                // 在导航结束后查找,从未在之前捕获:此时该区域的内容已被替换。
                // `?.` 链涵盖了没有新内容结束的导航情况。
                getElement()
                    .ref.closest( '[data-wp-router-region]' )
                    ?.querySelector( 'a' )
                    ?.focus();
            }

            wasNavigating[ regionId ] = navigating;
        },
    },
} );

观察者位于区域内部独立的元素上,区域的 ID 通过上述每个实例示例使用的相同上下文行传递给它:

<div
    data-wp-interactive="myPlugin"
    data-wp-router-region="myPlugin/gallery-1"
    data-wp-context='{ "regionId": "myPlugin/gallery-1" }'
>
    <span data-wp-watch="callbacks.focusAfterNavigation"></span>
</div>

如果区域 ID 是你手动编写的字面量,请删除 getContext() 行并与该字面量进行比较。

响应变化,而不是响应条件。 state.initiator 在导航结束后仍保留其值(参见 当键发生变化时),因此检查 ! state.navigating && state.initiator === regionId 的回调会在每次后续重新运行时再次移动焦点。与之前的值进行比较使其仅在导航结束时恰好执行一次。

将簿记工作移出 context。 一个通过响应式 context 代理读取和写入同一值的回调会订阅自身的写入并重新触发。之前的 navigating 值是簿记数据,不是页面渲染的内容,因此普通的模块级对象是存放它的正确位置。

也将簿记工作移出区域元素。 导航可以在你的模块保持加载的状态下拆除并重建区域的 DOM,因此存储在该元素本身上的任何内容都无法存活过它试图测量的那次导航。按区域 ID 索引的模块级对象可以存活下来,并且一个对象即可服务于页面上的所有区域。

不要在导航期间捕获任何 DOM 引用。 焦点目标是在导航结束后查找的,此时目标内容已经提交,因此查找找到的是新页面的元素。在 当导航在没有新内容的情况下结束时 描述的两种情况下,元素可能缺失,这就是为什么查找以 ?.focus() 结尾而不是假设成功的原因。

在初始页面加载时,回调在水合(hydration)时运行一次,将 state.navigating 读为 undefined,记录 false 并什么都不做。在后退/前进遍历中,state.initiator 为 null,因此比较失败,焦点保持在浏览器放置的位置,这通常是按下后退按钮的访问者所期望的。

防抖的页面级加载栏

路由器的内置加载动画仅在导航在 400ms 后仍在进行时出现,因此预取或缓存的导航永远不会向访问者闪烁加载栏。如果你禁用了该动画并绘制自己的动画(参见 禁用导航反馈),请重现延迟,而不是直接将加载栏绑定到 state.navigating:当 state.navigating 变为真值时启动计时器,当它变为假值时清除它。这是页面级别的工作,仅触及 store 状态,因此使用 watch() 而不是 data-wp-watch:

// view.js
import { store, watch } from '@wordpress/interactivity';

const { state: routerState } = store( 'core/router' );

const { state } = store( 'myPlugin', {
    state: {
        // 此 store 自身的状态,声明为空闲默认值。与路由器的两个属性不同,它不是任何内容的透传。
        showBar: false,
    },
} );

let timer;

watch( () => {
    if ( routerState.navigating ) {
        timer = setTimeout( () => {
            state.showBar = true;
        }, 400 );
    } else {
        clearTimeout( timer );
        state.showBar = false;
    }
} );
<div
    data-wp-interactive="myPlugin"
    data-wp-class--show-bar="state.showBar"
></div>

回调仅读取 state.navigating,不读取其他任何内容,因此它仅在该属性更改时重新运行,这防止了计时器被启动两次。由此衍生出三点,且都不需要你进行自己的簿记:

  • 重叠的导航只启动一次计时器。 state.navigating 从第一次开始直到获胜的导航结束都保持为真值(参见 重叠的导航),因此双击不会重新启动延迟或使加载栏闪烁。
  • 回退到全页加载的导航会在浏览器替换文档期间保持加载栏显示。 仅当 10 秒后文档仍然存在时,state.navigating 才会变为 false(参见 当导航在没有新内容的情况下结束时),因此只要全页加载真正在进行,加载栏就会保持显示;如果加载从未发生(例如,因为访问者拒绝了卸载提示),加载栏则会消失。
  • 遍历行为与其他任何导航一样。 这些由内存缓存提供服务的导航几乎总是在 400ms 窗口内完成,因此不会出现加载栏。

在初始页面加载时,state.navigating 为 undefined,因此第一次运行走 else 分支并什么都不做。

深入理解 Interactivity Router

本节提供了有关客户端导航如何在内部工作的详细技术解释。了解这些内部机制可以帮助你调试问题、优化性能,并就如何构建代码做出明智的决策。

内部的内存页面缓存

Interactivity API 路由器的核心是一个内部的内存页面缓存——一个简单的 store,它将 URL 映射到其处理后的页面表示形式。当你调用 prefetch() 或 navigate() 时,路由器首先检查此缓存,以确定目标页面是否已经被获取和处理。

缓存使用 URL 的规范化版本作为其键。这种规范化去除了域名和任何哈希片段,仅保留路径名和查询参数。例如,https://example.com/products/?category=shoes#details` 变为/products/?category=shoes`。这确保了导航到相同逻辑页面(无论 URL 是如何构造的)共享同一个缓存条目。

缓存中的每个条目不仅存储获取的 HTML,还存储完全处理后的页面表示形式,其中包含:

  • 虚拟 DOM 树,用于页面中找到的每个路由器区域
  • 样式表引用,页面所需
  • 脚本模块信息,用于应加载的 JavaScript
  • 页面标题,用于更新文档标题
  • 服务器状态,由 WordPress 嵌入页面中

一个重要的细节是,缓存存储的是承诺(promises)而不是解析后的值。当获取开始时,路由器立即将挂起的承诺存储在缓存中。这意味着如果对 prefetch() 或 navigate() 的多次调用同时针对相同的 URL(例如,如果用户快速悬停在指向同一页面的多个链接上),则只会发出一个网络请求。所有调用者都会收到相同的承诺并等待相同的结果。

一旦页面进入缓存,它将在整个浏览器会话期间保留在那里。随后对该 URL 的导航将立即使用缓存的版本,无需任何网络请求。这就是为什么在首次访问后客户端导航感觉如此迅速的原因——页面已经准备就绪并可渲染。

如果你需要强制进行新的获取(例如,在提交了更改页面内容的表单之后),你可以对 navigate() 或 prefetch() 使用 force: true 选项。这将绕过缓存检查并重新获取页面,用新鲜内容替换现有的缓存条目。

// 在表单提交后强制进行全新获取。
const { actions } = store( 'myPlugin', {
    actions: {
        *submitForm() {
            yield fetch( '/wp-json/my-plugin/v1/submit', {
                method: 'POST',
                body: JSON.stringify( {
                    /* 表单数据 */
                } ),
            } );
            // 导航到同一页面,绕过缓存
            // 以反映更新后的内容。
            const { actions: routerActions } = yield import(
                '@wordpress/interactivity-router'
            );
            yield routerActions.navigate( window.location.href, {
                force: true,
            } );
        },
    },
} );

Router regions

Router regions(路由器区域)是路由器知道如何在客户端导航期间更新的页面部分。它们充当边界,告诉路由器“这是在不同页面之间导航时应更改的内容。”

定义路由器区域

你可以通过向元素添加 data-wp-router-region 属性以及 data-wp-interactive(如上所述 设置路由器区域)来定义路由器区域。

属性值作为该区域的唯一标识符。你可以通过两种方式指定它:

  1. 作为简单字符串:
    <div
        data-wp-interactive="myPlugin"
        data-wp-router-region="myPlugin/main-content"
    >
        <!-- 区域内容 -->
    </div>
    
  2. 作为 JSON 对象(当你需要传递其他选项时):
    <div
        data-wp-interactive="myPlugin"
        data-wp-router-region='{ "id": "myPlugin/modal", "attachTo": "body" }'
    >
        <!-- 区域内容 -->
    </div>
    

区域 ID 必须在单个页面内是唯一的,并且在共享相同区域的页面之间保持一致。例如,如果你的“产品”页面和“产品详情”页面都有一个侧边栏,并且你希望该侧边栏在导航期间更新,那么两个页面都应定义具有相同 ID 的区域(例如,"myPlugin/sidebar")。

页面获取期间区域的处理方式

当路由器获取新页面(无论是通过 prefetch() 还是作为 navigate() 的一部分)时,它会处理 HTML 以提取并准备所有路由器区域。这分几个步骤发生:

首先,路由器使用浏览器内置的 HTML 解析器将获取的 HTML 解析为文档结构。这给它一个完整的 DOM 树来处理,就像正常加载页面一样。

接下来,路由器扫描此文档以查找同时具有 data-wp-interactive 和 data-wp-router-region 属性的所有元素。对于找到的每个区域,它提取区域 ID 并检查该区域是否嵌套在另一个区域内。只有顶级区域会直接处理;嵌套区域作为其父级内容的一部分进行处理。

对于每个顶级区域,路由器将 HTML 转换为虚拟 DOM (vDOM) 表示。虚拟 DOM 是一个轻量级的 JavaScript 对象结构,它镜像实际的 DOM,但可以被 Interactivity API 更高效地比较和操作。重要的是,区域元素本身包含在此转换中——不仅仅是它的子元素。这意味着区域元素上的属性,例如 data-wp-context,也会在导航期间处理和更新。

最后,每个区域的虚拟 DOM 存储在页面缓存条目中,按其区域 ID 索引。现在,缓存条目包含区域 ID 到其相应虚拟 DOM 树的映射。

除了处理区域外,路由器还在此步骤中提取页面的 CSS 样式表和 JavaScript 脚本模块。尚未加载的新样式表将以禁用状态添加到文档中,以便浏览器可以开始下载它们而不应用它们。同样,新的脚本模块被识别,其依赖树被解析和获取。这些资产提前准备好,以便当导航实际渲染新内容时,所有必要的样式和脚本都已就绪。有关如何处理样式和脚本模块的详细信息见下面的 CSS 处理 和 脚本模块处理 部分。

导航期间区域的渲染方式

当调用 navigate() 且目标页面已成功获取(或已缓存)时,路由器需要更新当前页面以显示新内容。此渲染过程经过精心编排,以确保效率并避免视觉故障。

路由器首先检查当前页面上存在哪些区域以及目标页面上存在哪些区域。基于此比较,可能发生三种不同的情况:

场景 1:区域存在于两个页面上(更新)

这是最常见的情况。当给定 ID 的区域同时存在于当前页面和目标页面上时,路由器会使用新内容更新现有区域。

路由器不仅仅简单地替换整个区域的 HTML(这会破坏任何内部状态并导致生硬的视觉过渡),而是使用虚拟 DOM 差异算法。此算法比较当前区域的虚拟 DOM 与新区域的虚拟 DOM,并计算将一个转换为另一个所需的最小更改集。

例如,如果产品列表区域在当前页面上包含 10 个产品,在新页面上包含 10 个不同的产品,差异算法可能会确定它只需要更新现有列表项元素内的文本内容和图像源——而不是从头销毁和重新创建所有 10 个项目。这保留了 DOM 状态(如区域内的滚动位置或焦点状态),并产生更平滑的视觉过渡。

协调算法依赖于启发式方法,当元素具有相同的类型和位置但代表不同的事物时,这些启发式方法可能会失败。你可以使用 data-wp-key 指令赋予元素稳定的身份,确保它们在导航过程中正确匹配。详见 使用 data-wp-key 保留元素。

场景 2:区域仅存在于带有 attachTo 的目标页面上(创建)

有时页面包含一个在当前页面上不存在的区域——例如,仅在特定页面上出现的模态对话框。如果此区域指定了 attachTo 属性,路由器将动态创建它。

attachTo 属性包含一个 CSS 选择器,用于标识当前页面中新区域应附加的位置。当路由器遇到此类区域时,它会:

  1. 在当前页面中找到匹配 attachTo 选择器的元素
  2. 为区域创建新的 DOM 元素
  3. 将这些元素附加到匹配的父元素
  4. 将区域的虚拟 DOM 渲染到新创建的元素中

这使得存在于一个页面但不存在于另一个页面的内容可以在导航期间平滑出现,而无需目标元素预先存在。

场景 3:区域仅存在于当前页面上(移除)

当区域存在于当前页面上但不存在于目标页面上时,意味着不再需要该内容。路由器通过将区域的内容设置为空来处理这种情况,有效地将其从显示中清除。

如果区域是在之前的导航期间通过 attachTo 动态创建的,则整个区域元素将从 DOM 中移除。如果它是原始页面结构的一部分,则元素保留但其内容被清除。

路由器区域之外的 HTML 会发生什么?

需要了解的一个重要细节是,在客户端导航期间,路由器区域之外的 HTML 完全不受影响。路由器只修改它所管理的区域内部的内容——DOM 中的所有内容保持原样。

这意味着如果你有静态元素(如站点标题、页脚或导航菜单)没有包裹在路由器区域中,当用户在页面之间导航时,它们不会改变。这可能是有意的(对于在所有页面上确实相同的元素),也可能成为困惑的来源,如果你期望这些元素更新的话。

但是,有一个重要的例外:**路由器区域之外的交互式元素仍然可以对全局状态更改做出反应**。如果你在没有任何路由器区域之外有交互式元素,并使用 getServerState() 读取全局状态的指令,这些指令将在导航带来新的服务器状态时自动重新评估。

例如,考虑标题中的购物车图标,它显示物品数量:

<!-- 此标题不在路由器区域内 -->
<header data-wp-interactive="myShop">
    <div class="cart-icon">
        <span data-wp-text="state.cartCount"></span> items
    </div>
</header>

<!-- 这是在导航期间更新的路由器区域 -->
<main data-wp-interactive="myShop" data-wp-router-region="myShop/content">
    <!-- 页面内容 -->
</main>

如果 state.cartCount 来自常规的客户端状态,购物车图标在导航过程中将不会更新——即使新页面在其服务器状态中具有不同的购物车计数。由于页眉是交互式的,但它位于任何路由器区域之外,因此它不会被重新渲染。

但是,如果您改用 getServerState():

const { state } = store( 'myShop', {
    state: {
        get cartCount() {
            // This reacts to server state changes during navigation.
            return getServerState().cartCount;
        },
    },
} );

现在,每当导航从服务器带来新的 cartCount 值时,购物车图标就会更新,尽管页眉本身位于任何路由器区域之外。这是因为 getServerState() 创建了对服务器提供状态的响应式订阅,该订阅会在每次导航期间更新。

这种模式对于需要在跨导航过程中与服务器数据保持同步的全局 UI 元素非常有用,而无需将它们放在路由器区域内。然而,getServerState() 也可用于同步路由器区域内交互式元素的 state,如处理服务器状态更新部分所述。

CSS 处理

客户端导航中较棘手的一个方面是管理 CSS 样式表。不同页面可能需要不同的样式,路由器必须确保每个页面都启用正确的样式——而不会导致未样式内容闪烁或破坏 CSS 层叠顺序。

CSS 层叠顺序的挑战

CSS 规则按特定顺序应用,当两条规则的优先级相同时,文档中后面出现的那条“胜出”。这意味着 HTML 中 <link> 和 <style> 元素的顺序很重要。如果路由器只是简单地将新样式表追加到文档末尾,可能会无意中改变哪些规则具有优先权,从而导致视觉错误。

考虑以下示例:页面 A 有样式表 base.css 和 theme.css,页面 B 有 base.css、components.css 和 theme.css。如果用户从 A 导航到 B,路由器需要将 components.css 插入到 base.css 和 theme.css 之间——而不是末尾。否则,theme.css 中旨在覆盖 components.css 的任何规则都将失效。

样式的提取与准备

当路由器获取页面时,它会提取所有与样式相关的元素:<link rel="stylesheet"> 标签和内联 <style> 块。每个样式元素通过其属性组合(对于 <link> 标签,主要是 href)或其内容哈希(对于内联 <style> 块)来标识。

然后,路由器将提取的样式与当前页面文档中已有的样式进行比较。样式分为三类:

  1. 已存在:样式表已在当前页面加载。在准备阶段无需操作。
  2. 新增:样式表在当前页面中不存在。需要添加。
  3. 不再需要:样式表在当前页面中但不在目标页面中。将在导航期间被禁用。

预加载新样式而不应用它们

对于新样式表,路由器面临两难境地:它需要确保在新页面内容显示之前样式已完全加载(以防止未样式内容闪烁),但它又不想立即应用这些样式(因为用户仍在查看当前页面)。

解决方案是添加新的 <link> 元素,并将其 media 属性设置为阻止其应用的值。路由器使用 media="preload",这告诉浏览器“此样式表不适用于任何媒体类型”——从而有效禁用它,但仍允许浏览器下载并解析它。

以这种方式添加 <link> 元素后,浏览器会立即下载 CSS 文件。路由器通过监听 load 事件来跟踪每个样式表的加载完成时间。这使得它能够等待所有新样式就绪后再进行导航。

使用最短公共超序列算法维护层叠顺序

在插入新样式表时,路由器必须保持正确的层叠顺序。它通过使用基于查找两个序列的最短公共超序列(SCS)的算法来实现这一点。

给定当前页面的样式表序列(序列 X)和目标页面的样式表序列(序列 Y),SCS 算法找到包含 X 和 Y 作为子序列且保持其内部顺序的最短序列。这告诉路由器确切地在哪里插入新元素以及保留哪些现有元素。

例如:

  • 当前页面样式 (X): [A, C, D]
  • 目标页面样式 (Y): [A, B, C, E]
  • 最短公共超序列: [A, B, C, D, E]

然后,算法确定:保持 A 和 C 原位不动,在 A 和 C 之间插入 B,在 C 之后保持 D,并在末尾插入 E。

这种方法确保:

  • 出现在两个页面中的样式表保持其正确的相对顺序
  • 新样式表插入到正确的位置以保持层叠正确性
  • 执行最少数量的 DOM 操作

在导航期间激活和停用样式

当 navigate() 实际渲染新页面时,路由器会切换样式表的开启和关闭:

  • 激活样式:对于属于目标页面的每个样式表,路由器恢复原始 media 属性(撤销在预取期间设置的 media="preload" 覆盖),并设置 sheet.disabled = false。这会导致浏览器应用这些样式。
  • 停用样式:对于当前页面中存在但目标页面中不存在的每个样式表,路由器设置 sheet.disabled = true。这会禁用样式而不从 DOM 中移除元素。

通过将停用的样式元素保留在 DOM 中(而不是移除它们),如果用户导航返回,路由器可以快速重新激活它们。样式已经加载并解析;它们只需要重新启用即可。

脚本模块处理

Interactivity API 使用 脚本模块 来实现交互行为。路由器必须确保在导航到新页面时,所需的脚本模块已加载并执行。

识别用于客户端导航的脚本模块

并非所有脚本模块都应在客户端导航期间加载。某些模块可能用于管理功能,或仅适用于初始页面加载的功能。如入门部分所述,WordPress 使用 data-wp-router-options 属性来标记哪些脚本模块应在导航期间加载:

<script
    type="module"
    src="/wp-content/plugins/my-plugin/view.js"
    data-wp-router-options='{"loadOnClientNavigation": true}'
></script>

当路由器获取页面时,它会扫描所有具有此属性且 loadOnClientNavigation 设置为 true 的 <script type="module"> 元素。这些就是它将预加载和执行的模块。

处理导入映射

现代 JavaScript 使用导入映射来解析裸模块说明符(如 @wordpress/interactivity)为实际 URL。WordPress 生成一个导入映射,告诉浏览器在哪里找到每个模块:

<script type="importmap">
    {
        "imports": {
            "@wordpress/interactivity": "/wp-includes/js/dist/interactivity.min.js",
            "@wordpress/interactivity-router": "/wp-includes/js/dist/interactivity-router.min.js"
        }
    }
</script>

当路由器获取新页面时,它会从该页面提取导入映射,并将任何新映射与当前页面的导入映射合并。这确保了脚本模块即使在在不同脚本集的页面之间导航时也能正确解析其依赖项。

预加载脚本模块及其依赖项

预加载脚本模块需要解析其完整的依赖树,因为单个入口点模块可能依赖于数十个其他脚本模块,而这些模块又可能依赖于数十个更多模块。

为了处理这种情况,路由器执行递归依赖解析:

  1. 它获取每个入口点脚本模块的源代码
  2. 它解析源代码以查找所有 import 语句
  3. 对于每个导入,它使用导入映射解析脚本模块说明符
  4. 它递归获取并解析每个依赖项
  5. 这一直持续到依赖树中的所有脚本模块都被获取为止

路由器巧妙地避免冗余工作。如果脚本模块已被初始页面加载(它出现在初始导入映射中),路由器不会再次获取它——浏览器已经有缓存了。

处理导入时机

一个重要的细微差别是,脚本模块代码不应在导航实际发生之前执行。路由器需要准备好脚本模块代码(以避免导航期间的延迟),但在用户仍查看当前页面时,它不应运行这些代码。

路由器通过转换获取的脚本模块来实现这一点。它会重写源代码以使用 blob URL(直接嵌入 URL 中的数据)并缓存这些转换后的脚本模块。当发生导航时,路由器使用动态 import() 来执行缓存的脚本模块。

由于浏览器的模块系统按 URL 缓存脚本模块,多次导入同一个 blob URL 会返回相同的模块实例。这确保了即使有多个代码路径尝试导入它,每个脚本模块也只会执行一次。

导航期间的脚本模块执行

当 navigate() 渲染新页面时,它会导入为该页面预加载的所有脚本模块:

// 简化概念视图,展示发生的情况。
for ( const moduleInfo of page.scriptModules ) {
    await import( moduleInfo.blobUrl );
}

每个脚本模块的顶层代码都会运行,通常包括调用 store() 来注册动作、回调和状态。由于 Interactivity API 的 store 是全局且可累加的,这些注册会与初始页面加载时的现有 store 定义合并。

服务器状态和上下文

交互式元素通常需要来自服务器的数据——配置值、数据库内容、用户偏好等。Interactivity API 为此提供了三种机制:全局状态、本地上下文和配置。

在客户端导航期间,需要从新页面中提取这些由服务器提供的数据,并将其提供给客户端代码使用。

服务器数据如何嵌入页面中

当 WordPress 渲染包含交互式元素的页面时,它会将服务器提供的数据嵌入特殊的 <script> 标签中:

<!-- Global state -->
<script
    type="application/json"
    id="wp-script-module-data-@wordpress/interactivity"
>
    {
        "state": {
            "myPlugin": {
                "cartItemCount": 3
            }
        },
        "config": {
            "myPlugin": {
                "userLoggedIn": true
            }
        }
    }
</script>

本地上下文直接嵌入到元素的 data-wp-context 属性中:

<div
    data-wp-interactive="myPlugin"
    data-wp-context='{ "productId": 42, "inStock": true }'
>
    <!-- Content -->
</div>

在 fetch 期间提取状态、上下文和配置

当路由器获取新页面时,它会提取这些类型的服务器数据:

  1. 全局状态:路由器查找 ID 为 wp-script-module-data-@wordpress/interactivity 的 <script type="application/json"> 元素,并解析其 JSON 内容以提取其 state 属性。此状态来自 wp_interactivity_state,存储在内部内存页面缓存条目中。
  2. 本地上下文:上下文值嵌入在每个路由器区域的虚拟 DOM 表示中。当区域的 HTML 转换为 vDOM 时,data-wp-context 属性会被保留,并在渲染期间进行处理。

  3. 配置:路由器查找 ID 为 wp-script-module-data-@wordpress/interactivity 的 <script type="application/json"> 元素,并解析其 JSON 内容以提取其 config 属性。此配置来自 wp_interactivity_config,存储在内部内存页面缓存条目中。

导航期间合并服务器数据

当导航渲染新页面时,根据用例,由服务器提供的数据可能需要与现有的客户端状态合并。这里的关键原则是客户端状态永远不会被服务器自动覆盖。此设计确保你的 JavaScript 代码对状态所做的任何更改(例如用户偏好、UI 切换或表单输入)在导航之间得以保留。

对于全局状态,合并方式如下:客户端上已存在的属性保持不变,仅从服务器数据中添加新的属性(即客户端尚不存在的属性)。如果你需要在导航期间让客户端状态反映服务器更改,请使用 getServerState() 订阅服务器提供的值并自行更新客户端状态。

初始页面的服务器状态:
  { "totalResults": 120, "isFiltersOpen": false }

导航前(用户打开了过滤器,修改了客户端状态):
  getServerState()  → { "totalResults": 120, "isFiltersOpen": false }
  state             → { "totalResults": 120, "isFiltersOpen": true }

新页面的服务器状态:
  { "totalResults": 85, "sortOrder": "date" }

导航后:
  getServerState()  → { "totalResults": 85, "sortOrder": "date" }
  state             → { "totalResults": 120, "isFiltersOpen": true, "sortOrder": "date" }

totalResults 在 state 中保持为 120,因为它已在客户端存在。isFiltersOpen 也被保留。sortOrder 被添加,因为它在客户端尚不存在。同时,getServerState() 始终准确反映服务器为新页面发送的内容。

对于本地上下文,行为遵循相同的原则。Interactivity API 分别跟踪服务器上下文和客户端上下文。在导航期间,服务器上下文会使用新页面中的值进行更新,但客户端上下文保持不变。使用 getServerContext() 读取服务器提供的值,使用 getContext() 读取客户端侧的值,根据你的用例选择合适的方式。

初始页面的服务器上下文:
  { "isAvailable": true, "isLiked": false }

导航前(用户已点赞该商品,修改了客户端上下文):
  getServerContext() → { "isAvailable": true, "isLiked": false }
  getContext()       → { "isAvailable": true, "isLiked": true }

新页面的服务器上下文:
  { "isAvailable": false, "discount": 15 }

导航后:
  getServerContext() → { "isAvailable": false, "discount": 15 }
  getContext()       → { "isAvailable": true, "isLiked": true, "discount": 15 }

isAvailable 在 getContext() 中保持为 true,因为它已在客户端存在。isLiked 也被保留。discount 被添加,因为它在客户端尚不存在。同时,getServerContext() 始终准确反映服务器为新页面发送的内容。

订阅服务器数据更改

Interactivity API 提供了两个用于访问在导航期间更新的服务器提供数据的函数:

  • getServerState():返回服务器为当前页面提供的全局状态
  • getServerContext():返回服务器为当前元素提供的本地上下文

这些函数是响应式的。当在回调或派生状态 getter 中使用它们时,它们会自动设置订阅。当发生导航且新的服务器数据到达时,使用这些函数的任何代码都将使用新值重新运行。

这与常规的 state 和 getContext() 不同,后者返回客户端状态和上下文。如上所述,现有的客户端值在导航期间不会被覆盖,因此 state 和 getContext() 将继续反映导航前客户端所持有的内容。当你需要响应服务器为新页面发送的值时,请使用 getServerState() 和 getServerContext()。

更多详细信息,请参阅理解全局状态、本地上下文和派生状态指南。

将所有内容整合在一起:导航流程

既然我们已经检查了每个组件,让我们追踪一次完整的导航,看看它们如何协同工作。

阶段 1:预取(可选但推荐)

当调用 prefetch() 时(例如,在链接悬停时):

  1. 路由器规范化 URL 并检查页面缓存。
  2. 如果未缓存,则开始获取 HTML。
  3. 获取的 HTML 被解析为文档。
  4. 提取路由器区域并转换为虚拟 DOM。
  5. 样式表与之前加载的进行比较;新增的样式表带有 media="preload"。
  6. 识别脚本模块并与之前加载的进行比较;新增模块会解析其依赖项并获取源代码。
  7. 提取服务器状态。
  8. 完全处理的页面存储在缓存中。
  9. 函数返回(页面现已准备好立即导航)。

阶段 2:导航

当调用 navigate() 时(例如,在链接点击时):

  1. 路由器检查是否禁用了客户端导航;如果已禁用,则回退到完整页面加载,且不触碰 state.navigating 或 state.initiator。
  2. 如果尚未预取,则从第一阶段开始获取过程。
  3. 在路由器等待页面之前,state.navigating 和 state.initiator 会在单个批次中一起设置。
  4. 路由器等待页面准备就绪(获取完成、样式加载完毕)。
  5. 如果等待时间超过阈值(400ms),可能会显示加载指示器,并为屏幕阅读器提供“loading”提示。该阈值仅适用于此内置反馈,不适用于 state.navigating。
  6. 渲染阶段开始:
    • 根据需要激活/停用样式。
    • 执行新页面的脚本模块。
    • 为了提高效率,在一个批次中:
      • 服务器状态与客户端状态合并。
      • 每个路由器区域使用其新的虚拟 DOM 进行更新。
      • 创建并追加不存在但带有 attachTo 的区域。
    • 更新文档标题。
  7. 更新浏览器历史记录(pushState 或 replaceState)。
  8. 为无障碍访问发出屏幕阅读器提示。
  9. 如果 URL 包含哈希值,页面将滚动到该元素。
  10. 导航完成。
  11. 在稍后的帧上,当步骤 6 中的提交已落地到 DOM 后,state.navigating 被重置为 false。此时 navigate() 自身的承诺早已解决。state.initiator 不会被清除;它保留其值,直到下一次导航将其替换。

步骤 3 和 11 是生命周期属性发生变化的两个时刻。响应导航生命周期 介绍了你的代码可以依赖它们的哪些方面,包括为什么结束状态是在稍后的帧上设置的,而不是在步骤 6 内部设置。

竞态条件保护

一个微妙但重要的细节:用户并不总是等到导航完成后再点击另一个链接。路由器会优雅地处理这种情况。

当调用 navigate() 时,路由器会记住目标 URL。如果在第一次导航完成之前又有另一个 navigate() 调用进来,路由器会更新其目标,并放弃第一次导航。当第一次导航的获取完成后,它会检查其 URL 是否仍然是当前目标——如果不是,则直接返回而不进行渲染。

这确保了快速连续点击多个链接不会导致视觉故障或渲染陈旧内容。只有最新的导航才会完成。

state.navigating 和 state.initiator 遵循相同的“最新获胜”规则,但导航结束时进行的更新不受上述目标 URL 比较的约束:它具有自己的最新获胜检查。这就是为什么被放弃的导航不会在获胜者仍在进行中时将 state.navigating 设置为 false,并且它适用于一种导航取代另一种导航的所有方式,包括在导航中途按下后退键。重叠的导航 描述了你的代码可以依赖的内容。

全页客户端侧导航(实验性功能)

全页客户端侧导航是一项实验性功能,扩展了本指南中描述的基于区域的方法。全页导航不需要你定义单独的路由器区域,而是将整个 <body> 元素视为单个区域——从而在导航期间有效地替换所有页面内容。

此功能仅在 Gutenberg 插件中可用,并且必须手动启用。要激活它,请转到 WP 管理 > 设置 > Gutenberg 实验 并勾选 “Interactivity API: Full-page client-side navigation” 选项。

启用后,此模式会自动拦截页面上的所有链接点击和悬停事件,触发客户端侧导航和预取,而无需你编写任何自定义操作处理器。它通过路由器包中的单独入口点提供:

import '@wordpress/interactivity-router/full-page';

全页客户端侧导航本质上是一种特殊的基于区域的导航,其中只有一个覆盖整个页面的区域。因为它会替换所有内容,所以页面上的每个交互元素都必须使用 Interactivity API(而不是 jQuery 或其他库),以便客户端侧导航正常工作。

全页模式还会改变 导航生命周期属性 报告的内容。由其文档级监听器处理的链接会将 state.initiator 设置为 null,无论链接位于何处。而且由于 <body> 元素本身带有 data-wp-router-region="core/body",因此来自每个块的路由器区域之外调用 navigate() 的具有你自己指令的元素会将 core/body 作为其发起者,而在基于区域的模式中,相同的标记会导出 null。

此功能处于实验阶段,仍在积极开发中。它可能无法在所有场景中正常工作。如果你尝试使用它,请在 Gutenberg GitHub 仓库 中报告你遇到的任何问题。我们也欢迎贡献!