NodeGUI Research

1,154 阅读5分钟

结论先行

一个基础的 Hello World 的用 Electron 构建的应用程序的尺寸会高达 115 MB。 NodeGUI 则成功地通过不内置 Chromium 浏览器包,编译一个内存更优的尺寸更小的可执行文件。相反,我们无法在 NodeGUI 应用程序中使用所有的浏览器中有的 API 以及 HTML 和 CSS 的功能

NodeGUI 应用程序本质上是一个 Node.js 应用程序,其用户界面由 Qt 控件如 QMainWindowQCheckBox 构建的,我们可以使用 Qt 样式表语法样式化应用程序。它使用了 Flexbox 进行布局 —— 这是网络浏览器的一维布局方法。Qt 控件的数量和覆盖范围可能小于 HTML 原生元素 的数量和覆盖范围,这实际上也限制了我们 —— 我们只能使用 Qt 支持的 HTML 子集。 NodeGUI 附带 13 种标签或称之为 UI 组件,包括按钮,图像标签,可编辑文本区域,进度条和窗口。

Qt 窗口小部件可能会发出事件(称为信号),可以监听事件并以编程方式将其与事件处理程序关联。NodeGUI 还提供了一组的内部事件,应用程序可以对其监听(即 QEvents)。

简介

NodeGUI,与跨平台 GUI 库 Qt 相捆绑,支持 mac、windows、linux。Qt 是最成熟最高效的构建桌面应用的库之一。这让 NodeGUI 在内存和 CPU 上更具效率,与其它的 JavaScript 桌面端 GUI 解决方案形成了明显的对比。可以直接使用node,也可以结合react进行构建桌面客户端。

NodeGui的一些功能包括:

  • 🧬 Cross platform. Works on major Linux flavours, Windows, and MacOS.

  • 📉 Low CPU and memory footprint. Current CPU stays at 0% on idle and memory usage is under 20MB for a Hello World program.

  • 💅 Styling with CSS (includes actual cascading). Also has full support for Flexbox layout (thanks to Yoga).

  • Complete Nodejs API support (Currently runs on Node v16.x - and is easily upgradable). Hence has access to all Nodejs compatible NPM modules.

  • 🎪 Native widget event listener support. Supports all events available from Qt / NodeJs.

  • 💸 Can be used for Commercial applications.

  • 🕵️♂️ Good Devtools support.

  • 📚 Good documentation and website.

  • 🧙♂️ Good documentation for contributors.

  • 🦹🏻♀️ Good support for dark mode (Thanks to Qt).

  • 🏅First class Typescript support. (Works on regular JS projects too 😉).

NodeGui由Qt框架提供支持,与其他基于Chromium的解决方案(例如Electron)相比,它使CPU和内存效率更高。 这意味着使用NodeGui编写的应用程序 不要 打开浏览器实例并在其中呈现UI。 相反,所有窗口小部件均本地呈现。

NodeGui需要CMake和C++编译工具来构建项目的本机C ++层。

Demo体验

使用Node.js和CSS(如样式)构建高性能、本地和跨平台桌面应用程序。🚀NodeGUI由Qt6💚提供支持,与其他基于chromium的解决方案(如Electron)相比,这使得它的CPU和内存效率更高。此外,它还有对应UI主流框架的版本

React NodeGUI.

Vue NodeGUI.

Svelte NodeGUI

demo效果

使用 @nodegui/nodegui

github.com/nodegui/exa…

import {
  QMainWindow,
  QPushButton,
  QPushButtonEvents,
  QWidget,
  QKeyEvent,
  FlexLayout,
  QLabel,
  BaseWidgetEvents,
  NativeElement
} from "@nodegui/nodegui";

// ===============
//  UI AND DESIGN
// ===============
const getButton = (
  label: string,
  value: number | string,
  type: "value" | "command"
) => {
  const button = new QPushButton();
  button.setText(label);
  button.setObjectName(`btn${value}`);
  button.addEventListener(QPushButtonEvents.clicked, () => {
    onBtnClick(value, type);
  });
  return {
    ui: button,
    value,
    type
  };
};

// Main Window
const win = new QMainWindow();
win.setFixedSize(230, 300);

// Root view
const rootView = new QWidget();
win.addEventListener(
  BaseWidgetEvents.KeyRelease,
  (nativeEvent: NativeElement) => {
    const keyEvt = new QKeyEvent(nativeEvent);
    const text = keyEvt.text();
    const isNotNumber = isNaN(parseInt(text));
    onBtnClick(text, isNotNumber ? "command" : "value");
  }
);
rootView.setObjectName("rootView"); //This is like ids in web world
win.setCentralWidget(rootView);
const rootStyleSheet = `
* {
  font-size: 20px;
  color: white;
}

QPushButton {
  min-width: '25%';
  height: 60px;
  border: 1px solid black;
}

QPushButton:pressed {
  background: grey;
}

#rootView {
  flex: 1;
  flex-direction: column;
}

#btnAC {
  min-width: '25%';
  border-right: 2px solid black;
}

#resultText {
  flex: 1;
  qproperty-alignment: 'AlignRight|AlignVCenter';
  padding-right: 5px;
  font-size: 40px;
}

#row0,#row1,#row2,#row3,#row4 {
  flex: 1;
  justify-content: space-between;
  flex-direction: row;
}

#row0 * {
  background: #1E1E1E;
}

#row0 QPushButton:pressed {
  background: grey;
}

#row1 QPushButton  {
  background: #2E2E2E;
}

#row1 QPushButton:pressed {
  background: grey;
}

#row2 QPushButton, #row2 QPushButton, #row3 QPushButton, #row4 QPushButton  {
    background: #4B4B4B;
}

#row2 QPushButton:pressed, #row2 QPushButton:pressed, #row3 QPushButton:pressed, #row4 QPushButton:pressed  {
  background: grey;
}
`;

const operatorStyleSheet = `
QPushButton {
  background: #FD8D0E;
}

QPushButton:pressed {
  background: grey;
}
`;

rootView.setStyleSheet(rootStyleSheet);
const rootViewFlexLayout = new FlexLayout();
rootViewFlexLayout.setFlexNode(rootView.getFlexNode());
rootView.setLayout(rootViewFlexLayout);

// Top Row
const btnClear = getButton("AC", "AC", "command");
const resultText = new QLabel();
resultText.setObjectName("resultText");
resultText.setText(0);
const row0 = new QWidget();
row0.setObjectName("row0");

const row0Layout = new FlexLayout();
row0Layout.setFlexNode(row0.getFlexNode());
row0.setLayout(row0Layout);
row0Layout.addWidget(btnClear.ui, btnClear.ui.getFlexNode());
row0Layout.addWidget(resultText, resultText.getFlexNode());

// First Row
const btn7 = getButton("7", 7, "value");
const btn8 = getButton("8", 8, "value");
const btn9 = getButton("9", 9, "value");
const btnDivide = getButton("/", "/", "command");
btnDivide.ui.setStyleSheet(operatorStyleSheet);
const row1 = new QWidget();
row1.setObjectName("row1");
const row1Layout = new FlexLayout();
row1Layout.setFlexNode(row1.getFlexNode());
row1Layout.addWidget(btn7.ui, btn7.ui.getFlexNode());
row1Layout.addWidget(btn8.ui, btn8.ui.getFlexNode());
row1Layout.addWidget(btn9.ui, btn9.ui.getFlexNode());
row1Layout.addWidget(btnDivide.ui, btnDivide.ui.getFlexNode());
row1.setLayout(row1Layout);

// Second Row
const btn4 = getButton("4", 4, "value");
const btn5 = getButton("5", 5, "value");
const btn6 = getButton("6", 6, "value");
const btnMultiply = getButton("x", "*", "command");
btnMultiply.ui.setStyleSheet(operatorStyleSheet);
const row2 = new QWidget();
row2.setObjectName("row2");
const row2Layout = new FlexLayout();
row2Layout.setFlexNode(row2.getFlexNode());
row2Layout.addWidget(btn4.ui, btn4.ui.getFlexNode());
row2Layout.addWidget(btn5.ui, btn5.ui.getFlexNode());
row2Layout.addWidget(btn6.ui, btn6.ui.getFlexNode());
row2Layout.addWidget(btnMultiply.ui, btnMultiply.ui.getFlexNode());
row2.setLayout(row2Layout);

// Third Row
const btn1 = getButton("1", 1, "value");
const btn2 = getButton("2", 2, "value");
const btn3 = getButton("3", 3, "value");
const btnMinus = getButton("-", "-", "command");
btnMinus.ui.setStyleSheet(operatorStyleSheet);

const row3 = new QWidget();
row3.setObjectName("row3");

const row3Layout = new FlexLayout();
row3Layout.setFlexNode(row3.getFlexNode());
row3Layout.addWidget(btn1.ui, btn1.ui.getFlexNode());
row3Layout.addWidget(btn2.ui, btn2.ui.getFlexNode());
row3Layout.addWidget(btn3.ui, btn3.ui.getFlexNode());
row3Layout.addWidget(btnMinus.ui, btnMinus.ui.getFlexNode());
row3.setLayout(row3Layout);

// Fourth Row
const btn0 = getButton("0", 0, "value");
const btnDot = getButton(".", ".", "command");
const btnEquals = getButton("=", "=", "command");
const btnPlus = getButton("+", "+", "command");
btnPlus.ui.setStyleSheet(operatorStyleSheet);

const row4 = new QWidget();
row4.setObjectName("row4");
const row4Layout = new FlexLayout();
row4Layout.setFlexNode(row4.getFlexNode());
row4Layout.addWidget(btn0.ui, btn0.ui.getFlexNode());
row4Layout.addWidget(btnDot.ui, btnDot.ui.getFlexNode());
row4Layout.addWidget(btnEquals.ui, btnEquals.ui.getFlexNode());
row4Layout.addWidget(btnPlus.ui, btnPlus.ui.getFlexNode());
row4.setLayout(row4Layout);

// Add to root view
rootViewFlexLayout.addWidget(row0, row0.getFlexNode());
rootViewFlexLayout.addWidget(row1, row1.getFlexNode());
rootViewFlexLayout.addWidget(row2, row2.getFlexNode());
rootViewFlexLayout.addWidget(row3, row3.getFlexNode());
rootViewFlexLayout.addWidget(row4, row4.getFlexNode());

win.show();

(global as any).win = win; //to keep gc from collecting ui widgets

// ========================
//  CALC APP LOGIC - LOGIC
// ========================
// This is probably the worst calculator logic ever but the purpose of demo is to showcase the UI and not the js logic.
// Read ahead of this line at your own risk.

let displayText = "0";
let currentInputString = "";
let total = 0;
let previousOperator = "+";

var onBtnClick = (value: number | string, type: "value" | "command") => {
  if (
    ![
      "0",
      "1",
      "2",
      "3",
      "4",
      "5",
      "6",
      "7",
      "8",
      "9",
      "+",
      "-",
      "/",
      "*",
      "=",
      "AC"
    ].includes(`${value}`)
  ) {
    return;
  }
  if (type === "value" || value === ".") {
    currentInputString += value;
    displayText = currentInputString;
  } else {
    const currentInput = parseFloat(currentInputString || "0");
    if (!previousOperator) {
      if (currentInputString) {
        total = currentInput;
      }
    }
    if (!currentInputString && value === "=") {
      previousOperator = "+";
    }
    switch (previousOperator) {
      case "+": {
        total += currentInput;
        break;
      }
      case "-": {
        total -= currentInput;
        break;
      }
      case "*": {
        total *= currentInput;
        break;
      }
      case "/": {
        total /= currentInput;
        break;
      }
    }
    currentInputString = "";

    if (value === "=") {
      displayText = String(total);
      previousOperator = "";
    } else {
      previousOperator = String(value);
      displayText = previousOperator;
    }
  }

  if (value === "AC") {
    displayText = "0";
    currentInputString = "";
    total = 0;
    previousOperator = "+";
  }

  if (Number.isNaN(total)) {
    total = 0;
    displayText = "Error";
  }

  // SET THE FINAL TEXT
  resultText.setText(displayText);
};

使用@nodegui/react-nodegui

github.com/nodegui/exa…

import React, { useReducer, Reducer } from "react";
import { Renderer, View, Text, Button, Window } from "@nodegui/react-nodegui";
import {
  QPushButtonEvents,
  QMainWindowEvents,
  QWidgetEvents,
  QKeyEvent,
  NativeEvent
} from "@nodegui/nodegui";

interface state {
  display: string;
  total: number;
  pendingOp: string;
  valueBuffer: string;
}
interface action {
  type: "operation" | "value";
  value: string;
}
const initialState: state = {
  display: "",
  total: 0,
  pendingOp: "~",
  valueBuffer: ""
};

const reducer: Reducer<state, action> = (state, action) => {
  const newState = { ...state };
  switch (action.type) {
    case "operation": {
      switch (newState.pendingOp) {
        case "+": {
          newState.total =
            newState.total + parseFloat(state.valueBuffer || "0");
          break;
        }
        case "-": {
          newState.total =
            newState.total - parseFloat(state.valueBuffer || "0");
          break;
        }
        case "*": {
          newState.total =
            newState.total * parseFloat(state.valueBuffer || "0");
          break;
        }
        case "/": {
          newState.total =
            newState.total / parseFloat(state.valueBuffer || "1");
          break;
        }
        case "=": {
          break;
        }
        case "~": {
          newState.total = parseFloat(state.valueBuffer || "0");
        }
        default:
      }
      newState.valueBuffer = "";
      newState.display = action.value;
      if (action.value === "=") {
        const total = newState.total;
        Object.assign(newState, initialState);
        newState.total = total;
        newState.display = `${total}`;
      }
      if (action.value === "~") {
        Object.assign(newState, initialState);
      }
      newState.pendingOp = `${action.value}`;
      break;
    }
    case "value": {
      if (state.pendingOp === "=") {
        newState.pendingOp = "~";
      }
      if (!state.valueBuffer) {
        newState.display = action.value;
        newState.valueBuffer = `${action.value}`;
      } else {
        newState.display = `${state.display}` + `${action.value}`;
        newState.valueBuffer += `${action.value}`;
      }
      break;
    }
    default:
      throw new Error("Invalid operation");
  }
  return newState;
};

