海阔天空的云

我们在自己的世界里独自狂欢

0%

缘起

最近的工作,接触了很多云计算相关的皮毛知识,虽然是皮毛,但也毕竟让我对此多了几分认识。于是就再整理整理思路,写下来吧。

发展历史

曾经有段时间,哪怕是一点都不了解互联网的人,也能够经常听到“云计算”,“大数据”这样的词汇,他们的热门程度,类似于较早之前的“AI”,”VR”,“人工智能”。不过作为一个搞计算机的,“AI”和“VR”其实我一直也从没有能够从技术上应用过他们,以至于一直觉得十分遥远。但”云计算“却随着了解地越来越多,觉得不再那么高山仰止了。

亚马逊应该是最早布局和应用云计算的。他从02年正式发布了AWS,之后这个业务也给他带来了很高的回报,股价一路飙升。后来微软和谷歌也紧随其后,开始布局云计算。国内云计算的发展稍晚了一下,但是也是很牛的。前一段时间,有篇《阿里云的这群疯子》的文章,讲了阿里云从初创到繁荣的历史转变,一时间也刷爆了朋友圈。阿里云诞生自09年,腾讯云诞生自10年,两个巨头也是你争我赶,虽然在我们外人看来已经很牛了吧,但毕竟亚马逊云计算发展的比较早,还是亚马逊的AWS更加给力一点。

发展历史终究只是浮云,对于开发者来说重要的是能够实际应用这些云计算技术,并为自己创造价值。

saas, paas, laas

关于这三者其实网上已经有很多人在科普了,比如我文章下面参考链接里的阮一峰大大。但是我还是想将我的理解表达出来,给自己一个回顾。

saas

按照我的理解,这三种服务面向的群体实际上并不是重合的。

什么是基础设施

我先说说我自己觉得最好理解的,laas,Infrastructure-as-a-service, 也就是基础设施即是服务。那么问题来了,什么是基础设施呢,我们试想假如你已经有了一个好点子,要建立一个面向全国范围内的电商网站,这个网站一旦投入使用,立刻就会成为行业前十,但是你没有太多的投资。这个时候你需要自己在万网或者狗爹(goaddy)买一个域名,如果是面向国内用户的站点,你要对你的域名进行备案,这些过程都还算简单。

接下来你需要买一台服务器,如果你自己有一些特别的需求,你还可以定制你的服务器。有了服务器,有了IP,你需要你所购买的域名能够指向你的IP,你又需要DNS服务

你要给服务器装上系统,装上合适的数据库,之后你的程序能够在你的服务器上面跑起来了。但是即便如此,可能还是会有很多问题,比如为了容灾的需要,你可能有不止一台服务器,比如3台,但是你只能把这3台服务器放在一起,于是面临的第一个问题,那就是你可能只能保证你服务器所在地的访问速度,而其他区域则保证不了访问速度。但是你只是个小的创业者,根本没有能力在全国范围内,搭建多个服务器来满足全国高效的需要。于是你陷入了苦恼,感慨原来创业并不只是”就缺一个程序员了”。

然后你姑且容忍了没有CDN的问题,你考虑到你的站点虽然面向全国,但是一半的用户来自北京,于是你把那几台服务器放到了北京的联通机房。除此之外,你还需要大量时间来做运维,来支持你的服务器的正常运转。我曾经见过公司从服务器选型到网站服务安全稳定运行的整个过程,的确是需要大量的时间成本以及很高的知识储备的。

解决方案

有了需求,接下来就要有人来做了。就拿亚马逊来说,他很早就做成了全美最大的电子商务平台,而他的站点规模,决定了他一定要使用上大规模的服务器,以及应用上CDN等技术。这个时候,亚马逊说,我想把我的空余的服务器租出去,注意,我不是像dell公司那样把服务器买给你再做做售后服务工作那么简单,我是把服务器租给你,如果你愿意的话,我还可以给你一整套的解决方案。也就是说,你服务器上安装的数据库也可以从我这里来租,数据库的配置我都可以预先给你配置好,保证是一个最佳实践,你无须再为此付出大量时间精力。

我也可以把我自己成熟的CDN技术分享给你,让你的网站也能够无论在哪里,都能够有一个很快速的访问速度。如果你的网站上有大量的多媒体资源(图片,视频等等),你也可以把这些多媒体资源存放在我这里。我保证,你的多媒体资源的访问速度,与我们亚马逊自己的多媒体资源访问速度相当。凡此种种,还有很多。由此,其实我们不难看出来,当今互联网以及计算机发展的一大潮流,就是从原来的购买转变为后来的租用,类似的情况,单就美国来说,还有租用影片的netflix, 以及微软的office虽然也在卖,但也有更多的用户选择了office365。

租用的好处与坏处

让我们暂时岔开一下话题,聊一聊究竟这种租用的方式能够带来哪些好处呢?为了不岔开得太远,我们还是以云计算服务为例,我们来算一笔帐,假设一家小公司初创,为了自己的网站能够安全稳定的上线,他需要服务器以及上面提到的一套解决方案,他为此要付出很大的成本,而又由于是初创企业,自己无法准确估计自己的需求,可能最后买到了合适的服务器,以及应用上了一套解决方案,一旦业务突飞猛进,他又要对此进行升级,相应地,又要花费大量的时间人力成本。而租用的方式,则是能够在一定规模内,**实现付出最小,利益最大。**当然,我也说到了是一定规模内,我们看到公司一旦达到了一定规模,租用的成本要远远大于直接买的方式,因此我们也看到像dropbox就离放弃了AWS转而自建基础设施和网路。

都有哪些基础设施

为了方便,我们这次把视线瞄准国内,看看阿里云是怎么做的。下图是阿里云的产品一览

阿里云产品一览

我们也可以看到,就我们刚才所说的例子来看,用到了弹性计算(云服务器)、存储服务(oss等)、CDN技术以及数据库这些基础设施。阿里云将这些服务拆分成一个个的产品进行出售,我们可以根据自己的需要进行量身定制。

感兴趣的同学,可以通过参考链接,进入阿里云的观望去瞅一瞅。

laas 的好处

