router-router 4 按需加载实践

image.png

1. 前言

随着前端项目的不断扩大,一个原本简单的网页应用所引用的js文件可能变得越来越庞大。尤其在近期流行的单页面应用中,越来越依赖一些打包工具(例如webpack),通过这些打包工具将需要处理、相互依赖的模块直接打包成一个单独的bundle文件,在页面第一次载入时,就会将所有的js全部载入。但是,往往有许多的场景,我们并不需要在一次性将单页应用的全部依赖都载下来。例如:我们现在有一个带有权限的"订单后台管理"单页应用,普通管理员只能进入"订单管理"部分,而超级用户则可以进行"系统管理";或者,我们有一个庞大的单页应用,用户在第一次打开页面时,需要等待较长时间加载无关资源。这些时候,我们就可以考虑进行一定的代码拆分(code splitting)。

2. 实现方式

2.1 简单的按需加载

代码拆分的核心目的,就是实现资源的按需加载。考虑这么一个场景,在我们的网站中,右下角有一个类似聊天框的组件,当我们点击圆形按钮时,页面展示聊天组件。

btn.addEventListener('click', function(e) {
    // 在这里加载chat组件相关资源 chat.js
});

从这个例子中我们可以看出,通过将加载chat.js的操作绑定在btn点击事件上,可以实现点击聊天按钮后聊天组件的按需加载。而要动态加载js资源的方式也非常简单(方式类似熟悉的jsonp)。通过动态在页面中添加<scrpt>标签,并将src属性指向该资源即可。

btn.addEventListener('click', function(e) {
    // 在这里加载chat组件相关资源 chat.js
    var ele = document.createElement('script');
    ele.setAttribute('src','/static/chat.js');
    document.getElementsByTagName('head')[0].appendChild(ele);
});

代码拆分就是为了要实现按需加载所做的工作。想象一下,我们使用打包工具,将所有的js全部打包到了bundle.js这个文件,这种情况下是没有办法做到上面所述的按需加载的,因此,我们需要讲按需加载的代码在打包的过程中拆分出来,这就是代码拆分。那么,对于这些资源,我们需要手动拆分么?当然不是,还是要借助打包工具。下面就来介绍webpack中的代码拆分。

3. 代码拆分

这里回到应用场景,介绍如何在webpack中进行代码拆分。在webpack有多种方式来实现构建是的代码拆分。

3.1 import()

这里的import不同于模块引入时的import,可以理解为一个动态加载的模块的函数(function-like),传入其中的参数就是相应的模块。例如对于原有的模块引入import react from 'react'可以写为import('react')。但是需要注意的是,import()会返回一个Promise对象。因此,可以通过如下方式使用:

btn.addEventListener('click', e => {
    // 在这里加载chat组件相关资源 chat.js
    import('/components/chart').then(mod => {
        someOperate(mod);
    });
});

可以看到,使用方式非常简单,和平时我们使用的Promise并没有区别。当然,也可以再加入一些异常处理:

btn.addEventListener('click', e => {
    import('/components/chart').then(mod => {
        someOperate(mod);
    }).catch(err => {
        console.log('failed');
    });
});

当然,由于import()会返回一个Promise对象,因此要注意一些兼容性问题。解决这个问题也不困难,可以使用一些Promise的polyfill来实现兼容。可以看到,动态import()的方式不论在语意上还是语法使用上都是比较清晰简洁的。

3.2 require.ensure()

在webpack 2的官网上写了这么一句话:

require.ensure() is specific to webpack and superseded by import().

所以,在webpack 2里面应该是不建议使用require.ensure()这个方法的。但是目前该方法仍然有效,所以可以简单介绍一下。包括在webpack 1中也是可以使用。下面是require.ensure()的语法:

require.ensure(dependencies: String[], callback: function(require), errorCallback: function(error), chunkName: String)

require.ensure()接受三个参数:

  • 第一个参数dependencies是一个数组,代表了当前require进来的模块的一些依赖;

  • 第二个参数callback就是一个回调函数。其中需要注意的是,这个回调函数有一个参数require,通过这个require就可以在回调函数内动态引入其他模块。值得注意的是,虽然这个require是回调函数的参数,理论上可以换其他名称,但是实际上是不能换的,否则webpack就无法静态分析的时候处理它;

  • 第三个参数errorCallback比较好理解,就是处理error的回调;

  • 第四个参数chunkName则是指定打包的chunk名称。

因此,require.ensure()具体的用法如下:

btn.addEventListener('click', e => {
    require.ensure([], require => {
        let chat = require('/components/chart');
        someOperate(chat);
    }, error => {
        console.log('failed');
    }, 'mychat');
});

3.3 Bundle Loader

除了使用上述两种方法,还可以使用webpack的一些组件。例如使用Bundle Loader

npm i --save bundle-loader

使用require("bundle-loader!./file.js")来进行相应chunk的加载。该方法会返回一个function,这个function接受一个回调函数作为参数。

let chatChunk = require("bundle-loader?lazy!./components/chat");
chatChunk(function(file) {
    someOperate(file);
});

和其他loader类似,Bundle Loader也需要在webpack的配置文件中进行相应配置。Bundle-Loader的代码也很简短,如果阅读一下可以发现,其实际上也是使用require.ensure()来实现的,通过给Bundle-Loader返回的函数中传入相应的模块处理回调函数即可在require.ensure()的中处理,代码最后也列出了相应的输出格式:

/*
Output format:
    var cbs = [],
        data;
    module.exports = function(cb) {
        if(cbs) cbs.push(cb);
            else cb(data);
    }
    require.ensure([], function(require) {
        data = require("xxx");
        var callbacks = cbs;
        cbs = null;
        for(var i = 0, l = callbacks.length; i < l; i++) {
            callbacks[i](data);
        }
    });
*/

4. react-router v4 中的代码拆分

最后,回到实际的工作中,基于webpack,在react-router4中实现代码拆分。react-router 4相较于react-router 3有了较大的变动。其中,在代码拆分方面,react-router 4的使用方式也与react-router 3有了较大的差别。

在react-router 3中,可以使用Route组件中getComponent这个API来进行代码拆分。getComponent是异步的,只有在路由匹配时才会调用。但是,在react-router 4中并没有找到这个API,那么如何来进行代码拆分呢?

react-router 4官网上有一个代码拆分的例子。其中,应用了Bundle Loader来进行按需加载与动态引入

import loadSomething from 'bundle-loader?lazy!./Something'

然而,在项目中使用类似的方式后,出现了这样的警告:

Unexpected '!' in 'bundle-loader?lazy!./component/chat'. Do not use import syntax to configure webpack loaders import/no-webpack-loader-syntax

Search for the keywords to learn more about each error.

在webpack 2中已经不能使用import这样的方式来引入loader了(no-webpack-loader-syntax

Webpack allows specifying the loaders to use in the import source string using a special syntax like this:

var moduleWithOneLoader = require("my-loader!./my-awesome-module");

This syntax is non-standard, so it couples the code to Webpack. The recommended way to specify Webpack loader configuration is in a Webpack configuration file.

我的应用使用了create-react-app作为脚手架,屏蔽了webpack的一些配置。当然,也可以通过运行npm run eject使其暴露webpack等配置文件。然而,是否可以用其他方法呢?当然。

这里就可以使用之前说到的两种方式来处理:import()require.ensure()

和官方实例类似,我们首先需要一个异步加载的包装组件Bundle。Bundle的主要功能就是接收一个组件异步加载的方法,并返回相应的react组件:

export default class Bundle extends Component {
    constructor(props) {
        super(props);
        this.state = {
            mod: null
        };
    }

    componentWillMount() {
        this.load(this.props)
    }

    componentWillReceiveProps(nextProps) {
        if (nextProps.load !== this.props.load) {
            this.load(nextProps)
        }
    }

    load(props) {
        this.setState({
            mod: null
        });
        props.load((mod) => {
            this.setState({
                mod: mod.default ? mod.default : mod
            });
        });
    }

    render() {
        return this.state.mod ? this.props.children(this.state.mod) : null;
    }
}

在原有的例子中,通过Bundle Loader来引入模块:

import loadArticleDetail from 'bundle-loader?lazy!./functions/ArticleDetial'

const ArticleDetail = (props) => (
    <Bundle load={ loadArticleDetail}>
        {(ArticleDetail) => <About {...props}/>}
    </Bundle>
)

注意: webpack 2 还是可以用Bundle Loader的

由于不再使用Bundle Loader,我们可以使用import()对该段代码进行改写:

const ArticleDetail = (props) => (
    <Bundle load={ () => import('./functions/ArticleDetail')}>
        { (ArticleDetail) => <ArticleDetail {...props} /> }
    </Bundle>
)

需要注意的是,由于import()会返回一个Promise对象,因此Bundle组件中的代码也需要相应进行调整

export default class Bundle extends Component {
    constructor(props) {
        super(props);
        this.state = {
            mod: null
        };
    }

    componentWillMount() {
        this.load(this.props)
    }

    componentWillReceiveProps(nextProps) {
        if (nextProps.load !== this.props.load) {
            this.load(nextProps)
        }
    }

    load(props) {
        this.setState({
            mod: null
        });
        //注意这里,使用Promise对象; mod.default导出默认
        props.load().then((mod) => {
            this.setState({
                mod: mod.default ? mod.default : mod
            });
        });
    }

    render() {
        return this.state.mod ? this.props.children(this.state.mod) : null;
    }
}

路由部分没有变化

<Route exact path="/post/:id" component={ArticleDetail}/>

这时候,执行npm run start,可以看到在载入最初的页面时加载的资源如下

image.png

而当点击触发到/post路径时,可以看到

image.png

动态加载了2.chunk.js这个js文件,如果打开这个文件查看,就可以发现这个就是我们刚才动态import()进来的模块。

当然,除了使用import()仍然可以使用require.ensure()来进行模块的异步加载。相关示例代码如下:

const ArticleDetail = (props) => (
    <Bundle load={ (cb) => {
                require.ensure([],require=>{
                     cb(require('./function/ArticleDetail'))
                });
       }}>
        { (ArticleDetail) => <ArticleDetail {...props} /> }
    </Bundle>
);
export default class Bundle extends Component {
    constructor(props) {
        super(props);
        this.state = {
            mod: null
        };
    }

    load = props => {
        this.setState({
            mod: null
        });
        props.load(mod => {
            this.setState({
                mod: mod ? mod : null
            });
        });
    }

    componentWillMount() {
        this.load(this.props);
    }

    render() {
        return this.state.mod ? this.props.children(this.state.mod) : null
    }
}

此外,如果是直接使用webpack config的话,也可以进行如下配置

output: {
    // The build folder.
    path: paths.appBuild,
    // There will be one main bundle, and one file per asynchronous chunk.
    filename: 'static/js/[name].[chunkhash:8].js',
    chunkFilename: 'static/js/[name].[chunkhash:8].chunk.js',
  },

5. 运用react-loadable库

5.1 背景

当你的项目足够大时,把所有代码打包到一个bundle中的启动时间就会成为问题。这时就需要把app拆分为若干个bundle,然后根据需求动态加载它们。

一个大bundle VS 若干个小bundle:

image.png

那如何把一个bundle拆成几个呢?这个问题其实已经被 BrowserifyWebpack 这些工具解决得很好了。

但还要做的是在项目中找到合适的地方拆分bundle,然后异步去加载。所以当项目中有东西在加载时的需要一种通信机制。

5.2 基于路由拆分 vs 基于组件拆分

通常的推荐做法就是把app根据路由进行拆分,然后异步地去加载每一个。看上去这种做法对于大多数app已经足够好,例如点击一个连接然后加载一个新页面,这种体验还不赖。

但是,我们可以做得更好。

其实在大多数React的路由管理工具中,路由以组件的形式存在。它们并没有什么非常特别的地方。所以假设我们围绕着组件优化而不是把责任推给路由会怎么样?这样会给我们带来什么?

基于路由 VS 基于组件代码拆分:

image

这会有很多结果。相比只是简单根据路由拆分app,这样做会有更多地方可以拆分。例如 Modals、tabs ,还有很多在用户做相应操作之前隐藏内容的组件。

更别说那些需要推迟到高优先级内容加载完成后才加载的内容了。一个在页面底部而且依赖了一大串类库的组件为什么要和页面顶部的内容同时加载呢?

你大可依然在路由只是简单组件时拆分他们。对于你的app,不管黑猫白猫,捉到老鼠就是好猫。

但我们需要在让组件层面拆分app像在路由层面拆分一样简单。简单得只要改几行代码,其他的事就自动OK。

5.3 React Loadable简介

React Loadable 是一个很小的库,是作者thejameskyle厌烦了你们总说这个很难做 之后写出来的。

Loadable 是一个高阶组件(创建组件的function)用来轻易地在组件层面拆分bundle。

我们试想一下有两个组件,其中一个引入并渲染了另一个。

import AnotherComponent from './another-component';

class MyComponent extends React.Component {
  render() {
    return <AnotherComponent/>;
  }
}

此时我们依赖了AnotherComponent并且通过import关键字同步引入。我们需要一种让它异步加载的方法。

使用ECMA中动态引用一个T39提案,目前stage3 )的特性来修改我们的组件使之异步加载AnotherComponent。

此时我们依赖了AnotherComponent并且通过import关键字同步引入。我们需要一种让它异步加载的方法。

使用ECMA中动态引用一个T39提案,目前stage3 )的特性来修改我们的组件使之异步加载AnotherComponent。

class MyComponent extends React.Component {
  state = {
    AnotherComponent: null
  };

  componentWillMount() {
    import('./another-component').then(AnotherComponent => {
      this.setState({ AnotherComponent });
    });
  }

  render() {
    let {AnotherComponent} = this.state;
    if (!AnotherComponent) {
      return <div>Loading...</div>;
    } else {
      return <AnotherComponent/>;
    };
  }
}

然而,这只是手动做法,并不适用大量其他各种各样的场景。比如说当import()失败的情况,以及服务端渲染的情况。

作为替代,你可以使用 Loadable 把问题抽象出来。Loadable的用法很简单。你仅仅要做的就是把要加载的组件和当你加载组件时的“Loading”组件传入一个方法中。

import Loadable from 'react-loadable';

function MyLoadingComponent() {
  return <div>Loading...</div>;
}

const LoadableAnotherComponent = Loadable(
  () => import('./another-component'),
  MyLoadingComponent
);

class MyComponent extends React.Component {
  render() {
    return <LoadableAnotherComponent/>;
  }
}

但是如果组件加载失败怎么办,我们还需要一个错误状态提示。 为了让你最大化控制要显示的东西,错误提示只是简单地作为LoadingComponent的一个prop传入。

function MyLoadingComponent({ error }) {
  if (error) {
    return <div>Error!</div>;
  } else {
    return <div>Loading...</div>;
  }
}

5.4 基于import()的自动代码拆分

import()的牛X之处在于 Webpack 2 可以自动拆分代码,不论你在何时加入新代码,都不用做其他额外的工作。

这意味着你在使用 React Loadable 时,你可以通过切换 import() 位置来轻易试验代码拆分点,以便让你的app达到最佳性能。你可以在这查看示例工程。或者查看 Webpack 2 文档(提示:一些相关文档在require.ensure() 一节中)

5.5 避免组件加载闪烁

有时组件加载非常快(<200ms),这时加载中的样式就会一闪而过。

有大量用户研究表明,这样会让用户感觉到比实际加载更长的等待时间。如果什么都不显示的话,用户会感觉更快。所以Loading组件需要接收一个pastDelay prop。

这样你的Loading组件只在加载时间比设定delay时间长时才会显示。

export default function MyLoadingComponent({ error, pastDelay }) {
  if (error) {
    return <div>Error!</div>;
  } else if (pastDelay) {
    return <div>Loading...</div>;
  } else {
    return null;
  }
}

这个 delay 默认200ms,但你也可以给Loadable传入第三个参数用来自定义这个值。

5.6

作为优化,你也可以在组件渲染之前对它进行预加载。举个例子,当你需要在点击按钮时加载一个新组建,可能需要用户hover在按钮上时就预加载它。

Loadable 创建的组件向外暴露了一个用于预加载的静态方法,具体如下:

let LoadableMyComponent = Loadable(
  () => import('./another-component'),
  MyLoadingComponent,
);

class MyComponent extends React.Component {
  state = { showComponent: false };

  onClick = () => {
    this.setState({ showComponent: true });
  };

  onMouseOver = () => {
    LoadableMyComponent.preload();
  };

  render() {
    return (
      <div>
        <button onClick={this.onClick} onMouseOver={this.onMouseOver}>
          Show loadable component
        </button>
        {this.state.showComponent && <LoadableMyComponent/>}
      </div>
    )
  }
}

5.7 服务端渲染

Loadable 通过控制最后一个参数同样支持服务端渲染。服务端运行时,通过传入要动态加载模块的绝对路径来允许 Loadable 同步 reqire() 模块。

import path from 'path';

const LoadableAnotherComponent = Loadable(
  () => import('./another-component'),
  MyLoadingComponent,
  200,
  path.join(__dirname, './another-component')
);

这意味着你的“异步加载”和“代码拆分”模块在服务端都是同步渲染。

此时在客户端遇到的问题回来了。我们可以在服务端完整渲染应用,但在客户端,我们同一时间只需要加载一个bunle。

设想一下如果我们能弄清楚服务端bundling进程中哪些bundle是我们所需的会怎样?这样我们就可以把这些bundle一下传给客户端并且带上服务端渲染的确切状态。

今天你其实离这个目标很近了。

因为我们在Loadable中掌握了所有server端依赖的路径,我们可以添加一个新的flushServerSideRequires方法用来返回所有在服务端渲染的路径。然后用webpack –json命令,我们就可以获得一个匹配了对应文件的bundle(我的具体代码)。

6. 结束

代码拆分在单页应用中非常常见,对于提高单页应用的性能与体验具有一定的帮助。我们通过将第一次访问应用时,并不需要的模块拆分出来,通过scipt标签动态加载的原理,可以实现有效的代码拆分。在实际项目中,使用webpack中的import()require.ensure()或者一些loader(例如Bundle Loader)来做代码拆分与组件按需加载。

后续打算弄一个脚手架出来

本项目地址: geekjc-antd-mobile

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,383评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,522评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,852评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,621评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,741评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,929评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,076评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,803评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,265评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,582评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,716评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,395评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,039评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,027评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,488评论 2 361
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,612评论 2 350

推荐阅读更多精彩内容

  • GitChat技术杂谈 前言 本文较长,为了节省你的阅读时间,在文前列写作思路如下: 什么是 webpack,它要...
    萧玄辞阅读 12,681评论 7 110
  • 无意中看到zhangwnag大佬分享的webpack教程感觉受益匪浅,特此分享以备自己日后查看,也希望更多的人看到...
    小小字符阅读 8,147评论 7 35
  • 目录第1章 webpack简介 11.1 webpack是什么? 11.2 官网地址 21.3 为什么使用 web...
    lemonzoey阅读 1,731评论 0 1
  • 作者:小 boy (沪江前端开发工程师)本文原创,转载请注明作者及出处。原文地址:https://www.smas...
    iKcamp阅读 2,747评论 0 18
  • 出了趟门,总会去吃许多吃的,总有些味道让人心里一惊。这些美食给人的幸福,亦是难得,为此书写一番亦值得。 虽是假期,...
    雪之音阅读 807评论 0 4