为 Phaser 打造一个 Vite 自动 Tree-shaking 插件
前言
Phaser 是一款功能强大的 HTML5 游戏开发引擎,深受广大 Web 游戏开发者的喜爱。然而,它有一个众所周知的痛点:不支持 Tree-shaking。这意味着即使你的游戏只用到了引擎的一小部分功能,最终打包时也必须引入整个 Phaser 库,导致项目体积异常臃肿。对于追求极致加载速度的小游戏而言,这无疑是难以接受的。
本文将分享如何利用 Vite 和 Babel,从零开始打造一个插件,实现对 Phaser 的自动化、智能化 Tree-shaking,让我们的项目彻底告别不必要的代码,大幅优化加载性能。
为什么 Phaser 不支持 Tree-shaking?
现代前端打包工具(如 Vite、Webpack)的 Tree-shaking 功能依赖于 ES6 模块的静态结构。然而,Phaser 的架构使其无法从中受益。
// 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 官方提供了一种手动剔除模块的自定义构建方案。其原理是在构建时,通过修改入口文件,手动注释掉不需要的大模块。
楼主之前也进行了实践:自定义构建方案实践
// phaser/src/phaser.js (入口文件简化示例)
var Phaser = {
Actions: require('./actions'),
Animations: require('./animations'),
// ... 其他模块
// 手动注释掉物理引擎模块
// Physics: require('./physics'),
// 手动注释掉插件管理模块
// Plugins: require('./plugins'),
// ...
};
这种方式虽然能减少体积,但操作起来却非常痛苦,有以下几个致命缺陷:
-
维护成本高:每次升级 Phaser 版本,都需要重新手动构建一次。
-
复用性差:不同项目用到的模块不同,就需要为每个项目维护一个定制版引擎。
-
开发流程繁琐:开发过程中新增了某个模块的功能,就必须重新构建,打断开发心流。
-
依赖关系复杂: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。
import { parse } from '@babel/parser';
const ast = parse(code, {
sourceType: 'module',
plugins: ['typescript', 'jsx'], // 支持 TS 和 JSX 语法
});
例如,对于这样一段 Phaser 代码:
class MyScene extends Phaser.Scene {
create() {
this.add.sprite(100, 100, 'player');
}
}
它对应的 AST 结构(简化后)大致如下:
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节点:
-
ClassDeclaration
:识别继承自 Phaser.Scene
的类,这是我们分析的起点和主要上下文。
-
MemberExpression
:捕获所有属性访问,例如 this.add.sprite
或 Phaser.GameObjects.Text
,这是最常见的 API 使用方式。
-
NewExpression
:捕获构造函数调用,如 new Phaser.Game()
。
下面是分析过程的伪代码:
// 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
这样的链式调用,我们会:
- 识别到基础属性是
add
。通过预设的 SCENE_CONTEXT_MAP
映射表,我们知道 this.add
对应 GameObjects.GameObjectFactory
模块。
- 识别到调用的方法是
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
-
buildStart
钩子:在构建开始时,这个钩子会最先被触发。我们在这里遍历项目所有源文件,一次性完成对 Phaser 使用情况的全局分析,并将结果缓存起来。
-
resolveId
钩子:当 Vite 遇到 import 'phaser'
时,我们拦截它,并返回一个自定义的虚拟模块 ID,例如 \0phaser-optimized
。
-
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前)
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做一些额外的适配,避免生产环境中的空指针
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
},
}
}