最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 21天学会写个仿Vue3的轮子:(二)第一次渲染虚拟树

    正文概述 掘金(yangjiang3973)   2021-03-29   571

    原生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!

    开始写前我们要先理下思路,需要实现哪些函数到我们的轮子里。

    1. 我们创建vnode需要一个createVNode函数(简写为h),用在组件的render function里,来描述这个组件的样子。

    2. 组件最终返回根vnode,需要从该vnode出发,创建出真实的DOM子树

    3. 将该生成的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,渲染到页面。所以需要再分别实现createVNoderender这两个方法。

    创建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里去。

    渲染有两种情况:

    1. 第一次渲染,没有旧的vnode,只需根据新vnode创建DOM(mount)

    2. 后续数据变化引发的渲染,需要比较新旧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有哪些可能,分别是:

    1. 创建app时(createApp),把根组件作为对象传入createVNode, 所以vnode里type属性是个组件对象。

    2. 创建<div>或者<span>, type是String类型。

    3. <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主要需要做三件事情,

    1. 我们需要根据传入的组件vnode,来生成组件的实例。

    在写Vue时,我们经常用到this.keyName来取data或者props,this就是指向这个实例。

    1. setup这个实例,比如把data挂到实例上。

    2. 最后调用组件里的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】


    起源地下载网 » 21天学会写个仿Vue3的轮子:(二)第一次渲染虚拟树

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元