基于 monorepo 的架構(gòu)實(shí)踐總結(jié): vscode 插件及其相關(guān) packages 開(kāi)發(fā)
前言
GithubBlog:https://github.com/Nealyang/PersonalBlog/issues/99
背景如是:
隨著腳手架的提供,以及新增頁(yè)面和模塊的功能封裝。
畢竟 「多提供一層規(guī)范,就多了一層約束。」 而架構(gòu)的本質(zhì)是為了讓開(kāi)發(fā)者能夠?qū)⒕Ω拥膄ocus 到業(yè)務(wù)的開(kāi)發(fā)中,無(wú)需關(guān)心其他。比如上述腳手架初始化出來(lái)的一些模塊配置、異步加載甚至一些已定義并且保留在初始化架構(gòu)中的一些業(yè)務(wù) hooks 等。
如上原因,我希望能夠提供一套可視化的操作(創(chuàng)建項(xiàng)目、選擇依賴、添加頁(yè)面,選擇所需物料、配置物料屬性等),一言以蔽之就是讓用戶對(duì)于源碼開(kāi)發(fā)而言,只需要編寫對(duì)應(yīng)的業(yè)務(wù)模塊組件,而不需管理架構(gòu)是如何組織模塊和狀態(tài)分發(fā)的,「除了業(yè)務(wù)模塊編碼,其他都是可視化操作」。
因?yàn)閳F(tuán)隊(duì)里 100%的同學(xué)都是以 vscode 作為飯碗,所以自然而然的 vscode extinction 就是我的第一選擇了。計(jì)劃中會(huì)提供創(chuàng)建項(xiàng)目、新增頁(yè)面、模塊配置、頁(yè)面配置、新增模塊等一系列插件。后續(xù)階段性進(jìn)展,再發(fā)文總結(jié)。咳咳,是的,這將是一個(gè)「源碼工作臺(tái)」的趕腳~
截止目前,已經(jīng)將項(xiàng)目的腳手架基本搭建了個(gè) 90% ,此處作為第一階段性總結(jié)。
成果展示


extensions 文件夾為 vscode 插件的文件夾、packages 文件夾是存放公共的組件、scripts 為發(fā)布、構(gòu)建、開(kāi)發(fā)的腳本,其他就是一些工程配置。

?當(dāng)然,這里最主要不是產(chǎn)品功能的展示,嘎嘎~
?
packages.json scripts
"scripts": {
"publish": "lerna list && publish:package",
"publish-beta": "lerna list && npm run publish-beta:package",
"start":"tnpm run start:packages && tnpm run start:extensions",
"start:packages": "tnpm run setup:packages && tnpm run packages:watch",
"start:extensions":"tnpm run extensions:link",
"commit": "git-cz",
"env": "node ./scripts/env.js",
"packages:link": "lerna link",
"packages:install": "rm -rf node_modules && rm -rf ./packages/*/node_modules && rm -rf ./packages/*/package-lock.json && SASS_BINARY_SITE=https://npm.taobao.org/mirrors/node-sass/ yarn install --registry=http://registry.npm.taobao.org",
"packages:clean": "rm -rf ./packages/*/lib",
"packages:watch": "ts-node ./scripts/watch.ts",
"packages:build": "npm run packages:clean && ts-node ./scripts/build.ts",
"setup:packages": "npm run packages:install && lerna clean --yes && npm run packages:build && npm run packages:link ",
"publish-beta:package": "ts-node ./scripts/publish-beta-package.ts",
"publish:package": "ts-node ./scripts/publish-package.ts",
"extensions:install": " rm -rf ./extensions/*/node_modules && rm -rf ./extensions/*/package-lock.json && rm -rf ./extensions/*/web/node_modules && rm -rf ./extensions/*/web/package-lock.json && ts-node ./scripts/extension-deps-install.ts",
"extensions:link": "ts-node ./scripts/extension-link-package.ts"
}
scripts 沒(méi)有添加完全,目前開(kāi)發(fā)直接 npm start 發(fā)布 packages 分別為 npm run publish-beta:package 、 npm run publish:package ,上面也有 publish 的命令匯總。
架構(gòu)選型
目前是為了將 pmCli 功能全部封裝成插件,然后通過(guò)可視化替代掉編碼過(guò)程中關(guān)于架構(gòu)配置的相關(guān)操作。所以插件必然不會(huì)只有一個(gè),而是一個(gè)基于源碼架構(gòu)的一個(gè)「操作集」:多 extensions。插件中有非常多的相似功能封裝。比如從 gitlab 上讀取基礎(chǔ)文件、vscode 和 WebView 的通信、AST 的基本封裝等,所以必然需要依賴非常多的 packages ,為了開(kāi)發(fā)提效和集合的統(tǒng)一管理,必然想到基于 lerna 的monorepo 的項(xiàng)目結(jié)構(gòu)。 
其中關(guān)于 lerna 的一些采坑就不多說(shuō)了,主要是我也只是看了市面上大部分的實(shí)踐文章和官方文檔,缺乏一些自己實(shí)踐(畢竟感覺(jué)研究多也解決不了多大的痛點(diǎn),就不想花精力了)最終的 monorepo 是基于 yarn workspace 實(shí)現(xiàn)的,通過(guò) lerna link 來(lái)軟鏈package、lerna 的發(fā)布 package 比較雞肋,就參考 App works 自己寫了一些打包發(fā)布到預(yù)發(fā)、線上的腳本。
項(xiàng)目工作流以及編碼約束通過(guò)husky、lint-staged、git-cz、 eslint、prettier等常規(guī)配置。
編碼采用 ts 編碼,所以對(duì)于 extensions 以及 packages 中都有很多公共的配置,這里可以提取出來(lái)公共部分放置到項(xiàng)目根目錄下(如上項(xiàng)目目錄截圖)。
實(shí)踐
通過(guò) lerna init、lerna create xxx 來(lái)初始化這里就不說(shuō)了。反正完事以后就是帶有一個(gè)packages 和 package.json 文件的一個(gè)目錄結(jié)構(gòu)。
項(xiàng)目架構(gòu)

package結(jié)構(gòu)

?以上結(jié)構(gòu)說(shuō)明都在圖片里了
?
腳本封裝
在項(xiàng)目的根目錄下放置了一個(gè) scripts 文件夾,存放著一些發(fā)布、開(kāi)發(fā)以及依賴的安裝的腳本啥的。

