1 Star 4 Fork 1

刘龙彬/vue2-toutiao

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
克隆/下载
贡献代码
同步代码
取消
提示: 由于 Git 不支持空文件夾,创建文件夹后会生成空的 .keep 文件
4年前
Loading...
README
MIT

移动端项目 - 《黑马头条》

线上项目演示地址:http://toutiao.liulongbin.top/

Project setup

npm install

Compiles and hot-reloads for development

npm run serve

Compiles and minifies for production

npm run build

Lints and fixes files

npm run lint

Customize configuration

See Configuration Reference.

1. 初始化项目

1.1 创建基本的项目结构

  1. 运行如下的命令:

    vue create toutiao
    
  2. 清空 App.vue 组件中的代码,并删除 components 目录下的 HelloWorld.vue 组件

  3. 清空 /src/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
    
    
  4. 执行 npm run serve 命令,把项目运行起来看效果

  5. 添加 .prettierrc 的配置文件:

    {
      "semi": false,
      "singleQuote": true,
      "trailingComma": "none"
    }
    
  6. .eslintrc.js 配置文件中,添加如下的规则:

    rules: {
      'space-before-function-paren': 0
    }
    

1.2 配置 vant 组件库

官网地址:https://vant-contrib.gitee.io/vant/#/zh-CN/

完整导入:

import Vue from 'vue'

import Vant from 'vant'
import 'vant/lib/index.css'

Vue.use(Vant)

1.3 Vant 组件库的 rem 布局适配

参考文档:https://vant-contrib.gitee.io/vant/#/zh-CN/advanced-usage#rem-bu-ju-gua-pei

1.3.1 配置 postcss-pxtorem

  1. 运行如下的命令:

    npm install postcss-pxtorem -D
    
  2. 在 vue 项目根目录下,创建 postcss 的配置文件 postcss.config.js,并初始化如下的配置:

    module.exports = {
      plugins: {
        'postcss-pxtorem': {
          rootValue: 37.5, // 根节点的 font-size 值
          propList: ['*']  // 要处理的属性列表,* 代表所有属性
        }
      }
    }
    
  3. 关于 px -> rem 的换算:

    iphone6
    
    375px = 10rem
    37.5px = 1rem
    1px = 1/37.5rem
    12px = 12/37.5rem = 0.32rem
    

1.3.2 配置 amfe-flexible

  1. 运行如下的命令:

    npm i amfe-flexible -S
    
  2. 在 main.js 入口文件中导入 amfe-flexible

    import 'amfe-flexible'
    

1.4 配置 axios

  1. 安装:

    npm i axios -S
    
  2. 创建 /src/utils/request.js 模块:

    import axios from 'axios'
    
    const instance = axios.create({
      // 请求根路径
      baseURL: 'http://toutiao-app.itheima.net'
    })
    
    export default instance
    

2. 登录功能

2.1 使用路由渲染登录组件

  1. 创建 /src/views/Login/Login.vue 登录组件:

    <template>
      <div>
        <h1>登录组件</h1>
      </div>
    </template>
    
    <script>
    export default {
      name: 'Login'
    }
    </script>
    
    <style lang="less" scoped>
    </style>
    
  2. 修改路由模块,导入Login.vue 登录组件并声明路由规则:

    import Vue from 'vue'
    import VueRouter from 'vue-router'
    
    // 1. 导入组件
    import Login from '@/views/Login/Login.vue'
    
    Vue.use(VueRouter)
    
    const routes = [
      // 2. 登录组件的路由规则
      { path: '/login', component: Login, name: 'login' }
    ]
    
    const router = new VueRouter({
      routes
    })
    
    export default router
    
  3. App.vue 中声明路由占位符:

    <template>
      <div>
        <!-- 路由占位符 -->
        <router-view></router-view>
      </div>
    </template>
    
    <script>
    export default {
      name: 'App'
    }
    </script>
    
    <style lang="less" scoped>
    </style>
    

2.2 渲染登录组件的头部区域

基于 vant 导航组件下的 NavBar 导航栏组件,渲染 Login.vue 登录组件的头部区域

  1. 渲染登录组件的 header 头部区域:

    <template>
      <div>
        <!-- NavBar 组件:只提供 title 标题 -->
        <van-nav-bar title="黑马头条 - 登录" />
      </div>
    </template>
    
  2. 基于 vant 展示组件下的 Sticy 粘性布局 组件,实现 header 区域的吸顶效果:

    <template>
      <div>
        <van-sticky>
          <van-nav-bar title="黑马头条 - 登录" />
        </van-sticky>
      </div>
    </template>
    

2.3 覆盖 NavBar 组件的默认样式

3 种实现方案:

  1. 定义全局样式表,通过审查元素的方式,找到对应的 class 类名后,进行样式的覆盖操作。
  2. 通过定制主题的方式,直接覆盖 vant 组件库中的 less 变量;
  3. 通过定制主题的方式,自定义 less 主题文件,基于文件的方式覆盖默认的 less 变量;

参考地址:https://vant-contrib.gitee.io/vant/#/zh-CN/theme

方案1:全局样式表

  1. src 目录下新建 index.less 全局样式表,通过审查元素的方式找到对应的 class 类名,进行样式的覆盖:

    // 覆盖 NavBar 组件的默认样式
    .van-nav-bar {
      background-color: #007bff;
      .van-nav-bar__title {
        color: white;
        font-size: 14px;
      }
    }
    
  2. main.js 中导入全局样式表即可:

    // 导入 Vant 和 组件的样式表
    import Vant from 'vant'
    import 'vant/lib/index.css'
    
    // 导入全局样式表
    + import './index.less'
    
    // 注册全局插件
    Vue.use(Vant)
    

方案2:定制主题 - 直接覆盖变量

  1. 修改 main.js 中导入 vant 样式的代码,把 .css 的后缀名改为 .less 后缀名:

    // 导入 Vant 和 组件的样式表
    import Vant from 'vant'
    
    // 这里要把 .css 后缀名改为 .less
    import 'vant/lib/index.less'
    
  2. 在项目根目录下新建 vue.config.js 配置文件:

    module.exports = {
      css: {
        loaderOptions: {
          less: {
            modifyVars: {
              // 直接覆盖变量,注意:变量名之前不需要加 @ 符号
              'nav-bar-background-color': '#007bff',
              'nav-bar-title-text-color': 'white',
              'nav-bar-title-font-size': '14px'
            }
          }
        }
      }
    }
    
    

方案3:定制主题 - 基于 less 文件

  1. 修改 main.js 中导入 vant 样式的代码,把 .css 的后缀名改为 .less 后缀名:

    // 导入 Vant 和 组件的样式表
    import Vant from 'vant'
    // 这里要把 .css 后缀名改为 .less
    import 'vant/lib/index.less'
    
  2. src 目录下新建 cover.less 主题文件,用来覆盖 vant 默认主题中的 less 变量:

    @blue: #007bff;
    @white: white;
    @font-14: 14px;
    
    // NavBar
    @nav-bar-background-color: @blue;
    @nav-bar-title-text-color: @white;
    @nav-bar-title-font-size: @font-14;
    
  3. 在项目根目录下新建 vue.config.js 配置文件:

    const path = require('path')
    
    // 自定义主题的文件路径
    const coverPath = path.join(__dirname, './src/cover.less')
    
    module.exports = {
      css: {
        loaderOptions: {
          less: {
            modifyVars: {
              // 通过 less 文件覆盖(文件路径为绝对路径)
              hack: `true; @import "${coverPath}";`
            }
          }
        }
      }
    }
    

2.4 实现登录功能

  1. 渲染 DOM 结构:

    <!-- 提交表单且验证通过后触发 submit 事件 -->
    <van-form @submit="onSubmit">
      <van-field v-model="formLogin.mobile" type="tel" label="手机号" placeholder="请输入手机号" required :rules="formLoginRules.mobile" />
      <van-field v-model="formLogin.code" type="password" label="密码" placeholder="请输入密码" required :rules="formLoginRules.code" />
      <div style="margin: 16px;">
        <van-button block type="info" native-type="submit">登录</van-button>
      </div>
    </van-form>
    
  2. 声明 data 数据和表单验证规则对象:

    data() {
      return {
        // 登录的表单数据对象
        formLogin: {
          mobile: '13888888888',
          code: '246810'
        },
        // 登录表单的验证规则对象
        formLoginRules: {
          mobile: [
            { required: true, message: '请填写手机号', trigger: 'onBlur' },
            {
              pattern: /^1\d{10}$/,
              message: '请填写正确的手机号',
              trigger: 'onBlur'
            }
          ],
          code: [{ required: true, message: '请填写密码', trigger: 'onBlur' }]
        }
      }
    },
    
  3. src/api/ 目录下,封装 user.js 模块,对外提供登录的 API 方法:

    import axios from '@/utils/request'
    
    // 登录
    export const login = data => {
      return axios.post('/v1_0/authorizations', data)
    }
    
  4. Login.vue 组件中,声明 onSubmit 方法如下:

    import { login } from '@/api/user'
    
    methods: {
      // 组件内自定义的方法
      async onSubmit() {
        const { data: res } = await login(this.formLogin)
        console.log(res)
        if (res.message === 'OK') {
          // 把登录成功的结果,存储到 vuex 中
        }
      }
    }
    

2.5 把 token 存储到 vuex

  1. 在 vuex 模块中声明 state 数据节点:

    export default new Vuex.Store({
      state: {
        // 登录成功之后的 token 信息
        tokenInfo: {}
      }
    })
    
  2. 声明 updateTokenInfo 方法:

    mutations: {
      // 更新 token 的信息
      updateTokenInfo(state, payload) {
        state.tokenInfo = payload
      }
    },
    
  3. Login.vue 组件中,通过 mapMutations 辅助方法,把 updateTokenInfo 方法映射到当前组件中使用:

    // 1. 按需导入辅助方法
    import { mapMutations } from 'vuex'
    
    export default {
      // 2. 映射 mutations 中的方法
      ...mapMutations(['updateTokenInfo']),
    
      // 3. 组件内自定义的方法
      async onSubmit() {
        const { data: res } = await login(this.formLogin)
        console.log(res)
        if (res.message === 'OK') {
          // 4. 更新 state 中的 token 信息
          this.updateTokenInfo(res.data)
          // 5. 跳转到主页
          this.$router.push('/')
        }
      }
    }
    

2.6 持久化存储 state

  1. 定义 initState 对象:

    // 初始的 state 数据
    let initState = {
      // 登录成功之后的 token 信息
      tokenInfo: {}
    }
    
  2. 读取本地存储中的 state 数据:

    // 读取本地存储中的数据
    const stateStr = localStorage.getItem('state')
    
    // 判断是否有数据
    if (stateStr) {
      initState = JSON.parse(stateStr)
    }
    
  3. 为 vuex 中的 state 赋值:

    export default new Vuex.Store({
      state: initState,
      // 省略其它代码...
    })
    

2.7 通过拦截器添加 token 认证

  1. /src/utils/request.js 模块中,声明请求拦截器:

    instance.interceptors.request.use(
      config => {
        return config
      },
      error => {
        return Promise.reject(error)
      }
    )
    
  2. request.js 模块中导入 vuex 的 store 模块:

    import store from '@/store/index'
    
  3. 添加 token 认证信息:

    instance.interceptors.request.use(
      config => {
        // 1. 获取 token 值
        const tokenStr = store.state.tokenInfo.token
        // 2. 判断 token 值是否存在
        if (tokenStr) {
          // 3. 添加身份认证字段
          config.headers.Authorization = 'Bearer ' + tokenStr
        }
        return config
      },
      function(error) {
        return Promise.reject(error)
      }
    )
    

3. 主页布局

3.1 实现 Layout 组件的布局

  1. src/views/Layout 目录下新建 Layout.vue 组件:

    <template>
      <div>
        <!-- 路由占位符 -->
    
        <!-- TabBar 区域 -->
      </div>
    </template>
    
    <script>
    export default {
      name: 'Layout'
    }
    </script>
    
    <style lang="less" scoped>
    </style>
    
  2. 在路由模块中导入 Layout.vue 组件,并声明路由规则:

    import Layout from '@/views/Layout/Layout.vue'
    
    const routes = [
      { path: '/login', component: Login, name: 'login' },
      // Layout 组件的路由规则
      { path: '/', component: Layout }
    ]
    
  3. 渲染 TabBar 区域:

    <!-- TabBar 区域 -->
    <van-tabbar route>
      <van-tabbar-item icon="home-o" to="/">首页</van-tabbar-item>
      <van-tabbar-item icon="user-o" to="/user">我的</van-tabbar-item>
    </van-tabbar>
    

    美化样式:

    .van-tabbar {
      border-top: 1px solid #f8f8f8;
    }
    

3.2 基于路由渲染 Home 和 User 组件

  1. views 目录下分别声明 Home.vueUser.vue 组件:

    • Home.vue 组件的基本结构:

      <template>
        <div class="home-container">
          <!-- 头部区域 -->
          <van-nav-bar fixed>
            <template #left>
              <img src="../../assets/toutiao_logo.png" alt="logo" class="logo" />
            </template>
            <template #right>
              <van-icon name="search" color="white" size="18" />
            </template>
          </van-nav-bar>
        </div>
      </template>
      
      <script>
        export default {
          name: 'Home'
        }
      </script>
      
      <style lang="less" scoped>
      .home-container {
        padding-top: 46px;
      }
        
      .logo {
        height: 80%;
      }
      </style>
      
    • User.vue 组件的基本结构:

      <template>
        <div>
          User 页面
        </div>
      </template>
      
      <script>
      export default {
        name: 'User'
      }
      </script>
      
      <style lang="less" scoped>
      </style>
      
      
  2. 在路由模块中导入 Home 和 User 组件,并声明对应的路由规则:

    import Home from '@/views/Home/Home.vue'
    import User from '@/views/User/User.vue'
    
    const routes = [
      { path: '/login', component: Login, name: 'login' },
      {
        path: '/',
        component: Layout,
        children: [
          // 默认子路由
          { path: '', component: Home, name: 'home' },
          { path: '/user', component: User, name: 'user' }
        ]
      }
    ]
    
  3. Layout.vue 组件中声明路由占位符:

    <!-- 路由占位符 -->
    <router-view></router-view>
    
    <!-- TabBar 区域 -->
    

3.3 获取频道列表的数据

  1. /src/api 目录下新建 home.js 模块:

    import axios from '@/utils/request'
    
    // 获取频道列表
    export const getChannelList = () => {
      return axios.get('/v1_0/user/channels')
    }
    
  2. Home.vue 组件中按需导入 getChannelList 方法:

    // 按需导入获取频道列表数据的 API 方法
    import { getChannelList } from '@/api/home'
    
  3. data 节点中声明 channels 数组,存放频道列表的数据:

    data() {
      return {
        // 频道列表
        channels: []
      }
    }
    
  4. created 生命周期函数中预调用 getChannels 方法,获取频道列表的数据:

    created() {
      this.getChannels()
    }
    
  5. Home.vue 组件的 methods 节点中声明 getChannels 方法如下:

    // 获取频道列表的数据
    async getChannels() {
      const { data: res } = await getChannelList()
      // 判断数据是否请求成功
      if (res.message === 'OK') {
        this.channels = res.data.channels
      }
    }
    

3.4 渲染频道列表结构

基于 Vant 导航组件下的 Tab 标签页组件,渲染出频道列表的基础结构

  1. 渲染频道列表的 DOM 结构:

    <!-- Tab 标签页 -->
    <van-tabs>
      <van-tab v-for="item in channels" :key="item.id" :title="item.name">
        {{item.name}}
      </van-tab>
    </van-tabs>
    
  2. src/views/Home 目录下新建 ArticleList.vue 组件:

    <template>
      <div>
        AritcleList 组件 --- {{id}}
      </div>
    </template>
    
    <script>
    export default {
      name: 'AritcleList',
      props: {
        // 频道 Id
        id: {
          type: Number,
          required: true
        }
      }
    }
    </script>
    
    <style lang="less" scoped>
    </style>
    
  3. Home.vue 组件中导入、注册并使用 ArticleList 组件

    导入:

    import ArticleList from './ArticleList.vue'
    

    注册:

    components: {
      ArticleList
    }
    

    使用:

    <!-- Tab 标签页 -->
    <van-tabs>
      <van-tab v-for="item in channels" :key="item.id" :title="item.name">
        <!-- 文章列表组件 -->
        <article-list :id="item.id"></article-list>
      </van-tab>
    </van-tabs>
    
  4. 定制主题:定制选中项的高亮颜色:

    // cover.less
    
    // Tab 标签页
    @tabs-bottom-bar-color: @blue;
    

3.5 根据频道 Id 获取文章列表数据

  1. @/api 目录下的 home.js 模块中,声明获取文章列表数据的方法:

    // 根据频道 Id 获取文章列表数据
    export const getArticleList = id => {
      return axios.get('/v1_1/articles', {
        params: {
          channel_id: id, // 频道id
          timestamp: Date.now(), // 时间戳整数 单位毫秒
          with_top: 1
        }
      })
    }
    
  2. ArticleList.vue 组件中按需导入获取文章列表数据的方法:

    import { getArticleList } from '@/api/home'
    
  3. ArticleList.vue 组件的 data 节点下声明文章列表的数组:

    data() {
      return {
        // 文章列表的数据
        articles: []
      }
    },
    
  4. created 生命周期函数中预调用 getArticleList 方法:

    created() {
      this.getArticleList()
    },
    
  5. methods 节点下声明 getArticleList 方法如下:

    methods: {
      // 获取文章列表数据
      async getArticleList() {
        const { data: res } = await getArticleList(this.id)
        // 判断数据是否请求成功
        if (res.message === 'OK') {
          this.articles = res.data.results
        }
      }
    }
    

