我们在 HBuilderX 插件开发指南(一) 中已经了解了 插件从开发到发布的细节,本期文章重点 将具体学习 插件开发需要注意的细节
项目结构
├── README.md // 插件文档
├── changelog.md // 插件更新日志
├── extension.js // 插件入口文件
├── node_modules
├── package.json // 插件配置文件
└── src
├── html.js // 界面
├── main.js // 主要逻辑
└── static // 依赖的静态资源
package.json (摘取 官方文档)
所有的插件在根目录都要有一个package.json文件,该文件继承npm规范,并扩展了部分字段,以下列出各个字段的含义:
字段名称 | 类型 | 是否必须 | 描述 |
---|---|---|---|
name | String | 是 | 插件名称 |
displayName | String | 是 | 用于展示在插件列表中的名称 |
version | String | 是 | 插件版本号,检查升级时会用到 |
engines | Object | 是 | 该Object的属性至少要包含HBuilderX,属性值为兼容的主版本号,如果HBuilderX的版本低于该版本,将会提示用户升级HBuilderX。例如:{“HBuilderX”:“^2.7.0”}。 |
description | String | 是 | 简短的插件描述,不要超过30个字 |
main | String | 否 | 插件代码入口文件,配置型插件可不填 |
activationEvents | Array | 否 | 激活事件的列表,如为空,则表示该插件不会懒加载 |
contributes | Object | 否 | 插件的配置扩展点 |
extensionDependencies | Array | 否 | 该插件依赖的其他插件id |
创建插件项目,默认会有activationEvents 字段,如果你开发的 使用度很高, 个人建议不要懒加载机制,删除此字段即可。
package.json 示例
这是一个 文章(一) 中 用到的一个简单示例,在 编辑器右键菜单 注册了一个 demo新命令,点击此选项 将在窗口右下角弹窗一个消息通知框
// package.json
{
"id": "ide-demo",
"name": "ide-demo",
"description": "针对HBuilderX 开发的demo",
"displayName": "ide-demo",
"version": "2.0.0",
"publisher": "your name",
"keywords":[
"ide"
],
"engines": {
"HBuilderX": "^3.6.2"
},
"categories": [
"Other"
],
"main": "./extension",
"activationEvents": [
"onCommand:extension.helloWorld"
],
"contributes": {
"commands": [{
"command": "extension.helloWorld",
"title": "demo新命令"
}],
"menus": {
"editor/context": [{
"command": "extension.helloWorld",
"group": "z_commands",
"when": "editorTextFocus"
},
{
"group": "z_commands"
}
]
}
},
"extensionDependencies": [
"plugin-manager"
],
"dependencies": {}
}
配置扩展点 contributes(重点)
commands 声明命令
commands 扩展点用于声明一个命令,命令可以通过menus扩展点和菜单关联到一起
// 注意menus 第4个对象,当属性title和command都为空时,将被识别为分割线
{
....
"main": "./extension",
"contributes": {
"commands": [
{
"command": "extension.demo1",
"title": "demo命令1"
},
{
"command": "extension.demo2",
"title": "demo命令2"
},
{
"command": "extension.demo3",
"title": "demo命令3"
}
],
"menus": {
"editor/context": [
{
"command": "extension.demo1",
"group": "z_commands",
"when": "editorTextFocus"
},
{
"command": "extension.demo2",
"group": "z_commands",
"when": "editorTextFocus"
},
{
"command": "extension.demo3",
"group": "z_commands",
"when": "editorTextFocus"
},
{
"group": "z_commands"
}
]
}
},
....
}
效果演示
因为 menus 使用的是 editor/context(编辑器右键菜单),所以我们可以 在编辑器区域,右键单击
我们可以发现,菜单栏中 出现了demo命令1、demo命令2、demo命令3 选项
当属性title和command都为空时,将被识别为分割线 (demo命令3 下面的分割线)
menus 菜单(重点)
menus扩展点会关联一个命令到相应的菜单项里面,当菜单触发时将会执行对应的命令。
menus节点下配置的对象内的key指的是要注册到哪个弹出菜单里面,详情参考
每个菜单内的group (重点)
每个弹出菜单内的group都不一样,下面列出所有的弹出菜单下可用的group,下边是图片,不可点击,具体文档查看这里~,配置参数都不相同,强烈建议亲自看文档
例如: 编辑器右键菜单 - editor/context~
- copy
- goto
- copyPath
- assist
- z_commands
官方文档有详细的介绍,这里就不一一介绍了,我们来查看具体效果
代码
我们在编辑器-右键菜单、顶部菜单-文件、顶部菜单-编辑,3个地方分别添加了一个命令项
{
"id": "ide-demo",
"name": "ide-demo",
"description": "针对HBuilderX 开发的demo",
"displayName": "ide-demo",
"version": "2.0.0",
"publisher": "your name",
"keywords":[
"ide"
],
"engines": {
"HBuilderX": "^3.6.2"
},
"categories": [
"Other"
],
"main": "./extension",
"contributes": {
"commands": [
{
"command": "extension.demo1",
"title": "demo编辑器-右键菜单"
},
{
"command": "extension.demo2",
"title": "demo顶部菜单-文件"
},
{
"command": "extension.demo3",
"title": "demo顶部菜单-编辑"
}
],
"menus": {
"editor/context": [
{
"command": "extension.demo1",
"group": "z_commands",
"when": "editorTextFocus"
},
{
"group": "z_commands"
}
],
"menubar/file": [
{
"command": "extension.demo2",
"group": "new",
"when": "editorTextFocus"
},
{
"group": "new"
}
],
"menubar/edit": [
{
"command": "extension.demo3",
"group": "undo",
"when": "editorTextFocus"
},
{
"group": "undo"
}
]
}
},
"extensionDependencies": [
"plugin-manager"
],
"dependencies": {
"json-comments": "^0.2.1",
"request": "^2.88.2"
}
}
位置演示
demo编辑器-右键菜单
demo顶部菜单-文件
demo顶部菜单-编辑
到了这一步,我们关于命令配置,便算是完成了。
下面开始梳理插件代码逻辑相关的内容
插件代码说明
extension.js
extension.js是插件入口文件,插件入口文件必须有 activate 方法,该方法在插件激活的时候调用。
打印日志
插件打印日志功能一直很重要,在开发时需要调试打印,发布后插件各个节点进度打印,这里我们可以通过 console.log、console.error 和 hx.window.createOutputChannel 实现
老窗口:console.log、console.error
新窗口:hx.window.createOutputChannel (建议使用)
const hx = require('hbuilderx')
let wgtConsole = null
//该方法将在插件激活的时候调用
function activate (context) {
let disposable = hx.commands.registerCommand('extension.demo1', param => {
console.log('ide-demo插件---编辑器-右键菜单启动命令-----会在老窗口打印')
wgtConsole = hx.window.createOutputChannel('ide-demo-console')
wgtConsole.show()
wgtConsole.appendLine('ide-demo插件---编辑器-右键菜单启动命令------会在新窗口打印')
if (param === null) {
wgtConsole.appendLine('请选中将要执行的项目!')
return
}
})
//订阅销毁钩子,插件禁用的时候,自动注销该command
context.subscriptions.push(disposable)
}
//该方法将在插件禁用的时候调用(目前是在插件卸载的时候触发)
function deactivate () {
}
module.exports = {
activate,
deactivate
}
效果
老窗口
新窗口
识别文件,并弹窗显示
开发插件的 初衷(功能) 各不相同,我们这里以 读取manifest.json文件(识别功能),填写一些信息(表单功能),提交给后端(api交互) 为例
// extension.js
const hx = require('hbuilderx')
const showView = require('./src/main.js')
var fs = require('fs')
let wgtConsole = null
//该方法将在插件激活的时候调用
function activate (context) {
let disposable = hx.commands.registerCommand('extension.demo1', param => {
console.log('ide-demo插件---编辑器-右键菜单启动命令-----会在老窗口打印')
wgtConsole = hx.window.createOutputChannel('ide-demo-console')
wgtConsole.show()
wgtConsole.appendLine('ide-demo插件---编辑器-右键菜单启动命令------会在新窗口打印')
if (param === null) {
wgtConsole.appendLine('请选中将要执行的项目!')
return
}
let workspaceFolder = (param.document && param.document.workspaceFolder) || param.workspaceFolder
const { uri,appid } = workspaceFolder
wgtConsole.appendLine(`本次检测appid:${appid}`)
wgtConsole.appendLine(`本次检测路径:${uri.fsPath}/manifest.json`)
// console.log(workspaceFolder,'------workspaceFolder')
fs.readFile(uri.fsPath + '/manifest.json', function (err,bytesRead) {
if (err) {
wgtConsole.appendLine(`本次检测没有检测到manifest.json!请检验此路径是否正确${uri.fsPath}`)
} else {
wgtConsole.appendLine('识别到manifest.json,正在打开发布窗口!')
showView(workspaceFolder,bytesRead,wgtConsole)
}
})
})
//订阅销毁钩子,插件禁用的时候,自动注销该command
context.subscriptions.push(disposable)
}
//该方法将在插件禁用的时候调用(目前是在插件卸载的时候触发)
function deactivate () {
}
module.exports = {
activate,
deactivate
}
// main.js
const fs = require('fs')
const path = require('path')
const hx = require('hbuilderx')
const JSONC = require('json-comments')
const Html = require('./html.js')
var request = require('request')
let wgtConsole = null
let webviewDialog = null
/**
* @description 打开视图
* @param {Object} projectInfo 项目管理器选中的项目信息
*/
const showView = async (param = {}, bytesRead, wgtCon) => {
wgtConsole = wgtCon
var file = bytesRead.toString('base64')
file = 'data:application/json;base64,' + file
let info = {}
// 创建webviewDialog, 并设置对话框基本属性,包括标题、按钮等
webviewDialog = hx.window.createWebViewDialog({
modal: true,
title: 'ide-demo插件',
description: '本插件功能包含:文件读取、权限授权、表单上传等功能;主要以演示为主,逻辑交互不会太严谨,大家后期根据诉求进行完善即可!',
dialogButtons: ['提交', '关闭'],
size: {
width: 700,
height: 600
}
}, { enableScripts: true })
// 获取项目信息,并读取manifest.json
let projectData = getProjectInfo(param)
// 用于渲染对话框主要内容
let webview = webviewDialog.webView
webview.html = Html(projectData)
webview.onDidReceiveMessage(msg => {
let action = msg.command
switch (action) {
case 'closed':
// 关闭对话框
webviewDialog.close()
break
case 'submitApp':
let data = msg.data
// 设置对话框指定按钮状态
webviewDialog.setButtonStatus('提交', ['loading', 'disable'])
submitApp({
...data,
file
}, info)
break
default:
break
}
})
// 显示对话框,返回显示成功或者失败的信息,主要包含内置浏览器相关状态。
let promi = webviewDialog.show()
// info = await loginFn()
promi.then(function(data) {
console.log(data,'---------首次打开插件')
})
}
// 授权
function loginFn() {
wgtConsole.appendLine('正在获取权限信息,请务必点击授权!!')
return new Promise(resolve => {
webviewDialog.setButtonStatus('授权中...', ['loading', 'disable'])
let prom = hx.authorize.login({
// 插件授权 ID https://open.dcloud.net.cn/
client_id: 'nIDmsBQxkI',
scopes: ['basic', 'email', 'phone'],
description: '获取用户信息和手机号;注意点击拒绝需关闭窗口后,才能重新授权!!'
})
prom.then(async param => {
let str = ''
// 授权没通过
if (param['error']) {
let obj = {
'0':'无错误',
'1':'当前没有登录用户',
'2':'用户取消了授权(直接关闭窗口操作)',
'3':'插件已废弃',
'4':'插件状态异常',
'5':'用户拒绝授权(用户点击拒绝),需关闭窗口后,才能重新授权',
'1002':'服务器参数错误',
'2001':'应用信息不存在',
'3004':'超时',
'3203':'404'
}
str = `授权异常:${obj[param['error']] || param['error']}`
wgtConsole.appendLine(str)
webviewDialog.displayError(str)
webviewDialog.setButtonStatus('提交', [])
return
}
let obj = await getToken(param['code'])
if (obj.err) {
str = `获取token异常:${obj.err}`
wgtConsole.appendLine(str)
webviewDialog.displayError(str)
webviewDialog.setButtonStatus('提交', [])
return
}
let info = await getUserInfo(obj.access_token)
if (info.err) {
str = `获取用户信息异常:${info.err}`
wgtConsole.appendLine(str)
webviewDialog.displayError(str)
webviewDialog.setButtonStatus('提交', [])
return
}
webviewDialog.setButtonStatus('提交', [])
resolve(info)
})
})
}
/**
* @description 提交
* @param {Object} appInfo
* @param {Object} webviewDialog
* @param {Object} manifest
*/
async function submitApp(appInfo, manifest) {
if (!manifest.data) {
manifest = await loginFn()
if (!manifest.data) return
}
let {
appName,
version,
description
} = appInfo
if (!version || !appName) {
webviewDialog.setButtonStatus('提交', [])
// 在对话框副标题下方显示红色错误信息,错误信息会由动态抖动效果
let emsg = '所有信息必填,不能为空'
wgtConsole.appendLine(emsg)
webviewDialog.displayError(emsg)
return
}
console.log(manifest, '--------manifest')
let data = {
// acl: 'private',
...appInfo,
publisher: manifest.data.phone,
publisherName: manifest.data.nickname,
description: description || manifest.data.nickname + '在 ' + getNowDate() + ' 提交上传',
timeStamp: new Date().getTime().toString().substr(0, 10)
}
uploadFn(data)
}
/**
* 提交信息
* @param {*} form
*/
async function uploadFn(form) {
wgtConsole.appendLine('调用我司上传接口:https://xxx.xxx.com/api/form')
request.post('https://xxx.xxx.com/api/form', {
body: form,
headers: {
// 'content-type': 'multipart/form-data; ',
'content-type': 'appliction/json'
},
json: true
},
(error, response, res) => {
if (error) {
webviewDialog.displayError('接口' + error)
webviewDialog.setButtonStatus('提交', [])
} else if (response.statusCode === 200) {
wgtConsole.appendLine('上传成功')
webviewDialog.close()
// hx.window.showInformationMessage('提交成功', ['关闭'])
} else {
let { error } = JSON.parse((res))
wgtConsole.appendLine(error)
webviewDialog.displayError(error)
webviewDialog.close()
webviewDialog.setButtonStatus('提交', [])
}
})
}
/**
* @description 获取项目信息,并读取manifest.json
* @param {Object} projectInfo
*/
function getProjectInfo(projectInfo) {
let data = {}
data.projectName = projectInfo.name
data.projectType = projectInfo.nature
data.projectPath = projectInfo.uri.fsPath
let manifestFilePath = path.join(data.projectPath, 'manifest.json')
if (!fs.existsSync(manifestFilePath)) {
manifestFilePath = path.join(data.projectPath, 'src/manifest.json')
}
if (['App', 'UniApp_Vue', 'Wap2App'].includes(data.projectType) && fs.existsSync(manifestFilePath)) {
let manifest = fs.readFileSync(manifestFilePath, 'utf-8')
try {
let {
name,
description,
versionName,
appid,
versionCode
} = JSONC.parse(manifest)
data.name = name
data.description = description
data.versionName = versionName
data.versionCode = versionCode
data.appid = appid
} catch (e) {}
}
return data
}
function getToken(code = '') {
return new Promise(resolve => {
wgtConsole.appendLine('授权成功,调用登录接口 https://account.dcloud.net.cn/dcloudOauthv2/accessToken!')
request({
url: 'https://account.dcloud.net.cn/dcloudOauthv2/accessToken',
method: 'POST',
json: true,
headers: { 'content-type': 'application/json' },
body: {
code,
client_id: '插件client_id 文章一有申请流程',
app_secret: '插件app_secret 文章一有申请流程'
}
}, (err, rep, body) => {
if (err) {
resolve({ err: 'post 获取token 错误 err : ' + err })
} else if (body.ret === 0) {
resolve(body.data)
} else {
resolve({ err: '错误:' + body.ret + '_' + body.desc })
}
})
})
}
function getUserInfo(access_token = '') {
return new Promise(resolve => {
wgtConsole.appendLine('登录成功,调用信息接口 https://account.dcloud.net.cn/dcloudOauthv2/userInfo')
request({
url: 'https://account.dcloud.net.cn/dcloudOauthv2/userInfo',
method: 'POST',
json: true,
headers: { 'content-type': 'application/json' },
body: { access_token }
}, (err, rep, body) => {
console.log(err, body, '-------err, rep, body')
if (err) {
resolve({ err: 'post 获取信息 错误 err : ' + err })
} else if (body.ret === 0) {
resolve(body)
} else {
resolve({ err: '错误:' + body.ret + '_' + body.desc })
}
})
})
}
// 格式化日对象
function getNowDate() {
var date = new Date()
var sign2 = ':'
var year = date.getFullYear() // 年
var month = date.getMonth() + 1 // 月
var day = date.getDate() // 日
var hour = date.getHours() // 时
var minutes = date.getMinutes() // 分
var seconds = date.getSeconds() //秒
var weekArr = ['星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期天']
var week = weekArr[date.getDay()]
// 给一位数的数据前面加 “0”
if (month >= 1 && month <= 9) {
month = '0' + month
}
if (day >= 0 && day <= 9) {
day = '0' + day
}
if (hour >= 0 && hour <= 9) {
hour = '0' + hour
}
if (minutes >= 0 && minutes <= 9) {
minutes = '0' + minutes
}
if (seconds >= 0 && seconds <= 9) {
seconds = '0' + seconds
}
return year + '-' + month + '-' + day + ' ' + hour + sign2 + minutes + sign2 + seconds
}
module.exports = showView
// html.js
const path = require('path')
const vueFile = path.join(path.resolve(__dirname), 'static', 'vue.min.js')
const utils = path.join(path.resolve(__dirname), 'static', 'utils.js')
const bootstrapCssFile = path.join(path.resolve(__dirname), 'static', 'bootstrap.min.css')
const customCssFile = path.join(path.resolve(__dirname), 'static', 'custom.css')
function Html (projectData) {
projectData = JSON.stringify(projectData)
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="${bootstrapCssFile}">
<link rel="stylesheet" href="${customCssFile}">
<script src="${vueFile}"></script>
<script src="${utils}"></script>
</head>
<body>
<div id="app" v-cloak>
<form>
<div class="form-group row m-0 mt-3">
<label for="repo-type" class="col-sm-2 col-form-label">应用包名</label>
<div class="col-sm-10" >
<input type="text" class="form-control outline-none" v-model="appInfo.appid" placeholder="应用包名" disabled />
</div>
</div>
<div class="form-group row m-0 mt-3">
<label for="repo-type" class="col-sm-2 col-form-label">应用名称</label>
<div class="col-sm-10">
<input type="text" class="form-control outline-none" v-model="appInfo.name" placeholder="应用名称" disabled/>
</div>
</div>
<div class="form-group row m-0 mt-3">
<label for="git-url" class="col-sm-2 col-form-label">应用版本</label>
<div class="col-sm-10">
<input type="text" class="form-control outline-none" v-model="appInfo.versionName" placeholder="请输入版本号,例如:1.0.0" disabled/>
</div>
</div>
<div class="form-group row m-0 mt-3">
<label for="repo-type" class="col-sm-2 col-form-label">应用描述</label>
<div class="col-sm-10">
<textarea type="text" v-model="appInfo.description" placeholder="请输入应用描述"></textarea>
</div>
</div>
</form>
</div>
<script>
Vue.directive('focus', {
inserted: function(el) {
el.focus()
}
});
var app = new Vue({
el: '#app',
data: {
appInfo: {
name: "",
appid: "",
versionName: "",
description: ""
}
},
async created() {
this.appInfo = ${projectData}
},
mounted() {
this.$nextTick(() => {
window.addEventListener('hbuilderxReady', () => {
this.btnClick();
})
});
},
methods: {
btnClick() {
hbuilderx.onDidReceiveMessage((msg)=>{
if(msg.type == 'DialogButtonEvent'){
let button = msg.button;
if(button == '关闭'){
hbuilderx.postMessage({
command: 'closed'
});
} else {
hbuilderx.postMessage({
command: 'submitApp',
data: this.appInfo
});
}
};
});
}
}
});
</script>
</body>
</html>
`
}
module.exports = Html
弹窗页面效果
授权弹窗效果
需要注意的地方
api接口
需要使用 request 与 json-comments
// package.json
...
"dependencies": {
"json-comments": "^0.2.1",
"request": "^2.88.2"
}
...
// main.js
let request = require('request')
request({
url: 'https://account.dcloud.net.cn/dcloudOauthv2/accessToken',
method: 'POST',
json: true,
headers: { 'content-type': 'application/json' },
body: {
code,
client_id: '插件client_id',
app_secret: '插件app_secret'
}
}, (err, rep, body) => {
})