【長文慎入】一文吃透 React SSR 服務端渲染和同構原理

zz_jesse發表於2019-09-18

打個廣告,我的第一本小冊釋出了 - 超完整的react ssr 同構原理和實踐。

React ssr 服務端渲染原理解析與同構實踐

小冊地址:juejin.im/book/5d8ae0…

寫在前面

前段時間一直在研究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,然後在已有的路由表內查詢到對應的元件,拿到需要請求的資料,將資料作為 propscontext或者store 形式傳入元件,然後基於 react 內建的服務端渲染api renderToString() or renderToNodeStream() 把元件渲染為 html字串或者 stream 流, 在把最終的 html 進行輸出前需要將資料注入到瀏覽器端(注水),server 輸出(response)後瀏覽器端可以得到資料(脫水),瀏覽器開始進行渲染和節點對比,然後執行元件的componentDidMount 完成元件內事件繫結和一些互動,瀏覽器重用了服務端輸出的 html 節點,整個流程結束。

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

這裡放一個架構圖

【長文慎入】一文吃透 React SSR 服務端渲染和同構原理

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編碼 ,用之前需要轉碼,看個人需要。

【長文慎入】一文吃透 React SSR 服務端渲染和同構原理

資料脫水

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

客戶端可以直接使用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.js, umi.js,但是原理相似,具體差異我會接下來進行對比後輸出。

原始碼參考

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

專案程式碼地址: github.com/Bigerfe/koa…

說點感想

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

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

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

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

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

參考資料

github.com/ReactTraini… reacttraining.com/react-route… blog.seosiwei.com/detail/10 www.jianshu.com/p/47c8e364d…


到這裡請停一下

  • 我正在打造一個純技術交流群,以學習、交流、思考、提升能力為目標,因為一個人學不如大家一起學,有了更多的交流才會進步的更快。
  • 我理想的模式是,每期讓一個人深入學習一個技術,然後自己再轉述給大家聽,類似一個分享課堂,這樣可以成倍的提升學習效率
  • 在這個群裡不用擔心自己的能力不足,不用擔心問題是否太小白而不敢說,大膽的說出問題, 讓更多的人一起來分析,說錯了也沒關係
  • 有想入群的加我微信 223344386 回覆加群即可

希望本文可以給你帶了一些幫助,文中如有錯誤,歡迎在評論區指。 如果這篇文章幫助到了你,歡迎點贊和關注。

另外推薦關注我的微信公眾號【前端技術江湖】,除了深度好文,還有我有精心整理的【500道前端面試題】等你來查收。

【長文慎入】一文吃透 React SSR 服務端渲染和同構原理

【長文慎入】一文吃透 React SSR 服務端渲染和同構原理

系統學習

建議從我的小冊入手

React SSR 服務端渲染原理解析與實踐

相關文章