TypeScript 实现微信的红点通知
在生活中我们经常能够看到一些“红点”,这些红点指引着我们去到某个地方,查看某个信息。
比如,你的好友张三在微信给你发了一条消息,你所看到的是微信图标上面的红点数字 ”1“,点进微信在底部消息图标上也有个红色 “1”,然后你又点到了消息列表,看到了一条条消息琳琅满目,而张三头像右上角一个显赫的红色 ”1“ ,让我们自然而然地先点进和张三的聊天记录,看到他最新的消息。
至此,这就是一个我们日常熟悉得不能再熟悉的流程。那么问题来了,为什么我们能跟着这些红点一步一步地点下去呢?
首先,从 颜色心理学 的角度来说,红色是令人兴奋的、充满活力的,同时也是一个引人注目的颜色。在一些 APP 中用户的行为甚至完全是被红点带着走,不把所有红点点完就浑身难受。但是这不是本篇文章的重点。本文来分析和实现一下红点通知的系统。
可以想到红点是分层级的,看似很多红点,实际这中间的过程都是由最深处的某个事件触发的,中间过程是类似自动生成一条指引路径,告诉用户这个红点在什么地方。则可以想到用 树结构(Tree) 去实现这个设计。
先上仓库地址吧:github.com/oloshe/red
分析
以微信为例子,我们简单把它的红点结构梳理一下,如下图所示。
可以得出以下简单的结论:
- 每个结点,都有一个名字(或者说
key
),用来和其他兄弟节点区分 - 每个结点都有一个父结点(除了根结点)
- 每个节点有 [0, n] 个子节点,其中数量为 0 的节点又称为 叶子结点
可以看出这就是一个普通的树,不是什么红黑树、B+树,没这么高级,但很实用,我愿称之为 红点树
根据以上,我们先创建一个简单的结点类:
class RedNode {
/** 名字 */
name: string
/** 双亲 */
parent: RedNode | null
/** 财产 */
_value: number = 0
/** 后代 */
children: Record<string, RedNode> = {}
/** 血统 */
lineage: string
}
复制代码
针对以上结构解释一下几个字段:
parent: RedNode | null
当parent is RedNode
时,代表其有父结点,而当parent is null
时,说明是根结点。children: Record<string, RedNode>
子节点为什么不用Array
呢?作为父亲,你肯定是能叫出每个孩子的名字,而不是单纯把他们塞进一个数组里,这样既能快速找到孩子,也更符合常理。_value: number
这个字段称为财产可能不太合适,这里表示这个红点所承受的红点数量。用下划线开头,因为需要设置getter
和setter
,先埋个伏笔。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);
}
}
复制代码
name
相当于告诉别人我叫什么parent
相当于告诉别人我爸是谁lineage
相当于 ”户口本“,这个作用还是有一些的,用空间换取时间。
如果没有传入 “户口本”的话,就要一代一代地去问,先找到你,再找到你爸,再找到你爷爷…
所以这个 [...this]
就是相当于把我的家族一代一代往上找。然后 .map(x => x.name)
是指找到每一代后只取他们的名字,得到的数组就是从我到我的祖先的名字数组,但是肯定顺序不能搞反,所以 .reverse()
倒置一下,最后 .join(splitter)
把这一串名字写成“血脉”。
至此,构造函数就完了。一个红点就这样构造出来了。
但是其实留了个坑,因为正常来说 [...this]
这种写法来说一定会报错的,因为 ES6 的扩展符不是所有东西都能扩展的,要实现了 Symbol.iterator
才能实现。所以还要给 RedNode
添加一个迭代器:
class RedNode {
...
/**
* 自身迭代器,从自己到祖先 (不包括 root)
*/
*[Symbol.iterator]() {
let dynasties: RedNode = this
while (dynasties && dynasties.parent) {
yield dynasties
dynasties = dynasties.parent
}
}
}
复制代码
这个迭代器,会从 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
结点不存在的话,会自动创建。(不推荐初始化的时候这样使用,因为中间结点就不会添加进 _initial_path_arr
了,下一节会讲到)
初始化
有了结点、根结点、构造函数、添加子节点的方法,自然而然就可以生成树了。让我们编写一个简单初始化树的函数:
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
用来记录初始化生成的红点,方便后面区分初始化结点和动态结点,以便执行一些不安全的操作unsafe
调用上面的静态方法,把微信的红点结构初始化:
red.init([
// 消息
'Chats',
// 联系人
'Contacts',
'Contacts/newFriends',
// 发现
'Discover',
'Discover/Moments',
'Discover/Channels',
'Discover/TopStories',
// 我
'Me',
'Me/Pay',
'Me/Cars&Offers',
])
复制代码
因为
Chats
下的结点是不确定的,比方说某一天张三被删了,那么他就不用添加到Chats
下面,所以这里暂时不生成。因为它有别于其他结构性的红点,例如Contacts/newFriends
这个结点就永远不会消失。
经过初始化之后的结构如下图:
更新机制
好了,树的结构有了,那么接下来就是要去设置它的值了。通过前面的分析我们知道,通过设置叶子结点,去影响上层结点,实现自动更新。
为 _value
字段上 getter
和 setter
:
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;
}
}
//...
}
复制代码
貌似有个陌生的东西:
red._notifyAll
。这个方法是用于通知所有监听者监听的数值发生变化,然后去做回调。
这里应该很好理解,先通过新值与旧值相减得到差值 delta
,然后设置自身的值,最后如果有父级,且父级不为根结点(前面说过,不带上根结点玩)就通过 +=
符号修改父结点的值,其实就是先调用父结点 value
的 getter
方法,再调用 setter
方法,以此实现不断向上传递。
这里的子结点去找到父结点其实很容易,一条线就到了,每一层之间都是 1: 1 的关系。(比如你描述你的曾祖父会很容易描述,反过来,你的曾祖父想要描述你,就不那么容易了,因为你可能是他众多子孙里的其中一个)
其实最初这个设计并不是这样的,之前的设计是一个子元素变化之后,会集合所有兄弟结点,把所有的值统计起来,然后上报给父级,然后父级的值直接被覆盖。这样做的弊端是浪费计算,时间复杂度会大很多。
这现在的这种做法,直接把复杂度从 O(n) 降到 O(1) , 前者有些情况的复杂度甚至是 O(n^2) 。
举个形象的例子:
前者:我赚到5块,然后召集四个兄弟姐妹,一起来统计钱,算出来一共有20块,然后再告诉父亲,父亲之前记的是15元,然后这次收到之后,他就记为20元,然后父亲就跟他的兄弟姐妹,一起算钱,算完告诉爷爷,然后爷爷再… (光听步骤就很蛋疼)
后者:我赚了5块,我直接告诉父亲 +5,然后之前父亲是15,他就直接改成20了,然后父亲告诉爷爷 +5,爷爷就也 +5 …
整个过程是不是变得简单了许多,所以有时候还是要审视一下当前的实现是不是最优的。
但是这个做法也有个弊端,就是不能修改非叶子结点的值,如果修改了,那么就乱套了,比如:
Chats: 2
、 Chats/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
}
复制代码
其中 red.resolvePath
方法是根据 path
路径去查找结点。当 force
为 true
时,会强制增加结点,也就是 动态结点。careless
代表对于结果是否关心。如果在关心却找不到该路径的时候会把提示信息写出来。
node.isLeafNode
是另外一个 RedNode
下的 getter
,表示是否为叶子结点,前面说过子结点个数为 0
时即为叶子结点,定义如下:
get isLeafNode(): boolean {
return Object.keys(this.children).length === 0
}
复制代码
这个方法,除了一些类型校验之外,也没什么重点了。最终也只是调用 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
,他所传递的参数其实就两个:路径和值。只要遍历所有该路径的监听者函数,调用时把参数传给他就行了。
忽略红点
在红点上还有一个属性:
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()
}
// ...
}
复制代码
redNode.prototype.ignore
方法会以深度优先找到根结点,然后调用 setter
方法,把全部叶子结点设为 0
,那么该结点也自然为 0
了。
red.clear
其实就是暴露给外部调用的方法,根据路径找到红点,然后调用 ignore
方法。
怎么样,结构如果设计好了,写什么方法看起来很简洁吧!?
释放结点
先看代码吧:
class red {
/** 红点变化监听者 */
static listeners: Record<string, ListenerData[]> = {}
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.unsafe.del
方法,给结点来个最终的告别 - 检查监听者,如果存在监听者,则直接删除所有监听者
- 删除自身
- 遍历后代继续删除(其实有一个可以优化的点,后代删除的时候没有必要调用
red.unsafe.set
了,因为他的父级已经被删除,而且父级已经处理完后事了)
多状态结点
在一些特殊情况,一个红点的数据,可能有多个来源,这种时候有两种方式解决:
- 使用多状态 (本文把多状态相关的代码删了,其实用第二种方式可以实现一样的效果,有兴趣可以去 github 查看源码)
- 建立子结点
我们用第二种方式来看下面两个例子如何实现
例1:朋友圈红点
朋友圈红点可以分为两种:
- 跟我无关(别人发了新动态)
- 与我有关(我参与的动态有消息)
这时候就可以这样建立红点:
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.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
设置对应的值。
在需要创建动态结点的时候,传递 { force: true }
即可。
优缺点
该总结一下这套方案的优缺点了:
优点:
- 红点间有依赖关系,自动更新值
- 性能好,计算量少
- 一次设置,随处使用。
缺点:
- 根据路径去设置值,缺少了强类型语言的引用追踪,如果频繁更换需求,路径之间经常修改的话,就可能会出现改了红点路径却忘记了修改某个地方的使用时就会出错。不过细心一点这个是可以避免的。
- 由于父子之间都是单向传递,所以是默认所有值都是正确的,如果滥用
red.unsafe.set
方法就会造成很多奇怪的表现,由于所有的红点统一在一个对象里面,所以可能会很难找到问题的根源。
解(jiao)释(bian)
第一个缺点:由于最初设计是为 js 程序使用,所以字符串能更好的适应,如果是对象结构去使用路径,分分钟报一个 ReferenceError
。但是到了强类型语言还是可以有针对性的解决方案的,于是我用 dart
又实现了一遍了,如果有空的话可以写一篇 《dart 实现红点树》。
第二个缺点:对于不好找到问题的方法,那就是多埋点,把过程和问题都 log 出来,给足提示。另外还写了一个 red.dump
方法方便查看红点状态。有兴趣的可以看看:
class red {
/** 调试用 */
static dump() {
log("监听者", red.listeners);
log("联合状态", red.unionMap);
log("状态树:", RedNode.root);
(function a(n, l) {
let g = `${n.name} (${n.value})`;
// @ts-ignore
l === 1 && console.groupCollapsed(g);
l > 1 && console.log(
`${"\t".repeat(l - 1)}%c${n.name} -> %c${n.value}`,
n.value ? "" : "color:#777;",
n.value ? "color:#f55;" : "color:#777;",
);
for (let i in n.children) {
a(n.children[i], l + 1);
}
// @ts-ignore
l === 1 && console.groupEnd(g);
})(RedNode.root, 0);
}
}
复制代码
打印效果如下:
总结
本篇文章只是探讨红点的一种实现方式,微信真实的实现方式是不是基于树结构的不得而知。也不知道其他软件的红点设计是怎么样的结构(我就比较好奇能把红点玩出花的支付宝)。可能这篇文章的内容会比较乱,如果感兴趣的可以去 github 上查看代码,但是不建议直接用npm
安装,还是要根据自己的实际业务的需要去设计,毕竟本身代码就不多,也没有依赖库,直接复制过去就好。
写文章不易,难得的单休就花半天时间写文章了。如果这篇文章你觉得对你有用的话,欢迎点赞!