Johnny Wang BlogJohnny Wang Blog
Articles
Project
Live2d
johnnywang/book
Articles
Project
Live2d
johnnywang/book
  • About Me

    • 關於 Johnny
    • 文章清單
  • Javascript

    • View Transitions - 認識並使用原生 document 轉場效果
    • 用 Web Container 打造自己的線上 NodeJS 開發環境
    • 來試用看看原生 Web Popover API
    • 在電腦裡搞一個 RWKV AI 小助手
    • 遊戲 App 素材解包學習紀錄
    • 動手自己做一個 ChatGPT UI 工具吧
    • Create a React Server Components Project without NextJS - 製作一個不依賴 NextJS 的 React Server Components 專案
    • 如何在 Vuepress 裡快樂寫 React
    • 從 Mock Service Worker 源碼中學習
    • 擺脫 Node modules 地獄,擁抱 Yarn Plug'n'Play(PnP)
    • 快速上手 NextJS v13 - 基礎觀念 AppRouter 篇
    • 快速上手 NextJS v13 - Data Fetching, Caching, Revalidating 篇
    • 用 Socket.io 搭配 Matterjs 製作一款 Real-Time Canvas 聊天室(文長慎入)
    • 如何只用一支 CDN 及 4行設定,讓瀏覽器讀懂 Typescript, React, Vue
    • 用 tsup 快速建立 Typescript 開發環境
    • 開發 Email EDM 你可以更輕鬆
    • 關於我的 Side project - Maju Web Editor
    • Web URL 中神奇的 createObjectURL method
    • Awesome Vite 一個由社群維護的 template 集大成
    • Vue、React 使用心得分享(文長慎入)
    • 一起動手用 Socket.io 和 Peerjs 打造 WebRTC 即時視訊
    • Temporal Typescript SDK 學習筆記
    • React Web3 Storage
    • React useState 取得最新值
    • Babel 7 Decorator 的神奇小問題
    • 用 Koa + Vite + Pinia 打造基礎 SSR 環境
    • Live2d 官方範例改寫
    • 如何把 Video 畫在 Canvas 上
    • 史上最簡單的 Webpack 5 教學
    • 在瀏覽器中直接 import Vue SFC 開發起來
    • 手寫一個可中斷的 delay promise
    • NodeJS 輕量開發框架 Expressjs 與 Koa2 的區別
    • 用 2D 物理引擎 Matterjs 製作經典馬力歐 1-1
    • 原生 Javascript 的類型標註工具 JSDoc
    • Pinia - Vuex 的後繼者
    • 用 Nodejs 寫個 FTP command line 工具
    • Vue3 Server Render 手把手帶你搭建
      • 專案環境架設
      • Entry files
      • Server for rendering
      • 結論
      • 參考
    • 你真的懂 Event Loop 嗎
    • Babel7 基本介紹與使用
    • Vue/Vitejs 部分源碼解析
    • Vuejs 依賴追蹤 2020 版
    • Vuejs 依賴追蹤 2019 版
    • Js literal 模板編譯
    • 用 JavaScript 鎖定用戶調整畫面比例
    • Date.now() 與 new Date()
    • Promise 相關知識
    • Web Component 學習筆記
    • TypeScript 基礎篇
    • TypeScript 進階篇
    • MVC, MVVM, MVI 軟體設計架構
    • MVVM 簡單模擬框架實作
  • CSS & Sass

    • 2024 CSS 年度報告筆記
    • 2021 CSS 年度報告筆記
    • 如何不用 setTimeout 幫 display: none 的 DOM 加動畫
    • 純 CSS 實作星球環繞動畫效果
    • 差點錯過的 Tailwindcss 入門學習筆記
    • Tailwindcss 進階學習筆記
    • Sass, SCSS Built-In-Modules 內建模組與函數
    • SCSS Parent Selector
    • CSS 畫面鎖橫屏時,滾動的問題!!
    • Safari 使用 animation 時動態產生 rem 的坑
    • CSS: mix-blend-mode 屬性混合圖層動畫
  • Memo

    • Javascript

      • Yarn Plug'n'Play (PnP) 的 VSCode 設定方式
      • 如何用 Web API 讀取剪貼簿內容
      • CSP (Content Security Policy) 是什麼?
      • Astro 入門學習筆記
      • 如何解決 Astro 套用主題切換時,畫面抖動瞬閃問題
      • Rspack - 以 Rust 打造的快速構建工具
      • Typescript v5 Decorator 學習筆記
      • Sequelize 筆記
      • ES2022 學習筆記
      • Crypto 密碼加密方法
      • 實作 Test 的注意事項
      • Jest 測試工具 - 基礎篇
      • 正則表達式
      • Rxjs 學習筆記
      • Gulp 學習筆記
      • Clean Code Javascript 學習
      • JS 實戰開發特殊小技巧
    • React

      • React forwardRef 使用筆記
      • 如何在 Nextjs 中使用 middleware set cookie
      • React Styled-Components 基礎篇
      • React Styled-Components 進階篇
      • React Unit Test
      • React Testing Library
      • React Emotion Basic
      • React Styled-JSX
      • React 18 - Concurrent Features Memo
      • useEffect 的高階封裝範例
      • React useContext & useReducer 搭配
      • React Router Config 筆記
    • Vue

      • Cypress Vue
      • Vue 單元測試學習筆記
      • 學習 Vuetify 的一些筆記
      • Vuex 學習筆記
    • React Native

      • React Native - basic
      • Expo Basic
      • Expo Router
      • Expo Build
      • Issues
    • GraphQL

      • GraphQL 學習筆記 - 基礎篇
      • GraphQL 學習筆記 - 進階篇
      • Apollo Client 學習使用筆記
      • GraphQL Memo
    • Parse

      • Parse Javascript 文檔閱讀筆記
      • Parse User Object 章節
      • Parse Session Object 章節
      • Parse Schema Object 章節
      • Parse Cloud Code 章節
    • CSS

      • Sass, SCSS 基礎筆記
    • Docker

      • Docker 基礎技術
      • Dockerfile 技術篇
      • Docker-compose 技術篇 - 3.7
      • Kubernetes 學習筆記
      • ArgoCD 學習筆記
      • Podman 學習筆記
      • Colima 安裝使用筆記
    • Git Learning

      • Git 版本控制
      • Git 基礎使用 - init, clone, add, commit, status, log
      • Git 分支
      • Git Merge 分支合併指令
      • Git Stash 保存紀錄
      • Git 基礎復原指令 - restore
      • Remote 遠端協同工作 - remote, fetch, pull, push
      • Git Tag 標籤 - tag
      • Git 設定 - config
      • Git 檔案比對與差異 - diff
      • Git Rebase 定義分支的參考基準
      • Subtree 子樹
      • Git Reflog 指令紀錄
      • Git Filter Branch
      • Git Crypt 使用筆記
      • Git 客製化工具
    • Bash

      • Bash 基礎概念
      • Variable 變數
      • Script 腳本撰寫
      • Condition 流程控制
      • Operation 算數表達式
      • Function 函數
      • Command Line Params 指令列參數處理
    • Patterns

      • Introduce
      • Design Patterns

        • Singleton Pattern
        • Compound Pattern
        • Proxy Pattern
        • Hooks Pattern
        • Observer Pattern
        • Mediator/Middleware Pattern
        • HOC Pattern
        • Factory Pattern
        • Render Props Pattern
        • Flyweight Pattern
        • Container/Presentational Component
        • Prototype Pattern
        • Mixin Pattern
        • Provider Component
        • Command Component
        • Module Pattern
      • Render Patterns

        • Rendering Patterns 渲染模式介紹
    • FE 性能優化

      • 前端性能優化
      • 靜態資源優化
      • 頁面渲染架構優化
      • Server 與 Network 優化
      • 開發流程、監控體系優化
    • Ollama 筆記
    • Nginx JS module 基本使用筆記
    • FlowiseAI 使用介紹筆記
    • Github Copilot 使用筆記
    • Traefik Memo
    • API First 學習筆記
    • What is AC - Acceptance Criteria 驗收條件
    • Mermaid 學習筆記
    • 串接 Sonarcube 筆記
    • Youtube Data API 基礎使用筆記
    • FB API 學習筆記
    • VSCode 好用快捷鍵
    • 在 Simulator 上開發測試
    • 問題修正紀錄
  • References

    • 全端開發學習資源
    • 實用工具
    • 網頁前端課程目錄
  • Daily

    • 中醫學習

      • 中醫自學方向
      • 黃帝內經-素問
      • 基礎理論

        • 中藥治病
        • 中藥產地與採收
        • 陰陽五行
        • 精氣血津液
        • 整體觀與臟象
        • 病因病機
        • 辨證論治
        • 四診八綱
        • 五運六氣
        • 經絡
      • 診斷學

        • 辯證
        • 望診
        • 聞診
        • 問診
        • 脈診簡史
        • 切診
        • 常見脈象與臨床意義
        • 經脈辨證
        • 中醫的六邪在疾病初期、後期分別有那些症狀
      • 中藥學

        • 中藥基礎理論
        • 常見中藥材
        • 解表藥(30)
        • 清熱藥(82)
        • 瀉下藥(16)
        • 利水渗湿药(8)
        • 化痰止咳平喘药(15)
        • 止血藥(8)
        • 開竅-平肝-安神藥(11)
        • 化濕藥(5)
        • 祛風濕藥(12)
        • 理氣藥(13)
        • 活血祛瘀藥(7)
        • 消食藥(4)
        • 驅蟲藥(6)
        • 祛寒药(6)
        • 補虛藥(43)
        • 收斂藥(13)
      • 方劑學

        • 總論
        • 解表劑
        • 瀉下劑
        • 清熱劑
        • 理血劑
        • 去暑劑
        • 去濕劑
      • 針灸學

        • 特定穴
      • 其他

        • 內證觀察筆記 上篇【內證漫談】
        • 內證觀察筆記 中篇【太極器官、五藏】
        • 內證觀察筆記 下篇【十二正經】
        • 磁力對生理的影響
    • 易經學習

      • 易經入門
    • 道德經學習

      • 道德經學習重點筆記
      • 第一章 道可道,非常道
      • 第二章 夫唯弗居,是以不去
      • 第三章 不尚賢,使民不爭
      • 第四章 道沖而用之或不盈,淵兮似萬物之宗
      • 第五章 天地不仁,以萬物為芻狗,多聞數窮,不如守中
      • 第六章 谷神不死,是謂玄牝
      • 第七章 天長地久。天地所以能長且久者,以其不自生,故能長生。
      • 第八章 上善若水。水善利萬物而不爭,處眾人之所惡,故幾於道
      • 第九章 功成身退,天之道也。
      • 第十章 載營魄抱一,能無離乎﹖
      • 第十一章 有之以為利,無之以為用
      • 第十二章 五色令人目盲,五音令人耳聾,五味令人口爽,馳騁畋獵令人心發狂,難得之貨令人行妨。
      • 第十三章 寵辱若驚,貴大患若身。愛以身為天下,若可托天下
      • 第十四章 是謂無狀之狀,無物之象,是謂惚恍。迎之不見其首,隨之不見其後。執古之道,以御今之有。
      • 第十五章 保此道者,不欲盈。夫唯不盈,故能蔽而新成。
      • 第十六章 致虛極,守靜篤。萬物並作,吾以觀復。
      • 第十七章 太上,下知有之。悠兮其貴言,功成事遂,百姓皆謂我自然。
      • 第十八章 大道廢,有仁義;智慧出,有大偽;
      • 第十九章 絕聖棄智,民利百倍。見素抱樸,少私寡欲
      • 第二十章 絕學無憂。眾人皆有餘,而我獨若遺。
      • 第二十一章 道之為物,惟恍惟惚。惚兮恍兮,其中有象;恍兮惚兮,其中有物。
      • 第二十二章 曲則全,枉則直,窪則盈,敝則新,少則得,多則惑。是以聖人抱一為天下式
      • 第二十三章 希言自然。天地尚不能久,而況於人乎?信不足焉,有不信焉。
      • 第二十四章 企者不立,跨者不行
      • 第二十五章 有物混成,先天地生。人法地,地法天,天法道,道法自然。
      • 第二十六章 重為輕根,靜為躁君。輕則失本,躁則失君。
      • 第二十七章 善行無轍跡,善言無瑕謫。故善人者,不善人之師;不善人者,善人之資。不貴其師,不愛其資,雖智大迷,是謂要妙。
      • 第二十八章 知雄守雌,為天下谿,知榮守辱,為天下谷
      • 第二十九章 天下神器,不可為也,是以聖人去甚,去奢,去泰。
      • 第三十章 以道佐人主者,不以兵強天下
      • 第三十一章 夫兵者,不祥之器,物或惡之,故有道者不處。
      • 第三十二章 道常無名,樸雖小,天下莫能臣也。
      • 第三十三章 知人者智,自知者明。
      • 第三十四章 大道氾兮,其可左右。以其終不自為大,故能成其大。
      • 第三十五章 執大象,天下往。往而不害,安平太。樂與餌,過客止。
      • 第三十六章 將欲歙之,必固張之;將欲弱之,必固強之
      • 第三十七章 道常無為而無不為
      • 第三十八章 上德不德,是以有德;下德不失德,是以無德
      • 第三十九章 昔之得一者,天得一以清。不欲琭琭如玉,珞珞如石。
      • 第四十章 反者道之動,弱者道之用。天下萬物生於有,有生於無
      • 第四十一章 上士聞道,勤而行之;大方無隅,大器晚成,大音希聲,大象無形,道隱無名。
      • 第四十二章 道生一,一生二,二生三,三生萬物。
      • 第四十三章 天下之至柔,馳騁天下之至堅
      • 第四十四章 名與身孰親﹖身與貨孰多﹖得與亡孰病﹖
      • 第四十五章 大成若缺,其用不弊。大盈若沖,其用不窮。
      • 第四十六章 天下有道,卻走馬以糞。天下無道,戎馬生於郊。
      • 第四十七章 不出戶,知天下;不窺牖,見天道。
      • 第四十八章 為學日益,為道日損。損之又損,以至於無為。
      • 第四十九章 聖人無常心,以百姓心為心。
      • 第五十章 出生入死。生之徒,十有三;死之徒,十有三;人之生,動之死地,亦十有三。
      • 第五十一章 道生之,德畜之,物形之,勢成之。
      • 第五十二章 天下有始,以為天下母。以知其子,既知其子,復守其母,沒身不殆。
      • 第五十三章 使我介然有知,行於大道,唯施是畏。大道甚夷,而人好徑。
      • 第五十四章 善建者不拔,善抱者不脫,子孫以祭祀不輟。
      • 第五十五章 含德之厚,比於赤子。知和曰常,知常曰明。益生曰祥。心使氣曰強。
      • 第五十六章 知者不言,言者不知。
      • 第五十七章 以正治國,以奇用兵,以無事取天下。
      • 第五十八章 其政悶悶,其民淳淳;其政察察,其民缺缺。禍兮福之所倚,福兮禍之所伏。
      • 第五十九章 治人事天,莫若嗇。夫唯嗇,是謂早服
      • 第六十章 治大國,若烹小鮮。夫兩不相傷,故德交歸焉。
      • 第六十一章 大國者下流,天下之交。天下之牝,牝常以靜勝牡,以靜為下。
      • 第六十二章 道者萬物之奧。雖有拱璧以先駟馬,不如坐進此道。
      • 第六十三章 為無為,事無事,味無味。圖難於其易,為大於其細;
      • 第六十四章 合抱之木,生於毫末;九層之臺,起於累土;千里之行,始於足下。為者敗之,執者失之。
      • 第六十五章 古之善為道者,非以明民,將以愚之。
      • 第六十六章 是以欲上民,必以言下之。欲先民,必以身後之。
      • 第六十七章 天下皆謂我道大,似不肖。夫唯大,故似不肖。
      • 第六十八章 善為士者不武,善戰者不怒,善勝敵者不與
      • 第六十九章 吾不敢為主而為客,不敢進寸而退尺。禍莫大於輕敵,輕敵幾喪吾寶。
      • 第七十章 吾言甚易知,甚易行。天下莫能知,莫能行。
      • 第七十一章 知不知上,不知知病。夫唯病病,是以不病。
      • 第七十二章 民不畏威,則大威至。無狎其所居,無厭其所生。夫唯不厭,是以不厭。
      • 第七十三章 天之道,不爭而善勝,不言而善應,不召而自來,繟然而善謀。
      • 第七十四章 民不畏死,奈何以死懼之?若使民常畏死,而為奇者,吾得執而殺之,孰敢?
      • 第七十五章 民之饑,以其上食稅之多,是以饑。
      • 第七十六章 人之生也柔弱,其死也堅強。萬物草木之生也柔脆,其死也枯槁。
      • 第七十七章 天之道,其猶張弓與﹖高者抑之,下者舉之;有餘者損之,不足者補之。
      • 第七十八章 天下莫柔弱於水,而攻堅強者莫之能勝,以其無以易之。
      • 第七十九章 和大怨,必有餘怨,安可以為善﹖
      • 第八十章 小國寡民。使有什伯之器而不用,使民重死而不遠徙。
      • 第八十一章 信言不美,美言不信。天之道,利而不害;聖人之道,為而不爭。
    • 人類真相推廣協會

      • 人類真相介紹
      • 人生大挑戰-閱讀筆記
      • 書籍-人生大挑戰

        • 序文
        • 童年的回憶
        • 賺外快的童年
        • 養家的童年
        • 少年時期的回憶
        • 粉紅睡衣女鬼的祕密
        • 麵線、甘蔗和賽鴿
        • 我在黑社會的日子
        • 『台北一條龍』
        • 賭徒‧妻子‧盤仔人
        • 人鬼之戰—正邪不分的恐怖
        • 開展創業石銅雕畫的日子
        • 渡畜牲者‧瞎掰鬼與邪靈
        • 無所不在的陷阱—邪靈的詭計
        • 鬼屋‧符令‧大揭祕
        • 妖魔鬼怪大變身—邪靈與動物
        • 乩童與宮廟的祕密
        • 活鬼纏身的恐怖亂象
        • 前面親兄弟、後面無情義的假面
        • 前面手牽手、後面下毒手的險境
        • 人心險惡、五馬分屍所逼自殺的真相
        • 自殺後的奇遇—人死後的世界
        • 我自殺後的奇遇—「陰間地府處」與「陰府大本營」
        • 我被趕出家門、面臨眾叛親離的苦境
        • 其他
    • 英文學習

      • 自我介紹
      • 簡單寒暄
      • 指路
      • 買賣
      • 轉接電話
      • 通勤
      • 訂位
      • 數字
    • 金剛經 時間不存在?自我是虛擬?
    • 20250319 外婆過世
    • 說服的藝術:避免陷阱,建立共識
    • 禪修一定要去除「如何」嗎?
    • 不對稱的回報
    • 避免慢性壓力
    • 關鍵溝通表達力
    • 快樂的秘訣
    • 生活在現代社會,維持高品質思考的重要性
    • 心理學家的面相術:解讀情緒的密碼
    • 什麼是真相,真相是什麼
    • 身為一位歷史觀察者(文長慎入)
    • 2022 Leisure Learn
    • 兩年八個月,我從 Garena 畢業了
    • DeFi
    • 我的終生大事,交往到結婚
    • 轉職前端工程師 3 年多的回顧
    • MacOS + Iterm2 + Oh My Zsh
    • 我喜歡的名言佳句
    • Web Interview Preparation

