给不支持摇树的三方库(phaser) tree-shake?

为 Phaser 打造一个 Vite 自动 Tree-shaking 插件

前言

Phaser 是一款功能强大的 HTML5 游戏开发引擎,深受广大 Web 游戏开发者的喜爱。然而,它有一个众所周知的痛点:不支持 Tree-shaking。这意味着即使你的游戏只用到了引擎的一小部分功能,最终打包时也必须引入整个 Phaser 库,导致项目体积异常臃肿。对于追求极致加载速度的小游戏而言,这无疑是难以接受的。

本文将分享如何利用 ViteBabel ,从零开始打造一个插件,实现对 Phaser 的自动化、智能化 Tree-shaking,让我们的项目彻底告别不必要的代码,大幅优化加载性能。

为什么 Phaser 不支持 Tree-shaking?

现代前端打包工具(如 Vite、Webpack)的 Tree-shaking 功能依赖于 ES6 模块的静态结构。然而,Phaser 的架构使其无法从中受益。

javascript 复制代码
// Phaser 的 API 设计方式 (不支持 tree-shaking)
import Phaser from 'phaser'
const sprite = new Phaser.GameObjects.Sprite()  // ❌ 整个 Phaser 对象都被构建和包含

// 理想中支持 tree-shaking 的方式:
// import { Sprite } from 'phaser/gameobjects'  // ✅ 只导入 Sprite 模块
// const sprite = new Sprite()

// 即使你尝试解构导入,也无济于事:
import { Game } from 'phaser'
// 这背后,整个 Phaser 对象仍然被完整构建,Game 只是从中取出的一个属性。

官方的"自定义构建"方案及其痛点

Phaser 官方提供了一种手动剔除模块的自定义构建方案。其原理是在构建时,通过修改入口文件,手动注释掉不需要的大模块。 楼主之前也进行了实践:自定义构建方案实践

javascript 复制代码
// phaser/src/phaser.js (入口文件简化示例)
var Phaser = {
    Actions: require('./actions'),
    Animations: require('./animations'),
    // ... 其他模块
//  手动注释掉物理引擎模块
//  Physics: require('./physics'),
//  手动注释掉插件管理模块
//  Plugins: require('./plugins'),
    // ...
};

这种方式虽然能减少体积,但操作起来却非常痛苦,有以下几个致命缺陷:

  1. 维护成本高:每次升级 Phaser 版本,都需要重新手动构建一次。
  2. 复用性差:不同项目用到的模块不同,就需要为每个项目维护一个定制版引擎。
  3. 开发流程繁琐:开发过程中新增了某个模块的功能,就必须重新构建,打断开发心流。
  4. 依赖关系复杂:Phaser 内部模块间存在复杂的耦合。手动剔除时,很容易误删被其他模块隐式依赖的模块,导致项目在运行时崩溃,需要大量试错才能找到最优组合。

这样的方式不仅效率低下,而且极易出错。当团队同时维护多款游戏时,很容易陷入不断重新构建引擎、不断排查运行时错误的泥潭。

那么,有没有一种一劳永逸、通用且自动化的解决方案呢?有的兄弟,有的。

解决方案:Vite + Babel 自动化 Tree-shaking 插件

我们的核心思路是:通过编写一个 Vite 插件,在项目构建时自动分析源码 ,找出项目中实际使用到的 Phaser API,然后动态生成一个定制版的 Phaser 入口文件,最后让 Vite 使用这个定制版文件来打包。

这样,我们就能在不侵入项目代码、不改变开发习惯的前提下,实现完美的 Tree-shaking。

本文环境

  • "vite": "^5.4.8"
  • "phaser": "3.86.0"

如何找到我们使用的模块?

要实现自动化,关键在于如何精确地找出代码中使用了哪些 Phaser 模块 。答案就是大名鼎鼎的 Babel

Babel 可以将代码解析为AST,通过分析和遍历这棵树,我们可以拿到我们想要的信息。

代码 -> AST

我们使用 @babel/parser 将代码字符串解析为 AST。

javascript 复制代码
import { parse } from '@babel/parser';

const ast = parse(code, {
  sourceType: 'module',
  plugins: ['typescript', 'jsx'], // 支持 TS 和 JSX 语法
});

例如,对于这样一段 Phaser 代码:

javascript 复制代码
class MyScene extends Phaser.Scene {
  create() {
    this.add.sprite(100, 100, 'player');
  }
}

它对应的 AST 结构(简化后)大致如下:

scss 复制代码
Program
└── ClassDeclaration (class MyScene...)
    ├── Identifier (MyScene)
    ├── Super (Phaser.Scene)
    └── ClassBody
        └── MethodDefinition (create)
            └── BlockStatement
                └── ExpressionStatement
                    └── CallExpression (this.add.sprite(...))
                        └── MemberExpression (this.add.sprite)
                            ├── MemberExpression (this.add)
                            │   ├── ThisExpression (this)
                            │   └── Identifier (add)
                            └── Identifier (sprite)

遍历 AST,捕获 Phaser API

有了 AST,我们就可以使用 @babel/traverse 遍历它,找出所有 Phaser 相关的 API 调用。我们的分析主要关注以下三种AST节点:

  1. ClassDeclaration :识别继承自 Phaser.Scene 的类,这是我们分析的起点和主要上下文。
  2. MemberExpression :捕获所有属性访问,例如 this.add.spritePhaser.GameObjects.Text,这是最常见的 API 使用方式。
  3. NewExpression :捕获构造函数调用,如 new Phaser.Game()

下面是分析过程的伪代码:

javascript 复制代码
// traverse(ast, visitors)

const visitors = {
  // 1. 识别 Phaser 场景
  ClassDeclaration: (classPath) => {
    const superClass = classPath.node.superClass;
    // 检查父类是否是 Phaser.Scene
    if (isPhaserScene(superClass)) {
      recordUsage('Scene'); // 记录 Scene 模块被使用
      isInPhaserScene = true; // 标记我们进入了 Phaser 场景的上下文
      // 继续遍历该类的内部
      classPath.traverse(sceneVisitors);
      isInPhaserScene = false; // 退出时恢复标记
    }
  },
  
  // 2. 在全局捕获 new Phaser.Game()
  NewExpression: (path) => {
    if (isNewPhaserGame(path.node)) {
      recordUsage('Game');
    }
  },
  
  // 3. 在全局捕获 Phaser.Math.Between() 等静态调用
  MemberExpression: (path) => {
    // 将节点转换为字符串路径,如 "Phaser.Math.Between"
    const memberPath = nodeToPath(path.node);
    if (memberPath.startsWith('Phaser.')) {
      recordUsage(memberPath);
    }
  }
};

const sceneVisitors = {
  // 4. 在场景内部,捕获 this.add.sprite() 等调用
  MemberExpression: (path) => {
    // 检查是否是 this.xxx 的形式
    if (isInPhaserScene && path.node.object.type === 'ThisExpression') {
      analyzeThisChain(path.node); // 分析 this 调用链
    }
  }
};

对于 this.add.sprite 这样的链式调用,我们会:

  1. 识别到基础属性是 add。通过预设的 SCENE_CONTEXT_MAP 映射表,我们知道 this.add 对应 GameObjects.GameObjectFactory 模块。
  2. 识别到调用的方法是 sprite。我们约定 add.sprite 对应 GameObjects.Sprite 这个游戏对象本身,以及 GameObjects.Factories.Sprite 这个工厂类。

通过以上步骤,我们就能将源码中所有对 Phaser 的使用,精确地映射到其内部的模块路径上,例如 Scene, Game, GameObjects.Sprite 等等。

插件集成:Hook 调用顺序踩坑

在 Vite (Rollup) 插件中,标准的处理流程是 resolveId -> load -> transform。但这个流程在这里会遇到一个"鸡生蛋还是蛋生鸡"的悖论:

  • 我们必须在 load('phaser') 钩子中返回定制版的 Phaser 代码。
  • 但生成这段代码,需要先分析完整个项目(transform 阶段的工作),才能知道哪些模块被用到了。

为了解决这个问题,我们需要调整工作流,利用 buildStart 这个钩子:

正确的工作流: buildStart -> resolveId -> load

  1. buildStart 钩子 :在构建开始时,这个钩子会最先被触发。我们在这里遍历项目所有源文件,一次性完成对 Phaser 使用情况的全局分析,并将结果缓存起来。
  2. resolveId 钩子 :当 Vite 遇到 import 'phaser' 时,我们拦截它,并返回一个自定义的虚拟模块 ID,例如 \0phaser-optimized
  3. load 钩子 :当 Vite 请求加载 \0phaser-optimized 这个虚拟模块时,我们根据 buildStart 阶段的分析结果,动态生成定制的 Phaser 入口文件内容,并将其作为代码返回。

这样,我们就完美地解决了时序问题。

踩坑细节:处理模块依赖与边界情况

这样的方式看起来很美好,实际上phaser内部子模块之间互相依赖,会有很多报错。我已经将某些必要模块、以及模块之间的依赖关系收集清楚,并且排除导致生产环境报错的phaser webgl debug依赖,如果有需要可以自取代码 之所以没有发布一个插件到npm,是因为我还没有大规模地验证过,只在自己使用到的phaser模块中做了适配。

不过我也写了一个顶级模块过滤版本,这个版本粒度会更粗,所以shake的效果会比较差,但是也更通用,更不容易报错,有需要的小伙伴可以自取。

成果

经过插件优化后,我们的示例项目构建产物体积对比非常显著:

  • 优化前 (全量引入) : Vendor chunk Gzip前体积约为 1188KB+
  • 优化后 (自动剔除) : Vendor chunk Gzip前体积降至 690KB,节约~500KB (具体取决于项目复杂度)。

