一起来一趟 Eva.js 之旅吧!

4,252 阅读16分钟

Hi,大家好。又是我yxchan🧑‍💻。不知道大家都没有发现,很多 App/Web 变得越来越「游戏化」了。我觉得主要的原因是,游戏的体验感、成就感等等能起到比较好的「拉新」、「留存」等作用。导致现在的需求也越来越「游戏化」了。

对于游戏开发,有很多成熟的框架可以使用了,好比如 CocosEgretLaya等等...

这种框架都非常完善(大且复杂),能够实现非常多的游戏能力。

但是,对于前端而言,大多数需求可能仅仅是一些不太复杂的交互,配合一些炫酷的动画,再加上一些「游戏榜单」之类的,并不算是「纯」游戏。前端更需要的是一款「轻量级」的游戏引擎,用于负责实现一些基本「交互」以及「动画渲染」,其他部分则可以由传统的 htmlcss 来实现。

相信很多人在 「11.11」 的时候,有玩过淘宝的「喵🐱糖」抢红包🧧游戏。该页面用的游戏引擎是阿里已经开源了的 Eva.js。(官方:阿里系有30多个项目使用了该引擎,覆盖用户超5亿),所以稳定性应该有保障(吧?)。

Eva.js 有三个优点:

  1. 简单:官方提供了大量开箱即用的游戏组件;
  2. 高性能:基于号称「2D 渲染界扛把子」的 PixiJS
  3. 可拓展:使用 ECS(在 Eva.js 中对应的是 GameObject/Component/System ) 架构,能使用「可高度定制的 Api 」进行拓展;

俗话说,「活到老学到老」。接下来,一起来一场 Eva.js 之旅吧。

资源管理

大多数游戏,在进入游戏前,都会先呈现 loading 页,待游戏的「所有资源加载完毕」后,才真正进入游戏。这样做能避免在游戏过程中出现资源加载速度「慢/失败」,提高游戏流畅性。

Eva.js 中,使用 resource 进行资源的统一管理。这样做的好处有两点:

  • 统一资源入口管理;
  • Eva.js 加载资源时,资源管理器可以对资源进行「预处理」,减少运行时处理资源产生卡顿等问题;

目前,官方支持的 RESOURCE_TYPE 有以下七种:

typevaluedescription
AUDIO'AUDIO'音频
VIDEO'VIDEO'视频
IMAGE'IMAGE'图片
SPINE'SPINE'骨骼动画
SPRITE'SPRITE'雪碧图
SPRITE_ANIMATION'SPRITE_ANIMATION'帧图
DRAGONBONE'DRAGONBONE'龙骨动画

同时,也还支持(不在RESOURCE_TYPE中,但实际还是支持):

valuedescription
'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 上。

添加系统

添加「系统」有两种方式:

  1. 创建 game 时添加:
const game = new Game({
    ...
    systems: [
        ...,
        new ImgSystem()
    ]
})
  1. 通过 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 }
})

上述代码,创建了一个名为 birdGameObject

对于 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}))

生命周期

每一个被添加进 GameObjectComponent 都会触发 ⬇️ 生命周期:

O1CN01gEgYOz1T8qevGDloU_!!6000000002338-2-tps-1448-906.png

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) {},
    }
  })
)

更新数据

没有烦人的 actiondispatch等,「直接改」也能触发事件监听!

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

截屏2022-09-06 下午3.08.04.png

游戏内容很简单,点击 Tap 、控制「小鸟」、计算「得分」、碰到物体就「结束」。

项目地址:flappy-bird

Store

第一步,先考虑 store 的结构。store 里需要存放「游戏状态」、「游戏得分」。 (为了方便全局调用,把实例化后的Game放进 store.game

flappy-bird/src/store.ts

export default {
  game: null, // 游戏
  status: "ready", // 游戏状态
  score: 0, // 成绩
}

游戏对象

根据游戏原型,把其拆分为各种 GameObject

总体拆分为 6 部分:

  • 背景
  • 小鸟
  • 准备标题
  • 得分
  • 结束标题
  • 管道

背景

背景分两部分:

大背景: bg.png

草地: ground.png

「大背景」包含「草地」。因为在游戏「开始」后,远处的「大背景」和近处的「草地」会以不同的速度滚动,所以,可以用「平铺精灵」,实现无限背景。

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 };
};
  1. 首先创建了名为 bgGameObject ,然后添加 TilingSprite 组件,引用了名为 bg 的静态资源;
  2. bg 中添加了 BackgroundMove(在后续「脚本组件」中会讲到);
  3. 接着创建了名为 groundGameObject ,也是添加了 TilingSprite 组件,引用了名为 ground 的静态资源;
  4. 同理,在 ground 中添加 BackgroundMove
  5. 因为游戏中,「小鸟」碰到「草地」也算游戏结束,所以要在 ground 中添加 Physics
  6. 接着把 ground 添加进 bg 中;
  7. 最后把 bg 添加进 game 中;

效果:

截屏2022-09-06 下午2.33.03.png

小鸟

同理,「小鸟」也和「背景」差不多。但因为「小鸟」有个「飞翔」的动作,所以用的是「帧图」。

bird.png

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 };
};
  1. 创建名为 birdGameObject ,然后添加了 SpriteAnimation 组件,引用了名为 bird 的静态资源;
  2. bird 添加到 game 中;

效果:

屏幕录制2022-09-06 下午5.19.34.2022-09-06 5_20_03 PM.gif

准备标题

「准备标题」用的是「雪碧图」,创建过程也是大同小异,就不再赘述了。

ready.png

有个关键点,就是点击 Tap 游戏才算真正开始。因此要在 Tap 这个 GameObject 上,监听「点击」事件,然后「启动」游戏。

flappy-bird/src/gameObjects/ready.ts

// ...

  const evt = tap.addComponent(new Event());
  evt.on("tap", () => {
    store.game.emit("start");
  });
  
// ...
  1. tap 上添加了 Event 组件;
  2. 监听 tap 的点击事件;在被点击后,向 game 抛出 start 事件;

效果:

截屏2022-09-06 下午2.55.05.png

得分

因为游戏的「得分」会随着游戏的进行,而不断变化。所以,选择由 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 };
};
  1. 在 score 上添加 Text 组件;
  2. score 上添加 UpdateScore「脚本组件」;
  3. score 上添加 EvaX 组件,并且监听 score,当 score 改变时,调用 UpdateScore 上的 updateScore 方法,更新「得分」;

效果:

截屏2022-09-06 下午3.08.04.png

结束标题

当游戏进行中时,「小鸟」碰到任何东西,游戏就会「结束」,然后显示 Game Over

具体实现和「准备标题」差不多,唯一的区别就是,在点击后,向 game 抛出 ready 事件。

flappy-bird/src/gameObjects/over.ts

const evt = play.addComponent(new Event());
evt.on("tap", () => {
    store.game.emit("ready");
});

管道

游戏开始后,会有源源不断「成对且上下对齐但长度不一」的管道从右边开始向左滑动,直至从屏幕左边消失。所以,当游戏一开始时,创建的「管道」的位置,应该是在「可见屏幕」的右侧。

实现思路:

  1. 实现 createBar 函数,负责根据「入参」创建初始不同位置的「管道」;
  2. 实现 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

在创建「得分」时,使用 EvaXscore 进行了监听,然后调用该组件的 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();
          }
        }
      }
    }
  }
}

监听

现在,「万事俱备,只欠东风」。只要对「准备标题」和「结束标题」抛出的 startready 事件进行监听,然后修改游戏「状态」,再「添加」 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);
    });
  });
  1. 修改游戏状态为 playing
  2. 移除「准备标题」;
  3. 创建「管道」;
  4. 为「小鸟」添加「物理引擎」;
  5. 监听 gametap 事件,每 tap 一下,则给予「小鸟」一个 y 轴向上的力;
  6. 监听「碰撞检测」,若发生「碰撞」,则修改游戏状态为 over,创建「结束标题」,移除「小鸟」的「物理引擎」以防止继续「下坠」,移除 gametap 监听;

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();
    });
  });
  1. 修改游戏状态为 ready
  2. 移除「结束标题」;
  3. 创建「准备标题」;
  4. 重置「小鸟」位置;
  5. 删除所有「管道」;

最终效果:

Untitled.2022-09-06 1_56_52 PM.gif

问题

在开发 flappy bird 时,发现在游戏过程中,在「管道」和「小鸟」的 x 轴位置重合时,会有短暂的卡顿,帧率也明显从 120 下降到了 100 左右。

屏幕录制2022-09-06 下午5.47.12.2022-09-06 5_48_27 PM.gif

分析

打开 performance ,可以看到:

截屏2022-09-06 下午5.53.31.png

在「小鸟」每次越过「管道」时,cpu 占用明显升高,之后明显下降,再次越过时,又明显升高。很明显,卡顿就出现在「越过」这个时候。

可以看到,在 cpu 占用升高期间,存在 long task 长耗时任务。

再具体看看是哪里产生的任务:

截屏2022-09-06 下午6.06.24.png

「罪魁祸首」已经很明显了,就是 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.updateStoreevax.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]}`];
    },
 });
}

deepClonelodash 里的一个对「对象」进行「深拷贝」的方法。

defineProperty 挟持了 storesetget 方法。在对 store 进行修改时,会触发 set 方法。在 set 方法内部,使用了「罪魁祸首」deepClone 方法对 store 拷贝一份oldStore(防止对 store 的修改影响到 oldStore),然后修改store,在使用callback返回新旧store

到这,卡顿的原因终于浮出水面了!因为 store.gameGame 的实例,这个实例对象结构复杂,层次很深,导致 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)
      },
    },
})

解决方法

有两种解决方案:

  1. store.game 移除,降低 store 的复杂度;

    :指标不治本。store 复杂起来也依旧会有性能问题。

  2. 修改 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 --- 」