本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
引言
以求职的为目的的学习,是一个选择,但不一定适用于所有人,比如我,我试过这样做,不快乐,感觉太功利,虽然我赞同“利”的重要性,但一味的追求,就会有虚无感,所以我认为单单的图利地去学习,是不够的,也许有必要思考一个方式,能够不言利地去学习,然后利自来。
我觉得我可以在开源社区中找到答案。
文章概览
项目不错啊
在机缘巧合的情况下,我看到了这个开源项目,一个日语学习app,我觉得非常的不错。
并且有幸通过这个项目认识了作者:深红老师,学到了不少知识,荣幸之至。
我想把我这次学习的收获分享出来,希望能够对掘友们有帮助。
核心技术
Angluar?这我哪会
Vue和React我熟,而angluar我只是知道大名,一点都没接触过,这次算是我第一次摸angular的项目,有一说一,刚看的时候我是懵圈的,Angular是真有有难度的(至少对于我),但这里我们就不深入聊Angular入门了,聊聊我的初体验吧。
ng难,就连为啥叫ng我都不清楚
为啥叫ng呢,A打头的名字,不应该Ag么?后来有一个朋友帮我解惑了。
告诉我,ng的英语读音和angular很接近
指令,我知道Vue有,但指令到底啥呢
ng里的指令,我大体看了看,了解到了,一共分为了属性型指令,结构型指令,组件。 属性型指令
类型 | |
---|---|
属性型指令 | |
结构型指令 | |
组件 | 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的文件)
我产生了一些疑问,并获得了一些理解
-
组件只能在一个模块里引用?
- 是的
-
那怎么让组件在多个地方使用?
- 组件用模块包装,然后引入到其他模块里面
-
组件和模块有区分?
- 组件就是组件,不能当做模块用,你得把它封装起来
- 没有这个限制,可能模块化就乱了
-
providers
是干嘛的?- 依赖注入,晓得吧?简单说,配置到
app.module.ts
里的providers
里的类,直接会成为所有组件实例的属性,省去了创建实例的过程 - 在哪个模块providers中配置,就在哪个模块之下全局使用
- 依赖注入,晓得吧?简单说,配置到
-
什么样的类可以配置在
providers
里?- 被
@Injectable()
装饰的类
- 被
-
@Injectable({ providedIn: 'root' })
这是啥意思?- 等同于把类配置到
app.module.ts
里的providers
里
- 等同于把类配置到
-
exports
和declarations
咋回事?- 在
declarations
中配置了Component
组件,才可以用这个组件指令。 - 只有在模块中配置成为指令之后,才可以被放在
exports
中被导出。 exports
严格约束了模块导出的内容,没导出,就无法被外界使用。
- 在
-
imports
干嘛的?- 比如引入一个被包成模块的组件的话,就可以使用这个组件指令了
-
rxjs
,这就是我学ng
遇到另外的一位大哥,但这里我们先提一嘴,下面我们重点聊项目亮点“消息队列”会有提到,这里我们就简单说几个概念。- 简单说就是订阅,然后触发
- Observable支持单个订阅
- Subject支持多个订阅
仅仅是对ng管中窥豹了一下,我便有了一种非常强烈的感受,我想分享出来
做一个项目,如果你越做越大,那么慢慢就会意识到规矩的重要。
那么为了能够更好的贯彻规矩就需要设置约束,软一点的就是制定一套规范,共同遵守,硬一点的就是把规范做成实体。
ng就是后者,这样就可以让本就有规矩的人,从维护秩序这件事解放出来了,相对的,这会让一些自由散漫惯的人,举步维艰,一头雾水。
那么把React,Vue和Angular放在一起看一下,我有了一个很直观的感受
写React的感觉,就好似搞艺术的,发散性的思考,你可以在js的世界自由翱翔,奔放灵活。
写Angular的感觉,就好似搞建筑的,结构性的思考,犹如一位严厉的教师,处处指导你严谨讲究。
Vue就是中庸,博览众长,平易近人,既有react的灵活,也有angular的讲究,这何尝不是一种和的智慧。
Ionic?这是啥
我在接触深红老师项目之前,我都没听过Ionic,现在我知道了,是一个能够将web应用转成ios和android的框架。就跟RN和Uniapp差不多
聊聊我在m1芯片上,搭建环境所遇到的问题链
1. 打包ios报错
- 解决
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
然后成功运行!
一些花~~~样繁多,非常精彩的功能。
live2d
经过阅读源码,了解到:
- 实现的
live2d
的方式一共有两种:pixi
和widiget
widiget
功能相比pixi
更好,主要讲widiget
pixi
不支持高分辨率缩放以及touch
跟踪
- 小姐姐是需要动画资源的,而且关键的是没有雷姆,这个问题最为严重
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操作,设置样式。
雷姆,没有雷姆,怎么行啊!
首先通过阅读源码,了解到小姐姐的动画资源是通过一定规则进行配置的。主要就是两点:资源和显示配置
添加资源
显示配置
然后雷姆就出现了
点击特效
经过阅读源码,实现的思路是:
- 事件绑定:绑定点击事件,通过点击创建一定数量球对象,并通过
pushBalls
存在this.balls
里 - requestAnimationFrame循环:使用的requestAnimationFrame函数,然后每帧都去循环执行一下loop
- 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);
比划动画
经过阅读源码,实现的思路是:
- 使用的dmak这个库来实现的动画。
- 每个比划的动画是需要对应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;
}
}
开发者工具
经过阅读源码,实现的思路是:
- 存在两个开发者工具,分别是:
vconsole
和eruda
- 安装是通过
script
标签
<script src="assets/lib/dev-tool/vconsole/3.14.6/vconsole.min.js"></script>
vconsole
安装
<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
安装
<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
,我对此非常感兴趣,故进行了深入学习。
不过再具体聊深红老师的具体实现之前,先说说我对前端通信的一些思考。
我对前端通信的一些思考
我先把我了解的通信模式摆一下,分别是:
- 观察者
- 发布订阅
- 事件驱动
观察者
代码大概的样子
-
创建观察者
let ob1 = new Observer();
-
创建消息源,并把观察者注册进来
let sub = new Subject(); sub.add(ob1);
-
消息源发消息就行了,这样
ob1
就会收到消息sub.notify('I fired `SMS` event');
存在的问题
- 耦合,这让消息源和观者者相互之间直接依赖了
- 无中心管理,每个消息源自己管理对应的订阅者(观察者)
事件驱动
代码大概的样子
- 监听事件
window.addEventListener('testEvent', () => { console.log(123) })
- 触发事件
const event = new CustomEvent('test') window.dispatchEvent(event)
进步的地方
- 解耦了
- 灵活,可以根据自己的意愿监听一些事件。
存在的问题
- 业务上没有中心管理,到处都有监听,那么混乱风险非常大。
- 时机这块还得操心,如果触发事件的时机在该事件监听之前,那就会错过。
发布订阅
代码大概的样子
- 创建中心
let center = new Center();
- 中心注册订阅的事件,事件通过自定一个字符进行标识,响应发布时,循环调用注册的订阅者。
center.subscribe('TEST', (data)=>{ // 事件被触发,循环通知给注册的订阅者 });
- 需要执行订阅的文件中获得中心管理,然后进行注册订阅者
center.register('TEST',(data)=>{ // 订阅响应的逻辑})
- 需要执行发布的文件中获得中心管理,然后进行发布
center.publish('TEST', 'I published `TEST` event');
进步的地方
- 解耦了。
- 加入了中心管理,订阅者和发布者的关系被中心管理起来了。
存在的问题
- 中心管理的负担太大,管理的内容太多,不够细
- 不太灵活,想订阅的事件,是需要在中心服务那注册的。
- 当页面卸载之后,中心需要从庞大的订阅管理数据中,剔除掉订阅者的相关信息,或存在页面卸载,但是订阅消息还存在的风险
那么经过以上分析,发现这三个模式都有一些缺点,也就发布订阅还算理想一点,那么怎么才能在发布订阅的基础上进步一下呢?当我阅读深红老师实现的消息通信模块,我发现了答案
既然中心太大,分裂一下呗
通过以上的图,我们能看出,相比发布订阅模式,发布者和中心管理这块没有变化,主要变化的是多了一个客户端的概念,而这个客户端便是整个模式的关键。
它分担了中心管理的责任,并且还具备一定的独立性,可以管理自身的连接和关闭。
代码大概的样子
-
建立连接
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建立一个客户端。
服务器接收的topic和message之后,在给订阅了这个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都发送信息
}
题外话
如果把人比作一个会发光的球体,那么核心便是心灵,当心灵有了念头即梦想,那么核心便会发光,这个光叫做热爱,现在的我还发不出什么光,但我可以努力地做到晶莹剔透,把我遇到的光从我这里折射出去,当越来越多的光穿越过我的时候,我也在被光温暖,充能,假以时日可能我也会发光,而这就是我想要的未来,我想用爱来照亮。