所以回过头来再来看云计算中的laas,能够看到他是有很大的优势的。主要表现在:

  • 租用的方式能够让付出最少,收益最大。前面已经提到过
  • 快速完成基础设施的应用,专注业务核心
  • 运维成本降到最低

saas

软件即服务

这个其实按照我的理解,并没有laas复杂。想对于laas而言,我认为主要区别在于:

对于购买laas的使用者,他对于在基础设施之上所搭建起来的应用程序有非常高度的掌控权,毕竟laas只是提供了基础设施的服务,真正的应用程序,还是需要自己来开发。但是saas则并非如此,他聚焦在了更高的层次上,直接为你提供了应用程序,因此你对应用程序本身的掌控力非常差,如果让我举一个例子的话,我会说当今很流行的微商城服务,比如微盟,你通过购买他的这个微商城服务,然后再在自己的微信公众号后台进行一些简单的配置,可以快速地建立起一个微商城来,如果你有需要,当然也可以快速完成微信小程序的创建。相比laas而言, 这种方式更加高效了。只是,会更加受制于人。他有点像是曾经经常被讨论的自有商城和天猫店铺的区别,在这里就不再多做阐述了。

paas

paas,也就是“平台即服务”,我接触的比较少,也就简单说一下我的理解。我自己用过的是,2013年左右的GAE,当时通过一些简单的配置,完成了一个应用,也就是goagent的开发(相信经历过那个年代科学上网的朋友,对此定会不陌生)。为了写这篇文,我也找到了一篇2014年其他人的博文,介绍goagent的原理,感兴趣的朋友,可以通过参考链接过去看看。我用goagent 的时候,还对云计算这一块毫无所知,完全是根据网上的傻瓜教程来搭建服务的。现在会过头来看,就像是阮一峰博文中所述的那样,paas是正好介于laas与saas之间的一种服务

阮一峰博文图片

还就goagent所使用的GAE为例,他并不只是给你基础设施,他也给了你一个应用程序所必须的环境(在这里是python环境),他也不同于saas,他没有给你一个完整的软件服务,让你直接就能够使用,他还是要让你自己来写程序,来实现某些功能的。其实写到这里,我又想起了当年玩贴吧的时候,也有朋友基于百度的BAE(与GAE类似),实现了百度贴吧的自动签订功能,当时依然觉得有趣,也仍不知所言。只是照猫画虎,但这照猫画虎,却让我对计算机有了更深的兴趣。

综述

其实说起来,我最早用的云服务是个人服务,百度云。后来,又用了很多其他的个人云服务,我早在4年多以前,我就说过要构建一个个人云端信息库。我就表态过,自己对于“云”这件事很乐观。我想既然2c是这样的,2b也应该不无例外。包括前面我的许多分析,也都表明,我自己是支持云计算的,即便从社会分工的角度来说,让专业的人做专业的事,这件事,从来也没有错。

标题说的个人工作实践指所见到的公司从服务器选型到网站服务安全稳定运行的整个过程,准备上云计算的过程,使用微盟微商城的体验,使用goagent的体验等等。

参考链接

前面

作为一面全栈工程师(偏重前端),对待老大交代下来的后端任务也是需要认真完成的。前段时间,有个工作,要通过淘宝的OAUTH进行授权,进而获取到access_token,通过access_token来作为授权码,进行所有需要登录权限的API访问,这些API 包括但不限于用户,商品,交易,评价,物流等API.

过程

在这里也无须去科普OAUTH2.0协议到底是什么了,感兴趣的可以自己去查wiki.

我来说的仍然是我自己的理解,所以OAUTH到底做了什么呢?它是一直验证机制,这个机制实现了两步验证,仍然以淘宝API获取access_token为例,淘宝认为开发者访问用户的信息,是以应用为单位的,每一个应用需要一个app_id,app_secret,我们是先要通过app_id 来置换到一个叫做code的字段,这个字段只是作为一个过渡,我们能够通过code值,再调取一个api,才能够最终获取到access_token.

拿实际例子来说,

、授权操作步骤

    此处以正式环境获取acccess_token为例说明,如果是沙箱环境测试,需将请求入口地址等相关数据换成沙箱对应入口地址,操作流程则同正式环境一致。
    实际进行授权操作时,测试的数据 client_id、client_secret、redirect_uri 均需要根据自己创建的应用实际数据给予替换,不能拿示例中给出的值直接进行测试,以免影响实际测试效果。下图为Server-side flow 授权方式流程图,以下按流程图逐步说明。
授权步骤

1)拼接授权url
拼接用户授权需访问url ,示例及参数说明如下:
https://oauth.taobao.com/authorize?response_type=code&client_id=23075594&redirect_uri=http://www.oauth.net/2/&state=1212&view=web

参数说明
名称
client_id
response_type
redirect_uri
state
view

2)引导用户登录授权
引导用户通过浏览器访问以上授权url,将弹出如下登录页面。用户输入账号、密码点“登录”按钮,即可进入授权页面。
授权

3)获取code
上图页面,若用户点“授权”按钮后,TOP会将授权码code 返回到了回调地址上,应用可以获取并使用该code去换取access_token;
若用户未点授权而是点了“取消”按钮,则返回如下结果,其中error为错误码,error_description为错误描述。分别如下图所示:错误

4)换取access_token

方式1(推荐):

通过taobao.top.auth.token.create api接口获取access_token(授权令牌)。api服务地址参考http://open.taobao.com/docs/doc.htm?docType=1&articleId=101617&treeId=1

最后

说起来,我最早使用OAUTH进行登录或者授权操作,还是早些年在用微博的时候,如果OAUTH的应用已经非常广泛了,了解它对我们,无论前端开发还是后端开发都有很多好处.

参考链接

http://open.taobao.com/doc.htm?docId=102635&docType=1

http://open.taobao.com/api.htm?docId=285&docType=2

需求

##1希望能够让开发者写代码更轻松

以往没有引入postcss autoprefixer之前,我们为了css相关特性能够在各个浏览器上的兼容,引入了scss,利用它的mixins来实现prefix。但是每一个mixin 都有一个自己的语法。

比如以往,如果我们我们想要写一条

div {
    display: flex;
    align-items: center;
    justify-content: center;
}

为了各个相对早期浏览器的prefix,我们需要mixin,然后这样写

import 'common/flexbox';

div {
    @include flexbox;
    @flex(1);
    @justify-content(center);
}

然后按照这样的约定,来加上需要的prefix。

这当然是一个方法,但是对于开发者来讲,实际上增加了一些学习成本,而且相当于将标准放到了一边,去使用另外一套标准。

我们之前的mixin,由于已经有好几年的历史。当时为了兼容市面上那些还有很大市场份额的浏览器,prefix写的很全。比如下面这个mixin flexbox到代码:

@mixin flexbox {
    display: -webkit-box;
    display: -webkit-flex;
    display: -moz-flex;
    display: -ms-flexbox;
    display: flex;
}

为了写这篇博客,我又确认了一下,那个mixin flexbox的开源库来自于2013年,距离现在已经五年了,五年的时间,淘汰了很多过时的浏览器,五年前可能需要兼容到ie8,现在移动端开发甚至完全可以无需理睬IE的存在,所以这样的一套prefix就显得有些过时了。那位说了,我更新我的mixin不就好了,比如在2018年做移动端开发,也许我只需要将上面的代码改写成:

@mixin flexbox {
    display: -webkit-flex;
    display: flex;
}

但是我们也许需要想地更远一些,也许再过两年,我们甚至不需要对-webkit-的支持了。那时候,难道我们又要改一遍这个mixin吗?

postcss解决了这样一个痛点,可以直接书写标准的css(现在是css3,但是过几年也可能就是css4了),我们有了这样的工具,再指定一个浏览器兼容表,就能够实现自动prefix,而这相比于sass/scss mixin的方式,维护起来更加简单,应该是一个很好的实践。

2对用户更友好

这当然也是显而易见的,以前我们可能为了兼容更多浏览器,而写更多的prefix,可是随着时间的推移,很多prefix完全并不需要。我们通过postcss来处理,简单明了,缩小了生成的css文件的体积,最后反映到用户那里,会快上一丢丢!

步骤

1 安装postcss-loader ,postcss-preset-env

yarn add -D postcss-loader  postcss-preset-env

安装postcss-preset-env,无需再安装autoprefixer,由于postcss-preset-env已经内置了相关功能。

2 添加.browserlistrc 文件到项目根目录

1% in CN
android >= 4.4
ios >= 8
not ie <= 11

这个需要根据项目的世纪情况来自由选择,我是考虑我们的项目是移动端项目,且确实有用户在用Android4.4 或者ios8,但是再很难看到低于这些版本之下的系统了。

3配置postcss.config.js

module.exports = {
  loader: 'postcss-loader',  
  plugins: {
      'postcss-preset-env': {},
    }
}

4 配置webpack.config.js

{
test: /\.scss$/,
use:[ 
    MiniCssExtractPlugin.loader,
    {
        loader: 'css-loader',
            options: {
                root: 'static',
                minimize: true,
                importLoaders: 1
            }
    },
    'postcss-loader',
    'sass-loader',
]
},
{
test: /\.css$/,
use: [
    MiniCssExtractPlugin.loader,
    {
        loader: 'css-loader',
            options: {
                root: 'static',
                minimize: true,
                importLoaders: 1
            }
    },
    'postcss-loader'
]
}

由于我们的项目中使用了scss,因此需要sass-loader。这里需要注意各个loader的顺序,一开始我的顺序是MiniCssExtractPlugin.loader,css-loader,sass-loader, postcss-loader,结果发现并没有能够autoprefix,原因是如果想让postcss-loader认识并且处理,需要先用sass-loader进行处理,转化成postcss-loader可以认识的代码。

另外需要注意的是,我们这里还在使用css-loader v0.28,目前已经有了v1.0,版本改动很大,以至于我们暂时不能升级。

熟悉前端开发的朋友,应该对 Babel这个项目并不陌生,早在两年多以前,阮一峰大大就已经写过文章《Babel 入门教程》 对他进行了介绍,那个时候,其实Babel应该已经算得上是网红开源前端库了。这两年,Babel其实也一直在发展,我这里想说的是我看到的Babel的成长

babel-preset-env

首先是babel-preset-env,详细的介绍文档在这里:https://www.babeljs.cn/docs/plugins/preset-env/

我也并不会去介绍怎么去用这个,只是想谈谈自己的体会。我记得刚开始使用babel的时候,的确有时候会用上一些stage-2,stage-3 的特性,那时候还为了了解这些所谓的stage去看了ECMASCRIPT 新版本发布的流程,觉得也很有意思。但是虽然有意思,但是配置起来却的确繁琐,有时候,你的确需要为了配置这样一个Babel看好多相关文档,这无疑算是一个痛点了。现在好了,就如同文档里面所提到的:

在没有任何配置选项的情况下,babel-preset-env 与 babel-preset-latest(或者babel-preset-es2015,babel-preset-es2016和babel-preset-es2017一起)的行为完全相同。

babel-polyfill

为什么你这么大

我们都知道,Babel为了存心让我们配置起来更困难,故意将他的功能分拆成了两部分,其一是语法上的转化,这个默认情况下他自会帮我们处理。而另外一部分,就是新API的polyfill,需要我们引入babel-polyfill来完成。当然,我前面说,Babel是存心折腾开发者的确是开玩笑的,毕竟并不是所有的时候我们都需要polyfill的。这有点像是React项目也有两个核心包,ReactReactDOM类似。

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

babel-polyfill是好东西,能够将新API作用在老的浏览器上,但是我们要注意不要滥用。比如我们随便在百度上搜索一篇文章,讲解如何使用 babel-polyfill的引用和使用,往往都会看到有这样的描述:

module.exports = {entry: ["babel-polyfill", "./app/js"]};

这样做,从功能实现上来看当然是没有错的。但是,如果我们原来的入口是

module.exports = {entry: ["./app/js"]};

那么很容易就能够发现由于入口处增加了babel-polyfill,导致webpack在处理之后,最终的到的入口核心js文件比原来增加了有100kb左右。对于这个问题,也已经有issue,不过很奇怪这位网友把issue提到了babel-loader这个项目下。

然后针对这个问题,有老外网友介绍到了transform-runtime等。但是经过我的测试,这也并不是一个很好的实践。

动态识别

为了polyfill 这件事情,其实是有两种思路可循的。第一个思路是根据浏览器缺失哪些特性来补全哪些特性,这个思路的代表是 polyfill-service,这个项目 。它能够根据浏览器的UA来判断当前浏览器缺失哪些特性,进而进行补强。但是经过我的调研,这个项目在天朝可能还存在水土不服的问题,一个很明显的事实是将安卓微信内置的QQ浏览器X5交给这个库来处理,它会认为当前的浏览器是safari,原因大概是因为UA上有safari这个字段。考虑到我们天朝还有类似微信这样很怪异的UA,我认为并不适合在当前这个时间来对此进行实践。(在这里多说两句,polyfill-service这个库和另外一个知名项目fastclick同属于英国金融时报,而最近我发现fastclick在现代浏览器上也有一些tricky的地方,在他们的issue区有能够找到一些吐槽,然而,这个库已经两年多没有人维护了)

把polyfill-service仍在一边,我们接下来说另一个思路。能不能根据我使用了多少新API,来决定我引入多少polyfill的内容呢?比如我只在我的项目中使用了ES6的set,没有使用其他新API。那么我引入polyfill的时候,能不能只引入set的polyfill呢?答案当然是可以的,这就是babel-7的新特性,没错,由于当前稳定版是babel-6,因此这个特性还处在测试阶段。但是根据我自己的测试,表现很多。

不过有趣的是,虽然还只是Beta版,Babel却将之写入了正式版的文档当中,结果当时我为了测试这个新特性,都已经测试到怀疑人生了。文档戳这里,感兴趣的同学可以去看看。总之,实际上只是对babel增加一项配置。

      "useBuiltIns": "usage"

不过由于涉及到升级Babel,当时我在测试的时候,还是有些不顺的。但是一旦迁移成功,应用上useBuiltIns的这项配置,的确能够让polyfill的体积大幅度减小。

browserlist

前面的动态polyfill,的确可以让webpack生成的js文件体积更小,但是能不能再小一些呢?毕竟我们前端开发的目的就是极致的用户体验,当然可以。这个时候browserlist能够帮到我们。

关于这个话题,网路上相关的文章非常多,我就不再多谈了。

感兴趣的同学,可以看看ant-design项目中使用的browserlist。

https://github.com/ant-design/antd-tools/blob/master/lib/getBabelCommonConfig.js

写在前面

需要说明一点是,关于比较常规的React性能优化,可以看这篇文章:
React 16 加载性能优化指南,我要聊到是一些非常规,与实际开发密切相关的坑,但也正是因为这是我个人遇到的个案,或者并不构成普遍意义。

我前面一篇《React开发实践5–详细说说滚动记忆》 其实跟React关系不大,只是因为做React的项目遇到了问题,也就顺手写下来了。最早遇到这个问题,的确第一反映是React这个库的锅,但是稍稍理性的想一想也知道,React的本质也是JS啊,于是很快就将注意力转移了。

说回头来,上一篇文章说到的那个业务需求,在React-router的官方文档里专门有一节在讲,也就是Scroll Restoration。这里也说到了我在上一篇结尾列出来的那个文档:scrollRestoration

如此说来,我现在资料颇多了。有了前面的铺垫,我想我也就不用再废话了。

CMS 这个坑

首先我引入了一个库react-router-scroll-4,眼尖的朋友看出来了为什么还要带个4呢,这是因为原本的库是react-router-scroll,因为这个库不支持react-router v4,因此有开发者又fork出来一个能够支持react-router v4的分支,我用的也就是这一个了。我看过他的源码,核心思想其实就像上面提到的react-router官方文档所介绍的一样,通过session-storage 来处理。

之后,我开始对商品列表页和APP首页进行改造。商品列表页改造地非常简单,无非是对组件进行一次包裹。但是APP首页的改造却遇到了麻烦。同样是对该组件进行包裹,结果现象却是无法实现滚动记忆。这让我好生无奈。这又是为什么呢?我首先想到了是不是这个react-router-scroll-4支持不够好,毕竟这是一个连github项目库都没有的npm包,顺着这个思路,我去看了这个库的源码,又通过打印log的方式去debug,发现了问题所在:原来这个库对 dangerouslySetInnerHTML这种注入HTML的方式没有进行处理,这里面的关键点在于生命周期,此处我并不想多讲,但最终导致无法实现滚动记忆。而我们之所以要在项目中使用 dangerouslySetInnerHTML,也是因为项目中有用到CMS的模块,历史遗留问题,一时无法解决。

但是你知道的,虽然我后来通过修改这个第三方库react-router-scroll-4,最终实现了首页的滚动记忆,但是前面我也提到了由于使用了CMS系统,造成首页的几乎所有点击都是普通的a标签href的跳转,即这样的交互将原本的React app的优势:独立的路由体系打散了。事实证明,在性能上也造成很大问题。

为了更好地说明问题,举个例子。如果是在同一个路由体系下,从首页/切换到 /abc,这个过程,只会去加载abc路由所需要的资源。但是如果脱离了这个路由体系,而是通过普通的a标签href跳转,进入/abc的时候,相当于又重新进入一遍app,这个时候原本一些bundle的资源又会被重新加载一边,虽然这些资源可能被浏览器缓存了起来,但是缓存好了和根本不需要又是两回事,对不对。

所以,如果能够尽量在进入一个React app之后,尽量不再脱离这个app,也就是一直React-router的方式进行跳转,用我前面文章提到过的说法,就是假跳转,其实是能够有很好的优势的。

原来有一个CMS,我会调用一个接口,返回一个HTML页面内容。如果能够将HTML转成REACT组件,这样是不是更好呢?我后来找到了html-react-parser这个库,其实还有另外一个库,不过另外一个库有一些问题,比如图片的url上面如果有大写字母会转成小写字母,造成图片加载失败。

这个库很好地解决了我的问题。接下来,我做了如下改动,一切都水到渠成。

  • 在第一次进入首页的时候,将首页内容(cms html)放置到React state中进行储存,在下次回到首页时,无须重复调用获取首页内容接口,即可快速获取首页内容,达到尽快地相应

  • 将原本html上面的a标签上的click事件进行劫持,根据情况,将原来的a跳转改为react-router 的history.push(),使之不脱离这个app。由于这里没有脱离app,则当接下来用户点击返回回到首页时,还是会按照react-router的方式去返回,则次过程也不会脱离app了。

这里有个小tips,如果我们获取e.target.href的话,我们会发现即便是我们原本写的a标签是这样的

one product

最后得到的 e.target.href也会自动添加上网址。可是我们知道history.push()只认相对路径,因此e.target.href并不能满足我们的需求,而通过获取a标签上的href属性,也就是getAttribute(‘href’),能够保留a标签上的href属性值,而这个值,正是我们所需要的。

结论:

React应用还是应该干净一些,避免使用 dangerouslySetInnerHTML,使用它会有很多tricky的事情发生,比如在componentDidMount的时候,dom上某个元素还处在undefind的状态,原因很可能是因为这个元素是由 dangerouslySetInnerHTML产生的,而他往dom里面添加节点是在componentDidMount之后。

我还是不很确定,通过history api完成跳转的,滚动记忆的情况。

react-router的假跳转,其核心当然还是利用了history api


我在上一篇 薛定谔的导航栏里面,关于代码的部分,用到了下面的语法:

document.addEventListener('focus', this.IosFocusHandler, true);

有同学可能会问了,为什么我们一般都默认capture:false,怎么这里监听focus事件的时候,就改成了捕获模式呢?

MDN上面对这一块已经说的很清楚了,因为focus事件是不支持冒泡的。但是虽然不能冒泡,但是一个事件三个阶段(捕获,处理中,冒泡),还要前两个阶段呢!所以,当我们改为捕获模式的时候,就能够在捕获阶段,拿到focus事件是否触发的消息了。

顺便提一句,与focus事件很像,focusin事件支持冒泡,所以按道理来说,如果我们想要在document上面监听是否有focus的动作,其实是可以使用focusin事件的,也无需再加capture:true ,但是从caniuse.com 可以知道firefox浏览器,直到17年出的firefox52才终于支持上focusin事件。

所以,就有了这个SO的问答: Focusin and focusout methods not working in firefox

如果想想在firefox浏览器下有好的表现,还是应该用

document.addEventListener('focus', this.IosFocusHandler, true);

focus事件加上capture:true 的写法的。

类似地,blur事件和focusout事件的区别也是冒不冒泡的区别。那么问题来了,为什么focus事件不直接支持冒泡,再去掉一个重复的focusin事件呢?

我只能试图从focus和blur的语义上来理解了。毕竟,规范这东西,都是人规定的。


昨天在阅读react-router的官方文档的时候,发现了以前没有注意到的一个东东。<UpdateBlocker>

当时由于在试图解决一个问题: 路由切换的时候,某一个公用组件又重新didmount,即进入下一个生命周期,因而也没有好好地阅读文档,误以为这个<UpdateBlocker>的作用是能够阻止组件进入下一个生命周期。

后来几经测试,也没有得到满意的结果。后来仔细阅读文档,才发现与我之前的理解有很大差别。

React-router文档里面提到的这个<UpdateBlocker>,实际上是只是告诉你结合React能够实现怎样的功能

我们知道有些时候,我们并不想让组件发生更新,或者根据实际情况有选择地进行更新。这样也能够最大限度地保证WEB APP的性能。而这个<UpdateBlocker>的作用正在于此。

这时候,其实还是要说回到React 的官方文档,从reactpurecomponent这一章节可以看出来,

React.PureComponent is similar to React.Component. The difference between them is that React.Component doesn’t implement shouldComponentUpdate(), but React.PureComponent implements it with a shallow prop and state comparison.

就是说React.PureComponent内部已经实现了 shouldComponentUpdate()方法,我们可以拿来就用。而不用像使用 React.Component那样,如果为了实现一个同样的判断组件是否需更新的功能,手动写这样一个方法。

P.S 渣英语理解错了,还以为React.PureComponent


需求

自从在本单位工作之后,大部分时候做的都是移动开发。因此,也就不可避免地会遇到很多移动开发的坑。这些坑来源于真实的业务场景,因此,简单总结一下。

第一个坑是关于导航栏的,我们知道市面上的大部分国产APP,它的iOS版本和Android版本,在UI设计上几乎都没有区别,我们做的APP也是如此。因此,也是会在APP的底部有一个导航栏。

这个时候,有意思的事情就出现了。如果是H5页面的话,通常情况下,这个底部导航栏都是通过fixed定位来实现的。而由于移动终端虚拟键盘的作用,Android和iOS对此的反应又并不相同,因此,就出现了这个坑。

我看了很多APP,比如美团,微信,孩子王等APP,当光标聚集到输入框上面的时候,往往会有一个动画,然后将这个搜索页全屏处理。且不论这些APP是否是内嵌H5的,但这样的交互,如果在H5上面也如是一样,就能避免上面提到的由于fixed定位产生的坑。

这个坑的详细表现

在iOS上面的表现:

软键盘唤起后,页面的 fixed 元素将失效(即无法浮动,也可以理解为变成了 absolute 定位),所以当页面超过一屏且滚动时,失效的 fixed 元素就会跟随滚动了。( 来自Web移动端Fixed布局的解决方案

而在Android上面的表现是:

软键盘唤起后,页面的 fixed 元素并没有失效,但是视窗的高度将重新被计算。(原来的视窗高度-虚拟键盘的高度)

所以,如果我们仍然要把底部导航栏和元素放在一个页面上,就需要着手进行改造。不然,就非常影响用户体验。

解决方案

Web移动端Fixed布局的解决方案 提到的当然是一种解决方案,但是,我试着总是不能符合我的要求。

这就说会到我这篇文的题目《薛定谔的导航栏》,我的想法是这样的:如果弹出了虚拟键盘,则将导航栏的样式设置为 display:none,反之,如果隐藏了虚拟键盘,则将导航栏的样式设置为display:block; position:fixed。所以,一个小白,如果看到了弹出虚拟键盘的页面,你问他,现在到底页面上存在不存在导航栏呢?

假如他回答存在,你把这个虚拟键盘隐藏掉,结果当然是有的。可是当时有没有呢?并没有吧。

加入他回答不存在,你把这个虚拟键盘隐藏点,结果却是有的,很明显又和实际看到的不一样了。

知识点

所以接下来和核心问题,就是如何在iOS上和Android上去检测虚拟键盘的弹出和隐藏了。

下面进入直接贴代码说明问题环节,这里要说明一下,由于我使用了React框架,因此,实际上,我下面写的代码都是JSX文件的一部分

iOS

addIosKeyboardHandler = () => {  // IOS上,弹出输入框后,同样隐藏底部导航。
    document.addEventListener('focus', this.IosFocusHandler, true);
    document.addEventListener('focusout', this.focusoutHandler, true);
}

removeKeyboardHandler = () => {
    document.removeEventListener('focus', this.IosFocusHandler, true);
    document.removeEventListener('focusout', this.focusoutHandler, true);
}

IosFocusHandler = () => {
    if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') {
        window.scrollTo(0, 0);
        const footer = document.getElementById('footer');
        footer.classList.remove('fixed');
        footer.classList.add('hidden');
        window.setTimeout(() => {
            document.activeElement.scrollIntoViewIfNeeded();
        }, 100);
    }
}
focusoutHandler =() => {
    window.scrollTo(0, 0);
    const footer = document.getElementById('footer');
    footer.classList.add('fixed');
    footer.classList.remove('hidden');
}

这里有几个点需要注意:

  • focus事件需要通过捕获(capture: true)的形式才能触发,在document上面的监听
  • focusout要比blur事件在描述虚拟键盘隐藏这件事情上更加准确,blur事件不会冒泡
  • 当弹出输入法准备敲击时,先将页面滚动到顶部

Android

Android 上和IOS上又有一些不同,主要的不同点在于:当弹出虚拟键盘后,通过点击返回键,隐藏掉虚拟键盘,这个时候,如果我们对此不做任何处理的话,那么光标还是会在输入框里面,换句话说,此时对于输入框俩说,并没有发生blur事件,因为很显然,光标还在输入框里面。

因此还是根据Android上的表现来做判断,既然弹出虚拟键盘会让视窗的高度变小,而收起虚拟键盘又会让视窗的高度变大,那么我们就从这里着手。

addAndroidKeyboardHandler = () => {
    this.maxHeight = document.documentElement.clientHeight;
    window.addEventListener('resize', this.AndroidKeyboardHandler.bind(this, this.maxHeight));
    document.addEventListener('focusout', this.focusoutHandler, true);
}

removeAndroidKeyboardHandler = () => {
    window.removeEventListener('resize', this.AndroidKeyboardHandler.bind(this, this.maxHeight));
    document.removeEventListener('focusout', this.focusoutHandler, true);
}

AndroidKeyboardHandler = maxHeight => { // 解决 Android 弹出输入框时,底部导航一同浮起的问题。隐藏底部导航
    const presentHeight = window.innerHeight;
    const footer = document.getElementById('footer');
    if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') {
        if (presentHeight < maxHeight) { // 隐藏底部导航
            footer.classList.remove('fixed');
            footer.classList.add('hidden');
            window.setTimeout(() => {
                document.activeElement.scrollIntoViewIfNeeded();
            }, 100);
        } else {
            document.activeElement.blur();
            footer.classList.add('fixed');
            footer.classList.remove('hidden');
        }
    }
}

小提示

这个时候只需要把我上面代码中的addIosKeyboardHandler 方法在componentDidMount的时候,进行触发即可。

当然,这里可能要补充一个小知识点,由于componentDidMount的时候,通过React渲染的dom已经处于loaded的状态,因此无需再像以往通过window.onload或者jq的$(document).ready去添加事件处理程序。

下面的问题

这样的方法可以解决大部分的问题。但是还是会有一些历史遗留代码与此方案相处地并不融洽。

比如在购物车页面,会有一个输入框,可以改变选定商品的数量。购物车的结算button是fixed的定位,当时考虑到在他下面还有导航栏,因此,提前计算好了导航栏的高度。于是这个部分的样式大概是这样的:

  display: fixed;
  bottom: 1.4rem;

恩,我们使用了rem布局。结果就会造成由于我前面的处理,只会在出现虚拟键盘的时候将footer(底部导航栏)隐藏掉,并不会对结算button进行处理,因此,如果看Android上的表现,这个时候,这个button所在的部分就会和键盘之间有1.4rem的间距。而诡异的是,结算button部分还是处于fixed的状态,而这,并非我所期待的。

这就是另外一个问题了。

所以,还是说回来。这也是为什么之前看到其他一些人有一些友情提示:在做移动开发的时候,不要将input元素和其他fixed的元素放在一起。而像我这种更要命,我直接在页面底部放了两个fixed的元素。

我目前的措施是针对这种情况,单独处理。毕竟,这也只是特例。


嵌套路由的应用

我们知道React-router v4中,可以实现嵌套路由。有了这样一个机制,我们不必将所有的路由完全放到一个统一的位置进行管理,而可以根据情况去对路由进行拆分。坦白讲,我一直认为在一个地方统一管理,要比四散开来维护起来更加简单(可能有点像中央集权和诸侯国各自为政的感觉)。但是最近有个实际应用,让我发现,嵌套路由的价值还是很大的。只是以前我们一直没有合理使用。

先说一下实际使用场景。

我做的电商应用中,有一个叫做“爱逛店”的功能。(由于该应用主打线上线下融合,线下发展其实更好),每次从首页点击“爱逛店”图标进入“爱逛店”的门店展示列表,由于展示门店列表的过程是:

获取当前用户地理位置坐标=》后端根据地理位置远近排序=》将排好序的门店列表给到前端=》前端展示

造成展示门店列表的过程其实是相对比较慢的。为了能够有一个尽量好的体验,我们在展示门店列表前,会有一个loading的动画效果。而由于采用路由统一管理的机制,原来的门店列表的组件和门店详情的组件之间是并列关系,导致每次从某一个门店详情页面返回到门店列表页面,都会造成门店详情页面组件生命周期结束,门店列表页面进入下一个生命周期。进入下一个生命周期意味着什么呢?意味着它又需要重新走一遍上面的流程,尽管有了这个loading的动画效果,但是体验上还是会差很多。而且,更影响用户体验的是:这个loading效果本来是好意,能够让用户在等待期间看到些东西,但是由于浏览器的机制,这个时候,浏览器判定你初始页面高度只有100%(屏幕高度)这么高,则一旦门店列表列出来之后,浏览器不会再将页面滚动到上一次这个页面滚动到的位置了。这意味着,你从门店详情页面返回到门店列表页面,每次先看到loading效果,然后每次看到的都是最顶上的三五个门店,而不是你上次浏览到的门店,这体验,当然就更差了。

有什么解决办法吗?当然了。使用嵌套路由,上面的问题就都不是问题了啊。

首先明确我们的需求,由于我们的用户看到的门店列表是基于地理位置的,他的大致地理位置短时间内是不会改变的。(我指的是用户点进门店详情页面时在北京,点返回后就来到了上海),门店的数量短时间内也是几乎不会改变的。(我指的是用户点进门店详情页面时有100家门店,点返回后就有105家)。正是基于这样的一个判断,我们或者可以将这些门店的数据暂时存储起来。每次用的时候,直接取出来用就好了。那么这个时候问题来了,我们应该将这些门店的数据存储在哪里呢?浏览器的本地存储(localStorage,sessionStorage)? 又或者是代码里?

解决办法

不绕圈子了。我的办法是不如增加一个”爱逛店”管理组件。采用嵌套路由的方式来实现,门店数据的存储。先在主路由配置处(我的项目是app.js)写上:

        <RouteWithLayout path="/stores" hideFooter={false} loader={() => import('./Store/Store')} />

然后先看下面这段代码:

        <Store>
                <Switch>
                    <RouteWithLayout exact path={`${this.props.match.url}`} hideFooter={false} loader={() => import('./StoreList')} stores={this.state.data} withLayout={false} />
                    <RouteWithLayout exact path={`${this.props.match.url}/:storeId`} hideFooter={false} loader={() => import('./StoreDetail')}  withLayout={false} />
                </Switch>
        </Store>

从上面代码我们可以看出来,无论用户想要浏览某一个门店(StoreDetail),还是说想要浏览门店列表(StoreList),都需要先进入Store这个管理组件。我可以用这个管理组件来做什么呢?很简单,以前没有这个管理组件的时候,从/stores这个路由切换到/stores:storeId这个路由,第一个组件生命周期结束,第二个组件生命周期开始。而由于我把获取门店列表的方法写在了StoreList这个组件里,因此每次从门店详情页点返回都会重新去找门店列表。现在,我要做的是,把获取门店列表的方法提到上一层,提到Store这个组件当中。

这个时候,我们再来看看。如果/stores这个路由切换到/stores:storeId这个路由

组件 生命周期
Store didupdate
StoreList unmount
StoreDetail didmount

而如果/stores:storeId这个路由切换到/stores这个路由呢?

组件 生命周期
Store didupdate
StoreList didmount
StoreDetail unmount

没错,无论哪种场景,Store这个组件都只会update,而不会进入下一个生命周期。因为在一开始我就配置好了。

        <RouteWithLayout path="/stores" hideFooter={false} loader={() => import('./Store/Store')} />

因为在/stores这个路由的模糊匹配下,我任意切换路由,都只会让Store这个组件更新,不会触发didmount,那么我干脆把获取门店列表的方法写到他的componentDidMount()之中不就好了么,然后门店列表作为一个state值存储在这个组件的状态里,之后每次切换路由都能够立刻获取门店列表,无需loading,浏览器也能够记住上次这个页面滚动到的位置,返回后,还是原来的位置。

那位又说了,可是我要是直接在浏览器上输入某个门店的url,直接访问某个门店。你是不是还要把这个门店列表也给我获取到呢?那是不是得不偿失,更加影响用户体验呢?说的很对,所以我的策略是,在获取门店列表的方法中进行判断,只有exact match path="/stores"这个路由的时候,我才去从服务端获取门店列表。那位又说了,那你这时候如果,如果点击页面上的返回导航,你回到门店列表页,由于在Store组件didmount阶段你没有获取门店列表,这个时候肯定会出错啊。是的,那我就不返回门店列表呗。反正用户你也不是从门店列表进入的我某个门店页面的,我直接让你回我的APP 首页就好了嘛!

除了用户体验的提升外,另外的好处是:随着你项目的扩大,这个对路由的分拆也能够便于管理。毕竟如果你的主路由配置里有一两百行路由配置,看起来也会很不舒服,类似的哲学是从中央不直接管辖所有的地级市(中国有338个地级市)而将这些市根据地理位置,由省来管理。中央只管理省一级。

总结

前面说的很具体,具体的东西好处是便于理解。坏处可能就是,不能做到举一反三。所以我这还是来总结一下。

如果你有一个应用场景:数据不会频繁更新,数据的请求过程影响用户体验,你的用户需要频繁地从子路由到孙子路由或孙子路由到子路由之间来还切换。那么可以考虑在子路由组件中,进行嵌套路由的配置,将请求数据的方法写在子路由组件当中,这样一来,用户体验会有一个很好的提高。


一个电商应用,有他的一个基本的结构。为了简化我们的代码,我们每一个页面组件外面都会再套上一层<BaseLayout/> ,通过配置<BaseLayout/>,我们可以控制每一个页面是否需要使用公共页头(header),公共的底部导航(footer)。这当然也是一些很基础的功能了,在此不再赘述。

