小型 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 配置文件n ...n----srcn--------assets # 資源文件n--------componentsn------------GoodsInfo # 商品信息組件n GoodsInfo.mst # 組件模板,採用 Mustache 語法n GoodsInfo.js # 組件渲染和操作邏輯。一般業務無關n GoodsInfo.less # 組件樣式n------------RiskPromtn ...n------------ShareHeadern ...n------------SharePaneln ...n utils.js # 業務無關,視圖層相關的輔助方法集合n--------dal # 數據接入層n index.js # 入口文件。集中管理請求介面和偽數據n getInfoById.js # 介面請求實現n getInfoById.json # 介面返回偽數據,在 index.js 中可生成 mock 方法n--------Main # 默認頁面入口n Main.html # 頁面模板n Main.js # 頁面業務邏輯n Main.less # 頁面樣式n--------MainBanner # 帶有底部 Banner 的頁面入口n ...n app.js # 抽取多頁面共有的業務邏輯,比如分享功能的具體實現n common.js # 應用級的輔助方法集合n common.lessn package.jsonn README.mdn
第三方依賴
在 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。規則如下:
{n test: /.(png|jpe?g|gif|svg)(?.*)?$/,n loader: url-loader,n options: {n limit: 10000,n name: static/[name].[ext]n }n},n
- 樣式文件
項目中樣式文件默認採用 Less,主要用到該庫的兩個特性:
- 可以方便的使用 CSS 變數,典型的比如定義通用像素大小;
- 層次化的樣式描述方式; Webpack 配置同時保留了 css 文件的載入能力,後期還可以加入對 SCSS 文件支持。規則如下:
{n test: /.css$/,n include: path.resolve(__dirname, ../src),n use: [style-loader, css-loader]n},n{n test: /.less$/,n include: path.resolve(__dirname, ../src),n use: [style-loader, css-loader, less-loader]n},n
同一個項目中,由於 CSS/LESS/SCSS 文件之間具有依賴關係,所以強烈推薦採用同一種技術實現。對於單個組件,不大可能像 Vue 一樣寫個 Webpack Loader 支持 .vue 類型的組件格式。樣式文件的載入需要在對應的 .js 文件中顯式引入 .less 文件,比如:
import ./GoodsInfo.less;n
- 模板文件
項目模板默認採用 Mustache,在 Webpack 的支持下,模板內容被單獨放在一個文件中,並以 .mst 作為自定義後綴,文件內容依然是 HTML 格式,只是根標籤為 <template>。Webpack 中選用 html-loader 對其進行解析,規則如下:
{n test: /.(html|mst)$/,n include: path.resolve(__dirname, ../src),n use: [html-loader]n}n
對於 Mustache 模板的自動解析和載入,網上有開源的 mustache-loader 實現,但其關注度實在太低,而 html-loader 足以達到所需功能:
- 載入 .mst 文件,並壓縮內容;
- 將文件中 img:src 等相對路徑屬性自動替換為絕對目標地址;
對於其他模板語言同樣可以使用這種方法,就可以在項目中靈活的使用不同的模板庫了。不過需要注意的是,同一個項目中最好只使用一種模板語言,方便管理,同時不會增加打包文件大小。
將 .mst 模板載入到頁面中和 .less 方法差不多。在對應的 .js 文件中顯式引入,然後用 extractTemplate 方法提取出模板內容即可:
import tplContent from ./GoodsInfo.mst;nnfunction extractTemplate(content) {n var elem = document.createElement(div);n elem.innerHTML = content;n return elem.querySelector(template).innerHTML;n}nnthis.tpl = extractTemplate(tplContent);n
這種顯式引入的方式有一個好處是,可以手動控制不同的模板和樣式。在實際產品需求中,內容和樣式改變是很頻繁的,而功能邏輯的變化相對要慢一些,這樣通過 js 引用不同版本的模板和樣式就會比較靈活。如果能把這一套管理機制抽象出來單獨進行配置也是很不錯的。
- 頁面文件
頁面文件在 Webpack 中也是以模板的角色存在的,解析方式和模板一樣,規則見上文。由於是頁面入口文件,在 Webpack 中還需要使用 HtmlWebpackPlugin 插件進行配置。如下配置中,項目存在兩個不同的頁面入口,所以需要兩個 HtmlWebpackPlugin 實例:
new HtmlWebpackPlugin({n filename: path.resolve(__dirname, ../dist/index.html),n template: ./src/Main/Main.html,n chunks: [manifest, vendor, main],n inject: true,n minify: {n removeComments: true,n collapseWhitespace: true,n removeAttributeQuotes: truen },n chunksSortMode: dependencyn}),nnew HtmlWebpackPlugin({n filename: path.resolve(__dirname, ../dist/index-banner.html),n template: ./src/MainBanner/MainBanner.html,n chunks: [manifest, vendor, mainBanner],n inject: true,n minify: {n removeComments: true,n collapseWhitespace: true,n removeAttributeQuotes: truen },n chunksSortMode: dependencyn})n
由於用戶每次進入 Web 頁面都會載入首頁,所以首頁越小越節省流量。參考 Vue 項目的 index.html 就會發現裡面基本只有一個骨架,具體內容都在組件中。但項目配置本身不會對這點進行假設,所以即使在首頁中寫入所有內容也是可行的。
打包
項目的主要打包配置前文已經介紹差不多了,其他具體配置參看官方文檔即可。採用該項目結構的最後打包結果,所有部署文件包括圖片加起來沒有超過 130K。在瀏覽器中,因為 gzip 的原因,全頁面載入網路流量不到 70K。
數據接入層
前文已經提到過,把數據請求單獨作為一個層主要是為了分離出複雜多變的數據請求介面,還有一個好處是介面 mock 數據也可以在這裡統一處理。
介面封裝
一個項目中可能在很多地方都會請求同一個介面。對於單個介面請求,可能有不同方法,比如用 ajax、fetch、jsonp、axios 甚至 jQuery 庫;有的是 GET,有的是 POST;有的還需要帶 cookie,其他卻不需要;返回數據的格式也許還不是統一的。而 JavaScript 邏輯只關心輸入和輸出,把這些請求細節都放在另外一個地方單獨維護,會使主要業務邏輯更加簡潔。在項目中使用時,只需要以 Promise 的形式調用方法即可。介面封裝的示例代碼如下:
import axios from axios;nnvar URL = https://zhuan.58.com/zz/transfer/getInfoById;nn/**n * 介面請求描述n * @param {*介面參數} params n */nexport default function(params) {n var paramArr = [];n params = params || {};n params.infoId = params.infoId || ;n Object.keys(params).forEach(function(key) {n paramArr.push(key + = + params[key]);n });nn return axios(URL + ? + paramArr.join(&), { withCredentials: true })n .then(function(response) {n return response.data;n })n .then(function(data) {n if (data.respCode == 0) {n return data.respData;n } else {n throw new Error(failed);n }n })n .catch(function(ex) {n throw ex;n });n}n
封裝 mock 數據
前後端協同開發時,需要首先定好介面,給出 mock 數據示例。所以在 DAL 層把 mock 數據封裝好,會節省很多工作。在項目中會將 mock 數據直接保存成 .json 格式的文件,然後在 DAL 入口文件中通過 import 導入,再使用一個工廠方法來對外提供 mock 方法,即可使用 mock 數據了。下面是入口文件中相關代碼:
import getInfoById from ./getInfoById;nimport getInfoById_MockData from ./getInfoById.json;nnfunction mockDataFactory(mockData) {n return function (params, isOnline) {n return new Promise(function (resolve, reject) {n if (isOnline) {n reject(Error("currently online mock data is not supported!"));n }n else if (mockData) {n resolve(mockData);n }n else {n reject(Error("mock data is not defined!"));n }n });n }n}nnexport default {n getInfoById: getInfoById,n mockInfoById: mockDataFactory(getInfoById_MockData)n}n
通過 DAL 使用介面
有了 DAL 層對各請求介面的聚合,在其他地方使用就比較簡單了,直接上代碼:
import DAL from ../dal;nnfunction init() {n DAL.getInfoById({n infoId: infoIdn }).then(function (data) {n render(data); // 渲染頁面n bindEvent(data); // 綁定事件n }).catch(function (err) {n console.log(err);n });n}n
組件化開發
小型 Web 頁面的組件和 .vue 文件結構類似,只是分成了三個文件:
- 樣式。內容和使用方式是基本一樣的;
- 模板。後者 Vue 有自己的模板語法,前者則用的 Mustache,也可支持其他模板。如果 Vue 的模板載入器單獨分離出來,那理論上也是可以拿過來使用的;
- 控制邏輯。JS 邏輯部分則有些不一樣,Vue 框架有著自己獨特完美的雙向綁定機制,其介面和生命周期也是圍繞它來設計的(這裡只針對 .vue 文件進行討論,類 React 使用方式很大程度是為了方便拉取用戶而設定)。小型 Web 頁面因為簡單,所以重心都放在了組件初始化和渲染上;
組件在小型 Web 頁面中定位是很明確的,即只針對頁面呈現和交互,所以對外介面的設計也不複雜。如果組件採用的是 MVC 模式,那就很難討論,因為 Controller 本身就是「老大」,可能有很多行為。Presenter 和 ViewModel 則相對簡單,它們的區別只是內在機制不同,對外是行為是差不多的。這裡不考慮大型 Web 頁面,小型 Web 頁中組件的介面默認就兩種:接受純數據參數(props);對外公布事件介面。相比於更高級的 Vue,少了一個 Slot 插槽功能。
使用組件
使用組件的方式很直接,看代碼:
import GoodsInfo from ../components/GoodsInfo/GoodsInfo;nnvar goodsInfo = new GoodsInfo();nnfunction render(data) {n goodsInfo.init(.goods-info-area, { title: data.title });n ...n}n
組件中一個 init 方法並不能搞定全部需求,因為項目中 init 方法不僅包含了組件渲染邏輯,還有事件綁定邏輯。當組件數據內容更新時,還需要抽取出一個 render 或 update 方法單獨進行調用來更新界面。這不像 Vue 自帶雙向數據綁定神器,所以要麻煩點。
使用組件提供的事件也很簡單,代碼如下:
// 商品被點擊ngoodsInfo.clicked(function (o, e) {n console.log(o);n});n
這裡事件句柄的參數採用了 (Object data, Event e) 的形式。其中 data 表示事件來源,它可以是被點擊對象的 ViewModel,或者簡單點,直接是被點擊對象所代表的的原始數據;e 則是 HTML 的事件參數。
組件的參數處理和渲染
組件內部綁定到具體的模板前文已經示例說明過了。在渲染組件內容時,還需要處理參數內容,並將其渲染到頁面指定地方。這裡直接上代碼:
import Mustache from mustache;nnfunction GoodsInfoPresenter() {n this.props = {n title: n };n ...n};nnGoodsInfoPresenter.prototype.init = function(target, data) {n this.props.title = data.title;n ...n // 渲染組件n $$(target).innerHTML = Mustache.render(this.tpl, this.props);n ...n};nn// 輔助方法nfunction $$(selector) {n return document.querySelector(selector);n};n
在構造器中,首先定義 props 參數的格式,並給上默認值。在 init 方法中,則將 data 中的參數賦值給 props,這裡一般是會有數據轉化處理邏輯。
最後直接進行組件渲染。可以發現,如果想要使用其他模板引擎,是很容易替換的。如果採用 SSR 服務端渲染組件,那可以各種模板庫全放進來,一個工廠方法就可以進行自動化處理。 組件的參數被取名為 props,完全是仿造 Vue/React。因為它們的功能和定位基本是一樣的,而且官方推薦的最佳實踐這裡也基本都推薦。具體這樣做的幾點思路如下:- 小項目做不到 Vue/React 的參數驗證功能,但顯式表示 props 參數有自描述文檔的作用,需要哪些參數及其類型一目了然;
- 構造器中同時給出了 props 默認值,無參數時組件有默認展示形式;
- 參數只有一個 data 對象。Vue 推薦參數都用基本類型,但內容龐大時,屬性繁多,分割成更小組件也不會減少多少使用的複雜性;
- props 中的每一個屬性不能是對象,只能是 Integer、String、Boolean、Array 等基本類型;
對外公布事件
將事件的觸發封裝到組件中也是為了減少業務的複雜性。很多 Web 項目中都是直接操作頁面內容,用戶交互、內容處理、業務邏輯都耦合在了一起,這裡組件將用戶交互封裝起來,同時對外提供事件介面。代碼如下:
function GoodsInfoPresenter() {n ...n this.clickCallback = null;n}nnGoodsInfoPresenter.prototype.init = function(target, data) {n ...n $$(target).querySelector(.goods-info)n .addEventListener(click, raiseClicked.bind(this, data), false);n};nnGoodsInfoPresenter.prototype.clicked = function(callback) {n this.clickCallback = callback;n};nnfunction raiseClicked(item, e) {n this.clickCallback && this.clickCallback(item, e);n}n
組件內部保存一個事件回調句柄 clickCallback,組件初始化時對用戶點擊事件進行數據綁定,並觸發這個回調。
結論
本文簡單描述了小型 Web 頁面的定位,通過對小型 Web 頁面的摸索和演化解釋了當前項目結構的設計思路,並對其細節進行了詳細描述,重點介紹了數據接入層和組件化開發。
當前的項目並不是最終形態,而只是一個 α 版本的雛形,還有很多地方值得改善:- 針對首屏時間進行優化,比如支持 SSR;
- 繼續改善打包部署方案,靈活支持多頁面部署,達到或接近離線應用的效果;
- 一些好的 ES6 語法很值得支持,需要找到一個方法在打包層面上漸進式的引入特定語法;
- 基於 Promise 的語法值得大面積採用,這是代碼層面需要考慮的;
- Webpack 挺好,但還不夠好,希望插件能更成熟更豐富;
可能還有很多點沒考慮到,不過實際需求永遠是最高優先順序。只要不斷的重構和改善,軟體就會一直有生命力~
推薦閱讀:
※Unity優化技巧(中)
※關於演算法競賽中快速乘的一些優化
※看完性能簡報,想不優化好都難!
※請問將方陣做特徵值分解,再去掉對角陣中的較小特徵值,這種操作叫什麼?
※如何根據網站日誌進行分析並做出優化改進?