Vue移动端项目(一)

一、项目初始化

目标

  • 能使用 Vue CLI 创建项目
  • 了解 Vant 组件库的导入方式
  • 掌握制作使用字体图标的方式
  • 掌握如何在 Vue 项目中处理 REM 适配
  • 理解 axios 请求模块的封装

模板项目结构:
Vue移动端项目(一)_第1张图片

使用 Vue CLI 创建项目

如果你还没有安装 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 访问地址。
打开浏览器,输入其中任何一个地址进行访问。

Vue移动端项目(一)_第2张图片
如果能看到该页面,恭喜你,项目创建成功了。

加入 Git 版本管理

几个好处:

  • 代码备份
  • 多人协作
  • 历史记录

1》创建远程仓库

  • GitHub:https://github.com/
  • 码云:https://gitee.com/
  • Coding:https://coding.net/

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、删除:

  • src/views/About.vue
  • src/views/Home.vue
  • src/components/HelloWorld.vue
  • src/assets/logo.png

4、创建以下几个目录

  • src/api 目录
    • 存储接口封装
  • src/utils 目录
    • 存储一些工具模块
  • src/styles 目录
    • index.less 文件,存储全局样式
    • 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/。

一、注册账户
直接选择第三方登录即可
二、创建项目
Vue移动端项目(一)_第3张图片
三、上传图标到项目
上传svg图标:
Vue移动端项目(一)_第4张图片四、生成链接
Vue移动端项目(一)_第5张图片
Vue移动端项目(一)_第6张图片
五、配置到项目中使用

一种方式是将 SVG 图标 包装为 Vue 组件来使用。
一种方式是将 SVG 制作为字体图标来使用:
新建src/styles/icon.less文件,将打开的地址中的代码复制到这里。然后在index.less中加载图标样式:@import "./icon.less";
用法
Vue移动端项目(一)_第7张图片
将素材图片复制到 src/assets 文件夹中:
Vue移动端项目(一)_第8张图片
将 favicon.ico 图标替换到 public文件夹中。

引入 Vant 组件库

Vant 是有赞商城前端开发团队开发的一个基于 Vue.js 的移动端组件库,它提供了非常丰富的移动端功能组件,简单易用。

  • 官方文档
  • GitHub 仓库

下面是在 Vant 官网中列出的一些优点:

  • 60+ 高质量组件
  • 90% 单元测试覆盖率
  • 完善的中英文文档和示例
  • 支持按需引入
  • 支持主题定制
  • 支持国际化
  • 支持 TS
  • 支持 SSR

在我们的项目中主要使用 Vant 作为核心组件库,下面我们根据官方文档将 Vant 导入项目中。

将 Vant 引入项目一共有四种方式:

  • 方式一:自动按需引入组件

    • 和方式二一样,都是按需引入,但是加载更方便一些(需要额外配置 babel-plugin-import 插件)
    • 优点:打包体积小
    • 缺点:每个组件在使用之前都需要手动加载注册
  • 方式二:手动按需引入组件

    • 在不使用插件的情况下,可以手动引入需要的组件
    • 优点:打包体积小
    • 缺点:每个组件在使用之前都需要手动加载注册
  • 方式三:导入所有组件

    • Vant 支持一次性导入所有组件,引入所有组件会增加代码包体积,因此不推荐这种做法
    • 优点:导入一次,使用所有
    • 缺点:打包体积大
  • 方式四:通过 CDN 引入

    • 使用 Vant 最简单的方法是直接在 html 文件中引入 CDN 链接,之后你可以通过全局变量vant访问到所有组件。
    • 优点:适合一些演示、示例项目,一个 html 文件就可以跑起来
    • 缺点:不适合在模块化系统中使用

这里建议为了前期开发的便利性我们选择方式三:导入所有组件,在最后做打包优化的时候根据需求配置按需加载以降低打包体积大小。

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)

Vue移动端项目(一)_第9张图片
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 和 Vue的版本:
Vue移动端项目(一)_第10张图片

