this亦然

this亦然 查看完整档案

成都编辑  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 该用户太懒什么也没留下

个人动态

this亦然 收藏了文章 · 12月1日

正则表达式高级进阶

概述

本文主要通过介绍正则表达式中的一些进阶内容,让读者了解正则表达式在日常使用中用到的比较少但是又比较重要的一部分内容,从而让大家对正则表达式有一个更加深刻的认识。

本文的主要内容为:

  • 正则表达式回溯法原理
  • 正则表达式操作符优先级

本文不介绍相关正则表达式的基本用法,如果对正则表达式的基本使用方法还不了解的同学,可以阅读我的上一篇博客——正则表达式语法入门

回溯法原理

回溯是影响正则表达式效率的一个非常重要的原因,我们在进行正则表达式匹配时,一定要尽可能的避免回溯。

很多人可能只是对听说过“回溯法”,并不了解其中具体内容和原理,接下来就先让我们看下什么是回溯法。

回溯法的定义

回溯法就是指正则表达式从头开始依次进行匹配,如果匹配到某个特定的情况下时,发现无法继续进行匹配,需要回退到之前匹配的结果,选择另一个分支继续进行匹配中的现象。这个描述可能有点抽象,我们举一个简单的例子,让大家能够更加明确的理解回溯法:

const reg = /ab{1,3}c/;

const str = 'abbc';

// 第1步:匹配/a/,得到'a'
// 第2步:匹配/ab{1}/,得到'ab'
// 第3步:匹配/ab{2}/,得到'abb'
// 第4步:匹配/ab{3}/,匹配失败,需要进行回溯
// 第5步:回溯到/ab{2}/,继续匹配/ab{2}c/,得到'abbc'
// 第6步:正则表达式匹配完成,得到'abbc'

如果我们把正则表达式的各个分支都整理成一棵树的话,正则表达式的匹配其实就是一个深度优先搜索算法。而回溯其实就是在进行深度优先匹配失败后的后退正常操作逻辑。

如果退回到了根节点仍然无法匹配的话,就会将index向后移动一位,重新构建匹配数。即/bc/'abc'时,由于第一个字符'a'无法匹配,则移动到'b'开始匹配。

回溯法产生场景

理解了回溯法和回溯操作,接下来我们来看下什么场景下会出现回溯。出现回溯的场景主要有以下几种:

  1. 贪婪量词(贪婪匹配)
  2. 惰性量词(非贪婪匹配)
  3. 分支结构(分支匹配)

接下来,让我们一个一个来看下这些场景是如何出现回溯的。

贪婪量词(贪婪匹配)

const reg = /ab{1,3}c/;

const str = 'abbc';

// 第1步:匹配/a/,得到'a'
// 第2步:匹配/ab{1}/,得到'ab'
// 第3步:匹配/ab{2}/,得到'abb'
// 第4步:匹配/ab{3}/,匹配失败,需要进行回溯
// 第5步:回溯到/ab{2}/,继续匹配/ab{2}c/,得到'abbc'
// 第6步:正则表达式匹配完成,得到'abbc'

最开始的例子其实就是一个贪婪匹配的示例,通过尽可能多的匹配b从而导致了回溯。

惰性量词(非贪婪匹配)

const reg = /ab{1,3}?c/;

const str = 'abbc';

// 第1步:匹配/a/,得到'a'
// 第2步:匹配/ab{1}/,得到'ab'
// 第3步:匹配/ab{1}c/,匹配失败,需要进行回溯
// 第4步:回溯到/ab{1}/,继续匹配/ab{2}/,得到'abb'
// 第5步:匹配/ab{2}c/,得到'abbc'
// 第6步:正则表达式匹配完成,得到'abbc'

与贪婪匹配类似,非贪婪匹配虽然每次都是去最小匹配数目,但是也会出现回溯的情况。

分支结构(分支匹配)

const reg = /(ab|abc)d/;

const str = 'abcd';

// 第1步:匹配/ab/,得到'ab'
// 第2步:匹配/abd/,匹配失败,需要进行回溯
// 第3步:回溯到//,继续匹配/abc/,得到'abc'
// 第4步:匹配/abcd/,得到'abcd'
// 第5步:正则表达式匹配完成,得到'abcd'

通过上面的示例我们可以看到,分支结构在出现两个分支情况类似的时候,也会出现回溯的情况,在这种情况下,如果一个分支无法匹配,则会回到这个分支的最初情况来重新进行匹配。

正则表达式操作符优先级

看完了回溯法,下面我们来了解下关于正则表达式操作符的优先级。

我们直接看结论,然后再根据结论来给大家提供示例进行理解。

操作符描述操作符优先级
转移符\1
小括号和中括号(…)、(?:…)、(?=…)、(?!…)、[…]2
量词限定符{m}、{m,n}、{m,}、?、*、+3
位置和序列^、$、元字符、一般字符4
管道符\5

通过操作符的优先级,我们能够知道如何来读一个正则表达式。以下面这个正则表达式为例,我们来介绍一下按照优先级进行分析的方法:

const reg = /ab?(c|de*)+|fg/;

// 第一步,根据优先级先考虑(c|de*)+,再根据优先级拆分得到c de*,即匹配c或者de*(注意,位置和序列的优先级高于管道符|,所以是c或de*而不是c或d和e*)
// 第二步,得到ab?,根据优先级拆分得到a和b?
// 第三步,得到fg,这个内容和第一步+第二步的结果为或的关系

最终,我们得到的效果如下:

clipboard.png

通过这个图,大家就能够理解我们的分析思路:先找括号,括号中的一定为一个整体(转移符只做转义,不分割正则,因此可以认为第一优先级其实是括号),没有括号后再从左到右按照优先级进行分析。量词限定符则看做是正则的一个整体。

注:如果大家需要话类似的正则表达式流程图,可以使用此网站

根据上面的优先级,我们就能够避免在正则表达式的理解中出现归类错误的情况。

总结

本文通过介绍在正则表达式中容易被忽略的两个内容:回溯法操作优先级,让大家能够在进行正则的阅读和书写过程中避免踩到相关的坑。

参考内容

  1. 《JavaScript正则表达式迷你书》——老姚 V1.1
  2. 《JavaScript权威指南》

我的博客即将搬运同步至腾讯云+社区,邀请大家一同入驻:https://cloud.tencent.com/dev...

查看原文

this亦然 提出了问题 · 11月7日

npm 版本管理的坑

项目中package.json里面一个包依赖是这样

"react-scroll-bar": "^0.0.3-drink.2"

但是每次yarn安装的时候实际安装的是0.0.3-polor.6这个版本,我理解^是安装大范围最新的包,但是按理说我指定了具体tag名(drink),为什么这里会安装到polor这个tag去(难道^的检查跟tag无关???),此外,我检查了lock文件没有问题,把lock全部删除了重新装也没用,所以应该跟yarn.lock无关,求大神解答下??

关注 1 回答 1

this亦然 发布了文章 · 10月18日

前端埋点总结

用户行为分析

随着互联网发展,企业对于网站的PV、UV、用户的转化、新增和留存也越来越关注。而完整的数据采集是一切的前提。

埋点即监控用户在应用表现层的行为,于产品迭代而言至关重要,运营,产品,数据分析基于此来对用户行为进行分析统计,同时埋点也可作为一种前端监控的手段,检验功能是否达预期的佐证。

基于埋点数据进行用户行为分析,可以得到包含页面点击量、用户访问量、用户访问路径、用户转化率、导流转化率、用户访问时长和用户访问内容分析等重要数据。

  • 用户怎么找到该产品的

通过埋点网站访问来源,可以统计用户入口分布,统计什么推广最有效,产品用户的聚集地方分布。

  • 用户感兴趣的是什么

通过产品功能点击的埋点,统计知道用户感兴趣的是什么,便于产品运营更好的更新产品,取消或改进不感兴趣的产品。

  • 用户有什么特征

地理分布浏览器类型、网站停留时常、寻找产品用户群体,针对群体进行改进更新,以及对其他群体进行吸引等等。

  • 其他

通过访问页面的注册用户数和页面 PV 的比值了解用户转化率。
通过导流页面PV和源页面 PV 的比值统计导流转化率。

可埋点数据

type - 上报类型
appid - 设备id
screen - 屏幕信息
userAgent - 浏览器信息
userInfo - 用户身份信息
timestamp - 上报时间
document.referrer - 访问来源
action - 上报事件的动作类型
element - 触发上报的元素
地理位置
访问渠道
以及其他自定义数据params等等

埋点方案

前端埋点大致分为:代码埋点、可视化埋点、无痕埋点三种。

  • 代码埋点也叫手动埋点属于侵入式埋点,由开发手动在代码内植入预埋点,完全由开发控制埋点的位置时间和触发机制。
  • 可视化埋点即以业务代码为输入,通过可视化系统配置埋点,最后以耦合的形式输出业务代码和埋点代码。
  • 无痕埋点即无差别地对全局所有事件和页面加载生命周期等进行拦截全埋点。

代码埋点

  • 使用第三方sdk埋点
如百度统计、友盟、TalkingData、Google Analytics、Sensors Analytics等都提供了这一方案。

使用相对简单,在APP或者界面初始化的时候,初始化第三方数据分析服务商的SDK,然后在某个事件发生时就调用SDK里面相应的数据发送接口发送数据。例如,我们想统计APP里面某个按钮的点击次数,则在APP的某个按钮被点击时,可以在这个按钮对应的 OnClick 函数里面调用SDK提供的数据发送接口来发送数据。

除此针对特定需求也可以统一封装数据上报通用sdk,各页面各业务模块按需调用,同时埋点的形式也是多种多样的

  • 基于事件点击埋点
// 上报sdk
export const sdk = {
  params: null,
  initParams() {
    const params={};
    params.domain = document.domain || '';
    params.title = document.title || '';
    params.referrer = document.referrer || '';
    params.sw = window.screen.width || 0;
    params.sh = window.screen.height || 0;
    params.lang = navigator.language || '';
    params.ua = navigator.userAgent || '';
    params.loadT = window.performance.timing.domContentLoadedEventEnd - window.performance.timing.navigationStart || 0;
    params.timestamp= new Date();
    sdk.params = params;
  },
  report(params = {}) {
    // 上报
     if(!sdk.params){
        sdk.initParams();
    }
    const _params = merge({},sdk.params,params);
    request('/api/report',{params:_params});
  }
};

// react wapper组件式
// 封装埋点包裹组件
export default function TrackerClick(props) {
  const { children, type } = props;

  return React.Children.map(children, child => {
    React.cloneElement(child, {
      onClick: (e) => {
        const originClick = child.props.onClick;
        typeof originClick==='function' && originClick.call(child, e);
        sdk.dispatch({type});
      }
    })
  });
}

// 页面使用
<TrackerClick type="namespace.click">
    <Button onClick={handleClick}>查看</Button>
</TrackerClick>
// 通用方式
//上报事件的绑定类型对应的绑定名称
const REPORT_EVENT_FUNC = 'data-reporteventfunc';
//上报事件数据对应的绑定参数名称
const REPORT_EVENT_DATA = 'data-reporteventdata';
document.body.addEventListener('click',function(e){
   if(e.target.getAttribute(REPORT_EVENT_FUNC)==='click'){
        const str=e.target.getAttribute(REPORT_EVENT_DATA);
        sdk.report(JSON.stringify(str));
   }
})

// 页面-react
<span 
    data-reporteventfunc="click" 
    data-reporteventdata={JSON.stringify({code:1,id:2})}></span>
// 页面-vue
<span 
    data-reporteventfunc="click" 
    :data-reporteventdata="JSON.stringify({code:1,id:2})"></span>
// 使用装饰器,剥离埋点与业务逻辑实现上的耦合,实现低侵入埋点
@tracker((params)=>request('/api/report',{params}))
click(params){
 // click业务...
}

const tracker = partical=>(target, key, descriptor)=>{
    if (typeof partical!=='function') {
        throw new Error('tracker arguments is not a function ' + partical)
    }
    const oldValue=descriptor.value;
    descriptor.value=function(...args){
        partical.apply(this,args);
        return oldValue.apply(this,args);
    }
    return descriptor;
}
  • 页面访问埋点-统计页面曝光时长
// Vue中通过mixin
beforeRouteEnter(to, from, next) {
    this.enterTime=+ new Date();
},
beforeRouteLeave(to, from, next) {
     sdk.report({
         type: 'visit',
         name: to.name,
         enterTime: this.enterTime,
         leaveTime: +new Date(),
         params: {
             from: {
                 name: from.name,
                 path: from.path,
                 query: from.query
             },
             to: {
                 name: to.name,
                 path: to.path,
                 query: to.query
             },
         } 
     })
}
传统基于DOMContentLoaded、beforeunload、onload等也可以实现
  • css埋点
<style>
.tracker:active::after{ 
    content: url("http://www.yzw.com/api/tracker/report?action=yourdata"); 
}
</style>
<a class="tracker">点击我,会发埋点数据</a>

埋点数据上报的形式

  • xhr上报

适用于需要接受数据上报后的返回结果进行回调处理

  • img/iframe/script上报
sdk.report=(params){
    // 1.img标签
    var img = document.createElement("img");
    img.src = '/api/report?' + querystring.stringify(params);
    // 2.img对象
    const img = new Image();
    img.data-original='/api/report?' + querystring.stringify(params);
    // 3.script标签
    var script = document.createElement("script");
    script.src = src;
    (document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]).appendChild(script);
}

可视化埋点

方案有Mixpanel、TalkingData、诸葛IO、腾讯MTA,Sensors AnalyticsV1.3+等

可视化埋点通常流程为:

输入页面的url =>
页面加载完成后 =>
配置可视化的工具 =>
点击创建事件(click) =>
进入元素选择模式 =>
用鼠标点击页面上的某个元素(例如button、a这些element)=>
就可以在弹出的对话框里面 =>
设置这个事件的名称(比如叫TEST),选上报数据属性(properties)=>
保存配置 =>
用户访问点击按钮 =>
数据上报

其中针对元素标记多是利用xpath,是在xml文档中查找信息的语言,如下所示

const getPath = function (elem) {
     if (elem.id != '') {
        return '//*[@id="' + elem.id + '"]';
     } 
     if (elem == document.body) {
        return '/html/' + elem.tagName.toLowerCase();
     } 
     let index = 1, siblings = elem.parentNode.childNodes;
     for (const i = 0, len = siblings.length; i < len; i++) {
        const sibling = siblings[i];
         if (sibling === elem) {
            return arguments.callee(elem.parentNode) + '/' + elem.tagName.toLowerCase() + '[' + (index) + ']';
         } else if (sibling.nodeType === 1 && sibling.tagName === elem.tagName) {
            index++;
         } 
     }
 }
通过上述方法,当我们点击某个元素时,将触发的元素event.target传入,即可得到完整的xpath。
如果将其换做dom的选择器,类似:div#container>div:nth-of-type(2)>p:nth-of-type(1),由此,可以定位到具体的DOM节点

mixpanel 分为三层结构:

  • 基本工具层:提供类型判断、遍历、继承、bind 等基本的工具函数;json、base64、utf8 编解码能力;url 参数读写函数;cookie、localStorage 读写能力;dom 事件绑定能力;dom 节点查询能力;info 浏览器信息获取能力。
  • 功能模块层:提供基于 DomTracker 实现的 LinkTracker 跳链接埋点、FormTracker 提交数据埋点功能;autotrack 自动埋点功能;基于 cookie 或 localStorage 的 MixpanelPersistence 持久化功能;MixpanelNotification 提示功能;gdbr 依据欧盟《通用数据保护条例》,首先判断用户是否设置了 navigator.doNotTrack 避免数据被追踪,其次判断持久层是否禁止数据被追踪,当两者同时允许追踪埋点数据时,mixpanel 才会上报埋点数据。
  • 核心实现层:MixpanelLib 串联功能、处理选项、发送埋点数据等;MaxpanelGroup;MaxpanelPeople

Sensors Analytics

使用者在自己的网页引入 Sensors Analytics 的 JavaScript SDK 代码后,从 Sensors Analytics 的后台可视化埋点管理界面跳转到使用者的网站界面时,会自动进入到可视化埋点模式。在这个模式下,使用者在网页上点击任意 html元素时,Sensors Analytics 都会取到这个元素的url,层级关系等信息来描述这个 html 元素,当使用者设置了这个元素和某个事件相关联时,SDK 会把这些关联信息和客户生成配置信息,并且存放在 Sensors Analytics 提供的相应保存位置。当真正的用户以普通模式访问这个网页时,SDK 会自动加载配置信息,从而在相应的元素被点击时,使用 Sensors Analytics 的数据发送接口来 track 事件。

从上面我们介绍的可视化埋点的方案可以看出,可视化埋点很好地解决了代码埋点的埋点代价大和更新代价大两个问题。但是,可视化埋点能够覆盖的功能有限,目前并不是所有的控件操作都可以通过这种方案进行定制;同时,Mixpanel 为首的可视化埋点方案是不能自己设置属性的,例如,一个界面上有一个文本框和一个按钮,通过可视化埋点设置点击按钮为一个“提交”事件时,并不能将文本框的内容作为事件的属性进行上传的,因此,对于可视化埋点这种方案,在上传事件时,就只能上传 SDK 自动收集的设备、地域、网络等默认属性,以及一些通过代码设置的全局公共属性了;最后,作为前端埋点的一种方案,可视化埋点也依然没有解决传输时效性和数据可靠性的问题。

无埋点

Heap、百度(点击猴子)、GrowingIO等
与可视化埋点又类似,二者的区别就是可视化埋点先通过界面配置哪些控件的操作数据需要收集;“无埋点”则是先尽可能收集所有的控件的操作数据,然后再通过界面配置哪些数据需要在系统里面进行分析。

“无埋点”相比可视化埋点的优点,一方面是解决了数据“回溯”的问题,例如,在某一天,突然想增加某个控件的点击的分析,如果是可视化埋点方案,则只能从这一时刻向后收集数据,而如果是“无埋点”,则从部署 SDK 的时候数据就一直都在收集了;另一方面,“无埋点”方案也可以自动获取很多启发性的信息,例如,“无埋点”可以告诉使用者这个界面上每个控件分别被点击的概率是多大,哪些控件值得做更进一步的分析等等。

当然,与可视化埋点一样,“无埋点”依然没有解决覆盖的功能优先,不能灵活地自定义属性,传输时效性和数据可靠性欠佳这几个缺点。甚至由于所有的控件事件都全部搜集,反而会给服务器和网络传输带来更大的负载。

技术实现上也可以通过拦截全局页面访问和事件响应,分析用户访问全流程路径,上报所有触发埋点,因此无埋点也叫全埋点。
aHR0cHM6Ly9pbWcyMDE4LmNuYmxvZ3MuY29tL2Jsb2cvMTY4MzQyMS8yMDE5MDUvMTY4MzQyMS0yMDE5MDUyNDE2MzYzMjQ5NS0yMTI2ODU4OTA3LnBuZw==.png

总结

&nbsp代码埋点可视化埋点无埋点
优点可控性强,灵活性高,可定制各种特殊埋点需求,监测数据准确。通过集成sdk,运营可自主选择,操作便捷,满足大部分场景数据全面,不需要关注埋点逻辑,前端开发量轻
缺点侵入型强,需要开发手动在相应位置进行埋点,增加维护成本通常需要引入第三方,控件有限,技术上推广和实现起来有难度,需要运营配合流量和采集的数据过于庞大,存在浪费、服务器性能压力大、难以特殊化定制
适用场景适用于埋点量少、定制化程度高的需求埋点量多,需要对数据深度整合分析网站需要全埋点监控

每种方案各有优劣,并不存在某种普遍完美的可以适应一切场景的埋点方案,而是应该根据不同的产品,不同的分析需求,不同的系统架构,不同的使用场景,选择最合适的一种接入方案。下面是一些典型的例子:

  1. 仅仅是分析UV、PV、点击量等基本指标,可以选择代码埋点或者可视化埋点等前端埋点方案;
  2. 精细化分析核心转化流程,则可能需要利用后端 SDK 或者 LogAgent 接入后端日志;
  3. 活动/新功能快速上线迭代时的效果评估,则可以利用可视化埋点快速完成;
  4. 对客服服务质量的考核,或者不同快递在不同省份运送不同品类产品的速度的比较,则需要使用后端 SDK 来对接第三方系统以便导入数据。
查看原文

赞 15 收藏 13 评论 0

this亦然 赞了文章 · 10月10日

一文了解文件上传全过程(1.8w字深度解析,进阶必备)

前言

平常在写业务的时候常常会用的到的是 GET, POST请求去请求接口,GET 相关的接口会比较容易基本不会出错,而对于 POST中常用的 表单提交,JSON提交也比较容易,但是对于文件上传呢?大家可能对这个步骤会比较害怕,因为可能大家对它并不是怎么熟悉,而浏览器Network对它也没有详细的进行记录,因此它成为了我们心中的一根刺,我们老是无法确定,关于文件上传到底是我写的有问题呢?还是后端有问题,当然,我们一般都比较谦虚, 总是会在自己身上找原因,可是往往实事呢?可能就出在后端身上,可能是他接受写的有问题,导致你换了各种请求库去尝试,axiosrequestfetch 等等。那么我们如何避免这种情况呢?我们自身要对这一块够熟悉,才能不以猜的方式去写代码。如果你觉得我以上说的你有同感,那么你阅读完这篇文章你将收获自信,你将不会质疑自己,不会以猜的方式去写代码。

本文比较长可能需要花点时间去看,需要有耐心,我采用自顶向下的方式,所有示例会先展现出你熟悉的方式,再一层层往下, 先从请求端是怎么发送文件的,再到接收端是怎么解析文件的。

前置知识

什么是 multipart/form-data?

multipart/form-data 最初由 《RFC 1867: Form-based File Upload in HTML》文档提出。

Since file-upload is a feature that will benefit many applications, this proposes an extension to HTML to allow information providers to express file upload requests uniformly, and a MIME compatible representation for file upload responses.

由于文件上传功能将使许多应用程序受益,因此建议对HTML进行扩展,以允许信息提供者统一表达文件上传请求,并提供文件上传响应的MIME兼容表示。

总结就是原先的规范不满足啦,我要扩充规范了。

文件上传为什么要用 multipart/form-data?

The encoding type application/x-www-form-urlencoded is inefficient for sending large quantities of binary data or text containing non-ASCII characters. Thus, a new media type,multipart/form-data, is proposed as a way of efficiently sending the values associated with a filled-out form from client to server.

1867文档中也写了为什么要新增一个类型,而不使用旧有的application/x-www-form-urlencoded:因为此类型不适合用于传输大型二进制数据或者包含非ASCII字符的数据。平常我们使用这个类型都是把表单数据使用url编码后传送给后端,二进制文件当然没办法一起编码进去了。所以multipart/form-data就诞生了,专门用于有效的传输文件。

也许你有疑问?那可以用 application/json吗?

其实我认为,无论你用什么都可以传,只不过会要综合考虑一些因素的话,multipart/form-data更好。例如我们知道了文件是以二进制的形式存在,application/json 是以文本形式进行传输,那么某种意义上我们确实可以将文件转成例如文本形式的 Base64 形式。但是呢,你转成这样的形式,后端也需要按照你这样传输的形式,做特殊的解析。并且文本在传输过程中是相比二进制效率低的,那么对于我们动辄几十M几百M的文件来说是速度是更慢的。

以上为什么文件传输要用multipart/form-data 我还可以举个例子,例如你在中国,你想要去美洲,我们的multipart/form-data相当于是选择飞机,而application/json相当于高铁,但是呢?中国和美洲之间没有高铁啊,你执意要坐高铁去,你可以花昂贵的代价(后端额外解析你的文本)造高铁去美洲,但是你有更加廉价的方式坐飞机(使用multipart/form-data)去美洲(去传输文件)。你图啥?(如果你有钱有时间,抱歉,打扰了,老子给你道歉)

multipart/form-data规范是什么?

摘自 《RFC 1867: Form-based File Upload in HTML》 6.Example

Content-type: multipart/form-data, boundary=AaB03x

--AaB03x
content-disposition: form-data; name="field1"
Joe Blow
--AaB03x
content-disposition: form-data; name="pics"; filename="file1.txt"
Content-Type: text/plain

... contents of file1.txt ...
--AaB03x-- 

可以简单解释一些,首先是请求类型,然后是一个 boundary (分割符),这个东西是干啥的呢?其实看名字就知道,分隔符,当时分割作用,因为可能有多文件多字段,每个字段文件之间,我们无法准确地去判断这个文件哪里到哪里为截止状态。因此需要有分隔符来进行划分。然后再接下来就是声明内容的描述是 form-data 类型,字段名字是啥,如果是文件的话,得知道文件名是啥,还有这个文件的类型是啥,这个也很好理解,我上传一个文件,我总得告诉后端,我传的是个啥,是图片?还是一个txt文本?这些信息肯定得告诉人家,别人才好去进行判断,后面我们也会讲到如果这些没有声明的时候,会发生什么?

好了讲完了这些前置知识,我们接下来要进入我们的主题了。面对File, formData,Blob,Base64,ArrayBuffer,到底怎么做?还有文件上传不仅仅是前端的事。服务端也可以文件上传(例如我们利用某云,把静态资源上传到 OSS 对象存储)。服务端和客户端也有各种类型,Buffer,Stream,Base64....头秃,怎么搞?不急,就是因为上传文件不单单是前端的事,所以我将以下上传文件的一方称为请求端,接受文件一方称为接收方。我会以请求端各种上传方式,接收端是怎么解析我们的文件以及我们最终的杀手锏调试工具-wireshark来进行讲解。以下是讲解的大纲,我们先从浏览器端上传文件,再到服务端上传文件,然后我们再来解析文件是如何被解析的。

file-upload

请求端

浏览端

File

首先我们先写下最简单的一个表单提交方式。

<form action="http://localhost:7787/files" method="POST">
    <input name="file" type="file" id="file">
    <input type="submit" value="提交">
</form> 

我们选择文件后上传,发现后端返回了文件不存在。

image-20200328191433694

不用着急,熟悉的同学可能立马知道是啥原因了。嘘,知道了也听我慢慢叨叨。

我们打开控制台,由于表单提交会进行网页跳转,因此我们勾选preserve log 来进行日志追踪。

image-20200328191807526

image-20200328191733536

我们可以发现其实 FormDatafile 字段显示的是文件名,并没有将真正的内容进行传输。再看请求头。

image-20200328192020599

发现是请求头和预期不符,也印证了 application/x-www-form-urlencoded 无法进行文件上传。

我们加上请求头,再次请求。

<form action="http://localhost:7787/files" enctype="multipart/form-data" method="POST">
  <input name="file" type="file" id="file">
  <input type="submit" value="提交">
</form> 

image-20200328192539734

发现文件上传成功,简单的表单上传就是像以上一样简单。但是你得熟记文件上传的格式以及类型。

FormData

formData 的方式我随便写了以下几种方式。

<input type="file" id="file">
<button id="submit">上传</button>
<script data-original="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script>
<script> submit.onclick = () => {
    const file = document.getElementById('file').files[0];
    var form = new FormData();
    form.append('file', file);
  
    // type 1
    axios.post('http://localhost:7787/files', form).then(res => {
        console.log(res.data);
    })
    // type 2
    fetch('http://localhost:7787/files', {
        method: 'POST',
        body: form
    }).then(res => res.json()).tehn(res => {console.log(res)});
    // type3
    var xhr = new XMLHttpRequest();
    xhr.open('POST', 'http://localhost:7787/files', true);
    xhr.onload = function () {
        console.log(xhr.responseText);
    };
    xhr.send(form);
} </script> 

image-20200328192539734

以上几种方式都是可以的。但是呢,请求库这么多,我随便在 npm 上一搜就有几百个请求相关的库。

image-20200328194431932

因此,掌握请求库的写法并不是我们的目标,目标只有一个还是掌握文件上传的请求头和请求内容。

image-20200328194625420

Blob

Blob 对象表示一个不可变、原始数据的类文件对象。Blob 表示的不一定是JavaScript原生格式的数据。File 接口基于Blob,继承了 blob 的功能并将其扩展使其支持用户系统上的文件。

因此如果我们遇到 Blob 方式的文件上方式不用害怕,可以用以下两种方式:

1.直接使用 blob 上传

const json = { hello: "world" };
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });
    
const form = new FormData();
form.append('file', blob, '1.json');
axios.post('http://localhost:7787/files', form); 

2.使用 File 对象,再进行一次包装(File 兼容性可能会差一些 https://caniuse.com/#search=File)

const json = { hello: "world" };
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });
    
const file = new File([blob], '1.json');
form.append('file', file);
axios.post('http://localhost:7787/files', form) 

ArrayBuffer

ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区。

虽然它用的比较少,但是他是最贴近文件流的方式了。

在浏览器中,他每个字节以十进制的方式存在。我提前准备了一张图片。

const bufferArrary = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,1,3,0,0,0,37,219,86,202,0,0,0,6,80,76,84,69,0,0,255,128,128,128,76,108,191,213,0,0,0,9,112,72,89,115,0,0,14,196,0,0,14,196,1,149,43,14,27,0,0,0,10,73,68,65,84,8,153,99,96,0,0,0,2,0,1,244,113,100,166,0,0,0,0,73,69,78,68,174,66,96,130];
const array = Uint8Array.from(bufferArrary);
const blob = new Blob([array], {type: 'image/png'});
const form = new FormData();
form.append('file', blob, '1.png');
axios.post('http://localhost:7787/files', form) 

这里需要注意的是 new Blob([typedArray.buffer], {type: 'xxx'}),第一个参数是由一个数组包裹。里面是 typedArray 类型的 buffer。

Base64

const base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAABlBMVEUAAP+AgIBMbL/VAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAACklEQVQImWNgAAAAAgAB9HFkpgAAAABJRU5ErkJggg==';
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
    byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const array = Uint8Array.from(byteNumbers);
const blob = new Blob([array], {type: 'image/png'});
const form = new FormData();
form.append('file', blob, '1.png');
axios.post('http://localhost:7787/files', form); 

关于 base64 的转化和原理可以看这两篇 base64 原理

原来浏览器原生支持JS Base64编码解码

小结

对于浏览器端的文件上传,可以归结出一个套路,所有东西核心思路就是构造出 File 对象。然后观察请求 Content-Type,再看请求体是否有信息缺失。而以上这些二进制数据类型的转化可以看以下表。

transform.77175c26

图片来源 (https://shanyue.tech/post/binary-in-frontend/#%E6%95%B0%E6%8D%AE%E8%BE%93%E5%85%A5)

服务端

讲完了浏览器端,现在我们来讲服务器端,和浏览器不同的是,服务端上传有两个难点。

1.浏览器没有原生 formData,也不会想浏览器一样帮我们转成二进制形式。

2.服务端没有可视化的 Network 调试器。

Buffer

Request

首先我们通过最简单的示例来进行演示,然后一步一步深入。相信文档可以查看 https://github.com/request/request#multipartform-data-multipart-form-uploads

// request-error.js
const fs = require('fs');
const path = require('path');
const request = require('request');
const stream = fs.readFileSync(path.join(__dirname, '../1.png'));
request.post({
    url: 'http://localhost:7787/files',
    formData: {
        file: stream,
    }
}, (err, res, body) => {
    console.log(body);
}) 

image-20200328234106276

发现报了一个错误,正像上面所说,浏览器端报错,可以用NetWork。那么服务端怎么办?这个时候我们拿出我们的利器 -- wireshark

我们打开 wireshark (如果没有或者不会的可以查看教程 https://blog.csdn.net/u013613428/article/details/53156957)

设置配置 tcp.port == 7787,这个是我们后端的端口。

image-20200328234316233

运行上述文件 node request-error.js

image-20200328234543643

我们来找到我们发送的这条http的请求报文。中间那堆乱七八糟的就是我们的文件内容。

POST /files HTTP/1.1
host: localhost:7787
content-type: multipart/form-data; boundary=--------------------------437240798074408070374415
content-length: 305
Connection: close

----------------------------437240798074408070374415
Content-Disposition: form-data; name="file"
Content-Type: application/octet-stream

.PNG
.
...
IHDR.............%.V.....PLTE......Ll.....  pHYs..........+.....
IDAT..c`.......qd.....IEND.B`.
----------------------------437240798074408070374415-- 

可以看到上述报文。发现我们的内容请求头 Content-Type: application/octet-stream有错误,我们上传的是图片请求头应该是image/png,并且也少了 filename="1.png"

我们来思考一下,我们刚才用的是fs.readFileSync(path.join(__dirname, '../1.png')) 这个函数返回的是 BufferBuffer是什么样的呢?就是下面的形式,不会包含任何文件相关的信息,只有二进制流。

<Buffer 01 02> 

所以我想到的是,需要指定文件名以及文件格式,幸好 request 也给我们提供了这个选项。

key: {
    value:  fs.createReadStream('/dev/urandom'),
    options: {
      filename: 'topsecret.jpg',
      contentType: 'image/jpeg'
    }
} 

可以指定options,因此正确的代码应该如下(省略不重要的代码)

...
request.post({
    url: 'http://localhost:7787/files',
    formData: {
        file: {
            value: stream,
            options: {
                filename: '1.png'
            }
        },
    }
}); 

我们通过抓包可以进行分析到,文件上传的要点还是规范,大部分的问题,都可以通过规范模板来进行排查,是否构造出了规范的样子。

Form-data

我们再深入一些,来看看 request 的源码, 他是怎么实现Node端的数据传输的。

打开源码我们很容易地就可以找到关于 formData 这块相关的内容 https://github.com/request/request/blob/3.0/request.js#L21

image-20200328235629308

就是利用form-data,我们先来看看 formData 的方式。

const path = require('path');
const FormData = require('form-data');
const fs = require('fs');
const http = require('http');
const form = new FormData();
form.append('file', fs.readFileSync(path.join(__dirname, '../1.png')), {
    filename: '1.png',
    contentType: 'image/jpeg',
});
const request = http.request({
    method: 'post',
    host: 'localhost',
    port: '7787',
    path: '/files',
    headers: form.getHeaders()
});
form.pipe(request);
request.on('response', function(res) {
    console.log(res.statusCode);
}); 
原生 Node

看完 formData,可能感觉这个封装还是太高层了,于是我打算对照规范手动来构造multipart/form-data请求方式来进行讲解。我们再来回顾一下规范。

Content-type: multipart/form-data, boundary=AaB03x

--AaB03x
content-disposition: form-data; name="field1"
Joe Blow
--AaB03x
content-disposition: form-data; name="pics"; filename="file1.txt"
Content-Type: text/plain

... contents of file1.txt ...
--AaB03x-- 

我模拟上方,我用原生 Node 写出了一个multipart/form-data 请求的方式。

主要分为4个部分
  • 构造请求header
  • 构造内容header
  • 写入内容
  • 写入结束分隔符
const path = require('path');
const fs = require('fs');
const http = require('http');
// 定义一个分隔符,要确保唯一性
const boundaryKey = '-------------------------461591080941622511336662';
const request = http.request({
    method: 'post',
    host: 'localhost',
    port: '7787',
    path: '/files',
    headers: {
        'Content-Type': 'multipart/form-data; boundary=' + boundaryKey, // 在请求头上加上分隔符
        'Connection': 'keep-alive'
    }
});
// 写入内容头部
request.write(
    `--${boundaryKey}rnContent-Disposition: form-data; name="file"; filename="1.png"rnContent-Type: image/jpegrnrn`
);
// 写入内容
const fileStream = fs.createReadStream(path.join(__dirname, '../1.png'));
fileStream.pipe(request, { end: false });
fileStream.on('end', function () {
    // 写入尾部
    request.end('rn--' + boundaryKey + '--' + 'rn');
});
request.on('response', function(res) {
    console.log(res.statusCode);
}); 

至此,已经实现服务端上传文件的方式。

Stream、Base64

由于这两块就是和Buffer的转化,比较简单,我就不再重复描述了。可以作为留给大家的作业,感兴趣的可以给我这个示例代码仓库贡献这两个示例。

// base64 to buffer
const b64string = /* whatever */;
const buf = Buffer.from(b64string, 'base64'); 
// stream to buffer
function streamToBuffer(stream) {  
  return new Promise((resolve, reject) => {
    const buffers = [];
    stream.on('error', reject);
    stream.on('data', (data) => buffers.push(data))
    stream.on('end', () => resolve(Buffer.concat(buffers))
  });
} 

小结

由于服务端没有像浏览器那样 formData 的原生对象,因此服务端核心思路为构造出文件上传的格式(header,filename等),然后写入 buffer 。然后千万别忘了用 wireshark进行验证。

接收端

这一部分是针对 Node 端进行讲解,对于那些 koa-body 等用惯了的同学,可能一样不太清楚整个过程发生了什么?可能唯一比较清楚的是 ctx.request.files ??? 如果ctx.request.files 不存在,就会懵逼了,可能也不太清楚它到底做了什么,文件流又是怎么解析的。

我还是要说到规范...请求端是按照规范来构造请求..那么我们接收端自然是按照规范来解析请求了。

Koa-body

const koaBody = require('koa-body');

app.use(koaBody({ multipart: true })); 

我们来看看最常用的 koa-body,它的使用方式非常简单,短短几行,就能让我们享受到文件上传的简单与快乐(其他源码库一样的思路去寻找问题的本源) 可以带着一个问题去阅读,为什么用了它就能解析出文件?

寻求问题的本源,我们当然要打开 koa-body的源码,koa-body 源码很少只有211行,https://github.com/dlau/koa-body/blob/v4.1.1/index.js#L125 很容易地发现它其实是用了一个叫做formidable的库来解析 files 的。并且把解析好的 files 对象赋值到了 ctx.req.files。(所以说大家不要一味死记 ctx.request.files, 注意查看文档,因为今天用 koa-bodyctx.request.files 明天换个库可能就是 ctx.request.body 了)

因此看完 koa-body我们得出的结论是,koa-body的核心方法是formidable

Formidable

那么让我们继续深入,来看看formidable做了什么,我们首先来看它的目录结构。

.
├── lib
│   ├── file.js
│   ├── incoming_form.js
│   ├── index.js
│   ├── json_parser.js
│   ├── multipart_parser.js
│   ├── octet_parser.js
│   └── querystring_parser.js 

看到这个目录,我们大致可以梳理出这样的关系。

index.js
|
incoming_form.js
|
type
?
|
1.json_parser
2.multipart_parser
3.octet_parser
4.querystring_parser 

由于源码分析比较枯燥。因此我只摘录比较重要的片段。由于我们是分析文件上传,所以我们只需要关心 multipart_parser 这个文件。

https://github.com/node-formidable/formidable/blob/v1.2.1/lib/multipart_parser.js#L72

...
MultipartParser.prototype.write = function(buffer) {
    console.log(buffer);
  var self = this,
      i = 0,
      len = buffer.length,
      prevIndex = this.index,
      index = this.index,
      state = this.state,
... 

我们将它的 buffer 打印看看.

<Buffer 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 34 36 31 35 39 31 30 38 30 39 34 31 36 32 32 35 31 31 33 33 36 36 36 ... >
144
<Buffer 89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52 00 00 00 01 00 00 00 01 01 03 00 00 00 25 db 56 ca 00 00 00 06 50 4c 54 45 00 00 ff 80 80 80 4c 6c bf ... >
106
<Buffer 0d 0a 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 34 36 31 35 39 31 30 38 30 39 34 31 36 32 32 35 31 31 33 33 36 ... > 

我们来看 wireshark 抓到的包

image-20200329144355168

我用红色进行了分割标记,对应的就是formidable所分割的片段 ,所以说这个包主要是将大段的 buffer 进行分割,然后循环处理。

这里我还可以补充一下,可能你对以上表非常陌生。左侧是二进制流,每1个代表1个字节,1字节=8位,上面的 2d 其实就是16进制的表示形式,用二进制表示就是 0010 1101,右侧是ascii 码用来可视化,但是 assii 分可显和非可显示。有部分是无法可视的。比如你所看到文件中有需要小点,就是不可见字符。

你可以对照,ascii表对照表来看。

我来总结一下formidable对于文件的处理流程。

formible-process

原生 Node

好了,我们已经知道了文件处理的流程,那么我们自己来写一个吧。

const fs = require('fs');
const http = require('http');
const querystring = require('querystring');
const server = http.createServer((req, res) => {
  if (req.url === "/files" && req.method.toLowerCase() === "post") {
    parseFile(req, res)
  }
})
function parseFile(req, res) {
  req.setEncoding("binary");
  let body = "";
  let fileName = "";
  // 边界字符
  let boundary = req.headers['content-type']
    .split('; ')[1]
    .replace("boundary=", "")
  
  req.on("data", function(chunk) {
    body += chunk;
  });
  req.on("end", function() {
    // 按照分解符切分
    const list = body.split(boundary);
    let contentType = '';
    let fileName = '';
    for (let i = 0; i < list.length; i++) {
      if (list[i].includes('Content-Disposition')) {
        const data = list[i].split('rn');
        for (let j = 0; j < data.length; j++) {
          // 从头部拆分出名字和类型
          if (data[j].includes('Content-Disposition')) {
            const info = data[j].split(':')[1].split(';');
            fileName = info[info.length - 1].split('=')[1].replace(/"/g, '');
            console.log(fileName);
          }
          if (data[j].includes('Content-Type')) {
            contentType = data[j];
            console.log(data[j].split(':')[1]);
          }
        }
      }
    }
    // 去除前面的请求头
    const start = body.toString().indexOf(contentType) + contentType.length + 4; // 有多rnrn
    const startBinary = body.toString().substring(start);
    const end = startBinary.indexOf("--" + boundary + "--") - 2; // 前面有多rn
     // 去除后面的分隔符
    const binary = startBinary.substring(0, end);
    const bufferData = Buffer.from(binary, "binary");
    fs.writeFile(fileName, bufferData, function(err) {
      res.end("sucess");
    });
    ;
  })
}

server.listen(7787) 

总结

相信有了以上的介绍,你不再对文件上传有所惧怕, 对文件上传整个过程都会比较清晰了,还不懂。。。。找我。

再次回顾下我们的重点:

请求端出问题,浏览器端打开 network 查看格式是否正确(请求头,请求体), 如果数据不够详细,打开 wireshark,对照我们的规范标准,看下格式(请求头,请求体)。

接收端出问题,情况一就是请求端缺少信息,参考上面请求端出问题的情况,情况二请求体内容错误,如果说请求体内容是请求端自己构造的,那么需要检查请求体是否是正确的二进制流(例如上面的blob构造的时候,我一开始少了一个[],导致内容主体错误)。

其实讲这么多就两个字: 规范,所有的生态都是围绕它而展开的。更多请看我的博客

系列文章

一文带你层层解锁「文件下载」的奥秘_蓝色的秋风 - SegmentFault 思否

⚡️前端多线程大文件下载实践,提速10倍,拿捏百度云盘_蓝色的秋风 - SegmentFault 思否

最后

如果我的文章有帮助到你,希望你也能帮助我,欢迎关注我的微信公众号 秋风的笔记,回复好友 二次,可加微信并且加入交流群,秋风的笔记 将一直陪伴你的左右。

image

参考

https://juejin.im/post/6844903810079391757

https://my.oschina.net/bing309/blog/3132260

https://segmentfault.com/a/1190000020654277

查看原文

赞 58 收藏 41 评论 0

this亦然 收藏了文章 · 10月10日

一文了解文件上传全过程(1.8w字深度解析,进阶必备)

前言

平常在写业务的时候常常会用的到的是 GET, POST请求去请求接口,GET 相关的接口会比较容易基本不会出错,而对于 POST中常用的 表单提交,JSON提交也比较容易,但是对于文件上传呢?大家可能对这个步骤会比较害怕,因为可能大家对它并不是怎么熟悉,而浏览器Network对它也没有详细的进行记录,因此它成为了我们心中的一根刺,我们老是无法确定,关于文件上传到底是我写的有问题呢?还是后端有问题,当然,我们一般都比较谦虚, 总是会在自己身上找原因,可是往往实事呢?可能就出在后端身上,可能是他接受写的有问题,导致你换了各种请求库去尝试,axiosrequestfetch 等等。那么我们如何避免这种情况呢?我们自身要对这一块够熟悉,才能不以猜的方式去写代码。如果你觉得我以上说的你有同感,那么你阅读完这篇文章你将收获自信,你将不会质疑自己,不会以猜的方式去写代码。

本文比较长可能需要花点时间去看,需要有耐心,我采用自顶向下的方式,所有示例会先展现出你熟悉的方式,再一层层往下, 先从请求端是怎么发送文件的,再到接收端是怎么解析文件的。

前置知识

什么是 multipart/form-data?

multipart/form-data 最初由 《RFC 1867: Form-based File Upload in HTML》文档提出。

Since file-upload is a feature that will benefit many applications, this proposes an extension to HTML to allow information providers to express file upload requests uniformly, and a MIME compatible representation for file upload responses.

由于文件上传功能将使许多应用程序受益,因此建议对HTML进行扩展,以允许信息提供者统一表达文件上传请求,并提供文件上传响应的MIME兼容表示。

总结就是原先的规范不满足啦,我要扩充规范了。

文件上传为什么要用 multipart/form-data?

The encoding type application/x-www-form-urlencoded is inefficient for sending large quantities of binary data or text containing non-ASCII characters. Thus, a new media type,multipart/form-data, is proposed as a way of efficiently sending the values associated with a filled-out form from client to server.

1867文档中也写了为什么要新增一个类型,而不使用旧有的application/x-www-form-urlencoded:因为此类型不适合用于传输大型二进制数据或者包含非ASCII字符的数据。平常我们使用这个类型都是把表单数据使用url编码后传送给后端,二进制文件当然没办法一起编码进去了。所以multipart/form-data就诞生了,专门用于有效的传输文件。

也许你有疑问?那可以用 application/json吗?

其实我认为,无论你用什么都可以传,只不过会要综合考虑一些因素的话,multipart/form-data更好。例如我们知道了文件是以二进制的形式存在,application/json 是以文本形式进行传输,那么某种意义上我们确实可以将文件转成例如文本形式的 Base64 形式。但是呢,你转成这样的形式,后端也需要按照你这样传输的形式,做特殊的解析。并且文本在传输过程中是相比二进制效率低的,那么对于我们动辄几十M几百M的文件来说是速度是更慢的。

以上为什么文件传输要用multipart/form-data 我还可以举个例子,例如你在中国,你想要去美洲,我们的multipart/form-data相当于是选择飞机,而application/json相当于高铁,但是呢?中国和美洲之间没有高铁啊,你执意要坐高铁去,你可以花昂贵的代价(后端额外解析你的文本)造高铁去美洲,但是你有更加廉价的方式坐飞机(使用multipart/form-data)去美洲(去传输文件)。你图啥?(如果你有钱有时间,抱歉,打扰了,老子给你道歉)

multipart/form-data规范是什么?

摘自 《RFC 1867: Form-based File Upload in HTML》 6.Example

Content-type: multipart/form-data, boundary=AaB03x

--AaB03x
content-disposition: form-data; name="field1"
Joe Blow
--AaB03x
content-disposition: form-data; name="pics"; filename="file1.txt"
Content-Type: text/plain

... contents of file1.txt ...
--AaB03x-- 

可以简单解释一些,首先是请求类型,然后是一个 boundary (分割符),这个东西是干啥的呢?其实看名字就知道,分隔符,当时分割作用,因为可能有多文件多字段,每个字段文件之间,我们无法准确地去判断这个文件哪里到哪里为截止状态。因此需要有分隔符来进行划分。然后再接下来就是声明内容的描述是 form-data 类型,字段名字是啥,如果是文件的话,得知道文件名是啥,还有这个文件的类型是啥,这个也很好理解,我上传一个文件,我总得告诉后端,我传的是个啥,是图片?还是一个txt文本?这些信息肯定得告诉人家,别人才好去进行判断,后面我们也会讲到如果这些没有声明的时候,会发生什么?

好了讲完了这些前置知识,我们接下来要进入我们的主题了。面对File, formData,Blob,Base64,ArrayBuffer,到底怎么做?还有文件上传不仅仅是前端的事。服务端也可以文件上传(例如我们利用某云,把静态资源上传到 OSS 对象存储)。服务端和客户端也有各种类型,Buffer,Stream,Base64....头秃,怎么搞?不急,就是因为上传文件不单单是前端的事,所以我将以下上传文件的一方称为请求端,接受文件一方称为接收方。我会以请求端各种上传方式,接收端是怎么解析我们的文件以及我们最终的杀手锏调试工具-wireshark来进行讲解。以下是讲解的大纲,我们先从浏览器端上传文件,再到服务端上传文件,然后我们再来解析文件是如何被解析的。

file-upload

请求端

浏览端

File

首先我们先写下最简单的一个表单提交方式。

<form action="http://localhost:7787/files" method="POST">
    <input name="file" type="file" id="file">
    <input type="submit" value="提交">
</form> 

我们选择文件后上传,发现后端返回了文件不存在。

image-20200328191433694

不用着急,熟悉的同学可能立马知道是啥原因了。嘘,知道了也听我慢慢叨叨。

我们打开控制台,由于表单提交会进行网页跳转,因此我们勾选preserve log 来进行日志追踪。

image-20200328191807526

image-20200328191733536

我们可以发现其实 FormDatafile 字段显示的是文件名,并没有将真正的内容进行传输。再看请求头。

image-20200328192020599

发现是请求头和预期不符,也印证了 application/x-www-form-urlencoded 无法进行文件上传。

我们加上请求头,再次请求。

<form action="http://localhost:7787/files" enctype="multipart/form-data" method="POST">
  <input name="file" type="file" id="file">
  <input type="submit" value="提交">
</form> 

image-20200328192539734

发现文件上传成功,简单的表单上传就是像以上一样简单。但是你得熟记文件上传的格式以及类型。

FormData

formData 的方式我随便写了以下几种方式。

<input type="file" id="file">
<button id="submit">上传</button>
<script data-original="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script>
<script> submit.onclick = () => {
    const file = document.getElementById('file').files[0];
    var form = new FormData();
    form.append('file', file);
  
    // type 1
    axios.post('http://localhost:7787/files', form).then(res => {
        console.log(res.data);
    })
    // type 2
    fetch('http://localhost:7787/files', {
        method: 'POST',
        body: form
    }).then(res => res.json()).tehn(res => {console.log(res)});
    // type3
    var xhr = new XMLHttpRequest();
    xhr.open('POST', 'http://localhost:7787/files', true);
    xhr.onload = function () {
        console.log(xhr.responseText);
    };
    xhr.send(form);
} </script> 

image-20200328192539734

以上几种方式都是可以的。但是呢,请求库这么多,我随便在 npm 上一搜就有几百个请求相关的库。

image-20200328194431932

因此,掌握请求库的写法并不是我们的目标,目标只有一个还是掌握文件上传的请求头和请求内容。

image-20200328194625420

Blob

Blob 对象表示一个不可变、原始数据的类文件对象。Blob 表示的不一定是JavaScript原生格式的数据。File 接口基于Blob,继承了 blob 的功能并将其扩展使其支持用户系统上的文件。

因此如果我们遇到 Blob 方式的文件上方式不用害怕,可以用以下两种方式:

1.直接使用 blob 上传

const json = { hello: "world" };
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });
    
const form = new FormData();
form.append('file', blob, '1.json');
axios.post('http://localhost:7787/files', form); 

2.使用 File 对象,再进行一次包装(File 兼容性可能会差一些 https://caniuse.com/#search=File)

const json = { hello: "world" };
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });
    
const file = new File([blob], '1.json');
form.append('file', file);
axios.post('http://localhost:7787/files', form) 

ArrayBuffer

ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区。

虽然它用的比较少,但是他是最贴近文件流的方式了。

在浏览器中,他每个字节以十进制的方式存在。我提前准备了一张图片。

const bufferArrary = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,1,3,0,0,0,37,219,86,202,0,0,0,6,80,76,84,69,0,0,255,128,128,128,76,108,191,213,0,0,0,9,112,72,89,115,0,0,14,196,0,0,14,196,1,149,43,14,27,0,0,0,10,73,68,65,84,8,153,99,96,0,0,0,2,0,1,244,113,100,166,0,0,0,0,73,69,78,68,174,66,96,130];
const array = Uint8Array.from(bufferArrary);
const blob = new Blob([array], {type: 'image/png'});
const form = new FormData();
form.append('file', blob, '1.png');
axios.post('http://localhost:7787/files', form) 

这里需要注意的是 new Blob([typedArray.buffer], {type: 'xxx'}),第一个参数是由一个数组包裹。里面是 typedArray 类型的 buffer。

Base64

const base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAABlBMVEUAAP+AgIBMbL/VAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAACklEQVQImWNgAAAAAgAB9HFkpgAAAABJRU5ErkJggg==';
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
    byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const array = Uint8Array.from(byteNumbers);
const blob = new Blob([array], {type: 'image/png'});
const form = new FormData();
form.append('file', blob, '1.png');
axios.post('http://localhost:7787/files', form); 

关于 base64 的转化和原理可以看这两篇 base64 原理

原来浏览器原生支持JS Base64编码解码

小结

对于浏览器端的文件上传,可以归结出一个套路,所有东西核心思路就是构造出 File 对象。然后观察请求 Content-Type,再看请求体是否有信息缺失。而以上这些二进制数据类型的转化可以看以下表。

transform.77175c26

图片来源 (https://shanyue.tech/post/binary-in-frontend/#%E6%95%B0%E6%8D%AE%E8%BE%93%E5%85%A5)

服务端

讲完了浏览器端,现在我们来讲服务器端,和浏览器不同的是,服务端上传有两个难点。

1.浏览器没有原生 formData,也不会想浏览器一样帮我们转成二进制形式。

2.服务端没有可视化的 Network 调试器。

Buffer

Request

首先我们通过最简单的示例来进行演示,然后一步一步深入。相信文档可以查看 https://github.com/request/request#multipartform-data-multipart-form-uploads

// request-error.js
const fs = require('fs');
const path = require('path');
const request = require('request');
const stream = fs.readFileSync(path.join(__dirname, '../1.png'));
request.post({
    url: 'http://localhost:7787/files',
    formData: {
        file: stream,
    }
}, (err, res, body) => {
    console.log(body);
}) 

image-20200328234106276

发现报了一个错误,正像上面所说,浏览器端报错,可以用NetWork。那么服务端怎么办?这个时候我们拿出我们的利器 -- wireshark

我们打开 wireshark (如果没有或者不会的可以查看教程 https://blog.csdn.net/u013613428/article/details/53156957)

设置配置 tcp.port == 7787,这个是我们后端的端口。

image-20200328234316233

运行上述文件 node request-error.js

image-20200328234543643

我们来找到我们发送的这条http的请求报文。中间那堆乱七八糟的就是我们的文件内容。

POST /files HTTP/1.1
host: localhost:7787
content-type: multipart/form-data; boundary=--------------------------437240798074408070374415
content-length: 305
Connection: close

----------------------------437240798074408070374415
Content-Disposition: form-data; name="file"
Content-Type: application/octet-stream

.PNG
.
...
IHDR.............%.V.....PLTE......Ll.....  pHYs..........+.....
IDAT..c`.......qd.....IEND.B`.
----------------------------437240798074408070374415-- 

可以看到上述报文。发现我们的内容请求头 Content-Type: application/octet-stream有错误,我们上传的是图片请求头应该是image/png,并且也少了 filename="1.png"

我们来思考一下,我们刚才用的是fs.readFileSync(path.join(__dirname, '../1.png')) 这个函数返回的是 BufferBuffer是什么样的呢?就是下面的形式,不会包含任何文件相关的信息,只有二进制流。

<Buffer 01 02> 

所以我想到的是,需要指定文件名以及文件格式,幸好 request 也给我们提供了这个选项。

key: {
    value:  fs.createReadStream('/dev/urandom'),
    options: {
      filename: 'topsecret.jpg',
      contentType: 'image/jpeg'
    }
} 

可以指定options,因此正确的代码应该如下(省略不重要的代码)

...
request.post({
    url: 'http://localhost:7787/files',
    formData: {
        file: {
            value: stream,
            options: {
                filename: '1.png'
            }
        },
    }
}); 

我们通过抓包可以进行分析到,文件上传的要点还是规范,大部分的问题,都可以通过规范模板来进行排查,是否构造出了规范的样子。

Form-data

我们再深入一些,来看看 request 的源码, 他是怎么实现Node端的数据传输的。

打开源码我们很容易地就可以找到关于 formData 这块相关的内容 https://github.com/request/request/blob/3.0/request.js#L21

image-20200328235629308

就是利用form-data,我们先来看看 formData 的方式。

const path = require('path');
const FormData = require('form-data');
const fs = require('fs');
const http = require('http');
const form = new FormData();
form.append('file', fs.readFileSync(path.join(__dirname, '../1.png')), {
    filename: '1.png',
    contentType: 'image/jpeg',
});
const request = http.request({
    method: 'post',
    host: 'localhost',
    port: '7787',
    path: '/files',
    headers: form.getHeaders()
});
form.pipe(request);
request.on('response', function(res) {
    console.log(res.statusCode);
}); 
原生 Node

看完 formData,可能感觉这个封装还是太高层了,于是我打算对照规范手动来构造multipart/form-data请求方式来进行讲解。我们再来回顾一下规范。

Content-type: multipart/form-data, boundary=AaB03x

--AaB03x
content-disposition: form-data; name="field1"
Joe Blow
--AaB03x
content-disposition: form-data; name="pics"; filename="file1.txt"
Content-Type: text/plain

... contents of file1.txt ...
--AaB03x-- 

我模拟上方,我用原生 Node 写出了一个multipart/form-data 请求的方式。

主要分为4个部分
  • 构造请求header
  • 构造内容header
  • 写入内容
  • 写入结束分隔符
const path = require('path');
const fs = require('fs');
const http = require('http');
// 定义一个分隔符,要确保唯一性
const boundaryKey = '-------------------------461591080941622511336662';
const request = http.request({
    method: 'post',
    host: 'localhost',
    port: '7787',
    path: '/files',
    headers: {
        'Content-Type': 'multipart/form-data; boundary=' + boundaryKey, // 在请求头上加上分隔符
        'Connection': 'keep-alive'
    }
});
// 写入内容头部
request.write(
    `--${boundaryKey}rnContent-Disposition: form-data; name="file"; filename="1.png"rnContent-Type: image/jpegrnrn`
);
// 写入内容
const fileStream = fs.createReadStream(path.join(__dirname, '../1.png'));
fileStream.pipe(request, { end: false });
fileStream.on('end', function () {
    // 写入尾部
    request.end('rn--' + boundaryKey + '--' + 'rn');
});
request.on('response', function(res) {
    console.log(res.statusCode);
}); 

至此,已经实现服务端上传文件的方式。

Stream、Base64

由于这两块就是和Buffer的转化,比较简单,我就不再重复描述了。可以作为留给大家的作业,感兴趣的可以给我这个示例代码仓库贡献这两个示例。

// base64 to buffer
const b64string = /* whatever */;
const buf = Buffer.from(b64string, 'base64'); 
// stream to buffer
function streamToBuffer(stream) {  
  return new Promise((resolve, reject) => {
    const buffers = [];
    stream.on('error', reject);
    stream.on('data', (data) => buffers.push(data))
    stream.on('end', () => resolve(Buffer.concat(buffers))
  });
} 

小结

由于服务端没有像浏览器那样 formData 的原生对象,因此服务端核心思路为构造出文件上传的格式(header,filename等),然后写入 buffer 。然后千万别忘了用 wireshark进行验证。

接收端

这一部分是针对 Node 端进行讲解,对于那些 koa-body 等用惯了的同学,可能一样不太清楚整个过程发生了什么?可能唯一比较清楚的是 ctx.request.files ??? 如果ctx.request.files 不存在,就会懵逼了,可能也不太清楚它到底做了什么,文件流又是怎么解析的。

我还是要说到规范...请求端是按照规范来构造请求..那么我们接收端自然是按照规范来解析请求了。

Koa-body

const koaBody = require('koa-body');

app.use(koaBody({ multipart: true })); 

我们来看看最常用的 koa-body,它的使用方式非常简单,短短几行,就能让我们享受到文件上传的简单与快乐(其他源码库一样的思路去寻找问题的本源) 可以带着一个问题去阅读,为什么用了它就能解析出文件?

寻求问题的本源,我们当然要打开 koa-body的源码,koa-body 源码很少只有211行,https://github.com/dlau/koa-body/blob/v4.1.1/index.js#L125 很容易地发现它其实是用了一个叫做formidable的库来解析 files 的。并且把解析好的 files 对象赋值到了 ctx.req.files。(所以说大家不要一味死记 ctx.request.files, 注意查看文档,因为今天用 koa-bodyctx.request.files 明天换个库可能就是 ctx.request.body 了)

因此看完 koa-body我们得出的结论是,koa-body的核心方法是formidable

Formidable

那么让我们继续深入,来看看formidable做了什么,我们首先来看它的目录结构。

.
├── lib
│   ├── file.js
│   ├── incoming_form.js
│   ├── index.js
│   ├── json_parser.js
│   ├── multipart_parser.js
│   ├── octet_parser.js
│   └── querystring_parser.js 

看到这个目录,我们大致可以梳理出这样的关系。

index.js
|
incoming_form.js
|
type
?
|
1.json_parser
2.multipart_parser
3.octet_parser
4.querystring_parser 

由于源码分析比较枯燥。因此我只摘录比较重要的片段。由于我们是分析文件上传,所以我们只需要关心 multipart_parser 这个文件。

https://github.com/node-formidable/formidable/blob/v1.2.1/lib/multipart_parser.js#L72

...
MultipartParser.prototype.write = function(buffer) {
    console.log(buffer);
  var self = this,
      i = 0,
      len = buffer.length,
      prevIndex = this.index,
      index = this.index,
      state = this.state,
... 

我们将它的 buffer 打印看看.

<Buffer 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 34 36 31 35 39 31 30 38 30 39 34 31 36 32 32 35 31 31 33 33 36 36 36 ... >
144
<Buffer 89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52 00 00 00 01 00 00 00 01 01 03 00 00 00 25 db 56 ca 00 00 00 06 50 4c 54 45 00 00 ff 80 80 80 4c 6c bf ... >
106
<Buffer 0d 0a 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 34 36 31 35 39 31 30 38 30 39 34 31 36 32 32 35 31 31 33 33 36 ... > 

我们来看 wireshark 抓到的包

image-20200329144355168

我用红色进行了分割标记,对应的就是formidable所分割的片段 ,所以说这个包主要是将大段的 buffer 进行分割,然后循环处理。

这里我还可以补充一下,可能你对以上表非常陌生。左侧是二进制流,每1个代表1个字节,1字节=8位,上面的 2d 其实就是16进制的表示形式,用二进制表示就是 0010 1101,右侧是ascii 码用来可视化,但是 assii 分可显和非可显示。有部分是无法可视的。比如你所看到文件中有需要小点,就是不可见字符。

你可以对照,ascii表对照表来看。

我来总结一下formidable对于文件的处理流程。

formible-process

原生 Node

好了,我们已经知道了文件处理的流程,那么我们自己来写一个吧。

const fs = require('fs');
const http = require('http');
const querystring = require('querystring');
const server = http.createServer((req, res) => {
  if (req.url === "/files" && req.method.toLowerCase() === "post") {
    parseFile(req, res)
  }
})
function parseFile(req, res) {
  req.setEncoding("binary");
  let body = "";
  let fileName = "";
  // 边界字符
  let boundary = req.headers['content-type']
    .split('; ')[1]
    .replace("boundary=", "")
  
  req.on("data", function(chunk) {
    body += chunk;
  });
  req.on("end", function() {
    // 按照分解符切分
    const list = body.split(boundary);
    let contentType = '';
    let fileName = '';
    for (let i = 0; i < list.length; i++) {
      if (list[i].includes('Content-Disposition')) {
        const data = list[i].split('rn');
        for (let j = 0; j < data.length; j++) {
          // 从头部拆分出名字和类型
          if (data[j].includes('Content-Disposition')) {
            const info = data[j].split(':')[1].split(';');
            fileName = info[info.length - 1].split('=')[1].replace(/"/g, '');
            console.log(fileName);
          }
          if (data[j].includes('Content-Type')) {
            contentType = data[j];
            console.log(data[j].split(':')[1]);
          }
        }
      }
    }
    // 去除前面的请求头
    const start = body.toString().indexOf(contentType) + contentType.length + 4; // 有多rnrn
    const startBinary = body.toString().substring(start);
    const end = startBinary.indexOf("--" + boundary + "--") - 2; // 前面有多rn
     // 去除后面的分隔符
    const binary = startBinary.substring(0, end);
    const bufferData = Buffer.from(binary, "binary");
    fs.writeFile(fileName, bufferData, function(err) {
      res.end("sucess");
    });
    ;
  })
}

server.listen(7787) 

总结

相信有了以上的介绍,你不再对文件上传有所惧怕, 对文件上传整个过程都会比较清晰了,还不懂。。。。找我。

再次回顾下我们的重点:

请求端出问题,浏览器端打开 network 查看格式是否正确(请求头,请求体), 如果数据不够详细,打开 wireshark,对照我们的规范标准,看下格式(请求头,请求体)。

接收端出问题,情况一就是请求端缺少信息,参考上面请求端出问题的情况,情况二请求体内容错误,如果说请求体内容是请求端自己构造的,那么需要检查请求体是否是正确的二进制流(例如上面的blob构造的时候,我一开始少了一个[],导致内容主体错误)。

其实讲这么多就两个字: 规范,所有的生态都是围绕它而展开的。更多请看我的博客

系列文章

一文带你层层解锁「文件下载」的奥秘_蓝色的秋风 - SegmentFault 思否

⚡️前端多线程大文件下载实践,提速10倍,拿捏百度云盘_蓝色的秋风 - SegmentFault 思否

最后

如果我的文章有帮助到你,希望你也能帮助我,欢迎关注我的微信公众号 秋风的笔记,回复好友 二次,可加微信并且加入交流群,秋风的笔记 将一直陪伴你的左右。

image

参考

https://juejin.im/post/6844903810079391757

https://my.oschina.net/bing309/blog/3132260

https://segmentfault.com/a/1190000020654277

查看原文

this亦然 赞了文章 · 9月17日

思考、创新、坚持——阿里做了七年前端,我的成长经验分享

在成长的未知道路上,我们总会遇到各种各样的问题,但是,所有的迷茫与逆境都能够帮助我们成长,我们要抓住每一个机会让自己进步,而不是徘徊不前。

淘系前端开发同学——林晚,今天就来和大家分享他这七年的成长经历,以及如何摆脱业务前端的职业迷茫感。

个人经历

我的经历相对来说比较简单,毕业后一直在阿里。

image.png

07到13年我在武汉大学学习通信工程专业,同时辅修了动画双学位。

13年毕业后加入阿里巴巴国际站,在B2B阶段我对动画和创新交互比较感兴趣,共申请了6项相关专利,有的已经获得授权,赚得了人生的第一桶金:5万 😁

15年转岗到蚂蚁口碑,当时主要负责互动营销和质量检测。

18年了解到淘宝直播,想在多媒体领域持续深耕,最近两年主要负责ALive直播开放和多媒体前端领域的建设。

三个阶段都有一定的成长,也得到了认可。下面就带着大家走进我的七年,希望能给处于迷茫期的你一些指引。

阶段回顾

每个阶段的回顾,按照下面几个阶段来划分,总结了下我在每个阶段的关键词:

  • 折腾点什么(点)
  • 主导点什么(线)
  • 突破点什么(面)
  • 引领点什么(体)

▐ 折腾点什么

初入职场

大家还记得自己做过的第一个需求是什么吗?

image.png

我的第一个需求是做一个类目选择器,索引、排序。我记得当时是周五,我了解完需求和师兄说,这个需求我周末弄两天,下周一就可以做好。当时师兄笑了一下。因为那个时候我还不清楚一个需求的完整流程,需要经过评审、排期、开发、联调、提测最后才能发布。

后来慢慢熟悉了公司里的工作流程,开始闷头做项目,一个接一个,很充实,但平淡。第一个绩效我就拿了3.25,绩效沟通完我给自己写了几个字“知耻而后勇”,我得折腾点什么。

举个栗子

折腾点什么呢?和大家分享个例子:当时喜欢用百度来搜索美女图片,但是列表的浏览体验不太好,于是自己设计实现了一个可以任意方向无限漫游的效果,每一个方向代表一种风格类型,可以实时推荐。大概是这样一个效果:

image.png

大晚上的撸完代码,看到这么多取之不尽的美女挺兴奋。又想能不能跟工作结合起来,于是包装了下概念,申请了一个商品加载和推荐的专利。申请完专利,又想能不能在业务里用起来,于是自己做产品设计,找运营聊场景,最后在俄罗斯的一个采购活动上完成了上线,业务效果List到Detail的转化率比传统的列表形式提升了 3.5倍。

image.png

在国际站一共申请了类似的6项专利,其中一项已经获得国家专利局授权,还有的被法务部推荐去申请了海外专利。

阶段成长和迷茫

国际站,让我完成了从学校到职场的转变。现在回过头看,在国际站我的成长是非常大的,对我的职业发展影响也很大。总结了一下,主要有以下几个方面的成长:

职业性:避免情绪化对工作产生影响;结构化思考和表达
业务和数据思维:养成深入了解业务的习惯,数据化思维看产出和价值
技术结合业务:不为技术而技术,结合业务场景推进技术建设
团队视角:将个人的能力变成团队的能力(比如结合自己在动画方面的经验,在团队里组织了动画兴趣小组、搭建动画平台竹马小站)

在这个阶段,我也产生了一些迷茫,自己折腾的一些事情,和业务场景不太匹配,国际站都是B端的场景,而自己更多的关注在C端的效果和交互,加上集团 all in 无线的大趋势,出现了H5工程师,自己要不要转型?经过考虑后做了一个决定:走向C端,转岗到了当时的创新业务支付宝口碑。

▐ 主导点什么

业务扛把子

在口碑的阶段,自己的职业度更加地成熟,能去主导一些核心的事情了。业务方面成为了扛把子,负责口碑一些核心的频道和栏目的建设,比如大牌抢购、旅游版等;结合之前动画领域的沉淀,团队里动画游戏类的营销活动我也成了一号位。

image.png

如何把业务做好?和大家分享一个大牌抢购的案例。

做业务之前需要充分的理解和思考:

  • 业务的站位是什么?我对口碑C端的业务做了一个分类:围绕“找门店”、“找优惠”、“找内容”这三个维度,对数据进行各种形式和场景化的运营。大牌抢购频道在整个C端业务中的站位,就是针对“找优惠”设计的一个抢购形式的运营场景。
  • 业务的特点是什么?因为有多个场次,场次有多个状态,以及券来源、状态、类型都非常丰富,导致了券的处理复杂度非常高;另外因为是抢购场景,所以对券信息的时效性要求比较高,库存状态和领取状态等信息都需要做到及时更新。
  • 挑战和解法是什么?比如券处理复杂度高很高,我抽象了一个统一的数据结构,这个结构对券的各种维度做了清晰的划分,各种维度交叉组合可以得出很多的种类,为了保证数据结构变动或扩展之后,视图能方便管理,我做了一层展示归一,得出一套展示模式,并且可扩展。

再比如做口碑年度榜单,我也没把它当成一个一次性的动画需求来做,而是通过这个项目沉淀一套动画类需求的标准化处理流程:动画分层、动效拆解、业务解耦、技术实现。为后续的动画类需求提供更高效、更灵活、高复用地处理方案。

技术攻坚

技术方面主导了口碑的真机检测平台砚台,从0-1构建了口碑的真机检测方案。这是一个比较复杂的项目,服务端通过chair应用来实现,在chair应用中又包括socket服务和http服务,socket服务主要负责group分组、agent管理、任务事件处理、数据接收和中转等工作,http服务主要提供页面、接口、数据的处理和读写,数据存储使用的是basement提供的db服务;跟socket服务保持长链接的agent层,负责手机设备的管理,心跳检查,并轮询上报给服务端,还负责命令的接收和下发,数据采集等工作;最后是真机设备,通过adb连接到agent主机。

整个系统比较复杂,自己主导了整个方案的设计并联动客户端完成落地,中间过程也解决了一些关键的技术问题,比如chair如何实现socket服务、进程间如何通信、docker部署多容器socket如何同步等。

阶段成长和迷茫

在口碑经过业务和技术的历练,主要有以下几个方面的成长:

  • 业务主人翁:负责的业务当成自己的孩子来养,每天多体验、每天多思考
  • 做业务更需要技术思维:大牌抢购抽丝剥茧、别有洞天
  • 系统架构和技术攻坚:砚台整体架构、chair应用结合socket服务

在口碑待了3年,我又开始迷茫了:我做的这些事情都是线条的,重点频道、营销业务、真机检测,我后续的发展还是继续沿着这几条线深入吗,再继续做几年,我的变化会有多大?这里其实引申出来的问题是:线条型发展,我的技术领域是什么?

考虑到自己学的是通信工程和数字媒体相关,毕业设计也是做的基于WebRTC的视频会议系统,有一些多媒体的基础,这个领域的壁垒深、空间大,于是想专注于多媒体前端领域发展,转岗到了淘宝直播。

▐ 突破点什么

翻车与逆袭

刚到直播的时候接手的第一个项目是直播间里的亲密度,一个客户端同学写的H5页面,当时在直播间里调试简直苦不堪言,端侧方案其实是很不成熟的。

在日常迭代还能勉强应付,一到大型晚会有很多复杂的实时互动,问题就暴露的淋漓尽致。18年双11的潮流盛典是整个直播团队最黑暗的阶段,连续几个通宵cr代码修bug,晚会现场还是问题不断,现场一边直播一边改bug发布,甚至降级下线一些功能,这个项目差一点就做垮掉。

image.png

从北京回来后我们痛定思痛,开始重构直播端侧方案,抽象直播容器,提供配套的工程调试链路,几个月后的双12和芒果TV合作的人民的宝贝落地了这套方案,从现场可以看到一片祥和,这套方案后来演变发展成了现在的ALive。

直播间的互动率和停留时长是很核心的指标,我们创新突破启动了媒体智能项目,给淘宝直播带来了流媒体互动的方案。

还有基础的播放器,我们的VideoX作为技术项目也进入了多媒体领域的深水区。

灵魂四拷问

上面提到的几个项目,我们在项目初期都会思考几个问题:客户是谁、解决什么问题、使用什么技术方案、带来什么价值。这几个问题思考下来,项目要不要做、怎么做,基本就清晰了,接下来就是细化方案和执行落地。

image.png

同时团队里会产出一张大图,让大家既能看到全貌,又能了解彼此之间的关联,更好的单点突破、全面协作,凝聚成一颗心,共同打好一场仗。

阶段成长和迷茫

在直播的这个阶段,总结了下我主要有以下几个方面的成长:

  • 行业洞察和预判:全面了解自己的业务,竞对的动态;预判趋势,提前布局,反推业务(比如ALive直播小程序)
  • 创新突破:停留时长、互动率下降,如何抢占用户时长,直播视频场景流媒体互动创新
  • 技术深水区:播放器除了多协议支持、低延时优化、单实例控制等,自研播放内核
  • 影响力:ALive、媒体智能、VideoX打造团队技术品牌,跨BU影响力

现阶段依然有些迷茫,但是已经不是方向上的迷茫感了,而是在多媒体领域,前端的核心价值是什么?如何做深做厚?接下来会尝试在集团层面打造Web Media的体系。

▐ 引领点什么

近2年的工作成果得到了认可,晋升到新的层级,对自己也有新的挑战。我觉得接下来,得引领点什么。

去年开始也在往这方面做一些尝试,比如拉通集团组织了多媒体专场分享,倾团队之力打造多媒体前端知识图谱和配套的前端手册,整理经济体多媒体前端大图等,接下来会继续在集团层面建设多媒体前端体系Web Media,今年也加入了W3C的MEIG媒体与娱乐兴趣组 https://www.w3.org/groups/ig/me/participants ,目前在这个组织里跟进和推动W3C关于媒体相关的标准。

image.png

一些感悟

关于业务前端的职业迷茫感,如何摆脱或者说衰减,我觉得最重要的是得找到一个自己愿意持续学习、有领域知识积累的细分方向。我自己找到了,我会在多媒体这个领域持续发展,现在我没有方向上的迷茫。工作了3-5年的同学应该需要回答这样一个问题,自己的技术领域是什么?前端工程化、nodejs、数据可视化、互动、搭建、多媒体?如果确定了自己的技术领域,业务前端的迷茫感应该会衰弱很多。

大家做业务,都有很大的业务压力,但公司对我们的要求是除了业务还要体现技术价值,这就需要我们做事情之前有充分的思考。在评估一个项目的时候,要想清楚3个问题:业务的目标是什么、技术团队的策略是什么,我们作为前端在里面的价值是什么。如果3个问题都想明白了,前后的衔接也对了,这事情才靠谱。

希望大家还是能像最初来阿里的时候一样,能多折腾,保留这种折腾劲,甚至是孩子气,如果你还有的话。

原文链接
本文为阿里云原创内容,未经允许不得转载。

查看原文

赞 5 收藏 3 评论 0

this亦然 提出了问题 · 9月9日

mongo的复杂聚合分组查询

[  
 {time:'3月',type:'aa'},      
 {time:'3月',type:'aa'},
 {time:'3月',type:'aa'},
 {time:'3月',type:'bbb'},
 {time:'4月',type:'aa'},  
 {time:'4月',type:'aa'},  
 {time:'5月',type:'aa'},  
 {time:'5月',type:'bbb'},   
]

数据库有以上的数据,
我想通过mongo的aggregate聚合分组,先按照时间维度分组,然后再在时间维度内按照type分组,最后得到如下结果:

[
  {time:'3月',type:{aa:3,bbb:1}},
  {time:'4月',type:{aa:2,bbb:0}},
  {time:'5月',type:{aa:1,bbb:1}},
]

请问mongo的二维分组聚合查询应该怎么写?

关注 3 回答 1

this亦然 回答了问题 · 6月4日

解决mongoose 联合查询 populate

populate是做ref引用查询的,要用populate的话你的model都创建错了
classUuid: { type: mongoose.Schema.Types.ObjectId, ref: 'classSchema' }
应该这样classUuid关联的classSchema这张表

关注 3 回答 2

this亦然 回答了问题 · 5月15日

解决后端在同一个接口返回中英文数据,前端应该如何更好地处理区分?

写一个方法呗,你都说了只有en前缀的区别,

getText(item,field){
    return this.isChinaEnv?
            item[field]:
            item['en'+field];
}
{{this.getText(item,'title')}}

关注 2 回答 1

this亦然 赞了文章 · 1月2日

动图学 JavaScript 之:事件循环(Event Loop)

前言

今天该学习 Event Loop 啦,其实之前我写过一篇 Event Loop 的文章:

浅析 JS 中的 EventLoop 事件循环(新手向)

这篇呢则是动图学 JS 系列中的,可以结合之前的文章食用~

我们都知道 JavaScript 是一门 单线程 的语言:同一时间只能运行一个任务。通常情况下这没什么问题,但是如果你有一个任务需要耗费 30 秒的时间,那其他任务难道都要等它 30 秒么?(由于 JS 运行在浏览器的主线程,所以这 30 秒的时间里,整个页面都会处于卡死状态)

幸运的是,浏览器提供了一些 JS 引擎不具备的功能:Web API。它包括 DOM APIsetTimeoutHTTP 请求 等等。这些功能都可以帮助我们处理 异步、非阻塞 的操作。

调用栈

当我们调用一个函数时,它会被添加到一个叫做 调用栈 (call stack) 的地方,调用栈是 JS 引擎的一部分,而不是浏览器特有的。本质上它是一个栈,具有 后进先出 (Last In, First Out. 即 LIFO) 的特点。当一个函数调用完成,它就被从调用栈中弹出。

1-call-stack.gif

上图中函数 respond 返回了一个 setTimeout 函数,它也被添加到调用栈中,(setTimeout 正是 Web API 提供的功能之一:它可以让我们延迟一个任务的执行并且不阻塞主线程。)setTimeout 被调用之后,传给它的箭头函数 () => { return 'Hey' } 就被添加进了 Web API (此处简化了概念,具体可以看笔者的另一篇文章)中。同时 setTimeoutrespond 函数从调用栈中弹出,它们都返回了相应的值。

2-setTimeout.gif

任务队列

在 Web API 中,一个定时器已经创建,它将会等待 1000 ms,当时间到后,这个箭头函数并不会立即被调用栈执行,它会被添加到一个队列中,我们暂且称之为 任务队列 (原文中叫 Callback Queue)。

3-task-queue.gif

这里可能会让人困惑:那个回调箭头函数并不是在 1000ms 后被直接添加到 调用栈 的,而是被添加进了 任务队列。队列嘛,就是大家排队,先来的先服务,被谁服务?没错!就是调用栈。

事件循环

说了这么多,终于轮到我们的 Event Loop 登场了!如果上面的调用栈是一个银行窗口,任务队列中的回调函数是一个个排队办业务的人,那么 Event Loop 就是叫号系统!Event Loop 的唯一任务就是 连接任务队列和调用栈

它不停检查 调用栈 中是否有任务需要执行,如果没有,就检查 任务队列,从中弹出一个任务,放入调用栈中,如此往复循环。

4-event-loop.gif

上图中终于轮到那个箭头函数接受调用了,它被调用完,也被弹出了,轻轻地它走了,只留下一个 Hey! o(╯□╰)o

5-arrow-called.gif

一个例子

看图片是不是挺好理解的~ 那就来看一个例子,可以把下面的代码粘贴到浏览器的控制台亲自跑一下:

const foo = () => console.log("First");
const bar = () => setTimeout(() => console.log("Second"), 500);
const baz = () => console.log("Third");

bar();
foo();
baz();

6-example.gif

  1. 我们调用了函数 barbar 返回了一个 setTimeout 函数。
  2. setTimeout 中的回调函数被添加到 Web APIsetTimeout 函数和 bar 调用完成被从调用栈弹出。
  3. 定时器开始,同时函数 foo 被调用,打印出 Firstfoo 函数返回 undefined
  4. 函数 baz 被调用,打印出 Third
  5. 500ms 定时器结束,回调函数被放入任务队列,Event Loop 检查到调用栈是空的,所以将其取出放在调用栈。
  6. 回调函数被执行,打印出 Second

全文就到这里啦,希望对你理解 Event Loop 有所帮助~

本文是翻译的系列文章:


本文首发于公众号:码力全开(codingonfire)

本文随意转载哈,注明原文链接即可,公号文章转载联系我开白名单就好~

codingonfire.jpg

参考文章

查看原文

赞 88 收藏 55 评论 13

this亦然 赞了文章 · 2019-08-22

前端代码规范 — JavaScript 风格指南

前言

本文基于 github 项目 airbnb/javascript 翻译,也加入了一些个人理解。规范有利于我们更好的提高代码可读性,避免一些不必要的 bug。但是,并没有统一的标准和硬性要求,这里只是给大家提供一些参考,适合团队和自己的才是最好的。

个人博客地址 🍹🍰 fe-code

类型

  • 1.1 基本类型
基本类型赋值时,应该直接使用类型的值
  • string
  • number
  • boolean
  • null
  • undefined
  • symbol
const foo = 1;
let bar = foo;

bar = 9;

console.log(foo, bar); // => 1,9
  • 复杂类型
复杂类型赋值其实是地址的引用
  • object
  • array
  • function
const foo = [1, 2];
const bar = foo;

bar[0] = 9;

console.log(foo[0], bar[0]); // => 9, 9
// const 只能阻止引用类型地址的重新赋值
// 并不能保证引用类型的属性等不变

状态的使用(原文为 Reference)

尽量确保你的代码中的状态是可控范围内的,重复引用会出现难以理解的 bug 和代码。
// bad
var a = 1;
var b = 2;

// good
const a = 1;
const b = 2;
  • 2.2 如果你一定要对参数重新赋值,那就用let,而不是var. eslint: no-var
let是块级作用域,var是函数级作用域,同样是为了减少代码的不可控,减少 “意外”
// bad
var count = 1;
if (true) {
  count += 1;
}

// good, use the let.
let count = 1;
if (true) {
  count += 1;
}
  • 2.3 letconst都是块级作用域
// const 和 let 都只存在于它定义的那个块级作用域
{
  let a = 1;
  const b = 1;
}
console.log(a); // ReferenceError
console.log(b); // ReferenceError

对象

// bad
const item = new Object();

// good
const item = {};
  • 3.2 当创建一个带有动态属性名的对象时,将定义的所有属性放在对象的一个地方。

function getKey(k) {
  return `a key named ${k}`;
}

// bad
const obj = {
  id: 5,
  name: 'San Francisco',
};
obj[getKey('enabled')] = true;

// good getKey('enabled')是动态属性名
const obj = {
  id: 5,
  name: 'San Francisco',
  [getKey('enabled')]: true,
};
// bad
const atom = {
  value: 1,

  addValue: function (value) {
    return atom.value + value;
  },
};

// good
const atom = {
  value: 1,

  // 对象的方法
  addValue(value) {
    return atom.value + value;
  },
};
const lukeSkywalker = 'Luke Skywalker';

// bad
const obj = {
  lukeSkywalker: lukeSkywalker,
};

// good
const obj = {
  lukeSkywalker
};
  • 3.5 将属性的缩写放在对象声明的开头。
const anakinSkywalker = 'Anakin Skywalker';
const lukeSkywalker = 'Luke Skywalker';

// bad
const obj = {
  episodeOne: 1,
  twoJediWalkIntoACantina: 2,
  lukeSkywalker,
  episodeThree: 3,
  mayTheFourth: 4,
  anakinSkywalker,
};

// good
const obj = {
  lukeSkywalker,
  anakinSkywalker,
  episodeOne: 1,
  twoJediWalkIntoACantina: 2,
  episodeThree: 3,
  mayTheFourth: 4,
};
  • 3.6 只对那些无效的标示使用引号 ''. eslint: quote-props
一般来说,我们认为它在主观上更容易阅读。它改进了语法突出显示,并且更容易被JS引擎优化。
// bad
const bad = {
  'foo': 3,
  'bar': 4,
  'data-blah': 5,
};

// good
const good = {
  foo: 3,
  bar: 4,
  'data-blah': 5,
};
  • 3.7 不要直接调用Object.prototype上的方法,如hasOwnProperty, propertyIsEnumerable, isPrototypeOf
在一些有问题的对象上, 这些方法可能会被屏蔽掉 - 如:{ hasOwnProperty: false } - 或这是一个空对象Object.create(null)
// bad
console.log(object.hasOwnProperty(key));

// good
console.log(Object.prototype.hasOwnProperty.call(object, key));

// best
const has = Object.prototype.hasOwnProperty; // 在模块作用内做一次缓存
/* or */
import has from 'has'; // https://www.npmjs.com/package/has
// ...
console.log(has.call(object, key));
  • 3.8 对象浅拷贝时,更推荐使用扩展运算符 ...,而不是Object.assign。解构赋值获取对象指定的几个属性时,推荐用 rest 运算符,也是 ...
// very bad
const original = { a: 1, b: 2 };
const copy = Object.assign(original, { c: 3 }); 
delete copy.a; // so does this  改变了 original

// bad
const original = { a: 1, b: 2 };
const copy = Object.assign({}, original, { c: 3 }); // copy => { a: 1, b: 2, c: 3 }

// good
const original = { a: 1, b: 2 };
const copy = { ...original, c: 3 }; // copy => { a: 1, b: 2, c: 3 }

const { a, ...noA } = copy; // noA => { b: 2, c: 3 }

数组

// bad
const items = new Array();

// good
const items = [];
  • 4.2 用Array#push 向数组中添加一个值而不是直接用下标。
const someStack = [];

// bad
someStack[someStack.length] = 'abracadabra';

// good
someStack.push('abracadabra');
  • 4.3 用扩展运算符做数组浅拷贝,类似上面的对象浅拷贝
// bad
const len = items.length;
const itemsCopy = [];
let i;

for (i = 0; i < len; i += 1) {
  itemsCopy[i] = items[i];
}

// good
const itemsCopy = [...items];
  • 4.4 推荐用 ... 运算符而不是Array.from来将一个类数组转换成数组。
const foo = document.querySelectorAll('.foo');

// good
const nodes = Array.from(foo);

// best
const nodes = [...foo];
  • 4.5 用 Array.from 去将一个类数组对象转成一个数组。
const arrLike = { 0: 'foo', 1: 'bar', 2: 'baz', length: 3 };

// bad
const arr = Array.prototype.slice.call(arrLike);

// good
const arr = Array.from(arrLike);
  • 4.6 用 Array.from 而不是 ... 运算符去迭代。 这样可以避免创建一个中间数组。
// bad
const baz = [...foo].map(bar);

// good
const baz = Array.from(foo, bar);
  • 4.7 在数组方法的回调函数中使用 return 语句。 如果函数体由一条返回一个表达式的语句组成, 并且这个表达式没有副作用, 这个时候可以忽略return,详见 8.2. eslint: array-callback-return
// good
[1, 2, 3].map((x) => {
  const y = x + 1;
  return x * y;
});

// good 函数只有一个语句
[1, 2, 3].map(x => x + 1);

// bad 没有返回值, 导致在第一次迭代后acc 就变成undefined了
[[0, 1], [2, 3], [4, 5]].reduce((acc, item, index) => {
  const flatten = acc.concat(item);
  acc[index] = flatten;
});

// good
[[0, 1], [2, 3], [4, 5]].reduce((acc, item, index) => {
  const flatten = acc.concat(item);
  acc[index] = flatten;
  return flatten;
});

// bad
inbox.filter((msg) => {
  const { subject, author } = msg;
  if (subject === 'Mockingbird') {
    return author === 'Harper Lee';
  } else {
    return false;
  }
});

// good
inbox.filter((msg) => {
  const { subject, author } = msg;
  if (subject === 'Mockingbird') {
    return author === 'Harper Lee';
  }

  return false;
});
  • 4.8 如果一个数组有很多行,在数组的 [ 后和 ] 前换行。
// bad
const arr = [
  [0, 1], [2, 3], [4, 5],
];

const objectInArray = [{
  id: 1,
}, {
  id: 2,
}];

const numberInArray = [
  1, 2,
];

// good
const arr = [[0, 1], [2, 3], [4, 5]];

const objectInArray = [
  {
    id: 1,
  },
  {
    id: 2,
  },
];

const numberInArray = [
  1,
  2,
];

解构

  • 5.1 用对象的解构赋值来获取和使用对象某个或多个属性值。 eslint: prefer-destructuring
这样就不需要给这些属性创建临时/引用
// bad
function getFullName(user) {
  const firstName = user.firstName;
  const lastName = user.lastName;

  return `${firstName} ${lastName}`;
}

// good
function getFullName(user) {
  const { firstName, lastName } = user;
  return `${firstName} ${lastName}`;
}

// best
function getFullName({ firstName, lastName }) {
  return `${firstName} ${lastName}`;
}
  • 5.2 数组解构.
const arr = [1, 2, 3, 4];

// bad
const first = arr[0];
const second = arr[1];

// good
const [first, second] = arr;
  • 5.3 多个返回值用对象的解构,而不是数组解构。
不依赖于返回值的顺序,更可读
// bad
function processInput(input) {
  // 然后就是见证奇迹的时刻
  return [left, right, top, bottom];
}

const [left, __, top] = processInput(input);

// good
function processInput(input) {
  return { left, right, top, bottom };
}

const { left, top } = processInput(input);

字符串

  • 6.1 string 统一用单引号 '' 。 eslint: quotes
// bad
const name = "Capt. Janeway";

// bad - 模板应该包含插入文字或换行
const name = `Capt. Janeway`;

// good
const name = 'Capt. Janeway';
  • 6.2 不应该用 + 连接换行字符串。
不好用,且可读性差
// bad
const errorMessage = 'This is a super long error that was thrown because \
of Batman. When you stop to think about how Batman had anything to do \
with this, you would get nowhere \
fast.';

// bad
const errorMessage = 'This is a super long error that was thrown because ' +
  'of Batman. When you stop to think about how Batman had anything to do ' +
  'with this, you would get nowhere fast.';

// good
const errorMessage = 'This is a super long error that was thrown because of Batman. When you stop to think about how Batman had anything to do with this, you would get nowhere fast.';
模板字符串更具可读性、语法简洁、字符串插入参数。
// bad
function sayHi(name) {
  return 'How are you, ' + name + '?';
}

// bad
function sayHi(name) {
  return ['How are you, ', name, '?'].join();
}

// bad
function sayHi(name) {
  return `How are you, ${ name }?`;
}

// good
function sayHi(name) {
  return `How are you, ${name}?`;
}
  • 6.4 永远不要在字符串中用eval(),漏洞太多。 eslint: no-eval
反斜线可读性差,只在必要时使用
// bad
const foo = '\'this\' \i\s \"quoted\"';

// good
const foo = '\'this\' is "quoted"';

//best
const foo = `my name is '${name}'`;

函数

  • 7.1 用命名函数表达式而不是函数声明。eslint: func-style
函数声明作用域会提升,降低了代码可读性和可维护性。如果你发现一个函数又大又复杂,这个函数妨碍这个文件其他部分的理解性,这可能就是时候把这个函数单独抽成一个模块了。(Discussion)
// bad
function foo() {
  // ...
}

// bad
const foo = function () {
  // ...
};

// good

const short = function longUniqueMoreDescriptiveLexicalFoo() {
  // ...
};
  • 7.2 把立即执行函数包裹在圆括号里。 eslint: wrap-iife
一个立即调用的函数表达式是一个单元 - 把它和他的调用者(圆括号)包裹起来。当然,现代模块开发中,你基本用不到。
// immediately-invoked function expression (IIFE)
(function () {
  console.log('Welcome to the Internet. Please follow me.');
}());
  • 7.3 不要在非函数块(if、while等等)内声明函数。而是把这个函数分配给一个变量。浏览器会允许你这样做,但浏览器解析方式不同,结果也许会有差异。【详见no-loop-func】 eslint: no-loop-func
  • 7.4 注意: 在ECMA-262中 [块 block] 的定义是: 一系列的语句; 但是函数声明不是一个语句。 函数表达式是一个语句。
// bad
if (currentUser) {
  function test() {
    console.log('Nope.');
  }
}

// good
let test;
if (currentUser) {
  test = () => {
    console.log('Yup.');
  };
}
  • 7.5 永远不要用arguments命名参数。它的优先级高于每个函数作用域自带的 arguments 对象, 所以会导致函数自带的 arguments 值被覆盖。
// bad
function foo(name, options, arguments) {
  // ...
}

// good
function foo(name, options, args) {
  // ...
}
... 更明确你想用哪些参数。
// bad
function concatenateAll() {
  const args = Array.prototype.slice.call(arguments);
  return args.join('');
}

// good
function concatenateAll(...args) {
  return args.join('');
}
  • 7.8 使用默认参数语法,而不是在函数里对参数重新赋值。
// really bad
function handleThings(opts) {
  // 虽然你想这么写, 但是这个会带来一些细微的bug
  // 如果 opts 的值为 false, 它会被赋值为 {}
  opts = opts || {};
  // ...
}

// still bad
function handleThings(opts) {
  if (opts === void 0) {
    opts = {};
  }
  // ...
}

// good
function handleThings(opts = {}) {
  // ...
}
  • 7.8 使用默认参数时,需要避免副作用
var b = 1;
// bad
function count(a = b++) {
  console.log(a);
}
count();  // 1
count();  // 2
count(3); // 3
count();  // 3
// 很容易让人懵逼
  • 7.9 把默认参数赋值放在最后
// bad
function handleThings(opts = {}, name) {
  // ...
}

// good
function handleThings(name, opts = {}) {
  // ...
}
  • 7.10 不要用 Function 创建函数。 eslint: no-new-func
// bad
var add = new Function('a', 'b', 'return a + b');

// still bad
var subtract = Function('a', 'b', 'return a - b');
// bad
const f = function(){};
const g = function (){};
const h = function() {};

// good
const x = function () {};
const y = function a() {};
特别注意引用类型的操作,保证数据的不可变性
// bad
function f1(obj) {
  obj.key = 1;
};

// good
function f2(obj) {
  const key = Object.prototype.hasOwnProperty.call(obj, 'key') ? obj.key : 1;
};
// bad
function f1(a) {
  a = 1;
  // ...
}

function f2(a) {
  if (!a) { a = 1; }
  // ...
}

// good
function f3(a) {
  const b = a || 1;
  // ...
}

function f4(a = 1) {
  // ...
}
Why? 这样更清晰,你不必提供上下文,而且你不能轻易地用apply来组成new
// bad
const x = [1, 2, 3, 4, 5];
console.log.apply(console, x);

// good
const x = [1, 2, 3, 4, 5];
console.log(...x);

// bad
new (Function.prototype.bind.apply(Date, [null, 2016, 8, 5]));

// good
new Date(...[2016, 8, 5]);
  • 7.15 多个参数的函数应该像这个指南里的其他多行代码写法一样: 每行只有一个参数,每行逗号结尾。
// bad
function foo(bar,
             baz,
             quux) {
  // ...
}

// good
function foo(
  bar,
  baz,
  quux,
) {
  // ...
}

// bad
console.log(foo,
  bar,
  baz);

// good
console.log(
  foo,
  bar,
  baz,
);

箭头函数

它创建了一个在上下文中执行的函数,这通常是您想要的,并且是一种更简洁的语法。
// bad
[1, 2, 3].map(function (x) {
  const y = x + 1;
  return x * y;
});

// good
[1, 2, 3].map((x) => {
  const y = x + 1;
  return x * y;
});
  • 8.2 如果函数体由一个没有副作用的表达式的单个语句组成,去掉大括号和 return。否则,保留大括号且使用 return 语句。 eslint: arrow-parens, arrow-body-style
// bad
[1, 2, 3].map(number => {
  const nextNumber = number + 1;
  `A string containing the ${nextNumber}.`;
});

// good
[1, 2, 3].map(number => `A string containing the ${number}.`);

// good
[1, 2, 3].map((number) => {
  const nextNumber = number + 1;
  return `A string containing the ${nextNumber}.`;
});

// good
[1, 2, 3].map((number, index) => ({
  [index]: number
}));

// 表达式有副作用就不要用隐式返回
function foo(callback) {
  const val = callback();
  if (val === true) {
    // Do something if callback returns true
  }
}

let bool = false;

// bad
foo(() => bool = true);

// good
foo(() => {
  bool = true;
});
  • 8.3 如果表达式有多行,首尾放在圆括号里更可读。
// bad
['get', 'post', 'put'].map(httpMethod => Object.prototype.hasOwnProperty.call(
    httpMagicObjectWithAVeryLongName,
    httpMethod
  )
);

// good
['get', 'post', 'put'].map(httpMethod => (
  Object.prototype.hasOwnProperty.call(
    httpMagicObjectWithAVeryLongName,
    httpMethod
  )
));
  • 8.4 为了清晰和一致,始终在参数周围加上括号 eslint: arrow-parens
// bad
[1, 2, 3].map((x) => x * x);

// good
[1, 2, 3].map(x => x * x);

// good
[1, 2, 3].map(number => (
  `A long string with the ${number}. It’s so long that we don’t want it to take up space on the .map line!`
));

// bad
[1, 2, 3].map(x => {
  const y = x + 1;
  return x * y;
});

// good
[1, 2, 3].map((x) => {
  const y = x + 1;
  return x * y;
});
// bad
const itemHeight = item => item.height > 256 ? item.largeSize : item.smallSize;

// bad
const itemHeight = (item) => item.height > 256 ? item.largeSize : item.smallSize;

// good
const itemHeight = item => (item.height > 256 ? item.largeSize : item.smallSize);

// good
const itemHeight = (item) => {
  const { height, largeSize, smallSize } = item;
  return height > 256 ? largeSize : smallSize;
};
// bad
(foo) =>
  bar;

(foo) =>
  (bar);

// good
(foo) => bar;
(foo) => (bar);
(foo) => (
   bar
)

类和构造函数

  • 9.1 始终用class,避免直接操作prototype
// bad
function Queue(contents = []) {
  this.queue = [...contents];
}
Queue.prototype.pop = function () {
  const value = this.queue[0];
  this.queue.splice(0, 1);
  return value;
};


// good
class Queue {
  constructor(contents = []) {
    this.queue = [...contents];
  }
  pop() {
    const value = this.queue[0];
    this.queue.splice(0, 1);
    return value;
  }
}
  • 9.2 使用extends实现继承
内置的方法来继承原型,而不会破坏 instanceof
// bad
const inherits = require('inherits');
function PeekableQueue(contents) {
  Queue.apply(this, contents);
}
inherits(PeekableQueue, Queue);
PeekableQueue.prototype.peek = function () {
  return this._queue[0];
}

// good
class PeekableQueue extends Queue {
  peek() {
    return this._queue[0];
  }
}
  • 9.3 方法可以返回this来实现方法链
// bad
Jedi.prototype.jump = function () {
  this.jumping = true;
  return true;
};

Jedi.prototype.setHeight = function (height) {
  this.height = height;
};

const luke = new Jedi();
luke.jump(); // => true
luke.setHeight(20); // => undefined

// good
class Jedi {
  jump() {
    this.jumping = true;
    return this;
  }

  setHeight(height) {
    this.height = height;
    return this;
  }
}

const luke = new Jedi();

luke.jump()
  .setHeight(20);
  • 9.4 允许写一个自定义的 toString() 方法,但是要保证它是可以正常工作且没有副作用
class Jedi {
  constructor(options = {}) {
    this.name = options.name || 'no name';
  }

  getName() {
    return this.name;
  }

  toString() {
    return `Jedi - ${this.getName()}`;
  }
}
  • 9.5 如果没有特殊说明,类有默认的构造方法。不用特意写一个空的构造函数或只是代表父类的构造函数。 eslint: no-useless-constructor
// bad
class Jedi {
  constructor() {}

  getName() {
    return this.name;
  }
}

// bad
class Rey extends Jedi {
  // 这种构造函数是不需要写的
  constructor(...args) {
    super(...args);
  }
}

// good
class Rey extends Jedi {
  constructor(...args) {
    super(...args);
    this.name = 'Rey';
  }
}
重复类成员会默默的执行最后一个,有重复肯定就是一个错误
// bad
class Foo {
  bar() { return 1; }
  bar() { return 2; }
}

// good
class Foo {
  bar() { return 1; }
}

// good
class Foo {
  bar() { return 2; }
}

模块

  • 10.1 在非标准模块系统上使用(import/export)。或者随时换成其他的首选模块系统。
// bad
const AirbnbStyleGuide = require('./AirbnbStyleGuide');
module.exports = AirbnbStyleGuide.es6;

// ok
import AirbnbStyleGuide from './AirbnbStyleGuide';
export default AirbnbStyleGuide.es6;

// best
import { es6 } from './AirbnbStyleGuide';
export default es6;
  • 10.2 不要用 import * 这种通配符
// bad
import * as AirbnbStyleGuide from './AirbnbStyleGuide';

// good
import AirbnbStyleGuide from './AirbnbStyleGuide';
  • 10.3 不要直接从 import 中直接 export
看起来简洁,但是影响可读性
// bad
// filename es6.js
export { es6 as default } from './AirbnbStyleGuide';

// good
// filename es6.js
import { es6 } from './AirbnbStyleGuide';
export default es6;
  • 10.4 一个入口只 import 一次。

eslint: no-duplicate-imports

Why? 从同一个路径下import多行会使代码难以维护
// bad
import foo from 'foo';
// … some other imports … //
import { named1, named2 } from 'foo';

// good
import foo, { named1, named2 } from 'foo';

// good
import foo, {
  named1,
  named2,
} from 'foo';
  • 10.5 不要导出可变的绑定

eslint: import/no-mutable-exports

尽量减少状态,保证数据的不可变性。虽然在某些场景下可能需要这种技术,但总的来说应该导出常量。
// bad
let foo = 3;
export { foo }

// good
const foo = 3;
export { foo }
  • 10.6 在只有一个导出的模块里,用 export default 更好。

eslint: import/prefer-default-export

鼓励使用更多文件,每个文件只做一件事情并导出,这样可读性和可维护性更好。
// bad
export function foo() {}

// good
export default function foo() {}
  • 10.7 import 放在其他所有语句之前。

eslint: import/first

防止意外行为。
// bad
import foo from 'foo';
foo.init();

import bar from 'bar';

// good
import foo from 'foo';
import bar from 'bar';

foo.init();
  • 10.8 多行 import 应该缩进,就像多行数组和对象字面量
// bad
import {longNameA, longNameB, longNameC, longNameD, longNameE} from 'path';

// good
import {
  longNameA,
  longNameB,
  longNameC,
  longNameD,
  longNameE,
} from 'path';
  • 10.9 在 import 语句里不允许 Webpack loader 语法

eslint: import/no-webpack-loader-syntax

最好是在webpack.config.js里写
// bad
import fooSass from 'css!sass!foo.scss';
import barCss from 'style!css!bar.css';

// good
import fooSass from 'foo.scss';
import barCss from 'bar.css';

迭代器和生成器

不可变原则,处理纯函数的返回值比处理副作用更容易。

数组的迭代方法: map() / every() / filter() / find() / findIndex() / reduce() / some() / ... , 对象的处理方法 :Object.keys() / Object.values() / Object.entries() 去产生一个数组, 这样你就能去遍历对象了。

const numbers = [1, 2, 3, 4, 5];

// bad
let sum = 0;
for (let num of numbers) {
  sum += num;
}
sum === 15;

// good
let sum = 0;
numbers.forEach(num => sum += num);
sum === 15;

// best (use the functional force)
const sum = numbers.reduce((total, num) => total + num, 0);
sum === 15;

// bad
const increasedByOne = [];
for (let i = 0; i < numbers.length; i++) {
  increasedByOne.push(numbers[i] + 1);
}

// good
const increasedByOne = [];
numbers.forEach(num => increasedByOne.push(num + 1));

// best (keeping it functional)
const increasedByOne = numbers.map(num => num + 1);
  • 11.2 现在不要用 generator
兼容性不好
function* 是同一概念,关键字 *不是function的修饰符,function*是一个和function不一样的独特结构
// bad
function * foo() {
  // ...
}

// bad
const bar = function * () {
  // ...
}

// bad
const baz = function *() {
  // ...
}

// bad
const quux = function*() {
  // ...
}

// bad
function*foo() {
  // ...
}

// bad
function *foo() {
  // ...
}

// very bad
function
*
foo() {
  // ...
}

// very bad
const wat = function
*
() {
  // ...
}

// good
function* foo() {
  // ...
}

// good
const foo = function* () {
  // ...
}

属性

const luke = {
  jedi: true,
  age: 28,
};

// bad
const isJedi = luke['jedi'];

// good
const isJedi = luke.jedi;
  • 12.2 获取的属性是变量时用方括号[]
const luke = {
  jedi: true,
  age: 28,
};

function getProp(prop) {
  return luke[prop];
}

const isJedi = getProp('jedi');
// bad
const binary = Math.pow(2, 10);

// good
const binary = 2 ** 10;

变量

  • 13.1 始终用 constlet 声明变量。如果你不想遇到一对变量提升、全局变量的 bug 的话。 eslint: no-undefprefer-const
// bad
superPower = new SuperPower();

// good
const superPower = new SuperPower();
  • 13.2 每个变量单独用一个 constlet 。 eslint: one-var
// bad
const items = getItems(),
    goSportsTeam = true,
    dragonball = 'z';

// bad
// (compare to above, and try to spot the mistake)
const items = getItems(),
    goSportsTeam = true;
    dragonball = 'z';

// good
const items = getItems();
const goSportsTeam = true;
const dragonball = 'z';
  • 13.3 const放一起,let放一起
新变量依赖之前的变量或常量时,是有帮助的
// bad
let i, len, dragonball,
    items = getItems(),
    goSportsTeam = true;

// bad
let i;
const items = getItems();
let dragonball;
const goSportsTeam = true;
let len;

// good
const goSportsTeam = true;
const items = getItems();
let dragonball;
let i;
let length;
  • 13.4 变量声明放在合理的位置
// bad - unnecessary function call
function checkName(hasName) {
  const name = getName();

  if (hasName === 'test') {
    return false;
  }

  if (name === 'test') {
    this.setName('');
    return false;
  }

  return name;
}

// good
function checkName(hasName) {
  if (hasName === 'test') {
    return false;
  }

  // 在需要的时候分配
  const name = getName();

  if (name === 'test') {
    this.setName('');
    return false;
  }

  return name;
}
Why? 链接变量分配创建隐式全局变量。
// bad
(function example() {
  // JavaScript 将其解释为
  // let a = ( b = ( c = 1 ) );
  // let 只对变量 a 起作用; 变量 b 和 c 都变成了全局变量
  let a = b = c = 1;
}());

console.log(a); // undefined
console.log(b); // 1
console.log(c); // 1

// good
(function example() {
  let a = 1;
  let b = a;
  let c = a;
}());

console.log(a); // undefined
console.log(b); // undefined
console.log(c); // undefined

// `const` 也一样
  • 13.6 不要使用一元递增递减运算符(++--). eslint no-plusplus
根据 eslint 文档,一元递增和递减语句受到自动分号插入的影响,并且可能会导致应用程序中的值递增或递减的静默错误。 使用num += 1 而不是 num++ 或代替语句来改变你的值也更具表现力。禁止一元递增和递减语句也会阻止您无意中预先递增/预递减值,从而减少程序出现意外行为。
  // bad

  let array = [1, 2, 3];
  let num = 1;
  num++;
  --num;

  let sum = 0;
  let truthyCount = 0;
  for(let i = 0; i < array.length; i++){
    let value = array[i];
    sum += value;
    if (value) {
      truthyCount++;
    }
  }

  // good

  let array = [1, 2, 3];
  let num = 1;
  num += 1;
  num -= 1;

  const sum = array.reduce((a, b) => a + b, 0);
  const truthyCount = array.filter(Boolean).length;
  • 13.7 避免在 = 前/后换行。 如果你的语句超出 max-len, 那就用()把这个值包起来再换行。 eslint operator-linebreak.
// bad
const foo =
  superLongLongLongLongLongLongLongLongFunctionName();

// bad
const foo
  = 'superLongLongLongLongLongLongLongLongString';

// good
const foo = (
  superLongLongLongLongLongLongLongLongFunctionName()
);

// good
const foo = 'superLongLongLongLongLongLongLongLongString';
// bad

var some_unused_var = 42;

// 定义了没有使用
var y = 10;
y = 5;

// 不会将用于修改自身的读取视为已使用
var z = 0;
z = z + 1;

// 参数定义了但未使用
function getX(x, y) {
    return x;
}

// good
function getXPlusY(x, y) {
  return x + y;
}

var x = 1;
var y = a + 2;

alert(getXPlusY(x, y));

// 'type' 即使没有使用也可以被忽略, 因为这个有一个 rest 取值的属性。
// 这是从对象中抽取一个忽略特殊字段的对象的一种形式
var { type, ...coords } = data;
// 'coords' 现在就是一个没有 'type' 属性的 'data' 对象

提升


function example() {
  console.log(notDefined); // => throws a ReferenceError
}

// 在变量声明之前使用会正常输出,是因为变量声明提升,值没有。
function example() {
  console.log(declaredButNotAssigned); // => undefined
  var declaredButNotAssigned = true;
}

// 表现同上
function example() {
  let declaredButNotAssigned;
  console.log(declaredButNotAssigned); // => undefined
  declaredButNotAssigned = true;
}

// 用 const, let 不会发生提升
function example() {
  console.log(declaredButNotAssigned); // => throws a ReferenceError
  console.log(typeof declaredButNotAssigned); // => throws a ReferenceError
  const declaredButNotAssigned = true;
}
  • 14.2 匿名函数表达式和 var 情况相同
function example() {
  console.log(anonymous); // => undefined

  anonymous(); // => TypeError anonymous is not a function

  var anonymous = function () {
    console.log('anonymous function expression');
  };
}
  • 14.3 已命名的函数表达式提升他的变量名,而不是函数名或函数体
function example() {
  console.log(named); // => undefined

  named(); // => TypeError named is not a function

  superPower(); // => ReferenceError superPower is not defined

  var named = function superPower() {
    console.log('Flying');
  };
}

// 函数名和变量名相同也是一样
function example() {
  console.log(named); // => undefined

  named(); // => TypeError named is not a function

  var named = function named() {
    console.log('named');
  };
}
  • 14.4 函数声明则提升了函数名和函数体
function example() {
  superPower(); // => Flying

  function superPower() {
    console.log('Flying');
  }
}

比较和相等

  • 15.1 使用 ===!== 而不是 ==!=. eslint: eqeqeq
  • 15.2 if 等条件语句使用强制 ToBoolean 抽象方法来评估它们的表达式,并且始终遵循以下简单规则:
  • Objects => true
  • Undefined => false
  • Null => false
  • Booleans => the value of the boolean
  • Numbers

    • +0, -0, or NaN => false
    • 其他 => true
  • Strings

    • '' => false
    • 其他 => true
if ([0] && []) {
  // true
  // 数组(即使是空数组)是对象,对象会计算成 true
}
  • 15.3 布尔值比较可以省略,但是字符串和数字要显示比较
// bad
if (isValid === true) {
  // ...
}

// good
if (isValid) {
  // ...
}

// bad
if (name) {
  // ...
}

// good
if (name !== '') {
  // ...
}

// bad
if (collection.length) {
  // ...
}

// good
if (collection.length > 0) {
  // ...
}
  • 15.4 switch case 中,在 casedefault 分句里用大括号创建一个块(如:let, const, function, and class). eslint rules: no-case-declarations.
词汇声明在整个 switch 块中都是可见的,但只有在分配时才会被初始化,这只有在 case 达到时才会发生。当多个 case 子句尝试定义相同的事物时,会出现问题。
// bad
switch (foo) {
  case 1:
    let x = 1;
    break;
  case 2:
    const y = 2;
    break;
  case 3:
    function f() {
      // ...
    }
    break;
  default:
    class C {}
}

// good
switch (foo) {
  case 1: {
    let x = 1;
    break;
  }
  case 2: {
    const y = 2;
    break;
  }
  case 3: {
    function f() {
      // ...
    }
    break;
  }
  case 4:
    bar();
    break;
  default: {
    class C {}
  }
}
  • 15.5 三元表达式不应该嵌套,通常是单行表达式。

eslint rules: no-nested-ternary.

// bad
const foo = maybe1 > maybe2
  ? "bar"
  : value1 > value2 ? "baz" : null;

// better
const maybeNull = value1 > value2 ? 'baz' : null;

const foo = maybe1 > maybe2
  ? 'bar'
  : maybeNull;

// best
const maybeNull = value1 > value2 ? 'baz' : null;

const foo = maybe1 > maybe2 ? 'bar' : maybeNull;
  • 15.7 避免不需要的三元表达式

eslint rules: no-unneeded-ternary.

// bad
const foo = a ? a : b;
const bar = c ? true : false;
const baz = c ? false : true;

// good
const foo = a || b;
const bar = !!c;
const baz = !c;
  • 15.8 混合操作符时,要放在 () 里,只有当它们是标准的算术运算符(+, -, *, & /), 并且它们的优先级显而易见时,可以不用。 eslint: no-mixed-operators
// bad
const foo = a && b < 0 || c > 0 || d + 1 === 0;

// bad
const bar = a ** b - 5 % d;

// bad
if (a || b && c) {
  return d;
}

// good
const foo = (a && b < 0) || c > 0 || (d + 1 === 0);

// good
const bar = (a ** b) - (5 % d);

// good
if (a || (b && c)) {
  return d;
}

// good
const bar = a + b / c * d;

// bad
if (test)
  return false;

// good
if (test) return false;

// good
if (test) {
  return false;
}

// bad
function foo() { return false; }

// good
function bar() {
  return false;
}
  • 16.2 elseif 的大括号保持在一行。 eslint: brace-style
// bad
if (test) {
  thing1();
  thing2();
}
else {
  thing3();
}

// good
if (test) {
  thing1();
  thing2();
} else {
  thing3();
}
  • 16.3 如果 if 语句都要用 return 返回, 那后面的 else 就不用写了。 如果 if 块中包含 return, 它后面的 else if 块中也包含了 return, 这个时候就可以把 else if 拆开。 eslint: no-else-return
// bad
function foo() {
  if (x) {
    return x;
  } else {
    return y;
  }
}

// bad
function cats() {
  if (x) {
    return x;
  } else if (y) {
    return y;
  }
}

// bad
function dogs() {
  if (x) {
    return x;
  } else {
    if (y) {
      return y;
    }
  }
}

// good
function foo() {
  if (x) {
    return x;
  }

  return y;
}

// good
function cats() {
  if (x) {
    return x;
  }

  if (y) {
    return y;
  }
}

// good
function dogs(x) {
  if (x) {
    if (z) {
      return y;
    }
  } else {
    return z;
  }
}

控制

  • 17.1 当你的控制语句 if, while 等太长或者超过最大长度限制的时候,把每个判断条件放在单独一行里,逻辑运算符放在行首。
// bad
if ((foo === 123 || bar === 'abc') && doesItLookGoodWhenItBecomesThatLong() && isThisReallyHappening()) {
  thing1();
}

// bad
if (foo === 123 &&
  bar === 'abc') {
  thing1();
}

// bad
if (foo === 123
  && bar === 'abc') {
  thing1();
}

// bad
if (
  foo === 123 &&
  bar === 'abc'
) {
  thing1();
}

// good
if (
  foo === 123
  && bar === 'abc'
) {
  thing1();
}

// good
if (
  (foo === 123 || bar === 'abc')
  && doesItLookGoodWhenItBecomesThatLong()
  && isThisReallyHappening()
) {
  thing1();
}

// good
if (foo === 123 && bar === 'abc') {
  thing1();
}
  • 17.2 不要用选择操作符代替控制语句。
// bad
!isRunning && startRunning();

// good
if (!isRunning) {
  startRunning();
}

注释

  • 18.1 多行注释用 /** ... */
// bad
// make() returns a new element
// based on the passed in tag name
//
// @param {String} tag
// @return {Element} element
function make(tag) {

  // ...

  return element;
}

// good
/**
 * make() returns a new element
 * based on the passed-in tag name
 */
function make(tag) {

  // ...

  return element;
}
  • 18.2 单行注释用//,将单行注释放在被注释区域上方。如果注释不是在第一行,就在注释前面加一个空行
// bad
const active = true;  // is current tab

// good
// is current tab
const active = true;

// bad
function getType() {
  console.log('fetching type...');
  // set the default type to 'no type'
  const type = this._type || 'no type';

  return type;
}

// good
function getType() {
  console.log('fetching type...');

  // set the default type to 'no type'
  const type = this._type || 'no type';

  return type;
}

// also good
function getType() {
  // set the default type to 'no type'
  const type = this._type || 'no type';

  return type;
}
  • 18.3 所有注释开头加一个空格,方便阅读。 eslint: spaced-comment
// bad
//is current tab
const active = true;

// good
// is current tab
const active = true;

// bad
/**
 *make() returns a new element
 *based on the passed-in tag name
 */
function make(tag) {

  // ...

  return element;
}

// good
/**
 * make() returns a new element
 * based on the passed-in tag name
 */
function make(tag) {

  // ...

  return element;
}
  • 18.4 在注释前加上 FIXME' 或 TODO` 前缀, 这有助于其他开发人员快速理解你指出的问题, 或者您建议的问题的解决方案。
class Calculator extends Abacus {
  constructor() {
    super();

    // FIXME: shouldn't use a global here
    total = 0;
  }
}
class Calculator extends Abacus {
  constructor() {
    super();

    // TODO: total should be configurable by an options param
    this.total = 0;
  }
}

空格

  • 19.1 Tab 使用两个空格(或者 4 个,你开心就好,但是团队统一是必须的)。 eslint: indent
// bad
function foo() {
∙∙∙∙const name;
}

// bad
function bar() {
∙const name;
}

// good
function baz() {
∙∙const name;
}
// bad
function test(){
  console.log('test');
}

// good
function test() {
  console.log('test');
}

// bad
dog.set('attr',{
  age: '1 year',
  breed: 'Bernese Mountain Dog',
});

// good
dog.set('attr', {
  age: '1 year',
  breed: 'Bernese Mountain Dog',
});
  • 19.3 在控制语句 if, while 等的圆括号前空一格。在函数调用和定义时,函数名和圆括号之间不空格。 eslint: keyword-spacing
// bad
if(isJedi) {
  fight ();
}

// good
if (isJedi) {
  fight();
}

// bad
function fight () {
  console.log ('Swooosh!');
}

// good
function fight() {
  console.log('Swooosh!');
}
// bad
const x=y+5;

// good
const x = y + 5;
  • 19.5 文件结尾空一行. eslint: eol-last
// bad
import { es6 } from './AirbnbStyleGuide';
  // ...
export default es6;
// bad
import { es6 } from './AirbnbStyleGuide';
  // ...
export default es6;↵
↵
// good
import { es6 } from './AirbnbStyleGuide';
  // ...
export default es6;↵
// bad
$('#items').find('.selected').highlight().end().find('.open').updateCount();

// bad
$('#items').
  find('.selected').
    highlight().
    end().
  find('.open').
    updateCount();

// good
$('#items')
  .find('.selected')
    .highlight()
    .end()
  .find('.open')
    .updateCount();

// bad
const leds = stage.selectAll('.led').data(data).enter().append('svg:svg').classed('led', true)
    .attr('width', (radius + margin) * 2).append('svg:g')
    .attr('transform', `translate(${radius + margin},${radius + margin})`)
    .call(tron.led);

// good
const leds = stage.selectAll('.led')
    .data(data)
  .enter().append('svg:svg')
    .classed('led', true)
    .attr('width', (radius + margin) * 2)
  .append('svg:g')
    .attr('transform', `translate(${radius + margin},${radius + margin})`)
    .call(tron.led);

// good
const leds = stage.selectAll('.led').data(data);
  • 19.7 在一个代码块之后,下一条语句之前空一行。
// bad
if (foo) {
  return bar;
}
return baz;

// good
if (foo) {
  return bar;
}

return baz;

// bad
const obj = {
  foo() {
  },
  bar() {
  },
};
return obj;

// good
const obj = {
  foo() {
  },

  bar() {
  },
};

return obj;

// bad
const arr = [
  function foo() {
  },
  function bar() {
  },
];
return arr;

// good
const arr = [
  function foo() {
  },

  function bar() {
  },
];

return arr;
  • 19.8 不要故意留一些没必要的空白行。 eslint: padded-blocks
// bad
function bar() {

  console.log(foo);

}

// also bad
if (baz) {

  console.log(qux);
} else {
  console.log(foo);

}

// good
function bar() {
  console.log(foo);
}

// good
if (baz) {
  console.log(qux);
} else {
  console.log(foo);
}
// bad
function bar( foo ) {
  return foo;
}

// good
function bar(foo) {
  return foo;
}

// bad
if ( foo ) {
  console.log(foo);
}

// good
if (foo) {
  console.log(foo);
}
// bad
const foo = [ 1, 2, 3 ];
console.log(foo[ 0 ]);

// good, 逗号后面要加空格
const foo = [1, 2, 3];
console.log(foo[0]);
// bad
const foo = {clark: 'kent'};

// good
const foo = { clark: 'kent' };


// bad
function foo() {return true;}
if (foo) { bar = 0;}

// good
function foo() { return true; }
if (foo) { bar = 0; }
  • 19.12 避免一行代码超过 100 个字符(包含空格、纯字符串就不要换行了)。
// bad
const foo = jsonData && jsonData.foo && jsonData.foo.bar && jsonData.foo.bar.baz && jsonData.foo.bar.baz.quux && jsonData.foo.bar.baz.quux.xyzzy;

// bad
$.ajax({ method: 'POST', url: 'https://airbnb.com/', data: { name: 'John' } }).done(() => console.log('Congratulations!')).fail(() => console.log('You have failed this city.'));

// good
const foo = jsonData
  && jsonData.foo
  && jsonData.foo.bar
  && jsonData.foo.bar.baz
  && jsonData.foo.bar.baz.quux
  && jsonData.foo.bar.baz.quux.xyzzy;

// good
$.ajax({
  method: 'POST',
  url: 'https://airbnb.com/',
  data: { name: 'John' },
})
  .done(() => console.log('Congratulations!'))
  .fail(() => console.log('You have failed this city.'));
  • 19.13 , 前避免空格, , 后需要空格。 eslint: comma-spacing
// bad
var foo = 1,bar = 2;
var arr = [1 , 2];

// good
var foo = 1, bar = 2;
var arr = [1, 2];
  • 19.14 在对象的属性中, 键值之间要有空格。 eslint: key-spacing
// bad
var obj = { "foo" : 42 };
var obj2 = { "foo":42 };

// good
var obj = { "foo": 42 };

<!-- markdownlint-disable MD012 -->

// bad
var x = 1;

var y = 2;

// good
var x = 1;

var y = 2;

逗号

// bad
const story = [
    once
  , upon
  , aTime
];

// good
const story = [
  once,
  upon,
  aTime,
];

// bad
const hero = {
    firstName: 'Ada'
  , lastName: 'Lovelace'
  , birthYear: 1815
  , superPower: 'computers'
};

// good
const hero = {
  firstName: 'Ada',
  lastName: 'Lovelace',
  birthYear: 1815,
  superPower: 'computers',
};
  • 20.2 结尾额外加逗号,看团队习惯吧 eslint: comma-dangle
// bad - 没有结尾逗号的 git diff
const hero = {
     firstName: 'Florence',
-    lastName: 'Nightingale'
+    lastName: 'Nightingale',
+    inventorOf: ['coxcomb chart', 'modern nursing']
};

// good - 有结尾逗号的 git diff
const hero = {
     firstName: 'Florence',
     lastName: 'Nightingale',
+    inventorOf: ['coxcomb chart', 'modern nursing'],
};
// bad
const hero = {
  firstName: 'Dana',
  lastName: 'Scully'
};

const heroes = [
  'Batman',
  'Superman'
];

// good
const hero = {
  firstName: 'Dana',
  lastName: 'Scully',
};

const heroes = [
  'Batman',
  'Superman',
];

// bad
function createHero(
  firstName,
  lastName,
  inventorOf
) {
  // does nothing
}

// good
function createHero(
  firstName,
  lastName,
  inventorOf,
) {
  // does nothing
}

// good (note that a comma must not appear after a "rest" element)
function createHero(
  firstName,
  lastName,
  inventorOf,
  ...heroArgs
) {
  // does nothing
}

// bad
createHero(
  firstName,
  lastName,
  inventorOf
);

// good
createHero(
  firstName,
  lastName,
  inventorOf,
);

// good (note that a comma must not appear after a "rest" element)
createHero(
  firstName,
  lastName,
  inventorOf,
  ...heroArgs
)

分号

  • 21.1 当 JavaScript 遇到没有分号的换行符时,它会使用Automatic Semicolon Insertion这一规则来决定行末是否加分号。但是,ASI 包含一些古怪的行为,如果 JavaScript 弄错了你的换行符,你的代码就会破坏。所以明确地使用分号,会减少这种不确定性。
// bad
(function () {
  const name = 'Skywalker'
  return name
})()

// good
(function () {
  const name = 'Skywalker';
  return name;
}());

// good
;(() => {
  const name = 'Skywalker';
  return name;
}());

更多.

类型

  • 22.1 在声明开头执行强制类型转换。
  • 22.2 String eslint: no-new-wrappers
// => this.reviewScore = 9;

// bad
const totalScore = new String(this.reviewScore); // typeof totalScore is "object" not "string"

// bad
const totalScore = this.reviewScore + ''; // invokes this.reviewScore.valueOf()

// bad
const totalScore = this.reviewScore.toString(); // 不保证返回string

// good
const totalScore = String(this.reviewScore);
  • 22.3 Number eslint: radix
const inputValue = '4';

// bad
const val = new Number(inputValue);

// bad
const val = +inputValue;

// bad
const val = inputValue >> 0;

// bad
const val = parseInt(inputValue);

// good
const val = Number(inputValue);

// good
const val = parseInt(inputValue, 10);
  • 22.4 请在注释中解释为什么要用移位运算,无论你在做什么,比如由于 parseInt 是你的性能瓶颈导致你一定要用移位运算。 请说明这个是因为性能原因,
// good
/**
 * parseInt 导致代码运行慢
 * Bitshifting the String 将其强制转换为数字使其快得多。
 */
const val = inputValue >> 0;
  • 22.5 注意: 使用 bitshift 操作时要小心。数字表示为 64 位值,但 bitshift 操作始终返回 32 位整数。对于大于32位的整数值,Bitshift可能会导致意外行为。
2147483647 >> 0 //=> 2147483647
2147483648 >> 0 //=> -2147483648
2147483649 >> 0 //=> -2147483647
  • 22.6 Booleans
const age = 0;

// bad
const hasAge = new Boolean(age);

// good
const hasAge = Boolean(age);

// best
const hasAge = !!age;

命名约定

  • 23.1 避免用一个字母命名,让你的命名更加语义化。 eslint: id-length
// bad
function q() {
  // ...
}

// good
function query() {
  // ...
}
  • 23.2 用 camelCase 命名你的对象、函数、实例。 eslint: camelcase
// bad
const OBJEcttsssss = {};
const this_is_my_object = {};
function c() {}

// good
const thisIsMyObject = {};
function thisIsMyFunction() {}
  • 23.3 用 PascalCase 命名类。 eslint: new-cap
// bad
function user(options) {
  this.name = options.name;
}

const bad = new user({
  name: 'nope',
});

// good
class User {
  constructor(options) {
    this.name = options.name;
  }
}

const good = new User({
  name: 'yup',
});
JavaScript 没有私有属性或方法的概念。尽管前置下划线通常的概念上意味着 “private”,但其实,这些属性是完全公开的,因此这部分也是你的 API 的内容。这一概念可能会导致开发者误以为更改这个不会导致崩溃或者不需要测试。
// bad
this.__firstName__ = 'Panda';
this.firstName_ = 'Panda';
this._firstName = 'Panda';

// good
this.firstName = 'Panda';
  • 23.5 不要保存 this 的引用,使用箭头函数或硬绑定。
// bad
function foo() {
  const self = this;
  return function () {
    console.log(self);
  };
}

// bad
function foo() {
  const that = this;
  return function () {
    console.log(that);
  };
}

// good
function foo() {
  return () => {
    console.log(this);
  };
}
  • 23.6 文件名应与默认导出(export default)的名称完全匹配
// file 1 contents
class CheckBox {
  // ...
}
export default CheckBox;

// file 2 contents
export default function fortyTwo() { return 42; }

// file 3 contents
export default function insideDirectory() {}

// in some other file
// bad
import CheckBox from './checkBox'; // PascalCase import/export, camelCase filename
import FortyTwo from './FortyTwo'; // PascalCase import/filename, camelCase export
import InsideDirectory from './InsideDirectory'; // PascalCase import/filename, camelCase export

// bad
import CheckBox from './check_box'; // PascalCase import/export, snake_case filename
import forty_two from './forty_two'; // snake_case import/filename, camelCase export
import inside_directory from './inside_directory'; // snake_case import, camelCase export
import index from './inside_directory/index'; // requiring the index file explicitly
import insideDirectory from './insideDirectory/index'; // requiring the index file explicitly

// good
import CheckBox from './CheckBox'; // PascalCase export/import/filename
import fortyTwo from './fortyTwo'; // camelCase export/import/filename
import insideDirectory from './insideDirectory'; // camelCase export/import/directory name/implicit "index"
// ^ supports both insideDirectory.js and insideDirectory/index.js
  • 23.7 默认导出(export default)一个函数时,函数名、文件名统一。
function makeStyleGuide() {
  // ...
}

export default makeStyleGuide;
  • 23.8 当你 export 一个构造函数/类/单例/函数库对象时用 PascalCase。
const AirbnbStyleGuide = {
  es6: {
  }
};

export default AirbnbStyleGuide;
  • 23.9 简称和首字母缩写应该全部大写或全部小写。
名字是给人看的,不是给电脑看的。
// bad
import SmsContainer from './containers/SmsContainer';

// bad
const HttpRequests = [
  // ...
];

// good
import SMSContainer from './containers/SMSContainer';

// good
const HTTPRequests = [
  // ...
];

// best
import TextMessageContainer from './containers/TextMessageContainer';

// best
const Requests = [
  // ...
];
  • 23.10 全大写字母定义用来导出的常量
// bad
const PRIVATE_VARIABLE = 'should not be unnecessarily uppercased within a file';

// bad
export const THING_TO_BE_CHANGED = 'should obviously not be uppercased';

// bad
export let REASSIGNABLE_VARIABLE = 'do not use let with uppercase variables';

// ---

// allowed but does not supply semantic value
export const apiKey = 'SOMEKEY';

// better in most cases
export const API_KEY = 'SOMEKEY';

// ---

// bad - unnecessarily uppercases key while adding no semantic value
export const MAPPING = {
  KEY: 'value'
};

// good
export const MAPPING = {
  key: 'value'
};

访问器

  • 24.1 不需要使用属性的访问器函数。
  • 24.2 不要使用 JavaScript 的 getters/setters,因为他们会产生副作用,并且难以测试、维护和理解。如果必要,你可以用 getVal()和 setVal() 去构建。
// bad
class Dragon {
  get age() {
    // ...
  }

  set age(value) {
    // ...
  }
}

// good
class Dragon {
  getAge() {
    // ...
  }

  setAge(value) {
    // ...
  }
}
  • 24.3 如果属性/方法是一个 boolean, 请用 isVal()hasVal()
// bad
if (!dragon.age()) {
  return false;
}

// good
if (!dragon.hasAge()) {
  return false;
}
  • 24.4 可以用 get() 和 set() 函数,但是要保持一致。
class Jedi {
  constructor(options = {}) {
    const lightsaber = options.lightsaber || 'blue';
    this.set('lightsaber', lightsaber);
  }

  set(key, val) {
    this[key] = val;
  }

  get(key) {
    return this[key];
  }
}

Events

  • 25.1 给事件或其他传递数据时,不直接使用原始值,而是通过对象包装。这样在未来需要增加或减少参数,不必找到每个使用中的处理器。
// bad
$(this).trigger('listingUpdated', listing.id);

...

$(this).on('listingUpdated', (e, listingId) => {
  // do something with listingId
});

prefer:

// good
$(this).trigger('listingUpdated', { listingId: listing.id });

...

$(this).on('listingUpdated', (e, data) => {
  // do something with data.listingId
});

小结

所谓规范,更多的还是为了代码的可读性,毕竟我们的代码更重要的是给人看。同时,合理的规范,也会帮助我们规避很多不必要的 bug。

交流群

关注微信公众号:前端发动机,回复:加群。

后记

如果你看到了这里,且本文对你有一点帮助的话,希望你可以动动小手支持一下作者,感谢🍻。文中如有不对之处,也欢迎大家指出,共勉。好了,又耽误大家的时间了,感谢阅读,下次再见!

感兴趣的同学可以关注下我的公众号 前端发动机,好玩又有料。

查看原文

赞 27 收藏 21 评论 3

this亦然 赞了文章 · 2019-08-15

深入理解Node.js 进程与线程(8000长文彻底搞懂)

前言

进程线程是一个程序员的必知概念,面试经常被问及,但是一些文章内容只是讲讲理论知识,可能一些小伙伴并没有真的理解,在实际开发中应用也比较少。本篇文章除了介绍概念,通过Node.js 的角度讲解进程线程,并且讲解一些在项目中的实战的应用,让你不仅能迎战面试官还可以在实战中完美应用。

文章导览

16c6cf612c275894?w=2772&h=1104&f=jpeg&s=377258

面试会问

Node.js是单线程吗?

Node.js 做耗时的计算时候,如何避免阻塞?

Node.js如何实现多进程的开启和关闭?

Node.js可以创建线程吗?

你们开发过程中如何实现进程守护的?

除了使用第三方模块,你们自己是否封装过一个多进程架构?

进程

进程Process是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础,进程是线程的容器(来自百科)。进程是资源分配的最小单位。我们启动一个服务、运行一个实例,就是开一个服务进程,例如 Java 里的 JVM 本身就是一个进程,Node.js 里通过 node app.js 开启一个服务进程,多进程就是进程的复制(fork),fork 出来的每个进程都拥有自己的独立空间地址、数据栈,一个进程无法访问另外一个进程里定义的变量、数据结构,只有建立了 IPC 通信,进程之间才可数据共享。

  • Node.js开启服务进程例子
const http = require('http');

const server = http.createServer();
server.listen(3000,()=>{
    process.title='程序员成长指北测试进程';
    console.log('进程id',process.pid)
})

运行上面代码后,以下为 Mac 系统自带的监控工具 “活动监视器” 所展示的效果,可以看到我们刚开启的 Nodejs 进程 7663

16c4dc0ca13fec40?w=1406&h=1182&f=jpeg&s=131412

线程

线程是操作系统能够进行运算调度的最小单位,首先我们要清楚线程是隶属于进程的,被包含于进程之中。一个线程只能隶属于一个进程,但是一个进程是可以拥有多个线程的

单线程

单线程就是一个进程只开一个线程

Javascript 就是属于单线程,程序顺序执行(这里暂且不提JS异步),可以想象一下队列,前面一个执行完之后,后面才可以执行,当你在使用单线程语言编码时切勿有过多耗时的同步操作,否则线程会造成阻塞,导致后续响应无法处理。你如果采用 Javascript 进行编码时候,请尽可能的利用Javascript异步操作的特性。

经典计算耗时造成线程阻塞的例子

const http = require('http');
const longComputation = () => {
  let sum = 0;
  for (let i = 0; i < 1e10; i++) {
    sum += i;
  };
  return sum;
};
const server = http.createServer();
server.on('request', (req, res) => {
  if (req.url === '/compute') {
    console.info('计算开始',new Date());
    const sum = longComputation();
    console.info('计算结束',new Date());
    return res.end(`Sum is ${sum}`);
  } else {
    res.end('Ok')
  }
});

server.listen(3000);
//打印结果
//计算开始 2019-07-28T07:08:49.849Z
//计算结束 2019-07-28T07:09:04.522Z

查看打印结果,当我们调用127.0.0.1:3000/compute
的时候,如果想要调用其他的路由地址比如127.0.0.1/大约需要15秒时间,也可以说一个用户请求完第一个compute接口后需要等待15秒,这对于用户来说是极其不友好的。下文我会通过创建多进程的方式child_process.forkcluster 来解决解决这个问题。

单线程的一些说明

  • Node.js 虽然是单线程模型,但是其基于事件驱动、异步非阻塞模式,可以应用于高并发场景,避免了线程创建、线程之间上下文切换所产生的资源开销。
  • 当你的项目中需要有大量计算,CPU 耗时的操作时候,要注意考虑开启多进程来完成了。
  • Node.js 开发过程中,错误会引起整个应用退出,应用的健壮性值得考验,尤其是错误的异常抛出,以及进程守护是必须要做的。
  • 单线程无法利用多核CPU,但是后来Node.js 提供的API以及一些第三方工具相应都得到了解决,文章后面都会讲到。

Node.js 中的进程与线程

Node.js 是 Javascript 在服务端的运行环境,构建在 chrome 的 V8 引擎之上,基于事件驱动、非阻塞I/O模型,充分利用操作系统提供的异步 I/O 进行多任务的执行,适合于 I/O 密集型的应用场景,因为异步,程序无需阻塞等待结果返回,而是基于回调通知的机制,原本同步模式等待的时间,则可以用来处理其它任务,

科普:在 Web 服务器方面,著名的 Nginx 也是采用此模式(事件驱动),避免了多线程的线程创建、线程上下文切换的开销,Nginx 采用 C 语言进行编写,主要用来做高性能的 Web 服务器,不适合做业务。

Web业务开发中,如果你有高并发应用场景那么 Node.js 会是你不错的选择。

在单核 CPU 系统之上我们采用 单进程 + 单线程 的模式来开发。在多核 CPU 系统之上,可以通过 child_process.fork 开启多个进程(Node.js 在 v0.8 版本之后新增了Cluster 来实现多进程架构) ,即 多进程 + 单线程 模式。注意:开启多进程不是为了解决高并发,主要是解决了单进程模式下 Node.js CPU 利用率不足的情况,充分利用多核 CPU 的性能。

Node.js 中的进程

process 模块

Node.js 中的进程 Process 是一个全局对象,无需 require 直接使用,给我们提供了当前进程中的相关信息。官方文档提供了详细的说明,感兴趣的可以亲自实践下 Process 文档。

  • process.env:环境变量,例如通过 process.env.NODE_ENV 获取不同环境项目配置信息
  • process.nextTick:这个在谈及 Event Loop 时经常为会提到
  • process.pid:获取当前进程id
  • process.ppid:当前进程对应的父进程
  • process.cwd():获取当前进程工作目录,
  • process.platform:获取当前进程运行的操作系统平台
  • process.uptime():当前进程已运行时间,例如:pm2 守护进程的 uptime 值
  • 进程事件:process.on(‘uncaughtException’, cb) 捕获异常信息、process.on(‘exit’, cb)进程推出监听
  • 三个标准流:process.stdout 标准输出、process.stdin 标准输入、process.stderr 标准错误输出
  • process.title 指定进程名称,有的时候需要给进程指定一个名称

以上仅列举了部分常用到功能点,除了 Process 之外 Node.js 还提供了 child_process 模块用来对子进程进行操作,在下文 Nodejs进程创建会继续讲述。

Node.js 进程创建

进程创建有多种方式,本篇文章以child_process模块和cluster模块进行讲解。

child_process模块

child_process 是 Node.js 的内置模块,官网地址:

child_process 官网地址:http://nodejs.cn/api/child_pr...

几个常用函数:
四种方式

  • child_process.spawn():适用于返回大量数据,例如图像处理,二进制数据处理。
  • child_process.exec():适用于小量数据,maxBuffer 默认值为 200 * 1024 超出这个默认值将会导致程序崩溃,数据量过大可采用 spawn。
  • child_process.execFile():类似 child_process.exec(),区别是不能通过 shell 来执行,不支持像 I/O 重定向和文件查找这样的行为
  • child_process.fork(): 衍生新的进程,进程之间是相互独立的,每个进程都有自己的 V8 实例、内存,系统资源是有限的,不建议衍生太多的子进程出来,通长根据系统 CPU 核心数设置。
CPU 核心数这里特别说明下,fork 确实可以开启多个进程,但是并不建议衍生出来太多的进程,cpu核心数的获取方式const cpus = require('os').cpus();,这里 cpus 返回一个对象数组,包含所安装的每个 CPU/内核的信息,二者总和的数组哦。假设主机装有两个cpu,每个cpu有4个核,那么总核数就是8。
fork开启子进程 Demo

fork开启子进程解决文章起初的计算耗时造成线程阻塞。
在进行 compute 计算时创建子进程,子进程计算完成通过 send 方法将结果发送给主进程,主进程通过 message 监听到信息后处理并退出。

fork_app.js
const http = require('http');
const fork = require('child_process').fork;

const server = http.createServer((req, res) => {
    if(req.url == '/compute'){
        const compute = fork('./fork_compute.js');
        compute.send('开启一个新的子进程');

        // 当一个子进程使用 process.send() 发送消息时会触发 'message' 事件
        compute.on('message', sum => {
            res.end(`Sum is ${sum}`);
            compute.kill();
        });

        // 子进程监听到一些错误消息退出
        compute.on('close', (code, signal) => {
            console.log(`收到close事件,子进程收到信号 ${signal} 而终止,退出码 ${code}`);
            compute.kill();
        })
    }else{
        res.end(`ok`);
    }
});
server.listen(3000, 127.0.0.1, () => {
    console.log(`server started at http://${127.0.0.1}:${3000}`);
});
fork_compute.js

针对文初需要进行计算的的例子我们创建子进程拆分出来单独进行运算。

const computation = () => {
    let sum = 0;
    console.info('计算开始');
    console.time('计算耗时');

    for (let i = 0; i < 1e10; i++) {
        sum += i
    };

    console.info('计算结束');
    console.timeEnd('计算耗时');
    return sum;
};

process.on('message', msg => {
    console.log(msg, 'process.pid', process.pid); // 子进程id
    const sum = computation();

    // 如果Node.js进程是通过进程间通信产生的,那么,process.send()方法可以用来给父进程发送消息
    process.send(sum);
})
cluster模块

cluster 开启子进程Demo

const http = require('http');
const numCPUs = require('os').cpus().length;
const cluster = require('cluster');
if(cluster.isMaster){
    console.log('Master proces id is',process.pid);
    // fork workers
    for(let i= 0;i<numCPUs;i++){
        cluster.fork();
    }
    cluster.on('exit',function(worker,code,signal){
        console.log('worker process died,id',worker.process.pid)
    })
}else{
    // Worker可以共享同一个TCP连接
    // 这里是一个http服务器
    http.createServer(function(req,res){
        res.writeHead(200);
        res.end('hello word');
    }).listen(8000);

}
cluster原理分析

16c5658b2e97e9b2

cluster模块调用fork方法来创建子进程,该方法与child_process中的fork是同一个方法。
cluster模块采用的是经典的主从模型,Cluster会创建一个master,然后根据你指定的数量复制出多个子进程,可以使用cluster.isMaster属性判断当前进程是master还是worker(工作进程)。由master进程来管理所有的子进程,主进程不负责具体的任务处理,主要工作是负责调度和管理。

cluster模块使用内置的负载均衡来更好地处理线程之间的压力,该负载均衡使用了Round-robin算法(也被称之为循环算法)。当使用Round-robin调度策略时,master accepts()所有传入的连接请求,然后将相应的TCP请求处理发送给选中的工作进程(该方式仍然通过IPC来进行通信)。

开启多进程时候端口疑问讲解:如果多个Node进程监听同一个端口时会出现 Error:listen EADDRIUNS的错误,而cluster模块为什么可以让多个子进程监听同一个端口呢?原因是master进程内部启动了一个TCP服务器,而真正监听端口的只有这个服务器,当来自前端的请求触发服务器的connection事件后,master会将对应的socket具柄发送给子进程。

child_process 模块与cluster 模块总结

无论是 child_process 模块还是 cluster 模块,为了解决 Node.js 实例单线程运行,无法利用多核 CPU 的问题而出现的。核心就是父进程(即 master 进程)负责监听端口,接收到新的请求后将其分发给下面的 worker 进程

cluster模块的一个弊端:

16c565aaeb065b4a?w=501&h=261&f=png&s=23033

cluster内部隐时的构建TCP服务器的方式来说对使用者确实简单和透明了很多,但是这种方式无法像使用child_process那样灵活,因为一直主进程只能管理一组相同的工作进程,而自行通过child_process来创建工作进程,一个主进程可以控制多组进程。原因是child_process操作子进程时,可以隐式的创建多个TCP服务器,对比上面的两幅图应该能理解我说的内容。

Node.js进程通信原理

前面讲解的无论是child_process模块,还是cluster模块,都需要主进程和工作进程之间的通信。通过fork()或者其他API,创建了子进程之后,为了实现父子进程之间的通信,父子进程之间才能通过message和send()传递信息。

IPC这个词我想大家并不陌生,不管那一张开发语言只要提到进程通信,都会提到它。IPC的全称是Inter-Process Communication,即进程间通信。它的目的是为了让不同的进程能够互相访问资源并进行协调工作。实现进程间通信的技术有很多,如命名管道,匿名管道,socket,信号量,共享内存,消息队列等。Node中实现IPC通道是依赖于libuv。windows下由命名管道(name pipe)实现,*nix系统则采用Unix Domain Socket实现。表现在应用层上的进程间通信只有简单的message事件和send()方法,接口十分简洁和消息化。

IPC创建和实现示意图

16c5b379ad12199e?w=391&h=311&f=png&s=23661

IPC通信管道是如何创建的

16c5b3812e3bb7d9?w=866&h=612&f=jpeg&s=103501

父进程在实际创建子进程之前,会创建IPC通道并监听它,然后才真正的创建出子进程,这个过程中也会通过环境变量(NODE_CHANNEL_FD)告诉子进程这个IPC通道的文件描述符。子进程在启动的过程中,根据文件描述符去连接这个已存在的IPC通道,从而完成父子进程之间的连接。

Node.js句柄传递

讲句柄之前,先想一个问题,send句柄发送的时候,真的是将服务器对象发送给了子进程?

子进程对象send()方法可以发送的句柄类型
  • net.Socket TCP套接字
  • net.Server TCP服务器,任意建立在TCP服务上的应用层服务都可以享受它带来的好处
  • net.Native C++层面的TCP套接字或IPC管道
  • dgram.Socket UDP套接字
  • dgram.Native C++层面的UDP套接字
send句柄发送原理分析

结合句柄的发送与还原示意图更容易理解。

16c5b52b15d87bbe?w=916&h=548&f=png&s=82815
send()方法在将消息发送到IPC管道前,实际将消息组装成了两个对象,一个参数是hadler,另一个是message。message参数如下所示:

{
    cmd:'NODE_HANDLE',
    type:'net.Server',
    msg:message
}

发送到IPC管道中的实际上是我们要发送的句柄文件描述符。这个message对象在写入到IPC管道时,也会通过JSON.stringfy()进行序列化。所以最终发送到IPC通道中的信息都是字符串,send()方法能发送消息和句柄并不意味着它能发送任何对象。

连接了IPC通道的子线程可以读取父进程发来的消息,将字符串通过JSON.parse()解析还原为对象后,才触发message事件将消息传递给应用层使用。在这个过程中,消息对象还要被进行过滤处理,message.cmd的值如果以NODE_为前缀,它将响应一个内部事件internalMessage,如果message.cmd值为NODE_HANDLE,它将取出message.type值和得到的文件描述符一起还原出一个对应的对象。

以发送的TCP服务器句柄为例,子进程收到消息后的还原过程代码如下:

function(message,handle,emit){
    var self = this;
    
    var server = new net.Server();
    server.listen(handler,function(){
      emit(server);
    });
}

这段还原代码,子进程根据message.type创建对应的TCP服务器对象,然后监听到文件描述符上。由于底层细节不被应用层感知,所以子进程中,开发者会有一种服务器对象就是从父进程中直接传递过来的错觉。

Node进程之间只有消息传递,不会真正的传递对象,这种错觉是抽象封装的结果。目前Node只支持我前面提到的几种句柄,并非任意类型的句柄都能在进程之间传递,除非它有完整的发送和还原的过程。

Node.js多进程架构模型

我们自己实现一个多进程架构守护Demo

16c565f2d5b5e5c2?w=533&h=352&f=png&s=47188
编写主进程

master.js 主要处理以下逻辑:

  • 创建一个 server 并监听 3000 端口。
  • 根据系统 cpus 开启多个子进程
  • 通过子进程对象的 send 方法发送消息到子进程进行通信
  • 在主进程中监听了子进程的变化,如果是自杀信号重新启动一个工作进程。
  • 主进程在监听到退出消息的时候,先退出子进程在退出主进程
// master.js
const fork = require('child_process').fork;
const cpus = require('os').cpus();

const server = require('net').createServer();
server.listen(3000);
process.title = 'node-master'

const workers = {};
const createWorker = () => {
    const worker = fork('worker.js')
    worker.on('message', function (message) {
        if (message.act === 'suicide') {
            createWorker();
        }
    })
    worker.on('exit', function(code, signal) {
        console.log('worker process exited, code: %s signal: %s', code, signal);
        delete workers[worker.pid];
    });
    worker.send('server', server);
    workers[worker.pid] = worker;
    console.log('worker process created, pid: %s ppid: %s', worker.pid, process.pid);
}

for (let i=0; i<cpus.length; i++) {
    createWorker();
}

process.once('SIGINT', close.bind(this, 'SIGINT')); // kill(2) Ctrl-C
process.once('SIGQUIT', close.bind(this, 'SIGQUIT')); // kill(3) Ctrl-\
process.once('SIGTERM', close.bind(this, 'SIGTERM')); // kill(15) default
process.once('exit', close.bind(this));

function close (code) {
    console.log('进程退出!', code);

    if (code !== 0) {
        for (let pid in workers) {
            console.log('master process exited, kill worker pid: ', pid);
            workers[pid].kill('SIGINT');
        }
    }

    process.exit(0);
}

工作进程

worker.js 子进程处理逻辑如下:

  • 创建一个 server 对象,注意这里最开始并没有监听 3000 端口
  • 通过 message 事件接收主进程 send 方法发送的消息
  • 监听 uncaughtException 事件,捕获未处理的异常,发送自杀信息由主进程重建进程,子进程在链接关闭之后退出
// worker.js
const http = require('http');
const server = http.createServer((req, res) => {
    res.writeHead(200, {
        'Content-Type': 'text/plan'
    });
    res.end('I am worker, pid: ' + process.pid + ', ppid: ' + process.ppid);
    throw new Error('worker process exception!'); // 测试异常进程退出、重启
});

let worker;
process.title = 'node-worker'
process.on('message', function (message, sendHandle) {
    if (message === 'server') {
        worker = sendHandle;
        worker.on('connection', function(socket) {
            server.emit('connection', socket);
        });
    }
});

process.on('uncaughtException', function (err) {
    console.log(err);
    process.send({act: 'suicide'});
    worker.close(function () {
        process.exit(1);
    })
})

Node.js 进程守护

什么是进程守护?

每次启动 Node.js 程序都需要在命令窗口输入命令 node app.js 才能启动,但如果把命令窗口关闭则Node.js 程序服务就会立刻断掉。除此之外,当我们这个 Node.js 服务意外崩溃了就不能自动重启进程了。这些现象都不是我们想要看到的,所以需要通过某些方式来守护这个开启的进程,执行 node app.js 开启一个服务进程之后,我还可以在这个终端上做些别的事情,且不会相互影响。,当出现问题可以自动重启。

如何实现进程守护

这里我只说一些第三方的进程守护框架,pm2 和 forever ,它们都可以实现进程守护,底层也都是通过上面讲的 child_process 模块和 cluster 模块 实现的,这里就不再提它们的原理。

pm2 指定生产环境启动一个名为 test 的 node 服务

pm2 start app.js --env production --name test

pm2常用api

  • pm2 stop Name/processID 停止某个服务,通过服务名称或者服务进程ID
  • pm2 delete Name/processID 删除某个服务,通过服务名称或者服务进程ID
  • pm2 logs [Name] 查看日志,如果添加服务名称,则指定查看某个服务的日志,不加则查看所有日志
  • pm2 start app.js -i 4 集群,-i <number of workers>参数用来告诉PM2以cluster_mode的形式运行你的app(对应的叫fork_mode),后面的数字表示要启动的工作线程的数量。如果给定的数字为0,PM2则会根据你CPU核心的数量来生成对应的工作线程。注意一般在生产环境使用cluster_mode模式,测试或者本地环境一般使用fork模式,方便测试到错误。
  • pm2 reload Name pm2 restart Name 应用程序代码有更新,可以用重载来加载新代码,也可以用重启来完成,reload可以做到0秒宕机加载新的代码,restart则是重新启动,生产环境中多用reload来完成代码更新!
  • pm2 show Name 查看服务详情
  • pm2 list 查看pm2中所有项目
  • pm2 monit用monit可以打开实时监视器去查看资源占用情况

pm2 官网地址:

http://pm2.keymetrics.io/docs...

forever 就不特殊说明了,官网地址

https://github.com/foreverjs/...

注意:二者更推荐pm2,看一下二者对比就知道我为什么更推荐使用pm2了。https://www.jianshu.com/p/fdc...

linux 关闭一个进程

  • 查找与进程相关的PID号

    ps aux | grep server

说明:

    root     20158  0.0  5.0 1251592 95396 ?       Sl   5月17   1:19 node /srv/mini-program-api/launch_pm2.js
上面是执行命令后在linux中显示的结果,第二个参数就是进程对应的PID


  • 杀死进程
  1. 以优雅的方式结束进程

    kill -l PID

    -l选项告诉kill命令用好像启动进程的用户已注销的方式结束进程。

当使用该选项时,kill命令也试图杀死所留下的子进程。
但这个命令也不是总能成功--或许仍然需要先手工杀死子进程,然后再杀死父进程。

  1. kill 命令用于终止进程

    例如: kill -9 [PID]

-9 表示强迫进程立即停止

这个强大和危险的命令迫使进程在运行时突然终止,进程在结束后不能自我清理。
危害是导致系统资源无法正常释放,一般不推荐使用,除非其他办法都无效。
当使用此命令时,一定要通过ps -ef确认没有剩下任何僵尸进程。
只能通过终止父进程来消除僵尸进程。如果僵尸进程被init收养,问题就比较严重了。
杀死init进程意味着关闭系统。
如果系统中有僵尸进程,并且其父进程是init,
而且僵尸进程占用了大量的系统资源,那么就需要在某个时候重启机器以清除进程表了。
  1. killall命令

    杀死同一进程组内的所有进程。其允许指定要终止的进程的名称,而非PID。

    killall httpd

Node.js 线程

Node.js关于单线程的误区

const http = require('http');

const server = http.createServer();
server.listen(3000,()=>{
    process.title='程序员成长指北测试进程';
    console.log('进程id',process.pid)
})

仍然看本文第一段代码,创建了http服务,开启了一个进程,都说了Node.js是单线程,所以 Node 启动后线程数应该为 1,但是为什么会开启7个线程呢?难道Javascript不是单线程不知道小伙伴们有没有这个疑问?

解释一下这个原因:

Node 中最核心的是 v8 引擎,在 Node 启动后,会创建 v8 的实例,这个实例是多线程的。

  • 主线程:编译、执行代码。
  • 编译/优化线程:在主线程执行的时候,可以优化代码。
  • 分析器线程:记录分析代码运行时间,为 Crankshaft 优化代码执行提供依据。
  • 垃圾回收的几个线程。

所以大家常说的 Node 是单线程的指的是 JavaScript 的执行是单线程的(开发者编写的代码运行在单线程环境中),但 Javascript 的宿主环境,无论是 Node 还是浏览器都是多线程的因为libuv中有线程池的概念存在的,libuv会通过类似线程池的实现来模拟不同操作系统的异步调用,这对开发者来说是不可见的。

某些异步 IO 会占用额外的线程

还是上面那个例子,我们在定时器执行的同时,去读一个文件:

const fs = require('fs')
setInterval(() => {
    console.log(new Date().getTime())
}, 3000)

fs.readFile('./index.html', () => {})

线程数量变成了 11 个,这是因为在 Node 中有一些 IO 操作(DNS,FS)和一些 CPU 密集计算(Zlib,Crypto)会启用 Node 的线程池,而线程池默认大小为 4,因为线程数变成了 11。
我们可以手动更改线程池默认大小:

process.env.UV_THREADPOOL_SIZE = 64

一行代码轻松把线程变成 71。

Libuv

Libuv 是一个跨平台的异步IO库,它结合了UNIX下的libev和Windows下的IOCP的特性,最早由Node的作者开发,专门为Node提供多平台下的异步IO支持。Libuv本身是由C++语言实现的,Node中的非苏塞IO以及事件循环的底层机制都是由libuv实现的。

libuv架构图

16c565ec3aaa0424?w=323&h=156&f=jpeg&s=7864

在Window环境下,libuv直接使用Windows的IOCP来实现异步IO。在非Windows环境下,libuv使用多线程来模拟异步IO。

注意下面我要说的话,Node的异步调用是由libuv来支持的,以上面的读取文件的例子,读文件实质的系统调用是由libuv来完成的,Node只是负责调用libuv的接口,等数据返回后再执行对应的回调方法。

Node.js 线程创建

直到 Node 10.5.0 的发布,官方才给出了一个实验性质的模块 worker_threads 给 Node 提供真正的多线程能力。

先看下简单的 demo:

const {
  isMainThread,
  parentPort,
  workerData,
  threadId,
  MessageChannel,
  MessagePort,
  Worker
} = require('worker_threads');

function mainThread() {
  for (let i = 0; i < 5; i++) {
    const worker = new Worker(__filename, { workerData: i });
    worker.on('exit', code => { console.log(`main: worker stopped with exit code ${code}`); });
    worker.on('message', msg => {
      console.log(`main: receive ${msg}`);
      worker.postMessage(msg + 1);
    });
  }
}

function workerThread() {
  console.log(`worker: workerDate ${workerData}`);
  parentPort.on('message', msg => {
    console.log(`worker: receive ${msg}`);
  }),
  parentPort.postMessage(workerData);
}

if (isMainThread) {
  mainThread();
} else {
  workerThread();
}

上述代码在主线程中开启五个子线程,并且主线程向子线程发送简单的消息。

由于 worker_thread 目前仍然处于实验阶段,所以启动时需要增加 --experimental-worker flag,运行后观察活动监视器,开启了5个子线程

16c6cfb939b5b268?w=1306&h=238&f=jpeg&s=49232

worker_thread 模块

worker_thread 核心代码(地址https://github.com/nodejs/nod...
worker_thread 模块中有 4 个对象和 2 个类,可以自己去看上面的源码。

  • isMainThread: 是否是主线程,源码中是通过 threadId === 0 进行判断的。
  • MessagePort: 用于线程之间的通信,继承自 EventEmitter。
  • MessageChannel: 用于创建异步、双向通信的通道实例。
  • threadId: 线程 ID。
  • Worker: 用于在主线程中创建子线程。第一个参数为 filename,表示子线程执行的入口。
  • parentPort: 在 worker 线程里是表示父进程的 MessagePort 类型的对象,在主线程里为 null
  • workerData: 用于在主进程中向子进程传递数据(data 副本)

总结

多进程 vs 多线程

对比一下多线程与多进程:

属性多进程多线程比较
数据数据共享复杂,需要用IPC;数据是分开的,同步简单因为共享进程数据,数据共享简单,同步复杂各有千秋
CPU、内存占用内存多,切换复杂,CPU利用率低占用内存少,切换简单,CPU利用率高多线程更好
销毁、切换创建销毁、切换复杂,速度慢创建销毁、切换简单,速度很快多线程更好
coding编码简单、调试方便编码、调试复杂编码、调试复杂
可靠性进程独立运行,不会相互影响线程同呼吸共命运多进程更好
分布式可用于多机多核分布式,易于扩展只能用于多核分布式多进程更好

加入我们一起学习吧!

16b8a3d23a52b7d0?w=940&h=400&f=jpeg&s=217901
node学习交流群

交流群满100人不能自动进群, 请添加群助手微信号:【coder_qi】备注node,自动拉你入群。

查看原文

赞 203 收藏 150 评论 7

this亦然 提出了问题 · 2019-02-21

react-create-app配置webpack别名alias

react-create-app怎么在不暴露(eject)webpack配置的情况下,配置alias访问路径别名

关注 3 回答 2

this亦然 提出了问题 · 2019-02-21

react-create-app配置webpack别名alias

react-create-app怎么在不暴露(eject)webpack配置的情况下,配置alias访问路径别名

关注 3 回答 2

this亦然 回答了问题 · 2019-01-04

解决js多层嵌套数据递归算法怎么改成迭代

可以,但没必要。非要用那就用while吧,外面维护一个结果,里面一直调用,当满足完成条件时break也可以,ps递归影响性能多半是你使用递归的姿势不对,尾递归了解一下

关注 5 回答 2

this亦然 赞了文章 · 2018-12-24

全面了解 React 新功能: Suspense 和 Hooks

悄悄的, React v16.7 发布了。 React v16.7: No, This Is Not The One With Hooks.

clipboard.png

最近我也一直在关注这两个功能,也听了程墨大佬的React讲座,十分受用,就花些时间就整理了一下, 在此分享给大家, 希望对大家有所帮助。


引子

为什么不推荐在 componentwillmount 里最获取数据的操作呢?

这个问题被过问很多遍了, 前几天又讨论到这个问题, 就以这个作为切入点吧。

有些朋友可能会想, 数据早点获取回来,页面就能快点渲染出来呀, 提升用户体验, 何乐而为不为?

这个问题, 简单回答起来就是, 因为是可能会调用多次

要深入回答这个问题, 就不得不提到一个React 的核心概念: React Fiber.

一些必须要先了解的背景

React Fiber

React Fiber 是在 v16 的时候引入的一个全新架构, 旨在解决异步渲染问题。

新的架构使得使得 React 用异步渲染成为可能,但要注意,这个改变只是让异步渲染成为可能

但是React 却并没有在 v16 发布的时候立刻开启,也就是说,React 在 v16 发布之后依然使用的是同步渲染

不过,虽然异步渲染没有立刻采用,Fiber 架构还是打开了通向新世界的大门,React v16 一系列新功能几乎都是基于 Fiber 架构。

说到这, 也要说一下 同步渲染异步渲染.

同步渲染 和 异步渲染

同步渲染

我们都知道React 是facebook 推出的, 他们内部也在大量使用这个框架,(个人感觉是很良心了, 内部推动, 而不是丢出去拿用户当小白鼠), 然后就发现了很多问题, 比较突出的就是渲染问题

他们的应用是比较复杂的, 组件树也是非常庞大, 假设有一千个组件要渲染, 每个耗费1ms, 一千个就是1000ms, 由于javascript 是单线程的, 这 1000ms 里 CPU 都在努力的干活, 一旦开始,中间就不会停。 如果这时候用户去操作, 比如输入, 点击按钮, 此时页面是没有响应的。 等更新完了, 你之前的那些输入就会啪啪啪一下子出来了。

这就是我们说的页面卡顿, 用起来很不爽, 体验不好。

这个问题和设备性能没有多大关系, 归根结底还是同步渲染机制的问题。

目前的React 版本(v16.7), 当组件树很大的时候,也会出现这个问题, 逐层渲染, 逐渐深入,不更新完就不会停

函数调用栈如图所示:

clipboard.png

因为JavaScript单线程的特点,每个同步任务不能耗时太长,不然就会让程序不会对其他输入作出相应,React的更新过程就是犯了这个禁忌,而React Fiber就是要改变现状。

异步渲染

Fiber 的做法是:分片。

把一个很耗时的任务分成很多小片,每一个小片的运行时间很短,虽然总时间依然很长,但是在每个小片执行完之后,都给其他任务一个执行的机会,这样唯一的线程就不会被独占,其他任务依然有运行的机会。 而维护每一个分片的数据结构, 就是Fiber

用一张图来展示Fiber 的碎片化更新过程:

clipboard.png

中间每一个波谷代表深入某个分片的执行过程,每个波峰就是一个分片执行结束交还控制权的时机。

更详细的信息可以看: Lin Clark - A Cartoon Intro to Fiber - React Conf 2017

在React Fiber中,一次更新过程会分成多个分片完成,所以完全有可能一个更新任务还没有完成,就被另一个更高优先级的更新过程打断,这时候,优先级高的更新任务会优先处理完,而低优先级更新任务所做的工作则会完全作废,然后等待机会重头再来

因为一个更新过程可能被打断,所以React Fiber一个更新过程被分为两个阶段: render phase and commit phase.

两个重要概念: render phase and commit phase

有了Fiber 之后, react 的渲染过程不再是一旦开始就不能终止的模式了, 而是划分成为了两个过程: 第一阶段和第二阶段, 也就是官网所谓的 render phase and commit phase

在 Render phase 中, React Fiber会找出需要更新哪些DOM,这个阶段是可以被打断的, 而到了第二阶段commit phase, 就一鼓作气把DOM更新完,绝不会被打断。

两个阶段的分界点

这两个阶段, 分界点是什么呢?

其实是 render 函数。 而且, render 函数 也是属于 第一阶段 render phase 的

那这两个 phase 包含的的生命周期函数有哪些呢?

render phase:

  • componentWillMount
  • componentWillReceiveProps
  • shouldComponentUpdate
  • componentWillUpdate

commit phase:

  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

clipboard.png

因为第一阶段的过程会被打断而且“重头再来”,就会造成意想不到的情况。

比如说,一个低优先级的任务A正在执行,已经调用了某个组件的componentWillUpdate函数,接下来发现自己的时间分片已经用完了,于是冒出水面,看看有没有紧急任务,哎呀,真的有一个紧急任务B,接下来React Fiber就会去执行这个紧急任务B,任务A虽然进行了一半,但是没办法,只能完全放弃,等到任务B全搞定之后,任务A重头来一遍,注意,是重头来一遍,不是从刚才中段的部分开始,也就是说,componentWillUpdate函数会被再调用一次。

在现有的React中,每个生命周期函数在一个加载或者更新过程中绝对只会被调用一次;在React Fiber中,不再是这样了,第一阶段中的生命周期函数在一次加载和更新过程中可能会被多次调用!

这里也可以回答文行开头的那个问题了, 当然, 在异步渲染模式没有开启之前, 你可以在 willMount 里做ajax (不建议)。 首先,一个组件的 componentWillMount 比 componentDidMount 也早调用不了几微秒,性能没啥提高,而且如果开启了异步渲染, 这就难受了。 React 官方也意识到了这个问题,觉得有必要去劝告(威胁, 阻止)开发者不要在render phase 里写有副作用的代码了(副作用:简单说就是做本函数之外的事情,比如改一个全局变量, ajax之类)。

static getDerivedStateFromProps(nextProps, prevState) {
  //根据nextProps和prevState计算出预期的状态改变,返回结果会被送给setState
}

新的静态方法

为了减少(避免?)一些开发者的骚操作,React v16.3,干脆引入了一个新的生命周期函数 getDerivedStateFromProps, 这个函数是一个 static 函数,也是一个纯函数,里面不能通过 this 访问到当前组件(强制避免一些有副作用的操作),输入只能通过参数,对组件渲染的影响只能通过返回值。目的大概也是让开发者逐步去适应异步渲染。

我们再看一下 React v16.3 之前的的生命周期函数 示意图:

clipboard.png

再看看16.3的示意图:

clipboard.png

上图中并包含全部React生命周期函数,另外在React v16发布时,还增加了一个componentDidCatch,当异常发生时,一个可以捕捉到异常的componentDidCatch就排上用场了。不过,很快React觉着这还不够,在v16.6.0又推出了一个新的捕捉异常的生命周期函数getDerivedStateFromError

如果异常发生在render阶段,React就会调用getDerivedStateFromError,如果异常发生在第commit阶段,React会调用componentDidCatch。 这个异常可以是任何类型的异常, 捕捉到这个异常之后呢, 可以做一些补救之类的事情。

componentDidCatchgetDerivedStateFromError 的 区别

componentDidCatch 和 getDerivedStateFromError 都是能捕捉异常的,那他们有什么区别呢?

我们之前说了两个阶段, render phasecommit phase.

render phase 里产生异常的时候, 会调用 getDerivedStateFromError;

在 commit phase 里产生异常大的时候, 会调用 componentDidCatch

严格来说, 其实还有一点区别:

componentDidCatch 是不会在服务器端渲染的时候被调用的 而 getDerivedStateFromError 会。

背景小结

啰里八嗦一大堆, 关于背景的东西就说到这, 大家只需要了解什么是Fiber: ‘ 哦, 这个这个东西是支持异步渲染的, 虽然这个东西还没开启’。

然后就是渲染的两个阶段:renderphasecommit phase.

  • render phase 可以被打断, 大家不要在此阶段做一些有副作用的操作,可以放心在commit phase 里做。
  • 然后就是生命周期的调整, react 把你有可能在render phase 里做的有副作用的函数都改成了static 函数, 强迫开发者做一些纯函数的操作。

现在我们进入正题: SuspenseHooks

正题


suspense

Suspense要解决的两个问题:

  1. 代码分片;
  2. 异步获取数据。

刚开始的时候, React 觉得自己只是管视图的, 代码打包的事不归我管, 怎么拿数据也不归我管。 代码都打到一起, 比如十几M, 下载就要半天,体验显然不会好到哪里去。

可是后来呢,这两个事情越来越重要, React 又觉得, 嗯,还是要掺和一下,是时候站出来展现真正的技术了。

Suspense 在v16.6的时候 已经解决了代码分片的问题,异步获取数据还没有正式发布。

先看一个简单的例子:

import React from "react";
import moment from "moment";
 
const Clock = () => <h1>{moment().format("MMMM Do YYYY, h:mm:ss a")}</h1>;

export default Clock;

假设我们有一个组件, 是看当前时间的, 它用了一个很大的第三方插件, 而我想只在用的时候再加载资源,不打在总包里。

再看一段代码:

// Usage of Clock
const Clock = React.lazy(() => {
  console.log("start importing Clock");
  return import("./Clock");
});

这里我们使用了React.lazy, 这样就能实现代码的懒加载。 React.lazy 的参数是一个function, 返回的是一个promise. 这里返回的是一个import 函数, webpack build 的时候, 看到这个东西, 就知道这是个分界点。 import 里面的东西可以打包到另外一个包里。

真正要用的话, 代码大概是这个样子的:

<Suspense fallback={<Loading />}>
  { showClock ? <Clock/> : null}
</Suspense>

showClock 为 true, 就尝试render clock, 这时候, 就触发另一个事件: 去加载clock.js 和它里面的 lib momment。

看到这你可能觉得奇怪, 怎么还需要用个<Suspense> 包起来, 有啥用, 不包行不行。

哎嗨, 不包还真是不行。 为什么呢?

前面我们说到, 目前react 的渲染模式还是同步的, 一口气走到黑, 那我现在画到clock 这里, 但是这clock 在另外一个文件里, 服务器就需要去下载, 什么时候能下载完呢, 不知道。 假设你要花十分钟去下载, 那这十分钟你让react 去干啥, 总不能一直等你吧。 Suspens 就是来解决这个问题的, 你要画clock, 现在没有,那就会抛一个异常出来,我们之前说
componentDidCatch 和 getDerivedStateFromError, 这两个函数就是来抓子组件 或者 子子组件抛出的异常的。

子组件有异常的时候就会往上抛,直到某个组件的 getDerivedStateFromError 抓住这个异常,抓住之后干嘛呢, 还能干嘛呀, 忍着。

下载资源的时候会抛出一个promise, 会有地方(这里是suspense)捕捉这个promise, suspense 实现了getDerivedStateFromError,捕获到异常的时候, 一看, 哎, 小老弟,你来啦,还是个promise, 然后就等这个promise resolve, 完成之后,它会尝试重新画一下子组件。

这时候资源已经到本地了,也就能画成功了。

用伪代码 大致实现一下:

getDerivedStateFromError(error) {
   if (isPromise(error)) {
      error.then(reRender);
   }
}

以上大概就是Suspense 的原理, 其实也不是很复杂,就是利用了 componentDidCatch 和 getDerivedStateFromError, 其实刚开始在v16的时候, 是要用componentDidCatch 的, 但它毕竟是commit phase 里的东西, 还是分出来吧, 所以又加了个getDerivedStateFromError来实现 Suspense 的功能。

这里需要注意的是 reRender 会渲染suspense 下面的所有子组件。

异步渲染什么时候开启呢, 根据介绍说是在19年的第二个季度随着一个小版本的升级开启, 让我们提前做好准备。

做些什么准备呢?

  • render 函数之前的代码都检查一边, 避免一些有副作用的操作

到这, 我们说完了Suspense 的一半功能, 还有另一半: 异步获取数据。

目前这一部分功能还没正式发布。 那我们获取数据还是只能在commit phase 做, 也就是在componentDidMount 里 或者 didUpdate 里做。

就目前来说, 如果一个组件要自己获取数据, 就必须实现为一个类组件, 而且会画两次, 第一次没有数据, 是空的, 你可以画个loading, didMount 之后发请求, 数据回来之后, 把数据setState 到组件里, 这时候有数据了, 再画一次,就画出来了。

虽然是一个很简答的功能, 我就想请求个数据, 还要写一堆东西, 很麻烦, 但在目前的正式版里, 不得不这么做。

但以后这种情况会得到改善, 看一段示例:

import {unstable_createResource as createResource} from 'react-cache';

const resource = createResource(fetchDataApi);

const Foo = () => {
  const result = resource.read();
  return (
    <div>{result}</div>
  );

// ...

<Suspense>
   <Foo />
</Suskpense>};

代码里我们看不到任何譬如 async await 之类的操作, 看起来完全是同步的操作, 这是什么原理呢。

上面的例子里, 有个 resource.read(), 这里就会调api, 返回一个promise, 上面会有suspense 抓住, 等resolve 的时候,再画一下, 就达到目的了。

到这,细心的同学可能就发现了一个问题, resource.read(); 明显是一个有副作用的操作, 而且 render 函数又属于render phase, 之前又说, 不建议在 render phase 里做有副作用的操作, 这么矛盾, 不是自己打脸了吗。

这里也能看出来React 团队现在还没完全想好, 目前放出来测试api 也是以unstable_开头的, 不用用意还是跟明显的: 让大家不要写class的组件,Suspense 能很好的支持函数式组件。

hooks

React v16.7.0-alpha 中第一次引入了 Hooks 的概念, 为什么要引入这个东西呢?

有两个原因:

  1. React 官方觉得 class组件太难以理解,OO(面向对象)太难懂了
  2. React 官方觉得 , React 生命周期太难理解。

最终目的就是, 开发者不用去理解class, 也不用操心生命周期方法。

但是React 官方又说, Hooks的目的并不是消灭类组件。此处应手动滑稽。

回归正题, 我们继续看Hooks, 首先看一下官方的API

clipboard.png

乍一看还是挺多的, 其实有很多的Hook 还处在实验阶段,很可能有一部分要被砍掉, 目前大家只需要熟悉的, 三个就够了:

  • useState
  • useEffect
  • useContext

useState

举个例子来看下, 一个简单的counter :

// 有状态类组件
class Counter extends React.Component {
   state = {
      count: 0
   }
   
   increment = () => {
       this.setState({count: this.state.count + 1});
   }
   
   minus = () => {
       this.setState({count: this.state.count - 1});
   }
   
   render() {
       return (
           <div>
               <h1>{this.state.count}</h1>
               <button onClick={this.increment}>+</button>
               <button onClick={this.minus}>-</button>
           </div>
       );
   }
}
// 使用useState Hook
const Counter = () => {
  const [count, setCount] = useState(0);
  
  const increment = () => setCount(count + 1);
  
  return (
    <div>
        <h1>{count}</h1>
        <button onClick={increment}>+</button>
    </div>
  );
};

这里的Counter 不是一个类了, 而是一个函数。

进去就调用了useState, 传入 0,对state 进行初始化,此时count 就是0, 返回一个数组, 第一个元素就是 state 的值,第二个元素是更新 state 的函数。

// 下面代码等同于: const [count, setCount] = useState(0);
  const result = useState(0);
  const count = result[0];
  const setCount = result[1];

利用 count 可以读取到这个 state,利用 setCount 可以更新这个 state,而且我们完全可以控制这两个变量的命名,只要高兴,你完全可以这么写:

 const [theCount, updateCount] = useState(0);

因为 useState 在 Counter 这个函数体中,每次 Counter 被渲染的时候,这个 useState 调用都会被执行,useState 自己肯定不是一个纯函数,因为它要区分第一次调用(组件被 mount 时)和后续调用(重复渲染时),只有第一次才用得上参数的初始值,而后续的调用就返回“记住”的 state 值。

读者看到这里,心里可能会有这样的疑问:如果组件中多次使用 useState 怎么办?React 如何“记住”哪个状态对应哪个变量?

React 是完全根据 useState 的调用顺序来“记住”状态归属的,假设组件代码如下:

const Counter = () => {
  const [count, setCount] = useState(0);
  const [foo, updateFoo] = useState('foo');
  
  // ...
}

每一次 Counter 被渲染,都是第一次 useState 调用获得 count 和 setCount,第二次 useState 调用获得 foo 和 updateFoo(这里我故意让命名不用 set 前缀,可见函数名可以随意)。

React 是渲染过程中的“上帝”,每一次渲染 Counter 都要由 React 发起,所以它有机会准备好一个内存记录,当开始执行的时候,每一次 useState 调用对应内存记录上一个位置,而且是按照顺序来记录的。React 不知道你把 useState 等 Hooks API 返回的结果赋值给什么变量,但是它也不需要知道,它只需要按照 useState 调用顺序记录就好了。

你可以理解为会有一个槽去记录状态。

正因为这个原因,Hooks,千万不要在 if 语句或者 for 循环语句中使用!

像下面的代码,肯定会出乱子的:

const Counter = () => {
    const [count, setCount] = useState(0);
    if (count % 2 === 0) {
        const [foo, updateFoo] = useState('foo');
    }
    const [bar, updateBar] = useState('bar');
 // ...
}

因为条件判断,让每次渲染中 useState 的调用次序不一致了,于是 React 就错乱了。

useEffect

除了 useState,React 还提供 useEffect,用于支持组件中增加副作用的支持。

在 React 组件生命周期中如果要做有副作用的操作,代码放在哪里?

当然是放在 componentDidMount 或者 componentDidUpdate 里,但是这意味着组件必须是一个 class。

在 Counter 组件,如果我们想要在用户点击“+”或者“-”按钮之后把计数值体现在网页标题上,这就是一个修改 DOM 的副作用操作,所以必须把 Counter 写成 class,而且添加下面的代码:

componentDidMount() {
  document.title = `Count: ${this.state.count}`;
}

componentDidUpdate() {
  document.title = `Count: ${this.state.count}`;
}

而有了 useEffect,我们就不用写一个 class 了,对应代码如下:

import { useState, useEffect } from 'react';

const Counter = () => {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    document.title = `Count: ${this.state.count}`;
  });

  return (
    <div>
       <div>{count}</div>
       <button onClick={() => setCount(count + 1)}>+</button>
       <button onClick={() => setCount(count - 1)}>-</button>
    </div>
  );
};

useEffect 的参数是一个函数,组件每次渲染之后,都会调用这个函数参数,这样就达到了 componentDidMount 和 componentDidUpdate 一样的效果。

虽然本质上,依然是 componentDidMountcomponentDidUpdate 两个生命周期被调用,但是现在我们关心的不是 mount 或者 update 过程,而是“after render”事件,useEffect 就是告诉组件在“渲染完”之后做点什么事。

读者可能会问,现在把 componentDidMountcomponentDidUpdate 混在了一起,那假如某个场景下我只在 mount 时做事但 update 不做事,用 useEffect 不就不行了吗?

其实,用一点小技巧就可以解决。useEffect 还支持第二个可选参数,只有同一 useEffect 的两次调用第二个参数不同时,第一个函数参数才会被调用. 所以,如果想模拟 componentDidMount,只需要这样写:

  useEffect(() => {
    // 这里只有mount时才被调用,相当于componentDidMount
  }, [123]);

在上面的代码中,useEffect 的第二个参数是 [123],其实也可以是任何一个常数,因为它永远不变,所以 useEffect 只在 mount 时调用第一个函数参数一次,达到了 componentDidMount 一样的效果。

useContext

在前面介绍“提供者模式”章节我们介绍过 React 新的 Context API,这个 API 不是完美的,在多个 Context 嵌套的时候尤其麻烦。

比如,一段 JSX 如果既依赖于 ThemeContext 又依赖于 LanguageContext,那么按照 React Context API 应该这么写:

<ThemeContext.Consumer>
    {
        theme => (
            <LanguageContext.Cosumer>
                language => {
                    //可以使用theme和lanugage了
                }
            </LanguageContext.Cosumer>
        )
    }
</ThemeContext.Consumer>

因为 Context API 要用 render props,所以用两个 Context 就要用两次 render props,也就用了两个函数嵌套,这样的缩格看起来也的确过分了一点点。

使用 Hooks 的 useContext,上面的代码可以缩略为下面这样:

const theme = useContext(ThemeContext);
const language = useContext(LanguageContext);
// 这里就可以用theme和language了

这个useContext把一个需要很费劲才能理解的 Context API 使用大大简化,不需要理解render props,直接一个函数调用就搞定。

但是,useContext也并不是完美的,它会造成意想不到的重新渲染,我们看一个完整的使用useContext的组件。

const ThemedPage = () => {
    const theme = useContext(ThemeContext);
    
    return (
       <div>
            <Header color={theme.color} />
            <Content color={theme.color}/>
            <Footer color={theme.color}/>
       </div>
    );
};

因为这个组件ThemedPage使用了useContext,它很自然成为了Context的一个消费者,所以,只要Context的值发生了变化,ThemedPage就会被重新渲染,这很自然,因为不重新渲染也就没办法重新获得theme值,但现在有一个大问题,对于ThemedPage来说,实际上只依赖于theme中的color属性,如果只是theme中的size发生了变化但是color属性没有变化,ThemedPage依然会被重新渲染,当然,我们通过给Header、Content和Footer这些组件添加shouldComponentUpdate实现可以减少没有必要的重新渲染,但是上一层的ThemedPage中的JSX重新渲染是躲不过去了。

说到底,useContext 需要一种表达方式告诉React:“我没有改变,重用上次内容好了。”

希望Hooks正式发布的时候能够弥补这一缺陷。

Hooks 带来的代码模式改变

上面我们介绍了 useStateuseEffectuseContext 三个最基本的 Hooks,可以感受到,Hooks 将大大简化使用 React 的代码。

首先我们可能不再需要 class了,虽然 React 官方表示 class 类型的组件将继续支持,但是,业界已经普遍表示会迁移到 Hooks 写法上,也就是放弃 class,只用函数形式来编写组件。

对于 useContext,它并没有为消除 class 做贡献,却为消除 render props 模式做了贡献。很长一段时间,高阶组件和 render props 是组件之间共享逻辑的两个武器,但如同我前面章节介绍的那样,这两个武器都不是十全十美的,现在 Hooks 的出现,也预示着高阶组件和 render props 可能要被逐步取代。

但读者朋友,不要觉得之前学习高阶组件和 render props 是浪费时间,相反,你只有明白 React 的使用历史,才能更好地理解 Hooks 的意义。

可以预测,在 Hooks 兴起之后,共享代码之间逻辑会用函数形式,而且这些函数会以 use- 前缀为约定,重用这些逻辑的方式,就是在函数形式组件中调用这些 useXXX 函数。

例如,我们可以写这样一个共享 Hook useMountLog,用于在 mount 时记录一个日志,代码如下:

const useMountLog = (name) => {
    useEffect(() => {
        console.log(`${name} mounted`);    
    }, [123]);
}

任何一个函数形式组件都可以直接调用这个 useMountLog 获得这个功能,如下:

const Counter = () => {
    useMountLog('Counter');
    
    ...
}

对了,所有的 Hooks API 都只能在函数类型组件中调用,class 类型的组件不能用,从这点看,很显然,class 类型组件将会走向消亡。

如何用Hooks 模拟旧版本的生命周期函数

Hooks 未来正式发布后, 我们自然而然的会遇到这个问题, 如何把写在旧生命周期内的逻辑迁移到Hooks里面来。下面我们就简单说一下,

模拟整个生命周期中只运行一次的方法

useMemo(() => {
  // execute only once
}, []);

我们可以看到useMemo 接收两个参数, 第一个参数是一个函数, 第二个参数是一个数组。

这里有个地方要注意, 就是, 第二个参数的数组里的元素和上一次执行useMemo的第二个参数的数组的元素 完全一样的话,那就表示没有变化, 就不用执行第一个参数里的函数了。 如果有不同, 说明有变化, 就执行。

上面的例子里, 我们只传入了一个空数组, 不会有变化, 也就是只会执行一次。

模拟shouldComponentUpdate

const areEqual = (prevProps, nextProps) => {
   // 返回结果和shouldComponentUpdate正好相反
   // 访问不了state
}; 
React.memo(Foo, areEqual);

模拟componentDidMount

useEffect(() => {
    // 这里在mount时执行一次
}, []);

模拟componentDidUpdate

const mounted = useRef();
useEffect(() => {
  if (!mounted.current) {
    mounted.current = true;
  } else {
    // 这里只在update是执行
  }
});

模拟componentDidUnmount

useEffect(() => {
    // 这里在mount时执行一次
    return () => {
       // 这里在unmount时执行一次
    }
}, []);

未来的代码形势

Hooks 未来发布之后, 我们的代码会写成什么样子呢? 简单设想一下:

// Hooks之后的组件逻辑重用形态

const XXXX = () => {
  const [xx, xxx, xxxx] = useX();
  
  useY();
  
  const {a, b} = useZ();
  

  return (
    <>
     //JSX
    </>
  );
};

内部可能用各种Hooks, 也可能包含第三方的Hooks。 分享Hooks 就是实现代码重用的一种形势。 其实现在已经有人在做这方面的工作了: useHooks.com, 有兴趣的朋友可以去看下。

Suspense 和 Hooks 带来的改变

Suspense 和 Hooks 发布后, 会带来什么样的改变呢? 毫无疑问, 未来的组件, 更多的将会是函数式组件。

原因很简单, 以后大家分享出来的都是Hooks,这东西只能在函数组件里用啊, 其他地方用不了,后面就会自然而然的发生了。

但函数式组件和函数式编程还不是同一个概念。 函数式编程必须是纯的, 没有副作用的, 函数式组件里, 不能保证, 比如那个resource.read(), 明显是有副作用的。

关于好坏

既然这两个东西是趋势, 那这两个东西到底好不好呢 ?

个人理解, 任何东西都不是十全十美。 既然大势所趋, 我们就努力去了解它,学会它, 努力用它好的地方, 避免用不好的地方。

React 发布路线图

最新的消息: https://reactjs.org/blog/2018...

  • React 16.6 with Suspense for Code Splitting (already shipped)
  • A minor 16.x release with React Hooks (~Q1 2019)
  • A minor 16.x release with Concurrent Mode (~Q2 2019)
  • A minor 16.x release with Suspense for Data Fetching (~mid 2019)

明显能够看到资源在往 Suspense 和 Hooks 倾斜。

结语

看到这, 相信大家都Suspense 和 Hooks 都有了一个大概的了解了。

收集各种资料花费了挺长时间,大概用了两三天写出来,中间参考了很多资料, 一部分是摘录到了上面的内容里。

在这里整理分享一下, 希望对大家有所帮助。

才疏学浅, 难免会有纰漏, 欢迎指正:)。

最后

觉得内容有帮助可以关注下我的公众号 「 前端e进阶 」,一起学习成长

clipboard.png

参考资料

查看原文

赞 163 收藏 96 评论 7

this亦然 赞了文章 · 2018-11-06

免费的编程中文书籍索引(2018第三版)

之前我在 github 上整理了来一份:free-programming-books-zh_CN(免费的计算机编程类中文书籍)

截至目前为止,已经在 GitHub 收获了 40000 多的 stars,有 90 多人发了 600 多个 Pull Requests 和 issues。

在收集的过程中,有不少书的链接失效了,也有不少书的链接变更了,感谢项目的参与者和维护者们即使更正链接。今天我又重新核准了文章中的链接地址,发布了 3.0 版。

欢迎大家提 PR: https://github.com/justjavac/...

操作系统

智能系统

分布式系统

编译原理

函数式概念

计算机图形学

WEB服务器

版本控制

编辑器

NoSQL

PostgreSQL

MySQL

管理和监控

项目相关

设计模式

Web

大数据

编程艺术

其它

Android

APP

AWK

C/C++

C#

Clojure

<h2 id="csshtml">CSS/HTML</h2>

Dart

Elixir

Erlang

Fortran

Go

Groovy

Haskell

iOS

Java

JavaScript

LaTeX

LISP

Lua

OCaml

Perl

PHP

Prolog

Python

R

Ruby

Rust

Scala

Shell

Swift

读书笔记及其它

最后,欢迎大家提 PR:https://github.com/justjavac/... (提交之前请确保书的版权)

查看原文

赞 604 收藏 476 评论 10

this亦然 赞了回答 · 2018-10-26

解决npm 安装参数中的 --save-dev 是什么意思

当你为你的模块安装一个依赖模块时,正常情况下你得先安装他们(在模块根目录下npm install module-name),然后连同版本号手动将他们添加到模块配置文件package.json中的依赖里(dependencies)。

-savesave-dev可以省掉你手动修改package.json文件的步骤。
spm install module-name -save 自动把模块和版本号添加到dependencies部分
spm install module-name -save-dve 自动把模块和版本号添加到devdependencies部分

至于配置文件区分这俩部分, 是用于区别开发依赖模块和产品依赖模块, 以我见过的情况来看 devDepandencies主要是配置测试框架, 例如jshint、mocha。

这一命令的Pull记录
官方文档
站内关于devdependencies的提问

我主要是基于浏览器写Javascript, npm用的少, 以上是个人理解。

关注 33 回答 5

this亦然 赞了问题 · 2018-10-26

解决npm 安装参数中的 --save-dev 是什么意思

看到有些 node.js 的包安装的时候都加上 --save-dev 参数,不知道这参数是做什么的,加和不加有什么区别吗?

$ npm install xxx --save-dev

关注 33 回答 5

this亦然 收藏了问题 · 2018-10-26

npm 安装参数中的 --save-dev 是什么意思

看到有些 node.js 的包安装的时候都加上 --save-dev 参数,不知道这参数是做什么的,加和不加有什么区别吗?

$ npm install xxx --save-dev