1. 響應式前端框架
1.1. 什么是響應式開發
wiki上的解釋
reactive programming is a declarative programming paradigm concerned with data streams and the propagation of change(響應式開發是一種專注於數據流和變化傳播的聲明式編程范式)
所謂響應式編程,是指不直接進行目標操作,而是用另外一種更為簡潔的方式通過代理達到目標操作的目的。
聯想一下,在各個前端框架中,我們現在要改變視圖,不是用jquery命令式地去改變dom,而是通過setState(),修改this.data或修改$scope.data...
1.1.1. concept
舉個例子
let a =3;
let b= a*10;
console.log(b) //30
a=4
//b = a * 10
console.log(b)//30
這里b並不會自動根據a的值變化,每次都需要b = a * 10再設置一遍,b才會變。所以這里不是響應式的。
B和A之間就像excel里的表格公式一樣。
B1的值要“響應式”地根據A1編輯的值相應地變化
A | B | |
---|---|---|
1 | 4 | 40(fx=A1*10) |
onAChanged(() => {
b = a * 10
})
假設我們實現了這個函數:onAChanged。你可以認為這是一個觀察者,一個事件回調,或者一個訂閱者。
這無所謂,關鍵在於,只要我們完美地實現了這個方法,B就能永遠是10倍的a。
如果用命令式(命令式和聲明式)的寫法來寫,我們一般會寫成下面這樣:
<span class="cell b1"></span>
document
.querySelector(‘.cell.b1’)
.textContent = state.a * 10
把它改的聲明式一點,我們給它加個方法:
<span class="cell b1"></span>
onStateChanged(() => {
document
.querySelector(‘.cell.b1’)
.textContent = state.a * 10
})
更進一步,我們的標簽轉成模板,模板會被編譯成render函數,所以我們可以把上面的js變簡單點。
模板(或者是jsx渲染函數)設計出來,讓我們可以很方便的描述state和view之間的關系,就和前面說的excel公式一樣。
<span class="cell b1">
{{ state.a * 10 }}
</span>
onStateChanged(() => {
view = render(state)
})
我們現在已經得到了那個漂亮公式,大家對這個公式都很熟悉了:
view = render(state)
這里把什么賦值給view,在於我們怎么看。在虛擬dom那,就是個新的虛擬dom樹。我們先不管虛擬dom,認為這里就是直接操作實際dom。
但是我們的應用怎么知道什么時候該重新執行這個更新函數onStateChanged?
let update
const onStateChanged = _update => {
update = _update
}
const setState = newState => {
state = newState
update()
}
設置新的狀態的時候,調用update()方法。狀態變更的時候,更新。
同樣,這里只是一段代碼示意。
1.2. 不同的框架中
在react里:
onStateChanged(() => {
view = render(state)
})
setState({ a: 5 })
redux:
store.subscribe(() => {
view = render(state)
})
store.dispatch({
type: UPDATE_A,
payload: 5
})
angularjs
$scope.$watch(() => {
view = render($scope)
})
$scope.a = 5
// auto-called in event handlers
$scope.$apply()
angular2+:
ngOnChanges() {
view = render(state)
})
state.a = 5
// auto-called if in a zone
Lifecycle.tick()
真實的框架里肯定不會這么簡單,而是需要更新一顆復雜的組件樹。
1.3. 更新過程
如何實現的?是同步的還是異步的?
1.3.1. angularjs (臟檢查)
臟檢查核心代碼
(可具體看test_cast第30行用例講解)
Scope.prototype.$$digestOnce = function () { //digestOnce至少執行2次,並最多10次,ttl(Time To Live),可以看test_case下gives up on the watches after 10 iterations的用例
var self = this;
var newValue, oldValue, dirty;
_.forEachRight(this.$$watchers, function (watcher) {
try {
if (watcher) {
newValue = watcher.watchFn(self);
oldValue = watcher.last;
if (!self.$$areEqual(newValue, oldValue, watcher.valueEq)) {
self.$$lastDirtyWatch = watcher;
watcher.last = (watcher.valueEq ? _.cloneDeep(newValue) : newValue);
watcher.listenerFn(newValue,
(oldValue === initWatchVal ? newValue : oldValue),
self);
dirty = true;
} else if (self.$$lastDirtyWatch === watcher) {
return false;
}
}
} catch (e) {
// console.error(e);
}
});
return dirty;
};
digest循環是同步進行。當觸發了angularjs的自定義事件,如ng-click,$http,$timeout等,就會同步觸發臟值檢查。(angularjs-demos/twowayBinding)
唯一優化就是通過lastDirtyWatch變量來減少watcher數組后續遍歷(這里可以看test_case:'ends the digest when the last watch is clean')。demo下有src
其實提供了一個異步更新的API叫$applyAsync。需要主動調用。
比如$http下設置useApplyAsync(true),就可以合並處理幾乎在相同時間得到的http響應。
angularjs為什么將會逐漸退出(注意不是angular),雖然目前仍然有大量的歷史項目仍在使用。
- 數據流不清晰,回環,雙向 (子scope是可以修改父scope屬性的,比如test_case里can manipulate a parent scope's property)
- api太復雜,黑科技
- 組件化大勢所趨
1.3.2. react (調和過程)
調和代碼
function reconcile(parentDom, instance, element) { //instance代表已經渲染到dom的元素對象,element是新的虛擬dom
if (instance == null) { //1.如果instance為null,就是新添加了元素,直接渲染到dom里
// Create instance
const newInstance = instantiate(element);
parentDom.appendChild(newInstance.dom);
return newInstance;
} else if (element == null) { //2.element為null,就是刪除了頁面的中的節點
// Remove instance
parentDom.removeChild(instance.dom);
return null;
} else if (instance.element.type === element.type) { //3.類型一致,我們就更新屬性,復用dom節點
// Update instance
updateDomProperties(instance.dom, instance.element.props, element.props);
instance.childInstances = reconcileChildren(instance, element); //調和子元素
instance.element = element;
return instance;
} else { //4.類型不一致,我們就直接替換掉
// Replace instance
const newInstance = instantiate(element);
parentDom.replaceChild(newInstance.dom, instance.dom);
return newInstance;
}
}
//子元素調和的簡單版,沒有匹配子元素加了key的調和
//這個算法只會匹配子元素數組同一位置的子元素。它的弊端就是當兩次渲染時改變了子元素的排序,我們將不能復用dom節點
function reconcileChildren(instance, element) {
const dom = instance.dom;
const childInstances = instance.childInstances;
const nextChildElements = element.props.children || [];
const newChildInstances = [];
const count = Math.max(childInstances.length, nextChildElements.length);
for (let i = 0; i < count; i++) {
const childInstance = childInstances[I];
const childElement = nextChildElements[I];
const newChildInstance = reconcile(dom, childInstance, childElement); //遞歸調用調和算法
newChildInstances.push(newChildInstance);
}
return newChildInstances.filter(instance => instance != null);
}
setState不會立即同步去調用頁面渲染(不然頁面就會一直在刷新了😭),setState通過引發一次組件的更新過程來引發重新繪制(一個事務里).
源碼的setState在src/isomorphic/modern/class/ReactComponent.js下(15.0.0)
舉例:
this.state = {
count:0
}
function incrementMultiple() {
const currentCount = this.state.count;
this.setState({count: currentCount + 1});
this.setState({count: currentCount + 1});
this.setState({count: currentCount + 1});
}
上面的setState會被加上多少?
在React的setState函數實現中,會根據一個變量isBatchingUpdates判斷是直接更新this.state還是放到隊列中回頭再說,而isBatchingUpdates默認是false,也就表示setState會同步更新this.state,但是,有一個函數batchedUpdates,這個函數會把isBatchingUpdates修改為true,而當React在調用事件處理函數之前就會調用這個batchedUpdates,造成的后果,就是由React控制的事件處理過程setState不會同步更新this.state。
但如果你寫個setTimeout或者使用addEventListener添加原生事件,setState后state就會被同步更新,並且更新后,立即執行render函數。
(示例在demo/setState-demo下)
那么react會在什么時候統一更新呢,這就涉及到源碼里的另一個概念事務。事務這里就不詳細展開了,我們現在只要記住一點,點擊事件里不管設置幾次state,都是處於同一個事務里。
1.3.3. vue(依賴追蹤)
核心代碼:
export function defineReactive(obj, key, val) {
var dep = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
// console.log('geter be called once!')
var value = val
if (Dep.target) {
dep.depend()
}
return value
},
set: function reactiveSetter(newVal) {
// console.log('seter be called once!')
var value = val
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
val = newVal
dep.notify()
}
})
}
1.3.4. 組件樹的更新
react的setState
vue的this.Obj.x = xxx
angular的state.x = x
優化方法
在vue中,組件的依賴是在渲染過程中自動追蹤的,所以系統能精確知道哪個組件確實需要被重渲染。你可以理解為每一個組件都已經自動獲得了shouldComponentUpdate,但依賴收集太過細粒度的時候,也是有一定的性能開銷。
1.4. MV*和組件化開發
1.4.1. MV*設計
MVP是MVC的變種
View與Model不發生聯系,都通過Presenter傳遞。Model和View的完全解耦
View非常薄,不部署任何業務邏輯,稱為“被動視圖”,即沒有任何主動性,而Presenter非常厚,所有邏輯都在這里。
Presenter調用View的方法去設置界面,仍然需要大量的、煩人的代碼,這實在是一件不舒服的事情。
能不能告訴View一個數據結構,然后View就能根據這個數據結構的變化而自動隨之變化呢?
於是ViewModel出現了,通過雙向綁定省去了很多在View層中寫很多case的情況,只需要改變數據就行。(angularjs和vuejs都是典型的mvvm架構)
另外,MVC太經典了,目前在客戶端(IOS,Android)以及后端仍然廣泛使用。
1.4.1.1. 那么前端的MVC或者是MV*有什么問題呢?
-
controller 和 view 層高耦合
下圖是view層和controller層在前端和服務端如何交互的,可以看到,在服務端看來,view層和controller層只兩個交互。透過前端和后端的之間。
但是把mvc放到前端就有問題了,controller高度依賴view層。在某些框架里,甚至是被view來創建的(比如angularjs的ng-controller)。controller要同時處理事件響應和業務邏輯,打破了單一職責原則,其后果可能是controller層變得越來越臃腫。
-
過於臃腫的Model層
另一方面,前端有兩種數據狀態需要處理,一個是服務端過來的應用狀態,一個是前端本身的UI狀態(按鈕置不置灰,圖標顯不顯示,)。同樣違背了Model層的單一職責。
1.4.1.2. 組件化的開發方式怎么解決的呢?
組件就是: 視圖 + 事件處理+ UI狀態.
下圖可以看到Flux要做的事,就是處理應用狀態和業務邏輯
很好的實現關注點分離
1.5. 虛擬dom,模板以及jsx
1.5.1. vue和react
虛擬dom其實就是一個輕量的js對象。
比如這樣:
const element = {
type: "div",
props: {
id: "container",
children: [
{ type: "input", props: { value: "foo", type: "text" } },
{ type: "a", props: { href: "/bar" } },
{ type: "span", props: {} }
]
}
};
對應於下面的dom:
<div id="container">
<input value="foo" type="text">
<a href="/bar"></a>
<span></span>
</div>
通過render方法(相當於ReactDOM.render)渲染到界面
function render(element, parentDom) {
const { type, props } = element;
const dom = document.createElement(type);
const childElements = props.children || [];
childElements.forEach(childElement => render(childElement, dom)); //遞歸
parentDom.appendChild(dom);
// ``` 對其添加屬性和事件監聽
}
jsx
<div id="container">
<input value="foo" type="text" />
<a href="/bar">bar</a>
<span onClick={e => alert("Hi")}>click me</span>
</div>
一種語法糖,如果不這么寫的話,我們就要直接采用下面的函數調用寫法。
babel(一種預編譯工具)會把上面的jsx轉換成下面這樣:
const element = createElement(
"div",
{ id: "container" },
createElement("input", { value: "foo", type: "text" }),
createElement(
"a",
{ href: "/bar" },
"bar"
),
createElement(
"span",
{ onClick: e => alert("Hi") },
"click me"
)
);
createElement會返回上面的虛擬dom對象,也就是一開始的element
function createElement(type, config, ...args) {
const props = Object.assign({}, config);
const hasChildren = args.length > 0;
props.children = hasChildren ? [].concat(...args) : [];
return { type, props };
//...省略一些其他處理
}
同樣,我們在寫vue實例的時候一般這樣寫:
// template模板寫法(最常用的)
new Vue({
data: {
text: "before",
},
template: `
<div>
<span>text:</span> {{text}}
</div>`
})
// render函數寫法,類似react的jsx寫法
new Vue({
data: {
text: "before",
},
render (h) {
return (
<div>
<span>text:</span> {{text}}
</div>
)
}
})
由於vue2.x也引入了虛擬dom,他們會先被解析函數轉換成同一種表達方式
new Vue({
data: {
text: "before",
},
render(){
return this.__h__('div', {}, [
this.__h__('span', {}, [this.__toString__(this.text)])
])
}
})
這里的this.h 就和react下的creatElement方法一致。
1.5.2. js解析器:parser
最后,模板的里的表達式都是怎么變成頁面結果的?
舉個簡單的例子,比如在angular或者vue的模板里寫上{{a+b}}
經過詞法分析(lexer)就會變成一些符號(Tokens)
[
{text: 'a', identifier: true},
{text: '+'},
{text: 'b', identifier: true}
]
然后經過(AST Builder)就轉化成抽象語法數(AST)
{
type: AST.BinaryExpression,
operator: '+',
left: {
type: AST.Identifier,
name: 'a' },
right: {
type: AST.Identifier,
name: 'b'
} }
最后經過AST Compiler變成表達式函數
function(scope) {
return scope.a + scope.b;
}
- 詞法分析會一個個讀取字符,然后做不同地處理,比如會有peek方法,如當遇到x += y這樣的表達式,處理+時會去多掃描一個字符。
(可以看下angularjs源碼test_case下516行的'parses an addition',最后ASTCompiler.prototype.compile返回的函數)
1.6. rxjs
響應式開發最流行的庫:rxjs
Netflix,google和微軟對reactivex項目的貢獻很大reactivex
RxJS是ReactiveX編程理念的JavaScript版本。ReactiveX來自微軟,它是一種針對異步數據流的編程。簡單來說,它將一切數據,包括HTTP請求,DOM事件或者普通數據等包裝成流的形式,然后用強大豐富的操作符對流進行處理,使你能以同步編程的方式處理異步數據,並組合不同的操作符來輕松優雅的實現你所需要的功能。
示例在demos/rxjs-demo下
1.7. 小結
響應式開發是趨勢,當前各個前端框架都有自己的響應式系統實現。另外,Observable應該會加入到ES標准里,可能會在ES7+加入。
https://medium.freecodecamp.org/is-mvc-dead-for-the-frontend-35b4d1fe39ec?gi=3d39e0be4c84#.q25l7qkpu