认识VUE服务端渲染到搭建第一个SSR项目

·  阅读 662

前言

如今提到前端技术栈,VUE、REACT等 MV* 框架已经是基本的前端知识,用这些框架可以快速地构建单页面应用(SPA),解决了多页面应用资源浪费的问题,同时提高了开发效率。但万物都有对立统一性,SPA也带来了它的问题:较差的SEO和首屏加载速度慢。如何解决这个问题呢?优秀的先驱者们给出的解决方案是:服务端渲染(SSR)。

SSR其实不是开创性的新技术,在前后端未分离之前,所有的应用程序几乎都是服务端渲染。SSR分为两种模式:单页面模式和非单页面模式。这篇文章主要了解的是单页面模式,将VUE单页面应用改造成SSR项目。

这篇文章将分为三个部分,第一个部分主要理解服务端渲染概念和做服务端渲染能解决的问题。第二个部分做一个改造VUE2的项目的服务端渲染实践。最后一个部分总结。

1. 什么是SSR

1.1 定义

先来看一下vue官网上关于服务端渲染的解释:

Vue.js 是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出 Vue 组件,进行生成 DOM 和操作 DOM。然而,也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。

简单理解一下,即将组装html的工作交给服务端完成,服务端输出html字符串给浏览器。我们知道,vue-cli构建的项目,一般是浏览器渲染(CSR)的。下面我们对比了解一下CSR和SSR的过程。

1.2 CSR 和 SSR

客户端渲染(CSR)时,和前端服务器的交互过程如图所示:

sequenceDiagram
客户端->>服务端: 请求,例如访问www.xxx.com/test1
服务端->>客户端: 返回html, app.bundle.js
Note left of 客户端: 渲染HTML,执行js
客户端->>服务端: 交互,发送数据请求
服务端->>客户端: 响应请求,返回数据

大体流程描述如下:

①首先浏览器请求URL,前端服务器直接返回一个空的静态HTML文件(不需要任何查数据库和模板组装),这个HTML文件中加载了很多渲染页面需要的 JavaScript 脚本和 CSS 样式表。

②浏览器拿到 HTML 文件后开始加载脚本和样式表,并且执行脚本,这个时候请求后端服务提供的API,获取数据,获取完成后将数据通过JavaScript脚本动态的将数据渲染到页面中,完成页面显示。

sequenceDiagram
客户端->>服务端: 请求,例如访问www.xxx.com/test1
Note right of 服务端: 读取请求,组装HTML
服务端-->>客户端: 返回对应路由的html
Note left of 客户端: 加载首屏html

大体流程描述如下:

①浏览器请求URL

②前端服务器接收到URL请求根据不同的URL,前端服务器向后端服务器请求数据,请求完成后,前端服务器会组装一个携带了具体数据的HTML文本,并且返回给浏览器

③浏览器得到HTML之后开始渲染页面,同时,浏览器加载并执行 JavaScript 脚本,给页面上的元素绑定事件,让页面变得可交互,当用户与浏览器页面进行交互,如跳转到下一个页面时,浏览器会执行 JavaScript 脚本,向后端服务器请求数据,获取完数据之后再次执行 JavaScript 代码动态渲染页面。

由此可见,客户端渲渲染时,浏览器承担着请求URL, 执行JS脚本以及渲染页面的工作,服务端渲染时,浏览器不用负责执行JS脚本来组装HTML这一步,减轻了浏览器的压力。同时应注意,当跳转下一下页面时,都是执行JavaScript脚本,进行路由跳转,也就是说,SPA的客户端渲染和服务端渲染的主要区别在于首屏html。

1.3 服务端渲染解决的问题

分析以上客户端渲染和服务端渲染,可以发现,纯浏览器渲染存在的问题:

①浏览器承担执行JS,渲染页面,请求数据等一系列工作, 会占用浏览器更多的运行内存,导致浏览器变卡。

②服务端返回的是空的HTML,不利于SEO

③浏览器拿到html后才请求下载JS, CSS, IMG等资源,然后在执行脚本生成页面,造成了一定的白屏时间,降低用户体验。

