15

前言

常见的js插件都很少使用ES6的class,一般都是通过构造函数,而且常常是手写CMDAMD规范来封装一个库,比如这样:

// 引用自: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.xbabel 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.存在哪?

记住浏览位置,需要将用户离开前的浏览位置记录在客户端的浏览器中。这些信息可以主要存放在:cookiesessionStoragelocalStorage中。

  1. 存放在cookie,大小4K,空间虽有限但也勉强可以。但cookie是每次请求服务器时都会携带上的,无形中增加了带宽和服务器压力,所以总体来说是不太合适的。
  2. 存放在sessionStorage中,由于仅在当前会话下有效,用户离开页面sessionStorage就会被清除,所以不能满足我们的需求。
  3. 存放在localStorage,浏览器可永久保存,大小一般限制5M,满足我们需求。

综上,最后我们应该选择localStorage

3. 需注意的问题

  1. 一个站点可能有很多页面,如何标识是哪个页面呢?

一般来说可以用页面的url作为页面的唯一标识,比如:www.xx.com/article/${id},不同的id对应不同的页面。

但博主考虑到现在很多站点都是用spa了,而且常见在url后面会带有#xxx的哈希值,如www.xx.com/article/${id}#tag1www.xx.com/article/${id}#tag2这种情况,这可能表示的是同一个页面的不同锚点,所以用url作为页面的唯一标识不太可靠。

因此,博主决定将这个页面唯一标识作为一个参数来让使用者来决定,姑且命名为pageKey,让使用者保证是全站唯一的即可。

  1. 如果用户访问我们的站点中很多很多的页面,由于localStorage是永久保存的,如何避免localStorage不断累积占用过大?

我们的需求可能仅仅是想近期记住即可,即只需要记住用户的浏览位置几天,可能会更希望我们存的数据能够自动过期。

localStorage自身是没有自动过期机制的,一般只能在存数据的时候同时存一下时间戳,然后在使用时判断是否过期。如果只能是在使用时才判断是否清除,而新访问页面时又会生成新的记录,localStorage中始终都会存在至少一条记录的,也就是说无法真正实现自动过期。这里不禁就觉得有点多余了,既然都是会一直保留记录在localStorage中,那干脆就不判断了,咱换一个思路:只记录有限的最新页面数量

举个例子:

咱们网站有个文章页:www.xx.com/articles/${id},每个的id表示不同的文章,咱们只记录用户最新访问的5篇文章,即维护一个长度为5的队列。

比如当前网站有id从1100篇文章,用户分别访问第1,2,3,4,5篇文章时,这5篇文章都会记录离开的位置,而当用户打开第六篇文章时,第六条记录入队的同时第一条记录出队,此时localStorage中记录的是2,3,4,5,6这几篇文章的位置,这就保证了localStorage永远不会累积存储数据且旧记录会随着不断访问新页面自动“过期”。

为了更灵活一点,博主决定给这个插件添加一个maxLength的参数,表示当前站点下记录的最新的页面最大数量,默认值设为5,如果有小伙伴的需求是记录更多的页面,可以通过这个参数来设置。

4. 实现思路

  1. 我们需要时刻监听用户浏览页面时的滚动条的位置,可以通过window.onscroll事件,获得当前的滚动条位置:scrollTop
  2. scrollTop和页面唯一标识pageKey存进localStorage中。
  3. 用户再次打开之前访问过的页面,在页面初始化时,读取localStorage中的数据,判断页面的pageKey是否一致,若一致则将页面的滚动条位置自动滚动到相应的scrollTop值。
是不是很简单?不过实现的过程中需要注意一下细节,比如做一下防抖处理。

实现步骤

逼逼了这么久,是时候开始撸代码了。

1.封装localStorage工具方法

工欲善其事,必先利其器。为更好服务接下来的工作,咱们先简单封装一下调用localStorage的几个方法,主要是get,set,remove

// storage.js
const 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 Storage

2. 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事件监听用户滚动行为;

因此,需要在构造函数中就执行initScrolladdScrollEvent这两个方法:

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

打包

接下来应该是本文的重点了,首先要清楚为什么要打包?

  1. 将项目中所用到的js文件合并,只对外输出一个js文件。
  2. 使项目同时支持AMD,CMD、浏览器<script>标签引入,即umd规范。
  3. 配合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.jsindex.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'
  }
};

注意libraryTargetumd,就是我们要打包的目标规范为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-js
core-js是JavaScript模块化标准库,在@babel/preset-env按需打包时会使用core-js中的函数,因此这里也是要安装的,不然打包的时候会报错。

2.修改webpack.config.js配置,添加rules

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'
  },
  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

发布步骤:

  1. 先到https://www.npmjs.com/注册一个账号,然后验证邮箱。
  2. 然后在命令行中输入:npm adduser,输入账号密码邮箱登录。
  3. 运行npm publish上传包,几分钟后就可以在npm搜到你的包了。

至此,基本就完成一个插件的开发发布过程啦。

不过一个优秀的开源项目,还应该要有详细的说明文档,使用示例等等,大家可以参考下博主这个项目的README.md中文README.md

最后

文章写了好几天了,可谓呕心沥血,虽然比较啰嗦,但应该比较清楚地交代了如何运用ES6语法从零写一个记住用户离开位置的js插件,也很详细地讲解了如何用最新的webpack打包我们的库,希望能让大家都有所收获,也希望大家能到GitHub上点个Star鼓励一下啦。

remember-scroll这个插件其实几个月前就已经发布到npm了,一直比较忙(懒)没写章分享。虽然功能简单但很有诚意,能兼容到IE9。

使用起来也非常方便简单,可直接通过script标签cdn引入,也可以在vue中import RememberScroll from 'remember-scroll'使用。文档中有详细的使用示例:

项目地址Github,在线Demo

欢迎大家评论交流,也欢迎PR,同时希望大家能点个Star鼓励一下啦。


fengxianqi
1.8k 声望2.2k 粉丝