前言
在使用vue-cli的項(xiàng)目中涮毫,我們可以使用vue ui來(lái)打開(kāi)一個(gè)可視化的項(xiàng)目管理器剖笙,在其中可以操作項(xiàng)目,運(yùn)行任務(wù)肌似,安裝插件讯赏、依賴等垮兑。
除了官方提供的默認(rèn)功能之外,我們還可以通過(guò)ui插件來(lái)進(jìn)行拓展漱挎。
ui插件的寫(xiě)法是這樣的:
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({
// ...
})
}
一個(gè)UI插件其實(shí)就是個(gè)函數(shù)系枪,這個(gè)函數(shù)接收api對(duì)象作為參數(shù),api對(duì)象是PluginApi的實(shí)例识樱。
正文
從打開(kāi)一個(gè)項(xiàng)目開(kāi)始發(fā)生了什么嗤无?
我們?cè)谑褂每梢暬?yè)面打開(kāi)一個(gè)項(xiàng)目的時(shí)候,客戶端會(huì)發(fā)起一個(gè)mutation類(lèi)型的graphql查詢怜庸。apollo-server接收到之后就會(huì)進(jìn)入相對(duì)應(yīng)的resolver当犯,在resolver中調(diào)用函數(shù)去打開(kāi)項(xiàng)目。
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
}
也就是先拿到該項(xiàng)目的信息割疾,設(shè)置cwd
嚎卫,調(diào)用plugins.list
方法,把信息設(shè)置到db
中之后將項(xiàng)目信息返回給前端宏榕。
這里拓诸,重點(diǎn)就在plugin.list
方法。
我們看一下他是如何實(shí)現(xiàn)的:
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麻昼,首次打開(kāi)項(xiàng)目會(huì)進(jìn)入該語(yǔ)句
if (resetApi || (autoLoadApi && !pluginApiInstances.has(file))) {
await resetPluginApi({ file, lightApi }, context)
}
return plugins
}
在這里奠支,它會(huì)從兩個(gè)地方查找插件:
- package.json的vuePlugins字段
- 依賴
查到之后將其存入map中進(jìn)行緩存,供后續(xù)使用抚芦。
如果是首次加載倍谜,或者重置時(shí)會(huì)進(jìn)入resetPluginApi
方法。插件的加載和調(diào)用就是在這里完成叉抡。
我們看一下具體實(shí)現(xiàn):
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)
// 運(yùn)行插件api
// 運(yùn)行默認(rèn)ui插件
runPluginApi(path.resolve(__dirname, '../../'), pluginApi, context, 'ui-defaults')
// 運(yùn)行在上面獲取到的依賴中的插件
plugins.forEach(plugin => runPluginApi(plugin.id, pluginApi, context))
// 運(yùn)行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)
}
// 注冊(cè)組件
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函數(shù),進(jìn)入之后:
- 拿到一些相關(guān)信息
- 做一些清理工作
- 創(chuàng)建PluginApi實(shí)例
- 運(yùn)行在三個(gè)地方定義的api(默認(rèn)褥民、依賴季春、vuePlugins字段),運(yùn)行時(shí)將PluginApi實(shí)例傳入(回想一下插件的寫(xiě)法)消返。
- 插件在運(yùn)行時(shí)载弄,就會(huì)調(diào)用PluginApi實(shí)例上的方法耘拇,在該示例上添加一系列的信息。在需要時(shí)即可直接拿出來(lái)進(jìn)行消費(fèi)侦锯。
- 消費(fèi)PluginApi實(shí)例上的信息驼鞭,添加視圖、附加組件等尺碰,調(diào)用一些鉤子函數(shù),加載一些插件译隘。
到此為止亲桥,插件的加載過(guò)程完畢。
彎彎繞繞這么多固耘,但總的來(lái)說(shuō)题篷,插件的加載過(guò)程可以抽象成一下的一個(gè)圖:
也就是:
- 導(dǎo)入(打開(kāi))一個(gè)項(xiàng)目時(shí)區(qū)緩存項(xiàng)目信息
- 加載插件信息
- 創(chuàng)建插件實(shí)例
- 運(yùn)行插件
- 使用插件實(shí)例上的信息
以上就是vue-cli ui插件機(jī)制的實(shí)現(xiàn)過(guò)程。
附錄
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
如上所示厅目,就是一些供插件使用的方法和一些數(shù)組番枚,以及一個(gè)訂閱發(fā)布模式。