从零实现桌面窗口管理(前端部分)
我正在开发 Ray Memex,这是一个宏大的项目,愿景是管理一切个人数据,包括视频、电子书、网页归档、语料、音乐。
Ray Memex 的前端展示部分,我想开发成操作系统桌面一样,也有窗口、开始菜单、最大最小化。
通过一番研究,成功实现。在本文中,我将以桌面窗口管理为主题,为大家介绍如何从零实现一个桌面窗口管理器。
标题所说的“前端部分”,指的是所有的窗口都是前端组件,不包含与实际桌面系统(如 X11)的合成。不过,实现后者也是有例可循的,参见 wnayes/bond-wm: An X Window Manager built on web technologies.。
本文使用的技术栈如下:React、Redux(Toolkit)、TypeScript,React 95,整个项目是一个 Electron 应用,使用 Electron Forge 创建。
运行截图如下,借助于 React 95 组件库,实现了复古风:
本文中窗口管理的代码,学自 geo-tp/React-Desktop: Operating system desktop style in a react app 项目,该项目带有一个 Web Demo,可以在线体验。
窗口的数据结构:WindowType
首先,让我们定义窗口的数据结构 WindowType:
export type WindowType = {
id: number;
appType: AppTypeEnum;
hidden: boolean;
width: string;
height: string;
top: number;
left: number;
zIndex: number;
viewState: WindowViewStateEnum;
params: any;
}
其中:
- 每个窗口都有一个唯一 id 进行标识。
- appType 标识窗口所对应的应用类型,是一个枚举
- width、height、top、left、hidden 标识窗口的宽高位置,以及是否隐藏
- zIndex 控制窗口的层次,用于彼此之间遮挡
- viewState 标识窗口所处状态,比如是否处于最大化
- params 是窗口创建时传入的参数,将传入实际的应用组件
AppTypeEnum 声明了 Ray Memex 内的应用类型,目前还很少:
export enum AppTypeEnum {
WebBrowser,
ImageViewer
}
前面说道,Ray Memex 愿景是管理一切个人数据,包括视频、电子书、网页归档、语料、音乐。因此未来,AppTypeEnum 将会包括浏览器、视频播放器、电子书阅读器……
WindowViewStateEnum:
export enum WindowViewStateEnum {
Fullscreen,
Custom
}
Redux Store
定义好数据结构部分,接下来开始写 Redux 部分。
这部分的完整代码,既可以参见原项目 geo-tp/React-Desktop 项目,也可以参见 Ray Memex。下面给出关键实现步骤。
Store 部分固定写法:
import { configureStore } from '@reduxjs/toolkit';
import { windowReducer } from './slices/windows/reducer';
const store = configureStore({
reducer: {
windows: windowReducer,
},
});
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
export default store;
关键在于 windowReducer,也就是窗口的状态管理部分。
Windows Slice
在 Redux 中,状态由多个子模块组成,windows 包即管理窗口状态的模块。在 slices/windows
下,包含以下内容:
actions.ts
:定义所有窗口操作constants.ts
:定义常量reducer.ts
:Reducer 是 Redux 的关键,newState = reducer(state, action)
,这是一个纯粹的函数式过程,也是 Redux 能够实现时光机功能的关键selectors.ts
:如何从总状态中选择出该模块的状态state.ts
:初始状态
Windows 状态
先看 state.ts
,定义了 Windows 模块的初始状态:
export const windowsInitalState = {
elements: [{
id: 1,
appType: AppTypeEnum.WebBrowser,
hidden: false,
width: "60%",
height: "300px",
top: 100,
left: 100,
zIndex: 1,
viewState: WindowViewStateEnum.Custom,
params: {}
},] as WindowType[],
}
可见,就是 WindowType
的列表。同时,还包含一个初始状态,即一个初始窗口。
==未来,桌面上的窗口,在背后都是由这个状态所映射的。==我很喜欢 React、Redux 的这种思想。
窗口操作 Actions
在 action
下定义了所支持的操作,这里以最常用的创建窗口和销毁窗口为例:
export const deleteWindow = (windowId: number) => ({
type: DELETE_WINDOW,
payload: { windowId }
});
export const addWindow = (params: WindowCreateParams) => ({
type: ADD_WINDOW,
payload: { params }
});
其中,创建窗口的入参要复杂一点:
export type WindowCreateParams = {
name: string;
app: AppTypeEnum;
params?: any;
};
包含窗口 id、名称、要打开的应用类型,以及启动参数。
需要注意,窗口仅仅是一层包装,窗口内部所要展示的内容,是由 AppTypeEnum 所控制的。在 Redux 中不会根据 AppTypeEnum 创建出具体的应用组件,而是等到在 UI 层,React 组件实际创建时,才会根据 AppTypeEnum 真正创建出对应的内容。
另外,params
的类型为 any,并且可为空,这是因为不同的内容组件所需要的参数是不同的。在后续实际创建内容组件的时候,需要对这个 any 类型的 params
加一层类型验证,以及 cast。
窗口 Reducer 实现
本节中介绍,在 Reducer 中,如何对上一节中的两个操作进行处理。
在状态层面,窗口的添加与删除,仅仅是操作 WindowType[]
列表中的数据结构。我非常喜欢这一点。
创建窗口:
case ADD_WINDOW:
const id = getWindowMaxId(state.elements) + 1;
const zIndex = getMaxZIndex(state.elements) + 1;
const appType = action.payload.params.type;
const newWindow: WindowType = {
id,
appType,
hidden: false,
width: "100%",
height: "100vh",
top: 0,
left: 0,
zIndex,
viewState: WindowViewStateEnum.Fullscreen,
params: action.payload.params
};
return {
...state,
elements: [...state.elements, newWindow]
};
其中 getWindowMaxId、getMaxZIndex 是从已有从已有的窗口列表中,获取最大的,然后新窗口比最大的再大一个。
删除窗口:
case DELETE_WINDOW:
elements = [];
for (let element of state.elements) {
if (element.id !== action.payload.windowId) {
elements.push(element);
}
}
return {
...state,
elements: [...elements]
};
删除操作也是一样,从窗口列表(state.elements
)中,把要被删除的那个窗口过滤掉,组成新列表即可。
前端展示:Windows 组件
状态层完成后,接下来可以写前端展示部分了。
首先,我们需要一个组件,将 WindowType[]
状态映射为 React 组件,在 Ray Memex 中该组件叫做 Windows:
export const Windows = () => {
const windows = useSelector(selectWindows);
return <WindowStyle>
{
windows?.elements?.length && windows.elements.map((window) =>
<WindowFrame key={window.id} window={window}><span>React95</span></WindowFrame>
)
}
</WindowStyle>
}
整体上也非常直观,首先使用 useSelector
拿出我们所需的状态。然后一个 map 操作,映射到 WindowFrame 组件上面。
上面代码中 WindowFrame 的自组件是一段“WindowFrame”文字,这里实际上应当是根据 appType 换出对应的内容组件。这部分代码还在编写当中。
不同从中,能够看出,WindowFrame 与内容组件之间的分工解耦。WindowFrame 并不关心内容组件,它只是一层包装。不管是 WindowFrame 还是内容组件,他们背后都是由 Redux 的 WindowType[]
的一个 WindowType
所映射。
前端展示:WindowFrame 组件
WindowFrame 组件负责对具体的窗口进行绘制,这里我使用了 React95 组件库。React95 组件库非常有意思,它提供了大量 Windows 95 风格的组件,你别说,还真有那个味道!我很喜欢。
WindowFrame 的代码实现如下,其中主要是对 React95 组件的组装,以及一些自定义 Style 的微调。
其中,width、height、top、left 这些,都是从 Redux 状态中映射而来的。这是一种响应式设计模式,非常好。
export const WindowFrame = (props: {
window: WindowType;
children: ReactElement
}) => {
return <Window resizable className="window" style={{
flexDirection: 'column',
width: props.window.width,
height: props.window.height,
top: props.window.top,
left: props.window.left,
zIndex: props.window.zIndex,
display: props.window.hidden ? "none" : "flex"
}}>
<WindowHeader>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
height: '100%',
padding: '0',
boxSizing: 'border-box',
}}>
<span>Ray Memex</span>
<Button style={{
display: 'flex',
alignItems: 'center',
width: '20px',
height: '20px',
marginLeft: '-1px',
marginTop: '-1px',
}}>
<IconClose />
</Button>
</div>
</WindowHeader>
<Toolbar></Toolbar>
<WindowContent style={{
flex: 1,
}}>
{/* {props.children} */}
<div>React95</div>
</WindowContent>
<Frame variant='well' className='footer'>
Put some useful information here
</Frame>
</Window>
}
其中,还有一个 React95
字样,这也是 Mock 代码,实际上应当换成 {props.children}
,把 Windows 组件中制定的内容,透传进来。
功能迭代:窗口拖动
注意,上面这段代码中,窗口的位置是固定的,不支持拖动。在本节中,以窗口拖动需求为例,介绍我们如何对这套系统进行迭代。
首先需要依赖 react-draggable - npm 库。
修改 WindowFrame 为如下实现:
handleResize
函数:当窗口大小改变时,这个函数会被调用。它首先检查当前窗口的状态是否为全屏,如果是,那么它会将窗口状态更新为自定义状态。这个函数被传递给useResizeObserver
,这是一个自定义的 React Hook,它使用ResizeObserver API来监听元素的大小改变。
handleDrag
函数:当用户拖动窗口时,这个函数会被调用。它首先检查当前窗口的状态是否为全屏,如果是,那么它会更新窗口的宽度和高度为50,并将窗口状态更新为自定义状态。然后,它会获取窗口的当前位置,并更新窗口的左和顶部位置。最后,它会更新窗口的translate样式,使其移动到鼠标的位置。
Draggable
组件:它使得其子组件可以被用户拖动。它接受一个onDrag
属性,当用户拖动元素时,这个函数会被调用。在这个例子中,onDrag
属性被设置为handleDrag
函数。
export const WindowFrame = (props: {
window: WindowType;
children: ReactElement
}) => {
const dispatch = useDispatch();
const handleResize = useCallback((target: HTMLDivElement) => {
// dispatch(setIsDragDisable(true));
if (props.window.viewState === WindowViewStateEnum.Fullscreen) {
dispatch(updateViewState(props.window.id, WindowViewStateEnum.Custom));
}
}, []);
const frameRef = useResizeObserver(handleResize);
const handleDrag = (e: any) => {
if (props.window.viewState === WindowViewStateEnum.Fullscreen) {
dispatch(updateWidth(props.window.id, 50));
dispatch(updateHeight(props.window.id, 50));
dispatch(updateViewState(props.window.id, WindowViewStateEnum.Custom));
}
if (frameRef?.current) {
const pos = frameRef.current.getBoundingClientRect();
dispatch(updateLeft(props.window.id, pos.x));
dispatch(updateTop(props.window.id, pos.y));
frameRef.current.style.translate = `translate(${e.target.clientX}, ${e.target.clientY})`;
}
};
return <Draggable
defaultPosition={{ x: props.window.left, y: props.window.top }}
onDrag={(e) => handleDrag(e)}
// disabled={os.isDragDisable}
bounds="parent"
>
<Window className="window" ref={frameRef} style={{
flexDirection: 'column',
width: props.window.width,
height: props.window.height,
zIndex: props.window.zIndex,
display: props.window.hidden ? "none" : "flex",
resize: "both",
position: "absolute",
overflow: 'hidden',
}}>
// ......
</Window>
</Draggable>
}
这段代码的核心在于 handleDrag 回调方法,有几个要点需要注意:
第一个注意点:frameRef
加载 Window 组件上,而 Window 组件被 Draggable 拖拽组件带着一起走。这样,在触发拖动时,调用 handleDrag,handleDrag 从 frameRef
取出的位置,是拖动后的位置,将其保存到 Redux 中
第二个注意点:我们引入了一个 React Hook,名为 useResizeObserver
,这个 ref 同时也监听 Window 组件的缩放,并在大小变化时调用 handleResize。
useResizeObserver
其中使用到一个自定义的React Hook,名为useResizeObserver
,它使用了ResizeObserver API来监听一个HTML元素的大小变化。
具体代码如下:
import { useLayoutEffect, useRef } from "react";
function useResizeObserver<T extends HTMLElement>(
callback: (target: T, entry: ResizeObserverEntry) => void
) {
const ref = useRef<T>(null);
useLayoutEffect(() => {
const element = ref?.current;
if (!element) {
return;
}
const observer = new ResizeObserver((entries) => {
callback(element, entries[0]);
});
observer.observe(element);
return () => {
observer.disconnect();
};
}, [callback, ref]);
return ref;
}
export default useResizeObserver;
其中:
-
useResizeObserver
接受一个回调函数callback
作为参数,这个回调函数会在元素大小变化时被调用。这个回调函数接受两个参数:目标元素target
和ResizeObserverEntry对象entry
。 -
在
useResizeObserver
内部,首先使用useRef
创建了一个引用ref
,这个引用将被赋值给需要监听大小变化的元素。 -
使用
useLayoutEffect
来创建一个副作用。在这个副作用中,首先获取ref
的当前值element
,如果element
存在,那么创建一个新的ResizeObserver实例,并开始观察element
。 -
ResizeObserver的回调函数接受一个entries参数,这是一个包含所有被观察元素的ResizeObserverEntry对象的数组。在这个例子中,我们只关心第一个元素,所以直接使用
entries[0]
。 -
当
element
的大小变化时,ResizeObserver的回调函数会被调用,然后调用传入useResizeObserver
的callback
函数,并传入element
和entries[0]
。 -
最后,
useLayoutEffect
的回调函数返回一个清理函数,这个函数会在组件卸载或callback
、ref
变化时被调用。在这个清理函数中,调用observer.disconnect()
来停止观察所有元素。 -
useResizeObserver
返回ref
,这样用户可以将这个ref
赋值给需要监听大小变化的元素。
本文作者:Maeiee
本文链接:从零实现桌面窗口管理(前端部分)
版权声明:如无特别声明,本文即为原创文章,版权归 Maeiee 所有,未经允许不得转载!
喜欢我文章的朋友请随缘打赏,鼓励我创作更多更好的作品!