Akara's Wonderland

简单讲讲 Nashi.js

2022-11-10 · 9 min read

很久没写中文博客了,今天来除草一下。

这段时间一直在忙着写一套自己的前端体系(就像阿里系整的 antd、ahooks、antv 全家桶),Nashi.js 就是其中的一部分。

这篇文章大概分为三部分,动机、设计和实现,带你了解 Nashi.js 的前世今生。

开始之前挂一下项目地址,关注 nashi.js 喵,关注 nashi.js 谢谢喵,希望大家多点 star(

Github StackBlitz

动机

于我而言,在应对一些小项目,或是前端人员欠缺的项目,并没有必要去上 Vue、React 这种体量的“大杀器”,然而原生 js 语法虽然优雅,但是 DOM API 实在是令人沮丧,冗长且无趣,这就是 jQuery 现在还存在的意义,正如我在 README.md 提到的,你肯定喜欢写这种代码:

$("p").text("hello").addClass("foo").toggleClass("bar");

而不是这种代码:

const p = document.querySelectorAll("p");
for (const item of p) {
  p.innerText = "hello";
  p.classList.add("foo");
  p.classList.toggle("bar");
}

然而 jQuery 作为旧时代唯一的神,在今天看来还是有很多短板的,其中最臭名昭著的就是体积了,在功能上不及 Vue2 的同时,其大小居然也几乎跟 Vue2 持平,达到了惊人的 90kb+(生产环境未压缩)。这是因为 jQuery 要保证旧时代浏览器的兼容性,做了大量工作,我敬佩他。然而现在是 2022 年,大部分用户使用的浏览器早已标准化,大量的兼容层显得有些多余,然而一个轻量的 jQuery 始终没有出现,于是就有了 Nashi.js。

简单说下特性,首先是重头戏,文件大小:

体积对比 生产环境 生产环境(压缩)
jquery 261.76 KB 84.63 KB
nashi 4.54 KB 1.67 KB

其他特性:

  1. 支持 jQuery 的大部分 DOM 相关方法,除了动画、Ajax 相关,因为没必要

  2. 写了数十个单元测试, 且在多个现代浏览器表现良好,稳定性还行

  3. API 设计现代,写起来比 jQuery 舒服点,比写原生不知道高到哪里去了

小到几乎可以忽略不计的体积,引入后几乎没有负担,但是能获得比 DOM API 强 114514 倍的开发体验,我感觉值得一试。

设计

在实现上,我利用现代 JavaScript 极高的灵活性,将压缩体积上更进一步,然而在设计上,我几乎和 jQuery 保持了一致,这并非倒退,jQuery 的设计在今天看仍有可圈可点之处,我也在 jQuery 的设计上改进了一些。

无论是 nashi 还是 jQuery,他们的默认导出是一个函数,直接调用则是一个“选择器”,选择器函数返回一个 QueryResult 对象,然后在 QueryResult 对象上提供各种方法,比如 append 用于插入子元素、bind 用于绑定事件等,这是为了对 DOM API 进行完全封装,让开发者完全不用接触到 DOM API。在选择器函数上又有若干方法,通常是与 DOM 操作无关的,比如 jQuery.ajax(),见名知意,以及 nashi.merge(),用于合并多个选择器。

选择器函数上附带的若干方法无论谁来写都是这回事,没什么可谈的,然而 QueryResult 对象上的方法,nashi 则更进一步,将方法细分为 GetterSetterGetterSetter 使用相同的方法名,通过传入的参数多少来决定调用 Getter 还是 Setter

由于 jQuery 并没有 GetterSetter 的概念,jQuery 是通过在方法内手动返回一个 QueryResult 对象来实现链式调用,而 nashi 得益于其设计,让所有的 Setter 都返回一个 QueryResult 对象,更优雅地实现了链式调用。

有时候我们希望对选中的元素进行遍历,对特定的元素进行操作,jQuery 的方案是在 QueryResult 上提供一个 map 方法,表现大致同 Array.prototype.map 一致,nashi 则实现了类似数组的方式,支持通过 for infor of 方式来对其进行遍历,我觉得这比 map 优雅,但是 nashi 也提供了一个 forEach 方法。

实现

我们先来看看第一版 nashi 的设计,循序渐进一步步重写到当今的 nashi:

class QueryResult {
  node: Array<HTMLElement> = [];
  constructor(arg) {
    this.node = Array.from(document.querySelectorAll(arg));
  }
  static merge(...args: QueryResult[]) {
    args.forEach((arg) => {
      arg.node.forEach((node) => {
        this.node.push(node);
      });
    });
  }
  text(text: string) {
    if (!text) {
      // Getter
      return this.node[0].innerText;
    } else {
      // Setter
      this.node.forEach((item) => {
        item.innerText = text;
      });
    }
  }
}

这样写有问题吗?没有。优雅吗?平心而论,并不优雅。

实现的 merge 倒是还行,但是 text 实现的则不堪入目,难道 QueryResult 的每一个 Getter 都要再写一遍取 node 的第 0 项, Setter 都要写一遍 forEach 吗?要知道这还是没有添加错误处理的,要是每个方法都要手写一遍各种处理,那代码还有法看吗?那必然是不可能的,我当然也可以给 GetterSetter 各自封装一个逻辑简化这部分操作,但是如果我忘记了调用可就要出事了,所以最好我能自动处理好这些逻辑。

我的方案是,Proxy,这里推荐一下阮一峰老师的教程:Proxy,不熟悉 Proxy 的可以看看。

首先原 QueryResult 的 class 保留构造器和 node,所有的方法都在 Proxy 内实现。在此之前,我们需要考虑一下在哪里存放所有的 GetterSetter,nashi 的方案是直接在 QueryResult 上写:

QueryResult.get = {};
QueryResult.set = {};
QueryResult.info = {};

当然,放在哪都是一样的,只要之后能读到就行。get 和 set 对象代表什么自不必讲,info 则是存放 GetterSetter 所需参数数量的地方。

介绍完了存储的结构,我们来实现一个添加方法的函数:

const extend = (key, { get, set }) => {
  const info = { set: -1, get: -1 };
  if (set) {
    QueryResult.set[key] = set;
    info.set = set.length;
  }

  if (get) {
    QueryResult.get[key] = get;
    info.get = get.length;
  }

  QueryResult.info[key] = info;
};

在原型链上添加了方法,我们需要实现一个将 QueryResult.{方法名} 重定向到 QueryResult.{get||set}.{方法名},于是 Proxy 派上了用场,我们来创建一个,返回已经代理好的 QueryResult 的方法:

const proxy = (argument) => {
  const queryResult = new QueryResult(argument);

  return new Proxy(queryResult, {
    get: (target, property, receiver) => {
      // 这里的 globalReceiver 就是当前这个 Proxy
      const globalReceiver = receiver;
      // 如果有这个方法
      if (QueryResult.info[property]) {
        // 先把方法的信息取出来
        const info = QueryResult.info[property];
        // 返回一个代理好的 Function
        return new Proxy(new Function(), {
          apply(_target, _thisArgument, argumentsList) {
            // 先获取下当前当前传了多少参数
            const { length } = argumentsList;
            // 如果参数数量满足 Getter 需要,则执行 Getter
            if (length === info.get) {
              return QueryResult.get[property].apply(
                // 自动取 QueryResult 的第一项
                queryResult.node[0],
                argumentsList
              );
            }
            // 如果参数数量满足 Setter 需要,则执行 Setter
            if (length === info.set) {
              // 对每一个选中的元素执行这个方法
              for (const item of queryResult.node) {
                QueryResult.set[property].apply(item, argumentsList);
              }
              // 返回外层的 Proxy,实现链式调用
              return globalReceiver;
            }
          },
        });
      }
    },
  });
};

在此之后,我们只需编写这样的代码就能完成方法的添加:

extend("html", {
  get() {
    return this.innerHTML;
  },
  set(html) {
    this.innerHTML = html;
  },
});

this 会指向选中的某个元素,我们只需要对 this 进行操作即可,Proxy 会自动为我们做好诸如“Getter 取数组第一项”、“Setter 实现链式调用”等等琐事,让我们安心完成方法的编写。

此时你可能发现,extend 函数针对某些逻辑,还能继续封装,比如上面的 html 方法逻辑只是对 innerHTML 的 get 和 set 而已,所以我定义了一个 bind 函数:

const bind = (key: string, property: string = key) => {
  extend(key, {
    get() {
      return this[property];
    },
    set(value) {
      this[property] = value;
    },
  });
};

上面的 html 方法可以直接简化成这样:

bind("html", "innerHTML");

这就是 Nashi Core 的核心原理,利用现代 js 的灵活性将代码简化到底。

最后

本篇文章只是粗略地介绍了一下 Nashi.js 的方方面面,如果你感兴趣的话,欢迎到本文开头的 Github 仓库地址给本人点个 star~