教你使用 koa2 + vite + ts + vue3 + pinia 構(gòu)建前端 SSR 企業(yè)級(jí)項(xiàng)目
了解 SSR
什么是 SSR
服務(wù)器端渲染(Server-Side Rendering)是指由服務(wù)端完成頁(yè)面的 HTML 結(jié)構(gòu)拼接的頁(yè)面處理技術(shù),發(fā)送到瀏覽器,然后為其綁定狀態(tài)與事件,成為完全可交互頁(yè)面的過程。
簡(jiǎn)單理解就是html是由服務(wù)端寫出,可以動(dòng)態(tài)改變頁(yè)面內(nèi)容,即所謂的動(dòng)態(tài)頁(yè)面。早年的 php[1]、asp[2] 、jsp[3] 這些 Server page 都是 SSR 的。
為什么使用 SSR
網(wǎng)頁(yè)內(nèi)容在服務(wù)器端渲染完成,一次性傳輸?shù)綖g覽器,所以 首屏加載速度非常快;有利于SEO,因?yàn)榉?wù)器返回的是一個(gè)完整的 html,在瀏覽器可以看到完整的 dom,對(duì)于爬蟲、百度搜索等引擎就比較友好;
快速查看
github 倉(cāng)庫(kù)地址[4]
長(zhǎng)話短說(shuō),直接開干 ~
建議包管理器使用優(yōu)先級(jí):pnpm > yarn > npm > cnpm
一、初始化項(xiàng)目
pnpm?create?vite?koa2-ssr-vue3-ts-pinia?--?--template?vue-ts
集成基本配置
由于本文的重點(diǎn)在于 SSR 配置,為了優(yōu)化讀者的觀感體驗(yàn),所以項(xiàng)目的基本配置就不做詳細(xì)介紹,在我上一篇文章《手把手教你用 vite+vue3+ts+pinia+vueuse 打造企業(yè)級(jí)前端項(xiàng)目》[5]中已詳細(xì)介紹,大家可以自行查閱
修改 tsconfig.json:查看代碼[6]修改 vite.config.ts:查看代碼[7]集成 eslint和prettier統(tǒng)一代碼質(zhì)量風(fēng)格的:查看教程[8]集成 commitizen和husky規(guī)范 git 提交:查看教程[9]
到這里我們項(xiàng)目的基本框架都搭建完成啦~
二、修改客戶端入口
修改 ~/src/main.ts
import?{?createSSRApp?}?from?"vue";
import?App?from?"./App.vue";
//?為了保證數(shù)據(jù)的互不干擾,每次請(qǐng)求需要導(dǎo)出一個(gè)新的實(shí)例
export?const?createApp?=?()?=>?{
????const?app?=?createSSRApp(App);
????return?{?app?};
}
2.新建 ~/src/entry-client.ts
import?{?createApp?}?from?"./main"
const?{?app?}?=?createApp();
app.mount("#app");
3.修改 ~/index.html 的入口
html>
<html?lang="en">
????...
????<script?type="module"?src="/src/entry-client.ts">script>
????...
html>
到這里你運(yùn)行 pnpm run dev ,發(fā)現(xiàn)頁(yè)面中還是可以正常顯示,因?yàn)榈侥壳爸皇亲隽艘粋€(gè)文件的拆分,以及更換了 createSSRApp 方法;
三、創(chuàng)建開發(fā)服務(wù)器
使用 Koa2
1.安裝 koa2
pnpm?i?koa?--save?&&?pnpm?i?@types/koa?--save-dev
2.安裝中間件 koa-connect
pnpm?i?koa-connect?--save
3.使用:新建 ~/server.js
備注:因?yàn)樵撐募?node 運(yùn)行入口,所以用 js 即可,如果用 ts 文件,需單獨(dú)使用 ts-node 等去運(yùn)行,導(dǎo)致程序變復(fù)雜
const?Koa?=?require('koa');
(async?()?=>?{
????const?app?=?new?Koa();
????app.use(async?(ctx)?=>?{
????????ctx.body?=?`
??????
????????koa2?+?vite?+?ts?+?vue3?+?vue-router
????????
??????????使用?koa2?+?vite?+?ts?+?vue3?+?vue-router?集成前端?SSR?企業(yè)級(jí)項(xiàng)目
????????
??????`;
????});
????app.listen(9000,?()?=>?{
????????console.log('server?is?listening?in?9000');
????});
})();
4.運(yùn)行 node server.js5.結(jié)果:

渲染替換成項(xiàng)目根目錄下的 index.html
1.修改 server.js 中的 ctx.body 返回的是 index.html
?const?fs?=?require('fs');
?const?path?=?require('path');
?
?const?Koa?=?require('koa');
?
?(async?()?=>?{
?????const?app?=?new?Koa();
?
?????//?獲取?index.html
?????const?template?=?fs.readFileSync(path.resolve(__dirname,?'index.html'),?'utf-8');
?
?????app.use(async?(ctx)?=>?{
?????????ctx.body?=?template;
?????});
?
?????app.listen(9000,?()?=>?{
?????????console.log('server?is?listening?in?9000');
?????});
?})();
2.運(yùn)行 node server.js后, 我們就會(huì)看到返回的是空白內(nèi)容的 index.html 了,但是我們需要返回的是 vue 模板 ,那么我們只需要做個(gè) 正則的替換
3.給 index.html 添加 標(biāo)記
?html>
?<html?lang="en">
???<head>
?????<meta?charset="UTF-8"?/>
?????<link?rel="icon"?href="/favicon.ico"?/>
?????<meta?name="viewport"?content="width=device-width,?initial-scale=1.0"?/>
?????<title>koa2?+?vite?+?ts?+?vue3title>
???head>
???<body>
?????<div?id="app">div>
?????<script?type="module"?src="/src/entry-client.ts">script>
???body>
?html>
4.修改 server.js 中的 ctx.body
//?other?code?...
(async?()?=>?{
????const?app?=?new?Koa();
????//?獲取index.html
????const?template?=?fs.readFileSync(path.resolve(__dirname,?'index.html'),?'utf-8');
????app.use(async?(ctx)?=>?{
????????let?vueTemplate?=?'現(xiàn)在假裝這是一個(gè)vue模板
';
????????//?替換?index.html?中的??標(biāo)記
????????let?html?=?template.replace('',?vueTemplate);
????????ctx.body?=?html;
????});
????app.listen(9000,?()?=>?{
????????console.log('server?is?listening?in?9000');
????});
})();
5.運(yùn)行 node server.js后,我們就會(huì)看到返回的 變量 vueTemplate 內(nèi)容
那么到現(xiàn)在服務(wù)已正常啟動(dòng)了,但是我們?cè)囅胍幌拢覀冺?yè)面模板使用的是 vue,并且 vue 返回的是一個(gè) vue 實(shí)例模板,所以我就要把這個(gè) vue 實(shí)例模板 轉(zhuǎn)換成 可渲染的 html,那么 @vue/server-renderer 就應(yīng)運(yùn)而生了
四、新增服務(wù)端入口
因?yàn)?vue 返回的是 vue 實(shí)例模板 而不是 可渲染的 html ,所以我們需要使用 @vue/server-renderer 進(jìn)行轉(zhuǎn)換
1.安裝 @vue/server-renderer
pnpm?i?@vue/server-renderer?--save
2.新建 ~/src/entry-server.ts
import?{?createApp?}?from?'./main';
import?{?renderToString?}?from?'@vue/server-renderer';
export?const?render?=?async?()?=>?{
??const?{?app?}?=?createApp();
?
??//?注入vue?ssr中的上下文對(duì)象
??const?renderCtx:?{modules?:?string[]}?=?{}
??let?renderedHtml?=?await?renderToString(app,?renderCtx)
??return?{?renderedHtml?};
}
那么如何去使用 entry-server.ts 呢,到這里就需要 vite 了
五、注入 vite
1.修改 ~/server.js
const?fs?=?require('fs')
const?path?=?require('path')
const?Koa?=?require('koa')
const?koaConnect?=?require('koa-connect')
const?vite?=?require('vite')
;(async?()?=>?{
????const?app?=?new?Koa();
????//?創(chuàng)建?vite?服務(wù)
????const?viteServer?=?await?vite.createServer({
????????root:?process.cwd(),
????????logLevel:?'error',
????????server:?{
????????middlewareMode:?true,
????????},
????})
????
????//?注冊(cè)?vite?的?Connect?實(shí)例作為中間件(注意:vite.middlewares?是一個(gè)?Connect?實(shí)例)
????app.use(koaConnect(viteServer.middlewares))
????app.use(async?ctx?=>?{
????????try?{
????????????//?1.?獲取index.html
????????????let?template?=?fs.readFileSync(path.resolve(__dirname,?'index.html'),?'utf-8');
????????????//?2.?應(yīng)用?Vite?HTML?轉(zhuǎn)換。這將會(huì)注入?Vite?HMR?客戶端,
????????????template?=?await?viteServer.transformIndexHtml(ctx.path,?template)
????????????//?3.?加載服務(wù)器入口,?vite.ssrLoadModule?將自動(dòng)轉(zhuǎn)換
????????????const?{?render?}?=?await?viteServer.ssrLoadModule('/src/entry-server.ts')
????????????//??4.?渲染應(yīng)用的?HTML
????????????const?{?renderedHtml?}?=?await?render(ctx,?{})
????????????const?html?=?template.replace('',?renderedHtml)
????????????ctx.type?=?'text/html'
????????????ctx.body?=?html
????????}?catch?(e)?{
????????????viteServer?&&?viteServer.ssrFixStacktrace(e)
????????????console.log(e.stack)
????????????ctx.throw(500,?e.stack)
????????}
????})
????app.listen(9000,?()?=>?{
????????console.log('server?is?listening?in?9000');
????});
})()
運(yùn)行 node server.js就可以看到返回的 App.vue 模板中的內(nèi)容了,如下圖

3.并且我們 右鍵查看顯示網(wǎng)頁(yè)源代碼,也會(huì)看到渲染的正常 html
html>
<html?lang="en">
??<head>
????<script?type="module"?src="/@vite/client">script>
????<meta?charset="UTF-8"?/>
????<link?rel="icon"?href="/favicon.ico"?/>
????<meta?name="viewport"?content="width=device-width,?initial-scale=1.0"?/>
????<title>koa2?+?vite?+?ts?+?vue3title>
??head>
??<body>
????<div?id="app"><img?alt="Vue?logo"?src="/src/assets/logo.png"><h1?data-v-469af010>Hello?Vue?3?+?TypeScript?+?Viteh1><p?data-v-469af010>?Recommended?IDE?setup:?<a?href=" "?target="_blank"?data-v-469af010>VSCodea>?+?<a?href="" ?target="_blank"?data-v-469af010>Volara>p><p?data-v-469af010>See?<code?data-v-469af010>README.mdcode>?for?more?information.p><p?data-v-469af010><a?href="" ?target="_blank"?data-v-469af010>?Vite?Docs?a>?|?<a?href=" "?target="_blank"?data-v-469af010>Vue?3?Docsa>p><button?type="button"?data-v-469af010>count?is:?0button><p?data-v-469af010>?Edit?<code?data-v-469af010>components/HelloWorld.vuecode>?to?test?hot?module?replacement.?p>div>
????<script?type="module"?src="/src/entry-client.ts">script>
??body>
html>
到這里我們就已經(jīng)在 開發(fā)環(huán)境 已經(jīng)正常的渲染了,但我們想一下,在 生產(chǎn)環(huán)境 我們應(yīng)該怎么做呢,因?yàn)樵蹅儾豢赡苤苯釉?生產(chǎn)環(huán)境 運(yùn)行使用 vite 吧!
所以咱們接下來(lái)處理如何在 生產(chǎn)環(huán)境 運(yùn)行吧
六、添加開發(fā)環(huán)境
為了將 SSR 項(xiàng)目可以在生產(chǎn)環(huán)境運(yùn)行,我們需要:
1.正常構(gòu)建生成一個(gè) 客戶端構(gòu)建包;
2.再生成一個(gè) SSR 構(gòu)建,使其通過?require()?直接加載,這樣便無(wú)需再使用 Vite 的?ssrLoadModule;
3.修改 package.json
...
{
"scripts":?{
????//?開發(fā)環(huán)境
????"dev":?"node?server-dev.js",
????//?生產(chǎn)環(huán)境
????"server":?"node?server-prod.js",
????//?構(gòu)建
????"build":?"pnpm?build:client?&&?pnpm?build:server",
????"build:client":?"vite?build?--outDir?dist/client",
????"build:server":?"vite?build?--ssr?src/entry-server.js?--outDir?dist/server",
??},
}
...
4.修改 server.js 為 server-dev.js5.運(yùn)行 pnpm run build 構(gòu)建包
6.新增 server-prod.js
注意:為了處理靜態(tài)資源,需要在此新增
koa-send中間件:pnpm i koa-send \--save
const?Koa?=?require('koa');
const?sendFile?=?require('koa-send');
const?path?=?require('path');
const?fs?=?require('fs');
const?resolve?=?(p)?=>?path.resolve(__dirname,?p);
const?clientRoot?=?resolve('dist/client');
const?template?=?fs.readFileSync(resolve('dist/client/index.html'),?'utf-8');
const?render?=?require('./dist/server/entry-server.js').render;
const?manifest?=?require('./dist/client/ssr-manifest.json');
(async?()?=>?{
????const?app?=?new?Koa();
????app.use(async?(ctx)?=>?{
????
????//?請(qǐng)求的是靜態(tài)資源
????????if?(ctx.path.startsWith('/assets'))?{
????????????await?sendFile(ctx,?ctx.path,?{?root:?clientRoot?});
????????????return;
????????}
????????const?[?appHtml?]?=?await?render(ctx,?manifest);
????????const?html?=?template
????????????.replace('',?appHtml);
????????ctx.type?=?'text/html';
????????ctx.body?=?html;
????});
????app.listen(8080,?()?=>?console.log('started?server?on?http://localhost:8080'));
})();
到這里,我們?cè)?開發(fā)環(huán)境 和 生成環(huán)境 已經(jīng)都可以正常訪問了,那么是不是就萬(wàn)事無(wú)憂了呢?
為了用戶的更極致的用戶體驗(yàn),那么 預(yù)加載 就必須要安排了
七、預(yù)加載
我們知道 vue 組件在 html 中渲染時(shí)都是動(dòng)態(tài)去生成的對(duì)應(yīng)的 js 和 css 等;
那么我們要是在用戶獲取 服務(wù)端模板 (也就是執(zhí)行 vite build 后生成的 dist/client 目錄) 的時(shí)候,直接在 html 中把對(duì)應(yīng)的 js 和 css 文件預(yù)渲染了,這就是 靜態(tài)站點(diǎn)生成(SSG) 的形式。
閑話少說(shuō),明白道理了之后,直接開干 ~
1.生成預(yù)加載指令:在 package.json 中的 build:client 添加 --ssrManifest?標(biāo)志,運(yùn)行后生成 ssr-manifest.json
...
{
"scripts":?{
????...
????"build:client":?"vite?build?--ssrManifest?--outDir?dist/client",
????...
??},
}
...
2.在 entry-sercer.ts 中添加解析生成的 ssr-manifest.json 方法
export?const?render?=?async?(
????ctx:?ParameterizedContext,
????manifest:?Record<string,?string[]>
):?Promise<[string,?string]>?=>?{
????const?{?app?}?=?createApp();
????console.log(ctx,?manifest,?'');
????const?renderCtx:?{?modules?:?string[]?}?=?{};
????const?renderedHtml?=?await?renderToString(app,?renderCtx);
????const?preloadLinks?=?renderPreloadLinks(renderCtx.modules,?manifest);
????return?[renderedHtml,?preloadLinks];
};
/**
?*?解析需要預(yù)加載的鏈接
?*?@param?modules
?*?@param?manifest
?*?@returns?string
?*/
function?renderPreloadLinks(
????modules:?undefined?|?string[],
????manifest:?Record<string,?string[]>
):?string?{
????let?links?=?'';
????const?seen?=?new?Set();
????if?(modules?===?undefined)?throw?new?Error();
????modules.forEach((id)?=>?{
????????const?files?=?manifest[id];
????????if?(files)?{
????????????files.forEach((file)?=>?{
????????????????if?(!seen.has(file))?{
????????????????????seen.add(file);
????????????????????links?+=?renderPreloadLink(file);
????????????????}
????????????});
????????}
????});
????return?links;
}
/**
?*?預(yù)加載的對(duì)應(yīng)的地址
?*?下面的方法只針對(duì)了?js?和?css,如果需要處理其它文件,自行添加即可
?*?@param?file
?*?@returns?string
?*/
function?renderPreloadLink(file:?string):?string?{
????if?(file.endsWith('.js'))?{
????????return?`${file}">`;
????}?else?if?(file.endsWith('.css'))?{
????????return?`${file}">`;
????}?else?{
????????return?'';
????}
}
3.給 index.html 添加 標(biāo)記
4.改造 server-prod.js
...
(async?()?=>?{
????const?app?=?new?Koa();
????app.use(async?(ctx)?=>?{
????
?...
????????const?[appHtml,?preloadLinks]?=?await?render(ctx,?manifest);
????????const?html?=?template
????????????.replace('',?preloadLinks)
????????????.replace('',?appHtml);
????????//?do?something
????});
????app.listen(8080,?()?=>?console.log('started?server?on?http://localhost:8080'));
})();
5.運(yùn)行 pnpm run build && pnpm run serve 就可正常顯示了
到這里基本的渲染就完成了,因?yàn)槲覀兪切枰跒g覽器上渲染的,所以 路由 vue-router 就必不可少了
八、集成 vue-router
1.安裝 vue-router
pnpm?i?vue-router?--save
2.新增對(duì)應(yīng)的路由頁(yè)面 index.vue 、 login.vue 、 user.vue3.新增 src/router/index.ts
import?{
????createRouter?as?createVueRouter,
????createMemoryHistory,
????createWebHistory,
????Router
}?from?'vue-router';
export?const?createRouter?=?(type:?'client'?|?'server'):?Router?=>
????createVueRouter({
????????history:?type?===?'client'???createWebHistory()?:?createMemoryHistory(),
????????routes:?[
????????????{
????????????????path:?'/',
????????????????name:?'index',
????????????????meta:?{
????????????????????title:?'首頁(yè)',
????????????????????keepAlive:?true,
????????????????????requireAuth:?true
????????????????},
????????????????component:?()?=>?import('@/pages/index.vue')
????????????},
????????????{
????????????????path:?'/login',
????????????????name:?'login',
????????????????meta:?{
????????????????????title:?'登錄',
????????????????????keepAlive:?true,
????????????????????requireAuth:?false
????????????????},
????????????????component:?()?=>?import('@/pages/login.vue')
????????????},
????????????{
????????????????path:?'/user',
????????????????name:?'user',
????????????????meta:?{
????????????????????title:?'用戶中心',
????????????????????keepAlive:?true,
????????????????????requireAuth:?true
????????????????},
????????????????component:?()?=>?import('@/pages/user.vue')
????????????}
????????]
????});
4.修改入口文件 src/enter-client.ts
import?{?createApp?}?from?'./main';
import?{?createRouter?}?from?'./router';
const?router?=?createRouter('client');
const?{?app?}?=?createApp();
app.use(router);
router.isReady().then(()?=>?{
????app.mount('#app',?true);
});
5.修改入口文件 src/enter-server.ts
...
import?{?createRouter?}?from?'./router'
const?router?=?createRouter('client');
export?const?render?=?async?(
????ctx:?ParameterizedContext,
????manifest:?Record<string,?string[]>
):?Promise<[string,?string]>?=>?{
????const?{?app?}?=?createApp();
????//?路由注冊(cè)
????const?router?=?createRouter('server');
????app.use(router);
????await?router.push(ctx.path);
????await?router.isReady();
????...
};
...
6.運(yùn)行 pnpm run build && pnpm run serve 就可正常顯示了
九、集成 pinia
1.安裝
pnpm?i?pinia?--save
2.新建 src/store/user.ts
import?{?defineStore?}?from?'pinia';
export?default?defineStore('user',?{
????state:?()?=>?{
????????return?{
????????????name:?'張三',
????????????age:?20
????????};
????},
????actions:?{
????????updateName(name:?string)?{
????????????this.name?=?name;
????????},
????????updateAge(age:?number)?{
????????????this.age?=?age;
????????}
????}
});
3.新建 src/store/index.ts
import?{?createPinia?}?from?'pinia';
import?useUserStore?from?'./user';
export?default?()?=>?{
????const?pinia?=?createPinia();
????useUserStore(pinia);
????return?pinia;
};
4.新建 UsePinia.vue 使用,并且在 pages/index.vue 中引入
????歡迎使用vite+vue3+ts+pinia+vue-router4</h2>
????{{?userStore.name?}}的年齡:?{{?userStore.age?}}div
????>
????
