Electron 踩坑

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

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

使 BrowserWindow 自适应 React 组件

我们知道 ElectronBrowserWindow 的默认尺寸为 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/inputno-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 需要调试查看其他端上的状态,所以会需要一个全局的样式开关,目前只区分了 macOSWindows,写作
    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_pricediscount 呢?不可以!细心的读者已经觉察到问题了 —— 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 分钟前」这样的口语化格式。

实际效果

表单

表单的验证可以有很多实现,最简单的莫过于 maxlengthrequire 这种,直接交给浏览器,项目里也用到了一些 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]],其中 mn 分别是 prevResultListlist 的项数,这样也就成功实现了 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 输出这样子的
  • TypeScriptGraphQL 也被问了很多次,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 相关

复盘一次 gulp & webpack 构建优化

接续前一篇《并行化密集型计算》,发现实际上线时 parallel terser 并没有提升速度。反而比单线程还要慢,这就有点不科学了。首先想本地跑起来没有出错,大概不是程序逻辑问题,再想想看,估计是配置问题,与 SA 进行了一波有力的交流。

有力的交流过程

原来 os.cpus() 拿到的是真实的计算机 CPU 核数,而 k8s 会限制容器的 CPU 使用量,所以我们应该手动将 NODE_ENV=production 时的并发数限制在 5 以内。

进一步思考这种 utility 和 business 代码混写在一起的方式极其耦合,想要更高效地发挥代码的作用必须分离它们,于是就借鉴了 terser-webpack-plugin 中对于 jest-worker 的应用改了一下,并将其应用于 webpack 的打包,取得了不错的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
gulp.task('webpack', function() {
const { result$, next, end } = parallelRunner(
__filename,
function(worker, configName) {
return Promise.resolve(
worker.compile(
null,
[configName]
)
)
},
isProduction ? PRODUCTION_PARALLEL_NUMBER : true
)
Object.keys(configNameToFileMap).forEach((name) => next(name))
end()
return result$
})

webpack 打包跑进了 40 秒大关

但实际到了线上 webpack 依然花了将近 4 分钟,与此同时 gulp parallel terser 已经稳定在 1 分钟之内了。这时一段异常的日志引起了我的注意。

terser-webpack-plugin 并发出错

原来 webpack 里自带的 terser-webpack-plugin 会在 production mode 下自动开启,又是 48 线程 …… 4 * 48 = 192 线程,OMG,不跑崩让 SA 来请喝茶才怪了。但转念一想,好像后面我们还会对各种文件都 terser 一遍,在 webpack 里压缩似乎是没有必要的?于是乎直接禁用 optimization.minimize 就可以了。

期间又优化了一下 parallel-runner,处理了一下 stream/rxjs observable/worker 之间的 back pressure 问题。简单说就是当 rxjs mergeMap 把一个值传到 worker 里去处理时才算作 consumed,会继续向上游 pull 下一个数据,这样就把上下游的数据链接给建立起来了,而不是之前那样全部 pull 下来堆在 mergeMap 外面,容易形成 memory leak。

Call the callback function only when the current file (stream/buffer) is completely consumed.

parallel-runner 把 data(vinyl File) 放到 worker 去处理标志该数据为 consumed(实际结果会在 result$ 里被 push 给下游的 writable stream),然后 stream 会根据是否 end 或者到达 highWaterMark 自动去 read(pull) 上游 readable stream 的下一个数据,也就是执行我们传入的 transform 方法。数据可能堆积在 mergeMap 外最多一个,因为那个数据不进入 mergeMap 就不会继续触发 consumed,之前是一口气全 read。

最后,规范化了一下 webpack 出错时向 main process 通报错误的方法,该出错就出错,不要静默失败到上线时出大问题。

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
return new Promise((resolve, reject) => {
webpack(configs, function(error, stats) {
let errors
if (stats) {
log(stats.toString(Object.assign({
// https://webpack.js.org/configuration/stats/
chunks: false,
colors: colors.supportsColor,
}, configs[0].stats)))
}

if (error || stats.hasErrors()) {
// errors should be serializable since it will go through processes
errors = error ? error.toString() : stats.toJson().errors
}

if (callback) {
callback(errors)
}
if (errors) {
reject(errors)
} else {
resolve()
}
})
})

至此,这次优化 gulp & webpack 打包构建流程的优化工作就算告一段落了,实际上大概处理了以下几个问题:

  • 将重复性的任务放到多线程并行执行
  • 提取公共代码转成 utilities
  • 区分本地开发与生产环境,尊重基础设施对于计算资源的分配规则
  • 剔除多余的步骤避免重复计算(侧面说明不要替用户预先做决定的重要性)
  • 向官方推荐实现靠拢,以求符合标准融入开源库的生态环境

历经近一周时间,完成了优化工作

整体打包速度从 700 ~ 800s 提升到了 250 ~ 300s!

下面直接贴出了 parallel-runner 的代码,小弟手艺不佳,写得不好,各位如有需要可以在此基础上稍加改动以适应业务需要。

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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
// Inspired by terser-webpack-plugin.

const os = require('os')
const rx = require('rxjs')
const rxOperators = require('rxjs/operators')
const JestWorker = require('jest-worker').default
const through2 = require('through2')
const log = require('fancy-log')

const { Subject, from } = rx
const { mergeMap, share } = rxOperators

function getAvailableNumberOfCores(parallel) {
const cpus = os.cpus() || { length: 1 }

return parallel === true
? cpus.length - 1
: Math.min(Number(parallel) || 0, cpus.length - 1)
}

function parallelRunner(
module,
taskCallback,
parallel = true
) {
const availableNumberOfCores = getAvailableNumberOfCores(parallel)
let concurrency = 1 // which means mergeMap will behave as concatMap
let worker
let total = 0
let completed = 0
let allScheduled = false

log('Parallel Running: ', module)
log('Available Number of Cores: ', availableNumberOfCores)

// setup worker
if (availableNumberOfCores > 0) {
const numWorkers = availableNumberOfCores
concurrency = numWorkers
log('Number of Workers: ', numWorkers)
worker = new JestWorker(module, { numWorkers, enableWorkerThreads: true })

const workerStdout = worker.getStdout()
if (workerStdout) {
workerStdout.on('data', (chunk) => {
return process.stdout.write(chunk)
})
}

const workerStderr = worker.getStderr()

if (workerStderr) {
workerStderr.on('data', (chunk) => {
return process.stderr.write(chunk)
})
}
}

// handle concurrency with rxjs
const scheduled = new Subject()
const consumed = new Subject()

const result$ = scheduled.pipe(
mergeMap((data) => {
// data is actually consumed here
consumed.next(null)
// worker[methodName] can only be invoked with serializable data
// and returned value could be just plain RESULT or Promise<RESULT>
return from(taskCallback(worker || require(module), data))
}, concurrency),
share()
)
result$.subscribe({
complete: function() {
if (worker) {
worker.end()
}
},
next: function() {
completed += 1
if (allScheduled && completed === total) {
scheduled.complete()
}
},
error: function(err) {
throw err
}
})

return {
result$,
consumed$: consumed.asObservable(),
next: (data) => {
scheduled.next(data)
total += 1
},
complete: () => { allScheduled = true }
}
}

function gulpParallelRunner(module, taskCallback, parallel) {
const {
result$,
consumed$,
next,
complete
} = parallelRunner(module, taskCallback, parallel)
let afterComplete, stream, afterConsume

consumed$.subscribe(() => {
// `afterComplete was defined` means there is no more data
if (!afterComplete && afterConsume) {
afterConsume()
}
})

result$.subscribe({
complete: () => {
if (afterComplete) {
afterComplete()
}
},
next: (data) => {
stream.push(data)
// if returned value is false means stream ends or meets highWaterMark
// but we don't care since we use rxjs to control concurrency
}
})

const flush = function(cb) {
afterComplete = cb
complete()
}
const transform = function(file, enc, afterTransform) {
if (!stream) {
stream = this
}
if (!afterConsume) {
afterConsume = afterTransform
}
next(file)
}
return through2.obj(transform, flush)
}

// Staticng has CPU limit of 5 on k8s, so we can't use os.cpus().length which
// reports the number of online CPUs, but running with 4 threads is fast enough.
// https://github.com/nodejs/node/issues/28762#issuecomment-513730856
const PRODUCTION_PARALLEL_NUMBER = 4

