题记
如果你要验证是否学习理解了某个东西,最好的检验办法之一是看能否可以讲给别人听。作为一个只了解HTML/CSS/Javascript皮毛的服务器程序员,很难找到那种我希望教程:从最基本的开始,了解技术发展的脉络,一步一步到现在。
以下就是我的部分成果,希望对你有用。
在努力劈开我们的全新应用开发之前,我们需要找一把好刀。这里我们选择React作为入门点。有这么几个理由:
- React的思想是纯粹的。如果你喜欢数学的简单直接,那React非常适合你
- React的思想是通用的。React解决问题的思路非常通用。你在这里的投资一点都不会浪费,学习其他的快到飞起
- React的代码是美观的。当把HTML组件都统统对应到JSX组件(一种描述HTML结构的语法),对应的JavaScript函数,你会发现这里面的统一,直观的美!
闲言少叙,我们从实现一个选择你最喜欢的水果开始。从蛮荒时代逐步进化到信息时代。
0. 从零起步
我们之前已经学习过DOM API,还记得么?下面的使用DOM API实现的版本,可以检查一下你的记忆。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>你最喜欢的水果</title>
</head>
<body>
<h2>请选择你喜欢的水果:</h2>
<ul id="fruit-list"></ul>
<p id="selected">你选择的水果:无</p>
<script>
const fruits = ["苹果", "香蕉", "芒果", "草莓"];
const listDom = document.getElementById("fruit-list");
const selectedDom = document.getElementById("selected");
fruits.forEach(fruit => {
const li = document.createElement("li");
li.textContent = fruit;
li.style.cursor = 'pointer';
li.onclick = () => {
selectedDom.textContent = `你选择的水果:${fruit}`;
};
listDom.appendChild(li);
});
</script>
</body>
</html>
把这段代码保存到文件 fruit.html。用浏览器打开它,并单击任意一项,你会看到如下结果(这里我选择了草莓,人人都爱草莓):
(插入截图)
它能工作,但是相当于手工构造了页面的每一个部分。当页面稍微复杂一点,就完全失控了。我们引入React库,看看能否简化操作。
如果需要增加一个按钮,你必须具体告诉 DOM 怎么加,这叫做命令式编程 —— 事无巨细,事必躬亲 React 是声明式编程,你说“要有按钮”,按钮就出现了。React当骡马背后给你做完了。需要逐渐体会这个思维方式的差异。
1. 引入React
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>你最喜欢的水果</title>
<!-- 引入React核心库。⚠️非推荐使用方式,仅仅示例使用 -->
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
</head>
<body>
<div id="root"></div>
<script>
const fruits = ["苹果", "香蕉", "芒果", "草莓"];
function App() {
const selectedRef = React.useRef(null);
const handleClick = fruit => {
if (selectedRef.current) {
selectedRef.current.textContent = `你选择的水果:${fruit}`;
}
};
const fruitList = fruits.map(fruit =>
React.createElement('li', {
key: fruit,
onClick: () => handleClick(fruit), style: { cursor: 'pointer' }
}, fruit)
);
return React.createElement(
'div',
null,
React.createElement('h2', null, '请选择你喜欢的水果:'),
React.createElement('ul', null, fruitList),
React.createElement('p', { ref: selectedRef }, '你选择的水果:无')
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(React.createElement(App));
</script>
</body>
</html>
React库包含两个文件,react.js, react-dom.js,我们都把它引入进来。
仔细观察body部分,我们可以看到两个明显的特征:
- body的主体消失了,替换为一个id为root的div标签元素。我们知道,div是通用容器,React会使用这个容器来放置页面内容。
- 所有的其余代码都成为JavaScript代码的一部分,被放到script里面。
代码的主体分为三部分
- 定义水果清单
- 定义一个函数,这个函数最后返回一个调用React.createElement()函数得到的对象。React.createElement()函数这里类似于DOM API的createElement()函数,但是仅仅返回HTML元素的内部表示。每个独立的HTML元素都分别调用了React.createElement()函数
- 找到root节点的位置并创建React的root,然后在这个节点上渲染(render)上面返回的内部表示。这里React.createElement()函数真正创建了所有的HTML元素
App()函数具体内容我们可以先不管它,因为我们很快就要简化这部分。
我们重申一下之前的说明
- React 本质上只是一个 JS 库
- 它只是用 JS 构造 DOM,替代手写 document.createElement()
但是这个初步的尝试产生的代码甚至比之前还多一点,我们继续前进。但是可以注意到,React操作的对象是函数,而不是HTML。所有的HTML由这些函数按需生成。
2. 引入JSX
JSX是一种在JavaScript中以类似HTML的方式书写页面方法。有几个基本要求:
-
如果是单行,你可以直接使用。如
const h1 = <h1> 标题 </h1>;定义了一个变量h1,它的值是一个HTML h1元素 -
如果要跨行,则需要使用括号括起来,下面定义一个多行的JSX元素:
const div = ( <div> <h1>什么是JSX?</h1> <p>JSX是React引入的一种简化实现的语法糖</p> </div> ); -
如果要在JSX引用JavaScript对象,使用{}包含起来,可以嵌套,深度不限。下面是一个样例
const question = '什么是JSX?'; const answer = 'JSX是React引入的一种简化实现的语法糖'; const div = ( <div> <h1>{question}</h1> <p>{answer}</p> </div> );
看起来确实清爽不少!使用JSX,我们的代码变更为:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>你最喜欢的水果</title>
<!-- 引入React核心库。⚠️非推荐使用方式,仅仅示例使用 -->
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- 引入Babel:让浏览器能理解 JSX。⚠️非推荐使用方式,仅仅示例使用 -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const fruits = ["苹果", "香蕉", "芒果", "草莓"];
function App() {
const selectedRef = React.useRef(null);
const handleClick = fruit => {
if (selectedRef.current) {
selectedRef.current.textContent = `你选择的水果:${fruit}`;
}
};
return (
<div>
<h2>请选择你喜欢的水果:</h2>
<ul>
{fruits.map(fruit => (
<li
key={fruit}
onClick={() => handleClick(fruit)}
style={{ cursor: 'pointer' }}
>
{fruit}
</li>
))}
</ul>
<p ref={selectedRef}>你选择的水果:无</p>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>
几个特点:
- 需要引入babel库。没有它,浏览器不能识别JSX。她负责把JSX转换为浏览器认识的普通JS代码
- script标签增加特别的类型说明,这是text/babel
- App()函数中,代码确实简化了。我们直接把列表也嵌入到返回语句中,看起来我们好像在JavaScript代码中直接写HTML,感觉不错!
- 渲染的时候,我们使用
<App />,就好像App是一个新的HTML标签一样。这就体现了组件化的思路
这里,我们需要记住:
- JSX不是模板,它是一种语法糖。语法糖的意思,它仅仅是语法层面的简化:
<div>{fruit}</div>可以理解为等价于React.createElement("div", null, fruit) - JSX就是一句“普通”的JavaScript语句!担任需要一点特别的处理才能执行,不是目前标准JavaScript支持的,谁说这个不会是未来的标准之一呢?
在JSX真正被大规模使用之前,人们尝试了很多种可能的办法,事实证明,JSX这种形式是最容易被大家接受的,很类似HTML,代码也不复杂。
代码中有一个点我们没有说,那就是useRef的使用。我们这里使用useRef更新DOM!美学上讲,这个太不好看,需要动态变化的元素越多,代码越糟糕;逻辑上讲,这个是回到了命令式编程的老路。我们需要更好地管理状态变更。
3. 引入useState —— React 的真正魔法
如果要说理想的状态,我们的目标是:
当状态变化时,界面自动同步变化。
这也是推动React不断进化的终极动力。
React 最终选择了“显式控制状态,重新渲染DOM,变化对比优化”的办法。
- 显式控制状态。useState以及相关的原语。用户明确使用,表示状态
- 重新渲染DOM。基于状态变化,传入新的值,渲染DOM
- 变化对比优化。重新渲染DOM非常费时,导致性能的优化。React通过内部比较,仅仅重新渲染变化的局部,可以消除绝大部分性能问题。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>你最喜欢的水果</title>
<!-- 引入React核心库。⚠️非推荐使用方式,仅仅示例使用 -->
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- 引入Babel:让浏览器能理解 JSX。⚠️非推荐使用方式,仅仅示例使用 -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const fruits = ["苹果", "香蕉", "芒果", "草莓"];
function App() {
// 使用 useState 存储选中的水果,初始值为 "无"
const [selectedFruit, setSelectedFruit] = React.useState("无");
const handleClick = fruit => {
// 调用状态更新函数,触发组件重新渲染
setSelectedFruit(fruit)
};
return (
<div>
<h2>请选择你喜欢的水果:</h2>
<ul>
{fruits.map(fruit => (
<li
key={fruit}
onClick={() => handleClick(fruit)}
style={{ cursor: 'pointer' }}
>
{fruit}
</li>
))}
</ul>
{/* 直接使用状态变量渲染文本 */}
<p>你选择的水果:{selectedFruit}</p>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>
我们可以看出一点端倪了:
- React的组件是一个函数,这里是App()函数
- 函数返回一个JSX对象。这里是Return语句对应的部分
- 状态的更新需要显式设置,就是useState返回结果的第二个部分。我们通过这个告诉 React 刷新页面
这是React的核心特性之一!
管理状态如果不使用显示管理的话,就是另一种思路:响应式。可以监控JavaScript每个对象的读取和写入操作,当写入发生的时候,自动触发渲染。这是一种更智能的办法,当然实现起来也更复杂。Vue就是使用这种办法。
组件组件,能有效组合的才是好组件。React在组件支持上可谓是独步天下。
4. 组件拆分 —— React 的核心能力
组件化的意义主要存在于:
- 复用。降低冗余,减少代码,简化整体复杂度
- 封装和隔离。通过隔离消除依赖,降低整体复杂度
降低复杂度是工程的终极追求。
使用组件的思路,我们重新整理代码如下:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>你最喜欢的水果</title>
<!-- 引入React核心库。⚠️非推荐使用方式,仅仅示例使用 -->
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- 引入Babel:让浏览器能理解 JSX。⚠️非推荐使用方式,仅仅示例使用 -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const fruits = ["苹果", "香蕉", "芒果", "草莓"];
function FruitItem({ fruit, onSelect }) {
return (
<li
key={fruit}
onClick={() => onSelect(fruit)}
style={{ cursor: 'pointer' }}
>
{fruit}
</li>
);
}
function App() {
const [selectedFruit, setSelectedFruit] = React.useState("无");
const handleClick = fruit => {
setSelectedFruit(fruit)
};
return (
<div>
<h2>请选择你喜欢的水果:</h2>
<ul>
{fruits.map(fruit => (
<FruitItem key={fruit} fruit={fruit} onSelect={handleClick} />
))}
</ul>
<p>你选择的水果:{selectedFruit}</p>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>
我们把 FruitItem 独立出来,作为一个组件。有这么几个好处:
- App 组件只关心 “我有什么水果,我选中了哪个水果”;FruitItem 组件只关心 “我应该如何显示自己,以及在点击时通知父级”。每个组件的职责完全分离了,这是软件设计上叫做分离关注点,是软件设计的核心原则之一
- 复用。FruitItem 现在可以轻松地在您应用的任何其它部分复用,只需传入不同的 fruit 和 onSelect 属性即可,这些传入的参数,统一叫做props,是父组件向子组件传递信息的单向通道
- 代码更清晰易读。
当应用复杂时,有效的组件划分成为控制复杂度的关键要素,而有效的组件管理就成为基础。
5. 多文件组织
为了简化,截至目前,我们一直在一个文件中包含了所有的代码,HTML页面本身,组件定义,React的结合代码等。这些其实都是无关的内容,如果把它们拆分成多个目的单一的不同文件,整体会更简单。
我们创建一个目录favorite-fruit,并且把这个文件拆分为三个独立的文件
/favorite-fruit
|- index.html
|- App.js
|- component.js
其内容分别是:
<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>你最喜欢的水果</title>
<!-- 引入React核心库。⚠️非推荐使用方式,仅仅示例使用 -->
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- 引入Babel:让浏览器能理解 JSX。⚠️非推荐使用方式,仅仅示例使用 -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="component.js"></script>
<script type="text/babel" src="App.js"></script>
<script type="text/babel">
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>
// App.js
function App() {
const fruits = ["苹果", "香蕉", "芒果", "草莓"];
const [selectedFruit, setSelectedFruit] = React.useState("无");
const handleClick = fruit => {
setSelectedFruit(fruit)
};
return (
<div>
<h2>请选择你喜欢的水果:</h2>
<ul>
{fruits.map(fruit => (
<FruitItem key={fruit} fruit={fruit} onSelect={handleClick} />
))}
</ul>
<p>你选择的水果:{selectedFruit}</p>
</div>
);
}
// component.js
function FruitItem({ fruit, onSelect }) {
return (
<li
onClick={() => onSelect(fruit)}
style={{ cursor: 'pointer' }}
>
{fruit}
</li>
);
}
拆分为多文件的第一个挑战是,这个文件没法直接用浏览器打开了。浏览器的安全机制阻止了我们从当前页面(index.html)访问其它本地文件(App.js和component.js)。我们需要一个web服务器。
有几种办法建立一个最简单的HTTP服务器。如果你的环境有python,python内置了一个HTTP服务器,如下代码就可以直接启动一个服务器:
python -m http.server
这里我们使用node,所以我们安装一个并使用这个托管我们的代码文件:
# 安装 http-server
pnpm install -g http-server
# 进入到本地的 favorite-fruit 目录
cd favorite-fruit
# 启动服务器
http-server
我们就可以从根据输出的信息,直接打开浏览器浏览效果了,因为仅仅是文件拆分,所以和之前的效果是一样的。
但是这个多文件组织没有充分利用现代JS的核心进步之一,ES的模块系统。有了模块,我们可以更有效的组织代码。
下一篇中,我们暂时告别React,到ES生态的历史长廊里游历一番,不见不散。