原生js的痛
假设我们要删除bar节点,无脑的办法就是分别找到bar和父节点。
<div id='foo'>
<span id='bar'>aaa</span>
<span id='baz'>bbb</span>
</div>
var foo = document.getElementById('foo')
var bar = document.getElementById('bar')
foo.removeChild(bar)
但是为了删除一个节点,我们还得先找到节点的爸爸是谁,才能把它干掉。每次删除都要进行“找爸爸”这种重复劳动。
<div>
<span>count: 0</span>
</div>
其次,没有数据绑定,count的值变化了,可能每次都还得手动选中节点更新。
最后往往业务逻辑里,混杂了一大堆DOM API的操作。
为了解决以上痛点,你能想到哪些方案?
比如,原生js的api设计的不太合理,名字又臭又长,经常重复操作DOM的话,把api改漂亮点能省不少事。
嗯jQuery也是这么想的。
但是这没从根本上解决,数据层和视图层的鸿沟,数据层变化了得手动更新视图层的问题。
进一步想,为什么不做一个,语法接近HTML的模板语言以及对应的Compiler呢,发明几个指令(directive)来指示此处该与对应数据绑定?
// 假设有个data object存着count
const data = {
count: 0
}
// 模板
<div>
<span id='count'>count: {{ data.count }}</span>
</div>
将模板输入到Compiler里,当解析到<span>
,Compiler发现有特殊指令{{ }}(双花括号)的存在,就知道这里有需要绑定的数据,立刻监视data.count。
一旦data.count发生了改变,就更新这个<span>
用伪代码近似表示下:
// 第一个参数监视数据,当数据变化,第二个callback就会执行,更新视图层的dom元素
watch(data.count, (el, data)=>{el.innerHTML = data.count})
这样,不管count变化多少次,我们只用关心data.count
本身,而不用再手动写document.getElementById('count')
之类的,直接操作DOM的代码。
好了,你想出来的这个模板 + 指令 + 监视数据, 更新DOM的方案,就很接近Vue1.x的思路了。
另一种脑洞
还有另一种方案,是比较难想到的,所以我比较佩服react作者的脑洞,那就是抽象出个Virtual DOM。
你不是说DOM操作起来繁琐,而且和数据分离开了吗?那你就当它不存在吧。
框架的用户只用和虚拟DOM打交道,剩下的事情交给框架。
具体来说,我们可以用javascript里的Object重新建立一个树形的数据结构,把DOM的信息进行抽象和存储,变成了由一个个Virtual Node组成的Virtual Tree。
一个dom元素无非有以下信息:
// div is a tag
// id is a prop
// span element is a child of div
<div id='foo'>
<span> 0 </span>
</div>
所以设计对应以上结构的vnode的话,我们可以先简单的设计三个属性,type,props,children
const vnode = {
type: 'div',
props: {id: 'foo'},
children: [
type: 'span',
props: null,
children: data.count // count is 0 now
]
}
因为vnode本身就是原生js构造的,所以我们可以在js里写vnode。
然后框架负责把vnode渲染成真正的DOM到页面。
// <div id='foo'>
// <span>data.count</span>
// </div>
render() {
// will return one root vnode
return createVNode('div', {id: 'foo'}, [
createVNode('span', {}, data.count)
]);
}
这样我们就完全不用触碰真实的DOM,不用手动调用DOM API。
完全可以在render function里创建vnode描述视图,然后框架根据render function里的vnode进行最终真实DOM的生成。
当然,为了更新数据时也能自动更新视图,框架需要提供一个特殊的函数,就叫他setState
吧。它特殊在每次被调用,就会通知render()。
// change count to 1
setState({
data.count: 1
});
用户保证更改数据时用setState更改,这样才能确保数据变化后会再次调用render()。
从而根据新的数据重新生成新的vnode,然后比较新旧vnode,更新真实DOM。
这个方案可以说是最早React的思路。真的是脑洞大开,把HTML视图层抽象到了javascript里,用户只用和vnode打交道,剩下的交给react。
融合
Vue1.0那样,在模板里,每个数据都进行一次绑定,细粒度太细了。
如果Web App复杂点,每个数据都进行watch
,可能占用的内存会很大。
另外,将DOM用js object进行一次抽象,生成vnode tree确实好处很大,比如可以跨平台。
业务逻辑上写vnode,在不同平台,只需把框架的渲染器(renderer)稍微订制下,就可以在不同平台渲染了(理想都很丰满~)。
综上,从vue2.x开始,就引入了虚拟dom概念,并且更新也是以组件为单位进行更新。
在组件的render function中定义了这个组件包含的vnode。
下面这段代码其实就比较接近Vue模板被编译后,生成的render函数写法。
// h is createVNode actually
import h from 'balbalabla...';
const simpleComponent = {
data() {
return {
count: 0
}
},
render() {
return h('div', {id: 'foo'}, [
h('span', {}, data.count)
]);
}
}
虽然大部分时候写Vue,都是写模板居多。其实模板里的组件编译后,都会变成这样一个个带render function的组件。
我们今天的目标就是写个hello world,并且渲染到页面上。
第一次渲染
要渲染的例子在playground/main.js
下面,并且我已经把今天完成的代码,作为新的branch(02),上传到了github(github.com/yangjiang39…) 上了:
import { createApp, createVNode as h } from '../packages/runtime-dom/src/index';
const app = createApp({
data() {
return {
title: 'Hello world!',
};
},
render() {
// <div>
// <span>“Hello world!”</span>
// </div>
// equivalence vnode:
return h('div', null, [h('span', null, [this.title])]);
},
});
app.mount('#app');
如果你想跟着文章的思路一起写,把repo的上个branch(我只搭好了环境)clone到本地,然后npm install
一下。
就可以直接在里面写了。写好了npm run dev
可以在浏览器里渲染出Hello world!
开始写前我们要先理下思路,需要实现哪些函数到我们的轮子里。
-
我们创建vnode需要一个createVNode函数(简写为h),用在组件的render function里,来描述这个组件的样子。
-
组件最终返回根vnode,需要从该vnode出发,创建出真实的DOM子树
-
将该生成的DOM tree插入到页面, 也就是
app.mount('#app')
中选中的id为"app"的节点。在这里插入。
我在index.html
里已经预先写好了HTML:
<body>
<div id="app">
/*insert into here*/
</div>
</body>
这是个仿Vue3的轮子,所以能模仿Vue的api我都是尽量起一样的名字,方便读者看Vue3源码的时候熟悉点。
我们先在packages下面创建个runtime-dom文件夹,开始写入口函数createApp
。(以后文件结构不再重复解释,直接看我在github上传的repo)
// packages/runtime-dom/src/index.ts
export const createApp = (rootComponent) => {
const app = {
_component: rootComponent,
};
//* here to add mount method
app.mount = (containerOrSelector) => {
// just make sure the container passed in is valid
const container = normalizeContainer(containerOrSelector);
if (!container) return;
const component = app._component;
// build a virtual node for this component
const vnode = createVNode(component);
render(vnode, container);
};
return app;
};
function normalizeContainer(container) {
if (isString(container)) {
const res = document.querySelector(container);
if (!res && __DEV__) {
console.error('Cannot find the target container');
}
return res;
}
}
目前入口很简单,只要创建个app对象,保存传入的根组件,并且挂上mount方法。
重要的是mount方法,负责生成vnode,渲染到页面。所以需要再分别实现createVNode
,render
这两个方法。
创建VNode
抽象出来的的,通用的vnode相关的代码,放在另外个runtime-core文件夹下。
我们先来写createVNode, 目前阶段逻辑非常简单。
我们只要创建vnode然后返回就行,注意的是,除了type,props和children,我还额外添加了两个属性。
随着后面继续开发vnode的属性会越来越多。
export function createVNode(type, props?, children?) {
const vnode: VNode = {
__v_isVNode: true,
type,
props,
children,
el: null,
};
return vnode;
}
渲染器
注意,我之前提过的render,是组件的里render,用户在这个约定好的地方定义vnode。
而此处我说的render是框架的渲染器的render,负责将用户在组件里声明的vnode渲染到真实DOM里去。
渲染有两种情况:
-
第一次渲染,没有旧的vnode,只需根据新vnode创建DOM(mount)
-
后续数据变化引发的渲染,需要比较新旧vnode来更新DOM(update)
我们目前只关心第一种情况,所以patch的第一个参数为null。
export function render(vnode, container) {
// first time mount, no oldVNode
patch(null, vnode, container);
}
function patch(oldVNode, newVNode, container) {
// mount or update
}
patch怎么设计, 这得看看目前的vnode有哪些可能,分别是:
-
创建app时(
createApp
),把根组件作为对象传入createVNode, 所以vnode里type属性是个组件对象。 -
创建
<div>
或者<span>
, type是String类型。 -
<span>Hello</span>
,Hello是个文字节点,在的vnode里它是children
最好把文字也单独变成一个vnode,type是Text,这样以后patch或者update更方便,都是vnode。
function patch(oldVNode, newVNode, container) {
const { type } = newVNode;
if (isObject(type)) {
processComponent(oldVNode, newVNode, container);
} else if (isString(type)) {
processElement(oldVNode, newVNode, container);
} else if (type === Text) {
processText(oldVNode, newVNode, container);
}
}
接下来我们开始处理今天最麻烦的根组件:processComponent
。
目前只考虑第一次插入的情况,更新暂时不考虑,写个TODO flag占位。
// n1=oldVnode, n2=newVNode
function processComponent(n1, n2, container) {
if (!n1) mountComponent(n2, container);
// else {
// TODO:
// updateComponent(n1, n2);
// }
}
在mountComponent主要需要做三件事情,
- 我们需要根据传入的组件vnode,来生成组件的实例。
在写Vue时,我们经常用到this.keyName
来取data或者props,this
就是指向这个实例。
-
setup这个实例,比如把data挂到实例上。
-
最后调用组件里的render方法,生成VNode subtree。
function mountComponent(compVNode, container) {
// init component instance
const instance = {
type: compVNode.type,
vnode: compVNode,
data: {},
proxy: {},
};
compVNode.component = instance;
// setup component, such as props
setupComponent(instance);
// generate component's root vnode tree, then patch again
setupRenderEffect(instance, compVNode, container);
}
如果你以前有使用Vue的经验,你肯定是直接通过this.count
或者this.title
来直接获取数据,
而不是this.data.title
。
这是因为数据直接被挂在了instance上,方便使用。
这里就用Proxy来把数据代理到instance上。
暂时不了解Proxy不要紧,可以去MDN上看文档,也可以等下一篇重点用到Proxy的时候我再介绍。
function setupComponent(instance) {
instance.proxy = new Proxy(instance, PublicInstanceProxyHandlers);
const Component = instance.type;
instance.render = Component.render || (() => {});
if (isFunction(Component.data)) {
const dataFn = Component.data;
const data = dataFn.call(instance.proxy);
instance.data = data;
}
}
// simple proxy handler to access data on instance directly
const PublicInstanceProxyHandlers = {
get: function (target, key) {
if (hasOwn(target.data, key))
return Reflect.get(target.data, key);
},
};
mount Component的最后一步,就是调用组件的render,生成vnode。
function setupRenderEffect(instance, initialVNode, container) {
const { proxy, render } = instance;
const subTree = render.call(proxy);
patch(null, subTree, container);
}
这里的subTree就是组件里,我们定义的根vnode。在要跑通的例子里,此处是包裹着的根节点
此时再次调用patch,对组件的vnode tree进行mount。
处理一般DOM Element
这种vnode属于我们在patch中设计的第二种情况。忘记了的回去重看patch。
处理一般的节点使用processElement, 同样只是mount,不管更新
function processElement(n1, n2, container) {
if (!n1) mountElement(n2, container);
// TODO:
// else patchElement(n1,n2,container)
}
这里很清楚的表明,从虚拟DOM到真实DOM的创建,都是由框架干的活。
框架负责进行DOM API的调用来生成真实页面。
function mountElement(vnode, container) {
const el = (vnode.el = document.createElement(vnode.type));
if (vnode.children) {
mountChildren(vnode.children, el);
}
container.appendChild(el);
}
一般的element如果有children,就递归式的再次调用patch,处理child。
这里有个特例,就是child是字符串。
比如<span>Hello world</span>
中,“Hello world”会作为字符串类型的child。
我们需要把它重新生成一个type为Text类型的vnode,传入patch再进行mount。
function mountChildren(children, container) {
for (let i = 0; i < children.length; i++) {
let child = children[i];
// TODO: should normalize all possible child types
if (isString(child)) {
child = createVNode(Text, null, child);
}
patch(null, child, container);
}
}
此时进入patch函数里的第三种情况,处理文字节点。
function processText(n1, n2, container) {
if (!n1) {
n2.el = document.createTextNode(n2.children);
container.append(n2.el);
}
// TODO:
// else
}
以上就是第一次渲染的整个流程。
期间用到了一些helper function, 比如 isString, isObject, hasOwn, isFunction
。
我就不在主线内容里提这些函数是干嘛的了,看名字就知道。
这些常用的helper function放在packages/shared/src/index.ts
里,不想自己写的,可以去github上看。
下一篇的任务是,让渲染出的内容可以变换,也就是自动把数据的变换更新到视图。
债见~
【首发在公众号:奔三程序员Club】
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!