一、从命令式到声明式的UI变革
用户在网页上输入信息,提交表单,查看结果列表等一系列的用户交互,都是由前端完成的。前端需要根据用户操作展示不同UI,从而响应用户。
例如常见的表单处理:
- 当用户输入数据时,提交按钮会变为"可用状态"。
- 当用户点击"提交"时,按钮会变为"不可用状态",并展示"加载中"动画。
- 如果网络请求成功,会跳转成功页面。
- 如果网络请求失败,会展示错误信息,同时按钮变为"可用状态"。
这是传统的命令式UI,前端需要根据用户操作来编写DOM指令,更新UI。
<html>
<body>
<form id="form">
<textarea id="textarea"></textarea>
<button id="button" disabled>Submit</button>
<p id="loading" style="display: none">Loading...</p>
<p id="error" style="display: none; color: red;"></p>
</form>
<h1 id="success" style="display: none">That's right!</h1>
<script>
async function handleFormSubmit(e) {
e.preventDefault();
disable(textarea);
disable(button);
show(loadingMessage);
hide(errorMessage);
try {
await submitForm(textarea.value);
show(successMessage);
hide(form);
} catch (err) {
show(errorMessage);
errorMessage.textContent = err.message;
} finally {
hide(loadingMessage);
enable(textarea);
enable(button);
}
}
function handleTextareaChange() {
if (textarea.value.length === 0) {
disable(button);
} else {
enable(button);
}
}
function hide(el) {
el.style.display = 'none';
}
function show(el) {
el.style.display = '';
}
function enable(el) {
el.disabled = false;
}
function disable(el) {
el.disabled = true;
}
function submitForm(answer) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (answer.toLowerCase() === 'istanbul') {
resolve();
} else {
reject(new Error('Good guess but a wrong answer. Try again!'));
}
}, 1500);
});
}
let form = document.getElementById('form');
let textarea = document.getElementById('textarea');
let button = document.getElementById('button');
let loadingMessage = document.getElementById('loading');
let errorMessage = document.getElementById('error');
let successMessage = document.getElementById('success');
form.onsubmit = handleFormSubmit;
textarea.oninput = handleTextareaChange;
</script>
</body>
</html>
问题是,这种模式下代码中会出现大量的DOM指令,可读性差,且不利于扩展。
为了解决这个问题,React和Vue等主流框架引入了声明式UI,我们只需要描述组件在不同状态下期望展示的UI,并根据用户操作来触发状态变更。
首先,我们要梳理出页面有哪些状态,以及期望展示的UI:
- 无数据:展示一个“不可用状态”的提交按钮。
- 输入中:展示一个“可用状态”的提交按钮。
- 提交中:展示一个“不可用状态”的提交按钮,展示“加载中”动画。
- 成功时:展示成功页面。
- 失败时:展示错误信息,展示一个“可用状态”的提交按钮。
export default function Form({ status }) {
if (status === 'success') {
return <h1>That's right!</h1>
}
return (
<form>
<textarea disabled={
status === 'submitting'
} />
<br />
<button disabled={
status === 'empty' ||
status === 'submitting'
}>
Submit
</button>
{status === 'error' &&
<p className="Error">
Good guess but a wrong answer. Try again!
</p>
}
</form>
);
}
然后,确定有哪些用户操作会触发状态变更:
- 输入框的值发生变化时,根据值是否为空决定表单状态为“无数据”或“输入中”。
- 点击提交按钮时,表单状态切换为“提交中”。
- 网络请求成功后,表单状态切换为“成功”。
- 网络请求失败后,表单状态切换为“失败”。
import { useState } from 'react';
export default function Form() {
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing');
if (status === 'success') {
return <h1>That's right!</h1>
}
async function handleSubmit(e) {
e.preventDefault();
setStatus('submitting');
try {
await submitForm(answer);
setStatus('success');
} catch (err) {
setStatus('typing');
setError(err);
}
}
function handleTextareaChange(e) {
setAnswer(e.target.value);
}
return (
<>
<form onSubmit={handleSubmit}>
<textarea
value={answer}
onChange={handleTextareaChange}
disabled={status === 'submitting'}
/>
<br />
<button disabled={
answer.length === 0 ||
status === 'submitting'
}>
Submit
</button>
{error !== null &&
<p className="Error">
{error.message}
</p>
}
</form>
</>
);
}
function submitForm(answer) {
//network request
}
与命令式不同的是,声明式UI是根据用户操作来触发状态变更,不需要编写DOM指令。底层原理是框架采用MVVM架构设计,实现了状态到UI的自动更新。
随着应用不断地变复杂,如何做好状态管理也是一件很重要的事情。例如,如何组织状态结构?如何维护状态更新逻辑?如何实现跨组件共享状态?
二、如何组织状态结构
良好的状态结构,可以让组件状态更新不容易出错,同时还方便调试。一般有以下原则:
- 单一职责,按组件功能划分状态,不要将无关联的状态混合在一起。
- 合并关联状态,如果两个状态总是一起更新,考虑将他们合并为一个。
- 去除冗余状态,这样就不需要保持同步了。
- 避免两个状态同时指向同一个对象。
- 避免深度嵌套,尽量扁平化。
三、跨组件共享状态
有时候我们希望两个组件的状态保持同步更新。例如常见的手风琴面板组件,当展开其中一个面板时,其他面板都自动折叠。
为了实现这一点,可以把状态转移到最近的父组件,并通过props将状态传递给子组件。这被称为“状态提升”,是最基础也是最常见的跨组件共享状态的方案。
import { useState } from 'react';
export default function Accordion() {
const [activeIndex, setActiveIndex] = useState(0);
return (
<>
<Panel
title="标题一"
isActive={activeIndex === 0}
onShow={() => setActiveIndex(0)}
>
面板一
</Panel>
<Panel
title="标题二"
isActive={activeIndex === 1}
onShow={() => setActiveIndex(1)}
>
面板二
</Panel>
</>
);
}
function Panel({
title,
children,
isActive,
onShow
}) {
return (
<section className="panel">
<h3>{title}</h3>
{isActive ? (
<p>{children}</p>
) : (
<button onClick={onShow}>
显示
</button>
)}
</section>
);
}
四、跨层共享状态
很多网站都支持“一键切换主题”,用户只需要点一下按钮,网页上所有组件都会展示"黑暗"或"光亮"主题。这种情况下,需要将主题状态传递给整个UI树,props传递路径变得很冗长,并且每一层的组件都必须新增额外的props参数,十分不方便。
App.js
import Foo from './Foo';
function App() {
const [theme, setTheme] = useState('light');
return (
<>
<Foo theme={theme}/>
<Button
onClick={() => {
setTheme(theme === 'light' ? 'dark' : 'light');
}}>
主题切换
</Button>
</>
);
}
Foo.js
import FooChild from './FooChild';
export default function Foo({theme}) {
return (
<div className={`box ${theme}`}>
Context Foo
<FooChild theme={theme} />
</div>;
)
}
<style>
.box{
width: 200px;
height: 200px;
}
.light{
background-color: #fff;
color: #333;
}
.dark{
background-color: #333;
color: #fff;
}
</style>
FooChild.js
export default function FooChild({theme}) {
return <div className={`child ${theme}`}>Context FooChild</div>;
}
<style>
.child{
width: 200px;
height: 200px;
}
.light{
background-color: #fff;
color: #333;
}
.dark{
background-color: #333;
color: #fff;
}
</style>
要是有一种方法不需要通过props就能传递状态,那就太好了。为此,React推出了新方案,即context。通过创建一个全局唯一的context对象来保存状态,然后在父组件中提供context对象并设置状态值,这样父组件下的整个UI树都能通过context对象访问到状态。
ThemeContext.js
import { createContext } from 'react';
//指定默认值
const defaultTheme = 'light';
//创建context
const ThemeContext = createContext(defaultTheme);
//导出
export default ThemeContext;
App.js
import ThemeContext from './ThemeContext';
import Foo from './Foo';
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={theme}>
<Foo />
<Button
onClick={() => {
setTheme(theme === 'light' ? 'dark' : 'light');
}}
>
主题切换
</Button>
</ThemeContext.Provider>
);
}
Foo.js
import { useContext } from 'react';
import ThemeContext from './ThemeContext';
import FooChild from './FooChild';
export default function Foo() {
const theme = useContext(ThemeContext);
return (
<div className={`box ${theme}`}>
Context Foo
<FooChild />
</div>;
)
}
<style>
.box{
width: 200px;
height: 200px;
}
.light{
background-color: #fff;
color: #333;
}
.dark{
background-color: #333;
color: #fff;
}
</style>
FooChild.js
import { useContext } from 'react';
import ThemeContext from './ThemeContext';
export default function FooChild() {
const theme = useContext(ThemeContext);
return <div className={`child ${theme}`}>Context FooChild</div>;
}
<style>
.child{
width: 200px;
height: 200px;
}
.light{
background-color: #fff;
color: #333;
}
.dark{
background-color: #333;
color: #fff;
}
</style>
context的特点很像CSS属性继承,父组件提供的context,不管层级多深的子组件都能访问到,并且中间组件也能通过提供新的context值来覆盖上层的context。
总结
前端需要根据用户操作展示不同UI,从而响应用户。传统的命令式UI出现了大量的DOM指令,可读性差,且不利于扩展。取而代之的是声明式UI,只需要描述组件在不同状态下期望展示的UI,并根据用户操作来触发状态变更,不需要编写DOM指令。在声明式UI中,状态管理是核心,包括状态结构和状态共享。