本文将从一个简单的例子开始,逐步深入 React 的编写细节。
React Native 主张用 React 的开发思维来编写 UI 层。因此在学习 React-Native 之前,了解基本的 React 的语法和存在的坑会对今后 React Native 的开发大有裨益。
本文将从一个简单的例子开始,逐步完善我们的程序。在这个过程中,我们将一步步探讨如何用 React 来开发网页应用,以及需要注意的陷阱。与其他教程不同,本文将采用类似 Zed A. Shaw 的 《Learn Code the Hard Way》 系列的案例驱动的形式,从例子开始着手。我相信,掌握一门新技术最好的方法就是自己动手。因此,我并不打算面面俱到的列举所有关于 React 的内容,而更倾向于担任一个引路人的角色:把读者们带到 React 花园的门前,然后让读者们在 React 花园里来一场自助游。为了让这个旅途更加有收获,我会在每节内容的最后安排几个练习,并在最后分享一些值得深入学习的文章和教程。
下载 React 的 Starter Kit 0.14.0 并解压。得到的目录结构长这样:
react-0.14.0/ # React 根目录
|
+-- build/ # React 的 js 代码
|
+-- examples/ # 官方提供的例子
| |
| +---- basic/
| |
| +---- basic-click-counter/
| |
| +---- ...
|
+-- README.md # 说明文档
接下来我们需要启动一个简单的 HTTP 服务器方便我们本地预览我们的应用:
$ cd react-0.14.0
$ python -m SimpleHTTPServer
接下来可以用浏览器访问 http://localhost:8000/examples/basic/ ,你将看到这样的页面:
该页面会统计用户自打开这个页面开始经过的时间。
用 Atom 载入整个目录。启动 Atom ,点击 【File】-> 【Add Project Folder】 菜单项,选择 react-0.14.0 目录所在文件夹。
在根目录下创建一个新的文件夹 test ,在 test 目录下新建页面文件 index.html 。
本文后面的大部分练习都只涉及对这个文件进行修改。
按照惯例,让我们先来实现一个简单的 Hello World 程序。在 index.html 里敲入下面的代码:
<!DOCTYPE html>
<html>
<head>
<script src="../build/react.js"></script>
<script src="../build/react-dom.js"></script>
</head>
<body>
<div id="container"></div>
<script type="text/javascript">
ReactDOM.render(
React.createElement("h1", null, "Hello World!"),
document.getElementById('container')
);
</script>
</body>
</html>
我们先看看这个页面的效果。访问 http://localhost:8000/test/ ,你将看到这样的界面:
如果您的 build 文件夹中没有 react-dom.js 文件,您可能下载的是 0.13 或者更早的版本,建议下载使用 Starter Kit 0.14.0 。
<head>
<script src="../build/react.js"></script>
<script src="../build/react-dom.js"></script>
</head>
ReactDOM.render
函数:ReactComponent render(
ReactElement element,
DOMElement container,
[function callback]
)
这个函数用来将一个 React 元素 element
渲染到 container
指定的 DOM 中。最后的一个参数 callback
是可选的,用于指定该组件绘制或更新完成后需要执行的回调。
某些教程会使用 React.render
来渲染页面,这个函数已经过时。建议使用新的 ReactDOM.render
函数。
在我们的例子中,我们用 React.createElement
创建了一个内容为 “Hello World!” 的一级标题。当页面启动时,这个一级标题会被插入到 id 为 container
的 div 容器中。
ReactElement createElement(
string/ReactClass type,
[object props],
[children ...]
)
React.createElement
函数的第一个参数是元素类型,可以是 h1
、div
等 HTML 元素,也可以是 ReactClass 类型(后面会提到),接下来是两个可选参数 props
和 children
,分别表示要赋予的属性和子元素。
打开浏览器的调试工具(例如 Chrome 的审查工具),可以看到带有 “Hello World!” 文字信息的一级标题被插入到了 container
这个 div 容器中:
React.DOM
是对 React.createElement
的封装和简化。查下 React.DOM
的文档,试试将代码用 React.createDOM
重写。在练习1中我们使用 React 提供的 render()
函数实现了向指定 DOM 中插入内容的简单功能。但这段文本内容是 Hard-Code 的,没有数据绑定的过程,不利于数据和页面模板的分离。
另一个很糟糕的问题是,像 React.createElement
这类创建元素的方法不如直接编写 HTML 直观。举个例子,假设现在我们需要在 “Hello World!” 标题和 container
容器中增加一层:把 “Hello World!” 放入一个名为 greeting
的 div 容器,再把这个 greeting
容器放入 container
容器里。从页面层级来看,关系应该是这样的:
+-------------------------+
| container |
| +-------------------+ |
| | | |
| | greeting | |
| | +-------------+ | |
| | |Hello World! | | |
| | +-------------+ | |
| | | |
| +-------------------+ |
| |
+-------------------------+
如果用 React.createElement
来实现 greeting
和 “Hello World!” 标题的动态创建,代码如下:
<!DOCTYPE html>
<html>
<head>
<script src="../build/react.js"></script>
</head>
<body>
<div id="container"></div>
<script type="text/javascript">
ReactDOM.render(
React.createElement("div", { id: "greeting" },
React.createElement("h1", null, "Hello World!")
), document.getElementById('container'));
</script>
</body>
</html>
可以看到,仅仅是增加一层嵌套,就需要再写一层 React.createElement
。想象一下,当日后我们的项目变得越来越复杂时,我们的代码里可能会有一堆的 Reacte.createElement
嵌套,代码的可读性越来越差,甚至难以继续维护。
JSX 就是为了解决上面的问题而设计出来的一套扩展语法,它的特点是在 JavaScript 中加入了类 XML 语法特性。我们在开发网页应用的时候,不再需要调用无趣的 Reacte.createElement
来创建页面元素,而可以写 HTML 页面一样完成页面的编写。
JSX 的取名含义应该就是 JS + XML 。
要使用 JSX ,我们需要对我们的代码做一些改造。将 ReactDOM.render
的内容改成:
ReactDOM.render(
<div id="greeting">
<h1>Hello World!</h1>
</div>,
document.getElementById('container')
);
不过这段代码并不能直接被浏览器渲染,我们需要将它保存到另一个文件 main.jsx 中:
完成后使用 babel
命令将 main.jsx 转成浏览器支持的 JavaScript 代码:
1234 | $ npm install --save-dev babel-cli babel-present-react # 安装 babel$ echo '{ "presets": [ "react" ], "plugins": []}' > ~/.babelrc # 将 react 插件添加进 .babelrc$ cd test$ babel main.jsx -o main.js |
---|
完成后会在当前目录下生成 main.js 文件,我们打开它看看里面的内容:
可以和我们在上一节写的JavaScript代码比较下,是不是一模一样?现在可以在我们页面代码中把个脚本文件引用进来:
$ npm install --save-dev babel-cli babel-present-react # 安装 babel
$ echo '{ "presets": [ "react" ], "plugins": []}' > ~/.babelrc # 将 react 插件添加进 .babelrc
$ cd test
$ babel main.jsx -o main.js
如前面所说,JSX 其实就是在 JS 的基础上加入了类 XML 的语法。HTML 的标签直接写在 JavaScript 代码中,不加任何引号,这就是 JSX 的语法。它允许 HTML 与 JavaScript 的混写。纯 JS 的代码很难看出页面的逻辑,而加入了 HTML 的标签支持后,程序的可读性就大大提高了。
为了更详细的说明 JSX 语法的特点,我们对 main.jsx 的代码做点修改,将 “Hello World!” 字符串提取出来作为一个变量 greeting
。
<!DOCTYPE html>
<html>
<head>
<script src="../build/react.js"></script>
<script src="../build/react-dom.js"></script>
</head>
<body>
<div id="container"></div>
<script src="main.js"></script>
</body>
</html>
上面代码体现了 JSX 的基本语法规则:遇到 HTML 标签(以 <
开头),就用 HTML 规则解析;遇到代码块(以 {
开头),就用 JavaScript 规则解析。我们再次用 babel 转换成 JS 代码,结果如下:
"use strict";
var greeting = "Hello World!";
ReactDOM.render(React.createElement(
"div",
{ id: "greeting" },
React.createElement(
"h1",
null,
greeting
)
), document.getElementById('container'));
由于是一门扩展语言,JSX 的代码并不能直接被浏览器渲染,所以我们不能直接在代码中引用 JSX 代码,而应该先用 babel 工具转换成 JavaScript 再引用。为了方便调试,我们可以使用 babel 中的 browser.js 来让浏览器支持渲染 JSX 。browser.js 属于 babel-core ,先安装 babel-core 。要注意的是 Babel 从 6.0 开始不再提供 browser.js ,因此我们需要安装版本 5 的 babel-core :
$ npm install babel-core@5
然后将 index.html 修改成:
<!DOCTYPE html>
<html>
<head>
<script src="../build/react.js"></script>
<script src="../build/react-dom.js"></script>
<script src="../node_modules/babel-core/browser.js"></script>
</head>
<body>
<div id="container"></div>
<script type="text/babel">
var greeting = "Hello World!";
ReactDOM.render(
<div id="greeting">
<h1>{greeting}</h1>
</div>,
document.getElementById('container')
);
</script>
</body>
</html>
程序的第 6 行添加了对 browser.js 的引用,第 10 行开始直接加入 JSX 代码。需要注意的是脚本的类型需要为 text/babel
,用于告诉浏览器这段代码是 JSX 代码,需要使用 browser.js 渲染。
browser.js 的原理其实是在页面运行时动态将 JSX 转成 JavaScript 再渲染,这个过程比较耗时。实际发布项目时依然建议使用 babel 将 JSX 预转换成 JavaScript 。
<h1>{"Hello " + "World!"}</h1>
。为了更好的将页面模块化,React 使用组件来表示每个页面模块。组件可以像其他 HTML 标签一样使用 ReactDOM.render
直接绘制。组件可以包含属性和状态。
this.props.属性名
获取。getInitialState()
中声明,使用 setState()
函数更新值,并通过 this.state.状态名
来获取值。我们将在下一个练习了解状态的使用。现在先让我们把焦点放在属性上。将 main.html 改写成:
<!DOCTYPE html>
<html>
<head>
<script src="../build/react.js"></script>
<script src="../build/react-dom.js"></script>
<script src="../node_modules/babel-core/browser.js"></script>
</head>
<body>
<div id="container"></div>
<script type="text/babel">
var Greeting = React.createClass({
render: function() {
return (
<div id="greeting">
<h1>{this.props.word}</h1>
</div>
);
}
});
ReactDOM.render(
<Greeting word="Hello World!"/>,
document.getElementById('container')
);
</script>
</body>
</html>
在上面的代码中,我们使用 React.createClass()
来创建一个组件实例。JSX 里约定分别使用首字母大、小写来区分本地组件的类和 HTML 标签。每个组件通常都会有一个 render()
函数,用于指定当调用 ReactDOM.render()
渲染该组件时的方式。该函数会使用 return
语句返回一个页面节点。在我们的例子中,我们将问候语作为一个 word 属性,在 Greeting 组件中通过 this.props.word
来获取,并放入一个一级标题中,再在外层用一个 id 为 “greeting” 的 div
包含。
在 ReactDOM.render()
函数中,我们可以像使用其他 HTML 标签一样使用自定义的组件,并传入一个自定义属性 word
。
经过这么修改,我们把原本 Hard-Code 的 “Hello World!” 字符串改成通过组件属性来传递,这个过程就完成了视图和数据的 绑定 。
现在我们使用 react-devtool 来调试 React 程序,看看属性是如何被传入到组件里的。如果你的浏览器还没有装这个插件,现在就装上它(Chrome 版 | Firefox 版)。
打开浏览器的调试工具,点击 React 选项卡,如图所示:
调试工具左侧的窗口展示了 Greeting 组件完成数据绑定后的结果,右边的窗口展示了 Greeting 组件的所有属性,目前只有一个 word
属性。我们在左边窗口的代码首行单击鼠标右键,可以打开一个菜单。选择 【Show Source】 可以跳进 Greeting 的源码,选择 【Show in Elements Pane】 可以跳进 HTML 元素面板中,如下图所示:
render
函数中返回多个根节点,看看会不会报错。word
属性的类型验证,并尝试将 word 的值修改为数值或者其他类型看看能否通过验证。word
属性增加一个默认值 “Hello World” 。date
,并使用 {..props}
传入这两个属性的值。我们继续完善我们的例子。现在我们希望能够传入一组人的名字,然后让 Greeting 组件向这些人问好。
将 index.html 改为:
<!DOCTYPE html>
<html>
<head>
<script src="../build/react.js"></script>
<script src="../build/react-dom.js"></script>
<script src="../node_modules/babel-core/browser.js"></script>
</head>
<body>
<div id="container"></div>
<script type="text/babel">
var Greeting = React.createClass({
render: function() {
return (
<ol id="greeting">
{
this.props.names.map(function (name) {
return <li>Hello, {name}!</li>;
})
}
</ol>
);
}
});
var names = ['Alice', 'Bob', 'Cindy'];
ReactDOM.render(
<Greeting names={names}/>,
document.getElementById('container')
);
</script>
</body>
</html>
刷新下浏览器,效果如下:
让我们先看看 ReactDOM.render()
部分:
var names = ['Alice', 'Bob', 'Cindy'];
ReactDOM.render(
<Greeting names={names}/>,
document.getElementById('container')
);
这一部分的内容和之前的区别不大,唯一的区别就是 names
属性的取值通过传入一个变量 names
来完成,由于是一个 JavaScript 的列表型变量,因此,names
的两端需要用 {}
包围 。
我们再看看 Greeting
组件的实现:
var Greeting = React.createClass({
render: function() {
return (
<ol id="greeting">
{
this.props.names.map(function (name) {
return <li>Hello, {name}!</li>;
})
}
</ol>
);
}
});
在程序的第 6 行,我们使用 JavaScript 的 Array.prototype.map()
操作将 names
数组的每个值 name
一个个使用 <li>Hello, {name}</li>
的形式重新创建,得到一个新的数组再返回给 ReactDOM.render()
函数绘制。注意 Array.prototype.map()
操作是一个 JavaScript 操作,所以必须使用 {}
包围。
打开 React 调试工具,可以看到 names 属性变成了一个列表:
注意到调试工具的终端窗口出现了一个警告:
为了解释这个问题,我们先来了解一下虚拟 DOM 。
HTML 或 XML 文档是使用 DOM (Document Object Model,文档对象模型)来表示和处理的。DOM 技术使得用户页面可以动态地变化,如可以动态地显示或隐藏一个元素,改变它们的属性,增加一个元素等,使得页面的交互性大大地增强。
然而,DOM 有一个致命的缺点——慢。举个例子,假如我们需要在某个节点动态插入一个元素,那就需要先定位到那个节点再进行插入。假如要插入多个元素,那么节点的定位和插入的时间就要成倍增加。对于一个复杂的页面,整个过程可能非常耗时。
为了提高页面元素操纵的效率,React 提出了虚拟 DOM 的技术:组件在插入文档之前,并不是真实的 DOM 节点,而是存在于内存之中的一种数据结构,因此称为虚拟 DOM 。与 DOM 相比,虚拟 DOM 放弃了定位和修改节点的过程,而是通过一种称为 DOM diff 的算法找出中这个虚拟 DOM 中发生改动的部分,然后对这些部分进行整体刷新。这样,多次的节点定位和修改就合并成了一次组件的整体刷新。这就是为什么虚拟 DOM 的速度要比 DOM 快的重要原因。
由上也可看出,虚拟 DOM 技术依赖于 DOM diff 算法的效率和准确性。而这个算法依赖于以下两个假设:
key
属性来标识节点。列表的每个子元素就是类型相同的兄弟节点,如果列表的子元素不加上 key
属性标识,当列表的元素发生改变(例如有个新元素插入到头部),有可能会影响 DOM diff 的判断,从而影响算法的效率和准确性。
this.props.children
的内容,尝试使用 this.props.children
取代例子中的 this.props.names
展示数据。到目前为止 Greeting 组件的 name
属性的值都是在代码中事先写好的,程序运行的过程中没法再改变。现在我们对这个例子做些修改,让它在运行时接受我们的输入,并生成问候语。
修改 index.html 代码如下:
<!DOCTYPE html>
<html>
<head>
<script src="../build/react.js"></script>
<script src="../build/react-dom.js"></script>
<script src="../node_modules/babel-core/browser.js"></script>
</head>
<body>
<div id="container"></div>
<script type="text/babel">
var Greeting = React.createClass({
getInitialState: function() {
return {
name_list : []
};
},
render: function() {
return (
<div>
<ol>
{
this.state.name_list.map(function (name) {
return <li key={name}>Hello {name}!</li>;
})
}
</ol>
<input ref="name_input" placeholder="Input a name here" type="text"/>
<input type="submit" onClick={this.handleClick} />
</div>
);
},
handleClick: function(event) {
var names = this.state.name_list;
var input_name = this.refs.name_input.value;
names.push(input_name);
this.setState({name_list: names});
this.refs.name_input.value = "";
}
});
ReactDOM.render(
<Greeting/>,
document.getElementById('container')
);
</script>
</body>
</html>
刷新下浏览器,此时页面初始化时只有一个文本输入框和一个提交按钮:
此时注意到调试工具中出现了一个新的 State 对象,该对象包含一个 0 元素的 name_list 列表。
往文本框中输入名字并点击提交按钮后,页面就会出现相应的问候语:
此时调试工具中的 State 对象也发生了相应变化,name_list 中的元素会记录下用户输入的所有名字。
在练习 3 中我们简单提过状态(state)。React 把用户界面当作简单状态机,把用户界面想像成拥有不同状态然后渲染这些状态。对于在代码中需要动态改变的数据,例如需要对用户输入、服务器请求或者时间变化等作出响应,这时就需要使用 state 。在我们的例子中,此时 Greeting 组件所需要渲染的名字列表是由用户输入的,所以应该将其改写成 state 。
name_list
状态并初始化为一个 0 元素的空列表([]
)。getInitialState: function() {
return {
name_list : []
};
},
在使用状态的组件中,这个函数通常是必须编写的。否则会报 “Cannot read property ‘name_list’ of null” 错误。
handleClick()
函数。<input ref="name_input" placeholder="Input a name here" type="text"/>
<input type="submit" onClick={this.handleClick} />
handleClick()
函数的实现。需要格外注意的一点是获取输入框的内容的方式。我们前面已经说到,组件在插入页面前其实是在虚拟 DOM 中的表示,因此,在渲染成最终实际的 DOM 前,你不能通过直接访问组件内的元素来试图获取它的属性。对于我们的代码,Greeting 组件的子节点有一个文本输入框,用于获取用户的输入。这时就必须获取真实的 DOM 节点,虚拟 DOM 是拿不到用户输入的。为了做到这一点,我们在文本输入框添加了一个 ref 属性 name_input
,然后通过 this.refs.name_input
就指向这个虚拟 DOM 的子节点。
handleClick: function(event) {
var names = this.state.name_list;
var input_name = this.refs.name_input.value;
names.push(input_name);
this.setState({name_list: names});
this.refs.name_input.value = "";
}
如果需要获取这个元素自身的真实 DOM 节点,可以使用 ReactDOM.findDOMNode
方法。该方法将在虚拟 DOM 插入文档以后才返回该元素实际的 DOM 节点。
name
的 state 传入给 Greeting 组件渲染。如下图所示:怎么对用户的输入进行验证?
ReactDOM.findDOMNode
函数,增加一个按钮,当点击该按钮时,让输入框获得焦点。value="Alice"
属性,让它在页面初始时给出一个示例。如下:但这引来了一个 bug :输入框变成了不可变。怎么解决这个问题?(留意终端的错误警告信息)
通过观察我们上一节的程序,我们可以看到 Greeting 组件其实包含了两个部分:一个用来展示问候语的列表,以及一个输入名字的表单。从功能上看,这两个部分可以各自作为一个独立的组件 NameList 和 NameForm ,然后再组合成一个复合组件 GreetingWidget 。画图示意如下:
+-----------------------------+
| GreetingWidget |
| |
| +-----------------------+ |
| | NameList | |
| | | |
| +-----------------------+ |
| |
| +-----------------------+ |
| | NameForm | |
| | | |
| +-----------------------+ |
+-----------------------------+
这样的设计看起来好像很合理,然而在 React 中实现可能会遇到问题。在 React 里面,数据流是一个方向的:从拥有者到子节点。这是因为根据 the Von Neumann model of computing ,数据仅向一个方向传递。你可以认为它是单向数据绑定。因此, NameList 里头展示的数据必须由 GreetingWidget 以属性的方式传入,而这些属性又必须从 NameForm 获取。试图从子节点获取数据就违反了 React 单向数据绑定的原则。为了解决这个问题,我们可以以属性的形式传递一个回调函数 onNameSubmit()
给 NameForm 。当点击 NameForm 里的 submit 按钮时,就调用这个回调函数并将 name 数据作为参数交给回调函数处理。
代码如下:
<!DOCTYPE html>
<html>
<head>
<script src="../build/react.js"></script>
<script src="../build/react-dom.js"></script>
<script src="../node_modules/babel-core/browser.js"></script>
</head>
<body>
<div id="container"></div>
<script type="text/babel">
var GreetingWidget = React.createClass({
getInitialState: function() {
return {
name_list : []
};
},
render: function() {
return (
<div>
<NameList name_list={this.state.name_list} />
<NameForm onNameSubmit={this.handleNameSubmit} />
</div>
);
},
handleNameSubmit: function(name) {
var names = this.state.name_list;
names.push(name);
this.setState({name_list: names});
}
});
var NameForm = React.createClass({
handleSubmit: function(e) {
e.preventDefault();
var name = this.refs.name_input.value;
if (!name) {
return;
}
this.props.onNameSubmit(name);
this.refs.name_input.value = "";
return;
},
render: function() {
return (
<form onSubmit={this.handleSubmit}>
<input ref="name_input" placeholder="Input a name here" type="text" />
<input type="submit" />
</form>
);
}
});
var NameList = React.createClass({
render: function() {
return (
<div>
<ol>
{
this.props.name_list.map(function (name) {
return <li key={name}>Hello {name}!</li>;
})
}
</ol>
</div>
);
}
});
ReactDOM.render(
<GreetingWidget />,
document.getElementById('container')
);
</script>
</body>
</html>
12 | <NameList name_list={this.state.name_list} /><NameForm onNameSubmit={this.handleNameSubmit} /> |
---|
其中,Greeting 组件将 handleNameSubmit()
函数作为一个属性传递给 NameForm 当做回调函数。
在上图所示的调试工具中也可以清楚的看到 GreetingWidget 在虚拟 DOM 中的内部结构。
onSubmit
事件指定使用该组件实例的 handleSubmit()
函数处理:12345678 | render: function() { return ( <form onSubmit={this.handleSubmit}> <input ref="name_input" placeholder="Input a name here" type="text" /> <input type="submit" /> </form> );} |
---|
而 handleSubmit()
函数调用了父节点 GreetingWidget 传进来的回调函数 onNameSubmit()
函数,并传入本节点的输入框控件的值作为 name
参数:
render: function() {
return (
<form onSubmit={this.handleSubmit}>
<input ref="name_input" placeholder="Input a name here" type="text" />
<input type="submit" />
</form>
);
}
注意在这里我们调用了 preventDefault()
来避免使用浏览器默认的行为提交表单。
onNameSubmit()
回调函数指定使用 handleNameSubmit()
函数来处理,该函数接收子节点回传的 name
参数,并通过 setState()
方法追加到当前 name_list
列表中:handleSubmit: function(e) {
e.preventDefault();
var name = this.refs.name_input.value;
if (!name) {
return;
}
this.props.onNameSubmit(name);
this.refs.name_input.value = "";
return;
},
而传给 NameList 的数据只用来展示,所以可以定义为 Props 。在调试工具中,点击 NameList 子节点,注意右侧数据区中的 name_list 是以 Prop 定义的:
e.preventDefault();
重新提交下数据,看看有什么变化;className
属性。详见 Supported Attributes 。本文从例子入手,一步步介绍了 JSX 、组件、属性、状态、数据展示、表单处理、复合组件等 React 开发中的基础概念,在其中存在的一些坑和值得深究的东西也尽量以扩展练习的形式交给读者主动去学习掌握。
受限于篇幅关系,本文所介绍的内容主要是为了后续学习 React Native 做准备,而不足以囊括 React 开发基础的所有方面。例如:
如果希望继续深入学习 React 开发,在学习完本文之后,您可以继续阅读下面列举的资料:
其它推荐的学习材料: