手写Vue2
使用rollup搭建开发环境
使用rollup打包第三方库会比webpack更轻量,速度更快
首先安装依赖
npm init -y
npm install rollup rollup-plugin-babel @babel/core @babel/preset-env --save-dev
然后添加 rollup 的配置文件 rollup.config.js
import babel from "rollup-plugin-babel"
export default {
input:"./src/index.js", // 配置入口文件
output:{
file:"./desc/vue.js", // 配置打包文件存放位置以及打包后生成的文件名
name:"Vue",// 全局挂载一个Vue变量
format:"umd", // 兼容esm es6模块
sourcemap:true, // 可以调试源代码
},
plugins:[
babel({
exclude:"node_modules/**", // 排除node_modules文件夹下的所有文件
})
]
}
添加 babel 的配置文件 .babelrc
{
"presets": [
"@babel/preset-env"
]
}
修改 package.json
{
"name": "vue2",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "rollup -cw"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.23.2",
"@babel/preset-env": "^7.23.2",
"rollup": "^4.3.0",
"rollup-plugin-babel": "^4.4.0"
},
"type": "module"
}
记得在 package.json
后面添加 "type": "module"
,否则启动时会提示 import babel from "rollup-plugin-babel"
错误
准备完成后运行启动命令
npm run dev
出现上图表示启动成功,并且正在监听文件变化,文件变化后会自动重新打包
查看打包出来的文件
然后新建一个 index.html
引入打包出来的 vue.js
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="./vue.js"></script>
</head>
<body>
<script>
console.log(Vue)
</script>
</body>
</html>
访问这个文件,并打开控制台查看打印
至此,我们准备工作完成,接下来开始实现Vue核心部分。
初始化数据
修改 src/index.js
import {initMixin} from "./init";
function Vue(options){
this._init(options)
}
initMixin(Vue)
export default Vue
添加 init.js
,用于初始化数据操作,并导出 initMixin 方法
import {initStatus} from "./state.js";
export function initMixin(Vue){
// 给Vue原型添加一个初始化方法
Vue.prototype._init = function (options){
const vm = this
vm.$options = options
// 初始化状态
initStatus(vm)
}
}
state.js
的写法
export function initStatus(vm){
const opt = vm.$options
if(opt.data){
initData(vm)
}
}
function initData(vm){
let data = vm.$options.data
data = typeof data === "function" ? data.call(vm) : data
console.log(data)
}
我们打开控制台查看打印的东西
可以发现已经正确的得到data数据
实现对象的响应式
现在我们在 initData 方法中拿到了data,接下来就是对data中的属性进行数据劫持
在 initData 中添加 observe 方法,并传递data对象
function initData(vm){
let data = vm.$options.data
data = typeof data === "function" ? data.call(vm) : data
// 拿到数据开始进行数据劫持,把数据变成响应式的
observe(data)
}
新建 observe/index.js
文件,实现 observe 方法
class Observer{
constructor(data) {
this.walk(data)
}
walk(data){
// 循环对象中的每一个属性进行劫持
Object.keys(data).forEach(key=>{
defineReactive(data,key,data[key])
})
}
}
export function defineReactive(target,key,value){
// 这里对当前的值进行判断,如果值还是一个对象,则递归继续进行深度劫持
observe(value)
// Object.defineProperty 接收三个参数,第一个是对象,第二是要劫持的key,第三个是一个对象,里面有get和set方法
// 当读取这个key是,会触发get方法,当设置key时,会触发set方法,并接收新值
Object.defineProperty(target,key,{
get(){
return value
},
set(newValue){
if(newValue === value) return
value = newValue
}
})
}
export function observe(data){
// 对这个对象进行劫持,需要判断一下是否是一个对象,如果不是一个对象不能进行劫持
if(typeof data !== "object" || data === null) return
return new Observer(data)
}
现在对数据就劫持完成了,但是我们如何获取呢?我们可以吧data方法返回的对象挂载到Vue的实例上即可
还是在 initData 方法内添加代码,并且增加一个 proxy 方法,让我们可以通过 vm.xxx 的方式直接获取data中的属性值
function initData(vm){
let data = vm.$options.data
data = typeof data === "function" ? data.call(vm) : data
// 拿到数据开始进行数据劫持,把数据变成响应式的
observe(data)
// 吧data方法返回的对象挂载到Vue的实例上
vm._data = data
// 目前取值必须通过 vm._data.xxx 的方式来获取值或者设置值
// 如果想直接通过 vm.xxx 的方式来设置值,则必须对vm再进行一次代理
proxy(vm,"_data")
}
function proxy(target,key){
for (const dataKey in target[key]) {
Object.defineProperty(target, dataKey,{
get(){
return target[key][dataKey]
},
set(newValue){
target[key][dataKey] = newValue
}
})
}
}
现在来打印一下 vm
通过打印发现,vm 自身上就有了data中定义的属性
并且直接通过 vm 来读取和设置属性值也是可以的
实现数组的响应式
实现思路:
- 首先遍历数组中的内容,吧数组中的数据变成响应式的
- 如果调用的数组中的方法,添加了新的数据,则也要吧新的数据变成响应式的,这里可以劫持7个变异方法来实现
首先在 Observer 类中添加判断,如果data是一个数组,则单独走一个observeArray方法,来实现对数组的响应式处理
import {newArrayProperty} from "./array.js";
class Observer{
constructor(data) {
// 定义一个__ob__,值是this,不可枚举
// 给数据加了一个标识,表示这个数据是一个已经被响应式了的
Object.defineProperty(data,"__ob__",{
// 定义这个属性值是当前的实例
value:this,
// 定义__ob__不能被遍历,否则会引起死循环
// 原因:在walk方法中会递归遍历对象中的每一个属性进行响应式处理,因为__ob__表示的当前对象的实例
// 实例本身又包含__ob__,这样就会导致递归无限往里面找,就造成了死循环,
// 所以这里要设置成 enumerable:false,不能遍历 __ob__ 这个属性
enumerable:false
})
// 如果data中的某个值定义的是一个数组,则对数组进行劫持,进行响应式处理
if(Array.isArray(data)){
data.__proto__ = newArrayProperty
this.observeArray(data)
}else{
this.walk(data)
}
}
walk(data){
// 循环对象中的每一个属性进行劫持
Object.keys(data).forEach(key=>{
defineReactive(data,key,data[key])
})
}
// 对数组中的数据进行响应式处理
observeArray(data){
data.forEach(item=>observe(item))
}
}
这里在 data 中定义了 __ob__
属性,并且值等于当前的 Observer 实例,是为了在 array.js 中拿到 Observe 实例中的 observeArray 方法,来实现对新传递进来的数据进行响应式处理
既然有了这个 __ob__
属性,我们就可以判断一下,如果 data 中有了 __ob__
属性,则表示这个数据已经被响应式了,则不需要进行再次响应式,所以我们可以在 observe 方法中加一个判断
export function observe(data){
// 对这个对象进行劫持,需要判断一下是否是一个对象,如果不是一个对象不能进行劫持
if(typeof data !== "object" || data === null) return
// 如果这个对象已经被代理过了,则直接返回当前示例
if(data.__ob__){
return data.__ob__
}
return new Observer(data)
}
然后下面是 array.js 的实现代码
// 获取原始的数组原型链
let oldArrayProperty = Array.prototype
// 复制一份出来,到新的对象中
export let newArrayProperty = Object.create(oldArrayProperty)
// 声明数组的变异方法有哪些
let methods = ["push", "pop", "unshift", "shift", "reserve", "sort", "splice"]
methods.forEach(method => {
// 调用新的方法时,接收传递进来的参数,然后再调用一下原来的
newArrayProperty[method] = function (...args) {
// 判断如果是下面的几个方法,则要对传递进来的参数继续进行响应式处理
let inserted;
// 这里的this指向的是函数的调用者,所以这里的this指向的是data,也就是在Observer类中接收到data
// 恰好我们给data的__ob__属性设置了值,等于Observe实例,
// 利用这点就可以拿到Observe中的observeArray方法,来对新数据进行响应式处理
let ob = this.__ob__
switch (method) {
case "push":
case "unshift":
case "shift":
inserted = args;
break
case "splice":
inserted = args.slice(2)
break
default:
break;
}
if(inserted){
// 对传递进来的参数继续进行响应式处理
ob.observeArray(inserted)
}
// 将结果返回
return oldArrayProperty[method].call(this, ...args);
}
})
现在看一下效果
可以看到我们新 push 的数据也被响应式了
解析HTML模板
我们可以根据option中的el来获取到根标签,然后获取对应的html,拿到html后开始解析
先写一些测试代码,准备一个html页面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="./vue.js"></script>
</head>
<body>
<div id="app">
<div style="font-size: 15px;color: blue" data-name = "123">
{{name}}
</div>
<div style="color: red">
{{age}}
</div>
</div>
</body>
<script>
const vm = new Vue({
el:"#app",
data(){
return{
name:"szx",
age:18,
address:{
price:100,
name:"少林寺"
},
hobby:['each','write',{a:"tome"}]
}
}
})
</script>
</html>
然后来到 init.js 中的 initMixin 方法,判断一下是否有 el 这个属性,如果有则开始进行模板解析
import {initStatus} from "./state.js";
import {compilerToFunction} from "./compile/index.js";
export function initMixin(Vue){
// 给Vue原型添加一个初始化方法
Vue.prototype._init = function (options){
const vm = this
vm.$options = options
// 初始化状态
initStatus(vm)
// 解析模板字符串
if(vm.$options.el){
vm.$mount(vm.$options.el)
}
}
// 在原型链上添加$mount方法,用户获取页面模板
Vue.prototype.$mount = function (el){
let template;
const vm = this
const opts = vm.$options
// 判断配置中是否已经存在template,如果没有,则根据el获取页面模板
if(!opts.render){
if(!opts.template && opts.el){
// 拿到模板字符串
template = document.querySelector(el).outerHTML
}
if(opts.template){
template = opts.template
}
if(template){
// 这里拿到模板开始进行模板编译
const render = compilerToFunction(template)
}
}
// 有render函数后再执行后续操作
}
}
下面就是 compilerToFunction
的代码
const unicodeRegExp = /a-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD/
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z${unicodeRegExp.source}]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
// 匹配到的是结束标签的名字 </xxx>
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
// 匹配属性的正则,第一个分组是key,value则是分组3/分组4/分组5
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
// 匹配自闭和的标签
const startTagClose = /^\s*(\/?)>/
// 匹配花括号中的的内容
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g
function parseHtml(html){
// 前进
function advance(n){
html = html.substring(n)
}
function parseStart(){
// 匹配开始标签
let start = html.match(startTagOpen)
if(start){
const match = {
// 获取到标签名
tagName:start[1],
attrs:[]
}
// 截取已经匹配到的内容
advance(start[0].length)
// 循环匹配剩下的属性,如果不是开始标签的结束位置,则开始匹配属性
let attr,end;
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))){
match.attrs.push({
name:attr[1],
value:attr[3] || attr[4] || attr[5] || true
})
// 匹配到一点后就删除一点
advance(attr[0].length)
}
if(end){
advance(end[0].length)
}
console.log(match)
return match
}
return false
}
while (html){
let textEnd = html.indexOf('<');
// 判断做尖括号的位置,如果是0表示这是一个开始标签
if(textEnd === 0){
// 匹配开始标签
const startTagMatch = parseStart()
if(startTagMatch){
continue
}
// 匹配结束标签
let endTagMatch = html.match(endTag)
if(endTagMatch){
advance(endTagMatch[0].length)
continue
}
}
// 进入到这里说明匹配到了文本:{{ xxx }}
if(textEnd > 0){
let text = html.substring(0,textEnd)
if(text){
advance(text.length)
}
}
}
console.log(html)
return ""
}
export function compilerToFunction(template){
let ast = parseHtml(template)
return ""
}
这段代码在不断的解析html内容,匹配到开始标签,就会标签名称和属性放在match数组中,并且删除一已经匹配到的内容,如果匹配到文本或者结束版本则删除匹配到的内容,最终html变成空,表示解析过程就结束了。
我们通过打印看一下html被解析的过程
可以看到html的内容再不断减少
接下来,我们只需要在这些方法中添加如果匹配到开始标签,就触发一个方法处理开始标签的内容,如果匹配到文本,就处理文本内容,如果匹配到结束标签,就处理结束标签的内容。
在 parseHtml 方法中添加三个方法如下,分别处理开始标签,文本,结束标签
- onStartTag
- onText
- onCloseTag
const unicodeRegExp = /a-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD/
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z${unicodeRegExp.source}]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
// 匹配到的是结束标签的名字 </xxx>
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
// 匹配属性的正则,第一个分组是key,value则是分组3/分组4/分组5
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
// 匹配自闭和的标签
const startTagClose = /^\s*(\/?)>/
// 匹配花括号中的的内容
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g
function parseHtml(html){
// 处理开始标签
function onStartTag(tag,attrs){
console.log(tag,attrs)
console.log("开始标签")
}
// 处理文本标签
function onText(text){
console.log(text)
console.log("文本")
}
// 处理结束标签
function onCloseTag(tag){
console.log(tag)
console.log("结束标签")
}
// 前进
function advance(n){
html = html.substring(n)
}
function parseStart(){
// 匹配开始标签
let start = html.match(startTagOpen)
if(start){
const match = {
// 获取到标签名
tagName:start[1],
attrs:[]
}
// 截取已经匹配到的内容
advance(start[0].length)
// 循环匹配剩下的属性,如果不是开始标签的结束位置,则开始匹配属性
let attr,end;
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))){
match.attrs.push({
name:attr[1],
value:attr[3] || attr[4] || attr[5] || true
})
// 匹配到一点后就删除一点
advance(attr[0].length)
}
if(end){
advance(end[0].length)
}
return match
}
return false
}
while (html){
let textEnd = html.indexOf('<');
// 判断做尖括号的位置,如果是0表示这是一个开始标签
if(textEnd === 0){
// 匹配开始标签
const startTagMatch = parseStart()
if(startTagMatch){
onStartTag(startTagMatch.tagName,startTagMatch.attrs)
continue
}
// 匹配结束标签
let endTagMatch = html.match(endTag)
if(endTagMatch){
onCloseTag(endTagMatch[1])
advance(endTagMatch[0].length)
continue
}
}
// 进入到这里说明匹配到了文本:{{ xxx }}
if(textEnd > 0){
let text = html.substring(0,textEnd)
if(text){
onText(text)
advance(text.length)
}
}
}
console.log(html)
return ""
}
export function compilerToFunction(template){
let ast = parseHtml(template)
return ""
}
并在在相对应的代码中调用者三个方法
查看打印效果
接下来构建语法树
function parseHtml(html){
const ELEMENT_TYPE = 1 // 标记这是一个元素
const TEXT_TYPE = 3 // 标记这是一个文本
const stack = [] // 声明一个栈
let currentParent;
let root;
function createNode(tag,attrs){
return{
tag,
attrs,
type:ELEMENT_TYPE,
children:[],
parent:null
}
}
// 处理开始标签
function onStartTag(tag,attrs){
let node = createNode(tag,attrs)
if(!root){
root = node
}
if(currentParent){
node.parent = currentParent
currentParent.children.push(node)
}
stack.push(node)
currentParent = node
}
// 处理文本标签
function onText(text){
text = text.replace(/\s/g,"")
text && currentParent.children.push({
type:TEXT_TYPE,
text,
parent:currentParent
})
}
// 处理结束标签
function onCloseTag(tag){
stack.pop()
currentParent = stack[stack.length -1]
}
// ...省略其他代码
return root
}
然后打印一下生成的ast语法树
export function compilerToFunction(template){
let ast = parseHtml(template)
console.log(ast)
return ""
}
代码生成的实现原理
现在我们已经得到了AST语法树,接下来我们就需要根据得到的AST语法树转化成一段cvs字符串
- _c 表示创建元素
- _v 表示处理文本内容
- _s 表示处理花括号包裹的文本
这里提前吧 parseHtml 方法抽离出来放在 parse.js 文件中并导出
然后在 compilerToFunction 方法中添加 codegen 方法,根据 ast 语法树生成字符串代码
import {parseHtml,ELEMENT_TYPE} from "./parse.js";
function genProps(attrs){
let str = ``
attrs.forEach(attr=>{
// 遍历行内元素,变成 {id:"app",style:{"color":"red","font-size":"20px"}} 这种效果
if(attr.name === "style"){
let obj = {}
attr.value.split(";").forEach(sItem=>{
let [key,value] = sItem.split(":")
obj[key] = value.trim()
})
attr.value = obj
}
str += `${JSON.stringify(attr.name)}:${JSON.stringify(attr.value)},`
})
str = `{${str.slice(0,-1)}}`
return str
}
function genChildren(children){
return children.map(child=>gen(child)).join(",")
}
// 匹配花括号中的的内容
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g;
function gen(child) {
// 匹配到的是一个元素
if (child.type === ELEMENT_TYPE) {
return codegen(child)
} else {
let text = child.text
// 匹配到的是一个纯文本,不是用花括号包裹起来的文本时,会走下面的方法,返回一个 _v 函数,用于创建文本节点
if (!defaultTagRE.test(text)) {
return `_v(${JSON.stringify(text)})`
} else {
// 如果这个文本元素包含有花括号包裹的数据,就会走else方法
let token = []
let match;
let lastIndex = 0
// 设置正则的lastIndex从0开始,也就是从文本的最前面开始进行匹配
defaultTagRE.lastIndex = 0
// exec方法会匹配到一个花括号数据时就会走一次循环,然后继续往后匹配剩余的花括号
// 只到匹配结束,会返回null,然后退出循环
while (match = defaultTagRE.exec(text)) {
// match.index返回的是当前匹配到的数据的下标
let index = match.index
// 如果出现匹配到的数据下标大于lastIndex下标,则表示如下情况
// hello {{ age }},表示这个花括号前面还有文本,我们需要先把花括号前面的文本放在token中
if (index > lastIndex) {
token.push(JSON.stringify(text.slice(lastIndex, index)))
}
// 然后再吧花括号里面的变量放在token中,并且用_s函数接收这个变量
token.push(`_s(${match[1].trim()})`)
// 更新lastIndex
lastIndex = index + match[0].length
}
// 当exec匹配完成后,出现lastIndex < text.length的情况
// 表示最后一个花括号后面还有普通文本,例如:{{ age }} word,则我们需要吧后面的文本也放在token中
if (lastIndex < text.length) {
token.push(JSON.stringify(text.slice(lastIndex)))
}
// 最后返回一个 _v() 函数,里面的token使用+拼接返回
return `_v(${token.join("+")})`
}
}
}
function codegen(ast) {
// _c("div",{xxx:xxx,xxx:xxx})
// 第一个参数是需要创建的元素,第二个是对应的属性
let code = `_c(
${JSON.stringify(ast.tag)},
${ast.attrs.length ? genProps(ast.attrs) : "null"},
${ast.children.length ? genChildren(ast.children) : "null"}
)`
return code
}
export function compilerToFunction(template) {
// 1. 解析DOM,转化成AST语法树
let ast = parseHtml(template)
// 2. 根据AST语法树生成cvs字符串
let cvs = codegen(ast)
console.log(cvs)
return ""
}
效果如下图
生成render函数
现在我们得到了一个字符串,并不是一个函数,下面就是要把这个字符串变成一个render函数
export function compilerToFunction(template) {
// 1. 解析DOM,转化成AST语法树
let ast = parseHtml(template)
// 2. 根据AST语法树生成cvs字符串
let code = codegen(ast)
// 3.根据字符串生成render方法
code = `with(this){return ${code}}`
let render = new Function(code)
console.log(render.toString())
return render
}
这里的 with 方法可以自动从 this 中读取变量值
with (object) {
// 在此作用域内可以直接使用 object 的属性和方法
// 无需重复引用 object
// 例如:
// property1 // 相当于 object.property1
// method1() // 相当于 object.method1()
// ...
// 注意:如果 object 中不存在某个属性或方法,会向上级作用域查找
// 如果上级作用域也找不到,则会抛出 ReferenceError
// 在严格模式下,不允许使用 with 语句
}
简单示例
let testObj = {
name:"Tome",
age:18
}
with (testObj) {
console.log(name + age); // 输出:Tome18
}
现在有了render函数后,将render 返回并添加到 $options 中
在 initMixin 方法中添加
import {initStatus} from "./state.js";
import {compilerToFunction} from "./compile/index.js";
import {mountComponent} from "./lifecycle.js";
export function initMixin(Vue){
// 给Vue原型添加一个初始化方法
Vue.prototype._init = function (options){
const vm = this
vm.$options = options
// 初始化状态
initStatus(vm)
// 解析模板字符串
if(vm.$options.el){
vm.$mount(vm.$options.el)
}
}
// 在原型链上添加$mount方法,用户获取页面模板
Vue.prototype.$mount = function (el){
let template;
const vm = this
const opts = vm.$options
// 判断配置中是否已经存在template,如果没有,则根据el获取页面模板
if(!opts.render){
if(!opts.template && opts.el){
// 拿到模板字符串
template = document.querySelector(el).outerHTML
}
if(opts.template){
template = opts.template
}
if(template){
// 这里拿到模板开始进行模板编译
opts.render = compilerToFunction(template)
}
}
// 有了render函数,开始对组件进行挂载
mountComponent(vm,el)
}
}
添加 mountComponent 方法,新建一个文件,单独写个这个方法
lifecycle.js
/**
* Vue 核心流程
* 1.创造了响应式数据
* 2.根据模板转化成ast语法树
* 3.将ast语法树转化成render函数
* 4.后续每次更新数据都只执行render函数,自动更新页面
*/
export function initLifeCycle(Vue){
Vue.prototype._render = function (){
}
Vue.prototype._update = function (){
}
}
// 挂载页面
export function mountComponent(vm,el){
vm.$el = el
// 1.调用render方法产生虚拟节点
vm._update(vm._render())
// 2.根据虚拟DOM产生真实DOM
// 3.插入到el中去
}
initLifeCycle 方法需要接收一个 Vue,我们可以在 index.js 文件中添加调用
import {initMixin} from "./init";
import {initLifeCycle} from "./lifecycle.js";
function Vue(options){
this._init(options)
}
initMixin(Vue)
initLifeCycle(Vue)
export default Vue
创建虚拟节点并更新视图
完善 lifecycle.js 文件的代码
/**
* Vue 核心流程
* 1.创造了响应式数据
* 2.根据模板转化成ast语法树
* 3.将ast语法树转化成render函数
* 4.后续每次更新数据都只执行render函数,自动更新页面
*/
import {createElementVNode, createTextVNode} from "./vdom/index.js";
export function initLifeCycle(Vue){
// _c 的返回值就是 render 函数的返回值
Vue.prototype._c = function (){
// 创建一个虚拟DOM
return createElementVNode(this,...arguments)
}
// _v 的返回值给 _c 使用
Vue.prototype._v = function (){
return createTextVNode(this,...arguments)
}
// _s 的返回值给 _v 使用
Vue.prototype._s = function (value){
return value
}
// _render函数的返回值会作为参数传递给 _update
Vue.prototype._render = function (){
return this.$options.render.call(this)
}
// 更新视图方法
Vue.prototype._update = function (vnode){
// 获取当前的真实DOM
const elm = document.querySelector(this.$options.el)
patch(elm,vnode)
}
}
function patch(oldVNode,newVNode){
// 判断是否是一个真实元素,如果是真实DOM会返回1
const isRealEle = oldVNode.nodeType;
if(isRealEle){
// 获取真实元素
const elm = oldVNode
// 获取真实元素的父元素
const parentElm = elm.parentNode
// 创建新的虚拟节点
let newRealEl = createEle(newVNode)
// 把新的虚拟节点插入到真实元素后面
parentElm.insertBefore(newRealEl,elm.nextSibling)
// 然后删除之前的DOM
parentElm.removeChild(elm)
}
}
function createEle(vnode){
let {tag,data,children,text} = vnode
if (typeof tag === "string"){
vnode.el = document.createElement(tag)
Object.keys(data).forEach(prop=>{
if(prop === "style"){
Object.keys(data.style).forEach(sty=>{
vnode.el.style[sty] = data.style[sty]
})
}else{
vnode.el.setAttribute(prop,data[prop])
}
})
// 递归处理子元素
children.forEach(child=>{
vnode.el.appendChild(createEle(child))
})
}else{
// 当时一个文本元素是,tag是一个undefined,所以会走else
vnode.el = document.createTextNode(text)
}
return vnode.el
}
// 挂载页面
export function mountComponent(vm,el){
vm.$el = el
// 1.调用render方法产生虚拟节点
// 2.根据虚拟DOM产生真实DOM
// 3.插入到el中去
vm._update(vm._render())
}
vnode/index.js
// 创建虚拟节点
export function createElementVNode(vm,tag,prop,...children){
if(!prop){
prop = {}
}
let key = prop.key
if(key){
delete prop.key
}
return vnode(vm,tag,prop,key,children,undefined)
}
// 创建文本节点
export function createTextVNode(vm,text){
return vnode(vm,undefined,undefined,undefined,undefined,text)
}
function vnode(vm,tag,data,key,children,text){
children = children.filter(Boolean)
return {
vm,
tag,
data,
key,
children,
text
}
}
此时我们的页面就可以正常显示数据了
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="./vue.js"></script>
</head>
<body>
<div id="app" style="color: red;font-size: 20px">
<div style="font-size: 15px;color: blue" data-name = "123">
你好 {{ name }} hello {{ age }} word
</div>
<span>
{{ address.name }}
</span>
</div>
</body>
<script>
const vm = new Vue({
el:"#app",
data(){
return{
name:"szx",
age:18,
address:{
price:100,
name:"少林寺"
},
hobby:['each','write',{a:"tome"}]
}
}
})
</script>
</html>
实现依赖收集
现在初次渲染已经可以吧页面上绑定的数据渲染成我们定义的数据,但是当我们改变data数据时,页面不会发生更新,这里就要使用观察者模式,实现依赖收集。
读取某个属性时,会调用get方法,在get方法中收集watcher(观察者),然后当更新数据时,会调用set方法,通知当前这个属性绑定的观察者去完成更新视图的操作。
在get方法中收集watcher的同时,watcher也要收集这个属性(dept),要知道我当前的这个watcher下面有几个dept。
一个dept对应多个watcher,因为一个属性可能会在多个视图中使用
一个watcher对应多个dept,因为一个组件中会有多个属性
下面的代码实现逻辑
修改 lifecycle.js
文件中的 mountComponent
方法
// 挂载页面
export function mountComponent(vm,el){
vm.$el = el
// 1.调用render方法产生虚拟节点
// 2.根据虚拟DOM产生真实DOM
// 3.插入到el中去
const updateComponent = ()=>{
vm._update(vm._render())
}
// 初始化渲染
new Watcher(vm,updateComponent)
}
新建 observe/watcher.js
import Dep from "./dep.js";
let id = 0
class Watcher{
constructor(vm,fn) {
this.id = id++ // 唯一ID
this.getter = fn // 这里存放多个dep,一个Watcher对应多个deps
this.depts = []
this.deptSet = new Set()
this.get()
}
get(){
// 在调用getter之前,吧当前的Watcher实例放在Dep全局上
Dep.target = this
// 调用getter就相当于调用的render函数,调用了render函数就会触发每个属性的get方法
this.getter()
// 调用完getter之后,把Dep.target置为null
Dep.target = null
}
// watcher也要知道我自己下面有几个dept,所以这里要收集一下
addDep(dep){
// 判断dep的id不能重复
if(!this.deptSet.has(dep.id)){
this.depts.push(dep)
this.deptSet.add(dep.id)
dep.addSubs(this)
}
}
// 更新视图
update(){
this.get()
}
}
export default Watcher
对应的 observe/dep.js
let id = 0
class Dep{
constructor() {
this.id = id++
this.subs = [] // 存放当前这个属性对应的多个watcher
}
depend(){
// Dep.target 就是当前的 watcher
// 让watcher记住当前的dep
Dep.target.addDep(this)
}
// 当在watcher函数中添加好dep后会调用dep的addSubs方法,在dep中再保存一下watcher
addSubs(watcher){
this.subs.push(watcher)
}
// 更新属性时更新这个属性对应的dep上的notify方法,会遍历这个dep对应的所有的watcher进行更新视图
notify(){
this.subs.forEach(watcher => watcher.update())
}
}
export default Dep
dep 就是被观察者,watcher 就是观察者,在属性的 get 和 set 方法中进行依赖收集和更新通知
修改 observe/index.js
的 defineReactive
方法
export function defineReactive(target,key,value){
// 这里对当前的值进行判断,如果值还是一个对象,则递归继续进行深度劫持
observe(value)
// Object.defineProperty 接收三个参数,第一个是对象,第二是要劫持的key,第三个是一个对象,里面有get和set方法
// 当读取这个key是,会触发get方法,当设置key时,会触发set方法,并接收新值
let dep = new Dep();
Object.defineProperty(target,key,{
get(){
// 进行依赖收集,收集这个属性的观察者(watcher)
if(Dep.target){
dep.depend()
}
return value
},
set(newValue){
if(newValue === value) return
value = newValue
// 通知观察者更新视图
dep.notify()
}
})
}
测试视图更新
const vm = new Vue({
el:"#app",
data(){
return{
name:"szx",
age:18,
address:{
price:100,
name:"少林寺"
},
hobby:['each','write',{a:"tome"}]
}
}
})
function addAge(){
vm.name = "李四"
vm.age = 20
}
我们发现,点击更新按钮后视图确实发生了更新。但是控制台打印了两次更新。这是因为我们在 addAge 方法中对两个属性进行了更改,所以触发了两次更新。下面我们来解决这个问题,让他只触发一次更新
实现异步更新
修改 Watch 中的 update 方法,同时新增一个 run 方法,专门用于更新视图操作
import Dep from "./dep.js";
let id = 0
class Watcher{
constructor(vm,fn) {
this.id = id++ // 唯一ID
this.getter = fn // 这里存放多个dep,一个Watcher对应多个deps
this.depts = []
this.deptSet = new Set()
this.get()
}
get(){
// 在调用getter之前,吧当前的Watcher实例放在Dep全局上
Dep.target = this
// 调用getter就相当于调用的render函数,调用了render函数就会触发每个属性的get方法
this.getter()
// 调用完getter之后,把Dep.target置为null
Dep.target = null
}
// watcher也要知道我自己下面有几个dept,所以这里要收集一下
addDep(dep){
// 判断dep的id不能重复
if(!this.deptSet.has(dep.id)){
this.depts.push(dep)
this.deptSet.add(dep.id)
dep.addSubs(this)
}
}
// 更新视图
update(){
// 实现异步更新,将多个watcher放在一个队列中,然后写一个异步任务实现异步更新
queueWatcher(this)
}
run(){
console.log('更新视图')
this.getter()
}
}
let queue = []
let watchObj = {}
let padding = false
function queueWatcher(watcher){
if(!watchObj[watcher.id]){
watchObj[watcher.id] = true
queue.push(watcher)
// 执行多次进行一个防抖
if(!padding){
// 等待同步任务执行完再执行异步更新
nextTick(flushSchedulerQueue,0)
padding = true
}
}
}
function flushSchedulerQueue(){
let flushQueue = queue.slice(0)
queue = []
watchObj = {}
padding = false
flushQueue.forEach(cb=>cb.run())
}
let callbacks = []
let waiting = false
export function nextTick(cb){
callbacks.push(cb)
if(!waiting){
// setTimeout(()=>{
// // 依次执行回调
// flushCallback()
// })
// 使用 Promise.resolve进行异步更新
Promise.resolve().then(flushCallback)
waiting = true
}
}
function flushCallback(){
let cbs = callbacks.slice(0)
callbacks = []
waiting = false
cbs.forEach(cb=>cb())
}
export default Watcher
在 src/index.js
中挂载全局的 $nexitTick 方法
import {initMixin} from "./init";
import {initLifeCycle} from "./lifecycle.js";
import {nextTick} from "./observe/watcher.js";
function Vue(options){
this._init(options)
}
Vue.prototype.$nextTick = nextTick
initMixin(Vue)
initLifeCycle(Vue)
export default Vue
页面使用
function addAge(){
vm.name = "李四"
vm.age = 20
vm.$nextTick(()=>{
console.log(document.querySelector("#name").innerText)
})
}
点击更新按钮执行 addAge 方法,可以在控制台看到只触发了一个更新视图,并且获取的页面也是更新后的
实现mixin核心功能
mixin的核心是合并对象,将Vue.mixin中的对象和在Vue中定义的属性进行合并,然后再初始化状态前后调用不同的Hook即可
首先在 index.js 中添加方法调用
index.js
文件增加 initGlobalApi,传入 Vue
import {initMixin} from "./init";
import {initLifeCycle} from "./lifecycle.js";
import {nextTick} from "./observe/watcher.js";
+ import {initGlobalApi} from "./globalApi.js";
function Vue(options){
this._init(options)
}
Vue.prototype.$nextTick = nextTick
initMixin(Vue)
initLifeCycle(Vue)
+ initGlobalApi(Vue)
export default Vue
globalApi.js
内容如下
import {mergeOptions} from "./utils.js";
export function initGlobalApi(Vue) {
// 添加一个静态方法 mixin
Vue.options = {}
Vue.mixin = function (mixin) {
this.options = mergeOptions(this.options, mixin)
return this
}
}
utils.js
中实现 mergeOptions 方法
// 定义一些策略,例如 created,beforeCreated 等,不需要写大量的if判断
const strats = {}
const LIFECYCLE = [
"beforeCreated",
"created"
]
LIFECYCLE.forEach(key => {
strats[key] = function (p, c) {
if (c) {
if (p) {
return p.concat(c)
} else {
return [c]
}
} else {
return p
}
}
})
// 合并属性的方法
export function mergeOptions(parent, child) {
const options = {}
// 先获取父亲的值
for (const key in parent) {
mergeField(key)
}
for (const key in child) {
// 如果父亲里面没有这个子属性,在进行合并子的
/**
* 示例:父亲:{a:1} 儿子:{a:2}
* 儿子中也有父亲的属性a,所以不会走儿子中的合并方法,但是在取值的时候,优先取的是儿子身上的属性值
* 所以合并到一个对象中时,儿子会覆盖父亲
*/
if (!parent.hasOwnProperty(key)) {
mergeField(key)
}
}
function mergeField(key) {
if (strats[key]) {
// {created:fn} {}
// 合并声明周期上的方法,例如:beforeCreated,created
options[key] = strats[key](parent[key], child[key])
} else {
// 先拿到儿子的值
options[key] = child[key] || parent[key]
}
}
return options
}
然后在 init.js 中进行属性合并和Hook调用
import {initStatus} from "./state.js";
import {compilerToFunction} from "./compile/index.js";
import {mountComponent} from "./lifecycle.js";
+import {mergeOptions} from "./utils.js";
export function initMixin(Vue){
// 给Vue原型添加一个初始化方法
Vue.prototype._init = function (options){
const vm = this
+ // this.constructor就是当前的大Vue,获取的是Vue上的静态属性
+ // this.constructor.options 拿到的就是mixin合并后的数据
+ // 然后再把用户写的options和mixin中的进行再次合并
+ vm.$options = mergeOptions(this.constructor.options,options)
+ // 初始化之前调用beforeCreated
+ callHook(vm,"beforeCreated")
+ // 初始化状态
+ initStatus(vm)
+ // 初始化之后调用created
+ callHook(vm,"created")
// 解析模板字符串
if(vm.$options.el){
vm.$mount(vm.$options.el)
}
}
// 在原型链上添加$mount方法,用户获取页面模板
Vue.prototype.$mount = function (el){
let template;
const vm = this
el = document.querySelector(el)
const opts = vm.$options
// 判断配置中是否已经存在template,如果没有,则根据el获取页面模板
if(!opts.render){
if(!opts.template && opts.el){
// 拿到模板字符串
template = el.outerHTML
}
if(opts.template){
template = opts.template
}
if(template){
// 这里拿到模板开始进行模板编译
opts.render = compilerToFunction(template)
}
}
// 有了render函数,开始对组件进行挂载
mountComponent(vm,el)
}
}
+function callHook(vm,hook){
+ // 拿到用户传入的钩子函数
+ const handlers = vm.$options[hook]
+ if(handlers){
+ // 遍历钩子函数,执行钩子函数
+ for(let i=0;i<handlers.length;i++){
+ handlers[i].call(vm)
+ }
+ }
+}
测试 Vue.mixin
// Vue内部会把minix进行合并,如果有两个created会合并成一个created数组,里面有两个方法,然后依次执行
Vue.mixin({
beforeCreated() {
console.log("beforeCreated")
},
created() {
console.log(this.name,"--mixin")
},
})
const vm = new Vue({
el: "#app",
data() {
return {
name: "szx",
age: 18,
address: {
price: 100,
name: "少林寺"
},
hobby: ['each', 'write', {a: "tome"}]
}
},
created() {
console.log(this.name,"--vue")
}
})
查看控制台打印
实现数组更新
我们之前给每一个属性都加了一个dep,实现依赖收集,但是如果这个属性值是一个对象类型的话,当我们不改变这个属性的引用地址,只是改变对象属性值,比如给数组push一个数据,不会改变原来的引用地址。这样的话页面就无法实现更新。
我们可以判断一下,当属性值是一个对象类型的时候,给这个对象本身也添加一个dep,当读取这个属性值的时候,进行一下依赖收集,如果是一个数组的话,当调用完push等方法时,在我们重写的方法哪里再执行一个更新就可以了。
下面是代码实现
修改 observe/index.js
import {newArrayProperty} from "./array.js";
import Dep from "./dep.js";
class Observer{
constructor(data) {
// 给对象类型的数据加一个 Dep 实例
+ this.dep = new Dep()
// 定义一个__ob__,值是this,不可枚举
// 给数据加了一个标识,表示这个数据是一个已经被响应式了的
Object.defineProperty(data,"__ob__",{
// 定义这个属性值是当前的实例
value:this,
// 定义__ob__不能被遍历,否则会引起死循环
// 原因:在walk方法中会递归遍历对象中的每一个属性进行响应式处理,因为__ob__表示的当前对象的实例
// 实例本身又包含__ob__,这样就会导致递归无限往里面找,就造成了死循环,
// 所以这里要设置成 enumerable:false,不能遍历 __ob__ 这个属性
enumerable:false
})
// 如果data中的某个值定义的是一个数组,则对数组进行劫持,进行响应式处理
if(Array.isArray(data)){
data.__proto__ = newArrayProperty
this.observeArray(data)
}else{
this.walk(data)
}
}
walk(data){
// 循环对象中的每一个属性进行劫持
Object.keys(data).forEach(key=>{
defineReactive(data,key,data[key])
})
}
// 对数组进行响应式处理
observeArray(data){
data.forEach(item=>observe(item))
}
}
+function dependArr(array){
+ array.forEach(item=>{
+ // 数组中的普通类型的值不会有__ob__
+ item.__ob__ && item.__ob__.dep.depend()
+ if(Array.isArray(item)){
+ dependArr(item)
+ }
+ })
+}
export function defineReactive(target,key,value){
// 这里对当前的值进行判断,如果值还是一个对象,则递归继续进行深度劫持
+ const childOb = observe(value)
// Object.defineProperty 接收三个参数,第一个是对象,第二是要劫持的key,第三个是一个对象,里面有get和set方法
// 当读取这个key是,会触发get方法,当设置key时,会触发set方法,并接收新值
let dep = new Dep();
Object.defineProperty(target,key,{
get(){
// 进行依赖收集,收集这个属性的观察者(watcher)
if(Dep.target){
dep.depend()
+ if(childOb){
+ childOb.dep.depend()
+ if(Array.isArray(value)){
+ dependArr(value)
+ }
+ }
}
return value
},
set(newValue){
if(newValue === value) return
value = newValue
// 通知观察者更新视图
dep.notify()
}
})
}
export function observe(data){
// 对这个对象进行劫持,需要判断一下是否是一个对象,如果不是一个对象不能进行劫持
if(typeof data !== "object" || data === null) return
// 如果这个对象已经被代理过了,则直接返回当前示例
if(data.__ob__){
return data.__ob__
}
return new Observer(data)
}
实现效果
const vm = new Vue({
el: "#app",
data() {
return {
name: "szx",
age: 18,
address: {
price: 100,
name: "少林寺"
},
hobby: ["爬山","玩游戏"]
}
},
created() {
console.log(this.name,"--vue")
}
})
function addAge() {
vm.hobby.push("吃")
}
点击后页面会自动更新,并且控制台打印了一次更新视图
实现计算属性
首先添加computed计算属性
const vm = new Vue({
el: "#app",
data() {
return {
name: "szx",
age: 18,
address: {
price: 100,
name: "少林寺"
},
hobby: ["爬山","玩游戏"]
}
},
created() {
console.log(this.name,"--vue")
},
computed:{
fullname(){
console.log("调用计算属性")
return this.name + this.age
}
}
})
找到 state.js 文件,添加如下代码,添加针对 computed 属性的处理逻辑
import {observe} from "./observe/index.js";
export function initStatus(vm){
// vm是Vue实例
const opt = vm.$options
// 处理data属性
if(opt.data){
initData(vm)
}
// 处理computed计算属性
if(opt.computed){
initComputed(vm)
}
}
//...省略原有代码
function initComputed(vm){
let computed = vm.$options.computed
// 遍历计算属性中的每一个方法,将方法名作为一个key
Object.keys(computed).forEach(key=>{
// 劫持每一个属性,将key作为一个新的属性,方法的返回值作为这个属性值添加到vm上
defineComputed(vm,key,computed)
})
}
function defineComputed(target,key,computed){
let getter = typeof computed[key] === "function" ? computed[key] : computed[key].get
let setter = computed[key].set || (()=>{})
Object.defineProperty(target,key,{
get:getter,
set:setter
})
}
现在我们就可以在页面上使用
<span>
{{fullname}} {{fullname}} {{fullname}}
</span>
但是会发现执行了三次计算属性的方法,在真正的vue中,计算属性是带有缓存的。我们可以定义一个标识,当执行完一次计算属性方法后,把这个标识改掉,下次再次调用计算属性时,从缓存获取
修改 initComputed 方法
function initComputed(vm){
let computed = vm.$options.computed
const computedWatchers = vm._computedWatchers = {}
// 遍历计算属性中的每一个方法,将方法名作为一个key
Object.keys(computed).forEach(key=>{
let getter = typeof computed[key] === "function" ? computed[key] : computed[key].get
// 给每个计算属性绑定一个watcher,并且标记状态是lazy
// 然后再watcher中判断这个状态,决定是否立即执行一次和是否返回缓存的数据
computedWatchers[key] = new Watcher(vm,getter,{lazy:true})
// 劫持每一个属性,将key作为一个新的属性,方法的返回值作为这个属性值添加到vm上
defineComputed(vm,key,computed)
})
}
function defineComputed(target,key,computed){
let setter = computed[key].set || (()=>{})
Object.defineProperty(target,key,{
get:createComputedGetter(key),
set:setter
})
}
// 收集计算属性watcher
function createComputedGetter(key){
return function (){
let watcher = this._computedWatchers[key]
// 这里的dirty默认是true
if(watcher.dirty){
// 调用完watcher上的evaluate方法后,会吧这个dirty改成false
// 同时吧计算属性的方法返回值赋值给当前watcher的value属性上
watcher.evaluate()
}
// 这样在多次调用计算属性的get方法时,只会触发一次真正的get方法
return watcher.value
}
}
修改 Watcher.js
import Dep from "./dep.js";
let id = 0
class Watcher{
constructor(vm,fn,options = {}) {
this.id = id++ // 唯一ID
this.vm = vm
this.getter = fn // 这里存放多个dep,一个Watcher对应多个deps
this.depts = []
this.deptSet = new Set()
this.lazy = options.lazy
// 用作计算属性的缓存,标记是否需要重新计算
this.dirty = this.lazy
// 由于watcher会默认执行一次get,会渲染一次页面,但是计算属性不需要一上来就执行渲染
// 所以这里判断,如果dirty为true,则不执行get,只有当dirty为false的时候才执行get,渲染页面
this.dirty ? undefined : this.get()
}
evaluate(){
// 在这个方法中调用get方法,会去执行计算属性的方法
// 这时get中的this指向的计算属性watcher,同时会这这个计算属性watch加入栈中
this.value = this.get()
this.dirty = false
}
get(){
// 在调用getter之前,吧当前的Watcher实例放在Dep全局上
// Dep.target = this
pushWatcher(this)
// 调用getter就相当于调用的render函数,调用了render函数就会触发每个属性的get方法
let value = this.getter.call(this.vm)
// 调用完getter之后,把Dep.target置为null
// Dep.target = null
popWatcher()
// 把计算属性的值赋值给value
return value
}
// ... 省略其他代码
}
// ... 省略其他代码
let stack = []
function pushWatcher(watcher){
stack.push(watcher)
Dep.target = watcher
}
function popWatcher(){
stack.pop()
Dep.target = stack[stack.length-1]
}
export default Watcher
上面我们给每一个计算属性绑定了一个计算watcher,并且添加了一个lazy标记,然后再watcher中吧dirty的值默认等于这个标记,同时添加一个evaluate方法,专门处理计算属性的返回值
现在我们页面上使用三次计算属性,但是只会执行一次
现在当我们更改依赖的属性时,页面不会发生变化
这是为什么呢?这是因为目前计算属性中依赖的属性中的dep绑定是的计算Watcher,并不是渲染Watcher,当我们改变了计算属性依赖值时,通知的只是计算属性Watcher,所以不会引起页面的渲染。这就需要同时去触发渲染Watcher。
在 createComputedGetter
方法中增加一个判断,判断Dep.target是否还有值,有值就表示计算属性的watcher出栈后,还有一个渲染watcher,调用watcher中的depend方法,获取计算属性watcher所有的属性,也就是每一个dep,遍历这些dep,去同时吧渲染watcher添加到这些计算属性所依赖的dep的订阅者中,这样当这些依赖的值发生变化时,就会通知到渲染watcher,从而去更新页面。
// 收集计算属性watcher
function createComputedGetter(key){
return function (){
let watcher = this._computedWatchers[key]
// 这里的dirty默认是true
if(watcher.dirty){
// 调用完watcher上的evaluate方法后,会吧这个dirty改成false
// 同时吧计算属性的方法返回值赋值给当前watcher的value属性上
watcher.evaluate()
}
// 判断Dep.target是否还有值,有值就表示计算属性的watcher出栈后,还有一个渲染watcher
if(Dep.target){
// 调用watcher中的depend方法,获取计算属性watcher所有的属性,也就是每一个dep
// 遍历这些dep,去同时吧渲染watcher添加到这些计算属性所依赖的dep的订阅者中
// 这样当这些依赖的值发生变化时,就会通知到渲染watcher,从而去更新页面
watcher.depend()
}
// 这样在多次调用计算属性的get方法时,只会触发一次真正的get方法
return watcher.value
}
}
然后再 Watcher 中添加 depend 方法
import Dep from "./dep.js";
let id = 0
class Watcher{
constructor(vm,fn,options = {}) {
this.id = id++ // 唯一ID
this.vm = vm
this.getter = fn // 这里存放多个dep,一个Watcher对应多个deps
this.depts = []
this.deptSet = new Set()
this.lazy = options.lazy
// 用作计算属性的缓存,标记是否需要重新计算
this.dirty = this.lazy
// 由于watcher会默认执行一次get,会渲染一次页面,但是计算属性不需要一上来就执行渲染
// 所以这里判断,如果dirty为true,则不执行get,只有当dirty为false的时候才执行get,渲染页面
this.dirty ? undefined : this.get()
}
evaluate(){
// 在这个方法中调用get方法,会去执行计算属性的方法
// 这时get中的this指向的计算属性watcher,同时会这这个计算属性watch加入栈中
this.value = this.get()
this.dirty = false
}
depend(){
let i = this.depts.length
while (i--){
this.depts[i].depend()
}
}
// ...省略其他代码
}
// ...省略其他代码
export default Watcher
现在当修改了计算属性所依赖的属性值时,会更新视图。然后重新调用一次计算属性
实现watch监听
watch可以理解为一个自定义的观察者watcher,当观察的属性发生变化时,执行对应的回调即可
首先新增一个全局 $watch
src/index.js
import {initMixin} from "./init";
import {initLifeCycle} from "./lifecycle.js";
import Watcher, {nextTick} from "./observe/watcher.js";
import {initGlobalApi} from "./globalApi.js";
function Vue(options) {
this._init(options)
}
Vue.prototype.$nextTick = nextTick
initMixin(Vue)
initLifeCycle(Vue)
initGlobalApi(Vue)
+ Vue.prototype.$watch = function (expOrFn, cb) {
+ new Watcher(this, expOrFn, {user:true},cb)
+ }
export default Vue
然后再初始化状态,增加一个初始化watch的方法
src/state.js
import {observe} from "./observe/index.js";
import Watcher from "./observe/watcher.js";
import Dep from "./observe/dep.js";
export function initStatus(vm){
// vm是Vue实例
const opt = vm.$options
// 处理data属性
if(opt.data){
initData(vm)
}
// 处理computed计算属性
if(opt.computed){
initComputed(vm)
}
// 处理watch方法
if(opt.watch){
initWatch(vm)
}
}
// ....省略其他代码
function initWatch(vm){
// 从vm中获取用户定义的watch对象
let watch = vm.$options.watch
// 遍历这个对象获取每一个属性名和属性值
for (const watchKey in watch) {
// 属性值
let handle = watch[watchKey]
// 属性值可能是一个数组
/**
age:[
(newVal,oldVal)=>{
console.log(newVal,oldVal)
},
(newVal,oldVal)=>{
console.log(newVal,oldVal)
},
]
*/
if(Array.isArray(handle)){
for (let handleElement of handle) {
createWatcher(vm,watchKey,handleElement)
}
}else{
// 如果不是数组可能是一个字符串或者是一个回调
// 这里先不考虑是字符串的情况
createWatcher(vm,watchKey,handle)
}
}
}
function createWatcher(vm,keyOrFn,handle){
vm.$watch(keyOrFn,handle)
}
然后修改Watcher类,当所监听的值发生变化时触发回调
src/observe/watcher.js
import Dep from "./dep.js";
let id = 0
class Watcher{
constructor(vm,keyOrFn,options = {},cb) {
this.id = id++ // 唯一ID
this.vm = vm
+ // 如果是一个字符串吗,则包装成一个方法
+ if(typeof keyOrFn === 'string'){
+ this.getter = function (){
+ return vm[keyOrFn]
+ }
+ }else{
+ this.getter = keyOrFn // 这里存放多个dep,一个Watcher对应多个deps
+ }
this.depts = []
this.deptSet = new Set()
this.lazy = options.lazy
// 用作计算属性的缓存,标记是否需要重新计算
this.dirty = this.lazy
// 由于watcher会默认执行一次get,会渲染一次页面,但是计算属性不需要一上来就执行渲染
// 所以这里判断,如果dirty为true,则不执行get,只有当dirty为false的时候才执行get,渲染页面
+ this.value = this.dirty ? undefined : this.get()
+ // 区分是否为用户自定义watcher
+ this.user = options.user
+ // 拿到watcher的回调
+ this.cb = cb
}
// ....省略其他代码
run(){
console.log('更新视图')
+ let oldVal = this.value
+ let newVal = this.getter()
+ // 判断是否是用户自定义的watcher
+ if(this.user){
+ this.cb.call(this.vm,newVal,oldVal)
+ }
}
}
// ....省略其他代码
export default Watcher
实现基本的diff算法
首先吧 src/index.js
中的 $nextTick
和 $watch
放在 src/state.js
文件中,并封装在 initStateMixin 方法内,并且导出
src/state.js
import Watcher, {nextTick} from "./observe/watcher.js";
// ....省略其他代码
export function initStateMixin(Vue){
Vue.prototype.$nextTick = nextTick
Vue.prototype.$watch = function (expOrFn, cb) {
new Watcher(this, expOrFn, {user:true},cb)
}
}
在 src/index.js
导出并使用,并且下面添加了diff的测试代码
import {initMixin} from "./init";
import {initLifeCycle} from "./lifecycle.js";
import {initGlobalApi} from "./globalApi.js";
import {initStateMixin} from "./state.js";
import {compilerToFunction} from "./compile/index.js";
import {createEle, patch} from "./vdom/patch.js";
function Vue(options) {
this._init(options)
}
initMixin(Vue)
initLifeCycle(Vue)
initGlobalApi(Vue)
initStateMixin(Vue)
//----------测试diff算法---------------
let render1 = compilerToFunction("<div style='color: red'></div>")
let vm1 = new Vue({data:{name:"张三"}})
let prevVNode = render1.call(vm1)
let el = createEle(prevVNode)
document.body.appendChild(el)
let render2 = compilerToFunction(`<div style='background-color: blue;color: white'>
<h2>{{name}}</h2>
<h3>{{name}}</h3>
</div>`)
let vm2 = new Vue({data:{name:"李四"}})
let newVNode = render2.call(vm2)
setTimeout(()=>{
console.log(prevVNode)
console.log(newVNode)
patch(prevVNode,newVNode)
},1000)
export default Vue
上面代码生成了两个虚拟节点,然后倒计时1秒后进行更新
在 src/vdom/patch.js
中对节点进行比较
下面的代码在patchVNode完成新节点和旧节点的对比
import {isSameVNode} from "./index.js";
export function patch(oldVNode,newVNode){
// 判断是否是一个真实元素,如果是真实DOM会返回1
const isRealEle = oldVNode.nodeType;
// 初次渲染
if(isRealEle){
// 获取真实元素
const elm = oldVNode
// 获取真实元素的父元素
const parentElm = elm.parentNode
// 创建新的虚拟节点
let newRealEl = createEle(newVNode)
// 把新的虚拟节点插入到真实元素后面
parentElm.insertBefore(newRealEl,elm.nextSibling)
// 然后删除之前的DOM
parentElm.removeChild(elm)
}else{
// 对比新旧节点
patchVNode(oldVNode,newVNode)
}
}
// 根据虚拟dom渲染真实的dom
export function createEle(vnode){
let {tag,data,children,text} = vnode
if (typeof tag === "string"){
vnode.el = document.createElement(tag)
// 处理节点的属性
patchProps(vnode.el,{},data)
// 递归处理子元素
children.forEach(child=>{
child && vnode.el.appendChild(createEle(child))
})
}else{
vnode.el = document.createTextNode(text)
}
return vnode.el
}
// 给节点添加属性
function patchProps(el,oldProps = {},props){
let oldPropsStyle = oldProps.style
let newPropsStyle = props.style
// 判断如果新节点上没有旧节点的样式,则应该吧原来的样式清空掉
for (const key in oldPropsStyle) {
if(!newPropsStyle[key]){
el.style[key] = ""
}
}
// 判断旧节点的属性在新节点是否存在
for (const key in oldProps) {
if(!props[key]){
el.removeAttribute(key)
}
}
for (const key in props) {
if(key === "style"){
Object.keys(props.style).forEach(sty=>{
el.style[sty] = props.style[sty]
})
}else{
el.setAttribute(key,props[key])
}
}
}
// 对比新旧节点
function patchVNode(oldVNode,newVNode){
// 进行diff算法,对比新老节点
// 对比两个节点的tag和key是否一样,如果不一样,则直接吧老的替换掉,换成新的节点
if(!isSameVNode(oldVNode,newVNode)){
let el = createEle(newVNode)
oldVNode.el.parentNode.replaceChild(el,oldVNode.el)
return el
}
// 一样的情况对DOM元素进行复用
let el = newVNode.el = oldVNode.el
// 如果一样,则还需要判断一下文本的情况
if(!oldVNode.tag){
if(oldVNode.text !== newVNode.text){
el.textContent = newVNode.text
}
}
// 比较新节点和旧节点的属性是否一致
patchProps(el,oldVNode.data,newVNode.data)
// 然后比较新旧节点的儿子节点
let oldVNodeChildren = oldVNode.children || []
let newVNodeChildren = newVNode.children || []
if(oldVNodeChildren.length > 0 && newVNodeChildren.length > 0){
// 进行完整的diff算法
console.log("进行完整的diff算法")
}else if(newVNodeChildren.length > 0){
// 这里表示老节点没有儿子,但是新节点有,需要遍历新节点的每一个儿子,放在新节点中
mountChildren(el,newVNodeChildren)
}else if(oldVNodeChildren.length > 0){
// 这里表示新节点没有儿子,但是老节点有,需要吧老节点的儿子删除掉
unMountChildren(el,oldVNodeChildren)
}
return el
}
function mountChildren(el,children){
for (const child of children) {
el.appendChild(createEle(child))
}
}
function unMountChildren(el,children){
// 直接删除老节点的子元素
el.innerHTML = ""
}
实现完整的diff算法
这里我们来完成当旧节点和新节点都有子元素时,进行互相对比。
在Vue2中使用了双指针来进行子元素之间的对比,一个指针指向第一个节点,一个指针指向最后一个节点,比较一次后,首指针往后移动一位,当首指针大于尾指针时,比较结束
patch.js
import {isSameVNode} from "./index.js";
export function patch(oldVNode,newVNode){
// 判断是否是一个真实元素,如果是真实DOM会返回1
const isRealEle = oldVNode.nodeType;
// 初次渲染
if(isRealEle){
// 获取真实元素
const elm = oldVNode
// 获取真实元素的父元素
const parentElm = elm.parentNode
// 创建新的虚拟节点
let newRealEl = createEle(newVNode)
// 把新的虚拟节点插入到真实元素后面
parentElm.insertBefore(newRealEl,elm.nextSibling)
// 然后删除之前的DOM
parentElm.removeChild(elm)
}else{
patchVNode(oldVNode,newVNode)
}
}
export function createEle(vnode){
let {tag,data,children,text} = vnode
if (typeof tag === "string"){
vnode.el = document.createElement(tag)
// 处理节点的属性
patchProps(vnode.el,{},data)
// 递归处理子元素
children.forEach(child=>{
child && vnode.el.appendChild(createEle(child))
})
}else{
vnode.el = document.createTextNode(text)
}
return vnode.el
}
function patchProps(el,oldProps = {},props = {}){
let oldPropsStyle = oldProps.style
let newPropsStyle = props.style
// 判断如果新节点上没有旧节点的样式,则应该吧原来的样式清空掉
for (const key in oldPropsStyle) {
if(!newPropsStyle[key]){
el.style[key] = ""
}
}
// 判断旧节点的属性在新节点是否存在
for (const key in oldProps) {
if(!props[key]){
el.removeAttribute(key)
}
}
for (const key in props) {
if(key === "style"){
Object.keys(props.style).forEach(sty=>{
el.style[sty] = props.style[sty]
})
}else{
el.setAttribute(key,props[key])
}
}
}
function patchVNode(oldVNode,newVNode){
// 进行diff算法,对比新老节点
// 对比两个节点的tag和key是否一样,如果不一样,则直接吧老的替换掉,换成新的节点
if(!isSameVNode(oldVNode,newVNode)){
console.log(oldVNode,'oldVNode')
console.log(newVNode,'newVNode')
let el = createEle(newVNode)
oldVNode.el.parentNode.replaceChild(el,oldVNode.el)
return el
}
// 一样的情况对DOM元素进行复用
let el = newVNode.el = oldVNode.el
// 如果一样,则还需要判断一下文本的情况
if(!oldVNode.tag){
if(oldVNode.el.text !== newVNode.text){
oldVNode.el.text = newVNode.text
}
}
// 比较新节点和旧节点的属性是否一致
patchProps(el,oldVNode.data,newVNode.data)
// 然后比较新旧节点的儿子节点
let oldVNodeChildren = oldVNode.children || []
let newVNodeChildren = newVNode.children || []
if(oldVNodeChildren.length > 0 && newVNodeChildren.length > 0){
// 声明两个指针,分别指向头节点和尾节点
// 然后进行对比,当旧node的头节点和新node的头节点相同时,则进行头指针往后移动
// 当头指针大于尾指针时停止循环
let oldStartIndex = 0
let oldEndIndex = oldVNodeChildren.length - 1
let oldStartNode = oldVNodeChildren[0]
let oldEndNode = oldVNodeChildren[oldEndIndex]
let newStartIndex = 0
let newEndIndex = newVNodeChildren.length - 1
let newStartNode = newVNodeChildren[0]
let newEndNode = newVNodeChildren[newEndIndex]
// 添加一个映射表
let nodeMap = {}
oldVNodeChildren.forEach((child,index)=>{
nodeMap[child.key] = index
})
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex){
if(!oldStartNode){
oldStartNode = oldVNodeChildren[++oldStartIndex]
}else if(!oldEndNode){
oldEndNode = oldVNodeChildren[--oldEndIndex]
}
// 1.进行头头对比
else if(isSameVNode(oldStartNode,newStartNode)){
// 递归更新子节点
patchVNode(oldStartNode,newStartNode)
oldStartNode = oldVNodeChildren[++oldStartIndex]
newStartNode = newVNodeChildren[++newStartIndex]
}
// 2.进行尾尾对比
else if(isSameVNode(oldEndNode,newEndNode)){
// 递归更新子节点
patchVNode(oldEndNode,newEndNode)
oldEndNode = oldVNodeChildren[--oldEndIndex]
newEndNode = newVNodeChildren[--newEndIndex]
}
// 3.进行尾头
else if(isSameVNode(oldEndNode,newStartNode)){
patchVNode(oldEndNode,newStartNode)
// 如果旧节点的尾节点和新节点的头节点相同,则吧把旧节点的尾节点放在头节点之前
// 然后把旧的尾指针往前移动,新节点的头指针往后移动
el.insertBefore(oldEndNode.el,oldStartNode.el)
oldEndNode = oldVNodeChildren[--oldEndIndex]
newStartNode = newVNodeChildren[++newStartIndex]
}
// 4.进行头尾对比
else if(isSameVNode(oldStartNode,newEndNode)){
patchVNode(oldStartNode,newEndNode)
// 如果旧节点的头和新节点的尾相同,则吧旧节点的头节点放在尾节点后
// 然后把旧的头指针往后移动,新节点的尾指针往前移动
el.insertBefore(oldStartNode.el,oldEndNode.el.nextSibling)
oldStartNode = oldVNodeChildren[++oldStartIndex]
newEndNode = newVNodeChildren[--newEndIndex]
}else{
// 5.进行乱序查找
let oldNodeIndex = nodeMap[newStartNode.key]
if(oldNodeIndex !== undefined){
let moveNode = oldVNodeChildren[oldNodeIndex]
el.insertBefore(moveNode.el,oldStartNode.el)
oldVNodeChildren[oldNodeIndex] = undefined
patchVNode(moveNode,newStartNode)
}else{
el.insertBefore(createEle(newStartNode),oldStartNode.el)
}
newStartNode = newVNodeChildren[++newStartIndex]
}
}
// 循环结束后,如果新节点的头指针小于等于新节点的尾指针
// 说明新节点是有多出来的内容,则要把新节点多出来的push到现有节点的后面
if(newStartIndex <= newEndIndex){
console.log("1")
for (let i = newStartIndex; i <= newEndIndex; i++) {
// 如果尾指针的下一个节点有值,说明是新节点的前面有多出来的节点
// 需要吧新的节点插入到前面去
let anchor = newVNodeChildren[newEndIndex + 1] ? newVNodeChildren[newEndIndex + 1].el : null
// 吧新节点插入到anchor的前面
el.insertBefore(createEle(newVNodeChildren[i]),anchor)
}
}
// 如果旧节点的头指针小于等于旧节点的尾指针,则说明旧的有多的节点,需要删除掉
if(oldStartIndex <= oldEndIndex){
for (let i = oldStartIndex; i <= oldEndIndex; i++) {
let chilEl = oldVNodeChildren[i]
chilEl && el.removeChild(chilEl.el)
}
}
}else if(newVNodeChildren.length > 0){
// 这里表示老节点没有儿子,但是新节点有,需要遍历新节点的每一个儿子,放在新节点中
mountChildren(el,newVNodeChildren)
}else if(oldVNodeChildren.length > 0){
// 这里表示新节点没有儿子,但是老节点有,需要吧老节点的儿子删除掉
unMountChildren(el,oldVNodeChildren)
}
return el
}
function mountChildren(el,children){
for (const child of children) {
el.appendChild(createEle(child))
}
}
function unMountChildren(el,children){
// 直接删除老节点的子元素
el.innerHTML = ""
}
测试一下,手动的编写两个虚拟节点进行比对
//----------测试diff算法---------------
let render1 = compilerToFunction(`<div style='color: red'>
<li key="a">a</li>
<li key="b">b</li>
<li key="c">c</li>
<li key="d">d</li>
</div>`)
let vm1 = new Vue({data:{name:"张三"}})
let prevVNode = render1.call(vm1)
let el = createEle(prevVNode)
document.body.appendChild(el)
let render2 = compilerToFunction(`<div style='background-color: blue;color: white'>
<li key="f">f</li>
<li key="e">e</li>
<li key="c">c</li>
<li key="n">n</li>
<li key="a">a</li>
<li key="m">m</li>
<li key="j">j</li>
</div>`)
let vm2 = new Vue({data:{name:"李四"}})
let newVNode = render2.call(vm2)
setTimeout(()=>{
console.log(prevVNode)
console.log(newVNode)
patch(prevVNode,newVNode)
},1000)
倒计时一秒后会自动变成新的
但是我们肯定不能使用这种方式来实现页面的更新和diff,需要在修改完数据后,在update中进行新旧节点的diff
修改 lifecycle.js
文件中的 update 方法
// 更新视图方法
Vue.prototype._update = function (vnode){
const vm = this
const el = vm.$el
const preVNode = vm._vnode
vm._vnode = vnode
if(preVNode){
// 第二个渲染,用两个虚拟节点进行diff
vm.$el = patch(preVNode,vnode)
}else{
// 第一次渲染页面,用虚拟节点直接覆盖真实DOM
vm.$el = patch(el,vnode)
}
}
查看效果
通过动画我们可以看到每次更新时只有里面的文字变化,其他元素并不会重新渲染
自定义组件实现原理
vue中可以声明自定义组件和全局组件,当自定义组件和全局组件重名时,会优先使用自定义组件。
在源码中,主要靠 Vue.extend 方法来实现
例如如下写法:
Vue.component("my-button",{
template:"<button>全局的组件</button>"
})
let Sub = Vue.extend({
template:"<button>子组件 <my-button></my-button></button>",
components:{
"my-button":{
template:"<button>子组件自己声明的button</button>"
}
}
})
new Sub().$mount("#app")
页面展示的效果
我们来实现这个源码
在 globalApi.js
文件中添加方法
import {mergeOptions} from "./utils.js";
export function initGlobalApi(Vue) {
// 添加一个静态方法 mixin
Vue.options = {
// 添加一个属性,记录Vue实例
_base:Vue
}
Vue.mixin = function (mixin) {
this.options = mergeOptions(this.options, mixin)
return this
}
Vue.extend = function (options){
function Sub(options = {}){
this._init(options)
}
Sub.prototype = Object.create(Vue.prototype)
Sub.prototype.constructor = Sub
Sub.options = mergeOptions(Vue.options,options)
return Sub
}
Vue.options.components = {}
Vue.component = function (id,options){
options = typeof options === "function" ? options : Vue.extend(options)
Vue.options.components[id] = options
}
}
在 utils.js
文件中添加组件合并策略,实现先找自身声明的组件,找不到再去原型链上找全局声明的组件
// 定义一些策略,例如 created,beforeCreated 等,不需要写大量的if判断
const strats = {}
const LIFECYCLE = [
"beforeCreated",
"created"
]
LIFECYCLE.forEach(key => {
strats[key] = function (p, c) {
if (c) {
if (p) {
return p.concat(c)
} else {
return [c]
}
} else {
return p
}
}
})
// 添加组件合并策略
strats.components = function (parentVal, childVal){
const res = Object.create(parentVal)
if(childVal){
for (const key in childVal) {
res[key] = childVal[key]
}
}
return res
}
// 合并属性的方法
export function mergeOptions(parent, child) {
const options = {}
// 先获取父亲的值
for (const key in parent) {
mergeField(key)
}
for (const key in child) {
// 如果父亲里面没有这个子属性,在进行合并子的
/**
* 示例:父亲:{a:1} 儿子:{a:2}
* 儿子中也有父亲的属性a,所以不会走儿子中的合并方法,但是在取值的时候,优先取的是儿子身上的属性值
* 所以合并到一个对象中时,儿子会覆盖父亲
*/
if (!parent.hasOwnProperty(key)) {
mergeField(key)
}
}
function mergeField(key) {
if (strats[key]) {
// {created:fn} {}
// 合并声明周期上的方法,例如:beforeCreated,created
options[key] = strats[key](parent[key], child[key])
} else {
// 先拿到儿子的值
options[key] = child[key] || parent[key]
}
}
return options
}
这一步实现了组件按照原型链查找,通过打断点可以看到
接着修改 src/vdom/index.js
文件,增加创建自定义组件的虚拟节点
// 判断是否是原生标签
let isReservedTag = (tag) => {
return ["a", "div", "span", "button", "ul", "li", "h1", "h2", "h3", "h4", "h5", "h6", "p", "input", "img"].includes(tag)
}
// 创建虚拟节点
export function createElementVNode(vm, tag, prop, ...children) {
if (!prop) {
prop = {}
}
let key = prop.key
if (key) {
delete prop.key
}
if (isReservedTag(tag)) {
return vnode(vm, tag, prop, key, children, undefined)
} else {
// 创建组件的虚拟节点
return createTemplateVNode(vm, tag, prop, key, children)
}
}
function createTemplateVNode(vm, tag, data, key, children) {
let Core = vm.$options.components[tag]
// 这里有两种情况,如果是自己组件本身定义的一个子组件,则拿到的直接是一个对象,里面有一个template
// 否则会往原型链上找,找到的是通过 Vue.component 定义的组件,拿到的是一个Sub构造函数
if (typeof Core === "object") {
// 需要将对象变成Sub构造函数
Core = vm.$options._base.extend(Core)
}
data.hook = {
init() {
}
}
return vnode(vm, tag, data, key, children = [], undefined, Core)
}
// 创建文本节点
export function createTextVNode(vm, text) {
return vnode(vm, undefined, undefined, undefined, undefined, text)
}
function vnode(vm, tag, data, key, children = [], text, componentsOptions) {
children = children.filter(Boolean)
return {
vm,
tag,
data,
key,
children,
text,
componentsOptions
}
}
// 判断两个节点是否一致
export function isSameVNode(oldVNode, newVNode) {
// 对比两个节点的tag和key是否都一样,如果都一样,就认为这两个节点是一样的
return oldVNode.tag === newVNode.tag && oldVNode.key === newVNode.key
}
实现组件渲染功能
上面我们根据tag判断是否是一个组件,并且添加了一个 createTemplateVNode 方法,返回组件的虚拟节点vnode。
然后需要在 src/vdom/patch.js
文件的 createEle 生成真实节点的方法中添加判断,是否是虚拟节点
function createComponent(vnode){
let i = vnode.data
if((i=i.hook) && (i=i.init)){
i(vnode)
}
if(vnode.componentsInstance){
return true
}
}
export function createEle(vnode){
let {tag,data,children,text} = vnode
if (typeof tag === "string"){
// 判断是否是组件
if(createComponent(vnode)){
return vnode.componentsInstance.$el
}
vnode.el = document.createElement(tag)
// 处理节点的属性
patchProps(vnode.el,{},data)
// 递归处理子元素
children.forEach(child=>{
child && vnode.el.appendChild(createEle(child))
})
}else{
vnode.el = document.createTextNode(text)
}
return vnode.el
}
在 createComponent 方法中就会去调用在上面 createTemplateVNode 方法中定义的 init 方法,并把当前的 vnode 传递过去
这时需要在init方法中接收这个vnode,并去new 这个 vnode 的 componentsOptions 中的 Core,这里的Core也就是 Vue.extend
修改 src/vdom/index.js
中的 createTemplateVNode 方法
function createTemplateVNode(vm, tag, data, key, children) {
// 从全局中的component中获取对应的组件,应为之前已经合并过了,所以这里可以直接获取
let Core = vm.$options.components[tag]
// 这里有两种情况,如果是自己组件本身定义的一个子组件,则拿到的直接是一个对象,里面有一个template
// 否则会往原型链上找,找到的是通过 Vue.component 定义的组件,拿到的是一个Sub构造函数
if (typeof Core === "object") {
// 需要将对象变成Sub构造函数
Core = vm.$options._base.extend(Core)
}
data.hook = {
init(vnode) {
// 从返回的vnode上获取componentsOptions中的Core
let instance = vnode.componentsInstance = new vnode.componentsOptions.Core
instance.$mount()
}
}
return vnode(vm, tag, data, key, children = [], undefined, {Core})
}
new 完 Core 后返回的实例同时赋值给当前vnode的componentsInstance上和局部变量instance
然后使用 instance 再去调用 $mount 方法,会触发 patch 方法,但是这里并没有传递参数,所以就需要在 patch 方法中添加一个判断,如果没有旧节点,直接创建新的节点并返回
export function patch(oldVNode,newVNode){
+ if(!oldVNode){
+ return createEle(newVNode)
+ }
// 判断是否是一个真实元素,如果是真实DOM会返回1
const isRealEle = oldVNode.nodeType;
// 初次渲染
if(isRealEle){
// 获取真实元素
const elm = oldVNode
// 获取真实元素的父元素
const parentElm = elm.parentNode
// 创建新的虚拟节点
let newRealEl = createEle(newVNode)
// 把新的虚拟节点插入到真实元素后面
parentElm.insertBefore(newRealEl,elm.nextSibling)
// 然后删除之前的DOM
parentElm.removeChild(elm)
// 返回渲染后的虚拟节点
return newRealEl
}else{
return patchVNode(oldVNode,newVNode)
}
}
这是用我们自己的vue.js来看一下实现的效果
<body>
<div id="app">
<ul>
<li>{{age}}</li>
<li>{{name}}</li>
</ul>
<button onclick="updateAge()">更新</button>
</div>
</body>
<script src="./vue.js"></script>
Vue.component("my-button",{
template:"<button>全局的组件</button>"
})
let Sub = Vue.extend({
template:"<button>子组件 <my-button></my-button></button>",
components:{
"my-button":{
template:"<button>子组件自己声明的button</button>"
}
}
})
new Sub().$mount("#app")
总结:
- 创建子类构造函数的时候,会将全局的组件和自己身上定义的组件进行合并。(组件的合并,会优先查找自己身上的,找不到再去找全局的)
- 组件的渲染:开始渲染的时候组件会编译组件的模板(template 属性对应的 html)变成render函数,然后调用 render函数
- createrElementVnode 会根据 tag 类型判断是否是自定义组件,如果是组件会创造出组件对应的虚拟节点(给组件增加一个初始化钩子,增加componentOptions选项 { Core })
- 然后再创建组件的真实节点时。需要 new Core,然后使用返回的实例在去调用 $mount() 方法就可以完成组件的挂载