Vue3 Server Render 手把手帶你搭建

Share On:

FacebookLINEMessengerTelegram

嗨~,大家好,我是 Garena Taiwan 的前端工程師 Johnny,最近工作用 Nuxtjs 做了一些專案像是 妖精尾巴手遊官網 和 天涯明月刀預登頁 後(沒聽過沒關係的 Orz...),覺得自己對整個 SSR 的構建流程似乎理解的沒有很透徹,決定自己動手試試從 0 搭建一個 Vue SSR 的專案試試,看能不能藉此提升一下對於 SSR 的構建理解狀況。

由於網路上大部分的教學目前都是圍繞著 Vue2 為基礎,為了增加樂趣(踩雷拉~~),本篇想嘗鮮以 Vue3 來實作看看,一些內容參考自網路上其他大大們的文章,希望也能幫助到大家加深理解 SSR

另外有些相關的套件仍在 alpha 階段,因此實際練習時,需要注意版本的差異喔,本篇主要依賴版本如下

dependencies

  • vue: v3.1.1
  • vue-router: v4.0.9
  • vuex: v4.0.1
  • vue-meta: v3.0.0-alpha.5(alpha.9 cjs 編譯輸出版本有 optional chaining 殘留會噴錯)
  • express: v4.17.1

dev-dependencies

  • @vue/compiler-sfc: v3.1.1(需跟 vue 版本一致)
  • @vue/server-renderer: v3.1.1(需跟 vue 版本一致)
  • webpack: v5.39.0
  • vue-loader: v16.2.0
  • vue-style-loader: v4.1.3
  • css-loader: v5.2.6

Webpack 5 預設將不再包含一些 nodejs 依賴,例如 buffer, stream-browserify 等等,需要在 config 中另外處理 fallback。

專案環境架設

原本 Nuxtjs 在環境上似乎是用 rollup 進行編譯打包的工作,這篇為求方便直接 webpack 幹到底!~

開始之前,我們要先理清一下頭緒,我們的 webpack 需要區分為 client, server side 分別編譯我們的 entry-client, entry-server,其中 entry-client 是給 browser 讀的,而 entry-server 則是讓我們後面啟動 server 時讀取用的~

主要分為三隻 config

  • base.config.js:
    client, server side 共用的一些 loader 設定等等
  • client.config.js:
    client 端的配置,基本就跟平常的差不多
  • server.config.js:
    server 端的配置,主要以 nodejs 讀取為主,本篇以 es6 module輸出使用

參考代碼如下:

  1. base.config.js

/**
 * Base Webpack config
 */
const { VueLoaderPlugin } = require('vue-loader');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const TerserPlugin = require('terser-webpack-plugin');

const isProd = process.env.NODE_ENV === 'production';

const config = {
  mode: process.env.NODE_ENV,
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
      },
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          extractCSS: isProd, // extract css in production
        },
      },
      {
        test: /\.s?css$/,
        use: [
          // extract css in production
          isProd ? MiniCssExtractPlugin.loader : 'vue-style-loader',
          {
            loader: 'css-loader',
            options: {
              sourceMap: true,
              esModule: false, // css-loader > 5.0 use esModule by default
            },
          },
          {
            loader: 'sass-loader',
            options: {
              sourceMap: true,
              additionalData: '', // if you need any sass mixins can be put here
            },
          },
        ],
      },
    ]
  },
  plugins: [
    new VueLoaderPlugin(),
  ],
  optimization: {
    minimizer: [],
  },
};

// In Production
if (isProd) {
  config.plugins.push(new MiniCssExtractPlugin({
    filename: 'css/[name].[contenthash].css',
    chunkFilename: 'css/[id].[contenthash].css',
  }));
  config.optimization.minimizer.push(new TerserPlugin());
}

module.exports = config;
  1. client.config.js

/**
 * Client Webpack config
 */
const path = require('path');
const { merge } = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');

const baseConfig = require('./base.config');

const config = merge(baseConfig, {
  entry: path.resolve(__dirname, '../src/entry-client.js'),
  output: {
    path: path.resolve(__dirname, '../.ssr'),
    publicPath: '/.ssr/', // match static folder name which served by server
    filename: 'entry-client.js'
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src')
    },
    // webpack 5 not includes these nodejs packages by default anymore
    fallback: {
      stream: require.resolve('stream-browserify'),
      buffer: require.resolve('buffer'),
    },
  },
});

// html-webpack-plugin: inject our scripts and css files or style tags
config.plugins.push(new HtmlWebpackPlugin({
  template: path.resolve(__dirname, '../index.html'),
  filename: 'index.html',
  inject: 'body',
}));

module.exports = config;
  1. server.config.js

/**
 * Server Webpack config
 */
const path = require('path');
const { merge } = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');

const baseConfig = require('./base.config');

const config = merge(baseConfig, {
  entry: path.resolve(__dirname, '../src/entry-server.js'),
  // output type "module" is still in experiments
  experiments: {
    outputModule: true,
  },
  output: {
    path: path.resolve(__dirname, '../.ssr'),
    filename: 'entry-server.js',
    library: {
      type: 'module', // esmodule
    },
  },
  target: 'node', // in node env
  node: {
    // tell webpack not to handle following
    __dirname: false,
    __filename: false,
  },
  externals: [nodeExternals({
    // polyfill, .vue, .css
    allowlist: [
      /\.(css|sass|scss)$/,
      /\.(vue)$/,
      /\.(html)$/,
    ],
  })], // external node_modules deps
});

module.exports = config;

上面的 nodeExternal 是一個專門剔除 node_modules 依賴的套件

ok~,基本上主要就是透過 webpack 幫我們編譯兩份檔案出來,之後一份給 browser,一份給 server,接著我們來寫一下我們編譯前的 entry-client, entry-server, main.js 吧

Entry files

在 SSR 的世界中,我們需要把一些實例化的過程放進一個產生器中,確保我們的每個 user 拿到的實例是全新的!首先來看看我們 main.js:

main.js

// use for ssr env
import { createSSRApp } from 'vue';
import App from './App.vue';
import { createMetaManager } from './meta';
import { createStore } from './store';
import { createRouter } from './router';

// createApp receives "isServer" to diff env
export function createApp({ isServer }) {
  const meta = createMetaManager();

  const store = createStore();

  const router = createRouter({ isServer });

  const app = createSSRApp(App);

  app.use(meta).use(store).use(router);

  // expose our instance for later render usage
  return { app, meta, store, router };
}

我們在 main.js 中定義一個函數,用來產生各種實例化所有常見的相關套件,並且輸出他們,提供後續渲染配置。

注意喔~,這邊不會立即執行 mount 的動作,我們將 mount 移轉到 entry-client 幫忙執行,為了確保 main.js 可以被 server 正確取用,這也是為何我們的 mounted, beforeMount 等生命週期不會在 server side 觸發執行的原因。

接著完成所有插件配置:

  1. vue-router

import { createRouter as _createRouter, createWebHistory, createMemoryHistory } from 'vue-router';
import ViewHome from './views/Home.vue';
import ViewAbout from './views/About.vue';

const routes = [
  {
    path: '/',
    name: 'Home',
    component: ViewHome,
  },
  {
    path: '/about',
    name: 'About',
    component: ViewAbout,
  },
];

export function createRouter({ isServer }) {
  return _createRouter({
    // diff from server and client
    history: isServer
      ? createMemoryHistory()
      : createWebHistory(),
    routes,
  })
};
  1. vuex

