hacker0limbo / my-blog

个人博客
5 stars 1 forks source link

简单聊一聊 React 和 VSCode Webview (一) #19

Open hacker0limbo opened 3 years ago

hacker0limbo commented 3 years ago

实习快走之前一直在写一个插件, 当时为了赶进度, Webview 部分用的是 vue + Ant Design, 通过 cdn 的方式直接引入. 写的我痛不欲生, 后打算用 React 重写, 于是就有了这篇文章记录, 也算是一个学习和总结吧.

这篇文章更多的是倾向于配置, 我本身对于 Webpack 和 TypeScript 等的配置也不是很熟, 写的时候也基本都是随手谷歌抄过来并没有进行深究. 有错误也请谅解, 另如有更好的方案或有错误也及时指出

代码: https://github.com/hacker0limbo/vscode-webview-react-boilerplate

项目结构

├── app # React 部分
│   ├── App.tsx
│   ├── index.tsx
│   └── tsconfig.json
├── package-lock.json
├── package.json
├── src
│   ├── extension.ts
│   └── view
│       └── ViewLoader.ts
├── test
├── tsconfig.json
└── webpack.config.js

初始化项目

根据官网的 tutorial 初始化项目

npm install -g yo generator-code

yo code

这里需要修改一下目录结构. 默认 test 目录是在 src 下面的, 我个人习惯抽出来和 src 平级. 搜索了一下发现微软很多自己的库 test 文件都是不放在 src 下的, 比如 vscode-postgresql 这个库. 自己给的脚手架却又是另一种方案, 也是很无语...

测试不是本篇文章的重点, 具体信息参考官网的 Testing Extensions 这一章

初始化的项目编译后的代码在 out 目录下呈现的结构是:

out
├── extension.js
└── test

我们希望的目录结构为:

out
├── src
│   ├── extension.js
└── test

因此需要改以下几个文件:

tsconfig.json:

官网关于 rootDir 这章已经说的很清晰了, 如果编译想保留当前目录名, rootDir 需要设置为 "."

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es6",
    "outDir": "out",
    "lib": [
      "es6"
    ],
    "sourceMap": true,
+   "rootDir": ".",
    "strict": true   /* enable all strict type-checking options */
    /* Additional Checks */
    // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
    // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
    // "noUnusedParameters": true,  /* Report errors on unused parameters. */
  },
  "exclude": [
    "node_modules",
    ".vscode-test",
  ]
}

package.json:

入口文件需要改一下:

{
- "main": "./out/extension.js",
+ "main": "./out/src/extension.js",
}

.vscode/launch.json:

我们希望编译后有对应的 source map 方便调试代码, 在 .vscode 目录下的 launch.json 文件修改一下配置:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Run Extension",
      "type": "extensionHost",
      "request": "launch",
      "args": [
        "--extensionDevelopmentPath=${workspaceFolder}"
      ],
      "outFiles": [
-       "${workspaceFolder}/out/**/*.js"
+       "${workspaceFolder}/out/src/**/*.js"
      ],
+     "sourceMaps": true,
      "preLaunchTask": "${defaultBuildTask}"
    },
    {
      "name": "Extension Tests",
      "type": "extensionHost",
      "request": "launch",
      "args": [
        "--extensionDevelopmentPath=${workspaceFolder}",
        "--extensionTestsPath=${workspaceFolder}/out/test/suite/index"
      ],
      "outFiles": [
        "${workspaceFolder}/out/test/**/*.js"
      ],
+     "sourceMaps": true,
      "preLaunchTask": "${defaultBuildTask}"
    }
  ]
}

思路

Webview 可以看成是一个独立的 iframe, 有自己独立的运行环境, 同时也可以和 extension 本身发送和监听消息. 官网的给的文档已经很全了, 这里不做深究

用 React 来写 Webview 其实也很简单, 本质就是给定好一个 html, 利用 Webpack 打包好编译 jsx 等文件到一个 script 中, 然后链接一下即可.

如之前的目录结构所示, app 目录为我们编写 React 代码的部分, 编译后的代码会打包到 out/app 目录下, 在 ViewLoader.ts 里引用这个编译后的文件即可

ViewLoader

原则上来讲, Webview 有一个即可, 这里用单例模式来实现 ViewLoader:

// src/view/ViewLoader.ts

import * as vscode from 'vscode';
import * as path from 'path';

export class ViewLoader {
  public static currentPanel?: vscode.WebviewPanel;

  private panel: vscode.WebviewPanel;
  private context: vscode.ExtensionContext;
  private disposables: vscode.Disposable[];

  constructor(context: vscode.ExtensionContext) {
    this.context = context;
    this.disposables = [];

    this.panel = vscode.window.createWebviewPanel('reactApp', 'React App', vscode.ViewColumn.One, {
      enableScripts: true,
      retainContextWhenHidden: true,
      localResourceRoots: [vscode.Uri.file(path.join(this.context.extensionPath, 'out', 'app'))],
    });

    // render webview
    this.renderWebview();

    // listen messages from webview
    this.panel.webview.onDidReceiveMessage(
      (message) => {
        console.log('msg', message);
      },
      null,
      this.disposables
    );

    this.panel.onDidDispose(
      () => {
        this.dispose();
      },
      null,
      this.disposables
    );
  }

  private renderWebview() {
    const html = this.render();
    this.panel.webview.html = html;
  }

  static showWebview(context: vscode.ExtensionContext) {
    const cls = this;
    const column = vscode.window.activeTextEditor ? vscode.window.activeTextEditor.viewColumn : undefined;
    if (cls.currentPanel) {
      cls.currentPanel.reveal(column);
    } else {
      cls.currentPanel = new cls(context).panel;
    }
  }

  static postMessageToWebview(message: any) {
    // post message from extension to webview
    const cls = this;
    cls.currentPanel?.webview.postMessage(message);
  }

  public dispose() {
    ViewLoader.currentPanel = undefined;

    // Clean up our resources
    this.panel.dispose();

    while (this.disposables.length) {
      const x = this.disposables.pop();
      if (x) {
        x.dispose();
      }
    }
  }

  render() {
    const bundleScriptPath = this.panel.webview.asWebviewUri(
      vscode.Uri.file(path.join(this.context.extensionPath, 'out', 'app', 'bundle.js'))
    );

    return `
      <!DOCTYPE html>
        <html lang="en">
        <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <title>React App</title>
        </head>

        <body>
          <div id="root"></div>
          <script src="${bundleScriptPath}"></script>
        </body>
      </html>
    `;
  }
}

对应的在 extension.ts 里面注册好打开 Webview 的命令后, 只需要用静态方法 showWebview() 即可初始化或显示之前被隐藏的 Webview panel.

// src/extension.ts

export function activate(context: vscode.ExtensionContext) {
  const disposable = vscode.commands.registerCommand('webview.open', () => {
    ViewLoader.showWebview(context);
  });

  context.subscriptions.push(disposable);
}

同时规定, 所有关于 React 的文件均放在 app 目录下, 编译后的文件名为 bundle.js 也在 out/app 目录下, 保持一致

安装依赖

npm install react react-dom react-router-dom
npm install --save-dev @types/react @types/react-dom @types/react @types/react-router-dom webpack webpack-cli ts-loader css-loader style-loader npm-run-all

这里提一下, 为了让编译 Webview 的任务和编译插件本身的任务同时进行, 安装了 npm-run-all 这个库.

配置 Webpack

本人对 Webpack 不熟, 这些配置基本都是从官网或者谷歌抄过来的, 如果你有更好的自定义方案求轻喷:

// webpack.config.js

const path = require('path');

module.exports = {
  entry: path.join(__dirname, 'app', 'index.tsx'),
  resolve: {
    extensions: ['.ts', '.tsx', '.js', '.jsx', '.css'],
  },
  devtool: 'inline-source-map',
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'ts-loader',
        exclude: '/node_modules/',
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'out', 'app'),
  },
};

这里需要更改一下根目录下的 tsconfig.json 文件, 如下:

{
  "exclude": [
    "node_modules",
    ".vscode-test",
+    "app"
  ]
}

app 下的所有文件均交给 Webpack 来处理, 该 tsconfig.json 文件仅负责对 extension 代码编译

app

app 目录下新建一个 tsconfig.json 文件, 这个文件是用于编译 app 部分的 ts 和 tsx 代码, 注意和根目录下的 tsconfig.json 区别:

// app/tsconfig.json

{
  "compilerOptions": {
    "module": "ESNext",
    "allowSyntheticDefaultImports": true,
    "moduleResolution": "node",
    "target": "ES5",
    "jsx": "react",
    "sourceMap": true,
    "experimentalDecorators": true,
    "lib": ["dom", "ES2015"],
    "strict": true
  },
  "exclude": ["node_modules"]
}

同样的, 我对 ts 配置也不熟, 这里只是给出从网上拔过来的最基本方案, 求轻喷

React & TSX

于是可以欢快的写 React 和 TSX 了...

// app/index.tsx

import React from 'react';
import ReactDOM from 'react-dom';

ReactDOM.render(<div>Hello World</div>, document.getElementById('root'));

编译运行

修改一下 package.json 文件里的命令:

"scripts": {
  "compile": "npm-run-all compile:*",
  "compile:extension": "tsc -p ./",
  "compile:view": "webpack --mode development",
  "watch": "npm-run-all -p watch:*",
  "watch:extension": "tsc -watch -p ./",
  "watch:view": "webpack --watch --mode development",
  "pretest": "npm run compile && npm run lint",
  "lint": "eslint src --ext ts",
  "test": "node ./out/test/runTest.js"
},

运行插件后, VSCode 会开两个进程, 一个负责编译 Extension 部分代码, 一个负责编译 Webview React 部分代码, cmd + shift + p 输入 open webview 命令就能看到最后的 web view 效果了. 至此基本的框架完成

后续

该篇文章只是很简单的说明了如何用 React 来写 VSCode 插件中的 Webview. 但真实的场景下会有很多其他的需求, 比如该如何模拟路由, 如何在插件和 Webview 之间传递消息进行沟通, 如何动态传递插件本身的变量

有时间会写篇文章讲讲上面的思路, 没时间就算了直接看源码吧

参考