脑阔疼的webpack按需加载

42,061 阅读8分钟

Q1:  什么是按需加载?

随着单页应用发展的越来越庞大,拆分js就是第一要务,拆分后的js,就可以根据我们需求来有选择性的加载了。

所以第一个问题,就是js怎么拆?

Q2:js怎么拆?

1,未拆分前是什么样子?

来个demo,先看一下未拆分之前是什么样子: a.js:

import b from './b.js';
console.log("this is a.js")
const btn = document.querySelector("#btn");
btn.onclick = ()=>{
  b();
}

b.js:

export default ()=>{
  console.log("this is b");
}

html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>
  <div id="btn">btn</div>
  <script src="./dist/main.js"></script>
</body>
</html>

webpack.config.js

module.exports = {
  entry:'./a.js',
  output:{
    filename:'[name].js'
  }
}
  1. a.js引用b.js
  2. webpack打包将b、a都打包到了一起,输出一个默认的main.js
  3. html引用打包好的main.js 结果如下:

image.png | left | 315x215


2,开搞!

step1:修改webpack.config.js

module.exports = {
  entry:'./a.js',
  output:{
    filename:'[name].js',
    chunkFilename:'[name].js'// 设置按需加载后的chunk名字
  }
}

这里就添加了一句,chunkFilename而已,chunkFilename的作用就是用来给拆分后的chunk们起名字的配置项。 ok,执行webpack

image.png | left | 296x138

还是只打包出了一个main.js,毫无变化... 不用担心,这是因为还有设置没搞定。


step2:修改a.js

// import b from './b.js';
console.log("this is a.js")
const btn = document.querySelector("#btn");
btn.onclick = ()=>{
    import('./b').then(function(module){
      const b = module.default;
      b();
    })
}
  1. 使用es6的import按需语法
  2. 在promise后执行拿到的返回的结果 此时再次执行webpack:

image.png | left | 348x154

输出文件变成了两个,1个main.js、1个1.js 这个1.js很迷...

image.png | left | 668x75

查看一下源码,可以看出来,它其实就是我们的b.js

总结一下 :

  • webpack中output的设置并不决定是否拆分代码
  • 拆分代码的决定因素在import语法上
  • webpack在扫描到代码中有import语法的时候,才决定执行拆分代码

step3:怎么使用?

image.png | left | 441x230

额,成功报错了...脑阔疼 分析报错:

  • 按需加载找的文件是/1.js
  • 但我们打包的结果在dist目录下,自然不可能在根目录下找到

step4:配置Public Path基础路径

该配置能帮助你为项目中的所有资源指定一个基础路径。它被称为公共路径(publicPath) 修改webpack.config.js

module.exports = {
  entry:'./a.js',
  output:{
    filename:'[name].js',
    chunkFilename:'[name].js',// 设置按需加载后的chunk名字
    publicPath:'dist/' // 设置基础路径
  }
}

step5:验证结果

image.png | left | 333x347


  • 点击前
  • 只引用了main.js

image.png | left | 413x703


  • 点击后
  • 加载了1.js
  • 并执行了1.js中的js代码
  • 控制台输出this is b.js
  • ok,验证成功

step6:填坑

前面1.js这玩意也不可读啊,有问题也很难明确,webpack,提供了定义按需chunkname的方式,修改a.js:

// import b from './b.js';
console.log("this is a.js")
const btn = document.querySelector("#btn");
btn.onclick = ()=>{
  import(/* webpackChunkName: "b" */ './b').then(function(module){
    const b = module.default;
    b();
  })
}

在动态引入的语法前,添加了注释,注释就是为chunk命明的方式,结果:

image.png | left | 474x208

输出了b.js,测试回归一次:

image.png | left | 259x432


  • chunk名字对按需加载没有影响
  • 修改了按需chunk的名字也只是方便文件可读性

Q3:按需加载之后还能热更新吗?

1,先跑个webpack-dev-server集成起来

先安装webpack-dev-server,配置npm scripts

{
  "devDependencies": {
    "webpack-dev-server": "^3.1.9"
  },
  "scripts": {
    "start:dev": "webpack-dev-server"
  },
  "dependencies": {
    "webpack": "^4.20.2",
    "webpack-cli": "^3.1.2"
  }
}

修改webpack.config.js

var path = require('path');
module.exports = {
  entry:'./a.js',
  mode:'development',
  output:{
    filename:'[name].js',
    chunkFilename:'[name].js',// 设置按需加载后的chunk名字
    publicPath:'dist/'
  },
  devServer: {
    contentBase: './',
    compress: true,
    port: 9000
  }
}
  • 这一次不再通过webpack命令来执行了
  • 而是通过npm run start:dev命令行来执行
  • webpack-dev-server会读取webpack.config.js中的devServer配置
  • ok,devServer已经集成好了

2,跑起来看看

修改webpack.config.js

var path = require('path');
var webpack = require('webpack');
module.exports = {
  entry:'./a.js',
  mode:'development',
  output:{
    filename:'[name].js',
    chunkFilename:'[name].js',// 设置按需加载后的chunk名字
    publicPath:'dist/'
  },
  devServer: {
    contentBase: './',
    compress: true,
    port: 9000,
    hot: true, // 开启热更新
  },
  plugins: [ // 开始热更新
      new webpack.NamedModulesPlugin(),
      new webpack.HotModuleReplacementPlugin()
  ],
}

上面一共起作用的就是3句话:

  • devServer中的hot语句
  • plugins中的两个webpack内置插件 将这两个插件开启后,还不行,还需要修改入口文件
// import b from './b.js';
console.log("this is a.js")
const btn = document.querySelector("#btn");
btn.onclick = ()=>{
  import(/* webpackChunkName: "b" */ './b').then(function(module){
    const b = module.default;
    b();
  })
}

if (module.hot) {// 开启热替换
     module.hot.accept()
}

ok,就这么简单,热更新+按需加载就齐活了。


Q4:react-router集成按需加载

react-router文档

业务中,除了点击的时候按需加载,还有大部分场景都是在路由切换的时候进行按需加载

step1:添加babel-loader

修改webpack.config.js

var path = require('path');
var webpack = require('webpack');
module.exports = {
  entry:'./a.js',
  mode:'development',
  output:{
    filename:'[name].js',
    chunkFilename:'[name].js',// 设置按需加载后的chunk名字
    publicPath:'dist/'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: 'babel-loader',
        }
      }
    ]
  },
  devServer: {
    contentBase: './',
    compress: true,
    port: 9000,
    hot: true,
  },
  plugins: [
      new webpack.NamedModulesPlugin(),
      new webpack.HotModuleReplacementPlugin()
  ],
}

上面新增的就是添加了一个babel-loader


step2:添加.babelrc

{
  "presets": ["@babel/preset-react","@babel/preset-env"]
}

step3:书写jsx

修改a.js

import React,{Component} from 'react';
import ReactDom from 'react-dom';
import B from './b.js';
export default class A extends Component{
  render(){
    return <div>
      this is A
      <B />
    </div>
  }
}
ReactDom.render(<A/>,document.querySelector("#btn"))
if (module.hot) {
     module.hot.accept()
}

修改b.js

import React,{Component} from 'react';
export default class B extends Component{
  render(){
    return <div>this is B</div>
  }
}

测试一下:

  • react跑起来了
  • 热更新依旧有效果

step4:集成react-loadable

react按需加载进化了好几个方式,目前最新的方式就是使用react-loadable这个组件 官方也推荐使用这个库来实现,目前这个库已经1w+star了

修改a.js

import React,{Component} from 'react';
import { BrowserRouter as Router, Route, Switch,Link } from 'react-router-dom';
import ReactDom from 'react-dom';
import Loadable from 'react-loadable';

const Loading = () => <div>Loading...</div>;

const B = Loadable({
  loader: () => import('./b.js'),
  loading: Loading,
})
const C = Loadable({
  loader: () => import('./C.js'),
  loading: Loading,
})
export default class A extends Component{
  render(){
    return <div>
      <Router>
        <div>
          <Route path="/B" component={B}/>
          <Route path="/C" component={C}/>
          <Link to="/B">to B</Link><br/>
          <Link to="/C">to C</Link>
        </div>
      </Router>
    </div>
  }
}
ReactDom.render(<A/>,document.querySelector("#btn"))
if (module.hot) {
     module.hot.accept()
}

  • loadable中使用的import语法是ECMA未来会支持的动态加载特性
  • loadable很简单,只需要按照它所规定的语法,包裹一下需要加载的组件就可以

image.png | left | 408x470

点击跳转toC

image.png | left | 413x511

可以看到加载了1.js,也就是说异步加载顺利完成 但是现在存在问题:在/C路径下刷新,会出现无法命中路由的情况


step5:跑个express验证一下

var express = require('express')
var app = express()
app.use(express.static('dist'))


app.get('*', function (req, res) {
  res.send(`<!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
  </head>
  <body>
    <div id="btn">btn</div>
    <script src="./main.js"></script>
  </body>
  </html>`)
})
app.listen(5000);

创建一个简单的express应用:

  • 验证通过
  • 同样会执行按需加载

step6:嵌套路由按需加载

路由一个很常见的功能就是路由嵌套,所以我们的按需加载必须支持嵌套路由才算合理 修改a.js

import React,{Component} from 'react';
import { BrowserRouter as Router, Route, Switch,Link } from 'react-router-dom';
import ReactDom from 'react-dom';
import Loadable from 'react-loadable';

const Loading = (props) => {
  return <div>Loading...</div>
};
const B = Loadable({
  loader: () => import('./b.js'),
  loading: Loading,
})
const C = Loadable({
  loader: () => import('./c.js'),
  loading: Loading,
})
export default class A extends Component{
  render(){
    return <div>
      <Router>
        <div>
          <Route path="/B" component={B}/>
          <Route path="/C" component={C}/>
          <Link to="/B">to B</Link><br/>
          <Link to="/C">to C</Link>
        </div>
      </Router>
    </div>
  }
}
ReactDom.render(<A/>,document.querySelector("#btn"))
if (module.hot) {
     module.hot.accept()
}

修改c.js

import React,{Component} from 'react';
import { Route,Link} from 'react-router-dom';
import Loadable from 'react-loadable';

const Loading = (props) => {
  return <div>Loadingc...</div>
};

const D = Loadable({
  loader: () => import('./d.js'),
  loading: Loading,
})
export default class C extends Component{
  render(){
    return <div>
      this is C
      <Route path="/C/D" component={D}/>
      <Link to="/C/D">to D</Link>
    </div>
  }
}

  • 入口文件引入两个动态路由B、C
  • c.js中嵌套了路由/C/D
  • 路由/C/D中使用了按需组件D

step7:验证嵌套路由

入口没问题

image.png | left | 303x152


点击跳转动态加载C没问题

image.png | left | 328x177


点击跳转D不行了

image.png | left | 747x228


可以看到动态引入资源./d.js的时候,出现了异常,莫名其妙的添加了路径/C


step8:该死的publicPath

这里疑惑了好一会,还查了很多内容,最后痛定思痛察觉到应该还是publicPath设置有问题,重新检查了设置,修改webpack.config.js

var path = require('path');
var webpack = require('webpack');
module.exports = {
  entry:'./a.js',
  mode:'development',
  output:{
    path:path.resolve(__dirname, 'dist'),
    filename:'[name].js',
    chunkFilename:'[name].js',// 设置按需加载后的chunk名字
    publicPath:'/dist/'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: 'babel-loader',
        }
      }
    ]
  },
  devServer: {
    contentBase: './',
    compress: true,
    port: 9000,
    hot: true,
  },
  plugins: [
      new webpack.NamedModulesPlugin(),
      new webpack.HotModuleReplacementPlugin()
  ],
}

这里唯一的改动,就是publicPath由原来的dist/,变成/dist/,只要把前面的路径补上,就不会去找相对的地址了。


Q5:到真实项目里怎么搞?

前面看似解决了问题,但在真实场景下,我们的要求肯定会更高! 首先就是要封装一个便捷使用的按需加载组件。

step1:封装LazyLoad组件

理想很美好,现实很骨干

const LazyLoad = (path)=>{
  return Loadable({
    loader: () => import(path),
    loading: Loading,
  })
}

const B = LazyLoad('./b.js')

然后就收获了报错

image.png | left | 747x55

这是因为webpack编译的时候import预发==不支持动态路径==


step2:可怕的import,了解一下

import不支持动态路径,是因为webpack需要先扫一遍js文件,找出里面按需加载的部分,进行按需打包,但不会关心内部的js执行上下文,也就是说,在webpack扫描的时候,js中的变量并不会计算出结果,所以import不支持动态路径。


step3:封装非import部分

既然import不能搞,那只能封装非import的部分了

const LazyLoad = loader => Loadable({
  loader,
  loading:Loading,
})

把loader这部分当作参数分离出去,下面就是具体的使用

const B = LazyLoad(()=>import('./b.js'));
const C = LazyLoad(()=>import('./c.js'));

下面是全部代码

import React,{Component} from 'react';
import { BrowserRouter as Router, Route, Switch,Link } from 'react-router-dom';
import ReactDom from 'react-dom';
import Loadable from 'react-loadable';

const Loading = (props) => {
  return <div>Loading...</div>
};

const LazyLoad = loader => Loadable({
  loader,
  loading:Loading,
})
const B = LazyLoad(()=>import('./b.js'));
const C = LazyLoad(()=>import('./c.js'));

export default class A extends Component{
  render(){
    return <div>
      <Router>
        <div>
          <Route path="/B" component={B}/>
          <Route path="/C" component={C}/>
          <Link to="/B">to B</Link><br/>
          <Link to="/C">to C</Link>
        </div>
      </Router>
    </div>
  }
}
ReactDom.render(<A/>,document.querySelector("#btn"))
if (module.hot) {
     module.hot.accept()
}

上面的封装方式并不是十分完美,webpack文档上说支持: ==import(./dynamic/\${path})的方式== 只要不全是变量貌似也是支持的,这就要看具体的业务形态了,如果按需的部分都在某个目录下,这种操作或许更舒适一些。

按目前的方式的话,看似比较繁琐,不过可以通过配置webpack的alias别名来进行路径支持。


Q6:按需加载+router config

react router除了组件方式以外,还可以通过config的方式来进行配置,config的方式便于统一维护controller层。

step1:封装LazyLoad

创建LazyLoad.js文件

import React from 'react';
import Loadable from 'react-loadable';
const Loading = (props) => {
  return <div>Loading...</div>
};

export default loader => Loadable({
  loader,
  loading:Loading,
})

首先把Lazyload组件单独封装出去


step2:配置routes

创建routes.js

import LazyLoad from './LazyLoad';
export default [
  {
    path: "/B",
    component: LazyLoad(()=>import('./b.js'))
  },
  {
    path: "/C",
    component: LazyLoad(()=>import('./c.js')),
    routes: [
      {
        path: "/C/D",
        component: LazyLoad(()=>import('./d.js'))
      },
      {
        path: "/C/E",
        component: LazyLoad(()=>import('./e.js'))
      }
    ]
  }
];

配置routes文件,用来动态引入路由


step3:封装工具方法RouteWithSubRoutes

创建utils.js

import React from 'react';
import {Route} from 'react-router-dom';
export const RouteWithSubRoutes = route => (
  <Route
    path={route.path}
    render={props => (
      // pass the sub-routes down to keep nesting
      <route.component {...props} routes={route.routes} />
    )}
  />
);

==这一步特别重要、特别重要、特别重要==

这个工具方法的作用就是将组件渲染出来


step4:修改第一层路由入口

import React,{Component} from 'react';
import { BrowserRouter as Router, Route, Switch,Link } from 'react-router-dom';
import ReactDom from 'react-dom';

import {RouteWithSubRoutes} from './utils';
import routes from './routes';

export default class A extends Component{
  render(){
    return <div>
      <Router>
        <div>
          <Link to="/B">to B</Link><br/>
          <Link to="/C">to C</Link>
          {routes.map((route, i) => <RouteWithSubRoutes key={i} {...route} />)}
        </div>
      </Router>
    </div>
  }
}
ReactDom.render(<A/>,document.querySelector("#btn"))
if (module.hot) {
     module.hot.accept()
}
  1. 引入RouteWithSubRoutes工具方法
  2. 引入routes路由配置文件
  3. 在包裹的文件中进行routes遍历渲染

==注意:这里只处理了第一层路由== ==注意:这里只处理了第一层路由== ==注意:这里只处理了第一层路由==


step5:修改二级路由入口

路由配置化之后,嵌套子路由要以函数式来书写

import React,{Component} from 'react';
import {RouteWithSubRoutes} from './utils';
import { Link} from 'react-router-dom';

export default ({ routes }) => (
  <div>
    this is C
    <Link to="/C/D">to D</Link>
    <Link to="/C/E">to E</Link>
    {routes.map((route, i) => <RouteWithSubRoutes key={i} {...route} />)}
  </div>
);
  1. 引入RouteWithSubRoutes工具方法
  2. 暴露的函数接受一个参数routes
  3. routes即config中内层的配置,也就是二级路由配置
  4. 二级路由配置,继续通过RouteWithSubRoutes进行渲染

==注意:config嵌套路由,需要逐层,一层一层的通过RouteWithSubRoutes来渲染。== ==新人很容易忽视这一点!== ==新人很容易忽视这一点!== ==新人很容易忽视这一点!==


Q7:router随心用?

前面使用config的方式配置了路由,但其实这里也可以混用,就是config方式+组件的方式混合使用。 修改二级路由入口:

import React from 'react';
import { Link,Route} from 'react-router-dom';
//import {RouteWithSubRoutes} from './utils';
import LazyLoad from './LazyLoad';

const D = LazyLoad(() => import('./d.js'))
const E = LazyLoad(() => import('./e.js'))

export default ({ routes }) => (
  <div>
    this is C
    <Route path="/C/D" component={D}/>
    <Route path="/C/E" component={E}/>
    <Link to="/C/D">to D</Link>
    <Link to="/C/E">to E</Link>
    {/* {routes.map((route, i) => <RouteWithSubRoutes key={i} {...route} />)} */}
  </div>
);

其实,这里的话,就是随便搞了

路由的话,还是统一维护为好,当然也可以根据业务来自主选择需要的方式!。

脑阔疼的webpack按需加载告一段落了。