webexcel制作详解

图标

豆瓜

豆瓜网

豆瓜网专栏

首发
豆瓜 图标 2020-06-25 21:56:25

Web版Excel制作过程分享

由于项目需要制作一个Web版本Excel用于表单、报表在线绘制,网上搜了一圈没有找到合适的资源,根据搜到的一些零散信息决定自己动手做一个,本文分享这个制作过程,主要包含表格布局、表头固定、动态调整行高列宽、单元格选中、合并与拆分单元格等功能,供大家交流分享。废话少说先上个效果图如下:

 

一、技术选型

1. 本例基于Jquery库和Vue框架实现,其中Vue并不是必须,仅仅因为项目需要而已,读者只需稍作改造去掉对Vue的依赖即可。

2. 出于对简单直观的追求,笔者选择基于table元素而不是基于div组合,

二、先用table做个Excel表格的样子

本来觉得很容易,用框架动态生成一个n行m列的table,并在第一行自动填充ABC...Z等作为列表头,在第一列自动填充123...n等作为行表头,使用Vue框架v-for循环生成tr和td元素即可,不熟悉vue的同学可以简单了解一下vue中v-for指令,当然也可以用原生js或jquery生成这个table的所有行列单元格,总体布局的思路如下:

1. 外层用一个div控制显示区域,让表格在这个区域内显示,超出该区域则滚动:overflow:scroll

2. 内层用table绘制表格,其中第一行和第一列单独绘制,填入表头字母和数字,每个单元格宽默认100px,tr行高默认28px

 View Code

结果如下图所示(红色是外层div的边框,为了调试方便),代码中我们给每个单元格设置了100px的宽度,一共生成了27列,按理说他应该把整个table撑开到至少2700px;然而如图所示整个table的宽度并没有被撑开,而是自适应了外层div的宽度。这就是我们今天要解决的第一个问题,table元素td标签宽度设置无效的问题。

三、解决table中td元素宽度设置无效的问题

网上有说给table添加table-layout: fixed样式,然而这种方法测试后并没效果;其实解决这个问题很简单,就是给table直接指定一个明确的宽度,不妨我们先设个2700px看看效果

        .form-table{
            border-spacing:0;
            width: 2700px;/*先指定一个明确的宽度*/
        }

 这时候我们发现整个table确实变宽了,div出现了横向滚动条(图下图所示),说明刚刚给table设置的2700px确实生效了;这也就是要求我们给table指定的宽度应该刚好是每一列的宽度之和,如果table宽度指定小了,那么他会从每一列中扣除多余的宽度,如果table宽度指定多了,他会给每一列加上相应的宽度,毕竟他要保证所有列不能超出table也不能填不满table。总之每个td的实际宽度会受到整个table的宽度影响,并不完全由td自身的width属性决定。

 然而很多时候我们并不能预判我们到底有多少列,每一列到底有多宽,因此我们很难一开始就给table设定一个准确的宽度,解决这个问题的办法也很简单,就是额外添加一个不指定宽度的列,这个列我们称之为自适应列,有了这一列后,table就不需要指定一个准确的宽度,而是设置一个比预估宽度稍大一些的值,多出的这部分宽度都会由该列自适应,因此我们修改源码,添加一个自适应列:

复制代码

                <tr class="form-row">
                    <th class="form-header" width="40px"></th>
                    <th class="form-header" v-for="col in 26" width="100px">{{String.fromCharCode(col+64)}}</th>
                    <th></th><!--自适应列-->
                </tr>
                <tr class="form-row" v-for="row in 40">
                    <td class="form-header" width="40px">{{row}}</td>
                    <td class="form-cell" v-for="col in 26"></td>
                    <td></td><!--自适应列-->
                </tr>

复制代码

 同时把table宽的设置为3000px:

        .form-table{
            border-spacing:0;
            width: 3000px;/*设置一个稍大的宽度*/
        }

效果如下图所示,最右侧这一列会自动适应多出的宽度,在不调整列宽的情况下这样就OK了。如果要调整列宽请看第五节内容。

关于table中td宽度的更多说明可以参考链接:http://www.cnblogs.com/mqingqing123/p/6163140.html

四、解决table表头固定的问题

本例中table第一行和第一列都属于Excel表格的表头,需要固定不动。网上有解决方案就是使用两个table,一个做表头,一个做表身,这种方案只能解决列表头的问题,如果要同时解决固定行表头和列表头的问题,可能需要三个table,这样的方案会让页面布局变得十分复杂,难以维护,违背我们简单直观的初衷。

为了让代码尽量简洁而优雅,有没有基于当前这一个table的办法呢?当然有,网上已经有人介绍过了,那就是将表头采用relative布局,并通过滚轮事件实时更新表头位置:

1. 给第一行表头添加col-header类,给第一列表头添加row-header类,注意:左上角第一个单元格,既是行表头又是列表头

2. 给所有表头单元格设置样式position: relative,并加上底色(表头得看上去像表头的样子)

3. 监听外层div的滚动事件,实时更新列表头的top值为div的scrollTop值,实时更新行表头的left值为div的scrollLeft值,这一步是关键,主要是保持表头的位置,让表头单元格不随着滚动条的滚动而移动。

html代码:

复制代码

                <tr class="form-row">
                    <th class="form-header col-header row-header" width="40px"></th>
                    <th class="form-header col-header" v-for="col in 26" width="100px">{{String.fromCharCode(col+64)}}</th>
                    <th class="form-header col-header"></th>
                </tr>
                <tr class="form-row" v-for="row in 40">
                    <td class="form-header row-header" width="40px">{{row}}</td>
                    <td class="form-cell" v-for="col in 26"></td>
                    <td></td>
                </tr>

复制代码

css代码:

复制代码

        .form-header{
            font-weight: normal;
            text-align: center;
            position: relative;/*设置相对定位*/
            background-color: #f7f7f7;/*表头背景色*/
        }

复制代码

js代码:

$(".form-frame").scroll(function () {
    $(".col-header").css('top',$(".form-frame").scrollTop()); //实时更新第一行表头的位置,让他不随滚动条滚动而移动
    $(".row-header").css('left',$(".form-frame").scrollLeft()); //实时更新第一列表头的位置,让他不随滚动条滚动而移动
})

效果如下图所示,可以实现内容滚动,而横竖表头都不动。

 然而如图所示还有一点问题,就是左上角压盖的问题,这个问题也很简单,我们只需要把第一行第一列的单元的z-index值设置为1即可,让它始终在其他单元格上面就不会被覆盖了。

 这种方案在chrome上表现十分完美,但是在ie会出现表头闪烁的问题,估计与ie触发滚动事件的频率或机制有关,如果对浏览器没有要求,那么十分推荐这种方式,如果无法容忍ie上的表头闪烁问题,那就只能另想办法了。

五、table动态调整列宽和行高

所谓动态调整列宽和行高,就是通过鼠标拖动表头单元格之间的分割线来实现行高和列宽的调整,可以参考这一片文章:https://blog.csdn.net/zanychou/article/details/46988529,基本思路如下:

1. 监听表头单元格的mousedown、mousemove和mouseup事件,

2. 通过鼠标坐标位置来判断是否处于可拖动区域,可以定义表头单元格分割线及其左右(上下)两边5px范围内为可拖动区域,如下图所示,

3. mousedown记录要调整的td及其原始宽度和坐标,mousemove实时计算新的宽度,mouseup结束拖动,

在上述参考文章的基础上,笔者稍做了些调整,基本思路不变,调整点有如下几项:

1. 让行表头和列表头都能动态调整列宽,但是第一列和第一行固定不动(表头本身的宽高要固定)

2. 两个单元格分割线的的两侧都可以拖动(原文只能拖动分割线的左侧区域)

3. 让整个table的宽度随着列宽的调整一起调整(保证其他列宽度不变,此处衔接上面第三节留下的疑问,原因参考上面的第三节)

4. 监听了整个table的mousemove和mouseup事件,让鼠标拖动操作不至于必须保持在表头单元格上,这样交互体验会更好。

关键代码如下黄色背景标记:

html:监听相关事件,同时为了让调整行高列宽的js能够生效,必须把第一行的单元格的宽度和第一列单元格的高度定义在html中,而不是在css中,如下:

复制代码

<table class="form-table" @mousemove="table_mousemove" @mouseup="table_mouseup">
    <tr class="form-row">
        <th class="form-header col-header row-header all-header"></th>
        <th class="form-header col-header" v-for="col in 26" width="100px"
            @mousedown="col_header_mousedown" @mousemove="col_header_mousemove">
            {{String.fromCharCode(col+64)}}        </th>
        <th class="col-header"></th>
    </tr>
    <tr class="form-row" v-for="row in 40">
        <td class="form-header row-header" height="28px"
            @mousedown="row_header_mousedown" @mousemove="row_header_mousemove">
            {{row}}        </td>
        <td class="form-cell" v-for="col in 26"></td>
        <td></td>
    </tr></table>

复制代码

css:为了保证第一行第一列即表头本身的行高列宽不变,因此把第一个单元格的高宽放到css中而不是放在html,这样动态调整行高列宽的js就对第一行第一列不生效了。

复制代码

/*第一行第一列单元格*/.all-header{
    z-index: 1;
    height: 28px;
    width: 40px;
}

复制代码

js代码:仅列了列宽的动态调整,行高的逻辑与之相似

复制代码