移动端 REM 适配

Vant 中的样式默认使用 px 作为单位,如果需要使用 rem 单位,推荐使用以下两个工具:

  • postcss-pxtorem 是一款 postcss 插件,用于将单位转化为 rem。
  • lib-flexible 用于设置 rem 基准值。

下面我们分别将这两个工具配置到项目中完成 REM 适配。

lib-flexible

一、使用 lib-flexible 动态设置 REM 基准值(html 标签的字体大小)

1、安装

# yarn add amfe-flexible
npm i amfe-flexible

2、然后在 main.js 中加载执行该模块

// 加载动态设置REM基准值
import 'amfe-flexible'

最后测试:在浏览器中切换不同的手机设备尺寸,观察 html 标签 font-size 的变化。
Vue移动端项目(一)_第11张图片
例如在 iPhone 6/7/8 Plus 设备下,html 标签字体大小为 41.4 px

postcss-pxtorem

二、使用 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
Vue移动端项目(一)_第12张图片
这是没有配置转换之前的。
Vue移动端项目(一)_第13张图片
这是转换之后的,可以看到 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语法编写的。

1》PostCSS 介绍

PostCSS 是一个处理 CSS 的处理工具,本身功能比较单一,它主要负责解析 CSS 代码,再交由插件来进行处理,它的插件体系非常强大,所能进行的操作是多种多样的,例如:

  • Autoprefixer 插件可以实现自动添加浏览器相关的声明前缀
  • PostCSS Preset Env 插件可以让你使用更新的 CSS 语法特性并实现向下兼容。
  • postcss-pxtorem 可以实现将 px 转换为 rem。

目前 PostCSS 已经有 200 多个功能各异的插件。开发人员也可以根据项目的需要,开发出自己的 PostCSS 插件。

PostCSS 一般不单独使用,而是与已有的构建工具进行集成。

Vue CLI 默认集成了 PostCSS,并且默认开启了 Autoprefixer 插件。
Vue移动端项目(一)_第14张图片

Vue CLI 内部使用了 PostCSS。

你可以通过 .postcssrc 或任何 postcss-load-config 支持的配置源来配置 PostCSS。也可以通过 vue.config.js 中的 css.loaderOptions.postcss 配置 postcss-loader。

Vue CLI 默认开启了 Autoprefixer。如果要配置目标浏览器,可使用 package.json 的 browserslist 字段。

2》Autoprefixer 插件的配置

Vue移动端项目(一)_第15张图片
Autoprefixer 是一个自动添加浏览器前缀的 PostCss 插件,browsers 用来配置兼容的浏览器版本信息,但是写在这里的话会引起编译器警告。
Vue移动端项目(一)_第16张图片
警告意思就是说你应该将 browsers 选项写到 package.json.browserlistrc 文件中。

[Android]
>= 4.0

[iOS]
>= 8

具体语法请参考这里。

3》postcss-pxtorem 插件的配置

Vue移动端项目(一)_第17张图片

  • rootValue:表示根元素字体大小,它会根据根元素大小进行单位转换。
  • propList 用来设定可以从 px 转为 rem 的属性。
    • 例如 * 就是所有属性都要转换,width 就是仅转换 width 属性。

rootValue 应该如何设置呢

如果你使用的是基于 lib-flexable 的 REM 适配方案(把一行分为10份),则应该设置为你的设计稿的十分之一例如:设计稿是 750 宽,则应该设置为 75。
Vue移动端项目(一)_第18张图片大多数设计稿的原型都是以 iphone6 为原型,iphone6 设备的宽是 750,我们的设计稿也是这样。

但是 Vant 建议设置为 37.5,为什么呢

因为 Vant 是基于 375 写的,所以如果你设置为 75 的话,Vant 的样式就小了一半。

所以如果设置为 37.5 的话,Vant 的样式是没有问题的,但是我们在测量设计稿的时候都 必须除2 才能使用,否则就会变得很大。

这样做其实也没有问题,但是有没有更好的办法呢?我就想实现测量多少写多少(不用换算)。于是聪明的你就想,可以不可以这样来做?

  • 如果是 Vant 的样式,就把 rootValue 设置为 37.5 来转换。
  • 如果是我们的样式,就按照 75 的 rootValue 来转换(750/10=75)

通过查阅文档我们可以看到 rootValue 支持两种参数类型:

  • 数字:固定值
  • 函数:动态计算返回
    • postcss-pxtorem 处理每个 CSS 文件的时候都会来调用这个函数。
    • 它会把被处理的 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: ['*']
    }
  }
}
  • file.indexOf('vant') 判断一个字符串里面是否有指定的字符串。(file是一个文件路径的字符串),-1 表示未找到。
    Vue移动端项目(一)_第19张图片

【参数解构】直接在函数的参数里面解构参数对象。
配置完毕,把服务重启一下,最后测试,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 实现表单验证的使用

Vue移动端项目(一)_第20张图片

准备

创建组件并配置路由

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')
}

最后,访问 /login 查看是否能访问到登录页面。
Vue移动端项目(一)_第21张图片

布局结构

这里主要使用到三个 Vant 组件:

  • NavBar 导航栏
  • Form 表单
    • Field 输入框
    • Button 按钮

经验:使用组件库中的现有组件快速布局,再慢慢调整细节,效率更高(刚开始可能会感觉有点麻烦,越用越熟,慢慢的就有了自己的思想)。

布局样式

