company_banner

Руководство по Discovery.js: быстрый старт

Автор оригинала: Roman Dvornov
  • Перевод
  • Tutorial

Это и последующие руководства проведут вас через процесс создания решения на основе проекта Discovery.js. Наша цель — создать инспектор NPM-зависимостей, то есть интерфейс для исследования структуры node_modules.



Примечание: Discovery.js находится на ранней стадии разработки, поэтому со временем что-то будет упрощаться и становиться полезнее. Если у вас есть идеи, как можно что-то улучшить, напишите нам.

Аннотация


Ниже вы найдёте обзор ключевых концепций Discovery.js. Изучить весь код руководства можно в репозитории на GitHub, или можете попробовать как это работает онлайн.


Начальные условия


Прежде всего нам нужно выбрать проект для анализа. Это может быть свежесозданный проект или уже существующий, главное, чтобы он содержал node_modules (объект нашего анализа).


Сначала установим основной пакет discoveryjs и его консольные инструменты:


npm install @discoveryjs/discovery @discoveryjs/cli

Далее запускаем сервер Discovery.js:


> npx discovery
No config is used
  Models are not defined (model free mode is enabled)
Init common routes ... OK
Server listen on http://localhost:8123

Если открыть в браузере http://localhost:8123, то можем увидеть следующее:


Discovery без настройки


Это режим без модели, то есть режим когда ничего не сконфигурировано. Но уже сейчас с помощью кнопки "Load data" можно выбрать любой JSON-файл, или просто перетащить его на страницу, и начать анализ.


Однако нам нужно нечто конкретное. В частности, нам нужно получить представление структуры node_modules. Для этого добавим конфигурацию.


Добавляем конфигурацию


Как вы могли заметить, при запуске сервера вывелось сообщение No config is used. Давайте создадим конфигурационный файл .discoveryrc.js с таким содержимым:


module.exports = {
    name: 'Node modules structure',
    data() {
        return { hello: 'world' };
    }
};

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


{
   ...
   "discovery": "path/to/discovery/config.js",
   ...
}

Перезапустим сервер, чтобы конфигурация была применена:


> npx discovery
Load config from .discoveryrc.js
Init single model
  default
    Define default routes ... OK
    Cache: DISABLED
Init common routes ... OK
Server listen on http://localhost:8123

Как видите, теперь созданный нами файл используется. И применяется описанная нами модель по умолчанию (Discovery может работать в режиме множества моделей, об этой возможности мы расскажем в следующих руководствах). Давайте посмотрим, что у нас изменилось в браузере:


С базовой конфигурацией


Что тут можно увидеть:


  • name используется в качестве заголовка страницы;
  • результат вызова метода data отображается как основное содержимое страницы.

Примечание: метод data должен возвращать данные или Promise, который разрешается в данные.

Основные настройки сделаны, можно двигаться дальше.


Контекст


Давайте посмотрим на страницу произвольного отчёта (кликните Make report):


Страница отчёта


На первый взгляд, это не сильно отличается от стартовой страницы… Но здесь можно всё менять! Например, мы можем легко воссоздать вид стартовой страницы:


Воссоздание стартовой страницы


Обратите внимание, как определяется заголовок: "h1:#.name". Это заголовок первого уровня с содержимым #.name, которое является запросом Jora. # ссылается на контекст запроса. Чтобы посмотреть его содержимое, просто введите # в редакторе запроса и воспользуйтесь отображением по умолчанию:


Значения контекста


Теперь вы знаете, как можно получить ID текущей страницы, её параметры и другие полезные значения.


Сбор данных


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


module.exports = {
    name: 'Node modules structure',
    data: require('./collect-node-modules-data')
};

Содержимое collect-node-modules-data.js:


const path = require('path');
const scanFs = require('@discoveryjs/scan-fs');

module.exports = function() {
    const packages = [];

    return scanFs({
        include: ['node_modules'],
        rules: [{
            test: /\/package.json$/,
            extract: (file, content) => {
                const pkg = JSON.parse(content);

                if (pkg.name && pkg.version) {
                    packages.push({
                        name: pkg.name,
                        version: pkg.version,
                        path: path.dirname(file.filename),
                        dependencies: pkg.dependencies
                    });
                }
            }
        }]
    }).then(() => packages);
};

Я использовал пакет @discoveryjs/scan-fs, который упрощает сканирование файловой системы. Пример использования пакета описан в его readme, я взял этот пример за основу и доработал как нужно. Теперь у нас есть некоторая информация о содержимом node_modules:


Данные собраны


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


Исследование структуры данных


Не смотря на то, что у нас уже есть некоторые данные, нам нужно больше деталей. Например, хорошо бы знать, в какой физический экземпляр разрешается каждая из объявленных зависимостей определенного модуля. Однако работа над улучшением извлечения данных выходит за рамки этого руководства. Поэтому мы сделаем замену на пакет @discoveryjs/node-modules (который также построен на основе @discoveryjs/scan-fs) для извлечения данных и получим необходимые подробности относительно пакетов. В результате collect-node-modules-data.js значительно упрощается:


const fetchNodeModules = require('@discoveryjs/node-modules');

module.exports = function() {
    return fetchNodeModules();
};

Теперь информация о node_modules выглядит так:


Новая структура данных


Скрипт подготовки


Как вы могли заметить, некоторые объекты описывающие пакеты содержат deps — список зависимостей. У каждой зависимости есть поле resolved, значение которого является ссылкой на физический экземпляр пакета. Такая ссылка — это значение path одного из пакетов, оно является уникальным. Для разрешения ссылки на пакет нужно использовать дополнительный код (например, #.data.pick(<path=resolved>)). И конечно же, было бы гораздо удобнее, если бы такие ссылки были бы уже разрешены в ссылки на объекты.


К сожалению, на этапе сбора данных мы не можем разрешать ссылки, поскольку это приведёт к циклическим связям, что создаст проблему передачи таких данных в виде JSON. Однако решение есть: это специальный скрипт prepare. Он определяется в конфигурации и вызывается каждый раз при назначении новых данных для экземпляра Discovery. Начнём с конфигурации:


module.exports = {
    ...
    prepare: __dirname + '/prepare.js', // Важно: значение это строка, путь к модулю
    ...
};

Определим prepare.js:


discovery.setPrepare(function(data) {
    // делаем что-то с data и/или с экземпляром discovery
});

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


discovery.setPrepare(function(data) {
    const packageIndex = data.reduce((map, pkg) => map.set(pkg.path, pkg), new Map());

    data.forEach(pkg =>
        pkg.deps.forEach(dep =>
            dep.resolved = packageIndex.get(dep.resolved)
        )
    );
});

Здесь мы создали индекс пакетов, в котором ключом является значение path пакета (уникальное). Затем мы проходим по всем пакетам и их зависимостям, и заменяем в зависимостях значение resolved ссылкой на объект пакета. Результат:


Преобразованный deps.resolved


Теперь гораздо легче делать запросы к графу зависимостей. Вот так можно получить кластер зависимостей (то есть зависимости, зависимости зависимостей и т.д.) для конкретного пакета:


Пример кластера зависимостей


Неожиданная история успеха: изучая данные в ходе написания руководства, я обнаружил проблему в @discoveryjs/cli (с помощью запроса .[deps.[not resolved]]), у которого была опечатка в peerDependencies. Проблема немедленно была исправлена. Случай является наглядным примером, как помогают подобные инструменты.

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


Настраиваем стартовую страницу


Для начала нам нужно создать модуль страницы, например, pages/default.js. Мы используем default, поскольку это идентификатор для стартовой страницы, который мы можем переопределить (в Discovery.js можно переопределить очень многое). Начнём с чего-нибудь простого, например:


discovery.page.define('default', [
    'h1:#.name',
    'text:"Hello world!"'
]);

Теперь в конфигурации нужно подключить модуль страницы:


module.exports = {
    name: 'Node modules structure',
    data: require('./collect-node-modules-data'),
    view: {
        assets: [
            'pages/default.js'  // ссылка на модуль страницы
        ]
    }
};

Проверяем в браузере:


Переопределённая стартовая страница


Работает!


Теперь давайте выведем какие-нибудь счётчики. Для этого внесём изменения в pages/default.js:


discovery.page.define('default', [
    'h1:#.name',
    {
        view: 'inline-list',
        item: 'indicator',
        data: `[
            { label: 'Package entries', value: size() },
            { label: 'Unique packages', value: name.size() },
            { label: 'Dup packages', value: group(<name>).[value.size() > 1].size() }
        ]`
    }
]);

Здесь мы определяем инлайновый список индикаторов. В качестве значения data стоит запрос Jora, создающий массив записей. В качестве основы для запросов используется список пакетов (корень данных), так мы получили длину списка (size()), количество уникальных имен пакетов (name.size()) и количество имен пакетов, которые имеют дубли (group(<name>).[value.size() > 1].size()).


Добавляем индикаторы на стартовую страницу


Неплохо. Тем не менее, было бы лучше кроме чисел иметь ссылки на соответствующие выборки:


discovery.page.define('default', [
    'h1:#.name',
    {
        view: 'inline-list',
        data: [
            { label: 'Package entries', value: '' },
            { label: 'Unique packages', value: 'name' },
            { label: 'Dup packages', value: 'group(<name>).[value.size() > 1]' }
        ],
        item: `indicator:{
            label,
            value: value.query(#.data, #).size(),
            href: pageLink('report', { query: value, title: label })
        }`
    }
]);

В первую очередь мы изменили значение data, теперь это обычный массив с некоторыми объектами. Также из запросов значений (value) убран метод size().


Помимо этого в представление indicator добавлен подзапрос. Такого вида запросы создают новый объект для каждого элемента, в котором вычисляются value и href. Для value выполняется запрос с использованием метода query(), в который данные передаются из контекста, а потом к результату запроса применяется метод size(). Для href используется метод pageLink(), который генерирует ссылку на страницу отчёта с конкретным запросом и заголовком. После всех этих изменений индикаторы стали кликабельными (обратите внимание, что их значения стали синего цвета) и более функциональными.


Кликабельные индикаторы


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


discovery.page.define('default', [
    // ... то же что и раньше

    'h2:"Packages with more than one physical instance"',
    {
        view: 'table',
        data: `
            group(<name>)
            .[value.size() > 1]
            .sort(<value.size()>)
            .reverse()
        `,
        cols: [
            { header: 'Name', content: 'text:key' },
            { header: 'Version & Location', content: {
                view: 'list',
                data: 'value.sort(<version>)',
                item: [
                    'badge:version',
                    'text:path'
                ]
            } }
        ]
    }
]);

Для таблицы используются те же данные, что и для индикатора Dup packages. Список пакетов был отсортирован по размеру группы в обратном порядке. Остальная настройка связана с колонками (кстати, обычно их не нужно настраивать). Для колонки Version & Location мы определили вложенный список (отсортированный по версии), в котором каждый элемент представляет собой пару из номера версии и пути к экземпляру.


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


Страница пакетов


Сейчас у нас есть лишь общий обзор пакетов. Но было бы полезно иметь страницу с подробностями о конкретном пакете. Для этого создадим новый модуль pages/package.js и определим новую страницу:


discovery.page.define('package', {
    view: 'context',
    data: `{
        name: #.id,
        instances: .[name = #.id]
    }`,
    content: [
        'h1:name',
        'table:instances'
    ]
});

В этом модуле мы определили страницу с идентификатором package. В качестве исходного представления использовался компонент context. Это невизуальный компонент, помогающий определять данные для вложенных отображений. Обратите внимание, что мы использовали #.id для получения названия пакета, которое извлекается из URL вроде такого http://localhost:8123/#package:{id}.


Не забываем включить новый модуль в конфигурацию:


module.exports = {
    ...
    view: {
        assets: [
            'pages/default.js',
            'pages/package.js'  // вот так
        ]
    }
};

Результат в браузере:


Пример страницы пакета


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


Боковая панель


Раз у нас уже есть страница пакета, хорошо бы иметь список всех пакетов. Для этого можно определить специальное представление – sidebar, которое отображается в том случае, если его определить (по умолчанию не определено). Создадим новый модуль views/sidebar.js:


discovery.view.define('sidebar', {
    view: 'list',
    data: 'name.sort()',
    item: 'link:{ text: $, href: pageLink("package") }'
});

Теперь у нас есть список всех пакетов:


Добавленная боковая панель


Выглядит хорошо. Но с фильтром было бы ещё лучше. Расширим определение sidebar:


discovery.view.define('sidebar', {
    view: 'content-filter',
    content: {
        view: 'list',
        data: 'name.[no #.filter or $~=#.filter].sort()',
        item: {
            view: 'link',
            data: '{ text: $, href: pageLink("package"), match: #.filter }',
            content: 'text-match'
        }
    }
});

Здесь мы обернули список в компонент content-filter, которое преобразует вводимое значение в поле ввода в регулярное выражения (или null, если поле пустое), и сохраняет его в качестве значения filter в контексте (название можно менять опцией name). Также для фильтрации данных для списка мы применили #.filter. Наконец, мы применили отображение ссылок, чтобы подсвечивать совпадающие части с помощью text-match. Результат:


Список с фильтром


В случае, если вам не нравится оформление по умолчанию, можете настроить стили по своему желанию. Допустим, вы хотите поменять ширину боковой панели, для этого нужно создать файл стиля (скажем, views/sidebar.css):


.discovery-sidebar {
    width: 300px;
}

И добавить в конфигурацию ссылку на этот файл, так же как на JavaScript модули:


module.exports = {
    ...
    view: {
        assets: [
            ...
            'views/sidebar.css',  // в assets можно задавать и *.css тоже
            'views/sidebar.js'
        ]
    }
};

Автоссылки


Последняя глава этого руководства посвящена ссылкам. Ранее, с помощью метода pageLink() мы делали ссылку на страницу пакета. Но помимо ссылки нужно еще задавать и текст ссылки. Но как бы нам делать это проще?


Чтобы упростить работу ссылками, нам нужно определить правило генерации ссылок. Это лучше сделать в скрипте prepare:


discovery.setPrepare(function(data) {
    ...

    const packageIndex = data.reduce(
        (map, item) => map
            .set(item, item)        // key is item itself
            .set(item.name, item),  // and `name` value
        new Map()
    );
    discovery.addEntityResolver(value => {
        value = packageIndex.get(value) || packageIndex.get(value.name);

        if (value) {
            return {
                type: 'package',
                id: value.name,
                name: value.name
            };
        }
    });
});

Мы добавили новую карту (индекс) пакетов и использовали её для ресолвера сущностей. Ресолвер сущностей пытается, по возможности, преобразовать переданное ему значение в дескриптор сущности. Дескриптор содержит:


  • type — тип сущности
  • id — уникальная ссылка на экземпляр сущности, используемая в ссылках в качестве ID
  • name — используется в качестве текста ссылки

Наконец, нужно присвоить этот тип определённой странице (ссылка ведь должна куда-то вести, не так ли?).


discovery.page.define('package', {
    ...
}, {
    resolveLink: 'package'  // привязываем тип сущности `package` к этой странице
});

Первое следствие этих изменений заключается в том, что некоторые значения в представлении struct теперь помечаются ссылкой на страницу пакета:


Автоссылки в struct


И теперь также можно применить компонент auto-link к объекту или имени пакета:


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


И, как пример, можно немного переработать боковую панель:


    // было раньше
        item: {
            view: 'link',
            data: '{ text: $, href: pageLink("package"), match: #.filter }',
            content: 'text-match'
        },

    // с использованием `auto-link`
        item: {
            view: 'auto-link',
            content: 'text-match:{ text, match: #.filter }'
        }

Заключение


Теперь у вас есть базовое представление о ключевых концепциях Discovery.js. В следующих руководствах мы подробнее рассмотрим затронутые темы.


Посмотреть весь исходный код руководства можно в репозитории на GitHub или попробовать как это работает онлайн.


Подписывайтесь на твиттер проекта @js_discovery, чтобы быть в курсе новостей проекта!

Mail.ru Group
859,33
Строим Интернет
Поделиться публикацией

Похожие публикации

Комментарии 10

    +3

    Добавлю ещё от себя, Discovery.js и jora два мощных инструмента, которые постоянно развиваются и улучшаются, например они используются в таких проектах как:


      0
      npm install @discoveryjs/discovery @discoveryjs/cli
      npx discovery

      Вероятно, имелась в виду установка пакета с ключом -g, иначе непонятно, куда его ставит автор. Если же пакет ставится в проект, который исследуется, то этим самым меняется его древо зависимостей, что очень странно (я понимаю, что это демо, но всё же).


      Кроме этого, совсем непонятно, зачем после установки пакет запускается через npx. Если так можно, то зачем его ставили локально? А если нужно ставить локально — то почему его не запускают локально?


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


      Теперь по сути. Морда discovery в целом забавная и можно найти ей применения. Насчёт jora — вроде уже есть jq, чем, как вы считаете, jora лучше?

        0
        Кстати, забавно, что переводится статья с английского, написанная явно русскоязычным автором. Смайлик.

        Это наш хитрый план с lahmatiy ;]

          0
          npm install @discoveryjs/discovery @discoveryjs/cli

          Ставит пакеты локально, тем самым, как Вы и написали, изменяет дерево зависимостей.


          npx discovery

          Запускает, локально установленный, пакет.


          Вроде все сходится.

            0

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

            +1

            Предполагалось, что так или иначе читатель разберется куда и что положить. В целом, тема не сложная, но в ней много нюансов – не хотелось на них отвлекаться и раздувать статью, потому эти моменты опущены. И все же, спасибо за фидбек. Я надеюсь будет статья по вопросам организации пакета/инструмента, как настраивать разные штуки, та же сборка (штатная в discovery/cli) еще за бортом осталась и тд...


            Если же пакет ставится в проект, который исследуется, то этим самым меняется его древо зависимостей

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


            Насчёт jora — вроде уже есть jq, чем, как вы считаете, jora лучше?

            Если про то, что есть прямо сейчас (то есть без того что в планах), первое что приходит:


            • субъективно проще, ближе к JS
            • работает с JS объектами, то есть пригоден не только для JSON
            • работает в node и браузерах без каких либо хаков с компиляцией (jq написан на си)
            • так как jora написан на JS — в него проще контрибьютить, можно расширять своими методами, то есть использовать их в запросах (планируются либы методов для работы с semver, датами, математикой и тд)
            • jora весит гораздо меньше (jora ~30kb vs. jq сборка в wasm ~1.7Mb)
            • килер фича: саджест при наборе запроса (на самом деле есть API для получения списка значений проходящих через определенные точки запроса, что можно использовать и для других фич типа отладки)
            • более менее работающий толерантный режим (полезно при наборе — меньше выскакивает ошибка, что запрос поломан и помогает делать саджест)

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

            +1

            Максимальная ширина картинки в посте 780px, фон всегда белый. Вес PNG-картинок с тенями альфа-каналом около 7 МБ, ширина 2200px каждая. Если сделать их JPG с 75% сжатием и шириной 1560px (так уж и быть, для ретины), то пост будет весить на 5 МБ меньше.


            Как я понял, что картинки тяжёлые? Я на вайфае видел как они загружаются. Как я понял про 5 МБ экономии? Я открыл Squoosh, закинул первую картинку, поставил настройки и перекидал туда все картинки из поста.


            Константин, за что вы так не любите читателей Хабра?

              0

              Не согласен, вопрос должен звучать как: «Почему хабр не заботиться о читателях и не оптимизирует картинки в зависимости от устройства и канала, ведь все возможности для этого есть, как и сделать открытие оригинала по клику?»


              А так, все изображения для статьи взяты из оригинальной, которая на dev.to весит как раз 2.1M, ибо dev.to, в отличии от хабра, думает о читателях.

                +1

                Ответ в стиле «у меня всё работает». Мы сейчас говорим не про то, как должно быть, а какой результат получился. Хабр ничего с этим не делает, Хабр не любит своих читателей. Но это же не повод поступать так же, правда?

                  0
                  Ответ в стиле «у меня всё работает».

                  Нет, это не так.


                  Но это же не повод поступать так же, правда?

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

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

            Самое читаемое