Electron 踩坑

来鹅厂做了俩常规项目(更新文件上传方式至 COS 及修改移动端顶部 UI)后参与了文档 App 的桌面 Electron App 开发,在 UI 层上又是一个坑接一个坑。主要有下述几点:

  • 实现适应于内置 React 组件大小的 BrowserWindow,同时保证首次加载时页面不会错乱
  • 使自定义弹窗可拖拽并且内部表单组件可使用
  • 在 BrowserWindow 内弹出超出 Window 范围的菜单
  • 跨端编译打包后发现 runtime 对不上

使 BrowserWindow 自适应 React 组件

我们知道 Electron 的 BrowserWindow 的默认尺寸为 800 × 600,虽然可以初始化设置弹窗大小,但是并不知道弹窗内容尺寸呀!于是乎,需要先隐藏弹窗直至 compsnentDidMount 时获取到 dialog 内容尺寸再设置 BrowserWindow 的大小并显示。

1
2
3
4
5
6
7
8
9
10
11
12
// main-process 打开弹窗
import { ipcMain } from 'electron'

export function openDialog() {
const dialog = new BrowserWindow({
show: false
})
ipcMain.once('dialog-did-mount', () => {
dialog.show()
})
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// renderer 页面在组件加载后设置宽高并返回消息显示 window
import React, { useEffect, useRef } from 'react'
import { ipcRenderer, remote } from 'electron'

export default function Dialog() {
const dialogRef = useRef()
useEffect(() => {
const window = remote.getCurrentWindow()
const { clientWidth, clientHeight } = dialogRef.current
window.setSize(clientWidth, clientHeight, true/* animate */)
window.center()
ipcRenderer.send('dialog-did-mount')
})
return <div ref={dialogRef}>
contents...
</div>
}

拖拽窗口

官方解答
遇到的坑是设置好 dialog 内部 button/input 的 no-drag 后发现 dui(某鹅厂组件库)会直接在 body 下生成 DOM 节点,哪怕设置上了 dui-* 这样的通配符都没用,在 Windows 上点击事件还是穿透了组件,只好给整个内容的区域都打上了 -webkit-app-region: no-drag。

弹出超出 Window 的菜单

官方做法
设计觉得 Windows 下不好看,于是要自定义 BrowserWindow。

1
2
3
4
5
6
7
8
9
10
11
// main-process 打开弹窗
import { BrowserWindow } from 'electron'

function openMenu(x, y) {
const menu = new BrowserWindow({ x, y })
menu.loadUrl(`file://${__dirname}/menu.html`)
menu.on('did-finish-load', () => {
menu.show()
})
}
ipcMain.on('open-menu', (event, x, y) => openMenu(x, y))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// renderer 渲染进程捕获触发元素
import { remote } from 'eletron'
import React, { useRef, useEffect } from 'react'

export default function App() {
const btnRef = useRef()
useEffect(() => {
const window = remote.getCurrentWindow()
const { x: windowX, y: windowY } = window.getBounds()
const { x: elX, y: elY, height } = btnRef.current.getBoundingClientRect()
const x = windowX + elX
const y = windowY + elY + height
ipcRenderer.send('open-menu', x | 0, y | 0)
})
return <div>
content...
<button ref={btnRef}>点我出菜单</button>
</div>
}

其中

1
ipcRenderer.send('open-menu', x | 0, y | 0)

非常重要 😂 因为 Electron 打开 menu 的 x & y 只认整型,而 getBoundingClientRect() 返回了浮点数,直接用就崩了……

区分「开发时」「编译时」和「运行时」

跨端开发的优势就是 Write Once, Run Everywhere。代码能贴近运行时判断就贴近运行时判断,不过为了开发和打包大小,有如下几个优化思路。

  • 跨端开发 UI 需要调试查看其他端上的状态,所以会需要一个全局的样式开关,目前只区分了 macOS 和 Windows,写作
    1
    2
    // constants/setting.ts
    const useMacStyle = process.platform === 'darwin'
    开发时只需要按需加 ! 取反就可以方便切换样式了,process.platform 是啥?这就是编译时了。
  • 编译时需要确定目标对象,一般会写成不同脚本或者是一个脚本里根据 platform 分发并写入进程参数,为了锁死各种依赖关系,假设某处写了 process.platform === 'darwin 如果 platform 不符合就会直接剪枝掉后面的部分。
  • 而运行时就广泛得多,比如关闭所有窗口时默认退出 App。
    1
    2
    3
    4
    5
    6
    7
    import os from 'os'
    import { app } from 'electron'
    app.on('window-all-closed', () => {
    if (os.platform() !== 'darwin') {
    app.quit()
    }
    })
    再比如根据系统类型开启不同的提示文本,这些都需要运行时判断,虽然也可以直接编译时判断,但终究不够灵活。

__结论__:类似于配置下发的流程,如果是偏开发侧的内容可以在一处统一管理,如果是偏向本地系统的功能可以根据实际运行环境开闭,做到尽量少依赖于编译时以求在多端最大化复用代码逻辑。


To be continued……

格式化与验证

众所周知,前端很大程度上就是做数据的展示和收集工作,这时候用户看到的内容和服务端存储的数据中间就会有多层转换过程。

钱

最重要的当然是 money 啦,相差一分钱都能让用户急得哇哇大叫。比如业务里有三种价格:

  • fixed_price 原价
  • promotion_price 特价,一般为 fixed_price * promotion.discount
  • vip_price 会员价,一般为 fixed_price * vip.discount,可能为空

经常出现的情形时既要显示折扣(discount)又要显示价格(promotion_price or vip_price),可不可以直接返回 fixed_price 和 discount 呢?不可以!细心的读者已经觉察到问题了 —— discount 为小数,fixed_price 与其相乘很可能不是一个整数(这里要插一句,一般情况下价格都是记录为 int,以 cent 作为单位)。比如臭名昭著的 JavaScript 浮点数相加问题。

1
2
3
4
> 0.1 + 0.2
< 0.30000000000000004
> 0.1 + 0.2 === 0.3
< false

原因就是 float 在计算机里存了 32 位,1 位符号位 + 8 位指数位 + 23 位尾数,反正就是不精确就完事了。那我们即使不精确也要保证各处拿到的是一个值,这个值只能以后端为准。

1
2
3
4
5
6
7
8
9
@property
def promotion_price(self):
promotion = self.get_active_promotion()
if not promotion:
return self.fixed_price

if self.is_chapter:
return int(self.fixed_price * self.column.promotion_discount)
return promotion.price

这个值是惰性的,也就是说只有用到时才会计算值,返回的一定是一个整数。有一些应用场景:

  • 直接展示价格:(price / 100).toFixed(2) => 0.99
  • 很多章节合并购买,items.reduce((total, item) => total + item.price, 0) 注意这个值可能会不等于整本的定价,这时就要引导或劝说用户直接买整本更划算呀
  • 满减活动,类似合并购买情形,只不过是有一些阈值情形
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    getMaxAvailableRebateAmountInGroup = (group) => {
    const total = this.getTotalPriceInGroup(group)
    let maxAmount = 0

    if (!group.event) {
    return maxAmount
    }

    group.event.availablecouponGroups.some((coupon) => {
    if (total > coupon[0]) {
    maxAmount = coupon[1]
    }
    return total > coupon[0]
    })
    return maxAmount
    }
    /**
    * Returns rebate threshold info.
    * @param {Object[]} couponGroups - Available coupons.
    * @param {number} couponGroups[][0] - The threshold of coupon.
    * @param {number} couponGroups[][1] - The amount of coupon, will be reduced from price when total meets threshold.
    * @param {numer} total - Total of prices.
    * @returns {[string, bool]} Description String, isMaxThresholdMet
    */
    // returned value: [descString, isMaxThresholdMet]
    getRebateThreshold = (couponGroups, total) => {
    const mf = moneyformatShort


    if (couponGroups.length === 0 ) {
    return ['本活动满减券已全部用完', true]
    }

    for (let i = 0, prev_threshold = 0, prev_amount = 0; i < couponGroups.length; i++) {
    const [threshold, amount] = couponGroups[i]

    if (total >= threshold) {
    if (i === 0) {
    return ['已购满 ' + mf(threshold) + ',已减 ' + mf(amount), true]
    } else {
    return ['已减 ' + mf(amount) +
    ',再购 ' + mf(prev_threshold - total) + ' 可减 ' + mf(prev_amount), false]
    }
    } else {
    if (i === couponGroups.length - 1) {
    return ['购满 ' + mf(threshold) + ' 可减 ' + mf(amount) +
    ',还差 ' + mf(threshold - total), false]
    }
    }
    [prev_threshold, prev_amount] = [threshold, amount]
    }
    }

    getTotalPriceInGroup = (group) => {
    return group.itemList.reduce((total, item) => {
    if (item.onSale && item.selected) {
    total += item.salePrice
    }
    return total
    }, 0)
    }

钱的计算大概就是这样,涉及到第三方支付就更头疼了。

时间

金钱是宝贵的,时间是更宝贵的。而我们需要根据不同场景甚至用户所在时区去显示不同的时间格式,有一种方案是 date-fns,项目里也有根据 timestamp 转换成用户可读格式的各种函数,但有时候只是想简简单单显示一个时间,同时考虑各种情况下的复用性。上 GraphQL!

1
2
3
4
5
6
7
8
9
10
11
12
13
import time
from libs.utils.date import mtimeformat
from ..types import TimeFormat

def format_time(time_, format=TimeFormat.FULL_TIME.value):
return {
TimeFormat.FURTHER: mtimeformat(time_),
TimeFormat.FULL_TIME: time_.strftime('%Y-%m-%d %H:%M:%S'),
TimeFormat.FULL_DAY: time_.strftime('%Y-%m-%d'),
TimeFormat.CHINESE_FULL_DAY: time_.strftime('%Y 年 %-m 月 %-d 日'),
TimeFormat.ISO: time_.isoformat(),
TimeFormat.TIMESTAMP: int(time.mktime(time_.timetuple()) * 1000),
}[format]

其中 TimeFormat 是一个 GraphQL 的 enum 类型,mtimeformat 是一个可以根据相差时间来区别展示的函数,比如可以展示成「刚刚」「5 分钟前」这样的口语化格式。

实际效果

表单

表单的验证可以有很多实现,最简单的莫过于 maxlength 及 require 这种,直接交给浏览器,项目里也用到了一些 jQuery 的表单绑定,在提交之前一次性遍历表单项根据 data-* 来进行 check。

现实是 react 相关的表单验证有以下两个痛点:

异步验证

所幸的是 formik 支持了 Promise 的验证结果

1
2
3
4
5
6
7
8
9
10
11
12
13
<Field name={name} type="number"
validate={function(value) {
return fetchAgent(value).then((res) => {
if (res.agentExisted) {
if (res.existed) {
return `该作者经纪合同已经存在,负责人:${res.editorName}`
}
} else {
return `'作者 ID' 为 ${value} 的用户不存在或不是作者身份`
}
})
}}
/>

依赖另一输入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import * as Yup from 'yup'

const OTHER_NATIONALITY = '其他'

export const validationSchema=Yup.object().shape({
nationality: Yup.string().nullable(true).required('请选择国家或地区'),
otherNationality: Yup.string().test(
'need-other-nationality',
'请填写其他国家或地区',
function(value) {
return this.parent.nationality !== OTHER_NATIONALITY || !!value
}
),
})

实际情形为中间的输入框依赖于前面的下拉筛选框是否选了「其他」

路由

异步鉴权路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const PrivateRoute = ({ component: Component, ...rest }) => {
const user = useSelector(state => selectors.user(state))
const isLoaded = useSelector(state => selectors.isLoaded(state))

return (
<Route
{...rest}
render={(props) =>
!isLoaded ? (
<></>
) : user ? (
<Component {...props} />
) : (
<Redirect to='/404' />
)
}
/>
)
}

面试小结

算法题

全排列

实现二维数组的全排列

1
2
3
4
5
6
7
8
9
10
// 如输入[[1,2],[3,4],[5,6]]
// 输出:
// [ 1, 3, 5 ]
// [ 1, 3, 6 ]
// [ 1, 4, 5 ]
// [ 1, 4, 6 ]
// [ 2, 3, 5 ]
// [ 2, 3, 6 ]
// [ 2, 4, 5 ]
// [ 2, 4, 6 ]

思路:最后需要得到一个二维数组,那基本都是 reduce 操作的话也应该是一个二维数组开头,每一次都把前一次结果得到的数组们尾部分别加上二维数组里的一项,也就是 m * n * [...prevResultList[i], list[j]],其中 m 和 n 分别是 prevResultList 和 list 的项数,这样也就成功实现了 m × n 的项数膨胀,至于降维操作我们有 flatMap 这个神器。面试时用了很丑陋的 reduce + map 嵌套,甚至还忘了把数组摊平……

1
2
3
4
5
function arrange(doubleList) {
return doubleList.reduce((prevResultList, list) => {
return prevResultList.flatMap((result) => list.map((v) => result.concat(v)))
}, [[]])
}

随机自然数组

1~1000 范围内生成长度为 1000 的随机不重复自然数数组,并验证

思路:这道题也是老生常谈了,直接暴力一点 sort(() => Math.random() > 0.5) 解之,80% 的面试官都会眼前一愣,心想算了算了投机取巧的家伙,但有一个面试官对此质疑了很久,想了想也是,sort 的内部实现并不稳定,而且每次排出来结果不一致不知道性能有没有问题,还是要把随机数稳定下来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function generateRandomInter(n) {
const list = Array(n).fill(0).map((v, i) => [i + 1, Math.random()])
list.sort((a, b) => a[1] - b[1])
return list.map((v) => v[0])
}

function validateResult(result, n) {
const uniqList = Array.from(new Set(result))
return uniqList.length === n
}

const result = generateRandomInter(1000)
console.log(result)
console.log(validateResult(result, 1000))

去重

用 es5 实现数组去重?
[1, 2, 3, true, '2']

思路:没啥思路,就老老实实遍历再挨个取 indexOf?最后面试官给出了 typeof 的解法,真是……

1
2
3
4
5
6
7
8
9
10
11
12
function unique(list) {
const cacheMap = {}
return list.reduce((acc, v) => {
const key = typeof v + v
if (cacheMap[key]) {
return acc
} else {
cacheMap[key] = true
return acc.concat(v)
}
}, [])
}

合并有序数组(链表?)

思路:简单来说维护两个 head,每次取一个值插入新数组后就步进一次,然后两者其中一个 head 到达底部后一次性将另一个数组剩余元素灌到新数组里。面试官问还有没有高效一点的方案,就用了 Symbol.iterator 理论上会高效一点?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function mergeList(list1, list2) {
let result = []
const iterator1 = list1[Symbol.iterator]()
const iterator2 = list2[Symbol.iterator]()
let head1 = iterator1.next()
let head2 = iterator2.next()
while (!head1.done && !head2.done) {
const value1 = head1.value
const value2 = head2.value
if (value1 <= value2) {
result.push(value1)
head1 = iterator1.next()
} else {
result.push(value2)
head2 = iterator2.next()
}
}
if (!head1.done) {
result = [...result, head1.value, ...iterator1]
}
if (!head2.done) {
result = [...result, head2.value, ...iterator2]
}
return result
}

console.log(mergeList([1, 2], [1, 2, 3]))

判断二叉树镜像

给定一个二叉树,判断是否为镜像
1
2 2
3 4 4 3
1
2 2
3 3

思路:跟判断两颗二叉树是否相同区别不大,面试时采用了简单粗暴的分层比较法,空间复杂度达到了 2 ** n …… 😂

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function checkMirror(roots) {
const len = roots.length
let isAllNull = true
const doesRootsEqual = roots.every((root, i) => {
if (root) {
isAllNull = false
}
const oppositeRoot = roots[len - 1 - i]
return (root && root.val) === (oppositeRoot && oppositeRoot.val)
})
if (!doesRootsEqual) { return false }
if (isAllNull) { return true }
const nextRoots = roots.flatMap((r) => r ? [r.left, r.right] : [null, null])
return checkMirror(nextRoots)
}

const root = {
val: 1,
left: { val: 2, left: { val: 3}, right: { val: 4, left: { val: 2}}},
right: { val: 2, left: { val: 4, right: { val: 2}}, right: { val: 3}}
}

console.log(checkMirror([root]))

工程题

控制 Promise 并发

[promise]

1
2
3
dispatch(arr, n) {

}

思路:肯定要用 Promise.race,然后如果要阻塞的话还需要是 async/await 写法。这道题其实还是算一般的,后面写个 Scheduler 就痛苦了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const promises = Array(10).fill(0).map((v, i) => (
new Promise((resolve) => setTimeout(() => resolve(i), i * 1000 + 1000)).then(console.log)
))

async function dispatch(list, n) {
const total = list.length
let pending = []
let completed = 0
while(completed < total && list.length) {
const moreCount = n - pending.length
Array(moreCount).fill(0).forEach(() => {
const p = list.shift()
.catch(() => {})
.then(() => pending = pending.filter(v => v !== p))
pending.push(p)
})
console.log('len: ', pending.length)
await Promise.race(pending)
completed += 1
}
}

dispatch(promises, 3)

实现异步调度器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Scheduler {
async add(promiseFunc: () => Promise<void>): Promise<void> {
}
}

const scheduler = new Scheduler()
const timeout = (time) => {
return new Promise(r => setTimeout(r, time))
}
const addTask = (time, order) => {
scheduler.add(() => timeout(time))
.then(() => console.log(order))
}

addTask(1000, 1)
addTask(500, 2)
addTask(300, 3)
addTask(400, 4)
// log: 2 3 1 4

思路:一开始看到这题还有些欣喜,似曾相识的感觉,但实际一做发现不是这样,需要直接在 add 方法后返回一个 Promise,面试时没有写出有效解,事后想想还是能写出个解的,就是实现比较丑陋 ……

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
type promiseFuncType = () => Promise<void>

class Scheduler {
pending: Array<[promiseFuncType, () => void]>
running: Array<Promise<void>>
CONCURRENCY = 2
constructor() {
this.pending = []
this.running = []
}
async add(promiseFunc: promiseFuncType): Promise<void> {
const pending = new Promise<void>((r) => {
this.pending.push([promiseFunc, r])
})
this.execute()
return pending
}

execute = () => {
let restNum = this.CONCURRENCY - this.running.length
if (this.running.length === 0 && this.pending.length === 0) {
return
}
while (restNum > 0 && this.pending.length) {
const [promiseFunc, callback] = this.pending.shift()
const prom = promiseFunc().then(() => {
this.running = this.running.filter(p => p !== prom)
callback()
})
this.running.push(prom)
restNum -= 1
}
Promise.race(this.running).then(this.execute)
}
}

const scheduler = new Scheduler()
const timeout: (number) => Promise<void> = (time) => {
return new Promise(r => setTimeout(r, time))
}
const addTask = (time, order) => {
scheduler.add(() => timeout(time))
.then(() => console.log(order))
}

addTask(1000, 1)
addTask(500, 2)
addTask(300, 3)
addTask(400, 4)

实现 Observable

思路:观察者和迭代器,需要理解 Observable 和 Observer,才疏学浅,没有 get 到精髓,此题仍为 WIP 状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const arr = [1, 2, 3]

function Observable() {
this.uid = 0
this.subscribers = {}
this.onSubscribe = null
}

Observable.from = function(array) {
const observable = new Observable()
observable.onSubscribe = (observer) => {
array.forEach((v) => observer.next(v))
observer.complete()
}
return observable
}

Observable.prototype.subscribe = function(observer) {
const id = this.id++
this.subscribers[id] = observer
if (this.onSubscribe) {
this.onSubscribe(observer)
}
return {
unsubscribe: () => {
this.subscribers[id] = null
console.log('unsubscribe')
}
}
}

const arr$ = Observable.from(arr)

const subscriber = arr$.subscribe({
next: console.log,
complete: () => console.log('complete'),
error: console.error,
})

subscriber.unsubscribe()

口答知识点

  • HTML 一点都没问,CSS 就问了简单九宫格 header/nav/main 布局以及垂直居中等,说明组件化已经深入人心,高级前端基本没有写样式的部分了
  • 简历里写到了 Gulp/Webpack 相关,所以被问了很多次 Webpack Loader/Plugin + Gulp plugin 开发 😂 像我这么水当然是只能扯扯从一个 File 到另一个 File 输出这样子的
  • TypeScript 和 GraphQL 也被问了很多次,TypeScript 如何实现类型推导(Pick, Omit),interface 和 type 区别,GraphQL 解决什么问题
  • React 相关:hooks 生命周期,fiber 是啥,setState 到渲染发生了什么 ……
  • 深拷贝也是问了无数次,直接 lodash.cloneDeep 它不香么 😂 当然有些比如循环引用,class instance 等也是要注意的
  • macroTask/microTask:一个 macroTask 多个 microTask,microTask-in-microTask 继续排队,Promise((r) => …) … 是 macroTask
  • HTTPS 如何加密通讯过程、Server/Client Hello + 校验证书合法性 + 三次生成随机字符串 + RSA 非对称加密 + 约定密钥对称加密 / 浏览器缓存有哪些字段 / WebSocket 做了啥 / SSO:在第三方 Cookie 无法读取情形下怎么办?(OS:我也很无奈啊)/ script async defer 具体怎么 load
  • 最复杂、最有挑战性的项目经历:复杂筛选器 + GraphQL 应用,小程序解析 nodes 图文混排,原生端通讯 + 跨端开发联调
  • 最感兴趣的方向:富文本渲染与编辑、GIS 系统以及 WebAssembly 相关