3.6 渲染文章列表的 DOM 结构

  1. 渲染基本的标题和文章信息:

    <template>
      <div>
        <van-cell v-for="item in articles" :key="item.art_id">
          <!-- 标题区域的插槽 -->
          <template #title>
            <div class="title-box">
              <!-- 标题 -->
              <span>{{item.title}}</span>
            </div>
          </template>
          <!-- label 区域的插槽 -->
          <template #label>
            <div class="label-box">
              <span>{{item.aut_name}}&nbsp;&nbsp;{{item.comm_count}}评论&nbsp;&nbsp;{{item.pubdate}}</span>
              <!-- 关闭按钮 -->
              <van-icon name="cross" />
            </div>
          </template>
        </van-cell>
      </div>
    </template>
    

    并美化样式:

    .label-box {
      display: flex;
      justify-content: space-between;
      align-items: center;
    }
    
  2. 根据 item.cover.type 的值,按需渲染1张或3张图片:

    <template>
      <div>
        <van-cell v-for="item in articles" :key="item.art_id">
          <!-- 标题区域的插槽 -->
          <template #title>
            <div class="title-box">
              <!-- 标题 -->
              <span>{{item.title}}</span>
              <!-- 单张图片 -->
              <img :src="item.cover.images[0]" alt="" v-if="item.cover.type === 1" class="thumb">
            </div>
            <!-- 三张图片 -->
            <div class="thumb-box" v-if="item.cover.type > 1">
              <img v-for="(imgsrc, i) in item.cover.images" :key="i" :src="imgsrc" alt="" class="thumb">
            </div>
          </template>
          <!-- label 区域的插槽 -->
          <template #label>
            <div class="label-box">
              <span>{{item.aut_name}}&nbsp;&nbsp;{{item.comm_count}}评论&nbsp;&nbsp;{{item.pubdate}}</span>
              <!-- 关闭按钮 -->
              <van-icon name="cross" />
            </div>
          </template>
        </van-cell>
      </div>
    </template>
    

    并美化图片的样式:

    .thumb {
      // 矩形黄金比例:0.618
      width: 113px;
      height: 70px;
      background-color: #f8f8f8;
      object-fit: cover;
    }
    
    .title-box {
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
    }
    
    .thumb-box {
      display: flex;
      justify-content: space-between;
    }
    

3.7 实现 van-tabs 的吸顶效果

  1. Home.vue 组件中,为 van-tabs 组件添加 sticky 属性,即可开启纵向滚动吸顶效果。
  2. 同时,为 van-tabs 组件添加 offset-top="1.22667rem" 属性,即可控制吸顶时距离顶部的位置。

3.8 实现上拉加载更多

基于 Vant 展示组件下的 List 列表组件,可以轻松实现上拉加载更多的效果。

  1. ArticleList.vue 组件中,使用 van-list 组件把文章对应的 van-cell 组件包裹起来,并提供如下的属性:

    <!-- 上拉加载更多 -->
    <!-- 1. v-model="loading" 用来控制当前是否正在请求下一页的数据; true 表示正在请求中、false 表示当前没有发起任何请求 -->
    <!-- 2. :finished="finished" 用来表示是否还有下一页数据; true 表示没有下一页的数据了;false 表示还有下一页数据 -->
    <!-- 3. finished-text="没有更多了" 用来设置数据加载完毕后,最终的提示 -->
    <!-- 4. @load="onLoad" 用来监听触底的事件,从而发起下一页数据的请求 -->
    <van-list v-model="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
      <van-cell v-for="item in articles" :key="item.art_id">
        <!-- 省略不必要的代码... -->
      </van-cell>
    </van-list>
    
  2. 在 data 中声明如下两个数据项,默认值都为 false:

    data() {
      return {
        // 是否正在加载数据
        loading: false,
        // 数据是否加载完毕
        finished: false
      }
    }
    
  3. 声明 @load 事件的处理函数如下:

    // 触发了上拉加载更多的操作
    onLoad() {
      this.getArticleList()
    },
    
  4. 修改 getArticleList 函数如下:

    // 获取文章列表数据
    async getArticleList(isRefresh) {
      const { data: res } = await getArticleList(this.id)
      if (res.message === 'OK') {
        // 旧数据后面,拼接新数据
        this.articles = [...this.articles, ...res.data.results]
        // 数据加载完之后,需要把 loading 设置为 false,方便下次发起 Ajax 请求
        this.loading = false
    
        // 判断所有数据是否加载完成
        if (res.data.results.length === 0) {
          this.finished = true
        }
      }
    },
    
  5. 注释掉 created 声明周期函数中,请求首屏数据的方法调用。因为 van-list 组件初次被渲染时,会立即触发一次 @load 事件:

    created() {
      // this.getArticleList()
    },
    

3.9 实现下拉刷新

基于 Vant 反馈组件下的 PullRefresh 下拉刷新,可以轻松实现下拉刷新的效果。

  1. ArticleList.vue 组件中,使用 van-pull-refresh 组件把 van-list 列表组件包裹起来,并定义如下两个属性:

    <!-- 下拉刷新 -->
    <!-- v-model="refreshing" 用来控制当前是否正在请求下拉刷新的数据。 true 表示正在请求数据;false 表示没有发起任何请求 -->
    <!-- @refresh="onRefresh" 用来监听下拉刷新的事件,从而发起下拉刷新的数据请求 -->
    <van-pull-refresh v-model="refreshing" @refresh="onRefresh">
      <!-- 上拉加载更多 -->
      <van-list v-model="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
        <van-cell v-for="item in articles" :key="item.art_id">
          <!-- 省略其它代码... -->
        </van-cell>
      </van-list>
    </van-pull-refresh>
    
  2. data 中定义如下的数据节点:

    data() {
      return {
        // 是否正在刷新列表数据
        refreshing: false
      }
    },
    
  3. ArticleList.vue 组件中声明 @refresh 的事件处理函数如下:

    // 触发了下拉刷新
    onRefresh() {
      // true 表示当前以下拉刷新的方式,请求列表的数据
      this.getArticleList(true)
    }
    
  4. 进一步改造 getArticleList 函数如下:

    // 获取文章列表数据
    async getArticleList(isRefresh) {
      const { data: res } = await getArticleList(this.id)
      if (res.message === 'OK') {
        if (isRefresh) {
          // 当前为:下拉刷新
          this.articles = [...res.data.results, ...this.articles] // 新数据在前,旧数据在后
          // 数据加载完成之后,把 refreshing 设置为 false,方便下次发起 Ajax 请求
          this.refreshing = false
        } else {
          // 当前为上拉加载更多
          this.articles = [...this.articles, ...res.data.results] // 旧数据在前,新数据在后
          // 数据加载完之后,需要把 loading 设置为 false,方便下次发起 Ajax 请求
          this.loading = false
        }
    
        // 判断所有数据是否加载完成
        if (res.data.results.length === 0) {
          this.finished = true
        }
      }
    },
    

4. 文章列表

4.1 格式化时间

dayjs 中文官网:https://dayjs.fenxianglu.cn/

  1. 安装 dayjs 包:

    npm install dayjs --save
    
  2. main.js 入口文件中导入 dayjs 相关的模块:

    // 导入 dayjs 的核心模块
    import dayjs from 'dayjs'
    
    // 导入计算相对时间的插件
    import relativeTime from 'dayjs/plugin/relativeTime'
    
    // 导入本地化的语言包
    import zh from 'dayjs/locale/zh-cn'
    
  3. 配置插件和语言包:

    // 配置插件
    dayjs.extend(relativeTime)
    
    // 配置语言包
    dayjs.locale(zh)
    
  4. 定义格式化时间的全局过滤器:

    Vue.filter('dateFormat', dt => {
      return dayjs().to(dt)
    })
    
  5. ArticleList.vue 组件中,使用全局过滤器格式化时间:

    <!-- label 区域的插槽 -->
    <template #label>
      <div class="label-box">
        <span>{{item.aut_name}}&nbsp;&nbsp;{{item.comm_count}}评论&nbsp;&nbsp;{{item.pubdate | dateFormat}}</span>
        <!-- 关闭按钮 -->
        <van-icon name="cross" />
      </div>
    </template>
    

4.2 文章列表图片的懒加载

基于 Vant 展示组件下的 Lazyload 懒加载指令,实现图片的懒加载效果

  1. main.js 入口文件中,按需导入 Lazyload 指令:

    import Vant, { Lazyload } from 'vant'
    
  2. main.js 中将 Lazyload 注册为全局可用的指令:

    Vue.use(Lazyload)
    
  3. ArticleList.vue 组件中,为 <img> 标签删除 src 属性,并应用 v-lazy 指令,指令的值是要展示的图片地址

    <!-- 单张图片 -->
    <img alt="" v-if="item.cover.type === 1" class="thumb" v-lazy="item.cover.images[0]" />
    
    <!-- 三张图片 -->
    <div class="thumb-box" v-if="item.cover.type > 1">
      <img v-for="(imgsrc, i) in item.cover.images" :key="i" alt="" class="thumb" v-lazy="imgsrc" />
    </div>
    

4.3 把文章信息抽离为单个组件

  1. @/views/Home 目录之下新建 ArticleInfo.vue 组件,并声明 DOM 结构:

    <template>
      <van-cell>
        <!-- 标题区域的插槽 -->
        <template #title>
          <div class="title-box">
            <!-- 标题 -->
            <span>{{article.title}}</span>
            <!-- 单张图片 -->
            <img alt="" v-if="article.cover.type === 1" class="thumb" v-lazy="article.cover.images[0]" />
          </div>
          <!-- 三张图片 -->
          <div class="thumb-box" v-if="article.cover.type > 1">
            <img v-for="(imgsrc, i) in article.cover.images" :key="i" alt="" class="thumb" v-lazy="imgsrc" />
          </div>
        </template>
        <!-- label 区域的插槽 -->
        <template #label>
          <div class="label-box">
            <span>{{article.aut_name}}&nbsp;&nbsp;{{article.comm_count}}评论&nbsp;&nbsp;{{article.pubdate | dateFormat}}</span>
            <!-- 关闭按钮 -->
            <van-icon name="cross" />
          </div>
        </template>
      </van-cell>
    </template>
    
  2. 定义 props 属性:

    export default {
      name: 'ArticleInfo',
      props: {
        // 要渲染的文章信息对象
        article: {
          type: Object,
          required: true
        }
      }
    }
    
  3. 美化组件样式:

    .article-info-container {
      border-top: 1px solid #f8f8f8;
    }
    
    .label-box {
      display: flex;
      justify-content: space-between;
      align-items: center;
    }
    
    .thumb {
      width: 113px;
      height: 70px;
      background-color: #f8f8f8;
      object-fit: cover;
    }
    
    .title-box {
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
    }
    
    .thumb-box {
      display: flex;
      justify-content: space-between;
    }
    
  4. ArticleList.vue 组件中导入、注册、并使用 ArticleInfo.vue 组件:

    导入:

    import ArticleInfo from './ArticleInfo.vue'
    

    注册:

    components: {
      ArticleInfo
    }
    

    使用:

    <template>
      <!-- 下拉刷新 -->
      <van-pull-refresh v-model="refreshing" @refresh="onRefresh">
        <!-- 上拉加载更多 -->
        <van-list v-model="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
          <!-- 使用 ArticleInfo 组件 -->
          <article-info v-for="item in articles" :key="item.art_id" :article="item"></article-info>
        </van-list>
      </van-pull-refresh>
    </template>
    

4.4 解决 js 中大数的问题

js 中的安全数字:

> Number.MAX_SAFE_INTEGER
> 9007199254740991

> Number.isSafeInteger(1323819148127502300)
> false

id 的值已经超出了 JavaScript 中最大的 Number 数值,会导致 JS 无法正确的进行数字的处理和运算,例如:

> 1323819148127502300 + 1 === 1323819148127502300 + 2
> true

解决方案:json-biginthttps://www.npmjs.com/package/json-bigint)

  1. 安装依赖包:

    npm i json-bigint -S
    
  2. @/utils/request.js 模块中导入 json-bigint 模块:

    import bigInt from 'json-bigint'
    
  3. 声明处理大数问题的方法:

    // 处理大数问题
    const transBigInt = data => {
      try {
        // 尝试着进行大数的处理
        return bigInt.parse(data)
      } catch {
        // 大数处理失败时的后备方案
        return JSON.parse(data)
      }
    }
    
  4. 在调用 axios.create() 方法期间,指定 transformResponse 选项:

    const instance = axios.create({
      // 请求根路径
      baseURL: 'http://toutiao-app.itheima.net',
      transformResponse: [transBigInt]
    })
    
  5. ArticleList.vue 组件中使用文章Id 时,需要调用 .toString() 方法,把大数对象转为字符串表示的数字

    <article-info v-for="item in articles" :key="item.art_id.toString()" :article="item"></article-info>
    

5. 反馈操作

5.1 展示反馈相关的动作面板

  1. ActionInfo.vue 组件中,为关闭按钮绑定点击事件处理函数:

    <!-- 关闭按钮 -->
    <!-- 通过 .stop 修饰符,阻止事件冒泡 -->
    <van-icon name="cross" @click.stop="onCloseClick" />
    
  2. methods 节点中声明 onCloseClick 事件处理函数如下:

    // 点击了叉号按钮
    onCloseClick() {
      // 展示动作面板
      this.show = true
    },
    
  3. ActionInfo.vue 组件中声明动作面板的 DOM 结构:

    <!-- 动作面板 -->
    <!-- v-model="show" 用来控制动作面板的显示与隐藏 -->
    <!-- cancel-text="取消" 用来定义取消按钮的文本 -->
    <!-- :closeable="false" 用来隐藏右上角的关闭按钮 -->
    <!-- @closed="onSheetClose" 用来监听动作面板关闭之后的事件 -->
    <van-action-sheet v-model="show" cancel-text="取消" :closeable="false" @closed="onSheetClose">
      <!-- 自定义动作面板要渲染的内容 -->
    </van-action-sheet>
    
  4. data 中声明布尔值 show,用来控制动作面板的展示与隐藏:

    data() {
      return {
        // 控制 ActionSheet 的显示与隐藏
        show: false
      }
    }
    

5.2 渲染第一个面板的数据

  1. ArticleInfo.vue 组件的 data 中声明 actions 数组:

    data() {
      return {
        // 第一个面板的可选项列表
        actions: [
          { name: '不感兴趣' },
          { name: '反馈垃圾内容' },
          { name: '拉黑作者' }
        ],
      }
    }
    
  2. 在动作面板中,循环渲染第一个面板的列表项:

    <!-- 动作面板 -->
    <van-action-sheet v-model="show" cancel-text="取消" :closeable="false" @closed="onSheetClose">
      <!-- 展示第一个面板 -->
      <div>
        <van-cell :title="item.name" v-for="(item, i) in actions" :key="i" clickable title-class="center-title" @click="onCellClick(item)" />
      </div>
    </van-action-sheet>
    
  3. style 节点中声明 center-title 美化每一项的样式:

    .center-title {
      text-align: center;
    }
    
  4. methods 中声明 onCellClick 如下:

    // 点击第一层的 item 项
    onCellClick(info) {
      if (info.name === '不感兴趣') {
        console.log('不感兴趣')
        this.show = false
      } else if (info.name === '拉黑作者') {
        console.log('拉黑作者')
        this.show = false
      } else if (info.name === '反馈垃圾内容') {
        // TODO:展示第二个面板的数据
      }
    },
    

5.3 渲染第二个面板的结构

  1. ArticleInfo.vue 组件中,找到动作面板对应的结构,并声明第二个面板对应的 DOM 结构:

        <!-- 动作面板 -->
        <van-action-sheet v-model="show" cancel-text="取消" :closeable="false" @closed="onSheetClose">
          <!-- 展示第一个面板 -->
          <div>
            <van-cell :title="item.name" v-for="(item, i) in actions" :key="i" clickable title-class="center-title" @click="onCellClick(item)" />
          </div>
          <!-- 展示第二个面板 -->
          <div v-else>
            <van-cell title="返回" title-class="center-title" />
          </div>
        </van-action-sheet>
    
  2. 按需控制两个面板的显示与隐藏:

    • 在 data 中声明布尔值 showFirstAction,用来控制第一个面板的显示与隐藏(true:显示;false:隐藏):

      data() {
        return {
          // 是否展示第一个面板
          showFirstAction: true,
        }
      }
      
    • 使用 v-ifv-else 指令,控制两个面板的显示与隐藏:

      <!-- 展示第一个面板 -->
      <div v-if="showFirstAction">
        <van-cell :title="item.name" v-for="(item, i) in actions" :key="i" clickable title-class="center-title" @click="onCellClick(item)" />
      </div>
      <!-- 展示第二个面板 -->
      <div v-else>
        <van-cell title="返回" title-class="center-title" />
      </div>
      
    • 点击反馈垃圾内容显示第二个面板:

      // 点击第一层的 item 项
      onCellClick(info) {
        if (info.name === '不感兴趣') {
          console.log('不感兴趣')
          this.show = false
        } else if (info.name === '拉黑作者') {
          console.log('拉黑作者')
          this.show = false
        } else if (info.name === '反馈垃圾内容') {
          // 将布尔值改为 false,即可显示第二个面板
          this.showFirstAction = false
        }
      },
      
    • 点击返回按钮,显示第一个面板:

      <van-cell title="返回" title-class="center-title" @click="showFirstAction = true" />
      
  3. 在动作面板关闭后,为了方便下次能够直接看到第一个面板,需要把 showFirstAction 的值重置为 true

    // 监听 Sheet 关闭完成后的事件
    onSheetClose() {
      // 下次默认渲染第一个面板
      this.showFirstAction = true
    },
    

