上手先搞一个简单的 SPA 应用

一上来步子太大容易扯到蛋,让我们先弄个最简单的 webpack 配置来热一下身。

安装 Node.js

webpack 是基于我大 Node.js 的打包工具,上来第一件事自然是先安装 Node.js 了,传送门 ->

初始化一个项目

我们先随便找个地方,建一个文件夹叫 simple, 然后在这里面搭项目。完成品在 examples/simple 目录,大家搞的时候可以参照一下。我们先看一下目录结构:

  1. ├── dist 打包输出目录,只需部署这个目录到生产环境
  2. ├── package.json 项目配置信息
  3. ├── node_modules npm 安装的依赖包都在这里面
  4. ├── src 我们的源代码
  5. ├── components 可以复用的模块放在这里面
  6. ├── index.html 入口 html
  7. ├── index.js 入口 js
  8. ├── shared 公共函数库
  9. └── views 页面放这里
  10. └── webpack.config.js webpack 配置文件

打开命令行窗口,cd 到刚才建的 simple 目录。然后执行这个命令初始化项目:

  1. npm init

命令行会要你输入一些配置信息,我们这里一路按回车下去,生成一个默认的项目配置文件 package.json

给项目加上语法报错和代码规范检查

我们安装 eslint, 用来检查语法报错,当我们书写 js 时,有错误的地方会出现提示。

  1. npm install eslint eslint-config-enough babel-eslint eslint-loader --save-dev

npm install 可以一条命令同时安装多个包,包之间用空格分隔。包会被安装进 node_modules 目录中。

--save-dev 会把安装的包和版本号记录到 package.json 中的 devDependencies 对象中,还有一个 --save, 会记录到 dependencies 对象中,它们的区别,我们可以先简单的理解为打包工具和测试工具用到的包使用 --save-dev 存到 devDependencies, 比如 eslint、webpack。浏览器中执行的 js 用到的包存到 dependencies, 比如 jQuery 等。那么它们用来干嘛的?

因为有些 npm 包安装是需要编译的,那么导致 windows / mac /linux 上编译出的可执行文件是不同的,也就是无法通用,因此我们在提交代码到 git 上去的时候,一般都会在 .gitignore 里指定忽略 node_modules 目录和里面的文件,这样其他人从 git 上拉下来的项目是没有 node_modules 目录的,这时我们需要运行

  1. npm install

它会读取 package.json 中的 devDependenciesdependencies 字段,把记录的包的相应版本下载下来。

这里 eslint-config-enough 是配置文件,它规定了代码规范,要使它生效,我们要在 package.json 中添加内容:

  1. {
  2. "eslintConfig": {
  3. "extends": "enough",
  4. "env": {
  5. "browser": true,
  6. "node": true
  7. }
  8. }
  9. }

业界最有名的语法规范是 airbnb 出品的,但它规定的太死板了,比如不允许使用 for-offor-in 等。感兴趣的同学可以参照 这里 安装使用。

babel-eslinteslint-config-enough 依赖的语法解析库,替代 eslint 默认的解析库以支持还未标准化的语法。比如 import()

eslint-loader 用于在 webpack 编译的时候检查代码,如果有错误,webpack 会报错。

项目里安装了 eslint 还没用,我们的 IDE 和编辑器也得要装 eslint 插件支持它。

Visual Studio Code 需要安装 ESLint 扩展

atom 需要安装 linterlinter-eslint 这两个插件,装好后重启生效。

WebStorm 需要在设置中打开 eslint 开关:

WebStorm ESLint Config

写几个页面

我们写一个最简单的 SPA 应用来介绍 SPA 应用的内部工作原理。首先,建立 src/index.html 文件,内容如下:

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <meta charset="utf-8">
  5. </head>
  6. <body>
  7. </body>
  8. </html>

它是一个空白页面,注意这里我们不需要自己写 <script src="index.js"></script>, 因为打包后的文件名和路径可能会变,所以我们用 webpack 插件帮我们自动加上。

src/index.js:

  1. // 引入 router
  2. import router from './router'
  3. // 启动 router
  4. router.start()

src/router.js:

  1. // 引入页面文件
  2. import foo from './views/foo'
  3. import bar from './views/bar'
  4. const routes = {
  5. '/foo': foo,
  6. '/bar': bar
  7. }
  8. // Router 类,用来控制页面根据当前 URL 切换
  9. class Router {
  10. start() {
  11. // 点击浏览器后退 / 前进按钮时会触发 window.onpopstate 事件,我们在这时切换到相应页面
  12. // https://developer.mozilla.org/en-US/docs/Web/Events/popstate
  13. window.addEventListener('popstate', () => {
  14. this.load(location.pathname)
  15. })
  16. // 打开页面时加载当前页面
  17. this.load(location.pathname)
  18. }
  19. // 前往 path,变更地址栏 URL,并加载相应页面
  20. go(path) {
  21. // 变更地址栏 URL
  22. history.pushState({}, '', path)
  23. // 加载页面
  24. this.load(path)
  25. }
  26. // 加载 path 路径的页面
  27. load(path) {
  28. // 首页
  29. if (path === '/') path = '/foo'
  30. // 创建页面实例
  31. const view = new routes[path]()
  32. // 调用页面方法,把页面加载到 document.body 中
  33. view.mount(document.body)
  34. }
  35. }
  36. // 导出 router 实例
  37. export default new Router()

src/views/foo/index.js:

  1. // 引入 router
  2. import router from '../../router'
  3. // 引入 html 模板,会被作为字符串引入
  4. import template from './index.html'
  5. // 引入 css, 会生成 <style> 块插入到 <head> 头中
  6. import './style.css'
  7. // 导出类
  8. export default class {
  9. mount(container) {
  10. document.title = 'foo'
  11. container.innerHTML = template
  12. container.querySelector('.foo__gobar').addEventListener('click', () => {
  13. // 调用 router.go 方法加载 /bar 页面
  14. router.go('/bar')
  15. })
  16. }
  17. }

src/views/bar/index.js:

  1. // 引入 router
  2. import router from '../../router'
  3. // 引入 html 模板,会被作为字符串引入
  4. import template from './index.html'
  5. // 引入 css, 会生成 <style> 块插入到 <head> 头中
  6. import './style.css'
  7. // 导出类
  8. export default class {
  9. mount(container) {
  10. document.title = 'bar'
  11. container.innerHTML = template
  12. container.querySelector('.bar__gofoo').addEventListener('click', () => {
  13. // 调用 router.go 方法加载 /foo 页面
  14. router.go('/foo')
  15. })
  16. }
  17. }

借助 webpack 插件,我们可以 import html, css 等其他格式的文件,文本类的文件会被储存为变量打包进 js 文件,其他二进制类的文件,比如图片,可以自己配置,小图片作为 Data URI 打包进 js 文件,大文件打包为单独文件,我们稍后再讲这块。

其他的 src 目录下的文件大家自己浏览,拷贝一份到自己的工作目录,等会打包时会用到。

页面代码这样就差不多搞定了,接下来我们进入 webpack 的安装和配置阶段。现在我们还没有讲 webpack 配置所以页面还无法访问,等会弄好 webpack 配置后再看页面实际效果。

安装 webpack 和 Babel

我们把 webpack 和它的插件安装到项目:

  1. npm install webpack webpack-cli webpack-serve html-webpack-plugin html-loader css-loader style-loader file-loader url-loader --save-dev

webpack 即 webpack 核心库。它提供了很多 API, 通过 Node.js 脚本中 require('webpack') 的方式来使用 webpack。

webpack-cli 是 webpack 的命令行工具。让我们可以不用写打包脚本,只需配置打包配置文件,然后在命令行输入 webpack-cli --config webpack.config.js 来使用 webpack, 简单很多。webpack 4 之前命令行工具是集成在 webpack 包中的,4.0 开始 webpack 包本身不再集成 cli。

webpack-serve 是 webpack 提供的用来开发调试的服务器,让你可以用 http://127.0.0.1:8080/ 这样的 url 打开页面来调试,有了它就不用配置 nginx 了,方便很多。

html-webpack-plugin, html-loader, css-loader, style-loader 等看名字就知道是打包 html 文件,css 文件的插件,大家在这里可能会有疑问,html-webpack-pluginhtml-loader 有什么区别,css-loaderstyle-loader 有什么区别,我们等会看配置文件的时候再讲。

file-loaderurl-loader 是打包二进制文件的插件,具体也在配置文件章节讲解。

接下来,为了能让不支持 ES6 的浏览器 (比如 IE) 也能照常运行,我们需要安装 babel, 它会把我们写的 ES6 源代码转化成 ES5,这样我们源代码写 ES6,打包时生成 ES5。

  1. npm install babel-core babel-preset-env babel-loader --save-dev

这里 babel-core 顾名思义是 babel 的核心编译器。babel-preset-env 是一个配置文件,我们可以使用这个配置文件转换 ES2015/ES2016/ES2017 到 ES5,是的,不只 ES6 哦。babel 还有 其他配置文件

光安装了 babel-preset-env,在打包时是不会生效的,需要在 package.json 加入 babel 配置:

  1. {
  2. "babel": {
  3. "presets": ["env"]
  4. }
  5. }

打包时 babel 会读取 package.jsonbabel 字段的内容,然后执行相应的转换。

babel-loader 是 webpack 的插件,我们下面章节再说。

配置 webpack

包都装好了,接下来总算可以进入正题了。我们来创建 webpack 配置文件 webpack.config.js,注意这个文件是在 node.js 中运行的,因此不支持 ES6 的 import 语法。我们来看文件内容:

  1. const { resolve } = require('path')
  2. const HtmlWebpackPlugin = require('html-webpack-plugin')
  3. const history = require('connect-history-api-fallback')
  4. const convert = require('koa-connect')
  5. // 使用 WEBPACK_SERVE 环境变量检测当前是否是在 webpack-server 启动的开发环境中
  6. const dev = Boolean(process.env.WEBPACK_SERVE)
  7. module.exports = {
  8. /*
  9. webpack 执行模式
  10. development:开发环境,它会在配置文件中插入调试相关的选项,比如 moduleId 使用文件路径方便调试
  11. production:生产环境,webpack 会将代码做压缩等优化
  12. */
  13. mode: dev ? 'development' : 'production',
  14. /*
  15. 配置 source map
  16. 开发模式下使用 cheap-module-eval-source-map, 生成的 source map 能和源码每行对应,方便打断点调试
  17. 生产模式下使用 hidden-source-map, 生成独立的 source map 文件,并且不在 js 文件中插入 source map 路径,用于在 error report 工具中查看 (比如 Sentry)
  18. */
  19. devtool: dev ? 'cheap-module-eval-source-map' : 'hidden-source-map',
  20. // 配置页面入口 js 文件
  21. entry: './src/index.js',
  22. // 配置打包输出相关
  23. output: {
  24. // 打包输出目录
  25. path: resolve(__dirname, 'dist'),
  26. // 入口 js 的打包输出文件名
  27. filename: 'index.js'
  28. },
  29. module: {
  30. /*
  31. 配置各种类型文件的加载器,称之为 loader
  32. webpack 当遇到 import ... 时,会调用这里配置的 loader 对引用的文件进行编译
  33. */
  34. rules: [
  35. {
  36. /*
  37. 使用 babel 编译 ES6 / ES7 / ES8 为 ES5 代码
  38. 使用正则表达式匹配后缀名为 .js 的文件
  39. */
  40. test: /\.js$/,
  41. // 排除 node_modules 目录下的文件,npm 安装的包不需要编译
  42. exclude: /node_modules/,
  43. /*
  44. use 指定该文件的 loader, 值可以是字符串或者数组。
  45. 这里先使用 eslint-loader 处理,返回的结果交给 babel-loader 处理。loader 的处理顺序是从最后一个到第一个。
  46. eslint-loader 用来检查代码,如果有错误,编译的时候会报错。
  47. babel-loader 用来编译 js 文件。
  48. */
  49. use: ['babel-loader', 'eslint-loader']
  50. },
  51. {
  52. // 匹配 html 文件
  53. test: /\.html$/,
  54. /*
  55. 使用 html-loader, 将 html 内容存为 js 字符串,比如当遇到
  56. import htmlString from './template.html';
  57. template.html 的文件内容会被转成一个 js 字符串,合并到 js 文件里。
  58. */
  59. use: 'html-loader'
  60. },
  61. {
  62. // 匹配 css 文件
  63. test: /\.css$/,
  64. /*
  65. 先使用 css-loader 处理,返回的结果交给 style-loader 处理。
  66. css-loader 将 css 内容存为 js 字符串,并且会把 background, @font-face 等引用的图片,
  67. 字体文件交给指定的 loader 打包,类似上面的 html-loader, 用什么 loader 同样在 loaders 对象中定义,等会下面就会看到。
  68. */
  69. use: ['style-loader', 'css-loader']
  70. },
  71. {
  72. /*
  73. 匹配各种格式的图片和字体文件
  74. 上面 html-loader 会把 html 中 <img> 标签的图片解析出来,文件名匹配到这里的 test 的正则表达式,
  75. css-loader 引用的图片和字体同样会匹配到这里的 test 条件
  76. */
  77. test: /\.(png|jpg|jpeg|gif|eot|ttf|woff|woff2|svg|svgz)(\?.+)?$/,
  78. /*
  79. 使用 url-loader, 它接受一个 limit 参数,单位为字节(byte)
  80. 当文件体积小于 limit 时,url-loader 把文件转为 Data URI 的格式内联到引用的地方
  81. 当文件大于 limit 时,url-loader 会调用 file-loader, 把文件储存到输出目录,并把引用的文件路径改写成输出后的路径
  82. 比如 views/foo/index.html 中
  83. <img src="smallpic.png">
  84. 会被编译成
  85. <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAA...">
  86. <img src="largepic.png">
  87. 会被编译成
  88. <img src="/f78661bef717cf2cc2c2e5158f196384.png">
  89. */
  90. use: [
  91. {
  92. loader: 'url-loader',
  93. options: {
  94. limit: 10000
  95. }
  96. }
  97. ]
  98. }
  99. ]
  100. },
  101. /*
  102. 配置 webpack 插件
  103. plugin 和 loader 的区别是,loader 是在 import 时根据不同的文件名,匹配不同的 loader 对这个文件做处理,
  104. 而 plugin, 关注的不是文件的格式,而是在编译的各个阶段,会触发不同的事件,让你可以干预每个编译阶段。
  105. */
  106. plugins: [
  107. /*
  108. html-webpack-plugin 用来打包入口 html 文件
  109. entry 配置的入口是 js 文件,webpack 以 js 文件为入口,遇到 import, 用配置的 loader 加载引入文件
  110. 但作为浏览器打开的入口 html, 是引用入口 js 的文件,它在整个编译过程的外面,
  111. 所以,我们需要 html-webpack-plugin 来打包作为入口的 html 文件
  112. */
  113. new HtmlWebpackPlugin({
  114. /*
  115. template 参数指定入口 html 文件路径,插件会把这个文件交给 webpack 去编译,
  116. webpack 按照正常流程,找到 loaders 中 test 条件匹配的 loader 来编译,那么这里 html-loader 就是匹配的 loader
  117. html-loader 编译后产生的字符串,会由 html-webpack-plugin 储存为 html 文件到输出目录,默认文件名为 index.html
  118. 可以通过 filename 参数指定输出的文件名
  119. html-webpack-plugin 也可以不指定 template 参数,它会使用默认的 html 模板。
  120. */
  121. template: './src/index.html',
  122. /*
  123. 因为和 webpack 4 的兼容性问题,chunksSortMode 参数需要设置为 none
  124. https://github.com/jantimon/html-webpack-plugin/issues/870
  125. */
  126. chunksSortMode: 'none'
  127. })
  128. ]
  129. }
  130. /*
  131. 配置开发时用的服务器,让你可以用 http://127.0.0.1:8080/ 这样的 url 打开页面来调试
  132. 并且带有热更新的功能,打代码时保存一下文件,浏览器会自动刷新。比 nginx 方便很多
  133. 如果是修改 css, 甚至不需要刷新页面,直接生效。这让像弹框这种需要点击交互后才会出来的东西调试起来方便很多。
  134. 因为 webpack-cli 无法正确识别 serve 选项,使用 webpack-cli 执行打包时会报错。
  135. 因此我们在这里判断一下,仅当使用 webpack-serve 时插入 serve 选项。
  136. issue:https://github.com/webpack-contrib/webpack-serve/issues/19
  137. */
  138. if (dev) {
  139. module.exports.serve = {
  140. // 配置监听端口,默认值 8080
  141. port: 8080,
  142. // add: 用来给服务器的 koa 实例注入 middleware 增加功能
  143. add: app => {
  144. /*
  145. 配置 SPA 入口
  146. SPA 的入口是一个统一的 html 文件,比如
  147. http://localhost:8080/foo
  148. 我们要返回给它
  149. http://localhost:8080/index.html
  150. 这个文件
  151. */
  152. app.use(convert(history()))
  153. }
  154. }
  155. }

走一个

配置 OK 了,接下来我们就运行一下吧。我们先试一下开发环境用的 webpack-serve:

  1. ./node_modules/.bin/webpack-serve webpack.config.js

执行时需要指定配置文件。

上面的命令适用于 Mac / Linux 等 * nix 系统,也适用于 Windows 上的 PowerShell 和 bash/zsh 环境(Windows Subsystem for Linux, Git BashBabunMSYS2 等)。安利一下 Windows 同学使用 Ubuntu on Windows,可以避免很多跨平台的问题,比如设置环境变量。

如果使用 Windows 的 cmd.exe,请执行:

  1. node_modules\.bin\webpack-serve webpack.config.js

npm 会把包的可执行文件安装到 ./node_modules/.bin/ 目录下,所以我们要在这个目录下执行命令。

命令执行后,控制台显示:

  1. wdm」: Compiled successfully

这就代表编译成功了,我们可以在浏览器打开 http://localhost:8080/ 看看效果。如果有报错,那可能是什么地方没弄对?请自己仔细检查一下~

我们可以随意更改一下 src 目录下的源代码,保存后,浏览器里的页面应该很快会有相应变化。

要退出编译,按 ctrl+c

开发环境编译试过之后,我们试试看编译生产环境的代码,命令是:

  1. ./node_modules/.bin/webpack-cli

不需要指定配置文件,默认读取 webpack.config.js

执行脚本的命令有点麻烦,因此,我们可以利用 npm,把命令写在 package.json 中:

  1. {
  2. "scripts": {
  3. "dev": "webpack-serve webpack.config.js",
  4. "build": "webpack-cli"
  5. }
  6. }

package.json 中的 scripts 对象,可以用来写一些脚本命令,命令不需要前缀目录 ./node_modules/.bin/,npm 会自动寻找该目录下的命令。我们可以执行:

  1. npm run dev

来启动开发环境。

执行

  1. npm run build

来打包生产环境的代码。