Web优雅的实现大数据量Excel导入

本文接上一篇文章:《Web优雅的实现大数据量Excel导出》
类似的思路也可以实现Excel导入:

实现思路

1、 浏览器读取上传的文件
2、 使用js-xlsx库读取文件
3、 每次ajax向后端发送若干行的数据,直到发送完所有行
4、 导入完成

本文省略php,web服务器等环境搭建步骤。看懂接下来代码实现,需要有一些js,html,PHP,ajax,ES6:promise,async,await,HTML5:FileReader的一些相关知识。
js-xlsx库的安装,引入参照上篇文章。

1、创建html页面元素

index.html

<body>
<!--    进度显示-->
<span>导入进度:</span><span id="progressText">0%</span>
<br>

<!--    选择文件按钮-->
<input type="file" id="fileBtn" accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" value="选择文件">
<!--    文件读取标识-->
<span id="readIndicator" style="display:none">文件读取中.....</span>
<br>
<!--    开始导入按钮-->
<input type="button" id="startBtn" value="开始导入" disabled>

</body>

浏览器显示:


image.png

2、添加js代码,绑定按钮点击事件

    //监听上传文件change事件
    document.getElementById('fileBtn').addEventListener('change', function () {
        //进度指示清零
        document.getElementById("progressText").innerHTML = "0%";
        //获取文件内容
        let file = this.files[0];
        //判断文件名
        if(!file.name.includes('.xlsx')){
            alert('只支持xlsx文件');
            return;
        }
        //读取文件内容
        let fileReader = new FileReader();
        //读取完成回调
        fileReader.addEventListener('load', function(e){
            //读取上传的xlsx文件内容
            let workbook = XLSX.read(e.target.result, {type: 'binary'});
            //隐藏文件读取中标识
            document.getElementById('readIndicator').style = "display:none";
            //准备导入
            importMgr = new ImportMgr(workbook);
            document.getElementById('startBtn').disabled = false;
        });
        fileReader.readAsBinaryString(file);
        //展示文件读取中标识
        document.getElementById('readIndicator').style = "display:inline";

    }, false);

    //点击开始导入按钮开始导入
    document.getElementById('startBtn').addEventListener('click', function(){
        importMgr.begin();
    });

ImportMgr类见下章。
这里的js-xlsx库的一个用法解释:

XLSX.read(binaryString, {type: 'binary'});
第一个参数: 输入的数据
第二个参数:{type: 'binary'}代表接收数据的格式是二进制字符串,fileReader.readAsBinaryString(file); 刚好满足这个格式,其他用法可以参考官网
返回:一个工作簿类对象


3、ImportMgr导入逻辑封装类

    //导入管理类对象
    let importMgr = null;
    //导入管理类
    class ImportMgr{

        //当前已经导入到第几行, 从1开始,跳过第一行标题
        rowIndex = 1;
        //总共有几行
        rowsTotal = 0;
        //列数组, ['A', 'B', 'C'......]
        cols = [];
        //每次ajax请求导入多少行
        rowsPerRequest = 50;

        //excel工作簿实例
        workbook=null;
        //excel sheet实例
        worksheet=null;
        //初始化
        constructor(workbook){
            this.workbook = workbook;
            this.worksheet = this.workbook.Sheets[this.workbook.SheetNames[0]];
            this.getTotal();
        }

        //开始导入
        async begin(){

            while(this.rowIndex <= this.rowsTotal){

                //计算本次请求结束行
                let rowEnd;
                if(this.rowIndex + this.rowsPerRequest <= this.rowsTotal){
                    rowEnd = this.rowIndex + this.rowsPerRequest;
                }else{
                    rowEnd = this.rowsTotal;
                }

                //异步导入
                await this.importOnce(this.rowIndex+1, rowEnd);
                //更新行index
                this.rowIndex += this.rowsPerRequest;
                //更新进度指示
                this.updateProgress(rowEnd, this.rowsTotal);
            }

            document.getElementById('fileBtn').value = null;
            alert("导入完成!");

        }

        //发起一次导入请求,rowStart:从指定行开始 rowEnd:从指定行结束
        importOnce(rowStart, rowEnd){
            // console.log(rowStart, rowEnd);
            //生成多行数据
            let dataArr = [];
            for(let i = rowStart; i<= rowEnd; i++){
                //当前行数据
                let rowDataArr = [];
                //当前行cell地址
                let cellAddrArr = this.getRowAddressArr(i);

                //获取当前行数据
                for(let cellAddr of cellAddrArr){
                    // console.log(cellAddr);
                    rowDataArr.push(this.worksheet[cellAddr].v);
                }

                dataArr.push(rowDataArr);
            }

            // console.log(dataArr);
            return new Promise((resolve, reject)=>{
                //发起ajax请求
                let xhr = new XMLHttpRequest();
                xhr.open('POST', 'store.php');
                xhr.setRequestHeader("content-type", "application/json" );
                xhr.responseType = "json";
                xhr.onload = function(){
                    console.log(xhr.response);
                    resolve();
                };

                xhr.send(JSON.stringify(dataArr));
            });
        }

        //获取行数,和列数
        getTotal(){
            //获取cell范围
            let refArr = this.worksheet['!ref'].split(':');
            let end = refArr[1];

            //生成列[A, B, C, .....]数组, A的unicode:65
            for(let i=65; i<=end.charCodeAt(0); i++){
                this.cols.push(String.fromCharCode(i));
            }

            //获取行数, 把类似"F123"的cell地址,提取出123
            let startIndexDigital = end.search(/\d+/);
            this.rowsTotal = parseInt(end.substring(startIndexDigital));
        }

        //根据列数组生成一行完整的cell地址
        getRowAddressArr(rowNum){
            let cols = [];
            for(let col of this.cols){
                cols.push(col + rowNum);
            }

            return cols;
        }

        /**更新进度显示
         *
         * @param now   当前已更新行数
         * @param total 总行数
         */
        updateProgress(now, total){
            //计算百分比
            let percent = (now/total * 100).toFixed(2);
            //更新dom
            document.getElementById("progressText").innerHTML = percent + "%";
        }

    }

解读:

begin()方法使用async, await关键字使得异步代码更清晰, 方法中await调用importOnce()异步操作

importOnce()是向服务器发起一次导入请求,使用了Promise

this.workbook.SheetNames[0]:获取工作簿中第一个标签页名
this.workbook.Sheets['标签页名']: 获取标签页sheet对象

this.worksheet['!ref'] 标签页对象中'!ref'属性中存储了excel单元格有内容范围 格式如:A1:B2 代表了2*2大小的范围

image.png

cell = this.worksheet['XX'] 标签页对象中字母数字属性代表了单元格,如:worksheet['A13']获取了第十三行,A列的单元格对象

cell.v 单元格对象,cell的v属性就是单元格内容,其他属性有需要可参考官方文档


4、后端store.php 代码:

<?php
//设置返回json头
header('Content-type: application/json');
//打开一个文件,模拟数据库
$file = fopen('import.txt', 'a');

//获取json数据
$json = file_get_contents("php://input");
$data = json_decode($json, true);

//处理
foreach($data as $row){
    insertData($row, $file);
}

fclose($file);

echo json_encode(['success'=>true]);

//插入数据到数据库,这里为了简化,写入文件
function insertData(array $dataArr, $file):bool {
    $str = implode(',', $dataArr);
    fwrite($file, $str."\n");
    return true;
}

用文件简化存储数据库流程


完整的前端代码:

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>demo-xlsx-import</title>
    <script src="node_modules/xlsx/jszip.js"></script>
    <script src="node_modules/xlsx/xlsx.js"></script>
</head>
<body>
<!--    进度显示-->
<span>导入进度:</span><span id="progressText">0%</span>
<br>

<!--    选择文件按钮-->
<input type="file" id="fileBtn" accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" value="选择文件">
<!--    文件读取标识-->
<span id="readIndicator" style="display:none">文件读取中.....</span>
<br>
<!--    开始导入按钮-->
<input type="button" id="startBtn" value="开始导入" disabled>

</body>
<script>

    //监听上传文件change事件
    document.getElementById('fileBtn').addEventListener('change', function () {
        //进度指示清零
        document.getElementById("progressText").innerHTML = "0%";
        //获取文件内容
        let file = this.files[0];
        //判断文件名
        if(!file.name.includes('.xlsx')){
            alert('只支持xlsx文件');
            return;
        }
        //读取文件内容
        let fileReader = new FileReader();
        //读取完成回调
        fileReader.addEventListener('load', function(e){
            //读取上传的xlsx文件内容
            let workbook = XLSX.read(e.target.result, {type: 'binary'});
            //隐藏文件读取中标识
            document.getElementById('readIndicator').style = "display:none";
            //准备导入
            importMgr = new ImportMgr(workbook);
            document.getElementById('startBtn').disabled = false;
        });
        fileReader.readAsBinaryString(file);
        //展示文件读取中标识
        document.getElementById('readIndicator').style = "display:inline";

    }, false);

    //点击开始导入按钮开始导入
    document.getElementById('startBtn').addEventListener('click', function(){
        importMgr.begin();
    });

    //导入管理类对象
    let importMgr = null;
    //导入管理类
    class ImportMgr{

        //当前已经导入到第几行, 从1开始,跳过第一行标题
        rowIndex = 1;
        //总共有几行
        rowsTotal = 0;
        //列数组, ['A', 'B', 'C'......]
        cols = [];
        //每次ajax请求导入多少行
        rowsPerRequest = 50;

        //excel工作簿实例
        workbook=null;
        //excel sheet实例
        worksheet=null;
        //初始化
        constructor(workbook){
            this.workbook = workbook;
            this.worksheet = this.workbook.Sheets[this.workbook.SheetNames[0]];
            this.getTotal();
        }

        //开始导入
        async begin(){

            while(this.rowIndex <= this.rowsTotal){

                //计算本次请求结束行
                let rowEnd;
                if(this.rowIndex + this.rowsPerRequest <= this.rowsTotal){
                    rowEnd = this.rowIndex + this.rowsPerRequest;
                }else{
                    rowEnd = this.rowsTotal;
                }

                //异步导入
                await this.importOnce(this.rowIndex+1, rowEnd);
                //更新行index
                this.rowIndex += this.rowsPerRequest;
                //更新进度指示
                this.updateProgress(rowEnd, this.rowsTotal);
            }

            document.getElementById('fileBtn').value = null;
            alert("导入完成!");

        }

        //发起一次导入请求,rowStart:从指定行开始 rowEnd:从指定行结束
        importOnce(rowStart, rowEnd){
            // console.log(rowStart, rowEnd);
            //生成多行数据
            let dataArr = [];
            for(let i = rowStart; i<= rowEnd; i++){
                //当前行数据
                let rowDataArr = [];
                //当前行cell地址
                let cellAddrArr = this.getRowAddressArr(i);

                //获取当前行数据
                for(let cellAddr of cellAddrArr){
                    // console.log(cellAddr);
                    rowDataArr.push(this.worksheet[cellAddr].v);
                }

                dataArr.push(rowDataArr);
            }

            // console.log(dataArr);
            return new Promise((resolve, reject)=>{
                //发起ajax请求
                let xhr = new XMLHttpRequest();
                xhr.open('POST', 'store.php');
                xhr.setRequestHeader("content-type", "application/json" );
                xhr.responseType = "json";
                xhr.onload = function(){
                    console.log(xhr.response);
                    resolve();
                };

                xhr.send(JSON.stringify(dataArr));
            });
        }

        //获取行数,和列数
        getTotal(){
            //获取cell范围
            let refArr = this.worksheet['!ref'].split(':');
            let end = refArr[1];

            //生成列[A, B, C, .....]数组, A的unicode:65
            for(let i=65; i<=end.charCodeAt(0); i++){
                this.cols.push(String.fromCharCode(i));
            }

            //获取行数, 把类似"F123"的cell地址,提取出123
            let startIndexDigital = end.search(/\d+/);
            this.rowsTotal = parseInt(end.substring(startIndexDigital));
        }

        //根据列数组生成一行完整的cell地址
        getRowAddressArr(rowNum){
            let cols = [];
            for(let col of this.cols){
                cols.push(col + rowNum);
            }

            return cols;
        }

        /**更新进度显示
         *
         * @param now   当前已更新行数
         * @param total 总行数
         */
        updateProgress(now, total){
            //计算百分比
            let percent = (now/total * 100).toFixed(2);
            //更新dom
            document.getElementById("progressText").innerHTML = percent + "%";
        }

    }

</script>
</html>


总结:

经测试效果不错,导入20w条数据浏览器占用900M内存


image.png

image.png

结束语:

对比服务端解析excel文件的方案,我觉得有以下一些优点:

  • 性能更好,导入速度快
  • 有进度指示器,对用户友好
  • 分批生成,不会触发请求超时
  • 不占用服务器资源,尤其是内存
  • 和后端更新记录的逻辑基本一致,后端几乎不用增加功能直接用

缺点:前端逻辑比较复杂

注意:
  • 本文为原创,代码可以随意使用,无版权,转载请注明原地址
  • 本文代码不能直接用在生产环境,只用作流程演示,如需使用需要修改增加安全性,数据字段校验,异常处理等等。
  • 能生成excel表格最大行数只和客户端配置有关
  • excel所有单元格都设置成文本格式,可以避免一些坑,比如excel的时间
  • 本文前端代码在Chrome 77内核的浏览器下运行正常,其他浏览器兼容性本文不做探讨
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,290评论 6 491
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,107评论 2 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,872评论 0 347
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,415评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,453评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,784评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,927评论 3 406
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,691评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,137评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,472评论 2 326
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,622评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,289评论 4 329
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,887评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,741评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,977评论 1 265
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,316评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,490评论 2 348

推荐阅读更多精彩内容