实现简单的MVVM框架
基本要求
- 对使用typescript写的代码文件打包成一个整体并且暴露到全局
- 实现数据劫持(数据的绑定)
- 对模板编译成ast树和虚拟节点
- 虚拟节点变成真实节点的处理
- 优化:实现vue的for循环、元素监听事件绑定
- 优化:实现元素的attribute与数据的绑定
- 实现发布订阅模式
- 优化:增加diff算法处理虚拟节点转变成真实节点的处理
大致流程
MVVM框架:数据–数据视图控制–视图
初始化流程
rollup打包
import typescript from 'rollup-plugin-typescript2'
import babel from 'rollup-plugin-babel'
const mode = process.env.MODE
const isProd = mode === 'prod'
export default {
input: './src/index.ts',
output: {
file: './dist/hskr.js',
name: 'Hskr',
format: 'umd',
sourcemap: true,
},
plugins: [
babel({
exclude: 'node_module/**',
}),
typescript({
useTsconfigDeclarationDir: true,
tsconfigOverride: { compilerOptions: { sourceMap: !isProd } },
}),
],
}
与webpack类似,input属性接收打包的入口文件,output接收打包输出的配置信息。plugins配置插件等。
rollup打包的output中相比webpack有几个不同:
1.file属性:制定打包输出的文件地址
2.name属性:制定暴露到全局的类的名称
3.format属性:模块定义模式(有amd异步模块定义、es、cjs、iife自执行等。本次使用的umd是通用模块定义,以amd、cjs和iife为一体。)
实现数据劫持
import IHskr from "../type/hskr";
import IOption from "../type/option";
export default function observe(data: IOption["data"], hskr: IHskr) {
Object.keys(data).forEach(key => {
Object.defineProperty(hskr, key, {
configurable: false,
get() {
return Reflect.get(hskr.$data, key);
},
set(newValue) {
hskr.$data[key] = newValue
}
})
})
return deepProxy(data, {
get(target, key, receiver) {
return Reflect.get(target, key);
},
set(target, key, value, receiver) {
return Reflect.set(target, key, value, receiver);
}
});
}
function deepProxy(object, handler) {
if (isComplexObject(object)) {
addProxy(object, handler);
}
return new Proxy(object, handler);
}
function addProxy(obj, handler) {
for (let i in obj) {
if (typeof obj[i] === 'object') {
if (isComplexObject(obj[i])) {
addProxy(obj[i], handler);
}
obj[i] = new Proxy(obj[i], handler);
}
}
}
function isComplexObject(object) {
if (typeof object !== 'object') {
return false;
} else {
for (let prop in object) {
if (typeof object[prop] == 'object') {
return true;
}
}
}
return false;
}
创建一个observe文件夹,其中包含observe、watch、dep三个文件
其中的watch和dep是后续观察者模式使用的后续会提到
observe文件暴露一个observe方法,这个方法接收用户配置的data、还有实例化的hskr指向。
深度绑定方法:判断data是否为对象,如果是则对其属性回调数据劫持。以此达到深度绑定的作用
Object.keys(data).forEach(key => {
Object.defineProperty(hskr, key, {
configurable: false,
get() {
return Reflect.get(hskr.$data, key);
},
set(newValue) {
hskr.$data[key] = newValue
}
})
})
这一段的作用是为了方便用户获取、改变data的数据。我们希望用户使用data中的数据时可以直接“this.xxx”使用而非“this.$data.xxx”。在外层添加一个defineproperty将date中的属性映射到实例中。
具体使用技术:
- object.defineProperty
- Proxy
- typeof类型判断
模板编译
createAst.ts
import IAstTree from "../type/astTree";
import IHskr from "../type/hskr";
export default function compile(el: string, hskr: IHskr): IAstTree | null {
const element = document.querySelector(el)
hskr.$el = element
if (!element) return null
let astStrack = createAstTree(element)
return astStrack
}
function createAstTree(el, parentNode: IAstTree | null = null): IAstTree {
let ast: IAstTree = {
tag: '',
type: 0,
children: null,
parent: parentNode,
attrs: {},
text: ''
};
const {
nodeType,
nodeName,
nodeValue,
} = el
if (nodeType === 8) { // 注释
ast.type = 2
return ast
} else if (nodeType === 3) {// 文本节点
ast.tag = ''
ast.type = 0;
ast.text = nodeValue
} else if (nodeType === 1) {
const attrsKeys = el.getAttributeNames();
attrsKeys.forEach(key => {
const value = el.getAttribute(key)
ast.attrs[key] = value
})
ast.tag = nodeName.toLowerCase()
ast.type = 1
if (el.firstChild) ast.children = []
for (let i = 0; i < el.childNodes.length; i++) {
let child = el.childNodes[i];
ast.children?.push(createAstTree(child, ast))
}
}
return ast
}
暴露一个compile方法,这个方法接收参数el用于获取挂在在html上的真实元素,使用createAstTree方法将这个真实元素转变成上图所示类型的astTree。其中的属性解释如下
–tag属性为标签的标签名
–type属性用于标识标签类型
–parent属性用于表示该元素的父节点元素
–children属性用于表示该元素的子节点元素数组
–attrs属性用于表示标签上的属性,如style等
–text属性为文本标签中的文本
**网上查到使用vue使用的方法是正则匹配方法,由于对正则有些不熟悉所以此处用了比较消耗性能的方法–直接操作dom
astToRender.ts
import IAstTree from "../type/astTree";
/**
*
* @param astTree
* @returns {string}
*/
export default function astToRender(astTree: IAstTree): string | null {
const { tag, attrs, children, text, type } = astTree
if (type === 0) { // 文本节点
return `_h('',${genAttrs(attrs)},null${text && ',' + searchDate(text).join('+')})`
}
if (type === 2) {
return `_h('')`
}
let childRenderer = ''
let key = ''
if (children && children.length) {
children.forEach(child => {
childRenderer += astToRender(child) + ','
});
childRenderer = `[${childRenderer.slice(0, -1)}]`
}
if (attrs['h-key']) {
key = searchDate(attrs['h-key']).join('+')
}
if (attrs['h-for']) {
const [forItem, forAttrs] = attrs['h-for'].split('in')
return `..._l('${tag}',${genAttrs(attrs)},
${(children && children.length) && JSON.stringify(childRenderer)},
'${forItem}', ${forAttrs}${key && ',"' + key + '"'})`
}
return `_h('${tag}',${genAttrs(attrs)},${(children && children.length) && childRenderer},null${key && ',' + key})`
}
function genAttrs(attrs) {
let str = ''
Object.keys(attrs).forEach(key => {
if (key === 'h-for') {
return
}
if (key === 'h-key') {
return
}
if (key === 'style') {
let styleValue = ''
attrs[key].split(';').forEach(item => {
let [key, value] = item.split(':');
if (key && value) {
styleValue += searchDate(key).join('+') + ':' + searchDate(value).join('+') + ','
}
});
str += `${searchDate(key)}:{${styleValue.slice(0, -1)}},`
return
}
str += `${searchDate(key)}:${attrs[key] ? searchDate(attrs[key]).join('+') : '""'},`
})
return `{${str.slice(0, -1)}}`
}
// 搜索{{}}数据生成数据
function searchDate(str: string) {
let token: Array<string> = []
let reg = /\{\{((?:.|\r?\n)+?)\}\}/g
let match;
let lastIndex = 0;
while (match = reg.exec(str)) {
if (lastIndex < match.index) {
token.push(JSON.stringify(str.slice(lastIndex, match.index)))
}
token.push(`_v(${match[1].trim().split('.').reduce((prev, current) => `${prev}["${current}"]`)})`)
lastIndex = match.index + match[0].length
}
if (lastIndex < str.length) {
token.push(JSON.stringify(str.slice(lastIndex, str.length)))
}
return token
}
ast转成render流程如下:
暴露一个astToRender方法,这个方法接收前面html转变成的ast树,对这个ast树处理,转变成render函数。
render函数主要是将ast树转变成虚拟dom。其中_h、_l、_s方法分别对应将非循环ast树转成虚拟dom树、将循环ast树转成虚拟dom树、搜寻data中的数据并返回。
ast树中的数据均为模板解析转换来的,所以其中的{{}}包括起来的数据应该与用户的options选项的data属性中的数据相结合起来。astToRender的主要作用就是实现这个功能
模板数据如何与data数据结合?
关键方法:searchDate
searchDate匹配传入的字符串中的{{}}包裹起来的内容,并且将匹配到的内容进行替换,如将"hallo {{name}}"替换成"hallo " + _v(name)。这样操作有利于后续操作中模板数据与data的结合
优化:如何实现类似于vue中的v-for方法
- 由于在模板解析步骤中将元素的attribute转换成astTree中的attrs属性,所以我们可以在astToRender阶段获取到attrs中是否存在有“h-for”属性
- 判断到如果存在“h-for"属性,则render函数改为_l函数。_l函数和_h函数用于区分是否是循环渲染
- 其中循环哪个数组数据、循环时使用的名称作为参数传入,至于如何在子节点中获取循环数据,在后续有提到
_h函数
import IVnode from "../type/vnode"
export default function _h(tag: string, attrs: object, children: Array<IVnode> | null, text: string | null, key: string) {
return {
hm: this, tag, attrs, children, text, key
}
}
_h函数将ast树中的key属性提取出来,整合ast树的数据,使其方便后续操作
_l函数
import IVnode from "../type/vnode";
export default function _l(tag: string, attrs: object, children: Array<IVnode> | null, forItem: string, forAttrs: Array<any>, key: string) {
let renderer: Array<any> = []
for (let i = 0; i < forAttrs.length; i++) {
renderer.push(new Function(`with(this){return function(${forItem}){return _h('${tag}',${JSON.stringify(attrs)},${children},null,${key})}(${JSON.stringify(forAttrs[i])})}`).call(this));
}
return renderer
}
_l函数循环forAttrs(循环数组),调用_h函数生成虚拟dom。
这里回答上面疑问:如何在子节点中获取循环数据
_l函数返回一个Function函数构建函数,其中构建函数传入一个string类型的参数(循环名称)在外层中将循环到的数据作为参数传入,这样就可以在子节点中得到循环的数据了
用一个简单的例子来解释:
定义schoolArr数据如下
schoolArr:["广工","清华","北大"]
定义一个for循环:
<div h-for="item in schoolArr">{{item}}</div>
此时在astToRender阶段就可以获取到存在”h-for“属性。则返回的render函数为_l函数
// render得到的_l函数如下
_l("div",{},[_h(""),{},null,_s(item),_s(schoolArr))
由此可以看出,如果_l函数只是单纯的做一个循环返回_h并且将参数传入,一定会抛出item变量未定义的错误
所以在_l函数中,返回的构建函数需要将item对应上数据。
for(let i =0;i < schoolArr.length;i++){
(function (item) {
console.log(`xxx${item}xxx`)
})(schoolArr[i])
}
// 这样就能获取到schoolArr循环的数据
类似的,在_l函数中:
new Function(`
with(this){
return
function(${forItem}){
return _h('${tag}',${JSON.stringify(attrs)},${children},null,${key})
}(${JSON.stringify(forAttrs[i])})}`
).call(this)
虚拟节点变成真实节点的处理
import IHskr from "../type/hskr";
import IVnode from "../type/vnode";
export default function patch(oldVnode, newVnode: IVnode): Element | Text {
const parentNode = oldVnode.parentNode
const newEl = createElement(newVnode)
parentNode?.replaceChild(newEl, oldVnode)
return newEl
}
function createElement(vnode): Element | Text {
if (!vnode.tag) {
if (vnode.text) {
vnode.el = document.createTextNode(vnode.text)
} else {
vnode.el = document.createTextNode("")
}
} else {
const el = document.createElement(vnode.tag)
patchElementAttrs(el, vnode.attrs, vnode.hm)
if (vnode.children && vnode.children.length) {
vnode.children.forEach(childVnode => {
el.appendChild(createElement(childVnode))
});
}
vnode.el = el
}
return vnode.el
}
function patchElementAttrs(el, newAttrs: object, hskr: IHskr) {
// 添加attrs中的属性
Object.keys(newAttrs).forEach(key => {
const value = newAttrs[key]
if (key === 'style') {
Object.keys(newAttrs['style']).forEach(style_key => {
el.style[style_key] = value[style_key]
})
} else if (key[0] == '@') {
el.addEventListener(key.slice(1, key.length), hskr.$method[value])
} else {
el.setAttribute(key, value)
}
})
}
虚拟dom转换成真实dom很简单。主要是使用createElement方法创建元素,setAttribute方法为元素添加属性,appendChild方法将属性挂载到页面中。
实现发布订阅模式
上面过程我们实现了将模板转成ast树,并且将模板中的数据与data中的数据结合起来。但是这样做我们只能实现第一次挂载的渲染,后续的数据改变不会影响页面的变化
所以在这里定义一个watch类和dep类。一个实例中只有一个watch类。watch类在第一次挂载时创建。dep类在模板解析时,对模板中使用到的数据进行依赖收集,并且记录dep所在的watch类。当dep中的数据依赖发送改变时,通知watch类更新页面数据。
watch类
import IHskr from "../type/hskr";
import Dep from "./dep";
export default class Watcher {
getter: Function;
id: number
subs: Array<any>;
debounceFn: NodeJS.Timeout
constructor(hm: IHskr, fn: Function) {
this.getter = fn
this.subs = []
this.init()
}
init() {
Dep.target = this
this.getter()
Dep.target = null
}
append(dep) {
this.subs.push(dep)
}
updata() {
// 小防抖
if (this.debounceFn !== null) {
clearTimeout(this.debounceFn)
}
this.debounceFn = setTimeout(() => {
this.getter()
}, 0)
}
}
dep类
export default class Dep {
static target: any = null
subs: Array<any>
constructor() {
this.subs = []
}
append() {
this.subs.push(Dep.target)
Dep.target.append(this)
}
notify() {
this.subs.forEach(watcher => {
watcher.updata()
})
}
}
更新observe方法
import IHskr from "../type/hskr";
import IOption from "../type/option";
import Dep from "./dep";
export default function observe(data: IOption["data"], hskr: IHskr) {
const targetMap = new Map()
Object.keys(data).forEach(key => {
Object.defineProperty(hskr, key, {
configurable: false,
get() {
return Reflect.get(hskr.$data, key);
},
set(newValue) {
hskr.$data[key] = newValue
}
})
})
return deepProxy(data, {
get(target, key, receiver) {
if (Dep.target) {
track(target, key, targetMap)
}
return Reflect.get(target, key);
},
set(target, key, value, receiver) {
trigger(target, key, targetMap)
return Reflect.set(target, key, value, receiver);
}
});
}
function deepProxy(object, handler) {
if (isComplexObject(object)) {
addProxy(object, handler);
}
return new Proxy(object, handler);
}
function addProxy(obj, handler) {
for (let i in obj) {
if (typeof obj[i] === 'object') {
if (isComplexObject(obj[i])) {
addProxy(obj[i], handler);
}
obj[i] = new Proxy(obj[i], handler);
}
}
}
function isComplexObject(object) {
if (typeof object !== 'object') {
return false;
} else {
for (let prop in object) {
if (typeof object[prop] == 'object') {
return true;
}
}
}
return false;
}
function track(target, key, targetMap) {
let depsMap = targetMap.get(target)
if (!depsMap) {
depsMap = new Map()
targetMap.set(target, depsMap)
}
let _dep = depsMap.get(key)
if (!_dep) {
let dep = new Dep()
dep.append()
depsMap.set(key, dep)
}
}
function trigger(target, key, targetMap) {
const depsMap = targetMap.get(target)
const dep = depsMap.get(key)
if (dep) {
dep.notify()
}
}
创建watch类
hskr._watch = new Watcher(hskr, () => {
hskr._updata(hskr._render())
})
观察者订阅者模式流程如下
1.第一次渲染时,先创建watch类,传入渲染函数,在watch类构造函数中调用渲染函数。
2.在将render函数转成虚拟节点阶段,将模板中使用到的数据收集成依赖。
3.将虚拟dom转换成真实dom渲染到页面中。
4.观察者观察数据的变化,当数据发送变化时,dep通知观察者重新渲染页面。
优化:diff算法
上面过程我们实现了数据改变时更新页面的过程。但是这样的更新是暴力性的直接将原来的元素删掉,重新绘制新的dom。可能只改变了一个数据,只需要将一个span元素删除等等简操作,却需要暴力更新整个元素,这样做十分消耗性能。所以,这里添加一个diff算法做细粒化更新
import IHskr from "../type/hskr";
import IVnode from "../type/vnode";
export default function patch(oldVnode, newVnode: IVnode): Element | Text {
if (oldVnode.nodeType) {
const parentNode = oldVnode.parentNode
const newEl = createElement(newVnode)
parentNode?.replaceChild(newEl, oldVnode)
return newEl
} else {
if (!isSameVnode(oldVnode, newVnode)) { // 元素不相同
const newEl = createElement(newVnode)
oldVnode.el.parentNode && oldVnode.el.parentNode.replaceChild(newEl, oldVnode.el)
return newEl
}
return patchVnode(oldVnode, newVnode)
}
}
function patchVnode(oldVnode, newVnode: IVnode): Element | Text {
let el = newVnode.el = oldVnode.el
if (!oldVnode.tag) { // 证明是文本标签
if (!(oldVnode.text == newVnode.text)) {
el.nodeValue = newVnode.text
}
} else { // 是标签
patchElementAttrs(el, oldVnode.attrs, newVnode.attrs, newVnode.hm)
// 比较儿子节点
let oldVnodeChild = oldVnode.children || []
let newVnodeChild = newVnode.children || []
if (oldVnodeChild.length > 0 && newVnodeChild.length > 0) {
patchDiff(el, oldVnodeChild, newVnodeChild)
} else if (oldVnodeChild.length == 0 && newVnodeChild.length > 0) {
newVnodeChild.forEach(childVnode => {
el.appendChild(createElement(childVnode))
})
} else if (oldVnodeChild.length > 0 && newVnodeChild.length == 0) {
const length = oldVnodeChild.length
for (let i = 0; i < length; i++) {
el.removeChild(el.firstChild)
}
}
}
return el
}
function isSameVnode(oldVnode: IVnode, newVnode: IVnode): boolean {
return oldVnode && newVnode && oldVnode.tag === newVnode.tag && oldVnode.key === newVnode.key
}
function createElement(vnode): Element | Text {
if (!vnode.tag) {
if (vnode.text) {
vnode.el = document.createTextNode(vnode.text)
} else {
vnode.el = document.createTextNode("")
}
} else {
const el = document.createElement(vnode.tag)
patchElementAttrs(el, {}, vnode.attrs, vnode.hm)
if (vnode.children && vnode.children.length) {
vnode.children.forEach(childVnode => {
el.appendChild(createElement(childVnode))
});
}
vnode.el = el
}
return vnode.el
}
function patchElementAttrs(el, oldAttrs, newAttrs: object, hskr: IHskr) {
// 删除old节点的属性
Object.keys(oldAttrs).forEach(key => {
if (key === 'style') {
Object.keys(oldAttrs['style']).forEach(style_key => {
if (!newAttrs['style'][style_key]) {
el.style[style_key] = ''
}
})
} else if (key[0] == '@') {
if (!newAttrs[key]) {
el.removeEventListener(key.slice(1, key.length), hskr.$method[newAttrs[key]])
}
} else {
if (!newAttrs[key])
el.removeAttribute(key)
}
})
// 添加attrs中的属性
Object.keys(newAttrs).forEach(key => {
const value = newAttrs[key]
if (key === 'style') {
Object.keys(newAttrs['style']).forEach(style_key => {
el.style[style_key] = value[style_key]
})
} else if (key[0] == '@') {
el.addEventListener(key.slice(1, key.length), hskr.$method[value])
} else {
el.setAttribute(key, value)
}
})
}
function patchDiff(el, oldVnodeChild, newVnodeChild) {
let oldStartIndex = 0;
let newStartIndex = 0;
let oldEndIndex = oldVnodeChild.length - 1;
let newEndIndex = newVnodeChild.length - 1;
let oldStartVnode = oldVnodeChild[0]
let newStartVnode = newVnodeChild[0]
let oldEndVnode = oldVnodeChild[oldEndIndex]
let newEndVnode = newVnodeChild[newEndIndex]
let child_map = new Map()
oldVnodeChild.forEach((child, index) => {
child_map.set(child.key, index)
});
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
// 头头 abcd -- abc
// debugger
if (oldEndVnode === undefined) {
oldEndVnode = oldVnodeChild[--oldEndIndex]
} else if (oldStartVnode === undefined) {
oldStartVnode = oldVnodeChild[++oldStartIndex]
}
else if (isSameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldVnodeChild[++oldStartIndex]
newStartVnode = newVnodeChild[++newStartIndex]
}
// 尾尾
else if (isSameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldVnodeChild[--oldEndIndex]
newEndVnode = newVnodeChild[--newEndIndex]
}
// 头尾 abcd -- dcba
else if (isSameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode)
el.insertBefore(oldStartVnode.el, oldEndVnode.el)
oldStartVnode = oldVnodeChild[++oldStartIndex]
newEndVnode = newVnodeChild[--newEndIndex]
}
// 尾头
else if (isSameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode)
el.insertBefore(oldEndVnode.el, oldStartVnode.el)
oldEndVnode = oldVnodeChild[--oldEndIndex]
newStartVnode = newVnodeChild[++newStartIndex]
} else {
let _index = child_map.get(newStartVnode.key)
if (_index) {
let _childNode = oldVnodeChild[_index]
el.insertBefore(_childNode.el, oldStartVnode.el)
oldVnodeChild[_index] = undefined
patchVnode(_childNode, newStartVnode)
} else {
el.insertBefore(createElement(newStartVnode), oldStartVnode.el)
}
newStartVnode = newVnodeChild[++newStartIndex]
}
}
// 循环结束后将剩余的新节点添加
if (newStartIndex <= newEndIndex) {
for (let i = newStartIndex; i <= newEndIndex; i++) {
let newChildEl = createElement(newVnodeChild[i])
let anchor = newVnodeChild[newEndIndex + 1] ? newVnodeChild[newEndIndex + 1].el : null
el.insertBefore(newChildEl, anchor)
}
}
// 删除多余的元素
if (oldStartIndex <= oldEndIndex) {
for (let i = oldStartIndex; i <= oldEndIndex; i++) {
if (oldVnodeChild[i]) {
el.removeChild(oldVnodeChild[i].el)
}
}
}
}
在diff算法中,我们把树形结构按照层级分解,只比较同级元素,不同层级的节点只有创建和删除操作。我们默认相同标签且key相同的元素为相同元素。对于不同元素则直接创建替换,对于相同元素则进行attrs属性比较、文本比较等操作
子节点的比较
diff算法中的关键在于子节点的比较。对于两个元素的比较更新难度不高,所以这里主要讨论子节点的比较。
diff最巧妙的一点就是:其针对用户最常用的数组操作,提出了命中这个概念
- 数组push操作(与pop方法类似)
可以看出,只有后面添加了一个E元素,前面的都一一对应
2.数组reverse操作
可以看出,原来的头与当前的尾一一对应
3.数组shift操作(与unshift方法类似)
可以看出,只有前面添加了一个E元素,前面的尾都一一对应
4.数组排序或其他乱序方法
虽然元素乱序了,但是元素内容都没有变,不需要重新创建,只需要改变顺序即可
5…
由此可以看出,一般的变化,不会使得旧节点和新节点完全不同。旧节点的第一个元素或最后一个元素与新节点的第一个元素或最后一个元素有大概率相等。所以我们提出如下规则(简易版)
对比过程中会引入四个指针,分别指向旧虚拟节点的子节点列表中的第一个节点和最后一个节点以及指向新虚拟节点的子节点列表中的第一个节点和最后一个节点,然后循环对比
对比中,
第一次对比旧前和新前节点
第二次对比旧前和新后节点
第三次对比旧后和新前节点
第四次对比旧后和新后节点
如果四次对比都没有名中,则需要循环对比当前的新节点和剩下的旧节点。
退出循环后,如果旧节点还有剩下的,则需要循环删除剩下的旧节点;如果新节点有多的,则需要循环创建添加新节点