前言

现在最热门的前端框架,毫无疑问是 React,React 是由 Facebook 出品的 JavaScript 框架,由于该框架比较新,比较少中文的资料。这几天看了很多篇关于 react 的英文文章,不得不说 React 是创建大型、快速的 Web 应用的最好方式。在本文中,我们将通过一步一步的创建一个简单的文字记忆游戏,来体验 React 的思想和强大之处。

PS: 由于对 React 的学习也是皮毛,但是在这里,我希望这个小游戏能够成为学习 React 的最佳开发结构,并且随着自己的不断学习,将会继续改进和完善这里的代码。假如您有任何的建议和反馈,请给我留言,谢谢!

在开始之前我们先来看看我们的 demo,游戏非常简单,输入想要记忆的文字,然后点击开始记忆即可。试玩了我们的游戏之后,那么现在就开始吧。

先来看看我们的目录结构,你可以在 GitHub 上找到相对应的源码

 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
├── bower_components
│   ├── bootstrap
│   └── jquery
├── node_modules
│   ├── browserify
│   ├── lodash
│   ├── react
│   ├── reactify
│   └── watchify
├── docs
│   ├── component.dot
│   └── component.png
├── build
│   └── app.js
├── css
│   └── style.css
├── index.html
├── js
│   ├── app.js
│   ├── board.js
│   ├── game.js
│   ├── status.js
│   ├── tile.js
│   └── word-form.js
├── bower.json
├── package.json
└── README.md
  • bower_components 和 bower.json 是安装 bower 组件的目录和配置信息。
  • node_modules 和 package.json 是安装 npm 模块的目录和配置信息。
  • docs 用于存放我们的文档信息。
  • css 和 js 用于存放样式和 JavaScript 源码。
  • build 用于存放最后编译的 css 和 js 文件。
  • index.html 是我们游戏的主页面,也就是 React 的入口。

初始化

  • 首先,创建 npm 模块的配置文件 package.json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
    "name": "react-memory",
    "version": "1.0.0",
    "description": "基于 nodejs + bower + react 的文字记忆游戏。",
    "browserify": {
        "transform": [
            ["reactify"]
        ]
    },
    "author": "wenzhixin <wenzhixin2010@gmail.com> (http://wenzhixin.net.cn/)",
    "license": "MIT"
}
  • 接着,创建 bower 组件的配置文件 bower.json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
    "name": "react-memory",
    "version": "1.0.0",
    "authors": [
        "zhixin <wenzhixin2010@gmail.com>"
    ],
    "license": "MIT",
    "ignore": [
        "**/.*",
        "node_modules",
        "bower_components",
        "test",
        "tests"
    ]
}
  • 安装所需要的依赖包
1
2
3
4
5
6
7
8
9
# 运行游戏时需要的依赖包
npm install --save react lodash
bower install --save bootstrap

# 编译游戏时需要的依赖包
npm install --save-dev browserify watchify reactify

# 全局命令行工具
npm install -g browserify watchify http-server

可以看到,我们安装了运行游戏时所需要的依赖包:react,lodash 模块,以及 bootstrap 组件,lodash 是一个非常实用的工具库,游戏中我们使用到了好多它所提供的操作 array 的简单方法,react 和 bootstrap 的话就不用说了。

React 组件依赖层次

React 中都是以组件的方式来体现的,从上往下,我们切割成非常小、功能单一的组件,分别是: * Game:游戏组件 * WordForm:文字输入组价 * Board:游戏面板组件 * Status:游戏状态组价 * Tile:单个卡片组件

组件模板

由于我们使用了 nodejs 的开发方式以及 React 独有的 JSX 语法,我们组件的模板为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var React = require('react'), // 加载 react 模块
    _ = require('lodash'), // 加载 lodash 模块
    OtherComponent = require('./other-component'); // 加载其他自定义 React 模块

var Component = React.createClass({
    // 定义组件所需要的 properties 属性
    propTypes: {
        prop1: React.PropTypes.string.isRequired,
        func1: React.PropTypes.func.isRequired
    },
    // 初始化组件的状态,并非所有组件都需要 state
    getInitialState: function () {
        return {};
    },
    // 渲染我们的界面
    render: function () {
        return (
            <OtherComponent prop1={this.props.prop1} func1={{this.props.func1}} />
        );
    }
});

