UNPKG

fec-builder

Version:

通用的前端构建工具,屏蔽业务无关的细节配置,开箱即用

440 lines (382 loc) 14.6 kB
import produce from 'immer' import { Configuration, RuleSetRule } from 'webpack' import postcssPresetEnv from 'postcss-preset-env' import MiniCssExtractPlugin from 'mini-css-extract-plugin' import { Transform } from '../constants/transform' import { BuildConfig, TransformObject, shouldAddGlobalPolyfill, AddPolyfill, shouldAddRuntimePolyfill } from '../utils/build-conf' import { Env, getEnv } from '../utils/build-env' import logger from '../utils/logger' import { LoaderInfo, adaptLoader, makeRule, addDefaultExtension } from '../utils/webpack' // ts-loader 开启 transpileOnly 后会出的 warning const tsTranspileOnlyWarningPattern = /export .* was not found in/ export interface Condition { /** 需要处理的资源 */ include: string /** 需要排除的资源 */ exclude: string[] } /** 添加资源转换逻辑(buildConfig.transforms) */ export function addTransforms( /** 当前 webpack 配置 */ config: Configuration, /** 构建配置 build config */ buildConfig: BuildConfig ): Configuration { const transformConfigs = Object.entries(buildConfig.transforms).map(([condition, transform]) => { const [extensionValue, contextValue = ''] = condition.split('@') const resource: Condition = { include: extensionValue, exclude: [] } const context: Condition = { include: contextValue, exclude: [] } return { transform, resource, context } }) // 处理 extension / context 冲突的 transform 项 transformConfigs.forEach(transformConfig => { const { resource, context } = transformConfig // 若存在其他 extension 比当前 extension 更精确,则在当前项中排除之; // 如 css & module.css,则在 css 对应项中排除 module.css const extensionValues = transformConfigs.map(({ resource }) => resource.include) resource.exclude = extensionValues.filter( target => target !== resource.include && endsWithExt(target, resource.include) ) // 若存在其他项 extension 与当前 extension 相同,而 context 更精确,则在当前项中排除之; // 如 svg@css & svg@module.css,则在 svg@css 对应项中排除 svg@module.css const contextValues = transformConfigs.filter( targetConfig => targetConfig.resource.include === resource.include ).map(({ context }) => context.include) context.exclude = contextValues.filter( target => target !== context.include && endsWithExt(target, context.include) ) }) transformConfigs.forEach(({ transform, resource, context }) => { config = addTransform(config, buildConfig, transform, resource, context) }) return config } /** 对 svgo 的配置 */ export const svgoConfig = { plugins: [{ name: 'preset-default', params: { overrides: { // removeViewBox 会导致指定了 width & height 的 svg 文件的 viewBox 被删掉, // 而删掉 viewBox 会导致 svg 不能在外部指定 css 宽高时正确地缩放内容 removeViewBox: false } } }] } interface TransformStyleConfig { modules?: boolean options?: unknown } type BabelPreset = string | [string, ...unknown[]] type BabelPlugin = string | [string, ...unknown[]] // babel-loader options(同 babel options) type BabelOptions = { presets?: BabelPreset[] plugins?: BabelPlugin[] sourceType?: string } type TransformBabelConfig = BabelOptions type TransformJsxConfig = { babelOptions?: BabelOptions } type TransformTsConfig = { // 默认开发模式跳过类型检查,提高构建效率,另,避免过严的限制 transpileOnlyWhenDev?: boolean babelOptions?: BabelOptions } function addTransform( /** 当前 webpack 配置 */ config: Configuration, /** 构建配置 build config */ { targets, optimization }: BuildConfig, /** transform 信息 */ transform: TransformObject, /** 资源后缀名条件 */ resource: Condition, /** 上下文资源(引入当前资源的资源)后缀名条件 */ context: Condition ) { // resource.include 当前处理的文件后缀 const extension = resource.include const excludePatterns = resource.exclude.map(makeExtensionPattern) // 针对后缀为 js 的 transform,控制范围(不对依赖做转换) if (extension === 'js') { const jsExcludePattern = makeJsExcludePattern(optimization.transformDeps) if (jsExcludePattern != null) excludePatterns.push(jsExcludePattern) } const resourcePattern = { include: makeExtensionPattern(extension), exclude: excludePatterns } const contextPattern = { include: context.include ? makeExtensionPattern(context.include) : undefined, exclude: context.exclude.map(makeExtensionPattern) } const appendRule = (previousConfig: Configuration, ruleBase: Partial<RuleSetRule>) => produce(previousConfig, (nextConfig: Configuration) => { const rule = makeRule(extension, resourcePattern, contextPattern, ruleBase) nextConfig.module!.rules!.push(rule) }) const appendRuleWithLoaders = (previousConfig: Configuration, ...loaders: LoaderInfo[]) => ( appendRule(previousConfig, { use: loaders.map(adaptLoader) }) ) const appendRuleWithAssetType = (previousConfig: Configuration, assetType: string) => ( appendRule(previousConfig, { type: assetType }) ) const markDefaultExtension = (previousConfig: Configuration) => { return addDefaultExtension(previousConfig, extension) } switch(transform.transformer) { case Transform.Css: case Transform.Less: { const transformConfig = (transform.config || {}) as TransformStyleConfig const loaders: LoaderInfo[] = [] if (getEnv() === Env.Dev) { loaders.push({ loader: 'style-loader' }) } else if (getEnv() === Env.Prod) { // dev 环境不能用 MiniCssExtractPlugin.loader // 已知 less with css-module 的项目,样式 hot reload 会有问题 loaders.push({ loader: MiniCssExtractPlugin.loader }) } loaders.push({ loader: 'css-loader', options: { // https://github.com/webpack-contrib/css-loader/issues/228#issuecomment-312885975 importLoaders: transform.transformer === Transform.Css ? 1 : 0, modules: ( transformConfig.modules ? { localIdentName: '[local]_[hash:base64:5]' } : false ) } }) loaders.push({ loader: 'postcss-loader', options: { postcssOptions: { plugins: [ postcssPresetEnv({ browsers: targets.browsers.join(', ') }) ] } }}) if (transform.transformer === Transform.Less) { loaders.push({ loader: 'less-loader', options: { lessOptions: transformConfig.options } }) } return appendRuleWithLoaders(config, ...loaders) } case Transform.Babel: { config = markDefaultExtension(config) const transformConfig = (transform.config || {}) as TransformBabelConfig return appendRuleWithLoaders(config, { loader: 'babel-loader', options: makeBabelLoaderOptions( transformConfig, targets.browsers, optimization.addPolyfill ) }) } case Transform.Jsx: { config = markDefaultExtension(config) const transformConfig = (transform.config || {}) as TransformJsxConfig return appendRuleWithLoaders(config, { loader: 'babel-loader', options: makeBabelLoaderOptions( transformConfig.babelOptions || {}, targets.browsers, optimization.addPolyfill, true ) }) } case Transform.Ts: case Transform.Tsx: { config = markDefaultExtension(config) const transformConfig: Required<TransformTsConfig> = { transpileOnlyWhenDev: true, babelOptions: {}, ...(transform.config as TransformTsConfig) } const babelOptions = makeBabelLoaderOptions( transformConfig.babelOptions, targets.browsers, optimization.addPolyfill, transform.transformer === Transform.Tsx ) const compilerOptions = { // 这里设置为 ESNext(最新的规范能力),进一步的转换由 babel 处理 target: 'ESNext', // enable tree-shaking,由 webpack 来做 module 格式的转换 module: 'ESNext', // module 为 ESNext 时,moduleResolution 默认为 Classic(虽然 TS 文档不是这么说的),这里明确指定为 Node moduleResolution: 'Node' } const tsLoaderOptions = { transpileOnly: getEnv() === Env.Dev && transformConfig.transpileOnlyWhenDev, compilerOptions, // 方便项目直接把内部依赖(portal-base / fe-core 等)的源码 link 进来一起构建调试 allowTsInNodeModules: true } if (tsLoaderOptions.transpileOnly) { // 干掉因为开启 transpileOnly 导致的 warning // 详情见 https://github.com/TypeStrong/ts-loader#transpileonly config = produce(config, newConfig => { newConfig.stats ??= {} if (typeof(newConfig.stats) === 'boolean' || typeof(newConfig.stats) === 'string') { throw new Error("Expect config.stats to be object.") } const originFilter = newConfig.stats.warningsFilter ?? [] const warningsFilter = Array.isArray(originFilter) ? originFilter : [originFilter] if (!warningsFilter.includes(tsTranspileOnlyWarningPattern)) { logger.debug('append warningsFilter:', tsTranspileOnlyWarningPattern) warningsFilter.push(tsTranspileOnlyWarningPattern) newConfig.stats.warningsFilter = warningsFilter } }) } return appendRuleWithLoaders( config, { loader: 'babel-loader', options: babelOptions }, // 这边预期 ts-loader 将 ts 代码编成 ES6 代码,然后再交给 babel-loader 处理 { loader: 'ts-loader', options: tsLoaderOptions } ) } case Transform.Raw: { return appendRuleWithAssetType(config, 'asset/source') } case Transform.File: { return appendRuleWithAssetType(config, 'asset') } case Transform.SvgSprite: { throw new Error('Transform svg-sprite is not supported any more.') } case Transform.Svgr: { const svgrOptions = ( getEnv() === Env.Prod && optimization.compressImage ? { svgo: true, svgoConfig } : { svgo: false } ) return appendRuleWithLoaders(config, { loader: '@svgr/webpack', options: svgrOptions }) } default: { throw new Error(`Invalid transformer: ${transform.transformer}`) } } } function endsWithExt(target: string, ext: string) { return target.endsWith(ext ? ('.' + ext) : '') } function regexpEscape(s: string) { return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') } function makeExtensionPattern(ext: string) { return new RegExp(`\\.${regexpEscape(ext)}\$`) } /** 构造处理 Javascript 内容时排除依赖用的正则 */ function makeJsExcludePattern( /** 是否排除依赖,或需要被处理(不应该被排除)的依赖包名 */ transformDeps: boolean | string[] ) { if (Array.isArray(transformDeps)) { return new RegExp(`node_modules/(?!(${transformDeps.join('|')})/).*`) } return transformDeps ? null : /node_modules\// } // 不支持 preset 简写的形式 function adaptBabelPreset(preset: BabelPreset): BabelPreset { if (typeof preset === 'string') { return require.resolve(preset) } const [name, ...options] = preset return [require.resolve(name), ...options] } // TODO: 添加 babel-plugin- 前缀 function adaptBabelPluginName(name: string) { return require.resolve(name) } function adaptBabelPlugin(plugin: BabelPlugin): BabelPlugin { if (typeof plugin === 'string') { return adaptBabelPluginName(plugin) } const [name, ...options] = plugin return [adaptBabelPluginName(name), ...options] } type BabelPresetOrPlugin = BabelPreset | BabelPlugin function includes<T extends BabelPresetOrPlugin>(list: T[], name: string) { return list.some(item => { const itemName = typeof item === 'string' ? item : item[0] return itemName === name }) } const corejsOptions = { version: 3, proposals: false } function getBabelPresetEnvOptions(targets: string[], polyfill: AddPolyfill) { return { // enable tree-shaking,由 webpack 来做 module 格式的转换 modules: false, targets, ...( // global polyfill shouldAddGlobalPolyfill(polyfill) && { // https://babeljs.io/docs/en/babel-preset-env#usebuiltins useBuiltIns: 'usage', corejs: corejsOptions } ) } } /** * 构造 babel-loader 的配置对象,主要是添加默认的 polyfill 相关配置 * 另外会调整 preset、plugin 的名字为绝对路径 */ function makeBabelLoaderOptions( /** babel options */ options: TransformBabelConfig, /** babel env targets: https://babeljs.io/docs/en/babel-preset-env#targets */ targets: string[], /** polyfill 模式 */ polyfill: AddPolyfill, /** 是否 react 项目 */ withReact = false ) { options = options || {} const isDev = getEnv() === Env.Dev return produce(options, nextOptions => { const presets = nextOptions.presets || [] const presetEnvName = '@babel/preset-env' if (!isDev && !includes(presets, presetEnvName)) { presets.unshift([presetEnvName, getBabelPresetEnvOptions(targets, polyfill)]) } const presetReactName = '@babel/preset-react' if (withReact && !includes(presets, presetReactName)) { presets.push([presetReactName, { development: isDev }]) } nextOptions.presets = presets.map(adaptBabelPreset) const plugins = nextOptions.plugins || [] const pluginTransformRuntimeName = '@babel/plugin-transform-runtime' if (!isDev && shouldAddRuntimePolyfill(polyfill) && !includes(plugins, pluginTransformRuntimeName)) { plugins.unshift([pluginTransformRuntimeName, { corejs: corejsOptions }]) } const pluginReactRefreshName = 'react-refresh/babel' if (withReact && isDev && !includes(plugins, pluginReactRefreshName)) { plugins.push(pluginReactRefreshName) } nextOptions.plugins = plugins.map(adaptBabelPlugin) // 用于指定预期模块类型,若用户未指定,则使用默认值 unambiguous,即:自动推断 nextOptions.sourceType = nextOptions.sourceType || 'unambiguous' }) }