使用vite搭建ssr活動頁架構的實現

前言

最近接瞭個需求,重構公司的活動頁項目。要實現:

  • SEO良好
  • MPA
  • 啟動速度快,構建速度快
  • 前端工程化
  • 瀏覽器兼容至少IE11

基於這些需求,我選擇瞭 vite + react + vite-plugin-ssr

文章前面是ssr入門,老手請隨意跳過,看最後即可

入門SSR

什麼是SSR

術語

  • ssr,全名 server side render,服務端渲染
  • csr,全名 client side render,客戶端渲染
  • spa,全名 single page application,單頁面應用
  • mpa,全名 multi page application,多頁面應用

ssr的歷史

我的學習習慣是,不論學什麼,先去瞭解它的歷史背景。存在即合理,瞭解到為什麼產生一個技術,能讓我更容易去理解這門技術

最初的網頁渲染,前端三劍客:html + css + js,放在服務器上,靜態部署就可以供用戶訪問瞭。

後來隨著網頁復雜度上升,出現瞭jsp/ejs等等一系列模板語法,在服務端獲取到數據後,把數據渲染到模板中,最後生成html返回給客戶端,這是最原始的ssr。

隨著前端框架的誕生(ng/react/vue),越來越多同學開始使用框架開發web,這些前端框架的出現使得前後端開發解耦(csr的情況下),前端同學可以更充分的利用前端工程化等等新技術來健壯前端項目。而這種完全解耦的方式也帶來瞭一些問題,比如非常不友好的SEO

csr的缺點

讓我們打開一個SPA網頁(使用腳手架默認方式搭建),右鍵查看網頁源代碼

第一個問題:SEO極度不友好。 網頁裡面根本沒有內容。爬蟲最喜歡這種網頁瞭,看一眼就走。

SPA的工作方式就是使用js來動態渲染html,壓力全部給到瞭客戶端(瀏覽器)這邊,正是因為這個,第二個問題也出現瞭:首屏的加載速度較慢

為什麼ssr的需求再次出現

為瞭更好的SEO,為瞭更快的加載速度(服務端生成瞭首頁靜態頁面,客戶端可以直接展示,隨後再用JS動態渲染)

前端開發使用react/vue,可以熟練開發網頁。而cra/vue-cli腳手架創建出來的模板默認是SPA。

那麼應該如何實現 “既要,還要”呢(前端框架/seo我全都要)

如何實現基礎ssr

基於上面的問題,我們希望實現:

  • 查看網頁源代碼時,展示網頁的內容

既然需要服務端渲染,服務端用來執行vue/react這種js框架,那第一反應就是用nodejs來做服務端渲染,因為nodejs天然執行js代碼

客戶端的話,用vue來做(react也行,隻不過最近在熟悉vue3),vue3的話,體積比react更小,toC網站更好一些。react18針對ssr出瞭新api,開發者可以使用 React.lazysuspense 實現懶加載,也提供瞭很好的用戶體驗:https://github.com/reactwg/react-18/discussions/37

下面是基礎的ssr例子

以下例子 請註意:客戶端使用的是esm規范,服務端使用的是cjs

如果希望統一使用esm,可以使用 tsx 執行node腳本 或修改package.json => type: "module"

創建服務端

const express = require('express')

const app = express()

app.get('*', (req, res) => {
  res.send('Hello World')
})

app.listen(4000, () => {
  console.log('Server running at http://localhost:4000');
})

啟動服務後,打開瀏覽器 http:localhost:4000,即可看到內容

渲染vue

服務端有瞭,但是是返回的string,我們想用vue來開發,嘗試返回一個vue組件

vue3提供瞭服務端渲染組件的方法,在 vue/server-renderer

const express = require('express')
const { renderToString } = require('vue/server-renderer')
const { createSSRApp } = require('vue')

const app = express()

app.get('*', (req, res) => {
  const vue = createSSRApp({
    data: () => ({ count: 1 }),
    template: `<button @click="count++">{{ count }}</button>`,
  })

  renderToString(vue).then((html) => {
    res.send(`
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
    </head>
    <body>
      <div id="app">${html}</div>
    </body>
    </html>
    `)
  })
})

app.listen(4000, () => {
  console.log('Server running at http://localhost:4000')
})

此時打開頁面,可以看到button瞭,但是此時頁面是靜態的,因為這個頁面在服務端已經渲染好瞭,但在客戶端沒有註入vue

右鍵查看網頁源代碼,可以看到button元素

客戶端渲染

我們希望button的交互可以動起來,此時需要客戶端來做渲染瞭

const { createSSRApp } = require('vue')

const vue = createSSRApp({
  data: () => ({ count: 1 }),
  template: `<button @click="count++">{{ count }}</button>`,
})

vue.mount('#app')

這段代碼是否很眼熟,其實基本上跟服務端渲染返回的內容是一樣的。所以ssr的本質是服務端渲染靜態html+客戶端渲染js

此外,為瞭在瀏覽器中加載客戶端文件,我們還需要:

  • server.js中添加 server.use(express.static('.')) 來托管客戶端文件。這裡要註意js執行順序
  • <script type="module" src="/client.js"></script>添加到 HTML 外殼以加載客戶端入口文件
  • 通過在 HTML 外殼中添加 Import Map 以支持在瀏覽器中使用 import * from 'vue'
const express = require('express')
const { renderToString } = require('vue/server-renderer')
const { createSSRApp } = require('vue')

const app = express()

app.get('/', (req, res) => {
  const vue = createSSRApp({
    data: () => ({ count: 1 }),
    template: `<button @click="count++">{{ count }}</button>`,
  })

  renderToString(vue).then((html) => {
    res.send(`
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
      <script type="importmap">
      {
        "imports": {
          "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
        }
      }
      </script>
      <script src="/client.js" type="module"></script>
    </head>
    <body>
      <div id="app">${html}</div>
    </body>
    </html>
    `)
  })
})

app.use(express.static('.'))

app.listen(4000, () => {
  console.log('Server running at http://localhost:4000')
})

此時打開本地地址,可以看到點擊button數字變化瞭

以上是最簡單的ssr,在vue官網上可以找到這個例子。

我們甚至沒有去考慮前端的路由,狀態管理 等等。一個完整的ssr還需要一系列構建。

網頁路由

ssr的網頁路由有兩種方式

  • 服務端路由
  • 客戶端路由

服務端路由

服務端路由,就是利用 web框架的路由能力,匹配到某個路由時,返回對應的html代碼,並且加載相應的客戶端代碼,比如:

import express from 'express'

const router = express.Router()

router.get('/some-page', (req, res) => {
  // 返回 some-page 的html
  res.send(`<!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
      <script src="/client.js" type="module"></script>
    </head>
    <body>
      <div id="app">要渲染的html字符串</div>
    </body>
    </html>`)
})

服務端路由跳轉直接使用 a標簽即可

客戶端路由

客戶端路由的話,就要用到前端框架對應的路由庫,vue-router / react-router 等

可以參照官方例子做

比較兩種方式

服務端路由適合做頁面零碎的項目,如活動頁,每次跳轉路由會刷新整個頁面

客戶端路由適合做頁面之間交互強的項目,如產品頁,跳轉路由不會刷新頁面

使用vite做ssr

vue官方推薦瞭幾個做ssr的例子,包括 Nuxt/ Quasar這種重框架,也有 vite的輕框架。為瞭細粒度把控項目,我使用瞭 vite+ vite-plugin-ssr的方案來做

vite-plugin-ssr

Like Next.js / Nuxt but as do-one-thing-do-it-well Vite plugin.

類似 Next/Nuxt 但是隻做一件事並把它做好 的vite插件

這個插件的文檔寫得非常詳細,而且github上有許多例子。

插件的具體功能我不贅述,各位可看官方文檔,我在這裡講一下這個插件(v0.3x)的約定式路由的工作原理。以下 vite-plugin-ssr 簡稱為 vps

vps的約定式路由

vps推薦使用文件夾名稱作為路由,這種方式也是最方便的。活動頁不存在頁面之間的交互,所以我選擇的默認方式。

vps規定瞭一系列文件命名,作為開發/構建遍歷的條件。以下4種命名會被vps收集,每種文件有其獨特的作用。我們不要隨意以 page.*** 來命名文件

// Vite resolves globs with micromatch: https://github.com/micromatch/micromatch
// Pattern `*([a-zA-Z0-9])` is an Extglob: https://github.com/micromatch/micromatch#extglobs
export const pageFiles = {
  //@ts-ignore
  '.page': import.meta.glob('/**/*.page.*([a-zA-Z0-9])'),
  //@ts-ignore
  '.page.client': import.meta.glob('/**/*.page.client.*([a-zA-Z0-9])'),
  //@ts-ignore
  '.page.server': import.meta.glob('/**/*.page.server.*([a-zA-Z0-9])'),
  //@ts-ignore
  '.page.route': import.meta.glob('/**/*.page.route.*([a-zA-Z0-9])'),
}

dev階段

  • node啟動服務端server,調用 vps 的createPageRenderer,返回瞭 renderPage方法,我們調用 renderPage 即可獲取到服務端渲染後的內容。源碼地址
  • vps在vite的dev階段,設置瞭 optimizeDeps做依賴預構建的優化。(咱們也可以參考這塊源碼對vite項目進行一些優化)。 源碼地址

