在 2023 年的年底,我终于有时间下定决心把我的 UtilMeta 项目官网 进行翻新,主要的原因是之前的官网是用 Vue2 实现的一个 SPA 应用,对搜索引擎 SEO 很不友好,这对于介绍项目的官网来说是一个硬伤

所以在调研一圈后,我准备用 Vite-SSG + Vue3 + Vuetify3 把官网重新来过,前后花了两周左右的时间,本文记录着开发过程中的思考和总结,要点主要有

  • 为什么 SPA 应用不应该用于搭建项目官网?
  • SSG 项目的结构是怎样的,如何配置页面的路由?
  • 如何搭建多语言的静态站,编写支持多语言的页面组件,以及使用 lang / hreflang 为页面指定不同的语言版本?
  • 如何用 unhead 库为每个页面配置不同的 html 头部元信息,优化搜索引擎收录?
  • 如何使用 @media CSS 媒体规则处理响应式页面在不同设备的首屏加载问题?
  • 如何优雅处理 404 问题,避免 soft 404 对搜索收录的影响?

为什么不应该用 SPA 开发官网

这里我们先收窄一下定义,把【官网】定义为一个介绍性质为主的网站,比如产品介绍,定价方案,关于我们等等,而不是一个直接交互的动态产品(比如各种各样的 2C 内容平台,社交平台),对于动态产品而言使用 SPA 其实无妨,如果想优化搜索收录可以定期把一些固定的 profile 页面或者文章页面提交给搜索引擎

所以就是一个原因,SEO。这是老生常谈的问题,SPA 只会生成单个 index.html,爬取你网站上的任何 URL 都只会返回同样的内容,其中还往往不包括即将渲染出的文本,关键词和链接等信息,这就导致搜索引擎呈现的结果一塌糊涂,不仅如此,在 Twiiter, Discord 等社交媒体直接抓取链接元信息(标题,描述,插图)并渲染的平台上,你的每个网页都只会呈现一样的信息

对于一个需要在互联网上获客的项目,我们都不应该忽视来自搜索引擎的流量,尤其是国际化的项目。即使我们来到了 AIGC 纪元,以 ChatGPT 为代表的大模型训练语料获取仍然以爬取网页数据为主,这时你的项目各页面如果能够提供清晰的,包含足够准确的关键词和信息的,符合 Web 规范的 HTML 结果,你的项目或文档也有可能会被 AI 收录并整合到它们的输出结果中,所以我认为对网页结构和渲染的优化其实就是可以统称为 Agent Optimization,即【对来自搜索引擎或大模型的】网络爬取优化,依然十分重要

合适的姿势是?

SSR(服务端渲染) / SSG(服务端生成) 都是介绍性官网开发的合适姿势,对于不需要太多渲染逻辑的静态页面来说,SSG 就足矣,你只需要把生成出来的 HTML 扔到任何页面托管网站上都可以直接提供访问,对 CDN 也足够友好,如果自己喜欢折腾也可以搞自己的服务器来部署,我自己就是使用 nginx 来部署 SSG 生成的静态页面作为 CDN 的回源

SSG 项目结构

与 SPA 应用相比,SSG 项目最主要的区别是:路由与对应的页面模板是固定的,并且在构建阶段会直接生成每个页面的 html 文件,而不是像 SPA 一样只生成一个 index.html

反映到 Vue 项目的文件结构上,SPA 应用往往需要一个 router 文件来定义 vue-router 的路由和对应的组件,而 SSG 应用则可以把每个页面的路由和对应的 Vue 页面组件直接定义在一个文件夹中(往往命名为 pages

所以 Vite-SSG 项目的 main.js 一般长这个样子:

import App from './App.vue'
import { ViteSSG } from 'vite-ssg'
import routes from '~pages';
import vuetify from './plugins/vuetify';

export const createApp = ViteSSG(
  App,
  // vue-router options
  {routes, scrollBehavior: () => ({ top: 0 }) },
  // function to have custom setups
  ({ app, router, routes, isClient, initialState }) => {
    // install plugins etc.
    app.use(vuetify)
  },
)

我们用 vite-ssg 定义的 ViteSSG 来代替 Vue 默认的 createApp,在导入路由时,我们使用了

import routes from '~pages';

这是来自 vite-plugin-pages 插件的支持,你可以直接把一个文件夹下的 Vue 组件转化为对应的页面路由,只需要在 vite.config.js 中配置

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

export default defineConfig(
  ({command, mode}) => {
    return {
      plugins: [
        Pages({
          extensions: ['vue', 'md'],
        }),
        ...
      ],
      ...
    }
  }
)

处理多语言页面路由

如果你的官网需要给来自世界各地的用户介绍你们的项目,多语言就基本上是一个必选项了,我们以支持中文与英文为例,其他的语言支持方式可以依此类推

之前我对于多语言的处理是根据 IP 属地返回语言然后前端直接设置语言,并没有反应到 URL 上,这其实是一种 Bad Practice,对于用户访问的时候看到的是什么语言版本的页面完全不可控(因为他们可能使用了代理),用户在分享页面时他的受众也是同理,搜索引擎也无法完全抓取所有的语言版本(因为 Google 的爬虫主要在美国),所以 Google 也在 文档 中说明很不建议这样的做法

对于 SSG 的页面路由,我的多语言实现实践是:为每个页面实现一个通用的页面组件,其中定义一个属性 lang,组件中展示的所有文字都可以根据这个 lang 属性选择对应的语言版本,由于页面的属性在 SSG 构建时会直接传入,所以会生成不同语言版本的 HTML 页面文件,一个最简化的页面组件示例如下

<script setup>
const props = defineProps({
  lang: {
    type: String,
    default() {
      return 'en'
    }
  },
})

const messages = {
  zh: {
    title: '构建数字世界的基础设施'
  },
  en: {
    title: 'Building the infrastructure of the digital world'
  },
}

const msg = messages[props.lang];
</script>

<template>
  <div>
    {{ msg.title }}
  </div>
</template>

接下来我们就可以搭建我们多语言页面的文件夹结构了,你可以选择把不同的语言都作为不同的子路由,比如

/pages
    /en
        index.vue
    /zh
        index.vue
    /ja
        index.vue
    /..

这样访问 /en 会进入英文页面,访问 /zh 会进入中文页面

还有一种方式是选择一种语言作为默认语言,如英语,然后将它的子路由置于与其他语言目录平行的位置,比如

/pages
    /zh
        index.vue
    /ja
        index.vue
    index.vue     # en

utilmeta.com 采用的是第二种模式,因为我想让官网的域名是可以直接访问和链接的,保持简洁,所以我对它的路由是这样规划的

/pages
    /zh
        index.vue ------ 首页(中文)
        about.vue ------ 关于我们(中文)
        solutions.vue -- 解决方案(中文)
        py.vue --------- UtilMeta Python 框架介绍(中文)
    index.vue    ------- 首页(英语)
    about.vue ---------- 关于我们(英语)
    solutions.vue ------ 解决方案(英语)
    py.vue ------------- UtilMeta Python 框架介绍(英语)

按照 JavaScript 的惯例,index 就会被处理为与它的目录一致的路由,其他的名称会根据名称分配路由

其中,每个语言的页面组件都可以直接引入它对应的通用页面组件,然后将 lang 属性传入通用页面组件中,比如 /zh/about.vue 是中文的 “关于我们” 页面组件

<script setup>
import About from "@/views/About.vue";
import AppWrapper from "@/components/AppWrapper.vue";
</script>

<template>
  <AppWrapper lang="zh" route="about">
     <About lang="zh"></About>
  </AppWrapper>
</template>

其中 @/views/About.vue 是 “关于我们” 页面的通用组件,我们传入了 lang="zh",而 AppWrapper 是我编写的一个通用的页面骨架组件,包含着每个页面都需要的顶栏,底栏,边栏等页面架构

语言切换

对于支持多语言的官网,我们可以需要在其中添加一个让用户主动切换语言的按钮,它的逻辑也非常简单,只需要将用户展示一个支持的语言列表,然后每个语言按钮都能将用户切换到对应的页面路由,比如

<template>
	<v-menu open-on-click>
	  <template v-slot:activator="{ props }">
		<v-btn v-bind="props">
		  <v-icon>mdi-translate</v-icon>
		</v-btn>
	  </template>
	  <v-list color="primary">
		<v-list-item
          v-for="(l, i) in languages"
          :to="getLanguageRoute(l.value)"
          :active="lang === l.value"
          :key="i"
        >
          <v-list-item-title>{{ l.text }}</v-list-item-title>
        </v-list-item>
	  </v-list>
	</v-menu>
</template>

<script setup>
  const props = defineProps({
    lang: {
      type: String,
      default(){
        return 'en'
      }
    },
    route: {
      type: String,
      default(){
        return ''
      }
    }
  });

  const languages = [{
    value: 'en',
    text: 'English'
  }, {
    value: 'zh',
    text: '中文'
  }];
	
  function getLanguageRoute(l){
    if(l === 'en'){
      return '/' + props.route;
    }
    if(!props.route){
      return `/${l}`
    }
    return `/${l}/` + props.route
  }
</script>

还是以上面的 About 页面为例,如果用户目前处于 https://utilmeta.com/about 路由(英语),而点击了 中文 语言,就需要被引导到 https://utilmeta.com/zh/about 页面,从用户视角看来,页面的结构完全一致,只不过语言从英语切换到了中文

使用 unhead 为页面注入元信息

对于静态页面而言,<head> 中的头信息与页面元信息非常重要,它决定着搜索引擎收录的索引与关键词,也决定着页面链接在社交媒体分享时渲染的信息,一般来说 Vue 的页面组件只是编写 <body> 中的元素,但只需要使用一个名为 unhead 的库,你就可以为不同的页面编写不同的头信息了,比如以下是我在 UtilMeta 中文首页的页面组件中编写的元信息

<script setup>
import { useHead } from '@unhead/vue'

const title = 'UtilMeta | 全周期后端 API 应用 DevOps 解决方案';
const description = '面向后端 API 应用的全生命周期解决方案,助力每个创造者,我们的产品有 UtilMeta Python 框架,一个面向后端 API 开发的渐进式元框架,API 管理平台,以及 utype';

useHead({
  title: title,
  htmlAttrs: {
    lang: 'zh'
  },
  link: [
    {
      hreflang: 'en',
      rel: 'alternate',
      href: 'https://utilmeta.com'
    }
  ],
  meta: [
    {
      name: 'description',
      content: description,
    },
    {
      property: 'og:title',
      content: title
    },
    {
      property: 'og:image',
      content: 'https://utilmeta.com/img/zh.index.png'
    },
    {
      property: 'og:description',
      content: description
    }
  ],
})

import Index from '@/views/Index.vue'
import AppWrapper from "@/components/AppWrapper.vue";

</script>

<template>
  <AppWrapper lang="zh">
    <Index lang="zh"></Index>
  </AppWrapper>
</template>

其中重要的属性有

  • title:页面的标题,直接影响着用户在浏览器中看到的页面标题与搜索引擎收录的网页中的标题
  • htmlAttrs.lang:可以直接在 html 根元素中编辑语言属性 lang 的值
  • hreflang:通过插入含有 hreflang 属性的 <link> 元素,你可以为页面指定不同的语言版本,这里我们就指定了首页的英文版本的链接,这样的属性能够更好地为搜索引擎的多语言呈现提供便利
  • meta.description:元信息中的描述,
  • og:* 按照社交媒体渲染链接所通用的 Open Graph 协议 规定的属性,可以决定着你在把链接分享到如 Twitter(X), Discord 等社交媒体或聊天软件中时,它们的标题,描述和插图

元信息的注入应该是页面级的,也就是对于不同语言的页面,你也应该注入该语言版本的元信息

实现静态页面的响应式

你当然希望你的官网在宽屏电脑,平板和手机中都能有着不错的显示效果(或者至少不要出现元素错乱重叠),想要做到这些,就需要开发响应式的网页

我开发 UtilMeta 官网使用的是 Vue 组件库是 Vuetify,Vuetify 已经提供了一套 Display 系统和 breakpoints 机制,能够提供一系列响应式的断点,让我们在开发时为不同的设备指定不同的显示效果

比如

<v-row>
	<v-col :cols="display.xs.value ? 12 : 6">
	</v-col>
	<v-col :cols="display.xs.value ? 12 : 6">
	</v-col>
<v-row>

这样你就可以通过行列调节内容在不同尺寸设备上的显示了,示意如下

模板语法的问题

一切看起来都不错吧?你发现本地调试时确实能够做到响应式,但是当网站上线时却发现了问题

那就是,网页在电脑端加载时,也会默认保持移动端的样式,直到 js 加载完毕后,才会根据屏幕尺寸调整到合适的样式,这样在加载或刷新时,用户会看到网页的元素在几秒内发生了跳变,这是很奇怪的体验,那么为什么会造成这样的问题呢?

我打开了 vite-ssg 生成的 html 后发现,SSG 在生成时会直接把模板中的配置进行固定和渲染,对于类似下面的响应式代码

<v-col :cols="display.xs.value ? 12 : 6">
	<h1 :style='{fontSize: display.xs.value ? "32px" : "48px"}'></h1>
</v-col>

其实在构建成 HTML 文件时就会渲染成

<div class="v-col-12">
	<h1 :style="font-size: 32px"></h1>
</div>

渲染程序会直接把 display.xs.value (以及其他的响应式条件)作为 true 来处理,得到的 HTML 文件就会把某一个设备的样式给固定,所以用户在加载时就只能等到控制响应式的 js 代码加载完毕才能够根据设备尺寸重新渲染,就会造成短暂的元素跳变的问题

救星 - @media CSS 媒体规则

那么如何正确处理静态页面的响应式样式呢?我探索出的答案是使用 @media 媒体规则,它可以让你根据屏幕的大小创建不同的样式规则,这样你的响应式样式就 完全由 CSS 控制 了,当页面渲染出来的时候(依赖的 css 加载完毕)就会完全按照 CSS 规则进行渲染,在不同设备刷新时也都会直接呈现适配对应设备尺寸的渲染结果,不会出现元素跳变的问题

比如我把 About 页面的标题添加了 about-title 类,然后在对应的 CSS 中编写

  .about-title{
    font-size: 60px;
    line-height: 72px;
    max-width: 800px;
    margin: 6rem auto 0;
  }

  @media (max-width: 600px){
    .about-title{
      font-size: 36px;
      line-height: 48px;
      margin: 3rem auto 0;
    }
}

这样,About 页面的标题在尺寸小于 600px 的设备中就可以按照 @media 块中定义的样式展现了

处理 v-row / v-col

Vuetify 提供的网格(v-row 控制行,v-col 控制列)系统可以很大程度提升响应式网页开发的效率,但是我们往往需要让行列的显示在不同的设备上保持响应式,然而 @media 属性尚不支持为不同的设备尺寸赋予不同的 HTML class,那么如何处理网格系统在 SSG 应用中的响应式呢?

下面是我的实践,仅供参考:对于需要在移动端切换行数的 v-col 组件,我们可以直接把它在移动端对应的行数命名为一个类,比如 xs-12-col

<v-row>
    <v-col :cols="6" class="xs-12-col">
    </v-col>
    <v-col :cols="6" class="xs-12-col">
    </v-col>
</v-row>

然后我们使用 @media 规则,在移动端尺寸的设备中直接为这些类指定网格样式参数,比如

@media (max-width: 600px) {
	.xs-12-col{
	  flex: 0 0 100%!important;
	  max-width: 100%!important;
	}
	.xs-10-col{
	  flex: 0 0 83.3%!important;
	  max-width: 83.3%!important;
	}
	.xs-2-col{
	  flex: 0 0 16.6%!important;
	  max-width: 16.6%!important;
	}
}

这样,我们的网格系统也可以支持 SSG 中的响应式样式,而不会出现加载跳变了

部署静态网站

优雅处理 404

在 SSG 静态页面中,我们的网站支持的路由是预先定义和生成好的,其他的路径访问都应该直接返回 404,但为了给用户更好的体验,一般常见的做法是单独制作一个 404 Notfound 页面,在访问路径没有页面时展示给用户,让他能方便地转回首页或其他页面,比如 UtilMeta 官网的 404 页面如下

使用 Vite-SSG 实现这样的效果并不困难,你只需要在 pages 文件夹中增加两个组件

  • 404.vue
  • [...all].vue

这两个组件中的内容都是相同的,都放置着 404 页面的组件代码,[...all].vue 会作为所有没有匹配到路由的页面请求的返回页面,而 404.vue 会输出一个显式的路由 404.html,方便在 nginx 中直接进行重定向

完成我们的 SSG 页面开发后,我们可以调用下面的命令将页面构建出对应的 HTML 文件

vite-ssg build

对于我的 UtilMeta 官网而言,生成的文件如下

/dist
    /zh
        about.html
        py.html
        solutions.html
    404.html
    zh.html
    about.html
    index.html
    py.html
    solutions.html

接着,你就可以将这些静态文件上传到页面托管服务或者自行搭建的静态服务器上即可提供访问了,我搭建 UtilMeta 官网的静态服务器使用的 nginx 配置如下

server{
    listen 80;
    server_name utilmeta.com;
    rewrite ^/(.*)/$ /$1 permanent;
    
    location ~ /(css|js|img|font|assets)/{
        root /srv/utilmeta/dist;
        try_files $uri =404;
    }
    location /{
        root /srv/utilmeta/dist;
        index index.html;
        try_files $uri $uri.html $uri/index.html =404;
    }

    error_page 404 403 500 502 503 504 /404.html;

    location = /404.html {
        root /srv/utilmeta/dist;
    }
}

配置中监听 80 而非 443 端口是因为我的官网作为静态站,官网需要的静态资源已经全部托管给 CDN 了(包括 SSL 证书),这里的 nginx 配置的是 CDN 的回源服务器,所以提供 HTTP 访问就 ok 了

nginx 配置中 rewrite ^/(.*)/$ /$1 permanent 的作用是将目录的访问映射到对相应 HTML 文件的访问,比如将 https://utilmeta.com/zh/ 映射到 https://utilmeta.com/zh,否则 Nginx 会出现 403 Forbidden 的错误

因为 vite-ssg 默认的生成策略会把位于目录路径的 index.vue 文件生成为与目录同名的 html 文件,而不是放置于目录中的 index.html 文件,所以如果不进行 rewrite 去掉路径结尾的 / 的话,https://utilmeta.com/zh/ 就会直接访问到 /zh/ 目录上,这对于 nginx 来说是禁止的行为

值得注意的是,对于 404 页面的返回,最好需要伴随着一个真正的 404 响应码(Status Code),而不是使用 200 OK 的响应(那样一般称为软 404),因为对于搜索引擎而言,只有检测到 404 响应码,才会把这个路由视为无效,而不是判断返回页面中的文字,尤其当你的站点进行翻新时,老站点的一些路由就会失效了,如果它们一直留在搜索引擎的结果中误导用户,也会给访客造成很大的困扰

在上面的 nginx 配置中,我们把所有 try_files 指令最后都附上了 =404 ,也就是在匹配不到任何文件时生成 404 的响应码,然后使用 error_page 把包括 404 在内的常见的错误或故障响应码的错误页面指定为 /404.html ,也就是我们之前编写的 404 页面,这样我们就解决了软 404 的问题,所有无法匹配的路径都会返回正确的 404 响应码以及制作好的 404 页面

总结

总结一下我们学到和完成的东西

  • 用 Vite-SSG 编写一个 SSG 官网项目,了解了 SSG 项目的页面路由方式
  • 编写可复用的多语言的 SSG 页面组件,通过路由切换实现语言切换功能
  • 使用 unhead 为每个页面注入头部元信息,使得每个页面在搜索引擎与社交媒体上都能正确美观地展示
  • 使用 @media 解决实现 SSG 静态页面的响应式中的问题,以及 Vuetify 网格布局在 SSG 响应式中的实践
  • 优雅处理静态页面的 404 问题,避免软 404,提高页面收录质量和用户体验

如果你觉得这篇文章有帮助,可以逛一下这篇文章中我最终构建的项目官网 utilmeta.com ,也可以关注一下我的 X(Twitter) ,我会不定期分享一些技术实践和项目