react 服務端渲染

____啊大發表於2019-05-08

前言

關於服務端渲染,這是一個老生常談的事情了,已經很有成熟的方案在裡面,最近接觸了一下react的服務端渲染方式,偶有心得,為什麼我要寫這麼一篇文章呢,因為我在研究如何開發的時候,發現網上的資料真的不夠齊全,很多導致我花費更多的時間,所以我希望能夠給予看到我文章的朋友一些幫助

專案github連結:github.com/HuskyToMa/h…

語言描述

ssr: 服務端渲染

前期準備

什麼是ssr

ssr就是伺服器渲染好前端頁面然後推送到前端進行展示的一個過程。

為什麼需要ssr

  1. 在單頁應用中,是無法很好的支援seo的,所以很多方法是將入口頁面用普通的html編寫然後剩下的用單頁應用的形式,這樣就顯得不是一個整體的專案。
  2. 服務端請求檔案是直接在本地讀取檔案的,而不是客戶端還需要傳送http請求去獲取檔案內容,所以ssr會比正常的專案速度快一點
  3. 單頁應用的首屏渲染會有一段空白時間,因為js還在載入的時候,頁面是沒有內容在裡面的,而ssr會直接把dom元素渲染到html裡面返回回來

前端如何做ssr

既然ssr是服務端渲染,那麼肯定是後端進行渲染我們的資料,那不是不能用前端的各種框架了嗎?

其實不然,由於nodejs的出現,打破了這麼一個僵局,nodejs良好的接洽react框架,以至於我們在node中可以直接使用react開發頁面,前後端共用同一套程式碼,通過react的rendertoString方法直接讓react元件轉換成dom字串,然後輸入到html模板中,在通過http response返回就直接渲染到頁面(當然講的簡單,裡面還是有一些東西的,後面慢慢分解)

技術支援

  1. react:主要庫
  2. redux:狀態管理庫
  3. react-router:路由管理庫
  4. webpack:程式碼編譯
  5. babel:處理es6+的程式碼
  6. babel-node:處理nodejs的es6+程式碼
  7. nodejs:基礎的nodejs知識
  8. express:nodejs框架
  9. 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

  1. 需要使用 webpack-manifest-plugin 外掛,生成manifest檔案,用於讀取生成的js目錄
  2. dev環境關掉輸出html的外掛 html-webpack-plugin

webpack.devServer.conf.js

  1. output中的libraryTarget要設定成commonjs2
  2. 新增 target:'node'
  3. 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,應該會增加額外的內容


希望這篇文章對大家有用處

相關文章