[Vue2] 중첩 객체 감지 리렌더링 | Nested Object Detect & Re-rendering

protect-me·2021년 8월 5일
0
post-thumbnail

🚨 발단


  • Vue 프로젝트 중, 중첩 객체 내부의 변화가 있을 때 html(template)re-rendering이 일어나지 않음
  • 리렌더링이 일어나지 않는 또 다른 경우와 해결 방법에 대해서 정리함

🧩 Re-rendering 일어나지 않는 경우

Reactivity in Depth

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;

🚀 ComponentKey를 할당하라


기존(Child ► Parent)

아래와 같은 상황에서 ChildaddPerson 함수를 실행할 경우,
참조값을 수정하는 방식으로 진행했기 때문에
ParentDOM{{ people }}은 수정되지만,
다시 people내에 person을 참조하고 있는 Child Componentv-data-tablere-rendering되지 않음.
즉, Parent에서 re-rendering되도록 Child에서 조작해주었지만, 정작 Child는 변화가 없었음

View(Parent)

<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>  

Component(Child)

<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>  

수정(Child ► Parent ► Child) ✨✨✨✨✨

  1. ParnetDatacKey(componentKey)를 할당하고 Childprops로 넘김.
  2. Child는 받은 cKeycomponentprops로 재할당함.
  3. Child에서 Parentemit할 때 cKey를 업데이트하는 로직을 추가함.
    즉, Childcomponent에서도 re-rendering될 트리거가 제공됨.

View(Parent)

<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>  

Component(Child)

<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>  



🧩 실제 적용 사례(스압 주의)

WorkoutSheet.vue

<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>

ExerciseBlock.vue

<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: 자바스크립트 객체 복사하기

profile
protect me from what i want

0개의 댓글