本項目源碼地址 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
發佈線上環境的配置。