深入浅出-Webpack学习笔记(三)

接下来学习如何用 Webpack 去解决实际项目中常见的场景

使用ES6

虽然目前部分浏览器和 Node.js 已经支持 ES6,但由于它们对 ES6 所有的标准支持不全,这导致在开发中不敢全面地使用 ES6。

通常我们需要把采用 ES6 编写的代码转换成目前已经支持良好的 ES5 代码,这包含2件事:

  • 把新的 ES6 语法用 ES5 实现,例如 ES6 的 class 语法用 ES5 的 prototype 实现。
  • 给新的 API 注入 polyfill ,例如项目使用 fetch API 时,只有注入对应的 polyfill 后,才能在低版本浏览器中正常运行。

Babel

Babel 可以方便的完成以上2件事。 Babel 是一个 JavaScript 编译器,能将 ES6 代码转为 ES5 代码,让你使用最新的语言特性而不用担心兼容性问题,并且可以通过插件机制根据需求灵活的扩展。 在 Babel 执行编译的过程中,会从项目根目录下的 .babelrc 文件读取配置。.babelrc 是一个 JSON 格式的文件,内容大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"plugins": [
[
"transform-runtime",
{
"polyfill": false
}
]
],
"presets": [
[
"es2015",
{
"modules": false
}
],
"stage-2",
"react"
]
}

Plugins

plugins 属性告诉 Babel 要使用哪些插件,插件可以控制如何转换代码。

以上配置文件里的 transform-runtime 对应的插件全名叫做 babel-plugin-transform-runtime,即在前面加上了 babel-plugin-,要让 Babel 正常运行我们必须先安装它:

1
npm i -D babel-plugin-transform-runtime

babel-plugin-transform-runtime 是 Babel 官方提供的一个插件,作用是减少冗余代码。 Babel 在把 ES6 代码转换成 ES5 代码时通常需要一些 ES5 写的辅助函数来完成新语法的实现,例如在转换 class extent 语法时会在转换后的 ES5 代码里注入 _extent 辅助函数用于实现继承:

1
2
3
4
5
6
7
8
9
10
11
function _extent(target) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i];
for (var key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
target[key] = source[key];
}
}
}
return target;
}

这会导致每个使用了 class extent 语法的文件都被注入重复的_extent 辅助函数代码,babel-plugin-transform-runtime 的作用在于不把辅助函数内容注入到文件里,而是注入一条导入语句:

1
var _extent = require('babel-runtime/helpers/_extent');

这样能减小 Babel 编译出来的代码的文件大小。

同时需要注意的是由于 babel-plugin-transform-runtime 注入了 require(‘babel-runtime/helpers/_extent’) 语句到编译后的代码里,需要安装 babel-runtime 依赖到你的项目后,代码才能正常运行。 也就是说 babel-plugin-transform-runtime 和 babel-runtime 需要配套使用,使用了 babel-plugin-transform-runtime 后一定需要 babel-runtime。

Presets

presets 属性告诉 Babel 要转换的源码使用了哪些新的语法特性,一个 Presets 对一组新语法特性提供支持,多个 Presets 可以叠加。 Presets 其实是一组 Plugins 的集合,每一个 Plugin 完成一个新语法的转换工作。Presets 是按照 ECMAScript 草案来组织的,通常可以分为以下三大类:

已经被写入 ECMAScript 标准里的特性,由于之前每年都有新特性被加入到标准里,所以又可细分为:

  • es2015 包含在2015里加入的新特性;
  • es2016 包含在2016里加入的新特性;
  • es2017 包含在2017里加入的新特性;

env 包含当前所有 ECMAScript 标准里的最新特性。

它们之间的关系如图:


被社区提出来的但还未被写入 ECMAScript 标准里特性,这其中又分为以下四种:

  • stage0 只是一个美好激进的想法,有 Babel 插件实现了对这些特性的支持,但是不确定是否会被定为标准;
  • stage1 值得被纳入标准的特性;
  • stage2 该特性规范已经被起草,将会被纳入标准里;
  • stage3 该特性规范已经定稿,各大浏览器厂商和 Node.js 社区开始着手实现;
  • stage4 在接下来的一年将会加入到标准里去。
    它们之间的关系如图:

为了支持一些特定应用场景下的语法,和 ECMAScript 标准没有关系,例如 babel-preset-react 是为了支持 React 开发中的 JSX 语法。
在实际应用中,你需要根据项目源码所使用的语法去安装对应的 Plugins 或 Presets。

接入 Babel

在了解 Babel 后,下一步要知道如何在 Webpack 中使用它。 由于 Babel 所做的事情是转换代码,所以应该通过 Loader 去接入 Babel,Webpack 配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: ['babel-loader'],
},
]
},
// 输出 source-map 方便直接调试 ES6 源码
devtool: 'source-map'
};

配置命中了项目目录下所有的 JavaScript 文件,通过 babel-loader 去调用 Babel 完成转换工作。 在重新执行构建前,需要先安装新引入的依赖:

Webpack 接入 Babel 必须依赖的模块

1
npm i -D babel-core babel-loader

根据你的需求选择不同的 Plugins 或 Presets

1
npm i -D babel-preset-env

使用TS

要让 Webpack 支持 TypeScript,需要解决以下2个问题:

  • 通过 Loader 把 TypeScript 转换成 JavaScript。
  • Webpack 在寻找模块对应的文件时需要尝试 ts 后缀。

对于问题1,社区已经出现了几个可用的 Loader,推荐速度更快的 awesome-typescript-loader。 对于问题2,根据2-4 Resolve 中的 extensions 我们需要修改默认的 resolve.extensions 配置项。

综上,相关 Webpack 配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const path = require('path');

module.exports = {
// 执行入口文件
entry: './main',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, './dist'),
},
resolve: {
// 先尝试 ts 后缀的 TypeScript 源码文件
extensions: ['.ts', '.js']
},
module: {
rules: [
{
test: /\.ts$/,
loader: 'awesome-typescript-loader'
}
]
},
devtool: 'source-map',// 输出 Source Map 方便在浏览器里调试 TypeScript 代码
};

在运行构建前需要安装上面用到的依赖:

1
npm i -D typescript awesome-typescript-loader

安装成功后重新执行构建,你将会在 dist 目录看到输出的 JavaScript 文件 bundle.js,和对应的 Source Map 文件 bundle.js.map。 在浏览器里打开 index.html 页面后,来开发工具里可以看到和调试用 TypeScript 编写的源码。

注意:截止2020年7月,webpack版本为4.44版本,解析ts可以使用ts-loader的4.X版本

你需要在当前项目根目录下新建一个用于配置编译选项的 tsconfig.json 文件,编译器默认会读取和使用这个文件,配置文件内容大致如下:

1
2
3
4
5
6
7
8
9
10
{
"compilerOptions": {
"module": "commonjs", // 编译出的代码采用的模块规范
"target": "es5", // 编译出的代码采用 ES 的哪个版本
"sourceMap": true // 输出 Source Map 方便调试
},
"exclude": [ // 不编译这些目录里的文件
"node_modules"
]
}

使用React

使用了 React 项目的代码特征有 JSX 和 Class 语法,例如:

1
2
3
4
5
class Button extends Component {
render() {
return <h1>Hello,Webpack</h1>
}
}

在使用了 React 的项目里 JSX 和 Class 语法并不是必须的,但使用新语法写出的代码看上去更优雅。

其中 JSX 语法是无法在任何现有的 JavaScript 引擎中运行的,所以在构建过程中需要把源码转换成可以运行的代码,例如:

1
2
3
4
5
// 原 JSX 语法代码
return <h1>Hello,Webpack</h1>

// 被转换成正常的 JavaScript 代码
return React.createElement('h1', null, 'Hello,Webpack')

目前 Babel 和 TypeScript 都提供了对 React 语法的支持,下面分别来介绍如何在使用 Babel 或 TypeScript 的项目中接入 React 框架。

React 与 Babel

要在使用 Babel 的项目中接入 React 框架是很简单的,只需要加入 React 所依赖的 Presets babel-preset-react。 接下来通过修改前面讲过的3-1 使用 ES6 语言中的项目,为其接入 React 框架。

通过以下命令:

安装 React 基础依赖

1
npm i -D react react-dom

安装 babel 完成语法转换所需依赖

1
npm i -D babel-preset-react

安装新的依赖后,再修改 .babelrc 配置文件加入 React Presets

1
2
3
"presets": [
"react"
],

就完成了一切准备工作。

再修改 main.js 文件如下:

1
2
3
4
5
6
7
8
9
10
11
import * as React from 'react';
import { Component } from 'react';
import { render } from 'react-dom';

class Button extends Component {
render() {
return <h1>Hello,Webpack</h1>
}
}

render(<Button/>, window.document.getElementById('app'));

重新执行构建打开网页你将会发现由 React 渲染出来的 Hello,Webpack。

React 与 TypeScript

TypeScript 相比于 Babel 的优点在于它原生支持 JSX 语法,你不需要重新安装新的依赖,只需修改一行配置。 但 TypeScript 的不同在于:

  • 使用了 JSX 语法的文件后缀必须是 tsx。
  • 由于 React 不是采用 TypeScript 编写的,需要安装 react 和 react-dom 对应的 TypeScript 接口描述模块 @types/react 和 @types/react-dom 后才能通过编译。

接下来通过修改3-2 使用 TypeScript 语言中讲过的的项目,为其接入 React 框架。 修改 TypeScript 编译器配置文件 tsconfig.json 增加对 JSX 语法的支持,如下:

1
2
3
4
5
{
"compilerOptions": {
"jsx": "react" // 开启 jsx ,支持 React
}
}

由于 main.js 文件中存在 JSX 语法,再把 main.js 文件重命名为 main.tsx,同时修改文件内容为在上面 React 与 Babel 里所采用的 React 代码。 同时为了让 Webpack 对项目里的 ts 与 tsx 原文件都采用 awesome-typescript-loader 去转换, 需要注意的是 Webpack Loader 配置的 test 选项需要匹配到 tsx 类型的文件,并且 extensions 中也要加上 .tsx,配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const path = require('path');

module.exports = {
// TS 执行入口文件
entry: './main',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, './dist'),
},
resolve: {
// 先尝试 ts,tsx 后缀的 TypeScript 源码文件
extensions: ['.ts', '.tsx', '.js',]
},
module: {
rules: [
{
// 同时匹配 ts,tsx 后缀的 TypeScript 源码文件
test: /\.tsx?$/,
loader: 'awesome-typescript-loader'
}
]
},
devtool: 'source-map',// 输出 Source Map 方便在浏览器里调试 TypeScript 代码
};

通过

1
npm i react react-dom @types/react @types/react-dom

安装新的依赖后重启构建,重新打开网页你将会发现由 React 渲染出来的 Hello,Webpack

