NutUI 落地实践-让组件库服务慧采协同采购业务

avatar
UX @京东

京东慧采 App 是企业专属移动采购平台,依托京东移动技术实现企业全采购场景移动化,为企业客户打造零研发成本的多场景一站式移动化智能采购平台。帮助企业实现采购模式的革新,加速企业数字化采购的发展进程,使企业采购变得更为阳光、高效、简单。目前覆盖的行业包含金融、运营商、大交通、能源、电网、烟草等。

帮助企业解决两大采购场景难题

  • 移动端采购。适用于移动流动性办公、网络限制等工作场景,突破时空限制,支持多账号体系,随时随地采购、审批等。
  • 员工福利/渠道奖励发放。支持员工福利商城、积分兑换、渠道奖励等 B2B2C 场景,支持APP统一登录,活动集中展示,一键领取。

采购痛点

  • 目前慧采客户进行集中采购中,通过 excel 表格、纸质填报等方式进行线下收集采购需求,导致集中采购前期工作重复性高、复杂繁琐;同时收集过程中无法精确到 skuid 维度,采购人在选品加购过程不确定性大,搜索选择难度大,需求和采买商品出入大。严重增加了客户及运营人员的工作量。
  • 目前慧采批量下单的功能(支持多地址下单),存在前期 excel 录入工作繁杂,常常需要运营人员协同,同时线下沟通确认成本大,可视化程度低。
  • 现有客户场景:①企业副食品供应场景;②企业日常集采场景等;③教育行业办公集采场景;④铁路移动集采场景等

明确需求

针对以上情况,我们开发了线上协同采购需求,应用它就可以彻底轻松解决这些难题,提高客户的采购效率。下面是我们需求的大致流程:(分为提报人和采购人)

以及在其中涉及到的部分页面:

在明确了需求之后,我们就开始正式的项目开发了。首先在框架选择上,我们采用 Vue ;其次,在组件库方面,我们采用团队自主研发的一套京东风格的移动端组件库 NutUI

前端基础架构

NutUI 组件库

基于 Vue 的 UI 组件库,我们选择了部门自主研发的开源组件库 NutUI。NutUI 是一套京东风格的移动端组件库,开发和服务于移动 Web 界面的企业级前中后台产品。2.0+ 更是在 1.0+ 的基础上做了全新的架构升级,组件的数量和项目覆盖率上也有了质的飞跃。在本次项目中,我们也亲身体验到了高质量组件给开发者带来的便捷( Dialog、TimeLine、Infiniteloading、Stepper、Popup、Toast、Address )。

  • Address:四级地址组件。我们在做移动端购物类需求时,必然会有选择地址的交互,故我们将此业务组件添加在我们的 NutUI 组件库中。方便有类似需求的直接拿来使用,方便又快捷。

这里也特别感谢组件owner 小璐 童鞋,在我们开发需求的同时,开发地址组件,不仅没有耽误整个项目的进度,而且接入项目的过程也很顺利,组件堪称完美,点赞 666~~~

  • Dialog:弹窗组件也是在项目中使用率较高的一个组件,我们在项目中也是频繁的在使用它。组件的功能还是很强大的。首先它支持标签式和函数式写法,还支持图片弹窗等功能。

    this.$dialog({ title: "是否确定提交", content: "采购人将第一时间看到您的提报,在下单之前可撤回重新修改" });

标签式写法在使用时有一个遮罩层的小问题,已反馈开发者进行修复

在项目中,我们采用的函数式写法,并且 content 里面传递的是 Html 标签。

    _this.$dialog({
            title: "清单Excel将发送至以下邮箱",
            content: "<input type=\"text\" placeholder=\"请输入邮箱地址\" class=\"inputemail\"/>",
    });

这样使用没有问题,页面可以正常展示,有一个不太好的地方就是,在获取 input 元素的值时,不能使用 Vue 的实例,而是采用了 DOM 操作

let email = (document.querySelector('.inputemail') as any).value

这里,建议做一下组件优化,可以使用 Vue 的实例获取内嵌的 DOM 的内容

  • Stepper:步进器,通常在购物车页加减数量使用。

在 API 里面定义了一系列方法,add、reduce、change、focus、blur 等。我们可以在实际的业务场景中监听这些事件来实现不同的逻辑。另外还支持简单的动画效果。以及里面为我们处理了许多有关 number 的优化和逻辑处理。大大减少了我们开发的成本。美中不足的地方有一处:

我们在加减数量时,有的场景下需要异步通知是否需要正常加减,而在组件中,只是同步的进行了加减的操作,没有跟接口有直接的关系,建议可以同时支持同步和异步操作供开发者选择。

构建工具 Gaea

Gaea:Gaea 构建工具是基于 Node.js、Webpack 模版工程等的 Vue 技术栈的整套解决方案,包含了开发、调试、打包上线完整的工作流程。Gaea 的全新升级改版,大大提升了项目构建速度,提高了我们的开发运行效率。

  1. 新版的 Gaea 将 Webpack 升级到了 4.0+,并且将之前只有一个 webpack.config.js 配置文件进行了拆分,这样不同的命令执行不同的操作,看上去也清晰很多。并且在构建速度方便加入了 Happypack ,将文件解析任务分解成多个子进程并发执行。子进程处理完任务后再将结果发送给主进程。这样大大提升了构建速度,同时也加入了 progress-bar-webpack-plugin、webpack-build-notifier 等插件,使得在构建过程中既能实时了解构建进度,又能在结束之后收到成功/失败的通知,还可以对构建之后的文件通过图文的方式进行分析,从而能够很清晰得看到每个文件的占比。
  2. 在执行 npm run dev/upload/build 时支持打本地包,这种适合前端只支持重构工作,然后将 Html、Css 交付给研发,直接可以在本地打开 Html 页面看效果,而无需再配置 Host ,是不是很方便~~

TypeScript

  • TypeScript + Vue + Vuex

    TypeScript VS Javascript:

    TypeScript 始于 JavaScript,归于 JavaScript。它可以编译出纯净、 简洁的 JavaScript 代码,并且可以运行在任何浏览器上、Node.js 环境中和任何支持 ECMAScript 3(或更高版本)的 JavaScript 引擎中,它还具备以下特点: (1)静态类型化是一种功能,可以在开发人员编写脚本时检测错误; (2)适用于大型的开发项目; (3)类型安全是一种在编码期间检测错误的功能,而不是在编译项目时检测错误。这为开发团队创建了一个更高效的编码和调试过程; (4)干净的 ECMAScript 6 代码,自动完成和动态输入等因素有助于提高开发人员的工作效率; 选择了使用 TypeScript,然后接着就需要结合我们本次项目选用的 Vue 技术栈来配合使用。

    Vue + TypeScript

    众所周知,Vue2.0+ 对 TS 的支持远远不如 React ,在 React 中, jsx 里面的类型提示应有尽有,可以大大提高开发效率,减少 TS 相关的很多 bug,Vue 里面虽然也支持 jsx ,但是 2.0+ 的官方还是推荐使用模版 Template 渲染,这样就失去了 TS 的强大提示功能。当然,如果一定要使用的话,也不是不可以,在项目中我们配合 vue-property-decorator 就可以使用了。这个是 TS 官网给出的,它就是一个装饰器,利用它就可以将 Vue 和 TypeScript 结合起来使用。如果要深入了解它的实现原理,可以参考我们的另一篇文章运用 NutUI - 快捷开发企业业务之酷兜 装饰器源码分析篇,里面深入剖析了它的实现,感兴趣的童鞋可以研究研究~~

    import { Vue, Component, Prop } from 'vue-property-decorator'
    @Component({
        components: {
    
        }
    })
    export default class ReportItem extends Vue {
        @Prop({
            type: Object,
            required: true,
            default: {}
        }) itemData!: object
    }
    
    Vuex + TypeScript

    当然,我们在项目中也使用到了 Vuex ,来存储一些 State 状态值。那么我们怎么使 Vuex 和 TypeScript 结合呢?那我们需要借助 Vuex 的装饰器 vuex-class ,

        import { createDecorator } from 'vue-class-component';
        import { mapState, mapGetters, mapActions, mapMutations } from 'vuex';
        export var State = createBindingHelper('computed', mapState);
    
        function createBindingHelper(bindTo, mapFn) {
            function makeDecorator(map, namespace) {
                return createDecorator(function (componentOptions, key) {
                    if (!componentOptions[bindTo]) {
                        componentOptions[bindTo] = {};
                    }
                    var mapObject = (_a = {}, _a[key] = map, _a);
                    componentOptions[bindTo][key] = namespace !== undefined
                        ? mapFn(namespace, mapObject)[key]
                        : mapFn(mapObject)[key];
                    var _a;
                });
            }
            function helper(a, b) {
                if (typeof b === 'string') {
                    var key = b;
                    var proto = a;
                    return makeDecorator(key, undefined)(proto, key);
                }
                var namespace = extractNamespace(b);
                var type = a;
                return makeDecorator(type, namespace);
            }
            return helper;
        }
    

    其中,createBindingHelper 就是核心处理函数,它的原理和 vue-property-decorator 的实现思路是一样的,这里不做过多解释。当然,我们在实际项目中使用也是非常简单了。

    import { State, Mutation } from 'vuex-class'
    export default class ReportList extends Vue {
        @State scrollTop
        @Mutation saveTop
    }
    

    掌握了 TypeScript 和 Vue、Vuex 的结合使用,我们就可以在项目中大展拳脚啦~~

请求 axios

作为前端开发,我们不得不打交道的就是后端接口了,无论传统开发 jQuery、Vue、React 都离不开对接口请求的封装,虽然它们实现的底层大部分都是基于 XMLHttpRequest or JSONP,但在开发者使用层面,却是出现了各种不同的封装库。本项目使用的 Vue 技术栈,与 Vue 结合使用的网络请求有几种:

  • vue-resource
  • axios
  • fetch
vue-resource

它是 Vue.js 的一款插件,可以通过 XMLHttpRequest 或者 JSONP 发起请求并处理响应。它的特点:

  1. 体积小(压缩之后只有 12 KB);
  2. 支持主流的浏览器(支持 IE 9+ 浏览器);
  3. 支持 Promise API ;
  4. 支持拦截器(可以在发送前和发送后做一些处理)

然而,我们在现阶段不会去用它,很大的一个原因是 Vue2.0+ 不会去同步更新了,而是推荐使用 Axios 。它是基于 Promise 的 HTTP 请求客户端,可以同时在浏览器和 Node.js 中使用。

Unlike routing and state-management, ajax is not a problem domain that requires deep integration with Vue core. A pure 3rd-party solution can solve the problem equally well in most cases. There are great 3rd party ajax libraries that solve the same problem, are more actively improved/maintained, and designed to be universal/isomorphic (works in both Node and Browsers, which is important for Vue 2.0 with its server-side rendering usage).

以上是 Vue.js 作者 Evan You 给出的我们在使用 Vue2.0+ 开发时不推荐使用 vue-resource 的原因,大致的意思是:与路由和状态管理不同,ajax 并不需要和 Vue 核心深度集成。在大多数情况之下,纯第三方库完全可以很好的解决问题;有很多优秀的第三方 ajax 库可以解决同样的问题,它们一直在更加积极的改进和维护,并且设计成了通用的库,在 Node 和浏览器环境都可以很好的使用,这对于 Vue2.0+ 支持的 SSR 渲染尤其重要。

既然作者尤大都不推荐使用了,我们使用者也应该紧跟作者脚步,放弃它!!!

fetch

fetch API Fetch 是一个现代的概念,等同于 XMLHttpRequest ,它提供了许多和 XMLHttpRequest 相同的功能。它提供的新的 API 更加强大和灵活。Fetch 的核心在于对 HTTP 接口的抽象,包括 Request、Response、Headers、Body 以及用于初始化异步请求的 global fetch。fetch(input,[, init]),其中, input 定义要获取的资源;init 是可选项,一个配置项对象,包括所有对请求的设置(method、headers、body等)。一个简单的 fetch 请求的使用如下:

const response = await fetch(reportTab, {
    credentials: 'include', 
    method: 'get',
    cache: "force-cache"
});

const data = await response.json()

以上通过一次 fetch 的简单调用,就打印出了 data。看起来挺简单,那我们在项目中为什么不使用它呢?

  1. 先来看看它的兼容性吧~~ 我们如果在项目中使用 fetch ,那么在前期封装请求方法时需要考虑到兼容性问题,需要同时支持 fetch 和 ajax 两种方式
  2. fetch 对一些错误处理不敏感,它只是对网络请求报错,对一些非 200 状态码,如 400 ,500 等都会当作成功请求去处理,这个需要在我们自己封装时做特殊处理
  3. 默认不会携带 cookie ,需要我们手动添加配置项 credentials: ‘include’
  4. 不支持超时控制和取消请求处理,容易造成流量浪费,尤其对于移动端产品及其不友好
  5. 请求的进度不能实时监测到,这个 XHR 可以做到

基于以上几点,我们还是选择不在本次的项目中使用~~

axios

本项目,我们还是使用了 Vue 官方推荐的 axios 库。它的好处我在这里就不一一列举了。相信大家都有体会和使用的经验。

一般我们在安装完成之后都会在自己的项目中封装一层,然后再在具体的模块中调用:

var instance = axios.create({
    baseURL: "",
    timeout: 10000
});
instance.interceptors.request.use(
   return ...
);
instance.interceptors.response.use(
   return ...
);
export default function(method,url,data) {
    return instance[method]()...
}

一般使用以上的封装或者在此基础上做一定的扩展就足以应对整个项目的请求了。 我们在项目中并没有这么做,当然上面的封装放在本项目中完全没有问题。但,我们项目中使用的 vue + ts,上面的封装完全没有体现出 ts 的作用。既然要使用,那就从底层的封装开始。

首先,定义两个接口,一个是请求时的入参,一个是接口返回数据结构

export interface ReqOptions {
    uri?: string;
    query?: object | null;
    data?: {
        [key: string]: any;
    };
}
export interface ResOptions {
    code: number | string;
    message: string;
    data: { 
        [key: string] : any 
    }
}

然后,将其引入 request.ts 文件中,在 request.ts 中,我们定义了一个 Request 类。

  static instance: Request

  request: AxiosInstance

  cancel: Canceler | null

  methods = ['get', 'post']

  curPath: string = ''

  constructor(options: AxiosRequestConfig) {
        this.request = axios.create(options)
        this.cancel = null
        this.curPath = options.baseURL || ''
        this.methods.forEach(method => {
            this[method] = (params: ReqOptions) => this.getRequest(method, params)
        })
        this.initInterceptors()//初始化拦截器
  }

在 constructor 中,创建了 axios 实例,定义了请求方法 get 、post ,并初始化拦截器 initInterceptors。其中 AxiosInstance , Canceler , AxiosRequestConfig等这些都是 axios 这个库中支持 ts 定义的接口。它们都是定义在 axios/index.d.ts 下

export interface AxiosRequestConfig {
    url?: string;
    method?: Method;
    baseURL?: string;
    transformRequest?: AxiosTransformer | AxiosTransformer[];
    ...
}

定义 拦截器、初始化请求实例、请求方法:

  initInterceptors() {
    this.request.interceptors.request.use((config: AxiosRequestConfig) => {
        ...
        return config
    })

    this.request.interceptors.response.use(
      (res: AxiosResponse<any>) => {
        ...
        return res
     })
  }

 static getInstance(options = defaultOptions) {//初始化实例
    if (!this.instance) {
      this.instance = new Request(options)
    }
    return this.instance
  }

async getRequest(method: string, options: ReqOptions = { uri: '', query: null, data: {} }): Promise<any> {
    ...
    if(method === 'get') {
        response = await this.request[method](url, {
            params: query
        })
    } else if (method === 'post') {
        response = await this.request[method](https://coding.jd.com/fe-rd2/article/blob/master/2020-Q2%2F%E3%80%8A%E8%BF%90%E7%94%A8NutUI-%E5%BF%AB%E6%8D%B7%E5%BC%80%E5%8F%91%E6%85%A7%E9%87%87%E5%8D%8F%E5%90%8C%E9%87%87%E8%B4%AD%E3%80%8B-%E8%8B%8F%E5%AD%90%E5%88%9A%2Furl%2C params)
    }
    ...
  }

其中, getInstance 静态方法采用单例模式生成请求实例;getRequest 中具体定义了请求的方法,并返回 response。 最后导出这个实例

    export let api = Request.getInstance()

    const res = await this.$api.get({
        uri: reportTab
    })

当然,在这里,this.$api 我们需要进行类型声明。在项目中创建 shime-global.d.ts 文件

import Vue from 'vue'
import VueRouter from 'vue-router';
import { Route } from 'vue-router';

declare module 'vue/types/vue' {
    interface Vue {
      $router: VueRouter
      $route: Route
      $api: any
      $toast: any
      $dialog: any
    }
}

这样,就会顺利通过 TS 的编译,并且可以直接使用 this.$api.get 了

当然,axios 固然好用,功能强大而全面,一个 axios.js 大约在 46KB 左右,压缩的也在 14KB 左右。如果我们在实际开发中,只是用到了一些基础的 API 功能,比如 get、post、取消请求、错误捕获等。我们也可以考虑自己去基于 XMLHttpRequest 封装一个针对自己项目的请求接口的函数,而没有必要依赖第三方库。

优化点

视觉体验

项目中涉及到的很多是 sku 列表页,购物车页,详情页中也有下单 sku 的列表,我们在加载的时候虽然说是分页加载,但是难免会有网络异常或者不稳定的情况发生,为了给用户以更好的视觉体验,我们给项目中的图片增加了懒加载的功能,我们会采用一张默认的图片先展示并占位,网络请求图片成功之后,再换成实际的图片,这里需要一个 Vue 的指令,当然我们可以自定义一个懒加载的指令:

Vue.directive('lazyload', {
    ...
});

自定义指令包含 5 个 生命周期:bind 、 inserted、update 、componentUpdate 、unbind 。

  • bind:只调用一次,指令第一次绑定到元素时候调用
  • inserted:被绑定的元素插入父节点的时候调用
  • update:被绑定元素所在模板更新时调用
  • componentUpdate:被绑定的元素所在模板完成一次更新周期的时候调用
  • unbind:只调用一次,指令元素解绑的时候调用

我们只需要实现这几个生命周期函数即可~~

为了方便,我们在项目中使用了 Vue 懒加载指令 Vue-lazyload,我们只需要在项目中安装,在入口文件中初始化,然后做一些配置就可以使用了。

import VueLazyload from 'vue-lazyload'
Vue.use(VueLazyload, {
  error: require('./asset/img/collpro/default.png'),
  loading: require('./asset/img/collpro/default.png')
})

这里指定了加载的默认图片,然后在项目中使用 v-lazy

<img v-lazy="item.skuImgUrl" />

页面轮廓效果

在页面数据返回之前呈现给用户的一个页面的轮廓,比起之前常用的 Loading ,在视觉效果上明显提升了很多,我们在项目中也用了这个提升手段,考虑到是单页面应用,如果在页面上直接使用,会导致骨架屏和实际的页面大相径庭。所以,我们在几个重要的路由页面中单独使用了骨架屏,这样让用户看起来更加真实一些。在 comopnents/ 下创建一个骨架屏组件 Skeleton ,分别对不同路由页面书写不同的布局结构,通过 Props page 去识别。

<div class="skeleton-content skulist" v-if="page === 'skulist'">
    <div class="list-item" v-for="item in new Array(5)" v-bind:key="item">
        <div class="left"></div>
        <div class="right">
            ...
        </div>
    </div>
</div>

<Skeleton v-if="initSkeleton" page="skulist"></Skeleton>

减少资源体积

通常,我们把项目开发完成,使用 Webpack 进行打包构建时,通常会打包出一个 app.js 文件,和一个 app.css 文件,把这两个文件引入对应的 Html 文件,当然可以正常去访问我们的应用。看似没什么问题。但是在比较大型的项目中,打包出的 app.js 文件通常是很大的,就拿我们本项目来说吧。

如果我们能把不同路由对应的组件分割成不同的代码块,然后当路由被访问的时候才加载对应组件,这样就更加高效了。我们可以使用动态 import 来定义代码分块点

const report = () => import("./../../view/collpro/C/report/reportlist.vue");

这样,我们再结合 Webpack 就实现了组件的异步加载功能,减少了静态资源大小,提升了页面加载速度。

Webpack 多页面打包

通常我们使用 Vue/React 技术栈开发的项目都是 SPA 应用,但是在一些比较大型的项目或者一些业务场景特殊的项目中,SPA 已经不能满足我们的需求了,这时候需要基于我们的打包工具 Webpack 进行多页面打包的支持。本次项目涉及B(采购人)、C(提报人)两个角色,而且两个的请求域名和入参都有区别,所以我们考虑采用多页面来支持。 对于多页面的配置,相信有些童鞋还是比较陌生,因为在项目中很少用到,故在这里说明一下几个主要的配置项:

  • 首先,多页面肯定是需要有多个入口,那么我们就先从 entry 这里入手

    entry: {
        app: './src/collpro/B/app.ts'
    }
    
    entry: {
        b: './src/collpro/B/app.ts',
        c: './src/collpro/C/app.ts'
    },
    

如果是多页面,采用上面第二种写法,这个也是本次项目中入口的配置。

  • 完善 html-webpack-plugin ,这个插件的作用是会生成一个 Html 文件,里面包含 js、css 等静态资源,那么如果是多页面,这个插件就需要初始化多次

    new HtmlWebpackPlugin({ template:'./src/index.html', filename: path.resolve(__dirname, './../build/b.html'), inject: true, chunks: ['b'] }), new HtmlWebpackPlugin({ template:'./src/index.html', filename: path.resolve(__dirname, './../build/c.html'), inject: true, chunks: ['c'] })

其中,需要注意的是里面的参数 chunks ,它指的是允许你添加的模块,也就是这个页面中需要引入的 js 模块,如果这里不指定,它将会默认将所有打包出来的模块都加载进来,我们来看一下效果:

这个是我没有指定 chunks 打包出来的静态资源引用,很明显是不对的~~

到此,多页面打包的配置就已经修改完成了,我们可以愉快的进行项目的开发了。但是,在开发时会发现,在修改某一个文件时,会执行两次 build ,我们在插件 emit(输出资源) 钩子中打印当前时间,然后随意修改逻辑代码:

可以看到,每个入口文件都执行了一遍,这样,大大消耗了构建时间,我们的期望是修改了哪个页面,对应就打包哪个页面就好,这样会大大提升构建效率,体现 HMR 的价值。我们需要稍微对这个插件做一些修改,增加 muticache 参数,然后在 emit 中增加:

    if (self.options.muticache && isValidChildCompilation) {
        return callback();
    }
    ...

isValidChildCompilation 需要在 done 钩子中设置 true,这样才能保证在多页面情况下,修改某处代码只编译一次。

难题攻克

提报单列表记录位置

先来看一下需要实现的效果:

需求描述如下:

  • 提报单编辑页点击返回,跳转至提报单列表,不需刷新页面,并且需记录上一次浏览的位置
  • 提报成功之后,点击返回列表,这时候需要刷新数据来改变提报单状态

也就是说,提报单列表并不是一直不刷新,而是会根据不同路由的来源,做是否需要刷新的判断。这里当然会用到 Vue 中的 keep-alive,但仅仅使用它是不能满足需求的~~ 下面来一点点分析:

首先,先设置 keep-alive
{ path: `${baseUrl}/reportlist`, component: report, meta:{title: '采购单提报', keepAlive: true} },

然后,在 app.vue 中,引入 keep-alive 组件

<keep-alive>
    <router-view v-cloak v-if="$route.meta.keepAlive"></router-view>
</keep-alive>

这样仅仅是缓存了当前组件,那么怎样去记录上一次的位置呢?我在项目中是这么做的。

let top = document.documentElement.scrollTop
this.saveScrollTop(top)

获取 top ,并且通过 saveScrollTop 方法将其存储在 store 中。然后再次访问的时候让其回到 top 位置

activated() {
    if(this.$route.meta.keepAlive) {
        document.documentElement.scrollTop = this.scrollTop
    }
}

注意:只有当组件在 keep-alive 内被切换,才会有 activated 和 deactivated 这两个钩子函数。

这样,上面的需求描述一就满足了,那么需求二又该如何实现呢?

其次,引入导航守卫

vue-router 为我们提供的导航守卫主要用来通过跳转或取消的方式守卫导航。导航守卫分为三种:全局的、单个路由独享的、组件级的。 在这里,我们只需要在全局做就可以了。

router.beforeEach(function(to, from, next){
    if(...) {
        to.meta.keepAlive = true
    } else {
        to.meta.keepAlive = false
    }
    next();
});

beforeEach 注册了一个全局前置守卫,from 表示导航正要离开的路由,我们就是利用这个 from 来动态设置 keepAlive 的值。 由此,我们同时使用了 keep-alive 和 vue-router 的导航守卫满足了以上的需求~

ios 低版本键盘弹出和收起造成页面不能还原

在低版本的 ios 部分手机会存在一个兼容性问题,应该是属于内部机制导致。在点击 input 获取焦点后,键盘会自动弹起,将页面顶起,当输入完成后点击‘完成’按钮,键盘自动收起,但是页面没有回滚,导致点击元素还停留在键盘弹起的地方。

解决的办法就是我们需要在 app.vue 中,在 mounted 钩子里面监听 focusout 事件,手动将页面滚动到初始位置。

    document.body.addEventListener("focusout", () => {
        window.scrollTo({ top: 0, left: 0, behavior: "smooth" });
    });

提报编辑、详情滑动头部渐变

本次需求中还有一个比较常见的动画效果,在页面滑动过程中顶部固态栏渐变。由这样↓

变成这样↓

先来捋一遍实现的思路: 这种渐变功能的实现,宽度等属性比较简单,比如 input 框的宽度,直接改变宽度值就可以了。颜色变化需要考虑的比较多:从透明到不透明,从白色到其他颜色,都可以通过控制透明度实现;颜色由白色渐变成其他的颜色,略微复杂,这样的渐变我们可以白色的打底,其他颜色作为上层,改变上层透明度来实现。 我们这个效果,要从红色变成白色,两个方向: 1、红色 rgba(255,0,0) ==> 白色 rgba(255,255,255)。直接渐变色值,可想而知,滑动过程肯定颜色变化过多,太花,放弃! 2、不能白色打底,只能改变透明度了,可以先尝试一下看看效果。要实现这个功能,首先要监听页面的滚动:

mounted() {
    //首先,在mounted钩子window添加一个滚动滚动监听事件
    window.addEventListener("scroll", this.handleScroll);
  },

  //由于是在整个window中添加的事件,所以要在页面离开时摧毁掉
  beforeDestroy() {
    window.removeEventListener("scroll", this.handleScroll);
  }

然后就是重点定义头部上滑事件:

const handleScroll = (that:any): void => {
    let _this = that;
    let scrollTop =window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop;
    let visibleDomHeight = _this.$refs.srollinfo.offsetHeight;//获取卡片得高度   
}

我们先获取页面上滑的高度,以及我们想要让固定栏渐变完成的高度,比如我们这个项目就需要它滑过卡片的时候渐变完成。 接下来通过滚动函数改变需要渐变的元素,我们这个需求需要改变的元素属性比较多,我们拿背景颜色举例:

if(scrollTop>0){
      //定义固定栏头部背景
     let opcity = scrollTop/visibleDomHeight <=1 ? (1-scrollTop/visibleDomHeight) : 0;
      _this.bgColor=`linear-gradient(270deg, rgba(250,151,97,${opcity}) 0%,rgba(247,39,28,${opcity}) 100%)`;
 }

scrollTop 是页面监听到到组件的滚动位置,当组件滚动的时候,scrollTop 的值就会改变,opacity 就会变,背景就会从透明度 1 变成 0 .

其实所有需要渐变的属性,都可以通过这种方式实现。以下是所有效果实现后的效果:

可以看到,效果是实现了,但总有点奇怪的感觉:滑动过程,固态栏透明度变小的时候跟底层的字体重复了,不太好看

最后,经过与产品沟通,我们选用了最干净简洁的方式:在滑动到一定高度的时候直接改变固态栏的样子,input 框根据页面不同展示或者不展示。如下:

if(scrollTop>visibleDomHeight){
      //定义固定栏头部背景
      _this.bgColor="#fff";
 }

更干净清爽一些,毕竟适合的才是最好的,至此,这个滑动效果就完成了~~

结语

到这里,文章马上接近尾声了,但我们对项目的持续优化以及对技术的热情还远远没有结束。无论是项目技术选型、组件开发、难题攻克还是性能优化,我们的路还很长,但我们需谨记,无论路有多长,我们只能而且必须一步一个脚印,脚踏实地,在做好项目的同时,做好每一个沉淀,日积月累,提升技术水平,然后服务好每一个项目/需求。