Vue SSR与React SSR从0到1开始搭建

4,118 阅读7分钟

SSR前言

随着前端路由的兴起,spa受到越来越多人的欢迎, 下面预先介绍spa的优缺点,方便同学们认识到ssr为什么会兴起

spa优缺点

  • 优点:

    1. 用户体验好, 不需要重新加载页面,web应用更具响应性和更令人着迷
    2. 减少了服务端的压力, 不需要去每个路径对应每个页面
    3. 基于上面一点,SPA相对对服务器压力小
    4. 交互性很强
  • 缺点:

    1. 不利于seo, 由于是浏览器动态替换内容,开始请求到的页面是空节点
    2. 首次渲染速度变慢, 原因是服务器不需要预先下载js与css

ssr原理

其实就是一句话: 服务器接收到请求时,将对应的资源填充到HTML模板里转化为字符串形式传递给浏览器, 浏览器解析js后绑定对应事件就完成了渲染

引申几个问题:

1. 服务器渲染完并且传递给浏览器后, 之后的链接是走浏览器还是走服务端?
2. 服务器在传递给浏览器之前会请求该页面的所需要加载的数据,那么,由于是响应式框架(数据驱动视图),浏览器必须要获取到服务器已经请求到了的数据,那么服务器是怎么传递这些数据的?
3. css文件是怎么处理?
4. 按需加载与平时有什么区别?

以上问题,现在可能刚接触的人不太理解为什么会提出这些问题,希望读者带着以上问题去阅读,看完下面,再回过头来想想这些问题会比较好

React SSR

两端需要做的事情

浏览器:

  • 挂载DOM
  • 获取服务端预取的数据

服务端:

  • 数据预取
  • 转换HTML字符串, 并且返回给客户端

同构

概念

什么是同构? 同构指的是一套代码能同时跑在浏览器端与服务器端,同构是SSR的核心理念,但是同构并不是没有缺点,下面列举下同构的优缺点:

优点:

1. 可复用性提高,一套代码跑两端,节省开发时间
2. 有利于seo, 服务器一开始就渲染了整个页面

缺点:

1. 服务器与浏览器的环境变量是不同的,很多npm包是针对于浏览器端的, 所以这里判断需要成本
2. 数据预取是有点同时也是缺点, 页面请求多个数据接口时,可能会花上数秒时间,导致页面无法渲染,从而资源到不了浏览器端,会留下一段空白时间
3. 内存泄露, 服务器不像浏览器刷新就可以内存重置,可能会造成服务器内存泄露

渲染

浏览器端:

了解过React的知道挂载DOM节点采用ReactDOM.render方法, 但是React为SSR提供了另一个挂载API

    import ReactDOMServer from 'react-dom'
    import App from './App'
    
    ReactDOMServer.hydrate(<App/>, document.getElementById('root'))

相信大家看到了一个关键的API, hydrate, 这个API的作用是复用已有的DOM节点,减少DOM重复渲染损耗,可提高初始化渲染速度, 该API要求服务器与浏览器渲染的内容是一致的,否则会进行警告,也可以认为是代码的bug, 大家可以想象,服务器传回的HTML已经是填充完数据后的模板,那么浏览器就没有必要重新渲染整个DOM树,直接复用即可

服务器端:

React v16以下推荐:(这里我特地标记了版本,是因为由于版本原因, 可供用户选择的api有所不同,具体的请往下看)

renderToString: 将DOM转换为字符串形式,以便服务器发送

    // entry-server.js
    import { renderToString } from 'react-dom/server'
    
    export default function render() {
        return renderToString(<App />)    
    }

server端代码:

    // server.js
    const express = require('express')
    const app = express()
    const server = require('/path/to/entry-server.js') // 需要经过打包,因为我们采用的是es6的写法,需要转换成commonjs
    
    app.get('/', (req, res) => {
        const render = server.render()
    
        res.end(`
            <!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>ssr</title>
                    </head>
                    <body>
                        <div id="root">
                            ${render}
                        </div>
                    </body>
                </html>
        `)
    })

