Table of Contents generated with DocToc
MVVM consiste dos três seguintes conceitos
- View: Interface
- Model:Modelo de dados
- ViewModel:Como uma ponte responsável pela comunicação entre a View e o Model
Na época do JQuery, se você precisar atualizar a UI, você precisar obter o DOM correspondente e então atualizar a UI, então os dados e as regras de negócio estão fortemente acoplados com a página.
No MVVM, o UI é condizudo pelos dados. Uma vez que o dado mudou, a UI correspondente será atualizada. Se a UI mudar, o dado correspondente também ira mudar. Dessas forma, nos preocupamos apenas com o fluxo de dados no processamento do negócio sem lidar com a página diretamente. ViewModel apenas se preocupa com o processamento de dados e regras de negócio e não se preocupa como a View manipula os dados. Nesse caso, nós podemos separar a View da Model. Se qualquer uma das partes mudarem, isso não necessariamente precisa mudar na outra parte, e qualquer lógica reusável pode ser colocado na ViewModel, permitindo multiplas View reusarem esse ViewModel.
No MVVM, o núcleo é two-way binding de dados, tal como a verificação suja do Angular e sequestro de dados no Vue.
Quando o evento especificado é disparado, ele irá entrar na verificação suja e chamar o laço $digest
caminhando através de todos os dados observados para determinar se o valor atual é diferente do valor anterior. Se a mudança é detectada, irá chamar a função $watch
, e então chamar o laço $digest
novamente até que nenhuma mudança seja encontrada. O ciclo vai de pelo menos de duas vezes até dez vezes.
Embora a verificação suja ser ineficiente, ele consegue completar a tarefa sem se preocupar sobre como o dado mudou, mas o two-way binding no Vue
é problemático. E a verificação suja consegue alcançar detecção de lotes de valores atualizados, e então unificar atualizações na UI, com grandeza reduzindo o número de operações no DOM. Assim sendo, ineficiência é relativa, e é assim que o benevolente vê o sábio e a sabedoria.
Vuew internamente usa Obeject.defineProperty()
para implementar o two-way binding, do qual permite você escutar por eventos de set
e get
.
var data = { name: 'yck' }
observe(data)
let name = data.name // -> ontém o valor
data.name = 'yyy' // -> muda o valor
function observe(obj) {
// juiz do tipo
if (!obj || typeof obj !== 'object') {
return
}
Object.keys(obj).forEach(key => {
defineReactive(obj, key, obj[key])
})
}
function defineReactive(obj, key, val) {
// recurse as propriedades dos filhos
observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
console.log('get value')
return val
},
set: function reactiveSetter(newVal) {
console.log('change value')
val = newVal
}
})
}
O código acima é uma simples implementação de como escutar os eventos set
e get
dos dados, mas isso não é o suficiente. Você também precisa adicionar um Publish/Subscribe para as propriedades quando apropriado.
<div>
{{name}}
</div>
::: v-pre
Nesse processo, análisando o código do modelo, como acima, quando encontrando {{name}}
, adicione um publish/subscribe para a propriedade name
:::
// dissociar por Dep
class Dep {
constructor() {
this.subs = []
}
addSub(sub) {
// Sub é uma instância do observador
this.subs.push(sub)
}
notify() {
this.subs.forEach(sub => {
sub.update()
})
}
}
// Propriedade global, configura o observador com essa propriedade
Dep.target = null
function update(value) {
document.querySelector('div').innerText = value
}
class Watcher {
constructor(obj, key, cb) {
// Aponte Dep.target para se mesmo
// Então dispare o getter para a propriedade adicionar o ouvinte
// Finalmente, set Dep.target como null
Dep.target = this
this.cb = cb
this.obj = obj
this.key = key
this.value = obj[key]
Dep.target = null
}
update() {
// obtenha o novo valor
this.value = this.obj[this.key]
// update Dom with the update method
// atualize o DOM com o método de atualizar
this.cb(this.value)
}
}
var data = { name: 'yck' }
observe(data)
// Simulando a ação disparada analisando o `{{name}}`
new Watcher(data, 'name', update)
// atualiza o DOM innerText
data.name = 'yyy'
Next, improve on the defineReactive
function.
function defineReactive(obj, key, val) {
// recurse as propriedades do filho
observe(val)
let dp = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
console.log('get value')
// Adiciona o Watcher para inscrição
if (Dep.target) {
dp.addSub(Dep.target)
}
return val
},
set: function reactiveSetter(newVal) {
console.log('change value')
val = newVal
// Execute o método de atualização do Watcher
dp.notify()
}
})
}
A implementação acima é um simples two-way binding. A idéia central é manualmente disparar o getter das propriedades para adicionar o Publish/Subscribe.
Apesar do Obeject.defineProperty
ser capaz de implementar o two-way binding, ele ainda é falho.
- Ele consegue implementar apenas o sequestro de dados nas propriedades,
- ele não consegue escutar a mudança de dados para arrays
Apesar de Vue conseguir detectar mudanças em um array de dados, é na verdade um hack e é falho.
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
// hack as seguintes funções
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
methodsToPatch.forEach(function (method) {
// obter a função nativa
const original = arrayProto[method]
def(arrayMethods, method, function mutator (...args) {
// chama a função nativa
const result = original.apply(this, args)
const ob = this.__ob__
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
if (inserted) ob.observeArray(inserted)
// dispara uma atualização
ob.dep.notify()
return result
})
})
Por outro lado, Proxy
não tem o problema acima. Ele suporta nativamente a escuta para mudança no array e consegue interceptar o objeto completo diretamente, então Vue irá também substituir Obeject.defineProperty
por Proxy
na próxima grande versão.
let onWatch = (obj, setBind, getLogger) => {
let handler = {
get(target, property, receiver) {
getLogger(target, property)
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
setBind(value);
return Reflect.set(target, property, value);
}
};
return new Proxy(obj, handler);
};
let obj = { a: 1 }
let value
let p = onWatch(obj, (v) => {
value = v
}, (target, property) => {
console.log(`Get '${property}' = ${target[property]}`);
})
p.a = 2 // vincula `value` para `2`
p.a // -> obtém 'a' = 2
As rotas no front-end é atualmente simples de implementar. A essência é escutar as mudanças na URL, então coincidir com as regras de roteamento, exibindo a página correspondente, e não precisa atualizar. Atualmente, existe apenas duas implementações de rotas usados pela página única.
- modo hash
- modo history
www.test.com/#/
é a hash URL. Quando o valor depois do hash #
muda, nenhuma request será enviada ao servidor. Você pode escutar as mudanças na URL através do evento hashchange
, e então pular para a página correspondente.
O modo history é uma nova funcionalidade do HTML5, do qual é muito mais lindo que o Hash URL.
Como nós sabemos, modificar o DOM é uma tarefa custosa. Poderiamos considerar usar objetos JS para simular objetos DOM, desde de que operando em objetos JS é economizado muito mais tempo que operar no DOM.
Por exemplo
// Vamos assumir que esse array simula um ul do qual cotém 5 li's.
[1, 2, 3, 4, 5]
// usando esse para substituir a ul acima.
[1, 2, 5, 4]
A partir do exemplo acima, é aparente que a terceira li foi removida, e a quarta e quinta mudaram suas posições
Se a operação anterior for aplicada no DOM, nós temos o seguinte código:
// removendo a terceira li
ul.childNodes[2].remove()
// trocando internamente as posições do quarto e quinto elemento
let fromNode = ul.childNodes[4]
let toNode = node.childNodes[3]
let cloneFromNode = fromNode.cloneNode(true)
let cloenToNode = toNode.cloneNode(true)
ul.replaceChild(cloneFromNode, toNode)
ul.replaceChild(cloenToNode, fromNode)
De fato, nas operações atuais, nós precisamos de um identificador para cada nó, como um index para verificar se os dois nós são idênticos. Esse é o motivo de ambos Vue e React sugerirem na documentação oficial usar identificadores key
para os nós em uma lista para garantir eficiência.
Elementos do DOM não só podem ser simulados, mas eles também podem ser renderizados por objetos JS.
Abaixo está uma simples implementação de um objeto JS simulando um elemento DOM.
export default class Element {
/**
* @param {String} tag 'div'
* @param {Object} props { class: 'item' }
* @param {Array} children [ Element1, 'text']
* @param {String} key option
*/
constructor(tag, props, children, key) {
this.tag = tag
this.props = props
if (Array.isArray(children)) {
this.children = children
} else if (isString(children)) {
this.key = children
this.children = null
}
if (key) this.key = key
}
// renderização
render() {
let root = this._createElement(
this.tag,
this.props,
this.children,
this.key
)
document.body.appendChild(root)
return root
}
create() {
return this._createElement(this.tag, this.props, this.children, this.key)
}
// criando um elemento
_createElement(tag, props, child, key) {
// criando um elemento com tag
let el = document.createElement(tag)
// definindo propriedades em um elemento
for (const key in props) {
if (props.hasOwnProperty(key)) {
const value = props[key]
el.setAttribute(key, value)
}
}
if (key) {
el.setAttribute('key', key)
}
// adicionando nós filhos recursivamente
if (child) {
child.forEach(element => {
let child
if (element instanceof Element) {
child = this._createElement(
element.tag,
element.props,
element.children,
element.key
)
} else {
child = document.createTextNode(element)
}
el.appendChild(child)
})
}
return el
}
}
The next step after using JS to implement DOM element is to detect object changes.
DOM is a multi-branching tree. If we were to compare the old and the new trees thoroughly, the time complexity would be O(n ^ 3), which is simply unacceptable. Therefore, the React team optimized their algorithm to achieve an O(n) complexity for detecting changes.
The key to achieving O(n) is to only compare the nodes on the same level rather than across levels. This works because in actual usage we rarely move DOM elements across levels.
We then have two steps of the algorithm.
- from top to bottom, from left to right to iterate the object, aka depth first search. This step adds an index to every node, for rendering the differences later.
- whenever a node has a child element, we check whether the child element changed.
First let's implement the recursion algorithm of the tree. Before doing that, let's consider the different cases of comparing two nodes.
- new nodes's
tagName
orkey
is different from that of the old one. This menas the old node is replaced, and we don't have to recurse on the node any more because the whole subtree is removed. - new node's
tagName
andkey
(maybe nonexistent) are the same as the old's. We start recursing on the subtree. - no new node appears. No operation needed.
import { StateEnums, isString, move } from './util'
import Element from './element'
export default function diff(oldDomTree, newDomTree) {
// for recording changes
let pathchs = {}
// the index starts at 0
dfs(oldDomTree, newDomTree, 0, pathchs)
return pathchs
}
function dfs(oldNode, newNode, index, patches) {
// for saving the subtree changes
let curPatches = []
// three cases
// 1. no new node, do nothing
// 2. new nodes' tagName and `key` are different from the old one's, replace
// 3. new nodes' tagName and key are the same as the old one's, start recursing
if (!newNode) {
} else if (newNode.tag === oldNode.tag && newNode.key === oldNode.key) {
// check whether properties changed
let props = diffProps(oldNode.props, newNode.props)
if (props.length) curPatches.push({ type: StateEnums.ChangeProps, props })
// recurse the subtree
diffChildren(oldNode.children, newNode.children, index, patches)
} else {
// different node, replace
curPatches.push({ type: StateEnums.Replace, node: newNode })
}
if (curPatches.length) {
if (patches[index]) {
patches[index] = patches[index].concat(curPatches)
} else {
patches[index] = curPatches
}
}
}
We also have three steps for checking for property changes
- iterate the old property list, check if the property still exists in the new property list.
- iterate the new property list, check if there are changes for properties existing in both lists.
- for the second step, also check if a property doesn't exist in the old property list.
function diffProps(oldProps, newProps) {
// three steps for checking for props
// iterate oldProps for removed properties
// iterate newProps for chagned property values
// lastly check if new properties are added
let change = []
for (const key in oldProps) {
if (oldProps.hasOwnProperty(key) && !newProps[key]) {
change.push({
prop: key
})
}
}
for (const key in newProps) {
if (newProps.hasOwnProperty(key)) {
const prop = newProps[key]
if (oldProps[key] && oldProps[key] !== newProps[key]) {
change.push({
prop: key,
value: newProps[key]
})
} else if (!oldProps[key]) {
change.push({
prop: key,
value: newProps[key]
})
}
}
}
return change
}
This algorithm is the core of the Virtual Dom. Let's go down the list. The main steps are similar to checking property changes. There are also three steps.
- iterate the old node list, check if the node still exists in the new list.
- iterate the new node list, check if there is any new node.
- for the second step, also check if a node moved.
PS: this algorithm only handles nodes with key
s.
function listDiff(oldList, newList, index, patches) {
// to make the iteration more convenient, first take all keys from both lists
let oldKeys = getKeys(oldList)
let newKeys = getKeys(newList)
let changes = []
// for saving the node daa after changes
// there are several advantages of using this array to save
// 1. we can correctly obtain the index of the deleted node
// 2. we only need to operate on the DOM once for interexchanged nodes
// 3. we only need to iterate for the checking in the `diffChildren` function
// we don't need to check again for nodes existing in both lists
let list = []
oldList &&
oldList.forEach(item => {
let key = item.key
if (isString(item)) {
key = item
}
// checking if the new children has the current node
// if not then delete
let index = newKeys.indexOf(key)
if (index === -1) {
list.push(null)
} else list.push(key)
})
// array after iterative changes
let length = list.length
// since deleting array elements changes the indices
// we remove from the back to make sure indices stay the same
for (let i = length - 1; i >= 0; i--) {
// check if the current element is null, if so then it means we need to remove it
if (!list[i]) {
list.splice(i, 1)
changes.push({
type: StateEnums.Remove,
index: i
})
}
}
// iterate the new list, check if a node is added or moved
// also add and move nodes for `list`
newList &&
newList.forEach((item, i) => {
let key = item.key
if (isString(item)) {
key = item
}
// check if the old children has the current node
let index = list.indexOf(key)
// if not then we need to insert
if (index === -1 || key == null) {
changes.push({
type: StateEnums.Insert,
node: item,
index: i
})
list.splice(i, 0, key)
} else {
// found the node, need to check if it needs to be moved.
if (index !== i) {
changes.push({
type: StateEnums.Move,
from: index,
to: i
})
move(list, index, i)
}
}
})
return { changes, list }
}
function getKeys(list) {
let keys = []
let text
list &&
list.forEach(item => {
let key
if (isString(item)) {
key = [item]
} else if (item instanceof Element) {
key = item.key
}
keys.push(key)
})
return keys
}
For this function, there are two main functionalities.
- checking differences between two lists
- marking nodes
In general, the functionalities impelemented are simple.
function diffChildren(oldChild, newChild, index, patches) {
let { changes, list } = listDiff(oldChild, newChild, index, patches)
if (changes.length) {
if (patches[index]) {
patches[index] = patches[index].concat(changes)
} else {
patches[index] = changes
}
}
// marking last iterated node
let last = null
oldChild &&
oldChild.forEach((item, i) => {
let child = item && item.children
if (child) {
index =
last && last.children ? index + last.children.length + 1 : index + 1
let keyIndex = list.indexOf(item.key)
let node = newChild[keyIndex]
// only iterate nodes existing in both lists
// no need to visit the added or removed ones
if (node) {
dfs(item, node, index, patches)
}
} else index += 1
last = item
})
}
From the earlier algorithms, we can already get the differences between two trees. After knowing the differences, we need to locally update DOM. Let's take a look at the last step of Virtual Dom algorithms.
Two main functionalities for this function
- Deep search the tree and extract the nodes needing modifications
- Locally update DOM
This code snippet is pretty easy to understand as a whole.
let index = 0
export default function patch(node, patchs) {
let changes = patchs[index]
let childNodes = node && node.childNodes
// this deep search is the same as the one in diff algorithm
if (!childNodes) index += 1
if (changes && changes.length && patchs[index]) {
changeDom(node, changes)
}
let last = null
if (childNodes && childNodes.length) {
childNodes.forEach((item, i) => {
index =
last && last.children ? index + last.children.length + 1 : index + 1
patch(item, patchs)
last = item
})
}
}
function changeDom(node, changes, noChild) {
changes &&
changes.forEach(change => {
let { type } = change
switch (type) {
case StateEnums.ChangeProps:
let { props } = change
props.forEach(item => {
if (item.value) {
node.setAttribute(item.prop, item.value)
} else {
node.removeAttribute(item.prop)
}
})
break
case StateEnums.Remove:
node.childNodes[change.index].remove()
break
case StateEnums.Insert:
let dom
if (isString(change.node)) {
dom = document.createTextNode(change.node)
} else if (change.node instanceof Element) {
dom = change.node.create()
}
node.insertBefore(dom, node.childNodes[change.index])
break
case StateEnums.Replace:
node.parentNode.replaceChild(change.node.create(), node)
break
case StateEnums.Move:
let fromNode = node.childNodes[change.from]
let toNode = node.childNodes[change.to]
let cloneFromNode = fromNode.cloneNode(true)
let cloenToNode = toNode.cloneNode(true)
node.replaceChild(cloneFromNode, toNode)
node.replaceChild(cloenToNode, fromNode)
break
default:
break
}
})
}
The implementation of the Virtual Dom algorithms contains the following three steps:
- Simulate the creation of DOM objects through JS
- Check differences between two objects
- Render the differences
let test4 = new Element('div', { class: 'my-div' }, ['test4'])
let test5 = new Element('ul', { class: 'my-div' }, ['test5'])
let test1 = new Element('div', { class: 'my-div' }, [test4])
let test2 = new Element('div', { id: '11' }, [test5, test4])
let root = test1.render()
let pathchs = diff(test1, test2)
console.log(pathchs)
setTimeout(() => {
console.log('start updating')
patch(root, pathchs)
console.log('end updating')
}, 1000)
Although the current implementation is simple, it's definitely enough for understanding Virtual Dom algorithms.