命令式、宣告式、函式式、物件導向、控制反轉之華山論劍(下)
本文的所有例子均在當前目錄下的html檔案中,出於對慵懶同學的保護,雙擊即可執行
命令式與宣告式的實際例子
上文說了一堆理論,部分同學已經出現了大海的感覺。下面我們通過一個實際的例子(本例子根據真實場景改編),介紹下命令式與宣告式的區別與函數語言程式設計中的控制反轉。
我們要編寫一個網頁版的IDE,IDE依賴Layout模組和Store模組,Layout依賴Menu等模組。如果使用指令式程式設計,IDE程式碼如下:
function IDE(options){
// 處理屬性
...
// 初始化元件
this.layout = Layout.init(options.layout)
this.store = Store.init(options.store)
// 渲染元件
return Layout.render(this.layout)
}複製程式碼
作為一個初級程式設計師,這樣完全能實現業務需求。但是缺點顯而易見,邏輯與資料完全耦合在IDE內部,只要業務有變動,我們就需要頻繁修改元件的程式碼。這樣非常不利於程式碼維護。
我們來把程式碼改造下。把邏輯封裝在配置宣告中。
// 宣告式
const options = {
title: 'ide',
args: 'ide args',
dependency: {
Layout: {
title: 'layout',
args: 'layout args',
dependency: {
Menu: {
title: 'menus',
args: 'menus args',
}
},
},
Store: {
title: 'store',
args: 'store args',
},
},
}複製程式碼
我們把元件的依賴放到配置檔案中的dependency欄位,依賴關係清晰可見。而不用像程式式程式設計中,我們需要閱讀原始碼,才能看懂元件之間的依賴於呼叫關係。
使用的地方更加簡潔
const render = Frame().render
const dispatchOptions = options => ({ render: () => render(options) })
const Menu = dispatchOptions
const Store = dispatchOptions
const Layout = dispatchOptions
const Ide = dispatchOptions複製程式碼
我們看到,我們僅僅是拿到了配置,呼叫了render函式而已。
而框架程式碼處理程式碼,也很簡單
const keys = Object.keys
const json2Str = 'stringify'
const drawHandleName = 'render'
const Util = {
resolve: (string, handle) => eval(string)[handle](),
tranlateJSON: (object, type) => JSON[type](object),
concat: (items, sep) => Array.isArray(items) ? items.join(sep) : items,
}
const { resolve, tranlateJSON, concat } = Util
const Frame = () => {
const renderModule = (children, funName) => {
const finalFunArgs = tranlateJSON(children[funName], json2Str)
return resolve(`${funName}(${finalFunArgs})`, drawHandleName)
}
const analyzeDependency = (dependency = {} ) =>
concat(keys(dependency).map(childrenName => renderModule(dependency, childrenName)), '')
const render = ({ title, options, dependency }) =>`標題:${title}-引數:${options}${analyzeDependency(dependency)}`
return {
render,
}
}複製程式碼
最終對外只暴露了一個render函式,這個函式拿到元件的配置與依賴,分別做輸入與處理依賴。
analyzeDependency拿到每個元件依賴,迴圈依賴,然後呼叫渲染模組函式。
渲染模組拿到依賴名字(如Layout)與引數args(如{ title: 'layout', options: 'layout options', dependency: ... }), 執行Layout().render(args)。把執行結果返回給analyzeDependency再返回給render。
在實際生產中,每個元件的引數分析函式可能並不相同,這個小例子裡並沒有處理。當然,這個並不難處理。
OK,這就是宣告式程式設計,業務需求並不關心Frame是如何處理,也就是說,我們無需關心計算機如何處理。每當有依賴變化時候,我們只需要處理宣告的配置與每個元件的render即可,實現起來非常簡單。
控制反轉-例子
當然,雖然上個例子做到了宣告式程式設計,但是依賴仍然耦合在配置宣告中,缺點如下:
- 不利於做配置宣告(宣告耦合,最後的宣告非常長,且難以分析)。
- 不利於做單元測試(因為我們必須依賴於最外層的IDE以及所有父級的元件和所有的配置宣告)。
- 不利於做邏輯分離,看似沒有任何邏輯依賴,但是每個元件render函式卻顯式呼叫了render函式。
- 不利於做依賴分析,每個元件的配置只有在執行時才能通過options傳入,在執行元件前,我們並不知道元件依賴哪些元件。(在不分析配置宣告的情況下)
現在,我們把剛才的程式碼做一下改動,把配置宣告挪到每個元件內,把控制權完全交給程式,讓程式根據元件配置,動態生成邏輯。
const render = ({ title, options }, children) =>
`
標題:${title}-引數:${options}
${children}
`
const Menu = () => {
return {
dependency: {},
render,
}
}
const Store = () => {
return {
dependency: {},
render,
}
}
const Layout = () => {
return {
dependency: {
Menu: {
title: 'menus',
options: 'menus options',
}
},
render,
}
}
const Ide = () => {
return {
dependency: {
Layout: {
title: 'layout',
options: 'layout options',
},
Store: {
title: 'store',
options: 'store options',
},
},
render,
}
}
const options = {
dependency: {
Ide: {
title: 'ide',
options: 'ide options',
},
},
}複製程式碼
我們看到,配置宣告移到了各個元件內部,而每個元件的配置自己實現了render函式,render函式不在依賴於框架,而是自己實現。每個render拿到子元件可以隨意處理。而框架要做的,就是把分析出每個元件要依賴的子元件,並且完成子元件的渲染,然後傳遞給父元件的render引數。
接下來,我們看一下框架的程式碼
const { assign, keys } = Object
const dependencyField = 'dependency'
const iocFrame = (options, modules) => {
const { dependency } = options
const concat = (items, sep) => Array.isArray(items) ? items.join(sep) : items
// 依賴彙總
const collectDependency = (depend, modules) => {
if (!depend || !keys(depend).length) return []
return keys(depend).map((moduleId) => {
const { dependency, render } = modules[moduleId]()
return assign(depend[moduleId], { render, dependency: collectDependency(dependency, modules) })
})
}
const dependencyTree = collectDependency(dependency, modules)
// 分析依賴
const analyzeDependency = (options, childrenName) => {
if (Array.isArray(options[childrenName])) {
const renderArgs = concat(options[childrenName].map((child, index) =>
analyzeDependency(child, childrenName)), '')
return options.render(options, renderArgs)
}
return options.render(options)
}
return {
render: () => analyzeDependency(dependencyTree[0], dependencyField),
}
}複製程式碼
collectDependency函式遞迴查詢所有的函式依賴,並且拼裝成一顆完整的依賴樹。analyzeDependency函式拿到依賴樹中的配置宣告,遞迴依次執行render函式。
注意renderArgs這個變數,我們遞迴分析出依賴的render函式返回值。把這個返回值,也就是元件所依賴所有子元件的render函式全部執行一遍,每次執行render的時候,我們查詢依賴,如果有依賴,把依賴當做引數在去遞迴查詢,直到沒有找到依賴為止。然後彙總所有render的返回結果,當做當前元件的子元件傳遞給當前元件的render函式。
有人說我們不是做了兩次遞迴,一次分析依賴,一次處理依賴,為什麼不在同一次遞迴內完成,這樣效能會有所增加,請注意,我們只在靜態初始化時候分析依賴,而在元件渲染時候才去處理依賴。而分析依賴,拿到完整的依賴列表,能讓我們處理更多的事情。
這樣我們僅僅宣告瞭配置(當然配置裡也可以有某些行為,就是這裡的render函式),通過框架來處理如何呼叫行為,做到了動態生成邏輯。
控制反轉
如果我們把函式呼叫完全宣告在配置宣告中,那是不是就可以不用寫任何邏輯,僅僅依靠宣告就可以完全控制邏輯的走向?沒錯,這就是控制反轉,把控制權完全交給底層框架(也可以認為是計算機),我們僅僅需要宣告函式即可,而函式如何呼叫,何時呼叫,我們需要框架來約定。至於框架如何約定,那完全取決於業務需求,對於同樣的業務,做好業務分析,找出常見(依賴)的變化,把不變的封裝成框架,把變化的留作配置宣告。
舉個例子:
function a(opts){
if (cond1)
b()
opts.forEach(opt=> c(opt))
if (cond2)
d()
...
}複製程式碼
這種程式碼通過控制反轉,如何宣告呢?
function a(opts) {
return {
logic: {
if_1: { condition: cond1 , handle: b, },
for: { source: opts, handle: opt => c(opt), },
if_2: { condition: cond2, handle: d, },
},
dependency : ['b', 'c', 'd']
}
}複製程式碼
分析程式碼也非常簡單,這裡賣個關子,各位感興趣的話,可以自己實現。
總結
筆者不愛寫總結,就醬。