You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

NextJS项目中Storybook触发webpack无限循环重建、反复热重载问题求助

问题现象
  • 在NextJS项目中运行Storybook时,出现无实际文件变更下的无限循环构建流程:每次webpack构建完成后立即触发下一轮构建,CPU占用率飙升,设备电量快速消耗
  • 实际构建日志(每次构建产物hash完全一致,无真实内容变更):
webpack building...
webpack built preview 4f726ddcef7523fb95db in 20394ms
webpack building...
webpack built preview 4f726ddcef7523fb95db in 8835ms
webpack building...
webpack built preview 4f726ddcef7523fb95db in 8763ms
webpack building...
webpack built preview 4f726ddcef7523fb95db in 7692ms
webpack building...
webpack built preview 4f726ddcef7523fb95db in 10166ms
webpack building...
webpack built preview 4f726ddcef7523fb95db in 8239ms
webpack building...
webpack built preview 4f726ddcef7523fb95db in 8956ms
webpack building...
webpack built preview 4f726ddcef7523fb95db in 7353ms
webpack building...
现有配置文件

main.js(Storybook主配置)

const path = require('path');

module.exports = {
  stories: ['../**/*.stories.mdx', '../**/*.stories.@(js|jsx|ts|tsx)'],
  addons: ['@storybook/addon-links', '@storybook/addon-essentials'],
  presets: [path.resolve(__dirname, './next-preset.js')],
};

next-preset.js(Webpack自定义配置)

const path = require('path');

module.exports = {
  webpackFinal: async (baseConfig, options) => {
    const { module = {} } = baseConfig;

    const newConfig = {
      ...baseConfig,
      module: {
        ...module,
        rules: [...(module.rules || [])],
      },
    };

    newConfig.resolve.modules.push(path.resolve(__dirname, '../'));

    newConfig.module.rules.push({
      test: /\.(ts|tsx)$/,
      include: [path.resolve(__dirname, '../')],
      use: [
        {
          loader: 'babel-loader',
          options: {
            presets: ['next/babel'],
            plugins: ['react-docgen'],
          },
        },
      ],
    });
    newConfig.resolve.extensions.push('.ts', '.tsx');

    // Remove original less loader
    newConfig.module.rules = baseConfig.module.rules.filter(
      (f) => f.test.toString() !== '/\\.less$/'
    );
    newConfig.module.rules.push({
      test: /\.less$/,
      include: [
        /[\\/]node_modules[\\/].*antd/,
        path.resolve(__dirname, '../'),
      ],
      use: [
        'style-loader',
        'css-loader',
        {
          loader: 'less-loader',
          options: {
            javascriptEnabled: true,
          },
        },
      ],
    });

    newConfig.module.rules.find(
      (rule) => rule.test.toString() === '/\\.css$/'
    ).exclude = /\.module\.css$/;

    newConfig.module.rules.push({
      test: /\.module\.css$/,
      include: [path.resolve(__dirname, '../')],
      use: [
        'style-loader',
        {
          loader: 'css-loader',
          options: {
            importLoaders: 1,
            modules: true,
          },
        },
      ],
    });

    newConfig.resolve.alias['/images'] = path.resolve(
      __dirname,
      '../public/images'
    );

    return newConfig;
  },
};

preview.js(Storybook预览层配置)

import React from 'react';
import { RouterContext } from 'next/dist/next-server/lib/router-context';
import * as NextImage from 'next/image';

require('../src/assets/styles/antd.less');

export const parameters = {
  actions: { argTypesRegex: '^on[A-Z].*' },
};

export const decorators = [
  (Story) => (
    <RouterContext.Provider
      value={{
        push: () => Promise.resolve(),
        replace: () => Promise.resolve(),
        prefetch: () => Promise.resolve(),
      }}
    >
      <Story />
    </RouterContext.Provider>
  ),
];

Object.defineProperty(NextImage, 'default', {
  configurable: true,
  value: (props) => {
    const height = props.height;
    const width = props.width;
    const quotient = height / width;
    const paddingTop = isNaN(quotient) ? '100%' : `${quotient * 100}%`;
    let wrapperStyle;
    let sizerStyle;
    let sizerSvg;
    let toBase64;
    let imgStyle = {
      position: 'absolute',
      top: 0,
      left: 0,
      bottom: 0,
      right: 0,
      boxSizing: 'border-box',
      padding: 0,
      border: 'none',
      margin: 'auto',
      display: 'block',
      width: 0,
      height: 0,
      minWidth: '100%',
      maxWidth: '100%',
      minHeight: '100%',
      maxHeight: '100%',
      objectFit: props.objectFit ? props.objectFit : undefined,
      objectPosition: props.objectPosition ? props.objectPosition : undefined,
    };

    if (
      width !== undefined &&
      height !== undefined &&
      props.layout !== 'fill'
    ) {
      if (props.layout === 'responsive') {
        wrapperStyle = {
          display: 'block',
          overflow: 'hidden',
          position: 'relative',
          boxSizing: 'border-box',
          margin: 0,
        };
        sizerStyle = {
          display: 'block',
          boxSizing: 'border-box',
          paddingTop,
        };
      } else if (props.layout === 'intrinsic' || props.layout === undefined) {
        wrapperStyle = {
          display: 'inline-block',
          maxWidth: '100%',
          overflow: 'hidden',
          position: 'relative',
          boxSizing: 'border-box',
          margin: 0,
        };
        sizerStyle = {
          boxSizing: 'border-box',
          display: 'block',
          maxWidth: '100%',
        };
        sizerSvg = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg" version="1.1"/>`;
        toBase64 = Buffer.from(sizerSvg).toString('base64');
      } else if (props.layout === 'fixed') {
        wrapperStyle = {
          overflow: 'hidden',
          boxSizing: 'border-box',
          display: 'inline-block',
          position: 'relative',
          width,
          height,
        };
      }
    } else if (
      width === undefined &&
      height === undefined &&
      props.layout === 'fill'
    ) {
      wrapperStyle = {
        display: 'block',
        overflow: 'hidden',
        position: 'absolute',
        top: 0,
        left: 0,
        bottom: 0,
        right: 0,
        boxSizing: 'border-box',
        margin: 0,
      };
    } else {
      throw new Error(
        `Image with src "${props.src}" must use "width" and "height" properties or "layout='fill'" property.`
      );
    }

    return (
      <div style={wrapperStyle}>
        {sizerStyle ? (
          <div style={sizerStyle}>
            {sizerSvg ? (
              <img
                style={{ maxWidth: '100%', display: 'block' }}
                alt={props.alt}
                aria-hidden={true}
                role='presentation'
                src={`data:image/svg+xml;base64,${toBase64}`}
              />
            ) : null}
          </div>
        ) : null}
        <img {...props} decoding='async' style={imgStyle} />
      </div>
    );
  },
});
问题根因与解决方案
  • 核心触发点:Storybook stories 扫描范围配置过宽
    原配置使用无边界的../**/*.stories.xxx通配符,会从.storybook目录向上遍历整个项目根目录,无差别扫描所有子目录下符合文件名规则的文件。一旦NextJS构建、Storybook自身构建的临时输出文件落在扫描范围内,文件监听器就会触发下一轮构建,构建过程生成的新临时文件又会被再次捕获,形成无限循环。因为构建输入完全一致,输出hash永远不会变化,和日志表现完全匹配。
    修复方式:收窄stories扫描范围,明确指向源码存放目录,排除所有构建产物目录:

    // 修改 main.js
    stories: [
      // 仅扫描src目录下的故事文件,根据实际项目调整路径
      '../src/**/*.stories.mdx',
      '../src/**/*.stories.@(js|jsx|ts|tsx)'
    ],
    
  • 辅助触发点:Webpack监听规则缺失/配置错误

    1. 自定义Webpack配置时未手动补充监听排除规则,导致Storybook会捕获NextJS构建目录(.next)、静态导出目录(storybook-static)内的文件变更,把无关的文件变动识别为源码更新。
    2. 原有Less规则、CSS规则的过滤逻辑存在字符串转义错误,导致过滤条件永远不成立,重复叠加相同的加载规则,造成模块构建缓存反复失效,加剧循环构建的性能损耗。
      修复方式:
    // 在 next-preset.js 的 webpackFinal 函数返回配置前补充以下内容
    // 1. 补充监听排除规则
    newConfig.watchOptions = {
      ignored: [
        /node_modules/,
        /\.next/,
        /storybook-static/,
        /\.out/,
        /dist/
      ]
    }
    
    // 2. 修正Less规则的过滤判断逻辑
    newConfig.module.rules = baseConfig.module.rules.filter(
      (f) => f.test?.toString() !== /\.less$/.toString()
    );
    
    // 3. 修正原有CSS规则的匹配判断逻辑
    newConfig.module.rules.find(
      (rule) => rule.test?.toString() === /\.css$/.toString()
    ).exclude = /\.module\.css$/;
    
  • preview.js中重写next/image的逻辑、Router上下文Provider的配置不会直接触发构建循环,无需调整,仅需确保其所在的.storybook目录不会被stories扫描规则捕获即可。

验证步骤
  • 启动Storybook时添加--no-manager-cache参数,清除旧配置缓存避免问题复现
  • 无手动修改文件的状态下,观察构建日志:不会自动触发连续的多次webpack building...流程即为修复生效
  • CPU占用率回落到正常开发区间,无异常持续满载的情况

内容的提问来源于stack exchange,提问作者gdvander

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.10.26 13:36:02