总结

通过 Babel AST 分析Vite 自定义插件,实现了一个非侵入式、全自动的 Phaser Tree-shaking 插件。这个方案不仅解决了官方手动构建方式的所有痛点,还让我们能更专注于游戏业务逻辑的开发,而无需为引擎的体积而烦恼。

如果文章有任何疏漏或错误之处,欢迎在评论区交流指正!

代码

代码1(更通用,shake能力较差,在我的场景下只shake了300kb gzip前)

typescript 复制代码
import { parse } from '@babel/parser'
import traverse from '@babel/traverse'
import * as t from '@babel/types'
import { Plugin } from 'vite'
import { glob } from 'glob'
import * as fs from 'fs/promises'
import * as path from 'path'

class PhaserUsageAnalyzer {
  usage: Map<string, Map<string, Set<string>>>

  constructor() {
    this.usage = new Map()
  }
  analyzeCode(code: string, filePath: string) {
    try {
      const ast = parse(code, {
        sourceType: 'module',
        plugins: ['typescript', 'jsx'],
      })

      traverse(ast, {
        ImportDeclaration: (p) => {
          if (p.node.source.value === 'phaser') {
            this.analyzeImportDeclaration(p.node)
          }
        },
        MemberExpression: (p) => {
          this.analyzeMemberExpression(p.node)
        },
        NewExpression: (p) => {
          this.analyzeNewExpression(p.node)
        },
        CallExpression: (p) => {
          this.analyzeCallExpression(p.node)
        },
      })
    } catch (error) {
      console.error(`[PhaserOptimizer] Error analyzing ${filePath}:`, error)
    }
  }

  analyzeImportDeclaration(node: t.ImportDeclaration) {
    node.specifiers.forEach((spec) => {
      if (t.isImportDefaultSpecifier(spec)) this.recordUsage('core', 'Phaser', 'default-import')
      else if (t.isImportSpecifier(spec)) {
        const importedName = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value
        this.recordUsage('named-import', importedName, 'direct')
      }
    })
  }

  analyzeMemberExpression(node: t.MemberExpression) {
    const code = this.nodeToCode(node)

    const rendererMatch = code.match(/^Phaser.(WEBGL|CANVAS|AUTO)$/)
    if (rendererMatch) {
      this.recordUsage('config', rendererMatch[1].toLowerCase(), 'direct-access')
      return
    }

    const phaserStaticMatch = code.match(/^Phaser.(\w+).\w+/)
    if (phaserStaticMatch) {
      this.recordUsage('static', phaserStaticMatch[1].toLowerCase(), 'member-access')
      return
    }

    const thisPropertyMatch = code.match(/^this.(\w+)/)
    if (thisPropertyMatch) {
      const mainProp = thisPropertyMatch[1]
      if (mainProp === 'constructor') return
      this.recordUsage('property', mainProp, 'member-access')
    }
  }

  analyzeNewExpression(node: t.NewExpression) {
    const callee = this.nodeToCode(node.callee)
    if (callee === 'Phaser.Game') {
      this.recordUsage('core', 'Phaser', 'new-game')
    }
  }

  analyzeCallExpression(node: t.CallExpression) {
    const code = this.nodeToCode(node.callee)
    const chainCallMatch = code.match(/^this.(\w+).(\w+)/)
    if (chainCallMatch) {
      this.recordUsage('property', chainCallMatch[1], 'call')
    }
  }

  recordUsage(category: string, feature: string, context: string) {
    if (!this.usage.has(category)) this.usage.set(category, new Map())
    const categoryMap = this.usage.get(category)!
    if (!categoryMap.has(feature)) categoryMap.set(feature, new Set())
    categoryMap.get(feature)!.add(context)
  }

  getUsage() {
    const result: Record<string, Record<string, string[]>> = {}
    this.usage.forEach((categoryMap, category) => {
      result[category] = {}
      categoryMap.forEach((contexts, feature) => {
        result[category][feature] = Array.from(contexts)
      })
    })
    return {
      features: result,
    }
  }

  nodeToCode(node: t.Node): string {
    if (t.isMemberExpression(node)) {
      const object = this.nodeToCode(node.object)
      const property = t.isIdentifier(node.property) ? node.property.name : 'computed'
      return `${object}.${property}`
    }
    if (t.isIdentifier(node)) return node.name
    if (t.isThisExpression(node)) return 'this'
    return 'unknown'
  }
}

export function phaserOptimizer(): Plugin {
  let usageAnalyzer: PhaserUsageAnalyzer
  let cachedOptimizedModule: string | null = null

  // Strategy: Module-level tree shaking inspired by phaser.js
  const TOP_LEVEL_MODULES = {
    Actions: 'actions/index.js',
    Animations: 'animations/index.js',
    BlendModes: 'renderer/BlendModes.js',
    Cache: 'cache/index.js',
    Cameras: 'cameras/index.js',
    Core: 'core/index.js',
    Class: 'utils/Class.js',
    Create: 'create/index.js',
    Curves: 'curves/index.js',
    Data: 'data/index.js',
    Display: 'display/index.js',
    DOM: 'dom/index.js',
    Events: 'events/index.js',
    FX: 'fx/index.js',
    Game: 'core/Game.js',
    GameObjects: 'gameobjects/index.js',
    Geom: 'geom/index.js',
    Input: 'input/index.js',
    Loader: 'loader/index.js',
    Math: 'math/index.js',
    Physics: 'physics/index.js',
    Plugins: 'plugins/index.js',
    Renderer: 'renderer/index.js',
    Scale: 'scale/index.js',
    ScaleModes: 'renderer/ScaleModes.js',
    Scene: 'scene/Scene.js',
    Scenes: 'scene/index.js',
    Structs: 'structs/index.js',
    Textures: 'textures/index.js',
    Tilemaps: 'tilemaps/index.js',
    Time: 'time/index.js',
    Tweens: 'tweens/index.js',
    Utils: 'utils/index.js',
    Sound: 'sound/index.js',
  }

  const USAGE_TO_MODULE_MAP = {
    property: {
      add: 'GameObjects',
      make: 'GameObjects',
      tweens: 'Tweens',
      time: 'Time',
      load: 'Loader',
      input: 'Input',
      physics: 'Physics',
      sound: 'Sound',
      cameras: 'Cameras',
      anims: 'Animations',
      plugins: 'Plugins',
      scale: 'Scale',
      cache: 'Cache',
      textures: 'Textures',
      events: 'Events',
      data: 'Data',
      renderer: 'Renderer',
    },
    static: {
      math: 'Math',
      geom: 'Geom',
      // ... Can be extended if other static properties are used
    },
    'named-import': {
      Scene: 'Scene',
      Game: 'Game',
    },
  }

  // Core modules that are almost always necessary for a Phaser game to run
  const ALWAYS_INCLUDE = new Set([
    'Class',
    'Core',
    'Game',
    'Events',
    'Scenes',
    'Scene',
    'Utils',
    'GameObjects',
    'Cameras',
  ])

  const generateOptimizedPhaserModule = () => {
    const usage = usageAnalyzer.getUsage()
    const requiredModules = new Set<string>(ALWAYS_INCLUDE)

    // Analyze properties (e.g., this.tweens)
    const props = usage.features.property || {}
    // eslint-disable-next-line
    for (const p of Object.keys(props)) {
      // @ts-ignore
      if (USAGE_TO_MODULE_MAP.property[p]) {
        // @ts-ignore
        requiredModules.add(USAGE_TO_MODULE_MAP.property[p])
      }
    }

    // Analyze static access (e.g., Phaser.Math)
    const statics = usage.features.static || {}
    // eslint-disable-next-line
    for (const s of Object.keys(statics)) {
      // @ts-ignore
      if (USAGE_TO_MODULE_MAP.static[s]) {
        // @ts-ignore
        requiredModules.add(USAGE_TO_MODULE_MAP.static[s])
      }
    }

    // Analyze named imports (e.g., import { Scene })
    const namedImports = usage.features['named-import'] || {}
    // eslint-disable-next-line
    for (const i of Object.keys(namedImports)) {
      // @ts-ignore
      if (USAGE_TO_MODULE_MAP['named-import'][i]) {
        // @ts-ignore
        requiredModules.add(USAGE_TO_MODULE_MAP['named-import'][i])
      }
    }

    // The 'type' in game config implies renderer and scale modes
    if (usage.features.config) {
      requiredModules.add('Renderer')
      requiredModules.add('ScaleModes')
    }

    console.log('\n--- Phaser Optimizer (New Strategy) ---')
    console.log('[+] Required Modules:', Array.from(requiredModules).sort())

    const allModules = Object.keys(TOP_LEVEL_MODULES)
    const excludedModules = allModules.filter((m) => !requiredModules.has(m))
    console.log('[-] Excluded Modules:', excludedModules.sort())
    console.log('-------------------------------------\n')

    const includedEntries = Object.entries(TOP_LEVEL_MODULES).filter(([name]) => requiredModules.has(name))

    const imports = includedEntries.map(([name, p]) => `import ${name} from 'phaser/src/${p}';`).join('\n')
    const phaserObjectProperties = includedEntries.map(([name]) => `  ${name}`).join(',\n')

    const moduleContent = `
// === Optimised Phaser Module (Generated by vite-plugin-phaser-optimizer) ===
${imports}
import CONST from 'phaser/src/const.js';
import Extend from 'phaser/src/utils/object/Extend.js';

var Phaser = {
${phaserObjectProperties}
};

// Merge in the consts
Phaser = Extend(false, Phaser, CONST);

export default Phaser;
`
    return moduleContent
  }

  return {
    name: 'vite-plugin-phaser-optimizer-new',
    enforce: 'pre',

    config() {
      // 告诉 Vite 如何解析我们生成的深度导入
      return {
        resolve: {
          alias: {
            'phaser/src': path.resolve(process.cwd(), 'node_modules/phaser/src'),
          },
        },
      }
    },

    async buildStart() {
      usageAnalyzer = new PhaserUsageAnalyzer()
      cachedOptimizedModule = null
      console.log('🎮 Phaser Optimizer: Analyzing project with new strategy...')

      const files = await glob('src/**/*.{ts,tsx,js,jsx}', {
        ignore: 'node_modules/**',
      })

      await Promise.all(
        files.map(async (id: string) => {
          try {
            const code = await fs.readFile(id, 'utf-8')
            if (code.includes('phaser') || code.includes('Phaser')) {
              usageAnalyzer.analyzeCode(code, id)
            }
          } catch (e) {
            // ...
          }
        }),
      )

      cachedOptimizedModule = generateOptimizedPhaserModule()
    },

    resolveId(id) {
      if (id === 'phaser') {
        return '\0phaser-optimized'
      }
      return null
    },

    load(id) {
      if (id === '\0phaser-optimized') {
        return cachedOptimizedModule
      }
      return null
    },

    transform(code, id) {
      if (id.includes('renderer/webgl/WebGLRenderer.js')) {
        const pattern = /if\s*(typeof WEBGL_DEBUG)\s*{[\s\S]*?require('phaser3spectorjs')[\s\S]*?}/g
        return {
          code: code.replace(pattern, ''),
          map: null,
        }
      }
      return null
    },
  }
}

代码2,更细粒度的shake,可能需要对map做一些额外的适配,避免生产环境中的空指针

typescript 复制代码
import { parse } from '@babel/parser'
import traverse from '@babel/traverse'
import * as t from '@babel/types'
import { Plugin } from 'vite'
import { glob } from 'glob'
import * as fs from 'fs/promises'
import * as path from 'path'

// Maps Phaser Scene properties (e.g., this.add) to their corresponding Phaser modules.
const SCENE_CONTEXT_MAP: Record<string, string> = {
  add: 'GameObjects.GameObjectFactory',
  make: 'GameObjects.GameObjectCreator',
  events: 'Events',
  game: 'Game',
  input: 'Input',
  load: 'Loader.LoaderPlugin',
  plugins: 'Plugins.PluginManager',
  registry: 'Data.DataManager',
  scale: 'Scale.ScaleManager',
  sound: 'Sound',
  textures: 'Textures.TextureManager',
  time: 'Time.Clock',
  tweens: 'Tweens.TweenManager',
  anims: 'Animations.AnimationManager',
  cameras: 'Cameras.Scene2D.CameraManager',
  data: 'Data.DataManager',
  sys: 'Scenes.Systems',
}

class PhaserUsageAnalyzer {
  usage: Set<string>
  private isInPhaserScene: boolean

  constructor() {
    this.usage = new Set()
    this.isInPhaserScene = false
  }
  analyzeCode(code: string, filePath: string) {
    try {
      const ast = parse(code, {
        sourceType: 'module',
        plugins: ['typescript', 'jsx'],
      })

      traverse(ast, {
        ClassDeclaration: (classPath) => {
          const superClass = classPath.node.superClass ? this.nodeToCode(classPath.node.superClass) : null
          const wasInScene = this.isInPhaserScene
          // Check for `extends Phaser.Scene` or `extends Scene` (if imported)
          if (superClass && superClass.endsWith('Scene')) {
            this.recordUsage('Scene')
            this.isInPhaserScene = true
          }
          classPath.traverse(this.visitors)
          this.isInPhaserScene = wasInScene
        },
      })
    } catch (error) {
      console.error(`[PhaserOptimizer] Error analyzing ${filePath}:`, error)
    }
  }

  // Define visitors for traversal inside a class
  private visitors = {
    MemberExpression: (p: any) => {
      this.analyzeMemberExpression(p.node)
    },
    NewExpression: (p: any) => {
      const callee = this.nodeToCode(p.node.callee)
      if (callee === 'Phaser.Game') {
        this.recordUsage('Game')
      }
    },
  }

  analyzeMemberExpression(node: t.MemberExpression) {
    const memberPath = this.getPhaserPath(node)
    if (memberPath) {
      // New: if it's a math/geom path, just record the parent as they are complex objects
      if (memberPath.startsWith('Phaser.Math.')) {
        this.recordUsage('Phaser.Math')
      } else if (memberPath.startsWith('Phaser.Geom.')) {
        this.recordUsage('Phaser.Geom')
      } else {
        this.recordUsage(memberPath)
      }
      return
    }

    if (this.isInPhaserScene) {
      // New: Smartly analyze chains like `this.add.rectangle`
      let currentNode: t.Node = node
      const chain: string[] = []
      while (t.isMemberExpression(currentNode) && t.isIdentifier(currentNode.property)) {
        chain.unshift(currentNode.property.name)
        currentNode = currentNode.object
      }

      if (t.isThisExpression(currentNode)) {
        const baseProp = chain[0]
        if (baseProp && SCENE_CONTEXT_MAP[baseProp]) {
          this.recordUsage(SCENE_CONTEXT_MAP[baseProp])
        }

        if ((baseProp === 'add' || baseProp === 'make') && chain.length > 1) {
          const goName = chain[1]
          // Capitalize the first letter, e.g., "rectangle" -> "Rectangle"
          const capitalizedGoName = goName.charAt(0).toUpperCase() + goName.slice(1)

          this.recordUsage(`GameObjects.${capitalizedGoName}`)

          if (baseProp === 'add') {
            this.recordUsage(`GameObjects.Factories.${capitalizedGoName}`)
          }

          if (baseProp === 'make') {
            this.recordUsage(`GameObjects.Creators.${capitalizedGoName}`)
          }
        }
      }
    }
  }

  getPhaserPath(node: t.Node): string | null {
    if (t.isMemberExpression(node)) {
      const propertyName = t.isIdentifier(node.property) ? node.property.name : null
      if (!propertyName) return null

      const parentPath = this.getPhaserPath(node.object)
      if (parentPath) {
        return `${parentPath}.${propertyName}`
      }
    } else if (t.isIdentifier(node) && node.name === 'Phaser') {
      return 'Phaser'
    }
    return null
  }

  recordUsage(p: string) {
    // We only care about the path from Phaser, e.g., "GameObjects.Sprite" from "Phaser.GameObjects.Sprite"
    const cleanedPath = p.replace(/^Phaser./, '')
    this.usage.add(cleanedPath)
  }

  getUsage() {
    return Array.from(this.usage)
  }

  nodeToCode(node: t.Node): string {
    if (t.isMemberExpression(node)) {
      const object = this.nodeToCode(node.object)
      const property = t.isIdentifier(node.property) ? node.property.name : 'computed'
      return `${object}.${property}`
    }
    if (t.isIdentifier(node)) return node.name
    if (t.isThisExpression(node)) return 'this'
    return 'unknown'
  }
}

export function phaserOptimizer(): Plugin {
  let usageAnalyzer: PhaserUsageAnalyzer
  let cachedOptimizedModule: string | null = null

  // A detailed, nested map based on the official phaser.js structure
  const PHASER_MODULE_MAP = {
    Animations: 'animations/index.js',
    BlendModes: 'renderer/BlendModes.js',
    Cache: 'cache/index.js',
    Cameras: { Scene2D: 'cameras/2d/index.js' },
    Core: 'core/index.js',
    Class: 'utils/Class.js',
    Data: 'data/index.js',
    Display: { Masks: 'display/mask/index.js' },
    DOM: 'dom/index.js',
    Events: {
      EventEmitter: 'events/EventEmitter.js',
    },
    FX: 'fx/index.js',
    Game: 'core/Game.js',
    GameObjects: {
      DisplayList: 'gameobjects/DisplayList.js',
      GameObjectCreator: 'gameobjects/GameObjectCreator.js',
      GameObjectFactory: 'gameobjects/GameObjectFactory.js',
      UpdateList: 'gameobjects/UpdateList.js',
      Components: 'gameobjects/components/index.js',
      BuildGameObject: 'gameobjects/BuildGameObject.js',
      BuildGameObjectAnimation: 'gameobjects/BuildGameObjectAnimation.js',
      GameObject: 'gameobjects/GameObject.js',
      Graphics: 'gameobjects/graphics/Graphics.js',
      Image: 'gameobjects/image/Image.js',
      Layer: 'gameobjects/layer/Layer.js',
      Container: 'gameobjects/container/Container.js',
      Rectangle: 'gameobjects/shape/rectangle/Rectangle.js',
      Sprite: 'gameobjects/sprite/Sprite.js',
      Text: 'gameobjects/text/Text.js',
      Factories: {
        Graphics: 'gameobjects/graphics/GraphicsFactory.js',
        Image: 'gameobjects/image/ImageFactory.js',
        Layer: 'gameobjects/layer/LayerFactory.js',
        Container: 'gameobjects/container/ContainerFactory.js',
        Rectangle: 'gameobjects/shape/rectangle/RectangleFactory.js',
        Sprite: 'gameobjects/sprite/SpriteFactory.js',
        Text: 'gameobjects/text/TextFactory.js',
      },
      Creators: {
        Graphics: 'gameobjects/graphics/GraphicsCreator.js',
        Image: 'gameobjects/image/ImageCreator.js',
        Layer: 'gameobjects/layer/LayerCreator.js',
        Container: 'gameobjects/container/ContainerCreator.js',
        Rectangle: 'gameobjects/shape/rectangle/RectangleCreator.js',
        Sprite: 'gameobjects/sprite/SpriteCreator.js',
        Text: 'gameobjects/text/TextCreator.js',
      },
    },
    Geom: 'geom/index.js',
    Input: 'input/index.js',
    Loader: {
      LoaderPlugin: 'loader/LoaderPlugin.js',
      FileTypes: {
        AnimationJSONFile: 'loader/filetypes/AnimationJSONFile.js',
        AtlasJSONFile: 'loader/filetypes/AtlasJSONFile.js',
        AudioFile: 'loader/filetypes/AudioFile.js',
        AudioSpriteFile: 'loader/filetypes/AudioSpriteFile.js',
        HTML5AudioFile: 'loader/filetypes/HTML5AudioFile.js',
        ImageFile: 'loader/filetypes/ImageFile.js',
        JSONFile: 'loader/filetypes/JSONFile.js',
        MultiAtlasFile: 'loader/filetypes/MultiAtlasFile.js',
        PluginFile: 'loader/filetypes/PluginFile.js',
        ScriptFile: 'loader/filetypes/ScriptFile.js',
        SpriteSheetFile: 'loader/filetypes/SpriteSheetFile.js',
        TextFile: 'loader/filetypes/TextFile.js',
        XMLFile: 'loader/filetypes/XMLFile.js',
      },
      File: 'loader/File.js',
      FileTypesManager: 'loader/FileTypesManager.js',
      GetURL: 'loader/GetURL.js',
      MergeXHRSettings: 'loader/MergeXHRSettings.js',
      MultiFile: 'loader/MultiFile.js',
      XHRLoader: 'loader/XHRLoader.js',
      XHRSettings: 'loader/XHRSettings.js',
    },
    Math: 'math/index.js',
    Plugins: 'plugins/index.js',
    Renderer: 'renderer/index.js',
    Scale: 'scale/index.js',
    ScaleModes: 'renderer/ScaleModes.js',
    Scene: 'scene/Scene.js',
    Scenes: {
      ScenePlugin: 'scene/ScenePlugin.js',
    },
    Structs: 'structs/index.js',
    Textures: 'textures/index.js',
    Time: {
      Clock: 'time/Clock.js',
    },
    Tweens: {
      TweenManager: 'tweens/TweenManager.js',
    },
    Sound: 'sound/index.js', // Added based on conditional require
  }

  // Core modules that are almost always necessary for a Phaser game to run
  const ALWAYS_INCLUDE = new Set([
    'Game',
    'Core',
    'Events',
    'Scenes.Systems',
    'Scenes.ScenePlugin',
    'Scene',
    'GameObjects.Components',
    'GameObjects.GameObjectFactory',
    'GameObjects.UpdateList',
    'GameObjects.DisplayList',
    'Loader.LoaderPlugin',
    'Loader.FileTypes.AnimationJSONFile',
    'Loader.FileTypes.AtlasJSONFile',
    'Loader.FileTypes.AudioFile',
    'Loader.FileTypes.AudioSpriteFile',
    'Loader.FileTypes.HTML5AudioFile',
    'Loader.FileTypes.ImageFile',
    'Loader.FileTypes.JSONFile',
    'Loader.FileTypes.MultiAtlasFile',
    'Loader.FileTypes.PluginFile',
    'Loader.FileTypes.ScriptFile',
    'Loader.FileTypes.SpriteSheetFile',
    'Loader.FileTypes.TextFile',
    'Loader.FileTypes.XMLFile',
  ])

  const generateOptimizedPhaserModule = () => {
    const detectedPaths = usageAnalyzer.getUsage()
    const requiredPaths = new Set<string>(ALWAYS_INCLUDE)
    detectedPaths.forEach((p) => requiredPaths.add(p))

    console.log('\n--- Phaser Optimizer ---')
    console.log('[+] Detected Usage Paths:', detectedPaths.sort())

    const imports: string[] = []
    const phaserStructure: any = {}

    // Function to traverse the map and find the corresponding path
    const findPathInMap = (map: any, pathParts: string[]): string | null => {
      const result = pathParts.reduce((acc, part) => {
        if (acc === null) return null
        return acc[part] !== undefined ? acc[part] : null
      }, map)
      return typeof result === 'string' ? result : null
    }

    // Function to build the nested structure for the final Phaser object
    const buildNestedObject = (obj: any, pathParts: string[], moduleName: string) => {
      let current = obj
      for (let i = 0; i < pathParts.length - 1; i++) {
        const part = pathParts[i]
        if (!current[part]) {
          current[part] = {}
        }
        current = current[part]
      }
      current[pathParts[pathParts.length - 1]] = moduleName
    }

    const importedModules = new Map<string, string>()

    requiredPaths.forEach((modulePath) => {
      const parts = modulePath.split('.')
      const resolvedModulePath = findPathInMap(PHASER_MODULE_MAP, parts)

      if (resolvedModulePath) {
        // Create a unique, valid variable name for the import
        const moduleName = `Phaser_${parts.join('_')}`
        if (!importedModules.has(resolvedModulePath)) {
          // No more path guessing, use the explicit path from the map
          imports.push(`import ${moduleName} from 'phaser/src/${resolvedModulePath}';`)
          importedModules.set(resolvedModulePath, moduleName)
        }
        buildNestedObject(phaserStructure, parts, importedModules.get(resolvedModulePath)!)
      }
    })

    const includedModulePaths = Array.from(importedModules.keys())
    console.log('[+] Included Modules:', includedModulePaths.sort())

    // New logic for excluded modules
    const allPossibleModulePaths = new Set<string>()
    const flatten = (obj: any) => {
      Object.values(obj).forEach((value) => {
        if (typeof value === 'string') {
          allPossibleModulePaths.add(value)
        } else if (typeof value === 'object' && value !== null) {
          flatten(value)
        }
      })
    }
    flatten(PHASER_MODULE_MAP)

    const excludedModulePaths = [...allPossibleModulePaths].filter((p) => !includedModulePaths.includes(p))
    console.log('[-] Excluded Modules:', excludedModulePaths.sort())

    // Function to recursively generate the Phaser object string
    const generateObjectString = (obj: any, indent = '  '): string => {
      const entries: string[] = Object.entries(obj).map(([key, value]) => {
        if (typeof value === 'string') {
          return `${indent}${key}: ${value}`
        }
        return `${indent}${key}: {\n${generateObjectString(value, `${indent}  `)}\n${indent}}`
      })
      return entries.join(',\n')
    }

    const moduleContent = `
// === Optimised Phaser Module (Generated by vite-plugin-phaser-optimizer) ===
${imports.join('\n')}
import CONST from 'phaser/src/const.js';
import Extend from 'phaser/src/utils/object/Extend.js';

var Phaser = {
${generateObjectString(phaserStructure)}
};

// Merge in the consts
Phaser = Extend(false, Phaser, CONST);

export default Phaser;
globalThis.Phaser = Phaser;
`
    console.log('------------------------\n')
    return moduleContent
  }

  return {
    name: 'vite-plugin-phaser-optimizer',
    enforce: 'pre',

    config() {
      // 告诉 Vite 如何解析我们生成的深度导入
      return {
        resolve: {
          alias: {
            'phaser/src': path.resolve(process.cwd(), 'node_modules/phaser/src'),
          },
        },
      }
    },

    async buildStart() {
      usageAnalyzer = new PhaserUsageAnalyzer()
      cachedOptimizedModule = null
      console.log('🎮 Phaser Optimizer: Analyzing project...')

      const files = await glob('src/**/*.{ts,tsx,js,jsx}', {
        ignore: 'node_modules/**',
      })

      await Promise.all(
        files.map(async (id: string) => {
          try {
            const code = await fs.readFile(id, 'utf-8')
            if (code.includes('Phaser')) {
              usageAnalyzer.analyzeCode(code, id)
            }
          } catch (e) {
            // ...
          }
        }),
      )

      cachedOptimizedModule = generateOptimizedPhaserModule()
    },

    resolveId(id) {
      if (id === 'phaser') {
        return '\0phaser-optimized'
      }
      return null
    },

    load(id) {
      if (id === '\0phaser-optimized') {
        return cachedOptimizedModule
      }
      return null
    },

    transform(code, id) {
      if (id.includes('renderer/webgl/WebGLRenderer.js')) {
        const pattern = /if\s*(typeof WEBGL_DEBUG)\s*{[\s\S]*?require('phaser3spectorjs')[\s\S]*?}/g
        return {
          code: code.replace(pattern, ''),
          map: null,
        }
      }
      return null
    },
  }
}
相关推荐
程序视点1 小时前
Escrcpy 3.0投屏控制软件使用教程:无线/有线连接+虚拟显示功能详解
前端·后端
silent_missile1 小时前
element-plus穿梭框transfer的调整
前端·javascript·vue.js
专注VB编程开发20年1 小时前
OpenXml、NPOI、EPPlus、Spire.Office组件对EXCEL ole对象附件的支持
前端·.net·excel·spire.office·npoi·openxml·spire.excel
古蓬莱掌管玉米的神1 小时前
coze娱乐ai换脸
前端
GIS之路2 小时前
GeoTools 开发合集(全)
前端
咖啡の猫2 小时前
Shell脚本-嵌套循环应用案例
前端·chrome
一点一木2 小时前
使用现代 <img> 元素实现完美图片效果(2025 深度实战版)
前端·css·html
萌萌哒草头将军3 小时前
🚀🚀🚀 告别复制粘贴,这个高效的 Vite 插件让我摸鱼🐟时间更充足了!
前端·vite·trae
布列瑟农的星空3 小时前
大话设计模式——关注点分离原则下的事件处理
前端·后端·架构
山有木兮木有枝_3 小时前
node文章生成器
javascript·node.js