SE(たぶん)の雑感記

一応SEやっている筆者の思ったことを書き連ねます。会計学もやってたので、両方を生かした記事を書きたいと考えています。 でもテーマが定まってない感がすごい。

TypeScriptで「xlsx.js」を使って、Excelをフロントエンドで頑張って読み込みした話

こんにちは。

こちらは、QiitaTypeScript Advent Calendar 2018の3日目の記事になります。

これまで、特にTypeScriptを使ってこなかったどころか、Webフロントの開発がほぼ初心者の筆者が、突然xlsx.jsというライブラリを使って、Webフロントの開発を行った話をします。

とりあえず動いたよ!というところを話します。そもそも間違ってるよ!というツッコミ大歓迎です。

経緯

PythonDjangoを使った開発をしていて、フロントエンドでExcel読み込めないかなぁ、という話になりました。

その中で、JavaScriptExcelを読み込むための、Sheetjs(ファイル名:xlsx.js)というものがあることを知りました。

GitHubページはこちらです。

github.com

もともと、簡単なDOM操作でTypeScriptを導入していたので、その延長線で使えるだろう、と踏んでのことでした。

TypeScript開発環境

今回はPythonのAdvent Calendarではないので詳細は省きますが、TypeScript自体はNode.jsで導入し、コンパイルtscで行っていました*1

結果のJavaScriptを、Djangoが呼び出すファイルから参照しています。

コーティングにはVisual Studio Codeを利用しています。

xlsx.jsのインストール

これも、npm経由でインストールできます。

npm install --save xlsx

とすればいいです。

TypeScriptから参照

TypeScriptからxlsx.jsを参照する場合、型定義が用意されています。

js-xlsx/demos/typescript at master · SheetJS/js-xlsx · GitHub

ここに書いてある通り、

import { WorkBook, WorkSheet, utils, Range, CellObject, CellAddress } from "xlsx";

という感じで、必要なモジュールを参照しました。

作ろうとしたもの

使い方に関しては、

qiita.com

qiita.com

の記事を参照しました。この場を借りてお礼を申し上げます。

使い方として、結局は

  • HTMLのinput要素でファイルを選択する
  • 選択されたファイルを検知し、Excelとして開く
  • あとはよしなに

というところに帰結するので、Workbookを取得するところまで、モジュールとして切り出してしまいました。

  • event.ts
interface HTMLElementEvent<T extends HTMLElement> extends Event {
    target: T;
}

interface ObjectEvent<T extends EventTarget> extends Event {
    target: T;
}
///<reference path="event.ts">
import * as excel from "xlsx";

/**
 * Excel読み込み処理
 */
export interface IExcelRead {
    read(xls: excel.WorkBook);
}

/**
 * ファイルを選択します。
 */
export class FileSelector {
    private input: HTMLInputElement;
    private selectedFile: File | null;
    private reader: IExcelRead;
    constructor(elem: HTMLInputElement, reader: IExcelRead) {
        this.input = elem;
        elem.addEventListener("change", this.fileSelect.bind(this));
        this.selectedFile = null;
        this.reader = reader;
    }

    private fileSelect(evt: HTMLElementEvent<HTMLInputElement>) {
        if (evt.target.files) {
            this.selectedFile = evt.target.files[0];

            this.read();
        }
    }

    private read() {
        if (!this.selectedFile) {
            return;
        }

        const reader = new FileReader();

        reader.addEventListener("load", this.loadSuccess.bind(this));

        reader.readAsArrayBuffer(this.selectedFile);
    }

    /**
     * ファイルロードに成功したら発生
     * @param evt イベント対象オブジェクト
     */
    private loadSuccess(evt: ObjectEvent<FileReader>) {
        const data = evt.target.result;
        if (!data) {
            return;
        }

        if (typeof data === "string") {
            return;
        }

        const arr = this.handleCodePoints(new Uint8Array(data));
        const book = excel.read(btoa(arr), {
            type: "base64",
            raw: true
        });

        this.reader.read(book);
    }

    // see: https://github.com/mathiasbynens/String.fromCodePoint/issues/1
    private handleCodePoints(array: Uint8Array) {
        var CHUNK_SIZE = 0x8000; // arbitrary number here, not too small, not too big
        var index = 0;
        var length = array.length;
        var result = '';
        let slice: Uint8Array;
        while (index < length) {
            slice = array.slice(index, Math.min(index + CHUNK_SIZE, length)); // `Math.min` is not really necessary here I think
            result += String.fromCharCode.apply(null, slice);
            index += CHUNK_SIZE;
        }
        return result;
    }
}

