组件

本系列作为基础教程《文档》的进阶内容,主要通过十类组件示例论述 xmlplus 组件的设计与实现。

概述

除了本章之外,本系列文章分十个部分,其中每个部分详细讲述了一类组件的设计与实现。本系列文章作为 文档 的一个进阶,在阅读之前请确保已经学习过 文档 中的相关内容。

文档 类似,本文档的相关示例代码位于目录 example/components/ 之下,现在以 选项卡 中的一个示例来说明如何找到示例源码。

// 05-01
Icon: {
    css: "#icon { width: 1.5em; height: 1.5em; display: inline-block; }",
    opt: { icon: "about" },
    xml: `<span id="icon"/>`,
    fun: function (sys, items, opts) {
        sys.icon.replace("icon/" + opts.icon).addClass("#icon");
    }
}

注意到示例开头的一个注释 05-01,那么你可以根据此注释定位到目录 /example/components/05-tabbar/01/,注释中的 05 即章节序,01 就是示例所在目录的名称。

下面给出的是各个章节的简单介绍,这可以作为阅读的一个索引。

  • 图标:介绍了文件图标、字体图标和 SVG 图标组件的设计与实现,其中 SVG 图标是重点

  • 按钮:学会封装现有开源框架提供的按钮与能够设计自定义按钮组件同样重要

  • 文本框:描述了几种具有不同外形或者功能的文本框的设计与实现

  • 列表:为了拥有好的体验,当列表有频繁更新数据的要求时,应该注意对列表组件实施相应的优化

  • 选项卡:这章讲述如何设计应用于手持设备的选项卡组件

  • 下拉刷新:在设计该组件时,应该把下拉刷新当成一个容器组件对象看待

  • 路由:具有普遍用途的视图栈组件,它实际上是一个路由组件

  • 分隔框:分隔框是一种布局组件,它允许嵌套使用

  • :这章实现了一个简单的树组件,你完全可以在此基础上创建复杂的树组件

  • 网格:这章设计的网格组件具有排序与过滤功能,这里关键要学会消息作用域的使用

图标

网页上使用的图标分可为三种:文件图标、字体图标和 SVG 图标。对于文件图标,下面仅以 PNG 格式来说明。

PNG 图标

对于 PNG 图标的引用,有两种方式。一种是直接由 HTML 元素 img 的 src 属性给出。下面是一个简单的示例。

// 01-01
Icon: {
    css: `#icon { width: 68px; height: 68px; }`,
    xml: `<img id='icon'/>`,
    fun: function (sys, items, opts) {
        this.attr("src", "img/" + this + ".png");
    }
}

这个示例的图标文件位于组件所在文件的子级目录 img/ 中。我们可以按如下的方式便捷地引用所需的图标。注意组件 Icon 巧妙地使 id 属性值与图片文件名关联,这样可以避免创建额外的属性。

// 01-01
<div id='index'>
    <Icon id='msg'/>
    <Icon id='home'/>
    <Icon id='contact'/>
</div>

另一种引用 PNG 图标的方式是给相应的对象添加 background-image 样式,并且由样式中给出图标所在路径。下面是一个简单的示例。

// 01-02
Icon: {
    css: `#icon { width: 68px; height: 68px; }`,
    xml: `<div id='icon'/>`,
    fun: function (sys, items, opts) {
        this.css("background-image", "url(img/" + this + ".png)");
    }
}

这种形式与前面由 img 标签给出的图标有许多相似之处。不同的是,前者动态指定的是 img 标签的 src 值 ,而后者动态指定的则是 div 元素的 css 样式。该组件与前面给出的 Icon 组件的使用方式完全一致,这里就不重复了。

对于以上给出的组件 Icon,使用的是离散的图标文件。实际应用中,通常给出的是一个包含许多图标的 PNG 文件。这种情况下该如何构建图标组件呢?请看下面给出的一种较为实用的方案。

// 01-03
Icon: {
    css: `#msg { background-position:  0 0; }
          #home { background-position: 0 -48px; }
          #contact { background-position: 0 -96px; }
          #icon { width: 68px; height: 68px; background-image: url(img/icons.png); }`,
    xml: `<div id='icon'/>`,
    fun: function (sys, items, opts) {
        sys.icon.addClass("#" + this);
    }
}

此组件在样式项 css 中直接给出了图标文件所在路径,以及各种图标在文件内的位置。并且图标实例 id 与相应图标类名对应。当然,组件的使用方式与前面给出的组件是一致的。

下面给出的是另一种组件设计方案,它把位置信息移到了函数项中。此方案是可行的,但组件的执行效率不如前者。该组件每次实例化都要生成位置信息一次,而对于前者,由于样式项在组件实例化时,仅生成一次,所以保证了组件的执行性能。

// 01-04
Icon: {
    css: `#icon { width: 48px; height: 48px; background-image: url(img/icons.png); }`,
    xml: `<div id='icon'/>`,
    fun: function (sys, items, opts) {
        var positions = {
            "msg": "0 0",
            "home": "0 -48px",
            "contact": "0 -96px"
        };
        sys.icon.css("background-position", positions[this]);
    }
}

注意,以上给出的一些图标组件的设计技巧同样也适用于图片组件的设计。

字体图标

字体图标通过引入包含图标的字体文件,将图标像文字一样使用。它与 PNG 图标相比,最关键一点在于它的矢量性。字体图标的引用方式有两种:通过类名的引用方式以及直接引用 unicode 的方式。

通过类名引用

这种类型的图标内容定义在样式项中,HTML 元素通过类名进行关联。

// 01-05
Msg: {
    css: `#msg { font-size: 48px; width: 48px; height: 48px; line-height: 48px; }
          #msg:before { content: '\\e608'; }`,
    xml: "<div id='msg'/>"
}

直接引用 unicode

这种引用方式与前一种在本质上没什么不同,它只是将图标内容由样式项转移到视图项中而已。

// 01-05
Home: {
    css: `#home { font-size: 48px; width: 48px; height: 48px; line-height: 48px; }`,
    xml: `<div id='home'>&#xe609;<div/>`
}

下面给出的示例展示了两种不同的引用字体图标的方式。注意,此示例简化了样式项中与兼容性相关的内容,详情请查阅配套源码。

// 01-05
Index: {
    css: `@font-face { font-family: 'iconfont'; url('font/iconfont.ttf') format('truetype');}
          #msg, #home { font-family: 'iconfont'; font-style:normal; }
          #index > * { display: inline-block; padding: 10px; background: #F9F9F9; }`,
    xml: `<div id='index'>
            <Msg id='msg'/>
            <Home id='home'/>
          </div>`
}

SVG 图标

最后来看看我们的重头戏,如何封装以及使用 SVG 图标。在 xmlplus 中,SVG 图标是推荐的图标使用形式,它允许直接嵌入代码,无需额外引用相关文件。

通过 xlink:href 引用

对于这种方式,首先你需要一个 svg 图标集,其包含的内容大概是下面这样子。

<!-- 01-06 -->
<svg>
    <symbol id="icon" width='48px' height='48px' viewBox='0 0 24 24'>
        <g><polygon points='9,16.2 4.8,12 3.4,13.4 9,19 21,7 19.6,5.6'/></g>\
    </symbol>
    <!-- 还可以有更多的symbol -->
</svg>

svg 图标集有两种存在方式,一个是以文件形式存在,这时 xlink:href 属性值需要明确指明文件的 url,下面是一个示例。

<!-- 01-06 -->
<svg>
   <use xlink:href='http://example.com/file.svg#home'/>\
</svg>

另一种形式是,图标集直接存在于页内,这种方式叫做页内引用,它无需指明 url,只要指定相应 symbol 的 ID 值就好了。

<!-- 01-07 -->
<svg>
   <use xlink:href='#home'/>\
</svg>

对 svg 图标的直接封装

相对于通过 xlink:href 引用图标,使用 xmlplus 的组件化技术直接封装会是一种更好的方式。请看下面的一个 SVG 图标组件。

// 01-08
Icon: {
    xml: "<svg width='48px' height='48px' viewBox='0 0 24 24'>\
            <g><polygon points='9,16.2 4.8,12 3.4,13.4 9,19 21,7 19.6,5.6'/></g>\
          </svg>",
    fun: function (sys, items, opts) {
        this.attr("fill", '' + this);
    }
}

这是一个钩形图标,组件中仅包含视图项以及函数项成份。根据函数项的内容可以知道,图标颜色由组件实例的 id 属性值给出。下面来看看如何使用该图标。

// 01-08
Index: {
    css: `#example > * { padding: 10px; background: #F9F9F9; }
          #example > *:hover { fill: #fff; background: #563d7c; }`,
    xml: `<div id='index'>
            <Icon id='red'/>
            <Icon id='green'/>
            <Icon id='blue'/>
          </div>`,
    fun: function (sys, items, opts) {
        sys.example.on("click", "*", e => console.log(this + " clicked"));
    }
}

