用Class写一个记住用户离开位置的js插件

前言常见的js插件都很少使用ES6的class,一般都是通过构造函数,而且常常是手写CMD、AMD规范来封装一个库,比如这样:// 引用自:https://www.jianshu.com/p/e65c246beac1;(function(undefined) { “use strict” var _global; var plugin = { // … } _global = (function(){ return this || (0, eval)(’this’); }()); if (typeof module !== “undefined” && module.exports) { module.exports = plugin; } else if (typeof define === “function” && define.amd) { define(function(){return plugin;}); } else { !(‘plugin’ in _global) && (_global.plugin = plugin); }}());但现在都9102年了,是时候祭出我们的ES6大法了,可以用更优雅的的写法来实现一个库,比如这样:class RememberScroll { constructor(options) { … }}export default RememberScroll在这篇文章,博主主要通过分享最近自己写的一个记住页面滚动位置小插件,讲一下如何用class语法配合webpack 4.x和babel 7.x封装一个可用的库。项目地址:Github, 在线Demo:Demo喜欢的朋友希望能点个Star收藏一下,非常感谢。需求来源相信很多同学都会遇到这样一个需求:用户浏览一个页面并离开后,再次打开时需要重新定位到上一次离开的位置。这个需求很常见,我们平时在手机上阅读微信公众号的文章页面就有这个功能。想要做到这个需求,也比较好实现,但博主有点懒,心想有没有现成的库可以直接用呢?于是去GitHub上搜了一波,发现并没有很好的且符合我需求的,于是得自己实现一下。为了灵活使用(只是部分页面需要这个功能),博主在项目中单独封装了这个库,本来是在公司项目中用的,后来想想何不开源出来呢?于是有了这个分享,这也是对自己工作的一个总结。预期效果博主喜欢在做一件事情前先yy一下预期的效果。博主希望这个库用起来尽量简单,最好是插入一句代码就可以了,比如这样:<html><head> <meta charset=“utf-8”> <title>remember-scroll examples</title></head><body> <div id=“content”></div> <script src="../dist/remember-scroll.js"></script> <script> new RememberScroll() </script></body></html>在想要加上记住用户浏览位置的页面上引入一下库,然后new RememberScroll()初始化一下即可。下面就带着这个目标,一步一步去实现啦。设计方案1. 需要存哪些信息?用户浏览页面的位置,主要需要存两个字段:哪个页面和离开时的位置,通过这两个字段,我们才可以在用户第二次打开网站的页面时,命中该页面,并自动跳转到上一次离开的位置。2.存在哪?记住浏览位置,需要将用户离开前的浏览位置记录在客户端的浏览器中。这些信息可以主要存放在:cookie、sessionStorage、localStorage中。存放在cookie,大小4K,空间虽有限但也勉强可以。但cookie是每次请求服务器时都会携带上的,无形中增加了带宽和服务器压力,所以总体来说是不太合适的。存放在sessionStorage中,由于仅在当前会话下有效,用户离开页面sessionStorage就会被清除,所以不能满足我们的需求。存放在localStorage,浏览器可永久保存,大小一般限制5M,满足我们需求。综上,最后我们应该选择localStorage。3. 需注意的问题一个站点可能有很多页面,如何标识是哪个页面呢?一般来说可以用页面的url作为页面的唯一标识,比如:www.xx.com/article/${id},不同的id对应不同的页面。但博主考虑到现在很多站点都是用spa了,而且常见在url后面会带有#xxx的哈希值,如www.xx.com/article/${id}#tag1和www.xx.com/article/${id}#tag2这种情况,这可能表示的是同一个页面的不同锚点,所以用url作为页面的唯一标识不太可靠。因此,博主决定将这个页面唯一标识作为一个参数来让使用者来决定,姑且命名为pageKey,让使用者保证是全站唯一的即可。如果用户访问我们的站点中很多很多的页面,由于localStorage是永久保存的,如何避免localStorage不断累积占用过大?我们的需求可能仅仅是想近期记住即可,即只需要记住用户的浏览位置几天,可能会更希望我们存的数据能够自动过期。但localStorage自身是没有自动过期机制的,一般只能在存数据的时候同时存一下时间戳,然后在使用时判断是否过期。如果只能是在使用时才判断是否清除,而新访问页面时又会生成新的记录,localStorage中始终都会存在至少一条记录的,也就是说无法真正实现自动过期。这里不禁就觉得有点多余了,既然都是会一直保留记录在localStorage中,那干脆就不判断了,咱换一个思路:只记录有限的最新页面数量。举个例子:咱们网站有个文章页:www.xx.com/articles/${id},每个的id表示不同的文章,咱们只记录用户最新访问的5篇文章,即维护一个长度为5的队列。比如当前网站有id从1到100篇文章,用户分别访问第1,2,3,4,5篇文章时,这5篇文章都会记录离开的位置,而当用户打开第六篇文章时,第六条记录入队的同时第一条记录出队,此时localStorage中记录的是2,3,4,5,6这几篇文章的位置,这就保证了localStorage永远不会累积存储数据且旧记录会随着不断访问新页面自动“过期”。为了更灵活一点,博主决定给这个插件添加一个maxLength的参数,表示当前站点下记录的最新的页面最大数量,默认值设为5,如果有小伙伴的需求是记录更多的页面,可以通过这个参数来设置。4. 实现思路我们需要时刻监听用户浏览页面时的滚动条的位置,可以通过window.onscroll事件,获得当前的滚动条位置:scrollTop 。将scrollTop和页面唯一标识pageKey存进localStorage中。用户再次打开之前访问过的页面,在页面初始化时,读取localStorage中的数据,判断页面的pageKey是否一致,若一致则将页面的滚动条位置自动滚动到相应的scrollTop值。是不是很简单?不过实现的过程中需要注意一下细节,比如做一下防抖处理。实现步骤逼逼了这么久,是时候开始撸代码了。1.封装localStorage工具方法工欲善其事,必先利其器。为更好服务接下来的工作,咱们先简单封装一下调用localStorage的几个方法,主要是get,set,remove:// storage.jsconst Storage = { isSupport () { if (window.localStorage) { return true } else { console.error(‘Your browser cannot support localStorage!’) return false } }, get (key) { if (!this.isSupport) { return } const data = window.localStorage.getItem(key) return data ? JSON.parse(data) : undefined }, remove (key) { if (!this.isSupport) { return } window.localStorage.removeItem(key) }, set (key, data) { if (!this.isSupport) { return } const newData = JSON.stringify(data) window.localStorage.setItem(key, newData) }}export default Storage2. class大法class即类,本质上虽然是一个function,但使用class定义一个类会更直观。咱们为即将写的库起个名字为RememberScroll,开始就是如下的样子啦:import Storage from ‘./storage’class RememberScroll { constructor() { }}1.处理传进来的参数我们需要在类的构造函数constructor中接收参数,并覆盖默认参数。还记得上面咱们预期的用法吗?即new RememberScroll({pageKey: ‘myPage’, maxLength: 10})。 constructor (options) { let defaultOptions = { pageKey: ‘_page1’, // 当前页面的唯一标识 maxLength: 5 } this.options = Object.assign({}, defaultOptions, options)}如果没有传参数,就会使用默认的参数,如果传了参数,就使用传进来的参数。this.options就是最终处理后的参数啦。2.页面初始化当页面初始化时,咱们需要做三件事情:从loaclStorage取出缓存列表将滚动条滚动到记录的位置(若有记录的话);注册window.onscroll事件监听用户滚动行为;因此,需要在构造函数中就执行initScroll和addScrollEvent这两个方法:import Storage from ‘./utils/storage’class RememberScroll { constructor (options) { // … this.storageKey = ‘_rememberScroll’ this.list = Storage.get(this.storageKey) || [] this.initScroll() this.addScrollEvent() } initScroll () { // … } addScrollEvent () { // … }}这里咱们将localStorage中的键名命名为_rememberScroll,应该能够尽量避免和平常站点使用localStorage的键名冲突。3.监听滚动事件:addScrollEvent()的实现 addScrollEvent () { window.onscroll = () => { // 获取最新的位置,只记录垂直方向的位置 const scrollTop = document.documentElement.scrollTop || document.body.scrollTop // 构造当前页面的数据对象 const data = { pageKey: this.options.pageKey, y: scrollTop } let index = this.list.findIndex(item => item.pageKey === data.pageKey) if (index >= 0) { // 之前缓存过该页面,则替换掉之前的记录 this.list.splice(index, 1, data) } else { // 如果已经超出长度了,则清除一条最早的记录 if (this.list.length >= this.options.maxLength) { this.list.shift() } this.list.push(data) } // 更新localStorage里面的记录 Storage.set(this.storageKey, this.list) } }ps:这里最好需要做一下防抖处理4.初始化滚动条位置: initScroll()的实现initScroll () { // 先判断是否有记录 if (this.list.length) { // 当前页面pageKey是否一致 let currentPage = this.list.find(item => item.pageKey === this.options.pageKey) if (currentPage) { setTimeout(() => { // 一致,则滚动到对应的y值 window.scrollTo(0, currentPage.y) }, 0) }}细心的同学可能会发现,这里用了setTimeout,而不是直接调用window.scrollTo。这是因为博主在这里遇到坑了,这里涉及到页面加载执行顺序的问题。在执行window.scrollTo前,页面必须是已经加载完成了的,滚动条要已存在才可以滚动对吧。如果页面加载时直接执行,当时的scroll高度可能为0,window.scrollTo执行就会无效。如果页面的数据是异步获取的,也会导致window.scrollTo无效。因此用setTimeout会是比较稳的一个办法。5.将模块export出去最后我们需要将模块export出去,整体代码大概是这个样子:import Storage from ‘./utils/storage’class RememberScroll { constructor (options) { let defaultOptions = { pageKey: ‘_page1’, // 当前页面的唯一标识 maxLength: 5 } this.storageKey = ‘_rememberScroll’ // 参数 this.options = Object.assign({}, defaultOptions, options) // 缓存列表 this.list = Storage.get(this.storageKey) || [] this.initScroll() this.addScrollEvent() } initScroll () { // … } addScrollEvent () { // … }}export default RememberScroll这样就基本完成整个插件的功能啦,是不是很简单哈哈。篇幅原因就不贴具体代码了,可以直接到GitHub上看:remember-scroll打包接下来应该是本文的重点了,首先要清楚为什么要打包?将项目中所用到的js文件合并,只对外输出一个js文件。使项目同时支持AMD,CMD、浏览器<script>标签引入,即umd规范。配合babel,将es6语法转为es5语法,兼容低版本浏览器。PS: 由于webpack和babel更新速度很快,网上很多教程可能早已过时,现在(2019-03)的版本已经是babel 7.3.0,webpack 4.29.6, 本篇文章只分享现在的最新的配置方法,因此本篇文章也是会过时的,读者们请注意版本号。npm init项目咱们先新建一个目录,这里名为:remember-scroll,然后将上面写好的remember-scroll.js放进remember-scroll/src/目录下。PS:一般项目的资源文件都放在src目录下,为了显得专业点,最好将remember-scroll.js改名为index.js。)此时项目还没有package.json文件,因此在根目录执行命令初始化package.json:npm init需要根据提示填写一些项目相关信息。安装webpack和webpack-cli运行webpack命令时需要同时装上webpack-cli:npm i webpack webpack-cli -D配置webpack.config.js在根目录中添加一个webpack.config.js,按照webpack官网的示例代码配置:const path = require(‘path’);module.exports = { entry: ‘./src/index.js’, output: { path: path.resolve(__dirname, ‘dist’), filename: ‘remember-scroll.js’ // 修改下输出的名称 }};然后在package.json的script中配置运行webpack的命令: “scripts”: { “test”: “echo "Error: no test specified" && exit 1”, “dev”: “webpack –mode=development –colors” },这样配置完成,在根目录运行npm run dev,会自动生成dist/remember-scroll.js。此时已经实现了我们的第一个小目标:赚它一个亿,哦不,是将storage.js和index.js合并输出为一个remember-scroll.js。这种简单的打包可以称为:非模块化打包。由于我们在js文件中没有通过AMD的return或者CommonJS的exports或者this导出模块本身,导致模块被引入的时候只能执行代码而无法将模块引入后赋值给其它模块使用。支持umd规范相信很多同学都听过AMD,CommonJS规范了,不清楚的同学可以看看阮一峰老师的介绍:Javascript模块化编程(二):AMD规范。为了让我们的插件同时支持AMD,CommonJS,所以需要将我们的插件打包为umd通用模块。之前看过一篇文章:如何定义一个高逼格的原生JS插件,在没有使用webpack打包时,需要在插件中手写支持这些模块化的代码:// 引用自:https://www.jianshu.com/p/e65c246beac1;(function(undefined) { “use strict” var _global; var plugin = { // … } // 最后将插件对象暴露给全局对象 _global = (function(){ return this || (0, eval)(’this’); }()); if (typeof module !== “undefined” && module.exports) { module.exports = plugin; } else if (typeof define === “function” && define.amd) { define(function(){return plugin;}); } else { !(‘plugin’ in _global) && (_global.plugin = plugin); }}());博主看到这坨东西,也是有点晕,不得不佩服大佬就是大佬。还好现在有了webpack,我们现在只需要写好主体关键代码,webpack会帮我们处理好这些打包的问题。在webpack4中,我们可以将js打包为一个库的形式,详情可看:[Webpack Expose the Library](https://webpack.js.org/guides…。在我们这里只需在output中加上library属性:const path = require(‘path’);module.exports = { entry: ‘./src/index.js’, output: { path: path.resolve(__dirname, ‘dist’), filename: ‘remember-scroll.js’, library: ‘RememberScroll’, libraryTarget: ‘umd’, libraryExport: ‘default’ }};注意libraryTarget为umd,就是我们要打包的目标规范为umd。当我们在html中通过script标签引入这个js时,会在window下注册RememberScroll这个变量(类似引入jQuery时会在全局注册$这个变量)。此时就直接使用RememberScroll这个变量了。<script src="../dist/remember-scroll.js"></script><script> console.log(RememberScroll)</script>这里有个坑需要注意一下,如果没有加上libraryExport: ‘default’,由于我们代码中是export default RememberScroll,打包出来的代码会类似:{ ‘default’: { initScroll () {} }}而我们期望的是这样:{ initScroll () {}}即我们希望的是直接输出default中的内容,而不是隔着一层default。所以这里还要加上libraryExport: ‘default’,打包时只输出default的内容。PS: webpack英文文档看得有点懵逼,这个坑让博主折腾了很久才爬起来,所以特别讲下。刚兴趣的同学可以看下文档:output.libraryExport。到这里,已经实现了我们的第二个小目标:支持umd规范。使用babel-loader上面我们打包出来的js,其实已经可以正常运行在支持es6语法的浏览器中了,比如chrome。但想要运行在IE10,IE11中,还得让神器Babel帮我们一把。PS: 虽然很多人说不考虑兼容IE了,但作为一个通用性的库,古董级的IE7,8,9可以不兼容,但较新版本的IE10,11还是需要兼容一下的。Babel是一个JavaScript转译器,相信大家都听过。由于JavaScript在不断的发展,但是浏览器的发展速度跟不上,新的语法和特性不能马上被浏览器支持,因此需要一个能将新语法新特性转为现代浏览器能理解的语法的转译器,而Babel就是充当了转译器的角色。PS:以前博主一直以为(相信很多刚接触Babel的同学也是这样),只要使用了Babel,就可以放心无痛使用ES6的语法了,然而事情并不是这样。Babel编译并不会做polyfill,Babel为了保证正确的语义,只能转换语法而不会增加或修改原有的属性和方法。要想无痛使用ES6,还需要配合polyfill。不太理解的同学,在这里推荐大家看下这篇文章:21 分钟精通前端 Polyfill 方案,写得非常通俗易懂。总的来说,就是Babel需要配合polyfill来使用。Babel更新比较频繁,网上搜出来的很多配置教程是旧版本的,可能并不适用最新的Babel 7.x,所以我们这里折腾一下最新的webpack4配置Babel方案:babel-loader。1.安装babel-loader,@babel/core、@babel/preset-env。npm install -D babel-loader @babel/core @babel/preset-env core-jscore-js是JavaScript模块化标准库,在@babel/preset-env按需打包时会使用core-js中的函数,因此这里也是要安装的,不然打包的时候会报错。2.修改webpack.config.js配置,添加rulesconst path = require(‘path’);module.exports = { entry: ‘./src/index.js’, output: { path: path.resolve(__dirname, ‘dist’), filename: ‘remember-scroll.js’, library: ‘RememberScroll’, libraryTarget: ‘umd’, libraryExport: ‘default’ }, module: { rules: [ { test: /.m?js$/, exclude: /(node_modules|bower_components)/, use: { loader: ‘babel-loader’ } } ] }};表示.js的代码使用babel-loader打包。3.在根目录新建babel.config.js,参考Babel官网const presets = [ [ “@babel/env”, { targets: { browsers: [ “last 1 version”, “> 1%”, “maintained node versions”, “not dead” ] }, useBuiltIns: “usage”, }, ],];browsers配置的是目标浏览器,即我们想要兼容到哪些浏览器,比如我们想兼容到IE10,就可以写上IE10,然后webpack会在打包时自动为我们的库添加polyfill兼容到IE10。博主这里用的是推荐的参数,来自:npm browserslist,这样就能兼容到大多数浏览器啦。配置好后,npm run dev打包即可。此时,我们已经实现了第三个小目标:兼容低版本浏览器。生产环境打包npm run dev打包出来的js会比较大,一般还需要压缩一下,而我们可以使用webpack的production模式,就会自动为我们压缩js,输出一个生产环境可用的包。在package.json再添加一条build命令: “scripts”: { “test”: “echo "Error: no test specified" && exit 1”, “build”: “webpack –mode=production -o dist/remember-scroll.min.js –colors”, “dev”: “webpack –mode=development –colors” },这里同时指定了输出的文件名为:remember-scroll.min.js,一般生产环境就是使用这个文件啦。发布到npm经过上面的步骤,我们已经写完这个库,有需求的同学可以将库发布到npm,让更多的人可以方便用到你这个库。在发布到npm前,需要修改一下package.json,完善下描述作者之类的信息,最重要的是要添加main入口文件:{ “main”: “dist/remember-scroll.min.js”,}这样别人使用你的库时,可以直接通过import RememberScroll from ‘remember-scroll’来使用remember-scroll.min.js。发布步骤:先到https://www.npmjs.com/注册一个账号,然后验证邮箱。然后在命令行中输入:npm adduser,输入账号密码邮箱登录。运行npm publish上传包,几分钟后就可以在npm搜到你的包了。至此,基本就完成一个插件的开发发布过程啦。不过一个优秀的开源项目,还应该要有详细的说明文档,使用示例等等,大家可以参考下博主这个项目的README.md, 中文README.md。最后文章写了好几天了,可谓呕心沥血,虽然比较啰嗦,但应该比较清楚地交代了如何运用ES6语法从零写一个记住用户离开位置的js插件,也很详细地讲解了如何用最新的webpack打包我们的库,希望能让大家都有所收获,也希望大家能到GitHub上点个Star鼓励一下啦。remember-scroll这个插件其实几个月前就已经发布到npm了,一直比较忙(懒)没写章分享。虽然功能简单但很有诚意,能兼容到IE9。使用起来也非常方便简单,可直接通过script标签cdn引入,也可以在vue中import RememberScroll from ‘remember-scroll’使用。文档中有详细的使用示例:script标签使用方式vue中使用方式vue异步获取数据时使用方式项目地址Github,在线Demo。欢迎大家评论交流,也欢迎PR,同时希望大家能点个Star鼓励一下啦。 ...

March 19, 2019 · 3 min · jiezi

前端开发中,滑动展现日志麻烦?这个组件来帮你

写在前面在这个数据无比重要的时代,用户在网页上面的一系列操作,都需要用数据记录下来。在一个网页中,某个元素的点击数,展现数可以说是最基本的指标了。点击数很简单,用户点击的时候,上报一条点击日志即可。但是展现日志,就稍微麻烦一点了。特别是对于必须要上下滑动页面才会出现的元素。滑动展现的场景,在feed流形式的产品上十分常见。所以,一个轻量级的组件,react-scroll-to-show-cb 诞生了。组件整体介绍安装:npm install react-scroll-to-show-cb –save该组件是基于React开发的,适用于采用react开发的项目。目前主流的react版本都支持。preact也支持,但是需要配置以下的alias :alias: { “react”: “preact-compat”, “react-dom”: “preact-compat”}使用者只需要将 react组件 或者 html element 直接塞到 react-scroll-to-show-cb 的childern里面去,当对应的元素进入可视区域时,会触发回调函数,并且返回必要的信息。使用者拿到这些信息,就能够上报展现日志了。使用者需要做的,就是完成回调函数里的逻辑即可,十分简单。组件用法先看一个例子:import React from ‘react’;import ReactDOM from ‘react-dom’;import ReactScrollToShowCb from’react-scroll-to-show-cb’;class App extends React.Component { render() { return <div> <ReactScrollToShowCb scrollToShowCb={this.handleShow} once={true} wait={500} async={false}> <div>0</div> <div>1</div> <div>2</div> <div>3</div> <div>4</div> <div>5</div> <div>6</div> <div>7</div> <div>8</div> </ReactScrollToShowCb> </div> } handleShow(index, dom) { console.log(’————————–’); console.log(index: ${index}); console.log(‘dom:’, dom); console.log(’————————–’); }}ReactDOM.render(<App />, document.body);ReactScrollToShowCb 的 children支持 Class React Component ,支持 Html Elements, 不支持 Functional React Component 。可以为数组,也可以为单个元素。如果为数组,则数组里面的每个元素都必须为同样的类型,即 属于同一类 Class React Component 或者 同一种 Html Elements。async如果你需要异步生成children,你需要设置async参数为true.scrollToShowCb元素展现时的回调函数,接受 index 和dom作为参数。once多次展现时,是否每次都触发回调函数wait组件里监听滑动事件时,用了throttle。wait 控制回调函数的触发频率。为什么不支持react函数式组件直接作为children组件在实现是,用了ref来获取原始DOM。而函数式组件不支持ref。之前考虑在函数式组件外面新增一层html,但是这样侵入性太强,直接破坏了原有的DOM结构,特别是在children是数组的情况下,会导致某些css失效。目前没有很好的方法在父组件中获取函数式组件的原始DOM。遇到函数式组件,可以将 ReactScrollToShowCb 写到函数式组件内部return的jsx里面去。支持异步生成children, 但如果后续修改了children, 那么组件将不会继续工作。考虑到修改children的情况太多,可以新增,替换,删除等等,如果支持所有情况,需要在组件内处理大量因为children变化而带来的逻辑,这样会使组件的复杂程度大大增加,并且对性能也是一个考验。而本组件的定位就是实现一个简单的滑动展现回调功能,所以react-scroll-to-show-cb只支持了异步生成children, 后续有对children的修改,组件将停止工作。如果有修改children,然后滑动展现触发回调的需求,可以考虑实例化多个react-scroll-to-show-cb来实现。写在后面之所以开发这个组件,确实是因为之前和如今的工作中确实遇到了各个业务线都需要滑动展现日志的情况,当时都是在业务中直接搞,和业务耦合度较大,不容易复用。完全抽离出来后,就可以直接使用了。本文简单介绍了组件,以及开发过程中的一些思考。最后,欢迎关注公众号: ...

December 20, 2018 · 1 min · jiezi

js scroll相关内容

前言看下面这段代码:<div class=“parent” style=“height:200px;overflow:auto;background-color:yellow;"> <div class=“children” style=“height: 300px;background-color: blue”> </div></div>父元素的高度小于子元素的高度,子元素的内容根据父元素的视区会有内容裁剪,这时我们设置父元素的overflow属性值为auto,我们可以看到此时显示了滚动条。那么问题来了,究竟是以哪个元素为视窗,滚动条是属于哪个元素呢?通过设置background-color,可以知道,是以parent高度为视窗,滚动条也是属于parent元素的。onscroll看下面这段代码:<script> document.getElementsByClassName(‘parent’)[0].onscroll=function () { console.log(’—–>’.repeat(10)); }</script>onscroll为元素的滚动条滚动时触发的事件,同时通过这段代码也验证了,滚动条是属于parent元素的。scrollTopscrollTop:滚动条当前位置距离滚动条顶部的高度,也就是相对于父元素顶部,子元素被隐藏内容的高度; 只是DOM元素的一个属性(不包括window和document)。看下面这段代码:<script> console.log(document.body.scrollTop); document.getElementsByClassName(‘parent’)[0].onscroll=function (e) { console.log(’—–>’.repeat(10)); console.log(’*****>’.repeat(10),e.target.scrollTop); }</script>首先在控制台会输出一个0. //这是body元素的scrollTop值;然后滚动滚动条的时候,会打印触发每次滚动事件时scrollTop的值。现在我们知道了如何获取scrollTop的值,那么如何改变呢?<script> document.getElementsByClassName(‘parent’)[0].scrollTop=50;</script>我们可以看到当刷新页面时,滚动条直接显示在中间位置,所以我们通过直接给scrollTop赋值就可以改变滚动条的位置。除了scrollTop属性外,DOM元素还有scrollHeight,scrollWidth,scrollLeft等与滚动条相关的属性,这些属性表达的含义不同,但是用法都是相同,值得注意的是,这些属性都是只读的。既然DOM元素可以通过scrollTop属性获取或是设置滚动条的位置,那么document和window如何操作呢?scroll scrollBy scrollTo这三个属性,都是window对象的方法,也是全局的方法。window.scroll(x:XXX,y:XXX):把窗口滚动到指定的位置;window.scrollTo(x,y):与window.scroll相同window.scrollBy(x,y):把窗口相对x,y滚动window.scrollBy(10,-20)//把窗口向右移动10px,向上移动20px。

December 19, 2018 · 1 min · jiezi

[源码阅读]通过react-infinite-scroller理解滚动加载要点

react-infinite-scroller就是一个组件,主要逻辑就是addEventListener绑定scroll事件。看它的源码主要意义不在知道如何使用它,而是知道以后处理滚动加载要注意的东西。此处跳到总结。初识参数:// 渲染出来的DOM元素nameelement: ‘div’,// 是否能继续滚动渲染hasMore: false,// 是否在订阅事件的时候执行事件initialLoad: true,// 表示当前翻页的值(每渲染一次递增)pageStart: 0,// 传递ref,返回此组件渲染的 DOMref: null,// 触发渲染的距离threshold: 250,// 是否在window上绑定和处理距离useWindow: true,// 是否反向滚动,即到顶端后渲染isReverse: false,// 是否使用捕获模式useCapture: false,// 渲染前的loading组件loader: null,// 自定义滚动组件的父元素getScrollParent: null,深入componentDidMountcomponentDidMount() { this.pageLoaded = this.props.pageStart; this.attachScrollListener();}执行attachScrollListenerattachScrollListenerattachScrollListener() { const parentElement = this.getParentElement(this.scrollComponent); if (!this.props.hasMore || !parentElement) { return; } let scrollEl = window; if (this.props.useWindow === false) { scrollEl = parentElement; } scrollEl.addEventListener( ‘mousewheel’, this.mousewheelListener, this.props.useCapture, ); scrollEl.addEventListener( ‘scroll’, this.scrollListener, this.props.useCapture, ); scrollEl.addEventListener( ‘resize’, this.scrollListener, this.props.useCapture, ); if (this.props.initialLoad) { this.scrollListener(); }}此处通过getParentElement获取父组件(用户自定义父组件或者当前dom的parentNode)然后绑定了3个事件,分别是scroll,resize,mousewheel前2种都绑定scrollListener,mousewheel是一个非标准事件,是不建议在生产模式中使用的。那么这里为什么要使用呢?mousewheel解决chrome的等待bug此处的mousewheel事件是为了处理chrome浏览器的一个特性(不知道是否是一种bug)。stackoverflow:Chrome的滚动等待问题上面这个问题主要描述,当在使用滚轮加载,而且加载会触发ajax请求的时候,当滚轮到达底部,会出现一个漫长而且无任何动作的等待(长达2-3s)。window.addEventListener(“mousewheel”, (e) => { if (e.deltaY === 1) { e.preventDefault() }})以上绑定可以消除这个"bug"。个人并没有遇到过这种情况,不知道是否有遇到过可以说说解决方案。getParentElementgetParentElement(el) { const scrollParent = this.props.getScrollParent && this.props.getScrollParent(); if (scrollParent != null) { return scrollParent; } return el && el.parentNode;}上面用到了getParentElement,很好理解,使用用户自定义的父组件,或者当前组件DOM.parentNode。scrollListenerscrollListener() { const el = this.scrollComponent; const scrollEl = window; const parentNode = this.getParentElement(el); let offset; // 使用window的情况 if (this.props.useWindow) { const doc = document.documentElement || document.body.parentNode || document.body; const scrollTop = scrollEl.pageYOffset !== undefined ? scrollEl.pageYOffset : doc.scrollTop; // isReverse指 滚动到顶端,load新组件 if (this.props.isReverse) { // 相反模式获取到顶端距离 offset = scrollTop; } else { // 正常模式则获取到底端距离 offset = this.calculateOffset(el, scrollTop); } // 不使用window的情况 } else if (this.props.isReverse) { // 相反模式组件到顶端的距离 offset = parentNode.scrollTop; } else { // 正常模式组件到底端的距离 offset = el.scrollHeight - parentNode.scrollTop - parentNode.clientHeight; } // 此处应该要判断确保滚动组件正常显示 if ( offset < Number(this.props.threshold) && (el && el.offsetParent !== null) ) { // 卸载事件 this.detachScrollListener(); // 卸载事件后再执行 loadMore if (typeof this.props.loadMore === ‘function’) { this.props.loadMore((this.pageLoaded += 1)); } }}组件核心。几个学习/复习点offsetParentoffsetParent返回一个指向最近的包含该元素的定位元素.offsetParent很有用,因为计算offsetTop和offsetLeft都是相对于offsetParent边界的。ele.offsetParent为 null 的3种情况:ele 为bodyele 的position为fixedele 的display为none此组件中offsetParent处理了2种情况在useWindow的情况下(即事件绑定在window,滚动作用在body)通过递归获取offsetParent到达顶端的高度(offsetTop)。calculateTopPosition(el) { if (!el) { return 0; } return el.offsetTop + this.calculateTopPosition(el.offsetParent); }通过判断offsetParent不为null的情况,确保滚动组件正常显示 if ( offset < Number(this.props.threshold) && (el && el.offsetParent !== null) ) {/* … / }scrollHeight和clientHeight在无滚动的情况下,scrollHeight和clientHeight相等,都为height+padding2在有滚动的情况下,scrollHeight表示实际内容高度,clientHeight表示视口高度。每次执行loadMore前卸载事件。确保不会重复(过多)执行loadMore,因为先卸载事件再执行loadMore,可以确保在执行过程中,scroll事件是无效的,然后再每次componentDidUpdate的时候重新绑定事件。renderrender() { // 获取porps const renderProps = this.filterProps(this.props); const { children, element, hasMore, initialLoad, isReverse, loader, loadMore, pageStart, ref, threshold, useCapture, useWindow, getScrollParent, …props } = renderProps; // 定义一个ref // 能将当前组件的DOM传出去 props.ref = node => { this.scrollComponent = node; // 执行父组件传来的ref(如果有) if (ref) { ref(node); } }; const childrenArray = [children]; // 执行loader if (hasMore) { if (loader) { isReverse ? childrenArray.unshift(loader) : childrenArray.push(loader); } else if (this.defaultLoader) { isReverse ? childrenArray.unshift(this.defaultLoader) : childrenArray.push(this.defaultLoader); } } // ref 传递给 ‘div’元素 return React.createElement(element, props, childrenArray);}这里一个小亮点就是,在react中,this.props是不允许修改的。这里使用了解构getScrollParent,…props} = renderProps;这里解构相当于Object.assign,定义了一个新的object,便可以添加属性了,并且this.props不会受到影响。总结react-infinite-scroller逻辑比较简单。一些注意/学习/复习点:Chrome的一个滚动加载请求的bug。本文位置offsetParent的一些实际用法。本文位置通过不断订阅和取消事件绑定让滚动执行函数不会频繁触发。本文位置scrollHeight和clientHeight区别。本文位置此库建议使用在自定义的一些组件上并且不那么复杂的逻辑上。用在第三方库可以会无法获取正确的父组件,而通过document.getElementBy..传入。面对稍微复杂的逻辑,例如,一个搜索组件,订阅onChange事件并且呈现内容,搜索"a",对呈现内容滚动加载了3次,再添加搜索词"b",这时候"ab"的内容呈现是在3次之后。 ...

November 25, 2018 · 2 min · jiezi

主流移动滚动插件分析以及思路方案拓展(原生onscroll 事件实现的滚动加载不逊色)

背景现在流行的各种移动端滚动加载、上拉刷新组件,滑动越来越流畅,体验很好;例如 better-scroll、vue-infinite-scroll、iscroll、vue-scroll 等等比较好的方案去解决业务上的问题,可是笔者总会听到使用过程中也会产生一些奇怪的问题,或许会引起很多人的共鸣比如很多 better-scroll 小白会在一开始发现不能滚动,或者滚动加载异步数据better-scroll页面怎么不能滚动滚动加载了重复的数据(分页)、明明下面有数据,却拉不动等等各种姿势不对。笔者曾经已入坑了vue-infinite-scroll 和vue-scrollvue-infinite-scroll 是利用原生scroll 滚动,优点是原生滚动可以在列表加载了很多的时候不会卡顿,(黑科技)ios在微信上点两下回到顶部,但是不能滑动加速,插件本身不支持下拉刷新,而且在v-if 的组件下用到滚动比如一个侧边栏,会报错$mount error , 发现原因是我组件还是隐藏的时候,插件默认给我去跑$mount 钩子 ,我是通过改源码解决的,好坑,不过官方还是很快地发现了这个bug, 所以我觉得仍然是比较靠谱的vue-scroller 坑比较多,现在好像已经好久没维护了,不知道大家有无遇到过, 在同一个页面用vue-scroll 然后弹窗要 textarea 输入,超过行数产生滚动条,但坑爹的是textarea 竟然不能滚动了,看源码发现是touchMove 的事件里禁止了原生滚动,提了链接issue,我只能手动改源码了, 有图有真相对于滚动插件的实现原理以及优缺点对better-scroll、vue-scroll 这类滚动插件是要父元素container 定位在body, 禁止原生滚动,然后通过touch 事件,改变transform: translateY去实现,然后 refresh 去更新模拟滚动条长度对于 vue-infinite-scroll 这一类是通过原生onscroll 事件,判断scrollTop到达底部触发loadMore 加载异步数据, 当然原生的scroll 的缺点是 滚动点透,比如说在body上有一个弹窗滚动,滚动到底部之后会发现body 在滚了;第二个问题是,在finger触摸滚动而未结束滚动时,如果要做一些动作会有延时,不能像touchMove、touchEnd 那么灵敏我也是看了这篇文章得到了启发知识点1:移动web滚动问题在移动端,使用滚动来处理业务逻辑的情况有很多,例如列表的滚动加载数据,下拉刷新等等都需要利用滚动的相关知识,但是滚动事件在不同的移动端机型却又有不同的表现,下面就来一一总结一下。滚动事件:即onscroll事件,形成原因通俗解释是当子元素的高度超过父元素的高度时且父元素的高度时定值window除外,就会形成滚动条,滚动分为两种:局部滚动和body滚动。onscroll方法: 一般情况下当我们需要监听一个滚动事件时通常会用到onscroll方法来监听滚动事件的触发。如果在浏览器上调试这个方法在浏览器上很好用,但是如果跑在手机端就没有想象中的效果了。body滚动:在移动端如果使用body滚动,意思就是页面的高度由内容自动撑大,body自然形成滚动条,这时我们监听window.onscroll,发现onscroll并没有实时触发,只在手指触摸的屏幕上一直滑动时和滚动停止的那一刻才触发,采用了wk内核的webview除外。body滚动局部滚动 局部滚动:在移动端如果使用局部滚动,意思就是我们的滚动在一个固定宽高的div内触发,将该div设置成overflow:scroll/auto;来形成div内部的滚动,这时我们监听div的onscroll发现触发的时机区分android和ios两种情况,具体可以看下面表格:不同机型onscroll事件触发情况:body滚动 局部滚动ios 不能实时触发 不能实时触发android 实时触发 实时触发ios wkwebview内核 实时触发 实时触发wkwebview内核:这里说明一下关于ios的wkwebview内核是ios从ios8开始提供的新型webview内核,和之前的uiwebview相比,性能要好,具体大家可以自行查看关于wkwebview的相关概念。body滚动和局部滚动demo:这里我需要指出的是在采用wkwebview内核的页面中scroll是可以实时触发的,如果使用的是原本的uiwebview则不能够实时触发,手q目前使用的是uiwebview而新版微信使用的是wkwebview,大家可以分别使用来尝试一下下面的demo:局部滚动body滚动分别用ios手q和微信和android手q体验会有不同的结果。知识点2:关于模拟滚动有了上面介绍的关于滚动的知识,理解的模拟滚动就不难了。正常的滚动:我们平时使用的scroll,包括上面讲的滚动都属于正常滚动,利用浏览器自身提供的滚动条来实现滚动,底层是由浏览器内核控制。模拟滚动:最典型的例子就是iscroll了,原理一般有两种:1). 监听滚动元素的touchmove事件,当事件触发时修改元素的transform属性来实现元素的位移,让手指离开时触发touchend事件,然后采用requestanimationframe来在一个线型函数下不断的修改元素的transform来实现手指离开时的一段惯性滚动距离。2).监听滚动元素的touchmove事件,当事件触发时修改元素的transform属性来实现元素的位移,让手指离开时触发touchend事件,然后给元素一个css的animation,并设置好duration和function来实现手指离开时的一段惯性距离。这两种方案对比起来各有好处,第一种方案由于惯性滚动的时机时由js自己控制所以可以拿到滚动触发阶段的scrolltop值,并且滚动的回调函数onscroll在滚动的阶段都会触发。第二种方案相比第一种要劣势一些,区别在于手指离开时,采用的时css的animation来实现惯性滚动,所以无法直接触发惯性滚动过程中的onscroll事件,只有在animation结束时才可以借助animationend来获取到事件,当然也有一种方法可以实时获取滚动事件,也是借助于requestanimationframe来不断的去读取滚动元素的transform来拿到scrolltop同时触发onscroll回调。模拟滚动的fps值波动较大,这样滚动起来会有明显的卡顿感觉,各位体验的时候如果滚动超过10屏之后就可以明显感觉到两着的区别。在使用模拟滚动时,浏览器在js层面会消耗更多的性能去改变dom元素的位置,在dom复杂层级深的页面更为高,所以在长列表滚动时还要使用正常滚动更好。知识点3:滚动和下拉刷新下拉刷新的元素在页面顶部,正常浏览时不可见的。当在页面顶部往下滚动时出现下拉刷新元素,当手指离开时收起。以上两点时实现一个下拉刷新组件的基本步骤,结合我们上述关于滚动的描述,我们可以这样实现下拉刷新:方案1:借助iscroll的原理,整个页面使用模拟滚动,将下拉刷新元素放在顶部,当页面滚动到顶部下拉时,下拉刷新元素随着页面的滚动出现,当手指离开时收回,此方案实现起来较为简单直接借助iscoll即可,但是使用了模拟滚动之后在正常的列表滚动时性能上不如正常滚动。方案2:页面使用正常滚动,将下拉刷新元素放置在顶部top值为负值(正常情况下不可见),当页面处于顶部时下拉,这时监听touchmove事件,修改scrollcontent的tranlateY值,同时修改下拉刷新元素的tranlateY值,将两者同时位移来将下拉刷新元素显示出来,手指离开时(touchend)收回,这种方案满足了在正常列表滚动时使用原生的滚动节省性能,只在下拉刷新时使用模拟滚动来实现效果。方案3:方案2的改良版,唯一不同是将下拉刷新元素和scrollcontent放在一个div里,将下拉刷新元素的margintop设为负值,在下拉刷新时,只需要修改scrollcontent一个元素的tranlateY值即可实现下拉,在性能上要比方案2好。在采用了上述方案之后,还会有一个性能上的问题就是:当页面的列表过长,dom元素过多时,在模拟滚动,下拉刷新这段时间内,页面也会有卡顿现象,这里采取了一个优化策略即:1) 列表较长时dom数量较多时,在触发下拉刷新的时机时将页面视窗之外的dom元素隐藏或者存放在fragment里面。2) 在刷新完成之后手指离开(touchend)时将隐藏的元素显示出来。3) 需要注意的是,隐藏和显示视窗外的元素这个操作在下拉刷新时只会执行一次,并且只有在下拉刷新时才会执行。看完这篇文章,会感觉 better-scroll 也没那么牛逼了自己的看法滚动加载、上拉刷新一直是个前端界内一直在解决的题目,最近越发感觉自己要有自己的滚动插件,一个是用别人的东西不顺手,有问题只能在百度碰运气,解决不了问题可能要换框架或者改源码,时间也是挺耗的我还是喜欢 原生的onscroll 滚动加载,自己现在也是用这个实现的滚动加载,屡试不爽 在大神面前惭愧地贴下代码进入页面时 document.addEventListener(“scroll”, this.isToBottom);离开页面时 document.removeEventListener(“scroll”, this.isToBottom);敬请期待现在笔者正开发一个用两百行代码就可以实现原生onscroll滚动加载、下拉刷新,能够两次点击回到顶部、尽力打造稳定、解决问题、兼容性好、入手快、维护简单、源码简易的一个插件,区别于better-scroll 这种流畅感较好的插件,也算是一个 符合大众的一个解决方案,没有最好的只有最适合自己的

November 17, 2018 · 1 min · jiezi