小型 Web 页项目打包优化方案
背景
目前团队中新的 Web 项目基本都采用了 Vue 或 React ,加上 RN,这些都属于比较重量级的框架,然而对于小型 Web 页面,又显得过大。早期的一些项目则使用了较原始的 HTML 页面构建技术,但业务逻辑基本无法复用。
近半年做过几个小型 Web 页面,在不断学习前端知识的同时,也在重构并摸索小型 Web 项目可能的更好解决方案。本文则对之前的工作进行一次整体描述。
目标和定位
单论小型 Web 页面,其相对于 Vue/React 等项目最大不同是不需要支持 SPA 这种比较重的形式,以 MVP(Minimum Viable Product) 为原则,小页面只要满足需求,做到够用即可。所以在对现有原始 Web 页面进行重构时,会将以下两个方面作为最高优先级:
-
不断提高项目的重用性、可维护性;
-
不断提高前端性能,这里主要是加载性能;
对于第一点,组件化代码结构是当前最可行的思路;对于第二点,在做到第一点的前提下,极少的第三方依赖,良好的打包方法,是必须要做到的。
项目结构演化历程
本文所描述的小型 Web 页项目结构和打包方法是经过若干次项目重构才得到的。
第一版
第一版项目基本上以最原始的 HTML+JS+CSS 为基准。为了让项目代码更好维护,首先考虑到的是有两个点需要做:
-
使页面内容具有维护性,需要采用 JS 模板;
-
由于业务复杂,分工较细,接口繁多,需要将数据接入层
DAL
(Data Access Layer) 单独分离出来;
对于第一个问题,最后选择 Mustache
库。原因是它语法极简,容易学习,同时该类型语法有广大用户群体,当然同样流行的还有 underscore
/ejs
类型的模板语法。为了保证内容页面的无逻辑性和简单,故 Mustache
的高级版 Handlebars
未被使用。
第二个问题是对公司业务和项目代码有所了解后所下的结论。相当于是对现有代码的重构,主要目的是进行职责分离,将复杂多变的接口隔离出来,让剩下的代码专心解决业务问题。
开始敲代码后,才发现另一个比较严重的问题:我需要把管理内容模板的代码单独分离出来,使其不会影响主要的业务逻辑,于是想到了 MVP(Model View Presenter) 模式。简单讲,这个就是 MVVM 模式去掉 View-ViewModel 双向数据绑定后的一个弱化版。如下图所示:
在小型 Web 页面中,一般是没有 Model 层的。页面中的 Presenter 部分只负责通过参数控制界面的渲染,并以组件的方式对外公开 View 层事件。按照这个思路,第一版项目结构就基本出来了,见下图:
引入 Webpack 2+
第一版项目结构已经足以应付小型 Web 页面的需求了,同时也不会带来较多的复杂性。但是原始 Web 页面天生就不利于模块化开发,同时存在一个根本性问题:
代码解耦会使项目文件结构清晰,职责分离,有利于维护;
打包结果需要将相关代码压缩到单个文件中,便于提高加载性能;
在 Web 页面开发中,这两者形成一个悖论。所以需要引入一个打包机制,将项目代码和打包文件进行解耦。年老的 Gulp 和 Grunt 就不看了,现存项目中用的较多的是 FIS3 和 Webpack 1.x。前者国产,使用起来也非常方便,后者难度高一些,但是跟其他国外开源项目一样,他们总能把一个软件的 50% —— 文档做的很好。(其实还有很多可以吐槽的地方,但看了之后感觉相对更踏实)
在看了 Webpack 2.x 的文档之后,基本就确定使用该打包机制了,它有如下优点让人欲罢不能:
-
原生支持
import
语法。这样就彻底摆脱文件结构不好管理的问题了,面向对象、模块化什么的统统都可以引进来,终于可以舒舒服服写代码了; -
支持 Tree Shaking。本来这是 rollup 打包机制独有的特点,现在 Webpack 也有了;
-
Webpack 的配置文件虽然复杂,但了解之后再配合插件机制,会发现它潜力很大,使用也相当灵活;
在引入 Webpack 2.x 后,不同功能均以单个文件的形式进行分开,各模块之间接口也变得非常明确,但还有待改进。
参考 Vue 项目结构
加入打包机制后,JavaScript 文件已经解耦的不错了,但是模板还都放在了首页中,样式也都放在了一个文件中,依赖关系混乱,不方便管理。改良它的一个好方法是参考其他优秀项目,比如 Vue 就有一套很好的项目组织结构,直接借鉴就行了。新的项目主要变化如下:
-
真正将组件分离出来。组件内容采用
Mustache
模板,样式采用Less
语法,JS 部分则控制组件的渲染逻辑,尽量不关联业务逻辑,三者合一就相当于一个 .vue 文件了。通过修改 Webpack 配置或使用合适的插件,该方式可以同样支持其他模板和 CSS 语法,比如ejs
或者SCSS
; -
选择支持多页面入口,而没有采用路由功能。这样可以简化 SPA 中复杂的 URL 结构,同时打包结果也不用附带路由逻辑。这样还有一个好处是后期引入简单版的 SSR 也会很方便,路由就是 nginx 的事儿了;
对于该部分项目结构的详细描述直接看下文。结构图如下:
需要说明的是,图中的 State Store
其实目前是没有的,放在这里主要是为了好看 :)。后期如果把 vuex/MobX/Redux 之类的加进去了,那就完整了,目前因为业务逻辑很简单,状态什么的暴力解决就行了。而 app.js 则处理项目中公共的业务逻辑,让页面入口解脱出来专心处理内容。
项目结构描述
目录结构
项目目录结构如下:
----build # Webpack 配置文件
...
----src
--------assets # 资源文件
--------components
------------GoodsInfo # 商品信息组件
GoodsInfo.mst # 组件模板,采用 Mustache 语法
GoodsInfo.js # 组件渲染和操作逻辑。一般业务无关
GoodsInfo.less # 组件样式
------------RiskPromt
...
------------ShareHeader
...
------------SharePanel
...
utils.js # 业务无关,视图层相关的辅助方法集合
--------dal # 数据接入层
index.js # 入口文件。集中管理请求接口和伪数据
getInfoById.js # 接口请求实现
getInfoById.json # 接口返回伪数据,在 index.js 中可生成 mock 方法
--------Main # 默认页面入口
Main.html # 页面模板
Main.js # 页面业务逻辑
Main.less # 页面样式
--------MainBanner # 带有底部 Banner 的页面入口
...
app.js # 抽取多页面共有的业务逻辑,比如分享功能的具体实现
common.js # 应用级的辅助方法集合
common.less
package.json
README.md
第三方依赖
在 Webpack 2+ 的帮助下,项目选用了如下开源第三方库作为基础依赖:
-
es6-promise
:采用 Promise 的方式可以使代码更清晰更好维护; -
axios
:Vue 官方推荐的 vue-resource 替代品; -
mustache
:项目所用的模板库
另外还使用了团队维护的 SDK:
-
@zz/zz-jssdk
:提供 Web 页面和转转 App 客户端的交互接口 -
@zz/perf
:性能统计工具
由于 axios
官方坚持不集成非标准的 jsonp
请求,对于现存部分只支持 jsonp
请求的接口,还需要引入 jsonp
第三方开源库。
以上是项目文件依赖。开发依赖中,所用的第三方库基本都是 Webpack 相关,包括 Less
文件的解析模块。项目没有引入 babel-polyfill
进行 ES6 语法的开发,因为容易产生不必要的额外打包代码。
文件加载规则配置
在 Webpack 的语义下,所有的项目文件都是一种资源,供 JavaScript 使用,所以处理任何资源时,只要配置好合适的 loader 即可。该部分则对项目中不同类型文件的加载和解析规则配置进行了简要描述。这里不会讲解 Webpack 配置细节,相关内容请查看官方文档。
-
资源和图片
对于一般资源文件的加载,采用 file-loader
即可。对于图片文件,采用推荐的 url-loader
。该加载器有一个选项是,如果图片小于指定值,会将其转化为 DataUri
嵌入到打包文件中,以减少额外 HTTP 请求,项目设置指定值为常用的 10K。规则如下:
-
样式文件
项目中样式文件默认采用 Less
,主要用到该库的两个特性:
-
可以方便的使用 CSS 变量,典型的比如定义通用像素大小;
-
层次化的样式描述方式;
Webpack 配置同时保留了 css 文件的加载能力,后期还可以加入对 SCSS
文件支持。规则如下:
同一个项目中,由于 CSS
/LESS
/SCSS
文件之间具有依赖关系,所以强烈推荐采用同一种技术实现。对于单个组件,不大可能像 Vue 一样写个 Webpack Loader 支持 .vue 类型的组件格式。样式文件的加载需要在对应的 .js 文件中显式引入 .less 文件,比如:
-
模板文件
项目模板默认采用 Mustache
,在 Webpack 的支持下,模板内容被单独放在一个文件中,并以 .mst
作为自定义后缀,文件内容依然是 HTML 格式,只是根标签为 <template>
。Webpack 中选用 html-loader
对其进行解析,规则如下:
对于 Mustache
模板的自动解析和加载,网上有开源的 mustache-loader
实现,但其关注度实在太低,而 html-loader
足以达到所需功能:
-
加载 .mst 文件,并压缩内容;
-
将文件中
img:src
等相对路径属性自动替换为绝对目标地址;
对于其他模板语言同样可以使用这种方法,就可以在项目中灵活的使用不同的模板库了。不过需要注意的是,同一个项目中最好只使用一种模板语言,方便管理,同时不会增加打包文件大小。
将 .mst 模板加载到页面中和 .less 方法差不多。在对应的 .js 文件中显式引入,然后用 extractTemplate
方法提取出模板内容即可:
这种显式引入的方式有一个好处是,可以手动控制不同的模板和样式。在实际产品需求中,内容和样式改变是很频繁的,而功能逻辑的变化相对要慢一些,这样通过 js 引用不同版本的模板和样式就会比较灵活。如果能把这一套管理机制抽象出来单独进行配置也是很不错的。
-
页面文件
页面文件在 Webpack 中也是以模板的角色存在的,解析方式和模板一样,规则见上文。由于是页面入口文件,在 Webpack 中还需要使用 HtmlWebpackPlugin
插件进行配置。如下配置中,项目存在两个不同的页面入口,所以需要两个 HtmlWebpackPlugin
实例:
由于用户每次进入 Web 页面都会加载首页,所以首页越小越节省流量。参考 Vue 项目的 index.html
就会发现里面基本只有一个骨架,具体内容都在组件中。但项目配置本身不会对这点进行假设,所以即使在首页中写入所有内容也是可行的。
打包
项目的主要打包配置前文已经介绍差不多了,其他具体配置参看官方文档即可。采用该项目结构的最后打包结果,所有部署文件包括图片加起来没有超过 130K。在浏览器中,因为 gzip 的原因,全页面加载网络流量不到 70K。
数据接入层
前文已经提到过,把数据请求单独作为一个层主要是为了分离出复杂多变的数据请求接口,还有一个好处是接口 mock 数据也可以在这里统一处理。
接口封装
一个项目中可能在很多地方都会请求同一个接口。对于单个接口请求,可能有不同方法,比如用 ajax、fetch、jsonp、axios 甚至 jQuery 库;有的是 GET,有的是 POST;有的还需要带 cookie,其他却不需要;返回数据的格式也许还不是统一的。而 JavaScript 逻辑只关心输入和输出,把这些请求细节都放在另外一个地方单独维护,会使主要业务逻辑更加简洁。在项目中使用时,只需要以 Promise
的形式调用方法即可。接口封装的示例代码如下:
封装 mock 数据
前后端协同开发时,需要首先定好接口,给出 mock 数据示例。所以在 DAL
层把 mock 数据封装好,会节省很多工作。在项目中会将 mock 数据直接保存成 .json 格式的文件,然后在 DAL 入口文件中通过 import
导入,再使用一个工厂方法来对外提供 mock
方法,即可使用 mock 数据了。下面是入口文件中相关代码:
通过 DAL 使用接口
有了 DAL
层对各请求接口的聚合,在其他地方使用就比较简单了,直接上代码:
组件化开发
小型 Web 页面的组件和 .vue
文件结构类似,只是分成了三个文件:
-
样式。内容和使用方式是基本一样的;
-
模板。后者
Vue
有自己的模板语法,前者则用的Mustache
,也可支持其他模板。如果Vue
的模板加载器单独分离出来,那理论上也是可以拿过来使用的; -
控制逻辑。JS 逻辑部分则有些不一样,
Vue
框架有着自己独特完美的双向绑定机制,其接口和生命周期也是围绕它来设计的(这里只针对.vue
文件进行讨论,类React
使用方式很大程度是为了方便拉取用户而设定)。小型 Web 页面因为简单,所以重心都放在了组件初始化和渲染上;
组件在小型 Web 页面中定位是很明确的,即只针对页面呈现和交互,所以对外接口的设计也不复杂。如果组件采用的是 MVC 模式,那就很难讨论,因为 Controller 本身就是“老大”,可能有很多行为。Presenter 和 ViewModel 则相对简单,它们的区别只是内在机制不同,对外是行为是差不多的。这里不考虑大型 Web 页面,小型 Web 页中组件的接口默认就两种:接受纯数据参数(props
);对外公布事件接口。相比于更高级的 Vue,少了一个 Slot
插槽功能。
使用组件
使用组件的方式很直接,看代码:
组件中一个 init
方法并不能搞定全部需求,因为项目中 init
方法不仅包含了组件渲染逻辑,还有事件绑定逻辑。当组件数据内容更新时,还需要抽取出一个 render
或 update
方法单独进行调用来更新界面。这不像 Vue 自带双向数据绑定神器,所以要麻烦点。
使用组件提供的事件也很简单,代码如下:
这里事件句柄的参数采用了 (Object data, Event e)
的形式。其中 data
表示事件来源,它可以是被点击对象的 ViewModel
,或者简单点,直接是被点击对象所代表的的原始数据;e
则是 HTML 的事件参数。
组件的参数处理和渲染
组件内部绑定到具体的模板前文已经示例说明过了。在渲染组件内容时,还需要处理参数内容,并将其渲染到页面指定地方。这里直接上代码:
在构造器中,首先定义 props
参数的格式,并给上默认值。在 init
方法中,则将 data 中的参数赋值给 props
,这里一般是会有数据转化处理逻辑。
最后直接进行组件渲染。可以发现,如果想要使用其他模板引擎,是很容易替换的。如果采用 SSR 服务端渲染组件,那可以各种模板库全放进来,一个工厂方法就可以进行自动化处理。
组件的参数被取名为 props
,完全是仿造 Vue
/React
。因为它们的功能和定位基本是一样的,而且官方推荐的最佳实践这里也基本都推荐。具体这样做的几点思路如下:
-
小项目做不到
Vue
/React
的参数验证功能,但显式表示props
参数有自描述文档的作用,需要哪些参数及其类型一目了然; -
构造器中同时给出了
props
默认值,无参数时组件有默认展示形式; -
参数只有一个
data
对象。Vue
推荐参数都用基本类型,但内容庞大时,属性繁多,分割成更小组件也不会减少多少使用的复杂性; -
props
中的每一个属性不能是对象,只能是Integer
、String
、Boolean
、Array
等基本类型;
对外公布事件
将事件的触发封装到组件中也是为了减少业务的复杂性。很多 Web 项目中都是直接操作页面内容,用户交互、内容处理、业务逻辑都耦合在了一起,这里组件将用户交互封装起来,同时对外提供事件接口。代码如下:
组件内部保存一个事件回调句柄 clickCallback
,组件初始化时对用户点击事件进行数据绑定,并触发这个回调。
结论
本文简单描述了小型 Web 页面的定位,通过对小型 Web 页面的摸索和演化解释了当前项目结构的设计思路,并对其细节进行了详细描述,重点介绍了数据接入层和组件化开发。
当前的项目并不是最终形态,而只是一个 α 版本的雏形,还有很多地方值得改善:
-
针对首屏时间进行优化,比如支持 SSR;
-
继续改善打包部署方案,灵活支持多页面部署,达到或接近离线应用的效果;
-
一些好的 ES6 语法很值得支持,需要找到一个方法在打包层面上渐进式的引入特定语法;
-
基于 Promise 的语法值得大面积采用,这是代码层面需要考虑的;
-
Webpack 挺好,但还不够好,希望插件能更成熟更丰富;
可能还有很多点没考虑到,不过实际需求永远是最高优先级。只要不断的重构和改善,软件就会一直有生命力~