TypeScript 实现微信的红点通知

841 阅读11分钟

TypeScript 实现微信的红点通知

在生活中我们经常能看到一些“红点”,这些红点指引我们去到某个地方,查看某个信息。

微信中就有很多的红点,设想一下,如果没有红点我们的使用会受到什么影响。

本文用树去实现一个可以自动更新状态的红点结构

微信看似有很多红点,实际这中间的过程都是由最深处的某个事件触发的,比如一条消息,一个朋友圈点赞,一个好友申请。中间的过程我们可以理解为通过最边缘的红点逐个传递到根部,用户从根部可以一层层去点击,像是一条指引路径,告诉用户这个红点在什么地方。

仓库地址:github.com/oloshe/red

分析

以微信为例子,我们简单把它的红点结构梳理一下,如下图所示。

微信的红点简化结构

简单分析:

  • 每个结点,都有一个名字(或者说 key ),用来和其他兄弟节点区分
  • 每个结点都有一个父结点(除了根结点)
  • 每个结点有 [0, n] 个子结点,数量为 0 的节点又称为 叶子结点
  • 有些结点的子结点个数是不确定的,需要动态添加一些结点

这就是一个普通的树,不高级,但很实用,我们暂且称之为 红点树

先创建一个简单的结点类:

class RedNode {
  /** 名字 */
  name: string
  /** 双亲 */
  parent: RedNode | null
  /** 信息荷载 */
  _value: number = 0
  /** 后代 */
  children: Record<string, RedNode> = {}
  /** 血统 */
  lineage: string
}

针对以上结构解释一下几个字段:

  • parent: RedNode | nullparent is RedNode 时,代表其有父结点,而当 parent is null 时,说明是根结点。
  • children: Record<string, RedNode> 子节点为什么不用 Array 呢?作为父亲,你肯定是能叫出每个孩子的名字,而不是单纯把他们塞进一个数组里,这样既能快速找到孩子,也更符合常理。
  • _value: number 这里表示这个红点所承受的红点数量。用下划线开头,因为需要设置 gettersetter
  • lineage: string 这里表示从根结点到此节点的完整路径,为了方便后续操作时可以快速得到该结点的完整路径。
  • 关于路径,以 A/B/C 的格式表示,其中 / 作为分隔符,定义了一个常量 const splitter = "/";

对于红点,一般来说会叫它为 Badge ,但是我觉得这个单词不是很形象,我们来打破传统,就叫他 RedNode

构造函数

构造函数把上面字段中没有赋值的一些字段赋上值:

class RedNode {
  // ...
  constructor(name: string, parent: RedNode | null, lineage?: string) {
    this.name = name, this.parent = parent;
    if (lineage !== void 0) {
      this.lineage = lineage
      return
    }
    // 历代血脉
    this.lineage = [...this]
      .map(x => x.name)
      .reverse()
      .join(splitter);
  }
  /**
   * 自身迭代器,从自己到祖先 (不包括 root)
   */
  *[Symbol.iterator]() {
    let dynasties: RedNode = this
    while (dynasties && dynasties.parent) {
      yield dynasties
      dynasties = dynasties.parent
    }
  }
}
  • name 红点唯一标识
  • parent 父结点,为 null 时是根结点
  • lineage 当传入时,不用遍历父结点

如果没有传入 lineage 时,就要一代一代地去问,从当前结点,向上不断迭代直到祖先结点然后形成一条完整路径

正常来说 [...this] 这种写法是会报错的,因为实现 Symbol.iterator 才能迭代。所以还要给 RedNode 添加一个迭代器,这个迭代器让寻找到祖先的方法变得更加优雅。

这个迭代器,会从 this 一直迭代到最先的结点 ,不包括根结点。(为什么不包括根结点,以微信为例子,根结点的红点数量,是集结了四个 tab 页的红点,最后设置到 applicationIconBadgeNumber 上实现通知操作系统的。而在 app 内是主要跟四个 tab 打交道。所以你会发现说是树,其实更像是森林,不过就看你怎么去定义结构了)

