使用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.lazy
和 suspense
實現懶加載,也提供瞭很好的用戶體驗: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!
推薦閱讀:
- Ajax 的初步實現(使用vscode+node.js+express框架)
- Nodejs如何解決跨域(CORS)
- nodeJS express路由學習req.body與req.query方法實例詳解
- AJAX請求以及解決跨域問題詳解
- vite2.0 踩坑實錄