Статьи

Неизменные данные и функциональный JavaScript с Mori

Эта статья была рецензирована Крейгом Билнером и Адрианом Санду . Спасибо всем рецензентам SitePoint за то, что сделали контент SitePoint как можно лучше!

Функциональное программирование и неизменяемые данные находятся в центре внимания многих разработчиков JavaScript, поскольку они пытаются найти способы сделать свой код проще и легче рассуждать.

Хотя JavaScript всегда поддерживал некоторые методы функционального программирования, они действительно стали популярными только в последние несколько лет, и традиционно не было встроенной поддержки неизменяемых данных. JavaScript все еще много изучает, и лучшие идеи приходят от языков, которые уже опробовали и опробовали эти методы.

В другом уголке мира программирования Clojure — это функциональный язык программирования, предназначенный для подлинной простоты , особенно в том, что касается структур данных. Mori — это библиотека, которая позволяет нам использовать постоянные структуры данных Clojure непосредственно из JavaScript.

В этой статье будет рассмотрено обоснование дизайна этих структур данных и рассмотрены некоторые шаблоны их использования для улучшения наших приложений. Мы могли бы также подумать об этом как о первом шаге для разработчиков JavaScript, заинтересованных в программировании на Clojure или ClojureScript.

Что такое постоянные данные?

Clojure проводит различие между постоянными значениями, которые нельзя изменить, и временными значениями, которые имеют временные времена жизни между мутациями. Попытки изменить постоянные структуры данных позволяют избежать изменения базовых данных, возвращая новую структуру с примененными изменениями .

Это может помочь увидеть, как будет выглядеть это различие в теоретическом языке программирования.

// transient list a = [1, 2, 3]; b = a.push(4); // a = [1, 2, 3, 4] // b = [1, 2, 3, 4] // persistent list c = #[1, 2, 3] d = c.push(4); // c = #[1, 2, 3] // d = #[1, 2, 3, 4] 

Мы можем видеть, что временный список был видоизменен, когда мы поместили в него значение. И a и b указывают на одно и то же изменяемое значение. Напротив, вызов push в постоянном списке вернул новое значение, и мы видим, что c и d указывают на отличия от дискретных списков.

Эти постоянные структуры данных не могут быть изменены, а это означает, что, как только у нас будет ссылка на значение, у нас также будет гарантия, что оно никогда не будет изменено. Эти гарантии обычно помогают нам писать более безопасный и простой код. Например, функция, которая принимает постоянные структуры данных в качестве аргументов, не может изменять их, и поэтому, если функция хочет сообщить о значимых изменениях, она должна исходить из возвращаемого значения. Это приводит к написанию ссылочно прозрачных, чистых функций , которые легче тестировать и оптимизировать .

Проще говоря, неизменяемые данные заставляют нас писать более функциональный код.

Что такое Мори?

Мори использует компилятор ClojureScript для компиляции реализаций структур данных в стандартной библиотеке Clojure в JavaScript. Компилятор испускает оптимизированный код, а это означает, что без дополнительного рассмотрения не легко общаться с скомпилированным Clojure из JavaScript. Мори это слой дополнительного рассмотрения.

Как и Clojure, функции Мори отделены от структур данных, с которыми они работают, что контрастирует с объектно-ориентированными тенденциями JavaScript. Мы обнаружим, что эта разница меняет направление написания кода.

 // standard library Array(1, 2, 3).map(x => x * 2); // => [2, 4, 6] // mori map(x => x * 2, vector(1, 2, 3)) // => [2, 4, 6] 

Мори также использует структурное совместное использование для внесения эффективных изменений в данные путем совместного использования как можно большей части исходной структуры. Это позволяет постоянным структурам данных быть почти такими же эффективными, как обычные переходные. Реализации этих концепций более подробно рассматриваются в этом видео .

Почему это полезно?

Для начала давайте представим, что мы пытаемся отследить ошибку в кодовой базе JavaScript, которую мы унаследовали. Мы перечитываем код, пытаясь выяснить, почему мы выбрали неправильное значение для fellowship .

 const fellowship = [ { title: 'Mori', race: 'Hobbit' }, { title: 'Poppin', race: 'Hobbit' } ]; deletePerson(fellowship, 1); console.log(fellowship); 

Какова ценность fellowship когда оно зарегистрировано на консоли?

Без запуска кода или чтения определения deletePerson() узнать это невозможно. Это может быть пустой массив. Это может иметь три новых свойства. Мы надеемся, что это массив с удаленным вторым элементом, но поскольку мы передали изменяемую структуру данных, никаких гарантий нет.

Хуже того, функция может удерживать ссылку и изменять ее асинхронно в будущем. Все ссылки на fellowship с этого момента будут работать с непредсказуемой ценностью.

Сравните это с альтернативой Мори.

 import { vector, hashMap } from 'mori'; const fellowship = vector( hashMap( "name", "Mori", "race", "Hobbit" ), hashMap( "name", "Poppin", "race", "Hobbit" ) ) const newFellowship = deletePerson(fellowship, 1); console.log(fellowship); 

Независимо от реализации deletePerson() , мы знаем, что исходный вектор будет зарегистрирован, просто потому, что есть гарантия, что он не может быть изменен. Если мы хотим, чтобы функция была полезной, она должна вернуть новый вектор с удалением указанного элемента.

Понять, как работают функции, работающие с неизменяемыми данными, легко, потому что мы знаем, что их единственным эффектом будет получение и возвращение определенного неизменного значения.

Поток через функции, которые работают с неизменяемыми данными

Функции, работающие с изменяемыми данными, не всегда возвращают значения, они могут изменять свои входные данные, и иногда программисту придется снова подобрать значение на другой стороне.

Поток через функции, которые работают с изменчивыми данными

Проще говоря, неизменяемые данные обеспечивают культуру предсказуемости .

На практике

Мы рассмотрим, как мы можем использовать Mori для создания пиксельного редактора с функцией отмены. Следующий код доступен как CodePen, который вы также можете найти в нижней части статьи .

Мори пиксель арт

Предположим, что вы либо следите за Codepen, либо работаете в среде ES2015 с Mori и следующим HTML.

 <div> <h3>Mori Painter</h3> </div> <div id="container"> <canvas id='canvas'></canvas> </div> <div> <button id='undo'></button> </div> 

Настройка и утилиты

Давайте начнем с деструктурирования функций, которые нам нужны из пространства имен Mori.

 const { list, vector, peek, pop, conj, map, assoc, zipmap, range, repeat, each, count, intoArray, toJs } = mori; 

Это в основном стилистическое предпочтение. Вы также можете использовать любую функцию из Mori, mori.list() к ним непосредственно в объекте Mori (например, mori.list() ).

Первое, что мы сделаем, это установим вспомогательную функцию для просмотра наших постоянных структур данных. Внутреннее представление Мори не имеет большого смысла в консоли, поэтому мы будем использовать toJs() чтобы преобразовать их в понятный формат.

 const log = (...args) => { console.log(...args.map(toJs)) }; 

Мы можем использовать эту функцию в качестве альтернативы console.log() когда нам нужно проверить структуры данных Мори.

Далее мы настроим некоторые значения конфигурации и вспомогательную функцию.

 // the dimensions of the canvas const [height, width] = [20, 20]; // the size of each canvas pixel const pixelSize = 10; // converts an integer to a 2d coordinate vector const to2D = (i) => vector( i % width, Math.floor(i / width) ); 

Надеюсь, вы заметили, что наша to2D() возвращает вектор . Векторы немного похожи на массивы JavaScript и поддерживают эффективный произвольный доступ.

Структурирование данных

Мы будем использовать нашу to2D() чтобы создать последовательность координат, которая будет представлять все пиксели на холсте.

 const coords = map(to2D, range(height * width)); 

Мы используем функцию range () для генерации последовательности чисел от 0 до height * width (в нашем случае 100 ) и используем map () для преобразования ее в список 2D координат с помощью нашего to2D() функция.

Это может помочь визуализировать структуру coords .

 [ [0, 0], [0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8], [0, 9], [1, 0], [1, 1], [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [2, 0], [2, 1], [2, 2], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], ... [8, 0], [8, 1], [8, 2], [8, 3], [8, 4], [8, 5], [8, 6], [8, 7], [8, 8], [8, 9] [9, 0], [9, 1], [9, 2], [9, 3], [9, 4], [9, 5], [9, 6], [9, 7], [9, 8], [9, 9] ] 

Это одномерная последовательность координатных векторов.

Вместе с каждой координатой мы также хотим сохранить значение цвета.

 const colors = repeat('#fff'); 

Мы используем функцию repeat () для создания бесконечной последовательности строк '#fff' . Нам не нужно беспокоиться об этом заполнении памяти и сбое нашего браузера, потому что последовательности Mori поддерживают ленивую оценку . Мы только вычислим значения элементов в последовательности, когда мы попросим их позже.

Наконец, мы хотим объединить наши координаты с нашими цветами в форме хэш-карты .

 const pixels = zipmap(coords, colors); 

Мы используем функцию zipmap () для создания хэш-карты с coords качестве ключей и colors качестве значений. Опять же, это может помочь визуализировать структуру наших данных.

 { [0, 0]: '#fff', [1, 0]: '#fff', [2, 0]: '#fff', [3, 0]: '#fff', [4, 0]: '#fff', [5, 0]: '#fff', ... [8, 9]: '#fff', [9, 9]: '#fff' } 

В отличие от объектов Javascript, хэш-карты Мори могут принимать любой тип данных в качестве ключа.

Рисование пикселя

Чтобы изменить цвет пикселя, мы свяжем одну из координат в нашей хэш-карте с новой строкой. Давайте напишем чистую функцию, которая окрашивает один пиксель.

 const draw = (x, y, pixels, color='#000') => { const coord = vector(x, y); return assoc(pixels, coord, color); }; 

Мы используем координаты x и y чтобы создать вектор координат, который мы можем использовать в качестве ключа, затем мы используем assoc (), чтобы связать этот ключ с новым цветом. Помните, что, поскольку структура данных постоянна, функция assoc() будет возвращать новую хэш-карту, а не изменять ее.

Рисование картины

Теперь у нас есть все, что нужно, чтобы нарисовать простое изображение на холсте. Давайте создадим функцию, которая берет хеш-карту координат относительно пикселей и рисует их в RenderingContext2D .

 const paint = (ctx, pixels) => { const px = pixelSize; each(pixels, p => { const [coord, color] = intoArray(p); const [x, y] = intoArray(coord); ctx.fillStyle = color; ctx.fillRect(x * px, y * px, px, px); }); }; 

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

Мы используем each () для перебора нашей хэш-карты пикселей. Он передает каждый ключ и значение (вместе как последовательность) в функцию обратного вызова как p . Затем мы используем функцию intoArray () для преобразования ее в массивы, которые можно деструктурировать, чтобы мы могли выбирать нужные значения.

 const [coord, color] = intoArray(p); const [x, y] = intoArray(coord); 

Наконец, мы используем методы canvas для рисования цветного прямоугольника в самом контексте.

 ctx.fillStyle = color; ctx.fillRect(x * px, y * px, px, px); 

Соединяя это вместе

Теперь нам нужно немного поработать, чтобы собрать все эти детали и работать.

 const canvas = document.getElementById('canvas'); const context = canvas.getContext('2d'); canvas.width = width * pixelSize; canvas.height = height * pixelSize; paint(context, pixels); 

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

Наконец, мы передадим наш контекст с нашими пикселями, которые будут нарисованы методом paint. Если повезет, ваш холст должен отображаться в виде белых пикселей. Не самое захватывающее открытие, но мы приближаемся.

интерактивность

Мы хотим прослушивать события кликов и использовать их для изменения цвета определенного пикселя с помощью нашей функции draw() из предыдущей.

 let frame = list(pixels); canvas.addEventListener('click', e => { const x = Math.floor(e.layerX / pixelSize); const y = Math.floor(e.layerY / pixelSize); const pixels = draw(x, y, frame); paint(context, pixels); frame = pixels; }); 

Мы прикрепляем прослушиватель кликов к нашему холсту и используем координаты события, чтобы определить, какой пиксель нужно нарисовать. Мы используем эту информацию для создания новой пиксельной хэш-карты с нашей функцией draw() . Затем мы рисуем это в нашем контексте и перезаписываем последний нарисованный кадр.

На этом этапе мы можем нарисовать черные пиксели на холсте, и каждый кадр будет основан на предыдущем, создавая составное изображение.

Отслеживание кадров

Чтобы реализовать отмену, мы хотим сохранить каждую историческую ревизию в пиксельной хэш-карте, чтобы мы могли получить их снова в будущем.

 let frames = list(pixels); 

Мы используем список для хранения различных «рамок», которые мы нарисовали. Списки поддерживают эффективное сложение в голове и поиск O (1) для первого элемента, что делает их идеальными для представления стеков.

Нам нужно изменить наш приемник щелчков, чтобы он работал с нашим стеком кадров.

 canvas.addEventListener('click', e => { const x = Math.floor(e.layerX / pixelSize); const y = Math.floor(e.layerY / pixelSize); const currentFrame = peek(frames); const newFrame = draw(x, y, currentFrame); frames = conj(frames, newFrame); paint(context, newFrame); }); 

Мы используем функцию peek (), чтобы получить кадр в верхней части стека. Затем мы используем его для создания нового фрейма с функцией draw() . Наконец, мы используем Con (), чтобы соединить новый кадр на вершине стека кадров.

Хотя мы меняем локальное состояние ( frame = conj(frames, newFrame) ), на самом деле мы не изменяем никакие данные.

Отмена изменений

Наконец, нам нужно реализовать кнопку отмены для выталкивания верхнего кадра из нашего стека.

 const undo = document.getElementById('undo'); undo.addEventListener('click', e => { if(count(frames) > 1) { frames = pop(frames); paint(context, peek(frames)); } }); 

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

Наконец, мы передаем верхний кадр нового стека нашей функции paint() чтобы отразить изменения. На этом этапе вы сможете рисовать и отменять изменения на холсте.

демонстрация

Вот что мы получаем в итоге:

расширения

Вот список идей о том, как можно улучшить это приложение:

  • Добавьте цветовую палитру, позволяя пользователю выбрать цвет перед рисованием
  • Используйте локальное хранилище для сохранения кадров между сессиями
  • Сделайте сочетание клавиш Ctrl + Z, чтобы отменить изменения
  • Разрешить пользователю рисовать при перетаскивании мыши
  • Реализуйте повтор, перемещая указатель индекса, а не удаляя кадры из стека
  • Прочитайте исходный код ClojureScript для той же программы

Вывод

Мы рассмотрели векторы, списки, диапазоны и хэш-карты, но Мори также поставляется с наборами, отсортированными наборами и очередями, и каждая из этих структур данных поставляется с дополнением полиморфных функций для работы с ними.

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