d

Webpack

React 开发因为需要很难配置的工具而臭名昭著。 这些天,由于create-react-app 的存在 ,开始使用 React 开发几乎是没有痛苦的。 对于浏览器端的 JavaScript 开发,可能从来没有过更好的开发工作流。

我们不能永远依赖f create-react-app的黑魔法,现在是时候让我们看看底层下面。 使 React 应用功能化的一个关键参与者是一个叫做webpack的工具。

Bundling

【捆绑】

我们已经实现了我们的应用,将我们的代码分割成单独的模块,这些模块已经被导入到需要它们的地方。 尽管 ES6模块是在 ECMAScript 标准中定义的,但没有浏览器真正知道如何处理划分为模块的代码。

由于这个原因,被划分为模块的代码对于浏览器必须是绑定的,这意味着所有的源代码文件都被转换成一个包含所有应用代码的文件。 在 第3章中部署 React 前端生产应用时,我们执行了将应用与 npm run build 命令绑定在一起的操作。 在底层,npm 脚本使用 webpack 捆绑源代码,在build 目录下生成如下文件集合:


├── asset-manifest.json
├── favicon.ico
├── index.html
├── manifest.json
├── precache-manifest.8082e70dbf004a0fe961fc1f317b2683.js
├── service-worker.js
└── static
    ├── css
    │   ├── main.f9a47af2.chunk.css
    │   └── main.f9a47af2.chunk.css.map
    └── js
        ├── 1.578f4ea1.chunk.js
        ├── 1.578f4ea1.chunk.js.map
        ├── main.8209a8f2.chunk.js
        ├── main.8209a8f2.chunk.js.map
        ├── runtime~main.229c360f.js
        └── runtime~main.229c360f.js.map

位于 build 目录根目录的index. html 文件是应用的“ main file” ,它用script 标签加载绑定的 JavaScript 文件(实际上有两个绑定的 JavaScript 文件) :

<!doctype html><html lang="en">
<head>
  <meta charset="utf-8"/>
  <title>React App</title>
  <link href="/static/css/main.f9a47af2.chunk.css" rel="stylesheet"></head>
<body>
  <div id="root"></div>
  <script src="/static/js/1.578f4ea1.chunk.js"></script>
  <script src="/static/js/main.8209a8f2.chunk.js"></script>
</body>
</html>

我们可以从使用 create-react-app 创建的示例应用中看到,构建脚本还将应用的 CSS 文件捆绑到单个/static/css/main.f9a47af2.chunk.css

实际上,进行绑定是为了为应用定义一个入口点,通常是index.js 文件。 当 webpack 打包代码时,它包含入口点导入的所有代码,以及导入代码的导入,等等。

由于部分导入的文件是 React、 Redux 和 Axios 之类的包,所以绑定的 JavaScript 文件也将包含这些库的内容。

将应用的代码划分为多个文件的老方法是基于这样一个事实,即index. html 文件在脚本标记的帮助下加载了应用的所有单独的 JavaScript 文件。 这导致性能下降,因为每个单独文件的加载都会导致一些开销。 出于这个原因,现在的首选方法是将代码捆绑到单个文件中。

接下来,我们将从头开始为 React 应用创建一个合适的 webpack 配置。

让我们用如下子目录( build 和 src)和文件为项目创建一个新目录:


├── build
├── package.json
├── src
│   └── index.js
└── webpack.config.js

package.json文件的内容可以如下:

{
  "name": "webpack-part7",
  "version": "0.0.1",
  "description": "practising webpack",
  "scripts": {},
  "license": "MIT"
}

让我们用下面的命令来安装 webpack:

npm install --save-dev webpack webpack-cli

我们在webpack.config.js 文件中定义了 webpack 的功能,我们使用如下内容初始化它:

const path = require('path')

const config = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: 'main.js'
  }
}
module.exports = config

然后我们将定义一个名为build 的新 npm 脚本,该脚本将执行与 webpack 的捆绑:

// ...
"scripts": {
  "build": "webpack --mode=development"
},
// ...

让我们在 src/index.js 文件中添加一些代码:

const hello = name => {
  console.log(`hello ${name}`)
}

当我们执行 npm run build 命令时,我们的应用代码将被 webpack 绑定。 该操作将生成一个新的main.js 文件,该文件添加在build 目录下:

fullstack content

这个文件包含了很多看起来很有趣的东西。 我们还可以在文件末尾看到我们之前写的代码:

fullstack content

让我们在src 目录下添加一个App.js 文件,内容如下:

const App = () => {
  return null
}

export default App

让我们导入并使用index.js 文件中的App 模块:

import App from './App';

const hello = name => {
  console.log(`hello ${name}`)
}

App()

当我们再次将应用与 npm run build 命令捆绑在一起时,我们注意到 webpack 已经确认了这两个文件:

fullstack content

我们的应用代码可以在 bundle 文件的末尾找到,格式相当模糊:

/***/ "./src/App.js":
/*!********************!*\
  !*** ./src/App.js ***!
  \********************/
/*! exports provided: default */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);\nconst App = () => {\n  return null\n}\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (App);\n\n//# sourceURL=webpack:///./src/App.js?");

/***/ }),

/***/ "./src/index.js":
/*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
/*! no exports provided */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _App__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./App */ \"./src/App.js\");\n\n\nconst hello = name => {\n  console.log(`hello ${name}`)\n};\n\nObject(_App__WEBPACK_IMPORTED_MODULE_0__[\"default\"])()\n\n//# sourceURL=webpack:///./src/index.js?");

/***/ })

Configuration file

【配置文件】

让我们仔细看看当前webpack.config.js 文件的内容:

const path = require('path')

const config = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: 'main.js'
  }
}

module.exports = config

配置文件使用 JavaScript 编写,配置对象使用 Node 的模块语法导出。

我们的最小配置定义几乎解释了它自己。 配置对象的entry属性指定将作为绑定应用的入口点的文件。

属性定义了将要存储绑定代码的位置 output。 目标目录必须被定义为绝对路径,这很容易用path.resolve方法创建。 我们还使用了__dirname ,它是 Node 中的一个全局变量,用于存储到工作目录的路径。

Bundling React

【捆绑React】

接下来,让我们把我们的应用转换成一个最小的 React 应用:

npm install --save react react-dom

让我们通过在index.js 文件中添加熟悉的定义,将我们的应用转换为 React 应用:

import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'

ReactDOM.render(<App />, document.getElementById('root'))

我们还将对App.js 文件进行如下更改:

import React from 'react'

const App = () => (
  <div>hello webpack</div>
)

export default App

我们仍然需要build/index.html 文件,它将作为我们应用的“主页” ,用 script 标签加载我们打包的 JavaScript 代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>React App</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="text/javascript" src="./main.js"></script>
  </body>
</html>

当我们捆绑应用时,会遇到如下问题:

fullstack content

Loaders

【装载器】

来自 webpack 的错误消息指出,我们可能需要一个适当的loader 来正确捆绑App.js 文件。 默认情况下,webpack 只知道如何处理普通的 JavaScript。 尽管我们可能没有意识到这一点,但我们实际上正在使用JSX在 React 中渲染我们的视图。 为了说明这一点,下面的代码不是普通的 JavaScript:

const App = () => {
  return <div>hello webpack</div>
}

上面使用的语法来自 JSX,它为我们提供了为 html div 标签定义 React 元素的替代方法。

我们可以使用装载器来告知 webpack 需要在捆绑之前处理的文件。

让我们为应用配置一个装载器,将 JSX 代码转换为常规的 JavaScript:

const config = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: 'main.js',
  },
  module: {    rules: [      {        test: /\.js$/,        loader: 'babel-loader',        query: {          presets: ['@babel/preset-react'],        },      },    ],  },}

装载器是在rules 数组中的module 属性下定义的。

单一装载器的定义包括三个部分:

{
  test: /\.js$/,
  loader: 'babel-loader',
  query: {
    presets: ['@babel/preset-react']
  }
}

test 属性指定加载程序用于名称以.js 结尾的文件。 属性指定对这些文件的处理将通过babel-loader来完成。query 属性用于为加载程序指定参数,用于配置其功能。

让我们将装载器及其所需的包作为开发依赖项 安装:

npm install @babel/core babel-loader @babel/preset-react --save-dev

捆绑应用现在将获得成功。

如果我们对App 组件进行一些修改,并查看捆绑的代码,我们会注意到该组件的捆绑版本如下所示:

const App = () =>
  react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(
    'div',
    null,
    'hello webpack'
  )

正如我们从上面的例子中看到的,在 JSX 中编写的 React 元素现在通过 React 的createElement函数使用常规的 JavaScript 创建。

你可以通过浏览器的open file 功能打开 build/index.html文件来测试捆绑的应用:

fullstack content

值得注意的是,如果捆绑的应用的源代码使用async/await,浏览器将不会在某些浏览器上渲染任何内容。 谷歌在控制台中搜索错误信息将会在这个问题上给出一些答案。 我们必须再安装一个缺失的依赖项,即@babel/polyfill :

npm install --save @babel/polyfill

让我们对webpack.config.js 文件中的 webpack 配置对象的entry 属性进行如下更改:

  entry: ['@babel/polyfill', './src/index.js']

我们的配置几乎包含了 React 开发所需的所有东西。

Transpilers

【转译工具】

将代码从一种 JavaScript 形式转换为另一种 JavaScript 形式的过程称为transpiling。 该术语的一般定义是通过将源代码从一种语言转换为另一种语言来编译源代码。

通过使用上一节中的配置,我们在babel的帮助下将包含 JSX 的代码转换为常规 JavaScript,这是目前最流行的工具。

正如第一章节中提到的,大多数浏览器不支持 ES6和 ES7中引入的最新特性,因此代码通常会转移到实现 ES5标准的 JavaScript 版本中。

通过plugins 定义了 Babel 执行的转译过程。 实际上,大多数开发人员使用的是现成的预设插件,这些插件是一组预先配置的插件。

目前,我们正在使用@babel/preset-react预设来转译我们应用的源代码:

{
  test: /\.js$/,
  loader: 'babel-loader',
  query: {
    presets: ['@babel/preset-react']  }
}

让我们添加一个@babel/pressing-env插件,它包含使用所有最新特性编写代码并将其转化为兼容 ES5标准的代码所需的所有内容:

{
  test: /\.js$/,
  loader: 'babel-loader',
  query: {
    presets: ['@babel/preset-env', '@babel/preset-react']  }
}

让我们用下面的命令来安装预置:

npm install @babel/preset-env --save-dev

当我们将代码转换为传统的 JavaScript 时。 转换后的App 组件的定义如下:

var App = function App() {
  return _react2.default.createElement('div', null, 'hello webpack')
};

正如我们看到的,变量是用 var 关键字声明的,因为 ES5 JavaScript 不理解 const 关键字。 也不使用箭头函数,这就是为什么函数定义使用函数关键字的原因。

CSS

让我们向我们的应用添加一些 CSS:

.container {
  margin: 10;
  background-color: #dee8e4;
}

然后让我们使用App 组件中的样式:

const App = () => {
  return (
    <div className="container">
      hello webpack
    </div>
  )
}

我们在index.js 文件中导入样式:

import './index.css'

这将导致转译过程中断:

fullstack content

当使用 CSS 时,我们必须使用CSSstyle装载器:

{
  rules: [
    {
      test: /\.js$/,
      loader: 'babel-loader',
      query: {
        presets: ['@babel/preset-react', '@babel/preset-env'],
      },
    },
    {      test: /\.css$/,      loaders: ['style-loader', 'css-loader'],    },  ];
}

css loader的工作是加载CSS 文件, style loader的工作是生成并注入一个style 元素,该元素包含应用的所有样式。

使用这种配置,CSS 定义包含在应用的main.js 文件中。 出于这个原因,不需要单独导入应用的主要index. html 文件中的CSS 样式。

如果需要,应用的 CSS 也可以通过使用mini-CSS-extract-plugin生成到它自己的独立文件中。

当我们安装装载器时:

npm install style-loader css-loader --save-dev

捆绑将再次成功,应用将获得新的样式。

Webpack-dev-server

当前的配置使得开发我们的应用成为可能,但是工作流非常糟糕(以至于它类似于 Java 的开发工作流)。 每次我们对代码进行修改时,我们必须将它捆绑起来并刷新浏览器以测试代码。

webpack-dev-server 为我们的问题提供了一个解决方案:

npm install --save-dev webpack-dev-server

让我们定义一个 npm 脚本来启动 dev-server:

{
  // ...
  "scripts": {
    "build": "webpack --mode=development",
    "start": "webpack-dev-server --mode=development"  },
  // ...
}

我们还可以在webpack.config.js 文件的配置对象中添加一个新的devServer 属性:

const config = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: 'main.js',
  },
  devServer: {    contentBase: path.resolve(__dirname, 'build'),    compress: true,    port: 3000,  },  // ...
};

Npm start 命令现在将在端口3000启动 dev-server,这意味着我们的应用将可以通过浏览器中的 http://localhost:3000 访问。 当我们修改代码时,浏览器会自动刷新页面。

更新代码的过程很快。 当我们使用 dev-server 时,代码不会以通常的方式捆绑到main.js 文件中。 捆绑的结果只存在于内存中。

让我们通过更改App 组件的定义来扩展代码,如下所示:

import React, {useState} from 'react'

const App = () => {
  const [counter, setCounter] = useState(0)

  return (
    <div className="container">
      hello webpack {counter} clicks
      <button onClick={() => setCounter(counter + 1)}>
        press
      </button>
    </div>
  )
}

export default App

值得注意的是,错误消息的显示方式与使用 create-react-app 创建的应用不同。 出于这个原因,我们必须更加关注控制台:

fullstack content

应用运行良好,开发工作流程相当流畅。

Source maps

让我们将 click 处理程序提取到它自己的函数中,并将计数器先前的值存储到它自己的values 状态中:

const App = () => {
  const [counter, setCounter] = useState(0)
  const [values, setValues] = useState()
  const handleClick = () => {
    setCounter(counter + 1)
    setValues(values.concat(counter))  }

  return (
    <div className="container">
      hello webpack {counter} clicks
      <button onClick={handleClick}>
        press
      </button>
    </div>
  )
}

应用不再工作,控制台将显示如下错误:

fullstack content

我们知道错误在 onClick 方法中,但是如果应用再大一点,错误消息就很难追踪了:


App.js:27 Uncaught TypeError: Cannot read property 'concat' of undefined
    at handleClick (App.js:27)

消息中说明的错误位置与源代码中错误的实际位置不匹配。 如果我们单击错误消息,我们会注意到显示的源代码与我们的应用代码不同:

fullstack content

当然,我们希望在错误消息中看到实际的源代码。

幸运的是,在这方面修复错误消息非常容易。 我们将要求 webpack 为捆绑包生成一个所谓的源映射 ,这样就可以将捆绑包执行期间发生的错误映射到原始源代码中的相应部分。

可以通过向配置对象添加一个新的devtool 属性来生成源映射,其值为‘ source-map’ :

const config = {
  entry: './src/index.js',
  output: {
    // ...
  },
  devServer: {
    // ...
  },
  devtool: 'source-map',  // ..
};

当我们修改 Webpack 的配置时,必须重新启动它。 也可以让 webpack 观察自身的变化,但这次我们不会这么做。

错误消息现在好多了

fullstack content

因为它指的是我们写的代码

fullstack content

生成源地图也使得使用 Chrome 调试器成为可能:

fullstack content

让我们通过将values 的状态初始化为一个空数组来修复这个 bug:

const App = () => {
  const [counter, setCounter] = useState(0)
  const [values, setValues] = useState([])
  // ...
}

Minifying the code

【压缩代码】

在将应用部署到生产环境时,我们使用的是 webpack 生成的main.js 代码包。 Js 文件的大小为974473字节,尽管我们的应用只包含几行我们自己的代码。 文件大小较大是因为 bundle 还包含整个 React 库的源代码。 捆绑代码的大小很重要,因为浏览器必须在第一次使用应用时加载代码。 对于高速互联网连接,974473字节不是问题,但是如果我们继续增加更多的外部依赖,加载速度可能会成为一个问题,特别是对于移动用户。

如果我们检查 bundle 文件的内容,我们注意到通过删除所有便笺,可以在文件大小方面大大优化它。 手动优化这些文件是没有意义的,因为有许多现有的工具可以完成这项工作。

Javascript 文件的优化过程被称为minification,用于此目的的主要工具之一是UglifyJS

从版本4的webpack,缩小插件不需要额外的配置使用。 修改package.json 文件中的 npm 脚本就足以指定 webpack 将在production模式下执行代码的捆绑:

{
  "name": "webpack-part7",
  "version": "0.0.1",
  "description": "practising webpack",
  "scripts": {
    "build": "webpack --mode=production",    "start": "webpack-dev-server --mode=development"
  },
  "license": "MIT",
  "dependencies": {
    // ...
  },
  "devDependencies": {
    // ...
  }
}

当我们再次捆绑应用时,得到的main.js 的大小会大幅减小:

$ ls -l build/main.js
-rw-r--r--  1 mluukkai  984178727  132299 Feb 16 11:33 build/main.js

缩小过程的输出类似于老式的 c 代码; 所有的便笺、甚至不必要的空格和换行符都被删除了,变量名被单个字符替换。

