feat: implement `module.generator.json.JSONParse`

For large `.json` modules, webpack will generate `JSON.parse` by default for
better performance. But there are some circumstances that `JSON.parse` is not a
good choice (e.g.: when doing AOT compilation).

Thus, a new generator option `module.generator.json.JSONParse` is added to
disable generating `JSON.parse` for `.json` module.

The default value is kept as `true` and can be opt-out by custom rules.

fix: #19319
This commit is contained in:
Qingyu Wang 2025-03-13 22:24:10 +08:00
parent cb25853b58
commit 8ab85e29bb
22 changed files with 236 additions and 13 deletions

View File

@ -3346,6 +3346,15 @@ export interface JavascriptParserOptions {
wrappedContextRegExp?: RegExp; wrappedContextRegExp?: RegExp;
[k: string]: any; [k: string]: any;
} }
/**
* Generator options for json modules.
*/
export interface JsonGeneratorOptions {
/**
* Use `JSON.parse` when the JSON string is longer than 20 characters.
*/
JSONParse?: boolean;
}
/** /**
* Options for the default backend. * Options for the default backend.
*/ */
@ -3882,6 +3891,10 @@ export interface GeneratorOptionsByModuleTypeKnown {
* No generator options are supported for this module type. * No generator options are supported for this module type.
*/ */
"javascript/esm"?: EmptyGeneratorOptions; "javascript/esm"?: EmptyGeneratorOptions;
/**
* Generator options for json modules.
*/
json?: JsonGeneratorOptions;
} }
/** /**
* Specify options for each generator. * Specify options for each generator.

View File

@ -0,0 +1,12 @@
/*
* This file was automatically generated.
* DO NOT MODIFY BY HAND.
* Run `yarn special-lint-fix` to update
*/
export interface JsonModulesPluginGeneratorOptions {
/**
* Use `JSON.parse` when the JSON string is longer than 20 characters.
*/
JSONParse?: boolean;
}

View File

@ -47,6 +47,7 @@ const {
/** @typedef {import("../../declarations/WebpackOptions").GeneratorOptionsByModuleTypeKnown} GeneratorOptionsByModuleTypeKnown */ /** @typedef {import("../../declarations/WebpackOptions").GeneratorOptionsByModuleTypeKnown} GeneratorOptionsByModuleTypeKnown */
/** @typedef {import("../../declarations/WebpackOptions").InfrastructureLogging} InfrastructureLogging */ /** @typedef {import("../../declarations/WebpackOptions").InfrastructureLogging} InfrastructureLogging */
/** @typedef {import("../../declarations/WebpackOptions").JavascriptParserOptions} JavascriptParserOptions */ /** @typedef {import("../../declarations/WebpackOptions").JavascriptParserOptions} JavascriptParserOptions */
/** @typedef {import("../../declarations/WebpackOptions").JsonGeneratorOptions} JsonGeneratorOptions */
/** @typedef {import("../../declarations/WebpackOptions").Library} Library */ /** @typedef {import("../../declarations/WebpackOptions").Library} Library */
/** @typedef {import("../../declarations/WebpackOptions").LibraryName} LibraryName */ /** @typedef {import("../../declarations/WebpackOptions").LibraryName} LibraryName */
/** @typedef {import("../../declarations/WebpackOptions").LibraryOptions} LibraryOptions */ /** @typedef {import("../../declarations/WebpackOptions").LibraryOptions} LibraryOptions */
@ -581,6 +582,14 @@ const applyJavascriptParserOptionsDefaults = (
if (futureDefaults) D(parserOptions, "exportsPresence", "error"); if (futureDefaults) D(parserOptions, "exportsPresence", "error");
}; };
/**
* @param {JsonGeneratorOptions} generatorOptions generator options
* @returns {void}
*/
const applyJsonGeneratorOptionsDefaults = generatorOptions => {
D(generatorOptions, "JSONParse", true);
};
/** /**
* @param {CssGeneratorOptions} generatorOptions generator options * @param {CssGeneratorOptions} generatorOptions generator options
* @param {object} options options * @param {object} options options
@ -682,6 +691,12 @@ const applyModuleDefaults = (
} }
); );
F(module.generator, "json", () => ({}));
applyJsonGeneratorOptionsDefaults(
/** @type {NonNullable<GeneratorOptionsByModuleTypeKnown["json"]>} */
(module.generator.json)
);
if (css) { if (css) {
F(module.parser, CSS_MODULE_TYPE, () => ({})); F(module.parser, CSS_MODULE_TYPE, () => ({}));

View File

@ -13,6 +13,7 @@ const { JS_TYPES } = require("../ModuleSourceTypesConstants");
const RuntimeGlobals = require("../RuntimeGlobals"); const RuntimeGlobals = require("../RuntimeGlobals");
/** @typedef {import("webpack-sources").Source} Source */ /** @typedef {import("webpack-sources").Source} Source */
/** @typedef {import("../../declarations/WebpackOptions").JsonGeneratorOptions} JsonGeneratorOptions */
/** @typedef {import("../ExportsInfo")} ExportsInfo */ /** @typedef {import("../ExportsInfo")} ExportsInfo */
/** @typedef {import("../Generator").GenerateContext} GenerateContext */ /** @typedef {import("../Generator").GenerateContext} GenerateContext */
/** @typedef {import("../Module").ConcatenationBailoutReasonContext} ConcatenationBailoutReasonContext */ /** @typedef {import("../Module").ConcatenationBailoutReasonContext} ConcatenationBailoutReasonContext */
@ -106,6 +107,14 @@ const createObjectForExportsInfo = (data, exportsInfo, runtime) => {
}; };
class JsonGenerator extends Generator { class JsonGenerator extends Generator {
/**
* @param {JsonGeneratorOptions} options options
*/
constructor(options) {
super();
this.options = options;
}
/** /**
* @param {NormalModule} module fresh module * @param {NormalModule} module fresh module
* @returns {SourceTypes} available types (do not mutate) * @returns {SourceTypes} available types (do not mutate)
@ -176,7 +185,9 @@ class JsonGenerator extends Generator {
// Use JSON because JSON.parse() is much faster than JavaScript evaluation // Use JSON because JSON.parse() is much faster than JavaScript evaluation
const jsonStr = /** @type {string} */ (stringifySafe(finalJson)); const jsonStr = /** @type {string} */ (stringifySafe(finalJson));
const jsonExpr = const jsonExpr =
jsonStr.length > 20 && typeof finalJson === "object" this.options.JSONParse &&
jsonStr.length > 20 &&
typeof finalJson === "object"
? `/*#__PURE__*/JSON.parse('${jsonStr.replace(/[\\']/g, "\\$&")}')` ? `/*#__PURE__*/JSON.parse('${jsonStr.replace(/[\\']/g, "\\$&")}')`
: jsonStr.replace(/"__proto__":/g, '["__proto__"]:'); : jsonStr.replace(/"__proto__":/g, '["__proto__"]:');
/** @type {string} */ /** @type {string} */

View File

@ -22,6 +22,15 @@ const validate = createSchemaValidation(
} }
); );
const validateGenerator = createSchemaValidation(
require("../../schemas/plugins/JsonModulesPluginGenerator.check.js"),
() => require("../../schemas/plugins/JsonModulesPluginGenerator.json"),
{
name: "Json Modules Plugin",
baseDataPath: "generator"
}
);
const PLUGIN_NAME = "JsonModulesPlugin"; const PLUGIN_NAME = "JsonModulesPlugin";
/** /**
@ -46,7 +55,10 @@ class JsonModulesPlugin {
}); });
normalModuleFactory.hooks.createGenerator normalModuleFactory.hooks.createGenerator
.for(JSON_MODULE_TYPE) .for(JSON_MODULE_TYPE)
.tap(PLUGIN_NAME, () => new JsonGenerator()); .tap(PLUGIN_NAME, generatorOptions => {
validateGenerator(generatorOptions);
return new JsonGenerator(generatorOptions);
});
} }
); );
} }

View File

@ -187,5 +187,6 @@
"node node_modules/prettier/bin/prettier.cjs --cache --write --ignore-unknown", "node node_modules/prettier/bin/prettier.cjs --cache --write --ignore-unknown",
"cspell --cache --no-must-find-files" "cspell --cache --no-must-find-files"
] ]
} },
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }

File diff suppressed because one or more lines are too long

View File

@ -1571,6 +1571,9 @@
}, },
"javascript/esm": { "javascript/esm": {
"$ref": "#/definitions/EmptyGeneratorOptions" "$ref": "#/definitions/EmptyGeneratorOptions"
},
"json": {
"$ref": "#/definitions/JsonGeneratorOptions"
} }
} }
}, },
@ -2009,6 +2012,17 @@
} }
} }
}, },
"JsonGeneratorOptions": {
"description": "Generator options for json modules.",
"type": "object",
"additionalProperties": false,
"properties": {
"JSONParse": {
"description": "Use `JSON.parse` when the JSON string is longer than 20 characters.",
"type": "boolean"
}
}
},
"Layer": { "Layer": {
"description": "Specifies the layer in which modules of this entrypoint are placed.", "description": "Specifies the layer in which modules of this entrypoint are placed.",
"anyOf": [ "anyOf": [

View File

@ -0,0 +1,7 @@
/*
* This file was automatically generated.
* DO NOT MODIFY BY HAND.
* Run `yarn special-lint-fix` to update
*/
declare const check: (options: import("../../declarations/plugins/JsonModulesPluginGenerator").JsonModulesPluginGeneratorOptions) => boolean;
export = check;

View File

@ -0,0 +1,6 @@
/*
* This file was automatically generated.
* DO NOT MODIFY BY HAND.
* Run `yarn special-lint-fix` to update
*/
"use strict";function r(e,{instancePath:t="",parentData:a,parentDataProperty:o,rootData:s=e}={}){if(!e||"object"!=typeof e||Array.isArray(e))return r.errors=[{params:{type:"object"}}],!1;{const t=0;for(const t in e)if("JSONParse"!==t)return r.errors=[{params:{additionalProperty:t}}],!1;if(0===t&&void 0!==e.JSONParse&&"boolean"!=typeof e.JSONParse)return r.errors=[{params:{type:"boolean"}}],!1}return r.errors=null,!0}module.exports=r,module.exports.default=r;

View File

@ -0,0 +1,11 @@
{
"title": "JsonModulesPluginGeneratorOptions",
"type": "object",
"additionalProperties": false,
"properties": {
"JSONParse": {
"description": "Use `JSON.parse` when the JSON string is longer than 20 characters.",
"type": "boolean"
}
}
}

View File

@ -225,7 +225,11 @@ describe("snapshots", () => {
}, },
}, },
], ],
"generator": Object {}, "generator": Object {
"json": Object {
"JSONParse": true,
},
},
"noParse": undefined, "noParse": undefined,
"parser": Object { "parser": Object {
"asset": Object { "asset": Object {
@ -2290,7 +2294,7 @@ describe("snapshots", () => {
+ }, + },
+ "resolve": Object { + "resolve": Object {
+ "fullySpecified": true, + "fullySpecified": true,
+ }, @@ ... @@
+ }, + },
+ ], + ],
+ "test": /\\.wasm$/i, + "test": /\\.wasm$/i,
@ -2299,7 +2303,7 @@ describe("snapshots", () => {
+ Object { + Object {
+ "mimetype": "application/wasm", + "mimetype": "application/wasm",
+ "rules": Array [ + "rules": Array [
+ Object { @@ ... @@
+ "descriptionData": Object { + "descriptionData": Object {
+ "type": "module", + "type": "module",
+ }, + },
@ -2331,12 +2335,11 @@ describe("snapshots", () => {
+ "resolve": Object { + "resolve": Object {
+ "fullySpecified": true, + "fullySpecified": true,
+ "preferRelative": true, + "preferRelative": true,
@@ ... @@ + },
+ "type": "css", + "type": "css",
+ }, + },
+ Object {
@@ ... @@ @@ ... @@
- "generator": Object {},
+ "generator": Object {
+ "css": Object { + "css": Object {
+ "esModule": true, + "esModule": true,
+ "exportsOnly": false, + "exportsOnly": false,
@ -2353,14 +2356,12 @@ describe("snapshots", () => {
+ "exportsConvention": "as-is", + "exportsConvention": "as-is",
+ "localIdentName": "[uniqueName]-[id]-[local]", + "localIdentName": "[uniqueName]-[id]-[local]",
+ }, + },
+ },
@@ ... @@
+ },
@@ ... @@ @@ ... @@
+ "css": Object { + "css": Object {
+ "import": true, + "import": true,
+ "namedExports": true, + "namedExports": true,
+ "url": true, + "url": true,
+ },
@@ ... @@ @@ ... @@
+ "exportsPresence": "error", + "exportsPresence": "error",
@@ ... @@ @@ ... @@

View File

@ -1672,6 +1672,19 @@ Object {
"multiple": false, "multiple": false,
"simpleType": "string", "simpleType": "string",
}, },
"module-generator-json-json-parse": Object {
"configs": Array [
Object {
"description": "Use \`JSON.parse\` when the JSON string is longer than 20 characters.",
"multiple": false,
"path": "module.generator.json.JSONParse",
"type": "boolean",
},
],
"description": "Use \`JSON.parse\` when the JSON string is longer than 20 characters.",
"multiple": false,
"simpleType": "boolean",
},
"module-no-parse": Object { "module-no-parse": Object {
"configs": Array [ "configs": Array [
Object { Object {

View File

@ -0,0 +1 @@
{"this is a large JSON object": "that should be converted to JSON.parse by default"}

View File

@ -0,0 +1,5 @@
[
{
"this is a large JSON object": "that should be converted to JSON.parse by default"
}
]

View File

@ -0,0 +1,24 @@
it("should avoid JSON.parse", () => {
const JSONParse = jest.spyOn(JSON, 'parse');
JSONParse.mockClear();
const data = require('./data.json');
const data2 = require('data:application/json,{"this is a large JSON object": "that should be converted to JSON.parse by default"}');
const data3 = require('./data1.json');
expect(data).toMatchObject({["this is a large JSON object"]: "that should be converted to JSON.parse by default"});
expect(data2).toMatchObject({["this is a large JSON object"]: "that should be converted to JSON.parse by default"});
expect(data3).toMatchObject([{"this is a large JSON object": "that should be converted to JSON.parse by default"}]);
expect(JSONParse).not.toHaveBeenCalled();
});
it("should JSON.parse when resourceQuery is JSONParse=true", () => {
const JSONParse = jest.spyOn(JSON, 'parse');
JSONParse.mockClear();
const data = require('./data.json?JSONParse=true');
expect(data).toMatchObject({["this is a large JSON object"]: "that should be converted to JSON.parse by default"});
expect(JSONParse).toHaveBeenCalledTimes(1);
});

View File

@ -0,0 +1,16 @@
/** @type {import("../../../../").Configuration} */
module.exports = {
devtool: false,
mode: "development",
module: {
rules: [
{
test: /\.json$/,
resourceQuery: /JSONParse=true/,
type: "json",
generator: { JSONParse: true }
}
],
generator: { json: { JSONParse: false } }
}
};

View File

@ -0,0 +1 @@
{"123this is a large JSON object": "that should be converted to JSON.parse by default"}

View File

@ -0,0 +1,5 @@
[
{
"this is a large JSON object": "that should be converted to JSON.parse by default"
}
]

View File

@ -0,0 +1,24 @@
it("should use JSON.parse", () => {
const JSONParse = jest.spyOn(JSON, 'parse');
JSONParse.mockClear();
const data = require('./data.json');
const data2 = require('data:application/json,{"this is a large JSON object": "that should be converted to JSON.parse by default"}');
const data3 = require('./data1.json');
expect(data).toMatchObject({["123this is a large JSON object"]: "that should be converted to JSON.parse by default"});
expect(data2).toMatchObject({["this is a large JSON object"]: "that should be converted to JSON.parse by default"});
expect(data3).toMatchObject([{"this is a large JSON object": "that should be converted to JSON.parse by default"}]);
expect(JSONParse).toHaveBeenCalledTimes(3);
});
it("should not call JSON.parse when resourceQuery is JSONParse=false", () => {
const JSONParse = jest.spyOn(JSON, 'parse');
JSONParse.mockClear();
const data = require('./data.json?JSONParse=false');
expect(data).toMatchObject({["123this is a large JSON object"]: "that should be converted to JSON.parse by default"});
expect(JSONParse).not.toHaveBeenCalled();
});

View File

@ -0,0 +1,16 @@
/** @type {import("../../../../").Configuration} */
module.exports = {
devtool: false,
mode: "development",
module: {
rules: [
{
test: /\.json$/,
resourceQuery: /JSONParse=false/,
type: "json",
generator: { JSONParse: false }
}
],
generator: { json: { JSONParse: true } }
}
};

15
types.d.ts vendored
View File

@ -5350,6 +5350,11 @@ declare interface GeneratorOptionsByModuleTypeKnown {
* No generator options are supported for this module type. * No generator options are supported for this module type.
*/ */
"javascript/esm"?: EmptyGeneratorOptions; "javascript/esm"?: EmptyGeneratorOptions;
/**
* Generator options for json modules.
*/
json?: JsonGeneratorOptions;
} }
/** /**
@ -7419,6 +7424,16 @@ declare interface JavascriptParserOptions {
*/ */
wrappedContextRegExp?: RegExp; wrappedContextRegExp?: RegExp;
} }
/**
* Generator options for json modules.
*/
declare interface JsonGeneratorOptions {
/**
* Use `JSON.parse` when the JSON string is longer than 20 characters.
*/
JSONParse?: boolean;
}
type JsonObjectFs = { [index: string]: JsonValueFs } & { type JsonObjectFs = { [index: string]: JsonValueFs } & {
[index: string]: [index: string]:
| undefined | undefined