module.exports = {
parallelRunner,
gulpParallelRunner,
PRODUCTION_PARALLEL_NUMBER,
}

实际应用于 terser 的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function parallelTerser(needMangle) {
const options = generateTerserOptions(needMangle)
return gulpParallelRunner(
require.resolve('terser'),
function(worker, file) {
return Promise.resolve(worker.minify({
[file.path]: file.contents.toString('utf-8')
}, options)).then((result) => {
if ('error' in result) {
throw new Error(result.error.message)
}
file.contents = 'from' in Buffer ? Buffer.from(result.code) : new Buffer(result.code)
return file
})
},
isProduction ? PRODUCTION_PARALLEL_NUMBER : true
)
}

并行化密集型计算

稍早前对 terser 压缩代码进行优化时将执行过程的代码从 Python 迁移到了 Node.js,准确地说是试图用 gulp stream 来批量压缩,用了一个比较老的包(gulp-terser),已经很久不维护了。与原先相比,底层都是 terser,只是为了在 Node.js 的镜像下使用而统一了文件的提取(glob)和批处理过程(Python Pool & subprocess -> gulp stream)。

但这样处理了以后发现压缩处理时长变长了很多,差不多慢了 1 分钟,研究了一下 gulp 的处理机制,发现虽然 gulp 号称流式处理,但文件依然是按个通过 pipe 的,前一个处理完下一个才能进入,但通过 parallel-transform 也能实现同时处理多个文件,可是实际操作中以 parallel-transform 替代 through2 并不能提升处理效率。笔者并未实际深究其原因,大致读了下 parallel-transform 的实现,它依然是围绕着 event-loop 做文章,设想成一个水池,干涸了就加水,满了就停止并等待处理完毕抬高基准线。

笔者甚至使用了 RxJS 来控制并发 …… 但事实证明还是自己太年轻了,这种密集型计算根本无法通过 event-loop 来优化,它和 ajax 或者 IO 操作不一样,处理数据的仍是这个进程,怎么办?那就多进程/多线程!

问题就变得简单了起来,第一步把文件列表拿到,第二步把它们分发到其他进程/线程处理。

首先想到的是 gulp.src 接受的是数组,而 glob 接收的是字符串,实现肯定不一样,事实证明果然相差甚远。想想看还是需要先拿到本着能不造轮子就不造轮子的思路,去网上搜搜代码,首先想到的是已废弃的 gulp-util。一看里面有个 buffer 符合我们的需要,直接抄来,只不过需要注意一下这里有个 cb 需要塞到 fn 的回调里去,这样才能准确得到 task 的总共执行时间。

1
2
3
4
5
6
7
8
9
var end = function(cb) {
this.push(buf);
--- cb();
--- if(fn) {fn(null, buf);
+++ if (fn) {
+++ fn(null, buf).then(cb)
+++ }
}
};

fn 是个耗时的操作,必须再它完成后再 cb

然后按照 terser 和 child_process 的关键词找到了 @bazel/rules_nodejs - terser 这个包,简直是宝藏 …… 一顿大抄特抄,写死了 terser 执行参数,加了个 Promise 的返回值。上 pre 跑了一下,功德圆满!

快了整整 4.5 倍!

为啥这么快?看了下 CPU 数量,我惊呆了!

48 核???

顺便看了下 issue 里也有试图引入 works_thread,不过 child_process 已经足够香了!

不过 web 上毕竟一个 page 只有一个进程,所以要有 Web Worker 来让密集计算在主线程之外运行,而可预计到的是 Web Worker 和 work_thread 的关系就好比 wasm 和 wasi,激动人心的进展!

并行计算大势所趋

通用设计

我们知道页面是 web 设计中单次呈现给用户的一个功能整体,但功能的粒度随着 AJAX 的出现不仅仅限于表单即 form 与服务器的单次交互,变成了 XMLHTTPRequestfetch 甚至 WebSocket 的多次往返,这样而言单一页面也可以承载更多的用户交互行为。与之同时带来的问题是如何有效划分功能区域以便实现最大程度的组件化,增强复用性和可维护性?

签约与填写身份信息分离

以某次产品设计为例,产品需要将 签署合同填写身份信息 作为一个步骤页面放到拉力赛作品的创建流程里,原先的页面长成下图这样。

原签署合同页面

产品的想法是直接复用这个页面,作为前端当即表达了反对:这个页面包含了签协议和填信息两种操作,但却共用一个接口,不利于今后业务逻辑更为复杂时拆分(当下就已经属于过度耦合了),建议将 填写身份信息 放到单独的页面去。但产品随即表达了反对,认为用户的操作流程不应该多一个页面跳转。这怎么办呢?

用弹窗啊!

弹窗作为一种全局模态框可以在不修改整体页面流式布局的基础上让用户看到额外信息,甚至进行交互操作。弹窗内的组件可以被复用,因为是动态挂载到页面上的,一般对页面已有数据的依赖较小。

现签署合同页面
在身份信息不全情形下会弹出弹窗以供填写

在实际代码构建时为了便于使用,将整个弹窗加上作者身份数据的获取、存储与更新逻辑整体封装到了一个 hook 中:

1
const { agentInfo, loaded, showAgentInfoFormDialog } = useAgentInfo(agentId)

这样将数据和方法返回给调用方,将代码的侵入性做到最低。如果以后有只需要身份信息而不需要修改弹窗的使用情形,也可以尽情复用。

更通用的标签筛选

一日接到了一个增加筛选器筛选条件的需求(筛选器是一个巨大的,有着上百个 CheckBox,相比各种电商筛选条件毫不逊色的怪物👹)。

需求

作为一个凡是遇到需求不杠一杠就不开心的开发,我理直气壮地提出了自己的看法:这种一次性使用的需求,会在文件里留下谜一样的代码,而且根本不可能实现复用,如果要重构或者迁移都会是巨大的沉没成本。

随后根据下图的需求来源项

征文大赛筛选项,五个硬编码的分类条件,实则为普通分类

提取出了可以重复利用的「标签输入框」

标签输入框

有人会问,这样经常需要输入特定标签会不会不方便呢?早就想过啦,可以使用「加入书签」功能

加入书签

会映射成形如 /admin/editorial_service?tags=亲密关系+轻装上场 的 url 存储在 localStorage 中,方便下次使用。大功告成!

总结

通用设计可以提供组件的复用能力,尽可能做到少暴露接口给外界,毕竟多一行代码就多一分风险和维护的成本,同时通用性足够好的情形下也能促进产品往更高效的方向发展,而不是拘泥于小而美的自我天地。

混合开发最小接口

2020 年目前在做的最复杂的项目是将基于 Draft.js 深度定制开发的 web 编辑器适配到移动端,因为数据上牵涉到很多转换过程 native 端要从头开发的话成本过高,并且发现 web 并不能很正常地识别虚拟键盘弹出与否,于是将下图所示的工具栏通过 native 端单独进行开发与 web 集成,以获得更好的使用体验。

编辑器工具栏

简单介绍下 web – native 通讯方式,ark_editor_native 是 native webview 注入到 window 对象上的一系列方法简称 AENark_editor_web 则是 web 编辑器挂载成功后写入到 window 对象上供 native 调用的一系列方法简称 AEW,每次 editor 有任何更新,通过 AEN.syncState 传递给 native 一个 JSON 字符串,客户端需要自行反序列化成 JSON 对象,称为 syncState

syncState

名称
inline_styles { "BOLD": false, \n"CODE": false, "ITALIC": false, "STRIKETHROUGH": false },
block_type “unstyled”
alignment “left”
disabled_buttons [ “BOLD”, “unstyled”, “header-one”, …]
undo_disabled true
redo_disabled false

操作栏

行为 value disabled toggle insert update remove native 帮帮我 交给你了 web
加粗 ‘BOLD’ ‘BOLD’ in syncState.disabled_buttons AEW.toggleInlineStyle(‘BOLD’)
楷体 ‘ITALIC’ ‘ITALIC’ in syncState.disabled_buttons AEW.toggleInlineStyle(‘ITALIC’)
删除线 ‘STRIKETHROUGH’ ‘STRIKETHROUGH’ in syncState.disabled_buttons AEW.toggleInlineStyle(‘STRIKETHROUGH’)
行内代码 ‘CODE’ ‘CODE’ in syncState.disabled_buttons AEW.toggleInlineStyle(‘CODE’)
链接 ‘LINK’ ‘LINK’ in syncState.disabled_buttons AEW.toggleLink() AEW.insertLink(url) AEW.updateLink(entityKey, url) AEW.removeLink() AEN.showLinkEditor(entityKey, url)
注释 ‘FOOTNOTE’ ‘FOOTNOTE’ in syncState.disabled_buttons AEW.insertFootnote(content) AEW.updateFootnote(entityKey, content) AEN.showFootnoteEditor(entityKey, content)
标题( 总称,无实际意义) ‘headlines’ ‘headlines’ in syncState.disabled_buttons
一级标题 ‘header-one’ ‘header-one’ in syncState.disabled_buttons AEW.toggleBlockType(‘header-one’)
二级标题 ‘header-two’ ‘header-two’ in syncState.disabled_buttons AEW.toggleBlockType(‘header-two’)
三级标题 ‘header-three’ ‘header-three’ in syncState.disabled_buttons AEW.toggleBlockType(‘header-three’)
四级标题 ‘header-four’ ‘header-four’ in syncState.disabled_buttons AEW.toggleBlockType(‘header-four’)
五级标题 ‘header-five’ ‘header-five’ in syncState.disabled_buttons AEW.toggleBlockType(‘header-five’)
六级标题 ‘header-six’ ‘header-six’ in syncState.disabled_buttons AEW.toggleBlockType(‘header-six’)
默认文字块 ‘unstyled’ ‘unstyled’ in syncState.disabled_buttons AEW.toggleBlockType(‘unstyled’)
对齐 ‘alignments’ ‘alignments’ in syncState.disabled_buttons AEW.toggleAlignment(‘left’|’center’|’right’)
有序列表 ‘ordered-list-item’ ‘ordered-list-item’ in syncState.disabled_buttons AEW.toggleBlockType(‘ordered-list-item’)
无序列表 ‘unordered-list-item’ ‘unordered-list-item’ in syncState.disabled_buttons AEW.toggleBlockType(‘unordered-list-item’)
引用 ‘blockquote’ ‘blockquote’ in syncState.disabled_buttons AEW.toggleBlockType(‘blockquote’)
图片 ‘FIGURE’ ‘atomic’ in syncState.disabled_buttons AEW.selectImage()
代码块 ‘code-block’ ‘code-block’ in syncState.disabled_buttons AEW.insertCodeBlock()
分割线 ‘PAGEBREAK’ ‘ atomic’ in syncState.disabled_buttons AEW.insertPagebreak()
分行 ‘ atomic’ in syncState.disabled_buttons AEW.insertSoftNewLine()
撤销 syncState.undo_disabled AEW.undo()
重做 syncState.redo_disabled AEW.redo()
清除行内样式 AEW.removeFormat()

优先级原则:

  • 先判断是不是 disabled
  • toggle > native 帮我 > 交给你了 web > insert/update/remove

上面就是编辑器跨端通讯的基本形式了,简单来说 web 端每次 React componentDidUpdate 会把状态同步给 native 端,保证工具栏的及时性。当用户点击工具栏中可用按钮时,首先判断是不是需要和 web 进行状态判断,毕竟前一步只是判断能不能触发,触发以后的行为需要结合更具体的数据状态。然后是如果发现需要更多用户输入(比如弹窗输入框或勾选项等等)就需要唤起 native 的方法(native 帮帮我),如果没有这一步就可能是 web 专享操作,无关更多状态,比如上传图片或者撤销重做等等,这种情况就全权交给 web 了。最后则是 native 端二次调用 web 的方法,通常是关掉弹窗以后传递数据去改变 web 的状态。

体会:

  • 原本通过 React props 保证的数据实时性需要手动同步给 native 端
  • native 和 web 互相暴露方法需要考虑实现的最小集
  • 需要找到优雅的 debug 方式,出现过直接传 JS Object,native 端得到 undefined
  • 异步操作可能需要加锁

