Code Monkey home page Code Monkey logo

blog's Issues

架构一个复杂前端系统需要关注哪些点

近几年的前端领域,最感慨的莫过于【变化太快】。各种**、框架、类库层出不穷,同一个需求点,通常会有多种实现方案,架构选型工作的难度与日俱增。面对每天都在产生的前端领域新词汇,不免感到心累,也难免产生廉颇老矣,尚能饭否的危机感。

无论如何,架构选型和升级的工作仍然要做,在这样的趋势和潮流下,要想对每一种流行概念都了如指掌成本很高,也没有这个必要。反倒是理清前端开发背后的需求至关重要,只要明白了前端开发需求的本质,也就很容易把散落在开源社区里的各种珍珠捡起来,串起来了。

关注点

在若干年前,大部分的web系统架构都是多页面架构,用户点击链接就会完全重新加载页面,这种架构如今在大部分场景中已经过时,所以这篇文章只针对SPA架构的应用。

接下来我会以对一个复杂Web前端系统的架构为例,分析一下都需要关注那些点,每个点上都有哪些考虑。

URL

一个web系统最终都是要通过URL来访问的,在SPA架构下,URL指代的是页面的状态,通过URL的变化来路由到另一种状态。(数据接口URL会在Server API部分介绍)因此,需要关注的点有:

  • URL的结构设计成什么样?
  • URL的结构设计的粒度到什么程度?
  • 如何监听URL的变化并触发不同状态的呈现?
  • 使用哪种history?
  • browserHistory / hashHistory / memoryHistory / ……
  • URL切换前后是否需要执行相关逻辑?
  • 哪些组件可以触发URL的变化?应该如何规范导致这一行为的写法?
  • URL传递参数应该遵循什么样的规范?如何保障参数的安全性?

Server API

WEB应用数据来源于服务端,针对不同的场景和不同的前后端架构模式,需要解决几个问题:

  • 如何针对不同的场景(开发、联调、线上)提供可靠的数据服务?
  • 如何定义接口书写规范?
  • 接口文档如何进行管理?
  • 如何能够优雅地进行前后端联调?

Data

Data主要描述页面上要显示什么信息,Data一般分为两类数据:一类是从后端Server API拉取回来的数据APIData,另一类是标识界面状态的数据State。这两类数据共同决定了界面最终呈现的状态。在实际的开发中,针对Data需要考虑以下几个点:

  • 怎么从服务器端拉取数据?
  • Ajax / Fetch / JSONP /……
  • 异步请求该如何优雅地书写和组织?
  • Promise / thunk / co / async / generator function / ……
  • 如何保证数据不被随意的篡改?应该定义怎样的数据读写规范?
  • 如何对数据进行预处理,从而实现View层渲染时需要的结构?
  • 数据该如何传递给View层?
  • 数据层是否应该独立管理?还是按照业务和其他角色代码混在一起?

View

View主要描述页面的最终呈现样式。主要关注的点有:

  • 采用什么技术来进行页面渲染?
  • 模板引擎
  • 虚拟dom
  • UI组件
  • 页面的初始状态如何定义和渲染?
  • 页面上的交互如何处理?
  • 页面的样式如何定义和使用?
  • 如何保证样式只针对当前页面生效?
  • 如何更优雅地书写和管理css样式资源?
  • less / sass /postcss / css module /……
  • 集中管理 / 就近管理 / 混合管理
  • 如何提高样式资源的可维护性?
  • View如何被创建,如何被销毁,生命周期如何管理?
  • View层代码改如何组织?独立还是和其他角色混合?独立的话,如何分拆关联?
  • 如何实现页面状态(data、url)改变时View层自动渲染刷新?

Action

Action主要描述用户或者程序对页面做了什么操作,以及操作行为的处理方法,关注点有

  • 页面存在哪些可能的交互?
  • 如何管理这些行为交互,确保行为变的可以预测、监控?是否所有的交互都需要可预测、监控?边界如何界定?
  • 哪些交互是纯粹组件内部的交互?哪些交互会影响其他组件的状态?对交互该如何分类?不同的交互类型应该采用什么样的处理规范?
  • 如何组织和书写交互响应逻辑代码?
  • 交互最终如何反映到View的更新?
  • 交互中如果存在异步请求,改如何组织代码?

Aspect

监控页面行为、搜集数据、统一错误处理、权限校验等需求

  • 我的系统中有哪些地方需要嵌入切面逻辑?
  • 如何优雅地实现切面?
  • 如何保证切面逻辑可以在任何路由场景下都保持可靠?
  • 点击链接触发路由切换
  • 手动输入url触发路由切换
  • api调用触发路由切换
  • session失效后如何处理?

AB Test

如何做针新功能进行灰度发布或者权限控制

  • 如何获取当前用户的灰度权限?
  • 对于同时存在多个灰度策略的场景如何兼顾获取权限的性能和编码体验?
  • 如何根据灰度权限来优雅地渲染页面?

组件复用

复杂web系统里组件复用是强需求,关注点在:

  • 如何决定组件的拆分粒度?
  • 如何定义和界定组件的类型?
  • 系统内复用的特定业务组件;
  • 系统间复用的通用业务组件;
  • 通用的基础组件、工具等
  • 不同类型的组件如何组织管理,便于复用?
  • 如何基于现有的技术体系制定通用组件的发布、复用规范?

工程化

如何简化开发体验,提高开发效率,解决环境搭建、服务启动、热更新、mock、测试、联调、构建、部署、性能优化等问题。

  • 如何快速地搭建项目脚手架?
  • 如何实现简单、优雅、团队统一的工程化开发体验?
  • 如何简化各种工程化配置?
  • 如何实现持续集成和持续构建?
  • 选择何种前后端上线部署方案?分离 or 合并?
  • 如何针对应用的特点做好极致的性能优化?
  • 如何实现优雅的线上、测试环境的代码调试?
  • 如何实现优雅、强大的打包构建服务?

结语

前端开发行业折腾来折腾去,其实都是围绕怎么更好地解决这些本质的问题。有些实现可能是针对某几类或者某一类问题,关注点更小的就只解决某几个问题。

比如:

  • Webpack主要针对工程化类中的打包构建问题;
  • react主要解决view层的问题;
  • redux主要解决data、view、action之间数据流转的问题;
    ……
    除了上述关注点以外,对于大的前端团队,通常还会考虑封装的问题。就是通过封装为业务开发者屏蔽很多杂乱、零碎的技术细节,提供简单、统一、好理解的API及开发工具来提高开发生产效率。

搜狗商业前端工程化蜕变之路

1. 前言

2006年yahoo前端团队发布了一篇名为《Best Practices for Speeding Up Your Web Site》的博文,在此后的十年间,这篇文章被前端开发人士奉为Web性能优化的圣经。同年8月jQuery发布了第一个版本,支持由Google在2005年使用在google map上的Ajax技术,由此开启了前端开发的一个全新时代。

借助Ajax技术带来的革命性体验,前端第一次拥有了数据,后端大部分渲染逻辑逐渐前移到浏览器,客户端浏览器负责的工作越来越多,也因此逐渐产生了那个年代的“富客户端”开发的挑战:

  1. JS体积日益庞大;
  2. JavaScript语言缺陷带来的可维护性问题
  3. 命名空间冲突问题
  4. 性能问题
  5. 等等……

前端工程化正是在这样的背景下开始萌芽的,而在早期,前端开发者还不知道这个新鲜的词语,更多的是在按照雅虎军规做性能优化;使用IIFE隔离作用域;使用YUI Compressor 对JS代码进行压缩和丑化;这些大多都是业务开发之外的工作,一般情况只有在生产环境出了问题或者提高了用户体验要求才会去做的事情。

2006到2010年间,随着前端开发的复杂度逐渐增长,JavaScript无Class的设计逐渐成为了JS代码难以维护的罪魁祸首,一时间提供了Class解决方案的各种JS框架和类库都出现在我们眼前:
YUI.js、DOJO.js、EXT.js、MooTools、prototype。那是一个百花齐放的年代,JS社区从未如此活跃。前端产品形态也早已从静态站,简单交互的动态站转变成了复杂交互的动态站。

搜狗商业平台正是在这个时代创建的。并且从此一路走来,经过了模块化、组件化时代的洗礼,
尤其是2009年Node.js的横空出世,更是为前端行业带来了极大的繁荣和发展。而这背后,确是更多的选择、更大的复杂度,也正式这个时期,迎来了前端工程化的蓬勃发展契机。

本文将会按照搜狗商业系统前端经历过的时代主线来介绍我们的架构和工程化方案的发展和蜕变。

2. Ajax 时代

起初为了求快和灵活,我们并未使用诸如 YUIEXT.js 这些大型框架,而是采用了jQuery+jQueryUI+ 自研UI组件的架构,使用多级命名空间的模块管理方式来组织代码:

sogou.dom = {};

sogou.manage.planListTable = {};
//...

上线也并未做任何的压缩、构建,js模块也并未形成统一的拆分管理规则,也存在部分模块同时写在一个文件里的情况。很快随着产品的不断迭代,功能越来越多,代码的可维护性、线上的性能都逐渐的产生问题。继续按照这种方式开发势必会导致系统维护成本越来越高,因此大家讨论决定引入当时正在兴起的模块化的开发方式。

3. 模块化时代

3.1 模块化和MVC架构升级

所谓的模块化,其实就是使用Javascript现有的语言特性,抽象出一种Javascript所没有的模块边界(类似Java这一类OOP语言,天然带有模块边界,比如一个java文件和另外一个java文件之间,是不能直接互相访问的。而JS天生就可以),让模块与模块之间的变量无法直接互相访问,必须通过模块化规范指定的方式暴露到公共环境。

模块化之前

//foo.js
var foo = 'foo';
var bar = 'bar in foo'
//bar.js
var bar = 'bar in bar';
console.log(foo, ' | ',bar);
//----output: foo | bar in bar

//index.html
<script src="foo.js"></script>
<script src="bar.js"></script>

模块化之后

/*a AMD module demo*/

//foo.js
define([],function(require, module, exports){
    var foo = 'foo';
    var bar = 'bar in foo';
    module.exports = {foo:foo, bar:bar};
})

//bar.js
define(['./foo'], function(require, module, exports){
    var Foo = require('./foo');
    var bar = 'bar in bar';
    
    module.export = function(){
        console.log(Foo.foo, ' | ',Foo.bar);
    }
})

//index.html
require('./bar', function(bar){
    bar();
    //----output: foo | bar in foo
})

模块化带来了js文件之间的访问边界,一个模块就像是一个黑盒,只暴露出必要的接口即可,各模块内部的逻辑不会互相产生任何影响。这无疑带来了非常好的代码管理的模式:

按照模块拆分文件,思考模块哪些要对外开放,哪些要对外封闭

除此之外,模块化的好处还体现在了两个方面:

  1. 解决散落在各个非模块js文件中的命名空间冲突的问题,如上述代码的bar变量
  2. 解决了模块的依赖加载问题,避免了人肉管理script标签的顺序问题

第一个方面是通过 模块化规范 来解决的,诸如AMDCMD这些浏览器端模块化规范都要求模块必须包裹在一个define函数参数中声明的一个factory function中,以便实现模块的延迟执行(不会一下载到浏览器就被执行)和私有空间(factory function里声明的变量默认都是private的,只有通过exports暴露出来的才是public的)。

第二个方面是通过遵循某种模块化规范创建的模块加载器(Loader)来实现的,最富盛名的AMD规范的加载器实现是require.js;国内程序员所熟知的CMD规范的加载器便是sea.js

模块化带来了优势非常明显,但同时也带来了相应的问题:

模块的书写规范问题:模块是否严格按照规范要求书写,还是使用一些简写模式。不同的写法都可以在线上正常运行,简写的方式虽然带来了书写上的便利性,但确需要通过构建来解决运行时的模块依赖加载问题。如下所示:

//完整CMD写法
define('bar'/*module name*/, ['./foo'/*dependencies*/], function(require, module,exports){
    var Foo = require('./foo');
});

//简写
define(function(require, module, exports){
    var Foo = require('./foo');
})

这个问题通常由loader內建静态分析机制来解决,一般不需要开发者关注。但是当需要通过合并模块来减少页面请求的时候,就产生了新的问题。

模块简写在合并模块时带来的问题

上线合并的时候,模块之间的界限不再以物理文件来区分,而是合并在一个文件里。loader并不能直接处理这样的问题,因为当所有简写的模块代码被合并在一起的时候,loader并不能区分出哪部分代码属于哪个模块,因此需要做特殊的补全处理。我们在不同的项目里同时使用了seajs和require.js作为模块加载器,因此两种方案各有不同:

  • 在seajs的项目里,我们使用了seajs-combo插件,配合在服务器端Java中实现的Filter来处理页面的js请求,并根据参数对模块进行依赖提取、补全CMD的完整写法,以及合并物理文件,生成文件缓存、压缩等一系列的操作。整体架构图如下所示:

  • 在requirejs的项目里,则是通过r.js进行构建优化,通过提供一个用于执行构建的sh脚本,嵌入在Java工程编译的流程中实现。

这个时代我们的系统架构大概是下图这个样子:

我们引入老牌的MVC框架Backbone.js作为模块业务逻辑和数据处理的拆分,引入无逻辑的Mustache.js作为前端模板,使用jQuery屏蔽浏览器之间的差异。

3.2 前后端分离开发和SPA架构升级

随着Node.js的流行和普及,利用Javascript技术实现一个webserver变得异常简单。Node.js为前端开发人员提供前后端分离的契机和可能性。

在此之前,我们的系统作为一个JavaWeb工程,前端开发和后端环境是完全绑定在一起的,开发调试都需要配置Java运行环境。更糟糕的情况是:在协同开发的时候,后端经常会因为随便提交了未经自测的代码从而造成了环境无法正常启动,阻塞开发调试。因此这段时期我们决定借鉴社区的经验,实现前后端分离开发。

要进行分离开发我们首先要解决几个问题:

  1. 需要webserver发布前端页面和资源
  2. 需要mock数据模拟对后台发起的Ajax请求的响应
  3. 需要提供自动化构建方案来替代之前的线上自动化构建方案解决性能优化和上线部署的问题

前面两个问题的解决方案比较简单,如下图所示:

自己开发了一个简单的支持按照url类型进行路由的功能,并在本地提供后端接口的mock数据文件,比如一个典型的mock数据文件是这样的:

{
    "enabled": true,
    "value": "success",
    "success": {
        "flag": "0",
        "msg": [],
        "data": [{
            "id": 1,
            "name": "xx健"
        }]
    },
    "error": {
        "flag": "1",
        "msg": ["input error"],
        "data": []
    }
}

针对第3个问题,当时采用了比较流行的Grunt来做构建任务管理,引入了模块化处理、js合并压缩和混淆、css合并压缩、以及静态资源版本追加MD5版本号等插件来实现上线前的性能优化。

持续集成仍然是通过在后端Java工程中的pom文件里添加了一个前端构建的sh脚本调用的插件来实现的,最终上线仍然是和后端打成一个war包进行上线。

通过前后端分离改造,前端基本上实现了无阻塞的开发,同时也将前端代码重构成一个标准的Node.js工程,通过package.json来描述工程和管理项目的依赖,通过npm scripts脚本实现了开发、测试、构建等流程的自动化。

3.3 前端工程化架构升级

前后端分离开发为前端开发人员带来了优雅的体验,解决了一直以来技术栈耦合、环境依赖的问题,使得前端开发的效率得到大大的提升。不过,与此同时,新技术新**也带来了新的挑战:

  1. npm仓库访问不稳定的问题
  2. npm仓库发布私有包需要收费
  3. 各个产品线在mock数据和构建方案上没有统一的规范,持续集成也存在多种方案
  4. 新同学上手成本高,开发不同产品线需要学习多种方案、多种新技术
  5. 随着Node.js的发展和ES2015规范的持续完善,社区倾向于在浏览器端和服务器端都使用CommonJS规范进行模块化开发,以便在不久的将来过渡到ES2015 Module规范。

随着业务的持续迭代和人员的变更,上面的这些问题也变得日益凸显。如何在持续进化的技术架构和代码的可维护性以及开发流程的规范性之间寻找一种平衡也自然成为亟待解决的问题。

2015年初,经过大量的技术方案调研和评估,结合我们自身的系统情况和业务特点,我们做了一次比较大的工程化架构升级,着重解决当下遇到的这些问题。涉及到几个方面的工作:

  1. 搭建私有NPM仓库。解决线上机器外网隔离、访问速度、私有包发布、以及NPM生态存在的依赖版本不稳定的问题;
  2. 设计开发统一的前端脚手架工具bizdp。规范前端开发流程,统一入口和开发体验;解初始化脚手架、安装启动、mock数据、热更新、测试、构建等一系列工程化问题。
  3. 更新Mock数据方案,开发bizmock组件。提供多级自适应的mock服务,实现更加丰富的mock数据要求以及线上调试的方案。
  4. 引入Webpack,切换模块化方案到CommonJS

3.3.1 搭建私有NPM仓库

官方的镜像源一直被国内开发者诟病,主要原因就是速度太慢,网络不稳定,经常会有延迟、卡顿、失败的情况出现。对开发效率和体验的影响比较明显,虽然国内像淘宝很早就开源出国内的npm镜像源供国内开发者使用,一定程度上解决了速度的问题。但因为另外一个重要的原因,让我们必须自己搭建自己的私有NPM仓库:我们内网用于构建、部署上线的机器出于安全的原因都无法直接访问外网。

私有的仓库还可依提供更强大私有包发布功能:

{
    "name": "@bizfe/biz-dp"
}

可以把内部的公有模块全部抽离出来,统一按照规范发布到私服上。我们申请了两台硬盘比较大的机器,用于主备容灾,并制定了定期+按需镜像的策略,一定程度上可以避免类似leftpad事件这种问题的影响。

此外,我们还在上线时遇到过一个和semver机制相关的问题,我们某个项目的依赖中间接依赖了yeoman这个包:

"yeoman": "^1.0.1"

在某个时间点发布了一个bugfix版本,比如1.0.2,这个版本存在一些bug会导致项目构建失败,而仓库很及时地把1.0.2镜像过来了,正巧赶上项目已经测试通过等待上线的这个时间点,结果就遇到了上线构建失败的问题,经过反复的排查才定位到是因为semver机制和开源的人为不稳定因素导致的。虽然是小概率事件,但也反映出了npm的这个语义化版本依赖的机制所存在的潜在风险。

在如今看来,解决方案用 yarn 对依赖进行锁定再合适不过了,但在当时 npm-shrinkwrap 方案并不成熟,所以我们重新设定了私有仓库的镜像方案,延长了镜像周期,设置合理的镜像时间点避开上线高峰期,并确保测试构建和上线构建时仓库没有任何的变化。

3.3.2 开发统一脚手架工具bizdp

每一个前端团队为了提高开发效率,保障开发质量和可维护性都或多或少会指定一些规范,并开发一些工具来辅助落实这些规范。bizdp就是这样一个角色,bizdp的设计目标是前端开发人员只需要使用这一个cli工具就可以完成前端开发从0到上线的所有流程。

这中间包含了很多功能和需求:

  1. 常用的脚手架生成服务
  2. 前后端分离的项目安装启动服务
  3. 热更新服务
  4. mock数据服务
  5. 构建及测试、静态检查等功能
  6. 部署

bizdp的设计**是:“不重复造轮子,只是优雅地使用轮子”。因此任何技术架构的系统都可以很方便地接入bizdp作为团队的统一的开发接口,目前商业产品数十个前端系统都是基于bizdp进行前端开发的。

bizdp的脚手架生成服务提供了多种宿主环境(pc、移动浏览器、移动os)下数十种前端脚手架的生成服务,这些脚手架项目不仅提供了经过线上环境验证的最佳实践,而且具备统一的UI设计风格,内部项目可以基于这些脚手架实现快速的项目搭建。各团队也可以基于自己团队的架构提供适合自己的脚手架集成。

bizdp目前还在持续的迭代演进,未来将提供更加简洁优雅的使用接口,隐藏更多的非业务相关配置和功能,让开发人员只需关注开发本身即可。

3.3.3 更强大的mock组件

业界有非常丰富的前后端分离实践,自然也产出了很多mock数据的解决方案。总结起来,无非就是三大类:

  1. 静态文件

    通过node server 拦截ajax接口请求,并根据规则读取本地静态接口数据文件响应给客户端

  2. 动态模板

    通过node server拦截ajax接口请求,根据规则读取接口对应的模板文件,使用工具生成和模板对应的动态数据。

  3. 真实环境

    通过模拟登陆,实现拉取线上、QA、后端开发环境的数据返回给客户端。

这三种方案各有优缺点,也各有适用的场景:

方案 优点 缺点
静态文件 简单、灵活、高效;不依赖后端环境; 数据缺乏逻辑性,很多需要有数据联动的逻辑很难测试;接口变更需要修改文件;不方便模拟边界值情况
动态模板 模拟真实的数据场景,对边界值问题很容易模拟;不依赖环境; 编写模板有一定成本,接口变动也需要相应修改模板文件;缺乏数据逻辑性
真实环境 数据有逻辑性,易于发现问题;可以方便线上调试 环境通常滞后于开发,新接口环境通常只有在开发完成的时候才可以提供;

基于实际的mock需求,我们开发了biz-mock组件来实现这三种方案的整合:

3.3.4 webpack构建升级

起初我们只是想把模块化方案从amd和cmd迁移到commonJS,像browserify这样的包就可以解决这个需求,使用grunt+browserify理论上是个可行的方案。但鉴于grunt配置文件难以阅读和维护,其本身也仅仅是一个task runner的实现,在处理很多诸如图片缓存、模板、以及一些特殊的静态资源时不得不自己去社区寻找解决方案,而且方案的质量也参差不齐。相比之下,facebook推出的webpack的**显得技高一筹:webpack万物皆模块的**,加上强大的依赖分析和code split功能,再配上webpack-dev-server和HMR技术,无疑是复杂web前端系统的最佳选择。

因此在总结完最佳实践后,我们把所有的项目统一由grunt切到了webpack。

4. 组件化时代

每一种技术、**、框架的流行必定有其所在时代的合理性。模块化时代的洗礼让我们习惯性去思考模块之间的边界,在那个时代,无疑为我们解决了很多不必要的麻烦。然而随着业务的持续迭代,当模块数量上升到数百个甚至上千个时,而且模块之间的各种依赖变得错综复杂,行为之间的联动难以预测时,每迭代一次都如履薄冰,经常出现一些难以预料的bug。

我们尝试用 pub/sub 的设计模式来解决模块之间耦合和通信,尝试通过一些 AOP 的方案来实现复杂的监控埋点需求,尝试通过一些组件化的方案来进行界面的拼装。情况得到相当的好转,不过在实际开发过程中我们发现,约定式的规范在人员变更时、培训不充分时通常会遇到执行困境。

如今Web前端已然进入组件化时代,各种组件化的方案也都有了大量的成功实践,综合来看,React+Redux给我们提供了非常理想的解决思路,其本质仍然是pub/sub模式,只是redux更进一步,在**层面进行角色(action、reducer)和职责的拆分,使用单一对象作为整个应用的state,并在数据流转上通过单一的模式进行控制。而最关键的是,这些行为通常是在框架层面做了约束和控制的,开发人员不得不去这么做,否则应用便运行不起来,这无疑很好地解决了我们遇到的问题。

目前我们已经在部分系统中完成了react+redux的组件化升级改造,也积累了一些最佳实践,有机会再另开篇详聊吧。

这个时期,我们也在工程化方面做了一些升级:

  1. 引入babel和polyfill全面支持ES2015+stage2的语法和API
  2. 引入css-module和资源就近依赖的组件管理规范
  3. 改造了前端代码部署的流程,支持后端不停服务的纯前端上线
  4. webpack配置管理和并行构建性能优化

4.1 前后端分离部署

前后端分离部署是我们在去年开始的一个基础架构改造的项目,主要目标是为了解耦前后端工程的依赖以及上线部署的依赖问题。之前一直是和后端Java工程一期打包成war包上线,改造之后,通过热更新的机制,我们可以实现后端无需重启服务的纯前端热更新上线。改造之后,前端人员自己管理自己的代码权限,不再受限于后端的版本管理和时间安排,真正做到了“想上就能上”。架构图如下所示:

5. 后记

聊到这里,已经介绍了我们从2009年一路走来所做的一些工作,我笼统地把它们都归为前端工程化的范畴,但并未对前端工程化这个词语下个明确的定义。社区对于这个词语的定义也莫衷一是,没有一个权威的定义,因此不妨聊一聊我个人对这个词的理解:

工程化中的工程应该来源于软件工程,这个工程包含了软件的生命周期所需要经历的过程,通俗点就是指代下面这个过程:

需求分析 - 设计 - 编码 - 测试 - 交付 - 维护

而对于开发而言,更多的是指:

编码 - 测试 - 交付 - 维护

工程化是个动词,指代的是把原来刀耕火种的工作纳入正规的软件工程管理的行为。而软件工程本身要解决的本质问题是:

  1. 保障软件产品的质量、可靠性和可交付性
  2. 提高软件产品的生产效率
  3. 降低软件产品的维护成本

对应到前端开发便是一切可以提高开发效率、降低维护成本、提高开发质量以及产品使用体验的事物和行为。比如:模块化、组件化、构建编译、性能优化、编码规范、开发流程优化、工具、测试部署发布等等。

根据上述分析,斗胆下个定义:

