在 2023 年的年底,我用 Vite-SSG + Vue3 + Vuetify3 把之前使用 SPA 編寫的官網進行了重構,支持多語言,響應式並且對 SEO 和社交媒體分享十分友好 ...
在 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) ,我會不定期分享一些技術實踐和項目