เดิมนั้นใน 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>
จะเห็นว่า 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>
จะเห็นว่านี่ไม่ได้เป็นการสร้าง 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),
};
เรามาลองทำให้ 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>
สิ่งที่ปรับปรุงคือ
- ย้าย 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;
}
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>
การปรับปรุงคือ
- เพิ่ม 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]);
}
นอกจากนั้นยังได้แสดงให้เห็นถึง 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,
};
อนาคต
เราสามารถทำให้ 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
}
}
code ตัวอย่างใน codesandbox
สรุป
Vue 3 composition API เป็น API ที่ดีมาก เปิดทางให้การพัฒนา Vue app ทำได้หลากหลายมากขึ้น ซึ่งการนำมาใช้งานกับ class น้้นก็ทำได้อย่างดีและราบลื่น ทำให้ Vue เป็น framework ที่ดีที่สุด
Top comments (1)
เขียนแบบ composition api และ class-component (class-component.vuejs.org)
อันไหนดีกว่ากันครับ ในเรื่องประสิทธิภาพ