webpack学习之对比 umi 和 create-react-app的打包流程和优化配置

[toc]

对外配置文件对比

umi

.umirc.js

// ref: https://umijs.org/config/
export default {
  treeShaking: true,
  plugins: [
    // ref: https://umijs.org/plugin/umi-plugin-react.html
    ['umi-plugin-react', {
      antd: false,
      dva: false,
      dynamicImport: false,
      title: 'umilearn',
      dll: false,
      
      routes: {
        exclude: [
          /components\//,
        ],
      },
    }],
  ],
}

可以看到umi的配置文件和webpack的标准配置文件明显不同.对于大多数的构建配置做到配置大于约定。后面我们会来看仔细看。

create-react-app

//使用npm run eject 输出配置文件
npm run eject

webpack.config.js


module.exports = function (webpackEnv) {
  const isEnvDevelopment = webpackEnv === 'development';
  const isEnvProduction = webpackEnv === 'production';
  const publicPath = isEnvProduction
    ? paths.servedPath
    : isEnvDevelopment && '/';
  const shouldUseRelativeAssetPaths = publicPath === './';
  const publicUrl = isEnvProduction
    ? publicPath.slice(0, -1)
    : isEnvDevelopment && '';
  const env = getClientEnvironment(publicUrl);
  const getStyleLoaders = (cssOptions, preProcessor) => {
    const loaders = [
      isEnvDevelopment && require.resolve('style-loader'),
      isEnvProduction && {
        loader: MiniCssExtractPlugin.loader,
        options: Object.assign({}, shouldUseRelativeAssetPaths
          ? {
            publicPath: '../../'
          }
          : undefined)
      }, {
        loader: require.resolve('css-loader'),
        options: cssOptions
      }, {
        loader: require.resolve('postcss-loader'),
        options: {
          ident: 'postcss',
          plugins: () => [
            require('postcss-flexbugs-fixes'), require('postcss-preset-env')({
              autoprefixer: {
                flexbox: 'no-2009'
              },
              stage: 3
            })
          ],
          sourceMap: isEnvProduction && shouldUseSourceMap
        }
      }
    ].filter(Boolean);
    if (preProcessor) {
      loaders.push({
        loader: require.resolve(preProcessor),
        options: {
          sourceMap: isEnvProduction && shouldUseSourceMap
        }
      });
    }
    return loaders;
  };
  return {
    mode: isEnvProduction
      ? 'production'
      : isEnvDevelopment && 'development',
    bail: isEnvProduction,
    devtool: isEnvProduction
      ? shouldUseSourceMap
        ? 'source-map'
        : false : isEnvDevelopment && 'cheap-module-source-map',
    entry: [
      isEnvDevelopment && require.resolve('react-dev-utils/webpackHotDevClient'),
      paths.appIndexJs
    ].filter(Boolean),
    output: {
      path: isEnvProduction
        ? paths.appBuild
        : undefined,
      pathinfo: isEnvDevelopment,
      filename: isEnvProduction
        ? 'static/js/[name].[contenthash:8].js'
        : isEnvDevelopment && 'static/js/bundle.js',
      chunkFilename: isEnvProduction
        ? 'static/js/[name].[contenthash:8].chunk.js'
        : isEnvDevelopment && 'static/js/[name].chunk.js',
      publicPath: publicPath,
      devtoolModuleFilenameTemplate: isEnvProduction
        ? info => path
          .relative(paths.appSrc, info.absoluteResourcePath)
          .replace(/\\/g, '/')
        : isEnvDevelopment && (info => path.resolve(info.absoluteResourcePath).replace(/\\/g, '/'))
    },
    optimization: {
      minimize: isEnvProduction,
      minimizer: [
        new TerserPlugin({
          terserOptions: {
            parse: {
              ecma: 8
            },
            compress: {
              ecma: 5,
              warnings: false,
              comparisons: false,
              inline: 2
            },
            mangle: {
              safari10: true
            },
            output: {
              ecma: 5,
              comments: false,
              ascii_only: true
            }
          },
          parallel: true,
          cache: true,
          sourceMap: shouldUseSourceMap
        }),
        new OptimizeCSSAssetsPlugin({
          cssProcessorOptions: {
            parser: safePostCssParser,
            map: shouldUseSourceMap
              ? {
                inline: false,
                annotation: true
              }
              : false
          }
        })
      ],
      splitChunks: {
        chunks: 'all',
        name: false
      },
      runtimeChunk: true
    },
    resolve: {
      modules: ['node_modules'].concat(process.env.NODE_PATH.split(path.delimiter).filter(Boolean)),
      extensions: paths
        .moduleFileExtensions
        .map(ext => `.${ext}`)
        .filter(ext => useTypeScript || !ext.includes('ts')),
      alias: {
        'react-native': 'react-native-web'
      },
      plugins: [
        PnpWebpackPlugin,
        new ModuleScopePlugin(paths.appSrc, [paths.appPackageJson])
      ]
    },
    resolveLoader: {
      plugins: [PnpWebpackPlugin.moduleLoader(module)]
    },
    module: {
      strictExportPresence: true,
      rules: [
        {
          parser: {
            requireEnsure: false
          }
        }, {
          test: /\.(js|mjs|jsx)$/,
          enforce: 'pre',
          use: [
            {
              options: {
                formatter: require.resolve('react-dev-utils/eslintFormatter'),
                eslintPath: require.resolve('eslint')
              },
              loader: require.resolve('eslint-loader')
            }
          ],
          include: paths.appSrc
        }, {
          oneOf: [
            {
              test: [
                /\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/
              ],
              loader: require.resolve('url-loader'),
              options: {
                limit: 10000,
                name: 'static/media/[name].[hash:8].[ext]'
              }
            }, {
              test: /\.(js|mjs|jsx|ts|tsx)$/,
              include: paths.appSrc,
              loader: require.resolve('babel-loader'),
              options: {
                customize: require.resolve('babel-preset-react-app/webpack-overrides'),
                plugins: [
                  [
                    require.resolve('babel-plugin-named-asset-import'), {
                      loaderMap: {
                        svg: {
                          ReactComponent: '@svgr/webpack?-svgo,+ref![path]'
                        }
                      }
                    }
                  ]
                ],
                cacheDirectory: true,
                cacheCompression: isEnvProduction,
                compact: isEnvProduction
              }
            }, {
              test: /\.(js|mjs)$/,
              exclude: /@babel(?:\/|\\{1,2})runtime/,
              loader: require.resolve('babel-loader'),
              options: {
                babelrc: false,
                configFile: false,
                compact: false,
                presets: [
                  [
                    require.resolve('babel-preset-react-app/dependencies'), {
                      helpers: true
                    }
                  ]
                ],
                cacheDirectory: true,
                cacheCompression: isEnvProduction,
                sourceMaps: false
              }
            }, {
              test: cssRegex,
              exclude: cssModuleRegex,
              use: getStyleLoaders({
                importLoaders: 1,
                sourceMap: isEnvProduction && shouldUseSourceMap
              }),
              sideEffects: true
            }, {
              test: cssModuleRegex,
              use: getStyleLoaders({
                importLoaders: 1,
                sourceMap: isEnvProduction && shouldUseSourceMap,
                modules: true,
                getLocalIdent: getCSSModuleLocalIdent
              })
            }, {
              test: sassRegex,
              exclude: sassModuleRegex,
              use: getStyleLoaders({
                importLoaders: 2,
                sourceMap: isEnvProduction && shouldUseSourceMap
              }, 'sass-loader'),
              sideEffects: true
            }, {
              test: sassModuleRegex,
              use: getStyleLoaders({
                importLoaders: 2,
                sourceMap: isEnvProduction && shouldUseSourceMap,
                modules: true,
                getLocalIdent: getCSSModuleLocalIdent
              }, 'sass-loader')
            }, {
              loader: require.resolve('file-loader'),
              exclude: [
                /\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/
              ],
              options: {
                name: 'static/media/[name].[hash:8].[ext]'
              }
            }
          ]
        }
      ]
    },
    plugins: [
      new HtmlWebpackPlugin(Object.assign({}, {
        inject: true,
        template: paths.appHtml
      }, isEnvProduction
        ? {
          minify: {
            removeComments: true,
            collapseWhitespace: true,
            removeRedundantAttributes: true,
            useShortDoctype: true,
            removeEmptyAttributes: true,
            removeStyleLinkTypeAttributes: true,
            keepClosingSlash: true,
            minifyJS: true,
            minifyCSS: true,
            minifyURLs: true
          }
        }
        : undefined)),
      isEnvProduction && shouldInlineRuntimeChunk && new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime~.+[.]js/]),
      new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw),
      new ModuleNotFoundPlugin(paths.appPath),
      new webpack.DefinePlugin(env.stringified),
      isEnvDevelopment && new webpack.HotModuleReplacementPlugin(),
      isEnvDevelopment && new CaseSensitivePathsPlugin(),
      isEnvDevelopment && new WatchMissingNodeModulesPlugin(paths.appNodeModules),
      isEnvProduction && new MiniCssExtractPlugin({filename: 'static/css/[name].[contenthash:8].css', chunkFilename: 'static/css/[name].[contenthash:8].chunk.css'}),
      new ManifestPlugin({fileName: 'asset-manifest.json', publicPath: publicPath}),
      new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
      isEnvProduction && new WorkboxWebpackPlugin.GenerateSW({
        clientsClaim: true,
        exclude: [
          /\.map$/, /asset-manifest\.json$/
        ],
        importWorkboxFrom: 'cdn',
        navigateFallback: publicUrl + '/index.html',
        navigateFallbackBlacklist: [new RegExp('^/_'), new RegExp('/[^/]+\\.[^/]+$')]
      }),
      useTypeScript && new ForkTsCheckerWebpackPlugin({
        typescript: resolve.sync('typescript', {basedir: paths.appNodeModules}),
        async: isEnvDevelopment,
        useTypescriptIncrementalApi: true,
        checkSyntacticErrors: true,
        tsconfig: paths.appTsConfig,
        reportFiles: [
          '**',
          '!**/*.json',
          '!**/__tests__/**',
          '!**/?(*.)(spec|test).*',
          '!**/src/setupProxy.*',
          '!**/src/setupTests.*'
        ],
        watch: paths.appSrc,
        silent: true,
        formatter: isEnvProduction
          ? typescriptFormatter
          : undefined
      })
    ].filter(Boolean),
    node: {
      module: 'empty',
      dgram: 'empty',
      dns: 'mock',
      fs: 'empty',
      net: 'empty',
      tls: 'empty',
      child_process: 'empty'
    },
    performance: false
  };
};


可以看出create-react-app 提供出了一份接近标准的webpack配置文件。entry和output都明显列出。

构建流程

create-react-app

scripts/build.js

const config = configFactory('production');
checkBrowsers(paths.appPath, isInteractive)
  .then(() => {
    return measureFileSizesBeforeBuild(paths.appBuild);
  })
  .then(previousFileSizes => {
    fs.emptyDirSync(paths.appBuild);
    copyPublicFolder();
    return build(previousFileSizes);
  })
  .then(
    ({ stats, previousFileSizes, warnings }) => {
     //.....
  });

// Create the production build and print the deployment instructions.
function build(previousFileSizes) {
  console.log('Creating an optimized production build...');

  let compiler = webpack(config);
  return new Promise((resolve, reject) => {
    compiler.run((err, stats) => {
      let messages;
     //....
    });
  });
}

从build的脚本中可以明确的看出来构建流程为

graph LR
A(导入生产的配置文件)-->B(检查文件目录)
B-->C(计算机上次输出文件大小,并清空)
C-->D(把公共文件复制到输出目录)
D-->E(交给webpack打包)

构建流程非常简单,对于只想做简单的单页应用的开发者来说应该来说是比较合适的.

umi的构建过程

脚本方法调用

umi/src/cli.js --> umi/src/scripts/builds->umi-build-dev/lib/service->run()

umi那么如何将将一个简单的可扩展的配置文件转换成一个标准的webpack配置文件呢.

umi 的整个生命周期都是插件化的,甚至其内部实现就是由大量插件组成,比如 pwa、按需加载、一键切换 preact、一键兼容 ie9 等等,都是由插件实现

umi的插件机制设计的尤为巧妙。我们来以打包方式为入口来分析一下

umi-build-dev/src/Service.js

class Service{
    //插件注册的命令
    this.commands = {};
    //所有应用的插件钩子
    this.pluginHooks = {};
    //所有内置的插件方法
    this.pluginMethods = {};
    //初始插件
    initPlugin(plugin) {
        const { id, apply, opts } = plugin;
        //通过代理PlugApi使得 插件可以通过api.[theapiname] 访问到预置的pluginMethods 并注入钩子,
        //总感觉这里设计的有点不合理.
        const api = new Proxy(new PluginAPI(id, this), {
        get: (target, prop) => {
          if (this.pluginMethods[prop]) {
            return this.pluginMethods[prop];
          }
          
      });
      apply(api, opts);
      plugin._api = api;
    } catch (e) {
      if (process.env.UMI_TEST) {
        throw new Error(e);
      } else {
        signale.error(
          `
Plugin ${chalk.cyan.underline(id)} initialize failed

${getCodeFrame(e, { cwd: this.cwd })}
        `.trim(),
        );
        debug(e);
        process.exit(1);
      }
    }
  }
    //应用插件方法
    applyPlugins(){
    //通过reduce方法对于不同的插件注册的同一方法 进行有序调用,并记录调用结果。
    return (this.pluginHooks[key] || []).reduce((memo, { fn }) => {
          try {
            return fn({
              memo,
              args: opts.args,
            });
          } catch (e) {
            console.error(chalk.red(`Plugin apply failed: ${e.message}`));
            throw e;
          }
    }, opts.initialValue);
    }
    
    runCommand(rawName, rawArgs) {
    
        const { fn, opts } = command;
        if (opts.webpack) {
          // webpack config
          this.webpackConfig = require('./getWebpackConfig').default(this);
        }
    
        return fn(args);
  }
}

下面我们来build插件也是作为umi生命周期的一部分是如何来实现的.
umi-build-dev/src/plugins/commands/build

export default function(api) {
  const { service, debug, config, log } = api;
  const { cwd, paths } = service;

  api.registerCommand(
    'build',
    {
      webpack: true,
      description: 'building for production',
    },
    () => {
      notify.onBuildStart({ name: 'umi', version: 2 });
      const RoutesManager = getRouteManager(service);
      RoutesManager.fetchRoutes();
      return new Promise((resolve, reject) => {
        process.env.NODE_ENV = 'production';
        service.applyPlugins('onStart');
        service._applyPluginsAsync('onStartAsync').then(() => {
          const filesGenerator = getFilesGenerator(service, {
            RoutesManager,
            mountElementId: config.mountElementId,
          });
          filesGenerator.generate();
          if (process.env.HTML !== 'none') {
            const HtmlGeneratorPlugin = require('../getHtmlGeneratorPlugin').default(
              service,
            );
            service.webpackConfig.plugins.unshift(new HtmlGeneratorPlugin());
          }
          require('af-webpack/build').default({
            cwd,
            webpackConfig: service.webpackConfig,
            onSuccess({ stats }) {
             if (process.env.RM_TMPDIR !== 'none') {
                debug(`Clean tmp dir ${service.paths.tmpDirPath}`);
                rimraf.sync(paths.absTmpDirPath);
              }
              .,...
            },
            onFail({ err, stats }) {
    
              });
        
            },
          });
        });
      });
    },
  );
}

可以看出 build.js中 注册了命令build 获取service中的webpackconfig 传入af-webpack 进一步处理.
当然这里最重要的还是去生成路由等相关文件,再交由af-webpack 去生成.
再build成功后也会及时清除这些文件,并且调用预留hooks(onBuildSuccess,onBuildSuccessAsync).可以通过这里去实现一些前端部署的方法。
那么webpackconfig到底有哪些约定的配置呢。可以看到
umi-build-dev/src/plugins/afwebpack-config


export default function(api) {
  const { debug, cwd, config, paths } = api;

  // 把 af-webpack 的配置插件转化为 umi-build-dev 的
  api._registerConfig(() => {
    return plugins
      .filter(p => !excludes.includes(p.name))
      .map(({ name, validate = noop }) => {
        return api => ({
          name,
          validate,
          onChange(newConfig) {
            try {
              debug(
                `Config ${name} changed to ${JSON.stringify(newConfig[name])}`,
              );
            } catch (e) {}
            if (name === 'proxy') {
              global.g_umi_reloadProxy(newConfig[name]);
            } else {
              api.service.restart(`${name} changed`);
            }
          },
        });
      });
  });

  const reactDir = compatDirname(
    'react/package.json',
    cwd,
    dirname(require.resolve('react/package.json')),
  );
  const reactDOMDir = compatDirname(
    'react-dom/package.json',
    cwd,
    dirname(require.resolve('react-dom/package.json')),
  );
  const reactRouterDir = compatDirname(
    'react-router/package.json',
    cwd,
    dirname(require.resolve('react-router/package.json')),
  );
  const reactRouterDOMDir = compatDirname(
    'react-router-dom/package.json',
    cwd,
    dirname(require.resolve('react-router-dom/package.json')),
  );
  const reactRouterConfigDir = compatDirname(
    'react-router-config/package.json',
    cwd,
    dirname(require.resolve('react-router-config/package.json')),
  );
  api.chainWebpackConfig(webpackConfig => {
    webpackConfig.resolve.alias
      .set('react', reactDir)
      .set('react-dom', reactDOMDir)
      .set('react-router', reactRouterDir)
      .set('react-router-dom', reactRouterDOMDir)
      .set('react-router-config', reactRouterConfigDir)
      .set(
        'history',
        compatDirname(
          'umi-history/package.json',
          cwd,
          dirname(require.resolve('umi-history/package.json')),
        ),
      )
      .set('@', paths.absSrcPath)
      .set('@tmp', paths.absTmpDirPath)
      .set('umi/link', join(process.env.UMI_DIR, 'lib/link.js'))
      .set('umi/dynamic', join(process.env.UMI_DIR, 'lib/dynamic.js'))
      .set('umi/navlink', join(process.env.UMI_DIR, 'lib/navlink.js'))
      .set('umi/redirect', join(process.env.UMI_DIR, 'lib/redirect.js'))
      .set('umi/prompt', join(process.env.UMI_DIR, 'lib/prompt.js'))
      .set('umi/router', join(process.env.UMI_DIR, 'lib/router.js'))
      .set('umi/withRouter', join(process.env.UMI_DIR, 'lib/withRouter.js'))
      .set(
        'umi/_renderRoutes',
        join(process.env.UMI_DIR, 'lib/renderRoutes.js'),
      )
      .set(
        'umi/_createHistory',
        join(process.env.UMI_DIR, 'lib/createHistory.js'),
      )
      .set(
        'umi/_runtimePlugin',
        join(process.env.UMI_DIR, 'lib/runtimePlugin.js'),
      );
  });

  api.addVersionInfo([
    `react@${require(join(reactDir, 'package.json')).version} (${reactDir})`,
    `react-dom@${
      require(join(reactDOMDir, 'package.json')).version
    } (${reactDOMDir})`,
    `react-router@${
      require(join(reactRouterDir, 'package.json')).version
    } (${reactRouterDir})`,
    `react-router-dom@${
      require(join(reactRouterDOMDir, 'package.json')).version
    } (${reactRouterDOMDir})`,
    `react-router-config@${
      require(join(reactRouterConfigDir, 'package.json')).version
    } (${reactRouterConfigDir})`,
  ]);

  api.modifyAFWebpackOpts(memo => {
    const isDev = process.env.NODE_ENV === 'development';

    const entryScript = join(cwd, `./${paths.tmpDirPath}/umi.js`);
    const setPublicPathFile = join(
      __dirname,
      '../../../template/setPublicPath.js',
    );
    const setPublicPath =
      config.runtimePublicPath ||
      (config.exportStatic && config.exportStatic.dynamicRoot);
    const entry = isDev
      ? {
          umi: [
            ...(process.env.HMR === 'none' ? [] : [webpackHotDevClientPath]),
            ...(setPublicPath ? [setPublicPathFile] : []),
            entryScript,
          ],
        }
      : {
          umi: [...(setPublicPath ? [setPublicPathFile] : []), entryScript],
        };

    const targets = {
      chrome: 49,
      firefox: 64,
      safari: 10,
      edge: 13,
      ios: 10,
      ...(config.targets || {}),
    };

    // Transform targets to browserslist for autoprefixer
    const browserslist =
      config.browserslist ||
      targets.browsers ||
      Object.keys(targets)
        .filter(key => {
          return !['node', 'esmodules'].includes(key);
        })
        .map(key => {
          return `${key} >= ${targets[key]}`;
        });

    return {
      ...memo,
      ...config,
      cwd,
      browserslist,
      entry,
      absNodeModulesPath: paths.absNodeModulesPath,
      outputPath: paths.absOutputPath,
      disableDynamicImport: true,
      babel: config.babel || {
        presets: [
          [
            require.resolve('babel-preset-umi'),
            {
              targets,
              env: {
                useBuiltIns: 'entry',
                ...(config.treeShaking ? { modules: false } : {}),
              },
            },
          ],
        ],
        plugins: [require.resolve('./lockCoreJSVersionPlugin')],
      },
      define: {
        'process.env.BASE_URL': config.base || '/',
        __UMI_BIGFISH_COMPAT: process.env.BIGFISH_COMPAT,
        __UMI_HTML_SUFFIX: !!(
          config.exportStatic &&
          typeof config.exportStatic === 'object' &&
          config.exportStatic.htmlSuffix
        ),
        ...(config.define || {}),
      },
      publicPath: isDev
        ? '/'
        : config.publicPath != null
        ? config.publicPath
        : '/',
    };
  });
}

umi在这里先配置了主要的几个重要配置包括
entry,publicPath,babel等重要参数
又利用webpack-chain 设置resolve中的alias确保 umi的相关api被准确引用到.ih
但是仅靠这里的配置是不够的.看到getWebPackConfig.js

import getConfig from 'af-webpack/getConfig';
import assert from 'assert';

export default function(service) {
  const { config } = service;

  const afWebpackOpts = service.applyPlugins('modifyAFWebpackOpts', {
    initialValue: {
      cwd: service.cwd,
    },
  });

  assert(
    !('chainConfig' in afWebpackOpts),
    `chainConfig should not supplied in modifyAFWebpackOpts`,
  );
  afWebpackOpts.chainConfig = webpackConfig => {
    service.applyPlugins('chainWebpackConfig', {
      args: webpackConfig,
    });
    if (config.chainWebpack) {
      config.chainWebpack(webpackConfig, {
        webpack: require('af-webpack/webpack'),
      });
    }
  };

  return service.applyPlugins('modifyWebpackConfig', {
    initialValue: getConfig(afWebpackOpts),
  });
}

可以得知 最初的配置还是要从af-webpack中获取的.

看到af-webpack/src/getConfig/index.js

太长,不沾了。简单总结一下
配置入口,输入,配置相关resolve
添加babel css url svg 等相关处理loader
增加必要插件如 从public中粘贴文件进入输出文件夹
最终返回一个标准的webpack配置。

最终看到af-webpack/src/build

export default function build(opts = {}) {
  const { webpackConfig, cwd = process.cwd(), onSuccess, onFail } = opts;
  assert(webpackConfig, 'webpackConfig should be supplied.');
  assert(isPlainObject(webpackConfig), 'webpackConfig should be plain object.');

  debug(
    `Clean output path ${webpackConfig.output.path.replace(`${cwd}/`, '')}`,
  );
  rimraf.sync(webpackConfig.output.path);

  debug('build start');
  webpack(webpackConfig, (err, stats) => {
    debug('build done');

    if (err || stats.hasErrors()) {
      if (onFail) {
        onFail({ err, stats });
      }
      if (!process.env.UMI_TEST) {
        process.exit(1);
      }
    }

    console.log('File sizes after gzip:\n');
    printFileSizesAfterBuild(
      stats,
      {
        root: webpackConfig.output.path,
        sizes: {},
      },
      webpackConfig.output.path,
      WARN_AFTER_BUNDLE_GZIP_SIZE,
      WARN_AFTER_CHUNK_GZIP_SIZE,
    );
    console.log();

    if (onSuccess) {
      onSuccess({ stats });
    }
  });
}

将配置信息传入webpack处理.

总结

不管是umi和create-react-app,最终都是将核心打包工作交给webpack实现。我们通过打印配置信息来对比一下两个脚手架在webpack配置项中的重要几点优化的处理.

对比项 umi create-react-app
搜索范围优化(resolve)
js & css压缩处理
sass&&less&svg处理
动态导入
插件缓存使用
ts 支持
moment locale优化 可配置
进度指示

你可能感兴趣的:(webpack学习之对比 umi 和 create-react-app的打包流程和优化配置)