声明式的三维地球渲染1:基于Webpack的Cesium+React应用

原文地址:http://blog.isquaredsoftware.com/2017/03/declarative-earth-part-1-cesium-webpack/

使用Create-React-App和Webpack的DllPlugin插件

介绍

Cesium.js是一个渲染三维地球的JavaScript库。可以实现丰富的地理空间可视化,比如图标、文字标签、矢量几何体和三维模型等等。这些对象都可视化在一个带有地形和影像的三维地球上。

Cesium与Google Earth再理念和功能上非常相似。但是,相比Google Earth浏览器插件(GEP,现已弃用),Cesium有许多优势。Google Earth插件是一个需要安装在客户机上的二进制插件,虽然提供JavaScript的API,但是插件内部的逻辑行为实现是一个封闭的黑箱。另外,虽然GEP可以通过Google Earth企业版本地搭载数据服务器,实现定制化,但是价格昂贵。

此外,虽然Cesium是由AGI公司的成员开发,但它是完全开源免费的。Cesium不需要浏览器插件。只要是支持WebGL的浏览器就可以运行。Cesium也支持大量遥感影像和地形数据。可以用在公网搭载或者本地搭载的地球数据服务器上。

我在多个地理空间应用中使用Cesium,对它的功能已经非常熟悉了。在工作中,我再多个客户端框架中使用了Cesium,包括GWT,Backbone和现在的React。

Cesium是一个复杂的工具包,开发开始于2012年。因此,它的结构有些复杂,在用于React和Webpack应用时有些困难。虽然有关于基于Webpack的Cesium应用配置的文章,不过没有一篇是讨论过如何将Cesium用在React上。此外,我利用一些Webpack的优势功能优化了开发和部署的过程。

这个系列有两个部分,我会教你:

  • 建立一个可以加载Cesium的React应用
  • 用DLLPlugin做代码分割,配置Webpack,加快Cesium的应用的编译时间和部署
  • 用React组件实现声明式的控制Cesium图元渲染

该教程默认读者具备基本的Cesium、React和Webpack知识,不会涉及这些内容的基础。

该教程的样例代码已经上传到Github上(github.com/markerikson/cesium-react-webpack-demo)。这篇教程中的commits可以在PR#1:Configure Webpack中找到。我会在链接到在写教程时提交的commit和commit中特定的文件。我不会贴出每一个修改的文件或者代码行,但是会尽量把与每一个commit最相关的修改列出来。

目录

  • 建立基本的React+Cesium应用
    • 配置Webpack来使用Cesium
    • 加载Cesium到应用中
  • 优化Cesium应用的部署
    • 在开发中加载Cesium
    • 用DllPlugin编译一个Cesium包(Cesium Bundle)
    • 在应用中使用一个DLL包(DLL Bundle)
    • 在产品中加入Cesium
    • 总结Webpack优化
  • 最后的思考
  • 更多信息

建立基本的React+Cesium应用

初始安装

我们首先要用Create-React-App工具建立一个新项目。为简化流程,在下面总结了步骤:

  • create-react-app建立项目,提交Git
  • 用Yarn配置一个lockfile作为工程的依赖
  • 清除现有<App>组件,只渲染“Empty”
  • 用Yarn添加Cesium到依赖包中

写教程的时候CRA版本为0.9.2,Cesium版本为1.31。

配置Webpack来使用Cesium

如上文所述,Cesium结构复杂,很难直接用Webpack打包,因为:

  • Cesium是用异步模块定义(AMD)的格式编写源码的
  • 它包括一些事先编译好的基于AMD的第三方库
  • Cesium中web worker的使用率很高
  • 一些代码使用了多行字符串

基础的Cesium+Webpack配置来自Mike Macaulay的文章Cesium and Webpack和样例代码mmacaula/cesium-webpack。在他的教程中,他讨论了两种使用Cesium的方式:使用预编译的Cesium包和直接使用Cesium源码。我们要用源码的方法开始。

Mike的教程中指出,我们需要在Webpack配置文件中设置两处地方。我们需要设置sourcePrefix: '',让Webpack正确缩进多行字符串。然后设置unknownContextCritical: false,不让Webpack打印载入特定库时候的警告。

遗憾的是,我们用的Create-React-App不能直接获取Webpack配置文件,因为CRA默认隐藏了文件。所以我们要用CRA的“安全舱口”:npm run eject命令。该命令可以复制CRA的配置文件到我们的项目中,并且更新package.json。使它包含所有独立工具的依赖包,而不是单纯的react-scripts依赖包。我们eject以后,所有代码还是和原来一样运行,但是我们不能靠升级react-scripts依赖包来获取最新的编译系统更新——我们现在拥有的所有编译配置都是“自己的”。

eject之后将新的代码提交git。我们可以做一些必要的改动。首先。根据“Cesium and Webpack”教程,我们需要复制Cesium预编译的worker文件到public文件夹。这是一个手动的步骤,不需要commit。浏览$PROJECT/node_modules/cesium/Build/。你会发现两个文件夹,CesiumCesiumUnminified。复制整个Cesium文件夹到PROJECT/public/,重命名为cesium。然后,删除文件夹里面的Cesium.js文件,那么,你现在拥有以下文件夹架构:

- $PROJECT
  - public
    - cesium
      - Assets
      - ThirdParty
      - Widgets
      - Workers

既然我们不想提交这个文件夹给git,可以再.gitignore文件中加入:

Commit c47204c: 忽略包含 Cesium 的 public/cesium 文件夹

.gitignore

# production
/build
+/public/cesium/

然后添加需要的配置改动到Webpack配置文件中:

Commit 2015273: 设置运行Cesium所需的Webpack配置项

config/webpack.config.dev.js

    publicPath: publicPath,
+   sourcePrefix : '',

// Skip ahead

  module: {
+   unknownContextCritical : false,

以上配置需要在开发模式和产品模式中都配置

加载Cesium到应用中

在加载Cesium之前需要配置Cesium,让它知道如何为所有资源构造URL。需要调用Cesium自带的buildModuleUrl()函数。完成后,就可以加载Viewer实例并让Cesium工作。

Commit 17479d3: 添加初始Cesium Viewer到app

src/index.js

import App from './App';  
import './index.css';

+import "cesium/Source/Widgets/widgets.css";
+
+import buildModuleUrl from "cesium/Source/Core/buildModuleUrl";
+buildModuleUrl.setBaseUrl('./cesium/');

注意,我们设置基础URL为"./cesium/",与我们之前复制到$PROJECT/public下的预编译Cesium对应。

从现在起,我们就可以按照React的规范方法获取DOM元素。用一个div作为Cesium Widget的渲染容器,用“回调引用”(callback ref)存储真实的DOM元素。然后,在componentDidMount方法中我们创建一个Cesium.Viewer实例,将真实节点的引用作为viewer的容器。

src/App.js

import React, { Component } from 'react';

import Viewer from "cesium/Source/Widgets/Viewer/Viewer";

class App extends Component {  
    componentDidMount() {
        this.viewer = new Viewer(this.cesiumContainer);
    }

    render() {
        return (
            <div>
                <div id="cesiumContainer" ref={ element => this.cesiumContainer = element }/>
            </div>
        );
    }
}

注意我们这里导入的是Cesium文件夹中的小文件模块,不是用import Cesium from "cesium"导入整个模块

如果运行工程,我们就可以看到:

成功!我们已经加载了Cesium,并且看到了一个默认配置的Cesium Viewer三维地球。如果你仔细观察图片,你会发现viewer不是完全填满整个屏幕的,下方有一点空白空间。我们会在下一部分解决这个问题。

这就建立了一个基础应用。下面我们设置产品模式编译环境。

优化Cesium应用部署

到此为止,我们已经简单直接地将Cesium导入应用。作为起步已经很不错了,但是这也就意味着我们的应用包会包含几乎所有的Cesium代码。遗憾的是,Cesium是一个很大的工具包。让我们编译一下产品:

$ node scripts/build.js
Creating an optimized production build...  
Compiled successfully.

File sizes after gzip:

  532.54 KB  build\static\js\main.304c45dc.js
  4.78 KB    build\static\css\main.c5310e60.css

Done in 48.13s.  

。。。“gzip压缩后还要532KB?”看起来很大啊。我们用source-map-explorer检查一下包内压缩的目录:

天哪!我们的产品应用包超过2MB了,而压缩版的Cesium自己就占了1.8MB。这真的很不好。同时,Cesium中大量的web worker和资源分别导入时,也占用了很大的空间。虽然gzip压缩可以把它缩小到532KB,但是这远不是我们想要加载的大小。

坏消息是我们能做的其实不多,Cesium的确很大很复杂,我们不能大量的缩小Cesium的大小。使用Cesium的应用恐怕很难获得“最小载入量”奖。

不过,好消息是我们可以将Cesium分离出应用包。我们还是需要载入它,实际上还需要在应用包之前载入它。但是分离出来之后我们起码能缓存它,用于下次加载。此外,预编译Cesium包以后,我们可以减少应用包的产品编译时间

在开发中加载Cesium

在分离编译Cesium之前,我们可以先提升一下我们的开发步骤。之前在预编译的输出文件夹中一直有一个$PROJECT/public/cesium/中Cesium的副本,因为这些文件必须在Cesium运行时从服务器加载到客户端。我们可以修改CRA dev server的设置,让这些文件直接从node_modules/cesium/

首先我们要给CRA预定义路径列表中添加几项:

Commit 73bdbe2: 添加额外路径用于编译

config/path.js

  ownNodeModules: resolveApp('node_modules'),
+  app : resolveApp('.'),
+  appConfig : resolveApp('config'),
+  cesiumDebugBuild : resolveApp('node_modules/cesium/Build/CesiumUnminified/'),
+  cesiumProdBuild : resolveApp('node_modules/cesium/Build/Cesium/'),
+  cesiumSourceFolder : resolveApp('node_modules/cesium/Source/'),
  nodePaths: nodePaths,

然后我们要修改CRA dev server的配置,让它直接从node_modules中的cesium加载静态文件:

Commit eabdcf5: 修改CRA dev server来加载Cesium文件

scripts/start.js

var openBrowser = require('react-dev-utils/openBrowser');  
var prompt = require('react-dev-utils/prompt');  
+var express = require("express");

// Skip ahead

function addMiddleware(devServer) {

+  // Handle requests for Cesium static assets that we want to
+  // serve up direct from /node_modules/cesium/.
+  devServer.use("/cesium", express.static(paths.cesiumDebugBuild));
+

现在我们可以删除$PROJECT/public/文件夹中的cesium/文件夹了,至少在开发阶段不需要他了。CRA进行产品编译时会自动复制/public/文件夹下的所有内容到输出文件夹,所以我们需要添加自定义的逻辑去处理在产品编译时复制Cesium资源的步骤。这一点我们一会儿会提到。

用DllPlugin编译一个Cesium包

Webpack构建代码包和代码块的方法有好几种。应用最广泛的方法是在配置文件的entry声明包的入口点,然后用CommonsChunkPlugin提取在多个代码块中共享的文件,放入一个独立的包中。

DllPlugin是另一种鲜为人知的Webpack插件。名字源自Windows中的“动态链接库”(或*nix用户中的.so),插件的机制是先预编译一个包含可复用或部分共享的代码包,然后创建一个元数据文件来描述包中的内容。然后,当你编译应用时,通过元数据索引让Webpack知道哪些代码已经编译好了。Webpack打包时就会跳过这些代码。

编译一个DLL包要求创建一个新的Webpack配置文件,与应用的配置文件分开。该配置文件如下:

Commit 4ddc26a: 添加预编译Cesium包的Webpack配置文件

config/webpack.cesium.dll.config.js

"use strict";

const path = require("path");  
const webpack = require("webpack");

const paths = require("./paths");  
const env = require("./env");

const outputPath = path.join(paths.app, "distdll");

const webpackConfig = {  
    entry : {
        cesiumDll : ["cesium/Source/Cesium.js"],
    },
    devtool : "#source-map",
    output : {
        path : outputPath,
        filename : "[name].js",
        library : "[name]_[hash]",
        sourcePrefix: "",
    },
    plugins : [
        new webpack.DllPlugin({
            path : path.join(outputPath, "[name]-manifest.json"),
            name : "[name]_[hash]",
            context : paths.cesiumSourceFolder,
        }),

        new webpack.DefinePlugin({
            'process.env.NODE_ENV': JSON.stringify("production")
        }),

        new webpack.optimize.UglifyJsPlugin({
            compress: {
                warnings: false
            }
        })

    ],
    module : {
        unknownContextCritical : false,
        loaders : [
            { test : /\.css$/, loader: "style!css" },
            {
                test : /\.(png|gif|jpg|jpeg)$/,
                loader : "file-loader",
            },
        ],
    },
};

这里有几点需要提及。首先,我们是通过设置process.env.NODE_ENV="production"在产品模式下编译和压缩代码。这个配置文件中也包含了我们先前对webpack默认配置文件的一些修改。

最重要的部分是加入了DllPlugin的配置。path选项告诉Webpack在哪儿写元数据文件,name是包文件的名称,而我认为context大概描述了源文件如何被查询到。

现在我们有了配置文件,我们需要运行Webpack生成Cesium DLLL包。我已经写了一些脚本文件来控制这个过程。有一个文件用于在特定配置文件下运行Webpack和打印一些可读统计数据。其他的文件用于在这个配置环境下调用Webpack编译器脚本。

Commit b55dbd5: 添加编译Cesium DLL包的脚本

我们会跳过脚本内容,下面是运行DLL编译脚本的输出结果:

$ node ./scripts/buildCesiumDLL.js
Compiling: cesium  
  build [====================] 100% (35.5 seconds) ()

Build completed in 35.47s

Hash: 0829da3ac0fb7ef638b5  
Version: webpack 1.14.0  
Time: 35472ms  
           Asset     Size  Chunks             Chunk Names
    cesiumDll.js  2.13 MB       0  [emitted]  cesiumDll
cesiumDll.js.map    19 MB       0  [emitted]  cesiumDll  
Done in 37.33s.  

如果我们查看$PROJECT/distdll,会发现三个新文件:cesiumDll.jscesiumDll.js.mapcesiumDll-manifest.json。Cesium还是一个相当庞大的代码块,但是至少它已经是一个独立的文件了。

在应用中使用DLL包

很棒!最难的部分已经被攻克了。现在已经生成了DLL包,我们可以把Webpack产品模式配置指向manifest文件: Commit 6db616f: 更新Webpack产品模式配置,来使用Cesium DLL包

config/webpack/config.prod.js

+var path = require('path');
var autoprefixer = require('autoprefixer');

// Skip ahead

  plugins: [
+     new webpack.DllReferencePlugin({
+       context : paths.cesiumSourceFolder,
+         manifest: require(path.join(paths.app, "distdll/cesiumDLL-manifest.json")),
+     }),

我们刚刚将DllReferencePlugin添加到了产品模式的配置中,将'manifest'属性设置乘刚刚产生的manifest文件的地址,然后用生成DLL包的时候同样的context值(必须确保前后两个源文件引用一致)。

让我们运行一下产品模式编译应用,看看情况如何:

$ node scripts/build.js
Creating an optimized production build...  
Compiled successfully.

File sizes after gzip:

  46.09 KB  build\static\js\main.f984614f.js
  4.78 KB   build\static\css\main.c5310e60.css

Done in 10.43s.  

看起来好多了!编译时间从48秒下降到10秒了,整个包的大小经过gzip压缩已经缩小到46KB了。让我们用source-map-explorer查看一下包中的内容:

整个应用包的大小是150KB。现在让我们着眼于应用代码和非Cesium库,发现还可以对它进行更多的优化,但是上述优化已经足以达到我的要求了。

在产品中加入Cesium

现在我们要把Cesium编译到产品输出中。之前我们把public/cesium文件夹删除了,Cesium的资源(assets)不能复制到/build/文件夹中。我们可以在CRA的编译脚本中添加一些逻辑。这些逻辑只会在node_modules/cesium/Build/Cesium/文件夹中用glob查找搜集一系列文件,和Cesium DLL包一样,分别复制到输出文件夹中。

Commit 902ba04: 更新编译脚本,让Cesium文件拷贝到输出文件夹

最后一步是将Cesium DLL包真正加入到页面中。CRA编译时使用HtmlWebpackPlugin将正确的script标签插入到index.html模板中。我们能自定义模板,让产品中只包含DLL包。

Commit c65e7bd: 选择性地将Cesium包加入到HTML主页面中

config/webpack.config.prod.js

    new HtmlWebpackPlugin({
      inject: true,
      template: paths.appHtml,
+     production : true,

HtmlWebpackPlugin有几个选项,但是你能添加你想要的其他选项,它会发选项值传入。那么,既然我们要让这个选项在产品模式中执行,我们可以添加production : true到代码中。

public/index.html

  <body>
    <div id="root"></div>
+   <% if(htmlWebpackPlugin.options.production) { %>
+       <script src="cesium/cesiumDll.js"></script>
+   <% } %>
    <!--

HtmlWebpackPlugin用lodash处理模板,所以我们可以在模板中加入检查production选项的代码。如果是产品模式编译,就只加入一个Cesium DLL的script标签。

注意,Cesium DLL包必须在主应用包之前加载!因为DLL包将它的内容作为全局变量暴露出来(比如var cesiumDll_0829da3ac0fb7ef638b5),然后DllReferencePlugin中的逻辑负责找到这些特定的变量。

修改完后,我们应该可以从build文件夹中运行HTTP静态文件服务器,查看应用成功加载。

总结Webpack优化

我在写这篇教程的时候,我本来打算用Webpack的代码分割能力延迟加载DLL包。不幸的是,我意识到我现在做的这些其实算不上延迟加载。我的确分离出了一些代码,但是我们刚才也看到了,DllPlugin生成的包还是要手动加载。既然DLL包通常都是vendor库,那么用script标签提前加载也是有道理的。

Webpack issues上有一些帖子在讨论在网页运行时正确加载DLL包的方法,实现真正的延迟加载。结论似乎是可行的,但是它要求使用其他一些非Webpack的代码帮助加载。详情可以查看Webpack issues#2592#3115
需要注意的是DllPlugin不是真正地减少加载代码量,而是将代码分割成可以缓存的好几块

最后的思考

Cesium是一个非常复杂而强大的工具,Webpack是一个复杂而强大的打包工具。让他们两者完美配合还有很多工作需要做,但是这些工作都是值得的。

在接下来的第二部分中,我们要学习如何用React组件控制Cesium API。记得来看哦!

更多信息

浙ICP备16041529号-1