一文喫透 React SSR 服務端渲染和同構原理

推薦下我自己的小冊 React SSR 服務端渲染原理解析與實踐

全網最完整的 React SSR 同構技術原理解析與實踐,從零開始手把手帶你打造自己的同構應用開發骨架,幫助大家徹底深入理解服務端渲染及底層實現原理,學完本課程,你也可以打造自己的同構框架。


寫在前面

前段時間一直在研究react ssr技術,然後寫了一個完整的ssr開發骨架。今天寫文,主要是把我的研究成果的精華內容整理落地,另外通過再次梳理希望發現更多優化的地方,也希望可以讓更多的人少踩一些坑,讓跟多的人理解和掌握這個技術。

相信看過本文(前提是能對你的胃口,也能較好的消化吸收)你一定會對 react ssr 服務端渲染技術有一個深入的理解,可以打造自己的腳手架,更可以用來改造自己的實際項目,當然這不僅限於 react ,其他框架都一樣,畢竟原理都是相似的。

爲什麼要服務端渲染(ssr)

至於爲什麼要服務端渲染,我相信大家都有所聞,而且每個人都能說出幾點來。

首屏等待

在 SPA 模式下,所有的數據請求和 Dom 渲染都在瀏覽器端完成,所以當我們第一次訪問頁面的時候很可能會存在“白屏”等待,而服務端渲染所有數據請求和 html內容已在服務端處理完成,瀏覽器收到的是完整的 html 內容,可以更快的看到渲染內容,在服務端完成數據請求肯定是要比在瀏覽器端效率要高的多。

沒考慮SEO的感受

有些網站的流量來源主要還是靠搜索引擎,所以網站的 SEO 還是很重要的,而 SPA 模式對搜索引擎不夠友好,要想徹底解決這個問題只能採用服務端直出。改變不了別人(搜索yinqing),只能改變自己。

SSR + SPA 體驗升級

只實現 SSR 其實沒啥意義,技術上沒有任何發展和進步,否則 SPA 技術就不會出現。

但是單純的 SPA又不夠完美,所以最好的方案就是這兩種體驗和技術的結合,第一次訪問頁面是服務端渲染,基於第一次訪問後續的交互就是 SPA 的效果和體驗,還不影響SEO 效果,這就有點完美了。

單純實現 ssr 很簡單,畢竟這是傳統技術,也不分語言,隨便用 php 、jsp、asp、node 等都可以實現。

但是要實現兩種技術的結合,同時可以最大限度的重用代碼(同構),減少開發維護成本,那就需要採用 react 或者 vue 等前端框架相結合 node (ssr) 來實現。

本文主要說 React SSR 技術 ,當然 vue 也一樣,只是技術棧不同而已。

核心原理

整體來說 react 服務端渲染原理不復雜,其中最核心的內容就是同構。

node server 接收客戶端請求,得到當前的req url path,然後在已有的路由表內查找到對應的組件,拿到需要請求的數據,將數據作爲 props
context或者store 形式傳入組件,然後基於 react 內置的服務端渲染api renderToString() or renderToNodeStream() 把組件渲染爲 html字符串或者 stream 流, 在把最終的 html 進行輸出前需要將數據注入到瀏覽器端(注水),server 輸出(response)後瀏覽器端可以得到數據(脫水),瀏覽器開始進行渲染和節點對比,然後執行組件的componentDidMount 完成組件內事件綁定和一些交互,瀏覽器重用了服務端輸出的 html 節點,整個流程結束。

技術點確實不少,但更多的是架構和工程層面的,需要把各個知識點進行鏈接和整合。

這裏放一個架構圖

react ssr

從 ejs 開始

實現 ssr 很簡單,先看一個 node ejs的栗子。

// index.html
<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <meta http-equiv="X-UA-Compatible" content="ie=edge">
   <title>react ssr <%= title %></title>
</head>
<body>
   <%=  data %>
</body>
</html>
 //node ssr
 const ejs = require('ejs');
 const http = require('http');

http.createServer((req, res) => {
    if (req.url === '/') {
        res.writeHead(200, {
            'Content-Type': 'text/html' 
        });
        // 渲染文件 index.ejs
        ejs.renderFile('./views/index.ejs', {
            title: 'react ssr', 
            data: '首頁'}, 
            (err, data) => {
            if (err ) {
                console.log(err);
            } else {
                res.end(data);
            }
        })
    }
}).listen(8080);

jsx 到字符串

上面我們結合 ejs模板引擎 ,實現了一個服務端渲染的輸出,html 和 數據直接輸出到客戶端。

參考以上,我們結合 react組件 來實現服務端渲染直出,使用 jsx 來代替 ejs,之前是在 html 裏使用 ejs 來綁定數據,現在改寫成使用jsx 來綁定數據,使用 react 內置 api 來把組件渲染爲 html 字符串,其他沒有差別。

爲什麼react 組件可以被轉換爲 html字符串呢?

簡單的說我們寫的 jsx 看上去就像在寫 html(其實寫的是對象) 標籤,其實經過編譯後都會轉換成React.createElement方法,最終會被轉換成一個對象(虛擬DOM),而且和平臺無關,有了這個對象,想轉換成什麼那就看心情了。

const  React  = require('react');

const { renderToString}  = require( 'react-dom/server');

const http = require('http');

//組件
class Index extends React.Component{
    constructor(props){
        super(props);
    }

    render(){
        return <h1>{this.props.data.title}</h1>
    }
}
 
//模擬數據的獲取
const fetch = function () {
    return {
        title:'react ssr',
        data:[]
    }
}

//服務
http.createServer((req, res) => {
    if (req.url === '/') {
        res.writeHead(200, {
            'Content-Type': 'text/html'
        });

        const data = fetch();

        const html = renderToString(<Index data={data}/>);
        res.end(html);
    }
}).listen(8080);

ps:以上代碼不能直接運行,需要結合babel 使用 @babel/preset-react 進行轉換

 
 npx babel script.js --out-file script-compiled.js --presets=@babel/preset-react
 

引出問題

在上面非常簡單的就是實現了 react ssr ,把jsx作爲模板引擎,不要小看上面的一小段代碼,他可以幫我們引出一系列的問題,這也是完整實現 react ssr 的基石。

  • 雙端路由如何維護?

首先我們會發現我在 server 端定義了路由 '/',但是在 react SPA 模式下我們需要使用react-router來定義路由。那是不是就需要維護兩套路由呢?

  • 獲取數據的方法和邏輯寫在哪裏?

發現數據獲取的fetch 寫的獨立的方法,和組件沒有任何關聯,我們更希望的是每個路由都有自己的 fetch 方法。

  • 服務端 html 節點無法重用

雖然組件在服務端得到了數據,也能渲染到瀏覽器內,但是當瀏覽器端進行組件渲染的時候直出的內容會一閃而過消失。

好了,問題有了,接下來我們就一步一步的來解決這些問題。

同構纔是核心

react ssr 的核心就是同構,沒有同構的 ssr 是沒有意義的。

所謂同構就是採用一套代碼,構建雙端(server 和 client)邏輯,最大限度的重用代碼,不用維護兩套代碼。而傳統的服務端渲染是無法做到的,react 的出現打破了這個瓶頸,並且現在已經得到了比較廣泛的應用。

路由同構

雙端使用同一套路由規則,node server 通過req url path 進行組件的查找,得到需要渲染的組件。

//組件和路由配置 ,供雙端使用 routes-config.js



class Detail extends React.Component{

    render(){
        return <div>detail</div>
    }
}

class Index extends React.Component {

    render() {
        return <div>index</div>
    }
}


const routes = [
  
            {
                path: "/",
                exact: true,
                component: Home
            },
            {
                path: '/detail', exact: true,
                component:Detail,
            },
            {
                path: '/detail/:a/:b', exact: true,
                component: Detail
            }
         
];

//導出路由表
export default routes;

//客戶端 路由組件

import routes from './routes-config.js';

function App(){
    return (
        <Layout>
            <Switch>

                        {
                            routes.map((item,index)=>{
                                return <Route path={item.path} key={index} exact={item.exact} render={item.component}></Route>
                            })
                        }
            </Switch>
        </Layout>
    );
}

export default App;

node server 進行組件查找

路由匹配其實就是對 組件path 規則的匹配,如果規則不復雜可以自己寫,如果情況很多種還是使用官方提供的庫來完成。

matchRoutes(routes, pathname)

//引入官方庫
import { matchRoutes } from "react-router-config";
import routes from './routes-config.js';

const path = req.path;

const branch = matchRoutes(routes, path);

//得到要渲染的組件
const Component = branch[0].route.component;
 

//node server 
http.createServer((req, res) => {
    
        const url = req.url;
        //簡單容錯,排除圖片等資源文件的請求
        if(url.indexOf('.')>-1) { res.end(''); return false;}

        res.writeHead(200, {
            'Content-Type': 'text/html'
        });
        const data = fetch();

        //查找組件
        const branch =  matchRoutes(routes,url);
        
        //得到組件
        const Component = branch[0].route.component;

        //將組件渲染爲 html 字符串
        const html = renderToString(<Component data={data}/>);

        res.end(html);
        
 }).listen(8080);

可以看下matchRoutes方法的返回值,其中route.component 就是 要渲染的組件


[
    { 
    
    route:
        { path: '/detail', exact: true, component: [Function: Detail] },
    match:
        { path: '/detail', url: '/detail', isExact: true, params: {} } 
        
    }
   ]

react-router-config 這個庫由react 官方維護,功能是實現嵌套路由的查找,代碼沒有多少,有興趣可以看看。

文章走到這裏,相信你已經知道了路由同構,所以上面的第一個問題 : 【雙端路由如何維護?】 解決了。

數據同構(預取同構)

這裏開始解決我們最開始發現的第二個問題 - 【獲取數據的方法和邏輯寫在哪裏?】

數據預取同構,解決雙端如何使用同一套數據請求方法來進行數據請求。

先說下流程,在查找到要渲染的組件後,需要預先得到此組件所需要的數據,然後將數據傳遞給組件後,再進行組件的渲染。

我們可以通過給組件定義靜態方法來處理,組件內定義異步數據請求的方法也合情合理,同時聲明爲靜態(static),在 server 端和組件內都也可以直接通過組件(function) 來進行訪問。

比如 Index.getInitialProps


//組件
class Index extends React.Component{
    constructor(props){
        super(props);
    }

    //數據預取方法  靜態 異步 方法
    static async  getInitialProps(opt) {
        const fetch1 =await fetch('/xxx.com/a');
        const fetch2 = await fetch('/xxx.com/b');

        return {
            res:[fetch1,fetch2]
        }
    }

    render(){
        return <h1>{this.props.data.title}</h1>
    }
}


//node server 
http.createServer((req, res) => {
    
        const url = req.url;
        if(url.indexOf('.')>-1) { res.end(''); return false;}

        res.writeHead(200, {
            'Content-Type': 'text/html'
        });
        
        //組件查找
        const branch =  matchRoutes(routes,url);
        
        //得到組件
        const Component = branch[0].route.component;
    
        //數據預取
        const data = Component.getInitialProps(branch[0].match.params);
      
        //傳入數據,渲染組件爲 html 字符串
        const html = renderToString(<Component data={data}/>);

        res.end(html);

 }).listen(8080);

另外還有在聲明路由的時候把數據請求方法關聯到路由中,比如定一個 loadData 方法,然後在查找到路由後就可以判斷是否存在loadData這個方法。

看下參考代碼


const loadBranchData = (location) => {
  const branch = matchRoutes(routes, location.pathname)

  const promises = branch.map(({ route, match }) => {
    return route.loadData
      ? route.loadData(match)
      : Promise.resolve(null)
  })

  return Promise.all(promises)
}

上面這種方式實現上沒什麼問題,但從職責劃分的角度來說有些不夠清晰,我還是比較喜歡直接通過組件來得到異步方法。

好了,到這裏我們的第二個問題 - 【獲取數據的方法和邏輯寫在哪裏?】 解決了。

渲染同構

假設我們現在基於上面已經實現的代碼,同時我們也使用 webpack 進行了配置,對代碼進行了轉換和打包,整個服務可以跑起來。

路由能夠正確匹配,數據預取正常,服務端可以直出組件的 html ,瀏覽器加載 js 代碼正常,查看網頁源代碼能看到 html 內容,好像我們的整個流程已經走完。

但是當瀏覽器端的 js 執行完成後,發現數據重新請求了,組件的重新渲染導致頁面看上去有些閃爍。

這是因爲在瀏覽器端,雙端節點對比失敗,導致組件重新渲染,也就是隻有當服務端和瀏覽器端渲染的組件具有相同的props 和 DOM 結構的時候,組件才能只渲染一次。

剛剛我們實現了雙端的數據預取同構,但是數據也僅僅是服務端有,瀏覽器端是沒有這個數據,當客戶端進行首次組件渲染的時候沒有初始化的數據,渲染出的節點肯定和服務端直出的節點不同,導致組件重新渲染。

數據注水

在服務端將預取的數據注入到瀏覽器,使瀏覽器端可以訪問到,客戶端進行渲染前將數據傳入對應的組件即可,這樣就保證了props的一致。

 
//node server  參考代碼
http.createServer((req, res) => {
    
        const url = req.url;
        if(url.indexOf('.')>-1) { res.end(''); return false;}

        res.writeHead(200, {
            'Content-Type': 'text/html'
        });

        console.log(url);
       
        //查找組件
        const branch =  matchRoutes(routes,url);
        //得到組件
        const Component = branch[0].route.component;

        //數據預取
        const data = Component.getInitialProps(branch[0].match.params);

        //組件渲染爲 html
        const html = renderToString(<Component data={data}/>);

        //數據注水
        const propsData = `<textarea style="display:none" id="krs-server-render-data-BOX">${JSON.stringify(data)}</textarea>`;

        // 通過 ejs 模板引擎將數據注入到頁面
        ejs.renderFile('./index.html', {
            htmlContent: html,  
            propsData
        },  // 渲染的數據key: 對應到了ejs中的index
            (err, data) => {
                if (err) {
                    console.log(err);
                } else {
                    console.log(data);
                    res.end(data);
                }
            })

 }).listen(8080);
 
 //node ejs html
 
 <!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
</head>

<body>
    <div id="rootEle">
        <%- htmlContent %> //組件 html內容
    </div>
    
    <%- propsData %> //組件 init  state ,現在是個字符串
</body>

</html>
</body>

需要藉助 ejs 模板,將數據綁定到頁面上,爲了防止 XSS攻擊,這裏我把數據寫到了 textarea 標籤裏。

下圖中,我看着明文數據難受,對數據做了base64編碼 ,用之前需要轉碼,看個人需要。

數據脫水

上一步數據已經注入到了瀏覽器端,這一步要在客戶端組件渲染前先拿到數據,並且傳入組件就可以了。

客戶端可以直接使用id=krs-server-render-data-BOX 進行數據獲取。

第一個方法簡單粗暴,可直接在組件內的constructor 構造函數 內進行獲取,如果怕代碼重複,可以寫一個高階組件。

第二個方法可以通過 context 傳遞,只需要在入口處傳入,在組件中聲明 static contextType 即可。

我是採用context 傳遞,爲了後面方便集成 redux 狀態管理 。


// 定義 context 生產者 組件

import React,{createContext} from 'react';
import RootContext from './route-context';

export default class Index extends React.Component {
    constructor(props,context) {
        super(props);
    }

    render() {
        return <RootContext.Provider value={this.props.initialData||{}}>
            {this.props.children}
        </RootContext.Provider>
    }
}

//入口  app.js
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import Routes from '../';
import Provider from './provider';


//渲染入口  接收脫水數據
function renderUI(initialData) {
    ReactDOM.hydrate(<BrowserRouter><Provider initialData={initialData}>
        <Routes />
    </Provider>
    </BrowserRouter>, document.getElementById('rootEle'), (e) => {
    });
}

//函數執行入口
function entryIndex() {
    let APP_INIT_DATA = {};
    let state = true;

    //取得數據
    let stateText = document.getElementById('krs-server-render-data-BOX');

    if (stateText) {
        APP_INIT_DATA = JSON.parse(stateText.value || '{}');
    }


    if (APP_INIT_DATA) {//客戶端渲染
        
        renderUI(APP_INIT_DATA);
    }
}

