【保姆级实战教程】Vue3+TS+Vite开发H5移动端应用--项目搭建

2,823 阅读6分钟

本文主要介绍

1、vite项目的搭建
2、基于模块化、工程化对项目进行优化
3、路由的配置、路径别名配置
4、移动端REM适配、px转rem配置
5、Vuex的使用
......

vite项目搭建

默认创建vite项目

你可以通过默认安装一步步选择自己要安装的插件

// 使用 npm 安装
npm init @vitejs/app  (实际操作时会提示该指令已被  npm init vite 替代)

// 使用 yarn 安装
yarn create @vitejs/app

以npm为例

命令栏输入npm init @vitejs/app,依次选择以下选项

  • 1、输入创建项目文件目录的名称
  • 2、package.json项目的名称
  • 3、你要选择的框架: Vue/React/......
  • 4、你要选择的变种语言(是否选用TS):Vue / Vue - TS image.png

指定模板创建vite项目

如果你感觉默认创建的配置过程繁琐,可以一步到位指定模板进行创建

# npm 6.x
npm init @vitejs/app my-vue-app --template vue

# npm 7+, 需要额外的双横线:
npm init @vitejs/app my-vue-app -- --template vue

# yarn
yarn create @vitejs/app my-vue-app --template vue

可选的模板:vanillavuevue-tsreactreact-ts、......

运行项目

  • vite项目搭建完成后,进入根目录 通过npm install安装所有项目依赖
  • npm run dev运行项目

image.png

项目目录优化

为了实现项目工程化、模块化管理,便于后期功能的拓展维护,我们先对项目目录进行优化

在src下新建文件夹

utils:用来存放插件、封装的工具模块、模拟数据等
api:将项目中与接口相关代码解耦抽离出来放于此,对接口进行统一配置方便后期维护
store:Vuex模块,不使用Vuex可忽略
views:存放每个页面级的组件
router:用来配置路由的模块
types:项目中使用的类型统一放到此处

image.png

项目开发前准备工作

引入公共的依赖

由于公司项目某些需求的特殊性,需要对项目中的某些依赖进行二次封装处理,所以会把这些依赖做成公共的js在项目中统一引入。
请小伙伴们手动引入一些js库的CDN资源,例如:JQ、axios、UI组件库之类的 image.png

配置路径别名

ts识别路径别名(tsconfig.json)

{
  "compilerOptions": {
     // 新增字段
    "baseUrl": ".",
    "paths": {
      "@/*": [
        "src/*"
      ]
    }
  },
}

vite识别路径别名(vite.config.ts)

export default defineConfig({
  // 新增字段
  resolve: {
   // vite自动将@解析为src目录的路径
   alias: { "@": path.resolve("./src") }
  }
})

配置路由

安装vue-router:npm i vue-router@4(一定要安装4.0以上版本)

创建首页组件(src/views/Home.vue)

<template>
  <div class="container">这是Home页</div>
</template>

<script>
import { defineComponent } from "vue"

export default defineComponent({
  mounted() {
    console.log("Home is mounted");
  }
})
</script>

配置路由(src/router/index.ts)

import { createRouter, createWebHistory, createWebHashHistory, RouteRecordRaw } from 'vue-router'
import Home from '../views/Home.vue'

const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    name: 'Home',
    component: Home
    // component: () => import(/* webpackChunkName: "about" */ '../views/home.vue')
  },
  {
    // 匹配前面未匹配到的路由
    path: '/:pathMatch(.*)*',
    component: Home
  }
]

const router = createRouter({
  // createWebHashHistory:  hash模式
  // createWebHistory:  history模式
  history: createWebHashHistory(),
  routes
})

router.beforeEach((to, from, next) => {
  next()
})

export default router

挂载路由(src/main.ts)

import router from '@/router'
// app实例use方法用来安装插件,会返回app实例  链式编程你懂的
createApp(App).use(router).mount('#app')

使用router-view组件(src/App.vue)

<template>
  <router-view></router-view>
</template>

运行项目: npm run dev

image.png

接口模块配置

我们将每个接口单独封装成一个函数,在业务代码中只需要调这个函数获取数据,不用关心每个接口的配置

声明全局变量、类型(src/types/globle.d.ts)

//  在没有iomport 情况下declare 可以自定义全局类型 值
// declare const axios: AxiosInstance;

// 如果有 import 要定义全局变量、类型需要 declare global {......}来声明
import { AxiosInstance, AxiosResponse } from "axios";
declare global {
  // 声明全局变量、类型  使用方式:plugTool.JLoading()
  type plugTool = {
    JLoading: () => void,
    JHideLoading: () => void,
    JGetShareInfo: (id: string) => void
  }
  type sdDefaults = {
    VServerAPI: string
  }
  type response = {
    code: number,
    data: any,
    message: string | null
  }
  const sdDefaults: sdDefaults
  const axios: AxiosInstance
  const plugTool: plugTool
  
  // 声明window的属性和方法  使用方式:windw.plugTool.JLoading()
  interface Window {
    axios: AxiosInstance
    plugTool: plugTool
    sdDefaults: sdDefaults
  }
}

声明接口函数(src/api/index.ts)

// 接口基准地址
let BASEURL = "http://localhost:3005/"
// 为每个接口拼接版本参数避免产生缓存
const date = new Date().getTime()

const homeInit = (params = {}) => {
  return window.axios.get(`${BASEURL}init.do?t=${date}`, { params })
}
export default { homeInit }

挂载全局API函数(src/main.ts)

为了能在组件中直接使用请求接口函数,Vue2可以挂载到Vue原型对象上。Vue3则提供了一个挂载全局属性的API-globalProperties

import api from '@/api'
const pageApp = createApp(App)
pageApp.config.globalProperties.$API = api
pageApp.use(router).mount('#app')

使用全局函数(src/views/Home.vue)

<script>
import { defineComponent, getCurrentInstance } from "vue"

export default defineComponent({
  mounted() {
    console.log("Home is mounted");
    // 通过Vue实例thi访问
    console.log("this", this.$API);
    // 通过getCurrentInstance来访问,Vue3新增用来解决setup中无法通过this访问Vue实例        
    console.log("instance",getCurrentInstance().appContext.config.globalProperties.$API))
  }
})
</script>

模拟接口json-server

项目开发中,前后端进度经常出现不一致的情况,当我们页面开发完成需要模拟接口联调时,可以使用json-server

1、安装json-server:npm install -g json-server
2、根据后端约定的接口数据格式写json (src/utils/data.json)

  
{
  // 接口路径地址
 "init.do": {
    "code": 1000,
    "data": {
      "vipFlag": "0",
      "guideShow": "0",
      "wednesday": true,
      "showModules": [
        {
          "MODULE_IMAGE": "https://m.sd.10086.cn/act_service/resources/1532/images/banner.png",
          "MODULE_NAME": "banner",
          "MODULE_LINK": "https://www.baodu.com",
          "SPECIAL_FLAG": "2021-10-01",
          "WEEK": "4"
        },
        {
          "MODULE_IMAGE": "https://m.sd.10086.cn/fe_service/sd1813/img/e_steward.e31d9b68.gif",
          "MODULE_NAME": "flutter",
          "MODULE_LINK": "https://www.baodu.com",
          "TITLE": "浮窗"
        },
        {
          "vipLists": [
            {
              "MODULE_IMAGE": "https://m.sd.10086.cn/act_service/resources/1532/images/zxVIP.png",
              "MODULE_NAME": "vip",
              "MODULE_LINK": "18764741017,18764741016",
              "couponList": [
                {
                  "GET_FLAG": "toGet",
                  "MODULE_NAME": "vip",
                  "IMAGE": "https://m.sd.10086.cn/act_service/resources/1532/images/zx1GBNew.png",
                  "TITLE": "新人首单礼-1GB流量兑换券  ",
                  "FLAG": "newGift",
                  "PRIZE_NO": "zxNNO002",
                  "SUB_MODULE": "zx",
                  "LINK": "http://www.baidu.com"
                },
                {
                  "GET_FLAG": "toGet",
                  "MODULE_NAME": "vip",
                  "IMAGE": "https://m.sd.10086.cn/act_service/resources/1532/images/zx1GB.png",
                  "TITLE": "1GB通用流量 月月可享",
                  "FLAG": "zwCoupon",
                  "PRIZE_NO": "zxZWNO001",
                  "SUB_MODULE": "zx",
                  "LINK": "http://www.baidu.com"
                },
                {
                  "GET_FLAG": "toGet",
                  "MODULE_NAME": "vip",
                  "IMAGE": "https://m.sd.10086.cn/act_service/resources/1532/images/zx1GBNew.png",
                  "TITLE": "新人首单礼-1GB流量兑换券  ",
                  "FLAG": "newGift",
                  "PRIZE_NO": "zxNNO002",
                  "SUB_MODULE": "zx",
                  "LINK": "http://www.baidu.com"
                },
                {
                  "GET_FLAG": "toGet",
                  "MODULE_NAME": "vip",
                  "IMAGE": "https://m.sd.10086.cn/act_service/resources/1532/images/zx1GB.png",
                  "TITLE": "1GB通用流量 月月可享",
                  "FLAG": "zwCoupon",
                  "PRIZE_NO": "zxZWNO001",
                  "SUB_MODULE": "zx",
                  "LINK": "http://www.baidu.com"
                }
              ],
              "SPECIAL_FLAG": "ZXVIP",
              "WAIT_TIME": "1000",
              "TITLE": "会员楼层",
              "OPEN_FLAG": "1"
            },
            {
              "MODULE_IMAGE": "https://m.sd.10086.cn/act_service/resources/1532/images/lifeVIP.png",
              "MODULE_NAME": "vip",
              "MODULE_LINK": "18764741017,18764741016",
              "couponList": [
                {
                  "GET_FLAG": "toJump",
                  "MODULE_NAME": "vip",
                  "IMAGE": "https://m.sd.10086.cn/act_service/resources/1532/images/life1GB.png",
                  "SUB_TITLE": "toJump",
                  "LINK": "http://www.baidu.com",
                  "TITLE": "1GB通用流量 月月可享",
                  "FLAG": "default",
                  "SUB_MODULE": "life"
                },
                {
                  "GET_FLAG": "toJump",
                  "MODULE_NAME": "vip",
                  "IMAGE": "https://m.sd.10086.cn/act_service/resources/1532/images/life20y.png",
                  "SUB_TITLE": "toJump",
                  "LINK": "http://www.baidu.com",
                  "TITLE": "20元商超券",
                  "FLAG": "default",
                  "SUB_MODULE": "life"
                },
                {
                  "GET_FLAG": "toLook",
                  "MODULE_NAME": "vip",
                  "IMAGE": "https://m.sd.10086.cn/act_service/resources/1532/images/lifemore.png",
                  "SUB_TITLE": "toLook",
                  "TITLE": "更多权益",
                  "FLAG": "default",
                  "SUB_MODULE": "life",
                  "LINK": "http://www.baidu.com"
                }
              ],
              "SPECIAL_FLAG": "LIFEVIP",
              "WAIT_TIME": "1000",
              "TITLE": "会员楼层",
              "OPEN_FLAG": "0"
            }
          ],
          "MODULE_IMAGE": "https://m.sd.10086.cn/act_service/resources/1532/images/lifeVIP.png",
          "MODULE_NAME": "vip",
          "SPECIAL_FLAG": "ZXVIP",
          "WAIT_TIME": "1000",
          "TITLE": "会员楼层"
        }
      ]
    }
  },
  // 其他接口
  ...
}

3、在src/utils/data.json处打开命令栏窗口输入指令运行接口服务

指令:json-server data.json

默认服务跑在3000端口上,如果端口被占用会报错。可以指定其他接口json-server data.json --port 3005

image.png

测试接口(src/views/Home.vue)

<script>
import { defineComponent, getCurrentInstance } from "vue"

export default defineComponent({
  mounted() {
    console.log("Home is mounted");
    const _this = getCurrentInstance()
    // 拉起loading
    plugTool.JLoading()
    _this.appContext.config.globalProperties.$API.homeInit().then(res => {
      console.log("res", res);
    }).catch(err => {
      // 发生错误
    }).finally(res => {
      // 隐藏loading
      plugTool.JHideLoading()
    })
  }
})
</script>

image.png

目前接口模块还存在一些问题
1、我们要使用的接口数据在res.data.data内,可以剔除res.data这层数据
2、每次调用API函数都要先拉起loading,接口返回数据时移除loading.还有接口返回错误的处理。将接口模块进行二次封装增加一个中间件通过这个中间件处理这些工作。

接口模块中间件

书写中间件(src/utils/request.ts)

// 接口模块中间层
import { AxiosResponse } from 'axios';

type axiosOption = {
  url: string
  params?: object
  data?: object
}

/**所有Get请求都通过这个中间件发送
 *  - 统一配置loading拉起、移除操作
 *  - 直接取res.data,剔除无用的数据层
 */
export const httpGet = ({ url, params = {} }: axiosOption) => {
  window.window.plugTool.JLoading()
  return new Promise<AxiosResponse>((resolve, reject) => {
    window.axios.get(url, { params }).then((res: AxiosResponse) => {
      resolve(res.data)
    }).catch(err => {
      reject(err)
    }).finally(() => {
      window.plugTool.JHideLoading()
    })
  })
}

使用中间层(src/api/index.ts)

import { httpGet } from './../utils/request';
// 声明接口基准地址
let BASEURL = "http://localhost:3005/"
// 为每个接口拼接版本参数避免产生缓存
const date = new Date().getTime()

const homeInit = (params = {}) => {
  return httpGet({
    url: `${BASEURL}init.do?t=${date}`,
    params
  })
}
export default { homeInit }

效果(src/views/Home.vue)

  mounted() {
    console.log("Home is mounted");
    const _this = getCurrentInstance()
    _this.appContext.config.globalProperties.$API.homeInit().then(res => {
      console.log("res", res);
    })
  }

image.png

移动端适配

移动端适配主要是通过REM来实现的

amfe-flexible:
这个插件可以通过监听设备视口宽度动态设置HTML标签字体大小

安装amfe-flexible:npm i amfe-flexible -S
在main.ts中进入:import 'amfe-flexible'

postcss-pxtorem:
这个插件可以通在构建项目时将所有px单位转为rem单位

安装postcss-pxtorem:npm i postcss-pxtorem --save-dev
配置postcss插件(vite.config.ts)

import pxToRem from 'postcss-pxtorem'  
export default defineConfig({
  ...
  // 新增字段
  css: {
    postcss: {
      plugins: [
        // px换算成rem
        PxToRem({
          rootValue: 75, // 换算的基数
          selectorBlackList: ['van-'], // 忽略转换正则匹配项
          propList: ['*'],
          unitPrecision: 4,
          minPixelValue: 2
        })
      ]
    }
  },
  // 打包后的文件夹名
  build: {
    outDir: "sd1125"
  },
  // 配置代理
  // 配置代理后会将所有接口URL以/api开头的接口,代理到https://www.jiekou.cn上
   server: {
    host: '127.0.0.1',
    proxy: {
      '/api': {
        target: 'https://www.jiekou.cn',
        changeOrigin: true,
        ws: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  },
})

配置Sass

vite内置了sass、less等css预处理器的支持,只需要安装依赖即可不需要做过多的配置

安装sass:npm install -D sass

项目设计模式

组件UI

页面级组件UI分为两个部分:页面、弹窗部分。

页面部分 放入section中,section铺满整个视口溢出部分滚动,所有楼层封装成组件,可由后端配置楼层顺序
弹窗部分 放在section之外(避免出现部分机型出现滚动穿透的问题),弹窗与遮罩分离可以单独控制,控制弹窗类型、弹窗数据的state数据放入Vuex中,全局共享。

// src/views/Home.vue
<template>
  <!-- 页面中所有内容 -->
  <section class="page_container">
    <!-- 头部 banner -->
    <header>这是banner部分</header>
    <!-- 页面主体 -->
    <main>
      <!-- 动态渲染楼层组件 -->
      <component
        :is="floorType(e.MODULE_NAME)"
        :floorData="e"
        v-for="e in floorData"
        :key="e.MODULE_NAME"
      />
    </main>
    <!-- 页面底部 -->
    <footer></footer>
  </section>
  <!-- 弹窗遮罩 -->
  <div class="popup_mask" v-show="popType"></div>
  <!-- 弹窗部分 -->
  <div class="popup popup_suc" v-if="popType === 'suc' || true">
    <div class="popup_title">这是成功弹窗</div>
  </div>
  <div class="popup popup_fail" v-if="popType === 'fail'">
    <div class="popup_title">这是失败弹窗</div>
  </div>
</template>

<script lang="ts">
import {
  defineComponent,
  getCurrentInstance,
  onMounted,
  reactive,
  toRefs,
} from "vue";

// 根据楼层ID渲染楼层组件
const floorType = (floorID: string) => {
  let floorName = "";
  switch (floorID) {
    case "vip":
      // 会员楼层
      floorName = "VIP";
      break;
    default:
      break;
  }
  return floorName;
};
export default defineComponent({
  setup() {
    interface DataProp {
      pageData: any;
      floorData: any[];
    }
    // 响应式数据
    const state:DataProp = reactive({
      pageData: {},
      floorData: [],
    });
    // DOM挂载完成
    onMounted(() => {
      const _this = getCurrentInstance();
      _this?.appContext.config.globalProperties.$API
        .homeInit()
        .then((res: any) => {
          if (res.code === 1000) {
            const { showModules, ...pageData } = res.data;
            // 获取页面数据、楼层数据
            state.pageData = pageData;
            state.floorData = showModules;
          }
        });
    });
    // 使用toRefs处理reactive数据后,在解包reactive数据不会丢失响应式
    return { ...toRefs(state), floorType };
  },
});
</script>

<style scoped lang="scss">
section {
  background-color: #fc6657;
}
main {
  margin-top: -332px;
}
.popup_mask {
  position: absolute;
  top: 0;
  left: 0;
  bottom: 0;
  right: 0;
  background-color: rgba(0, 0, 0, 0.7);
}
.popup {
  position: absolute;
  top: 50%;
  left: 50%;
  right: 50%;
  bottom: 50%;
  transform: translate(-50%, -50%);
  .popup_title {
    color: red;
  }
}
header {
  position: relative;
  .header_banner--img {
    width: 750px;
  }
  .header_btn--desc {
    position: absolute;
    top: 50px;
    right: 50px;
    width: 110px;
    height: 50px;
    line-height: 50px;
    text-align: center;
    font-size: 20px;
    border-radius: 25px;
    color: #fff;
  }
  .header_btn {
    width: 60px;
  }
  .header_btn--desc {
    position: absolute;
    top: 222px;
    right: 0;
  }
  .header_btn--share {
    position: absolute;
    top: 302px;
    right: 0;
  }
  .header_Nav--img {
    width: 60px;
  }
  .header_btn--eManager {
    position: absolute;
    width: 90px;
    left: 0;
    right: auto;
    top: 300px;
  }
  .eManag_btn--img {
    width: 90px;
  }
}
</style>

// src/APP.vue  
<style>
html,
body,
#app {
  height: 100%;
  overflow: hidden;
}
#app {
  position: relative;
}
</style>