getPakcageInfo.ts
?用于從
?packages中獲取相關(guān)publish信息。其中shouldPublish是將本地version和線上version對(duì)比,判斷師傅需要執(zhí)行publish的
/*
* @Author: 一凨
* @Date: 2021-06-07 18:47:32
* @Last Modified by: 一凨
* @Last Modified time: 2021-06-07 19:12:28
*/
import { existsSync, readdirSync, readFileSync } from 'fs';
import { join } from 'path';
import { getLatestVersion } from 'ice-npm-utils';
const TARGET_DIRECTORY = join(__dirname, '../../packages');
// 定義需要獲取到的信息結(jié)構(gòu)
export interface IPackageInfo {
name: string;
directory: string;
localVersion: string;
mainFile: string; // package.json main file
shouldPublish: boolean;
}
// 檢查 package 是否 build 成功
function checkBuildSuccess(directory: string, mainFile: string): boolean {
return existsSync(join(directory, mainFile));
}
// 判斷線上最新version是否和本地 version 相同
function checkVersionExists(pkg: string, version: string): Promise<boolean> {
return getLatestVersion(pkg)
.then((latestVersion) => version === latestVersion)
.catch(() => false);
}
export async function getPackageInfos ():Promise<IPackageInfo[]>{
const packageInfos: IPackageInfo[] = [];
if (!existsSync(TARGET_DIRECTORY)) {
console.log(`[ERROR] Directory ${TARGET_DIRECTORY} not exist!`);
} else {
// 拿到所有packages 目錄,再去遍歷其 package.json
const packageFolders: string[] = readdirSync(TARGET_DIRECTORY).filter((filename) => filename[0] !== '.');
console.log('[PUBLISH] Start check with following packages:');
await Promise.all(
packageFolders.map(async (packageFolder) => {
const directory = join(TARGET_DIRECTORY, packageFolder);
const packageInfoPath = join(directory, 'package.json');
// Process package info.
if (existsSync(packageInfoPath)) {
const packageInfo = JSON.parse(readFileSync(packageInfoPath, 'utf8'));
const packageName = packageInfo.name || packageFolder;
console.log(`- ${packageName}`);
// 從 package.json 中取信息 返回
try {
packageInfos.push({
name: packageName,
directory,
localVersion: packageInfo.version,
mainFile: packageInfo.main,
// If localVersion not exist, publish it
shouldPublish:
checkBuildSuccess(directory, packageInfo.main) &&
!(await checkVersionExists(packageName, packageInfo.version)),
});
} catch (e) {
console.log(`[ERROR] get ${packageName} information failed: `, e);
}
} else {
console.log(`[ERROR] ${packageFolder}'s package.json not found.`);
}
}),
);
}
return packageInfos;
}
代碼的解釋都在注釋里了,核心做的事情就是,從 packages 中讀取每一個(gè) package 的 package.json 中的信息,然后組成需要的格式返回出去,用于發(fā)布。
publish-beta-package
/*
* @Author: 一凨
* @Date: 2021-06-07 18:45:51
* @Last Modified by: 一凨
* @Last Modified time: 2021-06-07 19:29:26
*/
import * as path from 'path';
import * as fs from 'fs-extra';
import { spawnSync } from 'child_process';
import { IPackageInfo, getPackageInfos } from './fn/getPackageInfos';
const BETA_REG = /([^-]+)-beta\.(\d+)/; // '1.0.0-beta.1'
interface IBetaPackageInfo extends IPackageInfo {
betaVersion: string;
}
function setBetaVersionInfo(packageInfo: IPackageInfo): IBetaPackageInfo {
const { name, localVersion } = packageInfo;
let version = localVersion;
if (!BETA_REG.test(localVersion)) {
// 如果 localVersion 不是 beta version,則盤他!
let betaVersion = 1;
// 獲取package 的 dist-tag 相關(guān)信息
const childProcess = spawnSync('npm', ['show', name, 'dist-tags', '--json'], {
encoding: 'utf-8',
});
const distTags = JSON.parse(childProcess.stdout || "{}") || {};
const matched = (distTags.beta || '').match(BETA_REG);
// 1.0.0-beta.1 -> ["1.0.0-beta.1", "1.0.0", "1"] -> 1.0.0-beta.2
if (matched && matched[1] === localVersion && matched[2]) {
// 盤 version,+1
betaVersion = Number(matched[2]) + 1;
}
version += `-beta.${betaVersion}`;
}
return Object.assign({}, packageInfo, { betaVersion: version });
}
// 將矯正后的 betaVersion 寫到對(duì)應(yīng) package.json 中
function updatePackageJson(betaPackageInfos: IBetaPackageInfo[]): void {
betaPackageInfos.forEach((betaPackageInfo: IBetaPackageInfo) => {
const { directory, betaVersion } = betaPackageInfo;
const packageFile = path.join(directory, 'package.json');
const packageData = fs.readJsonSync(packageFile);
packageData.version = betaVersion;
for (let i = 0; i < betaPackageInfos.length; i++) {
const dependenceName = betaPackageInfos[i].name;
const dependenceVersion = betaPackageInfos[i].betaVersion;
if (packageData.dependencies && packageData.dependencies[dependenceName]) {
packageData.dependencies[dependenceName] = dependenceVersion;
} else if (packageData.devDependencies && packageData.devDependencies[dependenceName]) {
packageData.devDependencies[dependenceName] = dependenceVersion;
}
}
fs.writeFileSync(packageFile, JSON.stringify(packageData, null, 2));
});
}
// npm publish --tag=beta 發(fā)布
function publish(pkg: string, betaVersion: string, directory: string): void {
console.log('[PUBLISH BETA]', `${pkg}@${betaVersion}`);
spawnSync('npm', ['publish', '--tag=beta'], {
stdio: 'inherit',
cwd: directory,
});
}
// 入口文件
console.log('[PUBLISH BETA] Start:');
getPackageInfos().then((packageInfos: IPackageInfo[]) => {
const shouldPublishPackages = packageInfos
.filter((packageInfo) => packageInfo.shouldPublish)
.map((packageInfo) => setBetaVersionInfo(packageInfo));
updatePackageJson(shouldPublishPackages);
// Publish
let publishedCount = 0;
const publishedPackages = [];
shouldPublishPackages.forEach((packageInfo) => {
const { name, directory, betaVersion } = packageInfo;
publishedCount++;
// 打印此次發(fā)布的相關(guān)信息
console.log(`--- ${name}@${betaVersion} ---`);
publish(name, betaVersion, directory);
publishedPackages.push(`${name}:${betaVersion}`);
});
console.log(`[PUBLISH PACKAGE BETA] Complete (count=${publishedCount}):`);
console.log(`${publishedPackages.join('\n')}`);
});
基本功能都在注釋里了(這句話后面不贅述了),總結(jié)次腳本作用:
拿到所有的本地 packageInfo 信息 對(duì)比線上(已發(fā)布)信息,糾正此次發(fā)布需要的版本信息 將糾正的版本信息補(bǔ)充道(寫入)本地對(duì)應(yīng)的 package 中的 package.json 中 調(diào)用腳本,執(zhí)行發(fā)布
?
publish-package就非常簡(jiǎn)單了,寫的也比較簡(jiǎn)單,就是調(diào)用npm publish,當(dāng)然,也需要一些基本的線上校驗(yàn),比如上述的shouldPublish。不贅述了!需要注意的是,發(fā)布的時(shí)候,需要注意登陸(
?npm whoami)以及如果你也是采用@xxx/的命名方式的話,注意對(duì)應(yīng)organization的權(quán)限
watch
主要借助 nsfw 的能力對(duì)本地文件進(jìn)行監(jiān)聽(tīng)。「有變動(dòng),咱編譯就完事了!」
/*
* @Author: 一凨
* @Date: 2021-06-07 20:16:09
* @Last Modified by: 一凨
* @Last Modified time: 2021-06-10 17:19:05
*/
import * as glob from 'glob';
import * as path from 'path';
import * as fs from 'fs-extra';
import { run } from './fn/shell';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const nsfw = require('nsfw');
async function watchFiles(cwd, ext) {
const files = glob.sync(ext, { cwd, nodir: true });
const fileSet = new Set();
/* eslint no-restricted-syntax:0 */
for (const file of files) {
/* eslint no-await-in-loop:0 */
await copyOneFile(file, cwd);
fileSet.add(path.join(cwd, file));
}
const watcher = await nsfw(cwd, (event) => {
event.forEach((e) => {
if (
e.action === nsfw.actions.CREATED ||
e.action === nsfw.actions.MODIFIED ||
e.action === nsfw.actions.RENAMED
) {
const filePath = e.newFile ? path.join(e.directory, e.newFile!) : path.join(e.directory, e.file!);
if (fileSet.has(filePath)) {
console.log('non-ts change detected:', filePath);
copyOneFile(path.relative(cwd, filePath), cwd);
}
}
});
});
watcher.start();
}
watchFiles(path.join(__dirname, '../packages'), '*/src/**/!(*.ts|*.tsx)').catch((e) => {
console.trace(e);
process.exit(128);
});
// 在這之上的代碼都是為了解決 tsc 不支持 copy 非 .ts/.tsx 文件的問(wèn)題
async function tscWatcher() {
await run('npx tsc --build ./tsconfig.json -w');
}
tscWatcher();
async function copyOneFile(file, cwd) {
const from = path.join(cwd, file);
const to = path.join(cwd, file.replace(/src\//, '/lib/'));
await fs.copy(from, to);
}
extensions-deps-install
因?yàn)槲覀兊?workspace 是 packages 目錄下,所以針對(duì)于 extensions 下的插件以及 web 頁(yè)面,我們沒(méi)有辦法通過(guò) yarn 直接install 所有依賴,隨意提供了一個(gè)插件安裝依賴的腳本。「其實(shí)就是跑到項(xiàng)目目錄下,去執(zhí)行 npm i」
import * as path from 'path';
import * as fse from 'fs-extra';
import * as spawn from 'cross-spawn';
export default function () {
const extensionsPath = path.join(__dirname, '..', '..', 'extensions');
const extensionFiles = fse.readdirSync(extensionsPath);
const installCommonds = ['install'];
if (!process.env.CI) { // 拼接參數(shù)
installCommonds.push('--no-package-lock');
installCommonds.push('--registry');
installCommonds.push(process.env.REGISTRY ? process.env.REGISTRY : 'http://registry.npm.taobao.org');
}
for (let i = 0; i < extensionFiles.length; i++) {
// 遍歷安裝,如果有 web 目錄,則繼續(xù)安裝 web 頁(yè)面里的依賴
const cwd = path.join(extensionsPath, extensionFiles[i]);
// eslint-disable-next-line quotes
console.log("Installing extension's dependencies", cwd);
spawn.sync('tnpm', installCommonds, {
stdio: 'inherit',
cwd,
});
const webviewPath = path.join(cwd, 'web');
if (fse.existsSync(webviewPath)) {
// eslint-disable-next-line quotes
console.log("Installing extension webview's dependencies", webviewPath);
spawn.sync('tnpm', installCommonds, {
stdio: 'inherit',
cwd: webviewPath,
});
}
}
}
?注意
?scripts都是 ts 編碼,所以在npmScripts中采用ts-node去執(zhí)行
extension-link-package
刪除本地相關(guān)的 package,讓其遞歸向上(應(yīng)用級(jí))查找到對(duì)應(yīng)軟鏈后的 package
import * as path from 'path';
import * as fse from 'fs-extra';
import { run } from './fn/shell';
(async function () {
const extensionsPath = path.join(__dirname, '../extensions');
const extensionFiles = await fse.readdir(extensionsPath);
// 獲取 extensions 下的插件列表,挨個(gè)遍歷執(zhí)行 remove
return await Promise.all(
extensionFiles.map(async (extensionFile) => {
const cwd = path.join(extensionsPath, extensionFile);
if (fse.existsSync(cwd)) {
// link packages to extension
if (!process.env.CI) {
await removePmworks(cwd);
}
const webviewPath = path.join(cwd, 'web');
if (fse.existsSync(webviewPath)) {
// link packages to extension webview
if (!process.env.CI) {
await removePmworks(webviewPath);
}
}
}
}),
);
})().catch((e) => {
console.trace(e);
process.exit(128);
});
// 刪除 @pmworks 下的依賴
async function removePmworks(cwd: string) {
const cwdStat = await fse.stat(cwd);
if (cwdStat.isDirectory()) {
await run(`rm -rf ${path.join(cwd, 'node_modules', '@pmworks')}`);
}
}
小小總結(jié)
核心腳本目前就如上吧,其實(shí)都是比較簡(jiǎn)單直接的功能。關(guān)于 extensions 的發(fā)布啥的還沒(méi)有寫,其實(shí)也可以從 appworks 中借(抄)鑒(襲)到的。等后續(xù)發(fā)布插件了再補(bǔ)充吧。
一個(gè)項(xiàng)目完成基建以后,基本就可以開(kāi)工了。這里我拿創(chuàng)建項(xiàng)目來(lái)舉例子吧(著重說(shuō)基建部分,對(duì)插件功能和實(shí)現(xiàn)不展開(kāi)具體的解釋,第二階段再總結(jié)吧)。
vscode extensions(vscode-webview 封裝舉例)
我們通過(guò) yo code 在 extensions 文件夾中去初始化一下我們要寫的插件。具體的基礎(chǔ)知識(shí),參考官方文檔:https://code.visualstudio.com/api
如上以后,我們有了一個(gè)項(xiàng)目的基本架構(gòu),包的一系列管理,就已經(jīng)可以進(jìn)入到我們的開(kāi)發(fā)階段了。

畢竟我們插件是為了可視化的一系列操作,那么vscode 的按鈕和命令必然滿足不了我們,需要一個(gè)操作界面:webView。如上圖是一個(gè)帶有 webView 插件的整體交互過(guò)程:
Common-xxx(utils)是負(fù)責(zé)整個(gè)項(xiàng)目級(jí)別一些通用功能封裝Extension-utils是針對(duì)某一個(gè)插件提取的一些方法庫(kù),比如project-utils是createProject初始化項(xiàng)目時(shí)候用到的方法庫(kù),類似于一個(gè)controllerextension-service是承載vscode和webView通信的一些方法提取,顧名思義:service
上面說(shuō)的有些繞,與傳統(tǒng) MVC 不同的是這里的 view 有兩個(gè):vscode-extension 和 extension-webview

舉個(gè)栗子!這里以初始化一個(gè)項(xiàng)目教授架為例子吧~
?關(guān)于 vscode extension with WebView相關(guān)基礎(chǔ)概念可以看這里:https://code.visualstudio.com/api/extension-guides/webview
?
WebView
WebView 其實(shí)沒(méi)有太多要準(zhǔn)備的,就是準(zhǔn)備 HTML、JavaScript 和 css 前端三大件就行了。
這里我使用的 ice 的腳手架初始化出來(lái)的項(xiàng)目:npm init ice

然后修改 build.json 中的outputDir配置,以及指定為 mpa 模式
{
"mpa": true,
"vendor": false,
"publicPath": "./",
"outputDir": "../build",
"plugins": [
[
"build-plugin-fusion",
{
"themePackage": "@alifd/theme-design-pro"
}
],
[
"build-plugin-moment-locales",
{
"locales": [
"zh-cn"
]
}
],
"@ali/build-plugin-ice-def"
]
}
碼完代碼以后得到我們的三大件即可。

?更多關(guān)于 ice 的文檔,請(qǐng)移步官方文檔
?
Extensions
import * as vscode from 'vscode';
import { getHtmlFroWebview, connectService } from "@pmworks/vscode-webview";
import { DEV_WORKS_ICON } from "@pmworks/constants";
import services from './services';
export function activate(context: vscode.ExtensionContext) {
const { extensionPath } = context;
let projectCreatorPanel: vscode.WebviewPanel | undefined;
const activeProjectCreator = () => {
const columnToShowIn = vscode.window.activeTextEditor ? vscode.window.activeTextEditor.viewColumn : undefined;
if (projectCreatorPanel) {
projectCreatorPanel.reveal(columnToShowIn)
} else {
projectCreatorPanel = vscode.window.createWebviewPanel('BeeDev', '初始化源碼架構(gòu)', columnToShowIn || vscode.ViewColumn.One, {
enableScripts: true,
retainContextWhenHidden: true,
});
}
projectCreatorPanel.webview.html = getHtmlFroWebview(extensionPath, 'projectcreator', false);
projectCreatorPanel.iconPath = vscode.Uri.parse(DEV_WORKS_ICON);
projectCreatorPanel.onDidDispose(
() => {
projectCreatorPanel = undefined;
},
null,
context.subscriptions,
);
connectService(projectCreatorPanel, context, { services });
}
let disposable = vscode.commands.registerCommand('devworks-project-creator.createProject.start', activeProjectCreator);
context.subscriptions.push(disposable);
}
export function deactivate() { }
這里也都是常規(guī)操作,注冊(cè)命令和相關(guān)回調(diào),初始化 WebView 。這里說(shuō)下getHtmlFroWebview
/**
* 給本地資源帶上安全協(xié)議
* @param url 本地資源路徑
* @returns 帶有 vscode-resource 協(xié)議的安全路徑
*/
function originResourceProcess(url: string) {
return vscode.Uri.file(url).with({ scheme: 'vscode-resource' });
}
export const getHtmlFroWebview = (
extensionPath: string,
entryName: string,
needVendor?: boolean,
cdnBasePath?: string,
extraHtml?: string,
resourceProcess?: (url: string) => vscode.Uri,): string => {
resourceProcess = resourceProcess || originResourceProcess;
const localBasePath = path.join(extensionPath, 'build');
const rootPath = cdnBasePath || localBasePath;
const scriptPath = path.join(rootPath, `js/${entryName}.js`);
const scriptUri = cdnBasePath ?
scriptPath :
resourceProcess(scriptPath);
const stylePath = path.join(rootPath, `css/${entryName}.css`);
const styleUri = cdnBasePath ?
stylePath :
resourceProcess(stylePath);
// vendor for MPA
const vendorStylePath = path.join(rootPath, 'css/vendor.css');
const vendorStyleUri = cdnBasePath
? vendorStylePath
: resourceProcess(vendorStylePath);
const vendorScriptPath = path.join(rootPath, 'js/vendor.js');
const vendorScriptUri = cdnBasePath
? vendorScriptPath
: resourceProcess(vendorScriptPath);
// Use a nonce to whitelist which scripts can be run
const nonce = getNonce();
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<title>Iceworks</title>
<link rel="stylesheet" type="text/css" href="${styleUri}">
${extraHtml || ''}
` +
(needVendor ? `<link rel="stylesheet" type="text/css" href="${vendorStyleUri}" />` : '') +
`
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="ice-container"></div>
` +
(needVendor ? `<script nonce="${nonce}" src="${vendorScriptUri}"></script>` : '') +
`<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
}
function getNonce(): string {
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 32; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
方法位于 packages/vscode-webview/vscode.ts ,其實(shí)「就是獲取一段 html」,將本地資源添加 vscode 協(xié)議。支持 vendor、extraHtml等
截止目前,我們已經(jīng)可以在 vscode 中喚起我們的 WebView 了。

通信
然后就是解決 vscode 和 WebView 通信的問(wèn)題了。這里的通信跟 pubSub 非常的類似:
插件給 WebView 發(fā)消息
panel.webview.postMessage({text:"你好,這里是 vscode 發(fā)送過(guò)來(lái)的消息"});
webview 端接受消息
window.addEventListener('message',event=>{
const message = event.data;
console.log(`WebView 接受到的消息:${message}`);
})
webview 給插件發(fā)消息
vscode.postMessage({text:"你好,這是 webView 發(fā)送過(guò)來(lái)的消息"});
插件端接受
panel.webview.onDidReceiveMessage(msg=>{
console.log(`插件接受到的消息:${msg}`)
},undefined,context.subscriptions);
這種通信機(jī)制太零散了,在實(shí)際項(xiàng)目中,webView 更加的類似于我們的 view 層。所以「理論上它只要通過(guò) service 去調(diào)用 controller 接口去完成底層操作告訴我結(jié)果就可以」:
比如在創(chuàng)建項(xiàng)目的時(shí)候需要讓用戶選擇創(chuàng)建目錄,在 HTML 頁(yè)面點(diǎn)擊選擇按鈕的 click handle 應(yīng)該如下:
const getAppPath = async () => {
const projectPath = await callService('project', 'getFolderPath', 'ok');
setAppPath(projectPath);
};
callService的形參第一個(gè)作為 service 類、第二個(gè)作為類里面所需要調(diào)用的方法名,后續(xù)的為其對(duì)應(yīng)方法的參數(shù)。
正對(duì)如上,我們封裝一個(gè) callService 方法:
// packages/vscode-webview/webview.ts
// @ts-ignore
export const vscode = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : null;
export const callService = function (service: string, method: string, ...args) {
// 統(tǒng)一 return promise,統(tǒng)一調(diào)用方式
return new Promise((resolve, reject) => {
// 生成對(duì)應(yīng)的 eventId
const eventId = setTimeout(() => { });
console.log(`WebView call vscode extension service:${service} ${method} ${eventId} ${args}`);
// 收到 vscode 發(fā)來(lái)消息,一般為處理后 webView 的需求后
const handler = event => {
const msg = event.data;
console.log(`webview receive vscode message:}`, msg);
if (msg.eventId === eventId) {// 去定時(shí)對(duì)應(yīng)的 eventID,說(shuō)明此次通信結(jié)束,可以移除(結(jié)束)此次通信了
window.removeEventListener('message', handler);
msg.errorMessage ? reject(new Error(msg.errorMessage)) : resolve(msg.result);
}
}
// webview 接受 vscode 發(fā)來(lái)的消息
window.addEventListener('message', handler);
// WebView 向 vscode 發(fā)送消息
vscode.postMessage({
service,
method,
eventId,
args
});
});
}
webview 層完成了對(duì)發(fā)送時(shí)間請(qǐng)求、接受時(shí)間請(qǐng)求以及接受后取消完成(removeListener)此次時(shí)間請(qǐng)求的封裝。那么我們?cè)趤?lái)給 extension 添加上對(duì)應(yīng)的webView 需要的 service.methodName 才行。
這里我們?cè)俜庋b了一個(gè)叫做 connectService 的方法。
connectService(projectCreatorPanel, context, { services });
上面的projectCreatorPanel 就是create 出來(lái)的 WebviewPanel 的“實(shí)例”,而 services 可以理解為含有多個(gè)類的對(duì)象
const services = {
project:{
getFolderPath(...args){
//xxx
},
xxx
},
xxx:{}
}
具體的 connectService 方法如下:
export function connectService(
webviewPanel: vscode.WebviewPanel,
context: vscode.ExtensionContext,
options: IConnectServiceOptions
) {
const { subscriptions } = context;
const { webview } = webviewPanel;
const { services } = options;
webview.onDidReceiveMessage(async (message: IMessage) => {
const { service, method, eventId, args } = message;
const api = services && services[service] && services[service][method];
console.log('onDidReceiveMessage', message);
if (api) {
try {
const fillApiArgLength = api.length - args.length;
const newArgs = fillApiArgLength > 0 ? args.concat(Array(fillApiArgLength).fill(undefined)) : args;
const result = await api(...newArgs, context, webviewPanel);
console.log('invoke service result', result);
webview.postMessage({ eventId, result });
} catch (err) {
console.error('invoke service error', err);
webview.postMessage({ eventId, errorMessage: err.message });
}
} else {
vscode.window.showErrorMessage(`invalid command ${message}`);
}
}, undefined, subscriptions);
}
上面的代碼也比較簡(jiǎn)單,就是「注冊(cè)監(jiān)聽(tīng)函數(shù),然后只要監(jiān)聽(tīng)到WebView post 過(guò)來(lái)的 message,就去取對(duì)應(yīng) services 下的某個(gè) service 的 method 去執(zhí)行,并且傳入 WebView 傳過(guò)來(lái)的參數(shù)」。
extension 的 services 是在這里引入的