当然现在开发还在对接联调期,包括保存等功能还没有加上,与从零开始的搭建也不可同日而语。编辑器和阅读器都是电子阅读产品的重头戏,之前则是看了这篇《Visual Studio Code有哪些工程方面的亮点》,对编辑器的开发充满了崇敬的心情。VS Code 也是我们现在开发的主力工具,包括其丰富的插件体系、LSP 和宛如神器的 Remote 模式,都使我惊叹于其良好的实现。不说了,去下单《设计模式》了。

前端文件打包优化

前端开发除了 HTML 模板外最重要的就是 JS/CSS 文件了,现今开发者都是本地书写 ES6/Stylus/Sass 然后经打包发布至 CDN 等环境,于此带来的问题是一些不需要的代码被打包了进来,甚至更严重的是一份代码被打包几遍。当然,Webpack 这样的工具出现就是为了解决这些问题,不过考虑到打包过程是一次性的编译,运行时代码的区别仍然需要开发者手动对待。

JavaScript

1. 重复打包了类库文件

backbone、underscore 被同时打包进了 vendor 和 setup 入口文件

上图中可以看到 vendor 和 setup 里都有 backbone、underscore 和 zepto 等库文件,这是为什么呢?

原因是当我们引入 splitChunksPlugin 时仅将 /node_modules/ 下的文件纳入 vendor 范围,但在 entry 处又定义了包含通过 bower 安装的 backbone、underscore 等类库的 vendor,见下面两图。

mobile config 中定义了 vendor

base config 中定义了 cacheGroup.vendor.test 为 /node_modules/

如图所示将 /public/js/lib 写入 optimization.splitChunks.cacheGroups.vendor.test 就可以了,结果如下图。

backbone、underscore 等库乖乖地呆在了 vendor 里面

2. 类库文件无法自动剔除无用代码

date-fnsRxJS 这样 battle tested 的库,从早期原型链(prototype)实现到现在拥抱函数式的历史进程中无可避免地引入了很多历史负担,比如下图中 date-fns/esm 就非常巨大,里面很多都是我们暂时不需要的功能。

date-fns/esm

好在它们文档都比较全:

如下图一顿修改。
import function from 'date-fns/function' directly

见证奇迹的时刻!
date-fns/esm mini

3. 运行时才能确认使用的重依赖模块

membership app 内出现了 jQuery 等依赖

一个 React App 内竟然出现了 jQuery 依赖 …… 一定是哪里出了问题,经过不懈的努力,终于找到了是在 web 和 mobile 公用的组件里用了微信支付的 module,而这个 module 开头就直接引入了 backbone/underscore/zepto 三大金刚 ……

weixin_wap_payment 依赖了 backbone 等库

Webpack 编译时并不能知道运行时究竟在不在手机环境,怎么办呢?我们可以通过 webpack require AMD 模式 来拆分代码。

AMD require weixin_wap_payment

其实这个打出来的 34 号包永远也不会被 import ……
jQuery/backbone/underscore 等都被打包出去了

CSS

1. 重复 CSS Variables 定义

开发小王接到了一个任务,改进项目 css 代码以支持当下新出的黑夜模式(Dare Mode),小王犯了愁,一个个颜色变量替换也太苦逼了,小王挠挠头想出了一个在 source 文件定义 CSS Variables 的方法。

duplicate CSS Variables

看起来非常完美,但不足之处是这堆 CSS Variables 每次 import 都会被定义一遍,结果就是有多少 stylus 文件就被重复定义了多少遍 …… 不要问我为什么要用 import 而不是 require ……

我们知道 Stylus 是一种 css 的预编译器,它的变量和 CSS Variables 是不一样的,CSS Variables 是会编译到生成的 CSS 文件里,而 Stylus 变量则会在编译中承担一次桥梁的作用之后悄悄消失。可以通过比对 Stylus 变量是否已经赋值 var(--css-variable) 来判断是否需要定义 CSS Variables。

is css variables defined?

成果就是从有多少 stylus 文件就有多少次 CSS Variables 定义缩减到入口文件那么多个数的定义,足足降低了一个数量级,打开 Chrome DevTools > Elements > Styles 终于不卡了!

注意观察右侧滚动条的宽度!