海阔天空的云

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

0%

写在前面

需要说明一点是,关于比较常规的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>
                        )}
                    />
                );
            }
        }`


写在前面

这篇仍然并不会也不打算去介绍或者科普React框架的基本知识点,但是会在行文中有很多涉及。

怎样实现的思路

一个电商APP, 要想让他的web HTML5 体验更接近客户端本身还是有很大的挑战的。怎样做出一些更符合客户端用户操作习惯的交互来,是前端开发中很重要的一个点。

我想要做的是一个类似客户端的商品轮播图阅览交互,总体来说,他的主要逻辑是:

  1. 在商品详情页显示商品轮播图
  2. 点击任意一张商品轮播图,能够全局浏览商品轮播图
  3. 通过左划右滑手势可是在全屏状态下阅览商品轮播图
  4. 点击返回键,退出全屏,回到商品详情页

有赖于react-photoswipe 这个库的支持,它已经将前三项做得很好了。我希望做的其实是第四项。细化需求,它不仅仅要求能够回到商品详情页,还希望能够最大限度地优化性能,最好的体验就是像客户端那样。

这里,我采取的措施是,将我集成了react-photoswipe 这个库的组件放到</ProductDetail /> 组件之中。

1
<ProductCarousel images={product.images} alt = {product.name} />

接下来,我的想法是这样的:

当用户在商品详情页点击任意一张商品轮播图时,页面上全屏显示那张商品轮播图,同时,当前的路由发生改变,由/products/:productId/products/:productId/showpic,这样,当用户点击返回的时候,就能够实现从当前路由/products/:productId/showpic返回到了/products/:productId

但是,这个时候,我遇到了很多与React-router有关的坑,其实与其说是坑,倒不如说是知识上的不足。当我把这些知识补足之后,再来看,其实并不难了。

React-router

第一个问题: exact

很明显,第一个问题是关于React-routerapp.js 中的配置的。前面介绍了我的总体思路,这个思路的一个关键点是当路由发生改变的时候,</ProductDetail />组件不应该unmount 或者重新didmount, 这样才能够让这种轮播图阅览交互更像是客户端的体验。

这时候,用到的第一个知识点是React-router的exact 配置。这本身并不是什么难点,我在这里只是想说明它的这一妙用。

由于我们大部分的React-router的匹配都采用了exact的匹配,在这个时候,我把原来对商品详情页的exact匹配去掉了,使得能够实现前述我的思路。

第二个问题: </ProductDetail />组件的生命周期

前面说了,我们在这个项目中,为了优化前端性能,使用了动态加载,来进行split coding。这个时候,我发现,如果使用了动态加载,我的</ProductDetail />组件每次进入轮播图全屏页面时,都会因为进入到了下一个生命周期,而不能进入轮播图全屏页。

这个问题出在哪里呢?原来这个问题是由于React-router以及它所关联的React生命周期造成的。

我以前有个误区,不知道各位读者是否也会存在这样的误区。就是React简单地认为它是单向数据流,除非父组件传递給子组件能够改变父组件本身的属性,否则,子组件的更新,不会引起父组件的更新甚至进入下一个生命周期。

但是,当然没有那么简单。

还是以我上面的问题为例,为什么会出现那样的问题呢?原因是:当一开始进入轮播图详情页面时,在我的<ProductCarousel />这个子组件发生了变化,他引起了路由的变化。由于我的路由管理由React-router完成,实际上顶部的<Switch>也相应地发生了更新,甚至可以这么说, <Switch>组件也必须发生更新,只有这样,他才有存在的意义。而紧接着,由于我们对动态引入的错误使用,造成了</ProductDetail />组件被迫重新装载,而他的重新装载又直接造成了他进入下一个生命周期。

关于我们是怎样错误使用动态引入的,以后会详细来解释。

这里,还是针对我刚刚提到的误区来谈一谈。所以,还是说回来,这当然不能算是坑,这是对知识理解的不深刻。

看到这个问题之后,我最初想到的是: 如果路由改变了,而加载的组件(import)并没有改变,就不更新这个组件的状态

但是,马上就发现了问题,还是这个例子:路由从/products/:productId/products/:productId/showpic发生了改变,这个时候我们没有改变</ProductDetail />组件的状态,则他的属性(location,props)较之前并没有发生改变。这本身就不是正常的,也就会从根本上造成虽然一个问题解决了,但是总还有新的问题产生。为什么呢?因为你没有按照规律办事啊!

React-router的思想是什么呢?其中一个思想就是组件上的React-router赋予的属性(location,history )是与实际相一致的。但是刚才,我试图通过调用 componentWillReceiveProps方法,来实现阻止组件更新的目的。

结果造成什么问题了呢?

一开始,我点击轮播图,路由切换,打开轮播图,没有问题。可是当我点击返回键的时候,由于我前面的判断:如果路由改变了,而加载的组件(import)并没有改变,就不更新这个组件的状态,导致没有退回到商品详情浏览页。这当然是不正常的了。

我们应该顺应React-router的设计思想,最后采取的办法是:如果路由改变了,而加载的组件(import)并没有改变,这个装载的组件不变,但他的属性(location,history)要相应地发生改变,他必须仍然在原有的生命周期中

可能听起来,还是有点抽象,还是直接上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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>
)}
/>
);
}
}

第三个问题: 匹配不匹配

我们知道,在客户端的图片阅览里,实际上,是不能直接通过访问地址的方式,来全屏浏览商品轮播图的,这是一个更深层次的交互设计,因此我在</ProductDetail />组件的componentDidMount 方法中写下了这样的代码

1
2
3
if (!this.props.match.isExact) {
this.props.history.replace(this.props.match.url);
}

如此依赖,当初次载入</ProductDetail />组件时,一律显示商品详情页面。

最后

最终的实现效果,前面提到的指标都已经完成了。也能够比较完美地使用动态加载来加载组件了。


写在前面

最近,在实际项目中,遇到了一个坑。正好,借由这个坑,来多聊一聊前端的东西。

需求

背景知识

  • 我们的技术栈是React + React-Router+webpack4 的结构
  • 我们的应用是电商网站,所有页面会有一些公共的部分。因此,我们需要在实际用到的组件外面,再包裹一层组件,我们叫做

知识点1 动态加载

在 webpack 2或 webpack 3的时代,我们自己写了一个动态加载的实现。
这里的核心,大概就是下面这一行代码了。

1
loader={() => System.import('./ProductDetail').then(c =>  c.default)}

Module 的加载实现

https://github.com/systemjs/systemjs

包括我们当时,还自己实现了一个组件。嗯,他其实也是一个高阶组件。

到后来,我们引入了React-loadable 这个组件,同时由于能够使用babel-plugin-syntax-dynamic-import这个babel 插件,我们直接在引入组件方式上发生了改变。原来,我们的引入方式是System.import,现在我们则可以将import去掉,直接用来自未来的JavaScript语法规则来实现我们的需求了。

后来,阅读源代码我发现,我们自己实现的那个动态加载的高阶组件(),基础逻辑是相同的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
return class LoadableComponent extends React.Component {
constructor(props) {
super(props);
init();
}

render() {
if (this.state.loading || this.state.error) {
return React.createElement(opts.loading, {
isLoading: this.state.loading,
pastDelay: this.state.pastDelay,
timedOut: this.state.timedOut,
error: this.state.error,
retry: this.retry
});
} else if (this.state.loaded) {
return opts.render(this.state.loaded, this.props);
} else {
return null;
}
}
}

在这里,只截取了部分代码。

我在这里,就不展示我们以前的组件实现了。我们只实现了React-loadable最基础的功能,事实上它还能够有以下几点优势:

  • 当加载组件的过程中,给出过渡效果
  • 当加载失败、加载错误后,给出一个类似404的页面,不致于白屏
  • 能够进行retry 等行为

正是因为它有这些好处,并且有很多人的实践,我们才用到了它。

但是,无论是在引入React-loadable 之前还是之后,都有一个坑,我试图搞定它,却一直没有搞定,直到最近,才终于把这个坑填上。

下回《2-实现一个类似客户端的商品轮播图阅览交互》


1 html-webpack-plugin 已支持webpack 4
2 inline-manifest-webpack-plugin 的升级替换

inline-manifest-webpack-plugin 插件本身已支持webpack 4, 但从测试看有bug,不能将生成的manifest.js 插入到frontend-js.html 文件中。

解决方案,使用webpack-inline-manifest-plugin
https://github.com/szrenwei/inline-manifest-webpack-plugin/issues/10

3 extract-text-webpack-plugin 的升级替换

项目主页:https://github.com/webpack-contrib/extract-text-webpack-plugin

Since webpack v4 the extract-text-webpack-plugin should not be used for css. Use mini-css-extract-plugin instead.

官方推荐使用 mini-css-extract-plugin 代替

因此根据mini-css-extract-plugin(https://github.com/webpack-contrib/mini-css-extract-plugin) 项目介绍 ,添加了一些mini-css-extract-plugin 的高级配置

4 CommonsChunkPlugin 废弃

https://medium.com/webpack/webpack-4-code-splitting-chunk-graph-and-the-splitchunks-optimization-be739a861366

提取common vendor。

5 不再使用 inline-chunk-manifest-html-webpack-plugin

项目没有更新,插件不支持webpack 4,考虑到本插件优化作用不大,可以先不用。

6 统一使用url-loader 处理非js,css(scss)等资源
webpack在处理非js和css(scss)时,统一使用url-loader 。url-loader 本身是对file-loader 的扩展,当文件大小超过规定的限制时(目前limit为3kb),则将文件资源输出到指定文件夹中。

7 优化首屏,减少网络条件差时的白屏时间

head 里面的css 阻塞进程,使用webpack 3 时的CSS文件637kb。使用webpack 4 后,将url-loader limit 限制在3kb,使转base64 的资源越少越好,尽量不占用css的的空间。目前生产环境CSS 文件大小474kb。

8 升级后变化

升级前 build Done in 34.25s.
升级后build Done in 22.72s.


我在去年这个时候,写了 《博客三周年小记》 ,时隔一年,这个博客,终于又更新了。(我太懒了,连头图都用的去年的)

我自己其实也没有料到会是这么长的更新周期,但是正如看官老爷们所看到的,的确如此。在这里我就不再废话,找寻其他不更新的理由了。

因为有了一年的更新周期,我甚至觉得这篇博客可以作为我这过去一年的回顾来看。

1.工作

过去的一年,在同一家公司完整地工作了一年。对我来讲,过去的一年,还是有很大的成长。

都在哪里有提升

经验积累

我刚刚找工作那会儿,因为跨专业,因为没有工作经验,找工作并不容易。我自己也曾经想,究竟工作经验这种事情重不重要?如今,我已经正式工作快两年了,如果再问我工作经验重不重要,我可能会回答确实很重要。

哲学上有说从量变到质变的概念,工作经验的积累,在我看来就是量变到质变的过程。在我最初找工作那会儿,也会去背一些面试题,但是坦白来说,有些面试题,即便是背下来了,却还是不知其然,更不知其所以然。举个例子,作为一个前端,经常会有的面试题会问到“前端如何做性能优化”,在最早的时候,完全没有实践,就是去背那些答案。这些答案当然正确,但是却并没有经过实践,只是理论。可是有了这方面的工作经验,甚至是坑之后,想不去明白这些道理都是不行的。让实践跟理论一起去碰撞,实践去印证理论,理论再去指导实践,应该还是一个比较可靠的学习和工作方式,而这,都是要靠时间来积累经验的。

但是又没有必要把工作经验看得特别重要。这也让我想起来,前两天看热门日剧《unnatural》,其中有一集讲到两个解剖的法医比较谁的说法更可靠,一个老头说他解剖过15000具尸体,而由石原里美扮演的女主角则只解剖过1500具尸体,两者之间差了十倍的工作经验,但最终的结果当然是有光环护体的女主战胜了法医权威。所以,还是不能盲目地去崇拜工作经验,以为工作经验多了,水平就一定会直线提高,有时候并非如此。

说回来,对我来说,过去的一年,增长的工作经验,更多时候,是让我去印证之前在书本上看到的理论。让我明白,原来书上短短的一两句话,在实践之中就有如此多的门道。而这,正是我过去一年体会的。如果说的再明白一点,我认为,我关于前端的大部分知识,都是在学习前端的第一年获取到的。而这些获取到的知识,经过工作,消化吸收,则是在过去的一年完成甚至还没有完成,需要继续完成的。

端到端的打通

我在上家单位的时候,做的就是一个纯前端开发的工作,而在过去的一年里,我做起了全栈开发。技术栈是 python + react + postgresql 。起初,对我来说,这样的开发实践是一个很大的挑战,毕竟在此之前,我只是用python 写过简单的爬虫,关于数据库,甚至都没有动手写过一个查询语句。对于这个技术栈的后端,我几乎是从0开始做起,但是好在老大给了我很大的耐心。让我能够去补齐这些短板。我也的确觉得自己还是需要这样的一些全栈工作的训练的,这确实让我更能够理解计算机,理解互联网。以前有一个很有名的面试题,大概是说从用户用键盘在浏览器地址栏上面敲出google.com 到出现页面之间,发生了什么。这个面试题我自己从来没有遇到过,但我看很多人回答过,的确呀涉及到很多的知识。这些知识,当然不止有前端知识,还有很多更广阔的知识,包括计算机网络,通讯等,想的越多,能关联到的知识,学科领域就越多。毕竟,既然有些人能够因为别人是计算机专业的,就去找他修电脑。

我也因为自己的“野路子“出身而出过一些问题,比如在写一些API调用方法的时候,不写异常处理。给自己找理由说前端JS业务代码几乎从来不会进行try catch 的异常处理。也会因为一个break和 continue 的区别,而给代码造成bug。我也试想,如果我在大学里面修了完整的计算机课程,有了基本的计算机素养,或许会好很多。以前,我只写前端的时候,更像是在造空中楼阁,越造可能越虚幻,不稳定。而有了这种端到端的训练,对我来说,即便是以后不再写后端逻辑,也是受用的,至少基础更加扎实了。

前端重要吗

最近正好听了一期podcast,是《anyway.fm》的一期。他们请来了一个做设计的嘉宾进行交流,这位嘉宾提到自己在前雇主那里的收获的时候,说他发现原来设计没有那么重要。换到我自己这里,我也在问自己一个问题,前端重要吗?我的回答其实跟那位嘉宾差不多,用户前端所见所用(UI/UE)对于用户来说也可以笼统地算作设计,设计没有那么重要,前端也没有那么重要,一个电商网站,最重要的是他的商业运作,是能够把货物卖出去卖得好,乃至于无论前端,后端,设计,都不过是工具辅助工具而已,最终服务的,都是商业行为和商业逻辑。所以,我也看到很多程序员后来转型做了管理或者当了产品经理,这里面也不是没有道理的。

明确定位很重要,知道自己吃几碗干饭也很重要。知道了这些之后,如果你再说,我就是喜欢前端,或者说我就是喜欢开发,我就是不喜欢做产品分析,有问题吗?当然没有问题。这就好像是这个社会的分工,每个工作都有他的价值,但总有一些更加重要。我曾经听说某富家千金想以后当一名面点师,面点师当然也是社会的一个重要分工,但他显然这个社会最重要的,没了他,社会还是能够照样运行。但摆正位置,做自己喜欢做的事,或者能够做的事,很重要。

2.生活

过去的一年,我的生活稳定了,相比于毕业那年(2016年)的确稳定了,这种稳定有时候会让我焦虑,有时候又让我无所适从,我有时候也会面临很多新问题,比如相亲。生活当然精彩,在此省略一万字。

记录生活的地方很多,我的推特,我的公号,也欢迎各位看官关注。

3.获取信息

既然是因为博客四周年的由头,写的这篇博文。还是想说说跟博客有关的,过去的一年里,我还是会通过RSS来获取大量的信息,不过RSS里面几十个订阅源,活跃的也的确越来越少了。更多的时候,我会选择在工作日的中午饭后,打开Inoreader这个RSS阅读器进行阅读,这时候很大部分内容是好奇心日报的feed。好奇心日报是我最近一年在看的一个feed,也可以推荐给各位看官老爷。除此之外,我的订阅源里,独立博客虽然也偶尔更新,但是由于我自己很少更新博文了,导致其他博客更新之时,我也没有了去评论互动的欲望。虽然如此,我还在用这种非常传统的方式获取信息,我觉得是很高兴的。我没有用今日头条,但是会用微信的看一看,会关注微信上面有趣的公众号。

4.最后

这篇更新完之后,下次更新会是什么时候呢?