根据具体的业务特点,将前端GUI软件的开发交付流程、以及这个过程中所涉及到的技术、工具、经验等规范化、标准化、自动化的过程就是前端工程化。其目标是让前端开发能够自成体系,最大程度地提高前端工程师的开发效率和体验,提高开发质量和产品交付效果,并降低维护成本。

循着定义可知:工程化并不是一蹴而就的工作,而是随着前端行业的不断发展需要持续进化的工作。在如今一日三变的前端文艺复兴时代,各种**、标准、框架、工具层出不穷,如同这夜晚浩瀚的星空,我们唯有低头自省,深刻认识团队所处的环境、所解决的业务特点、成员的能力结构等等才能在仰望时找到东方那颗指路明星,在前端行业的斗转星移间走出一条属于我们自己的道路。

无意识设计-复盘React Hook的创造过程


题图:杜鹃湖的秋夜 | 摄影师:郭美青

2018年的React Conf上Dan Abramov正式对外介绍了React Hook,这是一种让函数组件支持状态和其他React特性的全新方式,并被官方解读为这是下一个5年React与时俱进的开端。从中细品,可以窥见React Hook的重要性。今年2月6号,React Hook新特性随React v16.8.0版本正式发布,整个上半年React社区都在积极努力地拥抱它,学习并解读它。虽然官方声明,React Hook还在快速的发展和更新迭代过程中,很多Class Component支持的特性,React Hook还并未支持,但这丝毫不影响社区的学习热情。

React Hook上手非常简单,使用起来也很容易,但相比我们已经熟悉了5年的类组件写法,React Hook还是有一些理念和**上的转变。React团队也给出了使用Hook的一些规则eslint插件来辅助降低违背规则的概率,但规则并不是仅仅让我们去记忆的,更重要的是要去真正理解设计这些规则的原因和背景。

本文是我个人在学习React Hook的过程中,通过学习官方文档、阅读源码、浏览其他优秀同行撰写的经验文章,再结合自己的思考,通过逆向思维从React Hook希望解决的问题出发,复盘了React Hook的核心架构设计和创造的过程。非常适合希望对React Hook有更深了解,但又不愿意去读晦涩的源码的同学。

文章中的代码很多只是伪代码,重点在解读设计思路,因此并非完整的实现。很多链表的构建和更新逻辑也一并省略了,但并不影响大家了解整个React Hook的设计。事实上React Hook的大部分代码都在适配React Fiber架构的理念,这也是源码晦涩难懂的主要原因。不过没关系,我们完全可以先屏蔽掉React Fiber的存在,去一点点构建纯粹的React Hook架构。

因本人能力的局限性,文中难免有解读不正确之处,盼望大家可以交流指正(笔者github博客地址:https://github.com/shanggqm/blog)。

1 设计的背景和初衷

React Hook的产生主要是为了解决什么问题呢?官方的文档里写的非常清楚,这里只做简单的提炼,不做过多陈述,没读过文档的同学可以先移步阅读React Hook简介

总结一下要解决的痛点问题就是:

  1. 在组件之间复用状态逻辑很难
    • 之前的解决方案是:render props 和高阶组件
    • 缺点是难理解、存在过多的嵌套形成“嵌套地狱”
  2. 复杂组件变的难以理解
    • 生命周期函数中充斥着各种状态逻辑和副作用
    • 这些副作用难以复用,且很零散
  3. 难以理解的Class
    • this指针问题
    • 组件预编译技术(组件折叠)会在class中遇到优化失效的case
    • class不能很好的压缩
    • class在热重载时会出现不稳定的情况

2 设计方案

React官网有下面这样一段话:

为了解决这些问题,Hook 使你在==非 class 的情况下可以使用更多的 React 特性==。 从概念上讲,React 组件一直更像是函数。而 Hook 则拥抱了函数,同时也没有牺牲 React 的精神原则。Hook 提供了问题的解决方案,无需学习复杂的函数式或响应式编程技术

2.1 设计目标和原则

对应第一节所抛出的问题,React Hook的设计目标便是要解决这些问题,总结起来就以下四点:

  1. 无Class的复杂性
  2. 无生命周期的困扰
  3. 优雅地复用
  4. 对齐React Class组件已经具备的能力

2.2 设计方案

2.2.1 无Class的复杂性(去Class)

React 16.8发布之前,按照是否拥有状态的维护来划分的话,组件的类型主要有两种:

  1. 类组件Class Component:主要用于需要内部状态,以及包含副作用的复杂的组件
class App extends React.Component{
    constructor(props){
        super(props);
        this.state = {
            //...
        }
    }
    //...
}
  1. 函数组件Function Component:主要用于纯组件,不包含状态,相当于一个模板函数
function Footer(links){
    return (
        <footer>
            <ul>
            {links.map(({href, title})=>{
                return <li><a href={href}>{title}</a></li>
            })}
            </ul>
        </footer>
    )
}

如果设计目标是==去Class==的话,似乎选择只能落在改造Function Component,让函数组件拥有Class Component一样的能力上了。

我们不妨畅想一下最终的支持状态的函数组件代码:

// 计数器
function Counter(){
    let state = {count:0}
    
    function clickHandler(){
        setState({count: state.count+1})   
    }
    
    return (
        <div>
            <span>{count}</span>
            <button onClick={clickHandler}>increment</button>
        </div>
    )
}

上述代码使用函数组件定义了一个计数器组件Counter,其中提供了状态state,以及改变状态的setState函数。这些API对于Class component来说无疑是非常熟悉的,但在Function component中却面临着不同的挑战:

  1. class实例可以永久存储实例的状态,而函数不能,上述代码中Counter每次执行,state都会被重新赋值为0;
  2. 每一个Class component的实例都拥有一个成员函数this.setState用以改变自身的状态,而Function component只是一个函数,并不能拥有this.setState这种用法,只能通过全局的setState方法,或者其他方法来实现对应。

以上两个问题便是选择改造Function component所需要解决的问题。

2.2.1.1 解决方案

在JS中,可以存储持久化状态的无非几种方法:

  1. 类实例属性
class A(){
    constructor(){
        this.count = 0;
    }
    
    increment(){
        return this.count ++;
    }
}
const a = new A();
a.increment();
  1. 全局变量
const global = {count:0};

function increment(){
    return global.count++;
}
  1. DOM
const count = 0;
const $counter = $('#counter');
$counter.data('count', count);

funciton increment(){
    const newCount = parseInt($counter.data('count'), 10) + 1;
    $counter.data('count',newCount);
    return newCount;
}
  1. 闭包
const Counter = function(){
    let count = 0;
    return {
        increment: ()=>{
            return count ++;
        }
    }
}()

Counter.increment();
  1. 其他全局存储:indexDB、LocalStorage等等

Function component对状态的诉求只是能存取,因此似乎以上所有方案都是可行的。但作为一个优秀的设计,还需要考虑到以下几点:

  1. 使用简单
  2. 性能高效
  3. 可靠无副作用

方案2和5显然不符合第三点;方案3无论从哪一方面都不会考虑;因此闭包就成为了唯一的选择了。

2.2.1.2 闭包的实现方案

既然是闭包,那么在使用上就得有所变化,假设我们预期提供一个名叫useState的函数,该函数可以使用闭包来存取组件的state,还可以提供一个dispatch函数来更新state,并通过初始调用时赋予一个初始值。

function Counter(){
    const [count, dispatch] = useState(0)
    
    return (
        <div>
            <span>{count}</span>
            <button onClick={dispatch(count+1)}>increment</button>
        </div>
    )
}

如果用过redux的话,这一幕一定非常眼熟。没错,这不就是一个微缩版的redux单向数据流吗?

给定一个初始state,然后通过dispatch一个action,再经由reducer改变state,再返回新的state,触发组件重新渲染。

知晓这些,useState的实现就一目了然了:

function useState(initialState){
    let state = initialState;
    function dispatch = (newState, action)=>{
        state = newState;
    }
    return [state, dispatch]
}

上面的代码简单明了,但显然仍旧不满足要求。Function Component在初始化、或者状态发生变更后都需要重新执行useState函数,并且还要保障每一次useState被执行时state的状态是最新的。

很显然,我们需要一个新的数据结构来保存上一次的state和这一次的state,以便可以在初始化流程调用useState和更新流程调用useState可以取到对应的正确值。这个数据结构可以做如下设计,我们假定这个数据结构叫Hook:

type Hook = {
  memoizedState: any,   // 上一次完整更新之后的最终状态值
  queue: UpdateQueue<any, any> | null, //更新队列
};

考虑到第一次组件mounting和后续的updating逻辑的差异,我们定义两个不同的useState函数的实现,分别叫做mountStateupdateState

function useState(initialState){
    if(isMounting){
        return mountState(initialState);
    }
    
    if(isUpdateing){
        return updateState(initialState);
    }
}

// 第一次调用组件的useState时实际调用的方法
function mountState(initialState){
    let hook = createNewHook();
    hook.memoizedState = initalState;
    return [hook.memoizedState, dispatchAction]
}

function dispatchAction(action){
    // 使用数据结构存储所有的更新行为,以便在rerender流程中计算最新的状态值
    storeUpdateActions(action);
    // 执行fiber的渲染
    scheduleWork();
}

// 第一次之后每一次执行useState时实际调用的方法
function updateState(initialState){
    // 根据dispatchAction中存储的更新行为计算出新的状态值,并返回给组件
    doReducerWork();
    
    return [hook.memoizedState, dispatchAction];
}   

function createNewHook(){
    return {
        memoizedState: null,
        baseUpdate: null
    }
}

上面的代码基本上反映出我们的设计思路,但还存在两个核心的问题需要解决:

  1. 调用storeUpdateActions后将以什么方式把这次更新行为共享给doReducerWork进行最终状态的计算
  2. 同一个state,在不同时间调用mountStateupdateState时,如何实现hook对象的共享
更新逻辑的共享

更新逻辑是一个抽象的描述,我们首先需要根据实际的使用方式考虑清楚一次更新需要包含哪些必要的信息。实际上,在一次事件handler函数中,我们完全可以多次调用dispatchAction

function Count(){
    const [count, setCount] = useState(0);
    const [countTime, setCountTime] = useState(null);
    
    function clickHandler(){
        // 调用多次dispatchAction
        setCount(1);
        setCount(2);
        setCount(3);
        //...
        setCountTime(Date.now())
    }
    
    return (
    <div>
        <div>{count} in {countTime}</div>
        <button onClick={clickHandler} >update counter</button>
    </div>
    )
}

在执行对setCount的3次调用中,我们并不希望Count组件会因此被渲染3次,而是会按照调用顺序实现最后调用的状态生效。因此如果考虑上述使用场景的话,我们需要同步执行完clickHandler中所有的dispatchAction后,并将其更新逻辑顺序存储,然后再触发Fiber的re-render合并渲染。那么多次对同一个dispatchAction的调用,我们如何来存储这个逻辑呢?

比较简单的方法就是使用一个队列Queue来存储每一次更新逻辑Update的基本信息:

type Queue{
    last: Update,   // 最后一次更新逻辑
    dispatch: any,
    lastRenderedState: any  // 最后一次渲染组件时的状态
}

type Update{
    action: any,    // 状态值
    next: Update    // 下一次Update
}

这里使用了单向链表结构来存储更新队列,为什么要用单向链表而不用数组呢?这个问题应该是一道经典的数据结构的面试题,留给大家自己去思考。

有了这个数据结构之后,我们再来改动一下代码:

function mountState(initialState){
    let hook = createNewHook();
    hook.memoizedState = initalState;
    
    // 新建一个队列
    const queue = (hook.queue = {
        last: null,
        dispatch: null,
        lastRenderedState:null
    });
    
    //通过闭包的方式,实现队列在不同函数中的共享。前提是每次用的dispatch函数是同一个
    const dispatch = dispatchAction.bind(null, queue);
    return [hook.memoizedState, dispatch]
}


function dispatchAction(queue, action){
    // 使用数据结构存储所有的更新行为,以便在rerender流程中计算最新的状态值
    const update = {
        action,
        next: null
    }
    
    let last = queue.last;
    if(last === null){
        update.next = update;
    }else{
        // ... 更新循环链表
    }
    
    // 执行fiber的渲染
    scheduleWork();
}

function updateState(initialState){
    // 获取当前正在工作中的hook
    const hook = updateWorkInProgressHook();
    
    // 根据dispatchAction中存储的更新行为计算出新的状态值,并返回给组件
    (function doReducerWork(){
        let newState = null;
        do{
            // 循环链表,执行每一次更新
        }while(...)
        hook.memoizedState = newState;
    })();
     
    return [hook.memoizedState, hook.queue.dispatch];
} 

到这一步,更新逻辑的共享,我们就已经解决了。

Hook对象的共享

Hook对象是相对于组件存在的,所以要实现对象在组件内多次渲染时的共享,只需要找到一个和组件全局唯一对应的全局存储,用来存放所有的Hook对象即可。对于一个React组件而言,唯一对应的全局存储自然就是ReactNode,在React 16x之后,这个对象应该是FiberNode。这里为了简单起见,我们暂时不研究Fiber,我们只需要知道一个组件在内存里有一个唯一表示的对象即可,我们姑且把他叫做fiberNode

type FiberNode {
    memoizedState:any  // 用来存放某个组件内所有的Hook状态
}

现在,摆在我们面前的问题是,我们对Function component的期望是什么?我们希望的是用Function componentuseState来完全模拟Class componentthis.setState吗?如果是,那我们的设计原则会是:

一个函数组件全局只能调用一次useState,并将所有的状态存放在一个大Object里

如果仅仅如此,那么函数组件已经解决了去Class的痛点,但我们并没有考虑优雅地复用状态逻辑的诉求。

试想一个状态复用的场景:我们有多个组件需要监听浏览器窗口的resize事件,以便可以实时地获取clientWidth。在Class component里,我们要么在全局管理这个副作用,并借助ContextAPI来向子组件下发更新;要么就得在用到该功能的组件中重复书写这个逻辑。

resizeHandler(){
    this.setState({
        width: window.clientWidth,
        height: window.clientHeight
    });
}

componentDidMount(){
    window.addEventListener('resize', this.resizeHandler)
}

componentWillUnmount(){
    window.removeEventListener('resize', this.resizeHandler);
}

ContextAPI的方法无疑是不推荐的,这会给维护带来很大的麻烦;ctrl+c ctrl+v就更是无奈之举了。

如果Function component可以为我们带来一种全新的状态逻辑复用的能力,那无疑会为前端开发在复用性和可维护性上带来更大的想象空间。

因此理想的用法是:

const [firstName, setFirstName] = useState('James');
const [secondName, setSecondName] = useState('Bond');

// 其他非state的Hook,比如提供一种更灵活更优雅的方式来书写副作用
useEffect()

综上所述,设计上理应要考虑一个组件对应多个Hook的用法。带来的挑战是:

我们需要在fiberNode上存储所有Hook的状态,并确保它们在每一次re-render时都可以获取到最新的正确的状态

要实现上述存储目标,直接想到的方案就是用一个hashMap来搞定:

{
    '1': hook1,
    '2': hook2,
    //...
}

如果用这种方法来存储,会需要为每一次hook的调用生成唯一的key标识,这个key标识需要在mount和update时从参数中传入以保证能路由到准确的hook对象。

除此方案之外,还可以使用hook.update采用的单向链表结构来存储,给hook结构增加一个next属性即可实现:

type Hook = {
    memoizedState: any,                     // 上一次完整更新之后的最终状态值
    queue: UpdateQueue<any, any> | null,    // 更新队列
    next: any                               // 下一个hook
}


const fiber = {
    //...
    memoizedState: {
        memoizedState: 'James', 
        queue: {
            last: {
                action: 'Smith'
            },  
            dispatch: dispatch,
            lastRenderedState: 'Smith'
        },
        next: {
            memoizedState: 'Bond',
            queue: {
                // ...
            },
            next: null
        }
    },
    //...
}

这种方案存在一个问题需要注意:

整个链表是在mount时构造的,所以在update时必须要保证执行顺序才可以路由到正确的hook。

我们来粗略对比一下这两种方案的优缺点:

方案 优点 缺点
hashMap 查找定位hook更加方便对hook的使用没有太多规范和条件的限制 影响使用体验,需要手动指定key
链表 API友好简洁,不需要关注key 需要有规范来约束使用,以确保能正确路由

很显然,hashMap的缺点是无法忍受的,使用体验和成本都太高了。而链表方案缺点中的规范是可以通过eslint等工具来保障的。从这点考虑,链表方案无疑是胜出了,事实上这也正是React团队的选择。

到这里,我们可以了解到为什么React Hook的规范里要求:

只能在函数组件的顶部使用,不能再条件语句和循环里使用

function Counter(){
    const [count, setCount] = useState(0);
    if(count >= 1){
        const [countTime, setCountTime] = useState(Date.now());
    }
}

// mount 阶段构造的hook链为
{
    memoizedState: {
        memoizedState: '0', 
        queue: {},
        next: null
}

// 调用setCount(1)之后的update 阶段,则会找不到对应的hook对象而出现异常

至此,我们已经基本实现了React Hooks 去Class的设计目标,现在用函数组件,我们也可以通过useState这个hook实现状态管理,并且支持在函数组件中调用多次hook。

2.2.2 无生命周期的困扰

上一节我们借助闭包、两个单向链表(单次hook的update链表、组件的hook调用链表)、透传dispatch函数实现了React Hook架构的核心逻辑:如何在函数组件中使用状态。到目前为止,我们还没有讨论任何关于生命周期的事情,这一部分也是我们的设计要解决的重点问题。我们经常会需要在组件渲染之前或者之后去做一些事情,譬如:

  • Class componentcomponentDidMount中发送ajax请求向服务器端拉取数据;
  • Class componentcomponentDidMountcomponentDidUnmount中注册和销毁浏览器的事件监听器

这些场景,我们同样需要在React Hook中予以解决。React为Class component设计了一大堆生命周期函数:

  • 在实际的项目开发中用的比较频繁的,譬如渲染后期的:componentDidMountcomponentDidUpdatecomponentWillUnmount
  • 很少被使用的渲染前期钩子componentWillMountcomponentWillUpdate
  • 一直以来被滥用且有争议的componentWillReceiveProps和最新的getDerivedStateFromProps
  • 用于性能优化的shouldComponentUpdate

React 16.3版本已经明确了将在17版本中废弃componentWillMountcomponentWillUpdatecomponentWillReceiveProps这三个生命周期函数。设计用来取代componentWillReceivePropsgetDerivedStateFromProps也并不被推荐使用。

真正被重度使用的就是渲染后和用于性能优化的几个,在React hook之前,我们习惯于以render这种技术名词来划分组件的生命周期阶段,根据名字componentDidMount我们就可以判断现在组件的DOM已经在浏览器中渲染好了,可以执行副作用了。这显然是技术思维,那么在React Hook里,我们能否抛弃这种思维方式,让开发者无需去关注渲染这件事儿,只需要知道哪些是副作用,哪些是状态,哪些需要缓存即可呢?

根据这个思路我们来设计React Hook的生命周期解决方案,或许应该是场景化的样子:

// 用来替代constructor初始化状态
useState()

// 替代 componentDidMount和componentDidUpdate以及componentWillUnmount
// 统一称为处理副作用
useEffect()

// 替代shouldComponent
useMemo()

这样设计的好处是开发者不再需要去理清每一个生命周期函数的触发时机,以及在里面处理逻辑会有哪些影响。而是更关注去思考哪些是状态,哪些是副作用,哪些是需要缓存的复杂计算和不必要的渲染。

2.2.2.1 useEffect

effect的全称应该是Side Effect,中文名叫副作用,我们在前端开发中常见的副作用有:

  • dom操作
  • 浏览器事件绑定和取消绑定
  • 发送HTTP请求
  • 打印日志
  • 访问系统状态
  • 执行IO变更操作

在React Hook之前,我们经常会把这些副作用代码写在componentDidMountcomponentDidUpdatecomponentWillUnmount里,比如:

componentDidMount(){
    this.fetchData(this.props.userId).then(data=>{
        //... setState
    })
    
    window.addEventListener('resize', this.onWindowResize);
    
    this.counterTimer = setInterval(this.doCount, 1000);
}

componentDidUpdate(prevProps){
   if (this.props.userID !== prevProps.userID) {
    this.fetchData(this.props.userID);
  }
}

componentWillUnmount(){
    window.removeEventListener('resize', this.onWindowResize);
    clearInterval(this.counterTimer);
}

这种写法存在一些体验的问题:

  1. 同一个副作用的创建和清理逻辑分散在多个不同的地方,这无论是对于新编写代码还是要阅读维护代码来说都不是一个上佳的体验
  2. 有些副作用可能要再多个地方写多份

第一个问题,我们可以通过thunk来解决:将清理操作和新建操作放在一个函数中,清理操作作为一个thunk函数被返回,这样我们只要在实现上保障每次effect函数执行之前都会先执行这个thunk函数即可:

useEffect(()=>{
    // do some effect work
    return ()=>{
        // clean the effect
    }
})

第二个问题,对于函数组件而言,则再简单不过了,我们完全可以把部分通用的副作用抽离出来形成一个新的函数,这个函数可以被更多的组件复用。

function useWindowSizeEffect(){
    const [size, setSize] = useState({width: null, height: null});
    
    function updateSize(){
        setSize({width: window.innerWidth, height: window.innerHeight});
    }
    
    useEffect(()=>{
        window.addEventListener('resize', updateSize);
        
        return ()=>{
            window.removeEventListener('resize', updateSize);
        }
    })
    
    return size;
}
useEffect的执行时机

既然是设计用来解决副作用的问题,那么最合适的时机就是组件已经被渲染到真实的DOM节点之后。因为只有这样,才能保证所有副作用操作中所需要的资源(dom资源、系统资源等)是ready的。

上面的例子中描述了一个在mount和update阶段都需要执行相同副作用操作的场景,这样的场景是普遍的,我们不能假定只有在mount时执行一次副作用操作就能满足所有的业务逻辑诉求。所以在update阶段,useEffect仍然要重新执行才能保证满足要求。

这就是useEffect的真实机制:

Function Component函数(useState、useEffect、...)每一次调用,其内部的所有hook函数都会再次被调用。

这种机制带来了一个显著的问题,就是:

父组件的任何更新都会导致子组件内Effect逻辑重新执行,如果effect内部存在性能开销较大的逻辑时,可能会对性能和体验造成显著的影响。

React在PureComponent和底层实现上都有过类似的优化,只要依赖的state或者props没有发生变化(浅比较),就不执行渲染,以此来达到性能优化的目的。useEffect同样可以借鉴这个**:

useEffect(effectCreator: Function, deps: Array)

// demo
const [firstName, setFirstName] = useState('James');
const [count, setCount] = useState(0);

useEffect(()=>{
    document.title = `${firstName}'s Blog`;
}, [firstName])

上面的例子中,只要传入的firstName在前后两次更新中没有发生变化,effectCreator函数就不会执行。也就是说,即便调用多次setCount(*),组件会重复渲染多次,但只要firstName没有发生变化,effectCreator函数就不会重复执行。

useEffect的实现

useEffect的实现和useState基本相似,在mount时创建一个hook对象,新建一个effectQueue,以单向链表的方式存储每一个effect,将effectQueue绑定在fiberNode上,并在完成渲染之后依次执行该队列中存储的effect函数。核心的数据结构设计如下:

type Effect{
    tag: any,           // 用来标识effect的类型,
    create: any,        // 副作用函数
    destroy: any,       // 取消副作用的函数,
    deps: Array,        // 依赖
    next: Effect,       // 循环链表指针
}

type EffectQueue{
    lastEffect: Effect
}

type FiberNode{
    memoizedState:any  // 用来存放某个组件内所有的Hook状态
    updateQueue: any  
}

deps参数的优化逻辑就很简单了:

let componentUpdateQueue = null;
function pushEffect(tag, create, deps){
    // 构建更新队列
    // ...
}

function useEffect(create, deps){
    if(isMount)(
        mountEffect(create, deps)
    )else{
        updateEffect(create, deps)
    }
}

function mountEffect(create, deps){
    const hook = createHook();
    hook.memoizedState = pushEffect(xxxTag, create, deps);
    
}

function updateEffect(create, deps){
    const hook = getHook();
    if(currentHook!==null){
        const prevEffect = currentHook.memoizedState;
        if(deps!==null){
            if(areHookInputsEqual(deps, prevEffect.deps)){
                pushEffect(xxxTag, create, deps);
                return;
            }
        }
    }
    
    hook.memoizedState = pushEffect(xxxTag, create, deps);
}
useEffect小结
  1. 执行时机相当于componentDidMountcomponentDidUpdate,有return就相当于加了componentWillUnmount
  2. 主要用来解决代码中的副作用,提供了更优雅的写法
  3. 多个effect通过一个单向循环链表来存储,执行顺序是按照书写顺序依次执行
  4. deps参数是通过循环浅比较的方式来判断和上一次依赖值是否完全相同,如果有一个不同,就重新执行一遍Effect,如果相同,就跳过本次Effect的执行。
  5. 每一次组件渲染,都会完整地执行一遍清除、创建effect。如果有return一个清除函数的话。
  6. 清除函数会在创建函数之前执行

2.2.2.2 useMemo

useEffect中我们使用了一个deps参数来声明effect函数对变量的依赖,然后通过areHookInputsEqual函数来比对前后两次的组件渲染时deps的差异,如果浅比较的结果是相同,那么就跳过effect函数的执行。

仔细想想,这不就是生命周期函数shouldComponentUpdate要做的事情吗?何不将该逻辑抽取出来,作为一个通用的hook呢,这就是useMemo这个hook的原理。

function mountMemo(nextCreate,deps) {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

function updateMemo(nextCreate,deps){
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  // 上一次的缓存结果
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    if (nextDeps !== null) {
      const prevDeps = prevState[1];
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

但useMemo和shouldComponentUpdate的区别在于useMemo只是一个通用的无副作用的缓存Hook,并不会影响组件的渲染与否。所以从这点上讲,useMemo并不能替代shouldComponentUpdate,但这丝毫不影响useMemo的价值。useMemo为我们提供了一种通用的性能优化方法,对于一些耗性能的计算,我们可以用useMemo来缓存计算结果,只要依赖的参数没有发生变化,就达到了性能优化的目的。

const result = useMemo(()=>{
    return doSomeExpensiveWork(a,b);
}, [a,b])

那么要完整实现shouldComponentUpdate的效果应该怎么办呢?答案是借助React.memo:

const Button = React.memo((props) => {
  // 你的组件
});

这相当于使用了PureComponent。

到目前为止,除了getDerivedStateFromProps,其他常用的生命周期方法在React Hook中都已经有对应的解决方案了,componentDidCatch官方已经声明正在实现中。这一节的最后,我们再来看看getDerivedStateFromProps的替代方案。

这个生命周期的作用是根据父组件传入的props,按需更新到组件的state中。虽然很少会用到,但在React Hook组件中,仍然可以通过在渲染时调用一次"setState"来实现:

function ScrollView({row}) {
  let [isScrollingDown, setIsScrollingDown] = useState(false);
  let [prevRow, setPrevRow] = useState(null);

  if (row !== prevRow) {
    // Row 自上次渲染以来发生过改变。更新 isScrollingDown。
    setIsScrollingDown(prevRow !== null && row > prevRow);
    setPrevRow(row);
  }

  return `Scrolling down: ${isScrollingDown}`;
}

如果在渲染过程中调用了"setState",组件会取消本次渲染,直接进入下一次渲染。所以这里一定要注意"setState"一定要放在条件语句中执行,否则会造成死循环。

2.2.3 优雅地复用

React组件化开发方式,本质上就是组件的复用,开发一个应用就像搭积木一样把各种组件有机地堆叠在一起。但这是整个组件层面的复用,是一种粗粒度的复用。在不同的组件内部,我们仍然会经常做一些重复劳动,这些重复劳动可能包含以下几种:

  • 状态及其逻辑的重复。比如loading状态,计数器等
  • 副作用的逻辑重复。比如有同一个ajax请求、多个组件内对同一个浏览器事件的监听、同一类dom操作或者宿主API的调用等。

React Hook的设计目标中很重要的一点就是:

如何让状态及其逻辑和副作用逻辑具备真正的复用性而不需要使用reder-propsHOC

React中的代码复用

使用过早期版本React的同学可能知道Mixins API,这是官方提供的一种比组件更细粒度的逻辑复用能力。在React推出基于ES6的Class Component的写法后,就被逐渐'抛弃'了。Mixins虽然可以非常方便灵活地解决AOP类的问题,譬如组件的性能日志监控逻辑的复用:

const logMixin = {
    componentWillMount: function(){
        console.log('before mount:', Date.now());
    }
    
    componentDidMount: function(){
        console.log('after mount:', Date.now())
    }
}

var createReactClass = require('create-react-class');
const CompA = createReactClass({
    mixins: [logMixin],
    render: function(){
        //... 
    }
})

const CompB = createReactClass({
    mixins: [logMixin],
    render: function(){
        //... 
    }
})

但这种模式本身会带来很多的危害,具体可以参考官方的一片博文:《Mixins Considered Harmful》

React官方在2016年建议拥抱HOC,也就是使用高阶组件的方式来替代mixins的写法。minxins API仅可以在create-react-class手动创建组件时才能使用。这基本上宣告了mixins这种逻辑复用的方式的终结。

HOC非常强大,React生态中大量的组件和库都使用了HOC,比如react-reduxconnect API:

class MyComp extends Component{
    //...
}
export default connect(MyComp, //...)

HOC实现上面的性能日志打印,代码如下:

function WithOptimizeLog(Comp){
    return class extends Component{
        constructor(props){
            super(props);
           
        }
        
        componentWillMount(){
            console.log('before mount:', Date.now());
        }
        
        componentDidMount(){
            console.log('after mount:', Date.now());
        }
        
        render(){
            return (
                <div>
                    <Comp {...props} />
                </div>
            )
        }
    }
} 

// CompA
export default WithOptimizeLog(CompA)

//CompB
export defaultWithOptimizeLog(CompB);

HOC虽然强大,但因其本身就是一个组件,仅仅是通过封装了目标组件提供一些上层能力,因此难以避免的会带来嵌套地狱的问题。并且因为HOC是一种将可复用逻辑封装在一个React组件内部的高阶思维模式,所以和普通的React组件相比,它就像是一个魔法盒子一样,势必会更难以阅读和理解。

可以肯定的是HOC模式是一种被广泛认可的逻辑复用模式,并且在未来很长的一段时间内,这种模式仍将被广泛使用。但随着React Hook架构的推出,HOC模式是否仍然适合用在Function Component中?还是要寻找一种新的组件复用模式来替代HOC呢?

React官方团队给出的答案是后者,原因是在React Hook的设计方案中,借助函数式状态管理以及其他Hook能力,逻辑复用的粒度可以实现的更细、更轻量、更自然和直观。毕竟在Hook的世界里一切都是函数,而非组件。

来看一个例子:

export default function Article() {
    const [isLoading, setIsLoading] = useState(false);
    const [content, setContent] = useState('origin content');
    
    function handleClick() {
        setIsLoading(true);
        loadPaper().then(content=>{
            setIsLoading(false);
            setContent(content);
        })
    }

    return (
        <div>
            <button onClick={handleClick} disabled={isLoading} >
                {isLoading ? 'loading...' : 'refresh'}
            </button>
            <article>{content}</article>
        </div>
    )
}

上面的代码中展示了一个带有loading状态,可以避免在加载结束之前反复点击的按钮。这种组件可以有效地给予用户反馈,并且避免用户由于得不到有效反馈带来的不断尝试造成的性能和逻辑问题。

很显然,loadingButton的逻辑是非常通用且与业务逻辑无关的,因此完全可以将其抽离出来成为一个独立的LoadingButton组件:

function LoadingButton(props){
    const [isLoading, setIsLoading] = useState(false);
    
    function handleClick(){
        props.onClick().finally(()=>{
            setIsLoading(false);
        });    
    }
    
    return (
        <button onClick={handleClick} disabled={isLoading} >
            {isLoading ? 'loading...' : 'refresh'}
        </button>
    )
}

// 使用
function Article(){
    const {content, setContent} = useState('');
    
    clickHandler(){
       return fetchArticle().then(data=>{
           setContent(data);
       })
    }
    
    return (
        <div>
            <LoadingButton onClick={this.clickHandler} />
            <article>{content}</article>
        </div>
    )
}

上面这种将某一个通用的UI组件单独封装并提取到一个独立的组件中的做法在实际业务开发中非常普遍,这种抽象方式同时将状态逻辑和UI组件打包成一个可复用的整体。

很显然,这仍旧是组件复用思维,并不是逻辑复用思维。试想一下另一种场景,在点击了loadingButton之后,希望文章的正文也同样展示一个loading状态该怎么处理呢?

如果不对loadingButton进行抽象的话,自然可以非常方便地复用isLoading状态,代码会是这样:

export default function Article() {
    const [isLoading, setIsLoading] = useState(false);
    const [content, setContent] = useState('origin content');
    
    function handleClick() {
        setIsLoading(true);
        loadArticle().then(content=>{
            setIsLoading(false);
            setContent(content);
        })
    }

    return (
        <div>
            <button onClick={handleClick} disabled={isLoading} >
                {isLoading ? 'loading...' : 'refresh'}
            </button>
            {
                isLoading
                    ? <img src={spinner}  alt="loading" />
                    : <article>{content}</article>
            }
        </div>
    )
}

但针对抽象出LoadingButton的版本会是什么样的状况呢?

function LoadingButton(props){
    const [isLoading, setIsLoading] = useState(false);
    
    function handleClick(){
        props.onClick().finally(()=>{
            setIsLoading(false);
        });    
    }
    
    return (
        <button onClick={handleClick} disabled={isLoading} >
            {isLoading ? 'loading...' : 'refresh'}
        </button>
    )
}

// 使用
function Article(){
    const {content, setContent} = useState('origin content');
    const {isLoading, setIsLoading} = useState(false);
    
    clickHandler(){
       setIsLoading(true);
       return fetchArticle().then(data=>{
           setContent(data);
           setIsLoading(false);
       })
    }
    
    return (
        <div>
            <LoadingButton onClick={this.clickHandler} />
            {
                isLoading
                    ? <img src={spinner}  alt="loading" />
                    : <article>{content}</article>
            }
        </div>
    )
}

问题并没有因为抽象而变的更简单,父组件Article仍然要自定一个isLoading状态才可以实现上述需求,这显然不够优雅。那么问题的关键是什么呢?

答案是耦合。上述的抽象方案将isLoading状态和button标签耦合在一个组件里了,这种复用的粒度只能整体复用这个组件,而不能单独复用一个状态。解决方案是:

// 提供loading状态的抽象
export function useIsLoading(initialValue, callback) {
    const [isLoading, setIsLoading] = useState(initialValue);

    function onLoadingChange() {
        setIsLoading(true);

        callback && callback().finally(() => {
            setIsLoading(false);
        })
    }

    return {
        value: isLoading,
        disabled: isLoading,
        onChange: onLoadingChange, // 适配其他组件
        onClick: onLoadingChange,  // 适配按钮
    }
}

export default function Article() {
    const loading = useIsLoading(false, fetch);
    const [content, setContent] = useState('origin content');

    function fetch() {
       return loadArticle().then(setContent);
    }

    return (
        <div>
            <button {...loading}>
                {loading.value ? 'loading...' : 'refresh'}
            </button>
           
            {
                loading.value ? 
                    <img src={spinner} alt="loading" />
                    : <article>{content}</article>
            }
        </div>
    )
}

如此便实现了更细粒度的状态逻辑复用,在此基础上,还可以根据实际情况,决定是否要进一步封装UI组件。譬如,仍然可以封装一个LoadingButton:

// 封装按钮
function LoadingButton(props){
    const {value, defaultText = '确定', loadingText='加载中...'} = props;
    return (
        <button {...props}>
            {value ? loadingText: defaultText}
        </button>
    )
}

// 封装loading动画
function LoadingSpinner(props) {
    return (
        < >
            { props.value && <img src={spinner} className="spinner" alt="loading" /> }
        </>
    )
}
// 使用

return (
    <div>
        <LoadingButton {...loading} />
        <LoadingSpinner {...loading}/>
        { loading.value || <article>{content}</article> }
    </div>
)

状态逻辑层面的复用为组件复用带来了一种全新的能力,这完全有赖于React Hook基于Function的组件设计,一切皆为函数调用。并且,Function Component也并不排斥HOC,你仍然可以使用熟悉的方法来提供更高阶的能力,只是现在,你的手中拥有了另外一种武器。

自定义Hook的实现原理

上述例子中的useIsLoading函数被称之为自定义Hook,它所做的仅仅是将部分hook代码提取到一个独立的函数中,就像我们把可复用的逻辑提取到一个独立的函数中一样。

从上文中我们了解到,Hook队列需要存储在组件对应的FiberNode上才可以,那么自定义hook也会对应一个FiberNode吗?自定义Hook对入参和结果有什么要求呢?

我们对自定义Hook的定义是逻辑的复用,而不是组件的复用,因此它不应该像Function Component一样直接返回组件树,自然也就没有一个独立的FiberNode来对应了。如果没有独立存储,那自定义hook函数内部调用的useState、useEffect等hook函数的数据结构应该如何存储呢?

答案是绑定在调用这个自定义hook的Function Component对应的FiberNode上,被抽离出来的自定义Hook逻辑,在实际执行的过程中,就好像copy了一份自定义Hook代码,替换了原来的调用代码,这就是自定义Hook的本质。

因此自定义Hook在使用时也需要遵循Hook规范,需要在函数顶部调用hook,不能写在条件语句和循环里。除此之外,由于规范允许在自定义Hook中调用hook函数,但不允许在普通的function中调用,因此需要一种规范或者机制来保障开发者不会犯错。

React团队给出的方案是命名规范和eslint校验:

  1. 自定义Hook必须以use开头,以便可以通过命名规范来区分。比如:'useIsLoading'
  2. 使用ESLINT插件来确保当开发者犯错时可以进行提示

2.2.4 对齐React Class组件已经具备的能力

在本文撰写的时间点上,仍然有一些Class Component具备的功能是React Hook没有具备的,譬如:生命周期函数componentDidCatchgetSnapshotBeforeUpdate。还有一些第三方库可能还无法兼容hook,官方给出的说法是:

我们会尽快补齐

未来可期,我们只需静静地等待。

3 小结

武侠小说中有”无招胜有招“的境界,在设计领域也有”没有设计就是最好的设计“的论断。React Hook抛弃Class,拥抱函数式编程,使用JS语言独特的闭包来存储状态,这种设计就像是日本设计师深泽直人倡导的无意识设计一样,对于Javascript程序员而言,使用的时候不需要多余的思考,一切皆函数,一切都那么自然、优雅和顺理成章。

4 参考文档

reselect源码分析和最佳实践思考

banner

源码分析

在阅读一个库的源码之前,搞清楚这个库的存在是为了解决了什么问题至关重要。

reselect的设计初衷是为了解决react-redux架构中,在把全局store里的state树的部分状态映射到对应组件的props中(mapStateToProps),每次组件更新的时候都需要执行计算的问题。即便组件依赖的状态并未发生改变。

因此可以知道,reselect提供的createSelector方法主要就是替代mapStateToProps函数的,并提了供缓存和比较机制,来减少组件更新时的无谓计算,从而提高性能。

实际使用示例如下:

import { createSelector } from 'reselect'
import { connect } from 'react-redux'
import { toggleTodo } from '../actions'
import TodoList from '../components/TodoList'

const getVisibilityFilter = (state) => state.visibilityFilter
const getTodos = (state) => state.todos

const getVisibleTodos = createSelector(
  [ getVisibilityFilter, getTodos ],
  (visibilityFilter, todos) => {
    switch (visibilityFilter) {
      case 'SHOW_ALL':
        return todos
      case 'SHOW_COMPLETED':
        return todos.filter(t => t.completed)
      case 'SHOW_ACTIVE':
        return todos.filter(t => !t.completed)
    }
  }
)

const mapStateToProps = (state) => {
  return {
    todos: getVisibleTodos(state)
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    onTodoClick: (id) => {
      dispatch(toggleTodo(id))
    }
  }
}

const VisibleTodoList = connect(
  mapStateToProps,
  mapDispatchToProps
)(TodoList)

export default VisibleTodoList
export const createSelector = createSelectorCreator(defaultMemoize)

export function createSelectorCreator(memoize, ...memoizeOptions) {/*...*/}

createSelector函数是由一个高阶函数,通过传入存储策略函数,以及缓存过期的比较函数来生成。reselect使用了默认提供的defaultMemoize和引用比较器defaultEqualityCheck来生成createSelector函数。

export function defaultMemoize(func, equalityCheck = defaultEqualityCheck) {
  let lastArgs = null
  let lastResult = null
  // we reference arguments instead of spreading them for performance reasons
  return function () {
    if (!areArgumentsShallowlyEqual(equalityCheck, lastArgs, arguments)) {
      // apply arguments instead of spreading for performance.
      lastResult = func.apply(null, arguments)
    }

    lastArgs = arguments
    return lastResult
  }
}

function defaultEqualityCheck(a, b) {
  return a === b
}

defaultMemoize函数有两个参数,一个是计算函数func,一个是比较器函数,返回一个带缓存的结果计算函数。

通过闭包定义了两个私有变量:lastArgs和lastResult,分别代表上一次执行计算所用的参数集合和计算的结果。并在返回的结果计算函数中对最新的参数和上一次的参数进行equalityCheck,如果相同则使用缓存;如果不同则重新计算结果。

defaultMemoize是一个通用结果计算和缓存模型,提供了一个容量为1的缓存,计算缓存过期的逻辑就是根据传入的比较器函数判断前后两次参数是否相同。

搞清楚defaultMemoize,理解reselect就非常简单了,我们接着来看createSelectorCreator函数:

export function createSelectorCreator(memoize, ...memoizeOptions) {
  return (...funcs) => {}
}

我们常用的createSelector方法就是由这个函数的返回值,支持传入多个函数作为参数,和我们熟知的createSelector写法一致:

createSelector(funA, funcB, (a, b) => {});
createSelector([funcA, funcB], (a, b) => {});

最后一个参数是结果计算函数,前面的funA和funB为中间结果计算函数,称之为结果计算函数的依赖函数。我们来看看createSelector函数内部的实现:

return (...funcs) => {
    //重新计算的次数统计
    let recomputations = 0
    //弹出结果计算函数
    const resultFunc = funcs.pop()
    //提取依赖函数数组,并对funcs数组中元素进行校验,默认只支持function
    const dependencies = getDependencies(funcs)

    //对结果计算函数使用用户传入的缓存策略(默认为上面说的defaultMemoize)。并且每执行一次结果计算函数,计数就加1
    const memoizedResultFunc = memoize(
      function () {
        recomputations++
        // apply arguments instead of spreading for performance.
        return resultFunc.apply(null, arguments)
      },
      ...memoizeOptions
    )

    //selector就是createSelector的返回值函数。
    //selector函数要做的事情就是把依赖的每一个中间结果计算函数依次执行,并组装成一个结果数组。
    //交给结果计算函数处理。在selector函数的参数值引用未发生变化时,中间计算函数不需要重复进行计算。
    const selector = defaultMemoize(function () {
      const params = []
      const length = dependencies.length
     
      for (let i = 0; i < length; i++) {
       
        // 循环执行所有依赖的计算函数,并把执行的结果保存在数组缓存中
        params.push(dependencies[i].apply(null, arguments))
      }

      //执行结果计算函数,返回最终计算结果
      return memoizedResultFunc.apply(null, params)
    })

    //绑定结果处理函数
    selector.resultFunc = resultFunc
    selector.recomputations = () => recomputations
    selector.resetRecomputations = () => recomputations = 0
    return selector
  }

到这一步基本上就吧reselect的核心代码分析完了,还剩下一个createStructuredSelector函数,是reselect提供的一种简化特殊场景的代码书写的方法。基本意思就是可以把下面这种写法:

const mySelectorA = state => state.a
const mySelectorB = state => state.b

const structuredSelector = createSelector(
   mySelectorA,
   mySelectorB,
   mySelectorC,
   (a, b, c) => ({
     a,
     b,
     c
   })
)

简写成

const mySelectorA = state => state.a
const mySelectorB = state => state.b

const structuredSelector = createStructuredSelector({
  x: mySelectorA,
  y: mySelectorB
})

const result = structuredSelector({ a: 1, b: 2 }) // will produce { x: 1, y: 2 }

源码不难,就不解读了。

最佳实践和思考

通过上面源码分析,可以得到如下的一些结论和思考:

  1. createSelector(state)中的state如果发生变化(引用发生变化),则会让所有的子selector缓存失效,但如果所有中间计算结果没有发生变化的话,最终resultFunc的缓存仍然可以正常使用。

  2. reselect的比较机制是简单的参数引用比较,如果参数引用发生了变化,则需要重新执行计算;否则走缓存。有两个地方有缓存:依赖的函数计算结果和最终计算结果

  3. selector.recomputations可以用来判断重复计算的次数

  4. reselect的设计目标是为了避免在redux流程中引发的mapStateToProp函数中的重复计算,而如果并不存在比较多的state复杂计算场景,则reselect的作用就显得微乎其微了,甚至由于闭包的存在,还会影响执行性能

  5. 当触发组件的更新流程时,往往会把对要传递给子组件的props的组装和计算流程写在render函数内部,更好的做法是在reselect中提前做好,render里直接拿就可以了,这样可以充分利用reselect的缓存。还可以更清晰地定义组件的state结构。

  6. reselect官方推荐的用法是和react-redux的connect方法配合使用,以便减少在mapStateToProps的时候重复计算性能损耗。然而在一个复杂的SPA系统里,当组件层级较深时,connect并不推荐在所有层级的组件里使用。因为每一个组件在connect的时候,可以将state tree上任意的状态对象映射到当前组件的props里,在团队协同开发,缺乏规范的状态小,这会导致组件对state tree的依赖变的异常复杂和不可控。一个比较靠谱的做法是在state tree的设计上尽量保证上层页面或者模块之间,在数据上尽量独立,互不依赖,模块内部的组件细分则通过至上而下的props进行流转便可。如此,connect操作也就仅限于在数量有限的上层组件上使用。这一定程度上限制了reselect的使用频次,因此在性能和可维护性上,到底如何宣召平衡点,则是一个需要深入思考的问题了。

    通常来说,移动应用的每一个页面的功能和数据相对简单,和其他页面之间的数据关系也比较独立,很少会有模块联动的需求。因此在不同层级频繁使用connect进行state到props的映射计算是一种可行且可控的实践方式。而对于复杂的PC Web而言,情况就有所不同了,同一个页面上,多个模块之间经常会有复杂的数据联动和依赖关系,而且代码量也会比较大。放开connect操作的使用场景,可以带来数据获取的便利和灵活性,但会造成模块之间数据耦合度高的可维护性问题。

    因此从这点上讲,reselect可能在移动端应用上更有用武之地。但另一个问题是:redux又通常被推荐在复杂的PC SPA项目中使用,真是让人纠结的很啊。

  7. 除了在connect时使用,reselect可以在其他场景中使用吗?

    reselect虽然设计用来解决redux的问题,但其本质就是一个数据映射的缓存方案,也就是说任何需要将一个数据对象经过计算映射成另外一个数据对象的场景都可以使用reselect来提高性能。

    譬如:在一个一次性加载所有数据的表格组件中,存在一个按照状态字段进行过滤的功能。

    const STATUS_NORMAL = 0;
    const STATUS_PAUSE = 1;
    const STATUS_FAIL = 2;
    
    const getNormal = (data)=> data.filter(item=> item.status === STATUS_NORMAL);
    const getPause = (data) => data.filter(item => item.status === STATUS_PAUSE);
    const getFail = (data) => data.filter(item => item.status === STATUS_FAIL);
    
    const filterSelector = createSelector(
        getNormal, 
        getPause, 
        getFail, 
        (normal, pause, fail) => {
            return {
                [STATUS_NORMAL]: normal, 
                [STATUS_PAUSE]: pause, 
                [STATUS_FAIL]: fail
            };
        }
    );
    
    function onFilter(status){
        render(myData[status]);
    }
    
    function load(data){
        return filterSelector(data);
    }
    
    const myData = load(data);

    看起来很完美,对吗?每次切换状态过滤的时候,都不用再次去进行分类过滤计算了。当重新执行load(data)时,如果data并没有发生变化,也不会再次进行计算。

    实际情况是这样吗?其实不是,reselect默认使用的比较器是引用比较,也就是说,判断data有没有发生变化的方式是

    oldData === data

    显然大多数场景下新的data往往来自于ajax请求的响应,其引用和oldData并不相同,所以reselect的缓存机制并未生效。

    使用引用比较器的原因是reselect的主要设计目标是为react-redux架构提供性能优化服务的,在这个体系中state是一个全局的Immutable对象,对象只要引用没有发生变化,就没必要再次执行映射计算,而一旦发生变化,其引用也会发生变化。因此使用引用比较省时省力,也更适配redux的理念。

    因此上面例子中只有在onFilter过滤的时候可以使用缓存,而在更新数据data的时候,reselect的缓存就会失效。这样看来,使用reselect做如上的数据映射处理的优势并不明显,和自己手动去做缓存区别不大:

    const getNormal = (data)=> data.filter(item=> item.status === STATUS_NORMAL);
    const getPause = (data) => data.filter(item => item.status === STATUS_PAUSE);
    const getFail = (data) => data.filter(item => item.status === STATUS_FAIL);
    
    const myData = {
        [STATUS_NORMAL]: getNormal(data), 
        [STATUS_PAUSE]: getPause(data), 
        [STATUS_FAIL]: getFail(data)
    };
    
    function onFilter(status){
        render(myData[status]);
    }

    当然,reselect还提供了更高级的用法,允许开发者自定义存储函数和比较器函数,来实现更个性化的需求。

    const memorize = (func, equalityCheck){
        //...
    }
    
    const deepCheck(a, b){
        //deep check for a and b
    }
    
    const createSelector = createSelectorCreator(memorize, deepCheck);

    具体使用场景则有待大家自己去发掘了。

结论

归纳一下最佳实践就是:

  • reselect适合用在state到props有很多计算的场景,计算量少的场景可以不用
  • 尽量把在render里对props的计算提取出来,放到reselect里去做,充分利用缓存
  • connect时使用reselect次数越多,越能体现reselect的缓存优势;但connect太多会影响可维护性,需要找个平衡点

开发一个高质量的前端组件,这些姿势一定要知道

image
题图 | 《蜕变》 | 19年十一作者拍摄于雨岔峡谷

2009年11月8日,在欧洲JSConf大会上,Ryan Dahl第一次正式向业界宣布了Node.js的面世,使JS语言书写后端应用程序成为了可能。在随后的几年里,Node.js受到了Javascript社区狂热地追捧,前端行业也因此进入了一个全新的工程化和全栈时代。

回顾历史,总会让人心潮澎湃。在这股浪潮中,有无数的人和项目在这座丰碑中刻下了自己的名字:React、Vue、Yeoman、RequireJS、Backbone、Antd、Webpack、Mocha、Eslint等等。在这些知名项目的熠熠光辉下,我们可能会忽略为Node.js生态的繁荣之下建立不世之功的NPM,它才是当之无愧的肱骨重臣。

NPM生于2010年1月,它从出生就背负了让Node.js社区更加繁荣的使命。NPM致力于让JS程序员能够更加方便地发布、分享Node.js类库和源码,并且简化模块的安装、更新和卸载的体验。

从今天(2019年)这个时间节点来看,NPM无论从知名度、模块数量、社区的话题数量来看,都算得上是一骑绝尘,将其他语言的模块仓库远远甩在了后面。

数据来源: moudlecounts

NPM的生态既已如此成熟,按说开发者对于NPM包的发布和维护应该非常熟悉才是,但事实真的是这样吗?环顾身边的FE,没有发过任何NPM包的同学大有人在,已经发过包的同学也有相当一部分并未考虑过如何才算规范地、高质量地发布一个包。

如今NPM的模块数量已上升至100W,在这样一个JavaScript组件化开发时代,除了能找到好用的组件,我们自然也需要了解如何才能成为创造这个时代的一员。而第一步就是要知道并掌握如何规范地、负责任地发布一个NPM包?

这就是本文接下来的主要内容。

1. 组件化思考

发布人生中第一个NPM组件虽然只是在终端命令行中潇洒地敲下npm publish,静等成功通知即可,但这从0到1的跨越却并非易事。这个行为的背后的始作俑者是开发者的大脑中开始萌发组件化思维方式。开始去思考何为组件?为什么要发布组件?这些更深一层次的问题。

组件的存在的终极意义是为了复用,一个组件只要具备了被复用的条件,并且开始被复用,那么它的价值才开始产生。组件复用的次数越高、被传播的越广,其价值就越大。而要实现组件的价值最大化,需要考虑以下几点:

  1. 我要写一个什么组件?组件提供什么样的能力?
  2. 组件的适用范围是什么?某个具体业务系统内还是整个团队、公司或者社区?
  3. 组件的生产过程是否规范、健壮和值得信赖?
  4. 组件如何被开发者发现和认识?

以上四点中,前两点是生产组件必须要思考的问题;第四点是组件如何推广运营的问题,这是另外一个话题,本文不展开探讨;第三点是开发者的基本素养,它决定了开发者如何看待这个组件,也间接暴露了开发者的素养和可信赖程度。

2. 组件开发的最佳姿势

一个优秀的组件除了拥有解决问题的价值,还应该具备以下三个特点:

  1. 生产和交付的规范性
  2. 优秀的质量和可靠性
  3. 较高的可用性

只有三者都能满足才可以称其为优秀组件,否则会给使用者带来各种各样的困惑:经常出Bug、坑很多、不稳定、文档太简单、不敢用等等。

2.1 规范性

2.1.1 目录结构

事实上,社区并没有一个官方的或者所有人都认同的目录结构规范,但从耳熟能详的知名项目中进行统计和分析,可以得出一个社区优秀开发者达成非官方共识的一个目录结构清单:

├─ test         // 测试相关
├─ scripts      // 自定义的脚本
├─ docs         // 文档,通常文档较多,有多个md文档
├─ examples     // 可以运行的示例代码
├─ packages     // 要发布的npm包,一般用在一个仓库要发多个npm包的场景
├─ dist|build   // 代码分发的目录
├─ src|lib      // 源码目录
├─ bin          // 命令行脚本入口文件
├─ website|site // 官方网站相关代码,譬如antd、react
├─ benchmarks   // 性能测试相关
├─ types|typings// typescript的类型文件
├─ Readme.md    // 仓库介绍或者组件文档
└─ index.js     // 入口文件

以上目录清单是一个比较完整的清单,大多数组件只需要根据自己的需求选择性地使用一部分即可。一份几乎适用于所有组件的最小目录结构清单如下:

├─ test         // 测试相关
├─ src|lib      // 源码目录
├─ Readme.md    // 仓库介绍或者组件文档
└─ index.js     // 入口文件

2.1.2 配置文件

这里的配置文件主要指的是各种工程化工具所依赖的本地化的配置文件,以及在Github上开源所需要声明的一些文件。一份比较全的配置文件清单如下:

├─ .circleci            // 目录。circleci持续集成相关文件
├─ .github              // 目录。github扩展配置文件存放目录
       ├─ CONTRIBUTING.md
       └─ ...
├─ .babelrc.js          // babel 编译配置
├─ .editorconfig        // 跨编辑器的代码风格统一
├─ .eslintignore        // 忽略eslint检测的文件清单
├─ .eslintrc.js         // eslint配置
├─ .gitignore           // git忽略清单
├─ .npmignore           // npm忽略清单
├─ .travis.yml          // travis持续集成配置文件
├─ .npmrc               // npm配置文件
├─ .prettierrc.json     // prettier代码美化插件的配置
├─ .gitpod.yml          // gitpod云端IDE的配置文件
├─ .codecov.yml         // codecov测试覆盖率配置文件
├─ LICENSE              // 开源协议声明
├─ CODE_OF_CONDUCT.md   // 贡献者行为准则
└─ ...                  // 其他更多配置

以上配置可以根据组件的实际情况,适用范围来进行删减。一份在各种场景都比较通用的清单如下:

├─ .babelrc.js          // babel 编译配置
├─ .editorconfig        // 跨编辑器的代码风格统一
├─ .eslintignore        // 忽略eslint检测的文件清单
├─ .eslintrc.js         // eslint配置
├─ .gitignore           // git忽略清单
├─ .npmignore           // npm忽略清单
├─ LICENSE              // 开源协议声明
└─ ...                  // 其他更多配置

上述清单移除了只有在Github上才用得到的配置,只关注仓库管理、发包管理、静态检查和编译这些基础性的配置,适用于团队内部、企业私有环境的组件开发。如果要在Github上维护,则还需要从大清单中继续挑选更多的基础配置,以便可以使用Github的众多强大的功能。

2.1.3 package.json

如果说NPM官方给出了一个发包规范的话,那么这个规范就是package.json文件,这是发包时唯一不可或缺的文件。一个最精简的package.json文件是执行npm init生成的这个版本:

{
  "name": "npm-speci-test", // 组件名
  "version": "0.1.0",       // 组件当前版本
  "description": "",        // 组件的一句话描述
  "main": "index.js",       // 组件的入口文件
  "scripts": {              // 工程化脚本,使用npm run xx来执行
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",             // 组件的作者
  "license": "ISC"          // 组件的协议
}

有这样一个版本的package.json文件,我们就可以直接在该目录下直接执行npm publish发布操作了,如果name的名称在npm仓库中尚未被占用的话,就可以看到发包成功的反馈了:

$ npm publish
+ [email protected]

但光有这些基础信息肯定是不够的,作为一个规范的组件,我们还需要考虑:

  1. 我的代码托管在什么位置了
  2. 别人可以在仓库里通过哪些关键词找到组件
  3. 组件的运行依赖有哪些
  4. 组件的开发依赖有哪些
  5. 如果是命令行工具,入口文件是哪个
  6. 组件支持哪些node版本、操作系统等

一份比较通用的package.json文件内容如下:

{
  "name": "@scope/xxxx",
  "version": "0.1.0",
  "description": "description:xxx",
  "keywords": "keyword1, keyword2,...",
  "main": "./dist/index.js",
  "bin": {},
  "scripts": {
    "lint": "eslint --ext ./src/",
    "test": "npm run lint & istanbul cover _mocha -- test/ --no-timeouts",
    "build": "npm run lint & npm run test & gulp"
  },
  "repository": {
    "type": "git",
    "url": "http://github.com/xxx.git"
  },
  "author": {
      "name": "someone",
      "email": "[email protected]",
      "url": "http://someone.com"
  },
  "license": "MIT",
  "dependencies": {},
  "devDependencies": {
    "eslint": "^5.2.0",
    "eslint-plugin-babel": "^5.1.0",
    "gulp": "^3.9.1",
    "gulp-rimraf": "^0.2.0",
    "istanbul": "^0.4.5",
    "mocha": "^5.2.0"
  },
  "engines": {
    "node": ">=8.0"
  }
}
  • name属性要考虑的是组件是否为public还是private,如果是public要先确认该名称是否已经被占用,如果没有占用为了稳妥起见,可以先发一个空白的版本;如果是private的,则需要加上@scope前缀,同样也需要确认名称是否已被占用。

  • version属性必须要符合semver规范,简单理解就是:

    • 第一个版本一般建议用0.1.0
    • 如果当前版本有破坏性变更,无法向前兼容,则考虑升第一位
    • 如果有新特性、新接口,但可以向前兼容,则考虑升第二位
    • 如果只是bug修复,文档修改等不影响兼容性的变更,则考虑升第三位
  • keywords会影响在仓库中进行检索的结果

  • main入口文件的位置最好可以固定下来,如果组件需要构建,建议统一设置为./dist/index.js, 如果不需要构建,可以指定为根目录下的index.js

  • scriptsscripts通常会包含两部分:通用脚本和自定义脚本。无论是个人还是团队,都应该为通用脚本建立规范,避免过于随意的命名scripts;自定义脚本则可以灵活定制,比如:

    • 通用scripts:start、lint、test、build
    • 自定义scripts:copy、clean、doc等
  • repository属性无论在私有环境还是公共环境,都应该加上,以便通过组件可以定位到源码仓库

  • author 如果是一个人负责的组件,用author,多个人就用contributors

更详细的package.json 文件规范可以参见npm-package.json

2.1.4 开发流程

很多同学在开发组件时都会使用master分支直接进行开发,觉得差不多可以发版了就直接手动执行一下npm publish,然后下一个版本,继续在master上搞。

这样做是非常不规范的,会存在很多问题,譬如:

  1. 正在开发一个比较大的版本,此时当前线上版本发现一个重要bug需要紧急修复
  2. 没有为每一个发布的版本指定唯一的tag标签以便回溯

git的workflow有很多种,各有适合的场景和优缺点。开发组件大多数时候是个人行为,偶尔是team行为,所以不太适合用比较复杂的流程。个人观点是,如果是在github上维护的开源组件,则参照github流程;如果是个人或者公司内私有环境,只要能保障并行多个版本,并且每一个发布的版本可回溯即可,可以在github流程上精简一下,不区分feature和hotfix,统一采用分支开发,用master作为线上分支和预发分支,开发分支要发版需要预先合并到master上,然后再master上review和单测后直接发布,并打tag标签,省略掉pull request的流程。

2.1.5 commit && changelog

一个组件从开发到发布通常会经历很多次的代码commit,如果能在一开始就了解git commit的message书写规范,并通过工具辅助以便低成本地完成规范的实践落地,则会为组件的问题回溯、了解版本变更明细带来很大的好处。我们可能都见过Node.js项目的changelog文件:

image

非常规范地将当前版本的所有关键Commit记录全部展示出来,每一条commit记录的信息也非常完整,包含了:commit的hash链接修改范围修改描述以及修改人pull request地址。试想一下,如果前期commit阶段如果没有很好的规范和工具来约束,手工完成这个工作需要花多长时间才能搞定呢?

目前社区使用最广泛的commit message规范是:Conventional Commits,由Angular Commit 规范演变而来,并且配备了非常全的工具:从git commit命令行工具commitizen,到自动生成Changelog文件、以及commitlint规范验证工具,覆盖非常全面。

2.3 质量和可维护性

开发组件的出发点是为了复用,其价值也体现在最大程度的复用上。团队内部的组件可能会在整个团队的多个系统间复用;公司内部通用的组件,可以为整个公司带来开发成本的降低;像reactantd这样的优秀开源组件,则会为整个社区和行业带来重大的价值。

组件是否可以放心使用,一个最简单直接的评判标准就是其质量的好坏。质量的好坏,除了上手试用以外,一般会通过几个方面来形成判断:

  1. 是否有高覆盖率的单元测试用例?
  2. 源码是否有规范的编码风格和语法检查?
  3. 源码是否使用了类型系统?

这些都直接决定了开发者对这个组件的评价。试想一下,如果开发了一个公共组件,没有规范的开发流程和编码风格检查,也没有单元测试,随手就发布了带bug的版本。此时用户第一次安装使用时就报错,这会让开发者对组件以产生强烈的不信任感,甚至这种不信任感会波及到作者本身。

因此,一个规范且合格的组件,至少要在保障组件的质量上做两件事情:1)引入JavaScript代码检查工具来规范代码风格和降低出错概率;2)引入单元测试框架,对组件进行必要的单元测试。此外,类型系统(TypeScript)的加入,会帮助组件提高代码质量和可维护性,是组件开发时的推荐选择。

2.3.1 JavaScript检查工具

JavaScript语言第一个检查工具是由前端大神 Douglas Crockford在2002年发布的JSLint,在后续前端行业高速发展的十几年间逐渐演变出了JSHintESLint两个检查工具。关于这三个工具的演变历史,可以参考尚春同学在知乎发表的一篇文章:《JS Linter 进化史》。本文不再赘述,我们可以通过google trends来简单了解一下这三个共工具的热度,这里还加上了一个JSCS的对比:
image

可以看到在过去的一年内全球范围内用户在google搜索这些关键词的热度情况,这个图和身处在前端行业的感受是一致的。因此在JavaScript检查工具的选择上,可以毫不犹豫地选择ESLint。

实际使用ESLint时有几点需要考虑:

  1. 无论团队还是个人,都需要就配置规范达成认知和共识,以便可以将配置沉淀下来,作为通用的脚手架和规范
  2. 对于不同的组件类型,譬如react或者vue,各有自己的独特的语法,需要特定的ESLint插件才可以支持,而和框架无关的组件,譬如lodash,则不需要这些插件。因此如何对配置进行分类和抽象,以便沉淀多套配置规范,而不必每次开发组件都需要重新对配置进行调整和修正。一个比较常规的做法是把组件按照应用的端(浏览器、Node、通用、Electron、...)和运行时依赖的框架(React、VUE、Angular等)来进行配置的组合。
  3. 借助IDE的插件来实现自动修复以便提高效率
  4. 如果是团队共同的规范,还需要形成一套规范变更的流程,以便组员对规范有争议时,可以有固定的渠道去讨论、决议并最终落实到规范中。
  5. 引入了ESLint,还需要考虑是否将ESLint加入到验收流程中,以及如何加入验收流程

2.3.2 单元测试和覆盖率

一直以来对于业务类的项目要不要写单测这个问题,个人的选择是可以不写。互联网倡导敏捷开发,快速迭代上线试错,需求变化太快,而为前端代码写单测本身的成本可能并不亚于代码本身。

但是组件的情况就完全不同了,组件是一组边界清晰、效果可预期的接口和能力的集合。而且和业务类代码相比,组件更具备通用性,也就是说不太会随着业务的变更而变更。并且组件的升级通常会对依赖组件的系统造成潜在影响,每一个版本的发布都理应对功能进行详尽的回归测试,以保障发布版本的质量。由于组件的测试通常依靠开发者自己保障,不会有专业的QA资源配备,因此单元测试就是最好的解决方案了。

JavaScript的单元测试解决方案非常之多,呈百花齐放百家争鸣的态势,耳熟能详的譬如:JasmineMochaJestAVATape等,每一个测试框架都有其独特的设计,有些是开箱即用的全套解决方案,有些自身很简约,还需要配合其他库一起使用。

事实上,这些框架并无绝对的好坏,如何选择完全取决于个人和团队的喜好。这有一篇测试框架评测的文章,不妨一读:《JavaScript unit testing frameworks: Comparing Jasmine, Mocha, AVA, Tape and Jest [2018]》

另外,我们依然可以通过Github上的star数和google trends上的搜索量来略窥流行趋势一二。

测试框架 Github stars
Jasmine 14.5k
Jest 27k
Mocha 18.3k
ava 16.7k
tape 5.1k

google trends的**数据
image

google trends在美国的数据
image

可以看出Jest从2014年发布以来,增长势头是最猛的,并在短短3年内超过了其他老牌对手,成为目前最炙手可热的Test Framwork。

除了测试框架选型以外,还有一个比较重要的指标要关注,就是测试覆盖率。推荐使用nyc, 很多同学可能还用过一个名字比较特殊的库:istanbul。这两个库之前的渊源可以看这个Issue了解一下。

2.3.3 类型系统

如今的JavaScript已经不是原来那个在浏览器写写动效和交互的愣头小子了,它已经在Web、Server、Desktop、App、IOT等众多场景中证明了自己的价值,证明了自己可以被用来解决复杂的问题。事实上,JavaScript正是通过将众多优秀的高质量组件、框架进行有机组合来提供这种能力的。

但是值得深思的是,JavaScript采用了动态弱类型的设计,过于灵活的类型转换往往会带来一些不好的事情。试想这样的场景:

  1. 调用一个组件的API函数,却不清楚这个函数的参数类型,只能自己去撸代码
  2. 对一个组件重要函数的参数做了优化重构,却无法评估影响面

这些问题在强类型语言中有很好的解决方案,很多可能的错误会在编译期就被发现,很多改动的影响也会第一时间就被IDE告警。

事实上,越来越多的知名组件库已经开始引入强类型系统来辅助提高代码的质量和可维护性,比如Vue.js、Angular、Yarn、Jest等等。如果你想让自己具备类型思维,让组件具备更好的质量和可维护性,可以考虑把类型系统加到组件的脚手架中去。

目前可选的为JavaScript增加强类型检查的解决方案有FaceBook的Flow和Microsoft的TypeScript,从当下的流行趋势来看,TypeScript是绝对的首选。

如果想系统、深入地学习TypeScript又不想自己苦逼的撸官方文档,强烈推荐购买学习搜狗高级架构师梁宵在极客时间上开发的TypeScript课程《TypeScript开发实战》

2.4 可用性

组件的可用性,主要指的是从组件的使用者角度来看待组件的使用体验:

  • 组件的文档是否完善并且易于阅读?
  • 组件暴露的API是否有详细且规范的输入输出描述?
  • 是否有可以直接运行或者借鉴的Demo?
  • 文档是否有考虑国际化?

2.4.1 文档

一个好的组件文档至少应该具备以下内容结构:

一句话描述组件是什么,解决什么问题

# Usage 
// 如何安装和使用,提供简单并且一目了然的示例

# API文档
// 提供规范且详细的API接口文档,包括示例代码或者示例链接

# 补充信息,譬如兼容性描述等
// 如果是浏览器端组件,最好补充一下兼容性的支持情况;如果是Node端组件,也需要描述一下支持的Node.js版本范围

# ChangeLog
// 描述各个版本的重要变更内容以及commit链接

# 贡献、联系作者、License等
// 如果组件希望他人一起参与贡献,需要有一个参与贡献的指南;除此之外,最好再提供一个可以直接联系上作者的方式

很多优秀的开发者可以很好地驾驭代码,但对如何写好一份组件文档却有些苦恼,这是因为代码是给自己看的,文档是给用户看的,这两种思维方式之间存在天然的差异。写文档时,需要换位思考,甚至可以把用户当小白,尽可能为小白考虑的多一些,如此可以提高文档的可读性,降低上手难度和使用的挫败感。

2.4.2 DEMO

对一个组件而言,Demo的重要性不言而喻,还记得Node.js那个经典的几行代码创建一个http server的招牌式demo吗?可以说它几乎成为了Node.js的招牌和广告。

组件的Demo和文档都是为了可用性负责,但应该互有侧重,相得益彰。文档侧重于介绍关键信息、Demo侧重于交付具体应用场景中的用法。

对于比较小的组件,这两者可以合二为一;对于demo代码量较多,且有多种使用方式和场景的情况,建议在examples目录下为每一种场景写一个可以直接运行的Demo;

3. 结语

组件是开发者创造的产品,在这个产品的生命周期中,第一次发布只是一个开始而已。如何让更多的用户关注到并且成为她的忠实用户,乃至参与贡献才是接下来要重点解决的问题。关于这个话题,本文就点到为止了,欢迎大家在下面留言分享自己在组件推广方面的经验和技巧。

参考文档

babel6默认添加use strict引发的问题和解决方案

问题描述

我们有一个遗留系统,其中所有模块全部按照非strict模式,使用ES5语法书写。现在系统中新增一个全新的模块,我们希望能在这个新模块中引入ES2015语法来书写代码,因此引入了babel编译器来实现ES2015-> ES5的翻译,babel的版本是6.*。

在一次测试的时候我们发现Firefox的控制台报了这样一个错误:

TypeError: access to strict mode caller function is censored

字面意思就是在严格模式(strict mode)下使用了caller,这个不符合strict mode的要求,所以报错。

实际上,我们在书写ES2015模块时的确显式地在模块开始的第一行声明了:

'use strict';

这些ES2015的模块里引用了一个遗留模块Ajax模块,这个模块里使用了caller这种写法,因此报错。

问题定位

本以为是新增的ES2015模块声明的use strict的问题,直接删掉就可以解决问题,结果发现删掉后问题依旧,因此怀疑是否是Babel在转换时动了手脚。

因为项目中使用了Webpack来做构建,引入了babel-loader来做转换,所以通过看构建后的源码发现,虽然刚才手动删掉了自己写的'use strict',但是构建时又被自动的加上了。因此基本可以定位是babel搞的鬼。

google查了一下 关键字: babel use strict,找到这样一个页面:How to remove global “use strict” added by babel

其中给出了问题和解决方案:
问题的原因是babel的es2015预设中默认使用了babel-plugin-transform-strict-mode,为所有模块自动加上**'use strict'**, 所以才有了上面的问题。

解决方案当然也呼之欲出:
对于Babel5有一种特殊的解决方案:

//在.babelrc文件中加入这样一行配置:
blacklist: ["useStrict"]

但是在Babel6中这个方案不生效,构建时会报错:

Module build failed: ReferenceError: 
[BABEL] E:\workspaces\branches\bundler\myapp\src\main\webapp\app.es6.js: Using removed Babel 5 option: 
E:\workspaces\branches\bundler\myapp\src\main\webapp\.babelrc.blacklist - 
Put the specific transforms you want in the `plugins` option

问题很明显,Babel6已经不支持blacklist这个option了,所以问题的答主给了一个babel6的解决方案:

因为babel给所有模块自动加'use strict'是通过babel-plugin-transform-strict-mode这个插件加的,因此在配置babel的时候不要使用es2015 的preset,自己手动指定plugins列表,把这个plugin排除在外不引进来就可以了。

查阅ES2015的preset发现压根没有这个plugins,在工程里的node_modules下搜索了一下,发现这个plugin是从transform-es2015-modules-commonjs这个插件里引入的。

至此终于定位到问题的根源了。

解决方案

由于无法限制ES2015模块依赖ES5的模块,所以理论上来说,只要是在ES2015模块里加了use strict,都会存在潜在的风险。所以最好的方式是去掉这个插件,或者再用一个插件移除掉所有的use strict,这样保证所有代码都在非严格模式下执行即可。

第一个方案显然是不靠谱了,除非不写import,否则必须要引入transform-es2015-modules-commonjs插件来做ES2015 -> commonJS Module 的转换,而引入了这个插件,就必须要引入babel-plugin-transform-strict-mode插件,这个很难在配置层面做到限制。

所以只能转向第二个方案:再写一个babel插件把所有的use strict干掉就可以了。习惯性地上github上搜索了一下,居然还真搜到了一个包:babel-plugin-transform-remove-strict-mode。果真是万能的Github!!!

粗略的看了下源码:

exports["default"] = function () {
    return {
        visitor: {
            Program: {
                exit: function exit(path) {
                    var list = path.node.directives;
                    for(var i=list.length-1, it; i>=0 ; i--){
                        it = list[i];
                        if (it.value.value==='use strict'){
                            list.splice(i,1);
                        }
                    }
                }

            }
        }
    };
};

module.exports = exports["default"];

再对比了一下babel-plugin-transform-strict-mode的源码:

import * as t from "babel-types";

export default function () {
    return {
        visitor: {
          Program(path, state) {
            if (state.opts.strict === false) return;

            let { node } = path;

            for (let directive of (node.directives: Array<Object>)) {
              if (directive.value.value === "use strict") return;
            }

            path.unshiftContainer("directives", t.directive(t.directiveLiteral("use strict")));
          }
        }
    };
}

源码我就不解读了,自己看吧。

确认无误后,在package.json文件中的devDependencies中加入

 "babel-plugin-transform-remove-strict-mode":"0.0.2"

然后在.babelrc文件中新增配置:

{ 
  "presets": [
    "es2015"
  ] ,
  "plugins":[
    "transform-runtime",
    //下面这行
    "transform-remove-strict-mode"
  ]
}

就把问题搞定了。

其他尚未确认的问题

  • 这个问题仅出现在firefox中,chrome中并未出现,至于是何原因,仍未深究,有兴趣的同学可以继续深挖一下。

研发工程化新玩法:云+WebIDE

最近关于WebIDE主题的一些调研,还有结合我们团队的现状的一些思考,时间仓促,不一定全面,尤其关于大厂实践这块,基本是根据公开资料了解到的,如有描述不准确的,欢迎指正。

image
image
image
image
image
image
image
image
image
image
image
image
image
image
image
image

参考资料

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.