# 一、前言

公司目前由我负责的主要有两个网页项目:

  1. 一个官网首页,纯做数据展示的;
  2. 一个官网课程资源页,用户能够点击进入课程页面,并能够阅读课程的 md 文档教程。

这两个项目都是非常单纯的通过 npm init vite@latest 创建的 Vue3+TS 项目,并且其中也都引入了目前非常流行的 ElementPlus 作为组件库。

目前多了一个新的需求:要交给我进行 SEO ,也就是 搜索引擎优化 。在这边先暂且列一些给 Vue+Vite 项目做 SEO 的一些常见方式吧:

  1. 服务器端渲染 (SSR): 由于 Vue 是一个客户端渲染的 JavaScript 框架,搜索引擎在抓取页面时可能不会执行 JavaScript,导致内容无法被索引。通过服务器端渲染,可以提前在服务器上生成页面的 HTML,确保搜索引擎能够抓取到页面的内容。
  2. 预渲染 (Pre-rendering): 对于静态内容较多的站点,可以使用预渲染。这意味着在构建过程中为每个路由生成静态的 HTML 文件。这可以通过像 vite-plugin-ssr 这样的插件来实现。
  3. 使用合适的元标签 (Meta tags): 确保每个页面都有恰当的 <title><meta> 描述标签,这些都是搜索引擎用来了解页面内容的重要信息。可以使用像 vue-meta 这样的库来在 Vue 组件中管理这些标签。
  4. 路由优化:使用基于历史的路由( history mode )而不是哈希路由( hash mode )。这样可以确保每个页面都有一个干净且易于索引的 URL。
  5. 生成网站地图 (Sitemap): 为网站生成一个 sitemap.xml,帮助搜索引擎更好地索引网站内容。
  6. 使用无障碍标准 (Accessibility): 确保网站符合无障碍标准,这不仅对用户友好,也有助于提高 SEO 排名。

当然,上面的这几种方式都是报菜名罢了。我们公司的技术负责人和我经过一些调研和讨论之后,最终选定了 静态站点生成(SSG, Static Site Generation) 来对我们的项目进行 SEO。为什么选择 SSG 呢?因为我们目前的项目都是偏向于 数据呈现 而与用于在实际应用层面上的交互相对比较少,也就是比较偏向于 静态页面

再次基础上,我们选择了 Vite-SSG 这个插件作为我们进行 SSG 打包的工具。

# 二、Vite-SSG 是什么?

Vite-SSG(Static Site Generation)是一个基于 Vite 的工具,用于生成静态站点。它专门为 Vue.js 应用程序设计,提供了一个高效的方式来构建静态生成的网站。 Vite-SSG 有几个主要关键特点:

  1. 静态站点生成:Vite-SSG 生成的是静态 HTML 文件。这意味着的网站可以作为静态文件部署在任何标准的 web 服务器或静态文件托管服务上。
  2. 基于 Vite:作为 Vite 的一个扩展,Vite-SSG 充分利用了 Vite 的快速冷启动和即时模块热更新(HMR)等特性,提供了快速的开发体验。
  3. Vue.js 支持:Vite-SSG 是为 Vue.js 应用程序设计的。它支持 Vue 3,允许利用 Vue.js 的全功能集,包括组件、路由、状态管理等,也可以让在原本已经开发完成的 Vue 项目中引入。
  4. 服务器端渲染(SSR)友好:虽然 Vite-SSG 重点是静态站点生成,但它也支持和优化了服务器端渲染的相关特性,使得生成的站点可以更好地支持 SEO 和首次加载性能。
  5. 客户端激活:生成的静态页面在客户端被 Vue.js “激活”,从而变成一个完全功能的单页面应用(SPA)。这使得在初始的快速加载和后续的丰富交互之间取得了很好的平衡。
  6. 灵活性和可扩展性:Vite-SSG 允许自定义配置,包括路由、预渲染行为、插件等,使得可以根据项目需求调整和优化。

