🔥使用vue3.0.0全家桶重构vue2.6.1的一个商城demo!

5,325 阅读5分钟

vue3-jd-h5

在vue的Composition API刚发布的时候,写了一篇基于vue3.0.1 beta,搭建仿京东的电商H5项目!的文章,介绍了vue的一些新特性,如今正式版本已经发布了,今年乘着有时间,开始使用最新的vue全家桶来进行重构!其他基础使用就直接略过了,不懂的可以直接看中文官网的例子或者我之前那篇文章!

项目介绍

vue3-jd-h5是一个电商H5页面前端项目,从vue2.6.1过度到vue3.0.0进行重构,基于Vue 3.0.0全家桶Vant 3.0.0 实现!

📖本地线下代码vue2.6在分支demo中,使用mockjs数据进行开发,效果图请点击🔗这里

⚠️master分支是线上生产环境代码,因为部分后台接口已经挂了😫,可能无法看到实际效果。

📌 本项目还有很多不足之处,如果有想为此做贡献的伙伴,也欢迎给我们提出PR,或者issue ;

🔑 本项目是免费开源的,如果有伙伴想要在次基础上进行二次开发,可以clone或者fork整个仓库,如果能帮助到您,我将感到非常高兴,如果您觉得这个项目不错还请给个start!🙏

开始搭建

  1. 首先,在本地选择一个文件,将代码clone到本地:
git clone https://github.com/GitHubGanKai/vue-jd-h5.git 
  1. 👉切换到分支vue-next开始进行体验(目前正在逐步重构中)!👈

  2. 在 IDEA 命令行中运行命令:npm install下载安装相关依赖;

安装vue全家桶

配置安装vue-router

// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const indexRouter = {
  path: '/',
  component: () => import('@/views/index'),
  redirect: '/index',
  children: []
}

const routes = [
  indexRouter,
  {
    path: '/*',
    name: '404',
    meta: {
      index: 1
    },
    component: () => import('@/views/error/404')
  },
]

const routerContext = require.context('./modules', true, /\.js$/)
routerContext.keys().forEach(route => {
  const routerModule = routerContext(route)
  indexRouter.children = [...indexRouter.children, ...(routerModule.default || routerModule)]
})

export default createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})

使用useRouterhooks可以获取路由对象:

import { onMounted } from "vue";
import { useRouter, useRoute } from "vue-router";

export default {
  name: "home",
  setup(props, context) {
    const $router = useRouter();
    
    const handleClick = id => {
      $router.push(`/classify/product/${id}`);
    };

    return {
      handleClick,
    };
  }
};

使用useRoute获取路由参数对象

import { onMounted } from "vue";
import { useRoute } from "vue-router";

export default {
  name: "home",
  setup(props, context) {
    // 可以拿到所有和路由相关的参数
    // 和useRouter()就差一个字母r,😅
    const $route = useRoute(); 
    
     onMounted(async () => {
      const { data } = await ctx.$http.get(
        `http://test.happymmall.com/product/${$route.params.id}`
      );
    });

    return {
      $route,
    };
  }
};

配置安装vuex

// src/store/index.js
import { createStore } from 'vuex'

import cart from './modules/cart'
import search from './modules/search'

export default createStore({
  modules: {
    cart,
    search
  },
  strict: process.env.NODE_ENV !== 'production'
})

在文件中使用如下:

import { useStore } from "vuex";
import { reactive, getCurrentInstance } from "vue";

setup(props, context) {
    const { ctx } = getCurrentInstance();
    const $store = useStore();
    // ctx.$store === $store  ==>true 其实是同一个对象!
    
    const ball = reactive({
      show: false,
      el: ""
    });

    const addToCart = (event, tag) => {
      $store.commit("cart/addToCart", tag);
      ball.show = true;
      ball.el = event.target;
    };

    return {
      ...toRefs(ball),
      addToCart,
    };
  }

在入口文件main.js中使用:

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import 'lib-flexible/flexible'

import Vant from 'vant'
import 'vant/lib/index.css' // 全局引入样式

const app = createApp(App);
app.use(Vant).use(store).use(router).mount('#app');

使用svg-sprite-loader处理svg文件

首先在vue.config.js中配置svg-sprite-loader:

module.exports = {
   chainWebpack: config => {
    const svgRule = config.module.rule("svg");
    svgRule.uses.clear();
    svgRule
      .use("svg-sprite-loader")
      .loader("svg-sprite-loader")
      .options({
        symbolId: "icon-[name]"
      })
      .end();
  },
}

src/components/SvgIcon/index.vue中:

import { computed, toRefs, toRef } from "vue";
export default {
  name: "svg-icon",
  props: {
    iconClass: {
      type: String,
      required: true
    },
    className: {
      type: String
    }
  },
  setup(initProps) {
    // const { iconClass } = initProps;❌
    // 因为 props 是响应式的,你不能使用 ES6 解构,因为它会消除 prop 的响应性。
    // 如果需要解构 prop,可以通过使用 setup 函数中的 toRefs 来完成此操作:
    const { iconClass } = toRefs(initProps);
    const iconName = computed(() => {
      return `#icon-${iconClass.value}`;
    });

    // 由于 className 是可选的 prop,则传入的 props 中可能没有 className 。
    // 在这种情况下,toRefs 将不会为 className 创建一个 ref ,需要使用 toRef 替代它。
    const className = toRef(initProps, "className");
    const svgClass = computed(() => {
      if (className) {
        return "svg-icon " + className.value;
      } else {
        return "svg-icon";
      }
    });
    return {
      iconName,
      svgClass
    };
  }
};

将这个写成一个插件,统一将所有的svg文件注册成组件的形式方便全局使用!

// src/icons/index.js
import SvgIcon from '@/components/SvgIcon'

const requireAll = requireContext => requireContext.keys().map(requireContext)

export default {
  install(app) {
    app.component('svg-icon', SvgIcon);
    const req = require.context('./svgs/', false, /\.svg$/)
    requireAll(req)
  }
}

统一注册所有组件

src/components/index.js文件中:

function capitalizeFirstLetter(str) {
  return str.charAt(0).toUpperCase() + str.slice(1)
}

function validateFileName(str) {
  return /^\S+\.vue$/.test(str) &&
    str.replace(/^\S+\/(\w+)\.vue$/, (rs, $1) => capitalizeFirstLetter($1))
}

const requireComponent = require.context('.', true, /\.vue$/)

export default {
  install(app) {
    requireComponent.keys().forEach(filePath => {
      const componentConfig = requireComponent(filePath)
      const fileName = validateFileName(filePath)
      const componentName = fileName.toLowerCase() === 'index' ?
        capitalizeFirstLetter(componentConfig.default.name) :
        fileName
      app.component(componentName, componentConfig.default || componentConfig)
    })
  }
}
function capitalizeFirstLetter(str) {
  return str.charAt(0).toUpperCase() + str.slice(1)
}

function validateFileName(str) {
  return /^\S+\.vue$/.test(str) &&
    str.replace(/^\S+\/(\w+)\.vue$/, (rs, $1) => capitalizeFirstLetter($1))
}
const requireComponent = require.context('.', true, /\.vue$/)
export default {
  install(app) {
    requireComponent.keys().forEach(filePath => {
      const componentConfig = requireComponent(filePath)
      const fileName = validateFileName(filePath)
      const componentName = fileName.toLowerCase() === 'index' ?
        capitalizeFirstLetter(componentConfig.default.name) :
        fileName
      app.component(componentName, componentConfig.default || componentConfig)
    })
  }
}

配置全局axios封装异步请求:

// src/plugins/axios.js
import axios from 'axios'
import router from '../router/index'
import { Toast } from 'vant'
const tip = msg => {
  Toast({
    message: msg,
    duration: 1000,
    forbidClick: true
  })
}
const errorHandle = (status, other) => {
  switch (status) {
    case 401:
      toLogin()
      break
    case 403:
      tip('登录过期,请重新登录')
      localStorage.removeItem('token')
      setTimeout(() => {
        toLogin()
      }, 1000)
      break
    case 404:
      tip('请求的资源不存在')
      break
    default:
      console.log(other)
  }
}
const instance = axios.create({
  baseURL: process.env.VUE_APP_BASE_URL,
  // baseURL: '',
  timeout: 1000 * 12
})
instance.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8'
instance.interceptors.request.use(
  config => {
    const token = localStorage.token
    token && (config.headers.token = token)
    return config
  },
  error => Promise.error(error))

// 响应拦截器
instance.interceptors.response.use(
  // 请求成功
  response => {
    return response.status === 200 ? Promise.resolve(response) : Promise.reject(response)
  },
  // 请求失败
  error => {
    const {
      response
    } = error
    if (response) {
      // 请求已发出,但是不在2xx的范围
      errorHandle(response.status, response.data.message)
      return Promise.reject(response)
    }
  })

export default {
  install(app) {
    // 这可以代替 Vue 2.x Vue.prototype 在单文件中,可以这样使用:
    // const { ctx } = getCurrentInstance();
    // ctx.$http访问
    app.config.globalProperties.$http = instance
  }
};

封装全局eventBus

2.x中,Vue 实例可用于触发由事件触发 API 通过指令式方式添加的处理函数 (onon,off 和 $once)。这可以创建 event hub,用来创建在整个应用程序中可用的全局事件监听器:

// src/utils/eventBus.js

const eventBus = new Vue()

export default eventBus

3.x从实例中完全移除了 $on$off$once 方法。$emit 仍然包含于现有的 API 中,因为它用于触发由父组件声明式添加的事件处理函数。 可以使用实现了事件触发接口的外部库来替换现有的 event hub,例如 mitttiny-emitter

所以需要在2.x的基础上进行自我改造(没有使用 mitttiny-emitter):

import { getCurrentInstance } from 'vue'

class EventBus {
  constructor(app) {
    if (!this.handles) {
      Object.defineProperty(this, 'handles', {
        value: {},
        enumerable: false
      })
    }
    this.app = app
    // _uid和EventName的映射
    this.eventMapUid = {}
  }
  setEventMapUid(uid, eventName) {
    if (!this.eventMapUid[uid]) {
      this.eventMapUid[uid] = []
    }
    this.eventMapUid[uid].push(eventName)
    // 把每个_uid订阅的事件名字push到各自uid所属的数组里
  }
  $on(eventName, callback, vm) {
    // vm是在组件内部使用时组件当前的this用于取_uid
    if (!this.handles[eventName]) {
      this.handles[eventName] = []
    }
    this.handles[eventName].push(callback)
    this.setEventMapUid(vm._uid, eventName)
  }
  $emit() {
    let args = [...arguments]
    let eventName = args[0]
    let params = args.slice(1)
    if (this.handles[eventName]) {
      let len = this.handles[eventName].length
      for (let i = 0; i < len; i++) {
        this.handles[eventName][i](...params)
      }
    }
  }
  $offVmEvent(uid) {
    let currentEvents = this.eventMapUid[uid] || []
    currentEvents.forEach(event => {
      this.$off(event)
    })
  }
  $off(eventName) {
    delete this.handles[eventName]
  }
}

let $EventBus = {}
$EventBus.install = (app) => {
  app.config.globalProperties.$eventBus = new EventBus(app)
  app.mixin({
    beforeUnmount() {
      const currentInstance = getCurrentInstance();
      // 拦截beforeUnmount钩子,自动销毁自身所有订阅的事件
      this.$eventBus.$offVmEvent(currentInstance._uid)
    }
  })
}
export default $EventBus

src/views/classify/index.vue文件中使用如下:

import ListScroll from "@/components/scroll/ListScroll";
import { ref, reactive, onMounted, toRefs, getCurrentInstance } from "vue";
import { useRouter } from "vue-router";

export default {
  name: "classify",
  components: {
    ListScroll
  },
  setup(props) {
    
    const { ctx } = getCurrentInstance();
    const $router = useRouter();

    const searchWrap = ref(null);

    const state = reactive({
      categoryDatas: [],
      currentIndex: 0
    });

    const selectMenu = index => {
      state.currentIndex = index;
    };

    const setSearchWrapHeight = () => {
      const { clientHeight } = document.documentElement;
      searchWrap.value.style.height = clientHeight - 100 + "px";
    };

    const selectProduct = sku => {
      $router.push({ path: "/classify/recommend", query: { sku } });
    };

    onMounted(async () => {
      setSearchWrapHeight();
      // 使用全局注过的$eventBus
      ctx.$eventBus.$emit("changeTag", 1);
      // 使用全局注册过的$http
      const { data } = await ctx.$http.get(
        "http://test.happymmall.com/category/categoryData"
      );
      const { categoryData } = data;
      state.categoryDatas = categoryData;
    });

    return {
      searchWrap,
      ...toRefs(state),
      selectProduct,
      selectMenu
    };
  }
};

封装个简单的hooks:useClickOutside

// src/hooks/useClickOutside.js
import { onMounted, onUnmounted, ref } from "vue";

export default useClickOutSide = (domRef) => {
  const isOutside = ref(false);

  const handler = (event) => {
    if (domRef.value) {
      if (domRef.value.contains(event.target)) {
        isOutside.value = false;
      } else {
        isOutside.value = true;
      }
    }
  }

  onMounted(() => {
    document.addEventListener('click', handler);
  });

  onUnmounted(() => {
    document.removeEventListener('click', handler);
  });

  return isOutside;
}

