vite+vue3+SSR 并实现语言国际化功能

1,972 阅读10分钟

1、什么是 SSR ?

服务端渲染(server side render)简称为 SSR ,是在浏览器请求页面url时,服务端将我们所需要的 html文本拼装好并返回给浏览器,这个html文本被浏览器解析之后,不需要经过js脚本的执行,就可以直接构建出dom结构并将其展示到页面中。这一个组装的过程,叫做服务端渲染。

传统客户端渲染的网络请求:

image.png 页面上的内容是通过执行js之后生成dom,并且渲染到页面上

服务端渲染的网络请求:

image.png

页面的html内容是直接由服务端生成并返回的

客户端渲染 VS 服务端渲染

服务端渲染的大体流程与客户端渲染有些相似,前端采用Node.js部署了前端服务器。首先是浏览器请求URL,前端服务器接收到URL请求之后,根据不同的URL,前端服务器向后端服务器请求数据,请求完成后,前端服务器会组装一个携带了具体数据的HTML文本,并且返回给浏览器,浏览器得到HTML之后开始渲染页面,同时,浏览器加载并执行JavaScript脚本,给页面上的元素绑定事件,让页面变得可交互,当用户与浏览器页面进行交互,如跳转到下一个页面时,浏览器会执行JavaScript 脚本,向后端服务器请求数据,获取完数据之后再次执行JavaScript代码动态渲染页面,这样在用户在看到页面首屏内容时只和服务器有一个http的请求交互用于获取html内容,这个内容就是完整的页面内容,之后的后续交互仍在前端完成。

服务端渲染的优势:
  • SEO 支持: 服务端渲染可以有效的进行seo优化,当页面爬虫请求页面地址时,就可以获取到完整的页面内容,而客户端渲染爬虫获取到的只是一个html的空壳,里面并没有内容
  • 白屏时间:服务端渲染在浏览器请求url之后已经得到了一个带有数据的html文本,浏览器只需要进行dom构建,之后渲染页面即可,客户端渲染则需要通过等待js脚本的下载和运行之后才可以看到,在复杂的应用中白屏时间会较漫长

2、vite 服务端渲染

同构:采用一套代码,构建双端(服务端和客户端)逻辑,最大限度的重用代码,不需要维护两套代码
源码结构

image.png

开始构建 SSR 项目

客户端渲染

1、创建一个 vite vue 的项目

pnpm create vite

之后按照提示依次选择 vue命令,完成项目的初始化,并去除项目中无关的代码

2、在 main.js 的同级创建两个入口:entery-client.js 和 entery-server.js 分别作为客户端入口和服务端入口

3、对 mian.js 进行改造

import App from './App.vue'
import { createSSRApp } from 'vue'
import { createRouter } from './router'
import './style.css'

export const createApp = () => {
  // 创建一个ssr 的实例
  const app = createSSRApp(App)
  const router = createRouter()
  app.use(router)

  //   暴露 app 实例
  return { app, router }
}

4、对 entry-client.js 进行改造,使其将实例挂载到一个dom元素上

import { createApp } from './main'

const { app, router } = createApp()

router.isReady().then(() => {
  app.mount('#app')

  console.log('hydrated')
})

5、改造 index.html 文件并将客户端入口(entry-client.js)引入,其中 preload-links 这个注释和 app-html 注释是为了进行占位,方便后续在 ssr 生成的字符串中进行对应的替换,(注意,这里app-html切记不要换行,否则会出现渲染两次的问题,一定要保证在#app这个容器内部尽量不要有换行符,否则在ssr生成的时候需要做一些特殊的处理)

<!DOCTYPE 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>
    <!--preload-links-->
  </head>
  <body>
    <div id="app"><!--app-html--></div>
    <script type="module" src="/src/entry-client.js"></script>
  </body>
</html>

6、修改 package.json 文件

"scripts": {
    "dev:client": "vite",
    "build:client": "vue-tsc && vite build --outDir dist/client --ssrManifest",
  },

--outDir参数为其指定了构建后所产生的文件存放的目录地址,--ssrManifest表示在进行客户端生产构建后,会生成一个ssr-manifest.json文件,这个文件标识了静态资源的映射信息,这样在服务端渲染时,它就可以自动推断并向渲染出来的HTML中注入需要preload/prefetch的资源,并且包括了懒加载的组件所对应的资源。

preload VS prefetch 他们均是以link标签来使用的

  • preload: 提前加载资源,告诉浏览器预先请求当前页需要的资源,从而提高这些资源的请求优先级,加载但是不运行,会占用浏览器对同一个域名的请求并发数
  • prefetch: 在浏览器空闲时下载资源并缓存,当有页面使用时直接从缓存中读取

vite中主要使用的是preload,对于es6模块改为了moudulepreload,当访问首屏时,会提前加载其他页面所需要的资源,这样当打开其他页面时,就会减少等待时间,提升用户体验。正常的客户端渲染出来的HTML默认情况下都会带有这个优化,服务端渲染的HTML则需要上面的ssr-manifest.json才能有对应的优化

image.png

至此,客户端渲染的逻辑已经基本完成,并且可以正常启动应用

服务端渲染

1、在index.html同级创建 server.js,作为服务端渲染的node服务器入口 引入一个node服务框架 express

pnpm i express  // 安装express
pnpm i serve-static 
pnpm i nodemon //自动监听文件变化并重启服务
pnpm i cross-env //设置环境变量
// server.js

import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import express from 'express'

// 该模块的路径
const isTest = process.env.VITEST
export async function createServer(
  root = process.cwd(),
  isProd = process.env.NODE_ENV === 'production',
  hrmProt
) {
  console.log('🚀 ~ file: server.js:11 ~ isProd:', isProd)
  // 返回当前模块的 URL 路径的 dirname
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
  
  const resolve = (p) => path.resolve(__dirname, p)
  
  //生产环境直接从构建产物中读取
  const indexProd = isProd
    ? fs.readFileSync(resolve('dist/client/index.html'), 'utf8')
    : ''

  const manifest = isProd
    ? JSON.parse(
        fs.readFileSync(path.resolve('dist/client/ssr-manifest.json'), 'utf-8')
      )
    : {}

  const app = express()
  
  let vite
  if (!isProd) {
  // 开发环境下需要链接vite
    vite = await (
      await import('vite')
    ).createServer({
      base: '/',
      root,
      logLevel: 'info',
      server: {
        middlewareMode: true,
        watch: {
          usePolling: true,
          interval: 100,
        },
        hrm: {
          port: hrmProt,
        },
      },
      appType: 'custom',
    })

    // 使用 vite 的 Connect 实例作为中间件
    app.use(vite.middlewares)
  } else {
  
    app.use((await import('compression')).default())

    // 生产环境下把 dist/client 映射为根路径,防止静态资源路由不匹配的问题
    app.use(
      '/',
      (await import('serve-static')).default(path.resolve('dist/client'), {
        index: false,
      })
    )
    // 这种方法是express自带的 app.use(express.static('dist/client'))
  }

  //   拦截所有的请求
  app.use('*', async (req, res, next) => {
    // 服务 index.html
    const url = req.originalUrl

    try {
      let render, template

      // 读取模版 index.html
      if (!isProd) {
        template = fs.readFileSync(
          path.resolve(__dirname, 'index.html'),
          'utf-8'
        )
        //   应用vite html转换 注入vite hmr客户端
        template = await vite.transformIndexHtml(url, template)
        // 加载服务器入口。vite.ssrLoadModule 将自动转换
        //  你的 ESM 源码使之可以在 Node.js 中运行!无需打包
        // 并提供类似 HMR 的根据情况随时失效。
        render = (await vite.ssrLoadModule('/src/entry-server.js')).render
      } else {
        template = indexProd
        render = (await import('./dist/server/entry-server.js')).render
      }

      // 渲染应用的 HTML。这假设 entry-server.js 导出的 `render`
      const [appHtml, preloadLinks] = await render(url, manifest)
      console.log('🚀 ~ file: server.js:55 ~ app.use ~ appHtml:', appHtml)
      //   替换
      const html = template
        .replace('<!--preload-links-->', preloadLinks)
        .replace('<!--app-html-->', appHtml)
      res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
    } catch (error) {
      console.log('🚀 ~ file: server.js: ~ app.use ~ error:', error)
      vite && vite.ssrFixStacktrace(error)
      res.status(500).end(error)
    }
  })

  return { app, vite }
}

if (!isTest) {
  createServer().then(({ app }) => {
    app.listen(3354, () => {
      console.log('http://localhost:3354')
    })
  })
}

2、对 entry-server.js 进行改造,这里主要是实现render和renderPreloadLinks方法,将页面实例和资源链接转换成字符串返回

import { basename } from 'node:path'
import { renderToString } from '@vue/server-renderer'
import { createApp } from './main'

export async function render(url, manifest, lang = 'zh') {
  const { app, router } = createApp()

  // set the router to the desired URL before rendering
  await router.push(url)
  await router.isReady()

  const ctx = {}
  const html = await renderToString(app, ctx)


  const preloadLinks = renderPreloadLinks(ctx.modules, manifest)
  return [html, preloadLinks]
}

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 {
    // TODO
    return ''
  }
}

3、package.json命令

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

至此服务端渲染已经基本完成啦

预渲染

对于一些静态页面,可以使用预渲染来实现服务端渲染的优势,可以直接生成完整的静态页面,提升首屏优化

在server.js同级创建prerender.js

// 预渲染
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'

const __dirname = path.dirname(fileURLToPath(import.meta.url))

const getAbsoluteFilePath = (filePath) => path.resolve(__dirname, filePath)

const manifest = JSON.parse(
  fs.readFileSync(getAbsoluteFilePath('dist/client/ssr-manifest.json'), 'utf-8')
)

const template = fs.readFileSync(
  getAbsoluteFilePath('dist/client/index.html'),
  'utf-8'
)

const { render } = await import('./dist/server/entry-server.js')

const routersToPreRender = fs
  .readdirSync(getAbsoluteFilePath('src/pages'))
  .map((file) => {
    const name = file.replace(/\.vue$/, '').toLowerCase()
    return name === 'home' ? '/' : `/${name}`
  })

console.log(
  '🚀 ~ file: prerender.js:20 ~ routersToPreRender ~ routersToPreRender:',
  routersToPreRender
)
;(async () => {
  // pre-render each route...
  for (const url of routersToPreRender) {
    const [appHtml, preloadLinks] = await render(url, manifest, 'en')

    const html = template
      .replace(`<!--preload-links-->`, preloadLinks)
      .replace(`<!--app-html-->`, appHtml)

    const filePath = `dist/static${url === '/' ? '/index' : url}.html`
    fs.writeFileSync(getAbsoluteFilePath(filePath), html)
    console.log('pre-rendered:', filePath)
  }

  // done, delete ssr manifest
  //   fs.unlinkSync(getAbsoluteFilePath('dist/static/ssr-manifest.json'))
})()

package.json

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

执行构建命令,查看dist/static/index.html,可以发现已经生成了一个完整的html文件

<!DOCTYPE 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>
    <link rel="modulepreload" crossorigin href="/assets/home-75a0ccf2.js"><link rel="stylesheet" href="/assets/home-22c2b1b8.css">
    <script type="module" crossorigin src="/assets/index-8b378272.js"></script>
    <link rel="stylesheet" href="/assets/index-bf4ee8fb.css">
  </head>
  <body>
    <div id="app"><div><a aria-current="page" href="/" class="router-link-active router-link-exact-active">Home</a>| <a href="/list" class="">list</a><div class="test" data-v-908e912e><h3 data-v-908e912e>这是一个测试</h3></div></div></div>
    
  </body>
</html>

至此,整个ssr可以暂时告一个段落,之后对其进行多语言改造

多语言改造

1、依赖安装:选择使用vue-i18n来进行开发

 pnpm i mkdirp //创建不存在的文件夹,往一个不存在的文件夹的文件写入内容会报错
 pnpm i @intlify/unplugin-vue-i18n -D //vite插件
 pnpm i vue-i18n //开发依赖

在 vite.config.ts 中引入18n的插件

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

import path from 'node:path'

import VueI18n from '@intlify/unplugin-vue-i18n/vite'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    VueI18n({
      runtimeOnly: true,
      compositionOnly: true,
      fullInstall: true,
      include: [path.resolve(__dirname, 'src/locale/yaml/**')],
    }),
  ],
})

在src目录下创建locale文件夹,用来存放与语言相关的逻辑代码及静态语言文件

// src/locale/index.ts
import { createI18n as _createI18n } from 'vue-i18n'

import localeEn from './yaml/en.yaml'
import localeZh from './yaml/zh.yaml'

export const createI18n = () => {
  return _createI18n({
    locale: 'en',
    legacy: false,
    messages: {
      en: localeEn,
      zh: localeZh,
    },
  })
}

// src/locale/yaml/en.yaml
test: this is a test

// src/locale/yaml/zh.yaml
test: 这是一个测试

之后在main.ts中将其引入

// src/main.ts

import App from './App.vue'
import { createSSRApp } from 'vue'
import { createRouter } from './router'
import './style.css'
import { createI18n } from './locale'

export const createApp = () => {
  const app = createSSRApp(App)
  const router = createRouter()
  创建i18n的实例
  const i18n = createI18n()
  app.use(router)
  app.use(i18n)
  //   暴露 app 实例
  return { app, router }
}

最后在项目中去使用i18n


// src/pages/home.vue

<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>

<template>
  <div class="test">
    <h3>{{ t('test') }}</h3>
  </div>
</template>

<style scoped lang="css">
.test {
  color: cadetblue;
}
</style>

回到浏览器刷新页面查看结果

image.png

至此,已经在项目中成功使用多语言了

疑问:为什么不在上面的多语言文件引入时采用异步的引入方式?

采用异步的方式去引入语言文件虽然能在语言文件较多时通过 import.meta.glob()的方式去获取所有的语言文件,然后在使用该语言时进行异步的加载,看上去并没有什么问题,但是在页面初始化和语言切换过程中会出现页面显示的是t函数中的key值的现象,当语言加载完成之后,才会去替换它,在网络情况等条件因素影响下,可能出现页面一直是key的情况,这是比较严重的,通过在最外层使用await的方式去等待它加载完成可以解决这样的一个问题,但是随之而来的是一个浏览器版本的兼容问题,vite目前默认打包的是Chrome >=87,Firefox >=78,Safari >=14,Edge >=88,而顶层await需要将打包版本设定为ESNExt,这对于低版本浏览器来说是不支持的

预渲染的多语言的打包问题

对于静态页面而言,将每一个页面打包成html文件能够有效提高页面的性能,那么就需要对每一种语言都输出其对应的html文件,所以需要对与渲染逻辑进行改造

// prerender.js
// 预渲染
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { mkdirp } from 'mkdirp'

const languageList = ['zh', 'en']

const __dirname = path.dirname(fileURLToPath(import.meta.url))

const getAbsoluteFilePath = (filePath) => path.resolve(__dirname, filePath)

const manifest = JSON.parse(
  fs.readFileSync(getAbsoluteFilePath('dist/client/ssr-manifest.json'), 'utf-8')
)

const template = fs.readFileSync(
  getAbsoluteFilePath('dist/client/index.html'),
  'utf-8'
)

const { render } = await import('./dist/server/entry-server.js')

const routersToPreRender = fs
  .readdirSync(getAbsoluteFilePath('src/pages'))
  .map((file) => {
    const name = file.replace(/\.vue$/, '').toLowerCase()
    return name === 'home' ? '/' : `/${name}`
  })

console.log(
  '🚀 ~ file: prerender.js:20 ~ routersToPreRender ~ routersToPreRender:',
  routersToPreRender
)
;(async () => {
  for (const lang of languageList) {
    for (let url of routersToPreRender) {
      if (lang === 'en') {
        if (url === '/') {
          url = `${url}en`
        } else {
          url = `/en${url}`
        }
      }
      const [appHtml, preloadLinks] = await render(url, manifest, lang, true)

      const html = template
        .replace(`<!--preload-links-->`, preloadLinks)
        .replace(`<!--app-html-->`, appHtml)
      const filePath = `dist/static${
        url === '/'
          ? '/index'
          : url === '/en'
          ? '/en/index'
          : url.replace('/products', '')
      }.html`

      mkdirp('dist/static/en').then(() => {
        fs.writeFileSync(getAbsoluteFilePath(filePath), html)
        console.log('pre-rendered:', filePath)
      })
    }
  }
})()


// entry-server.js
import { basename } from 'node:path'
import { renderToString } from '@vue/server-renderer'
import { createApp } from './main'
import { createI18n } from './locale'
export async function render(
  url,
  manifest,
  language = 'zh',
  isPreRender = false
) {
  const { app, router } = createApp()

  if (isPreRender) {
    const i18n = createI18n(language)
    app.use(i18n)
  }

  // set the router to the desired URL before rendering
  await router.push(url)
  await router.isReady()

  const ctx = {}
  const html = await renderToString(app, ctx)

  const preloadLinks = renderPreloadLinks(ctx.modules, manifest)
  return [html, preloadLinks]
}

在执行构建命令之后,能够发现在dist目录的static目录下已经生成了静态文件

image.png

通过检查对应文件夹下的文件发现,输出的内容与语言已经关联起来了,符合预期效果 至此,在ssr中使用i18n实现多语言已经完成

demo: github.com/HamsterCat-…