构造函数弄完之后,就可以生成一个根结点了:

class RedNode {
  // ...
  /** 红点树 根结点 */
  static root = new RedNode("@root", null);
}

根结点的名字不重要,所以随便起了。

有了根结点,但还需要一个添加结点的方法:

/** 添加孩子 */
addChild(path: string): RedNode | null {
  if (path === "") return null;
  let keyNames = path.split(splitter);
  let node: RedNode = this;
  let len = keyNames.length, tmpPath = "";
  // 从第0个开始到倒数第2个
  for (let i = 0; i < len - 1; i++) {
    let k = keyNames[i];
    // 如果该字符串为空字符串时,会直接跳过。
    if (!k) { continue }
    tmpPath += k;
    if (node.children[k]) {
      node = node.children[k];
    } else {
      // 中间存在不存在的结点的时候可以自动为其添加结点。
      let newNode = new RedNode(k, node, tmpPath + k);
      node.children[k] = newNode;
      node = newNode;
    }
    tmpPath += splitter;
  }
  let leafKey = keyNames[len - 1];
  let newNode = new RedNode(leafKey, node, path);
  node.children[leafKey] = newNode;
  return newNode;
}

这个方法作用就是通过传入的 path 根据 / 切分路径,然后为其添加结点。例如为 wechat 添加 Chats/zhangsan 。会切分成 ['Chats', 'zhangsan'] ,然后添加 wechat/Chats 结点,再添加 wechat/Chats/zhangsan 。(其中如果中间 Chats 结点不存在的话,会自动创建,但不推荐初始化的时候这样使用,因为这样就不能区分这是初始结点还是动态添加的结点了,后面会说到)

初始化

有了结点、根结点、构造函数、添加子节点的方法,自然而然就可以生成树了。让我们编写一个简单初始化树的函数:

class Red {
  static _initial_path_arr?: string[]
  /** 初始化红点树 */
  static init(initialPathArr: string[]) {
    red._initial_path_arr = initialPathArr;
    let len = initialPathArr.length;
    for (let i = 0; i < len; i++) {
      let path = initialPathArr[i]
      RedNode.root.addChild(path);
    }
  }
}

_initial_path_arr 用来记录初始化生成的红点,方便后面区分初始化结点和动态结点,以便执行一些不安全的操作

调用上面的静态方法,把微信的红点结构初始化:

red.init([
		// 消息
    'Chats',
  	// 动态结点 'Chats/...'
		// 联系人
    'Contacts',
    'Contacts/newFriends',
		// 发现
    'Discover',
    'Discover/Moments',
    'Discover/Channels',
    'Discover/TopStories',
  	// 我
    'Me',
    'Me/Pay',
    'Me/Cars&Offers',
])

因为 Chats 下的结点是不确定的,比方说某一天张三被删了,那么他就不用添加到 Chats 下面,所以这里暂时不生成。因为它有别于其他结构性的红点,例如 Contacts/newFriends 这个结点就永远不会消失。

经过初始化之后的结构如下图:

路径

更新机制

树的结构有了,那么接下来就是要去设置它的值了。通过前面的分析我们知道,通过设置叶子结点,去影响上层结点,实现自动更新。

_value 字段上 gettersetter :

class RedNode {
  
  // ...
  
  get value() {
    return this._value
  }

  set value(newValue: number) {
    if (newValue < 0) { newValue = 0 }

    // 相同值直接 return
    if (newValue == this._value) return;
    let delta = newValue - this._value;
    this._value += delta;

    console.log(`${this.lineage} = ${newValue} (${delta > 0 ? `+${delta}` : delta})`);
    
    // 通知所有监听者
    red._notifyAll(this.lineage, newValue);

    // 传递给父结点
    if (this.parent && this.parent.parent) {
      this.parent.value += delta;
    }
  }
  
