fec-builder
Version:
通用的前端构建工具,屏蔽业务无关的细节配置,开箱即用
345 lines (294 loc) • 11.4 kB
text/typescript
import { mapValues } from 'lodash'
import fs from 'fs'
import path from 'path'
import files from '../constants/files'
import { extend, watchFile } from '.'
import { getBuildConfigFilePath, abs, getBuildRoot } from './paths'
import logger from './logger'
import { Transform } from '../constants/transform'
export interface Engines {
/** required builder version range */
builder: string
}
export interface TestConfig {
/** files to run before each test */
setupFiles: string[]
/** map for modules, like https://facebook.github.io/jest/docs/en/configuration.html#modulenamemapper-object-string-string */
moduleNameMapper: Record<string, string>
}
export enum PolyfillType {
/** 在全局环境做 polyfill,适合应用 */
Global = 'global',
/** 在运行的上下文做 polyfill,不污染全局,适合工具类库 */
Runtime = 'runtime'
}
export type AddPolyfill = boolean | PolyfillType
export function shouldAddPolyfill(value: AddPolyfill) {
return !!value
}
export function shouldAddGlobalPolyfill(value: AddPolyfill) {
return value === true || value === PolyfillType.Global
}
export function shouldAddRuntimePolyfill(value: AddPolyfill) {
return value === PolyfillType.Runtime
}
export interface Optimization {
/** 是否抽取 entries 间的公共内容到单独的文件中 */
extractCommon: boolean
/** 抽取固定依赖行为 */
extractVendor: boolean | string[]
/** 是否压缩图片 */
compressImage: boolean
/** 是否对第三方依赖包的 Javascript 内容进行转换 */
transformDeps: boolean | string[]
/** 是否开启自动 polyfill 功能,以及开启何种形式的 polyfill */
addPolyfill: AddPolyfill
/** 是否提供高质量的 source map */
highQualitySourceMap: boolean
}
export interface EnvVariables {
[key: string]: unknown
}
export type Entries = Record<string, string>
export interface PageInput {
template: string
entries: string | string[]
path: string
}
export interface Page extends PageInput {
entries: string[]
}
export type PagesInput = Record<string, PageInput>
export type Pages = Record<string, Page>
export interface TransformObject {
transformer: Transform
config?: unknown
}
export type TransformsInput = Record<string, Transform | TransformObject>
export type Transforms = Record<string, TransformObject>
export type DevProxy = Record<string, string>
export interface QiniuDeploy {
target: 'qiniu'
config: {
accessKey: string
secretKey: string
bucket: string
}
}
export type Deploy = QiniuDeploy
export interface Targets {
browsers: string[]
}
export interface BuildConfigInput {
/** target config to extend */
extends?: string
publicUrl?: string
srcDir?: string
staticDir?: string
distDir?: string
entries?: Entries
pages?: PagesInput
transforms?: TransformsInput
/** 注入到代码中的环境变量 */
envVariables?: EnvVariables,
optimization?: Optimization
devProxy?: DevProxy
deploy?: Deploy
targets?: Targets
test?: TestConfig
engines?: Engines
}
export interface BuildConfig extends Required<BuildConfigInput> {
pages: Pages
transforms: Transforms
}
/** merge two config content */
function mergeConfig(cfg1: BuildConfigInput, cfg2: BuildConfigInput): BuildConfigInput {
return extend(cfg1, cfg2, {
transforms: extend(cfg1.transforms, cfg2.transforms) as TransformsInput,
envVariables: extend(cfg1.envVariables, cfg2.envVariables),
optimization: extend(cfg1.optimization, cfg2.optimization) as Optimization,
test: extend(cfg1.test, cfg2.test) as TestConfig,
engines: extend(cfg1.engines, cfg2.engines) as Engines
})
}
/** parse config content */
const parseConfig = (cnt: string) => JSON.parse(cnt) as BuildConfigInput
/** read and parse config content */
const readConfig = (configFilePath: string) => {
const configFileRawContent = fs.readFileSync(configFilePath, { encoding: 'utf8' })
const configFileContent = parseConfig(configFileRawContent)
return configFileContent
}
/** check if extends target should be looked up from deps (`node_modules/`) */
function isExtendsTargetFromDeps(
/** name of extends target */
name: string
): boolean {
// 满足以下格式的 name 认为应该从 `node_modules/` 中查找
return (
/^build-config-/.test(name) // build-config-xxx
|| /@[^/]+\/build-config(-|$|\/)/.test(name) // @xxx/build-config / @xxx/build-config-xxx
)
}
function tryResolve(name: string, paths: string[]): string | null {
logger.debug('resolve name:', name)
logger.debug('resolve paths:', paths)
try {
const result = require.resolve(name, { paths })
logger.debug(`resolve result: ${result}`)
return result
} catch (e) {
logger.debug('resolve error:', e)
return null
}
}
/** lookup extends target */
function lookupExtendsTarget (
/** name of extends target */
name: string,
/** path of source config file */
sourceConfigFilePath: string
): Promise<string> {
logger.debug(`lookup extends target config: ${name}`)
if (isExtendsTargetFromDeps(name)) {
logger.debug(`lookup extends target in node_modules/: ${name}`)
const paths = [sourceConfigFilePath, getBuildRoot()] // 从这俩路径出发进行查找
const resolvedPath = ( // 分别尝试查找 `${name}/build-config.json` 以及 `name`
tryResolve(path.join(name, files.config), paths)
|| tryResolve(name, paths)
)
if (resolvedPath == null) {
return Promise.reject(new Error(`lookup ${name} in node_modules/ failed, you may forget to add it as deps of your project`))
}
return Promise.resolve(resolvedPath)
}
const presetConfigFilePath = path.resolve(__dirname, `../../preset-configs/${name}.json`)
logger.debug(`try preset config: ${presetConfigFilePath}`)
if (fs.existsSync(presetConfigFilePath)) {
logger.debug(`found preset config: ${presetConfigFilePath}`)
return Promise.resolve(presetConfigFilePath)
}
const sourceConfigFileDir = path.dirname(sourceConfigFilePath)
const localConfigFilePath = path.resolve(sourceConfigFileDir, name)
logger.debug(`try local config: ${localConfigFilePath}`)
if (fs.existsSync(localConfigFilePath)) {
logger.debug(`found local config: ${localConfigFilePath}`)
return Promise.resolve(localConfigFilePath)
}
const localConfigFilePathWithExtension = path.resolve(sourceConfigFileDir, `${name}.json`)
logger.debug(`try local config with extension: ${localConfigFilePathWithExtension}`)
if (fs.existsSync(localConfigFilePathWithExtension)) {
logger.debug(`found local config with extension: ${localConfigFilePathWithExtension}`)
return Promise.resolve(localConfigFilePathWithExtension)
}
const message = `lookup extends target config failed: ${name}`
logger.debug(message)
return Promise.reject(new Error(message))
}
/** get extends target content */
async function getExtendsTarget(
/** name of extends target */
name: string,
/** path of source config file */
sourceConfigFilePath: string
): Promise<BuildConfigInput> {
const configFilePath = await lookupExtendsTarget(name, sourceConfigFilePath)
return readAndResolveConfig(configFilePath)
}
/** resolve config content by recursively get and merge config to `extends` */
async function readAndResolveConfig(
/** path of given config */
configFilePath: string
): Promise<BuildConfigInput> {
const config = readConfig(configFilePath)
const extendsTarget = config.hasOwnProperty('extends') ? config['extends'] : 'default'
if (!extendsTarget) {
return Promise.resolve(config)
}
const extendsConfig = await getExtendsTarget(extendsTarget, configFilePath)
return mergeConfig(extendsConfig, config)
}
function normalizePage({ template, entries: _entries, path }: PageInput): Page {
const entries = typeof _entries === 'string' ? [_entries] : _entries
return { template, path, entries }
}
function normalizePages(input: PagesInput): Pages {
return mapValues(input, normalizePage)
}
function normalizeTransforms(input: TransformsInput): Transforms {
return mapValues(input, value => (
typeof value === 'string'
? { transformer: value }
: value
))
}
function normalizeConfig({
extends: _extends, publicUrl: _publicUrl, srcDir, staticDir, distDir, entries, pages: _pages,
transforms: _transforms, envVariables, optimization, devProxy,
deploy, targets, test, engines
}: BuildConfigInput): BuildConfig {
if (_extends == null) throw new Error('Invalid value of field extends')
if (_publicUrl == null) throw new Error('Invalid value of field publicUrl')
if (srcDir == null) throw new Error('Invalid value of field srcDir')
if (staticDir == null) throw new Error('Invalid value of field staticDir')
if (distDir == null) throw new Error('Invalid value of field distDir')
if (entries == null) throw new Error('Invalid value of field entries')
if (_pages == null) throw new Error('Invalid value of field pages')
if (_transforms == null) throw new Error('Invalid value of field transforms')
if (envVariables == null) throw new Error('Invalid value of field envVariables')
if (optimization == null) throw new Error('Invalid value of field optimization')
if (devProxy == null) throw new Error('Invalid value of field devProxy')
if (deploy == null) throw new Error('Invalid value of field deploy')
if (targets == null) throw new Error('Invalid value of field targets')
if (test == null) throw new Error('Invalid value of field test')
if (engines == null) throw new Error('Invalid value of field engines')
const publicUrl = _publicUrl.replace(/\/?$/, '/')
const pages = normalizePages(_pages)
const transforms = normalizeTransforms(_transforms)
return {
extends: _extends, publicUrl, srcDir, staticDir, distDir, entries, pages,
transforms, envVariables, optimization, devProxy,
deploy, targets, test, engines
}
}
/** 获取最终使用的 build config 文件路径 */
function resolveBuildConfigFilePath() {
// 若指定了 build config file path,则使用之
// 否则使用 build root 下的 build config 文件
return getBuildConfigFilePath() || abs(files.config)
}
let cached: Promise<BuildConfig> | null = null
/** find config file and resolve config content based on paths info */
export async function findBuildConfig(disableCache = false): Promise<BuildConfig> {
if (cached && !disableCache) {
return cached
}
const configFilePath = resolveBuildConfigFilePath()
logger.debug(`use build config file: ${configFilePath}`)
return cached = readAndResolveConfig(configFilePath).then(
config => {
const normalized = normalizeConfig(config)
logger.debug('result build config:')
logger.debug(normalized)
return normalized
}
)
}
let needAnalyze = false
/** Whether analyze bundle */
export function getNeedAnalyze() {
return needAnalyze
}
export function setNeedAnalyze(value: boolean) {
needAnalyze = value
}
/** watch build config, call listener when build config changes */
export function watchBuildConfig(listener: (config: BuildConfig) => void) {
const configFilePath = resolveBuildConfigFilePath()
logger.debug(`watch build config file: ${configFilePath}`)
return watchFile(configFilePath, async () => {
const buildConfig = await findBuildConfig(true) // 把 build config 缓存刷掉
listener(buildConfig)
})
}