【一个开源,一位先生】深红老师的日语学习app

1,287 阅读12分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

引言

以求职的为目的的学习,是一个选择,但不一定适用于所有人,比如我,我试过这样做,不快乐,感觉太功利,虽然我赞同“利”的重要性,但一味的追求,就会有虚无感,所以我认为单单的图利地去学习,是不够的,也许有必要思考一个方式,能够不言利地去学习,然后利自来。

我觉得我可以在开源社区中找到答案。

文章概览

image.png

项目不错啊

在机缘巧合的情况下,我看到了这个开源项目,一个日语学习app,我觉得非常的不错。

并且有幸通过这个项目认识了作者:深红老师,学到了不少知识,荣幸之至。

我想把我这次学习的收获分享出来,希望能够对掘友们有帮助。

image.png iShot_2022-09-30_17.49.41.png

核心技术

Angluar?这我哪会

Vue和React我熟,而angluar我只是知道大名,一点都没接触过,这次算是我第一次摸angular的项目,有一说一,刚看的时候我是懵圈的,Angular是真有有难度的(至少对于我),但这里我们就不深入聊Angular入门了,聊聊我的初体验吧。

ng难,就连为啥叫ng我都不清楚

为啥叫ng呢,A打头的名字,不应该Ag么?后来有一个朋友帮我解惑了。

告诉我,ng的英语读音和angular很接近

指令,我知道Vue有,但指令到底啥呢

ng里的指令,我大体看了看,了解到了,一共分为了属性型指令,结构型指令,组件。 属性型指令

类型
属性型指令image.png
结构型指令image.png
组件em~~~,对,组件竟然也是,23333

这么一看,我大体有点感觉了,除了组件我有点不好理解以外,对于属性型指令我觉得就像在给一个节点赋予一些特点,结构性指令就好像在对节点进行结构性的编排,但组件呢? 组件居然也算指令,这么一想,我对指令的理解又有点小演化。

好像指令是一些能够在模版中使用的,具有一定封装作用的符号。

封装了啥?大体应该就是封装了一些与UI相关的业务。

em~~~,可以,先理解到这吧。

最让我迷惑的是NgModule

NgModule是那最打击我优越感的ng知识,二中之一。(另外一个大哥,你们猜是谁,23333

我本以为Vue和React都熟的自己,学ng应该也是手到擒来,但这个想法只在遇到NgModule之前存在。

模块化,ng居然自己搞了一套,为啥呢?不过在研究这问题之前,我们先看ng长啥样

@NgModule({
	imports: [
		BrowserModule,
		BrowserAnimationsModule,
		HttpClientModule,
		IonicModule.forRoot({
			mode: ConfigService.GET_THEME_BY_STATIC(),
		}),
		AppRoutingModule,
	],
	declarations: [AppComponent],
	providers: [
		{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
		{ provide: NZ_ICONS, useValue: icons },
		SharedService,
		InnerMqService,
		EffectManagementService,
	],
	bootstrap: [AppComponent],
})
export class AppModule {
	constructor(private effectManagementService: EffectManagementService) {}
}

简单说说我的理解,理解不对的可以批判我。

imports中放NgModule模块。 declarations中放Component组件。 providers中放Injectable服务(也就是service,我看放在这里的都是一些叫service的文件)

我产生了一些疑问,并获得了一些理解

  1. 组件只能在一个模块里引用?

    • 是的
  2. 那怎么让组件在多个地方使用?

    • 组件用模块包装,然后引入到其他模块里面
  3. 组件和模块有区分?

    • 组件就是组件,不能当做模块用,你得把它封装起来
    • 没有这个限制,可能模块化就乱了
  4. providers是干嘛的?

    • 依赖注入,晓得吧?简单说,配置到app.module.ts里的providers里的类,直接会成为所有组件实例的属性,省去了创建实例的过程
    • 在哪个模块providers中配置,就在哪个模块之下全局使用
  5. 什么样的类可以配置在providers里?

    • @Injectable()装饰的类
  6. @Injectable({ providedIn: 'root' })这是啥意思?

    • 等同于把类配置到app.module.ts里的providers
  7. exportsdeclarations咋回事?

    • declarations中配置了Component组件,才可以用这个组件指令。
    • 只有在模块中配置成为指令之后,才可以被放在exports中被导出。
    • exports严格约束了模块导出的内容,没导出,就无法被外界使用。
  8. imports干嘛的?

    • 比如引入一个被包成模块的组件的话,就可以使用这个组件指令了
  9. rxjs,这就是我学ng遇到另外的一位大哥,但这里我们先提一嘴,下面我们重点聊项目亮点“消息队列”会有提到,这里我们就简单说几个概念。

    • 简单说就是订阅,然后触发
    • Observable支持单个订阅
    • Subject支持多个订阅

仅仅是对ng管中窥豹了一下,我便有了一种非常强烈的感受,我想分享出来

做一个项目,如果你越做越大,那么慢慢就会意识到规矩的重要。

那么为了能够更好的贯彻规矩就需要设置约束,软一点的就是制定一套规范,共同遵守,硬一点的就是把规范做成实体。

ng就是后者,这样就可以让本就有规矩的人,从维护秩序这件事解放出来了,相对的,这会让一些自由散漫惯的人,举步维艰,一头雾水。

那么把React,Vue和Angular放在一起看一下,我有了一个很直观的感受

写React的感觉,就好似搞艺术的,发散性的思考,你可以在js的世界自由翱翔,奔放灵活。

写Angular的感觉,就好似搞建筑的,结构性的思考,犹如一位严厉的教师,处处指导你严谨讲究。

Vue就是中庸,博览众长,平易近人,既有react的灵活,也有angular的讲究,这何尝不是一种和的智慧。

Ionic?这是啥

我在接触深红老师项目之前,我都没听过Ionic,现在我知道了,是一个能够将web应用转成iosandroid的框架。就跟RNUniapp差不多

聊聊我在m1芯片上,搭建环境所遇到的问题链

1. 打包ios报错 image.png

  • 解决
    sudo chmod go-w /Users/johnlee/workSpace
    sudo chmod 775 /Users/johnlee
    

2. 下一个错误,报ffi的问题

/Library/Ruby/Gems/2.6.0/gems/ffi-1.15.5/lib/ffi/library.rb:275: [BUG
  • 解决
    sudo arch -x86_64 gem install ffi
    

然后成功运行!

image.png

一些花~~~样繁多,非常精彩的功能。

live2d

经过阅读源码,了解到:

  1. 实现的live2d的方式一共有两种:pixiwidiget
  2. widiget功能相比pixi更好,主要讲widiget
    • pixi不支持高分辨率缩放以及touch跟踪
  3. 小姐姐是需要动画资源的,而且关键的是没有雷姆,这个问题最为严重

live2d-widiget安装

官网

文档

动态安装,并执行init函数

	CommonUtil.loadScript('l2d-widget-min-js', 'assets/lib/live2d/2-widget-version/L2Dwidget.min.js').then((res) => {
		...
		this.init(type, name, bottom, callback);
		...
	});

init函数关键任务就是初始化L2Dwidget实例

	...
	L2Dwidget.on('*', (e: string) => {
		debugger
		if (e == 'create-container') {
			this.live2dDomJq = $('#live2d-widget');
			this.live2dDomJq.addClass('filter-brightness-able');
			this.live2dDomJq.css({ bottom: this.bottom + 'px' });
			this.live2dDomJq.css({ right: `-${Live2DConfig.data[type][name].width}px` }); // 开始时隐藏
		}
		callback(e);
	}).init({
		model: {
			jsonPath: `assets/lib/live2d/model/${type}/${name}/${name}.model.json` // 动画资源
		},
		mobile: {
			show: true,
			scale: 1
		},
		display: { // 显示设置
			position: 'right',
			scale: 1,
			width: Live2DConfig.data[type][name].width,
			height: Live2DConfig.data[type][name].height,
			hOffset: 0,
			vOffset: 0,
		},
		dialog: { // 动画是否存在动画框
			enable: Live2DConfig.data[type][name].script == null ? false : true,
			script: Live2DConfig.data[type][name].script,
		},
	});
	...

on是注册事件,然后链式调用init。 这里on监听了所有,当init执行之后,就会执行这个on注册的回调函数. 事件的回调函数主要做的就是dom操作,设置样式。

雷姆,没有雷姆,怎么行啊!

首先通过阅读源码,了解到小姐姐的动画资源是通过一定规则进行配置的。主要就是两点:资源和显示配置

添加资源 image.png

显示配置 image.png

然后雷姆就出现了

ezgif.com-gif-maker.gif

点击特效

ezgif.com-gif-maker (2).gif

经过阅读源码,实现的思路是:

  1. 事件绑定:绑定点击事件,通过点击创建一定数量球对象,并通过pushBalls存在this.balls
  2. requestAnimationFrame循环:使用的requestAnimationFrame函数,然后每帧都去循环执行一下loop
  3. loop逻辑:loop主要做的就是判断this.balls有没有数据,有数据就执行扩散动画的创建,并根据本根据长按的时长来动态设置扩散距离。

创建一个canvas

    this.canvas = document.createElement('canvas');
    this.canvas.setAttribute('class', 'filter-brightness-able');
    this.canvas.style.position = 'fixed';
    this.canvas.style.width = this.containerWidth + 'px';
    this.canvas.style.height = this.containerHeight + 'px';
    this.canvas.style.top = '0';
    this.canvas.style.left = '0';
    this.canvas.style.zIndex = EFFECT_CANVAS_ZINDEX;
    this.canvas.style.pointerEvents = 'none';
    this.canvas.width = this.containerWidth * StaticVar.DPR;
    this.canvas.height = this.containerHeight * StaticVar.DPR;
    document.body.appendChild(this.canvas);
    let ctx = this.canvas.getContext('2d');
    ctx && (this.ctx = ctx);

实现loop逻辑

    private loop(e: number) {
        if (e - this.frameTime >= 16) {
                this.frameTime = e;
                if (this.canvas != null && this.ctx != null) {
                        for (let i = 0; i < this.balls.length; i++) {
                                this.balls[i].update();
                        }
                        this.ctx.fillStyle = 'rgba(255, 255, 255, 0)';
                        this.ctx.clearRect(0, 0, this.containerWidth * StaticVar.DPR, this.containerHeight * StaticVar.DPR);
                        for (let i = 0; i < this.balls.length; i++) {
                                let b = this.balls[i];
                                if (b.r < 0) continue;
                                this.ctx.fillStyle = b.color;
                                this.ctx.beginPath();
                                this.ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2, false);
                                this.ctx.fill();
                        }
                        if (this.longPressed == true) {
                                this.multiplier += 0.2;
                        } else if (!this.longPressed && this.multiplier >= 0) {
                                this.multiplier -= 0.4;
                        }
                        this.removeBall();
                }
        }
        this.requestAnimationFrameFunc && this.requestAnimationFrameFunc((e: number) => {
                if (!this.isDestroyed) {
                        this.loop(e);
                }
        });
}

创建点击事件

    ...
    this.touchendListener = (e: TouchEvent) => {
        clearInterval(this.longPress);
        if (this.touchCacheEvent != null) {
            let x = this.touchCacheEvent.touches[0].clientX * StaticVar.DPR;
            let y = this.touchCacheEvent.touches[0].clientY * StaticVar.DPR;
            if (this.longPressed == true) {
                setTimeout(() => {
                    this.pushBalls(StaticVar.randBetween(50 + Math.ceil(this.multiplier), 100 + Math.ceil(this.multiplier)), x, y);
                    this.longPressed = false;
                });
            } else {
                setTimeout(() => {
                    this.pushBalls(StaticVar.randBetween(10, 20), x, y);
                });
            }
        }
    }
    ...
    this.touchendListener && window.addEventListener('touchend', this.touchendListener, false);

比划动画

ezgif.com-gif-maker (1).gif

经过阅读源码,实现的思路是:

  1. 使用的dmak这个库来实现的动画。
  2. 每个比划的动画是需要对应svg资源的。

安装Dmak

let dmak = await CommonUtil.loadScript('dmak-js', `assets/lib/dmak/dmak.min.js`);

CommonUtil.loadScript使用封装动态加载脚本的逻辑

    public static loadScript(id: string, url: string, param?: { type?: string }):           Promise<boolean> {
		return new Promise((resolve) => {
			if (document.getElementById(id) == null) {
				let script: HTMLScriptElement = document.createElement('script');
				script.type = 'text/javascript';
				script.id = id;
				script.src = url;
				if (param != null) {
					if (param.type != null) {
						script.type = param.type;
					}
				}
				document.head.appendChild(script);
				script.onload = () => {
					resolve(true);
				}
				script.onerror = () => {
					resolve(false);
				}
			} else {
				resolve(true);
			}
		});
	}

创建Dmak对象,传入svg资源作为uri参数

    this.hiraDmak = new Dmak(this.data[this.roma].hira, {
        'element': 'konnichiha-hira',
        'uri': `assets/data/word/50yin/stroke/hira/ping-${this.roma}-`
    });

调用api实现相应的功能

    hiraStroke(type: string): void {
		if (this.hiraDmak == null) {
			return;
		}
		switch (type) {
			case 'render':
				this.hiraDmak.render(); // 开始
				break;
			case 'pause':
				this.hiraDmak.pause(); // 停止
				break;
			case 'erase':
				this.hiraDmak.erase(); // 擦除
				break;
			default:
				break;
		}
	}

开发者工具

经过阅读源码,实现的思路是:

  1. 存在两个开发者工具,分别是:vconsoleeruda
  2. 安装是通过script标签
<script src="assets/lib/dev-tool/vconsole/3.14.6/vconsole.min.js"></script>

vconsole

image.png

安装

<script src="assets/lib/dev-tool/vconsole/3.14.6/vconsole.min.js"></script>

使用

	this.vConsole = new VConsole({
		defaultPlugins: ["log", "element", "network", "system", "storage"],
		theme: this.getTheme(this.config.v.dark_mode),
	});

eruda

image.png

安装

<script src="assets/lib/dev-tool/eruda/2.4.1/eruda.min.js"></script>

使用

	eruda.init();

fps

可以检测屏幕的fps帧数,em~~~可以监测画面卡不卡

this.addErudaFps();
...
private addErudaFps(): void {
	CommonUtil.loadScript('eruda-fps', `assets/lib/dev-tool/eruda/${LIB_ERUDA_VERSION}/eruda-fps.min.js`).then((res) => {
		if (res) {
			eruda.add(erudaFps);
		} else {
			this.addErudaFps();
		}
	})
}

内存

监测内存多少

this.addErudaMemory();
...
private addErudaMemory(): void {
	CommonUtil.loadScript('eruda-memory', `assets/lib/dev-tool/eruda/${LIB_ERUDA_VERSION}/eruda-memory.min.js`).then((res) => {
		if (res) {
			eruda.add(erudaMemory);
		} else {
			this.addErudaMemory();
		}
	})
}

消息队列么,有意思啊

深红老师作为一个全栈开发者,带着他对消息队列的理解,在这个项目中进行了尝试,实现了一个消息通信的模块,名为innerMqService,我对此非常感兴趣,故进行了深入学习。

不过再具体聊深红老师的具体实现之前,先说说我对前端通信的一些思考。

我对前端通信的一些思考

我先把我了解的通信模式摆一下,分别是:

  • 观察者
  • 发布订阅
  • 事件驱动

观察者

image.png

代码大概的样子

  • 创建观察者

    let ob1 = new Observer();
    
  • 创建消息源,并把观察者注册进来

    let sub = new Subject(); 
    sub.add(ob1);
    
  • 消息源发消息就行了,这样ob1就会收到消息

    sub.notify('I fired `SMS` event');
    

存在的问题

  • 耦合,这让消息源和观者者相互之间直接依赖了
  • 无中心管理,每个消息源自己管理对应的订阅者(观察者)

事件驱动

image.png

代码大概的样子

  • 监听事件
    window.addEventListener('testEvent', () => {
       console.log(123)
    })
    
  • 触发事件
    const event = new CustomEvent('test')
    window.dispatchEvent(event)
    

进步的地方

  • 解耦了
  • 灵活,可以根据自己的意愿监听一些事件。

存在的问题

  • 业务上没有中心管理,到处都有监听,那么混乱风险非常大。
  • 时机这块还得操心,如果触发事件的时机在该事件监听之前,那就会错过。

发布订阅

image.png

代码大概的样子

  • 创建中心
    let center = new Center();
    
  • 中心注册订阅的事件,事件通过自定一个字符进行标识,响应发布时,循环调用注册的订阅者。
    center.subscribe('TEST', (data)=>{ // 事件被触发,循环通知给注册的订阅者 });
    
  • 需要执行订阅的文件中获得中心管理,然后进行注册订阅者
    center.register('TEST',(data)=>{ // 订阅响应的逻辑}) 
    
  • 需要执行发布的文件中获得中心管理,然后进行发布
    center.publish('TEST', 'I published `TEST` event');
    

进步的地方

  • 解耦了。
  • 加入了中心管理,订阅者和发布者的关系被中心管理起来了。

存在的问题

  • 中心管理的负担太大,管理的内容太多,不够细
  • 不太灵活,想订阅的事件,是需要在中心服务那注册的。
  • 当页面卸载之后,中心需要从庞大的订阅管理数据中,剔除掉订阅者的相关信息,或存在页面卸载,但是订阅消息还存在的风险

那么经过以上分析,发现这三个模式都有一些缺点,也就发布订阅还算理想一点,那么怎么才能在发布订阅的基础上进步一下呢?当我阅读深红老师实现的消息通信模块,我发现了答案

既然中心太大,分裂一下呗

image.png

通过以上的图,我们能看出,相比发布订阅模式,发布者和中心管理这块没有变化,主要变化的是多了一个客户端的概念,而这个客户端便是整个模式的关键。

它分担了中心管理的责任,并且还具备一定的独立性,可以管理自身的连接和关闭。

代码大概的样子

  • 建立连接

    ngOnInit(): void {
        let mqClient = this.innerMqService.createConnect('Hatsuonn50yinPage');
    }
    
  • 订阅

    client.sub(TOPIC.CHANGE_DARK_MODE).subscribe((res) => {
        this.setDarkMode(res);
    });
    
  • 发布

    this.innerMqService.pub(TOPIC.CHANGE_DARK_MODE, value);
    
  • 断开连接

    ngOnDestroy(): void {
        this.innerMqService.destroyClient('Hatsuonn50yinPage');
    }
    

client的概念

把页面比作client设备,InnerMqService比作为服务器。

设备向服务器申请,用自己的id建立一个客户端。

服务器接收的topicmessage之后,在给订阅了这个topic的每个客户端发送消息。

如果,不设置这个概念,那么当页面写在,订阅的函数依然会存在,这样不合理了,所以就要一步的抽象出client。

那么也可以理解为:

每个页面都有一个client,service管理这些client,然后每个页面通过topic注册订阅,是注册在自己的client中,当触发的时候,比如触发一个topic为A,那么service就会遍历所有的client,然后出发client中topic为A的订阅

“消息队列”,光说消息了,那么队列呢?

当消息发布的时候,如果发现客户端没准备好,就会将消息放入队列中。

当客户端准备好时,再去监测是否存在未执行的消息队列,如果存在,就先执行。

/* 处理持久化消息 */
private processPersistentQueue(topic: TOPIC, subject: Subject<any>): void {
        let queue = this.persistentQueue.get(topic);
        if (queue == null) {
                return;
        }
        new Observable<boolean>((observer) => { // 异步发送已持久化的消息
                Promise.resolve().then(() => {
                        observer.next(true);
                })
        }).subscribe(() => {
                if (queue == null) {
                        return;
                }
                for (let i = 0; i < queue.length; i++) {
                        switch (queue[i].type) {
                                case PersistentType.ON_ONCE_SUB:
                                        subject.next(queue[i].data);
                                        queue.splice(i, 1); // 将使后面的元素依次前移,数组长度减1
                                        i--; // 如果不减,将漏掉一个元素
                                        break;
                                case PersistentType.ON_EVERY_CLIENT_EVERY_SUB:
                                        subject.next(queue[i].data);
                                        break;
                                default:
                                        break;
                        }
                }
                if (queue.length == 0) {
                        this.persistentQueue.delete(topic);
                }
        })
}

深红老师实现了一个可以持久化的消息队列,很好的解决了订阅和发布之间的是时机错过的问题,这样一来,你就不用担心发布发生在订阅之前了,更加的解耦了。

那么总结一下这个模式

进步的地方

  • 解耦,有中心管理
  • 灵活度提高,页面作为client进行管理订阅者,降低混乱的风险,有一定的自主权,可以自主订阅事件。
  • 有队列,不担心订阅和发布错过的时机问题
  • client客户端会随着生命周期维护自己的发布订阅体系,分担了中心管理的压力。

存在的问题

我还没发现,掘友们可以试试挖掘一下问题,这样也就有了进步的方向。

直接贴上完整代码,有兴趣的可以看看

import { Injectable } from '@angular/core';
import { Observable, Subject } from "rxjs";

@Injectable()
export class InnerMqService {

	private clients = new Map<string, InnerMqClient>(); // 客户端
	private persistentQueue = new Map<any, Array<{ type: PersistentType, data: any }>>(); // 持久化队列

	constructor() {
	}

	/* 建立连接 */
	public createConnect(clientId: string): InnerMqClient {
		let client = this.clients.get(clientId);
		if (client == null) {
			client = new InnerMqClientImpl({
				subscribe: (t, s) => {
					this.clientSubscribeCallback(t, s);
				}
			});
			this.clients.set(clientId, client);
		}
		return client;
	}

	/* 取消连接 */
	public destroyClient(clientId: string): void {
		let client = this.clients.get(clientId);
		if (client != null) {
			client.destroy();
		}
		this.clients.delete(clientId);
	}

	/* 发布 */
	public pub(topic: TOPIC, msg: any, opt?: { persistent: boolean, type: PersistentType }): void {
		let published = false;
		for (let client of this.clients.values()) {
			published = client.pub(topic, msg);
		}
		// 消息未发送,进行持久化存储
		if (published == false && (opt && opt.persistent)) {
			if (this.persistentQueue.get(topic) == null) {
				this.persistentQueue.set(topic, []);
			}
			this.persistentQueue.get(topic)?.push({ type: opt.type, data: msg });
		}
	}

	/* 客户端订阅回调 */
	private clientSubscribeCallback(topic: TOPIC, subject: Subject<any>): void {
		this.processPersistentQueue(topic, subject);
	}

	/* 处理持久化消息 */
	private processPersistentQueue(topic: TOPIC, subject: Subject<any>): void {
		let queue = this.persistentQueue.get(topic);
		if (queue == null) {
			return;
		}
		new Observable<boolean>((observer) => { // 异步发送已持久化的消息
			Promise.resolve().then(() => {
				observer.next(true);
			})
		}).subscribe(() => {
			if (queue == null) {
				return;
			}
			for (let i = 0; i < queue.length; i++) {
				switch (queue[i].type) {
					case PersistentType.ON_ONCE_SUB:
						subject.next(queue[i].data);
						queue.splice(i, 1); // 将使后面的元素依次前移,数组长度减1
						i--; // 如果不减,将漏掉一个元素
						break;
					case PersistentType.ON_EVERY_CLIENT_EVERY_SUB:
						subject.next(queue[i].data);
						break;
					default:
						break;
				}
			}
			if (queue.length == 0) {
				this.persistentQueue.delete(topic);
			}
		})
	}

}

export interface InnerMqClient {

	sub(topic: TOPIC): Observable<any>;

	pub(topic: TOPIC, msg: any): boolean;

	destroy(): void;

}

class InnerMqClientImpl implements InnerMqClient {

	private subjects = new Map<TOPIC, Subject<any>>(); // 实例
	private destroyed: boolean = false;

	constructor(
		private callback: {
			subscribe: (topic: TOPIC, subject: Subject<any>) => void
		}
	) {
	}

	/* 订阅 */
	public sub(topic: TOPIC): Observable<any> {
		let subject = this.subjects.get(topic);
		if (subject == null) {
			subject = new Subject<any>();
			this.subjects.set(topic, subject);
		}
		this.callback.subscribe(topic, subject);
		return subject.asObservable();
	}

	/* 发布 */
	public pub(topic: TOPIC, msg: any): boolean {
		if (this.destroyed) {
			return false;
		}
		let subject = this.subjects.get(topic);
		if (subject != null && !subject.closed) {
			subject.next(msg);
			return true;
		} else {
			return false;
		}
	}

	/* 销毁 */
	public destroy(): void {
		this.destroyed = true;
		for (let subject of this.subjects.values()) {
			subject.unsubscribe();
		}
		this.subjects.clear();
	}

}

export enum TOPIC {
	APP_PAGE_LOADED,
	INDEX_PAGE_LOADED,
	SWITCH_TO_PHONE,
	SWITCH_TO_DESKTOP,
	HIDE_SPLASH_SCREEN,
	MAIN_FRAME_BODY_RESIZE,
	INDEX_MODAL_OPEN,
	INDEX_MODAL_CLOSE,
	INDEX_TAB_OPEN,
	INDEX_TAB_CLOSE,
	CHANGE_DARK_MODE,
	CHANGE_STATUS_BAR,
	CHANGE_ZOOM,
	CHANGE_BACKGROUND_IMAGE,
	CHANGE_CLICK_EFFECT,
	CHANGE_MOUSEMOVE_EFFECT,
	CHANGE_TAB_EFFECT,
	CHANGE_DEV_TOOL,
	CHANGE_MUSIC_SWIPER_EFFECT,
	INDEX_FRAME_SHOW_HIDE,
	QUICK_PLAYER_PLAY,
}

export enum PersistentType {
	ON_ONCE_SUB, // 只进行一次缓存,一次sub后即删除
	ON_EVERY_CLIENT_EVERY_SUB, // 持久化,对每个客户端的每一次该TOPIC的sub都发送信息
}

题外话

如果把人比作一个会发光的球体,那么核心便是心灵,当心灵有了念头即梦想,那么核心便会发光,这个光叫做热爱,现在的我还发不出什么光,但我可以努力地做到晶莹剔透,把我遇到的光从我这里折射出去,当越来越多的光穿越过我的时候,我也在被光温暖,充能,假以时日可能我也会发光,而这就是我想要的未来,我想用爱来照亮。