使用eggjs+websocket(socket.io)处理刷新/关闭页面

·  阅读 381
使用eggjs+websocket(socket.io)处理刷新/关闭页面

前言

前段时间开发了一个功能, 处理项目不少, 页面上下滑动, 列表停留项(感兴趣/不感兴趣), 以及各种点击, 技术栈的话使用了typescript + vitejs , 以ts面向对象的方式开发了一个js文件, 使用的时候引入, 传入配置项然后实例化之后就行了, 目前已经稳定运行一段时间了

最终结果

项目中最终只是做了刷新以及页面的停留时长的处理

关闭页面算是我自己的一个探索, 实际项目中并未涉及, 在这里记录一下和大家分享探讨, 可能不是最优解, 就当抛砖引玉了吧

项目复盘

在我看来, 这里面的难点在于:

  1. 列表停留项的开发, 这项功能主要是使用getBoundingClientRect来检测列表项和浏览器视口顶端(上方边界)的相对位置来判断它是在上方边界外, 还是在上方边界内(可视区域内), 在上方边界外的表示不感兴趣, 及时提交, 在可视区域内停留n秒之后表示感兴趣, 然后提交感兴趣的条目, 当然, 提交操作是在滚动停止后进行的, n秒内如果还有滚动那就取消上次的操作重新计时, 也就是做了一个去抖的操作
  2. 刷新/关闭页面, 这也是这篇文章的一个重点, 接下来会着重和大家交流探讨

准备工作

在正式开始开发这个功能之前, 有几个知识点需要我们提前了解一下, 就是浏览器的beforeunload, unload, load这三个事件它们的执行顺序和时机:

  1. 刷新页面: beforeunloadunloadload
  2. 关闭页面: beforeunloadunload
  3. 页面初次打开时: 只执行load事件

前两个事件刷新/关闭页面的时候都会执行, 但关闭页面的时候load事件不会执行只有刷新页面的时候会执行, 也就是说这个需求的题眼就在我们的这个load事件, load执行了, 那么就是刷新页面, 没有执行, 那就是关闭页面

页面刷新

一开始我想到了在前两个事件中的任意一个事件中设置一个标识(localStorage), load事件中去检测, 有这个标识是刷新, 没有就是关闭, 但是这个思路有一个问题: 关闭页面都不执行load事件了, 此时怎么在load中去判断呢? 后来在站内搜索, 找到了这篇文章: Vue踩坑之旅(二)—— 监听页面刷新和关闭, 里面给我提供了个思路: sessionStorage, 将里面的思路归纳一下, 就是:

beforeunload/unload中往sessionStorage里设置一个标识, 然后在load中判断, 有这个标识是刷新, 没有就是关闭

这个思路乍一看只是将我一开始的localStorage换成了sessionStorage, 同样会因为关闭页面时load不执行而无法检测这个标识, 但我实际尝试之后发现了使用sessionStorage的优越之处

相信关于这二者的区别大家都不陌生:

  1. localStorage: 作用域是整个浏览器, 除非手动清除, 否则将一直存在
  2. sessionStorage: 作用域只是当前会话, 也就是当前页面, 关闭当前页面或者浏览器之后里面的数据就会被清除

比如这里, 我监听unload事件, 在unload事件中设置一个sessionStorage作为一个标识:

    //网页卸载事件
    window.addEventListener('unload', () => {
      //事件追踪_设置标识_刷新
      sessionStorage.setItem('isRefreshPivot', '1');
    });
复制代码

然后在load中去判断或者说去使用这个标识, 当属于刷新页面的情况时做一个计数的操作:

    //网页加载事件
    window.addEventListener('load', () => {
      //事件追踪_使用标识_刷新
      const isRefreshPivot = sessionStorage.getItem('isRefreshPivot');
      if(isRefreshPivot === '1') {
        let isRefreshNum = 0;
        let isRefreshCount = localStorage.getItem('isRefreshCount');
        if(isRefreshCount) {
          isRefreshNum = parseInt(isRefreshCount, 10);
        }
        localStorage.setItem('isRefreshCount', `${++isRefreshNum}`);
        sessionStorage.removeItem('isRefreshPivot');
      }
    });
复制代码

上述代码会在我们每次刷新页面的时候做一个计数的操作, 数字会被记录到localStorage中, 方便我们确认刷新操作相关代码成功执行了

刷新是没问题了, 那关闭的时候呢? 这就是使用sessionStorage的优越之处了: 关闭当前页面的时候, beforeunloadunload都会执行, 但由于此时会话被销毁了, 里面sessionStorage相关代码不会执行, 此时就不会有刷新标识isRefreshPivot了, 所以关闭就不会被误判为刷新了

页面关闭

到这里我们已经把页面刷新处理了, 关键就在页面关闭, 而js中没有方法能直接判断页面关闭, 网上有很多奇淫技巧, 比如检测鼠标的位置, 但万一鼠标往上移动, 用户不是点那个叉, 而是点收藏夹或者其他地方就不行了, 这个方法不准确, 大部分还是围绕beforeunload unload load这三个事件执行的时机和顺序来处理, 但都不理想, 这里就不一一赘述了

最后, 考虑到js无法检测页面/浏览器关闭的这个动作, 但关闭页面或者浏览器能断开WebSocket的连接, 因此选择了WebSocket, 既然要使用WebSocket, 那自然要先对它有一个了解, 这里我参考了阮一峰老师的WebSocket 教程一文, 最终的实现使用的是Socket.IO

服务端我使用了nodejs, 框架的话用的是阿里的egg, 它是基于koa开发的, 性能优异, Express虽然上手容易, 使用广泛, 但就如egg文档里提到的那样, 缺乏约定, 写法千奇百怪, 不利于团队协作开发, 而egg把插件 框架 应用分离开来, 比如维护框架的小伙伴将框架搭建好, 其他需要使用的可以使用框架进行开发, 遇到需要升级的情形, 那么维护框架的小伙伴升级框架, 其他小伙伴更新即可

这里还要提一下NestJS, 我司中台业务中在用, 并且还用到了tsGraphQL, 考虑到它的依赖注入的形式以及对ts的高度支持的原因, 最终选择了这个框架, 而个人觉得nest太新潮, 国内使用的人数不多, 相对小众, 以及我这个也只是一个自己个人的探索, 所以最终我没有选择nest而是选择了egg

同时需要留意的是, egg有自己的websocket实现: egg-socket.io, 它是封装自socket.io的, 封装之后能更好的配合egg来使用, 但使用上会有一些socket.io版本原因导致的问题, 加之里面的socket.io版本较老, 是2.x, 目前socket.io的版本已经到4.x了, 因此这里我在程序中直接使用了socket.io而不是egg-socket.io

socket-server

那么接下来就进入到我们代码编写的阶段了, 首先我们需要用egg搭建我们的项目 socket服务的建立详细可以查阅socket.io的文档: Socket.IO, 按照egg 文档的步骤使用它的脚手架搭建起项目框架之后, 我更新了里面的依赖, 全部换到目前最新, 并安装了socket.io:

package.json:

{
  "name": "egg-socket-server",
  "version": "1.0.0",
  "description": "",
  "private": true,
  "egg": {
    "declarations": true
  },
  "dependencies": {
    "egg": "^2.31.0",
    "egg-scripts": "^2.15.1",
    "socket.io": "^4.3.1"
  },
  "devDependencies": {
    "autod": "^3.1.1",
    "autod-egg": "^1.1.0",
    "egg-bin": "^4.16.4",
    "egg-ci": "^1.19.0",
    "egg-mock": "^3.26.0",
    "eslint": "^8.0.1",
    "eslint-config-egg": "^9.0.0"
  },
  "engines": {
    "node": ">=10.0.0"
  },
  "scripts": {
    "start": "egg-scripts start --daemon --title=egg-server-egg-socket-server",
    "stop": "egg-scripts stop --title=egg-server-egg-socket-server",
    "dev": "egg-bin dev",
    "debug": "egg-bin debug",
    "test": "npm run lint -- --fix && npm run test-local",
    "test-local": "egg-bin test",
    "cov": "egg-bin cov",
    "lint": "eslint .",
    "ci": "npm run lint && npm run cov",
    "autod": "autod"
  },
  "ci": {
    "version": "10"
  },
  "repository": {
    "type": "git",
    "url": ""
  },
  "author": "",
  "license": "MIT"
}
复制代码

然后在context中去初始化socket.io:

/app/extend/context.js:

'use strict';

const { Server } = require('socket.io');

// 初始化websocket
const io = new Server({
  path: '/socket', // ws路径名称
  serveClient: false, // 是否为客户端提供文件
  cors: { // 跨域相关配置
    origin: 'http://localhost:9090', // 请求头里的origin值
    methods: [ 'GET' ], // 允许的请求方式
  },
  transports: [ 'websocket', 'polling' ], // 允许的传输方式, 优先websocket, 不行再使用polling
});

const ctx = {
  get io() {
    return io;
  },
};

module.exports = ctx;
复制代码

这里使用了cors配置项是为了试试跨域的处理, 也可以不设置, 在egg的模板文件中写我们的客户端代码

接下来就是我们controller的编写, 默认脚手架生成项目框架的时候会有一个index的方法, 那个可以保留, 这里我们新建一个socket的方法, 在里面做事件监听的操作:

/app/controller/home.js:

  async socket() {
    const { ctx } = this;

    ctx.body = 'socket.io is on';

    const { io } = ctx;

    let timer = 0;

    // 是否是刷新
    let isRefresh = false;

    // 执行次数
    let exeTimes = 0;

    // 检测是刷新还是关闭
    const checkRefreshOrClose = () => (
      new Promise(
        resolve => {
          timer = setInterval(
            () => {
              ++exeTimes;
              console.log('checkRefreshOrClose');
              if (exeTimes === 20) {
                // 5秒之后无刷新操作认为是关闭
                clearInterval(timer);
                resolve('close');
              }
              if (isRefresh) {
                clearInterval(timer);
                resolve('refresh');
              }
            },
            250
          );
        }
      )
    );

    io.on('connection', socket => {
      socket.emit('init', {
        success: 1,
        data: {
          a: 1,
          b: 2,
        },
      });
      socket.on('disconnect', async reason => {
        const res = await checkRefreshOrClose();
        isRefresh = false;
        exeTimes = 0;
        console.log(res);
      });
      socket.on('refresh', () => {
        isRefresh = true;
        exeTimes = 0;
      });

    });

    io.listen(3001);

  }
复制代码

紧接着在我们路由文件中加一条:

/app/router.js:

  router.get('/socket', controller.home.socket);
复制代码

这个路径可以随意, 主要作用是curl或者浏览器访问的时候执行我们刚才写的socket方法, 将socket.io初始化完毕, 并设置事件监听, 不是一定要和controller中的socket方法名称一样

以及这里的nodejs程序只是我个人的一个探索和学习, 传入配置初始化socket.io的代码也可以直接写到/app/controller/home.jssocket方法中, 而不必要写到/app/extend/context.js中, 我这里只是想要尽可能多的用一用 试一试egg

代码详解

响应体中返回socket.io is on只是给个提示, 这里不写也没问题, 以及我们的egg约定, 在context中写的代码, 里面get方法返回的内容将绑定到this.ctx中, 于是接下来的io实例来自于我们的ctx

socket相关代码的详细解释可以查看socket.io的文档, 这里使用到的是连接成功的一些操作, 以及主动给客户端推送消息:

    io.on('connection', socket => {
      socket.emit('init', {
        success: 1,
        data: {
          a: 1,
          b: 2,
        },
      });
      socket.on('disconnect', async reason => {
        const res = await checkRefreshOrClose();
        isRefresh = false;
        exeTimes = 0;
        console.log(res);
      });
      socket.on('refresh', () => {
        isRefresh = true;
        exeTimes = 0;
      });

    });
复制代码

socket连接之后给客户端发送一个init的事件, 接下来socket监听disconnect以及客户端在刷新的时候发送过来的refresh事件, 这里发送/监听的事件有两类:

  1. socket.io自己的事件: connection建立连接事件, disconnect断开连接事件
  2. 服务端和客户端之间约定的自定义事件: socket实例使用emit发送的事件init, 以及监听的客户端发送过来的refresh事件

监听事件使用on, 发送事件使用emit, 这两个APIsocket.io的服务端和客户端中都是一样的

同时这里除了socket.io相关逻辑还有一些代码:

    // 检测是刷新还是关闭
    const checkRefreshOrClose = () => (
      new Promise(
        resolve => {
          timer = setInterval(
            () => {
              ++exeTimes;
              console.log('checkRefreshOrClose');
              if (exeTimes === 20) {
                // 5秒之后无刷新操作认为是关闭
                clearInterval(timer);
                resolve('close');
              }
              if (isRefresh) {
                clearInterval(timer);
                resolve('refresh');
              }
            },
            250
          );
        }
      )
    );
复制代码

这是因为当页面刷新的时候, socket会断开, 然后重连, 也就是, 刷新的时候会执行以下代码:

  1. 断开连接
  2. 重新连接
  3. 刷新的自定义事件

那么这个时候就需要我们在断开连接的时候去判断那个刷新的自定义事件是否接收到, 没有接收到, 那么就是断开连接的情形, 接收到了就是刷新

checkRefreshOrClose方法在断开连接的时候执行, 1秒执行4次, 累积执行20次也就是5秒, 后续代码等待这个方法的执行结果, 这期间如果刷新的自定义事件发送过来了, 则中断执行, 得到刷新页面的结果, 如果这期间刷新的自定义事件没有发送过来, 那么5秒之后得到关闭页面的结果, 这里定了一个5秒是为了兼顾网络卡顿的情况

同时这里需要留意, egg的服务默认启动端口是7001, 在这个项目里, 使用egg结合socket.io, socket的监听端口无法使用7001, socket.io的官网有结合express的示例, 我尝试express + socket.io启动websocket服务, nodejs的接口和websocket服务是可以监听同一个端口的, egg文档里有提到使用stickynginx, 但我尝试之后依旧失败了, 可能是由于直接使用socket.io需要额外的配置吧, 或者我哪里没有写对, 官网的配置都是基于egg-socket.io, 但这不是本文或者说本次探索的重点, 因此我也没有过多的纠结

socket-client

由于我本人有个draft草稿仓库, 实际上是自己使用webpack 4.x搭建的一个开发环境, 支持使用react来开发, 一来练习一下webpack的配置, 二来作为开发项目的脚手架来使用, 之前公司项目中还使用过, 后来用了阿里出的umijs之后就再没用过了, 目前就只是当一个静态资源服务器来用

但由于前端工程化的开发已经用不到html文件了, 确切的说是就一个index.html模板文件, 其他的js csswebpack分包处理之后写到其中, 写一个静态htmldemo的话要去修改那个index.html, 然后里面还有一些冗余的reactbundle代码, 因此我在里面安装了express, 使用express来让它成为一个静态资源服务器

启动静态资源服务器的相关代码可以到express的官网中查看, 这里就不再重复, 或者大家可以使用自己熟悉喜欢的方式启动本地开发环境来编写我们的客户端代码, 然后只需要修改服务端socket.io的配置即可, 比如跨域的配置, 这里贴出html中的代码, 也就是socket-client的代码:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <script type="text/javascript" src="./js/socket.io.min.js"></script>
  <script type="text/javascript">

    //网页卸载事件
    window.addEventListener('unload', () => {
      //事件追踪_设置标识_刷新
      sessionStorage.setItem('isRefreshPivot', '1');
    });

    //网页加载事件
    window.addEventListener('load', () => {
      //socket.io
      const socket = window.io('ws://localhost:3001',
        {
          path: '/socket',
          transports: [ 'websocket', 'polling' ]
        }
      );
      socket.on('connect', () => {
        console.log(socket.connected);
        //不能在这里注册事件处理处理器, 因为这个事件会在socket每次连接和重连的时候执行
      });
      socket.on('init', res => {
        console.log(res);
      });
      //事件追踪_使用标识_刷新
      const isRefreshPivot = sessionStorage.getItem('isRefreshPivot');
      if(isRefreshPivot === '1') {
        //发送请求告知服务器
        socket.emit('refresh');
      }

    });
  </script>
</body>
</html>
复制代码

服务端使用了socket.io, 那么客户端也需要使用socket.io的客户端sdk, 不然客户端可能会无法连接服务端, 版本也是v4.3.1, 客户端的sdk的版本要和服务端使用的socket.io的版本一致, 不然会出现一些问题, 同时接入方法也比较简单, 照文档一步步来即可

代码详解

页面加载完毕之后与服务端建立ws连接, 路径要和服务端一致, 都是/socket, transports是优先使用websocket, 如果不行再使用长轮询, 这里用的google chrome浏览器, 因此是支持ws的, 这不用担心

连接成功之后输出socketconnected状态, 这里只要连接成功就是true

以及还可以看到这里监听了一个init的事件, 这个事件是在连接成功时由服务端发送过来的, 这只是一个接受服务端主动发起的自定义事件的代码, 是一次尝试, 一次练习, 删除二者的自定义init事件的发送与监听不会影响本次功能的开发探索

接下来就是当用户刷新页面的时候, 客户端给浏览器发送一个自定义的refresh事件, 用不到参数, 所以这里并没有传递第二个参数到emit方法中, 相应的, 上文中提到的服务端监听的refresh事件就是来自这里啦

以及我这里页面的访问地址是:http://localhost:9090/socket-io-client.html, 这也就是服务端socket.io配置项里cors.origin值的来源了, 因为跨域

查看结果

好了, 到这里, socket.io的服务端和客户端代码都已经开发完毕, 接下来就可以查看结果了:

启动ws服务:

  1. 在服务端项目中使用命令: $ npm run dev运行程序
  2. 在浏览器中打开http://127.0.0.1:7001/socket或者是$ curl http://127.0.0.1:7001/socket启动ws服务

客户端运行:

  1. 启动静态资源服务
  2. 浏览器中打开http://localhost:9090/socket-io-client.html

刷新页面, 我们能看到服务端命令行中打印refresh, 关闭页面则能看到close, 具体入库的操作就不在本文的探讨范围了, 这里就是一个刷新/关闭页面动作的捕获记录

好的, 那么这次使用websocket处理刷新/关闭页面的功能到这里就告一段落了, 有任何问题欢迎在评论区探讨, 最后, 如果你觉得这篇文章写得还不错, 别忘了给我点个赞, 如果你觉得对你有帮助, 可以点个收藏, 以备不时之需

参考文献:

  1. EggJS
  2. Socket.IO
  3. WebSocket 教程
  4. WebSocket
分类:
前端
标签: