博客 / 詳情

返回

Vue3+Vite3 SSR基本搭建

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/entry-client.ts src/entry-server.js

    • server.js:服務端啓動文件
    • entry-client.ts:客户端入口,應用掛載元素
    • entry-server.js:服務端入口,處理服務端邏輯和靜態資源
  • 修改package.json運行腳本

    "scripts": {
      "dev": "node server", // 運行開發環境
    }
  • 然後需要把應用創建都改為函數的方式進行調用創建,因為在SSR環境下,和純客户端不一樣,服務器只會初始化一次,所以為了防止狀態污染,每次請求必須是全新的實例

    // src/main.ts
    import { 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/index
    import { 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
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.