# 介紹 React
## 要做什么
今天,我們將要構建一個交互式的 tic-tac-toe 游戲。我們假定你熟悉 HTML 和 JavaScript ,但是你應該可以即使沒有用過它們,應該也可以跟著做一下。
如果你喜歡,可以從這里檢出最終代碼:[Final Result](https://s.codepen.io/ericnakagawa/debug/ALxakj)。試試這個游戲。也可以點擊移動列表中的一個鏈接到 "back in time" 并查看這個移動做出之后的面板是什么樣子的。
## 什么是 React
React 是一個聲明式的、高效、靈活的 JavaScript 庫,用來構建用戶界面。
React 有一些不同種類的組件,但是我們將從 React.Component 子類開始:
~~~
class ShoppingList extends React.Component {
render() {
return (
<div className="shopping-list">
<h1>Shopping List for {this.props.name}</h1>
<ul>
<li>Instagram</li>
<li>WhatsApp</li>
<li>Oculus</li>
</ul>
</div>
);
}
}
// 用法: <ShoppingList name="Mark" />
~~~
這里將接觸到一些有趣的類似 XML 標簽的用法。你的組件告知 React 你想渲染什么 —— 然后在你的數據發生改變時, React 將會高效的更新并渲染前擋的部分。
這里,ShoppingList 是一個 React 組件類,或者 React.Component 類型。組件可以攜帶參數,稱為 props,并返回一個層級視圖來通過 render 方法進行顯示。
render 方法返回一個你想要渲染內容的描述,然后 React 獲得這個描述并渲染到屏幕。特別是,render 返回一個 React 元素,它是一個將要渲染的內容的輕量描述。多數 React 開發者使用一個特定的稱為 JSX 的語法,可以簡化這個構造的編寫。 <div /> 語法在構建時被轉換為 React.createElement('div')。上面的示例等效于:
~~~
return React.createElement('div', {className: 'shopping-list'},
React.createElement('h1', ...),
React.createElement('ul', ...)
);
~~~
可以在 JSX 中的花括號里放入任何 JavaScript 表達式。每個 React 元素是一個真正的 JavaScript 對象 ,可以將他們保存到一個變量中或者在程序中傳遞。
ShoppingList 組件只渲染內建的 DOM 組件,但是你同樣可以方便的組成自定義的 React 組件,通過編寫 <ShoppingList /> 。每個組件都是封裝的,所以它可以獨立地操作,使你可以使用簡單的組件構建復雜的 UIs 。
## 開始
從這個例子中的代碼開始這個游戲:[初始代碼](https://codepen.io/ericnakagawa/pen/vXpjwZ?editors=0010)。
這里摘錄如下:
初始代碼(JSX):
~~~
class Square extends React.Component {
render(){
return (
<button className="square">
{/* TODO */}
</button>
)
}
}
class Board extends React.Component {
renderSquare(i){
return <Square />
}
render(){
const status = 'Next player: X'
return (
<div>
<div className="status">{status}</div>
<div className="board-row">
{this.renderSquare(0)}
{this.renderSquare(1)}
{this.renderSquare(2)}
</div>
<div className="board-row">
{this.renderSquare(3)}
{this.renderSquare(4)}
{this.renderSquare(5)}
</div>
<div className="board-row">
{this.renderSquare(6)}
{this.renderSquare(7)}
{this.renderSquare(8)}
</div>
</div>
)
}
}
class Game extends React.Component {
render(){
return (
<div className="game">
<div className="game-board">
<Board />
</div>
<div className="game-info">
<div>{/* status */}</div>
<ol>{/* TODO */}</ol>
</div>
</div>
)
}
}
ReactDOM.render(
<Game />,
document.getElementById('mount-node')
)
function calculateWinner(squares) {
const lines = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
]
for(let i = 0; i < lines.length; i++){
const [a, b, c] = lines[i]
if(squares[a] && squares[a] === squares[b] && squares[a] === squares[c]){
return squares[a]
}
}
return null
}
~~~
它包含了我們將要構建內容的外殼。我們已經提供了樣式表,所以你只需要關心 JavaScript 。
樣式表:
~~~
body {
font: 14px "Century Gothic", Futura, sans-serif;
margin: 20px;
}
ol, ul {
padding-left: 30px;
}
.board-row:after {
clear: both;
content: "";
display: table;
}
.status {
margin-bottom: 10px;
}
.square {
background: #fff;
border: 1px solid #999;
float: left;
font-size: 24px;
font-weight: bold;
line-height: 34px;
height: 34px;
margin-right: -1px;
margin-top: -1px;
padding: 0;
text-align: center;
width: 34px;
}
.square:focus {
outline: none;
}
.kbd-navigation .square:focus {
background: #ddd;
}
.game {
display: flex;
flex-direction: row;
}
.game-info {
margin-left: 20px;
}
~~~
特別注意,我們有三個組件:
* Square
* Board
* Game
Square 組件渲染了一個單獨的 <div>,Board 渲染了 9 個正方形, Game 組件渲染了一個面板,帶有一些我們將會進行填充的占位符。此時,沒有一個組件可以進行交互。
(在 JS 文件的末尾,我們還定義了一個工具函數 calculateWinner ,稍后以供使用)
## 通過 Props 傳遞數據
簡單嘗試一下,試著傳遞一些數據從 Board 組件到 Square 組件。在 Board 的 renderSquare 方法中,改變代碼以返回 `<Square value ={i} />` 然后改變 Square 的 render 方法(使用 {this.props.value} 替代 {/* TODO */})來顯示這個值。
替換前:

替換后:

## 一個交互式組件
使 Square 組件在你點擊它時填充一個 “X”。試著改變 Square 類 render() 函數返回的標簽為:
~~~
<button className="square" onClick={() => alert('click')}>
~~~
這里使用了 JavaScript 中新的箭頭函數語法。如果你點擊一個正方形,應該可以在瀏覽器中得到一個 alert 了。
React 組件可以通過在 constructor 構造函數中設置 this.state 擁有一個狀態,被認為是這個組件私有的。保存當前正方形的值到 state 中,并在正方形被點擊的時候修改它。首先,添加一個構造函數到類中來初始化 state:
~~~
class Square extends React.Component {
constructor(){
super()
this.state = {
value: null
}
}
render(){
return (
<button className="square" onClick={()=>alert('click')}>
{this.props.value}
</button>
)
}
}
....
~~~
在 JavaScript 類中,當定義子類的構造函數時,你需要顯式調用 super()。
現在修改 render 方法來顯示 this.state.value 替換原來的 this.props.value ,并修改事件處理程序為 `()=>this.setState({value: 'X'})` 替換 alert :
~~~
class Square extends React.Component {
constructor(){
super()
this.state = {
value: null
}
}
render(){
return (
<button className="square" onClick={()=>this.setState({value:'X'})}>
{this.state.value}
</button>
)
}
}
...
~~~
無論何時 this.setState 被調用,都會預定一個該組件的更新,使 React 合并傳遞的 state 更新并重新渲染組件以及它的后代。當組件重新渲染, this.state.value 會變成 "X", 所以你將在網格中看到一個 X 。
如果你點擊任何正方形,一個 X 都會顯示在其中。
## 開發者工具
[Chrome](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en) 和 [Firefox](https://addons.mozilla.org/en-US/firefox/addon/react-devtools/) 的 React 開發者工具擴展,讓你可以在你的瀏覽器開發工具中檢查一個 React 組件樹。

它使你可以檢查任何樹中組件的 props 和 state。
由于多個 frames ,它在 CodePen 中不能很好的工作,但是如果你在登入到 CodePen 并確認你的郵件(為了防止垃圾郵件),你可以打開 Change View > Debug 以在新標簽頁打開你的代碼,然后開發者工具就可以工作了。你現在還不想這樣做也沒關系,但是應該知道它的存在。
## 提升狀態
現在我們已經有了一個 tic-tac-toe 游戲基本的構建塊。但是現在,state 是封裝在每個 Square 組件中。要使游戲完整的運行,現在需要檢查一個玩家是否贏得了游戲,并在 squares 中替換成 X 和 O 。要檢查是否某人獲勝,我們需要有 9 個正方形中的值在一個地方,而不是分開在 Square 組件中。
你可能認為 Board 應該只是查詢每個 Square 當前的狀態是什么。盡管從技術上說在 React 中可以這樣做,但是不建議這樣,因為這往往使代碼難以理解、更加脆弱而且難以重構。
最好的方案是在 Board 組件中保存這個 state 而不是在每個 Square 中 —— 而且 Board 組件可以告知每個 Square 要顯示什么,就像之前我們使每個正方形顯示它的索引那樣。
當你想要從多個子級合計數據或者使兩個子組件之間互相通訊,向上移動 state 使它存活在父組件中。父組件然后可以通過 props 傳遞 state 回到子組件,所以子組件總是互相之間同步,包括其父組件。
像這樣向上推動 state 在重構 React 組件時是非常常見的,所以利用這個機會嘗試一下。為 Board 添加一個初始狀態,包括一個有 9 個 null 的數組,對應到 9 個 正方形:
~~~
class Board extends React.Component {
constructor(){
super()
this.state = {
squares: Array(9).fill(null)
}
}
....
}
~~~
稍后我們填充它,那么一個 board 應該看起來像:
~~~
[
'O', null, 'X',
'X', 'X', 'O',
'O', null, null,
]
~~~
傳遞每個正方形的值,如下:
~~~
renderSquare(i) {
return <Square value={this.state.squares[i]} />;
}
~~~
然后修改 Square 來再次使用 this.props.value 。現在我們需要改變當一個正方形被點擊時的動作。 Board 組件現在保存了哪些正方形被填充,意味著我們需要一個 Square 的方法來更新 Board 的 state 。由于組件狀態是私有的,我們不能直接從 Square更新 Board 的狀態。這里通常的方式是傳遞一個函數從 Board 到 Square 在正方形被點擊的時候調用。修改 renderSquare :
~~~
return <Square value={this.state.squares[i]} onClick={() => this.handleClick(i)} />
~~~
現在我們傳遞兩個 props 從 Board 到 Square:value 和 onClick 。這是一個 Square 可以調用的函數。那么我們通過修改 Square 中的 render :
~~~
<button className="square" onClick={() => this.props.onClick()}>
~~~
這意味著當正方形被點擊,它調用父組件傳遞來的 onClick 函數。onClick 這里沒有任何特別的意義,但是通常命名處理程序 props 以 on 開頭,而它們的實現以 handle 開頭。試試點擊一個正方形 —— 你可能會得到一個 error ,因為我們還沒有定義 handleClick 。添加它到 Board 類:
~~~
handleClick(i) {
const squares = this.state.squares.slice();
squares[i] = 'X';
this.setState({squares: squares});
}
~~~
我們調用 slice() 來拷貝 squares 數組而不是改變現有數組。向后跳一下了解[為什么不變化是非常重要的](https://facebook.github.io/react/tutorial/tutorial.html#why-immutability-is-important)。
現在你應該可以點擊正方形再次填充它們了,但是狀態是保存在 Board 組件而不是每個 Square,那么應該繼續構建這個游戲。注意,無論如何 Board 的狀態發生改變, Square 組件都會自動重新渲染。
Square 不再保留它的自己的狀態;它從父組件 Board 接收它的值,并在被點擊時通知它的父組件。我們稱這樣的組件為約束組件。
## 為什么不變化是非常重要的
在前面的代碼示例中,我建議使用 slice() 操作符來在做出改變之前拷貝正方形數組,并阻止改變存在的數組。討論一下這有什么意義,為什么這是要了解的一個重要概念。
通常有兩種方式來改變數據。第一個方法是直接通過改變變量的值來修改數據。第二種方法是使用新的拷貝對象替代數據并包含預期的修改。
**通過變化改變數據**
~~~
var player = {score: 1, name: 'Jeff'};
player.score = 2;
// Now player is {score: 2, name: 'Jeff'}
~~~
**不發生變化改變數據**
~~~
var player = {score: 1, name: 'Jeff'};
var newPlayer = Object.assign({}, player, {score: 2});
// Now player is unchanged, but newPlayer is {score: 2, name: 'Jeff'}
// Or if you are using object spread, you can write:
// var newPlayer = {score: 2, ...player};
~~~
最終結果相同,但是不直接修改數據(或者改變底層的數據)我們現在有一個額外的好處,可以幫助我們增加組件和整個應用的性能。
**跟蹤修改**
確定一個改變的對象是否被修改是復雜的,因為修改是直接來自于對象本身。之后需要對比當前對象和之前的一個拷貝,遍歷整個對象樹,對比每個變量和值。這個過程可能變得越來越復雜。
確定一個未變化的對象被修改則容易的多。如果被引用的對象和之前的引用不同,那么對象則發生了改變。就是這樣。
**在 React 中決定何時重新渲染**
不可變在 React 中最大的好處是,當你構造一個簡單的純組件時。由于不可變數據更容易確定是否被修改,它還可以幫助決定一個組件何時需要被重新渲染。
要了解如何構造純組件,查看 [shouldComponentUpdate()](https://facebook.github.io/react/docs/update.html)。另外,看一下 [Immutable.js](https://facebook.github.io/immutable-js/) 庫來嚴格執行不可變數據。
## 功能組件
回到我們的項目,你現在可以刪除 Square 的構造函數了;我們不需要它了。事實上,React 支持一個簡單的語法,對于類似 Square 只由一個 render 方法構成的組件類型,稱為 無狀態功能組件。不用定義一個類繼承 React.Component,只要簡單的編寫一個函數,帶有 props 和 返回要被渲染的內容即可:
~~~
function Square(props){
return(
<button className="square" onClick={()=>props.onClick()}>
{props.value}
</button>
)
}
~~~
需要在出現 this.props 的地方修改為 props。許多應用中的組件都可以寫為功能組件:這些組件傾向于被容易的編寫,而且 React 會在將來對它們做更多優化。
## 逐個處理
我們游戲中一個明顯的缺陷是只能顯示 X。現在來修復它。
我們設置默認第一步移動是 X。在 Board 的 constructor 中修改我們的開始狀態。
~~~
constructor(){
super()
this.state = {
squares: Array(9).fill(null),
xIsNext: true,
}
}
~~~
每次移動我們應該翻轉布爾值來切換 xIsNext 并保存狀態。現在更新我們的 handleClick 函數來翻轉 xIsNext 的值。
~~~
handleClick(i){
let squares = this.state.squares.slice()
squares[i] = this.state.xIsNext ? 'X' : 'O'
this.setState({
squares: squares,
xIsNext: !this.state.xIsNext,
})
}
~~~
現在 X 和 O 輪流出現。接下來,修改 Board 的 render 方法中的 “status” 文本所以它可以顯示接下來是什么。
## 聲明一個贏家
看下何時可以贏得游戲。一個 calculateWinner(squares) 輔助函數在文件底部提供了,其中有 9 個值的一個列表。可以在 Board 的 render 函數中調用它來檢查是否某人贏得了游戲,并在某人獲勝時使 status 文本顯示 “Winner:[X/O]”:
~~~
render(){
const winner = calculateWinner(this.state.squares)
let status
if(winner){
status = 'Winner: ' + winner
}else{
status = 'Next player: ' + (this.state.xIsNext?'X':'O')
}
return (
<div>
<div className="status">{status}</div>
<div className="board-row">
{this.renderSquare(0)}
{this.renderSquare(1)}
{this.renderSquare(2)}
</div>
<div className="board-row">
{this.renderSquare(3)}
{this.renderSquare(4)}
{this.renderSquare(5)}
</div>
<div className="board-row">
{this.renderSquare(6)}
{this.renderSquare(7)}
{this.renderSquare(8)}
</div>
</div>
)
}
~~~
現在可以修改 handleClick 來在某人贏得游戲時或者如果正方形已經被填充時忽略點擊并前返回:
~~~
handleClick(i){
const squares = this.state.squares.slice()
if(calculateWinner(squares) || squares[i]) return
squares[i] = this.state.xIsNext ? 'X' : 'O'
this.setState({
squares: squares,
xIsNext: !this.state.xIsNext,
})
}
~~~
恭喜!現在游戲可以正常使用了。現在你已經了解了基礎的 React 知識。所以這里,你才是真正的贏家。
## 保存一個歷史記錄
我們可以使其能夠重新檢視舊的 board 狀態,以查看任何一步移動后可能看起來如何。在每次做出移動時我們已經創建了一個新的數組,意味著我們可以同時簡單的保存過去的 board 狀態。
準備像下面這樣在 state 中保存一個對象:
~~~
history = [
{
squares: [null x 9]
},
{
squares: [... x 9]
},
...
]
~~~
我們希望頂層的 Game 組件負責顯示移動的列表。所以就像我們之前從 Square 向 Board 提升 state 那樣,現在再次提升,從 Board 到 Game —— 所以我們可以在頂層組件中獲得我們所需要的所有信息。
首先,設置 Game 的初始狀態:
~~~
class Game extends React.Component {
constructor(){
super()
this.state = {
history:[{
squares: Array(9).fill(null)
}],
xIsNext: true
}
}
...
}
~~~
然后從 Board 中移除構造函數并修改 Board,然后它通過 props 獲得正方形,并具有 Game 組件中指定的 onClick prop,如我們早先對 Square 所做的那樣。你可以傳遞每個正方形的位置到點擊處理程序,所以我們仍然知道哪個正方形被點擊了:
~~~
return <Square value={this.props.squares[i]} onClick={() => this.props.onClick(i)} />;
~~~
Game 的 render 查找最近的歷史記錄,并帶入以計算游戲狀態:
~~~
render(){
const history = this.state.history
const current = history[history.length - 1]
const winner = calculateWinner(current.squares)
let status
if(winner){
status = 'Winner: ' + winner
}else{
status = 'Next player: ' + (this.state.xIsNext?'X':'O')
}
return (
<div className="game">
<div className="game-board">
<Board squares={current.squares} onClick={(i)=> this.handleClick(i)} />
</div>
<div className="game-info">
<div>{status}</div>
<ol>{/* TODO */}</ol>
</div>
</div>
)
}
~~~
它的 handleClick 可以推入一個新的記錄到棧中,連接新的歷史記錄來生成一個新的 history 數組:
~~~
handleClick(i){
const history = this.state.history
const current = history[history.length - 1]
const squares = current.squares.slice()
if(calculateWinner(squares) || squares[i]) return
squares[i] = this.state.xIsNext ? 'X' : 'O'
this.setState({
history: history.concat([{
squares: squares
}]),
xIsNext: !this.state.xIsNext,
})
}
~~~
此時, Board 只需要 renderSquare 和 render ;初始化的 state 和點擊處理程序都來自 Game。
## 顯示移動
顯示游戲中至今為止之前的移動。我們最先學習了 React 的元素是一級 JS 對象,我們可以保存它們或者傳遞它們。要在 React 中渲染多次,我們傳遞一個 React 元素的數組。構造這個數組最常用的方式是對你的數據數組做映像。我們在 Game 的 render 方法中去做:
對于 history 中的每個步驟,我們創建一個列表項 <li> 并包含一個 link ,并不跳轉到什么地方(href="#") ,但是有一個點擊處理程序,我們稍后會實現它。通過這個代碼,你應該看到游戲中已經做出的一個移動的列表,然而可能有一個警告信息:
>[warning] Warning: Each child in an array or iterator should have a unique "key" prop. Check the render method of "Game".
是說數組或迭代器中的每個子元素都應該有一個不重復的 “key” 屬性。檢查 Game 的 render 方法。
下面討論一下這個警告的意思。
## keys
當你渲染一些項的列表, React 總是保存每個項的信息到列表中。如果你渲染一個有狀態的組件,這個狀態需要被保存 —— 無論你如何實現你的組件, React 都會保存一個到后臺原生視圖的引用。
當你更新這個列表, React 需要確定什么被改變了。你可能添加、移除、重新排列或者更新了列表中的項。
試想從:
~~~
<li>Alexa: 7 tasks left</li>
<li>Ben: 5 tasks left</li>
~~~
到:
~~~
<li>Ben: 9 tasks left</li>
<li>Claudia: 8 tasks left</li>
<li>Alexa: 5 tasks left</li>
~~~
對于人眼來說,看起來好像 Alexa 和 Ben 交換了位置,并且 Claudia 被添加 —— 但是 React 只是一個計算機程序,并不知道你希望它做什么。結果是,React 詢問你指定一個 key 屬性到每個元素上,這是一個字符串,用于從它的同輩中區別每個組件。在這里,alexa、ben、claudia可以作為不錯的 keys ;如果項對應數據庫中的對象,數據庫的 ID 通常是一個好的選擇:
~~~
<li key={user.id}>{user.name}: {user.taskCount} tasks left</li>
~~~
key 是一個特別的屬性可以被 React 保留(連同 ref ,一個高級功能)。當一個元素被創建, React 取下 key 屬性并直接保存到返回的元素上。雖然它看起來是 props 的一部分,但是它不能被通過 this.props.key 引用。React 在決定更新哪個子項的時候自動使用;并沒有哪種方式可以讓一個組件查詢它自己的 key 。
當一個列表被渲染, React 攜帶每個元素的新版本,并查找之前列表中一個帶有匹配的 key 的項。當一個 key 被添加到組中,一個組件被創建;當一個 key 被移除,一個組件被銷毀。 keys 告知 React 每個組件的標識,所以它可以在重新渲染時維護狀態。如果你改變了一個組件的 key ,它將被完全銷毀并使用一個新的狀態重新創建。
強烈建議你無論何時構建動態列表時分配適當的 keys 。如果你沒有一個方便的合適的 key ,可能需要考慮重新構造你的數據。
如果沒有指定任何 key,React 將警告你,并回滾使用數組索引作為一個 key —— 如果你有重新排序元素或者添加/刪除非列表底部的其它元素時這并不是一個正確的選擇。明確傳遞 key={i} 可以消除這個警告,但是也會有同樣問題,所以多數情況并不建議這樣。
組件 keys 不是必須全局唯一,只需要相對于最近的同輩之間唯一即可。
## 實現時間漫游
對于我們的 move 列表,對于每一步我們已經有一個不重復的 ID:這一步發生時移動的數字。添加 key 為 `<li key={move}>` ,然后 key 的警告會被消除。
點擊任何的 move 鏈接會拋出一個錯誤,因為 jumpTo 還未定義。添加一個新的 key 到 Game 的 state 來表示我們正在查看哪一步。首先,添加 stepNumber:0 到初始狀態,然后用 jumpTo 更新這個狀態。
~~~
constructor(){
super()
this.state = {
history:[{
squares: Array(9).fill(null)
}],
stepNumber: 0,
xIsNext: true
}
}
~~~
我們也希望更新 xIsNext 。如果 move 數字的索引是一個偶數,設置 xIsNext 為 true 。
~~~
jumpTo(step){
this.setState({
stepNumber: step,
xIsNext: (step % 2) ? false : true
})
}
~~~
然后當一個新的 move 通過增加 stepNuber:history.length 到被 handleClick 更新的狀態 被做出時,更新 stepNumber。現在你可以修改 render 來讀取 history 中的步驟:
~~~
const current = history[this.state.stepNumber];
~~~
現在如果你點擊任何 move 鏈接,board 應該立即更新顯示游戲看起來在此時應該的樣子。你可能還想在讀取當前 board 狀態時更新 handleClick 關注 stepNumber,你才可以回到彼時然后點擊 board 創建一個新的記錄。(提示:最簡單的是在 handleClick 中很靠上的位置從 history 中 slice() 額外的元素)
## 收尾
現在,你的游戲已經:
* 正常的玩 tic-tac-toe
* 指示何時一個玩家贏得游戲
* 保存游戲中步驟的歷史記錄
* 允許玩家跳回查看游戲中之前的步驟
非常好,我們希望你對于 React 工作有了一個恰當的理解。
如果你有額外的時間或者想要實踐新的技能,這里有一些可以進行的改進,難度遞增的被列出來:
* 顯示移動位置格式為 “(1,3)”,取代“6”
* 在移動列表中粗體顯示當前的選項
* 重寫 Board 來使用兩個循環以產生正方形,取代硬編碼
* 添加切換按鈕使你以正序或倒序排列步驟
* 當某人獲勝,高亮顯示他獲勝的三個正方形