  //...
  
}

PS: red._notifyAll 。这个方法是用于通知所有监听者监听的数值发生变化,然后去做回调。

这里先通过新值与旧值相减得到差值 delta ,然后设置自身的值,最后如果有父级,且父级不为根结点(前面说过,不带上根结点玩)就通过 += 符号修改父结点的值,父结点也会触发它的 setter 方法,不断传递上去。

这里的 lineage 就派上用场了,不用再去迭代每一个父结点获取路径了。

通过子结点去找到父结点其实很容易的,比如你描述你的曾祖父会很容易描述,反过来,你的曾祖父想要描述你,就不那么容易了,因为你可能是他众多子孙里的其中一个。

但是这个做法有个弊端,就是不能修改非叶子结点的值,如果修改了,那么就乱套了,比如:

Chats: 2Chats/zhangsan: 2

如果此时把 Chats 改成 0,而不同时改变 Chats/zhangsan 时,那么如果 Chats/zhangsan 改成了 3 时,Chats 的值就会变成1,就再不同步。

不过后面会针对特殊情况下需要设置非叶子结点时设计的 unsafe 方法。

更新方法

由于篇幅问题,以下代码是简化后的:

interface SetOption {
  /** 强制增加结点(无该结点时) */
  force?: boolean
}

class red {
	/**
   * 设置红点状态
   */
  static set(path: string, value: boolean | number, options: SetOption = {}) {
    if (typeof value === "boolean") value = Number(value);
    if (typeof value !== 'number') { console.warn(`red.set('${path}', ${value}) 警告!\n类型需要为 boolean 或者 number,却收到了 ${typeof value} 类型。使用默认值:0`); value = 0 }
    let {
      force,
    } = options;
    let node = red.resolvePath(path, { force, careless: false });
    if (!node) {
      console.error(`red.set('${path}', ${value}) 失败! \n原因:路径不存在 \n若要添加动态结点请设置 force 为 true!\noptions:`, options);
      return
    }

    if (!node.isLeftNode) {
      if (!red._non_leaf_node_change_lock_) {
        console.log('修改非叶子结点')
      } else {
        console.error(`red.set('${path}', ${value}) 失败!\n原因:正在设置非叶子结点的值,这将会造成父子元素不同步!\n请尽量避免这么干!\n如果不得不修改请使用 red.unsafe.set 方法来设置。`, node)
        return
      }
    }

    node.value = value
  }
  /** 防止非叶子结点被修改的锁, true => 不允许修改 false => 允许修改 */
  static _non_leaf_node_change_lock_: boolean = true
}

PS: red.resolvePath 方法是根据 path 路径去查找结点。当 forcetrue 时,会强制增加结点,也就是 动态结点careless 代表对于结果是否关心。如果在关心却找不到该路径的时候会把提示信息写出来。

node.isLeafNodeRedNode 下的 getter ,表示是否为叶子结点,前面说过子结点个数为 0 时即为叶子结点,定义如下:

get isLeafNode(): boolean {
  return Object.keys(this.children).length === 0
}

这个方法总体来说是很好理解的,就是 log 的地方比较多(该 log 的地方一定要 log ),重点就是调用 node.value = value 触发 setter 了。

red._non_leaf_node_change_lock_ 则是用来防止刚才说的修改非叶子结点问题。

关于修改非叶子结点的值,有一个 unsafe 方法,定义如下:

class red {
  static unsafe = {
    set(path: string, value: boolean | number, options: SetOption = {}) {
      red._non_leaf_node_change_lock_ = false
      red.set(path, value, options)
      red._non_leaf_node_change_lock_ = true
    },
  }
}

其实只是在上下两行加锁解锁而已,是不是很简单!当然这不是一个摆设,这可以很清楚地告诉使用者,你正在执行一个不推荐的操作,你最好清楚你自己在干什么!

设置路径

监听红点

监听函数比较简单,我的做法是监听路径 path ,也就是结点的 lineage 属性。上面提到在 setter 函数会调用一个方法 red._notifyAll ,只要遍历所有该路径的监听者函数,调用时把红点值传递过去就行了。

interface ListenerData {
  callback: (num: number) => void
}
class red {
  /** 红点变化监听者 */
  static listeners: Record<string, ListenerData[]> = {}
}

忽略红点

在红点上还有一个属性:

class RedNode {
  // ...
  /** 忽略红点 深度优先遍历忽略所有子孙后代 */
  ignore() {
    if (this.isLeafNode) {
      this.value = 0;
    } else {
      for (let i in this.children) {
        this.children[i].ignore()
      }
    }
  }
  // ...
}
class red {
  // ...
  /** 清理红点 */
  static clear(path: string) {
    RedNode.find(RedNode.root, path)?.ignore()
  }
  // ...
}

ignore方法会以深度优先找到根结点,然后调用 setter 方法,把全部叶子结点设为 0 ,那么该结点也自然为 0 了。

red.clear 其实就是暴露给外部调用的方法,根据路径找到红点,然后调用 ignore 方法。

释放结点

释放结点,删除相关监听者和自身与子结点的内存占用:

class red {
  
  static del(path: string): boolean {
    if (!path) return false
    // 在初始化的红点中,默认不能删除,请使用 red.unsafe.del 删除
    if (red._initial_path_arr?.indexOf(path) != -1) {
      console.error(`删除红点 ${path} 失败!\n原因:该路径在初始化红点,默认不能删除,请使用 red.unsafe.del 删除。`)
      return false
    }
    return red.unsafe.del(path);
  }
  
  static unsafe = {
    /**
     * 删除任意一个红点
     * 会释放红点树和监听者占用的内存,此时监听函数将不会生效
     * @param path 
     */
    del(path: string): boolean {
      let del_node = red.resolvePath(path)
      if (!del_node) { return false }
      // 删除结点 触发连锁更新
      let del_path = del_node.lineage;
      red.unsafe.set(del_path, 0);

      // dfs 检查子结点
      const check_it_out = (node: RedNode) => {
        // 监听是否存在
        let path = node.lineage
        console.log(path)
        let arr = red.listeners[path]
        if (arr && arr.length) {
          warn(`删除红点:${node.lineage}`);
          delete red.listeners[path]
        }
        // 删除结点
        delete node.parent?.children[node.name]

        if (!node.isLeafNode) {
          // 删除非叶子结点需要把所有 children 干掉
          for (let i in node.children) {
            check_it_out(node.children[i]);
          }
        }
      }
      check_it_out(del_node)
      return true
    }
  }
}

删除结点有两个方法,一个是 red.del 一个是 red.unsafe.del ,区别是前者不能删除初始化生成的结点,只能删除动态结点。而后者是什么结点都可以删除。(通过 red._initial_path_arr

删除方法可以分为四件事:

  1. 触发更新,也就是调用 red.unsafe.set 方法(可以想一下为什么需要设置?)
  2. 检查监听者,如果存在监听者,则直接删除所有监听者
  3. 删除自身
  4. 遍历后代继续删除(其实有一个可以优化的点,你觉得哪里可以优化?)

多状态结点

在一些特殊情况,一个红点的数据,可能有多个来源,这种时候有两种方式解决:

  1. 使用多状态 (本文把多状态相关的代码删了,其实用第二种方式可以实现一样的效果,有兴趣可以去 github 查看源码)
  2. 建立子结点

我们用第二种方式来看下面两个例子如何实现

例1:朋友圈红点

朋友圈红点可以分为两种:

  1. 跟我无关(别人发了新动态)
  2. 与我有关(我参与的动态有消息)

这时候就可以这样建立红点:

red.init([
  // ...
  'Discover/Moments',
  'Discover/Moments/aboutMe',
  'Discover/Moments/others',
  // ...
])

然后在 发现页 的 朋友圈 项同时监听两个红点,如果只有 Discover/Moments/others 则显示一个小红点。如果 Discover/Moments/aboutMe 不为 0 ,那就在旁边加上相应的数字红点。

例2: 聊天消息

聊天我们假设分为以下几种:

  • 文字消息
  • 媒体信息(语音,文字,视频)
  • 链接信息
  • 交易消息(转账/红包)

红点结构(动态):

[
  'Chats',
  'Chats/zhangsan',
  'Chats/zhangsan/text',
  'Chats/zhangsan/media',
  'Chats/zhangsan/link',
  'Chats/zhangsan/transaction',
]

这时候外面可以监听到 Chats/zhangsan ,如果浏览了信息,就可以调用 red.clear('Chats/zhangsan') 把张三的红点清空了。

如果这时候产品经理说需要转账红包未收时红点不消失,那就需要设置除了 Chats/zhangsan/transaction 之外的红点为 0 ,其他的红点就全让他们自动更新吧。

red.dump

封装组建

组建的封装就要看自己实现了,每个平台,每个框架实现都不一样。大概的思路是有个监听路径的属性,创建组件时调用 red.get 方法获取初始值,然后注册监听,当红点值发生变化的时候更新视图,组件销毁时注销监听就完事了。

red.get:获取红点值的方法,根据传入的路径找到红点,然后返回他的 value

为了方便理解这里以 React 为例子实现一个简单的红点组件吧:

const RedDot = (props: { path: string }) => {
  let [num, setNum] = useState(0)
  
  const fn = UseCallback(() => num => setNum(num), [])
  
  const _listener = UseMemo(() => {
    _listener?.off()
    red.on(props.path, { callback: fn }
  }), [props.path])
  
  useEffect(() => {
    setNum(red.get(props.path))
		return () => {
      _listener.off()
    }
  }, [props.path])
  
	return <div>{num}</div>
}

上面的代码没跑过,临时手写的,大概懂意思就行了(逃)

监听后端数据设置红点

红点的数据通常来说是根据服务端返回来决定的,在请求方法里做一些字段的全局监听,然后调用 red.set 设置对应的值。

在有新数据需要红点时可以创建动态结点。

优缺点

该总结一下这套方案的优缺点了:

优点:

  • 红点间有依赖关系,自动更新值
  • 性能好,计算量少
  • 一次设置,随处使用。

缺点:

  • 根据路径去设置值,缺少了强类型语言的引用追踪,如果频繁更换需求,路径之间经常修改的话,就可能会出现改了红点路径却忘记了修改某个地方的使用时就会出错。不过细心一点这个是可以避免的。
  • 由于父子之间都是单向传递,所以是默认所有值都是正确的,如果滥用 red.unsafe.set 方法就会造成很多奇怪的表现,由于所有的红点统一在一个对象里面,所以可能会很难找到问题的根源。

解(jiao)释(bian)

第一个缺点:由于最初设计是为 js 程序使用,所以字符串能更好的适应,如果是对象结构去使用路径,分分钟报一个 ReferenceError。但是到了强类型语言还是可以有针对性的解决方案的,于是我用 dart 又实现了一遍了,如果有空的话可以写一篇 《dart 实现红点树》。

第二个缺点:对于不好找到问题的方法,那就是多埋点,把过程和问题都 log 出来,给足提示。另外还写了一个 red.dump 方法方便查看红点状态,本文的状态图就是 dump 方法打印出来的,调试的时候很好用。

总结

本篇文章只是探讨红点的一种实现方式,微信真实的实现方式是不是基于树结构的不得而知。如果你知道其他的实现方式欢迎一起讨论。可能这篇文章的内容会比较乱,如果感兴趣的可以去 github 上查看代码,如果能够使用那就是我的荣幸了,代码不多也没有依赖库,直接复制过去就好啦。

如果这篇文章你觉得对你有用的话,欢迎点赞~