前文记录了 npm 和 Vite。npm 解决第三方依赖管理问题,Vite 解决开发服务器、模块解析和生产构建问题。

但是即使用了 Vite,页面内部的交互逻辑仍然可以是原生 JavaScript 和 DOM API。比如 TodoList 中仍然可以写:

1
2
3
const li = document.createElement("li");
li.textContent = todo.text;
ul.appendChild(li);

当页面非常简单时,这样写完全够用。但是当页面状态越来越复杂,就会遇到新的问题,这就是 React 要解决的部分。

1. 从 DOM 操作到状态同步

如果 TodoList 只有添加和删除任务,原生 DOM 操作很直接。但是继续增加功能以后,例如编辑、筛选、统计、保存、批量操作,问题会逐渐出现:

  • 同一份数据可能影响页面中的多个位置,例如列表、未完成数量、筛选结果;
  • 增删改查以后,需要手动同步很多 DOM 节点;
  • 表单状态、列表状态、筛选状态交织在一起;
  • 页面拆成多个区域后,数据传递和事件处理容易混乱。

也就是说,原生 DOM 操作的问题不在于“不能做”,而是状态多了以后,程序员需要自己保证数据和页面始终同步。

React 解决的正是这个困难:它把手工维护 DOM 的逻辑,改成用组件和状态描述 UI。

2. 基本思想

原生 JavaScript 的思路通常是:

1
数据变化 -> 手动修改 DOM

React 的思路更接近:

1
state -> UI = f(state) -> React 更新 DOM

也就是说,程序主要维护 state,然后描述当前 state 下页面应该长什么样。至于怎样把真实 DOM 更新到这个状态,由 React 负责。

例如 TodoList 中有一个数组:

1
2
3
const todos = [
{ id: 1, text: "Learn React", done: false }
];

React 中可以直接把这个数组映射成 UI:

1
2
3
4
5
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>

这里的意思是:todos 数组里有什么,页面就显示什么。以后 todos 改变,React 会重新计算 UI,并同步到真实 DOM。

3. JSX

JSX 是什么

JSX 是 JavaScript 的语法扩展,用类似 HTML 的写法描述 UI。例如:

1
<button onClick={addTodo}>添加</button>

这段代码描述了一个按钮,其中显示“添加”,点击时调用 addTodo。它不是一段 HTML 字符串,而是一个 JavaScript 表达式,其结果用于描述一个 React 元素。

从概念上看,它大致相当于:

1
React.createElement("button", { onClick: addTodo }, "添加");

React 并不强制使用 JSX,也可以直接调用 JavaScript API 创建元素。不过 UI 结构复杂以后,JSX 通常更容易阅读和编写。

基本写法

JSX 可以像 HTML 一样嵌套标签:

1
2
3
4
<main>
<h1>TodoList</h1>
<button>添加</button>
</main>

普通文本直接写在标签中;需要使用 JavaScript 数据时,则把表达式放进 {}:

1
2
3
4
5
6
7
8
<h1>{title}</h1>
<p>剩余任务:{todos.length}</p>

<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>

这里的 {title} 用来显示变量,{todos.length} 用来显示计算结果,{todos.map(...)} 则根据数组生成一组元素。{} 中只能放表达式,不能直接放 if、for 等语句。

JSX 和 HTML 还有几个常见区别:

  • JSX 属性使用 JavaScript 的命名方式,例如 class 写成 className,onclick 写成 onClick;
  • 字符串属性可以写成 type="button",JavaScript 表达式则写在 {} 中,例如 disabled={todos.length === 0};
  • 标签必须闭合,例如 <input />;
  • 一段 JSX 包含多个并列元素时,需要用同一个父元素或 <>...</> 包起来。

JSX 如何运行

JSX 不是浏览器原生支持的 JavaScript 语法,不能直接交给浏览器执行。包含 JSX 的文件通常使用 .jsx 扩展名,例如 App.jsx。在使用 Vite 的项目中,Vite 会负责把 JSX 转换成普通 JavaScript,再交给浏览器运行。

4. 组件、props 和 state

理解 JSX 后,再来看 React 中的几个基本概念。

组件

组件是一块可以组合的 UI,最简单的组件就是一个返回 JSX 的 JavaScript 函数:

1
2
3
function TodoItem() {
return <li>Learn React</li>;
}

组件可以继续组合成更大的组件:

1
2
3
4
5
6
7
8
function App() {
return (
<main>
<h1>TodoList</h1>
<TodoItem />
</main>
);
}

props

组件通常需要接受外部传入的数据:

1
2
3
function TodoItem({ todo }) {
return <li>{todo.text}</li>;
}

使用组件时传入:

1
<TodoItem todo={todo} />

这里传入的 todo 就是 prop。可以暂时把组件理解成:

1
Component(props) -> UI

state

有些数据会随用户操作变化,例如输入框内容、Todo 列表、当前过滤条件等,这些内容适合放到 state 中。

1
const [todos, setTodos] = useState([]);

增加、删除和修改任务时,不直接操作 DOM,而是更新 state:

1
2
3
4
5
6
7
8
9
setTodos([...todos, newTodo]);

setTodos(todos.filter((todo) => todo.id !== id));

setTodos(
todos.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo
)
);

这时页面如何变化,不再靠 document.createElement(...)、element.remove() 或 element.classList.add(...) 手动完成,而是由 React 根据新的 state 重新计算。

5. 创建项目

可以使用 Vite 创建 React 项目:

1
2
3
4
5
6
npm create vite@latest todo-react
# 选择 React
# 选择 JavaScript
cd todo-react
npm install
npm run dev

这里选择普通的 JavaScript 模板,不选择 React Compiler。项目创建完成后,目录结构大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
todo-react/
├── node_modules/
├── public/
├── src/
│ ├── assets/
│ ├── App.css
│ ├── App.jsx
│ ├── index.css
│ └── main.jsx
├── index.html
├── package.json
├── package-lock.json
└── vite.config.js

其中大部分内容与普通 Vite 项目相同,React 相关的入口主要在 src/ 中:

  • main.jsx 是 JavaScript 入口,负责启动 React;
  • App.jsx 定义页面最外层的 App 组件;
  • index.css 保存整个页面的基础样式;
  • App.css 保存默认 App 组件的样式;
  • assets/ 中的图片只是默认演示页面使用的资源。

此外,package.json 中已经安装了 react 和 react-dom。前者提供组件、state 等 React API,后者负责把 React 页面渲染到浏览器 DOM。

vite.config.js 是 Vite 的配置文件,这里是内容是启用了 Vite 的 React 插件,让 Vite 能够处理 React 项目中的 JSX 文件:

1
2
3
4
5
6
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
plugins: [react()]
});

默认的入口 index.html 非常简单,通过模块脚本引入 src/main.jsx:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>todo-react</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

默认的 main.jsx 内容如下:

1
2
3
4
5
6
7
8
9
10
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

解释一下:document.getElementById("root") 找到 HTML 中的根节点,createRoot(...) 为它创建 React 根节点,render(<App />) 再把 App 组件渲染到页面中。StrictMode 用于在开发阶段帮助发现潜在问题,不会生成额外的页面元素。

这里的 App 来自 App.jsx。默认模板中的 App 是一个计数器演示组件,同时导入了 App.css 和 assets/ 中的图片。

这里涉及到的各个阶段依次是:

  • 浏览器仍然从 HTML index.html 开始加载
  • Vite 负责处理模块和 JSX
  • React 从 main.jsx 开始接管 #root 内部的页面

其中的 index.html 和 main.jsx 都模板化了,内容简单且固定,通常都不需要修改。

6. TodoList 示例

现在把默认计数器改成 TodoList。可以删除 src/assets/ 和 src/App.css,保留不需要修改的 main.jsx,然后重写 App.jsx 和 index.css:

1
2
3
4
src/
├── App.jsx
├── index.css
└── main.jsx

src/App.jsx 负责 TodoList 的状态、事件和页面结构:

App.jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import { useState } from "react";

let nextId = 1;

export default function App() {
const [text, setText] = useState("");
const [todos, setTodos] = useState([]);

function addTodo(event) {
event.preventDefault();

const value = text.trim();
if (!value) {
return;
}

setTodos([
...todos,
{
id: nextId++,
text: value,
done: false
}
]);
setText("");
}

function toggleTodo(id) {
setTodos(
todos.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo
)
);
}

function removeTodo(id) {
setTodos(todos.filter((todo) => todo.id !== id));
}

return (
<main>
<h1>TodoList</h1>

<form onSubmit={addTodo}>
<input
value={text}
onChange={(event) => setText(event.target.value)}
/>
<button type="submit">添加</button>
</form>

<ul>
{todos.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo(todo.id)}
/>
{todo.done ? <del>{todo.text}</del> : todo.text}
<button onClick={() => removeTodo(todo.id)}>删除</button>
</li>
))}
</ul>
</main>
);
}

src/index.css 可以写成:

index.css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
* {
box-sizing: border-box;
}

body {
margin: 0;
min-width: 320px;
min-height: 100vh;
font-family: sans-serif;
background: #f5f5f5;
}

#root {
width: min(90%, 480px);
margin: 80px auto;
padding: 24px;
background: #fff;
border: 1px solid #ddd;
}

h1 {
margin-top: 0;
}

form,
li {
display: flex;
align-items: center;
gap: 8px;
}

form input {
flex: 1;
min-width: 0;
padding: 8px;
}

button {
padding: 8px 12px;
cursor: pointer;
}

ul {
margin: 20px 0 0;
padding: 0;
list-style: none;
}

li {
padding: 10px 0;
border-bottom: 1px solid #eee;
}

li button {
margin-left: auto;
}

使用 React 的版本相比之前直接写的做法,核心变化是:程序不再直接创建和删除 DOM,而是修改 text 和 todos 两个 state。页面长什么样由 JSX 描述,React 负责把 state 的变化同步到 DOM。