此示例展示了三个不同颜色的图标,并且侦听了图标的点击事件,打开浏览器控制台,当点击不同图标时,可以看到相应的输出。

另外,有一种常见的 SVG 图标的封装方式,它把 SVG 文本经过 URL 编码后直接在 img 的 src 属性或者样式 background-image 中给出。就像下面这样子。

// 01-09
Icon: {
    css: `#icon {width: 16px; height: 16px; background-image: url('data:image/svg+xml;charset=utf-8,%3Csvg%20viewBox%3D...')}`,
    xml: "<div id='icon'/>"
}

这种方式与上一种方式比起来,有两个缺点:其一,你看不出 SVG 的源文件。其二,你失去了对 SVG 图标的操作权。当然,这种方式也并非不能用。如果你不需要对图标进行后续的操作,使用这种方式也是可以接受的。另外,与之相似的一种图标使用方式是对图标 base64 编码后的内嵌引用。下面是一个简单的示范:

// 01-10
Icon: {
    xml: `<img src='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIcAAA...' />`
}

这种方式与上一种 SVG 图标的封装方式是类似的。不过相对于 SVG 图标组件的直接封装,你同样失去了对图标的操作权。

按钮

除了图标组件以外,按钮也许是最简单的组件了,这章就来看看如何定义按钮组件。

使用原生按钮组件

在 xmlplus 中,HTML 元素也以组件的方式存在。所以,你可以直接通过使用 button 标签或者 input 标签来使用按钮组件。如下面的示例所示:

// 02-01
Index: {
    xml: `<div id='index'>
              <button>Default</button>
              <input type='submit'>Primary</input>
         </div>`
}

虽然原生按钮外观不那么吸引人,但原生按钮未经特殊包装,所以渲染起来最快,执行效率最高。

使用 Bootstrap 样式的按钮

如果你的项目在视觉上没有特别要求的话,使用 Bootstrap 样式来定义按钮组件是一个好主意。按传统方式使用 Bootstrap 按扭,你需要像下面这样使用:

<button type="button" class="btn btn-default">Default</button>
<button type="button" class="btn btn-primary">Primary</button>
<button type="button" class="btn btn-success">Success</button>

请认真观察,你是不是觉得它给你的比你要求的要多。你不但发现了好多的 type=button,还发现了好多的 btn。现在下面给出一个组件,它基于 Bootstrap 样式,但它明显地简化了按钮的使用方式。

// 02-02
Button: {
    xml: "<button type='button' class='btn'/>",
    fun: function (sys, items, opts) {
        this.addClass("btn-" + opts.type);
    }
}

此按钮组件封装了原始按钮需要重复书写的内容,在使用时,仅需提供 type 属性即可指明要生成的目标按钮,使用起来更为便捷。下面给出的是新定义的按钮组件的使用方式。

<!-- 02-02 -->
<Button type='default'>Default</Button>
<Button type='primary'>Primary</Button>
<Button type='success'>Success</Button>

带有图标的按钮

按钮上除了文字外,还可以附带图标。合适的图标可以使按扭的使用意图更加生动直观。这里以 EasyUI 的图标按钮为例来说明如何封装并使用图标按钮。我们首先来看看,EasyUI 图标按钮的原始使用方式。

<div style="padding:5px 0;">
    <a href="#" class="easyui-linkbutton" data-options="iconCls:'icon-add'">Add</a>
    <a href="#" class="easyui-linkbutton" data-options="iconCls:'icon-remove'">Remove</a>
    <a href="#" class="easyui-linkbutton" data-options="iconCls:'icon-save'">Save</a>
</div>

与上一节对 Bootstrap 按钮的封装类似,通过观察提炼出重复出现的部分,将变化的部分以接口形式展现。上面的按钮仅图标类型名和文本是可变的,所以我们可以做出如下的设计:

// 02-03
Button: {
    xml: "<a href="#" class="easyui-linkbutton"/>",
    fun: function (sys, items, opts) {
        this.attr("data-options", "iconCls:'icon-" + opts.type + "'");
        $(this.elem()).linkbutton();
    }
}

请注意该组件的函数项,由于这里的所以 HTML 元素都是动态生成的,所以需要使用函数 linkbutton 动态对按钮进行渲染,而不能指望 easyui 帮你自动帮你完成。下面是新图标的使用方式,它明显比原始的使用方式简洁多了。

<!-- 02-03 -->
<div style="padding:5px 0;">
    <Button type='add'>Add</Button>
    <Button type='remove'>Reomve</Button>
    <Button type='save'>Save</Button>
    <Button type='cut'>Cut</Button>
</div>

自定义按钮组件

使用类似 Bootstrap, EasyUI 等现成的开源框架,可以非常方便使用按钮。然而,当这些开源项目无法满足你的需求时,你就需要自己动手了。为简单起见,现在假定上述框架并不存在,那么如何设计一套具有 Bootstrap 样式的按钮呢?这样的实践是非常有意义的,它有助于你举一反三。

现在让我们重新对上面的按钮组件作观察。你会发现,Bootstrap 设计了一些可以组合的样式类,其中 btn 是每一个按钮都需要的,另外像 btn-defaultbtn-primary 以及btn-success 等等都根据需要与 btn 形成组合样式类。好了,根据这个思路,我们就可以设计出如下的组件框架。

// 02-04
Button: {
    css: `#button { 这里是按钮基本的样式 }
          #default { 这里是 default 样式 }
          #primary { 这里是 primary 样式 }
          #success { 这里是 success 样式 }`,
    xml: "<button type='button'/>",
    fun: function (sys, items, opts) {
        this.addClass("#" + opts.type, this);
    }
}

上述的设计思路与前面直接使用 Bootstrap 样式定义按钮不同点在于,前者已经为你定义好了各个全局的样式类,你只需要直接引用就可以了。而此处你需要在按扭组件内部自行定义相关样式类。从封装的角度看,后者的内聚性要强于前者,因为它并不暴露全局类名。注意,为了简化起见,这里的自定义按钮组件略去了 hoveractive 样式,所以与 Bootstrap 按钮有些不一样。下面是该按钮组件的使用示例。当然,它与前面封装的 Bootstrap 按钮的使用示例没什么不同。

<!-- 02-04 -->
<Button type='default'>Default</Button>
<Button type='primary'>Primary</Button>
<Button type='success'>Success</Button>

另外,切记一点,尽量避免定义功能大而杂的按钮组件。当然,定义其它类型的组件也是一样的。轻量、按需、足够使用就好。

文本框

文本框是页面中最常用的输入组件,这一章就来看看各种文本框组件的设计与使用。

原生的文本框

原生的文本框组件非常简单,它只包含一个 input 元素,下面是它的一个使用示例。

// 03-01
Index: {
    xml: "<input id='index' type='text'/>",
    fun: function (sys, items, opts) {
        sys.index.on("input", e => {
            console.log(sys.index.prop("value"));
        });
    }
}

对于组件对象文本的设置与获取,你需要使用系统函数 prop,而不能使用 attr,这与 JQuery 的相关接口的用法是一致的。

使用 Bootstrap 样式

Bootstrap 框架提供了不少输入框组的样式,与上一章一样,我们也可以通过封装以简化对它们使用。下面是一个简单的例子。

// 03-02
TextBox: {
    xml: `<div class="input-group">
              <span class="input-group-addon">https://example.com/users/</span>
              <input id="input" class="form-control" aria-describedby="basic-addon3">
          </div>`,
    fun: function (sys, items, opts) {
        return sys.input;
    }
}

该示例封装了一个允许输入 URL 剩余部分的网址输入框。注意,我们需要在函数项中导出原始文本框对象的系统接口,这样才能方便地对其进行后续操作。

具有格式化功能的文本框

在官方文档中的 参数映射 的相关内已经讲过如何自定义一个可以进行格式化输入输出的文本框,现将已定义的文本框组件重新列出如下:

// 03-03
TextBox: {
    xml: "<input id='input' type='text'/>",
    opt: { format: 'string' },
    map: { attrs: { input: "disabled value placeholder readonly" } },
    fun: function (sys, items, opts) {
        var parse = {"int": parseInt, "float": parseFloat, "string": String}[opts.format];
        function getValue() {
            return parse(sys.input.prop("value"));
        }
        function setValue(value) {
            sys.input.prop("value", parse(value));
        }
        return Object.defineProperty({}, "value", { get: getValue, set: setValue });
    }
}

自定义文本框的基本思路是封装原生的文本框组件并对其进行扩展,上面给出的文本框增加了原文本框的格式化输入输出能力。上一节给出的使用 Bootstrap 样式封装的文本框本质上也可以归类为自定义文本框的一种,只是它利用了第三方的内容。

带有选择定位文本功能的文本框

这一节我们来看一个带有选择文本功能的文本框组件。该组件包含两个接口,其中 select 用于选中指定开头和结尾的文本,focus 则用于控制光标的位置。

// 03-04
TextBox: {
    xml: "<input id='input' type='text'/>",
    map: { attrs: { input: "disabled value placeholder readonly" } },
    fun: function (sys, items, opts) {
        var e = sys.input.elem();
        function select(start, end){
            start == undefined && (start = 0);
            end == undefined && (end = e.value.length);
            e.focus();
            e.setSelectionRange(start,end);
        }
        function focus(ptr) {
            ptr == undefined && (ptr = e.value.length);
            return select(ptr, ptr);
        }
        return {focus: focus, select: select};
    }
}

你可以尝试着使用 xmlplus 的继承特性把上一节的组件功能与这一节的组件功能整合在一起。这样你就会得到一个既具备格式化功能,又具备便捷的选择文本与控制光标功能的文本框组件了。

列表

列表是极其常用的一种组件,是许多组件系统的必须包含的。列表可以做的很简单,只显示简洁的文本。列表也可以做的很复杂,用于展示非常丰富的内容。

列表的组成

列表离不开列表项以及包含列表项的容器。下面是最简单的列表组件,它包含一个列表项组件 Item 以及一个列表项容器组件 List。

// 04-01
Item: {
    xml: "<li id='item'/>"
},
List: {
    xml: "<ul id='list'/>"
}

此列表只是对原生列表元素的简单封装。里定义的列表组件尽管简单,但所构建的框架为我们的继续扩展奠定了基础。

使用系统接口操作列表

如上定义的列表组件的最基本的用法如下。这种用法与原生列表标签的用法没什么区别。我们将进行做进一步的改造。

// 04-01
Index: {
    xml: `<List id='index'>
             <Item>Item 1</Item>
             <Item>Item 2</Item>
          </List>`
}

列表组件普遍包含添加、删除以及修改这三种操作。由于我们定义的列表项足够的简单,所以这里不再定义新的操作接口,而直接使用系统提供的接口。

// 04-02
Index: {
    xml: `<div id='index'>
             <List id='list'/>
             <button id='append'>append</button>
             <button id='remove'>remove</button>
             <button id='modify'>modify</button>
          </div>`,
    fun: function (sys, items, opts) {
        sys.append.on("click", function() {
            sys.list.append("Item").text("Item 1");
        });
        sys.remove.on("click", function() {
            sys.list.first() && sys.list.first().remove();
        });
        sys.modify.on("click", function() {
            sys.list.first() && sys.list.first().text("Item 2");
        });
    }
}

该示例使用列表的系统函数 append 来追加列表项,并使用列表项的系统函数 remove 来移除列表项,同时还使用列表项的系统函数 text 来修改列表项的数据。

自定义列表项接口的使用

由于上一节列表项所包含的是简单的文本数据,所以上面示例使用 text 函数来操作数据是适合的。现在给出一个包含较复杂数据的列表项,该列表项额外定义了数据操作接口。

// 04-03
Item: {
    xml: `<li id='item'>
             <span id='color'>red</span>
             <span id='shape'>square</span>
          </li>`,
    fun: function (sys, items, opts) {
        function getValue() {
            return {color: sys.color.text(), shape: sys.shape.text()};
        }
        function setValue(obj) {
            sys.color.text(obj.color);
            sys.shape.text(obj.shape);
        }
        return Object.defineProperty({}, "data", { get: getValue, set: setValue});
    }
}

下面是包含新列表项的列表操作的一个示例。其中对于组件的追加与删除还可以使用系统提供的函数,但对于数据的获取与修正就只能使用新定义的接口了。

// 04-03
Index: {
    xml: `<List id='index'>
             <List id='list'/>
             <button id='append'>append</button>
             <button id='remove'>remove</button>
             <button id='modify'>modify</button>
          </List>`,
    fun: function (sys, items, opts) {
        sys.append.on("click", function() {
            sys.list.append("Item");
        });
        sys.remove.on("click", function() {
            sys.list.first() && sys.list.first().remove();
        });
        sys.modify.on("click", function() {
            var item = sys.list.first();
            item && (item.value().data = {color: "blue", shape: "rectangle"});
        });
    }
}

注意,对列表项接口的定义没有什么特别的要求,比如一定要使用 setValuegetValue 之类。这取决于具体的场景,根据需要灵活选择。

使用第三方列表组件

如今市面上已经有了种种功能丰富的列表组件,我们可以通过对其进行二次封装再方便地使用。这里结合 JQuery 带有排序功能的列表组件来说明如何操作。

首先需要对原列表项进行封装,因为原列表项实在太长了。注意需要引出数据操作接口。

// 04-04
Item: {
    xml: "<li class='ui-state-default'><span class='ui-icon ui-icon-arrowthick-2-n-s'/><span id='data'/></li>",
    map: { appendTo: "data" },
    fun: function (sys, items, opts) {
        return { data: sys.data.text };
    }
}

其次,定义列表项的容器组件,该容器组件函数项部分主要封装了 JQuery 的列表初始化代码。该初始化代码用于指明当前列表为可排序但不可选。注意需要同时把相关的样式也给写上。

// 04-04
List: {
    css: `#list{ list-style-type: none; margin: 0; padding: 0; width: 60%; }
          #list li { margin: 0 3px 3px 3px; padding: 0.4em; padding-left: 1.5em; font-size: 1.4em; height: 18px; }
          #list li span:first-child { position: absolute; margin-left: -1.3em; }`,
    xml: "<ul id='list'/>",
    fun: function (sys, items, opts) {
        var elem = this.elem();
        $(elem).sortable();
        $(elem).disableSelection();
    }
}

最后我们来看看如何使用该列表组件。该示例的使用与前面没什么不同,但功能与表现可就大不一样了。

// 04-04
Index: {
    xml: `<List id='index'>
             <Item>Item 1</Item>
             <Item>Item 2</Item>
             <Item>Item 3</Item>
          </List>`
}

优化

如果你的列表有频繁更新数据的要求,必然会产生频繁的列表项的增删操作,这可能会带来不好的应用体验。下面给出一个可行的优化方案,该方案在官方文档的 优化 章节中已出现过。

// 04-05
List: {
    xml: "<ul id='list'/>",
    fun: function (sys, items, opts) {
        function setValue(array) {
            var list = sys.list.children();
            for ( var i = 0; i < array.length; i++ )
                (list[i] || sys.list.append("Item")).show().text(array[i]);
            for ( var k = i; k < list.length; k++ )
                list[k].hide();
        }
        return Object.defineProperty({}, "value", { set: setValue });
    }
}

对于复杂的列表项,重新创建的代价是巨大的。所以此优化方案尽可能地复用了已有的列表项,非必要时只刷新数据而不是删除并重建新的列表项,并且只有在已有的列表项不够用时才创建新的列表项。

选项卡

这一章将设计一个选项卡组件,选项卡组件在手持设备上用的比较多,下面是一个示意图:

选项卡组成

在具体实现之前,想像一下目标组件是如何使用的,对于设计会有莫大的帮助。通过观察,可以将选项卡组件分为容器部分和子项部分,正如下面的 XML 结构所展示的。

<!-- 05-01 -->
<Tabbar id="tabbar">
    <TabItem id="home" label="首页"/>
    <TabItem id="setting" label="设置"/>
    <TabItem id="logs" label="日志"/>
    <TabItem id="about" label="关于"/>
</Tabbar>

现在我们把目光切换到选项卡组件的子项部分,来看看子项部分是如何分解的。通过示意图,你可以发现子项部分可以分解为子项容器以及包含一个图标和一个文本的子级部分。

<!-- 05-01 -->
<a id="tabitem">
    <Icon id="icon"/>
    <span id="label">首页</span>
</a>

所以,现在我们的目标已经很明确了,主要设计三个组件:图标组件 Icon、选项卡组件的子项 TabItem 以及选项卡组件的容器 Tabbar。

结构图

由于该组件比较简单,所以可以将三种子组件放置在同一层级。但请注意,我们还有四个图标组件,可以创建一个子级用于容纳它们。下面给出我们的组件结构图:

Tabbar/
├── Tabbar
├── TabItem
└── Icon/
     ├── About
     ├── Home
     ├── Logs
     └── Setting

图标的实现

我们从最简单的开始,先看四个图标组件,图标组件主要通过封装 SVG 文本来实现,由于图标文本较长,所以这里仅截取每个图标文本的一段。

// 05-01
About: {
    xml: `<svg width="48" height="48" viewBox="0 0 1024 1024">
               <path d="M507.577907 23.272727C240.142852..."/>
          </svg>`
},
Home: {
    xml: `<svg width="48" height="48" viewBox="0 0 1024 1024">
               <path d="M949.082218 519.343245 508.704442..."/>
          </svg>`
},
Logs: {
    xml: `<svg width="48" height="48" viewBox="0 0 1024 1024">
               <path d="M576 125.344l32 0 0 64-32 0 0-64Z..."/>
          </svg>`
},
Setting: {
    xml: `<svg width="48" height="48" viewBox="0 0 1024 1024">
               <path d="M512 336.664c-96.68 0-175.336 78...."/>
          </svg>`
}

请注意,这些图标位于虚拟目录 /icon 之下,也就是你要像下面这样导入:

// 05-01
xmlplus("ui", function (xp, $_, t) {
    $_().imports({Tabbar: {... }, TabItem: {...}});

    $_("icon").imports({--这里包含了四个图标组件--});
});

下面来实现图标组件 Icon,这里的图标组件与上面是不同的,它会根据输入的图标类型实例化不同的图标。这样设计可以复用部分相同的代码,避免冗余。

// 05-01
Icon: {
    css: "#icon { width: 1.5em; height: 1.5em; display: inline-block; }",
    opt: { icon: "about" },
    xml: `<span id="icon"/>`,
    fun: function (sys, items, opts) {
        sys.icon.replace("icon/" + opts.icon).addClass("#icon");
    }
}

该组件的函数项根据输入的图标类型创建图标组件并替换已有的 span 元素对象。注意,替换完后需要重新添加样式。

子项的实现

按从内到外的原则,接下来实现选项卡组件的子项 TabItem。对于此组件,需要在组件的映射项中做一次异名的属性映射,把 id 属性值映射给内部的图标组件的 icon 属性。

// 05-01
TabItem: {
    css: `a#tabitem { display: table-cell; overflow: hidden; width: 1%; height: 50px; text-align: center; ... }
          #label { display: block; font-size: .75em; overflow: hidden; text-overflow: ellipsis; -webkit-user-select: none; }
          a#primary { color: #337ab7; fill: currentColor; }`,
    map: {"attrs": { icon: "id->icon" } },
    xml: `<a id="tabitem">
              <Icon id="icon"/>
              <span id="label">首页</span>
          </a>`,
    fun: function (sys, items, opts) {
        sys.label.text(opts.label);
        function select(bool) {
            sys.tabitem[bool ? 'addClass' : 'removeClass']("#primary");
        }
        return Object.defineProperty({}, "selected", { set: select});
    }
}

此组件提供了用于选项切换时选中与非选中状态之间切换的接口。以供选项卡容器使用。

选项卡的实现

最后来看下选项卡组件 Tabbar 的实现。该组件侦听了用户触击选项卡时的事件,在侦听器里主要做两件事:一是维持选项卡状态的切换;另一是派发一选项卡切换时的状态改变事件。

// 05-01
Tabbar: {
    css: `#tabbar { display: table; width: 100%; height: 50px; padding: 0; table-layout: fixed; -webkit-touch-callout: none; }
          #tabbar { z-index: 10; background-color: #f7f7f7; backface-visibility: hidden; }`,
    xml: `<nav id="tabbar"/>`,
    fun: function (sys, items, opts) {
        var sel = this.first();
        this.on("touchend", "./*[@id]", function (e) {
            sel.value().selected = false;
            (sel = this).value().selected = true;
            this.trigger("switch", this.toString());
        });
        if (sel) sel.value().selected = true;
    }
}

至此,一个选项卡组件算是完成了,下面来看下具体的一个测试示例。注意,最好在 chrome 浏览器的移动模式下做测试,这样 touchend 事件才会生效。

// 05-01
Index: {
    xml: `<Tabbar id="index">
              <TabItem id="home" label="首页"/>
              <TabItem id="setting" label="设置"/>
              <TabItem id="logs" label="日志"/>
              <TabItem id="about" label="关于"/>
          </Tabbar>`,
    fun: function (sys, items, opts) {
        this.on("switch", (e, target) => console.log(target));
    }
}

在组件 Index 中,你可以侦听来自选项卡的切换事件来做相应的操作。比如结合后续我们介绍的视图栈组件做页面之间的切换操作。

下拉刷新

“下拉刷新”由著名设计师 Loren Brichter 设计,并应用于 Twitter 第三方应用 Tweetie 中。2010年4月,Twitter 收购 Tweetie 开发商 Atebits 后,该专利归 Twitter 所有。这一章我们就来看看如何实现一个简单的下拉刷新组件。

目标组件分析

和前面在设计组件时的做法一样,我们先想想看最终的成品组件是如何使用的,这需要点想像力。下拉刷新组件看成一个容器组件是合理的,用户可以对容器的内容进行下拉操作。如果用户完成了完整的下拉触发操作,该组件应该会有下拉完成的事件反馈,假定这个事件名为 ready。根据以上的分析,我们很有可能得到下面的一个该组件的应用示例。

Index: {
    xml: `<PullRefresh id='example'>
             <h1>Twitter</h1>
             <h2>Loren Brichter</h2>
          </PullRefresh>`,
    fun: function (sys, items, opts) {
        sys.example.on("ready", () => console.log("ready"));
    }
}

示例中的使用方式是非常简洁的,但我们还漏了一点。当刷新完毕,数据返回后,还要告知组件对象给出刷新成功的提示并且返回初始状态。好了,下面给出的是加入新接口的应用示例。

// 06-01
Index: {
    xml: `<PullRefresh id='example'>
             <h1>Twitter</h1>
             <h2>Loren Brichter</h2>
             <button id='refresh'>click</button>
          </PullRefresh>`,
    fun: function (sys, items, opts) {
        sys.example.on("ready", () => {
            setTimeout(() => sys.example.trigger("complete"), 3000);
        });
    }
}

该示例通过定时器模拟了下拉刷新完成后给出刷新成功的提示并且返回初始状态。

布局

现在让我们把目光转移到下拉刷新组件的内部,看看该如何去实现。观察文章开始部分的大图,很自然地我们可以将整个组件划分为三个子组件,如下面的 XML 文档所示。

<div id="refresh">
    <Status id="status"/>
    <div id="content"></div>
</div>

但为了方便控制,下面的布局可能会好一些。其中组件 page 代表视口,它与其父级 refresh 有相同的宽高尺寸。另外,内容组件 content 与视口组件 page 也具有相同的宽高尺寸。未定义的状态条组件 Status 的高度为 40px,这样在初始状态下,状态条组件与内容组件需要向上便宜 40 个像素。

// 06-01
PullRefresh: {
    css: `#refresh { position: relative; height: 100%; cursor: pointer; overflow-y: hidden; }
          #page { height: 100%; transform: translateY(0); }
          #status, #content { transform: translateY(-40px); } #content { height: 100%; }`,
    xml: `<div id='refresh' xmlns:i='pullrefresh'>
            <div id='page'>
                <i:Status id='status'/>
                <div id='content'></div>
            </div>
          </div>`,
    map: { "appendTo": "content" }
}

状态条的实现

暂且放下 PullRefresh 组件,我们先看看如何实现状态指示条。状态指示条用于显示“下拉刷新”、“松开刷新”、“加载中...”以及“刷新成功”四个状态提示,并且每一时刻仅显示一个状态。对于状态的切换,这里会先用到我们下一章将讲到的路由组件 ViewStack,这里仅需要了解如何使用即可。组件 ViewStack 对外只显示子级的一个子组件,同时侦听一个 switch 事件,该事件的派发者携带了一个切换到的目标对象的名称,也就是 ID。该组件根据这个 ID 来切换到目标视图。下面是状态条组件的完整实现。

// 06-01
Status: {
    css: "#statusbar { height: 2.5em; line-height: 2.5em; text-align: center; }",
    xml: <ViewStack id="statusbar">
            <span id="pull">Pull to refresh...</span>
            <span id="release">Release to refresh...</span>
            <span id="loading">Loading...</span>
            <span id="success">Loading success</span>
         </ViewStack>,
    fun: function (sys, items, opts) {
        var stat = "pull";
        function getValue() {
            return stat;
        }
        function setValue(value) {
            sys.statusbar.trigger("switch", stat = value);
        }
        return Object.defineProperty({}, "value", { get: getValue, set: setValue });
    }
}

该组件提供一个 value 接口用户设置与获取组件的显示状态。父级组件可根据不同的时机调用该接口。

事件响应

现在让我们来考虑下拉刷新组件操作实现的具体细节。我们需要考虑的事件主要有三个:stouchstarttouchmove 以及 touchend。下面是一个实现框架:

// 06-01
PullRefresh: {
    fun: function (sys, items, opts) {
        var startY, translateY;
        sys.page.on("touchstart", function(e) {
            // 1 记录下当前触点的坐标以及 page 的偏移
            // 2 侦听 touchmove 和 touchend事件
        });
        function touchmove(e) {
            // 1 计算出垂直方向上的偏移
            // 2 处理状态条与内容内面跟随触点移动
            // 3 根据触点移动的距离显示相当的状态条内容
        }
        function touchend(e) {
            // 1 移除 touchmove 和 touchend 事件
            // 2 根据触点移动的距离决定返回原始状态或者进入刷新状态并派发事件
        }
    }
}

现在我们一个个地来实现上面的三个侦听器。首先是 touchstart 侦听器:

// 06-01
sys.page.on("touchstart", function (e) {
    startY = e.targetTouches[0].pageY;
    translateY = parseInt(sys.page.css("transform").match(/\d+/)[0]);
    sys.page.on("touchmove", touchmove).on("touchend", touchend).css("transition", "");
});

下拉刷新过程中会涉及到动画,对于动画目前一般有两种选择,可以使用 JQuery 动画函数,也可以是 css3,这需要看各人喜好了。这里我们选择使用 css3 来实现。如上所示在下拉开始时需要把动画给禁用掉,否则会对后续造成干扰。

其次是 touchmove 侦听器。该侦听器必需判断出偏移的正负值,当偏移为正时才允许移动页面。

// 06-01
function touchmove(e) {
    var offset = e.targetTouches[0].pageY - startY;
    if ( offset > 0 ) {
        sys.page.css("transform", "translateY(" + (offset + translateY) + "px)");
        if (items.status.value != "loading")
            items.status.value = offset > 40 ? "release" : "pull";
    }
}

最后是 touchend 侦听器。该处理器需要处理三种情况。情况一,如果状态条处理等待数据返回状态,则回弹页面使状态条还处于该状态。情况二,如果用户下拉幅度未超过 40px,则回弹页面使状态条处于隐藏状态。情况三,如果用户下拉幅度超过 40px,则派发一个 ready 事件,并切换状态条至等待数据返回状态。

// 06-01
function touchend(e) {
    var offset = e.changedTouches[0].pageY - startY;
    sys.page.off("touchmove").off("touchend").css("transition", "all 0.3s ease-in 0s");
    if ( items.status.value == "release" ) {
        sys.page.css("transform", "translateY(40px)");
    } else if ( offset < 40 ) {
        sys.page.css("transform", "translateY(0)");
    } else {
        release();
    }
}

由于情况三的处理较复杂,所以独立封装成一个函数处理。请看下面的 release 函数。

// 06-01
function release() {
    items.status.value = "release";
    sys.refresh.once("complete", () => {
        items.status.value = "message";
        setTimeout(e => {
            sys.page.css("transform", "translateY(0)").once("webkitTransitionEnd", e => items.status.value = "pull");
        }, 300);
    });
    sys.page.css("transform", "translateY(40px)").trigger("ready");
}

此函数主要完成两件事,其一是派发 ready 事件,提醒上级组件发送数据请求,其二是侦听 complete 事件,一旦接收到来自上级派发的 complete 事件则显示完成数据请求的提示并返回初始状态。

状态条的改进

上面我们实现的状态条是纯文字的,这一节让我们把 加载中... 替换成一个动画,从而给用户带来更好的体验。下面实现的动画组件 Release 包含一个旋转的类似菊花一样的东西,同时还包含文本。

// 06-02
Release: {
    css: `#loader { display: inline-block; position: relative; height: 2.5em; line-height: 2.5em; }
          #spinner { width: 1.2em; height: 1.2em; position: absolute; top: .7em; }
          #label { display: inline-block; font-size: 0.75em; margin: 0 0 0 2em; }`,
    xml: `<div id='loader'>
            <Spinner id='spinner'/><span id='label'/>
          </div>`,
    map: { appendTo: "label" }
},
Spinner: {
    css: `#loader { width: 1.5em; height: 1.5em; animation: spin 1s linear infinite;... }
          @keyframes $spin { 0% {transform: rotate(0deg);} 100% {transform: rotate(360deg); } }
          @-webkit-keyframes $spin {0% {-webkit-transform: rotate(0deg);}... }`,
    xml: `<svg id='loader' width='48' height='48' viewBox='0 0 1024 1024'>
            <path d='M512.151961 3.978614l-0.308015 0c-21.655206 0-39.162952...'/>
            ...
          </svg>`
}

你只需要在状态条组件 Status 中把名为 release 的组件替换成上面新实现的 Release,其余地方不用改,示例就能很好的工作了。

// 06-02
Status: {
    css: "#statusbar { height: 2.5em; line-height: 2.5em; text-align: center; }",
    xml: `<ViewStack id='statusbar'>
            <span id='pull'>Pull to refresh...</span>
            <span id='release'>Release to refresh...</span>
            <Release id='loading'>Loading...</Release>
            <span id='success'>Loading success</span>
          </ViewStack>`,
    fun: function (sys, items, opts) {
        var status = "pull";
        function getValue() {
            return status;
        }
        function setValue(value) {
            sys.statusbar.trigger("switch", status = value);
        }
        return Object.defineProperty({}, "value", {get: getValue, set: setValue});
    }
}

路由

在浏览器端,对路由的理解一般是根据不同的 URL 完成页面的切换。在服务器端,则是根据不同的 URL 请求回馈相关的页面。在本章,我们广义的组件路由的定义:根据接收到的不同命令,组件对象呈现出不同的子级页面。在这里将介绍与路由相关的一个组件,即视图栈 ViewStack。

视图栈初步

该组件在《文档》部分的最后一个章节《延迟实例化》已经出现过了。这里将对一些细节部分进行解读。下面再次给出该组件的源码。

// 07-01
ViewStack: { 
    xml: "<div id='viewstack'/>",
    fun: function (sys, items, opts) {
        var args, children = this.children(),
            table = children.call("hide").hash(),
            ptr = table[opts.index] || children[0];
        if (ptr) ptr = ptr.trigger("show").show();
        this.on("switch", function ( e, to ) {
            table = this.children().hash();
            if ( !table[to] || table[to] == ptr ) return;
            e.stopPropagation();
            args = [].slice.call(arguments).slice(2);
            ptr.trigger("hide", [to+''].concat(args)).hide();
            ptr = table[to].trigger("show", [ptr+''].concat(args)).show();
        });
        return Object.defineProperty({}, "selected", { get: function() {return ptr;}});
    }
}

从静态接口看,该组件允许提供静态参数 index,该参数是组件 ViewStack 某一儿子组件对象的名称,它用于指出哪一个子级组件会被最先呈现。请看下面的示例。

// 07-01
Index: {
    xml: `<ViewStack index='bar'>
              <button id='foo'>foo</button>
              <button id='bar'>bar</button>
          </ViewStack>`
}

该示例中,ViewStack 包含一值为 bar 的属性 index,表明组件在实例化时,组件对象 bar 会最先呈现。而默认情况下,该组件的第一个子级组件会作为初始显示对象。再从动态接口看,该组件的函数项导出了一个名为 selected 的只读属性,该属性用于指示当前显示的子级组件对象。

通过事件切换目标组件对象

对于子级组件对象之间切换,该组件的函数项并未导出相关的接口,而是通过接收 switch 事件来完成切换。请看下面的示例。

// 07-02
Index: {
    xml: `<ViewStack id='index'>
             <button id='foo'>foo</button>
             <button id='bar'>bar</button>
          </ViewStack>`,
    fun: function (sys, items, opts) {
        sys.index.on("click", "*", function(e) {
            var to = this + '' == "foo" ? "bar" : "foo",
                data = "hello, world";
            this.trigger("switch", [to, data]);
        });
        sys.foo.on("show", function (e, prev, data) {
            console.log("previous page is " + prev, "from data is " + data);
        });
        sys.bar.on("hide", function (e, prev, data) {
            console.log("previous page is " + prev, "from data is " + data);
        });
    }
}

对于该示例,当用户点击文字时,文字会在 foo 和 bar 之间切换,也即两个页面之间切换,切换是通过相应子级对象派发 switch 事件进行的。另外,组件 ViewStack 在切换页面时,还会对本次显示的页面派发事件 show,以及对本次隐藏的页面派发事件 hide,相关页面可以根据需要选择侦听与否。并且在侦听函数中,可以获知前一显示页面 ID 以及所传输的相关数据。

动态添加与移除子级对象

组件 ViewStack 支持动态添加与移除子级的组件对象,请看下面的一个示例。

// 07-03
Index: {
    xml: `<ViewStack id='index'>
             <button id='foo'>foo</button>
          </ViewStack>`,
    fun: function (sys, items, opts) {
        sys.foo.on("click", function () {
            var xml = "<button id='bar'>bar</button>";
            sys.index.append(xml).trigger("switch", "bar");
        });
    }
}

该示例中,当用户点击按钮 foo 应用会动态添加了一个子级组件,并且通过派发事件 switch 将当前显示的视图切换为刚新添加的视图。

优化配置

组件 ViewStack 一般配合组件的延迟实例化功能使用。对于一些比较复杂的组件,这样有助于加快显示应用的初始页面。下面做简单示范。

// 07-04
Index: {
    xml: `<ViewStack id='index'>
             <button id='foo'>foo</button>
             <button id='bar'>bar</button>
          </ViewStack>`,
    map: { defer: "bar" },
    fun: function (sys, items, opts) {
        sys.foo.on("click", function () {
            sys.index.trigger("switch", "bar");
        });
    }
}

此示例中,ViewStack 子级包含三个子组件,其中组件对象 bar 被设置为需要延迟实例化,只有当视图切换在组件对象 bar 时,它才真正开始实例化。

与 HTML5 History API 的配合使用

这里我们看看如何让组件 ViewStack 与 HTML5 History API 的配合使用。下面是一个简单的例子。

// 07-05
Index: {
    xml: "<ViewStack id='index'>\
             <button id='foo'>foo</button>\
             <button id='bar'>bar</button>\
          </ViewStack>",
    fun: function (sys, items, opts) {
        sys.index.on("show", "button", function (e) { 
            window.history.pushState({name: this + ""}, null, "/" + this);
        });
        window.addEventListener("popstate", function (e) {
            e.state && sys.index.trigger("switch", e.state.name);
        });
        sys.foo.on("click", e => sys.foo.trigger("switch", "bar"));
        sys.bar.on("click", e => sys.foo.trigger("switch", "foo"));
    }
}

该示例的关键点在于,当视图栈组件对象的子级页面发生变更时,使用函数 pushState 记录下来;另外需要侦听浏览器的 popstate 事件,当用户点击「前进」、「后退」按钮时,完成相应页面的切换。这种技术非常适合在单页应用中完成无刷新跳转,可以给用户带来非常好的体验。

分隔框

分隔框(DividedBox)是一种布局类组件,可以分为两种,其中一种叫水平分隔框(HDividedBox),另一种叫垂直分隔框(VDividedBox)。水平分隔框会将其子级分为两列,而垂直分隔框则会将其子级分为两行。列与列之间以及行与行之间一般都会有一条可以拖动的用以改变子级组件大小的分隔条。下面仅以垂直分隔框为例来介绍此类组件是如何设计以及实现的。

成品组件用例

按照以往的设计经验,我们可以先写出想像中的成品组件用例,这将有助于我们后续的进一步的设计与实现。垂直分隔框既然是布局类的组件,那么它也一定是一个容器,该容器包含了上述我们提到的三种子级组件。为了使用方便,我们不应该把分隔框也写进去,分隔框应该由组件内部实现的。经过分析,我们得到下面的一个应用示例:

// 08-01
Index: {
    css: "#index { width: 640px; height: 480px; box-sizing: border-box; border: 1px solid blue; }\
          #top, #bottom { width: 100%; height: 100%; background: #AAA; }",
    xml: "<VDividedBox id='index'>\
             <div id='top'/>\
             <div id='bottom'/>\
          </VDividedBox>"
}

该示例由一垂直分隔框组件包裹着两个 div 元素。这里分别设置两个 div 元素的宽高为父级的 100%,同时设置它们的背景色为灰色,这只是为了方便测试。另外,我们还需要考虑一个子框的初始比例分配问题。我们可以设置默认比例为 50:50,比例最好可以在组件实例化时静态指定,同时提供比例设置的动态接口。于是我们就有了下面的改进用例。

// 08-01
Index: {
    css: "#index { width: 640px; height: 480px; box-sizing: border-box; border: 1px solid blue; }\
          #top, #bottom { width: 100%; height: 100%; background: #AAA; }",
    xml: "<VDividedBox id='index' percent='30'>\
             <div id='top'/>\
             <div id='bottom'/>\
          </VDividedBox>",
    fun: function (sys, items, opts) {
        sys.top.on("click", e => sys.index.percent = 50);
    }
}

这个用例在垂直分隔框初始化时设置子框的初始比例分配为 30:70,当用户点击第一子框时,比例分配重新恢复为 50:50。不过要注意,这些比例分配指的是对排除分隔条所占用空间后剩余空间的比例分配。

设计与实现

现在让我们把注意力转移到组件的内部。我们先大致地确定组件基本的组成。直观地看,垂直分隔框显示包含三个组件部分:上子框部分、分隔条以及下子框部分。于是我们暂时可以得到下面的视图项部分:

// 08-01
<div id='hbox'>
    <div id='top'/>
    <div id='handle'/>
    <div id='bottom'/>
</div>`

下一步,确保垂直分隔框组件实例的子级部分被正确地映射到上子框 top 以及下子框 bottom。方法是先让所有的子级元素对象全部被添加到上子框 top 中,然后在函数项中将下子级元素添加到下子框 bottom 中。

// 08-01
VDividedBox: {
    xml: `<div id='hbox'>
            <div id='top'/>
            <div id='handle'/>
            <div id='bottom'/>
          </div>`,
    map: {appendTo: "top" },
    fun: function (sys, items, opts) {
        sys.bottom.elem().appendChild(this.last().elem());
    }
}

现在让我们来考虑下视图项的样式,对于顶层 div 元素,我们设置其定位方式为相对定位。对于子级的三个元素则设置为绝对定位。另外,把分隔条高度设置为 5px

// 08-01
VDividedBox: {
    css: `#vbox { position:relative; width:100%; height:100%; box-sizing: border-box; }
          #top { top: 0; height: 30%; } #bottom { bottom: 0; height: calc(70% - 5px); }
          #top,#bottom { left: 0; right: 0; position: absolute; }
          #handle { height: 5px; width: 100%; position:absolute; left:0; top: 30%; z-index:11; cursor:row-resize; }`,
    xml: `<div id='vbox'>
            <div id='top'/>
            <div id='handle'/>
            <div id='bottom'/>
          </div>`,
    map: { appendTo: "top" },
    fun: function (sys, items, opts) {
        sys.bottom.elem().appendChild(this.last().elem());
    }
}

最后让我们看看如何响应分隔条的拖动事件,从而更改子框的分配比例。我们需要定义一个改变子框比例的函数,同时侦听分隔条的拖拽事件。下面是我们的一个实现。

// 08-01
VDividedBox: {
    // 视图项同上
    map: { format: {"int": "percent"}, appendTo: "top" }, 
    fun: function (sys, items, opts) {
        var percent = 50;
        sys.handle.on("dragstart", function (e) {
            sys.hbox.on("dragover", dragover);
        });
        sys.hbox.on("dragend", function (e) {
            e.stopPropagation();
            sys.hbox.off("dragover", dragover);
        });
        function dragover(e) {
            e.preventDefault();
            setPercent((e.pageY - sys.hbox.offset().top) / sys.hbox.height() * 100);
        }
        function setPercent(value) {
            sys.handle.css("top", value + "%");
            sys.top.css("height", value + "%");
            sys.bottom.css("height", "calc(" + (100 - value) + "% - 5px)");
        }
        setPercent(opts.percent || percent);
        sys.bottom.elem().appendChild(this.last().elem());
        return Object.defineProperty({}, "percent", {get: () => {return percent}, set: setPercent});
    }
}

上述代码的映射项中有一项关于 percent 格式的设置,该设置确保了 percent 为整型数。另外函数项中对子框的比例设定用到了 CSS3 的 calc 计算函数,该函数在浏览器窗体改变大小时仍然能够起作用。如果你希望兼容更多的浏览器,你需要做更多的工作。另外注意,为了让组件有好的性能表现,只有当用户开始拖拽时,才对事件 dragover 实施侦听。

进一步改进

上述组件在大部分情况下运作良好,但当我将 CodeMirror 组件整合进去时,出了点小问题。让我们现在做个小测试,写一个包含两个 CodeMirror 组件作为子级的垂直分隔框的应用实例。拖动分隔条,看会出现什么结果。

// 08-02
Index: {
    css: "#index { width: 640px; height: 480px; box-sizing: border-box; border: 1px solid #AAA; }",
    xml: "<VDividedBox id='index'>\
             <Editor id='top'/>\
             <Editor id='bottom'/>\
          </VDividedBox>"
},
Editor: {
    css: `.CodeMirror { height:100%; height: 100%; font-size: 14px; }
          .CodeMirror-gutters { border-right: 1px solid %border-color; background: linear-gradient...}
          #editor { position: relative; width: 100%; height: 100%; box-sizing: border-box; border: 1px solid #AAA; }`,
    map: { nofragment: true },
    opt: { lineNumbers: true, indentUnit: 4, mode: "text/html" }, 
    xml: "<div id='editor'/>",
    fun: function (sys, items, opts) {
        return CodeMirror(sys.editor.elem(), opts);
    }
}

如果你运行此示例,会发现分隔条失灵了,拖动分隔条子框比例不再出现变化。问题出在 CodeMirror 组件对象对拖拽事件进行了劫持,导致我们我组件内部收不到响应的事件。我们需要做些补丁才行,下面是改进后的组件:

// 08-03
VDividedBox: {
    css: `#vbox { position:relative; width:100%; height:100%; box-sizing: border-box; }
          #top { top: 0; height: 30%; } #bottom { bottom: 0; height: calc(70% - 5px); }
          #top,#bottom { left: 0; right: 0; position: absolute; }
          #handle { height: 5px; width: 100%; position:absolute; left:0; top: 30%; z-index:11; cursor:row-resize; }
          #mask { width: 100%; height: 100%; position: absolute; display: none; z-index: 10; }`,
    xml: "<div id='vbox'>\
            <div id='top'/>\
            <div id='handle' draggable='true'/>\
            <div id='bottom'/>\
            <div id='mask'/>\
          </div>",
    map: { format: {"int": "percent"}, appendTo: "top" }, 
    fun: function (sys, items, opts) {
        var percent = 50;
        sys.handle.on("dragstart", function (e) {
            sys.mask.show();
            sys.vbox.on("dragover", dragover);
        });
        sys.vbox.on("dragend", function (e) {
            sys.mask.hide();
            e.stopPropagation();
            sys.vbox.off("dragover", dragover);
        });
        function dragover(e) {
            e.preventDefault();
            setPercent((e.pageY - sys.vbox.offset().top) / sys.vbox.height() * 100);
        }
        function setPercent(value) {
            sys.handle.css("top", value + "%");
            sys.top.css("height", value + "%");
            sys.bottom.css("height", "calc(" + (100 - value) + "% - 5px)");
        }
        setPercent(opts.percent || percent);
        sys.bottom.elem().appendChild(this.last().elem());
        return Object.defineProperty({}, "percent", {get: () => {return percent}, set: setPercent});
    }
}

为了解决问题,我们在组件中引用了额外的 div 元素对象 mask,此元素默认是不显示的。当拖动开始时,它才显示并覆盖住子框以及分隔条,而拖动一结束,它又隐藏掉。这样就避免了 CodeMirror 组件对象对拖拽事件的劫持。

结合水平分隔框使用

我们有了上述垂直分隔框的设计经验,搞个水平分隔框也就不是什么难事了,这里就不列出来了。这里主要是给出一个综合使用水平分隔框和垂直分隔框的示例。当然,在设计之初,我们并没有想到要这么使用。

// 08-04
Index: {
    css: `#index { width: 640px; height: 480px; box-sizing: border-box; border: 1px solid blue; }
          #left0, #right0, #left1, #right1 { width: 100%; height: 100%; background: #AAA; }`,
    xml: `<HDividedBox id='index'>
              <VDividedBox percent='30'>
                  <div id='left0'/><div id='right0'/>
              </VDividedBox>
              <VDividedBox percent='30'>
                  <div id='left1'/><div id='right1'/>
              </VDividedBox>
          </HDividedBox>`
}

树形组件是一种具有层级结构的组件,广泛应用于各种场景。本章会实现一个简单的树形组件,尽管功能有限,但你可以通过扩展它来实现自己所需要的树形组件。

数据源

树形组件的数据源可以是 JSON 格式的数据对象,也可以是具有 XML 结构的数据或者是其它的具有层级结构的数据。本章将采用具有如下 JSON 格式的数据对象。

// 09-01
{
    name: 'My Tree',
    children: [
        { name: 'hello' },
        { name: 'world' },
        { name: 'child folder', children: [{ name: 'alice' }]}
    ]
};

上述数据源中,name 值会作为树结点的名称显示,含 children 的数组代表节点的子级。

递归结构的设计

由 HTML 中的列表元素 ul 以及 li 组合而成对象具有天然的树形结构,我们不妨采用它们作为构建树形组件的基本元素。树形组件的最外层必然是一个 ul 元素,所以我们可以暂时定义树形组件如下:

// 09-01
Tree: {
    xml: `<ul id='tree'>
            <Item id='item'/>
          </ul>`
}

这里的未定义的组件 Item 是一个需要递归定义的子项组件,它会根据提供的数据递归地生成子孙对象。下面是一种可能的设计:

// 09-01
Item: {
    xml: `<li id='item'>
            <div id='content'>
              <span id='neme'/><span id='expand'/>
            </div>
            <ul id='children'/>
          </li>`,
    map: { defer: "children" }
}

注意,上面的 neme 对象是用于显示 name 属性的。expand 对象用于展开或者关闭子级对象 entries。子级对象 children 被设置为需要延迟实例化,只有当用户点击 expand 对象展开子级时,该对象才会实例化。

数据的加载与渲染

如上一节所述,我们设定了子级对象 children 需要延迟实例化。所以,在给子项 Item 提供的数据设置接口不应该立马对 children 实例化。下面我们先给出数据接口函数。

// 09-01
Item: {
    // css, xml, map 项同上
    fun: function (sys, items, opts) {
        var data;
        return function (value) {
            data = value;
            sys.neme.text(data.name);
            data.children && data.children.length && sys.expand.show().text(" [+]");
        };
    }
}

该接口函数只是设置了当前节点相关的内容。下面我们来侦听 expand 对象的点击事件,并适时地完成组件对象 children 的实例化。

// 09-01
Item: {
    // css, xml, map 项同上
    fun: function (sys, items, opts) {
        var data, open;
        sys.expand.on("click", function () {
            open = !open;
            sys.expand.text(open ? " [-]" : " [+]");
            open ? (sys.children.show() && load()) : sys.children.hide();
        });
        function load() {
            if ( sys.children.children().length == 0 )
              for ( var item of data.children )
                sys.add.before("Item").value()(item);
        }
        return function (value) {
            data = value;
            sys.neme.text(data.name);
            data.children && data.children.length && sys.expand.show().text(" [+]");
        };
    }
}

上述代码中包含一个 open 参数,该参数记录了当前节点的是否处于展开状态以供相关的侦听器使用。

动态添加节点

现在我们对上述组件进行一个小的扩展,使得它支持动态添加树节点的功能。首先,我们在对象 children 的子级添加一个触发按钮,并命名为 add。

// 09-01
Item: {
    xml: `<li id='item'>
            <div id='content'>
              <span id='neme'/><span id='expand'/>
            </div>
            <ul id='children'>
              <li id='add'>+</li>
            </ul>
          </li>`,
    map: { defer: "children" }
}

其次,需要侦听 add 对象的点击事件,在侦听器中完成对象的添加。

// 09-01
Item: {
    // css, xml, map 项同前
    fun: function (sys, items, opts) {
        var data, open;
        sys.item.on("click", "//*[@id='add']", function () {
            var stuff = {name: 'new stuff'};
            data.children.push(stuff);
            sys.add.before("Item").value()(stuff);
        });
        // 其余代码同前
    }
}

这里需要注意,对 add 对象的侦听不能采取直接式的侦听:sys.add.on("click",...),而应该使用代理的方式,否则会报错。因为其父级属于延迟实例化的组件,在 children 对象未实例化之间,add 对象并不可见。

完整的树形组件

综合以上的内容,现在给出一个完整版本的树形组件,下面先给出的是树组件的子项组件:

// 09-01
Item: {
    css: "#item { cursor: pointer; }",
    xml: `<li id='item'>
            <div id='content'>
              <span id='neme'/><span id='expand'/>
            </div>
            <ul id='children'>
              <li id='add'>+</li>
            </ul>
          </li>`,
    map: { defer: "children" },
    fun: function (sys, items, opts) {
        var data, open;
        sys.item.on("click", "//*[@id='add']", function () {
            var stuff = {name: 'new stuff'};
            data.children.push(stuff);
            sys.add.before("Item").value()(stuff);
        });
        sys.expand.on("click", function () {
            open = !open;
            sys.expand.text(open ? " [-]" : " [+]");
            open ? (sys.children.show() && load()) : sys.children.hide();
        });
        function load() {
            if ( sys.children.children().length == 1 )
              for ( var item of data.children )
                sys.add.before("Item").value()(item);
        }
        return function (value) {
            data = value;
            sys.neme.text(data.name);
            data.children && data.children.length && sys.expand.show().text(" [+]");
        };
    }
}

其次给出树组件。在实际应用中的树形组件会比这里的功能更丰富些,你可以在上述代码的基础上进一步的改进,比如添加些 ICON 图标、让子项成为可拖动的等等。但在改进过程中尽量避免代码的复杂化,适当地剥离些代码出来封装成组件是非常有必要的。

// 09-01
Tree: {
    css: `#tree { font-family: Menlo, Consolas, monospace; color: #444; }
          #tree, #tree ul { padding-left: 1em; line-height: 1.5em; list-style-type: dot; }`,
    xml: `<ul id='tree'>
            <Item id='item'/>
          </ul>`,
    fun: function (sys, items, opts) {
        return items.item;
    }
}

测试

我们最后给出一个测试用例。该例子的测试数据与本章开始给出的数据一致。

Index: {
    xml: "<Tree id='tree' xmlns='tree'/>",
    fun: function (sys, items, opts) {
        items.tree({
            name: 'My Tree',
            children: [
                { name: 'hello' },
                { name: 'world' },
                { name: 'child folder', children: [{ name: 'alice' }]}
            ]
        });
    }
}

网格

这一章我们要实现是一个网格组件,该组件除了最基本的数据展示功能外,还提供排序以及数据过滤功能。

数据源

为了测试我们即将编写好网格组件,我们采用如下格式的数据源。此数据源包含两部分的内容,分别是表头数据集和表体数据集。网格组件实例最终的列数由表头数据集的长度决定。

// 10-01
var data = { 
    gridColumns: ['name', 'power'],
    gridData: [
      { name: 'Chuck Norris', power: Infinity },
      { name: 'Bruce Lee', power: 9000 },
      { name: 'Jackie Chan', power: 7000 },
      { name: 'Jet Li', power: 8000 }
    ]
};

顶层设计

从视觉上,我们很自然地把网格组件划分为表头与表体。此网格组件有三个功能,所以应该提供三个动态接口。但我们注意到排序功能是通过点击表头进行的,而表头属于网格组件的一部分,所以该功能应该内置。从而,实际上我们的网格组件对外只暴露两个动态接口:一个用于过滤,另一个用于接收数据源。于是我们可以得到如下的一个顶层设计。

// 10-01
DataGrid: {
    xml: `<table id='datagrid'>
            <Thead id='thead'/>
            <Tbody id='tbody'/>
          </table>`,
    fun: function (sys, items, opts) {
        function setValue(data) {
            items.thead.val(data.gridColumns);
            items.tbody.val(data.gridColumns, data.gridData);
        }
        function filter(filterKey) {
            // 过滤函数
        }
        return { val: setValue, filter: filter };
    }
}

设计表头

表头只有一行,所以可以直接给它提供一个 tr 元素。tr 元素的子级项 th 元素的个数取决于表头数据集的长度,所以需要动态创建。由于 th 元素包含了排序功能,所以需要另行封装。下面是我们给出的表头的设计。

// 10-01
Thead: {
    xml: `<thead id='thead'>
              <tr id='tr'/>
          </thead>`,
    fun: function (sys, items, opts) {
        return function (value) {
            sys.tr.children().call("remove");
            data.forEach(item => sys.tr.append("Th").value().val(item));
        };
    }
}

表头数据项组件提供一个文本设置接口。该组件本身并不负责排序,它只完成自身视图状态的变更以及排序命令的派发。排序命令的派发需要携带两个数据:一个是排序关键字,也就是表头文本;另一个排序方向:升序或者降序。

// 10-01
Th: {
    css: "#active { color: #fff; } #active #arrow { opacity: 1; } #active #key { color: #fff; }\
          #arrow { display: inline-block; vertical-align: middle; width: 0; height: 0;... }\
          #asc, #dsc { border-left: 4px solid transparent; border-right: 4px solid transparent; }\
          #asc { border-bottom: 4px solid #fff;} #dsc { border-top: 4px solid #fff; }",
    xml: "<th id='th'>\
            <span id='key'/><span id='arrow'/>\
          </th>",
    fun: function (sys, items, opts) {
        var order = "#asc";
        this.watch("sort", function (e, key, order) {
            sys.key.text().toLowerCase() == key || sys.th.removeClass("#active");
        });
        this.on("click", function (e) {
            sys.th.addClass("#active");
            sys.arrow.removeClass(order);
            order = order == "#asc" ? "#dsc" : "#asc";
            sys.arrow.addClass(order).notify("sort", [sys.key.text().toLowerCase(), order]);
        });
        sys.arrow.addClass("#asc");
        return sys.key.text;
    }
}

设计表体

表体可以有多行,但表体只负责展示数据,所以实现起来比表头要简单的多。

// 10-01
Tbody: {
    xml: `<tbody id='tbody'/>`,
    fun: function (sys, items, opts) {
        return function (gridColumns, gridData) {
            sys.tbody.children().call("remove");
            gridData.forEach(data => 
                tr = sys.tbody.append("tr");
                gridColumns.forEach(key => tr.append("td").text(data[key]));
            ));
        };
    }
}

此组件提供了一个接收数据源的动态接口,数据源需要包含两个部分:表头数据集与表体数据集。该动态接口根据这两个数据集完成数据的展示。

加入排序功能

为了便于管理,我们把排序功能单独封装成一个组件,该组件提供一个排序接口,同时侦听一个排序消息。一旦接收到排序消息,则记录下关键字与排序方向,并派发一个表体刷新命令。

// 10-01
Sort: {
    fun: function (sys, items, opts) {
        var sortKey, sortOrder;
        this.watch("sort", function (e, key, order) {
            sortKey = key, sortOrder = order;
            this.trigger("update");
        });
        return function (data) {
            return sortKey ? data.slice().sort(function (a, b) {
                a = a[sortKey], b = b[sortKey];
                return (a === b ? 0 : a > b ? 1 : -1) * (sortOrder == "#asc" ? 1 : -1);
            }) : data;
        };
    }
}

要完整地实现排序功能,对组件 DataGrid 作一些修正,主要是内置上述的排序功能组件并侦听表体刷新指令。一旦接收到刷新指令,则对表体数据完成排序并刷新表体。

// 10-01
DataGrid: {
    xml: `<table id='table'>
            <Thead id='thead'/>
            <Tbody id='tbody'/>
            <Sort id='sort'/>
          </table>`,
    fun: function (sys, items, opts) {
        var data = {gridColumns: [], gridData: []};
        function setValue(value) {
            data = value;
            items.thead(data.gridColumns);
            items.tbody(data.gridColumns, data.gridData);
        }
        function filter(filterKey) {
            // 过滤函数
        }
        this.on("update", function() {
            items.tbody(items.sort(data.gridData));
        });
        return { val: setValue, filter: filter };
    }
}

加入过滤功能

与排序功能的加入流程类似,我们把过滤功能单独封装成一个组件,该组件提供一个过滤接口,同时侦听一个过滤消息。一旦接收到消息,则记录下过滤关键字,并派发一个表体刷新命令。

// 10-01
Filter: {
    fun: function (sys, items, opts) {
        var filterKey = "";
        this.watch("filter", function (e, key) {
            filterKey = key.toLowerCase();
            this.trigger("update");
        });
        return function (data) {
            return data.filter(function (row) {
                return Object.keys(row).some(function (key) {
                    return String(row[key]).toLowerCase().indexOf(filterKey) > -1;
                });
            });
        };
    }
}

另外需要对组件 DataGrid 作一些修正,修正内容与上述的排序功能的加入类似,区别在于额外完善了 filter 接口以及对消息作用域进行了限制。下面是我们最终的网格组件。

// 10-01
DataGrid: {
    css: `#table { border: 2px solid #42b983; border-radius: 3px; background-color: #fff; }
          #table th { background-color: #42b983; color: rgba(255,255,255,0.66); cursor: pointer; ... }
          #table td { background-color: #f9f9f9; }
          #table th, #table td { min-width: 120px; padding: 10px 20px; }`,
    xml: `<table id='table'>
            <Thead id='thead'/>
            <Tbody id='tbody'/>
            <Sort id='sort'/>
            <Filter id='filter'/>
          </table>`,
    map: { msgscope: true },
    fun: function (sys, items, opts) {
        var data = {gridColumns: [], gridData: []};
        function setValue(value) {
            data = value;
            items.thead(data.gridColumns);
            items.tbody(data.gridColumns, data.gridData);
        }
        function filter(filterKey) {
            sys.table.notify("filter", filterKey);
        }
        this.on("update", function() {
            items.tbody(data.gridColumns, items.filter(items.sort(data.gridData)));
        });
        return { val: setValue, filter: filter };
    }
}

值得注意的是这里一定要在映射项中配置限制消息作用域的选项。否则,当在一个应用中实例化多个网格组件时,消息就会互相干扰。

测试

最后我们来测试下我们完成的组件,测试的功能主要就是刚开始提到的三个:数据展示、排序以及过滤。

// 10-01
Index: {
    css: `#index { font-family: Helvetica Neue, Arial, sans-serif; font-size: 14px; color: #444; }
          #search { margin: 8px 0; }`,
    xml: `<div id='index' xmlns:i='datagrid'>
            Search <input id='search'/>
            <i:DataGrid id='datagrid'/>
          </div>`,
    fun: function (sys, items, opts) {
        items.datagrid.val({
            gridColumns: ['name', 'power'],
            gridData: [
              { name: 'Chuck Norris', power: Infinity },
              { name: 'Bruce Lee', power: 9000 },
              { name: 'Jackie Chan', power: 7000 },
              { name: 'Jet Li', power: 8000 }
            ]
        });
        sys.search.on("input", e => items.datagrid.filter(sys.search.prop("value")));
    }
}