ChatGPT教我用200行程式碼寫一個簡版Vue框架

2023-06-10 12:00:17

AI 是未來最好的老師

最近,我正在準備一份關於 Vue 基礎的學習材料。期間我突發奇想:能否利用現在熱門的 ChatGPT 幫我建立學習內容?其實 Vue 本身不難學,特別是基礎用法,但是,如果你想深入掌握 Vue,就要通過閱讀 Vue 的原始碼來了解其原理。然而,不是每個人都有足夠的時間和耐心閱讀 Vue 的原始碼。於是我花了一個多小時與 ChatGPT 對話,引導它生成一些展示 Vue 原理的範例程式碼。在這個過程中,我只是充當一個提問者的角色,讓 ChatGPT 根據我的問題生成我想要的程式碼。結果讓我有些驚喜,但也不完全出乎意料。鑑於目前 AI 快速發展的趨勢,我相信到了 2024 年,將會有很多職業被 AI 所替代,儘管那時 AI 還不能完全取代老師,但是它有潛力成為一個私人輔教工具。我相信,未來並不是 AI 取代人類,而是懂得用 AI 的人取代不懂用 AI 的人。接下來,就讓我們一起見識這位 AI 助教的水平吧。

本篇文章主要講以下內容:

  • 讓 AI 列出 Vue 從入門到高階的學習大綱
  • 嘗試讓 AI 給出能解釋 Vue 原理的程式碼範例
  • 開始學習 Vue 生命週期的原理
  • 學習 Vue 模板語法和指令的原理
  • 學習 Vue 資料監聽和計算屬性的原理
  • 學習 Vue 事件處理和方法的原理
  • 學習 Vue 插槽(slot)的原理
  • 用 200 行程式碼實現一個簡版 Vue 框架

讓 AI 列出學習 Vue 的大綱

為了系統地學習 Vue,需要先制定一個學習大綱。結合我對 ChatGPT 的瞭解,給它提的第一個問題,就得到比較滿意的回覆,以下就是我提的問題原文:

請列出一個Vue.js從入門到高階的學習大綱

ChatGPT 的回答如下:

當涉及Vue.js的學習大綱時,下面是一個從入門到高階的學習路線,涵蓋了Vue.js的核心概念和高階主題:

  1. HTML、CSS和JavaScript基礎
    • HTML元素和標籤
    • CSS選擇器和樣式
    • JavaScript基本語法和概念
  2. Vue.js入門
    • Vue.js的介紹和特點
    • Vue.js的安裝和設定
    • Vue範例和生命週期勾點
    • Vue模板語法和指令
    • 資料繫結和計算屬性
    • 事件處理和方法
  3. Vue元件
    • 元件的基本概念和組成
    • 元件的註冊和使用
    • 元件的通訊方式(props、事件和vuex)
    • 動態元件和非同步元件
    • 插槽(slot)和作用域插槽
  4. Vue路由
    • Vue Router的安裝和設定
    • 路由的基本用法(導航、引數傳遞)
    • 巢狀路由和命名路由
    • 路由守衛和導航解析
  5. Vue狀態管理(Vuex)
    • Vuex的核心概念和使用場景
    • Vuex的安裝和設定
    • State、Mutation、Action和Getter
    • 模組化和名稱空間
  6. Vue進階
    • 自定義指令
    • 過濾器(Filter)
    • 混入(Mixin)
    • 外掛(Plugin)
    • 自定義過渡和動畫
    • 伺服器端渲染(SSR)
  7. Vue與外部庫的整合
    • 使用Axios進行HTTP請求
    • 使用Vue與第三方庫(如Chart.js、Google Maps等)進行整合
    • 使用Vue和Webpack進行專案構建
  8. Vue最佳實踐和效能優化
    • 元件的拆分和複用
    • 合理使用計算屬性和偵聽器
    • 避免不必要的渲染
    • 非同步元件和程式碼拆分
    • 優化網路請求和資料載入
  9. Vue測試
    • 單元測試和端到端測試的概念
    • 使用Vue Test Utils進行單元測試
    • 使用Cypress進行端到端測試
  10. Vue專案實戰
    • 基於Vue.js構建一個完整的應用程式
    • 使用Vue Router和Vuex進行狀態管理
    • 整合其他庫和工具
    • 測試和優化應用程式

請注意,這個學習大綱只是一個指導,你可以根據自己的學習進度和需求進行調整和擴充套件。此外,學習Vue.js最好通過實踐專案和閱讀官方檔案來加深理解和掌握。

以上內容是 ChatGPT 生成的,它具備一定的普適性,但我並不準備按照這個大綱來講,我希望大家通過閱讀 Vue 的原始碼,來學習和掌握 Vue 的用法。比如,很多人都覺得 Vue 資料雙向繫結的用法很簡單,但要讓自己來模擬實現資料雙向繫結卻不簡單。學習 Vue 的用法需要掌握其原理主要有以下四個原因(這些原因也是由 ChatGPT 生成的):

  • 深入理解框架:通過學習 Vue 的原理,你將更好地理解 Vue 框架的設計和工作原理。這包括 Vue 的響應式資料繫結、虛擬 DOM、元件化架構等核心概念。理解這些原理可以幫助你更好地使用 Vue,更高效地開發和偵錯應用程式。

  • 自定義擴充套件和優化:掌握 Vue 的原理使你能夠進行自定義擴充套件和優化。你可以根據專案需求自定義 Vue 的指令、外掛、過濾器等,以及優化效能、減少資源消耗等方面。理解原理可以讓你更靈活地應對各種開發場景。

  • 故障排查和偵錯:在開發過程中,難免會遇到一些問題和錯誤。如果你瞭解 Vue 的原理,你可以更快地定位和解決問題。理解原理可以幫助你深入瞭解 Vue 的內部機制,識別潛在的錯誤來源,並採取正確的解決方法。

  • 跨框架學習和遷移:掌握 Vue 的原理可以為你日後學習其他前端框架奠定基礎。許多前端框架在概念和原理上有相似之處,因此,一旦你掌握了 Vue 的原理,你將更容易理解和學習其他框架,如 React、Angular 等。此外,如果你需要遷移專案到其他框架,理解原理可以幫助你更好地理解新框架的工作方式。


讓 AI 給出解釋 Vue 原理的程式碼範例

該怎樣向 ChatGPT 提問,才能讓它生成模擬實現 Vue 某個特性的範例程式碼?按照以往經驗,起初我是這麼提問的:

請給一個簡單的JS程式碼實現的例子,說明Vue的生命週期及生命週期勾點的實現原理

ChatGPT 的回答如下:

以下是一個簡單的JavaScript程式碼範例,展示了Vue的生命週期及生命週期勾點的實現原理:

<!DOCTYPE html>
<html>
<head>
  <title>Vue Lifecycle Example</title>
  <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
</head>
<body>
  <div id="app">
    <p>{{ message }}</p>
    <button @click="updateMessage">Update Message</button>
  </div>

  <script>
    var app = new Vue({
      el: '#app',
      data: {
        message: 'Hello, Vue!'
      },
      beforeCreate: function() {
        console.log('beforeCreate hook');
      },
      created: function() {
        console.log('created hook');
      },
      beforeMount: function() {
        console.log('beforeMount hook');
      },
      mounted: function() {
        console.log('mounted hook');
      },
      beforeUpdate: function() {
        console.log('beforeUpdate hook');
      },
      updated: function() {
        console.log('updated hook');
      },
      beforeDestroy: function() {
        console.log('beforeDestroy hook');
      },
      destroyed: function() {
        console.log('destroyed hook');
      },
      methods: {
        updateMessage: function() {
          this.message = 'Updated message';
        }
      }
    });
  </script>
</body>
</html>

顯然,這只是 Vue 生命週期的用法範例,並不是我們想要的模擬 Vue 生命週期原理的範例。所以,掌握 AI 的提示技巧很重要。未來,AI 提示師可能會成為一種職業,學好 AI 提示技巧就像當下使用智慧手機一樣,將成為一種生存技能。


用 AI 學習 Vue 生命週期的原理

前面的程式碼已經讓我們瞭解如何使用 Vue 的生命週期。為了便於模擬,我們使用 Vue 的選項式 API,並且對生命週期的用法做一點調整,假設模擬用法的範例程式碼如下所示:

var app = new Vue({
  el: '#app',
  data: {
    message: 'Hello, Vue!'
  },
  beforeCreate: function() {
    console.log('beforeCreate hook');
  },
  created: function() {
    console.log('created hook');
  },
  beforeMount: function() {
    console.log('beforeMount hook');
  },
  mounted: function() {
    console.log('mounted hook');
  },
  beforeUpdate: function() {
    console.log('beforeUpdate hook');
  },
  updated: function() {
    console.log('updated hook');
  },
  render: function() {
    return '<p>' + this.message + '</p>';
  }
});

我們調整的地方,就是用 render 方法代替 methods,目的是將 render 方法返回的內容渲染到 ID 為 app 的 DOM 元素上。在 render 方法裡會獲取 this.message 的內容,該內容為在 data 物件裡定義的 message 屬性的值。

大家先思考一下如何實現上述功能,重點在於實現 Vue 設定物件的 el、data、beforeCreate、created、beforeMount、mounted、beforeUpdate、updated、render 屬性。

接下來我們看 ChatGPT 怎樣實現上述屬性,以下是我給 ChatGPT 的提示語:

請給出一段程式碼,模擬實現Vue.js類似生命週期效果的範例程式碼

ChatGPT 的回答如下:

以下是一個簡單的JavaScript程式碼範例,模擬實現類似Vue.js生命週期的效果:

function Vue(options) {
  this.$options = options;

  if (typeof options.beforeCreate === 'function') {
    options.beforeCreate.call(this);
  }

  this._data = typeof options.data === 'function' ? options.data() : options.data;

  this._proxyData();

  if (typeof options.created === 'function') {
    options.created.call(this);
  }

  this.$mount(options.el);
}

Vue.prototype.$mount = function(el) {
  this.$el = document.querySelector(el);

  if (typeof this.$options.beforeMount === 'function') {
    this.$options.beforeMount.call(this);
  }

  this.render();

  if (typeof this.$options.mounted === 'function') {
    this.$options.mounted.call(this);
  }
};

Vue.prototype._proxyData = function() {
  var self = this;
  Object.keys(this._data).forEach(function(key) {
    Object.defineProperty(self, key, {
      get: function() {
        return self._data[key];
      },
      set: function(newValue) {
        self._data[key] = newValue;
        if (typeof self.$options.beforeUpdate === 'function') {
          self.$options.beforeUpdate.call(self);
        }
        self.render();
        if (typeof self.$options.updated === 'function') {
          self.$options.updated.call(self);
        }
      }
    });
  });
};

Vue.prototype.render = function() {
  if (typeof this.$options.render === 'function') {
    this.$el.innerHTML = this.$options.render.call(this);
  }
};

// 使用範例
var app = new Vue({
  el: '#app',
  data: {
    message: 'Hello, Vue!'
  },
  beforeCreate: function() {
    console.log('beforeCreate hook');
  },
  created: function() {
    console.log('created hook');
  },
  beforeMount: function() {
    console.log('beforeMount hook');
  },
  mounted: function() {
    console.log('mounted hook');
  },
  beforeUpdate: function() {
    console.log('beforeUpdate hook');
  },
  updated: function() {
    console.log('updated hook');
  },
  render: function() {
    return '<p>' + this.message + '</p>';
  }
});

在這個範例中,我們通過定義一個 Vue 建構函式來模擬 Vue.js 的生命週期。建構函式接收一個設定物件作為引數,並在範例化過程中觸發各個生命週期勾點函數。

在建構函式中,我們首先判斷設定物件中是否定義了 beforeCreatecreated 勾點函數,並在相應的時機呼叫。然後,我們處理資料物件,讀取設定物件裡的 data 屬性,如果 data 是函數則執行獲取其返回值,如果是物件則直接賦值,然後通過 _proxyData 方法將資料物件中的屬性代理到 Vue 範例上,以便實現資料的雙向繫結。

接下來,在 $mount 方法中,我們判斷設定物件中是否定義了 beforeMountmounted 勾點函數,並在相應的時機呼叫。在 $mount 方法中,我們呼叫 render 方法將模板渲染到指定的 DOM 元素上。

_proxyData 方法中,我們使用 Object.defineProperty 將資料物件的屬性代理到 Vue 範例上,並在屬性的 set 方法中觸發 beforeUpdaterenderupdated 勾點,意味著只要資料物件的屬性發生變化,就會觸發檢視更新。

render 方法中,我們判斷設定物件中是否定義 render 函數,通過該函數獲取渲染的內容,再賦值給 $el 元素的 innerHTML 屬性,其中 $el 元素在 $mount 方法中使用 document.querySelector 初始化。

最後,我們使用範例中的設定物件來範例化 Vue ,通過模擬渲染觸發生命週期勾點函數,實現類似 Vue.js 的各個生命週期的效果。

上述針對程式碼的解釋也是由 ChatGPT 生成的,我只是補充了一些說明。可見 ChatGPT 這方面能力已經不亞於專業私教,它不僅節省我很多備課時間,甚至某些部分比我講得還好。

以上 JavaScript 程式碼還不能直接執行,我們將該段程式碼儲存為 lifecycle.js 檔案,然後建立一個 index.html 網頁並參照該檔案,網頁程式碼如下:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vue</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="./lifecycle.js"></script>
  </body>
</html>

這樣,我們直接開啟 index.html 就能執行 lifecycle.js,然後在瀏覽器檢視渲染結果,並在控制檯檢視紀錄檔輸出。另外,我們可以在控制輸入 app.message = 'ChatGPT' 來驗證資料繫結以及頁面更新機制。


用 AI 學習 Vue 模板語法和指令的原理

按照上面的學習路徑,我們在學習模板語法和指令之前,同樣先給出便於模擬的用法範例程式碼:

var app = new Vue({
  el: '#app',
  data: {
    message: 'Hello, Vue!',
    inputValue: 'ChatGPT'
  },
  template: `
    <div>
      <p>{{ message }}</p>
      <input v-model="inputValue" type="text">
      <p v-text="inputValue"></p>
    </div>
  `
});

我們重點關注以下涉及模板和指令的 HTML 片段:

<div>
  <p>{{ message }}</p>
  <input v-model="inputValue" type="text">
  <p v-text="inputValue"></p>
</div>

這個 HTML 片段本身就是模板的內容,裡面使用了一個模板語法和兩個指令,模板語法是 {{ message }},指令分別是 v-modelv-text

同樣的,大家先思考一下自己如何實現,然後再看 ChatGPT 怎樣實現模板語法和指令,以下是我給的提示語:

請給出一段程式碼,模擬實現Vue.js類似模板語法和指令的範例程式碼

ChatGPT 的回答如下:

以下是一個簡單的JavaScript程式碼範例,模擬實現類似Vue.js的模板語法和指令:

function Vue(options) {
  this.$options = options;
  this._data = typeof options.data === 'function' ? options.data() : options.data;
  this._compileTemplate();
}

Vue.prototype._compileTemplate = function() {
  var self = this;
  var template = this.$options.template || '';

  var evalExpression = function(expression) {
    with (self._data) return eval(expression);
  }

  var compiledTemplate = template.replace(/\{\{(.*?)\}\}/g, function(match, expression) {
    var value = evalExpression(expression);
    return value !== undefined ? value : '';
  });

  var element = document.querySelector(this.$options.el);
  element.innerHTML = compiledTemplate.trim();

  element.querySelectorAll('[v-model]').forEach(function(element) {
    var value = element.getAttribute('v-model');
    element.value = self._data[value];
    element.addEventListener('input', function(event) {
      self._data[value] = event.target.value;
    });
  });

  element.querySelectorAll('[v-text]').forEach(function(element) {
    var value = element.getAttribute('v-text');
    element.textContent = self._data[value];
  });
};

// 使用範例
var app = new Vue({
  el: '#app',
  data: {
    message: 'Hello, Vue!',
    inputValue: 'ChatGPT'
  },
  template: `
    <div>
      <p>{{ message }}</p>
      <input v-model="inputValue" type="text">
      <p v-text="inputValue"></p>
    </div>
  `
});

在這個範例中,我們通過定義一個 Vue 建構函式來模擬 Vue.js 的模板語法和指令。建構函式接收一個設定物件作為引數,並在範例化過程中編譯模板。

_compileTemplate 方法中,我們首先獲取設定物件中的模板字串,並使用正規表示式匹配 {{ expression }} 的部分。然後,我們使用 eval 函數根據表示式動態求值,將值替換回模板字串中。接下來,我們根據設定物件中的 el 屬性獲取對應的 DOM 元素,並將編譯後的模板字串賦值給該元素的 innerHTML 屬性。

然後,我們通過 querySelectorAll 方法獲取所有具有 v-model 屬性的元素,並遍歷每個元素。在遍歷過程中,我們解析 model 指令,將元素的值設定為對應的資料屬性值,並新增 input 事件監聽器,以實現 inputValue 簡單的雙向繫結。

接著,我們通過 querySelectorAll 方法獲取所有具有 v-text 屬性的元素,並遍歷每個元素。在遍歷過程中,我們解析 text 指令,將元素的文字內容設定為對應的資料屬性值。注意:該指令我們沒有實現 inputValue 的雙向繫結。

最後,我們使用範例中的設定物件來範例化 Vue,並傳入模板字串。通過模擬解析模板和執行指令,我們實現了類似 Vue.js 的模板語法和指令的效果,要檢視效果請在瀏覽器中開啟 index.html 頁面。

以上程式碼並不完全是 ChatGPT 生成的,因為在偵錯過程中,發現兩個問題需要修正才能正常執行:

  • 第一個問題是 ChatGPT 想直接使用 eval 函數根據表示式動態求值,ChatGPT 生成的程式碼為 eval(expression),其中 expression 為字串 " message ",請注意 message 兩邊有空格。直接呼叫 eval 函數獲取 message 的值會報錯,因為全域性物件裡並沒有定義 message 屬性,而表示式 {{ message }} 的本意是獲取 this._data 物件的 message 屬性值。為此我新增了一個 evalExpression 函數,通過 with 語句將 this._data 設定為全域性物件,這樣才能正確獲取 this._data.message 的值。但 with 語句在嚴格模式下不建議使用,因此並不是最優解,有興趣的同學可以研究一下最優解。

  • 第二個問題是 ChatGPT 想使用 querySelectorAll('[v-]') 同時獲取 v-modelv-text 指令的內容,但實際偵錯過程中並不能獲取。於是我拆解成兩個語句:querySelectorAll('[v-model]')querySelectorAll('[v-text]') 分別獲取指令內容。

由此可見,我們還不能完成信任 ChatGPT 生成的程式碼。如果發現程式碼有問題,你可以向它提出質疑,它會說抱歉然後重新生成新的程式碼,更新後的程式碼是否正確,依然需要你再次驗證。


用 AI 學習 Vue 資料監聽和計算屬性的原理

接下來要學習的仍是比較基礎的資料監聽和計算屬性,其實前面在生命週期的例子裡已經包含了資料雙向繫結,資料監聽和計算屬性的原理跟資料雙向繫結很類似。

同樣的,我們先給出便於模擬的用法範例程式碼:

var app = new Vue({
  data: {
    message: 'Hello, Vue!',
    firstName: 'John',
    lastName: 'Doe'
  },
  computed: {
    fullName: function() {
      return this.firstName + ' ' + this.lastName;
    }
  },
  watch: {
    message: function(newValue) {
      console.log('Message changed:', newValue);
    }
  }
});

console.log(app.message);       // Output: Hello, Vue!
app.message = 'Hello, Vue.js!'; // Output: Message changed: Hello, Vue.js!
console.log(app.message);       // Output: Hello, Vue.js!
console.log(app.fullName);      // Output: John Doe
app.message = 'New message';    // Output: Message changed: New message

從範例程式碼可以看出,設定物件新增了 computedwatch 屬性,分別定義了計算屬性 fullName,以及監聽 message 屬性變化的函數。

大家先思考一下自己如何實現,然後再看 ChatGPT 怎樣實現資料監聽和計算屬性,以下是我給的提示語:

請給出一段程式碼,模擬實現Vue.js類似資料監聽和計算屬性的範例程式碼

ChatGPT 的回答如下:

要模擬實現Vue.js類似資料監聽和計算屬性的功能,可以使用Object.defineProperty來定義屬性的get和set方法,並使用watch函數來監聽屬性的變化。以下是一個範例程式碼:

function Vue(options) {
  this._data = options.data;
  this._computed = options.computed;
  this._watch = options.watch;

  this._proxyData();
  this._createComputed();
  this._createWatchers();
}

Vue.prototype._proxyData = function() {
  var self = this;
  Object.keys(this._data).forEach(function(key) {
    Object.defineProperty(self, key, {
      get: function() {
        return self._data[key];
      },
      set: function(newValue) {
        self._data[key] = newValue;
      }
    });
  });
};

Vue.prototype._createComputed = function() {
  var self = this;
  var computed = this._computed || {};

  Object.keys(computed).forEach(function(key) {
    Object.defineProperty(self, key, {
      get: function() {
        return computed[key].call(self);
      }
    });
  });
};

Vue.prototype._createWatchers = function() {
  var self = this;
  var watch = this._watch || {};

  Object.keys(watch).forEach(function(key) {
    var callback = watch[key]
    var value = self._data[key];

    Object.defineProperty(self._data, key, {
      get: function() {
        return value;
      },
      set: function(newValue) {
        value = newValue;
        callback.call(self, newValue);
      }
    });
  });
};

// 使用範例
var app = new Vue({
  data: {
    message: 'Hello, Vue!',
    firstName: 'John',
    lastName: 'Doe'
  },
  computed: {
    fullName: function() {
      return this.firstName + ' ' + this.lastName;
    }
  },
  watch: {
    message: function(newValue) {
      console.log('Message changed:', newValue);
    }
  }
});

console.log(app.message);       // Output: Hello, Vue!
app.message = 'Hello, Vue.js!'; // Output: Message changed: Hello, Vue.js!
console.log(app.message);       // Output: Hello, Vue.js!
console.log(app.fullName);      // Output: John Doe
app.message = 'New message';    // Output: Message changed: New message

在這個範例中,我們定義了一個 Vue 建構函式,它接收一個包含 datacomputedwatch 的選項物件。

_proxyData 方法中,我們使用 Object.definePropertyVue 範例上的屬性定義了 getset 方法,將其代理到 _data 物件上。

_createComputed 方法中,我們通過遍歷 computed 物件,為每個計算屬性定義了 get方法,使其能夠被當做普通屬性使用。

_createWatchers 方法中,我們通過遍歷 watch 物件,為每個屬性使用 Object.defineProperty 監聽 _data 物件中該屬性的變化,並在變化時觸發回撥函數。

最後,我們使用範例中的設定物件來範例化 Vue,並傳入計算屬性和監聽屬性的函數,通過執行一段指令碼讀取和更改設定屬性的值,實現了類似 Vue.js 的資料監聽和計算屬性的效果。

與之前一樣,以上程式碼並不完全是 ChatGPT 生成的。在原始生成的程式碼中,監聽屬性需要執行 app.$watch('message', ...) 這樣的語句,即呼叫 app$watch 方法來新增監聽函數。修正後的監聽方式更符合 Vue 的官方用法。


用 AI 學習 Vue 事件處理和方法的原理

事件處理在元件開發過程中是很重要的一環,接下來我們模擬實現 Vue 的事件處理和方法的機制,以下是便於模擬的用法範例程式碼:

var app = new Vue({
  data: {
    message: 'Hello, Vue!'
  },
  methods: {
    greet: function() {
      this.$emit('greet', this.message);
    },
    updateMessage: function(newMessage) {
      this.message = newMessage;
    }
  },
});

app.$on('greet', function(message) {
  console.log('Greet:', message);
});

app.greet(); // Output: Greet: Hello, Vue!
app.updateMessage('Hello, World!');
app.greet(); // Output: Greet: Hello, World!

從範例程式碼可以看出,設定物件新增了 methods 屬性,用於定義範例的方法,同時新增內建的 $emit$on 方法,用於丟擲事件和監聽事件。

大家先思考一下自己如何實現,然後再看 ChatGPT 怎樣實現事件處理和方法,以下是我給的提示語:

請給出一段程式碼,模擬實現Vue.js類似事件處理和方法的範例程式碼

ChatGPT 的回答如下:

以下是一個簡單的JavaScript程式碼範例,模擬實現類似Vue.js的事件處理和方法:

function EventBus() {
  this._events = {};
}

EventBus.prototype.on = function(eventName, callback) {
  if (!this._events[eventName]) {
    this._events[eventName] = [];
  }
  this._events[eventName].push(callback);
};

EventBus.prototype.emit = function(eventName, payload) {
  if (this._events[eventName]) {
    this._events[eventName].forEach(function(callback) {
      callback(payload);
    });
  }
};

function Vue(options) {
  this._data = typeof options.data === 'function' ? options.data() : options.data;
  this._methods = options.methods;
  this._eventBus = new EventBus();

  this._proxyData();
  this._proxyMethods();
}

Vue.prototype._proxyData = function() {
  var self = this;
  Object.keys(this._data).forEach(function(key) {
    Object.defineProperty(self, key, {
      get: function() {
        return self._data[key];
      },
      set: function(newValue) {
        self._data[key] = newValue;
      }
    });
  });
};

Vue.prototype._proxyMethods = function() {
  var self = this;
  var methods = this._methods;
  if (methods) {
    Object.keys(methods).forEach(function(key) {
      self[key] = methods[key].bind(self);
    });
  }
};

Vue.prototype.$emit = function(eventName, payload) {
  this._eventBus.emit(eventName, payload);
};

Vue.prototype.$on = function(eventName, callback) {
  this._eventBus.on(eventName, callback);
};

// 使用範例
var app = new Vue({
  data: {
    message: 'Hello, Vue!'
  },
  methods: {
    greet: function() {
      this.$emit('greet', this.message);
    },
    updateMessage: function(newMessage) {
      this.message = newMessage;
    }
  },
});

app.$on('greet', function(message) {
  console.log('Greet:', message);
});

app.greet(); // Output: Greet: Hello, Vue!
app.updateMessage('Hello, World!');
app.greet(); // Output: Greet: Hello, World!

在這個範例中,我們首先使用 EventBus 物件來模擬事件匯流排。 EventBus 物件具有 on 方法用於監聽事件和 emit 方法用於觸發事件。

Vue 建構函式中,我們建立了一個 EventBus 範例並將其賦值給 _eventBus 屬性。

_proxyData 方法中,我們使用 Object.defineProperty 將資料物件的屬性代理到 Vue 範例上,以便實現資料的雙向繫結。

_proxyMethods 方法中,我們將設定物件中的方法系結到 Vue 範例上,以便在範例中可以直接存取和呼叫這些方法。

Vue 原型中,我們定義了 $emit$on 方法。 $emit 方法用於丟擲事件,接收兩個引數:事件名和可選的資料載荷。 $on 方法用於監聽事件,接收兩個引數:事件名和回撥函數。

最後,我們建立了一個 Vue 範例,先在 Vue 的設定物件的 greet 方法使用 $emit 方法觸發 greet 事件,以便觸發回撥函數並輸出訊息。然後,在範例中使用 $on 方法監聽 greet 事件,並且定義回撥函數來輸出訊息。通過這種方式,我們模擬實現了類似 Vue.js 中丟擲事件和接收事件的功能。

與之前一樣,以上程式碼並不完全是 ChatGPT 生成的。在原始生成的程式碼中,並沒有模擬實現丟擲事件和接收事件。我重新給出新的提示語,ChatGPT 才生成 EventBus 的程式碼,然後我再將兩段程式碼融合在一起,才得到上面的範例程式碼。


用 AI 學習 Vue 插槽(slot)的原理

最後,我們來學習 Vue 的插槽。為了模擬插槽的用法,我們還得先模擬 Vue 的元件宣告,在元件裡定義插槽,然後通過模板引入元件,並設定元件插槽的內容,以下是便於模擬的用法範例程式碼:

var HelloComponent = {
  data: function() {
    return {
      name: 'John'
    };
  },
  template: `
    <div>
      <h1>{{ name }}</h1>
      <slot></slot>
    </div>
  `
};

var app = new Vue({
  el: '#app',
  data: {
    message: 'Hello, Vue!'
  },
  components: {
    HelloComponent
  },
  template: `
    <HelloComponent>
      <p>{{ message }}</p>
    </HelloComponent>
  `
});

上述範例程式碼中,我們宣告了一個 HelloComponent 的元件,在該元件的模板中,我們新增了一個 slot 插槽,然後在應用的模板裡引入 HelloComponent 元件,並在該元件裡面新增 <p>{{ message }}</p> 作為插槽的內容。

程式碼執行的結果將生成如下 HTML 程式碼:

<div id="app">
  <hellocomponent>
    <div>
      <h1>John</h1>
      <slot>
        <p>Hello, Vue!</p>
      </slot>
    </div>
  </hellocomponent>
</div>

為了實現的程式碼簡化起見,HelloComponentslot 兩個節點依然保留。大家先思考一下自己如何實現,然後再看 ChatGPT 怎樣實現插槽,以下是我給的提示語:

請給出一段程式碼,模擬實現Vue.js類似插槽(slot)的範例程式碼

ChatGPT 的回答如下:

要模擬實現Vue.js類似插槽(slot)的功能,可以在元件定義中使用特殊的標記來表示插槽,並在元件範例中通過相應的屬性將內容傳遞到插槽中。以下是一個範例程式碼:

function Vue(options) {
  this.$options = options;
  this._data = typeof options.data === 'function' ? options.data() : options.data;
  this._components = options.components || {};

  this._proxyData();
  this._compileTemplate();
  this._proxyComponents();
}

Vue.prototype._proxyData = function() {
  var self = this;
  Object.keys(this._data).forEach(function(key) {
    Object.defineProperty(self, key, {
      get: function() {
        return self._data[key];
      },
      set: function(newValue) {
        self._data[key] = newValue;
      }
    });
  });
};

Vue.prototype._compileTemplate = function() {
  var self = this;
  var el = this.$options.el
  var template = this.$options.template || '';

  var evalExpression = function(expression) {
    with (self) return eval(expression);
  }

  var compiledTemplate = template.replace(/\{\{(.*?)\}\}/g, function(match, expression) {
    var value = evalExpression(expression);
    return value !== undefined ? value : '';
  });

  var element = el ? document.querySelector(el) : document.createElement('div');
  element.innerHTML = compiledTemplate.trim();
  this.$el = el ? element : element.childNodes[0];
};

Vue.prototype._proxyComponents = function() {
  var self = this;
  var components = this._components;

  Object.keys(components).forEach(function(componentName) {
    var component = new Vue(components[componentName]);
    self.$el.querySelectorAll(componentName).forEach(function(element) {
      component.$el.querySelectorAll('slot').forEach(function(slot) {
        slot.innerHTML = element.innerHTML;
      });
      element.innerHTML = component.$el.outerHTML;
    });
  });
};

// 使用範例
var HelloComponent = {
  data: function() {
    return {
      name: 'John'
    };
  },
  template: `
    <div>
      <h1>{{ name }}</h1>
      <slot></slot>
    </div>
  `
};

var app = new Vue({
  el: '#app',
  data: {
    message: 'Hello, Vue!'
  },
  components: {
    HelloComponent
  },
  template: `
    <HelloComponent>
      <p>{{ message }}</p>
    </HelloComponent>
  `
});

在這個範例中,我們定義了一個 Vue 建構函式,它接收一個包含 datatemplatecomponents 的選項物件。

_proxyData 方法中,我們使用 Object.defineProperty 將資料物件的屬性代理到 Vue 範例上,以便實現資料的雙向繫結。

_compileTemplate 方法中,我們首先獲取設定物件中的模板字串,並使用正規表示式匹配 {{ expression }} 的部分。然後,我們使用 eval 函數根據表示式動態求值,將值替換回模板字串中。注意:這裡 eval 前面的 with 語句跟的是 self,而不是之前的 self._data,這是因為本範例中新增了 _proxyData 方法。接下來,我們根據設定物件中的 el 屬性獲取對應的 DOM 元素,如果 DOM 元素不存在,我們就建立一個 div 元素代替,然後再將編譯後的模板字串賦值給該元素的 innerHTML 屬性。接著給 Vue 範例設定 $el 屬性,注意:前面如果用 div 元素代替,則需通過 childNodes[0] 排除該 div 元素。

_proxyComponents 方法中,我們首先獲取設定物件中的元件宣告,然後遍歷所有的元件,根據元件名稱獲取元件物件,建立該物件的 Vue 範例,並通過該範例的 $el 屬性,遍歷所有 slot 插槽,將原始的 innerHTML 設定為插槽的內容,然後重新設定元件的 innerHTML 為範例 $el 元素的 outerHTML 內容。

最後,我們先定義了一個 HelloComponent 物件,在該元件的模板中,新增了一個 slot 插槽。接著再建立一個 Vue 的範例,在設定物件的模板裡引入 HelloComponent 元件,並在該元件裡面新增插槽的內容。通過這種方式,我們模擬實現了類似 Vue.js 中丟擲事件和接收事件的功能。

同樣的,以上程式碼並不完全是 ChatGPT 生成的。在原始生成的程式碼中,還建立了一個 VueComponent 的建構函式,然而卻沒有實現插槽內容的替換,甚至除了使用範例之外,其他地方完全沒有插槽的相關程式碼,就連使用範例程式碼中的 components 物件也是我補上的。


用 200 行程式碼實現一個簡版 Vue 框架

前面我們已經模擬了 Vue 的生命週期、模板語法、指令、雙向繫結、資料監聽、計算屬性、事件處理、方法、元件、插槽,現在是時候把這些模擬的特性融合在一起,構建一個 Vue 框架的簡易版本。以下是便於模擬的用法範例程式碼:

var HelloComponent = {
  emits: ['greet'],
  data: function() {
    return {
      firstName: 'John',
      lastName: 'Doe'
    };
  },
  computed: {
    fullName: function() {
      return this.firstName + ' ' + this.lastName;
    }
  },
  updated: function() {
    this.$emit('greet', this.firstName);
  },
  template: `
    <div>
      <h1>{{ fullName }}</h1>
      <slot></slot>
    </div>
  `
};

var app = new Vue({
  el: '#app',
  data: {
    message: 'Hello, Vue!',
    inputValue: 'ChatGPT'
  },
  watch: {
    message: function(newValue, oldValue) {
      console.log('Message changed:', oldValue, ' -> ', newValue);
    },
    inputValue: function(newValue, oldValue) {
      console.log('InputValue changed:', oldValue, ' -> ', newValue);
    }
  },
  methods: {
    greetMessage: function(message) {
      this.$emit('greet', message);
    },
    updateMessage: function(newMessage) {
      this.message = newMessage;
    }
  },
  components: {
    HelloComponent
  },
  beforeCreate: function() {
    console.log('beforeCreate hook');
  },
  created: function() {
    console.log('created hook');
  },
  beforeMount: function() {
    console.log('beforeMount hook');
  },
  mounted: function() {
    console.log('mounted hook');
  },
  beforeUpdate: function() {
    console.log('beforeUpdate hook');
  },
  updated: function() {
    console.log('updated hook');
  },
  template: `
    <div>
      <HelloComponent v-on:greet="greetMessage">
        <p>{{ message }}</p>
      </HelloComponent>
      <input v-model="inputValue" type="text">
      <p v-text="inputValue"></p>
    </div>
  `
});

app.$on('greet', function(message) {
  console.log('Greet:', message);
});

app.inputValue = 'OpenAI'
app.HelloComponent.firstName = 'Tom';
app.updateMessage('Hello, World!');

這段程式碼在前面的基礎上新增了新功能,比如 app.HelloComponent.firstName 應用可以通過元件名獲取子元件的範例、v-on:greet 監聽子元件的事件等。特別是關於 greet 事件,發生的連環動作依次是:

  • HelloComponent 元件的生命週期 updated 中丟擲 greet 事件,事件的引數為 firstName 屬性。
  • 在 app 應用的模板裡通過 <HelloComponent v-on:greet="greetMessage"> 來宣告監聽 HelloComponent 元件的 greet 事件,事件會觸發 app 應用設定物件裡的 greetMessage 方法。
  • 在 app 應用的 greetMessage 方法中再次往外拋 greet 事件,由應用的範例通過 app.$on('greet', ...) 監聽 greet 事件,輸出 firstName 的值。

以上程式碼執行的結果,輸出的 HTML 頁面程式碼如下:

<div id="app">
  <div>
    <hellocomponent v-on:greet="greetMessage">
      <div>
        <h1>Tom Doe</h1>
        <slot>
          <p>Hello, World!</p>
        </slot>
      </div>
    </hellocomponent>
    <input v-model="inputValue" type="text" />
    <p v-text="inputValue">OpenAI</p>
  </div>
</div>

控制檯輸出的結果如下。另外,可以在控制檯輸入 app.inputValue = 123 等方式觀察資料雙向繫結的效果。

beforeCreate hook
created hook
beforeMount hook
mounted hook
InputValue changed: ChatGPT  ->  OpenAI
beforeUpdate hook
updated hook
Greet: Tom
Message changed: Hello, Vue!  ->  Hello, World!
beforeUpdate hook
updated hook

以下就是本篇文章的精華,只需 200 行程式碼實現的簡版 Vue 框架:

function EventBus() {
  this._events = {};
}

EventBus.prototype.on = function(eventName, callback) {
  if (!this._events[eventName]) {
    this._events[eventName] = [];
  }
  this._events[eventName].push(callback);
};

EventBus.prototype.emit = function(eventName, payload) {
  if (this._events[eventName]) {
    this._events[eventName].forEach(function(callback) {
      callback(payload);
    });
  }
};

function Vue(options) {
  this.$options = options;

  if (typeof options.beforeCreate === 'function') {
    options.beforeCreate.call(this);
  }

  this._data = typeof options.data === 'function' ? options.data() : options.data;
  this._eventBus = new EventBus();
  this._proxyData();
  this._proxyMethods();
  this._createComputed();
  this._createWatchers();

  if (typeof options.created === 'function') {
    options.created.call(this);
  }

  this.$mount();
}

Vue.prototype.$render = function() {
  if (typeof this.$options.render === 'function' && this.$options.el) {
    this.$el = document.querySelector(this.$options.el);
    this.$el.innerHTML = this.$options.render.call(this);
  } else {
    this._compileTemplate();
    this._proxyComponents();
  }
};

Vue.prototype.$mount = function() {
  if (typeof this.$options.beforeMount === 'function') {
    this.$options.beforeMount.call(this);
  }

  this.$render();

  if (typeof this.$options.mounted === 'function') {
    this.$options.mounted.call(this);
  }
};

Vue.prototype._proxyData = function() {
  var self = this;
  Object.keys(this._data).forEach(function(key) {
    Object.defineProperty(self, key, {
      get: function() {
        return self._data[key];
      },
      set: function(newValue) {
        self._data[key] = newValue;
        if (typeof self.$options.beforeUpdate === 'function') {
          self.$options.beforeUpdate.call(self);
        }

        self.$render();

        if (typeof self.$options.updated === 'function') {
          self.$options.updated.call(self);
        }
      }
    });
  });
};

Vue.prototype._createComputed = function() {
  var self = this;
  var computed = this.$options.computed || {};

  Object.keys(computed).forEach(function(key) {
    Object.defineProperty(self, key, {
      get: function() {
        return computed[key].call(self);
      }
    });
  });
};

Vue.prototype._createWatchers = function() {
  var self = this;
  var watch = this.$options.watch || {};

  Object.keys(watch).forEach(function(key) {
    var callback = watch[key]
    var value = self._data[key];

    Object.defineProperty(self._data, key, {
      get: function() {
        return value;
      },
      set: function(newValue) {
        var oldValue = value
        value = newValue;
        callback.call(self, newValue, oldValue);
      }
    });
  });
};

Vue.prototype._proxyMethods = function() {
  var self = this;
  var methods = this.$options.methods || {};
  Object.keys(methods).forEach(function(key) {
    self[key] = methods[key].bind(self);
  });
};

Vue.prototype.$emit = function(eventName, payload) {
  this._eventBus.emit(eventName, payload);
};

Vue.prototype.$on = function(eventName, callback) {
  this._eventBus.on(eventName, callback);
};

Vue.prototype._compileTemplate = function() {
  var self = this;
  var el = this.$options.el
  var template = this.$options.template || '';

  var evalExpression = function(expression) {
    with (self) return eval(expression);
  }

  var compiledTemplate = template.replace(/\{\{(.*?)\}\}/g, function(match, expression) {
    var value = evalExpression(expression);
    return value !== undefined ? value : '';
  });

  var element = el ? document.querySelector(el) : document.createElement('div');
  element.innerHTML = compiledTemplate.trim();
  this.$el = el ? element : element.childNodes[0];
  this._handleDirective()
};

Vue.prototype._handleDirective = function() {
  var self = this;

  this.$el.querySelectorAll('[v-model]').forEach(function(element) {
    var value = element.getAttribute('v-model');
    element.value = self._data[value];
    element.addEventListener('input', function(event) {
      self._data[value] = event.target.value;
      self.$emit(`update:${value}`, event.target.value);
    });
  });

  this.$el.querySelectorAll('[v-text]').forEach(function(element) {
    var value = element.getAttribute('v-text');
    element.textContent = self._data[value];
    self.$on(`update:${value}`, function(newValue) {
      element.textContent = newValue;
    });
  });
};

Vue.prototype._proxyComponents = function() {
  var self = this;
  var components = this.$options.components || {};

  Object.keys(components).forEach(function(componentName) {
    var component = self[componentName] || new Vue(components[componentName]);
    var isNewComponent = typeof self[componentName] === 'undefined';
    self[componentName] = component;

    self.$el.querySelectorAll(componentName).forEach(function(element) {
      component.$el.querySelectorAll('slot').forEach(function(slot) {
        slot.innerHTML = element.innerHTML;
      });
      element.innerHTML = component.$el.outerHTML;

      isNewComponent && component.$options?.emits.forEach(function(event) {
        var method = element.getAttribute('v-on:' + event);
        if (typeof self[method] === 'function') {
          component.$on(event, self[method]);
        }
      });
    });
  });
};

Vue 的建構函式裡,我們做了幾件事:處理生命週期勾點函數、建立 EventBus 範例、使用 _proxyData_proxyMethods_createComputed_createWatchers 方法將資料物件的屬性、方法、計算屬性、監聽器代理或繫結到 Vue 範例上。

然後再呼叫 $mount 方法掛載元件,觸發生命週期勾點函數並執行 $render 方法。在 $render 方法中,執行使用者自定義的渲染函數,或者使用 _compileTemplate_proxyComponents 方法編譯模板和解析子元件。

_proxyData 方法中,我們使用 Object.defineProperty 將資料物件的屬性代理到 Vue 範例上,並在屬性的 set 方法中觸發 beforeUpdate$renderupdated 勾點,意味著只要資料物件的屬性發生變化,就會觸發檢視更新。

_createComputed 方法中,我們通過遍歷 computed 物件,為每個計算屬性定義了 get方法,使其能夠被當做普通屬性使用。

_createWatchers 方法中,我們通過遍歷 watch 物件,為每個屬性使用 Object.defineProperty 監聽 _data 物件中該屬性的變化,並在變化時觸發回撥函數。注意:在 set 方法中,與之前相比我們新增了 oldValue 引數。

_proxyMethods 方法中,我們將設定物件中的方法系結到 Vue 範例上,以便在範例中可以直接存取和呼叫這些方法。

Vue 原型中,我們定義了 $emit$on 方法。 $emit 方法用於丟擲事件,接收兩個引數:事件名和可選的資料載荷。 $on 方法用於監聽事件,接收兩個引數:事件名和回撥函數。

_compileTemplate 方法中,我們首先獲取設定物件中的模板字串,並使用正規表示式匹配 {{ expression }} 的部分。然後,我們使用 eval 函數根據表示式動態求值,將值替換回模板字串中。接下來,我們根據設定物件中的 el 屬性獲取對應的 DOM 元素,如果 DOM 元素不存在,我們就建立一個 div 元素代替,然後再將編譯後的模板字串賦值給該元素的 innerHTML 屬性。接著給 Vue 範例設定 $el 屬性並且呼叫 _handleDirective 方法處理指令。注意:前面如果用 div 元素代替,則需通過 childNodes[0] 排除該 div 元素。

_handleDirective 方法,我們通過 querySelectorAll 方法獲取所有具有 v-model 屬性的元素,並遍歷每個元素。在遍歷過程中,我們解析 model 指令,將元素的值設定為對應的資料屬性值,並新增 input 事件監聽器。注意:在 addEventListener 方法中,與之前相比我們新增了 $emit 動作,用來觸發 update:inputValue 事件,從而實現 inputValue 完整的資料雙向繫結。

接著,我們通過 querySelectorAll 方法獲取所有具有 v-text 屬性的元素,並遍歷每個元素。在遍歷過程中,我們解析 text 指令,將元素的文字內容設定為對應的資料屬性值。注意:與之前相比我們新增了 $on 動作,用來監聽 update:inputValue 事件,讓文字內容隨著 inputValue 的值變化而變化。

_proxyComponents 方法中,我們首先獲取設定物件中的元件宣告,然後遍歷所有的元件,根據元件名稱獲取元件物件,建立該物件的 Vue 範例。注意:與之前相比我們會儲存該物件到範例上,並優先從範例中獲取已經建立好的物件。接著通過該範例的 $el 屬性,遍歷所有 slot 插槽,將原始的 innerHTML 設定為插槽的內容,並重新設定元件的 innerHTML 為範例 $el 元素的 outerHTML 內容。

最後,我們還新增了 v-on 的元件監聽事件功能。首先,我們從元件設定物件裡的 emits 陣列獲取元件丟擲的所有事件名稱,然後遍歷該陣列,判斷 app 應用是否監聽了該事件,如果從 app 應用的 self[method] 找到對應的監聽函數,則給元件通過 $on 方法系結該監聽函數。注意:由於元件更新會觸發多次 _proxyComponents 方法,因此必須判斷 isNewComponent 是否為新建立的元件,防止重複用 $on 方法系結相同的監聽函數。


總結

以上模擬 Vue 原理的範例程式碼都是按 Vue 的 Option 選項式 API 方式編寫的,與當前我們常用的 Composition 組合式 API 有所不同。這也許跟我使用的 ChatGPT 版本只能獲取 2021 年以前的資料有關,但這並不妨礙我們利用它學習 Vue 的用法、理解 Vue 的原理。

本篇文章涉及的內容都比較基礎,Vue 還有很多高階特性和用法,我們都可以藉助 AI 輔導我們學習。當然,在學習的過程中,我們要時刻注意 AI 的回答並不完全正確,需要自己通過實踐逐一甄別。在 Vue 的實戰開發過程中,我們同樣可以藉助 AI 來定位分析問題,畢竟它不知疲倦,脾氣又好,是不可多得的好老師。

本篇文章所有原始碼和範例工程都在 OpenTiny 站點,請存取 https://github.com/opentiny/ai-vue/

聯絡我們: