关于前端工程化的思考及《webpack多页应用架构系列》mock开发环境改造

Posted by Nutlee on 2016-10-24

最近看了 array_huang《webpack多页应用架构系列》颇有些感悟,加上入行两年多了,虽然自认为还算勤恳,奈何天赋过低,一直工作内容又“层次”不高,准确说是一直重复重复重复。直到这次跳槽以后,难得有段时间清闲,让我好好思考了一下关于前端工程化这个问题。

本文先会先介绍前端工程化的相关理解,然后是对于 array_huang《webpack多页应用架构系列》个人做的一些修改及补充。

才疏学浅,如有冒犯,通知即删,如有纰漏,还请指正,谢谢❤️

前端工程化

首先,前端工程化是什么?

做过后端的同学都会明白,对于一个再弱小的项目而言,后端架构都是要斟酌一番的,区别只是说架构复杂还是简单。然而,对于 “年轻” 的前端而言,每个人都是从手写 HTML、CSS、JavaScript 开始的,甚至说如果你不进一个 “像样的” 公司,你有可能一辈子都在庸庸碌碌的手写这 ”三驾马车”,项目中各种页面各种第三方库随意引用,各种代码不断重复手写,然后严重后端依赖 “套页面”。

因此前端工程化广义上是包括前端架构的,当然除此之外还包括前端 UI 的组件化复用、自动构建、自动化打包和自动化测试等一系列的流程设计。

前端工程化有什么用呢?

首先要强调的是前端工程化是为了项目的可维护性,换言之,并不是必不可少的,不工程化全手写用到什么功能引什么插件行不行?可以,但是这也就意味着你要么是做的一个 “万年不会更新” 的网页,要么说明你们的项目严重依赖后端。也就是说对于一个需要长期维护并且设计的就是前后端分离的项目而言,前端工程化基本上是必须的。

模块化

使用 CommonJS/AMD/CMD 来高度抽象代码逻辑和依赖关系,最典型和最简单的使用就是 require.js,具体变现为摒弃了传统的页面 js 堆砌方式,只通过一个 js 文件(入口文件),其中使用 CommonJS/AMD/CMD 来定义、暴露js 接口,同时显式的引用、管理依赖。

引用 GitHub 上一位开发者说的

很多人觉得模块化开发的工程意义是复用,我不太认可这种看法,在我看来,模块化开发的最大价值应该是分治,是分治,分治!(重说三)。

不管你将来是否要复用某段代码,你都有充分的理由将其分治为一个模块。

个人理解模块化最重要的几点优势

  • 按需加载
  • 按层抽象
  • 作用域隔离
  • 依赖隔离

关于 CommonJS/AMD/CMD 等规范及模块化的更多知识,可以看阮一峰老师的《Javascript模块化编程》系列

组件化

此处所谓的组件化并不是简单粗暴的像传统的 jQuery 插件那种一个 css 、一个高度封装的 js ,页面用到时直接引用。而是组件作为一个完整的个体从 UI 到交互都可以独立展示,然后通常利用模板/CommonJS 等特性来拼装加载的,从构建出来的项目上看是毫无痕迹的。

另外就我个人的理解,组件化是离不开模块化这个前提的,基本上一个好的组件也一定是模块化的。模块化是对资源加载的优化,组件化侧重对整个业务逻辑的整理和掌控。

特点

“资源高内聚”—— 组件资源内部高内聚,组件资源由自身加载控制
“作用域独立”—— 内部结构密封,不与全局或其他组件产生影响
“自定义标签”—— 定义组件的使用方式
“可相互组合”—— 组件正在强大的地方,组件间组装整合
“接口规范化”—— 组件接口有统一规范,或者是生命周期的管理

web组件化,最简单的例子就是真阿当老师慕课网的教程阿当大话西游之WEB组件,虽然有点年头但对理解组件化非常好。

所以整体上讲,按我个人的浅显的理解,前端工程化离不开 UI 分割、静态资源加载优化、功能组件化分割,自动化这么几部分。

当然还有关于测试、代码审查、持续集成等等这部分就扯远了。

webpack

基础

简言之 webpack 是一个优雅的模块化加载工具,你可以用它来加载几乎所有的静态资源,把所有引用的加载全部聚合到 js 里,维护性大大增强。

关于 webpack 的基本参数和基本教程就不赘述了,具体可以看 array_huangwebpack多页应用架构系列(二):webpack配置常用部分有哪些?

这里引用下原作者该项目解决的痛点

SPA好复杂,我还是喜欢传统的多页应用怎么破?又或是,我司项目需要后端渲染,页面模板怎么出?
每个页面相同的UI布局好难维护,UI稍微改一点就要到每个页面去改,好麻烦还容易漏,怎么破?
除js外的资源如css/less、图片、swf、字体等,要怎么打包呢?不然老是要外部引用,迁移、部署起来都好麻烦呢。
某些年久失修的jQuery插件怎么在webpack里使用呢?
能不能整合进ESLint来检查语法?
能不能整合postcss来加强浏览器兼容性?
部署代码的时候如何清除用户浏览器遗留下来的上个版本的缓存?

稍微补充下几个容易产生小困惑的地方

  • entry 的参数 publicPath

    在部署时需要改为公网地址,如果使用 webpack-dev-server 无法局域网访问,尽量不要改这里,去修改 webpack-dev-server 的 host/publicPath。

  • entry 的 hash 和 chunkhash 区别

    hash 是整个 webpack 编译时产生的一个共用 hash,chunkhash 是对应的 chunk 修改时产生的 hash。换句话说如果静态资源 a.js、b.js,不管修改哪一个,他们对应的都是相同的 hash,其中任一个发生改动两者的 hash 都会相同变化。而 chunkhash 则只和 chunk 本身是否改动有关,通常也是不同的。

  • entry 的参数 chunkFilename

    这个参数是为了将 CommonJS 中按需加载的 js 单独插入 script 标签引用的,即对使用 require.ensure 引用的依赖的命名规则,可以使用 ‘[name][hash:5].js’ 这种值。

  • html-webpack-plugin 插件

    html-webpack-plugin 的参数中,原作者通过遍历 entry 中的 chunk 数组,实现批量拼装模板,这其中

    1
    2
    3
    filename: `${page}/page.html`
    # 等同于
    filename: page+'/page.html'

    ES6 的模板字符串,类似 Linux bash/shell 命令中反引号、大括号的用法。使用反引号 ( ) 来代替普通字符串中的用双引号和单引号,使用特定语法(${expression})作占位符。

《webpack多页应用架构系列》的一些深入理解

一直以来都以为 webpack 只适合单页应用或者 React/Angular 用,直到我发现了 array_huangwebpack 脚手架 ,设计的很合理,而且很适合我这种写着传统多页项目且刚刚接触 webpack 的人。

  1. entry 入口

    webpack 的 entry 入口决定了打包页面 js 逻辑 ,即对应目录下的 page.js 用来绑定事件、加载该页的 css 等等。

    page.js 只是打包网页中使用的 js 及 css ,是为当前网页服务的。

    构建过程( “==>” 表示在前者引入了后者):

    entry入口 ==> page.js ==> common.page.js (cp) 加载所有页面共用的 js、css、bootstrap依赖,加载非 npm 管理的库。

  2. 模板拼接

    利用 webpack 的插件 html-webpack-plugin 来做模板拼接,通过遍历 entry 中的数组创建多个 html-webpack-plugin 对象,其中 template 参数通常是接受一个模板,此处比较巧妙的通过 js 来操作模板来完成页面拼装,这里的 js 都是为了拼接、引入服务的,并不是项目的业务逻辑,同时通过 chunks 参数引入 entry 里打包的 chunk,完美实现了 UI 构建。

    构建过程

    • 循环创建 html-webpack-plugin 对象添加每个页面目录中的 html.js

    • html.js 引用该页的 content.ejs,向共用的 html.js ( laytout/layout-without-nav),传入 title、content

    • html.js ( laytout/layout-without-nav) 中引用 common.config.js ( 拼装针对IE兼容性等条件加载路径数据) 和 without-jquery.js ( 创建部件的模板 a 标签指向路径所需的拼装方法 )。最后合并产生一个 renderData,由该页面所使用的 layout 中的 html.ejs 来进行渲染。

      简单说就是,每个页面目录下的 html.js 是用来通过一系列数据拼装,通过 layout 模板来构建页面的。除了拼装了部分 Bootstap 条件加载 js 的路径,并没有涉及任何的 js 加载,只是单纯的为模板服务,这是与 page.js 最大的不同。

补充

原作者的脚手架中

  • 开发环境:纯静态资源(未压缩)本地调试(项目中没有任何的服务器环境)
  • 生产环境:在 webpack-dev-server 下部署的生产需求(已压缩)的代码

这和我的使用习惯略有冲突,我个人习惯

  • 开发环境:项目中同时集成有完整的服务器环境( Node.js mock 环境 ),同时预留接口方便与后端联调,且代码是未压缩的原始的。
  • 生产环境:进行了完整的打包优化等处理的部署可以直接其他服务器的代码。

也就是说我和原作者这方面的需求基本上是相反的,所以需要我对代码修改,以适应此项目的开发环境既能热更新预览代码,又能具有完整的 mock 环境。

同时,原作者的热更新只是针对静态资源,所以,在集成了服务器环境后,还要加一个对服务器文件的监控,以便修改、增加接口服务器可以自动重启。

方案

  • 修改使默认使用 dev 的 webpack.config.js

    原作者的方案中 package.js 里

    1
    2
    3
    4
    5
    "scripts": {
    .......................................................
    "start": "webpack-dev-server --inline --progress --compress --devtool eval --content-base build/",
    ......................................................
    }

    这里 start 启动 webpack-dev-server ,默认是使用项目中的 webpack.config.js 这个配置文件,在此处对应的就是生产环境的配置。显然不是很合适,所以此处我们先把原作者项目中根目录下两个文件改改名

    1
    2
    webpack.config.js ==> webpack.product.config.js
    webpack.dev.config.js ==> webpack.config.js

    然后我们把 start 的值先改为空,后面再说填什么。

    事实上我后面发现,不改也行,不过我强迫症还是喜欢默认是开发环境,各位酌情修改吧。

    改完名字还需要把 package.js 里 scripts 其他字段里的值做适当修改。

  • 使用 Node API 启动 webpack-dev-server,同时使用 express 再启动一个服务来做接口模拟,还有集成服务器自动监控刷新

    为了启动 webpack-dev-server 做热更新,同时还要有一个服务器环境做 mock,需要用 Node API 来启动 webpack-dev-server。查看官网可知:

    add an entry point to the webpack configuration: webpack/hot/dev-server.
    add the new webpack.HotModuleReplacementPlugin() to the webpack configuration.
    add hot: true to the webpack-dev-server configuration to enable HMR on the server.

    官网这个说明太简略了,我查了各种资料才搞明白。针对原作者这个项目:

  1. 需要将现有的 entry.config.js 文件,复制创建 entry.dev.config.js 和 entry.product.config.js 两个文件,分别在 webpack.config.js 和 webpack.product.config.js 里引用,将 entry.dev.config.js 中的

    1
    2
    3
    pageArr.forEach((page) => {
    configEntry[page] = path.resolve(dirVars.pagesDir, page + '/page');
    });

    修改为

    1
    2
    3
    pageArr.forEach((page) => {
    configEntry[page] = [path.resolve(dirVars.pagesDir, page + '/page'),'webpack/hot/dev-server','webpack-dev-server/client?http://localhost:8080'];
    });

    就是在页面中引入 socket.io 需要的js。

  2. 增加热更新插件

    在 plugins.config.js 中 var configPlugins = [] 里增加一行

    1
    new webpack.HotModuleReplacementPlugin(),
  3. 创建 Node API 引用,同时增加对服务器文件监控自动重启

  • 安装依赖

    1
    2
    3
    4
    5
    6
    7
    8
    # express 框架为了做服务器
    npm install express --save-dev
    # 监控服务器代码变动重启服务器
    npm install supervisor -g
    # 处理windows和其他Unix系统在设置环境变量的写法上不一致的问题
    npm install cross-env --save-dev
    # mockjs 用来 mock 数据生成
    npm install mockjs --save-dev-dev

    原作者项目中移除了本地安装的 webpack 和 webpack-dev-server,根据我的测试,还是需要安装的,根据个人情况安装吧。

    1
    2
    npm install webpack --save-dev
    npm install webpack-dev-server --save-dev
  • 在根目录下创建 webpack.dev.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
    var webpack = require('webpack');
    var WebpackDevServer = require('webpack-dev-server');
    var config = require('./webpack.config.js');
    module.exports = function(app) {
    var server = new WebpackDevServer(webpack(config), {
    contentBase: 'build/',
    historyApiFallback: false,
    devtool: true,
    hot: true,
    quiet: false,
    noInfo: false,
    publicPath: 'http://192.168.2.66:8080/', // 此处填你的局域网 IP ,在我的代码中进行了抽离
    proxy: {
    '*': {
    target: 'http://localhost:3000',
    secure: false,
    }
    },
    stats: { colors: true }
    }).listen(8080, '0.0.0.0', function() {
    console.log('socketio listen 8080');
    });
    }
  • 再在根目录创建主启动 js app.js,内容如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    var express = require('express');
    var app = express();
    var webpackServer = require('./webpack.dev.server');
    // 抽离的路由文件
    var routes = require('./routes/api');
    webpackServer(app);
    app.get('/', function (req, res) {
    res.send('Hello World!');
    });
    app.use('/', routes);
    app.listen(3000, function () {
    console.log('The app listening on port 3000 was started!');
    });
  • 根目录创建文件夹 routes,此文件夹下可以用来放各种模拟接口,在其中创建 api.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
    var express = require('express');
    var mock = require('mockjs');
    var router = express.Router();
    router.route('/api/test')
    .get(function(req, res, next) {
    var data = mock.mock({
    // 属性 list 的值是一个数组,其中含有 1 到 10 个元素
    'list|1-10': [{
    // 属性 id 是一个自增数,起始值为 1,每次增 1
    'id|+1': 1
    }]
    });
    res.send(data);
    });
    router.route('/api/')
    .get(function(req, res, next) {
    res.send('Hello API!');
    });
    router.route('/api/post')
    .post(function(req, res, next) {
    res.send('Got a POST request');;
    });
    module.exports = router;
  • 最后再修改一下启动 package.js 中的启动脚本

    1
    2
    3
    4
    5
    6
    # supervisor 用 -i 来忽略文件
    "scripts": {
    ..........................................
    "start": "cross-env NODE_ENV=dev supervisor -i build,node_modules app",
    ........................................
    }

此时,就可以启动了。

1
npm run start

现在访问 http://localhost:8080/index.html 可以看到页面。

访问 http://localhost:8080/api/test 可以看到模拟的接口数据。

尚存问题

  • 由于使用的 html-webpack-plugin 拼接出来的 HTML,所以所有的 HTML 并没有监控,需要手动在浏览器刷新。

  • html-webpack-plugin 动态加载 chunk 并打上了 hash,导致每次任意一个 chunk 有改动,所有的 chunk 文件都会打上相同的 hash,对严格控制版本的情况不太合适。


附上我的项目地址 Nutlee/webpack-seed,如有不同以 GitHub 中为准,此项目还在持续更新中。


参考资料:

webpack多页应用架构系列(一):一步一步解决架构痛点
Array-Huang/webpack-seed
前端工程——基础篇
《Javascript模块化编程》系列
阿当大话西游之WEB组件
在express服务中搭建webpack-dev-server
Express结合Webpack的全栈自动刷新