//入口執行
entryIndex();

行文至此,核心的內容已經基本說完,剩下的就是組件內如何使用脫水的數據。

下面通過 context 拿到數據 , 代碼僅供參考,可根據自己的需求來進行封裝和調整。

import React from 'react';
import './css/index.scss';

export default class Index extends React.Component {

    constructor(props, context) {
        super(props, context);

        //將context 存儲到 state 
        this.state = {
            ... context
        }

    }

    //設置此參數 才能拿到 context 數據
    static contextType = RootContext;

    //數據預取方法
    static async getInitialProps(krsOpt) {

        if (__SERVER__) {
            //如果是服務端渲染的話  可以做的處理,node 端設置的全局變量
        }

        const fetch1 = fetch.postForm('/fe_api/filed-manager/get-detail-of-type', {
            data: { ofTypeId: 4000 }
        });

        const fecth2 = fetch.postForm('/fe_api/filed-manager/get-detail-of-type', {
            data: { ofTypeId: 2000 }
        });

        const resArr = await fetch.multipleFetch(fetch1, fecth2);
        //返回所有數據
        return {
            page: {},
            fetchData: resArr
        }
    }

    componentDidMount() {
        if (!this.isSSR) { //非服務端渲染需要自身進行數據獲取
            Index.getInitialProps(this.props.krsOpt).then(data => {
                this.setState({
                    ...data
                }, () => {
                   //可有的一些操作
                });
            });
        }
    }

    render() {

        //得到 state 內的數據,進行邏輯判斷和容錯,然後渲染
        const { page, fetchData } = this.state;
        const [res] = fetchData || [];

        return <div className="detailBox">
            {
                res && res.data.map(item => {
                    return <div key={item.id}>{item.keyId}:{item.keyName}---{item.setContent}</div>
                })
            }
        </div>
    }
}


到此我們的第三個問題:【服務端 html 節點無法重用 】已經解決,但人不夠完美,請繼續看。

css 過濾

我們在寫組件的時候大部分都會導入相關的 css 文件。


import './css/index.scss';//導入css

//組件
class Index extends React.Component{
    constructor(props){
        super(props);
    }


    static async  getInitialProps() {
        const fetch1 =await fetch('/xxx.com/a');
        const fetch2 = await fetch('/xxx.com/b');

        return {
            res:[fetch1,fetch2]
        }
    }

    render(){
        return <h1>{this.props.data.title}</h1>
    }
}

但是這個 css 文件在服務端無法執行,其實想想在服務端本來就不需要渲染 css 。爲什麼不直接幹掉? 所以爲了方便,我這裏寫了一個babel 插件,在編譯的時候幹掉 css 的導入代碼。


/**
 * 刪除 css 的引入
 * 可能社區已經有現成的插件但是不想費勁兒找了,還是自己寫一個吧。 
 */
module.exports = function ({ types: babelTypes }) {
    return {
        name: "no-require-css",
        visitor: {
            ImportDeclaration(path, state) {
                let importFile = path.node.source.value;
                if(importFile.indexOf('.scss')>-1){
                    // 幹掉css 導入
                    path.remove();
                }
            }
        }
    };
};

//.babelrc 中使用

 "plugins": [
                "./webpack/babel/plugin/no-require-css"  //引入        
            ]

動態路由的 SSR

現在要說一個更加核心的內容,也是本文的一個壓軸亮點,可以說是全網唯一,我之前也看過很多文章和資料都沒有細說這一塊兒的實現。

不知道你有沒有發現,上面我們已經一步一步的實現了 React SSR 同構 的完整流程,但是總感覺少點什麼東西。

SPA模式下大部分都會實現組件分包和按需加載,防止所有代碼打包在一個文件過大影響頁面的加載和渲染,影響用戶體驗。

那麼基於 SSR 的組件按需加載如何實現呢?

當然我們所限定按需的粒度是路由級別的,請求不同的路由動態加載對應的組件。

如何實現組件的按需加載?

在 webpack2 時期主要使用require.ensure方法來實現按需加載,他會單獨打包指定的文件,在當下 webpack4,有了更加規範的的方式實現按需加載,那就是動態導入 import('./xx.js'),當然實現的效果和 require.ensure是相同的。

咱們這裏只說如何藉助這個規範實現按需加載的路由,關於動態導入的實現原理先按下不表。

我們都知道 import 方法傳入一個js文件地址,返回值是一個 promise 對象,然後在 then 方法內回調得到按需的組件。他的原理其實就是通過 jsonp 的方式,動態請求腳本,然後在回調內得到組件。

import('../index').then(res=>{
    //xxxx
});

那現在我們已經得到了幾個比較有用的信息。

  • 如何加載腳本 - import 結合 webpack 自動完成
  • 腳本是否加載完成 - 通過在 then 方法回調進行處理
  • 獲取異步按組件 - 通過在 then 方法回調內獲取

我們可以試着把上面的邏輯抽象成爲一個組件,然後在路由配置的地方進行導入後,那麼是不是就完成了組件的按需加載呢?

先看下按需加載組件, 目的是在 import 完成的時候得到按需的組件,然後更改容器組件的 state,將這個異步組件進行渲染。


/**
 * 按需加載的容器組件
 * @class Bundle
 * @extends {Component}
 */
export default class Async extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            COMPT: null
        };
    }

    UNSAFE_componentWillMount() {
        //執行組件加載
        if (!this.state.COMPT) {
            this.load(this.props);
        }
    }


    load(props) {
        this.setState({
            COMPT: null
        });
        //注意這裏,返回Promise對象; C.default 指向按需組件
        props.load().then((C) => {
            this.setState({
                COMPT: C.default ? C.default : COMPT
            });
        });
    }

    render() {
        return this.state.COMPT ? this.props.children(this.state.COMPT) : <span>正在加載......</span>;
    }
}

Async 容器組件接收一個 props 傳過來的 load 方法,返回值是 Promise類型,用來動態導入組件。

在生命週期 UNSAFE_componentWillMount 得到按需的組件,並將組件存儲到 state.COMPT內,同時在 render 方法中判斷這個狀態的可用性,然後調用this.props.children 方法進行渲染。

//調用
const LazyPageCom = (props) => (
    <Async load={() => import('../index')}>
        {(C) => <C {...props} />}//返回函數組件
    </Async>
);

當然這只是其中一種方法,也有很多是通過 react-loadable 庫來進行實現,但是實現思路基本相同,有興趣的可以看下源碼。

//參考代碼
import React from 'react';
import Loadable from 'react-loadable';

//loading 組件
const Loading =()=>{
    return (
        <div>loading</div>
    ) 
}

//導出組件
export default Loadable({
    loader:import('../index'),
    loading:Loading
});

到這裏我們已經實現了組件的按需加載,剩下就是配置到路由。

看下僞代碼


//index.js

class Index extends React.Component {

    render() {
        return <div>detail</div>
    }
}


//detail.js

class Detail extends React.Component {

    render() {
        return <div>detail</div>
    }
}

//routes.js

//按需加載 index 組件
const AyncIndex = (props) => (
    <Async load={() => import('../index')}>
        {(C) => <C {...props} />}
    </Async>
);

//按需加載 detai 組件
const AyncDetail = (props) => (
    <Async load={() => import('../index')}>
        {(C) => <C {...props} />}
    </Async>
);

const routes = [

    {
        path: "/",
        exact: true,
        component: AyncIndex
    },
    {
        path: '/detail', exact: true,
        component: AyncDetail,
    }
];

結合路由的按需加載已經配置完成,先不管 server端 是否需要進行調整,此時的代碼是可以運行的,按需也是 ok 的。

但是ssr無效了,查看網頁源代碼無內容。

動態路由 SSR 雙端配置

ssr無效了,這是什麼原因呢?

上面我們在做路由同構的時候,雙端使用的是同一個 route配置文件routes-config.js,現在組件改成了按需加載,所以在路由查找後得到的組件發生改變了 - AyncDetail,AyncIndex,根本無法轉換出組件內容。

ssr 模式下 server 端如何處理路由按需加載

其實很簡單,也是參考客戶端的處理方式,對路由配置進行二次處理。server 端在進行組件查找前,強制執行 import 方法,得到一個全新的靜態路由表,再去進行組件的查找。


//獲得靜態路由

import routes from 'routes-config.js';//得到動態路由的配置

export async function getStaticRoutes() {

    const staticRoutes = [];//存放新路由

    for (; i < len; i++) {
        let item = routes[i];
       
        //存放靜態路由
        staticRoutes.push({
            ...item,
            ...{
                component: (await item.component().props.load()).default
            }
        });
  
    }
    return staticRoutes; //返回靜態路由
}

如今我們離目標更近了一步,server端已兼容了按需路由的查找。但是還沒完!

我們這個時候訪問頁面的話,ssr 生效了,查看網頁源代碼可以看到對應的 html 內容。

但是頁面上會顯示直出的內容,然後顯示<span>正在加載......</span> ,瞬間又變成直出的內容。

### ssr 模式下 client 端如何處理路由按需加載

這個是爲什麼呢?

是不是看的有點累了,再堅持一下就成功了。

其實有問題纔是最好的學習方式,問題解決了,路就通了。

首先我們知道瀏覽器端會對已有的節點進行雙端對比,如果對比失敗就會重新渲染,這很明顯就是個問題。

咱分析一下,首先服務端直出了 html 內容,而此時瀏覽器端js執行完後需要做按需加載,在按需加載前的組件默認的內容就是<span>正在加載......</span> 這個缺省內容和服務端直出的 html 內容完全不同,所以對比失敗,頁面會渲染成 <span>正在加載......</span>,然後按需加載完成後組件再次渲染,此時渲染的就是真正的組件了。

如何解決呢?

其實也並不複雜,只是不確定是否可行,試過就知道。

既然客戶端需要處理按需,那麼我們等這個按需組件加載完後再進行渲染是不是就可以了呢?

答案是:可以的!

如何按需呢?

向“服務端同學”學習,找到對應的組件並強制 執行import按需,只是這裏不是轉換爲靜態路由,只找到按需的組件完成動態加載即可。

既然有了思路,那就擼起代碼。


import React,{createContext} from 'react';
import RootContext from './route-context';

export default class Index extends React.Component {
    constructor(props,context) {
        super(props);
    }

    render() {
        return <RootContext.Provider value={this.props.initialData||{}}>
            {this.props.children}
        </RootContext.Provider>
    }
}

//入口  app.js
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import Routes from '../';
import Provider from './provider';


//渲染入口
function renderUI(initialData) {
    ReactDOM.hydrate(<BrowserRouter><Provider initialData={initialData}>
        <Routes />
    </Provider>
    </BrowserRouter>, document.getElementById('rootEle'), (e) => {
    });
}

function entryIndex() {
    let APP_INIT_DATA = {};
    let state = true;

    //取得數據
    let stateText = document.getElementById('krs-server-render-data-BOX');
    
    //數據脫水
    if (stateText) {
        APP_INIT_DATA = JSON.parse(stateText.value || '{}');
    }


    if (APP_INIT_DATA) {//客戶端渲染
        
        - renderUI(true, APP_INIT_DATA);
        //查找組件
        + matchComponent(document.location.pathname, routesConfig()).then(res => {
            renderUI(true, APP_INIT_DATA);
        });
    }
}

//執行入口
entryIndex();

matchComponent 是我封裝的一個組件查找的方法,在文章開始已經介紹過類似的實現,代碼就不貼了。

核心亮點說完,整個流程基本結束,剩下的都是些有的沒的了,我打算要收工了。

其他

SEO 支持

頁面的 SEO 效果取決於頁面的主體內容和頁面的 TDK(標題 title,描述 description,關鍵詞 keyword)以及關鍵詞的分佈和密度,現在我們實現了 ssr所以頁面的主體內容有了,那如何設置頁面的標題並且讓每個頁面(路由)的標題都不同呢?

只要我們每請求一個路由的時候返回不同的 tdk 就可以了。

這裏我在所對應組件數據預取的方法內加了約定,返回的數據爲固定格式,必須包含 page 對象,page 對象內包含 tdk 的信息。

看代碼瞬間就明白。


import './css/index.scss';

//組件
class Index extends React.Component{
    constructor(props){
        super(props);
    }

    static async  getInitialProps() {
        const fetch1 =await fetch('/xxx.com/a');
        const fetch2 = await fetch('/xxx.com/b');

        return {
            page:{
                tdk:{
                    title:'標題',
                    keyword:'關鍵詞',
                    description:'描述'
                }
            }
            res:[fetch1,fetch2]
        }
    }

    render(){
        return <h1>{this.props.data.title}</h1>
    }
}

這樣你的 tdk 可以根據你的需要設置成靜態還是從接口拿到的。然後可以在 esj 模板裏進行綁定,也可以在 componentDidMount通過 js
document.title=this.state.page.tdk.title設置頁面的標題。

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <meta http-equiv="X-UA-Compatible" content="ie=edge">
   <meta name="keywords" content="<%=page.tdk.keyword%>" />
   <meta name="description" content="content="<%=page.tdk.description%>" />
   <title><%=page.tdk.title%></title>
</head>
<body>
   <div id="rootEle">
       <%- htmlContent %>
   </div>
   <%- propsData %>
</body>
</html>
</body>
<%page.staticSource.js.forEach(function(item){%>

fetch 同構

可以使用isomorphic-fetchaxios或者whatwg-fetch + node-fetch 等庫來實現支持雙端的 fetch 數據請求,這裏推薦使用axios 主要是比較方便。

TODO 和 思考

沒有介紹結合 redux 狀態管理的 ssr 實現,其實也不復雜,關鍵還是看業務中是否需要使用redux,因爲文中已經實現了使用 context 傳遞數據,直接改成按store 傳遞也很容易,但是更多的還是對 react-redux 的應用。


//渲染入口 代碼僅供參考 
function renderUI(initialData) {
   ReactDOM.hydrate(<BrowserRouter><Provider store={initialData}>
       <Routes />
   </Provider>
   </BrowserRouter>, document.getElementById('rootEle'), (e) => {
   });
}

服務端同構渲染雖然可以提升首屏的出現時間,利於 SEO,對低端用戶友好,但是開發複雜度有所提高,代碼需要兼容雙端運行(runtime),還有一些庫只能在瀏覽器端運行,在服務端加載會直接報錯,這種情況就需要進行做一些特殊處理。

同時也會大大的增加服務端負載,當然這都容易解決,可以改用renderToNodeStream() 方法通過流式輸出來提升服務端渲染性能,可以進行監控和擴容,所以是否需要 ssr 模式,還要看具體的產品線和用戶定位。

最後

本文最初從 react ssr 的整體實現原理上進行說明,然後逐步的拋出問題,循序漸進的逐步解決,最終完成了整個React SSR 所需要處理的技術點,同時對每個技術點和問題做了詳細的說明。

但實現方式並不唯一,還有很多其他的方式, 比如 next.jsumi.js,但是原理相似,具體差異我會接下來進行對比後輸出。

源碼參考

由於上面文中的代碼較爲零散,恐怕不能直接運行。爲了方便大家的參考和學習,我把涉及到代碼進行整理、完善和修改,增加了一些基礎配置和工程化處理,目前已形成一個完整的開發骨架,可以直接運行看效果,所有的代碼都在這個骨架裏,歡迎star 歡迎 下載,交流學習。

項目代碼地址: https://github.com/Bigerfe/koa-react-ssr

說點感想

很多東西都可以基於你現有的知識創造出來。

只要明白了其中的原理,然後梳理出實現的思路,剩下的就是擼代碼了,期間會大量的自動或被動的從你現有的知識庫裏進行調取,一步一步的,只要不怕麻煩,都能搞得定。

這也是我爲什麼上來先要說下reac ssr 原理 的原因,因爲它指導了我的實踐。

全文都是自己親手一個一個碼出,也全部都是出自本人的理解,但個人文采有限,所以導致很多表達說的都是大白話,表達不夠清楚的地方還請指出和斧正,但是真正的核心已全部涵蓋。

希望本文的內容對你有所幫助,也可以對得住我這個自信的標題。

參考資料

https://github.com/ReactTrain...
https://reacttraining.com/rea...
https://blog.seosiwei.com/det...
https://www.jianshu.com/p/47c...


更多精彩好玩有用的前端內容,請關注公衆號《前端張大胖》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章