项目介绍:制作一个网赚应用
平台:ios手机
技术选型:vue + vuex + vue-router + vue-resource + webpack + es6 + sass + postcss
最终效果图:更多可到App Store下载“悦动music”
背景
毕业之后首个用新技术单独完成的项目,项目是从2015.10月开始的,当时vue还没有2.0。
产品需求
业务角度(不展开说,有兴趣私聊)
用户做任务(下载应用)-->检测有效性-->给分给用户
技术角度
- 可快速迭代
- 获取ios手机上的一些信息
- 用户体验友好
技术架构
出于以下的考量:
a. 获取ios手机上的一些信息,只能通过ios客户端来实现,这是业务的重心
b. ios客户端需要上传到App Store,每次迭代都需要至少2天的审核,这样对可快速迭代不利
c. 因为a的一些实现跟App Store的规则有打擦边球的嫌疑,有时候会突然下架
参考竞品,以及综合分析,然后我们定出了这样的架构:
Web App + Native App + 后端
这里稍微说一下
黑线路径,要经过客户端,发挥客户端的优势,譬如说加密、一些客户端的功能(譬如说截图、第三方软件分享、登录)
蓝色路径,只要前端跟客户端拿到token,就可以直接跟后端通讯,免除每次都经过客户端
前端技术选型分析
客观角度
问题来了,作为多页Web App,需要考虑的地方(更多点击这里):
i. 状态管理
譬如说,多页面应用下,A页面跳去B页面,在B页面提交了数据,返回A的时候,我想利用B页面的数据,是不可以的,因为进行了刷新,又或者说这两个页面没有可以通讯的中介。其实我们可以用localStorage、url参数来作为中介,这两个页面之间的通讯还好说,但是如果是两个以上的,就很难维护了。
ii. 丧失入口、路径控制权限主观角度
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
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就可以了。
无意中看到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