进阶|基于webpack的架构与构建优化——YY-DSA搭建心得

2022-06-29 15:49:59 浏览数 (1)

今天的投稿人是鹅厂的Casta Mo

这里附上他的github链接...

https://github.com/CastaMo

欢迎留言转发

1. 项目背景  

我们前端团队近期在为腾讯云DSA业务搭建内部运营运维系统【简称YY-DSA】,既然是内部系统,我们就可以“为所欲为”地选技术栈,搭框架,但要遵循以下约定:  

- 项目架构清晰,各个模块各司其职、互不耦合或者尽可能降低耦合度

- 在确保架构稳定的前提下,尽可能提升整体的效率,包括应用程序的效率以及研发流程的效率,而牺牲流程效率来换取程序效率的做法不可取。

这期间前前后后折腾了一两个星期,从无到有,系统终于成功稳定地跑起来。在这里写一下过程中的一些心得,希望能够达给大家带来一些帮助。

2. 前端技术选型

因为技术选型会直接影响到整个项目的结构,所以在这里先谈技术选型。

2.1 MV*框架 ------ vue.js

说到MV*框架,前端开发者们都不会陌生,vue.js、react.js更是我们最熟悉的两个成熟框架。  

在这里我们选用vue作为我们的框架,其中一个历史原因是,我们团队在两者之间对vue更熟悉,这样我们团队无需再花额外的学习成本。

这是一个第三方的跑分,尽管Mithril的性能是最好的,但由于它还处于开发期间,尚未成熟,不适合应用于一些规模较大的应用中。在所有现成的解决方案,vue通过使用virtual DOM应用于视图渲染(react也如此),以此提升框架的性能,这个比一般的MV*框架做得要好。  

另外:  

- vue.js提供简洁而又健全的API,开发者容易入门,而且代码结构十分清晰,有利于提升研发效率。  

- vue.js将注意力集中在核心库,而开发者可以配套使用一些高效库诸如路由、全局状态管理等等。在项目里,我们也配套地使用了vue-router作为SPA前端路由框架。   

关于vue.js与其他框架的对比,请参考官方的:[vue.js对比其他框架](https://cn.vuejs.org/v2/guide/comparison.html)

2.2 组件框架 ------ element-ui

由于我们的内部运营运维系统无需专门的重构(UI开发)来制定组件的样式交互,而通常内部系统的使用者更关注的是页面**交互与功能**,于是我们打算考察并直接采用现有的基于vue的成熟样式组件解决方案,从而减少因造轮子带来的成本。  

我们一共调研了以下几个组件框架:  

- [bootstrap-vue](https://bootstrap-vue.js.org/)  

- [uiv](https://uiv.wxsm.space/)  

- [vue-beauty](https://fe-driver.github.io/vue-beauty)  

- [element-ui](http://element-cn.eleme.io/#/zh-CN)  

- [iviews](https://www.iviewui.com/)  

bootstrap-vue、uiv:  

- bootstrap-vue是基于boostrap4的样式,uiv是基于boostrap3的样式,作者不同,组件开发完成度也不同。比如,bootstrap-vue没有datepicker,uiv没有table,其他就不一一列举。因为组件的完整度问题,这两个就暂不采用。  

vue-beauty、element-ui、iviews:    

- 在样式风格上,vue-beauty名曰基于ant design的样式优化,不过看起来它更像是ant design的vue版本(官方的ant design是基于react进行开发的),另外element-ui、iviews都有借鉴ant design这一设计规范,这三者在设计风格上类似。不过相比之下,element-ui的尺寸规格比另外两者稍微大一点,我们认为这样看起来会舒服一点。  

- 在编程风格上,对于自定义模板,element-ui主要采用scoped-slot(vue的模板风格),iviews主要采用render函数(react的jsx风格),而vue-beauty对于前两者的方法会混用,我们在编程风格上希望以主架构(vue)作为标准,所以这里我们偏向使用element-ui的scoped-slot。  

综上所述,我们采用了element-ui作为我们的样式组件框架。

2.3 自动化工具 ------ webpack

首先我们要明确在构建过程中,我们的项目需要完成哪些步骤:

- 编译es语法、.vue单文件还有其他预编译语言等

- 模块化处理

- 压缩混淆

- 生成静态资源版本号

- 注入html

目前能自动完成上述过程并且比较出名的解决方案gulp(grunt可以不考虑了)以及webpack。从本质上来看,gulp是一个真正意义上的**自动化构建工**具,而webpack只是一个**模块化打包工具**。  

gulp是通过一系列的工作流去完成这些任务,类似于流水作业。而webpack则是从入口文件开始沿着依赖线尽可能地找到需要操作的文件,使用不同的loaders进行处理,从而完成以上任务。  

在模块打包这一点上webpack更占优势,它的处理速度以及处理能力都要优于gulp,虽然gulp具备更多脚本式的功能,但我们的项目里所用到的自动化更多是以打包为主,所以首选webpack作为我们的自动化工具。如果后续有脚本的需求,我们会考虑同时引入gulp,将脚本的工作交给gulp,而webpack仍主负责模块打包。

3. 项目结构

第2章中提及到,我们的技术栈里包含了vue与webpack,而vue的官方脚手架里包含了webpack的模板,在项目里我们就基于vue-webpack脚手架来搭建我们的项目,通过下面两个命令即可完成。  

- 安装vue-cli  

```

npm install -g vue-cli

```

- 通过vue-cli借用webpack脚手架初始化yy-dsa  

```

vue init webpack yy-dsa

```

关于脚手架具体可参考:  

vue-cli: [https://github.com/vuejs/vue-cli](https://github.com/vuejs/vue-cli)  

vuejs-templates/webpack: [https://github.com/vuejs-templates/webpack](https://github.com/vuejs-templates/webpack)

3.1  前端结构

```

├── build         // 构建工具,包括开发环境服务器,且已经为2.4章中提到的步骤均配置好了。

├── config        // 构建配置 

├── dist          // 项目构建发布目录

├── lib-dist      // 项目外部依赖库,第5章会用到

├── index.html    // 根html

├── node_modules  // node模块包

├── src           // 前端源码,下文将会仔细介绍  

|  ├── router

|  ├── pages

|  ├── components

|  ├── util

|  ├── style

|  ├── assets

|  ├── App.vue

|  └── main.js

└── package.json   // 包管理

```

这里将重点介绍前端源码的部分:  

- router: vue-router的路由配置,同时也控制着路由子页面加载的过程。

- pages: 页面子模块,在这里一个页面对应着一个.vue文件,根据路由的配置进行目录的安放,比如页面路由`/user/vip/list`对应的是`pages/vip/list.vue`这个文件。

- components: 全局组件以及根vm节点上的子vm节点。

- util: 项目内共用的工具模块,包括封装的数据请求模块DAO。

- style: 样式文件,通过css-loader直接引入到入口文件main.js即可。

- assets: 静态资源。

- App.vue: 根vm节点的源代码。

- main.js: 入口文件,用于主页面架构的初始化、各个功能模块的初始化、配置文件的执行。

与脚手架不同的是,YY-DSA新增一个pages模块,用于区别页面与组件(components)。在某种意义上,pages(页面)确实也属于组件的一种,如果使用了vue-router,我们会知道,页面会根据对应的路由以components的方式替换router-view标签中。不过在这里,我们团队更加注重将pages看作是业务逻辑模块,包括App.vue亦如此,而components应是为业务逻辑模块服务的功能模块,而且在将来项目规模逐渐扩大时,我们开发的注意力会是沿着page再到component,所以我们将pages、components分开两个模块。  

在实际操作中,在这一点上可以根据项目情况进行调整。

3.2 web server端结构

```

├── middleware      // 中间件

├── node_modules    // node模块包

├── lib             // 外部库

├── util            // 项目内共用的工具模块

├── route           // 路由层

├── dao             // 数据访问层,主要是封装了对数据库的操作

├── controller      // 控制层,也可称为路由层

├── app.js          // 入口文件

├── config.js       // 配置文件

└── package.json    // 包管理

```

对于web server不做过多的介绍。  

这里建议,如果web server与前端放在一个项目里的话,要注意做package.json的划分,简而言之就是要分两个包管理。要是前端与web server共用一个包管理的话,会大大增加包管理的复杂度

4. 前端开发环境

搭好架子之后,下面我们就开始讲前端开发环境

4.1 开发环境服务dev-server

我们借助vue-webpack脚手架初始化的项目里会自带一个开发环境的服务。

- 在根目录下输入该命令即可启动开发环境服务,位于`build/dev-server.js`  

```

npm run dev

```

这个服务除了会引入预设的开发环境webpack打包以外,还会带有三个比较重要的中间件:  

1. webpack-dev-middleware  

这个中间件通过webpack将静态资源编译至内存中进行响应服务,这样做可以去除资源写入硬盘以及从硬盘读取资源的步骤,大大提高开发过程中的编译效率以及静态资源响应效率。

2. webpack-hot-middleware  

这个中间件会在服务里监听静态资源的变更,并生成一个长连接的url入口,而页面在开发环境中会注入这个长连接链接,当页面的静态资源发生变更时,长连接url就会推送变更信息,通过webpack的HMR(Hot Module Replacement) API进行(无缝)更新,一般与webpack-dev-middleware配套使用。

3. http-proxy-middleware  

这个中间件用于动态请求的代理转发,顾名思义就是由本地服务器从浏览器接收到的请求转发请求到指定服务器上,从而达到模拟请求的效果。对于这一点的配置,可以选择在`config/index.js`上进行配置增改,也可以在`build/dev-server.js`上直接修改源码。  

参考资料:  

- webpack-dev-middleware: [http://webpack.github.io/docs/webpack-dev-middleware.html](http://webpack.github.io/docs/webpack-dev-middleware.html)  

- webpack-hot-middleware: [https://www.npmjs.com/package/webpack-hot-middleware](https://www.npmjs.com/package/webpack-hot-middleware)  

- http-proxy-middleware: [https://www.npmjs.com/package/http-proxy-middleware](https://www.npmjs.com/package/http-proxy-middleware)   

4.2 web代理配置  

我们在版本迭代过程中,一般会采用**资源重定向**的方式进行开发,将涉及到需要更改的静态资源全都重定向本地开发环境中,而保留动态请求的原始路径,从而达到模拟线上环境进行开发。这里明确一下会影响我们项目的资源路径的因素:  

1. 我们采用了基于HTML5的SPA路由模式,因此项目内会有多个不同的url路径返回页面html文件。比如/pm/test,/operate/test等,这些路径均直接响应返回index.html。

2. 项目内的静态资源均放在/static路径下。

3. 本项目中动态请求统一放在/api下。

我们在配置开发代理的时候,遇到静态资源请求,重定向到本地开发环境上,而遇到动态请求,则直连。拿我们这里的项目来讲,几乎就是要将除了`/api(.*)`的路径都重定向到本地开发环境。我们团队曾经在开发YY-CDN-Mobile(CDN业务的运营运维平台)的时候,在抓包工具里配置`/static`重定向到本地,然后每开发一个新的业务页面又往里面添加一条规则重定向到本地,这样造成的不良后果是可想而知的。  

那么有没有一个方法可以既能保证静态与动态请求分别走不同的代理规则,又能不用每次来一个新的路径就加一条规则呢?答案是肯定的。利用正则表达式的零宽断言即可解决这样的问题:

```

/yy.dsa.oa.com(?!/api)(.*)/i localhost:8080$1

```

8080端口是脚手架的开发环境默认端口,具体可按照实际变动而变动。  

这条正则表达式的作用是,匹配yy.dsa.oa.com这个域名下,第一级路径不为/api的所有url,并将路径取出填充到localhost:8080后,以此达到将所有静态资源都重定向到本地,动态请求则直连的效果。

另外,如果对于路径要求不严格的话,在规则配置上进行简化,直接将静态资源都归到一个以项目的路径下,比如`/yy-dsa/xxxxxx`,这样做的话,规则就简化成:  

```

yy.dsa.oa.com/yy-dsa/* localhost:8080/yy-dsa

```

但是所付出的代价就是,我们需要做以下几个步骤:  

1. 在vue-router的配置中添加`base`参数,例:`/yy-dsa/`。

2. 在根目录下`config/index.js`里,修改`build.assetsSubDirectory`,例:`yy-dsa/static`。  

另外还有一种办法就是借助chrome插件SwitchyOmega,做规则代理切割。chrome插件介绍不属于本文范畴,这里不作详细介绍。这些其实是一个复杂度转移的决策,至于说采用哪一种方案更好,就看项目对于哪一种方案管理起来更便捷。

参考资料:  

- [正则表达式之:零宽断言不『消费』](http://fxck.it/post/50558232873)

5. 前端构建调整

5.1 code splitting

除了一些库模块,还有一些版本号的存放模块,我们的业务代码只有app.js一个文件,当我们的页面规模越来越大时,那么这个app.js必定也会呈现增长的趋势,随着带来的问题时,下载所需带宽增加(如果是内网业务这个影响可以忽略不计),脚本解析时间增加。  

code splitting可以很好地帮我们解决这个问题,webpack支持我们通过require.ensure来进行自定义分割点,将app.js切割出多个chunk,从而实现按需加载的效果,这样一来我们的业务代码就可以按照我们预设的模块进行打包切割。

```

// router/index.js

let Vue = require('vue');

let Router = require('vue-router');

Vue.use(Router);

const requireUserVip = (component) => {

  return (resolve, reject) => {

    return require.ensure([], (item) => {

      resolve(require('@/pages/user/vip/index.js')[component]);

    }, "user-vip");

  }

};

module.exports = new Router({

  mode: 'history',

  routes: [

    {path: '/user/vip/list', name: 'user-vip-list', component: requireUserVip("list")},

    {path: '/user/vip/add', name: 'user-vip-add', component: requireUserVip("add")},

    {path: '/user/vip/edit/:app_id', name: 'user-vip-edit', component: requireUserVip("edit")},

    {path: '/user/vip/detail/:app_id', name: 'user-vip-detail', component: requireUserVip("detail")}

  ]

});

```

```

// pages/user/vip/index.js

module.exports = {

  add: require("./add.vue"),

  list: require("./list.vue"),

  edit: require("./edit.vue"),

  detail: require("./detail.vue")

};

```

这里将切割点放在一个业务模块(几个同类业务页面的集合)中,是因为一个页面大小一般不会太大,如果纯粹地让每个页面都异步加载的话,那么反而会增加HTTP请求次数,不利于系统的性能。

参考资料:

- code-splitting: [http://webpack.github.io/docs/code-splitting.html](http://webpack.github.io/docs/code-splitting.html)

5.2 模块化规范转换

因为5.1中,我们采用了`require.ensure`来进行code splitting优化,这是CommonJS的规范,为了保持项目里模块化规范一致,我们需将项目里的esmodule打包变更为commonJS打包。  

首选,我们要在根目录下找到`.babelrc`文件,在plugins属性中添加`transform-es2015-modules-commonjs`,并在`package.json`中添加`babel-plugin-transform-es2015-modules-commonjs`这个包。  

这时,我们会发现,`.babelrc`加上属性了,代码中模块化打包也改成`commonJS`规范,重新启动`dev-server`之后页面**一片空白**。  

经过一系列排查发现,一般的.js文件都能按照commonJS的规范进行打包,然而碰到.vue文件却依旧还是按照esmodule打包。于是我们猜测问题出在vue-loader上,因为它是负责编译.vue文件的。接着我们在官方文档中发现,vue-loader有个esModule属性,会影响到.vue编译后的模块化格式,文档中提及其值默认为`undefined`,但在源码中我们不难发现其实它默认为`true`。所以我们需要在`build/vue-loader.conf.js`作以下修改:  

```

build/vue-loader.conf.js

'use strict'

const utils = require('./utils')

const config = require('../config')

const isProduction = process.env.NODE_ENV === 'production'

let loaders = utils.cssLoaders({

  sourceMap: isProduction ?

    config.build.productionSourceMap :

    config.dev.cssSourceMap,

  extract: isProduction

});

module.exports = {

  loaders: loaders,

  transformToRequire: {

    video: 'src',

    source: 'src',

    img: 'src',

    image: 'xlink:href'

  },

  esModule: false // 添加esModule属性,并设为false

}

```

参考资料:  

- vue-loader: [https://vue-loader.vuejs.org/zh-cn/options.html#esmodule](https://vue-loader.vuejs.org/zh-cn/options.html#esmodule)

5.3 commonChunk优化

脚手架生成的项目里,默认会将项目里所依赖的node_modules库通过commonChunk打包到一块,这一点设计得非常好。  

然而,像对于3.1章中提及的util项目内共用的工具模块,如果我们没有对它进行特殊处理,而我们在项目里引入code splitting之后,多个业务页面代码同时引入util里的模块,webpack构建就会出现重复打包的现象,即util里同个模块可能会被多次打包到不同的chunk中,这是我们不希望看到的。因此,我们也需要利用commonChunk将util模块打包到一个公共包中,对`build/webpack.prod.conf.js`做以下修改:  

```

// build/webpack.prod.conf.js

function pathJoin(dir) {

  return path.join(__dirname, dir)

}

function isInvolve(resource, path_arr) {

  for (let i = 0, len = path_arr.length; i < len; i ) {

    let path = path_arr[i];

    if (resource.indexOf(path) >= 0) {

      return true;

    }

  }

  return false;

}

...

const webpackConfig = merge(baseWebpackConfig, {

    plugins: [

    ...

    new webpack.optimize.CommonsChunkPlugin({

      name: 'node-modules',

      minChunks: function (module) {

        return (

          module.resource &&

          /.js$/.test(module.resource) &&

          isInvolve(module.resource, [

            pathJoin("../node_modules")

          ])

        )

      },

      chunks: ['app']

    }),

// 这里多打一个common包

    new webpack.optimize.CommonsChunkPlugin({

      name: 'common',

      minChunks: function (module) {

        return (

          module.resource &&

          /.js$/.test(module.resource) &&

          isInvolve(module.resource, [

            pathJoin("../src/util")

          ])

        )

      },

      chunks: ['app']

    }),

    new webpack.optimize.CommonsChunkPlugin({

      name: 'manifest',

      chunks: ['common', 'node-modules']

    })

    ...

  ]

});

```

如果你的项目里构建出来的文件不需要带有版本号,那么util模块可以和node_modules模块打包到一起,否则,建议将二者分开,因为只要util模块稍有改动都会影响生成包的版本号,不利于缓存的命中率

5.4 外部库构建优化:Dll VS externals

在我们的项目中,有些依赖库基本是固定不变的,包括库的版本更新,比如YY-DSA中的vue与element-ui这两个直接支撑主架构的库,我们希望在构建过程中,尽量减少对这些库处理,从而提升构建效率。webpack官方给我们提供了两个解决方案,Dll与external。  

Dll

要使用Dll方法处理外部库,首先我们需要有一个配置文件帮我们对这些外部库进行打包标记,我们称之为webpack.dll.conf.js: 

```

const webpack = require('webpack');

const config = require('../config')

const utils = require('./utils')

const pathLib = require('path')

module.exports = {

  entry: {

    dll: [

      // vue默认指向esmodule打包的发布版本,要用commonJS规范我们需要手动改变一下包的指向

      'vue/dist/vue.js',

      'vue-router/dist/vue-router.js',

      'vue-resource/dist/vue-resource.js',

      'element-ui/lib/index.js'

    ],

  },

  output: {

    path: pathLib.join(__dirname, '../static/js'),

    filename: '[name].js',

    library: '[name]_library'

  },

  plugins: [

    // 为了更好看到实验对比效果,这里加上uglify

    new webpack.optimize.UglifyJsPlugin({

      compress: {

        warnings: false

      },

      sourceMap: true,

      parallel: true

    }),

    new webpack.DllPlugin({

      path: pathLib.join(pathLib.join(__dirname, '../static/js'), 'dll.manifest.json'),

      name: '[name]_library',

    })

  ]

};

```

接着,我们来运行该配置文件

```

webpack --config webpack.dll.conf.js

```

与此同时我们还会看到还有一个`dll.manifest.json`的文件生成,这个文件包含了模块经过webpack处理后的id,在业务代码里模块引入即`__webpack_require__ `,会把将外部库的名称转化为对应的模块id来指定到对应的外部库中。  

紧接着,我们要在构建配置中的`plugins`,加入`DllReferencePlugin`插件。  

切记要把dll生成的包拷贝到dist中,这里我们是将包放到根目录的static中,而脚手架中已经默认在构建过程中帮我们把static拷贝到dist中了。

```

// build/webpack.base.conf.js

module.exports = {

    ...

    plugins: [

      new webpack.DllReferencePlugin({

        manifest: require("../static/js/dll.manifest.json")

      })

    ]

    ...

    // 将根目录的static目录拷贝到dist中

    new CopyWebpackPlugin([

      {

        from: path.resolve(__dirname, '../static'),

        to: config.build.assetsSubDirectory,

        ignore: ['.*']

      }

    ])

  }

}

```

在根目录下执行构建:  

```

npm run build

```

externals

external的工作原理很简单,大概就是我们在项目加载中,提前下载我们所需要引入的外部库,然后告诉webpack有哪些库是放在外部引用而无需内部再次构建的。下面将以externals的全局变量模式进行实验:

- 在找到对应外部库的min.js,发布到线上环境中。并且安放到3.1中提到的`lib-dist`中,保持版本对外部库有跟踪。

- 将外部库地址写入index.html中。 

```

<!-- vue全家桶放一起 -->

<script type="text/javascript" src="//yy.dsa.oa.com/lib/vue-all.js"></script>

<script type="text/javascript" src="//yy.dsa.oa.com/lib/element-ui.js"></script>

```

- 在 `build/webpack.base.conf.js` 中,配置 externals 属性。

```

// build/webpack.base.conf.js

module.exports = {

  ...

  externals: {

    'element-ui': 'ELEMENT',

    'vue': 'Vue',

    'vue-router': 'VueRouter',

    'vue-resource': 'VueResource'

  }

  ...

};

```

以上为使用externals处理外部库的三个步骤。   

externals还会有其他类似commonjs、amd等的引入形式,这里不一一介绍。

接着,我们在根目录进行构建:

```

npm run build

```

以下是externals的工作原理,以`Vue`框架为例:  

```

dist/static/js/app.js

// 引入Vue的代码段

var Vue = __webpack_require__("lRwf");

...

// externals做的特殊标记,将暴露到全局的Vue包装到标识符为"lRwf"的模块里

"lRwf":

/***/ (function(module, exports) {

module.exports = Vue;

/***/ })

```  

normal

为了能看到以上两种方案对构建效率的提高,我们在不作任何优化的情况下进行构建

实验结论

- 从构建效率上看,Dll与externals方案均要比原来优,而externals在效率优化上更胜一筹,这跟Dll在发布时仍要将外部库拷贝到dist中的操作有关。

- 从项目的配置管理来看,Dll方案仅需修改配置文件、重新生成包 两步即可,而externals方案则需要三步操作,这一点Dll方案更胜一筹。

最终我们项目里采用的是externals方案,也就是我们选择了构建性能更优的方案,因为我们认为引入外部库一般是针对主架构相关的库,修改这些库本身是一些低频操作,但构建却是高频操作,从长远来看,选择externals对我们带来的效率收益比Dll要高。

参考资料:

- DllPlugin: [https://doc.webpack-china.org/plugins/dll-plugin/#dllreferenceplugin](https://doc.webpack-china.org/plugins/dll-plugin/#dllreferenceplugin)  

- external: [https://doc.webpack-china.org/configuration/externals/](https://doc.webpack-china.org/configuration/externals/)

总结

- 技术选型不一定要最先进,符合自身实际需求即可。  

- 团队开发项目更加应注重的是让事情变得简单,获得最大的总体收益,所以在做性能优化之前三思,是否会影响到架构的复杂度。  

- 坚持探索小细节总会有大收获。

最后,感谢导师 @alsotang 在项目搭建以及优化过程给予各种帮助。

alsotang的github:

https://github.com/alsotang

欢迎留言转发

0 人点赞