项目目标

本篇用 Vant 搭建一个 移动端博客 App,覆盖以下页面:

  • 首页(文章列表 + 下拉刷新 + 分类 Tab)
  • 文章详情
  • 个人中心

项目结构

src/
├── App.vue                # 根组件
├── main.js                # 入口
├── router/index.js        # 路由配置
├── api/article.js         # 文章接口
├── views/
│   ├── Home.vue           # 首页
│   ├── ArticleDetail.vue  # 文章详情
│   └── Profile.vue        # 个人中心
└── components/
    └── ArticleCard.vue    # 文章卡片

步骤 1:路由配置

// router/index.js
import { createRouter, createWebHistory } from "vue-router";

const routes = [
  {
    path: "/",
    component: () => import("@/views/Home.vue"),
    meta: { title: "博客首页", showTab: true },
  },
  {
    path: "/article/:id",
    component: () => import("@/views/ArticleDetail.vue"),
    meta: { title: "文章详情" },
  },
  {
    path: "/profile",
    component: () => import("@/views/Profile.vue"),
    meta: { title: "我的", showTab: true },
  },
];

export default createRouter({
  history: createWebHistory(),
  routes,
});

步骤 2:App.vue 骨架

<template>
  <div class="app">
    <van-nav-bar
      :title="navTitle"
      v-if="showNav"
      fixed placeholder safe-area-inset-top
    />
    <div class="content">
      <router-view />
    </div>
    <van-tabbar v-model="tab" route safe-area-inset-bottom v-if="showTab">
      <van-tabbar-item icon="home-o" to="/">首页</van-tabbar-item>
      <van-tabbar-item icon="user-o" to="/profile">我的</van-tabbar-item>
    </van-tabbar>
  </div>
</template>

<script setup>
import { computed, ref } from "vue";
import { useRoute } from "vue-router";

const route = useRoute();
const tab = ref(0);
const navTitle = computed(() => route.meta.title || "");
const showNav = computed(() => !!route.meta.title);
const showTab = computed(() => route.meta.showTab);
</script>

<style>
.app { min-height: 100vh; background: #f7f8fa; }
.content { padding-bottom: env(safe-area-inset-bottom); }
</style>

步骤 3:首页 Home.vue

<template>
  <van-tabs v-model:active="activeCat" sticky swipeable>
    <van-tab v-for="cat in categories" :key="cat.id" :title="cat.name" :name="cat.id">
      <van-pull-refresh v-model="refreshing" @refresh="onRefresh">
        <van-list
          v-model:loading="loading"
          :finished="finished"
          finished-text="没有更多了"
          @load="onLoad"
        >
          <ArticleCard
            v-for="item in list"
            :key="item.id"
            :article="item"
            @click="goDetail(item.id)"
          />
        </van-list>
      </van-pull-refresh>
    </van-tab>
  </van-tabs>
</template>

<script setup>
import { ref, watch } from "vue";
import { useRouter } from "vue-router";
import ArticleCard from "@/components/ArticleCard.vue";
import { fetchCategories, fetchArticleList } from "@/api/article";

const router = useRouter();
const categories = ref([]);
const activeCat = ref(0);
const list = ref([]);
const loading = ref(false);
const finished = ref(false);
const refreshing = ref(false);
const page = ref(1);

fetchCategories().then((data) => {
  categories.value = data;
  activeCat.value = data[0].id;
});

watch(activeCat, () => {
  list.value = [];
  page.value = 1;
  finished.value = false;
  loading.value = true;
});

const onLoad = async () => {
  const { data } = await fetchArticleList({
    categoryId: activeCat.value,
    page: page.value++,
    pageSize: 10,
  });
  list.value.push(...data.list);
  if (list.value.length >= data.total) finished.value = true;
  loading.value = false;
};

const onRefresh = async () => {
  page.value = 1;
  const { data } = await fetchArticleList({
    categoryId: activeCat.value, page: 1, pageSize: 10,
  });
  list.value = data.list;
  finished.value = list.value.length >= data.total;
  refreshing.value = false;
};

const goDetail = (id) => router.push(`/article/${id}`);
</script>

步骤 4:文章卡片组件

<template>
  <van-cell :title="article.title" :label="article.summary" is-link @click="$emit('click')">
    <template #icon>
      <van-image
        v-if="article.cover"
        :src="article.cover"
        width="80" height="60"
        fit="cover" round
      />
    </template>
    <template #right-icon>
      <span class="meta">
        {{ article.views }} 阅读 · {{ formatDate(article.ctime) }}
      </span>
    </template>
  </van-cell>
</template>

<script setup>
defineProps({ article: { type: Object, required: true } });
defineEmits(["click"]);
const formatDate = (d) => `${new Date(d).getMonth() + 1}-${new Date(d).getDate()}`;
</script>

<style scoped>
.meta { font-size: 12px; color: #969799; }
</style>

步骤 5:文章详情页

<template>
  <div class="detail">
    <van-image
      v-if="article.cover"
      :src="article.cover"
      width="100%" aspect-ratio="2.5" fit="cover"
    />
    <div class="content">
      <h1 class="title">{{ article.title }}</h1>
      <div class="meta">
        <span>{{ formatDate(article.ctime) }}</span>
        <span>{{ article.views }} 阅读</span>
      </div>
      <div class="body" v-html="article.content" />
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from "vue";
import { useRoute } from "vue-router";
import { fetchArticleDetail } from "@/api/article";

const route = useRoute();
const article = ref({});

onMounted(async () => {
  article.value = await fetchArticleDetail(route.params.id);
});

const formatDate = (d) => new Date(d).toLocaleDateString("zh-CN");
</script>

<style scoped>
.detail { background: #fff; min-height: 100vh; }
.detail .content { padding: 16px; }
.detail .title { font-size: 20px; margin: 0 0 12px; }
.detail .meta { color: #969799; font-size: 13px; margin-bottom: 16px; }
.detail .meta span { margin-right: 12px; }
.detail .body { line-height: 1.8; font-size: 15px; }
.detail .body img { max-width: 100%; border-radius: 4px; }
.detail .body pre { background: #f7f8fa; padding: 12px; border-radius: 4px; overflow-x: auto; }
.detail .body code { background: #f7f8fa; padding: 2px 4px; border-radius: 2px; font-size: 13px; }
.detail .body pre code { background: none; padding: 0; }
</style>

启动运行

npm install
npm run dev

至此你已经用 Vant 搭建了一个完整的移动端博客 App。Vant 的组件远不止这些——Tabs、Swiper、Checkbox、Radio、Stepper、Uploader、Badge、Tag、NoticeBar、CountDown……可以按需查阅文档继续扩展。

推荐查阅