使用 this

setup() 内部,this 不会是该活跃实例的引用,因为 setup() 是在解析其它组件选项之前被调用的,所以 setup() 内部的 this 的行为与其它选项中的 this 完全不同。这在和其它选项式 API 一起使用 setup() 时可能会导致混淆。

可以通过getCurrentInstance获取当前单个文件组件的实例,同时ctx上面挂在了一些全局属性:

import { getCurrentInstance,  onMounted, reactive, toRefs } from 'vue'

export default {
  name: "classify",
  setup(props) {
    const { ctx } = getCurrentInstance();
    
    const state = reactive({
      categoryDatas: [],
      currentIndex: 0
    });
    
    onMounted(async () => {
      const { data } = await ctx.$http.get("http://test.happymmall.com/category/categoryData");
      const { categoryData, page } = data;
      state.categoryDatas = categoryData;
      state.currentIndex = page;
    });
    
    return {
      ...toRefs(state)
    };
  }
};

📦 封装better-scroll

<template>
  <div ref="wrapper" class="scroll-wrapper">
    <slot></slot>
  </div>
</template>
<script>
import BScroll from "better-scroll";
import { onMounted, nextTick, ref, watchEffect } from "vue";
export default {
  props: {
    probeType: {
      type: Number,
      default: 1
    },
    click: {
      type: Boolean,
      default: true
    },
    scrollX: {
      type: Boolean,
      default: false
    },
    listenScroll: {
      type: Boolean,
      default: false
    },
    scrollData: {
      type: Array,
      default: null
    },
    pullup: {
      type: Boolean,
      default: false
    },
    pulldown: {
      type: Boolean,
      default: false
    },
    beforeScroll: {
      type: Boolean,
      default: false
    },
    refreshDelay: {
      type: Number,
      default: 20
    }
  },
  setup(props, setupContext) {
    const wrapper = ref(null);

    const initScroll = () => {
      if (!wrapper.value) return;
      const scroll = new BScroll(wrapper.value, {
        probeType: props.probeType,
        click: props.click,
        scrollX: props.scrollX
      });
      // 是否派发滚动事件
      if (props.listenScroll) {
        scroll.on("scroll", pos => {
          setupContext.emit("scroll", pos);
        });
      }
      // 是否派发滚动到底部事件,用于上拉加载
      if (props.pullup) {
        scroll.on("scrollEnd", () => {
          // 滚动到底部
          if (scroll.y <= scroll.maxScrollY + 50) {
            setupContext.emit("scrollToEnd");
          }
        });
      }
      // 是否派发顶部下拉事件,用于下拉刷新
      if (props.pulldown) {
        scroll.on("touchend", pos => {
          // 下拉动作
          if (pos.y > 50) {
            setupContext.emit("pulldown");
          }
        });
      }
      // 是否派发列表滚动开始的事件
      if (props.beforeScroll) {
        scroll.on("beforeScrollStart", () => {
          setupContext.emit("beforeScroll");
        });
      }
    };
    const disable = () => {
      // 代理better-scroll的disable方法
      scroll?.disable();
    };
    const enable = () => {
      // 代理better-scroll的enable方法
      scroll?.enable();
    };
    const refresh = () => {
      // 代理better-scroll的refresh方法
      scroll?.refresh();
    };
    const scrollTo = () => {
      // 代理better-scroll的scrollTo方法
      scroll?.scrollTo.apply(scroll, arguments);
    };
    const scrollToElement = () => {
      // 代理better-scroll的scrollToElement方法
      scroll?.scrollToElement.apply(scroll, arguments);
    };
    onMounted(() => {
      nextTick(() => {
        initScroll();
      });
    });
    return {};
  }
};
</script>
<style lang="scss" type="text/scss" scoped>
.scroll-wrapper {
  width: 100%;
  height: 100%;
  overflow: hidden;
  overflow-y: scroll;
}
</style>

未完待续。。。

由于时间关系先写到这里了,明天还要上班,这个项目仅仅只是用来练习vue3用的小demo,里面还有部分的🐛,随时欢迎各位小伙伴提出分享意见!github代码点击这里

❤️ 看完三件事: 如果你觉得这篇内容对你挺有启发,我想邀请你帮我个小忙:

  1. 点赞,让更多的人也能看到这篇内容,也方便自己随时找到这篇内容(收藏不点赞,都是耍流氓 -_-);
  2. 关注我们,不定期分好文章;
  3. 也看看其它文章;

🎉欢迎你把自己的学习体会写在留言区,与我和其他同学一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。