webpack-unplugin
一、认识
unplugin 是一个为构建工具(如 Webpack
、Vite
、Rollup
、RsPack
等)提供插件系统的框架,旨在简化插件的开发。它通过钩子函数将构建逻辑集成到构建工具的生命周期中。unplugin
的设计使得插件可以跨多个构建工具工作,UnPlugin
可以编写一个插件并在多个构建工具中重用它, 另外, UnPlugin
可以很方便的处理虚拟模块。UnPlugin
提供了一些钩子, 钩子如下:
-
load
:UnPlugin load
在Webpack
的实现逻辑为: 在loader
配置数组的组前面新增一个加载虚拟模块的Loader
, 并会设置enforce
属性。根据loadInclude
来判断处理哪些模块。在Loader
中, 调用load
回调,load
回调的结果会替换之前的源码, 而且load
回调只携带了模块id
, 也没有能力去转换源码。因此, 可以load
钩子中自定义虚拟模块的内容。扩展: 在webpack
中,loader
会分为三类,Pre loaders
(前置loader
) 会在普通loader
执行之前运行;Normal loaders
(普通loader
);Post loaders
(后置loader
) 会在所有普通loader
执行之后运行。此外,对于同一类别中的多个loader
(例如多个pre loader
),它们的执行顺序是按照配置中的逆序执行,也就是后声明的先执行。{
type: 'javascript/auto',
enforce: plugin.enforce,
include: (id)=>{ loadInclude() },
use: [{
loader: (this, source, map)=> { const callback = this.async(); const res = load(); callback(null, res, map); },
options: {}
}],
} -
webpack
:webpack(compiler)
针对Webpack
特定的钩子 -
buildEnd
-
resolveId
:UnPlugin resolveId
在Webpack
的实现逻辑为: 在解析配置compiler.options.resolve.plugins
中插入一个虚拟模块解析插件, 这个虚拟模块解析插件在resolve Hook
(resolver.getHook("resolve")
) 阶段, 判断模块解析请求是否符合虚拟模块规则, 比如virtual:bolawen.css
, 如果符合, 那么, 将这个虚拟模块转换为绝对路径, 并基于finalInputFileSystem._writeVirtualFile
写入到虚拟空间。通过resolver.doResolve
重定向虚拟模块解析请求为转换后的绝对路径。const resolverPlugin = {
apply(resolver){
const target = resolver.ensureHook('resolve');
resolver
.getHook('resolve')
.tapAsync(plugin.name, async (request, resolveContext, callback) => {
if (!request.request) return callback();
let resolved = "";
const newRequest = { ...request, request: resolved };
resolver.doResolve(target, newRequest, null, resolveContext, callback);
});
}
}
compiler.options.resolve.plugins = compiler.options.resolve.plugins || []
compiler.options.resolve.plugins.push(resolverPlugin) -
transform
:UnPlugin transform
在Webpack
的实现逻辑为: 在loader
配置数组的组前面新增一个Loader
, 并会设置enforce
属性。根据transformInclude
来判断处理哪些模块。在Loader
中, 调用transform
回调,transform
回调的结果会替换之前的源码。但是transform
回调 会携带source
源码参数和模块id
, 因此可以在transform
回调中处理源码、转换源码。扩展: 在webpack
中,loader
会分为三类,Pre loaders
(前置loader
) 会在普通loader
执行之前运行;Normal loaders
(普通loader
);Post loaders
(后置loader
) 会在所有普通loader
执行之后运行。此外,对于同一类别中的多个loader
(例如多个pre loader
),它们的执行顺序是按照配置中的逆序执行,也就是后声明的先执行。注意:transform
在load
前面, 所以load
钩子先执行。[
{
enforce: plugin.enforce,
use(data){
return transformInclude(id) ? [
loader: (this, source, map)=> { const callback = this.async(); const res = transform(source, id); callback(null, res, map) },
] : []
}
},
{
type: 'javascript/auto',
enforce: plugin.enforce,
include: (id)=>{ loadInclude() },
use: [{
loader: (this, source, map)=> { const callback = this.async(); load(); callback(null, xxx, map); },
options: {}
}],
}
] -
buildStart
-
loadInclude
:UnPlugin loadInclude
在Webpack
的实现逻辑为: 首先判断是否为虚拟模块的id
。只有虚拟模块id
才会进入load Hooks
。{
type: 'javascript/auto',
enforce: plugin.enforce,
include: (id)=>{ loadInclude() },
use: [{
loader: (this, source, map)=> { const callback = this.async(); load(); callback(null, xxx, map); },
options: {}
}],
} -
watchChange
-
transformInclude
[
{
enforce: plugin.enforce,
use(data){
return transformInclude(id) ? [
loader: (this, source, map)=> { const callback = this.async(); transform(); callback(null, xxx, map) },
] : []
}
},
{
type: 'javascript/auto',
enforce: plugin.enforce,
include: (id)=>{ loadInclude() },
use: [{
loader: (this, source, map)=> { const callback = this.async(); load(); callback(null, xxx, map); },
options: {}
}],
}
]
二、实现
2.1 index.js
const path = require("path");
const defaultVirtualModulePrefix = "_virtual_";
const VirtualModulePlugin = require("./virtual-module-plugin");
const virtualModuleLoaderPath = path.resolve(
process.cwd(),
"../../",
"unplugin/webpackUnplugin/virtual-module-loader.js"
);
const virtualModuleResolvePlugin = require("./virtual-module-resolve-plugin");
function virtualModuleShouldInclude(id, options) {
const { virtualModulePrefix, virtualModuleShouldIncludeProp } = options;
if (id.startsWith(virtualModulePrefix)) {
if (virtualModuleShouldIncludeProp && !virtualModuleShouldIncludeProp(id)) {
return false;
}
return true;
}
return false;
}
function virtualModuleResolve(compiler, options) {
compiler.options.resolve.plugins = compiler.options.resolve.plugins || [];
compiler.options.resolve.plugins.push(virtualModuleResolvePlugin(options));
}
function virtualModuleRule(compiler, options) {
compiler.options.module.rules.unshift({
enforce: "pre",
type: "javascript/auto",
include(id) {
return virtualModuleShouldInclude(id, options);
},
use: [
{
options,
loader: virtualModuleLoaderPath,
},
],
});
}
function WebpackUnPlugin(
options = {
pluginName: "",
virtualModuleResolveProp: undefined,
virtualModuleLoadCallback: undefined,
virtualModuleShouldIncludeProp: undefined,
}
) {
return {
apply(compiler) {
const virtualModulePrefix = path.resolve(
compiler.options.context ?? process.cwd,
defaultVirtualModulePrefix
);
let virtualModulePlugin = compiler.options.plugins.find(
(i) => i instanceof VirtualModulePlugin
);
if (!virtualModulePlugin) {
virtualModulePlugin = new VirtualModulePlugin();
compiler.options.plugins.push(virtualModulePlugin);
}
if (options.virtualModuleResolveProp) {
virtualModuleResolve(compiler, {
...options,
virtualModulePrefix,
virtualModulePlugin,
});
}
if (options.virtualModuleLoadCallback) {
virtualModuleRule(compiler, { ...options, virtualModulePrefix });
}
if (options.webpack) {
options.webpack(compiler);
}
},
};
}
module.exports = WebpackUnPlugin;
2.2 virtual-module-loader.js
const { normalize, isAbsolute } = require("path");
function normalizeAbsolutePath(path) {
if (isAbsolute(path)) return normalize(path);
else return path;
}
async function virtualModuleLoader(source, map, data) {
let id = this.resource;
const callback = this.async();
const options = this.getOptions();
if (!id || !options.virtualModuleLoadCallback) {
return callback(null, source, map, data);
}
if (id.startsWith(options.virtualModulePrefix)) {
id = decodeURIComponent(id.slice(options.virtualModulePrefix.length));
}
const virtualModuleLoadContext = {};
const result = await options.virtualModuleLoadCallback.call(
virtualModuleLoadContext,
normalizeAbsolutePath(id)
);
if (result === null) {
callback(null, source, map, data);
} else if (typeof result !== "string") {
callback(null, result.code, result.map ?? map, data);
} else {
callback(null, result, map, data);
}
}
module.exports = virtualModuleLoader;
2.3 virtual-module-plugin.js
let inode = 45000000;
const path = require("path");
const constants = require("constants");
const pluginName = "virtual-module-plugin";
class VirtualStats {
constructor(config) {
for (const key in config) {
if (!Object.prototype.hasOwnProperty.call(config, key)) {
continue;
}
this[key] = config[key];
}
}
_checkModeProperty(property) {
return (this.mode & constants.S_IFMT) === property;
}
isDirectory() {
return this._checkModeProperty(constants.S_IFDIR);
}
isFile() {
return this._checkModeProperty(constants.S_IFREG);
}
isBlockDevice() {
return this._checkModeProperty(constants.S_IFBLK);
}
isCharacterDevice() {
return this._checkModeProperty(constants.S_IFCHR);
}
isSymbolicLink() {
return this._checkModeProperty(constants.S_IFLNK);
}
isFIFO() {
return this._checkModeProperty(constants.S_IFIFO);
}
isSocket() {
return this._checkModeProperty(constants.S_IFSOCK);
}
}
function getData(storage, key) {
if (storage._data instanceof Map) {
return storage._data.get(key);
} else if (storage._data) {
return storage.data[key];
} else if (storage.data instanceof Map) {
return storage.data.get(key);
} else {
return storage.data[key];
}
}
function setData(backendOrStorage, key, valueFactory) {
const value = valueFactory(backendOrStorage);
if (backendOrStorage._data instanceof Map) {
backendOrStorage._data.set(key, value);
} else if (backendOrStorage._data) {
backendOrStorage.data[key] = value;
} else if (backendOrStorage.data instanceof Map) {
backendOrStorage.data.set(key, value);
} else {
backendOrStorage.data[key] = value;
}
}
function getModulePath(filePath, compiler) {
return path.isAbsolute(filePath)
? filePath
: path.join(compiler.context, filePath);
}
function getStatStorage(fileSystem) {
if (fileSystem._statStorage) {
return fileSystem._statStorage;
} else if (fileSystem._statBackend) {
return fileSystem._statBackend;
} else {
throw new Error("Couldn't find a stat storage");
}
}
function getFileStorage(fileSystem) {
if (fileSystem._readFileStorage) {
return fileSystem._readFileStorage;
} else if (fileSystem._readFileBackend) {
return fileSystem._readFileBackend;
} else {
throw new Error("Couldn't find a readFileStorage");
}
}
function getReadDirBackend(fileSystem) {
if (fileSystem._readdirBackend) {
return fileSystem._readdirBackend;
} else if (fileSystem._readdirStorage) {
return fileSystem._readdirStorage;
} else {
throw new Error("Couldn't find a readDirStorage from Webpack Internals");
}
}
function getRealpathBackend(fileSystem) {
if (fileSystem._realpathBackend) {
return fileSystem._realpathBackend;
}
}
class VirtualModulesPlugin {
constructor() {
this._watcher = null;
this._compiler = null;
this._staticModules = null;
}
apply(compiler) {
this._compiler = compiler;
const afterEnvironmentHook = () => {
let finalInputFileSystem = compiler.inputFileSystem;
while (finalInputFileSystem && finalInputFileSystem._inputFileSystem) {
finalInputFileSystem = finalInputFileSystem._inputFileSystem;
}
if (!finalInputFileSystem._writeVirtualFile) {
const originalPurge = finalInputFileSystem.purge;
finalInputFileSystem.purge = () => {
originalPurge.apply(finalInputFileSystem, []);
if (finalInputFileSystem._virtualFiles) {
Object.keys(finalInputFileSystem._virtualFiles).forEach((file) => {
const data = finalInputFileSystem._virtualFiles[file];
finalInputFileSystem._writeVirtualFile(
file,
data.stats,
data.contents
);
});
}
};
finalInputFileSystem._writeVirtualFile = (file, stats, contents) => {
const statStorage = getStatStorage(finalInputFileSystem);
const fileStorage = getFileStorage(finalInputFileSystem);
const readDirStorage = getReadDirBackend(finalInputFileSystem);
const realPathStorage = getRealpathBackend(finalInputFileSystem);
finalInputFileSystem._virtualFiles =
finalInputFileSystem._virtualFiles || {};
finalInputFileSystem._virtualFiles[file] = {
stats: stats,
contents: contents,
};
setData(statStorage, file, createWebpackData(stats));
setData(fileStorage, file, createWebpackData(contents));
const segments = file.split(/[\\/]/);
let count = segments.length - 1;
const minCount = segments[0] ? 1 : 0;
while (count > minCount) {
const dir = segments.slice(0, count).join(path.sep) || path.sep;
try {
finalInputFileSystem.readdirSync(dir);
} catch (e) {
const time = Date.now();
const dirStats = new VirtualStats({
dev: 8675309,
nlink: 0,
uid: 1000,
gid: 1000,
rdev: 0,
blksize: 4096,
ino: inode++,
mode: 16877,
size: stats.size,
blocks: Math.floor(stats.size / 4096),
atime: time,
mtime: time,
ctime: time,
birthtime: time,
});
setData(readDirStorage, dir, createWebpackData([]));
if (realPathStorage) {
setData(realPathStorage, dir, createWebpackData(dir));
}
setData(statStorage, dir, createWebpackData(dirStats));
}
let dirData = getData(getReadDirBackend(finalInputFileSystem), dir);
dirData = dirData[1] || dirData.result;
const filename = segments[count];
if (dirData.indexOf(filename) < 0) {
const files = dirData.concat([filename]).sort();
setData(
getReadDirBackend(finalInputFileSystem),
dir,
createWebpackData(files)
);
} else {
break;
}
count--;
}
};
}
};
const afterResolversHook = () => {
if (this._staticModules) {
for (const [filePath, contents] of Object.entries(
this._staticModules
)) {
this.writeModule(filePath, contents);
}
this._staticModules = null;
}
};
const watchRunHook = (watcher, callback) => {
this._watcher = watcher.compiler || watcher;
const virtualFiles = compiler.inputFileSystem._virtualFiles;
const fts = compiler.fileTimestamps;
if (virtualFiles && fts && typeof fts.set === "function") {
Object.keys(virtualFiles).forEach((file) => {
const mtime = +virtualFiles[file].stats.mtime;
fts.set(
file,
version === 4
? mtime
: {
safeTime: mtime,
timestamp: mtime,
}
);
});
}
callback();
};
if (compiler.hooks) {
compiler.hooks.afterEnvironment.tap(pluginName, afterEnvironmentHook);
compiler.hooks.afterResolvers.tap(pluginName, afterResolversHook);
compiler.hooks.watchRun.tapAsync(pluginName, watchRunHook);
} else {
compiler.plugin("after-environment", afterEnvironmentHook);
compiler.plugin("after-resolvers", afterResolversHook);
compiler.plugin("watch-run", watchRunHook);
}
}
writeModule(filePath, contents) {
const len = contents ? contents.length : 0;
const time = Date.now();
const date = new Date(time);
const stats = new VirtualStats({
dev: 8675309,
nlink: 0,
uid: 1000,
gid: 1000,
rdev: 0,
blksize: 4096,
ino: inode++,
mode: 33188,
size: len,
blocks: Math.floor(len / 4096),
atime: date,
mtime: date,
ctime: date,
birthtime: date,
});
const modulePath = getModulePath(filePath, this._compiler);
let finalInputFileSystem = this._compiler.inputFileSystem;
while (finalInputFileSystem && finalInputFileSystem._inputFileSystem) {
finalInputFileSystem = finalInputFileSystem._inputFileSystem;
}
finalInputFileSystem._writeVirtualFile(modulePath, stats, contents);
let finalWatchFileSystem = this._watcher && this._watcher.watchFileSystem;
if (
finalWatchFileSystem &&
finalWatchFileSystem.watcher &&
(finalWatchFileSystem.watcher.fileWatchers.size ||
finalWatchFileSystem.watcher.fileWatchers.length)
) {
const fileWatchers =
finalWatchFileSystem.watcher.fileWatchers instanceof Map
? Array.from(finalWatchFileSystem.watcher.fileWatchers.values())
: finalWatchFileSystem.watcher.fileWatchers;
for (let fileWatcher of fileWatchers) {
if ("watcher" in fileWatcher) {
fileWatcher = fileWatcher.watcher;
}
if (fileWatcher.path === modulePath) {
if (process.env.DEBUG)
console.log(
this._compiler.name,
"Emit file change:",
modulePath,
time
);
delete fileWatcher.directoryWatcher._cachedTimeInfoEntries;
fileWatcher.emit("change", time, null);
}
}
}
}
}
module.exports = VirtualModulesPlugin;
2.3 virtual-module-resolve-plugin.js
const fs = require("fs");
const { normalize, isAbsolute } = require("path");
function normalizeAbsolutePath(path) {
if (isAbsolute(path)) return normalize(path);
else return path;
}
function virtualModuleResolvePlugin(options) {
const {
pluginName,
virtualModulePrefix,
virtualModulePlugin,
virtualModuleResolveProp,
} = options;
return {
apply(resolver) {
const target = resolver.ensureHook("resolve");
resolver
.getHook("resolve")
.tapAsync(pluginName, async (request, resolveContext, callback) => {
if (!request.request) {
return callback();
}
if (
normalizeAbsolutePath(request.request).startsWith(
virtualModulePrefix
)
) {
return callback();
}
const id = normalizeAbsolutePath(request.request);
const resolveVirtualModuleIdContext = {};
const resolveIdResult = await virtualModuleResolveProp.call(
resolveVirtualModuleIdContext,
id
);
if (resolveIdResult == null) {
return callback();
}
let resolved =
typeof resolveIdResult === "string"
? resolveIdResult
: resolveIdResult.id;
if (!fs.existsSync(resolved)) {
resolved = normalizeAbsolutePath(
virtualModulePrefix + encodeURIComponent(resolved)
);
virtualModulePlugin.writeModule(resolved, "");
}
const newRequest = {
...request,
request: resolved,
};
resolver.doResolve(
target,
newRequest,
null,
resolveContext,
callback
);
});
},
};
}
module.exports = virtualModuleResolvePlugin;