webpack-split-plugin
Version:
custom code splitting in webpack [wip]
585 lines (523 loc) • 18.3 kB
JavaScript
const log = require('fliplog')
const Cleaner = require('./cleaner')
const CollectionManager = require('./CollectionManager')
const collection = new CollectionManager()
let nextIdent = 0
class WebpackSplitPlugin {
constructor(options) {
this.shouldDebug = options.debug || false
collection.debug(options.debug || false)
if (options.totalSize) {
let totalSize = options.totalSize
if (typeof totalSize === 'string') {
if (totalSize.includes('kb')) {
totalSize = totalSize.split('kb').shift() * 1000
} else if (totalSize.includes('mb')) {
totalSize = totalSize.split('mb').shift() * 10000
}
}
collection.totalSize(totalSize)
}
const normalizedOptions = this.normalizeOptions(options)
this.chunkNames = normalizedOptions.chunkNames
this.filenameTemplate = normalizedOptions.filenameTemplate
this.minChunks = normalizedOptions.minChunks
this.selectedChunks = normalizedOptions.selectedChunks
this.children = normalizedOptions.children
this.async = normalizedOptions.async
this.minSize = normalizedOptions.minSize
this.ident = __filename + nextIdent++
}
// @TODO clean this up, see debug
normalizeOptions(options) {
if (Array.isArray(options)) {
return {
chunkNames: options,
}
}
if (typeof options === 'string') {
return {
chunkNames: [options],
}
}
// options.children and options.chunk may not be used together
if (options.children && options.chunks) {
throw new Error(
'You can\'t and it does not make any sense to use "children" and "chunk" options together.'
)
}
/**
* options.async and options.filename are also not possible together
* as filename specifies how the chunk is called but "async" implies
* that webpack will take care of loading this file.
*/
if (options.async && options.filename) {
throw new Error(
`You can not specify a filename if you use the \"async\" option.
You can however specify the name of the async chunk by passing the desired string as the \"async\" option.`
)
}
/**
* Make sure this is either an array or undefined.
* "name" can be a string and
* "names" a string or an array
*/
const chunkNames = options.name || options.names
? [].concat(options.name || options.names)
: undefined
return {
chunkNames: chunkNames,
filenameTemplate: options.filename,
minChunks: options.minChunks,
selectedChunks: options.chunks,
children: options.children,
async: options.async,
minSize: options.minSize,
}
}
apply(compiler) {
compiler.plugin('this-compilation', compilation => {
// this will happen for each file
compilation.plugin(['build-module'], module => {
collection.handle(module)
})
compilation.plugin(
['optimize-chunks', 'optimize-extracted-chunks'],
chunks => {
// only optimize once
if (compilation[this.ident]) return
compilation[this.ident] = true
collection.complete()
/**
* Creates a list of "common"" chunks based on the options.
* The list is made up of preexisting or newly created chunks.
* - If chunk has the name as specified in the chunkNames it is put in the list
* - If no chunk with the name as given in chunkNames exists a new chunk is created and added to the list
*
* These chunks are the "targets" for extracted modules.
*/
const targetChunks = this.getTargetChunks(
chunks,
compilation,
this.chunkNames,
this.children,
this.async
)
// log.bold('TARGETCHUNKS - CHUNKHACK - GETTARGETCHUNKS').echo()
// this.debug(targetChunks)
// @example this is only `noop`
// iterate over all our new chunks
targetChunks.forEach((targetChunk, idx) => {
// this.debug({ targetChunk, idx })
log.data({ idx }).echo()
/**
* These chunks are subject to get "common" modules extracted and moved to the common chunk
*/
const affectedChunks = this.getAffectedChunks(
compilation,
chunks,
targetChunk,
targetChunks,
idx,
this.selectedChunks,
this.async,
this.children
)
// THIS IS ONLY INDEX, NOT NOOP
// this.debug(affectedChunks)
// bail if no chunk is affected
if (!affectedChunks) {
return
}
// If we are async create an async chunk now
// override the "commonChunk" with the newly created async one and use it as commonChunk from now on
let asyncChunk
if (this.async) {
asyncChunk = this.createAsyncChunk(
compilation,
this.async,
targetChunk
)
targetChunk = asyncChunk
}
// @NOTE ------ MODIFIED THIS
/**
* Check which modules are "common" and could be extracted to a "common" chunk
*/
// const extractableModules = this.getExtractableModules(
// this.minChunks,
// affectedChunks,
// targetChunk
// )
const extractableModules = this.getModulesMagic(idx)
// If the minSize option is set check if the size extracted from the chunk is reached
// else bail out here.
// As all modules/commons are interlinked with each other, common modules would be extracted
// if we reach this mark at a later common chunk. (quirky I guess).
if (this.minSize) {
const modulesSize = this.calculateModulesSize(extractableModules)
// if too small, bail
if (modulesSize < this.minSize) return
}
// Remove modules that are moved to commons chunk from their original chunks
// return all chunks that are affected by having modules removed - we need them later (apparently)
const chunksWithExtractedModules = this.extractModulesAndReturnAffectedChunks(
extractableModules,
affectedChunks
)
// log
// .bold(
// '\nBEFORE ADDING EXTRACTED MODULES - CHUNKHACK - addExtractedModulesToTargetChunk'
// )
// .echo()
// this.debug(targetChunk)
// connect all extracted modules with the common chunk
this.addExtractedModulesToTargetChunk(
targetChunk,
extractableModules
)
// set filenameTemplate for chunk
if (this.filenameTemplate)
targetChunk.filenameTemplate = this.filenameTemplate
// log
// .bold(
// '\AFTER ADDING EXTRACTED MODULES - CHUNKHACK - addExtractedModulesToTargetChunk'
// )
// .echo()
// log.quick(targetChunk)
// this.debug(targetChunk)
// if we are async connect the blocks of the "reallyUsedChunk" - the ones that had modules removed -
// with the commonChunk and get the origins for the asyncChunk (remember "asyncChunk === commonChunk" at this moment).
// bail out
if (this.async) {
this.moveExtractedChunkBlocksToTargetChunk(
chunksWithExtractedModules,
targetChunk
)
asyncChunk.origins = this.extractOriginsOfChunksWithExtractedModules(
chunksWithExtractedModules
)
return
}
// 0
// this.debug(affectedChunks.length)
// we are not in "async" mode
// connect used chunks with commonChunk - shouldnt this be reallyUsedChunks here?
this.makeTargetChunkParentOfAffectedChunks(
affectedChunks,
targetChunk
)
// target chunk is `noop`
// affectedChunks is ONLY OTHER CHUNKS (ENTRY)
// JUST INDEX, AND INDEX HAS NOOP IN IT
//
// log.bold('\n MAKING TARGET PARENT!!! ')
// this.debug(targetChunk)
// log
// .bold('\n affectedChunks!!! ')
// .data(`- CHUNKHACK - makeTargetChunkParentOfAffectedChunks`)
// .echo()
// this.debug(affectedChunks)
})
return true
}
)
})
}
// - [x]
getTargetChunks(allChunks, compilation, chunkNames, children, asyncOption) {
const asyncOrNoSelectedChunk = children || asyncOption
// we have specified chunk names
if (chunkNames) {
// map chunks by chunkName for quick access
const allChunksNameMap = allChunks.reduce((map, chunk) => {
if (chunk.name) {
map.set(chunk.name, chunk)
}
return map
}, new Map())
// Ensure we have a chunk per specified chunk name.
// Reuse existing chunks if possible
return chunkNames.map(chunkName => {
if (allChunksNameMap.has(chunkName)) {
return allChunksNameMap.get(chunkName)
}
// add the filtered chunks to the compilation
return compilation.addChunk(chunkName)
})
}
// we dont have named chunks specified, so we just take all of them
if (asyncOrNoSelectedChunk) {
return allChunks.filter(chunk => !chunk.isInitial())
}
/**
* No chunk name(s) was specified nor is this an async/children commons chunk
*/
throw new Error(
`You did not specify any valid target chunk settings.
Take a look at the "name"/"names" or async/children option.`
)
}
getAffectedChunks(
compilation,
allChunks,
targetChunk,
targetChunks,
currentIndex,
selectedChunks,
asyncOption,
children
) {
const asyncOrNoSelectedChunk = children || asyncOption
if (Array.isArray(selectedChunks)) {
return allChunks.filter(chunk => {
const notCommmonChunk = chunk !== targetChunk
const isSelectedChunk = selectedChunks.indexOf(chunk.name) > -1
return notCommmonChunk && isSelectedChunk
})
}
if (asyncOrNoSelectedChunk) {
// nothing to do here
if (!targetChunk.chunks) {
return []
}
return targetChunk.chunks.filter(chunk => {
// we can only move modules from this chunk if the "commonChunk" is the only parent
return asyncOption || chunk.parents.length === 1
})
}
/**
* past this point only entry chunks are allowed to become commonChunks
*/
if (targetChunk.parents.length > 0) {
compilation.errors.push(
new Error(
"WebpackSplitPlugin: While running in normal mode it's not allowed to use a non-entry chunk (" +
targetChunk.name +
')'
)
)
return
}
/**
* If we find a "targetchunk" that is also a normal chunk (meaning it is probably specified as an entry)
* and the current target chunk comes after that and the found chunk has a runtime*
* make that chunk be an 'affected' chunk of the current target chunk.
*
* To understand what that means take a look at the "examples/chunkhash", this basically will
* result in the runtime to be extracted to the current target chunk.
*
* *runtime: the "runtime" is the "webpack"-block you may have seen in the bundles that resolves modules etc.
*/
return allChunks.filter(chunk => {
const found = targetChunks.indexOf(chunk)
if (found >= currentIndex) return false
return chunk.hasRuntime()
})
}
createAsyncChunk(compilation, asyncOption, targetChunk) {
const asyncChunk = compilation.addChunk(
typeof asyncOption === 'string' ? asyncOption : undefined
)
asyncChunk.chunkReason = 'async commons chunk'
asyncChunk.extraAsync = true
asyncChunk.addParent(targetChunk)
targetChunk.addChunk(asyncChunk)
return asyncChunk
}
// If minChunks is a function use that
// otherwhise check if a module is used at least minChunks or 2 or usedChunks.length time
getModuleFilter(minChunks, targetChunk, usedChunksLength) {
if (typeof minChunks === 'function') {
return minChunks
}
const minCount = minChunks || Math.max(2, usedChunksLength)
const isUsedAtLeastMinTimes = (module, count) => count >= minCount
return isUsedAtLeastMinTimes
}
getExtractableModules(minChunks, usedChunks, targetChunk) {
if (minChunks === Infinity) {
return []
}
// count how many chunks contain a module
const commonModulesToCountMap = usedChunks.reduce((map, chunk) => {
for (let module of chunk.modules) {
const count = map.has(module) ? map.get(module) : 0
map.set(module, count + 1)
}
return map
}, new Map())
// filter by minChunks
const moduleFilterCount = this.getModuleFilter(
minChunks,
targetChunk,
usedChunks.length
)
// filter by condition
const moduleFilterCondition = (module, chunk) => {
if (!module.chunkCondition) {
return true
}
return module.chunkCondition(chunk)
}
return Array.from(commonModulesToCountMap)
.filter(entry => {
const module = entry[0]
const count = entry[1]
// if the module passes both filters, keep it.
return (
moduleFilterCount(module, count) &&
moduleFilterCondition(module, targetChunk)
)
})
.map(entry => entry[0])
}
calculateModulesSize(modules) {
return modules.reduce((totalSize, module) => totalSize + module.size(), 0)
}
addExtractedModulesToTargetChunk(chunk, modules) {
for (let module of modules) {
chunk.addModule(module)
module.addChunk(chunk)
}
}
// ********************************************
// @important *********************************
// ********************************************
/**
* @NOTE after changing it around, this is unused in the CommonChunk hack
* because it
*
* @desc
* this is so we can remove grouped chunks from entry point
* map groups to get back modules
* map modules to userRequest, to simplify filtering entries
* flatten
* @param {[type]} index
* @return {[type]}
*/
getFileNamesMagic(index) {
const groups = collection.getCollections()
return [].concat(...groups.map(group => group.map(file => file.filename)))[
index
]
}
/**
* @desc return collection of modules we gathered during module build
* using the index
* @param {number} index
* @return {Array<WebpackModule>}
*/
getModulesMagic(index) {
const groups = collection.getCollections()
return groups[index].map(file => file.module)
}
// - [x]
extractModulesAndReturnAffectedChunks(reallyUsedModules, usedChunks) {
return reallyUsedModules.reduce((affectedChunksSet, module) => {
for (let chunk of usedChunks) {
// removeChunk returns true if the chunk was contained and succesfully removed
// false if the module did not have a connection to the chunk in question
if (module.removeChunk(chunk)) {
affectedChunksSet.add(chunk)
}
}
return affectedChunksSet
}, new Set())
}
// - [ ]
// confusingly named params
// is `affectedChunks` & `targetChunk`
makeTargetChunkParentOfAffectedChunks(usedChunks, commonChunk) {
// log
// .dim('it inserts chunk with parents...')
// .verbose(4)
// .data({ parents: chunk.parents, entrypoints: chunk.entrypoints })
// .echo()
for (let chunk of usedChunks) {
// set commonChunk as new sole parent
chunk.parents = [commonChunk]
// add chunk to commonChunk
commonChunk.addChunk(chunk)
for (let entrypoint of chunk.entrypoints) {
entrypoint.insertChunk(commonChunk, chunk)
}
// log
// .verbose(5)
// .data({ parents: chunk.parents, entrypoints: chunk.entrypoints })
// .echo()
}
}
// ********************************************
// @async *************************************
// ********************************************
// - [ ]
moveExtractedChunkBlocksToTargetChunk(chunks, targetChunk) {
for (let chunk of chunks) {
for (let block of chunk.blocks) {
block.chunks.unshift(targetChunk)
targetChunk.addBlock(block)
}
}
}
// - [ ]
extractOriginsOfChunksWithExtractedModules(chunks) {
const origins = []
for (let chunk of chunks) {
for (let origin of chunk.origins) {
const newOrigin = Object.create(origin)
newOrigin.reasons = (origin.reasons || []).concat('async commons')
origins.push(newOrigin)
}
}
return origins
}
// ********************************************
// @mine *********************************
// ********************************************
debug(chunks) {
// this is blacklist, we also want to do whitelist
// we'd like to keep `module`, `chunk`, `parnet`
const cleanedChunks = Cleaner.init(chunks)
// .debug()
.keys([
/parser/,
/loc/,
/range/,
/dependencies/,
/dependenciesWarnings/,
/strict/,
/debug/,
/loaders/,
/assets/,
/meta/,
/warnings/,
/used/,
/rawRequest/,
/resource/,
/contextDependencies/,
/_source/,
/built/,
/cachedSource/,
/issuer/,
/index/,
/index2/,
/id/,
/portableId/,
/lastId/,
/cacheable/,
/building/,
/depth/,
/buildTimestamp/,
/optional/,
/errors/,
/variables/,
/rendered/,
/error/,
/__NormalModuleFactoryCache/,
])
.clean()
log.verbose(100).data({ cleanedChunks }).exit()
}
}
module.exports = WebpackSplitPlugin