Hello 大家好!我是壹甲壹!
本文是手写 mini-vue 系列的第一篇,本篇主要是探索 Vue 中数据首次渲染流程。阅读完本文,你将了解以下内容:
- Vue 中是如何实现响应式数据的?
- Vue 中是如何监测数组的变化的?
- Vue 中模版编译是如何实现的?
- Vue 中实例首次渲染如何实现的?
本文 Vue 源码版本:2.6.11 ** mini-vue 仓库地址:mini-vue **,欢迎 start 👏👏
好了,让我们步入正题开始吧,墙裂建议搭配代码阅读
一、开发环境搭建
- 项目初始化
mkdir vue-source
cd vue-source
npm init -y
- 安装第三方依赖
npm i -D @babel/core @babel/preset-env rollup rollup-plugin-babel rollup-plugin-serve
采用的 rollupjs 模块打包器,一个 JavaScript 模块打包器,可以将小块代码编译成大块复杂的代码
- 入口文件编写 src/index.js
// src/index.js
function Vue (){
//...
}
export default Vue
- 首页文件 public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app"></div>
<script src="../dist/vue.js"></script>
<script>
const vue = new Vue()
console.log(vue)
</script>
</body>
</html>
- rollup 配置文件
rollup.config.js
import babel from 'rollup-plugin-babel'
import serve from 'rollup-plugin-serve'
export default {
input: './src/index.js',
output: {
format: 'umd', // amd commonjs规范 默认将打包后的结果挂载到window上
file: 'dist/vue.js', // 打包出的vue.js 文件 new Vue
name: 'Vue',
sourcemap: true
},
plugins: [
babel({ // 解析es6 -》 es5
exclude: "node_modules/**" // 排除文件的操作 glob
}),
serve({ // 开启本地服务
open: true,
openPage: '/public/index.html',
port: 3000,
contentBase: ''
})
]
}
- 配置
script
运行脚本
"scripts": {
"serve": "rollup -c -w"
}
- 运行项目
npm run serve
此次,开发环境搭建完成,详细代码可访问 mini-vue chapter-01 查看
二、Vue 数据初始化
我们都知道在 Vue 2.x 版本中,使用的是 Object.defineProperty
来对数据进行监测的,接下来,看具体怎么实现的吧
从创建一个简单的 Vue 实例开始:
const vue = new Vue({
el: '#app',
data () {
return {
use: 'tom',
age: 23,
taskInfo: {
id: '123321',
name: ''
}
}
}
// ...
})
data
属性在根组件中,可以直接写成一个对象,在组件中,推荐写成函数,返回一个对象,这样可以保证每个组件实例中的数据互不干扰。
在构造函数 Vue
中,我们就可以拿到传递的参数了,现在,我们需要一些做一些初始化工作
// src/index.js
function Vue(options) {
console.log(options)
}
export default Vue
2.1 _init(), 内部初始化
我们需要在 Vue 原型上定义一个方法 _init
,用来完成一些初始化的工作
// src/index.js
import {initMixin} from './init.js'
function Vue(options) {
this._init(options)
}
initMixin(Vue)
ps: 将初始化代码逻辑抽离出去,有利于后期扩展
// src/init.js
import {initState} from './state.js'
export function initMixin(Vue) {
Vue.prototype._init = function (options) {
const vm = this
vm.$options = options // 绑定用户传入的参数
initState(vm) // 初始化状态
}
}
在初始化函数 _init
中,不仅需要对数据做状态处理,后续还需要展开其它操作,例如挂载等,所以将状态初始化逻辑抽离到单独文件中
2.2 initState(), 状态初始化
// src/state.js
export function initState(vm) {
const opts = vm.$options
if (opts.props) {
initProps(vm)
}
if (opts.data) {
initData(vm)
}
}
// 初始化 props
function initProps(){}
// 初始化 data
function initData(){}
因为 $options
存在不同的属性,有 el, props, data, computed, watch
等,所以针对不同的属性,采取不同的初始化函数,我们先以 initData
函数展开介绍
// src/state.js
import {observer} from './observer/index'
function initData(vm) {
let data = vm.$options.data
data = typeof data === 'function' ? data.call(vm) : data
observer(data)
}
因为 data
可以是对象,也可以是个函数,若是函数,执行函数获取函数返回值。ps: data.call(vm)
, 传入 vm 是保证 data 函数中的 this 为当前 vue 实例。
通过 observer
函数对 data
进行监测,为了代码不耦合,我们将监测函数抽离出来
2.3 observer(),数据监测
2.3.1 普通监测
在 observer
函数中首先会判断 data
是否为对象,isObject
判断逻辑如下
// src/utils/index.js
export function isObject (obj) {
return (obj && typeof obj === 'object')
}
仅当 data
是对象,才会进行数据监测。通过 new Observer(data)
一个实例,就可以对 data
当中的所以属性定义 get 和 set 方法 (ps:暂时不考虑数组,数组情况特殊,后续展开)
// src/observer/index.js
import {isObject} from '../utils/index'
export function observer(data) {
if (!isObject(data)) {
return false
}
return new Observer(data)
}
class Observer{
constructor (data) {
this.walk(data) // 对数据一步一步处理
}
walk (data) {
Object.keys(data).forEach(key => {
defineReactive(data, key, data[key])
})
}
}
function defineReactive(target, key, value) {
observer(value) // 当 value 是个对象,需要迭代继续监测
Object.defineProperty(target, key, {
get () {
return value
},
set (newValue) {
if (newValue === value) return
observer(newValue) // 当设置的 newValue 是个对象,也需要对其监测
value = newValue
}
})
}
现在,我们可以测试下,数据监测后的结果
// public/index.html
<script>
const vue = new Vue({
el: '#app',
data () {
return {
use: 'tom',
age: 23,
taskInfo: {
id: '123321',
name: ''
}
}
}
})
console.log(vue)
</script>
打印结果
我们发现,监测过后的 data
并没有挂载到当前实例上,所以,添加以下代码就 OK了
// src/state.js
function initData(vm) {
vm.$data = data = typeof data === 'function' ? data.call(vm) : data
}
此时,通过 vm.$data 就可以访问到观测监听后的数据了,打印结果如下
现在我们看到每个属性都增加了 get 和 set 方法。⚠️注意,**taskInfo**
** 的属性值为对象,其中的属性也会被监测到。**
前面提到数组比较特别,我们可以先看下,当前在 data
增加个属性 hobbies
,值为 ['game', 'read', 'run']
,观测结果如下
此时,数组中每个索引都被增加上了 get
和 set
方法,但 Vue
中并没有使用该方法来监听数组,原因有二
- 1、当我们数组长度很大时,每个索引都进行监测,十分影响性能
- 2、开发中并不常用
arr[10]=xxx
这种通过下标来修改数组,而是使用push ,shift, splice
等方法操作数组
所以,数组的监测,需要区别对待
2.3.2 数组监测
在 Vue 中,对数组的监测采用的是函数劫持,将那些会改变数组的方法,进行重写。
// src/observer/index.js
import {arrayMethods} from './array'
class Observer{
constructor (data) {
if (Array.isArray(data)) {
data.__proto__ = arrayMethods;
this.observerArray(data) // 对象数组
} else {
this.walk(data) // 对数据一步一步处理
}
}
observerArray(data) {
for (let i = 0; i < data.length; i++) {
observer(data[i])
}
}
}
👆当 data 是个数组时,重写数组的原型,同时当 data 是个对象数组时,我们需要监测数组中的每一个对象
// src/observer/array.js
let oldArrayMethods = Array.prototype
export let arrayMethods = Object.create(oldArrayMethods)
const methods = [
'push',
'pop',
'shift',
'unshift',
'sort',
'reverse',
'splice'
]
methods.forEach(method => {
arrayMethods[method] = function (...args) {
const result = oldArrayMethods[method].apply(this, args)
return result
}
})
现在,我们对能够改变原数组的 7 中方法进行了重写,在重写函数中,首先会执行该方法默认的逻辑并拿到返回值,同时,push、unshift、splice
方法可以向原数组中添加新的子项,所以,我们也要监测新增加的子项
// src/observer/array.js
methods.forEach(method => {
arrayMethods[method] = function (...args) {
const ob = this.__ob__
const result = oldArrayMethods[method].apply(this, args)
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break;
case 'splice':
inserted = args.slice(2)
break;
}
inserted && ob.observerArray(inserted)
return result
}
})
在对数组中新增加的子项进行监测时,需要注意的是,监测方法 observerArray
是通过 this.__ob__
属性获取到的,此处的 this
指向的是当前操作的数组。默认数组上不存在该属性,所以我们需要添加下面的代码
// src/observer/index.js
class Observer{
constructor (data) {
// 标识
Object.defineProperty(data, '__ob__', {
enumerable: false,
configurable: false,
value: this
})
}
observerArray(data) {
for (let i = 0; i < data.length; i++) {
observer(data[i])
}
}
//.....
}
此时,_ ob_
属性相对于一个标识符,无论 data
是数组还是对象,都会添加上。使用 defineProperty
定义该属性,而非通过 data.__ob__= this
直接赋值,是为了避免该属性会通过 Object.keys()
遍历出来,从而被监测到。
现在,可以测试下,给对象数组增加一个新的对象子项,新的子项是否会被监听到
<script>
const vue = new Vue({
el: '#app',
data () {
return {
bookList: [
{
name: 'book_111',
price: 11
},
{
name: 'book_222',
price: 22
}
]
}
}
})
vue.$data.bookList.push({
name: 'book_333',
price: 33
})
console.log(vue)
</script>
输出结果如下
数组中默认存在的对象都被监测,同时通过 push
新增的子项也被监测到
2.3.3 数据代理
前面使用到 vue.$data.bookList.push
新增数据,但更期望的是通过实例直接访问操作 data
中的数据,像 vue.bookList.push
这样,所以需要做个数据代理
// src/util.js
export function proxy(vm, data, key) {
Object.defineProperty(vm, key, {
get() {
// vm.a => vm.$data.a
return vm[data][key];
},
set(newValue) {
// vm.a = 100; => vm.$data.a = 100;
vm[data][key] = newValue
}
})
}
// src/state.js
import { proxy } from './util.js'
function initData(vm) { // 数据初始化
let data = vm.$options.data;
vm.$data = data = typeof data == 'function'?data.call(vm):data;
for(let key in data){
proxy(vm,'$data',key);
}
observe(data);
}
此时就在 vm[key]
与 vm.$data[key]
之间做了数据代理,操作 vm[key]
就是在操作 vm.$data[key]
2.4 小结
至此,当属性值是对象或数组是,我们都可以对其进行监测了,本小节完整代码请参考 mini-vue chapter-02
三、模版编译
模版编译的核心是将 template
转换成 render
,在讨论模版编译之前,我们知道通过指定 el 属性,或者调用 $mount
方法,可将 Vue
实例挂载到页面上,而页面上具体会渲染什么内容呢?
所以在内部 $mount
函数中,会进行以下判断:
- 首先判断是否存在
render
函数? - 当不存在
render
时,判断template
属性是否存在,当template
不存在而el
存在时,会将el.outerHTML
作为template
// src/init.js
Vue.prototype.$mount = function (el) {
const vm = this
const options = vm.$options
// 获取真实 dom, 并挂载到 vm 上
el = document.querySelector(el)
vm.$el = el
if (!options.render) {
let template = options.template
if (!template && el) {
template = el.outerHTML
}
const render = compileToFunctions(template)
options.render = render
}
mountComponent(vm, el) 挂载组件,后续解析
}
接下来,需要实现 compileToFunctions
方法将 template
转换成 render
函数 (ps: template
其实就是 html
代码),具体分成以下几个步骤
- 解析
html
代码生成ast
语法树 - 对
ast
进行静态节点标记 (可提高VNode
Diff
效率) - 将
ast
转换成字符串,字符串中嵌入_c
,_v
,_s
等方法,分别用来描述 dom节点、文本节点、以及变量 - 将字符串通过
new Function
加with
转换成render
函数
// src/complier/index.js
import { parseHtml } from "./parseHtml"
import { generate } from "./generateCode"
export function compileToFunctions(template) {
// 模版编译,将html模版 => render 函数
// 1、html 代码转换成 ast 语法树
const ast = parseHtml(template)
// 2、标记静态节点
// 3、ast 转换成字符串 strCode
const strCode = generate (ast)
// 4、转换成 render 函数
const render = new Function(`with(this){return ${strCode}}`)
return render
}
3.1 parseHtml
以👇 HTML
代码为例,探索如何生成 ast
语法树
`<div id="app" style="color:red">Hello <span>World, {{name}}</span> </div>`
ast
树的生成核心就在于使用正则对 html
字符串进行匹配,大致步骤如下
- 当匹配到开始标签
<div
时,会创建一个对象match
标识匹配到的标签div
, 匹配成功后, 删除已匹配的内容 - 在匹配开始标签中的属性
id="app" style="color:red"
,并将属性存储到match.attr
数组中,删除已匹配内容 - 当匹配到开始标签结束符
>
时,表示整个开始标签匹配完成,删除已匹配内容 - 接着就是匹配文本
Hello
, 处理文本,直到最后匹配到</div>
结束标签,处理结束标签 - 此时,整个
html
就匹配完成
// src/complier/parseHtml.js
export function parseHtml(html){
let root
while(html) {
// 1、判断是否以 `<` 开头
let textEnd = html.indexOf('<')
if (textEnd === 0) {
// 开头是标签
}
if (textEnd > 0) {
// 开头是文本
}
}
return root // 返回 ast
}
3.1.1 匹配开始标签
当 textEnd === 0 时,html 字符串开头可能是开始标签
// src/complier/parseHtml.js
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*` // 匹配标签名
const qnameCapture = `((?:${ncname}\\:)?${ncname})` // 匹配类似 my:xxx 的标签名
const startTagOpen = new RegExp(`^<${qnameCapture}`) // 匹配开始标签
export function parseHtml(html){
let root
while(html) {
// 1、判断是否以 `<` 开头
let textEnd = html.indexOf('<')
if (textEnd === 0) {
// 开头是标签
const startTagMatch = parseStartTag()
if (startTagMatch) {
start(startTagMatch); // 处理标识开始标签的 match 对象,后续介绍
continue;
}
}
if (textEnd > 0) {
// 开头是文本
}
}
// 删除匹配成功的字符串
function advance (n) {
html = html.substring(n).trim()
}
function parseStartTag() {
const startTag = html.match(startTagOpen)
if (startTag) {
const match = {
tagName: startTag[1],
attrs: []
}
// 匹配成功后,就需要在 html 中删除已经匹配的字符长度
advance(startTag[0].length)
}
}
return root // 返回 ast
}
匹配成功后的 startTag 是个数组,输出如下
["<div", "div", index: 0, input: "<div id="app" style="color:red"><p>hello wor...</div>", groups: undefined]
通过 match 标识开始标签,同时使用 advance 函数删除已经匹配的 <div
,此时 html 显示如下
`id="app" style="color:red">Hello <span>World, {{name}}</span> </div>`
接下来,就需要匹配开始标签中的属性了
3.1.2 匹配标签属性
有些开始标签中并没有属性,所以需要判断是否匹配到属性了,同时当我们匹配到开始标签中的 >
结束字符时,也就表明所有属性匹配完成
// src/complier/parseHtml.js
const startTagClose = /^\s*(\/?)>/ // 匹配开始标签的结束符 >
// 匹配属性
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
function parseStartTag() {
const startTag = html.match(startTagOpen)
if (startTag) {
const match = {
tagName: startTag[1],
attrs: []
}
// 匹配成功后,就需要在 html 中删除已经匹配的字符长度
advance(startTag[0].length)
let end
let attr
while(!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
match.attrs.push({
name: attr[1],
value: attr[3] || attr[4] || attr[5]
})
advance(attr[0].length)
}
if (end) {
advance(end[0].length)
return match
}
}
}
大家可能会疑惑为啥属性值是 attr[3] || attr[4] || attr[5]
?🤔,原因是因为我们有以下三种方式写属性
<div id="app" class='wrap' data-xxx=yyy></div>
现在,parseStartTag
函数就会返回匹配开始标签已经标签中的属性值,通过 match
对象标识,match
对象会使用 start
方法处理,后续介绍。此时,html
字符串剩余字符如下,需要开始匹配文本了
`Hello {{name}} <span>World</span> </div>`
3.1.3 匹配文本
在本次 while
循环中,textEnd = html.indexOf('<')
的值肯定大于 0,文本的范围正好是 0 ~ textEnd
,所以匹配文本逻辑如下
if (textEnd > 0) {
// 从 0 ~ textEnd 表示的就是文本
let text = html.substring(0, textEnd)
if (text) {
chart(text) // 处理文本节点,后续介绍
advance(text.length)
}
}
此时 ,html 剩余字符串为
`<span>World</span></div>`
<span>World
跟前面相同逻辑,不再赘述。接下来就是需要匹配结束标签 </span>
了
3.1.4 匹配结束标签
结束标签以 <
开始,所以也会进入到 textEnd === 0
的逻辑中
// ...
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`) // 匹配结束标签
if (textEnd === 0) {
// 可能是开始标签
// ...
// 可能是结束标签
const endTagMatch = html.match(endTag)
if (endTagMatch) {
end(endTagMatch[1]) // 处理结束标签,后续展开
advance(endTagMatch[0].length)
continue;
}
}
剩余标签 </div>
也是结束标签,相同逻辑,不再赘述。
此时,整个 html 解析完成,我们在 start 、chart、end 三个方法中可获取成功匹配的开始标签、文本、结束标签
3.1.5 处理函数
返回的 ast 是一个树形结构,我们需要在处理函数中生成树形结构,大致流程如下
- 使用一个栈来维护节点之间的层级关系,一个变量
currentEle
标识正在处理的DOM节点 - 匹配到开始标签时,在 start 函数中,进行入栈操作,同时
currentEle
被赋值为开始标签元素 - 匹配到文本,在 chart 函数中,文本作为 currentEle 的子节点
- 匹配到结束标签,出栈,并将
currentEle
设置为当前栈中最后一个
具体逻辑如下
- start
export function parseHtml(html){
let root = ''
let currentEle = '' //标记当前正在处理的DOM 节点
let stackList = [] // 用栈来为何 DOM 节点之间的层级关系
function createAstElement (tagName, attrs) {
return {
tag: tagName,
type: 1, // dom节点类型都为 1
children: [],
attrs,
parent: null
}
}
function start({tagName, attrs}) {
let element = createAstElement(tagName, attrs)
if (!root) {
root = element
}
currentEle = element
stackList.push(element) // 处理开始标签进栈,处理结束标签出栈
}
return root
}
在 start
函数中,使用传入的标签创建 ast 元素,将元素入栈,并标识为 currentEle
- chart
function chart(text){
text = text.trim()
if (text) {
currentEle.children.push({
type: 3,
text,
parent: currentEle
})
}
}
文本 text
会作为当前 currentEle
的字元素,并将文本标识为type = 3
- end
function end(tagName){
let element = stackList.pop()
if (element.tag === tagName) {
currentEle = stackList[stackList.length - 1]
if (currentEle) {
// 双向绑定
element.parent = currentEle
currentEle.children.push(element)
}
}
}
还是以👇 html为例,探索 end
中的逻辑
`<div id="app" style="color:red">Hello {{name}}<span>World</span> </div>`
- 匹配到
<div
, 进栈,currentEle = div
- 匹配到
<span
, 进栈,currentEle = span
- 匹配到
</span>
, 此时stackList = [div, span]
, 将span
出栈, 并设置currentEle = stackList[stackList.length - 1]
- 此时,当
currentEle
存在时,会将span
作为currentEle
的子元素
至此,解析 html 生成 ast 语法树已经完成,返回的 ast 打印如下。 本小节完整代码请参考 mini-vue chapter-02
3.2 generate
generate
函数作用是将 ast
对象转换成字符串,字符串中嵌入 _c, _v, _s 等实例方法, 分别用来标识 DOM 节点、文本节点、以及变量。期望生成的字符串例如👇
`_c(
'div',
{id: "app",style: {"color":"red"}},
_v("Hello "+_s(name)),
_c('span',null, _v("World ")),
)`
可以看出,当 ast 中存在标签时,会使用 _c
方法标识,该方法参数是 _c(tag, attrs, ...children)
, 当遇到文本 Hello {{name}}
,使用 _v
进行标识,当文本中存在变量 {{name}}
,会使用正则匹配出变量,并使用 _s
进行标识
// src/complier/generateCode.js
export function generate(ast) {
const children = genChildren(ast.children)
const attrs = genPropertyData(ast.attrs)
let strCode = `_c('${ast.tag}',${attrs ? attrs : 'undefined'},${children ? children : ''})`
return strCode
}
function genChildren() {}
function genPropertyData() {}
将 attrs
和 children
的处理逻辑都拆分到不同函数中处理。先看 children
处理逻辑吧
3.2.1 处理 children
// src/complier/generateCode.js
function genChildren (children) {
if (!(children && children.length)) return false
return children.map(child => gen(child)).join(',')
}
function gen() {}
因为子节点可能是文本节点或标签节点,所以抽离到 gen
函数中处理,
// src/complier/generateCode.js
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; // 匹配大括号 {{}}
function gen(node) {
if (node.type === 1) { // DOM 节点
return generate(node)
} else {
// 文本节点, eg: `Hello {{name}}`, 需要匹配大括号里面的值
// _v('Hello'+ _s(name))
const {text} = node
if (!defaultTagRE.test(text)) {
// 纯文本
return `_v(${JSON.stringify(text)})`
} else {
let tokens = []
let match, index
let lastIndex = defaultTagRE.lastIndex = 0;
while(match = defaultTagRE.exec(text)) {
index = match.index
if (index > lastIndex) {
tokens.push(JSON.stringify(text.slice(lastIndex,index)))
}
tokens.push(`_s(${match[1].trim()})`)
lastIndex = index + match[0].length
}
if (lastIndex < text.length) {
tokens.push(JSON.stringify(text.slice(lastIndex)))
}
return `_v(${tokens.join('+')})`;
}
}
}
当子节点是标签时,直接递归调用 generate
函数即可;当是文本时,需要匹配出文本中可能存在的变量。
以将文本 Hello {{name}} World
转换成 _v('Hello'+ _s(name) + 'World')
为例,探索如何匹配出变量
我们先看下正则 exec
方法吧
**exec() **
方法在一个指定字符串中执行一个搜索匹配。返回一个结果数组或null
。 在设置了global
或sticky
标志位的情况下(如/foo/g
or/foo/y
),JavaScriptRegExp
对象是有状态的。他们会将上次成功匹配后的位置记录在lastIndex
属性中。使用此特性,exec()
可用来对单个字符串中的多次匹配结果进行逐条的遍历(包括捕获到的匹配)
变量 lastIndex
标识每次匹配开始的位置,将匹配成功返回的 match
打印输出如下
["{{name}}", "name", index: 6, input: "Hello {{name}} World", groups: undefined]
match[1]
就是要匹配的变量使用 _s
进行包裹,同时 match.index
标识匹配成功的位置为 6,那么 0 ~ 6 就是普通文本 Hello
,更新下次匹配开始的位置 lastIndex
为 index + match[0].length
等于 14
while 的第二次循环匹配不成功返回 null , 结束循环,此时 lastIndex
到文本最后,表示普通文本 World
,将所以匹配的数据通过拼接就可得到最终的字符串
3.2.2 处理 attrs
属性的处理相对简单点,不过需要注意的是 style 属性值可能是多个 "width: 20px; color: red;"
,需要转换成对象 {width: '20px', color: 'red'}
// src/complier/generateCode.js
function genPropertyData (attrs) {
if (!(attrs && attrs.length)) return false
let str = ''
attrs.forEach(attr => {
if (attr.name === 'style') {
let obj = {}
attr.value.split(';').map(item => {
let [k, v] = item.split(':')
obj[k] = v
})
attr.value = obj
}
str += `${attr.name}: ${JSON.stringify(attr.value)},`
});
return `{${str.slice(0, -1)}}` // 删除最后一个 ,
}
至此,ast 转换成字符串已经完成
3.3 render
通过将字符串通过 new Function
和 with
拼接,就可组成 render
函数
const render = new Function(`with(this){return ${strCode}}`)
因为 strCode
中存在实例上的变量,所以后面需要调用 render.call(vm)
确保 this
正确。
3.4 小结
至此,整个模版编译过程结束,然而在开发时尽量不要使用 template
,因为将 template
转化成 render
方法需要在运行时进行编译操作会有性能损耗,同时引用带有 compiler
包的 vue 体积也会变大,而默认vue项目中引入的vue.js是不带有compiler
模块的。
四、组件挂载
让我们回到 Vue.prototype.$mount
方法中,生成 render
函数后,就需要挂载组件了
// src/init.js
Vue.prototype.$mount = function (el) {
const vm = this
const options = vm.$options
// 获取真实 dom, 并挂载到 vm 上
el = document.querySelector(el)
vm.$el = el
if (!options.render) {
let template = options.template
if (!template && el) {
template = el.outerHTML
}
const render = compileToFunctions(template)
options.render = render
}
mountComponent(vm, el) 挂载组件,后续解析
}
mountComponent
中首先调用 _render()
方法返回创建 VNode
, 然后调用 _update
方法创建真实 DOM, 更新到页面上
// src/lifeycle.js
export function mountComponent (vm) {
vm._update(vm._render())
}
4.1 renderMixin
现在我们需要创建 _render 方法,在该方法中会调用之前生成的 render 函数,同时不要忘记,render 函数执行过程中,会调用 _c, _v, _s 等还未创建的方法
// src/vdom/index.js
export function renderMixin (Vue) {
// DOM节点
Vue.prototype._c = function () {
return createElement(...arguments)
}
// Text节点
Vue.prototype._v = function (text) {
return createTextElement(text)
}
// 变量
Vue.prototype._s = function (val) {
return val == null ? '' : (typeof val == 'object') ? JSON.stringify(val) : val;
}
Vue.prototype._render = function () {
// 调用真实的 render 方法,
const vm = this
const render = vm.$options.render
const vNode = render.call(vm) // 确保this正确
return vNode
}
}
当 render 执行,需要调用 _c, _v, _s 方法创建出 VNode 节点
// src/vdom/index.js
function createElement (tag, data = {}, ...children) {
return createVNode(tag, data, data.key, children)
}
function createTextElement (text) {
return createVNode(undefined,undefined,undefined,undefined,text);
}
function createVNode (tag,data,key,children,text) {
return {
tag,
data,
key,
children,
text
}
}
此时,调用 _render()
返回的 VNode
打印输出如下
然后在实例挂载、渲染之前,我们就应该先创建这些方法,所以在入口文件 src/index.js
文件导入 renderMixin
并执行
import {initMixin} from './init.js'
import { renderMixin } from './vdom/index.js'
function Vue(options) {
// 内部初始化操作
this._init(options)
}
initMixin(Vue) //
renderMixin(Vue) // 方法混合
export default Vue
4.2 patch
调用 vm._render()
返回的 VNode
会传递到 vm._update()
方法中, _update
方法定义在 lifecycleMixin
中,我们也需要在入口文件 src/index.js
文件导入 lifecycleMixin
并执行
// src/lifeycle.js
export function lifecycleMixin (Vue) {
Vue.prototype._update = function (vNode) {
const vm = this
patch(vm.$el, vNode)
}
}
import {initMixin} from './init.js'
import { renderMixin } from './vdom/index.js'
import { lifecycleMixin } from './lifecycle.js'
function Vue(options) {
// 内部初始化操作
this._init(options)
}
initMixin(Vue) //
lifecycleMixin(Vue) // 生命周期混合
renderMixin(Vue) // 方法混合
export default Vue
接下来,我们就需要实现 patch 方法,首次渲染时,将传入的 VNode 转换生成真实 DOM, 替换掉 el 对应的真实 DOM 即可
export function patch (oldVNode, newVNode) {
let newEl = createEl(newVNode)
let parentEl = oldVNode.parentNode
parentEl.insertBefore(newEl, oldVNode.nextSibling)
parentEl.removeChild(oldVNode)
// 非首次渲染,需要进行 VNode Diff
}
function createEl (vNode) {
let {tag,key,children,text} = vNode
if (tag && typeof tag === 'string') {
vNode.el = document.createElement(tag)
updateElProperties(vNode)
children.forEach(child => {
vNode.el.appendChild(createEl(child))
})
} else {
vNode.el = document.createTextNode(text)
}
return vNode.el
}
function updateElProperties (vNode) {
console.log(vNode)
const {el, data = {}} = vNode
for (const key in data) {
if (data.hasOwnProperty(key)) {
if (key === 'style') {
const styleObj = data.style
for (const v in styleObj) {
el.style[v] = styleObj[v]
}
} else if (key === 'class'){
el.className = data.class
} else {
el.setAttribute(key, data[key])
}
}
}
}
4.3 小结
至此,组件挂载完结,我们可以测试下
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app" style="color:red; border: 1px solid blue; width: 260px;" >
<p>Hello {{name}} World</p>
<li style="color:green">
{{book.name}}
</li>
<li>
{{book.price}}
</li>
</div>
<script src="../dist/vue.js"></script>
<script>
const vue = new Vue({
el: '#app',
data () {
return {
name: '掘金',
book: {
name: 'Hello Mini Vue',
price: 1
}
}
}
})
</script>
</body>
</html>
首次渲染截图如下
五、总结
至此,探索 Vue 实例首次渲染流程结束,但还有很多内容等着处理
- 依赖收集、实现响应式
- 解析 v-for v-model 等指令
- computed,watch,生命周期 hook 等
- VNode Diff ......
生活就是不断挖坑、填坑
仓库地址:mini-vue,欢迎 start 👏👏