likes
comments
collection
share

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

作者站长头像
站长
· 阅读数 37

1 前言

大家好,我是心锁。

今天要分享的事情很有意思,也非常有趣——即在经历了这么久的前端学习以及源码研读,在机缘巧合下,我开始了前端框架的开发。

本文本来预计做成小系列的,然鹅我又🐦了好几个月,又舍不得已经写的这些,遂缩减成一篇。

2 准备

本篇启动命令:

pnpn i
pnpm run build --filter=vite-raw-minify-plugin
pnpm run dev --filter=demo

Extreme,意为“极端”,既是框架的名字,也是框架的内核。原因很简单,我在启动开发的时候,原目标只是实现最基本的响应式更新,即简单的状态驱动框架。在这点之外的很多因素,说实话,没有调研,没有考虑,没有思考。

从我的视角,当下我希望它的体积是极小的、实现成本是极低的。那么我们本章的目标即可以定下来了:以最低成本实现一个微型的响应式框架。

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

那么,我们要怎么做?或者说,这期中有哪些关键技术组成?在本节中,我们会学习到以下知识:

  • 自己写一个模板引擎
  • 无虚拟 DOM 的响应式更新
  • JavaScript 基础知识

3 基本原理

3.1 编写模板引擎

模板化,初听着是什么很厉害的技术,其实也就那么回事儿。所谓模板化,其实就是一个根据模板和数据输出结果的一个过程。

在很多框架中,都有类似的实现。比如 vue 有着最经典的双大括号 {{}} 的模板处理能力。

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

而在 vue 外,不管是 Solid 和 React 的 JSX 语法,还是 Svelte 中和 vue 不同的模板化语法,我们都可以把它们理解成模板化能力。

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

这些框架的内部实现,有的依靠 JSX,有的靠着 AST——但是这些东西虽然不算高不可攀,但是也确实有入手门槛。

从我们实现一个玩具的角度,最理想的方式其实是“字符串替换”,原因有两点:

  1. 我们可以用 element.innerHTML 的方式渲染一个 DOM,尤其是 template API 的存在,通过字符串渲染一个元素远比命令式创建简单且效率高。
  2. 字符串替换学习门槛极低,我们只需要掌握一些小小的正则。

ok,在确定方案后,我们首先确定我们的模板化语法。我选择的是比较经典的双大括号语法 {{}},主要原因有两个,第一是双大括号日常使用不常见,第二是双大括号语法从 html 的角度并不会破坏 html 解析,换而言之带着双大括号的 html 文件即便放到浏览器上也可以正常渲染,既增加了容错,而且又和目前IDE支持的高亮能力不冲突。

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

看到/apps/demo/src/components/couter/index.html文件,我们现在的目标就是把这个看起来比较潦草的模板中的变量用我们的状态替换掉。

<div>
  <h1>Counter</h1>
  <p>Count: {{ count }} {{src}}</p>
  <img src="{{src}}" />
  <button id="{{incrementRef}}">Increment</button>
  <button id="{{decrementRef}}">Decrement</button>
  <button id="{{cleanRef}}">Clear Src</button>
</div>

要做替换是比较简单的,我们通过replace方法去匹配双大括号,即可把源字符串中大括号圈出来的变量替换成我们的数据。

这一步可以用下边的代码(useTemplate.ts 第21行)实现,通过replace取出大括号内的变量,再基于.操作符在我们的状态中取值,经历了这一步,我们即成功做到了将模板中的双大括号替换成我们的值。

const baseTemplate = template.replace(/{{(.*?)}}/g, (source, key, start) => {
    if (!state) return `[without state "${key}"]`;
    key = key.trim();
    ...
    const keys = key.split(".");
    const value = (() => {
      if (keys.length > 1) {
        let value = state;
        for (let i = 0; i < keys.length; i++) {
          value = value[keys[i]];
        }
        return value;
      } else {
        return state[key];
      }
    })();

    ...

    return value;
  });

原理还好,并不麻烦。这一步我们可以将值替换上去了,那下一步,我们该考虑如何将数据响应式绑定上去。

完成了这一步,我们成功实现了把count和src渲染到页面上。

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

3.2 响应式和渲染

响应式更新,是现代前端框架的重要特点,也是框架们赖以生存的重要组成。在响应式更新的实现上,react 选择了状态驱动的基于虚拟 DOM 和 diff 算法的渲染策略、solid 选择了通过编译时和代理将状态更新转化成细粒到 DOM 级别的更新函数、vue 选择了通过代理机制和虚拟 dom 在运行时做组件级状态更新。

总结起来无非是两种处理响应式的方法:

  • Values: 通过比较当前值和之前的值来检测数据变化。这种粗粒度的方案的显著特点是框架并不知道数据驱动了哪块视图,每次更新都要检测更改。
  • Signals:通过事件驱动视图更新,这个过程一般需要使用到代理的能力。在 Vue 和 Solid 框架中都使用 Signals。vue2 基于 Object.defineProperty ,vue3基于 Proxy,Vue 的 Signals 是组件级,在 Solid 中则是 DOM 级。

其中前者的方案,我们就不做考虑了,检测更改的活太重了,从程序员懒惰的心理来讲,哪里用到了数据我们就更新哪里是最好的。

要做到这一块,我们可以借鉴 solid 的做法,solid 通过 createSignal 的方式创建一个 getter 和 setter,在调用 setter 时,会自动触发使用了 getter 的地方。

这一块的代码我们可以手写一个类似的,其实就是事件订阅这一套东西,可以看到useState文件:

export const useState = <T = unknown>(value: T) => {
  let _value = value;
  const set = new Set<(v?: T) => void>();
  const getValue = (fn?: (v?: T) => void) => {
    if (fn) {
      set.add(fn);
    }
    return _value;
  };
  const setValue = (value: T) => {
    _value = value;
    set.forEach((fn) => fn(_value));
  };
  return [getValue, setValue] as const;
};

通过这种方式,我们相当于实现了一个非常简易的订阅机制,我们可以用js版本直接在浏览器中测试一下:

const useState = (value) => {
  let _value = value;
  const set = new Set();
  const getValue = (fn) => {
    if (fn) {
      set.add(fn);
    }
    return _value;
  };
  const setValue = (value) => {
    _value = value;
    set.forEach((fn) => fn(_value));
  };
  return [getValue, setValue];
};
const [value,setValue]=useState(0)
value(()=>console.log("update"))
setValue(1)
value()

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

也就是说,通过这种订阅的机制,我们现在可以在数据更新的时候,执行响应函数。

这就意味着,现在我们只需要把模板引擎和我们的响应式结合,编写一个更新函数,用于在数据更新时去跟踪我们的 dom 做更新。

4 自顶向下的框架解读

因为🐦了大半年,想把框架讲清楚只能回看思路了。我们来看到目前的 extreme 框架。

4.1 使用方式

在 extreme 框架中,也和 react、vue 等框架有着一样的组件化设计,只是其表现形式上不一致。

比如,当我们需要编写一个 Counter 组件,那么我们需要创建类似这样的结构:

/- components
|---row
     |--- index.html
     |--- index.css
     \--- index.ts

而其中的代码,大抵如下:

  • row/index.html
<div class="counter">
  <h1>
    Counter
    <a
      href="https://github.com/GrinZero/extreme/tree/main/apps/demo/src/components/counter"
      target="_blank"
    >
      <svg
        height="32"
        aria-hidden="true"
        viewBox="0 0 16 16"
        version="1.1"
        width="32"
        data-view-component="true"
        class="github-icon"
      >
        <path
          d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"
        ></path>
      </svg>
    </a>
  </h1>
  <p>{{title}}</p>
  <p>Count: {{ count }}</p>
  <button @click="{{increment}}">Increment</button>
  <button @click="{{decrement}}">Decrement</button>
  <button @click="{{handleSubmit}}">Submit</button>
  <button id="{{resetRef}}">Reset</button>
</div>
  • row/index.ts
import {
  useStyles,
  useRef,
  useState,
  useEffect,
  createComponent,
  useMount,
} from "@sourcebug/extreme";
import styles from "./index.css?raw";
import template from "./index.html?raw";

export const Counter = createComponent("Counter", () => {
  const resetRef = useRef();
  const [count, setCount] = useState(0);
  const [title, setTitle] = useState(`<img src="123"/>`);
  useStyles(styles);

  useMount(() => {
    resetRef()?.addEventListener("click", () => {
      setCount(0);
      setTitle("the form is not submit");
    });
  });

  const decrement = () => {
    setCount(count() - 1);
  };
  const increment = () => {
    setCount(count() + 1);
  };
  const submit = () => {
    setTitle("submit success");
  };

  count((newV) => {
    console.log("newV", newV);
  });

  useEffect(() => {
    console.log("count&title", count(), title());
  }, [count, title]);

  return {
    template,
    state: {
      count,
      title,
    },
    ref: {
      resetRef,
    },
    methods: {
      decrement,
      increment,
      handleSubmit: submit,
    },
  };
});

其中的 template,引用自 index.html,本质上是字符串,state 则是用于渲染字符串模板中双大括号的常量,以及后边的 methods 之所以是 methods,是因为在设计的时候就尚未考虑到传递函数参数和绑定 method 的区别,最终通过分开的形式来方便绑定。

同时,我们也会在其中见到很多熟悉的面孔,比如 react 中的🪝 useEffect/useState/useRef、 solidjs 中的 count() + 1,甚至 createComponent 其实也是来自于 React.createElement

嗯……总之,如果你阅读过各个框架的源码,想必阅读本文能从设计思路上在很多框架中找到元点。

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

诶嘿,至于如何使用这些组件,这份代码也和很多框架非常相似:

import App from './App.ts'

App(document.getElementById("app")!, {})

4.2 都是怎么(抄)来的?

既然你诚心诚意的发问了,那么我就大发慈悲的告诉你

我的意思是,我们从最基本的 createComponent 设计开始讲。

4.2.1 组件系统

对 extreme 来说,每一个组件都是可以独立在 dom 节点上渲染的函数。我们在挂载 root 节点时的代码正是 extreme 组件的渲染方式。

import App from './App.ts'

App(document.getElementById("app")!, {})

来看到这里,显然,createComponent 是从 React.createElement 中得到的灵感,部分逻辑又是从 vue2 的 Vue.component 中得到的灵感。

我们来看到这份代码,看看做了什么。

export const createComponent = <Props extends Record<any, unknown>>(
  name: string,
  component: ExtremeRenderFn<Props>
) => {
  const fn: ExtremeComponent<Props> = async (
    element: HTMLElement,
    props: Props,
    replace: boolean = true,
    isTemplate?: boolean
  ) => {
    const result = component(props);
    const pushElement = isTemplate
      ? document.createElement("template")
      : element;

    if(props.key){
      result.state = {
        ...result.state || {},
        key: props.key
      }
    }

    const ele = await render(
      pushElement,
      result.template,
      result,
      replace,
      isTemplate
    ).then((e) => {
      if (e) {
        e.id = element.id || e.id;
      }
      return e;
    });
    currentCell.mount?.();
    resetCurrentCell();
    return ele;
  };
  fn.displayName = name;
  if (extreme.store[name]) {
    throw new Error(`Component ${name} already exists`);
  }
  extreme.store[name] = fn;
  return fn;
};

在这份代码中,有一个重要思维是值得学习的,即全局上下文。这里这两行不起眼的代码,其实蕴含了 react hook 渲染模式的一个重要原理。

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

只看这两句代码的话,可以理解它们的作用:在每次执行渲染函数的时候,在每次渲染完之后,先触发 mount,再 reset 当前 cel。

之所以能做到这点,是因为我们的 currentCell 其实就是每次执行 render 函数内的全局上下文。在 component 渲染阶段,使用者可能会使用 hook,这些 hook 之所以能生效即是因为其实际上是在往 cell 中挂载了一些变量。而实际的 hook 执行时机会由我们的 createComponent 来调度。

通过这种设计方式,react 构建了能和实际 dom 对应的 fiber tree,其通过 root 节点进入 tree 中,而遍历节点的过程本质上就是在不断的切换局部上下文,让组件执行时的上下文能对应上对应的 fiber node

