原文:Build a universal React and Node App
演示:https://judo-heroes.herokuapp.com/
譯者:nzbin
譯者的話:這是一篇非常優秀的 React 教程,該文對 React 組件、React Router 以及 Node 做了很好的梳理。我是 9 月份讀的該文章,當時跟着教程做了一遍,收獲很大。但是由於時間原因,直到現在才與大家分享,幸好趕在年底之前完成了譯文,否則一定會成為 2016 年的小遺憾。翻譯倉促,其中還有個別不通順的地方,望見諒。
關於通用的 JavaScript
將 Node.js 作為運行 web 程序的后端系統的一個優勢就是我們只需使用 JavaScript 這一種語言。由於這個原因,前后端可以共享一些代碼,這可以將瀏覽器及服務器中重復的代碼減少到最小。創建 JavaScript 代碼的藝術是 "環境未知的",如今被看做 "通用的 JavaScript",這條術語在經過 很 長時間 爭論 之后,似乎取代了原始的名稱 "同構的 JavaScript"。
我們在創建一個通用的 JavaScript 應用程序時,主要考慮的是:
- 模塊共享: 如何將 Node.js 模塊用在瀏覽器中。
- 通用渲染: 如何從服務端渲染應用的視圖 (在應用初始化時) ,以及當用戶瀏覽其它部分時,如何繼續在瀏覽器中直接呈現其他視圖(避免整頁刷新)。
- 通用路由: 如何從服務器和瀏覽器中識別與當前路由相關的視圖。
- 通用數據檢索: 如何從服務器和瀏覽器訪問數據(主要通過 API)。
通用的 JavaScript 仍然是一個非常新的領域,還沒有框架或者方法可以成為解決所有這些問題的 "事實上" 的標准。盡管,已經有無數穩定的以及眾所周知的庫和工具可以成功地構建一個通用的 JavaScript 的 Web 應用程序。
在這篇文章中,我們將使用 React (包括 React Router 庫) 和 Express 來構建一個展示通用渲染和路由的簡單的應用程序。我們也將通過 Babel 來享受令人愉快的 EcmaScript 2015 語法以及使用 Webpack 構建瀏覽器端的代碼。
我們將做什么?
我是一個 柔道迷 ,所以我們今天要創建的應用叫做 "柔道英雄"。 這個 web 應用展示了最有名的柔道運動員以及他們在奧運會及著名國際賽事中獲得的獎牌情況。
這個 app 有兩個主要的視圖:
一個是首頁,你可以選擇運動員:
另一個是運動員頁面,展示了他們的獎牌及其他信息:
為了更好的理解工作原理,你可以看看這個應用的 demo 並且瀏覽一下整個視圖。
無論如何,你可能會問自己! 是的,它看起來像一個非常簡單的應用,有一些數據及視圖...
其實應用的幕后有一些普通用戶不會注意的特殊的事情,但卻使開發非常有趣: 這個應用使用了通用渲染及路由!
我們可以使用瀏覽器的開發者工具證明這一點。 當我們在瀏覽器中首次載入一個頁面(任意頁面, 不需要是首頁, 試試 這一個) ,服務器提供了視圖的所有 HTML 代碼並且瀏覽器只需下載鏈接的資源(圖像, 樣式表及腳本):
然后當我們切換視圖的時候,一切都在瀏覽器中發生:沒有從服務器加載的 HTML 代碼, 只有被瀏覽器加載的新資源 (如下示例中的 3 張新圖片) :
我們可以在命令行使用 curl 命令做另一個快速測試 (如果你仍然不相信):
curl -sS "https://judo-heroes.herokuapp.com/athlete/teddy-riner"
你將看到整個從服務器端生成的 HTML 頁面(包括被 React 渲染的代碼):
我保證你現在已經信心滿滿地想要躍躍欲試,所以讓我們開始編碼吧!
文件結構
在教程的最后,我們的文件結構會像下面的文件樹一樣:
├── package.json ├── webpack.config.js ├── src │ ├── app-client.js │ ├── routes.js │ ├── server.js │ ├── components │ │ ├── AppRoutes.js │ │ ├── AthletePage.js │ │ ├── AthletePreview.js │ │ ├── AthletesMenu.js │ │ ├── Flag.js │ │ ├── IndexPage.js │ │ ├── Layout.js │ │ ├── Medal.js │ │ └── NotFoundPage.js │ ├── data │ │ └── athletes.js │ ├── static │ │ ├── index.html │ │ ├── css │ │ ├── favicon.ico │ │ ├── img │ │ └── js │ └── views ` └── index.ejs
主文件夾中有 package.json
(描述項目並且定義依賴) 和 webpack.config.js
(Webpack 配置文件)。
余下的代碼都保存在 src
文件夾中, 其中包含路由 (routes.js
) 和渲染 (app-client.js
和 server.js
) 所需的主要文件。它包含四個子文件夾:
components
: 包含所有的 React 組件data
: 包含數據 "模塊"static
: 包含應用所需的所有靜態文件 (css, js, images, etc.) 和一個測試應用的index.html。
views
: 包含渲染服務器端的 HTML 內容的模板。
項目初始化
需要在你的電腦上安裝 Node.js (最好是版本 6) 和 NPM。
在硬盤上的任意地方創建一個名為 judo-heroes
的文件夾並且在給目錄下打開終端,然后輸入:
npm init
這將會啟動 Node.js 項目並允許我們添加所有需要的依賴。
我們需要安裝 babel, ejs, express, react 和 react-router 。 你可以輸入以下命令:
npm install --save babel-cli@6.11.x babel-core@6.13.x \ babel-preset-es2015@6.13.x babel-preset-react@6.11.x ejs@2.5.x \ express@4.14.x react@15.3.x react-dom@15.3.x react-router@2.6.x
我們也需要安裝 Webpack (以及它的 Babel loader 擴展) 和 http-server 作為開發依賴:
npm install --save-dev webpack@1.13.x babel-loader@6.2.x http-server@0.9.x
HTML boilerplate
現在, 我建設你已經具備了 React 和 JSX 以及基於組件方法的基礎知識。 如果沒有,你可以讀一下 excellent article on React components 或者 React related articles on Scotch.io。
首先我們只專注於創建一個實用的 "單頁應用" (只有客戶端渲染). 稍后我們將看到如何通過添加通用的渲染和路由來改進它。
因此我們需要一個 HTML 模板作為應用的主入口,將其保存在 src/static/index.html
:
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Judo Heroes - A Universal JavaScript demo application with React</title> <link rel="stylesheet" href="/css/style.css"> </head> <body> <div id="main"></div> <script src="/js/bundle.js"></script> </body> </html>
這里沒有什么特別的。只需強調兩件事:
- 需要一個簡單的 "手寫" 樣式表,你可以直接 下載 ,把它保存在
src/static/css/
。 - 引用包含所有前端 JavaScript 代碼的
/js/bundle.js
文件。 之后的文章會介紹如何使用 Webpack 和 Babel 生成該文件, 所以你現在不用擔心。
數據模塊
在一個真實的應用中,我們可能會使用 API 來獲取應用所需的數據。
在這個案例中只有 5 個運動員及其相關信息的很少的數據, 所以可以簡單點,把數據保存在 JavaScript 模塊中。這種方法可以很簡單的在組件或模塊中同步導入數據, 避免增加復雜度以及在通用 JavaScript 項目中管理異步 API 的陷阱, 這也不是這篇文章的目的。
讓我們看一下這模塊:
// src/data/athletes.js const athletes = [ { 'id': 'driulis-gonzalez', 'name': 'Driulis González', 'country': 'cu', 'birth': '1973', 'image': 'driulis-gonzalez.jpg', 'cover': 'driulis-gonzalez-cover.jpg', 'link': 'https://en.wikipedia.org/wiki/Driulis_González', 'medals': [ { 'year': '1992', 'type': 'B', 'city': 'Barcelona', 'event': 'Olympic Games', 'category': '-57kg' }, { 'year': '1993', 'type': 'B', 'city': 'Hamilton', 'event': 'World Championships', 'category': '-57kg' }, { 'year': '1995', 'type': 'G', 'city': 'Chiba', 'event': 'World Championships', 'category': '-57kg' }, { 'year': '1995', 'type': 'G', 'city': 'Mar del Plata', 'event': 'Pan American Games', 'category': '-57kg' }, { 'year': '1996', 'type': 'G', 'city': 'Atlanta', 'event': 'Olympic Games', 'category': '-57kg' }, { 'year': '1997', 'type': 'S', 'city': 'Osaka', 'event': 'World Championships', 'category': '-57kg' }, { 'year': '1999', 'type': 'G', 'city': 'Birmingham', 'event': 'World Championships', 'category': '-57kg' }, { 'year': '2000', 'type': 'S', 'city': 'Sydney', 'event': 'Olympic Games', 'category': '-57kg' }, { 'year': '2003', 'type': 'G', 'city': 'S Domingo', 'event': 'Pan American Games', 'category': '-63kg' }, { 'year': '2003', 'type': 'S', 'city': 'Osaka', 'event': 'World Championships', 'category': '-63kg' }, { 'year': '2004', 'type': 'B', 'city': 'Athens', 'event': 'Olympic Games', 'category': '-63kg' }, { 'year': '2005', 'type': 'B', 'city': 'Cairo', 'event': 'World Championships', 'category': '-63kg' }, { 'year': '2006', 'type': 'G', 'city': 'Cartagena', 'event': 'Central American and Caribbean Games', 'category': '-63kg' }, { 'year': '2006', 'type': 'G', 'city': 'Cartagena', 'event': 'Central American and Caribbean Games', 'category': 'Tema' }, { 'year': '2007', 'type': 'G', 'city': 'Rio de Janeiro', 'event': 'Pan American Games', 'category': '-63kg' }, { 'year': '2007', 'type': 'G', 'city': 'Rio de Janeiro', 'event': 'World Championships', 'category': '-63kg' }, ], }, { // ... } ]; export default athletes;
為簡潔起見這里的文件已被截斷,我們只是展示一個運動員的數據。如果你想看全部的代碼, 在官方倉庫中查看。你可以把文件下載到 src/data/athletes.js
。
如你所見,這個文件包含了一個對象數組。數組中的每個對象代表一個運動員,包含一些通用的信息比如 id
, name
和 country
,另外一個對象數組代表運動員獲得的獎牌。
你可以在倉庫中下載 所有的圖片文件 ,復制到: src/static/img/
。
React 組件
我們將把應用的視圖分成若干個組件:
- 用於創建視圖的一些小的 UI 組件:
AthletePreview
,Flag
,Medal
和AthletesMenu
- 一個
Layout
組件,作為主組件用來定義應用的通用樣式(header, content 和 footer) - 代表主要部分的兩個主組件:
IndexPage
和AthletePage
- 用作 404 頁面的一個額外的 "頁面" 組件:
NotFoundPage
- 使用 React Router 管理視圖間路由的
AppRoutes
組件
Flag 組件
我們將要創建的第一個組件會展示一個漂亮的國旗以及它所代表的國家名:
// src/components/Flag.js import React from 'react'; const data = { 'cu': { 'name': 'Cuba', 'icon': 'flag-cu.png', }, 'fr': { 'name': 'France', 'icon': 'flag-fr.png', }, 'jp': { 'name': 'Japan', 'icon': 'flag-jp.png', }, 'nl': { 'name': 'Netherlands', 'icon': 'flag-nl.png', }, 'uz': { 'name': 'Uzbekistan', 'icon': 'flag-uz.png', } }; export default class Flag extends React.Component { render() { const name = data[this.props.code].name; const icon = data[this.props.code].icon; return ( <span className="flag"> <img className="icon" title={name} src={`/img/${icon}`}/> {this.props.showName && <span className="name"> {name}</span>} </span> ); } }
你可能注意到這個組件使用了一個國家的數組作為數據源。 這樣做是有道理的,因為我們只需要很小的數據。由於是演示應用,所以數據不會變。在真實的擁有巨大以及復雜數據的應用中,你可能會使用 API 或者不同的機制將數據連接到組件。
在這個組件中同樣需要注意的是我們使用了兩個不同的 props, code
和 showName
。第一個是強制性的, 必須傳遞給組件以顯示對應的國旗。 showName
props 是可選的,如果設置為 true ,組件將會在國旗的后面顯示國家名。
如果你想在真實的 app 中創建可重用的組件,你需要添加 props 的驗證及默認值, 但我們省略這一步,因為這不是我們要構建的應用程序的目標。
Medal 組件
Medal
組件與 Flag
組件類似。它接受一些 props,這些屬性代表與獎牌相關的數據: type
(G
表示金牌, S
表示銀牌以及 B
表示銅牌), year
(哪一年贏得), event
(賽事名稱), city
(舉辦比賽的城市)以及 category
(運動員贏得比賽的級別)。
// src/components/Medal.js import React from 'react'; const typeMap = { 'G': 'Gold', 'S': 'Silver', 'B': 'Bronze' }; export default class Medal extends React.Component { render() { return ( <li className="medal"> <span className={`symbol symbol-${this.props.type}`} title={typeMap[this.props.type]}>{this.props.type}</span> <span className="year">{this.props.year}</span> <span className="city"> {this.props.city}</span> <span className="event"> ({this.props.event})</span> <span className="category"> {this.props.category}</span> </li> ); } }
作為前面的組件,我們也使用一個小對象將獎牌類型的代碼映射成描述性名稱。
Athletes Menu 組件
這一步我們將要創建在每個運動員頁面的頂端顯示的菜單,這樣用戶不需要返回首頁就可以很方便的切換運動員:
// src/components/AthletesMenu.js import React from 'react'; import { Link } from 'react-router'; import athletes from '../data/athletes'; export default class AthletesMenu extends React.Component { render() { return ( <nav className="atheletes-menu"> {athletes.map(menuAthlete => { return <Link key={menuAthlete.id} to={`/athlete/${menuAthlete.id}`} activeClassName="active"> {menuAthlete.name} </Link>; })} </nav> ); } }
這個組件非常簡單, 但是有幾個需要注意的地方:
- 我們在組件中直接導入數據模塊,這樣可以在應用中訪問運動員的列表。
- 我們使用
map
方法遍歷所有的運動員,給每個人生成一個Link
。 Link
是 React Router 為了在視圖間生成鏈接所提供的特殊組件。- 最后,我們使用
activeClassName
屬性,當當前路由與鏈接路徑匹配時會添加active
的類。
Athlete Preview 組件
AthletePreview
組件用在首頁顯示運動員的圖片及名稱。來看一下它的代碼:
// src/components/AthletePreview.js import React from 'react'; import { Link } from 'react-router'; export default class AthletePreview extends React.Component { render() { return ( <Link to={`/athlete/${this.props.id}`}> <div className="athlete-preview"> <img src={`img/${this.props.image}`}/> <h2 className="name">{this.props.name}</h2> <span className="medals-count"><img src="/img/medal.png"/> {this.props.medals.length}</span> </div> </Link> ); } }
代碼非常簡單。我們打算接受許多 props 來描述運動員的特征,比如 id
, image
, name
以及 medals
。再次注意我們使用 Link
組件在運動員頁面創建了一個鏈接。
Layout 組件
既然我們已經創建了所有的基本組件,現在我們開始創建那些給應用程序提供視覺結構的組件。 第一個是 Layout
組件, 它的唯一用途就是給整個應用提供展示模板,包括頁頭區、 主內容區以及頁腳區:
// src/components/Layout.js import React from 'react'; import { Link } from 'react-router'; export default class Layout extends React.Component { render() { return ( <div className="app-container"> <header> <Link to="/"> <img className="logo" src="/img/logo-judo-heroes.png"/> </Link> </header> <div className="app-content">{this.props.children}</div> <footer> <p> This is a demo app to showcase universal rendering and routing with <strong>React</strong> and <strong>Express</strong>. </p> </footer> </div> ); } }
組件非常簡單,只需看代碼就能了解它是如何工作的。 我們在這里使用了一個有趣的 props, children
屬性. 這是 React 提供給每個組件的特殊屬性,允許在一個組件中嵌套組件。
我們將在路由的部分看到 React Router 如何在 Layout
組件中嵌套另一個組件。
Index Page 組件
這個組件構成了整個首頁,它包含了之前定義的一些組件:
// src/components/IndexPage.js import React from 'react'; import AthletePreview from './AthletePreview'; import athletes from '../data/athletes'; export default class IndexPage extends React.Component { render() { return ( <div className="home"> <div className="athletes-selector"> {athletes.map(athleteData => <AthletePreview key={athleteData.id} {...athleteData} />)} </div> </div> ); } }
在這個組件中我們需要注意,我們使用了之前定義的 AthletePreview
組件。基本上我們在數據模塊中遍歷所有的運動員, 給每個人創建一個 AthletePreview
組件。因為 AthletePreview
組件的數據是未知的,所以我們需要使用 JSX 擴展操作符 ({...object}
) 來傳遞當前運動員的所有信息。
Athlete Page 組件
我們用同樣的方式創建 AthletePage
組件:
// src/components/AthletePage.js import React from 'react'; import { Link } from 'react-router'; import NotFoundPage from './NotFoundPage'; import AthletesMenu from './AthletesMenu'; import Medal from './Medal'; import Flag from './Flag'; import athletes from '../data/athletes'; export default class AthletePage extends React.Component { render() { const id = this.props.params.id; const athlete = athletes.filter((athlete) => athlete.id === id)[0]; if (!athlete) { return <NotFoundPage/>; } const headerStyle = { backgroundImage: `url(/img/${athlete.cover})` }; return ( <div className="athlete-full"> <AthletesMenu/> <div className="athlete"> <header style={headerStyle}/> <div className="picture-container"> <img src={`/img/${athlete.image}`}/> <h2 className="name">{athlete.name}</h2> </div> <section className="description"> Olympic medalist from <strong><Flag code={athlete.country} showName="true"/></strong>, born in {athlete.birth} (Find out more on <a href={athlete.link} target="_blank">Wikipedia</a>). </section> <section className="medals"> <p>Winner of <strong>{athlete.medals.length}</strong> medals:</p> <ul>{ athlete.medals.map((medal, i) => <Medal key={i} {...medal}/>) }</ul> </section> </div> <div className="navigateBack"> <Link to="/">« Back to the index</Link> </div> </div> ); } }
現在, 你一定可以理解上面的大部分代碼以及如何用其它的組件創建這個視圖。需要強調的是這個頁面組件只能從外部接受運動員的 id, 所以我們引入數據模塊來檢索運動員的相關信息。我們在 render
方法開始之前對數據采用了 filter
函數。我們也考慮了接受的 id 在數據模塊中不存在的情況。這種情況下會渲染 NotFoundPage
組件,我們會在后面的部分創建這個組件。
最后一個重要的細節是我們通過 this.props.params.id
(而不是簡單的 this.props.id
)來訪問 id:當在 Route
中使用組件時, React Router 會創建一個特殊的對象 params
,並且它允許給組件傳遞路由參數。當我們知道如何設置應用的路由部分時,這個概念更容易理解。
Not Found Page 組件
現在讓來看看 NotFoundPage
組件, 它是生成 404 頁面代碼的模板:
// src/components/NotFoundPage.js import React from 'react'; import { Link } from 'react-router'; export default class NotFoundPage extends React.Component { render() { return ( <div className="not-found"> <h1>404</h1> <h2>Page not found!</h2> <p> <Link to="/">Go back to the main page</Link> </p> </div> ); } }
App Routes 組件
我們創建的最后一個組件是 AppRoutes
組件,它是使用 React Router 渲染所有視圖的主要組件。這個組件將使用 routes
模塊,讓我們先睹為快:
// src/routes.js import React from 'react' import { Route, IndexRoute } from 'react-router' import Layout from './components/Layout'; import IndexPage from './components/IndexPage'; import AthletePage from './components/AthletePage'; import NotFoundPage from './components/NotFoundPage'; const routes = ( <Route path="/" component={Layout}> <IndexRoute component={IndexPage}/> <Route path="athlete/:id" component={AthletePage}/> <Route path="*" component={NotFoundPage}/> </Route> ); export default routes;
在這個文件中我們使用 React Router 的 Route
組件將路由映射到之前定義的組件中。注意如何在一個主 Route
組件中嵌套路由。我解釋一下它的原理:
- 跟路由會將
/
路徑映射到Layout
組件。這允許我們在應用程序的每個部分使用自定義的 layout 。在嵌套路由中定義的組件將會代替this.props.children
屬性在Layout
組件中被渲染,我們在之前已經討論過。 - 第一個子路由是
IndexRoute
,這個特殊的路由所定義的組件會在我們瀏覽父路由(/)的索引頁時被渲染。我們將IndexPage
組件作為索引路由。 - 路徑
athlete/:id
被映射為AthletePage
。注意我們使用了命名參數:id
。所以這個路由會匹配所有前綴是/athlete/
的路徑, 余下的部分將關聯參數id
並對應組件中的this.props.params.id
。 - 最后匹配所有的路由
*
會將其它路徑映射到NotFoundPage
組件。這個路由必須被定義為最后一條 。
現在看一下如何在 AppRoutes
組件中通過 React Router 使用路由:
// src/components/AppRoutes.js import React from 'react'; import { Router, browserHistory } from 'react-router'; import routes from '../routes'; export default class AppRoutes extends React.Component { render() { return ( <Router history={browserHistory} routes={routes} onUpdate={() => window.scrollTo(0, 0)}/> ); } }
基本上我們只需導入 Router
組件,然后把它添加到 render
函數中。router 組件會在 router
屬性中接收路由的映射。我們也定義了 history
屬性來指定要使用 HTML5 的瀏覽歷史記錄(as an alternative you could also use hashHistory).
最后我們也添加了 onUpdate
回調函數,它的作用是每當連接被點擊后窗口都會滾動到頂部。
應用程序入口
完成我們的應用程序的首個版本的最后一部分代碼就是編寫在瀏覽器中啟動 app 的 JavaScript 邏輯代碼:
// src/app-client.js import React from 'react'; import ReactDOM from 'react-dom'; import AppRoutes from './components/AppRoutes'; window.onload = () => { ReactDOM.render(<AppRoutes/>, document.getElementById('main')); };
我們在這里唯一要做的就是導入 AppRoutes
組件,然后使用 ReactDOM.render
方法渲染。React app 將會在 #main
DOM 元素中生成。
設置 Webpack 和 Babel
在運行應用之前,我們需要使用 Webpack 生成包含所有 React 組件的 bundle.js
組件。這個文件將會被瀏覽器執行,因此 Webpack 要確保將所有模塊轉換成可以在大多數瀏覽器環境執行的代碼。 Webpack 會把 ES2015 和 React JSX 語法轉換成相等的 ES5 語法(使用 Babel), 這樣就可以在每個瀏覽器中執行。此外, 我們可以使用 Webpack 來優化最終生成的代碼,比如將所有的腳本壓縮合並成一個文件。
來寫一下 webpack 的配置文件:
// webpack.config.js const webpack = require('webpack'); const path = require('path'); module.exports = { entry: path.join(__dirname, 'src', 'app-client.js'), output: { path: path.join(__dirname, 'src', 'static', 'js'), filename: 'bundle.js' }, module: { loaders: [{ test: path.join(__dirname, 'src'), loader: ['babel-loader'], query: { cacheDirectory: 'babel_cache', presets: ['react', 'es2015'] } }] }, plugins: [ new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) }), new webpack.optimize.DedupePlugin(), new webpack.optimize.OccurenceOrderPlugin(), new webpack.optimize.UglifyJsPlugin({ compress: { warnings: false }, mangle: true, sourcemap: false, beautify: false, dead_code: true }) ] };
在配置文件的第一部分,我們定義了文件入口以及輸出路徑。 文件入口是啟動應用的 JavaScript 文件。Webpack 會使用遞歸方法將打包進 bundle 文件的那些包含或導入的資源進行篩選。
module.loaders
部分會對特定文件進行轉化。在這里我們想使用 Babel 的 react
和 es2015
設置將所有引入的 JavaScript 文件轉化成 ES5 代碼。
最后一部分我們使用 plugins
聲明及配置我們想要使用的所有優化插件:
DefinePlugin
允許我們在打包的過程中將NODE_ENV
變量定義為全局變量,和在腳本中定義的一樣。 有些模塊 (比如 React) 會依賴於它啟用或禁用當前環境(產品或開發)的特定功能。DedupePlugin
刪除所有重復的文件 (模塊導入多個模塊).OccurenceOrderPlugin
可以減少打包后文件的體積。UglifyJsPlugin
使用 UglifyJs 壓縮和混淆打包的文件。
現在我們已經准備好生成 bundle 文件,只需運行:
NODE_ENV=production node_modules/.bin/webpack -p
NODE_ENV
環境變量和 -p
選項用於在產品模式下生成 bundle 文件,這會應用一些額外的優化,比如在 React 庫中刪除所有的調試代碼。
如果一切運行正常,你將會在 src/static/js/bundle.js
目錄中看到 bundle 文件。
玩一玩單頁應用
我們已經准備好玩一玩應用程序的第一個版本了!
我們還沒有 Node.js 的 web 服務器,因此現在我們可以使用 http-server
模塊(之前安裝的開發依賴) 運行一個簡單的靜態文件服務器:
node_modules/.bin/http-server src/static
現在你的應用已經可以在 http://localhost:8080 上運行。
好了,現在花些時間玩一玩,點擊所有的鏈接,瀏覽所有的部分。
一切似乎工作正常? 嗯,是的! 只是有一些錯誤警告... 如果你在首頁之外的部分刷新頁面, 服務器會返回 404 錯誤。
解決這個問題的方法有很多。我們會使用通用路由及渲染方案解決這個問題,所以讓我們開始下一部分吧!
使用 Express 搭建服務端路由及渲染
我們現在准備將應用程序升級到下一個版本,並編寫缺少的服務器端部分。
為了具有服務端路由及渲染, 稍后我們將使用 Express 編寫一個相對較小的服務端腳本。
渲染部分將使用 ejs 模板替換 index.html
文件,並保存在 src/views/index.ejs
:
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Judo Heroes - A Universal JavaScript demo application with React</title> <link rel="stylesheet" href="/css/style.css"> </head> <body> <div id="main"><%- markup -%></div> <script src="/js/bundle.js"></script> </body> </html>
與原始 HTML 文件僅有的不同就是我們在 #main
div 元素中使用了模板變量 <%- markup -%>
,為了在服務端生成的 HTML 代碼中包含 React markup 。
現在我們准備寫服務端應用:
// src/server.js import path from 'path'; import { Server } from 'http'; import Express from 'express'; import React from 'react'; import { renderToString } from 'react-dom/server'; import { match, RouterContext } from 'react-router'; import routes from './routes'; import NotFoundPage from './components/NotFoundPage'; // initialize the server and configure support for ejs templates const app = new Express(); const server = new Server(app); app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); // define the folder that will be used for static assets app.use(Express.static(path.join(__dirname, 'static'))); // universal routing and rendering app.get('*', (req, res) => { match( { routes, location: req.url }, (err, redirectLocation, renderProps) => { // in case of error display the error message if (err) { return res.status(500).send(err.message); } // in case of redirect propagate the redirect to the browser if (redirectLocation) { return res.redirect(302, redirectLocation.pathname + redirectLocation.search); } // generate the React markup for the current route let markup; if (renderProps) { // if the current route matched we have renderProps markup = renderToString(<RouterContext {...renderProps}/>); } else { // otherwise we can render a 404 page markup = renderToString(<NotFoundPage/>); res.status(404); } // render the index template with the embedded React markup return res.render('index', { markup }); } ); }); // start the server const port = process.env.PORT || 3000; const env = process.env.NODE_ENV || 'production'; server.listen(port, err => { if (err) { return console.error(err); } console.info(`Server running on http://localhost:${port} [${env}]`); });
代碼添加了注釋, 所以不難理解其中原理。
其中重要的代碼就是使用 app.get('*', (req, res) => {...})
定義的 Express 路由。 這是一個 Express catch-all 路由,它會在服務端將所有的 GET 請求編譯成 URL 。 在這個路由中, 我們使用 React Router match
函數來授權路由邏輯。
ReactRouter.match
接收兩個參數:第一個參數是配置對象,第二個是回調函數。配置對象需要有兩個鍵值:
routes
: 用於傳遞 React Router 的路由配置。在這里,我們傳遞用於服務端渲染的相同配置。location
: 這是用來指定當前請求的 URL 。
回調函數在匹配結束時調用。它接收三個參數, error
, redirectLocation
以及 renderProps
, 我們可以通過這些參數確定匹配的結果。
我們可能有四種需要處理的情況:
- 第一種情況是路由解析中存在錯誤。為了處理這種情況, 我們只是簡單的向瀏覽器返回一個 500 內部服務器錯誤。
- 第二種情況是我們匹配的路由是一個重定向路由。這種情況下,我們需要創建一個服務端重定向信息 (302 重定向) 使瀏覽器跳轉到新的地址 (這種情況在我們的應用中並不會真的發生,因為我們並沒有在 React Router 配置中使用重定向路由, 但是我們要對這一情況做好准備以防升級應用).
- 第三種情況是,當我們匹配一個路由必須渲染相關組件。這種情況下,
renderProps
對象參數包含了我們需要渲染組件的數據。我們需要渲染的組件是RouterContext
(包含在 React Router 模塊中),這就是使用renderProps
中的值渲染整個組件樹的原因。 - 最后一種情況是,當路由不匹配的時候,我們只是簡單的向瀏覽器返回一個 404 未找到的錯誤。
這是服務器端路由機制的核心,我們使用 ReactDOM.renderToString
函數渲染與當前路由匹配的組件的 HTML 代碼。
最后,我們將產生的 HTML 代碼注入到我們之前編寫的 index.ejs
模板中,這樣就可以得到發送到瀏覽器的 HTML 頁面。
現在我們准備好運行 server.js
腳本,但是因為它使用 JSX 語法,所以我們不能簡單的使用 node
編譯器運行。我們需要使用 babel-node
以及如下的完整的命令 (從項目的根文件夾) :
NODE_ENV=production node_modules/.bin/babel-node --presets 'react,es2015' src/server.js
啟動已完成的應用
現在你的應用已經可以在 http://localhost:3000 上運行,因為是教程,項目到此就算完成了。
再次任意地檢查應用,並嘗試所有的部分和鏈接。你會注意到這一次我們可以刷新每一頁並且服務器能夠識別當前路由並呈現正確的頁面。
小建議: 不要忘了輸入一個隨意的不存在的 URL 來檢查 404 頁面!