不止是UI:React的使用场景探索

日期:2015-08-04点击次数:2329

       React不仅是一个强大的交互式UI渲染类库,而且还提供了一个用于处理数据和用户输入的绝佳方法。它倡导可重用并且易于测试的轻量级组件。不仅在Web应用中,这些重要的特性同样适用于其他的技术场景。

       在这一部分内容中,我们将会看到如何在下面的场景中使用React:

  • 桌面应用
  • 游戏
  • 电子邮件
  • 绘图

       桌面应用

       借助 atom-shell 或者 node-webkit 这类项目,我们可以在桌面上运行一个 Web应用。来自 Github 的 Atom Editor 就是使用 atom-shell 以及 React创建的。

       下面将 atom-shell 应用于我们的SurveyBuilder

       首先,从这里下载并且安装 atom-shell。使用下面的 desktop 脚本运行 atom-shell,就可以在窗口中打开该应用。


  1. // desktop.js
  2. var app = require('app'); 
  3. var BrowserWindow = require('browser-window'); 
  4. // 加载 SurveyBuilder 服务,然后启动它。
  5. var server = require('./server/server'); 
  6. server.listen('8080'); 
  7. // 向我们的服务提供崩溃报告。
  8. require('crash-reporter').start(); 
  9. // 保留 window 对象的一个全局引用。
  10. // 当 javascript 对象被当作垃圾回收时,窗口将会自动关闭。
  11. var mainWindow = null
  12. // 当所有窗口都关闭时退出。
  13. app.on('window-all-closed', function() { 
  14.   if (process.platform != 'darwin'
  15.     app.quit(); 
  16. }); 
  17. // 当 atom-shell 完成所有初始化工作并准备创建浏览器窗口时,会调用下面的方法。
  18. app.on('ready', function() { 
  19.   // 创建浏览器窗口。
  20.   mainWindow = new BrowserWindow({ 
  21.     width: 800
  22.     height: 600
  23.   }); 
  24.   // 加载应用的 index.html 文件。
  25.   // mainWindow.loadUrl('file://' + __dirname + '/index.html');
  26.   mainWindow.loadUrl('http://localhost:8080/'); 
  27.   // 在窗口关闭时触发。
  28.   mainWindow.on('closed', function() { 
  29.     // 直接引用 window 对象,如果你的应用支持多个窗口,通常需要把 window 存储到
  30.     // 一个数组中。此时,你需要删除相关联的元素。
  31.     mainWindow = null
  32.   }); 
  33. }); 

       借助 atom-shell 或者 node-webkit 这类项目,我们可以将创建 web的技术应用于创建桌面应用。就像开发 web 应用一样,React同样可以帮助你构建强大的交互式桌面应用。

       游戏

       通常,游戏对用户交互有很高的要求,玩家需要及时地对游戏状态的改变做出响应。相比之下,在绝大多数web应用中,用户不是在消费资源就是在产生资源。本质上,游戏就是一个状态机,包括两个基本要素:

  1. 更新视图
  2. 响应事件

       在本书概览部分,你应该已经注意到:React关注的范畴比较窄,仅仅包括两件事:

  1. 更新 DOM
  2. 响应事件

       React 和游戏之间的相似点远不止这些。React 的虚拟 DOM 架构成就了高性能的3D 游戏引擎,对于每一个想要达到的视图状态,渲染引擎都保证了对视图或者DOM 的一次有效更新。

       2048这个游戏的实现就是将 React 应用于游戏中的一个示例。这个游戏的目的是把桌面上相匹配的数字结合在一起,直到2048。

       下面,深入地看一下实现过程。源码被分为两部分。第一部分是用于实现游戏逻辑的全局函数,第二部分是React 组件。你马上会看到游戏桌面的初始数据结构。


  1. var initial_board = { 
  2.   a1:null, a2:null, a3:null, a4:null
  3.   b1:null, b2:null, b3:null, b4:null
  4.   c1:null, c2:null, c3:null, c4:null
  5.   d1:null, d2:null, d3:null, d4:null
  6. }; 

       桌面的数据结构是一个对象,它的 key 与 CSS中定义的虚拟网格位置直接相关。继初始化数据结构后,你将会看到一系列的函数对该给定数据结构进行操作。这些函数都按照固定的方式执行,返回一个新的桌面并且不会改变输入值。这使得游戏逻辑更清晰,因为可以将在数字方块移动前后的桌面数据结构进行比较,并且在不改变游戏状态的情况下推测出下一步。

       关于数据结构,另一个有趣的属性是数字方块之间在结构上共享。所有的桌面共享了对桌面上未改变过的数字方块的引用。这使得创建一个新桌面非常快,并且可以通过判断引用是否相同来比较桌面。

       这个游戏由两个 React 组件构成,GameBoard 和Tiles。

       Tiles是一个简单的 React 组件。每当给它的 props 指定一个board,它总会渲染出完整的 Tiles。这给了我们利用 CSS3 transition实现动画的机会。


  1. var Tiles = React.createClass({ 
  2.   render: function(){ 
  3.     var board = this.props.board; 
  4.     // 首先,将桌面的 key 排序,停止 DOM 元素的重组。
  5.     var tiles = used_spaces(board).sort(function(a, b) { 
  6.       return board[a].id - board[b].id; 
  7.     }); 
  8.     return ( 
  9.       <div className="board"
  10.         {tiles.map(function(key){ 
  11.           var tile = board[key]; 
  12.           var val = tile_value(tile); 
  13.           return ( 
  14.             <span key={tile.id} className={key + " value" + val}> 
  15.               {val} 
  16.             </span> 
  17.           ); 
  18.         })} 
  19.       </div> 
  20.     ); 
  21.   } 
  22. }); 
  23. <!-- 渲染数字方块后的输出示例 --> 
  24. <div class="board" data-reactid=".0.1"
  25.   <span class="d2 value64" data-reactid=".0.1.$2">64</span> 
  26.   <span class="d1 value8" data-reactid=".0.1.$27">8</span> 
  27.   <span class="c1 value8" data-reactid=".0.1.$28">8</span> 
  28.   <span class="d3 value8" data-reactid=".0.1.$32">8</span> 
  29. </div> 
  30. /* 将 CSS transistion 应用于数字方块上的动画 */
  31. .board span{ 
  32.   /* ... */
  33.   transition: all 100ms linear; 

        GameBoard是一个状态机,用于响应按下方向键这一用户事件,并与游戏的逻辑功能进行交互,然后用一个新的桌面来更新状态。


  1. var GameBoard = React.createClass({ 
  2.   getInitialState: function() { 
  3.     return this.addTile(this.addTile(initial_board)); 
  4.   }, 
  5.   keyHandler: function(e) { 
  6.     var directions = { 
  7.       37 : left, 
  8.       38 : up, 
  9.       39 : right, 
  10.       40 : down 
  11.     }; 
  12.     if (directions[e.keyCode] 
  13.     && this.setBoard(fold_board(this.state, directions[e.keyCode])) 
  14.     && Math.floor(Math.random() * 300) > 0) { 
  15.       setTimeout(function() { 
  16.         this.setBoard(this.addTile(this.state)); 
  17.       }.bind(this), 100); 
  18.     } 
  19.   }, 
  20.   setBoard: function(new_board) { 
  21.     if (!same_board(this.state, new_board)) { 
  22.       this.setState(new_board); 
  23.       return true
  24.     } 
  25.     return false
  26.   }, 
  27.   addTile: function(board) { 
  28.     var location = available_spaces(board).sort(function() { 
  29.       return.5 - Math.random(); 
  30.     }).pop(); 
  31.     if (location) { 
  32.       var two_or_four = Math.floor(Math.random() * 20) ? 2 : 4
  33.       return set_tile(board, location, new_tile(two_or_four)); 
  34.     } 
  35.     return board; 
  36.   }, 
  37.   newGame: function() { 
  38.     this.setState(this.getInitialState()); 
  39.   }, 
  40.   componentDidMount: function() { 
  41.     window.addEventListener("keydown"this.keyHandler, false); 
  42.   }, 
  43.   render: function() { 
  44.     var status = !can_move(this.state) ? " - Game Over!"""
  45.     return ( 
  46.       <div className = "app" > 
  47.         <span className = "score" > 
  48.           Score: {score_board(this.state)} {status} 
  49.         </span> 
  50.         <Tiles board={this.state}/ > 
  51.         <button onClick={this.newGame}> New Game </button> 
  52.       </div > 
  53.     ); 
  54.   } 
  55. }); 

       在 GameBoard组件中,我们初始化了用于和桌面交互的键盘监听器。每一次按下方向键,我们都会去调用setBoard,该方法的参数是游戏逻辑中新创建的桌面。如果新桌面和原来的不同,我们会更新GameBoard 组件的状态。这避免了不必要的函数执行,同时提升了性能。

       在 render 方法中,我们渲染了当前桌面上的所有 Tile组件。通过计算游戏逻辑中的桌面并渲染出得分。

       每当我们按下方向键时,addTile方法会保证在桌面上添加新的数字方块。直到桌面已经满了,没有新的数字可以结合时,游戏结束。

       基于以上的实现,为这个游戏添加一个撤销功能就很容易了。我们可以把所有桌面的变化历史保存在GameBoard 组件的状态中,并且在当前桌面上新增一个撤销按钮(代码)。

       这个游戏实现起来非常简单。借助React,开发者仅聚焦在游戏逻辑和用户交互上即可,不必去关心如何保证视图上的同步。

       电子邮件

       尽管 React 在创建 web 交互式 UI 上做了优化,但它的核心还是渲染HTML。这意味着,我们在编写 React应用时的诸多优势,同样可以用来编写令人头疼的 HTML 电子邮件。

       创建 HTML 电子邮件需要将许多的 table在每个客户端上进行精准地渲染。想要编写电子邮件,你可能要回溯到几年以前,就像是回到1999 年编写 HTML 一样。

       在多终端下成功地渲染邮件并不是一件简单的事。在我们使用 React来完成设计的过程中,可能会碰到若干挑战,不过这些挑战与是否使用React 无关。

       用 React 为电子邮件渲染 HTML 的核心是React.renderToStaticMarkup。这个函数返回了一个包含了完整组件树的HTML 字符串,指定了最外层的组件。React.renderToStaticMarkup 和React.renderToString 之间唯一的区别就是前者不会创建额外的 DOM属性,比如 React 用于在客户端索引 DOM 的 data-react-id属性。因为电子邮件客户端并不在浏览器中运行——我们也就不需要那些属性了。

       使用 React 创建一个电子邮件,下图中的设计应该分别应用于 PC 端和移动端:

       为了渲染出电子邮件,我写了一小段脚本,输出用于发送电子邮件的 HTML 结构:


  1. // render_email.js
  2. var React = require('react'); 
  3. var SurveyEmail = require('survey_email'); 
  4. var survey = {}; 
  5. console.log( 
  6.   React.renderToStaticMarkup(<SurveyEmail survey={survey}/>) 
  7. ); 

       我们看一下 SurveyEmail 的核心结构。首先,创建一个 Email 组件:


  1. var Email = React.createClass({ 
  2.   render: function () { 
  3.     return ( 
  4.       <html> 
  5.         <body> 
  6.           {this.prop.children} 
  7.         </body> 
  8.       </html> 
  9.     ); 
  10.   } 
  11. }); 

<SurveyEmail/>组件中嵌套了<Email/>。


  1. var SurveyEmail = React.createClass({ 
  2.   propTypes: { 
  3.     survey: React.PropTypes.object.isRequired 
  4.   }, 
  5.   render: function () { 
  6.     var survey = this.props.survey; 
  7.     return ( 
  8.       <Email> 
  9.         <h2>{survey.title}</h2> 
  10.       </Email> 
  11.     ); 
  12.   } 
  13. }); 

        接下来,按照给定的两种设计分别渲染出这两个KPI,在 PC 端上左右相邻排版,在移动设备中上下堆放排版。每一个 KPI在结构上相似,所以他们可以共享同一个组件:


  1. var SurveyEmail = React.createClass({ 
  2.   render: function () { 
  3.     return ( 
  4.       <table className='kpi'
  5.         <tr> 
  6.           <td>{this.props.kpi}</td> 
  7.         </tr> 
  8.         <tr> 
  9.           <td>{this.props.label}</td> 
  10.         </tr> 
  11.       </table> 
  12.     ); 
  13.   } 
  14. }); 

       把它们添加到 <SurveryEmail/>组件中:


  1. var SurveyEmail = React.createClass({ 
  2.   propTypes: { 
  3.     survey: React.PropTypes.object.isRequired 
  4.   }, 
  5.   render: function () { 
  6.     var survey = this.props.survey; 
  7.     var completions = survey.activity.reduce(function (memo,ac){ 
  8.       return memo + a; 
  9.     }, 0); 
  10.     var daysRunning = survey.activity.length; 
  11.     return ( 
  12.       <Email> 
  13.         <h2>{survey.title}</h2> 
  14.         <KPI kpi={completions} label='Completions'/> 
  15.         <KPI kpi={daysRunning} label='Days running'/> 
  16.       </Email> 
  17.     ); 
  18.   } 
  19. }); 

       这里实现了将 KPI上下堆放的排版,但是在 PC 端我们的设计是左右相邻排版。现在的挑战是,让它既能在 PC 又能在移动设备上工作。首先我们应解决下面几个问题。

       通过添加 CSS 文件的方式美化 <Email/>:


  1. var fs = require('fs'); 
  2. var Email = React.createClass({ 
  3.   propTypes: { 
  4.     responsiveCSSFile: React.PropTypes.string 
  5.   }, 
  6.   render: function () { 
  7.     var responsiveCSSFile = this.props.responsiveCSSFile; 
  8.     var styles; 
  9.       if (responsiveCSSFile) { 
  10.         styles = <style>{fs.readFileSync(responsiveCSSFile)}</style>; 
  11.       } 
  12.       return ( 
  13.         <html> 
  14.           <body> 
  15.             {styles} 
  16.             {this.prop.children} 
  17.           </body> 
  18.         </html> 
  19.       ); 
  20.   } 
  21. }); 

完成后的 <SurveyEmail/> 如下:


  1. var SurveyEmail = React.createClass({ 
  2.   propTypes: { 
  3.     survey: React.PropTypes.object.isRequired 
  4.   }, 
  5.   render: function () { 
  6.     var survey = this.props.survey; 
  7.     var completions = survey.activity.reduce(function (memo, ac) { 
  8.       return memo + a; 
  9.     }, 0); 
  10.     var daysRunning = survey.activity.length; 
  11.     return ( 
  12.       <Email responsiveCSS='path/to/mobile.css'
  13.         <h2>{survey.title}</h2> 
  14.         <table className='for-desktop'
  15.           <tr> 
  16.             <td> 
  17.               <KPI kpi={completions} label='Completions'/> 
  18.             </td> 
  19.             <td> 
  20.               <KPI kpi={daysRunning} label='Days running'/> 
  21.             </td> 
  22.           </tr> 
  23.         </table> 
  24.         <div className='for-mobile'
  25.           <KPI kpi={completions} label='Completions'/> 
  26.           <KPI kpi={daysRunning} label='Days running'/> 
  27.         </div> 
  28.       </Email> 
  29.     ); 
  30.   } 
  31. }); 

       我们把电子邮件按照 PC 端和移动端进行了分组。不幸的是,在电子邮件中我们无法使用float: left,因为大多数的浏览器并不支持它。还有 HTML标签中的 align 和 valign 属性已经被废弃,因而 React也不支持这些属性。不过,他们已经提供了一个类似的实现可用于浮动两个div。而事实上,我们使用了两个分组,通过响应式的样式表,依据屏幕尺寸的大小来控制显示或隐藏。

       尽管我们使用了表格,但有一点很明确,使用 React渲染电子邮件和编写浏览器端的响应式 UI有着同样的优势:组件的重用性、可组合性以及可测试性。

       绘图

       在我们的 Survey Builder示例应用中,我们想要绘制出在公共关系活动日当天,某次调查的完成数量的图表。我们想把完成数量在我们的调查表中表现成一个简单的走势图,一眼就可以看出调查的完成情况。

       React 支持 SVG 标签,因而制作简单的 SVG 就变得很容易。

       为了渲染出走势图,我们还需要一个带有一组指令的<Path/>。

       完成后的示例如下:


  1. var Sparkline = React.createClass({ 
  2.   propTypes: { 
  3.     points: React.PropTypes.arrayOf(React.PropTypes.number).isRequired 
  4.   }, 
  5.   render: function () { 
  6.     var width = 200
  7.     var height = 20
  8.     var path = this.generatePath(width, height, this.props.points); 
  9.     return ( 
  10.       <svg width={width} height={height}> 
  11.         <path d={path} stroke='#7ED321' strokeWidth='2' fill='none'/> 
  12.       </svg> 
  13.     ); 
  14.   }, 
  15.   generatePath: function (width, height, points){ 
  16.     var maxHeight = arrMax(points); 
  17.     var maxWidth = points.length; 
  18.     return points.map(function (p, i) { 
  19.       var xPct = i / maxWidth * 100
  20.       var x = (width / 100) * xPct; 
  21.       var yPct = 100 - (p / maxHeight * 100); 
  22.       var y = (height / 100) * yPct; 
  23.       if (i === 0) { 
  24.         return 'M0,' + y; 
  25.       } else { 
  26.         return 'L' + x + ',' + y; 
  27.       } 
  28.     }).join(' '); 
  29.   } 
  30. }); 

       上面的 Sparkline 组件需要一组表示坐标的数字。然后,使用 path创建一个简单的 SVG。

       有趣的部分是,在 generatePath函数中计算每个坐标应该在哪里渲染并返回一个 SVG 路径的描述。

       它返回了一个像“M0,30 L10,20 L20,50”一样的字符串。 SVG路径将它翻译为绘制指令。指令间通过空格分开。“M0,30”意味着将指针移动到x0 和 y30。同理,“L10,20”意味着从当前指针位置画一条指向 x10 和 y20的线,以此类推。

       以同样的方式为大型的图表编写 scale 函数可能有一点枯燥。但是,如果使用 D3这样的类库编写就会变得非常简单,并且 D3 提供的 scale函数可用于取代手动地创建路径,就像这样:


  1. var Sparkline = React.createClass({ 
  2.   propTypes: { 
  3.     points: React.PropTypes.arrayOf(React.PropTypes.number).isRequired 
  4.   }, 
  5.   render: function () { 
  6.     var width = 200
  7.     var height = 20
  8.     var points = this.props.points.map(function (p, i) { 
  9.       return { y: p, x: i }; 
  10.     }); 
  11.     var xScale = d3.scale.linear() 
  12.       .domain([0, points.length]) 
  13.       .range([0, width]); 
  14.     var yScale = d3.scale.linear() 
  15.       .domain([0, arrMax(this.props.points)]) 
  16.       .range([height, 0]); 
  17.     var line = d3.svg.line() 
  18.       .x(function (d) { return xScale(d.x) }) 
  19.       .y(function (d) { return yScale(d.y) }) 
  20.       .interpolate('linear'); 
  21.     return ( 
  22.       <svg width={width} height={height}> 
  23.         <path d={line(points)} stroke='#7ED321' strokeWidth='2' fill='none'/> 
  24.       </svg> 
  25.     ); 
  26.   } 
  27. }); 

       总结

       在这一章里我们学到了:

  1. React 不只局限于浏览器,还可被用于创建桌面应用以及电子邮件。
  2. React 如何辅助游戏开发。
  3. 使用 React 创建图表是一个非常可行的方式,配合 D3这样的类库会表现得更出色。(文/  Tom Hallett等 InfoQ)