Introducción a vue 3

Este curso está orientado a empezar con Vue. Usamos la versión de Vue 3 ya que ya está estable y lista para producción. Vue 3 comparte muchos conceptos de la versión 2 y añade nuevas funcionalidades. Las librerías como tal son progresivamente adaptables, lo que significa que en teoría no hay breaking changes, y un proyecto hecho en Vue 2 debería ser portable a Vue 3. A lo largo del curso veremos que esto no es del todo exacto ya que el sistema de plugins (como usar otras librerías que añaden funcionalidades) ha cambiado, por lo que adaptar proyectos antiguos a Vue 3 va a requerir cierto esfuerzo de refactorización.

Hay que tener en cuenta que por regla general, los recursos disponibles para Vue 2 son diferentes de los de Vue 3. La documentación y la librerías serán diferentes.

Conceptos básicos

¿Qué es Vue?

Vue es un framework progresivo para construir interfaces gráficas. Vue esta pensado para ser adaptado de forma progresiva en los proyectos. Pero al final, junto a todo su ecosistema se convierte en un framework de-facto competidor de frameworks como angular o react.

Vue se podría utilizar como una librería a modo de sustituto de jquery. Una página web cualquiera podría añadir el script de Vue desde un CDN

<script src="https://unpkg.com/vue@next"></script>

y empezar a utilizar la librería para crear componentes y pintarlos, como se hace en este CodePen.

Se puede ver la estructura básica de una app Vue. Al instalar la librería disponemos del método createApp que recibe un objeto de configuración de la app. Este método devuelve una instancia de una app Vue. Dicha instancia tiene el método mount que permite pintar la app sobre un elemento existente del DOM, en este caso un <div id="app"></div>.

Observamos que la app tiene una propiedad (Una app es un componente que se utiliza como el componente raíz en Vue) llamada msg. Esta propiedad es pintada de forma dinámica en el DOM de la página.

Esta forma de trabajar no será la que utilicemos en el día a día ya que tiene muchas desventajas a trabajar con la herramienta de consola de Vue, Vue CLI.

Vue CLI, IDE, Single File Components (SFC)

Vue ofrece una CLI (Command Line Interface) para trabajar de forma más cómoda con él. Provee ayudas para crear proyectos y para tener un flujo de trabajo más moderno y eficiente. En pocos minutos tendremos un proyecto sobre el que trabajar con un montón de características que nos harán la vida más cómoda.

Para Vue 3 se debe usar Vue CLI v4.5, disponible en npm como @vue/cli. En caso de que se tenga una versión más antigua se deberá actualizar a dicha versión.

yarn global add @vue/cli
# OR
npm install -g @vue/cli

Crear un proyecto

Para esta primera parte de la formación vamos a trabajar en el típico ToDo List. Utilizaremos Vue CLI para crear el proyecto:

vue create todo-list

Tras la ejecución del comando, se nos ofrecerá una serie de opciones:

  1. Escogemos la última opción Manually select features que nos permite escoger las características según queramos.
  2. El segundo paso nos da opción a seleccionar las características de nuestro proyecto. Para el objetivo de la formación marcaremos las siguientes:
    • Choose Vue version
    • Babel (Es un transpilador javascript. Transforma código ESx en ES5)
    • Linter / Formatter
    • Unit Testing
  3. En el tercer paso escogeremos la opción 3.x.
  4. En el cuarto paso nos decidiremos por la opción ESLint + Prettier. (Utilizaremos prettier para formatear nuestro código. Es un formateador con configuración escogida de antemano que luego podríamos cambiar)
  5. En el quinto paso Lint on save.
  6. En el sexto paso Jest. (Jest es un framework de test que tiene mucho uso en la comunidad)
  7. En el séptimo paso escogeremos In dedicated config files. (Indicamos que los ficheros de configuración los queremos por separado)
  8. Finalmente indicamos si queremos guardar esta configuración para futuros proyectos con un nombre. De momento diremos que N.

Tras la selección de opciones, Vue CLI generará un proyecto en una subcarpeta de la carpeta donde estemos con el nombre que hayamos escogido al lanzar el comando.

Al finalizar la ejecución seguimos las instrucciones dadas y tendremos un proyecto listo para empezar a trabajar en él:

cd todo-list
yarn serve

Típicamente, si no tenemos el puerto 8080 ocupado, podremos navegar a http://localhost:8080 y ver el esqueleto de una aplicación Vue creada por Vue CLI.

Vue CLI genera una estructura de ficheros basada en Webpack en la versión 4. Pero la configuración de Webpack está escondida dentro de la dependencia @vue/cli-service que veremos más adelante.

Trabajar en el proyecto

Para trabajar durante la formación usaremos Visual Code. Es un Editor de Desarrollo Integrado (IDE) creado por Microsoft, pero opensource. Se puede instalar en cualquier SO y tiene la ventaja de ser muy ligero y potente. Está muy apoyado por la comunidad por lo que tendremos casi todas las herramientas que necesitemos para programar. Está especialmente indicado para proyectos JavaScript y TypeScript, aunque tiene extensiones para trabajar en muchos más lenguajes.

Una vez creado nuestro proyecto siguiendo los pasos de la sección anterior y teniendo instalado Visual Code podemos abrir el proyecto en él:

cd todo-list
code .

Para proyectos Vue, se recomienda instalar la extensión Vetur: Extensions -> Teclear Vetur -> Click en Install.

Esta extensión nos da herramientas y ayudas para trabajar más cómodo en proyectos Vue. Una vez instalada se recomienda reiniciar el Visual Code para ver todas sus características funcionando.

