使用import配合React-Router进行code split

1,844 阅读9分钟

title: 使用react-router和import
Router进行代码分片
date: 2018-03-19 08:58:50
tags: 翻译


原文链接

  • 代码分片可以让你把应用分成多个包,使你的用户能逐步加载应用而变得流行起来。在这篇文章中,我们将会看一下什么是代码分片和怎么去做,了解怎么去配合React Router去实现它。

  • 现在是2018年。你的用户不需要为了一小块内容而去下载整个应用。如果一个用户下载所有的代码,仅仅是为了请求一个注册页面是毫无意义的。而且用户在注册时并不需要下载用户设置页的巨大富文本编辑器代码。如果要下载那么多内容的话,是很浪费的。而且对于一些用户,他们会抱怨不尊重没有特别好带宽的他们。这个点子近年不仅很热,而且实现难度以指数级降低。甚至还有有了一个很酷的名字,代码分片。

  • 这个点子很简单,即按需加载。实践的话,它可能有一点复杂。而复杂的原因并不是代码分片本身,而是现在有各种各样的工具来做这个事情。而且每个人对哪个方式最好都有自己的看法。当你第一次开始着手的时候,可能很困难分析什么是什么。

  • 最常见的两种做法是使用Webpack和它的包加载器(bundle-loader),或者使用ECMAScript的stage3提案的动态import()。任何机会不用Webpack,我就不用,因此在这篇文章中,我将会使用动态import()。

  • 如果你很熟悉ES模块,你应该知道它们是静态的。意思就是说你必须在编译时确定你要引入和导出的内容,而不是运行时。这也意味着你不能基于一些条件来动态导入一个模块。导入的内容必须声明在文件的最开头否则会抛出一个错误。

    if (!user) {
        import * as api from './api' //不能这样做,“import”和“export”只能出现在文件顶部
    }
    
  • 现在,如果import不需要是静态的怎么办?意味着上面的代码可以工作?将会给我们带来什么好处?首先这意味着我可以按着需要加载某个模块。这非常强大,它让我们更接近按用户需要下载代码的想象。

    if (editPost === true) {
        import * as edit from './editpost'
        
        edit.showEditor()
    }
    
  • 假设__editpost__包含一个非常大的富文本编辑器,我们需要保证用户在没有使用它的时候不会去下载它。

  • 另外一个很酷的例子用于遗留支持。你可以在浏览器确定确实没有的时候才下载对应代码。

  • 好消息(我在上文中曾间接提及)。这种类型的方法确实存在,它被Create React App(React项目的一种官方创建方法)支持,而且它是ECMAScript stage3的提案。不同的是替换了你之前使用import的方式。它使用起来像一个方法,并返回一个Promise,一旦模块完全加载,就会把这个模块resolve回来。

    if (editPost === true) {
        import('./editpost')
          .then(module => module.showEditor())
          .catch(e =>)
    }
    
  • 特别好,对吧?

  • 现在我们知道怎么动态引入模块了,下一步是找出怎么结合React和React Router来使用它。

  • 第一个(可能是最大的一个)问题,我们对React代码分片时,我们应该对哪里进行分片?典型的回答有两个

    1. 在路由的层次上分片
    2. 在组件的层次上分片
  • 而更加常见的做法是在路由的层次上进行分片。你已经把你的应用分成了不同的路由,因此根据这个来代码分片是自然而然的事情。

  • 让我以一个简单的React Router例子开始。我们将有三条路由分别是: //topics/settings

    import React, { Component } from 'react'
    import {
        BrowserRouter as Router,
        Route,
        Link,
    } from 'react-router-dom'
    
    import Home from './Home'
    import Topics from './Topics'
    import Settings from './Settings'
    
    class App extends Component {
        render() {
            return (
              <Router>
                <div>
                  <ul>
                  	<li><Link to='/'>Home</Link></li>
    			   <li><Link to='/topics'>Topics</Link></li> 
                    <li><Link to='/settings'>Settings</Link></li>
                  </ul>    
                    
                  <hr />
                  
                  <Route exact path='/' component={Home} />
                  <Route exact path='/topics' component={Topics} />
     			 <Route exact path='/settings' component={Settings} />
                </div>
              </Router>  
            )
        }
    }
    
    export default App
    
  • 现在,假设我们的__/settings__路由内容非常多。它包含一个富文本编辑器,和一个原始超级马里奥兄弟的拷贝,和盖伊法利的高清图片。当用户不在__/settings__路由上时,我们不想让他们下载全部这些内容。让我们使用我们React和动态引入(import())的知识来分片__/settings__路由。

  • 就像我们在React里解决任何问题一样,我们先写一个组件。我们将叫它__DynamicImport__。这个组件的目的是动态的加载一个模块,只要模块加载好了,就把它传给它子节点(children)。

    const Settings = (props) => (
      <DynamicImport load={() => import('./Settings')}>
        {(Component) => Component === null 
           ? <Loading /> 
           : <Component {...props} />}
      </DynamicImport>
    )
    
  • 上面的代码告诉我们两个重要的要素。第一,这个组件在执行时会接受一个属性__load__,将使用我们前面提到的语法动态引入一个模块。第二,这个组件会接受一个函数作为他的子节点,这个函数需要和引入进来的模块一起调用。

  • 在我们深入思考__DynamicImport__的实现的之前,让我们想一下我们会怎么实现。第一件事我们需要确定的是要调用props.load。这让我们返回一个Promise,当它resolve的时候应该返回模块。接着,一旦我们有了模块,我们需要一种方式去触发重渲染,因此我们要把模块传给props.children并且调用它。怎样在React里面触发重渲染呢?设置state(setState)。通过把动态引入的模块加入到__DynamicImport__的state里面,就像我们之前使用的一样,我们遵循和React同样的过程- 获取数据 -> 设置到state里 -> 重渲染。而这一次我们只是把获取数据替换成了引入模块。

  • 好了,首先,让我们加入初始的状态到组件里。

    class DynamicImport extends Component {
        state = {
            component: null
        }
    }
    
  • 现在,我们需要调props.load方法。这将返回一个promise同时在resolve后有一个模块

    class DynamicImport extends Component {
        state = {
            component: null
        } 
        componentWillMount () {
            this.props.load()
                .then(component => {
                	this.setState(() =>{
                      	component
                     )}           
            	})
        }
    }
    
  • 这里有一个疑难杂症。如果我们ES模块和commonjs模块混用时,ES模块会有一个.default属性,而commonjs模块并没有。让我们改变一下代码,适应一下上面的情况。

    this.props.load()
        .then(component => {
        	this.setState(() => {
            	component: component.default ?
    component.default : component
        	})
    	})
    })
    
  • 现在我们动态引入的模块并且把它加入到了state里面,最后一件事就是render方法长什么样了。如果你会记得,当__DynamicImport__使用的时候,它看起来像这样

    const Settings = (props) => (
    	<DynamicImport load={() => import('./Settings')}>
            {(Component) => Component === null 
                ? <Loading/>
            	: <Component {...props} />}
        </DynamicImport>
    )
    
  • 注意我们给组件传了一个函数作为子节点。这意味着我们需要执行这个函数,传递的是这个引入在state里的组件。

    class DynamicImport extends Component {
        state = {
            component: null
        }
    	componentWillMount () {
        	this.props.load()
                .then((component) => {
                    this.setState({
    				  component: component.default 
                        ? component.default
                        : component
                    })
            	})
    	}
        render() {
            return this.props.children(this.state.component)
        }
    }
    
  • 欧了,现在任何时候我们动态引入一个模块,我们可以把它包裹在__DynamicImport__。如果我们之前尝试用这种方法到我们路由上,我们的代码会看起来像这样

    import React, { Component } from 'react'
    import {
        BrowserRouter as Router,
        Route,
        Link
    } from 'react-router-dom'
    
    class DynamicImport extends Component {
        state = {
            component: null
        }
    	componentWillMount () {
        	this.props.load()
                .then((component) =>&emsp;{
                	this.setState({
                        component: component.default 
                        ? component.default
                        : component
                    })
            	})
    	}
    	
    	render() {
        	return this.props.children(this.state.component)
    	}
    }
    
    const Home = (props) => (
    	<DynamicImport load={() => import('./Home')}>
        	{(Component) => Component === null 
              	? <p>Loading</p>
                : <Component {...props} />
            }
        </DynamicImport>
    )
    
    const Topics = (props) => (
    	<DynamicImport load={() => import('./Settings')}>
        	{(Component) => Component === null 
            	? <p>Loading</p>
                : <Component {...props}/>
            }
        </DynamicImport>
    )
    
    class App extends Component {
        render() {
            return (
            	<Router>
                	<div>
                    	<ul>
                        	<li><Link to='/'>Home</Link></li>
                            <li><Link to='/topics'>Topics</Link></li>
                            <li><Link to='/settings'>Settings</Link></li>
                        </ul>
                        <hr />
                        <Route exact path='/' component={Home} />
                        <Route path='/topics' component={Topics} />
                        <Route path='/settings' component={Settings} />
                    </div>
                </Router>
            )
        }
    }
    
    export default App
    

    我们怎么知道这个确实起作用并且分片了我们的路由呢?如果你用一个React官方的Create React App创建一个应用跑一下__npm run build__,你将看到应用被分片了。

  • 每一个包被一一引入进了我们的应用

  • 你到了这一步,可以跳个舞轻松一下了

  • 还记得我讲到有两种层级的代码分片方式吗?我们曾放在手边的引导

    1. 以路由层级分片
    2. 以组建层级分片
  • 至此,我们只讲了路由层级的代码分片。到这里很多人就停止了。在路由层级上代码分片,就像刷牙一样,你天天刷,牙齿大部分很干净,但是还会有蛀牙。

  • 除了思考用路由的分片方式,你应该想想怎么用组件的方式去分片。如果你在弹层里面有很多内容,路由分片还是会下载弹层的代码,无论这个弹层是否显示。

  • 从这一点看,它更多是在你大脑里的一种变更而不是新知识。你已经知道如何使用动态引入,现在你需要找出哪些组件是在用到时才要下载的。

  • 如果我不提React Loadable那我就是哑巴了。它是一个“通过动态引入加载组件的高阶组件”。重要的是,它处理所有我们提到的事情,并把它做成了一个精致的API。它甚至处理了很多很边角的事情,比如我们没有考虑服务端渲染和错误处理。看看它吧,如果你想要一个简单,开箱即用的解决方案的话。


欢迎加入DCG前端团队。
简历请投 hanshuangli@dcrays.cn
【一年16薪】【通讯津贴】【交通补助】【过节福利】【带薪年假】【绩效奖金】【定期体检】【生日福利】...