大概是在去年看到群友在抱怨为什么 vite 社区没有一个好用的bundle-analyzer-tool
,于是在我实现了一个接近于webpack-bundle-analyzer
的工具以后 对此也有一些碎碎念。
为什么需要新的 tool
正如文章开头所述 vite 社区现存的analyzer tool
相比 webpack 是非常不直观的,例如 rollup-plugin-visualizer
,vite-plugin-visualizer
等诸如此类的工具 我们不免发现一个问题那就是华丽的图表不是必须的而相对精确以及可观的模块才是我们需要的。这里以rollup-plugin-visualizer
为例 我们能看到和vite
相关联的 issue 都指向了一个问题那就是stat size
是不准确的以及收集到的模块数据也是错误的,当然了 这也和 rollup
的工作方式有关系。
实现 analyzer tool
根据我们使用过各种 analyzer tool
能观测到的结果来看我们注重的数据是每个 chunk
里面包含的模块他们的体积是多少,每个 chunk
是否包含了重复的模块。 好了透过这一点我们就清楚了插件应该如何实现。使用 generateBundle 我们可以轻易的获取每个chunk
所包含的基本信息。这里不会在做过多的阐述。
这里贴上所需要关注的数据签名
interface OutputAsset {
fileName: string
name?: string
needsCodeReference: boolean
source: string | Uint8Array
type: 'asset'
}
interface OutputChunk {
code: string
dynamicImports: string[]
exports: string[]
facadeModuleId: string | null
fileName: string
implicitlyLoadedBefore: string[]
imports: string[]
importedBindings: { [imported: string]: string[] }
isDynamicEntry: boolean
isEntry: boolean
isImplicitEntry: boolean
map: SourceMap | null
modules: {
[id: string]: {
renderedExports: string[],
removedExports: string[],
renderedLength: number,
originalLength: number,
code: string | null
}
}
moduleIds: string[]
name: string
preliminaryFileName: string
referencedFiles: string[]
sourcemapFileName: string | null
type: 'chunk'
}
从这段签名来看我们只需要关注type
,code
,moduleIds
,map
即可。至于剩下的属性我会在后面慢慢阐述为什么我们不需要这些或者说为什么我们选择了这 4 个属性。
Note
vite 在处理 suffix import 的时候会把资源当作OutputAsset
去做处理 这时候我们就需要手动去判断我们的这个chunk
到底是不是 javaScript 文件。因此我们需要编写一个方法去判断这些逻辑
// 返回的结果第一个参数是判断是不是chunk,第二个参数代表sourceMap的文件名
function validateChunk(
chunk: OutputAsset | OutputChunk,
allChunks: OutputBundle
): [boolean, string | null] {
const { type } = chunk
// 因为suffix import 在 generateBundle 阶段得到的是以js作为结尾的文件(尽管源文件是typescript)
if (type === 'asset' && path.extname(chunk.fileName) === '.js') {
const possiblePath = chunk.fileName + '.map'
if (possiblePath in allChunks) { return [true, possiblePath] }
return [true, null]
}
const isChunk = type === 'chunk'
return [isChunk, isChunk ? chunk.sourcemapFileName : null]
}
有了这样的一个工具方法我们就可以在generateBundle
里面迭代我们的bundle info
。但是有细心的同学可能会有疑问你要拿到sourceMap
不是需要用户指定开启这一配置才行吗?因此我们需要对这里做一个用户无感知的 hack 让我能拿到 sourcemap
的同时也不破坏用户的生成结果。
let previousSourcemapOption: boolean = false
return {
config(config) {
if (config.build?.sourcemap) {
previousSourcemapOption = typeof config.build.sourcemap === 'boolean'
? config.build.sourcemap
// sourcemap 可以为 true | false | 'hidden' | 'inline'
: config.build.sourcemap === 'hidden'
}
if (!config.build) {
config.build = {}
}
if (typeof config.build.sourcemap === 'boolean' && config.build.sourcemap) {
// 如果为true 就保持原样
config.build.sourcemap = true
} else {
// 设置为 hidden
config.build.sourcemap = 'hidden'
}
},
generateBundle(_, ouputBundle) {
for (const bundleName in outputBundle) {
const bundle = outputBundle[bundleName]
const [pass, sourcemapFileName] = validateChunk(bundle, outputBundle)
if (pass && sourcemapFileName) {
// TODO
}
// 恢复结果
if (!previousSourcemapOption) {
if (pass && sourcemapFileName) {
Reflect.deleteProperty(outputBundle, sourcemapFileName)
}
}
}
}
}
完成了上述的逻辑这时候我们只需要实现一个模块去记录这些chunk
并生成对应的文件树即可。
实现 analyzer Module
我们需要实现一个模块去管理这些模块,应该还记得我们之前提到的code
,moduleIds
以及map
吗?这一小节我们将会阐述他们的作用。
function wrapBundleChunk(
bundle: OutputChunk | OutputAsset,
chunks: OutputBundle,
sourcemapFileName: string
) {
const wrapped = <WrappedChunk> {}
const isChunk = bundle.type === 'chunk'
wrapped.code = stringToByte(isChunk ? bundle.code : bundle.source)
wrapped.map = findSourcemap(bundle.fileName, sourcemapFileName, chunks)
wrapped.imports = isChunk ? bundle.imports : []
wrapped.dynamicImports = isChunk ? bundle.dynamicImports : []
wrapped.moduleIds = isChunk ? bundle.moduleIds : []
wrapped.filename = bundle.fileName
wrapped.isEntry = isChunk ? bundle.isEntry : false
return wrapped
}
export class AnalyzerModule {
constructor() {
// 存放模块合集
this.modules = []
// generateBundle 里面的bundles
this.chunks = {}
}
// addModule 就是我们添加模块的方法
addModule(bundle: OutputAsset | OutputChunk, sourcemapFileName: string) {
// 生成一个统一的bundle信息抹平 suffix import 和 常规chunk的差异
const wrapped = wrapBundleChunk(bundle, this.chunks, sourcemapFileName)
}
}
export function createAnalyzerModule() {
return new AnalyzerModule()
}
// ... generateBundle
const analyzerModule = createAnalyzerModule()
analyzerMoudule.addModule(bundle, sourcemapFileName)
这样我们就有了一个简易的analyzerModule
去管理我们的模块。同时我们也在添加模块的时候抹平了每个 chunk 的差异这时候我们只需要给this.modules
里添加节点就行了。我们在使用webpack-bundle-analyzer
可以得知内容被分成 2 部分一部分是stats
另一部分是parsed
。处理stats
的流程就比较容易了。 我们只需要处理moduleIds
并且通过rollupContext
提供的getModuleInfo
去获取每个模块的内容。
Important
经过我的测试我能得出一个结论那就是通过上述方式获取的模块信息比直接从modules
里面获取的 code 更为准确。modules
里面的code
似乎不包含import statement
这一点会影响我们的计算结果。同时suffix import
是没有moduleIds
的这时候我们需要通过 sourcemap 提供的content
去映射 出每个模块的code block
。
const KNOWN_EXT_NAME = [
'.mjs',
'.js',
'.cjs',
'.ts',
'.tsx',
'.vue',
'.svelte',
'.md',
'.mdx'
]
const infomations = moduleIds.length
? moduleIds.reduce((acc, cur) => {
const info = pluginContext.getModuleInfo(cur)
if (info && info.code) {
// 我们在统计stat的时候是不需要关注style的情况的
if (
KNOWN_EXT_NAME.includes(path.extname(info.id)) ||
info.id.startsWith('\0')
) {
acc.push({ id: info.id, code: info.code })
}
}
return acc
}, [] as Array<ChunkMetadata>)
: await convertSourcemapToContents(map)
既然我们获取了引用的模块信息那么我们只需要迭代他们并且推送到 stat 里面记录大小即可。parsed
我们也可以依葫芦画瓢的方式去获取(getModuleInfo) 获取的内容是没有经过terser
这些压缩器去压缩的因此我们需要通过sourcemap
去反映射压缩后的产物。
function getStringFromSerializeMappings(
bytes: Uint8Array[],
mappings: Array<Loc>,
decoder: TextDecoder
) {
const mappingsWithLine: Record<number, Array<Loc>> = {}
let parsedString = ''
for (const mapping of mappings) {
const { generatedLine } = mapping
if (!(generatedLine in mappingsWithLine)) {
mappingsWithLine[generatedLine] = []
}
mappingsWithLine[generatedLine].push(mapping)
}
for (const line in mappingsWithLine) {
const l = parseInt(line) - 1
if (bytes[l]) {
const runes = decoder.decode(bytes[l])
const mappings = mappingsWithLine[line]
const [first, ...rest] = mappings
const end = rest[rest.length - 1]
// 如果没有结束符 证明整个source只有一个内容
if (!end) {
parsedString += runes.substring(first.generatedColumn)
} else {
// 如果没有结束符 证明整个source只有一个内容
if (typeof end.lastGeneratedColumn !== 'number') {
parsedString += runes.substring(first.generatedColumn)
} else {
// 截取字符 range
parsedString += runes.substring(
first.generatedColumn,
end.lastGeneratedColumn ?? end.generatedColumn
)
}
}
}
}
return parsedString
}
export async function getSourceMappings(
code: Uint8Array,
rawSourceMap: string,
formatter: (id: string) => string
) {
const hints: Record<string, string> = {}
const bytes = splitBytesByNewLine(code)
const promises: Array<[() => string, MappingItem]> = []
const decoder = new TextDecoder()
const consumer = await new SourceMapConsumer(rawSourceMap)
consumer.eachMapping(
(mapping) => {
if (mapping.source) { promises.push([() => formatter(mapping.source), mapping]) }
},
null,
SourceMapConsumer.ORIGINAL_ORDER
)
const mappings = promises.map(([fn, mapping]) => {
const id = fn()
return { mapping, id }
})
const sortedMappings = mappings.reduce((acc, cur) => {
if (!acc[cur.id]) {
acc[cur.id] = {
mappings: []
}
}
acc[cur.id].mappings.push(cur.mapping as any)
return acc
}, {} as Record<string, { mappings: Array<Loc> }>)
for (const key in sortedMappings) {
sortedMappings[key].mappings.sort(
(a, b) => a.generatedColumn - b.generatedColumn
)
const { mappings } = sortedMappings[key]
if (mappings.length > 0) {
const s = getStringFromSerializeMappings(bytes, mappings, decoder)
hints[key] = s
}
}
consumer.destroy()
return hints
}
值得注意的是在 js 世界中字符串的存储是以UTF-16
的方式进行的而我们的 code 是以UTF-8
去表示的我们在获range
的时候应该以字符偏移量而不是字节偏移量。(一个小插曲 terser
似乎并不能处理No-BMP
字符以及没有处理正确的generateColumn
会导致我们的解析结果错误。) 在完成了上述了工作我们就能获取到每个module
包含的stat
,parsed
信息只需要把他们分别组装成一个 tree 就能得到我们想要的结果。
结语
至此插件的主体逻辑已经剖析完,这一篇文章也仅仅只是我的碎碎念如果您对项目感兴趣可以查看vite-bundle-analyzer 获取更多细节。