Interactivity API 提供了对 TypeScript 的强大支持,使开发人员能够构建类型安全的存储(stores),从而通过静态类型检查、改进的代码补全和简化的重构来增强开发体验。本指南将引导您了解如何在 Interactivity API 存储中使用 TypeScript,涵盖从基本类型定义到处理复杂存储结构的 advanced 技术。

以下是 TypeScript 与 Interactivity API 交互的核心原则:

  • 推断的客户端类型:当您使用 store 函数创建存储时,TypeScript 会自动推断存储属性的类型(state、actions 等)。这意味着通常只需编写纯 JavaScript 对象,TypeScript 就会为您确定类型。
  • 显式的服务器端类型:在处理服务器上定义的数据时,例如本地上下文或全局状态的初始值,您可以显式定义其类型以确保一切都具有正确的类型。
  • 多个存储部分:即使您的存储被拆分为多个部分,您也可以为存储的每个部分定义或推断类型,然后将它们合并为一个代表整个存储的类型。
  • 类型化的外部存储:您可以从外部命名空间导入类型化的存储,从而以类型安全的方式使用其他插件的功能。

本地安装 @wordpress/interactivity

如果您尚未完成此操作,则需要本地安装 @wordpress/interactivity 包,以便 TypeScript 能在您的 IDE 中使用其类型。您可以使用以下命令执行此操作:

npm install @wordpress/interactivity

保持该包的更新也是一个良好的实践。

搭建新的类型化交互式区块

如果您想在本地环境中探索使用 TypeScript 的交互式区块示例,可以使用 @wordpress/create-block-interactive-template。

首先确保您的计算机上已安装 Node.js 和 npm。如果未安装,请查看 Node.js 开发环境 指南。

接下来,使用 @wordpress/create-block 包和 @wordpress/create-block-interactive-template 模板来搭建区块。

选择您要创建插件的文件夹,在该文件夹内的终端中执行以下命令,并在被询问时选择 typescript 变体。

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

重要提示:不要在终端中提供 slug。否则,create-block 不会询问您想选择哪种变体,并将默认选择非 TypeScript 的默认变体。

最后,您可以继续遵循 入门指南 中的说明,因为其余说明保持不变。

类型化存储

根据您的存储结构和个人偏好,有三种选项可供选择以生成存储的类型:

  1. 从您的客户端存储定义中推断类型。
  2. 手动键入服务器状态,但其余部分从您的客户端存储定义中推断。
  3. 手动编写所有类型。

1. 从您的客户端存储定义中推断类型

当您使用 store 函数创建存储时,TypeScript 会自动推断存储属性的类型(state、actions、callbacks 等)。这意味着通常只需编写纯 JavaScript 对象,TypeScript 就会为您确定正确的类型。

让我们从一个基本的计数器区块示例开始。我们将在区块的 view.ts 文件中定义存储,其中包含初始全局状态、一个动作和一个回调。

// view.ts
const myStore = store( 'myCounterPlugin', {
    state: {
        counter: 0,
    },
    actions: {
        increment() {
            myStore.state.counter += 1;
        },
    },
    callbacks: {
        log() {
            console.log( `counter: ${ myStore.state.counter }` );
        },
    },
} );

如果您使用 TypeScript 检查 myStore 的类型,您将看到 TypeScript 已经能够正确推断出类型。

const myStore: {
    state: {
        counter: number;
    };
    actions: {
        increment(): void;
    };
    callbacks: {
        log(): void;
    };
};

您还可以解构 state、actions 和 callbacks 属性,类型仍然能正常工作。

const { state } = store( 'myCounterPlugin', {
    state: {
        counter: 0,
    },
    actions: {
        increment() {
            state.counter += 1;
        },
    },
    callbacks: {
        log() {
            console.log( `counter: ${ state.counter }` );
        },
    },
} );

总之,当您在单个 store 函数调用中定义了简单的存储,并且不需要键入任何在服务器上初始化的状态时,推断类型非常有用。

2. 手动键入服务器状态,但其余部分从客户端存储定义中推断

使用 wp_interactivity_state 函数在服务器上初始化的全局状态不存在于您的客户端存储定义中,因此需要手动键入类型。但是,如果您不想定义存储的所有类型,您可以推断客户端存储定义的类型,并将其与您服务器初始化状态的类型合并。

请访问 服务器端渲染指南,了解更多关于 wp_interactivity_state 以及指令在服务器上如何处理的信息。

沿用我们之前的示例,让我们将 counter 状态初始化移至服务器。

wp_interactivity_state( 'myCounterPlugin', array(
    'counter' => 1,
));

现在,让我们定义服务器状态的类型,并将其与从客户端存储定义中推断出的类型合并。

// 类型化服务器状态。
type ServerState = {
    state: {
        counter: number;
    };
};

// 在变量中定义存储,以便稍后提取其类型。
const storeDef = {
    actions: {
        increment() {
            state.counter += 1;
        },
    },
    callbacks: {
        log() {
            console.log( `counter: ${ state.counter }` );
        },
    },
};

// 合并服务器状态类型和客户端存储定义类型。
type Store = ServerState & typeof storeDef;

// 在调用 `store` 函数时注入最终类型。
const { state } = store< Store >( 'myCounterPlugin', storeDef );

或者,如果您不介意键入整个状态(包括在服务器上定义的值和在客户端定义的值),您可以转换 state 属性,让 TypeScript 推断存储的其余部分。

假设您在客户端全局状态中还有一个名为 product 的属性。

type State = {
    counter: number; // 服务器状态。
    product: number; // 客户端状态。
};

const { state } = store( 'myCounterPlugin', {
    state: {
        product: 2,
    } as State, // 手动转换整个状态。
    actions: {
        multiply() {
            state.counter *= state.product;
        },
    },
} );

就是这样。现在,TypeScript 将从存储定义中推断 actions 和 callbacks 属性的类型,但对于 state 属性将使用 State 类型,因此它包含来自客户端和服务端定义的正确类型。

总之,这种方法在您有需要手动键入类型的服务器状态,但仍希望推断存储其余部分的类型时非常有用。

3. 手动编写所有类型

如果您更喜欢手动定义存储的所有类型,而不是让 TypeScript 从您的客户端存储定义中推断它们,您也可以这样做。您只需将它们传递给 store 函数即可。

// 定义存储类型。
interface Store {
    state: {
        counter: number; // 初始服务器状态
    };
    actions: {
        increment(): void;
    };
    callbacks: {
        log(): void;
    };
}

// 在调用 `store` 函数时传递类型。
const { state } = store< Store >( 'myCounterPlugin', {
    actions: {
        increment() {
            state.counter += 1;
        },
    },
    callbacks: {
        log() {
            console.log( `counter: ${ state.counter }` );
        },
    },
} );

就是这样!总之,这种方法在您想要控制存储的所有类型且不介意手动编写它们时非常有用。

类型化本地上下文

初始的本地上下文是使用 data-wp-context 指令在服务器端定义的。

<div data-wp-context='{ "counter": 0 }'>...</div>

因此,你需要手动定义其类型,并将其传递给 getContext 函数,以确保返回的属性具有正确的类型。

// 定义上下文的类型。
type MyContext = {
    counter: number;
};

store( 'myCounterPlugin', {
    actions: {
        increment() {
            // 将其传递给 getContext 函数。
            const context = getContext< MyContext >();
            // 现在 `context` 已正确输入。
            context.counter += 1;
        },
    },
} );

为了避免反复传递上下文类型,你还可以定义一个带类型的函数,并使用该函数替代 getContext。

// 定义上下文的类型。
type MyContext = {
    counter: number;
};

// 定义一个带类型的函数。只需执行一次。
const getMyContext = getContext< MyContext >;

