title: "使用 TypeScript" post_status: publish comment_status: open taxonomy: category: - gutenberg-docs post_tag: - Core Concepts - Interactivity Api - Reference Guides
使用 TypeScript
Interactivity API 为 TypeScript 提供了强大的支持,使开发者能够构建类型安全的存储,通过静态类型检查、改进的代码补全和简化的重构来提升开发体验。本指南将引导您了解在 Interactivity API 存储中使用 TypeScript 的过程,涵盖从基本类型定义到处理复杂存储结构的高级技术。
以下是 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. 从客户端存储定义推断类型
当你使用 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. Manually type the server state, but infer the rest from your client store definition
The global state that is initialized on the server with the wp_interactivity_state function doesn't exist on your client store definition and, therefore, needs to be manually typed. But if you don't want to define all the types of your store, you can infer the types of your client store definition and merge them with the types of your server initialized state.
Please, visit the Server-side Rendering guide to learn more about wp_interactivity_state and how directives are processed on the server.
Following our previous example, let's move our counter state initialization to the server.
wp_interactivity_state( 'myCounterPlugin', array(
'counter' => 1,
));
Now, let's define the server state types and merge it with the types inferred from the client store definition.
// Types the server state.
type ServerState = {
state: {
counter: number;
};
};
// Defines the store in a variable to be able to extract its type later.
const storeDef = {
actions: {
increment() {
state.counter += 1;
},
},
callbacks: {
log() {
console.log( `counter: ${ state.counter }` );
},
},
};
// Merges the types of the server state and the client store definition.
type Store = ServerState & typeof storeDef;
// Injects the final types when calling the `store` function.
const { state } = store< Store >( 'myCounterPlugin', storeDef );
Alternatively, if you don't mind typing the entire state including both the values defined on the server and the values defined on the client, you can cast the state property and let TypeScript infer the rest of the store.
Let's imagine you have an additional property in the client global state called product.
type State = {
counter: number; // The server state.
product: number; // The client state.
};
const { state } = store( 'myCounterPlugin', {
state: {
product: 2,
} as State, // Casts the entire state manually.
actions: {
increment() {
state.counter * state.product;
},
},
} );
That's it. Now, TypeScript will infer the types of the actions and callbacks properties from the store definition, but it will use the type State for the state property so it contains the correct types from both the client and server definitions.
In conclusion, this approach is useful when you have a server state that needs to be manually typed, but you still want to infer the types of the rest of the store.
3. Manually write all the types
If you prefer to define all the types of the store manually instead of letting TypeScript infer them from your client store definition, you can do that too. You simply need to pass them to the store function.
// Defines the store types.
interface Store {
state: {
counter: number; // Initial server state
};
actions: {
increment(): void;
};
callbacks: {
log(): void;
};
}
// Pass the types when calling the `store` function.
const { state } = store< Store >( 'myCounterPlugin', {
actions: {
increment() {
state.counter += 1;
},
},
callbacks: {
log() {
console.log( `counter: ${ state.counter }` );
},
},
} );
That's it! In conclusion, this approach is useful when you want to control all the types of your store and you don't mind writing them by hand.
Typing the local context
The initial local context is defined on the server using the data-wp-context directive.
<div data-wp-context='{ "counter": 0 }'>...</div>
For that reason, you need to define its type manually and pass it to the getContext function to ensure the returned properties are correctly typed.
// Defines the types of your context.
type MyContext = {
counter: number;
};
store( 'myCounterPlugin', {
actions: {
increment() {
// Passes it to the getContext function.
const context = getContext< MyContext >();
// Now `context` is properly typed.
context.counter += 1;
},
},
} );
To avoid having to pass the context types over and over, you can also define a typed function and use that function instead of getContext.
// Defines the types of your context.
type MyContext = {
counter: number;
};
// Defines a typed function. You only have to do this once.
const getMyContext = getContext< MyContext >;
store( 'myCounterPlugin', {
actions: {
increment() {
// Use your typed function.
const context = getMyContext();
// Now `context` is properly typed.
context.counter += 1;
},
},
} );
That's it! Now you can access the context properties with the correct types.
Typing the derived state
The derived state is data that is calculated based on the global state or local context. In the client store definition, it is defined using a getter in the state object.
Please, visit the Understanding global state, local context, derived state and config guide to learn more about how derived state works in the Interactivity API.
Following our previous example, let's create a derived state that is the double of our counter.
type MyContext = {
counter: number;
};
const myStore = store( 'myCounterPlugin', {
state: {
get double() {
const { counter } = getContext< MyContext >();
return counter * 2;
},
},
actions: {
increment() {
state.counter += 1; // This type is number.
},
},
} );
Normally, when the derived state depends on the local context, TypeScript will be able to infer the correct types:
const myStore: {
state: {
readonly double: number;
};
actions: {
increment(): void;
};
};
But when the return value of the derived state depends directly on some part of the global state, TypeScript will not be able to infer the types because it will claim that it has a circular reference.
For example, in this case, TypeScript cannot infer the type of state.double because it depends on state.counter, and the type of state is not completed until the type of state.double is defined, creating a circular reference.
const { state } = store( 'myCounterPlugin', {
state: {
counter: 0,
get double() {
// TypeScript can't infer this return type because it depends on `state`.
return state.counter * 2;
},
},
actions: {
increment() {
state.counter += 1; // This type is now unknown.
},
},
} );
In this case, depending on your TypeScript configuration, TypeScript will either warn you about a circular reference or simply add the any type to the state property.
However, solving this problem is easy; we simply need to manually provide TypeScript with the return type of that getter. Once we do that, the circular reference disappears, and TypeScript can once again infer all the state types.
const { state } = store( 'myCounterPlugin', {
state: {
counter: 1,
get double(): number {
return state.counter * 2;
},
},
actions: {
increment() {
state.counter += 1; // Correctly inferred!
},
},
} );
These are now the correct inferred types for the previous store.
const myStore: {
state: {
counter: number;
readonly double: number;
};
actions: {
increment(): void;
};
};
When using wp_interactivity_state in the server, remember that you also need to define the initial value of your derived state, like this:
wp_interactivity_state( 'myCounterPlugin', array(
'counter' => 1,
'double' => 2,
));
但如果你正在推断类型,就不需要手动定义派生状态的类型,因为它已经存在于你的客户端存储定义中。
// 这里你不需要为 `state.double` 指定类型。
type ServerState = {
state: {
counter: number;
};
};
// `state.double` 的类型是从这里推断出来的。
const storeDef = {
state: {
get double(): number {
return state.counter * 2;
},
},
actions: {
increment() {
state.counter += 1;
},
},
};
// 合并服务器状态和客户端存储定义的类型。
type Store = ServerState & typeof storeDef;
// 调用 `store` 函数时注入最终类型。
const { state } = store< Store >( 'myCounterPlugin', storeDef );
就是这样!现在你可以用正确的类型访问派生状态属性了。
Typing asynchronous actions
Another thing to keep in mind when using TypeScript with the Interactivity API is that asynchronous actions must be defined with generators instead of async functions.
The reason for using generators in the Interactivity API's asynchronous actions is to be able to restore the scope from the initially triggered action once the asynchronous action continues its execution after yielding. But this is a syntax change only, otherwise, these functions operate just like regular async functions, and the inferred types from the store function reflect this.
Following our previous example, let's add an asynchronous action to the 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;
},
},
} );
The inferred types for this store are:
const myStore: {
state: {
counter: number;
readonly double: number;
};
actions: {
increment(): void;
// This behaves like a regular async function.
delayedIncrement(): Promise< void >;
};
};
This also means that you can use your async actions in external functions, and TypeScript will correctly use the async function types.
const someAsyncFunction = async () => {
// This works fine and it's correctly typed.
await actions.delayedIncrement( 2000 );
};
When you are not inferring types but manually writing the types for your entire store, you can use async function types for your async actions.
type Store = {
state: {
counter: number;
readonly double: number;
};
actions: {
increment(): void;
delayedIncrement(): Promise< void >; // You can use async functions here.
};
};
There's something to keep in mind when using asynchronous actions. Just like with the derived state, if an asynchronous action uses state within a yield expression (for example, by passing state to an async function that is then yielded) or if its return value depends on state, TypeScript might not be able to infer the types correctly due to a potential circular reference.
const { state, actions } = store( 'myCounterPlugin', {
state: {
counter: 0,
},
actions: {
*delayedOperation() {
// Example: state.counter is used as part of the yielded logic.
yield fetchCounterData( state.counter );
// And/or the final return value depends on state.
return state.counter + 1;
},
},
} );
In such cases, TypeScript might issue a warning about a circular reference or default to any. To solve this, you need to manually type the generator function. The Interactivity API provides a helper type, AsyncAction<ReturnType>, for this purpose.
import { store, type AsyncAction } from '@wordpress/interactivity';
const { state, actions } = store( 'myCounterPlugin', {
state: {
counter: 0,
},
actions: {
*delayedOperation(): AsyncAction< number > {
// Now, this doesn't cause a circular reference.
yield fetchCounterData( state.counter );
// Now, this is correctly typed.
return state.counter + 1;
},
},
} );
That's it! The AsyncAction<ReturnType> helper is defined as Generator<any, ReturnType, unknown>. By using any for the type of values yielded by the generator, it helps break the circular reference, allowing TypeScript to correctly infer the types when state is involved in yield expressions or in the final return value. You only need to specify the final ReturnType of your asynchronous action.
Typing yielded values in asynchronous actions
While AsyncAction<ReturnType> types the overall generator and its final return value, the value resolved by an individual yield expression within that generator might still be typed as any.
If you need to ensure the correct type for a value that a yield expression resolves to (e.g., the result of a fetch call or another async operation), you can use the TypeYield<T> helper. This helper takes the type of the asynchronous function/operation being yielded (T) and resolves to the type of the value that the promise fulfills with.
Suppose fetchCounterData returns a promise that resolves to an object:
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;
},
},
} );
In this example, ( yield fetchCounterData( state.counter ) ) as TypeYield< typeof fetchCounterData > ensures that the data constant is correctly typed as { current: number, next: number }, matching the return type of fetchCounterData. This allows you to confidently access properties like data.current and data.next with type safety.
为分拆成多部分的存储区添加类型定义
有时,存储区会被拆分到不同的文件中。当不同区块共享相同命名空间,且每个区块仅加载所需的部分存储时,就可能出现这种情况。
让我们看一个包含两个区块的示例:
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 );
},
},
};
// 将推断出的类型与服务器状态类型合并。
export type TodoList = ServerState & typeof todoList;
// 调用 `store` 函数时注入最终类型。
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 );
}
},
},
};
// 将推断出的类型与服务器状态类型合并。
type Store = ServerState & typeof addPostToTodo;
// 调用 `store` 函数时注入最终类型。
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';
// ...
// 将推断出的类型与服务器状态类型合并。
type Store = TodoList & ServerState & typeof addPostToTodo;
That's it! Now TypeScript will know that state.todos and actions.addTodo are available in the add-post-to-todo block.
This approach allows the add-post-to-todo block to interact with the existing todo list while maintaining type safety and adding its own functionality to the shared store.
If you need to use the add-post-to-todo types in the todo-list block, you simply have to export its types and import them in the other view.ts file.
Finally, if you prefer to define all types manually instead of inferring them, you can define them in a separate file and import that definition into each of your store parts. Here's how you could do that for our todo list example:
// 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
} );
This approach allows you to have full control over your types and ensures consistency across all parts of your store. It's particularly useful when you have a complex store structure or when you want to enforce a specific interface across multiple blocks or components.
导入和导出类型化存储
在交互性 API 中,可以使用 store 函数访问来自其他命名空间的存储。
让我们回到 todo-list 区块示例,但这次假设 add-post-to-todo 区块属于不同的插件,因此将使用不同的命名空间。
// 导入 `todo-list` 区块的存储。
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 函数的结果(包含正确的类型),并通过脚本模块使其可用。
// 导出已类型化的 state 和 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 脚本模块声明为依赖项。
如果另一个存储是可选的,并且你不想急切地加载它,可以使用动态导入代替静态导入。
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 存储定义类型的多种方法,从自动推断类型到手动定义。我们还介绍了如何处理服务器初始化的状态、本地上下文和派生状态,以及如何为异步操作定义类型。
请记住,选择推断类型还是手动定义取决于您的具体需求和存储的复杂程度。无论选择哪种方法,TypeScript 都将帮助您构建更好、更可靠的交互式模块。