[3D游戏开发实践] Cocos Cyberpunk 源码解读-游戏逻辑框架全解

Cocos Cyberpunk 是 Cocos 引擎官方团队以展示引擎重度 3D 游戏制作能力,提升社区学习动力而推出的完整开源 TPS 3D游戏,支持 Web, IOS, Android 多端发布。

本系列文章将从各个方面对源码进行解读,提升大家的学习效率。希望能够帮助大家在 3D 游戏开发的路上更进一步。

工程源码免费下载页面:
https://store.cocos.com/app/detail/4543

麒麟子觉得,这篇文章至少可以让你节约好几天的研究时间。

不信?你往下看!

目录

其实这篇文章一开始不长这样,快要写完的时候,负责 Cocos Cyberpunk Gameplay 部分的大佬告诉我,这个部分会持续调整。

麒麟子这才意识到,过多的逻辑细节讲解会随着版本的更新而失去参考意义。

在向大佬了解了一些后续规划后,麒麟子决定推翻重来,从代码编写路数和逻辑运转机制的角度来做一个导读:

  • 1、预加载流程
  • 2、【划重点】Data 与 Action
  • 3、Game 模块导读
  • 4、Level 模块导读
  • 5、Actor 模块导读
  • 6、主角控制
  • 7、角色动画掩码与IK
  • 8、怪物生成机制
  • 9、物品掉落机制
  • 10、…

预加载流程

入口脚本 init.ts

上一篇文章简单提到过这个入口脚本 init.ts,它会检测设备是否支持 WEBGL,并在不支持的时候给予提示,还会将 init 节点设置为常驻,以确保游戏逻辑正常执行。

接下来,我们主要关注下面这段初始化函数:

...
// Load the resource cache data and execute the initialize game function.
ResCache.Instance.load(async () => {
    console.time('loadTextures')
    await loadTextures();
    console.timeEnd('loadTextures')
    Game.Instance.init();
});
...

可以看到,init.ts 中会先调用 ResCache.load 进行初始化,初始化完成后执行回调。

在回调函数中,调用 loadTextures 函数预加载 resources/textures 目录下的所有纹理。

最后再调用 Game.Instance.init 启动游戏逻辑。

ResCache

ResCache 是一个单例类,它负责加载并缓存所需资源。

除了提供 loadXXX 系列方法,也提供了 getXXX 系列方法,所有预加载成功的资源都可以直接使用。

进入 ResCache.load 函数可以看到,它先是加载了 data/data-res-cache.json 文件,再根据文件中的配置,预加载对应的 jsonspritesound

同时,ResCache.load 会向外发布 msg_loading 事件,UILoading 监听到此事件后,就会显示出来。

loading 阶段,ResCache 也承担了加载进度统计的职责,查看 addLoadremoveLoad 方法被调用的地方就可以一目了然。

当资源预加载结束,场景会由 scene-game-start 切换为 scene 场景,并且显示出 resources/ui/ui-logo.prefab

【划重点】Data 与 Action

应该有很多朋友和麒麟子一样,尝试去寻找场景切换和 UI 显示的相关的代码,但没有找到。

麒麟子咨询了负责 Gameplay 的大佬后,才恍然大悟:整套游戏逻辑代码,参考了行为树的节点设计机制,基于数据(data)和行为(action)来驱动

游戏中的 player 是对象,enemy 是对象,作用于全局的管理器 gamelevel 等也是对象。

Data

每一个对象,都拥有一个 data-xxx.json 配置文件,你可以在 resources/data下找到它们,比如:

  • game: data-game.json
  • level: data-level.json
  • player: data-player.json
  • enemy: data-enemy_1.json
  • boss: data-boss_0.json

这些文件中的数据字段,与用来解析它的类是一一对应的。比如:

  • game -> game.ts
  • level -> level.ts
  • player -> actor.ts
  • enemy -> actor.ts
  • boss -> actor.ts

所以,你会发现,data-player.jsondata-enemy_1.jsondata-boss_0.json 的内容格式是差不多的。 但与 data-game.jsondata-level.json 的差别就很大。

Action

每一个对象,都拥有一个 action-xxx.json 配置文件,你可以在 resources/action 下找到它们,比如:

  • game: action-game.json
  • level: action-level.json
  • player: action-player.json
  • enemy: action-enemy_1.json
  • boss: action-boss_0.json

Data 类似,Action 配置文件的内容也与执行它的类相关,同类会采用相近的配置格式。

每一个 Action 文件中,都定义了一系列的 Action Node

对象可以根据条件,触发不同的 Action Node

每一个 Action Node 都拥有两个命令:startend

start 会在 Action Node 进入时执行, end 会在 Action Node 离开时执行。

而每一个 startend 命令在执行时,又可以按序执行多个操作。

用于解析 action-xxx.json 配置文件的类在 action.ts 脚本中。

进入到 action.ts,我们可以看到 Action 类有两个主要的函数:

  • on: 执行 start 命令
  • off: 执行 end 命令

UtilAction 中,定义了 startend 可以执行的所有操作。 比如:

  • on_ui/off_ui: 打开/关闭 UI
  • on_inst_pool: 初始化对象池
  • on_bgm: 播放背景音乐
  • on_scene: 切换场景

接下来我们用 game 对象来讲述一下 Data & Action 的运转机制。

Game 模块

Game 模块涉及到的 3 个主要文件:

  • data-game.json
  • action-game.json
  • game.ts

先来看看两个 json 配置文件的关键内容:

data-game.json

{
    ...
    "fps":60,
    "start_node":"logo",
    "action_data":"action-game",
    "version":"version:202302271037",
    "show_version":"V1.2",
    ...
    "res_ui_root":"ui/",
    ...
}

data-game.json 中,我们可以看到它定义了许多东西,比如限定的帧率,版本号等等。

这里我们还应该关注 start_nodeaction_data 这两个值,这是游戏启动流程的关键数据。

  • action_data: 用于指定对应的 Action 配置文件
  • start_node: 用于指定默认的 Action Node。

action-game.json

{
    "logo": {
        "start": [ ... ],
        "end": [ ... ]
    },
    "level": {
        "start": [ ... ],
        "end": [ ... ]
    },
    ...

可以看到在 action-game.json 定义了许多 action,最关键的两个:

  • logo: 显示 Logo 和开始按钮时的状态
  • level: 关卡状态,此时可以操作主角进行战斗

game.ts

game.tsinit 方法中,我们可以看到下面两条代码

// Initialize the game data.
this._data = dataCore.DataGameInst._data;
// Initialize game action data.
this._action = new Action(this._data.action_data);
...
// Push the game initial node into the stack data.
this.push(this._data['start_node']);

第一行代码,是获得从 data-game.json 加载到的配置数据。

第二行代码,是使用 action_data(值为 ‘action-game.json’) 去创建一个 Action。

第三行代码,是使用 start_node(值为 logo) 去作为 game 的初始 action

我们再来看看, action-game.jsonlogo 节点的具体内容:

{ "time": 0, "name": "on_ui", "data": "ui_logo" },
{ "time": 0.1, "name": "on_inst_pool", "data": "gun_tracer_pool"},
{ "time": 0.15, "name": "on_inst_pool", "data": "sfx_heart"},
{ "time": 0.2, "name": "on_inst_pool", "data": "random-fly-car"},
{ "time": 0.3, "name": "on_inst_pool", "data": "level_events"},
{ "time": 0.4, "name": "on_bgm", "data": "bgm_logo"},
{ "time": 0.5, "name": "on_scene", "data": "scene"}

翻译为执行流程就是:

  • 显示 ui_logo
  • 初始化对象并放入对象池
  • 播放背景音乐 bgm_logo
  • 切换到场景 scene

Tips: 前面的 time 的单位是秒,用于分帧处理。 如果想快速处理完,可以全部填 0。

到这里,Game 模块的导读就结束了,相信有了上面的讲解,Game 模块的代码阅读会轻松不少。

Level 模块

Level 模块用于负责关卡相关的状态控制,它没有具体的行为。

它只是一个容器,将主角、怪物、物品掉落、寻路、存档等功能连接在一起。

当用户点击 START 按钮后,会执行 action-game.json 中的 level Action。

"level": {
    "start": [
        { "time": 0, "name": "on_bgm", "data": "bgm_level"},
        { "time": 0, "name": "on_ui", "data": "ui_fx" },
        { "time": 0, "name": "on_msg", "data": "msg_level_start" },
        { "time": 0.2, "name": "on_msg_str", "data": { "key":"msg_set_camera", "value": false } },
        { "time": 2, "name": "on_ui", "data": "ui_level" }
    ],
    ...
}

可以看到,这里做了以下操作:

  • 播放背景音乐 bgm_level
  • 显示UI ui_fx
  • 发送事件 msg_level_start
  • 发送事件 msg_set_camera, false
  • 显示UI ui_level

Level 模块也涉及到了三个主要文件:

  • data-level.json
  • action-level.json
  • level.ts

data-level.json

{
    "name":"Cyberpunk Scene",
    "total_time":300,
    "close_blood_fx": true,
    "each_rate_value":10000,
    "killed_to_score":100,
    "survival_time_to_score":10,
    "level_events":"level_events",
    "prefab_player":"player-tps",
    "prefab_enemy":"enemy-tps",
    "prefab_drop_item":"drop_item",
    "enemies":["enemy_0", "enemy_1", "enemy_2", "boss_0"],
    "fx_dead":"fx_dead_white",
    "score_level":[...],
    "items":[...],
    "probability_drop_enemy":{...},
    "probability_drop_items":{...},
    "cards":["life", "attack", "defense", "special"],
    "probability_drop_card":{...}
}

我们可以看到,在 Level 模块的数据配置文件中,包含了:

  • 击杀分数
  • 重生时间
  • prefab信息
  • 怪物信息
  • 掉落物品信息
  • 分数评级

通过修改这些配置,就可以改变关卡数据和玩法。

action-level.json

{
    "start":{...},
    "warning":{ ... },
    "end":{...}
}

而关卡的 Action 配置文件,则相对简单,只有 startwarningend 三种情况。

"start":{
    "start":[
        { "time": 0.2, "name": "on_msg_str", "data": { "key":"level_do", "value":"addPlayer" } },
        { "time": 0.5, "name": "on_inst", "data": "level_events_enemy" },
        { "time": 0.6, "name": "on_inst", "data": "level_events_items" }, 
        { "time": 0.7, "name": "on_inst", "data": "level_events_card" }
    ]
},

start 命名中我们可以看到,当关卡加载时,会执行以下操作:

  • on_msg_str:level_do, addPlayer
  • on_inst:level_events_enemy
  • on_inst:level_events_items
  • on_inst:level_events_card

on_msg_str

action.tsUtilAction 类中,我们可以看到, on_msg_str 的方法原型为:

  public static on_msg_str (data: key_type_string) {
      Msg.emit(data.key, data.value);
  }

不难看出,它的功能就是向外发出一个事件,并带有一个参数。

接下来我们看看在脚本代码中,是如何执行这些操作的。

{ "time": 0.2, "name": "on_msg_str", "data": { "key":"level_do", "value":"addPlayer" }

在这里就是:发出一个 level_do 消息, 参数为 addPlayer

打开 level.ts 我们可以看到,在它的 init 函数中,监听了这个消息:

Msg.on('level_do', this.do.bind(this));

level.tsdo 函数的原型如下:

public do (fun: string) {
    this[fun]();
}

由此可以看出, action-level.json 中的第一个操作,其实就是调用 LeveladdPlayer 函数。

on_inst

action.tsUtilAction 类中,我们可以看到, on_msg_str 的方法原型为:

    public static on_inst (key: string, actor: Actor) {
        var asset = ResCache.Instance.getPrefab(key);
        var obj = Res.inst(asset, Level.Instance._objectNode);
        if (actor && actor._viewRoot) {
            obj.parent = actor._viewRoot!;
            obj.setPosition(0, 0, 0);
        }
    }

从上面的原型中可以看出,on_inst 方法就是实例化一个指定的对象。

这个指定对象参数是 prefab 的名称。

  • level_events_enemy
  • level_events_items
  • level_events_cards

都是 prefab,对应的资源在 assets/resources/obj 目录下。

ResCache 在预加载阶段会将目录下的 prefab 都加载完,并按 prefab 名称存储下来。 使用的时候直接使用名称查询就可以了。

具体逻辑请参考 ResCache 类中的 loadPrefabsetPrefabgetPrefab

Actor 模块

接下来我们来讲讲大家最关心的主角控制怪物生成以及可拾取物品生成相关内容。

但在讲这些内容之前,我们先来看看 Actor 模块。

在前面的内容中,我们提到过,当你打开 action-player.jsonaction-enemy_1.jsonaction-boss_0.json
这些文件时,你会发现它们的内容格式都差不多。
这是因为,主角怪物BOSS 对应的类都是 Actor

打开 data-player.json 或者是 data-enemy_1.json 文件,都可以看到 action 配置文件名音效最大血量等信息。

{
    "name":"actor-enemy_0",
    "action":"action-enemy_0",
    "sfx_walk_ground":"sfx_walk_ground",
    "sfx_walk_grass":"sfx_walk_grass",
    ...
    "strength": 100,
    "max_strength": 100,
    "max_hp":60,
    ...

打开对应的 action-xxx.json,你可以看到有 playjumpdeadpickup 等命令。

而每个命令会执行以下命令:

  • on_call:调用当前 actor 上指定的函数名
  • on_set:设置当前 actor 上指定的变量值
  • on_anig:设置当前 actor 上动画图的变量
  • on_sfx:播放音效

具体的函数原型,大家可以自己去 action.ts 里查看。

以上动作的载体就是 Actor

主角控制

初始化

主角使用的 prefab 路径为 resources/obj/player-tps.prefab,这个在 data-player.json 中有配置。

我们从 level.ts 中的 addPlayer 函数继续讲。

public addPlayer () {
    //获得 player 的 prefab
    const prefab = ResCache.Instance.getPrefab(this._data.prefab_player);
    //通过 prefab 生成 player 实例
    const resPlayer = Res.inst(prefab, this._objectNode!, this._data.spawn_pos);
    // 获取 Actor 组件
    this._player = resPlayer.getComponent(Actor)!;
    // 标记这个 actor 为主角
    this._player.isPlayer = true;
    // 使用 data-player.json 初始化这个 actor
    this._player.init('data-player');
}

上面是 addPlayer 中的关键步骤。接下来,我们继续看 this._player.init 这段代码做的事情。

这段代码会先调用 ActorBase.init 完成一些基本的初始化工作,然后再调用 Actor.initView

Actor.initView 中的最后一步,会调用 play Action,启动对象。

action-player.josn 中,我们可以看到 play 的内容如下:

"play":{
    "start":[
        { "time": 1, "name": "on_call", "data": "onUpdate"},
        { "time": 1.5, "name": "on_inst_scene", "data": "actor_input"},
        { "time": 1.5, "name": "on_com", "data":"ActorStatistics"},
        { "time": 1.5, "name": "on_com", "data":"ActorSound"},
        { "time": 2, "name": "on_msg_str", "data": { "key":"msg_set_input_active", "value": true} }
    ]
},

它做了以下几件事:

  • 调用 onUpdate 函数
  • 实例化 actor_input 对象
  • 为 player 添加 ActorStatisticsActorSound 组件
  • 发送 msg_set_input_active 事件,激活输入

用户输入

{ "time": 1.5, "name": "on_inst_scene", "data": "actor_input"},

上面这条语句执行的就是将 resources/obj/actor_input.prefab 实例化,并添加到场景节点上。

actor_input.prefab 上有一个 ActorInput 组件,它的功能就是处理用户的输入。

进入 actor-input.ts 文件中,在 ActorInput 类的 initInput 方法中我们可以看到,它会做平台判断。 当处于移动端的时候,会启动 joysitck 方式。 反之,则使用鼠标键盘操作。

input-joystick.tsinput-keyboard.ts 不会直接控制角色,而是会把所有的操作指令交给 ActorInput 组件来处理。

ActorInput 实现了所有的用户操作,如 onMoveonJumponRotationonRun 等等。

主角摄像机

player-tps.prefab 中,我们可以找到 camera-root 节点,这个节点上挂了三个组件:

CameraTps:用于控制摄像机的上下旋转。由于摄像机的正方向总是与主角对齐的,所以不需要控制左右。

CameraMoveTarget:用于控制摄像机的缓动效果

SensorRayNodeToNode:用于摄像机与场景的碰撞检测

角色动画掩码与IK

射击游戏中,有两个最主要的功能:

  • 边移动边开枪
  • 根据镜头方向改变端枪姿势

Cocos Cyberpunk 中也实现了。

角色动画掩码

Cocos Creator 提供了动画图功能,并且提供了 分层动画与掩码功能。

分层动画:简单来说,就是用户可以同时播放多个动画,并且通过权重来进行混合。

动画掩码:标记动画在播放时,哪些骨骼需要更新。

Cocos Cyberpunk 中,角色(主角和怪物)的动画图有两层 basefire

可以看到 fire 的权重为 1.0,由于 fire 在分层图中的索引靠后,优先级较高,所以当 basefire 同时播放的时候,fire 会替换掉动画。

假如我们想要实现移动中射击,那我们希望的是 base 层播放移动fire 层播放射击,并且 fire 只影响上半身。

这时,就需要用到动画掩码。

双击打开 anig_player_mask 可以看到,所有的上半身骨骼都被选中了。 也就是说,fire 动作在播放时,只会进行上半射更新。

角色IK

如上图所示,我们需要根据镜头方向改变端枪姿势,才能保证游戏的射击体验。

这就需要用到我们的 IK

player-tps.prefab 中找到 actor-player-tps 节点,可以看到它上面挂了两个组件:

AimIK:用于绑定IK相关数据。端枪描准姿势主要受脊柱上 spine01spine02spine03 三根关节的影响,组件里对它们进行了引用。

AimControl:用于实现描准控制

IK 相关的代码在 scripts/core/ik 目录下,有兴趣的朋友可以深入研究。

怪物生成机制

配置

让我们从 action-level.json 中的 start Action 说起:

{ "time": 0.5, "name": "on_inst", "data": "level_events_enemy" },

Level 开始后,会创建 level_events_enemy 的实例,它在关卡中只有一个,是负责怪物生成和销毁的管理器。

与它相关的资源:

  • resources/obj/level_events_enemy.prefab
  • resources/obj/boss_0.prefab
  • resources/obj/enemy_0.prefab
  • resources/obj/enemy_1.prefab
  • resources/obj/enemy_2.prefab
  • level_events_enemy.ts

打开 level_events_enemy.ts 可以看到,在 LevelEventsEnemy 中使用了 data-level.json 中的 probability_drop_enemy 作为数据源,内容如下:

"probability_drop_enemy":{
    "interval":[2, 4],
    "interval_weight_max":1,
    "life_time":[30, 40],
    "max": 4,
    "init_count":[7, 10],
    "weights":[0.4, 0.7, 0.9, 1],
    "weights_max":[4, 4, 4, 1],
    "weights_group":[ 0, 1, 2, 3]
},

可以看到,它定义了刷新间隔,最大怪物数量等等。

生成

LevelEventsEnemy 中主要函数为 generateEvent,它会判断怪物数量是否需要生成。

如果符合生成条件,它会从 data-level.jsonenemies 中随机一种怪物的外观。

"enemies":["enemy_0", "enemy_1", "enemy_2", "boss_0"],

得到外观后,再随机一个行为组,组合成数据,发出 msg_add_enemy 事件。

const currentIndex = this.probability.weights_group[occurGroupIndex];
const res = DataLevelInst._data.enemies[currentIndex];
// Send add add enemy.
Msg.emit('msg_add_enemy', { res: res, groupID: occurGroupIndex })

还要特别注意的是,当 boss 出现时,它还会发出警告。(麒麟子觉得,这个配在 action-boss_0.json 中可能会更好)

  if (res == 'boss_0'){
      Msg.emit('level_action', 'warning');
  } 

同时,它也会响应 msg_remove_enemy 事件,处理怪物被删除的情况。

然后,在 addEnemy 中,从寻路系统中随机出一个可用的位置点,进行怪物创建:

  //从寻路系统中获取一个可用点
  const point = NavSystem.randomPoint();
  //创建 enemy
  var enemy = ResPool.Instance.pop(data.res, point.position); 
  const actor = enemy.getComponent(Actor);
  //初始化怪物数据      
  actor.init(`data-${data.res}`);

怪物AI

打开 boss_0.prefab 或者 enemy_0.prefab,可以看到两个与怪物 AI 相关的组件:

ActorBrain

真正的 怪物AI 驱动器,会根据周围环境执行相应动作。

ActorInputBrain
转接器,方便将 怪物AI 行为转递给 ActorInput

可拾取物品掉落机制

配置

level_events_enemy 相似,在 Level 加载成功后,会生成一个用于可拾取物品的管理器:

{ "time": 0.6, "name": "on_inst", "data": "level_events_items" }, 

与它相关的资源:

  • resources/obj/level_events_items.prefab
  • resources/obj/drop_item.prefab
  • level_events_items.ts
  • drop-item.ts

level_events_enemy.ts 中,会使用 data-level.json 中的 probability_drop_items 字段做为配置数据:

    "probability_drop_items":{
        "interval":[5, 30],
        "life_time":[30, 40],
        "max": 4,
        "interval_weight_max": 1,
        "init_count":[7, 10],
        "weights":[0, 0.125,0.25, 0.375,0.75,1],
        "weights_max":[1, 1, 1, 1,2,2],
        "weights_group":[0, 1,2, 3,4,5]
    },

可以看出,它和怪物生成类似,也配置了一些生成相关的参数。

生成

level_events_items.ts 中,不会处理实际的物品生成,它会监听 msg_remove_item 事件,调整自身数据。

当判定可以生成新物品时,会发出 msg_remove_item 事件。

level.ts 中,会监听这个事件并执行 addDrop 方法。

addDrop 方法中,会生成 drop_item.prefab 的实例。

拾取

在主角 player-tps.prefabsensor_detect_drop 节点上,有 SensorRaysActorSensorDropItem 两个组件。

SensorRays:会定期检查主角周围的对象,并存到 checkedNode 变量上。

ActorSensorDropItem:会做一些判断,并更新状态,同时将 checkedNode 保存为 pickedNode

ui_level.prefab 中的 grp_take_info 上有一个 UIDisplayByState 组件,它会检测这个状态,并显示出 “按E拾取” 这个提示。

当物品被拾取后,ActoronPicked 方法会被调用,并发出 picked 事件。

drop_item.prefab 上的 DropItem 组件会响应 picked 事件并销毁回收自己。

自动拾取

在主角 player-tps.prefab 上挂了一个 CheckAutoPick 组件,当处于移动端时,会自动调用 ActoronPicked 方法。

最后来几句

由于 UI系统寻路系统 涉及的内容太多,会专门用两篇新的文章来讲解。

希望这篇文章,能够帮助到想要研究 Cocos Cyberpunk 项目源码的朋友。

大佬花了几个月时间写完的 Gameplay,显然是麒麟子用两天时间无法吃透的。

希望更多朋友能够一起研究,一起产出一些学习心得、教程,让 Cocos Cyberpunk 这个开源项目发展得更好。

下一篇不出意外,会写自定义管线,敬请期待。

附1:类名 <—> 文件名

Cocos Cyberpunk 项目中,类名采用大驼峰命名法,即首字母大写的方式。

与类名对应的 ts 文件,则采用小写单词并用“-”连接的方式。

比如:

  • UILoading -> ui-loading.ts
  • CameraController -> camera-controller.ts

掌握这个对应关系,保证你能很快定位到想要的代码。

附2:两种单例写法

细心的朋友会发现,Cocos Cyberpunk 中有两种单例写法。

一类是传统的,适合所有面象对象语言的模式:Singleton

通过 类名.Instance 访问,如:

  • UI.Instance
  • ResCache.Instance
  • Level.Instance
  • GameSet.Instance

这样做的好处是:可以随时调用,会在第一次被调用时初始化

但不好的地方是:所有类都会直接被不同的文件引用,当类名修改或者移动文件时,涉及到的文件会非常多

另一类是适合 TS,JS,C++ 这种支持全局变量的语言。 参考 data-core.ts

import { DataEquip } from "../../logic/data/data-equip";
import { DataSound } from "../../logic/data/data-sound";
import { DataCamera } from "./data-camera";
...

export const DataEquipInst = new DataEquip();
export const DataSoundInst = new DataSound();
export const DataCameraInst = new DataCamera();
...

export function Init () {
    //Init data.
    DataEquipInst.init('data-equips');
    DataSoundInst.init('data-sound');
    DataCameraInst.init('data-camera');
}

这种写法,使用起来相当灵活。
可以像这样使用:

import * as dataCore from "./data-core";
dataCore.DataEquipInst.foo();

也可以像这样使用:

import { DataEquipInst } from '../data/data-core';
DataEquipInst.foo();

这种写法的好处是:所有引用的地方只依赖一个容器文件,发生重构时影响非常小

但也有一些较小的坏处:可能会有多人同时维护同一个容器文件,并且需要一个适合的地方调用初始化函数

相比而言,麒麟子更喜欢第二种,会让代码变得更易维护。

**Tips:**其实第二种方式并不新鲜,与在静态类中持有各实例对象的做法是一样的。只是得益于 TS 这类可以使用全局变量的语言特性,才可以用这样的方式编写。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值