Vue

前言

24年的年度目标其中就有一个是进入开源社区,而万事开头难,一开始选了VueCore和ElementUI作为我的开始,也向VueCore交了一个PR,但是没发现这个issue上一周就被修复了,所以就没了下文。

但是ElementUI的issue挺有意思的,下面我们来细说下:

问题

issue长这样:牙齿尖尖的

这边也贴一个SFC的链接吧:SFC

刚看到这个issue还挺有意思的:这个bug是在菜单组件过多时,elementUI会将过长的menu进行收纳进一个展开栏中:

elementUI

也就是上图中的省略号那个区域

当我们hover或者click那个区域时,会弹出一个展开栏,里面包含了所有的菜单

但是这个issue的问题是,当我们点击展开栏,当菜单栏的长度过多,会导致展开栏的长度过长,页面会往下拓展,导致页面的滚动条出现,后面页面的视宽内容会被挤压。

挤压不要紧,这个应该是符合预期的,因为当菜单栏过多时,我们的展开栏的长度也会变长,他就需要有地方进行拓展。

但是问题是,这个组件有一个节流函数:

const getIndexPath = (index: string) => subMenus.value[index].indexPath

// Common computer monitor FPS is 60Hz, which means 60 redraws per second. Calculation formula: 1000ms/60 ≈ 16.67ms, In order to avoid certain chance of repeated triggering when `resize`, set wait to 16.67 * 2 = 33.34
const debounce = (fn: () => void, wait = 33.34) => {
   let timmer: ReturnType<typeof setTimeout> | null
  return () => {
    timmer && clearTimeout(timmer)
    timmer = setTimeout(() => {
      fn()
    }, wait)
  }
}

let isFirstTimeRender = true
const handleResize = () => {
  if (sliceIndex.value === calcSliceIndex()) return
   const callback = () => {
    sliceIndex.value = -1
    nextTick(() => {
       sliceIndex.value = calcSliceIndex()
     })
   }    // execute callback directly when first time resize to avoid shaking
   isFirstTimeRender ? callback() : debounce(callback)()
   isFirstTimeRender = false
 }

他是这样的:当我们页面的视宽发生变化时,会触发resize事件,然后会执行handleResize函数,这个函数会计算当前的菜单栏的长度,然后会执行一个节流函数,这个节流函数会在一定的时间内,只执行一次回调函数,这个回调函数就是计算当前的菜单栏的长度,然后将这个长度赋值给sliceIndex,然后页面就会根据这个长度来进行展开栏的展开。

搞笑的就来了:刚刚我们触发的滚动条,使得这个节流函数的触发,页面就开始抽搐了 ,因为这个节流函数的节流时间是33.34ms,而我们的滚动条的触发频率是16.67ms,所以这个节流函数就会被频繁的触发,导致页面的抽搐。

解决

那么怎么solve这个问题呢:

我一开始想到的是改这个scss的样式,但是这个样式是根据菜单栏的长度来进行展开的,所以改样式是不行的。

后面我发现这个节流函数的触发应该是来解决拖动视窗时组件的销毁和重建,那么我们有必要这么频繁的触发这个节流函数吗?我们是否可以将当他改变组件数量的时候再进行触发呢?

所以在原来的代码中加了一行:

if (sliceIndex.value === calcSliceIndex()) return

这个问题整体来说还算简单,但是我觉得这个问题还是挺有意思的,所以就记录下来了。

提了这个PR也让我复习了一下git的操作,以及体验到了开源项目的PR流程和规范,还是挺有意思的。

下一个阶段的小目标:向Vue和elementUI提PR,看能不能有机会进core team!

前言

做项目需要用到富文本编辑器,但是看了很多的富文本编辑器都很少支持公式编辑,但项目有要用到,怎么办呢?先写一个显然并不现实。于是使用easy-formula-editorwangeditor写了一个功能小插件,是 基于Vue3和MathJax渲染的Latex富文本公式编辑器,支持零基础即可编辑公式,支持自定义编辑器配置和风格,支持二次编辑公式,支持作为插件和富文本编辑器一起使用。

  • 零基础即可编辑公式
  • 支持自定义编辑器配置和风格
  • 支持二次编辑公式
  • 支持作为插件和富文本编辑器一起使用

MathJax

安装和使用

NPM

npm i easy-formula-editor

import formulaEditor from "easy-formula-editor";
const editor = new formulaEditor();
editor.create('#test');

CDN

<script type="text/javascript" src="../dist/formula-editor.min.js"></script>
<script type="text/javascript">
  const editor = new formulaEditor();
  editor.create('#test');
</script>

导出

// latex 公式
editor.latex.text()

// html 公式
editor.$textSvgElem.html()

富文本编辑器菜单栏扩展

注册菜单

【注意】 推荐使用全局模式来注册菜单。 如果有多个编辑器,每个编辑器的自定义菜单不一样,则使用实例的方式来注册菜单

全局模式

// 菜单 key ,各个菜单不能重复
const menuKey = 'alertMenuKey' 

// 注册菜单
E.registerMenu(menuKey, AlertMenu)

const editor = new E('#div1')
editor.create()

const editor2 = new E('#div2')
editor2.create()

实例模式

// 菜单 key ,各个菜单不能重复
const menuKey = 'alertMenuKey' 
const menuKey2 = 'alertMenuKey2'

const editor = new E('#div1')
// 注册菜单
editor.menus.extend(menuKey, AlertMenu)

// 将菜单加入到 editor.config.menus 中    const menuKey = 'alertMenuKey' 
// 也可以通过配置 menus 调整菜单的顺序,参考【配置菜单】部分的文档    editor.config.menus.push(menuKey)
editor.config.menus = editor.config.menus.concat(menuKey)

// 注册完菜单,再创建编辑器,顺序很重要!!
editor.create()

const editor2 = new E('#div2')
editor2.menus.extend(menuKey2, AlertMenu)
editor2.config.menus.push(menuKey2)
editor2.create()

实际项目结合示例

import E from "wangeditor";
import formulaEditor from "easy-formula-editor";
import createPanelConf from "./create-panel-conf";

const { PanelMenu, Panel } = E;

class AlertMenu extends PanelMenu {
  constructor(editor) {
    // data-title属性表示当鼠标悬停在该按钮上时提示该按钮的功能简述
    const $elem = E.$(
      `<div class="w-e-menu" data-title="数学公式">
        <span>公式</span>
      </div>`
    );
    super($elem, editor);
  }

  /**
   * 菜单点击事件
   */
  clickHandler() {
    const formula = new formulaEditor();
    const conf = createPanelConf(this.editor, formula);
    const panel = new Panel(this, conf);
    panel.create();

    formula.create("#edit-content");
  }

  tryChangeActive() {}
}

const menuKey = "alertMenuKey";

// 注册菜单
E.registerMenu(menuKey, AlertMenu);

export default E;
//create-panel-conf.ts
export default function (wangEditor, formulaEditor) {
    const btnOkId = 'btn-ok'
  
    /**
     * 插入公式
     */
    function insertFomule() {
      const formula = formulaEditor.latex.text()
      // 注意插入wangEditor时左右两边的空格不能去掉,不然会导致无法获取焦点
      wangEditor.txt.append('<p>'+formula+'</p>')
      return true
    }
  
    // tabs配置
    const tabsConf = [
      {
        // tab 的标题
        title: "插入数学公式",
        // 模板
        tpl: `<div>
                <div id="edit-content"></div>
                <div class="w-e-button-container">
                  <button type="button" id="${btnOkId}" class="right">插入</button>
                </div>
              </div>`,
        // 事件绑定
        events: [
          // 插入视频
          {
            selector: '#' + btnOkId,
            type: 'click',
            fn: insertFomule,
            bindEnter: true,
          },
        ],
      }, // tab end
    ]
  
    return {
        width: 660,
        height: 0,
        // panel 中可包含多个 tab
        tabs: tabsConf, // tabs end
      }
}

使用上面的代码,就可以在富文本编辑器中添加一个公式编辑器的菜单了:

<template>
  <div class="formula-container">
    <v-card elevation="0" class="formula-card" title="输出区域" subtitle="Output">
      <div id="formula" class="formula-content">
        {{ renderedFormula ? `$${renderedFormula}$` : '' }}
      </div>
    </v-card>
    <div class="editor-area">
      <div id="wang-editor" class="editor"></div>
    </div>
  </div>
</template>


<script setup>
import E from "../utils/formula-menu-conf";
import { ref, onMounted, nextTick, defineProps, watchEffect } from "vue";

// 定义props
const props = defineProps({
  initMessage: {
    type: String,
    default: "",
  }
});

watchEffect(() => {
  props.initMessage;
});

const editor = ref(null);
const renderedFormula = ref("");

function convert() {
  MathJax.texReset();
  MathJax.typesetClear();
  MathJax.typesetPromise();
}

function updateFormula() {
  renderedFormula.value = editor.value.txt.text();
  nextTick(convert);
}

onMounted(() => {
  editor.value = new E("#wang-editor");
  editor.value.config.height = 360;
  editor.value.config.menus = ['head', 'bold', 'underline', 'strikeThrough','emoticon', 'undo', 'redo'];
  editor.value.config.onchange = updateFormula;
  editor.value.config.onchangeTimeout = 500;
  editor.value.create();
  editor.value.txt.html(props.initMessage);
});

</script>

可以看到,我们的公式编辑器和富文本编辑器完美结合了,效果如下:

MathJax

贴一个wangeditor的官方文档:www.wangeditor.com/v4

前言

想把Vuejs 组件事件与 emit 的实现讲清楚,我先要把组件事件和emit的应用讲清楚,这样大家会更清楚的理解组件事件和emit的实现。

对于组件事件,我们其实之前就已经提到过了浏览器的事件机制:

浏览器事件回顾

浏览器事件是指在 Web 页面中发生的各种交互和状态变化的信号。这些事件可以由用户的操作、浏览器的状态变化或其他一些因素触发。以下是一些常见的 DOM 事件和它们的简介:

鼠标事件:

  • click: 当鼠标点击一个元素时触发。对于触摸屏设备,也会在触摸屏上进行点击时触发。
  • contextmenu: 当鼠标右键点击一个元素时触发。
  • mouseover / mouseout: 当鼠标指针移入或离开一个元素时触发。
  • mousedown / mouseup: 当在元素上按下或释放鼠标按钮时触发。
  • mousemove: 当鼠标移动时触发。

键盘事件:

  • keydown 和 keyup: 当按下和松开一个按键时触发。

表单(form)元素事件:

  • submit: 当访问者提交了一个 <form> 时触发。
  • focus: 当访问者聚焦于一个元素时触发,例如聚焦于一个 <input>

Document 事件:

  • DOMContentLoaded: 当 HTML 的加载和处理均完成,DOM 被完全构建完成时触发。

CSS 事件:

  • transitionend: 当一个 CSS 动画完成时触发。

这些事件可以通过 JavaScript 中的事件监听器来捕获和处理。例如,通过给元素添加事件监听器,可以在特定事件发生时执行相应的代码。事件是 Web 开发中交互和响应的基础。

那我们所说的组件事件和浏览器事件有什么区别呢?

对于事件这一个定义,我们可以这样理解:事件是指在特定的时间点上发生的事情,在某个时间点产生了一个信号,而信号可以被接收处理,这就是事件。

下面我们所说的组件事件,其实相对于浏览器事件来说更为宏观,它是指在Vue组件中,组件之间的通信方式,而这种通信方式是通过事件来实现的,这里的事件就是我们所说的组件事件。

Vue 组件之间通过事件进行通信的主要机制是通过 emit 和 on 方法。当子组件需要向父组件通信时,它会触发一个事件,并通过 emit 方法将事件发送出去;而父组件则通过在模板中使用 v-on 指令监听这个事件,从而执行相应的逻辑。

<!-- 子组件 ChildComponent.vue -->
<template>
  <button @click="sendMessage">点击通知父组件</button>
</template>

<script>
export default {
  methods: {
    sendMessage() {
      // 使用 emit 发送自定义事件
      this.$emit('child-event', 'Hello from child!');
    }
  }
}
</script>
<!-- 父组件 ParentComponent.vue -->
<template>
  <div>
    <child-component @child-event="handleChildEvent"></child-component>
    <p>从子组件接收的消息: {{ messageFromChild }}</p>
  </div>
</template>

<script>
import ChildComponent from './ChildComponent.vue';

export default {
  components: {
    ChildComponent
  },
  data() {
    return {
      messageFromChild: ''
    };
  },
  methods: {
    handleChildEvent(message) {
      // 使用 v-on 监听子组件的事件
      this.messageFromChild = message;
    }
  }
}
</script>

Vue 的事件机制是通过发布/订阅模式实现的。$emit 用于发布(触发)事件,而 v-on 则用于订阅(监听)事件。这使得不同组件之间可以相互通信,实现了组件之间的解耦。

首先,我们会注意到,子组件通过 this.$emit(‘child-event’, ‘Hello from child!’) 发送了一个自定义事件 child-event,而父组件则通过 @child-event=”handleChildEvent” 监听了这个事件。 这里的 child-event 就是我们自定义的事件名,而 handleChildEvent 则是父组件中定义的一个方法,用于处理子组件发送过来的消息。

当使用一个自定义的MyComponent组件时,我们可以监听由 emit 函数发射的自定义事件

可以看到,自定义事件 change 被编译成名为 onChange 的属性,并存储在 props 数据对象中。这实际上是一种约定。作为框架设计者,也可以按照自己期望的方式来设计事件的编译结果。在具体的实现上,发射自定义事件的本质就是根据事件名称去props 数据对象中寻找对应的事件处理函数并执行,如下面的代码所示:

emit的实现

// MyComponent 组件定义
const MyComponent = {
  name: 'MyComponent',
  setup(props, { emit }) {
    // 发射 change 事件,并传递给事件处理函数两个参数
    emit('change', 1, 2);

    return () => {
      // 组件的渲染逻辑
      return /* ... */;
    };
  }
};

上面这一段代码会被编译成一个节点 CompNode,它的类型为 MyComponent 组件,同时会将 emit 函数传递给组件的 setup 函数,如下所示:

// 定义一个 Vue 组件节点 CompNode
const CompNode = {
  // 指定节点的类型为 MyComponent 组件
  type: MyComponent,

  // 为组件传递的 props 对象
  props: {
    // 在 MyComponent 组件中,会有一个 onChange 的事件处理函数,其值为 handler
    onChange: handler
  }
};

可以看到,自定义事件 change 被编译成名为 onChange 的属性,并存储在 props 数据对象中。这实际上是一种约定。作为框架设计者,也可以按照自己期望的方式来设计事件的编译结果。

在具体的实现上,发射自定义事件的本质就是根据事件名称去props 数据对象中寻找对应的事件处理函数并执行,如下面的代码所示:

// 挂载组件
function mountComponent(vnode, container, anchor) {
  // 省略部分代码

  const instance = {
    state, // 状态
    props: shallowReactive(props), // 响应式处理 props
    isMounted: false,
    subTree: null
  };

  // 定义 emit 函数,它接收两个参数
  // event: 事件名称
  // payload: 传递给事件处理函数的参数
  function emit(event, ...payload) {
    // 根据约定对事件名称进行处理,例如 change --> onChange
    const eventName = `on${event[0].toUpperCase()}${event.slice(1)}`;

    // 根据处理后的事件名称去 props 中寻找对应的事件处理函数
    const handler = instance.props[eventName];

    if (handler) {
      // 调用事件处理函数并传递参数
      handler(...payload);
    } else {
      console.error('事件不存在');
    }
  }

  // 将 emit 函数添加到 setupContext 中
  const setupContext = { attrs, emit };

  // 省略部分代码
}

// 解析 props 数据的函数,对事件类型的 props 做特殊处理
function resolveProps(options, propsData) {
  const props = {};
  const attrs = {};

  for (const key in propsData) {
    // 以字符串 'on' 开头的 props 添加到 props 数据中,否则添加到 attrs 中
    if (key in options || key.startsWith('on')) {
      props[key] = propsData[key];
    } else {
      attrs[key] = propsData[key];
    }
  }

  return [props, attrs];
}

emit 的实现原理主要涉及两个方面:setupContext 的注入和事件名称的约定转换。

  1. setupContext 的注入: 在 Vue 的组件中,setup 函数接收两个参数:propscontextcontext 中包含了一系列属性和方法,其中之一就是 emit 函数。Vue 3 的组件中,setup 函数返回的对象中,可以将 emit 函数添加到 setupContext 中,以便用户在组件内通过 setupContext.emit 访问。

    下面是一个简单的例子,展示了如何在 setup 函数中添加 emitsetupContext

    setup(props, context) {
      // 添加 emit 到 setupContext
      context.emit = emit;
    
      // 其他 setup 逻辑
      // ...
    
      // 返回 setup 返回的对象
      return {};
    }

    这样,在组件内部,用户就可以通过 setupContext.emit 来调用 emit 函数了。

  2. 事件名称的约定转换:emit 函数内部,为了匹配组件模板中的事件处理函数,需要对事件名称进行约定转换。Vue 使用了一种约定,将事件名称转换为驼峰命名的形式。例如,change 事件会转换为 onChange。这样,用户在组件模板中监听事件时,可以使用驼峰命名的方式。

    下面是一个简单的例子,展示了事件名称的约定转换:

    function emit(event, ...payload) {
      // 根据约定对事件名称进行处理,例如 change --> onChange
      const eventName = `on${event[0].toUpperCase()}${event.slice(1)}`;
    
      // 根据处理后的事件名称去 props 中寻找对应的事件处理函数
      const handler = instance.props[eventName];
    
      if (handler) {
        // 调用事件处理函数并传递参数
        handler(...payload);
      } else {
        console.error('事件不存在');
      }
    }

    这里的 emit 函数会将事件名称转换为以 “on” 开头并使用驼峰命名的形式,例如 change 转换为 onChange。然后,它会在 instance.props 中查找对应的事件处理函数并执行。

需要注意的是,事件类型的 props 在 instance.props 中是找不到的,因此它们会存储在 attrs 中。为了解决这个问题,在解析 props 数据时,需要对事件类型的 props 做特殊处理,确保它们被正确添加到 props 中,而不是 attrs。这样,emit 函数就能够在 instance.props 中正确地找到对应的事件处理函数。

前言

我们在前面讨论过了vue渲染器渲染器主要负责将虚拟 DOM 渲染为真实 DOM ,我们只需要使用虚拟 DOM来描述最终呈现的内容即可。

但当我们编写比较复杂的页面时,用来描述页面结构的虚拟 DOM 的代码量会变得越来越多,或者说页面模板会变得越来越大。这时,我们就需要组件化的能力。

有了组件,我们就可以将一个大的页面拆分为多个部分,每一个部分都可以作为单独的组件,这些组件共同组成完整的页面。组件化的实现同样需要渲染器的支持

渲染组件

从用户的角度来看,一个有状态组件就是一个选项对象,如下面的代码所示:

// MyComponent 是一个组件,它的值是一个选项对象
const MyComponent = {
name: 'MyComponent',
  data(){ 
  return  {foo: 1 }
  }
}

但是,如果从渲染器的内部实现来看,一个组件则是一个特殊类型的虚拟 DOM 节点。例如,为了描述普通标签,我们用虚拟节点的vnode.type 属性来存储标签名称,如下面的代码所示:

 // 该 vnode 用来描述普通标签
const vnode = {
type: 'div'
// ...
}

为了描述片段,我们让虚拟节点的 vnode.type 属性的值为Fragment, 而为了描述文本,我们让虚拟节点的 vnode.type 属性的值为Text,

还记得我们之前在渲染器讨论过的patch函数吗?

在patch函数中,Vue会根据新旧虚拟DOM节点的差异进行不同的处理逻辑。 如果是首次渲染,会执行首次渲染的逻辑;如果是更新渲染,会根据差异更新实际的DOM节点。

function patch(n1, n2, container, anchor) {
  // 02. 如果新旧节点类型不同,执行卸载操作
  if (n1 && n1.type !== n2.type) {
    unmount(n1);
    n1 = null;
  }

  // 07. 获取节点类型
  const { type } = n2;

  // 09. 判断节点类型
  if (typeof type === 'string') {
    // 10. 处理普通元素
    // TODO: 处理普通元素的逻辑
  } else if (type === Text) {
    // 11. 处理文本节点
    // TODO: 处理文本节点的逻辑
  } else if (type === Fragment) {
    // 13. 处理片段
    // TODO: 处理片段的逻辑
  }

  // 返回新节点的实际DOM元素
  return n2.el;
}

可以看到,渲染器会使用虚拟节点的 type 属性来区分其类型。对于不同类型的节点,需要采用不同的处理方法来完成挂载和更新。

实际上,对于组件来说也是一样的。为了使用虚拟节点来描述组件,我们可以用虚拟节点的 vnode.type 属性来存储组件的选项对象

function patch(n1, n2, container, anchor) {
  // 02. 如果新旧节点类型不同,执行卸载操作
  if (n1 && n1.type !== n2.type) {
    unmount(n1);
    n1 = null;
  }

  // 07. 获取节点类型
  const type = n2.type;

  // 09. 判断节点类型
  if (typeof type === 'string') {
    // 10. 处理普通元素
    // TODO: 处理普通元素的逻辑
  } else if (type === Text) {
    // 11. 处理文本节点
    // TODO: 处理文本节点的逻辑
  } else if (type === Fragment) {
    // 13. 处理片段
    // TODO: 处理片段的逻辑
  } else if (typeof type === 'object') {
    // 16. vnode.type 的值是选项对象,作为组件来处理
    if (!n1) {
      // 17. 挂载组件
      mountComponent(n2, container, anchor);
    } else {
      // 21. 更新组件
      patchComponent(n1, n2, anchor);
    }
  }

  // 返回新节点的实际DOM元素
  return n2.el;
}

在上面这段代码中,我们新增了一个 else if 分支,用来处理虚拟节点的 vnode.type 属性值为对象的情况,即将该虚拟节点作为组件的描述来看待,并调用 mountComponent 和 patchComponent 函数来完成组件的挂载和更新。

上面我们回顾了有关于组件的一些基本概念,组件的挂载和更新是如何实现的,其实我们更关注的还应该是用户应该如何编写组件?组件的选项对象必须包含哪些内容?以及组件拥有哪些能力?

因此,一个组件必须包含一个渲染函数,即 render 函数,并且渲染函数的返回值应该是虚拟 DOM。换句话说,组件的渲染函数就是用来描述组件所渲染内容的接口,如下面的代码所示:

// MyComponent 组件定义
const MyComponent = {
  // 组件名称,可选
  name: 'MyComponent',

  // 组件的渲染函数,其返回值必须为虚拟 DOM,一个组件必须包含一个渲染函数,即 render 函数,并且渲染函数的返回值应该是虚拟 DOM
  render() {
    // 返回虚拟 DOM
    return {
      type: 'div',
      children: ['text content']
    };
  }
};

// 用来描述组件的 vnode 对象,type 属性值为组件的选项对象
const CompNode = {
  type: MyComponent
};

// 调用渲染器来渲染组件
renderer.render(CompNode, document.querySelector('#app'));

// 渲染器中真正完成组件渲染任务的是 mountComponent 函数
function mountComponent(vnode, container, anchor) {
  // 通过 vnode 获取组件的选项对象,即 vnode.type
  const componentOptions = vnode.type;

  // 获取组件的渲染函数 render
  const render = componentOptions.render;

  // 执行渲染函数,获取组件要渲染的内容,即 render 函数返回的虚拟 DOM
  const subTree = render();

  // 最后调用 patch 函数来挂载组件所描述的内容,即 subTree
  patch(null, subTree, container, anchor);
}

总结

我们现在来回顾一下组件的基础,我们显式声明了一个组件的对象实例,该组件的选项对象必须包含一个渲染函数,即 render 函数,并且渲染函数的返回值应该是虚拟 DOM,之后我们再调用render和patch进行组件的更新和替换。换句话说,组件的渲染函数就是用来描述组件所渲染内容的接口,而渲染器的作用就是将组件的渲染函数的返回值渲染为真实 DOM。

前言

2023 年 12 ⽉ 31 ⽇后,功能仍然可⽤,但不再提供更新,包括
• 安全更新
• 浏览器兼容

Evan 宣布 Vue 3 的第一个 RC将于 7 月中旬发布。这篇文章建议库/插件作者开始迁移对 Vue 3 的支持。但是由于 API 和行为发生了很大变化,是否有可能使我们的库同时支持 Vue 2 和 3 ?

通用代码

最简单的方法是编写适用于两个版本的通用代码 ,无需任何额外的修改,就像人们对Python 2 和 3所做的那样。简单并不意味着容易。编写此类组件需要避免Vue 3 中新引入的内容 以及Vue 2 中弃用的内容 。换句话说,您不能使用:

  • 合成API
  • .sync .native修饰语
  • 过滤器
  • 3rd 方供应商对象

使用分支

核心团队成员对此问题的回复建议使用不同的分支来分隔对每个定位版本的支持。我认为这对于现有和成熟的库来说是一个很好的解决方案,因为它们的代码库通常更稳定,并且版本目标优化可能需要它们具有更好的代码隔离。

这样做的缺点是您需要维护两个代码库,这会使您的工作量增加一倍。对于小型库或想要支持两个版本的新库来说,进行两次错误修复或功能补充是不理想的。我不建议在项目一开始就使用这种方法。

构建脚本

在VueUse中,编写了一些构建脚本,以便在构建时从目标版本的 API 导入代码。之后,我需要发布两个标签vue2 vue3来区分不同版本的支持。有了这个,我可以编写一次代码并使库支持两个 Vue 版本。它的问题是我需要在每个版本上构建两次并引导用户安装相应的插件版本(@vue/composition-api对于Vue 2则需要手动安装)。

Vue 2/3 共存开发的思路

同时⽀持 Vue 2/3 项⽬

Vue 2/3 项⽬存在的可能场景

渐进式迁移: 如果有一个较大的 Vue 2 项目,但是想要逐步迁移到 Vue 3,可以选择在项目中同时引入 Vue 3,然后逐步将 Vue 2 组件迁移到 Vue 3。

依赖库和插件兼容性: 如果项目依赖于一些 Vue 2 的插件或库,而这些插件或库还没有升级到 Vue 3,可能需要同时使用 Vue 2 和 Vue 3 以确保兼容性。

新功能采用 Vue 3: 可能希望项目中使用 Vue 3 来利用其新功能和性能优势,同时保留 Vue 2 用于旧的组件或功能。

项⽬融合者: 公司内部基于体验要求,需要 Vue 2/3项⽬呈现在同⼀⻚⾯中

内部组件资产维护者: 需要在 Vue 2/3 的项⽬都⽀持,且能⼒必须⼀致

⽼项⽬应⽤开发者: 需要⽤到⼀个第三⽅图表组件,但只有 Vue 3 版本,⽽⾮ Vue 2 版本

解决⽅案

1. Vue 2/3 项⽬共存

vue-5

直接通过 Vue 3 的 createApp 创建⼀个 Vue 3 的根实例,然后通过 Vue 2 的 mount ⽅法挂载到 Vue 2 的根实例上,这样就可以在 Vue 2 的项⽬中使⽤ Vue 3 的组件。

相关的代码仓库贴在这里,大家自取:vue5

vue-5

// Vue 3 项⽬
import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#vue3')
// Vue 2 项⽬
import Vue from 'vue2'
import App from './App.vue'

new Vue({
  render: h => h(App as any),
}).$mount('#vue2')

这一个思路重要的是我们采用配置vite.config.ts解决不同模块的编译问题:编写了一些构建脚本,以便在构建时从目标版本的 API 导入代码。之后,我需要发布两个标签vue2 vue3来区分不同版本的支持。但是它的问题其实是需要在每个版本上引导用户安装相应的插件版本。这对于开发者处理包冲突问题并不是很友好。

import path from 'path'
import { defineConfig } from 'vite'
import Vue2 from '@vitejs/plugin-vue2'
import Vue3 from '@vitejs/plugin-vue'
import Inspect from 'vite-plugin-inspect'
import compiler from 'vue2/compiler-sfc'

const src = path.resolve(__dirname, 'src')

export default defineConfig({
  plugins: [
    Vue3({
      include: [/vue3[/\\].*\.vue$/],
    }),
    Vue2({
      include: [/vue2[/\\].*\.vue$/],
      compiler: compiler as any,
    }),
    Inspect(),
  ],
})

这样我们属于是将Vue2和Vue3单独做成了两个独立的包,然后在vite.config.ts中配置了不同的编译规则,这样就可以在同一个页面中使用Vue2和Vue3。

2. JessicaSachs/petite 方案

先来简单介绍一下petite

Petite是一个为Vue组件作者构建的主观GitHub模板。 它设置了开发、文档和测试通用SFC组件所需的工具,并与Vue 2.7运行时向后兼容。

这是通过一些运行时辅助函数和一个非常主观的单体库结构实现的。

Petite设置了Vite、Volar、Linting、Vitepress、TypeScript和Testing,这样您就可以选择编写Vue 3风格的代码,同时轻松保持对Vue 2.x用户的向后兼容性。

而这也意味着您将在 npm 上发布软件包的两个版本,而不是为了支持 Vue 2 或 Vue 3 而中断主要版本。

这样做的缺点是您的用户在升级和更改导入时需要安装新版本。 好处是您可以更轻松地编写向后兼容的代码并为用户提供定期升级。此外,您还可以拆分仅 Vue 2 和仅 Vue 3 的依赖项。

如果您在通用代码中使用lodash,您将需要在工作区根目录中运行后pnpm build,每个包 ( lib-vue3、lib-vue2) 应独立部署。

3. vue-bridge 方案

4. vue-demi 方案

仓库实例:vue-demi

Vue Demi是一个开发实用程序,允许您为 Vue 2 和 3 编写通用 Vue 库。无需担心用户安装的版本。

当您要创建 Vue 插件/库时,只需安装vue-demi为依赖项并从中导入与 Vue 相关的任何内容即可。像往常一样发布你的插件/库,你的包将变得通用!

{
  "dependencies": {
    "vue-demi": "latest"
  }
}
import Vue, { reactive, ref } from 'vue-demi'

在底层,它使用了postinstallnpm hook。安装所有包后,脚本将开始检查已安装的 Vue 版本,并将导出重定向到基于本地 Vue 版本。使用 Vue 2 时,@vue/composition-api如果未安装,它也会自动安装。

所需要注意的有关于库/组件的点:

库/组件

  • 单仓库 - 多个包构建
  • 依赖管理
  • alias 别名配置
    • npm 包名
    • 构建工具配置

Vue 2 应⽤中引⼊ Vue 3 组件

会有组件互操作的限制

  • context 共享
  • scoped slots
  • 事件

Vue 2 应⽤中引⼊ Vue 3 组件的思路

  • Vue 3 可以有多个全局实例
  • 前提:Vue 2 升级到 2.7、Vue CLI 移除部分过时插件
  • 互操作层:Custom Elements
  • 构建⼯具:Vite

前言

渲染器的核心就是 Diff 算法。简单来说,当新旧 vnode 的子节点都是一组节点时,为了以最小的性能开销完成更新操作,需要比较两组子节点,用于比较的算法就叫作 Diff 算法。我们知道,操作 DOM 的性能开销通常比较大,而渲染器的核心 Diff 算法就是为了解决这个问题而诞生的。

减少 DOM 操作的性能开销

核心 Diff 只关心新旧虚拟节点都存在一组子节点的情况。针对两组子节点的更新,我们之前采用了一种简单直接的手段,即卸载全部旧子节点,再挂载全部新子节点。这么做的确可以完成更新,但由于没有复用任何 DOM 元素,所以会产生极大的性能开销。

// 旧 vnode
const oldNode = {
  type: 'div',
  children: [
    { type: 'p', children: '1' },
    { type: 'p', children: '2' },
    { type: 'p', children: '3' }
  ]
}

// 新 vnode
const newNode = {
  type: 'div',
  children: [
    { type: 'p', children: '4' },
    { type: 'p', children: '5' },
    { type: 'p', children: '6' }
  ]
}

按照之前的做法,当更新子节点时,我们需要执行 6 次 DOM 操作:

  • 卸载所有旧子节点,需要 3 次 DOM 删除操作;
  • 挂载所有新子节点,需要 3 次 DOM 添加操作。

但是,通过观察上面新旧 vnode 的子节点,可以发现:更新前后的所有子节点都是 p 标签,即标签元素不变;只有 p 标签的子节点(文本节点)会发生变化。

例如,oldVNode 的第一个子节点是一个 p 标签,且该 p 标签的子节点类型是文本节点,内容是 ‘1’。而 newVNode 的第一个子节点也是一个 p 标签,它的子节点的类型也是文本节点,内容是 ‘4’。可以发现,更新前后改变的只有 p 标签文本节点的内容。

所以,最理想的更新方式是,直接更新这个 p 标签的文本节点的内容。这样只需要一次 DOM 操作,即可完成一个 p 标签更新。新旧虚拟节点都有 3 个 p标签作为子节点,所以一共只需要 3 次 DOM 操作就可以完成全部节点的更新。相比原来需要执行 6 次 DOM 操作才能完成更新的方式,其性能提升了一倍。

按照这个思路,我们可以重新实现两组子节点的更新逻辑,如下面 patchChildren 函数的代码所示:

function patchChildren(n1, n2, container) {
  if (typeof n2.children === 'string') {
    // 省略部分代码
  } else if (Array.isArray(n2.children)) {
    // 重新实现两组子节点的更新方式
    // 新旧 children
    const oldChildren = n1.children
    const newChildren = n2.children
    // 遍历旧的 children
    for (let i = 0; i < oldChildren.length; i++) {
      // 调用 patch 函数逐个更新子节点
      patch(oldChildren[i], newChildren[i])
    }
  } else {
    // 省略部分代码
  }
}

在这段代码中,oldChildren 和 newChildren 分别是旧的一组子节点和新的一组子节点。我们遍历前者,并将两者中对应位置的节点分别传递给 patch 函数进行更新。patch 函数在执行更新时,发现新旧子节点只有文本内容不同,因此只会更新其文本节点的内容。这样,我们就成功地将 6 次 DOM 操作减少为 3 次。其中菱形代表新子节点,矩形代表旧子节点,圆形代表真实 DOM 节点。

render-diff

这种做法虽然能够减少 DOM 操作次数,但问题也很明显。在上面的代码中,我们通过遍历旧的一组子节点,并假设新的一组子节点的数量与之相同,只有在这种情况下,这段代码才能正确地工作。但是,新旧两组子节点的数量未必相同。当新的一组子节点的数量少于旧的一组子节点的数量时,意味着有些节点在更新后应该被卸载。

render-diff

引言

本节我们将讨论如何处理事件,包括如何在虚拟节点中描述事件,如何把事件添加到 DOM 元素上,以及如何更新事件。我们先来解决第一个问题,即如何在虚拟节点中描述事件。事件可以视作一种特殊的属性,因此我们可以约定,在 vnode.props 对象中,凡是以字符串 on 开头的属性都视作事件。例如:

const vnode = {
  type: 'p',
  props: {
    // 使用 onXxx 描述事件
    onClick: () => {
      alert('clicked');
    }
  },
  children: 'text'
};

解决了事件在虚拟节点层面的描述问题后,我们再来看看如何将事件添加到 DOM 元素上。这非常简单,只需要在 patchProps 中调用 addEventListener 函数来绑定事件即可,如下面的代码所示:

function patchProps(el, key, prevValue, nextValue) {
  // 匹配以 on 开头的属性,视其为事件
  if (/^on/.test(key)) {
    // 根据属性名称得到对应的事件名称,例如 onClick ---> click
    const name = key.slice(2).toLowerCase();
    
    // 移除上一次绑定的事件处理函数
    prevValue && el.removeEventListener(name, prevValue);
    // 绑定新的事件处理函数
    el.addEventListener(name, nextValue);
  } else if (key === 'class') {
    // 省略部分代码(处理 class 属性的逻辑)
  } else if (shouldSetAsProps(el, key, nextValue)) {
    // 省略部分代码(处理其他属性的逻辑)
  } else {
    // 省略部分代码(处理其他属性的逻辑)
  }
}

事实上可以更为优化的事件更新机制,避免多次调用 removeEventListeneraddEventListener

function patchProps(el, key, prevValue, nextValue) {
  if (/^on/.test(key)) {
    const name = key.slice(2).toLowerCase();
    let invoker = el.__vei || (el.__vei = {});

    if (nextValue) {
      if (!invoker[name]) {
        // 如果没有 invoker,则创建一个伪造的 invoker 函数
        invoker[name] = (e) => {
          invoker[name].value(e);
        };
      }
      
      // 将真正的事件处理函数赋值给 invoker 函数的 value 属性
      invoker[name].value = nextValue;

      // 绑定 invoker 函数作为事件处理函数
      el.addEventListener(name, invoker[name]);
    } else if (invoker[name]) {
      // 如果新的事件处理函数不存在,且之前绑定的 invoker 存在,则移除绑定
      el.removeEventListener(name, invoker[name]);
      invoker[name] = null;
    }
  } else if (key === 'class') {
    // 省略部分代码(处理 class 属性的逻辑)
  } else if (shouldSetAsProps(el, key, nextValue)) {
    // 省略部分代码(处理其他属性的逻辑)
  } else {
    // 省略部分代码(处理其他属性的逻辑)
  }
}

观察上面的代码,事件绑定主要分为两个步骤。先从 el._vei 中读取对应的 invoker,如果 invoker 不存在,则将伪造的 invoker 作为事件处理函数,并将它缓存到el._vei 属性中。

把真正的事件处理函数赋值给 invoker.value 属性,然后把伪造的 invoker 函数作为事件处理函数绑定到元素上。可以看到,当事件触发时,实际上执行的是伪造的事件处理函数,在其内部间接执行了真正的事件处理函数 invoker.value(e)。

当更新事件时,由于 el._vei 已经存在了,所以我们只需要将invoker.value 的值修改为新的事件处理函数即可。

这样,在更新事件时可以避免一次 removeEventListener 函数的调用,从而提升了性能。实际上,伪造的事件处理函数的作用不止于此,它还能解决事件冒泡与事件更新之间相互影响的问题。但目前的实现仍然存在问题。现在我们将事件处理函数缓存在el._vei 属性中,问题是,在同一时刻只能缓存一个事件处理函数。这意味着,如果一个元素同时绑定了多种事件,将会出现事件覆盖的现象。

const vnode = {
  type: 'p',
  props: {
    // 使用 onXxx 描述事件
    onClick: () => {
      alert('clicked');
    },
    onContextmenu: () => {
      alert('contextmenu');
    }
  },
  children: 'text'
};

// 假设 renderer 是你的渲染器对象
renderer.render(vnode, document.querySelector('#app'));

当渲染器尝试渲染这上面代码中给出的 vnode 时,会先绑定click 事件,然后再绑定 contextmenu 事件。后绑定的contextmenu 事件的处理函数将覆盖先绑定的 click 事件的处理函
数。为了解决事件覆盖的问题,我们需要重新设计 el._vei 的数据结构。我们应该将 el._vei 设计为一个对象,它的键是事件名称,它的值则是对应的事件处理函数,这样就不会发生事件覆盖的现象了.

根据你提供的代码片段,这段代码主要是用于处理 DOM 元素的属性更新,其中包括事件的绑定和解绑逻辑。在这个代码中,它使用了一个 el._vei 的对象来缓存事件处理函数。下面是你提供的代码的一些修正:

function patchProps(el, key, prevValue, nextValue) {
  if (/^on/.test(key)) {
    const invokers = el._vei || (el._vei = {});
    const name = key.slice(2).toLowerCase();
    let invoker = invokers[name];

    if (nextValue) {
      if (!invoker) {
        invoker = el._vei[name] = (e) => {
          if (Array.isArray(invoker.value)) {
            invoker.value.forEach(fn => fn(e));
          } else {
            invoker.value(e);
          }
        };
      }

      invoker.value = nextValue;
      el.addEventListener(name, invoker);
    } else if (invoker) {
      el.removeEventListener(name, invoker);
      el._vei[name] = null;
    }
  } else if (key === 'class') {
    // 处理 class 属性的逻辑
  } else if (shouldSetAsProps(el, key, nextValue)) {
    // 处理其他属性的逻辑
  } else {
    // 处理其他属性的逻辑
  }
}

在这段代码中,我们修改了 invoker 函数的实现。当 invoker函数执行时,在调用真正的事件处理函数之前,要先检查invoker.value 的数据结构是否是数组,如果是数组则遍历它,并逐个调用定义在数组中的事件处理函数。

前言

Vue.js模板功能强大,几乎可以满足我们在应用程序中所需的一切。但是,有一些场景下,比如基于输入或插槽值创建动态组件,render函数可以更好地满足这些用例。

那些来自React世界的开发者可能对render函数非常熟悉。通常在JSX中使用它们来构建React组件。虽然Vue渲染函数也可以用JSX编写,但我们将继续使用原始JS,有助于我们可以更轻松地了解Vue组件系统的基础。。

每个Vue组件都实现了一个render函数。大多数时候,该函数将由Vue编译器创建。当我们在组件上指定模板时,该模板的内容将由Vue编译器处理,编译器最终将返回render函数。渲染函数本质上返回一个虚拟DOM节点,该节点将被Vue在浏览器DOM中渲染。

现在又引出了虚拟DOM的概念, 虚拟DOM到底是什么?

虚拟文档对象模型(或”DOM”)允许Vue在更新浏览器之前在其内存中渲染组件。 这使一切变得更快,同时也避免了DOM重新渲染的高昂成本。因为每个DOM节点对象包含很多属性和方法,因此使用虚拟DOM预先在内存进行操作,可以省去很多浏览器直接创建DOM节点对象的开销。

Vue更新浏览器DOM时,会将更新的虚拟DOM与上一个虚拟DOM进行比较,并仅使用已修改的部分更新实际DOM。这意味着更少的元素更改,从而提高了性能。Render函数返回虚拟DOM节点,在Vue生态系统中通常称为VNode,该接口是允许Vue在浏览器DOM中写入这些对象的接口。它们包含使用Vue所需的所有信息。

vue-render

挂载子节点和元素的属性

vnode.children的值是字符串类型时,会把它设置为元素的文本内容。一个元素除了具有文本子节点外,还可以包含其他元素子节点,并且子节点可以是很多个。为了描述元素的子节点,我们需要将vnode.children定义为数组:

const vnode = {
  type: 'div',
  children: [
    {
      type: 'p',
      children: 'hello'
    }
  ]
};

