Screen recorder app - Screen Studio I created is a desktop app that is heavily based on multi-windows architecture:
屏幕录制应用 - 我开发的屏幕工作室是一款深度依赖多窗口架构的桌面应用程序:
屏幕录制应用 - 我开发的屏幕工作室是一款深度依赖多窗口架构的桌面应用程序:
Technically it would be possible to handle it all in one window somehow, but that would make it feel way more like a web app, not a native desktop app.
从技术上来说,确实可以在一个窗口中处理所有内容,但这会让它更像是一个网页应用,而不是一个本地桌面应用。
从技术上来说,确实可以在一个窗口中处理所有内容,但这会让它更像是一个网页应用,而不是一个本地桌面应用。
Those windows have multiple purposes; some are rendered always on top of the other apps, some use macOS background effects, etc.
这些窗口有多种用途;有些窗口始终位于其他应用程序之上,有些则使用 macOS 的背景效果等。
这些窗口有多种用途;有些窗口始终位于其他应用程序之上,有些则使用 macOS 的背景效果等。
The app is created with Electron, so I needed an effective way to manage that.
这个应用是用 Electron 开发的,因此我需要一个有效的方式来进行管理。
这个应用是用 Electron 开发的,因此我需要一个有效的方式来进行管理。
The default way of doing it
常规做法
The common way of dealing with multi-window Electron apps is simply creating multiple windows by calling a
处理多窗口 Electron 应用的常见方法是通过调用新的 BrowserWindow 来创建多个窗口,然后在这些窗口中加载应用的相应部分,并在 Electron 端进行管理。
new BrowserWindow
and then loading proper parts of the app there and managing those windows on the Electron side.处理多窗口 Electron 应用的常见方法是通过调用新的 BrowserWindow 来创建多个窗口,然后在这些窗口中加载应用的相应部分,并在 Electron 端进行管理。
While I believe this is by far the most popular way, I also think it is extremely hard to scale the codebase without exploding the complexity this way.
我认为这确实是最受欢迎的方式,但我也觉得在不增加复杂性的前提下,扩展代码库非常困难。
我认为这确实是最受欢迎的方式,但我也觉得在不增加复杂性的前提下,扩展代码库非常困难。
There are many issues with this approach:
这种方法有很多问题:
这种方法有很多问题:
- There is no easy way to communicate between the windows. You likely will need the IPC channels you use to send data and messages between windows.
窗口之间没有简单的通信方式。您可能需要使用 IPC 通道来传输数据和消息。 - you’ll not be able to send things like callback functions or custom class instances easily
你将无法轻松地发送回调函数或自定义类实例等内容 - you’ll need boilerplate code for every single kind of message you want to send
你需要为每种要发送的消息准备模板代码
- You’ll need to bundle multiple javascript entry points and complicate your build pipeline
你需要将多个 JavaScript 入口点进行打包,这会使你的构建流程变得更加复杂 - You can bypass it by creating only one entry point, which will boot up different things depending on some condition sent to the window. It will work but will make opening each window slower as a lot of JavaScript will have to be loaded
你可以通过创建一个入口点来绕过这个问题,该入口点会根据发送到窗口的某些条件启动不同的内容。虽然这样可以实现,但会导致每个窗口的打开速度变慢,因为需要加载大量的 JavaScript。
- The code will be imperative as you write imperative handlers that open and manage several windows. I create the apps using React, which is declarative, and it is way more natural to write the app code this way. I want to express I need a new window and some content inside of it, the same way I create Modal or Popover, except the content of it is displayed in another window.
代码将是命令式的,因为您编写的命令式处理程序会打开和管理多个窗口。我使用 React 创建应用程序,它是声明式的,以这种方式编写应用程序代码更加自然。我想表达的是,我需要一个新窗口以及其中的一些内容,就像创建 Modal 或 Popover 一样,只是这些内容会显示在另一个窗口中。
Crazy idea, React portals
疯狂的想法,React 传送门
When working at around.co some years ago, some of my co-workers had this idea, and I fell in love with it since then.
几年前我在 around.co 工作时,一些同事提出了这个想法,从那时起我就深深地喜欢上了它。
几年前我在 around.co 工作时,一些同事提出了这个想法,从那时起我就深深地喜欢上了它。
The idea is simple: 这个想法很简单
- In React, you can use Portal to render a given React node into an arbitral DOM node
在 React 中,您可以使用 Portal 将指定的 React 节点渲染到任意 DOM 节点
- I’m pretty sure React authors did mean it mostly to allow devs to render things like modals into the root of the DOM structure.
我相信 React 的作者主要是希望开发者能够将像模态框这样的内容渲染到 DOM 结构的根部。
- But what if you could get a reference to a new window, using
window.open()
, and then render stuff intonewWindow.document.body
?
但是如果你可以使用 window.open() 获取一个新窗口的引用,并在 newWindow.document.body 中渲染内容,那会怎么样呢?
The architecture 建筑学
To implement this concept in Electron, there are several pieces needed
在 Electron 中实现这个概念需要几个组成部分
在 Electron 中实现这个概念需要几个组成部分
React side React 方面
On the React side, we need
在 React 中,我们需要一个 ChildWindow 组件,它将:
ChildWindow
component, which will: 在 React 中,我们需要一个 ChildWindow 组件,它将:
- actually, open a new window with all the options Electron will understand
实际上,打开一个新窗口,所有 Electron 能够理解的选项都会显示出来
- if options are changed, it will send a proper message to Electron to update the window settings
如果选项发生更改,它会向 Electron 发送消息以更新窗口设置
- if the window is closed “not by react” (by user closing it using native close button), we need to call
onClosed
prop so React can stop rendering the window component.
如果窗口是“非 React 方式”关闭的(用户使用原生关闭按钮关闭),我们需要调用 onClosed 属性,以便 React 停止渲染窗口组件。 - There is a convention here - “after
onClosed
is called, you must stop rendering the component. Otherwise, you’ll be rendering into non-existing window”
这里有一个约定 - “在调用 onClosed 后,您必须停止渲染组件。否则,您将会渲染到一个不存在的窗口中”
This is the pseudo-code of it.
这是它的伪代码示例。
这是它的伪代码示例。
For the sake of this article, it is greatly simplified only to show the main idea.
为了本文的目的,这段话被大大简化,以便更好地传达主要思想。
为了本文的目的,这段话被大大简化,以便更好地传达主要思想。
There are some aspects you might notice:
你可能会注意到一些细节:
你可能会注意到一些细节:
- I used React context using
WindowContext
, which will allow me to get the reference to the parent window out of React hierarchy usinguseWindow
corresponding hook.
我使用了 React 上下文中的 WindowContext,这样我就可以通过相应的 useWindow 钩子从 React 层次结构中获取父窗口的引用。
- I created
StyleSheetManager
. In Screen Studio, I usestyled-components
which is a CSS-in-JS library. By default, it adds proper CSS rules to the<head>
element of the window running the code. It would not work in this case, as we render into another window, and CSS is not shared between parent and child windows.
我创建了 StyleSheetManager。在 Screen Studio 中,我使用 styled-components,这是一个 CSS-in-JS 库。默认情况下,它会将适当的 CSS 规则添加到运行代码的窗口的 元素中。但在这种情况下,它无法工作,因为我们渲染到另一个窗口,父窗口和子窗口之间的 CSS 不共享。
Electron side 电子端
On the Electron side, we need to do 3 things:
在 Electron 方面,我们需要完成三项工作:
在 Electron 方面,我们需要完成三项工作:
- Handle
window.open
requests and parse the options and pass them to theBrowserWindows
options object so it has the correct settings instantly. We do that usingsetWindowOpenHandler
处理 window.open 请求,解析选项并将其传递给 BrowserWindows 的选项对象,以便它能够立即获得正确的设置。我们通过使用 setWindowOpenHandler 来实现这一点。
- Then we need to start listening for
updateWindowOptions
messages and apply new settings to the window.
然后我们需要开始监听 updateWindowOptions 消息,并将新的设置应用到窗口中。
- Then we need to listen to window close requests and send them to React, so it calls
onClosed
prop.
然后我们需要监听窗口关闭请求,并将其传递给 React,以便它调用 onClosed 属性。
This code is also greatly simplified, as it skips many aspects such as cleaning up, setting up IPC channels, etc which is not the subject of this article.
这段代码经过大幅简化,省略了许多内容,比如清理和设置 IPC 通道等,这些并不是本文讨论的重点。
这段代码经过大幅简化,省略了许多内容,比如清理和设置 IPC 通道等,这些并不是本文讨论的重点。
Example usage 示例用法说明
Here is an example of the screen.studio recording picker, which allows user to pick window, desktop, microphone and camera. For optimal experience - it is always on top and has default macOS background panel effect.
这是一个屏幕录制选择器的示例,用户可以选择窗口、桌面、麦克风和摄像头。为了获得最佳体验,该选择器始终保持在最上层,并具有默认的 macOS 背景面板效果。
这是一个屏幕录制选择器的示例,用户可以选择窗口、桌面、麦克风和摄像头。为了获得最佳体验,该选择器始终保持在最上层,并具有默认的 macOS 背景面板效果。
It uses macOS background effect, is always on top, has no frame, has a shadow, etc:
它使用 macOS 背景效果,始终保持在最上层,没有边框,并且有阴影等效果
它使用 macOS 背景效果,始终保持在最上层,没有边框,并且有阴影等效果
Advantages 优点
There are a lot of advantages to using this architecture:
采用这种架构有许多优势:
采用这种架构有许多优势:
Simplicity 简约性
You can simply copy-paste one of
你可以简单地复制粘贴一个 组件,这样就可以得到两个完全独立的窗口。你可以更改其中一个的属性,它们看起来会完全不同。如果操作得当,它将支持热重载,这样你就可以立即看到更改,而无需重新启动应用程序(注意:某些 BrowserWindow 选项在窗口创建后是无法更改的)。
<ChildWindow />
components, and boom, you have 2 fully independent windows. You can change the props of one of them, and they now look totally different. If done correctly, it will work with hot-reloading, so you instantly see changes without having to restart the app (note: some BrowserWindow
options are not changeable after the window was created)你可以简单地复制粘贴一个 组件,这样就可以得到两个完全独立的窗口。你可以更改其中一个的属性,它们看起来会完全不同。如果操作得当,它将支持热重载,这样你就可以立即看到更改,而无需重新启动应用程序(注意:某些 BrowserWindow 选项在窗口创建后是无法更改的)。
Communication 沟通
You can pass any prop, callback function, custom object, etc., into the window content react components. Under the hood, it works exactly like any other React code; it’s just you happen to render some of your React trees into another window
您可以将任何属性、回调函数、自定义对象等传递给窗口内容的 React 组件。在底层,它的工作原理与其他 React 代码完全相同;只是您将一些 React 组件渲染到了另一个窗口中。
您可以将任何属性、回调函数、自定义对象等传递给窗口内容的 React 组件。在底层,它的工作原理与其他 React 代码完全相同;只是您将一些 React 组件渲染到了另一个窗口中。
Declarative 声明性
You can escape the imperative nature of creating windows on the Electron side and enjoy the declarative nature of working with React. It means you express what you want, aka. “I now need a window with those props,” and you don’t worry about “what has to happen for this window with those props to be there”
你可以摆脱在 Electron 端创建窗口的命令式方式,享受与 React 一起工作的声明式方式。这意味着你可以直接表达你的需求,比如“我现在需要一个带有这些属性的窗口”,而不必担心“为了让这个窗口存在,需要发生什么”。
你可以摆脱在 Electron 端创建窗口的命令式方式,享受与 React 一起工作的声明式方式。这意味着你可以直接表达你的需求,比如“我现在需要一个带有这些属性的窗口”,而不必担心“为了让这个窗口存在,需要发生什么”。
Caveats and quirks 注意事项和特殊情况
There are MANY caveats and quirks related to this architecture. Those are sometimes very annoying, but I believe they are totally worth it.
这个架构有许多注意事项和独特之处,虽然有时会让人感到烦恼,但我相信这些都是非常值得的。
这个架构有许多注意事项和独特之处,虽然有时会让人感到烦恼,但我相信这些都是非常值得的。
Dev inspector 开发者工具
As we have multiple windows, we also have multiple Chromium dev inspectors, which we can open. All of those are relevant, but the contents of each are quite unintuitive for a day-to-day web developer:
由于我们有多个窗口,因此也有多个 Chromium 开发者工具可以打开。虽然这些工具都很重要,但它们的内容对于日常网页开发者来说相当不直观。
由于我们有多个窗口,因此也有多个 Chromium 开发者工具可以打开。虽然这些工具都很重要,但它们的内容对于日常网页开发者来说相当不直观。
The parent window inspector shows all the console logs as this is where JavaScript actually runs, but shows no HTML elements in DOM inspector:
父窗口检查器显示所有控制台日志,因为这是 JavaScript 实际运行的地方,但在 DOM 检查器中不显示任何 HTML 元素
父窗口检查器显示所有控制台日志,因为这是 JavaScript 实际运行的地方,但在 DOM 检查器中不显示任何 HTML 元素
This is the DOM inspector of the main window. In our case, the main window is actually an invisible window and everything that is visible, is rendered into child windows. As a result, the DOM inspector of the main window is quite empty.
这是主窗口的 DOM 检查器。在我们的案例中,主窗口实际上是一个不可见的窗口,所有可见的内容都渲染到子窗口中。因此,主窗口的 DOM 检查器几乎是空的。
这是主窗口的 DOM 检查器。在我们的案例中,主窗口实际上是一个不可见的窗口,所有可见的内容都渲染到子窗口中。因此,主窗口的 DOM 检查器几乎是空的。
But the console tab of the main window is full of logs related to those child windows.
但是主窗口的控制台标签页上充满了与这些子窗口相关的日志。
但是主窗口的控制台标签页上充满了与这些子窗口相关的日志。
It is a bit tricky if I render the actual DOM node into the console, eg
如果我将实际的 DOM 节点渲染到控制台,比如 console.log(elementRef.current),这会有点棘手。你可以看到它的预览,但无法点击它在 DOM 检查器中进行检查,因为检查器会在你正在查看的窗口中查找这个节点,而它并不存在。
console.log(elementRef.current)
. You will see its preview, but you’ll not be able to click it to inspect it in the DOM inspector, as the inspector will look for this node in the window you’re inspecting, and it is not there.如果我将实际的 DOM 节点渲染到控制台,比如 console.log(elementRef.current),这会有点棘手。你可以看到它的预览,但无法点击它在 DOM 检查器中进行检查,因为检查器会在你正在查看的窗口中查找这个节点,而它并不存在。
On the other hand, the inspector of the child window will show all the DOM nodes but no console logs.
另一方面,子窗口的检查器会显示所有 DOM 节点,但不会有控制台日志。
另一方面,子窗口的检查器会显示所有 DOM 节点,但不会有控制台日志。
Managing events 事件管理
Another very unintuitive thing. We, web devs are very used to doing things like
另一个非常不直观的事情。我们网页开发者习惯使用像 window.addEventListener 这样的操作,这本应正常工作。然而,在多窗口应用中,它却无法正常运作。以下是一些例子:
window.addEventListener
, which should just work. Except, it will not be in the case of multi-window apps. Some examples:另一个非常不直观的事情。我们网页开发者习惯使用像 window.addEventListener 这样的操作,这本应正常工作。然而,在多窗口应用中,它却无法正常运作。以下是一些例子:
- You might have global keyboard shortcuts handler attached to keyboard events. Now you need to make sure every window you have is listening to them, and the best would be to have only currently focused window to listen to them
你可能有一个全局键盘快捷键处理程序与键盘事件关联。现在你需要确保每个窗口都在监听这些快捷键,最好是只有当前聚焦的窗口在监听它们。
- You might be using external libraries that are “naively” attaching events to windows such as
window.addEventListener("resize")
. It is, of course, understandable for library devs to do something like this, but it would simply not work in our case. The code above would be listening for resizing events of the wrong window, as it is not the one given thing rendered into. This is especially painful with DOM-heavy libraries such as rich text editors (I was not able to make most of them work at all)
你可能正在使用一些外部库,这些库“天真地”将事件附加到窗口,例如 window.addEventListener("resize")。虽然库的开发者这样做是可以理解的,但在我们的情况下,这根本无法奏效。上述代码会监听错误窗口的调整大小事件,因为它并不是渲染内容的那个窗口。这在使用像富文本编辑器这样的 DOM 密集型库时尤其麻烦(我无法让大多数库正常工作)。 - Tip: a good practice is to use
domNode.ownerDocument.defaultView
to get thewindow
to which a given DOM node belongs.
提示:一个好的做法是使用 domNode.ownerDocument.defaultView 来获取与特定 DOM 节点相关联的窗口。
Managing CSS CSS 管理
CSS is not shared between Windows. It is also quite unintuitive for web devs to work in this architecture. We’re used to CSS being ‘singleton’ - you just add it, and it’s there. It’s not the case here. You need to make sure all relevant CSS is included in every window.
CSS 在 Windows 之间是无法共享的。这种架构对网页开发者来说也相当不直观。我们习惯于 CSS 是“单例”的——只需添加它,它就会存在。但在这里并不是这样。你需要确保每个窗口中都包含所有相关的 CSS。
CSS 在 Windows 之间是无法共享的。这种架构对网页开发者来说也相当不直观。我们习惯于 CSS 是“单例”的——只需添加它,它就会存在。但在这里并不是这样。你需要确保每个窗口中都包含所有相关的 CSS。
If you use regular CSS files, you’ll probably just need to add those to the
如果你使用常规的 CSS 文件,可能只需将它们添加到每个窗口的头部即可。(我不确定热重载是否能正常工作)
head
of every window. (I’m not sure if hot reloading will work just fine with it)如果你使用常规的 CSS 文件,可能只需将它们添加到每个窗口的头部即可。(我不确定热重载是否能正常工作)
If you use CSS-in-JS libraries, you depend on whether or not the authors of the library allow you to set the scope of collecting the styles and setting the target where
如果你使用 CSS-in-JS 库,你的使用依赖于库的作者是否允许你设置样式的收集范围以及指定 <style> 规则添加的目标。</style>
<style>
rules will be added.如果你使用 CSS-in-JS 库,你的使用依赖于库的作者是否允许你设置样式的收集范围以及指定 <style> 规则添加的目标。</style>
In screen.studio, I use amazing
在 screen.studio 中,我使用了非常出色的样式组件,它们配备了 StyleSheetManager,使我能够轻松实现这一点。
styled-components
, which have StyleSheetManager
allowing me to do exactly that.在 screen.studio 中,我使用了非常出色的样式组件,它们配备了 StyleSheetManager,使我能够轻松实现这一点。
Animations 动画作品
It is very common to use
使用 requestAnimationFrame(我应该说是 window.requestAnimationFrame)来有效地进行动画是非常普遍的。传递给请求动画帧(RAFs)的回调在一个非常合适的时刻被精确地定时,这个时刻非常适合在特定窗口中进行绘制等操作。
requestAnimationFrame
(or, I should say window.requestAnimationFrame
) to animate in an effective way. The callback passed to RAFs (request animation frame) is precisely timed in a moment which is perfect for a given window to do the painting and similar things. 使用 requestAnimationFrame(我应该说是 window.requestAnimationFrame)来有效地进行动画是非常普遍的。传递给请求动画帧(RAFs)的回调在一个非常合适的时刻被精确地定时,这个时刻非常适合在特定窗口中进行绘制等操作。
Except this moment is different for every window, and 99% of libraries obviously use
除了这个时刻对每个窗口来说都是不同的,99%的库显然使用的是 window.requestAnimationFrame,而不是 animatedNode.ownerDocument.defaultView.requestAnimationFrame,这完全削弱了它的所有优势。
window.requestAnimationFrame
, not animatedNode.ownerDocument.defaultView.requestAnimationFrame
, which totally ruins all its benefits.除了这个时刻对每个窗口来说都是不同的,99%的库显然使用的是 window.requestAnimationFrame,而不是 animatedNode.ownerDocument.defaultView.requestAnimationFrame,这完全削弱了它的所有优势。
It had a terrible impact on the performance of our WebGL rendering engine, which is using ‘ticker’, which ticks every frame and is the callback where you actually render things.
这对我们使用“ticker”的 WebGL 渲染引擎的性能造成了严重影响,“ticker”每帧都会触发,是实际渲染内容的回调函数。
这对我们使用“ticker”的 WebGL 渲染引擎的性能造成了严重影响,“ticker”每帧都会触发,是实际渲染内容的回调函数。
After many attempts, I ended up monkey-patching this function so it is using
经过多次尝试,我最终对这个函数进行了猴子补丁,使其使用当前焦点窗口的 requestAnimationFrame:
requestAnimationFrame
of the currently focused window:经过多次尝试,我最终对这个函数进行了猴子补丁,使其使用当前焦点窗口的 requestAnimationFrame:
We’re monkey-patching the original RAF, trying to get the currently active window and using RAF from it instead.
我们正在对原始的 RAF 进行修改,试图获取当前活动窗口并从中使用 RAF。
我们正在对原始的 RAF 进行修改,试图获取当前活动窗口并从中使用 RAF。
Other things 其他事项
There are many other things, and I think I don’t even remember all of those now:
还有很多其他的事情,我想我现在甚至都不记得这些了
还有很多其他的事情,我想我现在甚至都不记得这些了
- Preventing the window from being closed (e.g. “You have unsaved changes”) and keeping this in sync with React tree
防止窗口关闭(例如:“您有未保存的更改”),并确保与 React 树保持同步
- Avoiding empty window flicker on both opening it and closing it, e.g. React elements (and DOM nodes) are unmounted, but the window is not closed yet.
避免在打开和关闭窗口时出现空白闪烁,例如,React 元素(和 DOM 节点)已被卸载,但窗口仍未关闭。
Conclusion 结论内容
When creating larger apps, I believe you should be very careful about keeping the code complexity horizontally scalable.
在开发大型应用时,我认为您需要非常注意保持代码的水平可扩展性。
在开发大型应用时,我认为您需要非常注意保持代码的水平可扩展性。
If you don’t do that, you might semi-unconsciously avoid given patterns, knowing how complex it will be to implement them. E.g., you’ll create a “Settings view” in the same window as the app main window, even though it is not the default UX pattern e.g., on macOS where Settings are almost always in the new window.
如果你不这样做,你可能会在无意识中避免某些模式,因为你知道实现它们会非常复杂。例如,你可能会在应用程序主窗口中创建一个“设置视图”,尽管在 macOS 上,设置通常是在新窗口中打开的,这并不是默认的用户体验模式。
如果你不这样做,你可能会在无意识中避免某些模式,因为你知道实现它们会非常复杂。例如,你可能会在应用程序主窗口中创建一个“设置视图”,尽管在 macOS 上,设置通常是在新窗口中打开的,这并不是默认的用户体验模式。
Creating this,
创建 ChildWindow 架构本身就是一项复杂的任务,实施起来非常困难。此外,还有许多日常的细节问题,我无法使用一些第三方库。
ChildWindow
architecture was a complex task in itself. Implementing it was quite painful. There are also many day-to-day quirks involved, and I’m not able to use some 3rd party libraries.创建 ChildWindow 架构本身就是一项复杂的任务,实施起来非常困难。此外,还有许多日常的细节问题,我无法使用一些第三方库。
Even with all of that, I believe it unlocked many possibilities and is keeping developing screen.studio as simple as it was when it had 3 times fewer windows to manage.
尽管如此,我相信这为我们打开了许多可能性,并使 screen.studio 的开发保持简单,就像它在窗口数量减少三分之一时那样。
尽管如此,我相信这为我们打开了许多可能性,并使 screen.studio 的开发保持简单,就像它在窗口数量减少三分之一时那样。
I was thinking several times about open-sourcing the architecture I’m using, but it is currently tightly coupled with the app and as a solo dev of screen.studio, I constantly keep prioritizing other things.
我曾多次考虑开源我正在使用的架构,但由于它与应用程序紧密耦合,作为 screen.studio 的独立开发者,我总是优先处理其他事务。
我曾多次考虑开源我正在使用的架构,但由于它与应用程序紧密耦合,作为 screen.studio 的独立开发者,我总是优先处理其他事务。
I hope you enjoyed. If you want to follow my #buildinpublic journey, follow me on Twitter.
我希望你喜欢。如果你想跟随我的#buildinpublic 旅程,请在 Twitter 上关注我。
我希望你喜欢。如果你想跟随我的#buildinpublic 旅程,请在 Twitter 上关注我。