韩吉鑫 2019-06-26
这是我第一个基于 Vue 的项目作品,目的很简单,学以致用,将之前的前端知识积累加上目前流行的前端框架,以项目的形式展现出来。
源代码:https://github.com/nanyang24/...
演示地址:https://ele.n-y.io/
Vue有自己的脚手架构建工具vue-cli,使用起来非常方便,使用webpack来集成各种开发便捷工具,比如:
…
除此之外,vue-cli已经使用node配置了一套本地服务器和安装命令等,本地运行和打包只需要一个命令就可以搞定,非常的方便
├──app.vue │ ├──header.vue--头部组件 │ │ ├──star.vue--星星评分组件 │ ├──goods.vue--商品组件 │ │ ├──shopcart.vue--购物车组件,包括小球飞入购物车动画 │ │ ├──cartcontrol.vue--购买加减图标控件--选中数量返回给父组件goods,goods响应后,重新计算选中数量,将数据发送给购物车组件, │ │ ├──food.vue--商品详情页 │ │ │ ├──ratingselect.vue--评价内容筛选组件 │ ├──ratings.vue--评论组件 │ │ ├──ratingselect.vue--评价内容筛选组件 │ ├──seller.vue--商家组件 独立组件 ├──split.vue--关于分割线组件
common/---- 文件夹存放的是通用的css和fonts components/---- 文件夹用来存放 Vue 组件 router/---- 文件夹存放的是vue-router相关配置(linkActiveClass,routes注册组件路由) build/---- 文件是 webpack 的打包编译配置文件 config/---- 文件夹存放的是一些配置项,比如我们服务器访问的端口配置等 dist/---- 该文件夹一开始是不存在,在项目经过 build 之后才会生成 prod.server.js---- 该文件是测试是模拟的服务器配置,用来运行dist里面的文件,在config/index.js中,build对象中添加一条端口设置port:9000, App.vue---- 根组件,所有的子组件都将在这里被引用 index.html---- 整个项目的入口文件,将会引用我们的根组件 App.vue main.js---- 入口文件的 js 逻辑,在 webpack 打包之后将被注入到 index.html 中
当样式像素一定时,因手机有320px,640px等.各自的缩放比差异,所以设备显示像素就会有1Npx,2Npx。
公式:设备上像素 = 样式像素 * 设备像素比
为了保证设计稿高度还原,采用 media + scale 的方法解决
屏幕宽度: 320px 480px 640px 设备像素比: 1 1.5 2 通过查询它的设备像素比 devicePixelRatio 在设备像素比为1.5倍时, round(1px 1.5 / 0.7) = 1px 在设备像素比为2倍时, round(1px 2 / 0.5) = 1px
实现代码
// SCSS 语法
@mixin border-1px($color) {
  position: relative;
  &::after {
    display: block;
    position: absolute;
    left: 0;
    bottom: 0;
    width: 100%;
    border-top: 1px solid $color;
    content: '';
  }
}
@mixin border-none() {
  &::after{
    display: none;
  }
}
@media (-webkit-min-device-pixel-ratio: 1.5), (min-device-pixel-ratio: 1.5) {
  .border-1px {
    &::after {
      -webkit-transform: scaleY(0.7);
      transform: scaleY(0.7);
    }
  }
}
@media (-webkit-min-device-pixel-ratio: 2), (min-device-pixel-ratio: 2) {
  .border-1px {
    &::after {
      -webkit-transform: scaleY(0.5);
      transform: scaleY(0.5);
    }
  }
}在 header 组件的详情页采用 sticky-footer 布局,主要特点是如果页面内容不够长的时候,页脚块粘贴在视窗底部;如果内容足够长时,页脚块会被内容向下推送
父级 position:fixed,内容设 为padding-bottom:64px,页脚相对定位,margin-top:-64px,clear:both
为了保证兼容性,父级要清除浮动
参考:
https://www.cnblogs.com/shico...   
https://www.w3cplus.com/css3/...
// 左侧固定width:80px,右侧自适应
parent:
    display:fiexd;
child-left:
    flex:0 0 80px
child-right:
    flex:1例如:商品详情页面的商品图片展示样式
// stylus语法
.img_header {
    position:relative
    width:100% // width是 设备宽度
    height:0
    padding-top:100% // 高度设为0,使用padding撑开
    .img {
        position:absolute //定位布局
        top:0
        left:0
        width:100%
        height:100%
    }
}filter:blur(10px),注意,所有在内的子元素也会模糊,包括文字,所以采用定位布局,背景单独占用一个层,ios有一个设置backdrop-filter:blur(10px),只会模糊背景,但不支持android
在购买控件中使用transition过渡效果,实现添加减少按钮的动效,和小球飞入购物车的动效(模仿贝塞尔曲线的效果)
vue2.x里面定义了transition过渡状态,
name - string, 用于自动生成 CSS 过渡类名。
例如:name: 'fade' 将自动拓展为.fade-enter,.fade-enter-active等。默认类名为 "v" fade-enter fade-enter-active fade-leave fade-leave-active
包括transition过渡的钩子函数
before-enter before-leave before-appear enter leave appear after-enter after-leave after-appear enter-cancelled leave-cancelled (v-show only) appear-cancelled
解决方案:每个 li 要 display:inline-block,因为width不会自动撑开父级ul,所以需要将计算后的宽度赋值给ul的width,(每一张图片的width+margin)*图片数量-一个margin,因为最后一张图片没有margin
同时new BScroll里面要设置scrollX: true,eventPassthrough: 'vertical', // 滚动方向横向
问题分析:出现这种现象是因为better-scroll插件是严格基于DOM的,数据是采用异步传输的,页面刚打开,DOM并没有被渲染,所以,要确保DOM渲染了,才能使用 better-scroll,
解决方案:用到mounted钩子函数,同时必须搭配this.$nextTick()
问题分析:出现这种情况是因为mounted函数在整个生命周期中只会只行一次
解决方案:使用watch方法监控数据变化,并执行滚动函数 this._initScroll();this._initPicScroll();
使用window.localStorage保存和设置缓存信息,封装在store.js文件内
//将页面信息保存到localStorage里
export function saveToLocal(id, key, value) {
  let store = window.localStorage._store_; // 新定义一个key值_store_,存放要保存的数据对象
  // _store_ {
  //   store[id]: {
  //     key: value
  //   }
  // }
  if (!store) {
    store = {};
    store[id] = {};
  } else {
    store = JSON.parse(store); // String格式--> json格式
    if (!store[id]) {
      store[id] = {};
    }
  }
  store[id][key] = value;
  window.localStorage._store_ = JSON.stringify(store); // 将json格式转成String格式,存放到window.localStorage._store中
}
//将localStorage信息设置到页面中
export function loadFromLocal(id, key, defaults) {
  let store = window.localStorage._store_;
  if (!store) { // 一开始是没有的,因为没有点击事件,所以显示默认数据
    return defaults;
  }
  store = JSON.parse(store)[id]; // 将json格式-->String格式
  // console.log(store); // {"isFavorite":true}
  if (!store) {
    return defaults;
  }
  let ret = store[key];
  return ret || defaults;
}使用window.localStorage.search获取url地址,并进行解析 
封装在util.js文件内
/**
 * 解析URL参数
 * @example ?id=12345&a=b
 * @return Object {id:12345, a:b}
 **/
export function urlParse() {
  let url = window.location.search;
  let obj = {};
  let reg = /[?&][^?&]+=[^?&]+/g;
  let arr = url.match(reg);
  // ['?id=12345', '&a=b']
  if (arr) {
    arr.forEach((item) => {
      let temArr = item.substring(1).split('=');
      let key = decodeURIComponent(temArr[0]);
      let value = decodeURIComponent(temArr[1]);
      obj[key] = value;
    });
  }
  return obj;
};我们需要将得到的 id 和 name 带到数据中,实际上在获取数据的时候,并没有带着id和name,这时就要用到 es6 语法中Object.assign(),官方解释为:可以把任意多个的源对象自身的可枚举属性拷贝给目标对象,然后返回目标对象。
this.seller = Object.assign({}, this.seller, response.data);
//即将vm.seller属性和请求返回数据对象合并到空对象,然后赋值给vm.seller,这里加上this.seller即提供了一种可扩展的机制,倘若原来的属性中有预定义的其他属性。解决方案:在 app.vu 内使用 keep-alive,保留各组件状态,避免重新渲染
<keep-alive>
    <router-view :seller="seller"></router-view>
</keep-alive>使用<router-link>组件完成导航,<router-link> 默认会被渲染成一个 <a> 标签,但必须使用 to属性,指定连接
// app.vue <!-- 导航 --> <router-link to="/home">home</router-link> <router-link to="/about">about</router-link> <!-- 路由出口 组件渲染容器 --> <router-view></router-view>
// router: index.js
import Vue from 'vue';
import Router from 'vue-router';
import goods from 'components/goods/goods.vue';
import ratings from 'components/ratings/ratings.vue';
import seller from 'components/seller/seller.vue';
Vue.use(Router);
const routes = [{
  path: '/',
  redirect: '/goods'
}, {
  path: '/goods',
  component: goods
}, {
  path: '/ratings',
  component: ratings
}, {
  path: '/seller',
  component: seller
}];
export default new Router({
  routes,
  linkActiveClass: 'active'
});在vue1.x的时候,vue的官方推荐HTTP请求工具是vue-resource,但是在vue2.0的时候将推荐工具改成了axios。
如果想像以前使用 vue-resource 那样 this.$http.get 调用,要这样定义:
Vue.prototype.$http = axios;
通过 this.$http.get 来定义通过vue实例来发送get请求,然后通过then后面的回调函数将请求成功的数据接收,通过状态码来判断是否成功以及复制给vue的数据对象。由于这里是用的mock数据(模拟后台数据),所以用的模拟状态码。
const ERR_OK = 0;//表示没有错误信息,即获取数据成功
this.$http.get('/api/seller').then((response) => {
  response = response.data;
  if (response.errno === ERR_OK) {
    this.seller = Object.assign({}, this.seller, response.data);
  }
});vue是组件式开发,所以组件间通讯是必不可少的
vue提供了一种方式,即在子组件定义 props 来接受父组件传递来的数据对象。
// 父组件
<v-header :seller="seller"></v-header>
// 子组件 header.vue
props: {
  seller: {
    type: Object
  }
}如果是子组件想传递数据给父组件,需要派发自定义事件,使用 $emit 派发,
父组件使用v-on接收监控(v-on可以简写成@)
// 子组件 RatingSelect.vue,派发自定义事件isContent,将this.onlyContent数据传给父级
this.$emit('isContent', this.onlyContent);
this.$emit('selRatings', this.selectType);
// 父组件 foodInfo.vue 在子组件的模板标签里,使用v-on监控isContent传过来的数据
<v-ratingselect @selRatings="filterRatings" @isContent="iscontent"></v-ratingselect>父组件再利用 $refs 直接访问子组件B的方法,间接实现数据从子组件A传递至子组件B
将相同样式或功能的区块单独提出来,作为一个组件。
另外组件中用到的图片等资源就近维护,即可以考虑在组件文件夹中新建images文件夹。
抽离组件遵循原则:
要尽量遵循单一职责原则,复用性更高,不要设置额外的margin等影响布局的东西
想要达到这种目的,有两种方法,一种是利用重定向,另一种是利用vue-router的导航式编程。
//在router的index.js文件中设置,要多写一个对象,指向目标组件
Vue.use(Router);
const routes = [{
  path: '/',
  redirect: '/goods'   // 重定向
}, {
  path: '/goods',
  component: goods
}, {
  path: '/ratings',
  component: ratings
}, {
  path: '/seller',
  component: seller
}];
export default new Router({
  routes,
  linkActiveClass: 'active'
});router.push('/Goods');<div class="ball-container">
      <div v-for="ball in balls">
      //用了两种方式的动画,css和js钩子
        <transition name="drop" @before-enter="beforeDrop" @enter="dropping" @after-enter="afterDrop">
        //外层动画
          <div class="ball" v-show="ball.show">
          //内层动画
            <div class="inner inner-hook"></div>
          </div>
        </transition>
      </div>
    </div>data(){
      return {
        balls: [
          {show: false},
          {show: false},
          {show: false},
          {show: false},
          {show: false}
        ],
        dropBalls: []
      }
    },只要触发了drop事件,不止是drop事件里面的代码会执行,另外几个vue的js监听钩子也会一起按顺序执行
drop 事件的触发可以通过点击 cartcontrol 组件的添加小球按钮 addCart 事件触发使用 $emit ,也可以父组件  this.$refs.shopcart.drop(target); 直接触发
$emit 是触发当前实例上的事件。附加参数都会传给监听器回调。methods: {
      drop(el) { 
      //触发一次事件就会将所有小球进行遍历
        for (let i = 0; i < this.balls.length; i++) {
          let ball = this.balls[i];
          if (!ball.show) { //将false的小球放到dropBalls
            ball.show = true;
            ball.el = el; //设置小球的el属性为一个dom对象
            this.dropBalls.push(ball); 
            return;
          }
        }
      },
      beforeDrop(el){ //这个方法的执行是因为这是一个vue的监听事件
        let count = this.balls.length;
        while (count--) {
          let ball = this.balls[count];
          if (ball.show) {
            let rect = ball.el.getBoundingClientRect(); //获取小球的相对于视口的位移(小球高度)
            let x = rect.left - 32;
            let y = -(window.innerHeight - rect.top - 22); //负数,因为是从左上角往下的的方向
            el.style.display = ''; //清空display
            el.style.webkitTransform = `translate3d(0,${y}px,0)`; 
            el.style.transform = `translate3d(0,${y}px,0)`;
            //处理内层动画
            let inner = el.getElementsByClassName('inner-hook')[0]; //使用inner-hook类来单纯被js操作
            inner.style.webkitTransform = `translate3d(${x}px,0,0)`;
            inner.style.transform = `translate3d(${x}px,0,0)`;
          }
        }
      },
      dropping(el, done) { //这个方法的执行是因为这是一个vue的监听事件
        /* eslint-disable no-unused-vars */
        let rf = el.offsetHeight; //触发重绘html
        this.$nextTick(() => { //让动画效果异步执行,提高性能
          el.style.webkitTransform = 'translate3d(0,0,0)';
          el.style.transform = 'translate3d(0,0,0)';
          //处理内层动画
          let inner = el.getElementsByClassName('inner-hook')[0]; //使用inner-hook类来单纯被js操作
          inner.style.webkitTransform = 'translate3d(0,0,0)';
          inner.style.transform = 'translate3d(0,0,0)';
          el.addEventListener('transitionend', done); //Vue为了知道过渡的完成,必须设置相应的事件监听器。
        });
      },
      afterDrop(el) { //这个方法的执行是因为这是一个vue的监听事件
        let ball = this.dropBalls.shift(); //完成一次动画就删除一个dropBalls的小球
        if (ball) {
          ball.show = false;
          el.style.display = 'none'; //隐藏小球
        }
      }
    }关于 getBoundingClientRect (位移的计算是从左上角开始)
getBoundingClientRect 获取到当前元素的坐标,然后需要位移的left减去元素的宽获取真正的最终位移x坐标getBoundingClientRect 获取到当前元素的坐标,然后需要当前屏幕的高度减去元素的 top 再减去元素本身的高度获取到真正的最终位移 y 坐标,并且这个是负数,因为是从左上角往下的方向关于html重绘
let rf = el.offsetHeight; 这是一个手动触发html重绘的方法.ball-container
      .ball
        position: fixed //小球动画必须脱离html布局流
        left: 32px
        bottom: 22px
        z-index: 200 
        transition: all 0.4s cubic-bezier(0.49, -0.29, 0.75, 0.41)
        .inner
          width: 16px
          height: 16px
          border-radius: 50%
          background: rgb(0, 160, 220)
          transition: all 0.4s linear整个流程是:
最后形成的星星html就类似这样
<div class="star star-48"> <span class="star-item on"></span> <span class="star-item on"></span> <span class="star-item on"></span> <span class="star-item on"></span> <span class="star-item half"></span> </div>
<template>
  <div class="star" :class="starType">
    <span v-for="itemClass in itemClasses" :class="itemClass" class="star-item"></span>
  </div>
