傳送門javascript
同構這名詞你們應該都已經很熟悉了, 具體是怎麼一回事我就很少作解釋了, 萬一說的不對怕被大佬噴.php
本文旨在給你們介紹一下如何使用Node+react+react-router4實現服務端渲染.css
首先咱們來新建工程, 肯定目錄結構, 因爲同構項目包括服務端渲染和客戶端渲染兩部分, 而且它倆渲染時用的是同一套代碼, 因此目錄結構以下圖所示: html
import React from 'react';
import { hydrate } from 'react-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import App from '../shared/App';
hydrate(
<Router> <App /> </Router>,
document.getElementById('root')
);
複製代碼
其實, 只需關注不一樣點, 咱們平時使用是ReactDOM.render, 而這裏使用ReactDOM.hydrate, 官方解釋是該api和咱們平時使用是ReactDOM.render是同樣同樣的, 可是當container的HTML內容是由ReactDOMServer渲染, 那麼咱們須要調用hydrate, 它會嘗試將事件綁定在已渲染的dom上.vue
import express from "express"
import cors from "cors"
import React from "react"
import { renderToString } from "react-dom/server"
import { StaticRouter, matchPath } from "react-router-dom"
import serialize from "serialize-javascript"
import App from '../shared/App'
import routes from '../shared/routes'
const fs = require('fs');
const path = require('path');
// 讀取html模板
const template = fs.readFileSync(path.resolve(process.cwd(), 'public/index.html'), 'utf-8');
const app = express()
app.use(cors())
app.use(express.static("public"))
app.get("*", (req, res, next) => {
// 找到當前請求的url對應的route配置項
const activeRoute = routes.find((route) => matchPath(req.url, route)) || {}
// 若是路由配置項有fetchInitialData, 那就請求數據
const promise = activeRoute.fetchInitialData
? activeRoute.fetchInitialData(req.path)
: Promise.resolve()
promise.then((data) => {
// 在ssr中, 子路由可經過訪問this.props.staticContext拿到數據
const context = { data }
// 在server中, 須要使用StaticRouter
const markup = renderToString(
<StaticRouter location={req.url} context={context}> <App /> </StaticRouter>
)
// window.__INITIAL_DATA__, 即是客戶端的初始數據
// 讀取html模板 + 佔位符替換的方式更優雅
res.send(
template
.replace('<!-- SCRIPT_PLACEHOLDER -->', `<script>window.__INITIAL_DATA__ = ${serialize(data)}</script>`)
.replace('<!-- HTML_PLACEHOLDER -->', markup)
);
}).catch(next)
})
app.listen(3000, () => {
console.log(`Server is listening on port: 3000`)
})
複製代碼
接下來說的劃重點, 要考哦:java
服務端渲染調用的是StaticRouter 不一樣於客戶端渲染調用BrowserRouter.官方解釋大概是一個永遠不會改變location的router, 緣由是由於只有當咱們第一次在瀏覽器中輸入地址按下回車鍵訪問頁面時發起的請求才會通過服務端, 從第二次開始, 每次瀏覽器地址發生改變, 因爲客戶端使用BrowserRouter, 因此之後的每次請求都只是經過history.pushState/placeState記錄, 並不會向服務端發起請求. StaticRouter接受2個參數, location只需傳req.url, context屬性用來向子組件傳遞數據, 在StaticRouter下聲明的每一個子route都會接收到props.staticContext, 就像咱們平時經常使用的props.match, props.location.node
聲明式路由, 根據頁面獲取數據並生成相應html字符串 在實際場景中, 用戶可能會訪問不一樣的頁面, 也就是對應不一樣的route, 而有些頁面須要初始化數據, 而有些頁面不須要初始化數據. 在客戶端渲染中, 你們都已經很熟悉, 只需在componentDidMount中發起請求獲取數據便可, 可是在服務端渲染中, 咱們須要作的是, 先根據頁面去獲取數據, 而後使用這些數據去渲染html字符串並返回給客戶端, 因此這裏咱們須要用到聲明式路由. shared/routes.jsreact
import Home from './Home';
import Grid from './Grid';
import { fetchPopularRepos } from '../shared/api';
const routes = [
{
path: '/',
exact: true,
component: Home,
},
{
path: '/popular/:id',
exact: true,
component: Grid,
fetchInitialData: (path = '') => fetchPopularRepos(path.split('/').pop()),
}
]
export default routes;
複製代碼
shared/App.jswebpack
import React, { Component } from 'react';
import { Switch, Route } from 'react-router-dom';
import routes from './routes';
import NoMatch from './NoMatch';
import NavBar from './NavBar';
export default class App extends Component {
render() {
return (
<div>
<NavBar />
<Switch>
{ // render(props) {} props中有staticContext屬性
routes.map(({ path, exact, component: Component, ...rest }) => (
<Route key={path} path={path} exact={exact} render={(props) => (
<Component {...props} {...rest} />
)} />
))
}
<Route render={(props) => <NoMatch {...props} /> } />
</Switch>
</div>
);
}
}
複製代碼
在routes.js中, 我在數組中寫入每一個頁面對應的配置, 仔細觀察咱們會發現有的配置中有fetchInitialData, 這個字段就是用來聲明咱們獲取初始化數據的方法. 固然字段名你們可根據本身命名習慣隨意聲明. 接着讓咱們來看看App.js, 在這頁面中會根據routes配置來生成, 可是這裏有一個很關鍵的點, 與前文的StaticRouter的context呼應, 在Route中渲染我特地使用了render函數, 它接收的props的屬性中就包含staticContext, mact, location等重要數據.ios
服務端根據頁面請求數據, 生成html字符串 在這一步咱們會用到React-Router的另外一個api, macthPath, 咱們經過調用routes.find((route) => matchPath(req.url, route))獲取到當前請求對應的route配置, 隨之即可判斷是否有fetchInitialData方法來判斷是否須要獲取初始化數據, 而後經過context將數據傳遞給StaticRouter下的子組件. 相信你們必定都看到函數最後調用了res.send, 它就是用來給客戶端返回html字符串的.
在返回的html內容中經過script給客戶端寫入全局數據 先簡單介紹一下, 在服務端中頁面能夠經過StaticRouter對應的staticContext, 是拿到數據, 那麼客戶端又該怎麼拿到數據呢, 答案即是window.INITIAL_DATA 這一段代碼中我使用了一個庫'serialize-javascript', 它能夠防止xss攻擊, 固然重點是window.INITIAL_DATA, 這裏咱們先買個伏筆哈, 一會去客戶端渲染部分講解.
<script>window.__INITIAL_DATA__ = ${serialize(data)}</script>,
複製代碼
import React, { Component } from 'react';
import './Popular.less';
class Popular extends Component {
constructor(props) {
super(props);
let repos;
if (__isBrowser__) {
// 若是是客戶端, 則讀取window.__INITIAL_DATA__
repos = window.__INITIAL_DATA__;
delete window.__INITIAL_DATA__;
} else {
// 若是是服務端, 則讀取staticContext.data
repos = this.props.staticContext.data;
}
this.state = {
repos,
loading: !Array.isArray(repos) || !repos.length,
};
}
componentDidMount() {
if (!Array.isArray(this.state.repos) || !this.state.repos.length) {
this.fetchRepos(this.props.match.params.id);
}
}
componentWillReceiveProps(nextProps) {
if (nextProps.match.params.id !== this.props.match.params.id ) {
this.fetchRepos(this.props.match.params.id);
}
}
fetchRepos = (lang) => {
this.setState({loading: true});
this.props
.fetchInitialData(lang)
.then(data => {
this.setState({ loading: false, repos: data });
}).catch(() => {
this.setState({loading: false});
})
}
render() {
const { repos, loading } = this.state
if (loading) {
return <div>LOADING</div>;
}
return (
<ul style={{display: 'flex', flexWrap: 'wrap'}}> {repos.map(({ name, owner, stargazers_count, html_url }) => ( <li key={name} style={{margin: 30}}> <ul> <li><a href={html_url}>{name}</a></li> <li>@{owner.login}</li> <li className="star">{stargazers_count} stars</li> </ul> </li> ))} </ul>
)
}
}
export default Popular;
複製代碼
第一步: 咱們先是ReactDOM/Server.renderToString -> StaticRouter訪問Popular.js, 在這一步中咱們經過this.props.staticContext.data獲取初始數據, 而後經過html模板佔位符替換的方式返回給客戶端html
第二步: 客戶端解析html, 渲染首屏, 並去加載html中的script並解析. 這時候問題來了, 當客戶端執行Popular.js時, 因爲它的執行環境已是瀏覽器, 而且路由是BrowserRouter, 因此經過this.props.staticContext.data時頁面會報錯, 緣由是客戶端中props.staticContext爲undefined. 因此這時候, 就用到了前文所講的window.INITIAL_DATA 咱們經過script將服務端獲取的初始數據注入到全局變量中, 而後根據__isBrowser__字段來判斷當前代碼的執行環境, 來生成初始state.須要強調的是, 在這裏咱們用完即刪, 不能污染全局變量 ps: 咱們可經過webpack.DefinePlugin注入__isBrowser__變量
第三步: 加工componentDidMount, 由於訪問該頁面分爲2中狀況, 一種是用戶直接在瀏覽器中輸入該頁面地址, 這時候會通過服務端渲染+瀏覽器加載解析的整個過程, 因此頁面能拿到初始數據, 那麼咱們天然不須要重複請求數據. 第二種狀況是咱們經過React-Router api跳轉到該頁面, 這時候就沒有初始數據裏, 須要客戶端從新請求數據.
1. 怎麼處理css/less/scss 因爲Node環境並不支持解析樣式文件, 因此打包時會報錯. 這裏有2種解決方案
1️⃣ 用isomorphic-style-loader替代style-loader, 你們能夠去了解一下寫法, 和css-module差很少, 可能會與部分團隊的規範產生衝突, 因此不建議
2️⃣ 在webpack配置文件中, 對serverConfig添加rules, 針對node環境忽略樣式解析
{
test: /\.(less|css|scss)$/,
use: 'ignore-loader'
}
複製代碼
2. 在服務端返回的html字符串不建議手動拼寫, 而是採用html模板+佔位符, 在server/index.js中去讀取html文件, 而後替換佔位符的方式. 由於實際開發中一般會作分包處理, 提取出帶有hash的vendors等文件, 咱們會用到'html-webpack-plugin'來幫咱們自動引入, 因此我推薦html模板, 並且使用''佔位符即語義化, 又符合規範.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>SSR</title>
</head>
<body>
<div id="root">
<!-- HTML_PLACEHOLDER -->
<!-- SCRIPT_PLACEHOLDER -->
</div>
</body>
</html>
複製代碼
3. 在serverConfig中添加externals配置, 爲了避免把node_modules下的第三方模塊打包進包裏, 這裏用到了webpack-node-externals插件
4. 由於在服務端渲染時, 會執行componentDidMount以前的生命週期, 因此也不免會用到window, document等瀏覽器中才有的全局變量, 因此這裏咱們須要加點hack
if (typeof global.window === 'undefined') {
global.window = {};
}
複製代碼
5. 將fetch或者ajax發送請求改爲isomorphic-fetch或者axios