你是否对MVVM多少有点不解?
你是奇怪Jquery忽然成为“过时”的技术?
你是否想写出一个类似Vue的简单框架?
只要你会用原生JS, 不需要掌握Vue,react等高深技能,本文换种角度让你穷死理解现代前端框架。
0. 关于UI状态同步
有没有想过,为何使用现代前端框架?
React, Vue, Angular等提供很有意思的东西,如组件化,第三方UI组件,单网页支持,脚手加等工具。然而这些不是根本原因,《现代js 框架存在的根本原因》 给出本质原因:
现代前端框架支持UI状态同步。
所谓UI状态同步是指浏览器能实时显示JS中的数据,比如js中 name: '张三'
,则浏览器页面中显示张三
如果js中 `naee = '李四’, 则页面自动变为李四
在以前Jquery以前的时代。想实现这一操作困难重重,需要不断的更新dom, 不仅性能首限,而且零碎的dom操作代码容易导致代码混乱。如何决这个问题呢? 按前端发展的历程,分为四步:
- 观察者
- 脏检查
- 描述属性符
- 代理
1. 观察者
你没看错,这里的观察者就是N大设计模式中的观察者模式。在网页中,观察者即UI中显示的内容,被观察者就是JS中存储的数据。
JS中存储的数据通常会在页面多个地方显示, 一个被观察者可以对应多个观察者。
我们需要JS中数据的变动引起页面的变动,即被观察者变动,引起对应的观察者A、观察者B等变动,这就是观察者模式。
实现此过程很简单,把被观察者存在一个称为订阅池的数组中,观察者变动时循环遍历订阅池数组,更新观察者即可。
1.1 最简单的例子
下面用最简单的例子展示观察者。该例子简单到用日志输出console.log() 代表UI变动。
var uiName1 = function (val) {
console.log('Name#1 become:' + val)
};
var uiName2 = function (val) {
console.log('Name#2 become:' + val)
};
var subjects = [];
subjects.push(uiName1);
subjects.push(uiName2);
function set (val) {
subjects.forEach(item => {
item(val)
})
}
- 建立第一个观察者uiName1, 表示页面中有一个地方显示姓名。
var uiName1 = function (val) {
console.log('Name#1 become:' + val)
};
2.建立第二个观察者uiName1, 表示页面中另一个地方显示姓名。
...
var uiName2 = function (val) {
console.log('Name#2 become:' + val)
};
- 创建一个数组,表示订阅池 (有些地方也写作watchers)。订阅池是本文最重要的三个概念之一。
...
var subjects = []
- 将两个观察者放入订阅池中
...
subjects.push(uiName1);
subjects.push(uiName2);
- 写一个set函数表示观察者的变动, 参数
val
表示姓名值
...
function set (val) {
subjects.forEach(item => {
item(val)
})
}
打开浏览器命令行,打入set('Tom')
,便可看到Name发生变化
1.2 多个被观察者
上面的例子中只有一个被观察者Name,而实际中有多个被观察数据。修改上例,增加一个被观察者Age
var uiName1 = function (val) {
console.log('Name#1 become:' + val)
};
var uiName2 = function (val) {
console.log('Name#2 become:' + val)
};
// 增加一个新的被观察者Age
var uiAge = function (val) {
console.log('Age become:' + val)
};
// 改造订阅池
var subjects = {
name: [uiName1, uiName2],
age: [uiAge]
};
// 改造set函数
function set (key, val) {
subjects[key].forEach(item => {
item(val)
})
}
- 增加一个新的被观察者Age,通样用
console.log
表示变动
...
var uiAge = function (val) {
console.log('Age become:' + val)
};
...
- 改造订阅池,用对象的Key表示被观察者,Value为相应的观察者
...
var subjects = {
name: [uiName1, uiName2],
age: [uiAge]
};
...
- 改造set函数,遍历指定被观察者的观察者, 参数
key
表示被观察者name
和age
,val
表示样变成的值
...
function set (key, val) {
subjects[key].forEach(item => {
item(val)
})
}
...
在命令行控制台输入set('name','Tom')
, 会发现只有name
发生改变, 输入set('age',18)
z则只有age
发生改变
1.3在页面显示
总用命令行日志代码UI是不行的,我们把观察者模式用于网页。将上例中的
var uiName1 = function (val) {
console.log('Name#1 become:' + val)
};
var uiName2 = function (val) {
console.log('Name#2 become:' + val)
};
的观察者用下方的网页模版表示:
<p> {{name}} </p>
<p> {{name}} </p>
<p> {{age}} </p>
这种模版非常优雅,为简单起见,我们用如下方式呈现数据:
<p my-value='name'> </p>
<p my-value='name'> </p>
<p my-value='age'> </p>
我们需要将模版转为上例中观察者,这一个过程叫做模版解析compile, 这是本文最重要的三个概念之二
为确定渲染的范围,增加id='app'
,全部的html代码如下:
<html>
<head></head>
<body>
<p my-value='name'> </p>
<p my-value='name'> </p>
<p my-value='age'> </p>
</body>
</html>
下面增加JS部分的代码。
- 首先声明JS的数据,也就是前端框架常说的状态State:
var data = {
name: 'mike',
age: 1
};
- 创建订阅池和set函数,和上例几乎一样。只是需要把需要变的值赋值给
data
var subjects = {};
function set(key, val) {
data[key] = val
subjects[key].forEach(item=> {
item()
})
}
- 下面需做模版解析,即把模版解析成观察者。 参数id表示只解析
id
(‘app’
)范围内的html代码:
function compile (id) {
}
compile('app')
- 下面我们补充
comile()
解析函数
4.1 获取节点的全部子元素,nodes 的值为[<p my-value='name'> </p>, <p my-value='age'> </p>...]
function compile (id) {
var nodes = document.getElementById(id).children;
}
4.2 遍历子节点,node 的值为<p my-value='name'> </p>
,<p my-value='age'> </p>
等
function compile (id) {
var nodes = document.getElementById(id).children;
for (let i = 0; i < nodes.length; i ++ ) {
let node = nodes[i];
}
}
4.3 如果包含属性my-value
则获取该值,property
的值为 name
或 age
,表示被观察者。
...
let node = nodes[i]
if (node.hasAttribute('my-value')) {
let property = node.getAttribute('my-value');
...
4.4 如果订阅池中没有被观察者则放入被观察者
...
let property = node.getAttribute('my-value');
if (!subjects.hasOwnProperty(property)) {
subjects[property] = []
}
4.5 推入观察者至订阅池
...
if (!subjects.hasOwnProperty(property)) {
subjects[property] = []
}
subjects[property].push(()=>{
node.innerHTML = data[property]
})
...
4.6 修改Dom的显示
...
node.innerHTML = data[property]
...
完整JS代码如下
var data = {
name: 'mike',
age: 1
};
var subjects = {};
compile('app')
function set(key, val) {
data[key] = val
subjects[key].forEach(item=> {
item()
})
}
function compile(id) {
var nodes = document.getElementById(id).children;
for (let i = 0; i < nodes.length; i ++ ) {
let node = nodes[i];
if (node.hasAttribute('my-value')) {
let property = node.getAttribute('my-value');
if (!subjects.hasOwnProperty(property)) {
subjects[property] = []
}
subjects[property].push(()=>{
node.innerHTML = data[property]
})
node.innerHTML = data[property]
}
}
}
打开页面显示如下:
在命令行输入set('name', 'Jim')
会发现页面相应改变
输入`set('age', 99) 会改变年龄
1.4 使用者
emberJs, 微信小程序,react等都在使用观察者模式,利用set
,setData
或类似函数更新数据,很常见。
2.脏检查
观察者通过模版解析和订阅池实现了UI状态同步,然而想更新被观察者,需要手动的调用set(key, value)
函数,并不方便,如果JS中的状态变化能自动调用set函数就好啦。
为解决这个痛点,Angular1.0 提出脏检查这一概念。当触发了某些条件,比如页面加载完成,用户点击,或者一些数据发生改变后,会遍历所有的数据进行检查,如果发现有变化的地方则更新。
Angular虽然对脏检查做了很多优化,深入了解可以阅读angular脏检查原理及伪代码实现。但由于经常要遍历全部数据,对现在的大型网页应用而言,效率太慢。当Angular维护的状态达到数百后,可能会出现卡顿现象。
3 属性描述符
如何能搞效的进行UI状态同步? 属性描述符(或称为对象定义属性)defineProperty,给出答案。我们利用defineProperty的getter 和 setter劫持数据对象,当数据变动时会自动调用setter中的方法,进而改变页面。
Object.defineProperty
可以丰富对象的取值和赋值操作,语法如下:
Object.defineProperty(obj, prop, descriptor)
obj是目标对象, prop是属性名即键值,descriptor是目标属性所拥有的特性。返回值是被传递给函数的对象, 简言之一个对象。具体语法参见理解Object.defineProperty的作用
再看下面的例子
var data = {} // 被劫持的对象
Object.defineProperty(data, ‘name’, {
enumerable: true, // 可枚举
configurable: true, // 可忽略
get () { // 拦截取值
return val
},
set (newVal) { // 拦截赋值操作
val = newVal
console.log('我被劫持了')
}
})
当你在命令行执行 data.name = 'Tom'
时,会发现输出一条日志我被劫持了
3.1 用defineProperty做UI状态同步
仍然用之前的代码,只是增加对象的劫持操作
<div id=‘app’>
<p> {{name}} </p>
<p> {{name}} </p>
<p> {{age}} </p>
</div>
<script>
var data = {
name: 'mike',
age: 1
};
var subjects = {}
compile(‘app’)
obverser(data) // 注意这里,我妈劫持data啦
function set(key, val) {/* 同前例.. */}
function compile(el) {* 同前例.. */}
function obverser(data) {
// 注意这里,补全obverse函数
}
</script>
注意obverser(data)
这一行,obverse劫持对象,这是本文三个重点之三,参数data
是被劫持的数据。
obverser()
函数写起来也很简单,首先遍历data的每一个属性。Object.keys
能把对象的键转为一个数组如['name','age']
, forEach
遍历这个数组。
...
function obverser(data) {
Object.keys(data).forEach(key=>{
let value = data[key]
之后添加getter选项,直接返回数据的值即可。
...
function obverser(data) {
Object.keys(data).forEach(key=>{
let value = data[key]
Object.defineProperty(data, key, {
get () {
return value
},
最后增加getter拦截函数
...
Object.defineProperty(data, key, {
get () {
return value
},
set (newValue) {
if (value != newValue) { // 只有在赋不同值后才起作用,避免循环调用
// console.log('我被劫持啦')
value = newValue
set(key, value) // 以前需手动写的set函数,现在可以自动运行
}
}
})
})
}
至此完工,打开浏览器看看效果。
在Console中输入data.name= 'Jim'
试试? 看,不需要手动写set函数
再输入data.age = 99
改下年龄
3.2 小结
再理下思路,不外乎三点:
- UI中,对模版进行解析compile,产生观察者
- JS中,对状态state进行劫持(或称作观察)observer,产生被观察者
-
通过订阅池Watchers进行连接
本文的例子非常简单,只解释概念,如果继续完善下去,比如增加对表单onChange
事件的监听,可以做出一个类似Vue的MVVM的框架,有兴趣可以阅读《剖析vue实现原理,自己动手实现mvvm》
这就是《一种基于访问器劫持的前端数据双向绑定实现方法》,你没看错,这竟然被注册成专利,有兴趣可以深入阅读《双向绑定也能申请专利》
Vue,Angular 2以后的版本,以及国人出品的框架avalon在使用这种技术
4代理
事情往往并不完美,属性描述符defineProperty也是如此。在声明对象属性后,defineProperty才能对该属性进行劫持,于是vue中我们还需要写this.$set(data, key,val)
以添加新的属性。本节讲的代理将能完美解决definePropery的缺点。
代理Proxy, 作为ES6的新特性可能会遇到浏览器兼容问题。又由于profill的降级对代理几乎没用,很少有人将代理用于时间开发中,相信随着现代浏览器的普及这一现状将得到改变。使用代理Proxy前最好检查下浏览器的兼容问题,参加《Can I use proxy ?》
4.1 简明代理语法
Proxy代理, 可以理解在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。
比如,用proxy拦截取值操作:
var proxy = new Proxy({}, {
get: function () { // 拦截取值,类似getter
return 1;
}
}
proxy.name // 1
proxy.book //1
Proxy的写法都如此, 语法如下:
var proxy = new Proxy(target, handler)
new Proxy
表示生成一个Proxy
实例, taget参数表示所要拦截的目标对象。目标对象可以是js中的对象,数组,函数甚至另一个代理。handler参数也是一个对象,用来定制拦截行为。返回值是Proxy对象, new Proxy
是稳定操作,不会对target有任何影响。
常见handler 的有:
- get: 拦截取值
- set: 拦截赋值
- deleteProperty: 拦截删除
- apply: 拦截函数执行
- defineProperty: 拦截defineProperty操作
更多Proxy操作可阅读《ECMAScript6入门》
代理给JS编程打开了一扇门,灵活快速,可称是对JS的“元编程”。代理的用途很广泛,比如表单验证,图片懒加载,异步队列,等等,有兴趣可以阅读(《使用 Javascript 原生的 Proxy 优化应用》)[https://juejin.im/post/5a3cb0846fb9a044fb07f36c]
4.2 状态同步代理版
用代理做UI状态同步非常简单,我们还是用上例的代码,只需修改observer
函数即可。
<div id=‘app’>
<p> {{name}} </p>
<p> {{name}} </p>
<p> {{age}} </p>
<p value='phone'> </p> <!-- phone 是用来做什么的? 最后说 -->
</div>
<script>
var data = {
name: 'mike',
age: 1
};
var subjects = {}
compile(‘app’)
obverser(data) // 注意这里,我妈劫持data啦
function set(key, val) { /* 同前例.. */ }
function compile(el) { /* 同前例.. */ }
function obverser(state) {
// 注意这里,重写obverser函数
}
</script>
1.创建代理, 注意为避免变量重复,这里把函数参数改为state
function obverser(state) {
data = new Proxy(data, {
})
}
- 拦截取值操作
function obverser(target) {
data = new Proxy(target, {
get (target, property) {
return target[property]
},
})
}
其中targert
表示目标对象, property
表示目标对象的属性, return target[property]
相当于把原对象的值直接返回
- 拦截赋值操作
function obverser(target) {
data = new Proxy(target, {
get (target, property) {
return target[property]
},
set (target, property, newValue) {
target[property] = newValue
set(property, newValue)}
})
}
其中targert
表示目标对象, property
表示目标对象的属性, newValue
顾名思义是新设置的值。target[property] = newValue
是赋值操作。 set(property, newValue)
就是前面众多例子中的set()
函数。
至此,结束。
打开网页看下效果,注意日志中清晰简洁的呈现数据。
输入data.name = 'Jim'
, 会发现名字由Mike变为Jim
输入data.age= 99
, 会发现年龄由99变为1
输入data.phone= 15442258
, 会发页面多出了电话号码。
请注意在
var data = {
name: 'mike',
age: 1
};
data的声明中我们并没有写phone, 只是在模版中写有
<p value='phone'> </p> <!-- phone 是用来做什么的? 现在说 -->
可以看到代理可以对没有声明的属性进行监听,完美解决描述属性符的问题。
附: 完整代码
<html lang="en">
<head>
<meta charset="UTF-8">
<title>5代理</title>
</head>
<body>
<div id ='app'>
<p value='name'> </p>
<p value='name'> </p>
<p value='age'> </p>
<p value='phone'> </p>
</div>
<script>
var data = {
name: 'mike',
age: 1
};
var subjects = {};
function set(key, val) {
subjects[key].forEach(item=> {
item()
})
}
function compile(el) {
var nodes = document.getElementById(el).children;
for (let i = 0; i < nodes.length; i ++ ) {
let node = nodes[i];
if (node.hasAttribute('value')) {
let property = node.getAttribute('value');
if (!subjects.hasOwnProperty(property)) {
subjects[property] = []
}
subjects[property].push(()=>{
node.innerHTML = data[property]
});
node.innerHTML = data[property] || ''
}
}
}
compile('app');
obverser(data);
function obverser(state) {
data = new Proxy(state, {
get (target, property) {
return target[property]
},
set (target, property, newValue) {
target[property] = newValue;
set(property, newValue);
}
})
}
</script>
</body>
</html>
4.3 更进一步:双向绑定
我们用代理做状态同步,再进一步,我们可以用代理做双向绑定。实现原理很简单,仍用前例的代码,只是更改compile函数,增加对输入框的监听
node.addEventListener('input', () => {
// 利用代理的set拦截。
// 相当于在浏览器console中输入data.name = 'Jim'
data[property] = node.value
})
看下效果。
这部分内容已经超出本文的范围,有兴趣可以直接阅读下面的代码和注释。本例可能是行数最少的双向绑定代码,只是比前面的例子增加了几行代码。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>6代理双向绑定</title>
</head>
<body>
<div id ='app'>
<p value='name'> </p>
<input type="text" model = 'age'> <!-- 增加文本输入框 -->
<p value='age'> </p>
</div>
<script>
var data = {
name: 'mike',
age: 1
};
var subjects = {};
function set(key) {
subjects[key].forEach(item=> {
item()
})
}
/*
* 只重写compile函数,其余JS代码均没变化
* */
function compile(el) {
var nodes = document.getElementById(el).children;
// 遍历子节点,同前例
for (let i = 0; i < nodes.length; i ++ ) {
let node = nodes[i];
// 为模版绑定值,同前例让js的数据显示在页面上
if (node.hasAttribute('value')) {
let property = node.getAttribute('value');
if (!subjects.hasOwnProperty(property)) {
subjects[property] = []
}
subjects[property].push(()=>{
node.innerHTML = data[property]
});
node.innerHTML = data[property] || ''
// 新增部分: 当遇到`model'属性,表示双向绑定,
} else if (node.hasAttribute('model')) {
// 为模版绑定值,让js的数据显示在页面上
let property = node.getAttribute('model');
if (!subjects.hasOwnProperty(property)) {
subjects[property] = []
}
subjects[property].push(()=>{
node.value = data[property]
});
node.value = data[property] || ''
// 关键:增加监听。当文本框变动时触发
node.addEventListener('input', () => {
/*
* 关键中的关键: 利用代理的set拦截。
* 相当于在浏览器console中输入data.name = 'Jim'
*/
data[property] = node.value
})
}
}
}
compile('app');
obverser(data);
function obverser(state) {
data = new Proxy(state, {
get (target, property) {
return target[property]
},
set (target, property, newValue) {
target[property] = newValue;
set(property);
}
})
}
</script>
</body>
</html>
4.4 再进一步,再进一步
本文的例子很简单,不大可能用于实践,还有很多工作要做。
如果要写如下的嵌套模版怎么办?给Compile加层递归循环吧?
<div id ='app'>
<p>
姓名:
<span value='name'> </span>
</p>
<p>
年龄:
<input type="text" model = 'age'>
</p>
<p>
年龄:
<span value='age'> </span>
</p>
</div>
下方compile函数又丑又长怎么办? 向Vue一样 用watcher和dap改造吧!
...
node.innerHTML = data[property] || ''
} else if (node.hasAttribute('model')) {
let property = node.getAttribute('model');
if (!subjects.hasOwnProperty(property)) {
subjects[property] = []
}
subjects[property].push(()=>{
...
有兴趣阅读《用proxy实现一个更优雅Vue》
5.番外篇: 虚拟渲染
即使用代理进行双向绑定,也需要操作DOM,而操作DOM是耗时不高效的。
React另辟蹊径,不用代理,使用寻渲染做UI状态同步。
详细原理可阅读《如何理解虚拟DOM?》,大致原理如下:
- 创建虚拟DOM,即把HTML中的模版转为js显示。比如:
// html代码
<ul id='list'>
<li class='item'>Item 1</li>
<li class='item'>Item 2</li>
<li class='item'>Item 3</li>
</ul>
//转为JS
var tree = h('ul', {id: 'list'}, [
h('li', {class: 'item'}, ['Item 1']),
h('li', {class: 'item'}, ['Item 2']),
h('li', {class: 'item'}, ['Item 3'])
])
- 通过
render
渲染函数将虚拟DOM转为真正的DOM并加载在页面上
var root = tree.render()
document.body.appendChild(root)
- 如果JS发生改变,直接生成新的寻DOM,比如更改Item的名称
var newTree = h('ul', {id: 'list'}, [
h('li', {class: 'item'}, ['A']),
h('li', {class: 'item'}, ['B']),
h('li', {class: 'item'}, ['C])
])
- 用DIff算法比较新就DOM树,并将不同点存在变量pathces中
var patches = diff(tree, newTree)])
// patches 内容类似如下:
[{node: 'li', old: 'Item 1, new 'A'} , {node: ...} ....]
- 在真正的DOM树中变更
patch(root, patches)
// DOM将变为:
<ul id='list'>
<li class='item'>A</li>
<li class='item'>B</li>
<li class='item'>C</li>
</ul>
至此,结束。
6. 结语
UI状态同步简史,有两条科技线。一条是通过观察者模式对数据的观察,一条是虚拟函数用JS代替HTML。
而到今天,两条科技线早已相互结合,互相吸取优点。于是有了今天的React, Vue等。
不过故事仍没结束,在UI状态同步的路上,优化无止境。
本文源于公司的一次内部分享