【新前端模板记录】最新的 Webpack + React + Vue 项目集成方式

前言

上篇文章提过了,我准备重构博客的前端。为了更快的适应新变化,我决定先从一个基础模板开始,逐步的、循序渐进的的像搭积木一样搭建起来。下面我会记录过程,这个过程可能是实时的,逐渐更新的,大概会花费我数个小时左右(因为有太多新变化需要了解和适配)。

PS:由于博客前端项目是由多个框架组合起来的,所以不会使用 vue-cli 这类脚手架工具创建模板,因为这个仅适合单一使用 vue 的项目。

注意了!本文完成的所有代码已经在 Github 上建立了仓库【我是地址】,并且会持续保持代码提交。

过程

在第一个阶段,我们需要搭建起一个最基本的 Webpack 项目,它需要保证正确的被 Webpack 管理且能用 webpack-dev-server 启动

首先我们创建项目目录(名字随意),然后在根目录执行 yarn init,按照交互生成 package.json 文件(这个过程不重要,手动创建 package.json 也行)。接着安装相关依赖:

yarn add --dev webpack webpack-dev-server
          

此时 package.json 文件中多了 devDependencies 具体内容为:

{
            // ...
            "devDependencies": {
              "webpack": "^3.10.0",
              "webpack-dev-server": "^2.11.1"
            }
          }
          

从第一个阶段开始,请记住大版本。例如 webpack 是 3,webpack-dev-server 是 2。因为往往大版本升级会导致不兼容和用法变化,记住大版本有助于跟随新版本变化。

我们开始创建项目基础目录结构,在根目录新建 dist 和 src 两个目录。然后在 src 中创建 entry.js 文件,在根目录中新建 webpack.config.dev.js 文件。

entry.js 可以为空,webpack.config.dev.js 内容为:

const path = require('path');
          const buildPath = path.resolve(__dirname, 'dist');
          const srcPath = __dirname + '/src';
          
          module.exports = {
              entry: {
                  bundle: srcPath + '/entry.js'
              },
              output: {
                  path: buildPath,
                  filename: '[name].js'
              }
          };
          

上面的配置表示将入口文件 src 目录下的 entry.js 输出到出口目录 dist 中。文件跟随 entry 中的键命名。接着我们在 package.json 中添加一个命令(用于运行 webpack):

{
            // ...
            "scripts": {
              "build": "webpack --config webpack.config.dev.js"
            }
            // ...
          }
          

此时运行下列命令让 webpack 开始工作:

yarn run build
          

注意:因为我们非默认的 webpack.config.js 命名配置,所以需要 –config 参数手动指定配置文件(这个在多环境下需要)。

运行结束后会在 dist 目录中产生输出后的 bundle.js 文件。

然后我们在 webpack.config.dev.js 中添加 webpack-dev-server 的配置,它相当于在纯前端的项目中集成了一个自带 router 和热更新等功能的小型 Server,规避了本地文件化前端开发的缺陷。添加配置如下:

module.exports = {
              // ...
              devServer: {
                  port: 4000,
                  inline: true,
                  host: '0.0.0.0'
              }
          };
          

开放 4000 端口,绑定 0.0.0.0 地址。然后在 scripts 中添加用于启动 webpack-dev-server 的命令:

"scripts": {
              // ...
              "server": "webpack-dev-server --config webpack.config.dev.js"
            }
          

当然我们现在启动 Server 也访问不到任何内容,因为我们还没有网页呢。在根目录创建 index.html 文件:

<!DOCTYPE html>
          <html lang="zh-Hans">
          <head>
              <meta charset="UTF-8">
              <title>模板首页</title>
          </head>
          <body>
          
          </body>
          <script src="/dist/bundle.js"></script>
          </html>
          

此网页引用了输出后的 bundle.js 文件,在启动 webpack-dev-server 以后会先编译代码然后启动服务。所以我们直接运行启动命令,然后直接访问 http://localhost:4000 即可。

注意:输出的结果在 webpack-dev-server 中可以直接通过根目录访问。如果你想限定一个子目录,可以通过修改 output 配置,添加 publicPath 项:

output: {
              path: buildPath,
              filename: '[name].js',
              publicPath: '/dist/'
          }
          

如果这样做,那么原本可以通过 http://localhost:4000/bundle.js 引用的文件需要通过 http://localhost:4000/dist/bundle.js 来访问。当然这里的 dist 和 path 中的实际目录 dist 没有关系,你可以理解为映射了一个虚拟子目录。

yarn run server
          

上面的命令执行完毕会阻塞住,因为监听了一个 Web 服务。我们访问项目以后会看到那个空白网页,这样子第一个阶段就算完成了。

当然,你可以进一步编辑 src/entry.js 文件来测试它们是否正常工作。正常情况下,entry 或者被 entry 引用的 js 被更新网页会立马刷新以展示最新内容。例如你可以在 entry.js 写上一句输出 Hello World 的代码,浏览器页面会自动刷新,打开控制台便会发现 Hello World。

在第二个阶段,我们会集成 babel 以及相关配置,以达到使用 es2015 编写前端代码的同时还能保证浏览器兼容性。

首先我们安装相关依赖:

yarn add --dev babel-core babel-loader babel-polyfill babel-preset-env babel-preset-stage-2 
          

loader 是给 webpack 使用的,polyfill 是将原本不会转译的新 API 进一步翻译(原生只能转译新语法)。preset-env 是可供配置的预设环境(例如 stage 版本、ES 版本以及部分框架例如 React 的 JSX 支持)。
注意:这里有个新变化,babel-preset-env 库会以 es2015 作为默认编译目标版本,所以原来需要添加的 babel-preset-es2015 库已经是多余。并且 presets 配置中不再需要指定为 es2015。

我们向 webpack 集成 babel-loader(添加配置):

module.exports = {
              // ...
              module: {
                  rules: [
                      {
                          test: /\.js$/,
                          exclude: /(node_modules)/,
                          use: {
                              loader: 'babel-loader',
                              options: {
                                  presets: ['env', 'stage-2']
                              }
                          }
                      }
                  ]
              }
              // ...
          };
          

跟 Webpack1.x 不同的是,不再是向 module.loaders 中添加了,而是 module.rules 数组中。细节也有不同,例如多出来的父级项 use 和 options 这个替代 query 的子项等。

对了,我们为了保证兼容性,还需要将 babel-polyfill 添加到 entry 中:

entry: {
              bundle: [srcPath + '/entry.js', 'babel-polyfill']
          }
          

这一切都做完以后,就可以直接启动 webpack-dev-server。然后我们编辑 entry.js 文件:

{
              class Person {
                  constructor(name, age) {
                      this.name = name;
                      this.age = age;
                  }
          
                  toString() {
                      return `${this.name}今年${this.age}岁`;
                  }
              }
          
              let p1 = new Person('小明', '18');
              console.log(p1.toString());
          }
          

这是一段典型的 es2015 代码,使用了新增的特性 Class,使用了新的变量声明关键字 let,还使用了字符串模板。这段代码很简单,直接人脑就可以得出结果:控制台输出“小明今年18岁”。

让我们访问 http://localhost:4000/bundle.js 看看编译后的结果是怎样的。无视框架代码,直接提取 babel 编译结果:

{
              var Person = function () {
                  function Person(name, age) {
                      _classCallCheck(this, Person);
          
                      this.name = name;
                      this.age = age;
                  }
          
                  _createClass(Person, [{
                      key: 'toString',
                      value: function toString() {
                          return this.name + '\u4ECA\u5E74' + this.age + '\u5C81';
                      }
                  }]);
          
                  return Person;
              }();
          
              var p1 = new Person('小明', '18');
              console.log(p1.toString());
          }
          

OK,正常工作。它去掉了所使用的 es2015 特性,变向的以 ES5 的形式实现了相同的功能。至此,第二阶段结束。

第三个阶段,我们要集成 React 相关的组件。使用 JSX、react-router 和 redux 构建一个最简单的前台 SPA

同样的,我们先安装相关依赖(prop-types 库是 React 中用作类型检查的,现在被独立了出来):

yarn add --dev prop-types babel-preset-react react react-dom react-router react-redux redux
          

一步一步来,先让 React 开始工作,我们给 jsx 单独添加一个 loader 规则:

rules: [
              // ...
              {
                  test: /\.jsx$/,
                  exclude: /(node_modules)/,
                  use: {
                      loader: 'babel-loader',
                      options: {
                          presets: ['react', 'env', 'stage-2']
                      }
                  }
              }
          ]
          

接下来开始编辑文件,将 entry.js 重命名为 entry.jsx(记得 Webpack 配置中的 entry 路径也要改)。创建 src/frontdesk/App.jsx 文件,内容为:

import React from "react"
          
          
          class App extends React.Component {
          
              render() {
                  return (
                   <div id="app">
                       我是 App 首页。
                   </div>
                  )
              }
          }
          
          export default App;
          

编辑 entry.jsx 文件,内容为:

import React from 'react'
          import {render} from 'react-dom'
          
          import App from './frontdesk/App.jsx'
          
          render((
              <div>
                  <App/>
              </div>
          ), document.getElementById('container'));
          

从上面可以看到我们用 React 构建了一个最简单结构的组件,并且渲染在页面中 id 为 container 的元素中。所以 index.html 需要添加相应的文档结构(直接在 body 中放一个该 id 的 div 即可):

<body>
          <div id="container"></div>
          </body>
          

运行 webpack-dev-server,访问首页便可看到 App 组件中的内容。这样子集成后的 React 就是完全正常工作的。

接着,因为我们使用 Redux 管理状态流,所以需要给页面设计一个简单的有数据交互的功能:添加三个组件,点击左边按钮 +1,点击右边按钮 -1,中间显示结果。

这三个组件有两种类型,加或减的按钮是一种类型(这里叫做 Operator),结果是一种类型(Result)。每一个 Operator(容器组件) 其实都是一个 Button(展示组件),只不过显示的文字和执行操作有所不同。大概描述以后,我们就可以创建相关文件了。

在 frontdesk 目录中新建 components 目录,然后新建三个文件:Button.jsx、Operator.jsx 和 Result.jsx。规划好相关组件以后,根据 Redux 的老规矩,首先编写 actions 和 reducers:

frontdesk/actions.js

/**
           * actions 的类型
           */
          
          export const ADDITION = "ADDITION";
          export const SUBTRACTION = "SUBTRACTION";
          
          // todos 函数列表
          
          /**
           * +1
           */
          export function plusTodo() {
              return {type: ADDITION}
          }
          
          /**
           * -1
           */
          export function minusTodo() {
              return {type: SUBTRACTION}
          }
          

frontdesk/reducers.js(因为功能过于简单,所以这里并没有拆分 Reducer 函数)

import {ADDITION, SUBTRACTION} from './actions'
          
          /**
           * reducer 函数:计算
           */
          function computeApp(state = {val: 0}, action) {
              switch (action.type) {
                  case ADDITION:
                      return {val: state.val + 1};
                  case SUBTRACTION:
                      return {val: state.val - 1};
              }
              return state;
          }
          
          export default computeApp;
          

编写 Button.jsx 文件(基础展示组件:按钮):

