Vue3+Vite3 SSR根本搭建

  • 首先阐明如果是生产应用强烈推荐Nuxt,然而如果想深刻服务端渲染的运行原理,能够看本篇,会依据渲染流程搭建一个demo版ssr,源码在最初会贴上
  • 次要技术栈:Vite3 + Vue3 + pinia + VueRouter4 + express
  • 开始搭建之前,先说一下SSR渲染流程

SSR渲染流程

  • 首先浏览器向服务器申请,而后服务器依据申请的路由,会匹配相干的路由组件,而后执行组件的自定义服务端生命周期(例:Nuxt的asyncData)或者自定义获取数据的hook,并且把执行后的数据收集起来,对立在window的属性中存储
  • 而后vue的组件会被renderToString渲染成动态HTML字符串,替换掉index.html的提前指定的占位代码。而后index.html扭转后的动态字符串发给客户端
  • 客户端拿到后,首先对数据进行初始化,而后进行激活,因为以后html只是静态数据,激活次要做两件事

    1. 把页面中的DOM元素与虚构DOM之间建立联系
    2. 为页面中的DOM元素增加事件绑定

1. 创立我的项目

  • 首先用vite命令创立我的项目pnpm create vite vue-ssr --template vue-ts

    • 装置相干依赖:pnpm add express pinia vue-router@4
  • 创立三个文件 touch server.js src src/entry-client.ts src/entry-server.js

    • server.js:服务端启动文件
    • entry-client.ts:客户端入口,利用挂载元素
    • entry-server.js:服务端入口,解决服务端逻辑和动态资源
  • 批改package.json运行脚本

    "scripts": {  "dev": "node server", // 运行开发环境}
  • 而后须要把利用创立都改为函数的形式进行调用创立,因为在SSR环境下,和纯客户端不一样,服务器只会初始化一次,所以为了避免状态净化,每次申请必须是全新的实例

    // src/main.tsimport { createSSRApp } from 'vue'import App from './App.vue'import { createRouter } from './router'import { createPinia } from 'pinia'export function createApp() {  const app = createSSRApp(App)  const router = createRouter()  const pinia = createPinia()  app.use(router)  app.use(pinia)  return { app, router, pinia }}
  • router同理

    // src/router/indeximport { createRouter as _createRrouter, createMemoryHistory, createWebHistory, RouteRecordRaw } from 'vue-router'const routes: RouteRecordRaw[] = [  ...]export function createRouter() {  return _createRrouter({    history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),    routes,  })}
  • 而后批改index.html,减少正文占位和客户端入口文件,在之后的服务端渲染时注入

    <html lang="en"><head>  <meta charset="UTF-8" />  <link rel="icon" type="image/svg+xml" href="/vite.svg" />  <meta name="viewport" content="width=device-width, initial-scale=1.0" />  <title>Vite + Vue + TS</title>  <!-- 动态资源占位 .js .css ... -->  <!--preload-links--></head><body>  <!-- 利用代码占位 -->  <div id="app"><!--ssr-outlet--></div>  <script type="module" src="/src/main.ts"></script>  <!-- 援用客户端入口文件 -->  <script type="module" src="/src/entry-client.ts" ></script>  <script>    // 服务端获取的数据对立挂载到window上    window.__INITIAL_STATE__ = '<!--pinia-state-->'  </script></body></html>

2. 服务端启动文件

  • 创立我的项目后,就开始编写服务端启动文件,也就是我的项目根门路下的server.js文件
  • 这个文件的性能是启动一个node服务,而后依据申请,读取html文件,解决资源后把正文进行替换,最初把html发送给客户端

    import fs from 'fs'import path from 'path'import { fileURLToPath } from 'url'import express from 'express'import { createRequire } from 'module';const __dirname = path.dirname(fileURLToPath(import.meta.url))const require = createRequire(import.meta.url);const resolve = (p) => path.resolve(__dirname, p);const createServer = async () => {// 创立node服务const app = express()/** * @官网解释 * 以中间件模式创立vite利用,这将禁用vite本身的HTML服务逻辑 * 并让下级服务器接管 */const vite = await require('vite').createServer({  server: {    middlewareMode: true,  },  appType: 'custom'});app.use(vite.middlewares);app.use('*', async (req, res, next) => {  const url = req.originalUrl  try {    // 读取index.html    let template = fs.readFileSync(      resolve('index.html'),      'utf-8'    )    // 利用vite html转换,会注入vite HMR    template = await vite.transformIndexHtml(url, template)    // 加载服务端入口    const render = (await vite.ssrLoadModule('/src/entry-server.js')).render    const [ appHtml, piniaState ] = await render(url)    // 替换解决过后的模版    const html = template      .replace(`<!--ssr-outlet-->`, appHtml)      .replace(`<!--pinia-state-->`, piniaState)    res.status(200).set({ 'Content-Type': 'text/html' }).end(html)  } catch (error) {    vite?.ssrFixStacktrace(error)    next(e)  }})// 监听5100端口app.listen(5100)}createServer();

3. 服务端入口文件

  • 服务端入口文件次要是调用SSR的renderToString和收集须要发送的资源和数据

    import { renderToString } from 'vue/server-renderer'import { createApp } from './main'export async function render(url, manifest) {  const { app, router, pinia } = createApp()  router.push(url)  await router.isReady()  const ctx = {}  const html = await renderToString(app, ctx)  return [html, JSON.stringify(pinia.state.value)]}

4. 客户端入口文件

  • 客户端入口文件次要用于挂载节点和初始化数据

    import { createApp } from './main'const { app, router, pinia } = createApp()router.isReady().then(() => {  if (window.__INITIAL_STATE__) {    pinia.state.value = JSON.parse(window.__INITIAL_STATE__);  }  app.mount('#app')})

5. 组件和页面

  • 组件和页面获取数据次要有两种形式,一种是减少一个asyncData选项,而后在enter-server.js的逻辑中减少遍历以后组件的逻辑,对立触发asyncData,然而当初都是用script setup的形式写业务代码,所以有点麻烦,

    <script>export defualt {  asyncData() {    // 服务端获取数据逻辑  }}</script><script setup lang='ts'>...</script>
  • 另一种就是hook的形式,通过import.meta.env.SSR的形式进行判断
  • 对于数据具体存储形式,大略有三种,一种是存在vuex或者pinia这种全局状态库中,一种是存在context上下文中,还有一种是自定义数据

6. 生产环境

6.1 pacnakge.json

  • 减少构建脚本

    "scripts": {  "dev": "node server",  "build": "npm run build:client && npm run build:server",  "build:client": "vite build --ssrManifest --outDir dist/client",  "build:server": "vite build --ssr src/entry-server.js --outDir dist/server",  "serve": "cross-env NODE_ENV=production node server"},

6.2 服务端运行文件

  • 针对生产环境,须要启动动态资源服务,援用门路须要改为dist目录下
import fs from 'fs'import path from 'path'import { fileURLToPath } from 'url'import express from 'express'import { createRequire } from 'module';const __dirname = path.dirname(fileURLToPath(import.meta.url))const require = createRequire(import.meta.url);const resolve = (p) => path.resolve(__dirname, p);const createServer = async (isProd = process.env.NODE_ENV === 'production') => {  const app = express()-  const vite = await require('vite').createServer({-    server: {-      middlewareMode: true,-    },-    appType: 'custom'-  });-  app.use(vite.middlewares);+  let vite;+  if (isProd) {+    app.use(require('compression')());+    app.use(+      require('serve-static')(resolve('./dist/client'), {+        index: false+      })+    );+  } else {+    vite = await require('vite').createServer({+      server: {+        middlewareMode: true,+      },+      appType: 'custom'+    });+    app.use(vite.middlewares);+  }   // 通过bulid --ssrManifest命令生成的动态资源映射须要在生产环境下援用+  const manifest = isProd ? fs.readFileSync(resolve('./dist/client/ssr-manifest.json'), 'utf-8') :{}    app.use('*', async (req, res, next) => {    const url = req.originalUrl    try {-      let template = fs.readFileSync(-        resolve('index.html'),-        'utf-8'-      )-      template = await vite.transformIndexHtml(url, template)-      const render = (await vite.ssrLoadModule('/src/entry-server.js')).render-      const [ appHtml, piniaState ] = await render(url)+      let template, render+      if (isProd) {+        template = fs.readFileSync(resolve('./dist/client/index.html'), 'utf-8')+        render = (await import('./dist/server/entry-server.js')).render+      } else {+        template = fs.readFileSync(+          resolve('index.html'),+          'utf-8'+        )+        template = await vite.transformIndexHtml(url, template)+        render = (await vite.ssrLoadModule('/src/entry-server.js')).render+      }+      const [ appHtml, preloadLinks, piniaState ] = await render(url, manifest)      const html = template        .replace(`<!--preload-links-->`, preloadLinks)        .replace(`<!--ssr-outlet-->`, appHtml)+        .replace(`<!--pinia-state-->`, piniaState)      res.status(200).set({ 'Content-Type': 'text/html' }).end(html)    } catch (error) {      vite?.ssrFixStacktrace(error)      next()    }  })  app.listen(5100)}createServer();

6.3 服务端入口文件

  • 服务端入口文件次要是减少了构建时生成的动态资源映射解决的逻辑

    import { basename } from 'path'import { renderToString } from 'vue/server-renderer'import { createApp } from './main'export async function render(url, manifest) {  const { app, router, pinia } = createApp()  router.push(url)  await router.isReady()  const ctx = {}  const html = await renderToString(app, ctx)  const preloadLinks = renderPreloadLinks(ctx.modules, manifest)  return [html, preloadLinks, JSON.stringify(pinia.state.value)]}function renderPreloadLinks(modules, manifest) {  let links = ''  const seen = new Set()  modules.forEach((id) => {    const files = manifest[id]    if (files) {      files.forEach((file) => {        if (!seen.has(file)) {          seen.add(file)          const filename = basename(file)          if (manifest[filename]) {            for (const depFile of manifest[filename]) {              links += renderPreloadLink(depFile)              seen.add(depFile)            }          }          links += renderPreloadLink(file)        }      })    }  })  return links }  function renderPreloadLink(file) {  if (file.endsWith('.js')) {    return `<link rel="modulepreload" crossorigin href="${file}">`  } else if (file.endsWith('.css')) {    return `<link rel="stylesheet" href="${file}">`  } else if (file.endsWith('.woff')) {    return ` <link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`  } else if (file.endsWith('.woff2')) {    return ` <link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`  } else if (file.endsWith('.gif')) {    return ` <link rel="preload" href="${file}" as="image" type="image/gif">`  } else if (file.endsWith('.jpg') || file.endsWith('.jpeg')) {    return ` <link rel="preload" href="${file}" as="image" type="image/jpeg">`  } else if (file.endsWith('.png')) {    return ` <link rel="preload" href="${file}" as="image" type="image/png">`  } else {    return ''  }}

总结

  • repo

参考资料

  • Server-Side Rendering