VUE SSR基础功能搭建

543 阅读7分钟

前言

做C端产品免不了SEO(百度),SEO有个很关键点是非SPA页面,以目前前端的趋势都是SPA,因为SPA用户体验更好。而对于产品来说SEO很重要,又不想回到JQuery时代或者PHP,那么,只能搞搞SSR了。

建议:根据官方推荐,如果没特殊需要,还是使用nuxt.js吧,简单直接

SSR简要分析

1、概述

  1. 用户通过url请求页面;
  2. 服务器根据url指向ssr渲染的node服务;
  3. node服务根据vue ssr插件生成的JSON执行vue代码(这里需要push url到vue-router);
  4. vue生成当前url的html的页面源码字符串,将store的数据放在window.__INITIAL_STATE__里;
  5. node服务接收该字符串,并返回给浏览器;
  6. 浏览器渲染当前页面,检测是否有window.__INITIAL_STATE__,有则还原到vuex里
  7. 前端跳转到其他页面,则不进行ssr渲染,是客户端渲染(vue拦截了),但,刷新是服务端渲染

所以,ssr实际上是服务端渲染当前url的页面,直到下次刷新;因此,完美应用ssr能力,应该是传统的页面架构,即:点击开一个新tab的页面架构。

2、SSR构建后的基本文件

ssr_file.png

  1. index.html文件在SSR下是无用的,但是SPA下是根文件
  2. index.template.html是服务端渲染的页面模板
  3. server.js是服务端渲染的node执行文件
  4. vue-ssr-client-manifest.json是vue-server-renderer/client-plugin插件生成的客户端构建清单
  5. vue-ssr-server-bundle.json是vue-server-renderer/server-plugin插件生成的服务器配置传递给 createBundleRenderer 的 server bundle
  6. 其他文件属于正常的源码文件,提供给vue-ssr-server-bundle.json生成页面使用的

完整项目server.js包含压缩、缓存等功能,这里仅做基础功能

3、用户请求

sequenceDiagram
用户 ->> 服务器: 请求图片
服务器 ->> node: 请求链接
node -->> 服务器: 返回图片
服务器 -->> 用户: 返回图片
用户 ->> 服务器: 请求页面
服务器 ->> node: 请求链接
node ->> node: 服务端运行vue代码(只执行到created生命周期)
node ->> node: 将vm通过vue-server-renderer/renderToString生成页面
node -->> 服务器: html字符串
服务器 --) 用户: 返回页面

4、node渲染页面过程(vue官网基本用法

const path = require('path') // node path
const resolve = file => path.resolve(__dirname, file)
// 1. 
// 引入index.template.html模板(包含:<!--vue-ssr-outlet-->,这个程序插入代码的地方)
// 引入createBundleRenderer、vue-ssr-server-bundle.json、vue-ssr-client-manifest.json

const template = fs.readFileSync(resolve('./index.template.html'), 'utf-8')
const { createBundleRenderer } = require('vue-server-renderer')
const bundle = require(resolve('./vue-ssr-server-bundle.json'))
const clientManifest = require(resolve('./vue-ssr-client-manifest.json'))

// 2. 
// 创建 BundleRenderer 实例
const renderer = createBundleRenderer(bundle, {
  runInNewContext: false,
  template,
  clientManifest
})

// 3.
// 生成页面, context是上下文
renderer.renderToString(context).then(html => {
  console.log(html) // 这即是页面,返回给浏览器即可
}).catch(err => {
  console.error(err)
})

上面代码只是分析过程,后面才是可运行代码。细心的同学发现,这里并没有引入页面代码,怎么渲染出页面的呢?其实只要打开vue-ssr-server-bundle.json文件看看就知道了。

生成代码及构建

生成代码

1. 使用vue create快速生成脚手架

vue_cli_base.png

2. 源码结构,及代码(vue的结构主要是防止不同的用户访问到相同的内存数据)

vue_org_struct.png

熟悉基本原理之后,文件结构你就可以随意玩了。下面是主要改动的文件

router.js / createRouter

关键点:history模式

import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
  }
]

export function createRouter () {
  return new VueRouter({
    mode: 'history', // 这里必须是history模式,hash服务端是接收不到的
    base: process.env.BASE_URL,
    routes
  })
}

store.js / createStore

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export function createStore () {
  return new Vuex.Store({
    state: {
    },
    mutations: {
    },
    actions: {
    },
    modules: {
    }
  })
}

app.js

import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store'

Vue.config.productionTip = false

export function createApp () {
  const router = createRouter()
  const store = createStore()

  const app = new Vue({
    router,
    store,
    render: h => h(App)
  })

  return { app, router, store }
}

