原理分析之SSR

479 阅读9分钟

Vue SSR

传统的web开发

客户端负责渲染,服务端负责返回拼接的HTML,是一种最早的开发方式,比如PHP,JSP,
浏览器拿到的就是全部的dom结构,这时候前后端分离的概念还不是很清晰。

SPA时代

到了vue,react时代,单页应用优秀的用户体验,逐渐成为了主流,页面整体是JS渲染出来的,称之为客户端渲染CSR,前后端分离的概念变得清晰。

但是CSR存在两个问题:首屏渲染慢,因为要去渲染执行js后,首屏才会渲染出来,同时不利于
SEO,因为返回的不是完整的html,爬虫爬不到完整的页面内容。CSR每次都要请求拿数据
也比较消耗流量,这时候SSR服务端渲染就应运而生。

SSR

为了解决CSR首屏慢,SEO不利这两个问题,便出现了SSR解决方案,后端渲染出完整的首屏
dom结构返回,前端拿到的内容包括首屏及完整spa结构,应用激活后依然按照spa方式运行
这种页面渲染方式被称为服务端渲染(server side render)。

最简单的服务端渲染

基于node创建一个express服务器,并将vue ssr集成进来,index.js内容如下:

const express = require('express')
const Vue = require('vue') 
const app = express() 
const renderer = require('vue-server-renderer').createRenderer()    //服务端渲染库
const page = new Vue({    
    data:{ name:'hhh' },
    template:` 
        <div><h1>{{name}}</h1></div> 
    ` 
})
app.get('/',async function(req,res){ 
    // renderToString可以将vue实例转换为html字符串
    try {
        const html = await renderer.renderToString(page) 
        res.send(html) 
    } 
    catch (error) { 
        res.status(500).send('Internal Server Error') 
    } 
})
app.listen(3000, ()=>{ console.log('启动成功') })

但是真实的应用场景要复杂很多,如果全部构建过程交由服务端渲染将造成很多的资源浪费,服务端消耗太大,这时候我们可以同构我们的应用,将CSR和SSR相结合的方式,SSR负责首屏渲染,CSR负责后续交互。

开始构建(同构)

对于客户端应用程序和服务器应用程序,我们都要使用webpack。服务器需要服务器bundle
用于服务器端渲染(SSR),客户端bundle会发送给浏览器,用于混合静标记。

构建流程图

结构解析:我们的项目中包含组件components,router,store,app.js是整个应用的入口。然后CSR,SSR通过webpack分别使用server的配置文件server.entry.js,client的配置文件client.entry.js打包app.js成两个budle文件:server bundle,client bundle。server bundle会生成一个html负责首屏渲染,client bundle负责激活spa,用于后续的交互。

代码结构

├── App.vue    #vue项目文件
├── app.js # 用于创建vue实例
├── client-entry.js # 客户端入口,用于静态内容“激活”
└── server-entry.js # 服务端入口,用于首屏内容渲染

实例创建和相关配置

首先定义一个router/index.js文件负责路由解析,数据存储store/index.js类似。

import Vue from "vue"; 
import Router from "vue-router"; // 分别创建Index.vue和Detail.vue 
import Index from "@/components/Index"; 
import Detail from "@/components/Detail"; 
Vue.use(Router); 
//导出工厂函数 
export function createRouter() { 
    return new Router({ 
        mode: 'history', 
        routes: [ { path: "/", component: Index }, { path: "/detail", component: Detail } ] 
    }); 
}

然后是app.js文件:app.js负责创建vue实例,每次请求均会有独立的vue实例,router实例,store实例,以保证每个客户端之间不会互相干扰,彼此影响,服务器性能消耗可见一斑。

import Vue from 'vue' 
import App from './App.vue' 
import { createRouter } from './router'
export function createApp (context) { //context是node传给我们的,里面包含了请求地址url
    const router = createRouter() 
    const app = new Vue({ router, context, render: h => h(App) })
    return { app, router } 
}

服务端入口server.entry.js:

import { createApp } from './app' 
export default context => { 
    // 我们返回一个 Promise 
    // 确保路由或组件准备就绪 
    return new Promise((resolve, reject) => { 
        const { app, router } = createApp(context) 
        // 跳转到首屏的地址 
        router.push(context.url) 
        router.onReady(() => { resolve(app) }, reject) 
    }) 
}

客户端入口client.entry.js:只需创建vue实例并执行挂载,这一步称为激活。

import { createApp } from './app' 
const { app, router } = createApp() 
router.onReady(() => { 
    app.$mount('#app') //挂载就激活了
})

接下来就是webpack的配置vue.config.js:

const VueSSRServerPlugin = require("vue-server-renderer/server-plugin"); 
const VueSSRClientPlugin = require("vue-server-renderer/client-plugin");
const TARGET_NODE = process.env.WEBPACK_TARGET === "node"; //命令中有node就打包server.entry
const target = TARGET_NODE ? "server" : "client"; 
module.exports = { 
    css: { extract: false },
    outputDir: './dist/'+target, //生成文件放在dist/server,dist/client下
    configureWebpack: () => ({ 
        // 将 entry 指向应用程序的 server / client 文件 
        entry: `./src/${target}-entry.js`, //打包入口
        // 对 bundle renderer 提供 source map 支持 
        devtool: 'source-map', 
        // 这允许 webpack 以 Node 适用方式处理动态导入(dynamic import), 
        // 并且还会在编译 Vue 组件时告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
        target: TARGET_NODE ? "node" : "web", 
        node: TARGET_NODE ? undefined : false, 
        output: { 
            // 此处使用 Node 风格导出模块 
            libraryTarget: TARGET_NODE ? "commonjs2" : undefined 
        },
        // 这是将服务器的整个输出构建为单个 JSON 文件的插件。 
        // 服务端默认文件名为 `vue-ssr-server-bundle.json` 
        plugins: [TARGET_NODE ? new VueSSRServerPlugin() : new VueSSRClientPlugin()] 
    })
}

定义脚本package.json的script,执行npm run build会同时执行前两个命令:

"scripts": { 
    "build:client": "vue-cli-service build", 
    //cross-env负责兼容,WEBPACK_TARGET=node传参告知这是服务端打包
    "build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build --mode server", 
    "build": "npm run build:server && npm run build:client" 
}

宿主文件public/index.tmpl.html

    <!DOCTYPE html> 
    <html lang="en"> 
        <head> 
            <meta charset="utf-8"> 
            <meta http-equiv="X-UA-Compatible" content="IE=edge"> 
            <meta name="viewport" content="width=device-width,initial-scale=1.0"> 
            <title>Document</title> 
        </head> 
        <body> 
            <!--vue-ssr-outlet--> //这里是固定写法,不能增删改任何字符,用于SSR代码注入
        </body> 
    </html> 

上诉准备就绪后(已打包生成了client bundle和server bundle,并有了模板文件index.temp.html)我们就可以改造我们的测试代码index.js,并通过nodemon index.js
启动查看效果。

const express = require('express')
const fs = require('fs')
const app = express();
// 1.渲染器
const {createBundleRenderer} = require('vue-server-renderer');    //注意不是以前的render了。
const bundle = require('../dist/server/vue-ssr-server-bundle.json')
//客户端bundle要作为静态资源
app.use(express.static('../dist/client', {index: false}))
// bundle是服务端包
const renderer = createBundleRenderer(bundle, {    //此方法用于将三者整合到一起
    runInNewContext: false,    
    template: fs.readFileSync('../public/index.tmpl.html', "utf-8"),    
    clientManifest: require('../dist/client/vue-ssr-client-manifest.json')
})
app.get('*', async function(req, res) {    //监听所有的请求
    const context = {        
        title: 'SSR Test',        
        url: req.url    
    }    
    // 2.执行渲染    
    const html = await renderer.renderToString(context)    
    res.send(html);
})
app.listen(3000, () => {    
    console.log('渲染服务器就绪');    
})

整合store的方法与router类似,store/index.js中不直接生成一个store,而是暴露一个createStore方法,每次都生成一个新的store,供server.entry.js和client.entry.js使用。

数据预取

ssr中如何获取异步数据呢?采用数据预取的方法。首先在store/inde.js中mock一个异步方法:

export function createStore() { 
    return new Vuex.Store({
        mutations: { //正常改变数据的对象
            // 加一个初始化 
            init(state, count) { 
                state.count = count; 
            }, 
        },
        actions: { //执行一些异步操作的对象
            getCount({ commit }) { 
                return new Promise(resolve => { 
                    //模拟一个网络请求
                    setTimeout(() => { 
                        //调用mutation
                        commit("init", Math.random() * 100); 
                        resolve(); 
                    }, 1000); 
                }); 
            }, 
        }, 
    }); 
}

假使我们的index.vue中需要预取数据,我们需要定义一个方法asyncData:

export default { 
    asyncData({ store, route }) { 
        // 约定预取逻辑编写在预取钩子asyncData中 
        // 触发 action 后,返回 Promise 以便确定请求结果 
        return store.dispatch("getCount"); 
    } 
};

服务端入口server.entry.js预取数据逻辑:

import { createApp } from "./app"; 
export default context => { 
    return new Promise((resolve, reject) => { 
        const { app, router, store } = createApp(context); 
        router.push(context.url); //跳转到指定的路由
        router.onReady(() => {    //跳转之后的回调
            // 获取匹配的路由组件数组 
            const matchedComponents = router.getMatchedComponents(); 
            if (!matchedComponents.length) { 
                return reject({ code: 404 }); 
            }
            // 对所有匹配的路由组件调用 `asyncData()` 
            Promise.all( matchedComponents.map(Component => { 
                if (Component.asyncData) { 
                    return Component.asyncData({ store, route: router.currentRoute, }); 
                } 
            }).then(() => { 
                // 所有预取钩子 resolve 后, 
                // store 已经填充入渲染应用所需状态 
                // 将状态附加到上下文,且 `template` 选项用于 renderer 时, 
                // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。 
                context.state = store.state; 
                resolve(app); 
            }).catch(reject); 
        }, reject); 
    }); 
};

客户端在挂载到应用程序之前,store 就应该获取到状态,client-entry.js:

const { app, router, store } = createApp(); 
// 当使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态自动嵌入到最终的 HTML 
// 在客户端挂载到应用程序之前,store 就应该获取到状态
// 要保证客户端服务端的store一致才不会出现一些莫名错误,如浏览器闪一下数据消失
if (window.__INITIAL_STATE__) { 
    store.replaceState(window.__INITIAL_STATE__); 
}

以上预取数据都是服务端预取数据然后客户端获取服务端的数据并同步。如果不走SSR,就是我们之间熟悉的SPA,预取数据就是在created或者beforeMount中调用asyncData方法即可。

React SSR

与Vue的差别

react ssr与vue ssr类似,只不过有些细微差别。vue ssr同构应用是将server.entry和client entry打包(vue.config.js)成server bundle和client bundle,然后在index.js中整合模板文件以及两个bundle文件,输出最终的效果。react ssr是将server entry打包(webpack.server.js)成server bundle,client entry打包(webpack.client.js)成client bundle,并形成静态服务。启动server bundle时就会集成client bundle并输出最终的效果,无需模板文件和index.js。客户端跟服务端的分工更清晰。

代码结构

├── App.js #react项目文件
├── client/index.js #客户端打包入口
├── server/index.js #服务端打包入口
├── webpack-client.js # 客户端配置文件
└── webpack-server.js # 服务端配置文件

client/index.js:

import React from 'react'
import ReactDom from 'react-dom'
import {BrowserRouter, Route} from 'react-router-dom'    //客户端路由使用BrowserRouter
import {Provider} from 'react-redux'
import routes from '../src/App'
//这里的store是注入了服务端预取数据的store
import {getClientStore} from '../src/store/store'
import Header from '../src/component/Header'
// 注水 客户端入口
const Page = (
    <Provider store={getClientStore()}>  
        <BrowserRouter>  
            <Header></Header>    
            {routes.map(route=> <Route {...route}></Route>)}  
        </BrowserRouter>
    </Provider>
)
//采用ssr时,服务端已经render,客户端只需要hydrate混入即可(激活)
//如果降级渲染,hydrate要改为render
ReactDom.hydrate(Page, document.getElementById('root'))

server/index.js

// 这里的node代码。会用babel处理
import React from 'react'
import {renderToString } from 'react-dom/server'
import express from 'express'
//服务端渲染路由要使用StaticRouter
import {StaticRouter,matchPath, Route} from 'react-router-dom'
import {Provider} from 'react-redux'
//这里的store没有初始化数据,因为初始数据(预取数据)已经注入到store中
import {getServerStore} from '../src/store/store'
import routes from '../src/App'
import Header from '../src/component/Header'
const store = getServerStore()
const app = express()
//启用静态服务,server bundle才能引入client bundle
app.use(express.static('public'))
app.get('*',(req,res)=>{  
    // 获取根据路由渲染出的组件,并且拿到loadData方法 获取数据
    // 存储网络请求
    const promises = [];
    // use `some` to imitate `<Switch>` behavior of selecting only
    // 路由匹配
    routes.some(route=>{  const match = matchPath(req.path,route)  
    if(match){    
        //组件中定义的静态方法用于预取数据
        const {loadData} = route.component    
        if(loadData){      
            promises.push(loadData(store))    
        }  
    }
})  
// 等待所有网络请求结束再渲染  
Promise.all(promises).then(()=>{    
    // 把react组件,解析成html    
    const content = renderToString(      
        <Provider store={store}>        
            <StaticRouter location={req.url}>          
                <Header></Header>          
                {routes.map(route=><Route {...route}></Route>)}        
            </StaticRouter>      
        </Provider>    
    )    
    // 字符串模板    
    res.send(`    
        <html>      
            <head>        
                <meta charset="utf-8"/>        
                <title>react ssr</title>      
            </head>      
            <body>        
                <div id="root">${content}</div>        
                <script>          
                    //注意这里,预取数据放在了window.__context中,用于后续客户端store同步
                    window.__context=${JSON.stringify(store.getState())}        
                </script>        
                <script src="/bundle.js"></script>      
            </body>    
        </html>    
    `)  
    }).catch(()=>{    
        res.send('报错页面500')  
    })  
})
app.listen(9093,()=>{  console.log('监听完毕')})

webpack.client.js:

const path = require('path')
module.exports = {  
    mode:"development",  
    // 客户端入口  
    entry:'./client/index.js',  
    // 客户端输出  
    output:{    
        filename:'bundle.js',    
        path:path.resolve(__dirname, 'public')  
    },  
    module:{    
        rules:[{  
            test:/\.js$/,        
            // 才能支持import 支持jsx        
            loader:'babel-loader',        
            exclude:/node_modules/,        
            options:{          
                presets:['@babel/preset-react', ['@babel/preset-env']]        
            }      
        }]  
    }
}

webpack.server.js:

const path = require('path')
const nodeExternals = require('webpack-node-externals')
// 服务端的webpack
module.exports = {  
    target:"node",  
    mode:"development",  
    entry:'./server/index.js',  
    externals:[nodeExternals()],  
    output:{    
        filename:'bundle.js',    
        path:path.resolve(__dirname, 'build')  
    },  
    module:{    
        rules:[{         
            test:/\.js$/,        
            // 才能支持import 支持jsx        
            loader:'babel-loader',        
            exclude:/node_modules/,        
            options:{          
                presets:['@babel/preset-react', ['@babel/preset-env']]        
            }      
        }]  
    }
}

package.json脚本:

"scripts": {    
    //concurrently可以同时执行多个命令
    "start": "concurrently \"npm run dev:client\" \"npm run dev:server\" \"npm run dev:start\"",    
    "dev:client": "webpack --config webpack.client.js --watch",    
    "dev:server": "webpack --config webpack.server.js --watch",    
    "dev:start": "nodemon --watch build --exec node \"./build/bundle.js\""  
},

react ssr注意事项

预取数据:服务端通过在组件内定义静态函数,当server匹配路由时发现要显示的页面存在静态函数时,就会把该任务放入到一个promise数组中,然后通过Promise.all调用。获取的数据放入window.__context中用于后续客户端,服务端store同步。
Promise.all错误处理:要想实现其中一个promise报错了,后续请求还能正常进行,需要在每一个请求外面在包一层promise或者使用Promise.allSettle(此方法在部分浏览器中还不能使用)。
css解析:node服务端没有document对象,因此就不能使用style-load动态的添加style标签。需要使用同构版本的style-loader。
降级渲染:当访问流量过大时,需要降级渲染,以此来减少服务器的开销,或者使用负载均衡,让服务器做转发。如何降级?根据配置选择是否开启CSR或者服务端检测内存达到一定的阈值就全面开启CSR(使用HtmlWebpackPlugin),返回一个静态文件index.html,这时客户端入口client/index.js就要改hydrate方法为render方法。

SSR的另一种解决方案

使用一些无头浏览器如puppeteer,爬取需要进行SEO的页面内容,静态化后将结果返回。