</template>星星计算比较巧妙(根据分数转换为星星数)
<script>
  //设置常量
  const LENGTH = 5;
  const CLS_ON = 'on';
  const CLS_HALF = 'half';
  const CLS_OFF = 'off';
  export default{
    props: {
      size: { //传入的size变量
        type: Number //设置变量类型
      },
      score: { //传入的score变量
        type: Number
      }
    },
    computed: {
      starType(){ //通过计算属性,返回组装过的类型,用来对应class类型
        return 'star-' + this.size;
      },
      itemClasses(){
        let result = []; //返回的是一个数组,用来遍历输出星星
        let score = Math.floor(this.score * 2) / 2; //计算所有星星的数量
        let hasDecimal = score % 1 !== 0; //非整数星星判断
        let integer = Math.floor(score); //整数星星判断
        for (let i = 0; i < integer; i++) { //整数星星使用on
          result.push(CLS_ON);//一个整数星星就push一个CLS_ON到数组
        }
        if (hasDecimal) { //非整数星星使用half
          result.push(CLS_HALF);//类似
        }
        while (result.length < LENGTH) { //余下的用无星星补全,使用off
          result.push(CLS_OFF);//类似
        }
        return result;
      }
    }
  }
</script>[email protected] [email protected] [email protected] [email protected] [email protected] [email protected]
<style lang="scss" rel="stylesheet/scss">
  @import "../../common/css/mixin";
  .star {
    font-size: 0;
    .star-item {
      display: inline-block;
      background-repeat: no-repeat;
    }
    &.star-48 {  //48尺寸的星星
      .star-item {  //每一个星星的基本css信息
        width: 20px;
        height: 20px;
        margin-right: 22px;   //每一个星星dom都有外边距
        background-size: 20px 20px;
        &:last-child {  //最后一个的外边距就是0
          margin-right: 0;
        }
        &.on {  //全星状态的class
          @include bg-img('star48_on')
        }
        &.half {  //半星状态的class
          @include bg-img('star48_half')
        }
        &.off {   //无星状态的class
          @include bg-img('star48_off')
        }
      }
    }
    &.star-36 {
      .star-item {
        width: 15px;
        height: 15px;
        margin-right: 6px;
        background-size: 15px 15px;
        &:last-child {
          margin-right: 0;
        }
        &.on {
          @include bg-img('star36_on')
        }
        &.half {
          @include bg-img('star36_half')
        }
        &.off {
          @include bg-img('star36_off')
        }
      }
    }
    &.star-24 {
      .star-item {
        width: 10px;
        height: 10px;
        margin-right: 3px;
        background-size: 10px 10px;
        &:last-child {
          margin-right: 0;
        }
        &.on {
          @include bg-img('star24_on')
        }
        &.half {
          @include bg-img('star24_half')
        }
        &.off {
          @include bg-img('star24_off')
        }
      }
    }
  }
</style><ratingselect @select="selectRating" @toggle="toggleContent" :selectType="selectType"
                        :onlyContent="onlyContent" :desc="desc"
                        :ratings="food.ratings"></ratingselect>@select="selectRating" @toggle="toggleContent",通过将字组件的方法和父组件的方法进行关联,这样就能够实现跨组件通讯和操作:selectType="selectType":onlyContent="onlyContent" :desc="desc":ratings="food.ratings",这是通过pros传入到子组件的属性,将父组件的数据传到子组件里面,也带有一种通过父组件来初始化子组件属性的意思.<div class="ratingselect">
  <!--有使用一个border-1px的mixin-->
    <div class="rating-type border-1px">
    <!--绑定一个select方法控制切换,绑定class控制切换之后的按钮样式显示-->
      <span @click="select(2,$event)" class="block positive" :class="{'active':selectType ===2}">{{desc.all}}<span
        class="count">{{ratings.length}}</span></span>
      <span @click="select(0,$event)" class="block positive" :class="{'active':selectType ===0}">{{desc.positive}}<span
        class="count">{{positives.length}}</span></span>
      <span @click="select(1,$event)" class="block negative" :class="{'active':selectType ===1}">{{desc.negative}}<span
        class="count">{{negatives.length}}</span></span>
    </div>
    <!--绑定一个toggleContent方法来控制有内容和无内容的显示-->
    <div @click="toggleContent" class="switch" :class="{'on':onlyContent}">
      <span class="icon-check_circle"></span>
      <span class="text">只看有内容的评价</span>
    </div>
  </div>@click="select(2,$event)"  select方法传入类型和事件,然后在methods里面调用父组件的方法,实现子组件控制父组件的目的:class="{'active':selectType ===2}"  根据类型来确定显示的class,实现不同类型显示不同样式的目的positives.length 使用计算属性自动计算类型数组的长度,用来显示不同类型的数量@click="toggleContent" :class="{'on':onlyContent}"
toggleContent 控制是否展示有内容的rate,也是在methods里面调用父组件的方法,实现子组件控制父组件的目的on这个class来控制该按钮的样式const POSITIVE = 0; //设置显示常量
  const NEGATIVE = 1;
  const ALL = 2;
  export default{
    props: {
      ratings: { //传入ratings数组,跟food.ratings关联
        type: Array,
        default(){
          return [];
        }
      },
      selectType: { //跟selectType关联,通过在父组件里面设置这3个值来实现控制子组件的操作
        type: Number,
        default: ALL
      },
      onlyContent: { //跟onlyContent关联
        type: Boolean,
        default: true
      },
      desc: { //跟desc关联
        type: Object,
        default(){
          return {
            all: '全部',
            positive: '满意',
            negative: '不满意'
          }
        }
      }
    },
    computed: {
      positives(){ //自动过滤rateType(正面的rate)
        return this.ratings.filter((rating) => { //js的filter函数会返回一个处理后的(为true)结果的结果数组
          return rating.rateType === POSITIVE;
        })
      },
      negatives(){ //自动过滤rateType(反面的rate)
        return this.ratings.filter((rating) => {
          return rating.rateType === NEGATIVE;
        })
      }
    },
    methods: {
      select(type, event) { // 选择rateType并且通知父组件
        if (!event._constructed) {
          return;
        }
        this.$emit('select', type); // 派发事件,父组件监听此事件
      },
      toggleContent(event) { // 选择是否显示有内容的rate,并且通知父组件
        if (!event._constructed) {
          return;
        }
        this.$emit('toggle');
      }
    }
  }(item,index) in goods:class="{'current':currentIndex === index}" 是vue的绑定class的使用方法,通过绑定一个class变量来直接操作,并且这里的逻辑会跟js代码里面对应
v-show 和 v-if 的区别官网已经说过
一般来说, v-if 有更高的切换开销,而 v-show 有更高的初始渲染开销。因此,如果需要非常频繁地切换,则使用 v-show 较好;如果在运行时条件不太可能改变,则使用 v-if 较好。
$refs 的使用是vue操作dom的一种方式:
<food @add="addFood" :food="selectedFood" ref="food"> 是通过selectFood方法写入到vue实例里面,然后传给子组件food<shopcart ref="shopcart" :selectFoods="selectFoods"  这里selectFoods被自动添加了count属性,是为了让购物车更加简单的计算已选择的food这里最关键的是menu和food两个区域的对应处理:
_initScroll和_calculateHeight_calculateHeight 计算foods内部每一个块的高度,组成一个数组listHeight_initScroll 里面,设置了bscroll插件的一个监听事件scroll,将food区域当前的滚动到的位置的y坐标设置到一个vue实例属性 scrollY this.scrollY = Math.abs(Math.round(pos.y));:class="{'current':currentIndex === index},实现联动this.foodsScroll.scrollToElement(el, 300);关于在selectMenu中点击,在pc界面会出现两次事件,在移动端就只出现一次事件的问题:
原因:
解决:
_constructed: true,所以做处理,return 掉非bsScroll的事件index.scss是SCSS文件的入口文件,里面使用 @import 引入各种SCSS文件
@import "./base"; @import "./mixin"; @import "./icon.css";
在入口文件main.js中全局引用index.scss
import 'common/css/index.scss';
eslint 是一个js代码风格检查器,配合vue-cli脚手架中的热更新,可以很方便的定位和提示错误。在公司多人协作开发时可以确保代码风格保持一致,可以很方便的阅读他人的代码。
将 localhost 换成自己的ip (Windows在命令行执行ipconfig查看,mac执行ifconfig查看)
然后复制地址栏地址,进入草料二维码,然后生成二维码,然后用手机扫一扫就可以查看了,前提是,你手机和电脑必须在同一个局域网。
克隆项目到本地 git clone [email protected]:nanyang24/eleme-vue.git 安装依赖 npm install 本地开发,开启服务器,浏览器访问http://localhost:8080 npm run dev 构建生产 npm run build 运行打包文件 node prod.server.js 会看到 Listening at http://localhost:9000 在浏览器中打开即可
background-color: blue;background-color: yellow;<input type="button" value="变蓝" @click="changeColorT