变脸式应用

[序]

本书将以实战练习和示例分析为主,给读者展现用H5技术制作手机应用的开发体验。

当需要制作一款手机应用,希望它既可以在手机浏览器或微信公众号之类的轻应用平台使用,也可以在安卓、苹果等手机上安装使用,由于各平台技术栈完全不同,光前端就需要至少三套人马在各平台各自为战,开发和维护成本极高。现在你多了一种用H5技术制作全平台手机应用的选择,不仅极大的降低开发成本,而且由于H5技术源于已经用了至少20多年的网页技术,门槛也远低于才兴起几年的手机平台开发技术。架构师在选型时会纠结,又想优雅的做全平台应用,又担心用网页技术制作的手机应用的体验到底行不行。

川剧中的变脸艺术,如行云流水般切换脸谱,令人惊叹。 如果把手机应用中的每个页面看成一张脸谱,在操作时我们也希望像变脸表演一样有着轻松流畅的体验。 而用传统的网页技术制作的仿手机应用和原生手机应用的体验尚有较大差距,主要原因是每个页面都是一张网页,在操作时有大量网页加载和刷新,不流畅且浪费大量流量。

对于网页刷新问题的解决方案是使用Ajax技术制作交互式无刷新网页。早在1998年前后,微软的Outlook Web Access成为第一个应用了Ajax技术的成功的商业应用程序,2005年以后,随着谷歌地图、Gmail等交互式网页应用的流行,Ajax技术也开始流行起来,也诞生了“单网页应用(SPA)”这个概念。

在手机应用领域,由于早期手机性能弱,做应用程序基本只能使用原生开发语言,学习门槛很高。随着手机性能的增强和H5技术的流行,已经可以把“单网页应用”技术应用到手机上,让整个应用就是一个网页,而原本一张张相互链接的网页变成了H5应用中的一个个可流畅切换的逻辑页面,消除了网页加载,操作体验接近手机原生应用。

这种支持多逻辑页面的具有流畅的操作体验的H5单网页应用,我们将它称为变脸式应用。

变脸艺术
变脸艺术

对手机单网页应用有以下常见误解:

筋斗云前端框架(jdcloud-m)是一个变脸式单网页应用的开源开发框架,它以页面路由和接口调用为核心,提供多逻辑页支持和远程接口调用封装,同时对制作安卓、苹果原生应用也提供良好的支持,因而也是一个全平台H5应用框架。

本书就以该开发框架为基础,讲解手机应用开发中常见需求的解决方案。

版本更新说明:

1 运行变脸式应用

[任务]

用筋斗云前端框架创建一个H5应用项目myproject,在浏览器运行起来。

先从github上下载开源的筋斗云前端框架及示例应用:https://github.com/skyshore2001/jdcloud-m

建议安装git工具直接下载,便于以后更新,例如直接创建H5应用项目叫myproject:

git clone https://github.com/skyshore2001/jdcloud-m.git myproject

如果github访问困难,也可以用这个git仓库: http://dacatec.com/git/jdcloud-m.git

下载后,假定myproject是我们的项目目录。其中,子目录server是H5应用目录,开发主要集中在这里。tool是用于优化和发布的工具,之后在站点上线优化时介绍。

设置WEB服务器(如Apache/nginx/IIS等),添加虚拟目录myproject, 指向myproject/server目录,这时,就可以直接运行起前端应用:

http://localhost/myproject/

在开发环境下,建议配置WEB服务器,将所有文件都设置为不缓存(输出HTTP头Cache-Control: no-cache),避免修改文件后仍然显示旧内容。 测试时建议使用chrome浏览器,按F12打开开发者模式,点左上角的手机模式模拟(toggle device toolbar)或按Ctrl-Shift-M,模拟手机上的运行效果应用。

在这个示例应用中,用户可以创建订单、查看订单列表和订单详情,查看个人信息等,分别在各个逻辑页中展现,逻辑页跳转切换时不会刷新整个网页,这就是典型的变脸式应用。

变脸式应用与后端的交互完全是是业务数据,后端不传输任何网页、网页模板或前端样式。这里由于还没有连接后端,示例中使用的数据均为模拟后端接口返回的数据。

筋斗云前端支持模拟接口返回数据,这在前端开发时非常有用。文件mockdata.js中即是应用使用到的接口模拟,下面章节里将会详细介绍。

在server目录下,lib目录下的js/css文件都属于框架使用的文件,不宜随意修改,其中app_fw.js是筋斗云前端框架的核心部分。

page目录是默认的逻辑页目录,比如首页、登录页、订单列表等逻辑页的html和js文件都放在这里。

cordova目录是用于制作原生手机app时使用的。筋斗云框架支持全平台应用,即一套H5代码,可同时制作安卓应用、苹果应用、微信微站等。

2 逻辑页

2.1 第一个逻辑页 - HelloWorld

[任务]

上一节我们创建了新的H5应用项目,本节我们制作一个仅显示“hello world”的逻辑页面,在首页中添加一个链接,点击可进入这个页面。

制作一个逻辑页面,存到文件page/hello.html:

<div>
    <div class="hd">
        <h2>HelloWorld</h2>
    </div>

    <div class="bd">
        <p>Hello, world</p>
    </div>
</div>

这是个html片段,其中class="hd"class="bd"分别代表逻辑页的标题栏和主体部分。一般应保持这样的结构,即使不需要标题栏,也建议保留hd这个div,将其设置隐藏即可(style="display:none")。

在首页page/home.html中添加一个链接过来:

    ...
    <div class="bd">
        ...
        <li><a href="#hello">Hello</a></li>
    </div>

在浏览器时刷新H5应用进入首页,点击Hello链接,就可以看到新逻辑页Hello显示出来了。 筋斗云可以动态加载逻辑页,并为它指定id为“hello”(即页面文件名)。 除了使用链接,还可以通过JS代码MUI.showPage("#hello")来显示该页。

点击浏览器的返回按钮,可以回到首页。在返回时,没有网页刷新的过程,这也是变脸式应用的典型特点。

我们也可以在逻辑页的hd部分里为应用添加返回按钮,如:

    <div class="hd">
        <!-- 加上返回按钮 -->
        <a href="javascript:history.back();" class="btn-icon"><i class="icon icon-back"></i></a>
        <h2>Hello</h2>
    </div>

改好后,不必刷新页面重新从首页进入,直接在浏览器控制台中输入:

MUI.reloadPage();

就可以直接查看到更新后的逻辑页了。 如果页面有标题栏,可以连续点击标题栏5次,在弹出框上点击确定,也可以刷新逻辑页。

筋斗云支持逻辑页热更新技术,可以实时热更新当前页面,并保留当前的应用状态。这一技巧在开发调试逻辑页时非常好用,尤其对于复杂的H5应用,不必从首页操作进来。类似的技巧还有卸载一个逻辑页,以便再进入时可重新初始化:

MUI.unloadPage(); // 卸载当前页
MUI.unloadPage("hello"); // 卸载指定页

如果你嫌首页上加的链接太难看,可以使用框架默认集成的weui样式库来润色它,如:

    <div class="weui_cells" style="margin-top:100px">
        ...
        <!-- 显示为一个button -->
        <li class="weui_cell" style="display:block"><a href="#hello" class="weui_btn weui_btn_default">Hello</a></li>
    </div>

同样地,在修改好后,直接在控制台输入MUI.reloadPage()看效果。

筋斗云的核心是页面路由(showPage)和接口调用(callSvr)。它自身不提供移动UI样式库,而是通过集成的weui样式库来提供移动UI样式。 weui是一套同微信原生视觉体验一致的基础样式库,由微信官方团队开发,关于weui的使用可以参考https://weui.io/或自行搜索。 当然你也可以把它换成你自己喜欢的UI库。

2.2 为逻辑页添加样式和逻辑

[任务]

上一节中,我们添加了一个逻辑页“hello”。现在我们通过CSS修改显示字体为红色,并在进入和退出页面时弹出提示框。

为逻辑页设置样式,如果这个样式只在这个逻辑页使用,一般就在页面div中内嵌style标签。 页面的逻辑写在与页面同名的js文件中,在页面div上使用mui-script属性指定js文件,并通过mui-initfn标签指定页面初始化函数。

修改hello.html页面文件如下:

<div mui-initfn="initPageHello" mui-script="hello.js">
<style>
p {
    color: red;
}
</style>
    <div class="hd">
        <a href="javascript:history.back();" class="icon icon-back"></a>
        <h2>HelloWorld</h2>
    </div>

    <div class="bd">
        <p>Hello, world</p>
    </div>

</div>

在上例中,在内嵌的style标签中为“p”标签指定样式,让它显示红色。 你可能有疑问,这样写会不会影响其它页面中的p标签的样式呢?

筋斗云支持自动限定逻辑页样式作用域。 即框架保证这个样式只会用于当前逻辑页,不会污染到其它页面。

你也可以加页面id前缀来指定:

<style>
#hello p {
    color: red;
}
</style>

在不支持这一特性的其它框架中,一般都必须像这样人工来限定。

上面逻辑页中通过mui-script=“hello.js”指定了js文件,创建page/hello.js文件如下:

function initPageHello() 
{
    var jpage = $(this);
    jpage.on("pagebeforeshow", onBeforeShow);
    jpage.on("pagehide", onHide);

    function onBeforeShow()
    {
        app_alert("before show");
    }
    function onHide()
    {
        app_alert("hide");
    }
}

上面演示了逻辑页进入和退出时常用的事件处理,很容易理解。 一般从后端取数据的操作都习惯放在pagebeforeshow事件中处理。另外还有pageshow, pagecreate等事件。

app_alert是框架提供的异步弹出框函数,可用于提示消息(类似alert)、确认消息(类似confirm)、问询消息(类似prompt)等,弹出框界面也可自由定制,后面还将介绍。

2.3 页面栈

框架支持逻辑页面的前进和后退,甚至可以用手势左右划动页面来后退或前进。

本节讲述一个常见需求:提交订单后进入下一页面,这时点返回按钮(不管是点浏览器的返回按钮还是页面左上方的返回按钮), 应该跳过提交页面,返回再前一个页面。

在示例应用首页上,点击“立即下单”,进入“创建订单”页,点击“创建订单”按钮,进入“订单列表”页。 这时点击返回按钮,我们看到,它跳过了“创建订单”页,直接返回了再前面一页即首页。

我们查看“创建订单”页的代码(createOrder.js),其实只需在切换页面前,调用MUI.popPageStack()

    function api_OrdrAdd(data)
    {
        app_alert("订单创建成功!", "i", function () {
            // 到新页后,点返回不允许回到当前页
            MUI.popPageStack();
            PageOrders.refresh = true;
            MUI.showPage("#orders");
        });
    }

如果想在回退时跳过两个页面(比如提交过程有两步分了两个页面),调用MUI.popPageStack(2)即可。

2.4 使用Vue创建页面

以上是使用jquery来写逻辑页,也可以使用当前流行的vue库来写逻辑页面。尽管可用,一般还是不建议jquery与Vue混用的。

下载vue.js及vue.min.js到server/lib目录下(带min的是生产版本,不带min的是开发版本),在H5应用index.html中引用:

<script src="lib/vue.min.js"></script>

注意:由于不使用vue脚本架,前端代码并不编译,在写JS时不要使用ES6新语法。

可以使用Vue管理页面一部分,比如管理bd部分:

function initPageOrder() 
{
    var jpage = $(this);
    var vbd = new Vue({
        // [0]是从jquery对象中取出DOM对象
        el: jpage.find(".bd")[0]
        data: {},
    });

    // 从vue对象转为jquery对象可以用 $(vbd.$el),一般不建议混用
    ...
}

也可以用Vue管理整个页面,这时应注意,由于vue实例化时会替换掉原先的DOM模板,所以函数须返回一个新的jpage对象,示例:

function initPageOrder() 
{
    // this就是jpage, 所以this[0]是页面对应的DOM对象
    var vpage = new Vue({
        el: this[0],
        data: {},
        method: {
            onBeforeShow: function () {}
        }
    });

    // 从vue对象vpage封装出jquery对象jpage
    var jpage = $(vpage.$el);
    jpage.on("pagebeforeshow", function () {
        vpage.onBeforeShow();
    });
    ...
    // 必须返回新页面
    return jpage;
}

3 调用后端接口

调用后端接口是筋斗云框架提供的两大核心功能之一。

[任务]

继续hello页面的例子,要求每次进入页面时,不是固定的显示“hello, world”,而是需要根据服务端的返回内容来显示hello的内容,比如“hello, skys”或是“hello, jdcloud”。

我们先定义一个叫做“hello”的交互接口,由前端发起一个HTTP GET请求,比如:

http://myserver/myproject/api.php?ac=hello

如果调用成功,后端返回JSON格式的数据如下:

[0, "jdcloud"]

其中0是返回码,表示调用成功。如果调用失败,可返回:

[99, "对不起,服务器爆炸了"]

以上就是一个符合筋斗云接口规范的简单例子(称为业务查询协议-BQP),在成功调用时应返回[0, data],在调用失败时应返回[非0, 错误提示信息]

有了清晰的接口定义,前后端就可以并行开发了。 在前端,我们把页面稍作修改以动态显示hello的内容:

    <div class="bd">
        <p>Hello, <span id="what"></span></p>
    </div>

再写一段逻辑,每当进入页面时调用hello接口并显示内容,我们选择onBeforeShow回调来做这件事:

function initPageHello() 
{
    var jpage = $(this);
    jpage.on("pagebeforeshow", onBeforeShow);

    function onBeforeShow()
    {
        callSvr("hello", api_hello);
    }

    function api_hello(data)
    {
        jpage.find("#what").html(data);
    }
}

callSvr是框架提供的一个重要函数,它封装了ajax调用的细节,完整的函数原型为:

callSvr(ac, param?, fn?, postParam?, options?);

其中,ac是调用接口名,fn是回调函数,param和postParam分别是URL参数和POST参数。除ac外,其它参数均可省略。例如

callSvr("hello");
callSvr("hello", {id: 1}); // URL: hello?id=1
callSvr("hello", {id: 1}, api_hello); // function api_hello(data) {}
callSvr("hello", api_hello, {name: "hello"});

回调函数api_hello仅在成功时被调用,参数data是实际数据,即[0, data]中的data部分,不包括返回码0。 当调用失败时,框架会统一处理,显示错误信息,无须操心。

3.1 调用模拟接口

上面代码写好了,后端接口还没做好怎么测试?

筋斗云支持模拟接口返回数据。 在mockdata.js中,可以设置接口的模拟返回数据:

MUI.mockData = {
    ...
    "hello": [0, "jdcloud"]
}

此处还可以用函数做更复杂的基于参数的模拟,详见API文档,查询MUI.mockData

运行H5应用,进入hello页面,看看是不是可以正常显示了?

可以动态修改模拟数据,在控制台中输入:

MUI.mockData["hello"] = [0, "skys"]

然后从hello页返回首页,再进入hello页,看看显示内容是不是变了?

再改一个出错的试试:

MUI.mockData["hello"] = [99, "对不起,服务器爆炸了"]

进入hello页,我们看到,调用失败时,回调函数api_hello没有执行,而是框架自动接管过去,显示出错信息。

3.2 调用真实接口

在后端接口开发好后,我们可去掉对这个接口的模拟,直接远程调用服务端接口。这需要配置好后端接口的地址。

我们用php写一个简单的符合筋斗云接口规范的后端实现,通过名为“ac”的URL参数表示接口名,在server目录中创建文件api.php如下:

<?php

@$ac = $_GET['ac'];
if ($ac == 'hello') {
    $what = "jdcloud @ " . time();
    echo json_encode([0, $what]);
}
else {
    echo json_encode([1, "bad ac"]);
}

配置好php的调用环境后,访问

http://localhost/myproject/api.php?ac=hello

输出类似这样(根据时间动态变化):

[0,"jdcloud @ 1483526151"]

回到前端,我们在app.js中设置服务端接口地址:

    $.extend(MUI.options, {
        serverUrl: "api.php",
        serverUrlAc: "ac"
    });

serverUrl选项设置了服务端的URL地址,因为我们将“api.php”放在与“index.html”同一目录下,所以直接用相对路径就可以了。serverUrlAc选项定义了接口名对应的URL参数名称,即?ac={接口名}. 在mockdata.js中去掉对“hello”接口的模拟,刷新应用就可以看到调用后端的效果了。

如果前后端不在同一台服务器上,则要将URL写完整,如

serverUrl: "http://myserver/myproject/api.php";

注意:后端应设置好允许跨域请求。这里不做详述。

以上讲述的是符合筋斗云接口规范的接口调用设置,如果不符合该规范,请阅读下一节“接口适配”。

3.3 接口适配

在上例中,假定了后端接口兼容筋斗云接口规范,例如返回格式为[0, jsonData][非0, 错误信息]。 如果接口协议不兼容,则需要做接口适配。

接口适配的目标是通过callSvr函数更加简练地调用后台接口,同时达到:

[任务]

适配以下接口协议规范,约定接口返回格式为:{code, msg, data}, 例如上例中的hello接口,调用成功时返回:

{
    "code":"0",
    "msg":"success",
    "data":"jdcloud"
}

失败返回:

{
    "code":"99",
    "msg":"对不起,服务器爆炸了"
}

这时需要对callSvr进行适配,可以在app.js中,设置 MUI.callSvrExt如下:

    MUI.callSvrExt['default'] = {
        makeUrl: function(ac) {
            return 'http://hostname/lcapi/' + ac;
        },
        dataFilter: function (data) {
            if ($.isPlainObject(data) && data.code !== undefined) {
                if (data.code == 0)
                    return data.data;
                if (this.noex)
                    return false;
                app_alert("操作失败:" + data.msg, "e");
            }
            else {
                app_alert("服务器通讯协议异常!", "e"); // 格式不对
            }
        }
    };

我们在mockdata.js中设置好模拟数据用于测试:

MUI.mockData = {
    "User.get": {code: 0, msg: "success", data: user},
    "hello": {code: 0, msg: "success", data:"myworld"}
    ...
}

上例中,User.get接口在显示首页是会调用,所以和“hello”接口一并模拟下。

测试接口调用:

callSvr("hello", console.log);
或
callSvrSync("hello");

callSvrSynccallSvr的同步调用版本,它直接等到调用完成才返回,且返回值就是调用成功返回的数据。

可以动态修改模拟数据:

MUI.mockData["hello"] = {code: 99, msg: "对不起,服务器爆炸了"}

在接口适配完成后,应用层代码不用去做任何修改。 进入页面看看,是不是和上节的运行结果是一样的。

4 进入与退出应用

进入应用后,框架会自动设置一些全局变量,如g_args, g_data等。

问:如何在H5应用中获取URL参数?

全局变量g_args保存了H5应用的URL参数。 例如URL为http://myserver/myproject/index.html?orderId=10&dscr=上门洗车,则该对象有以下值:

g_args.orderId=10; // 注意:如果参数是个数值,则自动转为数值类型,不再是字符串。
g_args.dscr="上门洗车"; // 对字符串会自动进行URL解码。

要删除一项值,可以用delete g_args.orderId.

问:全局数据存放到哪里有规范吗?

全局数据建议都放在变量g_data中,而不是到处创建全局变量,这样查看这个变量就可以了解H5应用状态。 框架也会设置一些全局数据进去(例如userInfo保存登录后的返回数据等)。另外,如果是逻辑页之间传递信息,不要用全局变量,应使用逻辑页接口,后面章节将介绍。

对于全局配置信息,一般统一存到名为g_cfg的全局变量中。

查看H5应用JS文件index.js,有它们的声明,比如:

    var g_data = {
        userInfo : null, // {id, name, uname=phone}
    };

    var g_cfg = {
        WAIT: 3000, // 3s
    };

4.1 入口页

[任务]

在地址栏直接输入http://server/app/#hello,会发现它会跳转到首页,我们希望可直接进入前面我们制作的hello页面。

打开示例H5应用客户端index.html对应的逻辑文件即index.js,会发现一开始有如下设置:

$.extend(MUI.options, {
    appName: "user",
    homePage: "#home",
    pageFolder: "page",
});

MUI.validateEntry([
    "#home",
    "#me",
    "#order"
]);

第一句是设置一些框架的选项MUI.options,注意框架提供的功能多以MUI开头。 这里设置了H5应用内部名称为“uesr”(表示客户端,以后若有员工端等应用可区分开),首页名称是“#home”,逻辑页目录为“page”,也即首页实际文件为“page/home.html”.

第二句用MUI.validateEntry指定允许的入口逻辑页,如果不是从这些逻辑页进入应用,则自动跳转到首页。 如果注释掉这句,则是允许从任意逻辑页进入应用。

要想直接输入URL就能进入hello页,只要将它暴露成入口页即可,把它加到入口页列表中来:

MUI.validateEntry([
    "#home",
    "#me",
    "#order",
    "#hello"
]);

注意我们现在制作的是H5应用,不是单纯用于静态展示的网页,很多状态信息(比如已登录的信息)存储在全局变量之中。 如果允许任意逻辑页进入应用,很可能因状态错误而显示出错。 每个入口页都是个对外的接口,可通过URL直接访问,如无必要,尽量不要开放。

注意:如果页面配置为免登录页(后面章节会讲到,通过MUI.options.allowNoLogin配置项),则它自动是入口页,无须在validateEntry中指定。

4.2 登录与退出

登录和退出是多数应用都需要的功能。

我们在筋斗云示例应用中,可以看到登录退出相关的代码:(index.js文件)

function handleLogin(data)
{
    MUI.handleLogin(data);
    // g_data.userInfo已赋值
}

function logoutUser()
{
    // 这里可以删除当前用户相关的storage, cookie等。
    MUI.logout();
}

handleLogin将作为回调函数在所有登录成功时统一调用,退出登录则调用logoutUser函数。

筋斗云提供这些函数:

筋斗云示例应用提供了两个登录页面,分别是手机号/动态验证码登录(page/login.html)和用户名/密码登录(page/login1.html)。

以较简单的用户名/密码登录页面(login1)为例,简化后的代码如下:

HTML:(page/login1.html)

<div mui-initfn="initPageLogin1" mui-script="login1.js">
    ...
    <form action="login" class="bd">
        手机号 <input name="uname" required placeholder="11位手机号">
        密码 <input type="password" name="pwd" required placeholder="4位以上密码" minlength=4>
        <button type="submit" class="weui_btn weui_btn_primary">登录</button>
    </form>
</div>

JS: (page/login1.js)

function initPageLogin1()
{
    var jpage = $(this);

    var jf = jpage.find("form");
    MUI.setFormSubmit(jf, handleLogin);
}

这里使用了callSvr之外另一个常用的接口调用方式,即通过form提交调用后端接口的MUI.setFormSubmit,其用法是:

关于MUI.setFormSubmit的更多选项如合法性验证、计算字段赋值等,可查询参考文档。

要退出登录,调用前面定义过的logoutUser函数即可:

// 示例:个人中心页面(#me)的初始化函数,为按钮btnLogout绑定退出动作:
function initPageMe()
{
    ...
    jpage.find("#btnLogout").click(logoutUser);
}

退出会导致页面重刷新后进入入口页。要刷新H5应用,也可以直接调用:

reloadSite();

如果你的应用的退出接口不同,可自行在logoutUser函数中实现MUI.logout的功能:

4.3 自动跳转登录页和会话重用

为了避免每次打开或刷新应用都要再登录,会话重用是实现短期免登录进入的常用方法。

[任务]

要实现这样的需求,需要有以下接口设计:

这个特别的检测登录接口的主要用途是,通过会话重用(session),实现短时间内免登录。 会话重用一般由服务端设置cookie实现,由于浏览器会自动记住cookie,只要服务端会话未过期且用户未退出(logout),就可以一直免登录进入。 即使不使用cookie而用URL参数(比如token)的,H5应用只要自行记住这个token到本地存储,下次打开时重用即可。

这样,前端进入H5应用时的逻辑就是:

这些逻辑由框架函数MUI.tryAutoLoginMUI.handleLogin提供,应注意把这段逻辑放置到muiInit事件中,以便在显示任意页面之前调用。 我们在筋斗云示例应用中,可以看到这样的代码:(index.js中)

$(document).on("muiInit", myInit);

function myInit()
{
    MUI.tryAutoLogin(handleLogin, "User.get");
}

function handleLogin(data)
{
    MUI.handleLogin(data);
    // g_data.userInfo已赋值
}

在MUI.tryAutoLogin中指定了检查会话重用的接口名为“User.get”,于是H5应用便有了会话重用的功能。 在模拟接口中,我们看到“User.get”接口模拟如下:

"User.get": [0, user],

这表明模拟的是已登录过的状态,因此打开应用时可直接免登录进入。 我们把它改成返回“未登录”错误:

"User.get": [2, "no auth"],

这时刷新H5应用,是不是进入了登录页? 任意输入手机号和验证码可登录进来。进入页面“我”,点退出,看看是不是回到了登录页?

注意框架定义了“未认证错误”缺省错误码为2,如果要修改,可以用:

window.E_NOAUTH = 2;

再看会话中断时的行为,由于进入订单列表页会调用接口“Ordr.query”,我们在浏览器控制台上修改模拟接口让它返回未登录错:

MUI.mockData["Ordr.query"] = [2, "no auth"];

进入订单列表页(如果之前已经打开过,可以下拉刷新下),看看是不是自动跳转到登录页了?

如果后端接口格式不是使用筋斗云调用规范,则需要按上节介绍自行适配接口,在其中添加自动跳转登录页的的逻辑,如:

    MUI.callSvrExt['default'] = {
        ...
        dataFilter: function (data) {
            ...
            if (data.code == E_NOAUTH) {
                MUI.showLogin();
                return;
            }
        }
    };

这样,框架可以确保未登录时(或已掉线、服务端重启等情况时)调用了后端需要登录的接口,可以自动跳转到登录页。

注意:调用MUI.showLogin()来显示登录页,而不要用MUI.showPage("#login")来写死页面,而且MUI.showLogin可以在登录成功后跳回登录前想进入的页面。 类似的还有MUI.showHome()来显示首页。

上面示例中,用MUI.tryAutoLogin要求进入应用必须先登录。如果某些入口页面想要不登录也可打开,则可以通过allowNoLogin函数选项配置免登录打开。

示例:“hello”页面或“test”开头的页面无须登录可直接打开:

MUI.options.allowNoLogin = function (page) {
    return page == "hello" || /^test/.test(page);
}

如果大多页面都不需要登录,也可以直接设置MUI.tryAutoLogin的第三个参数allowNoLogin=true:

function myInit()
{
    MUI.tryAutoLogin(handleLogin, "User.get", true); // 参数true表示允许未登录进入
}

注意:在免登录页中,应特别小心是否登录带来的影响,可用g_data.userInfo == null判断是否为未登录。

而且,从未登录的入口页跳转到其它需要登录才能展示的页面,也需要在跳转前增加检查,示例:

    if (g_data.userInfo == null) {
        MUI.showLogin();
        return;
    }
    MUI.showPage("#orders");

或是在被跳转页的pagebeforeshow事件中添加检查:

    function onPageBeforeShow(ev, opt)
    {
        // 可能从一个未登录的页面跳转过来
        if (g_data.userInfo == null) {
            MUI.showLogin();
            return;
        }
        // 设置页面内容
    }

注意:在MUI.tryAutoLogin中调用接口时,都使用的是同步调用且忽略错误。

4.4 自动登录

自动登录是一个常见需求,基本上现在的手机应用,登录过一次后,下次都是免登录进入。

前面已经讲过通过会话重用,可以实现短时间内免登录。 通过对cookie设置较长的超时时间,且在后端长期保存会话数据,可以延长免登录的时间。

如果会话重用机制的实现并不可靠,比如过期、后端过载或重启等导致会话丢失,最好再设计专门的自动登录机制。 要实现自动登录,客户端必须将登录信息保存在本地。由于用户名、密码这些信息很敏感,不适合直接存储在客户端,一般通过token来实现自动登录。

需要后端login接口支持token,注意token参数要求通过POST参数传递的:

login(uname, pwd) -> {_token, ...} // 普通登录,额外返回_token字段
login()(token) -> {_token?, ...} // 可以不再返回token

与之前的login接口相比,普通的登录方式可返回一个_token字段,将这个字段保存在客户端本地,下次就可以通过login(token)方式自动登录。 服务器在实现时,一般在token中包含了用户信息,token过期时间等信息,当然进行了加密,所以比较安全。

H5应用要实现的逻辑如下:

框架已经在MUI.tryAutoLogin函数及默认的后端接口适配中完成以上逻辑,只要服务端接口符合上面约定,无需额外代码。

我们来模拟接口,让User.get接口返回未登录,让login接口支持返回_token,看看H5应用的行为:

    "login": function (param, postParam) {
        if (postParam.token) {
            console.log("用token自动登录");
            return [0, user];
        }
        return [0, $.extend({_token: "abcdefg"}, user)];
    },
    ...
    "User.get": [2, "no auth"],

如果是自行适配接口,只需将前面示例中跳转登录页的操作换成尝试自动登录,示例如下:

    MUI.callSvrExt['default'] = {
        ...
        dataFilter: function (data) {
            ...
            if (data.code == E_NOAUTH) {
                // 尝试自动登录,如果登录成功则重新发起当前请求;登录失败会自动转向登录页
                if (MUI.tryAutoLogin()) {
                    $.ajax(this);
                }
                // MUI.showLogin();
                return;
            }
        }
    };

注意:

上述对会话重用和自动登录的支持,核心是进入应用时及应用掉线时调用MUI.tryAutoLogin函数,而它是基于筋斗云后端的接口设计。 如果后端接口设计不同,可自行来写一个tryAutoLogin函数,在进入应用时及应用掉线时调用。

特别地,在tryAutoLogin中调用接口,一般使用同步调用(选项{async: false}),且忽略出错(选项{noex:1}):

var opt = {async: false, noex: 1};
callSvr("User.get", $.noop, null, opt);

5 常用组件

筋斗云框架有一些使用mui前缀的CSS类,包括:

注意:筋斗云框架不是UI组件库,它只提供极为有限的一些组件,更丰富的UI组件请使用weui库或其它第三方组件库。

如果想调整框架中组件的显示样式,一般在app.css文件中设置。

5.1 导航栏及图标

[任务]

导航栏用CSS类“mui-navbar”标识。

导航栏
导航栏

这是订单列表页中的例子,用导航栏和几个列表构建一个多栏页面, 当点击一项时,框架会自动为该项添加CSS类“active”,并只显示“mui-linkto”属性指向的组件。 被指向的组件仅限于当前逻辑页面:

    <div class="mui-navbar">
        <a mui-linkto="#lst1">待服务</a>
        <a mui-linkto="#lst2">已完成</a>
    </div>

整个H5应用有一个底部导航,用id=“footer”标识,放置在H5应用的主html文件中,如:

<div id="footer">
    <a href="#home" mui-opt="ani:'none'">
        <span>首页</span>
    </a>
    <a href="#orders" mui-opt="ani:'none'">
        <span>订单</span>
    </a>
    <a href="#me" mui-opt="ani:'none'">
        <span></span>
    </a>
</div>

底部导航可自动显示或隐藏,如果当前页面是在导航项中的,导航栏就会自动显示。 上面mui-opt属性用来指定显示页面的参数(参考MUI.showPage的参数),“ani:‘none’”表示不显示动画切页效果。

如果要给导航中的每项加图片,可以用CSS类icon:

<a href="#home" mui-opt="ani:'none'">
    <i class="icon icon-home"></i>
    <span>首页</span>
</a>

定义一个CSS类icon-home为它指定图标即可。 由于点击一项时会自动给该项加上active类,所以要想控制当前选中项或未选中项显示不同的图片,可以设置:

.icon-home {
    background-image: url(icon/24/home.png);
}
.active .icon-home {
    background-image: url(icon/24/home-active.png);
}

这些图标一般建议放在server/icon目录下,并最终在发布时优化拼合成一张大图,称为制作“精灵图”(sprite)。

对图标的CSS设置一般写在icon.css中,然后使用jdcloud-sprite工具生成拼合后的大图以及icon.out.css在H5应用中使用。 查看index.html可知它实际引用的是优化后的icon.out.css文件,icon.css只是作为源文件,用于生成icon.out.css。

在开发时,建议先把图标的CSS定义分别在icon.css与icon.out.css中各写一份。 待准备做优化时,只需在一台可制作精灵图的电脑上一次性生成icon.out.css比较方便。

图标优化方法(制作精灵图)

先安装imagemagick软件,确认在命令行中可以运行convert等命令。 安装好php 5.4或更高版本,然后在项目的tool目录下,运行命令:

php jdcloud-sprite.php ../server/icon.css -2x -group -sprite icon/icon@2x.png

查看server/icon.out.css文件是否已更新?在server/icon目录下是否生成了拼合后的大图?

命令中参数-2x表示源图标都是2x图标,即显示为24x24大小的图标,其实际尺寸是48x48。 在手机上一般使用2x图标,否则会看出有些模糊。 参数-group表示按图标宽度分组拼合图片,这样效率更高些,也可以去掉这个参数。

其实在文件tool/Makefile中已经包含了这个命令,确保开发环境有make工具, 就可以在git-bash中直接运行下面命令来优化图标:

./make-sprite.sh

5.2 简单对话框

框架提供的app_alert用于显示简单的提示框,类似alert/confirm/prompt这些函数, 只不过app_alert是异步的(调用后立即返回,需要通过回调函数来执行之后的操作),且可以定制显示样式。

在浏览器控制台里输入以下示例试试:

// 信息框,3秒后自动关闭
app_alert("操作成功", function () {
    MUI.showPage("#orderInfo");
}, {timeoutInterval: 3000});

// 错误框,"e"表示"error"
app_alert("操作失败", "e");

// 确认框(确定/取消),"q"表示"question"
app_alert("立即付款?", "q", function () {
    MUI.showPage("#pay");
});

