## 接下来做什么？

* **上一篇：** [构建页面列表](/docs/how-to-guides/data-basics/2-building-a-list-of-pages.md)
* **下一篇：** [构建创建页面表单](/docs/how-to-guides/data-basics/4-building-a-create-page-form.md)
* （可选）在 block-development-examples 代码库中查看[完整示例应用](https://github.com/WordPress/block-development-examples/tree/trunk/plugins/data-basics-59c8f8)

# 构建编辑表单

本节内容将为我们的应用添加*编辑*功能。以下是即将构建功能的预览图：

![](https://raw.githubusercontent.com/WordPress/gutenberg/HEAD/docs/how-to-guides/data-basics/media/edit-form/form-finished.png)

### 步骤一：添加“编辑”按钮

要实现*编辑*功能首先需要添加编辑按钮，让我们从在`PagesList`组件中添加按钮开始：

```js
import { Button } from '@wordpress/components';
import { decodeEntities } from '@wordpress/html-entities';

const PageEditButton = () => (
	<Button variant="primary">
		编辑
	</Button>
)

function PagesList( { hasResolved, pages } ) {
	if ( ! hasResolved ) {
		return <Spinner />;
	}
	if ( ! pages?.length ) {
		return <div>暂无结果</div>;
	}

	return (
		<table className="wp-list-table widefat fixed striped table-view-list">
			<thead>
				<tr>
					<td>标题</td>
					<td style={{width: 120}}>操作</td>
				</tr>
			</thead>
			<tbody>
				{ pages?.map( ( page ) => (
					<tr key={page.id}>
						<td>{ decodeEntities( page.title.rendered ) }</td>
						<td>
							<PageEditButton pageId={ page.id } />
						</td>
					</tr>
				) ) }
			</tbody>
		</table>
	);
}
```

`PagesList`组件中唯一的变化是新增了标为“操作”的列：

![](https://raw.githubusercontent.com/WordPress/gutenberg/HEAD/docs/how-to-guides/data-basics/media/edit-form/edit-button.png)

### 步骤二：显示编辑表单

我们的按钮外观不错但尚未实现功能。要显示编辑表单，首先需要创建它：

```js
import { Button, TextControl } from '@wordpress/components';
function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
	return (
		<div className="my-gutenberg-form">
			<TextControl
				__nextHasNoMarginBottom
				__next40pxDefaultSize
				value=''
				label='页面标题:'
			/>
			<div className="form-buttons">
				<Button onClick={ onSaveFinished } variant="primary">
					保存
				</Button>
				<Button onClick={ onCancel } variant="tertiary">
					取消
				</Button>
			</div>
		</div>
	);
}
```

现在让按钮触发显示刚创建的编辑表单。由于本教程不侧重网页设计，我们将使用需要最少代码量的[`Modal`](https://developer.wordpress.org/block-editor/reference-guides/components/modal/)组件将两者连接。更新`PageEditButton`如下：

```js
import { Button, Modal, TextControl } from '@wordpress/components';

function PageEditButton({ pageId }) {
	const [ isOpen, setOpen ] = useState( false );
	const openModal = () => setOpen( true );
	const closeModal = () => setOpen( false );
	return (
		<>
			<Button
				onClick={ openModal }
				variant="primary"
			>
				编辑
			</Button>
			{ isOpen && (
				<Modal onRequestClose={ closeModal } title="编辑页面">
					<EditPageForm
						pageId={pageId}
						onCancel={closeModal}
						onSaveFinished={closeModal}
					/>
				</Modal>
			) }
		</>
	)
}
```

现在点击*编辑*按钮，您将看到如下模态框：

![](https://raw.githubusercontent.com/WordPress/gutenberg/HEAD/docs/how-to-guides/data-basics/media/edit-form/form-scaffold.png)

很好！我们现在有了可操作的基础用户界面。

### 步骤三：在表单中填充页面详情

我们需要让`EditPageForm`显示当前编辑页面的标题。您可能注意到它并未接收`page`属性，仅接收`pageId`。这没有问题，Gutenberg Data让我们能够轻松在任何组件中访问实体记录。

这里我们需要使用[`getEntityRecord`](/docs/reference-guides/data/data-core.md#getentityrecord)选择器。得益于`MyFirstApp`中的`getEntityRecords`调用，记录列表已准备就绪，甚至无需发起额外的HTTP请求——我们可以直接获取缓存记录。

您可以在浏览器开发工具中这样尝试：

```js
wp.data.select( 'core' ).getEntityRecord( 'postType', 'page', 9 );  // 将9替换为实际页面ID
```

接下来更新`EditPageForm`：

```js
function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
	const page = useSelect(
		select => select( coreDataStore ).getEntityRecord( 'postType', 'page', pageId ),
		[pageId]
	);
	return (
		<div className="my-gutenberg-form">
			<TextControl
				__nextHasNoMarginBottom
				__next40pxDefaultSize
				label='页面标题:'
				value={ page.title.rendered }
			/>
			{ /* ... */ }
		</div>
	);
}
```

现在效果应如图所示：

![](https://raw.githubusercontent.com/WordPress/gutenberg/HEAD/docs/how-to-guides/data-basics/media/edit-form/form-populated.png)

### 步骤五：保存表单数据

既然我们已经能够编辑页面标题，接下来要确保能够保存它。在 Gutenberg 数据系统中，我们使用 `saveEditedEntityRecord` 操作将变更保存到 WordPress REST API。该操作会发送请求、处理结果，并更新 Redux 状态中的缓存数据。

以下示例可在浏览器开发者工具中尝试：

```js
// 将数字9替换为实际页面ID
wp.data.dispatch( 'core' ).editEntityRecord( 'postType', 'page', 9, { title: '更新后的标题' } );
wp.data.dispatch( 'core' ).saveEditedEntityRecord( 'postType', 'page', 9 );
```

以上代码片段保存了新标题。与之前不同，现在 `getEntityRecord` 会反映更新后的标题：

```js
// 将数字9替换为实际页面ID
wp.data.select( 'core' ).getEntityRecord( 'postType', 'page', 9 ).title.rendered
// 返回："更新后的标题"
```

在 REST API 请求完成后，实体记录会立即更新以反映所有已保存的变更。

这是带有生效*保存*按钮的 `EditPageForm` 组件示例：

```js
function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
	// ...
	const { saveEditedEntityRecord } = useDispatch( coreDataStore );
	const handleSave = () => saveEditedEntityRecord( 'postType', 'page', pageId );

	return (
		<div className="my-gutenberg-form">
			{/* ... */}
			<div className="form-buttons">
				<Button onClick={ handleSave } variant="primary">
					保存
				</Button>
				{/* ... */}
			</div>
		</div>
	);
}
```

虽然功能已实现，但还需修复一个问题：表单模态框不会自动关闭，因为我们从未调用 `onSaveFinished`。幸运的是，`saveEditedEntityRecord` 返回的 Promise 会在保存操作完成后解析。让我们在 `EditPageForm` 中利用这个特性：

```js
function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
	// ...
	const handleSave = async () => {
		await saveEditedEntityRecord( 'postType', 'page', pageId );
		onSaveFinished();
	};
	// ...
}
```

### 步骤六：错误处理

此前我们乐观地假设*保存*操作总能成功。但实际操作可能因以下原因失败：

* 网站可能宕机
* 更新内容可能无效
* 页面可能已被他人删除

为了在出现这些问题时通知用户，我们需要进行两处调整。当更新失败时，我们不希望关闭表单模态框。仅当更新确实成功时，`saveEditedEntityRecord` 返回的 Promise 才会解析为更新后的记录。若出现异常，则会解析为空值。我们可以利用这一点来保持模态框开启状态：

```js
function EditPageForm( { pageId, onSaveFinished } ) {
	// ...
	const handleSave = async () => {
		const updatedRecord = await saveEditedEntityRecord( 'postType', 'page', pageId );
		if ( updatedRecord ) {
			onSaveFinished();
		}
	};
	// ...
}
```

很好！现在让我们来显示错误信息。可以通过 `getLastEntitySaveError` 选择器获取失败详情：

```js
// 将数字9替换为实际页面ID
wp.data.select( 'core' ).getLastEntitySaveError( 'postType', 'page', 9 )
```

以下是在 `EditPageForm` 中的具体应用：

```js
function EditPageForm( { pageId, onSaveFinished } ) {
	// ...
    const { lastError, page } = useSelect(
        select => ({
			page: select( coreDataStore ).getEditedEntityRecord( 'postType', 'page', pageId ),
			lastError: select( coreDataStore ).getLastEntitySaveError( 'postType', 'page', pageId )
		}),
        [ pageId ]
	)
	// ...
	return (
		<div className="my-gutenberg-form">
			{/* ... */}
			{ lastError ? (
				<div className="form-error">
					错误：{ lastError.message }
				</div>
			) : false }
			{/* ... */}
		</div>
	);
}
```

太棒了！现在 `EditPageForm` 已能完全感知错误状态。

让我们通过触发无效更新来查看错误提示效果。由于文章标题很难引发错误，我们可以将 `date` 属性设置为 `-1` —— 这必定会触发验证错误：

```js
function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
	// ...
	const handleChange = ( title ) => editEntityRecord( 'postType', 'page', pageId, { title, date: -1 } );
	// ...
}
```

刷新页面后，打开表单修改标题并点击保存，您将看到如下错误提示：

![](https://raw.githubusercontent.com/WordPress/gutenberg/HEAD/docs/how-to-guides/data-basics/media/edit-form/form-error.png)

非常好！现在我们可以**恢复 `handleChange` 函数的先前版本**，继续下一步操作。

### 步骤七：状态指示器

我们的表单还存在最后一个问题：缺乏视觉反馈。在表单消失或显示错误信息之前，我们无法完全确定*保存*按钮是否生效。

现在我们将解决这个问题，并向用户传达两种状态：_保存中_和_未检测到更改_。相关的选择器是`isSavingEntityRecord`和`hasEditsForEntityRecord`。与`getEntityRecord`不同，这些选择器从不发起HTTP请求，仅返回当前实体记录状态。

让我们在`EditPageForm`中使用它们：

```js
function EditPageForm( { pageId, onSaveFinished } ) {
	// ...
	const { isSaving, hasEdits, /* ... */ } = useSelect(
		select => ({
			isSaving: select( coreDataStore ).isSavingEntityRecord( 'postType', 'page', pageId ),
			hasEdits: select( coreDataStore ).hasEditsForEntityRecord( 'postType', 'page', pageId ),
			// ...
		}),
		[ pageId ]
	)
}
```

现在我们可以使用`isSaving`和`hasEdits`在保存过程中显示加载动画，并在无编辑内容时禁用保存按钮：

```js
function EditPageForm( { pageId, onSaveFinished } ) {
	// ...
	return (
		// ...
		<div className="form-buttons">
			<Button onClick={ handleSave } variant="primary" disabled={ ! hasEdits || isSaving }>
				{ isSaving ? (
					<>
						<Spinner/>
						保存中
					</>
				) : '保存' }
			</Button>
			<Button
				onClick={ onCancel }
				variant="tertiary"
				disabled={ isSaving }
			>
				取消
			</Button>
		</div>
		// ...
	);
}
```

请注意，当没有编辑内容或页面正在保存时，我们会禁用*保存*按钮。这是为了防止用户意外重复点击按钮。

此外，由于`@wordpress/data`不支持中断正在进行的*保存*操作，我们也相应禁用了*取消*按钮。

实际运行效果如下：

![](https://raw.githubusercontent.com/WordPress/gutenberg/HEAD/docs/how-to-guides/data-basics/media/edit-form/form-inactive.png)
![](https://raw.githubusercontent.com/WordPress/gutenberg/HEAD/docs/how-to-guides/data-basics/media/edit-form/form-spinner.png)

### 整体联调

所有组件都已就位，太棒了！以下是我们本章构建的完整代码：

```js
import { useDispatch } from '@wordpress/data';
import { Button, Modal, TextControl } from '@wordpress/components';

function PageEditButton( { pageId } ) {
	const [ isOpen, setOpen ] = useState( false );
	const openModal = () => setOpen( true );
	const closeModal = () => setOpen( false );
	return (
		<>
			<Button onClick={ openModal } variant="primary">
				编辑
			</Button>
			{ isOpen && (
				<Modal onRequestClose={ closeModal } title="编辑页面">
					<EditPageForm
						pageId={ pageId }
						onCancel={ closeModal }
						onSaveFinished={ closeModal }
					/>
				</Modal>
			) }
		</>
	);
}

function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
	const { page, lastError, isSaving, hasEdits } = useSelect(
		( select ) => ( {
			page: select( coreDataStore ).getEditedEntityRecord( 'postType', 'page', pageId ),
			lastError: select( coreDataStore ).getLastEntitySaveError( 'postType', 'page', pageId ),
			isSaving: select( coreDataStore ).isSavingEntityRecord( 'postType', 'page', pageId ),
			hasEdits: select( coreDataStore ).hasEditsForEntityRecord( 'postType', 'page', pageId ),
		} ),
		[ pageId ]
	);

	const { saveEditedEntityRecord, editEntityRecord } = useDispatch( coreDataStore );
	const handleSave = async () => {
		const savedRecord = await saveEditedEntityRecord( 'postType', 'page', pageId );
		if ( savedRecord ) {
			onSaveFinished();
		}
	};
	const handleChange = ( title ) =>  editEntityRecord( 'postType', 'page', page.id, { title } );

	return (
		<div className="my-gutenberg-form">
			<TextControl
				__nextHasNoMarginBottom
				__next40pxDefaultSize
				label="页面标题："
				value={ page.title }
				onChange={ handleChange }
			/>
			{ lastError ? (
				<div className="form-error">错误：{ lastError.message }</div>
			) : (
				false
			) }
			<div className="form-buttons">
				<Button
					onClick={ handleSave }
					variant="primary"
					disabled={ ! hasEdits || isSaving }
				>
					{ isSaving ? (
						<>
							<Spinner/>
							保存中
						</>
					) : '保存' }
				</Button>
				<Button
					onClick={ onCancel }
					variant="tertiary"
					disabled={ isSaving }
				>
					取消
				</Button>
			</div>
		</div>
	);
}
```

### 步骤四：实现页面标题字段的可编辑功能

我们的*页面标题*字段存在一个问题：无法编辑。它接收固定值但在输入时不会更新。我们需要一个 `onChange` 处理函数。

您可能在其他 React 应用中也见过类似的模式，这被称为["受控组件"](https://reactjs.org/docs/forms.html#controlled-components)：

```js
function VanillaReactForm({ initialTitle }) {
	const [title, setTitle] = useState( initialTitle );
	return (
		<TextControl
			__nextHasNoMarginBottom
			__next40pxDefaultSize
			value={ title }
			onChange={ setTitle }
		/>
	);
}
```

在 Gutenberg 数据中更新实体记录与此类似，但不同之处在于，我们不使用 `setTitle` 将数据存储在本地（组件级别）状态，而是使用 `editEntityRecord` 操作将更新存储在 *Redux* 状态中。以下是在浏览器的开发工具中尝试的方法：

```js
// 我们需要一个有效的页面 ID 来调用 editEntityRecord，因此使用 getEntityRecords 获取第一个可用的 ID。
const pageId = wp.data.select( 'core' ).getEntityRecords( 'postType', 'page' )[0].id;

// 更新标题
wp.data.dispatch( 'core' ).editEntityRecord( 'postType', 'page', pageId, { title: '更新后的标题' } );
```

此时，您可能会问，`editEntityRecord` 比 `useState` 好在哪里？答案是它提供了一些额外功能。

首先，我们可以像检索数据一样轻松地保存更改，并确保所有缓存都能正确更新。

其次，通过 `editEntityRecord` 应用的更改可以通过 `undo` 和 `redo` 操作轻松撤销或重做。

最后，由于更改存储在 *Redux* 状态中，它们是“全局的”，可以被其他组件访问。例如，我们可以让 `PagesList` 显示当前编辑的标题。

关于最后一点，让我们看看使用 `getEntityRecord` 访问刚刚更新的实体记录时会发生什么：

```js
wp.data.select( 'core' ).getEntityRecord( 'postType', 'page', pageId ).title
```

它并未反映编辑后的内容。这是怎么回事？

实际上，`<PagesList />` 渲染的是 `getEntityRecord()` 返回的数据。如果 `getEntityRecord()` 反映了更新后的标题，那么用户在 `TextControl` 中输入的任何内容也会立即显示在 `<PagesList />` 中。这并不是我们想要的效果。在用户决定保存之前，编辑内容不应泄漏到表单之外。

Gutenberg 数据通过区分*实体记录*和*已编辑的实体记录*来解决这个问题。*实体记录*反映来自 API 的数据，忽略任何本地编辑，而*已编辑的实体记录*则在数据基础上应用了所有本地编辑。两者同时存在于 Redux 状态中。

让我们看看调用 `getEditedEntityRecord` 会发生什么：

```js
wp.data.select( 'core' ).getEditedEntityRecord( 'postType', 'page', pageId ).title
// "更新后的标题"

wp.data.select( 'core' ).getEntityRecord( 'postType', 'page', pageId ).title
// { "rendered": "<原始未更改的标题>", "raw": "..." }
```

如您所见，实体记录的 `title` 是一个对象，而已编辑实体记录的 `title` 是一个字符串。

这并非偶然。像 `title`、`excerpt` 和 `content` 这样的字段可能包含[短代码](https://developer.wordpress.org/apis/handbook/shortcode/)或[动态区块](/docs/how-to-guides/block-tutorial/creating-dynamic-blocks.md)，这意味着它们只能在服务器上渲染。对于这些字段，REST API 同时暴露了 `raw` 标记和 `rendered` 字符串。例如，在区块编辑器中，`content.rendered` 可用于视觉预览，而 `content.raw` 可用于填充代码编辑器。

那么，为什么已编辑实体记录的 `content` 是一个字符串？由于 JavaScript 无法正确渲染任意的区块标记，它仅存储 `raw` 标记，而不包含 `rendered` 部分。由于这是一个字符串，整个字段就变成了字符串。

现在我们可以相应地更新 `EditPageForm`。我们可以使用 [`useDispatch`](/packages/data/README.md#usedispatch) 钩子访问操作，类似于使用 `useSelect` 访问选择器：

```js
import { useDispatch } from '@wordpress/data';

function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
	const page = useSelect(
		select => select( coreDataStore ).getEditedEntityRecord( 'postType', 'page', pageId ),
		[ pageId ]
	);
	const { editEntityRecord } = useDispatch( coreDataStore );
	const handleChange = ( title ) => editEntityRecord( 'postType', 'page', pageId, { title } );

	return (
		<div className="my-gutenberg-form">
			<TextControl
				__nextHasNoMarginBottom
				__next40pxDefaultSize
				label="页面标题:"
				value={ page.title }
				onChange={ handleChange }
			/>
			<div className="form-buttons">
				<Button onClick={ onSaveFinished } variant="primary">
					保存
				</Button>
				<Button onClick={ onCancel } variant="tertiary">
					取消
				</Button>
			</div>
		</div>
	);
}
```

我们添加了一个 `onChange` 处理函数，通过 `editEntityRecord` 操作跟踪编辑，然后将选择器更改为 `getEditedEntityRecord`，以便 `page.title` 始终反映更改。

现在的效果如下：

![](https://raw.githubusercontent.com/WordPress/gutenberg/HEAD/docs/how-to-guides/data-basics/media/edit-form/form-editable.png)