不过需要注意的是,和 react 不同,extreme 虽然有 cell 的概念,但是并没有 fiber tree 的概念。因为在 extreme 的设计中,一个组件函数并不会被重复执行,所以 extreme 中的每个 component 都可以单独摘出以在实际 dom 节点上渲染。

除此之外,细心的你会注意到,在前边的组件代码中,我们需要手动传入一个字符串代表函数的组件名(这显然不是一个很好的设计)。

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

这是因为在 extreme 的组件系统中,我们在解析模板时如果遇到了大写开头的标签需要去检索 component 并执行,写法上和 react 类似,但是由于我们并非直接在 template 中调用函数,不存在导入导出这样的处理(换而言之,编译时不够),我们只能做的处理是:先收集,再使用。

全局上下文在组件系统中起着至关重要的作用。它为组件提供了一个共享的环境,使得组件在渲染过程中能够正确地管理和使用 hook。当组件执行渲染时,hook 会将相关的变量和函数挂载到当前的全局上下文中。而 createComponent 则负责调度这些 hook 的执行时机,确保它们在正确的阶段被触发。这种方式使得组件的状态管理和响应式行为能够有效地协同工作,提高了框架的灵活性和可扩展性。

4.2.2 hooks

extreme 对外提供了 useEffect、useState、useMount、useRef、useStyles 五个钩子函数。我们讲解前四个。

首先看到 useState,这个我们在前文其实有简单看到过,其本质是一个观察者模式。不同点有两点:

  1. 我们加入了异步的处理,这算是个优化。当状态发生变化时,setState 可以异步地执行相关的操作,避免了同步操作可能导致的性能问题。
  2. 我们加入了和 solidjs 一致的 getCurrentListener 函数。这个函数的存在使得在全局上下文的环境下,能够更灵活地处理状态变化的监听。
export type GetState<T = unknown> = (
  fn?: (v?: T, old?: T) => void | Promise<void>
) => T;
export type SetState<T = unknown> = (v: T) => void;
import { getCurrentListener } from "../core/listener";

export const idleCallback =
  globalThis.requestIdleCallback || ((fn) => setTimeout(fn, 0));

export const useState = <T = unknown>(value: T) => {
  let _value = value;
  const set = new Set<(v?: T, old?: T) => void | Promise<void>>();
  const getValue: GetState<T> = (fn) => {
    const listener = getCurrentListener();
    if (listener) set.add(listener);
    if (fn) set.add(fn);
    return _value;
  };
  const setValue: SetState<T> = async (value: T) => {
    if (_value === value) return;
    const oldV = _value;
    _value = value;
    for (const fn of set) {
      await fn(_value, oldV);
    }
  };
  return [getValue, setValue] as const;
};

后者虽然只是一个很不起眼的修改,但是实际上又是全局上下文的一种运用。毕竟有 getCurrentListener 自然有 setCurrentListener

我们来截取一些片段,可以看到,下边这份代码是 extreme 进行渲染时其中一个观察者的设置点。⚠️注意其中的 value 函数,本质上就是一个普通的函数。

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

我们再找到一个例子,马上就能明白为什么需要全局上下文了。在这个例子中,我们用 showReset 来表示是否允许展示 reset 按钮。

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

此时,我们的 showReset 应该表示为:

const showReset = () => getter().length > 0

在旧版本的 setState 中,我们传入回调的方式是getter(fn) ,显然在这情况下就不适用了,因为我们在 render 函数中只能拿到 showReset 变量,这意味着我们还需要强制用户把代码写成const showReset = (fn) => getter(fn).length > 0 ,这不合理。

而有了全局上下文之后,我们的 getter 每次调用时,都会自动往 set 中添加 fallback 函数,这样即便用户传入一个普通函数,我们的 getter 函数也能主动拿到上下文。

再来看到 useEffect,有了前边这部分 useState 的基础,我们再看 useEffect 源码其实异常简单:

import type { GetState } from "./useState";
import { idleCallback } from "./useState";

export const useEffect = (fn: Function, deps: GetState[]) => {
  for (const dep of deps)
    dep((v) => {
      idleCallback(() => fn(v));
      return;
    });
};