const App = () => {
  const [state, dispatch] = useReducer(reducer, initialState);
  const onOperator = (value: string) => () => {
    dispatch({ type: "operation", value });
  };
  const onValue = (value: string) => () => {
    dispatch({ type: "value", value });
  };
  const onKeyRelease = (evt: NativeEvent) => {
    const operatorKeys = ["~", "/", "*", "-", "=", "+"];
    const valueKeys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "."];
    const keyEvt = new QKeyEvent(evt);
    const keyText = keyEvt.text();
    if (operatorKeys.includes(keyText)) {
      dispatch({ type: "operation", value: keyText });
    } else if (valueKeys.includes(keyText)) {
      dispatch({ type: "value", value: keyText });
    }
  };

  return (
    <>
      <Window
        on={{
          [QMainWindowEvents.KeyRelease]: onKeyRelease
        }}
        maxSize={{ width: 500, height: 700 }}
        minSize={{ width: 300, height: 400 }}
        styleSheet={styleSheet}
      >
        <View on={{ [QWidgetEvents.KeyRelease]: onKeyRelease }} id="container">
          <View id="row0">
            <Button
              id="opBtn"
              text="AC"
              on={{ [QPushButtonEvents.clicked]: onOperator("~") }}
            />
            <Text id="result">{state.display || "0"}</Text>
          </View>
          <View id="row1">
            <Button
              id="valueBtn"
              text="7"
              on={{ [QPushButtonEvents.clicked]: onValue("7") }}
            />
            <Button
              id="valueBtn"
              text="8"
              on={{ [QPushButtonEvents.clicked]: onValue("8") }}
            />
            <Button
              id="valueBtn"
              text="9"
              on={{ [QPushButtonEvents.clicked]: onValue("9") }}
            />
            <Button
              id="opBtnY"
              text="/"
              on={{ [QPushButtonEvents.clicked]: onOperator("/") }}
            />
          </View>
          <View id="row">
            <Button
              id="valueBtn"
              text="4"
              on={{ [QPushButtonEvents.clicked]: onValue("4") }}
            />
            <Button
              id="valueBtn"
              text="5"
              on={{ [QPushButtonEvents.clicked]: onValue("5") }}
            />
            <Button
              id="valueBtn"
              text="6"
              on={{ [QPushButtonEvents.clicked]: onValue("6") }}
            />
            <Button
              id="opBtnY"
              text="x"
              on={{ [QPushButtonEvents.clicked]: onOperator("*") }}
            />
          </View>
          <View id="row">
            <Button
              id="valueBtn"
              text="1"
              on={{ [QPushButtonEvents.clicked]: onValue("1") }}
            />
            <Button
              id="valueBtn"
              text="2"
              on={{ [QPushButtonEvents.clicked]: onValue("2") }}
            />
            <Button
              id="valueBtn"
              text="3"
              on={{ [QPushButtonEvents.clicked]: onValue("3") }}
            />
            <Button
              id="opBtnY"
              text="-"
              on={{ [QPushButtonEvents.clicked]: onOperator("-") }}
            />
          </View>
          <View id="row">
            <Button
              id="valueBtn"
              text="0"
              on={{ [QPushButtonEvents.clicked]: onValue("0") }}
            />
            <Button
              id="valueBtn"
              text="."
              enabled={!state.valueBuffer.includes(".")}
              on={{ [QPushButtonEvents.clicked]: onValue(".") }}
            />
            <Button
              id="opBtn"
              text="="
              on={{ [QPushButtonEvents.clicked]: onOperator("=") }}
            />
            <Button
              id="opBtnY"
              text="+"
              on={{ [QPushButtonEvents.clicked]: onOperator("+") }}
            />
          </View>
        </View>
      </Window>
    </>
  );
};

const styleSheet = `
  #container {
    flex: 1;
    flex-direction: column;
    min-height: '100%';
  }
  #row, #row0, #row1 {
    flex: 1;
    align-items: stretch;
    justify-content: space-between;
    flex-direction: row;
    background: #4B4B4B;
  }
  #row0 {
    background: #1E1E1E;
  }
  #row1 {
    background: #2E2E2E;
  }
  #valueBtn, #opBtn, #opBtnY {
    min-width: '25%';
    border: 1px solid black;
    font-size: 20px;
    color: white;
  }
  #opBtnY {
    background: #FD8D0E;
  }
  #valueBtn:pressed, #opBtn:pressed, #opBtnY:pressed {
    background: grey;
  }
  #result {
    qproperty-alignment: 'AlignRight|AlignVCenter';
    padding-horizontal: 5px;
    font-size: 40px;
    flex: 1;
    color: white;
  }
`;

Renderer.render(<App />);

Demo小结

Nodegui: 有点类似于前端页面的操作dom的方式开发。该版本实现的 计算器程序,用的是nodegui提供的API,把每一个布局通过其提供的class进行实例化,最后都挂载到rootView上,实现效果展示。

@nodegui/react-nodegui: 将一些组件内置到@nodegui/react-nodegui中,和正常的一个react程序一样,使我们在使用的QT组件过程中像使用一个第三方UI库一样去画页面,逻辑清晰,代码可读性和可扩展性要比直接使用Nodegui更好

项目是怎么启动起来的?

通过 npm run start

 "scripts": {
    "build": "webpack -p",
    "start": "webpack && qode ./dist/index.js",
    "debug": "webpack && qode --inspect ./dist/index.js"
  },

所以,其中关键的是 qode命令,源码中其实是返回了一个二进制的地址

打开之后长这样:

看到这里其实感觉和electron的程序启动差不多,都是通过一个二进制的程序启动业务代码

image.png