感受

《重来》是37signals创始人在经营过程中整理的商业思维,主要是他们的经验之谈。书不厚,也就200余页,主要是些描述和比喻,读起来很快,不到三天就看完了。并没有想象中收获那么大,看完全书,在作者足够真诚的经验总结里,不自觉地流露着精英主义、人文主义、乐观主义的思想,相信个人的力量,对个人价值实现有自己的思考。全书主要在说小公司。小公司相对大公司更容易做出改变,适应环境和快速迭代。因此书中提出的经验也是效率、实用、cut the crap的基调。

我想,作者大概认为小公司做好自己的产品,造福自己产品的目标人群就是最大的美德了。所以才会有这本书里的建议。而大公司也有大公司的优势,规模效应、更容易滚雪球、造福更广泛的人类。可能这种风格和愿景,更像国内集中力量办大事,修身齐家主国平天下这种东方传统价值观。

总结一下,小公司的优势作者已经阐述的很好了,作者没有讨论大公司的原因和西方背景有关。不是所有书中的建议都适合国内的环境。另外,有不少思维建议也适合个人建设和管理。下面干脆从这个角度,用《重来》的文风给个简单的再创作。其中难免掺杂个人私货,见谅。

Rethinking

“黎耀辉,不如我们从头来过” ——《春光乍泄》

开始之前,告诉自己重来没那么难。

减负

  • 失败并不是成功的先决条件,失败就是失败
  • 要有目标和计划,但不要让计划限制住你的行动
  • 一开始不要把摊子铺的太大,在实力足够后再说
  • 不要让没有目标的努力冲昏了头脑,这是虚无的。一旦你开始有努力的困惑,就要问自己“我在解决什么问题”,“我需要解决这个问题吗”,“我能从中得到什么呢?”

行动

  • 要做真正有意义的事,至少对自己有意义,对别人也有意义就更好了
  • 从自己的兴趣和痛点入手至少能保证自己的动力
  • 过度的计划和提前设想会让你陷入虚无的自满,清醒过来,早点行动
  • 没有“能不能”,只有“想不想”
  • 要有信念和原则,并以此出发规范行为。最有效的公关是说真话,你的每一句话即是你的信念,你的信念活在你的每一句话里。
  • 控制目标数目,减少焦虑提高目标实现的质量
  • 现实一点,不盈利的公司是活不下去的

做事

  • 有舍才有得
  • MVP模型,从核心价值开始实现,把基础打牢,再快速迭代
  • 不要过早优化,他只会增加你的焦虑,降低你的工作效率
  • 给主意、提问题等人能做参谋,做决定的人才能做将军
  • 于细节处动人
  • 关注核心,时尚会过时,而目标人群的核心需求是不变的(如沟通),变的只是需求的在科技发展下的表现形态(电话->手机)
  • 最适合自己的才是最好的。远观甚美的预设环境很可能并不如你所想。找到自己的舒适点。
  • 积累会不自觉地给你收益
  • 要小目标,不要大目标
  • 学会拆解问题

效率

  • 成块的时间占用才有收益,碎片时间不会给你任何成长。上下文的频繁切换也会大大降低你的工作效率。最理想的工作环境是旁若无人的。
  • 绝大多数的会议都是低效的。能拯救这一点的可以是
    • 有主持人保证进度和不跑题
    • 事先确定会议目标,告知与会者
    • 只邀请相关的人
    • 保证所有人的参与度
    • 会议结论落实到人
  • 82原则,过犹不及,你花剩下80%的优化的时间可以做更紧要的事
  • 速战速决,更快给自己反馈,保证动力,维持self-esteem

产品

  • 大公司可以“抄袭”,小公司就算了
  • Diss在一定程度简单方便地做到了
    1. 你的产品时干什么的,大家大概要怎么用
    2. 为什么不用别人的用你的产品
  • 产品和设计要简洁有力,最符合使用者的直觉,似乎天生就是这样的
  • 用户要求 ≠ 用户需求
  • 不要头脑发热,大决定睡一觉再做
  • 可以忽略绝大多数的客户意见,因为真正的客户意见一定印象深刻到能让你记住

推销

  • 扮猪吃老虎。低调状态下,试错成本更低。稳定后再扩张
  • 头部流量 -> 用户沉淀 -> 商业变现
  • 开源可以赢得名声带来流量和口碑
  • 适度的不完美给受众真实感,更让人感动
  • 没有策略的推广都是垃圾,除了PR和GR需求
  • 免费甚至盗版至少能保证有人认识你
  • 一夜成本只是传说,做好自己

招聘

  • 亲力亲为
  • 不要招蠢人
  • 不要过分相信第一直觉,有的人会给你惊喜
  • 职业经理人真的很省事
  • 会写文章、做PPT、写文档、做presentation的人都是人才

负面消息

  • 负面消息永远比正面消息刺耳,请不要介意
  • 有负面消息比没有消息好,它代表你被更多人认识
  • 说实话,做好自己。不要被改变信念

工作

  • 没有企业文化的公司大不起来
  • 看boss,知文化
  • 好公司会给员工充分的隐私权、空间和必备工具。良好的工作环境显示了对其中工作者和他们工作的尊重。
  • 小公司里,对员工的不信任也是一大开销
  • 鼓励加班的公司会让公司糟糕的管理能力蒙混过关
  • 好公司内部开放谦逊做实事
  • 慎用加急

生活

  • 保持锻炼身体
  • 有事业外的爱好能帮助你认识更多人
  • 保证睡眠时间,在工作和生活中会更有灵感
  • 劳逸结合、有张有弛是巩固学习成果的极好方式

总结

其实也没什么好总结的。教人做人做事的道理最合适的是点到为止,不可好为人师。很可能你看完上面这些仍有鸡肋食之无味弃之可惜的感觉,没关系,我看完《重来》也是这种感觉。不过也许其中的某些部分在未来会给你帮助。

《自私的基因》是道金斯在上世纪70年代写的一本关于生物学的经典著作。书中从“自私的”复制因子基因的角度出发,推导出生物学、社会学各个方面的规律猜想,乃至最后上升到对生命、人类产生的必然性和偶然性的大胆设想。全书从第5章起,开始引人入胜,内容渐入佳境,让人手不释卷。其中对于代际、两性出现、共生群居等方面的探讨,令我有三观再塑之感。尽管前几章对复制因子基因的“自私性”和生存机器铺垫甚多,有点拖沓,整体还是很值得一读的。另外,把《自私的基因》和《人类简史》连在一起思考,甚至有更奇妙更透彻的感觉。

对“自私的基因”一词的解释

生物的进化的最基本单位是基因而不是生物个体。因为基因有复制性,可以控制个体性状。生物的生存繁衍等一切行为的本质是基因带来的。成功基因的标志是可以在复制中不断战胜其余复制因子,这个过程是“盲目的”。基因是不会做选择的,做选择的是自然环境。经过自然选择留下来的成功基因,自然是生存能力最强,留下复制最多的,看上去也是最“自私的”。自私一词是有些感性,但是它是对成功基因最恰当的形容了。你可以把它和“不被淘汰”等同。

复制因子

基因作为自然界诞生的产物,更通用的说法可以叫复制因子。在复制因子出现之前,自然界一片混沌,各种大分子在“分子汤”内自由游荡,随意组合,我们很难把它们叫做“个体”。直到有一天(说得不大严谨)出现了第一个复制因子。它们不见得是分子汤里最大的,但是它有可贵的性质——能复制自身。这个偶然性虽然非常之小,但是一旦出现就会不可逆的扩张开来。以至于复制因子一旦出现就会占据整个分子汤的主要地位,它会必然地在海洋里疯狂复制自己的拷贝。

很有可能基因只不过是一种复制因子,在基因出现前甚至出现之初,可能甚至很可能有类似基因的其他复制因子。复制因子之间必定会有胜利和失败者,因为分子汤资源是有限的,不足以维持无限量的复制。现在看来,最终基因胜利了。在基因的竞争和演化中,逐渐出现了蛋白质的保护膜,更多的基因渐渐聚合起来,蛋白质分工逐渐明确,生存机器由此产生。人不过也是一种生存机器罢了,“操纵”这个机器的是背后的基因。

另外,复制过程当然不会是完美无缺的,因此产生的多样性,让生存机器间也出现了越来越大的形态差异。

基因与染色体

我们生物都是同一种复制因子——DNA的生存机器,基因在染色体中,它通过蛋白酶监督着蛋白质的生成,并通过蛋白质控制生物性状。基因对生物的控制是“单向”的,也即后天所学是无法改变基因并遗传给后代的。个体的存活时间可能很有限,但是基因不断复制和交叉会一代代传递下去。

基因的复制最好是完美无缺的,自然选择对失误的复制惩罚往往很严重,如染色体丢失、倒位很容易导致个体的死亡。但是也有些复制失误不会造成那么大问题,只是在个体的一生逐渐积累,这也就是衰老。成功基因的标志除了“自私”,即能传递自身,还要保证和其他基因的通力合作下,能让生存机器的死亡至少推迟在生殖以后。有种观点认为性“促进了在单个个体内积累以往出现在不同个体内的有利突变”。

所谓进化就是指基因库中某些基因变多了,而另外的变少了的过程

基因机器

基因通过合成蛋白质,进而由神经控制和激素控制控制生物个体。控制下个体的每一次行为都是一次选择,在自然选择后留下的都是适于当前环境的。动物的行为无论是利己的还是利他的,都在基因控制之下,基因是主要的策略制定者,大脑则是执行者,并接管了很多决策职能。

一个生存机器对另外生存机器的行为或神经系统施加影响时,前者就是在和后者联络(communication)。这也让基因的影响力能辐射到另外的个体,产生相互的影响。即下章会提到的行为。

博弈论下的动物行为

动物间的搏斗是克制且按规则进行的,因为不分青红皂白地杀死对手并无明显的好处,在一个庞大复杂的竞争系统中,除掉一个对手不见得就是好事,但是把特定对手杀死或者至少搏斗一番是个好主意。在行为双方都可以做选择时,博弈论的理论告诉我们,在大量样本的情况下,会有进化上的稳定策略(ESS)。而偏离ESS的行为将会受到自然选择的惩罚。

(关于鹰和鸽子策略的探讨篇幅过长,建议网上查看,便于理解ESS)

进化中的稳定策略无处不在,且不止一种策略会留在最后的稳态中(稳定的多态性)。在行为双方能力不对称(这是常态)且行为双方有记忆时,开始个体的胜利或失败可能是完全偶然的,但是随着搏斗的进行,个体间会自动归类成等级,避免激烈的搏斗,从而产生了阶级。按照博弈论思路下的推导,一个种群内可能会从一个ESS跳到另一个ESS,伴随着环境不断进步。

不过,面对和自己有着很多共同基因的近亲,这种讨论就失效了。基因 + 自然选择必然会留下特地照顾自身复制的基因。这方面讨论见下一章。

亲代行为与计划生育

经过自然选择的基因必然具有一个特质:最大程度的复制自身。基因有没有一些比较合情理的“识别”自身复制的方法呢?有,其中一个就是个体的近亲。这显然是亲代对子代利他性行为普遍存在的原因。父母之爱是亲代之爱的一种特殊情况。亲代间基因的相似度显然是不同的,血缘关系越近,基因相似度越高,对应的 利他性行为也会越显著。很明显兄弟姐妹之爱不如父母之爱来得那么普遍(只从天生层面讲)。

