本项目源码地址 github.com/zwmmm/react… 喜欢的给个
star
鼓励下做者,有问题能够提issue
。css
也许你看过其余的ssr教程都会先说一说spa和ssr的区别以及优缺点,可是我相信能点进来看的小伙伴们确定是对这两个概念有过了解的,也无需我在这里多费口舌。不懂的能够直接看这里html
那么咱们就直接进入正题了!!!前端
首先咱们建立一个react-ssr
文件夹, 执行git init
初始化git仓库,添加以下目录和文件。node
.
|-- app
|-- build
|-- server
|-- template
|-- package.json
|-- README.md
|-- .gitignore
复制代码
.gitignore
忽略文件react
node_modules
.cache
.idea
复制代码
npm install --save-dev webpack webpack-cli
复制代码
推荐使用 --save-dev
安装,由于如今webpack版本不少,全局安装不利于各个项目管理。webpack
首先咱们明确下目标,要想运行react的代码,首先将react中的jsx编译成js代码。git
先在app
下建立入口文件main.js
es6
|-- app
| |-- main.js
复制代码
在template
下建立模板文件app.html
github
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>demo</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
复制代码
在build
文件夹中建立utils.js
文件。先写一些公共的方法。web
const path = require('path');
exports.resolve = (...arg) => path.join(__dirname, '..', ...arg);
复制代码
在build
文件夹中建立webpack.base.config.js
文件
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { resolve } = require('./utils');
module.exports = {
entry: resolve('app/main.js'),
output: {
path: resolve('dist'),
filename: 'index.js'
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
// 只编译app文件夹下的文件
include: resolve('app'),
use: {
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-env',
'@babel/preset-react',
],
}
}
},
]
},
resolve: {
// 设置路径别名
alias: {
'@': resolve('app'),
},
// 文件后缀自动补全, 就是你import文件的时候若是没写后缀名就会优先找下面这几个
extensions: [ '.js', '.jsx' ],
},
// 第三方依赖,能够写在这里,不打包
externals: {},
plugins: [
new HtmlWebpackPlugin({
filename: 'index.html',
template: resolve('template/app.html')
})
]
}
复制代码
安装下上面用到的包
npm i -D @babel/cli @babel/core @babel/preset-env @babel/preset-react babel-loader html-webpack-plugin
复制代码
简单说下这几个配置的做用
entry
指定入口output
设置出口并肯定输出的文件名称rules
配置loaderbabel
编译代码,将代码转成浏览器能够运行的代码HtmlWebpackPlugin
自动生成html的插件若是不熟悉babel
的同窗能够看这篇文章,不过我使用了babel7
因此在包名上会有不一样,新版的babel
统一有@babel
前缀
配置好了就须要咱们写点
react
代码测试下啦
首先下载react
相关的资源包
npm i --save react react-dom
复制代码
在app/main.js
编写以下代码
import React from 'react';
import { render } from 'react-dom';
function App() {
return <div>Hello React</div>
}
render(<App/>, document.getElementById('app'));
复制代码
在package.json
中增长一条script
命令
{
"scripts": {
"start": "webpack --config build/webpack.base.config.js"
},
}
复制代码
执行npm start
打开dist/index.html
就能够查看效果,正确状况下会显示Hello React
到此咱们就已经完成咱们的第一阶段,能够编写react
代码
上面咱们说了如何编译react代码,可是在咱们实际开发中不可能每次修改代码都要npm start
,因此在上面的基础上配置一个dev
环境
在配置dev
环境以前先介绍下webpack-dev-server
,这个插件能够在本地启动一个本地服务,而且提供了很是丰富的功能,例如热更新,接口代理。首先咱们安装下
npm i -D webpack-dev-server
复制代码
在build
下新建webpack.dev.config.js
const merge = require('webpack-merge');
const webpack = require('webpack');
const baseConfig = require('./webpack.base.config');
module.exports = merge(baseConfig, {
// 用于调试, inline-source-map模式效率比较高, 因此在dev模式下推荐使用这个
devtool: 'inline-source-map',
mode: 'development',
// 设置dev服务器
devServer: {
// 设置端口号,默认8080
port: 8000,
},
plugins: [
// 在js中注入全局变量process.env用来区分环境
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify('development'),
}
}),
],
})
复制代码
安装下webpack-merge
npm i -D webpack-merge
复制代码
简单说下上面的配置
webpack-merge
复用以前的配置devServer
process.env
全局变量区分环境最后咱们在修改下启动命令
{
"scripts": {
"start": "webpack-dev-server --hot --config build/webpack.dev.config.js"
},
}
复制代码
如今咱们执行下npm start
浏览器打开localhost:8000
访问,并尝试修改main.js
中的react
代码,不刷新浏览器是否会自动更新
如今咱们的webpack
已经能够支持简单的开发了,可是这还远远不够,在编写前端代码时,咱们还会接触到css
、image
、等其余文件的使用,因此须要增强下webpack
的配置
module: {
rules: [
{
test: /\.(js|jsx)$/,
// 只编译app文件夹下的文件
include: resolve('app'),
use: {
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-env',
'@babel/preset-react',
],
}
}
},
+ {
+ test: /\.html$/,
+ include: resolve('app'),
+ loader: 'html-loader'
+ },
+ {
+ test: /\.less/,
+ include: resolve('app'),
+ use: [
+ 'style-loader',
+ 'css-loader',
+ 'less-loader'
+ ]
+ },
+ {
+ test: /\.(png|jpg|gif|svg)$/,
+ loader: `url-loader?limit=1000`
+ },
+ {
+ test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
+ loader: `file-loader`
+ },
+ ]
},
复制代码
下载须要的loader
以及less
npm i -D html-loader style-loader css-loader less-loader url-loader file-loader less
复制代码
通过下面的配置咱们就能够在代码中作以下的操做
import img from './xxx.png'
import 'xxx.less'
import html from 'xxx.html'
复制代码
那么接下来咱们就给咱们的react
丰富一下代码
首先在app
文件夹下新建style
static
文件夹分别存放css
文件和静态资源,
新增index.less
和 timg.png
#app {
text-align: center;
color: deepskyblue;
}
.logo {
width: 500px;
}
复制代码
而后修改main.js
import React from 'react';
import { render } from 'react-dom';
import './style/index.less';
import logo from './static/timg.jpg'
function App() {
return <div> <h1>Hello React !!!</h1> <img src={ logo } className="logo"/> </div> } render(<App/>, document.getElementById('app')); 复制代码
最终的效果
这里可能会有同窗会有一个疑问, 图片为何直接使用
<img src="./static/time.png" className="logo"/>
这样引入?其实很好解释,咱们的网站是访问的webpack-dev-server
启动的服务,若是没有使用import
引入图片,则在服务器中就不会存在这个图片。而import
图片的时候 首先会找到对应的图片资源存到服务器上, 而且生成一个文件路径供咱们访问。
react
的部分咱们先告一段落,后面还会继续说到react-router
redux
,接下来咱们说下服务端,也算是正式讲点ssr
的东西
首先在这里提一嘴,ssr
和普通的spa
页面最大的区别在于,咱们是直接将完整的html
返回给浏览器的。
话很少说,直接开工!!!
先下载koa
npm i -S koa
复制代码
建立server/app.js
文件
const Koa = require('koa');
const app = new Koa();
app.use(ctx => {
ctx.body = '<div>Hello Koa<div/>'
})
app.listen(9000, () => {
console.log(`node服务已经启动, 请访问localhost:9000`)
})
复制代码
添加一条script
命令
"server": "node server/app.js"
复制代码
运行npm run server
并访问localhost:9000
这时候就能够看到Hello Koa
,其实这就是一个最基本的直出服务,如今让咱们想想,若是代码能够写成这样
app.use(ctx => {
- ctx.body = '<div>Hello Koa<div/>'
+ ctx.body = <App/>
})
复制代码
直接返回一个react
组件,那不就是咱们要的react ssr
?
固然上面的代码直接这么执行确定是会报错,不过react
给咱们提供了renderToString
方法,将组件转成字符串。这样咱们就能够实现渲染组件了!!!
来,咱们改良下上面的代码,让node
支持jsx
语法
先建立server/index.js
,使用@babel/register
在node运行时候编译咱们的jsx
代码以及es6
语法
安装@babel/register
npm i -S @babel/register
复制代码
require('@babel/register')({
presets: [
'@babel/preset-react',
'@babel/preset-env'
],
});
require('./app.js');
复制代码
修改script
命令
- "server": "node server/app.js"
+ "server": "node server/index.js"
复制代码
重构app.js
由于前面使用了babel
编译了代码,因此可使用es6
的模块化
// jsx编译以后会用到React对象, 因此须要引入
import React from 'react';
import Koa from 'koa';
import { renderToString } from "react-dom/server";
const app = new Koa();
const App = () => <div>Hello Koa SSR</div>
app.use(ctx => {
ctx.body = renderToString(<App/>);
})
app.listen(9000, () => {
console.log(`node服务已经启动, 请访问localhost:9000`)
})
复制代码
如今咱们已经完成了最简单的react ssr
,下一步咱们将加上路由,实现对应的路由显示对应的组件
看完上面的章节,大伙是否是想说,ssr是实现了,可是好像和我得前端部分并无关联起来啊,我在前端写的组件应该怎么在
Node
中去使用呢?下面我在路由这个篇章就会将前端和Node
关联起来说,让你们知道页面究竟是怎么渲染出来的。
在开始讲以前我仍是得先和你们说说传统的spa
页面路由是怎么配置的,下面就以history
模式为例
首先咱们从浏览器输入url
,无论你的url是匹配的哪一个路由,后端通通都给你index.html
,而后加载js
匹配对应的路由组件,渲染对应的路由。
那咱们的ssr
路由是怎么样的模式呢?
首先咱们从浏览器输入url
,后端匹配对应的路由获取到对应的路由组件,获取对应的数据填充路由组件,将组件转成html
返回给浏览器,浏览器直接渲染。当这个时候若是你在页面中点击跳转,咱们依旧仍是不会发送请求,由js
匹配对应的路由渲染
文字看懵的咱们直接看图
因此咱们须要同时配置前端路由以及后端路由
那一步步来,咱们先配置前端路由,前端路由使用react-router
,若是不会使用react-router
的同窗能够看下我写的这篇入门文章
下载react-router
npm i -S react-router-dom
复制代码
新建app/router.js
import { Link, Switch, Route } from 'react-router-dom';
import React from 'react';
const Home = () => (
<div> <h1>首页</h1> <Link to="/list">跳转列表页</Link> </div>
)
const list = [
'react真好玩',
'koa有点意思',
'ssr更有意思'
]
const List = () => (
<ul> { list.map((item, i) => <li key={ i }>{ item }</li>) } </ul>
)
export default () => (
<Switch>
<Route exact path="/" component={ Home }/>
<Route exact path="/list" component={ List }/>
</Switch>
)
复制代码
修改main.js
import React from 'react';
import { render } from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import Router from './router'
render(
<BrowserRouter> <Router/> </BrowserRouter>,
document.getElementById('app')
);
复制代码
执行npm start
访问localhost:8000
ok,前端路由就这么简单的配置好了,如今若是你跳转到列表页,而后刷新页面就会提示404
这是由于咱们的dev-server
没有匹配上对应的路由,那么接下来咱们就来配置服务端路由来解决这个问题,而且实现ssr
服务端路由咱们使用koa-router
先下载 npm i -S koa-router
新建server/router/index.js
import Router from 'koa-router';
import RouterConfig from '../../app/router';
import { StaticRouter } from 'react-router-dom';
import { renderToString } from "react-dom/server";
import React from 'react';
const routes = new Router();
routes.get('/', (ctx, next) => {
ctx.body = renderToString(
<StaticRouter location={ctx.url}> <RouterConfig/> </StaticRouter>
)
next();
})
routes.get('/list', (ctx, next) => {
ctx.body = renderToString(
<StaticRouter location={ctx.url}> <RouterConfig/> </StaticRouter>
)
next();
})
export default routes;
复制代码
一下看不懂不要紧,听我来解释
首先咱们用koa-router
注册了/
/list
两个路由,而且使用renderToString
将组件转成html
。
那这个StaticRouter
是干吗的呢?和BrowserRouter
有什么区别?其实很简单,在浏览器上咱们可使用js
获取到location
,可是在node
环境却获取不到,因此react-router
提供了StaticRouter
来让咱们本身设置location
。
如今你也许会有另一个疑问,这两个路由设置写的代码不是都同样的么,为何还要去区分路由?这是应为在生成
html
以前咱们还须要获取对应的数据,因此必需要分开。后面我会继续讲ssr
如何处理数据
接下来咱们改造下app.js
import Koa from 'koa';
import routes from './router';
const app = new Koa();
app.use(routes.routes(), routes.allowedMethods());
app.listen(9000, () => {
console.log(`node服务已经启动, 请访问localhost:9000`)
})
复制代码
启动npm run server
访问localhost:9000
如今咱们的localhost:9000
localhost:8000
均可以浏览了,正好大家能够对比下两种渲染方式。
ok,心细的朋友可能发现了localhost:9000
下的页面点击跳转是刷新页面的,并非单页面跳转。这是由于咱们返回的html里面根本就没有携带js
,因此跳转路由固然是直接发生跳转了啊,而且返回的html
也是不完整的,如今咱们就给咱们的内容添加一个html
模板
新建模板template/server.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>36氪_让一部分人先看到将来</title>
<link href="//36kr.com/favicon.ico" rel="shortcut icon" type="image/vnd.microsoft.icon">
</head>
<body>
<div id="app">{{ html }}</div>
<script src="http://localhost:8000/index.js"></script>
</body>
</html>
复制代码
这里咱们加载localhost:8000
服务下的inedx.js
,其实你能够吧webpack-dev-server
想象成静态资源服务器了,这样咱们的静态资源在你的开发阶段就能够实时更新。
而后咱们给ctx
对象扩展一个render
方法,用来渲染html
import fs from 'fs';
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'
import RouterConfig from '../app/router'
import React from 'react';
import path from 'path';
// 匹配模板中的{{}}
function templating(props) {
const template = fs.readFileSync(path.join(__dirname, '../template/server.html'), 'utf-8');
return template.replace(/{{([\s\S]*?)}}/g, (_, key) => props[ key.trim() ]);
}
export default function(ctx, next) {
try {
ctx.render = () => {
const html = renderToString(
<StaticRouter location={ ctx.url }> <RouterConfig/> </StaticRouter>
);
const body = templating({
html
});
ctx.body = body;
}
}
catch (err) {
ctx.body = templating({ html: err.message });
}
ctx.type = 'text/html';
// 这里必须是return next() 否则异步路由是404
return next();
}
复制代码
而后在app.js
中加载上面写的中间件
import Koa from 'koa';
import routes from './router';
+ import templating from './templating'
const app = new Koa();
+ app.use(templating);
app.use(routes.routes(), routes.allowedMethods());
app.listen(9000, () => {
console.log(`node服务已经启动, 请访问localhost:9000`)
})
复制代码
最后咱们来改造下路由
import Router from 'koa-router';
import React from 'react';
const routes = new Router();
routes.get('/', (ctx, next) => {
ctx.render();
next();
})
routes.get('/list', (ctx, next) => {
ctx.render();
next();
})
export default routes;
复制代码
重启你的localhost:9000
看看如今跳转list
是否是就不会再刷新页面了。
到这里咱们的路由就算配置完成了。相信你们对ssr
也有必定的了解了,可是还不够,目前咱们渲染的都是静态页面,也就是写死的,而实际业务确定是根据数据渲染出来的,以前的spa
页面咱们会在组件中去发送请求获取数据渲染,但咱们的ssr
确定不能这样作,因此得在生成html
这一步获取数据,那数据又该怎么传进组件内呢?以及先后端数据怎么作到同步呢?下一个章节咱们就讲讲ssr
的数据请求
react
中操做数据无非两种方式state
和props
,咱们在node
中确定是没办法给组件设置state
的,因此只能经过props
传进去,而且咱们的数据还要作到先后端同步,否则你就光渲染出了html
,数据没给前端这样也不行啊。而redux
恰好知足这两点需求。
既然要用redux
那就得先从前端开始了啊,不熟悉redux
的朋友建议先了解下基本概念
下载npm i redux react-redux -S
新建目录
|-- app
| |-- redux
| | |-- reducers
| | |-- store
复制代码
先建立reducers
// reducers/home.js
const defaultState = {
title: 'Hello Redux'
}
export default function(state = defaultState , action) {
switch (action.type) {
default:
return state
}
}
复制代码
// reducers/list.js
const defaultState = {
list: [
'react真好玩',
'koa有点意思',
'ssr更有意思'
]
}
export default function(state = defaultState , action) {
switch (action.type) {
default:
return state
}
}
复制代码
合并reducers
// reducers/index.js
import home from './home';
import list from './list';
import { combineReducers } from 'redux';
// 其实就是把分散的reducers给合并了
export default combineReducers({
home,
list,
})
复制代码
接下来建立store
import { createStore } from 'redux';
import reducers from '../reducers';
/** * 为何写成函数? * 由于咱们在前端和后端都须要去进行初始化store因此这里封装一个工厂函数 * @param data * @returns {*} */
export default data => createStore(reducers, data);
复制代码
而后将store
注入到组件中
// main.js
+ import { Provider } from 'react-redux';
+ import createStore from './redux/store/create';
+ const store = createStore();
render(
+ <Provider store={store}>
<BrowserRouter>
<Router/>
</BrowserRouter>
+ </Provider>,
document.getElementById('app')
);
复制代码
将page
从路由中抽离出来
// pages/home.js
import { Link } from 'react-router-dom';
import React from 'react';
import { connect } from 'react-redux';
const Home = props => (
<div> <h1>{ props.title }</h1> <Link to="/list">跳转列表页</Link> </div>
)
/** * 经过connect将redux中的数据传递进入组件 */
function mapStateTpProps(state) {
return { ...state.home };
}
export default connect(mapStateTpProps)(Home)
复制代码
// pages/list.js
import React from 'react';
import { connect } from 'react-redux';
const List = props => (
<ul> { props.list.map((item, i) => <li key={ i }>{ item }</li>) } </ul>
)
/** * 经过connect将redux中的数据传递进入组件 */
function mapStateTpProps(state) {
return { ...state.list };
}
export default connect(mapStateTpProps)(List)
复制代码
最后修改下路由
import { Switch, Route } from 'react-router-dom';
import React from 'react';
import Home from './pages/home';
import List from './pages/list';
export default () => (
<Switch>
<Route exact path="/" component={ Home }/>
<Route exact path="/list" component={ List }/>
</Switch>
)
复制代码
好了,最基本的redux
已经完成,如今咱们已经将数据从组件内部提取到了redux
来管理,接下来咱们实如今node
中填充数据。
其实这一步很是简单,只要修改下templating
就能够,直接看代码
import fs from 'fs';
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'
import RouterConfig from '../app/router'
import React from 'react';
import path from 'path';
+ import { Provider } from 'react-redux';
+ import createStore from '../app/redux/store/create';
// 匹配模板中的{{}}
function templating(props) {
const template = fs.readFileSync(path.join(__dirname, '../template/server.html'), 'utf-8');
return template.replace(/{{([\s\S]*?)}}/g, (_, key) => props[ key.trim() ]);
}
export default function(ctx, next) {
try {
+ ctx.render = (data = {}) => {
+ const store = createStore(data);
const html = renderToString(
+ <Provider store={ store }>
<StaticRouter location={ ctx.url }>
<RouterConfig/>
</StaticRouter>
+ </Provider>
);
const body = templating({
html
});
ctx.body = body;
}
}
catch (err) {
ctx.body = templating({ html: err.message });
}
ctx.type = 'text/html';
// 这里必须是return next() 否则异步路由是404
return next();
}
复制代码
而后咱们在调用ctx.render
的时候将数据当作参数传入就能够了
import Router from 'koa-router';
import React from 'react';
const routes = new Router();
routes.get('/', (ctx, next) => {
ctx.render({
home: {
title: '我是从node中获取的数据'
}
});
next();
})
routes.get('/list', (ctx, next) => {
ctx.render({
list: {
list: [
'我是从node中获取的数据',
'感受还不错',
'测试成功',
]
}
});
next();
})
export default routes;
复制代码
重启npm run server
刷新下localhost:9000
看看效果
诶,不对啊,是否是看到了,页面一开始是正确的,而后又被从新覆盖了?这是由于咱们加载了index.js
他又从新初始化store
,因此会产生这样的问题。
那怎么解决?还记得刚开始说的先后端数据同步么?只要我把node用到的数据传给前端,前端基于这个数据去初始化store
这样不就能够了?
怎么把数据传给前端?很简单,直接把store注入到window
上就行。
先修改下咱们的模板server.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>36氪_让一部分人先看到将来</title>
<link href="//36kr.com/favicon.ico" rel="shortcut icon" type="image/vnd.microsoft.icon">
</head>
<body>
<div id="app">{{ html }}</div>
+ <script>
+ window.__STORE__ = {{ store }}
+ </script>
<script src="http://localhost:8000/index.js"></script>
</body>
</html>
复制代码
改下templating
ctx.render = (data = {}) => {
const store = createStore(data);
const html = renderToString(
<Provider store={ store }>
<StaticRouter location={ ctx.url }>
<RouterConfig/>
</StaticRouter>
</Provider>
);
const body = templating({
html,
+ store: JSON.stringify(data, null, 4),
});
ctx.body = body;
}
复制代码
最后前端获取store
+ const defaultStore = window.__STORE__ || {}
- const store = createStore();
+ const store = createStore(defaultStore);
render(
<Provider store={store}>
<BrowserRouter>
<Router/>
</BrowserRouter>
</Provider>,
document.getElementById('app')
);
复制代码
重启npm run server
刷新下localhost:9000
是否是完美了
最后补充一点关于
api
请求的点
由于一个页面多是由node
直出的,也有多是js加载的
,因此咱们还须要在每一个组件的componentDidMount
中去分析有没有事先注入过store,来判断是否须要请求,以下面的伪代码。
componentDidMount() {
const { news, fetchHome } = this.props;
news.length || fetchHome();
}
复制代码
其实到这里咱们的
ssr
实现原理已经讲完了,接下来的章节我会带你们完成一个36kr
的案例,想本身动手直接开撸的同窗也能够直接看个人react-ssr-36kr源码,那若是你对redux
以及koa
不是很熟悉的同窗则能够继续看个人下篇文章,下篇文章会带你们进行实战开发以及build
发布线上环境的配置。