import { createStore as _createStore } from 'vuex';

export function createStore() {
  return _createStore({
    strict: true,
  });
};
  1. vue-meta

import { createMetaManager as _createMetaManager } from 'vue-meta';

export function createMetaManager() {
  return _createMetaManager();
}
  1. App.vue

這邊需要注意,新版的 vue-meta 寫法可能不同,這邊是以當下版本來寫,因為 vue-meta 使用到 vue3 的 teleport,必須用 div 包起來不然會出現問題,並且這邊只希望 client 端渲染就好因此簡單包裹如下。

<template>
  <div v-if="inClient">
    <metainfo></metainfo>
  </div>
  <div id="app">
    <router-view />
  </div>
</template>

<script>
import { useMeta } from 'vue-meta';

export default {
  name: 'App',
  data() {
    return {
      inClient: false,
    }
  },
  mounted() {
    console.log(this.$store); // test to see if store init correctly
    this.inClient = true;
  },
  setup () {
    useMeta({
      title: 'My Default App',
      htmlAttrs: {
        lang: 'en',
      },
    })
  },
};
</script>

<style lang="scss">
#app {
  font-size: 16px;
}
</style>
  1. Home.vue

接著我們來建立兩個簡單的 page 互相跳轉一下,看看 meta 有沒有正確~

<template>
  <div class="view-home">
    {{ msg }}
    <router-link to="/about">To About</router-link>
  </div>
</template>

<script>
import { useMeta } from 'vue-meta';

export default {
  name: 'ViewHome',
  data() {
    return {
      msg: 'Hello World',
    }
  },
  setup () {
    const { meta } = useMeta({
      title: 'My Default App - Home',
    })
  },
};
</script>

<style lang="scss">
.view-home {
  color: red;
}
</style>
  1. About.vue

<template>
  <div class="view-about">
    {{ msg }}
    <router-link to="/">To Home</router-link>
  </div>
</template>

<script>
import { useMeta } from 'vue-meta';

export default {
  name: 'ViewAbout',
  data() {
    return {
      msg: 'Hello About',
    }
  },
  setup () {
    const { meta } = useMeta({
      title: 'My Default App - About',
    })
  },
};
</script>

<style lang="scss">
.view-about {
  color: blue;
}
</style>

entry-client.js

接著看看 entry-client:

import { createApp } from './main';

// open an async
(async () => {
  console.log('pass client');
  const { app, router } = createApp({ isServer: false });

  // wait for router ready
  await router.isReady();
  
  // mount to our app wrapper
  app.mount('#__maju');
})();

entry-client 中主要就是把本來在 client 中的初始動作完成

entry-server.js

接著看看 entry-server

import { createApp } from './main';

// context will be injected by our server
export default async function serverEntry(context) {
  console.log('pass server');
  const { app, router, store, meta } = createApp({ isServer: true });

  // set server-side router's location
  router.push(context.url);

  // bind instance to context
  context.meta = meta;
  context.store = store;

  // wait for router ready
  await router.isReady();

  const matchedComponents = router.currentRoute.value.matched;
  // no matched routes, pass with next()
  if (!matchedComponents.length) {
    // error 404 or pass to other middleware
    context.next();
  }

  // the Promise should resolve to the app instance so it can be rendered
  return app;
}

entry-server 中我們會幫助 vue-router 跳轉至指定的 url 位置,這步驟在 client side 會自動完成,但 server 端需要手動處理,並且把一些實例掛載到我們的 context 中供後續 server 其他的 middleware 使用(這篇沒有實作 middleware 在 server 中~)

等待 router 初始完成後就可以將 app instance 回傳給 server 進行下一步的渲染摟。

Server for rendering

接著實作重頭戲的 server 本人!我們需要在 server 中完成幾件事情:

  • 啟動 server
  • serve 靜態檔案在 .ssr
  • 編譯渲染 app 並組合 meta tags 成 html string
  • 最後回傳 html string

其中編譯渲染的過程比較長,我們另外拉出一隻 compile.js 來處理

server.js

import path from 'path';
import express from 'express';
import compileServer from './compile';

// create express server
const server = express();

// serve static files in .ssr folder
server.use('/.ssr', express.static(path.join(__dirname, '../.ssr')));

// compile server html and serve
compileServer(server);

// listen port
const port = process.env.PORT || 3000;

server.listen(port, () => console.log(`Vue3 SSR server at port: ${port}`));

compile.js

import fs from 'fs';
import path from 'path';
import { renderToString } from '@vue/server-renderer';
import serverEntry from '../.ssr/entry-server.js'; // import out compiled server entry

/**
 * renderMetaToString
 * @param {vue app instance} app 
 * @returns 
 */
async function renderMetaToString(app) {
  const ctx = {};
  const appHtml = await renderToString(app, ctx);
  if (!ctx.teleports || !ctx.teleports.head) {
    const teleports = app.config.globalProperties.$metaManager.render();
    await Promise.all(teleports.map((teleport) => renderToString(teleport, ctx)));
  }

  const { teleports } = ctx;
  for (const target in teleports) {
    if (target.endsWith('Attrs')) {
      const str = teleports[target];
      // match from first space to first >, these should be all rendered attributes
      teleports[target] = str.slice(str.indexOf(' ') + 1, str.indexOf('>'));
    }
  }

  return [appHtml, ctx];
}

/**
 * renderWithMeta
 * @param {vue app instance} app
 * @returns {html string}
 */
async function renderWithMeta(app) {
  const [appHtml, ctx] = await renderMetaToString(app);

  // index.html injected with entry-client.js and css files
  const wrapper = fs.readFileSync(
    path.join(__dirname, '../.ssr/index.html'),
    'utf-8',
  );

  // replace meta tags in template
  return wrapper
    .replace('{{ HTML_ATTRS }}', ctx.teleports.htmlAttrs || '')
    .replace('{{ HEAD_ATTRS }}', ctx.teleports.headAttrs || '')
    .replace('{{ HEAD }}', ctx.teleports.head || '')
    .replace('{{ BODY_ATTRS }}', ctx.teleports.bodyAttrs || '')
    .replace('{{ APP }}', `<div id="__maju">${appHtml}</div>`);
}

export default function compileServer(serverApp) {
  serverApp.get('*', async (req, res, next) => {
    // context
    const context = {
      url: req.url,
      next,
    };

    // init app instance
    const app = await serverEntry(context);
  
    // render app to html
    const html = await renderWithMeta(app);

    // return
    res.end(html);
  })
}

以上就大功告成拉~~,最後我們需要指令來啟動,改一下 package.json 如下

為了同時編譯與啟動 server,我們需要安裝一個套件 concurrently。

{
  "scripts": {
    "dev": "concurrently 'pnpm dev:client' 'pnpm dev:server' 'pnpm start'",
    "dev:client": "cross-env NODE_ENV=development webpack --watch --config config/client.config.js",
    "dev:server": "cross-env NODE_ENV=development webpack --watch --config config/server.config.js",
    "build": "pnpm build:client && pnpm build:server",
    "build:client": "cross-env NODE_ENV=production webpack --config config/client.config.js",
    "build:server": "cross-env NODE_ENV=production webpack --config config/server.config.js",
    "start": "nodemon --watch .ssr/entry-server.js --exec 'babel-node config/server.js'"
  },
}

最後打開終端機輸入 npm run dev 就會看到到我們的 ssr server 摟~

結論

光是把基本配置搞好還沒開發就快搞死人了,真的是要感謝 Nuxt 團隊的努力啊~,不然每次配置 SSR 專案都要這樣折騰一次實在是不行

以上就是本次花了一整個禮拜的下班時間研究出的結果拉~希望大家會喜翻,source code在此,歡迎有興趣看看的大大們下載來玩玩,別忘了順手幫我點個讚喔,感恩拉!!~

參考

  1. Vue3 Server-Side Rendering Guide
  2. 手把手建立Vue-SSR開發環境
  3. Vue Router - api
  4. Vue meta

Share On:

FacebookLINEMessengerTelegram
最近更新:: 2022/3/29 凌晨2:43
Contributors: Johnny Wang, johnnywang1994
Prev
用 Nodejs 寫個 FTP command line 工具
Next
你真的懂 Event Loop 嗎