然而父母对子代的关心不会一直持续。因为父母对一个个体的关怀可以分为两个阶段:生育幼儿养育幼儿。从基因“自私”的一面来讲,亲代理应最大限度生育后代。但是事实却不是这样,一大原因是野生动物生存困难,几乎永远不可能因衰老死亡,疾病、饥饿、捕食者种种隐私很容易导致野生个体死亡。而且可以观察到,野生生物通常会控制自己生育后代的数目,即“计划生育”,这是因为,计划生育在资源和生存条件恶劣的情况下,反而能最大限度增加子代的存活数。节制的剩余数目反而是当前环境的最优解。过度生育的个体会被自然选择惩罚。

代际的竞争

亲代对子代的投资往往是不均等的,同时对某个子代的投入,必然是以牺牲对其他子代投入为代价的。子代为了保证自己的存活,往往会竭尽所能甚至是欺骗。同时,亲代在自身随着衰老养育能力下降后,通常会有生殖能力逐渐消失的现象(尤其是雌性),因为此时对子女的投入不如对孙子孙女的投入平均回报大。雄性往往是逐渐衰退的,原因可能是,父亲对子女的投资额比不上母亲。幼儿的哺乳期不宜过长,到尚未出生的弟弟妹妹因为他继续吃奶蒙受的损失超过从他那里得来好处的数倍时,他就不应再吃下去了。广义的断奶由此出现。

然而,子代为了自己利益的最大化,和亲代的目标必然有分歧,最终的结局是两方理想条件的某种妥协。我们不要指望子代个体本性里有利他主义的成分,道德准则和基因里的本性是两回事

这里要再强调一下,从基因到个体的“自私”表现,都是经过自然选择的必然结果,不自私的基因和个体都在激烈的竞争中被淘汰了。

两性

生物大多有性别之分,尤其是有性生殖个体。在生殖上,可以说每个配偶的本性都应该会设法利用对方,迫使自己少投资,对方多投资。那两性是如何出现的呢?我们知道两种生殖细胞,一种细胞(卵细胞)较大,数量少,营养物质充足,不灵活;另外一种(精子)较小,数量巨大,身材瘦小,灵活。明显卵细胞一方投资较多,看起来是精子那一方占了便宜,为什么会这样呢?假设最开始两种生殖配子是差不多的性质,一旦这种配子间的分歧产生,性质偏向卵细胞的一方在诞生个体上就会更有优势,而偏向精子的一方在寻找对象上(量大,灵活)也会更有优势,而性质介于两者之间的生殖配子就会收到自然选择的惩罚。从而这种差异就像脱缰的野马,一发不可收拾,到两者的形态到达再继续变化就要被惩罚的稳态,即现在这个模样。

而精子代表的一方就成为了雄性,卵子一方成为了雌性。从上面的理论可以自然推测出,朝着尽量多诞生复制的目标下,雄性个体数目较之雌性个体会越来越少(因为雄性个体可以很轻易产生大量精子,对应到大范围的雌性)。但是现状很显然不是这样,男女比例是很接近的。原因是,生育一个儿子的基因较之生育女儿极有可能会复制自己出现在成为大量后代中。 在基因朝着多生育儿子的趋势前进时,自然地就平衡了之前男少女多的情况。生育相同数目的儿女的策略是进化上的稳定策略

对生育个体而言,他或她在其中投资的越少,所能生育的子女就越多。但是雌性由于卵细胞的存在,个体往往从自己体内诞生,个体一旦死亡,自己比做父亲的要蒙受更大损失。顺便补充有趣的一点,在鱼类中情况是反过来的,有一种可能是,鱼类的交配过程是共同排出生殖细胞到水中完成交配,而不是在雌性个体内。这时情况就变了,谁先排出生殖细胞就更易把责任推给另一方(值得商榷),卵细胞相对较大,在水中不易散逸,精子则很容易散逸。所以雄鱼通常得等在雌性后面排出生殖细胞,而被迫承担养育责任。

说回来,在交配完成后,双方都要冒着被对方抛弃的危险(雌性可以遗弃还未诞生的个体,所以雄性也有风险)。在这种博弈情况下,有一些常见策略,如“家庭幸福”和“大丈夫”。前者指在交配前,双方仔细观察对方忠诚和眷恋家庭生活的可能迹象,“订婚期”长对雄性个体也有利,因为他有上当受骗,抚养其他雄性个体所生子女的风险。在雄性有忠诚、薄情两种决策,雌性有忸怩、放荡两种决策下,较多忠诚和较多忸怩以及较少薄情和较少放荡会达到一种稳态。大丈夫策略下,则是根据雄性个体素质进行选择,保证子代更加健壮,从而更容易拥有交配权。素质判断的过程随着雄性偶尔的欺骗出现,会向着雄性某些表征愈发明显和雌性观察力愈发敏锐发展。表征明显得有时甚至略显夸张,这一方面也是种“炫耀”,炫耀自己拥有一些累赘也依然能活的很好。

雌性因为卵子的自愿地位,不必像雄性个体那样,仅仅具有性吸引力就能保证自己的卵子有受精机会,所以在交配问题上更加挑剔(比如雌驴会避免和雄马交配,生育出没有生育能力的骡子),雄性则相反,需要具有更加吸引人(尤其是雌性)的种种性状。同时,凡是存在乱伦禁忌的地方,可以认为雌性会比雄性更严守此这种禁忌。

共生和合作

