Решил собрать волю в кулак и прочитать-таки документацию про ArrayBuffer, Uint8Array, DataView и всех их родственников. Оказалось, что типизированные массивы это милая и широко-используемая штука по всему фронту JS/HTML5, и я — последний человек на земле, который ими не пользуется.
Вообще-то, обычные массивы тоже отлично работают — размер устанавливается динамически, положить туда можно всё, что угодно, уйма методов для манипуляций есть прямо из коробки. Но в этом есть и недостаток. Первые разработчики WebGL столкнулись с проблемой, что скормить видеокарте JavaScript массив очень проблематично. Ведь нужно пробежаться по всем элементам, привести их к одному типу, скопировать результат в Си-образный массив и уже потом передать указатель видюхе. Как обходной маневр, они создали скриптовый враппер для Си-шных массивов Float. Через какое-то время это породило целый маленький зоопарк типов, которые мы сейчас и рассмотрим.
Точка входа — ArrayBuffer. В принципе, это просто readonly указатель на бинарный буфер в памяти. Читать напрямую из него нельзя. Писать тоже. Зато можно посмотреть размер в байтах и сделать копию.
1 2 3 4 |
var buf1 = new ArrayBuffer(16), buf2 = buf1.slice(); console.log(buf1.byteLength, buf2.byteLength); //16, 16 |
В этом месте внимательный читатель может задаться вопросом «нафига?». Терпение, мой дорогой товарищ.
Для того, чтобы читать и писать в буфер, нужно создать view, который будет знать, данные какого типа лежат в буфере. Под каждый числовой тип есть свой view. Целых девять штук. Говоря типизированный массив, мы обычно имеем ввиду именно их:
- Uint8Array
- Int8Array
- Uint8ClampedArray
- Uint16Array
- Int16Array
- Uint32Array
- Int32Array
- Float32Array
- Float64Array
Про ClampedArray будет чуть позже. Все типизированные массивы могут принимать на вход буфер или какой-то его кусок:
1 2 3 4 |
var buffer = new ArrayBuffer(4), u16 = new Uint16Array(buffer); u16[0] = 0xFF; |
Что здорово, на один и тот же буфер можно натравить несколько вьюшек, и читать одну и ту же область памяти как, например, массив байт и массив int32 одновременно. Эту фишку можно применить так: есть у нас, например, растр в памяти — последовательность Red, Green, Blue & Alpha компонент цвета каждой точки какой-то картинки. Мы создадим Uint8Array и Uint32Array и сможем читать и писать цвета как по компонентам, так и сразу RGBA точку целиком. Записать int32 будет быстрее, чем четыре int8.
Раз уж пошла речь о растре, Uint8ClampedArray используется canvas для хранения растра текущей картинки. Честно-честно. Если вызвать, например,
canvasContext.getImageData(0, 0, 100, 100).data , то вернется ClampedArray. Он отличается от Uint8Array только тем, что при записи в него значений больше 255, он будет обрезать их до 255. Если меньше нуля — ставить 0. Очень удобно для манипуляций с RGB. А Uint8Array просто отбросит старшие байты записываемого числа, и результат может быть чем угодно в пределах 0-255.
1 2 3 4 5 |
var uint8 = new Uint8Array(buffer1); var clamped = new Uint8ClampedArray(buffer2); uint8[0] = 0x0100; //256; сохранит 0 clamped[0] = 0x100; //256; сохранит 255 |
Другой способ работать с буфером — DataView. Он позволяет читать int8-32, uint8-32, float32-64 по заданному смещению. Это удобно, когда мы работаем с ArrayBuffer, в котором смешанные типы данных. Например, когда мы читаем заголовок файла, в котором тип файла может храниться в первом байте, потом идет uint32 контрольной суммы, потом, например, пары широта/долгота GPS координат в float32 до конца файла. Не создавать же типизированный массив для каждого типа поля. А с DataView все получится проще:
1 2 3 4 5 6 |
var view = new DataView(fileBuffer); var type = view.getUint8(0); //offset 0 var crc = view.getUint32(1); //offset 1 var latitude0 = view.getFloat32(5); //offset 5 var longitude0 = view.getFloat32(9); |
Все get/set методы могут взять дополнительный опциональный параметр — little-endian. Он определяет порядок байт в памяти у чисел, которые больше чем один байт. По умолчанию, DataView использует big-endian — от старшего байта к младшему. Процессоры x86, на секунду, используют little-endian. Типизированные массивы используют правила текущей архитектуры, то есть тоже little-endian. То есть можно увидеть такую картину:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
var buffer = new ArrayBuffer(2), view = new DataView(buffer), word = new Uint16Array(buffer), bytes = new Uint8Array(buffer), testValue = 0xFF00; word[0] = testValue; //2-byte value bytes[0] === view.getUint8(0); // true bytes[1] === view.getUint8(1); // true word[0] === view.getUint16(0); // > false word[0] === view.getUint16(0, true); // > true |
Побайтовое сравнение проходит. Мультибайтовое — нет, до тех пор, пока я явно не укажу, что в буфере данные лежат в little-endian формате.
Зачем вообще нужны типизированные массивы. Они быстрые. Они удобны для работы с сырыми двоичными данными. Так как типизированный массив лежит как есть в памяти, то его можно без потерь и колдовства передавать в нативные браузерные расширения.
Ну и напоследок, кто умеет работать с ArrayBuffer. Во-первых, это XMLHttpRequest (level 2), WebSocket и fetch() — для отправки и получения бинарных сообщений. Canvas для работы с растром, WebGL для буферов (текстур, вершин). Web Audio API тоже понимает массивы. Самая любопытная и, кажется, недооцененная возможность — передача (Transfer) буфера через postMessage между окном и WebWorker (либо два WebWorker). В этом случае буфер не клонируется, как обычно происходит в postMessage, а просто меняет владельца. Для предыдущего владельца ссылка становится невалидной. Можно, например, в воркерах собирать тяжелые картинки и по готовности отдавать родительскому окну. Скорость «передачи» данных при этом будет просто огромной.