写样式的原则:将公共样式写到全局(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>

实现基本登录功能

思路

  • 注册点击登录的事件
  • 获取表单数据(根据接口要求使用 v-model 绑定)
  • 表单验证
  • 发请求提交
  • 根据请求结果做下一步处理

一、根据接口要求绑定获取表单数据

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

Vue移动端项目(一)_第22张图片
Token 是用户登录成功之后服务端返回的一个身份令牌,在项目中的多个业务中需要使用到:

  • 访问需要授权的 API 接口
  • 校验页面的访问权限

但是我们只有在第一次用户登录成功之后才能拿到 Token。所以为了能在其它模块中获取到 Token 数据,我们需要把它存储到一个公共的位置,方便随时取用。

往哪儿存?

  • 本地存储
    • 获取麻烦
    • 数据不是响应式
  • Vuex 容器(推荐)
    • 获取方便
    • 响应式的

使用容器存储 Token 的思路:
Vue移动端项目(一)_第23张图片

  • 登录成功,将 Token 存储到 Vuex 容器中
    • 获取方便
    • 响应式
  • 为了持久化,还需要把 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:

  • token:访问令牌,有效期2小时
  • refresh_token:刷新令牌,有效期14天,用于访问令牌过期之后重新获取新的访问令牌

我们的项目接口中设定的 Token 有效期是 2 小时,超过有效期服务端会返回 401 表示 Token 无效或过期了。

为什么过期时间这么短?

  • 为了安全,例如 Token 被别人盗用

过期了怎么办?

  • 让用户重新登录,用户体验太差了
  • 使用 refresh_token 解决 token 过期

如何使用 refresh_token 解决 token 过期?

到课程的后面我们开发的业务功能丰富起来之后,再给大家讲解 Token 过期处理。

大家需要注意的是在学习测试的时候如果收到 401 响应码,请重新登录再测试

Vue移动端项目(一)_第24张图片
概述:服务器生成token的过程中,会有两个时间,一个是token失效时间,一个是token刷新时间,刷新时间肯定比失效时间长,当用户的 token 过期时,你可以拿着过期的token去换取新的token,来保持用户的登陆状态,当然你这个过期token的过期时间必须在刷新时间之内,如果超出了刷新时间,那么返回的依旧是 401。

处理流程

  1. 在axios的拦截器中加入token刷新逻辑
  2. 当用户token过期时,去向服务器请求新的 token
  3. 把旧的token替换为新的token
  4. 然后继续用户当前的请求

在请求的响应拦截器中统一处理 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移动端项目(一)_第25张图片

TabBar 处理

通过分析页面,我们可以看到,首页、问答、视频、我的 都使用的是同一个底部标签栏,我们没必要在每个页面中都写一个,所以为了通用方便,我们可以使用 Vue Router 的嵌套路由来处理。

  • 父路由:一个空页面,包含一个 tabbar,中间留子路由出口
  • 子路由
    • 首页
    • 问答
    • 视频
    • 我的

一、创建 tabbar 组件并配置路由
Vue移动端项目(一)_第26张图片
这里主要使用到的 Vant 组件:

  • Tabbar 标签栏

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>

用户退出

Vue移动端项目(一)_第27张图片
1、给退出按钮注册点击事件

2、退出处理

onLogout () {
  // 退出提示
  // 在组件中需要使用 this.$dialog 来调用弹框组件
  this.$dialog.confirm({
    title: '确认退出吗?'
  }).then(() => {
    // on confirm
    // 确认退出:清除登录状态(容器中的 user + 本地存储中的 user)
    this.$store.commit('setUser', null)
  }).catch(() => {
    // on cancel
    console.log('取消执行这里')
  })
}

最后测试。

展示登录用户信息

Vue移动端项目(一)_第28张图片
步骤

  • 封装接口
  • 请求获取数据
  • 模板绑定

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 才有访问权限。
通过接口文档可以看到,后端接口要求我们将 token 放到请求头 Header 中并以下面的格式发送。
在这里插入图片描述

字段名称:Authorization

字段值:Bearer token,注意 Bearertoken 之间有一个空格

方式一:在每次请求的时候手动添加(麻烦)。

axios({
  method: "",
  url: "",
  headers: {
    Authorization: "Bearer token"
  }
})

方式二:使用请求拦截器统一添加(推荐,更方便)。

发起请求 请求拦截器 服务端 http://xxx 设置 token 请求发出 发起请求 请求拦截器 服务端

Vue移动端项目(一)_第29张图片

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

四、首页—文章列表

Vue移动端项目(一)_第30张图片

页面布局

头部导航栏

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>

频道列表

Vue移动端项目(一)_第31张图片

使用 Tab 标签页组件

参考:Tab 标签页组件

样式调整

1》基础样式调整

  • 标签项
    • 右边框
    • 下边框
    • 宽高
    • 文字大小
    • 文字颜色
  • 底部条
    • 宽高
    • 颜色
    • 位置

2》处理汉堡按钮

1、使用插槽插入内容
2、样式调整

  • 定位
  • 内容居中
  • 宽高
  • 背景色、透明度
  • 字体图标大小

3、使用伪元素设置渐变边框

  • 定位
  • 宽高
  • 背景图
  • 背景图填充模式

4、添加占位符充当内容区域
Vue移动端项目(一)_第32张图片
CSS 样式:

.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. 找数据接口
  2. 把接口封装为请求方法
  3. 在组件中请求获取数据
  4. 模板绑定

1、封装数据请求接口

/**
 * 获取用户自己的信息
 */
export const getUserChannels = () => {
  return request({
    method: 'GET',
    url: '/app/v1_0/user/channels'
  })
}

2、请求获取数据
Vue移动端项目(一)_第33张图片
3、模板绑定
Vue移动端项目(一)_第34张图片

文章列表

Vue移动端项目(一)_第35张图片

思路分析

你的思路可能是这样的:
1、找到数据接口
2、封装请求方法
3、在组件中请求获取数据,将数据存储到 data 中
4、模板绑定展示

根据不同的频道加载不同的文章列表,你的思路可能是这样的:

  • 有一个 list 数组,用来存储文章列表
  • 查看 a 频道:请求获取数据,让 list = a 频道文章
  • 查看 b 频道:请求获取数据,让 list = b 频道文章
  • 查看 c 频道:请求获取数据,让 list = c 频道文章

Vue移动端项目(一)_第36张图片
思路没有问题,但是并不是我们想要的效果。
我们想要的效果是:加载过的数据列表不要重新加载

实现思路也非常简单,就是我们准备多个 list 数组,每个频道对应一个,查看哪个频道就把数据往哪个频道的列表数组中存放,这样的话就不会导致覆盖问题
Vue移动端项目(一)_第37张图片
可是有多少频道就得有多少频道文章数组,我们都一个一个声明的话会非常麻烦,所以这里的建议是利用组件来处理。
具体做法就是

  • 封装一个文章列表组件
  • 然后在频道列表中把文章列表遍历出来

因为文章列表组件中请求获取文章列表数据需要频道 id,所以 频道 id 应该作为 props 参数传递给文章列表组件,为了方便,我们直接把频道对象传递给文章列表组件就可以了。
Vue移动端项目(一)_第38张图片
在文章列表中请求获取对应的列表数据,展示到列表中。
最后把组件在频道列表中遍历出来,就像下面这样。
Vue移动端项目(一)_第39张图片

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 中注册使用
Vue移动端项目(一)_第40张图片
3、最后测试。

答疑

  • 为什么标签内容是懒渲染的?
    • 因为这是 Tab 标签页组件本身支持的默认功能,如果不需要可以通过配置 :lazy-render="false" 来关闭这个效果。

使用 List 列表组件

List 列表组件:瀑布流滚动加载,用于展示长列表。

List 组件通过 loading 和 finished 两个变量控制加载状态,
当组件初始化或滚动到到底部时,会触发 load 事件并将 loading 设置成 true,此时可以发起异步操作并更新数据,数据更新完毕后,将 loading 设置成 false 即可。
若数据已全部加载完毕,则直接将 finished 设置成 true 即可。

  • load 事件
    • List 初始化后会触发一次 load 事件,用于加载第一屏的数据。
    • 如果一次请求加载的数据条数较少,导致列表内容无法铺满当前屏幕,List 会继续触发 load 事件,直到内容铺满屏幕或数据全部加载完成。
  • loading 属性:控制加载中的 loading 状态
    • 非加载中,loading 为 false,此时会根据列表滚动位置判断是否触发 load 事件(列表内容不足一屏幕时,会直接触发)
    • 加载中,loading 为 true,表示正在发送异步请求,此时不会触发 load 事件
  • finished 属性:控制加载结束的状态
    • 在每次请求完毕后,需要手动将 loading 设置为 false,表示本次加载结束
    • 所有数据加载结束,finished 为 true,此时不会触发 load 事件
<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>

2、在文章列表组件中注册使用文章列表项组件
Vue移动端项目(一)_第41张图片

展示列表项内容

  • 使用 Cell 单元格组件
  • 展示标题
  • 展示底部信息
<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>

样式调整

  • 文章标题
    • 字号
    • 颜色
    • 多行文字省略
  • 单图封面
    • 封面容器
      • 去除 flex: 1,固定宽高
      • 左内边距
    • 封面图
      • 宽高
      • 填充模式:cover
  • 底部文本信息
    • 字号
    • 颜色
    • 间距
  • 多图封面
    • 外层容器
      • flex 容器
      • 上下外边距
    • 图片容器
      • 平均分配容器空间:flex: 1;
      • 固定高度
      • 容器项间距
    • 图片
      • 宽高
      • 填充模式

以下代码仅供参考。

<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>

关于第三方图片资源403问题

1.为什么文章列表数据中的好多图片资源请求失败返回 403?

这是因为我们项目的接口数据是后端通过爬虫抓取的第三方平台内容,而第三方平台对图片资源做了防盗链保护处理。

2.第三方平台怎么处理图片资源保护的?
服务端一般使用 Referer 请求头识别访问来源,然后处理资源访问。
Vue移动端项目(一)_第42张图片

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?