Vue 3 กับ class 
Tags: vue, javascript, tutorial, oop
Published: 2020-11-22 06:02:23

เดิมนั้นใน Vue 2 ออกแบบมาเป็น Options API แต่สามารถเขียนเป็นแบบ class ได้โดยใช้ [class component](https://class-component.vuejs.org) แต่มีข้อแม้ว่า ต้องใช้ TypeScript เท่านั้น

ผมได้ติดตามข่าวการพัฒนา Vue 3 มาเป็นระยะ ซึ่งได้มีการทำ [proposal ของ class API](https://github.com/vuejs/rfcs/blob/class-api/active-rfcs/0000-class-api.md) ทำให้สามารถเขียน Vue โดยใช้ native js class แต่ข่าวร้ายคือ ท้ายที่สุด proposal นี้ถูก[ยกเลิกไป](https://github.com/vuejs/rfcs/pull/17)

สิ่งที่มาแทน class API ก็คือ [composition API](https://v3.vuejs.org/guide/composition-api-introduction.html) ที่เขียน Vue ในรูปแบบ function โดยสามารถใช้ ความสามารถของ Vue ได้จากใน function เลย

เข้าใจล่ะครับว่าเทรนด์ของ function มาแรง เริ่มจาก React Hooks ที่ก็ได้พูดถึงข้อดีของ function ในแง่ของ logic composition และ Vue 3 ก็ได้รับเอาแนวคิดนี้มาใช้ไปด้วย แต่สำหรับตัวผมมีความชอบใน class syntax คือมีความคุ้นเคยและสบายตาในการอ่าน code มากกว่าแบบ function และ closures

หลังจากได้ลองศึกษา composition API แล้ว ผมพบว่ามีความคล้ายกับการเขียน class มาก ดังนั้นทำไมเราไม่ลองจับมันมาเขียนเป็น js native class ดูเลยว่าผลจะเป็นยังไง และในตอนท้ายจะแสดงให้ดูถึง logic composition ก็ทำได้ง่ายใน class ด้วยครับ

เรามาเริ่มจากการสร้าง app ง่ายๆด้วย composition API กันก่อน ซึ่ง app นี้เป็น counter นับการกดปุ่ม และยังทดลองใช้งาน ref, reactive และ props ด้วย

**Composition API**
```js
<template>
  <button @click="inc">Clicked {{ count }} times.</button>
  <div>state count {{ state.count }}</div>
  <div>state double count {{ doubled }}</div>
</template>

<script>
import { ref, reactive, computed, watch, onMounted } from "vue";
export default {
  props: {
    initialCounter: Number,
  },
  setup(props) {
    const count = ref(props.initialCounter);

    const state = reactive({
      count: 0,
    });

    const doubled = computed(() => state.count * 2);

    const inc = () => {
      count.value++;
      state.count++;
    };

    watch(count, (newValue, oldValue) => {
      console.log("The new counter value is: " + count.value);
    });

    onMounted(() => {
      console.log("counter mounted");
      state.count = 2;
    });

    return {
      count,
      state,
      doubled,
      inc,
    };
  },
};
</script>
```
จะเห็นว่า composition API พึ่งพา closures เป็นหลักซึ่ง closures ก็คือ function ที่ผูกติดอยู่กับ data ฟังดูคุ้นๆนะครับ ซึ่งมันก็คือ object นั่นเอง

ดังนั้นเรามาลองเขียน class กันเลยดีกว่า ด้วยความพยายามแรก

**Class 1**
```js
<template>
  <button @click="inc">Clicked {{ count }} times.</button>
  <div>state count {{ state.count }}</div>
  <div>state double count {{ doubled }}</div>
</template>

<script>
import { ref, reactive, computed, watch, onMounted } from "vue";

class Counter {
  setup(props) {
    this.count = ref(props.initialCounter);

    this.state = reactive({
      count: 0,
    });

    this.doubled = computed(() => this.state.count * 2);

    watch(this.count, (newValue, oldValue) => {
      console.log("The new counter value is: " + this.count.value);
    });

    onMounted(() => {
      this.mounted();
    });

    return {
      count: this.count,
      state: this.state,
      doubled: this.doubled,
      inc: this.inc.bind(this),
    };
  }

  inc() {
    this.count.value++;
    this.state.count++;
  }

  mounted() {
    this.state.count = 2;
  }
}

export default {
  props: {
    initialCounter: Number,
  },
  setup(props) {
    return new Counter().setup(props);
  },
};
</script>
```
จะเห็นว่านี่ไม่ได้เป็นการสร้าง Vue component ขึ้นมาจาก class ซะทีเดียว แต่เป็นการยก logic จาก setup function เข้าไปไว้ใน class และใช้ประโยชน์จาก concept ของ field และ method ของ class 

concept ของการส่งออก data และ method จาก setup ใน class นั้นเหมือน composition API เลย ยกเว้นคือ class method นั้นต้อง bind กับ this instance จึงจะสามารถทำงานได้ถูกต้อง เมื่อถึงคราวที่ vue runtime นำ method นี้ไปประกอบกลับเป็น Vue component

```js
    return {
      count: this.count,
      state: this.state,
      doubled: this.doubled,
      inc: this.inc.bind(this),
    };
```
เรามาลองทำให้ class ดูสะอาดมากขึ้นด้วยความพยายาม ครั้งที่ 2

**Class 2**
```js
<template>
  <button @click="inc">Clicked {{ count }} times.</button>
  <div>state count {{ state.count }}</div>
  <div>state double count {{ doubled }}</div>
</template>

<script>
import { ref, reactive, onMounted } from "vue";
import {
  useLifeCycle,
  useProps,
  createComponentDef,
  classWatch,
} from "./vue-class-composition";

class Counter {
  setup(props) {
    this.count = ref(this.initialCounter);

    this.state = reactive({
      count: 0,
    });

    //simplify watch syntax in class definition
    classWatch(this, this.count, this.countWatch);

    //expose all class fields and methods
    //expose getter as computed property
    let componentDef = createComponentDef(this);

    return componentDef;
  }

  get doubled() {
    return this.state.count * 2;
  }

  inc() {
    this.count.value++;
    this.state.count++;
  }

  countWatch() {
    console.log("The new counter value is: " + this.count.value);
  }

  mounted() {
    this.state.count = 2;
  }
}

export default {
  props: {
    initialCounter: Number,
  },
  setup(props) {
    const instance = new Counter();
    useLifeCycle(instance);
    useProps(instance, props);
    return instance.setup(props);
  },
};
</script>
```
สิ่งที่ปรับปรุงคือ
* ย้าย life cycle setup ไปไว้ใน function useLifeCycle
* useProps ช่วย set props ให้กับ class field แบบอัตโนมัติ ทำให้สามารถใช้ field this.initialCounter ได้ใน class
* classWatch function ช่วยให้ watch ใช้งาน class method ได้สะดวกขึ้น
* ย้าย logic ของการ expose Vue option ไปไว้ใน createComponentDef ซึ่ง function นี้จะ expose ทุก field และ method ของ class ให้แบบอัตโนมัติ
สำหรับ getter จะถูก expose เป็น computed property
  ซึ่งทั้งหมดนี้ทำด้วย js Reflect API

```js
export function createComponentDef(target) {
  const componentDef = {};
  const propertyKeys = Reflect.ownKeys(target);
  for (let index = 0; index < propertyKeys.length; index++) {
    const key = propertyKeys[index];

    componentDef[key] = target[key];
  }

  const prototype = Reflect.getPrototypeOf(target);
  let methodsKeys = Reflect.ownKeys(prototype);

  methodsKeys = methodsKeys.filter(
    (p) => typeof target[p] === "function" && p !== "constructor" //only the methods //not the constructor
  );

  for (let index = 0; index < methodsKeys.length; index++) {
    const key = methodsKeys[index];

    componentDef[key] = target[key].bind(target);
  }

  methodsKeys = Reflect.ownKeys(prototype);

  methodsKeys = methodsKeys.filter(
    (p) => typeof target[p] !== "function" && p !== "constructor" 
  );

  for (let index = 0; index < methodsKeys.length; index++) {
    const key = methodsKeys[index];

    componentDef[key] = classComputed(target, key);
  }

  return componentDef;
}
```
class ของเราเริ่มดูดีขึ้นมาแล้ว แต่ในส่วนของ Vue option นั้นยังอยู่นอก class เรามาลองปรับปรุงใหม่ในความพยายามครั้งที่ 3

**Class 3**
```js
<template>
  <button @click="inc">Clicked {{ count }} times.</button>
  <div>state count {{ state.count }}</div>
  <div>state double count {{ doubled }}</div>
  <div>
    mouse pos x <span>{{ pos.x }}</span> mouse pos y
    <span>{{ pos.y }}</span>
  </div>
</template>

<script>
import { ref, reactive, h } from "vue";
import {
  Vue,
  createComponentFromClass,
  createInstance,
} from "./vue-class-composition";

class MouseMove extends Vue {
  setup() {
    this.pos = reactive({ x: 0, y: 0 });

    this.createComponentDef();
  }

  mounted() {
    window.addEventListener("mousemove", (evt) => {
      this.pos.x = evt.x;
      this.pos.y = evt.y;
    });
  }
}

class Counter extends Vue {
  constructor() {
    super();
    //for clarity
    this.count = null;
    this.state = null;
    this.initialCounter = 0;
  }

  //static method instead of property
  //static properties are still under development
  static get options() {
    return {
      props: {
        initialCounter: Number,
      },
    };
  }

  setup(props) {
    this.count = ref(this.initialCounter);

    this.state = reactive({
      count: 0,
    });

    //simplify watch syntax in class definition
    this.watch(this.count, this.countWatch);

    //expose all class fields and methods
    //expose getter as computed property
    this.createComponentDef();

    const mouseMove = createInstance(MouseMove);

    //logic composition with object composition
    this.componentDef = {
      ...this.componentDef,
      ...mouseMove.componentDef,
    };
  }

  get doubled() {
    return this.state.count * 2;
  }

  inc() {
    this.count.value++;
    this.state.count++;
  }

  countWatch() {
    console.log("The new counter value is: " + this.count.value);
  }

  mounted() {
    this.state.count = 2;
  }

  // expose render function alternately
  // render() {
  //   return h("div", [this.count.value]);
  // }
}

//move component options to class
//wrap all component creation logic in function call
export default createComponentFromClass(Counter);
</script>
```
การปรับปรุงคือ
* เพิ่ม Vue base class เพื่อให้ watch และ createComponentDef ดูสะอาดตา
* ย้าย Vue options มาไว้ที่ static method 
* ย้าย logic การสร้าง class instance ไปไว้ใน createComponentFromClass
* สามารถใช้ render function ได้
```js
  // expose render function alternately
  render() {
    return h("div", [this.count.value]);
  }
```
นอกจากนั้นยังได้แสดงให้เห็นถึง logic composition ด้วย object composition จากตัวอย่างคือ class MouseMove สามารถนำมาใช้งานใน Counter ได้ด้วย function createInstance จากนั้นก็ใช้ spread operator รวม Vue component option ของ Counter และ MouseMove เข้าไว้ด้วยกัน
```js
    const mouseMove = createInstance(MouseMove);

    //logic composition with object composition
    this.componentDef = {
      ...this.componentDef,
      ...mouseMove.componentDef,
    };
```

**อนาคต**
เราสามารถทำให้ class ดูกระชับมากขึ้นในอนาคตถ้า js static property พัฒนาเสร็จแล้ว 
  
จาก class 3 นั้นการประกาศ props ใน class ยังเป็น double declaration ซ้ำกับการประกาศ field อยู่ ซึ่งในอนาคตถ้า js พัฒนา field decorator เสร็จแล้ว เราก็สามารถใช้ทำ props declaration แทน syntax เดิมได้

*decorator concept*
```js
class Counter extends Vue {
 @prop static initialCounter: number
 @Watch('count')
  countWatch(value: number, oldValue: number) {
    // watcher logic
  }
}
```

code ตัวอย่างใน [codesandbox](https://codesandbox.io/s/vue-3-class-composition-ex02c?file=/src/App.vue)
#สรุป
Vue 3 composition API เป็น API ที่ดีมาก เปิดทางให้การพัฒนา Vue app ทำได้หลากหลายมากขึ้น ซึ่งการนำมาใช้งานกับ class น้้นก็ทำได้อย่างดีและราบลื่น ทำให้ Vue เป็น framework ที่ดีที่สุด