第12-14章 组件化

12组件化

vue开发中的特色-组件化。当一个项目越来越庞大,页面结构越来越复杂,会有太多的虚拟DOM节点。这时候就需要用到组件化,将功能抽离成独立的组件,用这些组件完成项目开发,易于开发又易于维护。

12.1渲染组件

定义一个组件, 其实就是一个固定格式规范的对象。

  1. const myComponent = {
  2. name: "MyComponent",
  3. data(){
  4. return { foo: 1}
  5. }
  6. }

从渲染器的内部实现看,一个组件就是一个特殊类型的虚拟DOM节点。

  1. const vnode = { type: "div", // ... }
  1. const vnode = {type: Fragment, // ...}
  1. const vnode ={type :Text, //...}

渲染器的patch可以印证组件为特殊的虚拟节点

  1. function patch(n1, n2, container, anchor){
  2. if(n1 && n1.type !== n2.type){
  3. unmount(n1)
  4. n1 = null;
  5. }
  6. const {type} = n2;
  7. if(typeof type ==="string"){
  8. /// 普通元素
  9. }else if(type === Text){ // 文本节点}
  10. else if(type === Fragment) { // Fragment 片段节点}
  11. }

为了使用虚拟节点描述组件,可以使vnode.type属性存储组件的选项对象。

  1. const vnode = {
  2. type: MyComponent, // type存储组件的对象
  3. }

让patch函数能处理组件类型, 调用mountComponent和patchComponent函数完成组件的挂载和更新。

  1. function patch(n1, n2, container, anchor){
  2. if(n1 && n1.type !== n2.type){
  3. unmount(n1)
  4. n1 = null;
  5. }
  6. const {type} = n2;
  7. if(typeof type ==="string"){
  8. /// 普通元素
  9. }else if(type === Text){ // 文本节点
  10. }
  11. else if(type === Fragment) { // Fragment 片段节点
  12. }else if(typeof type ==="object"){
  13. if(!n1){
  14. mountComponent(n2, container, anchor);
  15. }else {
  16. patchComponent(n1, n2, anchor)
  17. }
  18. }
  19. }

渲染器有了处理组件的能力,然后就要设计组件在用户层面的接口。组件本身是对内容的封装,一个组件包含一个渲染函数render,函数返回值是虚拟DOM,组件的渲染函数就是用来描述组件所渲染内容的接口

  1. const MyComponent = {
  2. name: "myComponent", // 定义组件名称
  3. // 组件的渲染函数,返回值是虚拟DOM
  4. render(){
  5. return {
  6. type: "div",
  7. children : "text world"
  8. }
  9. }
  10. }

有了基本的组件结构,渲染器就可以完成组件渲染

  1. const CompVnode = {
  2. type: MyComponent //用来描述组件的vnode对象, type属性为组件的选项对象
  3. }
  4. // 调用渲染器来渲染组件
  5. renderer.render(CompVnode, document.querySelector("#app"));

渲染器中完成渲染任务的是 mountComponent 函数

  1. function mountComponent(vnode, container, anchor){
  2. // 通过vnode获取组件的选项对象--vnode.type
  3. const componentOptions = vnode.type;
  4. // 获取组件的渲染函数 render
  5. const { render } = componentOptions;
  6. // 执行渲染函数,获取组件要渲染的内容, render函数返回的虚拟DOM
  7. const subTree = render();
  8. // 调用patch函数挂载组件所描述的内容,subTree
  9. patch(null, subTree, container, anchor)
  10. }

12.2组件的状态与自更新

完成了初始组件渲染,接下来为组件设计自身状态 data

  1. const MyComponent = {
  2. name: "MyComponent",
  3. // data定义组件自身状态数据
  4. data (){
  5. return {
  6. foo:"hello vue3"
  7. }
  8. },
  9. render(){
  10. return {
  11. type:"div",
  12. children : `foo 的值是 ${this.foo}` //在渲染函数内使用组件的状态数据 data
  13. }
  14. }
  15. }
  • 使用data函数定义组件自身状态
  • 在渲染函数中,通过this 访问由data函数返回的状态数据

组件自身状态初始化

  1. function mountComponent(vnode, container, anchor){
  2. const componentOptions = vnode.type;
  3. const {render, data} = componentOptions;
  4. // 调用data函数获取原始数据,并调用reactive函数将数据包装为响应式数据
  5. const state = reactive(data());
  6. // 调用render函数,将this设置 为state
  7. const subTree = render.call(state, state);
  8. patch(null, subTree, container, anchor);
  9. }
  • 使用reactive函数将data函数返回值包装成响应式数据
  • 调用render渲染函数,第一个参数设置this的值为state,第二参数将state作为参数传入

当组件自身状态发生变化时,需要触发组件更新。需要将整个渲染任务包装到一个effect中。

  1. function mountComponent(vnode, container, anchor){
  2. const componentOptions = vnode.type;
  3. const {render, data} = componentOptions;
  4. // 调用data函数获取原始数据,并调用reactive函数将数据包装为响应式数据
  5. const state = reactive(data());
  6. // 将组件的render函数调用包装到effect中
  7. effect(()=>{
  8. const subTree = render.call(state, state);
  9. patch(null, subTree, container, anchor);
  10. })
  11. }

组件自身的响应式数据发生变化时,组件就会自动重新执行渲染函数。

由于effect执行是同步,当响应式数据发生变化时,与之关联的副作用函数会同步执行。这样会导致有多少次修改数据就有多少次渲染函数的重复执行,严重降低性能。 可以设计一个调度器scheduler,当副作用函数需要重新执行时,不会立即执行它,而是将它缓存到微任务队列中,等到执行栈清空后,再将它从微任务队列中取出并执行。有了缓存机制,就可以对任务进行去重,避免多次执行重复副作用函数带来的性能浪费。

  1. // 任务缓存队列,用Set数据结构表示,这样可以自动去重
  2. const queue = new Set();
  3. // 设置一个标记,代表是否正在进行刷新任务队列
  4. let isFlushing = false;
  5. // 创建一个立即 resolve 的Promise实例
  6. const p= Promise.resolve();
  7. // scheduler调度器的主要函数,用来将一个任务添加到缓存队列,并开始刷新队列
  8. function queueJob(job){
  9. // 将 job添加到任务队列中
  10. queue.add(job);
  11. //如果还没有刷新队列
  12. if(!isFlushing){
  13. //进入刷新队列,将标志位设置为true以避免重复刷新
  14. isFlushing = true;
  15. // 在微任务中刷新缓存队列
  16. p.then(()=>{
  17. try{
  18. // 执行任务队列中的任务
  19. queue.forEach(job => job())
  20. }finally{
  21. // 重置状态
  22. isFlushing = false;
  23. queue.length = 0;
  24. }
  25. })
  26. }
  27. }

利用微任务的异步执行机制,实现副作用函数的缓存。有了queueJob函数,就可以在创建渲染副作用时使用它。

  1. function mountComponent(vnode, container, anchor){
  2. const componentOptions = vnode.type;
  3. const {render, data} = componentOptions;
  4. // 调用data函数获取原始数据,并调用reactive函数将数据包装为响应式数据
  5. const state = reactive(data());
  6. // 将组件的render函数调用包装到effect中
  7. effect(()=>{
  8. const subTree = render.call(state, state);
  9. patch(null, subTree, container, anchor);
  10. }, {
  11. //指定副作用函数的调度器为 queueJob
  12. scheduler: queueJob
  13. })
  14. }

这样就能达到当数据发生变化时,副作用函数不会立即同步执行,而是被queueJob函数调度缓存,最后在一个微任务中执行。

12.3组件实例与组件的生命周期

组件实例本质是一个状态集合,包含组件运行过程中的所有信息,如组件生命周期、组件渲染的子树subTree、组件是否已经被挂载,组件的自身状态等。为了解决组件更新问题,引入组件实例的概念,以及与之相关的状态信息。

  1. function mountComponent(vnode, container, anchor){
  2. const componentOptions = vnode.type;
  3. const {render, data} = componentOptions;
  4. // 调用data函数获取原始数据,并调用reactive函数将数据包装为响应式数据
  5. const state = reactive(data());
  6. //定义组件实例
  7. const instance = {
  8. // 组件自身状态数据 data
  9. state,
  10. // 一个布尔值, 用来表示组件是否已经被挂载
  11. isMounted: false,
  12. // 组件所渲染的内容, subTree
  13. subTree: null
  14. }
  15. //将组件实例设置到vnode上, 用于后续更新
  16. vnode.component = instance;
  17. // 将组件的render函数调用包装到effect中
  18. effect(()=>{
  19. // 调用组件的渲染函数,获得子树
  20. const subTree = render.call(state, state);
  21. // 检查组件是否已经被挂载
  22. if(!instance.isMounted){
  23. // 初次挂载,patch第一个参数null
  24. patch(null, subTree, container, anchor);
  25. // 将组件的isMounted设置为true,阻止更新时执行挂载。
  26. instance.isMounted = true;
  27. }else{
  28. // isMounted为true,执行更新。patch函数的第一个参数,为组件上次渲染的子树
  29. // 意思是,使用新的子树与上次渲染的子树进行打补丁操作
  30. patch(instance.subTree, subTree, container, anchor);
  31. }
  32. // 更新组件实例子树
  33. instance.subTree = subTree;
  34. }, {
  35. //指定副作用函数的调度器为 queueJob
  36. scheduler: queueJob
  37. })
  38. }

用一个对象表示组件实例,该对象包含三个属性

  • state:组件自身的状态数据,即data
  • isMounted:表示组件是否被挂载
  • subTree:存储组件的渲染函数返回的虚拟DOM,即组件的子树 subTree。

    可以在任意的在组件实例instance上添加需要的属性。

用组件实例的isMounted属性来区分组件的挂载和更新,在合适的时机调用组件对应的生命周期钩子。

  1. function mountComponent(vnode, container, anchor){
  2. const componentOptions = vnode.type;
  3. const {render, data, beforeCreate, created, beforeMount, mounted, beforeUpdate, updated} = componentOptions;
  4. // 设置调用 beforeCreate 钩子
  5. beforeCreate && beforeCreate();
  6. // 调用data函数获取原始数据,并调用reactive函数将数据包装为响应式数据
  7. const state = reactive(data());
  8. //定义组件实例
  9. const instance = {
  10. // 组件自身状态数据 data
  11. state,
  12. // 一个布尔值, 用来表示组件是否已经被挂载
  13. isMounted: false,
  14. // 组件所渲染的内容, subTree
  15. subTree: null
  16. }
  17. //将组件实例设置到vnode上, 用于后续更新
  18. vnode.component = instance;
  19. // 设置调用 created 钩子
  20. created && create.call(state)
  21. // 将组件的render函数调用包装到effect中
  22. effect(()=>{
  23. // 调用组件的渲染函数,获得子树
  24. const subTree = render.call(state, state);
  25. // 检查组件是否已经被挂载
  26. if(!instance.isMounted){
  27. // 设置调用 beforeMount 钩子
  28. beforeMount && beforeMount.call(state)
  29. // 初次挂载,patch第一个参数null
  30. patch(null, subTree, container, anchor);
  31. // 将组件的isMounted设置为true,阻止更新时执行挂载。
  32. instance.isMounted = true;
  33. // 设置调用 mounted 钩子
  34. mounted && mounted.call(state);
  35. }else{
  36. // 设置调用 beforeUpdate 钩子
  37. beforeUpdate && beforeUpdate.call(state)
  38. // isMounted为true,执行更新。patch函数的第一个参数,为组件上次渲染的子树
  39. // 意思是,使用新的子树与上次渲染的子树进行打补丁操作
  40. patch(instance.subTree, subTree, container, anchor);
  41. // 设置调用 updated 钩子
  42. updated && updated.call(state)
  43. }
  44. // 更新组件实例子树
  45. instance.subTree = subTree;
  46. }, {
  47. //指定副作用函数的调度器为 queueJob
  48. scheduler: queueJob
  49. })
  50. }

代码中首先从组件的选项对象中取得注册到组件上的生命周期函数,然后在合适的时机调用它们,这就是组件生命周期的原理。
实际中可能存在多个同样的组件生命周期钩子,例如mixins中的生命周期钩子函数,因此需要将组件生命周期钩子序列化一个数组。

12.4 props和组件的被动更新

虚拟DOM对的props和html的属性类似
<MyComponent title="A big Title" :other="val" />

  1. const vnode = {
  2. type: MyComponent,
  3. props: {
  4. title: "A big Title",
  5. other: this.val
  6. }
  7. }
  1. const MyComponent = {
  2. name: "MyComponent",
  3. // 组件接收名为title的props,并且改props的类型为String
  4. props: {
  5. title: String
  6. },
  7. render(){
  8. return {
  9. type : "div",
  10. children: `count is : ${this.title}` //访问props数据
  11. }
  12. }
  13. }

关于props有两部分需要注意:

  • 组件选项定义的props的地方,MyComponent.props对象
  • 设置并传递props数据的地方, 即vnode.props对象 ```javascript function mountComponent(vnode, container, anchor){ const componentOptions = vnode.type; const {render, data, props: propsOption,beforeCreate, created, beforeMount, mounted, beforeUpdate, updated} = componentOptions;

    // 设置调用 beforeCreate 钩子 beforeCreate && beforeCreate(); // 调用data函数获取原始数据,并调用reactive函数将数据包装为响应式数据 const state = reactive(data()); // 解析出props和attrs数据 const [props, attrs] = resolveProps(propsOption, vnode.props)

    //定义组件实例 const instance = { // 组件自身状态数据 data state, // 一个布尔值, 用来表示组件是否已经被挂载 isMounted: false, // 组件所渲染的内容, subTree subTree: null, //将解析出的props数据用 shallowReactive 包裹,并定义到组件实例 props: shallowReactive(props), } //将组件实例设置到vnode上, 用于后续更新 vnode.component = instance;

    // 设置调用 created 钩子 created && create.call(state)

    // 将组件的render函数调用包装到effect中 effect(()=>{ // 调用组件的渲染函数,获得子树 const subTree = render.call(state, state); // 检查组件是否已经被挂载 if(!instance.isMounted){

    1. // 设置调用 beforeMount 钩子
    2. beforeMount && beforeMount.call(state)
    3. // 初次挂载,patch第一个参数null
    4. patch(null, subTree, container, anchor);
    5. // 将组件的isMounted设置为true,阻止更新时执行挂载。
    6. instance.isMounted = true;
    7. // 设置调用 mounted 钩子
    8. mounted && mounted.call(state);

    }else{

    1. // 设置调用 beforeUpdate 钩子
    2. beforeUpdate && beforeUpdate.call(state)
    3. // isMounted为true,执行更新。patch函数的第一个参数,为组件上次渲染的子树
    4. // 意思是,使用新的子树与上次渲染的子树进行打补丁操作
    5. patch(instance.subTree, subTree, container, anchor);
    6. // 设置调用 updated 钩子
    7. updated && updated.call(state)

    } // 更新组件实例子树 instance.subTree = subTree; }, { //指定副作用函数的调度器为 queueJob scheduler: queueJob }) }

// resolveProps 函数用于解析组件的 props 和 attrs数据 function resolveProps(options, propsData){ const props = {}; const attrs = {}; // 遍历pros 数据 for(const key in propsData){ if(key in options){ // 如果为组件传递的 props[key] = propsData[key]; }else{ // 将没有定义在props选项中的props数据,添加到attrs对象中 attrs[key] = propsData[key]; } } // 返回 props 和attrs return [props, attrs]; }

  1. props发生变化,会触发父组件重新渲染
  2. ```javascript
  3. // 定义的父组件模板
  4. <MyComponent :title="title">
  5. // 定义父组件的虚拟DOM
  6. const vnode = {
  7. type: MyComponent,
  8. props: {
  9. title: "first"
  10. }
  11. }
  12. // 当props中title发生变化,父组件会重新执行渲染,
  13. const vnode = {
  14. type: MyComponent,
  15. props: {
  16. title: "change ..."
  17. }
  18. }

父组件进行更新,在更新过程中,渲染器发现父组件的subTree包含组件类型的虚拟节点,会调用patchComponent函数完成子组件的更新。

  1. funtion patch(n1, n2, container, anchor){
  2. if(n1 && n1.type !== n2.type){
  3. unmount(n1);
  4. n1= null;
  5. }
  6. const {type} = n2;
  7. if(typeof type === "string"){}
  8. else if(type === Text){}
  9. else if(type === Fragment){}
  10. else if(typeof type === "object"){
  11. // vnode.type的值 是选项对象
  12. if(!n1){
  13. mountComponent(n2, container, anchor);
  14. }else{
  15. // 更新组件
  16. patchComponent(n1, n2, anchor)
  17. }
  18. }
  19. }

patchComponent函数完成子组件的更新。父组件引起的子组件更新,子组件被动更新时,需要判断

  • 检查子组件是否真的需要更新,因为子组件的props可能不变
  • 如果需要更新,则更新子组件的props和slots等内容

    1. function patchComponent(n1, n2, anchor){
    2. // 获取组件实例, 即n1.component,同时让新组件的虚拟节点n2.component也指向组件实例
    3. const instance = (n2.component = n1.component);
    4. // 获取当前的props数据
    5. const {props} = instance;
    6. // 调用hasPropsChanged检测为子组件传递的props是否发生
    7. if(hasPropsChange(n1.props, n2.props)){
    8. //调用resolveProps函数获取新 props 数据
    9. const [nextProps] = resolveProps(n2.type.props, n2.props);
    10. // 更新props
    11. for(const k in nextProps){
    12. props[k] = nextProps[k]
    13. }
    14. // 删除不存在的props
    15. for(const k in props){
    16. if(!(k in nextProps)) delete props[k]
    17. }
    18. }
    19. }
    20. function hasPropsChanged(oldProps, newProps){
    21. const newKeys = Object.keys(newProps);
    22. // 如果新旧 props的数量变化,则说明有改变
    23. if(newKeys.lenght !== Object.keys(oldProps).length){
    24. return true;
    25. }
    26. for(let i=0; i< newKeys.length; i++){
    27. const key = newKeys[i];
    28. if(oldProps[key] !== newProps[key]) return true;
    29. }
    30. return false;
    31. }

    组件被动更新时,需要将组件实例添加到新的组件vnode对象上,即n2.component = n1.component,否则下次更新时无法取得组件实例。
    由于props数据和组件自身的状态数据都需要暴露到渲染函数中,并使得渲染函数能够通过this访问他们,因此将props数据和组件自身的状态数据封装为一个上下文对象。

    1. function mountComponent(vnode, container, anchor){
    2. // ...
    3. //定义组件实例
    4. const instance = {
    5. // 组件自身状态数据 data
    6. state,
    7. // 一个布尔值, 用来表示组件是否已经被挂载
    8. isMounted: false,
    9. // 组件所渲染的内容, subTree
    10. subTree: null
    11. }
    12. //将组件实例设置到vnode上, 用于后续更新
    13. vnode.component = instance;
    14. // 创建渲染上下文对象
    15. const renderContext = new Proxy(instance, {
    16. get(t, k,r){
    17. const {state, props} = t;
    18. if(state && k in state){ // 获取组件自身状态和props数据
    19. return state[k]
    20. }else if(k in props){ // 如果组件自身没有该数据,则从props中获取
    21. return props[k]
    22. }else{
    23. console.error('不存在 props或state')
    24. }
    25. },
    26. set(t, k, v, r){
    27. const {state, props} = t;
    28. if(state && k in state){
    29. state[k] = v;
    30. }else if(k in props){
    31. props[k]
    32. }else{
    33. console.error('不存在 props或state')
    34. }
    35. }
    36. })
    37. // 生命周期函数调用时,绑定渲染上下文
    38. created && created.call(renderContext);
    39. }

    给组件创建了一个代理对象,即渲染上下文对象。该对象的作用在于拦数据状态的读取和设置操作,每当渲染函数和生命周期钩子中通过this来读取数据时,
    都会优先从组件的自身状态读取,如果组件本身没有对应的数据,则再从props数据中读取。最后将上下文对象作为渲染函数和生命周期钩子的this值。
    除了组件自身数据及props外,完整的组件还包含methods、computed等选项中定义的数据和方法。

    12.5 setup函数的作用和实现

    setup函数是vue3新增的组件选项,主要用于组合式API【composition】,用于组合逻辑、创建响应式数据、创建通用函数、注册生命周期钩子的能力。在组件整个生命周期中,setup函数只会被挂载时执行一次。它的返回值有2中情况:

  • 返回一个函数,作为组件的render函数

  • 返回一个对象,将对象中包含的数据暴露给模板使用

    【返回函数】

    1. const Comp = {
    2. setup(){
    3. //返回函数时,作为组件的渲染函数
    4. return ()=>{
    5. return {type: "div", children : "hello"}
    6. }
    7. }
    8. }

    这种方式用于组件不是以模板【使用template标签】定义渲染内容。

    【返回对象】

    1. const Comp = {
    2. setup(){
    3. const count = ref(0);
    4. return {count}
    5. },
    6. render(){
    7. return {type: 'div', children: `count is: ${this.count}`}
    8. }
    9. }

    将setup返回的对象暴露给render函数使用,可以通过this访问到暴露出的响应式对象。
    setup接收两个参数:第一个props数据对象;第二个参数也是对象,{attrs、emits、slots、expose};

  • props对象为外部给组件传递的属性数据

  • 第二个参数setupContext对象,保存着与组件接口相关的数据和方法

    • slots: 组件接收的插槽
    • emit: 一个函数,用于向父组件发送自定义事件
    • attrs:当为组件传递props时,那些没有显示声明为props属性的数据会存储到attrs对象中。
    • expose:用来显示的向外暴露组件数据,和vue2中的ref类型。 ```javascript function mountComponent(vnode, container, anchor){ const componentOptions = vnode.type; const {render, data, props: propsOption,beforeCreate, created, beforeMount, mounted, beforeUpdate, updated} = componentOptions;

    // 设置调用 beforeCreate 钩子 beforeCreate && beforeCreate(); // 调用data函数获取原始数据,并调用reactive函数将数据包装为响应式数据 const state = reactive(data()); // 解析出props和attrs数据 const [props, attrs] = resolveProps(propsOption, vnode.props)

    //定义组件实例 const instance = { // 组件自身状态数据 data state, // 一个布尔值, 用来表示组件是否已经被挂载 isMounted: false, // 组件所渲染的内容, subTree subTree: null, //将解析出的props数据用 shallowReactive 包裹,并定义到组件实例 props: shallowReactive(props), }

    /////———-setupContext 定义————-/// const setupContext = {attrs}; // attrs是第10行解构出来的 const setupResult = setup(shallowReadonly(instance.props), setupContext); // setupState存储由setup返回的数据 let setupState = null; //如果 setup 函数的返回值是函数,则将其作为渲染函数 if(typeof setupResult === ‘function’){ // 报告冲突 if(render) console.error(‘setup返回渲染函数,render选项被忽略’) render = setupResult }else{ // 如果 setup 的返回值不是函数, 作为数据状态赋值给setupState setupState = setupContext; }

    //将组件实例设置到vnode上, 用于后续更新 vnode.component = instance;

    // 创建渲染上下文对象 const renderContext = new Proxy(instance, { get(t, k,r){

    1. const {state, props} = t;
    2. if(state && k in state){ // 获取组件自身状态和props数据
    3. return state[k]
    4. }else if(k in props){ // 如果组件自身没有该数据,则从props中获取
    5. return props[k]
    6. }else{
    7. console.error('不存在 props或state')
    8. }

    }, set(t, k, v, r){

    1. const {state, props} = t;
    2. if(state && k in state){
    3. state[k] = v;
    4. }else if(k in props){
    5. props[k]
    6. }else{
    7. console.error('不存在 props或state')
    8. }

    } })

    // 设置调用 created 钩子 created && create.call(state)

    // 将组件的render函数调用包装到effect中 effect(()=>{ // 调用组件的渲染函数,获得子树 const subTree = render.call(state, state); // 检查组件是否已经被挂载 if(!instance.isMounted){

    1. // 设置调用 beforeMount 钩子
    2. beforeMount && beforeMount.call(state)
    3. // 初次挂载,patch第一个参数null
    4. patch(null, subTree, container, anchor);
    5. // 将组件的isMounted设置为true,阻止更新时执行挂载。
    6. instance.isMounted = true;
    7. // 设置调用 mounted 钩子
    8. mounted && mounted.call(state);

    }else{

    1. // 设置调用 beforeUpdate 钩子
    2. beforeUpdate && beforeUpdate.call(state)
    3. // isMounted为true,执行更新。patch函数的第一个参数,为组件上次渲染的子树
    4. // 意思是,使用新的子树与上次渲染的子树进行打补丁操作
    5. patch(instance.subTree, subTree, container, anchor);
    6. // 设置调用 updated 钩子
    7. updated && updated.call(state)

    } // 更新组件实例子树 instance.subTree = subTree; }, { //指定副作用函数的调度器为 queueJob scheduler: queueJob }) }

// resolveProps 函数用于解析组件的 props 和 attrs数据 function resolveProps(options, propsData){ const props = {}; const attrs = {}; // 遍历pros 数据 for(const key in propsData){ if(key in options){ // 如果为组件传递的 props[key] = propsData[key]; }else{ // 将没有定义在props选项中的props数据,添加到attrs对象中 attrs[key] = propsData[key]; } } // 返回 props 和attrs return [props, attrs]; }

  1. setup函数的简版实现
  2. - setupContext是一个对象
  3. - 通过检测setup返回值类型决定应该如何处理返回值。
  4. - 渲染上下文renderContext应该正确的处理setupState
  5. <a name="EDUxX"></a>
  6. ### 12.6 组件事件与emit实现
  7. emit用来发送组件的自定义事件
  8. ```javascript
  9. const MyComponent = {
  10. name:"MyComponent",
  11. setup(props, {emit}){
  12. //定义change事件
  13. emit("change", 1,23)
  14. return ()=>{
  15. return //...
  16. }
  17. }
  18. }

使用组件时,可以监听有emit函数定义的自定义事件。
<MyComponent @change="handler" />
对应的虚拟DOM:

  1. const CompVnode = {
  2. type: MyComponent,
  3. props: {
  4. onChange: handler
  5. }
  6. }

emit发射自定义事件的本质,根据事件名称去props数据对象去寻找对应的数据处理函数

  1. function mountComponent(vnode, container, anchor){
  2. // ...
  3. const instance = {
  4. // 组件自身状态数据 data
  5. state,
  6. // 一个布尔值, 用来表示组件是否已经被挂载
  7. isMounted: false,
  8. // 组件所渲染的内容, subTree
  9. subTree: null,
  10. //将解析出的props数据用 shallowReactive 包裹,并定义到组件实例
  11. props: shallowReactive(props),
  12. }
  13. // 定义emit 函数,它接收2个参数:event事件名称,payload传递给事件处理函数的参数
  14. function emit(event, ...payload){
  15. // 根据约定对事件名称添加on前缀,并将第一个字母大写
  16. const eventName = `on${event[0].toUpperCase() + event.slice(1)}`
  17. // 根据处理后的事件名称 去props中寻找 对应的事件处理函数
  18. const handler = instance.props[eventName];
  19. if(handler){
  20. // 调用事件处理函数并传递参数
  21. handler(...payload);
  22. }else{
  23. console.error(`事件不存在`)
  24. }
  25. }
  26. // 将emit 函数添加到setupContext中
  27. const setupContext = {attrs, emit}
  28. }
  • emit函数被调用是,根据约定对事件名称转换
  • 前面resolveProps定义过没有显示声明为props的属性,都会存储到attrs中,事件类型的props都不会出现在props中,导致无法根据事件名称在instance.props中找到对应事件处理函数。所以要调整resolveProps函数

    1. function resolveProps(options, propsData){
    2. const props = {}
    3. const attrs = {}
    4. for(const key in propsData){
    5. // 以字符串on开头的props,无论是否显示声明,都会添加到props数据中
    6. if(key in options || key.startsWith('on')){
    7. props[key] = propsData[key]
    8. } else{
    9. attrs[key] = propsData[key]
    10. }
    11. }
    12. return [props, attrs]
    13. }

    检查propsData的key值,是否以字符串 on 开头,如果是,则认为该属性是组件的自定义事件。即使没有显示的声明为props,也可以将它添加到最终的props数据对象中。

    12.7插槽的工作原理与实现

    slot插槽,是组件会预留一个位置,让用户调用组件时,可以自由传入内容。

    1. <template>
    2. <header><slot name="header"></header>
    3. <div>
    4. <slot name='body'></slot>
    5. </div>
    6. <footer><slot name="footer" /></footer>
    7. </template>

    调用组件时,可以根据插槽的名称,传入自定义的内容

    1. <MyComponent>
    2. <template #header>
    3. <h1>这里是 header 的插槽slot</h1>
    4. </template>
    5. <template #body>
    6. <div>这里是 body 的插槽slot</div>
    7. </template>
    8. <template #footer>
    9. <p>这里是 footer 的插槽slot</p>
    10. </template>
    11. </MyComponent>

    将上面MyComponent组件的模版编译成渲染函数

    1. function render(){
    2. return {
    3. type: MyComponent,
    4. children: {
    5. header(){
    6. return { type: "h1", children: "这里是 header 的插槽slot"}
    7. },
    8. body(){
    9. return { type: "div", children: "这里是 body 的插槽slot"}
    10. },
    11. footer(){
    12. return { type: "p", children: "这里是 footer 的插槽slot"}
    13. },
    14. }
    15. }
    16. }

    组件模板中的插槽内容被编译为插槽函数,插槽函数的返回值就是具体的插槽内容。
    而此时子组件 MyComponent 的模板会被编译为如下函数

    1. //MyComponent 组件模板的编译结果
    2. function render(){
    3. return [
    4. {type: "header", children: [this.$slots.header()] },
    5. {type: "body", children: [this.$slots.body()] },
    6. {type: "footer", children: [this.$slots.footer()] }
    7. ]
    8. }

    渲染插槽内容的过程,就是调用插槽函数并渲染由其返回的内容的过程。
    运行时,插槽则依赖于setupContext中的slots对象。

    1. function mountComponent(vnode, container, anchor){
    2. const slots = vnode.children || {};
    3. const instance = {
    4. state,
    5. props: shallowReactive(props),
    6. isMounted: false,
    7. subTree: null,
    8. slots
    9. }
    10. // ...
    11. const renderContext = new Proxy(instance, {
    12. get(t, k, r){
    13. const {state, props, slots} = t;
    14. // 当k为$slots时,直接返回组件实例上的slots
    15. if(k === "$slots") return slots;
    16. //...
    17. },
    18. set(t,k,v,r){}
    19. })
    20. }

    对渲染上下文对象renderContext代理对象的get拦截函数做特殊处理,当读取的键是$slots时,直接返回组件实例上的slots对象,用户可以通过this.$slots来访问插槽内容。

    12.8注册生命周期

    在vue3中,组合式API是用来注册生命周期钩子函数的,生命周期钩子函数定义在setup中

    1. import {onMounted} from 'vue'
    2. const MyComponent = {
    3. setup(){
    4. onMounted(()=>{
    5. console.log('mounted 1')
    6. })
    7. }
    8. }

    多个组件都存在生命周期钩子函数的调用,怎么能确定当前组件实例只触发自己身上定义的钩子函数。这需要定义一个变量currnetInstance,用它来存储当前组件实例,当组件初始化并执行setup函数之前,先将currentInstance设置为当前组件实例。

    1. let currentInstance = null;
    2. function setCurrentInstance(instance){
    3. currentInstance = instance;
    4. }

    由于生命周期钩子函数在setup中可以重复定义多个,需要在mountComponent函数中给生命周期钩子函数定义成数组类型。

    1. function mountComponent(vnode, container, anchor){
    2. const instance = {
    3. state,
    4. props: shallowReactive(props),
    5. isMounted: false,
    6. subTree: null,
    7. slots,
    8. mounted:[]
    9. }
    10. const setupContext = {attrs, emit, slots}
    11. setCurrentInstance(instance);
    12. const setupResult = setup(shallowReadonly(instance.props), setupContext)
    13. setCurrentInstance(null);
    14. }

    为了存储onMounted函数注册生命周期,需要在组件实例对象上添加instance.mounted数组。
    onMounted函数本身实现

    1. function onMounted(fn){
    2. if(currentInstance){
    3. currentInstance.mounted.push(fn)
    4. }else{
    5. console.error('onMounted只能在setup中定义')
    6. }
    7. }

    没有在setup中调用onMounted函数是错误的用法。
    最后在合适的时机调用instance.mounted数组中的生命周期

    1. function mountComponent(vnode, container, anchor){
    2. // ...
    3. effect(()=>{
    4. const subTree = render.call(renderContext,rendeContext);
    5. if(!instance.isMounted){
    6. instance.mounted && instance.mounted.forEach(hook => hook.call(renderContext))
    7. }else{
    8. //...
    9. }
    10. instance.subTree = subTree;
    11. }, {
    12. scheduler: queueJob
    13. })
    14. }

    在合适的时机遍历 instance.mounted 数组,并逐个执行该数组内的生命周期函数。

    13异步组件与函数式组件

    异步组件

    前端经常性的发送网络请求获取数据,改过程就是异步获取数据的过程。所以定义组件时,要能够支持异步,组件支持异步获取数据,并异步渲染组件。
    使用import()语法异步加载组件:

    1. <template>
    2. <CompA />
    3. <component :is="asyncComp">
    4. </template>
    5. <script>
    6. import {shallowRef} from "vue";
    7. import CompA from "CompA.vue";
    8. export default{
    9. components: {CompA},
    10. setup(){
    11. const asyncComp = shallowRef(null);
    12. // 异步加载组件
    13. import("CompB.vue").then(CompB => asyncComp.value = CompB );
    14. return { asyncComp }
    15. }
    16. }
    17. </script>

    这样简单实现了异步加载和渲染组件。
    但是异步加载组件还需要处理比较多的特殊情况:

  • 如果组件加载失败或超时,是否需要渲染error组件

  • 组件加载过程中,需要渲染loading组件
  • 组件加载失败后,是否重新加载

为了解决上面的这些问题,框架一般都会进行封装处理。

异步组件原理

vue内部封装的defineAsyncComponent函数

在定义组件时,可以直接使用defineAsyncComponent加载函数;

  1. <script>
  2. export default{
  3. components: {
  4. AsyncComp : defineAsyncComponent(()=>import("./compA"))
  5. }
  6. }
  7. </script>

使用defineAsyncComponent函数定义异步组件,简单方便,而且可以设置超时、error、loading、重试等机制。

  1. function defineAsyncComponent(loader){
  2. let innerComp = null; // 定义变量,用来存储异步加载的组件
  3. return {
  4. name: "AsyncComponentWrapper",
  5. setup(){
  6. const loaded = ref(false); //异步组件是否加载成功
  7. loader().then(c=>{
  8. innerComp = c;
  9. loaded.value = true;
  10. })
  11. return ()=>{
  12. return loaded.value ? {type: innerComp} : {type: Text, children: "loading"}
  13. }
  14. }
  15. }
  16. }
  • defineAsyncComponent函数本质时一个高阶组件,返回值时一个包装组件
  • 包装组件会根据加载器的状态来决定渲染什么内容
  • 通常占位内容是一个注释节点,组件没成功加载时,页面渲染一个注释节点。

    超时与error组件

    异步加载组件时,如果网络状态不好,可能造成长时间加载,如果时间过长,可以设置超时错误提醒。

    1. <script>
    2. export default{
    3. components: {
    4. AsyncComp : defineAsyncComponent({
    5. loader: ()=>import("./compA"),
    6. timeout: 2000, //超时时长
    7. errorComponent: ErrorComp //显示的错误组件
    8. })
    9. }
    10. }
    11. </script>

    定义好用户接口,可以具体实现。

    1. function defineAsyncComponent(options){
    2. if(typeof options === "function"){ //options可以是配置项,也可以是加载器
    3. options = {
    4. loader: options
    5. }
    6. }
    7. const {loader} = options;
    8. let innerComp = null; // 定义变量,用来存储异步加载的组件
    9. return {
    10. name: "AsyncComponentWrapper",
    11. setup(){
    12. const loaded = ref(false); //异步组件是否加载成功
    13. // 定义变量,判断是否超时
    14. // const timeout = ref(false);
    15. // 定义error,当错误发生时,用来存储错误对象
    16. let error = shallowRef(null);
    17. loader().then(c=>{
    18. innerComp = c;
    19. loaded.value = true;
    20. }).catch(err = > error.value = err);
    21. let timer = null;
    22. if(options.timeout){
    23. timer = setTimeout(()=>{
    24. //timeout.value = true
    25. //超时后,创建一个错误对象,并赋值给error.value
    26. const err = new Error(`Async component timed out after ${options.timeout}ms`);
    27. error.value = err;
    28. }, options.timeout)
    29. }
    30. onUnmounted(()=> clearTimeout(timer));
    31. const placeholder = {type: Text, children: "..."}
    32. return ()=>{
    33. if(loaded.value){
    34. return {type: innerComp}
    35. } else if(error.value && options.errorComponent){
    36. return {type: options.errorComponent, props: {error: error.value}}
    37. }else{
    38. return placeholder;
    39. }
    40. }
    41. }
    42. }
    43. }

    组件渲染是,只有error.value的值存在,且用户配置了 errorComponent 组件,就渲染errorComponent组件并将error.value的值作为该组件的proos传递。

    延时与loading组件

    异步组件加载受网络影响较大,加载过程可能很慢,在等待的过程中可以设置loading状态组件。这样有更好的用户体验,不会让用户觉得是卡死状态。

  • 显示loading组件需要在合适的时机,如果时间很短就能显示出组件内容,这时还渲染loading就会出现闪烁,需要设置一个超过多长时间,才显示loading组件。避免闪烁问题

    1. <script>
    2. export default{
    3. components: {
    4. AsyncComp : defineAsyncComponent({
    5. loader: ()=> import("./compA"),
    6. delay: 200, // 延迟200ms,如果还没加载出组件,则显示loading组件
    7. loadingComponent: { // 定义loading组件
    8. setup(){
    9. return ()=>{ return { type:"h2", children: "Loading..."}}
    10. }
    11. },
    12. timeout: 2000, //超时时长
    13. errorComponent: ErrorComp //显示的错误组件
    14. })
    15. }
    16. }
    17. </script>
  • delay: 设置延时展示loading组件的时长,超过该时间才显示loading

  • loadingComponent: 用于配置显示的loading组件

在defineAsyncComponent函数中进行实现

  1. function defineAsyncComponent(options){
  2. if(typeof options === "function"){ //options可以是配置项,也可以是加载器
  3. options = {
  4. loader: options
  5. }
  6. }
  7. const {loader} = options;
  8. let innerComp = null; // 定义变量,用来存储异步加载的组件
  9. return {
  10. name: "AsyncComponentWrapper",
  11. setup(){
  12. const loaded = ref(false); //异步组件是否加载成功
  13. // 定义变量,判断是否超时
  14. // const timeout = ref(false);
  15. // 定义error,当错误发生时,用来存储错误对象
  16. let error = shallowRef(null);
  17. // 一个标志,代表是否正在加载,默认为false
  18. let loading = ref(false);
  19. let loadingTimer = null;
  20. // 如果delay存在,则开启定时器,当延迟时长超过loading.value设置为true
  21. if(options.delay){
  22. loadingTimer = setTimeout(()=>{
  23. loading.value = true
  24. }, options.delay);
  25. }else{
  26. // 如果配置项没有delay,则直接标记为 loading
  27. loading.value =true;
  28. }
  29. loader().then(c=>{
  30. innerComp = c;
  31. loaded.value = true;
  32. }).catch(err = > error.value = err)
  33. .finally(()=>{
  34. loading.value = false;
  35. // 无论成功与否,最后都要清除延迟定时器
  36. clearTimeout(loadingTimer);
  37. });
  38. let timer = null;
  39. if(options.timeout){
  40. timer = setTimeout(()=>{
  41. //timeout.value = true
  42. //超时后,创建一个错误对象,并赋值给error.value
  43. const err = new Error(`Async component timed out after ${options.timeout}ms`);
  44. error.value = err;
  45. }, options.timeout)
  46. }
  47. onUnmounted(()=> clearTimeout(timer));
  48. const placeholder = {type: Text, children: "..."}
  49. return ()=>{
  50. if(loaded.value){
  51. return {type: innerComp}
  52. } else if(error.value && options.errorComponent){
  53. return {type: options.errorComponent, props: {error: error.value}}
  54. } else if(loading.value && options.loadingComponent){ // 渲染loading组件
  55. return {type: options.loadingComponent }
  56. } else{
  57. return placeholder;
  58. }
  59. }
  60. }
  61. }
  62. }
  • 使用loading变量,判断是否正在加载
  • 如果用户定义了延迟时间,则开启延迟定时器,再将loading.value设为true
  • 无论异步组件加载成功与否,都清除loading的延迟定时器
  • 在渲染函数中,如果组件正在加载,渲染用户定义的loading组件。

    重试机制

    重试是指当组件加载出错,重新发送加载组件的请求。异步组件加载失败后重试机制,与请求服务端接口失败后重试机制一样。
    模拟接口重试请求: ```javascript function fetch(){ return new Promise((resolve, reject) =>{ setTimeout(()=>{ reject(“error …”) }, 1000) }) }

function load(onError){ // 请求接口,得到Promise实例 const p = fetch(); // 捕获错误 return p.catch(err => { return new Promise((resolve, reject)=>{ // retry函数,执行重试。重新调用load函数并发送请求 const retry = () => resolve(load(onError)) const fail = () => reject(err) onError(retry, fail) }) }) }

  1. 通过给onError传递retryfail参数,这样用户就可以在错误发生时,主动选择重试或抛出错误。
  2. ```javascript
  3. load(
  4. (retry) => { retry() }
  5. ).then(res => { console.log(res) } );

基于接口请求失败的重试机制,来实现组件异步加载失败的重试

  1. function defineAsyncComponent(options){
  2. if(typeof options === "function"){ //options可以是配置项,也可以是加载器
  3. options = {
  4. loader: options
  5. }
  6. }
  7. const {loader} = options;
  8. let innerComp = null; // 定义变量,用来存储异步加载的组件
  9. //记录重试次数
  10. let retries = 0;
  11. //封装load函数 用来加载异步组件
  12. function load(){
  13. return loader().catch(err=>{
  14. // 如果用户知道onError回调,则将控制权交给用户
  15. if(options.onError){
  16. return new Promise((resolve, reject) => {
  17. // 重试
  18. const retry = ()=>{
  19. resolve(load());
  20. retries++
  21. }
  22. const fail = ()=> reject(err);
  23. options.onError(retry, fail, retries)
  24. })
  25. }else{
  26. throw err
  27. }
  28. })
  29. }
  30. return {
  31. name: "AsyncComponentWrapper",
  32. setup(){
  33. const loaded = ref(false); //异步组件是否加载成功
  34. // 定义变量,判断是否超时
  35. // const timeout = ref(false);
  36. // 定义error,当错误发生时,用来存储错误对象
  37. let error = shallowRef(null);
  38. // 一个标志,代表是否正在加载,默认为false
  39. let loading = ref(false);
  40. let loadingTimer = null;
  41. // 如果delay存在,则开启定时器,当延迟时长超过loading.value设置为true
  42. if(options.delay){
  43. loadingTimer = setTimeout(()=>{
  44. loading.value = true
  45. }, options.delay);
  46. }else{
  47. // 如果配置项没有delay,则直接标记为 loading
  48. loading.value =true;
  49. }
  50. loader().then(c=>{
  51. innerComp = c;
  52. loaded.value = true;
  53. }).catch(err = > error.value = err)
  54. .finally(()=>{
  55. loading.value = false;
  56. // 无论成功与否,最后都要清除延迟定时器
  57. clearTimeout(loadingTimer);
  58. });
  59. let timer = null;
  60. if(options.timeout){
  61. timer = setTimeout(()=>{
  62. //timeout.value = true
  63. //超时后,创建一个错误对象,并赋值给error.value
  64. const err = new Error(`Async component timed out after ${options.timeout}ms`);
  65. error.value = err;
  66. }, options.timeout)
  67. }
  68. onUnmounted(()=> clearTimeout(timer));
  69. const placeholder = {type: Text, children: "..."}
  70. return ()=>{
  71. if(loaded.value){
  72. return {type: innerComp}
  73. } else if(error.value && options.errorComponent){
  74. return {type: options.errorComponent, props: {error: error.value}}
  75. } else if(loading.value && options.loadingComponent){ // 渲染loading组件
  76. return {type: options.loadingComponent }
  77. } else{
  78. return placeholder;
  79. }
  80. }
  81. }
  82. }
  83. }

函数式组件

函数式组建的本质是一个普通函数,该函数返回值是虚拟DOM。
函数组件使用:

  1. function MyFunComp(props){
  2. return { type: "h1", children: props.title }
  3. }
  4. // 定义props
  5. MyFunComp.props = { title: String}

调整patch函数支持vnode.type的类型为function函数类型。

  1. function patch(n1, n2, container, anchor){
  2. if(n1.type !== n2.type){
  3. unmount(n1);
  4. n1 = null;
  5. }
  6. cosnt {type} = n2;
  7. if(typeof type==="string"){
  8. }else if(type === Text){}
  9. else if(type === Fragment){}
  10. else if(typeof type === "object" || typeof type === "function"){
  11. if(!n1){
  12. mountComponent(n2, container, anchor);
  13. }else {
  14. patchComponent(n1, n2, anchor);
  15. }
  16. }
  17. }

patch函数内部,检测vnode.type 的类型来判断组件的类型

  • 如果vnode.type 是一个对象,则它是一个有状态的组件,vnode.type是组件选项对象
  • 如果vnode.type是一个函数,则它是函数式组件

mountComponent 完成函数挂载,patchComponent完成函数更新;
修改 mountComponent 函数,让它支持挂载函数式组件

  1. function mountComponent(vnode, container, anchor){
  2. const isFunctional = typeof vnode.type === 'function'
  3. let componentOptions = vnode.type;
  4. if(isFunctional){
  5. componentOptions = {
  6. render: vnode.type,
  7. props: vnode.type.props
  8. }
  9. }
  10. }

实现对函数式组件的兼容。修改 mountComponent 函数内检查组件的类型。如果是函数式组件,则直接将组件函数作为组件选项对象的 render 选项, 并将组件函数的静态props属性作为组件的props选项。

14内建组件和模块

vue框架中的3个重要的内建组件和模块,KeepAlive组件、Teleport组件、Transition组件。

1KeepAlive组件的实现原理

1组件的激活与失活

KeepAlive借鉴于HTTP协议,在HTTP协议中,KeepAlive为持久连接,允许多个请求或响应共用一个TCP连接,可以减少HTTP重复销毁和创建代理的性能消耗。
vue内建KeepAlive组件可以避免组件被频繁的销毁和创建。比如组件

  1. <template>
  2. <Tab v-if="currentTab === 1"> </Tab>
  3. <Tab v-if="currentTab === 2"> </Tab>
  4. <Tab v-if="currentTab === 3"> </Tab>
  5. </template>

根据变量currentTab的不同,渲染不同的 Tab 组件。当用户频繁的切换Tab时,会导致不同的卸载并重新挂载对应的Tab组件。为了避免性能开销浪费,可以使用KeepAlive组件来解决这个问题。

  1. <template>
  2. <KeepAlive>
  3. <Tab v-if="currentTab === 1"> </Tab>
  4. <Tab v-if="currentTab === 2"> </Tab>
  5. <Tab v-if="currentTab === 3"> </Tab>
  6. </KeepAlive>
  7. </template>

KeepAlive 的实现原理是 缓存策略,再加上特殊的挂载和卸载逻辑。
KeepAlive 组件在卸载时,不能将其真正卸载,否则再次就要重新挂载,而是把组件放到一个隐藏容器中,实现假卸载。当被挂载时,也不是执行真正的挂载逻辑,而是把隐藏容器中的组件显示出来。

  1. const KeepAlive = {
  2. __isKeepAlive: true,
  3. setup(props, {slots}){
  4. // 创建缓存对象
  5. const cache = new Map();
  6. const instance = currentInstance
  7. // KeepAlive组件实例上存在特殊的 keepAliveCtx对象,该对象由渲染器注入
  8. // move函数,将一段DOM移动到另一个容器中
  9. const {move, createElement} = instance.keepAliveCtx;
  10. // 创建隐藏容器
  11. const storageContainer = createElement("div");
  12. //KeepAlive组件实例上被添加的两个内部函数 _deActivate 和 _activate
  13. instance._deActivate = (vnode) => {
  14. move(vnode, storageContainer)
  15. }
  16. instance._activate = (vnode, container, anchor) => {
  17. move(vnode, container, anchor)
  18. }
  19. return ()=>{
  20. // KeepAlive 的默认插槽就是被 KeepAlive 的组件
  21. let rawNode = slots.default();
  22. if(typeof rawNode.type !== "object"){
  23. return rawNode;
  24. }
  25. //在挂载时,先获取缓存的组件 vnode
  26. const cachedVnode = cache.get(rawNode.type)
  27. if(cachedVnode){
  28. // 如果有缓存,直接使用缓存内容
  29. rawNode.component = cachedVnode.component;
  30. rawNode.keptAlive = true;
  31. }else{
  32. // 没有缓存,则将其添加到缓存中
  33. cache.set(rawNode.type, rawNode);
  34. }
  35. // 将shouldKeepAlive标记为true,避免渲染器真的将组件卸载。
  36. rawNode.shouldKeepAlive = true;
  37. // 将 keepAlive组件的实例添加到vnode上,可以在渲染器中访问
  38. rawNode.keepAliveInstance = instance;
  39. return rawNode;
  40. }
  41. }
  42. }
  • shouldKeepAlive:该属性被添加到内部组件的vnode对象上,当渲染器卸载内部组件时,可以检查该属性得知内部组件需要被KeepAlive,于是组件不会真的被卸载,而是调用 _deActivate 函数完成隐藏操作
  • keepAliveInstance: 在unmount函数中,通过 keepAliveInstance 来访问 _deActivate 函数。
  • keptAlive: 如果组件已经被缓存,会添加一个keptAlive标记。当再次渲染时,渲染器不会重新挂载,而是将其激活。

    1. // 添加 vnode.shouldKeepAlive的判断,标识该组件是否应该被keepAlive
    2. function unmount(vnode){
    3. if(vnode.type === Fragment){
    4. vnode.children.forEach(c => unmount(c))
    5. }else if(typeof vnode.type === "object"){
    6. if(vnode.sholdKeepAlive){
    7. // 如果有sholdKeepAlive属性,不是直接卸载它,而是调用 _deActivate方法
    8. vnode.keepAliveInstance._deActivate(vnode);
    9. }else{
    10. unmount(vnode.component.subTree)
    11. }
    12. return
    13. }
    14. const parent = vnode.el.parentNode;
    15. if(parent){
    16. parent.removeChild(vnode.el)
    17. }
    18. }
    1. // 如果组件的keptAlive为真,则渲染器不会重新挂载它,而是会通过 keepAliveInstance._activate函数激活它
    2. function patch(n1, n2, container, anchor){
    3. if(n1 && n1.type !== n2.type){
    4. unmount(n1);
    5. n1 = null;
    6. }
    7. const {type} = n2;
    8. if(typeof type==="string"){
    9. }else if(type === Text){}
    10. else if(type === Fragment){}
    11. else if(typeof type === "object" || typeof type === "function"){
    12. if(!n1){
    13. // 如果组件已经被keepAlive,则不会重新挂载它,而是调用_activate函数激活
    14. if(n2.keptAlive){
    15. n2.keepAliveInstance._activate(n2, container, anchor);
    16. } else{
    17. mountComponent(n2, container, anchor);
    18. }
    19. }else {
    20. patchComponent(n1, n2, anchor);
    21. }
    22. }
    23. }

    用于激活 _activate 和失活 _deActivate 组件的两个函数:

    1. const {move, createElement} = instance.keepAliveCtx;
    2. instance._deActivate = (vnode) => {
    3. move(vnode, storageContainer);
    4. }
    5. instance._activate = (vnode, container, anchor)=>{
    6. move(vnode, container, anchor);
    7. }

    move函数是由渲染器注入,调整mountComponent函数实现move。

    1. function mountComponent(vnode, container, anchor){
    2. const instance = {
    3. state,
    4. props: shallowReactive(props),
    5. isMounted: false,
    6. subTree: null,
    7. slots,
    8. mounted: [],
    9. // 只有keepAlive组件的实例下会有 keepAliveCtx属性
    10. keepAliveCtx: null
    11. }
    12. // 检查当前挂载的组件是否时 keepAlive组件
    13. const isKeepAlive = vnode.type.__isKeepAlive
    14. if(isKeepAlive){
    15. // 在 keepAlive 组件实例上添加 keepAliveCtx 对象
    16. instance.keepAliveCtx = {
    17. // move 函数来移动一段vnode
    18. move(vnode, container, anchor){
    19. // 本质上是将组件渲染内容移动到指定容器内。
    20. insert(vnode.component.subTree.el, container, anchor)
    21. },
    22. createElement
    23. }
    24. }
    25. }

    2 include 和exclude

    默认情况下,keepAlive会对所有的包含在内的组件进行缓存,但是有时用户期望只缓存特定的几个组件,这时就需要能够自定义缓存规则,让KeepAlive组件支持两个props,分别为include和exclude。

  • include 显示配置应该被缓存的组件

  • exclude 显示的配置不应该被缓存的组件

KeepAlive组件的props定义如下:

  1. const KeepAlive = {
  2. __isKeepAlive: true,
  3. props: {
  4. include: RegExp,
  5. exclude: RegExp
  6. },
  7. setup(props, {slots}){}
  8. }

在keepAlive组件被挂载时,会根据内部组件的名称进行匹配。根据include和exclude的正则,对内部组件的名称进行匹配,来判断是否需要进行缓存。

  1. const cache = new Map();
  2. const KeepAlive = {
  3. __isKeepAlive: true,
  4. props: {
  5. include: RegExp,
  6. exclude: RegExp
  7. },
  8. setup(props, {slots}){
  9. return ()=>{
  10. let rawVnode = slots.default();
  11. if(typeof rawVnode.type !== 'object'){
  12. return rawVnode
  13. }
  14. // 获取内部组件的 name
  15. const name = rawVnode.type.name;
  16. // 对name进行匹配
  17. if(name && (
  18. // 如果name 无法匹配 include
  19. (props.include && !props.include.test(name)) ||
  20. // name被exclude匹配
  21. (props.exclude && props.exclude.test(name))
  22. )){
  23. // 直接渲染内部组件,不进行缓存
  24. return rawVnode
  25. }
  26. }
  27. }
  28. }

3 缓存管理

使用Map对象进行缓存管理,该Map对象的键是组件选项对象,即vnode.type属性的值;Map对象的值时用于描述组件的vnode对象。
缓存的处理逻辑:

  • 如果缓存存在,则继承组件实例,并将用于描述组件的vnode对象标记为keptAlive,这样渲染器就不会重新创建新的组件实例;
  • 如果缓存不存在,则设置缓存

    1. // 使用 组件选项对象 rawVnode.type 作为键去缓存中查找
    2. const cachedVnode = cache.get(rawVnode.type)
    3. if(cachedVnode){
    4. rawVnode.component = cachedVnode.component
    5. rawVnode.keptAlive = true;
    6. }else{
    7. cache.set(rawVnode.type, rawVnode)
    8. }

    这样会导致缓存不断增加,极端情况下会占有大量内存。为了解决这个问题,必须设置缓存阈值,超过了设置就对缓存进行删除,可以对缓存设置max属性。

    1. <KeepAlive :max="2">
    2. <component :is="dynamicComp">
    3. </KeepAlive>

    当缓存组件超过2个后,会对之前的组件进行删除处理,并缓存最新一次的组件。
    vue提供用户实现自定义的缓存测量,在用户接口层面,体现在keepAlive组件新增了 cache 接口,允许用户指定缓存实例:

    1. <KeepAlive :cache="cache">
    2. <Comp />
    3. </KeepAlive>

    缓存实例需要满足固定的格式,一个基本的缓存实例的实现:

    1. const _cache = new Map();
    2. const cache: KeepAliveCache = {
    3. get(key){
    4. _cache.get(key)
    5. },
    6. set(key, value){
    7. _cache.set(key, value)
    8. },
    9. delete(key){
    10. _cache.delete(key)
    11. },
    12. forEach(fn){
    13. _cache.forEach(fn)
    14. }
    15. }

    在KeepAlive组件的内部实现中,如果用户提供了自定义的缓存实例,则直接使用该缓存实例来管理缓存。

    2Teleport组件的实现原理

    Teleport组件是vue3新增的内建组件。主要解决将虚拟DOM渲染为真实DOM时,最终渲染出来的真实DOM的层级结构与虚拟DOM的层级结构一致。这样在某些情况下会导致错误,

    1. <template>
    2. <div id="box" style="z-index: -1;">
    3. <Overlay />
    4. </div>
    5. </template>

    Overlay组件作为蒙层组件,该组件需要设置z-index的层级最高,可以遮挡住其它元素,但是上层id为box的组件设置了z-index: -1,这就导致即使设置Overlay组件的层级无穷大,也无法实现遮挡。
    为了解决这个问题,vue3内建 Teleport 组件,传送门组件,可以将指定内容渲染到特定容器中,而不受DOM层级的限制。

    1. <template>
    2. <Teleport to="body">
    3. <div class="overlay"></div>
    4. </Teleport>
    5. </template>
    6. <style scoped>
    7. .overlay{z-index: 9999;}
    8. </style>

    Teleport 组件执行渲染目标为body,to属性的值,该组件会直接把它的插槽内容渲染到body下,而不会按照模版的DOM层级来渲染。

    实现Teleport组件

    Teleport 组件需要渲染器底层支持,将Teleport组件的渲染逻辑从渲染器中分离出来。

    1. function patch(n1, n2, container, anchor){
    2. //...
    3. if(typeof type ==="object" && type.__isTeleport){
    4. // 组件选项中如果存在 __isTeleport 标识,则是 Teleport组件
    5. // 调用Teleport 组件选项中的 process函数,将控制权交接出去
    6. type.process(n1, n2, container, anchor){
    7. patch,
    8. patchChildren,
    9. unmount,
    10. move(vnode, container, anchor){
    11. insert(vnode.component ? vnode.component.subTree.el : vnode.el, container, anchor)
    12. }
    13. }
    14. }
    15. }

    Teleport组件的定义:组件有两个特殊属性 __isTeleport 和 process;

    1. const Teleport = {
    2. __isTeleport: true,
    3. process(n1, n2, container, anchor){
    4. // 通过 internals 参数,获取渲染器的内部方法
    5. const {patch} = internals;
    6. if(!n1){
    7. const target = typeof n2.props.to === "string" ?
    8. document.querySelector(n2.props.to): n2.props.to
    9. // 将n2.children 渲染到指定的挂载点
    10. n2.children.forEach(c => patch(null, c, target, anchor))
    11. }
    12. }
    13. }

    通过判断n1是否存在,来决定是挂载还是执行更新,如果执行挂载,则需要根据 props.to 属性的值来取得真正的挂载点,最后遍历 Teleport 组件的children属性,并逐一调用 patch 函数完成子节点的挂载。

    1. const Teleport = {
    2. __isTeleport: true,
    3. process(n1, n2, container, anchor){
    4. // 通过 internals 参数,获取渲染器的内部方法
    5. const {patch} = internals;
    6. if(!n1){
    7. const target = typeof n2.props.to === "string" ?
    8. document.querySelector(n2.props.to): n2.props.to
    9. // 将n2.children 渲染到指定的挂载点
    10. n2.children.forEach(c => patch(null, c, target, anchor))
    11. } else{ //处理更新
    12. patchChildren(n1, n2, container);
    13. if(n2.props.to !== n1.props.to){
    14. const newTarget = typeof n2.props.to === "string"? document.querySelector(n2.props.to) : n2.props.to;
    15. n2.children.forEach(c => move(c, newTarget))
    16. }
    17. }
    18. }
    19. }

    调整 patch 中move函数的实现

    1. function patch(n1, n2, container, anchor){
    2. //...
    3. if(typeof type ==="object" && type.__isTeleport){
    4. // 组件选项中如果存在 __isTeleport 标识,则是 Teleport组件
    5. // 调用Teleport 组件选项中的 process函数,将控制权交接出去
    6. type.process(n1, n2, container, anchor){
    7. patch,
    8. patchChildren,
    9. unmount,
    10. move(vnode, container, anchor){
    11. insert(vnode.component ? vnode.component.subTree.el : vnode.el, container, anchor)
    12. }
    13. }
    14. }
    15. }

    3Transtion组件的实现原理

    Transtion组件的核心原理:

  • 当DOM被挂载时,将动效附加到该DOM元素上

  • 当DOM元素被卸载,不要立即卸载DOM元素,等到附加到该DOM元素上的动效执行完后在卸载它

    原生DOM的过渡效果

    将 class 为box的元素,从x轴的200px移动到0;

    1. <!-- <div class="box"> </div> -->
    2. <style>
    3. .box{
    4. width:100px;
    5. height: 100px;
    6. background-color: red;
    7. }
    8. .enter-from {
    9. transform: translateX(200px);
    10. }
    11. .enter-to {
    12. transform: translateX(0px);
    13. }
    14. .enter-active {
    15. transition: transform 1s ease-in-out;
    16. }
    17. </style>
    18. <script>
    19. // 创建 dom元素
    20. const el = document.createElement("div");
    21. el.classList.add("box");
    22. // 在dom元素被添加到页面之前,将初始状态和运动过程定义到元素上
    23. el.classList.add("enter-from");
    24. el.classList.add("enter-active");
    25. // 将元素添加到页面
    26. document.body.appendChild(el);
    27. </script>
  • 创建dom元素

  • 设置初始状态和运动过程定义到元素上
  • 将元素添加到页面

经过三步,元素的初始状态就会生效。接下来添加切换元素时的处理。

入场动效过渡实现
  1. // 创建 dom元素
  2. const el = document.createElement("div");
  3. el.classList.add("box");
  4. // 在dom元素被添加到页面之前,将初始状态和运动过程定义到元素上
  5. el.classList.add("enter-from");
  6. el.classList.add("enter-active");
  7. // 将元素添加到页面
  8. document.body.appendChild(el);
  9. // 切换元素的状态
  10. el.classList.remove("enter-from");
  11. el.classList.add("enter-to")

这样设置后,动画并不生效,原因是浏览器会在当前帧绘制DOM元素,最终结果是浏览器直接将enter-to这个类的样式绘制出来。为了解决这个问题,需要在下一帧执行状态切换

  1. // 创建 dom元素
  2. const el = document.createElement("div");
  3. el.classList.add("box");
  4. // 在dom元素被添加到页面之前,将初始状态和运动过程定义到元素上
  5. el.classList.add("enter-from");
  6. el.classList.add("enter-active");
  7. // 将元素添加到页面
  8. document.body.appendChild(el);
  9. // 在下一帧, 切换元素的状态. requestAnimationFrame会在当前帧执行,所以要嵌套一层
  10. requestAnimationFrame(()=>{
  11. requestAnimationFrame(()=>{
  12. el.classList.remove("enter-from");
  13. el.classList.add("enter-to")
  14. })
  15. })

最后当过渡完成,将enter-to和enter-active这两个类从DOM元素上移除。

  1. // 创建 dom元素
  2. const el = document.createElement("div");
  3. el.classList.add("box");
  4. // 在dom元素被添加到页面之前,将初始状态和运动过程定义到元素上
  5. el.classList.add("enter-from");
  6. el.classList.add("enter-active");
  7. // 将元素添加到页面
  8. document.body.appendChild(el);
  9. // 在下一帧, 切换元素的状态. requestAnimationFrame会在当前帧执行,所以要嵌套一层
  10. requestAnimationFrame(()=>{
  11. requestAnimationFrame(()=>{
  12. el.classList.remove("enter-from");
  13. el.classList.add("enter-to")
  14. // 监听 transitionend 事件 完成收尾工作
  15. el.addEventListener("transitionend", ()=>{
  16. el.classList.remove("enter-to");
  17. el.classList.remove("enter-active");
  18. })
  19. })
  20. })

进场过渡的过程,分为三步:

  • beforeEnter节点:添加 enter-from和enter-active类
  • enter阶段: 在下一帧 移除 enter-from 类,添加enter-to
  • 结束:移除enter-to 和 enter-active类

    离场过渡的实现
    1. .leave-from {
    2. transform: translateX(200px);
    3. }
    4. .leave-to {
    5. transform: translateX(0px);
    6. }
    7. .leave-active {
    8. transition: transform 1s ease-in-out;
    9. }

    离场动效发生在DOM元素被卸载的时候

    1. el.addEventListener("click", ()=>{
    2. el.parentNode.removeChild(el);
    3. })

    当点击元素时,元素会被立即移除,根本没有执行过渡的机会。需要在元素被卸载时,不要将其立即卸载,而是等到过渡效果结束后在卸载。将卸载DOm元素的代码封装到函数中,该函数等待过渡结束后才被调用。

    1. el.addEventListener("click", ()=>{
    2. const performRemove = ()=> el.parentNode.removeChild(el);
    3. //设置初始状态
    4. el.classList.add("leave-from");
    5. el.classList.add("leave-active");
    6. // 强制reflow 使初始状态生效
    7. document.body.offsetHeight;
    8. // 在下一帧, 切换元素的状态. requestAnimationFrame会在当前帧执行,所以要嵌套一层
    9. requestAnimationFrame(()=>{
    10. requestAnimationFrame(()=>{
    11. //切换到结束状态
    12. el.classList.remove('leave-from');
    13. el.classList.add('leave-to');
    14. el.addEventListener("transitionend", ()=>{
    15. el.classList.remove("leave-to");
    16. el.classList.remove("leave-active");
    17. // 当过渡完成,调用 performRemove 函数,将DOM移除
    18. performRemove();
    19. })
    20. })
    21. })
    22. })

    在vue中实现Transition组件

    vue中是基于虚拟DOM节点实现过渡。可以将整个过渡过程抽象为几个阶段,如:beforeEnter、enter、leave;
    为了实现Transition组件,先模拟在DOM中的表现形式

    1. <template>
    2. <Transition> <div> 过渡元素 </div> </Transition>
    3. </template>

    将该模版编译为虚拟DOM后:

    1. function render(){
    2. return {
    3. type: Transition,
    4. children: {
    5. default(){
    6. return {type: "div", children: "过渡元素"}
    7. }
    8. }
    9. }
    10. }

    Transition组件的子节点,被编译为默认插槽,这与普通的组件行为一致。
    接下来实现Transition组件:

    1. const Transition = {
    2. name : "Transition",
    3. setup(props, {slots}){
    4. return ()=>{
    5. const innerVnode = slots.default();
    6. innerVnode.transition = {
    7. beforeEnter(el){
    8. el.classList.add("enter-from");
    9. el.classList.add("enter-active");
    10. },
    11. enter(el){
    12. // 在下一帧 切换状态
    13. nextFrame(()=>{
    14. el.classList.remove("enter-from");
    15. el.classList.add("enter-to");
    16. el.addEventListener("transitionend", ()=>{
    17. el.classList.remove("enter-to");
    18. el.classList.remove("enter-active");
    19. })
    20. })
    21. },
    22. leave(el, performRemove){
    23. el.classList.add("leave-from");
    24. el.classList.add("leave-active");
    25. // 强制reflow 使初始状态生效
    26. document.body.offsetHeight;
    27. nextFrame(()=>{
    28. el.classList.remove("leave-from");
    29. el.classList.add("leave-to");
    30. el.addEventListener("transitionend", ()=>{
    31. el.classList.remove("leave-to");
    32. el.classList.remove("leave-acitve");
    33. performRemove()
    34. })
    35. })
    36. }
    37. }
    38. return innerVnode;
    39. }
    40. }
    41. }

    需要调整mountElement函数和unmount函数,对Transition组件的支持。

    1. function mountElement(vnode, container, anchor){
    2. const el = vnode.el = createElement(vnode.type);
    3. if(typeof vnode.children === "string"){
    4. setElementText(el, vnode.children)
    5. }else if(Array.isArray(vnode.children)){
    6. vnode.children.forEach(child=>{
    7. patch(null, child, el)
    8. })
    9. }
    10. if(vnode.props){
    11. for(cosnt key in vnode.props){
    12. patchProps(el, key, null, vnode.props[key])
    13. }
    14. }
    15. // vnode是否需要过渡
    16. const needTransition = vnode.transition;
    17. if(needTransition){
    18. vnode.transition.beforeEnter(el);
    19. }
    20. insert(el, container, anchor);
    21. if(needTransition){
    22. // 调用 transition.enter 钩子,将DOM元素作为参数传递
    23. vnode.transition.enter(el);
    24. }
    25. }
    1. function unmount(vnode){
    2. const needTransition = vnode.transition;
    3. if(vnode.type === Fragment){
    4. vnode.children.forEach(c => unmount(c))
    5. return
    6. } else if(typeof vnode.type === "object"){
    7. if(vnode.shouldKeepAlive){
    8. vnode.keepAliveInstance._deActivate(vnode);
    9. }else{
    10. unmount(vnode.component.subTree);
    11. }
    12. return
    13. }
    14. cosnt parent = vnode.el.parentNode;
    15. if(parent){
    16. const performRemove = ()=> parent.removeChild(vnode.el);
    17. if(needTransition){
    18. vnode.transition.leave(vnode.el, performRemove)
    19. }else{
    20. performRemove();
    21. }
    22. }
    23. }

    15-17编译器

    15编译器核心

    1模板DSL【特定领域代码】的编译器

    编译器是一段程序,将 语言A 翻译成 语言B,其中语言A为源代码(source code),语言B为目标代码(target Code)。编译的过程通常包括:词法分析、语法分析、语义分析、中间代码生成、优化、目标代码生成。
    parser -> transformer -> code generator
    整个编译过程分为编译前端和编译后端。

  • 编译前端包含词法分析、语法分析、语义分析;它与目标平台无关,仅负责分析源代码

  • 编译后端,通常与目标平台相关,编译后端涉及中间代码生成和优化,以及目标代码生成。

vue的模板作为DSL,被编译为可以在浏览器中运行的js代码。
读《vue3设计与实现》笔记3 - 图1
vue模版编译器的目标代码其实就是渲染函数;vue模板编译器首先会对模板进行词法分析和语法分析,得到AST语法树,然后将AST 转换 [transform]为 JavaScript AST ,最后根据 JavaScript AST 生成JavaScript代码。
读《vue3设计与实现》笔记3 - 图2

AST

AST是 abstract syntax tree[抽象语法树]的首字母缩写。
把如下模板编译成 AST
<div><h1 v-if="ok"> vue template</h1></div>

  1. const ast = {
  2. type: "Root",
  3. children : [
  4. {type: "Element", tag: "div", children: [
  5. { type: "Element", tag: "h1",
  6. props: [
  7. { type: "Directive", name: "if",
  8. exp: {type: "Expression", content: "ok"}
  9. }
  10. ]}
  11. ]}
  12. ]
  13. }

AST其实就是一个具有层次结构的对象,模板AST具有与模板同构的嵌套结构。每个AST都有一个逻辑上的根节点,类型为Root。模版中真正的根节点(div标签节点)则作为Root节点的children存在。

parse函数解析得到模版AST

读《vue3设计与实现》笔记3 - 图3
parse函数接收字符串模板作为参数,将解析后得到的AST作为返回值返回。生成模版AST。
然后继续将模板AST转换为 JavaScript AST,因为vue模板编译器的最终目标是生成渲染函数,而渲染函数的本质是JavaScript 代码。封装 transform 函数来完成模版AST到JavaScript AST的转换。
读《vue3设计与实现》笔记3 - 图4
const templateAST = parse(template);
const jsAST = transform(templateAST);
封装generate函数来完成渲染函数
读《vue3设计与实现》笔记3 - 图5
这一步也可以通过代码表达
const code = generate(jsAST);
读《vue3设计与实现》笔记3 - 图6

2parser的实现原理与状态机

vue模板编译器的基本结构和工作流程,主要分为三部分组件:

  • parser: 将模板字符串解析为模板AST
  • transformer: 将模板AST转为 JavaScript AST
  • generator:根据 JavaScript AST 生成渲染函数的js代码

parser解析器的入参是字符串模板,解析器会逐个读取字符串模板中的字符,并根据一定的规则将整个字符串切割为Token。Token代表词法标记。
读《vue3设计与实现》笔记3 - 图7
从图中可以看出:

  • 在初始状态下,当遇到字符 < 时,状态机 迁移到tag open state,即标签开始状态。
  • 遇到除字符 < 以外的字符,状态机迁移到 标签名称状态
  • 遇到标签 / ,进入结束标签状态

按照有限状态自动机的状态迁移过程,可以完成模板的标记化,最终生成一系列 Token。

  1. const template = `<p>Vue</p>`
  2. // 定义状态机 状态
  3. const State = {
  4. initial: 1,
  5. tagOpen: 2,
  6. tagName: 3,
  7. text: 4,
  8. tagEnd: 5,
  9. tagEndName: 6
  10. }
  11. // 辅助函数 用来判断是否是字母
  12. function isAlpha(char) {
  13. return char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z'
  14. }
  15. // 接收模板字符串作为参数, 并将模板切割为 Token 返回
  16. function tokenize(str) {
  17. // 状态机的 当前状态
  18. let currentState = State.initial
  19. // 用于缓存字符
  20. const chars = []
  21. // 生成的Token会存储在Tokens数字中,并作为函数的返回值返回
  22. const tokens = []
  23. // 使用while循环开启自动机,只要str没有被解析完,自动机会一直运行
  24. while(str) {
  25. // 查看第一个字符
  26. const char = str[0]
  27. // 使用switch 匹配状态
  28. switch (currentState) {
  29. // 状态机当前处于初始状态
  30. case State.initial:
  31. // 遇到标签 <
  32. if (char === '<') {
  33. // 状态机切换到标签开始状态,从1-2的过程
  34. currentState = State.tagOpen
  35. // 消费掉字符 <
  36. str = str.slice(1)
  37. } else if (isAlpha(char)) {
  38. // 遇到字母,切换到文本状态
  39. currentState = State.text
  40. // 把字母缓存到chars 数组中
  41. chars.push(char)
  42. // 消费当前字符
  43. str = str.slice(1)
  44. }
  45. break
  46. // 状态机处于标签开始状态
  47. case State.tagOpen:
  48. if (isAlpha(char)) {
  49. // 遇到字母,切换到标签名称状态
  50. currentState = State.tagName
  51. // 把字母缓存到chars 数组中
  52. chars.push(char)
  53. str = str.slice(1)
  54. } else if (char === '/') {
  55. // 遇到字符 /,切换到结束状态
  56. currentState = State.tagEnd
  57. str = str.slice(1)
  58. }
  59. break
  60. // 状态机当前处于标签名称状态
  61. case State.tagName:
  62. if (isAlpha(char)) {
  63. chars.push(char)
  64. str = str.slice(1)
  65. } else if (char === '>') {
  66. currentState = State.initial
  67. tokens.push({
  68. type: 'tag',
  69. name: chars.join('')
  70. })
  71. chars.length = 0
  72. str = str.slice(1)
  73. }
  74. break
  75. // 状态机当前处于文本状态
  76. case State.text:
  77. if (isAlpha(char)) {
  78. chars.push(char)
  79. str = str.slice(1)
  80. } else if (char === '<') {
  81. currentState = State.tagOpen
  82. tokens.push({
  83. type: 'text',
  84. content: chars.join('')
  85. })
  86. chars.length = 0
  87. str = str.slice(1)
  88. }
  89. break
  90. // 状态机当前处于标签结束状态
  91. case State.tagEnd:
  92. if (isAlpha(char)) {
  93. currentState = State.tagEndName
  94. chars.push(char)
  95. str = str.slice(1)
  96. }
  97. break
  98. // 状态机当前处于结束标签名称状态
  99. case State.tagEndName:
  100. if (isAlpha(char)) {
  101. chars.push(char)
  102. str = str.slice(1)
  103. } else if (char === '>') {
  104. currentState = State.initial
  105. tokens.push({
  106. type: 'tagEnd',
  107. name: chars.join('')
  108. })
  109. chars.length = 0
  110. str = str.slice(1)
  111. }
  112. break
  113. }
  114. }
  115. // 最后返回tokens
  116. return tokens
  117. }
  118. console.log('tokens', tokenize(template))

代码还原了vue 解析器状态机变化的过程。通过有限状态机,能够将模板解析成token,进而可以用它们构建出AST 语法树,根据AST可以生成目标平台需要的对应代码(generator code);

3构造AST数据结构

定义 AST 数据结构,以便下一步编写一个方法,可以将tokens转换为AST。
假如有如下vue 模板: <div><p>Vue</p><p>Template</p></div>,一个根节点div标签,2个子节点p标签。每个p标签都有一个文本节点作为子节点。定义对应的AST结构:

  1. const ast = {
  2. type: "Root",
  3. children : [
  4. { type: "Element", tag: "div",
  5. children: [
  6. {type: "Element", tag: "p", children: [{type: "Text", content: "Vue"}]},
  7. {type: "Element", tag: "p", children: [{type: "Text", content: "Template"}]}
  8. ]
  9. }
  10. ]
  11. }

读《vue3设计与实现》笔记3 - 图8
将模板 <div><p>Vue</p><p>Template</p></div>经过tokenize函数解析出tokens。

  1. const tokens = tokenize("<div><p>Vue</p><p>Template</p></div>");
  2. // 生成tokens
  3. tokens = [
  4. {
  5. "type": "tag",
  6. "name": "div"
  7. },
  8. {
  9. "type": "tag",
  10. "name": "p"
  11. },
  12. {
  13. "type": "text",
  14. "content": "Vue"
  15. },
  16. {
  17. "type": "tagEnd",
  18. "name": "p"
  19. },
  20. {
  21. "type": "tag",
  22. "name": "p"
  23. },
  24. {
  25. "type": "text",
  26. "content": "Template"
  27. },
  28. {
  29. "type": "tagEnd",
  30. "name": "p"
  31. },
  32. {
  33. "type": "tagEnd",
  34. "name": "div"
  35. }
  36. ]

Tokens列表转换为AST的过程,其实是对Token列表进行扫描的过程。按照顺序扫描整个Token列表,直到列表中所有Token处理完毕。
读《vue3设计与实现》笔记3 - 图9
继续Tokens列表向下扫描
读《vue3设计与实现》笔记3 - 图10
读《vue3设计与实现》笔记3 - 图11
扫描到文本节点,创建一个类型为Text的AST节点 Text(Vue) ,然后将该节点作为当前栈顶节点的子节点。
继续扫描Token列表,遇到了结束标签。
读《vue3设计与实现》笔记3 - 图12
继续向下扫描,再次遇到开始p标签,重复上述过程,将 Element(p) 压入栈中。
读《vue3设计与实现》笔记3 - 图13
读《vue3设计与实现》笔记3 - 图14
读《vue3设计与实现》笔记3 - 图15
读《vue3设计与实现》笔记3 - 图16
扫描到最后一个Token,它是一个div结束标签,将当前栈顶节点的 Element(div) 从elementStack中弹出,所有Token扫描完毕,AST树构建完成。
读《vue3设计与实现》笔记3 - 图17
扫码Token列表,并构建AST的具体实现如下:

  1. const template = `<div><p>Vue</p><p>Template</p></div>`
  2. const State = {
  3. initial: 1,
  4. tagOpen: 2,
  5. tagName: 3,
  6. text: 4,
  7. tagEnd: 5,
  8. tagEndName: 6
  9. }
  10. function isAlpha(char) {
  11. return char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z'
  12. }
  13. function tokenize(str) {
  14. let currentState = State.initial
  15. const chars = []
  16. const tokens = []
  17. while(str) {
  18. const char = str[0]
  19. switch (currentState) {
  20. case State.initial:
  21. if (char === '<') {
  22. currentState = State.tagOpen
  23. str = str.slice(1)
  24. } else if (isAlpha(char)) {
  25. currentState = State.text
  26. chars.push(char)
  27. str = str.slice(1)
  28. }
  29. break
  30. case State.tagOpen:
  31. if (isAlpha(char)) {
  32. currentState = State.tagName
  33. chars.push(char)
  34. str = str.slice(1)
  35. } else if (char === '/') {
  36. currentState = State.tagEnd
  37. str = str.slice(1)
  38. }
  39. break
  40. case State.tagName:
  41. if (isAlpha(char)) {
  42. chars.push(char)
  43. str = str.slice(1)
  44. } else if (char === '>') {
  45. currentState = State.initial
  46. tokens.push({
  47. type: 'tag',
  48. name: chars.join('')
  49. })
  50. chars.length = 0
  51. str = str.slice(1)
  52. }
  53. break
  54. case State.text:
  55. if (isAlpha(char)) {
  56. chars.push(char)
  57. str = str.slice(1)
  58. } else if (char === '<') {
  59. currentState = State.tagOpen
  60. tokens.push({
  61. type: 'text',
  62. content: chars.join('')
  63. })
  64. chars.length = 0
  65. str = str.slice(1)
  66. }
  67. break
  68. case State.tagEnd:
  69. if (isAlpha(char)) {
  70. currentState = State.tagEndName
  71. chars.push(char)
  72. str = str.slice(1)
  73. }
  74. break
  75. case State.tagEndName:
  76. if (isAlpha(char)) {
  77. chars.push(char)
  78. str = str.slice(1)
  79. } else if (char === '>') {
  80. currentState = State.initial
  81. tokens.push({
  82. type: 'tagEnd',
  83. name: chars.join('')
  84. })
  85. chars.length = 0
  86. str = str.slice(1)
  87. }
  88. break
  89. }
  90. }
  91. return tokens
  92. }
  93. function parse(str) {
  94. // 先进行词法解析,生成Token 列表
  95. const tokens = tokenize(str)
  96. // 创建根 节点 Root
  97. const root = {
  98. type: 'Root',
  99. children: []
  100. }
  101. // 创建elementStack栈, 开始只有Root根节点
  102. const elementStack = [root]
  103. // 开启 while循环扫描 tokens,直到所有tokens都被扫描完毕
  104. while (tokens.length) {
  105. // 获取当前栈顶节点,作为父节点 parent
  106. const parent = elementStack[elementStack.length - 1]
  107. // 当前扫描的 Token
  108. const t = tokens[0]
  109. switch (t.type) {
  110. case 'tag':
  111. const elementNode = {
  112. type: 'Element',
  113. tag: t.name,
  114. children: []
  115. }
  116. parent.children.push(elementNode)
  117. elementStack.push(elementNode)
  118. break
  119. case 'text':
  120. const textNode = {
  121. type: 'Text',
  122. content: t.content
  123. }
  124. parent.children.push(textNode)
  125. break
  126. case 'tagEnd':
  127. elementStack.pop()
  128. break
  129. }
  130. tokens.shift()
  131. }
  132. // 最后返回 AST
  133. return root
  134. }
  135. const ast = parse(template)
  136. console.log(ast)

4转换模板AST

上面完成了模板AST的创建,接下来进行AST转换,就是对AST进行一系列操作,将其转换为新的AST过程。
读《vue3设计与实现》笔记3 - 图18

1节点转换

为了对AST进行转换,需要有个工具函数能够访问到AST的每个节点。

  1. function dump(node, indent = 0) {
  2. // 节点类型
  3. const type = node.type
  4. // 节点描述,根节点、Element节点、Text节点
  5. const desc = node.type === 'Root'
  6. ? ''
  7. : node.type === 'Element'
  8. ? node.tag
  9. : node.content
  10. // 打印节点的类型和描述信息
  11. console.log(`${'-'.repeat(indent)}${type}: ${desc}`)
  12. //如果是有children,则递归调用的打印子节点
  13. if (node.children) {
  14. node.children.forEach(n => dump(n, indent + 2))
  15. }
  16. }

使用上面定义的模板,调用dump函数看看输出结果

  1. const template = `<div><p>Vue</p><p>Template</p></div>`
  2. const State = {
  3. initial: 1,
  4. tagOpen: 2,
  5. tagName: 3,
  6. text: 4,
  7. tagEnd: 5,
  8. tagEndName: 6
  9. }
  10. function isAlpha(char) {
  11. return char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z'
  12. }
  13. function tokenize(str) {
  14. let currentState = State.initial
  15. const chars = []
  16. const tokens = []
  17. while(str) {
  18. const char = str[0]
  19. switch (currentState) {
  20. case State.initial:
  21. if (char === '<') {
  22. currentState = State.tagOpen
  23. str = str.slice(1)
  24. } else if (isAlpha(char)) {
  25. currentState = State.text
  26. chars.push(char)
  27. str = str.slice(1)
  28. }
  29. break
  30. case State.tagOpen:
  31. if (isAlpha(char)) {
  32. currentState = State.tagName
  33. chars.push(char)
  34. str = str.slice(1)
  35. } else if (char === '/') {
  36. currentState = State.tagEnd
  37. str = str.slice(1)
  38. }
  39. break
  40. case State.tagName:
  41. if (isAlpha(char)) {
  42. chars.push(char)
  43. str = str.slice(1)
  44. } else if (char === '>') {
  45. currentState = State.initial
  46. tokens.push({
  47. type: 'tag',
  48. name: chars.join('')
  49. })
  50. chars.length = 0
  51. str = str.slice(1)
  52. }
  53. break
  54. case State.text:
  55. if (isAlpha(char)) {
  56. chars.push(char)
  57. str = str.slice(1)
  58. } else if (char === '<') {
  59. currentState = State.tagOpen
  60. tokens.push({
  61. type: 'text',
  62. content: chars.join('')
  63. })
  64. chars.length = 0
  65. str = str.slice(1)
  66. }
  67. break
  68. case State.tagEnd:
  69. if (isAlpha(char)) {
  70. currentState = State.tagEndName
  71. chars.push(char)
  72. str = str.slice(1)
  73. }
  74. break
  75. case State.tagEndName:
  76. if (isAlpha(char)) {
  77. chars.push(char)
  78. str = str.slice(1)
  79. } else if (char === '>') {
  80. currentState = State.initial
  81. tokens.push({
  82. type: 'tagEnd',
  83. name: chars.join('')
  84. })
  85. chars.length = 0
  86. str = str.slice(1)
  87. }
  88. break
  89. }
  90. }
  91. return tokens
  92. }
  93. function parse(str) {
  94. // 先进行词法解析,生成Token 列表
  95. const tokens = tokenize(str)
  96. // 创建根 节点 Root
  97. const root = {
  98. type: 'Root',
  99. children: []
  100. }
  101. // 创建elementStack栈, 开始只有Root根节点
  102. const elementStack = [root]
  103. // 开启 while循环扫描 tokens,直到所有tokens都被扫描完毕
  104. while (tokens.length) {
  105. // 获取当前栈顶节点,作为父节点 parent
  106. const parent = elementStack[elementStack.length - 1]
  107. // 当前扫描的 Token
  108. const t = tokens[0]
  109. switch (t.type) {
  110. case 'tag':
  111. const elementNode = {
  112. type: 'Element',
  113. tag: t.name,
  114. children: []
  115. }
  116. parent.children.push(elementNode)
  117. elementStack.push(elementNode)
  118. break
  119. case 'text':
  120. const textNode = {
  121. type: 'Text',
  122. content: t.content
  123. }
  124. parent.children.push(textNode)
  125. break
  126. case 'tagEnd':
  127. elementStack.pop()
  128. break
  129. }
  130. tokens.shift()
  131. }
  132. // 最后返回 AST
  133. return root
  134. }
  135. const ast = parse(template)
  136. dump(ast)
  137. // ----输出结果----
  138. /*
  139. Root:
  140. --Element: div
  141. ----Element: p
  142. ------Text: Vue
  143. ----Element: p
  144. ------Text: Template
  145. */

接下来着手实现对 AST 节点的访问,从根AST节点开始,深度优先遍历节点

  1. function traverseNode(ast) {
  2. // 当前节点, ast本身是 Root 节点
  3. const currentNode = ast
  4. // 可以对当前阶段进行特殊操作处理
  5. if(currentNode.type === "Element" && currentNode.tag === "p"){
  6. currentNode.tag = "h1"; //将p标签转换为 h1 标签
  7. }
  8. // 如果有子节点,则递归调用 traverseNode 函数进行遍历
  9. const children = currentNode.children
  10. if (children) {
  11. for (let i = 0; i < children.length; i++) {
  12. traverseNode(children[i])
  13. }
  14. }
  15. }

经过转换后,再次打印节点。由于此过程会重复执行多次,所以封装成一个transform函数,用来对AST 进行转换

  1. // transform函数,封装了traverseNode和dump的调用,以及设置 context 对象
  2. function transform(ast) {
  3. // 调用 traverseNode 完成转换
  4. traverseNode(ast)
  5. // 打印 AST 信息
  6. console.log(dump(ast))
  7. }

可以看到所有的p标签被转换为h1标签。

  1. Root:
  2. --Element: div
  3. ----Element: h1
  4. ------Text: Vue
  5. ----Element: h1
  6. ------Text: Template

另外还可以对文本节点的内容重复打印2次。

  1. function traverseNode(ast) {
  2. const currentNode = ast
  3. // 可以对当前阶段进行特殊操作处理
  4. if(currentNode.type === "Element" && currentNode.tag === "p"){
  5. currentNode.tag = "h1"; //将p标签转换为 h1 标签
  6. }
  7. // 对 Text 节点类型,重复文本内容2边
  8. if(currentNode.type === "Text"){
  9. currentNode.content = currentNode.content.repeat(2)
  10. }
  11. const children = currentNode.children
  12. if (children) {
  13. for (let i = 0; i < children.length; i++) {
  14. traverseNode(children[i])
  15. }
  16. }
  17. }
  18. // 打印结果
  19. Root:
  20. --Element: div
  21. ----Element: h1
  22. ------Text: VueVue
  23. ----Element: h1
  24. ------Text: TemplateTemplate

完整代码示例
随着功能的增加,traverseNode中包含太多逻辑处理,会越来越臃肿,可以对该函数进行解耦。

  1. // 接收第二个参数 context
  2. function traverseNode(ast, context) {
  3. const currentNode = ast
  4. const transforms = context.nodeTransforms
  5. for (let i = 0; i < transforms.length; i++) {
  6. transforms[i](currentNode, context)
  7. }
  8. const children = currentNode.children
  9. if (children) {
  10. for (let i = 0; i < children.length; i++) {
  11. traverseNode(children[i])
  12. }
  13. }
  14. }
  15. // 调整 transform函数对 traverseNode的调用
  16. function transform(ast) {
  17. const context = {
  18. nodeTransforms: [transformElement, transformText],
  19. }
  20. // 调用 traverseNode 完成转换
  21. traverseNode(ast, context)
  22. // 打印 AST 信息
  23. console.log(dump(ast))
  24. }
  25. function transformElement(node) {
  26. if (node.type === 'Element' && node.tag === 'p') {
  27. node.tag = 'h1'
  28. }
  29. }
  30. function transformText(node) {
  31. if (node.type === 'Text') {
  32. node.content = node.content.repeat(2)
  33. }
  34. }

首先为 traverseNode 函数增加第二个参数 context,暂时把特殊的逻辑处理函数放到context.nodeTransforms数组中,然后使用for循环遍历数组,逐个调用注册在其中的回调函数,最后将当前节点currentNode和context对象
分别作为参数传递给回调函数。
对处理逻辑进行单独的抽离,完整的示例代码

2转换上下文与节点操作

转换上下文context可以看作AST转换函数过程中的上下文数据。所有AST转换函数都可以通过context来共享数据。上下文对象中通常会维护程序的当前状态。

  1. function transform(ast) {
  2. const context = {
  3. // 增加currentNode,用来存储当前正在转换的节点
  4. currentNode: null,
  5. // 增加 childIndex,用来存储当前节点在父节点的children中的位置索引
  6. childIndex: 0,
  7. // 增加 parent ,用来存储当前转换节点的父节点
  8. parent: null,
  9. replaceNode(node) {
  10. context.currentNode = node
  11. context.parent.children[context.childIndex] = node
  12. },
  13. removeNode() {
  14. if (context.parent) {
  15. context.parent.children.splice(context.childIndex, 1)
  16. context.currentNode = null
  17. }
  18. },
  19. nodeTransforms: [
  20. transformElement,
  21. transformText
  22. ]
  23. }
  24. // 调用 traverseNode 完成转换
  25. traverseNode(ast, context)
  26. // 打印 AST 信息
  27. console.log(dump(ast))
  28. }

然后在 traverseNode 函数中设置转换上下文对象中的数据。

  1. function traverseNode(ast, context) {
  2. // 设置当前转换的节点信息 context.currentNode
  3. context.currentNode = ast
  4. const transforms = context.nodeTransforms
  5. for (let i = 0; i < transforms.length; i++) {
  6. transforms[i](context.currentNode, context)
  7. if (!context.currentNode) return
  8. }
  9. const children = context.currentNode.children
  10. if (children) {
  11. for (let i = 0; i < children.length; i++) {
  12. // 递归调用 traverseNode 转换子节点之前,将当前节点设置为父节点
  13. context.parent = context.currentNode
  14. // 设置位置索引
  15. context.childIndex = i
  16. // 递归调用 traverseNode函数,将context参数继续传递
  17. traverseNode(children[i], context)
  18. }
  19. }
  20. }

有了上下文数据context,可以实现节点替换 replaceNode 及节点删除removeNode 的操作。
可以在转换函数中使用 replaceNode 函数对AST中的节点进行替换。

  1. function transformText(node, context) {
  2. if (node.type === 'Text') {
  3. context.replaceNode({
  4. type: "Element",
  5. tag: "span"
  6. })
  7. }
  8. }

将文本节点类型 Text,替换为 span标签节点,而不是显示文本内容。

  1. // 转换后
  2. Root:
  3. --Element: div
  4. ----Element: h1
  5. ------Element: span
  6. ----Element: h1
  7. ------Element: span

还可以设置移除当前节点,在 transform 函数中的context,添加removeNode方法。调整 transformText 把替换节点改成删除节点。

  1. function transformText(node, context) {
  2. if (node.type === 'Text') {
  3. context.removeNode()
  4. }
  5. }
  1. Root:
  2. --Element: div
  3. ----Element: h1
  4. ----Element: h1

3进入和退出

在转换AST节点过程中,往往需要根据其子节点的情况来决定如何对当前节点进行转换,要求父节点的转换操作在所有子节点都转换完成后再执行。需要设计一种转换工作流,对节点的访问分为两个阶段:进入阶段和退出阶段。当函数处于进入阶段,先进入父节点,再进入子节点。当转换函数处于退出阶段时,则会先退出子节点,再退出父节点。重新调整 traverseNode 函数。

  1. function traverseNode(ast, context) {
  2. context.currentNode = ast
  3. // 增加退出阶段的回调函数 数组
  4. const exitFns = []
  5. const transforms = context.nodeTransforms
  6. for (let i = 0; i < transforms.length; i++) {
  7. // 转换函数可以返回另外一个函数,该函数可作为退出阶段的回调函数
  8. const onExit = transforms[i](context.currentNode, context)
  9. if (onExit) {
  10. // 将推出阶段的回调函数添加到 exitFns数组中
  11. exitFns.push(onExit)
  12. }
  13. if (!context.currentNode) return
  14. }
  15. const children = context.currentNode.children
  16. if (children) {
  17. for (let i = 0; i < children.length; i++) {
  18. context.parent = context.currentNode
  19. context.childIndex = i
  20. traverseNode(children[i], context)
  21. }
  22. }
  23. // 在节点处理的最后阶段,执行缓存到exitFns中的回调函数
  24. // 这里要反序执行
  25. let i = exitFns.length
  26. while (i--) {
  27. exitFns[i]()
  28. }
  29. }

给 transformElement 函数增加 return返回函数的处理,用来设置退出节点的操作

  1. function transformElement(node) {
  2. console.log(`进入:${JSON.stringify(node)}`)
  3. return () => {
  4. console.log(`退出:${JSON.stringify(node)}`)
  5. }
  6. }

完整代码示例

5将模板AST转换jsAST

vue框架会将模板编译成渲染函数,而渲染函数是由JavaScript代码构成,因此需要将模板AST转换为用于描述渲染函数的JavaScript AST。

  1. // 模版
  2. <div><p>Vue</p><p>Template</p></div>
  3. // 转换为渲染函数
  4. function render(){
  5. return h("div", [h("p", "vue"), h("p", "Template")])
  6. }

渲染函数的代码,是一个函数声明,首先描述JavaScript中的函数声明语句。一个函数声明语句包括以下部分:

  • id:函数的名称,是一个标识符 Identifier
  • params:函数参数,是一个数组
  • body:函数体,函数体可以包含多个语句,它也是一个数组

定义一个基本数据结构,来描述函数声明语句:

  1. const FunctionDeclNode = {
  2. type: "FunctionDecl",
  3. id: {
  4. type: "Identifier",
  5. name: "render"
  6. },
  7. params: [],
  8. body: [ {type: "ReturnStatement", return : null}]
  9. }

使用一个对象来描述JavaScript AST节点。每个节点都具有type字段,该字段表示节点的类型。
介绍完函数声明语句的节点结构,再来看一下渲染函数的返回值。渲染函数的返回的是虚拟DOM节点,是h函数的调用。可以使用CallExpression类型的节点来描述函数调用语句。

  1. const CallExp = {
  2. type: "CallExpression",
  3. callee: {
  4. type: "Identifier",
  5. name: "h"
  6. },
  7. arguments:[]
  8. }

类型为CallExpression的节点,有两个属性

  • callee:描述被调用函数的名称
  • arguments:被调用函数的参数,多个参数用数组表示

最外层的h函数的第一个参数是一个字符串字面量,可以使用类型为StringLiteral的节点来描述它。

  1. const Str = {
  2. type: "StringLiteral",
  3. value: "div"
  4. }

h函数的第二个参数是数组,可以使用ArrayExpression的节点来描述它

  1. const Arr = {
  2. type: "ArrayExpression",
  3. elements: []
  4. }

渲染函数的返回值:

  1. const FunctionDeclNode = {
  2. type: "FunctionDecl",
  3. id: {
  4. type: "Identifier",
  5. name: "render"
  6. },
  7. params: [],
  8. body: [ {type: "ReturnStatement", return : {
  9. type: "CallExpression",
  10. callee: {type: "Identifier", name: "h"},
  11. arguments: [
  12. {type: "StringLiteral", value: "div"},
  13. {type: "ArrayExpression", elements: [
  14. {type: "CallExpression", callee: {type: "Identifier", name: "h"},
  15. arguments:[
  16. {type: "StringLiteral", value: "p"},
  17. {type: "StringLiteral", value: "Vue"}
  18. ]
  19. },
  20. {type: "CallExpression", callee: {type: "Identifier", name: "h"},
  21. arguments:[
  22. {type: "StringLiteral", value: "p"},
  23. {type: "StringLiteral", value: "Template"}
  24. ]
  25. }
  26. ]}
  27. ]
  28. }}]
  29. }

以上这段JavaScriptAST的代码,它是对渲染函数代码的完整描述。接下来是编写转换函数,将模板AST转换为上面的JavaScript AST结构。
首先创建 JavaScript AST节点的辅助函数

  1. function createStringLiteral(value) {
  2. return {
  3. type: "StringLiteral",
  4. value
  5. };
  6. }
  7. function createIdentifier(name) {
  8. return {
  9. type: "Identifier",
  10. name
  11. };
  12. }
  13. function createArrayExpression(elements) {
  14. return {
  15. type: "ArrayExpression",
  16. elements
  17. };
  18. }
  19. function createCallExpression(callee, arguments) {
  20. return {
  21. type: "CallExpression",
  22. callee: createIdentifier(callee),
  23. arguments
  24. };
  25. }

为了转换AST,需要两个转换函数 transformText 和 transformElement ,分别用了处理标签节点和文本节点。

  1. // 转换文本节点
  2. function transformText(node) {
  3. if (node.type !== "Text") {
  4. return;
  5. }
  6. // 文本节点对应的JavaScript AST节点其实是一个字符串字面量
  7. // 将文本节点对应的JavaScript AST 节点添加到node.jsNode属性下
  8. node.jsNode = createStringLiteral(node.content);
  9. }
  10. // 转换标签节点
  11. function transformElement(node) {
  12. // 将转换代码编写在退出阶段的回调函数中,保证该节点的子节点全部被处理完毕
  13. return () => {
  14. // 如果被转换的节点不是元素节点,什么都不做
  15. if (node.type !== "Element") {
  16. return;
  17. }
  18. // 创建 h 函数调用语句
  19. const callExp = createCallExpression("h", [createStringLiteral(node.tag)]);
  20. // 处理 h 函数调用参数
  21. node.children.length === 1
  22. ? callExp.arguments.push(node.children[0].jsNode)
  23. : callExp.arguments.push(
  24. createArrayExpression(node.children.map((c) => c.jsNode))
  25. );
  26. // 将当前标签节点对于的 JavaScript AST添加到jsNode属性
  27. node.jsNode = callExp;
  28. };
  29. }

最后编写transformRoot函数实现对Root根节点的转换

  1. // 转换Root根节点
  2. function transformRoot(node) {
  3. // 将逻辑编写在退出阶段的回调函数中,保证子节点全部被处理完毕
  4. return () => {
  5. if (node.type !== "Root") {
  6. return;
  7. }
  8. // node是根节点,根节点的第一个子节点就是模板的根节点
  9. const vnodeJSAST = node.children[0].jsNode;
  10. // 创建render函数的声明语句节点, 将 vnodeJSAST 作为render 函数体的返回语句。
  11. node.jsNode = {
  12. type: "FunctionDecl",
  13. id: { type: "Identifier", name: "render" },
  14. params: [],
  15. body: [
  16. {
  17. type: "ReturnStatement",
  18. return: vnodeJSAST
  19. }
  20. ]
  21. };
  22. };
  23. }

经过这步处理,模板AST就转换为对应的JavaScript AST,并且可以通过根节点node.jsNode来访问转换后JavaScript AST。
完整代码示例

6根据 jsAST 生成代码

上面完成了JavaScript AST的构造,接下来根据JavaScript AST生成渲染函数的代码。代码生成的本质是字符串拼接的艺术。访问JavaScript AST的节点,为每一种类型的节点生成相符的JavaScript代码。

  1. function compile(template){
  2. // 模板ast
  3. const ast = parse(template);
  4. // 将模板ast转 为 JavaScript AST
  5. transform(ast);
  6. // 代码生成
  7. const code= generate(ast.jsNode);
  8. return code
  9. }

代码生成需要上下文对象,该对象用来维护代码生成过程的程序的运行状态

  1. function generate(node) {
  2. const context = {
  3. // 存储最终生成的渲染函数代码
  4. code: '',
  5. // 在生成代码时,通过调用 push 函数完成代码的拼接
  6. push(code) {
  7. context.code += code
  8. },
  9. // 当前缩进的位置
  10. currentIndent: 0,
  11. // 换行
  12. newline() {
  13. context.code += '\n' + ` `.repeat(context.currentIndent)
  14. },
  15. // 缩进
  16. indent() {
  17. context.currentIndent++
  18. context.newline()
  19. },
  20. // 删除缩进
  21. deIndent() {
  22. context.currentIndent--
  23. context.newline()
  24. }
  25. }
  26. // 调用 genNode函数 完成代码生成工作
  27. genNode(node, context)
  28. return context.code
  29. }

有了基础能力,开始编写genNode函数,来完成代码生成工作。

  1. function genNode(node, context) {
  2. switch (node.type) {
  3. case "FunctionDecl":
  4. genFunctionDecl(node, context);
  5. break;
  6. case "ReturnStatement":
  7. genReturnStatement(node, context);
  8. break;
  9. case "CallExpression":
  10. genCallExpression(node, context);
  11. break;
  12. case "StringLiteral":
  13. genStringLiteral(node, context);
  14. break;
  15. case "ArrayExpression":
  16. genArrayExpression(node, context);
  17. break;
  18. }
  19. }

在 genNode 内部,使用switch分支匹配不同类型的节点

  • 遇到 FunctionDecl 节点,使用genFunctionDecl函数为该类型节点生成对应的JavaScript代码
  • 遇到 ReturnStatement 节点,使用 genReturnStatement 函数为该类型节点生成对于的JavaScript代码
  • 遇到 CallExpression 节点,使用 genCallExpression 函数为该类型节点生成对于的JavaScript代码
  • 遇到 StringLiteral 节点,使用 genStringLiteral 函数为该类型节点生成对于的JavaScript代码
  • 遇到 ArrayExpression 节点,使用 genArrayExpression 函数为该类型节点生成对于的JavaScript代码

接下来完善代码生成工作,实现5个函数

  1. function genFunctionDecl(node, context) {
  2. const { push, indent, deIndent } = context;
  3. push(`function ${node.id.name} `);
  4. push(`(`);
  5. genNodeList(node.params, context);
  6. push(`) `);
  7. push(`{`);
  8. indent();
  9. node.body.forEach((n) => genNode(n, context));
  10. deIndent();
  11. push(`}`);
  12. }
  13. // genNodeList在genFunctionDecl函数内部调用,函数接受一个节点数组作为参数,并为每个节点递归调用genNode函数完成代码生成
  14. function genNodeList(nodes, context) {
  15. const { push } = context;
  16. for (let i = 0; i < nodes.length; i++) {
  17. const node = nodes[i];
  18. genNode(node, context);
  19. if (i < nodes.length - 1) {
  20. push(", ");
  21. }
  22. }
  23. }
  24. function genReturnStatement(node, context) {
  25. const { push } = context;
  26. push(`return `);
  27. genNode(node.return, context);
  28. }
  29. function genCallExpression(node, context) {
  30. const { push } = context;
  31. const { callee, arguments: args } = node;
  32. push(`${callee.name}(`);
  33. genNodeList(args, context);
  34. push(`)`);
  35. }
  36. function genStringLiteral(node, context) {
  37. const { push } = context;
  38. push(`'${node.value}'`);
  39. }
  40. function genArrayExpression(node, context) {
  41. const { push } = context;
  42. push("[");
  43. genNodeList(node.elements, context);
  44. push("]");
  45. }

配合生成器函数的实现,将得到符合预期的渲染函数代码;

  1. const ast = parse(`<div><p>Vue</p><p>Template</p></div>`);
  2. transform(ast);
  3. const code = generate(ast.jsNode);
  4. // 生成code代码
  5. // function render () {
  6. // return h('div', [h('p', 'Vue'), h('p', 'Template')])
  7. // }

完成代码示例

16解析器

上一章中编译器是包含有解析器的,这一章利用正则来优化解析器。

递归下降算法构造模板AST

定义一个状态表TextModes,描述预定义的文本模式,然后定义parse函数,内部定义上下文对象context,用来维护解析程序执行过程中程序的各种状态。接着调用parseChildren函数进行解析,该函数会返回解析后得到的子节点,并使用这些子节点作为children来创建Root根节点。最后parse函数返回根节点,完成模板AST构建。

  1. const TextModes = {
  2. DATA: 'DATA',
  3. RCDATA: 'RCDATA',
  4. RAWTEXT: 'RAWTEXT',
  5. CDATA: 'CDATA'
  6. }
  7. // 解析器函数,接收模板作为参数
  8. function parse(str) {
  9. // 定义上下文对象
  10. const context = {
  11. // source 是模板内容,用于解析过程中进行消费
  12. source: str,
  13. // 解析器当前处于文本模式,初始模式为 DATA
  14. mode: TextModes.DATA,
  15. advanceBy(num) {
  16. context.source = context.source.slice(num)
  17. },
  18. advanceSpaces() {
  19. const match = /^[\t\r\n\f ]+/.exec(context.source)
  20. if (match) {
  21. context.advanceBy(match[0].length)
  22. }
  23. }
  24. }
  25. // 调用 parseChildren函数进行解析,它返回解析后得到的子节点
  26. const nodes = parseChildren(context, [])
  27. // 解析器返回 Root 根节点
  28. return {
  29. type: 'Root',
  30. children: nodes
  31. }
  32. }

这段代码的实现思路和15章构建AST的思路不同。
上章是对模板内容进行标记化生成一系列Token,然后根据Token构建出模板AST。创建Token和构建模板AST可以同时进行。
上面代码中 parseChildren 函数是解析器的核心,递归的调用它不断的消费模板内容,parseChildren 函数会返回解析后得到的子节点。

  1. // 模板
  2. <p>1</p><p>2</p>
  3. // 经过 parseChildren 函数解析后,得到2个 p 节点组成的数组
  4. [
  5. {type: "Element", tag: "p", children: [/*....*/]},
  6. {type: "Element", tag: "p", children: [/*....*/]},
  7. ]

parseChildren 函数本质也是一个状态机,该状态机有多少状态取决于子节点的类型数量。

  • 标签节点
  • 文本插值节点 {{val}}
  • 普通文本节点 text
  • 注释节点
  • CDATA 节点: <![CDATA[ xxx ]]>

读《vue3设计与实现》笔记3 - 图19

  • 遇到字符 < 时,进入临时状态
    • 如果下一个字符匹配正则/a-z/i, 则认为是一个标签节点,调用parseElement函数
    • 遇到 <!— 开头,则认为是注释节点,调用 parseComment 函数
    • 遇到<![CDATA[ 开头, 则认为是一个 CDATA 节点,调用 parseCDATA 函数
  • 如果字符串以 {{ 开头,则认为这是一个插值节点,调用 parseInterpolation 函数
  • 其它情况作为普通文本,调用parseText 函数完成文本节点的解析

    1. function parseChildren(context, ancestors) {
    2. // 定义 nodes 数组存储子节点,将作为最终的返回值
    3. let nodes = []
    4. // 从上下文对象中取的当前状态
    5. const { mode } = context
    6. // 开启while循环,满足条件就会一直对字符串进行解析
    7. while(!isEnd(context, ancestors)) {
    8. let node
    9. if (mode === TextModes.DATA || mode === TextModes.RCDATA) {
    10. if (mode === TextModes.DATA && context.source[0] === '<') {
    11. if (context.source[1] === '!') {
    12. if (context.source.startsWith('<!--')) {
    13. // 注释
    14. node = parseComment(context)
    15. } else if (context.source.startsWith('<![CDATA[')) {
    16. // CDATA
    17. node = parseCDATA(context, ancestors)
    18. }
    19. } else if (context.source[1] === '/') {
    20. // 结束标签
    21. } else if (/[a-z]/i.test(context.source[1])) {
    22. // 标签
    23. node = parseElement(context, ancestors)
    24. }
    25. } else if (context.source.startsWith('{{')) {
    26. // 解析插值
    27. node = parseInterpolation(context)
    28. }
    29. }
    30. // node 不存在,说明处于其它模式
    31. if (!node) {
    32. // 解析文本节点
    33. node = parseText(context)
    34. }
    35. // 将节点添加到 nodes 数组中
    36. nodes.push(node)
    37. }
    38. return nodes
    39. }

    parseChildren 函数用来解析子节点,while 循环一定要遇到父级节点的结束标签才会停止。
    解析器进入标签节点状态,并调用 parseElement 函数进行解析。

    1. function parseElement(context, ancestors) {
    2. // 解析开始标签
    3. const element = parseTag(context)
    4. if (element.isSelfClosing) return element
    5. ancestors.push(element)
    6. if (element.tag === 'textarea' || element.tag === 'title') {
    7. context.mode = TextModes.RCDATA
    8. } else if (/style|xmp|iframe|noembed|noframes|noscript/.test(element.tag)) {
    9. context.mode = TextModes.RAWTEXT
    10. } else {
    11. context.mode = TextModes.DATA
    12. }
    13. // 递归调用 parseChildren 函数
    14. element.children = parseChildren(context, ancestors)
    15. ancestors.pop()
    16. if (context.source.startsWith(`</${element.tag}`)) {
    17. // 解析 结束标签
    18. parseTag(context, 'end')
    19. } else {
    20. console.error(`${element.tag} 标签缺少闭合标签`)
    21. }
    22. return element
    23. }

    parseChildren 函数是整个状态机的核心,状态迁移操作都在该函数内完成。在 parseChildren 函数运行过程中,为了处理标签节点,会调用parseElement解析函数,并会间接调用 parseChildren 函数,产生新的状态机。随着标签嵌套层次的增加,新的状态机会随着 parseChildren 函数被递归地调用而不断创建,这就是递归下降中递归含义。
    而上级 parseChildren 函数的调用用于构造上级模板AST节点,被递归调用的下级 parseChildren 函数则是用于构造下级模板AST节点,最终构造出一个模板AST树。

    状态机的开启与停止

    状态机何时停止?while循环应该在何时停止运行,涉及到isEnd()函数的逻辑判断。
    在调用parseElement函数解析标签节点时,会递归调用parseChildren函数,开启新的状态机。
    读《vue3设计与实现》笔记3 - 图20
    递归开启新的状态机
    读《vue3设计与实现》笔记3 - 图21
    向下递归遍历时,遇到p标签时,会调用parseElement函数解析解析,于是重复开启新的状态机,会把当前解析的标签节点压入父级节点栈,然后递归的调用parseChildren函数,开始状态机2。此时有2个状态机同时运行。而且此时状态机2拥有程序的执行权,持续解析模板直到遇到结束标签

    。这是因为一个结束标签,并且在父级节点栈中存在与该结束标签同名的标签节点。状态机2停止运行,并弹出父级节点栈中处于栈顶的节点。
    读《vue3设计与实现》笔记3 - 图22
    继续递归解析,再次遇到

    标签,,再次开启新的状态机3
    读《vue3设计与实现》笔记3 - 图23
    此时状态机3拥有程序的执行权,会继续解析模板,直到遇到结束标签

    。 遇到标签

    ,停止运行状态机3
    读《vue3设计与实现》笔记3 - 图24
    当解析器遇到开始标签时,会将标签压入父级节点栈,同时开启新的状态机。当解析器遇到结束标签时,并且父级节点栈中存在与该标签同名的开始标签时,会停止当前正在运行的状态机。
    根据以上规则,可以得出isEnd函数的逻辑

    1. function isEnd(context, ancestors){
    2. if(!context.source) return true;
    3. const parent = ancestors[ancestors.length -1];
    4. if(parent && context.source.startWith(`</${parent.tag}`)){
    5. return true
    6. }
    7. }

    处理错误节点

    当模板是<div><span></div></span>时,按照上面的parseChildren函数解析会出错,无法遇到正确的结束标签。
    使用将模板内容完整解析完毕后,解析器再打印错误信息。

    1. function isEnd(context, ancestors) {
    2. if (!context.source) return true
    3. // 与节点栈内全部的节点比较
    4. for (let i = ancestors.length - 1; i >= 0; --i) {
    5. if (context.source.startsWith(`</${ancestors[i].tag}`)) {
    6. return true
    7. }
    8. }
    9. }

    在调用parseElement解析函数是,parseElement函数能够发现缺少闭合标签,于是打印错误信息。

    1. function parseElement(context, ancestors) {
    2. const element = parseTag(context)
    3. if (element.isSelfClosing) return element
    4. ancestors.push(element)
    5. if (element.tag === 'textarea' || element.tag === 'title') {
    6. context.mode = TextModes.RCDATA
    7. } else if (/style|xmp|iframe|noembed|noframes|noscript/.test(element.tag)) {
    8. context.mode = TextModes.RAWTEXT
    9. } else {
    10. context.mode = TextModes.DATA
    11. }
    12. element.children = parseChildren(context, ancestors)
    13. ancestors.pop()
    14. if (context.source.startsWith(`</${element.tag}`)) {
    15. parseTag(context, 'end')
    16. } else {
    17. console.error(`${element.tag} 标签缺少闭合标签`)
    18. }
    19. return element
    20. }

    解析标签节点

    解析标签节点使用parseTag函数,parseTag接收2个参数,第一个是Context上下文对象,第二个是判断是开始标签还是结束标签,默认值为start。无论处理开始标签还是结束标签,parseTag函数都会消费对应的内容,为了实现对应内容的消费,需要在上下文对象中新增两个工具函数,

    1. function parse(str) {
    2. // 上下文对象
    3. const context = {
    4. // 模板内容
    5. source: str,
    6. mode: TextModes.DATA,
    7. // advanceBy函数用来消费指定数量的字符,
    8. advanceBy(num) {
    9. // 根据给定字符数 num,截取位置num后的模板内容,并替换当前模板内容
    10. context.source = context.source.slice(num);
    11. },
    12. // 无论是开始标签还是结束标签,都可能存在无用的空白字符,例如 <div >
    13. advanceSpaces() {
    14. // 匹配空白字符
    15. const match = /^[\t\r\n\f ]+/.exec(context.source);
    16. if (match) {
    17. // 调用 advanceBy 函数,消费空白字符
    18. context.advanceBy(match[0].length);
    19. }
    20. }
    21. };
    22. const nodes = parseChildren(context, []);
    23. return {
    24. type: "Root",
    25. children: nodes
    26. };
    27. }

    有了advanceByadvanceSpaces函数后,就可以实现parseTag函数

    1. function parseTag(context, type = "start") {
    2. // 从上下文对象中 获取工具函数
    3. const { advanceBy, advanceSpaces } = context;
    4. // 处理开始标签 和 结束标签的正则表达式
    5. const match =
    6. type === "start"
    7. ? /^<([a-z][^\t\r\n\f />]*)/i.exec(context.source)
    8. : /^<\/([a-z][^\t\r\n\f />]*)/i.exec(context.source);
    9. const tag = match[1];
    10. // 消费正则表达式 匹配的全部内容
    11. advanceBy(match[0].length);
    12. // 消费标签中的 空白字符
    13. advanceSpaces();
    14. // 解析属性
    15. const props = parseAttributes(context);
    16. // 判断是否是自闭合标签
    17. const isSelfClosing = context.source.startsWith("/>");
    18. // 如果是自闭合标签,消费 /> 两个字符;非自闭合,消费一个字符 >
    19. advanceBy(isSelfClosing ? 2 : 1);
    20. // 返回标签节点
    21. return {
    22. type: "Element",
    23. tag,
    24. props,
    25. children: [],
    26. isSelfClosing
    27. };
    28. }

    parseTag函数几个关键点

  • 在完成正则匹配后,需要调用advanceBy函数消费正则匹配的全部内容

  • 根据上面给出的第三个正则匹配例子,由于标签中可能存在无用的空白字符,例如
    ,因此需要调用advanceSpaces函数消费空白字符
  • 消费正则匹配的内容后,检查剩余模板内容是否以字符串 />开头
    • 如果是,则说明当前解析的是一个自闭合标签,下次需要消费2个字符
    • 判断非自闭合标签,只用消费 > 一个字符。

经过上面处理后,parseTag函数会返回一个标签节点。parseElement 函数在得到有parseTag函数产生标签节点后,需要根据节点的类型完成文本模式切换。

  1. function parseElement(context, ancestors) {
  2. const element = parseTag(context);
  3. if (element.isSelfClosing) return element;
  4. ancestors.push(element);
  5. // 切换到正确的文本模式
  6. if (element.tag === "textarea" || element.tag === "title") {
  7. // parseTag解析得到标签 是<textarea>或<title>,则切换到RCDATA模式
  8. context.mode = TextModes.RCDATA;
  9. } else if (/style|xmp|iframe|noembed|noframes|noscript/.test(element.tag)) {
  10. // 解析得到的标签是<style>、<xmp>、<iframe>、<noembed>、<noframe>、<noscript>、切换到RAWTEXT模式
  11. context.mode = TextModes.RAWTEXT;
  12. } else {
  13. // 否则切换到 DATA 模式
  14. context.mode = TextModes.DATA;
  15. }
  16. element.children = parseChildren(context, ancestors);
  17. ancestors.pop();
  18. if (context.source.startsWith(`</${element.tag}`)) {
  19. parseTag(context, "end");
  20. } else {
  21. console.error(`${element.tag} 标签缺少闭合标签`);
  22. }
  23. return element;
  24. }

解析属性

parseTag解析整个标签,标签中还包含属性及指令,因此parseTag函数需要有能力处理开始标签中的属性和指令。<div id="foo" v-show="display"></div>标签中包含了id属性和v-show指令。因此parseTag函数中增加 parseAttributes 解析函数。

  1. function parseAttributes(context) {
  2. const { advanceBy, advanceSpaces } = context;
  3. const props = [];
  4. while (!context.source.startsWith(">") && !context.source.startsWith("/>")) {
  5. const match = /^[^\t\r\n\f />][^\t\r\n\f />=]*/.exec(context.source);
  6. const name = match[0];
  7. advanceBy(name.length);
  8. advanceSpaces();
  9. advanceBy(1);
  10. advanceSpaces();
  11. let value = "";
  12. const quote = context.source[0];
  13. const isQuoted = quote === '"' || quote === "'";
  14. if (isQuoted) {
  15. advanceBy(1);
  16. const endQuoteIndex = context.source.indexOf(quote);
  17. if (endQuoteIndex > -1) {
  18. value = context.source.slice(0, endQuoteIndex);
  19. advanceBy(value.length);
  20. advanceBy(1);
  21. } else {
  22. console.error("缺少引号");
  23. }
  24. } else {
  25. const match = /^[^\t\r\n\f >]+/.exec(context.source);
  26. value = match[0];
  27. advanceBy(value.length);
  28. }
  29. advanceSpaces();
  30. props.push({
  31. type: "Attribute",
  32. name,
  33. value
  34. });
  35. }
  36. return props;
  37. }

模板经过消费标签及空白字符,最后剩下 id="foo" v-show="display",这段内容才是 parseAttributes 函数要处理的内容。
读《vue3设计与实现》笔记3 - 图25
parseAttributes 函数消费模板的过程,就是不断的解析属性名、等号、属性值的过程。parseAttributes 函数会从左往右的顺序不断消费字符串。

  • 首先解析出第一个属性的名称id,并消费字符串 id,模板剩余内容="foo" v-show="display",在解析属性名称时,除了要消费属性名称之外,还要消费名称后面可能存在的空白字符。属性名称解析完毕后,模板剩余内容一定是以等号开头。
  • 然后开始消费等号字符,由于等号和属性值之间也有可能存在空白字符,所以也需要消费对应的空白字符,进一步操作后,模板剩余内容为 "foo" v-show="display"
  • 接下来处理属性值环节,如果属性被引号包裹,则消耗引号,处理引号时,要记得处理空白字符。如果没有被引号包裹,那么下一个空白字符之前的所有字符都是属性值。模板中属性值存在三种情况:
    • 属性值被双引号包裹: id=”foo”
    • 属性值被单引号包裹: id=’foo’
    • 属性值没有引号包裹: id=foo
  • 此时模板中还剩下一个智力,只需要重新执行上述步骤,即可完成v-show指令的解析。当v-show指令解析完毕后,将会遇到标签的结束部分,即字符>。这是parseAttributes 函数中的while循环将停止,完成属性和指令的解析。

parseAttributes 函数中有两个重要的正则表达式:

  • /^[^\t\r\n\f />][^\t\r\n\f />=]*/,用来匹配属性名称
  • /^[^\t\r\n\f >]+/用来匹配没有使用引号引用的属性值

读《vue3设计与实现》笔记3 - 图26

  • 部分A用于匹配一个位置,这个位置不能是空白字符,也不能是字符/ 或者>,并且字符串要以该位置开头
  • 部分B用于匹配0个或多个位置,这些位置不能是空白字符,也不能是字符 / > =。注意这些位置不能出现=号字符,这就实现了只匹配等于号之前的内容,即属性名称。

读《vue3设计与实现》笔记3 - 图27
该正则表达式从字符串的开始位置进行匹配,并且会匹配一个或多个非空字符、非字符> 。该正则表达式会一直对字符串进行匹配,直到遇到空白字符或者字符>停止,这就实现了属性值得提取。
解析模板后,将会得到AST

  1. const ast = {
  2. type:"Root",
  3. children: [
  4. {type: "Element", tag:"div",
  5. props: [{type: "Attribute", name: "id", value: "foo"},
  6. {type: "Attribute", name: "v-show", value: "display"}]
  7. }
  8. ]
  9. }

解析文本和解码HTML实体

解析文本

接下来解析文本模板 <div>Text</div>,解析器解析这段模板内容,先经过parseTag 函数处理,会消费掉开始标签<div>,处理完成后,剩余内容 Text</div>。接着解析器调用parseChildren函数,开启新的状态机来处理模板。
读《vue3设计与实现》笔记3 - 图28
状态机从初始状态1,进入状态7,即调用parseText函数处理文本内容。然后解析器会在模板中寻找下一个 <字符或插值定界符 {{的位置,记录索引I,解析器会从模板头部到索引I的位置截取内容。截取出来的字符串将作为文本节点的内容。Text</div>,parseText函数截取出文本内容 Text

  1. function parseText(context) {
  2. // endIndex 为文本内容的结尾索引,默认将整个模板剩余内容都作为文本内容
  3. let endIndex = context.source.length;
  4. // 寻找字符 < 的位置
  5. const ltIndex = context.source.indexOf("<");
  6. // 寻找字符 {{ 的位置
  7. const delimiterIndex = context.source.indexOf("{{");
  8. // 取ltIndex和当前 endIndex中较小的一个作为新的结尾的索引
  9. if (ltIndex > -1 && ltIndex < endIndex) {
  10. endIndex = ltIndex;
  11. }
  12. // 取delimiterIndex和当前endIndex中较小的一个作为新的结尾索引
  13. if (delimiterIndex > -1 && delimiterIndex < endIndex) {
  14. endIndex = delimiterIndex;
  15. }
  16. // 此时 endIndex是最终文本内容的结尾索引,调用slice函数截取文本内容
  17. const content = context.source.slice(0, endIndex);
  18. // 消耗文本内容
  19. context.advanceBy(content.length);
  20. return {
  21. type: "Text",
  22. content: decodeHtml(content)
  23. };
  24. }
  • 由于字符 < 与定界符 {{ 出现的顺序未知,所以需要取两者中较小的一个作为文本截取的终点。
  • 截取终点后,只需要调用字符串的slice函数对字符串进行截取即可,截取出来的内容就是文本节点的文本内容。

parseText函数解析模板: const ast = parse(‘

Text
‘)
得到如下的AST:

  1. const ast = {type: "Root", children: [
  2. {
  3. type: "Element",
  4. tag: "div",
  5. props: [],
  6. isSelfClosing: false,
  7. children:[
  8. { type: "Text", content: "Text"}
  9. ]
  10. }
  11. ]}

解析HTML

解析html实体,解析器会对字符中的分号分情况处理:

  • 当存在分号时:执行完整匹配
  • 当省略分号,执行最短匹配

    解析数字字符引用

    使用正则表达式匹配文本中字符引用的开头部分

    1. const head = /&(?:#x?)?/i.exec(rawText);

    根据正则的匹配结果,判断字符引用的类型

  • 如果head[0] ===’&’,说明匹配的是命名字符引用

  • 如果head[0] ===’&#’ ,说明匹配的是以十进制表示的数字字符
  • 如果head[0] ===’&#x’,说明匹配的是以十六进制表示的数字字符

根据head[0]匹配的值,判断是十进制还是十六进制

  1. const head = /&(?:#x?)?/i.exec(rawText);
  2. const hex = head[0] === '&#x'
  3. const pattern = hex ? /^&#x([0-9z-f]+);?/i : /^&#([0-9]+);?/
  4. const body = pattern.exec(rawText); // body[1]的值就是 Unicode 值

decodeHtml函数源码

解析插值和注释

文本插值是vue模板中用来渲染动态数据的 {{count}}。解析器在解析文本时,遇到起始定界符{{,会进入解析插值函数 parseInterpolation 来解析插值内容.

  1. function parseInterpolation(context) {
  2. context.advanceBy("{{".length);
  3. let closeIndex = context.source.indexOf("}}");
  4. const content = context.source.slice(0, closeIndex);
  5. context.advanceBy(content.length);
  6. context.advanceBy("}}".length);
  7. return {
  8. type: "Interpolation",
  9. content: {
  10. type: "Expression",
  11. content: decodeHtml(content)
  12. }
  13. };
  14. }

最后是解析注释内容: parseComment函数

  1. function parseComment(context) {
  2. context.advanceBy("<!--".length);
  3. let closeIndex = context.source.indexOf("-->");
  4. const content = context.source.slice(0, closeIndex);
  5. context.advanceBy(content.length);
  6. context.advanceBy("-->".length);
  7. return {
  8. type: "Comment",
  9. content
  10. };
  11. }

15解析器和16解析器的区别

  • 在15章中编译器内的解析器原理是vue2中使用的,会按照把模板解析tokens,然后编译为模板ast。
  • 16章中,是vue3采用的解析器原理。直接使用核心函数parseChildren进行解析,生成模板ast。

    17编译优化

    编译优化是vue3更新的一大特性,对vue3整体编译模板有很多提升。vue优化方式,区分动态内容和静态内容,并针对不同的内容采用不同的优化策略。

    动态节点收集和补丁标志

    vue3在编译模板阶段,会提取编译时的关键信息,这些信息对渲染器在渲染时产生很大帮助,减少不必要的diff损耗操作。编译阶段区分出动态内容和静态内容,就可以实现极致的优化策略。
    1. <div>
    2. <div>foo</div>
    3. <p>{{bar}}</p>
    4. </div>
    该模板内容只有bar时动态内容,当响应式数据bar发生变化时,只需要更新p标签的文本节点即可。生成的传统虚拟DOM结构。
    1. const vnode = {
    2. tag: 'div',
    3. children: [
    4. {tag: 'div', children: 'foo'},
    5. {tag: 'p', children: ctx.bar}
    6. ]
    7. }
    经过编译优化后,提取出动态静态节点信息的虚拟DOM节点。
    1. const vnode = {
    2. tag: 'div',
    3. children: [
    4. {tag: 'div', children: 'foo'},
    5. {tag: 'p', children: ctx.bar, patchFlag: 1}, // 这是动态节点
    6. ]
    7. }
    通过添加patchFlag属性,它的值是一个数字,patchFlag表示节点不同含义
    1. const PatchFlags = {
    2. TEXT: 1, // 代表节点有动态的textContent
    3. CLASS: 2, // 元素有动态的class绑定
    4. STYLE: 3, // 元素有动态的style绑定
    5. //...
    6. }
    编写的模板代码,所有模板的根节点都会是block节点, 除了模板中的根节点需要作为block角色外, 任何带有v-if、v-for、v-else等指令的节点,都会作为block节点。
    Block节点,会将动态子代节点收集到 dynamicChildren 数组中。重新设计渲染函数的执行方式
    1. const dynamicChildrenChildrenStack = [];
    2. let currentDynamicChildren = null;
    3. function openBlock(){
    4. dynamicChildrenChildrenStack.push((currentDynamicChildren = []))
    5. }
    6. function closeBlock(){
    7. currentDynamicChildren = dynamicChildrenChildrenStack.pop();
    8. }
    9. render(){
    10. return (openBlock(), createBlock('div', null, [
    11. createVNode('p', {class: 'foo'}, null, 1 ),
    12. createVNode('p', {class: 'bar'}, null)
    13. ]))
    14. }
    15. function createBlock(tag, props, children){
    16. const block = createVNode(tag, props, children);
    17. block.dynamicChildren = currentDynamicChildren
    18. closeBlock();
    19. return block;
    20. }
    createBlock函数,任何应该作为Block角色的虚拟节点,都有该函数完成虚拟节点的创建。 createBlock函数的执行数学是由内到外,当createBlock函数执行时,内层的所有 createBlock 函数已经执行完毕。currentDynamicChildren数组中所存储的就是属于当前Block的所有动态子代节点。
    1. function patchElement(n1, n2) {
    2. const el = (n2.el = n1.el);
    3. const oldProps = n1.props;
    4. const newProps = n2.props;
    5. // 单节点,并且节点包含有patchFlags属性
    6. if (n2.patchFlags) {
    7. if (n2.patchFlags === 1) {
    8. // update class
    9. } else if (n2.patchFlags === 2) {
    10. // update style
    11. } else {
    12. // ...
    13. }
    14. } else {
    15. for (const key in newProps) {
    16. if (newProps[key] !== oldProps[key]) {
    17. patchProps(el, key, oldProps[key], newProps[key]);
    18. }
    19. }
    20. for (const key in oldProps) {
    21. if (!(key in newProps)) {
    22. patchProps(el, key, oldProps[key], null);
    23. }
    24. }
    25. }
    26. if (n2.dynamicChildren) {
    27. patchBlockChildren(n1, n2);
    28. } else {
    29. patchChildren(n1, n2, el);
    30. }
    31. }
    32. function patchBlockChildren(n1, n2) {
    33. for (let i = 0; i < n2.dynamicChildren.length; i++) {
    34. patchElement(n1.dynamicChildren[i], n2.dynamicChildren[i]);
    35. }
    36. }
    动态节点集合能够使得渲染器在执行更新是跳过静态节点,对于单动态节点并且存在patchFlag标志,可以针对性的完成靶向更新。这样避免全量的props更新,可以最大化提升性能。

    Block树

    Block树也是虚拟节点,比普通虚拟节点多出一个dynamicChildren数组,该数组用来收集所有动态子节点,利用createVnode和createBlock函数嵌套调用,完成收集,然后用一个节点栈完成动态节点的收集。由于Blcok会收集所有动态节点,所以对动态节点的比对操作是忽略DOM层级结构。
    为了节点v-if以及v-for指令引起的不稳定性,需要把指令标签也作为Block节点,它们下面仍会有一个dynamicChildren数组存储动态节点。
    1. <div>
    2. <section v-if="foo">
    3. <p>{{a}}</p>
    4. </section>
    5. <div v-else> <p>{{ a }}</p>
    6. </div>
    7. </div>
    8. // 解析后
    9. const block = {
    10. tag: "div",
    11. dynamicChildren:[
    12. /* Block(Section v-if) or Block(div v-else)*/
    13. {tag: 'section', {key:0 /* key值会根据不同的Block而变化*/}, dynamicChildren: [ //...]}
    14. ]
    15. }
    1. <div>
    2. <p v-for="item in list">{{item}}</p>
    3. <i>{{foo}}</i>
    4. <i>{{ bar }}</i>
    5. </div>
    6. // 解析后
    7. const block = {
    8. tag: "div",
    9. dynamicChildren:[
    10. {tag: Fragment, dynamicChildren: []},
    11. {tag: 'i', children: ctx.foo, 1 /* TEXT */},
    12. {tag: 'i', children: ctx.foo, 1 /* TEXT */},
    13. ]
    14. }

    静态节点提升

    和Block树相对的是静态节点,如果节点是静态的,就可以把标签进行提取。
  1. <div><p>static</p><p>{{count}}</p></div>
  2. //编译后
  3. const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "static", -1 /* HOISTED */)
  4. export function render(_ctx, _cache, $props, $setup, $data, $options) {
  5. return (_openBlock(), _createElementBlock("div", null, [
  6. _hoisted_1,
  7. _createElementVNode("p", null, _toDisplayString(_ctx.count), 1 /* TEXT */)
  8. ]))
  9. }

把静态节点提升到渲染函数外,渲染函数只是对静态节点的引用。这样当后边响应式数据变化,不用重新创建静态的虚拟节点,减少了额外的开销。
如果属性是静态的,也会被提升

  1. <div><p>static</p><p name='foo'>{{count}}</p></div>
  2. // 编译后
  3. const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "static", -1 /* HOISTED */)
  4. const _hoisted_2 = { name: "foo" }
  5. export function render(_ctx, _cache, $props, $setup, $data, $options) {
  6. return (_openBlock(), _createElementBlock("div", null, [
  7. _hoisted_1,
  8. _createElementVNode("p", _hoisted_2, _toDisplayString(_ctx.count), 1 /* TEXT */)
  9. ]))
  10. }

预字符串化

基于静态提升,可以进一步采用预字符串化,将静态提升的虚拟节点或者节点树,提前转为字符串化

  1. <div>
  2. <p></p>
  3. <p></p>
  4. // ... 20多个p标签
  5. <p></p>
  6. </div>

经过 模板编译

  1. const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "1", -1 /* HOISTED */)
  2. const _hoisted_2 = /*#__PURE__*/_createElementVNode("p", null, "1", -1 /* HOISTED */)
  3. const _hoisted_3 = /*#__PURE__*/_createElementVNode("p", null, "1", -1 /* HOISTED */)
  4. const _hoisted_4 = /*#__PURE__*/_createElementVNode("p", null, "1", -1 /* HOISTED */)
  5. const _hoisted_5 = /*#__PURE__*/_createElementVNode("p", null, "1", -1 /* HOISTED */)
  6. const _hoisted_6 = [
  7. _hoisted_1,
  8. _hoisted_2,
  9. _hoisted_3,
  10. _hoisted_4,
  11. _hoisted_5
  12. ]
  13. export function render(_ctx, _cache, $props, $setup, $data, $options) {
  14. return (_openBlock(), _createElementBlock("div", null, _hoisted_6))
  15. }

这样优势:

  • 大块的静态内容可以通过innerHTML进行设置,在性能上有一定优势
  • 减少创建虚拟节点产生的性能开销
  • 减少内存占用

    缓存事件

    缓存内联事件处理函数,可以避免不必要的更新;假设<Comp @change='a+b' />模板创建了绑定事件change,并且为事件绑定的事件处理程序是一个内联语句,编译器对内联函数进行缓存,减少不必要的更新

    1. function render(ctx, cache){
    2. return h(Comp, {
    3. onChange: cache[0] || (cache[0] = ($event) => (ctx.a + ctx.b))
    4. })
    5. }

    渲染函数的第二个参数是数组cache,该数组来自组件实例。

    v-once

    当编译器遇到v-once指令时,会利用cache数组缓存渲染函数的全部或者部分执行结果。

    1. // 模板内容
    2. <template>
    3. <div v-once> {{ foo }}</div>
    4. </template>
    5. // 编译后内容
    6. function render(ctx, cache){
    7. return (openBlock(), createBlock('div', null, [
    8. cache[1] || (cache[1] = createVnode('div', null, ctx.foo, 1 /* TEXT */))
    9. ]))
    10. }

    div标签内容被缓存到数组中,下次更新直接使用缓存的内容。
    v-once指令能够从2方面提升性能

  • 避免组件更新时重新创建虚拟DOM带来的性能损耗

  • 避免不必要的DIff计算,被v-once标记的虚拟DOM树会被父节点Block节点收集。