Пользовательские директивы - мощная часть API Vue.js. Давайте посмотрим, как создать собственную директиву v-drag и опубликовать ее в NPM вместе с набором тестов.

Мы создадим простую директиву, которая позволит любому элементу стать draggable. Вот краткий предварительный просмотр:

С такой разметкой:

<template>
  <div v-drag>
  </div>
</template> 

Исходный код демонстрационного проекта находится здесь, а окончательный код директивы - здесь. Наконец, опубликованный пакет здесь.

Настраивать

Для начала мы создадим новый проект, используя vue-cli. Если вы еще не установили, запустите npm install vue-cli -g. Затем создайте новый проект, используя vue init webpack-simple dragger.

После создания приложения внутри src/App.vue удалите существующую разметку и создайте, добавьте следующее:

<template>
  <div id="outer">
    <div id="dragger" v-drag></div>
  </div>
</template>
<script>
import drag from './drag'
export default {
  name: 'app'
directives: {
    drag
  }
}
</script>
<style>
body {
  height: 100vh;
}
#outer {
  height: 100%;
}
#dragger {
  position: absolute;
  border: 1px solid red;
  width: 100px;
  height: 100px;
}
</style>

Наша директива будет жить в drag.js. Создайте его на том же уровне, что и App.vue, и добавьте следующее:

import Vue from 'vue'
export default Vue.directive('drag', {
  inserted: function (el, binding, vnode) {
    console.log('Drag.js')
  } 
})

Запустите приложение, запустив npm run dev, и посетите localhost:8080. Вы должны увидеть «Drag.js» в консоли:

Обратите внимание на функцию inserted выше. В директивах Vue есть несколько ловушек, доступных разработчикам. Мы будем использовать inserted, который вызывается при монтировании элемента (например, компоненты хуковmounted). Мы также будем использовать unbind, который вызывается, когда директива не привязана, когда компонент удаляется из DOM.

Обнаружение щелчка по элементу

Следующим шагом будет обнаружение, когда пользователь нажимает на элемент с v-drag. Когда пользователь щелкает элемент, мы устанавливаем логическое значение down на true и наоборот, когда пользователь отпускает. Мы также сохраним координаты x и y, чтобы вычислить положение элемента при его перемещении.

Добавьте в drag.js следующее:

const data = {
  down: false,
  initialX: 0,
  initialY: 0
}
export function mousedown (e, el, _data) {
  _data.down = true
  _data.initialX = e.clientX
  _data.initialY = e.clientY
}
export function mouseup (e, el, _data) {
  _data.down = false
}
export default Vue.directive('drag', {
  inserted: function (el, binding, vnode) {
    el.addEventListener('mouseup', (e) => mouseup(e, el))
    el.addEventListener('mousedown', (e) => mousedown(e, el))
  } 
})

inserted получает три аргумента: сам элемент, binding объект с набором свойств, которые мы не будем использовать, и vnode, который используется компилятором Vue и виртуальным домом. Мы привяжем к элементу события mouseup и mousedown. Данные, которые будут использовать v-drag, будут сохранены в data. Затем мы передаем объект data всем функциям, которым он нужен - это упростит тестирование функций.

Тесты с Jest

Прежде чем двигаться дальше, давайте напишем несколько тестов. Мы будем использовать замечательную библиотеку тестирования Jest. Идите и установите Jest и некоторые вспомогательные пакеты.

npm install jest babel-preset-env babel-jest --save-dev

Вам также необходимо обновить .babelrc новым env, о чем я узнал из документации Jest.

{
  "presets": [
    ["env", { "modules": false }],
    "stage-3"
  ],
  "env": {
    "test": {
      "presets": [["env"]]
    }
  }
}

Создайте папку __tests__ внутри src и добавьте к ней drag.test.js. Теперь проект выглядит так:

Давайте начнем с теста на mouseup и mousedown. Добавьте следующее в drag.test.js. Также не забудьте добавить export к двум функциям в drag.js, чтобы их можно было импортировать в тест.

import { mouseup, mousedown } from '../drag.js'
describe('drag', () => {  
  describe('mouseup', () => {
    it('sets down = false', () => {
      const data = {
        down: true
      }
      mouseup(undefined, undefined, data)
      expect(data.down).toBe(false)
    })
  })
  describe('mousedown', () => {
    it('sets down = true and initial mouse position', () => {
      const data = { 
        initialX: 0,
        initialY: 0,
        down: false
      }
      const evt = {
        clientX: 1,
        clientY: 1
      }
      mousedown(evt, undefined, data) 
      expect(data.down).toBe(true)
      expect(data.initialX).toBe(1)
      expect(data.initialY).toBe(1)
    })
  })
})

Добавьте новую строку в package.json:

"scripts": {
  "test": "jest"
}

Теперь вы можете запустить набор тестов, используя npm run test.

В настоящее время директива out по-прежнему ничего не «делает» при нажатии, но с настроенным набором тестов мы готовы двигаться вперед.

Расчет движения и смещения

Чтобы рассчитать положение элемента при его перетаскивании, мы должны сохранить исходное местоположение при щелчке по элементу. Давайте сделаем это в setInitialOffset функции.

const _data = {
  /* other variables */  
  draggerOffsetLeft: 0,
  draggerOffsetTop: 0
}
export function setDraggerOffset (el, _data) {
  _data.draggerOffsetLeft = el.offsetLeft
  _data.draggerOffsetTop = el.offsetTop
}

и быстрый тест, чтобы убедиться, что он работает:

describe('setInitialOffset', () => {
  it('sets the initial offset of the element', () => {
    const data = {
      draggerOffsetLeft: 0,
      draggerOffsetTop: 0
    }
    const el = {
      offsetLeft: 1,
      offsetTop: 1
    }
    setDraggerOffset(el, data)
    expect(data.draggerOffsetLeft).toBe(1)
    expect(data.draggerOffsetTop).toBe(1)
  })
})

Большой. Давайте установим начальное смещение при монтировании элемента, вызвав setInitialOffset в inserted.

export default Vue.directive('drag', {
  inserted: function (el, binding, vnode) {
    el.addEventListener('mouseup', (e) => mouseup(e, el, _data))
    el.addEventListener('mousedown', (e) => mousedown(e, el, _data))
    setDraggerOffset(el, _data)
  }
})

Если вы добавите console.log(_data) в mousedown, чтобы дважды проверить, все работает. Щелчок теперь приведет к регистрации объекта _data.

Перемещение элемента

Давайте добавим mousemove функцию. Если _data.down истинно, мы вычислим новую позицию элемента и обновим значения style.top и style.left.

Начнем с ситуации, когда _data.down false. Мы не должны ничего делать. Тест выглядит так:

describe('mousemove', () => {
  it('does nothing is down === false', () => {
    const data = {
      down: false
    }
    const el = {
      style: {
        left: 0,
        top: 0
      }
    }
    mousemove(undefined, el, data)
    expect(el.style.left).toBe(0)
    expect(el.style.top).toBe(0)
  })
})

Мы утверждаем, что стиль элемента не меняется. Теперь самое интересное - перемещение элемента. Формула выглядит так:

elementNewLeft = elementInitialLeft + (mouseNewX - mouseInitialX)

У нас есть все эти ценности!

  • elementNewLeft: el.style.left
  • elementInitialLeft: _data.draggerOffsetLeft
  • mouseNewX: e.clientX. (Мы передадим клиентское событие)
  • mouseInitialX: _data.initialX

Итак, в коде:

export function mousemove (e, el, _data) {
  if (_data.down) {
    el.style.left = _data.draggerOffsetLeft + (e.clientX - _data.initialX) + 'px'
    el.style.top = _data.draggerOffsetTop + (e.clientY - _data.initialY) + 'px'
  }
}

И добавьте слушателя в inserted:

export default Vue.directive('drag', {
  inserted: function (el, binding, vnode) {
    el.addEventListener('mouseup', (e) => mouseup(e, el, _data))
    el.addEventListener('mousedown', (e) => mousedown(e, el, _data))
    el.addEventListener('mousemove', (e) => mousemove(e, el, _data))
    setDraggerOffset(el, _data)
  }
})

Этого должно быть достаточно, чтобы коробка сдвинулась с места!

Добавим еще тест:

describe('mousemove', () => {
  it('updates the element style if down === true', () => {
    const data = {
      down: true,
      initialX: 10,
      initialY: 10,
      draggerOffsetLeft: 0,
      draggerOffsetTop: 0
    }
    const e = {
      clientX: 20, // clientX - initialX. 20 - 10 = 10
      clientY: 20
    }
    const el = {
      style: {
        left: 0,
        top: 0
      }
    }
    mousemove(e, el, data)
    expect(el.style.left).toBe('10px')
    expect(el.style.top).toBe('10px')
  })
})

Есть несколько мелких ошибок. Во-первых, вы можете нарисовать элемент только один раз - мы должны сбросить начальное смещение элемента в mouseup.

export function mouseup (e, el, _data) {
  _data.down = false
  setDraggerOffset(el, _data)
}

Теперь тест не проходит. Обновите тест с помощью mock el.

describe('mouseup', () => {
  it('sets down = false', () => {
    const el = {}
    const data = {
      down: true
    }
  
    mouseup(undefined, el, data)
    expect(data.down).toBe(false)
  })
})

Набор тестов снова зеленый. Есть и другие улучшения: если пользователь быстро перетаскивает мышь, и она покидает элемент, mouseup не будет вызываться, и элемент будет вести себя некорректно. Для краткости перейдем к публикации модуля.

Публикация в NPM

Хотя для построения директивы мы использовали проект vue-cli. При публикации мы хотим немного изменить настройку проекта. Мы хотим, чтобы экспорт по умолчанию был директивой и чтобы код компилировался в ES5, чтобы гарантировать правильное поведение в любом браузере. Создайте новую папку v-drag и инициализируйте новый проект узла:

npm init -y

и установите несколько пакетов:

npm install babel-cli  babel-preset-env rimraf --save-dev

Нам нужен babel для компиляции и rimraf для очистки файлов compiledlib каждый раз, когда мы публикуем.

Обновить package.json:

{
  "name": "@branu-jp/v-drag",
  "version": "0.0.1",
  "description": "A Vue.js draggable directive",
  "main": "index.js",
  "dependencies": {},
  "devDependencies": {
    "babel-cli": "^6.26.0",
    "babel-preset-env": "^1.6.1",
    "rimraf": "^2.6.2"
  },
  "main": "lib/index.js",
  "scripts": {
    "start": "babel-node src",
    "build": "rimraf lib && babel src -d lib --ignore src/__tests__"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/branu-ws/v-drag"
  },
  "keywords": [],
  "author": "BRANU",
  "license": "MIT"
}

Убедитесь, что вы обновили свойство name. Я публикую пакет с ограниченной областью видимости, дополнительную информацию можно найти в документации npm. Также обратите внимание, что у нас есть сценарий build, который компилирует любой код из папки src в папку lib.

Создайте папку src и внутри нее добавьте файл index.js с кодом директивы из drag.js.

Мы также должны добавить __tests__ в папку src и обновить их для импорта из index.js, а не из drag.js. Наконец, мы хотим установить Vue как peerDependency, v-drag предполагаемый Vue установлен в любом проекте, который его использует.

Для публикации запускаем npm run build, который компилируется в ES5 в папке lib.

Теперь просто запустите npm publish. Если вы раньше не публиковали в npm, вы увидите сообщение об ошибке:

npm ERR! code ENEEDAUTH
npm ERR! need auth auth required for publishing
npm ERR! need auth You need to authorize this machine using `npm adduser`

Если у вас нет учетной записи, создайте в npm. Затем запустите npm adduser и добавьте свои данные. Тогда npm publish, и ваш пакет готов. Тебе следует увидеть

+ @branu-jp/[email protected]

Если все будет хорошо. Моя копия живая здесь. Теперь его можно установить, как и любой другой пакет. В моем случае npm install @branu-jp/v-drag.

Следующие шаги?

Вперед и попробуйте опубликовать пакет! Вот некоторые вещи, которые я буду делать, чтобы улучшить свой пакет:

  • Добавьте файл readme, объясняющий, как установить и использовать ваш пакет.
  • Добавить демо

Демо-код находится здесь (ветка статьи), а опубликованный код модуля - здесь.