目标
- 能使用 Vue CLI 创建项目
- 了解 Vant 组件库的导入方式
- 掌握制作使用字体图标的方式
- 掌握如何在 Vue 项目中处理 REM 适配
- 理解 axios 请求模块的封装
如果你还没有安装 VueCLI,请执行下面的命令安装或是升级: npm install --global @vue/cli
在命令行中输入以下命令创建 Vue 项目:vue create toutiao-m
Vue CLI v4.2.3
? Please pick a preset:
default (babel, eslint)
> Manually select features
default:默认勾选 babel、eslint,回车之后直接进入装包
manually:自定义勾选特性配置,选择完毕之后,才会进入装包
选择第 2 种:手动选择特性,支持更多自定义选项。
? Please pick a preset: Manually select features
? Check the features needed for your project:
(*) Babel
( ) TypeScript
( ) Progressive Web App (PWA) Support
(*) Router
(*) Vuex
(*) CSS Pre-processors
>(*) Linter / Formatter
( ) Unit Testing
( ) E2E Testing
分别选择:
Babel:es6 转 es5
Router:路由
Vuex:数据容器,存储共享数据
CSS Pre-processors:CSS 预处理器,后面会提示你选择 less、sass、stylus 等
Linter / Formatter:代码格式校验
? Use history mode for router? (Requires proper server setup for index fallback in production) (Y/n) n
是否使用 history 路由模式,这里输入 n 不使用
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default):
Sass/SCSS (with dart-sass)
Sass/SCSS (with node-sass)
> Less
Stylus
选择 CSS 预处理器,这里选择我们熟悉的 Less
? Pick a linter / formatter config:
ESLint with error prevention only
ESLint + Airbnb config
> ESLint + Standard config
ESLint + Prettier
选择校验工具,这里选择 ESLint + Standard config
? Pick additional lint features:
(*) Lint on save
>(*) Lint and fix on commit
选择在什么时机下触发代码格式校验:
- Lint on save:每当保存文件的时候
- Lint and fix on commit:每当执行
git commit
提交的时候
这里建议两个都选上,更严谨。
? Where do you prefer placing config for Babel, ESLint, etc.? (Use arrow keys)
> In dedicated config files
In package.json
Babel、ESLint 等工具会有一些额外的配置文件,这里的意思是问你将这些工具相关的配置文件写到哪里:
- In dedicated config files:分别保存到单独的配置文件
- In package.json:保存到 package.json 文件中
这里建议选择第 1 个,保存到单独的配置文件,这样方便我们做自定义配置。
? Save this as a preset for future projects? (y/N) N
这里里是问你是否需要将刚才选择的一系列配置保存起来,然后它可以帮你记住上面的一系列选择,以便下次直接重用。
这里根据自己需要输入 y 或者 n,我这里输入 n 不需要。
✨ Creating project in C:\Users\LPZ\Desktop\topline-m-fe89\topline-m-89.
� Initializing git repository...
⚙ Installing CLI plugins. This might take a while...
[ ........] - extract:object-keys: sill extract [email protected]
向导配置结束,开始装包。
安装包的时间可能较长,请耐心等待…
⚓ Running completion hooks...
� Generating README.md...
� Successfully created project topline-m-89.
� Get started with the following commands:
$ cd topline-m
$ npm run serve
安装结束,命令提示你项目创建成功,按照命令行的提示在终端中分别输入:
# 进入你的项目目录
cd toutiao-webapp
# 启动开发服务
npm run serve
DONE Compiled successfully in 7527ms
App running at:
- Local: http://localhost:8080/
- Network: http://192.168.10.216:8080/
Note that the development build is not optimized.
To create a production build, run npm run build.
启动成功,命令行中输出项目的 http 访问地址。
打开浏览器,输入其中任何一个地址进行访问。
几个好处:
1》创建远程仓库
2》将本地仓库推到线上
如果没有本地仓库。
# 创建本地仓库
git init
# 将文件添加到暂存区
git add 文件 (git add .)
# 提交历史记录
git commit -m "提交日志"
# 添加远端仓库地址
git remote add origin 你的远程仓库地址
# 推送提交
gti push --set-upstream origin master
# 简写:
git push -u origin master
# -u 表示把本次推送的信息记住,下次直接使用git push,不用写后面的参数。
# master:master简写成了master,表示将一个分支的内容推送到远程分支中去。
# origin 表示仓库地址
如果已有本地仓库(Vue CLI 已经帮我们初始化好了)。
# 添加远端仓库地址
git remote add origin 你的远程仓库地址
# 推送提交
git push -u origin master
如果之后项目代码有了变动需要提交:
git add
git commit
git push
默认生成的目录结构不满足我们的开发需求,所以这里需要做一些自定义改动。这里主要就是下面的两个工作:
1、将 App.vue
修改为:
<template>
<div id="app">
<h1>头条项目toutiao-mh1>
<router-view />
div>
template>
<script>
export default {
name: 'App' // 项目的根组件
}
script>
<style scoped lang="less">style>
2、将 router/index.js
修改为
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
const routes = []
const router = new VueRouter({
routes
})
export default router
3、删除:
4、创建以下几个目录
main.js
中加载全局样式 import './styles/index.less'
调整之后的目录结构如下:
├── README.md
├── babel.config.js
├── package-lock.json
├── package.json
├── public
│ ├── favicon.ico
│ └── index.html
└── src
├── api
├── App.vue
├── assets
├── components
├── main.js
├── router
├── utils
├── styles
├── store
└── views
设计师为我们单独提供了设计稿中的图标(svg图标),为了方便使用,我们在这里把它制作为字体图标。
制作字体图标的工具有很多,在这里我们推荐大家使用:https://www.iconfont.cn/。
一、注册账户
直接选择第三方登录即可
二、创建项目
三、上传图标到项目
上传svg图标:
四、生成链接
五、配置到项目中使用
一种方式是将 SVG 图标 包装为 Vue 组件来使用。
一种方式是将 SVG 制作为字体图标来使用:
新建src/styles/icon.less文件,将打开的地址中的代码复制到这里。然后在index.less中加载图标样式:@import "./icon.less";
用法:
将素材图片复制到 src/assets 文件夹中:
将 favicon.ico 图标替换到 public文件夹中。
Vant 是有赞商城前端开发团队开发的一个基于 Vue.js 的移动端组件库,它提供了非常丰富的移动端功能组件,简单易用。
下面是在 Vant 官网中列出的一些优点:
在我们的项目中主要使用 Vant 作为核心组件库,下面我们根据官方文档将 Vant 导入项目中。
将 Vant 引入项目一共有四种方式:
方式一:自动按需引入组件
babel-plugin-import
插件)方式二:手动按需引入组件
方式三:导入所有组件
方式四:通过 CDN 引入
vant
访问到所有组件。这里建议为了前期开发的便利性我们选择方式三:导入所有组件,在最后做打包优化的时候根据需求配置按需加载以降低打包体积大小。
1、安装 Vant:npm i vant
2、在 main.js
中加载注册 Vant 组件(用方式三-导入所有组件)
import Vue from 'vue'
// 加载Vant核心组件库
import Vant from 'vant'
// 加载Vant全局样式
import 'vant/lib/index.css'
Vue.use(Vant)
3、查阅文档使用组件
Vant 的文档非常清晰,左侧是组件目录导航,中间是效果代码,右边是效果预览。
例如我们在根组件使用 Vant 中的组件(因为是一次性引入,不需要单独引入Button按钮组件了):
<van-button type="default">默认按钮van-button>
<van-button type="primary">主要按钮van-button>
<van-button type="info">信息按钮van-button>
<van-button type="warning">警告按钮van-button>
<van-button type="danger">危险按钮van-button>
<van-cell-group>
<van-cell title="单元格" value="内容" />
<van-cell title="单元格" value="内容" label="描述信息" />
van-cell-group>
Vant 中的样式默认使用 px
作为单位,如果需要使用 rem
单位,推荐使用以下两个工具:
下面我们分别将这两个工具配置到项目中完成 REM 适配。
一、使用 lib-flexible 动态设置 REM 基准值(html 标签的字体大小)
1、安装
# yarn add amfe-flexible
npm i amfe-flexible
2、然后在 main.js
中加载执行该模块
// 加载动态设置REM基准值
import 'amfe-flexible'
最后测试:在浏览器中切换不同的手机设备尺寸,观察 html 标签 font-size
的变化。
例如在 iPhone 6/7/8 Plus 设备下,html 标签字体大小为 41.4 px
二、使用 postcss-pxtorem 将 px
转为 rem
1、安装
# yarn add -D postcss-pxtorem
# -D 是 --save-dev 的简写
npm i postcss-pxtorem -D
2、然后在项目根目录中创建 .postcssrc.js
文件
module.exports = {
plugins: {
'autoprefixer': {
browsers: ['Android >= 4.0', 'iOS >= 8']
},
'postcss-pxtorem': {
rootValue: 37.5,
propList: ['*']
}
}
}
3、配置完毕,重新启动服务
最后测试:刷新浏览器页面,审查元素的样式查看是否已将 px
转换为 rem
。
这是没有配置转换之前的。
这是转换之后的,可以看到 px 都被转换为了 rem。
需要注意的是:
该插件不能转换行内样式中的 px
,例如
.postcssrc.js
配置文件module.exports = {
plugins: {
'autoprefixer': {
browsers: ['Android >= 4.0', 'iOS >= 8']
},
'postcss-pxtorem': {
rootValue: 37.5,
propList: ['*']
}
}
}
.postcssrc.js
是 PostCSS 的配置文件。因为.postcsssrc.js是基于node.js来运行的工具,所以是用node.js语法编写的。
PostCSS 是一个处理 CSS 的处理工具,本身功能比较单一,它主要负责解析 CSS 代码,再交由插件来进行处理,它的插件体系非常强大,所能进行的操作是多种多样的,例如:
目前 PostCSS 已经有 200 多个功能各异的插件。开发人员也可以根据项目的需要,开发出自己的 PostCSS 插件。
PostCSS 一般不单独使用,而是与已有的构建工具进行集成。
Vue CLI 默认集成了 PostCSS,并且默认开启了 Autoprefixer 插件。
Vue CLI 内部使用了 PostCSS。
你可以通过
.postcssrc
或任何 postcss-load-config 支持的配置源来配置 PostCSS。也可以通过vue.config.js
中的css.loaderOptions.postcss
配置 postcss-loader。Vue CLI 默认开启了 Autoprefixer。如果要配置目标浏览器,可使用
package.json
的 browserslist 字段。
Autoprefixer 是一个自动添加浏览器前缀的 PostCss 插件,browsers
用来配置兼容的浏览器版本信息,但是写在这里的话会引起编译器警告。
警告意思就是说你应该将 browsers
选项写到 package.json
或 .browserlistrc
文件中。
[Android]
>= 4.0
[iOS]
>= 8
具体语法请参考这里。
rootValue
:表示根元素字体大小,它会根据根元素大小进行单位转换。propList
用来设定可以从 px 转为 rem 的属性。
*
就是所有属性都要转换,width
就是仅转换 width
属性。rootValue
应该如何设置呢?
如果你使用的是基于 lib-flexable
的 REM 适配方案(把一行分为10份),则应该设置为你的设计稿的十分之一。例如:设计稿是 750 宽,则应该设置为 75。
大多数设计稿的原型都是以 iphone6 为原型,iphone6 设备的宽是 750,我们的设计稿也是这样。
但是 Vant 建议设置为 37.5,为什么呢?
因为 Vant 是基于 375
写的,所以如果你设置为 75 的话,Vant 的样式就小了一半。
所以如果设置为 37.5
的话,Vant 的样式是没有问题的,但是我们在测量设计稿的时候都 必须除2 才能使用,否则就会变得很大。
这样做其实也没有问题,但是有没有更好的办法呢?我就想实现测量多少写多少(不用换算)。于是聪明的你就想,可以不可以这样来做?
rootValue
设置为 37.5 来转换。rootValue
来转换(750/10=75)通过查阅文档我们可以看到 rootValue
支持两种参数类型:
postcss-pxtorem
处理每个 CSS 文件的时候都会来调用这个函数。所以我们修改配置如下:
// PostCSS 配置文件
module.exports = {
// 配置要使用的 PostCSS 插件
plugins: {
// 配置使用 autoprefixer 插件
// 作用:生成浏览器 CSS 样式规则前缀
// VueCLI 内部已经配置了 autoprefixer 插件
// 这里又配置了一次,所以产生冲突了
// 'autoprefixer': { // autoprefixer 插件的配置
// // 配置要兼容到的环境信息
// browsers: ['Android >= 4.0', 'iOS >= 8']
// },
// 配置使用 postcss-pxtorem 插件
// 作用:把 px 转为 rem
'postcss-pxtorem': {
rootValue ({ file }) { // ES6的写法:参数解构
return file.indexOf('vant') !== -1 ? 37.5 : 75
},
// 配置要转换的css属性,*表示所有
propList: ['*']
}
}
}
【参数解构】直接在函数的参数里面解构参数对象。
配置完毕,把服务重启一下,最后测试,very good。
和之前项目一样,这里我们还是使用 axios 作为我们项目中的请求库,为了方便使用,我们把它封装为一个请求模块,在需要的时候直接加载即可。
1、安装 axios:npm i axios
2、创建 src/utils/request.js
// 封装 axios 请求模块
import axios from 'axios'
const request = axios.create({
baseURL: 'http://ttapi.research.itcast.cn/' // 基础路径
})
// 请求拦截器
// 响应拦截器
export default request
3、如何使用
Vue.prototype
原型对象中,然后在组件中通过 this.xxx
直接访问。在我们的项目中建议使用方式二,更推荐(在随后的业务功能中我们就能学到)。
目标
- 能实现登录页面的布局
- 能实现基本登录功能
- 能掌握 Vant 中 Toast 提示组件的使用
- 能理解 API 请求模块的封装
- 能理解发送验证码的实现思路
- 能理解 Vant Form 实现表单验证的使用
1、创建 src/views/login/index.vue
并写入以下内容:
<template>
<div class="login-container">登录页面div>
template>
<script>
export default {
name: 'LoginPage',
components: {},
props: {},
data () {
return {}
},
computed: {},
watch: {},
created () {},
mounted () {},
methods: {}
}
script>
<style scoped lang="less">style>
2、然后在 src/router/index.js
中配置登录页的路由表
{
path: '/login',
name: 'login',
component: () => import('@/views/login')
}
这里主要使用到三个 Vant 组件:
经验:使用组件库中的现有组件快速布局,再慢慢调整细节,效率更高(刚开始可能会感觉有点麻烦,越用越熟,慢慢的就有了自己的思想)。
写样式的原则:将公共样式写到全局(src/styles/index.less
),将局部样式写到组件内部。
1、src/styles/index.less
body {
background-color: #f5f7f9;
}
.page-nav-bar {
background-color: #3296fa;
.van-nav-bar__title {
color: #fff;
}
}
2、src/views/login/index.vue
<template>
<div class="login-container">
<van-nav-bar class="page-nav-bar" title="登录" />
<van-form @submit="onSubmit">
<van-field
name="用户名"
placeholder="请输入手机号"
>
<i slot="left-icon" class="toutiao toutiao-shouji">i>
van-field>
<van-field
type="password"
name="验证码"
placeholder="请输入验证码"
>
<i slot="left-icon" class="toutiao toutiao-yanzhengma">i>
<template #button>
<van-button class="send-sms-btn" round size="small" type="default">发送验证码van-button>
template>
van-field>
<div class="login-btn-wrap">
<van-button class="login-btn" block type="info" native-type="submit">
登录
van-button>
div>
van-form>
div>
template>
<script>
export default {
name: 'LoginIndex',
components: {},
props: {},
data () {
return {
}
},
computed: {},
watch: {},
created () {},
mounted () {},
methods: {
onSubmit (values) {
console.log('submit', values)
}
}
}
script>
<style scoped lang="less">
.login-container {
.toutiao {
font-size: 37px;
}
.send-sms-btn {
width: 152px;
height: 46px;
line-height: 46px;
background-color: #ededed;
font-size: 22px;
color: #666;
}
.login-btn-wrap {
padding: 53px 33px;
.login-btn {
background-color: #6db4fb;
border: none;
}
}
}
style>
思路:
一、根据接口要求绑定获取表单数据
1、在登录页面组件的实例选项 data 中添加 user
数据字段
...
data () {
return {
user: {
mobile: '',
code: ''
}
}
}
2、在表单中使用 v-model
绑定对应数据
<van-cell-group>
<van-field
v-model="user.mobile"
required
clearable
label="手机号"
placeholder="请输入手机号"
/>
<van-field
v-model="user.code"
type="number"
label="验证码"
placeholder="请输入验证码"
required
/>
van-cell-group>
最后测试。
小技巧:使用 VueDevtools 调试工具查看是否绑定成功。
二、请求登录
1、创建 src/api/user.js
封装请求方法
// 用户相关的请求模块
import request from "@/utils/request"
// 用户登录
export const login = data => {
return request({
method: 'POST',
url: '/app/v1_0/authorizations',
data
})
}
2、给登录按钮注册点击事件
async onLogin () {
try {
const res = await login(this.user)
console.log('登录成功', res)
} catch (err) {
if (err.response.status === 400) {
console.log('登录失败', err)
}
}
}
最后测试。
Vant 中内置了Toast 轻提示组件,可以实现移动端常见的提示效果。
// 简单文字提示
Toast("提示内容");
// loading 转圈圈提示
Toast.loading({
duration: 0, // 持续展示 toast
message: "加载中...",
forbidClick: true // 是否禁止背景点击
});
// 成功提示
Ttoast.success("成功文案");
// 失败提示
Toast.fail("失败文案");
提示:在组件中可以直接通过 this.$toast
调用。
另外需要注意的是:Toast 默认采用单例模式,即同一时间只会存在一个 Toast,如果需要在同一时间弹出多个 Toast,可以参考下面的示例
Toast.allowMultiple();
const toast1 = Toast('第一个 Toast');
const toast2 = Toast.success('第二个 Toast');
toast1.clear();
toast2.clear();
下面是为我们的登录功能增加 toast 交互提示。
async onLogin () {
// 开始转圈圈
this.$toast.loading({
duration: 0, // 持续时间,0表示持续展示不停止
forbidClick: true, // 是否禁止背景点击
message: '登录中...' // 提示消息
})
try {
const res = await request({
method: 'POST',
url: '/app/v1_0/authorizations',
data: this.user
})
console.log('登录成功', res)
// 提示 success 或者 fail 的时候,会先把其它的 toast 先清除
this.$toast.success('登录成功')
} catch (err) {
console.log('登录失败', err)
this.$toast.fail('登录失败,手机号或验证码错误')
}
}
假如请求非常快的话就看不到 loading 效果了,这里可以手动将调试工具中的网络设置为慢速网络。
测试结束,再把网络设置恢复为 Online
正常网络。
参考文档:Form 表单验证
<template>
<div class="login-container">
<van-nav-bar class="page-nav-bar" title="登录" />
<van-form @submit="onSubmit">
<van-field
v-model="user.mobile"
name="手机号"
placeholder="请输入手机号"
:rules="userFormRules.mobile"
type="number"
maxlength="11"
>
<i slot="left-icon" class="toutiao toutiao-shouji">i>
van-field>
<van-field
v-model="user.code"
name="验证码"
placeholder="请输入验证码"
:rules="userFormRules.code"
type="number"
maxlength="6"
>
<i slot="left-icon" class="toutiao toutiao-yanzhengma">i>
<template #button>
<van-button class="send-sms-btn" round size="small" type="default">发送验证码van-button>
template>
van-field>
<div class="login-btn-wrap">
<van-button class="login-btn" block type="info" native-type="submit">
登录
van-button>
div>
van-form>
div>
template>
<script>
import { login } from '@/api/user'
export default {
name: 'LoginIndex',
components: {},
props: {},
data () {
return {
user: {
mobile: '', // 手机号
code: '' // 验证码
},
userFormRules: {
mobile: [{
required: true,
message: '手机号不能为空'
}, {
pattern: /^1[3|5|7|8]\d{9}$/,
message: '手机号格式错误'
}],
code: [{
required: true,
message: '验证码不能为空'
}, {
pattern: /^\d{6}$/,
message: '验证码格式错误'
}]
}
}
},
computed: {},
watch: {},
created () {},
mounted () {},
methods: {
async onSubmit () {
// 1. 获取表单数据
const user = this.user
// TODO: 2. 表单验证
// 3. 提交表单请求登录
this.$toast.loading({
message: '登录中...',
forbidClick: true, // 禁用背景点击
duration: 0 // 持续时间,默认 2000,0 表示持续展示不关闭
})
try {
const res = await login(user)
console.log('登录成功', res)
this.$toast.success('登录成功')
} catch (err) {
if (err.response.status === 400) {
this.$toast.fail('手机号或验证码错误')
} else {
this.$toast.fail('登录失败,请稍后重试')
}
}
// 4. 根据请求响应结果处理后续操作
}
}
}
script>
<style scoped lang="less">
.login-container {
.toutiao {
font-size: 37px;
}
.send-sms-btn {
width: 152px;
height: 46px;
line-height: 46px;
background-color: #ededed;
font-size: 22px;
color: #666;
}
.login-btn-wrap {
padding: 53px 33px;
.login-btn {
background-color: #6db4fb;
border: none;
}
}
}
style>
async onSendSms () {
console.log('onSendSms')
// 1. 校验手机号
try {
await this.$refs.loginForm.validate('mobile')
} catch (err) {
return console.log('验证失败', err)
}
// 2. 验证通过,显示倒计时
// 3. 请求发送验证码
}
1、在 data 中添加数据用来控制倒计时的显示和隐藏
data () {
return {
...
isCountDownShow: false
}
}
2、使用倒计时组件
<van-field
v-model="user.code"
placeholder="请输入验证码"
>
<i class="icon icon-mima" slot="left-icon">i>
<van-count-down
v-if="isCountDownShow"
slot="button"
:time="1000 * 5"
format="ss s"
@finish="isCountDownShow = false"
/>
<van-button
v-else
slot="button"
size="small"
type="primary"
round
@click="onSendSmsCode"
>发送验证码van-button>
van-field>
1、在 api/user.js
中添加封装数据接口
export const getSmsCode = mobile => {
return request({
method: 'GET',
url: `/app/v1_0/sms/codes/${mobile}`
})
}
2、给发送验证码按钮注册点击事件
3、发送处理
async onSendSms () {
// 1. 校验手机号
try {
await this.$refs.loginForm.validate('mobile')
} catch (err) {
return console.log('验证失败', err)
}
// 2. 验证通过,显示倒计时
this.isCountDownShow = true
// 3. 请求发送验证码
try {
await sendSms(this.user.mobile)
this.$toast('发送成功')
} catch (err) {
// 发送失败,关闭倒计时
this.isCountDownShow = false
if (err.response.status === 429) {
this.$toast('发送太频繁了,请稍后重试')
} else {
this.$toast('发送失败,请稍后重试')
}
}
}
Token 是用户登录成功之后服务端返回的一个身份令牌,在项目中的多个业务中需要使用到:
但是我们只有在第一次用户登录成功之后才能拿到 Token。所以为了能在其它模块中获取到 Token 数据,我们需要把它存储到一个公共的位置,方便随时取用。
往哪儿存?
下面是具体实现。
1、在 src/store/index.js
中
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
// 用户的登录状态信息
user: JSON.parse(window.localStorage.getItem('TOUTIAO_USER'))
// user: null
},
mutations: {
setUser (state, user) {
state.user = user
window.localStorage.setItem('TOUTIAO_USER', JSON.stringify(user))
}
},
actions: {
},
modules: {
}
})
2、登录成功以后将后端返回的 token 相关数据存储到容器中
async onLogin () {
// const loginToast = this.$toast.loading({
this.$toast.loading({
duration: 0, // 持续时间,0表示持续展示不停止
forbidClick: true, // 是否禁止背景点击
message: '登录中...' // 提示消息
})
try {
const res = await login(this.user)
// res.data.data => { token: 'xxx', refresh_token: 'xxx' }
+ this.$store.commit('setUser', res.data.data)
// 提示 success 或者 fail 的时候,会先把其它的 toast 先清除
this.$toast.success('登录成功')
} catch (err) {
console.log('登录失败', err)
this.$toast.fail('登录失败,手机号或验证码错误')
}
// 停止 loading,它会把当前页面中所有的 toast 都给清除
// loginToast.clear()
}
创建 src/utils/storage.js
模块。
export const getItem = name => {
const data = window.localStorage.getItem(name)
try {
return JSON.parse(data)
} catch (err) {
return data
}
}
export const setItem = (name, value) => {
if (typeof value === 'object') {
value = JSON.stringify(value)
}
window.localStorage.setItem(name, value)
}
export const removeItem = name => {
window.localStorage.removeItem(name)
}
登录成功之后后端会返回两个 Token:
token
:访问令牌,有效期2小时refresh_token
:刷新令牌,有效期14天,用于访问令牌过期之后重新获取新的访问令牌我们的项目接口中设定的 Token
有效期是 2 小时
,超过有效期服务端会返回 401
表示 Token 无效或过期了。
为什么过期时间这么短?
过期了怎么办?
refresh_token
解决 token
过期如何使用 refresh_token
解决 token
过期?
到课程的后面我们开发的业务功能丰富起来之后,再给大家讲解 Token 过期处理。
大家需要注意的是在学习测试的时候如果收到 401 响应码,请重新登录再测试。
概述:服务器生成token的过程中,会有两个时间,一个是token失效时间,一个是token刷新时间,刷新时间肯定比失效时间长,当用户的 token
过期时,你可以拿着过期的token去换取新的token,来保持用户的登陆状态,当然你这个过期token的过期时间必须在刷新时间之内,如果超出了刷新时间,那么返回的依旧是 401。
处理流程:
在请求的响应拦截器中统一处理 token 过期:
// 封装 axios 请求模块
import axios from "axios";
import jsonBig from "json-bigint";
import store from "@/store";
import router from "@/router";
// axios.create 方法:复制一个 axios
const request = axios.create({
baseURL: "http://ttapi.research.itcast.cn/" // 基础路径
});
/**
* 配置处理后端返回数据中超出 js 安全整数范围问题
*/
request.defaults.transformResponse = [
function(data) {
try {
return jsonBig.parse(data);
} catch (err) {
return {};
}
}
];
// 请求拦截器
request.interceptors.request.use(
function(config) {
const user = store.state.user;
if (user) {
config.headers.Authorization = `Bearer ${user.token}`;
}
// Do something before request is sent
return config;
},
function(error) {
// Do something with request error
return Promise.reject(error);
}
);
// 响应拦截器
request.interceptors.response.use(
// 响应成功进入第1个函数
// 该函数的参数是响应对象
function(response) {
// Any status code that lie within the range of 2xx cause this function to trigger
// Do something with response data
return response;
},
// 响应失败进入第2个函数,该函数的参数是错误对象
async function(error) {
// Any status codes that falls outside the range of 2xx cause this function to trigger
// Do something with response error
// 如果响应码是 401 ,则请求获取新的 token
// 响应拦截器中的 error 就是那个响应的错误对象
console.dir(error);
if (error.response && error.response.status === 401) {
// 校验是否有 refresh_token
const user = store.state.user;
if (!user || !user.refresh_token) {
router.push("/login");
// 代码不要往后执行了
return;
}
// 如果有refresh_token,则请求获取新的 token
try {
const res = await axios({
method: "PUT",
url: "http://ttapi.research.itcast.cn/app/v1_0/authorizations",
headers: {
Authorization: `Bearer ${user.refresh_token}`
}
});
// 如果获取成功,则把新的 token 更新到容器中
console.log("刷新 token 成功", res);
store.commit("setUser", {
token: res.data.data.token, // 最新获取的可用 token
refresh_token: user.refresh_token // 还是原来的 refresh_token
});
// 把之前失败的用户请求继续发出去
// config 是一个对象,其中包含本次失败请求相关的那些配置信息,例如 url、method 都有
// return 把 request 的请求结果继续返回给发请求的具体位置
return request(error.config);
} catch (err) {
// 如果获取失败,直接跳转 登录页
console.log("请求刷线 token 失败", err);
router.push("/login");
}
}
return Promise.reject(error);
}
);
export default request;
通过分析页面,我们可以看到,首页、问答、视频、我的 都使用的是同一个底部标签栏,我们没必要在每个页面中都写一个,所以为了通用方便,我们可以使用 Vue Router 的嵌套路由来处理。
一、创建 tabbar 组件并配置路由
这里主要使用到的 Vant 组件:
1、创建 src/views/layout/index.vue
<template>
<div class="layout-container">
<router-view />
<van-tabbar class="layout-tabbar" route>
<van-tabbar-item to="/">
<i slot="icon" class="toutiao toutiao-shouye">i>
<span class="text">首页span>
van-tabbar-item>
<van-tabbar-item to="/qa">
<i slot="icon" class="toutiao toutiao-wenda">i>
<span class="text">问答span>
van-tabbar-item>
<van-tabbar-item to="/video">
<i slot="icon" class="toutiao toutiao-shipin">i>
<span class="text">视频span>
van-tabbar-item>
<van-tabbar-item to="/my">
<i slot="icon" class="toutiao toutiao-wode">i>
<span class="text">我的span>
van-tabbar-item>
van-tabbar>
div>
template>
<script>
export default {
name: 'LayoutIndex',
components: {},
props: {},
data () {
return {
}
},
computed: {},
watch: {},
created () {},
mounted () {},
methods: {}
}
script>
<style scoped lang="less">
.layout-container {
.layout-tabbar {
i.toutiao {
font-size: 40px;
}
span.text {
font-size: 20px;
}
}
}
style>
2、然后将 layout 组件配置到一级路由
{
path: '/',
component: () => import('@/views/layout')
}
访问 /
测试。
二、分别创建首页、问答、视频、我的页面组件
首页组件:
<template>
<div class="home-container">首页div>
template>
<script>
export default {
name: 'HomePage',
components: {},
props: {},
data () {
return {}
},
computed: {},
watch: {},
created () {},
mounted () {},
methods: {}
}
script>
<style scoped>style>
问答组件:
<template>
<div class="qa-container">问答div>
template>
<script>
export default {
name: 'QaPage',
components: {},
props: {},
data () {
return {}
},
computed: {},
watch: {},
created () {},
mounted () {},
methods: {}
}
script>
<style scoped>style>
视频组件:
<template>
<div class="video-container">首页div>
template>
<script>
export default {
name: 'VideoPage',
components: {},
props: {},
data () {
return {}
},
computed: {},
watch: {},
created () {},
mounted () {},
methods: {}
}
script>
<style scoped>style>
我的组件:
<template>
<div class="my-container">首页div>
template>
<script>
export default {
name: 'MyPage',
components: {},
props: {},
data () {
return {}
},
computed: {},
watch: {},
created () {},
mounted () {},
methods: {}
}
script>
<style scoped>style>
二、将四个主页面配置为 tab-bar 的子路由
{
path: '/',
name: 'tab-bar',
component: () => import('@/views/tab-bar'),
children: [
{
path: '', // 默认子路由
name: 'home',
component: () => import('@/views/home')
},
{
path: 'qa',
name: 'qa',
component: () => import('@/views/qa')
},
{
path: 'video',
name: 'video',
component: () => import('@/views/video')
},
{
path: 'my',
name: 'my',
component: () => import('@/views/my')
}
]
}
最后测试。
<template>
<div class="my-container">
<div class="header">
<img
class="mobile-img"
src="~@/assets/mobile.png"
@click="$router.push('/login')"
>
div>
<div class="grid-nav">div>
<van-cell title="消息通知" is-link url="" />
<van-cell title="实名认证" is-link url="" />
<van-cell title="用户反馈" is-link url="" />
<van-cell title="小智同学" is-link url="" />
<van-cell title="系统设置" is-link url="" />
div>
template>
<script>
export default {
name: 'MyIndex',
components: {},
props: {},
data () {
return {}
},
computed: {},
watch: {},
created () {},
mounted () {},
methods: {}
}
script>
<style scoped lang="less">
.my-container {
> .header {
height: 361px;
background: url("~@/assets/banner.png") no-repeat;
background-size: cover;
display: flex;
justify-content: center;
align-items: center;
.mobile-img {
width: 132px;
height: 132px;
}
}
}
style>
<div v-if="$store.state.user" class="user-info-wrap">
...
div>
<div v-else class="not-login" @click="$router.push('/login')">
...
div>
<van-cell-group v-if="$store.state.user">
...
van-cell-group>
2、退出处理
onLogout () {
// 退出提示
// 在组件中需要使用 this.$dialog 来调用弹框组件
this.$dialog.confirm({
title: '确认退出吗?'
}).then(() => {
// on confirm
// 确认退出:清除登录状态(容器中的 user + 本地存储中的 user)
this.$store.commit('setUser', null)
}).catch(() => {
// on cancel
console.log('取消执行这里')
})
}
最后测试。
1、在 api/user.js
中添加封装数据接口
// 获取用户自己的信息
export const getUserInfo = () => {
return request({
method: 'GET',
url: '/app/v1_0/user',
// 发送请求头数据
headers: {
// 注意:该接口需要授权才能访问
// token的数据格式:Bearer token数据,注意 Bearer 后面有个空格
Authorization: `Bearer ${store.state.user.token}`
}
})
}
2、在 views/my/index.vue
请求加载数据
import { getUserInfo } from '@/api/user'
export default {
name: 'MyPage',
components: {},
props: {},
data () {
return {
userInfo: {} // 用户信息
}
},
computed: {},
watch: {},
created () {
// 初始化的时候,如果用户登录了,我才请求获取当前登录用户的信息
if (this.$store.state.user) {
this.loadUser()
}
},
mounted () {},
methods: {
async loadUser () {
try {
const { data } = await getUserInfo()
this.user = data.data
} catch (err) {
console.log(err)
this.$toast('获取数据失败')
}
}
}
}
3、模板绑定
项目中的接口除了登录之外大多数都需要提供 token 才有访问权限。
通过接口文档可以看到,后端接口要求我们将 token 放到请求头 Header
中并以下面的格式发送。
字段名称:
Authorization
字段值:
Bearer token
,注意Bearer
和token
之间有一个空格
方式一:在每次请求的时候手动添加(麻烦)。
axios({
method: "",
url: "",
headers: {
Authorization: "Bearer token"
}
})
方式二:使用请求拦截器统一添加(推荐,更方便)。
在 src/utils/request.js
中添加拦截器统一设置 token:
/**
* 请求模块
*/
import axios from 'axios'
import store from '@/store'
const request = axios.create({
baseURL: 'http://ttapi.research.itcast.cn/' // 接口的基准路径
})
// 请求拦截器
// Add a request interceptor
request.interceptors.request.use(function (config) {
// Do something before request is sent
// config :本次请求的配置对象
// config 里面有一个属性:headers
const { user } = store.state
if (user && user.token) {
config.headers.Authorization = `Bearer ${user.token}`
}
return config
}, function (error) {
// Do something with request error
return Promise.reject(error)
})
// 响应拦截器
export default request
1、使用导航栏组件
2、在导航栏组件中插入按钮
3、样式调整
<template>
<div class="home-container">
<van-nav-bar class="page-nav-bar">
<van-button
class="search-btn"
slot="title"
type="info"
size="small"
round
icon="search"
>搜索van-button>
van-nav-bar>
div>
template>
<script>
export default {
name: 'HomeIndex',
components: {},
props: {},
data () {
return {}
},
computed: {},
watch: {},
created () {},
mounted () {},
methods: {}
}
script>
<style scoped lang="less">
.home-container {
.van-nav-bar__title {
max-width: unset;
}
.search-btn {
width: 555px;
height: 64px;
background-color: #5babfb;
border: none;
font-size: 28px;
.van-icon {
font-size: 32px;
}
}
}
style>
参考:Tab 标签页组件
1》基础样式调整
2》处理汉堡按钮
1、使用插槽插入内容
2、样式调整
3、使用伪元素设置渐变边框
.placeholder {
flex-shrink: 0;
width: 66px;
height: 82px;
}
.hamburger-btn {
position: fixed;
right: 0;
display: flex;
justify-content: center;
align-items: center;
width: 66px;
height: 82px;
background-color: #fff;
opacity: 0.902;
i.toutiao {
font-size: 33px;
}
&:before {
content: "";
position: absolute;
left: 0;
width: 1px;
height: 100%;
background-image: url(~@/assets/gradient-gray-line.png);
background-size: contain;
}
}
思路:
1、封装数据请求接口
/**
* 获取用户自己的信息
*/
export const getUserChannels = () => {
return request({
method: 'GET',
url: '/app/v1_0/user/channels'
})
}
你的思路可能是这样的:
1、找到数据接口
2、封装请求方法
3、在组件中请求获取数据,将数据存储到 data 中
4、模板绑定展示
根据不同的频道加载不同的文章列表,你的思路可能是这样的:
list
数组,用来存储文章列表a
频道:请求获取数据,让 list = a
频道文章b
频道:请求获取数据,让 list = b
频道文章c
频道:请求获取数据,让 list = c
频道文章
思路没有问题,但是并不是我们想要的效果。
我们想要的效果是:加载过的数据列表不要重新加载。
实现思路也非常简单,就是我们准备多个 list 数组,每个频道对应一个,查看哪个频道就把数据往哪个频道的列表数组中存放,这样的话就不会导致覆盖问题。
可是有多少频道就得有多少频道文章数组,我们都一个一个声明的话会非常麻烦,所以这里的建议是利用组件来处理。
具体做法就是:
因为文章列表组件中请求获取文章列表数据需要频道 id,所以 频道 id 应该作为 props 参数传递给文章列表组件,为了方便,我们直接把频道对象传递给文章列表组件就可以了。
在文章列表中请求获取对应的列表数据,展示到列表中。
最后把组件在频道列表中遍历出来,就像下面这样。
1、创建 src/views/home/components/article-list.vue
<template>
<div class="article-list">文章列表div>
template>
<script>
export default {
name: 'ArticleList',
components: {},
props: {
channel: {
type: Object,
required: true
}
},
data () {
return {}
},
computed: {},
watch: {},
created () {},
mounted () {},
methods: {}
}
script>
<style scoped lang="less">style>
2、在 home/index.vue
中注册使用
3、最后测试。
答疑:
:lazy-render="false"
来关闭这个效果。List 列表组件:瀑布流滚动加载,用于展示长列表。
List 组件通过 loading 和 finished 两个变量控制加载状态,
当组件初始化或滚动到到底部时,会触发 load 事件并将 loading 设置成 true,此时可以发起异步操作并更新数据,数据更新完毕后,将 loading 设置成 false 即可。
若数据已全部加载完毕,则直接将 finished 设置成 true 即可。
load 事件
:
loading 属性
:控制加载中的 loading 状态
finished 属性
:控制加载结束的状态
<template>
<div class="article-list">
<van-list
v-model="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<van-cell v-for="item in list" :key="item" :title="item" />
van-list>
div>
template>
<script>
export default {
name: 'ArticleList',
components: {},
props: {
channel: {
type: Object,
required: true
}
},
data () {
return {
list: [], // 存储列表数据的数组
loading: false, // 控制加载中 loading 状态
finished: false // 控制数据加载结束的状态
}
},
computed: {},
watch: {},
created () {},
mounted () {},
methods: {
// 初始化或滚动到底部的时候会触发调用 onLoad
onLoad () {
console.log('onLoad')
// 1. 请求获取数据
// setTimeout 仅做示例,真实场景中一般为 ajax 请求
setTimeout(() => {
// 2. 把请求结果数据放到 list 数组中
for (let i = 0; i < 10; i++) {
// 0 + 1 = 1
// 1 + 1 = 2
// 2 + 1 = 3
this.list.push(this.list.length + 1)
}
// 3. 本次数据加载结束之后要把加载状态设置为结束
// loading 关闭以后才能触发下一次的加载更多
this.loading = false
// 4. 判断数据是否全部加载完成
if (this.list.length >= 40) {
// 如果没有数据了,把 finished 设置为 true,之后不再触发加载更多
this.finished = true
}
}, 1000)
}
}
}
script>
<style scoped lang="less">style>
.article-list {
position: fixed;
top: 180px;
bottom: 100px;
right: 0;
left: 0;
overflow-y: auto;
}
实现思路:
1、创建 src/api/article.js
封装获取文章列表数据的接口
/**
* 文章接口模块
*/
import request from '@/utils/request'
/**
* 获取频道的文章列表
*/
export const getArticles = params => {
return request({
method: 'GET',
url: '/app/v1_1/articles',
params
})
}
注意:使用接口文档中最下面的 频道新闻推荐_V1.1
2、然后在首页文章列表组件 onload
的时候请求加载文章列表
<template>
<div class="article-list">
<van-list
v-model="loading"
:finished="finished"
finished-text="没有更多了"
:error.sync="error"
error-text="请求失败,点击重新加载"
@load="onLoad"
>
<van-cell
v-for="(article, index) in list"
:key="index"
:title="article.title"
/>
van-list>
div>
template>
<script>
import { getArticles } from '@/api/article'
export default {
name: 'ArticleList',
components: {},
props: {
channel: {
type: Object,
required: true
}
},
data () {
return {
list: [], // 文章列表数据
loading: false, // 上拉加载更多的 loading 状态
finished: false, // 是否加载结束
error: false, // 是否加载失败
timestamp: null // 请求下一页数据的时间戳
}
},
computed: {},
watch: {},
created () {},
mounted () {},
methods: {
// 当触发上拉加载更多的时候调用该函数
async onLoad () {
try {
// 1. 请求获取数据
const { data } = await getArticles({
channel_id: this.channel.id, // 频道 id
timestamp: this.timestamp || Date.now(), // 时间戳,请求新的推荐数据传当前的时间戳,请求历史推荐传指定的时间戳
with_top: 1 // 是否包含置顶,进入页面第一次请求时要包含置顶文章,1-包含置顶,0-不包含
})
// 2. 把数据添加到 list 数组中
const { results } = data.data
this.list.push(...results)
// 3. 设置本次加载中 loading 状态结束
this.loading = false
// 4. 判断数据是否加载结束
if (results.length) {
// 更新获取下一页数据的时间戳
this.timestamp = data.data.pre_timestamp
} else {
// 没有数据了,设置加载状态结束,不再触发上拉加载更多了
this.finished = true
}
} catch (err) {
console.log(err)
this.loading = false // 关闭 loading 效果
this.error = true // 开启错误提示
}
}
}
}
script>
<style scoped lang="less">style>
最后测试。
这里主要使用到 Vant 中的 PullRefresh 下拉刷新 组件。
思路:
下拉刷新时会触发组件的 refresh
事件,在事件的回调函数中可以进行同步或异步操作,操作完成后将 v-model
设置为 false
,表示加载完成。
// 当触发下拉刷新的时候调用该函数
async onRefresh () {
try {
// 1. 请求获取数据
const { data } = await getArticles({
channel_id: this.channel.id, // 频道 id
timestamp: Date.now(), // 下拉刷新每次都应该获取最新数据
with_top: 1 // 是否包含置顶,进入页面第一次请求时要包含置顶文章,1-包含置顶,0-不包含
})
// 2. 将数据追加到列表的顶部
const { results } = data.data
this.list.unshift(...results)
// 3. 关闭下拉刷新的 loading 状态
this.isRefreshLoading = false
// 提示成功
this.refreshSuccessText = `刷新成功,更新了${results.length}条数据`
} catch (err) {
console.log(err)
this.isRefreshLoading = false // 关闭下拉刷新的 loading 状态
this.$toast('刷新失败')
}
}
在我们项目中有好几个页面中都有这个文章列表项内容,如果我们在每个页面中都写一次的话不仅效率低而且维护起来也麻烦。所以最好的办法就是我们把它封装为一个组件,然后在需要使用的组件中加载使用即可。
1、创建 src/components/article-item/index.vue
组件
<template>
<div class="article-item">文章列表项div>
template>
<script>
export default {
name: 'ArticleItem',
components: {},
props: {
article: {
type: Object,
required: true
}
},
data () {
return {}
},
computed: {},
watch: {},
created () {},
mounted () {},
methods: {}
}
script>
<style scoped lang="less">style>
<template>
<van-cell
class="article-item"
>
<div slot="title" class="title">{{ article.title }}div>
<div slot="label">
<div v-if="article.cover.type === 3" class="cover-wrap">
<div
class="cover-item"
v-for="(img, index) in article.cover.images"
:key="index"
>
<van-image
width="100"
height="100"
:src="img"
/>
div>
div>
<div>
<span>{{ article.aut_name }}span>
<span>{{ article.comm_count }}评论span>
<span>{{ article.pubdate }}span>
div>
div>
<van-image
v-if="article.cover.type === 1"
slot="default"
width="100"
height="100"
:src="article.cover.images[0]"
/>
van-cell>
template>
<script>
export default {
name: 'ArticleItem',
components: {},
props: {
article: {
type: Object,
required: true
}
},
data () {
return {}
},
computed: {},
watch: {},
created () {},
mounted () {},
methods: {}
}
script>
<style scoped lang="less">style>
以下代码仅供参考。
<template>
<van-cell
class="article-item"
>
<div slot="title" class="title van-multi-ellipsis--l2">{{ article.title }}div>
<div slot="label">
<div v-if="article.cover.type === 3" class="cover-wrap">
<div
class="cover-item"
v-for="(img, index) in article.cover.images"
:key="index"
>
<van-image
class="cover-item-img"
fit="cover"
:src="img"
/>
div>
div>
<div class="label-info-wrap">
<span>{{ article.aut_name }}span>
<span>{{ article.comm_count }}评论span>
<span>{{ article.pubdate }}span>
div>
div>
<van-image
v-if="article.cover.type === 1"
slot="default"
class="right-cover"
fit="cover"
:src="article.cover.images[0]"
/>
van-cell>
template>
<script>
export default {
name: 'ArticleItem',
components: {},
props: {
article: {
type: Object,
required: true
}
},
data () {
return {}
},
computed: {},
watch: {},
created () {},
mounted () {},
methods: {}
}
script>
<style scoped lang="less">
.article-item {
.title {
font-size: 32px;
color: #3a3a3a;
}
.van-cell__value {
flex: unset;
width: 232px;
height: 146px;
padding-left: 25px;
}
.right-cover {
width: 232px;
height: 146px;
}
.label-info-wrap span {
font-size: 22px;
color: #b4b4b4;
margin-right: 25px;
}
.cover-wrap {
display: flex;
padding: 30px 0;
.cover-item {
flex: 1;
height: 146px;
&:not(:last-child) {
padding-right: 4px;
}
.cover-item-img {
width: 100%;
height: 146px;
}
}
}
}
style>
1.为什么文章列表数据中的好多图片资源请求失败返回 403?
这是因为我们项目的接口数据是后端通过爬虫抓取的第三方平台内容,而第三方平台对图片资源做了防盗链保护处理。
2.第三方平台怎么处理图片资源保护的?
服务端一般使用 Referer 请求头识别访问来源,然后处理资源访问。
3.Referer 是什么东西?
扩展参考:http://www.ruanyifeng.com/blog/2019/06/http-referer.html
Referer 是 HTTP 请求头的一部分,当浏览器向 Web 服务器发送请求的时候,一般会带上 Referer,它包含了当前请求资源的来源页面的地址。服务端一般使用 Referer 请求头识别访问来源,可能会以此进行统计分析、日志记录以及缓存优化等。
需要注意的是 referer 实际上是 “referrer” 误拼写。参见 HTTP referer on Wikipedia (HTTP referer 在维基百科上的条目)来获取更详细的信息。
怎么解决?
不要发送 referrer ,对方服务端就不知道你从哪来的了,姑且认为是你是自己人吧。
如何设置不发送 referrer?
用 、
、
、
、
或者
元素上的
referrerpolicy
属性为其设置独立的请求策略,例如:
<img src="http://……" referrerPolicy="no-referrer">
或者直接在 HTMl 页面头中通过 meta 属性全局配置:
<meta name="referrer" content="no-referrer" />
推荐两个第三方库:
两者都是专门用于处理时间的 JavaScript 库,功能差不多,因为 Day.js 的设计就是参考的 Moment.js。但是 Day.js 相比 Moment.js 的包体积要更小一些,因为它采用了插件化的处理方式。
Day.js 是一个轻量的处理时间和日期的 JavaScript 库,和 Moment.js 的 API 设计保持完全一样,如果您曾经用过 Moment.js, 那么您已经知道如何使用 Day.js 。
1、安装:npm i dayjs
2、创建 utils/dayjs.js
import Vue from 'vue'
import dayjs from 'dayjs'
// 加载中文语言包
import 'dayjs/locale/zh-cn'
import relativeTime from 'dayjs/plugin/relativeTime'
// 配置使用处理相对时间的插件
dayjs.extend(relativeTime)
// 配置使用中文语言包
dayjs.locale('zh-cn')
// 全局过滤器:处理相对时间
Vue.filter('relativeTime', value => {
return dayjs().to(dayjs(value))
})
3、在 main.js
中加载初始化
import './utils/dayjs'
4、使用
使用过滤器:
<p>{{ 日期数据 | relativeTime }}p>