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


extensions文件夾為vscode插件的文件夾、packages文件夾是存放公共的組件、scripts為發(fā)布、構(gòu)建、開發(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沒有添加完全,目前開發(fā)直接npm start發(fā)布packages分別為npm run publish-beta:package、npm run publish:package,上面也有publish的命令匯總。
架構(gòu)選型
目前是為了將pmCli功能全部封裝成插件,然后通過可視化替代掉編碼過程中關(guān)于架構(gòu)配置的相關(guān)操作。所以插件必然不會只有一個,而是一個基于源碼架構(gòu)的一個「操作集」:多extensions。插件中有非常多的相似功能封裝。比如從gitlab上讀取基礎(chǔ)文件、vscode和WebView的通信、AST的基本封裝等,所以必然需要依賴非常多的packages,為了開發(fā)提效和集合的統(tǒng)一管理,必然想到基于lerna的monorepo的項目結(jié)構(gòu)。
其中關(guān)于 lerna 的一些采坑就不多說了,主要是我也只是看了市面上大部分的實踐文章和官方文檔,缺乏一些自己實踐(畢竟感覺研究多也解決不了多大的痛點,就不想花精力了)最終的monorepo是基于yarn workspace實現(xiàn)的,通過lerna link來軟鏈package、lerna的發(fā)布package比較雞肋,就參考App works自己寫了一些打包發(fā)布到預(yù)發(fā)、線上的腳本。
項目工作流以及編碼約束通過husky、lint-staged、git-cz、eslint、prettier等常規(guī)配置。
編碼采用ts編碼,所以對于extensions以及packages中都有很多公共的配置,這里可以提取出來公共部分放置到項目根目錄下(如上項目目錄截圖)。
實踐
通過lerna init、lerna create xxx來初始化這里就不說了。反正完事以后就是帶有一個packages和package.json文件的一個目錄結(jié)構(gòu)。
項目架構(gòu)

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

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

getPakcageInfo.ts
?用于從
?packages中獲取相關(guān)publish信息。其中shouldPublish是將本地version和線上version對比,判斷師傅需要執(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中讀取每一個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 寫到對應(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 信息 對比線上(已發(fā)布)信息,糾正此次發(fā)布需要的版本信息 將糾正的版本信息補充道(寫入)本地對應(yīng)的 package 中的 package.json 中 調(diào)用腳本,執(zhí)行發(fā)布
?
publish-package就非常簡單了,寫的也比較簡單,就是調(diào)用npm publish,當(dāng)然,也需要一些基本的線上校驗,比如上述的shouldPublish。不贅述了!需要注意的是,發(fā)布的時候,需要注意登陸(
?npm whoami)以及如果你也是采用@xxx/的命名方式的話,注意對應(yīng)organization的權(quán)限
watch
主要借助 nsfw 的能力對本地文件進行監(jiān)聽。「有變動,咱編譯就完事了!」
/*
* @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 文件的問題
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
因為我們的workspace是packages目錄下,所以針對于extensions下的插件以及web頁面,我們沒有辦法通過yarn直接install所有依賴,隨意提供了一個插件安裝依賴的腳本。「其實就是跑到項目目錄下,去執(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 頁面里的依賴
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)用級)查找到對應(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 下的插件列表,挨個遍歷執(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é)
核心腳本目前就如上吧,其實都是比較簡單直接的功能。關(guān)于 extensions 的發(fā)布啥的還沒有寫,其實也可以從 appworks 中借(抄)鑒(襲)到的。等后續(xù)發(fā)布插件了再補充吧。
一個項目完成基建以后,基本就可以開工了。這里我拿創(chuàng)建項目來舉例子吧(著重說基建部分,對插件功能和實現(xiàn)不展開具體的解釋,第二階段再總結(jié)吧)。
vscode extensions(vscode-webview 封裝舉例)
我們通過yo code在extensions文件夾中去初始化一下我們要寫的插件。具體的基礎(chǔ)知識,參考官方文檔:https://code.visualstudio.com/api
如上以后,我們有了一個項目的基本架構(gòu),包的一系列管理,就已經(jīng)可以進入到我們的開發(fā)階段了。

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

舉個栗子!這里以初始化一個項目教授架為例子吧~
?關(guān)于 vscode extension with WebView相關(guān)基礎(chǔ)概念可以看這里:https://code.visualstudio.com/api/extension-guides/webview
?
WebView
WebView 其實沒有太多要準(zhǔn)備的,就是準(zhǔn)備 HTML、JavaScript 和 css 前端三大件就行了。
這里我使用的 ice 的腳手架初始化出來的項目: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 的文檔,請移步官方文檔
?
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ī)操作,注冊命令和相關(guān)回調(diào),初始化WebView。這里說下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,其實「就是獲取一段 html」,將本地資源添加vscode協(xié)議。支持vendor、extraHtml等
截止目前,我們已經(jīng)可以在 vscode 中喚起我們的 WebView 了。

通信
然后就是解決 vscode 和 WebView 通信的問題了。這里的通信跟 pubSub 非常的類似:
插件給 WebView 發(fā)消息
panel.webview.postMessage({text:"你好,這里是 vscode 發(fā)送過來的消息"});
webview 端接受消息
window.addEventListener('message',event=>{
const message = event.data;
console.log(`WebView 接受到的消息:${message}`);
})
webview 給插件發(fā)消息
vscode.postMessage({text:"你好,這是 webView 發(fā)送過來的消息"});
插件端接受
panel.webview.onDidReceiveMessage(msg=>{
console.log(`插件接受到的消息:${msg}`)
},undefined,context.subscriptions);
這種通信機制太零散了,在實際項目中,webView更加的類似于我們的view層。所以「理論上它只要通過service去調(diào)用controller接口去完成底層操作告訴我結(jié)果就可以」:
比如在創(chuàng)建項目的時候需要讓用戶選擇創(chuàng)建目錄,在 HTML 頁面點擊選擇按鈕的 click handle 應(yīng)該如下:
const getAppPath = async () => {
const projectPath = await callService('project', 'getFolderPath', 'ok');
setAppPath(projectPath);
};
callService的形參第一個作為service類、第二個作為類里面所需要調(diào)用的方法名,后續(xù)的為其對應(yīng)方法的參數(shù)。
正對如上,我們封裝一個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) => {
// 生成對應(yīng)的 eventId
const eventId = setTimeout(() => { });
console.log(`WebView call vscode extension service:${service} ${method} ${eventId} ${args}`);
// 收到 vscode 發(fā)來消息,一般為處理后 webView 的需求后
const handler = event => {
const msg = event.data;
console.log(`webview receive vscode message:}`, msg);
if (msg.eventId === eventId) {// 去定時對應(yīng)的 eventID,說明此次通信結(jié)束,可以移除(結(jié)束)此次通信了
window.removeEventListener('message', handler);
msg.errorMessage ? reject(new Error(msg.errorMessage)) : resolve(msg.result);
}
}
// webview 接受 vscode 發(fā)來的消息
window.addEventListener('message', handler);
// WebView 向 vscode 發(fā)送消息
vscode.postMessage({
service,
method,
eventId,
args
});
});
}
webview層完成了對發(fā)送時間請求、接受時間請求以及接受后取消完成(removeListener)此次時間請求的封裝。那么我們在來給extension添加上對應(yīng)的webView需要的service.methodName才行。
這里我們再封裝了一個叫做 connectService 的方法。
connectService(projectCreatorPanel, context, { services });
上面的projectCreatorPanel就是create 出來的WebviewPanel的“實例”,而 services 可以理解為含有多個類的對象
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)聽函數(shù),然后只要監(jiān)聽到WebViewpost過來的message,就去取對應(yīng)services下的某個service的method去執(zhí)行,并且傳入WebView傳過來的參數(shù)」。
extension 的 services 是在這里引入的

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

項目源碼參考已開源的 appworks.
參考文獻
appworks:https://appworks.site/ vscode extension api:https://code.visualstudio.com/api monorepo&leran:https://github.com/lerna/lerna

Dooring可視化搭建平臺數(shù)據(jù)源設(shè)計剖析
點個在看你最好看