5.4 渲染第二个面板的数据

  1. @/api/constant 目录下,新建 reports.js 模块,用来定义第二个面板要用到的常量数据

    // 以模块的方式导出 举报文章 时,后端接口约定的举报类型
    const reports = [
      {
        value: 0,
        label: '其它问题'
      },
      {
        value: 1,
        label: '标题夸张'
      },
      {
        value: 2,
        label: '低俗色情'
      },
      {
        value: 3,
        label: '错别字多'
      },
      {
        value: 4,
        label: '旧闻重复'
      },
      {
        value: 6,
        label: '内容不实'
      },
      {
        value: 8,
        label: '侵权'
      },
      {
        value: 5,
        label: '广告软文'
      },
      {
        value: 7,
        label: '涉嫌违法犯罪'
      }
    ]
    export default reports
    
  2. ArticleInfo.vue 组件中导入,并把常量数据挂载为 data 节点:

    import reports from '@/api/constant/reports'
    
    export default {
      name: 'ArticleInfo',
      data() {
        return {
          // 第二个面板要用到的列表数据
          reports
        }
      }
    }
    
  3. 在第二个面板中循环渲染列表数据,并为每一项绑定点击事件处理函数 onFeedbackCellClick

    <!-- 展示第二个面板 -->
    <div v-else>
      <van-cell title="返回" title-class="center-title" @click="showFirstAction = true" />
      <van-cell :title="item.label" v-for="(item, i) in reports" :key="i" clickable title-class="center-title" @click="onFeedbackCellClick(item)" />
    </div>
    
  4. methods 节点下定义 onFeedbackCellClick 处理函数:

    // 点击了反馈面板中的按钮
    onFeedbackCellClick(info) {
      // 关闭动作面板
      this.show = false
    }
    

5.5 指定动作面板的挂载位置

  1. 默认情况下,我们在 ArticleInfo.vue 组件中使用的 ActionSheet 组件,因此它会被渲染到 List 列表组件 内部

    • 导致的问题:动作面板中的内容上下滑动时,会导致 List 列表组件的 下拉刷新
  2. 解决方案:把 ActionList 组件,通过 get-container 属性,挂载到 body 元素下:

    <van-action-sheet v-model="show" cancel-text="取消" :closeable="false" @closed="onSheetClose" get-container="body">
      <!-- 省略其它代码 -->
    </van-action-sheet>
    

5.6 将文章设为不感兴趣

  1. @/api/home.js 模块中声明如下的 API 方法:

    // 将文章设置为不感兴趣
    export const dislikeArticle = artId => {
      return axios.post('/v1_0/article/dislikes', {
        target: artId
      })
    }
    
  2. ArticleInfo.vue 组件中,按需导入 dislikeArticle 方法:

    import { dislikeArticle } from '@/api/home'
    
  3. onCellClick 方法中调用不感兴趣的 API 接口:

    if (info.name === '不感兴趣') {
      // 调用接口,将此文章设置为不感兴趣
      const { data: res } = await dislikeArticle(
        this.article.art_id.toString()
      )
      // 接口调用成功
      if (res.message === 'OK') {
        // TODO:将此文章从列表中移除
        console.log(this.article.art_id.toString())
      }
      // 关闭动作面板
      this.show = false
    }
    

5.7 从列表中移除不感兴趣的文章

  1. ArticleInfo.vue 组件中,通过 this.$emit() 触发自定义事件,把要删除的文章 Id 传递给父组件:

    // 接口调用成功
    if (res.message === 'OK') {
      // TODO:将此文章从列表中移除
      this.$emit('remove-article', this.article.art_id.toString())
    }
    
  2. ArticleList.vue 组件中,监听 ArticleInfo.vue 组件的 remove-article 自定义事件:

    <article-info v-for="item in articles" :key="item.art_id.toString()" :article="item" @remove-article="onArticleRemove">
    </article-info>
    
  3. ArticleList.vue 组件中,声明 onArticleRemove 函数如下:

    // 触发了删除文章的自定义事件
    onArticleRemove(artId) {
      this.articles = this.articles.filter(x => x.art_id.toString() !== artId)
    }
    

5.8 实现举报文章的功能

  1. @/api/home.js 模块中声明如下的方法:

    // 举报文章
    export const reportArticle = (artId, type) => {
      return axios.post('/v1_0/article/reports', {
        target: artId, // 文章的 Id
        type // 举报的类型
      })
    }
    
  2. ArticleInfo.vue 中按需导入 reportArticle 方法:

    import { dislikeArticle, reportArticle } from '@/api/home'
    
  3. 在点击动作面板中反馈选项的时候,调用接口反馈提交反馈信息:

    <!-- 展示第二个面板 -->
    <div v-else>
      <van-cell title="返回" title-class="center-title" @click="showFirstAction = true" />
      <van-cell :title="item.label" v-for="(item, i) in reports" :key="i" clickable title-class="center-title" @click="onFeedbackCellClick(item)" />
    </div>
    
  4. 声明 onFeedbackCellClick 如下:

    // 点击了反馈面板中的按钮
    async onFeedbackCellClick(info) {
      // 发起请求,反馈文章的问题
      const { data: res } = await reportArticle(
        this.article.art_id.toString(), // 文章的 Id
        info.value // 文章存在的问题编号
      )
      if (res.message === 'OK') {
        // 反馈成功,从列表中移除对应的文章
        this.$emit('remove-article', this.article.art_id.toString())
      }
      
      // 关闭动作面板
      this.show = false
    }
    

6. 频道管理

6.1 渲染频道管理的小图标

  1. Home.vue 组件中,渲染小图标的基本结构:

    <template>
      <div>
        <!-- Tab 标签页 -->
        <van-tabs sticky offset-top="1.22667rem">
          <van-tab v-for="item in channels" :key="item.id" :title="item.name">
            <!-- 文章列表组件 -->
            <article-list :id="item.id"></article-list>
          </van-tab>
        </van-tabs>
    
        <!-- 右侧的编辑频道的小图标 -->
        <van-icon name="plus" size="14" class="moreChannels" />
      </div>
    </template>
    
  2. 美化标签页和小图标的样式:

    // 设置 tabs 容器的样式
    /deep/ .van-tabs__wrap {
      padding-right: 30px;
      background-color: #fff;
    }
    
    // 设置小图标的样式
    .moreChannels {
      position: fixed;
      top: 62px;
      right: 8px;
      z-index: 999;
    }
    

6.2 渲染频道管理的 DOM 结构

  1. 渲染基本的 DOM 结构:

    <!-- 弹出层组件 -->
    <!-- close-on-click-overlay 是否在点击遮罩层后关闭(false 不关闭) -->
    <van-popup v-model="show" :close-on-click-overlay="false">
      <div class="popup-container">
    
        <!-- 弹出层的头部区域 -->
        <van-nav-bar title="频道管理">
          <template #right>
            <van-icon name="cross" size="14" color="white" @click="show = false" />
          </template>
        </van-nav-bar>
    
        <!-- 弹出层的主体区域 -->
        <div class="pop-body">
          <!-- 我的频道 -->
          <div class="my-channel-box">
            <div class="channel-title">
              <span>我的频道:</span>
              <span>编辑</span>
            </div>
            <!-- 我的频道列表 -->
            <van-row type="flex">
              <van-col span="6" v-for="item in channels" :key="item.id">
                <div class="channel-item van-hairline--surround">{{item.name}}</div>
              </van-col>
            </van-row>
          </div>
    
          <!-- 更多频道 -->
          <div class="more-channel-box">
            <div class="channel-title">
              <span>点击添加更多频道:</span>
            </div>
            <!-- 更多频道列表 -->
            <van-row type="flex">
              <van-col span="6" v-for="item in channels" :key="item.id">
                <div class="channel-item van-hairline--surround">{{item.name}}</div>
              </van-col>
            </van-row>
          </div>
        </div>
      </div>
    </van-popup>
    
  2. 美化样式:

    .van-popup,
    .popup-container {
      background-color: transparent;
      height: 100%;
      width: 100%;
    }
    
    .popup-container {
      display: flex;
      flex-direction: column;
    }
    
    .pop-header {
      height: 90px;
      background-color: #007bff;
      color: white;
      text-align: center;
      font-size: 14px;
      position: relative;
      .title {
        width: 100%;
        position: absolute;
        bottom: 15px;
      }
    }
    
    .pop-body {
      flex: 1;
      overflow: scroll;
      padding: 8px;
      background-color: white;
    }
    
    .my-channel-box,
    .more-channel-box {
      .channel-title {
        display: flex;
        justify-content: space-between;
        font-size: 14px;
        line-height: 28px;
        padding: 0 6px;
      }
    }
    
    .channel-item {
      font-size: 12px;
      text-align: center;
      line-height: 36px;
      background-color: #fafafa;
      margin: 5px;
    }
    
  3. 在 data 中声明 show 来控制 popup 组件的显示与隐藏:

    data() {
      return {
       // 控制弹出层组件的显示与隐藏
        show: false
      }
    }
    
  4. 在点击 + 号小图标时展示弹出层:

    <!-- 右侧的编辑频道的小图标 -->
    <van-icon name="plus" size="14" class="moreChannels" @click="show = true" />
    

6.3 动态计算更多频道的列表数据

后台没有提供直接获取更多频道的 API 接口,需要程序员动态地进行计算:

更多频道 = 所有频道 - 我的频道

此时,需要先获取到所有频道地列表数据,再使用计算属性动态地进行筛选即可

  1. 请求所有频道的列表数据:

    • @/api/home.js 模块中封装 getAllChannels 方法:

      // 获取所有频道列表
      export const getAllChannels = () => {
        return axios.get('/v1_0/channels')
      }
      
    • Home.vue 组件中按需导入 getAllChannels 方法:

      import { getChannelList, getAllChannels } from '@/api/home'
      
    • data 中声明 allChannels 的数组,用来存放所有的频道列表数据:

      data() {
        return {
          // 所有频道的列表数据
          allChannels: []
        }
      }
      
    • created 声明周期函数中,预调用 this.getAllChannels() 方法:

      created() {
        this.getChannels()
        // 请求所有的频道列表数据
        this.getAllChannels()
      },
      
    • methods 中定义 getAllChannels 方法如下:

      // 获取所有频道列表的数据
      async getAllChannels() {
        const { data: res } = await getAllChannels()
        if (res.message === 'OK') {
          this.allChannels = res.data.channels
        }
      },
      
  2. Home.vue 组件中声明 moreChannels 的计算属性:

    computed: {
      // 更多频道的数据
      moreChannels() {
        // 1. 对数组进行 filter 过滤,返回的是符合条件的新数组
        return this.allChannels.filter(x => {
          // 判断当前循环项,是否在 “我的频道” 列表之中
          const index = this.channels.findIndex(y => y.id === x.id)
          // 如果不在,则 return true,表示需要把这一项存储到返回的新数组之中
          if (index === -1) return true
        })
      }
    },
    
  3. 修改更多频道列表的数据源:

    <!-- 更多频道列表 -->
    <van-row type="flex">
      <!-- 把数据源从 channels 修改为计算属性 moreChannels -->
      <van-col span="6" v-for="item in moreChannels" :key="item.id">
        <div class="channel-item van-hairline--surround">{{item.name}}</div>
      </van-col>
    </van-row>
    

6.4 渲染删除的徽标

  1. Home.vue 组件的 data 节点下声明布尔值 isEdit,来控制当前是否处于编辑状态:

    data() {
      return {
        // 频道数据是否处于编辑状态
        isEdit: false
      }
    }
    
  2. 为编辑按钮绑定点击事件,动态切换 isEdit 的值和渲染的文本内容:

    <span @click="isEdit = !isEdit">{{isEdit ? '完成' : '编辑'}}</span>
    
  3. 在我的频道中,渲染删除的徽标,并使用 v-if 控制其显示与隐藏:

    <!-- 我的频道列表 -->
    <van-row type="flex">
      <van-col span="6" v-for="item in channels" :key="item.id">
        <!-- 频道的 Item 项 -->
        <div class="channel-item van-hairline--surround">
          {{item.name}}
          <!-- 删除的徽标 -->
          <van-badge color="transparent" class="cross-badge" v-if="isEdit">
            <template #content>
              <van-icon name="cross" class="badge-icon" color="#cfcfcf" size="12" />
            </template>
          </van-badge>
        </div>
      </van-col>
    </van-row>
    
  4. 美化删除徽标的样式:

    .cross-badge {
      position: absolute;
      right: -3px;
      top: 0;
      border: none;
    }
    

6.5 实现删除频道的功能

注意:“推荐”这个频道不允许被删除!

  1. 为频道的 Item 项绑定点击事件处理函数:

    <!-- 频道的 Item 项 -->
    <div class="channel-item van-hairline--surround" @click="removeChannel(item.id)">
    </div>
    
  2. methods 中声明点击事件处理函数:

    // 移除频道
    removeChannel(id) {
      // 如果当前不处于编辑状态,直接 return
      if (!this.isEdit) return
      // 如果当前要删除的 Id 等于 0,则不允许被删除
      if (id === 0) return
      // 对频道列表进行过滤
      this.channels = this.channels.filter(x => x.id !== id)
    }
    
  3. 不为 “推荐” 频道展示 “删除” 的徽标:

    <!-- 删除的徽标 -->
    <van-badge color="transparent" class="cross-badge" v-if="isEdit && item.id !== 0">
      <template #content>
        <van-icon name="cross" class="badge-icon" color="#cfcfcf" size="12" />
      </template>
    </van-badge>
    
  4. 删除完毕之后,需要把最新的频道列表数据保存到后台数据库中:

    • @/api/home.js 中定义更新频道列表数据的 API 方法:

      // 更新我的频道列表
      export const updateMyChannels = data => {
        return axios.put('/v1_0/user/channels', data)
      }
      
    • 修改 Home.vue 组件的 methods 节点中声明更新频道数据的 updateChannels 方法:

      // 更新频道数据
      async updateChannels() {
        // 1. 处理要发送到服务器的 data 数据
        const data = {
          channels: this.channels
            .filter(x => x.id !== 0) // 不需要把 “推荐” 发送给后端
            .map((x, i) => ({
              id: x.id, // 频道的 Id
              seq: i + 1 // 频道的序号(给后端排序用的)
            }))
        }
        // 2. 调用 API 接口,把频道数据存储到后端
        const { data: res } = await updateMyChannels(data)
        if (res.message === 'OK') {
          // 3. 通过 notify 弹框提示用户更新成功
          this.$notify({ type: 'success', message: '更新成功', duration: 1000 })
        }
      },
      
    • removeChannel 方法中调用 updateChannels 方法,更新数据库中的频道数据:

      // 移除频道
      removeChannel(id) {
        // 如果当前不处于编辑状态,直接 return
        if (!this.isEdit) return
        // 如果当前要删除的 Id 等于 0,则不允许被删除
        if (id === 0) return
        // 对频道列表进行过滤
        this.channels = this.channels.filter(x => x.id !== id)
        this.updateChannels()
      },
      

6.6 实现新增频道的功能

  1. 更多频道列表中的频道 Item 项绑定点击事件处理函数:

    <!-- 更多频道列表 -->
    <van-row type="flex">
      <van-col span="6" v-for="item in moreChannels" :key="item.id">
        <div class="channel-item van-hairline--surround" @click="addChannel(item)">{{item.name}}</div>
      </van-col>
    </van-row>
    
  2. methods 中声明 addChannel 如下:

    // 新增频道
    addChannel(channel) {
      // 向前端数据中新增频道信息
      this.channels.push(channel)
      // 把前端数据保存到后台数据库中
      this.updateChannels()
    }
    

6.7 弹出层关闭时重置编辑的状态

  1. 监听弹出层关闭完成后的事件:

    <!-- 监听 closed 事件 -->
    <van-popup v-model="show" :close-on-click-overlay="false" @closed="onPopupClosed">
    </van-popup>
    
  2. 声明 onPopupClosed 方法如下:

    // 监听关闭弹出层且动画结束后触发的事件
    onPopupClosed() {
      this.isEdit = false
    }
    

