vue3+ts


Популярность Typescript растет день ото дня. Javascript нетипизированный язык(или слабо типизированный, если точнее), и одна и та же переменная способна принимать и строку, и число, и даже объект. С одной стороны, это делает язык гибким, с другой, потенциально ведет к многочисленным ошибкам. Typescript создан, чтобы решить эту проблему. Vue старается не отставать от моды, и в новой версии фреймворка была значительно улучшена поддержка языка. Теперь переход на Typescript проще и приятнее, чем был раньше. Хороший повод научиться чему-то новому, тем более, что в требованиях к вакансиям он встречается все чаще и чаще.


В этой статье мы перепишем тестовое задание, которое я разбирал ранее, на Vue 3 и Typescript и вдобавок используем обновленные Vue-Router и Vuex(критики, вы были услышаны).


Для тех, кто забыл, что представляет из себя техническое задание: освежить память можно здесь и здесь.


Все вопросы связанные с тем, как приступить к этому заданию были разобраны в предыдущей статье, поэтому без лишних слов к делу!


Для начала обновите ваш Vue CLI. Я знаю, что вы давно его не обновляли, а там много полезных фич завезли, в том числе улучшенную поддержку Typescript.


npm update -g @vue/cli

Теперь откройте папочку, где вы храните свои проекты и наберите в консоли:


vue create tsvue-todo-app

Из появившихся вариантов установки выбираем третий вариант: Manually select features. Далее обязательно помечаем звездочками следующие варианты:


  • (*) TypeScript
  • (*) Router
  • (*) Vuex

Выбираем версию Vue для проекта: 3.x (Preview)


Все остальные опции как рекомендовано.


Ждем установки проекта


cd tsvue-todo-app

Открываем любимым редактором проект.


Удаляем все файлы из папок components, assets и views. Очищаем файл App.vue от кода.


Для начала займемся нашим хранилищем, которое находится в папке store. Его, как и все приложение, мы напишем на typescript. Моя статья, если это не было понятно из предисловия, рассчитана на тех, кто хорошо знаком с Vue и javascript, но почти ничего не знает о typescript.


Несколько слов о typescript. Его можно воспринимать как обертку или препроцессор для джаваскрипта, который проверяет код на соблюдение строгой типизации, то есть, что переменные, функции и объекты принимают только те типы данных, для которых и были задуманы, в остальном это обычный javascript. Соблюдена обратная совместимость, то есть вы можете писать на ts, как на js, и ваш код будет работать. Браузер, конечно же, не понимает typescript, для этого его нужно компилировать в javascript, который в итоге и идет в продакшен. К счастью, вам не нужно об этом беспокоиться, Vue CLI настроил все для вас. Достаточно запомнить особенности создания компонентов на typescript во Vue, для этого есть отличное руководство от самих создателей фреймворка(пока что доступно только на английском), и выучить типизацию данных в typescript, которая во Vue имеет свои особенности.