服务端渲染有更好的 SEO,更快的内容到达时间 (time-to-content)的优点,为解决浏览器渲染提供了一个好的方案。

2. VUE2项目改造成服务端渲染

企业微信截图_16324547684716.png

上图为vue服务端渲染官网里的图。由上图可以看出,服务端渲染包括三个部分:

  1. Source。包括通用的应用代码(vue项目的话就是vue-cli构建的项目), 服务端的打包入口文件(Server entry和客户端的打包入口文件(Client entry)。
  2. webpack build。包括服务端打包配置文件和客户端打包配置文件,这一步目的将项目代码打包输出bundle。
  3. Node Server。要将bundle最终输出为html字符串,需要一个node服务。

根据以上,我们将分三步来改造vue2项目。

2.1 第一步,改造vue(main,router,vuex),编写server-entry.js和client-entry.js

main, router, vuex

在非SSR情况下,每个用户访问网站时,是将代码下载到客户端,然后独立渲染运行,因此每个用户的操作都是互不影响的。但是服务端渲染时,所有的用户访问的都是同一套运行在服务端的代码,如果创建一个单例对象,那么所有的用户将共享这个实例,很容易造成交叉请求状态污染,结果就是用户请求的页面不是自己当前请求处于的状态。这显然是不合理的。因此,应该为每次请求创建新的根实例,我们应该暴露一个可以重复执行的工厂函数。

  1. main.js
export function createApp () {
  ...
  const app = new Vue({
    router,
    store,
    // 根实例简单的渲染应用程序组件。
    render: h => h(App)
  })
  return { app, router, store}
}
复制代码
  1. router/index.js
const router = new VueRouter({
  mode: 'history', // 
  ...
})

export default function createRouter () {
  return router
}
复制代码
  1. vuex/index.js
const store = new Vuex.Store({
  state: {
  },
  mutations: {
  },
  actions: {
  },
  modules: {
  }
})
export default function createStore () {
  return store;
}
复制代码
  1. server-entry.js

sever-entry.js 暴露一个函数,接收一个参数context, 这个参数即为服务端渲染的上下文对象,包含服务端请求体中的信息。每次渲染时重复调用此函数,接受前端路由,将页面内的router-view内的内容显示为对应的页面,同时路由准备好之后执行数据预取逻辑(asyncData暴露在每个component上)

import { createApp } from './main'
// context:地址 context = req.url  {url: /page1}
// 每次访问时都会执行context => {}这个方法
export default context => {
  // promise
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()
    // 让router-view里的内容变成page1的内容
    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(() => {
        // 在所有预取钩子(preFetch hook) resolve 后,
        // 我们的 store 现在已经填充入渲染应用程序所需的状态。
        // 当我们将状态附加到上下文,
        // 并且 `template` 选项用于 renderer 时,
        // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
        context.state = store.state
        resolve(app)
      }).catch(reject)
    }, reject)
  })
}
复制代码
  1. client-entry.js

client-entry.js实现客户端激活(client-side hydration),即将应用程序挂载在根元素上,将其变为由Vue管理的动态DOM的过程。通过在根元素上添加 data-server-rendered="true"属性,标记html为服务端渲染的,同时激活html进行后期交互和数据处理。

import { createApp } from './main'
// 客户端激活,将app里的内容挂到响应的页面上。

const { app, router,store  } = createApp()

router.onReady(() => {
  app.$mount('#app')
})
复制代码

注意点:vrouter.onReady

2.2 第二步,编写打包配置文件

核心模块是vue-server-renderer。server-plugin和client-plugin分别负责打包服务端依赖和客户端依赖。

  1. webpack.server.config.js
const nodeExternals = require('webpack-node-externals')

const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

module.exports = {
    css: {
        // 不提取 CSS
        extract: false
    },
    outputDir: 'serverDist',
    configureWebpack: () => ({
        // 服务器入口文件
        entry: `./src/server-entry.js`,
        devtool: 'source-map',
        // 构建目标为nodejs环境
        target: 'node', // 运行在node端,所以指定打包目标(webpack,不写默认以浏览器标准)
        output: {
            filename: 'server-bundle.js',
            // 构建目标加载模式 commonjs
            libraryTarget: 'commonjs2',
        },
        // 跳过 node_mdoules,运行时会自动加载,不需要编译
        externals: nodeExternals({
            // 允许css文件,方便css module
            allowlist: [/\.css$/]
        }),
        // 关闭代码切割
        optimization: {
            splitChunks: false
        },
        plugins: [
            new VueSSRServerPlugin()
        ]
    })
}
复制代码
  1. webpack.client.config.js
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

const config = {
    // 这是将服务器的整个输出
    // 构建为单个 JSON 文件的插件。
    // 默认文件名为 `vue-ssr-server-bundle.json`
    configureWebpack: () => ({
        entry: `../src/client-entry.js`,
        devtool: 'source-map',
        plugins: [
            new VueSSRClientPlugin()
        ]
    }),
    chainWebpack: config => {
        config.plugins.delete('html');
        config.plugins.delete('preload');
        config.plugins.delete('prefetch');
    }
}
module.exports = config
复制代码

2.3 第三步,构建服务端代码

1.server.js

主要步骤:

①读取server bundle和 client bundle

②获取vue-server-renderer的createBundleRenderer对象

③利用createBundleRenderer对象将template, server bundle,client bundle结合生成渲染器renderer,生成vue实例。

④获取路由,renderer的renderToString方法将页面序列化,将序列化后的html返回。

const vue = require('vue')
const express = require('express');
const server = express()
const fs = require('fs')
const path = require('path')

const serverBundle = require(path.resolve(process.cwd(), 'serverDist', 'vue-ssr-server-bundle.json'));
const clientManifestPath = path.resolve(process.cwd(), 'dist', 'vue-ssr-client-manifest.json');
const clientManifest = JSON.parse(fs.readFileSync(clientManifestPath, 'utf-8'));
// html文件不是模块化的,所有只能fs读取文件的方式读取
const template = fs.readFileSync(path.resolve(__dirname,'index.html'), 'utf-8')
server.use(express.static(path.resolve(process.cwd(), 'dist')));
const { createBundleRenderer } = require('vue-server-renderer')
// renderer:生成vue实例
const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false,
  clientManifest: clientManifest,
  template:template
});
// const {  } = require('./main')
// * 代表用户只要访问1000的这个服务,无论请求哪个地址都会执行这个方法。
server.get('*', (req, res) => {
  // req 请求体
  // res 响应体
  if(req.url !== '/favicon.icon') {
    // context为传给server-entry的context
    const context = {
      url: req.url,
    }
    renderer.renderToString(context).then((html) => {
      res.end(html)
    })
  }
})
server.listen(1000)
复制代码

2.index.html

vue-ssr-outlet这个注释,告诉服务器将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>ssr-demo</title>
</head>
<body>
  <!--vue-ssr-outlet-->
</body>
</html>
复制代码

2.4. 补充用vue + nuxtJs做服务端渲染

nuxt做vue的服务端渲染超级简单,只需要用 npx create-nuxt-app app-name 就可以快速初始化一个SSR项目。项目目录如下:

capture_20210928001141932.bmp

  1. pages文件夹:放路由对应的页面,一个.vue文件代表一个路由,文件路劲即为URL,因此命名需谨慎。
  2. 其他文件夹的作用同vue项目。
  3. 运行只需要根据package.json里的脚本执行。

3. 总结

当用户访问服务端渲染的网站时,服务器将URL传递给渲染器,渲染器根据server-client暴露的函数,跳转到对应的路由组件,在服务器端组装html,返回给浏览器,浏览器加载html,减少了浏览器端执行脚本组装html的时间,使用户的体验更佳,同时返回组装好的html里有更多的网站信息,更利于SEO.

服务端渲染已经是很久之前出现的知识了,之前仅仅停留在看下文档的阶段,一直没有亲自写过,这次仅仅按照文档做了一个最基础的项目,过程中依然出现了很多问题,所以开发者要提升自己,还是要多动手。

最后,以几句七言绝句 “ 纸上得来终觉浅,绝知此事要躬行 ” 时刻提醒自己。

源代码的位置为:SSR-demo

参考文献

分类:
前端
标签:
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改