GrJsonViewer
Берут, когда ответ чужого сервиса разбирают по полям.
Когда брать
- ответ чужого сервиса разбирают по полям — вебхук, ответ модели, тело ошибки: нужно найти ключ, а не прочитать документ целиком;
- структура заранее неизвестна — форма приходит от бэкенда и меняется, и раскладывать её по колонкам не по чему;
- значение нужно достать точечно — копируется узел вместе с путём, а не вся простыня;
- данных много — свёртка, поиск и виртуализация держат ответ на тысячи узлов.
Когда взять другое
| Нужно | Берите |
|---|---|
| Прочитать текст или JSON целиком и скопировать разом | GrCodeBlock (пакет @feugene/granularity-code) |
| Дерево своих данных с выбором и чекбоксами | GrTree |
| Данные разложены по колонкам и известны заранее | GrDataTable |
| Пара «характеристика → значение» | GrDescriptionList |
Показ обрезается, копирование — нет
Длинное строковое значение обрезается в строке (maxStringLength), длинный
массив обрывается заглушкой «ещё N» (maxArrayItems). Это не косметика:
запрос к модели с картинкой в base64 — один лист на сотни тысяч символов, и
ни свёртка по узлам, ни виртуализация по строкам его не берут, потому что узел
там один и строка одна.
В буфер при этом уходит полное значение узла вместе с путём: обрезка принадлежит показу, и вставить её обратно нельзя.
Повторная ссылка — не цикл
Маркер [Circular] достаётся только настоящему предку по цепочке, а объект,
честно положенный в данные дважды, рисуется дважды.
Это тот случай, где обход дерева умеет строго больше сериализации: replacer
у JSON.stringify стека предков не получает и вынужден метить любую повторную
ссылку. Обход стек имеет, поэтому у GrCodeBlock и GrJsonViewer на одних и
тех же данных разный — и каждый по-своему правильный — результат.
Адрес узла читается
Ключом узла служит путь ($.items[3].name), а не порядковый номер: он уходит в
событие copy, по нему же задаётся раскрытие. Ключ с точкой или пробелом
экранируется ($["a.b"]), иначе адрес перестал бы быть адресом.
Раскрытие задаётся глубиной, а не списком
defaultExpandDepth раскрывает первые N уровней; expandAll() и
collapseAll() из defineExpose переключают всё дерево. Обе кнопки
сбрасывают ручное раскрытие пользователя — от «раскрыть всё» этого и ждут.
Поиск идёт по ключу и по значению
В чужом ответе ищут то одно, то другое, поэтому предикат смотрит и на имя ключа, и на показанное значение. Совпавшие узлы дерево подсвечивает целиком и раскрывает к ним путь.
Поле поиска можно убрать (searchable: false) и звать filter(query) снаружи —
когда строка поиска на странице уже есть и вторая была бы лишней.
Границы
Не редактирует и не диффит: просмотрщик показывает то, что пришло. Подсветки
совпавшей подстроки внутри строки нет — дерево отмечает узел целиком.
Виртуализация включается только вместе с maxHeight
(GrTree): без высоты окно считать не от чего.
Playground 9
Загружается…
<GrJsonViewer />Установка
npm i @feugene/granularityИмпорт
import { GrJsonViewer } from '@feugene/granularity/components/GrJsonViewer'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
ariaLabel | string | undefined | undefined | Имя области для скринридера. Безымянное дерево объявляется просто «дерево». |
virtual | boolean | undefined | false | Оставлять в DOM только окно вокруг вьюпорта. Требует `maxHeight`. |
maxHeight | string | number | undefined | undefined | Высота области просмотра: число — пиксели, строка — как есть. |
rootLabel | string | undefined | undefined | Подпись корня. По умолчанию `$` — так адресуют путь JSONPath и dev-tools. |
defaultExpandDepth | number | undefined | undefined | Сколько уровней раскрыто изначально. |
maxStringLength | number | undefined | undefined | Длина строкового значения, после которой показ обрезается. Не косметика: запрос к модели с картинкой в base64 — это один лист на сотни тысяч символов, и ни свёртка, ни виртуализация по строкам его не берут. Копирование при этом отдаёт значение целиком. |
maxArrayItems | number | undefined | undefined | Сколько элементов массива разбирать до заглушки «ещё N». |
searchable | boolean | undefined | undefined | Поле поиска над деревом. Выключено — поиск остаётся на `filter()` из `defineExpose`. |
copyable | boolean | undefined | undefined | Кнопка копирования узла в строке. |
valueобязательный | unknown | — | Показываемое значение. `unknown`, потому что приходит из БД или чужого сервиса. |
Slots
| Slot | Type | Описание |
|---|---|---|
leaf | { node: GrJsonNode; } | Значение листа: ссылка, дата, денежная сумма — оформление знает приложение. |
Events
| Event | Type | Описание |
|---|---|---|
copy | [payload: { path: string; value: unknown; }] | — |
Methods / Expose
| Methods / Expose | Type | Описание |
|---|---|---|
filter | (value: string) => void | — |
expandAll | () => void | — |
collapseAll | () => void | — |
Примеры 2
Ответ сервиса, разобранный по узлам
Корень раскрыт, остальное свёрнуто: сначала видно форму ответа, а не его объём. Поиск идёт и по ключу, и по значению — в чужом ответе ищут то одно, то другое.
<script setup lang="ts">
import { GrJsonViewer } from '@feugene/granularity'
// Ровно та форма, в которой ответ приходит из БД: `unknown` со всеми типами
// JSON, включая `null` и вложенный массив.
const response = {
id: 'run_01HXQZ8K3M7N2P4R6T8V0W1Y3Z',
model: 'gpt-4o-mini',
cached: false,
finished_at: null,
usage: { prompt_tokens: 1284, completion_tokens: 96, total_tokens: 1380 },
store: { name: 'Пятёрочка', inn: '7728029110', address: 'Москва, Ленинский проспект, 12' },
items: [
{ name: 'Молоко 3.2%', qty: 2, price: 89.9, sum: 179.8 },
{ name: 'Хлеб бородинский', qty: 1, price: 54.5, sum: 54.5 },
{ name: 'Кофе зерновой 1 кг', qty: 1, price: 1249, sum: 1249 },
],
totals: { subtotal: 1483.3, discount: 74.15, total: 1409.15 },
}
</script>
<template>
<GrJsonViewer :value="response" max-height="22rem" aria-label="Ответ модели" />
</template>Ключ узла — читаемый путь ($.items[2].name), а не порядковый номер: он уходит в событие copy, по нему же задаётся раскрытие.
Картинка в base64 и пять тысяч элементов
Два крайних случая в одном значении: один строковый лист на сотни тысяч символов и массив на пять тысяч узлов. Первый не берёт ни свёртка, ни виртуализация — узел там один, поэтому его режет maxStringLength; второй режет maxArrayItems и виртуализация.
Строка с картинкой обрезана до 80 символов, массив — до 50 элементов с заглушкой «ещё». Копирование любого узла всё равно отдаёт значение целиком: обрезка принадлежит показу.
<script setup lang="ts">
import { GrJsonViewer } from '@feugene/granularity'
/**
* Не выдуманный крайний случай, а форма запроса к модели с картинкой: провайдер
* кладёт изображение в base64 прямо в тело, и это **один** строковый лист на
* сотни тысяч символов. Свёртка по узлам его не берёт — узел там один.
*/
const request = {
model: 'gemini-3.1-pro',
contents: [
{
role: 'user',
parts: [
{ text: 'Разбери чек и верни JSON по схеме.' },
{ inline_data: { mime_type: 'image/jpeg', data: `data:image/jpeg;base64,${'R0lGODlhAQABAIAAAAUEBA'.repeat(2000)}` } },
],
},
],
// Массив на пять тысяч — вторая крайность: узлов много, каждый крошечный.
candidates: Array.from({ length: 5000 }, (_, index) => ({ index, logprob: -0.0001 * index })),
}
</script>
<template>
<div class="grid gap-3">
<GrJsonViewer
:value="request"
:max-string-length="80"
:max-array-items="50"
virtual
max-height="20rem"
aria-label="Запрос к модели"
/>
<p class="text-sm text-[var(--gr-muted-fg)]">
Строка с картинкой обрезана до 80 символов, массив — до 50 элементов с заглушкой «ещё».
Копирование любого узла всё равно отдаёт значение целиком: обрезка принадлежит показу.
</p>
</div>
</template>Копирование при этом отдаёт значение целиком: обрезка принадлежит показу, и вставить её обратно нельзя.
Доступность
- Паттерн APG
tree (через GrTree)- Клавиши
- своих клавиш нет: дерево внутри —
GrTree, и весь его контракт действует без изменений. Поле поиска и кнопки свёртки над деревом — обычные контролы, каждый со своей остановкойTab