生存机器间除了搏斗、亲代、有性参与的相互作用,还有共生和合作的存在。比如鸟群和鱼群的存在。有些群体个体会警告其余个体危险的存在等等。这些看似利他性行为实际都可以从有利自己生存的角度考虑。

蚂蚁、蜜蜂、裸鼹鼠等物种的职虫不育性是个很有趣的现象。它们的个体间营社会性生活。这其实是把生育和抚育策略结合在一起进化的结果。这个现象和雌虫的生殖特性密切相关。雌虫生育的个体都是单倍体,兄弟姐妹间基因的相似度甚至高于自己生育的后代的相似度。相比自己繁殖养育,不如“耕耘”有生殖力的母体,趋势母体提高繁殖力,复制自己的基因。同时,往往这种生物生存的环境资源有限,外出繁育ROI较低。

在共生现象中,双方都有骗子和傻瓜两种策略,但在斤斤计较策略存在的情况下,骗子策略会被渐渐战胜。有观点认为,人类细胞不过是共生微生物的结果,而我们本身也是不同基因共生的群体结果。

推广到文化

正如《人类简史》里面所说,涉及到道德、规范、法律、文化的领域,就进入了人类的想象空间。复制因子的规律能否推广到“文化因子”(meme)中是个不错的尝试。不过meme本身是可以人为操纵,而不是“盲目的”。和基因不同,数学模型和概念在此很难有用武之地,试图对人类意识的探讨更易让类比有刻意引申之嫌。

好人好报

在个体合作时,基于合作和背叛两种基本元素,可以产生很多合作策略。当合作明确只能做一次时,双方会面临“囚徒困境”,即个体的最优解不是整体的最优解。但是,合作明确会持续进行,且看不到终点时(看不到终点很重要,因为有终点存在,整个信任就会从最后一次合作的倒推开始崩塌),这个“零和问题”变成了“非零和问题”。这种情况下,拥有宽容善良不嫉妒特质的合作策略会在各种策略中获胜,并最终达到稳态。

描述过程见书第十二章,写的很有意思

总结来说,未知的长期的非零和进化博弈下,好人会取得最终的胜利,并占据大多数。

生命的必然和偶然

最后一章里,我们总结一下前面所有的结论,并试图给出更透彻、更明白的一些结论。首先,个体生物即载体以努力传播自己基因为任务,同时对基因有利对整个生命体也有利。基因所能影响的只有蛋白质合成,再操纵细胞乃至整个生存机器,并最终“从自身身体中逃逸出”,操作整个外部世界,如海狸的河坝。这里我们考虑一下“寄生”和“共生”的区别。通过一些例子来看,最终的区别是寄生个体将基因传递给后代的方式是否和宿主基因一样。享有共同命运的寄生生物基因,最终会享有共同利益,停止寄生行为。比如绿色水螅和水藻的基因,以及甲虫和细菌的基因,寄生基因只能通过宿主的生殖细胞拥有未来。

推广一下,我们自己的基因通力合作,不是因为它们共享同一个身体,而是它们共享同一条出路——精子和卵子。如果能找到一条另外的出路,一些基因自然就会表现得不再合作。比如,人体内流感病毒的基因通过飞沫传递,狂犬病病毒之于狗也是这样。基因对于个体的控制比你想象得要厉害,给一个男人看女人身体的图片,便可以唤起其性冲动,甚至勃起,而这个过程中,他并没有被欺骗,认为图片是个真实的女人。虽然他知道是打印机打出的图片,他的神经系统依然有和面对真实女性一样的反应。我们的身体不过是“寄生”基因的集合体。自然选择偏向控制他人的基因,动物行为倾向于最大化此基因的生存。

总结下,共生的基因共享离开当前基因载体的共同渠道,个体如果想成为有效的基因载体,必须保证对所有其中的基因提供等概率的、通往未来的共同通道。最后回答三个问题。

为什么基因汇聚在一起,形成细胞?,单个基因操纵的化学反应通常不足以合成所需的最终产物,一种蛋白酶需要在其他基因存在的情况下才能生长繁荣。

为什么细胞会汇聚在一起,形成多细胞?,一方面是体型的优势,另一方面是便于分工,让每一个部件处理特定任务时更有效率。

最后一个,也是最有意思的问题。为什么生命体循环总有瓶颈般的受精卵阶段?,不论大象、蚂蚁都是这样。原因是,从部分个体开始的繁殖只能获得很少一部分改变,彻底的改变,只能从“设计图纸”开始,保证每一个个体都拥有干净的起点。瓶颈保证了生命循环继承的是图纸而不是成品。另外,这种瓶颈的存在,让生命循环定型,更有规律的重复基因这种精确的行为规划是胚胎得意进化形成复杂组织和器官的先决条件,鹰的眼睛,燕子的翅膀,这些精确和复杂的器官不可能在没有时间规划下出现。最后,也是最关键一点,瓶颈的存在自然地带来的基因间的共同利益,因为所有的基因都需要通过瓶颈传给下一代,别无选择。

总结一下,生命循环成为“瓶颈状”,有生命的材料会渐渐聚在一起,形成独立而统一的生命体。生命材料越多,就有更多的载体细胞凝结努力,作用于特殊种类的细胞,使他们可以承载其共同的基因,通过瓶颈走向下一代。所有生命的基本单位和最初动力都是复制因子,没有任何复制过程是完美的,一些编译失去复制能力而灰飞烟灭。互惠的复制因子间可以帮助对方更好的生存,这些复制因子聚合一处形成了细胞,与而后形成的多细胞生命,由“瓶颈”生命循环进化而成的载体繁荣发展,逐渐变成愈加独立的载体。

smooth scroll

使用window.scroll API ,从MDN的文档来看,各浏览器的支持情况还不错。

或者自己通过setTimeout加上ease function实现难度也不大。

React在老版本浏览器或Webview下的支持问题

[JavaScript Environment Requirements

React 16依赖于ES6中的Map和Set特性。如果需要React运行在老版本的不支持ES6的浏览器或Webview下,需要babel-polyfillcore-js的支持。

类似于下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
import 'core-js/es6/map';
import 'core-js/es6/set';

// or
import 'babel-polyfill';

import React from 'react';
import ReactDOM from 'react-dom';

ReactDOM.render(
<h1>Hello, world!</h1>,
document.getElementById('root')
);

需要格外注意的是,不论是import core-js还是import babel-polyfill,都一定要写在第一次import React from 'react'的前面。否则是起不到polyfill React中ES6特性的效果的。

一些老版本Android的坑

  • 在伪元素如:before:after使用动画会导致crash,caniuse的known issues上只是说在safari v6版本和以下会有不支持的情况。
  • 不支持不带前缀的transform
  • 不支持在<line>上的stroke-width属性上使用rem
  • 不支持Element.matches方法

ffmpeg

wiki: https://en.wikipedia.org/wiki/FFmpeg
官网: https://www.ffmpeg.org/

主要包含三个命令行指令:

  • ffmpeg,多媒体转码
  • ffplay,基于SDL和ffmpeg的极简播放器
  • ffprobe,多媒体分析

ffmpeg部分支持参数:

  • -i指定输入文件
  • -t处理时间
  • -ss起始时间
  • -b:a-b:v指定音频、视频的输出码率。
  • -rfps 帧率
  • -s 1920x1080设置帧大小
  • -c:a-c:v设置音频、视频编码器
  • -ac声道数
  • -ar音频采样率

更多

React Router使用History路由时,不识别带‘.’的路径

webpack-dev-server的配置里,增加disableDotRule

1
2
3
4
5
...
historyApiFallback: {
disableDotRule: true
}
...

注,这样做会使xxx.html的形式也重定向到默认的index.html,在多入口的项目下会有问题。

React Hooks和React Hot Loader默认配置相冲突

设置RHL的pureSFC配置为true,详见讨论

1
setConfig({ pureSFC: true })

一个简单的rollup配置样例

最近有一个开发前端录音库(严格来说是改进)的需求,目标是发布到npm管理平台上,在打包库上rollup的发挥要优于webpack。刚好想用用试试,就用了rollup作为打包工具。因为场景比webpack更简单,配置上也比webpack好配很多,基本看看官方文档就可以上手了。

不过,文档里用的babel版本还是6.x,使用新版本babel后,配置文件rollup.config.js.babelrc有些改动,这里列在下面。

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
// rollup.config.js
import resolve from 'rollup-plugin-node-resolve';
import commonjs from 'rollup-plugin-commonjs';
import babel from 'rollup-plugin-babel';
import { terser } from "rollup-plugin-terser";

export default {
input: 'src/index.js',
output: {
file: 'index.js',
format: 'es'
},
plugins: [
resolve(),
commonjs(),
babel({
exclude: 'node_modules/**'
}),
terser()
]
};


// .babelrc
{
presets: [
[
'@babel/env',
{
loose: true,
modules: false
}
]
],
plugins: [
['@babel/proposal-object-rest-spread', { loose: true }]
]
}

–END–

背景

在视听类业务或重交互的业务场景下,有时需要在前端采集用户语音。前端实现录音功能可以使用MediaRecorder,或getUserMedia结合AudioContext。其中,前一种方法的支持度惨不忍睹,使用getUserMedia的方式是较为常用的选择。

现有问题

在实现前端录音上,Recorder.js实现了一个基础可用版,不过它支持的可配置项很少,音频采样率、声道数、采样的比特位数都使用的采集配置的默认值。但在大多场景下,录音文件体积较大,4s的录音可以达到700 ~ 800KB,不利于网络传输,需要录音采集参数可配置,以优化文件体积。

另外,有些场景录制的语音需要交给算法组做语音识别,对语音有特定要求:

  • 采样率16000Hz
  • 单声道
  • 采样位数16bit

这时就需要一个优化的前端录音方案,支持根据输入配置修改音频流数据。

优化

这里将原有录音方案的几个关键代码流程整理如下:

其中:

  • 先调用getUserMedia获取音频流,并初始化一个MediaStreamAudioSourceNode。使用connect连接到ScriptProcessorNode上,并连续触发audioprocess事件。
  • onaudioprocess事件处理函数中,拿到录音数据。根据当前recording的值判断是否写入recBuffers中。recording状态可以通过recordstop方法控制。
  • exportWAV方法会触发导出流程,导出步骤里
    • mergeBuffersrecBuffers数组扁平化
    • interleave将各声道信息数组扁平化
    • encodeWAV为即将生成的音频文件写入音频头
    • 最后floatTo16bitPCM将音频设备采集的元素范围在[0,1]之间的Float32Array,转换成一个元素是16位有符号整数的Float32Array中
  • 最后拿到的Blob类型数据可以本地播放或通过FormData上传服务端使用。
    下面分几方面介绍录音方案优化的设计和实现。

音频头拓展

要支持可拓展的采样率、声道、采样比特数,wav音频头也要动态配置。

WAVE格式是Resource Interchange File Format(RIFF)的一种,其基本块名称是“WAVE”,其中包含两个子块“fmt”和“data”。结构上由WAVE_HEADER、WAVE_FMT、WAVE_DATA、采样数据4个部分组成。可以看到实际上就是在PCM数据前面加了一个文件头。WAVE类型文件整体结构图如下:

其中和采样率、声道、采样位数相关的字段有:

  • NumChannels
  • SampleRate
  • ByteRate,等于SampleRate * BlockAlign
  • BlockAlign,等于ChannelCount * BitsPerSample / 8
  • BitsPerSample

这几个字段根据输入的配置项设置即可实现音频头拓展部分。
另外,需要注意的是其中字段有Big Endian和Little Endian的区分,对应在代码里,通过setUint16setUIint32的最后一个入参决定。如下所示:

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
function encodeWAV(samples) {
const buffer = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buffer);

/* RIFF identifier */
writeString(view, 0, 'RIFF');
/* RIFF chunk length */
view.setUint32(4, 36 + samples.length * 2, true);
/* RIFF type */
writeString(view, 8, 'WAVE');
/* format chunk identifier */
writeString(view, 12, 'fmt ');
/* format chunk length, PCM use 16 */
view.setUint32(16, 16, true);
/* sample format (raw), PCM use 1 */
view.setUint16(20, 1, true);
/* channel count */
view.setUint16(22, numChannels, true);
/* sample rate */
view.setUint32(24, sampleRate, true);
/* byte rate (sample rate * block align) */
view.setUint32(28, sampleRate * numChannels * sampleBit / 8, true);
/* block align (channel count * bytes per sample) */
view.setUint16(32, numChannels * sampleBit / 8, true);
/* bits per sample */
view.setUint16(34, sampleBit, true);
/* data chunk identifier */
writeString(view, 36, 'data');
/* data chunk length */
view.setUint32(40, samples.length * 2, true);

// ...

return view;
}