import React from 'react'
          
          import {ADDITION, SUBTRACTION} from '../actions'
          
          const Button = ({type, onClick}) => {
              let text;
              if (type === ADDITION) text = "加一";
              else if (type === SUBTRACTION) text = "减一";
              return (<button onClick={() => onClick(type)}>{text}</button>);
          };
          
          
          export default Button;
          

编写 Operator.jsx(容器组件:操作按钮)

import {connect} from 'react-redux'
          
          import {ADDITION, SUBTRACTION, plusTodo, minusTodo} from '../actions'
          import Button from './Button.jsx'
          
          const mapStateToProps = (state, ownProps) => {
              return {type: ownProps.type};
          };
          
          const mapDispatchToProps = dispatch => {
              return {
                  onClick: (type) => {
                      let operate;
                      if (type === ADDITION) operate = plusTodo;
                      else if (type === SUBTRACTION) operate = minusTodo;
                      dispatch(operate())
                  }
              }
          };
          
          export default connect(
              mapStateToProps,
              mapDispatchToProps
          )(Button);
          

编写 Result.jsx(容器组件:结果)

import React from 'react'
          import {connect} from 'react-redux'
          import PropTypes from 'prop-types'
          
          const Result = ({value}) => {
              return (<span>{value}</span>);
          };
          
          const mapStateToProps = state => {
              return {value: state.val};
          };
          
          Result.propTypes = {
              value: PropTypes.number.isRequired
          };
          
          Result.defaultProps = {
              value: 0
          };
          
          export default connect(mapStateToProps)(Result);
          

将 App.jsx 改为下面的内容(此时已将 App.jsx 移动到 components 目录中):

import React from 'react'
          import Operator from './Operator.jsx'
          import Result from './Result.jsx'
          
          import {ADDITION, SUBTRACTION} from '../actions'
          
          
          const App = () => (
              <div id="app">
                  我是 App 首页。<br/>
                  <Operator type={ADDITION}/>
                  <Result/>
                  <Operator type={SUBTRACTION}/>
              </div>
          );
          
          export default App;
          

将 entry.jsx 改为下面的内容:

import React from 'react'
          import {render} from 'react-dom'
          import {Provider} from 'react-redux'
          import {createStore} from 'redux'
          
          import computeApp from './frontdesk/reducers'
          import App from './frontdesk/components/App.jsx'
          
          
          let store = createStore(computeApp);
          
          render(
              <Provider store={store}>
                  <App/>
              </Provider>,
              document.getElementById('container')
          );
          

运行项目,会看到在“我是 App 首页。”下面有我们刚才创建的“加减器”功能区域,执行相关操作也能看到对应结果。

注意:Operator 组件和 Result 组件并不是父级与子级的关系,不能靠原生 React 简单上下级的 props 数据传递来解决操作与数据展示更新同步问题。需要加一个共同的父级组件让 Operator 执行父级组件传递过来的回调函数,然后才能让父级组件修改 state 以达到兄弟组件 Result 的重新渲染。但是使用 Redux 管理状态流就可以优雅的改善这些麻烦和别扭的开发方式。

关于 React 的最后一个部分,我们要集成 ReactRouter 作为前端路由,这样我们的 React 前台部分就结束了。首先我们添加依赖:

yarn add --dev react-router-dom
          

注意:因为 react-router 到 v4 版本以后拆分出了 react-router-dom 库,所以需要添加。值得一提的是 ReactRouter 和 Redux 还可以联合使用,让 store 保存 routing 信息,可以方便的进行 Time Travel。但是这并不是必备的,现阶段我们不需要。

我们创建 src/frontdesk/views 文件夹,用来存放表示“页面”的组件。将 App.jsx 移动到 views 中(因为 App.jsx 当前表示首页),然后再创建 Help.jsx、About.jsx 和 TopMenu.jsx 三个文件到 views 目录中。前两个是跟 App 同级的页面,后面一个是全局显示的导航页面,它控制着另外三个页面的切换。

About.jsx:

import React from 'react'
          export default () => (
              <div>
                  <h2>About</h2>
              </div>
          );
          
          

Help.jsx:

import React from 'react'
          export default () => (
              <div>
                  <h2>Help</h2>
              </div>
          );
          

TopMenu.jsx:

import React from "react";
          import {BrowserRouter as Router, Route, Link} from "react-router-dom";
          import About from './About.jsx'
          import Home from './App.jsx'
          import Help from './Help.jsx'
          
          export default () => (
              <Router>
                  <div>
                      <ul>
                          <li>
                              <Link to="/">Home</Link>
                          </li>
                          <li>
                              <Link to="/about">About</Link>
                          </li>
                          <li>
                              <Link to="/help">Help</Link>
                          </li>
                      </ul>
          
                      <hr/>
          
                      <Route exact path="/" component={Home}/>
                      <Route path="/about" component={About}/>
                      <Route path="/help" component={Help}/>
                  </div>
              </Router>
          );
          

此时,我们的 entry.jsx 修改成下面的样子:

import React from 'react'
          import {render} from 'react-dom'
          import {Provider} from 'react-redux'
          import {createStore} from 'redux'
          import TopMenu from './frontdesk/views/TopMenu.jsx'
          import computeApp from './frontdesk/reducers'
          
          let store = createStore(computeApp);
          
          render(
              <Provider store={store}>
                  <TopMenu/>
              </Provider>,
              document.getElementById('container')
          );
          

运行项目并访问首页,就可以看到上面的导航栏和默认的 App 页面内容。点击导航栏的链接,下面的页面组件也会相应的切换,并且地址栏会自动变化。ReactRouter v4 版和 v3 区别有些大,这里不进行细说,需要提的一点就是 ReactRouter 中的常见元素都成了标准的 React 组件:就像大家看到的,在 TopMenu.jsx 中,三个 Route 被包含在了一个 div 中。
假设我们点击了 Help 链接,地址栏变成了:http://localhost:4000/help ,不要急我们先实验一个问题:直接刷新页面会发生什么呢?答案是:找不到页面。原因在于后端没有做 /help 这个 路由,不知道该怎么进行跳转。既然我们已经应用了前端路由,那么后端就可以彻底简单化了。因为我们的后端实际就是 webpack-dev-server,我们对它进行配置即可:

devServer: {
                  port: 4000,
                  inline: true,
                  host: '0.0.0.0',
                  historyApiFallback: {
                      rewrites: [
                          {from: /.*/, to: '/index.html'}
                      ],
                  },
              }
          

在 Webpack 配置文件中的 devServer 项中添加 historyApiFallback 的相关配置,将所有访问全部跳转到 index.html 页面。重新运行项目,刷新页面一切正常,前端路由会自动根据 URL 进行组件渲染。此时 React 阶段基本完成,在实际的开发中我将会将 React 系列应用到博客的前台页面功能开发中。而下一个阶段则是 Vue(后台页面技术栈)相关的集成。

第四个阶段,我们要集成 Vue 相关组件。使用 .vue 模板、vue-router 和 vuex 构建一个最简单的后台 SPA

安装 Vue 相关的依赖:

yarn add --dev css-loader vue vue-loader vue-template-compiler
          

css-loader 虽然不属于 Vue 技术栈的一员,但是 Vue 模板中为了能支持样式嵌入需要它提供相关支持,所以它仍然是必须的。

编辑 Webpack 配置,添加 loader:

{
              test: /\.vue$/,
              exclude: /(node_modules)/,
              use: {
                  loader: 'vue-loader'
              }
          }
          

添加 resolve 项(跟 output/entry 和 module 是同级元素):

output: { /**/ },
          resolve: {
              extensions: ['.js', '.jsx', '.vue'],
              alias: {
                  'vue$': 'vue/dist/vue.js'
              }
          },
          module: { /**/ }
          

将 entry 修改成下面的样子:

entry: {
              front: [srcPath + '/front.jsx', 'babel-polyfill'],
              admin: [srcPath + '/admin.jsx', 'babel-polyfill']
          },
          

front.jsx 为 React 入口也就是之前的 entry.jsx,admin.jsx 为 Vue 入口。在 src 下分别创建 admin.jsx 文件、backstage/components 目录,在 components 目录中创建 Button.vue 文件作为我们自己的 Vue 组件:按钮。

Button.vue:

<template>
              <button class="vue-btn" @click="handleClick">
              <span v-if="$slots.default">
                <slot></slot>
              </span>
                  <span v-else>{{ text }}</span>
              </button>
          </template>
          <script>
              export default {
                  name: 'MyButton',
                  data() {
                      return {
                          text: '未命名按钮'
                      }
                  },
                  methods: {
                      handleClick(e) {
                          this.$emit('click', e);
                      }
                  }
              }
          </script>
          <style>
              .vue-btn {
                  background: skyblue;
                  border: none;
                  cursor: pointer;
              }
          
              .vue-btn:hover {
                  background: #F5DCC6;
              }
          </style>
          

admin.jsx:

import Vue from 'vue'
          import Button from './backstage/components/Button.vue'
          Vue.component(Button.name, Button);
          
          new Vue({
              el: '#app'
          });
          

其中 Button.vue 作为一个独立的组件,被 admin.jsx 引用和创建,并用在了一个 Vue 实例上。此时,我们在根目录创建 admin.html(跟 index.html 同一级)文件,内容为:

<!DOCTYPE html>
          <html lang="zh-Hans">
          <head>
              <meta charset="UTF-8">
              <title>模板后台</title>
          </head>
          <body>
          <div id="container">
              <div id="app">
                  <my-button>登录</my-button>
              </div>
          </div>
          </body>
          <script src="/dist/admin.js"></script>
          </html>
          

可以看到我们在 html 中使用了 Button 组件,这样就大功告成了。当然,我们的后端此时还只能访问一个页面 index.html,所以需要添加新的路由规则让 admin.html 能够访问。一般来讲,只需要让 /admin 和 /admin/.* 全部跳转到 admin.html 即可,前后台不冲突!

编辑 Webpack 配置中的 devServer 项中的 historyApiFallback:

historyApiFallback: {
              rewrites: [
                  {from: /admin\/?$/, to: '/admin.html'},
                  {from: /admin\/.*/, to: '/admin.html'},
                  {from: /.*/, to: '/index.html'}
              ],
          },
          

然后运行项目,访问 http://localhost:4000/admin 即可看到新的页面和 Vue 组件渲染结果。不过,这并太符合单页应用的基本原则,我们不应该仅仅组件化基础组件,整个页面也应该是一个“组件”。
创建 backstage/views/Index.vue 文件,表示首页。内容为:

<template>
              <my-button>登录</my-button>
          </template>
          
          <script>
              import MyButton from '../components/Button.vue'
          
              export default {
                  name: 'Index',
                  components: {
                      MyButton
                  }
              }
          </script>
          

页面组件就是把页面内容写进 template 中,并且通常会引用所用到的大量基础组件,例如 MyButton。接着我们删除 admin.html 中的 my-button 标签,但要留下 id 为 app 的 div。之后几乎就不会再更改这个网页的内容。
这时候,我们的 Vue 入口要变成:

import Vue from 'vue'
          import Index from './backstage/views/Index.vue'
          
          Vue.component(Index.name, Index);
          
          new Vue({
              el: '#app',
              render() {
                  return (<Index/>);
              }
          });
          

跟使用 Button 组件类似,先引用和创建,不过这次是用 render 函数渲染模板组件。因为在 render 中使用了标签,这个入口文件实际上就成了 JSX(当然它的命名的确是 .jsx 不过这无关紧要),需要使用 Vue 的相关插件进行处理。为了不影响 React 的 JSX 组件,所以这里我们将 admin.jsx 重命名为 admin.vue.jsx 然后用特定文件名匹配的方式应用 loader 和 plugin。
修改 Webpakck 配置,添加一个新 loader:

{
              test: /\.vue\.jsx$/,
              exclude: /(node_modules)/,
              use: {
                  loader: 'babel-loader',
                  options: {
                      plugins: ['transform-vue-jsx']
                  }
              }
          }
          

这个 loader 只会处理 .vue.jsx 后缀的文件(例如 admin.vue.jsx),所以不会影响到 React 的 JSX 组件(它们直接以 .jsx 结尾)。当然,在这之前我们要添加相关依赖:

yarn add --dev babel-plugin-transform-vue-jsx
          

对了,别忘了将 entry 中的输入文件重命名过来。这之后再启动项目,会发现还是原来的内容,但是我们现在不依赖 html 文件了,组件和页面全部由 Vue 的 Template 方式构建。

现在我们给页面设计一个功能,当点击登录按钮时候右边会显示你登录了几次(其实就是一个计数器)。因为我们要通过 vuex 管理状态,所以还是先添加相关依赖:

yarn add --dev vuex
          

Vuex 和 Redux 的大致思想和目的是相同的,并且 Vuex 更为简单易用一点。因为功能很简单,所以就单独创建一个 store.js 创建 store 并直接包含相关的 mutations 方法。

backstage/store.js:

import Vuex from 'vuex'
          import Vue from 'vue'
          
          Vue.use(Vuex);
          
          export default new Vuex.Store({
              state: {
                  count: 0
              },
              mutations: {
                  login(state) {
                      state.count++
                  }
              }
          });
          

Index.vue 的 template 部分:

<template>
              <div id="app">
                  <my-button @click="login">登录</my-button>
                  <span>您已登录{{ getCount }}次</span>
              </div>
          </template>
          

Index.vue 的 script 部分:

<script>
              import MyButton from '../components/Button.vue'
          
              export default {
                  name: 'Index',
                  components: {
                      MyButton
                  },
                  computed: {
                      getCount(){
                          return this.$store.state.count;
                      }
                  },
                  methods: {
                      login(){
                          this.$store.commit('login');
                      }
                  }
              }
          </script>
          

点击一次登录按钮会触发 Index 组件的 vue 实例的 methods 中的 login 方法,提交 Type 为 login 的 mutation,相应的 mutation 会进行 state 变更,进而同步影响到 computed 中的 getCount 方法的重新计算,最后渲染出新结果。虽然 Vuex 这个例子过于简单,连 actions 都没涉及到,但是考虑到之前 Redux 的例子已经比较完整了而且理解 Vuex 真的太容易,所以就不进一步完善例子了。

下面就是 Vue 的最后一个阶段了,也几乎是设计编码的最后一个部分:让 Vue 构建的后台成为一个 SPA。要做到这个,跟 React 一样的需要应用前端路由。Vue 技术栈的前端路由是 VueRouter(名字几乎一个风格懒得吐槽了),添加相关库:

yarn add --dev vue-router
          

由于我们之前只编写了一个页面组件,所以我们还需要第二个页面。在 views 中创建 Login.vue:

<template>
              <div>
                  <router-link :to="{ name: 'index' }">登录</router-link>
              </div>
          </template>
          
          <script>
              export default {
                  name: 'Login'
              }
          </script>
          

创建 backstage/router.js 文件:

import VueRouter from 'vue-router'
          import Vue from 'vue'
          
          
          import Index from './views/Index.vue'
          import Login from './views/Login.vue'
          
          Vue.component(Index.name, Index);
          Vue.component(Login.name, Login);
          
          Vue.use(VueRouter);
          
          const routes = [
          
              {name: 'login', path: '/login', component: Login},
              {name: 'index', path: '/admin', component: Index}
          ];
          
          export default new VueRouter({
              routes,
              mode: 'history'
          });
          

上面很简单的配置了两个静态规则:/login 和 /admin。前者表示登录页,后者表示后台首页。接着我们修改 Vue 入口文件(admin.vue.jsx):

import Vue from 'vue'
          import router from './backstage/router'
          import store from './backstage/store'
          
          new Vue({
              el: '#app',
              store,
              router,
              render() {
                  return (<router-view />);
              }
          });
          

还没结束,因为我们添加了一个独立的特殊的路由 /login,不在 /admin 或者 /admin/* 规则内,反而会被跳转到 index.html。所以我们需要再添加一个 devServer 的后端路由规则:

historyApiFallback: {
              rewrites: [
                  {from: /admin\/?$/, to: '/admin.html'},
                  {from: /admin\/.*/, to: '/admin.html'},
                  {from: /login\/?$/, to: '/admin.html'},
                  {from: /.*/, to: '/index.html'}
              ],
          },
          

由于 Login 也是后台 SPA 的一个组件,所以仍然被跳转到 admin.html 页面。接着我们运行项目,访问后台首页,点击注销链接就会跳转到 Login 页面,点击登录就会进入 Index 页面。/login 和 /admin 两个前端路由之间由这两个链接互相往返,页面却是同一个且不会刷新,这样就完成了 Vue 构建的后台部分。

注意:VueRouter 仅此还无法适应所有需求,例如嵌套路由和参数传递。本文做的主要是相关技术的在项目中的集成,保证正常工作下的最优配置,而不是介绍具体技术。关于具体技术的讲解文章我也写过一些,但是本文的目的并不是这个,也不可能在一篇文章讲解如此多的技术。

附加阶段,我们会集成更多的需要使用的 loader 和 plugin,尽量的进一步提升开发便捷程度

现在来讲应该没什么人会直接写 CSS 了,就连我这两年前的博客前端样式都是 SASS 写的(实际上虽说是两年前的前端但和本文所用的技术相差无几)。所以我们自然要集成一个用于处理 .sass 或者 .scss 的 loader 了,添加依赖:

yarn add --dev style-loader sass-loader node-sass
          

其实还有一个必须的依赖是 css-loader,但是在 Vue 阶段已经添加过了。添加 loader:

{
              test: /\.(scss|sass)$/,
              use: [
                  {loader: "style-loader"},
                  {loader: "css-loader"},
                  {loader: "sass-loader"}
              ]
          }
          

这里将 style、css 和 sass 三个 loader 配合起来了,其实在 Webpack1 中这样写就行了:’style!css!sass’,新版本反而麻烦了一些:)
如果项目还需要 CSS 文件,就需要单独再配置一个处理 .css 的 loader:

{
              test: /\.css$/,
              use: [
                  {loader: "style-loader"},
                  {loader: "css-loader"}
              ]
          }
          

虽然直接在上面的三个 loader 联合的 test 中加一个 .css 匹配也是可以处理的,但是并不需要最后一个 loader 参与,强迫症总觉得有点不舒服呢。如果你认为有了 sass 就不可能在项目中添加 CSS 那就错了,因为别忘了你有可能引用第三方样式库,而且很有可能只提供 CSS。我们创建 src/sass/global.scss 文件,放入页面全局都使用的样式

body {
            background: skyblue;
          }
          

既然是 loader,那么目的当然是参与最终 JS 的打包工作。无论怎么转换,样式代码最后都会以某种形式集成进入输出后的 JS 中,由 JS 在运行时阶段插入到页面文档中(生成将 CSS 插入页面节点代码其实就是 style-loader 的工作)。如果你明白了这一点,那么对样式的使用方式就会觉得很自然了:

// 将样式文件导入 admin.vue.jsx 和 front.jsx 中
          import './sass/global.scss'
          

就好像导入 JS 的一个类库一样,将样式文件导入到需要的 JS/JSX 中即可,当然你要理清引用关系,不要重复导入。此时再运行项目,访问前后台页面就能看到天蓝的背景色。利用浏览器的审核元素功能查看文档结构会发现 head 中被插入了 style 标签,嵌入了编译后的 CSS 代码。

有时候,我们在页面中会引用一些细小的资源,甚至单独为它发起一次浏览器请求都是浪费:例如典型的 favicon.ico 文件(页面图标,浏览器标签页上会显示)。如果这种细小文件多了,会造成同一个页面对服务端产生多次请求,是严重的浪费。所以我们为了避免这种情况,同时也方便开发和资源管理,可以直接将这些资源打包进 JS 中。安装所需要的 loader:

yarn add --dev url-loader
          

添加 loader 配置:

{
              test: /\.(png|jpg|woff|woff2)$/,
              loader: 'url-loader'
          }
          

这个 loader 会对所引用的资源进行处理,这里设置了四种后缀表示主要用来处理图片和字体。先创建目录 src/static 表示存放纯静态文件(除了添加和删除不会有别的修改和变动的资源)的地方,然后在 static 下创建 images 目录。

我们随意保存一张图片,例如我刚才保存了一张 Google 搜索主页的 LOGO 图片,并且已将它存放好。这时候我们修改 global.scss 文件:

body {
            background: url("../static/images/google.png");
          }
          

将刚才的图片资源引用过来,设置成网页背景。然后运行项目,访问页面就能看到效果了,但是这并不奇怪,因为即使不用这个 loader 同样能看到效果。如果你用浏览器看下页面请求,会发现并没有 google.png 的请求产生,反而是多了一段 data:image/png; 开头的编码。这段编码被插入到了样式的相应位置,替换了引用路径。
所以简单来说,url-loader 是将文件 base64 编码后替换掉了引用路径,这样资源能正常呈现却不必发起请求,因为被包含在 JS 或者 CSS 中了。

最终阶段,我们要区分环境。在同一个项目中区别出不同环境的配置方式

就算是前端项目,在经过这样一系列的“工程化”构建以后也能动态的产生针对不同环境下的配置差异,例如典型的 Development(开发环境) 和 Production(生产环境)。

在我们大概确定 Webpack 配置不会进行大变动以后,可以复制一份 webpack.config.dev.js 重命名为 webpack.config.prd.js 同样放根目录下。没错,上一份配置文件就当做开发环境了(也的确是以开发环境配置的),让我们看看那些需要修改?

首先,配置中 resolve 中对 vue.js 的引用不再是 vue.js 而应该是 vue.min.js:

resolve: {
              extensions: ['.js', '.jsx', '.vue'],
              alias: {
                  'vue$': 'vue/dist/vue.min' // 此处原本是 vue/dist/vue.js
              }
          },
          

项目构建的时候,很多 loader 会读取进程的环境变量,例如 NODE_ENV 的值。通常来讲,这个值在部署阶段,要设置为 production,React 和 Vue 等框架都是如此(所打包的文件会有所差异)。所以我们要添加插件来定义这个变量,虽然通过 package.json 中的 script 命令前缀入环境变量也行,但是这样做脱离了 Webpack,并且无法直接兼容不同的平台。

在 Webpack 中是这样做的:

module.exports = {
            // ...
            plugins: [
              // ...
              new webpack.DefinePlugin({
                'process.env': {
                  NODE_ENV: '"production"'
                }
              })
          }
          

记得还要在配置文件最上面导入 webpack:

const webpack = require('webpack');
          

但是这样还不够,生产环境的库没有调试信息能减少一部分 JS 体积,但是更加深入的减小体积当然是要压缩代码:

module.exports = {
            // ...
            plugins: [
              // ...
              new webpack.optimize.UglifyJsPlugin({
                compress: {
                  warnings: false
                }
              })
            ]
          }
          

注意:这两个插件并列配置,放进一个 plugins 数组里边。同样的,Webpack 命令行参数 -p 也有一样的作用,但是写进配置文件能有更加定制化的配置内容,而且命令行参数也脱离了配置文件化。

另外,我们在开发环境的配置文件中添加的 devServer 也不需要了,整个删掉即可。Why?
因为生产环境最终的目的是产出 JS 文件,而 devServer 这个嵌入式后端是我们开发者用作本机开发调试,生产环境下的配置是自动化调用的,不需要 devServer 参与,所以它是多余的。

原本这样子,我们就大概的区分出了开发环境和生产环境的配置。但是 vue-loader 有个 BUG,它并不读取 Webpack 配置中的具体 loader 的 presets 项。在以前的 Webpack1 中还可以直接配置一个全局的 babel: {/ * presets 配置*/}项,但是 Webpack3 已经不支持。所以我们需要在项目根目录新建一个 .babelrc 文件,填入内容:

{
            "presets": [
              "env"
            ]
          }
          

如果不这样做,UglifyJs 插件会出错并提示:

ERROR in admin.js from UglifyJs
          Unexpected token: punc (() [admin.js:
          

上面的步骤完成以后,我们需要根据生产环境配置产出最终的 JS 文件。我们编辑 package.json 中的 scripts 的 build 指令:

"build": "webpack --config webpack.config.prd.js"
          

将原来的 dev.js 改为 prd.js,然后执行命令:

yarn run build
          

完毕以后 /dist 目录就会产出最终的 JS 文件,分别是 front.js 和 admin.js,对应前台和后台两个单页应用。至于页面源码根本无关紧要,因为页面的结构极其简单且不会再变动,可以直接写进后端项目的模板文件中。当然这样做,还有另一个作用,不过在这儿之前需要介绍一种浏览器缓存优化方法。

众所周知的是,集成上面这些技术栈的代价就是会产生庞大的 JS 文件,例如本文例子中的 front.js 已经达到 250KB+, admin.js 也有 200KB+。如果让用户每次刷新都下载 JS 那肯定是不可行的,页面加载慢体验一定极差,CDN 宽带消耗还挺高。
这时候,我们可以让后端返回的响应协议头中加入缓存的相关控制,例如设置一个极大的缓存过期时间,让浏览器尽可能的读取本地缓存而不重新下载。这样做的确可以解决问题,因为用户第一次访问就已经产生缓存了,之后的访问速度就自然极快了。不过这样的坏处是,缓存可能刷新不及时,可能我们已经更新了 JS 代码,但是用户那边仍然读取的缓存无法及时看到页面的新变化,如果是修复 BUG 或者跟安全相关的漏洞,这肯定是比较致命的(虽然说前端一般不会有多么严重的安全问题)。
于是,我们可以将每一次产出的 JS 文件都采用不同的名字。基本上每一个阶段的代码提交到生产构建都是一个新版本,所以我们可以用版本来命名。例如这次构建出来的文件名是 front.v1.js 和 admin.v1.js 那么下次就是 front.v2.js…
每一次构建完成,就触发后端的资源版本更新 API,传递版本。后端有了版本,就可以动态的渲染出引用的 JS 路径。例如后端的模板可能是这样的:

<script scr="/dist/front.${ver}.js">
          

而这个 ver 变量的值是存在后端应用内存中的,通过指定 API 更新。这样子,即使用户浏览器有缓存也是之前的了,毕竟连引用资源都不同了,一定会第一时间下载最新的 JS。简单来说,就是强制客户端缓存配合版本化资源命名。

关于上面那一点,其实 Webpack 有更优雅的做法,那就是利用资源 hash 自动命名。修改生产环境的 Webpack 配置(开发环境配置不用动,改了也是多此一举):

output: {
              / ...
              filename: '[name].[hash].js',
              / ...
          },
          

将 output 中的 filename 的值中插入一个 [hash]。其实 filename 的值之前就是一个模板,只不过只注入了 name 变量,现在添加了第二个 hash 变量共同构成输出后的文件名。因为每一次只要存在变动带来的资源都是有变化的,所以 hash 值肯定是不同的,所以此时的 hash 就相当于上面递增的版本号,而这个版本号是借用 Webpack 生成的,并且有更严格的版本控制机制(如果 hash 相同,那么客户端浏览器就不需要下载这次改动的新 JS,因为改来改去没发生变化~)。所以我上面也说过了,将结构再简单的 HTML 文件转换成后端模板也是有好处的,此处的好处就是动态的引用。

下一个问题来了,如果我们的 CI 服务器不断的构建新版本,岂不是产生了一个庞大的 dist 目录?虽然我们可以让 CI 的过程中加入清空 dist 目录的步骤,但如果每个项目的构建步骤都添加一个这样多余的清空操作,真的显得很多余。
在 Webpack 中,有相关插件可以做到这一点,所以自动构建的步骤中就不用多此一举了。

添加相关插件:

yarn add --dev clean-webpack-plugin
          

在生产环境配置中添加相关插件配置:

plugins: [
              // ...
              new CleanWebpackPlugin(['dist'], {
                  root: path.resolve(__dirname),
                  verbose: true,
                  dry: false,
                  exclude: ['.gitignore']
              })
          ]
          

别忘了导入 CleanWebpackPlugin:

const CleanWebpackPlugin = require('clean-webpack-plugin');
          

完成以后,再执行 build 指令会自动清空 dist 目录。注意我在配置中还排除了一个 .gitignore 文件(目的是假设在本机执行了 build 操作的话防止产出的 JS 文件加入版本控制),类似的,如果想排除其它放在要清空的目录中的文件,只需要添加进 exclude 数组即可。这样在 CI 服务器的构建过程不需要参与目录清理的操作。

还记得我们之前配置过的样式相关 loader 吗?它将 CSS 代码打包进了 JS 中,缺陷是可能不能充分发挥部分系统架构优化能力或者业务需求,需要剥离出来单独储存和存放。例如后端根据客户端请求动态的渲染不同的样式文件,有的是专门给拖后腿的 IE 写的兼容代码,有的在移动端和 PC 上分别引用不同的样式以达到网页自适应,等情况。所在生产环境下配置应该是这个样子的:

const ExtractTextPlugin = require("extract-text-webpack-plugin");
          const extractSass = new ExtractTextPlugin({
              filename: "[name].[contenthash].css",
          });
          
          module.exports = {
              // ...
              module: {
                  rules: [
                      // ...
                      {
                          test: /\.(scss|sass)$/,
                          use: extractSass.extract({
                              use: [
                                  {
                                      loader: "css-loader",
                                      options: {
                                          minimize: true
                                      }
                                  },
                                  {loader: "sass-loader"}
                              ],
                              fallback: "style-loader"
                          })
                      }
                  ]
              },
              plugins: [
                  // ...
                  extractSass
              ]
          };
          

使用了 ExtractTextPlugin 插件抽取样式至文件中,并且启用了代码压缩。文件命名方式跟输出的 JS 一个样,style-loader 也被去掉了,因为独立的 .css 文件不需要包装上插入到页面节点的 JS 代码。
至于为什么没有添加针对 .css 的 loader?这个得看需求了,在我看来,如果在生产环境下需要剥离出自己写的样式代码的话,所引用的第三方 CSS 库也不可能被包含进 JS 中,压根不需要配置,因为直接引用 CDN 的链接即可。
最终还是上面那句话,看具体需求程度。也许你连抽取样式代码都不需要,那么直接包含进 JS 也没有毛病。

还有,虽然上面使用过的 url-loader 的作用简直妙不可言,能让开发者处于最前面的编码阶段就不必在意后续的资源引用优化,但是不进行控制却会带来严重的问题:如果所引用的文件非常巨大怎么办?即使不算很大,但是资源比较多的话岂不是会产生一个庞大的输出文件?例如我刚才保存的 Google 搜索 LOGO 有 70K+ 的大小,占输出后的 JS 体积的 1/4,也就是说 JS 中有四分之一都是几乎永远不会变化的 base64 代码,当然这才仅仅只是一个文件。所以,显然 70KB 这么大的资源不适合打包进来,虽然开发环境倒是无所谓生产环境肯定是不行的。所以在生产环境中,我们要严格控制引用优化的资源大小范围,需要这样来配置:

{
              test: /\.(png|jpg|woff|woff2)$/,
              loader: 'url-loader',
              options: {
                  limit: 15360,
                  name: '[name].[hash].[ext]',
                  publicPath: '/dist/'
              }
          }
          

还需要添加一个额外的 loader:

yarn add --dev file-loader
          

这里我限制为了 15360 个字节(15KB)大小,如果小于它效果上面已经看到了,如果大于它呢?
CSS 或者 JS 中仍然是储存的引用路径,只不过这个路径是输出后的,例如我用生产配置构建以后输出的 CSS 内容为:

body{background:url(/dist/google.a344f1b5066a77f9d829a3d5958dfc7f.png)}
          

文件被输出(实际上就是复制 + 重命名)到 ouput 中配置的目录,命名方式跟输出的 JS 是一个风格,并且 publicPath 跟 output 中所配置的一样,毕竟无论是图片等纯静态资源还是输出后的 CSS 和 JS 保持引用路径一致方便分发至 CDN。
不过为什么纯静态资源还要用 hash 命名呢?因为 base64 后的资源没有文件名所以压根没有缓存问题,而所引用的路径资源则会有。

需要提一下的就是,此处的效果就是 file-loader 的作用,跟 url-loader 是相反的又同时可以被 url-loader 调用,当然将它单独拿出来配置使用也是可以的。

最后

这篇文章是我陆续很多天不间断的更新完成的,包括代码字数已经达到 3W+ 了,日后再更新可能会更高。你们可以在浏览器控制台中看一下最新的总字数:

document.querySelector('#article-container').innerText.length
          

虽说代码不能直接算码的字,但总归代码也是我本人写的吧,毕竟全篇一块引用都没有呢。不过要注意的是,本文先阶段介绍的东西仍然是最基础必备的,更多有用、有趣和高级的东西没有集成进来,看我日后的更新了(虽然我暂时没有计划)。

如果这篇文章帮助到了你,请默默的在内心给我点个赞,因为这篇文章真的更新得好辛苦啊。为了适应所有的更新,我全手动搭建了新前端模板,日后就会把一部分精力放在博客新前端的开发上了。

谢谢默默关注我博客的大家~