服务端渲染react代码页面
首先创建 ssr-react目录,进入ssr-react目录,初始化一个npm项目
mkdir ssr-react
cd ssr-react
npm init -y
在根目录创建src文件夹,在src文件夹下创建server.js
采用node的一个框架 express来写。
首先安装express
yarn add express
接下来 用express写一个最简单的服务
const express = require('express');
const app = express();
const port = process.env.port || 3000;
app.get('*', (req, res) => {
res.writeHead(200,{
'content-type': 'text/html;charset=utf8'
})
res.end('你好ssr')
})
app.listen(port, () => {
console.log('http://localhost:3000')
})
写完以后运行 node src/server.js就能在http://localhost:3000 看到 页面上的输入
因为要做服务端渲染,要在server.js中引入React等前端的包,也就是import,但是 node不认识 import
这个时候我们使用webpack来让node认识import
在根目录创建config文件夹,在config文件夹创建webpack.server.js
const path = require('path')
const webpackExternals = require('webpack-node-externals')
module.exports = {
target: 'node',
mode: process.env.NODE_ENV === 'production' ? 'production': 'development',
entry: path.resolve(__dirname,'../src/server.js'),
output: {
path: path.resolve(__dirname,'../dist'),
filename: 'bundle_server.js'
},
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
exclude: '/node_modules/'
}
]
},
externals: [webpackExternals()] // 不会把node_module中的源码打包
}
这里同时使用了webpack-node-externals这个插件,这个插件功能是 在webpack打包的时候,不打包node_modules里面的源码。
为了在node中适配react和ES6的高级语法,我们需要使用babel来编译,安装babel插件
yarn add @babel/core @babel/preset-env "@babel/preset-react babel-loader
同时在根目录创建.babelrc文件
{
"presets": [
"@babel/preset-react",
"@babel/preset-env"
]
}
接着编写下scripts命令
"scripts": {
"webpack:server": "webpack --config ./config/webpack.server.js --watch",
"webpack:start": "nodemon --watch dist --exec node dist/bundle_server.js",
"dev": "npm-run-all --parallel webpack:*"
},
1.webpack:server 这个命令来打包 入口文件 server.js
2.webpack:start 这个命令来监听打包后的 bundle_server.js
3.dev 这个命令,使用npm-run-all第三方库 来监听所有的命令
接下来开始,写react组件,在node中进行渲染
首先在src目录下创建Home和Person两个组件
// src/pages/Home.js
import React from 'react';
const Home = () => {
return <div>home</div>
}
export default Home;
// src/pages/Person.js
import React from 'react';
const Person = () => {
return <div>Person</div>
}
export default Person;
然后开始编写路由,对应的查找这两个组件
在pages目录下创建routes.js文件
import React from 'react';
import { Routes, Route, Link } from 'react-router-dom'
import Home from './pages/Home';
import Person from './pages/Person';
const RoutesList = () => {
return (
<div>
<ul>
<li>
<Link to='/'>首页</Link>
</li>
<li>
<Link to='/person'>个人中心</Link>
</li>
</ul>
<Routes>
<Route exact path='/' element={<Home />} />
<Route exact path='/person' element={<Person />} />
</Routes>
</div>
)
}
export default RoutesList;
最后在server.js中编写 react代码,能够让react代码在node中渲染
1.react-dom库中有个server库,就是react-dom/server,来专门在node中渲染react
2.在react-router-dom下也有个server库,就是react-router-dom/server,来渲染react路由
首先引入这两个库,以及路由文件
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server'
import Routes from './routes'
然后通过ReactDOMServer中的renderToString来渲染react代码,而路由文件使用StaticRouter进行包裹,
代码如下:
const content = ReactDOMServer.renderToString(
<StaticRouter location={req.url}>
<Routes />
</StaticRouter>
)
最后将 content 写成 html的格式,进行输出
const html = `
<html>
<head></head>
<body>
<div id="root">${content}</div>
</body>
</html>
`
res.writeHead(200,{
'content-type': 'text/html;charset=utf8'
})
res.end(html)
看下现在的效果
当切换的首页的路由时:
当切换到个人中心的路由时:
前端注水:
比如在 Home 组件中 添加一个点击事件
import React from 'react';
const Home = () => {
const handleClick = () => {
console.log('click')
}
return <div>home
<button onClick={handleClick}>点击</button>
</div>
}
export default Home;
当在页面点击的时候,日志没有被打印。
这是因为,Home组件是服务端渲染的,点击事件是在客户端进行的,客户端接收不到 这个点击事件,所以日志没有被打印。
下面通过让客户端 拦截 路由 实现 事件点击
首先在pages下创建client.js
在react-dom中有hydrate可以进行注水,也就是拦截。
通过hydrate进行注水,并且绑定到 id为root的div下面
代码如下:
import React from 'react';
import ReactDom from 'react-dom';
import { BrowserRouter } from 'react-router-dom'
import Routes from './routes';
ReactDom.hydrate(
<BrowserRouter>
<Routes />
</BrowserRouter>,
document.getElementById('#root')
)
这个时候我们需要将这个clent.js文件进行打包
在config目录下创建webpack.client.js,来进行client.js的打包
注意:这个时候需要把webpack-node-externals去掉,因为这个时候是打包的react客户端
const path = require('path')
module.exports = {
target: 'web',
mode: process.env.NODE_ENV === 'production' ? 'production': 'development',
entry: path.resolve(__dirname,'../src/client.js'),
output: {
path: path.resolve(__dirname,'../dist/public'),
filename: 'bundle_client.js'
},
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
exclude: '/node_modules/'
}
]
}
}
然后在scripts中配置下命令
"webpack:client": "webpack --config ./config/webpack.client.js --watch"
最后在输出的html中引入打包后的client.js
const html = `
<html>
<head></head>
<body>
<div id="root">${content}</div>
<script src="bundle_client.js"></script>
</body>
</html>
`
这样重新 打包后,就能在页面上进行点击事件了
看下效果:
初始化 reactStore
使用 react-redux来管理状态
首先安装下redux
yarn add redux react-redux
在src目录下创建store文件夹
在store文件夹下创建index.js来管理store入口
在strore文件夹下创建 actions文件夹,actions文件夹下分别创建 home.js和 person.js来管理这两个的action
在store文件夹下创建reducers文件夹,在reducers文件夹下分别创建home.js和person.js来管理这两个的reducer
首先来写下action
// actions/home.js
export const FETCH_HOME_DATA = 'fetch_home_data';
export const fetchHomeData = async (dispatch) => {
const data = await new Promise((resolve, reject) => {
setTimeout(() => {
resolve({
articles: [
{
id: 1,
title: 'title1',
content: 'content1'
},
{
id: 2,
title: 'title2',
content: 'content2'
}
]
})
},2000)
})
dispatch({
type: FETCH_HOME_DATA,
payload: data
})
}
export const FETCH_PERSON_DATA = 'fetch_person_data';
export const fetchPersonData = async (dispatch) => {
const data = await new Promise((resolve, reject) => {
setTimeout(() => {
resolve({
userInfo: {
username: 'curry',
job: '前端工程师'
}
})
},2000)
})
dispatch({
type: FETCH_PERSON_DATA,
payload: data
})
}
让开始写reducers
// reducers/home.js
import { FETCH_HOME_DATA } from '../actions/home';
const initState = {
articles: []
}
export default (state = initState ,action) => {
switch(action?.type){
case FETCH_HOME_DATA:
return action.payload;
default:
return state;
}
}
// reducers/person.js
import { FETCH_PERSON_DATA } from '../actions/person';
const initState = {
info: {}
}
export default (state = initState ,action) => {
switch(action?.type){
case FETCH_PERSON_DATA:
return action.payload;
default:
return state;
}
}
最后将这两个reducer合并起来
在 reducers/index.js中将两个合并
import { combineReducers } from 'redux'
import homeReducer from './home'
import personReducer from './person'
export default combineReducers({
home: homeReducer,
person: personReducer
})
最后在stroe中引入redux
import { createStore } from 'redux'
import reducer from './reducers'
const store = createStore(reducer)
export default store;
开始使用store
在client.js中使用store
在使用store的时候,需要使用到react-redux提供的Provider,相当于context中的provider,
将Provider包裹住,将store传入Provider,这样的话,才能在组件中接受到store
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux'
import Routes from './routes';
import store from './store'
ReactDOM.hydrate(
<Provider store={store}>
<BrowserRouter>
<Routes />
</BrowserRouter>
</Provider>,
document.querySelector('#root')
);
同时也需要在server.js中引入Provider,并将store传入Provider
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server'
import { Provider } from 'react-redux'
import Routes from './routes'
import store from './store'
const express = require('express');
const app = express();
const port = process.env.port || 3000;
app.use(express.static('dist/public'))
app.get('*', (req, res) => {
const content = ReactDOMServer.renderToString(
<Provider store={store}>
<StaticRouter location={req.url}>
<Routes />
</StaticRouter>
</Provider>
)
const html = `
<html>
<head></head>
<body>
<div id="root">${content}</div>
<script src="bundle_client.js"></script>
</body>
</html>
`
res.writeHead(200,{
'content-type': 'text/html;charset=utf8'
})
res.end(html)
})
app.listen(port, () => {
console.log('http://localhost:3000')
})
reduxThunk中间件
接下来我们在home组件中使用store
我们使用react-redux提供的hooks来使用
引入两个hooks
import { useSelector, useDispatch } from 'react-redux'
使用useDispatch这个hooks来获取dispatch
const dispatch = useDispatch();
使用useSelector这个hooks来获取reducer中的数据
const homeData = useSelector((state) => state.home)
接下来 我们使用 csr的方式 来获取数据
使用useEffect
import { fetchHomeData } from '../store/actions/home'
useEffect(() => {
dispatch(fetchHomeData)
},[])
当我们刷新页面的时候,看到页面有报错
这个报错也提示,需要使用redux-thunk
因为 我们在action中 使用了 异步方式,所以要使用react-thunk来加载异步
首先来安装下redux-thunk
yarn add redux-thunk
redux提供了一个中间件来使用thunk,就是applyMiddleware中间件
最后在store中使用applyMiddleware来包裹这个thunk
import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk';
import reducer from './reducers'
const store = createStore(reducer, applyMiddleware(thunk))
export default store;
这样 页面就不会报错了
我们在home组件中 通过 点击事件,来渲染 异步获取的数据
最后看下效果
使用ssr方式来异步加载数据
首先在routers.js中 写一个 路由配置
export const routesConfig = [
{
path: '/',
component: Home,
},
{
path: '/person',
component: Person
}
]
参照一下next.js中的做法,next.js是提供了一个方法,来获取数据
我们也可以在 组件中 挂载一个方法 ,来获取数据
用Home组件来写
在home组件,因为home是一个函数,所有可以 挂载一个getInitData方法,参数是store,使用方法和csr一样,
通过store.dispatch(fetchHomeData)来获取数据
// home.js
Home.getInitData = async (store) => {
return store.dispatch(fetchHomeData)
}
然后在sever.js中引入
可以通过req获取当前访问的url,然后遍历路由的配置,当 当前访问的url和路由配置的一个匹配的时候,
就执行组件中的getInitData方法,同时传入store参数,这个时候返回的是promise
然后通过Promise.all方法,来执行所有的promise,渲染页面的数据
import Routes, { routesConfig } from './routes'
const url =req.url;
const promises = routesConfig.map(route => {
const component = route.component;
if(route.path === url && component.getInitData){
return component.getInitData(store)
}else{
return null;
}
})
Promise.all(promises).then(() => {
const content = ReactDOMServer.renderToString(
<Provider store={store}>
<StaticRouter location={req.url}>
<Routes />
</StaticRouter>
</Provider>
)
const html = `
<html>
<head></head>
<body>
<div id="root">${content}</div>
<script src="bundle_client.js"></script>
</body>
</html>
`
res.writeHead(200,{
'content-type': 'text/html;charset=utf8'
})
res.end(html)
})
最后看下效果
如下:是通过csr的方式渲染的数据
看下网页源代码:
这个是通过ssr的方式渲染的
因为 客户端不知道服务端已经渲染了数据,所有csr和ssr都渲染了数据。
这个时候来改造下
首先改造下store
这里给createStore传入一个默认的状态
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import reducer from './reducers';
export default function createStoreInstance(preloadedState = {}) {
return createStore(reducer, preloadedState, applyMiddleware(thunk));
}
然后改造server.js
1.首先引入store
2.在执行promise的时候通过store的getState方法,获取到异步获取后的stete,就是preloadedState
3.将preloadedState 注入到全局的变量PRELOAD_STATE中
import createStoreInstance from './store';
const store = createStoreInstance();
Promise.all(promises).then(() => {
const preloadedState = store.getState();
const content = ReactDOMServer.renderToString(
<Provider store={store}>
<StaticRouter location={req.url}>
<Routes />
</StaticRouter>
</Provider>
)
const html = `
<html>
<head></head>
<body>
<div id="root">${content}</div>
<script>
window.__PRELOAD_STATE__=${JSON.stringify(preloadedState)}
</script>
<script src="bundle_client.js"></script>
</body>
</html>
`
res.writeHead(200,{
'content-type': 'text/html;charset=utf8'
})
res.end(html)
})
最后改造client.js
1.引入store
2.使用createStoreInstance方法,参数从全局中获取PRELOAD_STATE,这个时候ssr已经将PRELOAD_STATE的数据注入到了window中,这个时候在csr就可以直接获取数据,存放到store中,
然后将store传入provder
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux'
import Routes from './routes';
// import store from './store'
import createStoreInstance from './store';
const store = createStoreInstance(window?.__PRELOAD_STATE__);
ReactDOM.hydrate(
<Provider store={store}>
<BrowserRouter>
<Routes />
</BrowserRouter>
</Provider>,
document.querySelector('#root')
);
最后看下效果:
页面数据会很快,因为现在是ssr渲染的数据