響應式前端框架

johnzhu12發表於2019-04-28

1. 響應式前端框架

@[toc]

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響應。

changeDetectorInAngular.jpg

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。

setStateProcess.png

但如果你寫個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()
        }
    })
}
複製程式碼

vueObserver.png

1.3.4. 元件樹的更新

react的setState vue的this.Obj.x = xxx angular的state.x = x

1.png

優化方法

2.png

在vue中,元件的依賴是在渲染過程中自動追蹤的,所以系統能精確知道哪個元件確實需要被重渲染。你可以理解為每一個元件都已經自動獲得了shouldComponentUpdate,但依賴收集太過細粒度的時候,也是有一定的效能開銷。

1.4. MV*和元件化開發

archDevelop.jpg

1.4.1. MV*設計

MVCDesign.png

MVPDesign.png

MVP是MVC的變種 View與Model不發生聯絡,都通過Presenter傳遞。Model和View的完全解耦 View非常薄,不部署任何業務邏輯,稱為“被動檢視”,即沒有任何主動性,而Presenter非常厚,所有邏輯都在這裡。

MVVMDesign.png

Presenter呼叫View的方法去設定介面,仍然需要大量的、煩人的程式碼,這實在是一件不舒服的事情。

能不能告訴View一個資料結構,然後View就能根據這個資料結構的變化而自動隨之變化呢?

於是ViewModel出現了,通過雙向繫結省去了很多在View層中寫很多case的情況,只需要改變資料就行。(angularjs和vuejs都是典型的mvvm架構)

另外,MVC太經典了,目前在客戶端(IOS,Android)以及後端仍然廣泛使用。

1.4.1.1. 那麼前端的MVC或者是MV*有什麼問題呢?

MVCDie.png

  • controller 和 view 層高耦合

    下圖是view層和controller層在前端和服務端如何互動的,可以看到,在服務端看來,view層和controller層只兩個互動。透過前端和後端的之間。

    serverMVC.png

    但是把mvc放到前端就有問題了,controller高度依賴view層。在某些框架裡,甚至是被view來建立的(比如angularjs的ng-controller)。controller要同時處理事件響應和業務邏輯,打破了單一職責原則,其後果可能是controller層變得越來越臃腫。

    clientMVC.png

  • 過於臃腫的Model層

    另一方面,前端有兩種資料狀態需要處理,一個是服務端過來的應用狀態,一個是前端本身的UI狀態(按鈕置不置灰,圖示顯不顯示,)。同樣違背了Model層的單一職責。

1.4.1.2. 元件化的開發方式怎麼解決的呢?

元件就是: 檢視 + 事件處理+ UI狀態.

下圖可以看到Flux要做的事,就是處理應用狀態和業務邏輯

componentDesign.png

很好的實現關注點分離

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}}

parser.png

經過詞法分析(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

Rx_Logo_S.png

響應式開發最流行的庫:rxjs

Netflix,google和微軟對reactivex專案的貢獻很大reactivex

RxJS是ReactiveX程式設計理念的JavaScript版本。ReactiveX來自微軟,它是一種針對非同步資料流的程式設計。簡單來說,它將一切資料,包括HTTP請求,DOM事件或者普通資料等包裝成流的形式,然後用強大豐富的操作符對流進行處理,使你能以同步程式設計的方式處理非同步資料,並組合不同的操作符來輕鬆優雅的實現你所需要的功能。

示例在demos/rxjs-demo下

1.7. 小結

響應式開發是趨勢,當前各個前端框架都有自己的響應式系統實現。另外,Observable應該會加入到ES標準裡,可能會在ES7+加入。

參考連結: medium.com/@j_lim_j/su…

medium.freecodecamp.org/is-mvc-dead…

相關文章