记 vue 移动端开发 中的经验

项目背景

手上的 vue移动端 项目已经开发了大几个月了,遇到了一些很有意思的坑,也让自己学习了很多;写此文主要目的是记下一些我遇到的坑,以及自己的解决方案,分享的同时也方便以后复习。

项目的底层是上司通过 Cordova 等常用的 hybird app工具打包出来的。然后通过 webview 打开我的vue项目。所以严格意义上说,我还是在做单页面应用。 hybird app 的底层会提供一些api 给我调用,方便我关闭打开webview,或者跳转到不同子页面。hybird app会集成不同的业务。这些业务有hybird app本事的服务,也有像我这种,完全来自其服务的页面。这些就是项目大概的背景。

提示:由于是项目总结文章,可能总结点会比较混乱,部分先后,想到什么写什么。

移动端resize.css

body, h1, h2, h3, h4, h5, h6, hr, p, blockquote, dl, dt, dd, ul, ol, li, pre, form, fieldset, legend, button, input, textarea, th, td { margin:0; padding:0;box-sizing: border-box; }
body, button, input, select, textarea { font:12px/1.5tahoma, arial, \5b8b\4f53; }
address, cite, dfn, em, var { font-style:normal; }
code, kbd, pre, samp { font-family:couriernew, courier, monospace; }
small{ font-size:14px; }
ul, ol { list-style:none; }
a { text-decoration:none; color:#000;}
a:hover { text-decoration:none; }
sup { vertical-align:text-top; }
sub{ vertical-align:text-bottom; }
legend { color:#000; }
fieldset, img { border:0; }
button, input, select, textarea { font-size:100%; }
table { border-collapse:collapse; border-spacing:0; }
input{-webkit-appearance: none;}

//直接再main.js 中引入就可以,common.css 也一样

* common.css
/*
 * @Author lizhenhua
 * @version 2018/5/14
 * @description
 */




/*--------------头中底布局样式*/

html {
  line-height: initial;
}

body {
  font-size: 0.32rem;
  //padding-top: constant(safe-area-inset-top);
  //padding-top: env(safe-area-inset-top);
}

html, body{
  position: relative;
  height: 100%;
  /*overflow-y: auto;*/
  /*overflow-x: hidden;*/ /*这里不能加overflow所有属性,在苹果下会有上下拉盖住顶部底部的bug */
}

.page{
  height: 100vh;
  box-sizing: border-box;
  //position: relative;/*relative 不能加载page上,会导致切换动画失效*/
}
.page-overflow{
  height: 100%;
  overflow: hidden;
}
.mobile-top{
  background: #3275dd;
  position: absolute;
  z-index: 1000;
  top: 0;
  left: 0;
  right: 0;
  padding-top: 20px;
  padding-top: constant(safe-area-inset-top); /* 这里需要使用 calc 动态计算 */
  padding-top: env(safe-area-inset-top);
  padding-left: constant(safe-area-inset-left);
  padding-left: env(safe-area-inset-left);
  padding-right: constant(safe-area-inset-right);
  padding-right: env(safe-area-inset-right);
}
.mobile-content {
  width: 100%;
  overflow: hidden;
  background: #f1f2f6;
  height: 100vh;
  box-sizing: border-box;
  position: relative;
  padding-top:62.5px;
  padding-top: calc(constant(safe-area-inset-top) + 42.5px);/*1.25rem 本身就预留了信号bar高度0.4rem,这里要减去*/
  padding-top: calc(env(safe-area-inset-top) + 42.5px);
  padding-bottom:50px;
  padding-bottom: calc(constant(safe-area-inset-bottom) + 50px);
  padding-bottom: calc(env(safe-area-inset-bottom) + 50px);
  padding-left: calc(constant(safe-area-inset-left));
  padding-left: calc(env(safe-area-inset-left));
}
.mobile-content-pb0{
  padding-bottom: 0;
  padding-bottom: constant(safe-area-inset-bottom);
  padding-bottom: env(safe-area-inset-bottom);
}
.mobile-bottom{
  height: 1rem;
  height: calc(constant(safe-area-inset-bottom) + 50px);
  height: calc(env(safe-area-inset-bottom) + 50px);
  /*position: fixed;*/
  position:absolute;
  overflow: hidden;
  box-shadow: 0px 0 1px 1px #ccc;
  background: #fff;
  border-bottom: 1px solid #ccc;
  z-index: 1000;
  display: flex;
  left: 0;
  right: 0;
  bottom: 0;
  padding-bottom: constant(safe-area-inset-bottom);
  padding-bottom: env(safe-area-inset-bottom);
  padding-left: constant(safe-area-inset-left);
  padding-left: env(safe-area-inset-left);
  padding-right: constant(safe-area-inset-right);
  padding-right: env(safe-area-inset-right);
}
//安卓弹窗键盘顶起底部的bug
@media screen and (max-height: 450px) {
  .mobile-bottom{
    display: none;
  }
}
.load-more-content{ //让拉动屏幕底部也可以刷新 load-more
  min-height: 77vh;
}
input[readonly]{
  background: #eee;
}
input:focus {
  outline: none;
}
.v-icon{
  width: 17px;
  height: 17px;
}
.icon{
  width: 17px;
  height: 17px;
}
/*动画闪屏bug*/
.mint-loadmore-content{
  -webkit-transform-style: preserve-3d;
  -webkit-backface-visibility: hidden;
  transform: translate3d(0,0,0);
  transform-style: preserve-3d;
  backface-visibility: hidden;
  li{
    -webkit-backface-visibility: hidden;
    backface-visibility: hidden;
  }
}
/*end 动画闪屏bug*/

/*fix 移动端输入板 挡住 input ,textarea 的bug*/
.input-bug{
  position: absolute;
  top: 20%;
  left: 0;
  right: 0;
  z-index: 6000;
}
 #inputBugModel{
  width: 4000px;
  height: 4000px;
  top:50%;
  left: 50%;
  transform: translate(-50%,-50%);
  position: absolute;
  background-color: #000;
  opacity: 0.5;
  z-index: 5000;
}
.input-bug-oh{
  overflow: hidden!important;
  -webkit-overflow-scrolling: inherit;
}
/*end fix移动端输入板 挡住 input textarea 的bug*/

/*end--------------------------- 头中底布局样式*/


/*-------------工具类*/
.flex-ar{
  display: flex;
  justify-content: space-around;
  align-items: center;
}
.flex-bet{
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.fl{
  float: left;
}
.fr{
  float: right;
}
.clear{
  *zoom: 1;
}
.clear:before,
.clear:after {
  display: table;
  line-height: 0;
  content: "";
}

.clear:after {
  clear: both;
}

.dian{
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap
}

.dian4{
  overflow: hidden; /*超出隐藏*/
  text-overflow: ellipsis; /*文本溢出时显示省略标记*/
  display: -webkit-box; /*设置弹性盒模型*/
  -webkit-line-clamp: 4; /*文本占的行数,如果要设置2行加...则设置为2*/
  -webkit-box-orient: vertical; /*子代元素垂直显示*/
}

.dian3 {
  overflow: hidden; /*超出隐藏*/
  text-overflow: ellipsis; /*文本溢出时显示省略标记*/
  display: -webkit-box; /*设置弹性盒模型*/
  -webkit-line-clamp: 3; /*文本占的行数,如果要设置2行加...则设置为2*/
  -webkit-box-orient: vertical; /*子代元素垂直显示*/
}
.wh100{
  width: 100%;
  height: 100%;
}
.oh{
  overflow: hidden!important;
  -webkit-overflow-scrolling: inherit;
}
.hide{
  display: none;
}
.no-scroll{
  position: fixed;
  width: 100%;
}
.pd{
  padding:0.2rem;
}
.pd20{
  padding:0.2rem;
}
pl20{
  padding-left:0.2rem;
}
pr20{
  padding-right:0.2rem;
}
.mb0{
  margin-bottom: 0;
}
.mb20{
  margin-bottom: 0.2rem;
}
.mt10{
  margin-top: 0.1rem;
}
.mt20{
  margin-top: 0.2rem;
}
.ml10{
  margin-left: 0.1rem;
}
.tr{
  text-align: right!important;
}
.nowrap{
  white-space: nowrap;
}
.ab-mid{
  position: absolute;
  top:50%;
  left: 50%;
  transform: translate(-50%,-50%);
}
.no-data{
  text-align: center;
  color: #ccc;
  padding: .5rem;
}
.clearfix:after {       //在类名为“clearfix”的元素内最后面加入内容;
content: ".";     //内容为“.”就是一个英文的句号而已。也可以不写。
display: block;   //加入的这个元素转换为块级元素。
clear: both;     //清除左右两边浮动。
visibility: hidden;      //可见度设为隐藏。注意它和display:none;是有区别的。仍然占据空间,只是看不到而已;
height: 0;     //高度为0;
font-size:0;    //字体大小为0;
}

.no-height {
  height: auto !important;
  .mint-button {
    border-radius: 0;
  }
}
.bg0{
  background: #fff;
}
.bg1{
  background: #f8f8f8;
}

.loading{ /*css3 loading icon*/
  margin: 0;
  padding:0;
  display: inline-block;
  width: 20px;
  height: 20px;
  border: 1px solid #3275dd;
  border-radius: 50%;
  border-left: none;
  animation: rotates 0.8s infinite linear;
}
@keyframes rotates {
  0% {transform: rotate(0);}
  100% {transform: rotate(360deg);}
}


/*动画*/
  .fade-enter-active {
    transition: all .2s ease;
  }
  .fade-leave-active {
    transition: all .3s ease;
  }
  .fade-enter, .fade-leave-to
    /* .slide-fade-leave-active for below version 2.1.8 */ {
    transform: translateX(100px);
    opacity: 0;
  }
/*end动画*/



/*end-------------工具类*/


/*-------------默认设定*/

/*end-------------默认设定*/


/*---------------form 相关*/
.form-card-input{
  padding:10px 0.2rem;
  border: none;
  font-size: 14px;
  text-align: right;
  &:focus{
    text-align: left;
  }
}
.form-line{
  width: 100%;
  height: 15px;
  background-color: #f8f8f8;
}
/*小纸条*/
.paper-tips {
  background: #f7f7f7;
  padding: 0.3rem 0.2rem;
  font-size: 15px;
  .tips-top {
    .btn {
      color: #2f6fdd;
    }
  }
  p {
    padding: 0.1rem 0;
    color: #d9534f;
    line-height: 0.4rem;
    font-size: 13px;
    text-align: left;
  }
}
/*end 小纸条*/

/*行中提示*/
.tips {
  font-size: 14px;
  text-align: left;
  padding: 5px 15px;
  color: #a0a0a0;
  background-color: #f8f8f8;
  b {
    font-weight: normal;
  }
}
/*end行中提示*/

/*通用input框 样式*/
.icon-input-style{
  color: #191919;
  margin-top: 0.1rem;
  border: 1px solid #cccccc;
  border-radius: 5px;
  overflow: hidden;
  height: 30px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  input{
    border: none;
    margin: 0;
    padding:0 0.2rem;
    height: 100%;
    width: 100%;
  }
  .iconfont{
    font-size: 20px;
    padding-left: 0.1rem;
    border-left: 1px solid #a4e1fe;
  }
}
/*end通用input框 样式*/

.no-touch.mint-button{/*禁止点击按钮*/
  background-color: #c8c9cc;
  color:#fff;
}


/*改 radio 控件样式*/
.mint-radiolist /deep/ {
  display: flex;
  justify-content: space-around;
  .mint-cell-wrapper {
    font-size: 14px;
    padding: 0;
    border: none!important;
    background-image: none!important;
    background: transparent!important;
  }
  .mint-cell {
    min-height: auto;
    background: transparent!important;
    background-image: none!important;
  }
  .mint-radio-input:checked + .mint-radio-core {
    background-color: #fff;
  }
  .mint-radio-input:checked + .mint-radio-core::after {
    background-color: #26a2ff;
  }
}


/*------------end form相关*/


/*---------------副页面相关*/
/*圆角弹窗*/
.radius-popup{
  border-radius: 10px;
  overflow: hidden;
}
.radiusPopup{
  border-radius: 5px;
  overflow: hidden;
}
/*my-popup 右划页面样式*/
body{
  /deep/ .my-popup {
    width: 100%;
    height: 100%;
    .mint-button{
      height: 100%;
    }
    .mobile-content{
      height: 100%;
      box-sizing: border-box;
    }
  }
}
.mint-button{
  .mint-button-text{
    user-select: none;
  }
}
/*end my-popup*/

/*loading圈层级*/
.mint-msgbox-wrapper{
  z-index: 3000!important;
  .mint-msgbox{
    box-shadow: 0 0 10px #ccc;
  }
}
.mint-indicator-wrapper{
  z-index: 4000;
}
.mint-indicator-mask{ //loading 盖住页面
  z-index: 4000;
}
/*end loading圈层级*/

/*表格*/
.gf-table {
  text-align: left;
  .t-head {
    background: #f5f5f5;
    font-size: 14px;
    height: 35px;
    color: #8f8f8f;
  }
  .row {
    height: 100%;
    display: flex;
    justify-content: space-around;
    align-items: center;
    padding: 0 0.2rem;
    .item {
      text-align: left;
      width: 2rem;
      font-size: 13px;
      span {
        color: #8f8f8f;
      }
    }
    .item:last-child {
      width: 3rem;
    }
  }
  .t-body .row {
    min-height: 50px;
    border-bottom: 1px solid #ededed;
    margin-left: 0.2rem;
    padding: 0 0.2rem 0 0;
    &:last-child {
      border-bottom: none;
    }
  }
}
/*表格end*/

/*Toast 颜色*/
.mint-toast{
  z-index: 2010;
  word-break: break-all;
}
.mint-toast.is-placebottom{
  font-weight: bolder;
  &.err{
    //background: rgba(245,108,108,0.8);
    background: #feccd5;
    color:#f56c6c;
  }
  &.suc{
    //background: rgba(103,194,58,0.8);
    background: #cdf9c3;
    color:#67c23a;
  }
  &.warn{
    //background: rgba(230,162,60,0.8);
    background: #fde8af;
    color:#e6a23c;
  }
  &.info{
    //background: rgba(144,147,153,0.7);
    background: #eaeaeb;
    color: #686b71;
  }
}
/*end Toast 颜色*/


/*end---------------副页面相关*/




上中下三部分的css定位问题。

这个问题我在 文章 中已经详细说过。

rem 的使用;

我直接在 app.vue 中添加以下方法,运行后,你会在html 标签中看到 fontsize 设置为了50px; 表示 1rem = 50px;

created() {
      this.resize(document, window);
    },
    
methods:{
    /*设置rem参照单位。width:1rem = 50px 所以设计稿宽 375px == 375/50 = 7.5rem
      * 由于页面中有些元素用了绝对定位。特别是top,bottom。由于设备不同,计算出的rem不同,
      * 导致定位覆盖。所以,建议涉及高度的 统一用 px 做单位,包括padding-top,bottom等。
      * 因为高度存在滚动条,不存在适配问题。主要针对宽度做适配。
      *
      * */
      resize(doc, win) {
        var docE1 = doc.documentElement,
          resizeEvt = 'orientationchange' in window ? 'orientationchange' : 'resize',
          recalc = function () {
            var clientWidth = docE1.clientWidth;
            if (!clientWidth) return;
            //docE1.style.fontSize = clientWidth / 375  + 'px'; 这里希望设置 1rem = 1px,实验证明,这样做 会导致 html 的 fontsize小于 12px
            docE1.style.fontSize = (clientWidth / (375*2)) * 100 + 'px'; //乘以100的意义是,1为了不受fontsize小于12的影响,2为了计算方便;
          };
        if (!doc.addEventListener) return;
        win.addEventListener(resizeEvt, recalc, false);
        doc.addEventListener('DOMContentLoaded', recalc, false);
      },
}    

clipboard.png

使用建议:
1,少量大小的定义尽量使用px,因为对自适应效果影响不大。例如某个div的padding,设置为5px 10px,影响是不大的。
2,宽度上的定义尽量使用rem 作为单位,因为移动设备对宽度敏感,可谓寸金寸土。设置了以上代码后,可以通过设计稿尺寸/50 得到rem单位的数值。 例如 padding:10px; 可以写成 padding: 10px 0.2rem; 或者 padding:0.2rem;
3,高度上的定义,尽量使用px;因为本项目可以滚动内容页,所以高度是不敏感的。设置为px 的原因是,后面定位 loadermore 组件会有帮助。当然,如果你对计算很有把握,或者页面内容不允许滚动,也可以使用 rem;

刷新某个子页面

遇到一个填写表单点保存形成草稿模式的需求。要求在url中加入参数 id;刷新本页面,重新通过id获取数据回填。 vue 是单页面应用,肯定不能全局刷新。

同事的解决方案

调用保存接口,获取到id后, 通过

this.router.push(this.$route.path + "&id=" + id);//加参数本页并不会刷新

改变url ,然后重新申请 调用接口,拿到最新的数据,回填回去。
这样做,理论上是行得通的。当时很危险,因为用户操作页面,会改变很多变量。如果回填数据后,由于没有经历完整的created等生命周期,这些变量还是原来状态,容易出bug;
其次,如果像本项目那样,需要支持 hybird app 通过url+id 的方式直接去到草稿的话,代码不好维护。所以,最理想的做法,就是真实的重新
load 一次这个子页面。

正确做法

利用vue 的provide / inject api

* app.vue 中定义
 <router-view v-if="isRouterAlive"/>
data() {
      return {
        isRouterAlive: true,
      }
    },
 provide() {
      return {
        reload: this.reload,
      }
    },
methods: {
    reload() {
        this.isRouterAlive = false
        this.$nextTick(() => (this.isRouterAlive = true))
      },
    }


* 需要刷新的子页面
inject: ['reload'],

//需要调用的地方
let path =  this.$route.path+"?id="+id
this.$router.replace(path);
this.reload();

keep-alive 页面怎么刷新

这个需求很常见,有个列表页面,点击某一条去到详情页面,点击返回,列表页面保持状态不变,滚动条保持原来位置。如果,详情对数据做了改变,点击返回,列表页面才刷新。

* app.vue 中
<div id="app">
    <keep-alive>
      <router-view v-if="isRouterAlive&&$route.meta.keepAlive"/>
    </keep-alive>
    <router-view v-if="isRouterAlive&&!$route.meta.keepAlive"/>
  </div>

* route.js 中
{
      path: 'a',//我的草稿
      name: 'myDraft',
      meta:{
        keepAlive:true,
      },
      component: resolve => require(['page/myDraft'],resolve)
    },

这样,定义了meta keepAlive 为true 的页面就会被 缓存。数据不变的情况下,点击返回, 只要把滚动条位置设置到原来离开哪里就好了。

但是问题来了,1,从首页进入 keepAlive 页面,每次都要刷新,二,详情页如果改变了数据,返回后也要刷新 页面。

这里我主要通过 eventBus 来解决了组件通知 页面 刷新的问题。
细节可以看 我的笔记,最好的实践应该是最后提到大神的链接文章。

topBar组件 点击返回,回到各个出发页面。

* topBar.vue 
组件的封装并不难,就是预留自定cancel函数,不然就调用 app.vue 中的 backHome 函数 对返回做统一处理
inject:['backHome'],
 cancel(){
        if(this.popup){ 
          this.$emit('cancel')
        }else{
          this.backHome();
        }
      },
      
* app.vue

provide() {
      return {
        backHome:this.backHome
      }
    },
backHome(){ //返回或退出webview
        let  isOutsidePage = this.$route.params.inside;
        let  from = this.$route.params.from;
        if(isOutsidePage=='in'){ //内页跳转
          if(from=="CC"){ //回到a中心
            this.$router.replace('/controlCenter')
          }else if(from=="SF"){ //回到b中心
            this.$router.replace('/controlCenter2')
          }else { //回到原来的子页面(从a页到b页前,必须要先保存lastFullPath)
            this.$router.replace(this.$store.getters.lastFullPath)
            this.$store.commit('setLastFullPath',"")//置空旧路径
          }
        }else{//关闭webView
            closeWebView();
        }
      }
      
* router.js
{
      path: '/myDraft/:from/:inside',
      name: 'myDraft',
      component: resolve => require(['page/myDraft'],resolve)
    },
    {
      path: '/myDraft', 
      redirect: 'myDraft/ll/out',
    },  
            

通过上面的定义 //hybrid app 只需要调用 ip:xxxx/myDraft 就能打开这个页面,并且返回键自动关闭webview;

通过 CC CF 等标志字符 可以判断来自哪个 中心的。

最后来到重点的 子页跳子页返回 操作,主要就是需要借助vuex 保存旧 路径

a.vue 子页
//跳转前先把当前路径保存到全局vuex变量lastFullPath
this.$store.commit('setLastFullPath',route.fullPath)//保存路由用于返回本页
this.$router.replace('/ ');//清空路由,不重置会导致url 混乱。
this.$router.replace(`b/`+route.name+`/in?id=`+id);

eventBus 使用

bus.vue

import Vue from 'vue'
export default new Vue()

//监听事件
Bus.$on('update', (param) => { //监听数据变动
        this.updatexxx(param);
      })
      
//触发事件
Bus.$emit('update',param)

//销毁事件监听
 Bus.$off('update');    

用钻层列表 代替 树形组件

树形选择 组件在pc端是常常用到的。特别是一些有明确层级关系,又需要勾选的数据。
但是移动端开发不能用树,通常就是像百度网盘那样,类型文件夹的方式交互。

原理

我项目是选择部门,然后选择人员,勾选或者取消。支持快速查询选择。
我的思路是,设置两个组件,一个presonInput,一个personBox;
personInput 主要用于表单中的显示,支持输入中文或者拼音,查找并生成选中人员。
personBox 便于选择多个人或部门,是一个页面大小的弹窗页,钻层列表,支持搜索。
input和Box 两个组件 都通过v-model 为父页面 维护同一组数据。就是选择的人员的数组。

实现

* personInput.vue 核心代码

created(){
      document.addEventListener('touchstart',(e)=>{  //点击其他地方下拉框消失
        if(this.$refs['con']&&!this.$refs['con'].contains(e.target)){
          this.visible=false;
        }
      })
    },

mounted(){
      Bus.$emit('updateHasSelectPerson');//通知selectPerson 组件更新缓存;
    },
    
cancelSelect(item) {
        //用这一句会不准确,请用findIndex
        // this.hasSelectPerson.splice(this.hasSelectPerson.indexOf(item),1);
        this.hasSelectPerson.splice(this.hasSelectPerson.findIndex(k => k.id == item.id), 1);
        Bus.$emit('updateHasSelectPerson');
      },    

 selected(item) {
        this.visible = false;
        this.inputText = "";
        if (this.one) {
          this.hasSelectPerson.splice(0);//先清空数组
        }else if(this.limit&&this.hasSelectPerson.length==this.limit){
          this.sureTips("最多选择 "+this.limit+" 个人");
          return;
        }
        //从带部门的接口中,选择出id与 人员接口的userCode 相同的人
        this.$http({
          url: this.ajaxApi.department.search,
          type: "post",
          data: {
            key: item.name,
          }
        }).then(res=>{
          let theGuy = res.filter(i=>{
            return i.id == item.userCode
          })
          this.hasSelectPerson.push(theGuy[0]);
        })
        Bus.$emit('updateHasSelectPerson'); //通知personBox 组件同步更新数据
      },
  • personBox 核心代码
<template>
  <div class="my-popup">
    <topBar :back="true" :popup="true" :title="title" @cancel="cancel" :saveBtn="true" @save="save"></topBar>
    <div class="mobile-content mobile-content-pb0">
        <div class="pd20">
          <div class="icon-input-style">
            <input type="text" v-model="searchText" @keyup.enter="search" @blur="search" :placeholder="`请输入人名或拼音搜索`">
            <icon icon-class="icon-search" @click.native="search"></icon>
          </div>
          <div class="list-btn flex-bet">
            <span v-if="dataIndex==1" class="no-more">
              <icon icon-class="icon-houtui"  size="25"></icon>
              <b>上一层</b>
            </span>
            <span v-if="dataIndex>1" @click="goBack">
               <icon icon-class="icon-houtui"  size="25"></icon>
              <b>上一层</b>
            </span>
            <span v-if="dataIndex==listData.length" class="no-more">
              <b>下一层</b>
              <icon icon-class="icon-qianjin"  size="25"></icon>
            </span>
            <span v-if="dataIndex>=1&&dataIndex<listData.length" @click="forward">
              <b>下一层</b>
              <icon icon-class="icon-qianjin"  size="25"></icon>
            </span>
          </div>
          <div class="person-list">
            <ul v-if="person&&person.length>0">
              <li v-for="(item,index) in person" :key="item.id">
                <div v-if="item.isParent==`false`" class="check-box" @click="selected(item.id,item,item.checked)">
                  <icon v-if="item.checked" color="#42bd56" icon-class="icon-checkbox-copy"></icon>
                  <icon v-else color="#000" icon-class="icon-checkbox"></icon>
                </div>
                <div class="item-icon">
                  <icon v-if="item.isParent==`true`" size="20" color="#2e6bd5" icon-class="icon-bumen"></icon>
                  <icon v-else size="20" color="#2e6bd5" icon-class="icon-iconmaijia" @click.native="selected(item.id,item,item.checked)"></icon>
                </div>
                <div class="item-title dian" v-if="item.isParent==`true`" @click="getData(item.id)">{{item.display}}</div>
                <div class="item-title dian" v-else @click="selected(item.id,item,item.checked)">{{item.display}}</div>
                <div v-if="item.isParent==`false`" class="selected-span" @click="selected(item.id,item,item.checked)"></div>
                <div v-else class="selected-span" @click="getData(item.id)"></div>
              </li>
            </ul>
            <ul v-else>
              <li style="justify-content: center;">暂无数据</li>
            </ul>
          </div>
        </div>
        <div class="pd20">
          <div class="has-select">
            <h3>已选择的人员:</h3>
            <ul class="person-name-box">
              <li v-for="item in hasSelectPerson" class="person-name">{{item.name}}<span @click="cancelSelect(item)">×</span></li>
            </ul>
          </div>
        </div>
    </div>
  </div>
</template>
<script>
  import Bus from "../common/bus.js"
  export default {
    data: function () {
      return {
        person : [],//部门的person数组,
        searchText:"",//搜索关键字
        listData:[],//缓存每次查询结果
        dataIndex:1, //当前渲染data指针
        forwardAction:false,//方便模拟 上一步动作
        oldSelected:[],
        first:true,
      }
    },
    model:{
      prop:'hasSelectPerson',
      event:'change'
    },
    props:{
      hasSelectPerson:{ //已经选择的人员
        type:Array,
        default:()=>{
          return []
        }
      },
      title:{ //弹窗标签
        type:String,
        default:"选择人员"
      },
      one:{//是否单选
        type:Boolean,
        default:false
      },
      limit:{
        type:Number,
        default:100,
      }
    },
    created(){
      //缓存第一次进来的数据,方便后面取消选择操作使用
      if(this.first){ //第一次操作,并且新旧值相同
        this.oldSelected = this.tools.cloneObj(this.hasSelectPerson);
        this.first = false;
      }
      this.$store.dispatch('getDeptList').then(res=>{
        this.upDatePerson(res)
      })
    },
    mounted(){
      Bus.$on('updateHasSelectPerson', () => { //监听数据变动
        this.save();
      })
    },
    watch:{
      //选择的人员如果改变,就更新person
      hasSelectPerson(val){
        //当在 personInput 改变了 hasSelectPerson 数组的时候,手动同步 oldSelected
        if(this.first&&!this.tools.eq(this.oldSelected,this.hasSelectPerson)){
          this.oldSelected = this.tools.cloneObj(this.hasSelectPerson);
        }
        this.person.forEach(k=>{
          k.checked = false;
          val.forEach(o=>{
            if(k.id == o.id){
              k.checked = true
            }
          })
        })
      },
      //如果变为单选,就取第一个已选择人员
      one(val){
        if(val){
          this.hasSelectPerson.splice(1);//先清空数组
        }
      }
    },
    methods: {
      upDatePerson(res,boor){ //boor 为true时,不改变listData;
        if(!boor){
          this.listData.push(res)
        }
        this.person = res
        if(this.person){
          this.person.forEach(k=>{
            k.checked = false;
            this.hasSelectPerson.forEach(o=>{
              if(k.id == o.id){
                k.checked = true
              }
            })
          })
        }
      },
      cancel() {
        if(!this.tools.eq(this.oldSelected,this.hasSelectPerson)){
          this.MessageBox({
            showCancelButton:true,
            confirmButtonText:'保存',
            cancelButtonText:'不保存',
            title:'改变保存',
            message:'选择人员发生改变,需要保存吗?',
          }).then((res)=>{
            if(res=="confirm"){
              this.save();
            };
            if(res=='cancel'){
              this.hasSelectPerson.splice(0);//清空已选择
              this.hasSelectPerson.push(...this.oldSelected);//用原来的替换
              this.$emit('cancel')
            }
          })
        }else{
          this.$emit('cancel')
        }
      },
      save(){
        this.first = true;
        this.oldSelected  =this.tools.cloneObj(this.hasSelectPerson);
        this.$emit('cancel')
      },
      selected(id,item,checked){
        this.first = false;//区别于 personInput 的select操作
        if(!checked){ //如果未选择,就操作选中
          if(this.one){
            this.hasSelectPerson.splice(0);//先清空数组
          }else if(this.limit&&this.hasSelectPerson.length==this.limit){
            this.sureTips("最多选择 "+this.limit+" 个人");
            return;
          }
          this.hasSelectPerson.push(item);
        }else{
          //这里如果用filter ,会完全替换了 this.hasSelectPerson;vue 失去了双向绑定
          // this.hasSelectPerson = this.hasSelectPerson.filter((o)=>{return o.id!=id});
          //下面这样只是在原数组上做修改,所以没有破坏双向绑定机制;
          this.hasSelectPerson.splice(this.hasSelectPerson.findIndex(k=>k.id==item.id),1);
        }
      },
      cancelSelect(item){
        this.first = false;//区别于 personInput 的select操作
        this.hasSelectPerson.splice(this.hasSelectPerson.findIndex(k=>k.id==item.id),1);
      },
      //功能:获取数据
      getData(id){
        this.$store.dispatch('getDeptListChild',id).then(res=>{
          if(this.forwardAction){ //点击了上一步,然后紧接着加载数据,则先把之前的下一层的缓存去掉,模拟浏览器行为
            this.listData.splice(this.dataIndex);
          }
          this.dataIndex++;
          this.upDatePerson(res)
        })
      },
      search(){
        if(!this.searchText){
          this.ToastTip("请输入名字查找",'warn')
          return
        }
        this.$http({
          url: this.ajaxApi.department.search,
          type:"post",
          data:{
            key:this.searchText
          }
        }).then(res=>{
          this.dataIndex+=1;
          this.upDatePerson(res)
        })
      },
      /**
      * 作者:lzh
      * 功能:返回上一步
      * 参数:
      * 返回值:
      */
      goBack(){
        this.dataIndex--;
        this.forwardAction = true;//点击了上一步
        this.upDatePerson(this.listData[this.dataIndex-1],true)
      },
      /**
      * 作者:lzh
      * 功能:返回下一步
      * 参数:
      * 返回值:
      */
      forward(){
        this.dataIndex++;
        this.forwardAction = false;
        this.upDatePerson(this.listData[this.dataIndex-1],true)
      }
    },
  }
</script>
<style lang="scss" scoped>
  .icon-input-style{
    background: #fff;
    margin-bottom: 0.1rem;
    box-shadow: 0 1px 1px 1px #ccc;
    i{
      color:#2e6bd5;
    }
  }
  .person-list{
    background: #fff;
    height: 5rem;
    overflow-y: auto;
    border-radius:0 0 5px 5px;
    ul{
      padding:0.1rem 0.1rem;
      li{
        border-bottom: 1px solid #f2f6fd;
        height: .7rem;
        display: flex;
        justify-content: left;
        align-items: center;
        .check-box,.item-icon{
          width: 0.7rem;
          height: 100%;
          text-align: center;
          line-height:  0.7rem;
          i{
            margin-right: 0;
          }
        }
        .item-title{
          height: 100%;
          line-height: 0.7rem;
          font-size: 16px;
          &:active{
            opacity: 0.4;
          }
        }
        .selected-span{
          flex:2;
          height: 100%;
        }
      }
    }
  }
  .list-btn{
    padding:0.2rem 1rem;
    background: #fff;
    border-radius: 5px 5px 0 0;
    /*margin-top: 0.2rem;*/
    padding-bottom: 0.1rem;
    border-bottom: 1px solid #f2f6fd;
    span{
      font-size: 14px;
      display: flex;
      align-items: center;
      i{
        margin:0 5px;
      }
    }
    span.no-more{
      opacity: 0.4;
    }
    span:active{
      opacity: 0.4;
    }
    .iconfont{
      opacity: 0.8;
    }
  }
  .person-name-box{
    text-align: left;
    padding:0.2rem;
    line-height: 0.2rem;
    box-sizing: border-box;
    max-height: 4rem;
    overflow-y: auto;
    .person-name{
      display: inline-block;
      padding:0.15rem;
      background:#4e7ccc;
      color: #fff;
      border-radius: 3px;
      margin-right: 0.2rem;
      margin-bottom: 0.1rem;
      margin-left: 0.1rem;
      margin-top: 0.1rem;
      position: relative;
      line-height: 0.25rem;
      font-size: 14px;
      span{
        display: inline-block;
        width: 0.3rem;
        height: 0.3rem;
        background: red;
        border-radius: 50%;
        text-align: center;
        font-size: 16px;
        line-height: 0.3rem;
        position: absolute;
        top:-0.1rem;
        right: -0.2rem;
      }
    }
  }
  .has-select{
    h3{
      text-align: left;
      font-size: 16px;
      height: 0.6rem;
      line-height: 0.6rem;
    }
    .person-name-box{
      background: #fff;
      border-radius: 5px;
      height: 3.5rem;
      overflow-y: auto;
    }
  }

</style>
  • 父组件使用
 <person-input title="授权人" @select="$refs[`permitMenBox`].open()" required v-model="permitMen" :one="true"/>

 <personBox @cancel="$refs[`permitMenBox`].close()" v-model="permitMen" :one="true"/>

permitMen: [],

效果

  • input效果

clipboard.png

  • personBox 效果

clipboard.png

比较两个对象是否相等

eq(a, b, aStack, bStack) {
    var toString = Object.prototype.toString;

    function isFunction(obj) {
      return toString.call(obj) === '[object Function]'
    }

    function eq(a, b, aStack, bStack) {

      // === 结果为 true 的区别出 +0 和 -0
      if (a === b) return a !== 0 || 1 / a === 1 / b;

      // typeof null 的结果为 object ,这里做判断,是为了让有 null 的情况尽早退出函数
      if (a == null || b == null) return false;

      // 判断 NaN
      if (a !== a) return b !== b;

      // 判断参数 a 类型,如果是基本类型,在这里可以直接返回 false
      var type = typeof a;
      if (type !== 'function' && type !== 'object' && typeof b != 'object') return false;

      // 更复杂的对象使用 deepEq 函数进行深度比较
      return deepEq(a, b, aStack, bStack);
    };

    function deepEq(a, b, aStack, bStack) {

      // a 和 b 的内部属性 [[class]] 相同时 返回 true
      var className = toString.call(a);
      if (className !== toString.call(b)) return false;

      switch (className) {
        case '[object RegExp]':
        case '[object String]':
          return '' + a === '' + b;
        case '[object Number]':
          if (+a !== +a) return +b !== +b;
          return +a === 0 ? 1 / +a === 1 / b : +a === +b;
        case '[object Date]':
        case '[object Boolean]':
          return +a === +b;
      }

      var areArrays = className === '[object Array]';
      // 不是数组
      if (!areArrays) {
        // 过滤掉两个函数的情况
        if (typeof a != 'object' || typeof b != 'object') return false;

        var aCtor = a.constructor,
          bCtor = b.constructor;
        // aCtor 和 bCtor 必须都存在并且都不是 Object 构造函数的情况下,aCtor 不等于 bCtor, 那这两个对象就真的不相等啦
        if (aCtor !== bCtor && !(isFunction(aCtor) && aCtor instanceof aCtor && isFunction(bCtor) && bCtor instanceof bCtor) && ('constructor' in a && 'constructor' in b)) {
          return false;
        }
      }


      aStack = aStack || [];
      bStack = bStack || [];
      var length = aStack.length;

      // 检查是否有循环引用的部分
      while (length--) {
        if (aStack[length] === a) {
          return bStack[length] === b;
        }
      }

      aStack.push(a);
      bStack.push(b);

      // 数组判断
      if (areArrays) {

        length = a.length;
        if (length !== b.length) return false;

        while (length--) {
          if (!eq(a[length], b[length], aStack, bStack)) return false;
        }
      }
      // 对象判断
      else {

        var keys = Object.keys(a),
          key;
        length = keys.length;

        if (Object.keys(b).length !== length) return false;
        while (length--) {

          key = keys[length];
          if (!(b.hasOwnProperty(key) && eq(a[key], b[key], aStack, bStack))) return false;
        }
      }

      aStack.pop();
      bStack.pop();
      return true;

    }


    return eq(a, b, aStack, bStack)

  },

输入面板 挡住 textarea 或者 input

移动端常见问题,原因上网找找。特征也比较明显,就是视口高度改变了,某些手机会触发 onresize 事件。
解决方案有很多,因为我的例子比较极端。自己搞出来一个比较极端的方案。就是把 整个 输入区域 定位到顶部,输入完后恢复。
虽然极端,个人觉得也算是一个通用做法,不用考虑滚动,兼容各种莫名其妙的问题。

方法实现

 /**
   * 作者:lzh
   * 功能:解决移动端输入板挡住输入框bug
   * 参数:id,需要修复点击bug的父元素id;
   * 参数:pullClass,需要被提起的盒子class;
   * 参数:scrollContentClass,发生滚动的盒子class,默认mobile-content;
   * 参数:top,发生滚动的盒子class,默认mobile-content;
   * 说明:fixBug,只有在原生标签 加上fixBug="true" 自定义属性才弹起修复;
   * 返回值:
   */
  fixInputBug(id="app",pullClass="form-item",scrollContentClass="mobile-content",top=100){
    var mobileArr = ["iPhone", "iPad", "Android", "Windows Phone", "BB10; Touch", "BB10; Touch", "PlayBook", "Nokia"];
    var ua = navigator.userAgent;
    var res = mobileArr.filter(function (arr) {
      return ua.indexOf(arr) > 0;
    });
    var nodeObj = document.getElementById(id);
    if (res.length > 0) {
      nodeObj.onclick = function (ev) {
        var ev = ev || nodeObj.event;
        var target = ev.target || ev.srcElement;
        let content = findParent(target,pullClass);
        let father = findParent(target,scrollContentClass);
        let scrollTop = father.scrollTop;
        let model = document.createElement('div');
        model.id = "inputBugModel";
        if (target.nodeName.toLowerCase() == 'input' || target.nodeName.toLowerCase() == 'textarea') {
          if(target.type!=="radio"&&target.type!=="checkbox"&&target.getAttribute('fixBug')){
            addClass(content,"input-bug")
            addClass(father,"input-bug-oh")
            if(document.getElementById("inputBugModel")){
              father.removeChild(document.getElementById("inputBugModel"));
            }
            father.appendChild(model);
            father.scrollTop = top;
            target.onblur = function () {
              removeClass(content,"input-bug")
              removeClass(father,"input-bug-oh")
              father.removeChild(model);
              father.scrollTop = scrollTop;
            }
          }
        }
      }
      function addClass(node,className) {
        if(node.className.split(" ").indexOf(className)==-1){
          node.className = node.className + ' ' + className;
        }
      }
      function removeClass(node,className) {
        node.className = node.className.replace(" "+className, '');
      }
      function  findParent(node, className){
        let target = node;
        if (target && target.parentNode&&target.parentNode.nodeName!=='HTML') {
          if(target.parentNode.className.split(" ").indexOf(className)!==-1){
              return target.parentNode;
          } else {
            return findParent(target.parentNode,className)
          }
        } else {
          return document.getElementsByTagName('body')[0];
        }
      }
    }
  },

* css
/*fix 移动端输入板 挡住 input ,textarea 的bug*/
.input-bug{
  position: absolute;
  top: 20%;
  left: 0;
  right: 0;
  z-index: 6000;
}
 #inputBugModel{
  width: 4000px;
  height: 4000px;
  top:50%;
  left: 50%;
  transform: translate(-50%,-50%);
  position: absolute;
  background-color: #000;
  opacity: 0.5;
  z-index: 5000;
}
.input-bug-oh{
  overflow: hidden!important;
  -webkit-overflow-scrolling: inherit;
}
/*end fix移动端输入板 挡住 input textarea 的bug*/

使用

<textarea v-model="item.reason" fixBug="true"></textarea>

mounted(){
      this.tools.fixInputBug("permitFlowContent");
    },

效果

clipboard.png

移动端快速点击

由于移动端浏览器存在300ms 延迟,某些组件需要快速响应点击事件,例如 - 0 + 组件;
利用 fastclick 插件 封装了一个组件

fastclick组件

<!--快速点击封装-->
<template>
  <div class="box fastClick">
    <slot></slot>
  </div>
</template>
<script>
  import fastclick from 'fastclick'


  export default {
    data: function () {
      return {}
    },
    mounted() {
      let dom = document.getElementsByClassName('fastClick')
      for (var i = 0; i < dom.length; i++) {
        fastclick.attach(dom[i]);
      }
    },
  }
</script>
<style lang="scss" scoped>
  .box{
    touch-action: none;
  }
</style>

使用

<fastClick>
    <mt-button size="small" class="number-button" @click.native="dayChange">
    </mt-button>
</fastClick>

输入板顶起底部 button

focus 的时候,由于底部的 mobile-bottom 部分是 absolute 的,所以被顶起来。
网上很多说法通过js判断 onresize 事件 控制 底部显示隐藏。可以实现,但是存在兼容性问题。且代码啰嗦
这里直接通过css 媒体查询实现了。

@media screen and (max-height: 450px) {
  .mobile-bottom{
    display: none;
  }
}

适配 iphoneX

苹果给出了 iphone的 有效区域概念。只要给碰到边框的大div做些css兼容写法就可以了。
设置高,宽,top,left,right,bottom 的都加上兼容。

  • 原来代码
.mobile-top{
  background: #3275dd;
  position: absolute;
  z-index: 1000;
  top: 0;
  left: 0;
  right: 0;
  padding-top: 20px;
}
.mobile-content {
  width: 100%;
  overflow: hidden;
  background: #f1f2f6;
  height: 100vh;
  box-sizing: border-box;
  position: relative;
  padding-top:62.5px;
  padding-bottom:50px;
}
.mobile-bottom{
  height: 1rem;
  /*position: fixed;*/
  position:absolute;
  overflow: hidden;
  box-shadow: 0px 0 1px 1px #ccc;
  background: #fff;
  border-bottom: 1px solid #ccc;
  z-index: 1000;
  display: flex;
  left: 0;
  right: 0;
  bottom: 0;
}
  • 兼容代码
.mobile-top{
  background: #3275dd;
  position: absolute;
  z-index: 1000;
  top: 0;
  left: 0;
  right: 0;
  padding-top: 20px;
  padding-top: constant(safe-area-inset-top); /* 这里需要使用 calc 动态计算 */
  padding-top: env(safe-area-inset-top);
  padding-left: constant(safe-area-inset-left);
  padding-left: env(safe-area-inset-left);
  padding-right: constant(safe-area-inset-right);
  padding-right: env(safe-area-inset-right);
}
.mobile-content {
  width: 100%;
  overflow: hidden;
  background: #f1f2f6;
  height: 100vh;
  box-sizing: border-box;
  position: relative;
  padding-top:62.5px;
  padding-top: calc(constant(safe-area-inset-top) + 42.5px);/*1.25rem 本身就预留了信号bar高度0.4rem,这里要减去*/
  padding-top: calc(env(safe-area-inset-top) + 42.5px);
  padding-bottom:50px;
  padding-bottom: calc(constant(safe-area-inset-bottom) + 50px);
  padding-bottom: calc(env(safe-area-inset-bottom) + 50px);
  padding-left: calc(constant(safe-area-inset-left));
  padding-left: calc(env(safe-area-inset-left));
}
.mobile-bottom{
  height: 1rem;
  height: calc(constant(safe-area-inset-bottom) + 50px);
  height: calc(env(safe-area-inset-bottom) + 50px);
  /*position: fixed;*/
  position:absolute;
  overflow: hidden;
  box-shadow: 0px 0 1px 1px #ccc;
  background: #fff;
  border-bottom: 1px solid #ccc;
  z-index: 1000;
  display: flex;
  left: 0;
  right: 0;
  bottom: 0;
  padding-bottom: constant(safe-area-inset-bottom);
  padding-bottom: env(safe-area-inset-bottom);
  padding-left: constant(safe-area-inset-left);
  padding-left: env(safe-area-inset-left);
  padding-right: constant(safe-area-inset-right);
  padding-right: env(safe-area-inset-right);
}

封装可用的阿里icon组件

<template>
      <i class="iconfont" :class="iconClass" :style="'font-size:'+ size +'px;color:'+color+';'"></i>
</template>
<script>
    export default {
      props: {
        iconClass: {
          type: String
        },
        size:{
            type:[Number,String],
        },
        color:{
          type:String
        }
      },
      data: function () {
          return {}
      },
    }
</script>
<style scoped>
  i{
    margin-right: 5px;
  }

</style>


* 复制阿里图标库的代码到alifont.css,并在main.js 中引入

//引入阿里图标
import "@/assets/icon/alifont.css"

使用

 <icon  @click.native="cancel" class="left" :icon-class="leftClass" :size="20"></icon>

leftClass 是你在阿里icon上面拿到的name
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值