服务端渲染react代码页面

首先创建 ssr-react目录,进入ssr-react目录,初始化一个npm项目

  1. mkdir ssr-react
  2. cd ssr-react
  3. npm init -y

在根目录创建src文件夹,在src文件夹下创建server.js
采用node的一个框架 express来写。

首先安装express

  1. yarn add express

接下来 用express写一个最简单的服务

  1. const express = require('express');
  2. const app = express();
  3. const port = process.env.port || 3000;
  4. app.get('*', (req, res) => {
  5. res.writeHead(200,{
  6. 'content-type': 'text/html;charset=utf8'
  7. })
  8. res.end('你好ssr')
  9. })
  10. app.listen(port, () => {
  11. console.log('http://localhost:3000')
  12. })

写完以后运行 node src/server.js就能在http://localhost:3000 看到 页面上的输入

因为要做服务端渲染,要在server.js中引入React等前端的包,也就是import,但是 node不认识 import
这个时候我们使用webpack来让node认识import

在根目录创建config文件夹,在config文件夹创建webpack.server.js

  1. const path = require('path')
  2. const webpackExternals = require('webpack-node-externals')
  3. module.exports = {
  4. target: 'node',
  5. mode: process.env.NODE_ENV === 'production' ? 'production': 'development',
  6. entry: path.resolve(__dirname,'../src/server.js'),
  7. output: {
  8. path: path.resolve(__dirname,'../dist'),
  9. filename: 'bundle_server.js'
  10. },
  11. module: {
  12. rules: [
  13. {
  14. test: /\.js$/,
  15. loader: 'babel-loader',
  16. exclude: '/node_modules/'
  17. }
  18. ]
  19. },
  20. externals: [webpackExternals()] // 不会把node_module中的源码打包
  21. }

这里同时使用了webpack-node-externals这个插件,这个插件功能是 在webpack打包的时候,不打包node_modules里面的源码。

为了在node中适配react和ES6的高级语法,我们需要使用babel来编译,安装babel插件

  1. yarn add @babel/core @babel/preset-env "@babel/preset-react babel-loader

同时在根目录创建.babelrc文件

  1. {
  2. "presets": [
  3. "@babel/preset-react",
  4. "@babel/preset-env"
  5. ]
  6. }

接着编写下scripts命令

  1. "scripts": {
  2. "webpack:server": "webpack --config ./config/webpack.server.js --watch",
  3. "webpack:start": "nodemon --watch dist --exec node dist/bundle_server.js",
  4. "dev": "npm-run-all --parallel webpack:*"
  5. },

1.webpack:server 这个命令来打包 入口文件 server.js
2.webpack:start 这个命令来监听打包后的 bundle_server.js
3.dev 这个命令,使用npm-run-all第三方库 来监听所有的命令

接下来开始,写react组件,在node中进行渲染

首先在src目录下创建Home和Person两个组件

  1. // src/pages/Home.js
  2. import React from 'react';
  3. const Home = () => {
  4. return <div>home</div>
  5. }
  6. export default Home;
  1. // src/pages/Person.js
  2. import React from 'react';
  3. const Person = () => {
  4. return <div>Person</div>
  5. }
  6. export default Person;

然后开始编写路由,对应的查找这两个组件
在pages目录下创建routes.js文件

  1. import React from 'react';
  2. import { Routes, Route, Link } from 'react-router-dom'
  3. import Home from './pages/Home';
  4. import Person from './pages/Person';
  5. const RoutesList = () => {
  6. return (
  7. <div>
  8. <ul>
  9. <li>
  10. <Link to='/'>首页</Link>
  11. </li>
  12. <li>
  13. <Link to='/person'>个人中心</Link>
  14. </li>
  15. </ul>
  16. <Routes>
  17. <Route exact path='/' element={<Home />} />
  18. <Route exact path='/person' element={<Person />} />
  19. </Routes>
  20. </div>
  21. )
  22. }
  23. 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路由

首先引入这两个库,以及路由文件

  1. import React from 'react';
  2. import ReactDOMServer from 'react-dom/server';
  3. import { StaticRouter } from 'react-router-dom/server'
  4. import Routes from './routes'

然后通过ReactDOMServer中的renderToString来渲染react代码,而路由文件使用StaticRouter进行包裹,
代码如下:

  1. const content = ReactDOMServer.renderToString(
  2. <StaticRouter location={req.url}>
  3. <Routes />
  4. </StaticRouter>
  5. )

最后将 content 写成 html的格式,进行输出

  1. const html = `
  2. <html>
  3. <head></head>
  4. <body>
  5. <div id="root">${content}</div>
  6. </body>
  7. </html>
  8. `
  9. res.writeHead(200,{
  10. 'content-type': 'text/html;charset=utf8'
  11. })
  12. res.end(html)

看下现在的效果
当切换的首页的路由时:
image.png

当切换到个人中心的路由时:
image.png

前端注水:

比如在 Home 组件中 添加一个点击事件

  1. import React from 'react';
  2. const Home = () => {
  3. const handleClick = () => {
  4. console.log('click')
  5. }
  6. return <div>home
  7. <button onClick={handleClick}>点击</button>
  8. </div>
  9. }
  10. export default Home;

当在页面点击的时候,日志没有被打印。
这是因为,Home组件是服务端渲染的,点击事件是在客户端进行的,客户端接收不到 这个点击事件,所以日志没有被打印。

下面通过让客户端 拦截 路由 实现 事件点击
首先在pages下创建client.js

在react-dom中有hydrate可以进行注水,也就是拦截。

通过hydrate进行注水,并且绑定到 id为root的div下面

代码如下:

  1. import React from 'react';
  2. import ReactDom from 'react-dom';
  3. import { BrowserRouter } from 'react-router-dom'
  4. import Routes from './routes';
  5. ReactDom.hydrate(
  6. <BrowserRouter>
  7. <Routes />
  8. </BrowserRouter>,
  9. document.getElementById('#root')
  10. )

这个时候我们需要将这个clent.js文件进行打包

在config目录下创建webpack.client.js,来进行client.js的打包

注意:这个时候需要把webpack-node-externals去掉,因为这个时候是打包的react客户端

  1. const path = require('path')
  2. module.exports = {
  3. target: 'web',
  4. mode: process.env.NODE_ENV === 'production' ? 'production': 'development',
  5. entry: path.resolve(__dirname,'../src/client.js'),
  6. output: {
  7. path: path.resolve(__dirname,'../dist/public'),
  8. filename: 'bundle_client.js'
  9. },
  10. module: {
  11. rules: [
  12. {
  13. test: /\.js$/,
  14. loader: 'babel-loader',
  15. exclude: '/node_modules/'
  16. }
  17. ]
  18. }
  19. }

然后在scripts中配置下命令

  1. "webpack:client": "webpack --config ./config/webpack.client.js --watch"

最后在输出的html中引入打包后的client.js

  1. const html = `
  2. <html>
  3. <head></head>
  4. <body>
  5. <div id="root">${content}</div>
  6. <script src="bundle_client.js"></script>
  7. </body>
  8. </html>
  9. `

这样重新 打包后,就能在页面上进行点击事件了

看下效果:
image.png

初始化 reactStore

使用 react-redux来管理状态

首先安装下redux

  1. 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

  1. // actions/home.js
  2. export const FETCH_HOME_DATA = 'fetch_home_data';
  3. export const fetchHomeData = async (dispatch) => {
  4. const data = await new Promise((resolve, reject) => {
  5. setTimeout(() => {
  6. resolve({
  7. articles: [
  8. {
  9. id: 1,
  10. title: 'title1',
  11. content: 'content1'
  12. },
  13. {
  14. id: 2,
  15. title: 'title2',
  16. content: 'content2'
  17. }
  18. ]
  19. })
  20. },2000)
  21. })
  22. dispatch({
  23. type: FETCH_HOME_DATA,
  24. payload: data
  25. })
  26. }
  1. export const FETCH_PERSON_DATA = 'fetch_person_data';
  2. export const fetchPersonData = async (dispatch) => {
  3. const data = await new Promise((resolve, reject) => {
  4. setTimeout(() => {
  5. resolve({
  6. userInfo: {
  7. username: 'curry',
  8. job: '前端工程师'
  9. }
  10. })
  11. },2000)
  12. })
  13. dispatch({
  14. type: FETCH_PERSON_DATA,
  15. payload: data
  16. })
  17. }

让开始写reducers

  1. // reducers/home.js
  2. import { FETCH_HOME_DATA } from '../actions/home';
  3. const initState = {
  4. articles: []
  5. }
  6. export default (state = initState ,action) => {
  7. switch(action?.type){
  8. case FETCH_HOME_DATA:
  9. return action.payload;
  10. default:
  11. return state;
  12. }
  13. }
  1. // reducers/person.js
  2. import { FETCH_PERSON_DATA } from '../actions/person';
  3. const initState = {
  4. info: {}
  5. }
  6. export default (state = initState ,action) => {
  7. switch(action?.type){
  8. case FETCH_PERSON_DATA:
  9. return action.payload;
  10. default:
  11. return state;
  12. }
  13. }

最后将这两个reducer合并起来

在 reducers/index.js中将两个合并

  1. import { combineReducers } from 'redux'
  2. import homeReducer from './home'
  3. import personReducer from './person'
  4. export default combineReducers({
  5. home: homeReducer,
  6. person: personReducer
  7. })

最后在stroe中引入redux

  1. import { createStore } from 'redux'
  2. import reducer from './reducers'
  3. const store = createStore(reducer)
  4. export default store;

开始使用store
在client.js中使用store
在使用store的时候,需要使用到react-redux提供的Provider,相当于context中的provider,
将Provider包裹住,将store传入Provider,这样的话,才能在组件中接受到store

  1. import React from 'react';
  2. import ReactDOM from 'react-dom';
  3. import { BrowserRouter } from 'react-router-dom';
  4. import { Provider } from 'react-redux'
  5. import Routes from './routes';
  6. import store from './store'
  7. ReactDOM.hydrate(
  8. <Provider store={store}>
  9. <BrowserRouter>
  10. <Routes />
  11. </BrowserRouter>
  12. </Provider>,
  13. document.querySelector('#root')
  14. );

同时也需要在server.js中引入Provider,并将store传入Provider

  1. import React from 'react';
  2. import ReactDOMServer from 'react-dom/server';
  3. import { StaticRouter } from 'react-router-dom/server'
  4. import { Provider } from 'react-redux'
  5. import Routes from './routes'
  6. import store from './store'
  7. const express = require('express');
  8. const app = express();
  9. const port = process.env.port || 3000;
  10. app.use(express.static('dist/public'))
  11. app.get('*', (req, res) => {
  12. const content = ReactDOMServer.renderToString(
  13. <Provider store={store}>
  14. <StaticRouter location={req.url}>
  15. <Routes />
  16. </StaticRouter>
  17. </Provider>
  18. )
  19. const html = `
  20. <html>
  21. <head></head>
  22. <body>
  23. <div id="root">${content}</div>
  24. <script src="bundle_client.js"></script>
  25. </body>
  26. </html>
  27. `
  28. res.writeHead(200,{
  29. 'content-type': 'text/html;charset=utf8'
  30. })
  31. res.end(html)
  32. })
  33. app.listen(port, () => {
  34. console.log('http://localhost:3000')
  35. })

reduxThunk中间件

接下来我们在home组件中使用store

我们使用react-redux提供的hooks来使用
引入两个hooks

  1. import { useSelector, useDispatch } from 'react-redux'

使用useDispatch这个hooks来获取dispatch

  1. const dispatch = useDispatch();

使用useSelector这个hooks来获取reducer中的数据

  1. const homeData = useSelector((state) => state.home)

接下来 我们使用 csr的方式 来获取数据
使用useEffect

  1. import { fetchHomeData } from '../store/actions/home'
  2. useEffect(() => {
  3. dispatch(fetchHomeData)
  4. },[])

当我们刷新页面的时候,看到页面有报错
image.png

这个报错也提示,需要使用redux-thunk
因为 我们在action中 使用了 异步方式,所以要使用react-thunk来加载异步

首先来安装下redux-thunk

  1. yarn add redux-thunk

redux提供了一个中间件来使用thunk,就是applyMiddleware中间件

最后在store中使用applyMiddleware来包裹这个thunk

  1. import { createStore, applyMiddleware } from 'redux'
  2. import thunk from 'redux-thunk';
  3. import reducer from './reducers'
  4. const store = createStore(reducer, applyMiddleware(thunk))
  5. export default store;

这样 页面就不会报错了

我们在home组件中 通过 点击事件,来渲染 异步获取的数据

最后看下效果
image.png

使用ssr方式来异步加载数据

首先在routers.js中 写一个 路由配置

  1. export const routesConfig = [
  2. {
  3. path: '/',
  4. component: Home,
  5. },
  6. {
  7. path: '/person',
  8. component: Person
  9. }
  10. ]

参照一下next.js中的做法,next.js是提供了一个方法,来获取数据

我们也可以在 组件中 挂载一个方法 ,来获取数据

用Home组件来写

在home组件,因为home是一个函数,所有可以 挂载一个getInitData方法,参数是store,使用方法和csr一样,
通过store.dispatch(fetchHomeData)来获取数据

  1. // home.js
  2. Home.getInitData = async (store) => {
  3. return store.dispatch(fetchHomeData)
  4. }

然后在sever.js中引入

可以通过req获取当前访问的url,然后遍历路由的配置,当 当前访问的url和路由配置的一个匹配的时候,
就执行组件中的getInitData方法,同时传入store参数,这个时候返回的是promise
然后通过Promise.all方法,来执行所有的promise,渲染页面的数据

  1. import Routes, { routesConfig } from './routes'
  2. const url =req.url;
  3. const promises = routesConfig.map(route => {
  4. const component = route.component;
  5. if(route.path === url && component.getInitData){
  6. return component.getInitData(store)
  7. }else{
  8. return null;
  9. }
  10. })
  11. Promise.all(promises).then(() => {
  12. const content = ReactDOMServer.renderToString(
  13. <Provider store={store}>
  14. <StaticRouter location={req.url}>
  15. <Routes />
  16. </StaticRouter>
  17. </Provider>
  18. )
  19. const html = `
  20. <html>
  21. <head></head>
  22. <body>
  23. <div id="root">${content}</div>
  24. <script src="bundle_client.js"></script>
  25. </body>
  26. </html>
  27. `
  28. res.writeHead(200,{
  29. 'content-type': 'text/html;charset=utf8'
  30. })
  31. res.end(html)
  32. })

最后看下效果

如下:是通过csr的方式渲染的数据
image.png

看下网页源代码:
这个是通过ssr的方式渲染的
image.png

因为 客户端不知道服务端已经渲染了数据,所有csr和ssr都渲染了数据。

这个时候来改造下

首先改造下store
这里给createStore传入一个默认的状态

  1. import { createStore, applyMiddleware } from 'redux';
  2. import thunk from 'redux-thunk';
  3. import reducer from './reducers';
  4. export default function createStoreInstance(preloadedState = {}) {
  5. return createStore(reducer, preloadedState, applyMiddleware(thunk));
  6. }

然后改造server.js
1.首先引入store
2.在执行promise的时候通过store的getState方法,获取到异步获取后的stete,就是preloadedState
3.将preloadedState 注入到全局的变量PRELOAD_STATE

  1. import createStoreInstance from './store';
  2. const store = createStoreInstance();
  3. Promise.all(promises).then(() => {
  4. const preloadedState = store.getState();
  5. const content = ReactDOMServer.renderToString(
  6. <Provider store={store}>
  7. <StaticRouter location={req.url}>
  8. <Routes />
  9. </StaticRouter>
  10. </Provider>
  11. )
  12. const html = `
  13. <html>
  14. <head></head>
  15. <body>
  16. <div id="root">${content}</div>
  17. <script>
  18. window.__PRELOAD_STATE__=${JSON.stringify(preloadedState)}
  19. </script>
  20. <script src="bundle_client.js"></script>
  21. </body>
  22. </html>
  23. `
  24. res.writeHead(200,{
  25. 'content-type': 'text/html;charset=utf8'
  26. })
  27. res.end(html)
  28. })

最后改造client.js
1.引入store
2.使用createStoreInstance方法,参数从全局中获取PRELOAD_STATE,这个时候ssr已经将PRELOAD_STATE的数据注入到了window中,这个时候在csr就可以直接获取数据,存放到store中,
然后将store传入provder

  1. import React from 'react';
  2. import ReactDOM from 'react-dom';
  3. import { BrowserRouter } from 'react-router-dom';
  4. import { Provider } from 'react-redux'
  5. import Routes from './routes';
  6. // import store from './store'
  7. import createStoreInstance from './store';
  8. const store = createStoreInstance(window?.__PRELOAD_STATE__);
  9. ReactDOM.hydrate(
  10. <Provider store={store}>
  11. <BrowserRouter>
  12. <Routes />
  13. </BrowserRouter>
  14. </Provider>,
  15. document.querySelector('#root')
  16. );

最后看下效果:
页面数据会很快,因为现在是ssr渲染的数据
image.png
image.png