全局工具函数

全局工具函数例如:用于埋点插码、跳转URL的方法等,放入一个tool.js中,统一挂载到globalProperties

// src/utils/tool.ts
// 公共的工具函数

// 埋点插码
const insertCode = () => {
  console.log("insertCode");

}
// URL跳转
const jumpLink = (url: string = '') => {
  if (url) window.location.href = url;
}

// 全局toast方法
const toast = (content: string) => {
  // 根据自己平台选择、或选择组件库
  alert(content)
}

const tool: {
  [key: string]: Function
} = { insertCode, jumpLink, toast }
export default {
  install(app: any) {
    Object.keys(tool).forEach(k => {
      app.config.globalProperties[k] = tool[k]
    })
  }
}

// 安装tool.ts (src/main.ts) 
import tool from '@/utils/tool'
pageApp.use(router).use(tool).mount('#app')

全局状态数据

全局状态数据放入vuex中统一管理,Vuex提供通过的mutation方法更新store中的状态数据

安装Vuex(Vue3仅支持Vuex4.X版本以上):npm i Vuex@next

创建Store并挂载到Vue

// src/store/index.ts  
import { App } from 'vue'
import { createStore, mapState } from 'vuex'

type state = {
  [k: string]: any
}
const store = createStore<state>({
  state: {
    // 弹窗类型
    popType: "init",
    // 弹窗数据
    popData: {}
  },
  mutations: {
    setState(state, data) {
      const keys = Object.keys(data)
      keys.forEach(k => {
        if (k in state) state[k] = data[k]
      })
    }
  }
})

// 所有state数据key
const storeKeys = Object.keys(store.state) || []

export default <T>(app: App<T>) => {
  // 应用安装vuex-store
  app.use(store)
  // 全局混入  将state映射到组件computed中
  // 大型项目谨慎使用混入,可能会产生副作用
  app.mixin({ computed: { ...mapState(storeKeys) } })
  // 同时提供修改state的mutation方法
  app.config.globalProperties.setState = (data: object) => {
    store.commit('setState', data)
  }
}

// src/main.ts  
import store from '@/store/index'
pageApp.use(router).use(tool).use(store).mount('#app')