再也不会分不清CSR、SSR、同构、SSG···

849 阅读13分钟

前言

现在前端一谈到SSR,不约而同都想到 Nuxt 和 Next 这两个非常有名的SSR框架。

但是在现有的SPA单页面应用中直接使用第三方的SSR框架(Nuxt、Next)重构项目,会大费周章。

但是如果本身对SSR的原理认识比较模糊,那么在现有的项目中,手动集成SSR又会变得无从而手,并且极其复杂。

所以本章带大家从头梳理一下 CSR、SSR、同构、SSG 等相关概念,再带你从头如何在现有项目中手动集成SSR,自己动手实现SSR,也会让你更加了解第三方的SSR框架(Nuxt、Next)的一些底层实现。

CSR、SSR及同构渲染

SSR(传统服务端渲染)

传统的服务端渲染有:asp、jsp、ejs等,服务端语言往往通过这些模板引擎将数据和dom在服务端渲染完成,返回一个完整的静态html页面给客户端,由客户端直接显示。

原理

  1. 客户端发送http请求
  2. 服务端响应http请求,返回拼接好的html字符串给客户端
  3. 客户端渲染html

缺点

  • 前后端分离,不好维护
  • 用户体验不佳,需要重新加载页面
  • 服务端压力大

CSR(客户端渲染)

在现代化的前端项目中,客户端渲染的代表性技术栈是Vue、React、Angular,我们常常使用它们来构建客户端单页应用程序。以SPA构建程序为例,在浏览器端首先渲染的是一套空的HTML,通过JS直接进行页面的渲染和路由跳转等操作,所有的数据通过ajax请求从服务器获取后,在进行客户端的拼装和展示。

原理

  1. 客户端发起http请求
  2. 服务端响应http请求,返回一个空的根元素的 html 文件
  3. 客户端初始化时加载必须的 js文件,请求接口
  4. 将生成的dom插入到 html 中

缺点

  • 首屏加载慢
  • 不利于SEO

同构(现代服务端渲染)

一个由服务端渲染的 Vue.js 应用也可以被认为是“同构的”(Isomorphic) 或“通用的”(Universal),因为应用的大部分代码同时运行在服务端客户端。——Vue3官网

这里说的SSR 特别指支持在 Node.js 中运行前端框架(例如 React、Preact、Vue 和 Svelte),将其预渲染成 HTML,最后在客户端进行水合处理。如果你正在寻找与传统服务器端框架的集成,请查看 后端集成指南。——Vite官网

Vue、React框架的SSR方案(Nuxt、Next)实际上就是同构渲染,这里的SSR,是指在前端单页面应用范畴内,基于Node.js server运行环境的服务端渲染方案,通过在 Node.is 中运行相同应用程序的前端框架(例如 React、Vue等),将其预渲染成 HTML,最后在客户端进行注水化处理。简单来讲,就是应用程序的大部分代码在服务端(node服务端)和客户端上运行,这就是所谓的现代服务端渲染:同构。

原理

  1. 客户端发起 http 请求
  2. 服务端渲染把 Vue 实例转换成了静态的 html 发送给客户端
  3. 客户端渲染需要把事件、响应式特性等 Vue 的特性都绑回去(水合或者叫激活)

优点

  • 首屏速度快:这一点在慢网速或者运行缓慢的设备上尤为重要。服务端渲染的 HTML 无需等到所有的 JavaScript 都下载并执行完成之后才显示,所以用户将会更快地看到完整渲染的页面。除此之外,数据获取过程在首次访问时在服务端完成,相比于从客户端获取,可能有更快的数据库连接。这通常可以带来更高的核心 Web 指标评分、更好的用户体验,而对于那些“首屏加载速度与转化率直接相关”的应用来说,这点可能至关重要。
  • 统一的心智模型:有一些现成框架(Nuxt.js、Next.js)可以使用相同的语言以及相同的声明式、面向组件的心智模型来开发整个应用,而不需要在后端模板系统和前端框架之间来回切换。
  • 更好的 SEO:搜索引擎爬虫可以直接看到完全渲染的页面。

