这个分享主要是想要让大家知道 electron 对比原生应用,可以做到什么程度,当然水平有限并不是百分百发挥其潜能,水平很有限,能力很一般,我尽力分享,您随意看看。
所见即所得的 markdown笔记应用 Simark
最初开始做独立开发的时候选择的是 swift UI 技术栈,但是使用了 Xcode 开发了两款原生应用还是被其糟糕的开发体验劝退,swift UI 桌面端的不成熟和糟糕的编译速度以及龟速 Xcode 等一系列问题打败了。我还没有吐槽 swiftUI 闭源,文档缺失,不详等一系列问题。在程序员独立开发法则的三个阶段,make it work ,make it right ,make it fast ,光第一个阶段就会把我消耗殆尽。在成品和功能不确定的状态下,应尽快完成原型给自己一个交代,不然耐心失去后就会很容易半途而废。
技术栈上一开始选择了 react.js ,后来我发现会很难维护,框架本身臃肿,太多的隐藏魔法和高度抽象会增加复杂度和不确定性。索性就抛弃任何 UI 框架纯手动操作 Dom ,一开始还很担心会掌控不了,后来发现其优点远远大于担忧,再也没有遇到过无法解释的 UI bug ,掌握每一处 UI 的渲染细节以及流程,能够快速定位遇到的问题,极大的降低了框架本身的高度抽象带来的心智负担。
参考 macOS 的邮件应用的双导航栏加主视图布局风格, 加对称式布局, 这类布局很适合笔记类应用。
UI 字体使用 system-UI ,字体大小为 14px 。字体颜色尽量控制在 3 种颜色以内,主要颜色,次要颜色,和"禁用"颜色。以及加上主题色来点缀,主题色我直接应用系统的设置。
html, body {
font-family: system-ui;
font-size: 14px;
accent-color: ${accentStateHandler.accentState.value};
//获取系统主题色
const accentColor = systemPreferences.getAccentColor()
导航菜单侧边栏透明风格应与原生应用保持一致。
const mainWindow = new BrowserWindow({
vibrancy: 'sidebar',
自适应黑暗模式
class ThemeWatcher {
#isDarkTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
#publishs: ((isDark: boolean) => void)[] = []
constructor() {
this.#onThemeChange()
}
#onThemeChange() {
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', e => {
const { matches: isDarkTheme } = e
this.#isDarkTheme = isDarkTheme
for (const pub of this.#publishs) {
pub(isDarkTheme)
}
})
}
isDarkTheme() {
return this.#isDarkTheme
}
subscript(subCall: (isDark: boolean) => void) {
this.#publishs.push(subCall)
}
unsubscript(subCall: (isDark: boolean) => void) {
this.#publishs = this.#publishs.filter(pub => !Object.is(subCall, pub))
}
}
以及其他一些细节打磨,比如适当的动画过度效果。(因为没有找到合适的 GIF 制作工具,就不加动图了,非常抱歉)。
一个桌面 App 和网页的区别就在于,加载速度和响应速度,在完成界面展示的同时尽可能的使用数据库存储和同步,保障本地优先。本地数据库的选择很多,sqlite ,levelDB ,pouchDB 等,按照自己喜好就好。数据读写全部由主进程,数据展示交由渲染进程。进程间通讯使用 ipcMain/ipcRender 通讯,这个时候 TypeScript 非常方便的地方就来了,将暴露到渲染端的 ipc 通讯的方法注册到 window 对象,方便类型检查和渲染进程调用。
// preload.ts
declare global {
interface Window {
appConfig: typeof appConfigBridge
stool: typeof toolBridge
contextMenu: typeof contextMenu
mainEvent: typeof mainEvent
notebookHandler: typeof notebookHandler
比如在窗口每次的移动和大小改变,都需要记录起来,下次打开的时候需要还原其最后一次的位置和大小。
mainWindow.on('close', async e => {
const windowBounds = mainWindow.getBounds()
await windowConfigHandle.setWindowConfig({
windowX: windowBounds.x,
windowY: windowBounds.y,
windowWidth: windowBounds.width,
windowHeight: windowBounds.height
})
应用的全局快捷键都应该被用户轻易找到,并说明功能。将其注册到系统顶部的 app 工具栏中。比如这个编辑功能菜单栏,还可以根据当前焦点是否处于可编辑状态来启用或禁用来更贴近原生风格。
export const macEditMenu = (): MenuItemConstructorOptions => ({
label: localMenuData.edit,
id: 'EditMenu',
submenu: [
{
id: menuIDs.undo,
label: localMenuData.undo,
click: () => {
if (menuStateManage.inEditorState.value) return
getFocusedWindow()?.webContents.undo()
},
enabled: menuStateManage.editState.value,
accelerator: 'CommandOrControl+Z',
},
{
id: menuIDs.redo,
label: localMenuData.redo,
click: () => {
if (menuStateManage.inEditorState.value) return
getFocusedWindow()?.webContents.redo()
},
enabled: menuStateManage.editState.value,
accelerator: 'CommandOrControl+Shift+Z'
},
{ type: 'separator' },
{
id: menuIDs.cut,
label: localMenuData.cut,
click: () => {
getFocusedWindow()?.webContents.cut()
},
enabled: menuStateManage.editState.value,
accelerator: 'CommandOrControl+X'
},
拥有独立的设置窗口并且保持和其他原生 app 风格一致,拥有独立的风格统一的设置窗口更加贴近原生体验,可以监听窗口的焦点事件,然后根据失去焦点来淡去主题色使其更加原生。
我自己会有个习惯使用Tab
键和Shift + Tab
来切换焦点,保障一定的脱离鼠标的可用性,这样会提升使用体验。
通过自己维护一组焦点列表状态,根据当前焦点来判断上一个和下一个焦点,保障每次的焦点区域都在可控的访问内。
export type FocusArea = 'left'
| 'middle'
| 'content'
| 'title'
| 'leftInput'
| 'searchInput'
| 'findInput'
| 'replaceInput'
| 'sideMenu'
| 'midDrag'
| 'leftDrag'
| 'dialog'
// 其中一个焦点区域的处理逻辑
titleInput.onfocus = () => {
if (this.focusSource.focusState.value !== 'title') {
this.focusSource.focusState.value = 'title'
}
}
const focusChangeHandler = () => {
const focus = this.focusSource.focusState.value
if (focus !== 'title') { return }
if (isCurrentFocusElement(titleInput)) { return }
titleInput.focus()
}
this.focusSource.focusState.subscriptChange(focusChangeHandler)
所有的右键菜单使用原生的 context menu ,统一用户体验。
const menus = Menu.buildFromTemplate(menuTemp)
const content = BrowserWindow.fromWebContents(event.sender)
if (content === null) { return }
menus.popup({ window: content, x: Math.round(x), y: Math.round(y) })
目前的话,还有很多的额地方等待完善,我还不是很满意这个应用的使用感觉,很多地方使用起来比较生涩,主要是还有一些地方的动画还没有做完,以及性能上的优化还没没完成。
仿的原生始终不是原生,最终的理想还是等有足够的时间和收入了,将其彻底迁到 swift 或者 c++,更加轻量的应用体积和节能环保谁不喜欢呢。不过 js 的优化极限我还没有彻底发挥,先一步一个脚印吧。
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.