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

渲染替換成項(xiàng)目根目錄下的 index.html
修改 `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');
?????});
?})();
復(fù)制代碼
運(yùn)行 `node server.js`后, 我們就會看到返回的是空白內(nèi)容的 `index.html` 了,但是我們需要返回的是 `vue 模板` ,那么我們只需要做個 `正則的替換`給 `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>
復(fù)制代碼
修改 `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)在假裝這是一個vue模板
';
????????//?替換?index.html?中的??標(biāo)記
????????let?html?=?template.replace('',?vueTemplate);
????????ctx.body?=?html;
????});
????app.listen(9000,?()?=>?{
????????console.log('server?is?listening?in?9000');
????});
})();
復(fù)制代碼
運(yùn)行 node server.js后,我們就會看到返回的變量 vueTemplate內(nèi)容
那么到現(xiàn)在服務(wù)已正常啟動了,但是我們試想一下,我們頁面模板使用的是 vue,并且 vue 返回的是一個 vue 實(shí)例模板,所以我就要把這個 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)換
安裝 `@vue/server-renderer`
pnpm?i?@vue/server-renderer?--save
復(fù)制代碼
新建 `~/src/entry-server.ts`
import?{?createApp?}?from?'./main';
import?{?renderToString?}?from?'@vue/server-renderer';
export?const?render?=?async?()?=>?{
??const?{?app?}?=?createApp();
?
??//?注入vue?ssr中的上下文對象
??const?renderCtx:?{modules?:?string[]}?=?{}
??let?renderedHtml?=?await?renderToString(app,?renderCtx)
??return?{?renderedHtml?};
}
復(fù)制代碼
那么如何去使用 entry-server.ts 呢,到這里就需要 vite 了
五、注入 vite
修改 `~/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,
????????},
????})
????
????//?注冊 vite 的 Connect 實(shí)例作為中間件(注意:vite.middlewares 是一個 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)換。這將會注入 Vite HMR 客戶端,
????????????template?=?await?viteServer.transformIndexHtml(ctx.path,?template)
????????????//?3.?加載服務(wù)器入口,?vite.ssrLoadModule?將自動轉(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');
????});
})()
復(fù)制代碼
運(yùn)行 node server.js就可以看到返回的 App.vue 模板中的內(nèi)容了,如下圖

并且我們 `右鍵查看顯示網(wǎng)頁源代碼`,也會看到渲染的正常 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>
復(fù)制代碼
到這里我們就已經(jīng)在 開發(fā)環(huán)境 已經(jīng)正常的渲染了,但我們想一下,在 生產(chǎn)環(huán)境 我們應(yīng)該怎么做呢,因?yàn)樵蹅儾豢赡苤苯釉?生產(chǎn)環(huán)境 運(yùn)行使用 vite 吧!
所以咱們接下來處理如何在 生產(chǎn)環(huán)境 運(yùn)行吧
六、添加開發(fā)環(huán)境
為了將 SSR 項(xiàng)目可以在生產(chǎn)環(huán)境運(yùn)行,我們需要:
正常構(gòu)建生成一個 `客戶端構(gòu)建包`;再生成一個 SSR 構(gòu)建,使其通過?`require()`?直接加載,這樣便無需再使用 Vite 的?`ssrLoadModule`;修改 `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",
??},
}
...
復(fù)制代碼
修改 server.js為server-dev.js運(yùn)行 pnpm run build構(gòu)建包新增 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)?=>?{
????
????//?請求的是靜態(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'));
})();
復(fù)制代碼
到這里,我們在 開發(fā)環(huán)境 和 生成環(huán)境 已經(jīng)都可以正常訪問了,那么是不是就萬事無憂了呢?
為了用戶的更極致的用戶體驗(yàn),那么 預(yù)加載 就必須要安排了
七、預(yù)加載
我們知道 vue 組件在 html 中渲染時都是動態(tài)去生成的對應(yīng)的 js 和 css 等;
那么我們要是在用戶獲取 服務(wù)端模板 (也就是執(zhí)行 vite build 后生成的 dist/client 目錄) 的時候,直接在 html 中把對應(yīng)的 js 和 css 文件預(yù)渲染了,這就是 靜態(tài)站點(diǎn)生成(SSG) 的形式。
閑話少說,明白道理了之后,直接開干 ~
`生成預(yù)加載指令`:在 package.json 中的 `build:client` 添加 `--ssrManifest`?標(biāo)志,運(yùn)行后生成 `ssr-manifest.json`
...
{
"scripts":?{
????...
????"build:client":?"vite?build?--ssrManifest?--outDir?dist/client",
????...
??},
}
...
復(fù)制代碼
在 `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ù)加載的對應(yīng)的地址
?*?下面的方法只針對了?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?'';
????}
}
復(fù)制代碼
給 `index.html` 添加 `` 標(biāo)記改造 `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'));
})();
復(fù)制代碼
運(yùn)行 pnpm run build && pnpm run serve就可正常顯示了
到這里基本的渲染就完成了,因?yàn)槲覀兪切枰跒g覽器上渲染的,所以 路由 vue-router 就必不可少了
八、集成 vue-router
安裝 vue-router
pnpm?i?vue-router?--save
復(fù)制代碼
新增對應(yīng)的路由頁面 `index.vue` 、 `login.vue` 、 `user.vue`新增 `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:?'首頁',
????????????????????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')
????????????}
????????]
????});
復(fù)制代碼
修改入口文件 `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);
});
復(fù)制代碼
修改入口文件 `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();
????//?路由注冊
????const?router?=?createRouter('server');
????app.use(router);
????await?router.push(ctx.path);
????await?router.isReady();
????...
};
...
復(fù)制代碼
運(yùn)行 pnpm run build && pnpm run serve就可正常顯示了
九、集成 pinia
安裝
pnpm?i?pinia?--save
復(fù)制代碼
新建 `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;
????????}
????}
});
復(fù)制代碼
新建 `src/store/index.ts`
import?{?createPinia?}?from?'pinia';
import?useUserStore?from?'./user';
export?default?()?=>?{
????const?pinia?=?createPinia();
????useUserStore(pinia);
????return?pinia;
};
復(fù)制代碼
新建 `UsePinia.vue` 使用,并且在 `pages/index.vue` 中引入
????歡迎使用vite+vue3+ts+pinia+vue-router4</h2>
????{{ userStore.name }}的年齡:?{{ userStore.age }}div
????>
????