上面这段代码描述的是“一个div标签具有一个子节点,且子节点是p标签”。可以看到,vnode.children是一个数组,它的每一个元素都是一个独立的虚拟节点对象。这样就形成了树型结构,即虚拟DOM树。

为了完成子节点的渲染,我们需要修改mountElement函数,如下面的代码所示:

function mountElement(vnode, container) {
  const el = createElement(vnode.type);
  if (typeof vnode.children === 'string') {
    setElementText(el, vnode.children);
  } else if (Array.isArray(vnode.children)) {
    // 如果`children`是数组,则遍历每一个子节点,并调用`patch`函数挂载它们
    vnode.children.forEach(child => {
      patch(null, child, el);
    });
  }
  insert(el, container);
}

在上面这段代码中,我们增加了新的判断分支。使用Array.isArray函数判断vnode.children是否是数组,如果是数组,则循环遍历它,并调用patch函数挂载数组中的虚拟节点。在挂载子节点时,需要注意以下两点:

  1. 传递给patch函数的第一个参数是null。因为是挂载阶段,没有旧vnode,所以只需要传递null即可。这样,当patch函数执行时,就会递归地调用mountElement函数完成挂载。

  2. 传递给patch函数的第三个参数是挂载点。由于我们正在挂载的子元素是div标签的子节点,所以需要把刚刚创建的div元素作为挂载点,这样才能保证这些子节点挂载到正确位置。

完成了子节点的挂载后,我们再来看看如何用vnode描述一个标签的属性,以及如何渲染这些属性。我们知道,HTML标签有很多属性,其中有些属性是通用的,例如idclass等,而有些属性是特定元素才有的,例如form元素的action属性。实际上,渲染一个元素的属性比想象中要复杂,不过我们仍然秉承一切从简的原则,先来看看最基本的属性处理。

为了描述元素的属性,我们需要为虚拟DOM定义新的vnode.props字段,如下面的代码所示:

const vnode = {
  type: 'div',
  props: {
    id: 'foo'
  },
  children: [
    {
      type: 'p',
      children: 'hello'
    }
  ]
};

vnode.props是一个对象,它的键代表元素的属性名称,它的值代表对应属性的值。这样,我们就可以通过遍历props对象的方式,把这些属性渲染到对应的元素上,如下面的代码所示:

function mountElement(vnode, container) {
  const el = createElement(vnode.type);
  // 省略children的处理

  // 如果`vnode.props`存在才处理它
  if (vnode.props) {
    // 遍历`vnode.props`
    for (const key in vnode.props) {
      // 调用`setAttribute`将属性设置到元素上
      el.setAttribute(key, vnode.props[key]);
    }
  }

  insert(el, container);
}

在这段代码中,我们首先检查了vnode.props字段是否存在,如果存在则遍历它,并调用setAttribute函数将属性设置到元素上。实际上,除了使用setAttribute函数为元素设置属性之外,还可以通过DOM对象直接设置:

function mountElement(vnode, container) {
  const el = createElement(vnode.type);
  // 省略children的处理

  if (vnode.props) {
    for (const key in vnode.props) {
      // 直接设置
      el[key] = vnode.props[key];
    }
  }

  insert(el, container);
}

在这段代码中,我们没有选择使用setAttribute函数,而是直接将属性设置在DOM对象上,即el[key] = vnode.props[key]。实际上,无论是使用setAttribute函数,还是直接操作DOM对象,都存在缺陷。如前所述,为元素设置属性比想象中要复杂得多。不过,在讨论具体有哪些缺陷之前,我们有必要先搞清楚两个重要的概念:HTML Attributes和DOM Properties。

当我们处理元素属性时,有两种主要的方式:使用HTML Attributes和DOM Properties。这两者在概念上有些许不同:

  1. HTML Attributes:

    • HTML Attributes是在HTML标签中定义的属性,例如idclasssrc等。
    • 通过setAttribute方法可以设置HTML Attributes。
    • HTML Attributes的值始终是字符串。
  2. DOM Properties:

    • DOM Properties是DOM对象上的属性,例如element.idelement.classNameelement.src等。
    • 直接操作DOM对象可以设置DOM Properties。
    • DOM Properties的值可以是字符串、数字、布尔值等,具体取决于属性的类型。

HTML Attributes和DOM Properties

在处理元素属性时,我们需要明确HTML Attributes和DOM Properties之间的区别。

如果我们使用setAttribute方法设置属性,那么属性会被设置为HTML Attributes。如果我们直接操作DOM对象的属性,属性会被设置为DOM Properties。

现在,我们来讨论一下这两种方式存在的问题:

  1. 属性值类型转换问题:

    • 当我们使用setAttribute方法设置属性时,属性的值始终被转换为字符串。这就意味着,如果我们将一个数字或布尔值赋给属性,它们都会被转换为字符串。例如,element.setAttribute('value', 42)会将值转换为字符串'42'
  2. 布尔属性问题:

    • HTML中的一些属性是布尔属性,例如checkeddisabled等。对于这些属性,如果存在就表示为true,不存在就表示为false
    • 当我们使用setAttribute方法设置布尔属性时,不论属性值是什么,都会被视为存在。例如,element.setAttribute('disabled', 'false')会使元素具有disabled属性,即使值是字符串'false'

考虑到这些问题,最好的做法是尽量使用DOM Properties而不是HTML Attributes来设置元素的属性。这样可以避免类型转换问题和布尔属性问题,确保属性被正确设置。

首先,HTML Attributes指的是定义在HTML标签上的属性,例如id="my-input"type="text"value="foo"。当浏览器解析这段HTML代码后,会创建一个与之相符的DOM元素对象,我们可以通过JavaScript代码来读取该DOM对象:

const el = document.querySelector('#my-input');

现在来说一下DOM Properties。许多HTML Attributes在DOM对象上有与之同名的DOM Properties,例如id="my-input"对应el.idtype="text"对应el.typevalue="foo"对应el.value等。但是,DOM Properties与HTML Attributes的名字并不总是一模一样的,例如:

<div class="foo"></div>

class="foo"对应的DOM Properties则是el.className。另外,并不是所有HTML Attributes都有与之对应的DOM Properties,例如:

<div aria-valuenow="75"></div>

aria-*类的HTML Attributes就没有与之对应的DOM Properties。

类似地,也不是所有DOM Properties都有与之对应的HTML Attributes,例如可以用el.textContent来设置元素的文本内容,但并没有与之对应的HTML Attributes来完成同样的工作。

HTML Attributes的值与DOM Properties的值之间是有关联的。例如下面的HTML片段:

<div id="foo"></div>

这个片段描述了一个具有id属性的div标签。其中,id="foo"对应的DOM Properties是el.id,并且值为字符串'foo'。我们把这种HTML Attributes与DOM Properties具有相同名称(即id)的属性看作直接映射。

但并不是所有HTML Attributes与DOM Properties之间都是直接映射的关系,例如:

<input value="foo" />

这是一个具有value属性的input标签。如果用户没有修改文本框的内容,那么通过el.value读取对应的DOM Properties的值就是字符串'foo'。而如果用户修改了文本框的值,那么el.value的值就是当前文本框的值。例如,用户将文本框的内容修改为'bar',那么:

console.log(el.value); // 'bar'

但如果运行下面的代码,会发生“奇怪”的现象:

console.log(el.getAttribute('value')); // 仍然是 'foo'
console.log(el.value); // 'bar'

可以发现,用户对文本框内容的修改并不会影响el.getAttribute('value')的返回值,这个现象蕴含着HTML Attributes所代表的意义。实际上,HTML Attributes的作用是设置与之对应的DOM Properties的初始值。一旦值改变,那么DOM Properties始终存储着当前值,而通过getAttribute函数得到的仍然是初始值。

但我们仍然可以通过el.defaultValue来访问初始值,如下面的代码所示:

el.getAttribute('value'); // 仍然是 'foo'
el.value; // 'bar'
el.defaultValue; // 'foo'

这说明一个HTML Attributes可能关联多个DOM Properties。例如在上例中,value="foo"el.valueel.defaultValue都有关联。

虽然我们可以认为HTML Attributes是用来设置与之对应的DOM Properties的初始值的,但有些值是受限制的,就好像浏览器内部做了默认值校验。如果你通过HTML Attributes提供的默认值不合法,那么浏览器会使用内建的合法值作为对应DOM Properties的默认值,例如:

<input type="foo" />

我们知道,为<input/>标签的type属性指定字符串'foo'是不合法的,因此浏览器会矫正这个不合法的值。所以当我们尝试读取el.type时,得到的其实是矫正后的值,即字符串'text',而非字符串'foo'

console.log(el.type); // 'text'

从上述分析来看,HTML Attributes与DOM Properties之间的关系很复杂,但实际上我们只需要记住一个核心原则:HTML Attributes的作用是设置与之对应的DOM Properties的初始值。

如何正确地设置元素属性

在上文中,我们讨论了在Vue.js单文件组件的模板中,HTML Attributes和DOM Properties的设置方式。在普通的HTML文件中,浏览器会自动解析HTML Attributes并设置相应的DOM Properties。然而,在Vue.js的模板中,需要框架手动处理这些属性的设置。

首先,我们以一个禁用的按钮为例,如下所示的HTML代码:

<button disabled>Button</button>

浏览器会自动将这个按钮设置为禁用状态,并将其对应的DOM Properties el.disabled的值设置为true。但是,如果同样的代码出现在Vue.js的模板中,情况就会有所不同。

在Vue.js的模板中,HTML模板会被编译成虚拟节点(vnode),其中props.disabled的值是一个空字符串。如果直接使用setAttribute函数设置属性,会导致意外的效果,即按钮被禁用。例如,以下模板:

<button disabled="false">Button</button>

对应的虚拟节点为:

const button = {
  type: 'button',
  props: {
    disabled: false
  }
};

如果使用setAttribute函数将属性值设置为空字符串,实际上相当于:

el.setAttribute('disabled', '');

而按钮的el.disabled属性是布尔类型的,不关心具体的HTML Attributes的值是什么,只要disabled属性存在,按钮就会被禁用。因此,渲染器不应该总是使用setAttribute函数将vnode.props对象中的属性设置到元素上。

为了解决这个问题,我们可以优先设置元素的DOM Properties,但当值为空字符串时,需要手动将其矫正为true。以下是一个具体的实现示例:

function mountElement(vnode, container) {
  const el = createElement(vnode.type);

  if (vnode.props) {
    for (const key in vnode.props) {
      if (key in el) {
        const type = typeof el[key];
        const value = vnode.props[key];
        if (type === 'boolean' && value === '') {
          el[key] = true;
        } else {
          el[key] = value;
        }
      } else {
        el.setAttribute(key, vnode.props[key]);
      }
    }
  }

  insert(el, container);
}

在上述代码中,我们检查每个vnode.props中的属性,看看是否存在对应的DOM Properties。如果存在,优先设置DOM Properties。同时,对布尔类型的DOM Properties做了值的矫正,即当要设置的值为空字符串时,将其矫正为布尔值true。如果vnode.props中的属性没有对应的DOM Properties,则仍然使用setAttribute函数完成属性的设置。

然而,上述实现仍然存在问题。有些DOM Properties是只读的,例如el.form。为了解决这个问题,我们可以添加一个辅助函数shouldSetAsProps,用于判断是否应该将属性作为DOM Properties设置。如果属性是只读的,或者需要特殊处理,就应该使用setAttribute函数来设置属性。

最后,为了使属性设置操作与平台无关,我们将属性设置相关的操作提取到渲染器选项中。以下是相应的代码示例:

const renderer = createRenderer({
  createElement(tag) {
    return document.createElement(tag);
  },
  setElementText(el, text) {
    el.textContent = text;
  },
  insert(el, parent, anchor = null) {
    parent.insertBefore(el, anchor);
  },
  patchProps(el, key, prevValue, nextValue) {
    if (shouldSetAsProps(el, key, nextValue)) {
      const type = typeof el[key];
      if (type === 'boolean' && nextValue === '') {
        el[key] = true;
      } else {
        el[key] = nextValue;
      }
    } else {
      el.setAttribute(key, nextValue);
    }
  }
});

mountElement函数中,只需要调用patchProps函数,并为其传递相应的参数即可。这样,我们就将属性相关的渲染逻辑从渲染器的核心中抽离出来,使其更加可维护和灵活。

前言

在 Vue.js 中,很多功能依赖渲染器来实现,例如 Transition组件、Teleport 组件、Suspense 组件,以及 template ref 和自定义指令等。

另外,渲染器也是框架性能的核心,渲染器的实现直接影响框架的性能。Vue.js 3 的渲染器不仅仅包含传统的 Diff 算法,它还独创了快捷路径的更新方式,能够充分利用编译器提供的信息,大大提升了更新性能。

在Vue.js中,渲染器(renderer)是负责执行渲染任务的组件。在浏览器平台上,它将虚拟DOM渲染为真实DOM元素。渲染器不仅可以渲染真实DOM元素,还是框架跨平台能力的关键。在设计渲染器时,需要考虑其可自定义的能力。

渲染器的基本概念及其含义

在实现一个最基本的渲染器之前,我们要先了解几个基本的概念:

在Vue.js中,渲染器(renderer)是用来执行渲染任务的组件。在浏览器平台上,它将虚拟DOM渲染为真实DOM元素。以下是渲染器的基本概念及其含义:

渲染器(Renderer)

渲染器是负责将虚拟DOM(或虚拟节点)渲染为特定平台上的真实元素的组件。在浏览器平台上,渲染器会将虚拟DOM渲染为真实的DOM元素。

虚拟DOM(vnode)

虚拟DOM(也称为虚拟节点,简写为vnode)是一个树型结构,类似于真实DOM,由各种节点组成。渲染器的任务是将虚拟DOM渲染为真实的DOM元素。

挂载(Mounting)

挂载是指将虚拟DOM渲染为真实DOM元素并将其添加到指定的挂载点上。在Vue.js中,组件的mounted钩子函数就是在挂载完成时触发,此时可以访问到真实DOM元素。

容器(Container)

容器是指用来指定挂载位置的DOM元素。渲染器会将虚拟DOM渲染为真实DOM元素并添加到指定的容器内。在渲染器的render函数中,通常会传入一个容器参数,表示将虚拟DOM挂载到哪个DOM元素上。

渲染器的创建与使用

渲染器的创建通常使用createRenderer函数,该函数返回一个包含渲染和激活(hydrate)函数的对象。激活函数在同构渲染时使用,将虚拟DOM激活为已有的真实DOM元素。以下是渲染器的创建和使用示例:

function createRenderer() {
  function render(vnode, container) {
    // 渲染逻辑
  }

  function hydrate(vnode, container) {
    // 激活逻辑
  }

  return {
    render,
    hydrate
  };
}

const { render, hydrate } = createRenderer();
// 首次渲染
render(vnode, document.querySelector('#app'));
// 同构渲染
hydrate(vnode, document.querySelector('#app'));

上面的代码片中通过createRenderer函数创建了一个渲染器对象,包含了renderhydrate函数。render函数用于将虚拟DOM渲染为真实DOM元素,而hydrate函数用于将虚拟DOM激活为已有的真实DOM元素。

好的,现在我们已经对渲染器有了一个比较基础的认识,下面来一步步深入了解一下:

渲染器的实现可以通过如下的函数来表示,其中domString是待渲染的HTML字符串,container是挂载点的DOM元素:

function renderer(domString, container) {
  container.innerHTML = domString;
}

使用渲染器的示例:

renderer('<h1>Hello</h1>', document.getElementById('app'));

上述代码将<h1>Hello</h1>插入到id为app的DOM元素内。渲染器不仅可以渲染静态字符串,还可以渲染动态拼接的HTML内容:

let count = 1;
renderer(`<h1>${count}</h1>`, document.getElementById('app'));

如果count是一个响应式数据,那么可以使用响应系统来自动化整个渲染过程。首先,定义一个响应式数据count,然后在副作用函数内调用渲染器函数进行渲染:

const count = ref(1);

effect(() => {
  renderer(`<h1>${count.value}</h1>`, document.getElementById('app'));
});

count.value++;

在上述代码中,count是一个ref响应式数据。当修改count.value的值时,副作用函数会重新执行,完成重新渲染,最终渲染到页面的内容是<h1>2</h1>

这里使用了Vue 3提供的@vue/reactivity包中的响应式API,通过<script>标签引入:

<script src="https://unpkg.com/@vue/reactivity@3.0.5/dist/reactivity.global.js"></script>

上述代码中给出了render函数的基本实现,下面将其执行流程进行详细分析。假设我们连续三次调用renderer.render函数来执行渲染:

const renderer = createRenderer();

// 首次渲染
renderer.render(vnode1, document.querySelector('#app'));
// 第二次渲染
renderer.render(vnode2, document.querySelector('#app'));
// 第三次渲染
renderer.render(null, document.querySelector('#app'));

在首次渲染时,渲染器将vnode1渲染为真实DOM,并将vnode1存储到容器元素的container.vnode属性中,作为旧vnode。

在第二次渲染时,旧的vnode存在(即container.vnode中有值),此时渲染器将vnode2作为新vnode,将新旧vnode一同传递给patch函数进行打补丁。