module.exports = Component;

对于各行代码的意思,已加了详细的注释说明,在下面的 js 代码中,也是一样在代码中做了详细注释,由于我们只关注组件的核心部分,与模板相同的地方,我们就不做解释了。

Game

创建文件:js/game.js

 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
var React = require('react'),
    _ = require('lodash'),
    Board = require('./board'),
    WordForm = require('./word-form');

var Game = React.createClass({
    // 初始化 state,这里我们使用了 words 数组,用于保存输入的文字
    getInitialState: function () {
        return {words: undefined};
    },
    // 开始游戏
    startGame: function (words) {
        this.setState({
            // 组合并打乱输入的文字
            words: _.shuffle(words.concat(words))
        });
    },
    // 结束游戏,设置 words 为 undefined
    endGame: function () {
        this.setState({words: undefined});
    },
    // 根据 words 来显示我们自定义的组件
    render: function () {
        return (
            this.state.words ?
                <Board onEndGame={this.endGame} words={this.state.words}/> :
                <WordForm onWordsEntered={this.startGame} />
        );
    }
});

module.exports = Game;

WordForm

  • 新建文件:js/word-form.js
 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
var React = require('react'),
    _ = require('lodash');

var WordForm = React.createClass({
    // 需要提供 onWordsEntered 方法,用于触发提交方法,在 Game 中我们使用了 startGame
    propTypes: {
        onWordsEntered: React.PropTypes.func.isRequired
    },
    // 初始化 error 状态
    getInitialState: function () {
        return {error: undefined};
    },
    // 显示错误信息,2s 后自动消失
    setError: function (msg) {
        this.setState({error: msg});
        setTimeout(function () {
            this.setState({error: ''});
        }.bind(this), 2000);
    },
    // 提交文字信息,判断是否符合条件
    submitWords: function (e) {
        e.preventDefault();

        var node = this.refs.words.getDOMNode(),
            // unique 用于生成唯一的字符
            words = _.unique((node.value || '').trim().split(''));

        if (words.length < 3) {
            this.setError('请至少输入三个不同的字符!');
        } else {
            this.props.onWordsEntered(words);
            node.value = '';
        }
    },
    render: function () {
        return (
            <form className='form-inline' onSubmit={this.submitWords}>
                <span>请输入你想记忆的字符</span>
                <input className='form-control' type='text' ref='words' maxLength='10'
                    defaultValue='文字记忆游戏' />
                <button className='btn btn-default' type='submit'>开始记忆</button>
                <p className='error'>{this.state.error}</p>
            </form>
        );
    }
});

module.exports = WordForm;
  • 由于用用到了 bootstrap 的样式和自定义了 error 样式,需要创建 css/style.css 文件
1
2
3
4
5
@import "../bower_components/bootstrap/dist/css/bootstrap.min.css";

.error {
    color: red;
}

Boar

新建文件:js/board.js

  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
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
var React = require('react'),
    _ = require('lodash'),
    Tile = require('./tile'),
    Status = require('./status');

var Board = React.createClass({
    // 需要提供 words 属性,以及 onEndGame 方法,分别对应 Game 的属性和方法
    propTypes: {
        words: React.PropTypes.arrayOf(React.PropTypes.string).isRequired,
        onEndGame: React.PropTypes.func.isRequired
    },
    // 在组件还未 mount 之前用于计算总共有多少对文字卡片
    componentWillMount: function () {
        this.max = this.props.words.length / 2;
    },
    // State 状态
    // found:表示找到了多少对文字卡片
    // message:显示当前的状态
    // tileStates
    getInitialState: function () {
        return {
            found: 0,
            message: 'chooseTile',
            tileStates: new Array(this.props.words.length + 1).join('unturned ').trim().split(' ')
        };
    },
    // 游戏逻辑的处理方法
    clickedTile: function (index) {
        // 当卡片的状态为 unturned(未翻转)时,才进行处理
        if (this.state.tileStates[index] === 'unturned') {
            // flippedTile 用于保存上个点击的卡片的 index
            if (this.flippedTile === undefined) {
                this.flippedTile = index;
                // 设置状态为 findMate
                this.setState({
                    message: 'findMate',
                    // 使用 lodash 方法,将对应的下标置为 revealed(翻转)状态
                    tileStates: _.extend(this.state.tileStates, _.object([index], ['revealed']))
                });
            } else {
                var otherIndex = this.flippedTile,
                    matched = this.props.words[index] === this.props.words[this.flippedTile];

                if (matched) {
                    // 找到相对应的卡片,found + 1,并将状态置为 foundMate
                    this.setState({
                        found: this.state.found + 1,
                        message: 'foundMate',
                        // 使用 lodash 方法,将对应的下标置为 correct(正确)状态
                        tileStates: _.extend(this.state.tileStates,
                            _.object([index, otherIndex], ['correct', 'correct']))
                    });
                } else {
                    // 没有找到相对应的卡片,将状态置为 wrong
                    this.setState({
                        message: 'wrong',
                        // 使用 lodash 方法,将对应的下标置为 wrong(错误)状态
                        tileStates: _.extend(this.state.tileStates,
                            _.object([index, otherIndex], ['wrong', 'wrong']))
                    });
                }
                // 删除保存的信息
                delete this.flippedTile;

                // 1.5s 后我们将卡片翻转回来
                setTimeout(function () {
                    // 需要判断组件是否 mounted
                    if (this.isMounted()) {
                        // 假如所有都选中了,将状态置为 foundAll
                        this.setState({
                            message: this.state.message === 'findMate' ? 'findMate' :
                                this.max === this.state.found ? 'foundAll' : 'chooseTile',
                            tileStates: matched ? this.state.tileStates : _.extend(this.state.tileStates,
                                _.object([index, otherIndex], ['unturned', 'unturned']))
                        });
                    }
                }.bind(this), 1500);
            }
        }
    },
    render: function () {
        // 使用 map 方式,将所有的卡片显示出来
        var tiles = this.props.words.map(function (word, i) {
            return (
                <div key={i} onClick={_.partial(this.clickedTile, i)}>
                    <Tile word={word} status={this.state.tileStates[i]} />
                </div>
            );
        }.bind(this));
        return (
            <div>
                <button className='btn btn-default' onClick={this.props.onEndGame}>结束记忆</button>
                <Status found={this.state.found} max={this.max} message={this.state.message} />
                {tiles}
            </div>
        );
    }
});

module.exports = Board;

Status

新建文件status.js

 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
var React = require('react');

var Status = React.createClass({
    propTypes: {
        found: React.PropTypes.number.isRequired,
        max: React.PropTypes.number.isRequired,
        message: React.PropTypes.oneOf([
            'chooseTile', 'findMate', 'wrong', 'foundMate', 'foundAll'
        ]).isRequired
    },
    render: function () {
        var found = this.props.found,
            max = this.props.max,
            texts = {
                chooseTile: '选择一张卡片!',
                findMate: '现在我们来查找相对应的卡片!',
                wrong: '很遗憾,这两张卡片不匹配!',
                foundMate: '不错,他们是一对的!',
                foundAll: '恭喜过关,你已经找到所有' + max + '对卡片了!'
            };
        return (
            <p>({found}/{max})&nbsp;&nbsp;{texts[this.props.message]}</p>
        );
    }
});

module.exports = Status;

Tile

  • 新建文件tile.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
var React = require('react');

var Tile = React.createClass({
    propTypes: {
        status: React.PropTypes.string.isRequired,
        word: React.PropTypes.string.isRequired
    },
    render: function () {
        return (
            <div className={'brick ' + this.props.status}>
                <div className='front'><i className='glyphicon glyphicon-question-sign'></i></div>
                <div className='back'>{this.props.word}</div>
            </div>
        );
    }
});

module.exports = Tile;
  • 修改文件css/style.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
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
@-webkit-keyframes wronganim {
    to {
        background-color: red;
    }
}

@-moz-keyframes wronganim {
    to {
        background-color: red;
    }
}

@keyframes wronganim {
    to {
        background-color: red;
    }
}

@-webkit-keyframes correctanim {
    to {
        background-color: green;
        color: white;
    }
}

@-moz-keyframes correctanim {
    to {
        background-color: green;
        color: white;
    }
}

@keyframes correctanim {
    to {
        background-color: green;
        color: white;
    }
}

.brick > div {
    width: 80px;
    height: 80px;
    border: 1px solid black;
    text-align: center;
    line-height: 80px;
    font-size: 24px;
    -webkit-backface-visibility: hidden;
    -webkit-transition: -webkit-transform 0.3s linear;
    -moz-transition: -moz-transform 0.3s linear;
    transition: transform 0.3s linear;
    -webkit-transform-style: preserve-3d;
    transform-style: preserve-3d;
    position: absolute;
    overflow: hidden;
    border-radius: 5px;
    backface-visibility: hidden;
}

.brick > .front {
    background-color: #AAA;
}

.brick, .brick div {
    user-select: none;
    cursor: pointer;
}

.brick {
    float: left;
    margin-right: 10px;
    margin-bottom: 10px;
    width: 80px;
    height: 80px;
}

.brick > .back {
    -webkit-animation-duration: 0.5s;
    -webkit-animation-timing-function: ease;
    -webkit-animation-delay: 0.3s;
    -webkit-animation-iteration-count: 1;
    -webkit-animation-fill-mode: forwards;
    -moz-animation-duration: 0.5s;
    -moz-animation-timing-function: ease;
    -moz-animation-delay: 0.3s;
    -moz-animation-iteration-count: 1;
    -moz-animation-fill-mode: forwards;
    animation-duration: 0.5s;
    animation-timing-function: ease;
    animation-delay: 0.3s;
    animation-iteration-count: 1;
    animation-fill-mode: forwards;
}

.brick.wrong > .back {
    -webkit-animation-name: wronganim;
    -moz-animation-name: wronganim;
    animation-name: wronganim;
}

.brick.correct > .back {
    -webkit-animation-name: correctanim;
    -moz-animation-name: correctanim;
    animation-name: correctanim;
}

.brick > .back {
    -webkit-transform: perspective(80px) rotateY(180deg) translate3d(0px, 0px, 2px);
    -moz-transform: perspective(80px) rotateY(180deg) translate3d(0px, 0px, 2px);
    transform: perspective(80px) rotateY(180deg) translate3d(0px, 0px, 2px);
}

.brick.correct > .front, .brick.wrong > .front, .brick.revealed > .front {
    -webkit-transform: perspective(80px) rotateY(-180deg) translate3d(0px, 0px, 2px);
    -moz-transform: perspective(80px) rotateY(-180deg) translate3d(0px, 0px, 2px);
    transform: perspective(80px) rotateY(-180deg) translate3d(0px, 0px, 2px);
}

.brick.correct > .back, .brick.wrong > .back, .brick.revealed > .back {
    -webkit-transform: perspective(80px) rotateY(0deg) translate3d(0px, 0px, 1px);
    -moz-transform: perspective(80px) rotateY(0deg) translate3d(0px, 0px, 1px);
    transform: perspective(80px) rotateY(0deg) translate3d(0px, 0px, 1px);
}

.front {
    font-size: 2em;
}

app.js

创建好了我们所有的组件之后,我们需要将组件组合起来,创建文件app.js

1
2
3
4
5
6
7
var React = require('react'),
    Game = require('./game');

React.render(
    <Game />,
    document.getElementById('app')
);

index.html

新建文件:index.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>记忆游戏</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="css/style.css">
</head>
<body>
<nav class="navbar navbar-inverse navbar-fixed-top">
    <div class="container">
        <div class="navbar-header">
            <span class="navbar-brand">记忆游戏</span>
        </div>
    </div>
</nav>
<div id="app" class="container">正在努力加载中……</div>
<script src="build/app.js"></script>
</body>
</html>

查看结果

  • 开始编译监听 jsx 文件为 js
1
watchify -v -o build/app.js js/app.js
  • 启用 http server
1
http-server -p 8888