build階段

  • 針對 client / server 分別打包。如果使用約定式路由,會根據上文講到的遍歷條件,遍歷所有文件後,把所有的 .page文件設置為 input 的每一項(MPA)。源碼地址
  • 生成vps的manifest文件,其命名為 vite-plugin-ssr.json,裡面會存放一些vps的基本信息。源碼地址
  • 生成單個的server bundled代碼,供部署使用,名為 importBuild.js。源碼地址
  • 生成 package.json。 如果我們指定打包為es,則package.json中的type = module,否則為 commonjs。源碼地址
  • page.server的代碼轉為固定的一個導出語句,用來判斷 page.server是否有導出。源碼地址
  • 移除vite的內置鉤子 vite:ssr-require-hook(我們如果想魔改插件鉤子,可以參考這種方法)源碼地址

項目大瞭之後,打包速度慢該怎麼辦?

做活動頁,每個頁面之間是沒有關聯的,其實我希望打包是增量式的打包,但是如果公共文件改變瞭,也無法避免全量打包。所以如果能做到緩存打包文件,就可以提升打包速度。

理想美好,現實往往相反。rollup2並不支持content hash,但是好消息是rollup3支持瞭並且會在最近發佈

目前我們隻能用hack的方式去實現content hash,比如使用node的 crypto模塊來做md5hash

import { createHash } from 'crypto'
import type { PreRenderedChunk } from 'rollup'

export function getContentHash(chunk: string | Uint8Array) {
  return createHash('md5').update(chunk).digest('hex').substring(0, 6)
}

export function getHash(chunkInfo: PreRenderedChunk) {
  return getContentHash(
    Object.values(chunkInfo.modules)
      .map((m) => m.code)
      .join(),
  )
}

然後在rollup的output中設置文件的命名

rollupOptions: {
  treeshake: 'smallest',
  output: {
    format: 'es',
    assetFileNames: (assetInfo) => {
      let extType = path.extname(assetInfo.name || '').split('.')[1]
      if (/png|jpe?g|svg|gif|tiff|bmp|ico/i.test(extType!)) {
          extType = 'img'
          }
      const hash = getContentHash(assetInfo.source)
      return `assets/${extType}/[name].${hash}.[ext]`
    },
    
    chunkFileNames: (chunkInfo) => {
      const server = chunkInfo.name.endsWith('server') ? 'server-' : ''
      const name = chunkInfo.facadeModuleId?.match(/src/pages/(.*?)//)?.[1] || chunkInfo.name
      
      if (chunkInfo.isDynamicEntry || chunkInfo.name === 'vendor') {
        const hash = getHash(chunkInfo)
        return `assets/js/${name}-${server}${hash}.chunk.js`
      } else {
        return `assets/js/${name}-${server}[hash].chunk.js`
      }
    },
    entryFileNames: (chunkInfo) => {
      if (chunkInfo.name === 'pageFiles') {
        return '[name].js'
      }
      const hash = getHash(chunkInfo)
      return `assets/js/entry-${hash}.js`
    },
  },
},

做瞭content-hash後,打包速度會有非常大的提升,因為rollup其實有個cache機制,針對cache的文件不會transform,而正好transform是非常耗時的一步。

我嘗試瞭打包1000個文件,耗時40+s,在我的接受范圍內

快速創建頁面模板

活動頁面會有比較多相似的地方,所以直接根據模板來創建頁面代碼,開發效率又高一點(又可以摸魚瞭)。代碼地址

做得不好的地方

記錄兩個ssr探索過程中,我想實現,但最後沒有實現的

  • 按需打包。因為做活動頁,按理說架構應該是按需打包,做完一個頁面打包一個頁面。嘗試瞭用monorepo,這樣打包的話,那麼就要啟動多個服務來監聽。不用monorepo的話,就需要在rollup打包的過程中,設置outdir,然後打包在指定目錄中。同理,也需要啟動多個服務。要做到隻啟動一個服務,就得每次打包服務端都全量打包,客戶端按需打包,那麼服務端和客戶端之間相互引用的文件路徑就很難去控制瞭。之所以想做按需打包,其實就是擔心以後項目大瞭打包慢。如果rollup的打包性能可以跟上的話,在接受范圍內的話,其實是不需要做按需打包的
  • 按需啟動。啟動指定路由文件,而不去遍歷整個項目。這個得等vps0.4瞭

部署

部署的話,打算使用docker來做,下篇文章再講

源碼地址

react + ssr

vue3 + ssr

到此這篇關於使用vite搭建ssr活動頁架構的實現的文章就介紹到這瞭,更多相關vite搭建ssr活動頁內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: