Vue开发知乎日报

知乎日报项目开发

知乎日报是由知乎开发的一款资讯类阅读 APP, 每日提供来自知乎社区精选的回答或者专栏文章, 本项目将使用 Vue 和 webpack 等相关技术, 利用知乎日报的接口开发一个 Web App.

分析与准备

webpack 的基础配置参考 GitHub

日报是一个单页的应用, 由 3 部分组成, 如图所示:

左侧是惨淡, 分为 “每日推荐” 和 “主题日报” 两个类型, 中间是文章列表, 右侧是文章正文和评论. 其中每日推荐按日期排列, 比如途中显示为 5 月 2 日的推荐文章, 中间栏滚动至底部时. 自动加载前一天的推荐内容.

主题日报有 “日常心理学” 等 10 多个子分类, 分类列表默认是收起的, 点击 “主题日报” 菜单时切换展开和收起的状态. 点击某个子分类后, 中间栏切换为该项目下的文章列表, 不再按时间排列. 点击文章列表中的某一项, 在右侧渲染对应文章的内容和评论.

知乎日报的接口地址前缀为 http://news-at.zhihu.com/api/4/, 图片地址前缀为 https://pic1.zhimg.com, 由于两者都开启了跨域限制, 无法在前端直接调用, 因此要开发一个代理.

我们使用基于 Node.js 的 request 库来做代理, 通过 NPM 安装 request:

npm install request –save-dev

在 daily 目录下新建一个 proxy.js 的文件, 写入以下内容:

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
51
52
53
54
55
56
//	daily/proxy.js
const http = require('http');
const request = require('request');

const hostname = '127.0.0.1';
const port = 8010;
const imgPort = 8011;

// 创建一个 API 代理服务
const apiServer = http.createServer((req, res) => {
const url = 'http://news-at.zhihu.com/api/4' + req.url;
const options = {
url: url
};

function callback (error, response, body) {
if (!error && response.statusCode === 200) {
// 设置编码类型, 否则中文会显示为乱码
res.setHeader('Content-Type', 'text-plain;charset=UTF-8');
// 设置所有域允许跨域
res.setHeader('Access-Control-Allow-Origin', '*');
// 返回代理后的内容
res.end(body);
}
}

request.get(options, callback);
})

// 监听 8010 端口
apiServer.listen(port, hostname, () => {
console.log(`接口代理运行在 http://${hostname}:${port}/`);
});
// 创建一个图片代理服务
const imgServer = http.createServer((req, res) => {
const url = req.url.split('/img/')[1];
const options = {
url: url,
encoding: null
};

function callback (error, response, body) {
if (!error && response.statusCode === 200) {
const contentType = response.headers['content-type'];
res.setHeader('Content-Type', contentType);
res.serHeader('Access-Control-Allow-Origin', '*');
res.end(body);
}
}

request.get(options, callback);
});
// 监听 8011 端口
imgServer.listen(imgPort, hostname, () => {
console.log(`图片代理运行在 http://${hostname}:${imgPort}/`)
})

监听了两个端口: 8010 和 8011. 8010 用于接口代理, 8011 用于图片代理. 比如请求的真实接口为 http://news-at.zhihu.com/api/4/news/3892357, 开发时改写为 http://127.0.0.1:8080/news/3892357; 图片的真实地址为 https://pic4.zhimg.com/v2-b44636ccd2affac97ccc0759a0f64f7f.jpg, 开发时改写为 http://127.0.0.1:8011/img/https://pic4.zhimg.com/v2-b44636ccd2affac97ccc0759a0f64f7f.jpg.

代理的核心是在返回的头部 (response header) 中添加一项 Access-Control-Allow-Origin 为 “*”, 也就是允许所有的域访问.

最后在终端使用 Node 启动代理服务:

node proxy.js

如果成功, 就会在终端显示两行日志:

接口代理运行在 http://127.0.0.1:8010/ 和 图片代理运行在 http://127.0.0.1:8011/

对于接口的 Ajax 请求, 前端有多种实现方案; Vue 官方提供了 vue-resource 插件, 但是不在维护了, 而是推荐使用 axios.

axios 是基于 Promise 的 HTTP 库, 同时支持前端和 Node.js. 首先使用 npm 安装 axios:

npm install axios –save

在 daily 目录下新建目录 libs, 并在 libs 下新建 util.js 文件, 项目中使用的工具函数可以在这里封装. 比如对 axios 封装, 写入请求地址的前缀, 在业务中只用写相对路径, 这样可以灵活控制. 另外, 可以全局拦截 axios 返回的内容, 简单处理, 只需返回我们需要的数据. 其代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//	libs/util.js
import axios from 'axios';
// 基本配置
const Util = {
imgPath: 'http://127.0.0.1:8011/img/',
apiPath: 'http://127.0.0.1:8010'
};
// Ajax 通用配置
Util.ajax = axios.create({
baseURL: Util.apiPath
});
// 添加响应拦截器
Util.ajax.interceptors.response.use(res => {
return res.data;
});

export default Util;

做好这些准备后, 就可以开始日报应用的开发了.

推荐列表与分类

搭建基本结构

项目中使用的 css 样式不多, 所以直接写在 daily/style.css, 并在 main.js 中导入:

1
2
3
4
5
6
7
8
9
10
11
//	main.js
import Vue from 'vue';
import App from './app.vue';
import './style.css';

new Vue({
el: '#app',
render: h => {
return h(App)
}
})

日报是单页应用, 没有路由, 只有一个入口组件 app.vue. 应用结构如图所示.

这里需要插入一张图片

应用分左、中、右 3 栏, 3 栏都可以滚动. 对左栏和中栏使用 fixed 固定, 并使用 overflow: auto 滚动, 而右栏高度自适应, 使用浏览器默认的 body 区域滚动即可. 基本的 HTML 和 CSS 结构如下.

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
//	app.vue
<template>
<div class="daily">
<div class="daily-menu">
<div class="daily-menu-item">每日推荐</div>
<div class="daily-menu-item">主题日报</div>
</div>
<div class="daily-list">
<Item></Item>
</div>
<daily-article></daily-article>
</div>
</template>

// style.css
html, body {
margin: 0;
padding: 0;
height: 100%;
color: #657180;
font-size: 16px;
}
.daily-menu {
width: 150px;
position: fixed;
top: 0;
bottom: 0;
left: 0;
overflow: auto;
background: #f5f7f9;
}
.daily-menu-item {
font-size: 18px;
text-align: center;
margin: 5px 0;
padding: 10px 0;
cursor: pointer;
border-right: 2px solid transparent;
transition: all .3s ease-in-out;
}
.daily-menu-item:hover{
background: #e3e8ee;
}
.daily-menu-item.on {
border-right: 2px solid #3399ff;
}

.daily-list {
width: 300px;
position: fixed;
top: 0;
bottom: 0;
left: 150px;
overflow: auto;
border-right: 1px solid #d7dde4;
}
.daily-item {
display: block;
color: inherit;
text-decoration: none;
padding: 16px;
overflow: hidden;
cursor: pointer;
transition: all .3s ease-in-out;
}
.daily-item:hover {
background: #e3e8ee;
}

.daily-article {
margin-left: 450px;
padding: 20px;
}

主题日报

“主题日报” 下有子类列表, 默认是收起的, 点击主题日报可以切换展开和收起的状态, 使用数据 showThemes 来控制, 并用 themes 来循环渲染子类目:

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
//	app.vue 
<template>
<div class="daily-menu">
<div class="daily-menu-item"
:class="{ on: type === 'recommend' }">每日推荐</div>
<div class="daily-menu-item"
:class="{ on: type === 'daily' }"
@click="showThemes = !showThemes">主题日报</div>
<ul v-show="showThemes">
<li v-for="item in themes">
<a :class="{ on: item.id === themeId && type === 'daily' }">{{ item.name }}</a>
</li>
</ul>
</div>
</template>
<script>
export default {
data () {
return {
themes: [],
showThemes: false,
type: 'recommend',
themeId: 0
}
},
}
</script>

// style.css
.daily-menu ul{
list-style: none;
}
.daily-menu ul li a {
display: block;
color: inherit;
text-decoration: none;
padding: 5px 0;
margin: 5px 0;
cursor: pointer;
}
.daily-menu ul li a:hover, .daily-menu ul li a.on {
color: #3399ff;
}

themeId 会在点击子类时设置, 稍后会介绍.

应用初始化时, 获取主题日报的分类列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//	app.vue
<script>
import $ from './libs/util';
export default {
data () {
return {
themes: []
}
},
methods: {
getThemes () {
// axios 发起 get 请求
$.ajax.get('themes').then(res => {
this.themes = res.others;
})
}
},
mounted () {
// 初始化时调用
this.getThemes();
}
}
</script>

主题日报类目列表为数组, 每一项的结构示例如下:

1
2
3
4
5
6
7
8
9
"others": [
{
"name": "日常心理学",
"id": 13,
"thumbnail": "http://pic3,zhimg.com/xxx.jpg",
"color" 15007,
"description": "了解自己和别人, 了解彼此的欲望和局限."
}
]

点击子类目时, 将菜单 type 切换为 “主题日报” 高亮点击的子类, 然后加载该类目下的文章列表:

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
//	app.vue
<template>
<ul v-show="showThemes">
<li v-for="item in themes">
<a :class="{ on: item.id === themeId && type === 'daily' }"
@click="handleToTheme(item.id)">{{ item.name }}</a>
</li>
</ul>
</template>
<script>
import $ from './libs/util';
export default {
data () {
return {
themes: [],
showThemes: false,
type: 'recommend',
list: [],
themeId: 0
}
},
methods: {
handleToTheme (id) {
// 改变菜单分类
this.type = 'daily';
// 设置当前点击子类的主题日报 id
this.themeId = id;
// 清空中间栏的数据
this.list = [];
$.ajax.get('theme/' + id).then(res => {
// 过滤掉类型为 1 的文章, 该类型下面的文章为空
this.list = res.stories
.filter(item => item.type !== 1);
})
}
}
}
</script>

文章列表 list 为数组, 每一项的结构示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
"stories": [
{
"type": 0,
"id": 7097426,
"title": "人们在虚拟生活中投入的精力是否对现实生活的人际关系有积极意义?"
},
{
"type": 0,
"id": 7101963,
"title": "写给想成为心理咨询师的学生同仁",
"images": [
"http://picl.zhing.com/xxx.jpg"
]
}
]

文章列表中的 id 字段是文章的 id, 请求文章内容和评论列表时会用到, title 为标题, images 为封面图片, 没有 images 字段就不显示封面图片.

每日推荐

应用初始化和点击 “每日推荐” 菜单时请求推荐的文章列表. 推荐列表的 API 相对地址为 news/before/20170503, before 后面是查询的日期, 这个日期比要查询的真实日期多一天. 每日推荐可以无限次地向前一天查询, 为方便操作日期, 在 libs/util.js 内定义两个时间长发:

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
//	libs/util.js
import axios from 'axios';
const Util = {};

// 获取今天的时间戳
Util.getTodayTime = function () {
const date = new Date();
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
return date.getTime();
};
// 获取前一天的日期
Util.prevDay = function (timestamp = (new Date()).getTime()) {
const date = new Date(timestamp);
const year = date.getFullYear();
const month = date.getMonth() + 1 < 10
? '0' + (date.getMonth() + 1)
: date.getMonth() + 1;
const day = date.getDate() < 10
? '0' + date.getDate()
: date.getDate();
return year + '' + month + '' + day
};
export default Util;

Util.prevDay 的参数为前一天的时间戳, 计算前一天的时间戳只需以今天 0 点的时间戳为基础, 也就是通过 Util.getTodayTime 获取的时间戳减去 86400000. 这种方法要比直接判断前一天的日期简单得多, 因为每个月的日期是不固定的, 另外还需要特殊处理闰年.

推荐文章的列表获取相关代码如下:

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
//	app.vue
<template>
<div class="daily-menu-item"
@click="handleToRecommend"
:class="{ on: type === 'recommend' }">每日推荐</div>
</template>
<script>
import $ from './libs/util' ;
export default {
data () {
return {
type: 'recommend',
recommendList: [],
dailyTime: $.getTodayTime(),
isLoading: false
}
},
methods: {
handleToRecommend () {
this.type = 'recommend';
this.recommendList = [];
this.dailyTime = $.getTodayTime();
this.getRecommendList();
},
getRecommendList () {
this.isLoading = true;
const prevDay = $.prevDay(this.dailyTime + 86400000);
$.ajax.get('news/before/' + prevDay).then(res => {
this.recommendList.push(res);
this.isLoading = false;
})
}
},
mounted () {
this.getRecommendList();
}
}
</script>

recommendList 为推荐文章列表的数据, 在初始化和每次点击 “每日推荐” 菜单时都会请求数据. dailyTime 默认获取今天 0 点的时间戳, 请求时需要多加一天. 因为推荐列表可能通过 “主题日报” 的子类切换而来, 需要重新获取一遍数据, 所以 handleToRecommend 方法每次都需要清空列表并重新设置 dailyTime.

推荐列表的数据结构和主题日报基本一致, 不同的是多了一个 date 字段来表示请求列表的日期.

两个文章列表 (list、recommendList)的每一项都用一个组件 item.vue 来展示, 在 daily/components 目录下新建 item.vue 文件, 内容如下:

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
51
52
53
54
55
56
//	components/item.vue
<template>
<a class="daily-item">
<div class="daily-img" v-if="data.images">
<img :src="imgPath + data.images[0]">
</div>
<div
class="daily-titme"
:class="{ noImg: !data.images }">{{ data.title }}</div>
</a>
</template>
<script>
import $ from '../libs/util' ;
export default {
props: {
data: {
type: Object
}
},
data () {
return {
imgPath: $.imgPath
}
}
}
</script>

// style.css
.daily-item {
display: block;
color: inherit;
text-decoration: none;
padding: 16px;
overflow: hidden;
cursor: pointer;
transition: all .3s ease-in-out;
}
.daily-item:hover {
background: #e3e8ee;
}
.daily-img {
width: 80px;
height: 80px;
float: left;
}
.daily-img img {
width: 100%;
height: 100%;
border-radius: 3px;
}
.daily-title {
padding: 10px 5px 10px 90px;
}
.daily-title.noImg {
padding-left: 5px;
}

prop: data 里可能没有 images 字段, 所以列表会显示两种模式, 即含封面图和不含封面图.

Item 组件会用到文章列表里, type 为 recommend 和 daily 两种类型下, 渲染会稍有不同. recommend 会显示每天的日期, daily 则没有. 对应的代码如下:

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
51
52
53
54
55
56
57
58
//	app.vue
<template>
<div class="daily-list">
<template v-if="type === 'recommend'">
<div v-for="list in recommendList">
<div class="daily-date">{{ formatDay(list.date) }}</div>
<Item
v-for="item in list.stories"
:data="item"
:key="item.id"></Item>
</div>
</template>
<template v-if="type === 'daily'">
<Item
v-for="item in list"
:data="item"
:key="item.id"></Item>
<template>
</div>
</template>
<script>
import Item from './components/item.vue';
export default {
components: { Item },
data () {
return {
type: 'recommend',
recommendList: [],
list: []
}
},
methods: {
// 转换为带汉子的日月
formatDay (date) {
let month = date.substr(4, 2);
let day = date.substr(6, 2);
if (month.substr(0, 1) === '0') month = month.substr(1, 1);
if (day.substr(0, 1) === '0') day = day.substr(1, 1);
return `${month} 月 ${day} 日`;
}
}
}
</script>

// style.css
.daily-list {
width: 300px;
position: fixed;
top: 0;
bottom: 0;
left: 150px;
overflow: auto;
border-right: 1px solid #d7dde4;
}
.daily-date {
text-align: center;
margin: 10px 0;
}

自动加载更多推荐类表

在 “每日推荐” 类型下, 中栏的文章列表滚动到底部会自动加载前一天的推荐列表, 所以要监听中栏 (.daily-list) 的滚动事件, 并在合适的时机触发加载请求:

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
//	app.vue
<template>
<div class="daily-list" ref="list">
</div>
</template>
<script>
export default {
data () {
return {
isLoading: false
}
},
methods: {
getRecommendList () {
// 加载时设置为 true, 加载完成后置为 false
this.isLoading = true;
const prevDay = $.prevDay(this.dailyTime + 86400000);
$.ajax.get('news/before/' + prevDay).then(res => {
this.recommendList.push(res);
this.isLoading = false;
})
}
},
mounted () {
this.getRecommendList();
// 获取到 DOM
const $list = this.$refs.list;
// 监听中栏的滚动事件
$list.addEventListener('scroll', () => {
// 在 "主题日报" 或正在加载推荐类表时停止操作
if (this.type === 'daily' || this.isLoading) return;
// 已经滚动的距离加页面的高度等于整个内容区域高度时, 视为接触底部
if ($list.scrollTop + document.body.clientHeight >= $list.scrollHeight) {
// 时间应对减少一天
this.dailyTime -= 86400000;
this.getRecommendList();
}
});
}
}
</script>

$list (.daily-list) 的 CSS 使用了 overflow: auto, 所以它具备滚动的能力, 进而可以监听滚动事件. 直接操作 DOM 在 Vue 中很少见, 但示例的场景和一些对 window、document 对象监听事件的场景还是有的, 使用监听时要注意在 beforeDestroy 生命周期使用 removeEventListener 移除. ¥list 的 scroll 是标准 DOM 事件, 所以也可以用 Vue 的 v-on 指令. 比如上例也可以改写为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<div class="daily-list"
ref="list"
@scroll="handleScroll"></div>
</template>
<script>
export default {
methods: {
handleScroll () {
const $list = this.$refs.list;
if (this.type === 'daily' || this.isLoading) return;
if ($list.scrollTop + document.body.clientHeight >= $list.scrollHeight) {
this.dailyTime -= 86400000;
this.getRecommendList();
}
}
}
}
</script>

文章详情页

加载内容

右侧的文章内容区域封装成了一个组件. 在 components 目录下新建 daily-article.vue 组件, 它接收唯一的一个 prop:id, 也就是文章的 id, 如果 id 变化了, 就说明切换了文章, 需要请求新的文章内容.

早 app.vue 中导入 daily-article.vue 组件, 并在文章类表的 Item 组件上绑定查看文章事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//	app.vue
<template>
<div class="daily">
<Item @click.native="handleClick(item.id)"></Item>
<daily-article :id="articleId"></daily-article>
</div>
</template>
<script>
import Item from './components/item.vue';
import dailyArticle from './components/daily-article.vue';
export default {
components: { Item, dailyArticle },
date () {
return {
articleId: 0
}
},
methods: {
handleClick (id) {
this.artivleId = id;
}
}
}
</script>

Item 是组件, 绑定原生事件时要带指定事件修饰符 .native, 否则会认为监听的是来自 Item 组件的自定义事件 click.

dailyArticle 组件在监听到 id 改变时请求文章内容:

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
51
52
53
54
55
56
57
58
59
60
61
62
//	components/daily-artivle.vue
<template>
<div class="daily-article">
<div class="daily-article-title">{{ data.title }}</div>
<div class="daily-article-content" v-html="data.body"></div>
</div>
</template>
<script>
import $ from '../libs/util';
export default {
props: {
id: {
type: Number,
default: 0
}
},
data () {
return {
data: {}
}
},
methods: {
getArticle () {
$.ajax.get('news' + this.id).then(res => {
// 将文章中的图片地址替换为代理的地址
res.body = res.body.replace(/src="http/g, 'src="' + $.imgPath + 'http');
res.body = res.body.replace(/src="https/g, 'src="' + $.imgPath + 'https');\
this.data = res;
// 返回文章顶端
window.scrollTo(0, 0);
})
}
},
watch: {
id (val) {
if (val) this.getArticle();
}
}
}
</script>

// style.css
.daily-article {
margin-left: 450px;
padding: 20px;
}
.daily-article-title {
font-size: 28px;
font-weight: bold;
color: #222;
padding: 10px 0;
}
.view-more a {
display: block;
cursor: pointer;
background: #f5f7f8;
text-align: center;
color: inherit;
text-decoration: none;
padding: 4px 0;
border-radius: 3px;
}

数据的 data 结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"title": "这茶, 明显是用了梅雨期的雨水, 我还是喜欢用腊月的雪水",
"body": "文章内容, 格式为 html",
"id": "9395306",
"type": 0,
"image": "https://pic3.zhimg.com/xxxxxx",
"image_source": "T.Tseng / CC BY",
"images": [
"https://pic4.zhimg.com/xxxxx"
],
"share_url": "http://daily.zhihu.com/xxxxx",
"ga_prefix": "050311",
"js": [],
"css": [
"http://news-at.zhihu.com/css/xxxxx"
]
}

这里只用了 title 和 body, 其中 body 的格式为 html, 需要用 v-html 指令直接显示. 用户 可能会在某篇文章阅读到一定位置时切换了别的文章, 这时文章的滚动条仍停留在上次浏览的位置, 使用 window.scollTo(0,0) 可以返回页面的顶端. 需要注意的是, .daily-article.vue 并没有使用 overflow: auto 滚动, 而是自然高度, 所以这里是让页面返回顶端, 而不能设置 .daily-article 的 scrollTop 为 0.

加载评论

每篇文章底部要加载评论, 评论的数据结构为:

每条评论要显示发表时间, 源数据格式为时间戳, 需要前端转为相对时间. 在 daily 目录下创建 directives 目录, 并创建 time.js 文件, 内容如下:

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
//	directives/time.js
var Time = {
// 获取当前时间戳
getUnix: function () {
var date = new Date();
return date.getTime();
},
// 获取今天 0 点 0 分 0 秒的时间戳
getTodayUnix: function() {
var date = new Date();
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
return date.getTime();
},
// 获取今年 1 月 1 日 0 点 0 分 0 秒的时间戳
getYearUnix: function() {
var date = new Date();
date.setMonth(0);
date.setDate(1);
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
return date.getTime();
},
// 获取标准年月日
getLastDate: function(time) {
var date = new Date(time);
var month = date.getMonth() + 1 < 10 ? '0' + (date.getMonth() + 1) : date.getMonth() + 1;
var day = date.getDate() < 10 ? '0' + date.getDate() : date.getDate();
return date.getFullYear() + '-' + month + '-' + day;
},
// 转换时间
getFormatTime: function() {
var now = this.getUnix(); // 当前时间戳
var today = this.getTodayUnix(); // 今天 0 点时间戳
var year = this.getYearUnix(); // 今年 0 点时间戳
var timer = (now - timestamp) / 1000; // 转换为秒级时间戳
vat tip = '';

if (timer <= 0) {
tip = '刚刚';
} else if (Math.floor(timer/60) <= 0){
tip = '刚刚';
} else if (timer < 3600) {
tip = Math.floor(timer/60) + '分钟前';
} else if (timer >= 3600 && (timestamp - today >= 0)) {
tip = Math.floor(timer/3600) + '小时前';
} else if (timer / 86400 <= 31) {
tip = Math.ceil(timer/86400) + '天前';
} else {
tip = this.getLastDate(timestamp);
}
return tip;
}
};

export default {
bind: function (el, binding) {
el.innerHTML = Time.getFormatTime(binding.value * 1000);
el.__timeout__ = setInterval(function(){
el.innerHTML = Time.getFormatTime(binding.value * 1000);
}, 60000);
},
unbind: function (el) {
clearInterval(el.__timeout__);
delete el.__timeout__;
}
}

评论列表在获取完文章内容后在获取.

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
//	components/daily-article.vue
<template>
<div class="daily-article">
<div class="daily-article-title">{{ data.title }}</div>
<div class="daily-article-content" v-html="data.body"></div>

<div class="daily-comments" v-show="comments.length">
<span>评论 ({{ comments.length }})</span>
<div class="daily-comment" v-for="comment in comments">
<div class="daily-comment-avatar">
<img :src="comment.avatar">
</div>
<div class="daily-comment-content">
<div class="daily-comment-name">{{ comment.author }}</div>
<div class="daily-comment-time" v-time="comment.time"></div>
<div class="daily-comment-text">{{ comment.content }}</div>
</div>
</div>
</div>
</div>
</template>

<script>
import Time from './directives/time';
import $ from '../libs/util';
export default {
directives: { Time },
props: {
id: {
type: Number,
default: 0
}
},
data () {
return {
data: {},
comments: []
}
},
methods: {
getArticle () {
$.ajax.get('news/' + this.id).then(res => {
//...
this.getComments();
})
},
getComments () {
this.comments = [];
$.ajax.get('story' + this.id + '/short-comments').then(res => {
this.comments = res.comments.map(comment => {
// 将头像的图片地址转为代理地址
comment.avatar = $.imgPath + comment.avatar;
return comment;
});
})
}
}
};
</script>

// style.css
.daily-comments {
margin: 10px 0;
}
.daily-comments span {
display: block;
margin: 10px 0;
font-size: 20px;
}
.daily-comment {
overflow: hidden;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px dashed #e3e8ee;
}
.daily-comment-avatar {
width: 50px;
height: 50px;
float: left;
}
.daily-comment-avatar img {
width: 100%;
height: 100%;
border-radius: 3px;
}
.daily-comment-content {
margin-left: 65px;
}
.daily-comment-name {

}
.daily-comment-time {
color: #9ea7b4;
font-size: 14px;
margin-top: 5px;
}
.daily-comment-text {
margin-top: 10px
}

至此, 知乎日报的所有核心代码分析完毕

总结

日报项目以单页面的形式呈现, 基本覆盖了 Vue 和 webpack 的核心功能, 他们包括:

  • Vue 的单文件组件用法
  • Vue 的基本指令、自定义指令.
  • 数据的获取、整理、可视化.
  • prop、事件、子组件索引
  • ES6 模块

日报项目是一个较独立的单页小应用, 没有使用路由和大规模状态管理插件 Vuex, 在工程上并不算复杂, 比较适合刚入手 Vue 的联系项目. 虽然看似简单, 但它覆盖了业务中很多场景, 对代码进行了组织和模块化, 很接近真实的生产项目. 项目对代码维护和扩展性也有考虑, 比如对 Ajax 的封装, 通用工具函数的提取、组件的解耦等, 这些细节都是在实际项目中要考虑的.

打赏功能
-------------本文结束感谢您的阅读-------------
0%