当前位置: 首页 > 工具软件 > Vue-SSR > 使用案例 >

基于vue2的vue-ssr体验学习

皇甫乐
2023-12-01

主要库包版本:

	"vue": "^2.6.11",
    "vue-server-renderer": "^2.6.11",
    "vuex": "^3.5.1"
    "@babel/core": "^7.10.4",
    "@babel/plugin-transform-runtime": "^7.10.4",
    "@babel/preset-env": "^7.10.4",
    "babel-loader": "^8.1.0",
    "css-loader": "^3.6.0",
    "file-loader": "^6.0.0",
    "vue-template-compiler": "^2.6.11",
    "webpack": "^4.43.0",
    "webpack-cli": "^3.3.12"

一、实践

1、nodejs做服务器返回静态文件(<基本用法>):

1.1、初始化项目:

npm install vue vue-server-renderer --save

此处有坑:vue与vue-server-renderer的版本必须一致,而vue-server-renderer当前最新版本为2.6.14,必须使用vue2.x,而vue2.x最高版本只到2.6.11,因此两个版本均为2.6.11保持一致,才可开发;

1.2、实例:

  1. 创建vue实例,使用vue-ssr渲染成html,启动服务访问;

  2. 抽离模板为index.template.html,测试插值:使用双花括号(double-mustache)进行 HTML 转义插值(HTML-escaped interpolation),使用三花括号(triple-mustache)进行 HTML 不转义插值(non-HTML-escaped interpolation)

  3. 解决问题:

    • 乱码:返回给浏览器请求头指定编码格式/在模板里指明编码格式。
const Vue = require('vue');
const server = require('express')();
const fs = require('fs');
const renderer = require('vue-server-renderer').createRenderer({
    template: fs.readFileSync('./index.template.html', 'utf-8')
});


server.get('*', (req, res) => {
    const app = new Vue({
        data: {
            url: req.url
        },
        template: `<div>url:{{url}}</div>`
    })

    renderer.renderToString(app, {title: 'hhh', meta: '<meta charset="UTF-8">'}).then(html => {
        // res.setHeader('Content-type', 'text/html;charset=utf8'); // 解决乱码,需指明给浏览器
        res.end(html)
    }).catch(error => {
        res.status(500).end('Internal Server Error' + error.toString())
    })
})

server.listen(8080) // 3000为更优,开发习惯

2、vue-ssr工作原理(<源码结构 + 客户端激活>)

先看源码结构是因为有vue ssr工作的原理图,有助于理解原理

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1uhIbT6n-1635214413157)(C:\Users\v_mjvzhang\AppData\Roaming\Typora\typora-user-images\1632812586651.png)]

通用APP代码通过app.js文件集成前后端入口文件(server),分别处理前后端渲染特定逻辑(如:数据预取、设置服务端router等),经由webpack配置打包,分别生成前后端bundle文件,服务器需要「服务器 bundle」然后用于服务器端渲染(SSR),而「客户端 bundle」会发送给浏览器,用于混合静态标记。
提点:

  • 每个请求应由可重复执行的工厂函数创建一个单例(vue、router、store 、event bus等应为如此 ),与在浏览器中使用新应用程序一致,避免状态污染
  • 通常 Vue 应用程序是由 webpack 和 vue-loader 构建,并且许多 webpack 特定功能不能直接在 Node.js 中运行(例如通过 file-loader 导入文件,通过 css-loader 导入 CSS)
    使用 webpack 的源码结构
src
├── components
│   ├── Foo.vue
│   ├── Bar.vue
│   └── Baz.vue
├── App.vue
├── app.js # 通用 entry(universal entry) 简单导出一个creatApp
├── entry-client.js # 仅运行于浏览器  创建模板,挂载app.$mount('#app')
└── entry-server.js # 仅运行于服务器  创建返回程序实例app,执行服务器端路由匹配和数据预取逻辑 。

