一,前言
上篇,介绍了 render 函数的生成,主要涉及以下两点:
- 使用 with 对生成的 code 进行一次包装
- 将包装后的完整 code 字符串,通过 new Function 输出为 render 函数
本篇,根据 render 函数,生成虚拟节点 vnode
二,前文回顾
前面介绍,html
模板最终会被编译为render
函数
为了避免重复编译,会将生成好的render
函数保存到 opts.render
上:
// src/init.js
Vue.prototype.$mount = function (el) {
const vm = this;
const opts = vm.$options;
el = document.querySelector(el);
vm.$el = el;
if (!opts.render) {
let template = opts.template;
if (!template) template = el.outerHTML;
let render = compileToFunction(template);
opts.render = render;
}
}
接下来,继续使用render
函数,完成渲染操作:
- 根据
render
函数生成虚拟节点; - 根据虚拟节点 + 真实数据,生成真实节点;
二,挂载组件 mountComponent
1,mountComponent 方法
- 调用生成的
render
函数,执行后最终生成虚拟节点vnode
- 虚拟节点
vnode
+ 真实数据 => 真实节点
所以,接下来的下一个步骤,就是进行组件渲染并完成挂载
mountComponent
方法:将组件挂载到vm.$el
上
创建生命周期模块:src/lifecycle.js
,在mountComponent
方法中调用render
函数,并抛出mountComponent
:
// src/lifecycle.js#mountComponent
export function mountComponent(vm) {
render();// 调用 render 方法
}
在原型方法$mount
中,导入mountComponent
方法并调用,完成渲染操作:
// src/init.js
import { mountComponent } from "./lifecycle"; // 引入 mountComponent
Vue.prototype.$mount = function (el) {
const vm = this;
const opts = vm.$options;
el = document.querySelector(el);
vm.$el = el; // 真实节点
if (!opts.render) {
let template = opts.template;
if (!template) template = el.outerHTML;
let render = compileToFunction(template);
opts.render = render;
}
// 将当前 render 渲染到 el 元素上
mountComponent(vm);
}
3,封装 vm._render
mountComponent
方法:主要完成组件的挂载工作;
而render
渲染只是其一,还有其他工作需要处理;
考虑到render
方法的复用性,将渲染方法render
进行独立封装;
创建src/render.js
// src/render.js#renderMixin
export function renderMixin(Vue) {
// 在 vue 上进行方法扩展
Vue.prototype._render = function () {
// todo...
}
}
在src/index.js
入口文件,调用renderMixin
混合render
原型方法:
// src/index.js
import { initMixin } from "./init";
import { renderMixin } from "./render";
function Vue(options){
this._init(options);
}
initMixin(Vue)
renderMixin(Vue) // 混合 render 方法
export default Vue;
在src/lifecycle.js
的mountComponent
方法中,调用render
函数的方式发生改变:
export function mountComponent(vm) {
// render(); // 以前的调用方式
vm._render(); // 当前的调用方式
}
当vm._render
被调用时,内部将会调用_c
,_v
,_s
三个方法(三个方法与render
相关,可以封装到一起)
所以,在vm._render
方法中,需要完成以下几件事:
- 调用
render
函数; - 提供
_c
,_v
,_s
三个方法;
// src/render.js#renderMixin
export function renderMixin(Vue) {
Vue.prototype._c = function () { // createElement 创建元素型节点
console.log(arguments)
}
Vue.prototype._v = function () { // 创建文本的虚拟节点
console.log(arguments)
}
Vue.prototype._s = function () { // 将对象转为字符串,相当于 JSON.stringify()
console.log(arguments)
}
Vue.prototype._render = function () {
// 在 vm 中,包含了全部数据 vm.xxx => vm._data.xxx
const vm = this;
let { render } = vm.$options;
// 内部会调用 _c、_v、_s 三个方法,最终返回虚拟节点
let vnode = render.call(vm);
console.log(vnode)
return vnode;
}
}
4,代码调试
代码示例:
<body>
<div id="app">aaa {{name}} bbb {{age}} ccc</div>
<script src="./vue.js"></script>
<script>
let vm = new Vue({
el: '#app',
data() {
return { name: "Brave" , age : 123}
}
});
</script>
</body>
设置断点并进行调试:
这里,mountComponent
方法的入参 vm
,包含了render
函数及所有数据
继续,调用vm.render
方法:
在vm._render
方法中,会调用render
方法:
当render
方法被调用时,将会执行:
由于函数的执行顺序是从内向外执行,所以执行顺序为:_s(name)
、_s(age)
、_v()
、_c()
;
执行 _s(name):
先从 _data 取 name 值
当进入 _s 时,传入 name 的值
取值代理
数据劫持
进入_s(name)
:
同理,进入 _s(age)
:(略)
先从 _data 取 age 值
当进入 _s 时,传入 age 的值
继续,进入 _v
:
由于当前的_s
没有返回值,所以字符串拼接结果中包含 2 个 undefined
;
继续,进入 _c
:
参数包含:标签名、属性、孩子;
5,实现_s
_s
方法:将对象转成字符串,并返回结果;
// src/render.js#renderMixin
// _s 相当于 JSON.stringify
Vue.prototype._s = function (val) {
if(isObject(val)){ // 是对象,转成字符串
return JSON.stringify(val)
} else { // 不是对象,直接返回
return val
}
}
调试:
在_v
中设置断点,查看_s
处理后返回的字符串
先调用两个_s
,并将拼接结果传递给_v
:
打印render
函数:
// src/render.js#renderMixin
Vue.prototype._render = function () {
const vm = this;
let { render } = vm.$options;
console.log(render.toString()); // 打印 render 函数结果
let vnode = render.call(vm);
return vnode;
}
观察render
函数:
- 两个
_s
执行后,将拼接后的字符串传递给了_v
, _v
接收文本text
,文本创建完成后,继续将结果传递给_c
所以,需要先创造文本的虚拟节点,再创造元素的虚拟节点
创建目录:src/vdom
,包含以下两个方法:
- 创建元素虚拟节点
- 创建文本虚拟节点
备注:
_v
、_c
两个方法都与虚拟节点有关,所以将两个方法放到虚拟 dom 包中;
// src/vdom/index.js
export function createElement() {
// 返回元素虚拟节点
}
export function createText() {
// 返回文本虚拟节点
}
注意:在
renderMixin
方法中,只负责渲染逻辑,而具体如何创建虚拟dom
,是vdom
需要做的事情,所以需要将这两部分逻辑拆分开(渲染逻辑、创建虚拟 dom);
renderMixin
方法,只返回虚拟节点,并不关心虚拟节点如何产生,将虚拟节点的生成逻辑封装到vdom
模块内部的createElement
、createText
方法中;
6,实现 _v 和 _c
_v
方法:创建并返回文本的虚拟节点;
// src/render.js#renderMixin
// 创建文本的虚拟节点
Vue.prototype._v = function (text) {
const vm = this;
return createText(vm, text);// vm 作用:确定虚拟节点所属实例
}
传入 vm
的作用:确定虚拟节点所属实例;
如何创建文本虚拟节点,就交给createText
来完成:createText
生成 vnode
;
vnode
:一个用来描述节点的对象;
// src/vdom/index.js
// 返回虚拟节点
export function createElement(vm, tag, data={}, ...children) {
// _c('标签', {属性}, ...孩子)
return {
vm, // 是谁的虚拟节点
tag, // 标签
children, // 孩子
data, // 数据
// ... // 其他
}
}
// 返回虚拟节点
export function createText(vm, text) {
return {
vm,
tag: undefined, // 文本没有 tag
children,
data,
// ...
}
}
综合以上逻辑,将生成虚拟节点对象的逻辑,提取为一个函数:vnode
方法:
// src/vdom/index.js
// 通过函数返回 vnode 对象
// key 标识作用:后边元素根据 key 标识做 diff 算法,取值 data.key;
function vnode(vm, tag, data, children, key, text) {
return {
vm,
tag,
data,
children,
key,
text
}
}
重构代码:
// src/vdom/index.js
// 参数:_c('标签', {属性}, ...孩子)
export function createElement(vm, tag, data={}, ...children) {
// 返回元素的虚拟节点(元素是没有文本的)
return vnode(vm, tag, data, children, data.key, undefined);
}
export function createText(vm, text) {
// 返回文本的虚拟节点(文本没有标签、数据、孩子、key)
return vnode(vm, undefined, undefined, undefined, undefined, text);
}
// 通过函数返回 vnode 对象
// key 标识作用:后边元素根据 key 标识做 diff 算法,取值 data.key;
function vnode(vm, tag, data, children, key, text) {
return {
vm, // 所属实例
tag, // 标签
data, // 数据
children, // 孩子
key, // 标识
text // 文本
}
}
输出最终的vnode
对象:
至此,就完成了根据render
函数生成虚拟节点vnode
;(一个描述 dom 结构的对象)
接下来,再根据“虚拟节点+真实数据”渲染成为真实节点,就完成了全部的初渲染流程;
当数据更新时,通过
render
函数生成新的虚拟节点,与新的真实数据再生成新的真实节点,就实现了更新渲染;
三,结尾
本篇,根据 render 函数,生成 vnode,主要涉及以下几点:
- 封装 vm._render 返回虚拟节点
- _s,_v,_c 的实现
下一篇,根据 vnode 虚拟节点渲染真实节点
更新日志
- 20230128:调整目录结构,优化内容描述、代码注释,添加内容中的代码高亮,添加若干备注;