在很久很久以前,我夢到了一個朋友那邊來的請求,是想幫忙調整一個外包使用 Vue 開發的前端頁面。然後我收到朋友給的 Gitlab 之後,在我 Clone 下來,並且嘗試用 Chrome 跑跑看的時候,我看到了一個叫做 <iframe href="首頁.html"... 的東西,然後我夢就醒了。


關於 Component 這件事

我之前(這裡這裡)也寫過一點關於元件的事情,不過,不知道為何總是會看到一些奇怪的用法。或者是說,其實只是想 new Vue() 這樣看起來比較潮(?),然後後面就把每一個 Vue 當作是單一元件在使用,換句話說,就是一個 html 一個 Vue,這樣看起來就比較厲害

接踵而至的問題就在於,每個元件失去了溝通方式。如果都只是在同一個 html 底下還好,頂多就是使用 EventBus 的方式,雖然比較麻煩,但是也不失為各個元件互相溝通的一種方法。關於元件互相溝通這件事情,這裡有一位 老司機 的文章可以參考一下(上車囉)。

動態載入

如果你不想將所有的東西都包在同一個 App 裡面運作,每次修改就是一大包的話,動態載入也是一種可行的方式,我之前聊過了,今天就來聊一點別的東西。

元件被切開所衍生的問題其實不少,

  1. 元件之間的溝通。
  2. 每個元件都有屬於自己的 scope
  3. 每一個 Vue Instance 都算是獨立個體。
  4. $mount 造成的差異(Kuro 有說過這件事)。

這裡所謂的『切開』,不是你在 App 裡面使用 Vue.component(...) 這種方法,這樣理論上還是屬於同一個 App,並不能算是切開的動作。所謂的『切開』是指,你在每一次驅動 App 的時候,都呼叫了 $mount 來將 App 綁定到頁面上。換句話說,你若是每一個 html 檔案,都初始化了一個 Vue,或是說,你在同一個 Vue 檔案,同時初始化了多個 Vue,然後綁定($mount)在不同的 DOM 上面,這是這裡所謂的『切開』的意思。

所以說,不同的 $mount 造成的差異是什麼呢?

  • 每個個體都有各自的 scope
  • 你的 rootScope 不是你的 rootScope
  • 每個元件的 Life-cycle 跟你載入的順序,或者是 $mount 順序無關。

最終,你是不是終究要面對這種差異,還是說全部包在同一個 App 就好了?

不使用 Webpack 包裝的範例

由於我不會用 Webpack,所以這邊就只聊一下不使用 Webpack 來封裝的一些元件使用範例。依據 Vue 官方的新手使用手冊,我們使用 Vue 的方式就是載入 vue.js 。這是我們的 index.html 所需要的內容,那麼,如果我們還需要做簡易的 Router 的話,就接著載入來用就好了。

<body>
  <div id="app"></div>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vue-router.min.js"></script>
  <script src="./main.js"></script>
</body>

接著我們來做 main.js 基本的部分,

import Vue from 'vue';
import VueRouter from 'vue-router';

let router = new VueRouter({
  routes: []
});

new Vue({
  router,
  el: '#app'
});

然後,你打開瀏覽器發現什麼都沒有,這是正確的喔!因為你的 Router 裡面本來什麼都沒有。接著,我們想要將首頁叫做 homepage 元件,所以說,我們就在路由表上加入一個 homepage 元件。

let router = new VueRouter({
  routes: [
    {
      path: '/',
      name: 'homepage',
      component: { template: '<my-homepage></my-homepage>' },
      props: true,
      meta: {
        name: '首頁'
      }
    }
  ]
});

然後你再次打開瀏覽器,發現還是什麼都沒有。這是因為 <my-homepage> 這個元件並不存在,所以畫面上也沒辦法染出什麼東西。 !!注意!! 上述原始碼當中的 component 寫法,請參照官方的 文件說明,在這邊就不贅述了。

接著我們得思考,到底要怎麼把 <my-homepage> 給載入呢?

  1. 直接在 component 實例化一個 Vue。
  2. 使用 Vue.component

衍生問題,

  1. 因為沒有 webpack,所以沒辦法用 .vue 來做。
  2. 因為沒有 webpack,所以你的 html 要想辦法拋給 template
  3. 你一直 new Vue() 你有想過瀏覽器的感受嗎?

這邊提供一個解決方式,

  1. 使用 xhr 將樣版檔案載入。
  2. 樣版檔案載入之後,再去初始化 Component。
  3. 最後將初始化的結果拋給 Router。

實際上的操作方式,以下提供一點範例,

!!注意!! 範例程式碼若是運行中有蟲不賠,複製貼上前請詳閱公開說明書。

// 接續著上面的 Router,我們針對 beforeEach 動手腳。
router.beforeEach(function(to, from, next) {
  let template = '';
  if (typeof to.name !== 'undefined' && to.name !== null) {
    template = to.name;
  }
  return generateTemplateLoader(template).then(function() {
    next();
  });
});

上述的意思是說,每次 Router 進來之前,我們就先拿到你 想去的地方 叫什麼名字,然後把你的名字餵給 template,請一個叫做 generateTemplateLoader 的人,幫我們把樣版給拿回來,拿回來之後再去運作 next()。具體 Router 的操作方法,請參閱 官方手冊

那麼 generateTemplateLoader 會幫我們做些什麼事情?

  1. 他是一個 Promise
  2. 他會先看看這個 template 有沒有載入過?
  3. 如果有,直接 resolve
  4. 如果沒有,就把那個 template 載入。

那麼,所謂的 template 具體來說是什麼?

  1. 他是一個 JavaScript 檔案。
  2. 你的 template 叫什麼名字,JavaScript 檔案就叫做什麼名字。
  3. 以上述的例子來說,你會有一支叫做 homepage.js 的檔案。

所以 generateTemplateLoader 大概會是這樣,

let generateTemplateLoader = function(template) {
  // 注意,我們這邊不處理 reject 的情況,請自行解決。
  return new Promise(function(resolve) {
    let exists = document.getElementById('my-' + template);
    if (exists) {
      resolve(true);
    } else {
      let ps = document.getElementsByTagName('script')[0];
      let s = document.createElement('script');
      s.src = './js/' + template + '.js';
      s.type = 'text/javascript';
      s.id = 'my-' + template;
      s.onload = function() {
        resolve(true);
      };
      ps.parentNode.insertBefore(s, ps);
    }
  });
};

上述就是利用插入 <script> 的方式,將我們的 homepage.js 載入。然後,你再次的打開瀏覽器,然後發現還是空的。歐,因為你還沒寫 homepage.js 的內容啊。這個時候你可能猜得到,既然 App 缺少了 <my-homepage> 這個元件,那麼我的 homepage.js 就是要補上這個元件對吧。

然後你的 homepage.js 可能就這麼開始寫了,

Vue.component('my-homepage', {
  template: '<h1>Hello World!</h1>'
});

接著你回到瀏覽器,發現好像出現了 Hello World! 了耶,如果你沒有出現的話,應該是哪裡做錯了,這一點都不甘我的事情喔。

!!注意!! 範例程式碼若是運行中有蟲不賠,複製貼上前請詳閱公開說明書。

載入元件所需要的樣版

接續上面的範例,我們在製作 homepage.js 的時候,裡面的 template 總不可能每次都寫一大堆 HTML 然後拼成字串吧?如果你要這麼做的話,其實我也是不反對的。所以,我們可以借用一下 XHR 來幫我們拿到比較不一樣的東西,例如說,一個叫做 homepage.html 的檔案。

那麼,因為我不會寫 XHR,所以就先用 axios 來幫我們做掉吧。首先,在 index.html 需要加入一點東西,

<script src="https://unpkg.com/axios/dist/axios.min.js"></script>

然後,修改一下 homepage.js 的地方,

axios({
  method: 'get',
  url: '../templates/homepage.html',
  responseType: 'text'
}).then(function(res) {
  Vue.component('my-homepage', {
    template: res.data
  });
});

然後我們在 templates/homepage.html 裡面就寫,

<div>
  <h1>Hello World!</h1>
</div>

不要問我為何需要用 <div> 包起來,請參考 官方說明

接著打開瀏覽器,發現,什麼都沒有。

!!注意!! 範例程式碼若是運行中有蟲不賠,複製貼上前請詳閱公開說明書。

以上的範例有幾個問題,

  1. axios 本身是 Promise
  2. generateTemplateLoaderonload 僅代表 homepage.js讀取 完了。
  3. 承上,不代表 Promise 已經被 resolve 了。
  4. 所以當 onload 觸發時,Router 運行 next() 後,會發現其實元件還沒準備好。

那麼,這個時候需要來一點 Event 互相溝通,最簡單的方式,就是 new Vue() 來當作 EventBus 使用,

// ... 前略
window.EventBus = new Vue();

// ... 中略
    if (exists) {
      resolve(true);
    } else {
      // 加入一個 EventBus 事件監聽
      window.EventBus.$off('templateLoaded');
      window.EventBus.$on('templateLoaded', function() {
        resolve(true);
      });
      
      let ps = document.getElementsByTagName('script')[0];
      let s = document.createElement('script');
      s.src = './js/' + template + '.js';
      s.type = 'text/javascript';
      s.id = 'my-' + template;
      // 原本的 onload 不需要了。
      ps.parentNode.insertBefore(s, ps);
// ... 後略

接著我們的 homepage.js 做一點事件發送的動作,

axios({
  method: 'get',
  url: '../templates/homepage.html',
  responseType: 'text'
}).then(function(res) {
  Vue.component('my-homepage', {
    template: res.data
  });
  // 發送一個事件,就說這個樣版已經讀取好了。
  window.EventBus.$emit('templateLoaded');
});

這樣做的話,你的 homepage.js 就會動態載入到頁面上,然後你的 templates/homepage.html 會被 homepage.js 載入到 Vue 的元件裡面,當作是樣版來使用。當然,如果要在 homepage.html 當中,使用 Vue 的相關動作也是可以的。

<div>
  <h1>Hello {{ yourName }}!</h1>
</div>

樣版有幾個必須留意的地方,

  1. 如果有使用到其他元件,請確認元件已經被載入。
  2. 樣版的絕對路徑與 index.html 相同。
  3. 承上,如果你有圖片資料夾 images,樣版裡要寫 ./images/,即便你的樣版檔案放在 ./templates/homepage.html 裡面。
  4. 如果你沒有使用 Vuex,那麼元件溝通多半得用 EventBus,或是你狂用 $refs 也可以。
  5. 如果你有使用 :is 這個 Vue 元件的魔術方法,請確認元件已經被載入。

文末

如果你不想使用 Webpack,這裡是一個蠻輕便的解法。至於 <iframe,我想應該是前陣子比較晚下班,遇到什麼髒東西的緣故吧(欸)。

!!注意!! 範例程式碼若是運行中有蟲不賠,複製貼上前請詳閱公開說明書。