前言
關於服務端渲染,這是一個老生常談的事情了,已經很有成熟的方案在裡面,最近接觸了一下react的服務端渲染方式,偶有心得,為什麼我要寫這麼一篇文章呢,因為我在研究如何開發的時候,發現網上的資料真的不夠齊全,很多導致我花費更多的時間,所以我希望能夠給予看到我文章的朋友一些幫助
專案github連結:github.com/HuskyToMa/h…
語言描述
ssr: 服務端渲染
前期準備
什麼是ssr
ssr就是伺服器渲染好前端頁面然後推送到前端進行展示的一個過程。
為什麼需要ssr
- 在單頁應用中,是無法很好的支援seo的,所以很多方法是將入口頁面用普通的html編寫然後剩下的用單頁應用的形式,這樣就顯得不是一個整體的專案。
- 服務端請求檔案是直接在本地讀取檔案的,而不是客戶端還需要傳送http請求去獲取檔案內容,所以ssr會比正常的專案速度快一點
- 單頁應用的首屏渲染會有一段空白時間,因為js還在載入的時候,頁面是沒有內容在裡面的,而ssr會直接把dom元素渲染到html裡面返回回來
前端如何做ssr
既然ssr是服務端渲染,那麼肯定是後端進行渲染我們的資料,那不是不能用前端的各種框架了嗎?
其實不然,由於nodejs的出現,打破了這麼一個僵局,nodejs良好的接洽react框架,以至於我們在node中可以直接使用react開發頁面,前後端共用同一套程式碼,通過react的rendertoString方法直接讓react元件轉換成dom字串,然後輸入到html模板中,在通過http response返回就直接渲染到頁面(當然講的簡單,裡面還是有一些東西的,後面慢慢分解)
技術支援
- react:主要庫
- redux:狀態管理庫
- react-router:路由管理庫
- webpack:程式碼編譯
- babel:處理es6+的程式碼
- babel-node:處理nodejs的es6+程式碼
- nodejs:基礎的nodejs知識
- express:nodejs框架
- typescript:js的超集(可以不用)
開始
ssr渲染本身是需要打包兩套程式碼,一個用於伺服器,一套用於客戶端,所以我需要兩套的開發環境以及生成環境
npm init 初始化我們的資料夾
建立檔案目錄
以下是我個人的習慣目錄,可能並不是很正規,看一下即可
- webpack : 包含webpack的配置檔案
- antd.config.js:antd的less配置檔案
- babel.config.js:webpack到時候使用的配置檔案
- webpack.base.conf.js:webpack base檔案
- webpack.dev.conf.js: webpack 客戶端dev檔案
- webpack.devServer.conf.js:webpack 服務端dev檔案
- webpack.pro.conf.js:webpack 客戶端pro檔案
- webpack.proServer.conf.js:webpack 服務端pro檔案
- src : 主要js目錄
- component:元件內容
- pages :入口檔案
- redux :redux入口
- store :生成store的方法
- sass : sass樣式目錄
- app.js : react檔案統一入口
- index.js: 渲染入口
- utils : 主要功能函式
- render.js:主要執行渲染的檔案
- getTpl.js:獲取模板
- build.js:啟動構建的方法
- devServer.js:啟動dev模式的檔案
- server.js:打包構建的服務端內容
- .gitignore:忽略git上傳內容
- .babelrc:node使用的babel配置檔案
- tsconfig.json:ts的配置檔案
複製程式碼
開發環境
需要幾個必要的包:
react , react-dom , react-redux , redux , react-router , react-router-dom
@types/react , @types/react-dom
複製程式碼
通過npm安裝完成之後,我們就能在專案中進行引用了
如果已經按照我剛剛的目錄建立好檔案了,那麼我們可以直接開始配置了。
很詳細的配置內容大家基本都清楚,所以我這裡就新增一些我自己的修改內容。
babel.conf.js
如果不用服務端渲染的話, 我們可以直接使用根目錄中的babelrc檔案,但是這次我們用了服務端渲染,所以我單獨把wenpack打包的檔案拎了出來放在一個js檔案中
很多人會問,webpack中不是隻能用require引用嗎,你這裡寫export能拿到嗎?
這個問題我會在稍後做出解釋
export const clientBabelConfig = {
presets:[
"@babel/preset-env",
"@babel/preset-react"
],// 沒有啥特別變化,正常流程
plugins: [
"@babel/plugin-syntax-dynamic-import",// 支援import(),雖然我最後沒有用
"@babel/plugin-transform-runtime",
"@babel/plugin-proposal-class-properties",
[
"import",
{
"libraryName": "antd",
"libraryDirectory": "es",
"style": true
}
],
],
}
export const serverBabelConfig = {
presets:[
[
"@babel/preset-env",
{
"modules": false,
"targets":{
node: 'current',
}
}
],
"@babel/preset-react"
],
plugins:[
[
"import",
{
"libraryName": "antd",
}
],
"@babel/plugin-proposal-class-properties",
"dynamic-import-node" // node端的import()
]
}
複製程式碼
貌似有一些重複的配置,我當時寫完也就沒有處理掉了,將就一下哈。
設定好babel的內容,就可以開始設定webpack的配置了,這一步相對簡單,畢竟有過webpack配置竟然的人,很快就能夠搭好了,我就只講我這邊覺得需要更改的幾個內容:
webpack.dev.conf.js
- 需要使用 webpack-manifest-plugin 外掛,生成manifest檔案,用於讀取生成的js目錄
- dev環境關掉輸出html的外掛 html-webpack-plugin
webpack.devServer.conf.js
- output中的libraryTarget要設定成commonjs2
- 新增 target:'node'
- entry: './utils/render.js' // 開發環境中,直接將入口定在render就行了,我們只改變render的內容進行渲染
其餘的配置與正常配置基本相近並沒有特別大的區別
devServer.js
由於我們要同時啟動客戶端的程式碼和服務端的程式碼,所以我寫了一個devServer的檔案,內部使用了webpack的nodeAPI來構建環境,以及express來搭建服務端服務
在這檔案裡,其實分成了兩個部分
import webpack from 'webpack';
import middleware from 'webpack-dev-middleware';
import devServerConfig from './webpack/webpack.devSever.conf';
import devClientConfig from './webpack/webpack.dev.conf';
import {ChunkExtractor} from '@loadable/server';
import express from 'express';
import MFS from 'memory-fs';
import path from 'path';
const app = new express();
const fs = new MFS();
const clientCompiler = webpack(devClientConfig);
const serverCompiler = webpack(devServerConfig);
const PORT = 8080;
let renderPath = '';
let render;
// 使用webpack-dev-middleware中介軟體( webpack , devServer用的外掛 )
app.use(middleware(clientCompiler , {
noInfo: true,
serverSideRender: true,
publicPath: devClientConfig.output.publicPath,
}))
// 監聽客戶端內容編譯完成
clientCompiler.hooks.done.tapAsync("done", stats=>{
const info = stats.toJson();
if( stats.hasErrors ) console.log(info.errors);
if( stats.hasWarnings ) console.log(info.warnings);
})
// 修改服務端編譯的輸出方式( memory-fs 輸入到記憶體 )
serverCompiler.outputFileSystem = fs;
serverCompiler.watch({
aggregateTimeout: 300,
poll: 1000,
ignored: /node_modules/,
},(err , stats)=>{
if(err) return console.log(err);
console.log('compiler done');
// 編譯到記憶體的路徑
renderPath = path.join( devServerConfig.output.path , devServerConfig.output.filename );
// 讀取內容並轉成String型別
const content = fs.readFileSync( renderPath , 'utf-8').toString();
// 因為讀取的是js檔案,所以直接執行可以獲取到輸出的內容
// new Function 找不到module 所以改用eval,由於在後端所以避免了風險
render = eval(content).default;
})
// 設定專案的靜態檔案地址
app.use( express.static( devServerConfig.output.path ) );
app.get('/*',(req,res)=>{
// console.log( , '111111');
const manifest = JSON.parse(clientCompiler.outputFileSystem.readFileSync(`${clientCompiler.outputPath}/manifest.json`));
res.send( render( req.url , manifest) );
})
app.listen(PORT,function(){
console.log('啟動成功:localhost:' + PORT);
})
複製程式碼
render檔案內容
import {Provider} from 'react-redux';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import getTpl from './getTpl';
import configureStore from 'STORE'; // store 就是我們react設定的store內容
import isObject from 'isobject';
import routerConfig from 'ROUTER';
// 設定store內容
const store = configureStore();
export default function( url , manifest){
if( !routerConfig[url] ){
return null;
}
const js = manifestJsLoader( manifest );
const css = manifestCssLoader( manifest );
// 獲取當前路由下面的元件
const Component = routerConfig[url].component;
const html = renderToStaticMarkup( <Provider store={store}><Component/></Provider> );
return getTpl( html , css , js )
}
const manifestJsLoader = ( manifest ) => {
return Object.keys(manifest)
.filter(item=>item.endsWith('.js'))
.map(item=>`<script src="${manifest[item]}"></script>`)
.join('\n');
}
const manifestCssLoader = ( manifest ) => {
return Object.keys(manifest)
.filter(item=>item.endsWith('.css'))
.map(item=>`<link rel="stylesheet" href="${manifest[item]}"/>`)
.join('\n');
}
複製程式碼
getTpl.js
就是用來渲染模板的
const getTpl = ( htmlContent , css , js) => {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>create-react-project</title>
<meta charset="UTF-8" />
<meta property="qc:admins" content="1521476575645356367" />
<!-- <meta name="google" value="notranslate" /> -->
<meta http-equiv="pragma" content="no-cache" />
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate" />
<meta http-equiv="Expires" content="0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
${css}
</head>
<body>
<div id="root">${htmlContent}</div>
</body>
${js}
</html>
`
}
export default getTpl;
複製程式碼
很多import中大寫的內容我用了webpack的alisa對應的檔案可以去github上看下,當然,跟redux資料相對應的內容,需要服務端請求完之後,然後寫入getTpl的模板裡面,用script標籤寫一個window的內容,然後客戶端進行重繪的時候,去呼叫window的內容存入store裡面。
寫好dev檔案,基本上pro檔案也已經出來了,配置基本相同沒有啥區別,唯一的區別在於構建的檔案中,webpack的入口我是用server.js 然後生成在dist目錄裡面。
還有重要的一點是,直接在命令列中執行node XXX.js如果內部使用了es6+的語法,那麼是不可以執行的,安裝一個babel-node然後用babel-node替換node來使用,但是最好在構建完之後的執行不要用babel-node,應該會增加額外的內容
希望這篇文章對大家有用處