UNPKG

san-cli-service

Version:

定制化的前端工程构建工具

526 lines (485 loc) 18.8 kB
/** * Copyright (c) Baidu Inc. All rights reserved. * * This source code is licensed under the MIT license. * See LICENSE file in the project root for license information. * * @file Service Class * inspired by https://github.com/vuejs/vue-cli/blob/dev/packages/%40vue/cli-service/lib/Service.js */ const {resolve, isAbsolute, join, dirname} = require('path'); const EventEmitter = require('events').EventEmitter; const {logger: consola, time, timeEnd, chalk, getDebugLogger} = require('san-cli-utils/ttyLogger'); const importLazy = require('import-lazy')(require); const fs = require('fs-extra'); const Config = importLazy('webpack-chain'); const webpackMerge = importLazy('webpack-merge'); const resolvePlugin = importLazy('./resolvePlugin'); const defaultsDeep = require('lodash.defaultsdeep'); const lMerge = require('lodash.merge'); const dotenv = require('dotenv'); const SError = require('san-cli-utils/SError'); const PluginAPI = require('./PluginAPI'); const {findExisting} = require('san-cli-utils/path'); const {textCommonColor} = require('san-cli-utils/color'); const argsert = require('san-cli-utils/argsert'); const readPkg = require('san-cli-utils/readPkg'); const {defaults: defaultConfig, validateSync: validateOptions} = require('./options'); const BUILDIN_PLUGINS = ['base', 'css', 'app', 'optimization']; const logger = consola.withTag('Service'); const debug = getDebugLogger('service'); const showConfig = getDebugLogger('webpack:config'); /* global Map, Proxy */ module.exports = class Service extends EventEmitter { constructor( name, { cwd, configFile, watch = false, mode = process.env.NODE_ENV, autoLoadConfigFile = true, plugins = [], useBuiltInPlugin = true, useProgress = true, useProfiler = false, useDashboard = false, projectOptions = {} } = {} ) { super(); // 不使用进度条 this.useProgress = useProgress; // 发送CLI UI信息的IPC服务 this.useDashboard = useDashboard; // webpackbar 的 profiler 需要开启进度条才能使用 this.useProfiler = useProgress && useProfiler; // watch模式 this.useWatchMode = watch; // 配置文件 this.configFile = configFile; // 不存在的时候,是否主动查找&加载本地的 san.config.js this.autoLoadConfigFile = autoLoadConfigFile; // 添加 global 配置,san config.js 使用 global.__isProduction = mode === 'production'; // mode this.mode = mode; // 名字,目前用于进度条 this.name = name; // 工作目录 this.cwd = cwd || process.cwd(); // logger this.logger = consola; // pkg this.pkg = readPkg(this.cwd); this.initialized = false; this._initProjectOptions = projectOptions; // webpack chain & merge array this.webpackChainFns = []; this.webpackRawConfigFns = []; this.devServerMiddlewares = []; // 插件 this.plugins = this.resolvePlugins(plugins, useBuiltInPlugin); } loadEnv(mode) { const load = envPath => { let env = {}; try { const content = fs.readFileSync(envPath); env = dotenv.parse(content) || {}; debug('loadEnv envPath %s', envPath); debug('loadEnv env object %O', env); } catch (err) { // 文件不存在 if (err.toString().indexOf('ENOENT') < 0) { logger.error(err); } else { return {}; } } return env; }; const merge = obj => { Object.keys(obj).forEach(key => { if (!process.env.hasOwnProperty(key)) { process.env[key] = obj[key]; } }); }; let defaultEnv = {}; const defaultEnvPath = join(this.cwd, '.env'); if (fs.existsSync(defaultEnvPath)) { defaultEnv = Object.assign(defaultEnv, load(defaultEnvPath)); } // this._configDir // 后续为:local 内容 const modeEnvName = `.env${mode ? `.${mode}` : ''}`; const envPath = findExisting([modeEnvName].map(k => join(this.cwd, k))); if (!envPath) { // 不存在默认的,则不往下执行了 merge(defaultEnv); return; } const localEnvPath = `${envPath}.local`; const localEnv = load(localEnvPath); defaultEnv = Object.assign(defaultEnv, load(envPath)); const envObj = Object.assign(defaultEnv, localEnv); Object.keys(envObj).forEach(key => { if (!process.env.hasOwnProperty(key)) { process.env[key] = envObj[key]; } }); merge(envObj); if (mode) { const defaultNodeEnv = mode === 'production' ? mode : 'development'; // 下面属性如果为空,会根据 mode 设置的 ['NODE_ENV', 'BABEL_ENV'].forEach(k => { if (!process.env[k]) { process.env[k] = defaultNodeEnv; } }); } } resolvePlugins(plugins = [], useBuiltInPlugin = true) { // 0. 判断是否需要加载 builtin plugin let builtInPlugins = []; if (useBuiltInPlugin) { builtInPlugins = BUILDIN_PLUGINS.map(id => require(`./configs/${id}`)); // * 添加上 babel 插件 builtInPlugins.push(require('san-cli-plugin-babel')); } plugins = Array.isArray(plugins) ? plugins : []; if (plugins.length) { // 2. 真正加载 plugins = plugins.map(this._loadPlugin.bind(this)); plugins = [...builtInPlugins, ...plugins]; } else { plugins = builtInPlugins; } return plugins; } _loadPlugin(p) { let pluginOptions; if (Array.isArray(p) && p.length === 2) { // 带有参数的plugin 配置 pluginOptions = p[1]; p = p[0]; } if (typeof p === 'string') { // 处理引入 try { // 是从工作目录开始的 // san cli 内部使用 require let plugin = require(resolvePlugin(p, this.cwd)); if (plugin.__esModule) { // 重新赋值 esmodule plugin = plugin.default; } if (typeof plugin === 'object' && typeof plugin.apply === 'function') { if (!plugin.id) { logger.warn(`Plugin is invalid: ${p}. Service plugin must has id.`); // 默认 id 是配置的string,方便查找 plugin.id = p; } // 这里支持两种情况: // 1. 普通 plugin,没有参数 // 2. plugin 是 array,则第二个 value 是 options // 这样兼容同一个 plugin 多次调用 options 不同情况 if (pluginOptions) { debug('Plugin loaded: %s with options %O', chalk.magenta(plugin.id), pluginOptions); return [plugin, pluginOptions]; } debug('Plugin loaded: %s', chalk.magenta(plugin.id)); return plugin; } logger.error(`Plugin is invalid: ${p}. Service plugin must has id and apply function!`); } catch (e) { logger.error(`Plugin load failed: ${p}`); logger.error(e); } } else if (typeof p === 'object' && p.id && typeof p.apply === 'function') { // 处理 object if (pluginOptions) { return [p, pluginOptions]; } return p; } else { logger.error('Service plugin is invalid'); if (p && p.toString() === '[object Object]') { logger.error(p); } } } init(mode) { if (this.initialized) { // 初始化过一次之后就不需要二次了 // 注意这里会导致 configFile 这类二次修改不会生效 return this; } this.initialized = true; this.mode = mode; this.plugins.forEach(plugin => { this.initPlugin(plugin); }); // webpack 配置 if (this.projectOptions.chainWebpack) { this.webpackChainFns.push(this.projectOptions.chainWebpack); } if (this.projectOptions.configWebpack) { this.webpackRawConfigFns.push(this.projectOptions.configWebpack); } return this; } _getApiInstance(id) { const self = this; return new Proxy(new PluginAPI(id, self), { get(target, prop) { // 传入配置的自定义 pluginAPI 方法 if (['on', 'emit', 'addPlugin', 'getWebpackChainConfig', 'getWebpackConfig'].includes(prop)) { if (typeof self[prop] === 'function') { return self[prop].bind(self); } return self[prop]; } return target[prop]; } }); } initPlugin(plugin) { let options = {}; if (Array.isArray(plugin)) { options = plugin[1]; plugin = plugin[0]; } const {id, apply} = plugin; const api = this._getApiInstance(id); // 传入配置的 options // * 因为一般 plugin 不需要自定义 options,所以 projectOption 作为第二个参数 apply(api, this.projectOptions, options); return this; } async loadProjectOptions(configFile) { let originalConfigFile = configFile; if (configFile && typeof configFile === 'string') { configFile = isAbsolute(configFile) ? configFile : resolve(this.cwd, configFile); if (!fs.existsSync(configFile)) { configFile = false; this.logger.warn(`config file \`${originalConfigFile}\` is not exists!`); } } // 首先试用 argv 的 config,然后寻找默认的,找到则读取,格式失败则报错 let config = defaultsDeep(this._initProjectOptions, defaultConfig); let result = { filepath: originalConfigFile, config: configFile ? require(configFile) : false }; if (!configFile && this.autoLoadConfigFile) { // 主动查找 cwd 目录的.san result.filepath = findExisting(['san.config.js', '.san.config.js'], this.cwd); if (result.filepath) { // 加载 config 文件 result.config = require(result.filepath); } } if (result && result.config) { let configPath = result.filepath; if (!result.config || typeof result.config !== 'object') { logger.error(`${textCommonColor(configPath)}: Expected object type.`); } else { // 校验config.js schema 格式 try { await validateOptions(result.config); } catch (e) { logger.error(`${textCommonColor(configPath)}: Invalid type.`); throw new SError(e); } } debug('loadProjectOptions from %s', configPath); // 这里特殊处理下 plugins 字段吧 // if (result.config.plugins && result.config.plugins.length) { // result.config.plugins = result.config.plugins.map(k => // typeof k === 'string' ? resolve(dirname(result.filepath), k) : k // ); // } // 加载默认的 config 配置 config = defaultsDeep(result.config, config); } else { // this.logger.warn(`${textCommonColor('san.config.js')} Cannot find! Use default configuration.`); } return this.normalizeConfig(config, result.filepath); } normalizeConfig(config, filepath) { // normalize publicPath ensureSlash(config, 'publicPath'); if (typeof config.publicPath === 'string') { config.publicPath = config.publicPath.replace(/^\.\//, ''); } removeSlash(config, 'outputDir'); // normalize pages const pages = config.pages; if (filepath && pages) { filepath = dirname(filepath); Object.keys(pages).forEach(p => { const page = pages[p]; // 相对于 san.config.js ['entry', 'template'].forEach(key => { if (page[key]) { if (Array.isArray(page[key])) { // 处理成相对 san.config.js page[key] = page[key].map(p => (isAbsolute(p) ? p : resolve(filepath, p))); } else { page[key] = resolve(filepath, page[key]); } } }); }); } return config; } async run(callback) { // 先加载 env 文件,保证 config 文件中可以用到 time('loadEnv'); this.loadEnv(this.mode); timeEnd('loadEnv'); // set mode // load user config time('loadProjectOptions'); const projectOptions = await this.loadProjectOptions(this.configFile); debug('projectOptions: %O', projectOptions); timeEnd('loadProjectOptions'); this.projectOptions = projectOptions; // 添加插件 if (Array.isArray(projectOptions.plugins) && projectOptions.plugins.length) { projectOptions.plugins.forEach(p => this.addPlugin(p)); } // 开始添加依赖 argv 的内置 plugin // 添加progress plugin if (this.useProgress) { const progressOptions = { name: this.name }; if (this.useProfiler) { progressOptions.profile = true; } this.addPlugin(require('san-cli-plugin-progress'), progressOptions); } // 添加dashboard Plugin if (this.useDashboard) { this.addPlugin(require('san-cli-plugin-dashboard'), { type: this.name, keepAlive: this.watch }); } time('init'); this.init(this.mode); timeEnd('init'); if (typeof callback === 'function') { time('callback'); callback(this._getApiInstance(`${this.name}:callback`), this.projectOptions); timeEnd('callback'); } } addPlugin(name, options = {}) { argsert('<string|array|object> [object|undefined]', [name, options], arguments.length); if (Array.isArray(name)) { [name, options = {}] = name; } else if (typeof name === 'object') { argsert('<string> <function>', [name.id, name.apply], 2); } const plugin = this._loadPlugin([name, options]); this.plugins.push(plugin); return this; } getWebpackChainConfig() { const chainableConfig = new Config(); // apply chains this.webpackChainFns.forEach(fn => fn(chainableConfig)); return chainableConfig; } getWebpackConfig(chainableConfig = this.getWebpackChainConfig()) { if (!this.initialized) { throw new SError('Service must call init() before calling getWebpackConfig().'); } // get raw config let config = chainableConfig.toConfig(); const original = config; // apply raw config fns this.webpackRawConfigFns.forEach(fn => { if (typeof fn === 'function') { // function with optional return value const res = fn(config); if (res) { config = webpackMerge(config, res); } } else if (fn) { // merge literal values config = webpackMerge(config, fn); } }); if (config !== original) { cloneRuleNames(config.module && config.module.rules, original.module && original.module.rules); } // 这里需要将 devServer 和 this.projectOptions.devServer 进行 merge config.devServer = lMerge(config.devServer || {}, this.projectOptions.devServer || {}) || {}; let before = config.devServer.before; if (this.devServerMiddlewares.length) { /* eslint-disable space-before-function-paren */ before = (function(before, devServerMiddlewares) { /* eslint-enable space-before-function-paren */ return (app, server) => { // 因为一些中间件存在监听等逻辑,所以这里包了一层 fn devServerMiddlewares.forEach(fn => typeof fn === 'function' && app.use(fn())); // 还原配置的 before typeof before === 'function' && before(app, server); }; })(before, this.devServerMiddlewares); } config.devServer.before = before; if (debug.enabled || showConfig.enabled) { // 在debug模式输出 let wpConfig = Config.toString(config); debug(wpConfig); showConfig(wpConfig); } return config; } }; function cloneRuleNames(to, from) { if (!to || !from) { return; } from.forEach((r, i) => { if (to[i]) { Object.defineProperty(to[i], '__ruleNames', { value: r.__ruleNames }); cloneRuleNames(to[i].oneOf, r.oneOf); } }); } function removeSlash(config, key) { if (typeof config[key] === 'string') { config[key] = config[key].replace(/\/$/g, ''); } } function ensureSlash(config, key) { let val = config[key]; if (typeof val === 'string') { if (!/^https?:/.test(val)) { val = val.replace(/^([^/.])/, '/$1'); } config[key] = val.replace(/([^/])$/, '$1/'); } }