// 输入框, "p"表示"prompt"
app_alert("输入要查询的名字:", "p", function (text) {
    callSvr("Book.query", {cond: "name like '%" + text + "%'});
});

由于app_alert对话框的id固定为“muiAlert”,所以要定制显示样式,可对#muiAlert及其子对象直接设置CSS样式; 或者自已重新定义一个id为“muiAlert”的div,详见参考文档。

5.3 对话框

对话框与页面类似,一般放在逻辑页面中。使用CSS类“mui-dialog”标识对话框。app_alert显示的就是最简单的一类对话框,

[任务]

把“创建订单”页面(createOrder页)改写成一个对话框,放在首页中。在首页增加一个“创建订单对话框”按钮,点击后显示对话框。 效果如下:

对话框
对话框

在首页home.html中增加一个id=“dlgCreateOrder”的div组件:

<div mui-initfn="initPageHome" mui-script="home.js">
    ... hd, bd ...
        <!--a href="#createOrder">立即下单</a-->
        <a href="#dlgCreateOrder">立即下单</a>

    <div class="mui-dialog" id="dlgCreateOrder" style="width:80%">
        <div class="hd">
            <h2>创建订单</h2>
        </div>

        <form class="bd" action="Ordr.add" style="padding:10px">
            选择套餐:
            <select name="dscr">
                <option value="基础套餐" data-amount=128>基础套餐 128元</option>
                <option value="精英套餐" data-amount=228>精英套餐 228元</option>
            </select>
            <button id="btnCreateOrder" class="mui-btn primary">创建订单</button>
            <input type="text" name="amount" value="0" style="display:none">
        </form>
    </div>
</div>

增加了一个链接指向它:

<a href="#dlgCreateOrder">立即下单</a>

注意:对话框的id以“dlg”开头,框架自动打开对话框而不是页面,点上面链接相当于执行:

MUI.showDialog("#dlgCreateOrder");

重新进入应用,点首页上的按钮,可以看到对话框已经显示出来了,点击对话框背景可关闭对话框。 按钮上使用了框架提供的CSS类“mui-btn”标识按钮,再加了“primary”类展现为缺省建议点击的按钮。

在home.js中用MUI.setupDialog为对话框增加逻辑: 当用户选择一个套餐并点击创建订单时,调用“Ordr.add”接口(定义在form.action属性上)。 调用成功后,显示订单列表页,并关闭对话框(MUI.closeDialog)。

function initPageHome()
{
    ...

    // 设置对话框初始化函数,一般名为 initDlgXXX
    MUI.setupDialog(jpage.find("#dlgCreateOrder"), initDlgCreateOrder);
    
    function initDlgCreateOrder()
    {
        var jdlg = this;
        var jf = jdlg.find("form");
        MUI.setFormSubmit(jf, api_OrdrAdd, {validate: onValidate});
        // 可以返回一个函数,每次显示时回调,类型"pagebeforeshow"回调。
        // 也可以直接return,没有返回值。
        return beforeShow;

        function onValidate(jf)
        {
            // 提交前,自动填写form中隐藏的amount字段
            var f = jf[0];
            f.amount.value = $(f.dscr).find("option:selected").attr("data-amount");
        }

        function api_OrdrAdd(data)
        {
            MUI.closeDialog(jdlg);
            app_alert("订单创建成功!", "i", function () {
                // 显示订单列表页
                PageOrders.refresh = true;
                MUI.showPage("#orders");
            });
        }

        function beforeShow() {
            // 每次打开对话框时清除之前选择。
            jf[0].reset();
        }
    }
}

注意:

5.4 菜单

菜单其实是一种特殊的对话框,显示一个菜单项列表。在框架中用CSS类“mui-menu”来标识它。

我们以订单详情页的右上角菜单为例讲解,效果是这样:

菜单
菜单
<div mui-initfn="initPageOrder" mui-script="order.js">
    <div class="hd">
        <a href="javascript:hd_back();" class="btn-icon"><i class="icon icon-back"></i></a>
        <a href="#dlgMenu" class="btn-icon"><i class="icon icon-menu"></i></a>
        <h1>订单明细</h1>
    </div>

    ...
    <ul id="dlgMenu" class="mui-menu top">
        <li id="mnuRefreshOrder"><i class="icon icon-refresh"></i>刷新</li>
        <li id="mnuCancelOrder"><i class="icon icon-delete"></i>取消订单</li>
    </ul>
</div>

这个是典型的手机页,标题栏左右各一个按钮,在“hd”中用两个a.btn-icon来定义并指定图标。 左上角显示后退按钮,执行操作hd_back(),这个函数是框架提供的,和history.back()类似,增强的功能是如果无法回退,则会显示首页,适合用在标题栏回退按钮上。 右上角显示菜单按钮,它的href属性设置为菜单div的id,注意要以“dlg”开头,框架就会自动以对话框方式打开它。

在页面最后定义了id=dlgMenu的菜单,指定了CSS类为mui-menu标识显示为菜单,另一个类top标识菜单在右上角,如果没有它则和对话框一样,默认显示在页面中央。

在页面js文件中,只要给每个菜单项绑定事件就可以了。

6 分页列表框架

本章介绍很常用的分页列表,详情可查阅官方参考文档中的“initPageList”函数介绍。

6.1 显示单个列表

当列表预期可能很长时,一般应支持分页。分页列表在手机上的典型展现方式是支持上拉加载和下拉刷新。

[任务]

我们先熟悉一下支持分页的列表查询接口。 在示例应用自带的模拟数据中,获取订单列表操作是支持分页的,在浏览器控制台上试试调用这些:

callSvrSync("Ordr.query");
// 返回 {nextkey: 20, list: [ {id: 147, dscr: "基础套餐", status: "CR", ...}, ...(共20条)] }

// 取下一页:上次返回的nextkey字段用于本次请求的pagekey参数
callSvrSync("Ordr.query", {pagekey: 20}); 
// 返回 {nextkey: 40, list: [ ...(共20条)] }

// 再取下一页
callSvrSync("Ordr.query", {pagekey: 40}); 
// 返回 {list: [ ...(共8条)] },没有nextkey属性,说明已是最后一页。

默认每次返回20条数据,可以通过pagesz参数控制每次返回的数据条目数,如:

callSvrSync("Ordr.query", {pagesz: 10});
// 返回 {nextkey: 10, list: [ ...(共10条)] }

我们使用这个模拟接口,新建页面orders2:

HTML: (page/orders2.html)

<div mui-initfn="initPageOrders2" mui-script="orders2.js">
    <div class="hd">
        <a href="javascript:hd_back();" class="btn-icon"><i class="icon icon-back"></i></a>
        <h2>分页列表练习</h2>
    </div>

    <div class="bd">
        <div id="lst1" data-ac="Ordr.query"></div>
    </div>
</div>

在bd部分中,用一个div(id=lst1)作为列表,用属性“data-ac”指定了后端接口。

在页面初始化函数initPageOrders2中,调用initPageList函数初始化一个分页列表:

JS: (page/orders2.js)

function initPageOrders2()
{
    var jpage = this;
    var listItf = MUI.initPageList(jpage, {
        navRef: "",
        listRef: "#lst1",
        onAddItem: onAddItem,
        onNoItem: onNoItem,
    });

    function onAddItem(jlst, itemData)
    {
        // 此处直接拼接html的做法破坏了UI与逻辑分离的原则,对复杂HTML的生成,一般应使用模板,见下节
        var ji = $("<div><b>" + itemData.dscr + "</b><p>订单号: " + itemData.id + "</p></div>");
        ji.appendTo(jlst);

        // 把itemData存储到事件中,可在事件回调中通过ev.data取到数据
        ji.on("click", null, itemData, li_click);
    }

    function onNoItem(jlst)
    {
        var ji = $("<div>没有订单</div>");
        ji.appendTo(jlst);
    }

    function li_click(ev)
    {
        var id = ev.data.id;
        // 显示订单详情页
        PageOrder.id = id;
        MUI.showPage("#order");
    }
}

函数initPageList封装了接口交互的诸多细节,调用者只需要考虑如何展示列表项即可。 在参数中, listRef指定了列表组件的引用(只在当前逻辑页上查找,相当于jpage.find(listRef)),navRef指定导航栏,这里未用到,赋值空就行,后面章节再介绍。 回调函数onAddItem用于添加一个列表项,onNoItem在列表为空时调用,用于显示没有数据时的提示。

我们在首页(page/home.html)中增加一个链接到页面orders2:

<li class="weui_cell" style="display:block"><a href="#orders2" class="weui_btn weui_btn_primary">分页列表练习</a></li>

进入页面,可以看到向下拉动可以刷新列表(重新取第一页数据),快到列表底部时可自动加载下一页数据。

还有个常用的参数是onGetQueryParam,允许编程指定调用后端接口的参数,如:

    var listItf = MUI.initPageList(jpage, {
        ...
        // 设置查询参数,静态值一般通过在列表对象上设置属性 data-ac, data-cond以及data-queryParam等属性来指定更方便。
        onGetQueryParam: function (jlst, queryParam) {
            // 指定调用名,参数为固定为"ac"
            queryParam.ac = "Ordr.query";
            // 指定其它后端接口调用参数,比如页大小,查询条件,排序顺序等
            queryParam.pagesz = 10;
            queryParam.orderby = "id desc";
        }
    }

例子中,由于是固定值,也可以在列表上通过属性data-ac="Ordr.query" data-queryParam="orderby:'id desc', pagesz:10"来指定。

默认页大小是20,由MUI.options.PAGE_SZ定义。

这里有一点要注意:列表的容器(在本例中,#lst1所在容器是.bd)需要有确定的高度,且一般设置样式“overflow-y: auto”,这样列表才能滚动。 由于页面的bd部分刚好会由框架自动设置高度,示例中没有特别去设置,如果是自定义的容器,需要设置好高度。 (TODO:这个限制可能在未来被去掉)

[规约:外界对逻辑页的操作使用逻辑页接口]

上面在显示订单详情页时,用的方法是:

    PageOrder.id = id;
    MUI.showPage("#order");

我们把PageOrder称为逻辑页order的接口(page interface),在H5应用JS文件index.js中定义:

var PageOrder = {
    // PageOrder.id
    id: null, 
};

在页面order的JS逻辑中,会根据这里的PageOrder.id显示相应订单。

尽管也可以通过全局变量等方式实现该功能(例如使用全局变量g_data.orderId),但不够清晰,不建议使用。

外界对逻辑页的操作,都应封装到逻辑页接口中。尤其不要在逻辑页外直接设置该页内的组件。 这样,要查看哪些页面引用了订单页,只要全局查找“PageOrder”即可。

这里要显示订单,也可以这样封装:

var PageOrder = {
    // PageOrder.show(id)
    show: function (id) {
        this.id_ = id;
        MUI.showPage("#order");
    },

    id_: null
};

外面直接这样调用:PageOrder.show(id). 把属性“PageOrder.id”改名为“PageOrder.id_”,暗示这个属性由逻辑页内部用,外界不应使用。

6.2 使用DOM模板创建组件

[任务]

上节练习中,函数onAddItem里,直接使用了拼接html的方式动态创建列表项,当组件复杂时可读性和可维护性很差。 我们将使用示例应用自带的weui样式库美化列表项,并用DOM模板的方法重写创建组件过程,让代码更清晰。

一般情况下,不建议直接拼接html,而是通过模板及mvvm等技术来创建。框架自带MUI.applyTpl(纯字符串操作), MUI.setFormData(对有name属性的DOM结点操作)可用于很简单的情况,请参照手册。 复杂一些的(比如有循环、条件判断创建结点等),推荐几个,比如jquery-dataview, juicervue

基于jQuery的库推荐开源的超轻量的jquery-dataview库,文档见上面链接。 下载后只需要jquery-dataview.min.js一个文件即可,把它复制到server/lib目录下,在H5应用index.html中引用:

<script src="lib/jquery-dataview.min.js"></script>

然后在页面中定义列表项的模板,我们使用示例应用自带的weui界面样式库:(page/orders2.html)

<div mui-initfn="initPageOrders2" mui-script="orders2.js">
    ...

    <div class="bd">
        <div id="lst1" class="weui_cells weui_cells_access" data-ac="Ordr.query"></div>
    </div>

<script id="tplOrder" type="text/template">
<div class="weui_cell" dv-on="li_click">
    <div class="weui_cell_hd">
        <i class="icon icon-dscr"></i>
    </div>
    <div class="weui_cell_bd weui_cell_primary">
        <p><b name="dscr"></b></p>
        <p>订单号: <span name="id"></span></p>
    </div>
    <div class="weui_cell_ft" name="status"></div>
</div>
</script>

</div>

上例中:

在JS中(page/orders2.js),我们重写onAddItem函数,使用这个模板clone出每一项:

function initPageOrders2()
{
    var jpage = this;
    ...

    // 列表项模板
    var jtplOrder_ = $(jpage.find("#tplOrder").html());

    function onAddItem(jlst, itemData)
    {
        var ji = jtplOrder_.clone().dataview(itemData, {
            events: {
                li_click: li_click
            }
        }).appendTo(jlst);
    }

    ...
    function li_click(ev)
    {
        var id = ev.data.id;
        // 显示订单详情页
        PageOrder.show(id);
        // 这仍是基于jquery的事件回调函数,如果需要取消事件冒泡可以 return false
    }
}

jquery-dataview在做事件绑定时,会自动将数据绑定到事件上。 例中,在li_click(ev)回调函数中,可以通过ev.data拿到绑定的数据,取订单id可以用var id = ev.data.id

6.2.1 使用Vue创建列表项

考虑到Vue使用的广泛,我们再介绍下怎样使用Vue模板,实现与上节完全一样的功能。引入Vue参见使用Vue创建页面章节。

然后在页面中定义列表项的模板,我们使用示例应用自带的weui界面样式库:(page/orders2.html)

<div mui-initfn="initPageOrders2" mui-script="orders2.js">
    ...

    <div class="bd">
        <div id="lst1" class="weui_cells weui_cells_access" data-ac="Ordr.query"></div>
    </div>

<script id="tplOrder" type="text/template">
<div class="weui_cell" @click="li_click">
    <div class="weui_cell_hd">
        <i class="icon icon-dscr"></i>
    </div>
    <div class="weui_cell_bd weui_cell_primary">
        <p><b>{{dscr}}</b></p>
        <p>订单号: {{id}}</p>
    </div>
    <div class="weui_cell_ft">{{status}}</div>
</div>
</script>

</div>

上例模板中用了{{dscr}}绑定数据及@click绑定事件,详细Vue模板的用法参考vue官网介绍

在JS中(page/orders2.js),初始化时用Vue.compile将模板编译一下,在onAddItem中反复使用该模板,生成DOM对象并添加到列表。

function initPageOrders2()
{
    var jpage = this;
    ...

    // 列表项模板
    var tplOrder = Vue.compile(jpage.find("#tplOrder").html());

    function onAddItem(jlst, itemData)
    {
        var vitem = new Vue($.extend({
            data: itemData,
            methods: {
                li_click: function () {
                    PageOrder.show(this.id);
                }
            }
        }, tplOrder));
        vitem.$mount();
        jlst.append(vitem.$el)
    }
    ...
}

如果模板只用一次,那么直接用 new Vue({ template: ... })就可以,在mount时会自动编译。 但由于添加列表项时会反复使用模板,所以先编译效率更好。

6.3 刷新分页列表

[任务]

控制刷新分页列表。

列表一旦显示后,每次回到该逻辑页时,不会重新请求数据或刷新,除非用户自己下拉刷新列表,这样保证了应用有良好的性能。

但有时需要在程序内控制列表刷新,考虑这样的需求:当一个订单在其它页面被修改了(例如取消订单),再回到订单列表页时希望能刷新列表。

initPageList可以很简单地实现这一需求。 先为逻辑页定义一个接口:

var PageOrders2 = {
    refresh: null,
}

在初始化列表时,添加一个pageItf选项(page interface缩写):

    var listItf = MUI.initPageList(jpage, {
        pageItf: PageOrders2,
        ...
    });

在取消订单操作时,只要赋值:

PageOrders2.refresh = true;

这样下次进入orders2页时,就会刷新列表,并把PageOrders2.refresh置回false。可以在浏览器控制台上操作试试看。

如果想要立刻刷新列表,也可以用listItf.refresh()操作。 listItfinitPageList返回值,是一个操作列表的接口,类似的操作还有显示下一页listItf.loadMore(),详见参考文档。

6.4 列表用于选择

[任务]

(choose-from-list)在首页上加一个“选择订单”按钮,点击后进入订单列表页,选择一项后返回首页,并显示订单内容。

还是用“orders2”页,我们在index.js中定义页面接口如下(主要是choose方法和onChoose回调):

var PageOrders2 = {
    ...
    // PageOrders2.choose(onChoose)
    // onChoose(order={id,dscr,...})
    choose: function (onChoose) {
        this.chooseOpt_ = {
            onChoose: onChoose
        }
        MUI.showPage('orders2');
    },

    chooseOpt_: null // {onChoose}
};

在页面orders2中:

示例:

function initPageOrders2()
{
    ...
    var pageItf_ = PageOrders2;
    jpage.on("pagehide", onPageHide);

    function li_click(ev)
    {
        var order = ev.data;
        if (pageItf_.chooseOpt_) {
            pageItf_.chooseOpt_.onChoose(order);
            return false;
        }

        // 正常点击操作 
        ...
    }

    function onPageHide()
    {
        pageItf_.chooseOpt_ = null;
    }
}

我们回到首页,在浏览器控制台中模拟调用:

PageOrders2.choose(function (order) {
    // 处理order
    app_alert('选择了订单: id=' + order.id);
    history.back(); // 由于进入列表选择时会离开当前页面,这时应返回
});

进入页面orders,选择一项后返回并继续操作。

6.5 显示多个列表

本节学习导航栏加多个列表这一常见模式。

[任务]

在示例应用时,订单列表页便按照订单状态,分成“待服务”和“已完成”两栏,分别对应一个列表。 我们将练习页面orders2也改造成支持分栏的样式。

首先,我们熟悉下后端列表查询的接口。 筋斗云后端接口支持业务查询协议,可以使用cond参数才指定查询条件:

我们现在使用的是在mockdata.js中定义的模拟接口,已经模拟了上面两个调用。

我们在页面中增加导航栏及列表:(page/orders2.html)

<div mui-initfn="initPageOrders2" mui-script="orders2.js">
    <div class="hd">
        ...
        <div class="mui-navbar">
            <a href="javascript:;" mui-linkto="#lst1">待服务</a>
            <a href="javascript:;" mui-linkto="#lst2">已完成</a>
        </div>
    </div>

    <div class="bd">
        <div id="lst1" class="weui_cells weui_cells_access"></div>
        <div id="lst2" class="weui_cells weui_cells_access"></div>
    </div>

    ...
</div>

框架提供导航栏组件,以CSS类“mui-navbar”标识,通过属性“mui-linkto”分别指向本页中的两个列表,点击时可自动切换。 我们把导航栏放在hd中,让整个bd作为列表容器。这是一种很方便的做法,如果把导航栏放在bd中,还要一个div作为列表容器,且要计算它的合适高度。

初始化列表做些修改,指定新的navRef, listRef,用onGetQueryParam来指定查询条件:(page/orders2.js)

    var listItf = MUI.initPageList(jpage, {
        ...
        navRef: ".mui-navbar",
        listRef: "#lst1,#lst2",
        onGetQueryParam: function (jlst, queryParam) {
            queryParam.ac = "Ordr.query";
            var id = jlst.attr("id");
            if (id == "lst1") {
                queryParam.cond = "status='CR')";
            }
            else if (id == "lst2") {
                queryParam.cond = "status='RE' OR status='CA'";
            }
        },

        ...
    });

6.6 分页列表的接口适配

上面学习了易用强大的分页列表,支持分页的后端接口使用的是筋斗云的规范,返回列表像这样:

{
    list: [
        {field1: "val1", field2: "val2"},
        {field1: "val3", field2: "val4"},
    ],
    nextkey: 2
}

上面用list字段返回列表。另外还支持一种等价的压缩表格式,使用h(表头)/d(数据)数组,如下:

{
    h: [ "field1","field2" ],
    d: [ ["val1","val2"], ["val3","val4"], ... ]
    nextkey: 2
}

返回列表如果没到最后一页,需要返回nextkey字段,用于请求下一页时的“pagekey”参数。 请求通过“pagesz”参数指定页大小,通过“pagekey”参数取下一页。

如果你遇到的后端分页列表接口设计不符合上述规则,则需要通过接口适配来使用分页列表框架,即让返回数据符合上面的规范,一般是设置好list/nextkey字段,或者是h/d/nextkey字段。

[任务]

后端分页机制为(jquery-easyui datagrid分页机制):

要求通过接口适配,不变动前面列表页面orders2的代码,让该页面仍能正常工作。

我们先来制作一下模拟数据,在mockdata.js中,修改“Ordr.query”部分:

    "Ordr.query": function (param, postParam) {
        var arr = orders;
        var ret = {total: arr.length, rows: []};
        var pagesz = param.rows || 20;
        var pagekey = param.page || 1;

        for (var n=0, i=(pagekey-1)*pagesz; n<pagesz && i<arr.length; ++n, ++i) {
            ret.rows.push(arr[arr.length-i-1]);
        }
        return [0, ret];
    },

这样就可以模拟了,试试

callSvrSync("Ordr.query");
callSvrSync("Ordr.query", {page: 2, rows: 10});

注意:上面返回数据的基本格式仍然是筋斗云框架的格式,即成功返回[0, 数据],失败返回[错误码,错误信息]。 如果不是这样的格式,请阅读前面介绍过的“接口适配”章节去配置MUI.callSvrExt

在app.js中设置为initPageList设置缺省选项:

$.extend(MUI.initPageList.options, {
    pageszName: "rows",
    pagekeyName: "page",
    // 设置 data.list, data.nextkey (如果是最后一页则不要设置); 注意pagekey可以为空
    onGetData: function (data, pagesz, pagekey) {
        data.list = data.rows;
        if (pagekey == null)
            pagekey = 1;
        if (data.total >  pagesz * pagekey)
            data.nextkey = pagekey + 1;
    }
});

在onGetData回调中,设置data.list及data.nextkey属性(如果是最后一页则不要设置)。

注意:app.js与index.js的区别是,前者适用于项目下的所有应用,而index.js只是index.html这个H5应用的主程序。

配置后,项目下所有列表都将应用这个适配规则。如果只是个别列表适配需要调整,可以在调用initPageList时指定这些选项,如:

    var listItf = MUI.initPageList(jpage, {
        ...

        pageszName: 'rows',
        pagekeyName: 'page',
        onGetData: ...
    });

考虑这样一种情况,后端就返回一个列表如[ {...}, {...} ],不支持分页,那么是否可以使用分页列表?

答案是仍然可用,initPageList支持一个纯数组,它将被当成列表的最后一页处理,无法上拉加载,但仍支持下拉刷新。

7 创建多个H5应用

[任务]

在示例项目中,只有一个应用即index.html。 实际在一个H5项目中,常常需要多个应用,例如给用户使用的手机客户端应用、给员工使用的员工端应用等。 我们将在同一项目下再创建一个新的“员工端”应用。

筋斗云要求每个H5应用有个内部名称(appName), 在示例应用中,appName定义为user,表示用户端,假如定义员工端应用的内部名称为emp,我们创建这些文件:

项目下所有H5应用共用的逻辑放在文件app.js中,共用的样式放在文件app.css中。 第三方库文件,一般放在lib目录下,每个应用均可引用。

在emp.js中正确配置:

$.extend(MUI.options, {
    appName: "emp",
    homePage: "#home",
    pageFolder: "emp",
});

应用内部名称appName将会在callSvr发起的调用中,自动通过URL参数_app传给后端。 后端可以根据应用不同,建议使用不同的cookie名来区分,这样即使浏览器同时打开这两个应用,也不会有冲突。

关于代码放到哪个文件中,原则如下:

8 H5应用优化

目前我们运行的H5应用直接是在项目下的server目录中,这称为开发版本,没有进行优化。 在生产环境下,一般会将开发版本进行优化,生成发布版本后上线,可提升H5应用性能。

8.1 用webcc编译H5应用

[任务]

使用webcc工具,编译项目下的server目录,生成发布版本目录“output_web”。

作为一个WEB应用,发布时最常见的需求是JS/CSS/HTML文件合并和压缩(minify)。 H5应用自身用的index.js/index.css文件可以内嵌到主文件index.html中,常用的逻辑页面(包括html/css文件)也可以内嵌到index.html中来。

筋斗云通过名为webcc的工具进行应用优化,也称为编译。 本章详细介绍可参考官方文档“webcc”。

我们先看怎么运行它。 webcc是php工具,必须先安装php环境(版本5.4或更高); 在Windows系统上,建议安装git,它自带的git-bash环境模拟了简单的linux/unix环境,如果已安装mingw或cygwin也可以。

webcc一般要求源代码使用git管理,通过git命令查询源文件列表及版本差异,实现增量编译、自动化发布等功能。 如果你已用git管理项目,则在项目目录中打开git-bash(或其它linux shell环境),运行命令:

$ php tool/webcc.php server

上面server是待编译的开发版本目录,里面有所有开发的内容。运行后生成发布版本目录“output_web”。

如果项目未使用git管理,则要求指定源文件列表,在运行webcc之前必须先设置环境变量WEBCC_LS_CMD,例如:

$ export WEBCC_LS_CMD='find . -type f'

注意:上面命令会将目录下所有文件都编译并发布,应确保清除目录下无用的文件。 如果你使用的是svn管理项目,则需要把“.svn”目录过滤掉以免生成到发布目录:

$ export WEBCC_LS_CMD='find . -type f | grep -v .svn'

或者使用svn命令精确列表哪些文件要发布:

$ export WEBCC_LS_CMD='svn ls -R'

至于编译生成的发布目录和源目录有哪些不同,下面将讲述。

8.2 webcc配置解读

在示例应用的server目录下,有一个webcc.conf.php的配置文件,里面定义了优化策略,一般无需修改:

$RULES = [
    '*.html' => 'HASH',
    ...
];

第一条规则是server目录下(不包括子目录)的所有html文件,即所有H5应用,执行HASH规则,对于html文件,会处理其中的webcc标记。

在示例应用中,我们在index.html中查找webcc,可以看到有这些标记:

<!-- WEBCC_BEGIN MERGE=lib 外部库 {{{-->
    <link rel="stylesheet" href="lib/weui.min.css" />

    <script src="lib/jquery-1.11.1.min.js"></script>
    <script src="lib/jquery.touchSwipe.min.js"></script>
<!-- WEBCC_END }}}-->

<!-- WEBCC_BEGIN MERGE=lib-app 内部库 {{{-->
    mui.css, app.css等css文件...
    app_fw.js, app.js等js文件...
<!-- WEBCC_END }}}-->

<!-- WEBCC_BEGIN MERGE 应用专用 {{{-->
    <link rel="stylesheet" href="index.css" />
    <script src="index.js"></script>
<!-- WEBCC_END }}}-->

...

<!-- WEBCC_BEGIN {{{ embeded pages -->
<!-- WEBCC_USE_THIS
WEBCC_CMD mergePage -minify yes page/home.html page/login.html page/login1.html page/me.html
WEBCC_END }}} -->

先看外部库、内部库,它们分别被放置在标记WEBCC_BEGIN MERGE=xxx / WEBCC_END之中。 这意味着其中的css, js文件会被合并到一起,压缩后生成一个文件xxx.js或xxx.css。

外部库表示第三方库,如果应用中用到了其它库,且文件大小并不大,可以放置到这一块中,以便多个库合并成一个文件优化下载。 内部库是筋斗云框架自身及你的项目内所有H5应用的通用部分(app.js, app.css)。

应用专用就是当前H5应用用到的js/css。使用的webcc标记与前面比,没有指定“MERGE=xxx”,只指定了“MERGE”,这表示合并其中内容到当前文件,即把index.css/index.js内嵌到index.html文件中。

最后一块是内嵌逻辑页,用“WEBCC_USE_THIS”标记和“mergePage”命令指定了一些逻辑页,这些页面一般是最常用的页面,这个html及其引用的js文件将被直接内嵌到index.html中。 框架在加载逻辑页时,如果发现已内置于主html中则优先使用内置页,否则就触发缺页中断从而远程加载。

9 H5应用发布上线

上一章介绍了H5应用的编译优化,生成了发布目录,配置好WEB服务器后,将发布目录上传到服务器即可完成发布。

为了H5应用程序升级后客户能及时更新,建议将H5应用的主html文件及逻辑页面文件夹下html/js文件的缓存策略设置为“no-cache”。 (在开发环境下,一般建议所有文件都设置为不缓存。)

建议使用Apache或nginx作为筋斗云H5应用线上生产环境的WEB服务器。 项目下已有专为这两种服务器的配置,即为Apache用的.htaccess文件,以及为nginx准备的.ht.nginx文件。

[任务]

配置Web服务器,访问H5应用,在Chrome浏览器的网络监控中查看请求,要求:

示例如下:

如果使用Apache服务器,应配置项目下允许.htaccess文件,比如

<Directory /var/www/html/myproject>
    AllowOverride All
</Directory>

如果使用nginx,可以把项目下的.ht.nginx文件包含到nginx的主配置文件中。注意一些路径可能需要修改。

如果使用其它WEB服务器(如IIS),应手工作相应的配置。

此外,对所有html/css/js这些文本文档都应设置gzip压缩,注意图片文件一般不设置gzip压缩,因为图片有自己的压缩算法。

9.1 自动化差量发布

[任务]

编写和运行项目下的build_web.sh文件做为上线工具,运行它实现自动编译和将新近修改的内容上线。

如果希望每次修改一些内容后,可以快速将差异部分上线,不必每次都上传所有文件,可以使用筋斗云自带的上线工具。

筋斗云框架支持WEB应用自动化发布,并可差量更新。 目前差量更新依赖git工具,要求源目录及编译生成的发布目录均使用git管理,每次只上传与线上版本差异的部分。 本章详细介绍可参考官方文档“webcc”中的“jdcloud-build”模块。

自动化发布支持ftp/git两种方式,前者只需服务器提供ftp上传帐号,后者需要服务器提供git-push权限。 本章介绍git方式,安全可靠且版本可任意回溯。ftp方式只需修改若干参数,可参考官方文档。

我们的示例项目名为myproject,已使用git管理。 先创建发布版本库(又称online版本库), 使用git管理,定名称为myproject-online,习惯上与目录myproject放在同一父目录下:

$ git init myproject-online

在线上服务器上设置ftp帐号或git帐号。使用git发布时,一般配置好用ssh证书登录,避免每次上线时输入密码。

将tool/git_init.sh上传服务器,用它创建线上目录:

$ git_init.sh myproject

编写项目根目录下的build_web.sh脚本:

#!/bin/sh

export OUT_DIR=../myproject-online
export GIT_PATH=www@myserver:myproject
tool/jdcloud-build.sh

在Windows平台上,打开git shell运行build_web.sh即可上线。

10 制作原生APP

H5应用可以打包生成苹果或安卓原生应用,一般也称为混合应用程序(Hybird App)。

并不是所有的H5应用都适合打包成原生应用,如果把一个普通的手机网站包装成原生应用,即使它制作的外观像是原生应用, 但切换页面时的网页刷新和加载导致速度慢、费流量、体验差,并不适合制作APP,而且像苹果应用市场等也容易拒绝此类APP上架。 为了接近原生应用的用户体验,可打包的H5应用应是支持多逻辑页的单网页应用,也就是变脸式应用。

我们制作的原生应用最大的特点是它与H5应用是分离的,从而在应用市场上架后仍可随时轻松升级应用。 这种原生应用我们也把它称为原生壳。

10.1 编译原生壳

[任务]

准备工作:

详细步骤可参考Cordova官方文档.

在添加cordova插件时,splashscreen和statusbar插件是必用的,在H5应用中会调用它们的接口。

我们的H5应用与原生壳是分离的,不打包到壳中去。 在config.xml中,以下项需要注意:

<allow-navigation href="*" />
<access origin="*" />
<content src="http://myserver/myproject/index.html?cordova=1" />

注意,H5应用后面加上参数cordova={壳版本},筋斗云框架将识别这个参数,进入原生应用模式,自动加载插件接口。

一般建议使用jdcloud-app模板工程,可在github上下载。 在模板工程中添加了图标和启动页工具,且已添加常用的插件,使用方法可查看其中的README文件。

编译好后,先不要立即安装到手机上,还有重要的一步,将插件接口更新到H5应用中去,这样在H5应用中可调用原生功能。

更新原生插件接口到H5应用。

在Cordova工程中,分别找到android和ios平台下的www目录:

{cordova工程}/platforms/android/assets/www
{cordova工程}/platforms/ios/www

然后找到以下三个插件接口相关的文件或目录:

cordova.js
cordova_plugins.js
plugins/ (整个目录)

把安卓平台中的这些文件复制到H5应用的目录server/cordova下面,把IOS平台中的这些文件复制到server/cordova-ios下面。这样就完成了H5应用中设置原生插件接口。

注意:当H5项目中有多个应用时,plugins目录直接取并集即可,而cordova_plugins.js文件需要小心合并。

这时将apk包或ipa包安装到手机上,打开应用程序,看看是否能正常运行。

我们在H5应用中书写代码时,可以检查全局变量g_cordova。 例如,想要仅在原生应用中显示某个页面,在微信或浏览器中访问时提示错误:

if (! g_cordova) {
    app_alert("本功能请在APP中点击进入");
    return;
}
MUI.showPage("#xxx");

10.2 壳版本管理

请牢牢记住,由于原生壳与H5应用的分离,用户安装的用户壳可能是旧版本的,而线上的H5应用永远是最新版本。

假如在原生壳中新增加了插件,应增加壳版本号。 操作上也可将壳版本等同于原生应用的版本代码(安卓叫App Version Code,苹果叫CF Bundle Version),那么要注意插件变动时,一定也要变动原生应用版本。

需求:新版本壳中增加了某插件,希望安装旧版本壳的用户在用到此插件时,提示更新APP。

假定当前壳版本为1,注意在配置文件config.xml中修改壳版本,假如为2:

<widget id="com.daca.jdcloud" version="2.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
    <content src="http://myserver/myproject/index.html?cordova=2" />
〈/widgets>

例子中,同时修改了version="2.0.0"以及URL中的?cordova=2。后者用于在H5应用代码中检查版本兼容性。

然后编译好新版本安装包。 由于插件变化了,仍然需要更新原生插件接口到H5应用,将新的插件合并到H5应用的cordova或cordova-ios目录中。

注意cordova_plugins.js文件定义H5应用可用哪些插件,需要手工合并和设置版本。

假设我们在用户端上新增加了一个微信分享插件,cordova_plugins.js文件如下:

module.exports = [
    ...,
    // 以下为新增部分:
    {
        "file": "plugins/com.xxx.weixin/www/weixin.js",
        "id": "com.xxx.weixin",
        "clobbers": [
            "navigator.weixin"
        ]
    }
]

前面提到过,每个筋斗云H5应用都有一个惟一的应用名(MUI.options.appName),例如用户端设置应用名为“user”。 我们为新的插件加上filter属性:

module.exports = [
    ...,
    // 以下为新增部分:
    {
        "file": "plugins/com.xxx.weixin/www/weixin.js",
        "id": "com.xxx.weixin",
        "clobbers": [
            "navigator.weixin"
        ]
        // 指定客户端应用(名为user)从壳版本2开始支持该插件
        "filter": [ ["user", 2] ]
    },
]
// 新加上这一句处理版本
filterCordovaModule(module);

filter属性的格式为[ [app1, minVer?=1, maxVer?=9999], ...], 仅当应用名匹配且版本在minVer/maxVer之间才使用。 如果未指定filter,则表示加载该插件。 假定还有个员工端应用名为emp,在壳版本300时增加了该插件,则可以设置:

"filter": [ ["user", 2], ["emp", 300] ]

这样,不同的H5应用版本加载的插件是不一样的,要在浏览器中测试查看每个壳版本分别加载了哪些插件,可以直接访问带cordova参数的H5应用地址如:

http://myserver/myproject/index.html?cordova=1

然后在Web控制台中执行:

cordova.require('cordova/plugin_list')

最后,我们在H5应用中检查插件是否可用,以及提示用户升级:

if (g_cordova < 2) {
    app_alert("您的APP版本太旧,请升级后使用本功能。");
    return;
}
// 调用新插件的功能。

10.3 调试原生应用

由于原生应用开发调试与H5开发调试的技术栈不同,只要不是原生插件本身的问题,尽量先在电脑浏览器上调试。

对于只能在手机上运行的功能,注意加些调试代码,让它也在网页中也能模拟运行。 比如微信分享后可以领取红包,为了在普通浏览器中可调试,可以这样做:

if (! g_cordova) {
    // 模拟代码
    if (g_args.mock) {
        if (confirm("模拟分享?"))
            onShareOk();
    }
    app_alert("必须在App中运行");
    return;
}
微信分享(成功后回调 onShareOk);

function onShareOk()
{
    // 领取红包
}

g_args.mock表示在URL参数中有“?mock=1”时走模拟分支。这样绝大多数问题都不用在手机上调试。

如果在电脑浏览器上运行正常,但在手机应用中运行出错,需要尝试在设备上调试H5应用。 对于安卓应用,可在Chrome中调试手机应用。注意在编译壳的选项中,不要加--release参数,如:

cordova build android

在安卓手机上,打开USB调试选项(请自行搜索如果进入开发者模式及打开USB调试),连上电脑, 然后在Chrome地址栏中输入chrome://inspect即可进入设备调试。

注意:由于google的站点国内很难访问,如果调试页面打不开,须通过代理访问。

调试苹果应用,得用苹果电脑上的Safari浏览器。 先开启iPhone/iPad上的Safari的远程调试功能:“设置 > Safari > 高级” > Web检查器选中。 将iphone插入电脑,弹出是否允许调试,选择“是”。 打开Safari,在“开发”菜单下可见有一行是 该手机的名字,里面显示有可调试的页面。

如果是首次在mac上使用safari时,应先激活“开发工具”菜单项: (menu)preference(偏好设置)->高级->在菜单栏中显示“开发”菜单。

如果以上调试环境很难配置成功,那么只能通过在代码中加alert来一点点逼近问题。

如果确定问题出在原生插件上,或者需要修改原生插件,那么只有调试原生java或object-c/swift代码。