DEV Community

Cover image for Vue 3 กับ class
Atipon
Atipon

Posted on

Vue 3 กับ class

เดิมนั้นใน Vue 2 ออกแบบมาเป็น Options API แต่สามารถเขียนเป็นแบบ class ได้โดยใช้ class component แต่มีข้อแม้ว่า ต้องใช้ TypeScript เท่านั้น

ผมได้ติดตามข่าวการพัฒนา Vue 3 มาเป็นระยะ ซึ่งได้มีการทำ proposal ของ class API ทำให้สามารถเขียน Vue โดยใช้ native js class แต่ข่าวร้ายคือ ท้ายที่สุด proposal นี้ถูกยกเลิกไป

สิ่งที่มาแทน class API ก็คือ composition API ที่เขียน 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

<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>
Enter fullscreen mode Exit fullscreen mode

จะเห็นว่า composition API พึ่งพา closures เป็นหลักซึ่ง closures ก็คือ function ที่ผูกติดอยู่กับ data ฟังดูคุ้นๆนะครับ ซึ่งมันก็คือ object นั่นเอง

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

Class 1

<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>
Enter fullscreen mode Exit fullscreen mode

จะเห็นว่านี่ไม่ได้เป็นการสร้าง 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

    return {
      count: this.count,
      state: this.state,
      doubled: this.doubled,
      inc: this.inc.bind(this),
    };
Enter fullscreen mode Exit fullscreen mode

เรามาลองทำให้ class ดูสะอาดมากขึ้นด้วยความพยายาม ครั้งที่ 2

Class 2

<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>
Enter fullscreen mode Exit fullscreen mode

สิ่งที่ปรับปรุงคือ

  • ย้าย 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
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;
}
Enter fullscreen mode Exit fullscreen mode

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

Class 3

<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>
Enter fullscreen mode Exit fullscreen mode

การปรับปรุงคือ

  • เพิ่ม Vue base class เพื่อให้ watch และ createComponentDef ดูสะอาดตา
  • ย้าย Vue options มาไว้ที่ static method
  • ย้าย logic การสร้าง class instance ไปไว้ใน createComponentFromClass
  • สามารถใช้ render function ได้
  // expose render function alternately
  render() {
    return h("div", [this.count.value]);
  }
Enter fullscreen mode Exit fullscreen mode

นอกจากนั้นยังได้แสดงให้เห็นถึง logic composition ด้วย object composition จากตัวอย่างคือ class MouseMove สามารถนำมาใช้งานใน Counter ได้ด้วย function createInstance จากนั้นก็ใช้ spread operator รวม Vue component option ของ Counter และ MouseMove เข้าไว้ด้วยกัน

    const mouseMove = createInstance(MouseMove);

    //logic composition with object composition
    this.componentDef = {
      ...this.componentDef,
      ...mouseMove.componentDef,
    };
Enter fullscreen mode Exit fullscreen mode

อนาคต
เราสามารถทำให้ class ดูกระชับมากขึ้นในอนาคตถ้า js static property พัฒนาเสร็จแล้ว

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

decorator concept

class Counter extends Vue {
 @prop static initialCounter: number
 @Watch('count')
  countWatch(value: number, oldValue: number) {
    // watcher logic
  }
}
Enter fullscreen mode Exit fullscreen mode

code ตัวอย่างใน codesandbox

สรุป

Vue 3 composition API เป็น API ที่ดีมาก เปิดทางให้การพัฒนา Vue app ทำได้หลากหลายมากขึ้น ซึ่งการนำมาใช้งานกับ class น้้นก็ทำได้อย่างดีและราบลื่น ทำให้ Vue เป็น framework ที่ดีที่สุด

Top comments (1)

Collapse
 
arsenohh profile image
Arsenoh 🇱🇦

เขียนแบบ composition api และ class-component (class-component.vuejs.org)
อันไหนดีกว่ากันครับ ในเรื่องประสิทธิภาพ