该方法推荐ejs作为html模板,可以动态填充数据,不了解ejs的同学可以去官网看下

GitHub地址: github.com/Gloomysunda…

路由

路由同构指的是,路由代码同时跑在服务端与浏览器端,路由的作用就是动态显示组件, React在浏览器与服务器有着不同的Router封装

  • 浏览器端: BrowserRouter或者是HashRouter

    这两个Router在此就不详细介绍了,相信用过React的同学都会知道

  • 服务器端: StaticRouter

    StaticRouter官方解释: 一个永远不会改变位置。当用户实际上没有四处点击时,这在服务器端渲染方案中很有用,因此位置永远不会实际更改。因此,名称为:静态。当您只需要插入一个位置并对渲染输出进行断言时,它在简单测试中也很有用。

    StaticRouter内部不会涉及到浏览器的history位置更迭,它只是一个静态路由,只做匹配路由填充数据

    // entry-server.js
    
    export default render(url) {
        ...
        return renderToString(<StaticRouter location={url}>
          <ServerRouter />
        </StaticRouter>)
    }

GitHub: github.com/Gloomysunda…

路由懒加载

项目打包通常不会把所有的内容打包到一个文件里面,那样会造成文件过大导致请求过慢从而影响了浏览器渲染速度, 通常我们会在请求某个页面时去请求相对应的资源,SSR也不例外。

浏览器端:

webpack结合import(path)即是按需加载(webpack2.x的require.ensure被import()替代了): import返回的是一个promise对象, 在then方法返回的参数即是内容

    const AsyncDetail = () => import('/path/to/AsyncDetail')
    
    const router = [
        {
            path: '/asyncdetail',
            component: AsyncDetail
        }
    ]
    
    export default function createRouter() {
        return <Switch>
            {router.map(route => (<Route {...route}/>))}        
        </Switch>
    }

以上的代码显然还是会报错, 原因就在于AsyncDetail是一个promise对象并不是ReactElement, 我们需要在浏览器还未加载之前先显示一段加载内容提示用户, 可以直接采用第三方的包, react-loadable, 返回的是一个组件这样就可以直接使用了, 或者是React v16以上的Suspense与lazy

但此时服务端的路由因为一开始就要显示出所有的内容,如果在内容还未加载完成之前服务器就已经将html发给客户端,那么还是会出现空白页面,此时entry-server.js需要更改下

    // 此时ServerRouter已经不能与客户端的Router共用一个router config了
    async function getServerRouter(router) {
        const serverRouter = []
        
        for (let route of router) {
            serverRouter.push({
                ...route,
                component: await (route.component())
            })   
        }
        
        return serverRouter
    }
    // entry-server.js
    import createRouter from '/path/to/Router'
    
    export default async render(url) {
        // async函数返还的是一个promise对象
        const serverRouterConfig = await getServerRouter(router)
        const ServerRouter = createRouter(serverRouterConfig)
        return renderToString(<StaticRouter location={url}>
            <ServerRouter />
        </StaticRouter>)
    }

数据预取

React是通过数据响应来驱动视图的变化,在服务端返回模板之前需要进行数据请求,将请求好的数据填充进React模板最后形成一个字符串返回给客户端

react-router官方推荐在router里定义一个loadData属性用来识别加载数据方法

    // router.js
    
    const router = [{
        path: '/component',
        loadData: Component.loadData,
        component: Component,
    }]
    // entry-server.js
    import { matchPath } from 'react-router-dom'
    import createStore from './store'
    
    export default render(url) {
        const store = createStore()
        const promise = []
        
        router.forEach(route => {
            const match = matchPath(url, route)
            
            if (match) {
                promise.push(route.loadData(store))
            }
        })
        
        Promise.all(promise).then(...)
    }
    function AsyncLoadData() {
        return <div>{props.data}</div>
    }
    
    AsyncLoadData.loadData = (store) => {
        return store.dispatch(action.getData)
    }
    
    export default connect(mapStateToProps)(AsyncLoadData)

首先介绍下以上涉及到的几个变量:

  1. matchPath

    实质上是对route配置的path进行正则分析,通过分析的结果对比url.pathname来判断是否有对应的配置,若有则返回该配置,没有则返回Null

  2. store

    由于同构原因,必须创建一个仓库可以适配于两端的,redux是用的比较多的一个公共仓库,所以这里采用redux作为数据存储仓库

  3. 不知道大家有没有发现这里不管是router还是store都是使用createXX方法来创建的,原因有以下两点:

    1. 服务器并不是客户端, 所有的客户端请求的是同一个服务器, 服务器内存始终保存着同一个变量,多个客户端请求同一个变量,会造成交叉污染,导致不必要的麻烦,所以采用函数创建,返回不同的副本

    2. 服务端与客户端配置会有些许不同, 采用传参方式可以复用代码,减少冗余

发生了问题

通过以上的写法,服务端会在请求完数据后填充数据并返回模板给到客户端,但是此时会发现客户端发生了闪屏, 原有的数据在一开始展示了但是瞬间又会消失掉, 打开NetWork,会发现服务端返回了完整的HTML,但是Element里又没有显示,原因出在哪里?

原因

原因在于服务端请求完数据后,但是客户端是不清楚返回数据的, 所以在服务端返回完整html后,一开始展示了有数据的页面,后来经过浏览器解析js后,又展示了空白页面

解决方案

我们需要在服务器请求完数据后,将其填充到window下,并且一起返回给客户端,客户端在挂载数据之前,先去取到window下的数据并且填充到视图里

    // entry-client.js
    ...
    
    if (window.initialData) {
        store.repalceState(window.initialData)
    }
    
    ReactDOM.hydrate(...)
    // entry-server.js
    
    Promise.all(promise).then(() => {
        resolove({
            template: ...,
            initialData: store.getState()
        })
    })
    // server.js
    
    ...
    <script type="text/javascript">
        window.initialData = initialData
    </script>

这样基本就完成了一个SSR全过程,下面来介绍下一些遇到的问题与webpoack的配置

webpack配置(基本配置不进行介绍了)

client端:

plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].css'
    })
]

客户端的目的就只是挂载DOM节点,所以它需要处理下css样式的问题

server端:

output: {
    libraryTarget: 'commonjs2', // module.exports
    libraryExport: 'default', // 若是所有的到处都是用default来导出,则可以使用
},
externals: [nodeExternals()],
target: 'node', // 可以在打包的时候不注入node环境下变量

服务端的目的是为了将html解析成字符串,它不管渲染等方便,所以对css没有什么要求

遇到的问题

css modules

1. React无法和Vue一样拥有独自的css局域块,所以通常采用css-loader里的module隔离开各个样式,但是此时就会发生一个问题:
    import styles from './xxx.less'

    class xxx extends Component {
        render() {
            return <div className={styles.yyyy}>xxx</div>   
        }
    }

在解析这段代码时,会报错,因为没有css-loader解析导致styles无法被获取,className自然无法被赋值,所以React在配置webpack时,还需要在server端也打包进css-loader,与此同时,又爆发出一个问题:

2.Css-loader在两端获取yyy属性时,表现出了不同的获取方式,server端还需要在locals下才能获取类名

原因:

css-loader只有在style-loader下才会直接在styles能够获取到类名

但是style-loader由于会添加style标签引用到浏览器特有属性才可以使用, 所以只能会使用isomorphic-style-loader来代替style-loader

renderToNodeStream

React v16版本以上提供了renderToNodeStream, 该方法为服务端提供了以流的方式传输HTML模板, 输出的内容等同于renderToString

好处: 以流的方式输出,可以有效缓解服务器压力,流不是在一开始就全部可以用,所以不会一开始全部塞入内存导致服务器堵塞。

Vue SSR

Vue SSR原理基本与React SSR类似,但是Vue比React更为简单一些,下面开始介绍下Vue SSR

Webpack配置

    // client端
    const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

    module.exports = merge(baseConfig, {
      entry: './src/entry-client.js',
      plugins: [
        // 重要信息:这将 webpack 运行时分离到一个引导 chunk 中,
        // 以便可以在之后正确注入异步 chunk。
        // 这也为你的 应用程序/vendor 代码提供了更好的缓存。
        new webpack.optimize.CommonsChunkPlugin({
          name: "manifest",
          minChunks: Infinity
        }),
        // 此插件在输出目录中
        // 生成 `vue-ssr-client-manifest.json`。
        new VueSSRClientPlugin()
      ]
    })
    // server端
    const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

    module.exports = merge(baseConfig, {
      // 将 entry 指向应用程序的 server entry 文件
      entry: './src/entry-server.js',
    
      // 这允许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import),
      // 并且还会在编译 Vue 组件时,
      // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
      target: 'node',
    
      // 对 bundle renderer 提供 source map 支持
      devtool: 'source-map',
    
      output: {
        libraryTarget: 'commonjs2'
      },
    
      externals: nodeExternals({
        // 不要外置化 webpack 需要处理的依赖模块。
        // 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
        // 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单
        whitelist: /\.css$/
      }),
    
      // 这是将服务器的整个输出
      // 构建为单个 JSON 文件的插件。
      // 默认文件名为 `vue-ssr-server-bundle.json`
      plugins: [
        new VueSSRServerPlugin()
      ]
    })

经过以上两步,出口文件夹在正常输出文件以外多出两个json文件

  • vue-ssr-client-manifest.json
  • vue-ssr-server-bundle.json

同构

同构同样是核心理念,在两端书写一套共有的代码

实例化

实例化Vue, 这里要主要的是上面讲到的是交叉渲染污染,所以设计成函数形式, 每次请求都会实例化一个新的app出来

    // app.js
    import { sync } from 'vuex-router-sync'
    ...
    export function createApp() {
      const router = createRouter()
      const store = createStore()
    
      sync(store, router) // 将vuex与vue-router状态共享
    
      const app = new Vue({
        store,
        router,
        render: h => h(App)
      })
    
      return {
        app,
        store,
        router
      }
    }

路由同构

    // router.js
    
    export default function createRouter() {
        return new Router({
            ... // 正常配置路由
        })
    }

浏览器正常使用, 在app.js实例化的时候就已经同步塞入Vue里了

服务端由于设计到一些数据预期等等额外的操作, 需要进行简单处理下

根据官方定义的一个调用方法asyncData, 在服务端获取到客户端请求的时候,会先检测组件配置里是否定义了asyncData, 若有的话, 则进行调用, 在这里声明下asyncData是在Vue实例化之前调用的, 也就是说此时是无法获取到执行上下文的, 所以需要用到公共库Vuex来存储数据(当然自己写一个类似的也可以), asyncData会传入store与router的参数给到组件

    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()
          // 匹配不到的路由,执行 reject 函数,并返回 404
          if (!matchedComponents.length) {
            return reject({ code: 404 })
          }
          
          Promise.all(matchedComponents.map(Component => {
            if (Component.asyncData) {
              return Component.asyncData({
                store,
                route: router.currentRoute
              })
            }
          })).then(() => {
            // 在所有预取钩子(preFetch hook) resolve 后,
            // 我们的 store 现在已经填充入渲染应用程序所需的状态。
            // 当我们将状态附加到上下文,
            // 并且 `template` 选项用于 renderer 时,
            // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
            context.state = store.state
        
            resolve(app)
          }).catch(reject)
            // 对所有匹配的路由组件调用 `asyncData()`
        }, reject)
      }).catch(err => {
      })
    }

以上有个特别要指明的地方: Vue有两种形式,一个是vue文件, 另一个是直接在Vue实例上进行视图驱动, 两种数据填充是不同的

  • Vue文件 与React一样,会填充到window下的__INITIAL_STATE__属性, 并且在entry-client.js里,挂载DOM之前, 会取到这个数据
    import { createApp } from './app'
    
    // 客户端特定引导逻辑……
    
    const { app, store } = createApp()
    
    if (window.__INITIAL_STATE__) {
      store.replaceState(window.__INITIAL_STATE__)
    }
    
    // 这里假定 App.vue 模板中根元素具有 `id="app"`
    app.$mount('#app')
  • 实例 Vue会直接在data上定义一个相应数据, 可以直接使用
    // store.js
     mutations: {
      setItem (state, { id, item }) {
        Vue.set(state.items, id, item) // 直接定义一个响应数据
      }
    }

启动服务

Vue提供了一个api, createBundleRenderer, 用于处理官方介绍的问题

    每次编辑过应用程序源代码之后,都必须停止并重启服务。这在开发过程中会影响开发效率。此外,Node.js 本身不支持 source map

好处:

  • 内置的 source map 支持(在 webpack 配置中使用 devtool: 'source-map')

  • 在开发环境甚至部署过程中热重载(通过读取更新后的 bundle,然后重新创建 renderer 实例)

  • 关键 CSS(critical CSS) 注入(在使用 *.vue 文件时):自动内联在渲染过程中用到的组件所需的CSS。更多细节请查看 CSS 章节。

  • 使用 clientManifest 进行资源注入:自动推断出最佳的预加载(preload)和预取(prefetch)指令,以及初始渲染所需的代码分割 chunk。

    const express = require('express')
    const server = express()
    const path = require('path')
    const { createBundleRenderer  } = require('vue-server-renderer')
    const serverMainfest = path.resolve(__dirname, './dist/vue-ssr-server-bundle.json')
    
    const renderer = createBundleRenderer(serverMainfest, {
      runInNewContext: false, // true的时候  每次请求Vue会自己去实例化一个App, 这个主要就是避免交叉污染,但是其实我们已经避免了这个问题,所以可以设置为false, 毕竟这是一个性能消耗的过程
      template: require('fs').readFileSync(path.join(__dirname, './index.html'), 'utf-8'), // html模板, 
      // <div id="app">
      // <!--vue-ssr-outlet--> Vue会将内容自动填充到此处
      // </div>
      clientManifest: require(path.resolve(__dirname, './dist/vue-ssr-client-manifest.json')) // 这里就是之前webpack打包好的
    })
    
    server.use(express.static(path.resolve(__dirname, './dist')))
    
    server.get('*', (req, res) => {
      const context = {
        url: req.url
      }
      
      renderer.renderToString(context, (err, html) => {
        if (err) {
          res.status(500).end('Internal Server Error')
          return
        }
        res.end(`
          ${html}
        `)
      })
    })
    
    server.listen(8080)

GitHub: github.com/Gloomysunda…

遇到的问题

  • 在app.js实例化之前之前会有一些依赖于浏览器环境的第三方包, 例如FastClick, 或者是在组件引入第三方组件时也有依赖浏览器环境的包, 此时会报错window is not defined

解决: 由于服务端只会执行beforeCreate与created生命周期(因为服务端不会去挂载DOM), 所以可以在beforeMount方法里去动态加载模块

    beforeMount() {
        if (typeof window !== 'undefined') {
          const { swiper, swiperSlide } = require('vue-awesome-swiper').default
          this.$options.components.swiper = swiper
          this.$options.components.swiperSlide = swiperSlide
        }
    },
    mounted() {
        if (typeof window !== "undefined") {
          const Fastclick = require('fastclick')
          Fastclick.attach(document.body)
        }
    }
  • 由于数据预取完全是取决于数据请求回来的时间,可能数据加载过慢导致页面出现白屏, 此时需要做下loading状态

后言

不知道我说的够不够清楚,总之,SSR核心理念就是同构, 写一套代码通用于两端

服务端职责

  • 数据预取
  • 转换HTML字符串模板返回给客户端

浏览器职责

  • 获取服务器预取的数据
  • 挂载DOM