Giter VIP home page Giter VIP logo

jocs.github.io's Introduction

Jocs's Blog

About Me

Hey, I'm Jocs, a web developer, currently living in Shanghai, I love my life and writing code , and I love to create some interesting software, such as MarkText and MindBox , if you have used it, I will be very honored, if you have any ideas to tell me, please feel free to contact me

Thanks

This blog is create by blogster, blogster is a collection of beautiful, accessible and performant blog templates built with Astro and Markdoc.

License

MIT © Jocs

jocs.github.io's People

Contributors

dependabot[bot] avatar jocs avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

jocs.github.io's Issues

《态度》读后感

最近在看吴军博士的《态度》一书,虽然书名是《态度》,我倒觉得叫"吴军家书"更为贴切,因为整书包含了吴军对他的女儿们写的总共 40 封家书,这 40 封家书涉及到不同方面的人生哲理,包括人生哲学、世界观、如何对待金钱、怎样处理好人际关系、如何高效的学习、以及做人做事的道理,整本书看完,很容易就和另外一本书联想起来,《曾国藩家书》,也是说的曾国藩对长辈、兄弟、子女的家书整理而成的。

在本篇读后感中,我希望以对吴军博士《态度》一书的理解,从高效学习、对待金钱、人际关系、做人做事四个方面说说自己的理解,同时也希望在《曾国藩家书》中,找到一些佐证。

高效学习

在第 27 封家书“上帝喜欢笨人”中,吴军博士说了一个道理,上帝喜欢笨人,原因很简单,上帝不喜欢比自己聪明的人,也其实也反映了一个人对自己能力和本领有一个正确的认知,不好高骛远,进而脚踏实地的去做事。而往往有些自以为是,自认为自己很聪明的人,他们往往高估了自己的能力,在学习、做事上去取巧,偷奸耍滑,这样反而取不到 一个好的结果。

上帝喜欢笨人,还有另外一个原因,笨人不懂得打擦边球,不懂得投机取巧,因此凡是都会留很大的余量,付出百分之三百的努力,当发生一些意外的时候,留有的余量,付出更多的努力就会起作用了,这样笨人总会把事做好,学习也是同样的道理。

在曾国藩家书中也有相应的佐证,在这个世界上,普通人总是占大多数。“凡人做一事,便须全副精神,注在此一事,首尾不懈。不可见异思迁,做这样想那样,做这山望那山。人而无恒,终身一无所成”。曾文正所要阐述上帝喜欢笨人的另外一个原因,因为知道自己笨,所以我们没有精力去做所有的事,所以不可见异思迁,这山望着那山高,同时既然决定做一件事了,那么就需要全副精神,坚持不懈,这也就是做事要专一且需恒心的道理。

对待金钱

在书中,吴军博士知道他的两个女儿,对待金钱的不同态度,小女儿梦馨因为从出生家境已经很富有,所以从小就是富养,所以对金钱没有什么概念。而大女儿相对来说很节俭。因此吴军在书信中给出了不同的建议。

对于大女儿梦华,吴军博士担心起过于节俭,因此对其建议“不乱花钱,也不乱省钱”。希望她不要过于节省,以至于不能让钱发挥它应有的作用,希望她不为钱所惑,能够从大处着眼,对于大女儿,他更多的谈到如何去花钱,“从大处着眼”。他希望,当人实现了财富自由,应该利用财富做一点其他人没有做过或者做不到的事,谷歌最早的工程师总裁韦恩罗辛一直对天文望远镜感兴趣,后来他有钱之后,建立了一个全球联网的天文台,这个天文台有两个重大的发现,证实了引力波,发现可持续爆炸的超新星。把财富运用到别人做不到或者没人做的地方更有意义。

对于小女儿梦馨,他的更多的是如何去赚钱,也许是想让小女儿知道赚钱的艰辛。金钱主要有两个用途,一是用来作为媒介,让他发挥更大的作用,比如投资赚取更多的钱,或者用他们来支持一项事业,改变我的的世界。二是用来享受生活,这方便的一些花销是必要的,但不能无节制,不能无度,这个度,最关键就是量入为出。你如果想得到什么就需要先挣钱,再花钱,这个顺序不能够颠倒,想要获得,先学会给予。在如今社会,各种信用卡、蚂蚁花呗、京东白条,商人们绞尽脑汁得让大家养成先花钱后挣钱的习惯,这往往会使得我们挣不到钱,因为钱都用来还信用卡、花呗了。

曾文正对待金钱的态度显得更加恬淡,在一个乱世之中,曾文正认为,“处此乱世,越穷越好”,做官且不可以敛财为目的,因此他的钱往往有两个用途,一是用来还债,二是用来接济亲友。同时在还债和馈赠亲友的态度上,他这样对其祖父说,“家中之债,今虽不还,后尚可还;赠人之举,今若不为,后必悔之!”。因为当时的俸禄毕竟有限,还债和馈赠亲友不可同时,曾文正选择了先接济亲友,后还债,这也是很容易理解的,在此乱世,能给借钱给别人的,家境往往还算富裕,而更多的人是吃了上顿没下顿的。

曾文正,事事节俭,同时也会存一些银两,以备不时之需。

人际关系

”交友时,不要怕吃小亏,交友时一定要真诚、大方和宽容。不要怕自己吃小亏,对别人的一些小毛病要容忍,毕竟人无完人,不要因为别人的一些确定而否定整个人”,吴军博士这些话,道出了交友的真谛。

同时在对梦华的书信中,他谈到了锻炼领导力,认为有两个方面最重要,第一个是组织工作的能力,一件事交给你,你能否将它分解,组织大家完成。第二是团结大多数人,让每个人各尽其才,发挥作用。他说道,美国的大学生有些“洁癖”,对那些夸夸其谈不愿意做事的人不齿,对那些只愿意一个人做事,不愿意合作的人反感,其实我们更应该包容各种各样的人,能够和这样的人和平相处,并且善用每人各的长处。

曾文正认为,交友和治学其实有相同之处,贵在专一,“凡事皆贵专。求师不专,则受益也不入;求友不专,而博爱而不亲。心有所专宗,而博观他途,以扩其识,亦无不可。无所专宗,而见异思迁,此炫彼夺,而大不可。”曾国藩认为,专一是打动人心最有效的方法,同时,曾文正在告诫弟弟上,告诫他们应该交什么样的朋友。“益者三友”,友直,友谅,友多闻。直就是指正直;谅是指诚实;多闻就是指见闻广博。

曾文正在写给他弟弟的信中也提到,“切勿占人便宜”,我认为这其实和吴军博士的“交友时,不要怕吃小亏”一个道理。“凡是不可占人半点便宜,不可轻取人财,情愿人占我的便宜,断不肯我占别人的便宜”。

做人做事

“今天的人,为了生存,常常不得不根据薪水的多少、名望的高低来决定自己该做什么事,很多人决定是否继续读书的理由,是能否找到更好的工作,或者能够提前两年赚钱,只有很少的人每天做的事情都是他们喜欢的工作”。

读到上面的话,似乎有一些无奈,有些时候,我们为了“一箪食”,不得不去做一些自己不愿意的事,通过“努力工作”,来浇灭从小深埋心底的梦想之火,“忙碌”占据了我们的生活,我们没有时间去思考,去改变。日复一日,年复一年。

生活也许需要复盘,有些时候我在想,如果让我从新去高考、上大学、重新走一遍,我是否依然会走到今天的地方?如果不是,是否是对现在的自己不满!人生没有如果,复盘也只是为了更好的规划未来。

“捡最重要的事情优先做”,我还记得,在学校是,考试前,老师总是对我们说,捡最简单的题先做,将做不出来的题放到最后,这样才能获得更高的分数。工作后,你会发现,工作是做不完的,这时候我们就不能因为简单的工作就优先做,二把一些难的、重要的工作放到最后,这样往往是收益甚微,我们应该游仙区做那些重要的工作,做那些有影响力的工作,对于工程师而言,简单重复的工作,交给机器做就好了。

曾文正,处乱世官场,做事显得尤为谨慎,宦海险恶,深处其中,能够平平安安上岸,实属很难,他始终保持一个清醒的头脑,以“做官不敛财,一心做实事”为做官的核心要义,不论是在京为官,还是带兵打仗,他都能游刃有余。

自动化生成 H5 骨架页面

骨架页面(Skeleton Page)指的是当你打开一个 H5 页面,在页面解析和数据加载之前,首先给用户展示页面的大概样式。在骨架页面中,图片、文字、图标都将通过灰色矩形块来展示,在真实页面展示之前,用户能够感知到即将加载页面的基本 CSS 样式和页面布局。饿了么移动 web 端骨架页面如下图所示。

骨架页面

本篇文章将给读者阐述一种自动化生成上图骨架页面的方案,通过该方案,可以将自动化生成骨架页面与你的开发流程完美结合。

为什么我们需要骨架页面

首先我们将该问题分解成两个子问题,第一我们为什么需要骨架页面。

  • 正如上文已经提及,骨架页面是在页面真正解析和应用启动之前给用户展示页面的 CSS 样式和页面布局,并通过骨架页面的明暗变化,告知用户页面正在努力加载中,让用户感知页面似乎加载得比以前快了。当应用启动、数据获取后,通过真实数据渲染的页面替换骨架页面。

  • 在骨架页面出现之前,很多应用在真实数据获取之前,都是采用 Loading 图标的形式告诉用户数据正在加载,请等待,但是用户此时无法感知即将呈现的页面,也无法确定等待的时长,千篇一律的 Loading 图标已经让用户产生了审美疲劳,长时间的等待促使用户产生等待焦虑,根据 Google Research 的研究显示,53% 的用户在等待加载 3s 后,选择关闭 Web 页面或应用,导致用户流失。而骨架页面让用户觉得数据已经加载好,只是还在渲染过程中,这也是为什么用户觉得页面加载得比之前快的原因所在。同时由于骨架页面和真实页面样式布局完全一致,在用户视觉感知上,骨架页面可以平滑的切换到真实数据渲染的页面。如果是通过 Loading 图标切换到最终页面,用户感知上会显得比较突兀。

  • 纵览当下前端框架,已然是 ReactVueAngular 三足鼎立之势,市面上大多数前端应用也都是基于这三个框架或库及相应生态圈完成的,饿了么前端项目也不例外,比如移动端H5就是使用的 Vue 库。这三大框架都有一个共同的特定,其都是 JS 驱动,在 JS 代码解析完成之前,页面不会展示任何内容,也就是所谓的白屏。用户是极其不喜欢看到白屏的,什么都没有展示,用户很有可能怀疑网络或者应用出了什么问题。 拿 Vue 来说,在应用 bootstrap 时,Vue 会对组件中的 data 和 computed 中状态值通过 Object.defineProperty 方法转化成 set、get 访问属性,以便对数据变化进行监听。而这一过程都是在启动应用时完成的,这也势必导致页面启动阶段比非 JS 驱动(比如 jQuery 应用)的页面要慢一些。

第二个子问题,为什么需要自动化生成骨架页面。

其实原因很简单,程序员都是「懒惰」的,没有哪个程序员愿意重复去做一些相同或者类似的工作,「加钱也不行」。而手动编写骨架页面正是这样的工作,重复而没有创新。既然骨架页面页面样式及布局和真实数据渲染的页面一致,只是没有图片、文字和图片的填充,那么为什么不复用页面样式及布局呢?为什么不通过工具根据真实页面自动化生成骨架页面呢?这样在节约了自己时间的同时,也为公司节省了人力成本,何乐而不为!

通过 puppeteer 生成骨架页面

生成骨架页面的基本方案

通过 puppeteer 在服务端操控 headless Chrome 打开开发中的需要生成骨架页面的页面,在等待页面加载渲染完成之后,在保留页面布局样式的前提下,通过对页面中元素进行删减或增添,对已有元素通过层叠样式进行覆盖,这样达到在不改变页面布局下,隐藏图片、文字和图片的展现,通过样式覆盖,使得其展示为灰色块。然后将修改后的 HTML 和 CSS 样式提取出来,这样就是骨架页面了。

上面描述生成骨架页面的原理可能听起来还是有些模糊,下面将通过一些 page-skeleton-webpack-plugin (简称PSWP)中具体代码片段来阐述骨架页面的生成。PSWP 是饿了么大前端一款内部生成骨架页面的工具,内部团队已经在使用,项目正在积极开发中,暂未开源,敬请期待。

在阐述具体生成骨架页面之前,先了解下 puppeteer, GitHub 上是这样介绍的。

Puppeteer is a Node library which provides a high-level API to control headless Chrome or Chromium over theDevTools Protocol. It can also be configured to use full (non-headless) Chrome or Chromium.

如果你对 Puppeteer 还比较陌生,建议在继续阅读本文之前,先了解下 Puppeteer API,我会等着你回来:alarm_clock:。

第一步:通过 Puppeteer 启动一个页面

在开始生成骨架页面之前,需要通过 Puppeteer 启动一个页面,PSWP 中通过一个 Skeleton 类来封装生成骨架页面的各种方法。代码如下:

// ./skeleton.js
class Skeleton {
  constructor(options = {}) {
    this.options = options
    this.browser = null
    this.page = null
  }

  async initPage() {
        // 第一步:用于启动一个页面
  }

  async makeSkeleton() {
      // 第二步:构建骨架页面
  }

  async genHtml(url) {
    // 第三步:根据构建的骨架页面,获取 HTML 和 CSS
  }
}
module.exports = Skeleton

在启动页面之前,我们可以通过配置来设置我们想要生成骨架页面的移动端设备,可选的设备可以通过 Puppeteer 项目中DeviceDescriptors。PSWP 中默认值是 iphone 6 Plus,当然你可以根据你的目标用户使用最多的设备来选择,目前PSWP 仅支持单一设备配置。启动页面代码如下:

// ./skeleton.js 
async initPage() {
    const { device, headless, debug } = this.options
    const browser = await puppeteer.launch({ headless })
    const page = await browser.newPage()
        // 设置模拟设备
    await page.emulate(devices[device])
    this.browser = browser
    this.page = page
    if (debug) {
      page.on('console', (...args) => {
        // do something with args
      })
    }

    return this.page
  }

从上面代码可以看出,我们可以通过传入 headless 配置来选择是否打开 headless Chrome,debug 配置用于是否在终端打印错误信息。

第二步:构建骨架页面

在这一步中,我们主要的工作就是打开的开发中页面进行 CSS 样式的覆盖,对元素进行增减,来生成骨架页面。感谢Puppeteer 提供了一个很不错的API page.addScriptTag,该方法可以将 JavaScript 代码通过 Script 标签插入到上一步打开的页面中,这样就使得我们能够直接调用 BOM 对象中方法属性了,代码如下:

// ./skeleton.js
async makeSkeleton() {
   const { defer } = this.options
   const content = await genScriptContent()

   // 将生产骨架页面的 js 代码插入到 page 中
   await this.page.addScriptTag({ content })
   await sleep(defer)
   await this.page.evaluate(async (options) => {
     const { genSkeleton } = Skeleton
     genSkeleton(options)
   }, this.options)
}

genScriptContent 方法用于获取插入 page 中的 JS 源码,还应注意一点,在PSWP 中有一个 defer 配置,用于告诉Puppeteer 打开页面后需等待的时间,这是因为,在打开开发中页面后,页面中有些内容还未真正加载完成,如果在这之前进行骨架页面生成,很有可能导致最终生成的骨架页面和真实页面不符。使得生成骨架页面失败。

其实整个生成骨架页面的核心也就是插入到 page 中的 JS 脚本了,下面笔者将重点阐述如何构建骨架页面

骨架页面生成主要发生在 genSkeleton 方法中,该方法写在插入页面的脚本中,绑定在window 对象上,这样我们就可以直接调用了。

在生成骨架页面的方案中,首先将页面根据不同元素分成不同的块,分块细则如下:

  • 文本块:包含唯一文本节点的 DOM 元素被视为文本块

  • 图片块:IMG 元素或者背景为图片的元素被视为图片块

  • SVG块:SVG 元素被视为 SVG 块

  • 伪元素块::before::after 伪类元素由于在页面中也会有展示,因此也需要做处理,被视为伪元素块

  • 按钮块:BUTTON、INPUT [type=button]、A [role=button] 等元素被视为按钮块,这儿需要注意一点,我们只将role=button 的 A 元素视为按钮块,其实如果需要将一个 A 元素视为按钮,为其添加一个 role=button 的特性是很有必要的,这也符合了前端可访问性的要求。

将元素区分为不同块后,下一步就是对这些块分别进行处理,包括元素的增减和样式的覆盖,目的只有一个,就是将这些块转化为骨架页面的样式,也就是题图中右边的样子,由于文章篇幅有限,本篇文章中仅对文本块图片块如果通过特定的算法生成骨架样式进行说明。

文本块生成算法

为了生成文本块的灰色条纹,首先我们需要知道文本块的高度,这样我们才能够绘制出灰色条纹的高度,文本块中灰色条纹的高度可以通过fontSize 来获取到,同时,如果是由多行文本生成的文本块,这样的文本块也应该是多行的,我们还需要知道文本块中行间距,幸运的是,行间距也很容易获取到。

lineHeight - fontSize 就是行间距

在多行文本下,绘制灰色条纹,还需要知道文本有多少行,这样我们才知道需要绘制多少条灰色条纹,文本行数可以通过如下公式计算:

contentHeight = ClientHeight - paddingTop - paddingBottom

lineNumber = contentHeight / lineHeight

在上面的公式中,我们首先计算了文本块内容的高度,通过ClientHeight 减去paddingTop 和 paddingBottom 来得到,而ClientHeight 通过 getBoundingClientRect API 获取,paddingTop 、paddingBottom 以及 lineHeight 可以通过 getComputedStyle 来得到,最后我们通过 contentHeight 除以 lineHeight 就能计算出文本块中究竟有多少行文本了。

有了行间距行高、以及文本块中行数我们就可以绘制我们的灰色条纹了。

相信很多人都读过@Lea VerouCSS Secrets 这本书,书中有一篇专门阐述怎么通过线性渐变生成条纹背景的文章,而本文中,绘制文本块中的灰色条纹也正是受到了 CSS Secrets 的启发,通过线性渐变来绘制灰色的文本条纹。代码如下:

const comStyle = window.getComputedStyle(ele)
const text = ele.textContent
let {
  lineHeight,
  paddingTop,
  paddingRight,
  paddingBottom,
  paddingLeft,
  position: pos,
  fontSize,
  textAlign,
  wordSpacing,
  wordBreak
} = comStyle

const height = ele.offsetHeight
// 向下取整
const lineCount = (height - parseInt(paddingTop, 10) -
     parseInt(paddingBottom, 10)) / parseInt(lineHeight, 10) | 0

let textHeightRatio = parseInt(fontSize, 10) / parseInt(lineHeight, 10)

Object.assign(ele.style, {
   backgroundImage: `linear-gradient(
     transparent ${(1 - textHeightRatio) / 2 * 100}%,
     ${color} 0%,
     ${color} ${((1 - textHeightRatio) / 2 + textHeightRatio) * 100}%,
     transparent 0%)`,
   backgroundOrigin: 'content-box',
   backgroundSize: `100% ${lineHeight}`,
   backgroundClip: 'content-box',
   backgroundColor: 'transparent',
   position,
   color: 'transparent',
   backgroundRepeat: 'repeat-y'
 })

正如上文提到,我们首先计算了行数lineCount,以及通过 fontSize 和 lineHeight 计算出了文本占整个行高的比值,textHeightRatio 这样我们就知道灰色条纹的渐变分界点了,正如 @lea Verou 所说:

摘自:CSS Secrets

“If a color stop has a position that is less than the specied position of any color stop before it in the list, set its position to be equal to the largest speci ed position of any color stop before it.”

— CSS Images Level 3 (w3.org/TR/css3-images)

也就是说,在线性渐变中,如果我们将线性渐变的起始点设置小于前一个颜色点的起始值,或者设置为0 %,那么线性渐变将会消失,取而代之的将是两条颜色分别的条纹,也就是说不再有线性渐变。

在我们绘制文本块的时候,backgroundSize 宽度为 100%, 高度为 lineHeight,也就是灰色条纹加透明条纹的高度是 lineHeight。虽然我们把灰色条纹绘制出来了,但是,我们的文字依然显示,在最终骨架样式效果出现之前,我们还需要隐藏文字,设置 color:‘transparent’ 这样我们的文字就和背景色一致,最终显示得也就是灰色条纹了。

在处理单行文本的时候,由于文本的宽度并没有整行宽度,因此,针对单行文本,我们还需要计算出文本的宽度,然后设置灰色条纹的宽度为文本宽度,这样骨架样式的效果才能够更加接近文本样式。

计算文本宽度代码如下:

  const getTextWidth = (text, style) => {
    let offScreenParagraph = document.querySelector(`#${MOCK_TEXT_ID}`)
    if (!offScreenParagraph) {
      const wrapper = document.createElement('p')
      offScreenParagraph = document.createElement('span')
      Object.assign(wrapper.style, {
        width: '10000px'
      })
      offScreenParagraph.id = MOCK_TEXT_ID
      wrapper.appendChild(offScreenParagraph)
      document.body.appendChild(wrapper)
    }
    Object.assign(offScreenParagraph.style, style)
    offScreenParagraph.textContent = text
    return offScreenParagraph.getBoundingClientRect().width
  }

这儿运用到了一个小技巧,我们在页面中创建了一个 SPAN 元素,然后将原来文本的样式赋予到该 SPAN 元素上面,同时将文本内容放到 SPAN 元素中,这样 SPAN 元素的宽度就是文本的宽度了。最后我们再根据文本的宽度来绘制灰色条纹的宽度。

const textWidth = getTextWidth(text, { fontSize, lineHeight, wordBreak, wordSpacing })
const textWidthPercent = textWidth / (width - parseInt(paddingRight, 10) - parseInt(paddingLeft, 10))
ele.style.backgroundSize = `${textWidthPercent * 100}% ${px2rem(lineHeight)}`
switch (textAlign) {
   case 'left': // do nothing
      break
   case 'center':
      ele.style.backgroundPositionX = '50%'
      break
   case 'right':
      ele.style.backgroundPositionX = '100%'
      break
 }

根据文本宽度,计算出文本在占整个元素内容宽度的一个比值,根据该比值,我们就能够设置出灰色条纹的一个宽度了。还有一点需要特殊处理,我们需要根据不同的 textAlign 来设置背景条纹在 X 轴上的偏移,这样绘制的灰色条纹才能够和原来的文本完全重合。以上就是整个文本块绘制的全部算法了,当然其中省略了一些细节,比如在真实项目中,我们使用的 rem 单位,所以我们还需要将 px 转化为 rem。也就是上面代码中的 px2rem 方法。

图片块生成算法

图片快的绘制比文本块要相对简单很多,但是在订方案的过程中也踩了一些坑,这儿简单分享下采坑经历。

最初订的方案是通过一个 DIV 元素来替换 IMG 元素,然后设置 DIV 元素背景为灰色,DIV 的宽高等同于原来 IMG 元素的宽高,这种方案有一个严重的弊端就是,原来通过元素选择器设置到 IMG 元素上的样式无法运用到 DIV 元素上面,导致最终图片块的骨架效果和真实的图片在页面样式上有出入,特别是没法适配不同的移动端设备,因为 DIV 的宽高被硬编码。

接下来我们又尝试了一种看似「高级」的方法,通过 canvas 来灰色和原来图片大小相同的灰色块,然后将 Canvas 转化为 dataUrl 赋予给 IMG 元素的 src 特性上,这样 IMG 元素就显示成了一个灰色块了,看似完美,当我们将生成的骨架页面生成 HTML 文件时,一下就傻眼了,文件大小尽然有 200 多 kb,我们做骨架页面渲染的一个重要原因就是希望用户在感知上感觉页面加载快了,如果骨架页面都有 200 多 kb,必将导致页面加载比之前要慢一些,违背了我们的初衷,因此该方案也只能够放弃。

最终方案,我们选择了将一张1 * 1 像素的 gif 透明图片,转化成 dataUrl ,让后将其赋予给 IMG 元素的 src 特性上,同时设置图片的 width 和 height 特性为之前图片的宽高,将背景色调至为骨架样式所配置的颜色值,该方案完美解决以上问题。

// 最小 1 * 1 像素的透明 gif 图片
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'

上面是1 * 1像素的 base64 格式,明显比之前通过 Canvas 绘制的图片小很多。

SVG 块、伪类元素块以及按钮块的绘制算法就不在赘述,有兴趣的话可以安装 page-skeleton-webpack-plugin 阅读源码。

第三步:根据 Puppeteer 渲染的骨架页面获取HTML 和 CSS

在第二步中,我们完成了骨架页面的绘制,接下来就是怎么去获取 HTML 和 CSS 了,然后写入到shell.html 文件中。

function getHtmlAndStyle() {
    const root = document.documentElement
    const rawHtml = root.outerHTML
    const styles = Array.from($$('style')).map(style => style.innerHTML || style.innerText)
    // ohter code
    const cleanedHtml = document.body.innerHTML
    return { rawHtml, styles, cleanedHtml }
}

获取 HTML 和 CSS 相对简单,看上面到代码就明白了,但是这样获取到的 CSS 和 HTML 还是有个问题,并不是所有的 HTML 和 CSS 样式都是骨架页面所需要的,比如首屏外的元素对于骨架页面根本不需要,由于我们在生成骨架页面的过程中删减了部分元素,而这些元素的样式依然在页面中保留,这些CSS 样式也是不需要的,因此在这一步中,关键点就是剔除掉无关的元素和 CSS 样式,也就是所谓的提取关键 CSS

删除首屏外元素

const inViewPort = (ele) => {
  const rect = ele.getBoundingClientRect()
  return rect.top < window.innerHeight
    && rect.left < window.innerWidth
}

根据上面方法判断元素是否在首屏内,如果在首屏内部,则保留,否则删除。

提取关键 CSS

这一部分代码比较多,就不贴整个代码了,简述下实现细节。

第一步从 style 元素中获取 CSS 样式,已经从 link 元素中拉去样式,接下来就是通过 css-tree 对提取出来的样式进行解析,解析出所有的 CSS 选择器及 Rules,通过 querySelector 方法来选择上面提取出来的 CSS 选择器,如果 querySelector 结果为 null 则删除该 Rule,如果能够选择上则保留。代码如下:

const checker = (selector) => {
  // other code
  if (/:{1,2}(before|after)/.test(selector)) {
    return true
  }
  try {
    const keep = !!document.querySelector(selector)
    return keep
  } catch (err) {
    const exception = err.toString()
    console.log(`Unable to querySelector('${selector}') [${exception}]`, 'error')
    return false
  }
}

上面代码就是用来判断是否保留保留 CSS 样式,需要注意一点,所有的伪类元素样式都保留,因为 querySelector 无法选择伪类元素,同时在生成骨架页面的过程中,伪类元素也都是保留的。

和 Webpack 珠联璧合

要想实现自动化生成骨架页面,还需要将上面的步骤和我们的开发流程结合起来,在开发过程中我们可以主动触发骨架页面的生成,在打包发布阶段可以将生成的骨架页面打包到最终项目中,而有了优秀的 webpack 使得上面两步变得容易很多。这也是为什么将 page skeleton 做成一个 webpack plugin 的原因之一。

PSWP 依赖于 html-webpack-plugin ,目前大部分前端项目都是用了该插件,一个重要的原因就是该插件省去了我们手动将 JS 和 CSS 插入到 html 的重复工作。PSWP 在生成项目 index.html 之前,将骨架页面插入到 index.html 中,代码如下:

compilation.plugin('html-webpack-plugin-before-html-processing', async (htmlPluginData, callback) => {
  // replace `<!-- shell -->` with `shell code`
  try {
    const code = await getShellCode(this.options.pathname)
    htmlPluginData.html = htmlPluginData.html.replace('<!-- shell -->', code)
  } catch (err) {
    log(err.toString(), 'error')
  }
  callback(null, htmlPluginData)
})

由上面代码可知,在最后打包阶段,用生成的 shell.html 中的骨架页面替换模板中的<!— shell —> 注释,这样在我们再次打开页面的时候,就能够看到骨架页面了。

最后的思考

在做 PSWP 项目的过程中,踩过一些坑过后,总结一点,我们在书写 HTML 的时候,尽量选择语义化的标签,以及添加可访问性的特性,按照 HTML 规范来书写 HTML。因为 HTML 毕竟是一门标记语言,通过不同语义的标签,也能够传达出被包裹的文本内容的一些引申含义,比如 LI 标签标识列表内容,role=button 特性代表该元素是一个按钮,这样我们在生成骨架页面的过程中也就能够按照规范来绘制骨架样式。

鉴于文章篇幅有限,本文并没有完全覆盖 PSWP 中所有细节,如果有兴趣,欢迎直接阅读源码,PSWP 是一个实验性的项目,任何问题欢迎在评论区讨论。

初识Elm语言你只需Y分钟

Share this page

Learn X in Y minutes

Where X=Elm

初识Elm语言你只需Y分钟

文章翻译自https://learnxinyminutes.com/docs/elm/

Elm is a functional reactive programming language that compiles to (client-side) JavaScript. Elm is statically typed, meaning that the compiler catches most errors immediately and provides a clear and understandable error message. Elm is great for designing user interfaces and games for the web.

Elm是一种函数响应式编程语言,可以被编译成(客户端)JavaScript。同时Elm也是一种静态类型语言,这意味着编译器能够在编译的过程中立即捕获大部分错误,并且Elm提供了清晰、易懂的错误信息。Elm语言在设计用户交互(user interfaces)和网页游戏方面表现格外出色。

-- Single line comments start with two dashes.
-- 通过两个连续破折号来进行单行注释
{- Multiline comments can be enclosed in a block like this.
{- They can be nested. -}
-}
{- 多行注释可以包含在像这样的一个代码块中。
{- 多行注释支持嵌套 -}
-}

{-- The Basics --}
{-- 基础部分 --}

-- Arithmetic
-- 算术
1 + 1 -- 2
8 - 1 -- 7
10 * 2 -- 20

-- Every number literal without a decimal point can be either an Int or a Float.
-- 没有小数点的数字字面量即可能是Int型,也可能是Float型。
33 / 2 -- 16.5 with floating point division
33 // 2 -- 16 with integer division

-- Exponents
-- 指数
5 ^ 2 -- 25

-- Booleans
-- 布尔值
not True -- False
not False -- True
1 == 1 -- True
1 /= 1 -- False
1 < 10 -- True

-- Strings and characters
-- 字符串和单个字符
"This is a string because it uses double quotes."
'a' -- characters in single quotes -- 单个字符通过单引号表示


-- Strings can be appended.
-- 字符串可以拼接(译者注:Elm中字符串拼接是通过两个加号,而JavaScript字符串拼接是通过一个加号或者
-- 模板语法)
"Hello " ++ "world!" -- "Hello world!"

{-- Lists, Tuples, and Records --}
{-- 列表,元组, Records (译者注:Records没有翻译 )--}

-- Every element in a list must have the same type.
-- 列表中的每一个元素必须是相同的类型(译者注:JavaScript中的Array中的元素可以是任意类型)
["the", "quick", "brown", "fox"]
[1, 2, 3, 4, 5]
-- The second example can also be written with two dots.
-- 上面的第二个例子也可以写成如下形式,1和5之间通过连个点表示1到5连续数字组成的list。
[1..5]

-- Append lists just like strings.
-- 列表也可以像字符串一样通过加号进行拼接
[1..5] ++ [6..10] == [1..10] -- True

-- To add one item, use "cons".
-- 通过"cons"向一个列表添加一个元素
0 :: [1..5] -- [0, 1, 2, 3, 4, 5]

-- The head and tail of a list are returned as a Maybe. Instead of checking
-- every value to see if it's null, you deal with missing values explicitly.
-- 通过head或者tail方法获取一个列表的头部或者尾部会返回一个Maybe类型。
-- 通过Maybe类型能够更明确的处理缺失值,而不需要检查list中的每个元素是否为null。
List.head [1..5] -- Just 1
List.tail [1..5] -- Just [2, 3, 4, 5]
List.head [] -- Nothing
-- List.functionName means the function lives in the List module.
-- List.functionName 意味着该方法存在于List模块中。

-- Every element in a tuple can be a different type, but a tuple has a
-- fixed length.
-- 元组中的元素可以是不同的类型,但是元组是固定长度的。
-- (译者注:JavaScript中的数组可以任意添加或删除某个元素,因此长度是不固定的。)
("elm", 42)

-- Access the elements of a pair with the first and second functions.
-- (This is a shortcut; we'll come to the "real way" in a bit.)
-- 可以通过first和second 方法来获取元组中的特定元素。
-- (这是一种快捷方式,稍后我们将使用一些“真正”的方法来获取元组中的值)
fst ("elm", 42) -- "elm"
snd ("elm", 42) -- 42

-- The empty tuple, or "unit", is sometimes used as a placeholder.
-- It is the only value of its type, also called "Unit".
-- 空元组,或者称为"unit",通常被用作占位符。
-- 空元组诗该类型的唯一值,通常也被称为"Unit"。
()

-- Records are like tuples but the fields have names. The order of fields
-- doesn't matter. Notice that record values use equals signs, not colons.
-- Records 类似元组,但是每个字断有自己的名字,并且Records对字断的顺序没有要求,需要注意的是,
-- record 中的值使用的‘等号’而非‘冒号’来表示的。
{ x = 3, y = 7 }

-- Access a field with a dot and the field name.
-- 获取record中某个字断的值可以用过dot语法形式。
{ x = 3, y = 7 }.x -- 3

-- Or with an accessor function, which is a dot and the field name on its own.
-- 也可以通过一个获取函数,通过一个‘点’号和该record的字断名作为函数名来获取该字断值。
.y { x = 3, y = 7 } -- 7

-- Update the fields of a record. (It must have the fields already.)
-- 更新record中的某一字段。(record中必须包含该字段)
{ person |
  name = "George" }

-- Update multiple fields at once, using the current values.
-- 通过record中的当前值一次更新record中多个字段。
{ particle |
  position = particle.position + particle.velocity,
  velocity = particle.velocity + particle.acceleration }

{-- Control Flow --}
{-- 控制流 --}

-- If statements always have an else, and the branches must be the same type.
-- If语句总是会紧接一个else语句,同时分支值必须是相同类型。(译者注:JavaScript中的If语句可以独立
-- else语句单独使用)
if powerLevel > 9000 then
  "WHOA!"
else
  "meh"

-- If statements can be chained.
-- If语句可以链式使用。
if n < 0 then
  "n is negative"
else if n > 0 then
  "n is positive"
else
  "n is zero"

-- Use case statements to pattern match on different possibilities.
-- 通过case语句可以对各种可能性进行模式匹配。
case aList of
  [] -> "matches the empty list"
  [x]-> "matches a list of exactly one item, " ++ toString x
  x::xs -> "matches a list of at least one item whose head is " ++ toString x
-- Pattern matches go in order. If we put [x] last, it would never match because
-- x::xs also matches (xs would be the empty list). Matches do not "fall through".
-- The compiler will alert you to missing or extra cases.
-- 模式匹配是按顺序执行。如果我们把[x]放在上面表达式最后面,作为case语句的最后一个可能性,它将永远
-- 不会被匹配到。因为x::xs也会匹配到只包含一个元素的列表(因为xs可能为空列表)。模式匹配不会"fall
-- through",也就是说,当匹配到某一可能性后,就不会再往下匹配了。当我们的case语句并没有包含所有可能
-- 性或者多余的case语句时,编译器都会报错。


-- Pattern match on a Maybe.
-- 对Maybe类型进行模式匹配
case List.head aList of
  Just x -> "The head is " ++ toString x
  Nothing -> "The list was empty."

{-- Functions --}
{-- 函数 --}

-- Elm's syntax for functions is very minimal, relying mostly on whitespace
-- rather than parentheses and curly brackets. There is no "return" keyword.
-- Elm的函数语法非常简洁,依赖空格符而非小括号或者大括号来进行函数调用或者函数申明。
-- Elm语言中的函数没有"return"语句。

-- Define a function with its name, arguments, an equals sign, and the body.
-- 通过函数名、形参、等号和函数主体这几个部分来定义一个函数。
multiply a b =
  a * b

-- Apply (call) a function by passing it arguments (no commas necessary).
-- 通过传递实参来调用一个函数(在参数之间没有逗号)
multiply 7 6 -- 42

-- Partially apply a function by passing only some of its arguments.
-- Then give that function a new name.
-- 通过传递部分参数到一个函数中来对函数进行部分应用。
-- 同时给该部分应用函数一个新的名字。
double =
  multiply 2

-- Constants are similar, except there are no arguments.
-- 定义常量和定义函数类似,除了定义常量没有参数。
answer =
  42

-- Pass functions as arguments to other functions.
-- 可以把函数作为参数传递给另一个函数。
List.map double [1..4] -- [2, 4, 6, 8]

-- Or write an anonymous function.
-- 或者通过匿名函数形式。
List.map (\a -> a * 2) [1..4] -- [2, 4, 6, 8]

-- You can pattern match in function definitions when there's only one case.
-- This function takes one tuple rather than two arguments.
-- 在进行函数定义同时只有唯一可能性的时候,我们可以使用到模式匹配。
-- 下面的例子中函数使用了一个元组作为参数而不是两个独立的参数。
area (width, height) =
  width * height

area (6, 7) -- 42

-- Use curly brackets to pattern match record field names.
-- Use let to define intermediate values.
-- 通过大括号语法可以匹配record的字段名。
-- 通过let...in...语句来定义来定义一些将要立即使用的值。
volume {width, height, depth} =
  let
    area = width * height
  in
    area * depth

volume { width = 3, height = 2, depth = 7 } -- 42

-- Functions can be recursive.
-- 函数可以递归调用。
fib n =
  if n < 2 then
    1
  else
    fib (n - 1) + fib (n - 2)

List.map fib [0..8] -- [1, 1, 2, 3, 5, 8, 13, 21, 34]

-- Another recursive function (use List.length in real code).
-- 另一个递归函数的例子(通过递归函数来实现List.length方法)
listLength aList =
  case aList of
    [] -> 0
    x::xs -> 1 + listLength xs

-- Function calls happen before any infix operator. Parens indicate precedence.
-- 函数调用的优先级比任何中缀运算符都高,使用小括号意味着更高的优先级。
cos (degrees 30) ^ 2 + sin (degrees 30) ^ 2 -- 1
-- First degrees is applied to 30, then the result is passed to the trig
-- functions, which is then squared, and the addition happens last.
-- 首先degrees方法运用到30上面,然后该方法调用的结果传递个三角函数,再然后取平方,最后才是
-- 加法运算符

{-- Types and Type Annotations --}
{-- 类型及类型推断 --}

-- The compiler will infer the type of every value in your program.
-- Types are always uppercase. Read x : T as "x has type T".
-- Some common types, which you might see in Elm's REPL.
-- 编译器会推断你的程序中的每一个值的类型。
-- 类型通常是首字母大写。Read x : T意思是"x 属于 T类型"
-- 你可以通过Elm REPL来查看Elm的常用类型。

5 : Int
6.7 : Float
"hello" : String
True : Bool

-- Functions have types too. Read -> as "goes to". Think of the rightmost type
-- as the type of the return value, and the others as arguments.
-- 函数也具有类型,'->'符号的右边是函数返回值的类型,左边是函数参数的类型。
not : Bool -> Bool
round : Float -> Int

-- When you define a value, it's good practice to write its type above it.
-- The annotation is a form of documentation, which is verified by the compiler.
-- 当你定义一个值的时候,最佳实践就是把该值的类型写在定义的上面。
-- 该类型声明是文档的一种形式,编译器可以通过该类型声明来验证函数调用的正确性。
double : Int -> Int
double x = x * 2

-- Function arguments are passed in parentheses.
-- Lowercase types are type variables: they can be any type, as long as each
-- call is consistent.
-- 当函数作为参数传递给另一个函数时,函数类型声明通常需要使用小括号括起来。
-- 小写的类型是类型参数,类型参数可以是任意类型,只要每次传递给类型构造函数都是一致的就行了。
List.map : (a -> b) -> List a -> List b
-- "List dot map has type a-goes-to-b, goes to list of a, goes to list of b."

-- There are three special lowercase types: number, comparable, and appendable.
-- Numbers allow you to use arithmetic on Ints and Floats.
-- Comparable allows you to order numbers and strings, like a < b.
-- Appendable things can be combined with a ++ b.
-- 有三种比较特殊的小写开头的类型:number、comparable、和appendable。
-- Numbers类型类允许你对Int或者Float类型的值进行算术运算。
-- Comparable类型类允许你比较数字或者字符串,比如:a < b。
-- Appendable类型类是具有如下特征的类型的集合,具有该特征的类型的值可以进行拼接运算a ++ b。

{-- Type Aliases and Union Types --}
{-- 类型别名和Union类型 --}

-- When you write a record or tuple, its type already exists.
-- (Notice that record types use colon and record values use equals.)
-- 当你书写一个record或者元组的时候,其类型已经存在。
-- (注意record类型使用冒号而record值定义时使用等号)
origin : { x : Float, y : Float, z : Float }
origin =
  { x = 0, y = 0, z = 0 }

-- You can give existing types a nice name with a type alias.
-- 通过type alias 可一个已经存在的类型去一个好听的别名。
type alias Point3D =
  { x : Float, y : Float, z : Float }

-- If you alias a record, you can use the name as a constructor function.
-- 如果你给一个record取了一个别名,你也就可以使用该别名作为一个构造函数来定义变量。
otherOrigin : Point3D
otherOrigin =
  Point3D 0 0 0

-- But it's still the same type, so you can equate them.
-- 因为无论是用别名还是最初的类型,它们始终具有相同类型,因此它们也应该相等。
origin == otherOrigin -- True

-- By contrast, defining a union type creates a type that didn't exist before.
-- A union type is so called because it can be one of many possibilities.
-- Each of the possibilities is represented as a "tag".
--  与type aliases相比之下,定义一个union 类型意味着该类型以前时不存在的。
-- 一个union type之所以称之为‘并集’类型时因为该类型可以取不同的值。
-- 并且每一个值代表一个'tag'。
type Direction =
  North | South | East | West

-- Tags can carry other values of known type. This can work recursively.
-- Tags可以包含其他已知类型的值,并且支持递归。
type IntTree =
  Leaf | Node Int IntTree IntTree
-- "Leaf" and "Node" are the tags. Everything following a tag is a type.
-- "Leaf"和"Node"是tags.任何tag之后的都是某一类型。

-- Tags can be used as values or functions.
-- Tags可以被当作某一值使用也可以作为函数调用。
root : IntTree
root =
  Node 7 Leaf Leaf

-- Union types (and type aliases) can use type variables.
-- Union types(和类型别名)可以拥有自己的类型变量。
type Tree a =
  Leaf | Node a (Tree a) (Tree a)
-- "The type tree-of-a is a leaf, or a node of a, tree-of-a, and tree-of-a."

-- Pattern match union tags. The uppercase tags will be matched exactly. The
-- lowercase variables will match anything. Underscore also matches anything,
-- but signifies that you aren't using it.
-- 对union tags进行模式匹配,大写的tags将被明确的匹配,而小写的变量讲匹配任意类型。
-- 下划线匹配任意值。
-- 但是也意味着你不会使用到下划线表示的值。
leftmostElement : Tree a -> Maybe a
leftmostElement tree =
  case tree of
    Leaf -> Nothing
    Node x Leaf _ -> Just x
    Node _ subtree _ -> leftmostElement subtree

-- That's pretty much it for the language itself. Now let's see how to organize
-- and run your code.
-- 上面的介绍对于Elm语言本身已经介绍够多了,现在让我们来组织我们的代码并让其运行起来吧。

{-- Modules and Imports --}
{-- 模块和模块引入 --}

-- The core libraries are organized into modules, as are any third-party
-- libraries you may use. For large projects, you can define your own modules.
-- Elm的核心库是通过模块来组织的,同时其他第三方的类库也是通过模块开组织的。
-- 对于大型的项目,你可能会使用到类库,同时也会定义自己的模块。


-- Put this at the top of the file. If omitted, you're in Main.
-- 把模块申明放到文件的顶部,如果没有模块申明,这些代码将在Main中。
module Name where

-- By default, everything is exported. You can specify exports explicity.
-- 默认情况下,模块中的所有函数、类型都是暴露出去的,你也可以通过如下形式暴露特定的方法或者类型到外部。
module Name (MyType, myValue) where

-- One common pattern is to export a union type but not its tags. This is known
-- as an "opaque type", and is frequently used in libraries.
-- 一种常见的模式就是暴露union type而非union type的tags到外部,该模式通常被称作"opaque type"
-- 该模式在类库中经常被使用到。

-- Import code from other modules to use it in this one.
-- Places Dict in scope, so you can call Dict.insert.
-- 通过import关键字来引入其他模块到本模块中。
-- 通过import语句,我们把Dict引入到了我们的代码中,这样我也就能够使用Dict.insert方法了。
import Dict

-- Imports the Dict module and the Dict type, so your annotations don't have to
-- say Dict.Dict. You can still use Dict.insert.
-- 引入Dict模块和Dict类型,因此你的类型注释没必要写成Dict.Dict。
-- 你依然可以写成Dict.insert。
import Dict exposing (Dict)

-- Rename an import.
-- 重命名引入的模块
import Graphics.Collage as C

{-- Ports --}
{-- 端口 --}

-- A port indicates that you will be communicating with the outside world.
-- Ports are only allowed in the Main module.
-- 一个端口意味着你可以通过该端口和外部世界通信。
-- 同时端口只允许在主模块中使用。

-- An incoming port is just a type signature.
-- 一个输入的端口其实就是一个类型注解。
port clientID : Int

-- An outgoing port has a definition.
-- 一个输出的端口需要进行定义。
port clientOrders : List String
port clientOrders = ["Books", "Groceries", "Furniture"]

-- We won't go into the details, but you set up callbacks in JavaScript to send
-- on incoming ports and receive on outgoing ports.
-- 我们并不会详细的讨论什么是端口及端口的使用,你仅需知道当我们使用端口的时候,我们还需要设置好
-- JavaScript的回调函数来发送信息到接收端口和通过回调函数来接收输出端口的信息。
-- (译者注:this part need review)

{-- Command Line Tools --}
{-- 命令行工具 --}

-- Compile a file.
-- 编译一个.elm文件。
$ elm make MyFile.elm

-- The first time you do this, Elm will install the core libraries and create
-- elm-package.json, where information about your project is kept.
-- 当你第一个使用上面的命令进行编译文件时,Elm会自动安装核心库并生成一个elm-package.json文件,
-- 该文件用来保存你项目的主要信息。

-- The reactor is a server that compiles and runs your files.
-- Click the wrench next to file names to enter the time-travelling debugger!
-- reactor是一个服务器,用来编译和运行你的代码。
-- 点击文件名旁边的扳手符号可以启动时空之旅来进行debugger。
-- (译者注:the part need review)
$ elm reactor

-- Experiment with simple expressions in a Read-Eval-Print Loop.
-- 可以通过如下命令在Read-Eval-Print中进行一些代码片段的测试。
$ elm repl

-- Packages are identified by GitHub username and repo name.
-- Install a new package, and record it in elm-package.json.
-- 包名通常通过GitHub的用户名和仓库名来标示。
-- 安装一个Elm依赖包,elm-package.json会自动记录该依赖包。
$ elm package install evancz/elm-html

-- See what changed between versions of a package.
-- 通过如下命令我们可以看到某一依赖包在不同版本下的差异。
$ elm package diff evancz/elm-html 3.0.0 4.0.2
-- Elm's package manager enforces semantic versioning, so minor version bumps
-- will never break your build!
-- elm 依赖包的管理使用了语义化的版本号,因此微笑的版本差异永远不会影响到你代码的构建。

The Elm language is surprisingly small. You can now look through almost any Elm source code and have a rough idea of what is going on. However, the possibilities for error-resistant and easy-to-refactor code are endless!

Elm编程语言惊奇的简洁,你现在可以浏览几乎所有的Elm源码并对Elm语言有一个初步的认识,了解Elm语言是怎样运行了。然而,写出稳健并且容易重构的代码依然是无止尽的。

Here are some useful resources.

下面是一些有用的资源。(译者注:以下内容不做翻译)

Go out and write some Elm!

停止阅读文章,开始写几行Elm代码吧!

《曾国藩传》文摘及感悟

为什么看这本书?

大概两年前,买了一本《曾国藩家书》,随后对曾国藩感兴趣来,在这本家书中,他描述了对治学、交友、理财、养生、行军等看法,特别是治学这部分,读到“一书未看完,不摸下一本书”,感触颇多,之前经常很多书随手翻翻,理解都很肤浅,自从坚持把一本书看完再看下一本书之后,对书内容理解也会深刻许多。《曾国藩传》主要描述了他十几年的京官生涯、以及之后创办湘军、任直隶总督的整个人生历程,出于对其个人崇拜,上个月把这本书看完了

本文主要以摘要和我的感悟为主,不含整书架构,想了解全书内容的欢迎直接看书

如何洗刷自己身上的鄙俗之气?

(摘要部分通过引用标注,下同)
如何洗刷自己身上的鄙俗之气,成了曾国藩新的焦虑

解读:曾国藩高中进士之后,衣锦返乡,在家修整了将近一年,于1339年开始了他的京官生涯。初入京城,踌躇满志,但一段时日之后,发现自己身上有很多严重缺陷,在一封家书中感慨:“兄少时天分不甚低,厥后日与庸鄙者处,全无所闻,窍被茅塞久矣。”,遂下定决心,多读书、交友,学做圣人,洗脱身上的鄙俗之气

感悟:这也是我常问自己的问题,其一唯有多读书,读好书,多读书并不仅仅是数量之多,更应该是书籍涉猎的广度,技术人也不要局限于技术书籍,需跳脱出来,多看一些人际交往,管理,经济,历史,心理学方面的书籍,这样才能把根基筑牢。读好书,应该是去读各个学科门派有名学者的书,并且取其精华,去其糟粕。其二摒弃身上鄙俗之气在于多和在某些方面比自己优秀的人交流,向他们请教治学处世之道

关于中心化系统的思考

读书人掏心掏肝的血诚,只变成了办事员纸篓中的废纸

解读:道光去世,咸丰皇帝继位,刚开始咸丰皇帝“发下求言诏书”,希望大臣们多提意见,广开言路,这时候曾国藩以为遇到了明君,写下了很多针砭当时京官无作为的奏折,但是咸丰反应平平,只是随口夸了几句就没有下文。刚开始求言的时候,咸丰确实诚心诚意。全神贯注一篇一篇认真阅读,但是几个月后,精力耗尽,很多奏折看一遍批阅一个好,就再也不理了。正如上面所说:“读书人掏心掏肝的血诚,只变成了办事员纸篓中的废纸”

感悟:从系统设计的角度,这是否是中心化系统的通病呢?中心化系统存在单点故障导致宕机风险,封建王朝所有奏章都需要皇帝批阅?一旦皇帝昏庸无能,因循守旧,也必将导致一个王朝的衰败

平等对话

管辖只论差事,不甚计较官阶

解读:为了镇压太平天国运动,曾国返开始组建自己的湘军,当时绿营兵毫无战斗力,所以只能组建一支和绿营兵全然不同的军队。这支军队:“勇丁帕首短衣,朴诚耐苦,但讲实际,不事虚文。营规只有数条,此外别无文告,管辖只论差事,不甚计较官阶。而挖壕筑垒,刻日而告成,运米搬柴,崇朝而集事。”,就是说,湘军人人都能吃苦,官员不在乎级别差异,只看谁负责哪一滩事。

感悟:上面这段话描述其实和字节价值观挺相符,No Title,在讨论问题或者在进行事故复盘,就事论事,勿牵扯到对人的评价,因为我们也很难通过一件事情上的对错和成败来判断一个人做事能力和管理能力的问题。其实还有一句话:“我劝天公重抖擞,不拘一格降人才”,天下英才众多,但是每个人都有他的长处,公司、团队在吸纳人才时,也应该充分考虑到人才和职位的匹配,将合适的人才放在适当的位置,充分发挥其才能

汰旧换新

汰旧换新

解读:在湘军取得战事胜利时,曾国藩并没有得意洋洋,他做事有个特点:“功虽大而不喜,过虽小而必究”,在战事失败后,他也会去总结教训,对自己深刻反省。他住在长沙城外妙高峰上,总结战斗的成功经验与失败教训,决定对湘军来一次汰旧换新

感悟:曾国藩淘汰掉了能力差表现不好的将领和士兵,不任人唯亲,淘汰也不忌讳亲属。奖励作战英勇的将领,授予更大的权利。其实很多公司内部机制也都如是,之前在看一本书中说道,不能把公司比作家庭,因为家庭成员趋于稳定,靠感情和亲情的维系。在公司,更多是价值观认同,个人价值实现及创造商业价值。公司会汰旧换新,吸纳更多优秀人才。员工由于个人发展的需求,也不太可能在一家公司待一辈子

再忙也坚持读书

直隶总督的工作量,是他做两江总督时的三倍。每天居然抽不出一点儿时间来读书,以至于让曾国藩感觉每天过得味同嚼蜡

解读:曾国藩在任直隶总督时,每天处理很多工作,没有时间来读书,于是感慨味同嚼蜡

感悟:再忙也坚持读书,“学于古,则多看书籍;学于今,则多觅榜样”,在看《更富有、更睿智、更快乐》一书,也有相似观点,巴菲特有个特点,即使到了老年,他仍然是一台“持续学习的机器”,每天阅读五六个小时。郞其斯也说到:“我尝试每天读4—7小时的书,我没有其他爱好,我这辈子从没打过高尔夫球……我的个性就是这样,我总是想变得更明智一些,因此得不断学习”。

当我们忙于工作、疲于生活的时候,我们变得“懒惰”,很少有时间去独立思考,越是这时候,越应该拿一本书“品茗”,找一位朋友聊天

开源 - 汲取和共享

Hi, 大家好,很高兴来参加这次平安云主办的「我与 GitHub 的故事」的分享会,先简单的自我介绍下,我叫罗冉,GitHub 账号 @Jocs,是一名前端工程师,之前在饿了么大前端,目前在石墨文档任职,工作之余,开发一款叫做 marktext 的 markdown 编辑器。

今天的分享主要分为以下三个部分:

汲取养分,茁壮成长

参与开源社区,贡献微博之力

主导开源项目的得与失

汲取养分、茁壮成长

相信很多 GitHub 新人都遇到过这样的问题:

开源就是奉献和付出,但是我作为 GitHub 新人,我几乎什么都不会,怎么参与到开源呢?

我认为上面的话,即对又不完全对,无论是普通开发者还是技术大牛在其职业生涯早期都有处于新手的阶段,在这个阶段我们参与到开源更多的是汲取一些养分,让我们的技术快速成长,作为一名前端工程师,我举一个自己的例子。我本科学习的是生物科学,在我 26 岁之前我几乎没有写过一行代码,后来我转行做程序员,在刚进入到新公司时,我几乎什么都不会,一切都需要从头学起,公司项目是使用 Angular.js 开发,但是当时我连 Angular.js 是什么都不知道,只会一些简单的 JavaScript API,根本没有办法接手公司的项目,好在当时的领导给了我一个月的时间,让我去学习一些基础知识,包括 Angular.js 等框架,在这一个月中,我看了两三本关于 JavaScript 和 Angular.js 的书籍,并且将 Angular.js 的源码从 GitHub 上 clone 下来,放到了 iPad 上面,在利用上下班的地铁上,阅读着 Angular.js 的源码,那个时候才知道,原来代码还可以写得这么美妙、优雅。

当然,一个月后,我顺利的接手了那个 Angular.js 项目,为其添加了一些新的功能,对一些组件进行了抽象和提取,并且移除了项目中所有的 jQuery 代码,增强了代码的可维护性。

其实在这个阶段,对于开源社区,我们几乎是没有奉献和付出的,但是并不能够说我们没有参与到开源社区,还是比如前端工程师,在技术选型以及开发的过程中,我们几乎做不到不和开源绝缘的,我们几乎所有的前端开发工具都是基于 node.js 开发的,比如说 babel、webpack、karma 等,在选择前端框架的过程中,我们可以在 React、Vue、Angular 等众多前端框架或库中做选择,而这些框架、库、工具,都是开源的。站在这些“巨人”的肩膀上,我们更应该去积极思考,什么样的工具或者框架才是真正适合目前项目的,打包工具有 webpack 和 rollup 等,我们选择哪一个?前端框架又有 React、Vue 等,结合目前项目的需求及团队成员的现状,我们又该怎么选?当这些问题都有答案后,其实我们也就成长了。

参与到开源社区、贡献微薄之力

在参与到开源项目之前,我们首先需要回答一个问题:

我们为什么要给开源社区做贡献?

我建议你直接去阅读这篇文章 How to contribute to Open Source ,文章内提到了很多原因,包括提升知识技能、结识有志同道合的朋友、获取声望以及获得一些与人沟通的技巧回馈开源社区等等,这儿我分享一下为什么我热爱开源并且强烈的想参与到开源的初衷。

我是 2014 年底注册 GitHub 账号的,但是到 2015 年 12 月份,我才在 GitHub 上提交第一个 PR。虽然只是一个给代码库升级依赖,将 babel 从 5.x 版本升级到 6.x 版本,但是你不知道当 PR 合并后,我有多么高兴,我第一时间将这个消息告诉当时公司的一位大牛,也是我的职业导师,当然他并没有表现出多么惊奇,但那真真确确的是我第一次从开源奉献中获得了乐趣。正如 Linux 作者 Linus Torvalds 的自传的书名一样:

Just for Fun

当我们明确地知道我们为什么要给开源做贡献后,下一步就是如何给开源社区贡献自己的力量。

怎么去选择你想参与的开源项目?

我的回答是不要为了贡献而贡献,一定参与自己喜欢的项目中,并且这个项目在自己的学习工作中是真实用到的。因为只有你真正用到的项目,你才能够发现项目中存在的问题,当你使用目标项目中,发现问题后,你的第一反应应该是先去它的 issue 页面进行搜索,看是否已经有有人提交过相同的 issue 了,避免重复提交。如果还没有人提交过类似的 issue,那么机会就来了,我们可以为该项目建一个该问题的 issue,一般的大型项目都有固定的 issue 格式,所以在填写 issue 的时候,一定需要按照他们要求的格式来填写。

在 issue 中最好先感谢项目作者和维护者的无私奉献,因为没有他们的无私劳动,我们根本没法使用上他们的开源项目。

当你提交完 issue 后,就可以静待作者的回复了,如果问题真的存在,那么作者可能会希望你和他一起讨论问题的解决方案,或者是希望你直接提交 PR 来修复该问题。

从 issue 到 PR

就我个人而言,我是很欢迎 issue 提交者能够直接给出问题的修复方案,并且提交一个相关的 PR 的,所以我也建议当你提交一个 issue 并被确认后,你可以和作者沟通,提出想要修复该问题,是否可以提交一个 PR?我相信几乎所有的作者都是很乐意看到你提 PR 的。

最熟悉一个项目的人肯定是项目的作者和维护者,所以在你提交 PR 的过程中,有任何问题,包括代码风格,解决方案等都可以在 PR 的评论中和作者直接沟通,他们也会提出代码审核的意见,直到帮助你完成合并 PR。

我不会写代码,我还有机会为开源做贡献吗?

很多人对开源的狭隘理解就是贡献代码,其实除了贡献代码,我们还有很多方式参与到社区,如果你热爱写作,你可以帮你喜爱的项目翻译文档,或者是修改文档中的一个 typo。如果你熟悉产品或项目,你可以在 issue 中和作者就一些问题的解决方案展开讨论,也许正是你提出的解决方案,解决了项目中的大难题。如果你熟悉项目代码,但是没有时间贡献 PR,你可以去 review 项目中的 PR,项目 PR 并不是只有作者和维护者才有权利去 review,所有贡献者都可以去审查别人的 PR,并提出一些意见或者建议。如果你不擅长写代码,但是你懂设计,你可以去帮助一些开源项目设计项目的 logo,marktext 的 logo 就是由 marktext 的使用者贡献的。

其实我们还有一些其他方式参与到开源社区,比如在 Stack Overflow 上回答一个你熟悉的项目中使用问题,并且得到了采纳。或者写一些开源项目的源码分析的文章,或者是使用技巧,帮助一些项目初期使用者能够快速上手项目。

主导开源项目的得与失

很多人会产生这样的疑问,参与开源都需要自己主导一个开源项目吗? 我的回答是否定的,是否去主导一个开源项目(开坑)其实与很多因素有关,如果你想做一个项目,首先需要去了解,在目前的开源社区有类似的项目了吗?如果有的话,我建议直接参与到该项目中,帮助他们解决一些问题,或者实现你需要的特性。为什么不是直接开一个新的项目呢?客观上来讲,新开项目,如果和已有的项目功能相同或相近,新开的项目只会造成没必要的冗余,在经济学上来说也不是帕雷托最优的。主观上来说,新开项目需要消耗比较大的时间和精力,如果你不能够保证你有足够的时间和精力,以及一颗负责到底的决心,建议在开新的开源项目前许慎重考虑,因为一旦选择了远方,你就须砥砺前行

既然是「我与 GitHub 的故事」主题分享,我们还是回到故事上面。

为什么选择开发 marktext?

在 2017 年底,当时公司团队成员都需要写一些知乎专栏文章,主要是记录下自己在工作中的一些技术总结和解决问题的方案。因为平时写一些 blog 都是使用的 markdown 格式,而开源社区好用的 markdown 编辑器又很少,几乎都是左右分栏,而没有所输及所得的预览模式。于是当时就下定决心写一款简单、优雅并且实时预览的 markdown 编辑器,marktext 就这样诞生了。

如何规划开源项目,让更多的贡献者参与进来?

在项目初期,我预计是三个月能够完成基础功能版本,达到可用的程度,但是开发三个月过后,才发现自己还是太乐观了,我仅仅完成了支持 markdown 的一些基本语法,甚至还不能够打开文件和保存文件。我意识到,凭我一己之力几乎无可能在短时间内完成这个项目。于是我决定把项目开源出去,让更多的 markdown 爱好者参与到项目中来,后来项目的发展也证实了我的选择是正确的。

在开源之前我做了如下一些准备工作:

  1. 完善 README 文件,README 文件中包括项目的介绍,marktext 支持或者将要支持的一些特性,这些特性区别于其他 markdown 编辑器,这样才能够吸引有特殊需求的使用者和贡献者。开发者文档,即开发者怎么能够快速的上手项目,并且如何做出贡献。

  2. 添加开源协议,通常是一个 LICENSE 文件,我选择了 MIT 协议,它是一个广泛使用的开源协议,并且我对别人使用 marktext 的代码也没有过多要求,并将协议链接添加到 README 中。

  3. 我依然担心贡献者想贡献代码时无从下手,所以我又在项目中添加了一个 CONTRIBUTING.md 文件,在这个文件中包括提交 issue 的规范,需要遵从 issue template,提交 PR 的一些规范,以及一些教开发者快速上手、项目结构的信息还有项目的代码风格等。并且将这个文件链接到 README 文件中,让开发者能够快速找到。

  4. 最后一步,也是相当重要的一步,项目的 ROADMAP,也就是说项目的蓝图或者规划,只有有了项目的规划,你的潜在贡献者才知道他们的 feature requist 是否已经在计划之中了,或者这个项目是否是他们所期许的那样,同时项目的规划一旦定下来,就不能随意在更改,不然这和没有项目规划没有两样。同时项目规划在之后解决一些成员间的意见冲突以及回复 issue 中的问题时,都会发挥作用。

有了上面这些准备,那么我们就可以一边继续开发项目,一边等待着开发者的第一个 PR 或者 issue 了。但是有些时候总是事与愿违,开源的前一个月也只有我一个在贡献代码,这个时候我有些着急了,我找我的朋友,我的同事,让他们来体验 marktext,希望他们能够提出一些改进意见,终于我一个朋友在体验 marktext 之后,提交了 marktext 第一个 PR,将 marktext 的打包后的体积缩减了 10+ M。

规范成员职责,同时制定项目规范

在 2018 年三月,Mark Text 组织迎来了除我以外的另一位成员,Felix Häusler,他是 Osnabrück 大学(奥斯纳布吕克大学)的一名在校学生,年仅 21 岁,已经相当熟悉 c++,并且在之前不了解前端开发的前提下,为 marktext 修改了好多 bug 和 添加特性。

Felix 对 Windows 以及 Linux 系统下的开发打包相当熟悉,因此我邀请他加入 Mark Text 组织,并且负责 Windows 和 Linux 系统下兼容性问题,以及项目的新版本发布,他也欣然接受了,如果没有他的加入,Mark Text 绝不可能发展到目前的规模,谢谢他。

在后来,marktext 多次登上 GitHub Trending,为 marktext 带来了一些流量,同时与之俱来的是,爆发式的 issue,这些 issue 都是千奇百怪的,没有统一的格式,有的 issue 只是一句话,因此我们不得不一条一条的去询问 issue 提交者,问他 issue 怎么复现,什么操作系统、marktext 的版本等,这个重复的工作量是巨大的,于是我们制定了 issue templates 和 PR templates,用来规范提交 issue 和 PR 的格式,它为我们减轻了很大一部分的工作量。

开源与工作、学习的关系

正如上面提到,Felix 还是一个大学生,有自己的学业,开源也只是他的兴趣爱好,因此到期末考试前,他告诉我没有多少时间参与到开源项目中。我是这样回复他的:

Academics should be ranked first. This is related to your personal development in the future. Open source is more like a hobby. We have free time and are willing to spend free time on open source projects and have fun.

这也许就是我看待开源和工作、学习的关系吧,我仅仅将开源作为一种爱好,并且愿意将业余时间花到开源项目中,而这一切仅仅是因为开源能给我带来乐趣,我不会把这种兴趣爱好转变为工作,因为我担心这一兴趣爱好一旦和 deadline 有关后,而失去我做开源的初衷。

开源的得与失

到这,我们终于进入到了这一部分的重点,在做开源项目的过程中,我得到了如下收获:

  1. 我认识了很多朋友,他们可能是贡献者,或者是 markdown 的爱好者,或者是编辑器爱好者。

  2. 提升了审查代码的能力,在曾经一段时间,每天都需要 review 好几个 PR,有时一个 PR 超过上千行(往往是新特性),这个时候审查代码就需要格外小心了,首先,我需要把代码拉去到本地,在本地运行一遍,确保代码执行没有问题。其次才是进入到审查代码的阶段,逐行阅读其增删的代码,在不解的地方进行批注,并希望 PR 的提交者进行回复。保证代码没有问题后,最后,看是否通过 CI 测试,是否需要添加新的测试。当测试都通过后,就进入到了合并阶段。

  3. 合作的乐趣,各自分工,在各自擅长的点上为项目添砖加瓦,我擅长写编辑器部分,但是我并不擅长英文写作文档以及回答 issue 中的问题,组织中的另外一位成员就很好的弥补了我的缺点。

  4. 为其他库修改 bug 的乐趣,在自己主导开源项目后,你也会用到其他的开源库,比如在 marktext 中,的markdown 解析器就是 fork 的 marked,并做了一些扩展,因为修改了一些解析 markdown 的 bug,同时我也把这些 patch 提交到了 marked 的项目中。又比如项目中使用 prismjs 来高亮代码,但是在使用过程中也遇到了一些小问题,在查找问题的时候,顺便也就一起帮 prismjs 修改了。

也并不是没有失去,因为做开源项目,我更少的时间陪伴家人,周末大部分时间都在写代码,审查 PR,以及解决 issue。因为过分专注一某个项目,让我很难在找到新项目的新鲜感,有时会产生代码疲劳,没有更多的时间去学习新的知识和技能。

未来不可期

可能有一天,marktext 已经达到了我想要的样子,并且有足够的开源爱好者参与到 marktext 项目中来,并且在没有我的参与下,她也能够井井有条的维护下去,那一天,可能就是我退出 marktext 的时候了,但是在这之前,正如我前文中所说的那样,一旦选择了远方,就必须砥砺前行

也有可能有一天,我会离开开源社区,但是做开源的初衷我不会忘记,在享受汲取、奉献和付出的同时,享受开源带来的乐趣。

JavaScript Errors 指南

JavaScript Errors 指南

文章翻译自:https://github.com/mknichel/javascript-errors

在README文件中包含了这么多年我对JavaScript errors的学习和理解,包括把错误报告给服务器、在众多bug中根据错误信息追溯产生错误的原因,这些都使得处理JavaScript 错误变得困难。浏览器厂商在处理JavaScript错误方面也有所改进,但是保证应用程序能够稳健地处理JavaScript错误仍然有提升的空间。

关于本手册测试用例可以从下面这个网站找到:https://mknichel.github.io/javascript-errors/

目录

Introduction

捕获、报告、以及修改错误是维护和保持应用程序健康稳定运行的重要方面。由于Javascript代码主要是在客户端运行、客户端环境又包括了各种各样的浏览器。因此使得消除应用程序中 JS 错误变得相对困难。关于如何报告在不同浏览器中引起的 JS 错误依然也没有一个正式的规范。除此之外,浏览器在报告JS错误也有些bug,这些原因导致了消除应用程序中的JS 错误变得更加困难。这篇文章将会以以上问题作为出发点,分析JS错误的产生、JS错误包含哪些部分、怎么去捕获一个JS错误。期待这篇文章能够帮助到以后的开发者更好的处理JS错误、不同浏览器厂商能够就JS错误找到一个标准的解决方案。

JavaScript 错误剖析

一个JavaScript 错误由 错误信息(error message)追溯栈(stack trace) 两个主要部分组成。错误信息是一个字符串用来描述代码出了什么问题。追溯栈用来记录JS错误具体出现在代码中的位置。JS 错误可以通过两种方式产生、要么是浏览器自身在解析JavaScript代码时抛出错误,要么可以通过应用程序代码本身抛出错误。(**译者注:例如可以通过throw new Error() 抛出错误)

产生一个JavaScript 错误

当JavaScript代码不能够被浏览器正确执行的时候,浏览器就会抛出一个JS错误,或者应用程序代码本身也可以直接抛出一个JS错误。

例如:

var a = 3;
a();

在如上例子中,a 变量类型是一个数值,不能够作为一个函数来调用执行。浏览器在解析上面代码时就会抛出如下错误TypeError: a is not a function 并通过追溯栈指出代码出错的位置。

开发者也通常在条件语句中当条件不满足的前提下,抛出一个错误,例如:

if (!checkPrecondition()) {
  throw new Error("Doesn't meet precondition!");
}

在这种情况下,浏览器控制台中的错误信息如是Error: Dosen't meet precondition!. 这条错误也会包含一个追溯栈用来指示代码错误的位置,通过浏览器抛出的错误或是通过应用本身抛出的错误可以通过相同的处理手段来处理。

开发者可以通过不同方式来抛出一个JavaScript 错误:

  • throw new Error('Problem description.')
  • throw Error('Problem description.') <-- equivalent to the first one
  • throw 'Problem description.' <-- bad
  • throw null <-- even worse

直接通过throw 操作符抛出一个字符串错误(**译者注:上面第三种方式)或者或者抛出null 这两种方式都是不推荐的,因为浏览器无法就以上两种方式生成追溯栈,也就导致了无法追溯错误在代码中的位置,因为推荐抛出一个Error 对象,Error对象不仅包含一个错误信息,同时也包含一个追溯栈这样你就可以很容易通过追溯栈找到代码出错的行数了。

Error Messages

不同浏览器在就错误信息的格式有不同的实现形式,比如上面的例子,在把一个原始类型的变量当做函数执行的时候,不同浏览器都在试图找到一个相同的方式来抛出这个错误,但是又没有统一标准,因此相同的形式也就没有了保证,比如在Chrome和Firefox中,会使用{0} is not a function 形式来抛出错误信息,而IE11 会抛出Function expected 错误信息(IE浏览器甚至不会指出是哪个变量被当做了函数调用而产生错误)

然而,不同浏览器在就错误信息上也有可能产生分歧,比如当switch 语句中有多个default 语句时,Chrome会抛出 "More than one default clause in switch statement" 而FireFox会抛出"more than one switch default". 当新特性加入到JavaScript语言中时,错误信息也应该实时更新。当处理容易产生混淆代码导致的错误时,往往也需要使用到不同的处理手段。

你可以通过如下地址找到不同浏览器厂商在处理错误信息上面的做法:

error message warning Browsers will produce different error messages for some exceptions.

追溯栈格式

追溯栈是用来描述错误出现在代码中什么位置。追溯栈通过一系列相互关联的帧组成,每一帧描述一行特定的代码,追溯栈最上面的那一帧就是错误抛出的位置,追溯栈下面的帧就是一个函数调用栈 - 也就是浏览器在执行JavaScript代码时一步一步怎么到抛出错误代码那一行的。

一个基本的追溯栈如下:

  at throwError (http://mknichel.github.io/javascript-errors/throw-error-basic.html:8:9)
  at http://mknichel.github.io/javascript-errors/throw-error-basic.html:12:3

追溯栈中的每一帧由以下三个部分组成:一个函数名(发生错误的代码不是在全局作用域中执行),发生错误的脚本在网络中的地址,以及发生错误代码的行数和列数。

遗憾的是,追溯栈还没有一个标准形式,因此不同浏览器厂商在实现上也是有差异的。

IE 11的追溯栈和Chrome 的追溯栈很相似,除了在全局作用域中的代码上有些差异:

  at throwError (http://mknichel.github.io/javascript-errors/throw-error-basic.html:8:3)
  at Global code (http://mknichel.github.io/javascript-errors/throw-error-basic.html:12:3)

Firefox 的追溯栈如下格式:

  throwError@http://mknichel.github.io/javascript-errors/throw-error-basic.html:8:9
  @http://mknichel.github.io/javascript-errors/throw-error-basic.html:12:3

Safari 的追溯栈格式和Firefox很相似,但是仍然有些出入:

  throwError@http://mknichel.github.io/javascript-errors/throw-error-basic.html:8:18
  global code@http://mknichel.github.io/javascript-errors/throw-error-basic.html:12:13

所有的浏览器厂商追溯栈基本信息差不多,但是格式上有些差异:

在上面Safari追溯栈的例子中,除了在追溯栈格式上和Chrome有差异外,发生错误的列数也和Chrome和Firefox不同。在不同的错误情境中,行数也会有所不同,比如如下代码:

(function namedFunction() { throwError(); })();

Chrome 会从throwError()开始计数行数,而IE11会从上面代码开始位置计算行数。这些不同浏览器之间在追溯栈格式上和计数上的差异也为后期解析追溯栈带来了困难。

See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/Stack for more information on the stack property of errors.

通过如下网站 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/Stack 了解更多关于追溯栈的问题。

stack trace format warning 不同浏览器厂商在追溯栈格式以及列数上都有可能存在差异

深入研究,浏览器厂商关于追溯栈还有很多细微差异,将在下面部分详细讨论。

为匿名函数取名

默认情况下,匿名函数没有名字,同时在追溯栈中要么表现为空字符串要么就是Anonymous function(根据不同浏览器会有区别)。为了提升代码的可调试性,你应该为所用的函数添加一个函数名,以使得其在追溯栈中出现,而不是空字符串或者Anonymous function。最简单的方法就是在所有的匿名函数前面加一个函数名,甚至该函数名不会在其他任何场合使用到。如下:

setTimeout(function nameOfTheAnonymousFunction() { ... }, 0);

上面代码的改变将使得追溯栈中也发生如下改变,从

at http://mknichel.github.io/javascript-errors/javascript-errors.js:125:17

变成了如下形式

at nameOfTheAnonymousFunction (http://mknichel.github.io/javascript-errors/javascript-errors.js:121:31)

上面给匿名函数添加姓名的方法可以保证函数名出现在追溯栈中,这样也使得代码更易调试,通过如下网站你可以了解更多关于代码调试的信息。 http://www.html5rocks.com/en/tutorials/developertools/async-call-stack/

将函数赋值给一个变量

浏览器通常也会使用匿名函数赋值给的变量作为函数名,在追溯帧中出现。举个例子:

var fnVariableName = function() { ... };

浏览器会使用fnVariableName作为函数名在追溯栈中出现。

    at throwError (http://mknichel.github.io/javascript-errors/javascript-errors.js:27:9)
    at fnVariableName (http://mknichel.github.io/javascript-errors/javascript-errors.js:169:37)

浏览器厂商在追溯栈上甚至还有更加细微的差异,如果一个函数被赋值给了一个变量,并且这个函数定义在另外一个函数内,几乎所有的浏览器都会使用被赋值的变量作为追溯帧中的函数名,但是,Firefox有所不同,在Firefox中,会使用外面的函数名加上内部的函数名(变量名)作为追溯帧中的函数名。举个例子:

function throwErrorFromInnerFunctionAssignedToVariable() {
  var fnVariableName = function() { throw new Error("foo"); };
  fnVariableName();
}

在Firefox中追溯帧格式如下:

throwErrorFromInnerFunctionAssignedToVariable/fnVariableName@http://mknichel.github.io/javascript-errors/javascript-errors.js:169:37

在其他的浏览器,追溯帧格式如下:

at fnVariableName (http://mknichel.github.io/javascript-errors/javascript-errors.js:169:37)

inner function Firefox stack frame warning 在一个函数定义在另外一个函数内部的情景下(闭包)Firefox会使用不同于其他浏览器厂商的格式来处理函数名

displayName 属性

除了IE11,函数名的展现也可以通过给函数定义一个displayName 属性,displayName会出现在浏览器的devtools debugger中。而Safari displayName还会出现在追溯帧中。

var someFunction = function() {};
someFunction.displayName = " # A longer description of the function.";

虽然关于displayName还没有官方的标准,但是该属性已经在主要的浏览器中实现了。通过如下网站你可以了解更多关于displayName的信息: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/displayNamehttp://www.alertdebugging.com/2009/04/29/building-a-better-javascript-profiler-with-webkit/

IE11 no displayName property IE11不支持displayName属性

Safari displayName property bug Safari 会使用displayName作为函数名在追溯帧中出现

通过编程来获取追溯栈

当抛出一个错误但又没有追溯栈的时候(通过下面的内容了解更多),我们可以通过一些编程的手段来捕获追溯栈。

在Chrome中,可以简单的调用Error.captureStackTrace API来获取到追溯栈,关于该API的使用可以通过如下链接了解: https://github.com/v8/v8/wiki/Stack%20Trace%20API

举个例子:

function ignoreThisFunctionInStackTrace() {
  var err = new Error();
  Error.captureStackTrace(err, ignoreThisFunctionInStackTrace);
  return err.stack;
}

在其它浏览器中,追溯栈也可以通过生成一个错误,然后通过stack属性来获取追溯栈。

var err = new Error('');
return err.stack;

但是在IE10中,只有当错误真正抛出后才能够获取到追溯栈。

try {
  throw new Error('');
} catch (e) {
  return e.stack;
}

如果上面的方法都起作用时,我们可以通过arguments.callee.caller 对象来粗糙的获取一个没有行数和列数的追溯栈,但是这种方法在ES5严格模式下不起作用,因此这种方法也不是一种推荐的做法。

Async stack traces

异步追溯栈

在JavaScript代码中异步代码是非常常见的。比如setTimeout的使用,或者Promise对象的使用,这些异步调用入口往往会给追溯栈带来问题,因为异步代码会生成一个新的执行上下文,而追溯栈又会重新形成追溯帧。

Chrome DevTools 已经支持了异步追溯栈,换句话说,追溯栈在追溯一个错误的时候也会显示引入异步调用的那一调用帧。在使用setTimeout的情况下,在Chrome中会捕获谁调用了产生错误的setTimeout 函数。关于上面内容,可以从如下网站获取信息: http://www.html5rocks.com/en/tutorials/developertools/async-call-stack/

一个异步追溯栈会采用如下形式:

  throwError    @   throw-error.js:2
  setTimeout (async)        
  throwErrorAsync   @   throw-error.js:10
  (anonymous function)  @   throw-error-basic.html:14

目前,异步追溯栈只有Chrome DevTools支持,而且只有在DevTools代开的情况下才会捕获,在代码中通过Error对象不会获取到异步追溯栈。

虽然可以模拟异步调用栈,但是这往往会代指应用性能的消耗,因为这种方法也显得并不可取。

Only Chrome supports async stack traces 只有Chrome DevTools原生支持异步追溯栈

命名行内JS代码或者使用eval情况

在追溯使用eval或者HTML 中写JS的情况,追溯栈通常会使用HTML的URL 以及代码执行的行数和列数。

例如:

  at throwError (http://mknichel.github.io/javascript-errors/throw-error-basic.html:8:9)
  at http://mknichel.github.io/javascript-errors/throw-error-basic.html:12:3

出于一些性能或代码优化的原因,HTML中往往会有行内脚本,而且这种情况下,URL, 行数、列数也有可能出错,为了解决这些问题,Chrome和Firefox 支持//# sourceURL= 声明,(Safari 和 IE 暂不支持)。通过这种形式声明的URL会在追溯栈中使用到,而且行数和列数也会通过\<script> 标签开始计算。比如上面相同的错误,通过sourceURL的声明,往往会在追溯帧后面添加一个inline.js.

  at throwError (http://mknichel.github.io/javascript-errors/inline.js:8:9)
  at http://mknichel.github.io/javascript-errors/inline.js:12:3

保证行内脚本及使用eval的情况下追溯栈的正确性依然是迫在眉睫的技术问题。

可以通过如下网站了解更多关于sourceurl的内容http://www.html5rocks.com/en/tutorials/developertools/sourcemaps/

Lack of sourceURL support Safari 和 IE 现在都不支持sourceURL 申明来命名 行内脚本和使用eval情况。如果你在这两个浏览器内使用行内脚本,那么在这些脚本中出现的错误往往不能够很好的解析

Chrome bug for computing line numbers with sourceURL 直到Chrome 42, Chrome也没有正确得计算行内脚本中发生错误的行数。访问如下链接,了解更多关于行内脚本内容: https://bugs.chromium.org/p/v8/issues/detail?id=3920

Chrome bug in line numbers from inline scripts 在使用sourceURL声明情况下,在行内脚本中,行数通常是从html文档开始位置开始计数,而不是从script标签处开始计数的,从html文档开始计数通常被认为是不正确的 https://code.google.com/p/chromium/issues/detail?id=578269

使用eval情景下的追溯栈

除了是否使用sourceURL声明,在代码中使用eval的情况下,不同浏览器在追溯栈上也有诸多差异:举个例子:

在Chrome在代码中使用eval,追溯栈如下:

Error: Error from eval
    at evaledFunction (eval at evalError (http://mknichel.github.io/javascript-errors/javascript-errors.js:137:3), <anonymous>:1:36)
    at eval (eval at evalError (http://mknichel.github.io/javascript-errors/javascript-errors.js:137:3), <anonymous>:1:68)
    at evalError (http://mknichel.github.io/javascript-errors/javascript-errors.js:137:3)

在IE11,中会是这样的。

Error from eval
    at evaledFunction (eval code:1:30)
    at eval code (eval code:1:2)
    at evalError (http://mknichel.github.io/javascript-errors/javascript-errors.js:137:3)

在Safari中:

Error from eval
    evaledFunction
    eval code
    eval@[native code]
    evalError@http://mknichel.github.io/javascript-errors/javascript-errors.js:137:7

在Firefox中:

Error from eval
    evaledFunction@http://mknichel.github.io/javascript-errors/javascript-errors.js line 137 > eval:1:36
    @http://mknichel.github.io/javascript-errors/javascript-errors.js line 137 > eval:1:11
    evalError@http://mknichel.github.io/javascript-errors/javascript-errors.js:137:3

兼容不同浏览器解析eval代码将变得异常困难。

Different eval stack trace format across browsers 不同浏览器都有自己处理eval代码错误的追溯栈格式

捕获JavaScript 错误

当发现应用程序中有错误的时候,程序中一些代码必须能够捕获错误,并且能够报告错误。现目前已经有很多方法能够捕获错误,他们有各自的优点和缺点:

window.onerror

window.onerror是开始捕获错误最简单的方法了,通过在window.onerror上定义一个事件监听函数,程序中其他代码产生的未被捕获的错误往往就会被window.onerror上面注册的监听函数捕获到。并且同时捕获到一些关于错误的信息。举个例子:

window.onerror = function(msg, url, line, col, err) {
  console.log('Application encountered an error: ' + msg);
  console.log('Stack trace: ' + err.stack);
}

访问https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers/onerror了解更过关于window.onerror的内容

在使用window.onerror方法捕获错误存在如下问题:

No Error object provided

window.onerror注册的监听函数的第五个参数是一个Error对象,这是2013年加入到WHATWG规范中的。https://html.spec.whatwg.org/multipage/webappapis.html#errorevent.Chrome ,Firefox, IE11现在都能够正确的在window.onerror中提供一个error对象(并且带有一个stack属性),但是Safari 和 IE10现在还没有,Firefox是从14版本加入Error对象的 (https://bugzilla.mozilla.org/show_bug.cgi?id=355430) ,而Chrome是从2013年晚期在window.onerror监听函数中加入Error对象的 (https://mikewest.org/2013/08/debugging-runtime-errors-with-window-onerror, https://code.google.com/p/chromium/issues/detail?id=147127)。

Lack of support for Error in window.onerror Safari 和 IE10还不支持在window.onerror的回调函数中使用第五个参数,也就是一个Error对象并带有一个追溯栈

Cross domain sanitization

在Chrome中,window.onerror能够检测到从别的域引用的script文件中的错误(**译者注:比如从CDN上面引用的jQuery源文件)并且将这些错误标记为Script error .如果你不想处理这些从别的域引入的script文件,那么可以在程序中通过script error标记将其过滤掉。然而,在Firefox、Safari或者IE11中,并不会引入跨域的JS错误,及时在Chrome中,如果使用try/catch将这些讨厌的代码包围,那么Chrome也不会再检测到这些跨域错误。

在Chrome中,如果你想通过window.onerror来获取到完整的跨域错误信息,那么这些跨域资源必须提供合适的跨域头信息。可以参考下面地址 https://mikewest.org/2013/08/debugging-runtime-errors-with-window-onerror

Cross domain sanitization in window.onerror Chrome 是唯一一个能够通过window.onerror检测到其他源上面的文件错误的浏览器,要么将其过滤掉,要么为其设置合适的跨域头信息

Chrome Extensions

在早期版本的Chrome浏览器中,安装在用户电脑中Chrome插件抛出的JS错误依然会被window.onerror检测到,这一bug在新版本的Chrome中已经被修正,参见下面Chrome插件部分。

window.addEventListener("error")

window.addEventListener("error") API 的效果和window.onerror API相同,可以通过下面网站了解更多信息: http://www.w3.org/html/wg/drafts/html/master/webappapis.html#runtime-script-errors

Showing errors in DevTools console for development

通过window.error并不能够阻止错误显示在浏览器控制台中,这通常是正确的,也是开发需要的,因为开发者可以很容易从控制台中看到错误信息。如果你不希望这些错误在生产环境中显示给最终用户,那么在window.addEventListener中使用e.preventDefault() 可以有效的避免错误显示在控制台上。举个例子:(**译者注:该例子为译者举例)

window.addEventListener('error', function(e) {
    e.preventDefault()
    //report error
})

推荐做法

window.onerror是捕获JS 错误最好的方法,我们推荐只有当JS错误带有一个合法的Error 对象和追溯栈时才将其报告给服务器(**译者注:搜集错误的服务器),因为其他不合法的错误不容易被分析,或者你可能会捕获到很多垃圾JS错误(从Chrome插件中得到)或者是从跨域资源上获取到一些信息不全的错误。

try/catch

鉴于以上window.onerror的不足之处,我们不能够完全依赖于window.onerror来获取全部的JS错误,如果只是需要在本地(**译者注:并不希望把错误抛到全局,然后在控制台中显示)捕获错误,那么try/catch 代码块将是一个更好的选择,我们甚至可以将所用的JavaScript代码通过一个try/catch包围来获取window.onerror获取不到的错误。这种方法能够改善有些浏览器不支持window.onerror的情况,但是try/catch依然会有如下一些劣势:

不能够捕获所有错误

try/catch并不能够捕获程序中的所有错误,比如try/catch就不能够捕获window.setTimeout异步操作抛出的错误。但是Try/catch可以通过 Protected Entry Points 来改善这一缺点。(**译者注:虽然try/catch不能够捕获异步代码中的错误,但是其将会把错误抛向全局然后window.onerror可以将其捕获,Chrome中已测试)

Use protected entry points with try/catch try/catch 包围所有的程序代码,但是依然不能够捕获所有的JS错误

try/catch 不利于性能优化

在V8(其他JS引擎也可能出现相同情况)函数中使用了try/catch语句不能够被V8编译器优化。参考 http://www.html5rocks.com/en/tutorials/speed/v8/

Protected Entry Points

一个JavaScript的''代码入口''就是指任意开始执行你代码的浏览器API.例如,setTimeout、setInterval、事件监听函数、XHR、web sockets、或者promise。都可以是代码入口。通过这些入口代码抛出的JS错误能够被window.onerror捕获到,但是遗憾的是,在浏览器中这些代码入口抛出的错误并不是完整的Error对象,(**译者注:在最新版Chrome中可以捕获到完整的Error对象),由于try/catch也不能够捕获到代码入口产生的JS错误,因为一个�可替代的方案急需被使用。

庆幸的是,JavaScript运行我们对这些入口代码进行包装,这样就是的在函数调用之前我们就可以引入try/catch语句,这样也就能够捕获入口代码抛出的错误了。

每个入口代码需要进行一些改变(**译者注:猴子补丁),这也就是所谓的「保护」代码入口,举个例子如下:

function protectEntryPoint(fn) {
  return function protectedFn() {
    try {
      return fn();
    } catch (e) {
      // Handle error.
    }
  }
}
_oldSetTimeout = window.setTimeout;
window.setTimeout = function protectedSetTimeout(fn, time) {
  return _oldSetTimeout.call(window, protectEntryPoint(fn), time);
};

Promises

遗憾的是,在Promises中产生的错误很容易就被掩盖而不能够观察到,Promise中的错误只会被rejection处理函数(**译者注:就是.catch())捕获到,而不会在其他任何地方捕获到Promise中的错误,也就是说,window.onerror是无法捕获到promise中的错误的。甚至即使promise自身带有rejection处理函数,我们也应该手动去处理错误。可以从下面的网站了解更多关于promise错误处理的信息。 http://www.html5rocks.com/en/tutorials/es6/promises/#toc-error-handling。举个例子:

window.onerror = function(...) {
  // This will never be invoked by Promise code.
};

var p = new Promise(...);
p.then(function() {
  throw new Error("This error will be not handled anywhere.");
});

var p2 = new Promise(...);
p2.then(function() {
  throw new Error("This error will be handled in the chain.");
}).catch(function(error) {
  // Show error message to user
  // This code should manually report the error for it to be logged on the server, if applicable.
});

我们可以使用 Protected Entry Points 来包装一些Promise的方法,在其中添加一个try/catch语句来处理错误,使用这种方法可使使得我们捕获更多错误信息。

  var _oldPromiseThen = Promise.prototype.then;
  Promise.prototype.then = function protectedThen(callback, errorHandler) {
    return _oldPromiseThen.call(this, protectEntryPoint(callback), protectEntryPoint(errorHandler));
  };

Errors in Promises will go unhandled by default 遗憾的是,默认情况下Promises中的错误不会被捕获到

Error handling in Promise polyfills

一些Promise实现,比如Q, Bluebird 和 Closure,这些Promise实现在处理JS错误时各有自己的方式,但是都比原生浏览器实现的Promise在处理错误上表现出色。

  • 在Q中,我们可以通过.done()来结束Pormise链,这样就保证了及时在Promise链中没有处理的错误依然会被抛出,然后可以通过其他方式处理。可以通过如下地址了解更多关于Q处理JS错误的信息。https://github.com/kriskowal/q#handling-errors
  • Bluebird中,没有处理的rejections会立即在控制台打印和报出。参见 http://bluebirdjs.com/docs/features.html#surfacing-unhandled-errors
  • Closure's goog.Promise 的Promise实现中,(**译者注,不了解,没翻译)unhandled rejections are logged and reported if no chain in the Promise handles the rejection within a configurable time interval (in order to allow code later in the program to add a rejection handler).

Long stack traces

async stack trace 部分,我们已经讨论了浏览器并不会捕获异步hook中的发生的错误的追溯栈信息,例如调用Promise.prototype.then时,一些Promise polyfills能够获取到异步错误的追溯栈信息,也使得诊断错误变得相对容易。虽然这样做有些代价,但是我们可以从这些方法中获取到更多有用的信息。

Web Workers

Web workers,包括dedicated workers、shared workers和service workers, 现在这些worker已经在应用程序中广泛被使用,由于所有的worker都是单独的JavaScript文件,因此他们应该有自己的错误处理代码,推荐的做法就是每个worker文件又应该有自己的错误处理和报告的脚本,这样就能够更加高效的处理workers中的错误了。

Dedicated workers

Dedicated web workers 在不同于主文件的另外一个上下文环境中运行,因此上面叙述的那些捕获错误的机制都不能够捕获Dedicated web workers中的错误,因此我需要采取一些额外的手段来捕获worker中的错误。

当我们生成一个worker时,我们可以吧onerror属性设置到worker上面,如下:

var worker = new Worker('worker.js');
worker.onerror = function(errorEvent) { ... };

这样做可以从下面的网站找到根据, https://html.spec.whatwg.org/multipage/workers.html#handler-abstractworker-onerror.这和window.onerror有所不同的是,我们把on error绑定到了worker上面,同时,监听的函数也不再接受五个参数,而是只有一个errorEvent对象作为参数。这个错误对象上面的API可以参考 https://developer.mozilla.org/en-US/docs/Web/API/ErrorEvent.这个对象包括错误信息、文件名、错误行数、错误列数,但是并没有追溯栈了(也就是errorEvent.error是null),由于这个API是在父文件中执行,因此我们也可以采取父文件中的发送错误机制来发送worker中的错误,但是遗憾的是,由于这个错误对象没有追溯栈,因此这个API使用也受到了限制。

在worker运行内部,我们也可以定义一个类似常规的window.onerror的API,参见,https://html.spec.whatwg.org/multipage/webappapis.html#onerroreventhandler.

self.onerror = function(message, filename, line, col, error) { ... };

关于self.onerror这个API的讨论可以参上上面关于window.onerror的讨论。然后,仍然有两点需要注意:

self.onerror中,FireFox和Safari在self.onerror的回调函数中不会有第五个参数,因此,在这连个浏览器中也就无法从worker错误中获取追溯栈(Chrome 和 IE11 能够获取到追溯栈),但是我们依然可以通过Protected Entry Points 对onmessage 函数进行包装,然后我们就能够在Firefox和Safari中获取到worker 错误的追溯栈了。

由于错误捕获代码在worker中执行,因此我们应该选择怎么把错误发送到错误搜集服务器中,我们可以选择postMessage 把错误信息发送给父级页面,或者直接在worker中通过XHR把错误直接报告给错误收集的服务器。

需要注意的是,在Firefox、Safari和IE11(不包括Chrome),父级页面中window.onerror在worker脚本中的onerror注册监听函数被调用后,依然会被调用,但是,父级页面中的window.onerror捕获的错误对象并不会包含追溯栈,我们也应该注意的是,不应该把相同的错误重复发送到服务器。

Shared workers

Chrome和Firefox支持ShareWorker API,这样worker就可以在多个页面共享了,由于worker是共享的,因此该worker也不从属与某一个父级页面,这也就导致了错误处理方式的不同,ShareWorker 通常可以采取和dedicated web worker相同的错误处理方式。

在Chrome中,当ShareWorker出现JS错误时,只有worker内部的错误捕获代码能够被执行(比如self .onerror),父级页面中的window.onerror不会被执行,同时Chrome还不支持虽然已在规范中定义了的AbstracWorker.onerror。.

在Firefox,行为又有些不同,worker中的错误会使得父级页面的window.onerror的监听函数也被调用,但是虽然父级页面也能捕获到错误,依然缺少第五个参数,也就是说捕获到的错误对象上面没有追溯栈,因此这也会有使用上的限制。

shared workers的错误处理在浏览期间差异性很大

Service Workers

Service Workers是新规范中提出的,现目前仅在Chrome和Firefox最近版本中实现,该worker和dedicated web worker的错误处理机制差不多。

Service workers是通过调用navigator.serviceWorker.register 开引入的,该方法返回一个Promise,当service worker引入失败,该Promise就会被reject掉。如果引入失败,那么在Service worker初始化时就会抛出一个错误,该错误仅包含一条错误信息。除此之外,由于Promise不会把错误暴露给window.onerror 事件监听函数,因此我们需要给上面方法返回的Promise添加一个catch代码块,用来捕获该Promise中抛出的错误。

navigator.serviceWorker.register('service-worker-installation-error.js').catch(function(error) {
  // error typeof string
});

和其他workers一样,service worker也可以设置self.onerror来捕获错误,service worker初始化错误会被self.onerror不会,但是遗憾的是,捕获的错误依然没有第五个参数,也就是没有追溯栈。

service worker API从AbstractWorker 接口上继承了onerror 属性,但是遗憾的是,Chrome并不支持该属性。

Worker Try/Catch

为了能够在Firefox和Safari浏览器的worker中捕获到追溯栈,onmessage监听函数内部可以通过一个try/catch 代码块包围,这样就可以捕获仍和冒泡上来的错误了。

self.onmessage = function(event) {
  try {
    // logic here
  } catch (e) {
    // Report exception.
  }
};

常规的try/catch 代码块能够捕获这些错误中的追溯栈,举个例子,产生错误的追溯栈如下:

Error from worker
throwError@http://mknichel.github.io/javascript-errors/worker.js:4:9
throwErrorWrapper@http://mknichel.github.io/javascript-errors/worker.js:8:3
self.onmessage@http://mknichel.github.io/javascript-errors/worker.js:14:7

Chrome Extensions

由于Chrome Extensions 不同的Chrome 扩展错误的表现也有所不同,因此他们应该有自己处理错误的方式,同时,Chrome 扩展中的错误在大型项目中的危害也不容小觑的。

Content Scripts

所谓的Content script就是当用户访问网站时,这些脚本在一个相对独立的执行环境中运行,可以在这些script中操作DOM,但是却不能够获取到网站中的其它JavaScript脚本。

由于content scripts有他们独立的执行环境,因此也可以使用window.onerror来捕获Content script中的错误,但是遗憾的是,在content script中通过window.onerror捕获的错误会被标记为"Script error"。没有文件名,行数和列数也被标记为0.可以通过以下网站了解 https://code.google.com/p/chromium/issues/detail?id=457785. 在这bug被解决之前,我们依然可以通过try/catch语句或者protected entry points来捕获Content script中带有追溯栈的JS错误。

在很多年前,Content script中的错误还会被父级网页中的window.onerror捕获到,这样就导致了父级网页中捕获到很多垃圾的错误信息,这一bug在2013年后期已被修复。 (https://code.google.com/p/chromium/issues/detail?id=225513).

Chrome 扩展中的JS错误应该在被window.onerror捕获之前被过滤掉

Browser Actions

Chrome扩展可以产生一个弹出窗口,这些弹出窗口是一个小型的HTML文件,有用户点击URL栏右边的Chrome 扩展图标所致。这些弹出窗口可以在一个完全不同的环境中执行JavaScript代码,window.onerror也会捕获到这些窗口产生的错误。

Reporting Errors to the Server

一旦客户端将带有正确的追溯栈的JS错误捕获到后,这些错误应该发回错误处理服务器,以便进一步对错误进行追踪、分析、和消除错误。通常吧错误发送到服务器是通过XHR来完成的,发送到服务器的错误包括:错误信息、追溯栈以及其他客户端和错误相关的信息,比如应用程序所用框架的版本号,用户代理(user agent),用户的地址,以及网页的URL。

如果应用程序使用了多种机制来捕获错误,那么应该注意的地方就是不要把相同的错误发送两次,同时,发送的错误信息最后带有一个追溯栈,这样在大型应用程序中才能够更好的找出问题根源。

一道「空间」很贵的排序算法

又是十点钟才离开公司,拖着疲惫的驱壳,挪着步子,走进地铁站,上海真的很多人,晚上十点多了,地铁上还是很挤,我习惯的拿出了手机,看一下微信群,放松下加班后劳累的身体。

打开了小区业主群,一个叫做「九哥哥」的邻居在小区群里竟然提了一个问题,准确的说是一道算法题:

借此地问个问题,各位ITer看此题是否有解,有一个队列结构(FIFO),如何对里面的元素进行从小到大排序,元素可以多次进出,而且允许有一个额外空间能暂存一个元素。

哎,本来想放松下心情的,结果又被这道算法题勾起了兴趣。。。脑子里迅速浮现了常见的排序算法:冒泡排序、快速排序、插入排序、归并排序。。。

果然,群里马上就炸开锅了(感觉我们业主群整个就是一个 IT 交流互助群了),迅速有一位「小毛」的业主回复了。

小毛:看上去是面试题呀,不考虑并发,不考虑性能, 用冒泡排序嘛

「unix」的业主跟着回复道:

Unix:[奸笑]冒泡啊。。不是有个空间能暂存一个元素的么。。

想想也就知道了,队列(FIFO)只提供了 pop(从队列末端推出元素)和 unshift(从队列入口推入元素),而且只有一个额外的空间用于暂存元素,因此根本就没法交换两个来冒泡排序。

后面果然有人反驳道了。

秋风哥哥:@ unix 冒泡搞不了啊

九哥哥(提问者):肯定不是冒泡撒[捂脸]

Unix:[捂脸]搞不了。。刚看到是队列。。

这时候,作为群主的「小毛」不服气了。怒怼众人。

小毛:冒泡咋就搞不定捏,我就问一个问题, 用冒泡写出来了, 有人吃屎不?

九哥哥(提问者):没法交换相邻的元素@小毛

终于来了一个反对冒泡排序,有理有据的回答了。

很着调:jj, 冒泡交换数据那个动作 比较+暂存需要三个存储空间了 除去出的那个算一个,还需要额外的两个存储空间,他们只有一个

看来,冒泡排序已经被大家否决了,但是群里又抛出了另外一个疑问?

秋风哥哥:@小毛 冒泡排序,也要知道总长度,不然你冒泡啥时候停止

九哥哥(提问者):总长度知道的

小毛:就是嘛,我就没见过类似集合类的数据结构本身无法描述长度的

讨论到这里,感觉,终于有人给出了文字描述的算法。

秋风哥哥:第一次队列全出进,可以确定一个最大值
秋风哥哥:然后插入队列尾部
秋风哥哥:然后第二次出队,出队个数为 总个数-1,求出第二大,这个要自己推演一下

感觉算法已经基本有了,群里疑问又一次升级。。。讨论到了算法复杂度的层面了。。。

肖立涛:你这样的时间复杂度基本是在O(n2),为什么空间控制这么严格呢?

九哥哥(提问者):因为这不是内存,而是个实际的柜子,只有这么大空间[捂脸],完全不考虑时间复杂度

很着调:柜子高兴开哪个就开哪个,它的数据结构不是可以是数组了,为什么还限制死是pipe的

九哥哥(提问者):OK,不是我们想的这种柜子,反正就是这么个结构,只能从上面放,从下面取,中间没有门@很着调

很着调:好吧

九哥哥(提问者):设计给机械操作的,做不到灵活的存取,只能固定两个位置

聊天看到这儿才发现,这原来不是面试题。。。是用算法解决实际问题啦。

👌,感觉聊天到这儿已经接近尾声,在地铁上无聊,也没心思去想它,开始打游戏了。回到家,蹭着脑袋清醒多了,打开电脑,写下了如下实现算法:

/**
 * 只能使用 unshift\pop
 * 只有 x 用于暂存元素。
 */
// 去队列顶部元素
function peak(list) {
	return list[list.length - 1]
}
function sort(list) {
	const len = list.length
	let x = list.pop()

	for(let i = 0; i < len - 1; i++) {
		for (let j = 0; j < len - i - 1; j++) {
			if (x > peak(list)) {
				list.unshift(x)
				x = list.pop()
			} else {
				list.unshift(list.pop())
			}
		}
		list.unshift(x)
		for (let k = 0; k < i; k++) {
			list.unshift(list.pop())
		}
		if(i !== len - 2) x = list.pop()
	}
	return list
}
const input = [1, 12, 45, 4, 5, 7, 1, 2, 9, 6]
sort(input) // [ 1, 1, 2, 4, 5, 6, 7, 9, 12, 45 ]

算法不复杂,嵌套两个循环,如果以代码中 x > peak(list) 比较作为最耗时的步骤,假设队列长度为 n,那么总共需要做 (n - 1) * n / 2 次比较。可见其时间复杂度为 O(n2)。

😝,感觉小群业主群已然变味了,不再讨论菜米油盐,也不再抱怨小区物业无作为。感觉聊天内容到了一个新的高度。竟然聊计算机算法了,这么稀奇的事情,我决定把它记下来。

写在最后,也没见「小毛」用冒泡排序写出实现代码来,当然也就没人吃屎了。

CSS切角效果实现

切角效果是时下非常流行的一种设计风格,并广泛运用于平面设计中,它最常见的形态就是把元素的一个或多个切成45°的切口,尤其是在最近几年,扁平化设计盖过拟物化设计后,这种切脚设计更为流行,例如下图就是通过切角实现的一个导航栏,在后面将详细论述起实现。

1. CSS中的两种渐变(linear-gradient radial-gradient)

在了解切角之前,我们首选来学习下CSS渐变,CSS渐变分两种线性渐变(linear-gradient)和径向渐变(radial-gradient)。

linear-gradient

通过CSS线性渐变函数(linear-gradient)可以生成线性渐变的颜色,这种渐变颜色是通过CSS数据类型来表示的。而这一线性渐变函数将返回一个CSS数据类型,和其他CSS渐变类型一样,CSS线性渐变并不属于CSS数据类型,而是一张没有固定尺寸的图片,线性渐变的大小由其应用的元素所决定的。

CSS中的线性渐变是通过一条渐变线(Gradient line)来定义的,渐变线上面的每一个点都具有不同的颜色,渐变线上面每一点的垂直线上面的点具有相同颜色。

线性渐变的语法:

linear-gradient( 

  [ <angle> | to <side-or-corner> ,]? <color-stop> [, <color-stop>]+ )

  where <side-or-corner> = [left | right] || [top | bottom]

  and <color-stop>     = <color> [ <percentage> | <length> ]?

线性渐变的语法可以分为两个部分,前面一部分用来定义线性渐变线(gradient line),后面一部分用来定义渐变的颜色点。线性渐变线可以通过两种方式定义,第一种是通过CSS来进行定义,例如45deg、135deg等。另外一种是通过CSS来进行定义的,例如to left top、to right bottom等。而渐变颜色点是通过CSS类型来定义的,该类型又一个CSS颜色和一个可选的百分比值或者长度值来定义,百分比值或者长度值是决定颜色在渐变线上的位置。

下面是一些线性渐变的例子:

linear-gradient( 45deg, blue, red ); /* A gradient on 45deg axis starting blue and finishing red */

linear-gradient( to left top, blue, red);

/* A gradient going from the bottom right to the top left starting blue and finishing red */

linear-gradient( 0deg, blue, green 40%, red );

/从下向上的渐变,从蓝色开始,渐变到40%时颜色为green,并以红色结束渐变/

radial-gradient

径向渐变(radial-gradient)主要由渐变的起始圆心、结束的形状和位置、渐变点来定义的。径向渐变函数最终也是返回一个CSS类型。

径向渐变语法如下:

radial-gradient(
  [ [ circle || <length> ]                         [ at <position> ]? , |
    [ ellipse || [ <length> | <percentage> ]{2} ]  [ at <position> ]? , |
    [ [ circle | ellipse ] || <extent-keyword> ]   [ at <position> ]? , |
    at <position> ,
  ]?
  <color-stop> [ , <color-stop> ]+
)
where <extent-keyword> = closest-corner | closest-side | farthest-corner | farthest-side
  and <color-stop>     = <color> [ <percentage> | <length> ]? 

径向渐变比线性渐变复杂一些,我们可以定义径向渐变的形状(circle和ellipse),渐变起始的位置(类似于background-position或者transform-origin),渐变结束点位置(通过extend-keyword来决定的)以及渐变点(color-stop)。其中extend-keyword有四个选择值:

closest-side:径向渐变结束于离渐变圆心最近的应用径向渐变元素的边。

closest-corner: 径向渐变结束于应用径向渐变元素离渐变圆心最近的一个角的位置。

farthest-side:和closet-side相反,径向渐变结束于离圆心最远的一条边。

farthest-corner:和closest-corner相反,径向渐变结束语离圆心最远的一个角的位置。

条纹

在css中我们可以使用渐变来实现条纹,在线性渐变中,如果多个色标具有相同的位置,它们将产生一个无限小的过渡区域,过渡的起止色分别是第一个和最后一个指定值,从效果上看就是颜色在该点突然变化为下一个颜色点,而不是一个渐变效果,这一特定还适用于下一个颜色点的位置小于前一个颜色点的位置。正是因为这一特性,我们可以定义条纹状的渐变效果。

background: linear-gradient(red 30%, blue 0);
background-size: 100% 30px;

可以看到上面的代码生成了水平状的条纹,红蓝相间,线性渐变我们省略了渐变方向,所以线性渐变使用默认值从上到下。background-size用来控制背景的大小,如果值是两个数字组成的,前面一个表示背景的宽度,后面一个值表示背景的高度。可以看到我们设置的30px。同时background-repeat是默认repeat的,因此我们最后的效果就是条纹背景在垂直方向上重复出现。

2. CSS中的background属性

background-image

在CSS中,我们可以通过background-image属性来为元素设置一个或多个背景,这些背景图片通过堆砌的方式排列在background-color上方,内容的下方,先设置的background-image离用户最近,后设置background-image离用户越远。

元素的border是画在了background-image上面的,background-image的位置取决于background-positionbackground-origin下面我们将具体说明。

background-position

background-positionCSS属性用来定义背景图片的位置,该位置相对于background-origin定义的位置层的。background-origin的默认值是padding-box。而background-postion的初始值是0% 0%。background-origin可以决定background-position定义的背景图片位置,其有三个值可选border-boxpadding-boxcontent-box。这三个值很好理解,比如我们background-position初始值设置的为0% 0%,那么当background-origin的值为border-box时,背景图片从border-box开始绘制,如果设置的是padding-box,那么背景图片从padding-box开始绘制,如果设置的是content-box,那么背景图片从content-box开始绘制。

background-repeat

这个属性最好理解了,就是背景图片的重复方式,常用的取值有repeat-xrepeat-yrepeatspaceroundno-repeat。默认值是repeat。

3. 通过CSS渐变和background属性实现切角效果

常规切角

我们已经知道,线性渐变可以做出条纹效果,那么我们是否也可以用线性渐变做出切角效果呢?答案是肯定的。

代码如下:

.banner {
    width: 200px;
    height: 150px;
    margin-top: 100px;
    background: #58a;
    background: linear-gradient(135deg, transparent 15px, #58a 0) top left,
                linear-gradient(-135deg, transparent 15px, #58a 0) top right,
                linear-gradient(-45deg, transparent 15px, #58a 0) bottom right,
                linear-gradient(45deg, transparent 15px, #58a 0) bottom left;
    background-size: 50% 50%;
    background-repeat: no-repeat;
}

首先我们定义一个类名为banner的元素,设置其宽高,然后设置了四张背景图片,宽高都为50%。分别位于top left、top right、bottom right、bottom left。设置其repeat方式为no-repeat。

好了重点来了,在每张背景图片中,我们设置了一个渐变背景,起始颜色transparent,gradient-line角度为135deg,渐变长度为15px,其他的渐变色为.banner的背景色。由于条纹渐变,视觉上我们就看到了一个被切掉一角的元素。同时处理其他三个角,最后就得到了一个被切掉四个角的元素。

弧形切角

既然实现了切角元素,那么弧形切角也就很简单了,只用把线性渐变变成径向渐变就好了,代码实现如下:

.arc {
    width: 200px;
    height: 150px;
    margin-top: 100px;
    background: #58a;
    background: radial-gradient(circle at top left, transparent 15px, #58a 0) top left,
                radial-gradient(circle at top right, transparent 15px, #58a 0) top right,
                radial-gradient(circle at bottom right, transparent 15px, #58a 0) bottom right,
                radial-gradient(circle at bottom left, transparent 15px, #58a 0) bottom left;
    background-size: 50% 50%;
    background-repeat: no-repeat;
}

上面代码就不做解释了。

我们知道了切角的实现方式,那么文章开头的切角导航的实现也就顺理成章了,这儿就不再贴代码了,留作大家思考。

《见识》读后感

这个月主要看了吴军博士《见识》一书,全书共分为共分为九章,其中有几点给我留下了比较深刻的印象,所以把它们提出来,谈谈自己的理解和感悟,分别是人生需要做减法阅读的意义大家的智慧拒绝伪工作者

人生需要做减法

本章开篇,吴军就举了一个例子,**人和印度人都比较聪明、用功,起点也差不多。最近这些年由于**经济的发展,**人的起点甚至会略高一筹,然而到目前为止,印度移民在美国大公司当首席执行官的相当多,而**人在这个级别的还真没有,在往下一级,即担任世界500强公司副总裁的印度人也比**人多很多,这是为什么呢?文中给了很多种解释,比如印度人踏出国门更早、先天的语言优势、再其次,在意识形态上,资本主义国家对**还是有一些防范意识,然而印度就没有。最后芝加哥大学商学院教授从幸福学角度给出了一种颇为合理的解释,印度人缺乏选择的状态,印度是一个阶级比较固化的国家,跨越阶级是比较困难的,因此他们选择的余地就会比较少,当他们有了某个工作机会后,他们就会拼了命的往上爬,这也帮助了印度的精英们在公司取得成功。

但是相对于印度人,在美国的**人今天的选择太多了,尤其是在大公司就职的**人,这要感谢祖国的发展,很多人从美国名校毕业,在一个大公司工作几年,如果表现比较好的话,会被提升一两次,他们原本应该继续努力,但是很多人会被发展更快的**公司挖走,以至于很多人不想通过努力在美国大公司中获得晋升,而是想通过在美国大公司的工作经历来包装自己,来在**获取一份高薪的工作。

选择多了,反而会分散我们的注意力,让我们没法讲精神能量集中到一点,举一个例子,小孩上幼儿园前的托班,我们总是在不同的托班中作比较,选择,A 机构师资力量更加雄厚,B 机构离家更近,但是我们却忽略了一点,孩子正真需要什么?也许仅仅是需要父母的陪伴和给予安全感,这样他们就可以去发现和大人们不一样的世界了。反而会发现上什么托班变得不重要,高效陪伴才是我们更应该思考的事。

西瓜与芝麻,这个故事我不止在一本书中听到吴军讲过,西瓜和芝麻的重量相差好几个数量级,即使我们再勤奋的捡芝麻,也捡不出西瓜的重量,所以我们一定要分清楚,什么是芝麻?什么是西瓜?不要在捡芝麻的事情上浪费过多时间。吴军列举了下面一些捡芝麻的例子:

  • 为了那免费的东西而打破头。

  • 为了省下 1 元的出租车钱,在路上多走 10 分钟。

  • 为了抢几元的红包,每隔三五分钟就看看微信。

  • 为了挣几百元的外快,上班偷偷干私活。

这些捡芝麻的事会使得我们利用时间非常没有效率,更糟糕的是,会使得我们的追求会越来越低,人的心志一旦变得非常低,就很难在提升自己,让自己走到越来越高的层次了。

所以在西瓜与芝麻的故事上,我们应该做减法,放弃芝麻小事,而应该把心志集中到“西瓜”的大事上,这样我们才能实现更高的人生追求。

阅读的意义

故事是这样的,吴军在图书馆遇到了以为老奶奶,他在看《哈利波特》,吴军就好奇的问,“你也喜欢这种书?还是给您的孙子借?”她告诉我,是她自己想读一下,应为她发现她和她的孙子已经到了“无话可说”的尴尬境地了,因此她决定和她的孙子看同一本书,来增加彼此的话题。

这个故事也让我重新审视了一下我看书的目的,之前我看书带有很强的功利性,一本书只有对我目前的工作和生活有帮助我才会去读它,比如在过去几年,我看的大部分书籍都是计算机科学相关的,因为我觉得这些书籍对我工作能力的提升大有裨益,这也就导致了我很少去花时间看一些文学、历史方面的优秀书籍,以至于我错过了大部分优秀的作品,因为我的功利性,我把它们都排除到了我的阅读列表之外。这样长此以往,导致了我技术上有所长进,但是在其他方面却止步不前了。

那么也读的意义何在呢?文中的故事已经提到了一点,增进和家人的共同语言,减少和孩子的代购,可以想想一下,在和孩子读同一本书的时候,相信你们都会有不同的感悟和理解,把自己的感悟分享给对方,这也是阅读的乐趣所在。

苏格拉底临死前说,未经审视的人生不止的度过,在如今的互联网时代,有太多获取知识和咨询的渠道,这些知识和咨询良莠不齐,甚至有很高的噪点,刷抖音、看知乎、逛微博并不能够帮我们审视人生,它们只会占用我们的闲暇时间,让我们更加没有办法利用闲暇来思考,读书则不同,手里捧着一本书,它能很容易的让我们进入“心流”状态,因此好的书能够帮助我们审视人生。

一本好书帮助我们重新认识自己,认清世界,弄清心头百思不得其解的疑惑,并最终成为一个更好的人。

大家的智慧

莎士比亚论朋友,《哈姆雷特》是莎士比亚医生最重要的作品,很多人都记得它里面的那句名言,“生存还是毁灭,这是一个问题。”,而今天我们来谈谈莎士比亚的交友原则。

凡是三思而行,不要想到什么就说什么。

对人要和气,但是不要过分狎昵。

相知有素的朋友,应该用钢圈箍在你的灵魂上,可是不要对每一个泛泛的新知烂施你的交情

拉里佩奇的经营管理智慧,拉里佩奇谈到,谷歌的商业模式本质,有用的内容并不需要是自己的,在未来的智能社会,连接比拥有更加重要。谷歌和脸谱这样的公司并不提供什么内容,但是他们有对用户的连接,爱彼迎没有自己的房产,但是他确实全世界最大的房产租赁公司,优步和滴滴不拥有汽车,确实全世界最大的出租车公司,懂得这一点,就理解了互联网经济的本质。

拒绝伪工作者

首先我们需要定义一下,什么是伪工作者?伪工作者每天把自己搞得很忙,他们所做的工作可能也是公司里面存在的,但是有些工作不产生什么效果,如果一个公司里面这样的伪工作者很多,完成的伪工作也很多,用不了多久它在竞争中就会处于下风。

伪工作主要有如下的一些特征:

  • 那些既不能够给公司带来较大收益,又不能给用户带来价值的改进和升级,很多都是委工作,比如在互联网行业,如果一个产品某些功能在上线之后,生命周期不到三个月,那么当初开发的工作都是伪工作。

  • 有的人明明能够通过学习一种新的技能更有效的工作,却偏偏守着过去旧的工具工作,甚至手工操作,而不能够把一些简单而重复的工作自动化,这种人就是典型的伪工作者。

  • 在做事前不认真思考,做事时通过简单的试错方法盲目寻找答案。

  • 做产品不讲究质量,不认真测试,上线后不停的修改 bug,总是花很多的时间找漏洞和补丁。

  • 不注重用优先的资源解决 95% 的问题,而是把大部分时间和经理用于纠结不重要的 5% 问题上。

  • 每次开会找大量不必要的人员旁听,或者总是去参加那些不重要的会议,听到比人在谈论事情,总是会上前去插入两句,事后又不采取行动,总是发现问题,但是又从来不解决问题。

那么我们怎么拒绝伪工作呢?

确立愿景-目标-道路,既然我们花 10000 个小时来提高专业水平是为了精进,而不是为了简单的重复,就需要有一个非常明确的方向,这个方向就是愿景。比如有些人是想成为优秀的软件工程师,这个愿景就非常好;相反,如果有些人 5 年坚持不懈地写 JavaScript,以便将来写得更熟、更快,这是非常糟糕的,因为这是低水平的重复,即使5 年的时间,你将 JavaScript 写得非常熟练了,可能 JavaScript 也已经过时了,或者有计算机来写了。作为一名前端工程师,还是颇有感悟,记得大约在两年前,我给自己阅读计划定下了一条原则,尽量阅读前端相关的书籍,因为我认为我在前几年已经看了相当多纯前端的书籍了,比如 React、Angular 等相关书籍,所以最近一两年,我把自己的阅读范围放得更大,从计算机网络、到数据库,都想有所涉猎,来弥补自己在计算机专业知识上的不足。

有了愿景,下一步就是确定目标,作为一名软件工程师,我们的目标也许不仅仅是将代码写得足够好,我们的最终目标是做出一款好的产品,甚至是世界级的产品。所以即使是作为工程师,我们的视角也应该是站在产品的角度,所以的技术都应该是支持产品的,因为只有产品才会带来最终的价值。当然这并不是说我们不应该注重代码质量,相反,为了做出世界级的产品来,我们更应该提高代码的质量,作为一名工程师,首先应该为代码质量负责,这和我们应该以产品的视角来思考问题并不矛盾,好的产品需要高质量的代码来支撑。

有了战略,还需要有战术。提高程序质量水平,可以从写单元测试这种可操作的事上做起,在一项技能稍微熟练之后,就可能需要做一件新的、有挑战的事情,以便达成下一个目标,任何公司和领导对于这种勇于挑战自己往上走的人都是欢迎的。当然,不断挑战自己要付出的代价不仅仅是辛苦,可能还有短期经济上的损失,因为从短期上来说,重复自己驾轻就熟的工作比接受新挑战在绩效上显得好很多,奖金也会多一些。

谓词演算在JavaScript中的应用

谓词演算在JavaScript中的应用

相关概念

谓词

在离散数学中这样定义谓词:句子中表示主语属性的那部分。比如语句"x > 3",其中变量x是语句的主语,第二部分“ > 3”表示了主语的“大于3”的属性,也就是谓词。上面的语句也可以表示为P(X),含义是命题函数P在X的值。其中P就是谓词。

在JavaScript中也有谓词的概念,JavaScript是一个只会返回true 或者 false的纯函数(对于唯一的输入只会有唯一的输入,除了通过传参不会引入外部变量,并且也不会改变外部对象)。比如下面的函数都是谓词:

const gt2 = x => x > 2
const lt2 = x => x <= 2
const isNumber = x => typeof x === 'number' && !isNaN(x) // 该方法可能考虑不周全
量词

在数学中主要有两种量词,一种是全称量词,一种是存在量词,下面将分别定义这两种量词:

全称量词:许多数学命题断言某一性质对于变量在某一特定域内所有值均为真,这一特定的域称为论域,这类语句可以用全称量词表示。

P(X)的全称量化是语句

​ ”P(X)对于x在其论域的所有值为真“。

可用符号∀xP(x)表示P(x)的全称量化,其中∀称为全称量词,其中命题∀xP(x)可以读作“对于所有的x,P(x)”。

存在量词:有一个个体使得某种性质成立,就可以用存在量化来表示。

P(X)的存在量化是命题

​ “论域中存在一个个体x满足P(x)”

可用符号∃xP(x)表示P(x)的存在量化,其中∃为存在量词。

在JavaScript中有与之相对应的概念,JavaScript数组中两个函数式的方法,everysome分别就是全称量词和存在量词。举个例子:

const array = [1, 2, 3, 4]
array.every(gt2) 
array.some(lt2) 

上面的例子中,论域是定义的数组array,其中为[1, 2, 3, 4]全称量化every就表示在论域array中,每一个元素都需要满足谓词gt2,这样整个语句才为真。当然上面的全称量化的真值为。因为存在反例,当取array中元素1时,gt2就是false

同样存在量化some表示在论域array中,只要存在一个元素满足谓词lt2,那么整个语句就为真。上面的存在量化真值为。当去array中元素1时,满足谓词lt2

量词的逻辑等价

首先我们需要定义什么是逻辑等价:

涉及谓词和量词的语句是逻辑等价当且仅当无论用什么谓词带入这些语句,也无论为这些命题函数变量指定什么论域,它们都具有相同的真值。

在数学中,∀ x (P(x) ∧ Q(x)) 逻辑等价于 ∀ x P(x) ∧ ∀ x Q(x)。其中∧表示取两个语句的合取,也就是汉语中"且"。(证明略)

上面的的逻辑等价翻译成JavaScript如下:

const a = [1, 2, 3, 4]

const gt0 = x => x > 0
const lt5 = x => x < 4

const and = (f1, f2) => x => f1(x) && f2(x)
const or = (f1, f2) => x => f1(x) || f2(x)


a.every(and(gt0, lt5)) // true
a.every(gt0) && a.every(lt5) // true

最后两个语句是逻辑等价的,在某一论域,对一个合取表达式进行全称量化逻辑等价于分别对合取表达式的每一个语句进行全称量化然后再取合取。

同样的,在数学中,∃ x (P(x) ∨ Q(x)) 逻辑等价于 ∃ x P(x) ∨ ∃ x Q(x),其中∨表示取两个语句的析取,也就是汉语中的‘或’。(证明略)

上面的逻辑等价翻译成JavaScript如下:

const a = [1, 2, 3, 4]
const gt2 = x => x >= 2 
const lt2 = x => x < 2

const and = (f1, f2) => x => f1(x) && f2(x)
const or = (f1, f2) => x => f1(x) || f2(x)

a.some(or(gt2, lt2))// true
a.some(gt2) || a.some(lt2) // true

上面最后两个语句是逻辑等价的,对于某一论域取析取得存在量化等价于分别对每一个析取语句进行存在量化然后在析取。

量化表达式的否定

量化表达式的规则成为量词的德.摩根律。可以用如下表达式表示:

┐∀ x P(x) 逻辑等价于 ∃ x ┐P(x).

┐∃ x Q(x) 逻辑等价于 ∀ x ┐Q(x).

我们可以这样理解第一个逻辑等价式,令P(X)表示x是鸟人,那么第一个等价式左边就是"不是所有的人都是鸟人"而等价式右边的含义是"至少存在一个人他不是鸟人",细想这两句话是等价的。

第二个逻辑等价式我们也可以同上面的方法解释,这儿就不赘述了。

上面两个逻辑等价式也可以翻译为Javascript。

const a = [1, 2, 3, 4]
const gt2 = x => x >= 2 
const lt2 = x => x < 2

!a.some(gt2) // false
a.every(lt2) // false

我们可以使用上面的逻辑等价式在全称量化和存在量化中相互转化,在JavaScript中,也就是可以在everysome两个方法中通过一些逻辑运算符进行相互 。

深入理解 Generators函数

本文翻译自:Diving Deeper With ES6 Generators

由于个人能力有限,翻译中难免有纰漏和错误,望不吝指正issue

ES6 Generators:完整系列

  1. The Basics Of ES6 Generators
  2. Diving Deeper With ES6 Generators
  3. Going Async With ES6 Generators
  4. Getting Concurrent With ES6 Generators

如果你依然对ES6 generators不是很熟悉,建议你阅读本系列第一篇文章“第一部分:ES6 Generators基础指南”,并练习其中的代码片段。一旦你觉得对基础部分掌握透彻了,那我们就可以开始深入理解Generator函数的一些细节部分。

错误处理

ES6 generators设计中最为强大部分莫过于从语义上理解generator中的代码都是同步的,尽管外部的迭代控制器是异步执行的。

也就是说,你可以使用简单的错误处理技术来对generators函数进行容错处理, 也就是你最为熟悉的try...catch机制。

例如:

function *foo() {
    try {
        var x = yield 3;
        console.log( "x: " + x ); // may never get here!
    }
    catch (err) {
        console.log( "Error: " + err );
    }
}

尽管上面例子中的foo generator函数会在yield 3表达式后暂停执行,并且可能暂停任意长的时间,如果向generator函数内部传入一个错误,generator函数内部的try...catch模块将会捕获传入的错误!就像通过回调函数等常见的异步处理机制一样来处理错误。:)

但是,错误究竟是怎样传递到generator函数内部的呢?

var it = foo();

var res = it.next(); // { value:3, done:false }

// instead of resuming normally with another `next(..)` call,
// let's throw a wrench (an error) into the gears:
it.throw( "Oops!" ); // Error: Oops!

如上代码,你会看到iterator的另外一个方法- -throw(..)- -,该方法向generator函数内部传入一个错误,该错误就如同在generator函数内部暂停执行的yield语句处抛出的错误一样,正如你所愿,try...catch模块捕获了通过throw方法抛出的错误。

**注意:**如果你通过throw(..)方法向generator函数内部抛出一个错误,同时在函数内部又没有try...catch模块来捕获错误,该错误(如同正常的错误冒泡机制)将从generator函数冒泡到函数外部(如果始终都没对该错误进行处理,该错误将冒泡到最外层成为未捕获错误)。代码如下:

function *foo() { }

var it = foo();
try {
    it.throw( "Oops!" );
}
catch (err) {
    console.log( "Error: " + err ); // Error: Oops!
}

显而易见,反向的错误处理依然能够正常工作(译者注:generator函数内部抛出错误,在generator外部捕获):

function *foo() {
    var x = yield 3;
    var y = x.toUpperCase(); // could be a TypeError error!
    yield y;
}

var it = foo();

it.next(); // { value:3, done:false }

try {
    it.next( 42 ); // `42` won't have `toUpperCase()`
}
catch (err) {
    console.log( err ); // TypeError (from `toUpperCase()` call)
}

代理 Generators函数

在使用generator函数的过程中,另外一件你可能想要做的事就是在generator函数内部调用另外一个generator函数。这儿我并不是指在普通函数内部执行generator函数,实际上是把迭代控制权委托给另外一个generator函数。为了完成这件工作,我们使用了yield关键字的变种:yield *(“yield star”)。

例如:

function *foo() {
    yield 3;
    yield 4;
}

function *bar() {
    yield 1;
    yield 2;
    yield *foo(); // `yield *` delegates iteration control to `foo()`
    yield 5;
}

for (var v of bar()) {
    console.log( v );
}
// 1 2 3 4 5

在第一篇文章中已经提及(在第一篇文章中,我使用function *foo() { }的语法格式,而不是function* foo() { }),在这里,我们依然使用yield *foo(),而不是yield* foo(),尽管很多文章/文档喜欢采用后面一种语法格式。我认为前面一种语法格式更加准确/清晰得表达此语法含义。

让我们来分解上面代码是如何工作的。yield 1yield 2表达式直接将值通过for..of循环(隐式)调用next()传递到外部,正如我们已经理解并期待的那样。

在代码执行过程中,我们遇到了yield *表达式,你将看到我们通过执行foo()将控制权交给了另外一个generator函数。因此我们基本上就是出产/委托给了另外一个generator函数的迭代器- -也许这就是最准确的理解代理generator函数如何工作的。

一旦yield *表达式(临时的)在*bar()函数中将控制权委托给*foo()函数,那么现在for..of循环中的next()方法的执行将完全控制foo(),因此yield 3yield 4表达式将他们的值通过for..of循环返回到外部。

*foo()运行结束,控制权重新交回最初的generator函数,最后在外层bar函数中执行yield 5

简单起见,在上面的实例中,我们仅通过yield表达式将值传递到generator函数外部,当然,如果我们不用for..of循环,而是手动的执行迭代器的next()方法来向函数内部传递值,这些值也会按你所期待的方式传递给通过yield *代理的generator函数中:

function *foo() {
    var z = yield 3;
    var w = yield 4;
    console.log( "z: " + z + ", w: " + w );
}

function *bar() {
    var x = yield 1;
    var y = yield 2;
    yield *foo(); // `yield*` delegates iteration control to `foo()`
    var v = yield 5;
    console.log( "x: " + x + ", y: " + y + ", v: " + v );
}

var it = bar();

it.next();      // { value:1, done:false }
it.next( "X" ); // { value:2, done:false }
it.next( "Y" ); // { value:3, done:false }
it.next( "Z" ); // { value:4, done:false }
it.next( "W" ); // { value:5, done:false }
// z: Z, w: W

it.next( "V" ); // { value:undefined, done:true }
// x: X, y: Y, v: V

尽管上面的代码中我们只展示了嵌套一层的代理generator函数,但是没有理由*foo()不可以通过yield *表达式继续代理其他的generator迭代器,甚至继续嵌套代理其他generator函数,等等。

yield *表达式可以实现另外一个窍门,就是yield *表达式将会返回被代理generator函数的函数返回值。

function *foo() {
    yield 2;
    yield 3;
    return "foo"; // return value back to `yield*` expression
}

function *bar() {
    yield 1;
    var v = yield *foo();
    console.log( "v: " + v );
    yield 4;
}

var it = bar();

it.next(); // { value:1, done:false }
it.next(); // { value:2, done:false }
it.next(); // { value:3, done:false }
it.next(); // "v: foo"   { value:4, done:false }
it.next(); // { value:undefined, done:true }

正如你所见,yield *foo()正在代理迭代器的控制权(调用next()方法)至到其运行完成,当前执行完成,foo()函数的函数return值(本例中是"foo"字符串)将会作为yield *表达式的值,在上例中将该值赋值给变量v

这是一个yieldyield*表达式有趣的区别:在yield表达式中,表达式的返回值是通过随后的next()方法调用传递进来的,但是在yield *表达式中,它将获取到被代理generator函数的return值(因为next()方法显式的将值传递到被代理的generator函数中)。

你依然可以双向的对yield *代理进行错误处理(如上所述):

function *foo() {
    try {
        yield 2;
    }
    catch (err) {
        console.log( "foo caught: " + err );
    }

    yield; // pause

    // now, throw another error
    throw "Oops!";
}

function *bar() {
    yield 1;
    try {
        yield *foo();
    }
    catch (err) {
        console.log( "bar caught: " + err );
    }
}

var it = bar();

it.next(); // { value:1, done:false }
it.next(); // { value:2, done:false }

it.throw( "Uh oh!" ); // will be caught inside `foo()`
// foo caught: Uh oh!

it.next(); // { value:undefined, done:true }  --> No error here!
// bar caught: Oops!

如你所见,throw("Uh oh!")通过yield*代理将错误抛出,然后*foo()函数内部的try..catch模块捕获到错误。同样地,在*foo()函数内部通过throw "Oops!"抛出错误冒泡到*bar()函数中被另外一个try..catch模块捕获,如果我们没有捕获到其中的某一条错误,该错误将会按你所期待的方式继续向上冒泡。

总结

Generators函数拥有同步执行的语义,这也意味着你可以通过try..catch错误处理机制来横跨yield语句进行错误处理。同时,generator迭代器有一个throw()方法来向generator函数中暂停处抛出一个错误,该错误依然可以通过generator函数内部的try..catch模块进行捕获处理。

yield *关键字允许你将迭代控制权从当前generator函数委托给其他generator函数。结果就是,yield *将扮演一个双向的信息和错误传递角色。

但是到目前为止,一个基础的问题依然没有解决:generator函数怎么帮助我们处理异步模式?在以上两篇文章中我们一直讨论generator函数的同步迭代模式。

构想generator函数异步机制的关键点在于,通过generator函数的暂停执行来开始一个异步任务,然后通过generator函数的重新启动(通过迭代器的next()方法的执行)来结束上面的异步任务。我们可以在接下来的文章中发现generator函数形式各样的异步控制机制。近期期待!

如何做一次高质量的技术分享

在开始分享之前想说,这不是严格意义的分享,更多是关于如何做好技术分享的讨论会,所以我们就从一个问题开始

问题 1:分享之前,问大家一个问题,我们为什么要做技术分享?

技术分享和个人成长的关系

  • 分享是学习知识最高效的方式,收益最大的是自己而非观众。这句话也是费曼学习法最直接的诠释,费曼学习法的理念就是把复杂的知识简单化,以教代学,让输出倒逼输入。所以说分享也是一个对已知知识、认知、**升华的过程
  • 分享可以为听众打开知识的大门,但是否愿意跨进大门(对分享内容感兴趣进而进一步了解)还是取决观众自身。很多分享者都有一个心理负担,认为一场高质量的分享就是在 40 分钟内,将自己在某个领域的知识向观众倾囊相授。首先,一个领域的知识不可能短时间讲述清楚,并做到面面俱到。其次,观众对于一场技术分享,第二天能够记住的通常只剩下 10% 了,所以说,分享更多是知识的索引,观众想进一步了解还需自行阅读更多相关资料和文献
  • 提升技术影响力,也是认识业界大佬的机会。分享不仅能够升华我们对某领域知识的理解、加深技术项目的思考,在参与技术大会过程中,也会认识很多业界大佬,交流心得和体会

流程图 (6)

了解分享的规模和观众画像

不同场合的技术分享,对分享主题和分享时长都是有要求的,比如 QCon 就要求分享的主题不能在其他会议上分享过。FDCon 就有不同会场,那 FDCon 2018 来说,就有 H5 小程序专场、全端与全栈专场、前端基础设施专场等。所以当我们被邀作为嘉宾参会时,我们所选的主题也就需要所涉会场之一相关。一般的技术大会,单场分享时间一般需控制在 40 分钟,分享后会有大约 10 分钟的观众提问时间。JSConf(Conferences for the JavaScript Community)在分享间隙有个 lighting talk 的环节,在常规分享间隙插入 5 分钟的即兴演讲,如果你想在下一年作为演讲嘉宾登上 JSConf,那么 lighting talk 是个很好展示自己的机会

问题2:我们为什么需要事先了解一场分享时长?

是的,分享内容长度是和分享时长息息相关的,普通人演讲的语速大概在 150 字 ~ 180 字 / 分钟,所以一场 40 分钟的演讲,演讲稿字数大约在 6000 字 ~ 7200 字,这样我们在准备演讲稿的时候,也就知道大概准备多少内容了。

问题3:下一个问题,为什么我们需要去了解分享的规模(单场参与人数)和受众群体(观众画像)?

一个 1000 人的会场和一个 100 人的会场,所需要的控场能力不全一样,1000 人的会场,在灯光的照射下,我们几乎看不清下面的观众,我们可能就需要减少一些与观众的互动,不然选择观众、传递话筒等都会耗费比较长的时间。而 100 人的会场,几乎可以看清每个人,可以加强一些与观众互动的环节

受众群体也决定了我们分享的侧重点,举例说明下,比如电商分享,我们就不应该去分享纯前端相关的技术,而应该分享电商业务、与电商相关的技术及架构设计,比如营销系统的设计,推荐算法在电商业务中的运用等,尽量少的涉及源码分析,如果一定需要源码来说明的话,我们也尽可能使用伪代码。又比如电商消费侧前端的技术分享,面向的受众都是前端同学,大家更关心的是前端技术如何赋能业务,想了解底层原理,在做前端技术分享时,就可以侧重系统设计及架构,源码分析等

挑选合适的分享主题

问题4:我们如何选择分享主题?

选择小的主题,在选择主题上,除了需要和会议会场相关,比如前端技术分享,那至少应该和前端技术相关。我们应该选择一些小的主题,大的主题容易不聚焦,同时使得我们的分享变得泛泛而谈,缺乏深意。举个例子:

Bad Case:性能优化在前端项目的应用
Good Case:我们如何在商超项目进行图片性能优化

下面一个主题比上面一个主题更加具体,也更能引起观众的兴趣和共鸣

选择自己参与或主导的技术项目,我们在做技术建设,一定遇到过很多问题,踩过很多坑,通过对过往遇到的问题的总结,不仅能够加深自己对问题的思考和理解,同时也会帮助到别人

准备分享稿

分享模型

其实技术分享都是有规律可循的,我将技术分享归为两种模型:复杂模型和简单模型

复杂模型:[发现问题 - 背景梳理] - [架构总览 - 方案对比 - 核心模块分析] - 展望未来
简单模型:发现问题 - 方案陈诉 - 总结陈词

选择不同的模型,主要根据分享时长来决定,比如 20 分钟的一个分享,那么我们就可以使用简单模型,而 40 分钟的技术分享,相对时间比较充裕,那我们就可以选择复杂模型。不论是哪种模型,都遵循了发现问题 -> 解决问题的思路,其实这也是为了将观众带入第一视角,使观众能够沉浸其中

这儿将以我之前的一次技术分享来分析复杂模型的应用。

一种自动化生成骨架屏的方案,这是我在 FDCon2018 的分享

发现问题

一份是 Akamai 的研究报告,当时总共采访了大约 1048 名网上购物者,得出了这样的结论:

  • 大约有 47% 的用户期望他们的页面在两秒之内加载完成
  • 如果页面加载时间超过 3s,大约有 40% 的用户选择离开或关闭页面
  • ......

背景梳理

通常方案,我们会在首屏、或者获取数据时,在页面中展现一个进度条,或者转动的 Spinner。

  • 进度条:明确知道交互所需时间,或者知道一个大概值的时候我们选择使用进度条。
  • Spinner:无法预测获取数据、或者打开页面的时长。
    ......
    其实,骨架屏(Skeleton Screen)已经不是什么新奇的概念了,Luke Wroblewski 早在 2013 年就首次提出了骨架屏的概念,并将这一概念成功得运用到他当时的产品「Polar app」中,2014 年,「Polar」加入 Google,Luke Wroblewski 本人也成为了Google 的一位产品总监。
    他是这样定义骨架屏的,他认为骨架屏是一个页面的空白版本,通过这个空白版本传递信息,我们的页面正在渐进式的加载过程中。
    苹果公司已经将骨架屏写入到了 iOS Human Interface Guidelines ,只是在该手册中,其用了一个新的概念「launch images」。在该手册中,其推荐在应用首屏中包含文本或者元素基本的轮廓。
    2015 年,Facebook 也首次在其移动端 App 中使用了骨架屏的设计来预览页面的加载状态。

架构总览

通过 puppeteer 在服务端操控 headless Chrome 打开开发中的需要生成骨架屏的页面,在等待页面加载渲染完成之后,在保留页面布局样式的前提下,通过对页面中元素进行删减或增添,对已有元素通过层叠样式进行覆盖,这样达到在不改变页面布局下,隐藏图片和文字,通过样式覆盖,使得其展示为灰色块。然后将修改后的 HTML 和 CSS 样式提取出来,这样就是骨架屏了。
架构图示意

方案对比

在选择骨架屏之前,我们也调研了其他两种备选方案:服务端渲染(ssr)和预渲染(prerender)。
......
核心模块分析(最好进行源码分析)
下面我将通过 page-skeleton-webpack-plugin 工具中的代码,来展示骨架屏的具体生成过程。
正如上面基本方案所描述的那样,我们将页面分成了不同的块:

  • 文本块:......
  • 图片块:......(不同图片块方案对比)
  • 按钮块:......
  • Webpack 插件:......
  async makeSkeleton(page) {
      const { defer } = this.options
      // ...
  }

展望未来

Page Skeleton webpack 插件在我们内部团队已经开始使用,在使用的过程中我们也得到了一些反馈信息。
首先是对 SPA 多路由的支持......
其次,玩过服务端渲染的同学都知道,在 React 和 Vue 服务端渲染中有一种称为 Client-side Hydration 的技术,指的是在 Vue 在浏览器接管由服务端发送来的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。
......
还有,在页面启动后,我们可能还是会通过 AJAX 获取后端数据,这时候我们也可以通过 骨架屏 来作为一种加载状态。也就是说,其实我们可以在「非首屏骨架屏」上做一些工作。
最后,在项目中可能会有一些性能监控的需求,比如骨架屏什么时候创建,什么时候被销毁,这些我们可能都希望通过一些性能监控的工具记录下来,以便将来做一些性能上面的分析。......

技术分享合理使用图表

问题5:再问大家一个问题,合理使用图表对我们的技术分享有什么帮助?

“Programming without an overall architecture or design in mind is like exploring a cave with only a flashlight: You don’t know where you’ve been, you don’t know where you’re going, and you don’t know quite where you are.”
Danny Thorpe(Danny Thorpe was an American programmer noted mainly for his work on Delphi)

  1. 一张好的架构图能够让观众对整个技术项目有个整体的感知,后面再拆模块讲技术方案时,能够清晰的知道该模块在整个系统中的地位和作用
  2. 一图胜千言,图表往往比文字更具表现力和感染力,比如数据的变化我们用折线图就比较直观,能力模型我们用雷达图就很清晰,市场份额占比我们用饼状图来变现就很明了

问题6:下一个问题,既然图表在技术分享中如此重要,那么我们怎样去合理使用图表呢?

“Incorrect documentation(diagram) is often worse than no documentation(diagram).”
Bertrand Meyer(编程语言专家,Eiffel 语言的创造者)

首先我们应该知道有哪些架构图类型可用,比如分层架构图、时序图、流程图等,其次我们应该了解目前有哪些软件架构模式,最后根据我们架构模式选择最合适的架构图(这儿的架构图是广义的架构图,不单指分层架构图)

架构图的类型(不止下面三种类型,这儿只列举了常见的三种):

分层架构图

image

时序图

image

流程图

image

关于如何画好架构图、时序图、流程图,推荐大家阅读以下几篇文章:

如何画好架构图_架构_Hockor_InfoQ写作社区
如何画好一张架构图?(内含知识图谱) - 掘金
快速学习时序图:时序图简介、画法及实例 | 人人都是产品经理
如何画好流程图_前端_Hockor_InfoQ写作社区

常见的软件架构模式

分层架构(Layered (N-tier) architecture)

我们经常在业务架构或者服务端上见到分层架构模式,分层架构将业务拆分成水平向的几层,层与层之间有逻辑依赖和信息交互,每一层都有自己独立的角色或功能,比如在上面分层架构图示例中,最上层是展现层,展现层和服务层之间通过通信层来沟通交流,最底层是数据层。分层架构突出了软件架构的一个核心**,关注点分离(separation of concerns (SoC))。

问题7:又一个问题,你还知道哪些和前端相关的分层架构的系统?

客户端-服务端架构(Client-server architecture)

这个应该是我们最熟悉不过的一种架构模式了,目前我们做的浏览器应用、客户端应用都属于该模式(去中心化应用 DAPP 不属于该架构模式),客户端-服务端架构主要包含两个组成部分:服务发起者和服务提供者。

image

微内核架构(Microkernel architecture)

微内核架构通常也被称作插件化架构模式,也是前端领域比较常见的一种架构模式了。在该架构模式中主要有两个组件: core system 和 plug-in modules。这也就支持了第三方去开发一些插件,在前端领域比如 webpack、Koa 都属于微内核架构。

image

Core system 包含了应用能够运行起来的最小逻辑部分,通过 Plug-in 模块来扩展 Core system,从而提供更多的功能。

问题8:在我们熟悉的开源项目,或者我们目前的技术建设中,你还知道哪些微内核架构?

软件工程还有其他的一些架构模型,由于和前端领域关系比较小,还有分享时间限制,这儿就不赘述了

  • 事件驱动架构
  • 微服务架构

有兴趣大家可以看看 Software architecture diagramming and patterns 这篇文章,总结得很好

讲解技术方案的要点

首先,把复杂的问题讲解的很简单也很清楚,关于怎么将复杂问题简单化,其实有方法论可循的,开篇一张架构图固然是好,但也只能让观众有个全局感知,对各个模块的理解还是不够深入,这个时候我们就可以举一个具体的 case,来阐述我们的技术方案,还是拿上面自动化生成骨架屏的分享为例,在说完整个原理之后,就会拆解到不同的模块,比如图片模块或者文本模块,并且通过关键代码来解释不同模块的实现。另外一个将复杂问题简单化的方法就是,我们尽量通过最小例子来解释,抛开一切和主流程不相关的分支。其实这个**也类似于微内核架构的**,比如我们要阐述 babel 的原理,那么我们只需说清楚 babel 处理代码的三个阶段:Parser -> Transformer -> Generator 就行,关于 babel plugin、presets 在解释明白核心原理上就不那么重要了

其次、有各种各样的推导和方案的比较,让观众知其然知其所以然,相信我们在做技术方案的时候,都做技术方案的选型(比较),以及从发现问题到解决问题该技术方案是推演出来的,这可能比我们直接陈述技术方案要更能打动观众,也能带领观众进入你的第一视角,重演一次发现问题到解决问题的整个过程

最后、原理、思路、方法论会让人一通百通,当我们把原理和思路讲清楚后,我们就可以在分享的最后部分,对其进行升华,形成一套方法论,计算机科学中很多架构模式、设计模式都是相通的,就拿操作系统进程调度来说,有不同的调度策略,先来先服务调度算法、最高优先级调度算法、最短作业优先调度算法、高响应比优先算法、时间片轮转调度算法、多级反馈队列(Multilevel Feedback Queue),当时我在看操作系统进程调度这部分的时候就在思考,进程的调度策略和我们接需求开发其实是类似的,最简单的模型就是我们按照先来先服务的算法,一段时间后,有产品说这是 P0 优先级的项目,其他项目先暂停,按照项目优先级来定容,这个时候我们可能会和产品 argue,虽然目前这个需求优先级不高,但是开发周期比较短,短时间上线是否可以先拿到一些收益?这就是最短作业调度算法,当有研发团队服务于多个产品团队时候,是否需要采用高响应比优先算法呢?谁等待时间长,优先服务,因为不同的产品团队也比较难排出一个准确的需求优先级。当我们同时开发多个需求的时候,就可以采用时间片轮转调度算法,这周前三天把 A 需求开发完成,后两天开发 B 需求。当然多级反馈队列是最合理的,首先将需求划分成不同的优先级,按照优先级来进行调度,需求结束后 PMO 组织产研进行价值回溯,对业务收益大的项目提升优先级,收益不那么明显的项目降低优先级。这儿扯得有点远了,收一下,其实想表达核心点就是我们在技术分享结束部分,对我们的技术项目做一些升华和拓展,方法论一通百通,解决 A 问题的思路是否可以用来解决 B 问题?

观众问题准备及话术

一般技术分享都会有 QA 环节,针对我们的分享内容,我们从观众的角度出发,或者从一个对该分享的技术方案小白的角度出发,他们会问些什么问题,整理一个问题列表(不用展示给观众),针对这些问题,我们思考一下怎么回答,并且事先准备好问题答案,这样在分享后的 QA 环节就可以避免因为紧张而回答不上来

如果观众问到的问题,是我们之前没有想到过,并且现场也没有答案时,我们需要准备一个话术,比如:“这个问题很有深度,由于 QA 环节时间有限,很难通过几句话解释清楚,会后我们单独讨论一下”

其他有助于提升分享质量的点

  1. 分享稿用语避免太过书面,应该更加口语化,因为分享是一个互动的过程,口语化更适合沟通,拉近分享中和观众的距离
  2. 向其他有过分享经验的同学请教,如何准备一场技术分享,分享的各个环节应该注意什么

排练和预演

确定分享中各个模块时间分配,找个安静的地方,排练,并进行录音,通常现场更不可控,有突发的提问,紧张时语速会过快,所以现场可以放一个计数器,准确控制分享进度。播放录音,看语速是否过快,发音是否准确,也看看分享中是否有节奏,而不是平铺直叙的背诵文章,预演也是为了最终脱稿

分享过程中的注意事项

分享中的 checklist:

  • 自我介绍(我是谁、履历、基于什么原因我来做这次分享)
  • 现场准备一个计时器,来精确控制整个分享过程的进度,各个模块的时间分配
  • 在分享前先介绍一下分享分哪几个部分,每个部分包含哪些内容,最忌讳一上来就进入正题,在介绍分享分哪几个部分时,观众会对本次分享有个大局观
  • 和观众互动、注意留白(提醒观众需要注意了),但是不要让观众误以为是忘词了
  • 分享结束,留社交账号,用于进一步沟通交流,致谢关注

分享后

  • 在技术分享大会,我们可以利用分享间隙、晚宴等场合,这是认识业界大佬很好的机会,主动沟通交流
  • 收集反馈,如果正好有朋友、同事在场,可以问问他们分享得怎么样,哪些地方可以改进
  • 分享现场一般都会有技术书籍出版社(图灵社区)的同学,他们一般都会主动找分享嘉宾加微信,问是否有出书的需求,如果你有出版技术书籍的打算,和出版社保持联系是有用的

絮叨许久,感谢大家,这是我在做技术分享后的一些经验之谈,希望对大家有所帮助,最重要的一点,适合自己的才是有用的

《赋能》读后感

我一直在思考,看完一本书后,为什么要写读后感,写读后感的作用?也许就是为了给别人、给将来的自己,介绍你看过的这本书,让他们对这本书产生兴趣,进而去阅读。这也是分享的本质,阅读的乐趣之一了。

我看了《赋能》加深了我对组织架构、团队建设的思考,以至于我本能的想把这本书分享给大家,上次有这想法的时候是阅读曾文正著的《曾国藩家书》。本篇读后感的思路将会和《赋能》的组织段落相似,分为以下三个部分:

  1. 在错综复杂的环境下,现在的团队遇到了什么问题?

  2. 我们怎样去调整组织架构,创建调整适应能力强的团队?

  3. 在新的团队架构中,管理者的职责应该怎样转变?

一、在错综复杂的环境下,现在的团队遇到了什么问提

本书的作者之一是一位美军军官,他在伊拉克反恐的过程中,遭遇到了前所未有的一些困难,甚至在面对伊拉克”基地“组织的恐怖袭击时,节节受挫,虽然美军对许多”基地“集聚地进行了打击,击毙或者抓获了多名”基地“组织的高层和中层成员,但是伊拉克”基地“组织的恐怖袭击,特别是”汽车炸弹“并没有因此而衰减,反而愈演愈烈,爆炸袭击变得愈发频繁。美军拥有先进的侦查系统及武器设备,以及实时的通信系统,可以将战况即使的反馈到指挥部,指挥部再对战况进行分析,进而做出进一步指示。而”基地“组织没有这么好的系统,通信经常被中断,炸弹也都是手工制作,那么为什么先进的美军却无法战胜落后的”基地“组织呢?

究其原因,”基地组织“的组织架构,并不是传统意义上的自上而下的组织架构,而是一张网状的结构,即使美军抓获了他们某一个高层,但是并不能破坏整张网,因为网状结构中的成员,并不像自上而下的组织架构那样,只能从上层领导那儿获取信息,他可以从其他成员那儿获取到信息,这也就是为什么美军抓获了一个高层,对整个基地组织没有太大的影响,因为这样的网状架构,其”自生“能力,很快就会有另外一个人补缺,这也是美军在最初和伊拉克”基地“组织战斗中节节败退的原因所在。

二、我们怎样去调整组织架构,创建调整适应能力强的团队

在和伊拉克”基地“组织的战斗中,书作者也在不断学习”基地“组织的组织结构。正如作者所说

如同伊拉克基地组织一开始观察我们,像我们学习一样,我们也不得不收齐我们的骄傲,开始像他们学习,要想击败网状组织,我们需要另外一张网。

2.1 建立团队成员之间的互信

在还原论(一种之上而下的管理体系)的体系下,管理者分派任务,下面的工人按标准和流程来完成任务,工人间不需要有过多的交流,交流反而会影响效率,管理者反而会反感甚至反对”工会“这样的组织,因为这些组织将工人联系起来,组成了一个整体,增加了工人间的交流,工会的存在,就会有因为工资太低而导致罢工的存在。

还原论的管理体系在20世纪早期是行得通的,那个时候世界环境并不像现在一样复杂,那个时候电话不像现在这样盛行,也没有互联网,很多人都局限在一个很小的圈子里,工作上也不需要太多的合作,工人们只要按照标准以及规则,就能够生产出符合标准的零件。但是随着科技的发展,世界变得错综复杂,简单的还原论管理体系已经无法满足如今的组织管理需求,成员之间如果不进行沟通以及相互信任,是无法完成团队的目标了,就像一个球队,队员们都各自踢自己的球,队员之间也没有交流和信任,那么就不可能完成一场满意的比赛,因此建立团队成员之间的互信是打造新型团队的基础。

书作者在打造互信团队上有自己的一套方法,”水下爆破训练“,这必须是两个人一起才能完成的训练,训练中,成员间一人拿着表,一人拿着指南针,通过手势相互沟通,向着目标地行进,这个训练没有很高的难度,但是两个人必须相互信任、手势沟通、倾力协作下才能完成,而最终完成的标准是两个人都需要到达终点,而不是某个人到达终点。在这个训练中,锻炼了成员间的相互信任。通过”水下爆破训练“,在战场上,成员间也愿意信任对方,并且能够很好的沟通,一个手势,一个眼神,就知道队友下一步将要做什么,甚至能够预测到对自己带来什么样的结果。

书作者这样描述”水下爆破训练“

海豹突击队水下爆破训练为了在队友之间建立信任感,一开始甚至有些蛮横的要求队员们一起吃饭,而那些最终挺过整个训练过程的人,也愿意将自己的生命交给海豹突击队的队友。

2.2 目标统一、锻炼团队的自发智慧

在团队成员之间建立起了互信使得团队具有重新布局的能力,并且重新布局后去做”正确的事“,团队成员也必须明确知道什么是”正确的事“。团队成员必须都向同一个目标努力,而在一个易变、错综复杂的环境里,目标可能会发生变化。

当团队有了统一的目标后,并且在相互信任的基础上,就会自发的去做一些能够促进目标完成的工作,这儿和还原论有一个本质的区别,在还原论中,工作是之上而下安排的,而在一个拥有自发智慧的团队中,团队成员会去自发的完成一些工作,这些工作都有一个统一的目标,就是促进团队目标的完成。

2.3 打造新型团队、培养共享意识

在之前,我们的组织架构都是自上而下的,在计算机科学中,可以描述成一颗”树“形结构,树的根节点通常是一个工厂的负责人,下面分属不同的部门,分别制造不同的零件,每个部门下又有不同的工人,整个工作都是自上而下安排的,工厂负责人指定工厂的目标,部门领导人根据以上目标进行分解,指定部门的目标,每个部门再将这些目标分解到每一个工人身上。这看似完美的组织架构,却不再适合目前错综复杂的环境了,很多弊端也都凸显出来,成员之间缺少沟通,部门与部门之间缺少交流,最终造成生产出来的部件不能够很好的匹配。

那么怎样的团队才能够适应目前错综复杂的环境了,正如之前讨论的伊拉克”基地“组织,我们要战胜一张网状结构的组织,我们只能创建另外一张网状组织,在这个新型的组织架构中,团队成员需要对整个体系有大致的了解,例如生成汽车轮子的工人需要了解整个汽车的原理,外科医生需要了解一些内科病理知识一样。一个团队成员去了解整个体系,势必会对效率造成影响,在短期内甚至会降低效率,但是从长远来看,反而会提升整个公司的效率,因为每个团队成员都对整个体系有所了解,就可以很好的避免一些低级错误,比如两个部门造出来的零件尺寸不匹配等问题。

当然打造体系思维,首先需要培养共享意识,只有信息的充分共享,团队成员才有渠道去了解整个体系。

2.4 以合作取代竞争、击败”囚徒困境“

在还原论的组织架构下,部门与部门之间是缺少合作的,更多的是处于一种竞争关系,团队之间、团队成员之间的竞争会导致低效率,一个经典的失败案例就是欧洲运载火箭发展组织,该组织集结了意大利、英国、法国、德国等国家,共同研究火箭发射,每个国家各司其职,英国研发火箭的第一节,法国制造第二节、德国制造第三节、意大利则制造卫星实验舱,但是他们的实验却接连失败,失败的原因是每个国家都视图实现自己的经济利益最大化,因此对信息、技术都藏着、掖着,研发信息也都没有拿出来共享,这就导致了每节火箭之间的一些零件不匹配,最终导致了合作的失败,”在一起做事三心二意、各怀鬼胎“。

相反,美国航空航天局的登月计划,以最终的登月成功宣告胜利,在登月计划中,总共雇佣了 2 万多个承包商,200 所大学、他们分布在 80 多个国家,雇员超过 30 万人,项目总投入 190 亿美元,在这么大的一个项目中,信息依然是共享的,会有一个专门的机构来收集信息,每天进行更新,并且公示给所有团队成员,这样每个成员对整个体系有大致的了解,并且知道其他团队的研发进度,以此来对本团队的研发做出调整,正是这样以合作来替代竞争,最终促使了美国航空航天局的登月计划的成功。

三、在新的组织架构下,领导者的角色转变

3.1 赋能

随着外部环境变得越来越错综复杂,市场、战场都在不断变化着,首先领导者已经不能像还原论组织架构中那样,对整个系统的每一个块都有详细的理解,并且能够做出正确的决策。其次,决策的传递是需要时间和空间的配合的,球场上的球员不需要每次传球都征求教练的意见,一是不现实,教练在球场外,根本没办法即使交流,二是最了解球场环境的依然是球员自己。这个时候,领导者、教练就需要将权力下放出去,让团队成员、球员自主做出决策,领导者会在下放权力之前,告诉团队成员,自己是怎样做决策的,做决策的一些考虑因素,这样也保证了团队成员最后做出的决策大体和总体目标是相符的,这一过程,被称为”赋能“。

在团队初期,赋能往往达不到预期的结果,因为在团队初期,团队成员之间的信任感、共享意识以及团队的共同目标都还没有建立起来,因此建立良好的团队氛围就成了领导者的首要责任,这也为赋能打下坚实的基础。

3.2 像园丁一样领导

在复杂环境上,去建立一个拥有团队信任感、拥有自发智慧的团队,这是领导者的首要职责,这个时候,领导者不应该像国际象棋大师那样,对每一步棋都进行控制,而应该像园丁一样,更多的培养,而不是指导。但是园丁的领导方式并不是放任不管,任其发展,而应该是”双眼紧盯、双手放开“,建立和维系一个良好的生态系统,并让组织在其中良性发展。

通过ES6 Generator函数实现异步操作

本文翻译自 Going Async With ES6 Generators

由于个人能力知识有限,翻译过程中难免有纰漏和错误,还望指正

ES6 Generators:完整系列

  1. The Basics Of ES6 Generators
  2. Diving Deeper With ES6 Generators
  3. Going Async With ES6 Generators
  4. Getting Concurrent With ES6 Generators

到目前为止,你已经对ES6 generators有了初步了解并且能够方便的使用它,是时候准备将其运用到真实项目中提高现有代码质量。

Generator函数的强大在于**允许你通过一些实现细节来将异步过程隐藏起来,**依然使代码保持一个单线程、同步语法的代码风格。这样的语法使得我们能够很自然的方式表达我们程序的步骤/语句流程,而不需要同时去操作一些异步的语法格式。

换句话说,我们很好的对代码的功能/关注点进行了分离:通过将使用(消费)值得地方(generator函数中的逻辑)和通过异步流程来获取值(generator迭代器的next()方法)进行了有效的分离。

结果就是?不仅我们的代码具有强大的异步能力, 同时又保持了可读性和可维护性的同步语法的代码风格。

那么我们怎么实现这些功能呢?

最简单的异步实现

最简单的情况,generator函数不需要额外的代码来处理异步功能,因为你的程序也不需要这样做。

例如,让我们假象你已经写下了如下代码:

function makeAjaxCall(url,cb) {
    // do some ajax fun
    // call `cb(result)` when complete
}

makeAjaxCall( "http://some.url.1", function(result1){
    var data = JSON.parse( result1 );

    makeAjaxCall( "http://some.url.2/?id=" + data.id, function(result2){
        var resp = JSON.parse( result2 );
        console.log( "The value you asked for: " + resp.value );
    });
} );

通过generator函数(不带任何其他装饰)来实现和上面代码相同的功能,实现代码如下:

function request(url) {
    // this is where we're hiding the asynchronicity,
    // away from the main code of our generator
    // `it.next(..)` is the generator's iterator-resume
    // call
    makeAjaxCall( url, function(response){
        it.next( response );
    } );
    // Note: nothing returned here!
}

function *main() {
    var result1 = yield request( "http://some.url.1" );
    var data = JSON.parse( result1 );

    var result2 = yield request( "http://some.url.2?id=" + data.id );
    var resp = JSON.parse( result2 );
    console.log( "The value you asked for: " + resp.value );
}

var it = main();
it.next(); // get it all started

让我来解释下上面代码是如何工作的。

request(..)帮助函数主要对普通的makeAjaxCall(..)实用函数进行包装,保证在在其回调函数中调用generator迭代器的next(..)方法。

在调用request(..)的过程中,你可能已经发现函数并没有显式的返回值(换句话说,其返回undefined)。这没有什么大不了的,但是与本文后面的方法相比,返回值就显得比较重要了。这儿我们生效的yield undefined

当我们代码执行到yield..时(yield表达式返回undefined值),我们仅仅在这一点暂停了我们的generator函数而没有做其他任何事。等待着it.next(..)方法的执行来重新启动该generator函数,而it.next()方法是在Ajax获取数据结束后的回调函数(推入异步队列等待执行)中执行的。

我们对yield..表达式的结果做了什么呢?我们将其结果赋值给了变量result1。那么我们是怎么将Ajax请求结果放到该yield..表达式的返回值中的呢?

因为当我们在Ajax的回调函数中调用it.next(..)方法的时候,我们将Ajax的返回值作为参数传递给next(..)方法,这意味着该Ajax返回值传递到了generator函数内部,当前函数内部暂停的位置,也就是result1 = yield..语句中部。

上面的代码真的很酷并且强大。本质上,result1 = yield request(..)作用是用来请求值,但是请求的过程几乎完全对我们不可见- -或者至少在此处我们不用怎么担心它 - - 因为底层的实现使得该步骤成为了异步操作。generator函数通过通过在yield表达式中隐藏的暂停功能以及将重新启动generator函数的功能分离到另外一个函数中,来实现了异步操作。因此在主要代码中我们通过一个同步的代码风格来请求值

第二句result2 = yield result()(译者注:作者的笔误,应该是result2 = yield request(..))代码,和上面的代码工作原理几乎无异:通过明显的暂停和重新启动机制来获取到我们请求的数据,而在generator函数内部我们不用再为一些异步代码细节为烦恼。

当然,yield的出现,也就微妙的暗示一些神奇(啊!异步)的事情可能在此处发生。和嵌套回调函数带来的回调地狱相比,yield在语法层面上优于回调函数(甚至在API上优于promise的链式调用)。

需要注意上面我说的是“可能”。generator函数完成上面的工作,这本身就是一件非常强大的事情。上面的程序始终发送一个异步的Ajax请求,假如不发送异步Ajax请求呢?倘若我们改变我们的程序来从缓存中获取到先前(或者预先请求)Ajax请求的结果?或者从我们的URL路由中获取数据来立刻fulfillAjax请求,而不用真正的向后端请求数据。

我们可以改变我们的request(..)函数来满足上面的需求,如下:

var cache = {};

function request(url) {
    if (cache[url]) {
        // "defer" cached response long enough for current
        // execution thread to complete
        setTimeout( function(){
            it.next( cache[url] );
        }, 0 );
    }
    else {
        makeAjaxCall( url, function(resp){
            cache[url] = resp;
            it.next( resp );
        } );
    }
}

**注意:**在上面的代码中我们使用了一个细微的技巧setTimeout(..0),当从缓存中获取结果时来延迟代码的执行。如果我们不延迟而是立即执行it.next(..)方法,这将会导致错误的发生,因为(这就是技巧所在)此时generator函数还没有停止执行。首先我们执行request(..)函数,然后通过yield来暂停generator函数。因此不能够在request(..)函数中立即调用it.next(..)方法,因为在此时,generator函数依然在运行(yield 还没有被调用)。但是我们可以在当前线程运行结束后,立即执行it.next(..)。这就是setTimeout(..0)将要完成的工作。在文章后面我们将看到一个更加完美的解答。

现在,我们generator函数内部主要代码依然如下:

var result1 = yield request( "http://some.url.1" );
var data = JSON.parse( result1 );
..

**看到没!?**当我们代码从没有缓存到上面有缓存的版本,我们generator函数内部逻辑(我们的控制流程)竟然没有变化。

*main()函数内部代码依然是请求数据,暂停generator函数的执行来等待数据的返回,数据传回后继续执行。在我们当前场景中,这个暂停可能相对比较长(真实的向服务器发送请求,这可能会耗时300~800ms)或者几乎立即执行(使用setTimeout(..0)手段延迟执行)。但是我们*main函数中的控制流程不用关心数据从何而来。

这就是从实现细节中将异步流程分离出来的强大力量。

更好的异步编程

利用上面提及的方法(回调函数),generators函数能够完成一些简单的异步工作。但是却相当局限,因此我们需要一个更加强大的异步机制来与我们的generator函数匹配结合。完成一些更加繁重的异步流程。什么异步机制呢?Promises

如果你依然对ES6 Promises感到困惑,我写过关于Promise的系列文章。去阅读一下。我会等待你回来,<滴答,滴答>。老掉牙的异步笑话了。

先前的Ajax代码例子依然存在反转控制的问题(啊,回调地狱)正如文章最初的嵌套回调函数例子一样。到目前为止,我们应该已经明显察觉到了上面的例子存在一些待完善的地方:

  1. 到目前为止没有明确的错误处理机制,正如我们上一篇学习的文章,在发送Ajax请求的过程中我们可能检测到错误(在某处),通过it.throw(..)方法将错误传递会generator函数,然后在generator函数内部通过try..catch模块来处理该错误。但是,我们在“后面”将要手动处理更多工作(更多的代码来处理我们的generator迭代器),如果在我们的程序中多次使用generators函数,这些错误处理代码很难被复用。
  2. 如果makeAjaxCall(..)工具函数不受我们控制,碰巧它多次调用了回调函数,或者同时将成功值或者错误返回到generator函数中,等等。我们的generator函数就将变得极难控制(未捕获的错误,意外的返回值等)。处理、阻止上述问题的发生很多都是一些重复的工作,同时也都不是轻轻松松能够完成的。
  3. 很多时候我们需要同时并行处理多个任务(例如两个并行的Ajax请求)。由于generator函数中的yield表达式执行后都会暂停函数的执行,不能够同时运行两个或多个yield表达式,也就是说yield表达式只能按顺序一个接一个的运行。因此在没有大量手写代码的前提下,一个yield表达式中同时执行多个任务依然不太明朗。

正如你所见,上面的所有问题都可以被解决,但是又有谁愿意每次重复手写这些代码呢?我们需要一种更加强大的模式,该模式是可信赖且高度复用的,并且能够很好的解决generator函数处理异步流程问题。

什么模式?yield 表达式内部是promise,当这些promise被fulfill后重新启动generator函数。

回忆上面代码,我们使用yield request(..),但是request(..)工具函数并没有返回任何值,那么它仅仅yield undefined吗?

让我们稍微调整下上面的代码。我们把request(..)函数改为以promise为基础的函数,因此该函数返回一个promise,现在我们通过yield表达式返回了一个真实的promise(而不是undefined)。

function request(url) {
    // Note: returning a promise now!
    return new Promise( function(resolve,reject){
        makeAjaxCall( url, resolve );
    } );
}

request(..)函数通过构建一个promise来监听Ajax的完成并且resolve返回值,并且返回该promise,因此promise也能够被yield传递到generator函数外部,接下来呢?

我们需要一个工具函数来控制generator函数的迭代器,该工具函数接收yield表达式传递出来的promise,然后在promie 状态转为fulfill或者reject时,通过迭代器的next(..)方法重新启动generator函数。现在我为这个工具函数取名runGenerator(..):

// run (async) a generator to completion
// Note: simplified approach: no error handling here
function runGenerator(g) {
    var it = g(), ret;

    // asynchronously iterate over generator
    (function iterate(val){
        ret = it.next( val );

        if (!ret.done) {
            // poor man's "is it a promise?" test
            if ("then" in ret.value) {
                // wait on the promise
                ret.value.then( iterate );
            }
            // immediate value: just send right back in
            else {
                // avoid synchronous recursion
                setTimeout( function(){
                    iterate( ret.value );
                }, 0 );
            }
        }
    })();
}

需要注意的关键点:

  1. 我们自动的初始化了generator函数(创建了it迭代器),然后我们异步运行it来完成generator函数的执行(done: true)。
  2. 我们寻找被yield表达式传递出来的promise(啊,也就是执行it.next(..)方法后返回的对象中的value字段)。如此,我们通过在promise的then(..)方法中注册函数来监听器完成。
  3. 如果一个非promise值被传递出来,我们仅仅将该值原样返回到generator函数内部,因此看上去立即重新启动了generator函数。

现在我们怎么使用它呢?

runGenerator( function *main(){
    var result1 = yield request( "http://some.url.1" );
    var data = JSON.parse( result1 );

    var result2 = yield request( "http://some.url.2?id=" + data.id );
    var resp = JSON.parse( result2 );
    console.log( "The value you asked for: " + resp.value );
} );

骗人!等等...上面代码**和更早的代码几乎完全一样?**哈哈,generator函数再次向我们炫耀了它的强大之处。实际上我们创建了promise,通过yield将其传递出去,然后重新启动generator函数,直到函数执行完成- - **所有被''隐藏''的实现细节!**实际上并没有隐藏起来,只是和我们消费该异步流程的代码(generator中的控制流程)隔离开来了。

通过等待yield出去的promise的完成,然后将fulfill的值通过it.next(..)方法传递回函数中,result1 = yield request(..)表达式就回获取到正如先前一样的请求值。

但是现在我们通过promises来管理generator代码的异步流程部分,我们解决了回调函数所带来的反转控制等问题。通过generator+promises的模式我们“免费”解决上述所遇到的问题:

  1. 现在我们用易用的内部错误处理机制。在runGenerator(..)函数中我们并没有提及,但是监听promise的错误并非难事,我们只需通过it.throw(..)方法将promise捕获的错误抛进generator函数内部,在函数内部通过try...catch模块进行错误捕获及处理。

  2. promise给我们提供了可控性/可依赖性。不用担心,也不用疑惑。

  3. Promises拥有一些强大的抽象工具方法,利用这些方法可以自动处理一些复杂的“并行”任务等。

    例如,yield Prmise.all([ .. ])可以接受一个promise数组然后“并行”执行这些任务,然后yield出去一个单独的promise(给generator函数处理),该promise将会等待所有并行的promise都完成后才被完成,你可以通过yield表达式的返回数组(当promise完成后)来获取到所有并行promise的结果。数组中的结果和并行promises任务一一对应(因此其完全忽略promise完成的顺序)。

首先,让我们研究下错误处理:

// assume: `makeAjaxCall(..)` now expects an "error-first style" callback (omitted for brevity)
// assume: `runGenerator(..)` now also handles error handling (omitted for brevity)

function request(url) {
    return new Promise( function(resolve,reject){
        // pass an error-first style callback
        makeAjaxCall( url, function(err,text){
            if (err) reject( err );
            else resolve( text );
        } );
    } );
}

runGenerator( function *main(){
    try {
        var result1 = yield request( "http://some.url.1" );
    }
    catch (err) {
        console.log( "Error: " + err );
        return;
    }
    var data = JSON.parse( result1 );

    try {
        var result2 = yield request( "http://some.url.2?id=" + data.id );
    } catch (err) {
        console.log( "Error: " + err );
        return;
    }
    var resp = JSON.parse( result2 );
    console.log( "The value you asked for: " + resp.value );
} );

当再URL 请求发出后一个promise被reject后(或者其他的错误或异常),这个promise的reject值将会映射到一个generator函数错误(通过runGenerator(..)内部隐式的it.throw(..)来传递错误),该错误将会被try..catch模块捕获。

现在,让我们看一个通过promises来管理更加错综复杂的异步流程的事例:

function request(url) {
    return new Promise( function(resolve,reject){
        makeAjaxCall( url, resolve );
    } )
    // do some post-processing on the returned text
    .then( function(text){
        // did we just get a (redirect) URL back?
        if (/^https?:\/\/.+/.test( text )) {
            // make another sub-request to the new URL
            return request( text );
        }
        // otherwise, assume text is what we expected to get back
        else {
            return text;
        }
    } );
}

runGenerator( function *main(){
    var search_terms = yield Promise.all( [
        request( "http://some.url.1" ),
        request( "http://some.url.2" ),
        request( "http://some.url.3" )
    ] );

    var search_results = yield request(
        "http://some.url.4?search=" + search_terms.join( "+" )
    );
    var resp = JSON.parse( search_results );

    console.log( "Search results: " + resp.value );
} );

Promise.all([ .. ])会构建一个新的promise来等待其内部的三个并行promise的完成,该新的promise将会被yield表达式传递到外部给runGenerator(..)工具函数中,runGenerator()函数监听该新生成的promise的完成,以便重新启动generator函数。并行的promise的返回值可能会成为另外一个URL的组成部分,然后通过yield表达式将另外一个promise传递到外部。关于更多的promise链式调用,参见文章

promise可以处理任何复杂的异步过程,你可以通过generator函数yield出去promises(或者promise返回promise)来获取到同步代码的语法形式。(对于promise或者generator两个ES6的新特性,他们的结合或许是最好的模式)

runGenerator(..): 实用函数库

在上面我们已经定义了runGenerator(..)工具函数来顺利帮助我们充分发挥generator+promise模式的卓越能力。我们甚至省略了(为了简略起见)该工具函数的完整实现,在错误处理方面依然有些细微细节我们需要处理。

但是,你不愿意实现一个你自己的runGenerator(..)是吗?

我不这么认为。

许多promise/async库都提供了上述工具函数。在此我不会一一论述,但是你一个查阅Q.spawn(..)co(..)库,等等。

但是我会简要的阐述我自己的库asynquence中的runner(..)插件,相对于其他库,我想提供一些独一无二的特性。如果对此感兴趣并想学习更多关于asynquence的知识而不是浅尝辄止,可以看看以前的两篇文章深入asynquence

首先,asynquence提供了自动处理上面代码片段中的”error-first-style“回调函数的工具函数:

function request(url) {
    return ASQ( function(done){
        // pass an error-first style callback
        makeAjaxCall( url, done.errfcb );
    } );
}

是不是看起来更加好看,不是吗!?

接下来,asynquence提供了runner(..)插件来在异步序列(异步流程)中执行generator函数,因此你可以在runner前面的步骤传递信息到generator函数内,同时generator函数也可以传递消息出去到下一个步骤中,同时如你所愿,所有的错误都自动冒泡被最后的or所捕获。

// first call `getSomeValues()` which produces a sequence/promise,
// then chain off that sequence for more async steps
getSomeValues()

// now use a generator to process the retrieved values
.runner( function*(token){
    // token.messages will be prefilled with any messages
    // from the previous step
    var value1 = token.messages[0];
    var value2 = token.messages[1];
    var value3 = token.messages[2];

    // make all 3 Ajax requests in parallel, wait for
    // all of them to finish (in whatever order)
    // Note: `ASQ().all(..)` is like `Promise.all(..)`
    var msgs = yield ASQ().all(
        request( "http://some.url.1?v=" + value1 ),
        request( "http://some.url.2?v=" + value2 ),
        request( "http://some.url.3?v=" + value3 )
    );

    // send this message onto the next step
    yield (msgs[0] + msgs[1] + msgs[2]);
} )

// now, send the final result of previous generator
// off to another request
.seq( function(msg){
    return request( "http://some.url.4?msg=" + msg );
} )

// now we're finally all done!
.val( function(result){
    console.log( result ); // success, all done!
} )

// or, we had some error!
.or( function(err) {
    console.log( "Error: " + err );
} );

asyquence的runner(..)工具接受上一步序列传递下来的值(也有可能没有值)来启动generator函数,可以通过token.messages数组来获取到传入的值。

然后,和上面我们所描述的runGenerator(..)工具函数类似,runner(..)也会监听yield一个promise或者yield一个asynquence序列(在本例中,是指通过ASQ().all()方法生成的”并行”任务),然后等待promise或者asynquence序列的完成后重新启动generator函数。

当generator函数执行完成后,最后通过yield表达式传递的值将作为参数传递到下一个序列步骤中。

最后,如果在某个序列步骤中出现错误,甚至在generator内部,错误都会冒泡到被注册的or(..)方法中进行错误处理。

asynquence通过尽可能简单的方式来混合匹配promises和generator。你可以自由的在以promise为基础的序列流程后面接generator控制流程,正如上面代码。

ES7 async

在ES7的时间轴上有一个提案,并且有极大可能被接受,该提案将在JavaScript中添加另外一个函数类型:async函数,该函数相当于用类似于runGenerator(..)(或者asynquence的runner(..))工具函数在generator函数外部包装一下,来使得其自动执行。通过async函数,你可以把promises传递到外部然后async函数在promises状态变为fulfill时自动重新启动直到函数执行完成。(甚至不需要复杂的迭代器参与)

async函数大概形式如下:

async function main() {
    var result1 = await request( "http://some.url.1" );
    var data = JSON.parse( result1 );

    var result2 = await request( "http://some.url.2?id=" + data.id );
    var resp = JSON.parse( result2 );
    console.log( "The value you asked for: " + resp.value );
}

main();

正如你所见,async 函数可以想普通函数一样被调用(如main()),而不需要包装函数如runGenerator(..)或者ASQ().runner(..)的帮助。同时,函数内部不再使用yield,而是使用await(另外一个JavaScript关键字)关键字来告诉async 函数等待当前promise得到返回值后继续执行。

基本上,async函数拥有通过一些包装库调用generator函数的大部分功能,同时关键是其被原生语法所支持

是不是很酷!?

同时,像asynquence这样的工具集使得我们能够轻易的且充分利用generator函数完成异步工作。

总结

简单地说:通过把promise和generator函数两个世界组合起来成为generator + yield promise(s)模式,该模式具有强大的能力及同步语法形式的异步表达能力。通过一些简单包装的工具(很多库已经提供了这些工具),我们可以让generator函数自动执行完成,并且提供了健全和同步语法形式的错误处理机制。

同时在ES7+的将来,我们也许将迎来async function函数,async 函数将不需要上面那些工具库就能够解决上面遇到的那些问题(至少对于基础问题是可行的)!

JavaScript的异步处理机制的未来是光明的,而且会越来越光明!我要带墨镜了。(译者注:这儿是作者幽默的说法)

但是,我们并没有在这儿就结束本系列文章,这儿还有最后一个方面我们想要研究:

倘若你想要将两个或多个generator函数结合在一起,让他们独立平行的运行,并且在它们执行的过程中来来回回得传递信息?这一定会成为一个相当强大的特性,难道不是吗?这一模式被称作“CSP”(communicating sequential processes)。我们将在下面一篇文章中解锁CSP的能力。敬请密切关注。

内存管理速成手册

原文地址

由于个人能力知识有限,翻译过程中难免有纰漏和错误,还望指正

这是本系列文章中的第一篇:

  1. 内存管理速成手册
  2. 通过漫画形式来解释 ArrayBuffers 和 SharedArrayBuffers
  3. 使用 Atomics 来在 SharedArrayBuffers 中避免竞用条件

在弄懂 ArrayBuffer 和 SharedArrayBuffer 为什么添加到 JavaScript 之前,你首先需要了解一些关于内存管理的知识。你可以把机器中的内存比喻成一组箱子,就像我把内存想象成办公室内部的信箱一样,或者是为学龄前儿童准备的用于存储杂物的小房间,如果你想给某位孩子准备一些礼物,你可以将物品放到某个箱子里。

A column of boxes with a child putting something in one of the boxes

在每个箱子旁边都有一个与之对应的数字,这就是内存地址。正是因为有了地址,你才能够告诉别人你为其准备动物品存放的位置。每个箱子具有相同的尺寸,也因此每个内存箱子也具有相同的容量来存储信息。箱子的尺寸是根据不同的机器而定的。箱子的尺寸被称作「字长」。它通常被标识为「32位」或者「64位」。但是为了简单的展示,在本文中我们使用「8位」的字长。(译者注:一个字长包含 8 个二进制位,也就是说一个内存单元的容量是 8 位)

A box with 8 smaller boxes in it

如果你打算将数字 2 放进其中一个内存箱子里,这将非常容易做到,因为数字可以很容易通过二进制来表示。

The number two, converted to binary 00000010 and put inside the boxes

倘若我们想放入内存箱子中的不是数字,而比如是字母「H」,怎么办呢?我们需要通过某种方法将其转化成可以使用数字来表示。为了完成此项工作,我们需要编码。类似于 UTF-8 。同时我们需要某种工具来按照 UTF-8 中的对应关系将字符转化成数字...比如说一个编码环。有了编码和编码环后,我们就可以将任意字符存入到内存箱子中了。

The letter H, put through an encoder ring to get 72, which is then converted to binary and put in the boxes

当我们打算将我们存入内存箱子中的信息取出时,我们需要将其放入一个解码器中,通过解码器将存放的数字转换成字母「H」。当你使用 JavaScript 工作时,你无须关心内存是怎样分配和使用的,因为在 JavaScript 中内存是自动管理的,内存管理和你的代码完全隔离。这意味着你不能够直接操作内存。JS 引擎将作为中介的角色,帮我们管理内存。

A column of boxes with a rope in front of it and the JS engine standing at that rope like a bouncer

让我们一些 JS 代码,比如在 React 中,我们需要创建一个变量并对其赋值。

Same as above, with React asking the JS engine to create a variable

JS 引擎的工作就是通过编码器将变量名转换成二进制表示。

The JS engine using an encoder ring to convert the string to binary

然后在内存中找到闲余的空间用来存放上面转换后的二进制表示。这一过程被称作分配内存。

The JS engine finding space for the binary in the column of boxes

接下来,JS 引擎会跟踪该变量并判断在程序中该变量是否还能够获取到。如果该变量不能够再被获取到,那么该内存箱子将会被回收再利用,以便 JS 引擎能够分配新的值到该内存中。

The garbage collector clearing out the memory

JS 引擎监听变量所代表的字符串、对象、以及内存中的其他数据类型的数据,当这些值不能再被获取到的时候,JS 引擎将会把它们清除出内存,这一过程被称作「垃圾回收」。比如 JavaScript 语言,代码不能够直接操纵内存,被称作自动内存管理语言。这一自动内存管理机制会使得开发变得相对简单。但是自动内存管理也会带来一些头疼的地方。比如自动内存管理可能会带来性能不可预测。而手动进行内存管理的语言就不会有这些问题。比如,通过 C 语言内存管理的方式来写 React 代码(当然,通过WebAssembly 已经使得其成为现实)。C 语言没有 JavaScript 自动内存管理的这一层功能抽象。所以,你可以直接操作内存,你可以从内存中对去数据,你也可以操作内存将数据存入内存中。

A WebAssembly version of React working with memory directly

当你讲其它语言比如 C 语言传递给 WebAssembly,你使用的工具将会添加一些代码到 WebAssembly 中,比如,将添加对字节进行编码和解码的代码。这些代码被称作运行时环境。运行时环境也将像 JS 引擎在 JavaScript 语言中的作用一样,处理一些与之相同的工作。

An encoder ring being shipped down as part of the .wasm file

但是对于手动内存管理的语言来说,运行时环境并不包含垃圾回收。这就意味着你必须手动来进行垃圾回收,即使是手动内存管理的语言,你通常也可以从该语言运行时环境中获取一些帮助。比如,在 C 语言中,C 语言运行时将会跟踪那些未被使用的内存,并将内存地址存储在一个链表中,该列表被称作「free list」。

A free list next to the column of boxes, listing which boxes are free right now

你可以使用 malloc 函数(memory allocate 简写)来请求运行时环境来寻找能够存放你数据的内存地址。这会使得这些内存地址从「free list」中移除。当你使用数据完成工作后,你必须通过free函数来讲该内存释放。这样该内存地址将会被重新添加至「free list」中。你必须知道什么时候该调用这些函数。这也是为什么称为手动内存管理的原因所在 -- 你完全自己管理程序中的内存。作为开发者,断定什么时候该清除内存是一件相当困难的事。如果在错误的时间点清除内存,将导致程序 bug,甚至一些安全漏洞。如果不对不在使用的内存进行处理,又将导致内存用尽。这也就是为什么现代语言都是用自动内存管理的原因 -- 避免人为错误。但是这也将会产生一些性能上的问题。我将在下一篇对此进行说明。

ES6 Generator函数实现协同程序

至此本系列的四篇文章翻译完结,查看完整系列请移步blogs

由于个人能力知识有限,翻译过程中难免有纰漏和错误,望不吝指正issue

ES6 Generators: 完整系列

  1. The Basics Of ES6 Generators
  2. Diving Deeper With ES6 Generators
  3. Going Async With ES6 Generators
  4. Getting Concurrent With ES6 Generators

如果你已经阅读并消化了本系列的前三篇文章:第一篇第二篇第三篇,那么在此时你已经对如何使用ES6 generator函数胸有成竹,并且我也衷心希望你能够受到前三篇文章的鼓舞,实际去使用一下generator函数(挑战极限),探究其究竟能够帮助我们完成什么样的工作。

我们最后一个探讨的主题可能和一些前沿知识有关,甚至需要动脑筋才能够理解(诚实的说,一开始我也有些迷糊)。花一些时间来练习和思考这些概念和示例。并且去实实在在的阅读一些别人写的关于此主题的文章。

此刻你花时间(投资)来弄懂这些概念对你长远来看是有益的。并且我完全深信在将来JS处理复杂异步的操作能力将从这些观点中应运而生。

正式的CSP(Communicating Sequential Processes)

起初,关于该主题的热情我完全受启发于 David Nolen @swannodette的杰出工作。严格说来,我阅读了他写的关于该主题的所有文章。下面这些链接可以帮助你对CSP有个初步了解:

OK,就我在该主题上面的研究而言,在开始写JS代码之前我并没有编写Clojure语言的背景,也没有使用Go和ClojureScript语言的经验。在阅读上面文章的过程中,我很快就发现我有一点弄不明白了,而不得不去做一些实验性学习或者学究性的去思考,并从中获取一些有用的知识。

在这个过程中,我感觉我达到了和作者相同的思维境界,并且追求相同的目标,但是却采取了另一种不那么正规的思维方式。

我所努力并尝试去构建一个更加简单的Go语言风格的CSP(或者ClojureScript语言中的core.async)APIs,并且(我希望)竟可能的保留那些潜在的能力。在阅读我文章的那些聪明的读者一定能够容易的发现我对该主题研究中的一些缺陷和不足,如果这样的话,我希望我的研究能够演进并持续发展下去,我也会坚持和我广大的读者分享我在CSP上的更多启示。

分解 CSP 理论(一点点)

CSP究竟是什么呢?在CSP概念下讲述的“communicating”、“Sequential”又是什么意思呢?“processes”有代表什么?

首先,CSP的概念是从Tony Hoare的"Communicating Sequential Processes"中首次被提及。这本书主要是一些CS理论上的东西,但是如果你对一些学术上的东西很感兴趣,相信这本书是一个很好的开端。在关于CSP这一主题上我绝不会从一些头疼的、难懂的计算机科学知识开始,我决定从一些非正式入口开始关于CSP的讨论。

因此,让我们先从“sequential”这一概念入手,关于这部分你可能已经相当熟悉,这也是我们曾经讨论过的单线程行为的另一种表述或者说我们在同步形式的ES6 generator函数中也曾遇到过。

回忆如下的generator函数语法:

function *main() {
    var x = yield 1;
    var y = yield x;
    var z = yield (y * 2);
}

上面代码片段中的语句都按顺序一条接一条执行执行,同一时间不能够执行多条语句。yield 关键字表示代码在该处将会被阻塞式暂停(阻塞的仅仅是 generator 函数代码本身,而不是整个程序),但是这并没有引起 *main() 函数内部自顶向下代码的丝毫改变。是不是很简单,难道不是吗?

接下来,让我们讨论下「processes」。「processes」究竟是什么呢?

本质上说,一个 generator 函数的作用相当于虚拟的「进程」。它是一段高度自控的程序,如果 JavaScript 允许的话,它能够和程序中的其他代码并行运行。

说实话,上面有一点捏造事实了,如果 generator 函数能够获取到共享内存中的值(也就是说,如果它能够获取到一些除它本身内部的局部变量外的「自由变量」),那么它也就不那么独立了。但是现在让我们先假设我们拥有一个 generator 函数,它不会去获取函数外部的变量(在函数式编程中通常称之为「组合子」)。因此理论上 generator 函数可以在其自己的进程中独立运行。

但是我们这儿所讨论的是「processes」--复数形式--,因为更重要的是我们拥有两个或者多个的进程。换句话说,两个或者多个 generator 函数通常会同时出现在我们的代码中,然后协作完成一些更加复杂的任务。

为什么将 generator 函数拆分为多个而不是一个呢?最重要的原因:实现功能和关注点的解耦。如果你现在正在着手一项 XYZ 的任务,你将这个任务拆分成了一些子任务,如 X, Y和 Z,并且每一个任务都通过一个 generator 函数实现,现在这样的拆分和解耦使得你的代码更加易懂且可维护性更高。

这个你将一个function XYZ()分解为三个函数X(),Y(),Z(),然后在X()函数中调用Y(),在Y()函数中调用Z()的动机是一样的,我们将一个函数分解成多个函数,分离的代码更加容易推理,同时也是的代码可维护性增强。

我们可以通过多个 generator 函数来完成相同的事情

最后,「communicating」。这有表达什么意思呢?他是从上面--协程—的概念中演进而来,协程的意思也就是说多个 generator 函数可能会相互协作,他们需要一个交流沟通的渠道(不仅仅是能够从静态作用域中获取到共享的变量,同时是一个真实能够分享沟通的渠道,所有的 generator 函数都能够通过独有的途径与之交流)。

这个通信渠道有哪些作用呢?实际上不论你想发送什么数据(数字 number,字符串 strings 等),你实际上不需要通过渠道来实际发送消息来和渠道进行通信。「Communication」和协作一样简单,就和将控制权在不同 generator 函数之间传递一样。

为什么需要传递控制权?最主要的原因是 JS是单线程的,在同一时间只允许一个 generator 函数的执行。其他 generator 函数处于运行期间的暂停状态,也就是说这些暂停的 generator 函数都在其任务执行过程中停了下来,仅仅是停了下来,等待着在必要的时候重新启动运行。

这并不是说我们实现了(译者注:作者的意思应该是在没有其他库的帮助下)任意独立的「进程」可以魔法般的进行协作和通信。

相反,显而易见的是任意成功得 CSP 实现都是精心策划的,将现有的问题领域进行逻辑上的分解,每一块在设计上都与其他块协调工作。// TODO 这一段好难翻译啊。

我关于 CSP 的理解也许完全错了,但是在实际过程中我并没有看到两个任意的 generator 函数能够以某种方式胶合在一起成为一个 CSP 模式,这两个 generator 函数必然需要某些特殊的设计才能够相互的通信,比如双方都遵守相同的通信协议等。

通过 JS 实现 CSP 模式

在通过 JS 实现 CSP 理论的过程中已经有一些有趣的探索了。

上文我们提及的 David Nolen 有一些有趣的项目,包括 Omcore.asyncKoa通过其use(..)方法对 CSP 也有些有趣的尝试。另外一个库 js-csp完全忠实于 core.async/Go CSP API。

你应该切实的去浏览下上述的几个杰出的项目,去发现通过 JS实现 CSP 的的不同途径和实例的探讨。

asynquence 中的 runner(..) 方法:为 CSP 而设计

由于我强烈地想要在我的 JS 代码中运用 CSP 模式,很自然地想到了扩展我现有的异步控制流的库asynquence ,为其添加 CSP 处理能力。

我已经有了 runner(..)插件工具能够帮助我异步运行 generator 函数(参见第三篇文章Going Async With Generators),因此对于我来说,通过扩展该方法使得其具有像CSP 形式一样处理多个 generator函数的能力变得相对容易很多。

首选我需要解决的设计问题:我怎样知道下一个处理哪个 generator 函数呢?

如果我们在每个 generator 函数上面添加类似 ID一样的标示,这样别的 generator 函数就能够很容易分清楚彼此,并且能够准确的将消息或者控制权传递给其他进程,但是这种方法显得累赘且冗余。经过众多尝试后,我找到了一种简便的方法,称之为「循环调度法」。如果你要处理一组三个的 generator 函数 A, B, C,A 首先获得控制权,当 A 调用 yield 表达式将控制权移交给 B,再后来 B 通过 yield 表达式将控制权移交给 C,一个循环后,控制权又重新回到了 A generator 函数,如此往复。

但是我们究竟如何转移控制权呢?是否需要一个明确的 API 来处理它呢?再次,经过众多尝试后,我找到了一个更加明确的途径,该方法和Koa 处理有些类似(完全是巧合):每一个 generator 对同一个共享的「token」具有引用,yield表达式的作用仅仅是转移控制权。

另外一个问题,消息渠道究竟应该采取什么样的形式呢。一端的频谱就是你将看到和 core.async 和 js-csp(put(..take(..))相似的 API 设计。经过我的尝试后,我倾向于频谱的另一端,你将看到一个不那么正式的途径(甚至不是一个 API,仅仅是共享一个像array一样的数据结构),但是它又是那么的合适且有效。

我决定使用一个数组(称作messages)来作为消息渠道,你可以采取任意必要的数组方法来填充/消耗数组。你可以使用push()方法来想数组中推入消息,你也可以使用pop()方法来将消息从数组中推出,你也可以按照一些约定惯例想数组中插入不同的消息,这些消息也许是更加复杂的数据接口,等等。

我的疑虑是一些任务需要相当简单的消息来传递,而另外一些任务(消息)却更加复杂,因此我没有在这简单的例子上面花费过多的精力,而是选择了不去对 message 渠道进行格式化,它就是简简单单的一个数组。(因此也就没有为array本身设计特殊的 API)。同时,在你觉得格式化消息渠道有用的时候,你也可以很容易的为该消息传递机制添加格外的格式化(参见下面的状态机的事例)。

最后,我发现这些 generator 函数「进程」依然受益于单独的 generator 函数的异步能力。换句话说,如果你通过 yield 表达式不是传递的一个「control-token」,你通过 yield 表达式传递的一个 Promise (或者异步序列),runner(..)的运行机制会暂停并等待返回值,并且不会转移控制权。他会将该返回值传递会当前进程(generator 函数)并保持该控制权。

上面最后一点(如果我说明得正确的话)是和其他库最具争议的地方,从其他库看来,真是的 CSP 模式在 yield 表达式执行后移交控制权,然而,我发现在我的库中我这样处理却相当有用。(译者注:作者就是这样自信)

一个简单的 FooBar 例子

我们已经理论充足了,让我们看一些代码:

// Note: omitting fictional `multBy20(..)` and
// `addTo2(..)` asynchronous-math functions, for brevity

function *foo(token) {
    // grab message off the top of the channel
    var value = token.messages.pop(); // 2

    // put another message onto the channel
    // `multBy20(..)` is a promise-generating function
    // that multiplies a value by `20` after some delay
    token.messages.push( yield multBy20( value ) );

    // transfer control
    yield token;

    // a final message from the CSP run
    yield "meaning of life: " + token.messages[0];
}

function *bar(token) {
    // grab message off the top of the channel
    var value = token.messages.pop(); // 40

    // put another message onto the channel
    // `addTo2(..)` is a promise-generating function
    // that adds value to `2` after some delay
    token.messages.push( yield addTo2( value ) );

    // transfer control
    yield token;
}

OK,上面出现了两个 generator「进程」,*foo()*bar()。你会发现这两个进程都将操作token对象(当然,你可以以你喜欢的方式称呼它)。token对象上的messages属性值就是我们的共享的消息渠道。我们可以在 CSP 初始化运行的时候给它添加一些初始值。

yield token明确的将控制权转一个「下一个」generator 函数(循环调度法)。然后yield multBy20(value)yield addTo2(value)两个表达式都是传递的 promises(从上面虚构的延迟数学计算方法),这也意味着,generator 函数将在该处暂停知道 promise 完成。当 promise 被解决后(fulfill 或者 reject),当前掌管控制权的 generator 函数重新启动继续执行。

无论最终的 yield的值是什么,在我们的例子中yield "meaning of..."表达式的值,将是我们 CSP 执行的最终返回数据。

现在我们两个 CSP 模式的 generator 进程,我们怎么运行他们呢?当然是使用 asynquence:

// start out a sequence with the initial message value of `2`
ASQ( 2 )

// run the two CSP processes paired together
.runner(
    foo,
    bar
)

// whatever message we get out, pass it onto the next
// step in our sequence
.val( function(msg){
    console.log( msg ); // "meaning of life: 42"
} );

很明显,上面仅是一个无关紧要的例子,但是其也能足以很好的表达 CSP 的概念了。

现在是时候去尝试一下上面的例子(尝试着修改下值)来搞明白这一概念的含义,进而能够编写自己的 CSP 模式代码。

另外一个「玩具」演示用例

如果那我们来看看最为经典的 CSP 例子,但是希望大家从文章上面的解释及发现来入手,而不是像通常情况一样,从一些学术纯化论者的观点中导出。

Ping-pong。多么好玩的游戏,啊!它也是我最喜欢的体育运动了。

让我们想象一下,你已经完全实现了打乒乓球游戏的代码,你通过一个循环来运行这个游戏,你有两个片段的代码(通常,通过if或者switch语句来进行分支)来分别代表两个玩家。

你的代码运行良好,并且你的游戏就像真是玩耍乒乓球一样!

但是还记得为什么我说 CSP 模式是如此有用呢?它完成了关注点和功能模块的分离。在上面的乒乓球游戏中我们怎么分离的功能点呢?就是这两位玩家!

因此,我们可以在一个比较高的层次上,通过两个「进程」(generator 函数)来对我们的游戏建模,每个进程代表一位玩家,我们还需要关注一些细节问题,我们很快就感觉到还需要一些「胶水代码」来在两位玩家之间进行控制权的分配(交换),这些代码可以作为第三个 generator 函数进程,我们可以称之为裁判员。

我们已经消除了所有可能会遇到的与专业领域相关的问题,比如得分,游戏机制,物理学常识,游戏策略,电脑玩家,控制等。在我们的用例中我们只关心模拟玩耍乒乓球的反复往复的过程,(这一过程也正隐喻了 CSP 模式中的转移控制权)。

想要亲自尝试下演示用例?那就运行把(注意:使用最新每夜版 FF 或者 Chrome,并且带有支持 ES6,来看看 generators 如何工作)

现在,让我们来一段一段的阅读代码。

首先,asynquence 序列长什么样呢?

ASQ(
    ["ping","pong"], // player names
    { hits: 0 } // the ball
)
.runner(
    referee,
    player,
    player
)
.val( function(msg){
    message( "referee", msg );
} );

我们给我们的序列设置了两个初始值["ping", "pong"]{hits: 0}。我们将在后面讨论它们。

接下来,我们设置 CSP 运行 3 个进程(协作程序):*referee() 和 两个*player()实例。

游戏最后的消息传递给了我们序列的第二步,我们将在序列第二步中输出裁判传递的消息。

裁判进程的代码实现:

function *referee(table){
    var alarm = false;

    // referee sets an alarm timer for the game on
    // his stopwatch (10 seconds)
    setTimeout( function(){ alarm = true; }, 10000 );

    // keep the game going until the stopwatch
    // alarm sounds
    while (!alarm) {
        // let the players keep playing
        yield table;
    }

    // signal to players that the game is over
    table.messages[2] = "CLOSED";

    // what does the referee say?
    yield "Time's up!";
}

我们称「控制中token」为table,这正好和(乒乓球游戏)专业领域中的称呼想一致,这是一个很好的语义化,一个游戏玩家通过用拍子将球「yields 传递 table」给另外一个玩家,难道不够形象吗?

while循环的作用就是在*referee()进程中,只要警报器没有吹响,他将不断地通过 yield 表达式将 table 传递给玩家。当警报器吹响,他掌管了控制权,宣布游戏结束「时间到了」。

现在,让我们来看看*player()generator 函数(在我们的代码中我们两次使用了该实例):

function *player(table) {
    var name = table.messages[0].shift();
    var ball = table.messages[1];

    while (table.messages[2] !== "CLOSED") {
        // hit the ball
        ball.hits++;
        message( name, ball.hits );

        // artificial delay as ball goes back to other player
        yield ASQ.after( 500 );

        // game still going?
        if (table.messages[2] !== "CLOSED") {
            // ball's now back in other player's court
            yield table;
        }
    }

    message( name, "Game over!" );
}

第一位玩家从消息数组中取得他的名字「ping」,然后,第二位玩家取得他的名字「pong」,这样他们可以很好的分辨彼此的身份。两位玩家同时共享ball这个对象的引用(通过他的hits计数)。

只要玩家没有从裁判口中听到结束的消息,他们就将通过将计数器加一来「hit」ball(并且会输入一条计数器消息),然后,等待500ms(仅仅是模拟乒乓球的飞行耗时,不要还以为乒乓球以光速飞行呢)。

如果游戏依然进行,游戏玩家「yield 传递 table」给另外一位玩家。

就是这样!

查看一下演示用例的代码获取一份完整用例的代码,看看不同代码片段之间是如何协同工作的。

状态机:Generator 协同程序

最后一个例子,通过一个 generator 函数集合组成的协同程序来定义一个状态机,这一协同程序都是通过一个简单的工具函数来运行的。

演示用例(注意:使用最新的每夜版 FF 或者 Chrome,并且支持 ES6的语法特性,看看 generator 函数如何工作)

首先让我们来定义一个工具函数,来帮助我们控制我们有限的状态:

function state(val, handler) {
    // make a coroutine handler (wrapper) for this state
    return function*(token) {
        // state transition handler
        function transition(to) {
            token.messages[0] = to;
        }

        // default initial state (if none set yet)
        if (token.messages.length < 1) {
            token.messages[0] = val;
        }

        // keep going until final state (false) is reached
        while (token.messages[0] !== false) {
            // current state matches this handler?
            if (token.messages[0] === val) {
                // delegate to state handler
                yield *handler( transition );
            }

            // transfer control to another state handler?
            if (token.messages[0] !== false) {
                yield token;
            }
        }
    };
}

state(..) 工具函数为一个特殊的状态值创建了一个generator 代理的上层封装,它将自动的运行状态机,并且在不同的状态转换下转移控制权。

按照惯例来说,我已经决定使用的token.messages[0]中的共享数据插槽来储存状态机的当前状态值,这也意味着你可以在序列的前一个步骤来对该状态值进行初始化,但是,如果没有传递该初始化状态,我们简单的在定义第一个状态是将该状态设置为初始状态。同时,按照惯例,最后终止的状态值设置为false。正如你认为合适,也很容易改变该状态。

状态值可以是多种数据格式之一,数字,字符串等等,只要改数据可以通过严格的===来检测相等性,你就可以使用它来作为状态值。

在接下来的例子中,我展示了一个拥有四个数组状态的状态机,并且其运行运行:1 -> 4 -> 3 -> 2。该顺序仅仅为了演示所需,我们使用了一个计数器来帮助我们在不同状态间能够多次传递,当我们的 generator 状态机最终遇到了终止状态false时,异步序列运行至下一个步骤,正如你所期待那样。

// counter (for demo purposes only)
var counter = 0;

ASQ( /* optional: initial state value */ )

// run our state machine, transitions: 1 -> 4 -> 3 -> 2
.runner(

    // state `1` handler
    state( 1, function*(transition){
        console.log( "in state 1" );
        yield ASQ.after( 1000 ); // pause state for 1s
        yield transition( 4 ); // goto state `4`
    } ),

    // state `2` handler
    state( 2, function*(transition){
        console.log( "in state 2" );
        yield ASQ.after( 1000 ); // pause state for 1s

        // for demo purposes only, keep going in a
        // state loop?
        if (++counter < 2) {
            yield transition( 1 ); // goto state `1`
        }
        // all done!
        else {
            yield "That's all folks!";
            yield transition( false ); // goto terminal state
        }
    } ),

    // state `3` handler
    state( 3, function*(transition){
        console.log( "in state 3" );
        yield ASQ.after( 1000 ); // pause state for 1s
        yield transition( 2 ); // goto state `2`
    } ),

    // state `4` handler
    state( 4, function*(transition){
        console.log( "in state 4" );
        yield ASQ.after( 1000 ); // pause state for 1s
        yield transition( 3 ); // goto state `3`
    } )

)

// state machine complete, so move on
.val(function(msg){
    console.log( msg );
});

上面代码的运行机制是不是非常简单。

yield ASQ.after(1000)表示这些 generator 函数可以进行 promise/sequence等异步工作,正如我们先前缩减,yield transition(..)告诉我们怎样将控制权传递给下一个状态。

我们的state(..)工具函数真实的完成了yield *代理这一艰难的工作,像变戏法一样,使得我们能够以一种简单自然的形式来对状态进行操控。

总结

CSP 模式的关键点在于将两个或者多个 generator「进程」组合在一起,并为他们提供一个共享的通信渠道,和一个在其彼此之间传递控制权的方法。

市面上已经有很多库多多少少实现了GO 和 Clojure/ClojureScript APIs 相同或者相同语义的 CSP 模式。在这些库的背后是一些聪明而富有创造力的开发者门,这些库的出现,也意味着需要更大的资源投入以及研究。

asynquence 尝试着通过着通过不那么正式的方法却依然希望给大家呈现 CSP 的运行机制,只不过,asynquence 的runner(..)方法使得了我们通过 generator 模拟 CSP 模式变得如此简单,正如你在本篇文章所学的那样。

asynquence CSP 模式中最为出色的部分就是你将所有的异步处理手段(promise,generators,flow control 等)以及剩下的有机的组合在了一起,你不同异步处理结合在一起,因此你可以任何合适的手段来处理你的任务,而且,都在同一个小小的库中。

现在,在结束该系列最后一篇文章后,我们已经完成了对 generator 函数详尽的研究,我所希望的是你能够在阅读这些文章后有所启发,并对你现有的代码进行一次彻底革命!你将会用 generator 函数创造什么奇迹呢?

这就是 Univer

文章首发于知乎Univer GitHub 地址

零. 开篇

这篇文章旨在帮助新人快速熟悉开源项目 univer 的架构及代码,也是我过去一段时间参与到 univer 开发中的学习和总结,肯定有不够准确或者理解偏差,欢迎大家评论指正

第壹章,会聊聊我对 univer 架构的理解,univer 是如何拆分模块,以及模块之间的依赖关系。然后将 univer 放入 MVC 的架构模式中,分别分析下其模型层、视图层、控制器的边界和职责
第贰章,我们先来看看 univer sheet 的模型层数据结构设计,如何区分 workbook、sheet、row、column、style 等,了解他们的包含关系,这对后面深入理解代码是有帮助的
第叁、肆章,我将从两条控制链路来分析 univer 的代码,一条链路是 univer 启动和初始化渲染的过程。在这条链路中,是从模型层到视图层的过程。另外一条链路是 univer 响应用户事件,并且触发模型层数据变更,页面重新渲染,在这条链路中,是从视图层到模型层的过程。在这两部分,我们会涉及到大量的源码分析,在保留代码主逻辑的前提,删除了边界 case 的代码。同时在每个代码块第一行,表示该代码块所在的 TS 文件,这样便于直接阅读源码

壹. 对代码架构的理解

外表的美只能取悦于人的眼睛,而内在的美却能感染人的灵魂。 ——伏尔泰

Univer 中的模块拆分和依赖关系

无依赖环原则

软件架构的规则其实就是排列组合代码块的规则,软件架构会根据业务域来将组织项目代码,将项目拆分成不同的模块,各个模块做到关注点分离,同时模块之间有明确的依赖关系,并且依赖关系是一个单向无环图,也就是无依赖环原则。正如下面图中所示,系统级、应用级业务逻辑是整个项目最核心的部分,同时也是应用最稳定的部分,应该放在架构的最里层,其他如用户界面、渲染引擎、前端框架、持久化的数据库,这些在架构演进的过程中,可能被替换,所以他们都依赖于最中心的业务实体,置于外层

(图注:core、base-sheet、base-render、base-ui、ui-plugin-sheet 对应仓库中 packages 下不同文件夹)

Univer 在整个架构设计中,尽量保证核心模块(core)仅包含最核心的业务逻辑,在核心业务逻辑之上所构筑的其他功能,都是通过插件化来提供的,这也是微内核架构的**。在上图中,base-render、base-ui、base-sheet 等都是插件化的,为 core 提供额外的能力。如 base-sheet 完善 sheet 相关功能,base-render(canvas 渲染引擎) 提供 canvas 渲染能力,公式引擎提供公式相关的计算和解析等

依赖反转

直观理解,我们可能会认为,core 模块依赖于 base-render 模块来做 canvas 渲染,依赖 base-ui 来做页面框架渲染及样式菜单等,base-ui 又依赖于 React 框架来渲染组件。这样会有一个问题,核心模块依赖于其他模块,其他模块往往是不稳定的,比如样式菜单,我们可能会经常改变位置或者样式,这也有可能导致核心模块易变。在 Univer 中,利用了依赖反转(Dependency Inversion)解决上面面临的问题,这也就是上面图中,所有的外面的环都依赖内部的环,而内部的环不能依赖外部环。在 Univer 中引入了依赖注入(DI),通过依赖注入的方式,反转依赖,避免了核心层对外层的依赖,通过下面代码示例来解释会更清晰一些

举个例子,在没有依赖注入,我们可能会写这样的代码:

class SheetPlugin {
    private _commandService = new CommandService(); 
}

在上面代码中,SheetPlugin 类依赖于 CommandService 类,CommandService 类中方法的变更直接会影响到 SheetPlugin,SheetPlugin 可能也需要修改,导致 SheetPlugin 的不稳定

image

我们通过依赖注入,代码如下:

class SheetPlugin {
    constructor(
        // ...
        @ICommandService private readonly _commandService: ICommandService,
        // ...
    )

    otherMethod(){
        this._commandService.registerCommand(SomeCommand);
    }
}

在上面的代码中,声明了 _commandService 属性拥有 ICommandService 接口,通过相关的依赖绑定,就可以在 SheetPlugin 的方法中调用 ICommandService 接口所定义的方法了。这样 SheetPlugin 依赖于 ICommandService 接口,同时 CommandService 类实现了这个接口。这样就解耦了 SheetPlugin 和 CommandService 之间的直接依赖关系,图示如下:

image

如上图,我们通过 ICommandService 接口实现依赖反转,在没有 ICommandService 接口下,SheetPlugin 直接依赖于 CommandService,导致核心业务逻辑(SheetPlugin)的不稳定。通过引入 ICommandService 接口,及依赖注入,如果将虚线框看成一个整体,CommandService 类指向(实现接口)虚线框,最终实现依赖反转,保证了核心业务逻辑稳定性

浅谈 Univer 中的 MVC 架构模式

image

MVC 在整个 GUI 编程领域已经有了 50 多年的历史了,但是 MVC 却一直没有一个明确的定义。如上图,就是两种比较典型的 MVC 变种,在 MVC with ASP.NET 中,控制器负责管理视图和模型,当控制器改变模型层中数据后,通过一些订阅机制,视图层直接读取模型层中数据,更新视图。在 MVC with Rails 中,视图层不直接和模型层交互,通过控制器做了一层代理,视图层需要通过控制器从模型层取数据进行渲染。这样有个好处就是视图层和模型层完全的解耦,控制流会更清晰

打开 univer 工程代码,我们可以发现大量以 controllerviewmodel后缀命名的文件,大致也能看出其采用了传统的 MVC 架构模式。Univer 中 MVC 架构更类似于 MVC with Rails, 因为视图层不直接读取 Model 层数据,也不订阅模型层的改变(下面会提到,是订阅了 Mutations),而是做了一层数据缓存(类似 ViewModel),SheetSkeleton 类

在最开始看工程代码时,一些疑虑一直萦绕在脑海:

  1. Univer 是如何组织和管理模型层的?
  2. Univer 中控制器有哪些职责,如何保证控制器代码架构清晰?
  3. Univer 的视图层怎么组织和管理的?

阅读源码,谈谈 Univer 如何从架构层面回答上面的问题

模型层(Model)

Univer 的整个模型层会比较薄,拿 univer sheet 来举例,在 core 模块中,通过 Workbook 和 Worksheet 类来管理和 sheet 相关的模型数据,提供了相关模型数据存储和管理的工作。如在 Worksheet 类中,有 row-manager、column-manager、相关的类和方法来管理每个 sheet 模型数据,拿row-manager来说,我们可以获取表格行的一些信息和数据:

getRowData(): ObjectArray<IRowData>;
getRowHeight(rowPos: number): number;
getRowOrCreate(rowPos: number): IRowData;
// ...

很容易理解,我们渲染的数据不能够直接使用底层模型数据,往往需要经过一定的计算,生成一个用于渲染的“模型层”才能用于直接的视图渲染。比如在渲染视图的时候,我们需要计算行、列的总高度。通过每行的文档内容,计算能够容纳数据的最小行高度。通过一系列的计算,最终确定 sheet 页面的布局,用于最终的渲染。而这一系列的计算都放在了视图层 SheetSkeleton 类中

控制器(Controller)的职责

image

在传统的MVC架构中,视图和模型层往往职责比较明晰,而控制器承担了主要的业务逻辑,和管理视图层、模型层的任务,往往会比较臃肿,那么 univer 是如何避免控制器臃肿的呢?在 Univer 中,控制器(MVC中的控制器)进一步拆解为 Controllers(Univer中狭义的控制器)、CommandsServices。同时在控制器中,几乎包含了 univer 所有的业务逻辑,他们各司其职,保证了 univer 应用的正常运行

Controllers 职责

  • 初始化一些渲染逻辑和事件的监听,如在 SheetRenderController 类中,在应用 Rendered生命周期执行,会去初始化页面的数据刷新(_initialRenderRefresh),会去监听 Commands 的执行,涉及到 Mutation 修改模型层,还会触发页面渲染逻辑
  • 和视图层交互,拿到视图层的一些数据信息。如在 AutoHeightController 类中,会根据 Commands 所需,通过视图层计算 sheet 自动行高
  • 绑定 UI 事件,如在 HeaderResizeController 类中,会在应用 Rendered生命周期执行,在初始化中,为spreadsheetRowHeader、spreadsheetColumnHeader 绑定 hover 事件,显示和隐藏 resize header(用于调节行列高度和宽度),也为 resize header 绑定 pointer down/move/up 等事件,这样 resize header 就会响应拖拽移动,处理相关用户操作,最终也会反应到模型层的修改和视图层的更新

Commands 职责

Commands 可以理解为用户的单次交互操作,比如合并单元格、清除选区、插入行列、设置单元格样式等,并且更改模型层,触发视图层渲染。Commands 有三种类型:COMMANDMUTATIONOPERATION

  • COMMAND 就是用户的一次交互操作,有用户行为触发,可以派生出另外一个 COMMAND,比如用户点击菜单中 text wrap 菜单项,会触发 SetTextWrapCommandSetTextWrapCommand 会派生出 SetStyleCommand 统一处理所有样式的更改。一个 COMMAND 可以派生另外一个 COMMAND,但是不能分叉,因为我们需要在 COMMAND 中处理 undo/redo 相关操作(后面 undo/redo 可能会移到数据层)。但是一个 COMMAND 可以派生出多个 MUTATIONOPERATION
  • MUTATION 可以理解为对模型层数据的原子操作,比如 SetRangeValuesMutation 修改选区范围内的单元格样式和值,SetWorksheetRowHeightMutation修改选区范围内行的高度。MUTATION 的执行,不仅会修改模型数据,同时也会触发视图的重新渲染。MUTATION 中修改的数据需要处理协同,和解决协同中的冲突
  • OPERATION 是对应用状态的变更,是应用的某个临时状态,如页面滚动位置、用户光标位置、当前的选区等,不涉及到协同和解决冲突的问题,主要用于之后 live share (类似于飞书的 magic share)等功能

Services 职责

Services 为整个 Univer 应用提供各种服务,是关注点分离(Separate of concern)在 Univer 项目架构中的承载者

  • 管理应用生命周期,如 LifecycleService 类,保存应用生命周期的状态值,并提供 subscribeWithPrevious方法供其他模块订阅应用生命周期状态值的变更,并做响应任务执行,如依赖的初始化等
  • 处理应用的 History 操作和存储历史操作,这样用户可以 undo/redo 之前的操作。在 LocalUndoRedoService 类中,通过 pushUndoRedo 方法将 undo/redo 信息推入栈中,通过 updateStatus 方法触发 undo/redo 操作
  • 处理网络 IO 和 websocket 链接

总结一下,将上面 Controllers、Commands、Services 统称为 MVC 中的控制器,他们完成了 univer 中大量业务逻辑,下面列举了其主要职责(在叁、肆部分,会更加详细的分析控制器是如何工作的):

  1. 负责整个应用的生命周期管理
  2. 绑定和响应 UI 事件,如双击、光标移动等
  3. 控制视图的渲染和触发渲染的逻辑
  4. 和视图层通信,如拿计算后页面布局信息
  5. 通过 Command/Mutation 改变模型层,触发界面渲染
  6. 处理 undo/redo 相关工作
  7. 负责协作、和网络 IO

视图层(View)

在 Univer 中,有两种渲染方式:一种是 Canvas 渲染引擎,一种是 React 通过 DOM 进行渲染。Canvas 渲染引擎主要渲染 sheet 的主体部分:行表头、列表头、sheet 单元格、选区、单元格编辑器等。React 主要用于渲染顶部菜单栏、右键菜单栏、浮窗等

Sheet 主体部分选用 Canvas 进行渲染,保证了在大数据量下表格渲染的极致性能体验,和流畅的动画效果。而菜单主要需要响应用户事件,DOM 往往比 Canvas 更具优势

Canvas 渲染所需要的组件、服务都在 base-render 文件夹中,如 sheet 渲染相关的:Spreadsheet、SpreadsheetRowHeader、SpreadsheetColumnHeader 等。同时在 Canvas 组件上定义了一套事件响应机制,保证了各个组件能够独立响应事件,但是并不会在视图层处理这些事件。这些事件都需要在 Controllers 中处理

Base-ui/Components 文件夹中代码负责菜单基础组件的渲染和用户事件的发布,base-ui 模块也负责整个应用的框架渲染。如在 DesktopUIController 类中,bootstrapWorkbench 启动了整个应用框架渲染,以及 Canvas 元素的挂载等

贰. Univer sheet 数据结构

了解一个项目,先从其数据结构开始

Sheet 相关的数据类型定义在 Interfaces 文件夹中,包含关系如下:

image

Univer sheet 整体的数据类型定义如上图所示,一个 workbook 包含多个 sheets,sheets 所引用的 styles 字段定义在了顶层 workbook 上,保证了样式的复用,减少内存开销,这也是和 Excel 保持一致。在 IWorksheetConfig 中,定义了 cellData 字段,这是一个二维矩阵,用于持久化单元格信息,也就是 ICellData 中定义的类型信息,p 是指富文本,接口类型 IDocumentData,也就是一篇 univer doc,这也是 univer 设计的独到之处,univer sheet 的每个单元格都可以转变成一个 univer doc。s 字段大多是一个字符串 id,指向 IWorkbookConfig 中 styles 字段,从中检索出该单元格的样式信息

图中并没有包含各个接口定义的所有字段,想了解更多,建议直接查看上面的类型定义文件,上面也有相应注释

叁. 应用启动到渲染的过程

Univer 如何渲染页面,其实就是 univer 应用启动的整个过程,也是模型层到视图层的整个过程。在了解页面渲染前,我们先了解下 univer 的生命周期,其实在上面 Services 部分也有所提及

应用的生命周期

export const enum LifecycleStages {
    /**
     * Register plugins to Univer.
     */
    Starting,
    /**
     * Univer business instances (UniverDoc / UniverSheet / UniverSlide) are created and services or controllers provided by
     * plugins get initialized. The application is ready to do the first-time rendering.
     */
    Ready,
    /**
     * First-time rendering is completed.
     */
    Rendered,
    /**
     * All lazy tasks are completed. The application is fully ready to provide features to users.
     */
    Steady,
}

Univer 生命周期有四个阶段,StartingReadyRenderedSteady。如在 Starting 阶段去注册各个插件到 univer 上面,在 Ready 阶段实例化 UniverSheet,并且执行各个插件的初始化函数,Rendered 阶段完成首次渲染,Steady 阶段,应用完成启动,用户可以使用完整功能

各个生命周期状态在什么时候触发呢?

Starting 状态:在 _tryStart方法中,LifecycleService 类实例化,应用进入 Staring 阶段,在这个阶段也会去执行插件的 onStarting 钩子函数

Ready 状态:在实例化 UniverSheet 后,在 _tryProgressToReady方法中,设置 LifecycleService stage 值为 Ready,在这个阶段也会执行各个插件的 onReady 钩子函数

Rendered 状态:在 DesktopUIController 中,bootStrap 整个应用后,标记 LifecycleService stage 值为 Rendered

Steady 状态:在 Rendered 状态后,延迟 3000 秒触发 Steady 状态

通过 @OnLifecycle 注解,我们可以精确控制某个类在什么生命周期阶段实例化,如下:

@OnLifecycle(LifecycleStages.Rendered, SheetRenderController)
export class SheetRenderController extends Disposable {
    //...
}

在上面代码中,SheetRenderController 将在 Rendered 阶段实例化

启动到渲染的整个过程

image

第一步:创建 Univer 实例、注册 sheet 所需的相关插件和创建 univer sheet 实例

注册的插件及相关功能如下:

  • base-docs:用于单元格和公式的编辑
  • base-render:Canvas 渲染引擎,也包含 sheet、doc、slide 所需的基础组件,负责 Canvas 渲染整个过程
  • base-sheets:管理 sheet canvas 相关的渲染,如 row header、column header、单元格等,同时也处理大量sheet相关业务逻辑
  • base-ui:管理 React DOM 渲染的基础组件,如菜单相关的组件。同时也负责整个 univer sheet 页面框架的渲染,以及和用户交互的操作都会放在这个插件中,如快捷键注册、复制、剪切黏贴等
  • ui-plugin-sheets:负责一些基础 UI 的渲染和业务逻辑,如右键菜单、单元格富文本编辑相关的任务

插件注册完成,通过 createUniverSheet 方法,创建 univer sheet 实例

/**
 * Create a univer sheet instance with internal dependency injection.
 */
createUniverSheet(config: Partial<IWorkbookConfig>): Workbook {
    let workbook: Workbook;
    const addSheet = () => {
        workbook = this._univerSheet!.createSheet(config);
        this._currentUniverService.addSheet(workbook);
    };

    if (!this._univerSheet) {
        this._univerSheet = this._rootInjector.createInstance(UniverSheet);

        this._univerPluginRegistry
            .getRegisterPlugins(PluginType.Sheet)
            .forEach((p) => this._univerSheet!.addPlugin(p.plugin as unknown as PluginCtor<any>, p.options));
        this._tryStart();
        this._univerSheet.init();
        addSheet();

        this._tryProgressToReady();
    } else {
        addSheet();
    }

    return workbook!;
 }

通过上面代码,我们可以看到,univer 将上面注册的插件中 PluginType.Sheet 类型的插件,重新注册到了 univerSheet 实例上,然后通过 _tryStart 应用进入 Starting 阶段,然后初始化,通过 addSheet 实例化 Workbook,完成了模型层的初始化。到这里模型数据准备完毕,univer 进入到 Ready 阶段

第二步:初始化页面框架,渲染页面框架
在上面讲述 Univer 应用生命周期时,提到过插件会在 univer 不同的生命周期执行,在这一步,我们重点关注 base-ui 插件

// base-ui-plugin.ts
override onStarting(_injector: Injector): void {
    this._initDependencies(_injector);
}

override onReady(): void {
    his._initUI();
}

如上代码,base-ui 插件在 onStarting 阶段会去声明和添加依赖,在 onReady 阶段,会去初始化渲染整个页面框架,将 View 界面挂载到 container 上。

// ui-desktop.controller.tsx
bootstrapWorkbench(options: IWorkbenchOptions): void {
    this.disposeWithMe(
        bootStrap(this._injector, options, (canvasElement, containerElement) => {
            this._initializeEngine(canvasElement);
            this._lifecycleService.stage = LifecycleStages.Rendered;
            this._focusService.setContainerElement(containerElement);

            setTimeout(() => (this._lifecycleService.stage = LifecycleStages.Steady), STEADY_TIMEOUT);
        })
    );
}
// ...
function bootStrap(
    injector: Injector,
    options: IWorkbenchOptions,
    callback: (canvasEl: HTMLElement, containerElement: HTMLElement) => void
): IDisposable {
    let mountContainer: HTMLElement;
    // ...
    const root = createRoot(mountContainer);
    const ConnectedApp = connectInjector(App, injector);
    const desktopUIController = injector.get(IUIController) as IDesktopUIController;
    const onRendered = (canvasElement: HTMLElement) => callback(canvasElement, mountContainer);

    function render() {
        const headerComponents = desktopUIController.getHeaderComponents();
        const contentComponents = desktopUIController.getContentComponents();
        const footerComponents = desktopUIController.getFooterComponents();
        const sidebarComponents = desktopUIController.getSidebarComponents();
        root.render(
            <ConnectedApp
                {...options}
                headerComponents={headerComponents}
                contentComponents={contentComponents}
                onRendered={onRendered}
                footerComponents={footerComponents}
                sidebarComponents={sidebarComponents}
            />
        );
    }

    // ...
    render();
    // ...
}

在上面代码可以看到,在页面框架挂载并渲染完成后,会去完成 canvas 渲染引擎容器挂载及调整 canvas 尺寸,整个应用进入 Rendered 阶段

第三步:渲染 canvas 界面,完成整个渲染过程

其实这个过程在应用 Ready 阶段就已经开始了 sheet canvas 的初始化和组件组装和添加

// sheet-canvas-view.ts
@OnLifecycle(LifecycleStages.Ready, SheetCanvasView)
export class SheetCanvasView {
    // ...
    constructor(
        // ...
    ) {
        this._currentUniverService.currentSheet$.subscribe((workbook) => {
            // ...
            const unitId = workbook.getUnitId();
            if (!this._loadedMap.has(unitId)) {
                this._currentWorkbook = workbook;
                this._addNewRender();
                this._loadedMap.add(unitId);
            }
        });
    }

    private _addNewRender() {
        // ...
        if (currentRender != null) {
            this._addComponent(currentRender);
        }
        const should = workbook.getShouldRenderLoopImmediately();
        if (should && !isAddedToExistedScene) {
            engine.runRenderLoop(() => {
                scene.render();
            });
        }
        // ...
    }

    private _addComponent(currentRender: IRender) {
        // ...
        currentRender.mainComponent = spreadsheet;
        currentRender.components.set(SHEET_VIEW_KEY.MAIN, spreadsheet);
        currentRender.components.set(SHEET_VIEW_KEY.ROW, spreadsheetRowHeader);
        currentRender.components.set(SHEET_VIEW_KEY.COLUMN, spreadsheetColumnHeader);
        currentRender.components.set(SHEET_VIEW_KEY.LEFT_TOP, SpreadsheetLeftTopPlaceholder);
        // ...
        this._sheetSkeletonManagerService.setCurrent({ sheetId, unitId });
    }

    private _addViewport(worksheet: Worksheet) {
        // ...
        scene
            .addViewport(
                viewMain,
                viewColumnLeft,
                viewColumnRight,
                viewRowTop,
                viewRowBottom,
                viewLeftTop,
                viewMainLeftTop,
                viewMainLeft,
                viewMainTop
            )
            .attachControl();
    }
}

上面代码,其实就是 sheet canvas 渲染的整个过程,首先会去订阅 currentSheet$,如果该 sheet 没有被render 过,那么就会调用 _addNewRender 方法,添加 sheet 所需的 canvas 渲染组件,添加 viewport,然后将 scene 的渲染添加到渲染引擎的渲染循环中(runRenderLoop)

在上面过程,完成了 sheet 所需 canvas 组件的组装以及添加 viewport,那么 canvas 的首次渲染发生在什么地方呢?和什么生命周期阶段呢?sheet canvas 的渲染被 SheetRenderController 类所管理,该类管理了 sheet canvas 的初始化渲染以及监听 Mutations 的变更,然后按需渲染 Canvas 界面

// sheet-render.controller.ts
@OnLifecycle(LifecycleStages.Rendered, SheetRenderController)
export class SheetRenderController extends Disposable {}

上面的代码可以看到,sheet canvas 的渲染时间点是在整个应用 Rendered 阶段,其实也好理解,这个阶段,页面框架才完成挂载到 container 上,同时 sheet canvas 也完成了初始化工作。在 Rendered 阶段,会去订阅 currentSkeleton$ 改变,然后去更新 skeleton,完成页面首次渲染。

// sheet-render.controller.ts
private _commandExecutedListener() {
    this.disposeWithMe(
         his._commandService.onCommandExecuted((command: ICommandInfo) => {
            // ...
            if (COMMAND_LISTENER_SKELETON_CHANGE.includes(command.id)) {
                // ...
                if (command.id !== SetWorksheetActivateMutation.id) {
                    this._sheetSkeletonManagerService.makeDirty(
                        {
                            unitId,
                            sheetId,
                            commandId: command.id,
                         ,
                        true
                    );
                }

                 this._sheetSkeletonManagerService.setCurrent({
                    unitId,
                    sheetId,
                    commandId: command.id,
                });
           }

            this._renderManagerService.getRenderById(unitId)?.mainComponent?.makeDirty(); // refresh spreadsheet
        })
    );
}

上面代码发生在 SheetRenderController 类,在 _commandExecutedListener 方法中,会去监听 Command 执行,如果在 COMMAND_LISTENER_SKELETON_CHANGE 列表内,标记当前 skeleton 为 dirty,mainComponent 为 dirty,这样 Canvas 渲染引擎就会在下个渲染循环中重新渲染页面了

第四步:单元格编辑器初始化

其实在第三步,基本已经完成了整个 sheet 界面的渲染,我们再来关注一下单元格编辑器的初始化过程。在应用 Rendered 阶段,univer 会去初始化两个 Doc 实例,一个用于单元格的编辑,另一个用于公式输入框的编辑。

// initialize-editor.controller.ts
private _initialize() {
    this._currentUniverService.createDoc({
        id: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
        documentStyle: {},
    });
    // create univer doc formula bar editor instance

    this._currentUniverService.createDoc({
        id: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
        documentStyle: {},
    });
}

同样在 Rendered 阶段,EditorBridgeController 类实例化时,会去初始化相关的事件监听,如双击单元格,单元格进入编辑模式。但是直到 Steady 阶段,StartEditController 类才完成实例化,单元格编辑才能完全可交互

肆. 界面如何响应用户操作?

下面的时序图描述了当用户点击 text wrap 菜单项,univer 从响应事件到界面渲染的整个过程

image

第一步:用户点击菜单中 text wrap 菜单项。

 // menu.ts
 export function WrapTextMenuItemFactory(accessor: IAccessor): IMenuSelectorItem<WrapStrategy> {
    // ...
    return {
        id: SetTextWrapCommand.id,
        // ...
    };
}
// ToolbarItem.tsx
 <Select
    // ...
    onClick={(value) => {
        let commandId = id;
        // ...
        commandService.executeCommand(commandId, value);
     }}
     // ...
 />

上面是菜单栏中 text wrap 菜单项的 Select 组件,可以看到在上面绑定了 click 事件处理函数,当点击后,commandService 将执行 commandId,也就是在 WrapTextMenuItemFactory 中配置的 id 值:SetTextWrapCommand 的 id

第二步:在 SetTextWrapCommand 中,包装一下参数,然后执行了统一设置样式的 Command,SetStyleCommand

export const SetTextWrapCommand: ICommand<ISetTextWrapCommandParams> = {
    type: CommandType.COMMAND,
    id: 'sheet.command.set-text-wrap',
    handler: async (accessor, params) => {
        // ...
        const commandService = accessor.get(ICommandService);
        const setStyleParams: ISetStyleParams<WrapStrategy> = {
            style: {
                type: 'tb',
                value: params.value,
            },
        };
  
        return commandService.executeCommand(SetStyleCommand.id, setStyleParams);
    },
};

第三步:在 SetStyleCommand 中,因为改变了选区内样式值,所以需要组装 SetRangeValuesMutation 的参数,比如将选区内所有单元格的 tb 设置为 WrapStrategy.WRAP。由于选区内 text wrap 的改变,同时该行是自动调整行高的,那么还需要去计算该行的一个 autoHeight,也就是容纳该行内容的一个最低高度。计算自动行高之前,需要先执行 SetRangeValuesMutation,因为 autoHeight 计算是依赖于更新后的视图数据的。 通过 SheetInterceptorService 中注册的 interceptor 拿到 autoHeight 的值(redos 中)

 // set-style.command.ts
 const { undos, redos } = accessor.get(SheetInterceptorService).onCommandExecute({
       id: SetStyleCommand.id,
        params,
 });

第四步:之所以上面能够拿到 autoHeight 的值,主要还是归因于 AutoHeightController 类,该类在 LifecycleStages.Ready 阶段被实例化,并且添加了会影响到行自动行高的所有 Command 的拦截,如对 SetStylecommand 拦截。

// auto-height.controller.ts
// for intercept set style command.
sheetInterceptorService.interceptCommand({
     getMutations: (command: { id: string; params: ISetStyleParams<number> }) => {
          if (command.id !== SetStyleCommand.id) {
              return {
                  redos: [],
                  undos: [],
              };
          }
          // ...
          const selections = selectionManagerService.getSelectionRanges();
 
          return this._getUndoRedoParamsOfAutoHeight(selections);
      },
  });

第五步:因为计算行的自动行高需要用到文档模型以及单元格布局的相关计算,所相关计算都放在了管理Spreadsheet 的 SheetSkeleton 类中(视图层), 通过该类中 calculateAutoHeightInRange 方法最终计算出行的自动行高

// auto-height.controller.ts
private _getUndoRedoParamsOfAutoHeight(ranges: IRange[]) {
    // ...
    const { skeleton } = sheetSkeletonService.getCurrent()!;
    const rowsAutoHeightInfo = skeleton.calculateAutoHeightInRange(ranges);
    // ...     
}

第六步:当拿到 autoHeight 的数据后,会触发 SetWorksheetRowHeightMutation。无论是上面触发的 SetRangeValuesMutation 还是 SetWorksheetRowHeightMutation,都会更改模型层,并且标记 sheetSkeleton 和 mainComponent 为 dirty,在 sheetSkeletion 重新计算布局等相关渲染所需信息,然后渲染页面

// sheet-render.controller.ts
private _commandExecutedListener() {
    this.disposeWithMe(
        this._commandService.onCommandExecuted((command: ICommandInfo) => {
            // ...
            if (COMMAND_LISTENER_SKELETON_CHANGE.includes(command.id)) {
                 // ...
                 if (command.id !== SetWorksheetActivateMutation.id) {
                    this._sheetSkeletonManagerService.makeDirty(
                        {
                            unitId,
                            sheetId,
                            commandId: command.id,
                        },
                        true
                    );
                  }
                  // ...
              }
              this._renderManagerService.getRenderById(unitId)?.mainComponent?.makeDirty(); // refresh spreadsheet
         })
     );
 }

以上就完成了从事件触发到修改模型层,进而视图层更新的整个过程

伍. 更多阅读

如果你想对架构有个整体的了解,推荐阅读Architecture Notes 架构概要,如果你想了解更多 sheet 的架构和各个模块的设计和职责,推荐阅读Univer Sheet Architecture - Univer Sheet 架构。如果你对 DI 系统比较陌生,建议 阅读 Univer 项目中所使用的 DI 框架 redi。项目使用 Rxjs 作为观察者模式,阅读 Rxjs 相关文档 是快速熟悉 Rxjs 的方式

通过漫画形式来解释 ArrayBuffers 和 SharedArrayBuffers

这是本系列三篇文章中的第二篇:

  1. 内存管理速成手册
  2. 通过漫画形式来解释 ArrayBuffers 和 SharedArrayBuffers
  3. 使用 Atomics 来在 SharedArrayBuffers 中避免竞用条件

在上一篇文章中,我解释了一些自动内存管理的语言比如 JavaScript 怎么管理内存。同时我也解释了例如 C 语言,如何进行手动内存管理。那么这和我们将要讨论的 ArrayBuffersSharedArrayBuffers 有什么关系呢?这是因为 ArrayBuffer 也使得你能够手动处理数据,尽管这是在 JavaScript 中,一种具有自动内存管理的语言。那么,你为什么想要进行手动处理呢?正如上一篇文章所描述,在使用自动内存管理上有一个权衡。自动内存管理使得开发者开发程序变得相对容易,但是它也带来了一些困扰。在某些场景中,自动内存管理可能会带来性能上的问题。

A balancing scale showing that automatic memory management is easier to understand, but harder to make fast

例如,当你使用 JS 创建一个变量的时候,JS 引擎不得不猜测这个 JS 变量所包含数据的类型以及怎样在内存中进行存储。因为这些猜测,JS 引擎通常会为这些变量实际需要的内存分配更大的内存空间。根据不同的变量,分配的内存空间可能是实际所需的 2-8 倍,这将导致极大的内存浪费。除此之外,特性模式的创建和使用 JS 对象也将会使得其很难被 JS 引擎垃圾回收。如果你正在进行手动的内存管理,你可以根据自己工作上的使用场景自己选择内存分配和解除分配的策略。当时在很多时候,却并不值得这样做。因为在很多使用场景下我们的程序并没有那么性能敏感以至于需要采用手动得内存管理。甚至在通常的使用中,手动内存管理甚至会使得程序更慢。但是在有些时候,你需要从一些更底层的操作来时的你的代码运行的更快,那么 ArrayBuffers 和 SharedArrayBuffers 将是很好的选择。

A balancing scale showing that manual memory management gives you more control for performance fine-tuning, but requires more thought and planning

那么 ArrayBuffer 是怎么工作的呢?基本上和其他的 JavaScript 数组没有什么区别。除了,当你使用 ArrayBuffer 的时候,你不可以将任意的 JavaScript 数据类型到 ArrayBuffer 中,例如 objects 或者 strings。唯一能够放入 ArrayBuffer 中的只有字节(可以通过数字来表示)。

Two arrays, a normal array which can contain numbers, objects, strings, etc, and an ArrayBuffer, which can only contain bytes

另外一件我必须明确说明的是,你并不能够直接的将字节放入 ArrayBuffer。这是因为,ArrayBuffer 并不知道一个字节有多大,也不知道不同的数字转化成字节的区别。ArrayBuffer 仅仅是一个「0」和「1」组成一行的二进制串。ArrayBuffer 也不知道分隔符应该放在该二进制串的什么位置。

A bunch of ones and zeros in a line

为了给 ArrayBuffer 提供上下文,将上面的二进制串分割在相同尺寸的盒子里,我们需要一个称作「视窗」概念将二进制串分割到不同的盒子里。这些二进制数据上的视窗可以以带类型的数组存储,同时在 ArrayBuffer 中有不同带类型数组。比如,你可以通过8位整数的类型数组将上面的 ArrayBuffer 8 位一字节分割开来。

Those ones and zeros broken up into boxes of 8

或者你可以使用无符号16位整数的数组,这样就将上面的 ArrayBuffer 分割成了16位一字节的不同块中,然后依然想无符号整数一样对其操作。

Those ones and zeros broken up into boxes of 16

你甚至可以在同一个基础 buffer 上面拥有不同的「视窗」。不同的「视窗」在相同的操作下会带来不同的结果。比如,在Int8 视窗中,你可能会得到 0 & 1 表达式,而在同样的 buffer 下,在 Uint16 视窗下你可能会得到其他结果,尽管他们都拥有相同的二进制位串。

Those ones and zeros broken up into boxes of 16

在上面描述得工作方式下,ArrayBuffer 的角色仅仅是向一块原始的内存。它模拟了像在 C 语言中直接获取\操作 内存的工作。你可能会产生疑问,为什么 JS 不直接提供给使用者直接获取/操作内存的接口而是添加ArrayBuffer 这一抽象层呢?这是因为直接获取/操作内存可能会导致一些安全漏洞。我将在将来的文章中讨论这一块内容。那么,SharedArrayBuffers 又是什么呢?为了解释 SharedArrayBuffers,我需要先简略解释 JavaScript 中并行运行代码。为了并行运行代码,你需要将工作拆分成不同部分。但是在一个典型的 app 中,所有的工作都是在一个独立的线程中完成。在之前的文章中我也提及过这一点...这个主线程就像一个全栈工程师一样。它掌管着 JavaScript、DOM、以及视图布局。所有你能够操作的工作都是在这个主线程帮助下完成的。在某些特定环境下,ArrayBuffers 可以减轻主线程的负担,代替完成主线程的部分工作。

The main thread standing at its desk with a pile of paperwork. The top part of that pile has been removed

但是有时候减少主线程的工作依然是不够的。有时候你需要引进增援…你需要将工作分开。在很多编程语言中,将工作分成不同块每一块也就称作一个线程。这个多人共同完成一个项目是一个道理。如果你有一些任务,同时该任务和其他任务相对独立,那么你就可以在其他线程中完成这些任务。因此,不同的线程就可以在同一时间完成互相独立的分离任务。在 JavaScript 中,我们可以通过被称作web worker的工具来完成以上工作。这些web workers与您在其他语言中使用的线程略有不同。默认情况下,它们不共享内存。

Two threads at desks next to each other. Their piles of paperwork are half as tall as before. There is a chunk of memory below each, but not connected to the other's memory

这也就意味着,如果你想和其他线程共享数据,那么你就需要将数据从一个地方复制到另外一个地方。这是通过函数postMessage 完成的。postMessage 将所有输入的对象序列化,将其发送到另一个web worker,并将其反序列化并放入内存中。

Thread 1 shares memory with thread 2 by serializing it, sending it across, where it is copied into thread 2's memory

这事一个相当慢的过程,比如一些类型的数据,像 ArrayBuffers,你可以转移内存。这意味着你可以将某一特定的内存块移动到其他地方,这样其他的 web worker 就可以获取/操作 该内存块。但是之前的 web worker 将不能够再获取到该内存块了。

Thread 1 shares memory with thread 2 by transferring it. Thread 1 no longer has access to it

这也许在某些场景中适用,但是在更多的情况,你可能需要更高效得并行策略,在这些场景下,你可能真实的想要共享内存单元。ShareArrayBuffer 能够帮助你达到此目的。

The two threads get some shared memory which they can both access

通过 ShareArrayBuffer,web worker、不同线程可以在相同的内存块中读写数据。这也意味着你不爱需要通过 postMessage 来在不同的线程中通信传递数据。不同的 web worker 都有获取/操作数据的权限。但是这也会带来一些问题,比如两个线程在同一时间对数据进行操作。这也就是通常被称作「竞用条件」的现象。

Drawing of two threads racing towards memory

我将在下一篇文章中解释什么是竞用条件。那么 SharedArrayBuffers 现阶段处于什么地位呢?庆幸得,在不久的将来,所有主流浏览器都贱支持 SharedArrayBuffers。

Logos of the major browsers high-fiving

SharedArrayBuffers 在 Safari(Safari 10.1)中已经可以使用。Firefox 和 Chrome 也将在今年的七八月发布的版本中包含此项功能。Edge 浏览器计划在今年的秋天完成此项功能的更新。但是即使所有主流浏览器都已经支持 SharedArrayBuffers,我们也不希望应用程序开发人员直接使用它。实际上,我们发对这样做。你应该在其之上进行抽象,使用更高层的一些库。我们所期待的是框架或库的开发者们能够创建一些工具库,这些工具库能够帮助我们更方便、安全的使用 SharedArrayBuffer。除此之外,一旦 SharedArrayBuffers 在平台上实现,WebAssembly 可以使用它来实现多线程。到时候,你就能够向 Rust 语言一样使用并发的抽象层,它将无所畏惧得将并发作为其主要目标。在下一篇文章中,我们将解释工具(Atomics )以及工具开发者是怎样来实现这一抽象层并如何避免竞用条件的。

Layer diagram showing SharedArrayBuffer + Atomics as the foundation, and JS libaries and WebAssembly threading building on top

新零售图片加载优化方案

饿了么 App 中新零售项目主要是以图片展示为主,引导用户点击轮播广告栏或者店铺列表进入指定的商品页面,因此页面中包含了大量图片,如搜索框下面的轮播广告栏、中部的促销栏以及底部的店铺列表,这些区域中都有大量的展示图片。因此图片的加载速率直接影响页面的加载速度。下面将从图片加载存在的问题和原因、解决方案两个方面来阐述如何优化新零售图片的加载。

图片加载存在的问题和原因

问题一:启动页面时加载过多图片

图1: 新零售图片请求瀑布图

问题原因分析: 如上图所示,页面启动时加载了大约 49 张图片(具体图片数量会根据后端返回数据而变化),而这些图片请求几乎是并发的,在 Chrome 浏览器,对于同一个域名,最多支持 6 个请求的并发,其他的请求将会推入到队列中等待或者停滞不前,直到六个请求之一完成后,队列中新的请求才会发出。上面的瀑布图中,在绿色的标记框中,我们看到不同长度的白色横柱,这些都是请求的图片资源排队等待时间。

问题二:部分图片体积过大

图2. 顶部轮播图中的一张图片加载图

问题原因分析:如图 1,红框中是搜索框下部的轮播广告中的一张图片,通过图 2 可以看到,该图片主要耗时在 Conent Download 阶段。在下载阶段耗时 13.50s。而该请求的总共时间也就 13.78s。产生该问题的原因从图 1 也能看出一些端倪,该图片体积 76.2KB图片体积过大,直接导致了下载图片时间过长。

前端解决方案

针对问题一的解决方案

由于新零售首页展示展示大量图片,其实在这大约 49 张图片中,大部分图片都不是首屏所需的,因此可以延迟首屏不需要的图片加载,而优先加载首屏所需图片。这儿首屏的含义是指打开新零售首页首先进入屏幕视窗内的区域范围。

判断图片是否是首屏内图片,首先想到的肯定是通过 getBonundingClientRect 方法,获取到图片的位置信息,判断其是否在 viewport 内部。可能的代码如下:

const inViewport = (el) => {
  const rect = el.getBoundingClientRect()

  return rect.top > 0
    && rect.bottom < window.innerHeight
    && rect.left > 0
    && rect.right < window.innerWidth
}

但是在项目中,我们并没有采用该方案来判断是否在首屏,其原因在于,只有当 DOM 元素插入到 DOM 树中,并且页面进行重拍和重绘后,我们才能够知道该元素是否在首屏中。在项目中我们使用了 v-img 指令(新零售项目使用该指令对图片进行加载、并且将 hash 转换成 Url。项目已开源,在符合需求前提下欢迎使用),在 Vue 指令中包含两个钩子函数 bindinserted。官网对这两个钩子函数进行如下解释:

  • bind:只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。
  • inserted:被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。

由上面解释可知,我们只能够在 inserted 钩子函数中才能够获取到元素的位置,并且判断其是否在首屏中。在新零售项目中,经过笔者测试,这两个钩子函数的触发时差大约是200ms,因此如果在 inserted 钩子函数内再去加载图片就回比在 bind 钩子函数中加载晚大约200ms,在 4G 网络环境下,200ms 对于很多图片来说已经足够用来加载了,因此我们最终放弃了在 inserted 钩子函数中加载首屏图片的方案。

如果元素没有插入到 DOM 树中并渲染,怎么能够判断其是否在首屏中呢

<img v-img="{ hash: 'xxx', defer: true }">

项目中使用了一种比较笨的方式来判断哪些是首屏图片,新零售页面布局是确定的,轮播广告栏下面是促销栏、再下面是店铺列表,这些组件的高度也都相对固定,因此这些组件是否在首屏中其实我们是事先知道的。因此在实际使用 v-img 指令的时候,通过传 defer 配置项来告诉 v-img 哪那些图片需要提前加载,哪些图片等待提前加载的图片加载完毕后再加载。这样我们就能够在 bind 钩子函数中加载优先加载的图片了。比如说,轮播组件图片、促销组件图片、前两个店铺中的展示图片需要先加载,除此以外的其他图片,需等待首屏图片完全加载后再进行请求加载。实际实现代码如下:

const promises = [] // 用来存储优先加载的图片  
Vue.directive('img', {
    bind(el, binding, vnode) {
   	  // ...
      const { defer } = binding.value
	  // ...
      if (!defer) {
        promises.push(update(el, binding, vnode))
      }
    },
    inserted(el, binding, vnode) {
      const { defer } = binding.value
      if (!defer) return
      if (inViewport(el)) {
        promises.push(update(el, binding, vnode))
      } else {
        Vue.nextTick(() => {
          Promise.all(promises)
          .then(() => {
            promises.length = 0
            update(el, binding, vnode)
          })
          .catch(() => {})
        })
      }
    },
    // ...
  })

首先通过声明一个数组 promises 用于存储优先加载的图片,在 bind 钩子函数内部,如果 defer 配置项为 false,说明不延时加载,那么就在 bind 钩子函数内部加载该图片,且将返回的 promise 推入到 promises 数组中。在 inserted 钩子函数内,对于延迟加载的图片(defer 为 true),但是其又在首屏内,那么也有优先加载权,在 inseted 钩子函数调用时就对其加载。而对于非首屏且延迟加载的图片等待 promises 数组内部所有的图片都加载完成后才加载。当然在实际代码中还会考虑容错机制,比如上面某张图片加载失败、或者加载时间太长等。因此我们可以配置一个最大等待时间。

优化后的图片加载瀑布图如下:

图2. 图片按需加载的瀑布图

如上图所示,下面红框的图片不是首屏图片,因此进行了延迟加载。可以看出,其是在上面所有图片(包括上面的红框中耗时最长的那张图)加载完成之后进行加载的。这样减少了首屏加载时的网络消耗。提升了图片下载速度。

优化前后对比

通过上面的优化方案,在预设的网络环境下(参见文末注),分别对优化前和优化后进行了 5 次平行清空缓存加载,平均数据如下:

DOMContentLoaded Loaded Max_size_image
优化前(平均值 s) 1.01 1.01 13.86±0.54
优化后(平均值 s) 0.952 0.951 8.12±0.50

通过上面表格可以看出,DOMContentLoadedLoaded 并没有多大参考价值,首屏的完整展现所需要的时间依然由加载最慢(一般都是体积最大那张图片)的图片决定,也就是上表的 Max_size_image 决定,上表可以看出,优化后比优化前最大体积图片的加载时间缩短了 5.74s。提速了整整 41.41%。加载最慢的图片加载速度的变化也很好的反应了首屏时间的变化。

当然上面的数据也不能够完全反应线上场景,毕竟测试的时间点及后端数据都有所不同。我们也不能够在同一时间点、同一网络环境下对优化前、优化后进行同时数据采集。

针对问题一还有些后续的解决方案:

  • 在 HTTP/1.0 和 HTTP/1.1 协议下,由于 Chrome 只支持同域同时发送 6 个并发请求,可以进行域名切分,来提升并发的请求数量。或者使用 HTTP/2 协议。

针对问题二的解决方案

**图片体积过大,导致下载时间过长。**在保证清晰度的前提下尽量使用体积较小的图片。而一张图片的体积由两个因素决定,该图片总的像素数目和编码单位像素所需的字节数。因此一张图片的文件大小就等于图片总像素数目乘以编码单位像素所需字节数,也就是有如下等式:

FileSize = Total Number Pixels * Bytes of Encode single Pixels

举个例子:

一张 100px * 100px 像素的图片,其包含该 100 * 100 = 10000 个像素点,而每个像素点通过 RGBA 颜色值进行存储,R\G\B\A 每个色道都有 0~255 个取值,也就是 2^8 = 256。正好是 8 位 1byte。而每个像素点有四个色道,每个像素点需要 4bytes。因此该图片体积为:10000 * 4bytes = 40000bytes = 39KB

有了上面的背景知识后,我们就知道怎么去优化一张图片了,无非就两个方向:

  • 一方面是减少单位像素所需的字节数
  • 另一方面是减少一张图片总的像素个数

单位像素优化:单位像素的优化也有两个方向,一个方向是「有损」的删除一些像素数据,另一个方面是做一些「无损」的图片像素压缩。正如上面例子所说,RGBA 颜色值可以表示 256^4 种颜色,这是一个很大的数字,往往我们不需要这么多颜色值,因此我们是否可以减少色板中的颜色种类呢?这样表示单位像素的字节数就减少了。而「无损」压缩是通过一些算法,存储像素数据不变的前提下,尽量减少图片存储体积。比如一张图片中的某一个像素点和其周围的像素点很接近,比如一张蓝天的图片,因此我们可以存储两个像素点颜色值的差值(当然实际算法中可能不止考虑两个像素点也许更多),这样既保证了像素数据的「无损」,同时也减少了存储体积。不过也增加了图片解压缩的开销。

针对单位像素的优化,衍生出了不同的图片格式,jpegpnggifwebp。不同的图片格式都有自己的减少单位像素体积的算法。同时也有各自的优势和劣势,比如 jpegpng 不支持动画效果,jpeg 图片体积小但是不支持透明度等。因此项目在选择图片格式上的策略就是,在满足自己需求的前提下选择体积最小的图片格式,新零售项目中已经统一使用的 WebP 格式,和 jpeg 格式相比,其体积更减少 30%,同时还支持动画和透明度。

图片像素总数优化

图3:图片加载尺寸和实际渲染尺寸对比

上图是新零售类目页在 Chrome 浏览器中的 iPhone 6 模拟器加载后的轮播展示的图片之一,展示的图片是 750 * 188 像素,但是图片的实际尺寸为 1440 * 360 像素,也就是说我们根本不需要这么大的图片,大图片不仅造成了图片加载的时长增加(后面会有数据说明),同时由于图片尺寸需要缩小增加CPU的负担。

上文中已经提及,项目中我们使用的 v-img 指令来加载项目中的所需图片,如果我们能够根据设备的尺寸来加载不同尺寸(像素总数不同)的图片,也就是说在保证图片清晰度的前提下,尽量使用体积小的图片。问题就迎刃而解了。项目中我们使用的是七牛的图片服务,七牛图片服务提供了图片格式转换、按尺寸裁剪等图片处理功能。只需要对 v-img 指令添加图片宽、高的配置,那么我们是不是可以对不同的设备加载不同尺寸的图片呢?

项目中我们使用的 lib-flexible 来对不同的移动端设备进行适配,lib-flexible 库在我们页面的html元素添加了两个属性,data-dprstyle。这儿我们主要会用到 style 中的 font-size 值,在一定的设备范围内其正好是html元素宽度的十分之一(具体原理参见:使用Flexible实现手淘H5页面的终端适配),也就是说我们可以通过style属性大概获取到设备的宽度。同时设计稿又是以 iPhone6 为基础进行设计的,也就是设计稿是宽度为 750px的设计图,这样在设计图中的图片大小我们也就能够转换成其他设备中所需的图片大小了。

举个例子:

设计稿中一张宽 200px 的图片,其对应的 iPhone 6 设备的宽度为 750px。我们通过 html 元素的 style 属性计算出 iPhone6 plus 的宽度为 1242px。这样也就能够计算中 iPhone6 plus 所需图片尺寸。计算如下:

200 * 1242 / 750 = 331.2px

实现代码如下:

const resize = (size) => {
  let viewWidth
  const dpr = window.devicePixelRatio
  const html = document.documentElement
  const dataDpr = html.getAttribute('data-dpr')
  const ratio = dataDpr ? (dpr / dataDpr) : dpr

  try {
    viewWidth = +(html.getAttribute('style').match(/(\d+)/) || [])[1]
  } catch(e) {
    const w = html.offsetWidth
    if (w / dpr > 540) {
      viewWidth = 540 * dpr / 10
    } else {
      viewWidth = w / 10
    }
  }

  viewWidth = viewWidth * ratio

  if (Number(viewWidth) >= 0 && typeof viewWidth === 'number') {
    return (size * viewWidth) / 75 // 75 is the 1/10 iphone6 deivce width pixel
  } else {
    return size
  }
}

上面 resize 方法用于将配置的宽、高值转换为实际所需的图片尺寸,也就是说,size 参数是 iphone 6 设计稿中的尺寸,resize 的返回值就是当前设备所需的尺寸,再把该尺寸配置到图片服务器的传参中,这样我们就能够获取到按设备裁剪后的图片了。

优化前后效果对比,有了上面的基础,我们在 Chrome 中的不同的移动端模拟器上进行了实验,我们对新零售类目页中的一张体积最大的广告图片在不同设备中的加载进行了数据统计(平行三次清空缓存加载),为什么选择体积最大的图片,上文也已经说过,其决定了首屏展现所需的时间。

Size(px) File Size(KB) Total Time(s) Download(s) TTFB(ms)
iphone5(640 * 160) 23.2 3.90 3.65 226.62
iphone6(750 * 188) 30.4 5.05 4.87 162.37
Nexus 5x(820 * 205) 34.2 5.87 5.35 501.34
Galaxy S5(1080 * 270) 51.1 9.31 9.13 222.67
Nexus 6P(1230 * 308) 61.9 10.57 9.12 220.67
Iphone6 plus(1240 * 310) 62.5 10.99 10.66 313.74
未优化(1440 * 360) 76.2 14.85 13.79 224.01

上表格中,除去最后一行是未优化的加载数据,从上到下,设备屏幕尺寸逐渐变大,加载的图片尺寸也从 23.2kb增加到 65.5kb。而加载时间和下载时长也跟随着图片体积的加大而增加,下面的折线图更能够反应图片尺寸、加载时长、下载时长之间的正相关关系。TTFB(从发送请求到接收到第一个字节所需时长)却和图片大小没有明显的正相关关系,可能对于图片服务器在裁剪上述不同尺寸的图片所需时长差异不大。

图4:不同设备中对同一张图片进行加载,文件大小、加载和下载时长的折线变化

由上折线图我们还能看到,对于小屏幕设备的效果尤为明显,在不优化下,iPhone5 中图片的加载需要 14.85s,而优化后,加载时长缩短到了 3.90s。加载时长整整缩短了 73.73%。而对于大屏幕的 iPhone6 plus 也有 26.00% 时长优化。

当然上面的数据是建立在 256 kbps ISDN/DSL 的网络环境下的,该低速网络环境下,图片的加载时间主要是由于下载时间决定的,因此通过优化图片体积能够达到很好的效果。在 4G(Charles模拟)环境下,iPhone5 中的优化效果就会有些折扣,加载时长缩短 69.15%。其实也很容易想到,在高速的网络环境下,TTFB 对加载时长的影响会比低速网络环境下影响要大一些。

最后总结

通过上面的研究及数据结果表明,新零售图片加载缓慢的优化策略:

  • 首屏图片优先加载,等首屏图片加载完全后再去加载非首屏图片。
  • 对大部分图片,特别是轮播广告中的图片进行按设备尺寸裁剪,减少图片体积,减少网络开销,加快下载速率。

本文中没有过多的讨论代码实现细节,而是把重点放在了图片加载缓慢的原因分析,以及优化前后效果对比的数据分析上,如果想看更多代码细节,请移步vue-img

:本文所有数据及图片都是通过 Charies 模拟 256 kbps ISDN/DSL 网络环境获取到的。在本案例中只考虑位图,因此文本中提及的图片都是指位图而非矢量图。

Muya 编辑器介绍及架构

从 2017 年年底开始,我利用业余时间在开发一款开源 markdown 编辑器 marktext,marktext 是一款使用 Electron 开发的所见即所得 markdown 编辑器,下面引用了 README 上的描述:

A simple and elegant open-source markdown editor that focused on speed and usability.
(Available for Linux, macOS and Windows.)

正如上面描述,marktext 更多的聚焦在高性能(speed)和可用性(usability)上面,这与其编辑器核心(muya)的架构设计是分不开的,这一系列文章(分享)将聚焦在 muya 的架构设计以及模块实现上。整个系列将拆分成三篇文章:

一、Muya 编辑器介绍

1.1 背景

Muya 编辑器最初是和 marktext 在一个仓库里开发,但是在开发过程中,一些架构及性能问题逐渐暴露:

  1. 在编辑文字内容较多的文档时,比如 50000 字以上,会出现卡顿的现象,Muya 使用 snabbdom 作为渲染引擎,每次编辑后全局渲染导致卡顿,比如在我们进行用户输入、或者通过按下 Enter 创建新段落、复制黏贴段落,都需要全局渲染整个文档。
  2. History(即 Undo/Redo 功能) 模块设计的过于简单,每次用户的操作都是记录的整个文档状态的深拷贝,导致了内存占用过多。
  3. marktext 不支持协作编辑。

由于上面的一些问题,大概在 2018 年开始重构 muya,在尽量保证功能不变的前提,来实现更高的性能以及更小的内存占用。

1.2 Muya 的支持哪些功能

marktext 官网 Feature 部分

  1. 所输及所得,传统 markdown 编辑器通常是分屏的,也就是左边是 markdown 编辑区,右边是预览区,muya 将编辑区和预览区整合在了一起,可以让我们更聚焦在写作上,而不用关注 markdown 语法。
  2. 支持 CommonMark SpecGitHub Flavored Markdown Spec markdown 标准,并且选择性支持 Pandocs Markdown。(更多的细节将在本系列第二篇文章 <Muya 编辑器核心模块的设计和实现> 讨论)
  3. 支持额外的 markdown 扩展语法,比如行内及块的数学公式、Front Matter、Emoji 等。
  4. 支持导出 HTML、markdown、JSON,支持导入 markdown、JSON。
  5. 支持图表,flowchartmermaid 等。

二、编辑器的核心实现原理及插件支持

在上一个部分主要介绍了 muya 重构的背景,以及 muya 所支持的功能,在这一个部分,我将通过一些简单的代码来描述下 muya 的核心实现原理,以及是如何支持语法和 UI 插件的。

2.1 Muya 编辑器核心实现原理概述

我们先来看看单个 markdown 段落是如何渲染的:

<span contenteditable="true"></span>

作为前端工程师,应该都知道,给一个 span 元素加上 contenteditable 的属性,那么这个 span 元素将转变成可编辑状态,这样不就是一个最简单的文本编辑器了吗?

<span contenteditable="ture">normal text **strong**</span>

可是当我在输入框中输入 strong 时,编辑器并没有帮我们对文本 strong 进行加粗,这时候 markdown 词法解析器(Lexical analysis)就派上用场了。

我们通过 tokenizer 函数,将 span 中的文本内容解析成一个 token 数组,如下:

[
  {
    "type": "text",
    "raw": "normal text ",
    "content": "normal text ",
    "range": {
      "start": 0,
      "end": 12
    }
  },
  {
    "type": "strong",
    "raw": "**strong**",
    "range": {
      "start": 12,
      "end": 22
    },
    "marker": "**",
    "children": [
      {
        "type": "text",
        "raw": "strong",
        "content": "strong",
        "range": {
          "start": 14,
          "end": 20
        }
      }
    ],
    "backlash": ""
  }
]

其实 tokenizer 的原理就是通过正则表达式匹配到通过 ** 语法,然后将其转化成 tokens,并且记录了语法开始和结束的位置,便于后面进行渲染,大家如果有兴趣可以看看 tokenizer 源码。

当我们拿到 tokens 后 ,接下来就是将其转换成标记后 HTML,然后插入到之前 contenteditable span 元素中。

<span contenteditable="true">
  <span class="mu-plain-text">normal text </span>
  <span class="mu-hide mu-remove">**</span>
  <strong class="mu-inline-rule">
    <span class="mu-plain-text">strong</span>
  </strong>
  <span class="mu-hide mu-remove">**</span>
 </span>

在转换成 HTML 过程中,我们使用了 snabbdom,对 tokens 进行深度优先的遍历,并生成 vnode(相关源码)。首先 snabbdom 能够节省我们去拼接 HTML 的工作量,最重要的原因,其 patch 方法会对新老 HTML (vnode)进行比对,仅替换或修改更改的 vnode,这显然比使用 innerHTML 性能更优。

当进行到这一步,我们的 strong 文本已经加粗显示在了编辑区中,但是问题来了,在我们编辑后,contenteditable span 进行了重新渲染,我们的光标或选区也会在重新渲染后丢失,那接下来的工作就是将光标或选区设置回之前的编辑位置。
在浏览器中提供了方法来设置光标:

  select (startNode, startOffset, endNode, endOffset) {
    const range = document.createRange()
    range.setStart(startNode, startOffset)
    if (endNode) {
      range.setEnd(endNode, endOffset)
    } else {
      range.collapse(true)
    }
    this.selectRange(range)

    return range
  }

任何一个光标或者选区都可以通过一对(Anchor 和 Focus) DOM Node 加 offset 来标识,在 Selecton 模块中,我们对光标位置进行记录,这样在内容重新渲染后,我们只需通过上面的方法重新设置光标或选区就能使编辑器保持输入状态了。上面只是截取了 Muya Selection 模块部分代码,Selection 模块更多设计细节将在 <Muya 编辑器核心模块的设计和实现> 描述。

2.2 Muya 是如何支持语法插件和 UI 组件的

我们在做软件架构的时候,都会考虑到其扩展性,muya 的设计也不例外,正如上面所描述,muya 仅支持 CommonMark Spec 和 GFM,以及部分 markdown 语法扩展,如果我们支持语法插件,那么 muya 的使用者就不用给开发者提 Feture Request 了,可以直接通过语法插件来添加新的扩展语法,比如 ==highlight==。

上文已经有所介绍,markdown 行内语法样式在渲染的过程中都是通过 tokenizer 来解析的,其实如果想添加自定义的 markdown 语法,就是向 tokenizer 添加更多的 rule(其实就是一个正则表达式及 相应 token 的拼装),可以从源码查看目前 muya 支持的 rules。

什么是 UI 组件?任何脱离编辑区的弹框、选择框、DropDown 等都是 UI 组件,首先,UI 组件的展示和隐藏是有一套事件系统来通信的,其次,就是 UI 组件的定位和渲染。

先说说事件系统,相信很多同学都写过 EventEmitter 类似的代码,在 muya 中也实现了一个 EventCenter,不仅可以绑定和解绑 DOM 事件,也可以触发和监听自定义事件 。

编辑器和 UI 组件的通信都是通过全局唯一的 EventCenter 来完成的,比如编辑器检测到输入 ```java 的时候,会发出 muya-code-picker 事件,UI 组件监听到该事件就会在当前编辑位置渲染一个 code picker。当选择一个语言后,code picker 会自动隐藏。

那么 UI 组件是怎么创建出来并渲染的呢?通过上面的描述,muya 是通过原生 JS 来写的,并没有使用前端框架(React、Vue),muya 并没有对 UI 组件的渲染做任何限制 ,UI 组件和编辑器唯一通信方式就是上面提到的 EventCenter,所以 muya 使用者可以选择任何喜欢的框架来完成他们想要的 UI 组件,在 muya 中,我们依然使用了 snabbdom 作为渲染引擎,来渲染 UI 组件,因为我们不想使 muya 变得太重,当然作为 UI 组件开发者,你可以 选择其它任何框架。

三、编辑器架构及与传统 MVC 架构区别

在第二部分,描述了 muya 实现的核心原理,主要聚焦在单个段落的渲染,编辑后的重渲染,光标或选区的回设,以及 muya 是如何支持语法插件和 UI 组件的,第二部分主要希望大家对 muya 有个微观的认识。在这一部分,我们将从宏观的角度来聊聊 muya 的整体架构,因此很多模块也都仅仅点到即止,更多细节可阅读本系列<Muya 编辑器核心模块的设计和实现> 和 <Muya 编辑器支持协作编辑>。

3.1 Muya 编辑器架构

我们可以将 markdown 编辑器想象成一个可操纵的黑盒,以 markdown 作为输入,用户可以对这个黑盒进行一些操作(键盘、鼠标事件),最终输出 markdown。

流程图

那么问题就在于如何来实现这个黑盒了,普通的 markdown 文本(string)并不是一个适合编辑的数据结构,比如,每次我们对文本进行编辑,添加或者删除文本,我们都需要对整个文本内容做 markdown 语法解析,这是一个比较耗时的过程,同时在 JS 中,直接对长文本操作也不是明智之举,因此我们选择了 JSON 作为 muya 编辑器内部存储数据结构(JSON state,更详细的描述可以直接跳到本系列第三篇文章<Muya 编辑器支持协作编辑>)。

流程图 (1)

Block Tree 介绍

在上图中,我们不仅看到了 JSON State 作为 muya 编辑器的数据层,还多了一个 Block Tree,我们知道编辑区域是一个 DOM Tree,但是直接操作 DOM 并不是一个明智之举,首先 DOM 并不能和数据层(JSON State)直接关联。其次 DOM 并没有和 markdown 语法一一映射的相应元素,比如代码模块,HTML 块等。因此我们需要根据 markdown 的语法来抽象我们自己的 UI 层,Block Tree 便应运而生。

foo **strong**

> content in quoteblock

上面的 markdown 文本,将解析成生成下面的 Block Tree, ScrollPage(下文会提及) 是Block Tree 的根节点,但是它也有个 parent 指针指向 muya 实例。

流程图 (2)

Block Tree 从名字来看就知道他是一个树结构,Block 的 parent 指针指向其 parent Block,children 指针指向其所有子节点,children 同时也是一个链表的数据结构,便于我们进行一些移除和插入的操作。根据 markdown 语法,可以区分两种 Block(括号中是 blockName,下同):

  1. Container Block(block-quote、bullet-list、order-list、task-list、list-Item 等)
  2. Leaf Block (paragraph、html 等)

根据支持的 markdown 标准,又可以区分为 CommonMark Block 和 GFM Block 以及 markdown extra Block:

  1. commonMark
  2. GFM
  3. extra

根据是否是直接可编辑(是否包含 contenteditable 元素),又分为可编辑和不可编辑 block:

  1. 不可编辑 block(atx-heading、paragraph 等)
  2. 可编辑 block(paragraph.content、atxheading.content 等)

Block Tree 同时会绑定渲染的 DOM 元素,同时也会绑定对应的 State。在第二部分我们描述的简单 markdown 编辑器其实就是一个 paragraph.content block 中的元素渲染部分。

Block 同时还承担了监听键盘事件的作用,来响应用户的操作,比如用户在段落中进行输入,监听 input 事件,来重新渲染段落,高亮 markdown 语法,同时也会响应式的去更新 JSON State。监听 Enter 事件,会创建新的 paragraph block,并且插入到之前的段落后面,在插入段落的过程中,重复上面的步骤,渲染 DOM 元素,更新 JSON State。

3.2 文档的生命周期

上面部分 ,我们了解了 muya 的两个核心模块,JSON State 和 Block Tree,但是对于他们如何协调工作,让我们顺利的进行文档编辑,可能还是有些模糊,在这一部分,我们将通过在编辑一个文档,来看看在文档编辑整个生命周期,Block 和 JSON State 如何协调工作,以及最后如何输出 markdown 的整个过程。

流程图 (3)

文档渲染

第一步,将 DEFAULT_MARKDOWN 作为参数传给 Muya 构造函数,生成编辑器实例,在编辑器内部将对 DEFAULT_MARKDOWN 做 markdown 语法解析,在 muya 仓库中,fork 了一份 marked 源码,并对其添加了额外支持 markdown 扩展 ,比如 inline math、block math、front matter 等。

const DEFAULT_MARKDOWN = `
foo **strong**

# header 1
`

const muya = new Muya(container, { markdown: DEFAULT_MARKDOWN })

marked 解析结果如下:

[
  {
    "name": "paragraph",
    "text": "foo **strong**"
  },
  {
    "name": "atx-heading",
    "meta": {
      "level": 1
    },
    "text": "# header 1"
  }
]

从上面的 JSON State 我们可以看到,marked 并不会解析行内样式,正如在上文提及,行内样式的解析是交给了 tokenizer。

第二步,上一步生成的 JSON State,将进一步生成 Block Tree,完成整个文档的渲染和光标的设置。

  init () {
    const { muya } = this
    const state = this.jsonState.getState()
    this.scrollPage = ScrollPage.create(muya, state)

    const firstLeafBlock = this.scrollPage.firstContentInDescendant()

    const cursor = {
      path: firstLeafBlock.path,
      block: firstLeafBlock,
      anchor: {
        offset: 0
      },
      focus: {
        offset: 0
      }
    }

    this.selection.setSelection(cursor)
  }

用户和文档交互

在编辑器核心实现原理部分,已经大概介绍了编辑一个段落,并重新渲染设置光标的过程,这儿就不再赘述了,这儿我们来聊一聊如何通过 Enter 键创建新的段落。

我们来看看 format block(paragraph.content 继承 format block) 的源码:

  enterHandler (event) {
    event.preventDefault() // 阻止 contenteditable 元素,Enter 事件的默认行为
    const { text: oldText, muya, parent } = this
    const { start, end } = this.getCursor()
    this.text = oldText.substring(0, start.offset)
    const textOfNewNode = oldText.substring(end.offset)
    const newParagraphState = {
      name: 'paragraph',
      text: textOfNewNode
    }

    const newNode = ScrollPage.loadBlock(newParagraphState.name).create(muya, newParagraphState)

    parent.parent.insertAfter(newNode, parent)

    this.update()
    newNode.firstContentInDescendant().setCursor(0, 0, true)
  }

从上面代码,我们来看看enterHander 做了些什么,首先将原来的段落内容根据光标所在位置进行切分,通过后部分文本创建新的段落 newNode,然后将 newNode 插入到之前段落的后面,更新之前段落 this.update()。最后设置光标在新段落的第一个可编辑 block 的 (0,0)位置。上面代码就完成了 Enter 创建段落的整个过程,当然真实过程比上面还会复杂很多,比如我们正在编辑标题、列表在按下 Enter 键又是另外的结果了。有兴趣可以阅读相关 block 源码。

在用户和编辑器的交互过程中,不仅仅涉及到 Enter 键的交互,还有很多其它的交互,比如 BackSpace 键、Tab 键、复制、粘贴、点击、方向键等,针对每一个 Keyboard 和 鼠标事件都会有相应的处理方法,这样就完成了整个编辑器的交互。

文档导出

文档的导出相对来说比较简单,因为我们有 JSON State,根据 markdown 语法规则将 JSON State 拼装成 markdown 文本,如果要输出 HTML,在通过 marked 将 markdown 转换成 HTML。

流程图 (4)

更多关于导入、导出细节将在 <Muya 编辑器支持协作编辑> 讨论,或参考源码 ExportMarkdown

3.3 Muya 架构和传统 MVC 的区别

image

MVC 架构将程序划分为三种组件,模型 - 视图 - 控制器(MVC)设计定义它们之间的相互作用。

  • 模型(Model) 用于封装与应用程序的业务逻辑相关的数据以及对数据的处理方法。“Model”不依赖“View”和“Controller”,也就是说, Model 不关心它会被如何显示或是如何被操作。但是 Model 中数据的变化一般会通过一种刷新机制被公布。为了实现这种机制,那些用于监视此 Model 的 View 必须事先在此 Model 上注册,从而,View 可以了解在数据 Model 上发生的改变。
  • 视图(View)能够实现数据有目的的显示。在 View 中一般没有程序上的逻辑。为了实现 View 上的刷新功能,View 需要访问它监视的数据模型(Model),因此应该事先在被它监视的数据那里注册。
  • 控制器(Controller)起到不同层面间的组织作用,用于控制应用程序的流程。它处理事件并作出响应。“事件”包括用户的行为和数据 Model 上的改变。

那么 muya 是 MVC 架构吗?答案 muya 是又不是 MVC 架构。我们再回到 muya 的架构图:

流程图 (5)

JSON State 就是标准的 Model 层,Block Tree 是 Controller,但是又承担了部分页面渲染更新视图的工作。在标准的 MVC 架构中,View 会监听数据的变化,并刷新视图,相对比较独立,而在 muya 中,View 已经和 Block Tree 耦合在了一起。说一下为什么这样设计的原因,比如在一个 contenteditable 的元素中进行编辑,我们编辑内容会实时显示在元素中,因此我们的交互控制和 DOM 渲染是紧密联系在一起的(这也是浏览器自带的键盘交互),这也就是为什么 Block Tree 是和文档渲染高度耦合的一个原因。当然在 muya 中会有一个校验检查机制,当我们编辑文本后,我们通过一些判断来决定是否需要重新渲染 DOM,也就是说并不是每次编辑页面都会重新渲染。有兴趣同学可以看看 checkNeedRender 源码。

比如其它应用,白板,我们就可以完全按照 MVC 架构来设计,因为编辑可以和渲染完全分离,比如我们画一条线(Controller),根据这条线生成一系列坐标点(Model),最后根据 Model 重新绘制这条线段(View)。

四、面向未来的思考

contenteditable 属性是大部分编辑器的核心所在,但是它实现的编辑器也有其局限性:

  1. 不同浏览器间存在兼容问题,这也是为什么我们在很多键盘事件 Enter 等都阻止了默认行为,自定义键盘事件后的行为,来兼容不同的浏览器。
  2. contenteditable 本质也是通过 DOM 来渲染文档的,当文档比较大的时候,不可避免会出现性能问题。
  3. 正如上面所说,通过 contenteditable 实现的编辑器,交互控制和 DOM 渲染是耦合在一起的,这为我们架构设计或者功能实现上带来了一些阻力。
  4. 光标的局限性,原生的只能支持单个光标或选区。
    那么有没有更好的方案来实现编辑器呢?像石墨文档 - 新 Doc,Google Doc 都开始采用 Canvas 来渲染文档,通过一个 textarea 或者 contenteditable 元素来进行输入,同时可以自定义绘制光标,也就支持了多光标的输入,这也许是 muya 未来发展的方向。

五、参考文献和 GitHub 仓库

  1. https://zh.wikipedia.org/wiki/MVC
  2. GitHub muya 仓库

非科班出身,如何成为程序员?

本文是知乎的一篇回答

身边有很多非科班出身的程序员,比如 sofish、粽神。当然我也是,所以决定结合自身经历强答一波。

关于我

我本科学习的是「生物科学」,大学四年无非就是拿着 eppendorf 的实验枪,在超净台旁养着各种知名或不知名的微生物,有广为人知的「海拉细胞」有很多人没有听过的「毕赤酵母」,每逢寒暑假可能还会去山里或者某个海滨城市实习,在鸟巢旁边装一个微型摄像头,记录下喂食雏鸟的频率,去海边抓一些招潮蟹或者海月水母,也算乐在其中。我很享受大学这段时光,因为毕竟是我儿时的梦想,成为一个生物学家。

毕业后,进入了北京水产科学研究所,主要工作是「养鱼」,每天固定时间给「小西伯利亚鲟」喂点吃的,给鱼缸换水。很不幸…,在一次换水的过程中水龙头我忘关了,结果水沿着鱼缸溢了出来,小鲟鱼也在想「外面的世界那么大,我想去看看」,都顺着鱼缸溢出的水推力,游到了鱼缸外面,等我发现时,这些小生命都散落在鱼缸周围,也不跳了。主任没有批评我把一缸鱼都养死了,但是也再让我去喂鱼了,给了我一份新的差事,通过毕赤酵母来表达出促性腺激素,促进鲟鱼快速性成熟。实验做了大概一年,有一些成果,毕竟在一年中成功过一次。后来觉得愧对这份工作,就跟主任请辞了,主任就介绍了上海的一份工作给我,去欧莱雅研发中心做实验。现在想想主任真是难得的人生导师。

在欧莱雅工作了三年,每天也是重复相同的工作,工作内容只是从以前的养各种微生物变成了养「人造皮肤」,然后再把化妆品涂在人造皮肤上,看看化妆品对皮肤的效果,其实和大学拿老鼠、兔子做实验没什么区别,只是欧洲那边不让在活体上做化妆品实验。在欧莱雅的三年,慢慢消磨了我对「生物科学」的热情,重复的工作让我麻痹了我的**。我决定做一些改变,那是2014年,我已经27了。

学习编程的动机

还得从 13 年年底说起,在欧莱雅工作期间,命运多舛,出了一次车祸,右胫腓骨粉碎骨折,当时还是女朋友的老婆放弃了东京的工作回国来照顾我,在床上躺了三四个月,老婆回上海后也找了一份广告公司的工作,做 SEM。到了14年,腿伤基本恢复,我重新回到欧莱雅工作,经历了人生变故(车祸),住院期间把这一生中的生离死别都看完了,开始思考起人生,更加热爱生命。

老婆是做 SEM 工作的,其中很大一部分工作是做 SEO,也就是搜索引擎优化,文科出生的她(日语系)自然对 HTML标记语言、JS代码感到陌生,学习起来也有些吃苦,在区分 CSS 属性 color 和 background 也会疑惑。出于对她回国照顾我的感恩,以及责任。我从图书馆借了一本书「15天掌握HTML\CSS\JS」,开始学习起前端来,那时候我还不知道有前端这个职业,也不是为了转行做前端,仅仅是想自己学会了,然后帮她解决问题。

也许看了这本书,让我对当时欧莱雅的工作有了更深入的思考,在欧莱雅,我每天工作内容相同,做实验、处理实验数据,写 report。实验之余看看 paper。而学习 HTML\CSS\JS 可以创造不同网站,然后分享给世界各地浏览你网站的人。写网站比做实验似乎更能够给我带来成就感和满足感。于是我决定正式学习 web development,成为一个前端工程师,那是2014年八月,我已经27岁了。

非科班出身怎么学习编程

才发现,写到这儿才进入正题,正式开始学习编程是在2014年九月,我并没有辞掉当时的工作,我是白天工作,晚上回家学习,周一到周五每天大概学习5~6个小时左右,周末全天学习。我习惯每个月给自己制定一些任务和目标,然后按照自己的目标前进,比如下面是我2014年九月份制定的一些计划:

九月份:《javascript》高级程序设计 + 慕课网练习(每天保证两小时coding)

十月份:学习 CSS2 和 CSS3,并对 javascript 的学习做个回顾,教材包括《精通CSS》,《javascript DOM编程艺术》,《javascript语言精粹》外加慕课网每天至少两小时coding!

十一月份:学习javascript库,jquery、prototype、html5。主要学习书籍《Javascript模式》、《锋利的jQuery》、《html5程序设计》、photoshop学习。

十二月份:主要任务是设计个人网站,主要书籍《精通javascript》、《编写可维护的Javascript》。

这儿我并不是向大家推荐学习书籍,以及个人的计划,因为那是 2014 年制定的,可能已经不再适合现在入门学习者了,再者,推荐前端书籍的知乎问题不胜枚举。

周末的时候习惯带个电脑去普陀区图书馆,上海市的图书馆周末也挺多人的,所以不得不很早就去占座,突然又有了回到大学的感觉,关于电脑当时也是下了血本,游说当时还是女朋友的老婆,把他从日本带回来的十几万日元去**银行换了人民币,然后买了一台 Macbook Pro。

2015年,我决定转行了,做一个前端工程师,那一年工作也忙了,学习的时间少了许多,为了找工作,我用 NodeJs + jQuery 搭建了一个多人博客系统,然后将自己的实现思路和技术栈连同博客地址发到了知乎和 NodeJs 社区,当然也附上了个人简历和邮箱地址,主要想看看以现在的水平,能不能够找到一份前端工作了。

那是15年八月,当时小鱼 sofish,推荐我去饿了么面试(估计是在 Nodejs 社区看到我发的帖子),但是由于知识体系的不健全,面试官可能觉得学习曲线会太陡,最终没有过初试,因为当时我还不知道 Promise,也不懂 Angular,更不会 Loopback。

于是我决定先找一份实习的工作,很幸运一家做 CRM 的公司愿意收留我,开的工资是4.5k,一下回到了刚毕业的水平,但是我很感谢这家公司,它给了我很大的学习空间,因为在这之前,我都是一个人在摸索,自学。甚至不知道真正的前端工程师到底做什么工作,而这家公司给了我机会,给我分配了导师,甚至在入职三个月内不用做任何业务,而是全身心学习。正是这三个月,我构建前自己的前端知识体系,从 JS 基础到 Angular 框架,从 HTML 到 CSS,从 Grunt 到 webpack,从NodeJs 到 Mongodb。在这三个月的学习期间,每天坚持早上九点到公司,晚上十点下班,每天坚持写日报,总结一天的学习,每个月完成一个小项目,三个月期间,自己写了一个所输及所得的 markdown 编辑器,写了一个 CSS selector 的 parser,通过 websocket 做了一个聊天工具。

当然,这三个月我也放弃了很多,因为刚入职,我放弃了和刚结婚老婆去度蜜月,两个人的蜜月,变成了她独自旅游。到现在也感到愧疚,争取明年补上。

三个月后,评级转正,当时的职级是 P5,我感到很欣慰,三个月的努力学习有了回报。和之前边工作边学习的离散学习完全不同,当然全职的效率更高,当然薪资也从4.5k 涨到了12k,说实话,对于刚入门的我,我很满意这薪资了。在这公司我工作了两年,合作和独立完成了几个项目,职级后来也再升了两级。在今年七月,我选择了离开,主要原因还是考虑个人发展,以及对饿了么的眷顾。

对的,我又参加了饿了么的面试,顺利入职饿了么前端工程师,感到很欣慰。好像实现了一个长久的夙愿。

我为什么感到焦虑

转行后,我无时不感到焦虑,毕竟是一个大龄转行程序员,身边的同事都是九零后甚至九五后,我无时不刻不在想我怎么规划我的职业,怎么做得更好,怎么缩短与科班出身的同事之间的差距。我没有后悔过转行,也没有后悔过那八年生物生涯(大学四年工作四年),毕竟那八年的生物学习,我快乐过,我不完整实现了儿时的梦想。

ES6 实现自己的 Promise

一、JavaScript异步编程背景

​ 从去年ES2015发布至今,已经过去了一年多,ES2015发布的新的语言特性中最为流行的也就莫过于Promise了,Promise使得如今JavaScript异步编程如此轻松惬意,甚至慢慢遗忘了曾经那不堪回首的痛楚。其实从JavaScript诞生,JavaScript中的异步编程就已经出现,例如点击鼠标、敲击键盘这些事件的处理函数都是异步的,时间到了2009年,Node.js横空出世,在整个Node.js的实现中,将回调模式的异步编程机制发挥的淋漓尽致,Node的流行也是的越来越多的JavaScripter开始了异步编程,但是回调模式的副作用也慢慢展现在人们眼前,错误处理不够优雅以及嵌套回调带来的“回调地狱”。这些副作用使得人们从回调模式的温柔乡中慢慢清醒过来,开始寻找更为优雅的异步编程模式,路漫漫其修远兮、吾将上下而求索。时间到了2015年,Promise拯救那些苦苦探索的先驱。行使它历史使命的时代似乎已经到来。

​ 每个事物的诞生有他的历史使命,更有其历史成因,促进其被那些探索的先驱们所发现。了解nodejs或者熟悉浏览器的人都知道,JavaScript引擎是基于事件循环或单线程这两个特性的。更为甚者在浏览器中,更新UI(也就是浏览器重绘、重拍页面布局)和执行JavaScript代码也在一个单线程中,可想而知,一个线程就相当于只有一条马路,如果一辆马车抛锚在路上了阻塞了马路,那么别的马车也就拥堵在了那儿,这个单线程容易被阻塞是一个道理,单线程也只能允许某一时间点只能够执行一段代码。同时,JavaScript没有想它的哥哥姐姐们那么财大气粗,像Java或者C++,一个线程不够,那么再加一个线程,这样就能够同时执行多段代码了,但是这样就会带来的隐患就是状态不容易维护,JavaScript选择了单线程非阻塞式的方式,也就是异步编程的方式,就像上面的马车抛锚在了路上,那么把马车推到路边的维修站,让其他马车先过去,等马车修好了再回到马路上继续行驶,这就是单线程非阻塞方式。正如Promise的工作方式一样,通过Promise去向服务器发起一个请求,毕竟请求有网络开销,不可能马上就返回请求结果的,这个时候Promise就处于pending状态,但是其并不会阻塞其他代码的执行,当请求返回时,修改Promise状态为fulfilled或者rejected(失败请求)。同时执行绑定到这两个状态上面的“处理函数”。这就是异步编程的模式,也就是Promise兢兢业业的工作方式,在下面一个部分将详细讨论Promise。

二、Promise基础

​ 怎么一句话解释Promise呢?Promise可以代指那些尚未完成的一些操作,但是其在未来的某个时间会返回某一特定的结果。

​ 当创建一个Promise实例后,其代表一个未知的值,在将来的某个时间会返回一个成功的返回值,或者失败的返回值,我们可以为这些返回值添加处理函数,当值返回时,处理函数被调用。Promise总是处于下面三种状态之一:

  • pending: Promise的初始状态,也就是未被fulfilled或者rejected的状态。
  • fulfilled: 意味着promise代指的操作已经成功完成。
  • rejected:意味着promise代指的操作由于某些原因失败。

一个处于pending状态的promise可能由于某个成功返回值而发展为fulfilled状态,也有可能因为某些错误而进入rejected状态,无论是进入fulfilled状态或者rejected状态,绑定到这两种状态上面的处理函数就会被执行。并且进入fulfilled或者rejected状态也就不能再返回pending状态了。

三、边学边写

上面说了那么多,其实都是铺垫。接下来我们就开始实现自己的Promise对象。go go go!!!

第一步:Promise构造函数

Promise有三种状态,pending、fulfilled、rejected。

const PENDING = 'PENDING' // Promise 的 初始状态
const FULFILLED = 'FULFILLED' // Promise 成功返回后的状态
const REJECTED = 'REJECTED' // Promise 失败后的状态

有了三种状态后,那么我们怎么创建一个Promise实例呢?

const promise = new Promise(executor) // 创建Promise的语法

通过上面生成promise语法我们知道,Promise实例是调用Promise构造函数通过new操作符生成的。这个构造函数我们可以先这样写:

class Promise {
    constructor(executor) {
        this.status = PENDING // 创建一个promise时,首先进行状态初始化。pending
        this.result = undefined // result属性用来缓存promise的返回结果,可以是成功的返回结果,或失败的返回结果
    }
}

我们可以看到上面构造函数接受的参数executor。它是一个函数,并且接受其他两个函数(resolve和reject)作为参数,当resolve函数调用后,promise的状态转化为fulfilled,并且执行成功返回的处理函数(不用着急后面会说到怎么添加处理函数)。当reject函数调用后,promise状态转化为rejected,并且执行失败返回的处理函数。

现在我们的代码大概是这样的:

class Promise {
    constructor(executor) {
        this.status = PENDING 
        this.result = undefined
        executor(data => resolveProvider(this, data), err => rejectProvider(this, err))
    }
}

function resolveProvider(promise, data) {
    if (promise.status !== PENDING) return false
    promise.status = FULFILLED
}
function rejectProvider(promise, data) {
    if (promise.status !== PENDING) return false
    promise.status = FULFILLED
}

Dont Repeat Yourselt!!!我们可以看到上面代码后面两个函数基本相同,其实我们可以把它整合成一个函数,在结合高阶函数的使用。

const statusProvider = (promise, status) => data => {
    if (promise.status !== PENDING) return false
    promise.status = status
    promise.result = data
}
class Promise {
    constructor(executor) {
        this.status = PENDING 
        this.result = undefined
        executor(statusProvider(this, FULFILLED), statusProvider(this, REJECTED))
    }
}

现在我们的代码就看上去简洁多了。

第二步:为Promise添加处理函数

其实通过 new Promise(executor)已经可以生成一个Promise实例了,甚至我们可以通过传递到executor中的resolve和reject方法来改变promise状态,但是!现在的promise依然没啥卵用!!!因为我们并没有给它添加成功和失败返回的处理函数。

首先我们需要给我们的promise增加两个属性,successListener和failureListener用来分别缓存成功处理函数和失败处理函数。

class Promise {
    constructor(executor) {
        this.status = PENDING
         this.successListener = []
         this.failureListener = []
        this.result = undefined
        executor(statusProvider(this, FULFILLED), statusProvider(this, REJECTED))
    }
}

怎么添加处理函数呢?ECMASCRIPT标准中说到,我们可以通过promise原型上面的then方法为promise添加成功处理函数和失败处理函数,可以通过catch方法为promise添加失败处理函数。

const statusProvider = (promise, status) => data => {
    if (promise.status !== PENDING) return false
    promise.status = status
    promise.result = data
    switch(status) {
        case FULFILLED: return promise.successListener.forEach(fn => fn(data))
        case REJECTED: return promise.failurelistener.forEach(fn => fn(data))
    }
}
class Promise {
    constructor(executor) {
        this.status = PENDING
        this.successListener = []
        this.failurelistener = []
        this.result = undefined
        executor(statusProvider(this, FULFILLED), statusProvider(this, REJECTED))
    }
    /**
     * Promise原型上面的方法
     */
    then(...args) {
        switch (this.status) {
            case PENDING: {
                this.successListener.push(args[0])
                this.failurelistener.push(args[1])
                break
            }
            case FULFILLED: {
                args[0](this.result)
                break
            }
            case REJECTED: {
                args[1](this.result)
            }
        }
    }
    catch(arg) {
        return this.then(undefined, arg)
    }
}

我们现在的Promise基本初具雏形了。甚至可以运用到一些简单的场景中了。举个例子。

/*创建一个延时resolve的pormise*/
new Promise((resolve, reject) => {setTimeout(() => resolve(5), 2000)}).then(data => console.log(data)) // 5
/*创建一个及时resolve的promise*/
new Promise((resolve, reject) => resolve(5)).then(data => console.log(data)) // 5
/*链式调用then方法还不能够使用!*/
new Promise(resolve=> resolve(5)).then(data => data).then(data => console.log(data))
// Uncaught TypeError: Cannot read property 'then' of undefined
第三步:Promise的链式调用

Promise需要实现链式调用,我们需要再次回顾下then方法的定义:

then方法为pormise添加成功和失败的处理函数,同时then方法返回一个新的promise对象,这个新的promise对象resolve处理函数的返回值,或者当没有提供处理函数时直接resolve原始的值。

可以看出,promise能够链式调用归功于then方法返回一个全新的promise,并且resolve处理函数的返回值,当然,如果then方法的处理函数本身就返回一个promise,那么久不用我们自己手动生成一个promise了。了解了这些,就开始动手写代码了。

const isPromise = object => object && object.then && typeof object.then === 'function'
const noop = () => {}

const statusProvider = (promise, status) => data => {
    // 同上面代码
}

class Promise {
    constructor(executor) {
        // 同上面代码
    }
    then(...args) {
        const child = new this.constructor(noop)

        const handler = fn => data => {
            if (typeof fn === 'function') {
                const result = fn(data)
                if (isPromise(result)) {
                    const successHandler = child.successListener[0]
		    const errorHandler = child.failureListener[0]
		    result
		    .then(successHandler, errorHandler)
                } else {
                    statusProvider(child, FULFILLED)(result)
                }   
            } else if(!fn) {
                statusProvider(child, this.status)(data)
            }
        }
        switch (this.status) {
            case PENDING: {
                this.successListener.push(handler(args[0]))
                this.failureListener.push(handler(args[1]))
                break
            }
            case FULFILLED: {
                handler(args[0])(this.result)
                break
            }
            case REJECTED: {
                handler(args[1])(this.result)
                break
            }
        }
        return child
    }
    catch(arg) {
        return this.then(undefined, arg)
    }
}

​ 首先我们写了一个isPromise方法,用于判断一个对象是否是promise。就是判断对象是否有一个then方法,免责声明为了实现上的简单,我们不区分thenable和promise的区别,但是我们应该是知道。所有的promise都是thenable的,而并不是所有的thenable对象都是promise。(thenable对象是指带有一个then方法的对象,该then方法其实就是一个executor。)isPromise的作用就是用于判断then方法返回值是否是一个promise,如果是promise,就直接返回该promise,如果不是,就新生成一个promise并返回该promise。

​ 由于需要链式调用,我们对successListener和failureListener中处理函数进行了重写,并不是直接push进去then方法接受的参数函数了,因为then方法需要返回一个promise,所以当then方法里面的处理函数被执行的同时,我们也需要对then方法返回的这个promise进行处理,要么resolve,要么reject掉。当然,大部分情况都是需要resolve掉的,只有当then方法没有添加第二个参数函数,同时调用then方法的promise就是rejected的时候,才需要把then方法返回的pormise进行reject处理,也就是调用statusProvider(child, REJECTED)(data).

toy Promise实现的完整代码:

const PENDING = 'PENDING' // Promise 的 初始状态
const FULFILLED = 'FULFILLED' // Promise 成功返回后的状态
const REJECTED = 'REJECTED' // Promise 失败后的状态

const isPromise = object => object && object.then && typeof object.then === 'function'
const noop = () => {}

const statusProvider = (promise, status) => data => {
    if (promise.status !== PENDING) return false
    promise.status = status
    promise.result = data
    switch(status) {
        case FULFILLED: return promise.successListener.forEach(fn => fn(data))
        case REJECTED: return promise.failureListener.forEach(fn => fn(data))
    }
}

class Promise {
    constructor(executor) {
        this.status = PENDING
        this.successListener = []
        this.failureListener = []
        this.result = undefined 
        executor(statusProvider(this, FULFILLED), statusProvider(this, REJECTED))
    }
    /**
     * Promise原型上面的方法
     */
    then(...args) {
        const child = new this.constructor(noop)

        const handler = fn => data => {
            if (typeof fn === 'function') {
                const result = fn(data)
                if (isPromise(result)) {
                    	const successHandler = child.successListener[0]
			const errorHandler = child.failureListener[0]
			result
			.then(successHandler, errorHandler)
                } else {
                    statusProvider(child, FULFILLED)(result)
                }   
            } else if(!fn) {
                statusProvider(child, this.status)(data)
            }
        }
        switch (this.status) {
            case PENDING: {
                this.successListener.push(handler(args[0]))
                this.failurelistener.push(handler(args[1]))
                break
            }
            case FULFILLED: {
                handler(args[0])(this.result)
                break
            }
            case REJECTED: {
                handler(args[1])(this.result)
                break
            }
        }
        return child
    }
    catch(arg) {
        return this.then(undefined, arg)
    }
}

四、怎么让我们的toy Promise变强健

  1. 在ECMAScript标准中,Promise构造函数上面还提供了一些静态方法,比如Promise.resolvePromise.rejectPromsie.allPromise.race。当我们有了上面的基础实现后,为我们的toy Promise添加上面这些新的功能一定能让其更加实用。

  2. 在我们的基本实现中,我们并没有区分thenable对象,其实Promise.resolvethen方法都可以接受一个thenable对象,并把该thenable对象转化为一个promise对象,如果想让我们的toy Promise用于生产的话,这也是要考虑的。

  3. 为了让我们的toy Promise变得更强壮,我们需要拥有强健的错误处理机制,比如验证executor必须是一个函数、then方法的参数只能是函数或者undefined或null,又比如executor和then方法中抛出的错误并不能够被window.onerror监测到,而只能够通过错误处理函数来处理,这也是需要考虑的因素。

  4. 如果我们的Promise polyfill是考虑支持多平台,那么首要考虑的就是浏览器环境或Node.js环境,其实在这两个平台,原生Promise都是支持两个事件的。就拿浏览器端举例:

    • unhandledrejection: 在一个事件循环中,如果我们没有对promise返回的错误进行处理,那么就会在window对象上面触发该事件。
    • rejectionhandled:如果在一个事件循环后,我们才去对promise返回的错误进行处理,那么就会在window对象上面监听到此事件。

    关于这两个事件以及node.js平台上面类似的事件请参考Nicholas C. Zakas新书

Promise能够很棒的处理异步编程,要想学好它我认为最好的方法就是亲自动手去实现一个自己的Promise,下面的项目Jocs/promise是我的实现,欢迎大家pr和star。

深入理解Angular 1.5的生命周期钩子

Posted on Jun 3, 2016 - Edit this page on GitHub

生命周期钩子是一些简单的函数,这些函数会在Angular应用组件特定生命周期被调用。生命周期钩子在Angular 1.5版本被引入,通常与.component()方法一起使用,并在接下来的几个版本中演变,并包含了更多有用的钩子函数(受Angular 2的启发)。让我们深入研究这些钩子函数并实际使用它们吧。这些钩子函数所带来的作用以及为什么我们需要使用它们,对于我们深入理解通过组件架构的应用具有重要的意义。

在Angular v1.3.0+版本,我自己实现了.component() 方法,该方法深刻得洞悉了怎么去使用这些生命周期函数以及这些函数在组件中的作用,让我们开始研究它吧。

**Table of contents

**$onInit

什么是$onInit ?首先,他是Angular组件(译注:通过.component() 方法定义的组件)控制器中暴露出来的一个属性,我们可以把一个函数赋值给该属性:

var myComponent = {
  bindings: {},
  controller: function () {
    this.$onInit = function() {

    };
  }
};

angular
  .module('app')
  .component('myComponent', myComponent);
**Using $onInit

$onInit 生命周期钩子用作控制器的初始化工作,下面举个常用例子:

var myComponent = {
  ...
  controller: function () {
    this.foo = 'bar';
    this.bar = 'foo';
    this.fooBar = function () {

    };
  }
};

注意上面的代码,我们把所有的属性直接赋值到了this上面,它们就像“浮在”控制器的各个角落。现在,让我们通过$onInit 来重写上面代码:

var myComponent = {
  ...
  controller: function () {
    this.$onInit = function () {
      this.foo = 'bar';
      this.bar = 'foo';
    };
    this.fooBar = function () {
      console.log(this.foo); // 'bar'
    };
  }
};

上面的数据明显地通过硬编码写入的,但是在实际的应用中,我们通常是通过bindings: {} 对象来把我们需要的数据传递到组件中,我们使用$onInit 来进行一些初始化工作,这样就把以前那些“浮在”控制器各处的初始化变量都集中起来了,$onInit 就像是控制器中的constructor ,包含了一些初始化信息。

对于this.fooBar函数呢?不要着急,该函数放在$onInit外面是完全能够访问到初始化数据的,比如当你调用this.fooBar的时候,函数会打印出this.foo的值,也就是在$onInit函数中定义的'bar'。因此所有你初始化的数据都正确地绑定到了控制器的this 上下文中。

**$onInit + “require”

因为这些生命周期钩子定义得如此优雅(不同的生命周期钩子都在组件的不同生命周期被调用),一个组件也可以从另外的组件中继承方法,甚至继承的方法在$onInit 钩子中就可以直接使用。

首先我们需要思考的是如何使用require,我写过另外一篇深入介绍$onInit 和 require的文章,但是在此我依然会简要介绍一些require的基本用法,随后将提供一个完整的实例。

让我们来看看myComponent的例子,在这儿require后面紧跟的是一个对象(只在.component()方法中require字段后面接对象),当require.directive()结合使用的时候,require字段后面也可以跟数组或者字符串语法形式。

var myComponent = {
  ...
  require: {
    parent: '^^anotherComponent'
  },
  controller: function () {
    this.$onInit = function () {
      this.foo = 'bar';
      this.bar = 'foo';
    };
    this.fooBar = function () {
      console.log(this.foo); // 'bar'
    };
  }
};

如上面的例子,require被设置为^^anotherComponentrequire值前面^^表示自会在当前组件的父组件中搜寻anotherComponent控制器,(如果require值前面是^那么首先会在当前组件搜寻是否有该控制器,如果没有再在其父组件中搜寻)这样我们就可以在$onInit中使用任何当定在父组件中的方法了。

var myComponent = {
  ...
  require: {
    parent: '^^anotherComponent'
  },
  controller: function () {
    this.$onInit = function () {
      this.foo = 'bar';
      this.bar = 'foo';
      this.parent.sayHi();
    };
    this.fooBar = function () {
      console.log(this.foo); // 'bar'
    };
  }
};

注意,在Angular 1.5.6版本(见 CHANGELOG)中,如果require对象中属性名和require的控制器同名,那么就可以省略控制器名。这一特性并没有带来给功能带来很大的改变,我们可以如下使用它:

var myComponent = {
  ...
  require: {
    parent: '^^'
  },
  controller: function () {
    ...
  }
};

正如你所见,我们完全省略了需要requre的控制器名而直接使用^^替代。完整写法^^parent就被省略为^^。需要谨记,在前面的一个例子中,我们只能使用parent: '^^anotherComponent'来表示我们需要使用另外一个组件中控制器中的方法(译者注:作者以上就是控制器和requre的属性名不相同时,不能够省略),最后,我们只需记住一点,如果我们想使用该条特性,那么被requre的控制器名必须和require的属性名同名。

**Real world $onInit + require

让我们使用$onInitrequire来实现一个tabs组件,首先我们实现的组件大概如如下使用:

<tabs>
  <tab label="Tab 1">
    Tab 1 contents!
   </tab>
   <tab label="Tab 2">
    Tab 2 contents!
   </tab>
   <tab label="Tab 3">
    Tab 3 contents!
   </tab>
</tabs>

这意味着我们需要两个组件,tabtabs。我们将transclude所有的tabs子元素(就是所有tab模板中的tabs元素)然后通过bindings绑定的对象来获取label值。

首先,组件定义了每个组件都必须使用的一些属性:

var tab = {
  bindings: {},
  require: {},
  transclude: true,
  template: ``,
  controller: function () {}
};

var tabs = {
  transclude: true,
  template: ``,
  controller: function () {}
};

angular
  .module('app', [])
  .component('tab', tab)
  .component('tabs', tabs);

tab组件需要通过bindings绑定一些数据,同时在该组件中,我们使用了require,transclude和一个template ,最后是一个控制器controller

tabs组件首先会transclude所有的元素到模板中,然后通过controller来对tabs进行管理。

让我们来实现tab组件的模板吧:

var tab = {
  ...
  template: `
    <div class="tabs__content" ng-if="$ctrl.tab.selected">
      <div ng-transclude></div>
    </div>
  `,
  ...
};

对于tab组件而言,我们只在$ctrl.tab.selectedtrue的时候显示该组件,因此我们需要一些在控制器中添加一些逻辑来处理该需求。随后我们通过transclude来对tab组件中的内容填充。(这些内容就是展示在不同tab内的)

var tabs = {
  ...
  template: `
    <div class="tabs">
      <ul class="tabs__list">
        <li ng-repeat="tab in $ctrl.tabs">
          <a href=""
            ng-bind="tab.label"
            ng-click="$ctrl.selectTab($index);"></a>
        </li>
      </ul>
      <div class="tabs__content" ng-transclude></div>
    </div>
  `,
  ...
};

对于tabs组件,我们创建一个数组来展示$ctrl.tabs内容,并对每一个tab选项卡绑定click事件处理函数$ctrl.selectTab(),在调用该方法是传入当前$index。同时我们transclude所有的子节点(所有的<tab>元素)到.tabs_content容器中。

接下来让我们来处理tab组件的控制器,我们将创建一个this.tab属性,当然初始化该属性应该放在$onInit钩子函数中:

var tab = {
  bindings: {
    label: '@'
  },
  ...
  template: `
    <div class="tabs__content" ng-if="$ctrl.tab.selected">
      <div ng-transclude></div>
    </div>
  `,
  controller: function () {
    this.$onInit = function () {
      this.tab = {
        label: this.label,
        selected: false
      };
    };
  }
  ...
};

你可以看到我在控制器中使用了this.label,因为我们在组件中添加了bindings: {label: '@'},这样我们就可以使用this.label来获取绑定到<tab>组件label属性上面的值了(字符串形式)。通过这样的绑定形式我们就可以把不同的值映射到不同的tab组件上。

接下来让我们来看看tabs组件控制器中的逻辑,这可能稍微有点复杂:

var tabs = {
  ...
  template: `
    <div class="tabs">
      <ul class="tabs__list">
        <li ng-repeat="tab in $ctrl.tabs">
          <a href=""
            ng-bind="tab.label"
            ng-click="$ctrl.selectTab($index);"></a>
        </li>
      </ul>
      <div class="tabs__content" ng-transclude></div>
    </div>
  `,
  controller: function () {
    this.$onInit = function () {
      this.tabs = [];
    };
    this.addTab = function addTab(tab) {
      this.tabs.push(tab);
    };
    this.selectTab = function selectTab(index) {
      for (var i = 0; i < this.tabs.length; i++) {
        this.tabs[i].selected = false;
      }
      this.tabs[index].selected = true;
    };
  },
  ...
};

我们在$onInit钩子处理函数中初始化this.tabs = [],我们已经知道$onInit用来初始化属性值,接下来我们定义了两个函数,addTabselectTabaddTab函数我们会通过require传递到每一个子组件中,通过这种形式来告诉父组件子组件的存在,同时保存一份对每个tab的引用,这样我们就可以通过ng-repeat来遍历所有的tab选项卡,并且可以点击(通过selectTab)选择不同的选项卡。

接下来我们通过tab组件的require来将addTab方法委派到tab组件中使用。

var tab = {
  ...
  require: {
    tabs: '^^'
  },
  ...
};

正如我们在文章关于$onInitrequire部分提到,我们通过^^来只requre父组件控制器中的逻辑而不在自身组件中寻找这些方法。除此之外,当我们require的控制器名和requre对象中的属性名相同时我们还可以省略requre的控制器名字,这是版本1.5.6新增加的一个特性。关于这一新特性准备好了吗?在下面代码中,我们使用tabs: '^^',我们有一个和require控制器同名的属性名{tabs: ...},这样我们就可以在$onInit中使用this.tabs来调用父组件控制器中的方法了。

var tab = {
  ...
  require: {
    tabs: '^^'
  },
  controller: function () {
    this.$onInit = function () {
      this.tab = {
        label: this.label,
        selected: false
      };
      // this.tabs === require: { tabs: '^^' }
      this.tabs.addTab(this.tab);
    };
  }
  ...
};

把所有代码放一起:

var tab = {
  bindings: {
    label: '@'
  },
  require: {
    tabs: '^^'
  },
  transclude: true,
  template: `
    <div class="tabs__content" ng-if="$ctrl.tab.selected">
      <div ng-transclude></div>
    </div>
  `,
  controller: function () {
    this.$onInit = function () {
      this.tab = {
        label: this.label,
        selected: false
      };
      this.tabs.addTab(this.tab);
    };
  }
};

var tabs = {
  transclude: true,
  controller: function () {
    this.$onInit = function () {
      this.tabs = [];
    };
    this.addTab = function addTab(tab) {
      this.tabs.push(tab);
    };
    this.selectTab = function selectTab(index) {
      for (var i = 0; i < this.tabs.length; i++) {
        this.tabs[i].selected = false;
      }
      this.tabs[index].selected = true;
    };
  },
  template: `
    <div class="tabs">
      <ul class="tabs__list">
        <li ng-repeat="tab in $ctrl.tabs">
          <a href=""
            ng-bind="tab.label"
            ng-click="$ctrl.selectTab($index);"></a>
        </li>
      </ul>
      <div class="tabs__content" ng-transclude></div>
    </div>
  `
};

点击选项卡相应内容就会呈现出来,当时,我们并没有设置一个初始化的展示的选项卡?这就是接下来$postLink要介绍的内容。

**$postLink

我们已经知道,compile函数会返回一个prepost‘链接函数’,如如下形式:

function myDirective() {
  restrict: 'E',
  scope: { foo: '=' },
  compile: function compile($element, $attrs) {
    return {
      pre: function preLink($scope, $element, $attrs) {
        // access to child elements that are NOT linked
      },
      post: function postLink($scope, $element, $attrs) {
        // access to child elements that are linked
      }
    };
  }
}

你也可能知道如下:

function myDirective() {
  restrict: 'E',
  scope: { foo: '=' },
  link: function postLink($scope, $element, $attrs) {
    // access to child elements that are linked
  }
}

当我们只需要使用postLink函数的时候,上面两种形式效果是一样的。注意我们使用的post: function)() {...} - 这就是我们的主角。我已经在上面的代码中添加了一行注释“可以获取到已经链接的子元素”,上面的注释意味着在父指令的post 函数中,子元素的模板已经被编译并且已经被链接到特定的scope上。而通过compilepre函数我们是无法获取到已经编译、链接后的子元素的。因此我们有一个生命周期钩子来帮我我们在编译的最后阶段(子元素已经被编译和链接)来处理一些相应逻辑。

**Using $postLink

$postLink给予了我们处理如上需求的可能,我们不需使用一些hack的范式就可以像如下形式一样使用$postLink钩子函数。

var myComponent = {
  ...
  controller: function () {
    this.$postLink = function () {
      // fire away...
    };
  }
};

我们已经知道,$postLink是在所有的子元素被链接后触发,接下来让我们来实现我们的tabs组件。

**Real world $postLink

我们可以通过$postLink函数来给我们的选项卡组件一个初始的选项卡。首先我们需要调整一下模板:

<tabs selected="0">
  <tab label="Tab 1">...</tab>
  <tab label="Tab 2">...</tab>
  <tab label="Tab 3">...</tab>
</tabs>

现在我们就可以通过bindings获取到selected特性的值,然后用以初始化:

var tabs = {
  bindings: {
    selected: '@'
  },
  ...
  controller: function () {
    this.$onInit = function () {
      this.tabs = [];
    };
    this.addTab = function addTab(tab) {
      this.tabs.push(tab);
    };
    this.selectTab = function selectTab(index) {
      for (var i = 0; i < this.tabs.length; i++) {
        this.tabs[i].selected = false;
      }
      this.tabs[index].selected = true;
    };
    this.$postLink = function () {
      // use `this.selected` passed down from bindings: {}
      // a safer option would be to parseInt(this.selected, 10)
      // to coerce to a Number to lookup the Array index, however
      // this works just fine for the demo :)
      this.selectTab(this.selected || 0);
    };
  },
  ...
};

现在我们已经有一个生动的实例,通过selected属性来预先选择某一模板,在上面的例子中我们使用selected=2来预先选择第三个选项卡作为初始值。

**What $postLink is not

$postLink函数中并不是一个好的地方用以处理DOM操作。在Angular生态圈外通过原生的事件绑定来为HTML/template扩展行为,Directive依然是最佳选择。不要仅仅将Directive(没有模板的指令)重写为component组件,这些都是不推荐的做法。

那么$psotLint存在的意义何在?你可能想在$postLink函数中进行DOM操作或者自定义的事件。其实,DOM操作和绑定事件最好使用一个带模板的指令来进行封装。正确地使用$postLink,你可以把你的疑问写在下面的评论中,我会很乐意的回复你的疑问。

**$onChanges

这是一个很大的部分(也是最重要的部分),$onChanges将和Angular 1.5.x中的组件架构及单向数据流一起讨论。一条金玉良言:$onChanges在自身组件被改变但是却在父组件中发生的改变(译者注:其实作者这儿说得比较含糊,$onChange就是在单向数据绑定后,父组件向子组件传递的数据发生改变后会被调用)。当父组件中的一些属性发生改变后,通过bindings: {}就可以把这种变化传递到子组件中,这就是$onChanges的秘密所在。

**What calls $onChanges?

在以下情况下$onChanges会被调用,首先,在组件初始化的时候,组件初始化时会传递最初的changes对象,这样我们就可以直接获取到我们所需的数据了。第二种会被调用的场景就是只当单向数据绑定<he @(用于获取DOM特性值,这些值是通过父组件传递的)改变时会被调用。一旦$onChanges被调用,你将在$onChanges的参数中获取到一个变化对象,我们将在接下来的部分中详细讨论。

**Using $onChanges

使用$onChanges相当简单,但是该生命周期钩子又通常被错误的使用或谈论,因此我们将在接下来的部分讨论$onChanges的使用,首先,我们声明了一个childConpoment组件。

var childComponent = {
  bindings: { user: '<' },
  controller: function () {
    this.$onChanges = function (changes) {
      // `changes` is a special instance of a constructor Object,
      // it contains a hash of a change Object and
      // also contains a function called `isFirstChange()`
      // it's implemented in the source code using a constructor Object
      // and prototype method to create the function `isFirstChange()`
    };
  }
};

angular
  .module('app')
  .component('childComponent', childComponent);

注意,这儿bindings对象包含了一个值为'<'user字段,该‘<’表示了单向数据流,这一点在我以前的 文章已经提到过,单向数据流会导致$onChanges钩子被调用。

但是,正如上面提到,我们需要一个parentComponent组件来完成我的实例:

var parentComponent = {
  template: `
    <div>
      <child-component></child-component>
    </div>
  `
};

angular
  .module('app')
  .component('parentComponent', parentComponent);

需要注意的是:<child-compoent></component>组件在<parent-component></parent-component>组件中渲染,这就是为什么我们能够初始化一个带有数据的控制器,并且把这些数据传递给childComponent:

var parentComponent = {
  template: `
    <div>
      <a href="" ng-click="$ctrl.changeUser();">
        Change user (this will call $onChanges in child)
      </a>
      <child-component
        user="$ctrl.user">
      </child-component>
    </div>
  `,
  controller: function () {
    this.$onInit = function () {
        this.user = {
        name: 'Todd Motto',
        location: 'England, UK'
      };
    };
    this.changeUser = function () {
        this.user = {
        name: 'Tom Delonge',
        location: 'California, USA'
      };
    };
  }
};

再次,我们使用$onInit来定义一些初始化数据,把一个对象赋值给this.user。同时我们有this.changeUser函数,用来更新this.user的值,这个改变发生在父组件,但是会触发子组件中的$onChange钩子函数被调用,父组件的改变通过$onChanges来通知子组件,这就是$onChanges的作用。

现在,让我们来看看childComponent组件:

var childComponent = {
  bindings: {
    user: '<'
  },
  template: `
    <div>
      <pre>{{ $ctrl.user | json }}</pre>
    </div>
  `,
  controller: function () {
    this.$onChanges = function (changes) {
      this.user = changes;
    };
  }
};

这儿,我们使用binding: {user: '<'},意味着我们可以通过user来接收来自父组件通过单向数据绑定传递的数据,我们在模板中通过this.user来展示数据的变化,(我通过使用| json过滤器来展示整个对象)

点击按钮来观察childCompoent通过$onChanges来传播的变化:“我并没有获取到变化??”像上面的代码,我永远也获取不到,因为我们把整个变化对象都赋值给了this.user,让我们修改下上面的代码:

var childComponent = {
  ...
  controller: function () {
    this.$onChanges = function (changes) {
      this.user = changes.user.currentValue;
    };
  }
};

现在我们可以使用user属性来获取到从父组件传递下来的数据,通过curentValue来引用到该数据,也就是change对象上面的curentChange属性,尝试下上面的代码:

**Cloning “change” hashes for “immutable” bindings

现在我们已经从组件中获取到从单向数据绑定的数据,我们可以在深入的思考。虽然单项数据绑定并没有被Angular所$watch,但是我们是通过引用传递。这意味着子组件对象(特别注意,简单数据类型不是传递引用)属性的改变依然会影响到父组件的相同对象,这就和双向数据绑定的作用一样了,当然这是无意义的。这就是,我们可以通过设计。聪明的通过深拷贝来处理单向数据流传递下来的对象,来使得该对象成为“不可变对象”,也就是说传递下来的对象不会在子组件中被更改。

这个是一个fiddle例子(注意user | json)过滤器移到了父组件中(注意,父组件中的对象也随之更新了)

作为替换,我们可以使用 angular.cocy()来克隆传递下来的对象,这样就打破了JavaScript对象的“引用传递“:

var childComponent = {
  ...
  controller: function () {
    this.$onChanges = function (changes) {
      this.user = angular.copy(changes.user.currentValue);
    };
  }
};

做得更好,我们添加了if语句来检测对象的属性是否存在,这是一个很好的实践:

var childComponent = {
  ...
  controller: function () {
    this.$onChanges = function (changes) {
      if (changes.user) {
        this.user = angular.copy(changes.user.currentValue);
      }
    };
  }
};

甚至我们还可以再优化我们的代码,因为当父组件中数据发生变化,该变化会立即反应在this.user上面,随后我们通过深拷贝changes.user.currentValue对象,其实这两个对象是相同的,下面两种写法其实是在做同一件事。

this.$onChanges = function (changes) {
  if (changes.user) {
    this.user = angular.copy(this.user);
    this.user = angular.copy(changes.user.currentValue);
  }
};

我更偏向于的途径(使用angular.copy(this.user))。

现在就开始尝试,通过深拷贝开复制从父组件传递下来的对象,然后赋值给子组件控制器相应属性。

感觉还不错吧?现在我们使用拷贝对象,我们可以任意改变对象而不用担心会影响到父组件(对不起,双向数据绑定真的不推荐了!)因此当我们更新数据后,通过事件来通知父组件,单向数据流并不是生命周期钩子的一部分,但是这$onChanges钩子被设计出来的意思所在。数据输入和事件输出(输入 = 数据, 输出 = 事件),让我们使用它吧。

**One-way dataflow + events

上面我们讨论了bindings$onChanges已经覆盖了单向数据流,现在我们将添加事件来扩展这一单向数据流。

为了使数据能够回流到 parentComponent,我们需要委托一个函数作为事件的回调函数,然我们添加一个叫updateUser的函数,该函数需要一个event最为传递回来的参数,相信我,这样做将会很有意义。

var parentComponent = {
  ...
  controller: function () {
    ...
    this.updateUser = function (event) {
      this.user = event.user;
    };
  }
};

从这我们可以看出,我们期待event是一个对象,并且带有一个user的属性,也就是从子组件传递回来的值,首先我们需要把该事件回调函数传递到子组件中:

var parentComponent = {
  template: `
    <div>
      ...
      <child-component
        user="$ctrl.user"
        on-update="$ctrl.updateUser($event);">
      </child-component>
    </div>
  `,
  controller: function () {
    ...
    this.updateUser = function (event) {
      this.user = event.user;
    };
  }
};

注意我创建了一个带有on-*前缀的特性,当我们需要绑定一个事件(想想 onclick/onblur)的时候,这是一个最佳实践。

现在我们已经将该函数传递给了<child-component>,我们需要通过bindings来获取这一绑定的函数。

var childComponent = {
  bindings: {
    user: '<',
    onUpdate: '&' // magic ingredients
  },
  ...
  controller: function () {
    this.$onChanges = function (changes) {
      if (changes.user) {
        this.user = angular.copy(this.user);
      }
    };
    // now we can access this.onUpdate();
  }
};

通过&,我们可以传递函数,所以我们通过this.updateUser字面量来把该函数从父组件传递到子组件,在子组件中更新的数据(通过在$onChanges中深拷贝从bindings对象中的属性)然后通过传递进来的回调函数来将更新后的数据传递回去,数据从父组件到子组件,然后通过事件回调将更新后的数据通知到父组件。

接下来,我们需要扩展我们的模板来时的用户可以更新深拷贝的数据:

var childComponent = {
  ...
  template: `
    <div>
      <input type="text" ng-model="$ctrl.user.name">
      <a href="" ng-click="$ctrl.saveUser();">Update</a>
    </div>
  `,
  ...
};

这意味着我们需要在控制器中添加this.saveUser方法,让我们添加它:

var childComponent = {
  ...
  template: `
    <div>
      <input type="text" ng-model="$ctrl.user.name">
      <a href="" ng-click="$ctrl.saveUser();">Update</a>
    </div>
  `,
  controller: function () {
    ...
    this.saveUser = function () {

    };
  }
};

尽管,当我们在子组件中"保存"的时候,这其实仅仅是父组件回调函数的一个封装,因此我们在子组件中直接调用父组件方法this.updateUser(该方法已经绑定到了子组件onUpdate属性上)

var childComponent = {
  ...
  controller: function () {
    ...
    this.saveUser = function () {
      // function reference to "this.updateUser"
        this.onUpdate();
    };
  }
};

好的,相信我,我们已经到了最后阶段,这也会使得事情变得更加有趣。相反我们并不是直接把this.user传递到回调函数中,而是构建了一个$event对象,这就像Angular 2一样(使用EventEmitter),这也提供了在模板中使用$ctrl.updateUser($event)来获取数据的一致性,这也就可以传递给子组件,$event参数在Angular中是真实存在的,你可以通过ng-submit等指令来使用它,你是否还记得如下函数:(译者注:上面这一段翻译需要推敲)

this.updateUser = function (event) {
  this.user = event.user;
};

我们期待event对象带有一个user的属性,好吧,那就让我们来在子组件中saveUser方法中添加该属性:

var childComponent = {
  ...
  controller: function () {
    ...
    this.saveUser = function () {
      this.onUpdate({
        $event: {
          user: this.user
        }
      });
    };
  }
};

上面的代码看上去有些怪异。也许有一点吧,但是他是始终一致的,当你使用十遍以后,你就再也不会停止使用它了。必要的我们需要在子组件中创建this.saveUser,然后在该方法中调用从父组件中通过bindings传递进来的this.updateUsser,接着我们传递给它event对象,来把我们更新后的数据返回给父组件:

尝试如上方式写代码吧:

这儿也有一个免费的教学视频,是我关于$onChanges和单向数据流教程的一部分,你可以从这获取到 check it out here.

**Is two-way binding through “=” syntax dead?

是的,单向数据绑定已经被认为是数据流的最佳方式,React,Angular 2 以及其他的类库都是用单向数据流,现在轮到Angualr 1了,虽然Angular 1加入单向数据流有些晚,但是依然很强大并将改变Angular 1.x应用开发方式。

img

**Using isFirstChange()

$onChanges还有一个特性,在changeshash对象中,该对象其实是SimpleChange构造函数的一个实例,该构造函数原型对象上有一个isFirstChange方法。

function SimpleChange(previous, current) {
  this.previousValue = previous;
  this.currentValue = current;
}
SimpleChange.prototype.isFirstChange = function () {
  // don't worry what _UNINITIALIZED_VALUE is :)
  return this.previousValue === _UNINITIALIZED_VALUE;
};

这就是变化对象根据不同的绑定策略怎么被创造出来(通过 new关键字)(我以前实现过单向数据绑定,并享受这一过程)

你为什么会想着使用该方法呢?上面我们已经提到过,$onChanges会在组件的某给生命周期阶段被调用,不仅在父组件的数据改变时,(译者注:也在数据初始化的时候也会被调用)因此我们可以通过该方法(isFirstChange)来判断是非需要跳过初始化阶段,我们可以通过在改变对象的某属性上面调用isFirstChange方法来判断$onChanges是否是第一次被调用。

this.$onChanges = function (changes) {
  if (changes.user.isFirstChange()) {
    console.log('First change...', changes);
    return; // Maybe? Do what you like.
  }
  if (changes.user) {
    this.user = angular.copy(this.user);
  }
};

Here’s a JSFiddle if you want to check the console.

**$onDestroy

我们最后来讨论下最简单的一个生命周期钩子,$onDestroy

function SomeController($scope) {
  $scope.$on('$destroy', function () {
    // destroy event
  });
}
**Using $onDestroy

你可以猜想到该生命周期钩子怎么使用:

var childComponent = {
  bindings: {
    user: '<'
  },
  controller: function () {
    this.$onDestroy = function () {
      // component scope is destroyed
    };
  }
};

angular
  .module('app')
  .component('childComponent', childComponent);

如果你使用了$postLink来设置了DOM事件监听函数或者其他非Angular原生的逻辑,在$onDestroy中你可以把这些事件监听或者非原生逻辑清理干净。

**Conclusion

Angular 1.x 应用开发者的的开发模式也随着单向数据流,生命周期事件及生命周期钩子函数的出现而改变,不久将来我将发布更多关于组件架构的文章。

Webpack Hot Module Replacement 的原理解析

文章发布在饿了么大前端专栏

Hot Module Replacement(以下简称 HMR)是 Webpack 发展至今引入的最令人兴奋的特性之一 ,当你对代码进行修改并保存后,Webpack 将对代码重新打包,并将新的模块发送到浏览器端,浏览器通过新的模块替换老的模块,这样在不刷新浏览器的前提下就能够对应用进行更新。例如,在开发 Web 页面过程中,当你点击按钮,出现一个弹窗的时候,发现弹窗标题没有对齐,这时候你修改 CSS 样式,然后保存,在浏览器没有刷新的前提下,标题样式发生了改变。感觉就像在 Chrome 的开发者工具中直接修改元素样式一样。

本篇文章不是告诉你怎么使用 HMR,如果你对 HMR 依然感觉到陌生,建议先阅读官网 HMR 指南,上面有 HMR 最简单的用例,我会等着你回来的。

为什么需要 HMR

在 Webpack HMR 功能之前,已经有很多 live reload 的工具或库,比如 live-server,这些库监控文件的变化,然后通知浏览器端刷新页面,那么我们为什么还需要 HMR 呢?答案其实在上文中已经提及一些。

  • live reload 工具并不能够保存应用的状态(states),当刷新页面后,应用之前状态丢失,还是上文中的例子,点击按钮出现弹窗,当浏览器刷新后,弹窗也随即消失,要恢复到之前状态,还需再次点击按钮。而 Webapck HMR 则不会刷新浏览器,而是运行时对模块进行热替换,保证了应用状态不会丢失,提升了开发效率。
  • 在古老的开发流程中,我们可能需要手动运行命令对代码进行打包,并且打包后再手动刷新浏览器页面,而这一系列重复的工作都可以通过 HMR 工作流来自动化完成,让更多的精力投入到业务中,而不是把时间浪费在重复的工作上。
  • HMR 兼容市面上大多前端框架或库,比如React Hot LoaderVue-loader,能够监听 React 或者 Vue 组件的变化,实时将最新的组件更新到浏览器端。Elm Hot Loader 支持通过 webpack 对 Elm 语言代码进行转译并打包,当然它也实现了 HMR 功能。

HMR 的工作原理图解

初识 HMR 的时候觉得其很神奇,一直有一些疑问萦绕在脑海。

  1. webpack 可以将不同的模块打包成 bundle 文件或者几个 chunk 文件,但是当我通过 webpack HMR 进行开发的过程中,我并没有在我的 dist 目录中找到 webpack 打包好的文件,它们去哪呢?
  2. 通过查看 webpack-dev-server 的 package.json 文件,我们知道其依赖于 webpack-dev-middleware 库,那么 webpack-dev-middleware 在 HMR 过程中扮演什么角色?
  3. 使用 HMR 的过程中,通过 Chrome 开发者工具我知道浏览器是通过 websocket 和 webpack-dev-server 进行通信的,但是 websocket 的 message 中并没有发现新模块代码。打包后的新模块又是通过什么方式发送到浏览器端的呢?为什么新的模块不通过 websocket 随消息一起发送到浏览器端呢?
  4. 浏览器拿到最新的模块代码,HMR 又是怎么将老的模块替换成新的模块,在替换的过程中怎样处理模块之间的依赖关系?
  5. 当模块的热替换过程中,如果替换模块失败,有什么回退机制吗?

带着上面的问题,于是决定深入到 webpack 源码,寻找 HMR 底层的奥秘。

hotModuleReplacement

图一:HMR 工作流程图解

上图是webpack 配合 webpack-dev-server 进行应用开发的模块热更新流程图。

  • 上图底部红色框内是服务端,而上面的橙色框是浏览器端。
  • 绿色的方框是 webpack 代码控制的区域。蓝色方框是 webpack-dev-server 代码控制的区域,洋红色的方框是文件系统,文件修改后的变化就发生在这,而青色的方框是应用本身。

上图显示了我们修改代码到模块热更新完成的一个周期,通过深蓝色的阿拉伯数字符号已经将 HMR 的整个过程标识了出来。

  1. 第一步,在 webpack 的 watch 模式下,文件系统中某一个文件发生修改,webpack 监听到文件变化,根据配置文件对模块重新编译打包,并将打包后的代码通过简单的 JavaScript 对象保存在内存中。
  2. 第二步是 webpack-dev-server 和 webpack 之间的接口交互,而在这一步,主要是 dev-server 的中间件 webpack-dev-middleware 和 webpack 之间的交互,webpack-dev-middleware 调用 webpack 暴露的 API对代码变化进行监控,并且告诉 webpack,将代码打包到内存中。
  3. 第三步是 webpack-dev-server 对文件变化的一个监控,这一步不同于第一步,并不是监控代码变化重新打包。当我们在配置文件中配置了devServer.watchContentBase 为 true 的时候,Server 会监听这些配置文件夹中静态文件的变化,变化后会通知浏览器端对应用进行 live reload。注意,这儿是浏览器刷新,和 HMR 是两个概念。
  4. 第四步也是 webpack-dev-server 代码的工作,该步骤主要是通过 sockjs(webpack-dev-server 的依赖)在浏览器端和服务端之间建立一个 websocket 长连接,将 webpack 编译打包的各个阶段的状态信息告知浏览器端,同时也包括第三步中 Server 监听静态文件变化的信息。浏览器端根据这些 socket 消息进行不同的操作。当然服务端传递的最主要信息还是新模块的 hash 值,后面的步骤根据这一 hash 值来进行模块热替换。
  5. webpack-dev-server/client 端并不能够请求更新的代码,也不会执行热更模块操作,而把这些工作又交回给了 webpack,webpack/hot/dev-server 的工作就是根据 webpack-dev-server/client 传给它的信息以及 dev-server 的配置决定是刷新浏览器呢还是进行模块热更新。当然如果仅仅是刷新浏览器,也就没有后面那些步骤了。
  6. HotModuleReplacement.runtime 是客户端 HMR 的中枢,它接收到上一步传递给他的新模块的 hash 值,它通过 JsonpMainTemplate.runtime 向 server 端发送 Ajax 请求,服务端返回一个 json,该 json 包含了所有要更新的模块的 hash 值,获取到更新列表后,该模块再次通过 jsonp 请求,获取到最新的模块代码。这就是上图中 7、8、9 步骤。
  7. 而第 10 步是决定 HMR 成功与否的关键步骤,在该步骤中,HotModulePlugin 将会对新旧模块进行对比,决定是否更新模块,在决定更新模块后,检查模块之间的依赖关系,更新模块的同时更新模块间的依赖引用。
  8. 最后一步,当 HMR 失败后,回退到 live reload 操作,也就是进行浏览器刷新来获取最新打包代码。

运用 HMR 的简单例子

在上一个部分,通过一张 HMR 流程图,简要的说明了 HMR 进行模块热更新的过程。当然你可能感觉还是很迷糊,对上面出现的一些英文名词也可能比较陌生(上面这些英文名词代表着代码仓库或者仓库中的文件模块),没关系,在这一部分,我将通过一个最简单最纯粹的例子,通过分析 wepack及 webpack-dev-server 源码详细说明各个库在 HMR 过程中的具体职责。

在开始这个例子之前简单对这个仓库文件进行下说明,仓库中包含文件如下:

--hello.js
--index.js
--index.html
--package.json
--webpack.config.js

项目中包含两个 js 文件,项目入口文件是 index.js 文件,hello.js 文件是 index.js 文件的一个依赖,js 代码如你所见(点击上面例子链接可以查看源码),将在 body 元素中添加一个包含「hello world」的 div 元素。

webpack.config.js的配置如下:

const path = require('path')
const webpack = require('webpack')
module.exports = {
	entry: './index.js',
	output: {
		filename: 'bundle.js',
		path: path.join(__dirname, '/')
	},
	devServer: {
		hot: true
	}
}

值得一提的是,在上面的配置中并没有配置 HotModuleReplacementPlugin,原因在于当我们设置 devServer.hot 为 true 后,并且在package.json 文件中添加如下的 script 脚本:

"start": "webpack-dev-server --hot --open"

添加 —hot 配置项后,devServer 会告诉 webpack 自动引入 HotModuleReplacementPlugin 插件,而不用我们再手动引入了。

进入到仓库目录,npm install 安装依赖后,运行 npm start 就启动了 devServer 服务,访问 http://127.0.0.1:8080 就可以看到我们的页面了。

下面将进入到关键环节,在简单例子中,我将修改 hello.js 文件中的代码,在源码层面上来分析 HMR 的具体运行流程,当然我还是将按照上面图解来分析。修改代码如下:(以下所有代码块首行就是该文件的路径)

// hello.js
- const hello = () => 'hello world' // 将 hello world 字符串修改为 hello eleme
+ const hello = () => 'hello eleme'

页面中 hello world 文本随即变成 hello eleme。

第一步:webpack 对文件系统进行 watch 打包到内存中

webpack-dev-middleware 调用 webpack 的 api 对文件系统 watch,当 hello.js 文件发生改变后,webpack 重新对文件进行编译打包,然后保存到内存中。

// webpack-dev-middleware/lib/Shared.js
if(!options.lazy) {
	var watching = compiler.watch(options.watchOptions, share.handleCompilerCallback);
	context.watching = watching;
}

你可能会疑问了,为什么 webpack 没有将文件直接打包到 output.path 目录下呢?文件又去了哪儿?原来 webpack 将 bundle.js 文件打包到了内存中,不生成文件的原因就在于访问内存中的代码比访问文件系统中的文件更快,而且也减少了代码写入文件的开销,这一切都归功于memory-fs,memory-fs 是 webpack-dev-middleware 的一个依赖库,webpack-dev-middleware 将 webpack 原本的 outputFileSystem 替换成了MemoryFileSystem 实例,这样代码就将输出到内存中。webpack-dev-middleware 中该部分源码如下:

// webpack-dev-middleware/lib/Shared.js
var isMemoryFs = !compiler.compilers && compiler.outputFileSystem instanceof MemoryFileSystem;
if(isMemoryFs) {
	fs = compiler.outputFileSystem;
} else {
	fs = compiler.outputFileSystem = new MemoryFileSystem();
}

首先判断当前 fileSystem 是否已经是 MemoryFileSystem 的实例,如果不是,用 MemoryFileSystem 的实例替换 compiler 之前的 outputFileSystem。这样 bundle.js 文件代码就作为一个简单 javascript 对象保存在了内存中,当浏览器请求 bundle.js 文件时,devServer就直接去内存中找到上线保存的 javascript 对象返回给浏览器端。

第二步:devServer 通知浏览器端文件发生改变

在这一阶段,sockjs 是服务端和浏览器端之间的桥梁,在启动 devServer 的时候,sockjs 在服务端和浏览器端建立了一个 webSocket 长连接,以便将 webpack 编译和打包的各个阶段状态告知浏览器,最关键的步骤还是 webpack-dev-server 调用 webpack api 监听 compile的 done 事件,当compile 完成后,webpack-dev-server通过 _sendStatus 方法将编译打包后的新模块 hash 值发送到浏览器端。

// webpack-dev-server/lib/Server.js
compiler.plugin('done', (stats) => {
  // stats.hash 是最新打包文件的 hash 值
  this._sendStats(this.sockets, stats.toJson(clientStats));
  this._stats = stats;
});
...
Server.prototype._sendStats = function (sockets, stats, force) {
  if (!force && stats &&
  (!stats.errors || stats.errors.length === 0) && stats.assets &&
  stats.assets.every(asset => !asset.emitted)
  ) { return this.sockWrite(sockets, 'still-ok'); }
  // 调用 sockWrite 方法将 hash 值通过 websocket 发送到浏览器端
  this.sockWrite(sockets, 'hash', stats.hash);
  if (stats.errors.length > 0) { this.sockWrite(sockets, 'errors', stats.errors); } 
  else if (stats.warnings.length > 0) { this.sockWrite(sockets, 'warnings', stats.warnings); } 	  	else { this.sockWrite(sockets, 'ok'); }
};

第三步:webpack-dev-server/client 接收到服务端消息做出响应

可能你又会有疑问,我并没有在业务代码里面添加接收 websocket 消息的代码,也没有在 webpack.config.js 中的 entry 属性中添加新的入口文件,那么 bundle.js 中接收 websocket 消息的代码从哪来的呢?原来是 webpack-dev-server 修改了webpack 配置中的 entry 属性,在里面添加了 webpack-dev-client 的代码,这样在最后的 bundle.js 文件中就会有接收 websocket 消息的代码了。

webpack-dev-server/client 当接收到 type 为 hash 消息后会将 hash 值暂存起来,当接收到 type 为 ok 的消息后对应用执行 reload 操作,如下图所示,hash 消息是在 ok 消息之前。

图二:websocket 接收 dev-server 通过 sockjs 发送到浏览器端的消息列表

在 reload 操作中,webpack-dev-server/client 会根据 hot 配置决定是刷新浏览器还是对代码进行热更新(HMR)。代码如下:

// webpack-dev-server/client/index.js
hash: function msgHash(hash) {
    currentHash = hash;
},
ok: function msgOk() {
    // ...
    reloadApp();
},
// ...
function reloadApp() {
  // ...
  if (hot) {
    log.info('[WDS] App hot update...');
    const hotEmitter = require('webpack/hot/emitter');
    hotEmitter.emit('webpackHotUpdate', currentHash);
    // ...
  } else {
    log.info('[WDS] App updated. Reloading...');
    self.location.reload();
  }
}

如上面代码所示,首先将 hash 值暂存到 currentHash 变量,当接收到 ok 消息后,对 App 进行 reload。如果配置了模块热更新,就调用 webpack/hot/emitter 将最新 hash 值发送给 webpack,然后将控制权交给 webpack 客户端代码。如果没有配置模块热更新,就直接调用 location.reload 方法刷新页面。

第四步:webpack 接收到最新 hash 值验证并请求模块代码

在这一步,其实是 webpack 中三个模块(三个文件,后面英文名对应文件路径)之间配合的结果,首先是 webpack/hot/dev-server(以下简称 dev-server) 监听第三步 webpack-dev-server/client 发送的 webpackHotUpdate 消息,调用 webpack/lib/HotModuleReplacement.runtime(简称 HMR runtime)中的 check 方法,检测是否有新的更新,在 check 过程中会利用 webpack/lib/JsonpMainTemplate.runtime(简称 jsonp runtime)中的两个方法 hotDownloadUpdateChunkhotDownloadManifest , 第二个方法是调用 AJAX 向服务端请求是否有更新的文件,如果有将发更新的文件列表返回浏览器端,而第一个方法是通过 jsonp 请求最新的模块代码,然后将代码返回给 HMR runtime,HMR runtime 会根据返回的新模块代码做进一步处理,可能是刷新页面,也可能是对模块进行热更新。

图三:hotDownloadManifest方法获取更新文件列表

图四:hotDownloadUpdateChunk获取到更新的新模块代码

如上两图所示,值得注意的是,两次请求的都是使用上一次的 hash 值拼接的请求文件名,hotDownloadManifest 方法返回的是最新的 hash 值,hotDownloadUpdateChunk 方法返回的就是最新 hash 值对应的代码块。然后将新的代码块返回给 HMR runtime,进行模块热更新。

还记得 HMR 的工作原理图解 中的问题 3 吗?为什么更新模块的代码不直接在第三步通过 websocket 发送到浏览器端,而是通过 jsonp 来获取呢?我的理解是,功能块的解耦,各个模块各司其职,dev-server/client 只负责消息的传递而不负责新模块的获取,而这些工作应该有 HMR runtime 来完成,HMR runtime 才应该是获取新代码的地方。再就是因为不使用 webpack-dev-server 的前提,使用 webpack-hot-middleware 和 webpack 配合也可以完成模块热更新流程,在使用 webpack-hot-middleware 中有件有意思的事,它没有使用 websocket,而是使用的长轮询。综上所述,HMR 的工作流中,不应该把新模块代码放在 websocket 消息中。

第五步:HotModuleReplacement.runtime 对模块进行热更新

这一步是整个模块热更新(HMR)的关键步骤,而且模块热更新都是发生在HMR runtime 中的 hotApply 方法中,这儿我不打算把 hotApply 方法整个源码贴出来了,因为这个方法包含 300 多行代码,我将只摘取关键代码片段。

// webpack/lib/HotModuleReplacement.runtime
function hotApply() {
  	// ...
  	var idx;
	var queue = outdatedModules.slice();
	while(queue.length > 0) {
		moduleId = queue.pop();
		module = installedModules[moduleId];
		// ...
		// remove module from cache
		delete installedModules[moduleId];
		// when disposing there is no need to call dispose handler
		delete outdatedDependencies[moduleId];
		// remove "parents" references from all children
		for(j = 0; j < module.children.length; j++) {
			var child = installedModules[module.children[j]];
			if(!child) continue;
			idx = child.parents.indexOf(moduleId);
			if(idx >= 0) {
				child.parents.splice(idx, 1);
			}
		}
	}
	// ...
  	// insert new code
	for(moduleId in appliedUpdate) {
		if(Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
			modules[moduleId] = appliedUpdate[moduleId];
		}
	}
  	// ...
}

从上面 hotApply 方法可以看出,模块热替换主要分三个阶段,第一个阶段是找出 outdatedModules 和 outdatedDependencies,这儿我没有贴这部分代码,有兴趣可以自己阅读源码。第二个阶段从缓存中删除过期的模块和依赖,如下:

delete installedModules[moduleId];

delete outdatedDependencies[moduleId];

第三个阶段是将新的模块添加到 modules 中,当下次调用 _webpack_require_ (webpack 重写的 require 方法)方法的时候,就是获取到了新的模块代码了。

模块热更新的错误处理,如果在热更新过程中出现错误,热更新将回退到刷新浏览器,这部分代码在 dev-server 代码中,简要代码如下:

module.hot.check(true).then(function(updatedModules) {
	if(!updatedModules) {
		return window.location.reload();
	}
	// ...
}).catch(function(err) {
	var status = module.hot.status();
	if(["abort", "fail"].indexOf(status) >= 0) {
		window.location.reload();
	}
});

dev-server 先验证是否有更新,没有代码更新的话,重载浏览器。如果在 hotApply 的过程中出现 abort 或者 fail 错误,也进行重载浏览器。

第六步:业务代码需要做些什么?

当用新的模块代码替换老的模块后,但是我们的业务代码并不能知道代码已经发生变化,也就是说,当 hello.js 文件修改后,我们需要在 index.js 文件中调用 HMR 的 accept 方法,添加模块更新后的处理函数,及时将 hello 方法的返回值插入到页面中。代码如下:

// index.js
if(module.hot) {
	module.hot.accept('./hello.js', function() {
		div.innerHTML = hello()
	})
}

这样就是整个 HMR 的工作流程了。

写在最后

这篇文章的作用并不是对 webpack HMR 的详尽解析,很多细节方面也没过多讨论,而只想起到一个抛砖引玉的作用,给大家展现一个 HMR 概述的工作流程,如果对 webpack 感兴趣,想知道 webpack HMR 更多的底层细节,相信阅读 webpack 源码将是一个不错的选择,也希望这篇文章能够对你阅读源码有所帮助,这才是我真正的写作目的。

ES6 块级作用域

当Brendan Eich在1995年设计JavaScript第一个版本的时候,考虑的不是很周到,以至于最初版本的JavaScript有很多不完善的地方,在Douglas Crockford的《JavaScript:The Good Parts》中就总结了很多JavaScript不好的地方,比如允许!===的使用,会导致隐式的类型转换,比如在全局作用域中通过var声明变量会成为全局对象(在浏览器环境中是window对象)的一个属性,在比如var声明的变量可以覆盖window对象上面原生的方法和属性等。

但是作为一门已经被广泛用于web开发的计算机语言来说,去纠正这些设计错误显得相当困难,因为如果新的语法和老的语法有冲突的话,那么已有的web应用无法运行,浏览器生产厂商肯定不会去冒这个险去实现这些和老的语法完全冲突的功能的,因为谁都不想失去自己的客户,不是吗?因此向下兼容便成了解决上述问题的唯一途径,也就是说在不改变原有语法特性的基础上,增加一些新的语法或变量声明方式等,来把新的语言特性引入到JavaScript语言中。

早在九年前,Brendan Eich在Firefox中就实现了第一版的let.但是let的功能和现有的ES2015标准规定有些出入,后来由Shu-yu Guo将let的实现升级到符合现有的ES2015标准,现在才有了我们现在在最新的Firefox中使用的let声明变量语法。

问题一:没有块级作用域

在ES2015之前,在函数中通过var声明的变量,不论其在{}中还是外面,其都可以在整个函数范围内访问到,因此在函数中声明的变量被称为局部变量,作用域被称为局部作用域,而在全局中声明的变量存在整个全局作用域中。但是在很多情境下,我们迫切的需要块级作用域的存在,也就是说在{}内部声明的变量只能够在{}内部访问到,在{}外部无法访问到其内部声明的变量,比如下面的例子:

function foo() {
    var bar = 'hello'
    if (true) {
        var zar = 'world'
        console.log(zar)
    }
    console.log(zar) // 如果存在块级作用域那么将报语法错误:Uncaught ReferenceError
}

在上面的例子中,如果JavaScript在ES2015之前就存在块级作用域,那么在{}之外将无法访问到其内部声明的变量zar,但是实际上,第二个console却打印了zar的赋值,'world'。

问题二:for循环**享迭代变量值

在for循环初始循环变量时,如果使用var声明初始变量i,那么在整个循环中,for循环内部将共享i的值。如下代码:

var funcs = []
for (var i = 0; i < 10; i++) {
    funcs.push(function() {
        return i
    })
}
funcs.forEach(function(f) {
    console.log(f()) // 将在打印10数字10次
})

上面的代码并没有按着我们希望的方式执行,我们本来希望是最后打印0、1、2...9这10个数字。但是最后的结果却出乎我们的意料,而是将数字10打印了10次,究其原因,声明的变量i在上面的整个代码块能够访问到,也就是说,funcs数组中每一个函数返回的i都是全局声明的变量i。也就说在funcs中函数执行时,将返回同一个值,而变量i初始值为0,当迭代最后一次进行累加,9+1 = 10时,通过条件语句i < 10判断为false,循环运行完毕。最后i的值为10.也就是为什么最后所有的函数都打印为10。那么在ES2015之前能够使上面的循环打印0、1、2、… 9吗?答案是肯定的。

var funcs = []
for (var i = 1; i < 10; i++) {
    funcs.push((function(value) {
        return function() {
            return value
        }
    })(i))
}
funcs.forEach(function(f) {
    console.log(f())
})

在这儿我们使用了JavaScript中的两个很棒的特性,立即执行函数(IIFEs)和闭包(closure)。在JavaScript的闭包中,闭包函数能够访问到包庇函数中的变量,这些闭包函数能够访问到的变量也因此被称为自由变量。只要闭包没有被销毁,那么外部函数将一直在内存中保存着这些变量,在上面的代码中,形参value就是自由变量,return的函数是一个闭包,闭包内部能够访问到自由变量value。同时这儿我们还使用了立即执行函数,立即函数的作用就是在每次迭代的过程中,将i的值作为实参传入立即执行函数,并执行返回一个闭包函数,这个闭包函数保存了外部的自由变量,也就是保存了当次迭代时i的值。最后,就能够达到我们想要的结果,调用funcs中每个函数,最终返回0、1、2、… 9。

问题三:变量提升(Hoisting)

我们先来看看函数中的变量提升, 在函数中通过var定义的变量,不论其在函数中什么位置定义的,都将被视作在函数顶部定义,这一特定被称为提升(Hoisting)。想知道变量提升具体是怎样操作的,我们可以看看下面的代码:

function foo() {
    console.log(a) // undefined
    var a = 'hello'
    console.log(a) // 'hello'
}

在上面的代码中,我们可以看到,第一个console并没有报错(ReferenceError)。说明在第一个console.log(a)的时候,变量a已经被定义了,JavaScript引擎在解析上面的代码时实际上是像下面这样的:

function foo() {
  var a
  console.log(a)
  a = 'hello'
  console.log(a)
}

也就是说,JavaScript引擎把变量的定义和赋值分开了,首先对变量进行提升,将变量提升到函数的顶部,注意,这儿变量的赋值并没有得到提升,也就是说a = "hello"依然是在后面赋值的。因此第一次console.log(a)并没有打印hello也没有报ReferenceError错误。而是打印undefined。无论是函数内部还是外部,变量提升都会给我们带来意想不到的bug。比如下面代码:

if (!('a' in window)) {
  var a = 'hello'
}
console.log(a) // undefined

很多公司都把上面的代码作为面试前端工程师JavaScript基础的面试题,其考点也就是考察全局环境下的变量提升,首先,答案是undefined,并不是我们期许的hello。原因就在于变量a被提升到了最上面,上面的代码JavaScript其实是这样解析的:

var a
if (!('a' in window)) {
  a = 'hello'
}
console.log(a) // undefined

现在就很明了了,bianlianga被提升到了全局环境最顶部,但是变量a的赋值还是在条件语句内部,我们知道通过关键字var在全局作用域中声明的变量将作为全局对象(window)的一个属性,因此'a' in windowtrue。所以if语句中的判断语句就为false。因此条件语句内部就根本不会执行,也就是说不会执行赋值语句。最后通过console.log(a)打印也就是undefined,而不是我们想要的hello

虽然使用关键词let进行变量声明也会有变量提升,但是其和通过var申明的变量带来的变量提升是不一样的,这一点将在后面的letvar的区别中讨论到。

关于ES2015之前作用域的概念

上面提及的一些问题,很多都是由于JavaScript中关于作用域的细分粒度不够,这儿我们稍微回顾一下ES2015之前关于作用域的概念。

Scope: collects and maintains a look-up list of all the declared identifiers (variables), and enforces a strict set of rules as to how these are accessible to currently executing code.

上面是关于作用域的定义,作用域就是一些规则的集合,通过这些规则我们能够查找到当前执行代码所需变量的值,这就是作用域的概念。在ES2015之前最常见的两种作用域,全局作用局和函数作用域(局部作用域)。函数作用域可以嵌套,这样就形成了一条作用域链,如果我们自顶向下的看,一个作用域内部可以嵌套几个子作用域,子作用域又可以嵌套更多的作用域,这就更像一个‘’作用域树‘’而非作用域链了,作用域链是一个自底向上的概念,在变量查找的过程中很有用的。在ES3时,引入了try catch语句,在catch语句中形成了新的作用域,外部是访问不到catch语句中的错误变量。代码如下:

try {
  throw new Error()
} catch(err) {
  console.log(err)
}
console.log(err) //Uncaught ReferenceError

再到ES5的时候,在严格模式下(use strict),函数中使用eval函数并不会再在原有函数中的作用域中执行代码或变量赋值了,而是会动态生成一个作用域嵌套在原有函数作用域内部。如下面代码:

'use strict'
var a = function() {
    var b = '123'
    eval('var c = 456;console.log(c + b)') // '456123'
    console.log(b) // '123'
    console.log(c) // 报错
}

在非严格模式下,a函数内部的console.log(c)是不会报错的,因为eval会共享a函数中的作用域,但是在严格模式下,eval将会动态创建一个新的子作用域嵌套在a函数内部,而外部是访问不到这个子作用域的,也就是为什么console.log(c)会报错。

通过let来声明变量

通过let关键字来声明变量也通过var来声明变量的语法形式相同,在某些场景下你甚至可以直接把var替换成let。但是使用let来申明变量与使用var来声明变量最大的区别就是作用域的边界不再是函数,而是包含let变量声明的代码块({})。下面的代码将说明let声明的变量只在代码块内部能够访问到,在代码块外部将无法访问到代码块内部使用let声明的变量。

if (true) {
  let foo = 'bar'
}
console.log(foo) // Uncaught ReferenceError

在上面的代码中,foo变量在if语句中声明并赋值。if语句外部却访问不到foo变量,报ReferenceError错误。

letvar的区别

变量提升的区别

在ECMAScript 2015中,let也会提升到代码块的顶部,在变量声明之前去访问变量会导致ReferenceError错误,也就是说,变量被提升到了一个所谓的“temporal dead zone”(以下简称TDZ)。TDZ区域从代码块开始,直到显示得变量声明结束,在这一区域访问变量都会报ReferenceError错误。如下代码:

function do_something() {
  console.log(foo); // ReferenceError
  let foo = 2;
}

而通过var声明的变量不会形成TDZ,因此在定义变量之前访问变量只会提示undefined,也就是上文以及讨论过的var的变量提升。

全局环境声明变量的区别

在全局环境中,通过var声明的变量会成为window对象的一个属性,甚至对一些原生方法的赋值会导致原生方法的覆盖。比如下面对变量parseInt进行赋值,将覆盖原生parseInt方法。

var parseInt = function(number) {
  return 'hello'
}
parseInt(123) // 'hello'
window.parseInt(123) // 'hello'

而通过关键字let在全局环境中进行变量声明时,新的变量将不会成为全局对象的一个属性,因此也就不会覆盖window对象上面的一些原生方法了。如下面的例子:

let parseInt = function(number) {
  return 'hello'
}
parseInt(123) // 'hello'
window.parseInt(123) // 123

在上面的例子中,我们看到let生命的函数parsetInt并没有覆盖window对象上面的parseInt方法,因此我们通过调用window.parseInt方法时,返回结果123。

在多次声明同一变量时处理不同

在ES2015之前,可以通过var多次声明同一个变量而不会报错。下面的代码是不会报错的,但是是不推荐的。

var a = 'xiaoming'
var a = 'huangxiaoming'

其实这一特性不利于我们找出程序中的问题,虽然有一些代码检测工具,比如ESLint能够检测到对同一个变量进行多次声明赋值,能够大大减少我们程序出错的可能性,但毕竟不是原生支持的。不用担心,ES2015来了,如果一个变量已经被声明,不论是通过var还是let或者const,该变量再次通过let声明时都会语法报错(SyntaxError)。如下代码:

var a = 345
let a = 123 // Uncaught SyntaxError: Identifier 'a' has already been declared

最好的总是放在最后:const

通过const生命的变量将会创建一个对该值的一个只读引用,也就是说,通过const声明的原始数据类型(number、string、boolean等),声明后就不能够再改变了。通过const声明的对象,也不能改变对对象的引用,也就是说不能够再将另外一个对象赋值给该const声明的变量,但是,const声明的变量并不表示该对象就是不可变的,依然可以改变对象的属性值,只是该变量不能再被赋值了。

const MY_FAV = 7
MY_FAY = 20 // 重复赋值将会报错(Uncaught TypeError: Assignment to constant variable)
const foo = {bar: 'zar'}
foo.bar = 'hello world' // 改变对象的属性并不会报错

通过const生命的对象并不是不可变的。但是在很多场景下,比如在函数式编程中,我们希望声明的变量是不可变的,不论其是原始数据类型还是引用数据类型。显然现有的变量声明不能够满足我们的需求,如下是一种声明不可变对象的一种实现:

const deepFreeze = function(obj) {
    Object.freeze(obj)
    for (const key in obj) {
        if (typeof obj[key] === 'object') deepFreeze(obj[key])
    }
    return obj
}
const foo = deepFreeze({
  a: {b: 'bar'}
})
foo.a.b = 'zar'
console.log(foo.a.b) // bar

最佳实践

在ECMAScript 2015成为最新标准之前,很多人都认为let是解决本文开始罗列的一系列问题的最佳方案,对于很多JavaScript开发者而言,他们认为一开始var就应该像现在let一样,现在let出来了,我们只需要根据现有的语法把以前代码中的var换成let就好了。然后使用const声明那些我们永远不会修改的值。

但是,当很多开发者开始将自己的项目迁移到ECMAScript2015后,他们发现,最佳实践应该是,尽可能的使用const,在const不能够满足需求的时候才使用let,永远不要使用var。为什么要尽可能的使用const呢?在JavaScript中,很多bug都是因为无意的改变了某值或者对象而导致的,通过尽可能使用const,或者上面的deepFreeze能够很好地规避这些bug的出现,而我的建议是:如果你喜欢函数式编程,永远不改变已经声明的对象,而是生成一个新的对象,那么对于你来说,const就完全够用了。

《斯坦福极简经济学》读后感

可能由于大学第二学位是“国际贸易”,所以看这本书有种似曾相识的感觉,因为大学也学过经济学,相隔十年,再读经济学原理时感悟又不同了,在读到做自己最适合的事,就有更好的生产力一章时,谈谈自己的理解

什么是分工?

本章主要内容聚焦在“分工”这个经济学名词上,在现如今的世界,看起来很简单的消费品,也经常会通过涉及到全球的复杂过程来生产。书中以铅笔为例,木材可能来自加利福利亚,在那里砍伐、运送、加工。铅是斯里兰卡生产的石墨与密西西比开采的黏土的混合物,两者的结合又可能在另外一个城市。铅笔外观的黄色涂料使用蓖麻子做成的,又涉及到种植、运送、制成涂料等过程。简单的铅笔就有如此多工序,涉及多个国家和地区。分工也为生产商品的厂商与国家创造了显著的经济利益
从上面的例子我们了解到了分工,那么分工具体有什么好处呢?专业的人做专业的事和规模经济

专业的人做专业的事

因为有了分工,我们可以专注在某一领域,去深耕去学习,通常我们也会变得更有生产力。拿互联网应用开发来说,最开始也不是前后端分离的,在早期前端也就是做一些页面内容的展示和表单的提交,这部分服务端就直接承接了。如使用PHP再加上jQuery将前端代码就直接写在了服务端。但随着前端交互越来越复杂,对一些动效要求也越来越高,同时由于技术的发展,需要学习的技能也越来越多,全栈开发变得不那么现实。前端开始独立出来,前后端的分离,进而又带来了前端技术突飞猛进的发展,一些前端框架也就应运而生,如Angular、React、Vue 等,提升了前端开发效率,也提升了生产力

规模经济

分工使企业可以利用规模经济(economics of scale),规模经济可能在互联网行业显得会更加明显一些。举一个例子,当一款App DAU在10万的时候,也就是日常服务10万用户,我们可能投入了50名研发同学,App DAU猛增到1亿时,研发投入也可能还不到500人,DAU增长了1000倍,但是研发投入只扩大了10倍,当然这儿把整个App投入做了简化,App的运营还包括:其他人员投入(产品、运营、设计等)和服务器成本等,这就是规模经济的优势

因此,是不是在大厂做研发比在小厂做研发创造更大的商业价值?答案是肯定的。这也是很多创业公司的发展困境。拿文档这个赛道来举例,石墨是国内最早做在线协作文档的公司之一,早在15年就开始在在线协作文档上布局了,早期也投入了大量的资金和研发,一度发展不错,到了18、19年,很多大厂开始挤入这一赛道,如腾讯文档、钉钉文档还包括飞书文档,这些企业利用已积累的大量用户及周边资源(微信、钉钉、飞书)实现弯道超车。那么创业公司的出路在哪?我理解需要勇于创新,快速占领市场,提升进入壁垒

如何避免被淘汰

上面提到,在分工这种协作模式下,我们会去深耕某一领域去学习,提升自己的专业技能。其实我们也做了一些取舍。因为分工,我们去饭店吃饭但是我们不用自己去种植水稻,在生活中我们需要用电,但是我们不用自己去发电(水力发电)。我们不需要自己造衣服,不用自己建造房屋。我们完全缺乏这方面的训练,在我过往的经历中,通下水道我可能都会去找专业的人士来帮忙。似乎很矛盾,一个国家越富有,人们在独立、无助时生存能力就越差

回到目前我所在的领域,软件开发。或者再具体一些,前端开发。在未来10年前端开发这个职位会消失吗?会消失也不会消失。目前AI技术的高速发展,GPT开发的App已上架到App Store。github copilot作为一款人工智能开发工具,可以帮我们自动完成代码和提供代码建议。可以断言,简单的页面交互或者单纯调用Api,或者是服务端增删改查,这些代码很有可能就直接AI完成了。从这一点来看前端会消失。那么前端为啥又不会消失呢?我理解的前端是人机交互,不论是目前的互联网应用,还是未来的如何和人工智能,都涉及到人机交互,技术在发展,但是人机交互都是需要的。所以需要我们不断去迭代我们的知识储备,面向未来进行学习和思考,抓住机遇和迎接挑战,这样才能避免被淘汰

聊聊 Git「改变历史」

聊聊 Git 「改变历史」

非常感谢你为 mint-ui 修复了这个 issue。不过你的 commit 信息能修改成如下格式吗?「issue 666: Any message about this issue」。

当我兴高采烈向 Element 提交 PR 的时候,维护者告诉我你能把你多个 commits 合并成一个 commit 吗?我们需要保持提交历史清晰明了。

修复了一个线上 master 分支的Bug,发现这个 Bug 在当前 dev 分支也是存在的,怎么将master分支上的 bugfix 的 commit 移植到 dev 分支呢?

其实上面的问题会经常出现在我们的开发过程中,或者是在向一些开源项目提交 PR 的时候。在本篇文章中,我将重现以上问题,聊聊 Git 怎么改变历史记录。

重写最后一次提交

在我们开发的过程中,我们经常会遇到这样的问题,当我们进行了一次「冲动」的 Git 提交后。发现我们的 commit 信息有误,或者我们把不应该这次提交的文件添加到了此次提交中,或者有的文件忘记提交了,怎么办?这些问题都可以通过如下命令来进行弥补。

git commit --amend

举个例子,在一个刚初始化的 Git 仓库中,有如下两个文件:

total 0
-rw-r--r--  1 ransixi  staff     0B  9 19 16:35 should-commit.js
-rw-r--r--  1 ransixi  staff     0B  9 19 16:35 should-not-commit.js

其中 should-commit.js 文件应该被提交,而 should-not-commit.js 不应该被提交,但是由于「冲动」,我把 should-not-commit.js 文件提交了。

# 其实应该添加 should-commit.js 文件
git add should-not-commit.js
# 啊哈,由于笔误,我把 commit 写成了 commmit
git commit -m 'commmit 1'

通过 git log 命令打印下当前的历史提交记录:

commit fba6199e7fd5f325cc0bfcec4c599c93603d48f8 (HEAD -> master)
Author: ran.luo03 <[email protected]>
Date:   Tue Sep 19 16:49:57 2017 +0800

    commmit 1

这样的错误的提交一定不能够给别人看到!是时候该祭出 git commit --amend 了。

# 首先,需要将 should-commit.js 文件添加到暂存区
git add should-commit.js
# 其次,将 should-not-commit.js 文件从已暂存状态转为未暂存状态,不会删除 should-not-commit.js 文件。
git rm --cache should-not-commit.js
# 最后,通过git commit --amend 修改提交信息
git commit--amend

当键入 git commit —amend 命令后,会打开 Git 默认编辑器,内容包括了上次错误提交的信息,我们只需将 commmit 1 改为 commit 1 就行了,然后保存退出编辑器。这样我们就完成了错误提交的修改,让我们再通过 git log 来查看一下历史提交记录:

commit 2a410384e14dadaff9b98f823b9f239da055637d (HEAD -> master)
Author: ran.luo03 <[email protected]>
Date:   Tue Sep 19 16:49:57 2017 +0800

    commit 1

啊哈,整个历史记录中只有我最新修改后的历史提交,你完全找不到上一次的提交踪迹了。是不是很酷呢?

**思考1:**怎么使用 git reset 命令修改最后一次提交记录?

多个提交合并、排序、删除操作

在一个大型项目中,为了保持提交历史的简洁和可逆,往往一个功能点或者一个 bug fix 对应一个提交,但是在我们实际开发的过程中,我们并不是完成整个功能才进行一次提交的,往往是开发了功能点的一部分,就需要给小伙伴们进行 code review,小的 commit 保证了 code review 的效率和准确性,想象一下如果一次给小伙伴 review 上千行代码,几十个文件,他一定会疯掉的。同时 code review 后的反馈,我们可能需要修改代码,然后再次提交。但是这些提交之间的反复修改不应该体现在最终的 PR 上面,因此, 我们需要根据功能点的前后对 commit 进行排序,对相同功能的commits 进行合并,并删除一些不需要的 commit,根据最终的提交历史提 PR。

举个例子,将王之涣的登鹳雀楼摘抄到我的读书笔记中。

首先创建 poem 文件,将「黄河入海流」这句诗添加到了文件中,创建第一个 commit 如下:

通过 git log --oneline 命令来看看提交记录。

da5ee49 (HEAD -> master) add 黄河入海流

后来觉得,摘抄一句有些单调,不如将其前面一句也摘抄到笔记中吧,于是又出现了第二个 commit 如下:

622c3c8 (HEAD -> master) add 百日依山尽
da5ee49 add 黄河入海流

...

觉得自己太随性,摘抄一首诗竟然添加了如此之多的 commits,commits 如下:

953aabb (HEAD -> master) add 文章出处
7fad941 add 摘抄时间
731d00b add 作者:王焕之
9a22044 add 标题:登鹳雀楼
4fee22a add 更上一层楼
d1293c5 add 欲穷千里目
622c3c8 add 百日依山尽
da5ee49 add 黄河入海流

再看看上面的提交历史,觉得如此多的 commits 确实有些冗余了,commits 的顺序似乎也有些问题,因为 commits 的顺序并不是按照正常摘抄一首诗的顺序来组织的。而且觉得添加摘抄时间有些多余了,git 的历史提交记录就已经帮我记录了添加时间。

让我们来一步一步通过「重写历史」来修改上面的问题。

这次我使用的命令是 git rebase -i 或者 git rebase - -interactive, Git 官方文档对其如下解释:

Make a list of the commits which are about to be rebased. Let the user edit that list before rebasing. This mode can also be used to split commits

可以看出,该命令罗列了将要 rebase 的提交记录,打开 Git 设置的编辑器,让用户有更多的选择,可以进行 commit 合并,对 commits 重新排序,删除 commit 等。

第一步:删除「add 摘抄时间」commit

运行命令

git rebase -i HEAD~2

Git 打开默认编辑器,出来如下对话信息:

pick 7fad941 add 摘抄时间
pick 953aabb add 文章出处

# Rebase 731d00b..953aabb onto 731d00b (2 commands)
#
# Commands:
# p, pick = use commit
# r, reword = use commit, but edit the commit message
# e, edit = use commit, but stop for amending
# s, squash = use commit, but meld into previous commit
# f, fixup = like "squash", but discard this commit's log message
# x, exec = run command (the rest of the line) using shell
# d, drop = remove commit

上面的对话信息中包含七条可选命令,很明显最后一条 d,drop 正式我需要的,因为我正打算删除 commit。于是我把第一行中的 pick 命令改为了 drop 命令。

drop 7fad941 add 摘抄时间
pick 953aabb add 文章出处

保存并推出编辑器。

Auto-merging poem
CONFLICT (content): Merge conflict in poem
error: could not apply 953aabb... add 文章出处

When you have resolved this problem, run "git rebase --continue".
If you prefer to skip this patch, run "git rebase --skip" instead.
To check out the original branch and stop rebasing, run "git rebase --abort".

Could not apply 953aabb... add 文章出处

OMG!竟然竟然提示 poem 文件中有冲突!打开 poem 文件,手动删除不需要的内容及冲突的标记符号,按照上面的提示,运行 git rebase --continue 命令。心想,这下总该好了吧!

poem: needs merge
You must edit all merge conflicts and then
mark them as resolved using git add

rebase 依然没有成功,原来忘记将解决冲突的修改添加到暂存区了,通过运行 git add 命令后,再次执行 git rebase —continue。

出来一个对话框,提示我可以修改 commit 信息,没有修改,直接保存退出。来看看此时的提交历史记录。

b8f0233 (HEAD -> master) add 文章出处
731d00b add 作者:王焕之
9a22044 add 标题:登鹳雀楼
4fee22a add 更上一层楼
d1293c5 add 欲穷千里目
622c3c8 add 百日依山尽
da5ee49 add 黄河入海流

和之前的 commits log 信息进行对比,发现 7fad941 add 摘抄时间 提交,已经被我成功得删除了,虽然期间有些波折。同时我还注意到了,「add 文章出处」的 SHA1的 hash 值也从 953aabb 变成了 b8f0233。说明,该 commit 是新创建的 commit。

第二步:调整 commits 顺序

看着上面提交历史记录总会有些别扭,因为不是安装诗本身的顺序来进行提交的,现在我需要修改提交的顺序。好吧,又该是 git rebase -i 命令大显身手的时候到了。

但是现在有个问题,git rebase -i 命令并不能够编辑最初的提交。不巧的是,我正需要改变第一个 commit 的顺序,这儿需要一点小技巧,用到 --root 选项,通过该选项,我们就能够编辑初始化的提交了。运行命令如下:

git rebase -i —root

Git 再次打开编辑器,提示如下对话信息:

pick da5ee49 add 黄河入海流
pick 622c3c8 add 百日依山尽
pick d1293c5 add 欲穷千里目
pick 4fee22a add 更上一层楼
pick 9a22044 add 标题:登鹳雀楼
pick 731d00b add 作者:王焕之
pick b8f0233 add 文章出处

# Rebase b8f0233 onto a69da76 (7 commands)
#
# Commands:
# p, pick = use commit
# r, reword = use commit, but edit the commit message
# e, edit = use commit, but stop for amending
# s, squash = use commit, but meld into previous commit
# f, fixup = like "squash", but discard this commit's log message
# x, exec = run command (the rest of the line) using shell
# d, drop = remove commit
# ...

修改上面的提交顺序如下:

pick 622c3c8 add 百日依山尽
pick da5ee49 add 黄河入海流
pick d1293c5 add 欲穷千里目
pick 4fee22a add 更上一层楼
pick 9a22044 add 标题:登鹳雀楼
pick 731d00b add 作者:王焕之
pick b8f0233 add 文章出处
...

然后保存并推出编辑器。

OMG,依然存在冲突,解决冲突,运行 git add . 和 git rebase —continue。最后来看看现在的历史提交记录:

ddb6576 (HEAD -> master) add 文章出处
a6e40b3 add 作者:王焕之
ce83346 add 标题:登鹳雀楼
cae4916 add 更上一层楼
f79b9ac add 欲穷千里目
fb65570 add 黄河入海流
8e25185 add 白日依山尽

第三步:合并 commits

添加标题和添加作者貌似应该放到一个 commit 里面,也就是说,我需要将a6e40b3 add 作者:王焕之 提交和 ce83346 add 标题:登鹳雀楼 合并成一个提交。这样显得提交更加简洁明晰。

依然使用命令

git rebase -i HEAD~3

Git 大概如下对话框:

pick ce83346 add 标题:登鹳雀楼
pick a6e40b3 add 作者:王焕之
pick ddb6576 add 文章出处

# Rebase cae4916..ddb6576 onto cae4916 (3 commands)
#
# Commands:
# p, pick = use commit
# r, reword = use commit, but edit the commit message
# e, edit = use commit, but stop for amending
# s, squash = use commit, but meld into previous commit
# f, fixup = like "squash", but discard this commit's log message
# x, exec = run command (the rest of the line) using shell
# d, drop = remove commit
# ...

这次我使用的命令是 s, squash。该命令用于合并两个或多个 commits,会将选择的 commit 合并到前一个 commt 中。修改上面对话第二行如下:

pick ce83346 add 标题:登鹳雀楼
squash a6e40b3 add 作者:王焕之
pick ddb6576 add 文章出处

然后保存并推出编辑器,啊哈,Git 似乎有点疑惑,它并不知道选择哪个 commit 信息作为合并的最终 commit 信息,于是 Git 打开了新的对话框,让我自己输入新的合并提交信息。

# This is a combination of 2 commits.
# This is the 1st commit message:

add 标题:登鹳雀楼

# This is the commit message #2:

add 作者:王焕之

# ...

修改如下:

# This is a combination of 2 commits.
# This is the 1st commit message:

add 标题:登鹳雀楼 作者:王焕之

# This is the commit message #2:

# add 作者:王焕之

# ...

保存上面的修改,并推出编辑器。

再来看看最后的历史提交记录

* b907e51 - (2 hours ago) add 文章出处 - ran.luo (HEAD -> master)
* bd0bfed - (3 hours ago) add 标题:登鹳雀楼 作者:王焕之 - ran.luo
* cae4916 - (3 hours ago) add 更上一层楼 - ran.luo
* f79b9ac - (3 hours ago) add 欲穷千里目 - ran.luo
* fb65570 - (3 hours ago) add 黄河入海流 - ran.luo
* 8e25185 - (3 hours ago) add 白日依山尽 - ran.luo

啊哈,该历史提交记录终于是我想要的了。

**思考2:**假如通过 rebase 合并了多个 commits 后,发现并不是我们想要的结果,怎么使用 git reset 将其恢复到合并前状态?

思考3: 在上面的例子中,由于 git rebase -i 不能够直接编辑最初的提交记录,因而使用了 --root 选项,那么有没有什么方法可以在最初的 commit 之前添加一个 root commit 呢?这样 git rebase -i 就可以直接使用了。

将其他分支的某个提交附加到当前分支

还记得文章开头提及的那个问题吗?修复了一个 master 分支上的线上 Bug,完成了项目的测试发布后,发现当前开发分支 dev 也存在同样的问题,怎么办?是把修复 Bug 的代码从 master 分支上线复制一遍到 dev 分支上,这显然效率不高,而且容易复制错误。还是以一个最小的例子来分析 Git 怎么帮我们解决这个问题。

当前版本库有两个分支,master 分支和 dev 分支,master 分支包含一个文件 file1,已经发布到线上,dev 分支是从 master 分支上分离出来的一个新的分支,并且已经完成了新功能的开发,添加了另外一个文件 file2。当前的提交图如下:

* 132cabb - (4 minutes ago) dev add file2 - ran.luo (dev)
* daaae54 - (4 minutes ago) add file1 - ran.luo (HEAD -> master)

上面代码可以看出,当前 HEAD 指向 master 分支,并且发现一个线上 bug,需要紧急修复,我对 file1文件内容进行修改,修复了该 bug。并提交一个新的 commit。当前的提交图如下:

* c6607dc - (4 seconds ago) master fix bug - ran.luo (HEAD -> master)
| * 132cabb - (6 minutes ago) dev add file2 - ran.luo (dev)
|/
* daaae54 - (7 minutes ago) add file1 - ran.luo

因为 dev 分支是从 master 分支上分离出来的新分支,因此先前 master 分支上面的 bug 在 dev 分支上也存在,但是又有谁想再次手写代码修复一遍 bug 呢?这时候我们就需要用到 git cherry-pick 命令。Git 官方文档对其解释如下:

git-cherry-pick - Apply the changes introduced by some existing commits

由官网文档可知,git-cherry-pick 命令常用于将版本库的一个分支上的特定提交引入到另一个分支上,也就是说,其可以将其他分支带来的改变直接作用到当前分支,这不就是本例所需要的吗?

首先需要切换到 dev 分支,由于我们需要的是版本库中 master 分支上面的最新的一个关于 bug fix 的提交,将其附加到 dev 分支后面,使用如下命令:

git cherry-pick master

执行完毕后,我们切回 master 分支,再来看看当前的提交图:

* 439cb35 - (14 minutes ago) master fix bug - ran.luo (dev)
* 132cabb - (20 minutes ago) dev add file2 - ran.luo
| * c6607dc - (14 minutes ago) master fix bug - ran.luo (HEAD -> master)
|/
* daaae54 - (21 minutes ago) add file1 - ran.luo

啊哈,成功得将 master 分支的最新提交附加到了 dev 分支上面,又双叒叕一次改变了历史,心中的自豪感悠然而生。

**思考4:**既然 git cherry-pick 可以将某一分支上面的制定提交附加到当前分支上线,那么这样是否可能通过不同的操作顺序来对将要附加的提交进行排序呢?

**思考5:**有时候可能一次需要将版本库中某一分支上面的多个连续的提交一次性的附加到当前分支上面,git cherry-pick (git cherry-pick X..Y)命令是否也能够满足我们的需求呢?

写在最后

当我还沉浸在改变历史的成就中难以自拔的时候,身边大佬的一句话让我清醒过来:「历史(记录)没有因你而变,而只是改变了历史(记录)的呈现方式」。当我查阅了.git/objects中的关于记录 commit 的文件后,才发现我还是too young too simple。我并没有改变或删除这些记录 commit 的文件,而只是生成了一些新的 commit 文件,尽然以为我改变了历史记录,可笑!这也是我们为什么能够恢复到改变历史记录前状态的原因,关于Git 中 hash、commit、history 的实质,请参考 git inside --simplified --part '1'

Warning

改变历史提交提交记录并非完美,你需要遵循如下准则,只要没有其他开发人员获取到你版本库的副本,或者没有共享你的提交记录,那么你就可以尽情的完善你的提交记录,可以修改提交信息,合并或者拆分多个提交,对多个提交进行排序等等。不过,记住一点,如果你的版本库已经公开,并且其他开发人员已经共享了你的提交记录,那么你就不应该重写、修改该版本库中的任意部分。否则,你的合作者会埋怨你,你的家人和朋友也会嘲笑你、抛弃你。

使用 Atomics 来在 SharedArrayBuffers 中避免竞用条件

原文地址

这是本系列三篇文章中的第三篇:

  1. 内存管理速成手册

  2. 通过漫画形式来解释 ArrayBuffers 和 SharedArrayBuffers

  3. 使用 Atomics 来在 SharedArrayBuffers 中避免竞用条件

在上一篇文章中,我已经提到过 SharedArrayBuffers 如何导致竞用条件。这使得在使用 SharedArrayBuffers时变得困难,因此我们并不希望应用开发者直接使用 SharedArrayBuffers。

但是对于拥有其他语言多线程开发经验的库开发者而言,他们可以使用这些底层的 APIs开发出更高级的工具。应用开发者就能够使用这些工具而不是直接使用 SharedArrayBuffers 或者 Atomics。

Layer diagram showing SharedArrayBuffer + Atomics as the foundation, and JS libaries and WebAssembly threading building on top

尽管你也许不会直接使用到 SharedArrayBuffers 和 Atomics,但是了解它们是如何工作的依然是一件有趣的事。因此在这篇文章中,我将解释 SharedArrayBuffers 将产生哪些类型的竞用条件,以及 Atomics 怎么帮助库开发者们避免这些竞用条件。

但是首先,什么是竞用条件呢?

[Drawing of two threads racing towards memory

竞用条件:一个你之前可能见过的例子

一个相当简单的关于竞用条件的例子,当你声明一个变量后,同时这个变量被两个线程所使用,那么将会导致竞用条件的产生。比如一个线程需要上传一个文件,而另外一个线程用来检查文件是否存在,这两个线程共享同一个变量 fileExists,用于通信。

最初,fileExists变量设置为 flase。

[Two threads working on some code. Thread 1 is loading a file if fileExists is true, and thread 2 is setting fileExists

只要线程 2 先运行,那么文件将被上传。

Diagram showing thread 2 going first and file load succeeding

但是,如果是线程 1 先运行,那么将会打印一条错误日志给用户,告诉用户文件不存在。

Diagram showing thread 1 going first and file load failing

但是,这并不是问题所在,也不是文件不存在导致,真正的问题在于竞用条件。

很多 JavaScript 开发者都遇到过这样的竞用条件,即使在单线程的代码中,你甚至不必去了解任何关于多线程的知识就知道为什么这就是竞用条件。

然后,有一些竞用条件并不发生在单线程的代码中,而是在你多线程编程时发生,这些线程共享内存中某些单元。

Atomics 怎么不避免同类型的竞用条件问题

让我们探索一些在多线程编程中遇到的一些关于竞用条件的例子,并看看 Atomics 是怎样避免其产生的。这些例子也许并不能够完全覆盖所有的竞用条件类型,但是它却在 API 为什么会提供这些方法上给予了你一些启发。

在我们开始前,需要重申一点:你不应该直接使用 Atomics。写多线程的代码是一项艰难的任务,你应该在你的多线程代码中使用一些可靠的库帮你解决共享内存的问题。

Caution sign

就这样...

单线程中的竞用条件

让我们看看下面的例子,你有两个线程都在对同一个变量进行递增。你也许会想无论哪个线程先运行,结果都会是一样的。

Diagram showing two threads incrementing a variable in turn

但是,尽管,在源码里,递增一个变量看上去是一个单步骤操作,但是当你查看编译后的代码时,你会发现递增并非单步骤操作。

在 CPU 层面上,递增一个值需要 3 条指令,这是因为计算机中同时拥有长期内存和短期内存。(我在我另外一篇文章中会阐述他们是如何工作的)

Drawing of a CPU and RAM

所有的线程都共享长期内存,短期内存-也就是寄存器-并不会在不同线程之间共享。

不同线程都需要从内存中获取到值让后放入寄存器中,只有这样,计算机才能够在寄存器中对这些值进行计算,当计算完成后,计算机将计算所得结果从短期内存中取出然后存入长期内存中。

Diagram showing a variable being loaded from memory to a register, then being operated on, and then being stored back to memory

如果线程 1 中所有的操作先运行,然后再是线程 2 的操作进行,那么我们将得到我们想要的结果。

Flow chart showing instructions happening sequentially on one thread, then the other

但是如果线程 1和线程 2 交错运行,也就是说线程2从内存中取出值存入寄存器中而此时线程1的结果还没有存入内存,也就是说线程 2 并没有获取到线程 1 运行的结果然后进行操作,相反,线程 2 只是和线程 1 一样从长期内存中取出相同的值,然后进行计算,最后再把相同的值放入长期内存中。

Flow chart showing instructions interleaved between threads

那些普通人认为是单步骤而计算机视为多步骤的操作,Atomic 所做的事情就是使得计算机也将普通人认为的单步骤操作视为单步骤操作。

这也是为什么他们被称作原子操作。这是因为当计算机进行一个多指令操作时,这些指令可能会被暂停或者重启,而 Atomic 能够使得这些同一操作内的指令立即执行,就好像它们是单一指令一样,这也就像一个单独的原子。

Instructions encased in an atom

当进行原子操作时,进行递增的代码看上去有些不同。

Atomics.add(sabView, index, 1)

现在我们使用 Atomics.add,在对变量进行递增的不同指令在两个线程中将不会交错,而是,一个线程在完成其原子操作之前,另外一个线程不会开始,当之前线程完成原子操作后,第二个线程才启动它的原子操作。

Flow chart showing atomic execution of the instructions

Atomics 提供了如下方法来避免竞用条件的产生:

  • Atomics.add

  • Atomics.sub

  • Atomics.and

  • Atomics.or

  • Atomics.xor

  • Atomics.exchange

你也许已经注意到了上面的列表提供的方法相当有限,它甚至没有包括乘法和除法等。库开发者可以开发出上面列表不包括的一些原子操作。

为了完成上面的新增原子操作,开发者们可以使用 Atomics.compareExchange.通过这个方法,你从 SharedArrayBuffer中获取到值,对其进行操作,只有当其他线程没有对该值进行改变时你才能够将该值写入 SharedArrayBuffers,如果其他线程已经更新了该值,那么你可以获取到新的值,并重新执行之前操作。

在不同操作之间的竞用条件

上面提及的原子操作能够有效的避免“单线程”中的竞用条件,但是有时候你需要改变一个对象上的多个值(也就是需要多个操作)同时需要确保没有其它线程同时在操作该对象。简单来说,这意味着在对一个对象进行某些改变时,该对象对于其它线程是锁定状态,并且无法操作。

Atomics 对象并没有提供任何工具来处理该问题,但是它提供了一些工具,库开发者们可以使用这些工具来解决以上问题,也就是说,库开发者能够开发一个「锁」。

Diagram showing two threads and a lock

如果代码需要获取到被锁的数据,那么首先需要获取到数据的锁。通过该锁锁定数据,是的其他线程无法访问数据,当锁是激活状态时,只有当前线程能够获取并且更新带锁数据。

为了开发这样的一个锁,库开发者需要使用 Atomics。waitAtomics。wake在加上其他的一些方法,不如 Atomics.compareExchangeAtomics.store。如果你想知道这些方法怎么工作的,那么你可以查看一个基本的实现用例

在下面的例子中,线程 2 获取到数据的锁,并且锁定数据,这意味着线程 1 无法访问数据直到线程 2 将该数据解锁。

Thread 2 gets the lock and uses it to lock up shared memory

如果线程 1 想要访问数据,它尝试去获取数据的锁,但是由于该锁依然在被使用,因此线程1无法获取到。那么该线程将会等待,也就是说线程1 将会被阻塞,直到该锁能够被获取到。

Thread 1 waits until the lock is unlocked

一旦线程 2 完成操作,将会解锁数据,该锁将会通知一到多个等待中的线程,告诉他们现在锁能够被重新获取到了。

Thread 1 is notified that the lock is available

后继的线程接手该锁,锁定数据,然后对数据进行操作。

Thread 1 uses the lock

一个拥有锁功能的库可能会对 Atomics 对象使用许多原子操作方法,但是在上面的用例中,最有用的两个方法是:

  • Atomics.wait

  • Atomics.wake

在指令排序过程中的竞用条件

这是第三个 Atomics 能够解决的同步问题,这个问题甚至有些出人意料。

您可能没有意识到,但是很有可能您编写的代码并没有按照您预期的顺序运行。编译器和cpu对代码进行重新排序使其运行得更快。

举个例子,你写了一些代码片段用来计算数字之和。并且你想在计算完之后进行标记。

subTotal = price + fee; total += subTotal; isDone = true

为了编译上面的代码,我们需要决定不同的变量分配不同寄存器。接下来将不同的源码转换成机器指令。

Diagram showing what that would equal in mock assembly

到目前为止,所有的事情按照预期进行。

如果您不了解计算机在芯片级的工作原理(以及它们用于执行代码工作的管道),那么你对上面的描述可能有些不清楚,我们代码中的第2行需要等待第一行运行完成才能执行。

几乎所有的计算机都将运行中的指令分解成不同的步骤,这样保证了CPU的不同部分在同一时间都是在使用状态,这样也保证了充分利用 CPU.

下面是一个关于指令分解成不同步骤的例子:

  • 从内存中获取到下一条指令

  • 弄明白该指令进行什么操作(解码指令),并且将值从寄存器中取出。

  • 执行指令

  • 将结果写回寄存器

Pipeline Stage 1: fetch the instruction

Pipeline Stage 2: decode the instruction and fetch register values

Pipeline Stage 3: Execute the operation

Pipeline Stage 4: Write back the result

上面描述了一条指令是如何运行的。我们希望第二条指令紧跟第一条指令,当第一条指令进入第二阶段时,我们就希望去获取下一天指令了。

问题在于在指令1和指令2之间有依赖关系。

Diagram of a data hazard in the pipeline

我们可以暂停 CPU直到指令1 更新了寄存器中的 subTotal变量。但是这也会使操作变慢。

为了使 CPU更加高效,很多编译器和 CPUs 会记录代码,然后寻找那些不会用到 subTotaltotal的指令,将这些指令提前。

Drawing of line 3 of the assembly code being moved between lines 1 and 2

这样保证了源源不断的指令能够通过管道。

因为第三条指令并不依赖于第一条或者第二条指令返回的结果,因此编译器或者 CPU 计算出将该指令提前是安全的。当你是在运行单线程代码时,其他代码在该函数运行完成之前不会看到运行的结果。

但是当在其他进程中计算机同时运行着其他线程,那么就不一样了,其他线程的代码没有必要等待该函数运行完成并获取到其结果,其他线程甚至在该函数将 isDone写回内存时就能够获取到该值了,也就是说,在写回 total 之前就能够获取到 isDone的值。

如果你通过 isDone来标识 total已经被计算出来并且可以被其他线程使用,那么上面对指令的重新排序将会导致竞用条件。

Atomics 试图解决以上问题,当你使用 Atomics 来写代码时,就好像在两块代码之间加了一个围栏。

Atomics 操作并没有对相关指令重排序,并且其他操作也不会移入这些操作之间,通常情况下,下面两个方法经常被用来确保操作按顺序进行:

  • Atomics.load

  • Atomics.store

在函数源码中,所有在 Atomics.store代码之上的变量都应该在 Atomics.store写回内存之前更新。即使一些非原子指令进行重排序,也是在这些非原子代码之下的 Atomics.store被执行后才能够进行重排序的。

在函数源码中,所有在 Atomics.load之下的代码将被放在 Atomics.load重新获取它的值之后执行,即使对于非原子操作的指令的重排序,也不会被移动到之前就在前面 Atomics.load代码之前。

Diagram showing Atomics.store and Atomics.load maintaining order

注意:上面我展示的循环被称作自旋锁,并且是非常低效的。如果在主线程上,它甚至会将你的应用带向地狱。你应该确保在真实代码中不会用到它。

再次提醒,这些方法不应该在应用开发中直接被使用,相反,库开发者应该使用它们开发出「锁」。共享内存的多线程编程是比较困难的,因为有众多的竞用条件等着你去解决。

Drawing of shared memory with a dragon and "Here be dragons" above

这也是为什么你不希望在应用代码中直接使用 SharedArrayBuffers 和 Atomics 的原因。相反,你应该依赖于经验丰富的多线程开发者开发的库,这些库的作者通常都对内存模型有着深入研究。

现在依然是 SharedArrayBuffers 和 Atomics 启蒙时期,这些库依然还没有被开发出来,但是这些新的 APIs 为这些库的开发提供了坚实的基础。

ES6 Generator 基础指南

ES6 Generator 基础指南

本文翻译自:The Basics Of ES6 Generators

JavaScript ES6(译者注:ECMAScript 2015)中最令人兴奋的特性之一莫过于Generator函数,它是一种全新的函数类型。它的名字有些奇怪,初见其功能时甚至更会有些陌生。本篇文章旨在解释其基本工作原理,并帮助你理解为什么Generator将在未来JS中发挥强大作用。

Generator从运行到完成的工作方式

当我们谈论Generator函数时,我们首先应该注意到的是,从“运行到完成”其和普通的函数表现有什么不同之处。

不论你是否已经意识到,你已经潜意识得认为函数具有一些非常基础的特性:函数一旦开始执行,那么在其结束之前,不会执行其他JavaScript代码。

例如:

setTimeout(function(){
    console.log("Hello World");
},1);

function foo() {
    // NOTE: don't ever do crazy long-running loops like this
    for (var i=0; i<=1E10; i++) {
        console.log(i);
    }
}

foo();
// 0..1E10
// "Hello World"

上面的代码中,for循环会执行相当长的时间,长于1秒钟,但是在foo()函数执行的过程中,我们带有console.log(...)的定时器并不能够中断foo()函数的运行。因此代码被阻塞,定时器被推入事件循环的最后,耐心等待foo函数执行完成。

倘若foo()可以被中断执行?它不会给我们的带来前所未有的浩劫吗?

函数可以被中断对于多线程编程来说确实是一个挑战,但是值得庆幸的是,在JavaScript的世界中我们没必要为此而担心,因为JS总是单线程的(在任何时间只有一条命令/函数被执行)。

注意: Web Workers是JavaScript中实现与JS主线程分离的独立线程机制,总的说来,Web Workers是与JS主线程平行的另外一个线程。在这儿我们并不介绍多线程并发的一个原因是,主线程和Web Workers线程只能够通过异步事件进行通信,因此每个线程内部从运行到结束依然遵循一个接一个的事件循环机制。

运行-停止-运行

由于ES6Generators的到来,我们拥有了另外一种类型的函数,这种函数可以在执行的过程中暂停一次或多次,在将来的某个时间继续执行,并且允许在Generator函数暂停的过程中运行其他代码。

如果你曾经阅读过关于并发或者多线程编程的资料,那你一定熟悉“协程”这一概念,“协程”的意思就是一个进程(就是一个函数)其可以自行选择终止运行,以便可以和其他代码**“协作”**完成一些功能。这一概念和“preemptive”相对,preemptive认为可以在进程/函数外部对其终止运行。

根据ES6 Generator函数的并发行为,我们可以认为其是一种“协程”。在Generator函数体内部,你可以使用yield关键字在函数内部暂停函数的执行,在Generator函数外部是无法暂停一个Generator函数执行的;每当Generator函数遇到一个yield关键字就将暂停执行。

然后,一旦一个Generator函数通过yield暂停执行,其不能够自行恢复执行,需要通过外部的控制来重新启动generator函数,我们将在文章后面部分介绍这是怎么发生的。

基本上,只要你愿意,一个Generator函数可以暂停执行/重新启动任意多次。实际上,你可以再Generator函数内部使用无限循环(比如非著名的while (true) { .. })来使得函数可以无尽的暂停/重新启动。然后这在普通的JS程序中却是疯狂的行径,甚至会抛出错误。但是Generator函数却能够表现的非常明智,有些时候你确实想利用Generator函数这种无尽机制。

更为重要的是,暂停/重新启动不仅仅用于控制Generator函数执行,它也可以在generator函数内部和外部进行双向的通信。在普通的JavaScript函数中,你可以通过传参的形式将数据传入函数内容,在函数内部通过return语句将函数的返回值传递到函数外部。在generator函数中,我们通过yield表达式将信息传递到外部,然后通过每次重启generator函数将其他信息传递给generator。

Generator 函数的语法

然我们看看新奇并且令人兴奋的generator函数的语法是怎样书写的。

首先,新的函数声明语法:

function *foo() {
    // ..
}

发现*符号没?显得有些陌生且有些奇怪。对于从其他语言转向JavaScript的人来说,它看起来很像函数返回值指针。但是不要被迷惑到了,*只是用于标识generator函数而已。

你可能会在其他的文章/文档中看到如下形式书写generator函数function* foo(){},而不是这样function *foo() {}(*号的位置有所不同)。其实两种形式都是合法的,但是最近我认为后面一种形式更为准确,因此在本篇文章中都是使用后面一种形式。

现在,让我们来讨论下generator函数的内部构成吧。在很多方面,generator函数和普通函数无异,只有在generator函数内部有一些新的语法。

正如上面已经提及,我们最先需要了解的就是yield关键字,yield__被视为“yield表达式”(并不是一条语句),因为当我们重新启动generator函数的时候,我们可以传递信息到generator函数内部,不论我们传递什么进去,都将被视为yield__表达式的运行结果。

例如:

function *foo() {
    var x = 1 + (yield "foo");
    console.log(x);
}

yield "foo"表达式会在generator函数暂停时把“foo”字符串传递到外部。同时,当generator函数恢复执行的时候,其他的值又会通过其他表达式传入到函数里面作为yield表达式的返回值加1最后再将结果赋值给x变量。

看到generator函数的双向通信了吗?generator函数将‘’foo‘’字符串传递到外部,暂停函数执行,在将来的某个时间点(可能是立即也可能是很长一段时间后),generator会被重启,并且会传递一个值给generator函数,就好像yield关键字就是某种发送请求获取值的请求形式。

在任意表达式中,你可以仅使用yield关键字,后面不跟任何表达式或值。在这种情况下,就相当于将undefined通过yield传递出去。如下代码:

// note: `foo(..)` here is NOT a generator!!
function foo(x) {
    console.log("x: " + x);
}

function *bar() {
    yield; // just pause
    foo( yield ); // pause waiting for a parameter to pass into `foo(..)`
}

Generator 迭代器

“Generator 迭代器”,是不是相当晦涩难懂?

迭代器是一种特殊的行为,准确说是一种设计模式,当我们通过调用next()方法去遍历一组值的集合时,例如,我们通过在长度为5的数组[1, 2, 3, 4, 5]上面实现了迭代器。当我们第一次调用next()的时候,会返回1。第二次调用next()返回2,如此下去,当所有的值都返回后,再次调用next()将返回null或者false或其他值,这意味着你已经遍历完真个数组中的值了。

我们是通过和generator迭代器进行交互来在generator函数外部控制generator函数,这听起来比起实际上有些复杂,考虑下面这个愚蠢的(简单的)例子:

function *foo() {
    yield 1;
    yield 2;
    yield 3;
    yield 4;
    yield 5;
}

为了遍历*foo()generator函数中的所有值,我们首先需要构建一个迭代器,我们怎么去构建这个迭代器呢?非常简单!

var it = foo();

如此之简单,我们仅仅想执行普通函数一样执行generator函数,其将返回一个迭代器,但是generator函数中的代码并不会运行。

这似乎有些奇怪,并且增加了你的理解难度。你甚至会停下来思考,问为什么不通过var it = new foo()的形式来执行generator函数呢,这语法后面的原因可能相当复杂并超出了我们的讨论范畴。

好的,现在让我们开始迭代我们的generator函数,如下:

var message = it.next();

通过上面的语句,yield表达式将1返回到函数外部,但是返回的值可能比想象中会多一些。

console.log(message); // { value:1, done:false }

在每一调用next()后,我们实际上从yield表达式的返回值中获取到了一个对象,这个对象中有value字段,就是yield返回的值,同时还有一个布尔类型的done字段,其用来表示generator函数是否已经执行完毕。

然我们把迭代执行完成。

console.log( it.next() ); // { value:2, done:false }
console.log( it.next() ); // { value:3, done:false }
console.log( it.next() ); // { value:4, done:false }
console.log( it.next() ); // { value:5, done:false }

有趣的是,当我们获取到值为5的时候,done字段依然是false。这因为,实际上generator函数还么有执行完全,我们还可以再次调用next()。如果我们向函数内部传递一个值,其将被设置为yield 5表达式的返回值,只有在这时候,generator函数才执行完全。

代码如下:

console.log( it.next() ); // { value:undefined, done:true }

所以最终结果是,我们迭代执行完我们的generator函数,但是最终却没有结果(由于我们已经执行完所有的yield__表达式)。

你可能会想,我能不能在generator函数中使用return语句,如果我这样这,返回值会不会在最终的value字段里面呢?

...

function *foo() {
    yield 1;
    return 2;
}

var it = foo();

console.log( it.next() ); // { value:1, done:false }
console.log( it.next() ); // { value:2, done:true }

... 不是.

依赖于generator函数的最终返回值也许并不是一个最佳实践,因为当我们通过for--of循环来迭代generator函数的时候(如下),最终return的返回值将被丢弃(无视)。

为了完整,让我们来看一个同时有双向数据通信的generator函数的例子:

function *foo(x) {
    var y = 2 * (yield (x + 1));
    var z = yield (y / 3);
    return (x + y + z);
}

var it = foo( 5 );

// note: not sending anything into `next()` here
console.log( it.next() );       // { value:6, done:false }
console.log( it.next( 12 ) );   // { value:8, done:false }
console.log( it.next( 13 ) );   // { value:42, done:true }

你可以看到,我们依然可以通过foo(5)传递参数(在例子中是x)给generator函数,就像普通函数一样,是的参数x5.

在第一次执行next(..)的时候,我们并没有传递任何值,为什么?因为在generator内部并没有yield表达式来接收我们传递的值。

假如我们真的在第一次调用next(..)的时候传递了值进去,也不会带来什么坏处,它只是将这个传入的值抛弃而已。ES6表明,generator函数在这种情况只是忽略了这些没有被用到的值。(注意:在写这篇文章的时候,Chrome和FF的每夜版支持这一特性,但是其他浏览有可能没有完全支持这一特性甚至可能会抛出错误)(译者注:文章发布于2014年)

yield(x + 1)表达式将传递值6到外部,在第二次调用next(12)时候,传递12到generator函数内部作为yield(x + 1)表达式的值,因此y被赋值为12 * 2,值为24。接下来,下一条yield(y / 3)(yield (24 / 3))将向外传递值8。第三次调用next(13)传递13到generator函数内部,给yield(y / 3)。是的z被设置为13.

最后,return (x + y + z)就是return (5 + 24 + 13),也就是42将会作为最终的值返回出去。

重新阅读几遍上面的实例。最开始有些难以理解。

for..of循环

ES6在语法层面上大力拥抱迭代器模式,提供了for..of循环来直接支持迭代器的遍历。

例如:

function *foo() {
    yield 1;
    yield 2;
    yield 3;
    yield 4;
    yield 5;
    return 6;
}

for (var v of foo()) {
    console.log( v );
}
// 1 2 3 4 5

console.log( v ); // still `5`, not `6` :(

正如你所见,通过调用foo()生成的迭代器通过for..of循环来迭代,循环自动帮你对迭代器进行遍历迭代,每次迭代返回一个值,直到done: true,只要done: false,每次循环都将从value属性上获取到值赋值给迭代的变量(例子中的v)。一旦当donetrue。循环迭代结束。(for..of循环不会对generator函数最终的return值进行处理)

正如你所看到的,for..of循环忽略了generator最后的return 6的值,同时,循环没有暴露next()出来,因此我们也不能够向generator函数内传递数据。

总结

OK,上面是关于generator函数的基本用法,如果你依然对generator函数感到费解,不要担心,我们所有人在一开始感觉都是那样的。

我们很自然的想到这一外来的语法对我们实际代码有什么作用呢?generator函数有很多作用,我们只是挖掘了其非常粗浅的一部分。在我们发现generator函数如此强大之前我们应该更加深入的了解它。

在你练习上面代码片段之后(在Chrome或者FF每夜版本,或者0.11+带有--harmony的node环境下),下面的问题也许会浮出水面:(译者注:现代浏览器最新版本都已支持Generator函数)

  1. 怎样处理generator内部错误?
  2. 在generator函数内部怎么调用其他generator函数?
  3. 异步代码怎么和generator函数协同工作?

这些问题,或者其他的问题都将在随后的文章中覆盖,敬请期待。

一种自动化生成骨架屏的方案

5aebdbd1066bf

大家好,我今天分享的主题是:「一种自动化生成骨架屏的方案」。

在分享之前,先自我介绍下,我叫罗冉,GitHub 账号是 @Jocs。第一份工作是在欧莱雅做化妆品研发,2015年转行,目前是饿了么的一名前端工程师,主要工作是研究前端加载性能及运行时性能优化。在工作之余,开发一款叫做@marktext 的 Markdown 编辑器。

今天的分享主要分为三个部分:

  • 首屏加载状态演进

  • 如何构建骨架屏

  • 将骨架屏打包的项目中

首屏加载的演进

我们先来看一些权威机构所做的研究报告。

一份是 Akamai 的研究报告,当时总共采访了大约 1048 名网上购物者,得出了这样的结论:

  • 大约有 47% 的用户期望他们的页面在两秒之内加载完成。

  • 如果页面加载时间超过 3s,大约有 40% 的用户选择离开或关闭页面。

5aebdbf2e554f

这是 TagMan 和眼镜零售商 Glasses Direct 合作进行的测试,研究页面加载速度和最终转化率的关系:

5aebc6d543104

在这份测试报告中,发现了网页加载速度和转化率呈现明显的负相关性,在页面加载时间为1~2 秒时的转化率是最高的,而当加载时间继续增长,转化率开始呈现一个下降的趋势,大约页面加载时间每增加 1s 转化率下降6.7个百分点。

另外一份研究报告是 MIT 神经科学家在 2014 年做的研究,人类可以在 13ms 内感知到离散图片的存在,并将图片的大概信息传输到我们的大脑中,在接下来的 100 到 140ms 之间,大脑会决定我们的眼睛具体关注图片的什么位置,也就是获取图片的关注焦点。从另一个角度来看,如果用户进行某项交互(比如点击某按钮),要让用户感知不到延迟或者数据加载,我们大概有 200 ms 的时间来准备新的界面信息呈现给用户。

在 200ms 到 1s 之间,用户似乎还感知不到自己处在交互等待状态,当一秒钟后依然得不到任何反馈,用户将会把其关注的焦点移到其他地方,如果等待超过 10s,用户将对网站失去兴趣,并浏览其他网站。

那么我们需要做些什么来留住用户呢?

通常方案,我们会在首屏、或者获取数据时,在页面中展现一个进度条,或者转动的 Spinner。

  • 进度条:明确知道交互所需时间,或者知道一个大概值的时候我们选择使用进度条。

  • Spinner:无法预测获取数据、或者打开页面的时长。

有了进度条或者 Spinner,至少告诉了用户两点内容:

  • 你所进行的操作需要等待一段时间。

  • 其次,安抚用户,让其耐心等待。

除此之外,进度条和 Spinner 并不能带来其他任何作用,既无法让用户感知到页面加载得更快,也无法给用户一个焦点,让用户将关注集中到这个焦点上,并且知道这个焦点即将呈现用户感兴趣的内容。

那么有没有比进度条和 Spinner 更好的方案呢?也许我们需要的是骨架屏。

5aebdc1b79c1c

其实,骨架屏(Skeleton Screen)已经不是什么新奇的概念了,Luke Wroblewski 早在 2013 年就首次提出了骨架屏的概念,并将这一概念成功得运用到他当时的产品「Polar app」中,2014 年,「Polar」加入 Google,Luke Wroblewski 本人也成为了Google 的一位产品总监。

A skeleton screen is essentially a blank version of a page into which information is gradually loaded.

他是这样定义骨架屏的,他认为骨架屏是一个页面的空白版本,通过这个空白版本传递信息,我们的页面正在渐进式的加载过程中。

苹果公司已经将骨架屏写入到了 iOS Human Interface Guidelines ,只是在该手册中,其用了一个新的概念「launch images」。在该手册中,其推荐在应用首屏中包含文本或者元素基本的轮廓。

2015 年,Facebook 也首次在其移动端 App 中使用了骨架屏的设计来预览页面的加载状态。

5aebdc36418ab

随后,Twitter,Medium,YouTube 也都在其产品设计中添加了骨架屏,骨架屏一时成为了首屏加载的新趋势,国内一些公司也紧随其后,饿了么、知乎、掘金、腾讯新闻等也都在其 PC 端或者移动端加入了骨架屏设计。

为什么需要骨架屏?

  • 在最开始关于 MIT 2014 年的研究中已有提到,用户大概会在 200ms 内获取到界面的具体关注点,在数据获取或页面加载完成之前,给用户首先展现骨架屏,骨架屏的样式、布局和真实数据渲染的页面保持一致,这样用户在骨架屏中获取到关注点,并能够预知页面什么地方将要展示文字什么地方展示图片,这样也就能够将关注焦点移到感兴趣的位置。当真实数据获取后,用真实数据渲染的页面替换骨架屏,如果整个过程在 1s 以内,用户几乎感知不到数据的加载过程和最终渲染的页面替换骨架屏,而在用户的感知上,出现骨架屏那一刻数据已经获取到了,而后只是数据渐进式的渲染出来。这样用户感知页面加载更快了。

  • 再看看现在的前端框架, ReactVueAngular 已经占据了主导地位,市面上大多数前端应用也都是基于这三个框架或库完成,这三个框架有一个共同的特点,都是 JS 驱动,在 JS 代码解析完成之前,页面不会展示任何内容,也就是所谓的白屏。用户是极其不喜欢看到白屏的,什么都没有展示,用户很有可能怀疑网络或者应用出了什么问题。 拿 Vue 来说,在应用启动时,Vue 会对组件中的 data 和 computed 中状态值通过 Object.defineProperty 方法转化成 set、get 访问属性,以便对数据变化进行监听。而这一过程都是在启动应用时完成的,这也势必导致页面启动阶段比非 JS 驱动(比如 jQuery 应用)的页面要慢一些。

如何构建骨架屏

饿了么移动 web 页面在 2016 年开始引入骨架屏,是完全通过 HTML 和 CSS 手写的,手写骨架屏当然可以完全复刻页面的真实样式,但也有弊端:

举个例子,突然有一天,产品经理跑到了我面前,这个页面布局需要调整一下,然后这一块推广内容可以去掉了,我当时的心情可能是这样的。

5aebd1e042a9a

手写骨架屏带来的问题就是,每次需求的变更我们不仅需要修改业务代码, 同时也要去修改骨架屏的样式和布局,这往往是比较机械重复的工作,手写骨架屏增加了维护成本。

因此饿了么前端团队一直在寻找一种更好、更快的将数据呈现到用户面前的方案。

在选择骨架屏之前,我们也调研了其他两种备选方案:服务端渲染(ssr)和预渲染(prerender)。

5aebdc4d74216

现在,前端领域,不同框架下,服务端渲染的技术已经相当成熟,开箱即用的方案也有,比如 Vue 的 Nuxt.js。那么为什么不直接使用服务端渲染来加快内容展现?

首先我们了解到,服务端渲染主要有两个目的,一是 SEO,二是加快内容展现。在带来这两个好处的同时,我们也需要评估服务端渲染的成本,首先我们需要服务端的支持,因此涉及到了到了服务构建、部署等,同时我们的 web 项目是一个流量较大的网站,也需要考虑服务器的负载,以及相应的缓存策略,特别是一些外卖行业,由于地理位置的不同,不同用户看到的页面也是不一样的,也就是所谓的千人千面,这也为缓存造成了一定困难。

5aebdc5f03f06

其次,预渲染(prerender),所谓预渲染,就是在项目的构建过程中,通过一些渲染机制,比如 puppeteer 或则 jsdom 将页面在构建的过程中就渲染好,然后插入到 html 中,这样在页面启动之前首先看到的就是预渲染的页面了。但是该方案最终也抛弃了,预渲染渲染的页面数据是在构建过程中就已经打包到了 html 中, 当真实访问页面的时候,真实数据可能已经和预渲染的数据有了很大的出入,而且预渲染的页面也是一个不可交互的页面,在页面没有启动之前,用户无法和预渲染的页面进行任何交互,预渲染页面中的数据反而会影响到用户获取真实的信息,当涉及到一些价格、金额、地理位置的地方甚至会导致用户做出一些错误的决定。因此我们最终没有选择预渲染方案。

生成骨架屏基本方案

通过 puppeteer 在服务端操控 headless Chrome 打开开发中的需要生成骨架屏的页面,在等待页面加载渲染完成之后,在保留页面布局样式的前提下,通过对页面中元素进行删减或增添,对已有元素通过层叠样式进行覆盖,这样达到在不改变页面布局下,隐藏图片和文字,通过样式覆盖,使得其展示为灰色块。然后将修改后的 HTML 和 CSS 样式提取出来,这样就是骨架屏了。

下面我将通过 page-skeleton-webpack-plugin 工具中的代码,来展示骨架屏的具体生成过程。

正如上面基本方案所描述的那样,我们将页面分成了不同的块:

  • 文本块:仅包含文本节点(NodeType 为 Node.TEXT_NODE)的元素(NodeType 为 Node.ELEMENT_NODE),一个文本块可能是一个 p 元素也可能是 div 等。文本块将会被转化为灰色条纹。

  • 图片块:图片块是很好区分的,任何 img 元素都将被视为图片块,图片块的颜色将被处理成配置的颜色,形状也被修改为配置的矩形或者圆型。

  • 按钮块:任何 button 元素、 type 为 button 的 input 元素,role 为 button 的 a 元素,都将被视为按钮块。按钮块中的文本块不在处理。

  • svg 块:任何最外层是 svg 的元素都被视为 svg 块。

  • 伪类元素块:任何伪类元素都将视为伪类元素块,如 ::before 或者 ::after

  • ...

首先,我们为什么要把页面划分为不同的块呢?

将页面划分为不同的块,然后分别对每个块进行处理,这样不会破坏页面整体的样式和布局,当我们最终生成骨架屏后,骨架屏的布局样式将和真实页面的布局样式完全一致,这样就达到了复用样式及页面布局的目的。

在所有分开处理之前,我们需要完成一项工作,就是将我们生成骨架屏的脚本,插入到 puppeteer 打开的页面中,这样我们才能够执行脚本,并最终生成骨架屏。

值得庆幸的是,puppeteer 在其生成的 page 实例中提供了一个原生的方法。

page.addScriptTag(options)

  • options<Object>

    • url

    • path

    • content

    • type(Use 'module' in order to load a Javascript ES6 module.)

有了这种方法,我们可以插入一段 js 脚本的 url 或者是相对/绝对路径,也可以直接是 js 脚本的内容,在我们的实践过程中,我们直接插入的脚本内容。

  async makeSkeleton(page) {
    const { defer } = this.options
    await page.addScriptTag({ content: this.scriptContent })
    await sleep(defer)
    await page.evaluate((options) => {
      Skeleton.genSkeleton(options)
    }, this.options)
  }

有了上面插入的脚本,并且我们在脚本中提供了一个全局对象 Skeleton,这样我们就可以直接通过 page.evaluate 方法来执行脚本内容并最终生成骨架页面了。

由于时间有限,这儿不会对每个块的生成骨架结构进行详尽分析,这儿可能会重点阐述下文本块、图片块、svg 块如何生成骨架结构的,然后再谈谈如何对骨架结构进行优化。

好,我们再来说下文本块的骨架结构生成。

文本块的骨架结构生成

文本块可以算是骨架屏生成中最复杂的一个区块了,正如上面也说的,任何只包含文本节点的元素都将视为文本块,在确定某个元素是文本块后,下一步就是通过一些 CSS 样式,以及元素的增减将其修改为骨架样式。

5aebdc81eee0f

在这张图中,图左边虚线框内是一个 p 元素,可以看到其内部有 4 行文本,右图是一个已经生成好的带有 4 行文本的骨架屏。在生成文本块骨架屏之前,我们首先需要了解一些基本的参数。

  • 单行文本内容的高度,可以通过 fontSize 获取到。

  • 单行文本内容加空白间隙的高度,可以通过 lineHeight 获取到。

  • p 元素总共有多少行文本,也就是所谓行数,这个可以通过 p 元素的(height - paddingTop - paddingBottom)/ lineHeight 大概算出。

  • 文本的 textAlign 属性。

在这些参数中,fontSize、lineHeight、paddingTop、paddingBottom 都可以通过 getComputedStyle 获取到,而元素的高度 height 可以通过 getBoundingClientRect 获取到,有了这些参数后我们就能够绘制文本块的骨架屏了。

5aebd4c465ec1

相信很多人都读过 @Lea VerouCSS Secrets 这本书,书中有一篇专门阐述怎么通过线性渐变生成条纹背景的文章,而在绘制文本块骨架屏方案,正是受到了这篇文章的启发,文本块的骨架屏也是通过线性渐变来绘制的。核心简化代码看屏幕:

const textHeightRatio = parseFloat(fontSize, 10) / parseFloat(lineHeight, 10)
const firstColorPoint = ((1 - textHeightRatio) / 2 * 100).toFixed(decimal)
const secondColorPoint = (((1 - textHeightRatio) / 2 + textHeightRatio) * 100).toFixed(decimal)

const rule = `{
  background-image: linear-gradient(
    transparent ${firstColorPoint}%, ${color} 0%,
    ${color} ${secondColorPoint}%, transparent 0%);
  background-size: 100% ${lineHeight};
  position: ${position};
  background-origin: content-box;
  background-clip: content-box;
  background-color: transparent;
  color: transparent;
  background-repeat: repeat-y;
}`

我们首先计算了lineHeight 和 fontSize 等一些样式参数,通过这些参数我们计算出了文本占整个行高的比值,也就是 textHeightRadio,有了这一比值,就可以知道灰色条纹的分界点,正如 @lea Verou 所说:

摘自:CSS Secrets
“If a color stop has a position that is less than the specied position of any color stop before it in the list, set its position to be equal to the largest speci ed position of any color stop before it.”
— CSS Images Level 3 (http://w3.org/TR/css3-images)

也就是说,在线性渐变中,如果我们将线性渐变的起始点设置小于前一个颜色点的起始值,或者设置为0 %,那么线性渐变将会消失,取而代之的将是两条颜色分明的条纹,也就是说不再有线性渐变。

在我们绘制文本块的时候,backgroundSize 宽度为 100%, 高度为 lineHeight,也就是灰色条纹加透明条纹的高度是 lineHeight。虽然我们把灰色条纹绘制出来了,但是,我们的文字依然显示,在最终骨架样式效果出现之前,我们还需要隐藏文字,设置 color:‘transparent’ 这样我们的文字就和背景色一致,最终显示得也就是灰色条纹了。

根据 lineCount 我们可以判断文本块是单行文本还是多行,在处理单行文本的时候,由于文本的宽度并没有整行宽度,因此,针对单行文本,我们还需要计算出文本的宽度,然后设置灰色条纹的宽度为文本宽度,这样骨架样式的效果才能够更加接近文本样式。

图片块的骨架生成

图片块的绘制比文本块要相对简单很多,但是在订方案的过程中也踩了一些坑,这儿简单分享下采坑经历。

最初订的方案是通过一个 DIV 元素来替换 IMG 元素,然后设置 DIV 元素背景为灰色,DIV 的宽高等同于原来 IMG 元素的宽高,这种方案有一个严重的弊端就是,原来通过元素选择器设置到 IMG 元素上的样式无法运用到 DIV 元素上面,导致最终图片块的骨架效果和真实的图片在页面样式上有出入,特别是没法适配不同的移动端设备,因为 DIV 的宽高被硬编码。

接下来我们又尝试了一种看似「高级」的方法,通过 Canvas 来绘制和原来图片大小相同的灰色块,然后将 Canvas 转化为 dataUrl 赋予给 IMG 元素的 src 特性上,这样 IMG 元素就显示成了一个灰色块了,看似完美,当我们将生成的骨架页面生成 HTML 文件时,一下就傻眼了,文件大小尽然有 200 多 kb,我们做骨架页面渲染的一个重要原因就是希望用户在感知上感觉页面加载快了,如果骨架页面都有 200 多 kb,必将导致页面加载比之前要慢一些,违背了我们的初衷,因此该方案也只能够放弃。

最终方案,我们选择了将一张1 * 1 像素的 gif 透明图片,转化成 dataUrl ,然后将其赋予给 IMG 元素的 src 特性上,同时设置图片的 width 和 height 特性为之前图片的宽高,将背景色调至为骨架样式所配置的颜色值,完美解决了所有问题。

// 最小 1 * 1 像素的透明 gif 图片
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'

这是1 * 1像素的 base64 格式的图片,总共只有几十个字节,明显比之前通过 Canvas 绘制的图片小很多。

代码看屏幕:

function imgHandler(ele, { color, shape, shapeOpposite }) {
  const { width, height } = ele.getBoundingClientRect()
  const attrs = {
    width,
    height,
    src
  }

  const finalShape = shapeOpposite.indexOf(ele) > -1 ? getOppositeShape(shape) : shape

  setAttributes(ele, attrs)

  const className = CLASS_NAME_PREFEX + 'image'
  const shapeName = CLASS_NAME_PREFEX + finalShape
  const rule = `{
    background: ${color} !important;
  }`
  addStyle(`.${className}`, rule)
  shapeStyle(finalShape)

  addClassName(ele, [className, shapeName])

  if (ele.hasAttribute('alt')) {
    ele.removeAttribute('alt')
  }
}

svg 块骨架结构

svg 块处理起来也比较简单,首先我们需要判断 svg 元素 hidden 属性是否为 true,如果为 true,说明该元素不展示的,所以我们可以直接删除该元素。

if (width === 0 || height === 0 || ele.getAttribute('hidden') === 'true') {
  return removeElement(ele)
}

如果不是隐藏的元素,那么我们将会把 svg 元素内部所有元素删除,减少最终生成的骨架页面体积,其次,设置svg 元素的宽、高和形状等。

const shapeClassName = CLASS_NAME_PREFEX + shape
shapeStyle(shape)

Object.assign(ele.style, {
  width: px2relativeUtil(width, cssUnit, decimal),
  height: px2relativeUtil(height, cssUnit, decimal),
})

addClassName(ele, [shapeClassName])

if (color === TRANSPARENT) {
  setOpacity(ele)
} else {
  const className = CLASS_NAME_PREFEX + 'svg'
  const rule = `{
    background: ${color} !important;
  }`
  addStyle(`.${className}`, rule)
  ele.classList.add(className)
}

一些优化的细节

  • 首先,由上面一些代码可以看出,在我们生成骨架页面的过程中,我们将所有的共用样式通过 addStyle 方法缓存起来,最后在生成骨架屏的时候,统一通过 style 标签插入到骨架屏中。这样保证了样式尽可能多的复用。
  • 其次,在处理列表的时候,为了生成骨架屏尽可能美观,我们对列表进行了同化处理,也就是说将 list 中所有的 listItem 都是同一个 listItem 的克隆。这样生成的 list 的骨架屏样式就更加统一了。
  • 还有就是,正如前文所说,骨架屏仅是一种加载状态,并非真实页面,因此其并不需要完整的页面,其实只需要首屏就好了,我们对非首屏的元素进行了删除,只保留了首屏内部元素,这样也大大缩减了生成骨架屏的体积。
  • 删除无用的 CSS 样式,只是我们只提取了对骨架屏有用的 CSS,然后通过 style 标签引入。

关键代码大致是这样的,看屏幕:

const checker = (selector) => {
  if (DEAD_OBVIOUS.has(selector)) {
    return true
  }
  if (/:-(ms|moz)-/.test(selector)) {
     return true
  }
  if (/:{1,2}(before|after)/.test(selector)) {
    return true
  }
  try {
    const keep = !!document.querySelector(selector)
    return keep
  } catch (err) {
    const exception = err.toString()
    console.log(`Unable to querySelector('${selector}') [${exception}]`, 'error')
    return false
  }
}

可以看出,我们主要通过 document.querySelector 方法来判断该 CSS 是否被使用到,如果该 CSS 选择器能够选择上元素,说明该 CSS 样式是有用的,保留。如果没有选择上元素,说明该 CSS 样式没有用到,所以移除。

在后面的一些 slides 中,我们来聊聊怎讲将构建骨架屏和 webpack 开发、打包结合起来,最终将我们的骨架屏打包到实际项目中。

通过 webpack 将骨架屏打包到项目中

在上一个部分,我们分析了怎么去生成骨架屏,在这一部分,我们将探讨如何通过 webpack 将骨架屏打包的项目中。在这过程中,思考了以下一些问题:

为什么在开发过程中生成骨架屏?

其主要原因还是为了骨架屏的可编辑。

在上一个部分,我们通过一些样式和元素的修改生成了骨架屏页面,但是我们并没有马上将其写入到配置的输出文件夹中,在写入骨架页面到项目之前。我们通过 memory-fs 将骨架屏写入到内存中,以便我们能够通过预览页面进行访问。同时我们也将骨架屏源码发送到了预览页面,这样我们就可以通过修改源码,对骨架屏进行二次编辑。

正如屏幕上这张图片,这张图是插件打开的骨架屏的预览页面,从左到右依次是开发中的真实页面、骨架屏、骨架屏可编辑源码。

5ae439b52c75d

这样我们就可以在开发过程中对骨架屏进行编辑,修改部分样式,中部骨架屏可以进行实时预览,这之间的通信都是通过websocket 来完成的。当我们对生成的骨架屏满意后,并点击右上角写入骨架屏按钮,将骨架屏写入到项目中,在最后项目构建时,将骨架屏打包到项目中。

如果我们同时在构建的过程中生成骨架屏,并打包到项目中,这时的骨架屏我们是无法预览的,因此我们对此时的骨架屏一无所知,也不能够做任何修改,这就是我们在开发中生成骨架屏的原因所在。

演讲最开始已经提到,目前流行的前端框架基本都是 JS 驱动,也就是说,在最初的 index.html 中我们不用写太多的 html 内容,而是等框架启动完成后,通过运行时将内容填充到 html 中,通常我们会在 html 模板中添加一个根元素(看屏幕):

<div id="app"></div>

当应用启动后,会将真实的内容填充到上面的元素中。这也就给了我们一个展示骨架屏的机会,我们将骨架屏在页面启动之前添加到上面元素内(看屏幕):

<div id="app"><!-- shell.html --></div>

我们在项目构建的过程中,将骨架屏 插入到上面代码注释的位置,这样在应用启动前,就是展示的骨架屏,当应用启动后,通过真实数据渲染的页面替换骨架屏页面。

怎样将骨架屏打包到项目中

Webpack 是一款优秀的前端打包工具,其也提供了一些丰富的 API 让我们可以自己编写一些插件来让 webpack 完成更多的工作,比如在构建过程中,将骨架屏打包到项目中。

Webpack 在整个打包的过程中提供了众多生命周期事件,比如compilationafter-emit 等,比如我们最终将骨架屏插入到 html 中就是在after-emit 钩子函数中进行的,简单的代码看下屏幕:

SkeletonPlugin.prototype.apply = function (compiler) {
  // 其他代码
  compiler.plugin('after-emit', async (compilation, done) => {
    try {
      await outputSkeletonScreen(this.originalHtml, this.options, this.server.log.info)
    } catch (err) {
      this.server.log.warn(err.toString())
    }
    done()
  })
  // 其他代码
}

我们再来看看 outputSkeletonScreen 是如何将骨架屏插入到原始的 HTML 中,并且写入到配置的输入文件夹的。

const outputSkeletonScreen = async (originHtml, options, log) => {
  const { pathname, staticDir, routes } = options
  return Promise.all(routes.map(async (route) => {
    const trimedRoute = route.replace(/\//g, '')
    const filePath = path.join(pathname, trimedRoute ? `${trimedRoute}.html` : 'index.html')
    const html = await promisify(fs.readFile)(filePath, 'utf-8')
    const finalHtml = originHtml.replace('<!-- shell -->', html)
    const outputDir = path.join(staticDir, route)
    const outputFile = path.join(outputDir, 'index.html')
    await fse.ensureDir(outputDir)
    await promisify(fs.writeFile)(outputFile, finalHtml, 'utf-8')
    log(`write ${outputFile} successfully in ${route}`)
    return Promise.resolve()
  }))
}

更多思考

Page Skeleton webpack 插件在我们内部团队已经开始使用,在使用的过程中我们也得到了一些反馈信息。

首先是对 SPA 多路由的支持,其实现在插件已经支持多路由了,只是还没有用到真实项目中,我们针对每一个路由页面生成一个单独的 index.html,也就是静态路由。然后将每个路由生成的骨架屏插入到不同的静态路由的 html 中。

其次,玩过服务端渲染的同学都知道,在 React 和 Vue 服务端渲染中有一种称为 Client-side Hydration 的技术,指的是在 Vue 在浏览器接管由服务端发送来的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。

在我们构建骨架屏的过程中,其 DOM 结构和真实页面的 DOM 结构基本相同,只是添加了一些行内样式和 classname,我们也在思考这些 DOM 能够被复用,也就是在应用启动时重新创建所有 DOM。我们只用激活这些骨架屏 DOM,让其能够相应数据的变化,这似乎就可以使骨架屏和真实页面更好的融合。

还有,在页面启动后,我们可能还是会通过 AJAX 获取后端数据,这时候我们也可以通过 骨架屏 来作为一种加载状态。也就是说,其实我们可以在「非首屏骨架屏」上做一些工作。

最后,在项目中可能会有一些性能监控的需求,比如骨架屏什么时候创建,什么时候被销毁,这些我们可能都希望通过一些性能监控的工具记录下来,以便将来做一些性能上面的分析。因此将来也会提供一些骨架屏的生命周期函数,或者提供相应的自定义事件,在生命周期不同阶段,调用相应的生命周期钩子函数或监听相应事件,这样就可以将骨架屏的一些数据记录到性能监控软件中。

网络请求问题指南

网络请求问题解决指南

本文翻译自Network Issues Guide

由于个人水平有限,翻译中难免有纰漏和不足,望不吝指正。

参见起步了解更多关于Chrome Devtool中网络面板的基础知识。

队列中或停滞的请求

症状

Chrome浏览器支持6个请求的并行下载,后面的请求将会推入请求队列中或者停滞不前。一旦前面的六个请求之一完成,队列中的一个请求将会启动。

An example of a queued or stalled series in the Network panel.

Figure 1. 一个网络面板中关于队列或停滞请求的例子。在上图的瀑布图中,你可以看到6个并行的logo-1024px.pngxhr请求。而后面的图片请求将停止不前直到上面某一个请求完成。

原因

同一域名下太多请求发出。在HTTP/1.0或者HTTP/1.1连接下,Chrome对于同一主机支持最多同时6个TCP链接。

解决方案

  • 如果一定要用HTTP/1.0或者HTTP/1.1,可以通过域名分片来解决上述问题。
  • 使用HTTP/2,在HTTP/2协议下不用对域名进行分片。
  • 移除或者延迟不必要的请求来使得一些关键性请求能够更早下载。

Slow Time To First Byte (TTFB)

症状

请求花费很长时间来接受到服务器传来的第一个字节。

An example of a request with a slow Time To First Byte.Figure 2. 上面是一个关于花费长时间从服务器获取到第一个字节的例子。在瀑布图中长长的绿色横柱表示了请求等待了很长时间。

原因

  • 客户端和服务器之间的链接慢。
  • 服务器反应迟缓,在本地启动服务,如果你依然有一个很慢的TTFB,那说明服务器链接或者服务器本身反应很慢。

解决方案

  • 如果是连接缓慢,考虑将你的内容放到CDN上面或者更换服务器提供商。
  • 如果是服务反应慢,考虑优化数据库请求、实施缓存或者更改服务器配置。

下载缓慢

症状

请求中的下载阶段花费很长时间

An example of a request that takes a long time to download.

Figure 3. 上图是一个请求下载花费长时间的例子,在上面的瀑布图中elements-panel.png旁的的一条长长的蓝色横柱表示了花费了很长时间来下载该图片。

原因

  • 客户端和服务器链接缓慢
  • 下载内容过大。

解决方案

  • 考虑将你的内容通过CDN来提供,或者更换服务器提供商。
  • 优化请求、减少下载内容体积。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.