表單開發是 Web 開發中最常見的需求之一,表單本身的複雜度也在日益增加。我們如何藉助技術手段,更好地實現表單結構、組織業務程式碼?本文介紹了使用 Vue.js 構造可配置化表單的一些經驗。
背景
作為現代網頁中最早具有邏輯的部分,表單至今仍在部落格類、分類資訊以及論壇等以使用者釋出的資訊為核心的網站中,扮演著重要的角色。對這些網站來說,表單意味著資訊的初始來源,因此它實際上承載了對於資訊處理的第一手邏輯。對於不同的類目,表單的內容顯然在業務上需要進行區分,所以,如何實現表單內容的區別化和可配置化就成為了這一類 Web 應用的一大重點。
傳統的 Web 應用使用服務端直接輸出表單的方式,來針對不同的頁面邏輯輸出不同的表單內容。一些相對完備的框架會提供服務端通過一些簡單的配置輸出表單的功能。例如,PHP 框架 Laravel 提供了通過 Form::textarea('content', null, ['class' => 'form-control'])
這樣的方式來允許在檢視的模板層渲染一個表單控制元件。然而,在互動邏輯日益複雜的今天,許多需求,例如:欄位的實時校驗、控制元件之間的聯動,在這種模式下的實現是非常困難的,簡單的服務端渲染已經遠遠不能滿足業務的發展需求。
微軟的 WPF 最早向我們展示了應用的 MVVM 模式,而 Knockout 則將它帶入了前端的世界。到目前,以 React 和 Vue 為代表的檢視層框架已經很好地將這種模式投入了生產中。而本文將要介紹的,則正是通過 Vue.js 框架來優化我們的表單開發能力和體驗。
目標
拋開技術領域的探索,對於表單,我們要達成的目標是什麼呢?
試想,有這樣的一些需求:
- 一個最簡單的表單中,需要有內容、地點、聯絡方式三個欄位
- 內容欄位至少需要填寫8個字,且不能包含一些簡單的違禁片語
- 地點欄位是一個樹形的選擇控制元件,需要提供給使用者從省級選到區縣級的能力
- 聯絡方式是必填的,並且這個欄位必須是手機號碼
- 如果內容欄位中出現了手機號碼,且使用者沒有填寫號碼,需要將這個號碼自動補充到聯絡方式中
大家看,即使是內容如此簡單的表單,也會有這樣的需求。有一些功能,例如:必填、格式校驗,我們可以通過 HTML5 中的 required
或者 pattern
這樣的欄位來實現原生的約束,而更多複雜的功能則必須交由 JavaScript。拋開這一部分不談,在純頁面結構上,我們想要的大概是這樣:
<form class="form">
<div class="form-line">
<div class="form-control">
<textarea name="content"></textarea>
</div>
</div>
<div class="form-line">
<div class="form-control">
<input type="hidden" name="address">
<!-- 具體的控制元件實現 -->
</div>
</div>
<div class="form-line">
<div class="form-control">
<input type="text" name="contact">
</div>
</div>
<input type="hidden" name="_token" value="1wev5wreb8hi1mn=">
<button type="submit">提交</button>
</form>複製程式碼
而我們期望能有這樣的配置直接配置上述的頁面結構,以及其部分的邏輯:
[
{
"type": "textarea",
"name": "content",
"validators": [
"minlength": 8
]
},
{
"type": "tree",
"name": "address",
"datasrc": "areaTree",
"level": 3
},
{
"type": "text",
"name": "contact",
"required": true,
"validators": [
"regexp": "<mobile>",
]
}
]複製程式碼
再加上一點簡單的業務邏輯程式碼,就構成了我們對於表單的全部配置,而剩下的工作都由表單框架來生成。
實現
關於如何使用 Vue.js 搭建一個簡單的 Web 應用,在很多地方已經有非常優秀的介紹,例如 Vue.js 的官網 [1] 就提供了很多例項,因此我們也不再贅述。在這裡我將只介紹一些核心的實現,以供大家參考。
基本的實現邏輯如下圖所示:
整個流程可以分為:後端資料傳遞(品紅)和外部擴充套件(藍色)兩部分,接下來會對各個部分的核心流程詳細介紹。
後端資料傳遞
Vue.js 面向的執行環境在絕大多數的手機瀏覽器上是可以良好支援的 [2] 。因此我們可以直接在 HTML 或者對應的模板檔案中寫如下的程式碼:
<div id="my-form">
<my-form :schema="schema" :context="context"></my-form>
<script type="text/json" ref="schema">{!! json_encode($schema) !!}</script>
<script type="text/json" ref="context">{!! json_encode($context) !!}</script>
</div>複製程式碼
(注:這裡使用的語言是 Blade [3])
#my-form
這個元素作為我們交由 Vue 控制的根容器宣告,而 <my-form>
則是我們為表單建立的控制元件。這裡值得注意的是,我們通過一個帶有 ref
的 script
標籤來使得我們可以從後端傳遞資料給 Vue 元件。
在這裡,我使用了兩個來自於後端的資料物件。schema
是類似於上一節中我提到的配置內容,它將通過 Vue 的根容器傳遞給對應的表單控制元件;而 context
則用於處理其他需要後端讀取的資料,例如一些程式碼中可能會根據不同的使用者角色進行處理,則我們可以把這部分資訊也傳遞給 JS 便於控制。
在 JS 檔案中,我們可以使用如下的方式來處理上述的資料:
new Vue({
// ...
mounted() {
this.schema = JSON.parse(this.$refs.schema.innerText)
this.context = JSON.parse(this.$refs.context.innerText)
}
})複製程式碼
這樣,我們就可以通過實現 form.vue
來實現我們的表單構造。
附註
構造表單控制元件
在 my-form
元件中,我們可以通過後端傳遞的 Schema 配置,來生成對應的控制元件
<template>
<form :class="form" method="post">
<my-line v-for="(item, index) in schema" :schema="item"></my-line>
</form>
</template>複製程式碼
my-line
這個元素,在這裡被我們用於構造統一的表單模板,例如,所有的控制元件都會被 <div class="form-line"></div>
這樣的容器包裹,那麼我們可以將這部分內容作為 my-line
元素的模板宣告。使用這種方法我們可以構造相同的 Label 元素、錯誤提示等。
在 my-line
元件中,我們可以通過這樣的方式來宣告實際的表單控制元件:
<div class="form-ctrl">
<my-input :schema="schema" v-if="schema.type === 'input'"></my-input>
<my-textarea :schema="schema" v-else-if="schema.type === 'textarea'"></my-textarea>
</div>複製程式碼
這種方式看起來簡單直接,但它會使 my-line
元件變得異常複雜。為了解決這個問題,我們可以引入一個虛擬元件 my-control
,由它自己根據不同的 schema.type
渲染出不同的表單元素。
Vue.js 中使用函式式元件可以宣告一個本身不渲染,但可以呼叫子元件的元件。我們只需要這樣宣告:
<div class="form-ctrl">
<my-control :schema="schema"></my-control>
</div>複製程式碼
// my-control.js
function getControl(context) {
const type = context.props.schema.type
// 在這裡分發元件
}
export default {
functional: true,
props: {
schema: Object
},
render(h, context) {
return h(getControl(context), context)
}
}複製程式碼
這樣,可以將控制元件的複雜度從 my-line
這個元件中抽離出來,更有利於各元件的獨立維護。
控制元件繼承
如上所述,我們已經可以將各種控制元件,例如 my-input
、my-textarea
獨立進行實現。但是,這些元件中可能會有一些通用的邏輯。比如,控制元件對應的表單欄位顯示的名稱,我們實際上需要這樣的屬性:
export default {
// ...
computed: {
displayName() {
// 如果有獨立配置就使用配置的名稱,而預設使用表單項的 name 屬性作為名稱
return this.schema.displayName || this.schema.name
}
}
}複製程式碼
再比如,我們對於所有的控制元件,都會有對應資料的 data
屬性;或者對於各個元件,我們需要統一執行生命週期方法對應的操作。這種情況下,我們可以將統一的實現抽象為一個獨立的類:
// contract.js
export default {
// 一些公用的方法
}
// input.vue
import Contract from './contract'
export default {
mixins: [Contract]
// ...
}複製程式碼
並且,由於 Vue 的 mixin 機制,我們可以在 contract.js
中宣告統一的生命週期函式,而在控制元件對應的元件中,再次宣告生命週期函式不會覆蓋統一的處理,而是會在統一函式之後執行。這保證了我們可以安全宣告獨立的生命週期而無需再次新增統一邏輯。
外部元素
有一些比較特別的元素,例如:提交按鈕、及有些網站釋出表單可能會出現的協議勾選,這些東西顯然不能作為表單控制元件注入。但我們可以使用其他方式來簡單實現:
<!-- template -->
<div id="my-form">
<my-form :schema="schema" :context="context"></my-form>
<div class="action" slot="action">
<button class="form-submit" type="submit">{{ $btnText }}</button>
</div>
</div>
<!-- my-form -->
<template>
<form :class="form" method="post">
<my-line v-for="(item, index) in schema" :schema="item"></my-line>
<slot name="action"></slot>
</form>
</template>複製程式碼
通過 Slot 機制,我們可以從外部向 Form 內注入一個不屬於表單控制元件的元素。同理,如果我們需要加入一些 CSRF 元素等隱藏的表單項,也可以通過這種方式進行。
擴充套件
在完成了基礎元件之後,我們還有一些基本的互動功能,以及業務邏輯可能會考慮的功能。例如上文中提到的必填等。這時候,我們需要從 JavaScript 角度對我們的表單進行擴充套件。
為了防止業務邏輯擴散到控制元件邏輯中,我們需要提供一套機制來使得業務邏輯可以在對應的時刻執行。例如,必填的真實含義其實是當控制元件資料改變時,觀察是否為空。如果存在必填項資料為空,禁用提交按鈕。顯然,控制元件資料改變時是生命週期的一個過程(updated,或者是自定義的 @change 事件),所以我們可以通過事件傳遞的機制來實現一套業務邏輯處理的框架。
表單的核心是 Form(表單元素)和 Control(控制元件),所以,我們需要通過一個獨立的 Event Emitter 將對應的核心控制元件的事件代理出來。
const storage = {
proxy: {
form: null,
control: {}
}
}
class Core {
constructor(target) {
this.target = target
}
static control(name) {
return storage.proxy.control[name] ||
(storage.proxy.control[name] = new CoreProxy(`control.${name}`))
}
static form() {
return storage.proxy.form ||
(storage.proxy.form = new CoreProxy('form'))
}
mount(target) {
// ...
}
on(events, handler) {
// ...
}
emit(events, ...args) {
// ...
}
}複製程式碼
通過這種方式,我們可以通過 Core.form()
或者諸如 Core.control('content')
的方式來獲得一個在當前頁面持久有效的 Emitter。然後我們只需要在對應的 Vue 檔案中代理生命週期事件:
import Core from './core.js'
export default {
// ...
beforeUpdate() {
// 避免初始化之前產生事件
if (!this.schema.length) return
Core.form().mount(this).emit('create', this)
},
}複製程式碼
為了避免全域性引入 CoreProxy
,可以把這個類暴露在 Vue.prototype
上。通過 Vue Plugin,可以實現下面的效果:
// contract.js
export default {
// ...
updated() {
this.$core.control(this.schema.name).emit('update', this)
// propagation
this.$core.form().emit('update', this)
}
}複製程式碼
通過這種方式,我們可以將對應的 Vue 物件傳遞給 Core 來代理,但同時不把它直接暴露給外部。比如我們的程式碼可能是這樣:
// 這個檔案用來實現“必填”功能
Core.form().on('update', function(control) {
if (!control.schema.required) return
if (control.model) {
// error對應的事件由其他檔案來處理
Core.form().emit('resolve-error', control, 'required')
} else {
Core.form().emit('reject-error', control, 'required', '此項必填')
}
})複製程式碼
同理,我們也可以將事件在不同的元件中傳遞。例如,我們需要在“型別”選擇為“手機號碼”的情況下校驗“聯絡方式”欄位:
Core.control('contact-type').on('change', function(control) {
// 這裡我們不能直接讀取到“聯絡方式”,應該通過其他的方式來處理
const proxy = Core.control('contact')
const contact = proxy.read()
// ...
})複製程式碼
因為在 Core 的內部,我們可以獲取到對應的 Vue 物件,所以我們完全可以暴露出一些類似於 read
這樣的只讀方法供外部呼叫;對於資料修改,例如外部也可能需要修改其他控制元件的資料,我們同樣可以提供一些內建的事件,例如 Core.control('contact').emit('write', newValue)
來使得外部有能力修改這些資料,同時可以得到統一的控制。
總結
以終為始,我們最後來聊一聊,為什麼我們的表單在 Vue 這樣的框架中可以被更好地表達:
- 雙向繫結機制。雙向繫結意味著我們無需關心資料的變化對檢視的重繪,也不用關心使用者操作如何同步到 JS 資料的修改,這使得我們對於資料的處理可以被高度簡化;同時,Vue 對資料同步給出了非常多的選項,例如
.lazy
、.trim
等修飾符,可以讓我們將精力集中在對於邏輯本身的處理上 - 元件化。表單本身是一個非常適合使用元件化的場景,因為每一種表單控制元件都表現出了共性和差異性,而表單本身就是由各種各樣的控制元件構成的。將控制元件抽象為元件,可以說是一種必然,也是處理控制元件的最佳方案
- 模板描述。Vue 使用模板來描述如何渲染一個元件,由於 HTML 本身的表單控制元件就是大量邏輯的封裝,因此,比起渲染函式,使用模板來描述一個表單控制元件是直接而自然的。使用 Vue 官方的語言來解釋,比起偏邏輯(logical)的元件而言,表單控制元件本身其實是偏檢視的(presentational),因此模板顯然會有更出色的表現
Vue.js 本身是一個非常優秀的框架,一方面,它可以通過最精簡的方式讓我們以 Vue 元件的形式描述出我們的控制元件;同時,我們可以使用 Vue 提供的一系列其他功能,來實現諸如控制元件抽象、控制元件分發、事件傳遞模組的共用、外部內容注入等更加複雜的功能。如果大家在平時也有類似的表單開發的需求,不妨嘗試使用 Vue 來構建。
作者:孫翛然
簡介:前端工程師、Web 開發工程師,致力於基於不同框架和語言的業務架構設計和開發。本文僅為作者個人觀點,不代表百姓網立場。
本文在 “百姓網技術團隊” 微信公眾號首發,掃碼立即訂閱: