近期,團(ling)隊(dao)準(yao)備(qiu)從Vue技術棧轉向React技術棧,並且特別指定了使用Ant Design的設計組件庫,出於相關生態考慮,咱們決定採用螞蟻金服團隊相關react方案。選擇理由以下:一來是React原裝全家桶比較散,引包組裝比較麻煩;二來是國內React生態相關方面阿里開源及社區作的比較突出,於是咱們決定使用阿里React相關技術棧。基於組件庫及相關可視化展現等因素,咱們選擇了螞蟻金服團隊的開源生態圈:umi + dva + antd + antv ( ps:最佳實踐案例是Ant Desgin Pro),固然淘系的飛冰相關React技術棧作的也很突出,但權衡以後,咱們最終仍是選擇了螞蟻金服團隊的React技術棧。做爲整個生態圈最爲核心的部分,umi可謂是王冠上的紅寶石,於是我的認爲對於整個umi架構內核的學習及設計哲學的理解,可能好比何使用要來的更爲重要;做爲一個使用者,但願能從各位大佬的源碼中汲取一些營養以及得到一些靈感,私覺得:思想的拓展要遠比不斷地重複勞做要來的重要!javascript
umi基於的是一種微內核架構方式,其核心是隻保留架構的核心功能,將其餘需求服務以插件的形式載入進來,「即插即用,不用即走」,於是又稱爲「插件化架構」,對於想具體瞭解微內核的童鞋,能夠看這篇文章微內核架構,總結來說就是「如無必要,勿增實體」,只保留最精簡、最核心的部分。css
對於umi來講,其具體是經過「約定大於配置」的核心理念,將技術收斂,讓開發者更集中精力於業務的開發,對於更具體的能夠看看雲謙(陳成)的大佬2019 SEE Conf的分享 雲謙 - 螞蟻金服前端框架探索之路前端
本質上umi最後會導出一個基於EventEmitter的Service的類,用戶經過Config中的配置將插件與核心Service關聯,umi將react-router和react-router-dom內嵌進了框架中,從而能夠「約定式定義路由」,此處能夠對比Next.js的方案java
packagesnode
corereact
srcwebpack
核心源碼在於core目錄下的Config、Route及Service,微內核中最最最核心的就是這個Service類,其餘都是基於其進行的相關的擴展與融合,重點分析Service、Route及Config這三個目錄中的源碼git
文件名 | 做用 | 備註 |
---|---|---|
Service.ts | 提供整個核心服務類,用於導出服務 | 核心配置 |
getPaths.ts | 獲取文件絕對路徑的核心方法 | 文件路徑 |
PluginAPI.ts | 插件的註冊及接入核心類 | 插件註冊 |
types.ts | 固定值 | 接口及類型 |
enums.ts | 固定值 | 枚舉 |
export default class Service extends EventEmitter { // 項目根路徑 cwd: string; // package.json的絕對路徑 pkg: IPackage; // 跳過的插件 skipPluginIds: Set<string> = new Set<string>(); // 生命週期執行階段 stage: ServiceStage = ServiceStage.uninitialized; // 註冊命令 commands: { [name: string]: ICommand | string; } = {}; // 解析完的插件 plugins: { [id: string]: IPlugin; } = {}; // 插件方法 pluginMethods: { [name: string]: Function; } = {}; // 初始化插件預設 initialPresets: IPreset[]; initialPlugins: IPlugin[]; // 額外的插件預設 _extraPresets: IPreset[] = []; _extraPlugins: IPlugin[] = []; // 用戶配置 userConfig: IConfig; configInstance: Config; config: IConfig | null = null; // babel處理 babelRegister: BabelRegister; // 鉤子函數處理 hooksByPluginId: { [id: string]: IHook[]; } = {}; hooks: { [key: string]: IHook[]; } = {}; // 用戶配置生成的路徑信息 paths: { // 項目根目錄 cwd?: string; // node_modules文件目錄 absNodeModulesPath?: string; // src目錄 absSrcPath?: string; // pages目錄 absPagesPath?: string; // dist導出目錄 absOutputPath?: string; // 生成的.umi目錄 absTmpPath?: string; } = {}; env: string | undefined; ApplyPluginsType = ApplyPluginsType; EnableBy = EnableBy; ConfigChangeType = ConfigChangeType; ServiceStage = ServiceStage; args: any; constructor(opts: IServiceOpts) { super(); this.cwd = opts.cwd || process.cwd(); // 倉庫根目錄,antd pro構建的時候須要一個新的空文件夾 this.pkg = opts.pkg || this.resolvePackage(); this.env = opts.env || process.env.NODE_ENV; // babel處理 this.babelRegister = new BabelRegister(); // 加載環境變量 this.loadEnv(); // 獲取用戶配置 this.configInstance = new Config({ cwd: this.cwd, service: this, localConfig: this.env === 'development', }); // 從.umirc.ts中獲取內容 this.userConfig = this.configInstance.getUserConfig(); // 獲取導出的配置 this.paths = getPaths({ cwd: this.cwd, config: this.userConfig!, env: this.env, }); // 初始化插件 const baseOpts = { pkg: this.pkg, cwd: this.cwd, }; // 初始化預設 this.initialPresets = resolvePresets({ ...baseOpts, presets: opts.presets || [], userConfigPresets: this.userConfig.presets || [], }); // 初始化插件 this.initialPlugins = resolvePlugins({ ...baseOpts, plugins: opts.plugins || [], userConfigPlugins: this.userConfig.plugins || [], }); // 初始化配置及插件放入babel註冊中 this.babelRegister.setOnlyMap({ key: 'initialPlugins', value: lodash.uniq([ ...this.initialPresets.map(({ path }) => path), ...this.initialPlugins.map(({ path }) => path), ]), }); } // 設置生命週期 setStage(stage: ServiceStage) { this.stage = stage; } // 解析package.json的文件 resolvePackage() { try { return require(join(this.cwd, 'package.json')); } catch (e) { return {}; } } // 加載環境 loadEnv() { const basePath = join(this.cwd, '.env'); const localPath = `${basePath}.local`; loadDotEnv(basePath); loadDotEnv(localPath); } // 真正的初始化 async init() { this.setStage(ServiceStage.init); await this.initPresetsAndPlugins(); // 狀態:初始 this.setStage(ServiceStage.initHooks); // 註冊了plugin要執行的鉤子方法 Object.keys(this.hooksByPluginId).forEach((id) => { const hooks = this.hooksByPluginId[id]; hooks.forEach((hook) => { const { key } = hook; hook.pluginId = id; this.hooks[key] = (this.hooks[key] || []).concat(hook); }); }); // 狀態:插件已註冊 this.setStage(ServiceStage.pluginReady); // 執行插件 await this.applyPlugins({ key: 'onPluginReady', type: ApplyPluginsType.event, }); // 狀態:獲取配置信息 this.setStage(ServiceStage.getConfig); // 拿到對應插件的默認配置信息 const defaultConfig = await this.applyPlugins({ key: 'modifyDefaultConfig', type: this.ApplyPluginsType.modify, initialValue: await this.configInstance.getDefaultConfig(), }); // 將實例中的配置信息對應修改的配置信息 this.config = await this.applyPlugins({ key: 'modifyConfig', type: this.ApplyPluginsType.modify, initialValue: this.configInstance.getConfig({ defaultConfig, }) as any, }); // 狀態:合併路徑 this.setStage(ServiceStage.getPaths); if (this.config!.outputPath) { this.paths.absOutputPath = join(this.cwd, this.config!.outputPath); } // 修改路徑對象 const paths = (await this.applyPlugins({ key: 'modifyPaths', type: ApplyPluginsType.modify, initialValue: this.paths, })) as object; Object.keys(paths).forEach((key) => { this.paths[key] = paths[key]; }); } async initPresetsAndPlugins() { this.setStage(ServiceStage.initPresets); this._extraPlugins = []; while (this.initialPresets.length) { await this.initPreset(this.initialPresets.shift()!); } this.setStage(ServiceStage.initPlugins); this._extraPlugins.push(...this.initialPlugins); while (this._extraPlugins.length) { await this.initPlugin(this._extraPlugins.shift()!); } } getPluginAPI(opts: any) { const pluginAPI = new PluginAPI(opts); [ 'onPluginReady', 'modifyPaths', 'onStart', 'modifyDefaultConfig', 'modifyConfig', ].forEach((name) => { pluginAPI.registerMethod({ name, exitsError: false }); }); return new Proxy(pluginAPI, { get: (target, prop: string) => { // 因爲 pluginMethods 須要在 register 階段可用 // 必須經過 proxy 的方式動態獲取最新,以實現邊註冊邊使用的效果 if (this.pluginMethods[prop]) return this.pluginMethods[prop]; if ( [ 'applyPlugins', 'ApplyPluginsType', 'EnableBy', 'ConfigChangeType', 'babelRegister', 'stage', 'ServiceStage', 'paths', 'cwd', 'pkg', 'userConfig', 'config', 'env', 'args', 'hasPlugins', 'hasPresets', ].includes(prop) ) { return typeof this[prop] === 'function' ? this[prop].bind(this) : this[prop]; } return target[prop]; }, }); } async applyAPI(opts: { apply: Function; api: PluginAPI }) { let ret = opts.apply()(opts.api); if (isPromise(ret)) { ret = await ret; } return ret || {}; } // 初始化配置 async initPreset(preset: IPreset) { const { id, key, apply } = preset; preset.isPreset = true; const api = this.getPluginAPI({ id, key, service: this }); // register before apply this.registerPlugin(preset); // TODO: ...defaultConfigs 考慮要不要支持,可能這個需求能夠經過其餘渠道實現 const { presets, plugins, ...defaultConfigs } = await this.applyAPI({ api, apply, }); // register extra presets and plugins if (presets) { assert( Array.isArray(presets), `presets returned from preset ${id} must be Array.`, ); // 插到最前面,下個 while 循環優先執行 this._extraPresets.splice( 0, 0, ...presets.map((path: string) => { return pathToObj({ type: PluginType.preset, path, cwd: this.cwd, }); }), ); } // 深度優先 const extraPresets = lodash.clone(this._extraPresets); this._extraPresets = []; while (extraPresets.length) { await this.initPreset(extraPresets.shift()!); } if (plugins) { assert( Array.isArray(plugins), `plugins returned from preset ${id} must be Array.`, ); this._extraPlugins.push( ...plugins.map((path: string) => { return pathToObj({ type: PluginType.plugin, path, cwd: this.cwd, }); }), ); } } // 初始化插件 async initPlugin(plugin: IPlugin) { const { id, key, apply } = plugin; const api = this.getPluginAPI({ id, key, service: this }); // register before apply this.registerPlugin(plugin); await this.applyAPI({ api, apply }); } getPluginOptsWithKey(key: string) { return getUserConfigWithKey({ key, userConfig: this.userConfig, }); } // 註冊插件 registerPlugin(plugin: IPlugin) { // 考慮要不要去掉這裏的校驗邏輯 // 理論上不會走到這裏,由於在 describe 的時候已經作了衝突校驗 if (this.plugins[plugin.id]) { const name = plugin.isPreset ? 'preset' : 'plugin'; throw new Error(`\ ${name} ${plugin.id} is already registered by ${this.plugins[plugin.id].path}, \ ${name} from ${plugin.path} register failed.`); } this.plugins[plugin.id] = plugin; } isPluginEnable(pluginId: string) { // api.skipPlugins() 的插件 if (this.skipPluginIds.has(pluginId)) return false; const { key, enableBy } = this.plugins[pluginId]; // 手動設置爲 false if (this.userConfig[key] === false) return false; // 配置開啓 if (enableBy === this.EnableBy.config && !(key in this.userConfig)) { return false; } // 函數自定義開啓 if (typeof enableBy === 'function') { return enableBy(); } // 註冊開啓 return true; } // 判斷函數:是否有插件 hasPlugins(pluginIds: string[]) { return pluginIds.every((pluginId) => { const plugin = this.plugins[pluginId]; return plugin && !plugin.isPreset && this.isPluginEnable(pluginId); }); } // 判斷函數:是否有預設 hasPresets(presetIds: string[]) { return presetIds.every((presetId) => { const preset = this.plugins[presetId]; return preset && preset.isPreset && this.isPluginEnable(presetId); }); } // 真正的插件執行函數,基於promise實現 async applyPlugins(opts: { key: string; type: ApplyPluginsType; initialValue?: any; args?: any; }) { const hooks = this.hooks[opts.key] || []; switch (opts.type) { case ApplyPluginsType.add: if ('initialValue' in opts) { assert( Array.isArray(opts.initialValue), `applyPlugins failed, opts.initialValue must be Array if opts.type is add.`, ); } const tAdd = new AsyncSeriesWaterfallHook(['memo']); for (const hook of hooks) { if (!this.isPluginEnable(hook.pluginId!)) { continue; } tAdd.tapPromise( { name: hook.pluginId!, stage: hook.stage || 0, // @ts-ignore before: hook.before, }, async (memo: any[]) => { const items = await hook.fn(opts.args); return memo.concat(items); }, ); } return await tAdd.promise(opts.initialValue || []); case ApplyPluginsType.modify: const tModify = new AsyncSeriesWaterfallHook(['memo']); for (const hook of hooks) { if (!this.isPluginEnable(hook.pluginId!)) { continue; } tModify.tapPromise( { name: hook.pluginId!, stage: hook.stage || 0, // @ts-ignore before: hook.before, }, async (memo: any) => { return await hook.fn(memo, opts.args); }, ); } return await tModify.promise(opts.initialValue); case ApplyPluginsType.event: const tEvent = new AsyncSeriesWaterfallHook(['_']); for (const hook of hooks) { if (!this.isPluginEnable(hook.pluginId!)) { continue; } tEvent.tapPromise( { name: hook.pluginId!, stage: hook.stage || 0, // @ts-ignore before: hook.before, }, async () => { await hook.fn(opts.args); }, ); } return await tEvent.promise(); default: throw new Error( `applyPlugin failed, type is not defined or is not matched, got ${opts.type}.`, ); } } // 運行方法 async run({ name, args = {} }: { name: string; args?: any }) { args._ = args._ || []; if (args._[0] === name) args._.shift(); this.args = args; await this.init(); this.setStage(ServiceStage.run); await this.applyPlugins({ key: 'onStart', type: ApplyPluginsType.event, args: { args, }, }); return this.runCommand({ name, args }); } // 運行命令 async runCommand({ name, args = {} }: { name: string; args?: any }) { assert(this.stage >= ServiceStage.init, `service is not initialized.`); args._ = args._ || []; if (args._[0] === name) args._.shift(); const command = typeof this.commands[name] === 'string' ? this.commands[this.commands[name] as string] : this.commands[name]; assert(command, `run command failed, command ${name} does not exists.`); const { fn } = command as ICommand; return fn({ args }); } }
export default function getServicePaths({ cwd, config, env, }: { cwd: string; config: any; env?: string; }): IServicePaths { // 項目根目錄 let absSrcPath = cwd; // 若是存在src目錄,將absSrcPath定位到src路徑下 if (isDirectoryAndExist(join(cwd, 'src'))) { absSrcPath = join(cwd, 'src'); } // src下是page仍是pages const absPagesPath = config.singular ? join(absSrcPath, 'page') : join(absSrcPath, 'pages'); // 臨時文件路徑 const tmpDir = ['.umi', env !== 'development' && env] .filter(Boolean) .join('-'); // outputPath 指定輸出路徑 return normalizeWithWinPath({ cwd, absNodeModulesPath: join(cwd, 'node_modules'), absOutputPath: join(cwd, config.outputPath || './dist'), absSrcPath, absPagesPath, absTmpPath: join(absSrcPath, tmpDir), }); }
描述插件核心方法的類,插件的編寫需藉助這個api,擴展方法須要在preset-built-in的presets集合中進行擴展github
export default class PluginAPI { // 插件的id,區別不一樣的插件 id: string; // 插件內的不一樣內容,如方法及數據等 key: string; service: Service; Html: typeof Html; utils: typeof utils; logger: Logger; constructor(opts: IOpts) { this.id = opts.id; this.key = opts.key; this.service = opts.service; this.utils = utils; this.Html = Html; this.logger = new Logger(`umi:plugin:${this.id || this.key}`); } // TODO: reversed keys describe({ id, key, config, enableBy, }: { id?: string; key?: string; config?: IPluginConfig; enableBy?: EnableBy | (() => boolean); } = {}) { const { plugins } = this.service; // this.id and this.key is generated automatically // so we need to diff first if (id && this.id !== id) { if (plugins[id]) { const name = plugins[id].isPreset ? 'preset' : 'plugin'; throw new Error( `api.describe() failed, ${name} ${id} is already registered by ${plugins[id].path}.`, ); } plugins[id] = plugins[this.id]; plugins[id].id = id; delete plugins[this.id]; this.id = id; } if (key && this.key !== key) { this.key = key; plugins[this.id].key = key; } if (config) { plugins[this.id].config = config; } plugins[this.id].enableBy = enableBy || EnableBy.register; } // 註冊插件 register(hook: IHook) { assert( hook.key && typeof hook.key === 'string', `api.register() failed, hook.key must supplied and should be string, but got ${hook.key}.`, ); assert( hook.fn && typeof hook.fn === 'function', `api.register() failed, hook.fn must supplied and should be function, but got ${hook.fn}.`, ); this.service.hooksByPluginId[this.id] = ( this.service.hooksByPluginId[this.id] || [] ).concat(hook); } // 註冊插件命令 registerCommand(command: ICommand) { const { name, alias } = command; assert( !this.service.commands[name], `api.registerCommand() failed, the command ${name} is exists.`, ); this.service.commands[name] = command; if (alias) { this.service.commands[alias] = name; } } // 註冊預設 registerPresets(presets: (IPreset | string)[]) { assert( this.service.stage === ServiceStage.initPresets, `api.registerPresets() failed, it should only used in presets.`, ); assert( Array.isArray(presets), `api.registerPresets() failed, presets must be Array.`, ); const extraPresets = presets.map((preset) => { return isValidPlugin(preset as any) ? (preset as IPreset) : pathToObj({ type: PluginType.preset, path: preset as string, cwd: this.service.cwd, }); }); // 插到最前面,下個 while 循環優先執行 this.service._extraPresets.splice(0, 0, ...extraPresets); } // 在 preset 初始化階段放後面,在插件註冊階段放前面 registerPlugins(plugins: (IPlugin | string)[]) { assert( this.service.stage === ServiceStage.initPresets || this.service.stage === ServiceStage.initPlugins, `api.registerPlugins() failed, it should only be used in registering stage.`, ); assert( Array.isArray(plugins), `api.registerPlugins() failed, plugins must be Array.`, ); const extraPlugins = plugins.map((plugin) => { return isValidPlugin(plugin as any) ? (plugin as IPreset) : pathToObj({ type: PluginType.plugin, path: plugin as string, cwd: this.service.cwd, }); }); if (this.service.stage === ServiceStage.initPresets) { this.service._extraPlugins.push(...extraPlugins); } else { this.service._extraPlugins.splice(0, 0, ...extraPlugins); } } // 註冊方法 registerMethod({ name, fn, exitsError = true, }: { name: string; fn?: Function; exitsError?: boolean; }) { if (this.service.pluginMethods[name]) { if (exitsError) { throw new Error( `api.registerMethod() failed, method ${name} is already exist.`, ); } else { return; } } this.service.pluginMethods[name] = fn || // 這裏不能用 arrow function,this 需指向執行此方法的 PluginAPI // 不然 pluginId 會不會,致使不能正確 skip plugin function (fn: Function) { const hook = { key: name, ...(utils.lodash.isPlainObject(fn) ? fn : { fn }), }; // @ts-ignore this.register(hook); }; } // 跳過插件,不執行的插件 skipPlugins(pluginIds: string[]) { pluginIds.forEach((pluginId) => { this.service.skipPluginIds.add(pluginId); }); } }
文件名 | 做用 | 備註 |
---|---|---|
Route.ts | 路由的核心類 | 封裝了路由匹配等方法 |
routesToJSON.ts | 路由轉化爲json的方法 | 用於先後端傳遞 |
getConventionalRoutes.ts | 獲取默認路由 | 前端開發時經常使用寫的路由表 |
class Route { opts: IOpts; constructor(opts?: IOpts) { this.opts = opts || {}; } async getRoutes(opts: IGetRoutesOpts) { // config 用戶 + 插件配置 // root 是 absPagesPath // componentPrefix是路徑的分割符號,默認是 "/" const { config, root, componentPrefix } = opts; // 避免修改配置裏的 routes,致使重複 patch let routes = lodash.cloneDeep(config.routes); let isConventional = false; // 若是用戶沒有自定義,則使用約定式路由;若是配置了則約定式路由無效 if (!routes) { assert(root, `opts.root must be supplied for conventional routes.`); // 默認路由的拼接方式 routes = this.getConventionRoutes({ root: root!, config, componentPrefix, }); isConventional = true; } // 生成的路由能夠被插件新增,修改,刪除 await this.patchRoutes(routes, { ...opts, isConventional, }); return routes; } // TODO: // 1. 移動 /404 到最後,並處理 component 和 redirect async patchRoutes(routes: IRoute[], opts: IGetRoutesOpts) { // 執行插件的 onPatchRoutesBefore 鉤子函數對路由修改 if (this.opts.onPatchRoutesBefore) { await this.opts.onPatchRoutesBefore({ routes, parentRoute: opts.parentRoute, }); } // routes中的route執行patrchRoute方法 for (const route of routes) { await this.patchRoute(route, opts); } // onPatchRoutes進行最終的路由修改 if (this.opts.onPatchRoutes) { await this.opts.onPatchRoutes({ routes, parentRoute: opts.parentRoute, }); } } async patchRoute(route: IRoute, opts: IGetRoutesOpts) { if (this.opts.onPatchRouteBefore) { await this.opts.onPatchRouteBefore({ route, parentRoute: opts.parentRoute, }); } // route.path 的修改須要在子路由 patch 以前作 if ( route.path && route.path.charAt(0) !== '/' && !/^https?:\/\//.test(route.path) ) { route.path = winPath(join(opts.parentRoute?.path || '/', route.path)); } if (route.redirect && route.redirect.charAt(0) !== '/') { route.redirect = winPath( join(opts.parentRoute?.path || '/', route.redirect), ); } // 遞歸 patchRoutes if (route.routes) { await this.patchRoutes(route.routes, { ...opts, parentRoute: route, }); } else { if (!('exact' in route)) { // exact by default route.exact = true; } } // resolve component path if ( route.component && !opts.isConventional && typeof route.component === 'string' && !route.component.startsWith('@/') && !path.isAbsolute(route.component) ) { route.component = winPath(join(opts.root, route.component)); } // resolve wrappers path if (route.wrappers) { route.wrappers = route.wrappers.map((wrapper) => { if (wrapper.startsWith('@/') || path.isAbsolute(wrapper)) { return wrapper; } else { return winPath(join(opts.root, wrapper)); } }); } // onPatchRoute 鉤子函數 if (this.opts.onPatchRoute) { await this.opts.onPatchRoute({ route, parentRoute: opts.parentRoute, }); } } // 約定式路由 getConventionRoutes(opts: any): IRoute[] { return getConventionalRoutes(opts); } getJSON(opts: { routes: IRoute[]; config: IConfig; cwd: string }) { return routesToJSON(opts); } getPaths({ routes }: { routes: IRoute[] }): string[] { return lodash.uniq( routes.reduce((memo: string[], route) => { if (route.path) memo.push(route.path); if (route.routes) memo = memo.concat(this.getPaths({ routes: route.routes })); return memo; }, []), ); } }
// 正則匹配,而後JSON.stringify() export default function ({ routes, config, cwd }: IOpts) { // 由於要往 routes 里加無用的信息,因此必須 deep clone 一下,避免污染 const clonedRoutes = lodash.cloneDeep(routes); if (config.dynamicImport) { patchRoutes(clonedRoutes); } function patchRoutes(routes: IRoute[]) { routes.forEach(patchRoute); } function patchRoute(route: IRoute) { if (route.component && !isFunctionComponent(route.component)) { const webpackChunkName = routeToChunkName({ route, cwd, }); // 解決 SSR 開啓動態加載後,頁面閃爍問題 if (config?.ssr && config?.dynamicImport) { route._chunkName = webpackChunkName; } route.component = [ route.component, webpackChunkName, route.path || EMPTY_PATH, ].join(SEPARATOR); } if (route.routes) { patchRoutes(route.routes); } } function isFunctionComponent(component: string) { return ( /^\((.+)?\)(\s+)?=>/.test(component) || /^function([^\(]+)?\(([^\)]+)?\)([^{]+)?{/.test(component) ); } function replacer(key: string, value: any) { switch (key) { case 'component': if (isFunctionComponent(value)) return value; if (config.dynamicImport) { const [component, webpackChunkName] = value.split(SEPARATOR); let loading = ''; if (config.dynamicImport.loading) { loading = `, loading: LoadingComponent`; } return `dynamic({ loader: () => import(/* webpackChunkName: '${webpackChunkName}' */'${component}')${loading}})`; } else { return `require('${value}').default`; } case 'wrappers': const wrappers = value.map((wrapper: string) => { if (config.dynamicImport) { let loading = ''; if (config.dynamicImport.loading) { loading = `, loading: LoadingComponent`; } return `dynamic({ loader: () => import(/* webpackChunkName: 'wrappers' */'${wrapper}')${loading}})`; } else { return `require('${wrapper}').default`; } }); return `[${wrappers.join(', ')}]`; default: return value; } } return JSON.stringify(clonedRoutes, replacer, 2) .replace(/\"component\": (\"(.+?)\")/g, (global, m1, m2) => { return `"component": ${m2.replace(/\^/g, '"')}`; }) .replace(/\"wrappers\": (\"(.+?)\")/g, (global, m1, m2) => { return `"wrappers": ${m2.replace(/\^/g, '"')}`; }) .replace(/\\r\\n/g, '\r\n') .replace(/\\n/g, '\r\n'); }
須要考慮多種狀況,如:目錄、文件、動態路由等web
// 考慮多種狀況: // 多是目錄,沒有後綴,好比 [post]/add.tsx // 多是文件,有後綴,好比 [id].tsx // [id$] 是可選動態路由 const RE_DYNAMIC_ROUTE = /^\[(.+?)\]/; // 獲取文件,主要就是fs模塊的讀寫問價等方法 function getFiles(root: string) { if (!existsSync(root)) return []; return readdirSync(root).filter((file) => { const absFile = join(root, file); const fileStat = statSync(absFile); const isDirectory = fileStat.isDirectory(); const isFile = fileStat.isFile(); if ( isDirectory && ['components', 'component', 'utils', 'util'].includes(file) ) { return false; } if (file.charAt(0) === '.') return false; if (file.charAt(0) === '_') return false; // exclude test file if (/\.(test|spec|e2e)\.(j|t)sx?$/.test(file)) return false; // d.ts if (/\.d\.ts$/.test(file)) return false; if (isFile) { if (!/\.(j|t)sx?$/.test(file)) return false; const content = readFileSync(absFile, 'utf-8'); try { if (!isReactComponent(content)) return false; } catch (e) { throw new Error( `Parse conventional route component ${absFile} failed, ${e.message}`, ); } } return true; }); } // 文件路由的reducer方法 function fileToRouteReducer(opts: IOpts, memo: IRoute[], file: string) { const { root, relDir = '' } = opts; const absFile = join(root, relDir, file); const stats = statSync(absFile); const __isDynamic = RE_DYNAMIC_ROUTE.test(file); if (stats.isDirectory()) { const relFile = join(relDir, file); const layoutFile = getFile({ base: join(root, relFile), fileNameWithoutExt: '_layout', type: 'javascript', }); const route = { path: normalizePath(relFile, opts), routes: getRoutes({ ...opts, relDir: join(relFile), }), __isDynamic, ...(layoutFile ? { component: layoutFile.path, } : { exact: true, __toMerge: true, }), }; memo.push(normalizeRoute(route, opts)); } else { const bName = basename(file, extname(file)); memo.push( normalizeRoute( { path: normalizePath(join(relDir, bName), opts), exact: true, component: absFile, __isDynamic, }, opts, ), ); } return memo; } // 格式化路由 function normalizeRoute(route: IRoute, opts: IOpts) { let props: unknown = undefined; if (route.component) { try { props = getExportProps(readFileSync(route.component, 'utf-8')); } catch (e) { throw new Error( `Parse conventional route component ${route.component} failed, ${e.message}`, ); } route.component = winPath(relative(join(opts.root, '..'), route.component)); route.component = `${opts.componentPrefix || '@/'}${route.component}`; } return { ...route, ...(typeof props === 'object' ? props : {}), }; } // 格式化路徑 function normalizePath(path: string, opts: IOpts) { path = winPath(path) .split('/') .map((p) => { // dynamic route p = p.replace(RE_DYNAMIC_ROUTE, ':$1'); // :post$ => :post? if (p.endsWith('$')) { p = p.slice(0, -1) + '?'; } return p; }) .join('/'); path = `/${path}`; // /index/index -> / if (path === '/index/index') { path = '/'; } // /xxxx/index -> /xxxx/ path = path.replace(/\/index$/, '/'); // remove the last slash // e.g. /abc/ -> /abc if (path !== '/' && path.slice(-1) === '/') { path = path.slice(0, -1); } return path; } // 格式化路由表 function normalizeRoutes(routes: IRoute[]): IRoute[] { const paramsRoutes: IRoute[] = []; const exactRoutes: IRoute[] = []; const layoutRoutes: IRoute[] = []; routes.forEach((route) => { const { __isDynamic, exact } = route; delete route.__isDynamic; if (__isDynamic) { paramsRoutes.push(route); } else if (exact) { exactRoutes.push(route); } else { layoutRoutes.push(route); } }); assert( paramsRoutes.length <= 1, `We should not have multiple dynamic routes under a directory.`, ); return [...exactRoutes, ...layoutRoutes, ...paramsRoutes].reduce( (memo, route) => { if (route.__toMerge && route.routes) { memo = memo.concat(route.routes); } else { memo.push(route); } return memo; }, [] as IRoute[], ); } // 獲取路由表 export default function getRoutes(opts: IOpts) { const { root, relDir = '', config } = opts; const files = getFiles(join(root, relDir)); const routes = normalizeRoutes( files.reduce(fileToRouteReducer.bind(null, opts), []), ); if (!relDir) { const globalLayoutFile = getFile({ base: root, fileNameWithoutExt: `../${config.singular ? 'layout' : 'layouts'}/index`, type: 'javascript', }); if (globalLayoutFile) { return [ normalizeRoute( { path: '/', component: globalLayoutFile.path, routes, }, opts, ), ]; } } return routes; }
文件名 | 做用 | 備註 |
---|---|---|
Config.ts | 核心配置類 | 關聯用戶輸入與腳手架輸出的中介者 |
export default class Config { cwd: string; service: Service; config?: object; localConfig?: boolean; configFile?: string | null; constructor(opts: IOpts) { this.cwd = opts.cwd || process.cwd(); this.service = opts.service; this.localConfig = opts.localConfig; } // 獲取默認配置 async getDefaultConfig() { const pluginIds = Object.keys(this.service.plugins); // collect default config let defaultConfig = pluginIds.reduce((memo, pluginId) => { const { key, config = {} } = this.service.plugins[pluginId]; if ('default' in config) memo[key] = config.default; return memo; }, {}); return defaultConfig; } // 獲取配置的方法 getConfig({ defaultConfig }: { defaultConfig: object }) { assert( this.service.stage >= ServiceStage.pluginReady, `Config.getConfig() failed, it should not be executed before plugin is ready.`, ); const userConfig = this.getUserConfig(); // 用於提示用戶哪些 key 是未定義的 // TODO: 考慮不排除 false 的 key const userConfigKeys = Object.keys(userConfig).filter((key) => { return userConfig[key] !== false; }); // get config const pluginIds = Object.keys(this.service.plugins); pluginIds.forEach((pluginId) => { const { key, config = {} } = this.service.plugins[pluginId]; // recognize as key if have schema config if (!config.schema) return; const value = getUserConfigWithKey({ key, userConfig }); // 不校驗 false 的值,此時已禁用插件 if (value === false) return; // do validate const schema = config.schema(joi); assert( joi.isSchema(schema), `schema return from plugin ${pluginId} is not valid schema.`, ); const { error } = schema.validate(value); if (error) { const e = new Error( `Validate config "${key}" failed, ${error.message}`, ); e.stack = error.stack; throw e; } // remove key const index = userConfigKeys.indexOf(key.split('.')[0]); if (index !== -1) { userConfigKeys.splice(index, 1); } // update userConfig with defaultConfig if (key in defaultConfig) { const newValue = mergeDefault({ defaultConfig: defaultConfig[key], config: value, }); updateUserConfigWithKey({ key, value: newValue, userConfig, }); } }); if (userConfigKeys.length) { const keys = userConfigKeys.length > 1 ? 'keys' : 'key'; throw new Error(`Invalid config ${keys}: ${userConfigKeys.join(', ')}`); } return userConfig; } // 獲取用戶配置 getUserConfig() { const configFile = this.getConfigFile(); this.configFile = configFile; // 潛在問題: // .local 和 .env 的配置必須有 configFile 纔有效 if (configFile) { let envConfigFile; if (process.env.UMI_ENV) { const envConfigFileName = this.addAffix( configFile, process.env.UMI_ENV, ); const fileNameWithoutExt = envConfigFileName.replace( extname(envConfigFileName), '', ); envConfigFile = getFile({ base: this.cwd, fileNameWithoutExt, type: 'javascript', })?.filename; if (!envConfigFile) { throw new Error( `get user config failed, ${envConfigFile} does not exist, but process.env.UMI_ENV is set to ${process.env.UMI_ENV}.`, ); } } const files = [ configFile, envConfigFile, this.localConfig && this.addAffix(configFile, 'local'), ] .filter((f): f is string => !!f) .map((f) => join(this.cwd, f)) .filter((f) => existsSync(f)); // clear require cache and set babel register const requireDeps = files.reduce((memo: string[], file) => { memo = memo.concat(parseRequireDeps(file)); return memo; }, []); requireDeps.forEach(cleanRequireCache); this.service.babelRegister.setOnlyMap({ key: 'config', value: requireDeps, }); // require config and merge return this.mergeConfig(...this.requireConfigs(files)); } else { return {}; } } addAffix(file: string, affix: string) { const ext = extname(file); return file.replace(new RegExp(`${ext}$`), `.${affix}${ext}`); } requireConfigs(configFiles: string[]) { return configFiles.map((f) => compatESModuleRequire(require(f))); } mergeConfig(...configs: object[]) { let ret = {}; for (const config of configs) { // TODO: 精細化處理,好比處理 dotted config key ret = deepmerge(ret, config); } return ret; } getConfigFile(): string | null { // TODO: support custom config file const configFile = CONFIG_FILES.find((f) => existsSync(join(this.cwd, f))); return configFile ? winPath(configFile) : null; } getWatchFilesAndDirectories() { const umiEnv = process.env.UMI_ENV; const configFiles = lodash.clone(CONFIG_FILES); CONFIG_FILES.forEach((f) => { if (this.localConfig) configFiles.push(this.addAffix(f, 'local')); if (umiEnv) configFiles.push(this.addAffix(f, umiEnv)); }); const configDir = winPath(join(this.cwd, 'config')); const files = configFiles .reduce<string[]>((memo, f) => { const file = winPath(join(this.cwd, f)); if (existsSync(file)) { memo = memo.concat(parseRequireDeps(file)); } else { memo.push(file); } return memo; }, []) .filter((f) => !f.startsWith(configDir)); return [configDir].concat(files); } // 發佈訂閱,監聽用戶配置的修改 watch(opts: { userConfig: object; onChange: (args: { userConfig: any; pluginChanged: IChanged[]; valueChanged: IChanged[]; }) => void; }) { let paths = this.getWatchFilesAndDirectories(); let userConfig = opts.userConfig; const watcher = chokidar.watch(paths, { ignoreInitial: true, cwd: this.cwd, }); watcher.on('all', (event, path) => { console.log(chalk.green(`[${event}] ${path}`)); const newPaths = this.getWatchFilesAndDirectories(); const diffs = lodash.difference(newPaths, paths); if (diffs.length) { watcher.add(diffs); paths = paths.concat(diffs); } const newUserConfig = this.getUserConfig(); const pluginChanged: IChanged[] = []; const valueChanged: IChanged[] = []; Object.keys(this.service.plugins).forEach((pluginId) => { const { key, config = {} } = this.service.plugins[pluginId]; // recognize as key if have schema config if (!config.schema) return; if (!isEqual(newUserConfig[key], userConfig[key])) { const changed = { key, pluginId: pluginId, }; if (newUserConfig[key] === false || userConfig[key] === false) { pluginChanged.push(changed); } else { valueChanged.push(changed); } } }); debug(`newUserConfig: ${JSON.stringify(newUserConfig)}`); debug(`oldUserConfig: ${JSON.stringify(userConfig)}`); debug(`pluginChanged: ${JSON.stringify(pluginChanged)}`); debug(`valueChanged: ${JSON.stringify(valueChanged)}`); if (pluginChanged.length || valueChanged.length) { opts.onChange({ userConfig: newUserConfig, pluginChanged, valueChanged, }); } userConfig = newUserConfig; }); return () => { watcher.close(); }; } }
umi是螞蟻金服前端架構的基石,其餘的各類擴展應用,諸如:antd組件庫、dva數據流等,都是基於umi來構建的,而Ant Design Pro算是螞蟻金服中後臺應用的一個最佳實踐。umi對於自研前端生態的核心基礎庫有着重要的參考價值,對整個生態的支撐也起着「牽一髮而動全身」的做用,若是用一句話來歸納umi的核心設計理念,那就是「約定大於配置」,其餘各類設計都是圍繞着這一設計哲學展開的,於是對於生態的建設要想好我想給外界傳遞一種什麼樣的價值與理念,反覆造輪子是沒有意義的,只有真正能解決問題,好用的輪子才能走的更長遠!