store( 'myCounterPlugin', {
    actions: {
        increment() {
            // 使用你的带类型函数。
            const context = getMyContext();
            // 现在 `context` 已正确输入。
            context.counter += 1;
        },
    },
} );

就是这样!现在你可以使用正确的类型访问上下文属性了。

推导状态的类型定义

推导状态是基于全局状态或本地上下文计算得出的数据。在客户端 store 定义中,它是在 state 对象中使用 getter 定义的。

请访问理解全局状态、本地上下文、推导状态和配置指南,以了解 Interactivity API 中推导状态的工作原理。

延续我们之前的示例,让我们创建一个等于计数器两倍的推导状态。

type MyContext = {
    counter: number;
};

const myStore = store( 'myCounterPlugin', {
    state: {
        get double() {
            const { counter } = getContext< MyContext >();
            return counter * 2;
        },
    },
    actions: {
        increment() {
            state.counter += 1; // 此类型为 number。
        },
    },
} );

通常情况下,当推导状态依赖于本地上下文时,TypeScript 能够推断出正确的类型:

const myStore: {
    state: {
        readonly double: number;
    };
    actions: {
        increment(): void;
    };
};

但是,当推导状态的返回值直接依赖于全局状态的某一部分时,TypeScript 将无法推断类型,因为它会声称存在循环引用。

例如,在这种情况下,TypeScript 无法推断 state.double 的类型,因为它依赖于 state.counter,而 state 的类型在 state.double 的类型定义完成之前尚未完成,从而形成了循环引用。

const { state } = store( 'myCounterPlugin', {
    state: {
        counter: 0,
        get double() {
            // TypeScript 无法推断此返回类型,因为它依赖于 `state`。
            return state.counter * 2;
        },
    },
    actions: {
        increment() {
            state.counter += 1; // 此时此类型为 unknown。
        },
    },
} );

在这种情况下,根据你的 TypeScript 配置,TypeScript 要么会警告你存在循环引用,要么只会将 any 类型添加到 state 属性中。

然而,解决这个问题很简单;我们只需要手动为 TypeScript 提供该 getter 的返回类型。一旦这样做,循环引用就会消失,TypeScript 可以再次推断所有 state 类型。

const { state } = store( 'myCounterPlugin', {
    state: {
        counter: 1,
        get double(): number {
            return state.counter * 2;
        },
    },
    actions: {
        increment() {
            state.counter += 1; // 正确推断!
        },
    },
} );

这是前面 store 的正确推断类型。

const myStore: {
    state: {
        counter: number;
        readonly double: number;
    };
    actions: {
        increment(): void;
    };
};

在服务器端使用 wp_interactivity_state 时,请记住你还需要定义推导状态的初始值,如下所示:

wp_interactivity_state( 'myCounterPlugin', array(
    'counter' => 1,
    'double'  => 2,
));

但是,如果你正在推断类型,则无需手动定义推导状态的类型,因为它已经存在于你的客户端 store 定义中。

// 你不需要在此处对 `state.double` 进行类型定义。
type ServerState = {
    state: {
        counter: number;
    };
};

// `state.double` 的类型从此处推断。
const storeDef = {
    state: {
        get double(): number {
            return state.counter * 2;
        },
    },
    actions: {
        increment() {
            state.counter += 1;
        },
    },
};

// 合并服务器状态和客户端 store 定义的类型。
type Store = ServerState & typeof storeDef;

// 在调用 `store` 函数时注入最终类型。
const { state } = store< Store >( 'myCounterPlugin', storeDef );

就是这样!现在你可以使用正确的类型访问推导状态属性了。

异步动作的类型定义

在使用 TypeScript 与 Interactivity API 时,另一件需要注意的事情是,异步动作必须使用生成器(generators)而非 async 函数来定义。

Interactivity API 的异步动作使用生成器的原因是,以便在异步动作在 yield 之后继续执行时,能够从最初触发的动作恢复作用域。但这仅仅是语法上的变化,否则这些函数的行为与普通 async 函数完全相同,且从 store 函数推断出的类型反映了这一点。

延续我们之前的示例,让我们向 store 添加一个异步动作。

const { state } = store( 'myCounterPlugin', {
    state: {
        counter: 0,
        get double(): number {
            return state.counter * 2;
        },
    },
    actions: {
        increment() {
            state.counter += 1;
        },
        *delayedIncrement() {
            yield new Promise( ( r ) => setTimeout( r, 1000 ) );
            state.counter += 1;
        },
    },
} );

此 store 的推断类型为:

const myStore: {
    state: {
        counter: number;
        readonly double: number;
    };
    actions: {
        increment(): void;
        // 这表现得像一个普通的 async 函数。
        delayedIncrement(): Promise< void >;
    };
};

这也意味着你可以在外部函数中使用异步动作,TypeScript 将正确使用 async 函数类型。

const someAsyncFunction = async () => {
    // 这样可以正常工作,并且类型正确。
    await actions.delayedIncrement();
};

当你不推断类型而是手动为你整个 store 编写类型时,你可以为你的异步动作使用 async 函数类型。

type Store = {
    state: {
        counter: number;
        readonly double: number;
    };
    actions: {
        increment(): void;
        delayedIncrement(): Promise< void >; // 你可以在这里使用 async 函数。
    };
};

使用异步动作时有一些注意事项。就像推导状态一样,如果异步动作在 yield 表达式内使用了 state(例如,通过将 state 传递给随后被 yield 的 async 函数),或者其返回值依赖于 state,由于潜在的循环引用,TypeScript 可能无法正确推断类型。

const { state, actions } = store( 'myCounterPlugin', {
    state: {
        counter: 0,
    },
    actions: {
        *delayedOperation() {
            // 示例:state.counter 作为被 yield 逻辑的一部分被使用。
            yield fetchCounterData( state.counter );

            // 和/或最终返回值依赖于 state。
            return state.counter + 1;
        },
    },
} );

在这种情况下,TypeScript 可能会发出关于循环引用的警告,或者默认为 any。要解决此问题,你需要手动对生成器函数进行类型定义。Interactivity API 为此提供了一个辅助类型 AsyncAction<ReturnType>。

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

const { state, actions } = store( 'myCounterPlugin', {
    state: {
        counter: 0,
    },
    actions: {
        *delayedOperation(): AsyncAction< number > {
            // 现在,这不会导致循环引用。
            yield fetchCounterData( state.counter );

            // 现在,这是正确输入的。
            return state.counter + 1;
        },
    },
} );

就是这样!AsyncAction<ReturnType> 辅助类型被定义为 Generator<any, ReturnType, unknown>。通过使用 any 作为生成器产生的值的类型,它有助于打破循环引用,使 TypeScript 能够在 yield 表达式或最终返回值涉及 state 时正确推断类型。你只需要指定异步操作的最终 ReturnType。

为异步操作中的产生值添加类型

虽然 AsyncAction<ReturnType> 对整体生成器及其最终返回值进行了类型定义,但该生成器中单个 yield 表达式解析的值可能仍然被键入为 any。

如果你需要确保 yield 表达式解析到的值的正确类型(例如 fetch 调用的结果或其他异步操作的结果),你可以使用 TypeYield<T> 辅助类型。这个辅助类型接受正在产生的异步函数/操作的类型(T),并解析为该承诺 fulfilled 时的值的类型。

假设 fetchCounterData 返回一个解析为对象的 Promise:

import {
    store,
    type AsyncAction,
    type TypeYield,
} from '@wordpress/interactivity';

// Assume this function is defined elsewhere and fetches specific data.
const fetchCounterData = async (
    counterValue: number
): Promise< { current: number; next: number } > => {
    // internal logic...
};

const { state, actions } = store( 'myCounterPlugin', {
    state: {
        counter: 0,
    },
    actions: {
        *loadCounterData(): AsyncAction< void > {
            // Use TypeYield to correctly type the resolved value of the yield.
            const data = ( yield fetchCounterData(
                state.counter
            ) ) as TypeYield< typeof fetchCounterData >;

            // Now, `data` is correctly typed as { current: number, next: number }.
            console.log( data.current, data.next );

            // Update state based on the fetched data.
            state.counter = data.next;
        },
    },
} );

在此示例中,( yield fetchCounterData( state.counter ) ) as TypeYield< typeof fetchCounterData > 确保 data 常量被正确键入为 { current: number, next: number },与 fetchCounterData 的返回类型匹配。这允许你以类型安全的方式自信地访问像 data.current 和 data.next 这样的属性。

为分为多部分的存储添加类型

有时,存储可以被划分到不同的文件中。当不同的区块共享相同的命名空间,且每个区块加载其所需的存储部分时,就会发生这种情况。

让我们看两个区块的例子:

  • todo-list:显示待办事项列表的区块。
  • add-post-to-todo:显示一个按钮,用于将新待办事项添加到列表中,文本为“阅读 {$post_title}”。

首先,让我们在服务器上初始化 todo-list 区块的全局状态和派生状态。

<?php
// todo-list-block/render.php
$todos = array( 'Buy milk', 'Walk the dog' );
wp_interactivity_state( 'myTodoPlugin', array(
  'todos'         => $todos,
  'filter'        => 'all',
  'filteredTodos' => $todos,
));
?>

<!-- HTML markup... -->

现在,让我们为服务器状态添加类型并添加客户端存储定义。请记住,filteredTodos 是派生状态,因此你不需要手动为其添加类型。

// todo-list-block/view.ts
type ServerState = {
    state: {
        todos: string[];
        filter: 'all' | 'completed';
    };
};

const todoList = {
    state: {
        get filteredTodos(): string[] {
            return state.filter === 'completed'
                ? state.todos.filter( ( todo ) => todo.includes( '✅' ) )
                : state.todos;
        },
    },
    actions: {
        addTodo( todo: string ) {
            state.todos.push( todo );
        },
    },
};

// Merges the inferred types with the server state types.
export type TodoList = ServerState & typeof todoList;

// Injects the final types when calling the `store` function.
const { state } = store< TodoList >( 'myTodoPlugin', todoList );

到目前为止,一切顺利。现在让我们创建我们的 add-post-to-todo 区块。

首先,让我们将当前文章标题添加到服务器状态中。

<?php
// add-post-to-todo-block/render.php
wp_interactivity_state( 'myTodoPlugin', array(
  'postTitle' => get_the_title(),
));
?>

<!-- HTML markup... -->

现在,让我们为该服务器状态添加类型并添加客户端存储定义。

// add-post-to-todo-block/view.ts
type ServerState = {
    state: {
        postTitle: string;
    };
};

const addPostToTodo = {
    actions: {
        addPostToTodo() {
            const todo = `Read: ${ state.postTitle }`.trim();
            if ( ! state.todos.includes( todo ) ) {
                actions.addTodo( todo );
            }
        },
    },
};

// Merges the inferred types with the server state types.
type Store = ServerState & typeof addPostToTodo;

// Injects the final types when calling the `store` function.
const { state, actions } = store< Store >( 'myTodoPlugin', addPostToTodo );

这在浏览器中可以正常工作,但 TypeScript 会抱怨,在此区块中,state 和 actions 不包含 state.todos 和 actions.addtodo。

要修复此问题,我们需要从 todo-list 区块导入 TodoList 类型,并将其与其他类型合并。

import type { TodoList } from '../todo-list-block/view';

// ...

// Merges the inferred types inferred the server state types.
type Store = TodoList & ServerState & typeof addPostToTodo;

就是这样!现在 TypeScript 将知道在 add-post-to-todo 区块中可以使用 state.todos 和 actions.addTodo。

这种方法允许 add-post-to-todo 区块与现有的待办事项列表交互,同时保持类型安全并向共享存储添加其自己的功能。

如果你需要在 todo-list 区块中使用 add-post-to-todo 的类型,只需导出其类型并在其他 view.ts 文件中导入即可。

最后,如果你更喜欢手动定义所有类型而不是推断它们,你可以在单独的文件中定义它们,并将该定义导入到你的每个存储部分中。以下是我们待办事项列表示例的操作方法:

// types.ts
interface Store {
    state: {
        todos: string[];
        filter: 'all' | 'completed';
        filtered: string[];
        postTitle: string;
    };
    actions: {
        addTodo( todo: string ): void;
        addPostToTodo(): void;
    };
}

export default Store;
// todo-list-block/view.ts
import type Store from '../types';

const { state } = store< Store >( 'myTodoPlugin', {
    // Everything is correctly typed here
} );
// add-post-to-todo-block/view.ts
import type Store from '../types';

const { state, actions } = store< Store >( 'myTodoPlugin', {
    // Everything is correctly typed here
} );

这种方法允许你对类型拥有完全的控制权,并确保你的存储各部分之间的一致性。当你具有复杂的存储结构,或者希望在多个区块或组件之间强制执行特定接口时,它特别有用。

导入和导出带类型的存储

在 Interactivity API 中,可以使用 store 函数访问来自其他命名空间的存储。

让我们回到我们的 todo-list 区块示例,但这次,让我们想象 add-post-to-todo 区块属于另一个插件,因此将使用不同的命名空间。

// Import the store of the `todo-list` block.
const myTodoPlugin = store( 'myTodoPlugin' );

store( 'myAddPostToTodoPlugin', {
    actions: {
        addPostToTodo() {
            const todo = `Read: ${ state.postTitle }`.trim();
            if ( ! myTodoPlugin.state.todos.includes( todo ) ) {
                myTodoPlugin.actions.addTodo( todo );
            }
        },
    },
} );

这在浏览器中可以正常工作,但 TypeScript 会抱怨 myTodoPlugin.state 和 myTodoPlugin.actions 没有类型。

为了解决这个问题,myTodoPlugin 插件可以导出调用带有正确类型的 store 函数的结果,并使用脚本模块使其可用。

// Export the already typed state and actions.
export const { state, actions } = store< TodoList >( 'myTodoPlugin', {
    // ...
} );

现在,add-post-to-todo 区块可以从 myTodoPlugin 脚本模块导入带类型的存储,这不仅确保加载了存储,还确保其中包含正确的类型。

import { store } from '@wordpress/interactivity';
import {
    state as todoState,
    actions as todoActions,
} from 'my-todo-plugin-module';

store( 'myAddPostToTodoPlugin', {
    actions: {
        addPostToTodo() {
            const todo = `Read: ${ state.postTitle }`.trim();
            if ( ! todoState.todos.includes( todo ) ) {
                todoActions.addTodo( todo );
            }
        },
    },
} );

请记住,您需要将 my-todo-plugin-module 脚本模块声明为依赖项。

如果另一个 store 是可选的,并且您不希望尽早加载它,可以使用动态导入代替静态导入。

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

store( 'myAddPostToTodoPlugin', {
    actions: {
        *addPostToTodo() {
            const todoPlugin = yield import( 'my-todo-plugin-module' );
            const todo = `Read: ${ state.postTitle }`.trim();
            if ( ! todoPlugin.state.todos.includes( todo ) ) {
                todoPlugin.actions.addTodo( todo );
            }
        },
    },
} );

结论

在本指南中,我们探讨了为 Interactivity API store 进行类型定义的不同方法,从自动推断类型到手动定义类型。我们还介绍了如何处理服务器初始化的状态、本地上下文和派生状态,以及如何为异步操作添加类型。

请记住,在推断类型和手动定义类型之间的选择取决于您的具体需求和 store 的复杂性。无论您选择哪种方法,TypeScript 都将帮助您构建更好、更可靠的交互式区块。