entry-client.js

该文件,有些比较熟悉ssr的同学发现和官网最终的效果不一样,少了一些路由的处理,实际上,如果最终的项目架构是多页面模式(MPA),那么,这个就这样也没问题。前面我说了,ssr是渲染浏览器刷新时url的页面,之后的页面是SPA,就需要router,客户端模拟服务端请求接口的过程;如果是MPA,那么,就这样也是没问题的。

import { createApp } from './app'

const { app, store } = createApp()

// 服务端获取数据渲染给页面后,输入根据这个继续服务端的逻辑
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

app.$mount('#app')

当然,你写上,和用不用有什么关系?当然没关系。代码如下:

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

// 客户端特定引导逻辑……
const { app, router, store } = createApp()

// store状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

router.onReady(() => {
  // 添加路由钩子函数,用于处理 asyncData.
  // 在初始路由 resolve 后执行,
  // 以便我们不会二次预取(double-fetch)已有的数据。
  // 使用 `router.beforeResolve()`,以便确保所有异步组件都 resolve。
  router.beforeResolve((to, from, next) => {
    const matched = router.getMatchedComponents(to)
    const prevMatched = router.getMatchedComponents(from)

    // 我们只关心非服务端渲染、已渲染的组件
    // 所以我们对比它们,找出两个匹配列表的差异组件
    let diffed = false
    const activated = matched.filter((c, i) => {
      return diffed || (diffed = (prevMatched[i] !== c))
    })

    if (!activated.length) {
      return next()
    }

    // 这里如果有加载指示器 (loading indicator),那么,就触发处理
    Promise.all(activated.map(c => {
      if (c.asyncData) {
        return c.asyncData({ store, route: to })
      }
    })).then(() => {

      // 停止加载指示器(loading indicator)

      next()
    }).catch(next)
  })
  
  app.$mount('#app')
})

entry-server.js

该代码的主要作用是,在渲染页面前,先请求数据,请求完数据塞进vuex里,再进行页面渲染, 如果接口卡很久,不好意思,页面就会卡很久

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

export default content => {
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()

    // 请求的url,定位需要渲染的页面
    router.push(content.url)

    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()

      if (!matchedComponents.length) {
        return resolve(app)
      }

      Promise.all(matchedComponents.map(component => {
        // asyncData是页面的对象的key,跟data、created、mounted同级
        // 是在页面运行前执行的代码,可以更改为其他的
        if (component.asyncData) {
          return component.asyncData({ store, route: router.currentRoute })
        }
      })).then(() => {
        // content是哪里来的参数?console.log就知道了
        // console.log(content)
        // 该状态会存入window.__INITIAL_STATE__中,如下图
        content.state = { ...store.state, desc: 'test' }

        resolve(app)
      }).catch(reject)
    }, reject)
  })
}

上面代码中content.state 对象的值:

QQ浏览器截图20220217183233.png

index.template.html

别漏了<!--vue-ssr-outlet-->,这是个标记,服务端渲染后的代码插入的地方,类似与vue的#app

<!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">
  <meta name="keyword" content="{{ keyword }}">
  <meta name="description" content="{{ description }}">
  <title>{{ title }}</title>
</head>

<body>
  <!--vue-ssr-outlet-->
</body>

</html>

3. 更改vue.config.js,生成服务端渲染需要的vue-ssr-server-bundle.json和客户端需要vue-ssr-client-manifest.json以及源码

1. 最终生成的dist文件

vue_ssr_code.png

2. 更改package.json,生成目标代码文件

  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "npm run build:client && npm run build:server && node ./server/copy_server.js",
    "build:client": "vue-cli-service build",
    "build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build --no-clean",
    "lint": "vue-cli-service lint"
  }
  • node ./server/copy_server.js是将上图server/server.js拷贝到dist文件夹下的node代码,后面用到
  • 执行npm run build生成上图除server.js外的代码
  • build:server的参数--no-clean不能去掉,否则执行npm run build:server会删除npm run build:client的dist

当然,你也可以更改vue.config.jsentry,分别将文件放在不同的位置,just ok,以及cross-env依赖(npm install cross-env -D

3. 更改/新增vue.config.js,参考

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

const nodeExternals = require('webpack-node-externals')

const WEBPACK_TARGET = process.env.WEBPACK_TARGET === 'node'

module.exports = {
  css: {
    extract: false
  },
  configureWebpack: {
    entry: `./src/entry-${WEBPACK_TARGET ? 'server' : 'client'}.js`, // 根据关键指向不同的入口
    devtool: 'source-map',
    target: WEBPACK_TARGET ? 'node' : 'web',
    node: WEBPACK_TARGET ? undefined : false,
    output: {
      libraryTarget: WEBPACK_TARGET ? 'commonjs2' : undefined // node环境源码的模块化规范
    },
    externals: WEBPACK_TARGET ? nodeExternals({ allowlist: [/\.css$/] }) : undefined,
    optimization: {
      splitChunks: undefined
    },
    plugins: [WEBPACK_TARGET ? new VueSSRServerPlugin() : new VueSSRClientPlugin()]
  },
  chainWebpack: config => {
    if (WEBPACK_TARGET) {
      config.optimization.delete('splitChunks')
    }

    config.module
      .rule('vue')
      .use('vue-loader')
      .tap(options => {
        options.optimizeSSR = false // https://vue-loader-v14.vuejs.org/zh-cn/options.html#optimizessr
        return options
      })
  }
}

别忘了依赖: -D vue-server-renderer、-D webpack-node-externals

到这里,就可以执行npm run build,生成除了server.js外的文件了

构建生产环境

完成以上步骤,距离我们的图中的源码仅剩一步之遥:server.js

记得npm install koa koa-send -S,因为node机器上有node没有koa和koa-send,当然要-S啦

1. server.js

const fs = require('fs')
const path = require('path')

const Koa = require('koa')
const send = require('koa-send')

const app = new Koa()
const resolve = file => path.resolve(__dirname, file)

// 第 2 步:获得一个createBundleRenderer
const template = fs.readFileSync(resolve('./index.template.html'), 'utf-8')
const { createBundleRenderer } = require('vue-server-renderer')
const bundle = require(resolve('./vue-ssr-server-bundle.json'))
const clientManifest = require(resolve('./vue-ssr-client-manifest.json'))

const renderer = createBundleRenderer(bundle, {
  runInNewContext: false,
  template,
  clientManifest
})

function renderToString (content) {
  return new Promise((resolve, reject) => {
    renderer.renderToString(content, (err, html) => {
      err ? reject(err) : resolve(html)
    })
  })
}

// 注入变量
app.use(async (ctx, next) => {
  ctx.ssrContext = {
    url: ctx.path + ctx.search,
    title: 'title',
    des: '是我,是我,就是我', // 这个是个测试,在entry-server.js的console content就能获取
    description: 'description',
    keyword: 'keyword'
  }

  await next()
})

// 第 3 步:添加一个中间件来处理所有请求
app.use(async (ctx, next) => {
  const url = ctx.path
  if (/[.](js|css|jpg|jpeg|png|gif|map|ico|cur|json|html|txt|svg|font|woff|ttf)$/.test(url)) {
    console.log(`proxy ${url}`)
    await send(ctx, url, { root: path.resolve(__dirname, './') })
    return
  }

  ctx.res.setHeader('Content-Type', 'text/html')
  try {
    const html = await renderToString(ctx.ssrContext)
    ctx.body = html
  } catch (res) {
    console.log(
      `服务器catch异常:${
        res instanceof Error ? res.stack : JSON.stringify(res)
      }`
    )
    ctx.response.redirect(`/error/${res.code ? res.code : 500}`)
  }
  next()
})

const port = 3000
app.listen(port, function () {
  console.log(`server started at localhost:${port}`)
})

2. copy_server.js

const fs = require('fs')
const path = require('path')

const resolve = (file) => path.resolve(__dirname, file)

const file = resolve('./server.js')
const fileName = path.basename(file)

fs.readFile(file, (err, data) => {
  if (err) throw err
  fs.writeFile(resolve(`../dist/${fileName}`), data, { encoding: 'utf-8' }, (err) => {
    if (err) throw err
    console.log('成功拷贝')
  })
})

上文中的node ./server/copy_server.js,就是将server.js拷贝到dist文件中,所有代码添加后,执行npm run build生成的dist文件应当与上面结果图一致;那么,就剩下最后一步了

执行server.js 查看结果

  • cd dist
  • node server.js
  • 结果应当如下:

vue_ssr_view.png

到这里ssr已经完成了,但是,是不是发现少了点什么?没错就是开发环境了。其实,前文讲述entry-client.js的时候已经描述过了,ssr只是渲染当前路由的页面,entry-client.js的路由过滤了非服务端渲染、已渲染的组件。按照道理来说,只要在这里做一些处理就可以在客户端完美模式服务端过程,当然,需要的同构能力较强。

至于开发环境搭建,后期有空再写,如果急需,可以参考: 尤大的Demo