韩吉鑫 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