《红包日历》ios版本——基于vue全家桶的webapp单页应用

项目介绍:制作一个网赚应用
平台:ios手机
技术选型:vue + vuex + vue-router + vue-resource + webpack + es6 + sass + postcss
最终效果图:更多可到App Store下载“悦动music”


首页
tipLayer

背景

毕业之后首个用新技术单独完成的项目,项目是从2015.10月开始的,当时vue还没有2.0。

产品需求

业务角度(不展开说,有兴趣私聊)
用户做任务(下载应用)-->检测有效性-->给分给用户

技术角度

  1. 可快速迭代
  2. 获取ios手机上的一些信息
  3. 用户体验友好

技术架构

出于以下的考量:
a. 获取ios手机上的一些信息,只能通过ios客户端来实现,这是业务的重心
b. ios客户端需要上传到App Store,每次迭代都需要至少2天的审核,这样对可快速迭代不利
c. 因为a的一些实现跟App Store的规则有打擦边球的嫌疑,有时候会突然下架

参考竞品,以及综合分析,然后我们定出了这样的架构:
Web App + Native App + 后端


架构

这里稍微说一下
黑线路径,要经过客户端,发挥客户端的优势,譬如说加密、一些客户端的功能(譬如说截图、第三方软件分享、登录)
蓝色路径,只要前端跟客户端拿到token,就可以直接跟后端通讯,免除每次都经过客户端

前端技术选型分析

  1. 客观角度
    问题来了,作为多页Web App,需要考虑的地方(更多点击这里):
    i. 状态管理
    譬如说,多页面应用下,A页面跳去B页面,在B页面提交了数据,返回A的时候,我想利用B页面的数据,是不可以的,因为进行了刷新,又或者说这两个页面没有可以通讯的中介。其实我们可以用localStorage、url参数来作为中介,这两个页面之间的通讯还好说,但是如果是两个以上的,就很难维护了。
    ii. 丧失入口、路径控制权限

  2. 主观角度
    2015年10月,vue还没出1.0,第一次接触mvvm框架,也听说过当时很热的Angular、React。当时是抱着越简单越好的心态去选,然后就无意中挑选了vue。关于mvvm的框架对比,可以看看这里
    而且也只有我一个人负责,所以更大胆地用新框架。途中经历过入职答辩,以及跟网友聊天。才发现自己在技术选型上是随意的,我问了网友一个关于vue-resource的跨域问题,网友问了我一个问题:为什么选择vue-resource,有什么特别之处吗?为什么不用普通的ajax?我这才从懵逼中醒悟过来,技术为需求而生。

单页面应用架构,有以下特点:
1)在一个页面下切换视图,而不需要重新加载整个网页,这样一来就减轻了加载资源的负载、缩短了用户的等待时间;
2)路由控制视图切换
3)组件化开发,利于分治、复用
4)MV*,免掉繁重的dom操作
5)方便共享数据

Vue是前端MVVM框架,它实现了组件化、模板渲染等功能
VueRouter可以控制路由,从而切换视图
VueResource封装了Promise的写法以及对Restful API更友好

最后不谋而合,我们就选取了Vue全家桶来制作单页应用

当然单页应用也有缺点:
1)首次加载比较慢
2)对SEO不友好
3)浏览器本身的历史回退

JUST DO IT √

  • 构建项目
    vue-cli:目录结构
    webpack gulp:构建项目,压缩代码,自动化脚本,打包代码
    npm:包管理

  • 开发flow
    git flow
     搭建开发/测试环境:webpack-dev-server hot-reload webpack.conf
    webpack打包大小优化:code splitting、压缩
    webpack本地构建优化:把第三方库放在vendor或者externals等等

  • 功能区分以及开发
    utils
    config
    mixins
    公用组件
    view

  • 布局
    z轴上,采用weui的规范
    区分公用组件和view进行布局
    自适应布局flexible.js + rem + flex布局
    -webkit-overflow-scrolling : touch造成的堆叠上下文

  • 其他
    官网上百度搜索,zhanzhang.baidu.com


vue的功能:(√ 表示项目中用到的)

  • 数据驱动更新视图 √
  • 试图切换&过渡效果 √
  • 路由 √
  • 组件之间的通讯※ √
  • 状态管理 √
  • vdom
  • 单元测试
  • 后端渲染

制作的过程中,我觉得组件间通讯比较重要:
vue1.x:
方法①broadcast、dispatch(父子、兄弟组件通讯)vue2.x废弃该方法
方法②this.$root、this.$children(父子、兄弟组件通讯)
方法③prop、emit(父子组件通讯)vue1.x prop支持双向绑定
方法④this.$refs(父->子单向通讯)
vue2.x:
方法①event bus(兄弟组件通讯)
方法②prop、emit(父子组件通讯)vue2.x prop不支持双向绑定


遇到的问题

1. 跨域cookie共享
因为需要知道用于的登录状态,所以使用token来作为标示,前端传送的http头部如果带有token的cookie,后端检验token通过,就代表该用户有效且处于登录状态。但是跨域传输cookie需要配置一些东西。

如果不跨域,前端直接使用document.cookie,发送请求到后端,会自动带上cookie;
如果跨域,默认是不带cookie的,如果需要跨域带上cookie需要做以下步骤:
1)前端设置cookie的domain为后端的域名

document.cookie = "key=value;domain=backend.website.com;path=/"

如果前端域名和后端域名的主域名是相同的,可以直接设置为主域名,譬如这个项目是前后端分离的,浏览器访问html文件,是m.hongbaorili.com,这个域名对应的是前端的文件,而后端的接口是ios.hongbaorili.com,他们有相同的部分hongbaorili.com,直接把cookie的domain设置为这个也可以。

document.cookie = "token=xxx;domain=hongbaorili.com;path=/"

2)前端设置xhr.withCredentials=true
3)后端设置http返回头部

// 设置允许跨域的域名,注意如果是跨域传送cookie,是不能设置为*的,必须指定域名
Access-Control-Allow-Origin: http://m.hongbaorili.com
// 设置允许跨域共享cookie
Access-Control-Allow-Credentials: true

2. http的简单请求和非简单请求(preflight)
因为使用vue-resource来进行处理请求,其实它主要就是使用了promise包了一层ajax,当然还有设置了一些勾子让用户灵活设置,譬如我在这里提问的问题。
它还默认对post、get方法设置了一些方法和html头部,其中的post方法(vue-resource v0.9.3)默认设置了HTTP头部Content-Type:"application/json;charset=utf-8。当我尝试使用vue-resource的方法的时候,会失败,抓包一看状态是403(method not allow),它发送了一个method为OPTION的包,我当时是想着有没有方法不走OPTION直接走post方法,就查到了相关的资料:

浏览器将CORS的请求分为:简单请求非简单请求
简单请求必须同时满足以下要求,否则为非简单请求:

来源:阮一峰博客

非简单请求:
请求方法是PUT或者DELETE
Content-Type: application/json
凡是非简单请求,在正式通讯之前,会发送一个OPTION方法的数据包,作为预检请求(preflight),询问后端当前请求是否在许可名单(Origin)、可以使用哪些http方法(Access-Control-Request-Method)、可以带上哪些头部信息字段(Access-Control-Request-Headers)。后端通过查询对应的Access-Control-Allow-Origin、Access-Control-Allow-Methods、Access-Control-Allow-Headers字段,如果通过就返回一个200,然后就进行数据通讯。如果不通过,返回的数据不包含跨域的信息头部,表示失败,这时候xhr的onerror就会响应。

所以找到了问题的关键是vue-resource默认的post方法使用了Content-Type: application/json触发了preflight。所以可以直接把默认的选项去掉,然后加上emulateJSON: true来表示application/x-www-form-urlencoded,然后就能触发简单请求。具体的解决步骤在这里

以上两个问题详见:
http://www.ruanyifeng.com/blog/2016/04/cors.html

3. 组件:无限滚动
关键点:
1)判断滚动到底部,触发拉取新数据,添加新数据
判断滚动是否到底部有用到:滚上去的高度scrollTop + 页面的高度clientHeight === 网页的高度scrollEle.offsetHeight
2)零部件:
设置flag变量,防止滚动到底部发送多个请求;
设置page、size变量表示拉取的页数、数据条数;
3)优化点:
首次进入的时候,未获取到数据的时候,用变量loading来记录;
当加载完毕(条目<size || 第一个的size===0),用变量nodata来记录。
使用-webkit-overflow-scrolling: touch在ios端滑动起来体验很好

可继续优化的点:
上拉加载更多,或者下拉加载更多,有多余的块显示

4. 布局、组件与功能的考虑
首先我们把组件分为公用组件和私有组件,后来看到资料,发现私有组件都在view(视图)里面,所以应该是这样分类:components(组件)和view(视图)。

然后以App.vue为根组件,公共组件和router view挂载在App.vue下,大概是这样的:

我的布局

其中遇到的问题:
1)一些可复用组件,譬如说confirm组件,它的模子就只有提示框的骨架,其中的内容需要用slot来写。它应该放在公共组件的位置,还是view里面?
当初我没有细想,就放在公共组件的位置,执行起来的时候,遇到超级不爽的地方:
①每个view都要跟公用组件通讯,传递提示框的自定义信息,包括插图地址、主标题、副标题、正文、提示;
②每个提示框的“取消事件”还好说,都是把confirm组件隐藏掉;但是”确定事件”就不是每个confirm组件是一样的,所以也需要动态绑定。
这种方案用以下两种方法实现组件间的通讯:
a. confirm组件作为全局组件,状态记录在vuex。这样对于①的操作就很简单了,传一个json过去就可以;但问题在于如何动态绑定“确定事件”,我的做法是在vuex添加一个变量yesCounter,每次confirm组件的“确定”按钮点击之后,yesCounter就+1,在view里面watch这个变量(yesCounter),方法写在view的methods里面,当监测到改变就触发事件。

b. confirm作为公共组件,挂在在App.vue下,指定组件名称confirm。在view里面,用this.$root.refs.confirm来调用里面的东西、以及赋值。

c. confirm组件作为view里面的组件,对于①的操作,很直观简单;对于②的操作用emit事件;而且这个方案的好处在于“按需加载”,因为有些view是不需要confirm组件的。

于是我采用方案c,但是呢,这又有一个问题,就是当confirm组件的出现的时候,我希望它把全屏遮住了,但是它又内嵌到view里面。《当时我没找到方法,就徘徊地用回方案a,但是的确太恶心了,就狠下心来把方案c产生的问题解决掉(这种习惯应该抛弃啊!)。当时想了三个方案:
i) app__header、app__content放在同一个wrapper里面,控制content的高度,超出的范围滚动条显示。这种ok


方案 i

ii) app__header、app__content放在同一个wrapper里面,但是是使用flex布局的。这种方案肯定不可以,因为view怎么样都覆盖不了header的;

iii) app__header用fixed布局,app__content的高度是100%,header跟content的堆叠上下文是相同的,但是我需要把header置顶,所以直接把header的顺序放在下面。然后放在app__content的confirm组件设置z-index就可以了。

App.vue
confirm

无意中看到weui的布局,印证了当初自己的思考也比较合理

weui页面层级

5. 抽象view的逻辑 && promise && es6
由于每个view都有以下特点:
①每次加载的时候都会向后端ajax请求数据;
②通过设置route的data选项,如果①请求数据失败,视图就切换回去之前的;
③每次按刷新的时候,向后端ajax请求数据,更新data;

稍微分析一下,其实①②③的加载数据是可以复用的,但是②中,路由的data勾子要传入transition这个变量,用transition.next()和transition.abort()控制视图切换,是否但是在①③不需要。综合以上需求,就写了一个mixin,如下:

export let routerDataMixin = {
    route: {
        data: function (transition) {
            var that = this;
            if (this.assist.token && this.fetchOption) {
                new Promise(function(resolve, reject) {
                    that.fetchData({resolve, reject})
                })
                .then(function(data){
                    transition.next();
                })
                .catch(function(error){
                    console.log(error);
                    transition.abort();
                })
            } else {
                transition.next();
            }
        }   
        //waitForData: true
    },
    methods: {
        fetchData: function(...rest) {  // 用上es6的rest,很方便
            if (!this.fetchOption) {
                return false;
            }

            this.$http.get(
                this.fetchOption.url,
                {
                    params: this.fetchOption.params || {},
                    credentials: /hongbaorili/g.test(this.fetchOption.url)
                }
            ).then(
            function (response) {
                if (response.data.c === 0) {
                    if ( this.fetchSuccess ) {
                        this.fetchSuccess(response);
                    } else {
                        this.userData = response.data.d
                    }
                    try {
                        rest[0].resolve();
                    } catch(e) {}
                } else if (response.data.c === -10000){
                } else {
                    if ( this.fetchAbnormal ) {
                        this.fetchAbnormal(response);
                    }

                    try {
                        rest[0].reject(new Error("fetchData: c!=0"))
                    } catch(e) {}
                }
                this.endProgress();
            },
            function (response) {
                if ( this.fetchFail ) {
                    this.fetchFail(response);
                } else {
                    this.showToast();
                }

                try {
                    rest[0].reject(new Error("fetchData: fail"));
                } catch(e) {}

                this.endProgress();
            });
        }
}

6. BEM类命名方法
.component-name__component-part_component-status
eg:
.tab__tab-item_active
当然也可以灵活处理
.tab__tab-item.active
更多详见这里

7. Restful API
增删查改
post del get put
后端同事说项目小没必要这么复杂,就只做了get和post

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,530评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,403评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,120评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,770评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,758评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,649评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,021评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,675评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,931评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,751评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,410评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,004评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,969评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,042评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,493评论 2 343

推荐阅读更多精彩内容