而@pmworks/project-service這個(gè) package 里面也只是封裝一些基本的方法調(diào)用。核心的處理邏輯比如下載對(duì)應(yīng)gitRpo、解析本地文件等都是在對(duì)應(yīng)的extension-utils 里面進(jìn)行。「service只管調(diào)用即可。」
小小問(wèn)題
如上已經(jīng)完成了基本的流程封裝,剩下就是具體邏輯的編寫了。但是在實(shí)際開(kāi)發(fā)中,web 頁(yè)面需要拿到 vscode 傳入的參數(shù)才行,而在 web 頁(yè)面開(kāi)發(fā)中,vscode 插件又沒(méi)法讀取未編譯后的代碼。如何解決呢?
「在 webView 里面在封裝一層 callService 用于本地 web 頁(yè)面開(kāi)發(fā)所需」

后續(xù)展望

截止目前,基本介紹完了這兩周除業(yè)務(wù)工作外的一些開(kāi)發(fā)總結(jié)了。接下來(lái)需要惡補(bǔ)一下 vscode 插件的相關(guān) api 準(zhǔn)備開(kāi)始操刀了。當(dāng)然,在這之前,另一個(gè)非常非常緊急的任務(wù)就是還需要再升級(jí)下去年整理的源碼架構(gòu),對(duì)齊下集團(tuán)內(nèi)現(xiàn)在 rax 體系的一些能力。
在回到這個(gè)插件體系(BeeDev源碼工作臺(tái))的開(kāi)發(fā)中,后續(xù)還需要:
初始化源碼架構(gòu) 創(chuàng)建頁(yè)面、拖拉拽相關(guān) H5 源碼物料(需要整個(gè)物料后臺(tái))生成初始化頁(yè)面 創(chuàng)建模塊、可視化配置模塊加載類別等
如果精力有余,其實(shí)還需要個(gè)node 后臺(tái),這樣才能打通服務(wù)端和本地的能力(就是個(gè)桌面應(yīng)用了呀~)
好吧,不 YY,先醬紫吧~ 下一個(gè)里程碑了再總結(jié)下~~
至于項(xiàng)目源碼。。。

參考文獻(xiàn)
appworks:https://appworks.site/ vscode extension api:https://code.visualstudio.com/api monorepo&leran:https://github.com/lerna/lerna
其他
關(guān)注微信公眾號(hào)【全棧前端精選】,每天推送精選文章~
