前幾天提到在 ASP.NET 使用輕前端 整合 Vue.js 做 MVVM,若遇到伺服端元件輸出 script/style 的注意事項。FB 留言區 Died Liu 分享透過 defineAsyncComponent 方法載入 .vue 元件檔的做法,不需要 npm、webpack 也能享受用 .vue 封裝元件邏輯的好處,讓我十分心動。

複雜的前端自訂元件,通常會包含 HTML 模板、JavaScript 程式跟 CSS 樣式,最理想做法是將該元件需要的東西放在一起,引用時一個檔案搞定,如此能簡化開發、測試及部署複雜度,較符合模組化設計。Vue.js 也有這種單檔元件 (Single File Component,SFC) 概念,副檔名為 .vue,格式如下,將 HTML 模板、JavaScript 程式及 CSS 樣式分別放入 <tempalte>、<script>、<style> 存成 .vue 檔案:

<script>
export default {
  data() {
    return {
      greeting: 'Hello World!'
    }
  }
}
</script>

<template>
  <p class="greeting">{{ greeting }}</p>
</template>

<style>
.greeting {
  color: red;
  font-weight: bold;
}
</style>

不過,之前看到的範例,要用 SFC 都需要結合 JavaScript Module (起手式是 import MyComponent from './MyComponent.vue') 配合 Vue CLI、webpack 打包使用,我一直覺得輕前端與它無緣。所以,當看到 createApp 時 import('./some.vue') 寫法,我眼睛為之一亮。

createApp({ /*...*/ 
  components: { 
      AsyncComponent: defineAsyncComponent(() => import('./components/AsyncComponent.vue'))  
  } 
})

經過一番研究與摸索,總算試出輕前端使用 .vue 的方法。由於 SFC 的獨有格式必須先經過解析轉譯成 JavaScript 程式碼,才能在瀏覽器執行,Vue CLI 打包部署時會使用 vue-loader 進行轉譯;輕前端要從瀏覽器直接載入 .vue,則需依賴第三方程式庫 vue3-sfc-loader進行前置處理。參考

vue3-sfc-loader 使用範例如下:

<!DOCTYPE html>
<html>

<body>
    <div id="app">
        <drk-timer secs="20"></drk-timer>
        <drk-timer></drk-timer>
    </div>
    <script src="https://unpkg.com/vue@next"></script>
    <script src="https://cdn.jsdelivr.net/npm/vue3-sfc-loader"></script>
    <script>
        const options = {
            moduleCache: { vue: Vue },
            async getFile(url) {
                //if (url === '/myComponent.vue')
                //    return Promise.resolve(vueString);
                const res = await fetch(url);
                if (!res.ok)
                    throw Object.assign(new Error(url + ' ' + res.statusText), { res });
                return await res.text();
            },
            addStyle(textContent) {
                const style = Object.assign(document.createElement('style'), { textContent });
                const ref = document.head.getElementsByTagName('style')[0] || null;
                document.head.insertBefore(style, ref);
            },
            log(type, ...args) { console[type](...args); },
            compiledCache: {
                set(key, str) {
                    // naive storage space management
                    for (; ;) {
                        try {
                            // doc: https://developer.mozilla.org/en-US/docs/Web/API/Storage
                            window.localStorage.setItem(key, str);
                            break;
                        } catch (ex) {
                            // handle: Uncaught DOMException: Failed to execute 'setItem' on 'Storage': Setting the value of 'XXX' exceeded the quota
                            window.localStorage.removeItem(window.localStorage.key(0));
                        }
                    }
                },
                get(key) {
                    return window.localStorage.getItem(key);
                },
            }
        }
        const { loadModule } = window['vue3-sfc-loader'];
        var vm = Vue.createApp({
            components: {
                drkTimer: Vue.defineAsyncComponent(() => loadModule('./timer.vue', options))
            }
        }).mount('#app');
    </script>
</body>

</html>

使用 vue3-sfc-loader 時要先宣告一個 options 物件,其中包含:

  • moduleCache 屬性
    一般寫 即可
  • getFile() 方法
    依 url 取回 .vue 內容,也可用其他方法傳回字串,不一定要從伺服器下載。這段自訂邏輯非常好用,提供無限的擴充彈性,例如:正式上線時可打包多個 .vue 一次下載,減少 HttpRequest 次數
  • addStyle() 方法
    決定如何將 <style<區塊內容掛到目前的網頁
  • log()
    訊息輸出方式
  • compiledCache
    將編譯結果存入 localStorage 重複使用以提升效能

要載入 .vue 的地方則寫成:

components: {
    drkTimer: Vue.defineAsyncComponent(() => loadModule('./timer.vue', options))
}

我寫了一個倒數計時器當練習:

我人生第一個 .vue 檔,HTML、JavaScript 跟 Style 氣刀體一致的感覺真棒!

<template>
  <div class="timer" v-bind:class="[status]">
    <div class="screen">{{ mm }}:{{ ss }}</div>
    <div class="buttons">
      <button @click="start()">開始</button>
      <button @click="pauseOrResume()">
        {{ status == "P" ? "繼續" : "暫停" }}
      </button>
      <button @click="reset()">重置</button>
    </div>
  </div>
</template>
<script>
export default {
  name: "drk-timer",
  props: ["secs"],
  data() {
    return {
      //W-Waiting, R-Running, P-Paused, O-Timeout
      status: "W",
      lastTime: 0,
      totalTime: 0,
      countdownSecs: 0,
      intHandle: 0,
    };
  },
  computed: {
    remainingSecs() {
      if (!this.countdownSecs) return 0;
      return Math.max(
        0,
        this.countdownSecs - Math.round(this.totalTime / 1000)
      );
    },
    mm() {
      return Math.floor(this.remainingSecs / 60)
        .toString()
        .padStart(2, "0");
    },
    ss() {
      return (this.remainingSecs % 60).toString().padStart(2, "0");
    },
  },
  watch: {
    secs() {
      this.start();
    },
  },
  methods: {
    start() {
      this.reset();
      this.status = "R";
    },
    pauseOrResume() {
      if (this.status == "R") this.status = "P";
      else if (this.status == "P") {
        //resume
        this.lastTime = new Date().getTime();
        this.status = "R";
      }
    },
    reset() {
      this.lastTime = new Date().getTime();
      this.totalTime = 0;
      this.status = 'W';
    },
    tick() {
      if (this.status == "R") {
        const currTime = new Date().getTime();
        this.totalTime += currTime - this.lastTime;
        this.lastTime = currTime;
      }
      if (this.mm + this.ss == 0) {
        this.status = "O";
      }      
    },
  },
  mounted() {
    this.countdownSecs = parseInt(this.secs);
    if (!this.countdownSecs) this.countdownSecs = 180;
    this.intHandle = setInterval(this.tick, 500);
  },
  unmounted() {
    clearInterval(this.intHandle);
  },
};
</script>
<style scoped>
.W .screen { color: white; }
.R .screen { color: lightgreen; }
.P .screen { color: #888; }
.O .screen { color: rgb(255, 7, 7); }
.timer { 
  display: inline-block; margin: 3px;
  background-color: #ddd;
}
.buttons { padding: 6px; text-align: center; }
@font-face {
  /* https://torinak.com/font/7-segment */
  font-family: "7segments"; font-weight: 200; font-style: normal;
  src: url("data:application/font-woff;base64,d09GRgABAAAAA...略...LAAACxFAeMA") format("woff");
}
.screen {
  font-family: "7segments"; font-size: 64px; width: 140px;
  margin: 6px; padding: 6px; background-color: #222;
  border: 2px solid #aaa; border-style: none none solid solid;
}
</style>

打通在輕前端用 .vue 寫元件的關卡,元件模板及程式不用硬塞進 .js,連樣式都能放在一起,回到文明的開發方式,感覺前進了一大步,讚!!

(範例已上傳到 Github)

Tips of how to load and use .vue SFC directly from browser.


Comments

# by GM

早期我也是用vue2-sfc-loader,升上vue3時一開始也是沿用vue3-sfc-loader,但後來遇上問題(細節我忘了,反正找了半天發現問題是vue3-sfc-loader载入.vue失敗)。自己就搞了奇怪的純JS載入法.... $.ajax({ method: "get", url:`../VueComponent/swd.vue` ,async:false }) .done(function( retData ) { $("body").append(retData); }); const app = Vue.createApp({ }); //將元件加入vue app app.component('swd-page-ctrl', swd_page_ctrl); ----------------------------------------------------------------- swd.vue: <script type="text/x-template" id="swd-page-ctrl-template"> .... </script> <style> .... } </style> <script> var swd_page_ctrl={ template: '#swd-page-ctrl-template' ,props:{'s-show-data-list':Array,'s-data-source':Array,'s-rows-of-page':Number,'s-rows-page-list':Array ,'s-page-list-length':Number,'s-id':String,'s-order-cols':Array,'s-sys-id':String}//propB: [String, Number] ,data(){ } ,mounted(){ } ,methods:{ } }; </script>

Post a comment