记录学习路程


  • 首页

  • 分类

  • 归档

electron-vue邮件客户端

发表于 2018-05-10 | | 阅读次数

关于项目

这是我的毕业设计(2018),邮件客户端
包含收发邮件、通讯录、多账户登录、本地数据保存等功能

使用的相关模块

  • 用vue-cli构建electron-vue项目
  • 用node-imap模块接收邮件
  • 用nodemailer发送邮件
  • 用element-ui做样式框架
  • 用lowdb做本地数据存储
  • 用iconv-lite、quoted-printable、utf8等处理编码
  • 用vue-quill-editor做富文本编辑器

调试运行

1
2
3
npm run dev # 调试运行,localhost:9080
npm run build # 打包

页面截图

收件箱

邮箱详情

写邮件

通讯录

项目目录

最外层结构是由electron-vue创建,主要看src的结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
─ src
├── main
│ ├── index.js #主进程,创建渲染进程
├── models #定义模型,用于封装对象
├── renrender #渲染进程,里面就是一个vue项目目录
│ ├── common #一些重要的js函数与公共样式
│ ├── javascript
│ ├── cache.js #硬盘存取相关函数
│ ├── config.js #存放配置及正则表达式
│ ├── getEmail.js #获取email的函数
│ ├── parseEmail.js #解析email的函数
│ ├── sendEmail.js #发送email的函数
│ ├── style
│ ├── components #存放组件
│ ├── pages #存放页面
│ ├── router #路由
│ ├── store #vuex的store相关文件
│ ├── app.vue #vue页面最外层结构
│ ├── main.js #vue项目入口
├── index.ejs #electron页面入口

开发过程

关于electron和vue

electron将chromium和nodejs合并到同一运行时环境中,可以用html、css、javascript来构建跨平台的桌面应用。说白了就是我们写网页的同时还可以调用nodejs的api(如调用fs模块存储数据到电脑),然后electron帮我们打包成一个跨平台的桌面应用。
vue是当前主流mvvm框架之一,这里就不多介绍了,用到了vuex、router等,不懂的话需要先去了解一下才能看懂项目
本项目用vue-vli初始化electron-vue,开发方便

1
vue init simulatedgreg/electron-vue Vmail

初始化选项

打包选的是electron-builder,这个工具可以直接打包安装包,而electron-packager打包成可执行文件

项目思路分解

项目主体是邮件,比较重要的有四步:获取解析邮件、存储邮件、显示邮件和发送邮件
获取和发送邮件要根据邮件协议来分析

获取与解析邮件

读取邮件的协议有pop3(Post Office Protocol)、imap(Internet Message Access Protocol)。pop3简单但交互性较弱。imap较复杂,可交互性强,是一个联机协议,如可以获取邮件后将邮件置为已读,而pop3协议是只读的。
如果要自己实现获取协议会比较麻烦,去github逛了一圈,发现node-imap这个库挺不错的,就用了它

关于密码要先去邮件服务器开通获取,如qq:邮箱->设置->账号->imap服务,开启(需要自己手动保存密码)

项目中获取邮件有两个方法:一个是获取一个完整的邮件(getEmailDetail),一个获取一组邮件头(getEmailList)。如在登陆成功后,会自动获取邮件列表显示出来,此时是调用getEmailList。当点击某一个邮件时,会自动获取一个完整的邮件,调用getEmailDetail。node-imap这个库包含着两个功能,还支持很多不同参数,可自行去github熟悉。

下面重点讲解析一封邮件

邮件有邮件头和邮件体两部分。邮件头的格式基本都是一样的,而邮件体格式就多种多样了,因为有很多类型如:纯文本,html页面,包含附件等等
第一步是看Content-Type

  1. text,主要有text/html和text/plain,内容需要用Content-Transfer-Encoding解码,常见传输编码为base64和quoted-printable
  2. multipart,又分为mixed、alternative和related。multipart有boundary分割符,将邮件体分割成不同段
    • mixed是有附件的类型
    • alternative是纯文本和超文本同时存在的类型
    • related是资源内嵌类型,如内容为html,但html里有图片,把图片提取出来以附件形式发送
  3. image、application,一般是出现在附件中的格式

第二步看boundary

只有multipart类型才有boundary,因为这种类型比较复杂,需要用boundary分段解析

1
2
Content-Type: multipart/mixed;
boundary="----=_NextPart_5A640E3E_0AF97620_651579F6"

这里的boundary是一串字符,但是分割不是直接用boundary,而是用父段和子段来分割

父段: '--' + boundary + '--'
子段: '--' + boundary

据我观察,父段只出现0次或1次并且在最后的位置(可能我遇到的邮件类型有限),所以内容就是分割后的数组的第一个元素:emailText = emailText.split(fatherBoundary)[0].trim()
子段将内容分为不同的段,每个子段需要单独重新解析,因为里面也有自己的Content-type和boundary,所以一封邮件可能出现两个不同的boundary

第三步解析
若分割后的段是html或附件等,直接根据charset和encoding等解析;若仍是multipart类型,则用同样的思路再次解析(关于编码解析下面有说)

下面举个例子(已删除部分不必要的):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
From: "=?gb18030?B?amlhbmJvKw==?=" <490549111@qq.com>
To: "=?gb18030?B?amlhbmJvKw==?=" <635638508@qq.com>
Subject: 123
Content-Type: multipart/mixed;
boundary="----=_NextPart_5A5F05FC_0A84FD10_42CF1A15"
Content-Transfer-Encoding: 8Bit
Date: Wed, 17 Jan 2018 16:14:52 +0800
This is a multi-part message in MIME format.
------=_NextPart_5A5F05FC_0A84FD10_42CF1A15
Content-Type: multipart/alternative;
boundary="----=_NextPart_5A5F05FC_0A84FD10_3247BB56";
------=_NextPart_5A5F05FC_0A84FD10_3247BB56
Content-Type: text/plain;
charset="gb18030"
Content-Transfer-Encoding: base64
MTINCjM=
------=_NextPart_5A5F05FC_0A84FD10_3247BB56
Content-Type: text/html;
charset="gb18030"
Content-Transfer-Encoding: base64
PGRpdj4xMjwvZGl2PjxkaXY+MzwvZGl2Pg==
------=_NextPart_5A5F05FC_0A84FD10_3247BB56--
------=_NextPart_5A5F05FC_0A84FD10_42CF1A15
Content-Type: application/octet-stream;
charset="gb18030";
name="=?gb18030?B?08q8/s/qx+kucG5n?="
Content-Disposition: attachment; filename="=?gb18030?B?08q8/s/qx+kucG5n?="
Content-Transfer-Encoding: base64
iVBORw0KGgoAAAANSUhEUgAAA2QAAAHRCAIAAACKEu1wAAAAAXNSR0IArs4c6QAAA...(很长,省略)
------=_NextPart_5A5F05FC_0A84FD10_42CF1A15--

分析:

  1. Content-type是multipart/mixed,说明是包含附件类型
  2. 父段出现在最后,分割后的数组取第一项即可
  3. 根据子段分割,段内有各自的boundary、Content-type和charset等。
    1. 第一段是一个alternative的小邮件,包含纯文本和超文本。再根据boundary分割即可
      1. 解析后可的到纯文本内容为12\r\n3
      2. 解析后可的到超文本内容为<div>12</div><div>3</div>
    2. 第二段是application类型附件,根据charset和encoding解析得到文件名是邮件详情.png

编写时要注意的问题

  1. 遇到过Content-type为multipart/related;type="multipart/alternative";boundary="----=_NextPart_5A6951CD_6F185580_3879981A,这样要算related,不能算alternative,按复杂的那个算
  2. 观察发现related类型和mixed类型的解析规则一样
  3. 有些邮件一些值不全(如没有charset),需要设置默认值
  4. base64值解析错误,是因为base64有换行符,需要去掉

存储邮件

分析了邮件的类型,那存储邮件就不难了。下面是刚解析完的邮件对象格式

1
2
3
4
5
6
7
── attr #有邮件uid等简单信息
── body
├──attachment #附件
├──bodyHtml #超文本
├──bodyText #纯文本
── emailText #完整的邮件文本
── header #头部信息

我们需要根据邮件Content-type进行转换再存储,不然的话就要在显示时在判断不同类型不同处理。显然存储前处理更好

  1. 若是html类型,则将bodyHtml单独存入一个文件,bodyHtml的值为文件路径。这里需要考虑有的html并不是完整页面而是一个片段
  2. 若是mixed类型,将attachment存入单独文件,同样存为文件路径
  3. 对于alternative和related,到这里已经不用单独考虑。因为alternative是纯文本和超文本共存,也就是重复的,超文本是html片段,包含格式,而纯文本只有文字,直保留超文本即可;related是和mixed解析规则一样,并需要将资源拼合成完整html,将html单独存储即可。
1
2
3
if (contentType.match(htmlTypeReg)) {...} //单独存储html
else if (contentType.match(mixedMultipart)) {...} // 单独存储附件
if (!contentType.match(htmlTypeReg)) {...} //非htmlTypeReg也进行单独存储html

显示邮件

进行了很规则的存储,所以显示时逻辑就很清晰了

  1. bodyHtml以.html结尾,则是html路径,用webview的src引入
  2. bodyHtml不是路径,则将html片段插入
  3. 没有bodyHtml,则将bodyText插入
  4. 有attachment则显示,没有就不显示
1
2
3
4
if (bodyHtml.indexOf(HTML) && bodyHtml.indexOf(HTML) + HTML.length === bodyHtml.length) {...} //html是路径
else if (bodyHtml) {...} //html不是路径
else {...} //没有html,只能取bodyText
if (detail.body.attachment && detail.body.attachment.length) {...} //显示附件

发送邮件

发送邮件有stmp(Simple Mail Transfer Protocol),github有现成较成熟的nodemailer,无论发送html还是附件,都非常简单。

开发遇到的问题

编码

邮件最开始获取的是流,需要一个编码转为最初的字符串。我用的是gb18030解码
关于gb系列编码可以自行了解,简单提一下:最初只有ascii,中国想显示中文,就有了gb2312、gbk等,从简体中文慢慢加入繁体字等,最后更新的版本是gb18030,所以是gb系列直接用gb18030即可,因为它向下兼容。
解析最初的流好像用gb18030或utf-8都可以,因为各个部分都已经用base64或其他编码转为ascii码了。

1
From: "=?gb18030?B?amlhbmJvKw==?=" <490549111@qq.com>

上面的字符串反应了两个事:
1、字符集为gb18030,即本邮件由gb18030编码
2、B代表base64,后面的字符用base64编码

解析的思路是先用base64转为buffer,在用gb18030字符集转为字符串
解析的方法是iconv.decode(iconv.encode('amlhbmJvKw==?=','base64'),'gb18030')

1
From: =?UTF-8?B?6Zi/6YeM5LqR?= <system@notice.aliyun.com>

同样的道理,这是utf-8字符集的base64编码
解析的方法是iconv.decode(iconv.encode('B?6Zi/6YeM5LqR?=','base64'),'utf-8')

1
2
iconv.decode(iconv.encode('阿里郎阿里云','gb18030'),'utf-8')
iconv.decode(iconv.encode('6Zi/6YeM6YOO6Zi/6YeM5LqR','base64'),'gb18030')

如果gb18030和utf-8混用了,那就出现乱码了,因为他们字符集不一样,同一个编码代表的文字不一样。
上面的第一行输出�����ɰ�����。第二行输出闃块噷閮庨樋閲屼簯

对于boundary段内的内容如:

1
2
3
4
5
6
------=_NextPart_5A640E3E_0AF97620_02509F49
Content-Type: text/plain;
charset="gb18030"
Content-Transfer-Encoding: base64
cXdlenhj

里面清楚写了字符集和传输编码,按它规则解析即可得到纯文本qwezxc

使用imap的node-imap相关

使用node-imap模块,一方面是较灵活,另一方面是可以同步状态。最大的问题是发现同步状态失败,根据文档下面这样就可以标记邮件已读,但是怎么都失败。。可能是支持性不够

1
2
imap.openBox('INBOX', false, cb); //openBox不能是readOnly,置false
let f = imap.fetch(results, { bodies: '', markSeen: true }); //markSeen为true

文档api比较多,参数也多,但是很多得到的结果不一致,要多观察多测试,查出哪个是要用的api。

electron最小化,全屏按钮和无边框

默认的窗口是有系统边框的,我不喜欢,只要再创建渲染进程是配置去掉即可

1
2
3
4
5
6
7
8
9
mainWindow = new BrowserWindow({
height: 563,
width: 1000,
useContentSize: true,
autoHideMenuBar: true,
title: 'Vmail',
disableAutoHideCursor: true,
frame: false // 没有边框
})

没有了边框,就要手动添加界面拖动。

1
<header style="-webkit-app-region: drag">

这样,header就可以拖动了。但要注意,只有写行内样式才起效。同时会导致里面标签的hover不触发,要想触发,就要将这个标签设置不可拖动

1
<div class="refresh fl" style="-webkit-app-region: no-drag">

下面是最小化和全屏的部分代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const { remote } = require('electron')
...
data () {
return {
isFullScreen: false //当前是否全屏状态
}
},
mounted () {
window.addEventListener('resize', () => { //当resize时检测是否全屏
this.isFullScreen = remote.getCurrentWindow().isMaximized()
})
},
methods: {
close () {
remote.getCurrentWindow().close() //点击关闭,停止渲染进程
},
minimize () {
remote.getCurrentWindow().minimize() //窗口最小化
},
full () {
const browserWindow = remote.getCurrentWindow() //全屏toggle
if (browserWindow.isMaximized()) {
browserWindow.unmaximize()
this.isFullScreen = false
} else {
browserWindow.maximize()
this.isFullScreen = true
}
},

判断全屏还有browserWindow.isFullScreen(),发现双击拖动栏全屏不能正确返回,browserWindow.isMaximized()可以正确判断。
关于事件监听,要用addEventListener,不能用onresize,因为多个地方用到了resize,用onresize会相互覆盖

打包

项目是用electron-builder打包,第一次build要下载依赖。因为资源在墙外,要翻墙,否则报错。也可以尝试手动下载,根据命令行的提示下载对应的文件

npm应该设置镜像,配置文件为~/.npmrc

1
2
3
4
registry=https://registry.npm.taobao.org
sass_binary_site=https://npm.taobao.org/mirrors/node-sass/
phantomjs_cdnurl=http://npm.taobao.org/mirrors/phantomjs
ELECTRON_MIRROR=http://npm.taobao.org/mirrors/electron/

https://www.cnblogs.com/chenweixuan/p/7693718.html
http://blog.csdn.net/bailong1/article/details/78657605

其他

下图是硬盘存储结构
其中config.js存储所有用户和当前用户
每个邮箱目录都有一个index文件,存着各种邮件列表,具体html或附件都单独提取出来了
硬盘存储结构
邮箱用户目录结构

开始是用qq邮箱测试,之后用其他邮箱测试,基本是没问题的,因为大多数都是根据标准来收发邮件。测试了qq、163、aliyun等都基本没问题。(163需要多一个授权步骤)

项目缺点

项目做的比较粗糙,有些功能时不完善的。比如草稿箱,写邮件时的右侧快捷选择收件人,webview不能自适应高度等。功能也不多,如没有快捷回复邮件功能等。

总结

此次项目,业务不难,主要是邮件解析部分比较绕。我没有查阅权威的文档,所以可能有缺陷。
匹配字符串用到了大量正则表达式,我写的正则也不是很好
对于vue项目结构,vuex等知识不是重点,所以我一笔带过
以上就是对项目的总结,如果有错,望指正

参考:
MIME—multipart类型
邮件编码Content-Transfer-Encoding的各种形式

vue+koa2+mongodb点餐系统总结

发表于 2017-12-14 | | 阅读次数

关于项目

这是一个点餐系统,包含用户点餐、商家出餐、管理员管理三部分功能
这个项目本来是校内实训,需要用java编写,我负责一部分。但是我不太喜欢用java,且时间足够,就自己独自做了一份,用于学习。
项目的功能和需求是根据前期小组讨论出来的,也基本都是仿饿了么的
各项功能基本都实现了
线上地址:(比较慢)47.93.254.91:3333
源码地址:chihuobao

1
2
3
4
5
登录账号:
用户:12345678910
商家:11112222333
管理员:admin2
登录密码都是123456

功能结构


调试运行

1
2
3
4
5
6
npm install
npm run dev
cd server #打开koa2后台,会开启3333端口
npm install
node bin/www
1
2
npm run build #打包
cp dist/* server/public/ #将打包好的文件放到koa2静态目录

页面截图




总体分析

使用的框架、插件等

  • 用Vue-cli脚手架、vue-router、vuex
  • 用element-ui样式框架
  • 用axios发请求
  • 用koa2做后台,在node高版本直接用async、await
  • 用mongoose连接mongodb数据库

包含的功能

  • 手机注册,登录,重置密码
  • 用户点餐,该商家会收到消息提示有新订单(用轮询实现)
  • 用户查看自己的订单,评价、删除等
  • 修改自己的信息,申请成为商户等
  • 商家管理订单,接单等
  • 统计商家订单数,评分等(页面上的月销量是总销量)
  • 商家管理菜单、查看评论
  • 管理员管理用户、商铺、分类等
  • 搜索功能

目录结构

顶层就是vue-cli的结构,主要看前端src和后台server的结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
─ src
├── common #
│ ├── audio #音频
│ ├── images #图片
│ ├── javascript #api接口、cache、config等js文件
│ ├── style #公用style
├── components #组件
├── pages #页面,处理业务,主要分为三个模块
│ ├── admin
│ ├── seller
│ ├── user
│ ├── index.vue
│ ├── login.vue
├── router #路由
│ ├── index.js
├── store #vuex的store,分了三个模块
│ ├── admin
│ ├── seller
│ ├── user
│ ├── index.js
├── App.vue
├── main.js
1
2
3
4
5
6
7
8
9
─ server
├── app
├── ├── common # 工具
├── ├── controllers # 业务
├── ├── models # 定义数据库模型
├── db_vue # 导出来的数据库数据
├── routes # 路由
├── app.js
├── config.js # 短信api的key相关

开发过程

使用vue-cli

我之前用react,为了熟悉webpack就没有使用脚手架(如yeoman),深深感受到了babel的复杂,webpack配置的繁琐。用到vue-cli简直就是一个字:爽,各种复杂的配置都配好了,如使用sass下载后在style配置一下就好了,不用再到webpack配置,这些发杂的配置本该就不要重复做。现在Parceiljs打包工具也出来了,以后可以更爽快的开发了

对vue的感觉就是真的对新手很友好,官网教程很全,例子很多,上手快。
使用vuex + map辅助函数用起来很方便
下面是一个登陆的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# login.vue
# 先请求登录,返回用户信息,通过vuex的mapAction函数调用actions,这里vuex分了user、seller、admin模块
methods: {
...mapAction('user',
[
'saveUserInfo'
]
),
login () {
_loginApi(phone, pass).then(res => {
this.saveUserInfo(res.data)
this.$router.push('/home')
})
}
}
1
2
3
4
5
6
7
# user/actions.js
# 调用函数,先做一个客户端存储存到localStorage,再存到state中
import { _saveUserInfo } from 'common/javascript/cache'
export function saveUserInfo ({commit, state}, info) {
commit(types.SET_USER_INFO, _saveUserInfo(info))
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# index.vue
# 需要数据的组件用vuex的mapGetters函数获取
<template>
<user-header :userInfo='userInfo'></user-header>
</template>
<script>
export default {
computed: {
...mapGetters(
'user',
[
'userInfo',
'reLogin'
]
)
}
}
</script>

数据的流向是单向的

开发遇到的问题

vuex分模块的修改

一开始没有分模块是这样写的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# store.js
#
export default new Vuex.Store({
getter,
state,
mutations,
actions
})
# 组件调用,直接调用
computed: {
...mapGetters(['suggestion'])
},
methods: {
...mapActions(['saveUserInfo']),
...mapMutations({
setCoordinate: 'SET_COORDINATE'
})
}

分了模块写法有区别的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# store.js
# 各模块分别有各自的state、getters、actions
# 模块结构自己定义,所以可以定义一个顶层公用的,再在里层分模块
export default new Vuex.Store({
modules: {
user,
seller,
admin
}
})
# 组件调用
# 调用要有模块名,mapActions取不同模块时要分开取
computed: {
...mapGetters(
'user',
[
'suggestionList',
'userInfo'
]
)
},
methods: {
...mapMutations({
setCoordinate: 'user/SET_COORDINATE'
}),
...mapActions('user',
[
'saveInfo'
]
),
...mapActions('seller',
[
'saveSellerInfo'
]
)
}

父子组件通信

一般父子组件,是父组件向子组件传入数据,子组件显示数据,数据单向流动。
当子组件需要传递数据给父组件时,通过触发函数,以参数的形式向父组件传递数据,跟react数据传递一样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 父组件
<food-card @addOne='addOne' :info='info'></food-card>
# 子组件
<p class='name'>{{info.dishName}}</p>
<span class='money'>¥{{info.dishPrice}}</span>
<div :class='_status' @click='addToCart'>加入购物车</div>
...
props: {
info: {
type: Object, #定义父组件传入的数据类型,当传入类型和定义的不一致,vue会警告
default: {}
}
},
methods: {
addToCart () {
this.$emit('addOne', this.info) #用this.$emit触发父组件的addOne函数
}
}

上面的例子中,不能修改父组件传入的数据。若要修改数据,则需要在$emit前复制一份数据然后修改,再传递给父组件,也可以用sync实现父子组件数据双向绑定。sync在2.0被移除因为这破坏了单向数据流,但2.3又引入了,因为有场景需要如一些复用的组件。但sync和以前的实现又有点不一样,它只是一个语法糖,会被扩展为一个自动更新父组件属性的 v-on 监听器。
并且子组件需要显示触发更新:this.$emit(‘update:xx’, newVal)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 父组件
<card-item :data.sync='item'></card-item>
#会被扩展为这样
<comp :data="item" @update:data="newVal => item = newVal"></comp>
# 子组件
<input class='commend' type="text" v-model='commend' placeholder="写下对此菜品的评价">
export default {
data () {
return {
commend: ''
}
},
watch: {
commend (newC) {
this.data.commend = newC
this.$emit('update:data', this.data) #显示触发data的更新达到双向数据绑定
}
},
props: {
data: {
type: Object,
default: {}
}
}
}

element-ui设置样式无效

使用了element-ui样式框架,有时需要对他们的组件做一些样式的修改。但它是封装好的,我就需要查看源代码才知道它内部定义的类或标签来自定义样式,但是发现无效,举个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<el-rate
v-model="item.score"
disabled
show-text
text-color="#ff9900">
</el-rate>
<style scoped lang='sass'>
.el-rate #组件都自带同名的类
div
background: red
<style>
#发现element-ui通过jsfiddle演示的代码却没问题,就查找不同点,然后发现是style标签的scoped导致的,可能局限了样式的作用范围。去掉就可以了,此时要注意样式是全局的,所以要注意类名的使用

监听$route要仔细

在查看商铺页面,可以选择不同类型商家,也可以搜索商家,可以有不同的实现方法。可以把状态全放在在组件内或vuex管理,但是这样刷新后状态就消失了。所以我选择用url的hash来保存状态,通过监听路由变化来加载不同数据。商家列表数据放在vuex

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# 商家页面,place.vue
data () {
return {
pageNum: 1,
totalPage: 1,
keyword: '',
loading: false
}
},
created () {
this.getList()
# 滚动加载下一页
window.onscroll = () => {
if (!this.loading && this.__getScrollHeight() <= (this.__getWindowHeight() + window.scrollY + 100)) {
if (this.pageNum < this.totalPage) {
this.loading = true
this.pageNum++
this.getList()
}
}
}
},
watch: {
$route () {
this.getList()
}
},
methods: {
changeTag (tag) {
this.pageNum = 1
this.shopType = tag
this.keyword = ''
# this.clearShopList()
this.$router.push({path: '/place', query: {shopType: code, keyword: undefined}})
},
search (str) {
this.keyword = str
this.pageNum = 1
this.shopType = 1
# this.clearShopList()
this.$router.push({path: '/place', query: {shopType: undefined, keyword: str}})
},
getList () {
const { keyword, shopType } = this.$router.currentRoute.query
this.loading = true
_getShopList(keyword, shopType, this.pageNum).then(res => { #请求数据,然后concat到商家list存入vuex
...
})
}
}

后面使用时,发现了bug:在别的页面变动路由,这里会加载了重复的数据。所以要限定监听路由变动的路由,在本页面才有效

1
2
3
4
5
6
7
watch: {
$route () {
if (this.$router.currentRoute.name === 'place') {
this.getList()
}
}
}

然后又发现bug:从别的页面回到这里,也加载了重复的数据,解决办法是离开组件时把原数据删除。要这样做是因为我把数据存入了vuex,感觉不必要存入vuex..

1
2
3
4
5
6
7
8
9
beforeDestroy () {
window.onscroll = null
this.clearShopList()
},
methods: {
...mapMutations({
clearShopList: 'user/CLEAR_SHOP_LIST'
})
}

请求异常跳转登录页

请求有时需要出现异常如401,需要让用户重新登录,我用的是axios

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 这是一个请求封装,返回异常全部调用reLogin的action返回登录页
import store from '../../store'
export function basePOST (api, params) {
return axios({
method: 'post',
url: api,
headers: {
'content-Type': 'application/x-www-form-urlencoded'
},
data: config.toFormData({
...params
})
}).then(res => {
return res.data
}).catch(() => {
store.dispatch('user/reLogin')
})
}

koa2基本配置

使用koa生成器初始化项目

1
2
3
4
npm install koa-generator -g
koa2 server
cd server && npm install
npm start

加入session中间件

1
2
3
4
const session = require('koa-session2')
app.use(session({
key: 'sessionid---'
}))

设置静态资源缓存

1
2
3
4
5
6
# 注意,时间需要用变量放入,否则无效
var staticCache = require('koa-static-cache')
const cacheTime = 365 * 24 * 60 * 60
app.use(staticCache(path.join(__dirname, 'public'), {
maxAge: cacheTime
}))

传输文件压缩

1
2
3
4
5
6
7
8
var compress = require('koa-compress')
app.use(compress({
filter: function (contentType) {
return /text/i.test(contentType)
},
threshold: 2048,
flush: require('zlib').Z_SYNC_FLUSH
}))

mongoose使用遇到的坑

在建表时需要注意数据类型,若schema定义是Number,存入的却是String,会报错。
若schema没有定义字段,创建collection时传入其他字段,会存不进去
数据库的Number数据,用字符查找会找不到如:user.find({age: ‘18’})

moment解决mongodb时区问题

mongodb用的是中时区的时间,我们是东八区,所以时间都会晚8小时,用moment插件处理
moment是用在客户端,而不是存储。存储的数据是中时区的,在显示数据时修正
因为moment很多地方都要用,所以我直接将它放入Vue的原型,这样所有vue实例都可以拿到这个方法

1
2
3
4
5
6
7
#main.js
import Vue from 'vue'
import moment from 'moment'
Object.defineProperty(Vue.prototype, '$moment', {value: moment})
#组件中
<p class='fr date'>{{$moment(item.commentDate).format('YYYY-MM-DD HH:mm:ss')}}</p>

其他

传输数据格式转换

用post传输数据,用x-www-form-urlencoded格式,但是表单里有个对象数组:

1
2
3
4
5
{
userId: 13546515,
dishs: [{id: 4545, num: 2, price: 12}, {id: 1446, num: 1, price: 8}],
...
}

我用node做后台,拿这个数据一点问题都没有,但是小组内跟java后台配合,不能这样传对象数组字符串。他需要直接用List<>包装,需要这样传递才行

看到这样的取值,我想:卧槽,还有这样传的。。
虽然感觉很不合理,但还是做了转换。下面的代码就能达到这个目的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let dishs = {}
_dishs.forEach((item, index) => {
for (let key in item) {
if (!dishs[`dishs[${index}]`]) {
dishs[`dishs[${index}]`] = {}
}
dishs[`dishs[${index}]`][key] = item[key]
}
})
let temp = {}
let result = {}
for (let i in dishs) {
for (let j in dishs[i]) {
if (!temp[i]) temp[i] = {}
result[`${i}.${j}`] = dishs[i][j]
}
}

mongodb导入数据

server/db_vue路径是导出的数据,可以导入到自己的mongodb数据库

1
2
# d是数据库名,c是collection名
mongoimport -d vue -c users --file vue/users.json

总结

总体来说,项目结构还算清晰,我对Vue还不是很熟悉,所以运用的还不是很好,比如使用Vuex的使用,我对于不同组件需要共享的数据存入store或同时存在本地,对于单个组件内的数据,感觉没必要存入store。
对koa2也不是很熟悉,一开始总是忘记await等各种小问题,写完虽然做了缓存压缩,因为不是一个后端,所以性能上还是弄不好,线上的可能比较卡,因为是学生服务器。
写完这个对Vue熟悉一点了,接下来会继续学学Vue的原理,学学新东西
以上就是对项目的总结,如果有错,望指正

Hello World

发表于 2017-10-18 | | 阅读次数

Welcome to Hexo! This is your very first post. Check documentation for more info. If you get any problems when using Hexo, you can find the answer in troubleshooting or you can ask me on GitHub.

Quick Start

Create a new post

1
$ hexo new "My New Post"

More info: Writing

Run server

1
$ hexo server

More info: Server

Generate static files

1
$ hexo generate

More info: Generating

Deploy to remote sites

1
$ hexo deploy

More info: Deployment

react+redux+router+echarts可视化项目总结

发表于 2017-10-07 | | 阅读次数

写在前面

项目来自于今年中国软件杯赛题:基于WIFI探针的商业大数据分析技术
赛题包括探针程序,数据分析程序,展示界面等,我做的是可视化展示部分和后台管理页面
这个仓库是数据可视化程序,将分析好的数据可视化的展示出来
因为后台是java写的,为了简单的产生数据,我用node写了个小后台,提供数据
可直接到我的服务器看:47.93.254.91:3000
源码地址:https://github.com/ooooevan/react-redux-echarts

功能结构

调试运行

1
npm run dev

打开数据后台

1
2
cd server/demo1
npm start

我已经把打包好的dist文件拷贝到server的静态目录下,所以打开数据后台可以直接访问localhost:3000看到页面

修改后打包

1
npm run deploy

生成的文件在dist目录中。
因为是windows下,需要手动复制静态文件:
将lib目录复制到dist目录下,将favicon.ico复制到dist目录下。

页面截图



使用的相关技术

  • 使用es6写法,安装一堆babel插件(现在有babel-preset-env可以少很多了)
  • react、react-redux、react-router,页面结构较多,这样用是比较合适的。当然也可以用mobx
  • 使用了不可变数据,因为要渲染的数据层级深,用immutable很有必要
  • 使用webpack2做模块化,打包,做了代码分割加快首屏渲染
  • 使用百度的echarts做可视化

总体分析

目录结构

1
2
3
4
lib:存放一些静态资源,这里我放了logo图片和字体文件
server:调试时提供数据的node后台
src:放代码的地方
test:编写测试用例


再看src目录下的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
根据功能分为4个模块,分别为:firstPage、sellers、statics、compare
对应概况、商家、统计分析、数据对比4个模块
后面改了页面结构,所以这里的结构和调试页面会不一样,47.93.254.91:3000的页面是旧代码的,和这个结构完全对应
─ src
├── actions //分别是4个模块各自的action
│ ├── compareAction.js
│ ├── firstPageAction.js
│ ├── sellersAction.js
│ ├── staticsAction.js
├── components //放各个模块的组件
│ ├── compare
│ ├── firstPage
│ ├── sellers
│ ├── statistics
│ ├── app.js //入口文件
│ ├── calendar.js //一个日历组件
│ ├── devTool.js //一个调试插件,代码已经注释
│ ├── general.js //进入4个个模块的Nav组件
│ ├── notFindPage.js //404页面
│ ├── tools.js //几个工具函数
├── constants //放actionapi和actionType
├── options //echarts对不同图形需要不同配置,都写在这里
├── reducers
├── store
├── styles

数据流向

总体流向

使用的是redux,当然是 components->actions->reducers->components

举个具体的例子

打开一个组件

要经过的地方:

1
2
3
4
5
6
1. component执行生命周期,componentWillMount里产生一个获取数据的action
2. action中发送一个异步请求
3. component继续生命周期函数,render出页面骨架,调用echarts的init和showLoading,此时可以看到页面转圈提示加载中
4. action中服务器返回数据,调用dispatch把此次action交给reducer处理
5. reducer把原state和服务器的数据更新合并,交给component以渲染
6. component中更新了数据,更新组件,执行echarts的setOption和hideLoading方法,渲染出了数据

所有的数据流向都是这样,很清晰

项目遇到的问题

1. echarts实例对象放哪里

因为ehcarts要先初始化才能显示一个loading的图像,等数据返回才能渲染数据,但是这个实例化的对象放哪里呢?

1
2
3
4
5
echartsExample = echarts.init(targetDom);
echartsExample.showLoading();
...
echartsExample.setOption(Data);
echartsExample.hideLoading();

一开始我把这个对象跟数据的流向一起,把对象当做参数,带到action再到reducer。
虽然这样也成功了,但是我发现,reducer是纯函数啊,怎么可以做这么不纯洁的操作呢,然后才想到,应该存在组件的state中。。

1
2
3
4
5
this.state.echartsExample = echarts.init(targetDom);
this.state.echartsExample.showLoading();
...
this.state.echartsExample.setOption(Data);
this.state.echartsExample.hideLoading();

因为这个对象本身不是用来显示数据的,所以不用this.setState()而是直接赋值
这应该是个很弱智的问题,但是当时就是困扰了我。。

2. 数据层级深导致数据出问题

echarts要求的数据格式层级很深,随便就3,4层嵌套(应该所有的可视化数据都是这么深)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
sellersNum: {
...
color: ['#c23531', '#de9a48'],
title: {
text: '客流量峰值对比',
show: false
},
toolbox: {
feature: {
dataView: {show: true, readOnly: false},
magicType: {show: true, type: ['line', 'bar']},
restore: {show: true},
saveAsImage: {show: true}
}
},
...
}

在没有引入immutable时,我想让数据尽量快点渲染,在componentWillReceiveProps里执行渲染操作,却犯了个错误

1
2
3
4
5
componentWillReceiveProps(){
//这个错误就是不应该用this.props.data,这是旧的数据,应该用nextProps.data
this.state.echartsExample.setOption(this.props.data);
this.state.echartsExample.hideLoading();
}

当我发现这个错误时,却发现这么大错误没有导致页面任何异常。。
通过debugger才知道,原来我执行reducer时更改了原state,所以到这里时,this.props就等于nextProps
而更改了原state,肯定就是因为数据层级嵌套太深导致的。所以才引入的immutable,不改变原数据,返回新数据

1
2
3
4
5
//若层级不深时,可以用Object.assign、slice、concat等方法更新state
//当层级太深,怎么都显得太麻烦,用了immutable就很清晰了:
return state.setIn(['customerNum', 'xAxis', 0, 'data'], data.time)
.setIn(['customerNum', 'series', 0, 'data'], data.num1)
.setIn(['customerNum', 'series', 1, 'data'], data.num2);

immutable和原生js对象不一样,不能互相使用方法的方法,所以引入immutable要多记几个api

1
2
3
let immutableObj = immutable.fromJS(obj); //原始对象转为immutable对象
let nativeObj = immutableObj.toJS(); //immutable对象转为原始对象
...

两种对象互转比较消耗性能,能不转就不转,而且如果要在shouldComponentUpdate手动判断更新的话,用immutable很容易。
我这里的转化:reducer拿到服务器数据即转为immutable->更新state->组件使用->echarts渲染函数接收的参数要原始对象,所以在这里转为原始对象
immutable对象也有像原始对象那样一系列函数如map、forEach等,基本满足数据渲染要求,所以如果项目中没有echarts这样必须使用原始对象的,可以一直用immutable对象
还有就是combinereducer不支持immutable,需要用redux-immutable库重写了的combinereducer

3. 打包文件过大

打包生成最终文件,好几兆,太大,于是寻找减少大小的方法

  1. webpack配置压缩文件

    1
    2
    3
    4
    5
    6
    7
    8
    new webpack.optimize.UglifyJsPlugin({
    output:{
    comments:false, //去掉所以注释
    },
    compress:{
    warnings:false
    }
    }),
  2. 代码切割,根据路由分成多个小文件,按需加载
    有两个地方需要改:
    1、路由

    1
    2
    3
    4
    5
    6
    7
    8
    9
    //原来
    import Statistics from './statistics/statistics';
    <Route path="statistics" component={Statistics} />
    //现在
    const Statistics = (location, callback) => {
    require.ensure([], (require) => {
    callback(null, require('./statistics/statistics').default);
    }, 'statistics');
    };

2.webpack配置

1
2
3
4
5
6
7
8
9
10
entry: {
app: './src/components/app.js',
vendors:['react','react-dom']
},
output: {
path: path.join(__dirname, 'dist/'),
publicPath: '',
filename: '[name]-[chunkhash:8].js',
chunkFilename: '[name]-[chunkhash:8].js' //路由对应文件,chunkhash是将文件内容哈希
},

这样能分隔出多个文件,加载时只需加载公共的vendors和此路由需要的js文件,大大加快了首屏渲染
并且,用chunkhash能做版本控制,在修改代码后,只会使修改的部分文件的哈希改变,其他文件都没有变,这样,只需要看名字有没有变就可以知道哪些文件更新了,对部署很有帮助,请看:大公司里怎样开发和部署前端代码?

其他

函数去抖

因为echarts图像是canvas绘制的,当改变浏览器窗口大小时发现canvas不会变,此时要重新绘制图像。调用echarts提供的方法。并且是在window.resize触发时执行

1
this.state.allSellersLineChart.resize();

仅仅这样会发现,改变窗口大小时,会一直触发这个函数,就行onmousemove那样一直触发,这样就很浪费性能,严重会引起假死状态,所以要用函数去抖(类似还有函数节流)

1
2
3
4
5
6
7
8
9
10
11
window.addEventListener('resize', this.resizeFun);
resizeFun = () => {
if (this.state.resizeHandler) {
clearTimeout(this.state.resizeHandler);
}
if (this.state.allSellersLineChart) {
this.state.resizeHandler = setTimeout(() => {
this.state.allSellersLineChart.resize();
}, 100);
}
}

代码很简单,监听resize事件,然后设置定时器,只要一定时间范围内又触发,则重新计时,知道最后不触发了再执行

缺点

由于是已经结束的项目,就想不再修改了,但是缺点还是在的

  • 最大的缺点就是组件重用,大部分图形组件都是一样的,是可以共用一个组件,将数据传入进去的,我却一个图形一个组件,所以项目的这么多图像组件基本都是一样的
  • scss没有模块化,开始想共用一些代码就没有分离,然后变成了全部样式全挤在一个文件里。。
  • propTypes没写好,没有用typescript,类型检查是很有必要的,但是我偷懒很多都没写
  • 代码比较乱,很多代码和注释没有去掉

这个部分是比较简单的,业务很简单(其实还有个管理后台,因为没有写后台提供数据就不放出来了)。但对react的理解还是挺有帮助的,写代码时另一大问题就是react生命周期、路由等问题,写着写着就懂一点了。
这是过了挺久才做的总结,所以有些都忘记了,只能写这么多,如果有错,望指正

Evan

Evan

一个正在学习的码农

4 日志
Github
© 2018 Evan
个人博客
github - ooooevan