var vue = new Vue({
    el:'#app',
    data:{        //记录当前正在调整行高和列宽表头单元格        resize_header:{
            row_header: null,
            col_header: null
        },
    },    //初始化固定表头
    mounted:function(){
        $(".form-frame").scroll(function () {
            $(".col-header").css('top',$(".form-frame").scrollTop());
            $(".row-header").css('left',$(".form-frame").scrollLeft());
        })
    },
    methods:{        //鼠标点击列表头(第一行)
        col_header_mousedown:function (event) {            //判断有效区域,单元格分割线前后5个像素
            if (event.offsetX >= event.target.offsetWidth - 5 && event.buttons == 1) {                this.resize_header.col_header = event.target; //当前单元格
            } else if (event.offsetX < 5 && event.buttons == 1) {                this.resize_header.col_header = $(event.target).prev()[0];//左侧单元格            }            //记录表头原始属性
            if(this.resize_header.col_header != null){                this.resize_header.col_header.oldX = event.clientX;                this.resize_header.col_header.oldWidth = this.resize_header.col_header.offsetWidth;
                $(".form-table")[0].oldWidth = $(".form-table").width();//记录整表宽度            }
        },        //鼠标在第一行移动,改变光标符号
        col_header_mousemove:function (event) {            //改变光标样式
            if (event.offsetX >= event.target.offsetWidth - 5 || event.offsetX < 5)
                event.target.style.cursor = 'col-resize';            else
                event.target.style.cursor = 'default';
        },        //鼠标拖动中,实时计算新的宽高
        table_mousemove:function (event) {            //调整列宽
            var c_header = this.resize_header.col_header;            if(c_header != null){                if(c_header.oldWidth + event.clientX - c_header.oldX > 10){
                    c_header.width = c_header.oldWidth + event.clientX - c_header.oldX;
                    c_header.style.width = c_header.width;
                    $(".form-table").width($(".form-table")[0].oldWidth + event.clientX - c_header.oldX);//同步调整表格宽度                }
            }
        },        //表格鼠标抬起,清空记录
        table_mouseup:function (event) {            this.resize_header.col_header = null;
        }
    }
});

复制代码

效果图如下:

 

六、table中td单元格单选和多选(鼠标拖动选中或叫拉框选中)

单元格的选中是一个非常重要的功能,很多Excel其他功能都是针对当前选中单元格的,这里我们主要讨论通过鼠标交互的单元格单选和多选,因此我们需要给每个单元格监听三个鼠标事件:mousedown、mouseover和mouseup。

  1. 通过mousedown实现单选和确定当前激活单元格,而不使用mouseclick,因为click需要等鼠标按键抬起才会触发,而我们要求鼠标点下立即触发(可以参考MS Excel的交互机制);

  2. 使用mouseover实现鼠标拖动时触发区域多选,而不使用mousemove,因为mouseover只会在一个单元格内触发一次,而mousemove在鼠标移动过程中会不停的触发,影响性能而且没有必要;

  3. mouseup中做状态清除工作。

6.1 样式分析

首先我们来分析一下选中区域的样式,有一个焦点单元格背景为白色,其他选中单元格背景为浅绿色,最外围单元格存在绿色加粗边框线:

通过简单的分析我们可以用以下6个class来拆分这些样式,最后将这些class分别叠加到相应的单元格上即可:

  • .cell-select: 浅绿色背景,应用到所有选中单元格上,后面可以通过该class一次性获取所有选中单元格

  • .cell-focus: 白色背景,应用到焦点单元格上,覆盖第一个class

  • .cell-select-top: 带有上边框,应用在最上面的单元格上

  • .cell-select-right: 带有右边框,应用在最右边的单元格

  • .cell-select-bottom: 带有下边框,应用在最下边单元格

  • .cell-select-left: 带有左边框,应用在最左边单元格

将以上6个class用到对应的单元格上即可呈现上图所示的选中效果,例如上图中第一个单元格同时拥有:.cell-select、.cell-focus、.cell-select-left、.cell-select-top四个样式。

6.2 位置分析

所谓位置分析即,根据鼠标点击和移动的位置提取出所有选中的单元格,然后才能给他们设置相应的样式,为了方便处理位置信息,我们给所有单元格添加一个row和col属性,用于标记该单元格的行列坐标位置:

复制代码

<table class="form-table" @mousemove="table_mousemove" @mouseup="table_mouseup">
    <tr class="form-row">
        <th class="form-header col-header row-header all-header"></th>
        <th class="form-header col-header" v-for="col in 26" width="100px"
            @mousedown="col_header_mousedown" @mousemove="col_header_mousemove">
            {{String.fromCharCode(col+64)}}        </th>
        <th class="col-header"></th>
    </tr>
    <tr class="form-row" v-for="row in 40">
        <td class="form-header row-header" height="28px"
            @mousedown="row_header_mousedown" @mousemove="row_header_mousemove">
            {{row}}        </td>
        <td class="form-cell" v-for="col in 26" v-bind:row="row" v-bind:col="col"
            @mousedown="cell_mousedown" @mouseover="cell_mousemove" @mouseup="cell_mouseup"></td>
        <td></td>
    </tr></table>

复制代码

1. 监听所有单元格mousedown事件,触发该事件的单元格即为起始单元格,也是焦点单元格,记录到全局变量focus_td中,代码略

2. 监听所有单元格mouseover事件,触发该事件的单元格即为当前单元格起始单元格当前单元格之间的位置关系根据鼠标移动方向不同有以下四种:

不论是哪一种方向,我都转换为第一种类型,即转换为通过左上角坐标和右下角坐标定位的方式,设 fromTd 为起始单元格,toTd为当前单元格,那么设置选中区域核心代码如下:

js代码(在mouseover事件中调用):

复制代码

//选中指定两个单元格之间的所有单元格region_select:function (fromTd, toTd) {    //清除之前的选区
    this.remove_select();    //获取两个单元格的坐标数据
    var f_row = Number(fromTd.attr("row"));    var f_col = Number(fromTd.attr("col"));    var t_row = Number(toTd.attr("row"));    var t_col = Number(toTd.attr("col"));    //提取左上角坐标和右下角坐标
    var ltRow = f_row <= t_row ? f_row : t_row; //左上角对应行
    var ltCol = f_col <= t_col ? f_col : t_col; //左上角对应列
    var rbRow = f_row >= t_row ? f_row : t_row; //右下角对应行
    var rbCol = f_col >= t_col ? f_col : t_col; //右上角对应列

    //根据坐标范围遍历单元格,设置相应的样式
    var table = fromTd[0].offsetParent;    for(var r=ltRow; r<=rbRow; r++){        for(var c=ltCol; c<=rbCol; c++){
            table.rows[r].cells[c].classList.add("cell-select");            if(r==ltRow) table.rows[r].cells[c].classList.add("cell-select-top");            if(r==rbRow) table.rows[r].cells[c].classList.add("cell-select-bottom");            if(c==ltCol) table.rows[r].cells[c].classList.add("cell-select-left");            if(c==rbCol) table.rows[r].cells[c].classList.add("cell-select-right");
        }
    }
},//清除所有选中效果remove_select:function () {
    $(".cell-select").removeClass("cell-select");
    $(".cell-select-top").removeClass("cell-select-top");
    $(".cell-select-right").removeClass("cell-select-right");
    $(".cell-select-bottom").removeClass("cell-select-bottom");
    $(".cell-select-left").removeClass("cell-select-left");
}

复制代码

具体的事件监听及其处理逻辑代码略 

七、table中合并单元格与拆分单元格

在上一步完成后,就可以开始做单元格合并与拆分了,即将当前选中区域的所有单元格合并,或将已经合并的单元格拆分。

7.1 合并单元格

table标签本身就支持合并单元格,这也是一开始技术选型使用table而不是div的好处之一,具体方法看图分析如下:

1. 选区中第一个单元称之为扩展单元格,本例中只需要设置该单元格的colspan=3,rowspan=4,即可达到扩展的效果,即合并单元格效果

2. 选区中其他单元格称之为被合并单元格,被合并单元格如果不做任何处理,会被扩展单元格挤开而向两边顺延导致整个table不规则;如果直接把这些被合并的单元格remove掉,那么后面做拆分单元格的时候又需要重新create出来;因此最好的处理办法是将他们设置为display:none,拆分单元格的时候去掉display样式即可。

3. 为了后面做拆分单元格更加方便,我们需要把这一次合并的单元做一个统一的标记,例如统一添加一个merged-by属性,属性值为扩展单元格的行列坐标。

复制代码

//合并当前选中的所有单元格merge:function () {    var first = $(".cell-select:first");    var last = $(".cell-select:last");    if(!first.is(last)){        var ltRow = Number(first.attr('row'));        var ltCol = Number(first.attr('col'));        var rbRow = Number(last.attr('row'));        var rbCol = Number(last.attr('col'));        var rest_cells = $(".cell-select:gt(0)");
        rest_cells.addClass("cell-removed"); //即display:none
        rest_cells.attr("merged-by", ltRow + '_' + ltCol); //添加merged-by标记,方便后期拆分单元格
        first.attr("colspan", rbCol - ltCol + 1);
        first.attr("rowspan", rbRow - ltRow + 1);        this.region_select(first, first); //选中合并后的单元格    }
}

复制代码

7.2 拆分单元格

拆分单元格实际上就是合并单元格的逆过程:

1. 把当前要拆分单元格的colspan和rowspan属性去掉

2. 把之前被合并的单元格根据merged-by属性一次性选出来(此处是关键),去掉display属性,去掉merged-by标记

复制代码

//取消合并单元demerge:function () {    var colspan = Number(this.focus_td.attr("colspan"));    var rowspan = Number(this.focus_td.attr("rowspan"));    if(colspan > 1 || rowspan > 1) {        //去掉colspan、rowspan
        this.focus_td.removeAttr("colspan");        this.focus_td.removeAttr("rowspan");        //根据merged-by找到被合并的单元格
        var flagAttr = this.focus_td.attr("row") + '_' + this.focus_td.attr("col")        var merged_cells = $(".cell-removed[merged-by="+flagAttr+"]");
        merged_cells.removeClass("cell-removed"); //去掉display:none
        merged_cells.removeAttr("merged-by"); //去掉merged-by标记
        this.region_select(this.focus_td, merged_cells.last()); //选中拆分后的区域    }
}

复制代码

效果如图所示

 

八、针对第六节中单元格选中的重构

当有了合并单元格后,第六节中的单元格选中功能就存在bug了,可能存在如下情况:

因此一旦选区中包含了合并的单元格,那么整个选区范围的计算就不一样了,此时我们需要把所有相关的合并单元格都要纳入到选区范围计算中来。如果整个表格中存在多个合并单元格,情况会变得更加复杂:每次纳入一个合并单元格后,选区范围可能会扩大,选区范围扩大后可能会再与另一个合并单元格相交,这时就需要继续扩大选区,直到再没有与其他合并单元格相交为止,已经变成一个递归问题了,考虑性能问题,还是转换为循环问题处理。

本文采用边缘扫描法来实现循环扩展选区:

    1. 给定一个初始的左上角和右下角单元格坐标;

    2. 扫描初始坐标构成的矩形边缘单元格,寻找是否存在合并单元格(有merged-by属性或colspan属性);

        2.1. 如果找到了合并单元格,获取该合并单元格左上角和右下角坐标,并与初始坐标范围对比;

            2.1.1 如果超出了初始坐标,则扩大初始坐标至可以包含该合并单元格,返回到第1步;

            2.1.2 没有超出坐标则继续;

        2.2. 如果没有找到则继续;

    3. 最终得到的坐标范围即为当前完整的选区。

下图说明了整个边缘扫描算法的选区扩大过程:

修改之前的region_select方法如下:

 View Code

效果图如下,图中“起”表示鼠标开始位置,“止”表示鼠标结束位置,由于受图中三个合并单元格的影响,整个选取扩大至刚好能包含三个合并单元格的范围:

九 其他相关功能

前面主要探讨了:表格布局,固定表头、动态调整行高列宽,鼠标区域选中,合并与拆分单元格等功能的实现原理,这些仅仅是Excel中最基本的交互操作,还有其他一些基本功能可以在此基础上延伸,例如整行整列选中、文字对齐、字体字号、边框设置、背景设置等等,大多数情况下只需要通过.cell-select样式获取到当前选中的单元格,然后应用相应的样式即可,因此单元格选中是基础功能中的基础功能。

最后,笔者非专业前端开发出身,欢迎大家批评指正。


本文由豆瓜网专栏作家 豆瓜 投稿发布,并经过豆瓜网编辑审核。

转载此文章须经作者同意,并附上出处(豆瓜网)及本页链接。

若稿件文字、图片、视频等内容侵犯了您的权益,请联系本站进行 投诉处理

相关搜索

webexcel
图标 图标

豆瓜

豆瓜网

豆瓜网专栏

  • webexcel制作详解

    图标
    豆瓜 图标 · 今天 21:56:25 · 0浏览
  • measurestring字符串的像素长度

    图标
    豆瓜 图标 · 今天 21:55:18 · 9浏览
  • css样式大全详解

    图标
    豆瓜 图标 · 今天 21:34:18 · 8浏览
  • 全部评论

    豆瓜

    豆瓜网

    豆瓜网专栏

  • webexcel制作详解
  • measurestring字符串的像素长度
  • css样式大全详解
  • directoryindex默认索引页面详解
  • validaterequest指令作用详解
  • 我来说两句