TOC

Web 保存数据的特殊方案

原文:

需求

实现数据的保存与加载。

前提是没有以下支持:

  • Cookie
  • LocalStorage
  • Server Side Storage

设计

  1. 使用图片
  2. 支持几十 KB 数据
  3. 不需要考虑特定图片格式的处理细节

实现

关于保存为图片的方案选择

排除方案 1

数据 -> JSON -> 动态生成 Data URLs下载链接

移动端 Safari 上无效:不认 <a> 标记的 download 属性。

排除方案 2

二维码

编码容易,解码麻烦,需要一个很大的库。

选择方案:Canvas

每三个 ASCII 组成一个 RGB 像素,不足部分用 0 填充。

问题:图片太小的话,不便于点击保存(?原话:this is not easy to tap to save)
方案:预设大小:256 * 256

第一行保留(最后一列顺带着保留了,因为是数据方阵的设计)用来记录元数据,比如数据规模。
可用空间:255 * 255,65025 => 190KB

JSON 字符串 ---TextEncoder---> 字节数组

> (new TextEncoder('utf-8')).encode('Hello World')
Uint8Array(11) [72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]

测试数据:

$ curl -X POST -qd "name=胡昂&location=中华人民共和国&job=软件开发工程师" https://httpbin.org/post | jq --tab

主要逻辑:

function createElementFromHTML(html) {
    var emptyElement = document.createElement("empty");
    emptyElement.innerHTML = html.trim();
    return emptyElement.firstChild;
}

var exportedImage = null;
var importedData = null;

function exportData(data) {
    // JSON 序列化
    var strData = JSON.stringify(data);
    // TextEncoder 编码成数字
    var uint8array = new TextEncoder("utf-8").encode(strData);
    // 计算需要多大方阵存储全部数据
    var dataSize = Math.ceil(Math.sqrt(uint8array.length / 3));

    // 用 255 填充 RGBA 中的 alpha 通道,设置完全不透明
    // 否则 alpha = 0,即透明图片)会遇到 RGB 损坏的问题
    // https://stackoverflow.com/questions/22384423/canvas-corrupts-rgb-when-alpha-0
    var paddedData = new Uint8ClampedArray(dataSize * dataSize * 4);
    var idx = 0;
    // 每三个数字后加一个 255
    for (var i = 0; i < uint8array.length; i += 3) {
        var subArray = uint8array.subarray(i, i + 3);
        paddedData.set(subArray, idx);
        paddedData.set([255], idx + 3);
        idx += 4;
    }

    // 生成图像数据
    var imageData = new ImageData(paddedData, dataSize, dataSize);

    // 创建一个屏外(off screen)画布
    var imgSize = 256;
    var canvas = document.createElement("canvas");
    canvas.width = canvas.height = imgSize;
    // 获取画布上下文
    var ctx = canvas.getContext("2d");
    // 画布设置:256 * 256 的黑布
    ctx.fillStyle = "#AA0000"; // 颜色没关系
    ctx.fillRect(0, 0, imgSize, imgSize);
    // 用一个像素的 R 值记录数据规模
    ctx.fillStyle = "rgb(" + dataSize + ", 0, 0)";
    ctx.fillRect(0, 0, 1, 1);
    // 画布填充内容
    ctx.putImageData(imageData, 0, 1);

    var body = document.getElementsByTagName("body")[0];

    exportedImage = canvas.toDataURL();
    var downloadedLink = createElementFromHTML('<a id="hiddenLink" href="' + exportedImage + '" style="display:;" download="image.png">Download</a>');
    body.appendChild(downloadedLink);

    // MDN 中 Element 没有这个方法,可能不通用
    // downloadedLink.click();

    var e = document.createEvent("MouseEvents");
    e.initEvent("click", true, true);
    // downloadedLink.dispatchEvent(e); // 无效
    var downloadedLink = document.getElementById("hiddenLink");
    downloadedLink.dispatchEvent(e);

    body.removeChild(downloadedLink);
}

// exportData 的反向操作
function importData(imgSrc, callback) {
    var img = new Image();
    img.onload = function () {
        // 先把图片填充到画布上
        var imgSize = img.width;
        var canvas = document.createElement("canvas");
        canvas.width = canvas.height = imgSize;
        var ctx = canvas.getContext("2d");
        ctx.drawImage(img, 0, 0);

        // 获取数据规模
        var headerData = ctx.getImageData(0, 0, 1, 1);
        var dataSize = headerData.data[0];

        // 获取数据
        var imageData = ctx.getImageData(0, 1, dataSize, dataSize);
        var paddedData = imageData.data;

        // 抛弃每一个像素数据中的第四位(RGBA 中的 alpha 通道)
        var uint8array = new Uint8Array((paddedData.length / 4) * 3);
        var idx = 0;
        for (var i = 0; i < paddedData.length - 1; i += 4) {
            var subArray = paddedData.subarray(i, i + 3);
            uint8array.set(subArray, idx);
            idx += 3;
        }

        // 将最后为 0 的填充数据去掉
        var includeBytes = uint8array.length;
        for (var i = uint8array.length - 1; i > 0; i--) {
            if (uint8array[i] === 0) {
                includeBytes--;
            } else {
                break;
            }
        }
        var data = uint8array.subarray(0, includeBytes);
        var strData = new TextDecoder("utf-8").decode(data);

        try {
            importedData = JSON.parse(strData);
            if (callback) {
                callback();
            }
        } catch (error) {
            if (callback) {
                callback(error);
            }
        }
    };

    // 可以设置 src 属性为普通 URL 或 Data URL
    img.src = imgSrc;
}

运行

RunJS: https://codepen.io/catroll/pen/BGjPoO