65.9K
CodeProject 正在变化。 阅读更多。
Home

从零开始的轻量级 HTML5 网格

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (45投票s)

2015年6月8日

CPOL

12分钟阅读

viewsIcon

79537

downloadIcon

2626

介绍如何从零开始创建一个半高级、可扩展的 HTML5 网格。这真的不像许多人想象的那么艰巨!

在此查看网格的实际效果

引言

本文介绍了如何从零开始创建一个 HTML5 网格。它探索了一种技术,即先创建带有占位符的网格骨架,然后按需填充这些占位符。自己编写网格的优势在于,您可以完全控制网格的功能。如果需要更改或添加任何内容,正如我在过去几年中发现的那样,这可以在数小时内完成,而不是数天。

在此仔细“实时”查看该网格。源代码可在 GitHub 上以 BlueSkyGrid 的名义获取

背景

构建自己的控件的想法是一个争议激烈的话题。尤其是网格,它被认为是一个复杂的控件,更是属于这一类。

虽然购买一个现成的网格确实能让你“开箱即用”地获得许多功能,但这确实是有代价的。我们发现,如果你想要的功能不是网格原生提供的,你就会束手无策。是的,你可以购买源代码并尝试扩展它,但这通常并不那么容易。而且当供应商发布新版本或更新时,还需要进行管理。

别误会,购买现成的网格也有优势,它可以快速启动和运行,并且可能与其他一些组件(尽管是来自同一供应商的)很好地配合。

所以,如果你愿意妥协,那么购买一个网格可能适合你。

然而,正如我将在本文中演示的那样,编写自己的网格实际上并不像你想象的那么难。最重要的是,你可以得到你想要的确切功能,如果有什么东西不工作,你可以自己快速找到并修复它,而不是无休止地检查供应商的常见问题解答和论坛,那里人们可能有“变通方法”,而这些方法在新版本发布时可能会失效。

网格功能

该网格目前提供以下功能

  • 列宽调整,
  • 列重排,
  • 排序,
  • 分页,
  • 弹性列 - 单独一列,用于填充网格中的任何空白区域。(调整弹性列的大小可禁用其弹性功能,只需双击列调整器即可重新激活此功能!)
  • 货币

目前它缺少

  • 筛选器(不过,目前可以限制提供给网格的数据!)

创建你的第一个网格

你有一个 DOM 元素,希望在其中插入一个网格。因此,首先定义一组列定义(Column Definitions)来表示你希望显示的数据。ColDefs 告知网格*什么*、*哪里*以及*如何*显示其数据。请注意,可以提供更多数据,但如果没有找到匹配的 ColDef,这些数据将不会被显示。我们还(可选地)定义了一些 CurrencyInfo 对象,这样网格就可以查找可能需要此功能的字段的任何货币符号。

下面的代码在你的客户端代码中定义,你从那里创建和管理你的网格。

// one-off, define some currencies for the CurrencyManager so it can return symbols for the grid
__cm.setCurrencyInfo(new __cm.CurrencyInfo("GBP", "British Pound", "£", "GBP", ""));
__cm.setCurrencyInfo(new __cm.CurrencyInfo("USD", "US Dollar", "$", "USD", ""));
__cm.setCurrencyInfo(new __cm.CurrencyInfo("EUR", "Euro", "€", "EUR", ""));

// define the column definitions for the grid
this._coldefs = [];
this._coldefs.push(new __gc.ColDefinition("code", "Code", 100, "", "", "", "asc"));
this._coldefs.push(new __gc.ColDefinition("fullname", "Fullname", -1));     
this._coldefs.push(new __gc.ColDefinition("county", "County", 110, "", "", "", "", "", true));
this._coldefs.push(new __gc.ColDefinition("currency", "Currency", 90, "", "", "center"));
this._coldefs.push(new __gc.ColDefinition("valuation", "Valuation", 110, "number", "0,0.00", "right", "", "currency"));
this._coldefs.push(new __gc.ColDefinition("price", "Price", 110, "number", "0,0.00", "right", "", "currency"));
this._coldefs.push(new __gc.ColDefinition("myimage", "Img", 50, "image", "", "center"));
this._coldefs.push(new __gc.ColDefinition("created", "Created", 150, "date", "", "center"));

让我们仔细看看“Price”这一行。该行定义了一个名为“price”的属性的 ColDefinition,该属性作用于给定的数据行。在这种情况下,我们指定表头应显示“Price”,宽度为 110 像素,告诉该列为数字类型,并通过使用 numeral.js 应用格式化('0,0.00')。如果字段是“date”类型,它会调用 moment.js 进行格式化。该列还应右对齐,并查看“currency”数据字段以获取其货币符号。提供一个货币列查找将强制网格查找等效的符号并显示它。

现在我们有了一组 ColDefinitions,我们找到要放置网格的 DOM 元素,创建一个 GridController,并请求创建网格骨架(如下面的代码片段所示)。

现在网格已完全创建并准备好接受数据,我们创建一些示例数据并将其(连同 coldefs)交给网格实例。

请注意,在此示例中,我们在网格创建后将 ColDefs 数组与数据本身一起提供。这表明你可以随时发送不同的数据,只要它附带一组匹配的 ColDefs!

// find the element we will place the grid in
var $grid = $('.mygrid');

// create a grid controller
this._gc = new __gc.GridController();
this._gc.createGrid($grid);

// create some sample data
var data = __data.generateSampleData(rowcount);

// simply pass on the data (and their definitions) to the grid
this._gc.setData(data, this._coldefs).done(function () {
    ...
});

...

这就是将网格放置在你的占位符元素中所需的全部操作。

请注意,对 setData 的调用返回一个 jQuery promise,通知调用者网格创建完成的时间。

代码

代码太多,无法详细描述网格代码,因此在本文中,我将讨论核心原则,并重点介绍一些关键代码部分。我建议复制 zip 文件或在 github 上查看此代码以获得更深入的理解。

值得一提的是,我有 C# 背景,因此我像拥抱失散多年的孩子一样拥抱了 Typescript。它让我能够保持面向对象的思维模式,轻松地从其他类派生或实现接口,拥有类型安全以及许多其他好东西!

如前所述,完整的源代码可以在 GitHub 上找到,一个功能齐全的工作示例可以在我远未完成的博客网站上看到。如果有足够的兴趣,我会随着时间的推移编辑和增强这个网格。

网格构建

创建此网格实例的核心思想是它在代码中构建,并使用“createGridStructure”函数注入到目标 div 中。此函数在代码中构建一个字符串,该字符串被转换为 DOM 元素(使用 jQuery),然后注入到等待的目标元素中。

然后获取对 $header 和 $datarows 部分的句柄,之后填充这些部分。同样,首先在代码中构建,然后注入到各自的占位符中(分别为 createHeader 和 createRows)。

该网格基于三部分信息,即*实际数据*、一个描述显示此数据的列的*列定义数组*,以及我们将注入最终网格的*目标 DOM 元素*。

列定义描述了每一列,包括使用数据行中的哪个数据字段、标题、格式、对齐方式、类型(字符串、数字、日期、图像等)以及更多信息。

下面是用于生成此网格的核心类的概述

 

 

DataEngine.ts

创建 DataEngine 的实例是为了分离控制网格操作和数据操作的关注点。DataEngine 的工作是持有原始数据,并管理一组准备好的数据,控制器在构建行时会调用这些数据。这可以在以后扩展,以包括数据过滤、分组和/或编辑的流水线。

GridController.ts

GridController.ts 专注于从头开始创建所有注入到给定 DOM 元素中的 HTML。这意味着所有这些行在重新排序和列重排时都会被重新创建。在调整列大小时,所有受影响的单元格都在代码中进行调整,因此速度更快。重新创建所有行并不是世界末日,正如我发现的(1000 行仍然是亚秒级的)。

CurrencyMananger.ts

当 ColDefinition 标识一个货币查找字段时,currencyManager 会接收该字段的值(如“USD”),并返回其符号(“$”),然后该符号将用作单元格格式化的一部分。

 

单行内的所有网格单元格都使用 CSS flex-box 并排放置。我整理了一些有用的 CSS 类,调用了 CSS flex-box,我在很多地方都使用它们,比如当我在一个父 div 中有几个 div,并且其中一个 div 需要填满任何可用空间时。

查看下面的示例——有一个父 div(高度可变),它包含两个固定高度的顶部 div 和一个底部 div,然后我希望任何剩余部分被填满。要实现这一点,只需将父类设置为 'flex-parent-col',并让其中一个子元素的类设置为 'flex-child',就这样。对于行等效项,有一个 'flex-parent-row'。

网格行也建立在此结构之上。行本身用 'flex-parent-row' 类装饰,其子元素之一将设置其类为 'flex-child' 以填充可用空间。

GridController.ts

以下所有代码示例都包含在 GridController.ts

以下代码展示了网格核心结构的创建。它生成一个字符串,然后该字符串被转换为 DOM 元素并注入到相关的占位符中。

private createGridStructure(showBorder: boolean): string {
    var s: string = "";

    // --------------------
    // define the grid structure
    // --------------------
    var s: string = "";

    s += "<div class='bs-grid flex-parent-col " + (showBorder ? "border" : "") + "' >";

    s += "  <div class='spinner' style='display: none'}> <div> <i class='fa fa-spinner fa-spin fa-3x'></i> </div> </div>"

    s += "  <div class='header-containment'> </div>"

    s += "  <div class='header' >"
    s += "      <div class='resize-marker' title='resize this column or double-click to make it the flex-column'> </div>"
    s += "      <div class='insert-marker'>";
    s += "          <i class='down fa fa-caret-down fa-2x'></i>";
    s += "      </div>";
    s += "      <div class='header-template' style='position: relative'> </div>"
    s += "  </div>";

    s += "  <div class='data-scrollable flex-child flex-scrollable' >";
    s += "      <div class='data'> </div>";
    s += "      <div class='full-resize-marker'> </div>"
    s += "  </div> "

    s += "  " + this.createPager();

    s += "</div>";

    return s;
}

private createPager(): string {
    var s: string = "";

    s += "      <div class='pager'>"

    s += "          <button type='button' class='btnFirstPage pager-button'> <i class='fa fa-step-backward' title='First page'></i> </button> ";
    s += "          <button type='button' class='btnPrevPage pager-button'> <i class='fa fa-caret-left fa-lg' title='Previous page'></i> </button> ";
    s += "          <span class='pager-text' style='margin: 0px 4px 0px 4px'></span>";
    s += "          <button type='button' class='btnNextPage pager-button'> <i class='fa fa-caret-right fa-lg' title='Next page'></i> </button> ";
    s += "          <button type='button' class='btnLastPage pager-button'> <i class='fa fa-step-forward' title='Last page'></i> </button> ";

    s += "          <span style='margin: 0px 2px 0px 20px'>page size: </span>";
    s += "          <select class=page-size title='Number of rows per page'></select>";

    return s;
}

调用方式如下

...

// create a grid string
var grid = this.createGridStructure(showBorder);

// create a DOM grid 
this.$grid = $(grid);

// add this grid to the given dom element
this.$grid.appendTo($el);

// get a handle to some important data parts
this.$header = $('.header-template', this.$grid);
this.$datarows = $('.data', this.$grid);
this.$pager = $('.pager', this.$grid);
this.$resizemarker = $('.resize-marker', this.$grid);
this.$resizeline = $('.full-resize-marker', this.$grid);
this.$insertmarker = $('.insert-marker', this.$grid);
this.$sgloading = $('.spinner', this.$grid);
this.$datascrollable = $('.data-scrollable', this.$grid);

...

所以我们首先调用 'createGridStructure',它以字符串形式返回网格的整个结构。然后我们将这个字符串转换为一个 jQuery DOM 元素,并将其附加到给定的 DOM 元素上。

瞧,我们的网格有了结构,并被放置在客户端 DOM 树中。

然后我们获取一些重要元素的句柄,如 header、data-area、resizemarker 以及我们希望控制的其他各种元素。请记住,此时我们仍然只有骨架布局,所以现在让我们创建 header 和 rows 部分。

header 和 datarows 的创建遵循与网格骨架相同的模式。首先我们构建表示完整 header(或 data-rows)的字符串,然后我们用这些字符串创建真实的 DOM 元素,并将它们注入到我们的占位符(分别为 $header 或 $datarows)中。

让我们看一下构建这个 header 的代码

private createHeader(): string {

    var self = this;
    var headerTemplate: string = "<div class='row-header flex-parent-row' > ";

    $.each(this.colDefinitions, function (index, coldef: ColDefinition) {

        // define the css classes we apply to each header column
        var cssclasses: string = "cell-header cell-right-column ";
        cssclasses += coldef.classAlign + " ";
        if (coldef.isFlexCol) cssclasses += "stretchable ";
            
        // get and lowercase the colName allowing us to get hold of the appropriate column when taking actions like sorting, resizing etc.
        var colName = coldef.colName.toLowerCase();
        var hitem: string = "";
        hitem += "<div class='" + cssclasses + "' data-sgcol='" + colName + "' style='width: " + coldef.width + "px;'>";
        hitem += "<span>" + coldef.colHeader + "</span>";
        hitem += "<span class='" + self.getSortSymbol(coldef) + "'></span>";
        if (coldef.isFlexCol) 
            hitem += "<i class='pull-right fa fa-arrows-h' style='background-color: transparent; color: rgb(201, 201, 208);' title='this column is flexible sized'></i>";
        hitem += "</div>";
        headerTemplate += hitem;

    });
    headerTemplate += "</div>";
    return headerTemplate;
}

然后这样调用它

// create the header (string and DOM and append to placeholder)
var header = self.createHeader();

$(header).appendTo(self.$header);

然后我们就有了一个 header!

然后,同样的方法也适用于实际的数据行

private createRows(): string {
    var self = this;
    var s = "";

    for (var i = 0; i < self.dataEngine.baseDataPrepared.length; i++) {
        var dataItem: any = self.dataEngine.baseDataPrepared[i];

        // start row definition
        s += "      <div class='row flex-parent-row' data-pkvalue='" + dataItem.pkvalue + "' >";

        // step through each ColDefinition
        self.colDefinitions.forEach(function (coldef: ColDefinition) {

            // retrieve the actual raw cell value
            var myvalue = dataItem[coldef.colName];

            // check if any custom formatting is required
            myvalue = self.getFormattedValue(myvalue, dataItem, coldef);

            // ask the client to fill in any generic styling properties
            var styleProp: CellStyleProperies = self.cbStyling(coldef, dataItem);

            // start Cell definition
            s += "<div class='cell cell-right-column " + (coldef.isFlexCol ? "stretchable" : "") + " " + coldef.classAlign + "' ";
            s += "data-sgcol='" + coldef.colName + "' style='width: " + coldef.width + "px; ";

            // if a cell backcolour was given then apply it
            if (styleProp.cellBackColour) 
                s += " background-color: " + styleProp.cellBackColour + "; ";

            // if a cell forecolour was given then apply it
            if (styleProp.cellForeColour) 
                s += " color: " + styleProp.cellForeColour + "; ";

            s += "'>";  

            // check if this cell is merged with an image and we were given an image
            if (coldef.mergeWithImage && styleProp.imgName) 
                s += "<div class='merged-image " + coldef.colAlign + "' style='background-color: " + styleProp.imgBackColour + "; color: " + styleProp.imgForeColour + ";'> <i class='fa " + styleProp.imgName + "' > </i> </div>"

            // if this is an image colum then just show the image
            if (coldef.colType == "image") {
                var faImage = styleProp.imgName;  
                if (faImage.length == 0) faImage = myvalue;         // if no return value was given then use the actual data
                s += "<i class='fa " + faImage + "'\"></i>";
            }
            else
                // simply write out the actual cell value
                s += myvalue;

            s += " </div>"      // end cell definition

        });
        s += "      </div>";    // end row definition
    }

    return s;
}

再次像这样调用

// create all rows (string and DOM and append to placeholder)

var rows = self.createRows();

$(rows).appendTo(self.$datarows);

这里有几点需要注意。首先,我们遍历实际的(准备好的)数据项,对于每个数据项,我们遍历 ColDefinitions 数组,这为我们定义每个单独的单元格提供了足够的信息。

在定义单元格时,我们首先将原始值通过格式化程序处理,返回格式化后的值。然后我们通过分配任何适当的类以及设置宽度来装饰单元格。

单元格样式

为了允许客户端为每个单元格定义自定义样式,我们检查是否提供了自定义样式函数。如果提供了,我们用当前的 ColDef 和实际的数据行来调用它,让客户端在掌握所有可用数据的情况下做出决定。

这个想法是,对于每个单元格,客户端创建一个 CellStyleProperties 对象,GridController 在定义单元格时将使用该对象。可以设置的属性包括背景色和前景色、图像等。

为了了解每个单元格的自定义样式是如何工作的,让我们看一下下面的代码(它定义在客户端代码类中)

this._gc.cbStyling = function (coldef: __gc.ColDefinition, item: any) {

    // define a new CellStyleProperties object we will return
    var styleProp: __gc.CellStyleProperies = new __gc.CellStyleProperies();

    // now check which column is being checked and set any style properties appropriately 
    if (coldef.colName == "county") {
        if (item["county"] == "Kent" && Math.floor(item["price"]) % 2 == 0)
            styleProp.cellBackColour = "rgb(178, 232, 178)";
        if (Math.floor(item["price"]) % 4 == 0) 
            styleProp.imgBackColour = "rgb(255, 196, 8)";
        if (item["county"] == "Sussex")
            styleProp.imgForeColour = "red";
    }

    // image
    if (coldef.colName == "myimage") {
        if (item["price"] < 250)
            styleProp.imgName = "fa-floppy-o";
        else if (item["price"] < 500)
            styleProp.imgName = "fa-warning";
    }

    ...
    ...

    return styleProp;
};

该函数在构建每个单元格时都会被调用,这为我们提供了高度的定制化能力。

网格图像

你可能已经注意到,我首选的将图像放入网格的方法是使用 font-awesome 图像。由于它们通过使用特定类来装饰元素而工作,因此它们非常适合在代码中构建的网格。

关于事件处理的一点说明

在我们创建了网格骨架并获得了它的占位符之后,我们会为其中一些附加处理程序。这些包括导航按钮的点击处理程序、行单击和双击等等。

上述事件处理程序是“一次性”连接的,因为骨架不会被重新创建。网格头部则不同,它会频繁地被重新创建。因此,附加用于移动列的 jQuery-ui 的可拖动和可放置交互的代码被放在一个单独的函数中,并且每次创建头部时都会调用它。

选择一行

为了支持选择行的功能,DataEngine 确保将一个名为“pkvalue”的属性附加到每个生成的行。这个唯一值被放置在每个 DOM 行中,带有一个名为“data-pkvalue”的数据属性,这样当一行被点击时,jQuery 就可以轻松识别是哪一行,并向该行元素添加一个“selected”类。

Ajax

在本文中,网格从一开始就接收所有数据,这些数据由 DataEngine 持有和控制。尽管 javascript 似乎完全有能力持有和处理大量数据,但显然没有什么能阻止你扩展这个 DataEngine 并回调服务器获取分页数据。只需确保你使用 JQueryPromise<T> 作为从引擎的“refresh”调用返回的对象,并相应地调整你的代码。

结束语

希望本文已经证明,创建自己的网格是一个可行的提议。以网格目前的状态,增加更多功能真的不难。如果有足够的兴趣,我将增强网格以公开诸如过滤、公式列甚至可能是 ajax 调用等功能?

我为我的雇主创建了一个类似的网格,它有更多的功能,并使用 Knockout 绑定了许多属性和功能,如填充单元格内容、宽度、顺序、数据的动态更新等等。在这里,我试图避免使用 knockout 绑定,以保持简单,并通过代码完成所有操作。

请务必查看存放源代码的 GitHub 仓库,并随时 fork 代码和/或为该项目做出贡献!

关注点

起初,我对浏览器和 JavaScript 能够进行如此多次回调和如此多即时 DOM 操作的程度持怀疑态度,但可以说,JS 和编译器/优化器似乎完全有能力处理这个问题!必须为 JavaScript 编译器和优化器给予极大的赞誉!!

历史

版本 1.0 - 初始发布。

版本 1.1 - 更新演示网格的域名引用

© . All rights reserved.