实现对数组的劫持
上篇文章已经说过,Object.defineProperty是无法实现对数组的劫持的,那么来说,vue是采用什么办法来解决这个问题的呢?vue是新建了一个数组的原型对象,这个对象的原型指向Array.prototype。具体实现代码如下:
const arrayProto = Object.create(Array.prototype)
let methodName = ['push', 'pop', 'shift', 'unshift', 'reverse', 'sort', 'splice']
methodName.forEach(method => {
arrayProto[method] = function(...args) {
// array 的push的元素可能是一个对象, 使用这种方法处理数组中元素是对象的情况为响应式的
if(method === 'push') {
this.__ob__.observeArray(args)
}
const result = Array.prototype[method].apply(this, args)
this.__ob__.dep.notify()
return result
}
})
// 然后在上文的Observer方法中判断当前的监听的对象是否是数组,对数组的情况进行特殊处理
class Observer {
constructor(data) {
this.dep = new Dep()
if(Array.isArray(data)) {
data.__proto__ = arrayProto
this.observeArray(data)
} else {
this.walk(data)
}
Object.defineProperty(data, '__ob__', {
value: this,
enumerable: false,
writable: true,
configurable: true
})
}
// 将数组中元素为对象的数据,处理成响应式的
observeArray(arr) {
for(let i = 0;i < arr.length;i++) {
observe(arr[i])
}
}
...
}
实现computed
熟悉vue的同学应该都很清楚了,computed计算属性,会依赖data中的数据,当data中的数据发生变化的时候,计算属性会自动更新,同时computed中的属性是不能手动设置的。且应该具有惰性和缓存两大优势。另外关于computed的使用:尤大大在官网中也提到了:vue模板中的表达式设计的初衷是用于简单计算的,模板中涉及到的复杂逻辑运算都应该放到计算属性中。接下来我们就开始实现一个computed属性了:
class MVue{
constructor(){
this.ininComputed()
// watcher能监听computed中的属性
this.initWatcher()
}
...
initComputed() {
let computed = this.$options.computed
if(computed) {
Reflect.ownKeys(computed).forEach(watcher => {
const compute = new Watcher(this, computed[watcher], () => {})
Object.defineProperty(this, watcher,{
enumerable: true,
configurable: true,
get: function computedGetter() {
compute.get()
return compute.value
},
set: function computedSetter() {
console.warn('计算属性不可赋值')
}
})
})
}
}
}
上面仅仅是简单实现了一个computed,但是我们都知道computed属性有两个特点:
-
- 计算属性是
惰性的:计算属性依赖的其他属性发生变化的时候,计算属性并不会立即重新执行,要等到获取的时候才会去执行。
- 计算属性是
-
- 计算属性是
缓存的:如果计算属性依赖的其他属性没有变化的时候,即使重新对计算属性求值,亦不会重新计算。
- 计算属性是
class Watcher {
constructor(vm, exp, cb, options = {}) {
this.lazy = this.dirty = !!options.lazy
this.vm = vm
this.exp = exp
this.cb = cb
this.id = ++watchId
if(!this.lazy) {
this.get()
}
}
get() {
Dep.target = this
if(typeof this.exp === 'function'){
this.value = this.exp.apply(this.vm)
} else {
this.value = this.vm[this.exp]
}
Dep.target = null
}
// 当lazy 为true的时候 run方法并不执行 实现惰性
update() {
// 是惰性的 就不要去执行
if(this.lazy) {
this.dirty = true
} else {
this.run()
}
}
run() {
if(watcherQueue.includes(this.id)) {
return
}
watcherQueue.push(this.id)
Promise.resolve().then(() =>{
this.cb.call(this.vm)
watcherQueue.pop()
})
}
}
// 改造initComputed如下
// 初始化计算属性
initComputed() {
let computed = this.$options.computed
if(computed) {
Reflect.ownKeys(computed).forEach(watcher => {
const compute = new Watcher(this, computed[watcher], () => {}, { lazy: true })
Object.defineProperty(this, watcher,{
enumerable: true,
configurable: true,
get: function computedGetter() {
// 如果compute是脏值的话 触发get方法 实现缓存!
if(compute.dirty) {
compute.get()
compute.dirty = false
}
// 否则直接返回上次读取的结果
return compute.value
},
set: function computedSetter() {
console.warn('计算属性不可赋值')
}
})
})
}
}
模板编译
简单实现vue的模板编译,我们可以使用
new Watcher(this, () => {
document.querySelector('#app').innerHTML = `<p>${this.name}</p>`
}, ()=>{})
1.但是这样实现是可以使用模板语法的,需要把模板进行一些处理,最终转换成一个执行dom更新的函数 2.直接替换所有dom的开销很大,最好还是按需更新dom。
为了尽量减少不必要的dom操作和实现跨平台的特性,vue中引入了 Virtual-DOM 即虚拟DOM
什么是vdom? 其实就是一个js对象,用来描述dom长什么样的。
为了得到当前实例的VDOM,每个实例需要有一个render函数来生成VDOM,被称为渲染函数
vue实例如果传入了dom或者template,首先就是要把模板字符串转化成渲染函数,这个过程就是编译。
Vue编译原理
- 1.将模板字符串转换成element AST解析器)
- 2.对AST进行静态节点标记,用来做VDOM的渲染优化
- 3.使用element ASTs生成render函数代码字符串
注:AST是一种代码转换成另一种代码,是对源代码的描述。
vue会将template中结构当成一个字符串来处理,这里,我将模拟vue中模板编译来实现
元素节点的parser,具体实现如下:(经过parser之后生成一个AST结构) 原理:
// 解析器
function parser(html){
let stack = []
let root
let currentParent
while (html) {
let index = html.indexOf('<')
// 前面还有文本节点
if (index > 0) {
let text = html.slice(0, index)
const element = {
parent: currentParent,
type: 3,
text
}
currentParent.children.push(element)
// 截取html为除文本节点以外的剩余的html
html = html.slice(index)
} else if( html[index + 1] !== '/' ) {
// 前面没有文本节点 且是开始标签
let gtIndex = html.indexOf('>')
const element = {
type: 1,
tag: html.slice(index + 1, gtIndex),
parent: currentParent,
children: []
}
if(!root) {
root = element
} else {
currentParent.children.push(element)
}
stack.push(element)
currentParent = element
html = html.slice(gtIndex + 1)
} else {
// 结束标签
let gtIndex = html.indexOf('>')
stack.pop()
currentParent = stack[stack.length - 1]
html = html.slice(gtIndex + 1)
}
}
return root
}
// 解析一个文本节点
function parseText(text) {
let originText = text
let type = 3
let tokens = []
while(text) {
let start = text.indexOf('{{')
let end = text.indexOf('}}')
if(start !== -1 && end !== -1) {
type = 2
if(start > 0) {
tokens.push(JSON.stringify(text.slice(0, text)))
}
let exp = text.slice(start + 2, end)
tokens.push(`_s(${exp})`)
text = text.slice(end + 2)
} else {
tokens.push(JSON.stringify(text))
text = ''
}
}
let element = {
text: originText,
type
}
if(type === 2) {
element.expression = tokens.join('+')
}
return element
}
经过parser生成AST后需要把AST转换成渲染函数 步骤:
- 1.递归AST,遇到元素节点则生成如下结构
_c(标签名, 属性对象, 后代数组) - 2.遇到文本节点,如果是纯文本,则生成如下结构
_v(文本字符串) - 3.遇到带变量的文本节点,则生成
_v(_s(变量名)) - 4.为了让变量能正常取到,生成最后一个字符串包一层
with(this) - 5.最后把字符串作为函数生成一个函数,挂载到
vm.$options上 方法练习可结合vue中render做比较,若要查看vue中render函数可在 vm.$option.render.call(vm)查看。
// type: 1元素节点 2带变量的文本节点 3纯文本节点
// 核心方法 将AST转换成render函数
function generate(ast) {
let code = genElement(ast)
return {
render: `with(this)${code}`
}
}
// 转换元素节点
function genElement(el) {
let children = genChildren(el)
return `_c(${JSON.stringify(el.tag)}, {}, ${children})`
}
// 遍历后代节点
function genChildren(el){
if(el.children.length) {
return '['+ el.children.map(child => genNode(child)) +']'
}
}
// 转换文本节点
function genText(node) {
if(node.type === 2) {
return `_v(${node.expression})`
} else if( node.type === 3 ) {
return `_v(${JSON.stringify(node.text)})`
}
}
// 转换节点
function genNode(node) {
// 元素节点
if(node.type === 1) {
return genElement(node)
} else {
return genText(node)
}
}
下期内容会继续实现一个vdom, 并且会附带一些vue常见原理题的解析~