缺点

  • 开发中的限制。浏览器端特定的代码只能在某些生命周期钩子中使用;一些外部库可能需要特殊处理才能在服务端渲染的应用中运行。
  • 更多的与构建配置和部署相关的要求。服务端渲染的应用需要一个能让 Node.js 服务器运行的环境,因为需要 Node.js 来执行JS代码和构建用户页面,不像完全静态的 SPA 那样可以部署在任意的静态文件服务器上。
  • 更高的服务端负载。在 Node.js 中渲染一个完整的应用要比仅仅托管静态文件更加占用 CPU 资源,因此如果你预期有高流量,请为相应的服务器负载做好准备,并采用合理的缓存策略。

判断一个网页是纯SSR、CSR、同构?

如何区分页面是CSR还是SSR?

一般可以通过查看网页的源代码,如果body标签里包含了网页的所有内容html标签,那就是SSR服务端渲染,在服务端生成完整HTML并发送给客户端;

如果body标签里只有少数几个html标签元素,那就是SPA单页面应用,属于CSR,是在客户端通过JS动态构建页面。

也可以通过复制一段页面文本,看网页源代码是否可以搜索到,如果搜索不到就是CSR。反之就是SSR。

如何查看SSR是否是同构渲染?

同构渲染也属于SSR范畴,只不过用的是Vue、React等前端技术栈实现的,比如飞书官网就是利用React技术栈(Nextjs)实现的同构渲染:

Vue项目手动集成SSR

在现有的 Vue 项目中,利用 Vite 脚手架,手动改造成 SSR,包括 Vue-Router、Pinia、数据预取等。

基本原理

  • 通过 Vue 的 server-renderer 模块将 Vue 应用实例转换成一段纯文本的 HTML 字符串
  • 通过 Nodejs 创建一个静态 Web 服务器
  • 通过 Nodejs 将服务端所转换好的 HTML 结构发送到浏览器端进行展示。也就是说部署 SSR 项目,需要服务器提供运行 Node 的环境,即安装 Node。

上图构建过程中的Webpack替换为Vite

根据 Vite 对 SSR渲染的介绍(点击查看Vite官网集成SSR指南),一个典型的 SSR 应用程序的目录结构如下:

- index.html
- server.js 				 # 执行SSR入口文件
- src/
  - main.ts          # 导出环境无关的(通用的)应用代码
  - entry-client.ts  # 激活应用挂载到一个 DOM 元素上
  - entry-server.ts  # 使用 Vue 框架的 SSR API 渲染该应用

下面是从0搭建一个Vue框架的SSR项目模板:首先pnpm create vue@latest创建一个Vue3项目(包括 ts、vue-router、pinia),手动添加三个文件server.jsentry-client.tsentry-server.ts

main.ts

修改原始main.ts文件,为了激活应用,必须使用 createSSRApp() 而不是 createApp()

import { createSSRApp } from 'vue' // 为了激活应用,必须使用 createSSRApp() 而不是 createApp()
import App from './App.vue'
import { createRouter } from './router' // 返回一个方法,每次创建新的 Vue-Router 实例
import { createPinia } from 'pinia'

// 每次请求时调用
export function createApp() {
    // 创建一个和服务端完全一致的应用实例
    const app = createSSRApp(App)
    // 对每个请求都创建新的 Vue-Router 实例
    const router = createRouter()
    // 对每个请求都创建新的 pinia 实例
    const pinia = createPinia()

    app.use(router)
    app.use(pinia)
    return { app, router, pinia }
}

// 预取接口数据
// 有两个地方用到,客户端 entry-client.ts 和服务端 entry-server.ts
// 因为是服务端渲染,所以肯定要预取数据,然后将状态序列化为window.__INITIAL_STATE__,注入到HTML
// 客户端也会预取数据,但是会做限制,只有打开页面才会获取数据,激活页面。
// 刷新页面客户端不会二次获取数据,而是 从window.__INITIAL_STATE__恢复数据,减轻服务器压力;但是服务端会重新获取数据,因为要返回新的 HTML
// 有一点需要注意,现在路由是由前端路由控制,路由跳转的时候不会真实请求服务端,所以只会在客户端预取数据,服务端不会预取数据。
// 所以有时候查看源代码的时候,会发现服务端返回 HTML 里的状态window.__INITIAL_STATE__是旧的。要想永远都是新数据,那就必须改造路由跳转,变成window.location.ref的方式跳转
export function asyncData(actived: any, route: any) {
    return Promise.all(actived.map((Component: any) => {
        if (Component.asyncData) {
            return Component.asyncData({
                route
            })
        }
    }))
}

router.ts

Vue-Router 需要针对 CSR 和 SSR 渲染,选择不同的 history 模式:

import {
  createRouter as _createRouter,
  createMemoryHistory, // 在服务端使用 createMemoryHistory() 函数创建历史记录
  createWebHistory,
} from 'vue-router'
// 自动生成./pages 目录下文件路由
// https://vitejs.dev/guide/features.html#glob-import
// const pages = import.meta.glob('./pages/*.vue')

// const routes = Object.keys(pages).map((path) => {
//   const name = path.match(/./pages(.*).vue$/)![1].toLowerCase()
//   return {
//     path: name === '/home' ? '/' : name,
//     component: pages[path], // () => import('./pages/*.vue')
//   }
// })

export function createRouter() {
  return _createRouter({
    // import.meta.env.SSR 由 Vite 注入提供
    history: import.meta.env.SSR
      ? createMemoryHistory('/')
      : createWebHistory('/'),
    // routes,
    routes: [
      {
        path: '/',
        name: 'home',
        // route level code-splitting
        // this generates a separate chunk (About.[hash].js) for this route
        // which is lazy-loaded when the route is visited.
        component: () => import('../views/HomeView.vue'),
        // SEO优化
        meta: {
          title: '首页',
          keywords: 'SSR,Vue,Vite,Home',
          description: '这是vue-ssr-vite项目的首页',
        }
      },
      {
        path: '/about',
        name: 'about',
        component: () => import('../views/AboutView.vue'),
        meta: {
          title: '关于',
          keywords: 'SSR,Vue,Vite,About',
          description: '这是vue-ssr-vite项目的关于页',
        }
      }
    ]
  })
}

index.html

替换默认的入口文件main.tsentry-client.ts,同时放置占位符<!--preload-links--><!--app-html-->等,用于给服务端渲染的时候注入内容;同时设置全局变量__INITIAL_STATE__,用于保存pinia数据,每次刷新页面就不用重新获取接口,直接恢复保存的pinia数据即可:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title></title>
    <meta name="keywords" content="" />
    <meta name="description" content="" />
    <!--preload-links-->
  </head>
  <body>
    <div id="app"><!--app-html--></div>
    <script type="module" src="/src/entry-client.ts"></script>
    <script>
      window.__INITIAL_STATE__ = '<!--app-state-->';
    </script>
  </body>
</html>

server.js

server.js 是 SSR 的入口文件,处理 index.html,然后返回给客户端:

import fs from 'node:fs/promises';
import express from 'express';

// 常量
const isProduction = process.env.NODE_ENV === 'production';
const port = process.env.PORT || 5173;
const base = process.env.BASE || '/';

// 缓存生产环境资源
const templateHtml = isProduction
  ? await fs.readFile('./dist/client/index.html', 'utf-8')
  : '';
const ssrManifest = isProduction // 在客户端构建过程中会生成ssr-manifest.json预加载配置,该文件包含所有模块Id的映射,可以解决样式错乱问题
  ? await fs.readFile('./dist/client/.vite/ssr-manifest.json', 'utf-8')
  : undefined;

// 创建 http server
const app = express();

let vite; // 开发环境用到,ViteDevServer 的一个实例
if (!isProduction) {
  // 以中间件模式创建 Vite 应用,并将 appType 配置为 'custom',这将禁用 Vite 自身的 HTML 服务逻辑,并让上级服务器接管控制
  // 就是在开发环境下,开启 SSR,用 express 接管返回 HTML
  const { createServer } = await import('vite');
  vite = await createServer({
    server: { middlewareMode: true },
    appType: 'custom',
    base,
  });
  // vite.middlewares 是一个 Connect 实例,它可以在任何一个兼容 connect 的 Node.js 框架中被用作一个中间件。
  // 如果你使用了自己的 express 路由(express.Router()),你应该使用 router.use
  // 当服务器重启(例如用户修改了 vite.config.js 后),
  // `vite.middlewares` 仍将保持相同的引用(带有 Vite 和插件注入的新的内部中间件堆栈)。即使在重新启动后,以下内容仍然有效。
  app.use(vite.middlewares);
} else {
  // 生产环境下,将 Vite 与生产环境脱钩,用 sirv 静态文件服务中间件来服务 dist/client 中的文件。
  // pnpm add -s compression 压缩
  const compression = (await import('compression')).default;
  // pnpm add -s sirv 比Node自带的 serve-static 性能更好
  const sirv = (await import('sirv')).default;
  app.use(compression());
  app.use(base, sirv('./dist/client', { extensions: [] }));
}

// 处理供给服务端渲染的 index.html。
// 主要步骤如下:
// 1. 读取并且转换index.html,比如客户端访问 /home 页面,此时 index.html 的内容就是 Home 页面的内容
// 2. 然后交给 entry-server.ts 进行注入处理
app.use('*', async (req, res) => {
  try {
    const url = req.originalUrl.replace(base, '');
    let template;
    let render;
    if (!isProduction) {
      // 1. 读取 index.html。开发环境总是读取最新的index.html
      template = await fs.readFile('./index.html', 'utf-8');
      // 2. 应用Vite HTML 转换。这将会注入 Vite HMR 客户端
      //    同时也会从 Vite 插件应用 HTML 转换
      //    例如:@vitejs/plugin-react-refresh 中的 global preambles
      template = await vite.transformIndexHtml(url, template);
      // 3a. 加载服务器入口。vite.ssrLoadModule 将自动转换你的 ESM 源码使之可以在 Node.js 中运行!无需打包,并提供类似 HMR 的根据情况随时失效。
      render = (await vite.ssrLoadModule('/src/entry-server.ts')).render;
      // 3b. 从 Vite 5.1 版本开始,你可以试用实验性的 createViteRuntime API。
      // 这个 API 完全支持热更新(HMR),其工作原理与 ssrLoadModule 相似
      // 如果你想尝试更高级的用法,可以考虑在另一个线程,甚至是在另一台机器上,使用 ViteRuntime 类来创建运行环境。
      // const runtime = await vite.createViteRuntime(vite)
      // render = (await runtime.executeEntrypoint('./src/entry-server.ts')).render
    } else {
      template = templateHtml;
      // @ts-ignore
      render = (await import('./dist/server/entry-server.js')).render;
    }
    // 4. 渲染应用的 HTML。这假设 entry-server.ts 导出 `render`
    //    函数调用了适当的 SSR 框架 API。 例如 ReactDOMServer.renderToString()
    const rendered = await render(url, ssrManifest);
    // 5. 替换 index.html 里的占位符,注入渲染后的应用程序 HTML 到模板中。
    const { title, keywords, description } = rendered.route.meta;
    const html = template
      .replace(`<!--preload-links-->`, rendered.preloadLinks ?? '')
      .replace(`<!--app-html-->`, rendered.html ?? '')
      .replace(`<!--app-state-->`, JSON.stringify(rendered.state) ?? '')
      .replace('<title>', `<title>${title}`)
      .replace(
        '<meta name="keywords" content="" />',
        `<meta name="keywords" content="${keywords}" />`
      )
      .replace(
        '<meta name="description" content="" />',
        `<meta name="description" content="${description}" />`
      );
    // 6. 返回渲染后的 HTML。
    res.status(200).set({ 'Content-Type': 'text/html' }).send(html);
  } catch (e) {
    // 如果捕获到了一个错误,让 Vite 来修复该堆栈,这样它就可以映射回你的实际源码中。
    vite?.ssrFixStacktrace(e);
    console.log('🚀', e.stack);
    res.status(500).end(e.stack);
  }
});

// 启用 http server
app.listen(port, () => {
  console.log(
    `node server 运行 http://localhost:${port}`,
    isProduction ? '生产环境' : '开发环境'
  );
});

entry-server.ts

entry-server.ts 是将客户端的页面组件转换成服务端的 HTML 字符串:在 entry-server.ts 文件中,我们需要创建一个 render 函数,初始化一个 Vue 实例,配置必要的中间件(如路由器和存储),并将 URL 路径作为参数。然后导出该实例,供服务器使用,以便将应用程序呈现为一个字符串,供服务器端呈现。

import { basename } from "node:path";
import { renderToString } from 'vue/server-renderer' // 利用Vue的 renderToString API, 可以将组件转成 HTML 字符串j
import { createApp, asyncData } from './main'

export async function render(url: string, manifest: any) {
    const { app, router, pinia } = createApp()

    await router.push(url)
    await router.isReady()


    const matchedComponents = router.currentRoute.value.matched.flatMap(record =>
        Object.values(record.components!)
    )
    console.log('匹配组件', matchedComponents)
    // 对所有匹配的路由组件调用 `asyncData()`,在服务端进行数据预取,并将状态序列化为window.__INITIAL_STATE__,注入到HTML
    await asyncData(matchedComponents, router.currentRoute)
    const ctx: any = {}
    // 传递 SSR context 对象,可以通过 useSSRContext() api 获取
    // @vitejs/plugin-vue injects code into a component's setup() that registers
    // itself on ctx.modules. After the render, ctx.modules would contain all the
    // components that have been instantiated during this render call.
    const html = await renderToString(app, ctx)
    const state = pinia.state.value
    if (import.meta.env.PROD) {
        const preloadLinks = renderPreloadLinks(ctx.modules, manifest)
        return { html, state, preloadLinks }
    } else {
        return { html, state }
    }
}

function renderPreloadLinks(modules: any, manifest: any) {
    let links = "";
    const seen = new Set();
    modules.forEach((id: string) => {
        const files = manifest[id];
        if (files) {
            files.forEach((file: any) => {
                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: any) {
    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 "";
    }
}

entry-client.ts

entry-client.ts 作用就是在客户端激活(hydrate)挂载#app

/** 
 * 客户端浏览器使用
*/

import './assets/main.css' // 一些样式文件
import { createApp, asyncData } from './main'

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

// 在 index.html 里,window.__INITIAL_STATE__ = '<!--app-state-->'
// 在 server.ts 中已经处理将 pinia 数据赋值给了 window.__INITIAL_STATE__
// 所以需要在客户端激活 pinia 的 state。使用场景为首次加载和页面刷新(页面刷新的时候,不重新获取数据,减轻服务器压力,而是从这里恢复数据)
if ((window as any).__INITIAL_STATE__) {
    pinia.state.value = JSON.parse(((window as any).__INITIAL_STATE__))
}

router.isReady().then(() => {
    // 1. 先进行激活(hydrate),主要是数据和交互事件绑定
    router.beforeResolve((to, from, next) => {
        const toComponents = router.resolve(to).matched.flatMap(record =>
            Object.values(record.components!)
        )
        const fromComponents = router.resolve(from).matched.flatMap(record =>
            Object.values(record.components!)
        )
        // 防止前端数据的二次预取(只有打开页面才会获取数据,刷新页面客户端不会重新获取数据,减轻服务器压力。但是刷新页面服务端还是会预取一下数据)
        const actived = toComponents.filter((c, i) => {
            return fromComponents[i] !== c
        })
        // 可以先跳转到路由页面,然后再获取数据,填充页面
        // next()
        // asyncData(actived, router.currentRoute).then(() => {
        //     console.log('结束loading。。。。。')
        // })

        // 但一般都是客户端数据预取,在路由导航之前拿到数据,然后再处理视图
        // 两种区别就是,在哪里等待。一个是跳过去等待数据刷新,一个是等待数据后再跳过去
        if (!actived.length) {
            return next()
        }
        console.log('开始loading。。。。。') // 这里可以模拟loading
        asyncData(actived, router.currentRoute).then(() => {
            console.log('结束loading。。。。。')
            next()
        })

    })
    // 2. 然后进行挂载
    app.mount('#app')
})

// 修改 title 和 meta 信息,进行 SEO 优化
// 虽然服务端渲染的时候,会拼接好当前页面的所有信息返回给客户端
// 但由于是前端路由管理,跳转页面不会请求服务端,所以 title 和 meta 等信息都是旧的,所以客户端需要在下面修改一下
router.afterEach((to, from, next) => {
    const { title, keywords, description } = to.meta
    if (title) {
        document.title = `${title}`
    } else {
        document.title = ""
    }

    const keywordsMeta = document.querySelector('meta[name="keywords"]')
    keywordsMeta && keywordsMeta.setAttribute("content", `${keywords}`)

    const descriptionMeta = document.querySelector('meta[name="description"]')
    descriptionMeta?.setAttribute("content", `${description}`)
})

package.json

配置 ssr 脚本命令

"scripts": {
  "dev": "vite",
  "dev:ssr": "cross-env NODE_ENV=development node server", // 本地预览开发环境SSR
  "build:ssr": "pnpm run build:client && pnpm run build:server", // 打包客户端和服务端文件
  "build:client": "vite build --ssrManifest --outDir dist/client", // 打包客户端文件。--ssrManifest 标志会在客户端构建输出目录中生成一份 .vite/ssr-manifest.json
  "build:server": "vite build --ssr src/entry-server.js --outDir dist/server", // 打包服务端文件。--ssr 标志表明这是一个 SSR 构建,同时需要指定 SSR 的入口
  "prod:ssr": "cross-env NODE_ENV=production node server", // 本地预览生产环境开启SSR
  "preview": "vite preview"
},
"dependencies": {
  "compression": "^1.7.4",
  "express": "^4.19.2",
  "pinia": "^2.1.7",
  "sirv": "^2.0.4",
  "vue": "^3.4.21",
  "vue-router": "^4.3.0"
},
"devDependencies": {
  "@tsconfig/node20": "^20.1.2",
  "@types/node": "^20.11.28",
  "@vitejs/plugin-vue": "^5.0.4",
  "@vitejs/plugin-vue-jsx": "^3.1.0",
  "@vue/tsconfig": "^0.5.1",
  "cross-env": "^7.0.3",
  "npm-run-all2": "^6.1.2",
  "typescript": "~5.4.0",
  "vite": "^5.1.6",
  "vue-tsc": "^2.0.6"
}

本地/线上测试

本地测试

利用项目创建时候自带的 useCounterStore,同时改造下 AboutView.vue

import { ref, computed } from 'vue'
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const doubleCount = computed(() => count.value * 2)
  function increment() {
    count.value++
  }

  return { count, doubleCount, increment }
})

注意:这里没有使用 setup 语法糖,因为 asyncData() 是和 setup() 同级:

<template>
  <div class="about">
    <h1>计数器: {{ store.count }}</h1>
  </div>
</template>

<script lang="ts">
  import { defineComponent, onMounted } from 'vue';
  import { useCounterStore } from '../stores/counter';

  export default defineComponent({
    setup() {
      // 不要在这里直接调用浏览器端特定的代码,服务端渲染的时候会报错
      // 可以将执行浏览器端的代码放到onMounted()生命周期里,服务端渲染的时候会自动过滤掉
      onMounted(() => {});
      const store = useCounterStore();
      return {
        store,
      };
    },
    // 预取接口数据,执行在 setup() 函数之前
    // 这里没有使用真实接口,只是用pinia模拟一下返回,可以在store里定义异步数据获取
    asyncData({ route }: any) {
      // const { id } = route.value.params;
      const store = useCounterStore();
      return store.increment();
    },
  });
</script>

<style>
  @media (min-width: 1024px) {
    .about {
      min-height: 100vh;
      display: flex;
      align-items: center;
    }
  }
</style>

跑一下项目看看效果,执行pnpm run dev:ssr本地开发环境启动SSR,整个过程会发生:

  1. 打开网址,客户端会发起请求
  2. 服务端收到请求,执行server.js,本地会启动一个express服务
  3. 继续读取 index.html文件,加载 entry-server.ts服务端渲染入口文件,将对应页面内容替换,返回index.html
  4. 客户端收到返回的 index.html,这里 index.html里面有<script type="module" src="/src/entry-client.ts"></script>,所以会执行entry-client.ts客户端渲染文件,进行注水激活页面

线上测试

线上部署,就是将distpackage.jsonserver.js等文件放到服务器中,然后服务器需要安装 Node 环境,配置好 Nginx,然后安装依赖后执行npm run build:prod命令启动项目,即可公网访问项目。

SSG/预渲染

静态站点生成 (Static-Site Generation,缩写为 SSG),也被称为预渲染,是另一种流行的构建快速网站的技术。如果用服务端渲染一个页面所需的数据对每个用户来说都是相同的,那就可以只渲染一次,提前在构建过程中完成,而不是每次请求进来都重新渲染页面。预渲染的页面生成后作为静态 HTML 文件被服务器托管。

SSG 保留了和 SSR 应用相同的性能表现:它带来了优秀的首屏加载性能。同时,它比 SSR 应用的花销更小,也更容易部署,因为它输出的是静态 HTML 和资源文件。这里的关键词是静态:SSG 仅可以用于消费静态数据的页面,即数据在构建期间就是已知的,并且在多次部署期间不会改变。每当数据变化时,都需要重新部署。

如果你调研 SSR 只是为了优化为数不多的营销页面的 SEO (例如 /、/about 和 /contact 等),那么你可能需要 SSG 而不是 SSR。SSG 也非常适合构建基于内容的网站,比如文档站点或者博客。VitePress 就是一个由 Vite 和 Vue 驱动的静态站点生成器。

Vite脚手架渲染SSG代码示例