エラーチェックは激甘です。

動かない

こう書いて、コーディング中はIntelliSenseも動作しました。

使う時は、

const input = <HTMLInputElement>document.getElementById("input要素の名前");

const reader = new "IExcelReaderを実装したクラス"();

const f = new FileSelector(input, reader);

という形で使えます。

いざ、tscコンパイルして動かすと、

f:id:hiroronn:20181203212925p:plain

Uncaught ReferenceError: exports is not defined

というエラーが。

これがなんなのか、まったくわかりませんでした。

tsconfig.json定義

最低限ですが。

{
  "compilerOptions": {
    /* Basic Options */
    "target": "es5",
    "module": "commonjs",
    "lib": ["es2017", "dom"],
  }
}

そもそもこの定義でimportが使えるのかわかりませんが…

さんざん悩んだ後

これで合っているかどうかわかりませんが。

  • xlsxでは、index.d.tsはあるものの、型定義(DefinitelyTyped)は無い
  • 自分で型定義を書かない限り、importからは逃れられない
  • ビルドはできても、生成されたJavaScriptがエラーになる

という状況だったので、

  • まず、tscでビルドしたJavaScriptを用意する
  • それらをbrowserifyを使ってくっつける

という、なんだかごり押しな方法で動きました。

browserifyですが、

Node.jsのモジュールシステムをブラウザでも利用できるようになる

ものです。

www.npmjs.com

というか、import等はNode.js限定の書き方なんですか…?TypeScriptの公式ではいかにも使えます感で書いてありましたが…ES6からは使えるそうなので、対応待ちでしょうか。

上で書いたexcel.tsですが、上記のtsconfig.jsonの設定でビルドすると、JavaScript先頭に以下の記述が付与されます。

"use strict";
var __importStar = (this && this.__importStar) || function (mod) {
    if (mod && mod.__esModule) return mod;
    var result = {};
    if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
    result["default"] = mod;
    return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
var excel = __importStar(require("xlsx"));

そうです。requireが現れます。よって、browserifyで結合すれば使えます。

ビルドを自動化したい

browserifyで結合するのはよいのですが、ビルド手順が

  • tscTypeScriptJavaScriptに変換
  • browserifyで、指定したJavaScriptだけ結合

となり、毎回手作業はしんどいです。

そこで、package.jsonscriptsに、コマンドを定義してしまいました。こんな感じです。

import.tsというのは、上記excel.tsを参照しているファイルです。

{
  // 略
  "scripts": {
    "build:ts": "tsc",
    "build:ts:import": "tsc import.ts tslib/event.ts tslib/excel.ts  --target es5 --outDir dist --removecomments --declaration --sourcemap --lib es2015,dom",
    "build:br:debug": "browserify dist/import.js --debug -o ../static/src/js/imports.js",
    "build:debug": "npm run build:ts:import && npm run build:br:debug"
  },
  // 略
}

一つのコマンドでは複数のコマンドを実行できないため、npm runを複数回実行するようにしています。

これで、

TypeScriptJavaScriptに変換し、一つのフォルダにまとめる
→生成されたJavaScriptに対し、browserifyrequiresの内容を解決し、一つのファイルにまとめる

という作業が、一つのコマンド(npm run build:debugコマンド)で実行できます。

あとは、Visual Studio Codeのタスクで呼び出すよう設定すると、実行時にスクリプトのビルドが勝手に行われます。便利。

おわりに

なんか、Excelを読み込んだ話はどこかに行った感があります。

実際、上のような設定をしてビルドすると、Excel読み込みが可能になりました。

最終的には、uglify-es等を使ってスクリプトを圧縮すれば使えそうです。

…ただ、いろいろ調べると、webpackで複数ファイルの結合から圧縮まで、全部できそうな雰囲気があります。

いつか試してみたいところです。

途中、説明が飛んだ部分とかある気がするので、ちょいちょい追加します。


余談

筆者の周りはTypeScript分かる人が誰もいないので、一人で突っ走って何とかしました。

教えてくれる人が欲しい今日この頃です。


*1:tscコンパイルした結果のjsを、Djangoのstatic(静的ファイル格納場所)に置くように、tsconfig.jsonを記述