function h(){if(!d){var e=u(p);d=!0;for(var t=c.length;t;){for(s=c,c=[];++f<t;)s&&s[f].run();f=-1,t=c.length}s=null,d=!1,function(e){if(o===clearTimeout)return clearTimeout(e);if((o===l||!o)&&clearTimeout)return o=clearTimeout,clearTimeout(e);try{o(e)}catch(t){try{return o.call(null,e)}catch(t){return o.call(this,e)}}}(e)}}a.nextTick=function(e){var t=new Array(arguments.length-1);if(arguments.length>1)

Development and production configuration

【开发及生产配置】

接下来,让我们为应用添加一个后端,并重用现在熟悉的 note 应用后端。

让我们在db.json 文件中存储如下内容:

{
  "notes": [
    {
      "important": true,
      "content": "HTML is easy",
      "id": "5a3b8481bb01f9cb00ccb4a9"
    },
    {
      "important": false,
      "content": "Mongo can save js objects",
      "id": "5a3b920a61e8c8d3f484bdd0"
    }
  ]
}

我们的目标是以这样一种方式配置应用,即当在本地使用时,应用使用端口3001中可用的 json-server 作为其后端。

然后将绑定的文件配置为使用 https://blooming-atoll-75500.herokuapp.com/api/notes 地址中可用的后端。

我们将安装axios,启动 json-server,然后对应用进行必要的更改。 为了更改内容,我们将使用名为 useNotes 的custom hook从后端获取便笺:

import React, { useState, useEffect } from 'react'
import axios from 'axios'

const useNotes = (url) => {  const [notes, setNotes] = useState([])  useEffect(() => {    axios.get(url).then(response => {      setNotes(response.data)    })  }, [url])  return notes}
const App = () => {
  const [counter, setCounter] = useState(0)
  const [values, setValues] = useState([])
  const url = 'https://blooming-atoll-75500.herokuapp.com/api/notes'
  const notes = useNotes(url)
  const handleClick = () => {
    setCounter(counter + 1)
    setValues(values.concat(counter))
  }

  return (
    <div className="container">
      hello webpack {counter} clicks
      <button onClick={handleClick} >press</button>
      <div>{notes.length} notes on server {url}</div>    </div>
  )
}

export default App

后端服务器的地址目前在应用代码中是硬编码的。 当代码为生产打包时,我们如何以受控的方式更改地址以指向生产后端服务器?

让我们将webpack.js 文件中的配置对象更改为函数而不是对象:

const path = require('path');

const config = (env, argv) => {
  return {
    entry: './src/index.js',
    output: {
      // ...
    },
    devServer: {
      // ...
    },
    devtool: 'source-map',
    module: {
      // ...
    },
    plugins: [
      // ...
    ],
  }
}

module.exports = config

定义几乎保持不变,除了配置对象现在由函数返回这一事实。 函数接收两个参数, envargv,第二个参数可用于访问在 npm 脚本中定义的mode

我们也可以使用 webpack 的DefinePlugin来定义全局默认常量,这些常量可以用在捆绑的代码中。 让我们定义一个新的全局常量BACKEND_URL,它的值取决于打包代码的环境:

const path = require('path')
const webpack = require('webpack')
const config = (env, argv) => {
  console.log('argv', argv.mode)

  const backend_url = argv.mode === 'production'    ? 'https://blooming-atoll-75500.herokuapp.com/api/notes'    : 'http://localhost:3001/api/notes'
  return {
    entry: './src/index.js',
    output: {
      path: path.resolve(__dirname, 'build'),
      filename: 'main.js'
    },
    devServer: {
      contentBase: path.resolve(__dirname, 'build'),
      compress: true,
      port: 3000,
    },
    devtool: 'source-map',
    module: {
      // ...
    },
    plugins: [      new webpack.DefinePlugin({        BACKEND_URL: JSON.stringify(backend_url)      })    ]  }
}

module.exports = config

全局常量在代码中如下列方式使用:

const App = () => {
  const [counter, setCounter] = useState(0)
  const [values, setValues] = useState([])
  const notes = useNotes(BACKEND_URL)
  // ...
  return (
    <div className="container">
      hello webpack {counter} clicks
      <button onClick={handleClick} >press</button>
      <div>{notes.length} notes on server {BACKEND_URL}</div>    </div>
  )
}

如果开发和生产的配置有很大的不同,那么将两者的配置分离到各自的配置文件 中可能是一个不错的主意

通过在build 目录中执行如下命令,我们可以在本地检查应用的捆绑生产版本:

npx static-server

默认情况下,捆绑的应用将在 http://localhost:9080 提供。

Polyfill

我们的应用已经完成,并且可以与所有相对较新的现代版本的浏览器一起工作,除了 Internet Explorer 浏览器。 这是因为我们的代码使用了 axios Promises ,并且现有的 IE 版本都不支持它们:

fullstack content

在标准中还有很多 IE 不支持的东西。 一些像 JavaScript 数组的find方法一样无害的东西超过了 IE 的能力:

fullstack content

在这些情况下,仅仅透露代码是不够的,因为透露只是将代码从一个新版本的 JavaScript 转换到一个有更广泛的浏览器支持的旧版本。 在语法上理解 Promises,但是还没有实现他们的功能。 Ie 中数组的 find 属性只是undefined

如果我们希望应用兼容 ie,我们需要添加一个polyfill ,这是代码添加缺少的功能到旧的浏览器。

Polyfills 可以在webpack and Babel的帮助下添加,也可以安装现有的多填充库中的一个。

promise-polyfill库提供的polyfills很容易使用,我们只需在现有的应用代码中添加如下内容:

import PromisePolyfill from 'promise-polyfill'

if (!window.Promise) {
  window.Promise = PromisePolyfill
}

如果全局 Promise 对象不存在,这意味着浏览器不支持 Promises,则 polyfilled Promise 存储在全局变量中。 如果 polyfilled Promise 实现得足够好,那么剩下的代码应该可以正常工作。

一个现有polyfills的详尽列表可以在这里 here找到。

不同 API 的浏览器兼容性可以通过访问https://caniuse.com 或者Mozilla 网站来检查。

Eject

Create-react-app 工具在幕后使用 webpack。 如果缺省配置不够,可以eject这个项目,它将摆脱所有的黑魔法,并且缺省配置文件将存储在config 目录和一个修改过的package.json 文件中。

如果您eject一个用 create-react-app 创建的应用,就不会返回,所有的配置都必须手动维护。 默认配置并不简单,与其从 create-react-app中eject,不如从一开始就编写自己的 webpack 配置。

检查和读取eject应用的配置文件仍然是推荐的,而且非常有教育意义。