前言
在使用vue-cli的项目中,我们可以使用vue ui来打开一个可视化的项目管理器,在其中可以操作项目,运行任务,安装插件、依赖等。
除了官方提供的默认功能之外,我们还可以通过ui插件来进行拓展。
ui插件的写法是这样的:
module.exports = api => {
api.describeTask({
match: /vue-cli-service serve(\s+--\S+(\s+\S+)?)*$/,
description: 'org.vue.vue-webpack.tasks.serve.description',
link: 'https://cli.vuejs.org/guide/cli-service.html#vue-cli-service-serve',
icon: '/public/webpack-logo.png',
prompts: [
// ...
],
onBeforeRun: ({ answers, args }) => {
// ...
}
});
api.addView({
// ...
})
}
一个UI插件其实就是个函数,这个函数接收api对象作为参数,api对象是PluginApi的实例。
正文
从打开一个项目开始发生了什么?
我们在使用可视化页面打开一个项目的时候,客户端会发起一个mutation类型的graphql查询。apollo-server接收到之后就会进入相对应的resolver,在resolver中调用函数去打开项目。
module.exports = {
Mutation: {
projectOpen: (root, { id }, context) => projects.open(id, context),
}
}
我们看一下project.open做了什么:
async function open (id, context) {
const project = findOne(id, context)
// ...
lastProject = currentProject
currentProject = project
cwd.set(project.path, context)
// 加载插件
await plugins.list(project.path, context)
// ...
return project
}
也就是先拿到该项目的信息,设置cwd
,调用plugins.list
方法,把信息设置到db
中之后将项目信息返回给前端。
这里,重点就在plugin.list
方法。
我们看一下他是如何实现的:
async function list (file, context, { resetApi = true, lightApi = false, autoLoadApi = true } = {}) {
let pkg = folders.readPackage(file, context)
let pkgContext = cwd.get()
// 从package.json里加载插件信息
if (pkg.vuePlugins && pkg.vuePlugins.resolveFrom) {
pkgContext = path.resolve(cwd.get(), pkg.vuePlugins.resolveFrom)
pkg = folders.readPackage(pkgContext, context)
}
pkgStore.set(file, { pkgContext, pkg })
let plugins = []
// 重依赖中查找插件
plugins = plugins.concat(findPlugins(pkg.devDependencies || {}, file))
plugins = plugins.concat(findPlugins(pkg.dependencies || {}, file))
// 将cli-service放到最顶层
const index = plugins.findIndex(p => p.id === CLI_SERVICE)
if (index !== -1) {
const service = plugins[index]
plugins.splice(index, 1)
plugins.unshift(service)
}
pluginsStore.set(file, plugins)
// 如果条件满足就去重置插件api,首次打开项目会进入该语句
if (resetApi || (autoLoadApi && !pluginApiInstances.has(file))) {
await resetPluginApi({ file, lightApi }, context)
}
return plugins
}
在这里,它会从两个地方查找插件:
- package.json的vuePlugins字段
- 依赖
查到之后将其存入map中进行缓存,供后续使用。
如果是首次加载,或者重置时会进入resetPluginApi
方法。插件的加载和调用就是在这里完成。
我们看一下具体实现:
function resetPluginApi ({ file, lightApi }, context) {
return new Promise((resolve, reject) => {
const widgets = require('./widgets')
let pluginApi = pluginApiInstances.get(file)
let projectId
// 做一些清理工作
if (pluginApi) {
// ...
}
if (!lightApi) {
// ...
}
setTimeout(async () => {
const projects = require('./projects')
const project = projects.findByPath(file, context)
// ...
const plugins = getPlugins(file)
// ...
pluginApi = new PluginApi({
plugins,
file,
project,
lightMode: lightApi
}, context)
pluginApiInstances.set(file, pluginApi)
// 运行插件api
// 运行默认ui插件
runPluginApi(path.resolve(__dirname, '../../'), pluginApi, context, 'ui-defaults')
// 运行在上面获取到的依赖中的插件
plugins.forEach(plugin => runPluginApi(plugin.id, pluginApi, context))
// 运行package.json中vuePlugins字段中的插件
const { pkg, pkgContext } = pkgStore.get(file)
if (pkg.vuePlugins && pkg.vuePlugins.ui) {
const files = pkg.vuePlugins.ui
if (Array.isArray(files)) {
for (const file of files) {
runPluginApi(pkgContext, pluginApi, context, file)
}
}
}
// 添加客户端附加组件
pluginApi.clientAddons.forEach(options => {
clientAddons.add(options, context)
})
// 添加视图
for (const view of pluginApi.views) {
await views.add({ view, project }, context)
}
// 注册组件
for (const definition of pluginApi.widgetDefs) {
await widgets.registerDefinition({ definition, project }, context)
}
if (projectId !== project.id) {
callHook({
id: 'projectOpen',
args: [project, projects.getLast(context)],
file
}, context)
} else {
callHook({
id: 'pluginReload',
args: [project],
file
}, context)
// View open hook
const currentView = views.getCurrent()
if (currentView) views.open(currentView.id)
}
// Load widgets for current project
widgets.load(context)
resolve(true)
})
})
}
可以看到,resetPluginApi函数,进入之后:
- 拿到一些相关信息
- 做一些清理工作
- 创建PluginApi实例
- 运行在三个地方定义的api(默认、依赖、vuePlugins字段),运行时将PluginApi实例传入(回想一下插件的写法)。
- 插件在运行时,就会调用PluginApi实例上的方法,在该示例上添加一系列的信息。在需要时即可直接拿出来进行消费。
- 消费PluginApi实例上的信息,添加视图、附加组件等,调用一些钩子函数,加载一些插件。
到此为止,插件的加载过程完毕。
弯弯绕绕这么多,但总的来说,插件的加载过程可以抽象成一下的一个图:
也就是:
- 导入(打开)一个项目时区缓存项目信息
- 加载插件信息
- 创建插件实例
- 运行插件
- 使用插件实例上的信息
以上就是vue-cli ui插件机制的实现过程。
附录
PluginApi的定义
const path = require('path')
// Connectors
const logs = require('../connectors/logs')
const sharedData = require('../connectors/shared-data')
const views = require('../connectors/views')
const suggestions = require('../connectors/suggestions')
const progress = require('../connectors/progress')
const app = require('../connectors/app')
// Utils
const ipc = require('../util/ipc')
const { notify } = require('../util/notification')
const { matchesPluginId } = require('@vue/cli-shared-utils')
const { log } = require('../util/logger')
// Validators
const { validateConfiguration } = require('./configuration')
const { validateDescribeTask, validateAddTask } = require('./task')
const { validateClientAddon } = require('./client-addon')
const { validateView, validateBadge } = require('./view')
const { validateNotify } = require('./notify')
const { validateSuggestion } = require('./suggestion')
const { validateProgress } = require('./progress')
const { validateWidget } = require('./widget')
/**
* @typedef SetSharedDataOptions
* @prop {boolean} disk Don't keep this data in memory by writing it to disk
*/
/** @typedef {import('../connectors/shared-data').SharedData} SharedData */
class PluginApi {
constructor ({ plugins, file, project, lightMode = false }, context) {
// Context
this.context = context
this.pluginId = null
this.project = project
this.plugins = plugins
this.cwd = file
this.lightMode = lightMode
// Hooks
this.hooks = {
projectOpen: [],
pluginReload: [],
configRead: [],
configWrite: [],
taskRun: [],
taskExit: [],
taskOpen: [],
viewOpen: []
}
// Data
this.configurations = []
this.describedTasks = []
this.addedTasks = []
this.clientAddons = []
this.views = []
this.actions = new Map()
this.ipcHandlers = []
this.widgetDefs = []
}
/**
* Register an handler called when the project is open (only if this plugin is loaded).
*
* @param {function} cb Handler
*/
onProjectOpen (cb) {
if (this.lightMode) return
if (this.project) {
cb(this.project)
return
}
this.hooks.projectOpen.push(cb)
}
/**
* Register an handler called when the plugin is reloaded.
*
* @param {function} cb Handler
*/
onPluginReload (cb) {
if (this.lightMode) return
this.hooks.pluginReload.push(cb)
}
/**
* Register an handler called when a config is red.
*
* @param {function} cb Handler
*/
onConfigRead (cb) {
if (this.lightMode) return
this.hooks.configRead.push(cb)
}
/**
* Register an handler called when a config is written.
*
* @param {function} cb Handler
*/
onConfigWrite (cb) {
if (this.lightMode) return
this.hooks.configWrite.push(cb)
}
/**
* Register an handler called when a task is run.
*
* @param {function} cb Handler
*/
onTaskRun (cb) {
if (this.lightMode) return
this.hooks.taskRun.push(cb)
}
/**
* Register an handler called when a task has exited.
*
* @param {function} cb Handler
*/
onTaskExit (cb) {
if (this.lightMode) return
this.hooks.taskExit.push(cb)
}
/**
* Register an handler called when the user opens one task details.
*
* @param {function} cb Handler
*/
onTaskOpen (cb) {
if (this.lightMode) return
this.hooks.taskOpen.push(cb)
}
/**
* Register an handler called when a view is open.
*
* @param {function} cb Handler
*/
onViewOpen (cb) {
if (this.lightMode) return
this.hooks.viewOpen.push(cb)
}
/**
* Describe a project configuration (usually for config file like `.eslintrc.json`).
*
* @param {object} options Configuration description
*/
describeConfig (options) {
if (this.lightMode) return
try {
validateConfiguration(options)
this.configurations.push({
...options,
pluginId: this.pluginId
})
} catch (e) {
logs.add({
type: 'error',
tag: 'PluginApi',
message: `(${this.pluginId || 'unknown plugin'}) 'describeConfig' options are invalid\n${e.message}`
}, this.context)
console.error(new Error(`Invalid options: ${e.message}`))
}
}
/**
* Describe a project task with additional information.
* The tasks are generated from the scripts in the project `package.json`.
*
* @param {object} options Task description
*/
describeTask (options) {
try {
validateDescribeTask(options)
this.describedTasks.push({
...options,
pluginId: this.pluginId
})
} catch (e) {
logs.add({
type: 'error',
tag: 'PluginApi',
message: `(${this.pluginId || 'unknown plugin'}) 'describeTask' options are invalid\n${e.message}`
}, this.context)
console.error(new Error(`Invalid options: ${e.message}`))
}
}
/**
* Get the task description matching a script command.
*
* @param {string} command Npm script command from `package.json`
* @returns {object} Task description
*/
getDescribedTask (command) {
return this.describedTasks.find(
options => typeof options.match === 'function' ? options.match(command) : options.match.test(command)
)
}
/**
* Add a new task independently from the scripts.
* The task will only appear in the UI.
*
* @param {object} options Task description
*/
addTask (options) {
try {
validateAddTask(options)
this.addedTasks.push({
...options,
pluginId: this.pluginId
})
} catch (e) {
logs.add({
type: 'error',
tag: 'PluginApi',
message: `(${this.pluginId || 'unknown plugin'}) 'addTask' options are invalid\n${e.message}`
}, this.context)
console.error(new Error(`Invalid options: ${e.message}`))
}
}
/**
* Register a client addon (a JS bundle which will be loaded in the browser).
* Used to load components and add vue-router routes.
*
* @param {object} options Client addon options
* {
* id: string,
* url: string
* }
* or
* {
* id: string,
* path: string
* }
*/
addClientAddon (options) {
if (this.lightMode) return
try {
validateClientAddon(options)
if (options.url && options.path) {
throw new Error('\'url\' and \'path\' can\'t be defined at the same time.')
}
this.clientAddons.push({
...options,
pluginId: this.pluginId
})
} catch (e) {
logs.add({
type: 'error',
tag: 'PluginApi',
message: `(${this.pluginId || 'unknown plugin'}) 'addClientAddon' options are invalid\n${e.message}`
}, this.context)
console.error(new Error(`Invalid options: ${e.message}`))
}
}
/* Project view */
/**
* Add a new project view below the builtin 'plugins', 'config' and 'tasks' ones.
*
* @param {object} options ProjectView options
*/
addView (options) {
if (this.lightMode) return
try {
validateView(options)
this.views.push({
...options,
pluginId: this.pluginId
})
} catch (e) {
logs.add({
type: 'error',
tag: 'PluginApi',
message: `(${this.pluginId || 'unknown plugin'}) 'addView' options are invalid\n${e.message}`
}, this.context)
console.error(new Error(`Invalid options: ${e.message}`))
}
}
/**
* Add a badge to the project view button.
* If the badge already exists, add 1 to the counter.
*
* @param {string} viewId Project view id
* @param {object} options Badge options
*/
addViewBadge (viewId, options) {
if (this.lightMode) return
try {
validateBadge(options)
views.addBadge({ viewId, badge: options }, this.context)
} catch (e) {
logs.add({
type: 'error',
tag: 'PluginApi',
message: `(${this.pluginId || 'unknown plugin'}) 'addViewBadge' options are invalid\n${e.message}`
}, this.context)
console.error(new Error(`Invalid options: ${e.message}`))
}
}
/**
* Remove 1 from the counter of a badge if it exists.
* If the badge counter reaches 0, it is removed from the button.
*
* @param {any} viewId
* @param {any} badgeId
* @memberof PluginApi
*/
removeViewBadge (viewId, badgeId) {
views.removeBadge({ viewId, badgeId }, this.context)
}
/* IPC */
/**
* Add a listener to the IPC messages.
*
* @param {function} cb Callback with 'data' param
*/
ipcOn (cb) {
const handler = cb._handler = ({ data, emit }) => {
if (data._projectId) {
if (data._projectId === this.project.id) {
data = data._data
} else {
return
}
}
// eslint-disable-next-line standard/no-callback-literal
cb({ data, emit })
}
this.ipcHandlers.push(handler)
return ipc.on(handler)
}
/**
* Remove a listener for IPC messages.
*
* @param {any} cb Callback to be removed
*/
ipcOff (cb) {
const handler = cb._handler
if (!handler) return
const index = this.ipcHandlers.indexOf(handler)
if (index !== -1) this.ipcHandlers.splice(index, 1)
ipc.off(handler)
}
/**
* Send an IPC message to all connected IPC clients.
*
* @param {any} data Message data
*/
ipcSend (data) {
ipc.send(data)
}
/**
* Get the local DB instance (lowdb)
*
* @readonly
*/
get db () {
return this.context.db
}
/**
* Display a notification in the user OS
* @param {object} options Notification options
*/
notify (options) {
try {
validateNotify(options)
notify(options)
} catch (e) {
logs.add({
type: 'error',
tag: 'PluginApi',
message: `(${this.pluginId || 'unknown plugin'}) 'notify' options are invalid\n${e.message}`
}, this.context)
console.error(new Error(`Invalid options: ${e.message}`))
}
}
/**
* Indicates if a specific plugin is used by the project
* @param {string} id Plugin id or short id
*/
hasPlugin (id) {
return this.plugins.some(p => matchesPluginId(id, p.id))
}
/**
* Display the progress screen.
*
* @param {object} options Progress options
*/
setProgress (options) {
if (this.lightMode) return
try {
validateProgress(options)
progress.set({
...options,
id: '__plugins__'
}, this.context)
} catch (e) {
logs.add({
type: 'error',
tag: 'PluginApi',
message: `(${this.pluginId || 'unknown plugin'}) 'setProgress' options are invalid\n${e.message}`
}, this.context)
console.error(new Error(`Invalid options: ${e.message}`))
}
}
/**
* Remove the progress screen.
*/
removeProgress () {
progress.remove('__plugins__', this.context)
}
/**
* Get current working directory.
*/
getCwd () {
return this.cwd
}
/**
* Resolves a file relative to current working directory
* @param {string} file Path to file relative to project
*/
resolve (file) {
return path.resolve(this.cwd, file)
}
/**
* Get currently open project
*/
getProject () {
return this.project
}
/* Namespaced */
/**
* Retrieve a Shared data instance.
*
* @param {string} id Id of the Shared data
* @returns {SharedData} Shared data instance
*/
getSharedData (id) {
return sharedData.get({ id, projectId: this.project.id }, this.context)
}
/**
* Set or update the value of a Shared data
*
* @param {string} id Id of the Shared data
* @param {any} value Value of the Shared data
* @param {SetSharedDataOptions} options
*/
async setSharedData (id, value, { disk = false } = {}) {
return sharedData.set({ id, projectId: this.project.id, value, disk }, this.context)
}
/**
* Delete a shared data.
*
* @param {string} id Id of the Shared data
*/
async removeSharedData (id) {
return sharedData.remove({ id, projectId: this.project.id }, this.context)
}
/**
* Watch for a value change of a shared data
*
* @param {string} id Id of the Shared data
* @param {function} handler Callback
*/
watchSharedData (id, handler) {
sharedData.watch({ id, projectId: this.project.id }, handler)
}
/**
* Delete the watcher of a shared data.
*
* @param {string} id Id of the Shared data
* @param {function} handler Callback
*/
unwatchSharedData (id, handler) {
sharedData.unwatch({ id, projectId: this.project.id }, handler)
}
/**
* Listener triggered when a Plugin action is called from a client addon component.
*
* @param {string} id Id of the action to listen
* @param {any} cb Callback (ex: (params) => {} )
*/
onAction (id, cb) {
let list = this.actions.get(id)
if (!list) {
list = []
this.actions.set(id, list)
}
list.push(cb)
}
/**
* Call a Plugin action. This can also listened by client addon components.
*
* @param {string} id Id of the action
* @param {object} params Params object passed as 1st argument to callbacks
* @returns {Promise}
*/
callAction (id, params) {
const plugins = require('../connectors/plugins')
return plugins.callAction({ id, params }, this.context)
}
/**
* Retrieve a value from the local DB
*
* @param {string} id Path to the item
* @returns Item value
*/
storageGet (id) {
return this.db.get(id).value()
}
/**
* Store a value into the local DB
*
* @param {string} id Path to the item
* @param {any} value Value to be stored (must be serializable in JSON)
*/
storageSet (id, value) {
log('Storage set', id, value)
this.db.set(id, value).write()
}
/**
* Add a suggestion for the user.
*
* @param {object} options Suggestion
*/
addSuggestion (options) {
if (this.lightMode) return
try {
validateSuggestion(options)
suggestions.add(options, this.context)
} catch (e) {
logs.add({
type: 'error',
tag: 'PluginApi',
message: `(${this.pluginId || 'unknown plugin'}) 'addSuggestion' options are invalid\n${e.message}`
}, this.context)
console.error(new Error(`Invalid options: ${e.message}`))
}
}
/**
* Remove a suggestion
*
* @param {string} id Id of the suggestion
*/
removeSuggestion (id) {
suggestions.remove(id, this.context)
}
/**
* Register a widget for project dashboard
*
* @param {object} def Widget definition
*/
registerWidget (def) {
if (this.lightMode) return
try {
validateWidget(def)
this.widgetDefs.push({
...def,
pluginId: this.pluginId
})
} catch (e) {
logs.add({
type: 'error',
tag: 'PluginApi',
message: `(${this.pluginId || 'unknown plugin'}) 'registerWidget' widget definition is invalid\n${e.message}`
}, this.context)
console.error(new Error(`Invalid definition: ${e.message}`))
}
}
/**
* Request a route to be displayed in the client
*/
requestRoute (route) {
app.requestRoute(route, this.context)
}
/**
* Create a namespaced version of:
* - getSharedData
* - setSharedData
* - onAction
* - callAction
*
* @param {string} namespace Prefix to add to the id params
*/
namespace (namespace) {
return {
/**
* Retrieve a Shared data instance.
*
* @param {string} id Id of the Shared data
* @returns {SharedData} Shared data instance
*/
getSharedData: (id) => this.getSharedData(namespace + id),
/**
* Set or update the value of a Shared data
*
* @param {string} id Id of the Shared data
* @param {any} value Value of the Shared data
* @param {SetSharedDataOptions} options
*/
setSharedData: (id, value, options) => this.setSharedData(namespace + id, value, options),
/**
* Delete a shared data.
*
* @param {string} id Id of the Shared data
*/
removeSharedData: (id) => this.removeSharedData(namespace + id),
/**
* Watch for a value change of a shared data
*
* @param {string} id Id of the Shared data
* @param {function} handler Callback
*/
watchSharedData: (id, handler) => this.watchSharedData(namespace + id, handler),
/**
* Delete the watcher of a shared data.
*
* @param {string} id Id of the Shared data
* @param {function} handler Callback
*/
unwatchSharedData: (id, handler) => this.unwatchSharedData(namespace + id, handler),
/**
* Listener triggered when a Plugin action is called from a client addon component.
*
* @param {string} id Id of the action to listen
* @param {any} cb Callback (ex: (params) => {} )
*/
onAction: (id, cb) => this.onAction(namespace + id, cb),
/**
* Call a Plugin action. This can also listened by client addon components.
*
* @param {string} id Id of the action
* @param {object} params Params object passed as 1st argument to callbacks
* @returns {Promise}
*/
callAction: (id, params) => this.callAction(namespace + id, params),
/**
* Retrieve a value from the local DB
*
* @param {string} id Path to the item
* @returns Item value
*/
storageGet: (id) => this.storageGet(namespace + id),
/**
* Store a value into the local DB
*
* @param {string} id Path to the item
* @param {any} value Value to be stored (must be serializable in JSON)
*/
storageSet: (id, value) => this.storageSet(namespace + id, value),
/**
* Add a suggestion for the user.
*
* @param {object} options Suggestion
*/
addSuggestion: (options) => {
options.id = namespace + options.id
return this.addSuggestion(options)
},
/**
* Remove a suggestion
*
* @param {string} id Id of the suggestion
*/
removeSuggestion: (id) => this.removeSuggestion(namespace + id),
/**
* Register a widget for project dashboard
*
* @param {object} def Widget definition
*/
registerWidget: (def) => {
def.id = namespace + def.id
return this.registerWidget(def)
}
}
}
}
module.exports = PluginApi
如上所示,就是一些供插件使用的方法和一些数组,以及一个订阅发布模式。