Всем, привет! Снова на связи я – Дмитрий, React-разработчик, и в этот раз мы поговорим о создании фундамента для дальнейшей разработки.
Идея — сделать компонент в реакте, который сможет отобразить файл Excel в обычной HTML-таблице со всеми его слияниями ячеек, форматированием, несколькими строками заголовка и полностью сохранённой структурой.
Казалось бы, задача простая: берёшь любую библиотеку, читаешь файл и показываешь. На практике всё оказалось гораздо интереснее.
Требования к компоненту
Показать Excel полностью как есть.
Сохранять merge ячеек.
Корректно отображать несколько строк заголовка.
Сделать React-компонент без обращения к сторонним сервисам и без промежуточной конвертации на бэкенде.
Забегу вперёд, что всё-таки XLSX библиотеку использовать будем.
Провалившиеся варианты
Я, естественно, начал с поиска готовых решений.
Перебрал несколько вариантов из npm: таблицы, которые «парсят Excel», «выводят Excel в браузере» и т.п.
Проблемы следующие: большинство показывают только данные, полностью игнорируя стили, merge поддерживают либо некорректно, либо вообще не поддерживают, некоторые библиотеки грузят Excel через backend-парсинг, что или не подходит, или представляет собой какой-то коммерческий вариант, а нужна основа для своего решения.
Бывают сервисы, которые превращают Excel в HTML-таблицу, но ни один не подходит для приватных данных, т.к. в них не контролируешь процесс, нет нормальной интеграции в React, merge или стили по дороге теряются.
Рендер через canvas или SVG? Да, такие подходы существуют, но сложность разработки возрастает в ×10, возникают проблемы с копированием текста, невозможно нормально выделять ячейки, а также нет нативного HTML-поведения.
В общем, использовать библиотеку xlsx мы все же будем, но придётся попотеть.
Кстати, если что-то уже есть и я изобретаю велосипед, то напишите в комментариях.
Прототип
В этой статье я покажу не готовую библиотеку и не промышленный компонент, а прототип, который я собирал на чистом энтузиазме — поздней ночью под луной, в свободное от работы время, где-то на югах России.
Это не финальная версия, не продукт уровня production-ready — это исследование, база, фундамент, на который позже можно будет надстроить всё, что нужно.
Давайте приступим
Полный код я приложил в конец статьи, но чтобы не прыгать с места в карьер, пойдем последовательно и будем хранить интригу.
Я инициализировал чистый проект React + TypeScript с помощью Vite.
Чтобы не нажимать каждый раз на <input type="file" />, я решил для удобства автоматически подгружать Excel из /public/test.xlsx сразу при запуске приложения. Но при этом оставил заготовку для функционала подгрузки через интуп.
Это ускоряет отладку и позволяет сосредоточиться на логике таблицы, а не на повторении однообразных действий.
В моём случае Excel-файл лежит в /public, поэтому я просто грузил его через fetch:
useEffect(() => {
fetch("/test.xlsx")
.then((res) => res.arrayBuffer())
.then((data) => {
const file = new File([data], "test.xlsx", {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
setFile(file);
})
.catch((err) => console.error("Ошибка при загрузке Excel:", err));
}, []);
Почему arrayBuffer()? Библиотека xlsx не умеет читать Blob напрямую, ей нужен массив байтов. Поэтому преобразуем ответ ArrayBuffer в Uint8Array и далее в File.
Зачем снова создаю File?
Потому что компонент <ExcelTable> ожидает именно File, как будто его загрузили через <input type="file" />. Это делает компонент универсальным именно на данный момент времени. Задел на будущее – можно подгружать с бекенда, а можно с кнопки.
Когда файл готов, он передаётся в компонент ExcelTable, который занимается всей логикой.
В итоге файл App.tsx выглядит очень просто:
import React, { useEffect, useState } from "react";
import ExcelTable from "./components/ExcelTable/ExcelTable";
const App = () => {
const [file, setFile] = useState<File | null>(null);
useEffect(() => {
fetch("/test.xlsx")
.then((res) => res.arrayBuffer())
.then((data) => {
const file = new File([data], "test.xlsx", {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
setFile(file);
})
.catch((err) => console.error("Ошибка при загрузке Excel:", err));
}, []);
return (
<div className="p-4">
{file
? <ExcelTable file={file} startRow={1} headerRows={5} />
: <p>Загрузка Excel...</p>}
</div>
);
};
export default App;
Пока изи.
Что же происходит в самом компоненте ExcelTable?
Переходим к компоненту, который делает всю работу.
Вот ключевой useEffect, который читает файл и превращает его в данные необходимого формата.
useEffect(() => {
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const data = new Uint8Array(e.target?.result as ArrayBuffer);
const workbook = XLSX.read(data, { type: "array", cellStyles: true })
const sheetName = workbook.SheetNames[0];
const newSheet = workbook.Sheets[sheetName];
const json = XLSX.utils.sheet_to_json(newSheet, { header: 1, raw: true });
const visibleRows = json.slice((startRow || 1) - 1);
const headerPart = visibleRows.slice(0, headerRows);
const bodyPart = visibleRows.slice(headerRows);
setHeader(headerPart);
setBody(bodyPart);
setMerges(newSheet["!merges"] || []);
setSheet(newSheet);
};
reader.readAsArrayBuffer(file);
}, [file, startRow, headerRows]);
Давайте разбираться:
FileReader — браузерный API для чтения локальных File/Blob. Мы используем readAsArrayBuffer, чтобы получить бинарный ArrayBuffer.
Как я уже писал выше, библиотека xlsx принимает бинарные данные в виде массива байтов. Получается, следующая манипуляция — ArrayBuffer преобразовывается в Uint8Array, а это уже удобный формат для XLSX.read.
reader.onload — колбек, который выполняется, когда чтение файла завершено. В e.target.result лежит ArrayBuffer. Мы приводим его к Uint8Array для дальнейшей передачи в xlsx.
В строчке:
const workbook = XLSX.read(data, { type: "array", cellStyles: true })
пользуемся функцией библиотеки XLSX.read, которая превращает байты в объект workbook. У нее есть опции:
{ type: "array" }— говорим библиотеке, что мы передаём Uint8Array/ArrayBuffer.cellStyles: true— ключевой параметр, без него xlsx не будет читать стили (fill, font и т.д.). Нам важны фоновые цвета и прочие оформления. Тут есть нюанс: для получения стилей часто нужно использовать full-сборку xlsx (xlsx.full.min), иначе некоторые поля стилей могут быть недоступны. XLSX поддерживает стили только для чтения, но не все типы стилей доступны. Границы, жирность, выравнивание частично или полностью могут отсутствовать. Особенно границы (border) и цвет текста (font.color) библиотека часто не возвращает.
Далее в строках:
const sheetName = workbook.SheetNames[0];
const newSheet = workbook.Sheets[sheetName];
workbook.SheetNames — массив названий листов в книге. По умолчанию берём первый ([0]).
workbook.Sheets[name] — объект листа, в терминах xlsx — это набор ячеек и служебных полей.
Если нужно поддерживать несколько листов, здесь можно показать выбор пользователю или рендерить вкладки, но нам это пока не интересно.
Снова пользуемся функцией из xlsx и конвертируем лист в двумерный массив:
const json = XLSX.utils.sheet_to_json(newSheet, { header: 1, raw: true });
sheet_to_json с опцией { header: 1 } возвращает матрицу, где каждая строка — массив значений. Например, [['A1','B1'], ['A2', 'B2']]. Это удобно для рендера <table>, т.к. мы работаем с индексами строк и столбцов.
raw: true означает: вернуть сырые значения ячеек (числа, даты в их исходном виде), без попыток преобразовать формат, например: не конвертировать даты в строки.
Если использовать header: 'A' или другие режимы, формат результата будет другим — для нашей задачи двумерная матрица оптимальна.
Учёт смещения и разделение header/body
Далее в этих строчках мы условно разделяем данные на заголовок и тело таблицы.
const visibleRows = json.slice((startRow || 1) - 1);
const headerPart = visibleRows.slice(0, headerRows);
const bodyPart = visibleRows.slice(headerRows);
startRow — переменная, которая даёт возможность пропустить верхние строки заголовка таблицы в файле. startRow || 1 гарантирует корректный индекс.
headerRows — число строк заголовка. Мы делим видимые строки на часть, в котором отрисовывается заголовок и часть на всё остальное тело таблицы. Это нужно, чтобы рендерить <thead> и <tbody> отдельно и корректно рассчитывать merge-офсеты для тела.
После вычислений сохраняем всё в стейт.
setHeader(headerPart);
setBody(bodyPart);
setMerges(newSheet["!merges"] || []);
setSheet(newSheet);
header и body — двумерные массивы значений, готовые к дальнейшему превращению в матрицу с colSpan/rowSpan.
newSheet["!merges"] — служебное поле листа, массив диапазонов объединённых ячеек. Формат каждого элемента:
{ s: { r: startRowIndex, c: startColIndex }, e: { r: endRowIndex, c: endColIndex } }
где индексы r и c 0-based. Если !merges нет, используем пустой массив.
setSheet(newSheet) — сохраняем сам объект листа. Он нужен, чтобы извлекать стили конкретных ячеек (например, cell.s.fill) при построении окончательной матрицы.
Моменты, которые опущены
file, startRow, headerRows — логично, при смене любого из них нужно пересчитать матрицы. Но при больших файлах это может быть «дорого» — тут можно подумать о memo, о дебаунсе или буферизации.
"Где обработка ошибок чтения или загрузки самой эксельки, ее обработки и т.п?" – Спросите вы. «Прототип, который призван понять, как оно работает,» — отвечу я
Для гигантских Excel-файлов парсинг в основном потоке может тормозить UI. Возможные решения: Web Worker для парсинга, виртуализация при рендере (react-window) или ленивое чтение по диапазонам.
Для корректного считывания стилей нужен xlsx.full.min и опция cellStyles: true. Но даже это не гарантирует 100% идентичность с Excel — некоторые сложные стили/границы/условное форматирование библиотека не отражает. Некоторые сборки full-версии тоже не содержат полной поддержки стилей, зависит от конкретного билда.
Сейчас всегда берём первый лист. Если файл многостраничный — лучше сделать UI для выбора листа.
Иногда sheet_to_json возвращает строки разной длины с пропущенными trailing-ячейками. При построении матрицы стоит нормализовать длины строк (заполнить undefined), чтобы избежать смещений при рендере.
raw: true возвращает внутренние значения. Если нужно форматирование, например, отображать даты в локальном формате. То нужно добавить постобработку по cell.z – это форматная маска или использовать XLSX.SSF.format. Уточню, что Excel хранит дату как число (days since 1900 или 1904), raw: true оставляет это число как есть. Чтобы получить строковое значение даты, нужно либо вручную форматировать через XLSX.SSF.format(cell.z, cell.v), либо включить raw: false
Функция getMergedMatrix
Эта функция — центральная часть рендера Excel в виде React-таблицы.
Её задача — превратить двумерный массив значений (rows) в такую структуру, где каждая ячейка имеет полную информацию для рендера. Добавить в каждую ячейку value, bgColor, при необходимости — colSpan и rowSpan, правильно обработать merge-ячейки из Excel (sheet['!merges']) и пометить все ячейки внутри merge-области значением null, чтобы они не рендерились.
Без этого React-таблица будет ломаться, и merged-ячейки будут дублироваться.
Первым делом
const startRowOffset = (isBody ? headerRows : 0) + (startRow - 1);
Excel-файл может начинаться не с первой строки (startRow). Таблица разделена на header и body, и body должен начинаться после headerRows строк заголовка, поэтому каждая строка из rows соответствует строке в Excel по формуле:
excelRowIndex = визуальный rowIndex + смещение
Зачем это нужно? Merge-ячейки в Excel используют глобальные координаты (s.r / e.r).
Но мы работаем с частью таблицы — заголовком или телом.
Смещение позволяет правильно сопоставить merge-область с нужными строками.
Далее формируется базовая матрица (значение + цвет)
const matrix: CellData[][] = rows.map((row, rowIndex) =>
row.map((cell, colIndex) => {
const bgColor = sheet ? getCellBgColor(sheet, rowIndex + startRowOffset, colIndex) : undefined;
return { value: cell, bgColor };
})
);
Здесь мы проходим по всем ячейкам. Для каждой ячейки читаем value и bgColor через функцию getCellBgColor, о которой расскажу ниже и создаём структуру: { value: any, bgColor?: string }. На этом шаге merge еще не применяется — мы просто создаём «чистую» таблицу.
Мержим ячейки
Берём каждое merge-правило из Excel. Оно выглядит так: { s: { r: 5, c: 2 }, e: { r: 7, c: 4 } }, где:
s — start (верхняя левая ячейка)
e — end (нижняя правая ячейка)
и вычисляем rowIndex в виртуальной таблице. Если это body — вычитаем количество заголовков. Если header — просто корректируем смещение.
Merge-ячейки Excel имеют абсолютные координаты. Мы же работаем с локальными строками.Без этого merge попадёт не в те строки.
Далее нам нужно понять размеры мержа
const colSpan = e.c - s.c + 1;
const rowSpan = e.r - s.r + 1;
Если Excel говорит, что merge идет, например, с (2, 3) по (2, 5), то от 3 до 5 включительно 5 — 3 + 1 = 3, значит colSpan = 3. Зачем это нужно? Потому что про colSpan <td знает только браузер, а Excel не знает о существовании colSpan/rowSpan — он работает координатами.
Далее устанавливаем merge только в верхнюю левую ячейку
if (matrix[rowIndex] && matrix[rowIndex][s.c]) {
matrix[rowIndex][s.c] = {
...matrix[rowIndex][s.c],
colSpan,
rowSpan,
};
}
Логика такая. В HTML только самая первая ячейка (s.r, s.c) должна получить colSpan/rowSpan, а все остальные ячейки внутри merge-области должны быть скрыты.Потому что если ты отрендеришь остальные ячейки, таблица станет кривой, merge съедет, браузер наложит td друг на друга.
Далее заполняем все остальные ячейки внутри merge значением null
for (let r = s.r; r <= e.r; r++) {
for (let c = s.c; c <= e.c; c++) {
if (r === s.r && c === s.c) continue;
const row = matrix[r - (startRow - 1) - (isBody ? headerRows : 0)];
if (row && row[c]) row[c].value = null;
}
}
Здесь мы проходим по каждой координате внутри merge-прямоугольника. Пропускаем стартовую ячейку, а всем остальным присваиваем value = null.
А не удаляем мы, потому что нам нужно сохранить структуру матрицы, React должен рендерить <td /> только для ячеек со значением. А null мы ставим, потому что наш рендер будет проверять на value !== null.
Вернёмся к функции getCellBgColor
Я упоминал эту функцию выше, давайте рассмотрим её.
Эта функция отвечает за извлечение фонового цвета ячейки из объекта листа Excel (sheet) и преобразование его в HEX для CSS. Excel хранит стили ячеек в виде сложной структуры, поэтому простого свойства cell.bgColor нет. Нужен специальный разбор.
const getCellBgColor = (sheet: XLSX.WorkSheet, r: number, c: number) => {
const cellAddress = XLSX.utils.encode_cell({ r, c });
const cell = sheet[cellAddress];
if (!cell || !cell.s || !cell.s.fill) return undefined;
const fill = cell.s.fill;
const rgb = fill.fgColor?.rgb || fill.bgColor?.rgb;
if (rgb && typeof rgb === "string") {
const hex = rgb.length > 6 ? rgb.slice(-6) : rgb;
return `#${hex}`;
}
return undefined;
};
Сначала преобразуем координаты в адрес ячейки
const cellAddress = XLSX.utils.encode_cell({ r, c });
Excel хранит ячейки не в виде [row][col], а как объект с ключами типа "A1", "B2" и т.д. XLSX.utils.encode_cell({r, c}) конвертирует индексы строки и колонки (0-based) в строковый адрес ячейки.
Дальше получим объект ячейки:
const cell = sheet[cellAddress];
if (!cell || !cell.s || !cell.s.fill) return undefined;
Здесь:
sheet[cellAddress] — объект ячейки.
cell.s — стиль ячейки (style), содержит информацию о шрифтах, границах, заливке и т.д.
cell.s.fill — собственно, заливка.
Если ячейки нет или она без заливки, возвращаем undefined (чтобы таблица рендерила стандартный фон).
Теперь нам остаётся получить цвет заливки и преобразовать ARGB/RGB в HEX
const fill = cell.s.fill;
const rgb = fill.fgColor?.rgb || fill.bgColor?.rgb;
if (rgb && typeof rgb === "string") {
const hex = rgb.length > 6 ? rgb.slice(-6) : rgb;
return `#${hex}`;
}
Отрисовка таблицы Excel в React
После того как мы разобрали, как Excel-файл превращается в матрицу данных с учётом merge и стилей, следующий шаг — отрисовать таблицу.
Помним, что мы хотим, чтобы таблица выглядела максимально похоже на исходный Excel и имела в себе merged cells (colSpan, rowSpan), фоновые цвета ячеек, отдельные строки заголовка (<thead>) и тело (<tbody>), плюс нативное поведение таблиц (горизонтальный скролл, ширина колонок и т.д.).
Построение матрицы с merge
Прежде чем рендерить, мы создаём окончательные матрицы для заголовка и тела:
const headerMatrix = sheet ? getMergedMatrix(header, sheet, false) : [];
const bodyMatrix = sheet ? getMergedMatrix(body, sheet, true) : [];
getMergedMatrix — функция, которая объединяет данные ячеек с их стилями и merge-информацией (colSpan, rowSpan).
Каждая ячейка теперь представлена объектом:
interface CellData {
value: any;
colSpan?: number;
rowSpan?: number;
bgColor?: string;
}
Ячейки, которые входят в merge, кроме первой, имеют value: null — чтобы не рендерить их повторно.
Рендер <table> и <thead> и <tbody>
Можно уже что-нибудь и отрендерить. Давайте начнём с заголовка.
<thead>
{headerMatrix.map((row, i) => (
<tr key={`h-${i}`}>
{row.map((cell, j) =>
cell.value !== null ? (
<th
key={`h-${i}-${j}`}
colSpan={cell.colSpan}
rowSpan={cell.rowSpan}
style={{ backgroundColor: cell.bgColor }}
>
{cell.value}
</th>
) : null
)}
</tr>
))}
</thead>
Что здесь важно:
cell.value !== nullпропускает ячейки, которые находятся внутри merge, чтобы не рендерить дубли.colSpan и rowSpanберёт значения из объекта CellData, а HTML автоматически объединяет ячейки, как Excel.С помощью
backgroundColorмы извлекли цвет ячейки из sheet[cellAddress].s.fill. CSS backgroundColor позволяет точно воспроизвести заливку.Уникальные ключи React. Об этом забывать нельзя никак. Используем индексы строки и колонки, чтобы React корректно рендерил <tr> и <th>.
Тело таблицы
Логика идентична заголовку.
<tbody>
{bodyMatrix.map((row, i) => (
<tr key={`b-${i}`}>
{row.map((cell, j) =>
cell.value !== null ? (
<td
key={`b-${i}-${j}`}
colSpan={cell.colSpan}
rowSpan={cell.rowSpan}
style={{ backgroundColor: cell.bgColor }}
>
{cell.value}
</td>
) : null
)}
</tr>
))}
</tbody>
Отличие: здесь мы рендерим <td>, а не <th>.
bodyMatrix учитывает смещение startRow и headerRows, поэтому merge и цвета применяются корректно даже в теле.
Подводные камни
Стоит обратить внимание на пустые ячейки внутри merge. Обязательно нужно проверить cell.value !== null, иначе merge ломается и появляются лишние <td>.
Также я наткнулся на то, что иногда Excel возвращает цвет в формате ARGB (FF00FF00). Нужно преобразовывать в HEX, обрезая первые два символа: #00FF00.
Конечно же, ключи итерируемых элементов. Для больших таблиц подойдет генерация ключей в виде rowIndex + colIndex, но только если таблица статична.
Если будет сортировка, фильтрация, добавление строк — нужно использовать другие ключи.
И, конечно же, нужно будет в дальнейшем уделить внимание производительности. Для большого количества ячеек стоит использовать виртуализацию (react-window) или lazy-рендеринг, иначе страница будет лагать.
Как итог, вот полный код компонента:
import React, { useEffect, useState } from "react";
import * as XLSX from 'xlsx/dist/xlsx.full.min';
import "./ExcelTable.css";
interface ExcelTableProps {
file: File | null;
startRow?: number;
headerRows?: number;
}
interface CellData {
value: any;
colSpan?: number;
rowSpan?: number;
bgColor?: string;
}
const ExcelTable: React.FC<ExcelTableProps> = ({
file,
startRow = 1,
headerRows = 1,
}) => {
const [header, setHeader] = useState<any[][]>([]);
const [body, setBody] = useState<any[][]>([]);
const [merges, setMerges] = useState<XLSX.Range[]>([]);
const [sheet, setSheet] = useState<XLSX.WorkSheet | null>(null);
useEffect(() => {
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const data = new Uint8Array(e.target?.result as ArrayBuffer);
const workbook = XLSX.read(data, { type: "array", cellStyles: true })
const sheetName = workbook.SheetNames[0];
const newSheet = workbook.Sheets[sheetName];
const json = XLSX.utils.sheet_to_json(newSheet, { header: 1, raw: true });
const visibleRows = json.slice((startRow || 1) - 1);
const headerPart = visibleRows.slice(0, headerRows);
const bodyPart = visibleRows.slice(headerRows);
setHeader(headerPart);
setBody(bodyPart);
setMerges(newSheet["!merges"] || []);
setSheet(newSheet);
};
reader.readAsArrayBuffer(file);
}, [file, startRow, headerRows]);
const getCellBgColor = (sheet: XLSX.WorkSheet, r: number, c: number) => {
const cellAddress = XLSX.utils.encode_cell({ r, c });
const cell = sheet[cellAddress];
if (!cell || !cell.s || !cell.s.fill) return undefined;
const fill = cell.s.fill;
const rgb = fill.fgColor?.rgb || fill.bgColor?.rgb;
if (rgb && typeof rgb === "string") {
const hex = rgb.length > 6 ? rgb.slice(-6) : rgb;
return `#${hex}`;
}
return undefined;
};
const getMergedMatrix = (rows: any[][], sheet: XLSX.WorkSheet, isBody: boolean = false): CellData[][] => {
const startRowOffset = (isBody ? headerRows : 0) + (startRow - 1);
const matrix: CellData[][] = rows.map((row, rowIndex) =>
row.map((cell, colIndex) => {
const bgColor = sheet ? getCellBgColor(sheet, rowIndex + startRowOffset, colIndex) : undefined;
return { value: cell, bgColor };
})
);
merges.forEach((m) => {
const { s, e } = m;
const rowIndex = isBody ? s.r - (startRow - 1) - headerRows : s.r - (startRow - 1);
if (rowIndex < 0 || rowIndex >= matrix.length) return;
const colSpan = e.c - s.c + 1;
const rowSpan = e.r - s.r + 1;
if (matrix[rowIndex] && matrix[rowIndex][s.c]) {
matrix[rowIndex][s.c] = {
...matrix[rowIndex][s.c],
colSpan,
rowSpan,
};
}
for (let r = s.r; r <= e.r; r++) {
for (let c = s.c; c <= e.c; c++) {
if (r === s.r && c === s.c) continue;
const row = matrix[r - (startRow - 1) - (isBody ? headerRows : 0)];
if (row && row[c]) row[c].value = null;
}
}
});
return matrix;
};
const headerMatrix = sheet ? getMergedMatrix(header, sheet, false) : [];
const bodyMatrix = sheet ? getMergedMatrix(body, sheet, true) : [];
return (
<div className="excel-table-container">
<table className="excel-table">
<thead>
{headerMatrix.map((row, i) => (
<tr key={`h-${i}`}>
{row.map((cell, j) =>
cell.value !== null ? (
<th
key={`h-${i}-${j}`}
colSpan={cell.colSpan}
rowSpan={cell.rowSpan}
style={{ backgroundColor: cell.bgColor }}
>
{cell.value}
</th>
) : null
)}
</tr>
))}
</thead>
<tbody>
{bodyMatrix.map((row, i) => (
<tr key={`b-${i}`}>
{row.map((cell, j) =>
cell.value !== null ? (
<td
key={`b-${i}-${j}`}
colSpan={cell.colSpan}
rowSpan={cell.rowSpan}
style={{ backgroundColor: cell.bgColor }}
>
{cell.value}
</td>
) : null
)}
</tr>
))}
</tbody>
</table>
</div>
);
};
export default ExcelTable;
Результат
Вот так выглядит мой тестовый файл Excel:

И также выглядит отображение в браузере:

<table> отображает Excel почти на 100% идентично: merge, шапка, тело, текст копируется, работает скролл, HTML-таблица остаётся стандартной и совместимой с любым CSS. На этом фундаменте можно строить необходимый функционал.
Да, я здесь не заморачивался с типами данных с форматированием, но основа работает, и я доволен. Готов услышать ваши идеи, мнения, варианты. Пишите!
nbelyh
А такие варианты рассматривал (open-source, Apache/Git) Самые популярные вроде как, или?
Univer/Luckysheet
x-spreadsheet/wolf-table
Из коммерческих SpreadJS
4Nun4ku Автор
Спасибо, я посморю эти варианты)
JerryI
Handsontable
nbelyh
Не, Handsontable это IMHO отстой. Бесконечные глюки (особенно на мобилке) за конский ценник.
4Nun4ku Автор
платное отпало сразу)