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 手把手帶你搭建
    • 你真的懂 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 指令列參數處理
    • Python

      • Pyenv with virtualenv 配置
    • 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

MVVM 簡單模擬框架實作

tags: JavaScript, Vuejs, MVVM

Share On:

FacebookLINEMessengerTelegram

背景

某天閒來無事時,剛好搜尋到一篇介紹 MVVM 的好文,仔細閱讀後覺得收穫頗多,決定將一些想法跟理解記錄下來所誕生。

概要

將該篇文章所講的主要概念轉為我自己的理解並記錄實作步驟。話不多說,動手開始吧。

何謂 MVVM?

在實作之前,先簡單提一下什麼是 MVVM,它是一種軟體設計架構的模式,會在 Model 及 View 之間建立一層 ViewModel,來幫助 Model 及 View 之間的交互操作,可以簡化開發的一些繁瑣重複的動作。

Front End 與 MVVM 有何關係?

在 Front End 開發上,撇除 CSS 不談,最主要的工作就是將資料數據渲染在畫面上,而在瀏覽器中,也配置有許多這類型的方法,統稱為 Element Methods,這些 methods 在 MVC 的架構下,就是負責處理 Model 與 View 的交互,JQuery 主要也就是在做這件事情,但這些 methods 始終都必須要透過人工的操作來完成交互,而 MVVM 的架構下,就是將這些繁瑣的動作都交由 ViewModel 來代勞,我們只需要處理及更新資料,ViewModel 會根據更新的資料去完成更新 View 的動作。

實作練習

實作前說明

實現 MVVM 的方式不限於一種,本文採用 Vuejs 的數據劫持+訂閱發布方式實作,主要專注在 MVVM 概念的理解與實踐,具體功能不會一一仔細去深入研究。

首先,要完成 MVVM 的架構,必須包含五個主要的模組:

  • Observer:對數據進行劫持設定
  • Dep:訂閱器用來儲存被劫持資料的 watchers 依賴
  • Watcher:儲存被劫持資料更新時需執行的動作
  • Compiler:編譯模板,對不同類型節點解析並加入 watcher
  • MVVM:整合所有模組,提供使用者調用

思路整理

由於所要完成的最終步驟稍顯龐大與複雜,我會分成兩個部分來說明,第一部分是各別模組的實踐,第二部分才會將模組進行整合。

最終實踐上,程式碼會如下順序進行執行:

  1. 使用者建立實例,對 MVVM 模組傳入資料
  2. MVVM 模組分別將傳入的資料依序傳遞給 Observer 及 Compiler 處理
  3. Observer 模組對資料設定劫持機制
  4. Compiler 解析元素,根據傳入的資料進行編譯後加入 watcher
  5. Watcher 在 Compiler 中被建立時,會自動將自己掛載到對應的資料 Dependency 裡
  6. 完成 compile,並在後續資料更新時,調用該資料所有 Dependency 裡掛載的 watchers,執行與編譯該節點時相同的動作。

Observer 模組的實踐

首先第一步來看 Observer 是怎麼做到劫持資料的,主要是使用 defineProperty 屬性來對物件的所有屬性進行遞回劫持,包括子屬性與新值,初步實踐如下:

function observe(data) {
  if (!data || typeof data !== 'object') return;
  Object.keys(data).forEach((key) => defineReactive(data, key, data[key]));
}

function DefineReactive(data, key, val) {
  observe(val); // 子屬性遞迴
  Object.defineProperty(data, key, {
    enumerable: true, // 可遍歷
    configurable: false, // 不可再 define
    get() {
      return val;
    },
    set(newValue) {
      console.log(key, '監聽到變化'); // 劫持動作
      val = newValue;
      observe(newValue); // 遞回新值
    }
  });
}

Dep 模組的實踐

以上已可初步劫持資料的變化了,但一個單一資料可能會被用在多個地方,我們需要一個獨立的地方來儲存每一個資料的 watchers(被用一次必須產生一個新 watcher 去處理),因此我們需要一個訂閱器來儲存 watchers,訂閱器代碼如下:

function Dep() {
  this.subs = [];
}

Dep.prototype = {
  // 添加 watcher
  addSub(watcher) {
    this.subs.push(watcher);
  },
  // 通知執行所有相依 watchers
  notify() {
    this.subs.forEach((watcher) => watcher.update());
  }
}

接著,因為每個資料都必須創建一個新的訂閱器來儲存屬於他自己的所有 watchers,所以具體在剛剛 defineReactive 方法中使用如下:

// 上略...

function DefineReactive(data, key, val) {
  const dep = new Dep(); // 創建屬於它自己的訂閱器
  observe(val);
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: false,
    // 暫略...
    set(newValue) {
      console.log(key, '監聽到變化');
      val = newValue;
      observe(newValue);
      dep.notify(); // 通知所有 watchers
    }
  });
}

完成通知的設定後,還剩下一個問題,==怎麼添加 watchers 給 dep?==,剛剛有提到,每個資料都有屬於自己的訂閱器實例,訂閱器是在 defineReactive 方法中創建的,也就是說加入 watcher 的動作必定是在閉包內進行,因此,我們先假設後續會透過在 Dep.target屬性中掛載 watcher 實例,此時就把它加入進去 dep 中。

function DefineReactive(data, key, val) {
  const dep = new Dep(); // 訂閱器
  observe(val);
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: false,
    get() {
      Dep.target && dep.addSub(Dep.target); // 將掛載的 watcher 加入實例
      return val;
    },
    // 略...
  });
}

此時我們須先記著,待會在 Watcher 的模組中,我們必須將 watcher 實例掛載到 Dep.target 這個地方後,強制拿取一次對應的資料,才能觸發他的 get 來加入 watcher。

Compiler 模組的實踐

為什麼先跳過 Watcher 模組呢?因為 Watcher 模組中有許多實作的概念必須先講到 Compiler 之後才能夠理解為什麼要寫那樣,所以我覺得先講 Compiler 比較有助於理解喔~

在 Compiler 中,我們主要會需要知道編譯的對象,以及拿來編譯的資料,再根據節點類型做對應的編譯方式,另外由於初次編譯會大量操作到 DOM 節點,我們會先將需要編譯的元素轉為文檔碎片 Fragment 的形式處理,待最後解析編譯完成後再一次塞回原來的元素中,具體實踐如下:

function Compiler(el, data) {
  this.$el = el;
  this.$data = data;
  if (this.$el) {
    this.$fragment = this.node2Fragment(this.$el);
    this.init();
    this.$el.appendChild(this.$fragment);
  }
}

Compiler.prototype = {
  init() {
    // 解析的對象是文檔碎片
    this.compileElement(this.$frament);
  },
  node2Fragment(el) {
    const fg = document.createDocumentFragment();
    let child = null;
    while (child = el.firstChild) {
      fg.appendChild(child);
    }
    return fg;
  }
}

接著,來完成解析節點的 compileElement 方法,其中 _getDataVal用來取得屬性內容裡寫的物件路徑,並返回該路徑的資料:

Compiler.prototype = {
  // 上略...
  // 第一步:分析節點類型
  compileElement(el) {
    const childNodes = el.childNodes, self = this;
    childNodes.forEach((node) => {
      if (self.isElementNode(node)) {
        self.compile(node);
      } else if (self.isTextNode(node)) {
        self.compileText(node);
      }
      // 若節點中還有子節點,遞迴此步驟
      if (self.hasChildNodes(node)) {
        self.compileElement(node);
      }
    });
  },
  // 第二步:編譯不同類型的節點
  // 1. 編譯標籤:比對屬性指令,並調用對應的指令函數後
  compile(node) {
    const attrs = node.attributes, self = this;
    [...attrs].forEach((attr) => {
      const attrName = attr.name;
      // 判斷是否為 v- 開頭的屬性
      if (self.isDirective(attrName)) {
        const dir = attrName.substring(2); // 指令名稱
        const exp = attr.value; // 指令內容
        // 事件屬性
        if (self.isEventDirective(dir)) {
          self.directives['eventHandler'](node, self._getDataVal(exp), dir, self.$data);
        // 一般
        } else {
          self.directives[dir](node, self._getDataVal(exp));
        }
        // 移除專用屬性
        node.removeAttribute(attrName);
      }
    });
  },
  /* -- 工具方法 -- */
  isElementNode(node) {
    return node.nodeType === 1;
  },
  isTextNode(node) {
    return node.nodeType === 3;
  },
  hasChildNodes(node) {
    return node.childNodes && node.childNodes.length;
  },
  isDirective(attrName) {
    return attrName.indexOf('v-') == 0;
  },
  isEventDirective(dir) {
    return dir.indexOf('on') === 0;
  },
  _getDataVal(exp) {
    let val = this.$data;
    exp = exp.split('.');
    exp.forEach((k) => {
      val = val[k];
    });
    return val;
  },
  /* 指令清單 */
  directives: {
    text(node, value) {
      node.textContent = value;
    },
    html(node, value) {
      node.innerHTML = value;
    },
    show(node, value) {
      node.style.display = Boolean(value) ? null : 'none';
    },
    eventHandler(node, value, dir, data) {
      const eventType = dir.split(':')[1];
      const fn = value;
      if (eventType && fn) {
        node.addEventListener(eventType, fn.bind(data), false);
      }
    }
  }
}

以上只完成了一般標籤節點的編譯,我們還剩文字節點的編譯須完成,此部分因為牽涉到標籤模板的概念,必須岔開來説明,請見諒~

題外話:標籤模板編譯

標籤模板編譯的實踐方式也有很多,常見的像是直接比對法,或是正則比對法等等,我們這次使用的是 new Function 的方式,用正則比對並將字串替換後放入 new Function 來幫助我們編譯字串中的所有變數,render 方法代碼如下:

function removeWrapper(arr) {
  let ret = [];
  arr.forEach((exp) => {
    ret.push(exp.replace(/[\{|\}]/g, '').trim());
  });
  return ret;
}

function render(str, data) {
  const self = this;
  let exps = null;
  str = String(str);
  const t = function(str) {
    const re = /\{\{\s*([^\}]+)?\s*\}\}/g;
    exps = self.removeWrapper(str.match(re));
    str = str.replace(re, '" + data.$1 + "');
    return new Function('data', 'return "'+ str +'";');
  };
  let r = t(str);
  return {
    exps,
    value: r(data)
  };
}

具體實踐的原理不是本篇的重點,就不深入討論了,此簡單的 render 函數會返回編譯完成的字串,以及所有使用到的 exps,之所以要得到 exps 是因為後續加入 watchers 時必須用到。

回歸正題:Compiler 模組的實踐

我們將剛剛上面的 render 函數加入 prototype 中,並繼續完成 compileText 方法:

Compiler.prototype = {
  // 上略...
  // 第一步:分析節點類型
  compileElement(el) {
    const childNodes = el.childNodes, self = this;
    childNodes.forEach((node) => {
      if (self.isElementNode(node)) {
        self.compile(node);
      } else if (self.isTextNode(node)) {
        self.compileText(node);
      }
      // 若節點中還有子節點,遞迴此步驟
      if (self.hasChildNodes(node)) {
        self.compileElement(node);
      }
    });
  },
  // 第二步:編譯不同類型的節點
  // 1. 編譯標籤:比對屬性指令,並調用對應的指令函數
  // 略...
  // 2. 字串編譯:比對字串中所有用到的 exp
  compileText(node) {
    const text = node.textContent,
          self = this,
          reg = /\{\{(.*)\}\}/;
    if (reg.test(text)) {
      const { exps, value } = self.render(text.trim(), self.$data);
      self.directives.text(node, value);
    }
  },
  render(str, data) {
    const self = this;
    let exps = null;
    str = String(str);
    const t = function(str) {
      const re = /\{\{\s*([^\}]+)?\s*\}\}/g;
      exps = self.removeWrapper(str.match(re));
      str = str.replace(re, '" + data.$1 + "');
      return new Function('data', 'return "'+ str +'";');
    };
    let r = t(str);
    return {
      exps,
      value: r(data)
    };
  },
  removeWrapper(arr) {
    let ret = [];
    arr.forEach((exp) => {
      ret.push(exp.replace(/[\{|\}]/g, '').trim());
    });
    return ret;
  },
  // 略...
  /* 指令清單 */
  directives: {
    text(node, value) {
      node.textContent = value;
    },
    html(node, value) {
      node.innerHTML = value;
    },
    show(node, value) {
      node.style.display = Boolean(value) ? null : 'none';
    },
    eventHandler(node, value, dir, data) {
      const eventType = dir.split(':')[1];
      const fn = value;
      if (eventType && fn) {
        node.addEventListener(eventType, fn.bind(data), false);
      }
    }
  }
}

以上我們已經完成了基本的初次編譯了,接下來要在每個編譯動作完成時,都加入一個 watcher 來幫助我們之後更新資料時,能夠再次進行局部編譯的動作,因此我們將 compile 及 compileText 方法做點修改如下:

Compiler.prototype = {
  // 上略...
  compile(node) {
    const attrs = node.attributes, self = this;
    [...attrs].forEach((attr) => {
      const attrName = attr.name;

      if (self.isDirective(attrName)) {
        const dir = attrName.substring(2);
        const exp = attr.value;
        // 事件屬性
        if (self.isEventDirective(dir)) {
          self.directives['eventHandler'](node, self._getDataVal(exp), dir, self.$vm);
        // 一般
        } else {
          self.directives[dir](node, self._getDataVal(exp));
          new Watcher(this.$data, exp, function(value) {
            self.directives[dir](node, value);
          });
        }
        node.removeAttribute(attrName);
      }
    });
  },
  compileText(node) {
    const text = node.textContent,
          self = this,
          reg = /\{\{(.*)\}\}/;
    if (reg.test(text)) {
      const { exps, value } = self.render(text.trim(), self.$data);
      self.directives.text(node, value);
      // 字串節點中,所有用到的 exp 都需依序加入監聽器
      exps.forEach((exp) => {
        new Watcher(this.$data, exp, function() {
          const { value } = self.render(text.trim(), self.$data);
          self.directives.text(node, value);
        });
      });
    }
  },
  // 下略...
}

到此為止,我們完成了基本的 Compiler 了,接著繼續講到 Watcher 模組~!!加油加油!

Watcher 模組的實踐

經過 Compiler 的實作後,接下來最重要的 Watcher 扮演著串起 Compiler 及 Observer 溝通的橋樑,也是整個 MVVM 的靈魂,前面最開頭我們提到了在 Dep.target 上掛載 watcher 實例,接著必須在創建 watcher 實例時,對指定的資料進行一次 get 的動作,才能強制將 watcher 加入訂閱器中,具體實現如下:

function Watcher(data, exp, cb) {
  this.$data = data;
  this.$exp = exp;
  this.$cb = cb;
  this.init();
}

Watcher.prototype = {
  update() {
    this.run();
  },
  init() {
    this._hasInit = false;
    this.value = this.get(); // 初始化同時自動調用
    this._hasInit = true;
  },
  run() {
    const value = this.get();
    const oldValue = this.value;
    if (value !== oldValue) {
      this.value = value;
      this.$cb.call(this.$data, value, oldValue); // 調用綁定的 compile 動作
    }
  },
  get() {
    !this._hasInit && (Dep.target = this); // 掛載 watcher(只可在初始化時掛載,避免重複掛載)
    const value = this._getDataVal(this.$exp); // 強制調用一次目標的 get 觸發劫持動作
    Dep.target = null; // 加入完成,必須移除暫存的 watcher
    return value;
  },
  /* 工具方法 */
  _getDataVal(exp) {
    let val = this.$data;
    exp = exp.split('.');
    exp.forEach((k) => {
      val = val[k];
    });
    return val;
  }
}

恭喜你!!看到這邊您已經掌握了整個架構的主要核心模組了,最後只剩下 MVVM 構造器,也就是使用者調用的模組摟~

MVVM 模組的實踐(第二部分)

MVVM 的角色就是,將前面所有模組組合起來,使用 Observer 劫持資料變化,透過 Compiler 解析編譯模板,透過 Watcher 完成 Observer 對 Compiler 的聯繫,另外最重要的就是將所有資料綁定到 MVVM 的實例上,方便使用者輕鬆使用,具體構造器代碼如下:

function MVVM(options) {
  this.$options;
  this.$data = this.$options.data;
  this.$computed = this.$options.computed;
  this.$methods = this.$options.methods;
  
  // 先將所有資料綁定到 MVVM 實例上
  this.walk(this.$data, (key) => this._proxyData(key));
  this.walk(this.$computed, (key) => this._proxyComputed(key));
  this.walk(this.$methods, (key) => this._proxyMethods(key));
  
  // 再進行初始化,因為 Compiler 必須用到所有的 computed 跟 methods
  this.$el = this.$options.el || document.body;
  this.init();
}

MVVM.prototype = {
  init() {
    new Observer(this.$data);
    new Compiler(this.$el, this);
  },
  walk(data, fn) {
    return Object.keys(data).forEach(fn);
  },
  /* Proxys */
  _proxyData(key) {
    const self = this;
    Object.defineProperty(self, key, {
      enumerable: true,
      configurable: false,
      get() {
        return self.$data[key];
      },
      set(nV) {
        self.$data[key] = nV;
      }
    });
  },
  _proxyComputed(key) {
    const self = this;
    const computed = this.$computed;
    if (typeof computed === 'object') {
      Object.defineProperty(self, key, {
        get: typeof computed[key] === 'function' 
                ? computed[key]
                : computed[key].get,
        set: typeof computed[key] !== 'function'
                ? computed[key].set
                : function() {}
      });
    }
  },
  _proxyMethods(key) {
    const self = this;
    const methods = this.$methods;
    if (typeof methods === 'object') {
      Object.defineProperty(self, key, {
        get: typeof methods[key] === 'function' 
                ? () => methods[key] 
                : function() {},
        set: function() {}
      });
    }
  }
}

到此,一個簡單的 MVVM 架構算是完成了,實際已可簡單編譯模板以及加入事件了,使用範例如下:

<div id="app">
  Hello World
  <h3>Title</h3>
  <p>{{ info }}</p>

  <div>
    <button v-on:click="toggleName">Toggle</button>
    <p v-show="showName">{{ name }}</p>
  </div>
</div>
const vm = new MVVM({
  el: '#app',
  data: {
    name: 'Johnny',
    age: 100,
    showName: true
  },
  computed: {
    info() {
      return this.name + ' ' + this.age;
    }
  },
  methods: {
    toggleName() {
      this.showName = !this.showName;
    }
  }
});

以上,就是基本 MVVM 的概念流程實作,當然文中有許多地方一定還可以改善寫法或不夠周全的部分,也請各路高手多多包涵,本文僅作為 MVVM 加深理解以及筆記的方式存在,再次感謝您的閱讀~

參考文獻:

  • Model-View-ViewModel

  • 剖析Vue实现原理 - 如何实现双向绑定mvvm

  • js 模板编译的实现

Share On:

FacebookLINEMessengerTelegram
最近更新: 2025/6/1 下午2:35
Contributors: Johnny Wang, johnnywang1994, Lindy Liao
Prev
MVC, MVVM, MVI 軟體設計架構