También se recomienda ir formateando el código con Ctrl + Shift + I. Si se pulsa el botón derecho del ratón sobre un editor, también podemos formatearlo e incluso elegir el formateador por defecto (Format Document With... -> Configure Default Formatter... -> Prettier - Code Formatter). Escogemos Prettier ya que lo seleccionamos al crear el proyecto con Vue CLI.

Al abrir el proyecto tendremos la siguiente estructura de ficheros y carpetas:

SFC (Single File Component)

Como podemos ver App.vue es un fichero especial hecho solo para el ecosistema de Vue. Es un Single File Component, un fichero que especifica todo lo necesario para un componente: Template, Javascript y Estilos.

Esto es posible porque al instalar el proyecto con Vue CLI, se nos han instalado una serie de plugins dentro de nuestra configuración Webpack que nos permiten tratar estos tipos de ficheros(vue-loader es el principal plugin que permite esto). Al final, al compilar y construir el proyecto para producción, cada parte irá correctamente montada al sitio que le corresponde.

Esta forma de trabajar nos permite tener todo lo relativo a un componente en el mismo fichero. A la vez que nos permite modularizar código y comportamientos similares en ficheros independientes.

Los tags <script> y <style> nos permite seleccionar si queremos programar en javascript o typescript y también las si queremos introducir los estilos css, scss, stylus, etc, a parte de indicar si queremos que sean estilos que solo afectan al componente o afectan al proyecto en general(scoped).

Diferencia Vue2 y Vue3

Vue 3 está concebido para poder ser incorporado desde el principio a cualquier proyecto Vue 2. Es decir, la adaptación a Vue 3 debería ser bastante sencilla ya que se ha realizado un esfuerzo por mantener la sintaxis para minimizar los breaking changes. Dicho esto, Vue 3 es una reescritura del código base de Vue 2. Hay conceptos nuevos como la Composition API o la Reactividad. Estos conceptos hacen que muchas librerías aún no sean utilizables con Vue 3, ya que fueron concebidas como plugins de Vue 2, con el sistema de plugins de Vue 2. Debido a esto, aunque Vue 3 ya esté estable y pueda ser utilizado en producción, muchos de sus plugins y por lo tanto su ecosistema aún no está listo.

Por poner ejemplos concretos, muchas librerías muy conocidas como Vuetify, VueMaterial no están aún adaptadas a trabajar con Vue 3, con las implicaciones en el desarrollo y la productividad que pueda tener en muchos proyectos.

A lo largo de 2021, estás librerías irán migrando con toda seguridad para no quedarse atrás.

ToDo List: Una pequeña aplicación de ejemplo

La primera aplicación que haremos con Vue es una típica ToDo List que nos permitirá recorrer los conceptos básicos de la librería Vue 3.

Partiremos del scaffolding creado con Vue CLI e iremos modificando y añadiendo componentes.

Nuestro pequeño proyecto consta de tres componentes a parte del App.vue:

El código lo realizaremos con JavaScript. El código base de Vue 3 está realizado con TypeScript, por lo que ahora será más fácil utilizar TypeScript en los nuevos proyectos. Pero nuestro código de ejemplo estará en JavaScript. Aún así tendremos la dependencia de TypeScript para poder ejecutar los tests.

Los estilos de la aplicación están en scss. Como veremos más adelante, éstos pueden ir en los propios componentes o a nivel global de la aplicación.

Preparar nuestro código

Primero, lo que necesitamos hacer con el código generado por Vue CLI es borrar parte del código que se ha autogenerado.

Para ello, tendremos que borrar el fichero src/components/HelloWorld.vue, la referencia a él dentro de src/App.vue y el fichero de test test/unit/example.spec.js.

Estos ficheros están porque seleccionamos la opción de unit test en en Vue CLI. Al seleccionar dicha opción también se nos configuró la tarea

yarn unit:test

dentro del fichero package.json.

Si ahora ejecutamos nuestro código, veremos un error al lanzar los test (yarn test:unit). ¡No existen test!

Primero vamos a crear un nuevo fichero components/Logo.vue que mostrará un logo y un título para nuestra aplicación. ¡Nuestro primer componente Vue!:

<template>
  <div class="logo">
    <img alt="logo" src="../assets/logo.png" />
    <h1>ToDo List</h1>
  </div>
</template>

<style scoped>
  .logo {
    display: flex;
    align-items: center;
  }
  img {
    width: 50px;
    margin-right: 1rem;
  }
</style>

El componente está dividido en dos partes: <template> y <style>. Es tan sencillo que no requiere código JS.

La parte de <template> es código HTML que el componente pintará en su tag cuando se muestre dentro de la aplicación. Consta de una imagen que referencia al logo y un título. La imagen está alojada en la carpeta src/assets.

La parte de <style> inserta los estilos necesarios para mostrar el logo y a la derecha el título marcado. Notar la palabra scoped que acompaña al tag <style scoped>. Ésto quiere indicar que los estilos aquí definidos sólo afectarán al componente Logo.vue.

Vue compila los estilos (más adelante veremos cómo utilizar scss) y los inserta en la <head> de la página. Si indicamos scoped, además de insertarlos los prefija con un indicador único consiguiendo que los estilos solo afecten al componente. Si no utilizamos scoped las reglas serán aplicables a cualquier parte de la página que añada dichas clases.

Por último para tener el componente pintado necesitamos insertarlo en nuestra App.vue.

Cómo hemos indicado antes, todo componente es un descendiente de App.vue. Vue nos da varias formas de definir un componente. De forma global a la aplicación:

const app = Vue.createApp({});

app.component("component-a", {
  /* ... */
});
app.component("component-b", {
  /* ... */
});

De esta forma siempre que queramos utilizar component-a dentro de otro, solo tendremos que incluir el tag <component-a> dentro del template de otro componente.

En nuestro caso, vamos a utilizar Logo.vue como un componente registrado localmente. Para ello debemos utilizar la propiedad components que todo componente Vue tiene e insertarlo ahí. Vemos el ejemplo de insertar Logo.vue dentro de App.vue:

<template>
  <Logo></Logo>
  ...
</template>

<script>
  import Logo from './components/Logo.vue'

  export default {
      name: 'app',
      components: {
          Logo
      },
      ...
  }
</script>

De esta manera, Logo.vue está disponible como tag html para ser insertado en el template App.vue.

Ejecutamos yarn serve si no lo teníamos antes ejecutado y comprobamos que tenemos nuestro logo insertado en la aplicación.

Por último, para tener un proyecto correcto, nuestro nuevo fichero debería tener un test unitario definido. Crear el fichero tests/unit/components/Logo.spec.js:

import { shallowMount } from "@vue/test-utils";
import Logo from "@/components/Logo.vue";

describe("Logo.vue", () => {
  it("renders h1 text", () => {
    const wrapper = shallowMount(Logo);
    expect(wrapper.text()).toMatch("ToDo List");
  });
});

Ahora, si ejecutamos yarn test:unit deberíamos ver como nuestros test pasan.

Con esto ya hemos creado nuestro primer componente. Es tan sencillo que ni siquiera necesita una sección <script> como si lo tienen el componente App.vue.

Vue utiliza Webpack por debajo, usando el compilador Babel para transpilar código de ES6 a ES5. Ésto nos permite utilizar el sistema de módulos standard de las nuevas versiones JavaScript. Cuando importamos un fichero *.vue, el loader vue-loader utiliza el plugin de Babel para exportar los componentes SFC definidos en los ficheros .vue como la parte default de un módulo JS. Por eso para importarlo en otro componente tenemos que utilizar la nomenclatura

import Logo from "@/components/Logo.vue";

Crear un componente para introducir ToDos

Ahora que ya hemos visto cómo se crea un componente Vue, vamos a crear un componente más complejo. Vamos a realizar un componente que nos permita ir creando todos permitiendo la introducción del texto en un cuadro de texto. Las especificaciones de dicho son componente serían:

  1. El componente tiene que tener un <input type="text"> donde el usuario puede introducir el texto de un ToDo.
  2. El <input> debe tener un label indicando su propósito.
  3. Cuando el usuario pulse enter el componente tiene que informar a App.vue que un nuevo todo tiene que crearse con el texto que el usuario ha introducido en el <input>.
  4. Después del enter el texto del <input> tiene que borrarse para poder introducir un nuevo ToDo.
  5. El componente tiene que evitar enviar el evento al padre, si el cuadro de texto está vacío.

Empezamos creando el fichero de src/components/ToDoInput.vue y tests/components/ToDoInput.spec.js.

Dentro de src/components/ToDoInput.vue creamos la siguiente estructura:

<template>Un input para ToDo</template>

<script>
  export default {};
</script>

<style lang="scss" scoped></style>

Con esta estructura ya podemos incluir nuestro nuevo componente dentro de App.vue para empezar a hacer uso de él:

<template>
  <Logo></Logo>
  <ToDoInput></ToDoInput>
  ...
</template>

<script>
  import Logo from './components/Logo.vue';
  import ToDoInput from './components/ToDoInput.vue';

  export default {
    name: 'App',
    components: {
      Logo,
      ToDoInput
    },
    ...
  }
</script>

reiniciamos el proyecto si no está lanzado: yarn serve y vemos la url http://localhost:8080.

Una vez que tenemos la estructura podemos empezar a trabajar en src/components/ToDoInput.vue:

<template>
  <div class="todo-input">
    <label for="todo-input">Introduzca un texto</label>
    <input type="text" id="todo-input" @keyup.enter="onKeyup" v-model="text" />
  </div>
</template>

<script>
  export default {
    data() {
      return {
        text: "",
      };
    },
    methods: {
      onKeyup() {
        if (this.text) {
          this.$emit("add", this.text);
          this.text = "";
        }
      },
    },
  };
</script>

<style lang="scss" scoped>
  .todo-input {
    margin-bottom: 1rem;
    input {
      font-family: Avenir, Helvetica, Arial, sans-serif;
      font-size: 20px;
      margin-left: 1rem;
    }
  }
</style>

En la parte template vemos que hemos incluido tanto el <input> como el <label> asociado. El atributo for del <label> es para hacer referencia al <input>. <input> tiene dos atributos específicos de Vue:

Como se puede apreciar, Vue facilita la gestión de estado y de eventos respecto a si lo haríamos con Vanilla JS.

Ejercicio Realizar la gestión de un input y un manejador con el API JavaScript en VanillaJS.

Vayamos por partes.

Para la consecución del primer objetivo (El componente tiene que tener un <input type="text"> donde el usuario puede introducir el texto de un ToDo) hacemos uso de v-model para guardar la entrada del usuario. Pero, ¿dónde guardamos ese dato? Vue ofrece una manera de definir propiedades internas en un componente que además sean reactivas. Es decir, de forma que cuando la propiedad cambie, el cambio se refleje en el template y a la inversa, si cambia en el template que cambie el dato interno del componente. Si nos fijamos en la parte <script>, hay una propiedad del componente que se llama data:

...
<script>
export default {
  props: {
    todoInput: String
  },
  data() {
    return {
      text: ''
    };
  },
  ...

Esta propiedad es una función que devuelve un objeto que representa los datos internos de un componente. Vue automáticamente definirá las propiedades devueltas dentro del prototipo del componente.

Como vemos, se devuelve una propiedad con nombre text. Si volvemos al <template>, esta propiedad está referenciada por v-model="text". Esto ha creado el two-way data binding, de forma que actualizar el <input> actualizará la propiedad text.

El objetivo dos (El input debe tener un label indicando el propósito del input) se consigue mediante <label for="todo-input">, utilizando HTML.

El objetivo tres (Cuando el usuario haga pulse enter el componente tiene que informar a App.vue que un nuevo todo tiene que crearse con el texto que el usuario ha introducido en el input) nos obliga a utilizar los mecanismos de event handling de Vue:

<template>
  ...
  <input type="text" id="todo-input" @keyup.enter="onKeyup" v-model="text" />
  ...
</template>

<script>
  export default {
    ...
    methods: {
      onKeyup() {
        if (this.text) {
          this.$emit('add', this.text);
          this.text = '';
        }
      }
    },
    ...
  }
</script>

Como ya hemos visto para añadir un manejador de evento en Vue tenemos que utilizar el carácter @ dentro del <template>: @keyup.enter="onKeyup". La sintaxis es @[nombre-evento] como se explica aquí. En este caso indicamos que el evento keyup tiene que ejecutar el método onKeyup del componente.

Al igual que un componente puede definir propiedades internas mediante la propiedad data, también puede definir métodos internos que serán implementados dentro de la propiedad methods. La parte .enter de @keyup.enter="onKeyup" es un modificador del evento que indica que solo se reaccione a la tecla enter. Si vemos el extracto de código de arriba, cada vez que un usuario suelte la tecla enter de <input> se ejecutará el método onKeyup definido dentro de methods.

Con esto conseguimos manejar el evento, pero ¿cómo informamos al padre (App.vue) de este evento? Si miramos la implementación de onKeyup vemos que dentro del if hay una línea de código que dice this.$emit('add', this.text). Esta línea hace uso del método $emit existente en todos los componentes Vue. $emit crea un custom event que más tarde puede ser utilizado en el padre de ToDoInput.vue para reaccionar al evento add. Finalmente, observando el código de App.vue (padre de ToDoInput.vue) vemos el uso que hacemos de este evento add:

<template>
  ...
  <ToDoInput :todoInput="todoInput" @add="handleAddTodo"></ToDoInput>
  ...
</template>

<script>
  ...
  export default {
    ...
    methods: {
      handleAddTodo(text) {
        // TODO implement
      },
    },
    ...
  }
</script>

Cuando nuestro ToDoInput.vue emita el evento add (se habrá ejecutado la línea this.$emit('add', this.text)), App.vue ejecutará el método handleAddTodo.

El objetivo cuatro (Después del enter el texto del input tiene que borrarse para poder introducir un nuevo ToDo), lo conseguimos en la implementación de onKeyup de ToDoInput.vue:

<script>
  ...
  methods: {
    onKeyup() {
      if (this.text) {
        this.$emit('add', this.text);
        this.text = '';
      }
    }
  },
  ...
</script>

Como la propiedad text es reactiva por el uso que hicimos de v-model, cuando actualicemos la propiedad dentro de onKeyup (this.text = ''), el cambio se verá reflejado en el <template>. Se actualizará la propiedad value del <input>.

El objetivo cinco (El componente tiene que evitar enviar el evento al padre, si el cuadro de texto esta vacío), se consigue evitando la ejecución del código de onKeyup con la comprobación de si la propiedad text está vacía. El if que existe dentro del método.

Queda un detalle por analizar. ¿Cómo sabe el padre qué se ha escrito dentro del <input>? Los custom events permiten el paso de un payload del hijo al padre utilizando el segundo parámetro de $emit que es opcional. En nuestro caso estamos enviando el contenido de la variable text interna del componente ToDoInput.vue.

Para que nuestro código tenga calidad suficiente no pueden faltar test unitarios. Siguiendo una metodología TDD deberíamos haber creado los tests primero, pero para el objetivo del curso los iremos creando después y explicando tras ver el código de Vue

El test creado para ToDoInput.vue es el siguiente:

import { mount } from "@vue/test-utils";
import ToDoInput from "@/components/ToDoInput.vue";

describe("ToDoInput.vue", () => {
  it("renders an input and a label", () => {
    const wrapper = mount(ToDoInput);
    const input = wrapper.get("input");
    const label = wrapper.get("label");
    expect(input.attributes().id).toMatch("todo-input");
    expect(input.attributes().type).toMatch("text");
    expect(label.attributes().for).toMatch("todo-input");
  });

  it("type enter should emit add event and remove input text", async () => {
    const wrapper = mount(ToDoInput);
    const input = wrapper.get("input");
    await input.setValue("un todo de ejemplo");
    expect(input.element.value).toBe("un todo de ejemplo");
    await input.trigger("keyup.enter");
    expect(input.element.value).toBe("");
    expect(wrapper.emitted()).toHaveProperty("add");
  });

  it("type enter with empty input should not emit add event", async () => {
    const wrapper = mount(ToDoInput);
    const input = wrapper.get("input");
    await input.setValue("");
    await input.trigger("keyup.enter");
    expect(input.element.value).toBe("");
    expect(wrapper.emitted()).not.toHaveProperty("add");
  });
});

mount es un método provisto por Vue Test Utils, a partir de ahora VTU. Tened en cuenta que esta versión es la 2 porque es la que vale para Vue 3. mount sirve para simular como Vue monta un componente y poder probarlo de forma realista. A mount le pasamos el componente que queremos probar. Además aceptar otras opciones que se pueden ver con detalle en la referencia de la API.

El primer test comprueba que <input> y <label> han sido pintados. Se corresponde a los objetivos 1 y 2.

El segundo test comprueba que se emite un evento cuando se introduce enter y que el campo de texto se borra. Objetivos 3 y 4. Notar que insertar la propiedad value y lanzar un evento son asíncronos. Como se explica en VTU es un shorthand:

wrapper.find("button").trigger("click");
await nextTick();

// se puede sustituir por
await wrapper.find("button").trigger("click");

La reactividad sobre los elementos del DOM en Vue se ejecuta de forma asíncrona y se realiza en el siguiente tick de reloj de JavaScript. Es por eso la necesidad de utilizar nextTick.

El tercer test comprueba que no se emite el evento si el campo de texto está vacío. Objetivo 5.

Crear un ToDo

Vamos a implementar un elemento de la lista de ToDos. Como queremos organizar nuestro código de forma eficiente y mantenible, vamos a modelar un ToDo de forma independiente para poder reutilizarlo.

Las especificaciones de dicho componente son:

  1. El componente tiene que tener un <span> donde vaya el texto.
  2. El componente tiene que tener un checkbox que permita marcar si la tarea está realizada. Cuando se hace click en el checkbox se tiene que informar al padre.
  3. El componente tiene que ser configurable para que pueda ser reutilizado. El padre de este componente puede indicar qué texto se tiene que escribir así como indicar el estado, si está realizado o no.
  4. Cuando una tarea está realizada, el estilo del <span> del texto tiene que cambiar y poner el texto tachado.
  5. Queremos que el ToDo se muestre en una línea, con el checkbox a la izquierda del <span>.

Creamos el fichero de src/components/ToDo.vue y tests/components/ToDo.spec.js. Dentro de src/components/ToDo.vue :

<template>
  <div class="todo">
    <div>
      <input
        data-test="checkbox"
        type="checkbox"
        :checked="done"
        @change="change"
      />
    </div>
    <div>
      <span data-test="span" :style="styleObject">{{ text }}</span>
    </div>
  </div>
</template>

<script>
  export default {
    props: {
      text: String,
      done: Boolean,
    },
    computed: {
      styleObject() {
        const styleObject = {
          textDecoration: "none",
        };
        if (this.done) {
          styleObject.textDecoration = "line-through";
        }
        return styleObject;
      },
    },
    methods: {
      change(event) {
        this.$emit("todoDone", event.target.checked);
      },
    },
  };
</script>

<style lang="scss" scoped>
  .todo {
    display: flex;
    align-items: center;
    div:first-child {
      margin-right: 1rem;
    }
    div:nth-child(2) {
      text-align: left;
      padding: 0.5rem;
      min-width: 100px;
    }
  }
</style>

Vamos a ir desgranando el código de arriba explicando los conceptos clave.

Para la consecución del primer objetivo (El componente tiene que tener un <span> donde vaya el texto) incluimos en el <template>:

<template>
  ...
  <div class="text-input">
    <span data-test="span" :style="styleObject">{{ text }} </span>
  </div>
  ...
</template>

Como podemos ver que utilizamos la notación {{ text }} que es la forma de interpolar variables desde el código del componente a su template. De esta forma, {{ text }} se sustituirá por el valor de la prop text del componente. Más adelante veremos que es una prop en detalle.

Para el segundo objetivo (El componente tiene que tener un checkbox que permita marcar si la tarea está realizada. Cuando se hace click en el checkbox se tiene que informar al padre) incluimos el siguiente markup:

<template>
  <div>
    <input
      data-test="checkbox"
      type="checkbox"
      :checked="done"
      @change="change"
    />
  </div>
</template>

Como vemos, hemos utilizado tanto la forma de hacer un bind entre una variable del componente y su template (:checked="done") como el bind entre un evento del elemento (en este caso <input type="checkbox">) con un método del componente (@change="change").

Con ésto conseguimos que cuando props.done sea verdadera, el checkbox esté marcado. También que cuando hagamos click en el checkbox se emita un custom event de nombre todoDone al ejecutarse el método change (@change="change"):

<script>
  ...
    methods: {
      change(event) {
        this.$emit('todoDone', event.target.checked);
      },
    }
  ...
</script>

Utilizamos el valor nativo del checkbox (event.target.checked) como payload del custom event.

Para el tercer objetivo (El componente tiene que ser configurable para que pueda ser reutilizado. El padre de este componente puede indicar qué texto se tiene que escribir así como indicar el estado, si está realizado o no) hacemos uso de las props de Vue. Como vemos abajo, un componente permite definir una serie de props que es la manera que ofrece Vue de pasar datos de un componente Padre a su Hijo.

<script>
export default {
  props: {
    text: String,
    done: Boolean
  },
  ...

Vemos que hemos definido text: String y done: Boolean. Hacer esto nos permitirá en el padre tener ToDos definidos de la siguiente manera:

<template>
...
  <ToDo :text="'un todo realizado'" :done="true">
  <ToDo :text="'un todo por realizar'" :done="false">
...
</template>

Es decir, nos permite reutilizar el ToDo.vue desde un componente padre, configurando sus propiedades de entrada. Hay que tener en cuenta que las props son solo de lectura. Un componente hijo no debería poder modificar las props que les pase su padre. Para facilitar el desarrollo, las props pueden tener un tipo, como hemos visto con String para el caso de text arriba.

Falta decir que : es un shorthand para v-bind.

Pregunta: ¿Por qué el valor de los ToDo están rodeados por comillas simples, en lugar de poner el texto directamente dentro de las comillas dobles?

El cuarto objetivo (Cuando una tarea está realizada, el estilo del <span> del texto tiene que cambiar y poner el texto tachado) requiere que nuestro componente reaccione al cambio de props.done. Para ello hacemos uso de las computed properties de Vue. Una computed property es un dato interno de un componente que está derivado de otros datos del componente. Una computed property es reactiva, cuando el dato de la que ella depende cambia, se vuelve a revaluar para volver a cambiar. En nuestro caso, el estilo del <span> se deriva del valor de props.done. Si done es true, el span debe estar tachado:

<template>
  ...
  <span data-test="span" :style="styleObject">{{ text }} </span>
  ...
</template>

<script>
  ...
  export default {
    ...
    computed: {
      styleObject() {
        const styleObject = {
          textDecoration: 'none'
        };
        if (this.done) {
          styleObject.textDecoration = 'line-through';
        }
        return styleObject;
      }
    },
    ...
  }
  ...
</script>

Como vemos en el código de arriba, styleObject es una propiedad del componente que en realidad es una computed property. Observamos como en el <template> hacemos uso de la notación :style="styleObject". Estamos indicando que el atributo style de <span> está bindeado con el styleObject. Cuando Vue resuelve el template, crea una propiedad reactiva styleObject que cambiará en función de props.done. Vemos que dentro de la computed styleObject hemos utilizado la prop this.done. Cuando props.done cambie, el código dentro de computed.styleObject se ejecutará para resolver el valor de styleObject. Por defecto el estilo aplicado es text-decoration: "none", y éste cambiará cuando done == true.

De esta forma, Vue permite simplificar el template, permitiendo utilizar computed properties en lugar de expresiones muy complejas en el template.

Finalmente, para conseguir nuestro objetivo cinco (Queremos que el ToDo se muestre en una línea, con el checkbox a la izquierda del <span>) tenemos que aplicar unos estilos al componente ToDo.vue:

<style lang="scss" scoped>
  .todo {
    display: flex;
    align-items: center;
    div:first-child {
      margin-right: 1rem;
    }
    div:nth-child(2) {
      text-align: left;
      padding: 0.5rem;
      min-width: 100px;
    }
  }
</style>

Mediante el uso de Single File Component (SFC), Vue nos permite definir los estilos de un componente en el mismo fichero donde tenemos el código y el template. Para ello utilizamos el tag <style>. Si usamos la propiedad lang="scss" indicamos que estamos definiendo los estilos con scss. Para que esto funcione, tenemos que haber incluido un plugin Webpack para el precompilador sass. Esto lo hicimos al tener en nuestro package.json la dependencia "sass-loader". Si utilizamos la propiedad scoped indicamos que los estilos aquí definidos sólo afecten a este componente.

El código scss de arriba está estilando el componente mediante flex disponiendo sus elementos en la misma línea.

A parte de estilos concretos de un componente, en Vue también podemos incluir estilos globales a la aplicación. Dichos estilos serán aplicados en el <head> de la página en tiempo de construcción. Si observamos la estructura de ficheros dada vemos que hay una carpeta src/scss que contiene el fichero inputs.scss. Dicho fichero es importado en el fichero src/main.js. Al compilar el proyecto, la configuración interna de Webpack, tratará este fichero mediante el plugin "sass-loader" y generará los estilos css correspondientes. Nosotros, para dejar la SPA más bonita hemos incluido algunos estilos para los checkbox.

Igualmente nos falta el test unitario sobre este componente. Por eso creamos un fichero tests/components/ToDo.spec.js:

import { mount } from "@vue/test-utils";
import ToDo from "@/components/ToDo.vue";
// import { nextTick } from 'vue';

const getWrapper = (options) => {
  let obj = {
    global: {
      stubs: ["font-awesome-icon"],
    },
  };
  if (options) {
    Object.assign(obj, options);
  }
  return mount(ToDo, obj);
};

describe("ToDo.vue", () => {
  let wrapper;
  beforeEach(() => {
    wrapper = getWrapper();
  });

  it("have a checkbox and a span", () => {
    expect(wrapper.findAll('[data-test="checkbox"]')).toHaveLength(1);
    expect(wrapper.findAll('[data-test="span"]')).toHaveLength(1);
  });

  it("when checkbox is checked it should emit todoDone", async () => {
    const checkbox = wrapper.get('[data-test="checkbox"]');
    await checkbox.setValue(true);
    expect(wrapper.emitted("todoDone")[0]).toEqual([true]);
  });

  it("should print text prop", () => {
    wrapper = getWrapper({
      props: {
        text: "a todo",
        done: false,
      },
    });
    expect(wrapper.get('[data-test="span"]').text()).toBe("a todo");
  });

  it("should render when todo is completed", () => {
    wrapper = getWrapper({
      props: {
        text: "a todo",
        done: true,
      },
    });
    const text = wrapper.get('[data-test="span"]');
    const checkbox = wrapper.get('[data-test="checkbox"]');
    expect(checkbox.element.checked).toBe(true);
    expect(text.attributes().style).toBe("text-decoration: line-through;");
  });
});

La única peculiaridad de este test es que en la versión final usamos una librería para mostrar iconos. Por ello tenemos que hacer stub del componente que nos muestra los iconos.

El primer test cubre objetivos 1 y 2.

El segundo test cubre el objetivo 2.

El tercer test cubre el objetivo 3.

El cuarto test cubre los objetivos 3 y 4.

Ejercicio: Acabamos de ver cómo usar estilos dinámicos mediante :style (abreviatura de v-bind:style). Ésto también se puede hacer utilizando una clase dinámica usando un bind sobre el atributo class de <span>. Mirar la documentación y realizarlo mediante una clase dinámica.

Unir las piezas y finalizar la aplicación ToDo List

Ahora que ya tenemos las piezas básicas (Logo.vue, ToDoInput.vue y ToDo.vue), las usaremos para terminar nuestra aplicación. El código que hace de pegamento de estos componentes irá en nuestro App.vue. La especificación de este componente podría ser:

  1. La aplicación debe mantener una lista de ToDos y pintarlos.
  2. La aplicación debe cambiar el estilo de un ToDo si pulsamos en su checkbox.
  3. La aplicación debe crear un nuevo ToDo cuando insertamos un texto en ToDoInput.vue.
  4. La aplicación permitirá borrar los ToDos si están a done.

Para la consecución de dichos objetivos se propone el siguiente código:

<template>
  <Logo></Logo>
  <ToDoInput @add="handleAddTodo"></ToDoInput>
  <ToDo
    v-for="(todo, index) in todos"
    data-test="todo"
    :key="index"
    :text="todo.text"
    :done="todo.checked"
    @todoDone="handleTodoChange($event, index)"
  ></ToDo>
</template>

<script>
  import Logo from "./components/Logo.vue";
  import ToDo from "./components/ToDo.vue";
  import ToDoInput from "./components/ToDoInput.vue";

  export default {
    name: "App",
    components: {
      Logo,
      ToDo,
      ToDoInput,
    },
    data() {
      return {
        todos: [],
      };
    },
    methods: {
      handleTodoChange(event, index) {
        this.todos[index].checked = event;
      },
      handleAddTodo(text) {
        this.todos.push({
          checked: false,
          text,
        });
      },
    },
  };
</script>

<style>
  #app {
    font-family: Avenir, Helvetica, Arial, sans-serif;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-align: center;
    color: #2c3e50;
    margin-top: 12px;
    display: flex;
    flex-direction: column;
  }
</style>

Lo primero que debemos notar es cómo se utilizan los componentes que hemos ido creando dentro de nuestra App.vue. Como comentamos al principio del curso, hay dos formas de crear componentes en Vue, de forma global y de forma local. En este caso, utilizamos la forma local. Los componentes que creamos los registramos localmente en App.vue de la siguiente forma:

<script>
import Logo from "./components/Logo.vue";
import ToDo from "./components/ToDo.vue";
import ToDoInput from "./components/ToDoInput.vue";

export default {
  name: "App",
  components: {
    Logo,
    ToDo,
    ToDoInput,
  },
  ...
}
<script>

de esta manera, los componentes están disponibles en el <template> de App.vue.

Para conseguir el primer objetivo (La aplicación debe mantener una lista de ToDos y pintarlos) creamos una propiedad data.todos dentro de App.vue. Este array mantendrá los todos que se irán creando.

Para pintar un array de todos utilizamos la directiva v-for que nos ofrece Vue. La directiva requiere la siguiente sintaxis: item in items, donde items es nuestro todos:

<template>
  ...
  <ToDo
    v-for="(todo, index) in todos"
    :key="index"
    :text="todo.text"
    :done="todo.checked"
    @todoDone="handleTodoChange($event, index)"
  ></ToDo>
  ...
</template>

En primer lugar hay que notar que no usamos item in items si no (item, index) in items. Vue nos ofrece la opción de incluir un segundo argumento opcional que indicará el índice del elemento que se está recorriendo. Como veremos más adelante, utilizaremos este índice para modificar correctamente el estado de un ToDo cuando se pulse sobre su checkbox.

En segundo lugar, hacemos uso de una propiedad dentro de la directiva v-for, :key="index". Por defecto Vue utiliza una estrategia de patch para actualizar la lista si esta cambia. Esto es eficiente, pero solo si el pintado de la lista no depende de los estados internos de la variable. Para dar una idea a Vue de cómo repintar la lista y rastrear cada nodo de la lista hay que pasar la propiedad :key. Está recomendado pasar un key siempre. El valor de key debe ser un number o un string.

La forma que estamos utilizando v-for es la descrita aquí.

Con el template puesto arriba, Vue recorrerá la lista dada en la variable todos y pintará un componente ToDo.vue por cada elemento del array todos. Vemos que los datos necesarios para un todo son pasados al componente hijo (ToDo.vue) a través de las props que éste ha expuesto (:text y :done). Por ello, lo inteligente será tener un array de elementos del tipo

{
  text: 'el texto del todo',
  done: true // si el todo ya está realizado
}

El segundo objetivo (La aplicación debe cambiar el estilo de un ToDo si pulsamos en el checkbox) trata de aprovechar el custom event que define ToDo.vue (@todoDone) para cambiar el estado de nuestra lista de todos. Primero vemos que hemos enganchado el evento al método handleTodoChange($event, index). El payload que pasamos al evento (true o false dependiendo de event.target.value) se pasa al componente padre a través de la variable $event. Además, también le pasamos la variable index que hemos obtenido del uso de v-for. De ésta forma sabemos cómo tenemos que actualizar nuestra lista, qué elemento actualizar y qué valor tendrá la propiedad done:

<script>
  ...
  export default {
    ...
    methods: {
      handleTodoChange(event, index) {
        this.todos[index].checked = event;
      },
    ...
  }
</script>

Si depuramos la aplicación podemos observar el flujo de los datos. Primero, el checkbox cambia de valor, lo que provoca la emisión del evento change. Este evento está enganchado al método change de ToDo.vue que internamente llama a this.$emit('todoDone', event.target.checked). Esto provoca que se lance el evento @todoDone en App.vue que está enganchado al método handleTodoChange($event, index). Cuando se ejecuta el método, se cambia el estado interno de un nodo de la lista todos. Como además hemos utilizado :key, Vue sabe que al actualizar un nodo, tiene que actualizar la lista. Esto provoca que la propiedad :done="todo.checked" del nodo que fue pulsado haya cambiado. Lo que produce una actualización de la prop done en el componente ToDo.vue relativo a ese nodo. Como hemos utilizado una computed property sobre dicha propiedad (this.done), se ejecuta el cuerpo de la función asociada a styleObject y se actualiza el valor para styleObject que en este caso contendrá text-decoration: "line-through".

El tercer objetivo (La aplicación debe crear un nuevo ToDo cuando insertamos un texto en ToDoInput.vue) permite al usuario crear nuevos todos. Para ello utilizamos el custom event @add que define el componente ToDoInput.vue.

Si recordamos, ToDoInput.vue emitirá @add cuando el campo de texto tenga texto y el usuario pulse enter. En ese momento, el componente App.vue ejecutará el método

<script>
  ...
    handleAddTodo(text) {
      this.todos.push({
        checked: false,
        text,
      });
    }
  ...
</script>

que modificará la variable todos añadiendo un nuevo nodo al array. Esto hará que la directiva v-for que hemos utilizado actualice el <template> con un nuevo todo.

Notar que el método push modifica el propio array. Si por ejemplo, para calcular el array utilizáramos métodos como concat que devuelven un nuevo array deberíamos sustituir el antiguo por el nuevo, hacer una asignación del nuevo array. Internamente Vue es bastante eficiente manejando estos casos, y a nivel de <template> (DOM) no se sustituirá toda la lista, mejorando la eficiencia.

Finalmente, el cuarto objetivo (La aplicación permitirá borrar los ToDos si están a done) necesita de alguna modificación en ToDo.vue y en App.vue. Si un ToDo esta a done, debemos mostrar un botón borrar dentro del ToDo. Este botón emitirá un evento al padre (App.vue) que se encargará de actualizar la lista de todos eliminando el todo seleccionado para borrar.

Modificaciones en ToDo.vue:

<template>
...
    <div>
        <span data-test="span" :style="styleObject">{{ text }}</span>
        <!-- añadimos el botón -->
        <button v-if="this.done" @click="remove">borrar</button>
      </div>
    </div>
</template>

<script>
 ...
    methods: {
      ...
      // creamos el método asociado al evento @click
      remove() {
        this.$emit('remove');
      }
    }
 ...
</script>

Primero, utilizamos la directiva v-if para mostrar o no el botón en función de si el todo ya está realizado. v-if modifica el DOM insertando o eliminando la parte correspondiente del <template> en función del valor de la condición que se pase. En este caso como la prop.done es un Boolean, la podemos utilizar para pintar o no el botón Borrar. Otra forma de conseguirlo sería utilizar la directiva v-show. Esta directiva no elimina el template si no que lo oculta utilizando la propiedad de estilo display: none.

Si el botón está visible y se pulsa, se emite un custom event que informará al padre con @remove.

Para utilizar el evento @remove, modificamos App.vue:

<template>
  ...
  <ToDo
    v-for="(todo, index) in todos"
    data-test="todo"
    :key="index"
    :text="todo.text"
    :done="todo.checked"
    @todoDone="handleTodoChange($event, index)"
    @remove="handleTodoRemove(index)"
  ></ToDo>
</template>

<script>
  ...
  export default {
    ...
    methods: {
      ...
      handleTodoRemove(index) {
        this.todos.splice(index, 1);
      }
    }
  }
</script>
...

Como podemos observar usamos el método splice que modifica el array, por lo que Vue actualizará el <template> en consecuencia.

App.vue también debe tener su fichero de test tests/App.spec.js:

import { shallowMount } from "@vue/test-utils";
import App from "@/App.vue";

describe("App.vue", () => {
  it("should paint an empty list of todos", () => {
    const wrapper = shallowMount(App, {
      data() {
        return {
          todos: [],
        };
      },
    });
    expect(wrapper.findAll('[data-test="todo"')).toHaveLength(0);
  });
  it("should paint a list of todos", () => {
    const wrapper = shallowMount(App, {
      data() {
        return {
          todos: [
            {
              checked: false,
              text: "todo 1",
            },
            {
              checked: false,
              text: "todo 2",
            },
          ],
        };
      },
    });
    expect(wrapper.findAll('[data-test="todo"')).toHaveLength(2);
  });
});

Ejercicio: Utilizar la directiva v-show para conseguir el mismo efecto sobre el botón borrar que con v-if.

Ejercicios

Referencias