# vue-itcast-toutiao **Repository Path**: shiyu228/vue-itcast-toutiao ## Basic Information - **Project Name**: vue-itcast-toutiao - **Description**: 基于vue2实现移动端黑马头条 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 4 - **Forks**: 1 - **Created**: 2022-08-11 - **Last Updated**: 2024-02-22 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README --- typora-root-url: ./ --- # 黑马头条项目 该文档记录了完成项目的过程,包括第三方包的下载及相关配置,和遇到的一些问题及解决方案......如果你也喜欢的话留个小星星吧。 # 前端地址:(本地) https://gitee.com/shiyu228/vue-itcast-toutiao.git # 后端接口文档:(黑马提供) https://gitee.com/lidongxuwork/bilibili-matching-code/blob/master/Web%E5%89%8D%E7%AB%AF/5-%E6%A1%86%E6%9E%B6/V2.x/%E9%A1%B9%E7%9B%AE/%E9%BB%91%E9%A9%AC%E5%A4%B4%E6%9D%A1_%E7%A7%BB%E5%8A%A8%E7%AB%AF%E9%A1%B9%E7%9B%AE/%E9%85%8D%E5%A5%97%E7%AC%94%E8%AE%B0/02_%E6%8E%A5%E5%8F%A3%E6%96%87%E6%A1%A3/%E6%8E%A5%E5%8F%A3%E6%96%87%E6%A1%A3.md#%E9%A2%91%E9%81%93_%E8%8E%B7%E5%8F%96%E7%94%A8%E6%88%B7%E5%B7%B2%E9%80%89 ## 技术栈 - vue2 - vant2 - axios - vue-router - vuex - less - Socket.io-client - amfe-flexible ## 页面展示 #### 1.登录 ![login](/images/login.png) #### 2.文章首页 ![home1](/images/home1.png) #### 3.频道管理 ![channel1](/images/channel1.png) #### 4.文章搜索页 ![search1](/images/search1.png) ![search2](/images/search2.png) #### 5.文章详情页 ![detail1](/images/detail1.png) ![detail2](/images/detail2.png) #### 6.个人中心 ![user](/images/user.png) #### 7.编辑信息 ![useredit](/images/useredit.png) #### 8.小思同学 ![chat](/images/chat.png) # 2022/8/11 ## 一、初始化项目 ``` //1.创建项目 vue create itcast-toutiao //2.删除不需要的组件 src/components/ src/views/ //3.修改路由,将引入的组件和配置的路由删除 src/router/index //4.删除App.vue中的代码,留下template和div即可 ``` ## 二、配置Eslint 1. 在vscode中下载Eslint插件,并启用 2. 将下面配置放到vscode的Eslint配置中 ``` "eslint.run": "onType", "editor.codeActionsOnSave": { "source.fixAll.eslint": true } ``` ## 三、下载所需第三方包 1.下载axios ``` npm i axios ``` 2.下载vant组件库(下面再写引入方式) ``` npm i vant@2.12.34 ``` 3.下载移动端适配插件 ``` npm i amfe-flexible@2.2.1 ``` 在main.js中引入 ``` import "amfe-flexible" ``` 4.下载postcss和postcss-pxtorem@5.1.1 postcss: 后处理css, 编译翻译css代码 postcss-pxtorem: 把css代码里所有px计算转换成rem ``` npm i postcss postcss-pxtorem@5.1.1 ``` ## 四、封装axios请求 在src目录下创建utils/request.js文件,写入下面代码 ``` // 基于axios封装网络请求 import Axios from 'axios' const axios = Axios.create({ baseURL: 'http://toutiao.itheima.net', timeout: 2000 }) // 导出自定义函数,参数对象结构赋值 export default ({ url, method = 'GET', params = {}, data = {}, header = {} }) => { return axios({ url, method, params, data, headers }) } ``` ## 五、按需引入vant组件 1.在src下新建 vant/index 文件,引入所需组件(可全局使用) ``` import Vue from 'vue' import { NavBar, Button, } from 'vant' Vue.use(NavBar) Vue.use(Button) ``` 2.在main.js中引入 vant/index 文件 ``` // 导入按需加载vant组件 import '@/vant' ``` 3.配置babel.plugin.js,按需导入样式(不用全局引入样式,减小体积) ``` module.exports = { plugins: [ ['import', { libraryName: 'vant', libraryDirectory: 'es', // 指定样式路径 style: (name) => `${name}/style/less` }, 'vant'] ] } ``` 现在就可以正常使用vant组件库了...... ## 六、登录页面 //页面内容就不写了 请求接口:/v1_0/authorizations 请求方式:get #### 1.token 的设置 在src/utils/下新建token.js文件,封装localStorage的方法 ``` // 此文件专门用于操作token的 const key = 'key-token' // 保存token export const setToken = (token) => { localStorage.setItem(key, token) } // 获取token export const getToken = () => { localStorage.getItem(key) } // 删除token export const removeToken = (key) => { localStorage.removeItem(key) } ``` #### 2.所用到的组件 ``` NavBar,Button,Form, Field,Notify ``` #### 3.页面效果 ![login](C:\Users\29809\Desktop\webCode\project\itcast-toutiao\images\login.png) #### 4.登录成功后路由跳转,两种方式 this.$router.push(url),跳转后可以返回 this.$router.replace(url),跳转后不能返回 # 2022/8/12 ## 七、首页页面 #### 1.Layout底部导航 引入组件Tabbar, TabbarItem,Icon ![tabbar](C:\Users\29809\Desktop\webCode\project\itcast-toutiao\images\tabbar.png) 注意:这里Tarbar底部导航是固定定位,所以内容区域需要加上padding-bottom值,防止内容区域被覆盖 ``` .home_container{ padding-bottom:50px ; } .van-tabbar{ border-top: 1px solid #f8f8f8; } ``` #### 2.头部Header ![header](C:\Users\29809\Desktop\webCode\project\itcast-toutiao\images\header.png) 头部使用固定定位,也是需要给一个padding-top值,避免tab导航被覆盖 被定位的头部导航挡住, 给tabs设置固定定位/粘性定位, 距离上边46px(手动转rem) tab标签导航部分使用粘贴定位: ``` ``` **粘性定位是什么?(又学到了一个新知识,哈哈哈)** 正常随着页面滚动, 到达指定位置, 变成固定定位 #### 3.获取用户的频道数据 请求接口: /v1_0/user/channels 请求方式:get #### 4.时间处理 下载dayjs包进行处理 ``` npm i dayjs ``` 在src/utils下新建date.js进行封装(项目中会多次用到就进行封装) ``` import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' import 'dayjs/locale/zh' /** * @params {*} 之前的时间 * @return 系统时间到之前指定时间的距离值 */ export const timeAgo = (targetTime) => { // 格式化时间 dayjs.extend(relativeTime) dayjs.locale('zh') const a = dayjs() const b = dayjs(targetTime) return a.to(b) // 返回多久之前 } ``` #### 5.渲染文章列表 **!!!注意坑(找了半天终于找到问题所在了):** 在组件传值直接把当前频道的ID传过去,让自己来获取数据,不要在组件中获取文章列表后传数组,否则所有tab标签共享的都是同一个文章列表,在切换tabs标签的时候再切换回来每次显示的文章都不一样。(就是因为一个tab标签对应了一个tab内容,比如一共10个tab标签,就会有10个tab内容体,但是这10个内容体都是共享同一个文章列表,所以才要把频道ID传给让它自己发起请求获取自己的文章列表,这个10个内容体都是自己的文章列表了)。 ```
``` 3.实现文章列表下拉刷新,使用list组件来包裹 ``` ``` **!!!下拉刷新报错(又是一个坑):** **Duplicate keys detected: '7656'. This may cause an update error.** (key值重复了) ``` // 获取文章列表 async getArticleList () { const { data: res } = await getArticleListAPI({ channel_id: this.articleId, timestamp: this.nextTime }) // 先处理时间格式 res.data.results.forEach((item) => { item.pubdate = timeAgo(item.pubdate) } ) // 把下一次的时间戳存放到nextTime,到底部进行加载的时候用这个时间戳去请求(类似于懒加载) this.nextTime = res.data.pre_timestamp //=========================因为这里,把新请求的数据放进去,key有可能重复=================== this.articleList = [...this.articleList, ...res.data.results] // console.log(res.data) // 如果不关闭,底部会一直处于加载状态 this.loading = false // 如果接收回来的数据没有时间戳,说明没有数据了,就不需要发起请求了 if (res.data.results.pre_timestamp === null) { this.finished = true } }, ``` 解决: 1.直接用index来用作key值了(简单粗暴,但是这种方法不可取) 2.在下滑触发加载的时候先判断列表里是否有数据,有数据再进行加载(没有数据说明是刚加载页面,此时在created中加载了,就不需要在onload里加载,否则两次加载会向articleList列表里存放相同的数据,导致key值相同,这样就可以解决啦!!! ``` onLoad () { if (this.articleList.length > 0) { this.getArticleList() } }, ``` #### 6.文章反馈 **注意:**文章反馈这里需要身份认证,发起请求时报错401是因为token过期了,需要重新登录(一开始把headers写成了header,导致一直报错401,以为是token过期,重新登录也没有用,后来看到header想着改成headers,没想到真是这个错误) ``` // 对文章不感兴趣 export const dislikeAPI = ({ target }) => axios({ url: '/v1_0/article/dislikes', method: 'POST', data: { target: target }, headers: { Authorization: 'Bearer ' + store.state.token } }) ``` # 2022/8/13 ## 八、频道管理 #### 1.请求(响应)拦截器 ``` // 请求拦截器 axios.interceptors.request.use(config => { // 发起请求之前干点什么 // 判断Authorization中是否有token,本地有token但是Authorization没有的话就进行赋值 if (getToken() && config.headers.Authorization === undefined) { config.headers.Authorization = `Bearer ${getToken()}` } return config }, err => { return Promise.reject(err) }) ``` ``` // 响应拦截器 axios.interceptors.response.use(config => { // 对响应的数据做点什么 return config }, err => { // 对响应的错误做点什么 // 返回状态码为401 说明身份未认证 if (err.response.status === 401) { store.commit('setToken', '') router.push('/login') } return Promise.reject(err) }) ``` #### 2.频道的添加与删除 **!!!坑:**添加频道时由于是直接往**this.channelList**中push的,在调用添加频道接口时由于需要参数需要一个数组,并且每一项为一个对象,形式为{ id: 频道id,seq: 频道的序列号},不能直接修改**this.channelList**,需要使用深拷贝的方法来做调整。 添加和删除直接调用一个接口就行(更新接口) ``` // 把更新方法提取,分别调用 async updateChannel () { // 这里不能直接赋值,涉及到浅拷贝问题,原来的channelList会受到影响,所以要用深拷贝的方法去解决 // const newArr = this.channelList /** 深拷贝,要让对象和原数组脱离关系 */ const newArr = this.channelList.filter(item => item.id !== 0) const resultList = newArr.map((item, index) => { const newObj = { ...item } // 添加seq序列 newObj.seq = index + 1 // 把name属性删掉 delete newObj.name return newObj }) // console.log(resultList) // 调用接口更新频道 await updateChannelAPI(resultList) }, ``` #### 3.进入频道 ![channel](C:\Users\29809\Desktop\webCode\project\itcast-toutiao\images\channel.png) 若是编辑状态下,每个频道右上角会显示叉号,此时点击我的频道--->下的某个频道的话调用删除接口,点击添加更多频道----->下的频道的话是添加某个频道到当前用户下。 若不是编辑状态,点击我的频道---->下的某个频道,会关闭当前弹层,显示这个对应频道的文章。 这里通过isEdit来判断,进入频道的话会获取这个频道对象,传递给父组件,父组件会更新当前频道的id,自会调用对应频道的接口 ``` // 删除频道,还是调用更新接口,因为点击的是van-col标签,只能通过isEdit来判断是删除还是进入频道 deleteChannelFn (obj) { if (this.isEdit && obj.id !== 0) { this.$emit('deleteChannel', obj) } else { this.$emit('updateChannelId', obj) } }, ``` ## 九、文章搜索 #### 1.自定义指令 ``` // 封装指令 import Vue from 'vue' export default { install () { Vue.directive('fofo', { inserted (el) { // 需要找到van-search组件中的input 输入框,焦点只能聚焦到input框中 el = el.querySelector('input') el.focus() } }) } } ``` 进行注册: ``` // 注册全局指令 import directiveobj from '@/utils/directives' Vue.use(directiveobj) ``` 绑定到search搜索框中 ``` ``` #### 2.防抖 要根据输入框的关键词进行搜索,但是需要对输入框进行防抖处理 ``` // 监听输入框的输入事件,需要做防抖处理 inputFn () { // 判断是否有定时器,有则清除定时器,没有的话不会执行 && 之后的语句 this.timer && clearTimeout(this.timer) this.timer = setTimeout(() => { if (this.keyValue.length === 0) return console.log(this.keyValue) }, 500) } ``` 什么是防抖? - 降低逻辑代码触发频率 - 只要最后一次执行即可 - 如果中间再次触发, 重新弄个计时器倒计时 #### 3.输入关键词,显示搜索列表 **重要知识点**:实现文字后面的三个省略号 ``` // 实现省略号 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; ``` **联想菜单?** - 当输入关键字, 提示你要搜索的关键词 **关键词高亮:** 主要思想就是把关键词(target)拿出来,准备一个标签(${target})把关键词放进去,然后给一个颜色,最后把这个标签替换为原来的关键词 ``` /** * @originStr :原来字符串 * @target :关键字 * @return :替换后的完整字符串 */ lightFn (originStr, target) { if (!originStr) return // return originStr.replaceAll(target, `${target}`) // 使用变量作为正则表达式 const reg = new RegExp(target, 'ig') // i忽略大小写 g全局匹配 return originStr.replace(reg, (match) => { return `${match}` }) } ``` 4.历史记录的本地保存 使用侦听器去监听localHistory数组的变化,并且做好数组去重后再保存到本地 ``` watch: { // 历史数组的改变 localHistory: { deep: true, handler () { // 去重 set类数组转化为数组 this.localHistory = [...new Set(this.localHistory)] // 立即覆盖式的保存到本地 localStorage.setItem('history', JSON.stringify(this.localHistory)) } } }, ``` **!!!关于点击了清空历史记录的小图标后localStorage中的值清空了,但是页面并未刷新** 的 # 2022/8/14 ## 十、搜索结果页 #### 1.文章列表渲染 - 这里直接复用 ArticleItem 组件就可以了 ,但是在搜索结果页面的文章没有那个小叉号,可以给ArticleItem 一个布尔值来控制小叉号的显示与隐藏,默认值为true,这样就不用再ArticleList 中再传值了。 - 直接传值 isShow=false。 ``` ``` #### 2.获取文章列表 - 获取回来的数据为空的话让 this.finished 为true,页面底部会显示 "没有更多了",就不会在请求数据了。 - 合并数据是将请求下一页的数据存放到 searchResultList 数组中。 ``` // 文章搜索结果列表 async getSearchResult () { this.loading = true const { data: res } = await searchResultAPI({ q: this.$route.params.keywords, page: this.page }) // 没有数据了 if (res.data.results.length === 0) { this.finished = true return } // 合并数据 this.searchResultList = [...this.searchResultList, ...res.data.results] this.loading = false }, ``` #### 3.上拉加载 - 上拉加载就是直接请求下一页的数据 ``` // 上拉加载 onLoad () { this.page++ this.getSearchResult() }, ``` #### 4.下拉刷新 - 下拉刷新需要将当前存放文章列表的数据清空,再将重新获取的数据存放进去 - page++ 是刷新获取下一页的数据 ``` // 下拉刷新 onRefresh () { // 清空列表数据 this.searchResultList = [] // 让刷新加载为false this.refreshing = false // 页码加1 this.page++ // 重新加载数据 this.getSearchResult() this.finished = false // 将 loading 设置为 true,表示处于加载状态 this.loading = true } ``` #### 5.图片403问题 图片防盗链 有时候报错403状态码, 发现都是图片路径请求问题 **原因:** - 在自己的网页里, img的src地址是别人服务器的, 他们做了限制 - http请求头中有一个referrer字段,用来表示发起http请求的源地址信息 - 服务器端在拿到这个referrer值后判断请求是否来自本站,若不是则返回403,从而实现图片的**防盗链**。上面出现403就是因为,请求的是别人服务器上的资源,但把自己的referrer信息带过去了,被对方服务器拦截返回了403 **解决:** 在前端可以通过meta来设置referrer policy(来源策略),referrer设置成`no-referrer`,发送请求不会带上referrer信息,对方服务器也就无法拦截了 在index.html中, 添加meta信息, 不携带referrer给第三方服务器 ``` ``` #### 6.图片404问题 请求的图片不存在,后端已经删除该图片或者是其它处理(总之请求图片的路径不存在) 加载失败则直接显示 “加载失败”,使用image组件 ``` ``` ## 十一、文章详情页 #### 1.跳转到详情页 传参 art_id **有两种方式传参跳转路由:** - 使用query的方式 路由: ``` { path: '/detail', component: ArticleDetail } ``` 跳转: ``` this.$router.push(`/detail?id=${art_id}`) ``` - 使用params的方式 路由: ``` { path: '/detail/:id', component: ArticleDetail } ``` 跳转: ``` this.$router.push(`/detail/${art_id}`) ``` #### 2.点击文章跳转事件绑定 - 在SearchResult.vue里的文章单元格article-item组件上点击跳转 给组件绑定原生的点击事件 **@click.native (使用修饰符)** ``` ``` #### 3.事件冒泡 首页每项文章列表后面的叉号x, 点击也跳转详情了, **原因:** 事件冒泡 **解决:**在src/components/ArticleItem, 使用@click.stop阻止叉号x的事件冒泡 ``` ``` #### 4.获取路由的参数 query的参数: ``` const id = this.$route.query.id ``` params的参数: ``` const id = this.$route.params.id ``` !!!记得这里 **是 route** **不是router** #### 5.渲染文章内容v-html ```
``` #### 6.关注与取关作者 响应状态码 400 请求参数错误 **注意坑**:点击关注时报错400,先看一下网络发送的消息(自己不能关注自己),找了半天问题,参数没有错,换一个账号登录就行了。 #### 7.点赞和取消点赞 注意点:和关注与取关作者一样 # 2022/8/15 ## 十二、文章评论 #### 1.textarea和input聚焦指令 ``` // 封装指令 import Vue from 'vue' export default { install () { Vue.directive('fofo', { inserted (el) { // 这里必须大写 if (el.nodeName === 'TEXTAREA' || el.nodeName === 'INPUT') { el.focus() } else { const theInput = el.querySelector('input') const theArea = el.querySelector('textarea') // 需要判断是否为空才能聚焦 否则会报错 if (theInput) theInput.focus() if (theArea) theArea.focus() } } }) } } ``` #### 2.收集评论总数 使用Badge组件 绑定content属性 最大值为99 ``` ``` ![pinglun](/images/pinglun.png) #### 3.点击评论图标滚动窗口 ``` // 评论滑动 async moveComment () { // 真实DOM都在document,所以即使不在一个vue组件中也是可以获取的 // 获取点赞的那个DOM // scrollIntoView 进入视口 document.querySelector('.bottom-box').scrollIntoView({ behavior: 'smooth' }) }, ``` #### 4.获取输入框值 **!!!大坑:**点击提交按钮后会失去焦点,textarea输入框会销毁,想要获取输入框的值就必须先拿到值再销毁,这里就可以使用setTimeout(它会在最后执行) ``` // 评论失去焦点,这里必须拿到输入框的值后才能让它失去焦点,否则失去焦点后DOM销毁,拿不到输入框的值 blurFn () { setTimeout(() => { this.showComment = true }, 0) }, ``` #### 5.上拉加载更多评论 **!!!又是坑:** 记得使用List组件时绑定 :immediate-check="false",让页面第一次加载的时候不会监听load事件,否则第一次加载,在created中获取一次数据,load事件中又请求一次,两次的数据一样(因为请求回来的数据会合并到 commentList 数组中),会导致key相同,报错 : Duplicate keys detected: '7129342d-bb4e-487e-af82-8e27d4af5a80'. This may cause an update error. ``` ``` 我是在发起请求接口之前让this.loading为true,请求完之后再让 this.loading 为false,这样在其它地方就不用写 this.loading 了 **注意:**每次请求回来后,判断是否有数据,没有数据的话让this.finished为true ``` // 获取当前文章的评论 async getComments () { this.loading = true const { data: res } = await getCommentsAPI({ id: this.$route.query.id, offset: this.offset }) console.log(res) // 合并 this.commentList = [...this.commentList, ...res.data.results] this.total_count = res.data.total_count if (res.data.results.length === 0) this.finished = true // 将最后一条评论的id保存 this.offset = res.data.last_id this.loading = false }, ``` #### 6.发起评论报错 **报错:**Duplicate keys detected: '7129342d-bb4e-487e-af82-8e27d4af5a80'. This may cause an update error. ``` // 发布评论 async sendComment () { const { data: res } = await commitCommentAPI({ target: this.$route.query.id, content: this.comText.trim() }) // console.log(res) this.offset = null // 发起请求,重新获取评论 this.getComments() this.comText = '' }, ``` (这里没想明白,我在发布评论后重新调用接口获取数据,再进行渲染页面没毛病啊) **原因:**(后来想明白了哈哈哈),此时commentList数组里面是有数据的,我再请求回来的数据再放进去肯定会重复呀,只要在请求之前将数组置空,将偏移量offset设为null,再一次发起请求就OK了 **方法一:**在请求之前将数组置空,将偏移量offset设为null,再一次发起请求 ``` // 发布评论 async sendComment () { const { data: res } = await commitCommentAPI({ target: this.$route.query.id, content: this.comText.trim() }) console.log(res) // 不去重新获取接口,直接操作数组 this.commentList = [] this.offset = null this.getComments() // this.commentList.unshift(res.data.new_obj) // this.total_count++ this.comText = '' }, ``` **但是:**将数组置空,页面刷新的话数组就只有10条评论了,要加载更多评论就需要重新发起请求(感觉这个方法也不是很好) **方法二:**将重新发起请求获取过来的第一条评论添加到数组中,this.total_count+1,这里重新写一下调用接口,没有直接去调用 this.getComments() ``` // 发布评论 async sendComment () { // 发布评论 await commitCommentAPI({ target: this.$route.query.id, content: this.comText.trim() }) // 获取新的评论 const { data: res1 } = await getCommentsAPI({ id: this.$route.query.id, offset: null, limit: 1 }) // 添加到数组 this.commentList.unshift(res1.data.results[0]) this.total_count++ this.comText = '' }, ``` **方法三:**不去重新发起请求,直接将发布的评论信息添加到数组中,发布评论后会返回这条评论的数据,将这个数据直接加到数组就OK了 ``` // 发布评论 async sendComment () { const { data: res } = await commitCommentAPI({ target: this.$route.query.id, content: this.comText.trim() }) // console.log(res) // 不去重新获取接口,直接操作数组 this.commentList.unshift(res.data.new_obj) this.total_count++ this.comText = '' }, ``` (这里都是自己遇到的,通过这个问题,我理清楚代码的逻辑,想到了解决办法,又激发我学前端的兴趣了嘻嘻。。) **不过最推荐方法三,因为不用重新发起请求,减小性能。**。 #### 7.发表评论后滑动到第一条评论 直接在发布评论完后调用一下this.moveComment()就OK了 # 2022/8/16 ## 十三、个人中心 #### 1.基本信息 /v1_0/user ``` // 获取用户基本信息 export const getUserAPI = () => axios({ url: '/v1_0/user' }) ``` 页面展示 ![user](/images/user.png) #### 2.编辑资料——数据回显 调用接口:/v1_0/user/profile 主要就是为了获取生日 ``` // 获取用户个人资料(为了获取生日) export const getUserInfoAPI = () => axios({ url: '/v1_0/user/profile' }) ``` #### 3.修改头像 使用表单对象提交数据 PATCH:局部更新 ``` // file选择框的点击事件 async onFileChange (e) { // 如果用户点击了取消上传图片,直接return if (e.target.files.length === 0) return // 创建FormData表单对象 const fd = new FormData() // 必须为photo fd.append('photo', e.target.files[0]) const { data: res } = await updatePhotoAPI(fd) // console.log(res) // 替换头像 没有重新请求刷新 效果会更好 this.userObj.photo = res.data.photo }, ``` #### 4.修改姓名 对话框:Dialog 自动聚焦:使用我们自定义指令v-fofo 第一次点击的时候可以自动聚焦,但是关闭再点击就不能聚焦了,因为我们写的自定义指令v-fofo是写在insert函数中,只用插入DOM的时候才会获取焦点,所以我们需要再写一个update函数的v-fofo指令 ``` // 页面更新DOM时自动聚焦 update (el) { // 这里必须大写 if (el.nodeName === 'TEXTAREA' || el.nodeName === 'INPUT') { el.focus() } else { const theInput = el.querySelector('input') const theArea = el.querySelector('textarea') // 需要判断是否为空才能聚焦 否则会报错 if (theInput) theInput.focus() if (theArea) theArea.focus() } } ``` #### 5.修改出生日期 使用弹层来包裹 van-datetime-picker ``` ``` 时间格式化: ``` export const formatDate = (targetTime) => { return dayjs(targetTime).format('YYYY-MM-DD') } ``` ## 十四、小思机器人聊天 #### 1.页面样式 ![chat](/images/chat.png) #### 2.对话消息布局 小三角形的布局:使用伪元素布局,给相邻的两条边一个边框,再使用transform:rotate旋转 - **新知识点(对我来说):** word-break: break-all; 表示强行换行 ``` word-break: normal; //默认的的换行规则 word-break: break-all; //表示强行换行,意思就是允许任意非文本间(比如网址类型的等)的单词断行 word-break : keep-all; //也表示换行,但不允许文本中的单词换行,只能在半角空格或连字符处换行 ``` 在这三个属性值中,break-all这个属性值是所有浏览器都支持,但是 keep-all就不这样了,虽然有一定的发展和进步,但目前移动端还**不适合**使用word-break : keep-all。 - **新知识点**:overflow-y:scroll ; 表示超出出现滚动条 ​ ``` .chat-pao{ vertical-align: top; display: inline-block; min-width: 40px; max-width: 70%; min-height: 35px; font-size: 15px; border: 0.5px solid #c2d9ea; border-radius: 4px; position: relative; padding: 0 10px; background-color: #e0effb; word-break: break-all; 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-color: #e0effb; } } .left{ text-align: left; .chat-pao{ margin-left: 15px; margin-right: 0; &::before{ left:-5px; transform: rotate(-135deg); } } } .right{ text-align: right; .chat-pao{ margin-right: 15px; margin-left: 0; text-align: left; &::before{ right: -5px; transform: rotate(45deg); } } } ``` #### 3.webScoket使用步骤 (1)下载客户端scoket,内部对websocket进行了封装 ``` npm i socket.io-client@4.0.0 ``` (2)引入 ``` // 导入 socket.io-client 包 import { io } from 'socket.io-client' // 定义变量,存储 websocket 实例 let socket = null ``` (3)创建socket实例,在created中调用 地址:ws://geek.itheima.net ``` // 建立连接 connectSocket () { this.socket = io('ws://geek.itheima.net', { query: { token: getToken() }, transports: ['websocket'] }) }, ``` (4)监听是否连接成功 ``` created() { // 建立连接的事件 socket.on('connect', () => { console.log('与服务器建立了连接') }) } ``` (5)接收消息,在created中调用 ``` // 获取服务端发送的消息 getMessage () { // 接收消息 this.socket.on('message', data => { // 把服务器发送过来的消息,存储到list数组中 this.list.push({ name: 'xs', message: data.msg }) }) }, ``` (6)发送消息,点击发送时调用 ``` // 发送消息 sendMessage () { if (!this.value.trim()) return // 把我的消息添加到数组中 this.list.push({ name: 'me', message: this.value }) // 发送给服务端 this.socket.emit('message', { msg: this.value, timestamp: new Date().getTime() }) // 清空文本框的内容 this.value = '' } ``` #### 4.滚动到底部 querySelectorAll是获取所有的聊天消息结点(一开始写成了querySelector,找半天没发现为什么不滑动) ``` // 自动滚动到聊天的最底部 scrollToBottom () { const chatItem = document.querySelectorAll('.chat-item') const lastItem = chatItem[chatItem.length - 1] lastItem.scrollIntoView({ behavior: 'smooth' }) }, ``` 在发送消息和接收消息之后使用this.$nextTick来调用这个方法 ``` this.$nextTick(() => { this.scrollToBottom() }) ``` #### 5.头像获取 使用vuex来保存当前用户的信息(主要获取头像),因为会在多处使用到 **注意:**个人中心和小思聊天页面时两个路由,不是父子组件的关系,不能使用父子传值,可以使用路由传值 在用户个人中心获取数据的时候直接把用户数据对象保存到state中 ``` state: { user: {} }, mutations: { SET_USER (state, userObj) { state.user = userObj } } ``` ``` ``` 但是我发现,在用户个人信息页面点进小思同学时头像是正常的,点击刷新后state中的user对象没有了,想不明白这样用store有什么用 # 2022/8/17 ## 十五、优化 #### 1.组件缓存 使用keep-alive 在一级路由和二级路由下分别使用keep-alive组件包裹 ``` ``` **但是有个致命问题:** 因为路由被缓存了,在我搜索文章时,不管搜索的关键词是什么,都会保存第一次搜索的结果,点击进入详情页也是第一次进入加载的文章详情 **所以:**需要使用exclude排除文章详情页和搜索页的缓存 **注意:**这些组件是一级路由,因此exclude要写在一级路由下 ``` ``` #### 2.头像更新 将用户信息存放在store中,使用到头像的地方直接绑定 $store.state.user.photo ``` // 设置头像 mutations: { SET_USERPHOTO (state, photo) { state.user.photo = photo } } ``` 在编辑页面和用户信息页面,小思同学聊天页面的头像同一使用state中user.photo头像 (上面十四下的第5点,头像获取,其实是解决了问题的) 3.用户的姓名和生日更新 和头像更新一样,在使用到的地方直接使用state中user下的属性,更新的时候调用set方法去更新store中的user ``` // 修改名字 mutations: { SET_USERNAME (state, name) { state.user.name = name } } ``` #### 3.文章详情——代码高亮 (1)下载包 ``` npm i highlight.js -D ``` (2)在main.js中引入 ``` import 'highlight.js/styles/default.css' // 代码高亮的样式 ``` #### 4.文章详情Loading效果 使用loading组件,并使用v-if来判断文章对象是否获取成功,loading和文章内容互斥显示 ```
文章疯狂加载中...
``` v-if判断对象: 方法一:可以判断对象中的某个属性是否为undefined ``` v-if="articleDetail.title===undefined" ``` 方法二:判断对象的长度是否为0 ``` v-if="Object.keys(obj).length===0" ``` 方法三:将对象转化为字符串判断是否为空对象 ``` v-if="JSON.stringfy(obj)==={}" // 返回true ``` #### 5.图片懒加载 方法一:直接在van-image组件上使用lazy-load属性,插槽部分用来显示请求错误时的404图片 ``` ``` 方法二:如果img标签使用的是原生的,可以使用vant组件库中的LazyLoad懒加载指令 注册: ``` // 注册时设置`lazyComponent`选项 Vue.use(Lazyload, { lazyComponent: true, }); ``` 使用:这种方式就像组件正常使用就好了 ``` ``` 要是直接在img标签上使用指令,直接添加v-lazy就OK了 ``` ``` #### 6.保存滚动条位置 !!!掌握两个新学的生命周期: 这两个生命周期必须配合keep-alive组件使用,也就是页面中的组件使用了组件缓存就有这两个生命周期 activated()组件激活(相当于created) deactivated()组件销毁(相当于destoryed) 前提:组件缓存,切走了就是失去激活生命周期方法触发 无组件缓存,被切走了,destoryed销毁生命周期方法 (1)路由切换保存滚动条 给/layout/home路由添加meta,scrollTop用来存放滚动条的位置 ``` children: [ { path: 'home', component: Home, meta: { isRecord: true, scrollTop: 0 } }, ``` 监听scroll的滚动 ``` // 切换回来 activated () { // 只用滚动了才会触发 window.addEventListener('scroll', this.scrollFn) document.documentElement.scrollTop = this.$route.meta.scrollTop }, ``` scrollFn方法 ``` // 监听网页滚动条的滚动事件 scrollFn () { // 把当前滚动条的位置记录下来,等下次切换回来的时候就显示到该位置 this.$route.meta.scrollTop = document.documentElement.scrollTop } ``` 销毁 ``` deactivated () { window.removeEventListener('scroll', this.scrollFn) } ``` (2)频道的切换保存滚动条 在data中声明一个对象,用来保存每个频道的滚动条位置 ``` data(){ return { scrollTopObj: {} // 存放对应频道的滚动条位置,key值为频道id } } ``` 监听网页滚动条的滚动事件,将滚动距离保存到对应频道id中 ``` // 监听网页滚动条的滚动事件 scrollFn () { // 把当前滚动条的位置记录下来,等下次切换回来的时候就显示到该位置 this.$route.meta.scrollTop = document.documentElement.scrollTop // 保存到scrollTopObj对象中 this.scrollTopObj[this.channelID] = document.documentElement.scrollTop } ``` 给tabs绑定change事件 ``` ``` tab切换回来,拿到当前频道id,获取滚动距离 注意:这里为什么要使用this.$nextTick() 因为tabs切换DOM是要进行加载的,如果DOM还没加载完成就去滚动的话是不成功的 ``` // tab 标签页改变时触发 tabChangeFn () { // 要等到DOM加载完成才执行,否则每次刚切换tab的时候高度是为0的 this.$nextTick(() => { console.log(this.scrollTopObj[this.channelID]) document.documentElement.scrollTop = this.scrollTopObj[this.channelID] }) }, ``` #### 7.Storage的封装 方便以后要使用sessionStorage的话直接在这里改变就可以了 ``` // 封装本地存储方式 // 整个项目使用localStorage,sessionStorage,cookie // 只要改变这里就可以了 export const setStorage = (key, value) => { localStorage.setItem(key, value) } export const getStorage = (key) => { return localStorage.getItem(key) } export const removeStorage = (key) => { localStorage.removeItem(key) } ``` #### 8.Notify组件的封装 原来使用的方法: ``` Notify({ type: 'danger', message: '账号或密码错误' }) ``` 如果我们想要用自己的弹窗方式:(引用人家的Toast,嘻嘻) ``` // 基于vant进行二次封装 // 可以封装使用自己想要的样式 import { Toast } from 'vant' export const myNotify = ({ type, message }) => { if (type === 'success') { Toast.success(message) } if (type === 'warning') { Toast.fail(message) } } ``` #### 9.去掉console.log **方法一:**(我用了这种方法没有生效) 1)在项目根目录下新建两个文件夹 .env.development(开发环境变量) ``` NODE_ENV=development ``` .env.production(生产环境变量) ``` NODE_ENV=production ``` 2)封装函数 ``` if (process.env.NODE_ENV !== 'development') { // process是Node环境全部变量, 运行时根据敲击的命令不同, 脚手架会取环境变量给env添加属性和值 console.log = function () {} console.error = function(){} console.dir = function(){} } ``` - 我们可以在这2个文件里定义不同的属性和值, 来区分线上和线下环境不同的值 - 而且可以让代码自适应在不同环境, 自动选择对应值来使用 3)在main.js中引入 ``` import '@/utils/console' ``` **方法二:** (生效) 在vue.config.js中配置 ``` chainWebpack (config) { config.optimization.minimizer('terser').tap((args) => { args[0].terserOptions.compress.drop_console = true return args }) } ``` ## 十六、知识点 #### 1.前端传参格式 | Content-Type值 | 请求体值 | 解释 | | --------------------------------- | --------------------- | ------------ | | application/json; charset=UTF-8 | { name:'zs', age:18} | JSON字符串 | | multipart/form-data; | | form表单对象 | | application/x-www-form-urlencoded | key=value&key=value | 查询字符串 | | text/plain | 你好啊 | 普通文本 | #### 2.跨域 当前网页的URL和ajax请求的地址只要 协议、域名、端口 有一种一个不相同就会跨域 解决跨域问题: **方法一**:后端使用cors 前端什么也不用做 (1)下载cors ``` npm i cors ``` (2)引入 ``` const cors=require('cors') ``` (3)配置中间件 ``` app.use(cors()) ``` **方法二**:jsonp方法 - 前端和后端同时支持 前端用script+src属性, 发送函数名给后台, 同时准备好同名的函数, 准备接收数据 后端返回的字符串一定用方法名(数据字符串)格式返回, 到script标签中执行 调用函数名, 并传递数据 - 例子代码 ``` ``` 但是这种方式**只能使用get**请求 **方法三**:proxy代理转发 在vue.config.js中配置 ``` devServer: { proxy: { // http://c.m.163.com/nc/article/headline/T1348647853363/0-40.html '/api': { // 请求相对路径以/api开头的, 才会走这里的配置 target: 'http://c.m.163.com', // 后台接口域名 changeOrigin: true, // 改变请求来源(欺骗后台你的请求是从http://c.m.163.com) pathRewrite: { '^/api': '' // 因为真实路径中并没有/api这段, 所以要去掉这段才能拼接正确地址转发请求 } } } } ``` #### 3.npm 包管理 (1)上传包 a)新建文件夹,并初始化包环境 ``` npm init ``` 得到package.json文件 b)把要发布的js文件拿过来 c)创建index.js(package.json记录入口文件名字统一),然后把其他模块导进来并导出去 ``` // 包的唯一出口 export * from './date' // 基于dayjs的时间处理 export * from './directives' // 基于vue的自定义指令 export * from './storage' // 原生js封装storage存储 export * from './str' // 原生js封装字符串高亮 ``` #### 4.打包上线 运行命令打包 ``` npm run build ``` **问题:**打包后运行出现bug 打包后搜索那里点击历史或者点击匹配项就卡住了,页面直接无了, 然后我看了页面的[DOM](直接没了 **解决:** 将src/views/article/Search文件中的watch侦听器删除,这个是监听localHistory数组的变化,在打包后页面卡死就是因为这里死循环了 ``` watch: { // 历史数组的改变 localHistory: { deep: true, handler () { // 去重 set类数组转化为数组 this.localHistory = [...new Set(this.localHistory)] // 立即覆盖式的保存到本地 setStorage('history', JSON.stringify(this.localHistory)) } } }, ``` 在按下回车或点击某个搜索的匹配项时,再将该搜索值保存到历史数组中,然后数组去重保存到localStorage中 ``` // 按下回车跳转传参, 其实在手机里面是没有回车的,它这里相当于是点击键盘上的搜索按钮 searchFn () { if (this.keyValue.length > 0) { this.localHistory.push(this.keyValue) // 去重 set类数组转化为数组 this.localHistory = [...new Set(this.localHistory)] // 立即覆盖式的保存到本地 setStorage('history', JSON.stringify(this.localHistory)) // 跳转后, 并未保存到本地(==原因: 先跳转了, watch还未来的及执行==) setTimeout(() => { this.$router.push(`/search/${this.keyValue}`) }, 0) } }, ``` #### 5.将项目部署到Gitee上