Приключения в виртуальной DOM - Часть 3: Тестирование и планирование

Источник Github: Учебник по виртуальному DOM - Часть 3

Ранее в этой серии

  1. Часть 1 - Определение проблемы
  2. Часть 2 - Цикл различий / рендеринга
  3. Часть 4 - Атрибуты визуализации

Вступление

Когда мы в последний раз останавливались, у нас была рабочая библиотека. Серьезно, вы можете взять эту библиотеку и действительно создать что-нибудь с декларативным пользовательским интерфейсом. Почему тогда мы все еще едем? Что ж, название должно выдавать часть этого, но это только часть картины. Я прогнал много кода в первых двух частях, чтобы подвести нас к чему-то работающему, чтобы мы могли проводить исследования и эксперименты с работающим кодом. Использование чистой теории часто оставляет место для противоречивых предположений и выводов. Теперь мы можем посмотреть, что нам дает рабочий код.

Если вы когда-либо создавали веб-приложения на ванильном JS, без каких-либо библиотек и фреймворков, вы должны быть хорошо знакомы с тем, какие накладные расходы могут возникнуть при обновлении пользовательского интерфейса DOM с отслеживанием состояния. Возможно, ваше приложение ведет себя иначе, если этот класс существует в этом элементе. Или, возможно, вам нужно найти какой-то элемент и проверить, существует ли он. Есть много неуверенности. Существует множество состояний, которые, строго говоря, не принадлежат вашему приложению. Вы должны думать не только о том, каким должно быть ваше представление, вы должны разбить это на серию обновлений, которые необходимо выполнить на основе вашего текущего состояния приложения и состояния DOM, чтобы достичь желаемого. Посмотреть. Выполняемые вами обновления могут изменяться в зависимости от состояния DOM.

Возможность рендеринга без сохранения состояния

При работе с декларативным UI, таким как наша виртуальная DOM, при каждом изменении вам просто нужно объявить, какой UI должен быть основан на данном состоянии. Мы также можем назвать это рендерингом без сохранения состояния. Неважно, каким было предыдущее состояние нашего пользовательского интерфейса. Насколько нам известно, предыдущего пользовательского интерфейса никогда не существовало. Рендеринг без сохранения состояния - это жизнь в настоящем. Нам не нужно беспокоиться о прошлом или планировать будущее.

interface IState {
    todos: Array<ITodo>
}
interface ITodo {
    name: string
    isComplete: boolean
}
const view = (state: IState): Html =>
    div({ id: 'root-container' }, [
        h1({}, [ 'Todo List' ]),
        ul({ id: 'item-list' }, [
            ...state.todos.map((todo: ITodo) => {
                return li({ className: 'todo-item' }, [
                    todo.name
                ])
            })
        ])
    ])

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

Такая архитектура известна как поток данных или, что более знакомо в веб-разработке, архитектура Elm. Вы заметите, что такая архитектура представляется естественным образом, когда вы создаете свое приложение на основе рендеринга без сохранения состояния в системе, управляемой событиями.

Если вы кодируете это вручную, ваша update функция может выглядеть примерно так:

const enum ActionType {
    AddTodo,
    RemoveTodo,
    MarkCompleted,
    MarkUncompleted,
}
function update(action: IAction, state: IState): IState {
    switch (action.type) {
        case ActionType.AddTodo:
            // return updated state
        case ActionType.RemoveTodo:
            // return updated state
        case ActionType.MarkCompleted:
            // return updated state
        case ActionType.MarkUncompleted:
            // return updated state
    }
}

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

Но это не все

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

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

Очень реальная возможность, предоставляемая созданием виртуальной модели DOM, заключается в том, что мы можем изменить способ поведения DOM, по крайней мере, в отношении того, как она работает для хост-приложений. Возможно, мы даже не называем это виртуальной DOM. Возможно, наши абстракции построены таким образом, что то, что использует хост, больше не напоминает DOM. Это просто библиотека пользовательского интерфейса для Интернета. Создаваемую нами логику обновления DOM можно использовать для поддержки любого публичного API, который мы выберем. Некоторые из моих любимых вещей, с которыми я могу поэкспериментировать, - это примитивы анимации / перехода и моделирование событий.

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

Возвращение к работе

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

По общему признанию, тестирование - это то место, где я часто отстаю. Я не всегда в курсе последних разработок и не трачу много времени на оптимизацию своих тестов. Код библиотеки тестирования обычно не страдает ужасным временем сборки / тестирования. Обычно я делю свои тесты по каталогам unit и integration. Я буду запускать модульные тесты в режиме просмотра по мере разработки и запускать интеграцию, когда буду готов выпить кофе. Для наших целей я постараюсь упростить задачу, ожидая, что мы все добавим к этому еще больше. Тестирование не так уж и интересно, но оно здесь для полноты и правильности.

В прошлый раз я упомянул, что буду использовать karma и puppeteer. Это хорошее место для начала. Давайте npm install все, что нам понадобится для этого.

$ npm install --save-dev karma karma-mocha karma-chrome-launcher
$ npm install --save-dev puppetter
$ npm install --save-dev mocha @types/mocha
$ npm install --save-dev chai @types/chai

Наш тест должен хорошо разбиваться на то, что я описывал ранее, разделив наши тесты на unit и integration. Для запуска модульных тестов не требуется среда браузера. Это должно запускать различия, создавать объекты, вещи, которым не нужны веб-API. Интеграционные тесты должны тестировать биты, которые необходимы для использования веб-API, создавать узлы и применять исправления.

Ранее мы добавляли в src/tests/index.spec.ts один жалкий маленький тест:

import { assert } from 'chai'
import { node, NodeType } from '../main' 
assert.deepEqual(node('div', {}, []), {
    type: NodeType.NODE,
    tagName: 'div',
    attributes: {},
    children: [],
})

Это хорошо, хорошо, но давайте переедем в новый дом. Нам нужно создать две новые папки src/tests/unit и src/tests/integration. Наш старый тест найдет дом в src/tests/unit/elements.spec.ts. Мы также собираемся обновить его, чтобы использовать mocha.

import { assert } from 'chai'
import { node } from '../../main/elements'
import { NodeType } from '../../main/types'
describe('node', () => {
    it('should correctly construct a node object', async () => {
        assert.deepEqual(node('div', {}, []), {
            type: NodeType.NODE,
            tagName: 'div',
            attributes: {},
            children: [],
        })
    })
})

Чтобы запустить наши модульные тесты, мы собираемся обновить наш package.json, чтобы он имел test:unit скрипт для запуска.

"scripts": {
    "clean": "rm -rf dist",
    "clean-all": "npm run clean; rm -rf node_modules",
    "lint": "tslint --fix './src/**/*.ts'",
    "webpack": "webpack",
    "prebuild": "npm run clean && npm run lint",
    "build": "tsc && npm run webpack",
    "test": "npn run test:unit",
    "pretest:unit": "npm run build",
    "test:unit": "mocha dist/tests/unit/**/*.spec.js"
},

Давай попробуем.

$ npm run test:unit
node
    ✓ should correctly construct a node object
1 passing (8ms)

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

Все становится немного сложнее, когда нам нужно ввести среду браузера. Мы собираемся использовать karma, чтобы координировать действия за нас. Нам нужно будет добавить новый файл в корневой каталог нашего проекта karma.conf.js.

process.env.CHROME_BIN = require('puppeteer').executablePath()
module.exports = (config) => {
    config.set({
        port: 9890,
        frameworks: [ 'mocha' ],
        browsers: [ 'ChromeHeadless' ],
        files: [ './dist/bundles/index.spec.js' ],
        singleRun: true,
        plugins: [
            'karma-mocha',
            'karma-chrome-launcher'
        ],
        // logLevel: config.LOG_DEBUG,
    })
}

Мы собираемся объединить наши интеграционные тесты в один файл, чтобы упростить работу с karma и puppeteer. Это означает, что нам понадобится второй комплект. Этот второй пакет будет работать иначе, чем наш дистрибутив, в том смысле, что наша цель не будет библиотекой. Поскольку наш тест не будет создаваться как библиотека, я собираюсь добавить вторую конфигурацию webpack webpack.test.config.js.

const { resolve } = require('path')
module.exports = {
    mode: 'development',
    entry: './dist/tests/integration/index.js',
    output: {
        path: resolve(__dirname, 'dist', 'bundles'),
        filename: 'index.spec.js',
    }
}

Вторая конфигурация веб-пакета означает, что нам понадобится вторая команда сборки в нашем package.json. Это также означает, что нам понадобится еще одна тестовая команда для запуска наших интеграционных тестов: start karma, чтобы запустить наш тестовый пакет.

"scripts": {
    // other scripts
    "build:test": "webpack --config webpack.test.config.js",
    "test": "npn run test:unit",
    "pretest:unit": "npm run build",
    "test:unit": "mocha dist/tests/unit/**/*.spec.js",
    "pretest:integration": "npm run build:test",
    "test:integration": "karma start"
},

На данный момент у нас по-прежнему отсутствуют какие-либо тесты, которые можно было бы запустить karma. Что нам понадобится в среде браузера для тестирования? Рендеринг - это довольно просто. Давайте добавим два новых файла src/tests/integration/index.ts и src/tests/integration/render.spec.ts. Новый файл index.ts существует только как точка входа для webpack для создания наших интеграционных тестов. Это означает, что все, что ему нужно сделать, это импортировать тесты, которые нам нужно запустить.

import './render.spec'

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

В любом случае, протестировать нашу render функцию просто.

import { assert } from 'chai'
import { div } from '../../main/elements'
import { render } from '../../main/render'
import { NodeCache, NodeType } from '../../main/types'
describe('render', () => {
    it('should correctly generate an element from a virtual node', async () => {
        const node = div({}, [ 'Hello World' ])
        const nodeCache = new NodeCache()
        const realNode: HTMLElement =
            render(node, nodeCache) as HTMLElement
        assert.equal(realNode.outerHTML, '<div>Hello World</div>')
    })
})

Теперь мы можем запустить npm test, и он запустит как наши unit, так и integration тесты.

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

Добавьте новый файл src/tests/unit/diff.spec.ts.

import { assert } from 'chai'
import { diff } from '../../main/diff'
import { div, p, section, text } from '../../main/elements'
import { NodeCache, NodeType, PatchType } from '../../main/types'
describe('diff', () => {
    it('should return an empty array for identical nodes', async () => {
        const oldNode = div({}, [])
        const newNode = div({}, [])
        const nodeCache = new NodeCache()
        const patches = diff(oldNode, newNode, nodeCache)
        assert.deepEqual(patches, [])
    })
    it('should correctly diff adding attributes to node', async () => {
        const oldNode = div({}, [])
        const newNode = div({ id: 'test-div' }, [])
        const nodeCache = new NodeCache()
        const patches = diff(oldNode, newNode, nodeCache)
        assert.deepEqual(patches, [ {
            type: PatchType.PROPS,
            attributes: {
                id: 'test-div',
            },
            domNode: undefined,
        } ])
    })
})

Итак, у нас есть два теста, на которые стоит обратить внимание. Первое достаточно просто. Если два виртуальных узла идентичны, массив патчей, возвращаемый для различения этих двух узлов, должен быть пустым. Самое интересное - во втором. Сначала это незаметно. Когда я утверждаю, какие патчи ожидаются, у меня domNode: undefined. В нашей diff функции мы помещаем ссылку на фактический узел DOM на Patch, чтобы Patch знал, какой реальный узел он обновляет. Наш тип для этого конкретного патча был примерно таким:

export interface IPropsPatch {
    type: PatchType.PROPS
    attributes: IAttributes
    domNode: Node
}

Этот интерфейс не учитывает наше тестовое использование, когда мы можем вызвать diff, не выполнив предварительно render. Настоящий узел был создан во время рендеринга. В этом тестовом варианте использования domNode равно undefined, потому что он никогда не отображался. Я собираюсь обновить тип для учета тестового примера, сделав свойство domNode необязательным.

export interface IPropsPatch {
    type: PatchType.PROPS
    attributes: IAttributes
    domNode?: Node
}

В нашем коде есть только одно место, где это вызывает какие-либо проблемы, в нашей функции applyPatch. TypeScript будет жаловаться на то, что мы используем domNode небезопасно, поскольку это может быть undefined. Чтобы учесть это, я собираюсь добавить условие вверху функции applyPatch в src/main/patches.ts.

function applyPatch(patch: Patch, nodeCache: NodeCache): void {
    if (patch.domNode === undefined) {
        console.error(`Patch does not have DOM node to apply to.`)
    } else {
        // Apply patches as normal
    }
 }

В случае, если Patch с неопределенным domNode попадет в applyPatch функцию, мы просто зарегистрируем ошибку и не будем пытаться применить Patch.

Ладно, это больше не то, как у меня сейчас настроено тестирование. Я оставлю вас заполнить больше тестов или проверить проект Github.

Планирование

Когда мы в последний раз останавливались, планировщик, возвращенный нашей функцией scene, ничего не планировал. Все, что он делал, это выполнял свою работу синхронно при вызове. Одна из вещей, если не самая главная, которую мы пытаемся сделать, - это ограничить объем работы, которую мы должны сделать. То, что делает наша библиотека, может быть дорогостоящим. Нам нужно ограничить это. Если наша сцена просто визуализируется синхронно при каждом обновлении хост-приложения, мы можем делать больше работы, чем нам нужно. У нас нет причин рендерить быстрее желаемой частоты кадров, обычно 60 кадров в секунду.

Самый простой способ добиться этого - запустить наш планировщик на requestAnimationFrame. Мы могли бы сделать это настраиваемым, чтобы еще больше ограничить работу. Может быть, для пользователя все в порядке, если мы выполним рендеринг с максимальной частотой 30 или 20 кадров в секунду. А пока мы будем придерживаться requestAnimationFrame.

Оглядываясь на мгновение назад, наша текущая функция scene выглядит так:

import { diff } from './diff'
import { applyPatches } from './patches'
import { render } from './render'
import { Html, NodeCache } from './types'

export type Scheduler =
    (newView: Html) => void

export function scene(
    initialView: Html,
    rootNode: HTMLElement,
): Scheduler {
    let savedView: Html = initialView

    const nodeCache: NodeCache = new NodeCache()
    const domNode: Node = render(initialView, nodeCache)

    rootNode.appendChild(domNode)

    return (newView: Html): void => {
        const patches = diff(savedView, newView, nodeCache)
        applyPatches(patches, nodeCache)
        savedView = newView
    }
}

У нас есть кое-что, что нам нужно. Когда мы создаем сцену, нам нужна переменная для хранения ранее визуализированного вида savedView. Нам также нужен уникальный экземпляр NodeCache для сохранения ссылок на узлы, которые мы рендерим. Нам также нужно отрендерить исходный вид. Это все хорошо. Возникает вопрос: что значит запланировать обновление? Это означает, что мы не собираемся сразу запускать сравнение и рендерить. Мы собираемся сохранить это на какое-то время в будущем.

Давайте начнем с нашей scene функции и оставим только то, что, как мы знаем, нам сейчас нужно.

export function scene(
    initialView: Html,
    rootNode: HTMLElement,
): Scheduler {
    let savedView: Html = initialView

    const nodeCache: NodeCache = new NodeCache()
    const domNode: Node = render(initialView, nodeCache)

    rootNode.appendChild(domNode)
    return (newView: Html): void => {
    }
}

Внешний вид функции Scheduler не изменится. Это просто функция, которая принимает новое представление и возвращает void, указывая на то, что вся ее работа заключается в выполнении побочного эффекта. Одна очевидная вещь, о которой обычно не говорится явно, - это то, что если функция возвращает void, она существует для выполнения побочного эффекта. Мы на нечистой территории.

Я собираюсь предложить добавить новую переменную в нашу сцену. Одна часть состояния, которую мы должны отслеживать, - это savedView, или текущее представление, другая вещь, которую мы должны отслеживать, - это следующее представление или запланированное представление. Чтобы учесть это, я собираюсь переименовать savedView в currentView и добавить новую переменную scheduledView.

export function scene(
    initialView: Html,
    rootNode: HTMLElement,
): Scheduler {
    let currentView: Html = initialView
    let scheduledView: Html | null = null
    const nodeCache: NodeCache = new NodeCache()
    const domNode: Node = render(initialView, nodeCache)

    rootNode.appendChild(domNode)
    return (newView: Html): void => {
    }
}

Запланированное представление может быть либо типа Html, либо null, потому что у нас не всегда будет представление по расписанию. Или сцена будет вести себя немного иначе, когда у нас запланирован просмотр, а когда нет.

Функция, которую возвращает scene, называется Scheduler. Он не собирается выполнять фактическую работу по выполнению различий и рендеринга. Ему просто нужно запланировать эту работу. Для этого мы используем requestAnimationFrame. Что мы планируем с requestAnimationFrame? Нам нужна другая функция, которая действительно выполняет эту работу.

export function scene(
    initialView: Html,
    rootNode: HTMLElement,
): Scheduler {
    let currentView: Html = initialView
    let scheduledView: Html | null = null
    const nodeCache: NodeCache = new NodeCache()
    const domNode: Node = render(initialView, nodeCache)

    rootNode.appendChild(domNode)
    function draw() {
    }
    return (newView: Html): void => {
        scheduledView = newView
    }
}

Здесь наша сцена должна вести себя по-разному в зависимости от наличия scheduledView. Смысл планирования обновлений в том, чтобы мы не перерисовывали быстрее, чем желаемая частота кадров. Если да, то это просто напрасная работа. Поэтому, если пользователь планирует представление, а затем планирует новое представление до того, как будет выполнено следующее сравнение и рендеринг, нам просто нужно сравнить / отрендерить последний просмотр, который они запланировали. Они могли вызывать Scheduler сотню раз между обновлениями кадров, а мы фактически выполняли бы работу по различию / рендерингу только для новейшего представления, которое они запланировали. Это позволяет хост-приложению работать с любой желаемой частотой, не пытаясь обновить экран быстрее, чем это целесообразно.

То есть нам нужно запланировать draw только тогда, когда scheduledView имеет значение null. Когда уже есть запланированный просмотр, нам нужно только обновить scheduledView, а не сбрасывать ожидающие вызовы на diff / render.

Вот как это выглядит в коде:

export function scene(
    initialView: Html,
    rootNode: HTMLElement,
): Scheduler {
    let currentView: Html = initialView
    let scheduledView: Html | null = null
    const nodeCache: NodeCache = new NodeCache()
    const domNode: Node = render(initialView, nodeCache)

    rootNode.appendChild(domNode)
    function draw() {
    }
    return (newView: Html): void => {
        if (scheduledView === null) {
            requestAnimationFrame(draw)
        }
        scheduledView = newView
    }
}

Это говорит о том, что если у нас есть newView для планирования, нам нужно убедиться, что обновление запланировано. Обновление в нашей сцене фактически выполняет работу, необходимую для рисования на экране (diff / render).

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

export function scene(
    initialView: Html,
    rootNode: HTMLElement,
): Scheduler {
    let currentView: Html = initialView
    let scheduledView: Html | null = null
    const nodeCache: NodeCache = new NodeCache()
    const domNode: Node = render(initialView, nodeCache)

    rootNode.appendChild(domNode)
    function draw() {
        if (scheduledView !== null) {
            const patches = diff(
                currentView,
                scheduledView,
                nodeCache,
            )
            applyPatches(patches, nodeCache)
            currentView = scheduledView
            scheduledView = null
        }
    }
    return (newView: Html): void => {
        if (scheduledView === null) {
            requestAnimationFrame(draw)
        }
        scheduledView = newView
    }
}

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

Заключение

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