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;
[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.
*/
@ -3882,6 +3891,10 @@ export interface GeneratorOptionsByModuleTypeKnown {
* No generator options are supported for this module type.
*/
"javascript/esm"?: EmptyGeneratorOptions;
/**
* Generator options for json modules.
*/
json?: JsonGeneratorOptions;
}
/**
* 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").InfrastructureLogging} InfrastructureLogging */
/** @typedef {import("../../declarations/WebpackOptions").JavascriptParserOptions} JavascriptParserOptions */
/** @typedef {import("../../declarations/WebpackOptions").JsonGeneratorOptions} JsonGeneratorOptions */
/** @typedef {import("../../declarations/WebpackOptions").Library} Library */
/** @typedef {import("../../declarations/WebpackOptions").LibraryName} LibraryName */
/** @typedef {import("../../declarations/WebpackOptions").LibraryOptions} LibraryOptions */
@ -581,6 +582,14 @@ const applyJavascriptParserOptionsDefaults = (
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 {object} options options
@ -682,6 +691,12 @@ const applyModuleDefaults = (
}
);
F(module.generator, "json", () => ({}));
applyJsonGeneratorOptionsDefaults(
/** @type {NonNullable<GeneratorOptionsByModuleTypeKnown["json"]>} */
(module.generator.json)
);
if (css) {
F(module.parser, CSS_MODULE_TYPE, () => ({}));

View File

@ -13,6 +13,7 @@ const { JS_TYPES } = require("../ModuleSourceTypesConstants");
const RuntimeGlobals = require("../RuntimeGlobals");
/** @typedef {import("webpack-sources").Source} Source */
/** @typedef {import("../../declarations/WebpackOptions").JsonGeneratorOptions} JsonGeneratorOptions */
/** @typedef {import("../ExportsInfo")} ExportsInfo */
/** @typedef {import("../Generator").GenerateContext} GenerateContext */
/** @typedef {import("../Module").ConcatenationBailoutReasonContext} ConcatenationBailoutReasonContext */
@ -106,6 +107,14 @@ const createObjectForExportsInfo = (data, exportsInfo, runtime) => {
};
class JsonGenerator extends Generator {
/**
* @param {JsonGeneratorOptions} options options
*/
constructor(options) {
super();
this.options = options;
}
/**
* @param {NormalModule} module fresh module
* @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
const jsonStr = /** @type {string} */ (stringifySafe(finalJson));
const jsonExpr =
jsonStr.length > 20 && typeof finalJson === "object"
this.options.JSONParse &&
jsonStr.length > 20 &&
typeof finalJson === "object"
? `/*#__PURE__*/JSON.parse('${jsonStr.replace(/[\\']/g, "\\$&")}')`
: jsonStr.replace(/"__proto__":/g, '["__proto__"]:');
/** @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";
/**
@ -46,7 +55,10 @@ class JsonModulesPlugin {
});
normalModuleFactory.hooks.createGenerator
.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",
"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": {
"$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": {
"description": "Specifies the layer in which modules of this entrypoint are placed.",
"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,
"parser": Object {
"asset": Object {
@ -2290,7 +2294,7 @@ describe("snapshots", () => {
+ },
+ "resolve": Object {
+ "fullySpecified": true,
+ },
@@ ... @@
+ },
+ ],
+ "test": /\\.wasm$/i,
@ -2299,7 +2303,7 @@ describe("snapshots", () => {
+ Object {
+ "mimetype": "application/wasm",
+ "rules": Array [
+ Object {
@@ ... @@
+ "descriptionData": Object {
+ "type": "module",
+ },
@ -2331,12 +2335,11 @@ describe("snapshots", () => {
+ "resolve": Object {
+ "fullySpecified": true,
+ "preferRelative": true,
@@ ... @@
+ },
+ "type": "css",
+ },
+ Object {
@@ ... @@
- "generator": Object {},
+ "generator": Object {
+ "css": Object {
+ "esModule": true,
+ "exportsOnly": false,
@ -2353,14 +2356,12 @@ describe("snapshots", () => {
+ "exportsConvention": "as-is",
+ "localIdentName": "[uniqueName]-[id]-[local]",
+ },
+ },
@@ ... @@
+ },
@@ ... @@
+ "css": Object {
+ "import": true,
+ "namedExports": true,
+ "url": true,
+ },
@@ ... @@
+ "exportsPresence": "error",
@@ ... @@

View File

@ -1672,6 +1672,19 @@ Object {
"multiple": false,
"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 {
"configs": Array [
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.
*/
"javascript/esm"?: EmptyGeneratorOptions;
/**
* Generator options for json modules.
*/
json?: JsonGeneratorOptions;
}
/**
@ -7419,6 +7424,16 @@ declare interface JavascriptParserOptions {
*/
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 } & {
[index: string]:
| undefined