使用vite搭建ssr活動(dòng)頁架構(gòu)的實(shí)現(xiàn)
前言
最近接了個(gè)需求,重構(gòu)公司的活動(dòng)頁項(xiàng)目。要實(shí)現(xiàn):
- SEO良好
- MPA
- 啟動(dòng)速度快,構(gòu)建速度快
- 前端工程化
- 瀏覽器兼容至少IE11
基于這些需求,我選擇了 vite + react + vite-plugin-ssr
文章前面是ssr入門,老手請(qǐng)隨意跳過,看最后即可
入門SSR
什么是SSR
術(shù)語
- ssr,全名
server side render
,服務(wù)端渲染 - csr,全名
client side render
,客戶端渲染 - spa,全名
single page application
,單頁面應(yīng)用 - mpa,全名
multi page application
,多頁面應(yīng)用
ssr的歷史
我的學(xué)習(xí)習(xí)慣是,不論學(xué)什么,先去了解它的歷史背景。存在即合理,了解到為什么產(chǎn)生一個(gè)技術(shù),能讓我更容易去理解這門技術(shù)
最初的網(wǎng)頁渲染,前端三劍客:html + css + js,放在服務(wù)器上,靜態(tài)部署就可以供用戶訪問了。
后來隨著網(wǎng)頁復(fù)雜度上升,出現(xiàn)了jsp/ejs等等一系列模板語法,在服務(wù)端獲取到數(shù)據(jù)后,把數(shù)據(jù)渲染到模板中,最后生成html返回給客戶端,這是最原始的ssr。
隨著前端框架的誕生(ng/react/vue),越來越多同學(xué)開始使用框架開發(fā)web,這些前端框架的出現(xiàn)使得前后端開發(fā)解耦(csr的情況下),前端同學(xué)可以更充分的利用前端工程化等等新技術(shù)來健壯前端項(xiàng)目。而這種完全解耦的方式也帶來了一些問題,比如非常不友好的SEO
csr的缺點(diǎn)
讓我們打開一個(gè)SPA網(wǎng)頁(使用腳手架默認(rèn)方式搭建),右鍵查看網(wǎng)頁源代碼
第一個(gè)問題:SEO極度不友好。 網(wǎng)頁里面根本沒有內(nèi)容。爬蟲最喜歡這種網(wǎng)頁了,看一眼就走。
SPA的工作方式就是使用js
來動(dòng)態(tài)渲染html,壓力全部給到了客戶端(瀏覽器)這邊,正是因?yàn)檫@個(gè),第二個(gè)問題也出現(xiàn)了:首屏的加載速度較慢
為什么ssr的需求再次出現(xiàn)
為了更好的SEO,為了更快的加載速度(服務(wù)端生成了首頁靜態(tài)頁面,客戶端可以直接展示,隨后再用JS動(dòng)態(tài)渲染)
前端開發(fā)使用react/vue,可以熟練開發(fā)網(wǎng)頁。而cra/vue-cli腳手架創(chuàng)建出來的模板默認(rèn)是SPA。
那么應(yīng)該如何實(shí)現(xiàn) “既要,還要”呢(前端框架/seo我全都要)
如何實(shí)現(xiàn)基礎(chǔ)ssr
基于上面的問題,我們希望實(shí)現(xiàn):
- 查看網(wǎng)頁源代碼時(shí),展示網(wǎng)頁的內(nèi)容
既然需要服務(wù)端渲染,服務(wù)端用來執(zhí)行vue/react這種js框架,那第一反應(yīng)就是用nodejs來做服務(wù)端渲染,因?yàn)閚odejs天然執(zhí)行js代碼
客戶端的話,用vue來做(react也行,只不過最近在熟悉vue3),vue3的話,體積比react更小,toC網(wǎng)站更好一些。react18針對(duì)ssr出了新api,開發(fā)者可以使用 React.lazy
和 suspense
實(shí)現(xiàn)懶加載,也提供了很好的用戶體驗(yàn):https://github.com/reactwg/react-18/discussions/37
下面是基礎(chǔ)的ssr例子
以下例子 請(qǐng)注意:客戶端使用的是esm規(guī)范,服務(wù)端使用的是cjs
如果希望統(tǒng)一使用esm,可以使用 tsx 執(zhí)行node腳本 或修改package.json => type: "module"
創(chuàng)建服務(wù)端
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'); })
啟動(dòng)服務(wù)后,打開瀏覽器 http:localhost:4000,即可看到內(nèi)容
渲染vue
服務(wù)端有了,但是是返回的string,我們想用vue來開發(fā),嘗試返回一個(gè)vue組件
vue3提供了服務(wù)端渲染組件的方法,在 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') })
此時(shí)打開頁面,可以看到button了,但是此時(shí)頁面是靜態(tài)的,因?yàn)檫@個(gè)頁面在服務(wù)端已經(jīng)渲染好了,但在客戶端沒有注入vue
右鍵查看網(wǎng)頁源代碼,可以看到button元素
客戶端渲染
我們希望button的交互可以動(dòng)起來,此時(shí)需要客戶端來做渲染了
const { createSSRApp } = require('vue') const vue = createSSRApp({ data: () => ({ count: 1 }), template: `<button @click="count++">{{ count }}</button>`, }) vue.mount('#app')
這段代碼是否很眼熟,其實(shí)基本上跟服務(wù)端渲染返回的內(nèi)容是一樣的。所以ssr的本質(zhì)是服務(wù)端渲染靜態(tài)html+客戶端渲染js
此外,為了在瀏覽器中加載客戶端文件,我們還需要:
- 在
server.js
中添加server.use(express.static('.'))
來托管客戶端文件。這里要注意js執(zhí)行順序 - 將
<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') })
此時(shí)打開本地地址,可以看到點(diǎn)擊button數(shù)字變化了
以上是最簡單的ssr,在vue官網(wǎng)上可以找到這個(gè)例子。
我們甚至沒有去考慮前端的路由,狀態(tài)管理 等等。一個(gè)完整的ssr還需要一系列構(gòu)建。
網(wǎng)頁路由
ssr的網(wǎng)頁路由有兩種方式
- 服務(wù)端路由
- 客戶端路由
服務(wù)端路由
服務(wù)端路由,就是利用 web框架的路由能力,匹配到某個(gè)路由時(shí),返回對(duì)應(yīng)的html代碼,并且加載相應(yīng)的客戶端代碼,比如:
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>`) })
服務(wù)端路由跳轉(zhuǎn)直接使用 a標(biāo)簽
即可
客戶端路由
客戶端路由的話,就要用到前端框架對(duì)應(yīng)的路由庫,vue-router / react-router 等
可以參照官方例子做
比較兩種方式
服務(wù)端路由適合做頁面零碎的項(xiàng)目,如活動(dòng)頁,每次跳轉(zhuǎn)路由會(huì)刷新整個(gè)頁面
客戶端路由適合做頁面之間交互強(qiáng)的項(xiàng)目,如產(chǎn)品頁,跳轉(zhuǎn)路由不會(huì)刷新頁面
使用vite做ssr
vue官方推薦了幾個(gè)做ssr的例子,包括 Nuxt
/ Quasar
這種重框架,也有 vite的輕框架。為了細(xì)粒度把控項(xiàng)目,我使用了 vite
+ vite-plugin-ssr
的方案來做
Like Next.js / Nuxt but as do-one-thing-do-it-well Vite plugin.
類似 Next/Nuxt 但是只做一件事并把它做好 的vite插件
這個(gè)插件的文檔寫得非常詳細(xì),而且github上有許多例子。
插件的具體功能我不贅述,各位可看官方文檔,我在這里講一下這個(gè)插件(v0.3x)的約定式路由的工作原理。以下 vite-plugin-ssr 簡稱為 vps
vps的約定式路由
vps推薦使用文件夾名稱作為路由,這種方式也是最方便的?;顒?dòng)頁不存在頁面之間的交互,所以我選擇的默認(rèn)方式。
vps規(guī)定了一系列文件命名,作為開發(fā)/構(gòu)建遍歷的條件。以下4種命名會(huì)被vps收集,每種文件有其獨(dú)特的作用。我們不要隨意以 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啟動(dòng)服務(wù)端server,調(diào)用 vps 的
createPageRenderer
,返回了renderPage
方法,我們調(diào)用renderPage
即可獲取到服務(wù)端渲染后的內(nèi)容。源碼地址 - vps在vite的dev階段,設(shè)置了
optimizeDeps
做依賴預(yù)構(gòu)建的優(yōu)化。(咱們也可以參考這塊源碼對(duì)vite項(xiàng)目進(jìn)行一些優(yōu)化)。 源碼地址
build階段
- 針對(duì) client / server 分別打包。如果使用約定式路由,會(huì)根據(jù)上文講到的遍歷條件,遍歷所有文件后,把所有的
.page
文件設(shè)置為 input 的每一項(xiàng)(MPA)。源碼地址 - 生成vps的manifest文件,其命名為
vite-plugin-ssr.json
,里面會(huì)存放一些vps的基本信息。源碼地址 - 生成單個(gè)的server bundled代碼,供部署使用,名為
importBuild.js
。源碼地址 - 生成 package.json。 如果我們指定打包為es,則package.json中的type = module,否則為 commonjs。源碼地址
- 把
page.server
的代碼轉(zhuǎn)為固定的一個(gè)導(dǎo)出語句,用來判斷page.server
是否有導(dǎo)出。源碼地址 - 移除vite的內(nèi)置鉤子
vite:ssr-require-hook
(我們?nèi)绻肽Ц牟寮^子,可以參考這種方法)源碼地址
項(xiàng)目大了之后,打包速度慢該怎么辦?
做活動(dòng)頁,每個(gè)頁面之間是沒有關(guān)聯(lián)的,其實(shí)我希望打包是增量式的打包,但是如果公共文件改變了,也無法避免全量打包。所以如果能做到緩存打包文件,就可以提升打包速度。
理想美好,現(xiàn)實(shí)往往相反。rollup2并不支持content hash,但是好消息是rollup3支持了并且會(huì)在最近發(fā)布
目前我們只能用hack的方式去實(shí)現(xiàn)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中設(shè)置文件的命名
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后,打包速度會(huì)有非常大的提升,因?yàn)閞ollup其實(shí)有個(gè)cache機(jī)制,針對(duì)cache的文件不會(huì)transform,而正好transform是非常耗時(shí)的一步。
我嘗試了打包1000個(gè)文件,耗時(shí)40+s,在我的接受范圍內(nèi)
快速創(chuàng)建頁面模板
活動(dòng)頁面會(huì)有比較多相似的地方,所以直接根據(jù)模板來創(chuàng)建頁面代碼,開發(fā)效率又高一點(diǎn)(又可以摸魚了)。代碼地址
做得不好的地方
記錄兩個(gè)ssr探索過程中,我想實(shí)現(xiàn),但最后沒有實(shí)現(xiàn)的
- 按需打包。因?yàn)樽龌顒?dòng)頁,按理說架構(gòu)應(yīng)該是按需打包,做完一個(gè)頁面打包一個(gè)頁面。嘗試了用monorepo,這樣打包的話,那么就要啟動(dòng)多個(gè)服務(wù)來監(jiān)聽。不用monorepo的話,就需要在rollup打包的過程中,設(shè)置outdir,然后打包在指定目錄中。同理,也需要啟動(dòng)多個(gè)服務(wù)。要做到只啟動(dòng)一個(gè)服務(wù),就得每次打包服務(wù)端都全量打包,客戶端按需打包,那么服務(wù)端和客戶端之間相互引用的文件路徑就很難去控制了。之所以想做按需打包,其實(shí)就是擔(dān)心以后項(xiàng)目大了打包慢。如果rollup的打包性能可以跟上的話,在接受范圍內(nèi)的話,其實(shí)是不需要做按需打包的
- 按需啟動(dòng)。啟動(dòng)指定路由文件,而不去遍歷整個(gè)項(xiàng)目。這個(gè)得等vps0.4了
部署
部署的話,打算使用docker來做,下篇文章再講
源碼地址
到此這篇關(guān)于使用vite搭建ssr活動(dòng)頁架構(gòu)的實(shí)現(xiàn)的文章就介紹到這了,更多相關(guān)vite搭建ssr活動(dòng)頁內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Vue3 directive自定義指令內(nèi)部實(shí)現(xiàn)示例
這篇文章主要為大家介紹了Vue3 directive自定義指令內(nèi)部實(shí)現(xiàn)示例,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-12-12在vue項(xiàng)目創(chuàng)建的后初始化首次使用stylus安裝方法分享
下面小編就為大家分享一篇在vue項(xiàng)目創(chuàng)建的后初始化首次使用stylus安裝方法,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2018-01-01使用watch監(jiān)聽路由變化和watch監(jiān)聽對(duì)象的實(shí)例
下面小編就為大家分享一篇使用watch監(jiān)聽路由變化和watch監(jiān)聽對(duì)象的實(shí)例,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2018-02-02vue3中的reactive函數(shù)聲明數(shù)組方式
這篇文章主要介紹了vue3中的reactive函數(shù)聲明數(shù)組方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-05-05Ant?Design?of?Vue?select框獲取key和name的問題
這篇文章主要介紹了Ant?Design?of?Vue?select框獲取key和name的問題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-06-06一篇文章告訴你Vue3指令是如何實(shí)現(xiàn)的
在計(jì)算機(jī)技術(shù)中,指令是由指令集架構(gòu)定義的單個(gè)的CPU操作,在更廣泛的意義上,“指令”可以是任何可執(zhí)行程序的元素的表述,例如字節(jié)碼,下面這篇文章主要給大家介紹了關(guān)于Vue3指令是如何實(shí)現(xiàn)的相關(guān)資料,需要的朋友可以參考下2022-01-01