6.8 实现频道的点击联动效果

  1. Home.vue 组件中声明 activeTabIndex 索引值,用来记录激活的 tab 标签页的索引:

    data() {
      return {
        // 激活的 Tab 标签页索引,默认激活第一项
        activeTabIndex: 0
      }
    }
    
  2. van-tabs 组件通过 v-model 指令双向绑定激活项的索引:

    <!-- Tab 标签页 -->
    <van-tabs sticky offset-top="1.22667rem" v-model="activeTabIndex">
      <van-tab v-for="item in channels" :key="item.id" :title="item.name">
        <!-- 文章列表组件 -->
        <article-list :id="item.id"></article-list>
      </van-tab>
    </van-tabs>
    
  3. 在点击我的频道 Item 项时,把索引值传递到点击事件的处理函数中:

    <!-- 频道的 Item 项 -->
    <div class="channel-item van-hairline--surround" @click="removeChannel(item.id, i)">
    </div>
    
  4. 改造 removeChannel 方法如下:

    // 移除频道
    removeChannel(id, index) {
      // 当前不处于编辑状态
      if (!this.isEdit) {
        // 为 tab 标签页的激活项索引重新赋值
        this.activeTabIndex = index
        // 关闭弹出层
        this.show = false
        return
      }
      
      // 如果当前要删除的 Id 等于 0,则不允许被删除
      if (id === 0) return
      // 对频道列表进行过滤
      this.channels = this.channels.filter(x => x.id !== id)
      this.updateChannels()
    },
    

7. 文章搜索

7.1 基于路由渲染搜索组件

  1. @/views/ 目录下新建 Search 文件夹,并创建 Search.vueSearchResult.vue 组件。

  2. 在路由模块中导入上述的两个组件,并声明对应的路由规则:

    // 导入搜索相关的组件
    import Search from '@/views/Search/Search.vue'
    import SearchResult from '@/views/Search/SearchResult.vue'
    
    const routes = [
      { path: '/login', component: Login, name: 'login' },
      {
        path: '/',
        component: Layout,
        children: [
          { path: '', component: Home, name: 'home' },
          { path: '/user', component: User, name: 'user' }
        ]
      },
      // 搜索组件的路由规则
      { path: '/search', component: Search, name: 'search' },
      // 搜索结果组件的路由规则
      { path: '/search/:kw', component: SearchResult, name: 'search-result' }
    ]
    
  3. Home.vue 组件中,为 NavBar 右侧的搜索图标绑定点击事件处理函数,通过编程式导航跳转到搜索组件页面:

    <van-icon name="search" color="white" size="18" @click="$router.push('/search')" />
    

7.2 渲染搜索页面的 Header 区域

  1. Search.vue 组件中声明如下的 DOM 结构:

    <template>
      <div>
        <div class="search-header">
          <!-- 后退按钮 -->
          <van-icon name="arrow-left" color="white" size="18" class="goback" @click="$router.go(-1)" />
          <!-- 搜索组件 -->
          <van-search v-model.trim="kw" placeholder="请输入搜索关键词" background="#007BFF" shape="round" />
        </div>
      </div>
    </template>
    
  2. data 中声明 kw 关键词:

    data() {
      return {
        // 搜索关键词
        kw: ''
      }
    }
    
  3. 美化样式:

    .search-header {
      height: 46px;
      display: flex;
      align-items: center;
      background-color: #007bff;
      overflow: hidden;
      // 后退按钮
      .goback {
        padding-left: 14px;
      }
      // 搜索组件
      .van-search {
        flex: 1;
      }
    }
    

7.3 实现搜索框自动获得焦点

  1. van-search 组件添加 ref 引用:

    <!-- 搜索组件 -->
    <van-search v-model.trim="kw" placeholder="请输入搜索关键词" background="#007BFF" shape="round" ref="search" />
    
  2. mounted 生命周期函数中,获取组件的引用,并通过 DOM 操作查找到 input 输入框,使其获得焦点:

    mounted() {
      // 如果搜索组件的 ref 引用存在,则获取下面的 input 输入框,使其自动获得焦点
      this.$refs.search && this.$refs.search.querySelector('input').focus()
    }
    

7.4 实现输入框的防抖操作

  1. data 中声明 timerId,用来存储延时器的 Id:

    data() {
      return {
        // 延时器的 Id
        timerId: null
      }
    }
    
  2. 监听搜索组件的 input 输入事件:

    <!-- 搜索组件 -->
    <van-search v-model.trim="kw" placeholder="请输入搜索关键词" background="#007BFF" shape="round" ref="search" @input="onInput" />
    
  3. methods 中声明 onInput 处理函数:

    // 监听文本框的输入事件
    onInput() {
      // 清除延时器
      clearTimeout(this.timerId)
    
      // 判断是否输入了内容
      if (this.kw.length === 0) return
    
      // 创建延时器
      this.timerId = setTimeout(() => {
        console.log(this.kw)
      }, 500)
    }
    

7.5 渲染搜索建议列表数据

  1. @/api/ 目录下新建 search.js 模块:

    import axios from '@/utils/request'
    
    // 获取搜索关键词的列表
    export const getSuggList = kw => {
      return axios.get('/v1_0/suggestion', {
        params: {
          q: kw
        }
      })
    }
    
  2. Search.vue 组件的 data 中声明搜索建议的数组:

    data() {
      return {
        // 建议列表
        suggList: []
      }
    }
    
  3. Search.vue 组件中按需导入 getSuggList 方法:

    import { getKwList } from '@/api/search'
    
  4. 定义 getKeywordsList 方法如下:

    // 请求搜索关键词的列表
    async getKeywordsList() {
      const { data: res } = await getSuggList(this.kw)
      if (res.message === 'OK') {
        this.suggList = res.data.options
      }
    }
    
  5. 修改 onInput 方法:

    // 监听文本框的输入事件
    onInput() {
      // 清除延时器
      clearTimeout(this.timerId)
      // 判断是否输入了内容
      if (this.kw.length === 0) {
        this.suggList = []
        return
      }
      // 创建延时器
      this.timerId = setTimeout(() => {
        // TODO:请求搜索建议的关键词
        this.getKeywordsList()
      }, 500)
    },
    
  6. 基于 v-for 指令循环渲染搜索建议列表:

    <!-- 搜索建议 -->
    <div class="sugg-list">
      <div class="sugg-item" v-for="(item, i) in suggList" :key="i">{{item}}</div>
    </div>
    
  7. 美化样式:

    .sugg-list {
      .sugg-item {
        padding: 0 15px;
        border-bottom: 1px solid #f8f8f8;
        font-size: 14px;
        line-height: 50px;
        // 实现省略号的三行代码
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }
    }
    

7.6 高亮搜索关键词

  1. 把插值表达式改造为 v-html 指令:

    <!-- 搜索建议 -->
    <div class="sugg-list">
      <div class="sugg-item" v-for="(item, i) in suggList" :key="i" v-html="item"></div>
    </div>
    
  2. methods 中声明 hightlightKeywords 方法:

    // 高亮关键词
    hightlightKeywords(arr) {
      // 1. 创建正则实例,其中:
      // 修饰符 i 表示执行对大小写不敏感的匹配
      // 修饰符 g 表示全局匹配(查找所有匹配而非在找到第一个匹配后停止)
      const reg = new RegExp(this.kw, 'ig')
      // 2. 循环数组中的每一项,返回一个处理好的新数组
      return arr.map(x => {
        // 2.1 调用字符串的 .replace(正则, 替换的函数) 方法进行替换
        const result = x.replace(reg, val => {
          // 2.2 return 一个替换的结果
          return `<span style="color: red; font-weight: bold;">${val}</span>`
        })
        // 3. 当前 map 循环需要 return 一个处理的结果
        return result
      })
    }
    
  3. 改造 getKeywordsList 方法:

    // 请求搜索关键词的列表
    async getKeywordsList() {
      const { data: res } = await getSuggList(this.kw)
      if (res.message === 'OK') {
        // this.suggList = res.data.options
    
        // 调用 hightlightKeywords 方法,对关键字进行高亮处理
        this.suggList = this.hightlightKeywords(res.data.options)
      }
    },
    

7.7 渲染搜索历史的 DOM 结构

  1. data 中定义假数据:

    data() {
      return {
        // 搜索历史
        history: ['API', 'java', 'css', '前端', '后台接口', 'python']
      }
    }
    
  2. 渲染搜索历史的 DOM 结构:

    <!-- 搜索历史 -->
    <div class="search-history">
      <!-- 标题 -->
      <van-cell title="搜索历史">
        <!-- 使用 right-icon 插槽来自定义右侧图标 -->
        <template #right-icon>
          <van-icon name="delete" class="search-icon" />
        </template>
      </van-cell>
    
      <!-- 历史列表 -->
      <div class="history-list">
        <span class="history-item" v-for="(tag, i) in history" :key="i">{{tag}}</span>
      </div>
    </div>
    
  3. 美化样式:

    .search-icon {
      font-size: 16px;
      line-height: inherit;
    }
    
    .history-list {
      padding: 0 10px;
      .history-item {
        display: inline-block;
        font-size: 12px;
        padding: 8px 14px;
        background-color: #efefef;
        margin: 10px 8px 0px 8px;
        border-radius: 10px;
      }
    }
    
  4. 根据搜索关键字 kw 的 length 是否为 0,再结合 v-ifv-else 指令,实现搜索建议搜索历史的按需展示:

    <!-- 搜索建议 -->
    <div class="sugg-list" v-if="kw.length !== 0"></div>
    
    <!-- 搜索历史 -->
    <div class="search-history" v-else></div>
    

7.8 存储搜索关键词

  1. 关键词去重
  2. 最新的关键词插入到头部位置
  3. 通过 Set 对象实现数组的去重
  1. 改造 getKeywordsList 方法:

    // 请求搜索关键词的列表
    async getKeywordsList() {
      const { data: res } = await getSuggList(this.kw)
      if (res.message === 'OK') {
        this.suggList = this.hightlightKeywords(res.data.options)
        // 1. 创建一个 Set 对象,用来去重
        const set = new Set([this.kw, ...this.history])
        // 2. 把去重之后的结果,转化成数组,存放到 history 数组中
        this.history = Array.from(set)
      }
    },
    
  2. 定义 watch 侦听器,监视数组的变化,持久化存储到 localStorage 中:

    watch: {
      // 监视 history 数组的变化,持久化存储到本地
      history(newVal) {
        window.localStorage.setItem('searchHistory', JSON.stringify(newVal))
      }
    }
    
  3. data 中初始化 history 数组:

    data() {
      return {
        // 搜索历史
        history: JSON.parse(window.localStorage.getItem('searchHistory') || '[]')
      }
    }
    

7.9 跳转到搜索结果页

  1. 为搜索建议的 item 项绑定 click 点击事件处理函数:

    <!-- 搜索建议 -->
    <div class="sugg-list" v-if="kw.length !== 0">
      <div class="sugg-item" v-for="(item, i) in suggList" :key="i" v-html="item" @click="gotoSearchResult"></div>
    </div>
    
  2. 为历史列表的 item 项绑定 click 点击事件处理函数:

    <!-- 历史列表 -->
    <div class="history-list">
      <span class="history-item" v-for="(tag, i) in history" :key="i" @click="gotoSearchResult(tag)">{{tag}}</span>
    </div>
    
  3. Search.vue 组件中声明如下的 methods 处理函数:

    // 点击搜索结果 Or 搜索历史,跳转到搜索结果页
    gotoSearchResult(e) {
      // 1. 获取到搜索关键字
      const q = typeof e === 'string' ? e : e.target.innerText
    
      // 2. 编程式导航 + 命名路由
      this.$router.push({
        // 2.1 路由名称
        name: 'search-result',
        // 2.2 路由参数
        params: {
          kw: q
        }
      })
    }
    
  4. 渲染搜索结果页面的基本 DOM 结构:

    <template>
      <div class="search-result-container">
        <!-- 点击实现后退效果 -->
        <van-nav-bar title="搜索结果" left-arrow @click-left="$router.go(-1)" fixed />
      </div>
    </template>
    
    <script>
    export default {
      name: 'SearchResult',
      props: ['kw']
    }
    </script>
    
    <style lang="less" scoped>
    .search-result-container {
      padding-top: 46px;
    }
    </style>
    

7.10 实现数据请求和上拉加载更多

  1. @/api/search.js 模块中封装 getSearchResult 方法:

    // 根据关键词查询搜索结果列表的数据
    export const getSearchResult = (q, page) => {
      return axios.get('/v1_0/search', {
        params: {
          q,
          page
        }
      })
    }
    
  2. SearchResult.vue 组件中,通过 van-list 组件实现上拉加载更多的效果:

    <!-- 上拉加载更多 -->
    <van-list v-model="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
      <!-- 文章信息组件 -->
      <article-info v-for="item in searchResult" :key="item.art_id" :article="item"></article-info>
    </van-list>
    
  3. data 中声明对应的数据节点:

    data() {
      return {
        // 页码值
        page: 1,
        // 搜索的结果
        searchResult: [],
        // 是否正在请求数据
        loading: false,
        // 数据是否已经加载完毕
        finished: false
      }
    }
    
  4. 导入接口和 ArticleInfo.vue 组件:

    // 导入 API 接口
    import { getSearchResult } from '@/api/search'
    // 导入组件
    import ArticleInfo from '@/views/Home/ArticleInfo.vue'
    
    // 注册组件
    components: {
      ArticleInfo
    }
    
  5. 在 methods 中声明 onLoad 处理函数如下:

    // 加载数据
    async onLoad() {
      // 请求数据
      const { data: res } = await getSearchResult(this.kw, this.page)
      // 判断是否请求成功
      if (res.message === 'OK') {
        // 拼接数据
        this.searchResult = [...this.searchResult, ...res.data.results]
        // 重置加载状态
        this.loading = false
        // 页码值 + 1
        this.page += 1
        // 判断数据是否加载完毕
        if (res.data.results.length === 0) {
          this.finished = true
        }
      }
    }
    

7.11 自定义关闭按钮的显示与隐藏

  1. ArticleInfo.vue 组件中,新增名为 closable 的 props 节点:

    props: {
      // 是否展示关闭按钮
      closable: {
        type: Boolean,
        // 默认值为 true,表示展示关闭按钮
        default: true
      }
    }
    
  2. 使用 v-if 动态控制关闭按钮的展示与隐藏:

    <!-- 关闭按钮 -->
    <van-icon name="cross" @click.stop="onCloseClick" v-if="closable" />
    
  3. SearchResult.vue 组件中使用 ArticleInfo.vue 组件时,不展示关闭按钮:

    <!-- 上拉加载更多 -->
    <van-list v-model="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
      <!-- 文章信息组件 -->
      <article-info v-for="item in searchResult" :key="item.art_id" :article="item" :closable="false"></article-info>
    </van-list>
    

8. 文章详情

8.1 通过路由渲染详情页组件

  1. @/views/ArticleDetail 目录下,新建 ArticleDetail.vue 组件:

    <template>
      <div>
        文章详情
      </div>
    </template>
    
    <script>
    export default {
      name: 'ArticleDetail',
      props: {
        // 文章Id
        artId: {
          type: [String, Number],
          required: true
        }
      }
    }
    </script>
    
    <style lang="less" scoped>
    </style>
    
  2. @/router/index.js 路由模块中,声明详情页的路由规则:

    // 导入文章详情页
    import ArticleDetail from '@/views/ArticleDetail/ArticleDetail.vue'
    
    const routes = [
      // 省略其它代码...
    
      // 文章详情页的路由规则
      {
        path: '/article/:artId',
        component: ArticleDetail,
        name: 'article-detail',
        props: true // 开启路由的 props 传参
      }
    ]
    
  3. 在文章列表页面和搜索结果页面,为 <article-info> 组件绑定 click 事件处理函数,通过编程式导航跳转到文章详情页:

    <article-info v-for="item in articles" :key="item.art_id.toString()" :article="item" @remove-article="onArticleRemove" @click="gotoDetail(item.art_id.toString())"></article-info>
    
    methods: {
      // 跳转到文章详情页
      gotoDetail(artId) {
        // 编程式导航 + 命名路由
        this.$router.push({
          name: 'article-detail',
          params: { // 导航参数
            artId
          }
        })
      }
    }
    
  4. ArticleInfo.vue 组件中,为最外层包裹性质的容器绑定 click 事件处理函数,通过 $emit() 触发自定义的 click 事件:

    <div @click="$emit('click')">
      <van-cell>
        <!-- 省略其它代码... -->
      </van-cell>
    </div>
    

8.2 渲染文章详情页的基本结构

  1. 声明如下的 DOM 结构:

    <template>
      <div>
        <!-- Header 区域 -->
        <van-nav-bar fixed title="文章详情" left-arrow @click-left="$router.back()" />
    
        <!-- 文章信息区域 -->
        <div class="article-container">
          <!-- 文章标题 -->
          <h1 class="art-title">小程序</h1>
    
          <!-- 用户信息 -->
          <van-cell center title="张三" label="3天前">
            <template #icon>
              <img src="" alt="" class="avatar">
            </template>
            <template #default>
              <div>
                <van-button type="info" size="mini">已关注</van-button>
                <van-button icon="plus" type="info" size="mini" plain>关注</van-button>
              </div>
            </template>
          </van-cell>
    
          <!-- 分割线 -->
          <van-divider></van-divider>
    
          <!-- 文章内容 -->
          <div class="art-content">好好学习, 天天向上</div>
    
          <!-- 分割线 -->
          <van-divider>End</van-divider>
    
          <!-- 点赞 -->
          <div class="like-box">
            <van-button icon="good-job" type="danger" size="small">已点赞</van-button>
            <van-button icon="good-job-o" type="danger" plain size="small">点赞</van-button>
          </div>
        </div>
      </div>
    </template>
    
  2. 美化样式:

    .article-container {
      padding: 10px;
      margin-top: 46px;
    }
    .art-title {
      font-size: 16px;
      font-weight: bold;
      margin: 10px 0;
    }
    
    .art-content {
      font-size: 12px;
      line-height: 24px;
      width: 100%;
      overflow-x: scroll;
      word-break: break-all;
    }
    
    .van-cell {
      padding: 5px 0;
      &::after {
        display: none;
      }
    }
    
    .avatar {
      width: 60px;
      height: 60px;
      border-radius: 50%;
      background-color: #f8f8f8;
      margin-right: 5px;
      border: none;
    }
    
    .like-box {
      display: flex;
      justify-content: center;
    

}




### 8.3 请求并渲染文章详情

1. 在 `@/api` 目录下新建 `article.js` 模块:

```js
import axios from '@/utils/request'

// 获取文章详情数据
export const getArticleInfo = artId => {
  return axios.get(`/v1_0/articles/${artId}`)
}
  1. ArticleDetail.vue 组件中发起数据请求:

    // 1. 按需导入 API 接口
    import { getArticleInfo } from '@/api/article'
    
    export default {
      name: 'ArticleDetail',
      props: {
        // 文章Id
        artId: {
          type: [String, Number],
          required: true
        }
      },
      data() {
        return {
          // 2. 定义文章详情的数据
          article: {}
        }
      },
      // 4. 页面初次加载时,请求详情数据
      created() {
        this.getArticleDetail()
      },
      methods: {
        // 3. 声明获取文章数据的方法
        async getArticleDetail() {
          const { data: res } = await getArticleInfo(this.artId)
          if (res.message === 'OK') {
            console.log(res)
            this.article = res.data
          }
        }
      }
    }
    
  2. 渲染文章详情的数据:

    <template>
      <div>
        <!-- Header 区域 -->
        <van-nav-bar title="文章详情" left-arrow @click-left="$router.back()" />
    
        <!-- 文章信息区域 -->
        <div class="article-container">
          <!-- 文章标题 -->
          <h1 class="art-title">{{article.title}}</h1>
    
          <!-- 用户信息 -->
          <van-cell center :title="article.aut_name" :label="article.pubdate | dateFormat">
            <template #icon>
              <img :src="article.aut_photo" alt="" class="avatar">
            </template>
            <template #default>
              <div>
                <van-button type="info" size="mini" v-if="article.is_followed">已关注</van-button>
                <van-button icon="plus" type="info" size="mini" plain v-else>关注</van-button>
              </div>
            </template>
          </van-cell>
    
          <!-- 分割线 -->
          <van-divider></van-divider>
    
          <!-- 文章内容 -->
          <div class="art-content" v-html="article.content"></div>
    
          <!-- 分割线 -->
          <van-divider>End</van-divider>
    
          <!-- 点赞 -->
          <div class="like-box">
            <van-button icon="good-job" type="danger" size="small" v-if="article.attitude === 1">已点赞</van-button>
            <van-button icon="good-job-o" type="danger" plain size="small" v-else>点赞</van-button>
          </div>
        </div>
      </div>
    </template>
    

8.4 实现关注和取消关注的功能

  1. @/api/article.js 模块中声明如下两个接口:

    // 关注用户
    export const followUser = uid => {
      return axios.post('/v1_0/user/followings', {
        target: uid
      })
    }
    
    // 取消关注用户
    export const unfollowUser = uid => {
      return axios.delete(`/v1_0/user/followings/${uid}`)
    }
    
  2. @/utils/request.js 模块中,修改 transBigInt 函数如下:

    // 处理大数问题
    const transBigInt = data => {
      // 如果接口请求成功后没有响应任何数据,则直接返回空字符串
      if (!data) return ''
      try {
        return bigInt.parse(data)
      } catch {
        return JSON.parse(data)
      }
    }
    
  3. ArticleDetail.vue 组件中,为关注按钮绑定点击事件处理函数:

    <van-button type="info" size="mini" v-if="article.is_followed" @click="setFollow(false)">已关注</van-button>
    <van-button icon="plus" type="info" size="mini" plain v-else @click="setFollow(true)">关注</van-button>
    
  4. 按需导入相关的 API 方法,并定义 setFollow 方法如下:

    // 1. 按需导入 API 接口
    import { getArticleInfo, followUser, unfollowUser } from '@/api/article'
    
    // 修改关注状态
    async setFollow(followState) {
      if (followState) {
        // 调用关注的接口
        await followUser(this.article.aut_id.toString())
        this.$toast.success('关注成功!')
      } else {
        // 调用取消关注的接口
        await unfollowUser(this.article.aut_id.toString())
        this.$toast.success('取消关注成功!')
      }
    
      // 修改文章的状态
      this.article.is_followed = followState
    }
    

8.5 实现点赞和取消点赞的功能

  1. @/api/article.js 模块中定义如下的方法:

    /**
     * 点赞
     * @param {*} id 文章Id
     * @returns
     */
    export const addLike = id => {
      return axios.post('/v1_0/article/likings', {
        target: id
      })
    }
    
    /**
     * 取消点赞
     * @param {*} id 文章Id
     * @returns
     */
    export const delLike = id => {
      return axios.delete(`/v1_0/article/likings/${id}`)
    }
    
  2. ArticleDetail.vue 中,为点赞按钮绑定点击事件处理函数:

    <van-button icon="good-job" type="danger" size="small" v-if="article.attitude === 1" @click="setLike(false)">已点赞</van-button>
    <van-button icon="good-job-o" type="danger" plain size="small" v-else @click="setLike(true)">点赞</van-button>
    
  3. 导入对应的 API 方法,并声明 setLike 方法如下:

    // 1. 按需导入 API 接口
    import {
      getArticleInfo,
      followUser,
      unfollowUser,
      addLike,
      delLike
    } from '@/api/article'
    
    // 修改点赞状态
    async setLike(likeState) {
      if (likeState) {
        // 调用点赞的接口
        await addLike(this.article.art_id.toString())
        this.$toast.success('点赞成功!')
      } else {
        // 调用取消点赞的接口
        await delLike(this.article.art_id.toString())
        this.$toast.success('取消点赞成功!')
      }
    
      this.article.attitude = likeState ? 1 : -1
    }
    

9. 文章评论

9.1 渲染评论组件的基本结构

  1. @/views/ArticleDetail 目录下新建 ArtCmt.vue 组件:

    <template>
      <div>
        <!-- 评论列表 -->
        <div class="cmt-list">
          <!-- 评论的 Item 项 -->
          <div class="cmt-item">
            <!-- 头部区域 -->
            <div class="cmt-header">
              <!-- 头部左侧 -->
              <div class="cmt-header-left">
                <img src="" alt="" class="avatar">
                <span class="uname">zs</span>
              </div>
              <!-- 头部右侧 -->
              <div class="cmt-header-right">
                <van-icon name="like" size="16" color="red" />
                <van-icon name="like-o" size="16" color="gray" />
              </div>
            </div>
            <!-- 主体区域 -->
            <div class="cmt-body">
              基于字体的图标集,可以通过 Icon 组件使用,也可以在其他组件中通过 icon 属性引用。基于字体的图标集,可以通过 Icon 组件使用,也可以在其他组件中通过 icon 属性引用。
            </div>
            <!-- 尾部区域 -->
            <div class="cmt-footer">3天前</div>
          </div>
        </div>
      </div>
    </template>
    
  2. 美化样式:

    .cmt-list {
      padding: 10px;
      .cmt-item {
        padding: 15px 0;
        + .cmt-item {
          border-top: 1px solid #f8f8f8;
        }
        .cmt-header {
          display: flex;
          align-items: center;
          justify-content: space-between;
          .cmt-header-left {
            display: flex;
            align-items: center;
            .avatar {
              width: 40px;
              height: 40px;
              background-color: #f8f8f8;
              border-radius: 50%;
              margin-right: 15px;
            }
            .uname {
              font-size: 12px;
            }
          }
        }
        .cmt-body {
          font-size: 14px;
          line-height: 28px;
          text-indent: 2em;
          margin-top: 6px;
          word-break: break-all;
        }
        .cmt-footer {
          font-size: 12px;
          color: gray;
          margin-top: 10px;
        }
      }
    }
    
  3. ArticleDetal.vue 组件中导入并使用 ArtCmt.vue 组件:

    // 导入组件
    import ArtCmt from './ArtCmt.vue'
    
    // 注册组件
    components: {
      ArtCmt
    }
    
    <!-- 使用文章评论组件 -->
    <art-cmt></art-cmt>
    

9.2 请求并渲染评论列表的数据

带有评论的文章链接地址:http://localhost:8080/#/article/1323570687952027648

  1. @/api/article.js 中定义获取评论数据的接口:

    // 获取文章的评论列表
    export const getCmtList = (artId, offset) => {
      return axios.get('/v1_0/comments', {
        params: {
          // a表示对文章的评论 ,c表示对评论的回复
          type: 'a',
          // 文章的 Id
          source: artId,
          //  获取评论数据的偏移量,值为评论id,表示从此id的数据向后取,不传表示从第一页开始读取数据
          offset
        }
      })
    }
    
  2. ArtCmt.vue 组件中声明文章 Id 的 props:

    props: {
      // 文章的 Id
      artId: {
        type: [String, Number],
        required: true
      }
    },
    
  3. ArticleDetail.vue 组件中使用 <art-cmt> 组件时,把文章 Id 传递到评论组件中:

    <!-- 文章评论组件 -->
    <art-cmt :artId="article.art_id.toString()" v-if="article.art_id"></art-cmt>
    
  4. ArtCmt.vue 组件的 data 中声明如下的数据:

    data() {
      return {
        // 偏移量
        offset: null,
        // 是否正在加载数据
        loading: false,
        // 数据是否加载完毕了
        finished: false,
        // 评论列表的数据
        cmtlist: []
      }
    },
    
  5. class="cmt-list" 的 div 替换为 <van-list> 组件:

    <!-- 评论列表 -->
    <van-list v-model="loading" :finished="finished" finished-text="没有更多了" @load="onLoad" class="cmt-list">
    </van-list>
    
  6. 定义 onLoad 函数如下:

    methods: {
      // 触发了加载数据的事件
      async onLoad() {
        const { data: res } = await getCmtList(this.artId, this.offset)
        if (res.message === 'OK') {
          // 为偏移量赋值
          this.offset = res.data.last_id
          // 为评论列表数据赋值
          this.cmtlist = [...this.cmtlist, ...res.data.results]
    
          // 重置 loading 和 finished
          this.loading = false
          if (res.data.results.length === 0) {
            this.finished = true
          }
        }
      }
    }
    

9.3 实现评论的点赞和取消点赞的功能

  1. @/api/article.js 模块中定义如下两个 API 接口:

    // 评论点赞
    export const addCmtLike = cmtId => {
      return axios.post('/v1_0/comment/likings', {
        target: cmtId
      })
    }
    
    // 评论取消点赞
    export const removeCmtLike = cmtId => {
      return axios.delete(`/v1_0/comment/likings/${cmtId}`)
    }
    
  2. 为点赞和取消点赞的图片绑定点击事件处理函数:

    <van-icon name="like" size="16" color="red" v-if="item.is_liking" @click="setLike(false, item)" />
    <van-icon name="like-o" size="16" color="gray" v-else @click="setLike(true, item)" />
    
  3. 按需导入评论点赞的 API 方法:

    import { getCmtList, addCmtLike, removeCmtLike } from '@/api/article'
    
  4. methods 中声明 setLike 方法如下:

    // 切换评论的点赞与取消点赞
    async setLike(likeState, cmt) {
      // 获取评论的 Id
      const cmtId = cmt.com_id.toString()
      if (likeState) {
        // 点赞
        await addCmtLike(cmtId)
        this.$toast.success('点赞成功!')
      } else {
        // 取消点赞
        await removeCmtLike(cmtId)
        this.$toast.success('取消点赞成功!')
      }
    
      // 切换当前评论的点赞状态
      cmt.is_liking = likeState
    }
    

9.4 渲染发布评论的基本结构

  1. 渲染基本的 DOM 结构:

    <!-- 底部添加评论区域 - 1 -->
    <div class="add-cmt-box van-hairline--top">
      <van-icon name="arrow-left" size="18" @click="$router.back()" />
      <div class="ipt-cmt-div">发表评论</div>
      <div class="icon-box">
        <van-badge :content="cmtCount ? cmtCount : ''" :max="99">
          <van-icon name="comment-o" size="20" />
        </van-badge>
        <van-icon name="star-o" size="20" />
        <van-icon name="share-o" size="20" />
      </div>
    </div>
    
    <!-- 底部添加评论区域 - 2 -->
    <div class="cmt-box van-hairline--top">
      <textarea placeholder="友善评论、理性发言、阳光心灵"></textarea>
      <van-button type="default" disabled>发布</van-button>
    </div>
    
  2. data 中定义 cmtCount 的值:

    data() {
      return {
        // 评论数量
        cmtCount: 0
      }
    },
    
  3. 在请求数据的方法中,为 cmtCount 赋值:

    // 触发了加载数据的事件
    async onLoad() {
      const { data: res } = await getCmtList(this.artId, this.offset)
      console.log(res)
      if (res.message === 'OK') {
        this.offset = res.data.last_id
        // 为评论数量赋值
        this.cmtCount = res.data.total_count
        this.cmtlist = [...this.cmtlist, ...res.data.results]
    
        this.loading = false
        if (res.data.results.length === 0) {
          this.finished = true
        }
      }
    },
    
  4. 美化样式:

    // 外层容器
    .art-cmt-container-1 {
      padding-bottom: 46px;
    }
    .art-cmt-container-2 {
      padding-bottom: 80px;
    }
    
    // 发布评论的盒子 - 1
    .add-cmt-box {
      position: fixed;
      bottom: 0;
      left: 0;
      width: 100%;
      box-sizing: border-box;
      background-color: white;
      display: flex;
      justify-content: space-between;
      align-items: center;
      height: 46px;
      line-height: 46px;
      padding-left: 10px;
      .ipt-cmt-div {
        flex: 1;
        border: 1px solid #efefef;
        border-radius: 15px;
        height: 30px;
        font-size: 12px;
        line-height: 30px;
        padding-left: 15px;
        margin-left: 10px;
        background-color: #f8f8f8;
      }
      .icon-box {
        width: 40%;
        display: flex;
        justify-content: space-evenly;
        line-height: 0;
      }
    }
    
    .child {
      width: 20px;
      height: 20px;
      background: #f2f3f5;
    }
    
    // 发布评论的盒子 - 2
    .cmt-box {
      position: fixed;
      bottom: 0;
      left: 0;
      width: 100%;
      height: 80px;
      display: flex;
      justify-content: space-between;
      align-items: center;
      font-size: 12px;
      padding-left: 10px;
      box-sizing: border-box;
      background-color: white;
      textarea {
        flex: 1;
        height: 66%;
        border: 1px solid #efefef;
        background-color: #f8f8f8;
        resize: none;
        border-radius: 6px;
        padding: 5px;
      }
      .van-button {
        height: 100%;
        border: none;
      }
    }
    

9.5 实现 textarea 的按需展示

  1. data 中定义布尔值 isShowCmtInput 用来控制 textarea 的显示与隐藏:

    data() {
      return {
        // 是否展示评论的输入框
        isShowCmtInput: false
      }
    }
    
  2. 使用 v-if 和 v-else 指令控制两个区域的按需展示:

    <!-- 底部添加评论区域 - 1 -->
    <div class="add-cmt-box van-hairline--top" v-if="!isShowCmtInput"></div>
    
    <!-- 底部添加评论区域 - 2 -->
    <div class="cmt-box van-hairline--top" v-else></div>
    
  3. 点击发表评论的 div,展示 textarea 所在的盒子:

    <div class="ipt-cmt-div" @click="showTextarea">发表评论</div>
    
    // 点击发表评论的 div, 展示 textarea 所在的盒子
    showTextarea() {
      this.isShowCmtInput = true
      // 让文本框自动获得焦点
      this.$nextTick(() => {
        this.$refs.cmtIpt.focus()
      })
    },
    
  4. 在 textarea 失去焦点时,重置布尔值为 false:

    <textarea placeholder="友善评论、理性发言、阳光心灵" ref="cmtIpt" @blur="onCmtIptBlur"></textarea>
    
    // 文本框失去焦点
    onCmtIptBlur() {
      this.isShowCmtInput = false
    }
    
  5. 动态控制 ArtCmt.vue 组件外层容器底部的 padding 距离:

    <div :class="isShowCmtInput ? 'art-cmt-container-2' : 'art-cmt-container-1'"></div>
    

9.6 点击评论按钮平滑滚动到评论列表

  1. 为评论的小图标绑定 click 点击事件处理函数:

    <!-- 评论的小图标 -->
    <van-badge :content="cmtCount ? cmtCount : ''" :max="99">
      <van-icon name="comment-o" size="20" @click="scrollToCmtList" />
    </van-badge>
    
  2. methods 中声明 scrollToCmtList 函数如下:

    // 实现滚动条平滑滚动的方法
    scrollToCmtList() {
      // 1.1 返回文档在垂直方向已滚动的像素值
      const now = window.scrollY
      // 1.2 目标位置(文章信息区域的高度)
      let dist = document.querySelector('.article-container').offsetHeight
      // 1.3 可滚动高度 = 整个文档的高度 - 浏览器窗口的视口(viewport)高度
      const avalibleHeight = document.documentElement.scrollHeight - window.innerHeight
    
      // 2.1 如果【目标高度】 大于 【可滚动的高度】
      if (dist > avalibleHeight) {
        // 2.2 就把目标高度,设置为可滚动的高度
        dist = avalibleHeight
      }
    
      // 3. 动态计算出步长值
      const step = (dist - now) / 10
    
      setTimeout(() => {
        // 4.2 如果当前的滚动的距离不大于 1px,则直接滚动到目标位置,并退出递归
        if (Math.abs(step) <= 1) {
          return window.scrollTo(0, dist)
        }
        // 4.1 每隔 10ms 执行一次滚动,并递归地进行下一次的滚动
        window.scrollTo(0, now + step)
        this.scrollToCmtList()
      }, 10)
    }
    

9.7 发布评论

  1. @/api/article.js 模块中定义如下的 API 方法:

    // 对文章发表评论
    export const pubComment = (artId, content) => {
      return axios.post('/v1_0/comments', {
        target: artId, // 文章的 id
        content // 评论的内容
      })
    }
    
  2. ArtCmt.vue 组件中按需导入 API 方法:

    import {
      getCmtList,
      addCmtLike,
      removeCmtLike,
      pubComment
    } from '@/api/article'
    
  3. 为输入框添加 v-model.trim 的双向数据绑定:

    <textarea placeholder="友善评论、理性发言、阳光心灵" ref="cmtIpt" v-model.trim="cmt"></textarea>
    
    data() {
      return {
        // 评论内容
        cmt: ''
      }
    }
    
  4. 动态控制发布按钮的禁用状态:

    <van-button type="default" :disabled="cmt.length === 0">发布</van-button>
    
  5. 修改输入框的 blur 事件处理函数:

    <textarea placeholder="友善评论、理性发言、阳光心灵" ref="cmtIpt" @blur="onCmtIptBlur" v-model.trim="cmt"></textarea>
    
    // 文本框失去焦点
    onCmtIptBlur() {
      // 延迟隐藏的操作,否则无法触发按钮的 click 事件处理函数
      setTimeout(() => {
        this.isShowCmtInput = false
      })
    },
    
  6. 为发布文章的按钮绑定点击事件处理函数:

    <van-button type="default" :disabled="cmt.length === 0" @click="pubCmt">发布</van-button>
    
  7. 声明 pubCmt 事件处理函数:

    // 发布新评论
    async pubCmt() {
      // 转存评论内容
      const cmt = this.cmt
      // 清空评论文本框
      this.cmt = ''
      // 隐藏输入区域
      this.isShowCmtInput = false
    
      // 发布评论
      const { data: res } = await pubComment(this.artId, cmt)
      if (res.message === 'OK') {
        // 更新评论数据(头部插入)
        this.cmtlist = [res.data.new_obj, ...this.cmtlist]
        // 提示成功
        this.$toast.success('评论成功!')
        // 总评论数 +1
        this.cmtCount++
      }
    }
    

10. 个人中心

10.1 渲染个人中心页面的基本结构

  1. 声明个人中心页面的基本 DOM 结构:

    <template>
      <div class="user-container">
        <!-- 用户基本信息面板 -->
        <div class="user-card">
          <!-- 用户头像、姓名 -->
          <van-cell>
            <!-- 使用 title 插槽来自定义标题 -->
            <template #icon>
              <img src="" alt="" class="avatar">
            </template>
            <template #title>
              <span class="username">用户名</span>
            </template>
            <template #label>
              <van-tag color="#fff" text-color="#007bff">申请认证</van-tag>
            </template>
          </van-cell>
          <!-- 动态、关注、粉丝 -->
          <div class="user-data">
            <div class="user-data-item">
              <span>0</span>
              <span>动态</span>
            </div>
            <div class="user-data-item">
              <span>0</span>
              <span>关注</span>
            </div>
            <div class="user-data-item">
              <span>0</span>
              <span>粉丝</span>
            </div>
          </div>
        </div>
    
        <!-- 操作面板 -->
        <van-cell-group class="action-card">
          <van-cell icon="edit" title="编辑资料" is-link />
          <van-cell icon="chat-o" title="小思同学" is-link />
          <van-cell icon="warning-o" title="退出登录" is-link />
        </van-cell-group>
      </div>
    </template>
    
  2. 美化样式:

    .user-container {
      .user-card {
        background-color: #007bff;
        color: white;
        padding-top: 20px;
        .van-cell {
          background: #007bff;
          color: white;
          &::after {
            display: none;
          }
          .avatar {
            width: 60px;
            height: 60px;
            background-color: #fff;
            border-radius: 50%;
            margin-right: 10px;
          }
          .username {
            font-size: 14px;
            font-weight: bold;
          }
        }
      }
      .user-data {
        display: flex;
        justify-content: space-evenly;
        align-items: center;
        font-size: 14px;
        padding: 30px 0;
        .user-data-item {
          display: flex;
          flex-direction: column;
          justify-content: center;
          align-items: center;
          width: 33.33%;
        }
      }
    }
    

10.2 获取并渲染用户的基本信息

  1. @/api/user.js 模块中,定义获取用户信息的 API 接口:

    // 获取用户的基本信息
    export const getUserInfo = () => {
      return axios.get('/v1_0/user')
    }
    
  2. User.vue 组件中调用接口,获取用户的基本信息:

    // 按需导入 API 接口
    import { getUserInfo } from '@/api/user'
    
    export default {
      name: 'User',
      data() {
        return {
          // 用户的基本信息
          user: {}
        }
      },
      created() {
        // 在组件初始化的时候,请求用户信息
        this.initUserInfo()
      },
      methods: {
        // 初始化用户的基本信息
        async initUserInfo() {
          const { data: res } = await getUserInfo()
          if (res.message === 'OK') {
            console.log(res)
            this.user = res.data
          }
        }
      }
    }
    
  3. 渲染用户的基本信息:

    <!-- 用户基本信息面板 -->
    <div class="user-card">
      <!-- 用户头像、姓名 -->
      <van-cell>
        <!-- 使用 title 插槽来自定义标题 -->
        <template #icon>
          <img :src="user.photo" alt="" class="avatar">
        </template>
        <template #title>
          <span class="username">{{user.name}}</span>
        </template>
        <template #label>
          <van-tag color="#fff" text-color="#007bff">申请认证</van-tag>
        </template>
      </van-cell>
    
      <!-- 动态、关注、粉丝 -->
      <div class="user-data">
        <div class="user-data-item">
          <span>{{user.art_count}}</span>
          <span>动态</span>
        </div>
        <div class="user-data-item">
          <span>{{user.follow_count}}</span>
          <span>关注</span>
        </div>
        <div class="user-data-item">
          <span>{{user.fans_count}}</span>
          <span>粉丝</span>
        </div>
      </div>
    </div>
    

10.3 把用户信息存储到 vuex

为了方便在多个页面之间共享用户的信息,可以把用户的信息存储到 vuex 中

  1. 在 vuex 的 state 节点下,声明 user 数据节点:

    // 初始的 state 数据
    let initState = {
      // 登录成功之后的 token 信息
      tokenInfo: {},
      // 用户的基本信息
      user: {}
    }
    
  2. mutaions 节点下新增 updateUserInfo 函数:

    // 更新用户的基本信息
    updateUserInfo(state, payload) {
      state.user = payload
      this.commit('saveToStorage')
    }
    
  3. User.vue 组件中按需导入 vuex 的辅助函数:

    import { mapState, mapMutations } from 'vuex'
    
  4. 注释掉 data 节点下的 user 节点,并使用 mapState 把数据映射为 computed 计算属性:

    export default {
      name: 'User',
      data() {
        return {
          // 用户的信息对象
          // user: {}
        }
      },
      computed: {
        // 映射需要的 state 数据
        ...mapState(['user'])
      },
    }
    
  5. 使用 mapMutations 把方法映射为当前组件的 methods 处理函数,并进行调用:

    methods: {
      // 1. 映射需要的 mutations 方法
      ...mapMutations(['updateUserInfo']),
    
      // 初始化用户的基本信息
      async initUserInfo() {
        const { data: res } = await getUserInfo()
        if (res.message === 'OK') {
          console.log(res)
          // 2. 注释掉下面这一行,不再把数据存储到当前组件的 data 中
          // this.user = res.data
          // 3. 把数据存储到 vuex 中
          this.updateUserInfo(res.data)
        }
      }
    }
    
  6. 为了防止 user 为空对象时导致的数据渲染失败问题,可以为 User.vue 最外层的 div 元素添加 v-if 指令的判断:

    <template>
      <div class="user-container" v-if="user.name">
        <!-- 省略其它代码... -->
      </div>
    </template>
    

10.4 实现退出登录的功能

  1. 为退出登录的 van-cell 组件绑定 click 点击事件处理函数:

    <van-cell icon="warning-o" title="退出登录" is-link @click="logout" />
    
  2. 在 vuex 的 mutations 中定义 cleanState 方法:

    // 清空 state 中的关键数据
    cleanState(state) {
      state.tokenInfo = {}
      state.user = {}
      // 清空本地存储
      window.localStorage.clear()
    }
    
  3. User.vue 组件中通过 mapMutations 辅助函数,把 cleanState 映射到当前组件中:

    methods: {
      // 映射需要的 mutations 方法
      ...mapMutations(['updateUserInfo', 'cleanState']),
    }
    
  4. User.vue 组件的 methods 中声明 logout 方法如下:

    // 退出登录
    async logout() {
      // 1. 询问用户是否退出登录
      const confirmResult = await this.$dialog
        .confirm({
          title: '提示',
          message: '确认退出登录吗?'
        })
        .catch(err => err)
    
      // 2. 用户取消了退出登录
      if (confirmResult === 'cancel') return
    
      // 3. 执行退出登录的操作
      // 3.1 调用 mutations 中的方法,清空 vuex 中的数据
      this.cleanState()
      // 3.2 跳转到登录页面
      this.$router.push('/login')
    }
    

10.5 跳转到编辑用户资料的页面

  1. @/views/User 目录下新建 UserEdit.vue 组件:

    <template>
      <div class="user-edit-container">
        <!-- Header 区域 -->
        <van-nav-bar title="编辑资料" left-arrow @click-left="$router.back()" fixed />
      </div>
    </template>
    
    <script>
    export default {
      name: 'UserEdit'
    }
    </script>
    
    <style lang="less" scoped>
    .user-edit-container {
      padding-top: 46px;
    }
    </style>
    
  2. @/router/index.js 路由模块中导入 UserEdit.vue 组件并声明对应的路由规则:

    // 导入编辑用户信息的组件
    import UserEdit from '@/views/User/UserEdit.vue'
    
    const routes = [
      // 编辑用户信息的路由规则
      {
        path: '/user/edit',
        component: UserEdit,
        name: 'user-edit'
      }
    ]
    
  3. User.vue 组件中,为编辑资料对应的 van-cell 组件添加 to 属性绑定:

    <!-- 通过命名路由实现导航跳转 -->
    <van-cell icon="edit" title="编辑资料" is-link :to="{name: 'user-edit'}" />
    

10.6 渲染用户的基本资料

  1. @/api/user.js 模块中定义如下的 API 方法:

    // 获取用户的简介信息
    export const getProfile = () => {
      return axios.get('/v1_0/user/profile')
    }
    
  2. @/store/index.js 模块中定义 profile 节点来存放用户的简介,并提供更新 profile 的 mutations 方法:

    // 初始的 state 数据
    let initState = {
      // 登录成功之后的 token 信息
      tokenInfo: {},
      // 用户的基本信息
      user: {},
      // 用户的简介
      profile: {}
    }
    
    export default new Vuex.Store({
      state: initState,
      mutations: {
        // 更新用户的简介信息
        updateProfile(state, payload) {
          state.profile = payload
          this.commit('saveToStorage')
        },
        // 清空 state 中的关键数据
        cleanState(state) {
          state.tokenInfo = {}
          state.user = {}
          state.profile = {}
          // 清空本地存储
          window.localStorage.clear()
        }
      }
    })
    
  3. UserEdit.vue 组件中请求用户的简介信息:

    // 1. 按需导入 API 和辅助函数
    import { getProfile } from '@/api/user'
    import { mapState, mapMutations } from 'vuex'
    
    export default {
      name: 'UserEdit',
      // 2. 页面首次被加载时请求用户的简介
      created() {
        this.getUserProfile()
      },
      computed: {
        // 2.1 映射数据
        ...mapState(['profile'])
      },
      methods: {
        // 2.2 映射方法
        ...mapMutations(['updateProfile']),
        // 3. 获取用户的简介信息
        async getUserProfile() {
          const { data: res } = await getProfile()
          if (res.message === 'OK') {
            console.log(res)
            this.updateProfile(res.data)
          }
        }
      }
    }
    
  4. 渲染用户的基本资料:

    <template>
      <div class="user-edit-container">
        <!-- Header 区域 -->
        <van-nav-bar title="编辑资料" left-arrow @click-left="$router.back()" fixed />
    
        <!-- 用户资料 -->
        <van-cell-group class="action-card">
          <van-cell title="头像" is-link center>
            <template #default>
              <van-image round class="avatar" :src="profile.photo" />
            </template>
          </van-cell>
          <van-cell title="名称" is-link :value="profile.name" />
          <van-cell title="性别" is-link :value="profile.gender === 1 ? '男' : '女'" />
          <van-cell title="生日" is-link :value="profile.birthday" />
        </van-cell-group>
      </div>
    </template>
    
  5. 美化样式:

    .user-edit-container {
      padding-top: 46px;
      .avatar {
        width: 50px;
        height: 50px;
      }
    }
    

10.7 展示修改名称的对话框

  1. data 中声明如下的数据:

    data() {
      return {
        // 是否展示修改用户名的对话框
        isShowNameDialog: false,
        // 名称
        username: ''
      }
    }
    
  2. 渲染对话框的基本 DOM 结构:

    <!-- 修改用户名称的对话框 -->
    <van-dialog v-model="isShowNameDialog" title="修改名称" show-cancel-button :before-close="onNameDialogBeforeClose" @closed="username = ''">
      <!-- 输入框 -->
      <van-field v-model.trim="username" input-align="center" maxlength="7" placeholder="请输入名称" autofocus ref="unameRef" />
    </van-dialog>
    
  3. 名称对应的 van-cell 绑定 click 事件处理函数:

    <van-cell title="名称" is-link :value="profile.name" @click="showNameDialog" />
    
  4. 定义 showNameDialog 方法:

    // 展示修改名称的对话框
    showNameDialog() {
      // 显示修改之前的旧名称
      this.username = this.profile.name
      this.isShowNameDialog = true
      // 让对话框中的文本框自动获得焦点
      this.$nextTick(() => {
        this.$refs.unameRef.focus()
      })
    },
    
  5. 定义对话框 before-close 时对应的处理函数:

    // 用户名对话框 - 关闭之前
    onNameDialogBeforeClose(action, done) {
      // 1. 取消
      if (action !== 'confirm') {
        done()
        return
      }
    
      // 2. 确认
      if (this.username.length === 0 || this.username.length > 7) {
        // 长度不合法
        this.$notify({
          type: 'warning',
          message: '名称的长度为1-7个字符',
          duration: 2000
        })
        done(false)
        return
      }
    
      // 3. TODO:发起请求修改名称
      done(false)
    }
    

10.8 发起请求修改名称

  1. @/api/user.js 模块下新增 API:

    // 修改姓名,生日,性别都使用此接口,修改传参即可
    export const updateProfile = data => {
      return axios.patch('/v1_0/user/profile', data)
    }
    
  2. 修改 onNameDialogBeforeClose 方法,预调用 updateUserProfile 方法:

    import { getProfile, updateProfile } from '@/api/user'
    
    // 用户名对话框 - 关闭之前
    onNameDialogBeforeClose(action, done) {
      // 省略其它代码...
      
      // 3. TODO:发起请求修改名称
      this.updateUserProfile(
        { name: this.username },
        '名称被占用,请更换后重试!',
        done
      )
    }
    
  3. 定义 updateUserProfile 方法如下:

    // 更新用户简介的方法
    async updateUserProfile(data, errMsg, done) {
      try {
        // 3.1 发起请求,更新数据库
        const { data: res } = await updateProfile(data)
        if (res.message === 'OK') {
          // 重新请求用户的数据
          this.getUserProfile()
          // 提示用户成功
          this.$toast.success('修改成功!')
          // 关闭对话框
          done && done()
        }
      } catch {
        // 3.2 如果网络请求失败,则对用户进行友好提示
        this.$notify({
          type: 'warning',
          message: errMsg,
          duration: 2000
        })
        done && done(false)
      }
    }
    

10.9 修改生日

  1. UserEdit.vue 组件的 data 中定义如下的数据:

    data() {
      // 是否展示选择出生日期的 ActionSheet
      isShowBirth: false,
      // 最小的可选的日期
      minDate: new Date(1900, 0, 1),
      // 最大的可选日期
      maxDate: new Date(2030, 10, 1),
      // 当前日期
      currentDate: new Date()
    }
    
  2. 基于 van-action-sheetvan-datetime-picker 渲染修改生日的 DOM 结构:

    <!-- 修改时间 -->
    <van-action-sheet v-model="isShowBirth">
      <!-- 日期选择控件 -->
      <van-datetime-picker v-model="currentDate" type="date" title="选择出生日期" :min-date="minDate" :max-date="maxDate" :show-toolbar="true" @cancel="onPickerCancel" @confirm="onPickerConfirm" />
    </van-action-sheet>
    
  3. 点击生日van-cell 展示日期选择控件:

    <van-cell title="生日" is-link :value="profile.birthday" @click="isShowBirth = true" />
    
  4. 定义 onPickerCancelonPickerConfirm 方法如下:

    // 日期控件 - 取消
    onPickerCancel() {
      this.isShowBirth = false
    },
    
    // 日期控件 - 确认
    onPickerConfirm(value) {
      // 1. 隐藏选择日期的 ActionSheet
      this.isShowBirth = false
    
      // 2. 格式化时间
      const dt = new Date(value)
      const y = dt.getFullYear()
      const m = (dt.getMonth() + 1).toString().padStart(2, '0')
      const d = dt.getDate().toString().padStart(2, '0')
    
      const dtStr = `${y}-${m}-${d}`
    
      // 3. 更新出生日期
      this.updateUserProfile({ birthday: dtStr }, '更新生日失败,请稍后再试!')
    }
    

10.10 更新用户头像

借助于 file 文件选择框,实现更新用户头像的功能

  1. @/api/user.js 模块中定义如下的 API 接口:

    // 更新用户的头像
    export const updateUserPhoto = fd => {
      return axios.patch('/v1_0/user/photo', fd)
    }
    
  2. UserEdit.vue 组件中按需导入 updateUserPhoto 的 API 方法:

    import { getProfile, updateProfile, updateUserPhoto } from '@/api/user'
    
  3. 头像对应的 van-cell 组件中,添加隐藏的 input:file 文件选择框:

    <van-cell title="头像" is-link center>
      <template #default>
        <van-image round class="avatar" :src="profile.photo" @click="choosePhoto" />
        <!-- file 选择框 -->
        <input type="file" ref="iptFile" v-show="false" accept="image/*" @change="onFileChange" />
      </template>
    </van-cell>
    
  4. 定义 choosePhoto 事件处理函数如下:

    // 选择头像的照片
    choosePhoto() {
      // 模拟点击操作
      this.$refs.iptFile.click()
    },
    
  5. 定义 onFileChange 事件处理函数如下:

    // 文件选择框的选中项发生了变化
    async onFileChange(e) {
      // 1. 获取选中的文件列表
      const files = e.target.files
      // 2. 判断选中的个数是否为 0
      if (files.length === 0) return
    
      // 3.1 创建 FormData 实例
      const fd = new FormData()
      // 3.2 添加用户的头像
      fd.append('photo', files[0])
    
      // 4.1 调用接口
      const { data: res } = await updateUserPhoto(fd)
      if (res.message === 'OK') {
        // 4.2 重新拉取数据
        this.getUserProfile()
      }
    }
    

11. 小思同学

11.0 认识 websocket

11.0.1 什么是 websocket

和 http 协议类似,websocket 也是是一个网络通信协议,是用来满足前后端数据通信的。

11.0.2 websocket 相比于 HTTP 的优势

HTTP 协议:客户端与服务器建立通信连接之后,服务器端只能被动地响应客户端的请求,无法主动给客户端发送消息。

websocket 协议:客户端与服务器建立通信连接之后,服务器端可以主动给客户端推送消息了!!!

11.0.3 websocket 主要的应用场景

需要服务端主动向客户端发送数据的场景,比如我们现在要做的智能聊天

11.0.4 HTTP 协议和 websocket 协议对比图

<img alt="">

11.1 渲染小思同学的页面

  1. @/views/Chat 目录下新建 Chat.vue 组件:

    <template>
      <div class="container">
        <!-- 固定导航 -->
        <van-nav-bar fixed left-arrow @click-left="$router.back()" title="小思同学"></van-nav-bar>
    
        <!-- 聊天主体区域 -->
        <div class="chat-list">
          <!-- 左侧是机器人小思 -->
          <div class="chat-item left">
            <van-image fit="cover" round src="https://img.yzcdn.cn/vant/cat.jpeg" />
            <div class="chat-pao">hi,你好!我是小思</div>
          </div>
    
          <!-- 右侧是当前用户 -->
          <div class="chat-item right">
            <div class="chat-pao">我是编程小王子</div>
            <van-image fit="cover" round src="https://img.yzcdn.cn/vant/cat.jpeg" />
          </div>
        </div>
    
        <!-- 对话区域 -->
        <div class="reply-container van-hairline--top">
          <van-field v-model.trim="word" placeholder="说点什么...">
            <template #button>
              <span @click="send()" style="font-size:12px;color:#999">提交</span>
            </template>
          </van-field>
        </div>
      </div>
    </template>
    
    <script>
    export default {
      name: 'Chat',
      data() {
        return {
          word: ''
        }
      },
      methods: {
        send() {
          if (!this.word) return
          console.log(this.word)
        }
      }
    }
    </script>
    
    <style lang="less" scoped>
    .container {
      height: 100%;
      width: 100%;
      position: absolute;
      left: 0;
      top: 0;
      box-sizing: border-box;
      background: #fafafa;
      padding: 46px 0 50px 0;
      .chat-list {
        height: 100%;
        overflow-y: scroll;
        .chat-item {
          padding: 10px;
          .van-image {
            vertical-align: top;
            width: 40px;
            height: 40px;
          }
          .chat-pao {
            vertical-align: top;
            display: inline-block;
            min-width: 40px;
            max-width: 70%;
            min-height: 40px;
            line-height: 38px;
            border: 0.5px solid #c2d9ea;
            border-radius: 4px;
            position: relative;
            padding: 0 10px;
            background-color: #e0effb;
            word-break: break-all;
            font-size: 14px;
            color: #333;
            &::before {
              content: '';
              width: 10px;
              height: 10px;
              position: absolute;
              top: 12px;
              border-top: 0.5px solid #c2d9ea;
              border-right: 0.5px solid #c2d9ea;
              background: #e0effb;
            }
          }
        }
      }
    }
    .chat-item.right {
      text-align: right;
      .chat-pao {
        margin-left: 0;
        margin-right: 15px;
        &::before {
          right: -6px;
          transform: rotate(45deg);
        }
      }
    }
    .chat-item.left {
      text-align: left;
      .chat-pao {
        margin-left: 15px;
        margin-right: 0;
        &::before {
          left: -5px;
          transform: rotate(-135deg);
        }
      }
    }
    .reply-container {
      position: fixed;
      left: 0;
      bottom: 0;
      height: 44px;
      width: 100%;
      background: #f5f5f5;
      z-index: 9999;
    }
    </style>
    
  2. @/router/index.js 路由模块中,导入组件并声明小思聊天的路由规则:

    // 导入小思同学的组件页面
    import Chat from '@/views/Chat/Chat.vue'
    
    const routes = [
      // 小思聊天的路由规则
      {
        path: '/chat',
        component: Chat,
        name: 'chat'
      }
    ]
    
  3. @/views/User/User.vue 组件中,为小思同学对应的 van-cell 组件添加 to 属性:

    <van-cell icon="chat-o" title="小思同学" is-link to="/chat" />
    

11.2 动态渲染聊天消息

  1. 在 data 中声明 list 数组,用来存放机器人和用户的聊天消息内容:

    data() {
      return {
        // 用户填写的内容
        word: '',
        // 所有的聊天消息
        list: [
          // 1. 只根据 name 属性,即可判断出这个消息应该渲染到左侧还是右侧
          { name: 'xs', msg: 'hi,你好!我是小思' },
          { name: 'me', msg: '我是编程小王子' }
        ]
      }
    },
    
  2. 动态渲染聊天消息:

    <!-- 聊天主体区域 -->
    <div class="chat-list">
      <!-- 左侧是机器人小思 -->
      <div v-for="(item, index) in list" :key="index">
        <div class="chat-item left" v-if="item.name === 'xs'">
          <van-image fit="cover" round src="https://img.yzcdn.cn/vant/cat.jpeg" />
          <div class="chat-pao">{{item.msg}}</div>
        </div>
    
        <!-- 右侧是当前用户 -->
        <div class="chat-item right" v-else>
          <div class="chat-pao">{{item.msg}}</div>
          <van-image fit="cover" round src="https://img.yzcdn.cn/vant/cat.jpeg" />
        </div>
      </div>
    </div>
    
  3. 动态渲染用户的头像:

    // 1. 按需导入辅助函数
    import { mapState } from 'vuex'
    
    computed: {
      // 2. 把用户的信息,映射为当前组件的计算属性
      ...mapState(['profile'])
    }
    
    <!-- 3. 动态绑定用户头像 -->
    <van-image fit="cover" round :src="profile.photo || 'https://img.yzcdn.cn/vant/cat.jpeg'" />
    
  4. 用户点击按钮,把消息存储到 list 数组中:

    methods: {
      send() {
        // 1. 判断内容是否为空
        if (!this.word) return
        // 2. 添加聊天消息到 list 列表中
        this.list.push({
          name: 'me',
          msg: this.word
        })
        // 3. 清空文本框的内容
        this.word = ''
      }
    },
    

11.3 配置 websocket 客户端

  1. 安装 websocket 客户端相关的包:

    npm i socket.io-client@4.0.0 -S
    
    # 如果 npm 无法成功安装 socket.io-client,可以尝试用 yarn 来装包
    

    参考官方文档进行使用:https://socket.io/docs/v4/client-initialization/

  2. Chat.vue 组件中,导入 socket.io-client 模块:

    // 1.1 导入 socket.io-client 包
    import { io } from 'socket.io-client'
    
    // 1.2 定义变量,存储 websocket 实例
    let socket = null
    
  3. Chat.vue 组件的 created 生命周期函数中,创建 websocket 实例对象:

    created() {
      // 2. 创建客户端 websocket 的实例
      socket = io('ws://www.liulongbin.top:9999')
    }
    
  4. Chat.vue 组件的 beforeDestroy 生命周期函数中,关闭 websocket 连接并销毁 websocket 实例对象:

    // 组件被销毁之前,清空 sock 对象
    beforeDestroy() {
      // 关闭连接
      socket.close()
      
      // 销毁 websocket 实例对象
      socket = null
    },
    
  5. created 生命周期函数中,监听 websocket 实例对象的 connectmessagedisconnect 事件:

    created() {
      // 创建客户端 websocket 的实例
      socket = io('ws://www.liulongbin.top:9999')
    
      // 建立连接的事件
      socket.on('connect', () => {
        console.log('connect')
      })
    
      // 接收到消息的事件
      socket.on('message', msg => {
        // 把服务器发送过来的消息,存储到 list 数组中
        this.list.push({
          name: 'xs',
          msg
        })
      })
    
      // 关闭的事件
      socket.on('disconnect', () => {
        console.log('disconnect')
      })
    },
    
  6. message 事件中,把服务器发送到客户端的消息,存储到 list 数组中:

    // 接收到消息的事件
    socket.on('message', msg => {
      // 把服务器发送过来的消息,存储到 list 数组中
      this.list.push({
        name: 'xs',
        msg
      })
    })
    
  7. 客户端调用 socket.emit('send', 消息内容) 方法把消息发送给 websocket 服务器:

    // 向服务端发送消息
    send() {
      // 判断内容是否为空
      if (!this.word) return
    
      // 添加聊天消息到 list 列表中
      this.list.push({
        name: 'me',
        msg: this.word
      })
    
      // 把消息发送给 websocket 服务器
      socket.emit('send', this.word)
    
      // 清空文本框的内容
      this.word = ''
    }
    

11.4 自动滚动到底部

https://developer.mozilla.org/zh-CN/docs/web/api/element/scrollintoview

  1. methods 中声明 scrollToBottom 方法:

    // 滚动到页面底部
    scrollToBottom() {
      // 获取到所有的聊天 Item 项
      const chatItem = document.querySelectorAll('.chat-item')
      // 获取到最后一项对应的 DOM 元素
      const lastItem = chatItem[chatItem.length - 1]
      // 调用 scrollIntoView() 方法,显示这个元素
      lastItem.scrollIntoView({
        behavior: 'smooth',
        block: 'end',
        inline: 'nearest'
      })
    }
    
  2. Chat.vue 组件中定义 watch 侦听器,监视 list 数组的变化,从而自动滚动到页面底部:

    watch: {
      list() {
        // 监视到 list 数据变化后,等下次 DOM 更新完毕,再执行滚动到底部的操作
        this.$nextTick(() => {
          this.scrollToBottom()
        })
      }
    },
    

12. 页面权限控制

12.1 未登录不允许访问 User 页面

  1. @/router/index.js 模块中声明全局前置导航守卫:

    // 导入 store 模块,方便拿到 store 中的数据
    import store from '@/store/index'
    
    // 导航守卫
    router.beforeEach((to, from, next) => {
      const tokenInfo = store.state.tokenInfo // {token, refresh_token}
      // 访问的是有权限的页面
      if (to.path === '/user') {
        if (!tokenInfo.token) {
          // token 的值不存在,强制跳转到登录页
          next('/login?pre=' + to.fullPath)
        } else {
          // token 的值存在,放行
          next()
        }
      } else {
        // 访问的是普通页面
        next()
      }
    })
    
  2. Login.vue 组件中,修改 onSubmit 方法如下:

    async onSubmit() {
      const { data: res } = await login(this.formLogin)
      if (res.message === 'OK') {
        this.updateTokenInfo(res.data)
    
        // 1. 判断是否携带了 pre 参数
        if (this.$route.query.pre) {
          // 1.1 如果有,则跳转到指定页面
          this.$router.push(this.$route.query.pre)
        } else {
          // 1.2 如果没有,则跳转到 / 主页
          this.$router.push('/')
        }
      }
    }
    

12.2 登录状态下不允许访问登录页

在路由导航守卫中添加如下的判断条件:

else if (to.path === '/login') {
  if (!tokenInfo.token) {
    next()
  } else {
    next(false)
  }
}

13. Token 过期处理

两种主流方案:

  1. 只要发现 Token 过期,则强制用户跳转到登录页,并清空本地和 Store 中的关键数据!
  2. 如果发现 Token 过期,则自动基于 refresh_token 无感知地请求一个新 Token 回来,在替换掉旧 Token 的同时,继续上次未完成的请求!

13.1 方案1:强制跳转到登录页

  1. @/utils/request.js 模块中,导入 Store 和 Router 模块:

    import store from '@/store/index'
    import router from '@/router/index'
    
  2. 在 axios 的 instance 实例上声明响应拦截器,如果身份认证失败,则强制用户跳转到登录页:

    // 响应拦截器
    instance.interceptors.response.use(
      response => {
        // 响应成功
        return response
      },
      error => {
        // 响应失败时,处理未授权的情况:
        // 1. 判断是否为未授权(Token 过期)
        if (error.response && error.response.status === 401) {
          // 2. 清空 Store 中的关键数据
          store.commit('cleanState')
          // 3. 跳转到登录页
          router.push('/login?pre=' + router.currentRoute.fullPath)
        }
        return Promise.reject(error)
      }
    )
    

13.2 方案2:无感知刷新 Token

  1. @/utils/request.js 模块中,导入 Store 和 Router 模块:

    import store from '@/store/index'
    import router from '@/router/index'
    
  2. 在 axios 的 instance 实例上声明响应拦截器,如果身份认证失败,则根据 refresh_token 重新请求一个有效的新 Token 回来:

    // 响应拦截器
    instance.interceptors.response.use(
      response => {
        return response
      },
      async error => {
        // 1. 从 vuex 中获取 token 对象
        const tokenInfo = store.state.tokenInfo
        // 2. 判断是否为未授权(Token 过期)
        if (error.response && error.response.status === 401 && tokenInfo.token) {
          try {
            // 3.1 TODO: 发起请求,根据 refresh_token 重新请求一个有效的新 token
            // 3.2 TODO: 更新 Store 中的 Token
            // 3.3 基于上次未完成的配置,重新发起请求
            return instance(error.config)
          } catch {
            // 4. 证明 refresh_token 也失效了:
            // 4.1 则清空 Store 中的关键数据
            store.commit('cleanState')
            // 4.2 并强制跳转到登录页
            router.push({
              path: '/login?pre=' + router.currentRoute.fullPath
            })
          }
        }
        return Promise.reject(error)
      }
    )
    
  3. 发起请求,根据 refresh_token 重新请求一个有效的新 token:

    const { data: res } = await axios({
      method: 'PUT',
      url: 'http://toutiao-app.itheima.net/v1_0/authorizations',
      headers: {
        Authorization: `Bearer ${tokenInfo.refresh_token}`
      }
    })
    
  4. 更新 Store 中的 Token:

    store.commit('updateTokenInfo', {
      refresh_token: tokenInfo.refresh_token,
      token: res.data.token
    })
    

14. 项目优化

14.1 保持组件的状态

结合 vue 内置的 keep-alive 组件,可以实现组件的状态保持。

官方文档地址:https://cn.vuejs.org/v2/api/#keep-alive

14.1.1 实现 Layout 组件的状态保持

  1. App.vue 组件中,在 <router-view> 路由占位符之外包裹一层 <keep-alive> 组件,从而实现 Layout 组件的状态保持:

    <template>
      <div>
        <!-- 路由占位符 -->
        <keep-alive>
          <router-view></router-view>
        </keep-alive>
      </div>
    </template>
    
  2. 通过步骤 1,的确实现了 Layout 组件的状态保持。但是随之而来的:详情页也被缓存了,导致了文章数据不会动态刷新的问题。

  3. 可以通过 <keep-alive> 组件提供的 include 属性,来有条件的缓存组件:

    <template>
      <div>
        <!-- 路由占位符 -->
        <keep-alive include="Layout">
          <router-view></router-view>
        </keep-alive>
      </div>
    </template>
    

14.1.2 实现 Home 组件的状态保持

点击 tabBar 实现 Home 页面和 User 页面切换展示的时候,发现 Home 组件的状态每次都会被刷新

  1. Layout.vue 组件中,在 <router-view> 路由占位符之外包裹一层 <keep-alive> 组件,从而实现 Home 组件的状态保持:

    <template>
      <div class="layout-container">
        <!-- 路由占位符 -->
        <keep-alive>
          <router-view></router-view>
        </keep-alive>
    
        <!-- TabBar 区域 -->
        <van-tabbar route>
          <van-tabbar-item icon="home-o" to="/">首页</van-tabbar-item>
          <van-tabbar-item icon="user-o" to="/user">我的</van-tabbar-item>
        </van-tabbar>
      </div>
    </template>
    
  2. 通过步骤 1,的确实现了 Home 组件的状态保持。但是随之而来的,User.vue 组件也被缓存了,导致修改用户头像后,头像不刷新的问题。

  3. 可以在被缓存的 User.vue 组件中,声明 activateddeactivated 声明周期函数,来监听组件被激活被缓存的状态变化:

    created() {
      // 把下面这一行注释掉,因为 activated 在组件首次加载时也会调用一次
      // this.initUserInfo()
    },
    
    // 被激活了
    activated() {
      // 只要组件被激活了,就重新初始化用户的信息
      this.initUserInfo()
    },
    // 被缓存了
    deactivated() {
      console.log('被缓存了')
    },
    

14.1.3 实现 SearchResult 组件的状态保持

  1. App.vue 组件中,为 <keep-alive> 组件添加要缓存的组件名称:

    <template>
      <div>
        <!-- 路由占位符 -->
        <keep-alive include="Layout,SearchResult">
          <router-view></router-view>
        </keep-alive>
      </div>
    </template>
    
  2. SearchResult.vue 组件的 data 中声明 preKw 节点,用来缓存上次的搜索关键词:

    data() {
      return {
        // 缓存的搜索关键词
        preKw: ''
      }
    }
    
  3. SearchResult.vue 组件中定义 activateddeactivated 声明周期如下:

    // 组件被激活
    activated() {
      // 如果上一次的 kw 不为空,且这次的 kw 和上次缓存的 kw 值不同,则需要重新请求列表数据
      if (this.preKw !== '' && this.kw !== this.preKw) {
        // 1. 重置数据
        this.page = 1
        this.searchResult = []
        this.loading = false
        this.finished = false
    
        // 2. 重新请求列表数据
        this.onLoad()
      }
    },
    
    // 组件被缓存
    deactivated() {
      // 组件被缓存时,将搜索关键词保存到 data 中
      this.preKw = this.kw
    },
    

14.2 详情页代码高亮

基于 highlight.js 美化详情页的代码片段

  1. 运行如下的命令,在项目中安装 highlight.js

    npm i highlight.js@10.6.0 -S
    
  2. index.html 页面的 <head> 标签中引入 highlight.js 的样式表:

    <link rel="stylesheet" href="https://cdn.staticfile.org/highlight.js/10.6.0/styles/default.min.css" />
    
  3. ArticleDetail.vue 组件中导入 highlight.js 模块:

    // 导入 highlight.js 模块
    import hljs from 'highlight.js'
    
  4. ArticleDetail.vue 组件的 updated 声明周期函数中,对位置内容进行高亮处理:

    // 1. 当组件的 DOM 更新完毕之后
    updated() {
      // 2. 判断是否有文章的内容
      if (this.article.content) {
        // 3. 对文章的内容进行高亮处理
        hljs.highlightAll()
      }
    },
    

14.3 添加文章加载的 loading 效果

  1. ArticleDetail.vue 组件中,在文章信息区域文章评论组件之外包裹一个 div 元素:

    <template>
      <div>
        <!-- Header 区域 -->
        <van-nav-bar fixed title="文章详情" left-arrow @click-left="$router.back()" />
    
        <div v-if="article.title">
          <!-- 文章信息区域 -->
          <!-- 文章评论组件 -->
        </div>
    
        <van-loading size="24px" vertical v-else class="loading">文章加载中...</van-loading>
      </div>
    </template>
    
  2. 添加 loading 样式:

    .loading {
      margin-top: 50px;
    }
    

15. 打包发布

15.1 初步打包发布

  1. 在终端下运行如下的打包命令:

    npm run build
    
  2. 基于 Node + Express 手写一个 web 服务器,对外托管 web 项目:

    // app.js
    
    // 导入 express 模块
    const express = require('express')
    // 创建 express 的服务器实例
    const app = express()
    
    // 1. 将 dist 目录托管为静态资源服务器
    app.use(express.static('./dist'))
    
    // 调用 app.listen 方法,指定端口号并启动web服务器
    app.listen(3001, function () {
      console.log('Express server running at http://127.0.0.1:3001')
    })
    
    

15.2 优化网络传输时的文件体积

基于 Express 的 express-compression 中间件,可以在服务器端对文件进行压缩。

压缩后文件网络传输的体积会大幅变小,客户端在接收到压缩的文件后会自动进行解压。

  1. 在终端下运行如下的命令:

    npm i express-compression -S
    
  2. app.js 中导入并使用网络传输压缩的中间件:

    // 导入 express 模块
    const express = require('express')
    // 创建 express 的服务器实例
    const app = express()
    
    // 2. 安装并配置网络传输压缩的中间件
    //    注意:必须在托管静态资源配置此中间件
    const compression = require('express-compression')
    app.use(compression())
    
    // 1. 将 dist 目录托管为静态资源服务器
    app.use(express.static('./dist'))
    
    // 调用 app.listen 方法,指定端口号并启动web服务器
    app.listen(3001, function () {
      console.log('Express server running at http://127.0.0.1:3001')
    })
    
  3. 最终的效果截图:

    <img alt="">

15.3 移除代码中所有的 console

  1. 运行如下的命令,安装 Babel 插件:

    npm install babel-plugin-transform-remove-console --save-dev
    
  2. babel.config.js 中新增如下的 plugins 数组节点:

    module.exports = {
      presets: ['@vue/cli-plugin-babel/preset'],
      // 配置移除 console 的插件
      plugins: ['transform-remove-console']
    }
    
  3. 重新运行打包的命令:

    npm run build
    

15.4 生成打包报告

  1. 打开 package.json 配置文件,为 scripts 节点下的 build 命令添加 --report 参数:

    {
      "scripts": {
        "serve": "vue-cli-service serve",
        "build": "vue-cli-service build --report",
        "lint": "vue-cli-service lint"
      }
    }
    
  2. 重新运行打包的命令:

    npm run build
    
  3. 打包完成后,发现在 dist 目录下多了一个名为 report.html 的文件。在浏览器中打开此文件,会看到详细的打包报告。

15.5 基于 externals 优化打包的体积

未配置 externals 之前,项目中使用 import 导入的第三方模块,在最终打包时,会被打包合并到一个 js 文件中。最后导致项目体积过大的问题。

配置了 externals 之后,webpack 在进行打包时,会把 externals 节点下声明的第三方包排除在外。因此最终打包生成的 js 文件中,不会包含 externals 节点下的包。这样就优化了打包后项目的体积。

  1. 在项目根目录下找到 vue.config.js 配置文件,在里面新增 configureWebpack 节点如下:

    module.exports = {
      // 省略其它代码...
    
      // 增强 vue-cli 的 webpack 配置项
      configureWebpack: {
        // 打包优化
        externals: {
          // import 时的包名称: window 全局的成员名称
          'highlight.js': 'hljs'
        }
      }
    }
    
  2. 打开 public 目录下的 index.html 文件,在 body 结束标签之前,新增如下的资源引用:

    <!DOCTYPE html>
    <html lang="">
    
    <head>
      <meta charset="utf-8">
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <meta name="viewport"
        content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
      <link rel="icon" href="<%= BASE_URL %>favicon.ico">
      <title>
        <%= htmlWebpackPlugin.options.title %>
      </title>
      <!-- 1. 引用 highlight.js 的样式表 -->
      <link rel="stylesheet" href="https://cdn.staticfile.org/highlight.js/10.6.0/styles/default.min.css" />
    </head>
    
    <body>
      <noscript>
        <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled.
            Please enable it to continue.</strong>
      </noscript>
      <div id="app"></div>
    
      <!-- 2. 为 window 对象全局挂载 hljs 成员 -->
      <script src="https://cdn.staticfile.org/highlight.js/10.6.0/highlight.min.js"></script>
    </body>
    
    </html>
    
  3. 重新运行打包发布的命令,对比配置 externals 前后文件的体积变化。

15.6 完整的 externals 配置项

  1. vue.config.js 配置文件中,找到 configureWebpack 下的 externals,添加如下的配置项:

    // 增强 vue-cli 的 webpack 配置项
    configureWebpack: {
      // 打包优化
      externals: {
        'highlight.js': 'hljs',
        vue: 'Vue',
        'vue-router': 'VueRouter',
        vuex: 'Vuex',
        axios: 'axios',
        vant: 'vant',
        'socket.io-client': 'io',
        dayjs: 'dayjs',
        'bignumber.js': 'BigNumber'
      }
    }
    
  2. /public/index.html 文件的 <head> 结束标签之前,添加如下的样式引用:

    <!-- heightlight.js 的样式 -->
    <link rel="stylesheet" href="https://cdn.staticfile.org/highlight.js/10.6.0/styles/default.min.css" />
    <!-- vant 的图标样式 -->
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vant@2.12.7/lib/icon/local.css" />
    
  3. /public/index.html 文件的 <body> 结束标签之前,添加如下的 js 引用:

    <script src="https://cdn.staticfile.org/highlight.js/10.6.0/highlight.min.js"></script>
    <script src="https://cdn.staticfile.org/vue/2.6.11/vue.min.js"></script>
    <script src="https://cdn.staticfile.org/vue-router/3.2.0/vue-router.min.js"></script>
    <script src="https://cdn.staticfile.org/vuex/3.6.2/vuex.min.js"></script>
    <script src="https://cdn.staticfile.org/axios/0.21.1/axios.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/vant@2.12.7/lib/vant.min.js"></script>
    <script src="https://cdn.staticfile.org/socket.io/4.0.0/socket.io.min.js"></script>
    <script src="https://cdn.staticfile.org/dayjs/1.10.4/dayjs.min.js"></script>
    <script src="https://cdn.staticfile.org/bignumber.js/9.0.1/bignumber.min.js"></script>
    

15.7 只在生产阶段对项目进行打包优化

  1. 在 development 开发阶段的需求是:急速的打包生成体验、不需要移除 console、也不需要对打包的体积进行优化

  2. 在 production 生产阶段的需求是:移除 console、基于 externals 对打包的体积进行优化

  3. 问题:如何判断当前打包期间的运行模式?

    // 获取当前编译的环境 development 或 production

    const env = process.env.NODE_ENV

  1. babel.config.js 配置文件中,先获取到当前打包的模式,再决定是否使用移除 console 的 Babel 插件:

    // 1. 获取当前编译的环境 development 或 production
    const env = process.env.NODE_ENV
    // 2. 当前是否处于发布模式
    const isProd = env === 'production' ? true : false
    
    // 3.1 插件的数组
    const plugins = []
    // 3.2 判断是否处于发布模式
    if (isProd) {
      plugins.push('transform-remove-console')
    }
    
    module.exports = {
      presets: ['@vue/cli-plugin-babel/preset'],
      // 4. 动态的向外导出插件的数组
      plugins
    }
    
  2. vue.config.js 配置文件中,先获取到当前打包的模式,再决定是否开启 externals 特性:

    // 1. 获取当前编译的环境 development 或 production
    const env = process.env.NODE_ENV
    // 2. 当前是否处于发布模式
    const isProd = env === 'production' ? true : false
    
    // 3. 自定义的 webpack 配置项
    const customWebpackConfig = {
      externals: {
        'highlight.js': 'hljs',
        vue: 'Vue',
        'vue-router': 'VueRouter',
        vuex: 'Vuex',
        axios: 'axios',
        vant: 'vant',
        'socket.io-client': 'io',
        dayjs: 'dayjs',
        'bignumber.js': 'BigNumber'
      }
    }
    
    module.exports = {
      // 省略其它配置节点...
      
      // 4. 增强 vue-cli 的 webpack 配置项
      configureWebpack: isProd ? customWebpackConfig : {},
    }
    

15.8 在 index.html 中按需引入 css 和 js

由于 externals 节点是按需生效的。为了与之匹配,index.html 页面中的 css 样式和 js 脚本也要按需进行引入。

问题:在 index.html 页面中,如何判断当前的打包模式呢?

答案:可以对 html-webpack-plugin 插件进行自定义配置,从而支持在 index.html 页面中获取到当前的打包模式。

  1. vue.config.js 中新增 chainWebpack 节点,可以对 webpack 已有的配置进行修改

    module.exports = {
      // 省略其它配置节点...
      
      // 对 webpack 已有的配置进行修改
      chainWebpack: config => { /* 在这个函数中对 webpack 已有的配置进行修改 */ }
    }
    

    具体代码示例如下:

    module.exports = {
      // 省略其它配置节点...
      
      // 对 webpack 已有的配置进行修改
      chainWebpack: config => {
        config.plugin('html').tap(args => {
          // 打印 html 插件的参数项
          // console.log(args)
    
          // 当前是否处于发布模式
          args[0].isProd = isProd
          return args
        })
      }
    }
    
  2. index.html 中,根据 html-webpack-plugin 插件提供的 <% %> 模板语法,按需渲染 link 和 script 标签:

    <% if (htmlWebpackPlugin.options.isProd) { %>
      <!-- heightlight.js 的样式 -->
      <link rel="stylesheet" href="https://cdn.staticfile.org/highlight.js/10.6.0/styles/default.min.css" />
      <!-- vant 的图标样式 -->
      <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vant@2.12.7/lib/icon/local.css" />
    <% } %>
    
    <% if (htmlWebpackPlugin.options.isProd) { %>
      <!-- 为 window 对象全局挂载 hljs 成员 -->
      <script src="https://cdn.staticfile.org/highlight.js/10.6.0/highlight.min.js"></script>
      <script src="https://cdn.staticfile.org/vue/2.6.11/vue.min.js"></script>
      <script src="https://cdn.staticfile.org/vue-router/3.2.0/vue-router.min.js"></script>
      <script src="https://cdn.staticfile.org/vuex/3.6.2/vuex.min.js"></script>
      <script src="https://cdn.staticfile.org/axios/0.21.1/axios.min.js"></script>
      <script src="https://cdn.jsdelivr.net/npm/vant@2.12.7/lib/vant.min.js"></script>
      <script src="https://cdn.staticfile.org/socket.io/4.0.0/socket.io.min.js"></script>
      <script src="https://cdn.staticfile.org/dayjs/1.10.4/dayjs.min.js"></script>
      <script src="https://cdn.staticfile.org/bignumber.js/9.0.1/bignumber.min.js"></script>
    <% } %>
    
MIT License Copyright (c) 2021 刘龙彬 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

简介

vue2 + vue-router + vuex + vant 展开 收起
JavaScript
MIT
取消

发行版

暂无发行版

贡献者 (1)

全部

近期动态

3年多前创建了任务 #I48ZDL 刘老师, 你的头条项目有录播吗? 想听你讲的课!
4年前推送了新的提交到 master 分支,31e31f7...0d88ca5
4年前推送了新的提交到 master 分支,208842e...31e31f7
4年前推送了新的提交到 master 分支,b38047b...208842e
4年前推送了新的提交到 master 分支,3c12e1b...b38047b
加载更多
不能加载更多了
马建仓 AI 助手
尝试更多
代码解读
代码找茬
代码优化
JavaScript
1
https://gitee.com/vsdeveloper/vue2-toutiao.git
git@gitee.com:vsdeveloper/vue2-toutiao.git
vsdeveloper
vue2-toutiao
vue2-toutiao
master

搜索帮助