而 ref 的概念的话,其实在我们的框架中 useRef 的存在意义更多是取巧的,我们的框架在渲染时本身就是通过 id 来得到元素实例,那么在运行时自然是相似的。由于 id 是字符串,我们最终处理通过 toString 取巧的方式,来拿到 element 实例。

import { getRandomID } from "../core/dom-str";

export type Ref = {
  (): HTMLElement | null;
  toString(): string;
};

export const useRef = (): Ref => {
  const id = getRandomID();
  const fn = () => document.getElementById(id);
  fn.toString = () => id;
  return fn;
};

最后的 useMount 最不起眼,实质上是 cell 机制对外提供的 hook,mount 函数最终由 extreme 来调度其调用时机。

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

4.2.3 响应式更新

和大部分框架不一样的是,extreme 没有采用 babel 来分析出词法树,而是通过相当硬核(简陋)的正则表达式来实现渲染的逻辑。

但好在万变不离其宗,不管是词法树还是正则替换,都很难离开三个步骤:标记、收集、修改。同时对于 Web 框架来说,最终都要有直接操作 Dom 的部分。

现在我们来讲讲三个步骤:

  • 标记:在词法树的节点中加入不同的属性,以标记之后可能要做的处理或存储部分状态 。在 extreme 框架中,当定义重渲染回调时,就在一切开始之前预先标记了每个存在动态变量的节点。这样的标记使得框架能够快速识别哪些节点需要在数据变化时进行更新。
  • 收集:收集要处理的节点,先收集以防止修改后导致无法处理到后续节点。在 extreme 中,会在真正开始修改节点属性前预先收集了所有的 state 状态。这样的收集操作确保了框架在进行响应式更新时能够获取到原始的状态信息,从而准确地判断哪些节点需要更新以及如何更新。
  • 修改:替换词法树节点或修改节点属性。在实际使用时,extreme 会基于当前节点分析出要修改的部分,通过闭包变量持久化一个修改的 task,再利用 getValue 函数从收集到的 state 中拿到 getter 函数,通过函数调用的方式拿到最新的值再基于 task 修改 dom 节点。

比如说,在 extreme 框架定义重渲染回调时,就在一切开始之前预先标记了每个存在动态变量的节点。

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

然后在真正开始修改节点属性前预先收集了所有的 state 状态。

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

进一步在实际使用时,则基于当前节点分析出要修改的部分,通过闭包变量持久化一个修改的 task,再利用 getValue 函数从收集到的 state 中拿到 getter 函数,通过函数调用的方式拿到最新的值再基于 task 修改 dom 节点。

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

4.2.4 条件渲染

条件渲染是框架们提供的基础能力之一,extreme 同样实现了这一部分。原代码在这里,不想看的话,我整理了一份流程图看起来更方便。

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

其中我们要讲的是中间 rerenderIf 部分。

分两部分,首先解释流程图中红色部分,这一部分是在做渲染和卸载掉处理。由于条件渲染每次都要实际清除 dom,目前的处理方式是在每次条件变化时用 dom 字符串重新执行 render 函数,这种方式对性能有一定损耗并且不利于修改成编译时,实际上应该可以有更好的方式才对。

而另一部分则是定位相关的,即条件渲染时如果移除了 dom,再次出现时怎么还原到用户视角的位置,对应下边的源码:

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

这种定位方式本身并不难以理解,我们要思考的是我们利用上了哪些东西——显然,在定位的过程中,我们借助了 parentsibling 两个节点。这是必不可少的,sibling 帮助我们在再次恢复节点时正确地将 dom 节点插入到原本的位置。

在条件渲染的过程中,当条件发生变化时,框架会根据条件判断是否需要显示或隐藏相应的节点。如果需要隐藏节点,框架会将其从 dom 树中移除,并记录其在 parent 和 sibling 节点中的位置信息。当条件再次满足需要显示该节点时,框架会根据之前记录的位置信息,将节点重新插入到 dom 树中,确保其在用户视角中的位置与之前一致。

4.2.5 批量渲染

和大部分框架一样, extreme 也实现了批量渲染的能力,在 extreme 的 DSL 中,可以通过:for 符号来批量渲染 dom 节点。

 <Row :for="item in data" key="{{item.id}}" item="{{item}}" dispatch="{{dispatch}}"></Row>

相比条件渲染,批量渲染的部分还要更麻烦一些,因为我们需要处理好「新增、移动、修改、删除」各个场景的渲染,而非无脑重渲染,否则极可能造成较大的性能问题。

所以,衍生出 extreme 基于 key 机制来识别单条数据的唯一性。在当前的设计中,将批量渲染拆分了两个关键函数:

  1. 渲染子项的 renderItem 函数。每一个 item,都被 extreme 进行专门的标注,然后递归进行render。在 renderItem 函数中,会将每个子项都转换成 signal 对象,进而如果子项存在修改,也能进行监听并重新渲染。在最后,同样做了一次递归 render,对每个子项进行了单独的渲染。
  2. 重渲染整片列表的rerenderList函数。rerenderList 需要对 dom 进行实际操作,其主要任务是通过 key 的比较来对真实的 dom 元素进行「新增、移动、修改、删除」的操作。

其中 renderItem 的逻辑可以用这份流程图来显示。其中红色标注的部分即核心的逻辑,extreme 在批量渲染时,会将每一个子项都转换成 signal 对象,进而如果子项存在修改,也能进行监听并重新渲染。

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

在最后,同样做了一次递归 render,对每个子项进行了单独的渲染。

可以预估的是,这种方式虽然能成功对每个子项做监听,但是由于重复监听了较多类似的子项、重复进行了 render,有较大的性能浪费,这里有一个显著的优化空间。

而在 rerenderList 函数中,要复杂许多,但是 rerenderList 也是最影响批量渲染性能的部分。全量重渲染不可取,所以我们要进行 diff。

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

diff 的思路即这部分的核心思路,可以看到我们的流程图。黄色部分代表删除,蓝色部分代表新增,绿色部分则是移动或者删除。

重渲染的过程中,extreme 会优先处理 delete 事件,因为删除对应索引上的 dom 并不会对我们后边的 diff 有影响。

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

接着,再从 parent.childNode 中拿到 dom list 列表,我们开始处理修改和换位这两种特殊的类型。这一步还需要额外考虑的 case 是如果在批量渲染的列表的父节点中存在普通节点,则需要跳过相应的处理。

我们主要看看移动的逻辑,即前后两个 dom 节点的数据没有发生实际变化,实际上只是移动到新索引位置上的行为。

从零开始自己写一个框架是什么体验?源码阅读留下的记忆并不能在我们的大脑中形成稳定的神经连接,无实践不沉淀,我是较真的,所

在这段代码中,childNode 是当前被移动的节点,而其原本所在位置的节点并没有显式地处理。不过,由于 DOM 操作具有“自动调整”的特性,childNode 被插入到新的位置时,浏览器会自动将它从原位置移除。因此,原本的节点并不会残留在原位置,而是被移动到新的位置。

而由于旧节点的位置并不重要,所以我们只要保证需要移动的 dom 在整个操作过程后保持正确的索引就行。

在批量渲染的过程中,extreme 通过 key 机制来识别单条数据的唯一性,从而能够准确地判断每个子项的状态变化。renderItem 函数负责对每个子项进行单独的渲染和监听,而 rerenderList 函数则根据 key 的比较来对 dom 元素进行具体的操作,如新增、移动、修改和删除。通过这种方式,extreme 能够在保证渲染准确性的同时,尽量提高批量渲染的性能。

5 总结

extreme 的出现是一场意外,但也是必然。源码阅读留下的记忆并不能形成稳定的神经连接,无实践不沉淀,我是较真的,所以事情总是会发生的。

在开发的过程中,我也想过让它成为什么,要不要让它和其他框架区别开。

后来我就释然了,extreme 不需要专门去成为什么,相比成为一个优秀的亦或特立独行的框架,它更有潜质成为一个很好的玩具。

玩具框架也是框架,希望这个框架的原理可以对初学者产生比较好的作用。

转载自:https://juejin.cn/post/7413607758577221666
评论
请登录