Vue
프로젝트 중, 중첩 객체 내부의 변화가 있을 때 html(template)
의 re-rendering
이 일어나지 않음1) 페이지 새로고침
: 원시적이지만 가장 확실한 방법입니다. 하지만 막대한 비용이 수반되며 사용자 경험을 해친다는 단점이 있습니다.
2) 참조값 변경
: 중첩 객체 내부에서 변경이 일어나면 겉의 객체를 복사하여 참조값을 재할당 하는 방식입니다. 객체 내부 구조가 복잡한 경우 깊은 복사를 해야합니다.
3) v-if
: flag 변수를 설정하고 v-if를 통해서 컴포넌트를 지웠다가 다시 그리는 방식입니다. nextTick을 비동기 방식으로 처리해야하며 컴포넌트가 완전히 새로 그려지면서 불필요한 비용이 수반될 수 있습니다.
4) vue.$set
: 간단한 경우에는 vue.set을 통해서 반응성 상태 업데이트 트리거를 작동시킬 수 있으며, 대부분의 경우 해결이 가능한 방법입니다. 하지만 다른 개발자가 제작한 라이브러리를 활용하거나 여러 수정사항을 받아 한꺼번에 업데이트하는 것과 같이 복잡한 상황에서는 활용도가 떨어졌습니다.
5) $forceUpdate
: 아주 훌륭한 해결책입니다. 하지만 Vue가 업데이트를 감지한 경우에도 리렌더를 강제하는 경우가 생길 수 있으며 그 비용은 상당하기 때문에 신중하게 사용해야 합니다.
6) componentKey 할당
: 컴포넌트에 key를 할당하고 업데이트할 때 key 값을 업데이트해줍니다. 반복문 내에서도 업데이트하고자 하는 컴포넌트의 key값만 업데이트하면 되기 때문에 불필요하게 넓은 범위의 리렌더링이 일어나지 않습니다. 주의할 점은 key값이 중복되지 않도록 하는 것입니다.
<v-data-table :items="people"></v-data-table>
(...)
data() {
return {
people: [
{
name: "Peter",
age: 30,
playList: [
{
singer: 'Darin',
title: 'autumn'
},
{
singer: 'Sunwoojunga',
title: 'Run With Me'
},
]
},
{
...
}
]
기존의
people
데이터에push
를 통해 새 값을 추가하면people
의 참조값은 변하지 않는다.
const newPeople = {...};
this.people.push(newPoeple);
새 값을 추가한 후
clone
을 만들어서 참조값을 재할당하면re-rendering
이 일어난다.
clone
을 만들 때는JSON.parse && JSON.stringify
를 활용함(참조2)
const newPeople = {...};
this.people.push(newPoeple);
const clone = JSON.parse(JSON.stringify(this.people));
this.people = clone;
아래와 같은 상황에서
Child
의addPerson
함수를 실행할 경우,
참조값을 수정하는 방식으로 진행했기 때문에
Parent
의DOM
의{{ people }}
은 수정되지만,
다시people
내에person
을 참조하고 있는Child Component
의v-data-table
은re-rendering
되지 않음.
즉,Parent
에서re-rendering
되도록Child
에서 조작해주었지만, 정작Child
는 변화가 없었음
<template>
<v-card>
<v-card-title>{{ people }}<v-card-title>
<v-card-text>
<Child v-for="(person, index) in people" :key="index"
:person="person" @addPerson="addPerson"></Child>
</v-card-text>
</v-card>
</template>
<script>
data() {
return {
people: (...)
}
},
methods: {
addPerson() {
(...)
}
}
</script>
<template>
<v-container>
<v-btn @click="addPerson"> Button </v-btn>
<v-data-table :item="person.playList"></v-data-table>
</v-container>
</template>
<script>
props: ['person']
methods: {
addPerson() {
// 참조값을 수정하는 방식으로 person을 people에 추가하여 Parent에 "emit"하는 로직
emit()
}
}
</script>
Parnet
의Data
에cKey(componentKey)
를 할당하고Child
에props
로 넘김.Child
는 받은cKey
를component
의props
로 재할당함.Child
에서Parent
로emit
할 때cKey
를 업데이트하는 로직을 추가함.
즉,Child
의component
에서도re-rendering
될 트리거가 제공됨.
<template>
<v-card>
<v-card-title>{{ people }}<v-card-title>
<v-card-text>
<Child v-for="(person, index) in people" :key="index"
:person="person" :cKey="cKey" @addPerson="addPerson"></Child>
<!-- 🚨 Child Components에 props로 "cKey"를 넘김 -->
</v-card-text>
</v-card>
</template>
<script>
data() {
return {
cKey: 0,
people: (...)
}
},
methods: {
updateCKey() {
this.cKey++;
}
addPerson() {
(...)
this.updateCKey();
// 🚨 기존 로직을 탄 후에 cKey를 update!
}
}
}
</script>
<template>
<v-container>
<v-btn @click="addPerson"> Button </v-btn>
<v-data-table :item="person.playList" :cKey="cKey"></v-data-table>
<!-- 🚨 받은 cKey를 다시 넘김 -->
</v-container>
</template>
<script>
props: ['person', 'cKey'] // cKey를 props로 받고 v-data-table로 다시 넘김
methods: {
addPerson() {
// 참조값을 수정하는 방식으로 person을 people에 추가하여 Parent에 "emit"하는 로직
}
}
</script>
<template>
<div class="text-center">
<v-btn color="primary" dark @click="openBottomSheet"> Workout </v-btn>
<v-bottom-sheet
v-if="existence"
v-model="sheet"
:fullscreen="isFullsreen"
scrollable
persistent
hide-overlay
class="here"
style="z-index: 1"
>
<v-sheet class="text-center" :height="bottomSheetHeight">
<v-card>
<v-card-title v-if="mode == 'create'">
<v-btn
text
color="error"
class="pa-0"
min-width="40px"
@click="eraseWorkoutSheet"
>
<v-icon>mdi-close</v-icon>
</v-btn>
<v-spacer></v-spacer>
<v-divider class="mx-3" inset vertical></v-divider>
<v-spacer></v-spacer>
<v-text-field
label="Routine name"
v-model="form.name"
hide-details
required
></v-text-field>
<v-spacer></v-spacer>
<v-divider class="mx-3" inset vertical></v-divider>
<v-spacer></v-spacer>
<v-btn
color="primary"
:disabled="!exercises.length"
@click="savePreProcessing"
>
저장
</v-btn>
</v-card-title>
<v-card-title v-if="mode == 'record'">
<span>Routine Title</span>
<v-spacer></v-spacer>
<v-divider class="mx-3" inset vertical></v-divider>
<v-spacer></v-spacer>
<v-btn @click="fullscreenToggle" outlined small color="secondary">
<span v-if="isFullsreen">Minimize 👇🏻</span>
<span v-else>Maximize 👆🏻</span>
</v-btn>
</v-card-title>
<v-card-subtitle align="left" v-if="mode == 'record'">
<StopWatch></StopWatch>
</v-card-subtitle>
<v-card-text style="overflow-y: scroll; height: calc(100vh - 80px)">
<draggable v-model="exercises" @change="updateCKey">
<ExerciseBlock
v-for="(exercise, exerciseIndex) in exercises"
:key="exerciseIndex"
:exercise="exercise"
:cKey="cKey"
@updateExerciseSet="updateExerciseSet($event, exerciseIndex)"
></ExerciseBlock>
</draggable>
<div>
{{ exercises }}
</div>
<div style="display: flex; flex-direction: column">
<v-dialog v-model="exerciseDialog" fullscreen persistant>
<template v-slot:activator="{ on, attrs }">
<v-btn
class="mt-3"
color="primary"
block
outlined
@click="openExerciseDialog"
v-bind="attrs"
v-on="on"
>
종목 추가
</v-btn>
</template>
<v-card>
<Exercise
v-if="exerciseDialog"
mode="select"
@selectExerciseComplete="addSelectedExercises"
@closeExerciseDialog="closeExerciseDialog"
></Exercise>
</v-card>
</v-dialog>
<v-btn
v-if="mode == 'record'"
class="mt-3"
color="error"
block
outlined
>
운동 종료
</v-btn>
</div>
</v-card-text>
</v-card>
</v-sheet>
</v-bottom-sheet>
</div>
</template>
<script>
import StopWatch from "@/utils/StopWatch";
import Exercise from "@/views/Exercise";
import ExerciseBlock from "@/components/ExerciseBlock";
import draggable from "vuedraggable";
export default {
components: {
StopWatch,
Exercise,
ExerciseBlock,
draggable,
},
watch: {
isFullsreen() {
if (this.isFullsreen) {
this.$emit("fullscreen");
} else {
this.$emit("halfscreen");
}
},
},
computed: {
bottomSheetHeight() {
if (this.isFullsreen) {
return "100vh";
} else {
return "200px";
}
},
},
data() {
return {
cKey: 0,
mode: "create", // record || create
existence: false,
exercises: [],
newRoutine: [],
testRoutine: {
name: "hell",
age: 30,
},
exerciseDialog: false,
editIndex: -1,
sheet: false,
isFullsreen: true,
form: {
name: "New Routine",
},
};
},
methods: {
updateCKey() {
this.cKey++;
},
openBottomSheet() {
this.sheet = true;
this.existence = true;
this.isFullsreen = true;
},
eraseWorkoutSheet() {
if (this.exercises > 0) {
if (confirm("데이터가 모두 삭제됩니다. 그래도 닫으시겠어요? 🧙🏻♂")) {
this.existence = false;
this.exercises = [];
}
} else {
this.existence = false;
this.exercises = [];
}
},
closeBottomSheet() {
this.sheet = false;
},
fullscreenToggle() {
this.isFullsreen = !this.isFullsreen;
},
openExerciseDialog() {
this.exerciseDialog = true;
},
closeExerciseDialog() {
this.exerciseDialog = false;
},
addSelectedExercises(selectedExercises) {
this.exercises.push(...selectedExercises);
this.closeExerciseDialog();
},
updateExerciseSet($event, exerciseIndex) {
this.exercises[exerciseIndex].dataOfSet = $event;
this.updateCKey();
},
savePreProcessing() {
const userUuid = this.$store.state.userUuid;
let countOfExercise = 0;
this.exercises.forEach((exercise) => {
++countOfExercise;
let countOfSet = 0;
const dataOfSet = exercise.dataOfSet;
for (const item in dataOfSet) {
const newLine = [];
newLine.push(
userUuid,
exercise.exerciseUuid,
countOfExercise,
++countOfSet,
dataOfSet[item].plusWeight,
dataOfSet[item].minusWeight,
dataOfSet[item].lap,
dataOfSet[item].timeMin,
dataOfSet[item].timeSec
);
this.newRoutine.push(newLine);
}
});
// test print
this.newRoutine.forEach((item) => {
console.log(item);
});
this.save();
},
async save() {
try {
// Test Get
// const userUuid = this.$store.state.userUuid;
// const res = await this.$http.get(`/api/routine/${userUuid}`);
const newRoutine = this.newRoutine;
const res = await this.$http.post(`/api/routine/regist`, {
newRoutine,
});
if (res.data.success == true) {
this.$store.dispatch("popToast", {
msg: res.data.message,
color: "primary",
});
console.log(res.data.message);
this.exercises = [];
this.eraseWorkoutSheet();
} else {
this.$store.dispatch("popToast", {
msg: res.data.message,
color: "error",
});
console.log(res.data.message);
}
} catch (err) {
this.$store.dispatch("popToast", {
msg: `Regist Failed(500) ${err}`,
color: "error",
});
console.log(err);
} finally {
this.newRoutine = [];
}
},
},
};
</script>
<style lang="scss">
</style>
<template>
<v-card class="elevation-1">
<v-card-title class="pb-0 subtitle-1 font-weight-bold">
<v-chip class="mr-2" color="primary" outlined small>
{{ exercise.target }} | {{ exercise.category }}
</v-chip>
<v-spacer></v-spacer>
<v-btn text min-width="40px" class="pa-0">
<v-icon> mdi-dots-horizontal </v-icon>
</v-btn>
</v-card-title>
<v-card-title class="py-0 subtitle-1 font-weight-bold">
{{ exercise.name }}
</v-card-title>
<v-card-text>
<v-card-subtitle align="left" class="pa-0" v-if="exercise.note">
{{ exercise.note }}
</v-card-subtitle>
<v-data-table
:cKey="cKey"
class="exercise-block-data-table"
:headers="headers"
:items="exercise.dataOfSet"
mobile-breakpoint="1"
disable-pagination
hide-default-footer
>
<template v-slot:[`item.setCount`]="{ item }">
{{ exercise.dataOfSet.indexOf(item) + 1 }}
</template>
<template v-slot:[`item.plusWeight`]="{ item }">
<v-text-field
style="display: block; width: 60px; margin: 0 auto"
outlined
dense
v-model.number="item.plusWeight"
hide-details
reverse
type="number"
@input="emitData"
></v-text-field>
</template>
<template v-slot:[`item.minusWeight`]="{ item }">
<v-text-field
v-model.number="item.minusWeight"
style="display: block; width: 60px; margin: 0 auto"
outlined
dense
reverse
hide-details
type="number"
@input="emitData"
></v-text-field>
</template>
<template v-slot:[`item.time`]="{ item }">
<div style="display: flex">
<v-text-field
v-model.number="item.timeMin"
style="width: 12px; margin: 0 auto"
outlined
dense
reverse
hide-details
type="number"
@input="emitData"
></v-text-field>
<div class="py-2 px-1">:</div>
<v-text-field
v-model.number="item.timeSec"
style="width: 12px; margin: 0 auto"
outlined
dense
reverse
hide-details
type="number"
@input="emitData"
></v-text-field>
</div>
</template>
<template v-slot:[`item.lap`]="{ item }">
<v-text-field
v-model.number="item.lap"
style="display: block; width: 40px; margin: 0 auto"
outlined
dense
reverse
hide-details
type="number"
@input="emitData"
></v-text-field>
</template>
<template v-slot:[`item.actions`]="{ item }">
<v-icon
color="error"
small
:disabled="exercise.dataOfSet.length == 1"
@click="deleteSet(item)"
>
mdi-delete
</v-icon>
</template>
</v-data-table>
<v-btn class="mt-3" block outlined small @click="addNewSet">
세트 추가
</v-btn>
<div>
{{ exercise.dataOfSet }}
</div>
</v-card-text>
</v-card>
</template>
<script>
export default {
props: {
exercise: {
type: Object,
default: () => ({}),
},
cKey: {
type: Number,
default: 0,
},
},
mounted() {
// 여기에서 userUuid, exerciseUuid를 들고 database에서 history를 뒤져서 가져와야할 듯
this.initDataOfSet();
this.initHeader();
},
watch: {
exercise: {
deep: true,
handler() {
this.initHeader();
},
},
},
data() {
return {
exerciseType: [],
headers: [],
};
},
methods: {
initDataOfSet() {
this.exercise.dataOfSet = [];
this.exercise.dataOfSet.push({
prev: 0,
plusWeight: 0,
minusWeight: 0,
lap: 0,
timeMin: 0,
timeSec: 0,
});
this.emitData();
},
checkExerciseCategory() {
this.exerciseType.length = 0;
switch (this.exercise.category) {
case "바벨":
case "덤벨":
case "머신":
case "기구":
case "케이블":
case "가중":
case "바벨":
case "기타":
this.exerciseType.push("plusWeight");
this.exerciseType.push("lap");
break;
case "보조":
this.exerciseType.push("minusWeight");
this.exerciseType.push("lap");
break;
case "시간":
this.exerciseType.push("time");
break;
case "밴드":
case "렙":
this.exerciseType.push("lap");
break;
}
},
initHeader() {
this.headers = [
{
text: "Set",
align: "center", // start, center, end
value: "setCount",
sortable: false,
},
{
text: "Prev",
align: "center",
value: "prev",
sortable: false,
},
{
text: "X",
align: "center",
value: "actions",
sortable: false,
},
];
this.checkExerciseCategory();
this.exerciseType.forEach((type) => {
this.headers.splice(this.headers.length - 1, 0, {
text: this.replaceText(type),
align: "center",
value: type,
sortable: false,
});
});
},
replaceText(type) {
if (type == "plusWeight") {
return "+kg";
} else if (type == "minusWeight") {
return "-kg";
} else {
return type.charAt(0).toUpperCase() + type.slice(1);
}
},
addNewSet() {
const lastOfDataOfSet = this.exercise.dataOfSet.length - 1;
const newSet = Object.assign(
{},
this.exercise.dataOfSet[lastOfDataOfSet]
);
this.exercise.dataOfSet.push(newSet);
const refresh = JSON.parse(JSON.stringify(this.exercise.dataOfSet));
this.$emit("updateExerciseSet", refresh);
},
deleteSet(item) {
if (this.exercise.dataOfSet.length == 1) return;
const deleteIndex = this.exercise.dataOfSet.indexOf(item);
this.exercise.dataOfSet.splice(deleteIndex, 1);
const refresh = JSON.parse(JSON.stringify(this.exercise.dataOfSet));
this.$emit("updateExerciseSet", refresh);
},
emitData() {
this.$emit("updateExerciseSet", this.exercise.dataOfSet);
},
},
};
</script>
<style lang="scss">
.exercise-block-data-table {
th {
padding: 0px 10px !important;
}
td {
padding: 0px 10px !important;
}
.v-input__slot {
padding: 0 6px !important;
}
input {
text-align: center !important;
}
}
</style>
참조1: The correct way to force Vue to re-render a component
참조2: 자바스크립트 객체 복사하기