客户端激活

  • data-server-rendered 特殊属性,让客户端 Vue 知道这部分 HTML 是由 Vue 在服务端渲染的,并且应该以激活模式进行挂载。注意,这里并没有添加 id="app",而是添加 data-server-rendered 属性:你需要自行添加 ID 或其他能够选取到应用程序根元素的选择器,否则应用程序将无法正常激活。

  • 在没有 data-server-rendered 属性的元素上,app.$mount('#app', true)来强制使用激活模式

  • 开发模式下,vue推断客户端生成的virtual DOM tree是否与服务器渲染的DOM结构匹配,不匹配则退出混合模式丢弃现有DOM从头渲染;生成模式下,避免性能损耗会跳过此检测步骤;因此需要注意一些浏览器会帮助修改不符合标准的一些HTML结构,如tbody等会导致dom结构不一致。

3、完善webpack打包配置(<构建配置>)

配置分为三个文件:base, clientserver。基本配置 (base config) 包含在两个环境共享的配置,例如,输出路径 (output path),别名 (alias) 和 loader。服务器配置 (server config) 和客户端配置 (client config),可以通过使用 webpack-merge 来简单地扩展基本配置。

根据官网默认配置,运行采坑:

  • Uncaught SyntaxError: Unexpected token <
    页面打包之后后引入:<script src="/dist/app[hash].js" defer=""></script>,拉取资源会有500报错。因为这是静态资源,需要再服务器下注册一下
//server.js
server.use('/dist', express.static('./dist'))
  • [vue-server-renderer-webpack-plugin] webpack config output.libraryTarget should be “commonjs2”.

    Error: Entry “main” not found. Did you specify the correct entry option?

    暂降低版本为webpack4;

  • 报错:@vitejs/plugin-vue requires vue (>=3.2.13) or @vue/compiler-sfc to be present in the dependency tree;

    不去升级vue到3版本,因为vue-server-renderer版本只到2.x,而需要保持一致;安装@vue/compiler-sfc

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Wj204pXx-1635214413159)(C:\Users\v_mjvzhang\AppData\Roaming\Typora\typora-user-images\1632822793949.png)]

  • Internal Server ErrorError: Cannot find module ‘vue/server-renderer’

    这个错误一直很懵逼,几乎调整了所有npm包的版本 ???

4、基础优化

  • bundle renderer

    • 内置的 source map 支持(在 webpack 配置中使用 devtool: 'source-map'
    • 在开发环境甚至部署过程中热重载(通过读取更新后的 bundle,然后重新创建 renderer 实例)
    • 关键 CSS(critical CSS) 注入(在使用 *.vue 文件时):自动内联在渲染过程中用到的组件所需的CSS。更多细节请查看 CSS 章节。
    • 使用 clientManifest 进行资源注入:自动推断出最佳的预加载(preload)和预取(prefetch)指令,以及初始渲染所需的代码分割 chunk。
  • nodemon监控代码变更,自动重新启动服务

  • 区分环境开发,开发模式下自动监听打包(bundle renderer依赖的文件template、bundle、mainfest)

    综上,代码调整如下:

    // server.js
    const Vue = require('vue');
    const express = require('express');
    const server = express();
    const fs = require('fs');
    const {createBundleRenderer} = require('vue-server-renderer');
    
    // 物理磁盘中的资源
    server.use('/dist', express.static('./dist'))
    
    const setupDevServer = require('./build/setup-dev-server')
    
    let renderer
    let onReady // 拿到返回的promise状态
    const isProd = process.env.NODE_ENV === 'production';
    if(isProd){
        const serverBundle = require('./dist/vue-ssr-server-bundle.json')
        const template = fs.readFileSync('./index.template.html', 'utf-8')
        const clientManifest = require('./dist/vue-ssr-client-manifest.json')
        renderer = createBundleRenderer(serverBundle, {
            template, clientManifest
        });
    }else{
        // 监视打包构建 -> 重新生成Renderer渲染器
        onReady = setupDevServer(server, (template, serverBundle, clientManifest) => {
            renderer = createBundleRenderer(serverBundle, {
                template, clientManifest
            });
        }); // 传递server,挂载中间件
    }
    
    // 匹配路由之后渲染的render
    const render = (req, res) => {
        // app实例自动去找, renderToString第一个参数即为context对象
        renderer.renderToString({
            url: req.url
        }).then(html => {
            // console.log('html ---> ', html);
            // res.setHeader('Content-type', 'text/html;charset=utf8');
            res.end(html)
        }).catch(error => {
            console.log('error ---> ', error);
            res.status(500).end('Internal Server Error' + error.toString())
        })
    }
    
    const renderDev = async (req, res) => {
        // 等待有render之后再渲染
        await onReady
        render(req, res)
    }
    
    server.get( '*', isProd ? render: renderDev)
    
    server.listen(8080)
    
    const fs = require('fs');
    const path = require('path');
    const chokidar = require('chokidar');
    const webpack = require('webpack');
    const webpackDevMiddleware = require('webpack-dev-middleware');
    const hotMiddleware = require('webpack-hot-middleware');
    
    const fileResolve = file => path.resolve(__dirname, file);
    
    module.exports = (server, callback) => {
        
        let ready
        const onReady = new Promise(r => ready = r); // ready = resovler,等待调用
    
        let serverBundle
        let template
        let clientManifest
    
        const update = () => {
            if(template && serverBundle && clientManifest){
                ready();
                callback(template, serverBundle, clientManifest)
            }
        }
    
        // 监视构建 -> 更新update -> 更新render
        // 1、监视template
        const templatePath = fileResolve('../index.template.html')
        template = fs.readFileSync(templatePath, 'utf-8')
        update()
        // fs.watch fs.watchFile
        // chokidar 监视包
        chokidar.watch(templatePath).on('change', () => {
            template = fs.readFileSync(templatePath, 'utf-8')
            // console.log('template ---> ', template);
            update()
        })
    
        // 2、监视serverBundle
        const serverConf = require('./webpack.server.config')
        const serverCompiler = webpack(serverConf);
        // webpack打包结果存在磁盘,开发模式下内存会快些; <Custom File Systems>- memfs包\ webpack-dev-middleware
        const serverDevMid = webpackDevMiddleware(serverCompiler, {
            logLevel: 'silent', // 关闭日志输出,由FriendlyErrorsWebpackPlugin统一管理日志
        })
        serverCompiler.hooks.done.tap('server', () => {
            serverBundle = JSON.parse(serverDevMid.fileSystem.readFileSync(fileResolve('../dist/vue-ssr-server-bundle.json'), 'utf-8'))
            // console.log('serverBundle ---> ', serverBundle);
            update()
        })
        // serverCompiler.watch({}, (err, stats) => {
        //     if(err) throw err; // webpack的错误
        //     if(stats.hasErrors()) return // 代码自身错误
        //     serverBundle = JSON.parse(fs.readFileSync(fileResolve('../dist/vue-ssr-server-bundle.json'), 'utf-8'))
        //     console.log('serverBundle ---> ', serverBundle);
        //     update()
        // })
        
    
        // 3、监视clientManifest
        const clientConf = require('./webpack.client.config')
        clientConf.plugins.push(new webpack.HotModuleReplacementPlugin())
        clientConf.entry.app = [
            'webpack-hot-middleware/client?quiet=true&reload=true', // 和服务端交互处理热更新一个客户端脚本
            clientConf.entry.app
        ]
        clientConf.output.filename = '[name].js' // 热更新下不启动hash
        const clientCompiler = webpack(clientConf);
        const clientDevMid = webpackDevMiddleware(clientCompiler, {
            publicPath: clientConf.output.publicPath,
            logLevel: 'silent',
        })
        clientCompiler.hooks.done.tap('client', () => {
            clientManifest = JSON.parse(clientDevMid.fileSystem.readFileSync(fileResolve('../dist/vue-ssr-client-manifest.json'), 'utf-8'))
            // console.log('clientManifest ---> ', clientManifest);
            // console.log('client change');
            update()
        })
        
        server.use(hotMiddleware(clientCompiler, {
            log: false // 关日志
        }))
    
        // 将 clientDevMid 挂载到express服务中,提供对其内部内存中数据的访问
        server.use(clientDevMid);
    
        return onReady
    }
    
    // package.json scripts
    "scripts": {
        "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js",
        "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js",
        "build": "rimraf dist && npm run build:client && npm run build:server",
        "start": "cross-env NODE_ENV=production node server.js",
        "dev": "node server.js",
        "nmdev": "nodemon server.js"
      },
    

5、添加路由,多页面跳转(<路由和代码分割>)

提点:

  • server中匹配’*’,获取url传递到vue程序,对客户、服务端复用相同的路由配置

  • mode: ‘history’, // 服务端不支持hash

  • 服务端要等到routrer将可能的异步组件和钩子函数解析完router.onReady才可进行返回,客户端也是才能挂载app

  • 异步组件,实现惰性加载(代码分割),有助于减少浏览器在初始渲染中下载的资源体积,可以极大地改善大体积 bundle 的可交互时间(TTI - time-to-interactive)。这里的关键在于,对初始首屏而言,“只加载所需”。

  • pretch预加载资源

    客户端尽早接管服务端的内容,减少服务器的损耗;

    <link rel="preload" href="/dist/app.js" as="script">
    <link rel="prefetch" href="/dist/2.js">
    

    link 预加载,不执行代码,不影响渲染;提高加载渲染的速度

    preload当前页面一定用到的资源

    prefetch可能下页要用到的,浏览器空闲的时候加载(不一定加载到)

6、存取数据(数据预取和状态>)

通常页面都是数据驱动的,且客户、服务端状态一致才能渲染成功,

所以ssr渲染之前,需先预取和解析好这些数据;另为使得客户、服务端状态相同混合成功,需要获取到服务端渲染完全相同的数据。

需要中间容器(缓存数据的地方),选取vuex,服务端serverPrefetch()生命周期函数获取之后存入,在html页面通过window对象内联inline给客户端,客户端再存入vuex中进行正常vue状态管理

// entry-server.js
import { createApp } from './app'

export default async context => {
  // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
    // 以便服务器能够等待所有的内容在渲染前,
    // 就已经准备就绪。
  const { app, router, store } = createApp()
  // 设置服务器端 router 的位置
  router.push(context.url)

  await new Promise(router.onReady.bind(router))

  context.rendered = () => { // 服务端渲染完毕调用
    // Renderer 会把 context.state 数据对象内联到页面模板中
    // 最终发送给客户端的页面中会包含一段脚本:window.__INITIAL_STATE__ = context.state
    // 客户端就要把页面中的 window.__INITIAL_STATE__ 拿出来填充到客户端 store 容器中
    context.state = store.state
  }

  return app
}
import { createApp } from './app'

// 客户端特定引导逻辑……

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

if(window.__INITIAL_STATE__){
    store.replaceState(window.__INITIAL_STATE__)
}

// 这里假定 App.vue 模板中根元素具有 `id="app"`
router.onReady(() => {
    app.$mount('#app')
})

返回代码效果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gYdDDDvP-1635214413162)(C:\Users\V_MJVZ~1\AppData\Local\Temp\企业微信截图_16328118502426.png)]

其他思考:这样岂非便利了爬虫?

6、其他

  • head管理

    使用vueMeta包

    // app.js
    import VueMeta from 'vue-meta'
    
    Vue.use(VueMeta)
    
    Vue.mixin({
      metaInfo: {
        titleTemplate: '%s - dudu'
      }
    })
    
    // entry-server.js
    const meta = app.$meta()
    context.meta = meta
    
  • 缓存

    对一些静态或不易更改的页面进行一定时间的缓存配置,或可加上时间戳增大可缓存文件的范围

  • 流式渲染

    依赖生命周期填充数据,不建议使用。虽然在流式渲染模式下,当 renderer 遍历虚拟 DOM 树 (virtual DOM tree) 时,会尽快发送数据。这意味着我们可以尽快获得"第一个 chunk",并开始更快地将其发送给客户端。

    然而,当第一个数据 chunk 被发出时,子组件甚至可能不被实例化,它们的生命周期钩子也不会被调用。这意味着,如果子组件需要在其生命周期钩子函数中,将数据附加到渲染上下文 (render context),当流 (stream) 启动时,这些数据将不可用。

  • 非nodejs环境中使用

    官方文档

7、编写通用代码

提点:

  • 只有 beforeCreatecreated 会在服务器端渲染 (SSR) 过程中被调用。应该避免在 beforeCreatecreated 生命周期时产生全局副作用的代码,例如在其中使用 setInterval 设置 timer,移到beforeMountmounted 生命周期中
  • 通用代码不可接受特定平台的 API,独立开发
  • 大多数自定义指令直接操作 DOM,因此会在服务器端渲染 (SSR) 过程中导致错误。有两种方法可以解决这个问题:
    1. 推荐使用组件作为抽象机制,并运行在「虚拟 DOM 层级(Virtual-DOM level)」(例如,使用渲染函数(render function))。
    2. 如果你有一个自定义指令,但是不是很容易替换为组件,则可以在创建服务器 renderer 时,使用 directives 选项所提供"服务器端版本(server-side version)"。

8、拓展了解

与传统 SPA (单页应用程序 (Single-Page Application)) 相比,服务器端渲染 (SSR) 的优势主要在于:

  • 更好的 SEO,由于搜索引擎爬虫抓取工具可以直接查看完全渲染的页面。抓取工具并不会等待异步完成后再行抓取页面内容
  • 更快的内容到达时间 (time-to-content),特别是对于缓慢的网络情况或运行缓慢的设备。无需等待所有的 JavaScript 都完成下载并执行,才显示服务器渲染的标记,所以你的用户将会更快速地看到完整渲染的页面。通常可以产生更好的用户体验,并且对于那些「内容到达时间(time-to-content) 与转化率直接相关」的应用程序而言,服务器端渲染 (SSR) 至关重要。

使用服务器端渲染 (SSR) 时还需要有一些权衡之处:

  • 开发条件所限。浏览器特定的代码,只能在某些生命周期钩子函数 (lifecycle hook) 中使用;一些外部扩展库 (external library) 可能需要特殊处理,才能在服务器渲染应用程序中运行。
  • 涉及构建设置和部署的更多要求。与可以部署在任何静态文件服务器上的完全静态单页面应用程序 (SPA) 不同,服务器渲染应用程序,需要处于 Node.js server 运行环境。
  • 更多的服务器端负载。在 Node.js 中渲染完整的应用程序,显然会比仅仅提供静态文件的 server 更加大量占用 CPU 资源 (CPU-intensive - CPU 密集),因此如果你预料在高流量环境 (high traffic) 下使用,请准备相应的服务器负载,并明智地采用缓存策略

ssr vs 前端同构应用,概念对比

vue ssr官网有说,服务器渲染的 Vue.js 应用程序也可以被认为是"同构"或"通用",因为应用程序的大部分代码都可以在服务器客户端上运行。意即:同构即为服务客户端渲染同一套代码,各自完成对应的任务(服务端直出html,客户端处理交互及后续路由切换)

**备注:**实际的业务应用开发才是所有技术服务的重点,需要谨记开发规范;

 类似资料: