教你使用 koa2 + vite + ts + vue3 + pinia 構建前端 SSR 企業級專案

語言: CN / TW / HK

回覆 交流 ,加入前端程式設計面試演算法每日一題群

面試官也在看的前端面試資料

前言

大家好,我是 [1] 今天給大家帶來爆肝許久的 如何使用vite 打造前端 SSR 企業級專案 ,希望大家能喜歡!

如果大家對 Vite 感興趣可以去看看專欄: 《Vite 從入門到精通》 [3]

瞭解 SSR

什麼是 SSR

伺服器端渲染 (Server-Side Rendering)是指由服務端完成頁面的 HTML 結構拼接的頁面處理技術,傳送到瀏覽器,然後為其繫結狀態與事件,成為完全可互動頁面的過程。

簡單理解就是html是由服務端寫出,可以動態改變頁面內容,即所謂的動態頁面。早年的 php [4]asp [5]jsp [6] 這些 Server page 都是 SSR 的。

為什麼使用 SSR

  • 網頁內容在伺服器端渲染完成,一次性傳輸到瀏覽器,所以 首屏載入速度非常快
  • 有利於SEO ,因為伺服器返回的是一個完整的 html,在瀏覽器可以看到完整的 dom,對於爬蟲、百度搜索等引擎就比較友好;

快速檢視

github 倉庫地址 [7]

長話短說,直接開幹 ~

建議包管理器使用優先順序:pnpm > yarn > npm > cnpm

一、初始化專案

pnpm create vite koa2-ssr-vue3-ts-pinia -- --template vue-ts
複製程式碼

整合基本配置

由於本文的重點在於 SSR 配置 ,為了優化讀者的觀感體驗,所以專案的 基本配置 就不做詳細介紹,在我上一篇文章 《手把手教你用 vite+vue3+ts+pinia+vueuse 打造企業級前端專案》 [8] 中已詳細介紹,大家可以自行查閱

  1. 修改 tsconfig.json檢視程式碼 [9]
  2. 修改 vite.config.ts檢視程式碼 [10]
  3. 整合 eslintprettier 統一程式碼質量風格的: 檢視教程 [11]
  4. 整合 commitizenhusky 規範 git 提交: 檢視教程 [12]

到這裡我們專案的基本框架都搭建完成啦~

二、修改客戶端入口

  1. 修改 `~/src/main.ts`
    
import { createSSRApp } from "vue";
import App from "./App.vue";

// 為了保證資料的互不干擾,每次請求需要匯出一個新的例項
export const createApp = () => {
    const app = createSSRApp(App);
    return { app };
}
複製程式碼
  1. 新建 `~/src/entry-client.ts`
    
import { createApp } from "./main"

const { app } = createApp();

app.mount("#app");
複製程式碼
  1. 修改 `~/index.html` 的入口
    
<!DOCTYPE html>
<html lang="en">

    ...

    <script type="module" src="/src/entry-client.ts"></script>

    ...

</html>
複製程式碼

到這裡你執行 pnpm run dev ,發現頁面中還是可以正常顯示,因為到目前只是做了一個檔案的拆分,以及更換了 createSSRApp 方法;

三、建立開發伺服器

使用 Koa2

  1. 安裝 `koa2`
    
pnpm i koa --save && pnpm i @types/koa --save-dev
複製程式碼
  1. 安裝中介軟體 `koa-connect`
    
pnpm i koa-connect --save
複製程式碼
  1. 使用:新建 ~/server.js

備註:因為該檔案為 node 執行入口,所以用 js 即可,如果用 ts 檔案,需單獨使用 ts-node 等去執行,導致程式變複雜

const Koa = require('koa');

(async () => {
    const app = new Koa();

    app.use(async (ctx) => {
        ctx.body = `<!DOCTYPE html>
      <html lang="en">
        <head><title>koa2 + vite + ts + vue3 + vue-router</title></head>
        <body>
          <h1 style="text-align: center;">使用 koa2 + vite + ts + vue3 + vue-router 整合前端 SSR 企業級專案</h1>
        </body>
      </html>`;
    });

    app.listen(9000, () => {
        console.log('server is listening in 9000');
    });
})();
複製程式碼
  1. 執行 node server.js
  2. 結果:

Untitled.png

渲染替換成專案根目錄下的 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');
     });
 })();
複製程式碼
  1. 執行 `node server.js`後, 我們就會看到返回的是空白內容的 `index.html` 了,但是我們需要返回的是 `vue 模板` ,那麼我們只需要做個 `正則的替換`
    
  2. 給 `index.html` 新增 `<!--app-html-->` 標記
    
 <!DOCTYPE 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 + vue3</title>
   </head>
   <body>
     <div id="app"><!--app-html--></div>
     <script type="module" src="/src/entry-client.ts"></script>
   </body>
 </html>
複製程式碼
  1. 修改 `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 = '<h1 style="text-align:center;">現在假裝這是一個vue模板</h1>';

        // 替換 index.html 中的 <!--app-html--> 標記
        let html = template.replace('<!--app-html-->', vueTemplate);

        ctx.body = html;
    });

    app.listen(9000, () => {
        console.log('server is listening in 9000');
    });
})();
複製程式碼
  1. 執行 node server.js 後,我們就會看到返回的 變數 vueTemplate 內容

那麼到現在服務已正常啟動了,但是我們試想一下,我們頁面模板使用的是 vue,並且 vue 返回的是一個 vue 例項模板 ,所以我就要把這個 vue 例項模板 轉換成 可渲染的 html ,那麼 @vue/server-renderer 就應運而生了

四、新增服務端入口

因為 vue 返回的是 vue 例項模板 而不是 可渲染的 html ,所以我們需要使用 @vue/server-renderer 進行轉換

  1. 安裝 `@vue/server-renderer`
    
pnpm i @vue/server-renderer --save
複製程式碼
  1. 新建 `~/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 };
}
複製程式碼

那麼如何去使用 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();

    // 建立 vite 服務
    const viteServer = await vite.createServer({
        root: process.cwd(),
        logLevel: 'error',
        server: {
        middlewareMode: true,
        },
    })
    
    // 註冊 vite 的 Connect 例項作為中介軟體(注意:vite.middlewares 是一個 Connect 例項)
    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. 應用 Vite HTML 轉換。這將會注入 Vite HMR 客戶端,
            template = await viteServer.transformIndexHtml(ctx.path, template)

            // 3. 載入伺服器入口, vite.ssrLoadModule 將自動轉換
            const { render } = await viteServer.ssrLoadModule('/src/entry-server.ts')

            //  4. 渲染應用的 HTML
            const { renderedHtml } = await render(ctx, {})

            const html = template.replace('<!--app-html-->', 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');
    });

})()
複製程式碼
  1. 執行 node server.js 就可以看到返回的 App.vue 模板中的內容了,如下圖
Untitled 1.png
  1. 並且我們 `右鍵檢視顯示網頁原始碼`,也會看到渲染的正常 html
    
<!DOCTYPE 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 + vue3</title>
  </head>
  <body>
    <div id="app"><!--[--><img alt="Vue logo" src="/src/assets/logo.png"><!--[--><h1 data-v-469af010>Hello Vue 3 + TypeScript + Vite</h1><p data-v-469af010> Recommended IDE setup: <a href="<https://code.visualstudio.com/>" target="_blank" data-v-469af010>VSCode</a> + <a href="<https://github.com/johnsoncodehk/volar>" target="_blank" data-v-469af010>Volar</a></p><p data-v-469af010>See <code data-v-469af010>README.md</code> for more information.</p><p data-v-469af010><a href="<https://vitejs.dev/guide/features.html>" target="_blank" data-v-469af010> Vite Docs </a> | <a href="<https://v3.vuejs.org/>" target="_blank" data-v-469af010>Vue 3 Docs</a></p><button type="button" data-v-469af010>count is: 0</button><p data-v-469af010> Edit <code data-v-469af010>components/HelloWorld.vue</code> to test hot module replacement. </p><!--]--><!--]--></div>
    <script type="module" src="/src/entry-client.ts"></script>
  </body>
</html>
複製程式碼

到這裡我們就已經在 開發環境 已經正常的渲染了,但我們想一下,在 生產環境 我們應該怎麼做呢,因為咱們不可能直接在 生產環境 執行使用 vite 吧!

所以咱們接下來處理如何在 生產環境 執行吧

六、新增開發環境

為了將 SSR 專案可以在生產環境執行,我們需要:

  1. 正常構建生成一個 `客戶端構建包`;
    
  2. 再生成一個 SSR 構建,使其通過 `require()` 直接載入,這樣便無需再使用 Vite 的 `ssrLoadModule`;
    
  3. 修改 `package.json`
    
...

{
"scripts": {
    // 開發環境
    "dev": "node server-dev.js",
    // 生產環境
    "server": "node server-prod.js",
    // 構建
    "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",
  },
}

...

複製程式碼
  1. 修改 server.jsserver-dev.js
  2. 執行 pnpm run build 構建包
  3. 新增 server-prod.js

注意:為了處理靜態資源,需要在此新增 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) => {
    
    // 請求的是靜態資源
        if (ctx.path.startsWith('/assets')) {
            await sendFile(ctx, ctx.path, { root: clientRoot });
            return;
        }

        const [ appHtml ] = await render(ctx, manifest);

        const html = template
            .replace('<!--app-html-->', appHtml);

        ctx.type = 'text/html';
        ctx.body = html;
    });

    app.listen(8080, () => console.log('started server on http://localhost:8080'));
})();
複製程式碼

到這裡,我們在 開發環境生成環境 已經都可以正常訪問了,那麼是不是就萬事無憂了呢?

為了使用者的更極致的使用者體驗,那麼 預載入 就必須要安排了

七、預載入

我們知道 vue 元件 在 html 中渲染時都是動態去生成的對應的 jscss 等;

那麼我們要是在使用者獲取 服務端模板 (也就是執行 vite build 後生成的 dist/client 目錄) 的時候,直接在 html 中把對應的 jscss 檔案預渲染了,這就是 靜態站點生成(SSG) 的形式。

閒話少說,明白道理了之後,直接開幹 ~

  1. 生成預載入指令`:在 package.json 中的 `build:client` 新增 `--ssrManifest` 標誌,執行後生成 `ssr-manifest.json`
    
...

{
"scripts": {
    ...
    "build:client": "vite build --ssrManifest --outDir dist/client",
    ...
  },
}

...
複製程式碼
  1. 在 `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];
};

/**
 * 解析需要預載入的連結
 * @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;
}

/**
 * 預載入的對應的地址
 * 下面的方法只針對了 js 和 css,如果需要處理其它檔案,自行新增即可
 * @param file
 * @returns string
 */
function renderPreloadLink(file: string): string {
    if (file.endsWith('.js')) {
        return `<link rel="modulepreload" crossorigin href="${file}">`;
    } else if (file.endsWith('.css')) {
        return `<link rel="stylesheet" href="${file}">`;
    } else {
        return '';
    }
}
複製程式碼
  1. 給 `index.html` 新增 `<!--preload-links-->` 標記
    
  2. 改造 `server-prod.js`
    
...

(async () => {
    const app = new Koa();

    app.use(async (ctx) => {
    
 ...

        const [appHtml, preloadLinks] = await render(ctx, manifest);

        const html = template
            .replace('<!--preload-links-->', preloadLinks)
            .replace('<!--app-html-->', appHtml);

        // do something
    });

    app.listen(8080, () => console.log('started server on http://localhost:8080'));
})();
複製程式碼
  1. 執行 pnpm run build && pnpm run serve 就可正常顯示了

到這裡基本的渲染就完成了,因為我們是需要在瀏覽器上渲染的,所以 路由 vue-router 就必不可少了

八、整合 vue-router

  1. 安裝 vue-router
    
pnpm i vue-router --save
複製程式碼
  1. 新增對應的路由頁面 `index.vue` 、 `login.vue` 、 `user.vue`
    
  2. 新增 `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')
            }
        ]
    });
複製程式碼
  1. 修改入口檔案 `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);
});
複製程式碼
  1. 修改入口檔案 `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();

    ...
};

...
複製程式碼
  1. 執行 pnpm run build && pnpm run serve 就可正常顯示了

九、整合 pinia

  1. 安裝
    
pnpm i pinia --save
複製程式碼
  1. 新建 `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;
        }
    }
});

複製程式碼
  1. 新建 `src/store/index.ts`
    
import { createPinia } from 'pinia';
import useUserStore from './user';

export default () => {
    const pinia = createPinia();

    useUserStore(pinia);

    return pinia;
};

複製程式碼
  1. 新建 `UsePinia.vue` 使用,並且在 `pages/index.vue` 中引入
    
<template>
    <h2>歡迎使用vite+vue3+ts+pinia+vue-router4</h2>
    <div>{{ userStore.name }}的年齡: {{ userStore.age }}</div
    ><br />
    <button @click="addAge">點選給{{ userStore.name }}的年齡增加一歲</button>
    <br />
</template>

<script lang="ts">
    import { defineComponent } from 'vue';
    import useUserStore from '@/store/user';
    export default defineComponent({
        name: 'UsePinia',
        setup() {
            const userStore = useUserStore();

            const addAge = () => {
                userStore.updateAge(++userStore.age);
            };
            return {
                userStore,
                addAge
            };
        }
    });
</script>

複製程式碼
  1. 注入 `pinia` :修改 `src/entry-client.ts`
    
...

import createStore from '@/store';
const pinia = createStore();

const { app } = createApp();

app.use(router);
app.use(pinia);

// 初始化 pini
// 注意:__INITIAL_STATE__需要在 src/types/shims-global.d.ts中定義
if (window.__INITIAL_STATE__) {
    pinia.state.value = JSON.parse(window.__INITIAL_STATE__);
}

...
複製程式碼
  1. 修改 `src/entry-server.ts`
    
...

import createStore from '@/store';

export const render = () => {
    ...
    // pinia
    const pinia = createStore();
    app.use(pinia);
    const state = JSON.stringify(pinia.state.value);

    ...

    return [renderedHtml, state, preloadLinks];

}
...
複製程式碼
  1. 修改 `server-dev.js` 和 `server-prod.js`
    
...

const [renderedHtml, state, preloadLinks] = await render(ctx, {});

const html = template
     .replace('<!--app-html-->', renderedHtml)
     .replace('<!--pinia-state-->', state);
    // server-prod.js
    .replace('<!--preload-links-->', preloadLinks)

...
複製程式碼
  1. 給 `index.html` 新增 `<!--pinia-state-->` 標記
    
<script>
    window.__INITIAL_STATE__ = '<!--pinia-state-->';
</script>
複製程式碼
  1. 執行 pnpm run dev 就可正常顯示了

備註: 整合 pinia 這塊由於注入較為 複雜且方法不一 ,暫時不做詳細講解,如果大家有需要,後面會出詳細解析!

十、其它

  • vueuse 的整合:可參考 《手把手教你用 vite+vue3+ts+pinia+vueuse 打造大廠企業級前端專案》 [13]
  • CSS 整合參考如上 [14]
    • 原生 css variable 新特性
      scss
      less
      
  • CSS 的 UI 庫參考同上 [15]
    • 需要注意的是 按需引入
  • 壓測
    併發
    負載均衡
    
  • 負載均衡
    pm2
    docker
    

專案模板地址

傳送門 [16]

最後

友情提示:目前 Vite 的 SSR 支援還處於試驗階段,可能會遇到一些未知 bug ,所以在公司的生產環境請謹慎使用,個人專案中可以濫用喲 ~

該系列會是一個持續更新系列,關於整個 《Vite 從入門到精通》專欄 [17] ,我主要會從如下圖幾個方面講解,請大家拭目以待吧!!!

Untitled.png

靚仔靚女們 ,都看到這裡了,要不點個贊再走唄 :rose::rose::rose:

關於本文

作者:易師傅

https://juejin.cn/post/7086467466703929358

最後

歡迎關注「 三分鐘學前端

號內回覆:

網路 」,自動獲取三分鐘學前端網路篇小書(90+頁)

JS 」,自動獲取三分鐘學前端 JS 篇小書(120+頁)

演算法 」,自動獲取 github 2.9k+ 的前端演算法小書

面試 」,自動獲取 github 23.2k+ 的前端面試小書

簡歷 」,自動獲取程式設計師系列的  120 套模版

》》面試官也在看的前端面試資料《《

“在看和轉發” 就是最大的