Существует множество способов создания типов в typescript. С полным спектром возможностей для наследования и инкапсуляции, которые так любят сторонники строго типизированных языков(таких как C# и Java). Мы пойдем по простому пути. Используем interface для создания модели объектов дел и списков дел. Создадим папку models в папке src. Добавим файл ToDoModel.ts с кодом:


export default interface ToDo {
  text: string;
  completed: boolean;
}

Теперь добавим файл NoteModel.ts:


import ToDo from './ToDoModel';

export default interface Note {
  title: string;
  todos: Array<ToDo>;
  id: number;
}

Обратите внимание, что типы примитивов пишутся с маленькой буквы, они хоть и похожи, но отличаются от собратьев из js. Строка todos: Array<ToDo> означает массив, содержащий элементы типа ToDo, который мы импортировали из ToDoModel.ts


Теперь мы можем использовать эти модели в нашем приложении. Сначала займемся хранилищем. Оно должно находиться в src/store/index.ts. В первую очередь я советую установить строгий режим для хранилища. Дело в том, что данные в нем реактивные, как и во Vue, то есть, если вы просто используете их в компонентах, они будут автоматически меняться, в следствии локальных действий над ними. Это приведет к неразберихе в больших приложениях со множеством данных. Теперь мутации хранилища возможны только в самом хранилище, в противном случае будут вылетать ошибки. Импортируем наши модели. Создаем две переменные notes: [] as Note[],, будет содержать все заметки со списками задач(сразу определяем его тип с помощью typescript как массив записей), и currentNote, для создания/редактирования выбранной заметки. Создаем "небольшую" CRUD модель для них, и вот что должно получиться:


import { createStore } from 'vuex'
import Note from '@/models/NoteModel'
import ToDo from "@/models/ToDoModel"

export default createStore({
  state: {
    notes: [] as Note[],
    currentNote: {
      title: "",
      todos: [] as ToDo[],
      id: 0
    } as Note
  },
  mutations: {
    addNote(state) {
      state.notes.push(JSON.parse(JSON.stringify(state.currentNote)))
    },
    deleteNote(state) {
      state.notes = state.notes.filter(note => note.id != state.currentNote.id)
    },
    updateNote(state) {
      let note = state.notes.find(note => note.id === state.currentNote.id)
      let index = state.notes.indexOf(note as Note)
      state.notes[index] = JSON.parse(JSON.stringify(state.currentNote))
    },
    setCurrentNote(state, payload: Note) {
      state.currentNote = JSON.parse(JSON.stringify(payload))
    },
    updateTitle(state, payload: string) {
      state.currentNote.title = payload
    },
    updateTodos(state, payload: ToDo[]) {
      state.currentNote.todos = payload
    },
    addNewTodo(state) {
      state.currentNote.todos.push({
        text: "",
        completed: false
      })
    },
    deleteTodo(state, index: number) {
      state.currentNote.todos.splice(index, 1)
    }
  },
  actions: {
    saveNote({ commit }) {
      const isOldNote: boolean = this.state.notes.some(el => el.id === this.state.currentNote.id)
      if (isOldNote) {
        commit('updateNote')
      }
      else {
        commit('addNote');
      }

    },
    fetchCurrentNote({ commit }, noteId: number) {
      let note = this.state.notes.find(note => note.id === noteId)
      commit('setCurrentNote', note)
    },
    updateCurrentNote({ commit }, note: Note) {
      commit('setCurrentNote', note)
    },

  },
  getters: {
    getIdOfLastNote(state): number {
      if (state.notes.length > 0) {
        const index = state.notes.length - 1

        return state.notes[index].id
      } else {
        return 0
      }
    }
  },
  strict: true

})

Как видите, нет большой разницы с написанием кода, кроме того, что теперь всем переменным и входящим параметрам указывается тип, а для создания хранилища требуется импортировать функцию createStore. currentNote будет использоваться для хранения заметки, которую редактируют в настоящий момент, при создании новой она будет пустой, при редактировании старой будем получать нужную запись из массива записей notes.


А теперь перейдем к созданию первого компонента. Добавим файл ToDoItem.vue в папку components. Он нам пригодится для компонента создания/редактирования записи, и будет отвечать за отдельное дело.


<template>
  <li>
    <div>
      <input v-model="checked" type="checkbox" />
    </div>
    <div>
      <span
        :class="{ completed: todo.completed }"
        v-if="!editable"
        @click="editable = !editable"
      >
        {{ todo.text ? todo.text : "Click to edit Todo" }}
      </span>
      <input
        v-else
        type="text"
        :value="todo.text"
        @input="onTextChange"
        v-on:keyup.enter="editable = !editable"
      />
    </div>

    <div>
      <button @click="editable = !editable">
        {{ editable ? "Save" : "Edit" }}
      </button>
      <button @click="$emit('remove-todo', todo)">Delete</button>
    </div>
  </li>
</template>

<script lang="ts">
  import { defineComponent, PropType } from "vue"
  import ToDo from "@/models/ToDoModel"

  export default defineComponent({
    name: "TodoItem",
    props: {
      todo: {
        type: Object as PropType<ToDo>,
        required: true
      }
    },
    data() {
      return {
        editable: false
      }
    },
    methods: {
      onTextChange(e: { target: { value: string } }) {
        this.$emit("update-todo", e.target.value)
      }
    },
    computed: {
      checked: {
        get(): boolean {
          return this.todo.completed
        },
        set(value: boolean) {
          this.$emit("checkbox-click", value)
        }
      }
    }
  })
</script>

<style scoped>
  .completed {
    text-decoration: line-through;
  }
</style>

Обратите внимание, что Vue компонент на typescript создается с помощью команды defineComponent. Всё внутреннее Options API должно быть вам знакомо. Единственно, что возникает путаница, при типизации props компилятор думает, что происходит операция присваивания. Нам пригодится объект PropType, который импортируется также из vue, он служит для указания типа объекта в typescript. В сложных ситуациях можно на месте указывать какое строение принимаемого объекта мы ожидаем onTextChange(e: { target: { value: string } }). Конечно, еще можно присваивать тип any, тогда переменная будет принимать все типы данных, но это убивает весь смысл typescript.


Строка this.$emit("update-todo", e.target.value) это просто вызов функции, которую передадут из внешнего компонента, и передача ей параметра e.target.value. Во внешней функции это будет выглядеть так:


      <TodoItem

        <?--... -->
        @update-todo="onUpdateTodo($event, index)"
      />

Далее нам предстоит создание компонента Note.vue. Для интереса используем новый Composition API и функцию setup, все так же на typescript. Это усложняет работу, так как придется импортировать кучу вещей из vue, вот так:
import { defineComponent, computed, onMounted } from "vue". Наши $store и $router не будут доступны в функции setup, для доступа к ним — их тоже придется импортировать. Мой обзор Composition API и работу с setup можно прочитать в моей прошлой статье. Напомню только, что функция setup вызывается до создания компонента и после получения props, хуки created и beforeCreate в ней не нужны, фактически это новый beforeCreate/created, все что вы писали в этих хуках, теперь нужно писать в setup. Что делает компонент Note.vue? Он используется для создания новой заметки и редактирования старых, для начала он связывает currentNote из хранилища с переменной note, потом он проверяет роута, если в нем присутствует :id, он просит хранилище найти нужную заметку в массиве заметок и записать её в currentNote, если нет, то устанавливает пустую заметку. А так же он содержит все логику для редактирования и сохранения заметок и отдельных дел.


<template>
  <div>
    <input :value="note.title" @input="updateTitle" />
    <h2>{{ note.title }}</h2>
    <hr />
    <ul>
      <TodoItem
        v-for="(todo, index) in note.todos"
        :todo="todo"
        :key="index"
        @remove-todo="onRemoveTodo(index)"
        @update-todo="onUpdateTodo($event, index)"
        @checkbox-click="onCheckboxClick($event, index)"
      />
    </ul>
    <div class="new-todo">
      <button @click="addNewTodo">
        Add Todo
      </button>
      <span @click="addNewTodo">Add New Todo</span>
    </div>
    <hr />
    <div>
      <button @click="saveNote">
        Save
      </button>
      <button @click="cancelEdit">
        Cancel
      </button>
      <button @click="DeleteNote">
        Delete
      </button>
    </div>
    <hr />
  </div>
</template>

<script lang="ts">
  import TodoItem from "@/components/ToDoItem.vue"
  import { defineComponent, computed, onMounted } from "vue"
  import Note from "@/models/NoteModel"
  import ToDo from "@/models/ToDoModel"
  import store from "@/store"
  import router from "@/router"

  export default defineComponent({
    name: "Note",
    components: {
      TodoItem
    },
    setup() {
      const note = computed(() => store.state.currentNote)

      const saveNote = () => {
        store.dispatch("saveNote")
        router.push("/")
      }
      const DeleteNote = () => {
        store.commit("deleteNote", note)
        router.push("/")
      }

      const { currentRoute } = router
      const fetchNote = () => {
        if (currentRoute.value.params.id) {
          const routeId: number = +currentRoute.value.params.id
          store.dispatch("fetchCurrentNote", routeId)
        } else {
          const id = store.getters.getIdOfLastNote + 1
          store.commit("setCurrentNote", {
            title: "",
            todos: [] as ToDo[],
            id: id
          })
        }
      }
      onMounted(fetchNote)

      const updateTitle = (e: { target: { value: string } }) => {
        store.commit("updateTitle", e.target.value)
      }

      const addNewTodo = () => {
        store.commit("addNewTodo")
      }

      const onRemoveTodo = (index: number) => {
        store.commit("deleteTodo", index)
      }
      const onUpdateTodo = (text: any, index: any) => {
        let todos = JSON.parse(JSON.stringify(store.state.currentNote.todos))

        todos[index].text = text
        store.commit("updateTodos", todos)
      }
      const onCheckboxClick = (value: boolean, index: number) => {
        let todos = JSON.parse(JSON.stringify(store.state.currentNote.todos))

        todos[index].completed = value
        store.commit("updateTodos", todos)
      }

      const cancelEdit = () => {
        if (currentRoute.value.params.id) {
          // undo changes
        } else {
        }
        router.push("/")
      }
      const clearNote = () => {
        const id = store.getters.getIdOfLastNote + 1
        store.commit("setCurrentNote", {
          title: "",
          todos: [] as ToDo[],
          id: id
        } as Note)
      }

      return {
        note,
        saveNote,
        addNewTodo,
        cancelEdit,
        onRemoveTodo,
        onUpdateTodo,
        DeleteNote,
        clearNote,
        updateTitle,
        onCheckboxClick
      }
    },
    beforeRouteLeave(to, from, next) {
      this.clearNote()
      next()
    }
  })
</script>

<style>
  .new-todo {
    display: flex;
    justify-content: flex-start;
    background-color: #e2e2e2;
    height: 36px;
    margin: 5px 0px;
    padding-top: 4px;
    padding-left: 10px;
    padding-right: 15px;
    border-radius: 5px;
  }
</style>

Теперь логику компонента с помощью setup можно группировать для удобства чтения. Красота!


Вне функции setup вы заметили следующую функцию:



    beforeRouteLeave(to, from, next) {
      this.clearNote()
      next()
    }

Это хук роутера, который вызывается каждый раз, когда юзер покидает страницу компонента. У нас он вызывает функцию "очистки" заметки.


Конечно, все это не будет работать без роутера. Вот какой код следует написать в \router\index.ts:


import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import List from '@/views/List.vue'
import Note from '@/views/Note.vue'

const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    name: 'List',
    component: List
  },
  {
    path: '/note',
    name: 'Create',
    component: Note
  },
  {
    path: '/note/:id',
    name: 'Edit',
    component: Note
  }
]

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})

export default router

А так же отредактируем файл App.vue:


<template>
  <div id="app">
    <nav>
      <router-link class="router-link" to="/" exact>List Of Notes</router-link>
      <router-link class="router-link" to="/note" exact
        >Create Note</router-link
      >
    </nav>
    <hr />
    <router-view />
  </div>
</template>

<script>
  export default {
    name: "App"
  }
</script>

И добавим файл List.vue в папку views:


<template>
  <h2>List of Notes</h2>
  <hr />
  <div v-for="note in notes" :key="note.id" :to="`/note/${note.id}`">
    <h2>{{ note.title }}</h2>
    <h3>{{ note.id }}</h3>
    <ul>
      <li
        :class="{ completed: todo.completed }"
        v-for="(todo, index) in note.todos"
        :key="index"
      >
        {{ todo.text }}
      </li>
    </ul>
    <button @click="$router.push(`/note/${note.id}`)">Go to note</button>
    <hr />
  </div>
</template>

<script>
  export default {
    computed: {
      notes() {
        return this.$store.state.notes
      }
    }
  }
</script>

<style>
  .completed {
    text-decoration: line-through;
  }
</style>

Теперь все должно работать. Если вы запутались, то можно клонировать весь проект отсюда.




Это не все тонкости работы с typescript на vue. Это только начало для интересных открытый и возможностей. Рекомендую также прочитать официальную документацию поддержка ts на Vue 3 и typescript.


Конечно, наш проект далек от завершения. Внешний вид убогий(отсутствуют стили) и необходимый функционал: кеширование записей, модальные окна-предупреждения, кнопки redo\undo для истории редактирования. К счастью, большая часть этих проблем решается плагинами. А настоящий фронтендер, познавший дзен разработки, знает, что для решения любой сложной задачи, есть свой npm-пакет. Этим мы и займемся в следующей статье.