采样率

通常前端录音的音频采样率是音频设备默认使用的44.1kHz(或48kHz)。开发者需要默认以外的采样率时(比如16kHz),可以在录音数据交给encodeWAV封装前根据新的采样率做重采样。

1
2
3
4
5
6
7
8
9
10
function compress(samples, ratio) {
const length = samples.length / ratio;
const result = new Float32Array(length);

for (let index = 0; index < length; index++) {
result[index] = samples[index * ratio];
}

return result;
}

重采样的原理上,程序根据重采样和原始采用率的比值,间隔采样音频原数据,丢弃掉其他采样点数据,从而模拟采样率的等比例下降。

注:间隔丢弃原数据在重采样率是原采样率的整数倍分之一时(即1、1/2、1/3…)才不会损失用户音色。另外,重采样率比原采样率高时,需要在采样点中间额外插值,这里未实现;

声道数

audioprocess事件中,需要根据配置项中的声道数,从inputBuffer取对应声道数据,一般的处理下,会丢弃多余的声道数据。类似地,在存储声道数据时,也要灵活考虑配置项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
this.node.onaudioprocess = (e) => {
if (!this.recording) return;

const buffer = [];
for (let channel = 0; channel < this.config.numChannels; channel++) {
buffer.push(e.inputBuffer.getChannelData(channel));
}
// ...
};

// ...
function record(inputBuffer) {
for (let channel = 0; channel < numChannels; channel++) {
recBuffers[channel].push(inputBuffer[channel]);
}
recLength += inputBuffer[0].length;
}

在最后导出时,根据声道数判断是否需要interleave的步骤。

1
2
3
4
5
if (numChannels === 2) {
interleaved = interleave(buffers[0], buffers[1]);
} else {
[interleaved] = buffers;
}

采样位数

默认的采样位数是16位,在对音质或位数没有明确要求时,可以转成8位。

PCM16LE格式的采样数据的取值范围是-32768到32767,而PCM8格式的采样数据的取值范围是0到255。因此PCM16LE转换到PCM8需要将-32768到32767的16bit有符号数值转换为0到255的8bit无符号数值。实现上,见下面的对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function floatTo16BitPCM(output, offset, input) {
let initOffset = offset;
for (let i = 0; i < input.length; i++, initOffset += 2) {
const s = Math.max(-1, Math.min(1, input[i]));
output.setInt16(initOffset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
}

function floatTo8bitPCM(output, offset, input) {
let initOffset = offset;
for (let i = 0; i < input.length; i++, initOffset++) {
const s = Math.max(-1, Math.min(1, input[i]));
const val = s < 0 ? s * 0x8000 : s * 0x7FFF;
output.setInt8(initOffset, parseInt(val / 256 + 128, 10), true);
}
}

上方的floatTo16BitPCM是转换音频采样数据到PCM数据的原始方法,下面的floatTo8BitPCM方法中parseInt(val / 256 + 128, 10)做了16位到8位的转换。最后在封装音频数据为Blob类型时,根据采样位数使用不同函数即可。

1
2
3
4
5
6
7
8
9
function encodeWAV(samples) {
// ...

sampleBit === 8
? floatTo8bitPCM(view, 44, samples)
: floatTo16BitPCM(view, 44, samples);

return view;
}

其他

最后,由于前端录音场景下,音频流基本都来自getUserMedia,为了减少模板代码,库里封装了一个static方法,快捷地直接由getUserMedia构造一个recorder对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static async createFromUserMedia(config) {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
mandatory: {
googEchoCancellation: 'false',
googAutoGainControl: 'false',
googNoiseSuppression: 'false',
googHighpassFilter: 'false'
},
optional: []
},
video: false
});
const context = new AudioContext();
return new Recorder(context.createMediaStreamSource(stream, config));
}

使用

在之前提到了需要算法组音频识别的场景下,只需要在构造时指定配置项即可。

1
2
3
4
5
6
7
import Recorder from './audio-recorder';

this.recorder = Recorder.createFromUserMedia({
sampleBit: 16, // 可省略
numChannels: 1,
sampleRate: 16000
});

此时,一个500ms的录音大概15KB,换算下来4s大约120KB,比此前的体积小了很多。在不强调音质的场景下,表现要好许多。

小结

上面的录音方案优化实践主要包含下面几点:

  • WAVE音频头修改
  • 重采样音频数据
  • 丢弃多余的声道数据
  • 转换16位音频数据到8位

源码在这里,欢迎使用与拍砖。

参考

HooksReact v16.7.0-alpha中引入的新特性,目前(2018年10月底)还在讨论之中

关于这次改动,官网里特地表明这不是Breaking Changes,并且向前兼容,大家可以放心地使用。在动机上:

  • 使用Hooks将便于开发者拆分和复用state管理的逻辑(而不是state本身)
  • 使用Hooks将把Class组件中的React生命周期方法抽象成effects,根据需要插入
  • 除了state和生命周期方法,React还将class提供的更多features拆分出来作为额外功能,按需使用

下面将从上面几点分别展开介绍,并给出一些使用须知。

State Hook

根据上面所述,用来拆分和复用state管理逻辑。通常情况下,class组件中的state更新逻辑比较简单。和官网给的例子本质上没什么差别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { useState } from 'react';

function Example() {
// Declare a new state variable, which we'll call "count"
const [count, setCount] = useState(0);

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}

useState是State Hook提供的API方法,它只需1个入参,表示状态的初始值。返回一个pair:

  • 第一个元素,状态本身,类似this.state.xxx
  • 第二个元素,设置状态方法,类似this.setState({ xxx: 'foo' })

需要注意的是,第二个元素,设置状态的方法不是增量更新,而是直接替换,这点和setState有区别。

在下面的渲染部分,直接使用状态名即可。当然这里只声明了一个需要的状态变量,需要新的状态变量(比如:[fruit, setFruit])时,需要用同样的方法获得返回,像下面这样:

1
2
3
4
// Declare multiple state variables!
const [age, setAge] = useState(42);
const [fruit, setFruit] = useState('banana');
const [todos, setTodos] = useState([{ text: 'Learn Hooks' }]);

可以看到,使用State Hook时,如何拆分state到各useState中是需要考虑的事情。

Effect Hook

Effect Hook用来处理React每次渲染完成后的副作用。它等同于componentDidMount, componentDidUpdate, 再加上componentWillUnmount。副作用分两种,需要cleanup和不需要cleanup的。

不需要Cleanup

通常的副作用包括数据请求、DOM修改等等。这些操作不需要清理占用的资源。使用时类似下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { useState, useEffect } from 'react';

function Example() {
const [count, setCount] = useState(0);

useEffect(() => {
document.title = `You clicked ${count} times`;
});

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}

使用useEffect方法,传入一个函数作为唯一入参。这样,在每次render完成后(包含第一次render),都会执行这个函数,去完成副作用的部分。

你可能有些疑惑,如果我有某个副作用,只在componentDidMount使用一次,比如获取DOM ref这种呢?另外,每次重新渲染后,如果副作用依赖于当前的状态值,难道还需要写if语句判断状态有没有变化吗?接着,往下看。

userEffect这个方法可以有第二个入参,这个入参是数组类型,表示这个effects所依赖的内部状态。(注意:这个状态必须是上面用useState声明的)只有数组内的状态变化时,React才会去执行第一个入参的函数。

另外,数组为空时,表示函数没有依赖,即只在componentDidMount时执行一次即可。

1
2
3
4
5
6
7
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]); // Only re-run the effect if count changes

useEffect(() => {
console.log('This component has been rendered.');
}, []); // Only re-run at the first rendering

最后,useEffect是异步完成的,即不会block浏览器更新屏幕内容,以节省资源。在一些不常见的场景,如需要测量当前布局时,需要用同步的useLayoutEffect

需要Cleanup

有的副作用以添加事件监听、设置定时器等等的subscription的形式进行,这些在组件销毁后需要释放掉占用的资源,避免内存泄漏。类似你之前在componentWillUnmount里写的逻辑。

React用useEffect表示这个副作用的清除操作。用法类似setTimeout,用返回值作为handler。

1
2
3
4
5
6
7
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
// Specify how to clean up after this effect:
return function cleanup() {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});

在实际运行时,一个Effect在组件re-render时都会被重新销毁再重建,以便于在componentDidUpdate时,也能跟踪到副作用内使用的状态的最新值。上面那段代码可能会遇到下面这样的实际运行情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Mount with { friend: { id: 100 } } props
ChatAPI.subscribeToFriendStatus(100, handleStatusChange); // Run first effect

// Update with { friend: { id: 200 } } props
ChatAPI.unsubscribeFromFriendStatus(100, handleStatusChange); // Clean up previous effect
ChatAPI.subscribeToFriendStatus(200, handleStatusChange); // Run next effect

// Update with { friend: { id: 300 } } props
ChatAPI.unsubscribeFromFriendStatus(200, handleStatusChange); // Clean up previous effect
ChatAPI.subscribeToFriendStatus(300, handleStatusChange); // Run next effect

// Unmount
ChatAPI.unsubscribeFromFriendStatus(300, handleStatusChange); // Clean up last effect

为了避免这样的频繁操作影响性能,可以通过上面介绍的传第二个参数的方式优化性能。官方文档在最后还补充了一句:

In the future, the second argument might get added automatically by a build-time transformation.

一些使用准则

  • 在函数内部的最外层使用,别在块语句内使用,以保证正确的内部状态
  • 只在函数组件和自定义Hooks中使用Hooks API,以保证可读性
  • 这个eslint-plugin能帮助你检查代码风格

为什么会有看起来比较别扭的上面两条规则呢?

useStateuseEffect看到,API本身是没有状态的,并不知道API的返回赋值给了哪个变量名。所以,就像介绍里说的:

React relies on the order in which Hooks are called.

React依赖于Hooks的调用顺序,因此在每次render时,Hooks方法的调用顺序一定要保持一致

(猜测内部用类似数组的结构保存了一个函数组件内的多个Hooks)

从而,所有导致Hooks可能不按一致顺序执行的写法都不建议使用。为了保证Hooks执行顺序所见即所得,又有了第二条准则。

组合 - 自定义Hooks

Hooks除了或多或少基于React提供的Hooks外,只是再普通不过的JavaScript function而已。可以将组件中共用的状态逻辑拆分出来作为自定义Hooks。类似下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { useState, useEffect } from 'react';

function useFriendStatus(friendID) {
const [isOnline, setIsOnline] = useState(null);

function handleStatusChange(status) {
setIsOnline(status.isOnline);
}

useEffect(() => {
ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
};
});

return isOnline;
}

强烈建议用户自定义的Hooks函数也以use开头。在使用时,就像使用正常的函数即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function FriendStatus(props) {
const isOnline = useFriendStatus(props.friend.id);

if (isOnline === null) {
return 'Loading...';
}
return isOnline ? 'Online' : 'Offline';
}

function FriendListItem(props) {
const isOnline = useFriendStatus(props.friend.id);

return (
<li style={{ color: isOnline ? 'green' : 'black' }}>
{props.friend.name}
</li>
);
}

Hooks共用的是状态逻辑,使用同一个自定义Hooks创建的状态是相互隔离的

你可以发挥你的想象力,抽象共用的状态逻辑,使用组合的方式(在函数中组合,React并不建议在class组件中使用mixin)构建新组件,减少组件代码长度。

官网举了个非常简单却普遍的useReducer的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function useReducer(reducer, initialState) {
const [state, setState] = useState(initialState);

function dispatch(action) {
const nextState = reducer(state, action);
setState(nextState);
}

return [state, dispatch];
}

function Todos() {
const [todos, dispatch] = useReducer(todosReducer, []);

function handleAddClick(text) {
dispatch({ type: 'add', text });
}

// ...
}

还有什么Hooks

  • useContext,接受React.createContext作为入参,在每次provider更新后,自动用最新的context重渲染。
  • useReducer,组件状态逻辑很复杂时,代替useState使用
  • useCallback,保存一个和当前状态相关的函数,只有状态变化时,函数才会更新,避免重复创建函数。
    1
    2
    3
    4
    5
    6
    const memoizedCallback = useCallback(
    () => {
    doSomething(a, b);
    },
    [a, b]
    );
  • useMemo,保存一个和当前状态相关的值,只有状态变化时,值才会重新计算。不提供数组代表每次渲染都会更新。
  • useRef,获取DOM ref
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    function TextInputWithFocusButton() {
    const inputEl = useRef(null);
    const onButtonClick = () => {
    // `current` points to the mounted text input element
    inputEl.current.focus();
    };
    return (
    <>
    <input ref={inputEl} type="text" />
    <button onClick={onButtonClick}>Focus the input</button>
    </>
    );
    }
  • useImperativeMethods,暴露当前组件Ref给父组件
  • useMutationEffectuseLayoutEffect,类似useEffect只是同步执行,而且执行时机有区别

更多参考文档介绍

还有问题?

实践

刚好最近在某管理后台需要有用户列表页的展示,除了获取数据的副作用,只有渲染的功能,用Hooks实现起来就很自然,而在原来的范式下,因为一个额外的网络请求,就需要把functional组件转成class,随之而来的又是一系列的模板代码和声明周期函数。

使用Hooks之后的代码像下面这样:

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
// ...

function UserList() {
const [users, setUsers] = useState([]);
async function fetchData() {
const res = await getUsers();
if (res.code === 200) {
setUsers(res.data);
}
}
useEffect(() => {
fetchData();
}, []);

return (
<div className="admin-users">
<h2>用户信息</h2>
<Table
columns={columns}
dataSource={users}
pagination={false}
/>
</div>
);
}

使用原来的模式时,大概像下面这样:

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
class UserList extends Component {
constructor(props) {
super(props);
this.state = {
users: []
};
}

async componentDidMount() {
const res = await getUsers();
if (res.code === 200) {
this.setState({ users: res.data });
}
}

render() {
return (
<div className="admin-users">
<h2>用户信息</h2>
<Table
columns={columns}
dataSource={this.state.users}
pagination={false}
/>
</div>
);
}
}

虽然代码行数类似,但是代码信噪比和可拓展性明显上面更优。

感受与展望

我理解Hooks的目的并不是强行把class组件里的功能硬塞到functional组件里(虽然从用法上确实有这种感觉),推荐使用funcational组件的形式。而是一次新的复用组件逻辑方式的尝试。毕竟组合函数比组合class简单多了(React并不建议mixin)。同时通过上面的简单实践可以发现,使用Hooks之后,少了许多Spaghetti code,看上去清爽了许多,可读性也随着提高。

不过另一方面,Hooks的API初看上去挺美,挺简洁好用,那是因为最开始举例的场景简单,不需要hack。由于使用Hooks就意味着用全盘用function的形式写组件,原来用class写法写的复杂的业务组件,如果都用Hooks的方式写,也需要开发者具有一定的设计模式意识。同时在有些场景(比如上面说的prevState,prevProps)要使用比较反直觉的操作才能完成。期待后面Hooks API不断优化后的结果。

在逐渐使用Hooks的方式写组件后,业务中会有一些共用的Hooks抽象出来,整个项目目录结构也会发生变化,Hooks文件的管理方式还要再实践。期待Hooks能让每个模块代码都能小于200行的一天更早到来😌。

0%