技术点汇总 注意:
如果用服务器端渲染,一定要让服务器端塞给 React 组件的数据和浏览器端一致。
package.json
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 { ... "devDependencies": { "isomorphic-style-loader": "^5.1.0", // 服务端处理css的插件 "nodemon": "^2.0.4", // 不用每次都重新node 监听文件改动自己重新打包 "npm-run-all": "^4.1.5", // 可同时执行多个npm指令 "babel-core": "^6.26.3", "babel-loader": "^7.1.5", "babel-preset-env": "^1.7.0", "babel-preset-react": "^6.24.1", "babel-preset-stage-0": "^6.24.1", "css-loader": "^3.6.0", "style-loader": "^1.2.1", "webpack": "4.16.0", "webpack-cli": "^3.3.12", "webpack-merge": "^5.1.3", "webpack-node-externals": "^2.5.2" // 服务端webpack配置项externals对应的包,防止将某些import的包(package)打包到bundl.js中, 如cdn引入的JQ之类 }, "dependencies": { "axios": "^0.20.0", "express": "^4.17.1", "express-http-proxy": "^1.6.2", // express代理中间件 "react": "^16.13.1", "react-dom": "^16.13.1", "react-redux": "^7.2.1", "react-router-config": "^5.1.1", // 同构特殊的router模式对应的插件 "react-router-dom": "^5.2.0", "redux": "^4.0.5", "redux-thunk": "^2.3.0" } ... }
两端渲染
服务端渲染
使用renderToString(react element)
返回一段HTML字符串
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 import { renderToString } from 'react-dom/server' ;import express from 'express' ;const app = express();app.use(express.static('public' )); export const render = () => { const content = renderToString( <div> ... </div> ); return (` <html> <head> <title> ssr </ title> </head> <body> <div id="root">${content}</ div> <script src='./index.js' ></script> / / 引入js 客户端第二次渲染 </ body> </html> `); }; app.get('*', (req, res) => { / / 执行渲染 将html返回给客服端, 客户端第一次渲染 ... res.send(render()) }
客户端渲染
使用ReactDom.hydrate
来渲染。
hydrate
由ReactDOMServer
渲染的。 React将尝试将事件监听器附加到现有的标记。
相比较于ReactDom.render
, 前者需要对整个应用做dom diff和dom patch, 花销较大,后者只会对text Content内容做patch。
使用render
,一旦客户端渲染和服务端渲染dom比对有差异,客户端就会整个抛弃服务端渲染结构,重新渲染浏览器端产生的内容,也就造成了闪烁。
1 2 3 4 5 6 7 8 9 10 11 const App = () => { return ( <div> ... </div> ) }; ReactDom.hydrate(<App / >, document .getElementById('root' ));
router路由配置
使用react-router-config
react-router-dom
改用这种方式,是为了服务端数据 的渲染
安装
1 npm i react-router-config react-router-dom --save-dev
配置router json结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import Home from './containers/Home' ;import Login from './containers/Login' ;import App from './App' ;const routers = [ { path: '/' , component: App, key: 'app' , routes: [ { path: '/' , component: Home, exact: true , key: 'home' } ] } ] export default routers
服务端
使用 StaticRouter
react-router-config
插件的renderRoutes
方法 server代码自此拆分为 server/index.js server/utils.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 import React from 'react' ;import { renderToString } from 'react-dom/server' ;import { StaticRouter } from 'react-router-dom' ;import { renderRoutes } from 'react-router-config' ;export const render = (req, routes ) => { const content = renderToString( <StaticRouter location={req.path} context={{}}> <div> {renderRoutes(routes)} </div> </ StaticRouter> ) return (` <html> <head> <title> ssr </title> </head> <body> <div id="root">${content} </div> <script src='./index.js'></script> </body> </html> ` )}
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 import express from 'express' ;import { render } from './utils' ;import routes from '../Routes' ;import { matchRoutes } from 'react-router-config' ;const app = express();app.get('*' , (req, res) => { const matchedRoutes = matchRoutes(routes, req.path); const html = render(req, routes); res.send(html) }); const server = app.listen(3000 );
为何使用react-router-config的matchRoutes? 因为react-router-dom
的matchpath
只能匹配到一层routes,对于嵌套路由处理是乏力的。
redux配合router实现服务端数据渲染(脱水&注水) 安装
1 npm i redux react-redux redux-thunk --save
router json中配置loadData
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 ... const routers = [ { path: '/' , component: App, key: 'app' , routes: [ { path: '/' , component: Home, exact: true , key: 'home' , loadData: Home.loadData } ] } ] ...
业务组件Home代码添加loadData方法,redux方法不做详解,不是重点
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 import React, { Component } from 'react' import { connect } from 'react-redux' ;import { getHomeList } from './store/actions' ;class Home extends Component { componentDidMount() { if (!this .props.list.length) { this .props.getHomeList(); } } getList() { const { list } = this .props; if (list && list.length) { return list.map(item => <div key ={item.id} > {item.title}</div > ); } return null } render() { return ( <div className={style.home}> {this .getList()} <button onClick={() => {alert('click' )}}> click </button> </ div> ) } } const mapStateToProps = state => ({ list: state.home.newsList }) const mapDispatchToProps = dispatch => ({ getHomeList() { dispatch(getHomeList()) } }) Home.loadData = store => { return store.dispatch(getHomeList()) } export default connect(mapStateToProps, mapDispatchToProps)(Home)
server逻辑作出调整,渲染前请求路由中的loadData方法,确保数据请求到在进行后面的渲染步骤
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 import express from 'express' ;import { render } from './utils' ;import routes from '../Routes' ;import { matchRoutes } from 'react-router-config' ;import { getServerStore } from '../stores' ;const app = express();app.get('*' , (req, res) => { const store = getServerStore(); const matchedRoutes = matchRoutes(routes, req.path); const promises = []; matchedRoutes.forEach(item => { if (item.route.loadData) { const promise = new Promise ((resolve, _ ) => { item.route.loadData(store).then(resolve).catch(resolve) }) promises.push(promise) } }); Promise .all(promises).then(_ => { const html = render(req, store, routes); res.send(html) }) }); const server = app.listen(3000 );
在render之前,通过Promise来确保store中的数据获取,同时将获取的数据通过window.context={}形式传递给第二次的客户端渲染,这个过程就是注水。
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 import React from 'react' ;import { renderToString } from 'react-dom/server' ;import { StaticRouter } from 'react-router-dom' ;import { Provider } from 'react-redux' ;import { renderRoutes } from 'react-router-config' ;export const render = (req, store, routes ) => { const content = renderToString( <Provider store={store}> <StaticRouter location={req.path} context={{}}> <div> {renderRoutes(routes)} </div> </ StaticRouter> </Provider> ) return (` <html> <head> <title> ssr </ title> </head> <body> <div id="root">${content}</ div> <script> window .context = { state: ${JSON .stringify(store.getState())} } </script> <script src='./i ndex.js'></script> </body> </html> `) }
客户端获取store时,预读注水数据存储下来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import { createStore, applyMiddleware, combineReducers } from 'redux' ;import thunk from 'redux-thunk' ;import { reducer as headReduce } from '../containers/components/Header/store' ;const reducer = combineReducers({ home: homeReduce }) export const getServerStore = () => createStore(reducer, applyMiddleware(thunk)export const getClientStore = () => { const defaultState = window .context.state; return createStore(reducer, defaultState, applyMiddleware(thunk) }
客户端渲染读取store中预先读取的注水数据,这个过程就是脱水。
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 import React from 'react' ;import { hydrate } from 'react-dom' ;import { BrowserRouter } from 'react-router-dom' ;import routes from '../Routes' ;import { getClientStore } from '../stores' ;import { Provider } from 'react-redux' ;import { renderRoutes } from 'react-router-config' ;const store = getClientStore();const App = () => { return ( <Provider store={store}> <BrowserRouter> <div> {renderRoutes(routes)} </div> </ BrowserRouter> </Provider> ) } hydrate(<App / >, document .getElementById('root' ));
routes json的loadData方法 因为在服务端中react的生命周期止步于componentWillMount, 在业务组件的componentDidMount中请求的数据自然在服务端无法获取,因此需要通过调用loadData方法来主动触发diaptch方法(使用redux)更新数据, 这样服务端就有数据了,同时第二次客户端渲染时候,通过一个条件判断,避免componentDidMount中重复请求数据。
数据脱水和注水 在服务端渲染的时候将获取到的数据赋值一个全局变量(注水),客户端创建的store以这个变量的值作为初始值(脱水)。
404页面配置 router json结构中配置相应页面
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 import Home from './containers/Home' ;import NotFound from './containers/NotFound' import App from './App' ;const routers = [ { path: '/' , component: App, loadData: App.loadData, key: 'app' , routes: [ { path: '/' , component: Home, exact: true , key: 'home' , loadData: Home.loadData }, { component: NotFound } ] } ] export default routers
404页面添加notFound属性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import React from 'react' class NotFound extends React .Component { componentWillMount() { const { staticContext } = this .props; staticContext && (staticContext.notFound = true ); } render() { return ( <p>Page Not Found 404 !!!</p> ) } } export default NotFound
将http状态修改为404
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 ... app.get('*' , (req, res) => { ... Promise .all(promises).then(_ => { const context = {} const html = render(req, store, routes, context); if (context.notFound) { res.status(404 ) } res.send(html) }) ... } ...
配置redirect 301 react-router-config
遇到<Redirect />
会给context添加一些属性
1 2 3 4 5 6 7 8 9 10 11 { action: 'REPLACE' , loaction: { pathname: 'xx' , search: '' , hash: '' , state: undefined }, url: 'xx' }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 ... app.get('*' , (req, res) => { ... Promise .all(promises).then(_ => { const context = {} const html = render(req, store, routes, context); if (context.action === 'REPLACE' ) { res.redirect(301 , context.url) return } if (context.notFound) { res.status(404 ) } res.send(html) }) ... } ...
客户端
css配置
服务端需要使用isomorphic-style-loader
代替css-loader
,因为服务端环境没有dom对象(window & document)
1 npm i isomorphic-style-loader --save
可能遇到的问题汇总 css-loader
版本问题
1 2 ValidationError: Invalid options object. CSS Loader has been initialized using an options object that does not match the API schema. - options has an unknown property 'localIdentName' . These properties are valid:
处理方法
安装3.6.0
1 npm i css-loader@3.6 .0 --save
配置问题
1 2 3 4 Error : Module build failed (from ./node_modules/css-loader/dist/cjs.js):ValidationError: Invalid options object. CSS Loader has been initialized using an options object that does not match the API schema. - options has an unknown property 'localIdentName' . These properties are valid: object { url?, import ?, modules?, sourceMap?, importLoaders?, localsConvention?, onlyLocals?, esModule? }
处理方法
查询文档,判断配置项是否正确, 特别指出服务端如果使用isomorphic-style-loader
,是不支持 localIdentName配置项的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 module : { rules: [ { test: /\.css?$/ , use: ['style-loader' , { loader: 'css-loader' , options: { importLoaders: 1 , modules: { localIdentName: '[name]_[local]_[hash:base64:5]' }, } }] } ] }
静态文件
服务单报错找不到种种静态文件
看是否在服务端有如下配置
1 app.use(express.static('public' ));