移动端 rem 适配:
-
lib-flexible用于设置 rem 基准值
-
postcss-pxtorem是一款 postcss 插件,用于将单位转化为 rem
安装:
yarn add amfe-flexible
在 main.js
中加载执行该模块:
import 'amfe-flexible'
安装:
npm install postcss-pxtorem@5.1.1 -D
在项目根目录中创建 .postcssrc.js
或 postcss.config.js
文件
配置只适用于vant内部的相关组件内容,我们自己书写的样式,并不是按照这个,我们希望设计图是多少px,就写多少px,故而修改内容如下:
module.exports = {
plugins: {
// postcss-pxtorem 插件的版本需要 >= 5.0.0
'postcss-pxtorem': {
rootValue({ file }) { // 如果是vant的就按照375 尺寸, 如果是其他的就是按照750
return file.indexOf('vant') !== -1 ? 37.5 : 75; // rootValue 的值一般是 设计稿 1/10
},
propList: ['*'],
},
},
};
配置路由:
在 src/router/index.js
中:
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
// 路由映射数组
const routes = [
{
path: '/login',
name: 'login',
component: () => import('@/views/login') // 路由的懒加载
}
]
// 实例化路由对象
const router = new VueRouter({
routes
})
export default router // 暴露出去
封装axios请求:
创建 src/utils/request.js:
/**
* 封装 axios 请求模块
*/
import axios from 'axios'
// 创建一个axios实例对象
const request = axios.create({
baseURL: 'http://toutiao.itheima.net' // 基础路径
})
// 配置请求拦截器
// 配置响应拦截器
// 暴露出去
export default request
创建 src/api/user.js:
请求登录:
import request from '@/utils/request'
// 实现登录的接口
export const login = data => {
return request({
method: 'POST',
url: '/v1_0/authorizations',
data
})
}
优化封装本地存储操作模块:
创建 src/utils/storage.js
模块:
// 封装优化处理token
// 存储数据
export const setItem = (key, data) => {
if (typeof data === 'object') {
data = JSON.stringify(data)
}
window.localStorage.setItem(key, data)
}
// 获取本地存储的数据
export const getItem = (key) => {
const data = window.localStorage.getItem(key)
try {
return JSON.parse(data)
} catch (err) {
return data
}
}
// 删除数据
export const removeItem = (key) => {
window.localStorage.removeItem(key)
}
使用的时候可以直接:
import { setItem, getItem } from '@/utils/storage'
state: {
// 存储当前登录的用户信息(token等数据)
// user: JSON.parse(window.localStorage.getItem(TOKEN_KEY))
user: getItem(TOKEN_KEY)
},
mutations: {
setUser(state, data) {
state.user = data
// 本地存储, 防止刷新丢失
// window.localStorage.setItem(TOKEN_KEY, JSON.stringify(state.user))
setItem(TOKEN_KEY, state.user)
}
},
优化设置 Token, 配置请求拦截器:
在 src/utils/request.js
中添加拦截器统一设置 token:
import store from '@/store'
// 配置请求拦截器
request.interceptors.request.use(function (config) {
// config :本次请求的配置对象
// config 里面有一个属性:headers
const user = store.state.user
if (user && user.token) {
config.headers.Authorization = `Bearer ${user.token}`
}
return config
}, function (error) {
return Promise.reject(error)
})
请求数据使用的时候: 就可以不用写请求头了
// 获取个人用户信息
export const getUserInfo = () => {
return request({
method: 'GET',
url: '/v1_0/user',
// headers: {
// Authorization: `Bearer ${store.state.user.token}`
// }
})
}
点击返回上一页: @click="$router.back()"
点击返回指定页面路径: @click="$router.push('/login')"
在CSS里面使用@路径的表达: src="~@/assets/mobile.png"
插槽的两个使用方式:
<template #left-icon>
<i class="toutiao toutiao-shouji"></i>
</template>
// 一样的
<i slot="icon" class="toutiao toutiao-lishi"></i>
展示频道列表:
思路:
- 第1步:
api
目录下面文件封装请求方法 - 第2步:data里面定义变量用于存储数据
- 第3步:methods里定义获取数据方法
- 3.1 页面里面导入这个方法
- 3.2 methods方法里面调用方法发送请求
- 3.3 请求错误处理
- 3.4 请求成功赋值data里面变量
- 第4步:created里面调用这个方法
- 第5步: 渲染数据
解决滚动条位置问题: 不设置高度的话, 会共用body的高度, 实现不了滚动条记住位置的效果, 所以要给大盒子一个高度.article-list:
<style lang='less' scoped>
.article-list {
// 百分比单位是相对于父元素的
// height: 100%;
// 视口(在移动端是布局视口)单位:vw 和 vh,不受父元素影响
// 1vw = 视口宽度的百分之一
// 1vh = 视口高度的百分之一
height: 79vh;
overflow-y: auto; // 滚动条
}
</style>
List 组件通过 loading 和 finished 两个变量控制加载状态, 当组件初始化或滚动到到底部时,会触发 load 事件并将 loading 设置成 true,此时可以发起异步操作并更新数据,数据更新完毕后,将 loading 设置成 false 即可。 若数据已全部加载完毕,则直接将 finished 设置成 true 即可。
load 事件
:- List 初始化后会触发一次 load 事件,用于加载第一屏的数据。
- 如果一次请求加载的数据条数较少,导致列表内容无法铺满当前屏幕,List 会继续触发 load 事件,直到内容铺满屏幕或数据全部加载完成。
loading 属性
:控制加载中的 loading 状态- 非加载中,loading 为 false,此时会根据列表滚动位置判断是否触发 load 事件(列表内容不足一屏幕时,会直接触发)
- 加载中,loading 为 true,表示正在发送异步请求,此时不会触发 load 事件
finished 属性
:控制加载结束的状态- 在每次请求完毕后,需要手动将 loading 设置为 false,表示本次加载结束
- 所有数据加载结束,finished 为 true,此时不会触发 load 事件
try {
// 1. 发送请求获取数据
const { data } = await getArticles({
channel_id: this.channel.id,
timestamp: this.pre_timestamp || Date.now(), // 时间戳,请求新的推荐数据传当前的时间戳,请求历史推荐传指定的时间戳
with_top: 1,
});
// if (Math.random() > 0.5) {
// throw new Error("错误");
// } 错误的测试代码
console.log(data);
// 2. 将数据追加到data 数组中保存
const { results } = data.data;
this.list = [...this.list, ...results];
// 3. 将loading设置false
this.loading = false;
// 将时间戳更新实现翻页请求历史数据
this.pre_timestamp = data.data.pre_timestamp;
// 4. 判断数据是否加载完成
if (data.data.pre_timestamp === null) {
// 数据已经加载完成
this.finished = true;
}
} catch (error) {
this.loading = false;
this.error = true;
this.$toast("获取当前频道的文章失败!");
}
},
下拉刷新: isLoading: false, // 下拉刷新加载状态 使用van-pull-refresh组件
处理相对时间:
安装:
npm i dayjs
// 导入语言包
import 'dayjs/locale/zh-cn'
// 导入相对时间的插件
import relativeTime from 'dayjs/plugin/relativeTime'
// 使用中文语言包
dayjs.locale('zh-cn')
// console.log(dayjs().format('YY-MM-DD'));
// 注册相对时间的插件
dayjs.extend(relativeTime)
// 定义全局格式时间的过滤器
Vue.filter('relativeTime', time => {
return dayjs().to(dayjs(time))
})
使用的时候用 |
展示推荐频道列表:
所有频道列表 - 我的频道 = 剩余推荐的频道
实现过程所以一共分为两大步:
- 获取所有频道
- 基于所有频道和我的频道计算获取剩余的推荐频道
方法一:
computed: {
// 推荐频道=所有-我的
recommentChannels() {
let arr = []; // 推荐频道
this.allChannels.forEach((channel) => {
let ret = this.myChannels.find((myChannel) => {
return myChannel.id === channel.id;
});
// ret true 用户频道
// ret false 推荐频道
if (!ret) {
arr.push(channel);
}
});
return arr;
}
}
方法二:
computed: {
return this.allChannels.filter((channel) => {
return !this.myChannels.find((myChannel) => {
return myChannel.id === channel.id;
});
});
}
然后模板绑定:
<van-grid-item
class="grid-item"
v-for="(channel, index) in recommendChannels"
:key="index"
icon="plus"
:text="channel.name"
/>
</van-grid>
添加频道:
思路:
- 给推荐频道列表中每一项注册点击事件
- 获取点击的频道项
- 将频道项添加到我的频道中
将当前点击的频道项从推荐频道中移除- 不需要删除,因为我们获取数据使用的是计算属性,当我频道发生改变,计算属性重新求值了
编辑频道:
思路:
- 给我的频道中的频道项注册点击事件
- 在事件处理函数中
- 如果是编辑状态,则执行删除频道操作
- 如果是非编辑状态,则执行切换频道操作
// 点击添加
anAddChannel(channel) {
this.myChannels.push(channel);
},
onMyChannelClick(channel, index) {
// 判断是否为编辑状态
if (this.isEdit) {
// 编辑删除
// 不能删除推荐频道
if (this.fiexdChannels.includes(channel.id)) {
return;
}
if (index < this.active) {
this.$emit("updateActive", this.active - 1, true);
}
this.myChannels.splice(index, 1);
} else {
// 非编辑切换频道
this.$emit("updateActive", index, false);
}
},
固定推荐频道不能删除, 没删除按钮:
首先在data里声明 fiexdChannels: [0], // 固定推荐频道, 不允许删除
给删除按钮绑定是否出现的时候: 用includes取反
<van-icon
slot="icon"
name="clear"
v-show="isEdit && !fiexdChannels.includes(index)"
></van-icon>
数据持久化:
思路:
- 如果未登录,则存储到本地
- 如果已登录,则存储到线上
- 找到数据接口
- 封装请求方法
- 请求调用
搜索页面:
三个页面的v-if v-else-if v-else的使用:
<!-- 搜索结果 -->
<search-result v-if="isResultShow" :search-text="searchText"/>
<!-- /搜索结果 -->
<!-- 联想建议 -->
<search-suggestion
v-else-if="searchText"
:search-text="searchText"
@search="onSearch"
/>
<!-- /联想建议 -->
<!-- 搜索历史记录 -->
<search-history v-else />
<!-- /搜索历史记录 -->
// isResultShow: false, // 控制搜索结果列表的显示和隐藏
防抖的实现:
安装 lodash:
yarn add lodash
// lodash 支持按需加载,有利于打包结果优化
import { debounce } from "lodash"
watch: {
// 监听搜索关键词的变化
searchText: {
// handler 只有监听的数据发生变化的时候执行
// 防抖: debounce(需要防抖的函数function,延迟时间:毫秒)
handler: debounce(function (val) {
console.log(val);
this.loadSearchSuggestions(val);
}, 200),
// 所以设置immediate: 页面初始化一打开就立即执行
immediate: true,
},
},
关键词高亮的实现:
highlight(text) {
const highlightStr = `<span style="color:red;">${this.searchText}</span>`;
// RegExp 正则表达式构造函数 g全局 i忽略大小写
// new RegExp(要匹配的模板字符串, 匹配的模式)
const reg = new RegExp(this.searchText, "gi");
// replace替换 把text中的符合reg正则规则的 替换成 highlightStr
// replace(正则规则,要替换成的)
return text.replace(reg, highlightStr);
},
然后用插槽渲染到页面:
<div slot="title" v-html="highlight(text)"></div
搜索结果页面的实现:
data() {
return {
list: [],
loading: false,
finished: false,
page: 1, // 页数,默认为1
per_page: 10, // 每页数量
};
},
methods: {
async onLoad() {
// 1. 获取数据
try {
const { data } = await getSearchResult({
page: this.page,
per_page: this.per_page,
q: this.searchText,
});
console.log(data);
const { results } = data.data;
// 2. 将获取到的数据追加到数组, 实现数据的更新
this.list = [...this.list, ...results];
// 3. 将loading的状态设置为false
this.loading = false;
// 4. 判断数据是否加载完毕, 如果加载完毕 将finished设置为true
if (results.length) {
// 实现翻页 page加1
this.page++;
} else {
// 数据请求完毕
this.finished = true;
}
} catch (error) {
this.loading = false;
this.$toast("获取搜索结果数据失败!");
}
},
},
搜索历史记录
在data中保存搜索历史记录: searchHistories,
定义方法:
methods: {
onSearch(val) {
this.searchText = val;
// 不能加入重复的数据, 所以添加之前需要判断, 如果重复就先删除再添加
const index = this.searchHistories.indexOf(val);
if (index !== -1) {
this.searchHistories.splice(index, 1);
}
// 向后添加历史记录
this.searchHistories.unshift(val);
this.isResultShow = true;
console.log(val);
},
},
删除历史记录:
在data中保存是否删除的状态: isDeleteShow: false,
定义方法:
methods: {
// 删除
onSearchItemClick(index, text) {
// 判断是否处于删除的状态
if (this.isDeleteShow) {
// 是, 删除数据
this.searchHistories.splice(index, 1);
} else {
// 不是, 实现搜索
this.$emit("search", text);
}
},
},
实现全部删除: 子传父
在父组件中, 定义自定义事件:
@clearHistories="searchHistories = []"
在子组件中, 删除按钮绑定事件, 触发自定义事件:
<span @click="$emit('clearHistories')">全部删除 </span>
数据持久化:
// 导入本地存储封装方法
import { setItem, getItem } from '@/utils/storage'
设置监听:
watch: {
searchHistories(val) {
// 往本地存储同步搜索的记录
console.log(val);
// 打印的是字符串, 没有必要深度监听, 数组对象才用深度监听
setItem("HISTORIES-LIST", val);
},
},
然后初始化的时候从本地存储获取数据:
searchHistories: getItem("HISTORIES-LIST") || [],
文章详情实现:
每篇文章都有自己的Id号, 发送请求的时候要带上Id
/**
* 根据 id 获取指定文章
*/
export const getArticleById = articleId => {
return request({
method: 'GET',
url: `/v1_0/articles/${articleId}`
})
}
配置路由的时候使用 动态路径参数的解耦:
{
path: '/article/:articleId',
name: 'Article',
component: () => import('@/views/article'),
props: true // 动态路径参数的解耦: 将动态路径的参数映射到对应组件的props属性中 这里就是articleId
}
然后在该页面views/article/index.vue中:
props: {
// 使用props获取动态路由的数据
articleId: {
type: [Number, String],
required: true,
},
},
在src/components/article-item/index.vue
配置路由跳转:
<!-- 配置路由跳转:
@click="$router.push('/article/' + article.art_id)"
:to="'/article/' + article.art_id"
:to="`/article/${article.art_id}`"
:to="{ name:'路径名称', params:{ 标识符:数据 } } -->
<van-cell
class="article-item"
:to="{ name: 'Article', params: { articleId: article.art_id } }"
>
四个页面设置
隐藏显现:
<!-- 加载中 -->
<div v-if="loading" class="loading-wrap"> ... </div>
<!-- /加载中 -->
<!-- 加载完成-文章详情 -->
<div v-else-if="article.title" class="article-detail"> ... </div>
<!-- /加载完成-文章详情 -->
<!-- 加载失败:404 -->
<div v-else-if="errStatus === 404" class="error-wrap"> ... </div>
<!-- /加载失败:404 -->
<!-- 加载失败:其它未知错误(例如网络原因或服务端异常) -->
<div v-else class="error-wrap">...</div>
<!-- /加载失败:其它未知错误(例如网络原因或服务端异常) -->
处理正文样式:
导入
<style scoped lang="less">
@import "./github-markdown.css";
然后添加类名: markdown-body
.postcssrc.js
中配置不要转换样式文件中的字号:
// 配置不要转换的样式资源
exclude: 'github-markdown' // 增加这一句!
实现图片点击预览:
思路:
1、从文章内容中获取到所有的 img DOM 节点
2、获取文章内容中所有的图片地址
3、遍历所有 img 节点,给每个节点注册点击事件
4、在 img 点击事件处理函数中,调用 ImagePreview 预览
<!-- 增加ref属性 -->
<div class="article-content markdown-body" ref="article-content" v-html="article.content" ></div>
data() {
return {
article: {}, // 2.定义变量存储文章详情
loading: true, // 加载中的 loading 状态
errStatus: 0, // 失败的状态码
};
},
created() {
this.loadArticle();
},
mounted() {},
methods: {
async loadArticle() {
// 展示 loading 加载中
this.loading = true;
try {
const { data } = await getArticleById(this.articleId);
// console.log(data);
data.data.content = data.data.content.replaceAll(
"https://images.weserv.nl/?url=",
""
);
this.article = data.data; // vue的dom更新是异步的 微任务
// console.log(this.article);
// 宏任务
setTimeout(() => {
// 在数据更新之后, 调用图片预览功能
this.previewImage();
}, 0);
} catch (error) {
if (error.response && error.response.status === 404) {
this.errStatus = 404;
} else {
this.$toast("获取文章详情失败!");
}
}
// 无论成功还是失败,都需要关闭 loading
this.loading = false;
},
// 处理文章中的图片预览功能
previewImage() {
// 1. 从文章内容中获取到所有的img DOM节点
const articleContent = this.$refs["article-content"]; // 获取到了容器节点
const imgs = articleContent.querySelectorAll("img");
// 2. 获取文章内容中所有的图片地址
const images = [];
imgs.forEach((img, index) => {
images.push(img.src);
// 3. 遍历所有img节点, 给每个节点注册点击事件
// 4. 在img点击事件处理函数中, 调用ImagePreview预览
img.onclick = () => {
ImagePreview({
images: images,
// 指定预览图片的起始位置
startPosition: index,
});
};
});
},
},
组件的v-model的使用:
在父组件:
<!-- 关注 -->
<!-- $event是用来接受子组件传过来的值 -->
<!-- 在使用组件的同一个变量既要传递数据又需要修改数据
传递prpps:
:is-followed="article.is_followed"
修改: 自定义事件
@updateFollow="article.is_followed = $event"
-->
<!-- 简化: -->
<!-- 可以使用简化的方式: v-model -->
<!-- 传递props:
:value="article.is_followed"
修改: 内置封装 input
@input="article.is_followed = $event"
-->
<FollowUser
:follow-userid="article.aut_id"
v-model="article.is_followed"
class="follow-btn"
></FollowUser>
在子组件:
props: {
value: {
type: Boolean,
required: true,
},
followUserid: {
type: [String, Number],
required: true,
},
},
子传父的:
this.$emit("input", !this.value);
传入props的是value,自定义事件是input,
总结: v-model 是 value属性和input事件的语法糖!
还可以自定义修改value和input名字
// module: {
// prop: "userFollowed", // 修改prop属性名
// event: "changeFollowed", // 修改事件名
// },
实现关注用户的时候要更新视图, 因为发送请求更新的只是后台数据 article是我们自己加的 刷新才可以 所有不刷新的话 我们要手动更新视图的状态 取反
关注用户的实现:
methods: {
// 关注用户和取消关注功能
async onFollow() {
this.followLoading = true;
// 判断用户是否登录, 如果没有请先登录
if (!this.$store.state.user) return this.$toast("未登录, 请先登录!");
// 发送请求实现添加关注和取消关注
try {
const AutId = this.followUserid;
// 判断是否已经关注
if (this.value) {
// 已经关注了就取消关注 发送请求
await deleteFollow(this.followUserid);
} else {
// 没有关注就发送关注请求
await addFollow(this.followUserid);
}
// 更新视图
// 发送请求更新的只是后台数据 article是我们自己加的 刷新才可以 所有不刷新的话 我们要手动更新视图的状态 取反
// this.isFollowed = !this.isFollowed;
// 子传父
// this.$emit("updateFollow", !this.isFollowed);
this.$emit("input", !this.value);
} catch (error) {
if (error && error.response.status === 400) {
this.$toast("不能关注自己!");
} else {
this.$toast.fail("操作失败!");
}
}
this.followLoading = false;
},
},
祖先后代通信:
// 给所有的后代组件提供数据
provide: function () {
return {
articleId: this.articleId
}
}
comment-post.vue
注入数据:
// inject:['articleId']
inject: {
articleId: {
type: [Number, String, Object],
default: null
}
}
两种写法
整个文章详情的思路: