React Ways1——函数即组件
未經審視的代碼是不值得寫的
? —— 沃茲吉碩德
React 中有一個經典的公式:
const View = f(data) 復制代碼從這個公式里我們可以提取出兩個特點:
- 視圖由函數定義——函數即組件
- 視圖的展示與 data 有關——數據驅動
接下來,我們就從這兩點出發,來探討探討 React 的編程模式
函數即組件——聲明式編程
函數即組件,顧名思義,就是一個函數就可以是一個組件。 在 React 中,組件一般有兩種形式:
-
類組件
class MyClassComp extends React.Component {render () {return <div className="my-comp"></div>} } 復制代碼 -
純函數組件(無狀態組件)
const MyPureFuncComp = () => (<div className="my-comp"></div> ) 復制代碼
純函數描述的組件一目了然,但是類組件是否就不那么“函數即組件”了呢?
這就像偶像劇的劇情一樣毫無驚喜——并非如此。
首先,我們知道,在 JavaScript 中,class 其實更像是函數對象的語法糖,本質上還是原型及原型鏈那一套,沒出圈兒!
其次,在實際的開發場景下,囿于當前的瀏覽器形勢,我們產出的代碼更多時候需要兼容到 es5 這個沒有 class 概念的標準。
所以我們會看到上面的 MyClassComp 在生產環境下會這樣產出:
;function _inheritsLoose(subClass, superClass) { subClass.prototype = Object.create(superClass.prototype); subClass.prototype.constructor = subClass; subClass.__proto__ = superClass; }var MyClassComp = /*#__PURE__*/ function (_React$Component) {_inheritsLoose(MyClassComp, _React$Component);function MyClassComp() {return _React$Component.apply(this, arguments) || this;}var _proto = MyClassComp.prototype;_proto.render = function render() {return React.createElement("div", {className: "myClassComp"});};return MyClassComp; }(React.Component); 復制代碼其中 _inheritsLoose 函數用于實現繼承,在它下面,MyClassComp 被編譯成了一個函數!
好的,現在我們無須擔心函數即組件這個概念的準確性了。同時,自 Hooks 在 React 16.8 正式 release 后,函數寫法的組件會越來越多。
PS:代碼中的 /#*__PURE__*/ 的作用是將純函數(即無副作用)標記出來,方便編譯器在做 tree-shaking 的時候可以放心的將其剝除
那么,為什么 React 要使用函數作為組件的最小單元呢?
答案就是聲明式編程(Declarative Programming)。
聲明式編程
In computer science, declarative programming is a programming paradigm—a style of building the structure and elements of computer programs—that expresses the logic of a computation without describing its control flow. ——Wiki
根據維基的解釋可知,與命令式編程相對立,聲明式編程更加注重于表現代碼的邏輯,而不是描述具體的過程。
也就是說,在聲明式編程的實踐過程中,我們需要更多的告知計算機我們需要什么——比如調用一個具體的函數,而不是用一些抽象的關鍵字來一行一行的實現我們的需求。
在這個模型下,數據是不可變的,這就避免如死鎖等變量改變帶來的問題。
這不就是封裝方法?
是的,JavaScript 作為一門基于對象的語言,封裝是很常見的 coding 方式。但封裝的目的是為了通過對外提供接口來隱藏細節和屬性,加強對象的便捷性和安全性。而聲明式編程不僅要將對象內部的細節封裝起來,更要將各種流程封裝起來,在需要實現該流程的時候可以直接使用該封裝。
舉個例子,現有如下一個數據結構,我們要通過一個方法將其中名字的各個部分用空格連接起來,然后返回一個新數組,
const raw = [{firstName: 'Daoming',lastName: 'Chen'},{firstName: 'Scarlett',lastName: 'Johnson'},{firstName: 'Samuel',lastName: 'Jackson'},{firstName: 'Kevin',lastName: 'Spacy'} ] 復制代碼我們很容易想到,一個 for 循環即可,
function getFullNames (data) {const res = []for (let i = 0; i < data.length; i++) {res.push(data[i].firstName + ' ' + data[i].lastName)}return res } 復制代碼那么問題來了,以上的數據看起來相當的標準,因此我們只需要連接 firstName 和 lastName。然而,有一天數據結構中加入了中間名,姑且叫 midName 吧,為了正確的輸出全名,我們不得不修改一下 getFullNames 方法——搞個 if…else 來判斷 midName 是否可用?這個思路可以排除了,因為它并沒有考慮擴展性,如果將來錄入了一個不知道多少個中間名的俄羅斯人怎么辦?
好吧,我們用 Object.keys() 先將所有部分提取出來,然后嵌套一個 for 循環將它們都拼在一起。
這看起來并沒有什么問題,但我們仔細分析一下這個算法的目的——將名字用空格連接起來,我們并不需要關心這些名字究竟屬于什么部分,直須安順序將這些值提取就行——對,我們可以用 Object.values() 來實現這個改寫,
function getFullNames (data) {const res = []for (let i = 0; i < data.length; i++) {res.push(Object.values(data[i]).join(' '))}return } 復制代碼這甚至無須手動的拼接這些值,告訴計算機讓 join 來完成它。
PS:Object.values 屬于 es2017 標準,在使用它的時候需要加上對應的 preset 或 polyfill,當然了,也可以在你的方法庫中實現一個。
It is the end?
No。既然我們已經省去了一個 for 循環命令,何不再省一個?來吧,
function getFullNames (data) {return data.map(item => Object.values(item).join(' ')) }// 甚至再簡單一點 const getFullNames = data => data.map(item => Object.values(item).join(' ')) 復制代碼一行代碼!
想想原來的命令式寫法,不支持中間名的情況下就有 9 行,若是再嵌套一層循環,這個如此簡單的需求看著就不那么簡單了。
我們分析分析現在的 getFullNames:
可以看到,在這個分析過程中,我們注重的是流程的邏輯,而實現每個邏輯點的時候,我們都可以用現成的方式去得到想要的結果,換言之,我們是在一個個的求值過程中去達到目的,而非在一大堆代碼中掙扎。
簡單總結一下聲明式編程的優點:
- 復用性:封裝是聲明式編程的一大要點,而封裝的主要目的之一就是復用代碼
- 安全性:明確關注點,省去了不必要的變量聲明和對象引用,防止副作用。
- 可讀性:方法的調用代替直接編寫流程,使得代碼更加直觀
- 邏輯性:顯然通過語義化的方法名能更加清楚的體現代碼的邏輯
當然了,在這些優點之下,對開發者的編程素質也有相當的要求。比如代碼規范,所有有意義的封裝都是為了復用,那么其規范性就必須得提起來,包括注釋以及代碼格式,我們都知道良好的代碼規范是提升團隊編程效率的重中之重;其次,前面提到了“有意義的封裝”,這意味著,并非所有的流程都需要隱藏起來,封裝到什么程度?哪些東西需要被封裝?該如何封裝?這都是需要在實踐中逐漸總結的,我們也稱其為封裝的粒度問題。
好了,說了這么多,back to React!
首先 React 的核心思想是組件化,其最小的粒度單元就是組件,還記得前面提到的嗎——函數即組件!
我們可以將這種思維理解成,React 就是將一個個函數按照一定的邏輯關系組合起來,最終構建出我們想要的應用。這也幾乎就是聲明式編程的思維。
因此,一個好的 React 組件,也應當具有前文提到的聲明式編程的優點,并且有更深的含義:
-
復用性:對于組件來說,復用性體現在其是否與其他組件有太多不必要的耦合,你不一定會真的復用它,但是保持其獨立性對于維護有著相當積極的意義
-
安全性:因業務復雜程度的關系,組件不一定能保證完全沒有副作用(幾乎不可能),但是它們對流程來說應當是透明可見的。也就是說,開發者應當知道一個組件會產生哪些副作用,以及它們會在其他地方產生什么影響,盡力使得整體依然是可控的。
-
可讀性:這涉及到組件的整體設計,包含命名和接口等因素,舉個例子,我們設計一個時針組件:
// 時針的英文為 hour hand,那么我們有如下的選擇 const HH = () => (<div className="hh"></div>) const SZ = () => (<div className="sz" />) const HourHand = () => (<div className="hour-hand" />) const ShiZhen = () => (<div className="shizhen" />) 復制代碼顯然,前兩種方式容易讓人摸不著頭腦,它們需要進一步的閱讀代碼才能推斷出其作用,這還是對其邏輯性樂觀的情況下。
第三種方案則一目了然,幾乎沒有推理成本,對于項目的交接以及維護的便捷性都大有裨益。
第四種方案呢,同樣一目了然,但這只對懂漢語拼音的開發者有效,如果你的項目向全世界開源了,那么對于外國友人來說,可能依然和沒開源一樣。
好了,我們確定這個時針組件叫 HourHand 了,那我們應該怎么使用它呢?
// 聯想到時鐘的形態,我們首先會意識到的就是旋轉角度,那組件的接口或許是這樣 import PropTypes from 'prop-types'const HourHand = props => (<divclassName="hour-hand"style={{transform: `rotate(${props.deg}deg)`}}/> )HourHand.propTypes = {deg: PropTypes.number } 復制代碼這看起來并沒有什么問題,但是從邏輯上來說,時針的含義是角度嗎?當然不是,應該是當前的小時,而我們知道小時之間的角度偏移為 30°,因此,為了使其整體更具有邏輯性,我們優化一下:
import PropTypes from 'prop-types'const HourHand = props => (<divclassName="hour-hand"style={{transform: `rotate(${this.props.hour * 30}deg)`}}/> )HourHand.propTypes = {hour: (props, propName) {if (props[propName] < 0 || props[propName] > 12) {return new Error('Hour must be a number between 0 and 12.')}} } 復制代碼現在這個時針組件的接口就與其本身的含義統一了,下次再使用它的時候,只需關注我們熟悉的小時這個屬性,而不用再去關心應當轉換什么角度——這個流程已經被封裝到組件內部了。
-
邏輯性:其實這一點人為的因素比較大,因為無論多么優秀的編程模型,只要涉及到了業務,都能被 coding 成難以讀懂的代碼。而我們使用 React 的最終目的就是實現我們的業務需求,因而提升邏輯性需要我們加強對應用的整體理解。
不過這里我們可以列舉一個 JSX 在邏輯性上的優勢。
在通過模板編譯的方式構建視圖的框架中,往往需要先在父組件中注冊子組件:
<template><el-form><el-form-item><el-input></el-input></el-form-item></el-form> </template><script>import { Form, FormItem } from 'element-ui'export default {components: {[Form.name]: Form,[FormItem.name]: FormItem}} </script>復制代碼這樣寫其實已經足夠語義化了,沒有問題。
然而我們再仔細想想,其實視圖和邏輯本身是應該分離的,但在這個模式下我們除了要在模板中查看組件結構之外,在邏輯中去關注組件的關系,并且 Form 和 FormItem 的父子關系并未得到體現。
How about JSX's way?
import { Form, Input } from 'antd'export default () => (<Form><Form.Item><Input /></Form.Item></Form> ) 復制代碼顯然,在這種模式下,組件結構可以完美的體現組件關系,我們對視圖的關注只需要集中在這個 JSX 代碼塊中。
其實,在這個問題上,分離一下關注點似乎也沒什么大不了(同時,許多框架也兼容了 JSX);在 components 里注冊也可以理解成是配置而非邏輯;甚至,根據習慣和 UI 庫實現的不同,我們也可能解構的引入這些邏輯上的子組件。最重要的是,我們如何在不同的模型下優化我們的邏輯。
以上部分內容看起來有些像是在聊函數式編程,沒有錯,函數式編程是最常見的聲明式編程的子范式之一,在實際開發中,我們還體會到許多函數式編程的理念。
以上就是對”函數即組件“這一大概念的基本詮釋,理解起來并不難,但是最重要的是如何將其最優的實踐到實際開發中,這是需要我們不斷探索的。Ok,Let‘s next into the next plate。
數據驅動——單向數據流
在 JS 的世界中,對象是最基本的數據形式之一;而在 React 的世界中,驅動視圖更新的因可能來自 state 的更新,也可能來自 props 的更新——它們都是對象。也就是說數據決定了 React 應用的展示形態,這些形態當且僅當數據發生改變的時候才會更新(這里先回避直接操作 dom 的場景),這是不折不扣的數據驅動。
順勢的,我們來理解一下 React 的數據驅動方式——單向數據流。
單向數據流
單向數據流與雙向數據流相對,是一種通道內只允許單向數據傳遞的數據流形式。也就是說,在同一鏈路上,只有一種數據流向,類似于通信工程中的單工信道。
而雙向數據流則是上允許同一鏈路有兩種數據流向,如 MVVM 的雙向綁定形式。其在概念上類似于雙工信道。進一步的,在代碼層面,同一時段只能有一種方向的工作,所以在實際的工作方式上它更接近半雙工。
那么,我們就從兩種信道的角度來理解探索兩種數據流的特點及使用場景:
- 單工信道通常被應用在電視、廣播等純內容輸出的場景。在這種情況下,數據的來源及其下發的目標都是可追蹤可記錄的,并且可以保證數據的統一性
- 半雙工信道最常見的應用即是對講機,其特點是在一方在響應(發)的時候,另一方只能接收(收)。如果兩邊同時響應(收發并行),那么信道便不能正常工作了
回過頭來,我們前端開發的主要目的就是將數據視覺化的輸出給用戶,那么,單向數據流(單工信道)自然是具有得天獨厚的優勢的——從 QA 到線上,前端的一大目標就是在同一狀態下對同一角色的用戶有同一的展示形式。
對,提到用戶,用戶的行為是如何引起的視圖變化的呢?
再一次回到 React。前面我們提到了,在 React 中所有的視圖變化都來自于數據的變化,而數據存儲在狀態中,因此,無論是用戶還是其他副作用,引起視圖變化的原因都是他們修改了狀態,我們看看在 React 中這是如何進行的:
class MyComp extends React.Component {state = {showName: true}hideName = () => {this.setState({showName: false})}render () {return (<div>{this.state.showName ? (<div>Samuel</div>) : null}<button onClick={this.hideName}>hide name</button></div>)} } 復制代碼在上面的代碼中,我們實現了通過一個按鈕來隱藏顯示名字的組件,可以看到,點擊按鈕后,會觸發 hideName 方法,而這個方法中只做了一件事,就是調用 setState 方法來修改 state,而 setState 方法則會去開啟更新視圖的流程。
看到了嗎,hideName 本身并不知道會對視圖會有什么影響,它只是影響了狀態,而 render 才知道如何根據這些狀態來渲染界面。我們可以得到一個簡單的示意圖:
如此就清晰多了——行為到狀態同樣也是單向的!
因此,在 React 中,狀態到視圖的更新和行為到狀態的修改,是兩條相互獨立的通道,這意味著,在這個基礎上,所有的行為和變化都可以追蹤,可控性非常的強。
這種模式就好比開發者為應用構建了一個神經中樞,整個應用軀干都受這個神經中樞的控制,如果應用出了問題,便可以在中樞中進行行為的回溯,對癥下藥。
同時,在前端非常流行的 Flux 應用架構也同樣采用了單向數據流模型,由其衍生出的各種狀態管理框架則在不斷地體現著這個模型的優越性。
與 雙向綁定 共存
說到這兒,我們還是得提一提雙向綁定,盡管 React 0.15 版開始就不提供這種數據綁定方式了,但它依然是被其他框架所采用的現代前端開發的關鍵技術之一。
在 Vue 和 Angular 中,雙向綁定是一個很常見的交互處理方案,對于各類表單控件,它有很強的即時同步能力。然而,這也意味著,它的工作頻率會非常的高,對于一些規模較小的應用來說,這種雞毛蒜皮兒的小事兒影響可能不大,但應用一旦擴展起來,狀態樹將會越來越復雜,我們就應該盡量減少這種可控性較差的實踐。
譬如,Vuex 的誕生,在技術棧層面重新梳理了 Vue 的狀態管理方式,而 Vuex 的模式也是由 Flux 思想演變過來的,同樣具有單向數據流的特點。這時候,Vue 的開發者們可以重新思考雙向綁定與整體狀態的結合形式,以在保證應用穩定性的情況下最大化發揮這種高效數據處理方式的能力。
最后,我們再進一步的考察一下 Vue 和 Angular 雙向綁定的本質看看會發生什么,我們以 input 為例:
-
Vue:在 Vue 中,實現 input (包括 textarea) 標簽雙向綁定的源碼如下:
// input 和 textarea 是比較基礎的表單組件,除此之外還有 genRadioModel、genCheckBoxModel 等方法進行對應的標簽綁定 function genDefaultModel (el: ASTElement,value: string,modifiers: ?ASTModifiers ): ?boolean {const type = el.attrsMap.typeconst { lazy, number, trim } = modifiers || {}const needCompositionGuard = !lazy && type !== 'range'// v-model.lazy 的實現在這里const event = lazy? 'change': type === 'range'? RANGE_TOKEN: 'input'let valueExpression = '$event.target.value'if (trim) {valueExpression = `$event.target.value.trim()`}if (number) {valueExpression = `_n(${valueExpression})`}// 看這里,生成的 code 被傳入到了下面的 addHanlder 中 let code = genAssignmentCode(value, valueExpression)// 如果有輸入法守衛,就增加一個判斷,當正在輸入的時候不觸發 codeif (needCompositionGuard) {code = `if($event.target.composing)return;${code}`}// 給該標簽設置 value 屬性addProp(el, 'value', `(${value})`)// 給該標簽添加事件處理函數addHandler(el, event, code, null, true)if (trim || number || type === 'number') {addHandler(el, 'blur', '$forceUpdate()')} } 復制代碼整體下來,就是一個為該元素添加 handler 的過程,深入 genAssignmentCode 似乎可以找到數據綁定的答案,來看看:
/*** Cross-platform codegen helper for generating v-model value assignment code.*/ export function genAssignmentCode (value: string,assignment: string ): string {const res = parseModel(value)if (res.key === null) {return `${value}=${assignment}`} else {return `$set(${res.exp}, ${res.key}, ${assignment})`} } 復制代碼一目了然,該方法的作用就是生成將genDetaultModel 中的 valueExpression 賦值給要綁定的 value ,要么直接觸發 setter 以啟動依賴檢測,要么通過 $set 方法通知檢測器——最終都是狀態樹更新引發數據下流帶來的視圖影響,本質上,這依然是單向數據流。那么是否說明 MVVM 的本質實際上都是單向數據流呢?我們繼續往下
-
Angular:Angular 通過 ngModel 指令進行雙向綁定,其源碼如下(篇幅較長只提取了重要部分,完整源碼可戳這里):
@Directive({selector: '[ngModel]:not([formControlName]):not([formControl])',providers: [formControlBinding],exportAs: 'ngModel' }) export class NgModel extends NgControl implements OnChanges,OnDestroy {public readonly control: FormControl = new FormControl();/*** @description* Tracks the value bound to this directive.*/// 根據注釋,這里即指令要跟蹤的值@Input('ngModel') model: any;@Input('ngModelOptions')options !: {name?: string, standalone?: boolean, updateOn?: FormHooks};@Output('ngModelChange') update = new EventEmitter();// 定義 ngOnChange( Angular 對 change 事件的封裝) 的 handlerngOnChanges(changes: SimpleChanges) {this._checkForErrors();if (!this._registered) this._setUpControl();if ('isDisabled' in changes) {this._updateDisabled(changes);}if (isPropertyUpdated(changes, this.viewModel)) {// 調用 _updateValue 來應用新的值this._updateValue(this.model);this.viewModel = this.model;} } // 用 control.setValue 給綁定的屬性賦值private _updateValue(value: any): void {resolvedPromise.then(() => { this.control.setValue(value, {emitViewToModelChange: false}); });}} } 復制代碼像之前一樣,我們來看看這個 control.setValue 方法干了些什么:
setValue(value: any, options: {onlySelf?: boolean,emitEvent?: boolean,emitModelToViewChange?: boolean,emitViewToModelChange?: boolean} = {}): void {(this as{value: any}).value = this._pendingValue = value;if (this._onChange.length && options.emitModelToViewChange !== false) {this._onChange.forEach((changeFn) => changeFn(this.value, options.emitViewToModelChange !== false));}this.updateValueAndValidity(options);} 復制代碼從這里知道,setValue 方法先是將新值下發給了 change 事件的訂閱者們,然后調用了 updateValueAndValidity。
可見,除了將新值賦值給 model 外,ngModel 還“手動”調用了相關的方法進行后續工作,說明在 Angular 中,ngModel 實現的是一個真正的雙向通道。
綜上所述,在 Angular 中,單向數據流和雙向數據流共同存在。但更需注意的是,上面實現雙向綁定的過程中用到的 control 對象,是 FormControl 類的一個實例,也就是說,Angular 將這種模式內聚到了表單控制器之中,使我們有了明確的問題域,這即是在框架層面就將雙向綁定的場景進行了規劃,從而為龐大而復雜的應用做了準備——聊到 Ng 的時候不就是幾乎在聊這樣的應用嗎?
-
總結
總結一下本文說了些什么:
- 函數即組件
- 函數式 React 組件的本質
- 這是一種聲明式編程的實踐
- 聲明式編程使得代碼更加靈活、復用性更強
- 理解這一切,構建更好的 React 應用
- 單向數據流
- 這是目前前端世界最流行的數據流形式
- 它使得應用的數據和行為能更好的被監聽和捕獲
- 提升了應用表現的一致性
- 它可以與雙向數據流共存,方式的合理可以發揮它們各自最大的效能
以上貫穿 React 開發的兩個最基本的概念,可以說“函數即組件”是 React 應用的各個器官;“單向數據流”就是這個應用的血液管道,支持著各個組件呈現出它們應該的樣子;而開發者,就是大腦,為應用注入了靈魂。
理解它們,我們就知道了 React 的基本形態;而能在開發過程中正確地實踐它們,應用將會更加優秀。
Hold it if you agree with it~!
如果您尚未嘗試 React,或許本文并不能讓您馬上著手開發,若不嫌棄,還有后文。
如果您已經在 React 的世界中自由翱翔,希望本文能對您有益,或是得到您的批評。
Thanks
總結
以上是生活随笔為你收集整理的React Ways1——函数即组件的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 图像理解之物体检测object dete
- 下一篇: [2018.12.9]BZOJ2153