在第三次渲染时,新的vnode的值为null,即不渲染任何内容。但是此时容器中渲染的是vnode2所描述的内容,所以渲染器需要清空容器。在上面的代码中,使用container.innerHTML = ''来清空容器。需要注意的是,这种清空容器的方式并不是最佳实践,但在这里仅用于演示目的。

关于patch函数,它是整个渲染器的核心入口,接收三个参数:旧vnode n1、新vnode n2和容器 container。在首次渲染时,旧vnode n1undefined,表示挂载动作。patch函数不仅用于打补丁,也可以执行挂载动作。

自定义渲染器

自定义渲染器的实现是通过抽象核心渲染逻辑,使其不再依赖于特定平台的API。以下是自定义渲染器的实现示例代码,使用配置项来实现平台无关的渲染:

// 创建渲染器函数,接收配置项作为参数
function createRenderer(options) {
  // 从配置项中获取操作 DOM 的 API
  const { createElement, insert, setElementText } = options;

  // 定义挂载元素的函数
  function mountElement(vnode, container) {
    // 调用 createElement 函数创建元素
    const el = createElement(vnode.type);
    // 如果子节点是字符串,调用 setElementText 设置文本内容
    if (typeof vnode.children === 'string') {
      setElementText(el, vnode.children);
    }
    // 调用 insert 函数将元素插入到容器内
    insert(el, container);
  }

  // 定义打补丁函数
  function patch(n1, n2, container) {
    // 实现打补丁逻辑,这部分内容在示例中省略
  }

  // 定义渲染函数,接收虚拟节点和容器作为参数
  function render(vnode, container) {
    // 如果旧虚拟节点存在,执行打补丁逻辑,否则执行挂载逻辑
    if (container.vnode) {
      patch(container.vnode, vnode, container);
    } else {
      mountElement(vnode, container);
    }
    // 将当前虚拟节点存储到容器的 vnode 属性中
    container.vnode = vnode;
  }

  // 返回渲染函数
  return render;
}

// 创建自定义渲染器的配置项
const customRendererOptions = {
  // 用于创建元素
  createElement(tag) {
    console.log(`创建元素 ${tag}`);
    // 在实际应用中,可以返回一个自定义的对象,模拟DOM元素
    return { type: tag };
  },
  // 用于设置元素的文本节点
  setElementText(el, text) {
    console.log(`设置 ${JSON.stringify(el)} 的文本内容:${text}`);
    // 在实际应用中,设置对象的文本内容
    el.textContent = text;
  },
  // 用于在给定的 parent 下添加指定元素
  insert(el, parent, anchor = null) {
    console.log(`${JSON.stringify(el)} 添加到 ${JSON.stringify(parent)}`);
    // 在实际应用中,将 el 插入到 parent 内
    parent.children = el;
  },
};

// 使用自定义渲染器的配置项创建渲染函数
const customRenderer = createRenderer(customRendererOptions);

// 创建一个虚拟节点描述 <h1>hello</h1>
const vnode = {
  type: 'h1',
  children: 'hello',
};

// 使用一个对象模拟挂载点
const container = { type: 'root' };

// 使用自定义渲染器渲染虚拟节点到挂载点
customRenderer(vnode, container);

上面的代码片我们通过createRenderer函数创建了一个自定义渲染器,并通过配置项(customRendererOptions)传递操作DOM的API。渲染器在执行时,根据配置项中的API来完成相应的操作。通过这种方式,我们实现了一个通用的、不依赖于特定平台的渲染器。在实际应用中,可以根据不同的平台需求,通过配置不同的API,实现跨平台的渲染能力。

前言

介绍 reactive 与 shallowReactive 的区别,即深响应和浅响应的区别。

浅响应式与深相应式

const obj = reactive({ foo: { bar: 1 } })
effect(() =>{ 
console.log(obj.foo.bar)
})
// 修改 obj.foo.bar 的值,并不能触发响应
obj.foo.bar = 2

首先,创建 obj 代理对象,该对象的 foo 属性值也是一个对象,即 { bar: 1} 。接着,在副作用函数内访问 obj.foo.bar 的值。但是我们发现,后续对 obj.foo.bar 的修改不能触发副作用函数重新执行,这是为什么呢?来看一下现在的实现:

function reactive(obj) {
  return new Proxy(obj ,{
    get(target, key, receiver) {
      if (key === 'raw') 
        return target;
    track(target, key);
// 当读取属性值时,直接返回结果
    return Reflect.get(target, key, receiver)
}
// 省略其他拦截函数
})
}

由上面这段代码可知,当我们读取 obj.foo.bar 时,首先要读取 obj.foo 的值。这里我们直接使用 Reflect.get 函数返回obj.foo 的结果。由于通过 Reflect.get 得到 obj.foo 的结果是一个普通对象,即 { bar: 1} ,它并不是一个响应式对象,所以在副作用函数中访问 obj.foo.bar 时,是不能建立响应联系的。要解决这个问题,我们需要对 Reflect.get 返回的结果做一层包装:

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver);
      // If the result is an object, make it reactive
      if (typeof result === 'object') {
        return reactive(result);
      }
      return result;
    },
    // Other traps...
  });
}

这段代码定义了一个名为reactive的函数,该函数接收一个对象作为参数,并返回该对象的代理。这个代理使用了get陷阱函数,当我们尝试获取对象的某个属性时,这个函数就会被触发。

在get陷阱函数中,我们首先使用Reflect.get方法获取目标对象的属性值。Reflect.get方法接收三个参数:目标对象、属性名和接收器对象。在这里,接收器对象就是代理对象本身。

然后,我们检查获取的结果是否为对象。如果是对象,我们就对其进行响应式处理,即再次调用reactive函数。这样做的目的是确保嵌套的对象也具有响应式特性,也就是说,当我们修改这些嵌套对象的属性时,也能触发响应式系统。

最后,如果获取的结果不是对象,我们就直接返回结果。

浅响应式

然而,并非所有情况下我们都希望深响应,这就催生了shallowReactive,即浅响应。所谓浅响应,指的是只有对象的第一层属性是响应的,例如:

例如,我们有一个对象,它的属性值也是一个对象:

let obj = {
  innerObj: {
    key: 'value'
  }
}

如果我们对obj进行深响应处理:

let reactiveObj = reactive(obj);

那么,无论我们修改obj的属性,还是修改innerObj的属性,都会触发响应式系统:

reactiveObj.innerObj.key = 'new value'; // 触发响应式系统

但是,如果我们只想要obj的第一层属性是响应的,也就是说,只有当我们修改obj的属性时才触发响应式系统,而修改innerObj的属性则不触发,那么我们就需要使用shallowReactive函数:

let shallowReactiveObj = shallowReactive(obj);

这样,只有当我们修改obj的属性时,才会触发响应式系统:

shallowReactiveObj.innerObj = {}; // 触发响应式系统
shallowReactiveObj.innerObj.key = 'new value'; // 不触发响应式系统

Vuejs里reactive和shallowReactive

在Vue.js中,reactive和shallowReactive函数都用于创建响应式对象,这一小节来讨论下他们的不同。

reactive函数创建的是深度响应式对象。这意味着不仅对象本身,而且它内部的所有嵌套对象都会变成响应式的。无论是修改对象的属性,还是修改其嵌套对象的属性,都会触发响应式系统。

而shallowReactive函数创建的是浅层响应式对象。这意味着只有对象的顶层属性是响应式的。如果对象包含嵌套对象,那么修改这些嵌套对象的属性不会触发响应式系统。

let obj = {
  innerObj: {
    key: 'value'
  }
}

let reactiveObj = Vue.reactive(obj);
reactiveObj.innerObj.key = 'new value'; // 这将触发响应式系统

let shallowReactiveObj = Vue.shallowReactive(obj);
shallowReactiveObj.innerObj.key = 'new value'; // 这将不会触发响应式系统

只读和浅只读

讨论完响应式和浅响应式,我们在来说下只读和浅只读:

Vue.js还提供了readonlyshallowReadonly函数,它们用于创建只读的响应式对象。

readonly函数创建的是深度只读的响应式对象。这意味着不仅对象本身是只读的,而且它内部的所有嵌套对象也都是只读的。任何尝试修改对象或其嵌套对象的属性的操作都会失败。

shallowReadonly函数创建的是浅层只读的响应式对象。这意味着只有对象的顶层属性是只读的。如果对象包含嵌套对象,那么这些嵌套对象的属性是可以修改的。

let obj = {
  innerObj: {
    key: 'value'
  }
}

let readonlyObj = Vue.readonly(obj);
readonlyObj.innerObj.key = 'new value'; // 这将失败,因为对象是只读的

let shallowReadonlyObj = Vue.shallowReadonly(obj);
shallowReadonlyObj.innerObj.key = 'new value'; // 这将成功,因为只有顶层属性是只读的