Hi,大家好。又是我yxchan🧑💻。不知道大家都没有发现,很多 App/Web 变得越来越「游戏化」了。我觉得主要的原因是,游戏的体验感、成就感等等能起到比较好的「拉新」、「留存」等作用。导致现在的需求也越来越「游戏化」了。
对于游戏开发,有很多成熟的框架可以使用了,好比如 Cocos
、Egret
、Laya
等等...
这种框架都非常完善(大且复杂),能够实现非常多的游戏能力。
但是,对于前端而言,大多数需求可能仅仅是一些不太复杂的交互,配合一些炫酷的动画,再加上一些「游戏榜单」之类的,并不算是「纯」游戏。前端更需要的是一款「轻量级」的游戏引擎,用于负责实现一些基本「交互」以及「动画渲染」,其他部分则可以由传统的 html
、 css
来实现。
相信很多人在 「11.11」 的时候,有玩过淘宝的「喵🐱糖」抢红包🧧游戏。该页面用的游戏引擎是阿里已经开源了的 Eva.js
。(官方:阿里系有30多个项目使用了该引擎,覆盖用户超5亿),所以稳定性应该有保障(吧?)。
Eva.js
有三个优点:
- 简单:官方提供了大量开箱即用的游戏组件;
- 高性能:基于号称「2D 渲染界扛把子」的
PixiJS
; - 可拓展:使用
ECS
(在Eva.js
中对应的是GameObject
/Component
/System
) 架构,能使用「可高度定制的 Api 」进行拓展;
俗话说,「活到老学到老」。接下来,一起来一场 Eva.js
之旅吧。
资源管理
大多数游戏,在进入游戏前,都会先呈现 loading
页,待游戏的「所有资源加载完毕」后,才真正进入游戏。这样做能避免在游戏过程中出现资源加载速度「慢/失败」,提高游戏流畅性。
在 Eva.js
中,使用 resource
进行资源的统一管理。这样做的好处有两点:
- 统一资源入口管理;
- 在
Eva.js
加载资源时,资源管理器可以对资源进行「预处理」,减少运行时处理资源产生卡顿等问题;
目前,官方支持的 RESOURCE_TYPE
有以下七种:
type | value | description |
---|---|---|
AUDIO | 'AUDIO' | 音频 |
VIDEO | 'VIDEO' | 视频 |
IMAGE | 'IMAGE' | 图片 |
SPINE | 'SPINE' | 骨骼动画 |
SPRITE | 'SPRITE' | 雪碧图 |
SPRITE_ANIMATION | 'SPRITE_ANIMATION' | 帧图 |
DRAGONBONE | 'DRAGONBONE' | 龙骨动画 |
同时,也还支持(不在RESOURCE_TYPE
中,但实际还是支持):
value | description |
---|---|
'LOTTIE' | Lottie动画 |
import { RESOURCE_TYPE, LOAD_EVENT, resource } from "@eva/eva.js";
const source = [
{
name: "bird", // 鸟
type: RESOURCE_TYPE.IMAGE,
src: {
image: {
type: "png",
url: "./static/bird.png",
},
},
preload: true,
},
]
// 加载资源
resource.addResource(source);
// 加载进度更新
resource.on(LOAD_EVENT.PROGRESS, (value) => {
console.log("加载进度", value.progress * 100 + "%");
// 更新进度条...
});
// 资源加载完成
resource.on(LOAD_EVENT.COMPLETE, () => {
console.log("资源加载完成!");
// 创建游戏...
});
这样,就完成了资源的预加载。接着就可以创建游戏啦。
创建游戏
Game
是「游戏对象」(官网这句话写的很容易误导人,这个Game
不是GameObject
)。
import { Game } from '@eva/eva.js'
// 初始化游戏
const game = new Game({
frameRate: 60, // 可选
autoStart: true, // 可选
})
这样,就完成了Game
的实例化。但此时 game
还不具备任何能力。
开始游戏
game.start()
在实例化过程中,如果 autoStart
设置为 false
,则需要手动调用上述方法。
暂停游戏
game.pause()
官方建议在应用退出到后台时暂停游戏,返回后再开始。
修改游戏播放速度
game.ticker.setPlaybackRate(1.5) // 1.5倍速播放
创建系统
「系统」的作用是赋予游戏「能力」。通过添加不同的「系统」,来赋予游戏不同的「能力」。
import { RendererSystem } from '@eva/plugin-renderer'
// 创建渲染系统
const rendererSystem = new RendererSystem({
canvas: document.querySelector('#canvas'), // 可选,自动生成canvas 挂在game.canvas上
width: 750, // 必填
height: 1000, // 必填
transparent: false, // 可选
resolution: window.devicePixelRatio / 2, // 可选, 如果是2倍图设计 可以除以 2
enableScroll: true, // 允许页面滚动
renderType: 0 // 0:自动判断,1: WebGL,2:Canvas,建议android6.1 ios9 以下使用Canvas,需业务判断。
})
上述代码中,创建了 RendererSystem
,作用是将「游戏实例」挂载到指定的 canvas
上,并设置相应的参数。但该「系统」还没发挥作用,还需要把其添加到 game
上。
添加系统
添加「系统」有两种方式:
- 创建
game
时添加:
const game = new Game({
...
systems: [
...,
new ImgSystem()
]
})
- 通过
addSystem
添加:
const game = new Game({...})
game.addSystem(ImgSystem)
至此,一个具备「能力」的游戏已经创建完成。
多场景
如果游戏需要进行「场景」切换,可以使用 loadScene
。
import { Scene, LOAD_SCENE_MODE } from '@eva/eva.js';
const scene = new Scene('bg');
game.loadScene({
scene,
mode: LOAD_SCENE_MODE.SINGLE
})
默认是渲染到一个 canvas
上,但也可以将「场景」渲染到其他指定的canvas
上。
game.loadScene({
// ...
params: {
canvas: document.querySelector('#canvas-other'), //可选,自动生成canvas
// ...
}
})
创建游戏对象
在 Eva.js
的世界中,「游戏对象(GameObject
)」可以是一棵 🌲,一个 🏀,一只🐔......万物皆 GameObject
。它是游戏中的「物体」,但只是无意义的「空壳」。
通过对 GameObject
添加「组件」来把这个「空壳」具体化。每一个 GameObject
都自带一个不可删除的 Transform
组件,用于定义该 GameObject
的「大小」、「位置」等基础属性。
import { GameObject } from '@eva/eva.js'
const bird = new GameObject('bird', {
size: { width: 100, height: 100 },
position: { x: 50, y: 50 }
})
上述代码,创建了一个名为 bird
的 GameObject
。
对于 Transform
,可以在创建 GameObject
时,一同定义。
创建组件
这只 bird
需要显示一张 🐦 的图片,可以创建 Img
组件:
import { Img } from '@eva/plugin-renderer-img'
const birdImg = new Img({
resource: 'bird'
})
⚠️:别忘了,需要在 game
上添加相关的 ImgSystem
,让 game
具备渲染图片的能力。
import { Game } from '@eva/eva.js'
import { ImgSystem } from '@eva/plugin-renderer-img'
const game = new Game({
// ...
systems: [
// ...
new ImgSystem()
]
})
添加组件
接着,要把创建好的 birdImg
添加进 bird
。
bird.addComponent(birdImg)
添加游戏对象
bird
创建完后,添加进 game
。
game.scene.addChild(bird)
至此,一只「鸟」已经被添加到游戏里了。
脚本组件
在日常业务开发中,需要自定义一些业务逻辑,每个 GameObject
可能需要「单独的逻辑」或者「数据驱动器」。这时,可以通过「脚本组件(Component
)」的形式进行业务开发。
「脚本组件」是一个继承 Component
的「类」:
import { Component } from '@eva/eva.js'
class BirdMove extends Component {
static componentName = 'birdMove' // 组件名字
speed = {
// 设置属性
// 移动速度
x: 100,
y: 200
}
}
把创建好的 BirdMove
添加进 bird
中:
bird.addComponent(new BirdMove({x: 150, y: 150}))
生命周期
每一个被添加进 GameObject
的 Component
都会触发 ⬇️ 生命周期:
class BirdMove extends Component {
//...
// 初始化
init(params: MoveParams) {
this.speed = params.speed || { x: 0, y: 0 }
}
// ...
}
数据管理
在使用 Vue
/React
时,经常会使用 VueX
/Redux
这类数据管理工具。同样,在 Eva.js
中,官方也提供了一套 EvaX
来进行数据管理。
创建store
store
是一个纯粹的「对象」:
const store = {
score: 0, // 得分
}
初始化
添加 EvaXSystem
,并进行初始化:
import { EvaXSystem } from '@eva/plugin-evax'
const game = new Game({
// ...
systems: [
// ...
new EvaXSystem({
store // 这里将定义的 store 传入
})
]
})
监听变化
通过在 bird
上添加 EvaX
组件,来实现对数据的监听:
import { EvaX } from '@eva/plugin-evax'
bird.addComponent(
new EvaX({
events: {
// score变化时触发
'store.score'(store, oldStore) {},
}
})
)
更新数据
没有烦人的 action
、dispatch
等,「直接改」也能触发事件监听!
store.score += 1
// 或者
evaxSystem.store.score += 1
更新所有
evaxSystem.emit('evax.updateStore', newStore)
全覆盖模式更新,对比内容变化,变化的内容才会触发更新。
强制更新所有
evaxSystem.emit('evax.forceUpdateStore', newStore)
全覆盖模式更新,不对比内容变化,全部触发更新。
触发事件
在「监听变化」时,不仅可以监听「数据」的变化,还能监听「事件」:
import { EvaX } from '@eva/plugin-evax'
bird.addComponent(
new EvaX({
events: {
// 自定义事件
fail(arg) {
console.log(arg) // 0
}
}
})
)
触发相应 fail
事件:
evaxSystem.emit('fail', 0)
以上,是Eva.js
的最核心部分。
接下来,是「实战」部分。利用从上面的知识,开发一个之前很🔥的 flappy Bird
!
Flappy Bird
游戏内容很简单,点击 Tap
、控制「小鸟」、计算「得分」、碰到物体就「结束」。
项目地址:flappy-bird
Store
第一步,先考虑 store
的结构。store
里需要存放「游戏状态」、「游戏得分」。
(为了方便全局调用,把实例化后的Game
放进 store.game
)
flappy-bird/src/store.ts
export default {
game: null, // 游戏
status: "ready", // 游戏状态
score: 0, // 成绩
}
游戏对象
根据游戏原型,把其拆分为各种 GameObject
。
总体拆分为 6 部分:
- 背景
- 小鸟
- 准备标题
- 得分
- 结束标题
- 管道
背景
背景分两部分:
大背景:
草地:
「大背景」包含「草地」。因为在游戏「开始」后,远处的「大背景」和近处的「草地」会以不同的速度滚动,所以,可以用「平铺精灵」,实现无限背景。
flappy-bird/src/gameObjects/background.ts
export default () => {
// 背景
const bg = new GameObject("bg", {
// ...
});
bg.addComponent(
new TilingSprite({
resource: "bg",
// ...
})
);
bg.addComponent(new BackgroundMove(0.3));
// 草地背景
const ground = new GameObject("ground", {
// ...
});
ground.addComponent(
new TilingSprite({
resource: "ground",
// ...
})
);
// 添加物理引擎
ground.addComponent(
new Physics({
type: PhysicsType.RECTANGLE,
bodyOptions: {
isStatic: true,
},
})
);
ground.addComponent(new BackgroundMove(1));
// 把草地添加进背景
bg.addChild(ground);
// 把背景添加到game中
store.game.scene.addChild(bg);
return { bg };
};
- 首先创建了名为
bg
的GameObject
,然后添加TilingSprite
组件,引用了名为bg
的静态资源; - 在
bg
中添加了BackgroundMove
(在后续「脚本组件」中会讲到); - 接着创建了名为
ground
的GameObject
,也是添加了TilingSprite
组件,引用了名为ground
的静态资源; - 同理,在
ground
中添加BackgroundMove
; - 因为游戏中,「小鸟」碰到「草地」也算游戏结束,所以要在
ground
中添加Physics
; - 接着把
ground
添加进bg
中; - 最后把
bg
添加进game
中;
效果:
小鸟
同理,「小鸟」也和「背景」差不多。但因为「小鸟」有个「飞翔」的动作,所以用的是「帧图」。
flappy-bird/src/gameObjects/bird.ts
export default () => {
const bird = new GameObject("bird", {
// ...
});
bird.addComponent(
new SpriteAnimation({
resource: "bird",
speed: 100,
})
);
store.game.scene.addChild(bird);
return { bird };
};
- 创建名为
bird
的GameObject
,然后添加了SpriteAnimation
组件,引用了名为bird
的静态资源; - 把
bird
添加到game
中;
效果:
准备标题
「准备标题」用的是「雪碧图」,创建过程也是大同小异,就不再赘述了。
有个关键点,就是点击 Tap
游戏才算真正开始。因此要在 Tap
这个 GameObject
上,监听「点击」事件,然后「启动」游戏。
flappy-bird/src/gameObjects/ready.ts
// ...
const evt = tap.addComponent(new Event());
evt.on("tap", () => {
store.game.emit("start");
});
// ...
- 在
tap
上添加了Event
组件; - 监听
tap
的点击事件;在被点击后,向game
抛出start
事件;
效果:
得分
因为游戏的「得分」会随着游戏的进行,而不断变化。所以,选择由 Text
组件来实现。
flappy-bird/src/gameObjects/score.ts
export default () => {
const score = new GameObject("overScore", {
// ...
});
score.addComponent(
new Text({
text: "得分: 0",
style: {
// ...
},
})
);
score.addComponent(new UpdateScore());
score.addComponent(
new EvaX({
events: {
"store.score"(store, oldStore) {
score.getComponent(UpdateScore).updateScore(store);
},
},
})
);
store.game.scene.addChild(score);
return { score };
};
- 在
score
上添加Text
组件; - 在
score
上添加UpdateScore
「脚本组件」; - 在
score
上添加EvaX
组件,并且监听score
,当score
改变时,调用UpdateScore
上的updateScore
方法,更新「得分」;
效果:
结束标题
当游戏进行中时,「小鸟」碰到任何东西,游戏就会「结束」,然后显示 Game Over
;
具体实现和「准备标题」差不多,唯一的区别就是,在点击后,向 game
抛出 ready
事件。
flappy-bird/src/gameObjects/over.ts
const evt = play.addComponent(new Event());
evt.on("tap", () => {
store.game.emit("ready");
});
管道
游戏开始后,会有源源不断「成对且上下对齐但长度不一」的管道从右边开始向左滑动,直至从屏幕左边消失。所以,当游戏一开始时,创建的「管道」的位置,应该是在「可见屏幕」的右侧。
实现思路:
- 实现
createBar
函数,负责根据「入参」创建初始不同位置的「管道」; - 实现
createBars
函数,该函数生成上下管道的「随机长度」以及初始「位置」,然后调用createBar
创建上下两个管道,并且确保上下两管道的x
轴要对齐;
flappy-bird/src/gameObjects/bars.ts
createBar
:
const createBar = (distance, x, y, cWidth, cHeigt) => {
const bar = new GameObject("bar", {
size: { width: cWidth, height: cHeigt },
position: {
x: x,
y: y,
},
// ...
});
bar.addComponent(
new Sprite({
resource: "bar",
spriteName: distance,
})
);
let physics = bar.addComponent(
new Physics({
type: PhysicsType.RECTANGLE,
bodyOptions: {
isStatic: true,
// ...
},
})
);
// 管道移动
bar.addComponent(new BarMove());
// 创建新管道
bar.addComponent(new BarNext());
return { bar, physics }
};
createBars
:
export default () => {
const gapTop = randomNum(100, 300); // 随机长度
const gapBottom = randomNum(100, 300);
const topH = 800 - gapTop;
const bottomH = 800 - gapBottom;
const top = createBar("bar_r.png", 815, topH / 4, 130, topH);
const bottom = createBar("bar.png", 815, 960 - bottomH / 4, 130, bottomH);
store.game.scene.addChild(top.bar);
store.game.scene.addChild(bottom.bar);
return { top, bottom };
};
脚本组件
所有的 GameObject
都被创建后,代表游戏的「静态」部分已经实现完成了。接下来就是实现 Component
,让 GameObject
能够「动起来」。
backgroundMove
当 Tap
被点击后,游戏状态就从 ready
转换 为 playing
。此时,背景就会动起来。
因为「背景」用的是「平铺精灵」,所以,只需要在 store.status
=== 'playing'
时,不断地改变其 x
轴位置,就可以实现这样的效果。
flappy-bird/src/component/backgroundMove.ts
class BackgroundMove extends Component {
gameObject: GameObject;
tilePositionX: number;
static componentName: "BackgroundMove";
// 初始化调用
init(tilePositionX: number) {
this.tilePositionX = tilePositionX;
}
// 每一帧调用
update(): void {
if (store.status === "playing") {
this.gameObject.getComponent(TilingSprite).tilePosition.x -=
this.tilePositionX;
}
}
}
updateScore
在创建「得分」时,使用 EvaX
对 score
进行了监听,然后调用该组件的 updateScoce
方法。所以,这个 Component
的目的只有一个,就是实现 updateScore
,在该函数内部,把 Score
里的 Text
里的 text
修改为 最新的 score
。
flappy-bird/src/component/updateScore.ts
class UpdateScore extends Component {
gameObject: GameObject;
static componentName: "UpdateScore";
updateScore(score) {
this.gameObject.getComponent(Text).text = "得分: " + score;
}
}
BarMove
游戏开始后,管道从「右」向「左」匀速滚动。在创建「管道」时,为了实现「小鸟」与「管道」的「碰撞检测」,而添加了「物理引擎」。利用「物理引擎」提供的能力,可以在 store.status
=== 'playing'
时,每一帧都为「管道」添加一个 x
轴的「负向量」,这样就能实现滚动的效果。
flappy-bird/src/component/barMove.ts
class BarMove extends Component {
gameObject: GameObject;
static componentName = "barMove";
update() {
const physics = this.gameObject.getComponent(Physics);
if (store.game && physics.body) {
if (store.status === "playing") {
let pushVec = Matter.Vector.create(-5, 0);
Matter.Body.translate(physics.body, pushVec);
}
}
}
}
BarNext
当「管道」移动到某个位置时,就会有新的「管道」出现,当「小鸟」穿越「管道」时,「得分」 + 1。当「管道」溢出到「可视屏幕」时,就销毁。
⚠️:因为上下「管道」的 x
轴位置是「同步」的,所以只需要执行一次「计算得分」和「创建管道」时。
flappy-bird/src/component/barNext.ts
export default class BarNext extends Component {
gameObject: GameObject;
static componentName = "barNext";
update() {
const physics = this.gameObject.getComponent(Physics);
if (store.status === "playing") {
if (physics.body) {
let x = physics.body.position.x;
const only =
this.gameObject.getComponent(Sprite).spriteName === "bar.png";
if (x == -130) {
this.gameObject.getComponent(Physics).removeAllListeners();
store.game.scene.removeChild(this.gameObject);
this.gameObject.destroy();
return;
}
if (x == 60) {
if (only) {
store.score += 1;
}
}
if (x == 500) {
if (only) {
createBar();
}
}
}
}
}
}
监听
现在,「万事俱备,只欠东风」。只要对「准备标题」和「结束标题」抛出的 start
和 ready
事件进行监听,然后修改游戏「状态」,再「添加」 or 「删除」对应的 GameObject
就可以了!
flappy-bird/src/index.ts
start
监听:
game.on("start", () => {
if (store.status !== "ready") return;
store.status = "playing";
// 移除准备按钮
game.scene.removeChild(title);
game.scene.removeChild(taps);
// 创建管道
createBar();
// bird添加物理引擎
const birdPhysics = bird.addComponent(
new Physics({
type: PhysicsType.RECTANGLE,
// ...
})
);
// 监听game的tap事件;
const evt = game.scene.addComponent(new Event());
evt.on("tap", () => {
if (birdPhysics) {
try {
birdPhysics.body.force.y = -0.3;
} catch (err) {}
}
});
// 碰撞检测
birdPhysics.on("collisionStart", () => {
store.status = "over";
// 创建结束标题
createOver();
// 移除bird物理引擎
bird.removeComponent(birdPhysics);
// 移除game tap 事件
game.scene.removeComponent(evt);
});
});
- 修改游戏状态为
playing
; - 移除「准备标题」;
- 创建「管道」;
- 为「小鸟」添加「物理引擎」;
- 监听
game
的tap
事件,每tap
一下,则给予「小鸟」一个y
轴向上的力; - 监听「碰撞检测」,若发生「碰撞」,则修改游戏状态为
over
,创建「结束标题」,移除「小鸟」的「物理引擎」以防止继续「下坠」,移除game
的tap
监听;
ready
监听:
game.on("ready", () => {
store.status = "ready";
store.score = 0;
// 移除结束标题按钮
const overBox = game.scene.gameObjects.find(
(item) => item.name === "overBox"
);
game.scene.removeChild(overBox);
// 新增准备按钮
game.scene.addChild(title);
game.scene.addChild(taps);
// 重置鸟位置
bird.transform.position.x = 100;
bird.transform.position.y = 640;
// 删除所有管道
const pipes = game.scene.gameObjects.filter((item) => item.name == "bar");
pipes.forEach((pipe) => {
game.scene.removeChild(pipe);
pipe.destroy();
});
});
- 修改游戏状态为
ready
; - 移除「结束标题」;
- 创建「准备标题」;
- 重置「小鸟」位置;
- 删除所有「管道」;
最终效果:
问题
在开发 flappy bird
时,发现在游戏过程中,在「管道」和「小鸟」的 x
轴位置重合时,会有短暂的卡顿,帧率也明显从 120 下降到了 100 左右。
分析
打开 performance
,可以看到:
在「小鸟」每次越过「管道」时,cpu
占用明显升高,之后明显下降,再次越过时,又明显升高。很明显,卡顿就出现在「越过」这个时候。
可以看到,在 cpu
占用升高期间,存在 long task
长耗时任务。
再具体看看是哪里产生的任务:
「罪魁祸首」已经很明显了,就是 plugin-evax.esm.js
里的 cloneDeep
。
扒一下 @eva/plugin-evax
的源码,分析下 deepClone
长耗时的具体原因 :
packages/plugin-evax/lib/EvaXSystem.ts
export default class EvaXSystem extends System<EvaXSystemParams> {
// ...
changeList: { key: string; oldStore: any }[] = [];
init({ store = {} } = { store: {} }) {
this.ee = new EventEmitter();
this.store = store;
this.bindDefaultListener();
}
bindDefaultListener() {
this.ee.on("evax.updateStore", (store) => {
this.updateStore(store);
});
this.ee.on("evax.forceUpdateStore", (store) => {
this.forceUpdateStore(store);
});
}
// ...
lateUpdate() {
for (const item of this.changeList) {
this.ee.emit(item.key, this.store, item.oldStore);
}
this.changeList = [];
}
}
在 EvaX
里,使用了 eventemitter3
进行「事件管理」,然后默认绑定了 evax.updateStore
和 evax.forceUpdateStore
。然后在每一帧,遍历 changeList
,进行相应「事件发布」,再清空 changList
。
很明显,当 store
有「变化」时,这个「变化」会被添加进 changeList
当中,然后被「发布」。
再继续看:
packages/plugin-evax/lib/EvaXSystem.ts
// ...
bindListener(key, deep) {
if (key.indexOf('store.') === -1) {
return;
}
const realKey = key.split('.').slice(1).join('.');
defineProperty(realKey, deep, this.store, key, this.store, (key, oldStore) => this.changeCallback(key, oldStore));
}
changeCallback(key, oldStore) {
this.changeList.push({
key: key as string,
oldStore: oldStore as any,
});
}
// ...
changeCallback
函数很简单,就是把「入参」添加进 changeList
。
EvaX
会在每一帧进行监听,并调用 componentObserver.clear()
(返回「组件」的「大变化」并及时清理),然后判断该「变化」的类型为 OBSERVER_TYPE.ADD
(有新的属性被监听)时,就调用 bindListener
进行事件订阅(在flappy-bird
中事件名为store.score
)。
接着看 defineProperty
:
packages/plugin-evax/lib/utils.ts
import cloneDeep from "lodash-es/cloneDeep";
export function defineProperty(
key,
deep,
store,
originKey,
originStore,
callback
) {
// ...
Object.defineProperty(obj, props[length - 1], {
set(val) {
const oldStore = cloneDeep(originStore);
obj[`_${props[length - 1]}`] = val;
callback(originKey, oldStore);
if (deep && isObject(val)) {
_defineCache.delete(obj);
for (const key in val) {
defineProperty(key, deep, val, originKey, originStore, callback);
}
}
},
get() {
return obj[`_${props[length - 1]}`];
},
});
}
deepClone
是lodash
里的一个对「对象」进行「深拷贝」的方法。
defineProperty
挟持了 store
的 set
和 get
方法。在对 store
进行修改时,会触发 set
方法。在 set
方法内部,使用了「罪魁祸首」deepClone
方法对 store
拷贝一份oldStore
(防止对 store
的修改影响到 oldStore
),然后修改store
,在使用callback
返回新旧store
。
到这,卡顿的原因终于浮出水面了!因为 store.game
是 Game
的实例,这个实例对象结构复杂,层次很深,导致 deepClone
的时候,耗时过长。
但是,我又没监听 store.game
🤔️ ?
主要的原因是,EvaX
在监听数据变化时,返回的是一个完整的新store
和 完整的oldStore
。
我觉得这种设计是不太合理的。在 flappy-bird
中,我只监听了一个 Number
类型的 store.score
的变化,但在内部却对整个 store
做了处理,影响「性能」的同时还影响「开发体验」。
// 😭
new EvaX({
events: {
"store.score"(store, oldStore) {
const { score } = store;
const { oldScore } = oldStore;
console.log(score, oldScore)
},
},
})
// 😊
new EvaX({
events: {
"store.score"(score, oldScore) {
console.log(score, oldScore)
},
},
})
解决方法
有两种解决方案:
-
把
store.game
移除,降低store
的复杂度;:指标不治本。
store
复杂起来也依旧会有性能问题。 -
修改
EvaX
,使其直接返回score
;:合理,开搞!
第一步,修改 set
方法,只对当前监听的属性进行 deepClone
:
import cloneDeep from "lodash-es/cloneDeep";
export function defineProperty(
key,
deep,
store,
originKey,
originStore,
callback
) {
// ...
Object.defineProperty(obj, props[length - 1], {
set(val) {
// 不合理
// const oldStore = cloneDeep(originStore);
// 合理
const oldValue = cloneDeep(obj[`_${props[length - 1]}`]);
obj[`_${props[length - 1]}`] = val;
// 这里也要修改,callback的作用是 push change into changList
// callback(originKey, oldStore);
callback(originKey, oldValue);
if (deep && isObject(val)) {
_defineCache.delete(obj);
for (const key in val) {
defineProperty(key, deep, val, originKey, originStore, callback);
}
}
},
get() {
return obj[`_${props[length - 1]}`];
},
});
}
第二步,修改 lateUpdate
函数,只返回「监听值」。
lateUpdate() {
for (const item of this.changeList) {
// 根据 item.key 找出当前的监听的值
const list = item.key.split(".");
let value = this;
for (let i = 0; i < list.length; i++) {
value = value[list[i]];
}
// 这里的oldStore实际上也被替换成oldvalue
this.ee.emit(item.key, value, item.oldStore);
}
this.changeList = [];
}
大功告成,现在 flappy-bird
已经可以稳定不掉帧啦 !不过,当「监听的值」过于复杂时,也会出现性能问题。但是可以通过监听「更具体的值」来避免这种情况出现。
最后
参考文章:
Eva.js
之旅到这就结束啦。祝大家生活愉快,工作顺利!
「 --- The end --- 」