一、问题背景:生产环境地图库的“臃肿危机”

做过前端地图功能的开发者大概率都碰过这么个糟心事儿:辛辛苦苦把地图功能上线到生产环境,结果发现打包出来的包比预期大了好几倍。比如我之前做的一个城市配送调度系统,用了Leaflet加几个第三方地图插件,一开始本地开发打包完还挺正常,结果上线前做性能检测,发现整个项目的主包体积直接干到了12MB,其中地图相关的代码占了7MB还多。

这么大的包带来的问题太明显了:用户打开页面得等半天,尤其是网络不好的地方,可能半分钟都加载不出来,直接导致用户流失;另外,CDN缓存的成本也上去了,每次更新都得传大体积的资源,浪费带宽。后来我仔细查了原因,发现是所有的地图功能代码都被打包进了主包,不管用户当前用不用得到,都得跟着主包一起下载。比如用户只看基础地图,结果连热力图、路径规划这些他根本用不到的功能代码也一起加载了,这不就是纯浪费嘛。

二、核心解决思路:按需加载+拆包

要解决这个问题,核心就是“让用户只加载自己需要的代码”,具体拆成两个方向:一个是按需加载,就是只有当用户触发某个地图功能的时候,才去加载这个功能对应的代码;另一个是拆包,就是把地图相关的代码从主包中拆出来,分成多个小的包,而不是一股脑堆在一起。

这里我用的是Leaflet(一款轻量的前端地图库)加Webpack(前端打包工具)的组合,整个实现过程都是基于这两个工具的原生能力,没有用什么复杂的第三方工具,大家跟着做就能上手。

三、具体实现步骤

3.1 技术栈说明

先明确整个实现用的技术栈,所有示例都基于这个组合,不会混其他工具:

  • 地图库:Leaflet 1.9.4
  • 打包工具:Webpack 5.74.0
  • 项目基础:Vue 2.6.14(其实不用Vue也可以,这里只是拿它举例子,核心逻辑和框架无关)

3.2 第一步:地图功能的模块化拆分

首先得把地图相关的代码拆成独立的模块,每个模块对应一个功能,这样才能按需加载。比如我把原来的地图代码拆成了三个模块:

  1. 基础地图模块:只包含地图初始化、显示的核心代码
  2. 热力图模块:包含热力图的渲染、数据更新的代码
  3. 路径规划模块:包含路径查询、路径绘制的代码

举个基础地图模块的例子,代码结构大概是这样的:

// 地图核心模块:src/map/core.js
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';

// 初始化基础地图的方法
export function initBaseMap(containerId) {
  // 地图容器必须是页面上存在的DOM元素
  const map = L.map(containerId).setView([39.9042, 116.4074], 13); // 北京坐标,缩放等级13
  // 添加瓦片图层(这里用的是高德的瓦片,大家可以换自己的)
  L.tileLayer('https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=7&x={x}&y={y}&z={z}', {
    maxZoom: 19,
    attribution: '© 高德地图'
  }).addTo(map);
  return map;
}

再举个热力图模块的例子,这个模块依赖leaflet.heat这个第三方插件:

// 地图热力图模块:src/map/heatmap.js
import L from 'leaflet';
import 'leaflet.heat'; // 热力图插件

// 添加热力图到地图的方法
export function addHeatmap(map, data) {
  // data格式:[[纬度, 经度, 权重], ...],比如[[39.9, 116.4, 10], [39.91, 116.41, 15]]
  L.heatLayer(data, {
    radius: 25, // 热力点半径
    blur: 15, // 模糊程度
    maxZoom: 17 // 最大缩放等级下的热力效果
  }).addTo(map);
}

路径规划模块的代码结构类似,大家可以自己根据功能拆分,核心是每个模块只做一件事。

3.3 第二步:Webpack配置拆包规则

拆包的核心是让Webpack把地图相关的代码拆成独立的包,而不是和主包混在一起。Webpack5自带了splitChunks插件,专门用来做拆包,我们只需要配置它的规则就行。

找到项目里的webpack.config.js(如果是Vue项目的话,可能是vue.config.js里的configureWebpack属性),添加下面的配置:

// webpack.config.js 中的 splitChunks 配置
module.exports = {
  // 其他配置...
  optimization: {
    splitChunks: {
      chunks: 'all', // 对所有类型的chunk进行拆包,包括同步和异步
      minSize: 20000, // 包的最小体积,小于20KB的不拆
      minRemainingSize: 0,
      minChunks: 1, // 至少被1个chunk引用才会被拆
      maxAsyncRequests: 30, // 异步加载时最多同时请求30个包
      maxInitialRequests: 30, // 初始加载时最多同时请求30个包
      enforceSizeThreshold: 50000,
      cacheGroups: {
        // 专门给Leaflet相关的代码做拆包规则
        leaflet: {
          test: /[\\/]node_modules[\\/](leaflet|leaflet.heat|leaflet-routing-machine)[\\/]/,
          name: 'leaflet', // 拆出来的包名叫leaflet.js
          priority: 20, // 优先级,比默认的规则高
        },
        // 给我们自己写的地图业务代码做拆包规则
        mapBusiness: {
          test: /[\\/]src[\\/]map[\\/]/,
          name: 'map-business', // 拆出来的包名叫map-business.js
          priority: 15,
        },
        // 其他第三方库的拆包规则(默认的,不用改)
        defaultVendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          reuseExistingChunk: true,
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true,
        },
      },
    },
  },
};

这里解释一下这个配置的作用:原来所有的地图相关代码,包括Leaflet本身、第三方插件、我们自己写的业务代码,都会被打包进主包;配置之后,Webpack会把Leaflet相关的第三方库拆成一个叫leaflet.js的包,把我们自己写的地图业务代码拆成一个叫map-business.js的包,主包就只包含页面的核心代码,比如登录、布局这些和地图无关的内容。

3.4 第三步:实现按需加载

拆包完成后,还需要实现按需加载,也就是只有当用户需要某个地图功能的时候,才去加载对应的代码。这里用到的是ES6的import()语法,这个语法可以异步加载模块,返回一个Promise,等模块加载完成后再执行对应的逻辑。

举个实际的例子,比如我们的页面上有一个“显示热力图”的按钮,用户点击这个按钮的时候才加载热力图的代码,而不是页面一打开就加载。原来的代码可能是这样的:

// 原来的代码:页面一打开就加载热力图模块,不管用户用不用
import { addHeatmap } from './map/heatmap';

// 页面初始化完成后初始化地图
const map = initBaseMap('map-container');

// 按钮点击事件
document.getElementById('show-heatmap-btn').addEventListener('click', () => {
  // 加载热力图数据(假设是从接口获取的)
  fetch('/api/heatmap-data')
    .then(res => res.json())
    .then(data => {
      addHeatmap(map, data);
    });
});

修改成按需加载的代码:

// 按需加载的代码:只有点击按钮的时候才加载热力图模块
import { initBaseMap } from './map/core';

// 页面初始化完成后初始化基础地图(这个是必须的,所以同步加载)
const map = initBaseMap('map-container');

// 按钮点击事件
document.getElementById('show-heatmap-btn').addEventListener('click', async () => {
  try {
    // 异步加载热力图模块,这个时候Webpack会单独加载map-business.js里的热力图部分
    const { addHeatmap } = await import('./map/heatmap');
    // 加载热力图数据
    const res = await fetch('/api/heatmap-data');
    const data = await res.json();
    // 添加热力图到地图
    addHeatmap(map, data);
  } catch (err) {
    console.error('加载热力图失败', err);
    alert('加载热力图失败,请稍后再试');
  }
});

同样的,路径规划功能也可以用这个方法,比如点击“开始路径规划”按钮的时候,才加载路径规划的模块:

// 路径规划按钮点击事件
document.getElementById('start-route-btn').addEventListener('click', async () => {
  try {
    // 异步加载路径规划模块
    const { initRoute } = await import('./map/route');
    // 调用路径规划方法
    initRoute(map, [39.9042, 116.4074], [39.9142, 116.4174]); // 起点和终点坐标
  } catch (err) {
    console.error('加载路径规划失败', err);
    alert('加载路径规划失败,请稍后再试');
  }
});

这里有个细节要注意:基础地图模块是必须同步加载的,因为页面一打开就要显示地图,不能等用户点击什么按钮才显示地图,所以基础地图的代码还是会被打包进主包,但它的体积很小,一般只有几百KB,对主包的影响不大。

四、效果验证

配置完成后,我们可以用Webpack的打包分析工具来验证效果,比如webpack-bundle-analyzer,这个工具可以把打包后的包体积可视化,让我们清楚地看到每个包的大小和内容。

先安装这个工具:

npm install webpack-bundle-analyzer --save-dev

然后在webpack.config.js里添加配置:

// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  // 其他配置...
  plugins: [
    // 其他插件...
    new BundleAnalyzerPlugin({
      analyzerMode: 'static', // 生成静态的分析报告
      reportFilename: 'bundle-report.html', // 报告的文件名
      openAnalyzer: true, // 打包完成后自动打开报告
    }),
  ],
};

然后执行打包命令:

npm run build

打包完成后,会自动打开一个HTML报告,我们可以看到:

  • 主包的体积从原来的12MB降到了2MB左右
  • Leaflet相关的第三方库被拆成了一个单独的leaflet.js包,体积大概是1.5MB
  • 我们自己写的地图业务代码被拆成了map-business.js包,体积大概是1MB
  • 热力图、路径规划这些功能的代码,只有当用户点击对应的按钮时,才会被加载,不会出现在初始加载的包里面

再验证线上效果:打开页面,初始加载的资源只有主包、leaflet.js、map-business.js(基础地图部分),总大小不到4.5MB,比原来的12MB小了很多;点击“显示热力图”按钮的时候,会多加载一个小的chunk(大概几十KB),这个chunk就是热力图的代码;点击“开始路径规划”按钮的时候,会再加载一个路径规划的chunk,整个过程的体验比原来流畅很多。

五、应用场景、优缺点、注意事项

5.1 应用场景

这个方案适合所有用到前端地图的项目,尤其是下面这些场景:

  1. 功能复杂的地图项目:比如调度系统、导航系统、GIS系统,这些项目的地图功能多,代码体积大
  2. 对性能要求高的项目:比如面向C端的应用,用户对加载速度敏感,不能忍受长时间等待
  3. 移动端项目:移动端的网络环境更复杂,加载大体积的包体验很差,这个方案能有效减少初始加载时间
  4. 模块化的项目:如果项目本身是按功能模块拆分的,这个方案能很好地适配项目结构

5.2 技术优缺点

优点:

  1. 大幅减少初始包体积:把地图相关的代码拆出来,主包只保留核心内容,初始加载速度明显提升
  2. 按需加载非核心功能:只有用户需要的时候才加载对应的代码,减少不必要的带宽消耗
  3. 提升缓存效率:拆出来的包如果内容没有变化,CDN缓存不会失效,下次访问可以直接用缓存,减少加载时间
  4. 实现简单:基于Webpack和Leaflet的原生能力,不需要额外的复杂配置,开发者容易上手

缺点:

  1. 增加了页面的请求数:原来只需要加载一个主包,现在需要加载主包、leaflet.js、map-business.js等多个包,不过HTTP2/3对多请求的优化已经很好了,这个影响不大
  2. 按需加载的功能会有短暂的等待:比如点击“显示热力图”按钮的时候,需要先加载热力图的代码,再显示热力图,可能会有几百毫秒的延迟,不过可以通过加载动画来优化体验
  3. 配置不当可能导致拆包不合理:比如把不常用的代码拆成多个小的包,反而增加请求数,所以需要根据项目实际情况调整splitChunks的配置

5.3 注意事项

  1. 基础地图的体积控制:基础地图是同步加载的,所以要尽量精简基础地图的代码,不要把不必要的功能加进去
  2. 按需加载的错误处理:异步加载模块的时候,可能会因为网络问题加载失败,所以一定要加错误处理,比如显示一个友好的提示,或者重试加载
  3. 瓦片图层的优化:地图的瓦片图层占的流量也很大,要尽量用CDN加速的瓦片,或者对瓦片做压缩、缓存,比如设置瓦片的缓存时间,减少重复加载
  4. 拆包规则的优先级:Webpack的splitChunks的cacheGroups是按优先级排序的,优先级高的规则会先匹配,所以要把自定义的拆包规则的优先级设得比默认规则高,不然可能会被默认规则覆盖
  5. 兼容旧浏览器:import()语法是ES6的,旧浏览器(比如IE11)不支持,如果项目需要兼容旧浏览器,需要用babel转译,或者用其他的按需加载方案

六、文章总结

原来前端地图项目的体积膨胀问题,本质上是代码没有合理拆分和加载导致的,通过Webpack的拆包能力和ES6的异步加载语法,就能很好地解决这个问题。整个方案的核心思路很简单:把必须同步加载的核心代码(基础地图)和非核心代码(热力图、路径规划等)分开,非核心代码只有在用户需要的时候才加载,同时把地图相关的第三方库和业务代码拆成独立的包,减少主包的体积。

这个方案不仅能解决地图项目的体积问题,其他有类似问题的项目也可以参考,比如富文本编辑器、图表库这些体积大的第三方库,都可以用拆包和按需加载的方式优化。优化的核心是站在用户的角度思考:用户需要什么,就加载什么,不要把用户不需要的东西硬塞给他,这样才能给用户更好的体验。