使用Vue

认识 Vue

Vue 和 React 一样,它们都推崇组件化和由数据驱动视图的思想,视图和数据绑定在一起,数据改变视图会跟着改变,而无需直接操作视图。 还是以前面的 Hello,Webpack 为例,来看下 Vue 版本的实现。

App.vue 文件代表一个单文件组件,它是项目唯一的组件,也是根组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!--渲染模版-->
<template>
<h1>{{ msg }}</h1>
</template>

<!--样式描述-->
<style scoped>
h1 {
color: red;
}
</style>

<!--组件逻辑-->
<script>
export default {
data() {
return {
msg: 'Hello,Webpack'
}
}
}
</script>

Vue 的单文件组件通过一个类似 HTML 文件的 .vue 文件就能描述清楚一个组件所需的模版、样式、逻辑。

main.js 入口文件:

1
2
3
4
5
6
7
import Vue from 'vue'
import App from './App.vue'

new Vue({
el: '#app',
render: h => h(App)
});

入口文件创建一个 Vue 的根实例,在 ID 为 app 的 DOM 节点上渲染出上面定义的 App 组件。

接入 Webpack

目前最成熟和流行的开发 Vue 项目的方式是采用 ES6 加 Babel 转换,这和基本的采用 ES6 开发的项目很相似,差别在于要解析 .vue 格式的单文件组件。 好在 Vue 官方提供了对应的 vue-loader 可以非常方便的完成单文件组件的转换。

修改 Webpack 相关配置如下:

1
2
3
4
5
6
7
8
module: {
rules: [
{
test: /\.vue$/,
use: ['vue-loader'],
},
]
}

安装新引入的依赖:

Vue 框架运行需要的库

1
npm i -S vue

构建所需的依赖

1
npm i -D vue-loader css-loader vue-template-compiler

在这些依赖中,它们的作用分别是:

  • vue-loader:解析和转换 .vue 文件,提取出其中的逻辑代码 script、样式代码 style、以及 HTML 模版 template,再分别把它们交给对应的 Loader 去处理。
  • css-loader:加载由 vue-loader 提取出的 CSS 代码。
  • vue-template-compiler:把 vue-loader 提取出的 HTML 模版编译成对应的可执行的 JavaScript 代码,这和 React 中的 JSX 语法被编译成 JavaScript 代码类似。预先编译好 HTML 模版相对于在浏览器中再去编译 HTML 模版的好处在于性能更好。

重新启动构建你就能看到由 Vue 渲染出的 Hello,Webpack 了。

使用 TypeScript 编写 Vue 应用

从 Vue 2.5.0+ 版本开始,提供了对 TypeScript 的良好支持,使用 TypeScript 编写 Vue 是一个很好的选择,因为 TypeScript 能检查出一些潜在的错误。 下面讲解如何用 Webpack 构建使用 TypeScript 编写的 Vue 应用。

新增 tsconfig.json 配置文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
{
"compilerOptions": {
// 构建出 ES5 版本的 JavaScript,与 Vue 的浏览器支持保持一致
"target": "es5",
// 开启严格模式,这可以对 `this` 上的数据属性进行更严格的推断
"strict": true,
// TypeScript 编译器输出的 JavaScript 采用 es2015 模块化,使 Tree Shaking 生效
"module": "es2015",
"moduleResolution": "node"
}
}

以上代码中的 “module”: “es2015” 是为了 Tree Shaking 优化生效,阅读 4-10 使用 TreeShaking 进一步了解。

修改 App.vue 脚本部分内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!--组件逻辑-->
<script lang="ts">
import Vue from "vue";

// 通过 Vue.extend 启用 TypeScript 类型推断
export default Vue.extend({
data() {
return {
msg: 'Hello,Webpack',
}
},
});
</script>

注意 script 标签中的 lang=”ts” 是为了指明代码的语法是 TypeScript。

修改 main.ts 执行入口文件为如下:

1
2
3
4
5
6
7
import Vue from 'vue'
import App from './App.vue'

new Vue({
el: '#app',
render: h => h(App)
});

由于 TypeScript 不认识 .vue 结尾的文件,为了让其支持 import App from ‘./App.vue’ 导入语句,还需要以下文件 vue-shims.d.ts 去定义 .vue 的类型:

1
2
3
4
5
// 告诉 TypeScript 编译器 .vue 文件其实是一个 Vue
declare module "*.vue" {
import Vue from "vue";
export default Vue;
}

Webpack 配置需要修改两个地方,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const path = require('path');

module.exports = {
resolve: {
// 增加对 TypeScript 的 .ts 和 .vue 文件的支持
extensions: ['.ts', '.js', '.vue', '.json'],
},
module: {
rules: [
// 加载 .ts 文件
{
test: /\.ts$/,
loader: 'ts-loader',
exclude: /node_modules/,
options: {
// 让 tsc 把 vue 文件当成一个 TypeScript 模块去处理,以解决 moudle not found 的问题,tsc 本身不会处理 .vue 结尾的文件
appendTsSuffixTo: [/\.vue$/],
}
},
]
},
};

除此之外还需要安装新引入的依赖:

1
npm i -D ts-loader typescript

构建同构应用

认识同构应用

现在大多数单页应用的视图都是通过 JavaScript 代码在浏览器端渲染出来的,但在浏览器端渲染的坏处有:

  • 搜索引擎无法收录你的网页,因为展示出的数据都是在浏览器端异步渲染出来的,大部分爬虫无法获取到这些数据。
  • 对于复杂的单页应用,渲染过程计算量大,对低端移动设备来说可能会有性能问题,用户能明显感知到首屏的渲染延迟。

为了解决以上问题,有人提出能否将原本只运行在浏览器中的 JavaScript 渲染代码也在服务器端运行,在服务器端渲染出带内容的 HTML 后再返回。 这样就能让搜索引擎爬虫直接抓取到带数据的 HTML,同时也能降低首屏渲染时间。 由于 Node.js 的流行和成熟,以及虚拟 DOM 提出与实现,使这个假设成为可能。

实际上现在主流的前端框架都支持同构,包括 React、Vue2、Angular2,其中最先支持也是最成熟的同构方案是 React。 由于 React 使用者更多,它们之间又很相似,本节只介绍如何用 Webpack 构建 React 同构应用。

同构应用运行原理的核心在于虚拟 DOM,虚拟 DOM 的意思是不直接操作 DOM 而是通过 JavaScript Object 去描述原本的 DOM 结构。 在需要更新 DOM 时不直接操作 DOM 树,而是通过更新 JavaScript Object 后再映射成 DOM 操作。

虚拟 DOM 的优点在于:

  • 因为操作 DOM 树是高耗时的操作,尽量减少 DOM 树操作能优化网页性能。而 DOM Diff 算法能找出2个不同 Object 的最小差异,得出最小 DOM 操作;
  • 虚拟 DOM 的在渲染的时候不仅仅可以通过操作 DOM 树来表示出结果,也能有其它的表示方式,例如把虚拟 DOM 渲染成字符串(服务器端渲染),或者渲染成手机 App 原生的 UI 组件( React Native)。

以 React 为例,核心模块 react 负责管理 React 组件的生命周期,而具体的渲染工作可以交给 react-dom 模块来负责。

react-dom 在渲染虚拟 DOM 树时有2中方式可选:

  • 通过 render() 函数去操作浏览器 DOM 树来展示出结果。
  • 通过 renderToString() 计算出表示虚拟 DOM 的 HTML 形式的字符串。

构建同构应用的最终目的是从一份项目源码中构建出2份 JavaScript 代码,一份用于在浏览器端运行,一份用于在 Node.js 环境中运行渲染出 HTML。 其中用于在 Node.js 环境中运行的 JavaScript 代码需要注意以下几点:

  • 不能包含浏览器环境提供的 API,例如使用 document 进行 DOM 操作,  因为 Node.js 不支持这些 API;
  • 不能包含 CSS 代码,因为服务端渲染的目的是渲染出 HTML 内容,渲染出 CSS 代码会增加额外的计算量,影响服务端渲染性能;
  • 不能像用于浏览器环境的输出代码那样把 node_modules 里的第三方模块和 Node.js 原生模块(例如 fs 模块)打包进去,而是需要通过 CommonJS 规范去引入这些模块。
  • 需要通过 CommonJS 规范导出一个渲染函数,以用于在 HTTP 服务器中去执行这个渲染函数,渲染出 HTML 内容返回。

解决方案

接下来改造在3-6使用 React 框架中介绍的 React 项目,为它增加构建同构应用的功能。

由于要从一份源码构建出2份不同的代码,需要有2份 Webpack 配置文件分别与之对应。 构建用于浏览器环境的配置和前面讲的没有差别,本节侧重于讲如何构建用于服务端渲染的代码。

用于构建浏览器环境代码的 webpack.config.js 配置文件保留不变,新建一个专门用于构建服务端渲染代码的配置文件 webpack_server.config.js,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const path = require('path');
const nodeExternals = require('webpack-node-externals');

module.exports = {
// JS 执行入口文件
entry: './main_server.js',
// 为了不把 Node.js 内置的模块打包进输出文件中,例如 fs net 模块等
target: 'node',
// 为了不把 node_modules 目录下的第三方模块打包进输出文件中
externals: [nodeExternals()],
output: {
// 为了以 CommonJS2 规范导出渲染函数,以给采用 Node.js 编写的 HTTP 服务调用
libraryTarget: 'commonjs2',
// 把最终可在 Node.js 中运行的代码输出到一个 bundle_server.js 文件
filename: 'bundle_server.js',
// 输出文件都放到 dist 目录下
path: path.resolve(__dirname, './dist'),
},
module: {
rules: [
{
test: /\.js$/,
use: ['babel-loader'],
exclude: path.resolve(__dirname, 'node_modules'),
},
{
// CSS 代码不能被打包进用于服务端的代码中去,忽略掉 CSS 文件
test: /\.css$/,
use: ['ignore-loader'],
},
]
},
devtool: 'source-map' // 输出 source-map 方便直接调试 ES6 源码
};

以上代码有几个关键的地方,分别是:

  • target: 'node' 由于输出代码的运行环境是 Node.js,源码中依赖的 Node.js 原生模块没必要打包进去;
  • externals: [nodeExternals()] webpack-node-externals 的目的是为了防止 node_modules 目录下的第三方模块被打包进去,因为 Node.js 默认会去 node_modules 目录下寻找和使用第三方模块;
  • {test: /\.css$/, use: ['ignore-loader']} 忽略掉依赖的 CSS 文件,CSS 会影响服务端渲染性能,又是做服务端渲不重要的部分;
  • libraryTarget: 'commonjs2' 以 CommonJS2 规范导出渲染函数,以供给采用 Node.js 编写的 HTTP 服务器代码调用。

为了最大限度的复用代码,需要调整下目录结构:

把页面的根组件放到一个单独的文件 AppComponent.js,该文件只能包含根组件的代码,不能包含渲染入口的代码,而且需要导出根组件以供给渲染入口调用,AppComponent.js 内容如下:

1
2
3
4
5
6
7
8
import React, { Component } from 'react';
import './main.css';

export class AppComponent extends Component {
render() {
return <h1>Hello,Webpack</h1>
}
}

分别为不同环境的渲染入口写两份不同的文件,分别是用于浏览器端渲染 DOM 的 main_browser.js 文件,和用于服务端渲染 HTML 字符串的 main_server.js 文件。

main_browser.js 文件内容如下:

1
2
3
4
5
6
import React from 'react';
import { render } from 'react-dom';
import { AppComponent } from './AppComponent';

// 把根组件渲染到 DOM 树上
render(<AppComponent/>, window.document.getElementById('app'));

main_server.js 文件内容如下:

1
2
3
4
5
6
7
8
9
import React from 'react';
import { renderToString } from 'react-dom/server';
import { AppComponent } from './AppComponent';

// 导出渲染函数,以给采用 Node.js 编写的 HTTP 服务器代码调用
export function render() {
// 把根组件渲染成 HTML 字符串
return renderToString(<AppComponent/>)
}

为了能把渲染的完整 HTML 文件通过 HTTP 服务返回给请求端,还需要通过用 Node.js 编写一个 HTTP 服务器。 由于本节不专注于将 HTTP 服务器的实现,就采用了 ExpressJS 来实现,http_server.js 文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const express = require('express');
const { render } = require('./dist/bundle_server');
const app = express();

// 调用构建出的 bundle_server.js 中暴露出的渲染函数,再拼接下 HTML 模版,形成完整的 HTML 文件
app.get('/', function (req, res) {
res.send(`
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
<div id="app">${render()}</div>
<!--导入 Webpack 输出的用于浏览器端渲染的 JS 文件-->
<script src="./dist/bundle_browser.js"></script>
</body>
</html>
`);
});

// 其它请求路径返回对应的本地文件
app.use(express.static('.'));

app.listen(3000, function () {
console.log('app listening on port 3000!')
});

再安装新引入的第三方依赖:

安装 Webpack 构建依赖

1
npm i -D css-loader style-loader ignore-loader webpack-node-externals

安装 HTTP 服务器依赖

1
npm i -S express

以上所有准备工作已经完成,接下来执行构建,编译出目标文件:

执行命令 webpack --config webpack_server.config.js 构建出用于服务端渲染的 ./dist/bundle_server.js 文件。
执行命令 webpack 构建出用于浏览器环境运行的 ./dist/bundle_browser.js 文件,默认的配置文件为 webpack.config.js。
构建执行完成后,执行 node ./http_server.js 启动 HTTP 服务器后,再用浏览器去访问 http://localhost:3000 就能看到 Hello,Webpack 了。 但是为了验证服务端渲染的结果,你需要打开浏览器的开发工具中的网络抓包一栏,再重新刷新浏览器后,就能抓到请求 HTML 的包了,抓包效果图如下:

可以看到服务器返回的是渲染出内容后的 HTML 而不是 HTML 模版,这说明同构应用的改造完成。

检查代码

当项目代码变得日益庞大复杂时,如何保障代码质量?如何保障多人协助开发时代码的可读性?

完全解决以上问题不是一个简单的事,但做检查代码能解决大部分问题。本节将教你如何结合构建做代码检查。

代码检查具体是做什么

检查代码和 Code Review 很相似,都是去审视提交的代码可能存在的问题。 但 Code Review 一般通过人去执行,而检查代码是通过机器去执行一些自动化的检查。 自动化的检查代码成本更低,实施代价更小。

检查代码主要检查以下几项:

  • 代码风格:让项目成员强制遵守统一的代码风格,例如如何缩进、如何写注释等,保障代码可读性,不把时间浪费在争论如何写代码更好看上;
  • 潜在问题:分析出代码在运行过程中可能出现的潜在 Bug。

其中检查代码风格相关的工具很多也很成熟,分析潜在问题的检查由于情况复杂目前还没有成熟的工具。

目前已经有成熟的工具可以检验诸如 JavaScript、TypeScript、CSS、SCSS 等常用语言。

怎么做代码检查

在做代码风格检查时需要按照不同的文件类型来检查,下面来分别介绍。

检查 JavaScript

目前最常用的 JavaScript 检查工具是 ESlint ,它不仅内置了大量常用的检查规则,还可以通过插件机制做到灵活扩展。

ESlint 的使用很简单,在通过

1
npm i -g eslint

按照到全局后,再在项目目录下执行

1
eslint --init

来新建一个 ESlint 配置文件 .eslintrc,该文件格式为 JSON。

如果你想覆盖默认的检查规则,或者想加入新的检查规则,你需要修改该文件,例如使用以下配置:

1
2
3
4
5
6
7
8
9
10
11
{
// 从 eslint:recommended 中继承所有检查规则
"extends": "eslint:recommended",
// 再自定义一些规则
"rules": {
// 需要在每行结尾加 ;
"semi": ["error", "always"],
// 需要使用 "" 包裹字符串
"quotes": ["error", "double"]
}
}

写好配置文件后,再执行

1
eslint yourfile.js

去检查 yourfile.js 文件,如果你的文件没有通过检查,ESlint 会输出错误原因,例如:

1
2
3
4
5
/yourfile.js
296:13 error Strings must use doublequote quotes
298:7 error Missing semicolon semi

✖ 2 problems (2 errors, 0 warnings)

ESlint 还有很多功能和检查规则,由于篇幅有限这里就不详细介绍,可以去其官网阅读文档。

检查 TypeScript

TSLint 是一个和 ESlint 相似的 TypeScript 代码检查工具,区别在于 TSLint 只专注于检查 TypeScript 代码。

TSLint 和 ESlint 的使用方法很相似,首先通过

1
npm i -g tslint

按照到全局,再去项目根目录下执行

1
tslint --init

生成配置文件 tslint.json,在配置好后,再执行

1
tslint yourfile.ts

去检查 yourfile.ts 文件。

检查 CSS

stylelint 是目前最成熟的 CSS 检查工具,内置了大量检查规则的同时也提供插件机制让用户自定义扩展。 stylelint 基于 PostCSS,能检查任何 PostCSS 能解析的代码,诸如 SCSS、Less 等。

首先通过

1
npm i -g stylelint

按照到全局后,去项目根目录下新建 .stylelintrc 配置文件, 该配置文件格式为 JSON,其格式和 ESLint 的配置相似,例如:

1
2
3
4
5
6
7
8
{
// 继承 stylelint-config-standard 中的所有检查规则
"extends": "stylelint-config-standard",
// 再自定义检查规则
"rules": {
"at-rule-empty-line-before": null
}
}

配置好后,再执行

1
stylelint "yourfile.css"

去检查 yourfile.css 文件。

stylelint 还有很多功能和配置项没有介绍到,可以访问其官方进一步了解。

目前很多编辑器,例如 Webstorm、VSCode 等,已经集成了以上介绍过的检查工具,编辑器会实时地把检查工具输出的错误显示编辑的源码上。 通过编辑器集成后,你不用通过命令行的方式去定位错误。

结合 Webpack 检查代码

以上介绍的代码检查工具可以和 Webpack 结合起来,在开发过程中通过 Webpack 输出实时的检查结果。

结合 ESLint

eslint-loader 可以方便的把 ESLint 整合到 Webpack 中,使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
module.exports = {
module: {
rules: [
{
test: /\.js$/,
// node_modules 目录的下的代码不用检查
exclude: /node_modules/,
loader: 'eslint-loader',
// 把 eslint-loader 的执行顺序放到最前面,防止其它 Loader 把处理后的代码交给 eslint-loader 去检查
enforce: 'pre',
},
],
},
}

接入 eslint-loader 后就能在控制台中看到 ESLint 输出的错误日志了。

结合 TSLint

tslint-loader 是一个和 eslint-loader 相似的 Webpack Loader, 能方便的把 TSLint 整合到 Webpack,其使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
module.exports = {
module: {
rules: [
{
test: /\.ts$/,
// node_modules 目录的下的代码不用检查
exclude: /node_modules/,
loader: 'tslint-loader',
// 把 tslint-loader 的执行顺序放到最前面,防止其它 Loader 把处理后的代码交给 tslint-loader 去检查
enforce: 'pre',
},
],
},
}

结合 stylelint

StyleLintPlugin 能把 stylelint 整合到 Webpack,其使用方法很简单,如下:

1
2
3
4
5
6
7
8
const StyleLintPlugin = require('stylelint-webpack-plugin');

module.exports = {
// ...
plugins: [
new StyleLintPlugin(),
],
}

一些建议
把代码检查功能整合到 Webpack 中会导致以下问题:

  • 由于执行检查步骤计算量大,整合到 Webpack 中会导致构建变慢;
  • 在整合代码检查到 Webpack 后,输出的错误信息是通过行号来定位错误的,没有编辑器集成显示错误直观;

为了避免以上问题,还可以这样做:

  • 使用集成了代码检查功能的编辑器,让编辑器实时直观地显示错误;
  • 把代码检查步骤放到代码提交时,也就是说在代码提交前去调用以上检查工具去检查代码,只有在检查都通过时才提交代码,这样就能保证提交到仓库的代码都是通过了检查的。
  • 如果你的项目是使用 Git 管理,Git 提供了 Hook 功能能做到在提交代码前触发执行脚本。

husky 可以方便快速地为项目接入 Git Hook, 执行

1
npm i -D husky

安装 husky 时,husky 会通过 Npm Script Hook 自动配置好 Git Hook,你需要做的只是在 package.json 文件中定义几个脚本,方法如下:

1
2
3
4
5
6
7
8
9
10
{
"scripts": {
// 在执行 git commit 前会执行的脚本
"precommit": "npm run lint",
// 在执行 git push 前会执行的脚本
"prepush": "lint",
// 调用 eslint、stylelint 等工具检查代码
"lint": "eslint && stylelint"
}
}

precommit 和 prepush 你需要根据自己的情况选择一个,无需两个都设置。

加载图片和其他静态资源

在网页中不可避免的会依赖图片资源,例如 PNG、JPG、GIF,下面来教你如何用 Webpack 加载图片资源。

使用 file-loader

file-loader 可以把 JavaScript 和 CSS 中导入图片的语句替换成正确的地址,并同时把文件输出到对应的位置。

例如 CSS 源码是这样写的:

1
2
3
#app {
background-image: url(./imgs/a.png);
}

被 file-loader 转换后输出的 CSS 会变成这样:

1
2
3
#app {
background-image: url(5556e1251a78c5afda9ee7dd06ad109b.png);
}

并且在输出目录 dist 中也多出 ./imgs/a.png 对应的图片文件 5556e1251a78c5afda9ee7dd06ad109b.png, 输出的文件名是根据文件内容的计算出的 Hash 值。

同理在 JavaScript 中导入图片的源码如下:

1
2
3
4
import imgB from './imgs/b.png';

window.document.getElementById('app').innerHTML = `
<img src="${imgB}"/>

经过 file-loader 处理后输出的 JavaScript 代码如下:

1
module.exports = __webpack_require__.p + "0bcc1f8d385f78e1271ebfca50668429.png";

也就是说 imgB 的值就是图片对应的 URL 地址。

在 Webpack 中使用 file-loader 非常简单,相关配置如下:

1
2
3
4
5
6
7
8
9
10
module.exports = {
module: {
rules: [
{
test: /\.png$/,
use: ['file-loader']
}
]
}
};

使用 url-loader

url-loader 可以把文件的内容经过 base64 编码后注入到 JavaScript 或者 CSS 中去。

例如 CSS 源码是这样写的:

1
2
3
#app {
background-image: url(./imgs/a.png);
}

被 url-loader 转换后输出的 CSS 会变成这样:

1
2
3
#app {
background-image: url(data:image/png;base64,iVBORw01afer...); /* 结尾省略了剩下的 base64 编码后的数据 */
}

同理在 JavaScript 中效果也类似。

从上面的例子中可以看出 url-loader 会把根据图片内容计算出的 base64 编码的字符串直接注入到代码中,由于一般的图片数据量巨大, 这会导致 JavaScript、CSS 文件也跟着变大。 所以在使用 url-loader 时一定要注意图片体积不能太大,不然会导致 JavaScript、CSS 文件过大而带来的网页加载缓慢问题。

一般利用 url-loader 把网页需要用到的小图片资源注入到代码中去,以减少加载次数。因为在 HTTP/1 协议中,每加载一个资源都需要建立一次 HTTP 链接, 为了一个很小的图片而新建一次 HTTP 连接是不划算的。

url-loader 考虑到了以上问题,并提供了一个方便的选择 limit,该选项用于控制当文件大小小于 limit 时才使用 url-loader,否则使用 fallback 选项中配置的 loader。 相关 Webpack 配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
module.exports = {
module: {
rules: [
{
test: /\.png$/,
use: [{
loader: 'url-loader',
options: {
// 30KB 以下的文件采用 url-loader
limit: 1024 * 30,
// 否则采用 file-loader,默认值就是 file-loader
fallback: 'file-loader',
}
}]
}
]
},
};

webpack打包后的图片无法显示问题

之前做react项目的时候遇到过这种问题,项目打包后页面图片加载有问题,打开控制台看到类似这样的错误

webpack要将图片进行打包,需要安装儒url-loader加载器,加载器有个默认的设置选项limit:8196,当你的图片大小不超过8kb的时候,打包的时候会生成base64位的图片地址,这种情况下背景图片可以正常显示,当你图片大小超过limit设置的限制时,它会生成一个静态资源图片

打包后的图片一般会存放在dist文件夹下,但是由于某种原因(暂时不知道什么原因),打包后的代码引用的资源地址依然是没有打包之前的那个地址,那自然就会报错。

解决方案1:将limit这个配置变大或者注释掉,都以base64位的地址显示图片
解决方案2:
在webpack.config.js中添加配置:

为单页应用生成HTML

截止到目前,我们的hello,webpack例子在构建时会输出一个bundle.js文件,而要想访问页面必须首先更改html文件当中script文件的引入路径,然后打开这个html文件

不仅如此,实际的项目其实远比我们这个例子复杂得多,例如:

  • 项目采用 ES6 语言加 React 框架。
  • 给页面加入 Google Analytics,这部分代码需要内嵌进 HEAD 标签里去。
  • 给页面加入 Disqus 用户评论,这部分代码需要异步加载以提升首屏加载速度。
  • 压缩和分离 JavaScript 和 CSS 代码,提升加载速度。

在开始前先来看看该应用最终发布到线上的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<html>
<head>
<meta charset="UTF-8">
<!--注入 Chunk app 依赖的 CSS-->
<style rel="stylesheet">h1{color:red}</style>
<!--内嵌 google_analytics 中的 JavaScript 代码-->
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-XXXXX-Y', 'auto');
ga('send', 'pageview');
</script>
<!--异步加载 Disqus 评论-->
<script async="" src="https://dive-into-webpack.disqus.com/embed.js"></script>
</head>
<body>
<div id="app"></div>
<!--导入 app 依赖的 JS-->
<script src="app_746f32b2.js"></script>
<!--Disqus 评论容器-->
<div id="disqus_thread"></div>
</body>
</html>

HTML 应该是被压缩过的,这里为了方便大家阅读而格式化了 HTML,并且加入了注释。

构建出的目录结构为:

dist
├── app_792b446e.js
└── index.html

可以看到部分代码被内嵌进了 HTML 的 HEAD 标签中,部分文件的文件名称被打上根据文件内容算出的 Hash 值,并且加载这些文件的 URL 地址也被正常的注入到了 HTML 中。 如果你还采用手写 index.html 文件去完成以上要求,这就会使工作变得复杂、易错,项目难以维护。 本节教你如何自动化的生成这个符合要求的 index.html。

解决方案

推荐一个用于方便的解决以上问题的 Webpack 插件 web-webpack-plugin。 该插件已经被社区上许多人使用和验证,解决了大家的痛点获得了很多好评,下面具体介绍如何用它来解决上面的问题。

首先,修改 Webpack 配置为如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
const path = require('path');
const UglifyJsPlugin = require('webpack/lib/optimize/UglifyJsPlugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const DefinePlugin = require('webpack/lib/DefinePlugin');
const { WebPlugin } = require('web-webpack-plugin');

module.exports = {
// 此时的entry必须写成这样的对象形式
entry: {
main: './main.js'// app 的 JavaScript 执行入口文件
},
output: {
filename: '[name]_[chunkhash:8].js',// 给输出的文件名称加上 Hash 值
path: path.resolve(__dirname, './dist'),
},
module: {
rules: [
{
test: /\.js$/,
use: ['babel-loader'],
// 排除 node_modules 目录下的文件,
// 该目录下的文件都是采用的 ES5 语法,没必要再通过 Babel 去转换
exclude: path.resolve(__dirname, 'node_modules'),
},
{
test: /\.css$/,// 增加对 CSS 文件的支持
// 提取出 Chunk 中的 CSS 代码到单独的文件中
use: ExtractTextPlugin.extract({
use: ['css-loader?minimize'] // 压缩 CSS 代码
}),
},
]
},
plugins: [
// 使用本文的主角 WebPlugin,一个 WebPlugin 对应一个 HTML 文件
new WebPlugin({
template: './template.html', // HTML 模版文件所在的文件路径
filename: 'index.html' // 输出的 HTML 的文件名称
}),
new ExtractTextPlugin({
filename: `[name]_[contenthash:8].css`,// 给输出的 CSS 文件名称加上 Hash 值
}),
new DefinePlugin({
// 定义 NODE_ENV 环境变量为 production,以去除源码中只有开发时才需要的部分
'process.env': {
NODE_ENV: JSON.stringify('production')
}
}),
// 压缩输出的 JavaScript 代码
new UglifyJsPlugin({
// 最紧凑的输出
beautify: false,
// 删除所有的注释
comments: false,
compress: {
// 在UglifyJs删除没有用到的代码时不输出警告
warnings: false,
// 删除所有的 `console` 语句,可以兼容ie浏览器
drop_console: true,
// 内嵌定义了但是只用到一次的变量
collapse_vars: true,
// 提取出出现多次但是没有定义成变量去引用的静态值
reduce_vars: true,
}
}),
],
};

以上配置中,大多数都是按照前面已经讲过的内容增加的配置,例如:

  • 增加对 CSS 文件的支持,提取出 Chunk 中的 CSS 代码到单独的文件中,压缩 CSS 文件;
  • 定义 NODE_ENV 环境变量为 production,以去除源码中只有开发时才需要的部分;
  • 给输出的文件名称加上 Hash 值;
  • 压缩输出的 JavaScript 代码。

但最核心的部分在于 plugins 里的:

1
2
3
4
new WebPlugin({
template: './template.html', // HTML 模版文件所在的文件路径
filename: 'index.html' // 输出的 HTML 的文件名称
})

其中 template: ‘./template.html’ 所指的模版文件 template.html 的内容是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<html>
<head>
<meta charset="UTF-8">
<!--注入 Chunk app 中的 CSS-->
<link rel="stylesheet" href="main?_inline">
<!--注入 google_analytics 中的 JavaScript 代码-->
<script src="./google_analytics.js?_inline"></script>
<!--异步加载 Disqus 评论-->
<script src="https://dive-into-webpack.disqus.com/embed.js" async></script>
</head>
<body>
<div id="app"></div>
<!--导入 Chunk app 中的 JS-->
<script src="main"></script>
<!--Disqus 评论容器-->
<div id="disqus_thread"></div>
</body>
</html>

该文件描述了哪些资源需要被以何种方式加入到输出的 HTML 文件中。

<link rel="stylesheet" href="app?_inline"> 为例,按照正常引入 CSS 文件一样的语法来引入 Webpack 生产的代码。 href 属性中的 app?_inline 可以分为两部分,前面的 app 表示 CSS 代码来自名叫 app 的 Chunk 中,后面的 _inline 表示这些代码需要被内嵌到这个标签所在的位置。

同样的 <script src="./google_analytics.js?_inline"></script> 表示 JavaScript 代码来自相对于当前模版文件 template.html 的本地文件 ./google_analytics.js, 而且文件中的 JavaScript 代码也需要被内嵌到这个标签所在的位置。

也就是说资源链接 URL 字符串里问号前面的部分表示资源内容来自哪里,后面的 querystring 表示这些资源注入的方式。

除了 _inline 表示内嵌外,还支持以下属性:

  • _dist 只有在生产环境下才引入该资源
  • _dev 只有在开发环境下才引入该资源
  • _ie 只有IE浏览器才需要引入的资源,通过 [if IE]>resource<![endif] 注释实现
    这些属性之间可以搭配使用,互不冲突。例如 app?_inline&_dist 表示只在生产环境下才引入该资源,并且需要内嵌到 HTML 里去。

WebPlugin 插件还支持一些其它更高级的用法,详情可以访问该项目主页阅读文档。

  • Copyright: Copyright is owned by the author. For commercial reprints, please contact the author for authorization. For non-commercial reprints, please indicate the source.

扫一扫,分享到微信

微信分享二维码
  • Copyrights © 2015-2021 AURORA_ZXH
  • Visitors: | Views:

请我喝杯咖啡吧~

支付宝
微信