RefluxJS — альтернативный взгляд на Flux архитектуру от Facebook

в 4:41, , рубрики: flux, html, javascript, react.js, ReactJS, reflux, refluxjs, Веб-разработка, разработка

От переводчика: посмотрев на ReactJS и вдохновившись его простотой, начал искать библиотеку, которая бы обеспечивала такой же простой обмен данными внутри моего приложения. Наткнулся на Flux, увидел примеры кода и пошел искать альтернативу. Набрел на RefluxJS, немедленно полюбил и пошел переводить официальную доку. Она написана как раз в стиле статьи, поэтому в первую очередь решил поделиться ей с читателим :) Перевод несколько вольный. Кое-где, если мне казалось, что что-то нуждается в дополнительном пояснении или примере, я не стеснялся.

В переводе ниже в качестве перевода для термина Action из Reflux иногда используется термин «событие», а иногда — термин «экшен», в зависимости от контекста. Более удачного перевода мне подобрать не удалось. Если у вас есть варианты, жду предложений в комментариях ;)

Обзор

image image image image image

RefluxJS — простая библиотека, обеспечивающая в вашем приложении однонаправленный поток данных, использующая концепцию Flux от Facebook.

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

╔═════════╗       ╔════════╗       ╔═════════════════╗
║ Actions ║──────>║ Stores ║──────>║ View Components ║
╚═════════╝       ╚════════╝       ╚═════════════════╝
     ^                                      │
     └──────────────────────────────────────┘

Паттерн состоит из экшенов (actions) и хранилищ данных (stores). Экшены инициируют движение данных с помощью событий через хранилища к визуальным компонентам. Если пользователь сделал что-то, с помощью экшена генерируется соответствующее событие. На это событие подписано хранилище данных. Оно обрабатывает событие и, возможно, в свою очередь генерирует какое-то свое.

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

Содержание

  • Сравнение Reflux и Facebook Flux
  • Примеры
  • Установка
  • Использование
    • События
    • Хранилища
    • Использование с компонентами ReactJS
  • Детали
  • Эпилог

Сравнение Reflux и Facebook Flux

Цель проекта RefluxJS — более простая и быстрая интеграция Flux архитектуры в ваш проект как на стороне клиента, так и на стороне сервера. Однако существуют некоторые различия между тем, как работает RefluxJS и тем, что предлагает классическая Flux-архитектура. Более подробная информация есть в этом блог-посте.

Сходства с Flux

Некоторые концепции RefluxJS сходны с Flux:

  • Есть экшены
  • Есть хранилища данных
  • Данные движутся только в одном направлении.
Отличия от Flux

RefluxJS — улучшенная версия Flux-концепции, более динамичная и более дружелюбная к функциональному реактивному программированию:

  • Диспетчера событий (dispatcher), который в Flux был синглтоном, в RefluxJS нет. Вместо этого каждое событие (экшен) является своим собственным диспетчером.
  • Поскольку на экшены можно подписываться, хранилища могут это делать напрямую без использования громоздких операторов switch для отделения мух от котлет.
  • Хранилища могут подписываться на другие хранилища. То есть, появляется возможность создавать хранилища, которые агрегируют и обрабатывают данные в стиле map-reduce.
  • Вызов waitFor() удален. Вместо него обработка данных может производиться последовательно или параллельно.
    • Хранилища, агрегирующие данные (см. выше) могут подписываться на другие хранилища, обрабатывая сообщения последовательно
    • Для ожидания обработки других событий можно использовать метод join()

  • Специальные фабрики экшенов (action creators) не нужны вовсе, поскольку экшены RefluxJS являются функциями, передающими нужные данные всем, кто на них подписался.

Примеры

Некоторые примеры можно найти по следующим адресам:

Установка

В настоящий момент RefluxJS можно установить с помощью npm или с помощью bower.

NPM

Для установки с помощью npm выполните следующую команду:

    npm install reflux
Bower

Для установки с помощью bower:

    bower install reflux
ES5

Как и React, RefluxJS требует наличия es5-shim для устаревших браузеров. Его можно взять тут

Использование

Полноценный пример можно найти тут.

Создаем экшены

Экшены создаются с помощью вызова `Reflux.createAction()`. В качестве параметра можно передать список опций.

var statusUpdate = Reflux.createAction(options);

Объект экшена является функтором, поэтому его можно вызвать, обратившись к объекту как к функции:

statusUpdate(data); // Вызываем экшен statusUpdate, передавая в качестве данных data
statusUpdate.triggerAsync(data); // Тоже самое, что выше

Если `options.sync` установлено в значение «истина», событие будет инициировано как синхронная операция. Эту настройку можно изменить в любой момент. Все следующие вызовы будут использовать установленное значение.

Для удобного создания большого количества экшенов можно сделать так:

var Actions = Reflux.createActions([
    "statusUpdate",
    "statusEdited",
    "statusAdded"
  ]);

// Теперь объект Actions содержит экшены с именами, которые мы передали в вызов createActions().
// Инициировать события можно как обычно

Actions.statusUpdate();
Асинхронная работа с экшенами

Для событий, которые могут обрабатываться асинхронно (например, вызовы API) есть несколько различных вариантов работы. В самом общем случае мы рассматриваем успешное завершение обработки и ошибку. Для создания различных событий в таком варианте можно использовать `options.children`.

// Создаем экшены 'load', 'load.completed' и 'load.failed'
var Actions = Reflux.createActions({
    "load": {children: ["completed","failed"]}
});

// При получении данных от экшена 'load', асинхронно выполняем операцию, 
// а затем в зависимости от результата, вызываем экшены failed или completed
Actions.load.listen( function() {
    // По умолчанию обработчик привязан к событию. 
    //Поэтому его дочерние элементы доступны через this
    someAsyncOperation()
        .then( this.completed )
        .catch( this.failed );
});

Для рассмотренного случая есть специальная опция: `options.asyncResult`. Следующие определения экшенов эквивалентны:

createAction({
    children: ["progressed","completed","failed"]
});

createAction({
    asyncResult: true,
    children: ["progressed"]
});

Для автоматического вызова дочерних экшенов `completed` и `failed` есть следующие методы:

  • `promise` — В качестве параметра ожидает объект промиса и привязывает вызов `completed` и `failed` к этому промису с использованием `then()` и `catch()`.
  • `listenAndPromise` — В качестве параметра ожидает функцию, которая возвращает объект промиса. Он (объект промиса, который вернула функция) будет вызван при наступлении события. Соответственно, по `then()` и `catch()` промиса автоматически вызваны completed и failed

Следующие три определения эквивалентны:

asyncResultAction.listen( function(arguments) {
    someAsyncOperation(arguments)
        .then(asyncResultAction.completed)
        .catch(asyncResultAction.failed);
});

asyncResultAction.listen( function(arguments) {
    asyncResultAction.promise( someAsyncOperation(arguments) );
});

asyncResultAction.listenAndPromise( someAsyncOperation );
Асинхронные экшены как промисы

Асинхронные экшены можно использовать как промисы. Особенно это удобно для рендеринга на сервере, когда вам требуется дождаться успешного (или нет) завершения некоторого события перед рендерингом.

Предположим, у нас есть экшен и хранилище и нам нужно выполнить API запрос:

// Создаем асинхронный экшен с `completed` & `failed` "подэкшенами"
var makeRequest = Reflux.createAction({ asyncResult: true });

var RequestStore = Reflux.createStore({
    init: function() {
        this.listenTo(makeRequest, 'onMakeRequest');
    },

    onMakeRequest: function(url) {
        // Предположим, что `request` - какая-то HTTP библиотека
        request(url, function(response) {
            if (response.ok) {
                makeRequest.completed(response.body);
            } else {
                makeRequest.failed(response.error);
            }
        })
    }
});

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

makeRequest('/api/something').then(function(body) {
    // Render the response body
}).catch(function(err) {
    // Handle the API error object
});
Хуки, доступные при обработке событий

Для каждого события доступно несколько хуков.

  • `preEmit` — вызывается перед тем, как экшен передаст информацию о событии подписчикам. В качестве аргументов хук получает аргументы, использованнные при отправке события. Если хук вернет что-либо, отличное от undefined, возвращаемое значение будет использовано как параметры для хука `shouldEmit` и заменит собой отправленные данные
  • `shouldEmit` — вызывается после `preEmit`, но до того, как экшен передаст информацию о событии подписчикам. По умолчанию этот обработчик возвращает true, что разрешает отправку данных. Это поведение можно переопределить, например, чтобы проверить аргументы и решить, должно ли событие быть отправлено в цепочку или нет.

Пример использования:

Actions.statusUpdate.preEmit = function() { console.log(arguments); };
Actions.statusUpdate.shouldEmit = function(value) {
    return value > 0;
};

Actions.statusUpdate(0);
Actions.statusUpdate(1);
// Должно быть выведено: 1

Определять хуки можно прямо при объявлении экшенов:

var action = Reflux.createAction({
    preEmit: function(){...},
    shouldEmit: function(){...}
});
Reflux.ActionMethods

Если вам нужно, на объектах всех экшенов можно было выполнить какой-то метод, для этого вы можете расширить объект`Reflux.ActionMethods`, который автоматически подмешивается ко всем экшенам при создании.

Пример использования:

Reflux.ActionMethods.exampleMethod = function() { console.log(arguments); };

Actions.statusUpdate.exampleMethod('arg1');
// Выведет: 'arg1'
Создание хранилищ

Хранилища создаются примерно так же, как и классы компонентов ReactJS (`React.createClass`) — путем передачи объекта, определяющего параметры хранилища методу `Reflux.createStore`. Все обработчики событий можно проинициализовать в методе `init` хранилища, вызывав собственный метод хранилища `listenTo`.

// Создаем хранилище
var statusStore = Reflux.createStore({

    // Начальная настройка
    init: function() {

        // Подписываемся на экшен statusUpdate
        this.listenTo(statusUpdate, this.output);
    },

    // Определяем сам обработчик события, отправляемого экшеном
    output: function(flag) {
        var status = flag ? 'ONLINE' : 'OFFLINE';

        // Используем хранилище как источник события, передавая статус как данные
        this.trigger(status);
    }

});

В примере выше, при вызове экшена `statusUpdate`, будет вызыван метод хранилища `output` со всеми параметрами, переданными при отправке. Например, если событие было отправлено с помощью вызова `statusUpdate(true)` в функцию `output` будет передан флаг `true`. А после этого само хранилище сработает как экшен и передаст своим подписчикам в качестве данных `status`.

Поскольку хранилища сами являются инициаторами отправки событий, у них тоже есть хуки `preEmit` и`shouldEmit`.

Reflux.StoreMethods

Если необходимо сделать так, чтобы определенный набор методов был доступен сразу во всех хранилищах, для этого можно расширить объект `Reflux.StoreMethods`, который подмешивается во все хранилища при их создании.

Пример использования:

Reflux.StoreMethods.exampleMethod = function() { console.log(arguments); };

statusStore.exampleMethod('arg1');
// Будет выведено: 'arg1'
Примеси (mixins) в хранилищах

Точно также, как вы подмешиваете объекты в компоненты React, вы можете подмешивать их к вашим хранилищам:

var MyMixin = { foo: function() { console.log('bar!'); } }
var Store = Reflux.createStore({
    mixins: [MyMixin]
});
Store.foo(); // Выведет "bar!" в консоль

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

var MyMixin = { mixinMethod: function() { console.log(this.foo); } }
var Store = Reflux.createStore({
    mixins: [MyMixin],
    foo: 'bar!',
    storeMethod: function() {
        this.mixinMethod(); // Выведет "bar!"
    }
});

Удобно, что если в хранилище подмешано несколько примесей, определяющих одни и те же методы жизненного цикла событий (`init`, `preEmit`, `shouldEmit`), все эти методы будут гарантировано вызваны (как и в ReactJS, собственно)

Удобная подписка на большое количество экшенов

Поскольку обычно в методе init хранилища выполняется подписка на все зарегистрированные экшены, у хранилищ имеется метод `listenToMany`, который принимает в качестве аргумента объект со всеми созданными событиями. Вместо вот такого кода:

var actions = Reflux.createActions(["fireBall","magicMissile"]);

var Store = Reflux.createStore({
    init: function() {
        this.listenTo(actions.fireBall,this.onFireBall);
        this.listenTo(actions.magicMissile,this.onMagicMissile);
    },
    onFireBall: function(){
        // whoooosh!
    },
    onMagicMissile: function(){
        // bzzzzapp!
    }
});

… можно использовать такой:

var actions = Reflux.createActions(["fireBall","magicMissile"]);

var Store = Reflux.createStore({
    init: function() {
        this.listenToMany(actions);
    },
    onFireBall: function(){
        // whoooosh!
    },
    onMagicMissile: function(){
        // bzzzzapp!
    }
});

Подобный код добавит обработчики для всех экшенов `actionName`, для которых есть соответствующий метод хранилища `onActionName` (или `actionName` если вам так удобнее). В примере выше, если бы объект `actions` содержал также экшен `iceShard` он просто был бы проигнорирован (поскольку для него нет соответствующего обработчика).

Свойство `listenables`

Чтобы вам было еще более удобно, вы можете присвоить свойству хранилища `listenables` объект с экшенами, он он будет автоматически передан в `listenToMany`. Поэтому пример выше можно упростить до такого:

var actions = Reflux.createActions(["fireBall","magicMissile"]);

var Store = Reflux.createStore({
    listenables: actions,
    onFireBall: function(){
        // whoooosh!
    },
    onMagicMissile: function(){
        // bzzzzapp!
    }
});

Свойство `listenables` может представлять собой и массив подобных объектов. В этом случае каждый объект будет передан в `listenToMany`.Это позволяет удобно делать следующее:

var Store = Reflux.createStore({
    listenables: [require('./darkspells'),require('./lightspells'),{healthChange:require('./healthstore')}],
    // остальной код удален для улучшения читаемости
});
Подписка на хранилища (обработка событий, отправляемых хранилищами)

В вашем компоненте вы можете подписаться на обработку событий от хранилища вот так:


// Хранилище данных для статуса
var statusStore = Reflux.createStore({

    // Начальная настройка
    init: function() {

        // Подписываемся на экшен statusUpdate
        this.listenTo(statusUpdate, this.output);
    },

    // Обработчик
    output: function(flag) {
        var status = flag ? 'ONLINE' : 'OFFLINE';

        // Инициируем собственное событие
        this.trigger(status);
    }
});

// Очень простой компонент, который просто выводит данные в консоль
function ConsoleComponent() {

    // Регистрируем обработчик протоколирования
    statusStore.listen(function(status) {
        console.log('status: ', status);
    });
};
var consoleComponent = new ConsoleComponent();

Отправляем события по цепочке, используя объект экшена `statusUpdate` как функции:

statusUpdate(true);
statusUpdate(false);

Если сделать все, как указано выше, вывод должен получиться вот таким:

status:  ONLINE
status:  OFFLINE
Пример работы с компонентами React

Подписываться на экшены в компоненте React можно в методе `componentDidMount` [lifecycle method](), а отписываться в методе `componentWillUnmount` примерно вот так:

var Status = React.createClass({
    initialize: function() { },
    onStatusChange: function(status) {
        this.setState({
            currentStatus: status
        });
    },
    componentDidMount: function() {
        this.unsubscribe = statusStore.listen(this.onStatusChange);
    },
    componentWillUnmount: function() {
        this.unsubscribe();
    },
    render: function() {
        // Рендеринг компонента
    }
});
Примеси для удобной работы внутри компонентов React

Поскольку в компонентах необходимо постоянно подписываться / отписываться от событий в нужные моменты, для удобства использования можно использовать примесь `Reflux.ListenerMixin`. С его использованием пример выше можно переписать так:

var Status = React.createClass({
    mixins: [Reflux.ListenerMixin],
    onStatusChange: function(status) {
        this.setState({
            currentStatus: status
        });
    },
    componentDidMount: function() {
        this.listenTo(statusStore, this.onStatusChange);
    },
    render: function() {
        // render specifics
    }
});

Эта примесь делает доступным для вызова внутри компонента метод `listenTo, который работает точно также, как одноименный метод хранилищ. Можно использовать и метод `listenToMany`.

Использование Reflux.listenTo

Если вы не используете никакой специфичной логики в отношении `this.listenTo()` внутри `componentDidMount()`, вы можете использовать вызов `Reflux.listenTo()` как примесь. В этом случае `componentDidMount()` будет автоматически сконфигурирован требуемым образом, а вы получите примесь `ListenerMixin` в вашем компоненте. Таким образом пример выше может быть переписан так:

var Status = React.createClass({
    mixins: [Reflux.listenTo(statusStore,"onStatusChange")],
    onStatusChange: function(status) {
        this.setState({
            currentStatus: status
        });
    },
    render: function() {
        // Рендеринг с использованием `this.state.currentStatus`
    }
});

Можно вставлять несколько вызовов `Reflux.listenTo` внутри одного и того же массива`mixins`.

Существует также `Reflux.listenToMany` который работает аналогичным образом, позволяя использовать `listener.listenToMany`.

Использование Reflux.connect

Если все, что вам нужно, это обновить состояние компонента при получении данных от хранилища, вы можете воспользоваться выражением `Reflux.connect(listener,[stateKey])` как примесью компонента ReactJS. Если передать туда необязательный ключ `stateKey`, состояние компонента будет автоматически обновлено с помощью `this.setState({:data})`. Если `stateKey` не передан, будет сделан вызов `this.setState(data)`. Вот пример выше, переписанный с учетом новых возможностей:

var Status = React.createClass({
    mixins: [Reflux.connect(statusStore,"currentStatus")],
    render: function() {
        // render using `this.state.currentStatus`
    }
});
Использование Reflux.connectFilter

`Reflux.connectFilter` можно использовать точно также, как `Reflux.connect`. Используйте `connectFilter` в качестве примеси в случае, если вам требуется передавать в компонент только часть состояния хранилища. Скажем, блог, написанный с использованием Reflux, скорее всего будет держать в хранилище все публикации. А на странице отдельного поста можно использовать `Reflux.connectFilter` для фильтрации постов.

var PostView = React.createClass({
    mixins: [Reflux.connectFilter(postStore,"post", function(posts) {
        posts.filter(function(post) {
           post.id === this.props.id;
        });
    })],
    render: function() {
        // Отрисовываем, используя `this.state.post`
    }
});
Обработка событий об изменениях от других хранилищ

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

// Создаем хранилище, которое реагирует на изменения, происходящие в statusStore
var statusHistoryStore = Reflux.createStore({
    init: function() {

        // Подписываемся на хранилище как на экшен
        this.listenTo(statusStore, this.output);

        this.history = [];
    },

    // Обработчик экшена
    output: function(statusString) {
        this.history.push({
            date: new Date(),
            status: statusString
        });
        // Инициируем собственное событие
        this.trigger(this.history);
    }
});

Дополнительные возможности

Использование альтернативной библиотеки управления событиями

Не нравится `EventEmitter`, предоставляемый по умолчанию? Вы можете переключиться на использование любого другого, в том числе и встроенного в Node вот так:

// Это нужно сделать до создания экшенов и хранилищ
Reflux.setEventEmitter(require('events').EventEmitter);
Использование альтернативной библиотеки промисов

Не нравится библиотека, реализующая функциональность промисов, предоставляемая по умолчанию? Вы можете переключиться на использование любой другой (например, Bluebird вот так:

// Это нужно сделать до вызова любых экшенов
Reflux.setPromise(require('bluebird'));

Имейте ввиду, что промисы в RefluxJS создаются с помощью вызова `new Promise(...)`. Если ваша библиотека использует фабрики, используйте вызов `Reflux.setPromiseFactory()`.

Использование фабрики промисов

Поскольку большая часть библиотек для работы с промисами не использует конструкторы (`new Promise(...)`), настраивать фабрику не нужно.

Однако, если вы используете что-нибудь вроде `Q` или какую-нибудь другую библиотеку, которая использует для создания промисов фабричный метод, используйте вызов `Reflux.setPromiseFactory` чтобы его указать.

// Это нужно сделать до использования экшенов
Reflux.setPromiseFactory(require('Q').Promise);
Использование альтернативы nextTick

Когда вызывается экшен вызывается как функтор, это происходит асинхронно. Возврат управления производится немедленно, а соответствующий обработчик вызывается через `setTimeout` (функция `nextTick`) внутри RefluxJS.

Вы можете выбрать ту реализацию отложенного вызова методов (`setTimeout`, `nextTick`, `setImmediate` и т.д.) которая вас устраивает.

// node.js env
Reflux.nextTick(process.nextTick);

В качестве альтернатив получше, вам может понадобится полифил `setImmediate` или `macrotask`

Ожидание завершения работы всех экшенов в цепочке

В Reflux API есть методы `join`, которые обеспечивают удобную агрегацию источников, отправляющих события параллельно. Это тоже самое, что делает метод `waitFor` в оригинальной реализации Flux от Facebook.

Отслеживание аргументов

Обработчик, переданный соответствующему `join()` вызову будет вызыван как только все участники отправят событие как минимум единожды. Обработчику будут переданы параметры каждого события в том порядке, в котором участники операции объявлялись при вызове `join`.

Существует четыре варианта `join`, каждый из которых представляет собой особую стратегию работы с данными:

  • `joinLeading`: От каждого издателя сохраняется только результат первого вызова события. Все остальные данные игнорируются
  • `joinTrailing`: От каждого издателя сохраняется только результат последнего вызова события. Все остальные данные игнорируются
  • `joinConcat`: Все результаты сохраняются в массиве.
  • `joinStrict`: Повторный вызов события от одного и того же издателя приводит к ошибке.

Сигнатуры всех методов выглядят одинаково:

joinXyz(...publisher, callback)

Как только `join()` выполнится, все связанные с ним ограничения будут сняты и он снова сможет сработать, если издатели снова отправят события в цепочку.

Использование методов экземпляра для управления событиями

Все объекты, использующие listener API (хранилища, компоненты React, подмешавшие `ListenerMixin`, или другие компоненты, использующие `ListenerMethods`) получают доступ к четырем вариантам метода `join`, о которых мы говорили выше:

var gainHeroBadgeStore = Reflux.createStore({
    init: function() {
        this.joinTrailing(actions.disarmBomb, actions.saveHostage, actions.recoverData, this.triggerAsync);
    }
});

actions.disarmBomb("warehouse");
actions.recoverData("seedyletter");
actions.disarmBomb("docks");
actions.saveHostage("offices",3);
// `gainHeroBadgeStore` в этом месте кода хранилище отправит событие в цепочку с параметрами `[["docks"],["offices",3],["seedyletter"]]`
Использование статических методов

Поскольку использование методов `join`, а затем отправки события в цепочку является обычным делом для хранилища, все методы join имеют свои статические эквиваленты в объекте `Reflux`, которые возвращают объект хранилища, подписанный на указанные события. Используя эти методы пример выше можно переписать так:

var gainHeroBadgeStore = Reflux.joinTrailing(actions.disarmBomb, actions.saveHostage, actions.recoverData);
Отправка состояния по умолчанию с использованием метода listenTo

Функция `listenTo`, предоставляемая хранилищем и `ListenerMixin` имеет третий параметр, который может быть функцией. Эта функция будет вызвана в момент регистрации обработчика с результатом вызова `getInitialState` в качестве параметров.

var exampleStore = Reflux.createStore({
    init: function() {},
    getInitialState: function() {
        return "какие-то данные по умолчанию";
    }
});

// Подписываемся на события от хранилища
this.listenTo(exampleStore, onChangeCallback, initialCallback)

// initialCallback будет вызван немедленно с параметром "какие-то данные по умолчанию"

Помните метод `listenToMany`? Если вы используете его с другими хранилищами, он тоже поддерживает `getInitialState`. Данные, возвращаемые этим методом будут переданы обычному обработчику, либо в метод `this.onDefault`, если он существует.

Колофон

Автор: FractalizeR

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js