基于上面的需要,我们最初写了下面的代码

        const RouteWithLayout = ({loader = null, exportName = null, hideFooter = true, hideReturnTop = false, ...rest}) => {
                const loadableOpts = {
                    loader,
                    loading: LoadingComponent
                };

                if (exportName) {
                    loadableOpts.render = (loaded, props) => { // eslint-disable-line  react/display-name
                        const Component = loaded[exportName];
                        return <Component {...props} />;
                    };
                }
                const LoadableComponent = Loadable(loadableOpts);
                return (
                    <Route {...rest} render={matchProps => (
                        <BaseLayout hideFooter={hideFooter} hideReturnTop={hideReturnTop} {...matchProps}>
                            <LoadableComponent {...matchProps} />
                        </BaseLayout>
                        )}
                    />
                );
        };

看了前面 part-1的同学可能知道,我们在项目中引入了React-loadable这个第三方库,上面的代码也是基于它的实现。乍一看,他似乎没有问题。接下来,我们只需要写这样的配置就ok了。

        <RouteWithLayout exact path="/shopping-cart" hideFooter={false} hideReturnTop={true} loader={() => import('./ShoppingCart')} />

但是,在2-实现一个类似客户端的商品轮播图阅览交互 里面,我也已经提到过,我在此处遇到了一个坑。这个坑,也正是与上面我展示的代码有关。

再来简单回顾一下,当路由从/products/:productId 到/products/:productId/showpic,事实上,动态引入的组件还是那个ProductDetail组件。按照我们的期待,如果前后两次引入的是同一个组件,这个组件需要update,而不是重新mount。但是就如同我在《React开发实践–2》里面介绍的那样,当路由发生改变的时候,</ProductDetail />竟然进入了下一个生命周期。

究其原因,由于路由发生了改变,如果RouteWithLayout没有写其他的生命周期方法的话,那它必然会得到更新。那么我们来看看RouteWithLayout组件更新之后的影响。这时候我们发现LoadableComponent这个变量的引用实际上发生了变化。因此造成当render <LoadableComponent {...matchProps} />时,需要到下一个生命周期重新render。

可是对象引用发生了变化这种事情,我们应该怎么理解呢?说起来,这其实考察的还是JavaScript的基础。让我们暂时忘掉React这个框架,只提JavaScript。来看看下面这几行代码

let arr = []
for(let i=0;i<2;i++) {
    const foo={a:1};
    arr.push(foo)
}

console.log(arr[0]===arr[1]);  //false

大概学过半年JavaScript的同学,也都能明白这里为什么arr[0]和arr[1]并不相等了。这里还是谈谈我对这件事情的理解:

对于for循环内部的每次执行,都是一个block,每一个 block之间都相互独立。所以,我们也可以将上面的代码进行拆解。

let arr = []
 {
    const foo={a:1};
    arr.push(foo)
}
 {
    const foo={a:1};
    arr.push(foo)
}

console.log(arr[0]===arr[1]);  //false

这个时候,就更好理解了。对于每一个块里面的i变量来说,他只在当前块内有效,当前块执行完毕之后,他就会被垃圾回收,销毁掉了。因此当我们比较的时候,arr[0]和arr[1]之间就不相等了。

有同学可能会问了,你用的是ES6 的语法。ES5是这样的吗?of course!

var arr = []
for(var i=0;i<2;i++) {
    var foo={a:1};
    arr.push(foo)
}

console.log(arr[0]===arr[1]);  //false
console.log(i);

关于我的这个示例,ES5 和 ES6的结果并没有区别。如果硬要说有什么区别的话,那就是i这个变量是否在块级(block)之外继续有效。那相对于我本次讨论,实际上是另外一个话题了。

既然说到了这里,我们再想一想。怎么样才能让arr[0]与arr[1]相等呢?在JavaScript,只能是在比较的两者为同一引用的情况下,才能实现严格相等。所以按照这个思路,我们可以对上面的代码进行个改写。

const foo={a:1};
let arr = []
for(let i=0;i<2;i++) {
    arr.push(foo)
}

console.log(arr[0]===arr[1]);  //true

没错,就是把foo的生命提到for循环之外。这样的话,每次推入到arr这个数组中的值的引用就一定是相同的了。

这时候,让我们重新回到刚才遇到问题的React代码。RouteWithLayout当前是一个无状态组件,只要它的父组件发生了更新,它也会相应地发生更新。然后执行相应的代码。这其实就类似于我们上面提到的for循环结构。每一次RouteWithLayout执行的时候,都像是for循环在执行其中一个block,不难理解,每一次LoadableComponent的引用发生了变化。

所以,还是上面的思路,我们把LoadableComponent的声明放到render()方法之外,怎么做到呢?改写无状态组件为有状态组件。将这个对象声明以状态的方式存在于组件当中。这样就可以实现我们想要的效果,即如果动态加载的是同一个组件,则组件只会发生更新,而不会进入下一个生命周期。相应的代码在《React开发实践–2》中已经提到过了,再粘贴一遍。

        `
        import React from 'react';
        import ReactDOM from 'react-dom';
        import PropTypes from 'prop-types';
        import {BrowserRouter, Route, Switch, Redirect} from 'react-router-dom';
        import BaseLayout from './BaseLayout';
        import Loading from 'react-loading';
        import Loadable from 'react-loadable';


        const LoadingComponent = props => {
        // something 
        };


        class RouteWithLayout extends React.Component {
            state = {
                loader: () => {},
                exportName: null,
                LoadableComponent: null
            }

            static getDerivedStateFromProps(nextProps, prevState) {
            
                if (nextProps.loader.toString() === prevState.loader.toString() && nextProps.exportName === prevState.exportName) return null;
                const { loader, exportName } = nextProps;
                const loadableOpts = {
                    loader,
                    loading: LoadingComponent
                };

                if (exportName) {
                    loadableOpts.render = (loaded, props) => {
                        const Component = loaded[exportName];
                        return <Component {...props} />;
                    };
                }
                return {
                    loader,
                    exportName,
                    LoadableComponent: Loadable(loadableOpts)
                };
            }

            render() {
                const { hideFooter, hideReturnTop, ...rest} = this.props;
                return (
                    <Route {...rest} render={matchProps => (
                        <BaseLayout hideFooter={hideFooter} hideReturnTop={hideReturnTop} {...matchProps}>
                            <this.state.LoadableComponent {...matchProps} />
                        </BaseLayout>
                        )}
                    />
                );
            }
        }`