Vite-SSG 适用于需要快速静态内容加载,同时在客户端提供丰富交互的网站。这包括博客、企业官网、产品展示网站、文档站点等。同时,对于需要良好搜索引擎优化(SEO)的站点,Vite-SSG 也是一个很好的选择。

# 三、在开发完成的项目中进行配置

按照一般的开发流程开发完成之后,就需要先引入 Vite-SSG 的插件,并对整个项目的关键配置项进行修改,最后运行打包,逐一排查错误并修复。

# 安装插件

先安装 Vite-SSG 插件:

npm i -D vite-ssg

# 修改配置文件

# package.json

安装完成后,修改 package.json 中的 build 指令:

{
  "scripts": {
    "dev": "vite",
-   "build": "vite build"
+   "build": "vite-ssg build"
    // 想使用另外的vite配置文件,不使用原本的vite.config.ts,把build的命令改成下面这样
+   "build": "vite-ssg build -c another-vite.config.ts"
  }
}

# src/router/index.ts(vue-router)

之后,需要修改在原本的项目中配置好的 src/router/index.ts 文件(默认采用了 vue-router 作为项目的路由管理插件)以适应 ssg 的打包操作。

按照一般的项目开发流程,一般会在这个文件中使用 createRouter 来创建 Vue Router 的实例,并由 main.ts 引入之后给 app 进行注册。

// src/router/index.ts
// 这是一般的路由配置文件
import { createWebHistory, createRouter, RouteRecordRaw } from 'vue-router'
import Home from '@/views/Home/index.vue'
const routes: RouteRecordRaw[] = [
  {
    path: '/',
    redirect: '/home'
  },
  {
    path: '/home',
    component: Home,
    name: 'Home',
  }
  ...
]
const router = createRouter({
  history: createWebHistory(),
  routes
})
export default routes

而对于 vite-ssg 而言,是不需要进行实例的创建并注册的。它只需要知道的整个路由配置数组即可,并且通过在路由配置中所引入的组件来解析,为应用中的每个路由生成对应的静态 HTML 文件。因此可以把上述的配置稍作修改:

// src/router/index.ts
// 不需要 createRouter 创建 Vue Router 实例
import { RouteRecordRaw } from 'vue-router'
import Home from '@/views/Home/index.vue'
export const routes: RouteRecordRaw[] = [
  {
    path: '/',
    redirect: '/home'
  },
  {
    path: '/home',
    component: Home,
    name: 'Home',
  }
  ...
]

# src/main.ts

修改好路由之后,我们可以返回我们的 main.ts 进行配置。先给出官方定义的一套模板:

// src/main.ts
import { ViteSSG } from "vite-ssg";
import App from "./App.vue";
// `export const createApp` is required instead of the original `createApp(App).mount('#app')`
export const createApp = ViteSSG(
  // the root component
  App,
  // vue-router options
  { routes },
  // function to have custom setups
  ({ app, router, routes, isClient, initialState }) => {
    // install plugins etc.
  }
);

可以看到,在配置了 App 和 routes 之外,第三个参数是一个回调函数,其中的参数对象包含了五个属性:app, router, routes, isClient, initialState。

  1. app
  • 类型App 实例
  • 用途:这是 Vue 应用的实例。可以通过这个实例来安装插件、注册全局组件或进行其他全局配置。
  1. router
  • 类型Router 实例
  • 用途:这是 Vue Router 的实例。可以通过它来访问路由相关的功能,比如添加路由守卫、访问当前路由等。这个就是通过 routes 创建出来的,可以在 app 上面进行注册。
  1. routes
  • 类型RouteRecordRaw[]
  • 用途:这是定义在的应用中的所有路由规则的数组。每个条目都是一个 RouteRecordRaw 对象,包含路由的路径、组件等信息。
  1. isClient
  • 类型boolean
  • 用途:这是一个布尔值,用来指示当前代码是在客户端运行还是在服务器端(SSR/SSG)环境中运行。这对于处理只能在客户端运行的代码(如直接操作 DOM)非常有用。
  1. initialState
  • 类型any
  • 用途:这是一个用于传递初始状态的对象,可以在服务器端渲染时用来共享状态到客户端。在 SSR/SSG 场景中,可以在服务器端设置一些初始状态,然后在客户端用这个状态来 “激活” 应用。

为了更好理解,我给出一个我自己的个人博客的配置示例:

import { ViteSSG } from "vite-ssg";
import App from "./App.vue";
import "./styles/index.scss";
import "element-plus/dist/index.css";
import { createPinia } from "pinia";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
import { routes } from "./router";
import NoList from "@/components/Global/NoList.vue";
import CommonHeader from "@/components/Global/CommonHeader.vue";
// ViteSSG 函数
export const createApp = ViteSSG(
  // 根组件
  App,
  // 路由选项
  { routes },
  // 可选的回调函数,进行更多的应用配置
  ({ app, router, initialState, isClient, onSSRAppRendered }) => {
    const whilelist = ["/login"];
    // 配置路由守卫
    router.beforeEach((to, _, next) => {
      if (
        whilelist.includes(to.path) ||
        (!import.meta.env.SSR && localStorage.getItem("token"))
      ) {
        next();
      } else {
        next("/login");
      }
    });
    // 配置状态管理
    const store = createPinia();
    store.use(piniaPluginPersistedstate);
    // 使用状态管理和路由
    app.use(store).use(router);
    // 注册全局组件
    app.component("NoList", NoList).component("CommonHeader", CommonHeader);
    // 服务端渲染时,将状态同步到客户端
    if (isClient) {
      store.state.value = initialState.pinia || {};
    } else {
      onSSRAppRendered(() => {
        initialState.pinia = store.state.value;
      });
    }
  }
);

我的配置中有一个比较明显的点,就是 !import.meta.env.SSR && localStorage.getItem('token') 这个逻辑。其实原本这条逻辑我只是用于判断 token 是否存在本地而已,但是为了配合 SSR,必须加上 !import.meta.env.SSR 来判断是否是服务端渲染环境。因为在服务端环境是不能够调用 window 的 API 的。如果是服务端渲染环境, import.meta.env.SSR 的值为 true;反之则为 false。

我的配置相对于传统模板多了个 onSSRAppRendered 的钩子函数,用于在服务器端渲染(SSR)过程中,在应用渲染完成后执行特定的操作。这个钩子在服务器端渲染环境中特别有用,它允许在 Vue 应用渲染成 HTML 字符串之后,但在该字符串发送到客户端之前,执行一些额外的逻辑。在我这边就是将 pinia 给初始化成 {}。

可以看出,相较于平常的 main.ts 编写,vite-ssg 的引入把对 app 以及插件的配置全部放到了一个回调函数内部进行处理。在这个回调函数的内部,插件引入的注册等还是和原来的 main.ts 相差不大。

# vite.config.ts

main.ts 配置完成后,需要再在 vite.config.ts 中对 element-plus 这个插件进行配置。

// vite.config.ts
import { defineConfig, loadEnv } from "vite";
import vue from "@vitejs/plugin-vue";
import path from "path";
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, __dirname);
  return {
    base: "/",
    plugins: [
      vue(),
      AutoImport({
        resolvers: [ElementPlusResolver()],
      }),
      Components({
        resolvers: [ElementPlusResolver()],
      }),
    ],
    resolve: {
      // 配置路径别名 @
      alias: {
        "@": path.resolve(__dirname, "./src"),
      },
    },
    server: {
      port: env.VITE_PORT as unknown as number, // 端口号
      open: false, // 是否自动打开浏览器
    },
    // 在这里进行 SSR 渲染相关配置
    ssr: {
      noExternal: ["element-plus"],
    },
  };
});

vite.config.ts 中的 ssr 选项是针对服务器端渲染(SSR)的一项配置,它的作用是告诉 Vite 在 SSR 构建过程中处理特定的外部模块(在这个例子中是 element-plus )的方式。

在服务器端渲染(SSR)的上下文中,处理外部模块(即 node_modules 中的模块)可能会比较复杂。一些模块可能不兼容 SSR,或者可能需要特殊处理以便在服务器端正确运行。

noExternal 这个选项用于指定哪些模块不应该被视为外部模块。通常情况下,Vite 会将 node_modules 中的模块视为外部模块,这意味着它们不会被包含在服务器端的最终打包文件中。但当你在 noExternal 中指定了某个模块,Vite 会将这个模块包含在 SSR 的打包过程中。

对于 element-plus 这样的 UI 框架,如果它包含对 SSR 不友好的代码(例如,直接访问浏览器的 windowdocument 对象),将其包含在 SSR 打包中并进行适当的处理,可以避免在服务器端渲染时出现问题。

# 尝试打包排查错误

上述配置文件修改好了之后,最后一步便是运行打包并排查错误。

按照我个人的经验,一般打包的时候都会遇到和浏览器自身 API 相关的错误,如 window、localStorage 未定义等等。因为 Vite-SSG 在进行打包的时候会进行 Server 端的打包,它会确保打包完成的 dist 能够放到服务端进行渲染,也就是 SSR ;但是一般涉及到与用户进行客户端层面数据交互的应用都是会放到浏览器端运行的。因此为了消除报错,我们可以在打包报错指定的地方加上 !import.meta.env.SSR 的判断。之前说过,它会自动检测当前部署的环境并改变值。

接下来我给一个我自己在打包的时候遇到的一个错误:在 pinia 中引入 pinia-plugin-persistedstate 持久化插件。

我创建了一个 pinia 的 module,名为 user,用于全局存放用户个人信息,随用随取,并且使用 persist: true 开启持久化。

import { defineStore } from "pinia";
import { User } from "@/api/user/types";
export const useUserStore = defineStore("user", {
  state: () => ({
    userInfo: <User>{},
    isLogin: false,
  }),
  actions: {
    setUserInfo(userInfo: User) {
      this.userInfo = userInfo;
    },
    setLogin(isLogin: boolean) {
      this.isLogin = isLogin;
    },
    reset() {
      this.userInfo = <User>{};
      this.isLogin = false;
    },
  },
  persist: true,
});

我进行 npm run build 进行 vite-ssg 打包,出现如下报错:

image-20231214151344277

可以看到,有关于 localStorage 的报错很多。因为 pinia-plugin-persistedstate 这个插件本身就是利用 localStorage 进行持久化的。

我们可以利用 !import.meta.env.SSR 配合其 storage 的配置来判断当前的环境是否是服务器端。

我们可以修改如下:

import { defineStore } from "pinia";
import { User } from "@/api/user/types";
export const useUserStore = defineStore("user", {
  state: () => ({
    userInfo: <User>{},
    isLogin: false,
  }),
  actions: {
    setUserInfo(userInfo: User) {
      this.userInfo = userInfo;
    },
    setLogin(isLogin: boolean) {
      this.isLogin = isLogin;
    },
    reset() {
      this.userInfo = <User>{};
      this.isLogin = false;
    },
  },
  // 如果是在浏览器端渲染,开启 localStorage 持久化
  persist: import.meta.env.SSR === false && {
    storage: localStorage,
  },
});

这里的 && 有点 React 写 JSX 的时候的条件渲染的味道。

这里只是举了个我自己碰到的报错,如果有其他的报错也如法炮制即可。而且目前的关于浏览器 API 报错仅存在于一些单纯的配置文件(.ts)中,在.vue 组件内部调用都是不会出现问题的。

# 四、打包完成!

按照上述步骤,如果不出意外的话就可以打包成功了。

image-20231214152638510

打包成功了之后,会输出很干净的页面列表。每个页面都对应着在 src/router/index.ts 中定义的路由列表的配置,可以在 dist 里面查看。

image-20231214152744179

我再贴上我的路由配置文件,可以做一个对照:

import { RouteRecordRaw } from "vue-router";
export const routes: RouteRecordRaw[] = [
  {
    path: "/",
    redirect: "/home",
  },
  {
    path: "/login",
    name: "login",
    component: () => import("@/views/login/index.vue"),
  },
  {
    path: "/home",
    name: "home",
    component: () => import("@/views/home/index.vue"),
  },
  {
    path: "/articleHome/:id",
    name: "articleHome",
    component: () => import("@/views/articleHome/index.vue"),
  },
  {
    path: "/postArticle",
    name: "postArticle",
    component: () => import("@/views/post/index.vue"),
  },
  {
    path: "/personalCenter/:id",
    name: "personalCenter",
    redirect: (to) => {
      return `/MyArticles/${to.params.id}`;
    },
    component: () => import("@/views/personalCenter/index.vue"),
    children: [
      {
        path: "/MyArticles/:id",
        name: "MyArticles",
        component: () =>
          import("@/components/ModelPersonalCenter/MyArticle/ArticlePost.vue"),
      },
      {
        path: "/MyCollection/:id",
        name: "MyCollection",
        component: () =>
          import(
            "@/components/ModelPersonalCenter/MyCollection/ArticleCollection.vue"
          ),
      },
      {
        path: "/MyInfo/:id",
        name: "MyInfo",
        component: () =>
          import("@/components/ModelPersonalCenter/MyInfo/InfoMain.vue"),
      },
      {
        path: "/MyFocus/:id",
        name: "MyFocus",
        redirect: "/MyFocusList/:id",
        component: () =>
          import("@/components/ModelPersonalCenter/MyFocus/FocusIndex.vue"),
        children: [
          {
            path: "/MyFocusList/:id",
            name: "MyFocusList",
            component: () =>
              import(
                "@/components/ModelPersonalCenter/MyFocus/MyFocusList.vue"
              ),
          },
          {
            path: "/MyFansList/:id",
            name: "MyFansList",
            component: () =>
              import("@/components/ModelPersonalCenter/MyFocus/MyFansList.vue"),
          },
        ],
      },
      {
        path: "/MyData/:id",
        name: "MyData",
        component: () =>
          import("@/components/ModelPersonalCenter/MyData/GraphInfo.vue"),
      },
    ],
  },
  {
    path: "/message",
    name: "message",
    redirect: "/message/common",
    component: () => import("@/views/messages/index.vue"),
    children: [
      {
        path: "/message/common",
        name: "messageCommon",
        component: () => import("@/components/ModelMessages/MessageCommon.vue"),
      },
      {
        path: "/message/users",
        name: "messageUsers",
        component: () => import("@/components/ModelMessages/MessageUsers.vue"),
      },
      {
        path: "/message/system",
        name: "messageSystem",
        component: () => import("@/components/ModelMessages/MessageSystem.vue"),
      },
    ],
  },
];
  • home.html 对应着 @/views/home/index.vue
  • login.html 对应着 @/views/login/index.vue
  • message.html 对应着 @/views/messages/index.vue
  • message 目录里面分别对应着三个 children 组件
  • postArticle.html 对应着 @/views/post/index.vue
  • index.html 代表着项目的启动页

可以看到,每个页面都相当于一个静态页面文件,已经完成了打包。之后可以运行 npm run preview 进行预览,或者可以直接在服务器进行客户端部署。

# 五、结语

以上就是大致的在一个成熟的 Vue3+Vite+TS+Pinia+ElementPlus 项目中引入 Vite-SSG 进行静态页面生成的大致配置打包步骤。

这篇文章是我在平常接触的业务的积累,当作是一个技术随笔吧。如果有对自己的个人项目(比如自己的博客)想做 SEO 的同学,可以参考一下我的经历。

希望能够给大家想对自己的项目进行 SEO 优化的同学们提供一点小小的帮助!(不过其实主要是给自己做记录(笑))