Denzel

Denzel 查看完整档案

成都编辑哈尔滨工业大学  |  电气工程及其自动化 编辑KRY  |  前端工程师 编辑 closertb.site 编辑
编辑

不要假装很努力,因为结果不会陪你演戏

个人博客:https://closertb.site

Github: https://github.com/closertb

个人动态

Denzel 发布了文章 · 49 分钟前

纯 CSS 解决H5布局中的吸顶吸底

演示Demo地址(手机端打开):https://closertb.site/Klotski...
演示Demo源码:https://github.com/closertb/k...
原文:https://github.com/closertb/c...

哪些想啥提啥的产品们

最近做了一个需求,准确说是迭代需求:加了一个头部概览(类似下图),以更好的让用户观察到营销变化,故事的开头就这样悄悄的埋下了伏笔。

以前这个页面只是一个评价列表(可上拉加载),为了数据更易读,列表的头采用了固定布局。然而加了这个概览时,产品没提,我就简单粗暴的将这个列表头换成了相对布局,ok,提测。
20201017180423
但第二天,我发现上拉加载数据多了,列表头部被顶上去之后,想再做筛选,就要再把列表上滑才能看到,这个体验非常之差。于是同事就说要不问问产品,要不把概览加概览做成固定。

我第一反应就是,恐怕提了之后,产品会让我把筛选列表头部做成固定,注意那个只。

然后就有了下面的对话:

20201017175848

果然怕什么,来什么,毕竟是很常规的操作。但就像同事说的,自己问的需求,含着泪也要接下。

局部吸顶

以下代码是页面的dom结构

<div id="demo" className={style.demo}>
  <h3 id="title" className="title">这是一个概览头部</h3>
  <div id="content" className="content">
    <div className="filter-bar">
      <h3>这是列表头部</h3>
      <h3>可筛选</h3>
      <h3>下面是滚动列表</h3>
    </div>
    <ul className="list">
      {arr.map(({ key, label }) => <li key={key}>{label}</li>)}
    </ul>
  </div>
</div>

JS 实现

因为页面本身就有scroll事件监听,所以第一个念头是用JS完成,但当时已经下班,又是周五,感觉5分钟内搞不定,所以我就跑了。

现在来尝试用JS实现,先理一下思路:

  • 监听页面的滚动,当ul元素顶部距离页面顶部大于title 高度时,添加一个css类使筛选头部吸顶;
  • 当ul元素距离顶部小于等于title 高度时,删除添加的类,取消筛选头部吸顶
JS 代码
useEffect(() => {
  const demo = document.querySelector('#demo');
  const content = document.querySelector('#content');
  const titleHeight = document.querySelector('#title').clientHeight;
  let fixed = false;
  demo.addEventListener('scroll', (e) => {
    // 添加吸顶
    if (!fixed && e.target.scrollTop >= titleHeight) {
      fixed = true;
      content.classList.add('with-fixed');
    }
    // 取消吸顶
    if (fixed && e.target.scrollTop < titleHeight - 5) {
      content.classList.remove('with-fixed');
      fixed = false;
    }
  });
}, []);

看起也不难,但其实离代码上线,还有很大优化的空间,后面会分析补充。

CSS 实现

JS 看似很简单,但就像那句热门句子:这突如其来的噩耗,让本不富裕的家庭雪上加霜。在这种有下拉加载的页面,我们本来就在监听里面做了很多逻辑处理,所以能用CSS实现的,就尽量不要再去麻烦JS了。

首先理一下思路,深挖产品的需求:

  • 保持筛选头在可视范围之内(吸顶), 保证可筛选;
  • 当列表数据多时,尽可能多展示列表,即概览头部就没必要看到了;
  • 列表是上拉加载的;

当理清上面思路时,我们发现,其实就是当列表很长时,隐藏概览头部,简单用伪代码表示就是(vh是视口单位 ,100vh代表整个屏幕可视高度):

   if (titleHeight + filterBarHeight + listHeight > 100vh) {
     title.hide();
   }

那又怎样实现概览头部隐藏,而筛选头和列表又正好出于视口呢?

 filterBarHeight + listHeight = 100vh

当用户往上划,只需要内容(筛选头和列表)正好是一个视口高度(100vh)时,概览头就恰好被隐藏,而筛选头又正好吸顶,用CSS实现就是类似这样的:

// 不是完整代码,详情请看demo:
.demo {
 :global {
   .title {
     height: 15vh;
     line-height: 15vh;
     text-align: center;
     border-bottom: 1PX solid #eee;
     background-color: #fff;
   }
   .filter-bar {
     height: 15vw;
     background-color: #888;
     display: flex;
     align-items: center;
   }
   .list {
     max-height: calc(100vh - 15vw);; // 这里的设置很重要
     overflow: scroll;
     background-color: rgba(127, 255, 212, .8);
   }

### 对比

是不是感觉CSS很简单,稍微设置一下即搞定,只是要想到内容高度正好是100vh需要一点经(yun)验(qi)。其实不光简便,对比JS至少还有三个优点:

  • JS 如果只是上面那样,直接将筛选头的定位改成固定定位,眼力好的人,其实是能感觉到列表有跳变的一瞬间,就是列表会突然上移filterBar高度,来填补筛选头离开正常文档流;(解决方案就是在筛选头外多套一层dom,并给一个固定高度,这样筛选头脱离正常文档流,但高度依旧还在);
  • 当用户下拉加载很多数据时,还想看到概览头部,他就得费劲的往上划,直到移除吸顶筛选头的类;而CSS实现就不存在,他想看到对比数据时,只需要按住筛选头,往下一拉,就直接看到筛选头了(可以在筛选头部增加js来实现);
  • 当用JS来操作Dom元素重排时,这每年面试官说的那些重绘重拍我就不多说了,这消耗的性能肯定高于CSS实现;

当然缺点也是存在的:

  • 兼容性问题,倒不是说vh的兼容性,而是在ios手机上,这方案有bug。由于safari的头部和底部滑动时可见性会改变,所以当Bar可见时,实际的100vh高于屏幕可见高度,就会导致吸顶头部被遮挡。到目前为止,虽然网上有很多说height: -webkit-fill-available;,但针对这种场景是无效的;

20201024155007

经过上面分析,100vh在IOS safari上的致命问题,会让这种纯CSS的方案褪色。但PC页面,或者你和我一样,要编写的页面是运行在APP中(即没有bar存在),那这种方案就是可行的。所有的方案都要具体场景,具体分析,没有谁出生就是完美。

如果对重绘重排有兴趣,建议观看Chrome的官方博文: 浏览器四部曲

弹性吸底

说完局部弹性吸顶,再说一个常见的,选择性吸底:在页面内容不足100vh时,我们希望Footer是吸底的,当页面内容大于100vh时,Footer处于正常文档流,让内容可视区域更大,而又不会因为内容太少影响美观,见图:
20201025095027

像第一张图那样不做定位的还是大有人在,因为他们坚信自己网站的内容不会出现不够的时候,但以前更常见做法是底部固定定位。

弹性吸底利用min-height 加绝对定位,其实现很简单。核心代码不超过5行css:

body{
  position: relative;
  min-height: 100vh;
}

footer {
  width: 100%;
  position: absolute;
  bottom: 0;
}

原理就是内容区域最低高度为一个屏幕,然后底部相对屏幕进行绝对定位;当内容变多时,高度大于100vh,由于是依赖bottom: 0;,所以会一直吸底,其巧妙之处就在于此。

针对于这个场景,height: -webkit-fill-available 就是有效的。
20201025102807

更多关于-webkit-fill-available, 参见[https://allthingssmitty.com/2...];

总结

vh 确实是个好东西,可以解决移动端的适配问题。我个人觉得作为一个合格的前端,CSS 仍然是必备技能,不要对JS产生太多的依赖,不是不可以,而是好钢要用在刀刃上。

公众号:前端黑洞

查看原文

赞 1 收藏 0 评论 1

Denzel 收藏了文章 · 10月25日

Node.js运行原理、高并发性能测试对比及生态圈汇总

Node.js是从纯前端走向更高阶层的前端,以及全栈工程师的唯一快速途径

  • 简单的说Node.js 就是运行在服务端的 JavaScript
  • Node.js 是一个基于Chrome JavaScript 运行时建立的一个平台
  • Node.js是一个事件驱动I/O服务端JavaScript环境,基于GoogleV8引擎,V8引擎执行Javascript的速度非常快,性能非常好

如果你是一个前端程序员,你不懂得像PHPPythonRuby等动态编程语言,然后你想创建自己的服务,那么Node.js是一个非常好的选择

  • Node.js 是运行在服务端的 JavaScript,如果你熟悉Javascript,那么你将会很容易的学会Node.js
  • 当然,如果你是后端程序员,想部署一些高性能的服务,那么学习Node.js也是一个非常好的选择

Node.JS适合运用在高并发、I/O密集、少量业务逻辑的场景

Node.js的模块组成如下:

Node.js的运行机制

  • V8引擎解析JavaScript脚本
  • 解析后的代码,调用Node API
  • libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个EventLoop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。
  • V8引擎再将结果返回给用户。

事件循环(Event Loop)

  • Nodejs 执行之后会初始化一个事件循环,执行代码程序(这些程序可能会造成异步调用、定时器或者process.nextTick()),然后开始执行事件循环。
  • 事件循环的执行循序:

  • 上边的每一个模块都是事件循环的一个阶段,每个阶段都有一个要执行的回调的FIFO队列。虽然每个阶段都不同,一般来说,当事件执行到一个阶段,先执行这个阶段特有的操作,然后操作这个阶段的队列,当队列执行完或者达到了回调上限,事件循环就会执行下一个阶段。

各个阶段执行的任务如下:

  • timers 阶段: 这个阶段执行setTimeoutsetInterval预定的callback;
  • I/O callbacks 阶段: 执行除了close事件的callbacks、被timers设定的callbackssetImmediate()设定的callbacks这些之外的callbacks;
  • idle, prepare 阶段: 仅node内部使用;
  • poll 阶段: 获取新的I/O事件, 适当的条件下node将阻塞在这里;
  • check 阶段: 执行setImmediate() 设定的callbacks;
  • close callbacks 阶段: 执行socket.on('close', ...)这些 callback
  • process.nextTick()不属于上面的任何一个phase,它在每个phase结束的时候都会运行。也可以认为,nextTick在下一个异步方法的事件回调函数调用前执行。
TIPS:Node.js中的事件循环机制不会掉头,只会由上往下,循环执行。

完整的一次执行机制可以这样描述

Node.js中,绝大部分API都是异步的,有一个很形象的故事描述了JAVA和Node.js的区别,JAVA是一个餐厅100个服务员对应100客户,Node.js是一个服务员玩命干,也对应100个客户,上菜的速度很大一部分取决于厨师的做菜速度

Node.js的单线程并不是真正的单线程,只是开启了单个线程进行业务处理(cpu的运算),同时开启了其他线程专门处理I/O。当一个指令到达主线程,主线程发现有I/O之后,直接把这个事件传给I/O线程,不会等待I/O结束后,再去处理下面的业务,而是拿到一个状态后立即往下走,这就是“单线程”、“异步I/O”。

  • I/O操作完之后呢?Node.jsI/O 处理完之后会有一个回调事件,这个事件会放在一个事件处理队列里头,在进程启动时node会创建一个类似于While(true)的循环,它的每一次轮询都会去查看是否有事件需要处理,是否有事件关联的回调函数需要处理,如果有就处理,然后加入下一个轮询,如果没有就退出进程,这就是所谓的“事件驱动”。这也从Node的角度解释了什么是”事件驱动”。
  • node.js中,事件主要来源于网络请求,文件I/O等,根据事件的不同对观察者进行了分类,有文件I/O观察者,网络I/O观察者。事件驱动是一个典型的生产者/消费者模型,请求到达观察者那里,事件循环从观察者进行消费,主线程就可以马不停蹄的只关注业务不用再去进行I/O等待。

    • 优点: Node 公开宣称的目标是 “旨在提供一种简单的构建可伸缩网络程序的方法”。我们来看一个简单的例子,在 Java和 PHP 这类语言中,每个连接都会生成一个新线程,每个新线程可能需要2MB的配套内存。在一个拥有8GBRAM的系统上,理论上最大的并发连接数量是4,000个用户。随着您的客户群的增长,如果希望您的Web应用程序支持更多用户,那么,您必须添加更多服务器。所以在传统的后台开发中,整个Web应用程序架构(包括流量、处理器速度和内存速度)中的瓶颈是:服务器能够处理的并发连接的最大数量。这个不同的架构承载的并发数量是不一致的。
    • 而Node的出现就是为了解决这个问题:更改连接到服务器的方式。在Node 声称它不允许使用锁,它不会直接阻塞 I/O 调用。Node在每个连接发射一个在 Node 引擎的进程中运行的事件,而不是为每个连接生成一个新的 OS 线程(并为其分配一些配套内存)。
  • 缺点:如上所述,nodejs的机制是单线程,这个线程里面,有一个事件循环机制,处理所有的请求。在事件处理过程中,它会智能地将一些涉及到IO、网络通信等耗时比较长的操作,交由worker-threads去执行,执行完了再回调,这就是所谓的异步IO非阻塞吧。但是,那些非IO操作,只用CPU计算的操作,它就自己扛了,比如算什么斐波那契数列之类。它是单线程,这些自己扛的任务要一个接着一个地完成,前面那个没完成,后面的只能干等。因此,对CPU要求比较高的CPU密集型任务多的话,就有可能会造成号称高性能,适合高并发的node.js服务器反应缓慢。

Node.js高并发使用Nginx+pm2,pm2中可以开启多线程负载均衡,模式分两种:

pm2简介: PM2node进程管理工具,可以利用它来简化很多node应用管理的繁琐任务,如性能监控、自动重启、负载均衡等,而且使用非常简单。

下面就对PM2进行入门性的介绍,基本涵盖了PM2的常用的功能和配置。

  • fork模式,单实例多进程,常用于多语言混编,比如php、python等,不支持端口复用,需要自己做应用的端口分配和负载均衡的子进程业务代码。

缺点就是单服务器实例容易由于异常会导致服务器实例崩溃。

  • cluster模式,多实例多进程,但是只支持node,端口可以复用,不需要额外的端口配置,0代码实现负载均衡。

优点就是由于多实例机制,可以保证服务器的容错性,就算出现异常也不会使多个服务器实例同时崩溃。

  • 共同点,由于都是多进程,都需要消息机制或数据持久化来实现数据共享。

pm2部署,默认开启负载均衡:

  • npm i pm2 -g
  • $ pm2 start app.js # 启动app.js应用程序
  • $ pm2 start app.js -i 4 # cluster mode 模式启动4个app.js的应用实例 # 4个应用程序会自动进行负载均衡
  • pm2 start app.js -i max 根据你的cpu数量最大化启动多线程进行负载均衡
  • 如果要停止所有应用,可以pm2 stop all
  • 查看进程状态 pm2 list
  • pm2真心很好很强大,可以在线热更新代码,更多的指令需要上官网看

pm2Nginx配合

  • pm2 + nginx
  • 无非就是在nginx上做个反向代理配置,直接贴配置。
 upstream my_nodejs_upstream {
 server 127.0.0.1:3001;
}
server { 
listen 80;
server_name my_nodejs_server;
root /home/www/project_root; location / { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host; 
proxy_set_header X-NginX-Proxy true; 
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; 
proxy_set_header Connection "upgrade";
proxy_max_temp_file_size 0; proxy_pass http://my_nodejs_upstream/;
proxy_redirect off; proxy_read_timeout 240s;
    }
    
特别说明,我们不建议使用Node.js作为底层服务器,更多时候作为中间件和接入层使用,例如Electron开发跨平台应用
  • Nginx开启多线程,负载均衡

负载均衡的作用

  • 负载均衡:分摊到多个操作单元上进行执行,和它的英文名称很匹配。就是我们需要一个调度者,保证所有后端服务器都将性能充分发挥,从而保持服务器集群的整体性能最优,这就是负载均衡。

负载均衡这里面涉及的东西相对也是比较多的,理论就不说太多了,网上,书上很多,今天我们就利用Nginx服务器来实现一个简单的负载均衡

负载均衡算法

  • 源地址哈希法:根据获取客户端的IP地址,通过哈希函数计算得到一个数值,用该数值对服务器列表的大小进行取模运算,得到的结果便是客服端要访问服务器的序号。采用源地址哈希法进行负载均衡,同一IP地址的客户端,当后端服务器列表不变时,它每次都会映射到同一台后端服务器进行访问。
  • 轮询法:将请求按顺序轮流地分配到后端服务器上,它均衡地对待后端的每一台服务器,而不关心服务器实际的连接数和当前的系统负载。
  • 随机法:通过系统的随机算法,根据后端服务器的列表大小值来随机选取其中的一台服务器进行访问。
  • 加权轮询法:不同的后端服务器可能机器的配置和当前系统的负载并不相同,因此它们的抗压能力也不相同。给配置高、负载低的机器配置更高的权重,让其处理更多的请;而配置低、负载高的机器,给其分配较低的权重,降低其系统负载,加权轮询能很好地处理这一问题,并将请求顺序且按照权重分配到后端。
  • 加权随机法:与加权轮询法一样,加权随机法也根据后端机器的配置,系统的负载分配不同的权重。不同的是,它是按照权重随机请求后端服务器,而非顺序。
  • 最小连接数法:由于后端服务器的配置不尽相同,对于请求的处理有快有慢,最小连接数法根据后端服务器当前的连接情况,动态地选取其中当前积压连接数最少的一台服务器来处理当前的请求,尽可能地提高后端服务的利用效率,将负责合理地分流到每一台服务器。
  • 下载Nginx,找到config文件夹下面的nginx.conf,修改下面配置文件
  • 每个
upstream test{ 
      server 11.22.333.11:6666 weight=1; 
      server 11.22.333.22:8888 down; 
      server 11.22.333.33:8888 backup;
      server 11.22.333.44:5555 weight=2; 
}
//down 表示单前的server临时不參与负载.
//weight 默觉得1.weight越大,负载的权重就越大
//backup: 其他全部的非backup机器down或者忙的时候,请求backup机器。所以这台机器压力会最轻
 

nginx命令汇总 :

nginx 服务器重启命令,关闭
nginx -s reload :修改配置后重新加载生效
nginx -s reopen :重新打开日志文件
nginx -t -c /path/to/nginx.conf 测试nginx配置文件是否正确

关闭nginx:
nginx -s stop :快速停止nginx
quit :完整有序的停止nginx

其他的停止nginx 方式:

ps -ef | grep nginx

kill -QUIT 主进程号 :从容停止Nginx
kill -TERM 主进程号 :快速停止Nginx
pkill -9 nginx :强制停止Nginx

 

启动nginx:
nginx -c /path/to/nginx.conf

平滑重启nginx:
kill -HUP 主进程号

在开启Nginx多线程负载均衡和部署pm2负载均衡后的架构图:

  • 第一种,Node.js作为底层服务器,直接操作数据库的方式:

  • 第二种,Node.js作为中间件,访问底层服务器的方式:

高并发下性能对比,Apache、Nginx 与 Node.js 之争

高并发下的性能测试对比:
  • 参考文章 : 巨头终极对决,Apache、Nginx 与 Node.js 之争
  • 所有的测试都在本地运行:
  • 英特尔酷睿 i7-2600k,四核八线程的机器
  • Gentoo Linux 是用于测试的操作系统
  • 用于基准测试的工具:ApacheBench,2.3 <$Revision: 1748469 $>
  • 测试包括一系列基准,从 1000 到 10000 个请求以及从 100 到 1000 个的并发请求——结果相当令人惊讶。

高并发测试结果对比:

  • Apache、Nginx 与 Node 的对比:请求负载的性能(每 100 位并发用户)

  • Apache、Nginx 与 Node 的对比:用户负载能力(每 1000 个请求)

  • 压力测试

我们可以从结果中得到什么?
  • 从以上结果判断,似乎 Nginx 可以在最少的时间内完成最多请求,换句话来说,Nginx 是最快的 HTTP 服务器。
  • 还有一个相当惊人的事实是,在特定的用户并发数和请求数下,Node.js 可以比 Nginx 和 Apache 更快。
  • 但当请求的数量在并发测试中增加的时候,Nginx 将重回领先的位置,这个结果可以让那些陷入 Node.js 的遐想的人清醒一下。
  • 和 Apache、Nginx 不同的是,Node.js 似乎对用户的并发数不太敏感,尤其是在集群节点。如图所示,集群节点在 0.1 秒左右保持一条直线,而 Apache 和 Nginx 都有大约 0.2 秒的波动。
  • 基于上述统计可以得出的结论是:网站比较小,其使用的服务器就无所谓。然而,随着网站的受众越来越多,HTTP 服务器的影响变得愈加明显。
  • 当涉及到每台服务器的原始速度的底线的时候,正如压力测试所描述的,我的感觉是,性能背后最关键的因素不是一些特定的算法,而实际上是运行的每台服务器所用的编程语言。
  • 由于 Apache 和 Nginx 都使用了 C 语言—— AOT 语言(编译型语言),而 Node.js 使用了 JavaScript ——这是一种 JIT 语言(解释型语言)。这意味着 Node.js 在执行程序的过程中还有额外的工作负担。
  • 这意味着我不能仅仅基于上面的结果来下结论,而要做进一步校验,正如你下面看到的结果,当我使用一台经过优化的 Node.js 服务器与流行的 Express 框架时,我得到几乎相同的性能结论。
全面考虑
  • 逝者如斯夫,如果没有服务的内容,HTTP 服务器是没什么用的。因此,在比较 we服务器的时候,我们必须考虑的一个重要的部分就是我们希望在上面运行的内容。
  • 虽然也有其它的功能,但是 HTTP 服务器最广泛的使用就是运行网站。因此,为了看到每台服务器的性能的实际效果,我决定比较一下世界上使用最广泛的 CMS(内容管理系统)WordPress 和 Ghost —— 内核使用了 JavaScript 的一颗冉冉升起的明星。
  • 基于 JavaScript 的 Ghost 网页能否胜过运行在 PHP 和 Apache / Nginx 上面的 WordPress 页面?
  • 这是一个有趣的问题,因为 Ghost 具有操作工具单一且一致的优点——无需额外的封装,而 WordPress 需要依赖 Apache / Nginx 和 PHP 之间的集成,这可能会导致显著的性能缺陷。
  • 除此之外,PHP 距 Node.js 之间还有一个显著的性能落差,后者更佳,我将在下面简要介绍一下,可能会出现一些与初衷大相径庭的结果。
PHP 与 Node.js 的对决
  • 为了比较 WordPress 和 Ghost,我们必须首先考虑一个影响到两者的基本组件。
  • 基本上,WordPress 是一个基于 PHP 的 CMS,而 Ghost 是基于 Node.js(JavaScript)的。与 PHP 不同,Node.js 有以下优点:
  • 非阻塞的 I/O
  • 事件驱动
  • 更新颖、更少的残旧代码
  • 由于有大量的测评文章解释和演示了 Node.js 的原始速度超过 PHP(包括 PHP 7),我不会再进一步阐述这个主题,请你自行用谷歌搜索相关内容。
  • 因此,考虑到 Node.js 的性能优于 PHP,一个 Node.js 的网站的速度要比 Apache / Nginx 和 PHP 的网站快吗?
  • WordPress 和 Ghost 对决
  • 当比较 WordPress 和 Ghost 时,有些人会说这就像比较苹果和橘子,大多数情况下我同意这个观点,因为 WordPress 是一个完全成熟的 CMS,而 Ghost 基本上只是一个博客平台。
  • 然而,两者仍然有共同竞争的市场,这两者都可以用于向世界发布你的个人文章。
  • 制定一个前提,我们怎么比较两个完全基于不同的代码来运行的平台,包括风格主题和核心功能。
  • 事实上,一个科学的实验测试条件是很难设计的。然而,在这个测试中我对更接近生活的情景更感兴趣,所以 WordPress 和 Ghost 都将保留其主题。因此,这里的目标是使两个平台的网页大小尽可能相似,让 PHP 和 Node.js 在幕后斗智斗勇。
  • 由于结果是根据不同的标准进行测量的,最重要的是尺度不一样,因此在图表中并排显示它们是不公平的。因此,我改为使用表:
  • Node、Nginx、Apache 以及运行 WordPress 和 Ghost 的比较。前两行是 WordPress,底部的两行是 Ghost
  • Node、Nginx、Apache 以及运行 WordPress 和 Ghost 的比较。前两行是 WordPress,底部的两行是 Ghost
  • 正如你所见,尽管事实上 Ghost(Node.js)正在加载一个更小的页面(你可能会惊讶 1kb 可以产生这么大的差异),它仍然比同时使用 Nginx 和 Apache 的 WordPress 要慢。
  • 此外,使用 Nginx 代理作为负载均衡器来接管每个 Node 服务器的请求实际上会提升还是降低性能?
  • 那么,根据上面的表格,如果说它产生什么效果的话,它造成了更慢的效果——这是一个合理的结果,因为额外封装一层理所当然会使其变得更慢。当然,上面的数字也表明这点差异可以忽略不计。
  • 但是上表中最重要的一点是,即使 Node.js 比 PHP 快,HTTP 服务器的作用也可能超过某个 web 平台使用的编程语言的重要性。
  • 当然,另一方面,如果加载的页面更多地依赖于服务器端的脚本处理,那么我怀疑结果可能会有点不同。
  • 最后,如果一个 web 平台真的想在这场竞赛里击败 WordPress,从这个比较中得出的结论就是,要想性能占优,必须要定制一些像 PHP-FPM 的工具,它将直接与 JavaScript 通信(而不是作为服务器来运行),因此它可以完全发挥 JavaScript 的力量来达到更好的性能。

Node.js的生态圈汇总:

  • Node.js遵循commonJS规范,要说它的生态圈,第一个肯定是webpack,用不好Node.js的人肯定用不好webpack,所以说Node.js的一个突破初级前端工程师的好学习方向
  • express koa koa2 egg一系列的Node.js框架,在Restful架构下使用,完成常规的一些http,ajax请求响应
  • GraphQLGraphQL 是一种 API 所使用的查询语言,不止Node.js有,其他语言也有,不止可以查询,还可以多数据库CRUD操作,解决了一部分RestFul架构带来的问题
  • mongodb,非关系型数据库,轻量级别数据库,目前Node.js配合使用的比较多的数据库,在Node.js中我们一般使用 mongoose这个库来配合使用
  • sqlite,SQLite是一个进程内的库,实现了自给自足的、无服务器的、零配置的、事务性的 SQL 数据库引擎。它是一个零配置的数据库,这意味着与其他数据库一样,您不需要在系统中配置。就像其他数据库,SQLite 引擎不是一个独立的进程,可以按应用程序需求进行静态或动态连接。SQLite 直接访问其存储文件。
  • Electron,跨平台桌面开发,可以使用Node.js的API,V8的环境也被打包在内。
  • C++插件,Node.js的V8环境就是C++写的,自然也是可以使用C++插件
  • Redis,数据缓存层,Redis支持主从同步。数据可以从主服务器向任意数量的从服务器上同步,从服务器可以是关联其他从服务器的主服务器。这使得Redis可执行单层树复制。存盘可以有意无意的对数据进行写操作。由于完全实现了发布/订阅机制,使得从数据库在任何地方同步树时,可订阅一个频道并接收主服务器完整的消息发布记录。同步对读取操作的可扩展性和数据冗余很有帮助。
  • SSR, 以React为例,在中间层对代码进行注水,在客户端对代码脱水,实现部分首屏SSR,优化首屏渲染时间。
  • websocket通讯等
  • puppeteer爬虫

总结一下Node.js

  • Node.js在目前前端的开发中,是一项不可或缺的技能,它也是让我们走向真正全栈工程师的路不那么陡峭
  • Node.js适用场景,非密集型计算型
  • Node.js最核心的部分不止是RestFul架构的那一套接受请求,返回数据。还有文件IO,流,Buffer,redis层这一类的操作
  • Node.js配合Nginx进行负载均衡,不仅能提升性能,更能替后端真正减轻很多负担,完成许多特定的需求。
  • Node.js在做接入层,比如Electron中,可以调用很多Node API,完成渲染进程不能做的事情,例如文件io,buffer操作等
今天由于时间有限,很多东西都没有细化下去写,可能还是有不少漏掉的,以后都会慢慢补上,走过路过,点点赞,咱们永远都是A

查看原文

Denzel 发布了文章 · 9月30日

用JS 对象神侃软硬链接与文件拷贝的区别

前言

在Linux或MacOS系统中,ln命令是一个重要的命令,它的功能是为某一个文件在另外一个位置建立一个同步的链接。

对于前端来说,ln 命令被应用最多的地方就是, 就是全局安装并创建一个 npm 命令

npm i -g xxx(nrm)

当敲下回车,上面的安装执行完成后,在输出中,会看到这样一串字符:

/usr/local/bin/nrm -> /usr/local/lib/node_modules/nrm/cli.js

这串字符背后的意思就是系统建立了node_modules/nrm/cli.js 的软链接/bin/nrm

其实bin文件夹中的可执行命令,基本都是以软链接的形式存在。

更多关于 ln 的使用,可参考菜鸟教程

下面会围绕路径A 和 B 这两个实例来讲软硬链接和文件拷贝的区别:

路径A: /user/wam/A/request.js

路径B: /user/wam/B/request.js

request.js 内容

import utils from './utils';

console.log('res:', utils.res());

目录与文件

在开始前,简单回顾一下大学没学过,可能在那里看到过的文件系统,这里围绕简单易理解的Linux为例。
20200930152537
大部分的Linux文件系统(如ext2、ext3)规定,一个文件由目录、节点(inode)和数据块(block)组成

  • 目录项:包括文件名和inode节点号。
  • inode:又称文件索引节点,包含文件的基础信息以及数据块的位置。
  • block:包含文件的具体内容。

当我们随便打开我们某个开发项目,命令行输入ls -li, 就可看到目录与inode的对应信息,下图第一行就是目录对应的inode。
20200930150545

由于一个文件块(block)的大小有限(通常为4kb),所有常常一个文件需要存储在多个文件块中,这样 inode 就需要存储多个block的位置信息(如最上图所示), 而一个inode本身只有128 Btyes 的存储空间,所以存储文件block位置也是间接通过block来做的, 所以block 可以理解为分两种: 文件内容block 与 inode信息block,搞懂这些就可以往下了。

参考资料

软链接

软链接(soft link) 又被称为符号链接,相当于Window 系统中的快捷方式。

eg: 建立A 为 B 的软链接

ln -s /user/wam/B/request.js /user/wam/A/request.js

image

建立软链接 其实质就是某为路径的建立一个超链接(在这表现为 A 为 B的超链接),其不具有文件实体。当我们尝试打开A 路径所在的文件,其最终在编辑器打开的是路径B的文件,所以其文件内的相对路径引用文件也是相对路径B来计算的,即utils 文件路径为:

/user/wam/B/utils.js
当删除B文件,再去访问A, 会发现索引不存在,无法访问。

硬链接

硬链接(hard link), 是为源文件建立另一个索引。

eg: 建立A 为 B 的硬链接

// 少一个 -s 选项
ln /user/wam/B/request.js /user/wam/A/request.js

image

建立硬链接 其实质就是为文件实体创建另一个可访问的路径索引。所以当我们尝试打开A 路径所在的文件,与软链接区别的是:其最终在编辑器打开的是路径A自己,所以其文件内的 相对路径引用文件 也是相对路径A来计算的, 即utils 文件路径为:

/user/wam/A/utils.js

但值得一提的是,由于 A 与 B 路径都指向同一个源文件,所以在A路径对文件内容所做的编辑都会反映在 B 路径文件,即两边文件的变动是相互同步影响的。

当删除路径B时,源文件不会被垃圾回收,因为路径A 仍保持对源文件的索引。

硬链接和软链接还有一个区别是:因为系统的限制,硬链接要求路径是在文件维度,而软链接既可以是文件,也可以是文件夹。

文件拷贝

这个应该用过电脑的人都懂。

eg: 拷贝文件B 到路径 A

// 少一个 -s 选项
cp -f /user/wam/B/request.js /user/wam/A/request.js

image.png

文件拷贝,是日常我们最常见的操作,只是更常见的形式是用ctrl + c/v,而非cp 命令(实际上cp 也能实现ln链接的操作), 其实质就是拷贝一份文件实体并创建一个可访问的路径索引。所以当我们尝试打开A 路径所在的文件,其指向的实体是不同于B的(克隆体),所以其文件内的相对路径引用文件也是相对路径A来计算的即utils 文件路径为,与建立硬链接一致:

/user/wam/A/utils.js
由于 A 路径 与 B 路径 都分别指向自己的实体,所以A/B各自是独立的,当删除B时,B对应的源文件会被回收,A不受任何影响。

神侃JS对象与硬软链接

作为前端我们都知道,JS对象(object)是引用类型。

引用类型的值是保存在内存中的对象。JS 不允许直接访问内存中的位置,即不能直接操作对象的内存空间。在操作对象时,实际上是在操作对象的引用而不是实际的对象。为此,引用类型的值是按引用访问的。(摘抄自红宝书 P87)

你品,你细品。是不是感觉 引用类型 和我们上面讲到文件链接与源文件很像。

以:

const B = { a: 1 };

20200911111937

所以当执行下面这种操作:

const C = B;

C.a = 2;

console.log('B.a:', B.a); // 2

B.a = 3; 
console.log('C.a:', C.a); // 3

从上面的执行输出,我们可以很容易看出,原来JS中的引用类型变量赋值和硬链接 是一回事。

接着我们引入一个lodash 的深拷贝(cloneDeep)函数:

import { cloneDeep } from 'lodash';

const C = cloneDeep(B);

C.a = 2;

console.log('B.a:', B.a); // B.a: 1

B.a = 3; 
console.log('C.a:', C.a); // C.a: 2

从上面的执行输出,我们可以很容易看出,原来JS中的深拷贝和文件拷贝 是一回事。

软链接怎么用 JS 来描述呢?Proxy?

Proxy 中文译作代理,在表现上其实是与硬链接一致的,而硬链接与软链接从表现上最大的区别就是:B(母体) 被删除后,A(超链接)就不可访问了,所以这并不是正确的答案。

而正确答案是:WeakRef,弱引用. 当下处于proposal阶段,不过在Chrome 与 Firefox 最新的版本都对其做了实现;

看个demo:

let B = { a: 1 };

const C = new WeakRef(B);

const registry = new FinalizationRegistry(heldValue => {
  console.log('GC worked:', heldValue); // GC worked: B
  // 当B所指向的值被垃圾回收后,这个回调将被执行
  console.log('C.a:', C.deref()?.a); //C.a: undefined
});

// 注册B所指向的值被垃圾回收的监听
registry.register(B, "B");

console.log('C.a:', C.deref().a); // C.a: 1

C.deref().a = 2; // 通过索引改变值

console.log('B.a:', B.a); // B.a: 2

B.a = 3;

console.log('C.a:', C.deref().a); // C.a: 3

// 切断对值得索引, 观察上面的GC 回调
B = null;

// console.log('after C.a:', C.deref()?.a);

貌似上面的JS代码能勉强阐述软链接的原理,但离理想确实还有距离,这个神侃更多的是想让大家对ES新提案中的WeakRefFinalizationRegistry有一个感性的认识。

更多关于 WeakRef 请阅读

结语

通过本文,你是不是发现,这世间万事万物是不是特别奇妙。虽然是神侃,读到这里,希望你能有一丝丝收获。

原文见: https://github.com/closertb/c...

公众号:前端黑洞

查看原文

赞 3 收藏 2 评论 0

Denzel 收藏了文章 · 9月22日

微前端的设计理念与实践初探

🎗 本文节选自 Web 开发导论/微前端与大前端,着眼阐述了微服务与微前端的设计理念以及微服务的潜在可行方案,需要致敬的是,本文的很多考虑借鉴了 Phodal 关于微前端的系列讨论以及 Web Architecture Links 中声明的其他文章,此外结合了自己浅薄的考量与实践体悟,框架代码可以参阅 Ueact/micro-frontend

微前端

微服务与微前端,都是希望将某个单一的单体应用,转化为多个可以独立运行、独立开发、独立部署、独立维护的服务或者应用的聚合,从而满足业务快速变化及分布式多团队并行开发的需求。如康威定律(Conway’s Law)所言,设计系统的组织,其产生的设计和架构等价于组织间的沟通结构;微服务与微前端不仅仅是技术架构的变化,还包含了组织方式、沟通方式的变化。微服务与微前端原理和软件工程,面向对象设计中的原理同样相通,都是遵循单一职责(Single Responsibility)、关注分离(Separation of Concerns)、模块化(Modularity)与分而治之(Divide & Conquer)等基本的原则。

image

在某些场景下,微前端也包含了对于系统的纵向切分;即不同的团队会负责系统中某个特性/模块,从数据库、服务端到用户界面完整的流线。每个团队会更多地着眼于业务模型与特点。独立并不意味着完全的切割,各个特性/模块之间的共现组件可以通过 NPM/Git Submodule 等方式进行协同开发与复用。微前端的落地,需要考虑到产品研发与发布的完整生命周期;我们会关注如何保证各个团队的独立开发与灵活的技术栈选配,如何保证代码风格、代码规范的一致性,如何合并多个独立的前端应用,如何在运行时对多个应用进行有效治理,如何保障多应用的体验一致性,如何保障个应用的可测试与可依赖性等方面。具体而言,我们可能从应用组合、应用隔离、应用协调与治理、开发环境等几个方面进行考虑:

  • 应用组合:

    • 组合时机,在构建时组合,还是在运行时组合
    • 应用路由,如何根据 URL 加载/导航到不同的页面,如何根据子应用界面的变化切换 URL
    • 应用加载,确定加载应用的版本,依赖于框架的加载机制,还是采用 AMD 或者 SystemJS 异步加载
  • 应用隔离:

    • 应用容错,某个应用的崩溃不应影响到其他应用或容器应用;
    • 样式隔离,避免 CSS 相互污染
    • DOM 隔离,避免子应用操作非自身作用域内的结点
  • 应用协调与治理:

    • 统一配置与切换,主题,利用 CSS Variables 等方式动态换肤
    • 应用的生命周期,规范化子应用的生命周期,并且在不同生命周期中执行不同的操作
    • 数据共享,子应用间数据共享
    • 服务共享,跨应用数据共享与服务调用
    • 组件共享,可能将某个纯界面组件或者业务组件以插件(Plugin)或者部件(Widget)的方式共享出去;提供某个计算能力。
  • 开发环境:

    • 跨技术栈支持
    • 统一的构建流程与规范
    • 打桩、埋点与 Hijack

此外值得一提的是,微前端化本身是为了保证系统的持续集成与快速迭代,那么对于各个子模块与系统本身的可用性与稳定性势必会带来挑战,这就要求我们在设计微前端解决方案时,考虑持续构建的时机与对应的测试方案;除了标准的单元测试、集成测试、端到端测试之外,我们还需要保证模块的依赖一致性与功能模块的可生成性;关于此部分的详细讨论参阅 Web 自动化测试概述

微服务

📚 更多关于微服务的讨论参考微服务理念、架构与实践速览

微服务是一个简单而泛化的概念,不同的行业领域、技术背景、业务架构对于微服务的理解与实践也是不一致的。与微服务相对的,即是单体架构的巨石型(Monolithic)应用,典型的即是将所有功能都部署在一个 Web 容器中运行的系统。虽然很多的文章对于巨石型应用颇多诟病,但并不意味着其就真的一无是处,毕竟微服务本身也是有代价的。除了组织的结构之外,微服务往往还要求组织具备快速的环境提供(Rapid Provisioning)与云开发、基本的监控(Basic Monitoring)、快速的应用发布(Rapid Application Deployment)、DevOps 等能力。

image

微服务应用往往由多个粒度较小,版本独立,有明确边界并可扩展的服务构成,各个服务之间通过定义好的标准协议相互通信。在构建微服务架构时,模块化(Modularity)和分而治之(Divide & Conquer)是基本的思路。然后需要考虑单一职责(Single Responsibility)原则,即一个服务应当承担尽可能单一的职责,服务应基于有界的上下文(Bounded Context),通常是边界清晰的业务领域构建。从系统衍化的角度,在系统早期流量较少时,只需一个应用将所有功能都部署在一起,以减少部署节点和成本。随着流量逐步增大,我们过渡为了包含多个相互隔离应用的垂直应用架构;即是将不同职能的模块分成不同的服务,也逐步开始了微服务化的步伐。接下来,随着垂直应用越来越多,应用之间交互不可避免,将核心业务抽取出来,作为独立的服务,逐渐形成稳定的服务中台。

基于这些思考,我们可以将微服务中的挑战与关注点,划分为以下方面:

📖 图片源于 Awesome-MindMap/MicroService-MindMap

microservice

浏览器硬隔离

组合与隔离,本就是一体两面,往往某种组合方案就自然解决了隔离的痛点,而某种隔离方案又会限制组合的方式。笔者首先从硬/软隔离的角度来对方案进行分类,服务端路由分发与 iFrame 是典型的基于浏览器的硬隔离方案,其天然支持多技术栈、多源的灵活组合,不过其在应用协调与治理方面需要投入较大的精力。Web Components 及其衍生方案同样能带来浏览器级别的隔离与松散的应用协调,但是较差的浏览器兼容性也限制了其应用场景。

iFrame

iFrame 可以创建一个全新的独立的宿主环境,iFrame 的页面和父页面是分开的,作为独立区域而不受父页面的 CSS 或者全局的 JavaScript 影响。iFrame 的不足或缺陷也非常明显,其会进行资源的重复加载,占用额外的内存;其会阻塞主页面的 onload 事件,和主页面共享连接池,而浏览器对相同域的连接有限制,所以会影响页面的并行加载。

iFrame 的改造门槛较低,但是从功能需求的角度看,其无法提供 SEO,并且需要我们自定义应用管理与应用通讯机制。iFrame 的应用管理不仅要关注其加载与生命周期,还需要考虑到浏览器缩放等场景下的界面重适配问题,以提供用户一致的交互体验;这里我们再简要讨论下同源场景中的跨界面通讯解决方案。

📖 详细解读参阅 DOM CheatSheet
  • BroadcastChannel

BroadcastChannel 能够用于同源不同页面之间完成通信的功能。它与 window.postMessage 的区别就是,BroadcastChannel 只能用于同源的页面之间进行通信,而 window.postMessage 却可以用于任何的页面之间;BroadcastChannel 可以认为是 window.postMessage 的一个实例,它承担了 window.postMessage 的一个方面的功能。

const channel = new BroadcastChannel('channel-name');

channel.postMessage('some message');
channel.postMessage({ key: 'value' });

channel.onmessage = function(e) {
  const message = e.data;
};

channel.close();
  • SharedWorker API

Shared Worker 类似于 Web Workers,不过其会被来自同源的不同浏览上下文间共享,因此也可以用作消息的中转站。

// main.js
const worker = new SharedWorker('shared-worker.js');

worker.port.postMessage('some message');

worker.port.onmessage = function(e) {
  const message = e.data;
};

// shared-worker.js
const connections = [];

onconnect = function(e) {
  const port = e.ports[0];
  connections.push(port);
};

onmessage = function(e) {
  connections.forEach(function(connection) {
    if (connection !== port) {
      connection.postMessage(e.data);
    }
  });
};
  • Local Storage

localStorage 是常见的持久化同源存储机制,其会在内容变化时触发事件,也就可以用作同源界面的数据通信。

localStorage.setItem('key', 'value');

window.onstorage = function(e) {
  const message = e.newValue; // previous value at e.oldValue
};

Web Components && Shadow DOM

Web Components 的目标是减少单页应用中隔离 HTML,CSS 与 JavaScript 的复杂度,其主要包含了 Custom Elements, Shadow DOM, Template Element,HTML Imports,Custom Properties 等多个维度的规范与实现。Shadow DOM 它允许在文档(document)渲染时插入一棵 DOM 元素子树,但是这棵子树不在主 DOM 树中。因此开发者可利用 Shadow DOM 封装自己的 HTML 标签、CSS 样式和 JavaScript 代码。子树之间可以相互嵌套,对其中的内容进行了封装,有选择性的进行渲染。这就意味着我们可以插入文本、重新安排内容、添加样式等等。其结构示意如下:

image

简单的 Shadow DOM 创建方式如下:

<html>
  <head></head>
  <body>
    <p id="hostElement"></p>
    <script>
      // 创建 shadow DOM
      var shadow = document.querySelector('#hostElement').attachShadow({mode: 'open'});
      // 给 shadow DOM 添加文字
      shadow.innerHTML = '<p>Here is some new text</p>';
      // 添加CSS,将文字变红
      shadow.innerHTML += '<style>p { color: red; }</style>';
    </script>
  </body>
</html>

我们也可以将 React 应用封装为 Custom Element 并且封装到 Shadow DOM 中:

import React from 'react';
import retargetEvents from 'react-shadow-dom-retarget-events';

class App extends React.Component {
  render() {
    return <div onClick={() => alert('I have been clicked')}>Click me</div>;
  }
}

const proto = Object.create(HTMLElement.prototype, {
  attachedCallback: {
    value: function() {
      const mountPoint = document.createElement('span');
      const shadowRoot = this.createShadowRoot();
      shadowRoot.appendChild(mountPoint);
      ReactDOM.render(<App />, mountPoint);
      retargetEvents(shadowRoot);
    }
  }
});
document.registerElement('my-custom-element', { prototype: proto });

Shadow DOM 的兼容性较差,仅在 Chrome 较高版本浏览器中可以使用。

单体应用软隔离

与硬隔离相对的,笔者称为单体应用软隔离,其更多地依赖于应用框架或者开发构建流程,来实现容错与样式、DOM 等隔离。单体应用软隔离又可以从应用的组合时机与技术栈的支持情况这两个维度,划分不同的解决方案。对于需要支持不同技术栈(React, Angular, Vue.js, etc.)的场景,我们往往需要彻底的类后端微服务化,每个前端应用都是独立的服务化应用,而宿主应用则提供统一的应用管理和启动机制;此时若需要解决资源重复加载、冗余的问题,则需要依赖统一构建或者由宿主应用提供公共依赖库,子应用打包时仅打包自身或非公用库代码。如果是相同技术栈的场景,那么我们可以方便地利用框架本身的懒加载能力,在开发阶段以模块划分为微应用进行开发,构建时以单体应用的形式构建,在运行时是以应用模块的形式存在。

image

📌 本部分会随着笔者的实践逐步完善丰富,可以保持关注 Web 开发导论 或者 Ueact

Application Composition | 应用组合

典型的应用组合方式分为构建时(Build Time)组合与运行时(Runtime)组合,如下图所示即是典型的构建时组合方案:

🎗 图片源自 Building application in a "Microfrontends" way

image

构建时组合的优势在于能够进行较好地依赖管理,抽取公共模块,减少最终的包体大小,不过其最终的产出仍是单体应用,各个应用模块无法进行独立部署。 与之相对的,运行时组合能够保障真正地独立开发与独立部署:

image

运行时组合中,我们可以选择在使用 Tailor 这样的工具进行服务端组合(SSI),也可以使用 JSPM, SystemJS 这样的动态导入工具,进行客户端组合。运行时组合同时能提供按需加载的特性,优化首页的加载速度。不过运行时组合可能重复加载依赖项(通过浏览器缓存或 HTTP2 适度解决),并且不同于 iFrame 的硬隔离,运行时组合仍可能面临难以预料的第三方依赖冲突。

React 这样的声明式组件框架,天然就支持应用的组合,我们可以传入渲染锚点以进行应用组合,也可以将不同框架的应用封装为 Web Components。首先我们可以将 React 应用定义为自定义元素:

📎 完整代码参考 fe-boilerplate/micro-frontend
window.customElements.define(
  'react-app',
  class ReactApp extends HTMLElement {
    ...
    render() {
      render(<App title={this.title} />, this);
    }
    ...
  }
);

然后在前端中直接使用该自定义元素:

<react-app title="React Separate Running App" />

在单体应用中,框架将路由指定到对应的组件或者内部服务中;而微前端中,我们需要将应用内的组件调用变成了更细粒度的应用间组件调用,即原先我们只是将路由分发到应用的组件执行,现在则需要根据路由来找到对应的应用,再由应用分发到对应的组件上。具体的实践中,可能宿主应用使用 Hash Router 已经占用了 Hash 标记位,那么就需要为子应用提供专属的查询键,来进行子应用内跳转。

应用隔离与治理

在 React 中可以使用 ErrorBoundary, 来限制应用崩溃的影响;如果是自定义的应用加载器,也可以实现 Promise 容错方案。Redux 可以考虑在宿主应用创建统一的 Store,每个应用中按照命名空间划分使用子状态空间:

const subConnect = subAppName => (mapStateToProps, mapDispatchToProps) =>
  connect(
    state => mapStateToProps({ ...state[subAppName] }, state),
    mapDispatchToProps
  );

对于 Action 可以使用命名空间形式:

`app/service-name/action`;

而对于应用治理方面,single-spa 或者 ueact-component 都定义了跨框架的组件生命周期,譬如在 single-spa 中,可以将 React 生命周期归一化:

const reactLifecycles = singleSpaReact({
  React,
  ReactDOM,
  rootComponent,
  domElementGetter: () => document.getElementById('main-content')
});

export const bootstrap = [reactLifecycles.bootstrap];

export const mount = [reactLifecycles.mount];

export const unmount = [reactLifecycles.unmount];

然后将其导出为单一应用并且异步加载:

// src/index.js
import { registerApplication, start } from 'single-spa';

registerApplication(
  // Name of our single-spa application
  'root',
  // Our loading function
  () => import('./root.app.js'),
  // Our activity function
  () => true
);

start();
查看原文

Denzel 发布了文章 · 9月15日

一切的前端安全都是纸老虎

引子

最近逼乎有一个很火热的问题,叫前端能否限制用户截图?

20200911180320

当我看到这个问题,我就觉得这个提问者应该是个萌新,或者已经被产品经理或SB leader 折磨的失去理智。因为下方有一个非常直中要害的回答:

无论多么牛的技术手段限制了软件的截图, 用户只要简单的掏出手机对着屏幕拍照就好了。

这个问题,真的说明一切的前端安全,其实都是纸老虎。

接下来,我结合自己遇到的几个场景,来谈一些做前端以来,自己遇到的那些伪前端安全需求。

曾经那些被怼回去的安全需求

最近几年互联网数据泄露非常频繁,我上一家公司是做金融贷款的,非常强调数据安全,这两年也做了不少关于安全的需求。

前端数据脱敏

前端数据脱敏是一个很常见的需求,特别是当今隐私被卖的这么猖狂的时代,所以很多公司都开始注重这些细节,最基础的就是数据脱敏。

数据脱敏,就是将用户的隐私信息,用一些手段,让这些信息有一定辨识度,但又无法准确获取,比如:

  • 金*胖
  • 186**2892
  • 510*1262
  • 川A 7*1

上面一般是我们常见到的数据脱敏格式,我又叫他数据马赛克。前端能不能做,肯定能做,一个正则配上一个String.replace方法就搞定。但如果产品让我们实现这种需求,我们肯定要拒绝,因为前端做数据脱敏就是被单里眨眼睛 - 自欺欺人。

归根揭底,一个稍微有点IT常识的人,如果想要这些数据,直接从请求拿就是了,何必从页面复制。

所以数据脱敏这种事,一定要交给后端做,从源头开始脱敏。

可能后面一些场景,有些被脱敏的数据,在前端又要被用到。比如列表数据脱敏,到详情/表单编辑操作时又需要脱敏前的,那就根据ID再发一次请求获取脱敏前的数据,然后对这个接口调用做权限限制和日志记录,让敏感数据的使用相对安全。

表单校验是为了安全么

我们在做表单时,很多时候都会针对数据格式做校验,比如邮箱、电话号码、银行卡号这些,甚至还有一些非常复杂的联动校验。
20200913211120

前端做校验是为了安全么?

可能有那么一丁点意思吧,比如以前我们总是在提校验输入防XSS攻击。但现在前端这种格式校验,更多是为了:提升用户体验,提升用户体验,提升用户体验

  • 首先提醒并引导用户,应该怎么输入;
  • 其次,如果用户输入半天,前端不校验,直接到后台,后台发现格式不正确,再提示用户,这是一个非常耗时且不专业的交互体验;
  • 如果前端没校验,后端也没校验,那这就是一条脏数据插入到数据库,有可能造成XSS攻击SQL攻击, 这就非常危险了。

数据报表加水印

页面加水印,其实在前端很普遍,比如钉钉, 企业微信 的群聊都是加了水印的,很多在线图片编辑工具也是加了的,比如我常用的图怪兽,你想白嫖,他就给你加个水印:
20200912214649

而当时我们有些列表,因为运营需要,有些数据没法做数据脱敏,所以领导说,前端能不能做个水印,让数据安全一点:防止运营人员不按规范处理问题,私自截图。所以当我看到知乎那个提问是,我特别庆幸,没有让我做:限制用户截图

从我个人经验来讲,前端加水印有三个层次:

  • 通过 CSS 背景加水印,简单粗暴,能骗一点文科运营。但稍微懂行的人,就知道通过 Elements 编辑面板屏蔽这个水印。正所谓你加的简单,别人去掉更简单。
  • 通过 JS 定向植入水印dom节点,这个比上一个稍微复杂点,但还是通过Elements 编辑面板屏蔽,只不过多思考一下,操作步骤多点。
  • 终解: 服务端加水印生成列表图片,实现思路和图怪兽网站一致。但这个操作描述起来简单,具体实现就非常复杂,需要考虑投入产出比

有可能你会疑问,为什么是服务端加水印生成图片,而不是前端自己通过 canvas 生成?

  • 第一,同上面提到过的,通过请求拿到敏感数据,本身就是不安全的;
  • 第二,JS 本身是不安全的,可篡改;

JS 可篡改

我上面反复在说前端安全是个伪需求,你可能不信。但如果你知道 JS 是可篡改的,那你就明白为什么了。我们总是在提JS丑化,但丑化更多是减少包的体积,在某种程度上,可以让发布的js资源可读性更差,但做到不可读很难。

接下来体验一下什么叫 JS 可篡改吧

实战演练

  • 第一步:Chrome 下载安装 Header Editor 插件

20200914181822

  • 第二步:找一个目标网站,并找到一个你想篡改的 JS 资源。我这里以我常用的作图网站的jquery 资源(https://js.tuguaishou.com/js/...)引用为例;
  • 第三步:拷贝代码带编辑器,输入你想篡改的内容,我这里就只加了个console输出, 然后本地起一个静态资源服务
  // ...
  console.log('do some change, ho ho ho');
  var c = []
    , d = c.slice
    , e = c.concat
  // ...
  • 第四步,使用插件重定向网站静态资源,我这里就是使用本地的jquery 代替网站原有的,保存配置并启动那个规则;

20200914182637

  • 第五步: 强刷浏览器,使代理资源生效,当你看到请求被标成了307的响应,说明篡改生效,然后看console,就有了对应的输出;

20200914183058

至此,一次完整的篡改完成。但这个教程并不是让你用这个方法去做一些XX的事情,而只是让你明白由于JS的可篡改,我们在做网站设计时,你需要时刻思考代码安全的事,能做不代表可以做

分享个趣事

年初,前公司要做一次大的系统融合:就是两个子公司各有各的权限系统,然后资本青睐的一方占优(以系统设计来讲,我们的权限系统设计更专业),被遗弃的我们不得不改我们现有系统,去对接对方的系统。

这中间因为异地沟通问题,对方开始没把规则说好,然后对接人将我们清洗重组后的数据导入了数据库。后面由于需要修改一些数据,网站页面提示我们导入数据key的格式不对,而这个key,又被设置成了只读,这就走入了死路。然后沟通能不能把数据库数据删除了重新导入,对方一万个不情愿,让我们自己在网站上自己删除再添加数据,那可是200多条数据啊,侮辱谁呢????
20200915093613
这真的把我同事们惹毛了,然后就试了上面的招数,看了对方的JS,代理然后再一提交,成功!!!

知道对方业余,但没想到这么业余:仅在前端做了限制,服务端没校验。

20200915093256

这个案例提醒我们,前端重在交互,服务端重在安全,JS是可篡改的;所以不要产品以为,要你以为,用你的专业Say no!!!;也告诉我们做技术,谦虚点,能帮忙就尽量帮,做个好人。

结语

又摧拉枯朽扯了一堆,但愿对你以后的需求评审和方案设计有用。前端安全重要吗?重要。网站的安全全交给前端合适吗? 不合适。

公众号:前端黑洞

查看原文

赞 35 收藏 19 评论 4

Denzel 赞了文章 · 8月29日

前端也要懂物理 —— 惯性滚动篇

HEADER
作者:凹凸曼-吖伟

我们在平时编程开发时,除了需要关注技术实现、算法、代码效率等因素之外,更要把所学到的学科知识(如物理学、理论数学等等)灵活应用,毕竟理论和实践相辅相成、密不可分,这无论是对于我们的方案选型、还是技术实践理解都有非常大的帮助。今天就让我们一起来回顾中学物理知识,并灵活运用到惯性滚动的动效实现当中。

惯性滚动(也叫 滚动回弹momentum-based scrolling)最早是出现在 iOS 系统中,是指 当用户在终端上滑动页面然后把手指挪开,页面不会马上停下而是继续保持一定时间的滚动效果,并且滚动的速度和持续时间是与滑动手势的强烈程度成正比。抽象地理解,就像高速行驶的列车制动后依然会往前行驶一段距离才会最终停下。而且在 iOS 系统中,当页面滚动到顶/底部时,还有可能触发 “回弹” 的效果。这里录制了微信 APP 【账单】页面中的 iOS 原生时间选择器的惯性滚动效果:

微信原生 date-picker

熟悉 CSS 开发的同学或许会知道,在 Safari 浏览器中有这样一条 CSS 规则:

-webkit-overflow-scrolling: touch;

当其样式值为 touch 时,浏览器会使用具有回弹效果的滚动, 即“当手指从触摸屏上移开,内容会继续保持一段时间的滚动效果”。除此之外,在丰富多姿的 web 前端生态中,很多经典组件的交互都一定程度地沿用了惯性滚动的效果,譬如下面提到的几个流行 H5 组件库中的例子。

流行 UI 库效果

为了方便对比,我们先来看看一个 H5 普通长列表在 iOS 系统下(开启了滚动回弹)的滚动表现:

iOS 下长列表滚动表现

  • weui 的 picker 组件

    weui picker

    明显可见,weui 选择器的惯性滚动效果非常弱,基本上手从屏幕上移开后滚动就很快停止了,体验较为不好。

  • vant 的 picker 组件

    vant picker

    相比之下,vant 选择器的惯性滚动效果则明显清晰得多,但是由于触顶/底回弹时依然维持了普通滚动时的系数或持续时间,导致整体来说回弹的效果有点脱节。

应用物理学模型

惯性 一词来源于物理学中的惯性定律(即 牛顿第一定律):一切物体在没有受到力的作用的时候,运动状态不会发生改变,物体所拥有的这种性质就被称为惯性。可想而知,惯性滚动的本质就是物理学中的惯性现象,因此,我们可以恰当利用中学物理上的 滑块模型 来描述惯性滚动全过程。

为了方便描述,我们把浏览器惯性滚动效果中的滚动目标(如浏览器中的页面元素)模拟成滑块模型中的 滑块。而且分析得出,惯性滚动的全过程可以模拟为(人)使滑块滑动一定距离然后释放的过程,那么,全流程可以拆解为以下两个阶段:

  • 第一阶段,滑动滑块使其从静止开始做加速运动;

    滑块模型第一阶段

    在此阶段,滑块受到的 F拉 大于 F摩 使其从左到右匀加速前进。

    需要注意的是,对于浏览器的惯性滚动来说,我们一般关注的是用户即将释放手指前的一小阶段,而非滚动的全流程(全流程意义不大),这一瞬间阶段可以简单模拟为滑块均衡受力做 匀加速运动
  • 第二阶段,释放滑块使其在只受摩擦力的作用下继续滑动,直至最终静止;

    滑块模型第二阶段

    在此阶段,滑块只受到反向的摩擦力,会维持从左到右的运动方向减速前进然后停下。

基于滑块模型,我们需要找到适合的量化指标来建立惯性滚动的计算体系。结合模型和具体实现,我们需要关注 滚动距离速度曲线 以及 滚动时长 这几个关键指标,下面会一一展开解析。

滚动距离

对于滑动模型的第一阶段,滑块做匀加速运动,我们不妨设滑块的滑动距离为 s1,滑动的时间为 t1,结束时的临界点速度(末速度)为 v1 ,根据位移公式

位移公式

可以得出速度关系

第一阶段末速度

对于第二阶段,滑块受摩擦力 F拉 做匀减速运动,我们不妨设滑动距离为 s2,滑动的时间为 t2,滑动加速度为 a,另外初速度为 v1,末速度为 0m/s,结合位移公式和加速度公式

加速度公式

可以推算出滑动距离 s2

第二阶段滑动距离

由于匀减速运动的加速度为负(即 a < 0),不妨设一个加速度常量 A,使其满足 A = -2a 的关系,那么滑动距离

第二阶段滑动距离和常量关系

然而在浏览器实际应用时,v1 算平方会导致最终计算出的惯性滚动距离太大(即对滚动手势的强度感应过于灵敏),我们不妨把平方运算去掉

两阶段关系

所以,求惯性滚动的距离(即 s2)时,我们只需要记录用户滚动的 距离 s1滚动时长 t1,并设置一个合适的 加速度常量 A 即可。

经大量测试得出,加速度常量 A 的合适值为 0.003

另外,需要注意的是,对于真正的浏览器惯性滚动效果来说,这里讨论的滚动距离和时长是指能够作用于惯性滚动的范围内的距离和时长,而非用户滚动页面元素的全流程,详细的可以看【启停条件】这一节内容。

惯性滚动速度曲线

针对惯性滚动阶段,也就是第二阶段中的匀减速运动,根据位移公式可以得到位移差和时间间距 T 的关系

位移差和时间关系

不难得出,在同等时间间距条件下,相邻两段位移差会越来越小,换句话说就是惯性滚动的偏移量增加速度会越来越小。这与 CSS3 transition-timing-function 中的 ease-out 速度曲线非常吻合,ease-out (即 cubic-bezier(0, 0, .58, 1))的贝塞尔曲线为

ease-out 贝塞尔曲线

曲线图来自 在线绘制贝塞尔曲线网站

其中,图表中的纵坐标是指 动画推进的进程,横坐标是指 时间,原点坐标为 (0, 0),终点坐标为 (1, 1),假设动画持续时间为 2 秒,(1, 1) 坐标点则代表动画启动后 2 秒时动画执行完毕(100%)。根据图表可以得出,时间越往后动画进程的推进速度越慢,符合匀减速运动的特性。

我们试试实践应用 ease-out 速度曲线:

ease-out 曲线应用

很明显,这样的速度曲线过于线性平滑,减速效果不明显。我们参考 iOS 滚动回弹的效果重复测试,调整贝塞尔曲线的参数为 cubic-bezier(.17, .89, .45, 1)

调整后的贝塞尔曲线

调整曲线后的效果理想很多:

调整后的曲线效果

回弹

接下来模拟惯性滚动时触碰到容器边界触发回弹的情况。

我们基于滑块模型来模拟这样的场景:滑块左端与一根弹簧连接,弹簧另一端固定在墙体上,在滑块向右滑动的过程中,当滑块到达临界点(弹簧即将发生形变时)而速度还没有降到 0m/s 时,滑块会继续滑动并拉动弹簧使其发生形变,同时滑块会受到弹簧的反拉力作减速运动(动能转化为内能);当滑块速度降为 0m/s 时,此时弹簧的形变量最大,由于弹性特质弹簧会恢复原状(内能转化成动能),并拉动滑块反向(左)运动。

类似地,回弹过程也可以分为下面两个阶段:

  • 滑块拉动弹簧往右做变减速运动;

    回弹第一阶段模型

此阶段滑块受到摩擦力 F摩 和越来越大的弹簧拉力 F弹 共同作用,加速度越来越大,导致速度降为 0m/s 的时间会非常短。

  • 弹簧恢复原状,拉动滑块向左做先变加速后变减速运动;

    回弹第二阶段模型

    此阶段滑块受到的摩擦力 F摩 和越来越小的弹簧拉力 F弹 相互抵消,刚开始 F弹 > F摩,滑块做加速度越来越小的变加速运动;随后 F弹 < F摩,滑块做加速度越来越大的变减速运动,直至最终静止。这里为了方便实际计算,我们不妨假设一个理想状态:当滑块静止时弹簧刚好恢复形变

回弹距离

根据上面的模型分析,回弹的第一阶段做加速度越来越大的变减速直线运动,不妨设此阶段的初速度为 v0,末速度为 v1,那么可以与滑块位移建立关系:

回弹第一阶段位移

其中 a 为加速度变量,这里暂不展开讨论。那么,根据物理学的弹性模型,第二阶段的回弹距离为

回弹第二阶段位移

微积分都来了,简直没法计算……

然而,我们可以根据运动模型适当简化 S回弹 值的计算。由于 回弹第二阶段的加速度 是大于 非回弹惯性滚动阶段的加速度F弹 + F摩 > F摩)的,不妨设非回弹惯性滚动阶段的总距离为 S滑,那么

回弹距离关系

因此,我们可以设置一个较为合理的常量 B,使其满足这样的等式:

回弹距离等式

经大量实践得出,常量 B 的合理值为 10。

回弹速度曲线

触发回弹的整个惯性滚动轨迹可以拆分成三个运动阶段:

触发回弹的运动轨迹

然而,如果要把阶段 a 和阶段 b 准确描绘成 CSS 动画是有很高的复杂度的:

  • 阶段 b 中的变减速运动难以准确描绘;
  • 这两个阶段虽运动方向相同但动画速度曲线不连贯,很容易造成用户体验断层;

为了简化流程,我们把阶段 ab 合并成一个运动阶段,那么简化后的轨迹就变成:

简化后的回弹运动轨迹

鉴于在阶段 a 末端的反向加速度会越来越大,所以此阶段滑块的速度骤减同比非回弹惯性滚动更快,对应的贝塞尔曲线末端就会更陡。我们选择一条较为合理的曲线 cubic-bezier(.25, .46, .45, .94)

回弹阶段 a 曲线

对于阶段 b,滑块先变加速后变减速,与 ease-in-out 的曲线有点类似,实践尝试:

ease-in-out 曲线实践

仔细观察,我们发现阶段 a 和阶段 b 的衔接不够流畅,这是由于 ease-in-out 曲线的前半段缓入导致的。所以,为了突出效果我们选择只描绘变减速运动的阶段 b 末段。贝塞尔曲线调整为 cubic-bezier(.165, .84, .44, 1)

调整后的贝塞尔曲线

实践效果:

调整后的贝塞尔曲线实践

由于 gif 转格式导致部分掉帧,示例效果看起来会有点卡顿,建议直接体验 demo

CSS 动效时长

我们对 iOS 的滚动回弹效果做多次测量,定义出体验良好的动效时长参数。在一次惯性滚动中,可能会出现下面两种情况,对应的动效时间也不一样:

  • 没有触发回弹

    惯性滚动的合理持续时间为 2500ms

  • 触发回弹

    对于阶段 a,当 S回弹 大于某个关键阈值时定义为 强回弹,动效时长为 400ms;反之则定义为 弱回弹,动效时长为 800ms

    而对于阶段 b,反弹的持续时间为 500ms 较为合理。

启停条件

前文中有提到,如果把用户滚动页面元素的整个过程都纳入计算范围是非常不合理的。不难想象,当用户以非常缓慢的速度使元素滚动比较大的距离,这种情况下元素动量非常小,理应不触发惯性滚动。因此,惯性滚动的触发是有条件的。

  • 启动条件

    惯性滚动的启动需要有足够的动量。我们可以简单地认为,当用户滚动的距离足够大(大于 15px)和持续时间足够短(小于 300ms)时,即可产生惯性滚动。换成编程语言就是,最后一次 touchmove 事件触发的时间和 touchend 事件触发的时间间隔小于 300ms,且两者产生的距离差大于 15px 时认为可启动惯性滚动。

  • 暂停时机

    当惯性滚动未结束(包括处于回弹过程),用户再次触碰滚动元素时,我们应该暂停元素的滚动。在实现原理上,我们需要通过 getComputedStylegetPropertyValue 方法获取当前的 transform: matrix() 矩阵值,抽离出元素的水平 y 轴偏移量后重新调整 translate 的位置。

示例代码

基于 vuejs 提供了部分关键代码,也可以直接访问 codepen demo 体验效果(完整代码)。

<html>
  <body>
    <div id="app"></div>
    <template id="tpl">
      <div
        ref="wrapper"
        @touchstart.prevent="onStart"
        @touchmove.prevent="onMove"
        @touchend.prevent="onEnd"
        @touchcancel.prevent="onEnd"
        @transitionend="onTransitionEnd">
        <ul ref="scroller" :style="scrollerStyle">
          <li v-for="item in list">{{item}}</li>
        </ul>
      </div>
    </template>
    <script>
      new Vue({
        el: '#app',
        template: '#tpl',
        computed: {
          list() {},
          scrollerStyle() {
            return {
              'transform': `translate3d(0, ${this.offsetY}px, 0)`,
              'transition-duration': `${this.duration}ms`,
              'transition-timing-function': this.bezier,
            };
          },
        },
        data() {
          return {
            minY: 0,
            maxY: 0,
            wrapperHeight: 0,
            duration: 0,
            bezier: 'linear',
            pointY: 0,                    // touchStart 手势 y 坐标
            startY: 0,                    // touchStart 元素 y 偏移值
            offsetY: 0,                   // 元素实时 y 偏移值
            startTime: 0,                 // 惯性滑动范围内的 startTime
            momentumStartY: 0,            // 惯性滑动范围内的 startY
            momentumTimeThreshold: 300,   // 惯性滑动的启动 时间阈值
            momentumYThreshold: 15,       // 惯性滑动的启动 距离阈值
            isStarted: false,             // start锁
          };
        },
        mounted() {
          this.$nextTick(() => {
            this.wrapperHeight = this.$refs.wrapper.getBoundingClientRect().height;
            this.minY = this.wrapperHeight - this.$refs.scroller.getBoundingClientRect().height;
          });
        },
        methods: {
          onStart(e) {
            const point = e.touches ? e.touches[0] : e;
            this.isStarted = true;
            this.duration = 0;
            this.stop();
            this.pointY = point.pageY;
            this.momentumStartY = this.startY = this.offsetY;
            this.startTime = new Date().getTime();
          },
          onMove(e) {
            if (!this.isStarted) return;
            const point = e.touches ? e.touches[0] : e;
            const deltaY = point.pageY - this.pointY;
            this.offsetY = Math.round(this.startY + deltaY);
            const now = new Date().getTime();
            // 记录在触发惯性滑动条件下的偏移值和时间
            if (now - this.startTime > this.momentumTimeThreshold) {
              this.momentumStartY = this.offsetY;
              this.startTime = now;
            }
          },
          onEnd(e) {
            if (!this.isStarted) return;
            this.isStarted = false;
            if (this.isNeedReset()) return;
            const absDeltaY = Math.abs(this.offsetY - this.momentumStartY);
            const duration = new Date().getTime() - this.startTime;
            // 启动惯性滑动
            if (duration < this.momentumTimeThreshold && absDeltaY > this.momentumYThreshold) {
              const momentum = this.momentum(this.offsetY, this.momentumStartY, duration);
              this.offsetY = Math.round(momentum.destination);
              this.duration = momentum.duration;
              this.bezier = momentum.bezier;
            }
          },
          onTransitionEnd() {
            this.isNeedReset();
          },
          momentum(current, start, duration) {
            const durationMap = {
              'noBounce': 2500,
              'weekBounce': 800,
              'strongBounce': 400,
            };
            const bezierMap = {
              'noBounce': 'cubic-bezier(.17, .89, .45, 1)',
              'weekBounce': 'cubic-bezier(.25, .46, .45, .94)',
              'strongBounce': 'cubic-bezier(.25, .46, .45, .94)',
            };
            let type = 'noBounce';
            // 惯性滑动加速度
            const deceleration = 0.003;
            // 回弹阻力
            const bounceRate = 10;
            // 强弱回弹的分割值
            const bounceThreshold = 300;
            // 回弹的最大限度
            const maxOverflowY = this.wrapperHeight / 6;
            let overflowY;

            const distance = current - start;
            const speed = 2 * Math.abs(distance) / duration;
            let destination = current + speed / deceleration * (distance < 0 ? -1 : 1);
            if (destination < this.minY) {
              overflowY = this.minY - destination;
              type = overflowY > bounceThreshold ? 'strongBounce' : 'weekBounce';
              destination = Math.max(this.minY - maxOverflowY, this.minY - overflowY / bounceRate);
            } else if (destination > this.maxY) {
              overflowY = destination - this.maxY;
              type = overflowY > bounceThreshold ? 'strongBounce' : 'weekBounce';
              destination = Math.min(this.maxY + maxOverflowY, this.maxY + overflowY / bounceRate);
            }

            return {
              destination,
              duration: durationMap[type],
              bezier: bezierMap[type],
            };
          },
          // 超出边界时需要重置位置
          isNeedReset() {
            let offsetY;
            if (this.offsetY < this.minY) {
              offsetY = this.minY;
            } else if (this.offsetY > this.maxY) {
              offsetY = this.maxY;
            }
            if (typeof offsetY !== 'undefined') {
              this.offsetY = offsetY;
              this.duration = 500;
              this.bezier = 'cubic-bezier(.165, .84, .44, 1)';
              return true;
            }
            return false;
          },
          // 停止滚动
          stop() {
            const matrix = window.getComputedStyle(this.$refs.scroller).getPropertyValue('transform');
            this.offsetY = Math.round(+matrix.split(')')[0].split(', ')[5]);
          },
        },
      });
    </script>
  </body>
</html>

参考资料


欢迎关注凹凸实验室博客:aotu.io

或者关注凹凸实验室公众号(AOTULabs),不定时推送文章:

欢迎关注凹凸实验室公众号

查看原文

赞 31 收藏 12 评论 1

Denzel 发布了文章 · 8月18日

2020年,是时候进阶一下Babel了

20200816121459

本文为这个系列的第二篇,上一篇见:Babel 入门指引?

本文将围绕顶部的图剖析,旨在让你更了解Babel 编译的四大助手和区别:

有力的开场白

20200816125414
在@babel/preset-env文档的开头,很隐晦的说了这样一个知识点,中文详细解释就是:只转换新的 JavaScript 句法(syntax),比如let、const、asyncawait、箭头函数、...、管道运算符等,而不转换新的 API,比如 Set、Map、Proxy、Reflect、Symbol、Promise 等全局对象,以及一些定义在全局对象上的方法(比如 Object.assign,array.flat ),举个🌰:

// 1:新的语法const,export, async,箭头函数与? 管道预算符
export default async (input, arr) => {
  const _in = input?.name;
  // 2:新的API 和 静态方法
  const map = new Map();
  map.set('exp', 'example');

  const mapArr = Array.from(map);
  // 3:新的实例方法
  const _arr = arr.flat();
  const val = await new Promise((res) => {
    setTimeout(() => {
      res({
        name: _in,
        arr: _arr
      });
    }, 100);
  });
  return val;
};

export class Test {
  constructor() {
    this.name = 'test';
  }
  method() {
    console.log('name', this.test);
  }
}

加个配置:

{
  "presets": [
    "@babel/preset-env",
  ],
}

执行, 得到的转换结果:

"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.Test = exports["default"] = void 0;

function _classCallCheck(instance, Constructor) {
  // 省略具体实现...
}
function _defineProperties(target, props) {
  // 省略具体实现...
}
function _createClass(Constructor, protoProps, staticProps) {
  // 省略具体实现...
}
function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) {
  // 省略具体实现...
}
function _asyncToGenerator(fn) {
  // 省略具体实现...
}

var _default = /*#__PURE__*/function () {
  var _ref = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee(input, arr) {
    var _in, _arr, map, mapArr, val;

    return regeneratorRuntime.wrap(function _callee$(_context) {
      while (1) {
        switch (_context.prev = _context.next) {
          case 0:
            _in = input === null || input === void 0 ? void 0 : input.name;
            _arr = arr.flat();
            map = new Map();
            mapArr = Array.from(map);
            map.set('exp', 'example');
            _context.next = 6;
            return new Promise(function (res) {
              setTimeout(function () {
                res({
                  name: _in,
                  arr: _arr
                });
              }, 100);
            });

          case 6:
            val = _context.sent;
            return _context.abrupt("return", val);

          case 8:
          case "end":
            return _context.stop();
        }
      }
    }, _callee);
  }));

  return function (_x, _x2) {
    return _ref.apply(this, arguments);
  };
}();

exports["default"] = _default;

var Test = /*#__PURE__*/function () {
  function Test() {
    _classCallCheck(this, Test);

    this.name = 'test';
  }

  _createClass(Test, [{
    key: "method",
    value: function method() {
      console.log('name', this.test);
    }
  }]);

  return Test;
}();

exports.Test = Test;

看了结果就会明白,什么叫仅对语法做转换。因为上面的PromiseMaparr.flat() 都保持了原样,未做兼容。接下来,我们来搞懂为什么。

细说插件Plugins

在第一篇已经讲过,插件是我们依赖Babel做项目打包时极其重要的东西。从使用上来讲,我个人将插件归为三类:

  • 是ES 标准,但浏览器还未全部实现的,这占大头,比如preset-env中包含的插件;
  • 已处于提议阶段,暂未成为ES标准,比如装饰器语法:@babel/plugin-proposal-decorators;
  • 工具类语法,便于开发;比如@babel/preset-react系列插件;

preset-env

几乎我们每个利用Babel编译的项目,都要用到这个预设(preset),其使用方式就像我们开场一样。这个预设包含了所有es6+ 语法插件,我数了数大概有50来个。但是不是每个编译,这些插件都会被用上,这取决于你对这个预设的配置。

就像上面那样直接使用,其传达的信息是兼容所有es6+语法,所有的插件都会被用上。所以如你看到的,例子中的所有ES6+ 语法都被做了转换,但仅仅是语法

如果你的目标是只需要兼容Chrome最近的5个版本,你可以这样配置:

["@babel/preset-env", { 
    "targets": {
      "browsers": "last 5 chrome versions"
    },
}]

再执行一下,你会发现编译输出基本和源文件一致,因为Chrome 对新的ES6语法响应极快。

因为我们上面反复提到过,preset-env只会对语法做兼容,所以其转换后的代码,并不是完全的es5语法,所以为了更好的兼容IE浏览器,在以前我们需要借助@babel/polyfill 来实现ES6+ 中新的API 及其 全局对象上的方法。

@babel/polyfill

要使用polyfill,其实是一件非常容易的事,比如在你的入口文件:

// index.js
import "@babel/polyfill";

但这种方式在7.4.0以后的版本,不再被官方提倡,取而代之的是:

import "core-js/stable";
import "regenerator-runtime/runtime";

以上这都是官方给的使用示例,我自己并没有这样做,后面会细说。

接着来聊聊polyfill中的具体实现(更准确的说是corejs 中的实现), 以core-js/fn/array/includes为例:

// _array-includes.js
var toIObject = require('./_to-iobject');
var toLength = require('./_to-length');
var toAbsoluteIndex = require('./_to-absolute-index');
module.exports = function (IS_INCLUDES) {
  return function ($this, el, fromIndex) {
    var O = toIObject($this);
    var length = toLength(O.length);
    var index = toAbsoluteIndex(fromIndex, length);
    var value;
    // Array#includes uses SameValueZero equality algorithm
    // eslint-disable-next-line no-self-compare
    if (IS_INCLUDES && el != el) while (length > index) {
      value = O[index++];
      // eslint-disable-next-line no-self-compare
      if (value != value) return true;
    // Array#indexOf ignores holes, Array#includes - not
    } else for (;length > index; index++) if (IS_INCLUDES || index in O) {
      if (O[index] === el) return IS_INCLUDES || index || 0;
    } return !IS_INCLUDES && -1;
  };
};

// add to prototype
var $export = require('./_export');
var $includes = require('./_array-includes')(true);

$export($export.P, 'Array', {
  includes: function includes(el /* , fromIndex = 0 */) {
    return $includes(this, el, arguments.length > 1 ? arguments[1] : undefined);
  }
});

简单来讲,就是以es6 以前的语法来实现includes这个实例方法,并将其添加到Array.prototype原型上。但其具体实现比我说的更严谨一些,可以自行去看源码。

除了直接在入口文件导入,还可以通过配合preset-env的useBuiltIns属性,其默认值为false,即不处理API 和 方法,要使用polyfill,需要将其设置为:

  • entry: 全量导入, 即所有API 和 方法,无论项目中是否用到;
  • usage: 按需导入,仅项目中是否用到的,polyfill文件不需要在入口手动注入,会自动注入,然后你会发现构建后的文件头部多了类似下面的代码:

    require("core-js/modules/es6.array.iterator");
    
    require("core-js/modules/es6.object.to-string");
    
    require("core-js/modules/es6.string.iterator");
    
    require("core-js/modules/es6.map");
    
    require("core-js/modules/es6.function.name");
    
    require("regenerator-runtime/runtime");
    
    // ...

transform-runtime

如果你的打包场景是组件库,你会发现,在你构建后的每个js文件都存在下面两个问题:

1.辅助函数,每个文件中都有相同的实现,造成项目体积变大;

// ...
function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) {
  // 省略具体实现...
}
function _asyncToGenerator(fn) {
  // 省略具体实现...
}

2.polyfill 的引入文件,会污染全局变量

// ...
require("regenerator-runtime/runtime");
// ...

作为一个组件库,前面说过,引入polyfill会直接在其全局对象上添加ES6+ 新增的静态方法和示例,是带有侵入性的。如果使用这个组件库的人不知情,且某个hack方法和浏览器的实现有差别,那就会带来一些让使用者非常头疼的bug, 这种锅谁背谁脸黑。

那针对上面两点,有没有好的解决方法?

有,@babel/plugin-transform-runtime,其官方文档是这样介绍的:

一个可重用Babel注入的帮助程序代码以节省代码大小的插件。

但需要记住的是@babel/plugin-transform-runtime只是一个插件(或者叫媒介),其并不包含复用的函数和代码,复用代码的其具体实现是存在于@babel/runtime(-corejsx), 使用哪个代码包随着插件配置属性corejs的值变化而变化,其对应关系:

  • false: @babel/runtime
  • 2:@babel/runtime-corejs2
  • 3: @babel/runtime-corejs3

当引入@babel/plugin-transform-runtime,并将配置改成下面这样:

{
  "presets": [
    "@babel/preset-env",
  ],
  "plugins": [
    ["@babel/plugin-transform-runtime", {
      "corejs": false
    }],
  ]
}

我们将惊喜的看到,函数复用的功能实现了,其代码编程了下面这样:

var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.Test = exports["default"] = void 0;

var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));

var _createClass2 = _interopRequireDefault(require("@babel/runtime/helpers/createClass"));

var _regenerator = _interopRequireDefault(require("@babel/runtime/regenerator"));

var _asyncToGenerator2 = _interopRequireDefault(require("@babel/runtime/helpers/asyncToGenerator"));

// 下面代码与最开始一致

可以很明显的看出,其只是将函数的具体实现变成了模块引入,这样就很容易的完成了复用。那corejs为2和3 带来的意义呢?

设置corejs: 2:除了false包含的功能,还包含了对新增API 和 全局静态方法(Object.assign, Array.from等)的polyfill

var _from = _interopRequireDefault(require("@babel/runtime-corejs2/core-js/array/from"));
var _map = _interopRequireDefault(require("@babel/runtime-corejs2/core-js/map"));

// ...
_arr = arr.flat();
map = new _map["default"]();
mapArr = (0, _from["default"])(map);
map.set('exp', 'example');

设置corejs: 3:除了2包含了的代码,其增加了对实例方法的polyfill

var _flat = _interopRequireDefault(require("@babel/runtime-corejs3/core-js-stable/instance/flat"));

// ...
_arr = (0, _flat["default"])(arr).call(arr);
map = new _map["default"]();

到这,你应该就看明白了runtime、runtime-corejs2、runtime-corejs3 三个选项的区别;同时也解决了最前面提到的两个问题,相比polyfill,对于组件库这种实现确实更加灵活,而且不会影响外部应用代码实现。

@babel/plugin-transform-runtime除了corejs这个选项,还包含其他的一些属性,点击这里在官网查看更多,可以自己拷贝代码,运行一下加深印象。

IE 兼容最佳实践

前面我们提到过两种兼容到ES5 语法的方式:

  • polyfill 配合useBuiltIns,这种方式侧重于web应用的构建;
  • transform-runtime 配合corejs,这种方式侧重于组件方法类库的开发

哪正对应大多数场景,polyfill 配合useBuiltIns 是否就是最优解呢?

答案:否,这里给个链接:2020 如何优雅的兼容 IE

至此,本文卒!!!!!!

公众号:前端黑洞

查看原文

赞 8 收藏 4 评论 1

Denzel 发布了文章 · 8月17日

Babel 入门指引

20200810223955

译文:Babel.js Guide -Part 1- The Absolute Must-Know Basics: Plugins, Presets, And Config

本文将讲述:
  • Babel 是什么以及怎么在日常开发中使用它?
  • presets and plugins 是什么及区别,在babel执行中,他们的执行顺序是什么?

虽然原文标题看似是一个系列,但作者似乎没有继续,但我已经想好了下一部分要写的内容;非专业翻译,夹带自己理解,有小改动。

进阶篇:2020年,是时候进阶一下Babel了

Babel 是什么

babel 是一个免费开源的JavaScript 编译库. 它根据你的配置将代码转化成各式各样的JS代码。

最常见的使用方式就是将现代语法JavaScript es6+编写的代码 转化成 es5,从而兼容更多的浏览器(特别是IE),下面以Babel 转换es6 箭头函数 为 es5 函数的为例。

// The original code
const foo = () => console.log('hello world!');

转移后

// The code after babel transpilation
var foo = function foo() {
  return console.log('hello world!');
};

你可以在这里在线尝试

使用Babel

在线Repl

这是使用Babel的最简单方法。这也许不是一个非常实用的工具,但是是最快的测试或者实验Babel如何工作的工具, 在线Repl地址

构建库

使用Babel的最流行方法是使用WebpackGulpRollup等构建库进行打包构建。每个方式都使用Babel作为构建的一部分来实现自己的构建过程。

比如,我们最常用的webpack:

{
  ...
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: 'babel-loader',
          options: {
            plugins: ['@babel/plugin-transform-arrow-functions']
          }
        }
      }
    ]
  },
  ...
}

库常用的构建工具:Rollup

rollup.rollup({
  ...,
  plugins: [
    ...,
    babel({
      plugins: ['@babel/plugin-transform-arrow-functions']
    }),
    ...
  ],
  ...
}).then(...)

脚手架cli

除了依赖构建库,也可以直接用命令行依赖官方提供的@babel/cli 包来编译NIIT的代码:

# install the core of babel and it's cli interface
npm install @babel/core @babel/cli

# use the babel command line
babel script.js --out-file script-compiled.js

Babel Plugins

Babel是通过插件配置的。开箱即用,Babel不会更改您的代码。没有插件,它基本上是这样的:

// parse -> generate, 大白话就是英翻中,中翻英
const babel = code => code;

通过Babel插件运行代码,您的代码将转换为新代码,如下所示:

// parse -> do something -> generate, 大白话就是英翻中,添油加醋,中翻英
const babel = code => babelPlugin2(babelPlugin1(code));

Babel Presets

您可以单独添加Babel插件列表,但是通常更方便的方法是使用Babel presets。

Babel presets结合一系列插件的集合。传递给presets的选项会影响其聚合的插件, 这些选项将控制使用哪些插件以及这些插件的配置。

比如,我们前面看到的@babelplugin-transform-arrow-functions插件是@babel/preset-env presets的一部分。

@babel/preset-env可能是最受欢迎的presets。 它根据用户传递给预设的配置(比如browsers:目标浏览器/环境), 将现代JavaScript(即ES Next)转换为较旧的JavaScript版本。

比如:它可以将()=> arrowFunctions,{…detructuring} 和class {}转换为旧版浏览器支持的JavaScript语法,举个🌰, 目标浏览器为IE11:

// 新版语法
class SomeClass {
  constructor(config){
    this.someFunction = params => {
      console.log('hello world!', {...config, ...params});
    }
  }
  someMethod(methodParams){
    this.someFunction(methodParams);
  }
  someOtherMethod(){
    console.log('hello some other world');
  }
}

编译后:

// explained here: https://www.w3schools.com/js/js_strict.asp
"use strict";
// this is a babel helper function injected by babel to mimic a {...destructuring} syntax
function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; var ownKeys = Object.keys(source); if (typeof Object.getOwnPropertySymbols === 'function') { ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function (sym) { return Object.getOwnPropertyDescriptor(source, sym).enumerable; })); } ownKeys.forEach(function (key) { _defineProperty(target, key, source[key]); }); } return target; }
// this is a babel helper function injected by babel for a faster-than-native property defining on an object
// very advanced info can be found here:
// https://github.com/babel/babel/blob/3aaafae053fa75febb3aa45d45b6f00646e30ba4/packages/babel-helpers/src/helpers.js#L348
function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }

// this is a babel helper function that makes sure a class is called with the "new" keyword like "new SomeClass({})" and not like "SomeClass({})"
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }

// like "_defineProperty" above, but for multiple props
function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }

// used by babel to create a class with class functions 
function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }

var SomeClass =
/*#__PURE__*/ // this marks the function as pure. check https://webpack.js.org/guides/tree-shaking/ for more info.
function () {
  // the class got converted to a function
  function SomeClass(config) {
    // make sure a class is called with the "new" keyword
    _classCallCheck(this, SomeClass);
    // someFunction is set on SomeClass 
    this.someFunction = function (params) {   
      // notice how the {...config, ...params} became _objectSpread({}, config, params) here
      console.log('hello world!', _objectSpread({}, config, params));
    };
  }
  // this function adds the class methods to the transpiled class created above
  _createClass(SomeClass, [
    {
      key: "someMethod",
      value: function someMethod(methodParams) {
        this.someFunction(methodParams);
      }
    },
    {
      key: "someOtherMethod",
      value: function someOtherMethod() {
        console.log('hello some other world');
      }
    }
  ]);
  return SomeClass;
}();

对于更复杂的构建要求,配置将使用项目根目录中的babel.config.js文件。由于是JavaScript文件,因此比.babelrc更加灵活。例如:

module.exports = function (api) {
  
  // Only execute this file once and cache the resulted config object below for the next babel uses.
  // more info about the babel config caching can be found here: https://babeljs.io/docs/en/config-files#apicache
  api.cache.using(() => process.env.NODE_ENV === "development")
  return {
    presets: [
      // Use the preset-env babel plugins
      '@babel/preset-env'
    ],
    plugins: [
      // Besides the presets, use this plugin
      '@babel/plugin-proposal-class-properties'
    ]
  }
}

@babel/preset-env不同配置,编译出的代码可能大不一样,比如当配置为:latest 10 Chrome versions是,上面一段代码编译结果与编译前一致,因为上面的特性,chrrome都支持;但如果将10调整为30,40时,你会发现,编译的代码将会越来越多;可以点击这里尝试一下

配置

Babel Plugins and Presets 是非常重要的概念,Babel的配置由Plugins and Presets组合而成(也可以使用其他几个高级属性);

简单的配置,可以直接使用.babelrc,babelrc是一种JSON5文件(和JSON一样,但其允许注释),被放置在项目根目录下,比如下面这样:

// Comments are allowed as opposed to regular JSON files
{
  presets: [
    // Use the preset-env babel plugins
    '@babel/preset-env'
  ],
  plugins: [
    // Besides the presets, use this plugin
    '@babel/plugin-proposal-class-properties'
  ]
}

对于更复杂的配置,一般使用babel.config.js文件来代替.babelrc文件,因为他是js文件,所以比.babelrc配置更灵活,举个例子:

module.exports = function (api) {
  // Only execute this file once and cache the resulted config object below for the next babel uses.
  // more info about the babel config caching can be found here: https://babeljs.io/docs/en/config-files#apicache
  api.cache.using(() => process.env.NODE_ENV === "development")
  return {
    presets: [
      // Use the preset-env babel plugins
      '@babel/preset-env'
    ],
    plugins: [
      // Besides the presets, use this plugin
      '@babel/plugin-proposal-class-properties'
    ]
  }
  
}

一些配置文件可能非常复杂, 例如: Babel项目本身的babel.config.js。莫慌!阅读本指南系列后,您将知道此复杂配置的每一行的意义(看,有头牛在天上飞)。

Babel Plugins and Presets 执行顺序

如果您在配置中混合使用了Plugins和Presets,Babel将按以下顺序应用它们:

  • 首先从上到下应用插件;
  • 然后,将预设应用在插件之后,从下到上;

举个🌰:

{
  presets: [
    '@babel/preset-5', //   ↑    ** End Here ** Last preset to apply it's plugins *after* all the plugins below finished to run 
    '@babel/preset-4', //   ↑
    '@babel/preset-3', //   ↑    2
    '@babel/preset-2', //   ↑
    '@babel/preset-1', //   ↑    First preset to apply it's plugins *after* all the plugins below finished to run 
  ],
  plugins: [
    '@babel/plugin-1', //   ↓    >>> Start Here <<< First plugin to transpile the code.
    '@babel/plugin-2', //   ↓  
    '@babel/plugin-3', //   ↓    1
    '@babel/plugin-4', //   ↓  
    '@babel/plugin-5', //   ↓    Last plugin to transpile the code before the preset plugins are applied
  ]
}

另外,值得一提的是,每个Presets中的插件也自上而下应用。

以正确的顺序配置Plugins和Presets非常重要! 正确的顺序可能会加快翻译速度,错误的顺序可能会产生不需要的结果或者导致错误。

可能上面的🌰不够真实,那就来个真实的吧:

{
  "presets": [
    "@babel/preset-env",
    "@babel/preset-react",
  ],
  "plugins": [
    ["@babel/plugin-proposal-decorators", { "legacy": true }],
  ]
}

decorators 是装饰器语法,现在还处于stage-3阶段难产,而JSX则是React的专有语法;如果没有@babel/plugin-proposal-decorators@babel/preset-react 先编译,直接运行@babel/preset-env编译,就会报@<div> 无效的语法标识,所有正确的配置插件和插件顺序是多么重要。

Babel Plugins and Presets 选项

上面提到过给@babel/preset-env设置不同的browsers选项,会得到不同的编译结果;通过将选项包装在数组中并向其中添加选项,可以将选项配置传递给Babel Plugins and Presets,比如位于@babel/preset-env后面的对象就是一个选项配置,告诉编译的目标是兼容到chrome 58版本和IE11:

{
  presets: [
    // Notice how @babel/preset-env is wrapped in an array with an options object
    ['@babel/preset-env', {
      "targets": {
        "chrome": "58",
        "ie": "11"
      }
    }],
    '@babel/some-other-preset'
  ]
}

@babel/preset-env基本是项目编译必选的Presets,除了targets,还有useBuiltIns,esmodules,modules等常见选项,还有更多关于配置可参考官网

最后

关于指引,就这么多,将在不久后推出进阶篇, 关于babel-runtime与babel-polyfill。

进阶篇:2020年,是时候进阶一下Babel了

公众号:前端黑洞

查看原文

赞 7 收藏 6 评论 0

Denzel 赞了文章 · 8月10日

一年Node.js开发开发经验总结

本文首发于公众号:符合预期的CoyPan

写在前面

不知不觉的,写Node.js已经一年了。不同于最开始的demo、本地工具等,这一年里,都是用Node.js写的线上业务。从一开始的Node.js同构直出,到最近的Node接入层,也算是对Node开发入门了吧。目前,我一个人维护了大部分组内流传下来的Node服务,包括内部系统和线上服务。新增的后台服务,也是尽可能地使用Node进行开发。本文是一下自己最近的一些小小的总结和思考。

本文不会深入讲解Node.js本身的特性,架构等等。我也没有写过Node扩展或者库什么的,对Node.js的了解也并不够深入。

为何用Node

对于我来说,对于团队来说,适用Node的原因其实很简单:开发起来快。熟悉JS的前端同学可以很快上手,节省成本。选一个http server库起一个server,选择合适的中间件,匹配好请求路由,看情况合理使用ORM库链接数据库、增删改查即可。

Node的适用场景

Node.js 使用了一个事件驱动、非阻塞式 I/O 的模型,使其轻量又高效。这种模型使得Node.js 可以避免了由于需要等待输入或者输出(数据库、文件系统、Web服务器...)响应而造成的 CPU 时间损失。所以,Node.js适合运用在高并发、I/O密集、少量业务逻辑的场景。

对应到平时具体的业务上,如果是内部的系统,大部分仅仅就是需要对某个数据库进行增删改查,那么Server端直接就是Node.js一把梭。

对于线上业务,如果流量不大,并且业务逻辑简单的情况下,Server端也可以完全使用Node.js。对于流量巨大,复杂度高的项目,一般用Node.js作为接入层,后台同学负责实现服务。如下图:

同样是写JS,Node.js开发和页面开发有什么区别

在浏览器端开发页面,是和用户打交道、重交互,浏览器还提供了各种Web Api供我们使用。Node.js主要面向数据,收到请求后,返回具体的数据。这是两者在业务路径上的区别。而真正的区别其实是在于业务模型上(业务模型,这是我自己瞎想的一个词)。直接用图表示吧。

开发页面时,每一个用户的浏览器上都有一份JS代码。如果代码在某种情况下崩了,只会对当前用户产生影响,并不会影响其他用户,用户刷新一下即可恢复。而在Node.js中,在不开启多进程的情况下,所有用户的请求,都会走进同一份JS代码,并且只有一个线程在执行这份JS代码。如果某个用户的请求,导致发生错误,Node.js进程挂掉,server端直接就挂了。尽管可能有进程守护,挂掉的进程会被重启,但是在用户请求量大的情况下,错误会被频繁触发,可能就会出现server端不停挂掉,不停重启的情况,对用户体验造成影响。

以上,可能是Node.js开发和前端JS开发最大的区别。

Node.js开发时的注意事项

用户在访问Node.js服务时,如果某一个请求卡住了,服务迟迟不能返回结果,或者说逻辑出错,导致服务挂掉,都会带来大规模的体验问题。server端的目标,就是要 快速、可靠 地返回数据。

缓存

由于Node.js不擅长处理复杂逻辑(JavaScript本身执行效率较低),如果要用Node.js做接入层,应该避免复杂的逻辑。想要快速处理数据并返回,一个至关重要的点:使用缓存。

例如,使用Node做React同构直出,renderToString这个Api,可以说是比较重的逻辑了。如果页面的复杂度高,每次请求都完整执行renderToString,会长时间占用线程来执行代码,增加响应时间,降低服务的吞吐量。这个时候,缓存就十分重要了。

实现缓存的主要方式:内存缓存。可以使用Map,WeakMap,WeakRef等实现。参考以下简单的示例代码:

const cache = new Map();

router.get('/getContent', async (req, res) => {
  const id = req.query.id;
  
  // 命中缓存
  if(cache.get(id)) {
    return res.send(cache.get(id));
  }
  
  // 请求数据
  const rsp = await rpc.get(id);
     // 经过一顿复杂的操作,处理数据
  const content = process(rsp);
  // 设置缓存
  cache.set(id, content);
  
  return res.send(content);
});

使用缓存时,有一个很重要的问题是:内存缓存如何更新。一种最简单的方法,开一个定时器,定期删除缓存,下一次请求到来时,重新设置缓存即可。在上述代码中,增加如下代码:

setTimeout(function() {
  cache.clear();
}, 1000 * 60); // 1分钟删除一次缓存

如果server端完全使用Node实现,需要用Node端直接连接数据库,在数据时效性要求不太高、且流量不太大的情况下,就可以使用上述类似的模型,如下图。这样可以降低数据库的压力且加快Node的响应速度。

另外,还需要注意内存缓存的大小。如果一直往缓存里写入新数据,那么内存会越来越大,最终爆掉。可以考虑使用LRU(Least Recently Used)算法来做缓存。开辟一块内存专门作为缓存区域。当缓存大小达到上限时,淘汰最久未使用的缓存。

内存缓存会随着进程的重启而全部失效。

当后台业务比较复杂,接入层流量,数据量较大时,可以使用如下的架构,使用独立的内存缓存服务。Node接入层直接从缓存服务取数据,后台服务直接更新缓存服务。

当然,上图中的架构是最简单的情形,现实中还需要考虑分布式缓存、缓存一致性的问题。这又是另外一个话题了。

错误处理

由于Node.js语言的特性,Node服务是比较容易出错的。而一旦出错,造成的影响就是服务不可用。因此,对于错误的处理十分的重要。

处理错误,最常用的就是try catch 了。可是 try catch无法捕获异步错误。Node.js中,异步操作是十分常见的,异步操作主要是在回调函数中暴露错误。看一个例子:

const readFile = function(path) {
    return new Promise((resolve,reject) => {
        fs.readFile(path, (err, data) => {
            if(err) { 
                throw err; // catch无法捕获错误,这和Node的eventloop有关。
        // reject(err); // catch可以捕获
      }
      resolve(data);
        });
    });
}

router.get('/xxx', async function(req, res) {
  try {
    const res = await readFile('xxx');
    ...
  } catch (e){
    // 捕获错误处理
    ...
    res.send(500);
  }
});

上面的代码中,readFile 中 throw 出来的错误,是无法被catch捕获的。如果我们把 throw err 换成 Promise.reject(err),catch中是可以捕获到错误的。

我们可以把异步操作都Promise化,然后统一使用 async 、try、catch 来处理错误

但是,总会有地方会被遗漏。这个时候,可以使用process来捕获全局错误,防止进程直接退出,导致后面的请求挂掉。示例代码:

process.on('uncaughtException', (err) => {
  console.error(`${err.message}\n${err.stack}`);
});

process.on('unhandledRejection', (reason, p) => {
  console.error(`Unhandled Rejection at: Promise ${p} reason: `, reason);
});

关于Node.js中错误的捕获,还可以使用domain模块。现在这个模块已经不推荐使用了,我也没有在项目中实践过,这里就不展开了。Node.js 近几年推出的 async_hooks 模块,也还处于实验阶段,不太建议线上环境直接使用。做好进程守护,开启多进程,错误告警及时修复,养成良好的编码规范,使用合适的框架,才能提高Node服务的效率及稳定性。

写在后面

本文总结了Node.js开发一年多以来的实践总结等。Node.js的开发与前端网页的开发思路不同,着重点不一样。我正式开发Node.js的时间也不算太长,一些点并没有深入的理解,本文仅仅是一些经验之谈。欢迎交流。

查看原文

赞 19 收藏 13 评论 4

Denzel 发布了文章 · 7月22日

当我们在用Hooks时,我们到底在用什么?

开篇有奖

如果你最近一年出去面过试,很可能面临这些问题:

  • react 16到底做了哪些更新;
  • react hooks用过么,知道其原理么;

第一个问题如果你提到了Fiber reconciler,fiber,链表,新的什么周期,可能在面试官眼里这仅仅是一个及格的回答。以下是我整理的,自我感觉还良好的回答:

分三步:

  • react作为一个ui库,将前端编程由传统的命令式编程转变为声明式编程,即所谓的数据驱动视图,但如果简单粗暴的操作,比如讲生成的html直接采用innerHtml替换,会带来重绘重排之类的性能问题。为了尽量提高性能,React团队引入了虚拟dom,即采用js对象来描述dom树,通过对比前后两次的虚拟对象,来找到最小的dom操作(vdom diff),以此提高性能。
  • 上面提到的vDom diff,在react 16之前,这个过程我们称之为stack reconciler,它是一个递归的过程,在树很深的时候,单次diff时间过长会造成JS线程持续被占用,用户交互响应迟滞,页面渲染会出现明显的卡顿,这在现代前端是一个致命的问题。所以为了解决这种问题,react 团队对整个架构进行了调整,引入了fiber架构,将以前的stack reconciler替换为fiber reconciler。采用增量式渲染。引入了任务优先级(expiration)requestIdleCallback的循环调度算法,简单来说就是将以前的一根筋diff更新,首先拆分成两个阶段:reconciliationcommit;第一个reconciliation阶段是可打断的,被拆分成一个个的小任务(fiber),在每一侦的渲染空闲期做小任务diff。然后是commit阶段,这个阶段是不拆分且不能打断的,将diff节点的effectTag一口气更新到页面上。
  • 由于reconciliation是可以被打断的,且存在任务优先级的问题,所以会导致commit前的一些生命周期函数多次被执行, 如componentWillMount、componentWillReceiveProps 和 componetWillUpdate,但react官方已申明这些问题,并将其标记为unsafe,在React17中将会移除
  • 由于每次唤起更新是从根节点(RootFiber)开始,为了更好的节点复用与性能优化。在react中始终存workInprogressTree(future vdom) 与 oldTree(current vdom)两个链表,两个链表相互引用。这无形中又解决了另一个问题,当workInprogressTree生成报错时,这时也不会导致页面渲染崩溃,而只是更新失败,页面仍然还在。

以上就是我上半年面试自己不断总结迭代出的答案,希望能对你有所启发。

接着来回答第二个问题,hooks本质是什么?

hooks 为什么出现

当我们在谈论React这个UI库时,最先想到的是,数据驱动视图,简单来讲就是下面这个公式:

view = fn(state)

我们开发的整个应用,都是很多组件组合而成,这些组件是纯粹,不具备扩展的。因为React不能像普通类一样直接继承,从而达到功能扩展的目的。

出现前的逻辑复用

在用react实现业务时,我们复用一些组件逻辑去扩展另一个组件,最常见比如Connect,Form.create, Modal。这类组件通常是一个容器,容器内部封装了一些通用的功能(非视觉的占多数),容器里面的内容由被包装的组件自己定制,从而达到一定程度的逻辑复用。

在hooks 出现之前,解决这类需求最常用的就两种模式:HOC高阶组件Render Props

高阶组件类似于JS中的高阶函数,即输入一个函数,返回一个新的函数, 比如React-Redux中的Connect:

class Home extends React.Component {
  // UI
}

export default Connect()(Home);

高阶组件由于每次都会返回一个新的组件,对于react来说,这是不利于diff和状态复用的,所以高阶组件的包装不能在render 方法中进行,而只能像上面那样在组件声明时包裹,这样也就不利于动态传参。而Render Props模式的出现就完美解决了这个问题,其原理就是将要包裹的组件作为props属性传入,然后容器组件调用这个属性,并向其传参, 最常见的用props.children来做这个属性。举个🌰:

class Home extends React.Component {
  // UI
}

<Route path = "/home" render= {(props) => <Home {...props} } />

更多关于render 与 Hoc,可以参见以前写的一片弱文:React进阶,写中后台也能写出花

已存方案的问题

嵌套地狱

上面提到的高阶组件和RenderProps, 看似解决了逻辑复用的问题,但面对复杂需求时,即一个组件需要使用多个复用包裹时,两种方案都会让我们的代码陷入常见的嵌套地狱, 比如:

class Home extends React.Component {
  // UI
}

export default Connect()(Form.create()(Home));

除了嵌套地狱的写法让人困惑,但更致命的深度会直接影响react组件更新时的diff性能。

函数式编程的普及

Hooks 出现前的函数式组件只是以模板函数存在,而前面两种方案,某种程度都是依赖类组件来完成。而提到了类,就不得不想到下面这些痛点:

  • JS中的this是一个神仙级的存在, 是很多入门开发趟不过的坑;
  • 生命周期的复杂性,很多时候我们需要在多个生命周期同时编写同一个逻辑
  • 写法臃肿,什么constructor,super,render

所以React团队回归view = fn(state)的初心,希望函数式组件也能拥有状态管理的能力,让逻辑复用变得更简单,更纯粹。

架构的更新

为什么在React 16前,函数式组件不能拥有状态管理?其本质是因为16以前只有类组件在更新时存在实例,而16以后Fiber 架构的出现,让每一个节点都拥有对应的实例,也就拥有了保存状态的能力,下面会详讲。

hooks 的本质

有可能,你听到过Hooks的本质就是闭包。但是,如果满分100的话,这个说法最多只能得60分。

哪满分答案是什么呢?闭包 + 两级链表

下面就来一一分解, 下面都以useState来举例剖析。

闭包

JS 中闭包是难点,也是必考点,概括的讲就是:

闭包是指有权访问另一个函数作用域中变量或方法的函数,创建闭包的方式就是在一个函数内创建闭包函数,通过闭包函数访问这个函数的局部变量, 利用闭包可以突破作用链域的特性,将函数内部的变量和方法传递到外部。
export default function Hooks() {
  const [count, setCount] = useState(0);
  const [age, setAge] = useState(18);

  const self = useRef(0);

  const onClick = useCallback(() => {
    setAge(19);
    setAge(20);
    setAge(21);
  }, []);

  console.log('self', self.current);
  return (
    <div>
      <h2>年龄: {age} <a onClick={onClick}>增加</a></h2>
      <h3>轮次: {count} <a onClick={() => setCount(count => count + 1)}>增加</a></h3>
    </div>
  );
}

以上面的示例来讲,闭包就是setAge这个函数,何以见得呢,看组件挂载阶段hook执行的源码:

// packages/react-reconciler/src/ReactFiberHooks.js
function mountReducer(reducer, initialArg, init) {
  const hook = mountWorkInProgressHook();
  let initialState;
  if (init !== undefined) {
    initialState = init(initialArg);
  } else {
    initialState = initialArg;
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue = (hook.queue = {
    last: null,
    dispatch: null,
    lastRenderedReducer: reducer,
    lastRenderedState: initialState,
  });
  // 重点
  const dispatch = (queue.dispatch = (dispatchAction.bind(
    null,
    currentlyRenderingFiber,
    queue,
  )));
  return [hook.memoizedState, dispatch];
}

所以这个函数就是mountReducer,而产生的闭包就是dispatch函数(对应上面的setAge),被闭包引用的变量就是currentlyRenderingFiberqueue

  • currentlyRenderingFiber: 其实就是workInProgressTree, 即更新时链表当前正在遍历的fiber节点(源码注释:The work-in-progress fiber. I've named it differently to distinguish it from the work-in-progress hook);
  • queue: 指向hook.queue,保存当前hook操作相关的reducer 和 状态的对象,其来源于mountWorkInProgressHook这个函数,下面重点讲;

这个闭包将 fiber节点与action, action 与 state很好的串联起来了,举上面的例子就是:

  • 当点击增加执行setAge, 执行后,新的state更新任务就储存在fiber节点的hook.queue上,并触发更新;
  • 当节点更新时,会遍历queue上的state任务链表,计算最终的state,并进行渲染;

ok,到这,闭包就讲完了。

第一个链表:hooks

在ReactFiberHooks文件开头声明currentHook变量的源码有这样一段注释。

/*
Hooks are stored as a linked list on the fiber's memoizedState field.  
hooks 以链表的形式存储在fiber节点的memoizedState属性上
The current hook list is the list that belongs to the current fiber.
当前的hook链表就是当前正在遍历的fiber节点上的
The work-in-progress hook list is a new list that will be added to the work-in-progress fiber.
work-in-progress hook 就是即将被添加到正在遍历fiber节点的hooks新链表
*/
let currentHook: Hook | null = null;
let nextCurrentHook: Hook | null = null;

从上面的源码注释可以看出hooks链表与fiber链表是极其相似的;也得知hooks 链表是保存在fiber节点的memoizedState属性的, 而赋值是在renderWithHooks函数具体实现的;

export function renderWithHooks(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  props: any,
  refOrContext: any,
  nextRenderExpirationTime: ExpirationTime,
): any {
  renderExpirationTime = nextRenderExpirationTime;
  currentlyRenderingFiber = workInProgress;
  // 获取当前节点的hooks 链表;
  nextCurrentHook = current !== null ? current.memoizedState : null;
  // ...省略一万行
}

有可能代码贴了这么多,你还没反应过来这个hooks 链表具体指什么?

其实就是指一个组件包含的hooks, 比如上面示例中的:

const [count, setCount] = useState(0);
const [age, setAge] = useState(18);
const self = useRef(0);
const onClick = useCallback(() => {
  setAge(19);
  setAge(20);
  setAge(21);
}, []);

形成的链表就是下面这样的:

20200717112830

所以在下一次更新时,再次执行hook,就会去获取当前运行节点的hooks链表;

const hook = updateWorkInProgressHook();
// updateWorkInProgressHook 就是一个纯链表的操作:指向下一个 hook节点

到这 hooks 链表是什么,应该就明白了;这时你可能会更明白,为什么hooks不能在循环,判断语句中调用,而只能在函数最外层使用,因为挂载或则更新时,这个队列需要是一致的,才能保证hooks的结果正确。

第二个链表:state

其实state 链表不是hooks独有的,类操作的setState也存在,正是由于这个链表存在,所以有一个经(sa)典(bi)React 面试题:

setState为什么默认是异步,什么时候是同步?

结合实例来看,当点击增加会执行三次setAge

const onClick = useCallback(() => {
  setAge(19);
  setAge(20);
  setAge(21);
}, []);

第一次执行完dispatch后,会形成一个状态待执行任务链表:
20200720111316

如果仔细观察,会发现这个链表还是一个(会在updateReducer后断开), 这一块设计相当有意思,我现在也还没搞明白为什么需要环,值得细品,而建立这个链表的逻辑就在dispatchAction函数中。

function dispatchAction(fiber, queue, action) {
  // 只贴了相关代码
  const update = {
    expirationTime,
    suspenseConfig,
    action,
    eagerReducer: null,
    eagerState: null,
    next: null,
  };
  // Append the update to the end of the list.
  const last = queue.last;
  if (last === null) {
    // This is the first update. Create a circular list.
    update.next = update;
  } else {
    const first = last.next;
    if (first !== null) {
      // Still circular.
      update.next = first;
    }
    last.next = update;
  }
  queue.last = update;

  // 触发更新
  scheduleWork(fiber, expirationTime);
}

上面已经说了,执行setAge 只是形成了状态待执行任务链表,真正得到最终状态,其实是在下一次更新(获取状态)时,即:

// 读取最新age
const [age, setAge] = useState(18);

而获取最新状态的相关代码逻辑存在于updateReducer中:

function updateReducer(reducer, initialArg,init?) {
  const hook = updateWorkInProgressHook();
  const queue = hook.queue;
  // ...隐藏一百行
  // 找出第一个未被执行的任务;
  let first;
  // baseUpdate 只有在updateReducer执行一次后才会有值
  if (baseUpdate !== null) {
    // 在baseUpdate有值后,会有一次解环的操作;
    if (last !== null) {
      last.next = null;
    }
    first = baseUpdate.next;
  } else {
    first = last !== null ? last.next : null;
  }

  if (first !== null) {
    let newState = baseState;
    let newBaseState = null;
    let newBaseUpdate = null;
    let prevUpdate = baseUpdate;
    let update = first;
    let didSkip = false;
    // do while 遍历待执行任务的状态链表
    do {
      const updateExpirationTime = update.expirationTime;
      if (updateExpirationTime < renderExpirationTime) {
        // 优先级不足,先标记,后面再更新
      } else {
        markRenderEventTimeAndConfig(
          updateExpirationTime,
          update.suspenseConfig,
        );

        // Process this update.
        if (update.eagerReducer === reducer) {
          // 简单的说就是状态已经计算过,那就直接用
          newState = update.eagerState;
        } else {
          const action = update.action;
          newState = reducer(newState, action);
        }
      }
      prevUpdate = update;
      update = update.next;
      // 终止条件是指针为空 或 环已遍历完
    } while (update !== null && update !== first);  
    // ...省略100行
    return [newState, dispatch];
  }
}

最后来看,状态更新的逻辑似乎是最绕的。但如果看过setState,这一块可能就比较容易。至此,第二个链表state就理清楚了。

读到这里,你就应该明白hooks 到底是怎么实现的:

闭包加两级链表

虽然我这里只站在useState这个hooks做了剖析,但其他hooks的实现基本类似。

另外分享一下在我眼中的hooks,与类组件到底到底是什么联系:

  • useState: 状态的存储及更新,状态更新会触发组件更新,和类的state类似,只不过setState更新时是采用Object.assign(oldstate, newstate); 而useState的set是直接替代式的
  • useEffect: 类似于以前的componentDidMount 和 componentDidUpdate生命周期钩子(即render 执行后,再执行Effect, 所以当组件与子组件都有Effect时,子组件的Effect先执行), Update需要deps依赖来唤起;
  • useRefs: 用法类似于以前直接挂在类的this上,像this.selfCount 这种,用于变量的临时存储,而又不至于受函数更新,而被重定义;与useState的区别就是,refs的更新不会导致Rerender
  • useMemo: 用法同以前的componentWillReceiveProps与getDerivedStateFromProps中,根据state和props计算出一个新的属性值:计算属性
  • useCallback: 类似于类组件中constructor的bind,但比bind更强大,避免回调函数每次render造成回调函数重复声明,进而造成不必要的diff;但需要注意deps,不然会掉进闭包的坑
  • useReducer: 和redux中的Reducer相像,和useState一样,执行后可以唤起Rerender

第一次写源码解析,出发点主要两点:

  • 最近半年自己在react确实下了一些功夫,有一个输出也是为了自己以后更好的回忆;
  • 网上太多的人用一个闭包来概括hooks,我觉得这是对技术的亵渎(个人意见);

文章中若有不详或不对之处,欢迎斧正;

推荐阅读: 源码解析React Hook构建过程:没有设计就是最好的设计

首发链接:当我们在用Hooks时,我们到底在用什么?

查看原文

赞 17 收藏 13 评论 5

Denzel 赞了文章 · 7月3日

在 Node 服务中发生 OOM 时,如何监控内存?

本文章已备份在 github 上 山月的博客 欢迎 star

刚开始,先抛出一个问题:

你知道你们生产环境的 Node 服务平时占用内存多少吗?或者说是多少量级?

山月在面试 Node 候选人时,这个问题足够筛掉一半的自称Node精通者,不过没有回答上来,我往往会再补充一个问题,以免漏掉优秀的无线上经验的候选人:

如何知道某个进程消耗多少内存?

当使用 Node 在生产环境作为服务器语言时,并发量过大或者代码问题造成 OOM (out of memory) 或者 CPU 满载这些都是服务器中常见的问题,此时通过监控 CPU 及内存,再结合日志及 Release 就很容易发现问题。

本章将介绍如何监控本地环境及生产环境的内存变化

一个 Node 应用实例

所以,如何动态监控一个 Node 进程的内存变化呢?

以下是一个 Node Server 的示例,并且是一个有内存泄漏问题的示例,并且是山月在生产环境定位了很久的问题的精简版。

那次内存泄漏问题中,导致单个容器中的内存从原先的 400M 暴涨到 700M,在 800M 的容器资源限制下偶尔会发生 OOM,导致重启。一时没有定位到问题 (发现问题过迟,半个月前的时序数据已被吞没,于是未定位到 Release),于是把资源限制上调到 1000M。后发现是由 ctx.request 挂载了数据库某个大字段而致
const Koa = require('koa')
const app = new Koa()

function getData () {
  return Array.from(Array(1000)).map(x => 10086)
}

app.use(async (ctx, next) => {
  ctx.data = getData()
  await next()
})

app.use(ctx => {
  ctx.body = 'hello, world'
})

app.listen(3200, () => console.log('Port: 3200'))

进程内存监控

一些问题需要在本地及测试环境得到及时扼杀,来避免在生产环境造成更大的影响。那么了解在本地如何监控内存就至关重要。

pidstatsysstat 系列 linux 性能调试工具的一个包,竟然用它来调试 linux 的性能问题,包括内存,网络,IO,CPU 等。

这不仅试用与 node,而且适用于一切进程,包括 pythonjava 以及 go

# -r: 指输出内存指标
# -p: 指定 pid
# 1: 每一秒输出一次
# 100: 输出100次
$ pidstat -r -p pid 1 100

而在使用 pidstat 之前,需要先找到进程的 pid

如何找到 Node 进程的 pid

node 中可以通过 process.pid 来找到进程的 pid

> process.pid
16425

虽然通过写代码可以找到 pid,但是具有侵入性,不太实用。那如何通过非侵入的手段找到 pid 呢?有两种办法

  1. 通过多余的参数结合 ps 定位进程
  2. 通过端口号结合 lsof 定位进程
$ node index.js shanyue

# 第一种方法:通过多余的参数快速定位 pid
$ ps -ef | grep shanyue
root     31796 23839  1 16:38 pts/5    00:00:00 node index.js shanyue

# 第二种方法:通过端口号定位 pid
lsof -i:3200
COMMAND   PID USER   FD   TYPE    DEVICE SIZE/OFF NODE NAME
node    31796 root   20u  IPv6 235987334      0t0  TCP *:tick-port (LISTEN)

使用 pidstat 监控内存

从以上代码中可以知道,node 服务的 pid 为 31796,为了可以观察到内存的动态变化,再施加一个压力测试

$ ab -c 10000 -n 1000000 http://localhost:3200/
# -r: 指输出内存指标
# -p: 指定 pid
# 1: 每一秒输出一次
# 100: 输出100次
$ pidstat -r -p 31796 1 100
Linux 3.10.0-957.21.3.el7.x86_64 (shuifeng)     2020年07月02日  _x86_64_        (2 CPU)

             UID       PID  minflt/s  majflt/s     VSZ    RSS   %MEM  Command
19时20分39秒     0     11401      0.00      0.00  566768  19800   0.12  node
19时20分40秒     0     11401      0.00      0.00  566768  19800   0.12  node
19时20分41秒     0     11401   9667.00      0.00  579024  37792   0.23  node
19时20分42秒     0     11401  11311.00      0.00  600716  59988   0.37  node
19时20分43秒     0     11401   5417.82      0.00  611420  70900   0.44  node
19时20分44秒     0     11401   3901.00      0.00  627292  85928   0.53  node
19时20分45秒     0     11401   1560.00      0.00  621660  81208   0.50  node
19时20分46秒     0     11401   2390.00      0.00  623964  83696   0.51  node
19时20分47秒     0     11401   1764.00      0.00  625500  85204   0.52  node

对于输出指标的含义如下

  • RSS: Resident Set Size,常驻内存集,可理解为内存,这就是我们需要监控的内存指标
  • VSZ: virtual size,虚拟内存

从输出可以看出,当施加了压力测试后,内存由 19M 涨到了 85M。

使用 top 监控内存

pidstat 是属于 sysstat 下的 linux 性能工具,但在 mac 中,如何定位内存的变化?

此时可以使用 top/htop

$ htop -p 31796

使用 htop 监控内存

生产环境内存监控

由于目前生产环境大都部署在 k8s因此生产环境对于某个应用的内存监控本质上是 k8s 对于某个 workload/deployment 的内存监控,关于内存监控 metric 的数据流向大致如下:

k8s -> metric server -> prometheus -> grafana

架构图如下:

以上图片取自以下文章

最终能够在 grafana 中收集到某一应用的内存监控实时图:

由于本部分设计内容过多,我将在以下的章节中进行介绍

这不仅仅适用于 node 服务,而且适用于一切 k8s 上的 workload

总结

本章介绍了关于 Node 服务的内存在本地环境及生产环境的监控

  1. 本地使用 htop/top 或者 pidstat 监控进程内存
  2. 生产环境使用 k8s/metric-server/prometheus/grafana 监控 node 整个应用的内存

当监控到某一服务发生内存泄漏后,如何解决问题?因此接下来的文章将会讲到

  1. 生产环境是如何监控整个应用的内存的
  2. 当生产环境发生 OOM 后,如何快速定位
  3. 真实生产环境若干 OOM 的示例定位
本文由博客一文多发平台 OpenWrite 发布!
查看原文

赞 16 收藏 7 评论 0

Denzel 发布了文章 · 7月2日

组件库重构,支持antd 4.x

前言

React 15.x 升 React 16.x 是一次内部重构,对于使用者来说,原来的使用方式仍然可用,额外加了新的功能;而Antd 3.x 升 Antd 4.x, 在我的认知范围里,可以称作是飞(po)跃(huai)性的重构, 因为以前很多写法都不兼容了,组件代码重构,使用者的代码也得重构。但这次重构解决了3.x的很多问题,比如:

  • 由于Icon无法按需加载导致打包体积过大;
  • 由于Form表单项变化会造成其他表单项全量渲染,大表单会有性能问题;
  • 时间库moment包体积太大。

说这么多,还是直接来张图吧,我个人项目的打包体积变化:Antd 3.x VS Antd 4.x

20200629171830

20200627171745

升4.x之后,gzip少了150kb,也就是包大小少了500多kb,这不香么。

关于升级

我和我的小组,为了用更爽的方式来开发迭代,针对于antd的Form和Table等组件做了一些简单的二次封装,形成了组件库antd-doddle。虽然Antd 4.x推出快小半年了,受疫情影响,今年业务迭代比较缓慢,没有新系统,也觉得暂时没必要去重构业务代码,所以一直只关注不动手。最近比较闲,组件库针对Antd 4.0做了适应性重构,作为一个胶水层,最大程度的去磨平4.0版本Form这种破坏性变动,减少以后业务代码升级4.x版本的调整量。

Antd 4.x到底做了哪些变化,在官方文档可以看到。

这篇文章主要讲针对于4.x Form的变化,我重构组件库的思路。

Antd-doddle 2.x文档地址, 支持4.x: http://doc.closertb.site, 首次加载较慢,请耐心等候。

Antd-doddle 1.x文档地址, 支持3.x: http://static.closertb.site, 首次加载较慢,请耐心等候。

试用项目Git 地址

项目在线试用地址, 请勿乱造

FormGroup重构思路

Form组件变化

4.x 中除了Icon,最大的更改就在于Form,我自己感受到的变化是:

  1. 舍去了Form.create高阶组件包裹表单的写法,而改采用hooks或ref的方式去操作form实例;
  2. 以前每个表单组件的数据绑定是通过getFieldDecorator这个装饰方法完成,现在改由FormItem来完成;
  3. 初始values设置,以前是通过getFieldDecorator设置,并且是动态的,即value改变,表单值跟随改变。现在是在Form最外层设置,但是以defaultValue形式设置,即value改变,表单值不跟随变化;
  4. 最大的改变就是增量式更新,3.x版本,任意表单项改变,都会造成Form.create包裹的全部表单项重新render,这是非常大的性能消耗;而4.x之后,任意表单项改变,只有设置了shouldUpdate属性的表单项有可能执行render,类似于React 16新增的componentShouldUpdate

根据上面的变化点,由外向内层层剖析,针对性的做重构;

FormGroup的变化

由于以前FormGroup组件,除了收集form方法和公共配置,也作为一个标识,接管了组件内部的渲染层;3.x版本其form实例由Form.create,即业务代码提供;4.x与其相似,只不过是通过hooks生成form实例。

变化点主要在于4.x版本Form要提供initialValues的设置,且这是一个defaultValue的设置,所以我们需要拓展,让其支持values为异步数据时,表单项的值能跟随其改变, 其原理很简单,监听value的变化,并重置表单数据,实现代码如下:

// 伪代码,只涉及相关改动
const FormGroup = (props, ref) => {
  const { formItemLayout = layout, children, datas = {}, ...others } = props;

  // 兼容了非hooks 组件调用的写法,内部再声明一个ref, 以备用;
  const insideRef = useRef();
  const _ref = ref || insideRef;

  const formProps = {
    initialValues: {}, // why
    ...formItemLayout,
    ...others
  };
  // 如果datas 值变化,重置表单的值
  useEffect(() => {
    const [data, apiStr] = Type.isEmpty(datas) ? [undefined, 'resetFields'] : [datas, 'setFieldsValue'];
    // 函数式组件采用form操作;
    if (props.form) {
      props.form[apiStr](data);
      return;
    }
    // 如果是类组件,才采用ref示例更新组件
    if (typeof _ref === 'object') {
      _ref.current[apiStr](data);
    }
  }, [datas]);


  return (
    <Form {...formProps} ref={_ref}>
      {deepMap(children, extendProps, mapFields)}
    </Form>);
};

上面有句代码 initialValues: {},会让人困惑, 为什么没有赋值为datas呢;这个又是antd的一个隐藏知识点,举个例子:

form.setFieldsValue({ name: 'antd', age: 18 }); 

// 后面想清空
form.setFieldsValue({}); 

// 最后发现上面清空根本没生效,原因可以自己想想

所以当我们想做一些需求,比如先编辑一个表单,表单中有数据了;但没做操作关闭了,然后点了新增按钮,传了一个空对象,这事就发现bug了,上一次编辑的数据还在,除了这个,还有一些其他的业务场景会用到,在antd中也有一个类似的issue:

在表单里有很多元素且拥有initialValue的时候, 如何简单的清空表单

所以在这个组件设计上,就将initialValues默认置空数据,然后设置的数据采用setFieldsValue来重置。如果想清空表单,直接传入一个空数据,被组件检测到后,内部调用resetFields来实现。快夸一下天才的我。

FormRender的变化

相比于FormGroup的变化,子组件FormRender相比较就变化小一点,主要在适应第二点变动,用代码的方式更直观:

// 3.x
const render = renderType[type];
content = (
  <FormItem
    label={name}
    rules={gerateRule(required, pholder, rules)}
    {...formProps}
  >
   {getFieldDecorator(key, {
     initialValue: data,
     rules: gerateRule(required, pholder, rules)
   })(
    render({ field: common, name, enums: selectEnums, containerName }))}
  </FormItem>);

重构之后

// 4.x
const render = renderType[type];
content = (
  <FormItem
    name={key}
    label={name}
    rules={gerateRule(required, pholder, rules)}
    {...formProps}
  >
    {render({ field: common, name, enums: selectEnums, containerName })}
  </FormItem>);

联动表单的实现变化

主要实现三种联动,根据其他表单项的变化,来改变关联表单项

  • 是否渲染或改变渲染方式;
  • 是否禁用;
  • 校验规则

由于以前每次表单项变化,都会引起其他表单项render,所以可以暴力的通过FormGroup增加数据层(useRef)与监(bao)听(guo)每个表单项的onChange来实现;

新的Form新增了onFormChange回调来支持增量式数据收集,但第四点变动,让老的方案GG;表单项的联动需要依赖shouldUpdate来实现,这也是官方推荐的方案;

20200629215947

其本质是,设置了shouldUpdate属性的FormItem,其仅仅作为一个容器,这个容器监听了类似onFormChange这种事件,然后根据shouldUpdate来判断是否需要重新渲染容器内的子元素,子元素渲染实现是一个应用React renderPrrops的设计模式;

所以联动方案似乎变得更简单了, 就多一层FormItem包裹,看部分代码实现:

const render = renderType[type];
content = shouldUpdate ? (
  <FormItem shouldUpdate={shouldUpdate} noStyle>
    {form => { 
      const datas = form.getFieldsValue();
      const require = typeof required === 'function' ? required(initData, datas) : required;
      const disabled = typeof disableTemp === 'function'
      ? disableTemp(initData, datas) : disableTemp;
      return finalEnable(initData, datas) ?
      (<FormItem
        key={key}
        name={key}
        label={name}
        dependencies={dependencies}
        rules={gerateRule(require, pholder, rules)}
        {...formProps}
        {...otherFormPrrops}
      >
        {render({ field: Object.assign(common, { disabled }), name, enums: selectEnums, containerName })}
      </FormItem>) : selfRender(datas, form)}
    }
  </FormItem>) : /* 非联动实现 */

其他

除了上面这些变化,其实还有很多边边角角的变化,比如:

  • 搜索框组件其实也是依赖FormGroup来实现的,所以内部也做了一些小调整,但业务代码就完全不需要做改动;
  • 以前支持了RangePicker 的数据自动组装,但由于4.x 支持DayJs 和 Moment 时间库切换,加上initValues的提前设置,这个功能暂时就取消了;
  • 样式文件的按需加载;

还有,还有一些未考虑好的的新增特性。

使用对比

实现一个小的编辑框,类似下面这样:

20200629230026

重构前的代码

import React from 'react';
import { FormGroup } from 'antd-doddle';
import { editFields } from './fields';

const { FormRender } = FormGroup;

function Edit({ id, form, data }) {
  const { getFieldDecorator } = form;

  return (
    <FormGroup getFieldDecorator={getFieldDecorator} required>
      {editFields.map(field => <FormRender key={field.key} field={field} data={data} />)}
    </FormGroup>
  );
}

export default FormGroup.create()(Edit);

重构之后

import React from 'react';
import { FormGroup } from 'antd-doddle';
import { editFields } from './fields';

const { FormRender } = FormGroup;

function Edit({ id, data, ...others }) {
  const [form] = FormGroup.useForm();

  return (
    <FormGroup required form={form} datas={data}>
      {editFields.map(field => <FormRender key={field.key} field={field} />)}
    </FormGroup>
  );
}

export default Edit

不仔细看,是不是不易察觉到变化

重构感受

因为用的还不像3.x版本那么熟练,很多特性还没用上,所以先重构一个简易版本,来完成日常场景,后面再慢慢迭代。

如果感兴趣,可以fork项目或查看项目文档

文章原地址

查看原文

赞 4 收藏 2 评论 5

Denzel 回答了问题 · 6月18日

解决ReactRouter中父子组件如何通信

<Route path="/objective" component={() => <Objective callback={this.callback} />} ></Route>

这是最简单直接的方式

关注 2 回答 2

Denzel 发布了文章 · 6月1日

也许这才是你想要的微前端方案

前言

微前端是当下的前端热词,稍具规模的团队都会去做技术探索,作为一个不甘落后的团队,我们也去做了。也许你看过了Single-Spaqiankun这些业界成熟方案,非常强大:JS沙箱隔离、多栈支持、子应用并行、子应用嵌套,但仔细想想它真的适合你吗?

对于我来说,太重了,概念太多,理解困难。先说一下背景,我们之所以要对我司的小贷管理后台做微前端改造,主要基于以下几个述求:

  • 系统从接手时差不多30个页面,一年多时间,发展到目前150多个页面,并还在持续增长;
  • 项目体积变大,带来开发体验很差,打包构建速度很慢(初次构建,1分钟以上);
  • 小贷系统开发量占整个web组50%的人力,每个迭代都有两三个需求在这一个系统上开发,代码合并冲突,上线时间交叉。带来的是开发流程管理复杂;
  • 业务人员是分类的,没有谁会用到所有的功能,每个业务人员只拥有其中30%甚至更少的功能。但不得不加载所有业务代码,才能看到自己想要的页面;

所以和市面上很多前端团队引入微前端的目的不同的是,我们是,而更多的团队是。所以本方案适合和我目的一致的前端团队,将自己维护的巨婴系统瓦解,然后通过微前端"框架"来聚合,降低项目管理难度,提升开发体验与业务使用体验。

巨婴系统技术栈: Dva + Antd

方案参考美团一篇文章:微前端在美团外卖的实践

在做这个项目的按需提前加载设计时,自己去深究过webpack构建出的项目代码运行逻辑,收获比较多:webpack 打包的代码怎么在浏览器跑起来的?, 不了解的可以看看

方案设计

基于业务角色,我们将巨婴系统拆成了一个基座系统和四个子系统(可以按需扩展子系统),如下图所示:

20200528165839

基座系统除了提供基座功能,即系统的登录、权限获取、子系统的加载、公共组件共享、公共库的共享,还提供了一个基本所有业务人员都会使用的业务功能:用户授(guan)信(li)。

子系统以静态资源的方式,提供一个注册函数,函数返回值是一个Switch包裹的组件与子系统所有的models。

路由设计

子系统以组件的形式加载到基座系统中,所以路由是入口,也是整个设计的第一步,为了区分基座系统页面和子系统页面,在路由上约定了下面这种形式:

// 子系统路由匹配,伪代码
function Layout(layoutProps) {
  useEffect(() => {
      const apps = getIncludeSubAppMap();
      // 按需加载子项目;
      apps.forEach(subKey => startAsyncSubapp(subKey));
  }, []);

  return (
    <HLayout {...props}>
      <Switch>
          {/* 企业用户管理 */}
          <Route exact path={Paths.PRODUCT_WHITEBAR} component={pages.ProductManage} breadcrumbName="企业用户管理" />
          {/* ...省略一百行 */}
          <Route path="/subPage/" component={pages.AsyncComponent} />
      </Switch>
    </HLayout>
}

即只要以subPage路径开头,就默认这个路由对应的组件为子项目,从而通过AsyncComponent组件去异步获取子项目组件。

异步加载组件设计

路由设计完了,然后异步加载组件就是这个方案的灵魂了,流程是这样的:

  • 通过路由,匹配到要访问的具体是那个子项目;
  • 通过子项目id,获取对应的manifest.json文件;
  • 通过获取manifest.json,识别到对应的静态资源(js,css)
  • 加载静态资源,加载完,子项目执行注册
  • 动态加载model,更新子项目组件

直接上代码吧,简单明了,资源加载的逻辑后面再详讲,需要注意的是model和component的加载顺序

export default function AsyncComponent({ location }) {
  // 子工程资源是否加载完成
  const [ayncLoading, setAyncLoaded] = useState(true);
  // 子工程组件加载存取
  const [ayncComponent, setAyncComponent] = useState(null);
  const { pathname } = location;
  // 取路径中标识子工程前缀的部分, 例如 '/subPage/xxx/home' 其中xxx即子系统路由标识
  const id = pathname.split('/')[2];
  useEffect(() => {
    if (!subAppMapInfo[id]) {
      // 不存在这个子系统,直接重定向到首页去
      goBackToIndex();
    }
    const status = subAppRegisterStatus[id];
    if (status !== 'finish') {
      // 加载子项目
      loadAsyncSubapp(id).then(({ routes, models }) => {
        loadModule(id, models);
        setAyncComponent(routes);
        setAyncLoaded(false);
        // 已经加载过的,做个标记
        subAppRegisterStatus[id] = 'finish';
      }).catch((error = {}) => {
        // 如果加载失败,显示错误信息
        setAyncLoaded(false);
        setAyncComponent(
          <div style={{
            margin: '100px auto',
            textAlign: 'center',
            color: 'red',
            fontSize: '20px'
          }}
          >
            {error.message || '加载失败'}
          </div>);
      });
    } else {
      const models = subappModels[id];
      loadModule(id, models);
      // 如果能匹配上前缀则加载相应子工程模块
      setAyncLoaded(false);
      setAyncComponent(subappRoutes[id]);
    }
  }, [id]);
  return (
    <Spin spinning={ayncLoading} style={{ width: '100%', minHeight: '100%' }}>
      {ayncComponent}
    </Spin>
  );
}

子项目设计

子项目以静态资源的形式在基座项目中加载,需要暴露出子系统自己的全部页面组件和数据model;然后在打包构建上和以前也稍许不同,需要多生成一个manifest.json来搜集子项目的静态资源信息。

子项目暴露出自己自愿的代码长这样:

// 子项目资源输出代码
import routes from './layouts';

const models = {};

function importAll(r) {
  r.keys().forEach(key => models[key] = r(key).default);
}

// 搜集所有页面的model
importAll(require.context('./pages', true, /model\.js$/));

function registerApp(dep) {
  return {
    routes, // 子工程路由组件
    models, // 子工程数据模型集合
  };
}

// 数组第一个参数为子项目id,第二个参数为子项目模块获取函数
(window["registerApp"] = window["registerApp"] || []).push(['collection', registerApp]);

子项目页面组件搜集:

import menus from 'configs/menus';
import { Switch, Redirect, Route } from 'react-router-dom';
import pages from 'pages';

function flattenMenu(menus) {
  const result = [];
  menus.forEach((menu) => {
    if (menu.children) {
      result.push(...flattenMenu(menu.children));
    } else {
      menu.Component = pages[menu.component];
      result.push(menu);
    }
  });
  return result;
}

// 子项目自己路径分别 + /subpage/xxx 
const prefixRoutes = flattenMenu(menus);

export default (
  <Switch>
    {prefixRoutes.map(child =>
      <Route
        exact
        key={child.key}
        path={child.path}
        component={child.Component}
        breadcrumbName={child.title}
      />
    )}
    <Redirect to="/home" />
  </Switch>);

静态资源加载逻辑设计

开始做方案时,只是设计出按需加载的交互体验:即当业务切换到子项目路径时,开始加载子项目的资源,然后渲染页面。但后面感觉这种改动影响了业务体验,他们以前只需要加载数据时loading,现在还需要承受子项目加载loading。所以为了让业务尽量小的感知系统的重构,将按需加载换成了按需提前加载。简单点说,就是当业务登录时,我们会去遍历他的所有权限菜单,获取他拥有那些子项目的访问权限,然后提前加载这些资源。

遍历菜单,提前加载子项目资源:

// 本地开发环境不提前按需加载
if (getDeployEnv() !== 'local') {
  const apps = getIncludeAppMap();
  // 按需提前加载子项目资源;
  apps.forEach(subKey => startAsyncSubapp(subKey));
}

然后就是show代码的时候了,思路参考webpackJsonp,就是通过拦截一个全局数组的push操作,得知子项目已加载完成:

import { subAppMapInfo } from './menus';

// 子项目静态资源映射表存放:
/**
 * 状态定义:
 * '': 还未加载
 * ‘start’:静态资源映射表已存在;
 * ‘map’:静态资源映射表已存在;
 * 'init': 静态资源已加载;
 * 'wait': 资源加载已完成, 待注入;
 * 'finish': 模块已注入;
*/
export const subAppRegisterStatus = {};

export const subappSourceInfo = {};

// 项目加载待处理的Promise hash 表
const defferPromiseMap = {};

// 项目加载待处理的错误 hash 表
const errorInfoMap = {};

// 加载css,js 资源
function loadSingleSource(url) {
  // 此处省略了一写代码
  return new Promise((resolove, reject) => {
    link.onload = () => {
      resolove(true);
    };
    link.onerror = () => {
      reject(false);
    };
  });
}

// 加载json中包含的所有静态资源
async function loadSource(json) {
  const keys = Object.keys(json);
  const isOk = await Promise.all(keys.map(key => loadSingleSource(json[key])));

  if (!isOk || isOk.filter(res => res === true) < keys.length) {
    return false;
  }

  return true;
}

// 获取子项目的json 资源信息
async function getManifestJson(subKey) {
  const url = subAppMapInfo[subKey];
  if (subappSourceInfo[subKey]) {
    return subappSourceInfo[subKey];
  }

  const json = await fetch(url).then(response => response.json())
    .catch(() => false);

  subAppRegisterStatus[subKey] = 'map';
  return json;
}

// 子项目提前按需加载入口
export async function startAsyncSubapp(moduleName) {
  subAppRegisterStatus[moduleName] = 'start'; // 开始加载
  const json = await getManifestJson(moduleName);
  const [, reject] = defferPromiseMap[moduleName] || [];
  if (json === false) {
    subAppRegisterStatus[moduleName] = 'error';
    errorInfoMap[moduleName] = new Error(`模块:${moduleName}, manifest.json 加载错误`);
    reject && reject(errorInfoMap[moduleName]);
    return;
  }
  subAppRegisterStatus[moduleName] = 'map'; // json加载完毕
  const isOk = await loadSource(json);
  if (isOk) {
    subAppRegisterStatus[moduleName] = 'init';
    return;
  }
  errorInfoMap[moduleName] = new Error(`模块:${moduleName}, 静态资源加载错误`);
  reject && reject(errorInfoMap[moduleName]);
  subAppRegisterStatus[moduleName] = 'error';
}

// 回调处理
function checkDeps(moduleName) {
  if (!defferPromiseMap[moduleName]) {
    return;
  }
  // 存在待处理的,开始处理;
  const [resolove, reject] = defferPromiseMap[moduleName];
  const registerApp = subappSourceInfo[moduleName];

  try {
    const moduleExport = registerApp();
    resolove(moduleExport);
  } catch (e) {
    reject(e);
  } finally {
    // 从待处理中清理掉
    defferPromiseMap[moduleName] = null;
    subAppRegisterStatus[moduleName] = 'finish';
  }
}

// window.registerApp.push(['collection', registerApp])
// 这是子项目注册的核心,灵感来源于webpack,即对window.registerApp的push操作进行拦截
export function initSubAppLoader() {
  window.registerApp = [];
  const originPush = window.registerApp.push.bind(window.registerApp);
  // eslint-disable-next-line no-use-before-define
  window.registerApp.push = registerPushCallback;
  function registerPushCallback(module = []) {
    const [moduleName, register] = module;
    subappSourceInfo[moduleName] = register;
    originPush(module);
    checkDeps(moduleName);
  }
}

// 按需提前加载入口
export function loadAsyncSubapp(moduleName) {
  const subAppInfo = subAppRegisterStatus[moduleName];

  // 错误处理优先
  if (subAppInfo === 'error') {
    const error = errorInfoMap[moduleName] || new Error(`模块:${moduleName}, 资源加载错误`);
    return Promise.reject(error);
  }

  // 已经提前加载,等待注入
  if (typeof subappSourceInfo[moduleName] === 'function') {
    return Promise.resolve(subappSourceInfo[moduleName]());
  }

  // 还未加载的,就开始加载,已经开始加载的,直接返回
  if (!subAppInfo) {
    startAsyncSubapp(moduleName);
  }

  return new Promise((resolve, reject = (error) => { throw error; }) => {
    // 加入待处理map中;
    defferPromiseMap[moduleName] = [resolve, reject];
  });
}

这里需要强调一下子项目有两种加载场景:

  • 从基座页面路径进入系统, 那么就是按需提前加载的场景, 那么startAsyncSubapp先执行,提前缓存资源;
  • 从子项目页面路径进入系统, 那就是按需加载的场景,就存在loadAsyncSubapp先执行,利用Promise完成发布订阅。至于为什么startAsyncSubapp在前但后执行,是因为useEffect是组件挂载完成才执行;

至此,框架的大致逻辑就交代清楚了,剩下的就是优化了。

其他难点

其实不难,只是怪我太菜,但这些点确实值得记录,分享出来共勉。

公共依赖共享

我们由于基座项目与子项目技术栈一致,另外又是拆分系统,所以共享公共库依赖,优化打包是一个特别重要的点,以为就是webpack配个external就完事,但其实要复杂的多。

antd 构建

antd 3.x就支持了esm,即按需引入,但由于我们构建工具没有做相应升级,用了babel-plugin-import这个插件,所以导致了两个问题,打包冗余与无法全量导出antd Modules。分开来讲:

  • 打包冗余,就是通过BundleAnalyzer插件发现,一个模块即打了commonJs代码,也打了Esm代码;
  • 无法全量导出,因为基座项目不知道子项目会具体用哪个模块,所以只能暴力的导出Antd所有模块,但babel-plugin-import这个插件有个优化,会分析引入,然后删除没用的依赖,但我们的需求和它的目的是冲突的;

结论:使用babel-plugin-import这个插件打包commonJs代码已经过时, 其存在的唯一价值就是还可以帮我们按需引入css 代码;

项目公共组件共享

项目中公共组件的共享,我们开始尝试将常用的组件加入公司组件库来解决,但发现这个方案并不是最理想的,第一:很多组件和业务场景强相关,加入公共组件库,会造成组件库臃肿;第二:没有必要。所以我们最后还是采用了基座项目收集组件,并统一暴露:

function combineCommonComponent() {
 const contexts = require.context('./components/common', true, /\.js$/);
 return contexts.keys().reduce((next, key) => {
   // 合并components/common下的组件
   const compName = key.match(/\w+(?=\/index\.js)/)[0];
   next[compName] = contexts(key).default;
   return next;
 }, {});
}

webpackJsonp 全局变量污染

如果对webpack构建后的代码不熟悉,可以先看看开篇提到的那篇文章。

webpack构建时,在开发环境modules是一个对象,采用文件path作为module的key; 而正式环境,modules是一个数组,会采用index作为module的key。
由于我基座项目和子项目没有做沙箱隔离,即window被公用,所以存在webpackJsonp全局变量污染的情况,在开发环境,这个污染没有被暴露,因为文件Key是唯一的,但在打正式包时,发现qa 环境子项目无法加载,最后一分析,发现了window.webpackJsonp 环境变量污染的bug。

最后解决的方案就是子项目打包都拥有自己独立的webpackJsonp变量,即将webpackJsonp重命名,写了一个简单的webpack插件搞定:

// 将webpackJsonp 重命名为 webpackJsonpCollect
config.plugins.push(new RenameWebpack({ replace: 'webpackJsonpCollect' }));

子项目开发热加载

基座项目为什么会成为基座,就因为他迭代少且稳定的特殊性。但开发时,由于子项目无法独立运行,所以需要依赖基座项目联调。但做一个需求,要打开两个vscode,同时运行两个项目,对于那个开发,这都是一个不好的开发体验,所以我们希望将dev环境作为基座,来支持本地的开发联调,这才是最好的体验。

将dev环境的构建参数改成开发环境后,发现子项目能在线上基座项目运行,但webSocket通信一直失败,最后找到原因是webpack-dev-sever有个host check逻辑,称为主机检查,是一个安全选项,我们这里是可以确认的,所以直接注释就行。

总结

这篇文章,本身就是个总结。如果有什么疑惑或更好的建议,欢迎一起讨论,issues地址

查看原文

赞 47 收藏 35 评论 7

Denzel 关注了专栏 · 5月19日

蚂蚁技术

蚂蚁金服科技官方账号,专注于分享蚂蚁金服的技术

关注 2045

Denzel 发布了文章 · 5月18日

webpack 打包的代码怎么在浏览器跑起来的?看不懂算我输

说点什么

最近在做一个工程化强相关的项目-微前端,涉及到了基座项目和子项目加载,并存的问题;以前对webpack一直停留在配置,也就是常说的入门级。这次项目推动,自己不得不迈过门槛,往里面多看一点。

本文主要讲webpack构建后的文件,是怎么在浏览器运行起来的,这可以让我们更清楚明白webpack的构建原理。

文章中的代码基本只含核心部分,如果想看全部代码和webpack配置,可以关注工程,自己拷贝下来运行: demo地址

在读本文前,需要知道webpack的基础概念,知道chunk 和 module的区别

本文将循序渐进,来解析webpack打包后的文件,代码是怎么跑起来的,从以下三个步骤娓娓道来:

  • 单文件打包,从IIFE说起;
  • 多文件之间,怎么判断依赖的加载状态;
  • 按需加载的背后,黑盒中究竟有什么黑魔法;

从最简单的说起:单文件怎么跑起来的

最简单的打包场景是什么呢,就是打包出来html文件只引用一个js文件,项目就可以跑起来,举个🌰:

// 入口文件:index.js
import sayHello from './utils/hello';
import { util } from './utils/util';

console.log('hello word:', sayHello());
console.log('hello util:', util);

// 关联模块:utils/util.js
export const util = 'hello utils';

// 关联模块:utils/hello.js
import { util } from './util';

console.log('hello util:', util);

const hello = 'Hello';

export default function sayHello() {
  console.log('the output is:');
  return hello;
};

入门级的代码,简单来讲就是入口文件依赖了两个模块: util 与 hello,然后模块hello,又依赖了util,最后运行html文件,可以在控制台看到console打印。打包后的代码长什么样呢,看下面,删除了一些干扰代码,只保留了核心部分,加了注释,但还是较长,需要耐心:

 (function(modules) { // webpackBootstrap
  // 安装过的模块的缓存
  var installedModules = {};
  // 模块导入方法
  function __webpack_require__(moduleId) {
    // 安装过的模块,直接取缓存
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    // 没有安装过的话,那就需要执行模块加载
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    };
    // 上面说的加载,其实就是执行模块,把模块的导出挂载到exports对象上;
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    // 标识模块已加载过
    module.l = true;
    // Return the exports of the module
    return module.exports;
  }
  // 暴露入口输入模块;
  __webpack_require__.m = modules;
  // 暴露已经加载过的模块;
  __webpack_require__.c = installedModules;
  // 模块导出定义方法
  // eg: export const hello = 'Hello world';
  // 得到: exprots.hello = 'Hello world';
  __webpack_require__.d = function (exports, name, getter) {
    if (!__webpack_require__.o(exports, name)) {
      Object.defineProperty(exports, name, {
        enumerable: true,
        get: getter
      });
    }
  };

  // __webpack_public_path__
  __webpack_require__.p = '';
  // 从入口文件开始启动
  return __webpack_require__(__webpack_require__.s = "./src/index.js");
 })({
  "./webpack/src/index.js":
  /*! no exports provided */
  (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    __webpack_require__.r(__webpack_exports__);
    var _utils_hello__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils/hello */ "./webpack/src/utils/hello.js");
    var _utils_util__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./utils/util */ "./webpack/src/utils/util.js");
    console.log('hello word:', Object(_utils_hello__WEBPACK_IMPORTED_MODULE_0__["default"])());
    console.log('hello util:', _utils_util__WEBPACK_IMPORTED_MODULE_1__["util"]);
  }),
  "./webpack/src/utils/hello.js":
  (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    __webpack_require__.r(__webpack_exports__);
    __webpack_require__.d(__webpack_exports__, "default", function() { return sayHello; });
    var _util__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./util */ "./webpack/src/utils/util.js");

    console.log('hello util:', _util__WEBPACK_IMPORTED_MODULE_0__["util"]);
    var hello = 'Hello';
    function sayHello() {
      console.log('the output is:');
      return hello;
    }
  }),

  "./webpack/src/utils/util.js":
  (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    __webpack_require__.r(__webpack_exports__);
    __webpack_require__.d(__webpack_exports__, "util", function() { return util; });
    var util = 'hello utils';
  })
});

咋眼一看上面的打包结果,其实就是一个IIFE(立即执行函数),这个函数就是webpack的启动代码,里面包含了一些变量方法声明;而输入是一个对象,这个对象描述的就是我们代码中编写的文件,文件路径为对面key,value就是文件中定义的代码,但这个代码是被一个函数包裹的:

/**
 * module:               就是当前模块
 * __webpack_exports__: 就是当前模块的导出,即module.exports
 * __webpack_require__:  webpack加载器对象,提供了依赖加载,模块定义等能力
**/
function(module, __webpack_exports__, __webpack_require__) {
  // 文件定义的代码
}

加载的原理,在上面代码中已经做过注释了,耐心点,一分钟就明白了,还是加个图吧,在vscode中用drawio插件画的,感受一下:

20200516121057

除了上面的加载过程,再说一个细节,就是webpack怎么分辨依赖包是ESM还是CommonJs模块,还是看打包代码吧,上面输入模块在开头都会执行__webpack_require__.r(__webpack_exports__), 省略了这个方法的定义,这里补充一下,解析看代码注释:

  // 定义模块类型是__esModule, 保证模块能被其他模块正确导入,  
  __webpack_require__.r = function (exports) {
    if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
      Object.defineProperty(exports, Symbol.toStringTag, {
        value: 'Module'
      });
    }
    // 模块上定义__esModule属性, __webpack_require__.n方法会用到
    // 对于ES6 MOdule,import a from 'a'; 获取到的是:a[default];
    // 对于cmd, import a from 'a';获取到的是整个module
    Object.defineProperty(exports, '__esModule', {
      value: true
    });
  };
  // esModule 获取的是module中的default,而commonJs获取的是全部module
  __webpack_require__.n = function (module) {
    var getter = module && module.__esModule ?
      function getDefault() {
        return module['default'];
      } :
      function getModuleExports() {
        return module;
      };
    // 为什么要在这个方法上定义一个 a 属性? 看打包后的代码, 比如:在引用三方时
    // 使用import m from 'm', 然后调用m.func();
    // 打出来的代码都是,获取模块m后,最后执行时是: m.a.func();
    __webpack_require__.d(getter, 'a', getter);
    return getter;
  };

最常见的:多文件引入的怎么执行

看完最简单的,现在来看一个最常见的,引入splitChunks,多chunk构建,执行流程有什么改变。我们常常会将一些外部依赖打成一个js包,项目自己的资源打成一个js包;

还是刚刚的节奏,先看打包前的代码:

// 入口文件:index.js
+ import moment from 'moment';
+ import cookie from 'js-cookie';
import sayHello from './utils/hello';
import { util } from './utils/util';

console.log('hello word:', sayHello());
console.log('hello util:', util);
+ console.log('time', moment().format('YYYY-MM-DD'));
+ cookie.set('page', 'index');
// 关联模块:utils/util.js
+ import moment from 'moment';
export const util = 'hello utils';

export function format() {
  return moment().format('YYYY-MM-DD');
}

// 关联模块:utils/hello.js
// 没变,和上面一样

从上面代码可以看出,我们引入了moment与js-cookie两个外部JS包,并采用分包机制,将依赖node_modules中的包打成了一个单独的,下面是多chunk打包后的html文件截图:

20200516132542

再看看async.js 包长什么样:

// 伪代码,隐藏了 moment 和 js-cookie 的代码细节
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["async"],{
  "./node_modules/js-cookie/src/js.cookie.js": (function(module, exports, __webpack_require__) {}),
  "./node_modules/moment/moment.js": (function(module, exports, __webpack_require__) {})
})

咋一样看,这个代码甚是简单,就是一个数组push操作,push的元素是一个数组[["async"],{}], 先提前说一下,数组第一个元素数组,是这个文件包含的chunk name, 第二个元素对象,其实就和第一节简单文件打包的输入一样,是模块名和包装后的模块代码;

再看一下index.js 的变化:

(function(modules) { // webpackBootstrap
  // 新增
  function webpackJsonpCallback(data) {
    return checkDeferredModules();
    };
    
  function checkDeferredModules() {
  }

  // 缓存加载过的模块
  var installedModules = {};
  // 存储 chunk 的加载状态
  // undefined = chunk not loaded, null = chunk preloaded/prefetched
  // Promise = chunk loading, 0 = chunk loaded
  var installedChunks = {
    "index": 0
  };
  var deferredModules = [];
  // on error function for async loading
  __webpack_require__.oe = function(err) { console.error(err); throw err; };

  // 加载的关键
  var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
  var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
  jsonpArray.push = webpackJsonpCallback;
  jsonpArray = jsonpArray.slice();
  for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
  var parentJsonpFunction = oldJsonpFunction;

  // 从入口文件开始启动
  - return __webpack_require__(__webpack_require__.s = "./src/index.js");
  // 将入口加入依赖延迟加载的队列
  + deferredModules.push(["./webpack/src/index.js","async"]);
  // 检查可执行的入口
  + return checkDeferredModules();
})
({
  // 省略;
})

从上面的代码看,支持多chunk执行,webpack 的bootstrap,还是做了很多工作的,我这大概列一下:

  • 新增了checkDeferredModules,用于依赖chunk检查是否已准备好;
  • 新增webpackJsonp全局数组,用于文件间的通信与模块存储;通信是通过拦截push操作完成的;
  • 新增webpackJsonpCallback,作为拦截push的代理操作,也是整个实现的核心;
  • 修改了入口文件执行方式,依赖deferredModules实现;

这里面文章很多,我们来一一破解:

### webpackJsonp push 拦截

// 检查window["webpackJsonp"]数组是否已声明,如果未声明的话,声明一个;
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
// 对webpackJsonp原生的push操作做缓存
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
// 使用开头定义的webpackJsonpCallback作为代码,即代码中执行indow["webpackJsonp"].push时会触发这个操作
jsonpArray.push = webpackJsonpCallback;
// 这不操作,其实就是jsonpArray开始是window["webpackJsonp"]的快捷操作,现在我们对她的操作已完,就断开了这个引用,但值还是要,用于后面遍历
jsonpArray = jsonpArray.slice();
// 这一步,其实要知道他的场景,才知道他的意义,如果光看代码,觉得这个数组刚声明,遍历有什么用;
// 其实这里是在依赖的chunk 先加载完的情况,但拦截代理当时还没生效;所以手动遍历一次,让已加载的模块再走一次代理操作;
for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
// 这个操作就是个赋值语句,意义不大;
var parentJsonpFunction = oldJsonpFunction;

直接写上面注释了,webpackJsonpCallback在后面会解密。

代理 webpackJsonpCallback 干了什么

function webpackJsonpCallback(data) {
  var chunkIds = data[0];
  var moreModules = data[1];
  var executeModules = data[2];

  // add "moreModules" to the modules object,
  var moduleId, chunkId, i = 0, resolves = [];
  for(;i < chunkIds.length; i++) {
    chunkId = chunkIds[i];
    // 下一节再讲
    installedChunks[chunkId] = 0;
    
  }
  for(moduleId in moreModules) {
    if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
      // 将其他chunk中的模块加入到主chunk中;
      modules[moduleId] = moreModules[moduleId];
    }
    }
  // 这里才是原始的push操作
  if(parentJsonpFunction) parentJsonpFunction(data);
  while(resolves.length) {
    // 下一节再讲
  }
  // 这一句在这里没什么用
  deferredModules.push.apply(deferredModules, executeModules || []);

  // run deferred modules when all chunks ready
  return checkDeferredModules();
};

还记得前面push的数据是什么格式吗:

window["webpackJsonp"].push([["async"], moreModules])

拦截了push操作后,其实就做了三件事:

  • 将数组第二个变量 moreModules 加入到index.js 立即执行函数的输入变量modules中;
  • 将这个chunk的加载状态置成已完成;
  • 然后checkDeferredModules,就是看这个依赖加载后,是否有模块在等这个依赖执行;

checkDeferredModules 干了什么

function checkDeferredModules() {
  var result;
  for(var i = 0; i < deferredModules.length; i++) {
    var deferredModule = deferredModules[i];
    var fulfilled = true;
    for(var j = 1; j < deferredModule.length; j++) {
      // depId, 即指依赖的chunk的ID,,对于入口‘./webpack/src/index.js’这个deferredModule,depId就是‘async’,等async模块加载后就可以执行了
      var depId = deferredModule[j];
      if(installedChunks[depId] !== 0) fulfilled = false;
    }
    if(fulfilled) {
        // 执行过了,就把这个延迟执行项移除;
        deferredModules.splice(i--, 1);
        // 执行./webpack/src/index.js模块
      result = __webpack_require__(__webpack_require__.s = deferredModule[0]);
    }
  }
  return result;
}

还记得入口文件的执行替换成了: deferredModules.push(["./webpack/src/index.js","async"]), 然后执行checkDeferredModules。
这个函数,就是检查哪些chunk安装了,但有些module执行,需要依赖某些
chunk,等依赖的chunk加载了,再执行这个module。上面的那一句代码就是./webpack/src/index.js这个模块执行依赖async这个chunk。

小总结

到这里,似乎多chunk打包,文件的执行流程就算理清楚了,如果你能想明白在html中下面两种方式,都不会导致文件执行失败,你就真的明白了:

<!-- 依赖项在前加载 -->
<script type="text/javascript" data-original="async.bundle_9b9adb70.js"></script>
<script type="text/javascript" data-original="index.4f7fc812.js"></script>

<!-- 或依赖项在后加载 -->
<script type="text/javascript" data-original="index.4f7fc812.js"></script>
<script type="text/javascript" data-original="async.bundle_9b9adb70.js"></script>

按需加载:动态加载过程解析

等多包加载理清后,再看按需加载,就没有那么复杂了,因为很多实现是在多包加载的基础上完成的,为了让理论更清晰,我添加了两处按需加载,还是那个节奏:

// 入口文件,index.js, 只列出新增代码
let count = 0;

const clickButton = document.createElement('button');

const name = document.createTextNode("CLICK ME");

clickButton.appendChild(name);

document.body.appendChild(clickButton);

clickButton.addEventListener('click', () => {
  count++;
  import('./utils/math').then(modules => {
    console.log('modules', modules);
  });

  if (count > 2) {
    import('./utils/fire').then(({ default: fire }) => {
      fire();
    });
  }
})

// utils/fire
export default function fire() {
  console.log('you are fired');
}

// utils/math
export default function add(a, b) {
  return a + b;
}

代码很简单,就是在页面添加了一个按钮,当按钮被点击时,按需加载utils/math模块,并打印输出的模块;当点击次数大于两次时,按需加载utils/fire模块,并调用其中暴露出的fire函数。相对于上一次,会多打出两个js 文件:0.bundle_29180b93.js 与 1.bundle_42bc336c.js,这里就列其中一个的代码:

(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[1],{
"./webpack/src/utils/math.js":
  (function(module, __webpack_exports__, __webpack_require__) {})
}]);

格式与上面的async chunk 格式一模一样。

然后再来看index.js 打包完,新增了哪些:

 (function(modules) { 
  // script url 计算方法。下面的两个hash 是否似曾相识,对,就是两个按需加载文件的hash值
  // 传入0,返回的就是0.bundle_29180b93.js这个文件名
     function jsonpScriptSrc(chunkId) {
         return __webpack_require__.p + "" + ({}[chunkId]||chunkId) + ".bundle_" + {"0":"29180b93","1":"42bc336c"}[chunkId] + ".js"
  }
  // 按需加载script 方法
  __webpack_require__.e = function requireEnsure(chunkId) {
         // 后面详讲
     }; 
 })({
  "./webpack/src/index.js": (function(module, __webpack_exports__, __webpack_require__) {
    // 只列出按需加载utils/fire.js的代码
    __webpack_require__.e(/*! import() */ 0)
    .then(__webpack_require__.bind(null, "./webpack/src/utils/fire.js"))
    .then(function (_ref) {
        var fire = _ref["default"];
        fire();
    });
  }
})

在上一节的接触上,只加了很少的代码,主要涉及到两个方法jsonpScriptSrcrequireEnsure,前者在注释里已经写得很清楚了,后者其实就是动态创建script标签,动态加载需要的js文件,并返回一个Promise,来看一下代码:

__webpack_require__.e = function requireEnsure(chunkId) {
  var promises = [];
    var installedChunkData = installedChunks[chunkId];
  // 0 意为着已加载.
  if(installedChunkData !== 0) {
      // a Promise means "currently loading": 意外着,已经在加载中
      // 需要把加载那个promise:(即下面new的promise)加入到当前的依赖项中;
    if(installedChunkData) {
      promises.push(installedChunkData[2]);
    } else {
      // setup Promise in chunk cache:new 一个promise
      var promise = new Promise(function(resolve, reject) {
        installedChunkData = installedChunks[chunkId] = [resolve, reject];
        });
      // 这里将promise本身记录到installedChunkData,就是以防上面多个chunk同时依赖一个script的时候
      promises.push(installedChunkData[2] = promise);

      // 下面都是动态加载script标签的常规操作
      var script = document.createElement('script');
      var onScriptComplete;
      script.charset = 'utf-8';
      script.timeout = 120;
      if (__webpack_require__.nc) {
        script.setAttribute("nonce", __webpack_require__.nc);
      }
      script.src = jsonpScriptSrc(chunkId);

      // 下面的代码都是错误处理
      var error = new Error();
      onScriptComplete = function (event) {
          // 错误处理
      };
      var timeout = setTimeout(function(){
        onScriptComplete({ type: 'timeout', target: script });
      }, 120000);
      script.onerror = script.onload = onScriptComplete;
      // 添加script到body
      document.head.appendChild(script);
    }
  }
  return Promise.all(promises);
};

相对来说requireEnsure的代码实现并没有多么特别,都是一些常规操作,但没有用常用的onload回调,而改用promise来处理,还是比较巧妙的。模块是否已经加装好,还是利用前面的webpackJsonp的push代理来完成。

现在再来补充上面一节说留着下一节讲的代码:

function webpackJsonpCallback(data) {
  var chunkIds = data[0];
  var moreModules = data[1];
  var executeModules = data[2];

  var moduleId, chunkId, i = 0, resolves = [];
  for(;i < chunkIds.length; i++) {
    chunkId = chunkIds[i];
    if(Object.prototype.hasOwnProperty.call(installedChunks, chunkId) && installedChunks[chunkId]) {
      // installedChunks[chunkId] 在这里加载时,还是一个数组,元素分别是[resolve, reject, promise],这里取的是resolve回调;
      resolves.push(installedChunks[chunkId][0]);
    }
    installedChunks[chunkId] = 0;
    // moreModules 注入忽略
    while(resolves.length) {
      // 这里resolve时,那么promise.all 就完成了
             resolves.shift()();
    }
  }
}

所以上面的代码做的,还是利用了这个代理,在chunk加载完成时,来把刚刚产生的promise resolved 掉,这样按需加载的then就继续往下执行了,非常曲折的一个发布订阅。

总结

自此,对webpack打包后的代码执行过程就分析完了,由简入难,如果多一点耐心,还是比较容易就看懂的。毕竟wbepack的高深,是隐藏在webpack自身的插件系统中的,打出来的代码基本是ES5级别的,只是用了一些巧妙的方法,比如push的拦截代理。

如果有什么不清楚的,推荐clone项目,自己打包分析一下代码:demo地址: webpack项目

(https://github.com/closertb/c...

查看原文

赞 24 收藏 19 评论 1

Denzel 发布了文章 · 4月26日

Web网页渲染的几种模式

译文:Rendering on the Web
本文主要内容来源于对上文的翻译,图也来源于此,加上了一点平时工作的理解,英语渣、翻译不是很准确,有条件的可以直接阅读上文链接。本文主要是自己在阅读时做的笔记,供自己以后查看。

预备知识

几种常见的模式:

  • SSR: Server-Side Rendering - rendering a client-side or universal app to HTML on the server.
  • CSR: Client-Side Rendering - rendering an app in a browser, generally using the DOM.
  • Rehydration: “booting up” JavaScript views on the client such that they reuse the server-rendered HTML’s DOM tree and data.
  • Prerendering: running a client-side application at build time to capture its initial state as static HTML.

在开启渲染模式对比之前,了解几个有意义的性能对比参数:

  • TTFB: Time to First Byte - seen as the time between clicking a link and the first bit of content coming in.
  • FP: First Paint - the first time any pixel gets becomes visible to the user.
  • FCP: First Contentful Paint - the time when requested content (article body, etc) becomes visible.
  • TTI: Time To Interactive - the time at which a page becomes interactive (events wired up, etc).

渲染模式

SSR 服务端渲染

Server-Side Rendering - 就是服务端渲染出HTML页面,比如以前的JSP,PHP

20200425163137

除了TTFB会延长(服务端需要去准备相应的页面数据),其他三个性能参数都比较客观;

优点是更好的性能数据,客户端压力更小,利于SEO;缺点是对服务端性能要求更高,压力较大;页面切换无法渐进式加载,每个页面都需要重新渲染,页面切换时不能定义过渡动画(间隔有白屏,Chrome 在同域名页面跳转时,有一个PLS优化,即延迟到下一个页面FCP节点时开始下一个页面的渲染);

在2014年以前,更多网站是以SSR的形式,随着前端职业化和JS技术的不断发展,纯SSR正在逐渐淡出历史舞台。

Static Rendering 静态页面渲染(古老的客户端渲染方式)

静态渲染就是直接用已经成型的html文件进行渲染,会有一些辅助的JS来增强页面交互;比如Hexo模板引擎生成的文章Html,当然很多公司也还存在页面内容纯手工的做法,这种渲染适合交互少的一些官方展示性网站;
20200425170027
静态渲染的优点:

  • 性能参数都比较优异,TTFB 和 FP 和 FCP几乎相同;
  • 适合CDN部署;
  • 客户端与服务端压力都比较小;

静态渲染的缺点之一是必须为每个可能的URL生成单独的HTML文件。 当您无法提前预测这些URL的URL或具有大量唯一页面的网站时,这可能具有挑战性甚至不可行, 如果是纯手工开发,那开发效率相对也比较低。

但从工作几年的经验来看,这种纯静态渲染的网站除了一些文档,博客类网站,更多是借助JS技术来提高页面的可交互能力。

静态渲染与SSR渲染(或则服务端预渲染)很像,可以通过禁用js 和 降低网速来甄别网站是采用的静态渲染技术还是预渲染技术;

CSR 客户端渲染

Client-side rendering (CSR) ,一种纯在客户端(浏览器)利用JS操作Dom渲染页面的方式. 所有的生成逻辑, 数据获取, 模板 and 路由 都由浏览器而不是服务端来控制。
20200425170438
除了TTFB短,其他都会被延长,可以通过Http2的服务端推送与<link rel=preload>来提升FCP;

客户端渲染是当前最流行的渲染模式,常以SPA单页面应用的方式存在;因为有React,Vue, Angular这种热门UI框架支持,加上Webpack的构建打包,开发效率相比前两种方式高出很多,页面可做很多复杂的交互操作与图形渲染

由于JS和CSS的大小会影响首屏的渲染,所以最好做好代码分割,只提供页面渲染必要的资源代码,应用懒加载的形式来提供其余的资源时非常有必要的;

骨架屏渲染(CSR with Prerendering)在当代也是一种比较时髦的技术
,GatsBy引擎就是这种技术的突出使用者。就是会先通过服务端渲染出一个大致的骨架,告诉用户网站已经对你的请求有响应了,但其实这个时候只能看到一个很模糊的布局且不能交互,得等到页面数据请求回来后,页面才开始正式的渲染。

Rehydration 同构渲染

同构其实就是SSR+CSR的合体。首屏的html页面由服务端提供,然后加载js,js利用现有的dom树来接管渲染后页面的交互操作,跳转到新页面时就变成纯CSR渲染,是一种比较有技术含量的渲染方式,当下比较流行的NextJs(React), NuxtJs(Vue)就是这种渲染技术的成熟框架;

20200425173953

从上图可看,TTFB,FP和SSR几乎一样,FCP会由于js的下载解析变长。页面看起来很快被渲染出来(直出),且看起可交互,但实际上这时候还不能马上响应时间,因为要等到客户端JS执行,事件监听启动后,输入这些交互操作才可用,所以TTI相比SSR也会变长。

优点:利于SEO,同时结合了SSR与CSR的特点,首屏之后的页面交互可实现渐进式加载,可控性高;
缺点:

  • 技术要求更高(包含代码处理),同时对服务器和客户端都有性能要求;
  • React过去提供的服务端html生成方法renderToString是同步的,这回阻塞Node服务主线程;但后面推出了异步的renderToNodeStream,服务端压力相对而言就没那么大了

我自己在公司的两个项目做过同构尝试,都是基于Dva的React 同构渲染项目,感兴趣的话,可作为参考入门。

对于同构渲染可以优化的点,自己的总结是:

  • JS,CSS等页面静态资源文件,最好还是单独部署走CDN;
  • 交互JS 在页面交互要求不高的情况下,可以设置async或则deffer标志

其他

没咋理解,想了解最好看原文

  • Partial Rehydration 部分同构渲染:顾名思义就是页面的部分组件或试图采用渐进式同构的方式加载,主要目的是减少页面对客户端JS的依赖;
  • Trisomorphic Rendering,三态渲染:这种渲染技术仅适用于service workers可用的时候,大致意思是,首先由服务端提供一个初始文件流,然后service workers接管将文件流渲染出一个html文件,并开始在页面渲染,同时服务端也在生成可用的页面;下图是其提供的思路:

20200425231613

贴出原文:
If service workers are an option for you, “trisomorphic” rendering may also be of interest. It's a technique where you can use streaming server rendering for initial/non-JS navigations, and then have your service worker take on rendering of HTML for navigations after it has been installed. This can keep cached components and templates up to date and enables SPA-style navigations for rendering new views in the same session. This approach works best when you can share the same templating and routing code between the server, client page, and service worker

总结

在决定渲染方法时,请思考并了解您应用的瓶颈在哪;考虑静态渲染还是服务器渲染是否可以帮助您达到90%的效果;完全可以以最少的JS来配合HTML来获得交互体验也是完全可以的,下面是一个全文总结性的图表:
20200425233348

首发于我的博客:Web网页渲染的几种模式,转载请注明

(https://github.com/closertb/c...

查看原文

赞 11 收藏 7 评论 0

Denzel 收藏了文章 · 4月23日

【阿里前端面试点】目标,想成为一名好的前端工程师

介绍

狭义的来讲,前端指的就是我们常说的html, css, javascript. 三者必不可缺. 而其中涵盖的知识点不可一篇文章就能完整的讲述出来的。广义的定位,涉及到浏览器,手机App里面的用户交互展示的内容,都属于前端。

知识点

  • HTML
  • CSS 布局(流式布局, 栅格布局,弹性布局) flex布局介绍
  • CSS 过渡及动画, 继承与特殊性
  • LESS, SASS, PostCSS
  • JavaScript
  • Node.js 工具,服务,部署
  • 浏览器/手机调试, 抓包工具(JSFiddle, Charles, Whistle)
  • 手机适配, 前端性能优化策略
  • Chrome Debug DevTool 使用
  • Canvas, SVG 的原理
  • HTTP, HTTPS, HTTP 2.0 协议
  • React, Vue.js 框架原理

前端问题

  • 介绍一下ES5的 defineProperty

    • 设置 enumerable: true后,如何获取可枚举的key
    • 设置 enumerable: false后, 什么样的方式检测key存在与对象中
  • 简单介绍下盒模型,以及flexbox 弹性布局
  • CSS3 有哪些新特性
  • HTML5 有哪些新特性及API
  • 描述一下HTTP 协议缓存机制
  • Canvas的实现原理
  • 前端跨域 解决方式有哪些
  • Cookie, session, 本地存储
  • Ajax的工作流程
  • throttledebounce 的使用场景
  • 事件委托机制以及实现方式
  • 简单介绍一下函数闭包
  • 导致内存泄露的有哪些
  • 简单介绍一下原型链的实现方式
  • 如何实现预加载,懒加载

技术实现问题

  • 将一个驼峰式变量转换为下划线变量
  • 将一个表格相同元素进行单元格合并

框架问题

  • MVVM的双向绑定原理是什么
  • 如何更优雅的实现双向绑定
  • Vue.jscomputed 计算属性的实现
  • Vue.js 组件之间数据通信的方式有哪些 (vuex, 父子通信)
  • proxy数据代理的实现
  • vue-router的实现机制是什么
  • 形容描述下 VNode 以及 diff算法
  • v-for 循环中 key 起到了什么样的作用

ES6问题

  • let, const 块作用域如何被转化的. 如果自己转化,请介绍下你的实现方法
  • 箭头函数的作用域上下文普通函数作用域上下文 的区别
  • ES6模块加载机制
  • 介绍下ES6的新特性给你带来了哪些变化

打包工具问题

  • 简单介绍下webpack的工作原理
  • webpack的基本配置有哪些
  • grunt, gulp, webpack三者的区别

Node.js 问题

  • require的模块系统加载方式是什么
  • npm包管理工具介绍, 如何写一个npm模块
  • setTimeout, setImmediate, nextTick 三种定时器的区别
  • PromiseGeneratorAsync/Await 三者的关联
  • pm2, forever 模块的工作原理
  • express, koa 框架的区别
  • Node.js 核心模块有哪些
  • Node.js 多进程部署的原理

测试问题

  • 前端单元自动化测试框架有哪些 (mocha, jasmine, QUnit)
  • 持续集成, 集成测试的意义
  • BDDTDD的区别
  • Node.js前端的调试方式

额外知识

  • websocket工作原理, 以及建立连接方式
  • https, http2.0 知识介绍
  • git命令的使用, 介绍一下git flow工作流
  • 有一个192.168.0.1的 IP, 如何使用一个Int变量存储对应的信息
  • 简单介绍一下三次握手, 和四次挥手的过程

小结

永远记住要多动手,动脑把学到的东西写下来,加深记忆。对自己有好处. 因为多次和阿里的面试官进行了电话面试沟通,所以这些不只是一个面试官提出的问题,而是多个面试官提出的问题。 希望大家能够在闲暇的时间里,将自己的技术不断提高。保持一个虚心学习的状态。

查看原文

Denzel 赞了文章 · 4月23日

我在阿里招前端,我该怎么帮你?

我是谁?为什么写这篇文章?

我是淘宝技术部的一名普通的前端技术专家,花名磐冲。每年都想给团队内招几个同学,但是努力了几年,一个都没有招进来。是我看简历太少了吗?不是,只算内部简历系统,我看过的简历至少上千。是我要求太严格吗?也许是吧,不过,我电话面试拒绝的同学,只有1位在一段时间后,入职了另一个部门。

好吧,我承认,我自己在招聘上可能是有点没找到方法。但是,看了那么多简历,经历了那么多次面试,我最大的感受却是惋惜。因为有好多同学,在电话那头我听出了努力,听出了能力,听出了激情,但是却没有听到亮点、和让我觉得,能够继续闯过下一关的能力。

我面试过的同学,在结束的时候,我都会指出问题,并给出学习建议。大部分同学不是不够努力,不是不够聪明,而是没有找对方法,没有切中要害。我总结了一下之前所有的面试经历,以及常见的问题,写下这篇文章,希望能够给前端的同学,不论是否来面试阿里的职位,有一个参考。同时,也是写下我自己总结的方法,希望能帮助到其他技术相关的同学。

我们想要的同学

JD

业务背景

淘宝内部最大创新项目之一,大团队已有百人规模,大部分项目处于保密阶段,前景远大

职位描述

1.负责组件库与业务页面开发。
2.带领团队完成技术产品实现。
3.负责大型多应用架构设计。
4.利用前端技术与服务端协同完成团队业务目标。

职位要求

0.掌握图形学,webgl或熟练使用threejs框架,熟练canvas相关渲染及动画操作的优先。
1.熟练掌握JavaScript。
2.熟悉常用工程化工具,掌握模块化思想和技术实现方案。
3.熟练掌握React前端框架,了解技术底层。同时了解vue以及angular等其他框架者优先。
4.熟练掌握react生态常用工具,redux/react-router等。
5.熟悉各种Web前端技术,包括HTML/XML/CSS等,有基于Ajax的前端应用开发经验。
6.有良好的编码习惯,对前端技术有持续的热情,个性乐观开朗,逻辑性强,善于和各种背景的人合作。
7.具有TS/移动设备上前端开发/NodeJS/服务端开发等经验者优先。

翻译一下JD

为什么起这个标题呢?因为有很多人看到职位描述,可能就在和自己做的事情一一比对,把关键字都核对上。而很多前端同学看到职位要求第一条里的图形学,可能就开始打退堂鼓了。或者看到几个关键字自己都认识,就觉得没问题,还挺简单的。

就这样望而却步真的好吗?为什么职位描述看着简单,面试却这么难呢?你真的读懂这份职位描述了吗?

现在,不妨先停一下,就上面的问题,我们来细细品一下。什么叫读懂职位描述呢?从我个人的理解,读懂职位描述,应该是读懂这个职位需要哪些基础能力,以及可能遇到哪些挑战。我们写自己简历的时候,“精通react”和“熟练使用react”,相信大家不会随意去写。同样的,JD里面的:掌握、熟练掌握、了解、熟悉,也不是随意写的,这代表了团队对新同学的能力要求。

回想写自己简历的时候,我们会对这个前缀扪心自问一下。因为会担心一旦写了精通,面试官的问题会更难,甚至觉得只有源码倒背如流的人,才能称得上精通。当然也会有同学非常自信,用react做过几个项目,就写上了精通react。

这两种都可以称为精通,也都不可以。没有客观标准,又怎么去衡量呢?而标准在哪里呢?所以在这里,我从阿里面试官角度,给出我认为的标准,尽可能的做到客观可量化。那么,基于上面这份职位标准,我来翻译一下职位要求:

首先,总览全部的要求,会发现这个职位虽然提到了3d相关的技能,但是大部分却是应用开发相关的能力,所以这个职位并不是想找专业的3d领域同学,而是需要一个工程化能力强,对3d有了解的同学。

0.掌握图形学,webgl或熟练使用threejs框架,熟练canvas相关渲染及动画操作的优先。

初级:

  • 学习过图形学相关知识,知道矩阵等数学原理在动画中的作用,知道三维场景需要的最基础的构成,能用threejs搭3d场景,知道webgl和threejs的关系。
  • 知道canvas是干嘛的,聊到旋转能说出canvas的api。
  • 知道css动画,css动画属性知道关键字和用法(换句话说,电话面试会当场出题要求口喷css动画,至少能说对大概,而不是回答百度一下就会用)。
  • 知道js动画,能说出1~2个社区js动画库,知道js动画和css动画优缺点以及适用场景。
  • 知道raf和其他达到60fps的方法。

中级:

  • 如果没有threejs,你也能基于webgl自己封装一个简单的threejs出来。
  • 聊到原理能说出四元数,聊到鼠标操作能提到节流,聊到性能能提到restore,聊到帧说出raf和timeout的区别,以及各自在优化时候的作用。
  • 知道怎样在移动端处理加载问题,渲染性能问题。
  • 知道如何结合native能力优化性能。
  • 知道如何排查性能问题。对chrome动画、3d、传感器调试十分了解。

高级:

  • 搭建过整套资源加载优化方案,能说明白整体方案的各个细节,包括前端、客户端、服务端分别需要实现哪些功能点、依赖哪些基础能力,以及如何配合。
  • 设计并实现过前端动画引擎,能说明白一个复杂互动项目的技术架构,知道需要哪些核心模块,以及这些模块间如何配合。
  • 有自己实现的动画相关技术方案产出,这套技术方案必须是解决明确的业务或技术难点问题的。为了业务快速落地而封装一个库,不算这里的技术方案。如果有类似社区方案,必须能从原理上说明白和竞品的差异,各自优劣,以及技术选型的原因。
1.熟练掌握JavaScript。

初级:

  • JavaScript各种概念都得了解,《JavaScript语言精粹》这本书的目录都得有概念,并且这些核心点都能脱口而出是什么。这里列举一些做参考:
  • 知道组合寄生继承,知道class继承。
  • 知道怎么创建类function + class。
  • 知道闭包在实际场景中怎么用,常见的坑。
  • 知道模块是什么,怎么用。
  • 知道event loop是什么,能举例说明event loop怎么影响平时的编码。
  • 掌握基础数据结构,比如堆、栈、树,并了解这些数据结构计算机基础中的作用。
  • 知道ES6数组相关方法,比如forEach,map,reduce。

中级:

  • 知道class继承与组合寄生继承的差别,并能举例说明。
  • 知道event loop原理,知道宏微任务,并且能从个人理解层面说出为什么要区分。知道node和浏览器在实现loop时候的差别。
  • 能将继承、作用域、闭包、模块这些概念融汇贯通,并且结合实际例子说明这几个概念怎样结合在一起。
  • 能脱口而出2种以上设计模式的核心思想,并结合js语言特性举例或口喷基础实现。
  • 掌握一些基础算法核心思想或简单算法问题,比如排序,大数相加。
2.熟悉常用工程化工具,掌握模块化思想和技术实现方案。

初级:

  • 知道webpack,rollup以及他们适用的场景。
  • 知道webpack v4和v3的区别。
  • 脱口而出webpack基础配置。
  • 知道webpack打包结果的代码结构和执行流程,知道index.js,runtime.js是干嘛的。
  • 知道amd,cmd,commonjs,es module分别是什么。
  • 知道所有模块化标准定义一个模块怎么写。给出2个文件,能口喷一段代码完成模块打包和执行的核心逻辑。

中级:

  • 知道webpack打包链路,知道plugin生命周期,知道怎么写一个plugin和loader。
  • 知道常见loader做了什么事情,能几句话说明白,比如babel-loader,vue-loader。
  • 能结合性能优化聊webpack配置怎么做,能清楚说明白核心要点有哪些,并说明解决什么问题,需要哪些外部依赖,比如cdn,接入层等。
  • 了解异步模块加载的实现原理,能口喷代码实现核心逻辑。

高级:

  • 能设计出或具体说明白团队研发基础设施。具体包括但不限于:
  • 项目脚手架搭建,及如何以工具形态共享。
  • 团队eslint规范如何设计,及如何统一更新。
  • 工具化打包发布流程,包括本地调试、云构建、线上发布体系、一键部署能力。同时,方案不仅限于前端工程部分,包含相关服务端基础设施,比如cdn服务搭建,接入层缓存方案设计,域名管控等。
  • 客户端缓存及预加载方案。
3.熟练掌握React前端框架,了解技术底层。同时了解vue以及angular等其他框架者优先。

初级:

  • 知道react常见优化方案,脱口而出常用生命周期,知道他们是干什么的。
  • 知道react大致实现思路,能对比react和js控制原生dom的差异,能口喷一个简化版的react。
  • 知道diff算法大致实现思路。
  • 对state和props有自己的使用心得,结合受控组件、hoc等特性描述,需要说明各种方案的适用场景。
  • 以上几点react替换为vue或angular同样适用。

中级:

  • 能说明白为什么要实现fiber,以及可能带来的坑。
  • 能说明白为什么要实现hook。
  • 能说明白为什么要用immutable,以及用或者不用的考虑。
  • 知道react不常用的特性,比如context,portal。
  • 能用自己的理解说明白react like框架的本质,能说明白如何让这些框架共存。

高级:

  • 能设计出框架无关的技术架构。包括但不限于:
  • 说明如何解决可能存在的冲突问题,需要结合实际案例。
  • 能说明架构分层逻辑、各层的核心模块,以及核心模块要解决的问题。能结合实际场景例举一些坑或者优雅的处理方案则更佳。
4.熟练掌握react生态常用工具,redux/react-router等。

初级:

  • 知道react-router,redux,redux-thunk,react-redux,immutable,antd或同级别社区组件库。
  • 知道vue和angular对应全家桶分别有哪些。
  • 知道浏览器react相关插件有什么,怎么用。
  • 知道react-router v3/v4的差异。
  • 知道antd组件化设计思路。
  • 知道thunk干嘛用的,怎么实现的。

中级:

  • 看过全家桶源码,不要求每行都看,但是知道核心实现原理和底层依赖。能口喷几行关键代码把对应类库实现即达标。
  • 能从数据驱动角度透彻的说明白redux,能够口喷原生js和redux结合要怎么做。
  • 能结合redux,vuex,mobx等数据流谈谈自己对vue和react的异同。

高级:

  • 有基于全家桶构建复杂应用的经验,比如最近很火的微前端和这些类库结合的时候要注意什么,会有什么坑,怎么解决
5.熟悉各种Web前端技术,包括HTML/XML/CSS等,有基于Ajax的前端应用开发经验。

初级:

  • HTML方面包括但不限于:语义化标签,history api,storage,ajax2.0等。
  • CSS方面包括但不限于:文档流,重绘重排,flex,BFC,IFC,before/after,动画,keyframe,画三角,优先级矩阵等。
  • 知道axios或同级别网络请求库,知道axios的核心功能。
  • 能口喷xhr用法,知道网络请求相关技术和技术底层,包括但不限于:content-type,不同type的作用;restful设计理念;cors处理方案,以及浏览器和服务端执行流程;口喷文件上传实现;
  • 知道如何完成登陆模块,包括但不限于:登陆表单如何实现;cookie登录态维护方案;token base登录态方案;session概念;

中级:

  • HTML方面能够结合各个浏览器api描述常用类库的实现。
  • css方面能够结合各个概念,说明白网上那些hack方案或优化方案的原理。
  • 能说明白接口请求的前后端整体架构和流程,包括:业务代码,浏览器原理,http协议,服务端接入层,rpc服务调用,负载均衡。
  • 知道websocket用法,包括但不限于:鉴权,房间分配,心跳机制,重连方案等。
  • 知道pc端与移动端登录态维护方案,知道token base登录态实现细节,知道服务端session控制实现,关键字:refresh token。
  • 知道oauth2.0轻量与完整实现原理。
  • 知道移动端api请求与socket如何通过native发送,知道如何与native进行数据交互,知道ios与安卓jsbridge实现原理。

高级:

  • 知道移动端webview和基础能力,包括但不限于:iOS端uiwebview与wkwebview差异;webview资源加载优化方案;webview池管理方案;native路由等。
  • 登陆抽象层,能够给出完整的前后端对用户体系的整体技术架构设计,满足多业务形态用户体系统一。考虑跨域名、多组织架构、跨端、用户态开放等场景。
  • mock方案,能够设计出满足各种场景需要的mock数据方案,同时能说出对前后端分离的理解。考虑mock方案的通用性、场景覆盖度,以及代码或工程侵入程度。
  • 埋点方案,能够说明白前端埋点方案技术底层实现,以及技术选型原理。能够设计出基于埋点的数据采集和分析方案,关键字包括:分桶策略,采样率,时序性,数据仓库,数据清洗等。
6.有良好的编码习惯,对前端技术有持续的热情,个性乐观开朗,逻辑性强,善于和各种背景的人合作。

初级:

  • 知道eslint,以及如何与工程配合使用。
  • 了解近3年前端较重要的更新事件。
  • 面试过程中遇到答不出来的问题,能从逻辑分析上给出大致的思考路径。
  • 知道几个热门的国内外前端技术网站,同时能例举几个面试过程中的核心点是从哪里看到的。

高级:

  • 在团队内推行eslint,并给出工程化解决方案。
  • 面试过程思路清晰,面试官给出关键字,能够快速反应出相关的技术要点,但是也要避免滔滔不绝,说一堆无关紧要的东西。举例来说,当时勾股老师面试我的时候,问了我一个左图右文的布局做法,我的回答是:我自己总结过7种方案,其中比较好用的是基于BFC的,float的以及flex的三种。之后把关键css口喷了一下,然后css就面完了。
7.具有TS/移动设备上前端开发/NodeJS/服务端开发等经验者优先。
  • 根据了解的深度分初/中/高级。
  • 知道TS是什么,为什么要用TS,有TS工程化实践经验。
  • 知道移动端前端常见问题,包括但不限于:rem + 1px方案;预加载;jsbridge原理等。
  • 能说出大概的服务端技术,包括但不限于:docker;k8s;rpc原理;中后台架构分层;缓存处理;分布式;响应式编程等。

JD的要求很难吗?

首先,感谢你能看到这里,如果你是仔细看的,那么我更加感动了。而且你已经用实际行动,证明了你的学习能力和耐心。上面那么大篇幅的JD翻译,有一个问题,大家应该都有答案了:为什么职位描述看着简单,面试却这么难呢?然而,有些同学可能会嘲讽起来:写了那么多,我认识的有些阿里P6,P7也不是都会啊,大厂都是螺丝钉,也就面试时候问问,实际工作不还是if else,何况我又遇不到这些场景,我怎么可能知道。

在这里,我想严肃的说明的是:

  1. 我所认识的淘宝前端,以及我所在团队的P6同学,上面初级都能做到,中级至少覆盖60%,高级覆盖20%;P6+同学,中级覆盖80%以上,高级覆盖50%以上;P7同学高级覆盖80%以上。
  2. 我们团队的前端,每一个人都负责多个复杂业务项目(客观数据上:至少对接20+服务端接口,5个以上router配置,涉及多个用户角色的综合业务系统),以及一些通用能力,比如组件库等。不存在一个人只接一条业务线,只负责维护某几个组件这种螺丝钉式的工作。我不知道大厂都是螺丝钉的言论为什么会被复用到互联网企业,我个人感受是,如果我在阿里的工作是螺丝钉,那么我以前几份工作可能勉强算是螺纹。另外,如果你想要晋升,那么维护好这几个业务系统只是你的本职工作,晋升时请提供一些更高层面的思考和技术产出。
  3. if else也分鲜花和牛粪。有的人写的是[].reduce,而有的人写的是var temp = ''; for() { temp += 'xxx' }。另外,如果不知道原理,那么类似webpack这种明星级的技术产品,将永远与你无缘。冷静下来想想,webpack难道也只是if else吗?是,又不全是。

聪明的你应该看出来了,上面JD翻译里的初级、中级和高级,对应的就是我认为的,阿里p6/p6+/p7的能力标准,同时也是一张知识图谱。初级的要求更偏实际应用和基础原理,中级的要求是基于原理的拓展和复杂技术架构的应用,高级的要求是对跨栈、跨端,多领域结合产出综合方案的能力。而且,我们对技术的要求,都是能够与实际业务场景结合,或者能对提升工作效率有帮助的。空谈和尬想,或者只是百度来的文章,没有经过内化,那么面试过程中将被瞬间拆穿。

有时我会在boss直聘上直接打字面试,有时我也会听到面试过程中,电话那头传来键盘敲击的声音,甚至有时候我会主动让面试的同学去百度一下,或者挂电话思考一下,过15分钟再聊。我敢这么面试,因为我知道,我要的答案你查不出来,我看的是你真正理解的东西。能搜索到的,我不在乎,我也希望你去查,来为你更好的表现综合能力。

破局的方法

好了,如果看到这里,并没有把你劝退的话,那么让我们来点希望的曙光。这里用一句阿里土话来给大家一些安慰:不难,要你干嘛?

开篇我提到面试过那么多同学之后,我最大的感受是惋惜,因为有很多同学在我看来就差一点点,他有足够的个人能力,可能只是没有找到感觉。这里我例举两个比较典型的问题。

什么是亮点?

我相信这是很多同学心中的疑惑,而且事实上,我看到很多简历下面的面试记录都会写:缺乏亮点,暂不考虑。如果仔细看了上文,到这里还有这个疑惑,那么我觉得你需要再静下心来感受一下。

这里我不对亮点做明确的表述,我举一个例子来让大家更有体感一些:

A: 负责公司前端工作,使用webpack打包代码并发布线上。使用webpack配置对整体性能做优化,用happypack加快了打包速度。

B: 建设内部云构建体系,产出通用命令行指令工具;将发布、环境切换、快速回滚能力平台化,保证了线上环境稳定性;同时将研发流程量化管控,每周产出研发效能报告。

如果你是面试官,在简历的大海里看一个项目描述,什么最吸引你的眼球呢?是webpack,happypack的关键字吗?还是一句话就让你想到这件事的复杂性,和这个系统带来的巨大价值?

没有场景怎么办?

这也是很多同学经常遇到的问题。上面例举了那么多技术点,而我在的环境,前端就我一个,甚至服务端我都要写一点,哪有精力去搞这种大规模团队用到的东西?

首先,时间靠自己合理规划。我和老婆两个人自己带孩子,有两个娃,每天平均9点下班,我每天回家收拾玩具,孩子睡得晚可能需要再陪玩一下,周末我带孩子为主,但是我去年仍然白金了2个ps4的游戏。

在时间问题排除之后,我建议分三个阶段:

  1. 毕业3年以内的阶段:不用着急,你的选择很多,你可以核对上面初级的点,看自己是否都做到了,没做到就去好好学习吧,初级的技术要点对团队规模没有依赖,一个人也能做到极致。如果你所处的环境已经有2个人,可以同时关注中级和高级的点,不要觉得人少就不去尝试,放手去做,过程中会有实打实的收获。
  2. 毕业5年以内的阶段:不论你处的环境团队规模如何,请开始着眼于中级和高级相关能力,人少就不需要研发提效了吗?我在segmentFault上发的第一篇文章,是如何用travis和github做一键部署,那时候我还没有去淘宝,我所在的团队也没有用到这个能力,这篇文章是我自己的个人项目用到的。而整个过程同样涉及到了研发效能的方方面面。
  3. 毕业8年以内的阶段:请开始着眼于高级相关的技术方案产出。我以组件动态化为例,我早年维护手机淘宝的整个交易链路H5页面,所有页面的ui部分都是细粒度组件化抽离,通过配置下发页面结构的。即使一个人维护一个页面,也要竭尽所能去思考好的技术方案。这种高度动态的设计,带来的好处是,每年双十一,80%的需求交给pd自己处理就行了,剩下流转到我手上需要开发的需求,都是新增交互,或者之前抽象不足的组件。所以当时我在的团队,3个人在维护了包括手淘首页、商品详情和正逆向交易链路所有H5页面,同时还有额外精力去支持大促会场页。更好的技术思考和设计,一定能给你带来更多的可能性,而系统的优雅程度,一定不是靠业务代码的堆砌,而是作为技术核心的你,如何去思考。

我想怎么帮你

我相信每个人都是能快速成长的,只是每个人缺少的东西不同。有的人少了些脚踏实地,有的人少了些登高望远的机会,更多的人或许只是没有找到那条正确的路。

我希望这篇文章能够帮助到正在前端领域努力的人,也希望这一篇文章就能成为指路明灯之一。但同时我也深知,每个人都是不一样的,所以,我这里留下联系方式,
需要的同学可以加微信:vianvio
编组.png
加备注:前端同路人。我可以给你做模拟面试,同时给出我认为的,适合你的发展思路和建议,当然也可以帮你内推。

另外,目前我们成立了一个模拟面试群,有定期活动,可以参考 https://github.com/vianvio/FE...
欢迎有兴趣的同学来参加。

介绍一下我所在的团队

我在阿里巴巴淘宝技术部-ihome业务。目前,ihome正在深耕家居家装行业,纵向深入行业内部,希望能给行业带来一些创新。目前可对外公开的产品和业务形态有:躺平App、位于青岛和宁波的桔至生活门店。我们还有更多有趣、充满挑战和超出你想象的业务。我们期待有志之士的加入!

如果你愿意来和我们一起相信,那请发送简历过来,我们一定会一起看见!

前端简历请发送到:yefei.niuyf@alibaba-inc.com 或 lijie.slj@alibaba-inc.com
主攻3d方向的同学,简历请发送到:jiangcheng.wxd@alibaba-inc.com
java简历请发送到:xiaoxian.zzy@taobao.com 或 wuxin.sn@taobao.com
客户端简历请发送到:fangying.fy@alibaba-inc.com

或许有人会觉得奇怪,联系方式写在最后,还有多少人能看到,这里我引用马爸爸和逍遥子大佬对阿里价值观的解读,来解释一下:我们的价值观是为了帮助我们寻找同路的人。

感谢你陪我一起走到这篇文章的最后,如果你觉得这篇文章已经对你有很大帮助了,那就请我喝杯咖啡吧~

查看原文

赞 219 收藏 134 评论 20

Denzel 赞了文章 · 3月27日

从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理

前言

见解有限,如有描述不当之处,请帮忙及时指出,如有错误,会及时修正。

----------超长文+多图预警,需要花费不少时间。----------

如果看完本文后,还对进程线程傻傻分不清,不清楚浏览器多进程、浏览器内核多线程、JS单线程、JS运行机制的区别。那么请回复我,一定是我写的还不够清晰,我来改。。。

----------正文开始----------

最近发现有不少介绍JS单线程运行机制的文章,但是发现很多都仅仅是介绍某一部分的知识,而且各个地方的说法还不统一,容易造成困惑。
因此准备梳理这块知识点,结合已有的认知,基于网上的大量参考资料,
从浏览器多进程到JS单线程,将JS引擎的运行机制系统的梳理一遍。

展现形式:由于是属于系统梳理型,就没有由浅入深了,而是从头到尾的梳理知识体系,
重点是将关键节点的知识点串联起来,而不是仅仅剖析某一部分知识。

内容是:从浏览器进程,再到浏览器内核运行,再到JS引擎单线程,再到JS事件循环机制,从头到尾系统的梳理一遍,摆脱碎片化,形成一个知识体系

目标是:看完这篇文章后,对浏览器多进程,JS单线程,JS事件循环机制这些都能有一定理解,
有一个知识体系骨架,而不是似懂非懂的感觉。

另外,本文适合有一定经验的前端人员,新手请规避,避免受到过多的概念冲击。可以先存起来,有了一定理解后再看,也可以分成多批次观看,避免过度疲劳。

大纲

  • 区分进程和线程
  • 浏览器是多进程的

    • 浏览器都包含哪些进程?
    • 浏览器多进程的优势
    • 重点是浏览器内核(渲染进程)
    • Browser进程和浏览器内核(Renderer进程)的通信过程
  • 梳理浏览器内核中线程之间的关系

    • GUI渲染线程与JS引擎线程互斥
    • JS阻塞页面加载
    • WebWorker,JS的多线程?
    • WebWorker与SharedWorker
  • 简单梳理下浏览器渲染流程

    • load事件与DOMContentLoaded事件的先后
    • css加载是否会阻塞dom树渲染?
    • 普通图层和复合图层
  • 从Event Loop谈JS的运行机制

    • 事件循环机制进一步补充
    • 单独说说定时器
    • setTimeout而不是setInterval
  • 事件循环进阶:macrotask与microtask
  • 写在最后的话

区分进程和线程

线程和进程区分不清,是很多新手都会犯的错误,没有关系。这很正常。先看看下面这个形象的比喻:

- 进程是一个工厂,工厂有它的独立资源

- 工厂之间相互独立

- 线程是工厂中的工人,多个工人协作完成任务

- 工厂内有一个或多个工人

- 工人之间共享空间

再完善完善概念:

- 工厂的资源 -> 系统分配的内存(独立的一块内存)

- 工厂之间的相互独立 -> 进程之间相互独立

- 多个工人协作完成任务 -> 多个线程在进程中协作完成任务

- 工厂内有一个或多个工人 -> 一个进程由一个或多个线程组成

- 工人之间共享空间 -> 同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)

然后再巩固下:

如果是windows电脑中,可以打开任务管理器,可以看到有一个后台进程列表。对,那里就是查看进程的地方,而且可以看到每个进程的内存资源信息以及cpu占有率。

所以,应该更容易理解了:进程是cpu资源分配的最小单位(系统会给它分配内存)

最后,再用较为官方的术语描述一遍:

  • 进程是cpu资源分配的最小单位(是能拥有资源和独立运行的最小单位)
  • 线程是cpu调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程)

tips

  • 不同进程之间也可以通信,不过代价较大
  • 现在,一般通用的叫法:单线程与多线程,都是指在一个进程内的单和多。(所以核心还是得属于一个进程才行)

浏览器是多进程的

理解了进程与线程了区别后,接下来对浏览器进行一定程度上的认识:(先看下简化理解)

  • 浏览器是多进程的
  • 浏览器之所以能够运行,是因为系统给它的进程分配了资源(cpu、内存)
  • 简单点理解,每打开一个Tab页,就相当于创建了一个独立的浏览器进程。

关于以上几点的验证,请再第一张图

图中打开了Chrome浏览器的多个标签页,然后可以在Chrome的任务管理器中看到有多个进程(分别是每一个Tab页面有一个独立的进程,以及一个主进程)。
感兴趣的可以自行尝试下,如果再多打开一个Tab页,进程正常会+1以上

注意:在这里浏览器应该也有自己的优化机制,有时候打开多个tab页后,可以在Chrome任务管理器中看到,有些进程被合并了
(所以每一个Tab标签对应一个进程并不一定是绝对的)

浏览器都包含哪些进程?

知道了浏览器是多进程后,再来看看它到底包含哪些进程:(为了简化理解,仅列举主要进程)

  1. Browser进程:浏览器的主进程(负责协调、主控),只有一个。作用有

    • 负责浏览器界面显示,与用户交互。如前进,后退等
    • 负责各个页面的管理,创建和销毁其他进程
    • 将Renderer进程得到的内存中的Bitmap,绘制到用户界面上
    • 网络资源的管理,下载等
  2. 第三方插件进程:每种类型的插件对应一个进程,仅当使用该插件时才创建
  3. GPU进程:最多一个,用于3D绘制等
  4. 浏览器渲染进程(浏览器内核)(Renderer进程,内部是多线程的):默认每个Tab页面一个进程,互不影响。主要作用为

    • 页面渲染,脚本执行,事件处理等

强化记忆:在浏览器中打开一个网页相当于新起了一个进程(进程内有自己的多线程)

当然,浏览器有时会将多个进程合并(譬如打开多个空白标签页后,会发现多个空白标签页被合并成了一个进程),如图

另外,可以通过Chrome的更多工具 -> 任务管理器自行验证

浏览器多进程的优势

相比于单进程浏览器,多进程有如下优点:

  • 避免单个page crash影响整个浏览器
  • 避免第三方插件crash影响整个浏览器
  • 多进程充分利用多核优势
  • 方便使用沙盒模型隔离插件等进程,提高浏览器稳定性

简单点理解:如果浏览器是单进程,那么某个Tab页崩溃了,就影响了整个浏览器,体验有多差;同理如果是单进程,插件崩溃了也会影响整个浏览器;而且多进程还有其它的诸多优势。。。

当然,内存等资源消耗也会更大,有点空间换时间的意思。

重点是浏览器内核(渲染进程)

重点来了,我们可以看到,上面提到了这么多的进程,那么,对于普通的前端操作来说,最终要的是什么呢?答案是渲染进程

可以这样理解,页面的渲染,JS的执行,事件的循环,都在这个进程内进行。接下来重点分析这个进程

请牢记,浏览器的渲染进程是多线程的(这点如果不理解,请回头看进程和线程的区分

终于到了线程这个概念了?,好亲切。那么接下来看看它都包含了哪些线程(列举一些主要常驻线程):

  1. GUI渲染线程

    • 负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等。
    • 当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行
    • 注意,GUI渲染线程与JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起(相当于被冻结了),GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。
  2. JS引擎线程

    • 也称为JS内核,负责处理Javascript脚本程序。(例如V8引擎)
    • JS引擎线程负责解析Javascript脚本,运行代码。
    • JS引擎一直等待着任务队列中任务的到来,然后加以处理,一个Tab页(renderer进程)中无论什么时候都只有一个JS线程在运行JS程序
    • 同样注意,GUI渲染线程与JS引擎线程是互斥的,所以如果JS执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。
  3. 事件触发线程

    • 归属于浏览器而不是JS引擎,用来控制事件循环(可以理解,JS引擎自己都忙不过来,需要浏览器另开线程协助)
    • 当JS引擎执行代码块如setTimeOut时(也可来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会将对应任务添加到事件线程中
    • 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理
    • 注意,由于JS的单线程关系,所以这些待处理队列中的事件都得排队等待JS引擎处理(当JS引擎空闲时才会去执行)

  4. 定时触发器线程

    • 传说中的setIntervalsetTimeout所在线程
    • 浏览器定时计数器并不是由JavaScript引擎计数的,(因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确)
    • 因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待JS引擎空闲后执行)
    • 注意,W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms。
  5. 异步http请求线程

    • 在XMLHttpRequest在连接后是通过浏览器新开一个线程请求
    • 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由JavaScript引擎执行。

看到这里,如果觉得累了,可以先休息下,这些概念需要被消化,毕竟后续将提到的事件循环机制就是基于事件触发线程的,所以如果仅仅是看某个碎片化知识,
可能会有一种似懂非懂的感觉。要完成的梳理一遍才能快速沉淀,不易遗忘。放张图巩固下吧:

再说一点,为什么JS引擎是单线程的?额,这个问题其实应该没有标准答案,譬如,可能仅仅是因为由于多线程的复杂性,譬如多线程操作一般要加锁,因此最初设计时选择了单线程。。。

Browser进程和浏览器内核(Renderer进程)的通信过程

看到这里,首先,应该对浏览器内的进程和线程都有一定理解了,那么接下来,再谈谈浏览器的Browser进程(控制进程)是如何和内核通信的,
这点也理解后,就可以将这部分的知识串联起来,从头到尾有一个完整的概念。

如果自己打开任务管理器,然后打开一个浏览器,就可以看到:任务管理器中出现了两个进程(一个是主控进程,一个则是打开Tab页的渲染进程)
然后在这前提下,看下整个的过程:(简化了很多)

  • Browser进程收到用户请求,首先需要获取页面内容(譬如通过网络下载资源),随后将该任务通过RendererHost接口传递给Render进程
  • Renderer进程的Renderer接口收到消息,简单解释后,交给渲染线程,然后开始渲染

    • 渲染线程接收请求,加载网页并渲染网页,这其中可能需要Browser进程获取资源和需要GPU进程来帮助渲染
    • 当然可能会有JS线程操作DOM(这样可能会造成回流并重绘)
    • 最后Render进程将结果传递给Browser进程
  • Browser进程接收到结果并将结果绘制出来

这里绘一张简单的图:(很简化)

看完这一整套流程,应该对浏览器的运作有了一定理解了,这样有了知识架构的基础后,后续就方便往上填充内容。

这块再往深处讲的话就涉及到浏览器内核源码解析了,不属于本文范围。

如果这一块要深挖,建议去读一些浏览器内核源码解析文章,或者可以先看看参考下来源中的第一篇文章,写的不错

梳理浏览器内核中线程之间的关系

到了这里,已经对浏览器的运行有了一个整体的概念,接下来,先简单梳理一些概念

GUI渲染线程与JS引擎线程互斥

由于JavaScript是可操纵DOM的,如果在修改这些元素属性同时渲染界面(即JS线程和UI线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。

因此为了防止渲染出现不可预期的结果,浏览器设置GUI渲染线程与JS引擎为互斥的关系,当JS引擎执行时GUI线程会被挂起,
GUI更新则会被保存在一个队列中等到JS引擎线程空闲时立即被执行。

JS阻塞页面加载

从上述的互斥关系,可以推导出,JS如果执行时间过长就会阻塞页面。

譬如,假设JS引擎正在进行巨量的计算,此时就算GUI有更新,也会被保存到队列中,等待JS引擎空闲后执行。
然后,由于巨量计算,所以JS引擎很可能很久很久后才能空闲,自然会感觉到巨卡无比。

所以,要尽量避免JS执行时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞的感觉。

WebWorker,JS的多线程?

前文中有提到JS引擎是单线程的,而且JS执行时间过长会阻塞页面,那么JS就真的对cpu密集型计算无能为力么?

所以,后来HTML5中支持了Web Worker

MDN的官方解释是:

Web Worker为Web内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面

一个worker是使用一个构造函数创建的一个对象(e.g. Worker()) 运行一个命名的JavaScript文件 

这个文件包含将在工作线程中运行的代码; workers 运行在另一个全局上下文中,不同于当前的window

因此,使用 window快捷方式获取当前全局的范围 (而不是self) 在一个 Worker 内将返回错误

这样理解下:

  • 创建Worker时,JS引擎向浏览器申请开一个子线程(子线程是浏览器开的,完全受主线程控制,而且不能操作DOM)
  • JS引擎线程与worker线程间通过特定的方式通信(postMessage API,需要通过序列化对象来与线程交互特定的数据)

所以,如果有非常耗时的工作,请单独开一个Worker线程,这样里面不管如何翻天覆地都不会影响JS引擎主线程,
只待计算出结果后,将结果通信给主线程即可,perfect!

而且注意下,JS引擎是单线程的,这一点的本质仍然未改变,Worker可以理解是浏览器给JS引擎开的外挂,专门用来解决那些大量计算问题。

其它,关于Worker的详解就不是本文的范畴了,因此不再赘述。

WebWorker与SharedWorker

既然都到了这里,就再提一下SharedWorker(避免后续将这两个概念搞混)

  • WebWorker只属于某个页面,不会和其他页面的Render进程(浏览器内核进程)共享

    • 所以Chrome在Render进程中(每一个Tab页就是一个render进程)创建一个新的线程来运行Worker中的JavaScript程序。
  • SharedWorker是浏览器所有页面共享的,不能采用与Worker同样的方式实现,因为它不隶属于某个Render进程,可以为多个Render进程共享使用

    • 所以Chrome浏览器为SharedWorker单独创建一个进程来运行JavaScript程序,在浏览器中每个相同的JavaScript只存在一个SharedWorker进程,不管它被创建多少次。

看到这里,应该就很容易明白了,本质上就是进程和线程的区别。SharedWorker由独立的进程管理,WebWorker只是属于render进程下的一个线程

简单梳理下浏览器渲染流程

本来是直接计划开始谈JS运行机制的,但想了想,既然上述都一直在谈浏览器,直接跳到JS可能再突兀,因此,中间再补充下浏览器的渲染流程(简单版本)

为了简化理解,前期工作直接省略成:(要展开的或完全可以写另一篇超长文)

- 浏览器输入url,浏览器主进程接管,开一个下载线程,
然后进行 http请求(略去DNS查询,IP寻址等等操作),然后等待响应,获取内容,
随后将内容通过RendererHost接口转交给Renderer进程

- 浏览器渲染流程开始

浏览器器内核拿到内容后,渲染大概可以划分成以下几个步骤:

  1. 解析html建立dom树
  2. 解析css构建render树(将CSS代码解析成树形的数据结构,然后结合DOM合并成render树)
  3. 布局render树(Layout/reflow),负责各元素尺寸、位置的计算
  4. 绘制render树(paint),绘制页面像素信息
  5. 浏览器会将各层的信息发送给GPU,GPU会将各层合成(composite),显示在屏幕上。

所有详细步骤都已经略去,渲染完毕后就是load事件了,之后就是自己的JS逻辑处理了

既然略去了一些详细的步骤,那么就提一些可能需要注意的细节把。

这里重绘参考来源中的一张图:(参考来源第一篇)

load事件与DOMContentLoaded事件的先后

上面提到,渲染完毕后会触发load事件,那么你能分清楚load事件与DOMContentLoaded事件的先后么?

很简单,知道它们的定义就可以了:

  • 当 DOMContentLoaded 事件触发时,仅当DOM加载完成,不包括样式表,图片。

(譬如如果有async加载的脚本就不一定完成)

  • 当 onload 事件触发时,页面上所有的DOM,样式表,脚本,图片都已经加载完成了。

(渲染完毕了)

所以,顺序是:DOMContentLoaded -> load

css加载是否会阻塞dom树渲染?

这里说的是头部引入css的情况

首先,我们都知道:css是由单独的下载线程异步下载的。

然后再说下几个现象:

  • css加载不会阻塞DOM树解析(异步加载时DOM照常构建)
  • 但会阻塞render树渲染(渲染时需等css加载完毕,因为render树需要css信息)

这可能也是浏览器的一种优化机制。

因为你加载css的时候,可能会修改下面DOM节点的样式,
如果css加载不阻塞render树渲染的话,那么当css加载完之后,
render树可能又得重新重绘或者回流了,这就造成了一些没有必要的损耗。
所以干脆就先把DOM树的结构先解析完,把可以做的工作做完,然后等你css加载完之后,
在根据最终的样式来渲染render树,这种做法性能方面确实会比较好一点。

普通图层和复合图层

渲染步骤中就提到了composite概念。

可以简单的这样理解,浏览器渲染的图层一般包含两大类:普通图层以及复合图层

首先,普通文档流内可以理解为一个复合图层(这里称为默认复合层,里面不管添加多少元素,其实都是在同一个复合图层中)

其次,absolute布局(fixed也一样),虽然可以脱离普通文档流,但它仍然属于默认复合层

然后,可以通过硬件加速的方式,声明一个新的复合图层,它会单独分配资源
(当然也会脱离普通文档流,这样一来,不管这个复合图层中怎么变化,也不会影响默认复合层里的回流重绘)

可以简单理解下:GPU中,各个复合图层是单独绘制的,所以互不影响,这也是为什么某些场景硬件加速效果一级棒

可以Chrome源码调试 -> More Tools -> Rendering -> Layer borders中看到,黄色的就是复合图层信息

如下图。可以验证上述的说法

如何变成复合图层(硬件加速)

将该元素变成一个复合图层,就是传说中的硬件加速技术

  • 最常用的方式:translate3dtranslateZ
  • opacity属性/过渡动画(需要动画执行的过程中才会创建合成层,动画没有开始或结束后元素还会回到之前的状态)
  • will-chang属性(这个比较偏僻),一般配合opacity与translate使用(而且经测试,除了上述可以引发硬件加速的属性外,其它属性并不会变成复合层),

作用是提前告诉浏览器要变化,这样浏览器会开始做一些优化工作(这个最好用完后就释放)

  • <video><iframe><canvas><webgl>等元素
  • 其它,譬如以前的flash插件

absolute和硬件加速的区别

可以看到,absolute虽然可以脱离普通文档流,但是无法脱离默认复合层。
所以,就算absolute中信息改变时不会改变普通文档流中render树,
但是,浏览器最终绘制时,是整个复合层绘制的,所以absolute中信息的改变,仍然会影响整个复合层的绘制。
(浏览器会重绘它,如果复合层中内容多,absolute带来的绘制信息变化过大,资源消耗是非常严重的)

而硬件加速直接就是在另一个复合层了(另起炉灶),所以它的信息改变不会影响默认复合层
(当然了,内部肯定会影响属于自己的复合层),仅仅是引发最后的合成(输出视图)

复合图层的作用?

一般一个元素开启硬件加速后会变成复合图层,可以独立于普通文档流中,改动后可以避免整个页面重绘,提升性能

但是尽量不要大量使用复合图层,否则由于资源消耗过度,页面反而会变的更卡

硬件加速时请使用index

使用硬件加速时,尽可能的使用index,防止浏览器默认给后续的元素创建复合层渲染

具体的原理时这样的:
**webkit CSS3中,如果这个元素添加了硬件加速,并且index层级比较低,
那么在这个元素的后面其它元素(层级比这个元素高的,或者相同的,并且releative或absolute属性相同的),
会默认变为复合层渲染,如果处理不当会极大的影响性能**

简单点理解,其实可以认为是一个隐式合成的概念:如果a是一个复合图层,而且b在a上面,那么b也会被隐式转为一个复合图层,这点需要特别注意

另外,这个问题可以在这个地址看到重现(原作者分析的挺到位的,直接上链接):

http://web.jobbole.com/83575/

从Event Loop谈JS的运行机制

到此时,已经是属于浏览器页面初次渲染完毕后的事情,JS引擎的一些运行机制分析。

注意,这里不谈可执行上下文VOscop chain等概念(这些完全可以整理成另一篇文章了),这里主要是结合Event Loop来谈JS代码是如何执行的。

读这部分的前提是已经知道了JS引擎是单线程,而且这里会用到上文中的几个概念:(如果不是很理解,可以回头温习)

  • JS引擎线程
  • 事件触发线程
  • 定时触发器线程

然后再理解一个概念:

  • JS分为同步任务和异步任务
  • 同步任务都在主线程上执行,形成一个执行栈
  • 主线程之外,事件触发线程管理着一个任务队列,只要异步任务有了运行结果,就在任务队列之中放置一个事件。
  • 一旦执行栈中的所有同步任务执行完毕(此时JS引擎空闲),系统就会读取任务队列,将可运行的异步任务添加到可执行栈中,开始执行。

看图:

看到这里,应该就可以理解了:为什么有时候setTimeout推入的事件不能准时执行?因为可能在它推入到事件列表时,主线程还不空闲,正在执行其它代码,
所以自然有误差。

事件循环机制进一步补充

这里就直接引用一张图片来协助理解:(参考自Philip Roberts的演讲《Help, I'm stuck in an event-loop》)

上图大致描述就是:

  • 主线程运行时会产生执行栈,

栈中的代码调用某些api时,它们会在事件队列中添加各种事件(当满足触发条件后,如ajax请求完毕)

  • 而栈中的代码执行完毕,就会读取事件队列中的事件,去执行那些回调
  • 如此循环
  • 注意,总是要等待栈中的代码执行完毕后才会去读取事件队列中的事件

单独说说定时器

上述事件循环机制的核心是:JS引擎线程和事件触发线程

但事件上,里面还有一些隐藏细节,譬如调用setTimeout后,是如何等待特定时间后才添加到事件队列中的?

是JS引擎检测的么?当然不是了。它是由定时器线程控制(因为JS引擎自己都忙不过来,根本无暇分身)

为什么要单独的定时器线程?因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确,因此很有必要单独开一个线程用来计时。

什么时候会用到定时器线程?当使用setTimeoutsetInterval,它需要定时器线程计时,计时完成后就会将特定的事件推入事件队列中。

譬如:

setTimeout(function(){
    console.log('hello!');
}, 1000);

这段代码的作用是当1000毫秒计时完毕后(由定时器线程计时),将回调函数推入事件队列中,等待主线程执行

setTimeout(function(){
    console.log('hello!');
}, 0);

console.log('begin');

这段代码的效果是最快的时间内将回调函数推入事件队列中,等待主线程执行

注意:

  • 执行结果是:先beginhello!
  • 虽然代码的本意是0毫秒后就推入事件队列,但是W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms。

(不过也有一说是不同浏览器有不同的最小时间设定)

  • 就算不等待4ms,就算假设0毫秒就推入事件队列,也会先执行begin(因为只有可执行栈内空了后才会主动读取事件队列)

setTimeout而不是setInterval

用setTimeout模拟定期计时和直接用setInterval是有区别的。

因为每次setTimeout计时到后就会去执行,然后执行一段时间后才会继续setTimeout,中间就多了误差
(误差多少与代码执行时间有关)

而setInterval则是每次都精确的隔一段时间推入一个事件
(但是,事件的实际执行时间不一定就准确,还有可能是这个事件还没执行完毕,下一个事件就来了)

而且setInterval有一些比较致命的问题就是:

  • 累计效应(上面提到的),如果setInterval代码在(setInterval)再次添加到队列之前还没有完成执行,

就会导致定时器代码连续运行好几次,而之间没有间隔。
就算正常间隔执行,多个setInterval的代码执行时间可能会比预期小(因为代码执行需要一定时间)

  • 譬如像iOS的webview,或者Safari等浏览器中都有一个特点,在滚动的时候是不执行JS的,如果使用了setInterval,会发现在滚动结束后会执行多次由于滚动不执行JS积攒回调,如果回调执行时间过长,就会非常容器造成卡顿问题和一些不可知的错误(这一块后续有补充,setInterval自带的优化,不会重复添加回调)
  • 而且把浏览器最小化显示等操作时,setInterval并不是不执行程序,

它会把setInterval的回调函数放在队列中,等浏览器窗口再次打开时,一瞬间全部执行时

所以,鉴于这么多但问题,目前一般认为的最佳方案是:用setTimeout模拟setInterval,或者特殊场合直接用requestAnimationFrame

补充:JS高程中有提到,JS引擎会对setInterval进行优化,如果当前事件队列中有setInterval的回调,不会重复添加。不过,仍然是有很多问题。。。

事件循环进阶:macrotask与microtask

这段参考了参考来源中的第2篇文章(英文版的),(加了下自己的理解重新描述了下),
强烈推荐有英文基础的同学直接观看原文,作者描述的很清晰,示例也很不错,如下:

https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/

上文中将JS事件循环机制梳理了一遍,在ES5的情况是够用了,但是在ES6盛行的现在,仍然会遇到一些问题,譬如下面这题:

console.log('script start');

setTimeout(function() {
    console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
    console.log('promise1');
}).then(function() {
    console.log('promise2');
});

console.log('script end');

嗯哼,它的正确执行顺序是这样子的:

script start
script end
promise1
promise2
setTimeout

为什么呢?因为Promise里有了一个一个新的概念:microtask

或者,进一步,JS中分为两种任务类型:macrotaskmicrotask,在ECMAScript中,microtask称为jobs,macrotask可称为task

它们的定义?区别?简单点可以按如下理解:

  • macrotask(又称之为宏任务),可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)

    • 每一个task会从头到尾将这个任务执行完毕,不会执行其它
    • 浏览器为了能够使得JS内部task与DOM任务能够有序的执行,会在一个task执行结束后,在下一个 task 执行开始前,对页面进行重新渲染
(`task->渲染->task->...`)
  • microtask(又称为微任务),可以理解是在当前 task 执行结束后立即执行的任务

    • 也就是说,在当前task任务后,下一个task之前,在渲染之前
    • 所以它的响应速度相比setTimeout(setTimeout是task)会更快,因为无需等渲染
    • 也就是说,在某一个macrotask执行完后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)

分别很么样的场景会形成macrotask和microtask呢?

  • macrotask:主代码块,setTimeout,setInterval等(可以看到,事件队列中的每一个事件都是一个macrotask)
  • microtask:Promise,process.nextTick等

__补充:在node环境下,process.nextTick的优先级高于Promise__,也就是可以简单理解为:在宏任务结束后会先执行微任务队列中的nextTickQueue部分,然后才会执行微任务中的Promise部分。

参考:https://segmentfault.com/q/1010000011914016

再根据线程来理解下:

  • macrotask中的事件都是放在一个事件队列中的,而这个队列由事件触发线程维护
  • microtask中的所有微任务都是添加到微任务队列(Job Queues)中,等待当前macrotask执行完毕后执行,而这个队列由JS引擎线程维护

(这点由自己理解+推测得出,因为它是在主线程下无缝执行的)

所以,总结下运行机制:

  • 执行一个宏任务(栈中没有就从事件队列中获取)
  • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
  • 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
  • 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
  • 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)

如图:

另外,请注意下Promisepolyfill与官方版本的区别:

  • 官方版本中,是标准的microtask形式
  • polyfill,一般都是通过setTimeout模拟的,所以是macrotask形式
  • 请特别注意这两点区别

注意,有一些浏览器执行结果不一样(因为它们可能把microtask当成macrotask来执行了),
但是为了简单,这里不描述一些不标准的浏览器下的场景(但记住,有些浏览器可能并不标准)

20180126补充:使用MutationObserver实现microtask

MutationObserver可以用来实现microtask
(它属于microtask,优先级小于Promise,
一般是Promise不支持时才会这样做)

它是HTML5中的新特性,作用是:监听一个DOM变动,
当DOM对象树发生任何变动时,Mutation Observer会得到通知

像以前的Vue源码中就是利用它来模拟nextTick的,
具体原理是,创建一个TextNode并监听内容变化,
然后要nextTick的时候去改一下这个节点的文本内容,
如下:(Vue的源码,未修改)

var counter = 1
var observer = new MutationObserver(nextTickHandler)
var textNode = document.createTextNode(String(counter))

observer.observe(textNode, {
    characterData: true
})
timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
}

对应Vue源码链接

不过,现在的Vue(2.5+)的nextTick实现移除了MutationObserver的方式(据说是兼容性原因),
取而代之的是使用MessageChannel
(当然,默认情况仍然是Promise,不支持才兼容的)。

MessageChannel属于宏任务,优先级是:MessageChannel->setTimeout
所以Vue(2.5+)内部的nextTick与2.4及之前的实现是不一样的,需要注意下。

这里不展开,可以看下https://juejin.im/post/5a1af88f5188254a701ec230

写在最后的话

看到这里,不知道对JS的运行机制是不是更加理解了,从头到尾梳理,而不是就某一个碎片化知识应该是会更清晰的吧?

同时,也应该注意到了JS根本就没有想象的那么简单,前端的知识也是无穷无尽,层出不穷的概念、N多易忘的知识点、各式各样的框架、
底层原理方面也是可以无限的往下深挖,然后你就会发现,你知道的太少了。。。

另外,本文也打算先告一段落,其它的,如JS词法解析,可执行上下文以及VO等概念就不继续在本文中写了,后续可以考虑另开新的文章。

最后,喜欢的话,就请给个赞吧!

附录

博客

初次发布2018.01.21于我个人博客上面

http://www.dailichun.com/2018/01/21/js_singlethread_eventloop.html

招聘软广

阿里巴巴钉钉商业化团队大量hc,高薪股权。机会好,技术成长空间足,业务也有很大的发挥空间!

还在犹豫什么,来吧!!!

社招(P6~P7)

职责和挑战

  1. 负责钉钉工作台。工作台是帮助企业实现数字化管理和协同的门户,是拥有亿级用户量的产品。如何保障安全、稳定、性能和体验是对我们的一大挑战。
  2. 负责开放能力建设。针对纷繁的业务场景,提供合理的开放方案,既要做到深入用户场景理解并支撑业务发展,满足企业千人千面、千行千面的诉求,又要在技术上保障用户的安全、稳定和体验。需要既要有技术抽象能力、平台架构能力,又要有业务的理解和分析能力。
  3. 开放平台基础建设。保障链路的安全和稳定。同时对如何保障用户体验有持续精进的热情和追求。

职位要求

  1. 精通HTML5、CSS3、JS(ES5/ES6)等前端开发技术
  2. 掌握主流的JS库和开发框架,并深入理解其设计原理,例如React,Vue等
  3. 熟悉模块化、前端编译和构建工具,例如webpack、babel等
  4. (加分项)了解服务端或native移动应用开发,例如nodejs、Java等
  5. 对技术有强追求,有良好的沟通能力和团队协同能力,有优秀的分析问题和解决问题的能力。

前端实习

面向2021毕业的同学

  1. 本科及以上学历,计算机相关专业
  2. 熟练掌握HTML5/CSS3/Javascript等web前端技术
  3. 熟悉至少一种常用框架,例如React、vue等
  4. 关注新事物、新技术,有较强的学习能力,有强烈求知欲和进取心
  5. 有半年以上实际项目经验,大厂加分

image.png

image.png

内推邮箱

lichun.dlc@alibaba-inc.com

简历发我邮箱,必有回应,符合要求直接走内推!!!

一对一服务,有问必答!

也可加我微信了解更多:a546684355

参考资料

查看原文

赞 790 收藏 924 评论 99