传送门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