抛开 Vue、React、JQuery 这类第三方js,我们该怎么写代码?

5,221 阅读17分钟
原文链接: mp.weixin.qq.com

首先感谢React、Vue、Angular、Cycle、JQuery 等这些第三方js为开发带来的便利。

以下将Vue、React这类常用的框架(库)统称为“第三方js”。

第三方js的现状

无论是新入行的小白还是有经验的开发者,前端圈里的人一定听过这类第三方js的大名。一方面是因为它们实在太火了:

  • 各种文章对框架进行对比、源码解析以。
  • GitHub 上 star 数量高速增长。
  • 各种针对框架的培训课程层出不穷。
  • ……

另一方面是因为用它们开发非常方便:

  • 利用脚手架工具几行命令就可以快速搭建项目。
  • 减少大量的重复代码,结构更加清晰,可读性强。
  • 有丰富的UI库和插件库。
  • ……

但是一则 GitHub 放弃使用 JQuery 的消息让我开始思考:

第三方js除了带来便利之外还有哪些副作用?抛弃第三方js我们还能写出高效的代码吗?

第三方js的副作用

雪球滚起来

如果现在让你开发一个项目,你会怎么做?假设你熟悉的是React,那么用可以用create-react-app快速搭建一个项目。

  • 很好,react、react-dom、react-router-dom 已经写入了package.json,不过事情还没完。
  • http请求怎么处理呢?引入axios吧。
  • 日期怎么处理?引入 moment 或 day 吧。
  • ……

要知道,这种“拿来主义”是会“上瘾”的,所以第三方依赖就像一个滚动的雪球,随着开发不断增加,最后所占体积越来越大。如果用 webpack-bundle-analyzer 工具来分析项目的话,会发现项目代码大部分体积都在node_modules目录中,也就意味着都是第三方js,典型的二八定律(80%的源代码只占了编译后体积的20%)。

类似下面这张图:

于是不得不开始优化,比如治标不治本的code split(代码体积并没有减小,只是拆分了),比如万试万难灵的 tree shaking(你确定shaking之后的代码都只有你真正依赖的代码?),优化效果有限不说,更糟糕的是依赖的捆绑。比如ant-design的模块的日期组件依赖了 moment,那我们在使用它的时候moment就被引入了。而且我即使发现体积更小的 dayjs可以基本取代moment的功能,也不敢引入,因为替换它日期组件会出问题,同时引入又增加了项目体积。

有些第三方js被合称之为“全家桶”,这种叫法让我想起了现在PC端的一些工具软件,本来你只想装一个电脑管家,结果它不断弹窗提示你电脑不安全,建议你安装一个杀毒软件,又提示你软件很久没更新,提示你安装某某软件管家…..本来只想装一个,结果装了全家。

工具驯化

如果你注意观察,在这些第三方js的使用者中,会看到这样一些现象:

  • 排他。一些使用 MV* 框架的开发者很喜欢站队进行讨论,比如喜欢用 VueJS 的开发者很可能会吐槽 ReactJS,喜欢 Angular 的开发者会喷 VueJS。
  • 浮躁。一些经验并不丰富的开发者会觉得:使用JavaScript操作DOM多么低效,直接来个第三方js双向数据绑定好了。自己写XMLHTTPRequest发送请求多么麻烦,来第三方js直接调用好了。
  • 局限。一些面试者以为自己熟悉某种第三方js之后就觉得自己技术不错(甚至很多时候这种“熟悉”还要打上引号),大有掌握了某种第三方js就掌握了前端之意。

这些第三方js本来是为了提升开发效率的工具,却不知不觉地把开发者驯化了,让其产生了依赖。如果每次让你开发新项目,你不得不依赖第三方js提供的脚手架来搭建项目,然后才能开始写代码。那么很可能你已经形成工具思维,就像手里拿着锤子,是什么都是钉子,你处理问答的方式,看问题的角度很可能会受此局限。同时也意味着你正在离底层原生编码越来越远,越不熟悉原生API,你就越只能依赖第三方js,如此循环往复。

怎么打破这种状况?先推荐张鑫旭的一篇文章《不破不立的哲学与个人成长》,当然就是放弃它们。这里需要注意的是,我所说的放弃并不是所有项目都自己写框架,这样在效率上而言是做不到的。更推荐的而是在一些时间相对充裕、影响(规模)不大的项目中进行尝试。比如开发某个公司内部使用的小工具,或者页面数量不多的时间不紧张(看个人开发速度)的小项目。

用原生API进行开发的时候我们可以参考下面两条建议。

理解精髓

虽然我们不使用任何第三方js,但是其原理及实现我们是可以学习,比如你知道实现数据绑定的方式有脏值检测、以及Object.defineProperty,那么你在写代码的时候就可以使用它们,你会发现懂这些原理和真正使用起来还有不小的距离。换个角度而言,这也可以进一步加深我们对第三方js的理解。

当然我们的目的并不是为了再造一个山寨版的js,而是适当地结合、删减和优化已有的技术和思想,为业务定制最合适的代码。

文中提到的第三方js受欢迎很重要的一个原因是因为对DOM操作进行了优化甚至是隐藏。JQuery号称是DOM操作的利器,将DOM封装成JQ对象并扩展了API,而MV框架取代JQuery的原因是因为在DOM操作这条路上做得更绝,直接屏蔽了底层操作,将数据映射到模板上。如果这些MV的思考方式还只是停留在DOM的层次上的话估计也无法发展到今天的规模。因为屏蔽DOM只是简化了代码而已,要搭建大型项目还要考虑代码组织的问题,就是抽象和复用。这些第三方js选择的方式就是“组件化”,把HTML、js和CSS封装在一个具有独立作用域的组件中,形成可复用的代码单元。

下面我们通过不引入任何第三方js的情况下来进行实现。

无依赖实践

web components

先来考虑组件化。其实浏览器原生就支持组件化(web components),它由3个关键技术组成,我们先来快速了解一下。

Custom elements(自定义元素)

一组js API,允许自定义元素及其行为,然后可以在您的用户界面中按照需要使用它们。简单示例:

// 定义组件类
class LoginForm extends HTMLElement {
  constructor() {
    super();
    ...
  }
}
// 注册组件
customElements.define('login-form', LoginForm);
<!-- 使用组件 -->
<login-form></login-form>

Shadow DOM(影子DOM)

一组js API,创建一颗可见的DOM树,这棵树会附着到某个DOM元素上。这棵树的根节点称之为shadow root,只有通过shadow root 才可以访问内部的shadow dom,并且外部的css样式也不会影响到shadow dom上。相当于创建了一个独立的作用域。

常见的shadow root可以通过浏览器的调试工具进行查看:

简单示例:

// 'open' 表示该shadow dom可以通过js 的函数进行访问
const shadow = dom.attachShadow({mode: 'open'})
// 操作shadow dom
shadow.appendChild(h1);

HTML templates(HTML模板)

HTML模板技术包含两个标签:<template><slot>。当需要在页面上重复使用同一个 DOM结构时,可以用 template 标签来包裹它们,然后进行复用。slot标签让模板更加灵活,使得用户可以自定义模板中的某些内容。简单示例如下:

<!-- template的定义 -->
<template id="my-paragraph">
  <p><slot>My paragraph</slot></p>
</template>
// template的使用
let template = document.getElementById('my-paragraph');
let templateContent = template.content;
document.body.appendChild(templateContent);

<!-- 使用slot -->
<my-paragraph>
  <span slot="my-text">Let's have some different text!</span>
</my-paragraph>
<!-- 渲染结果 -->
<p>
  <span slot="my-text">Let's have some different text!</span>
</p>

MDN上还提供了一些简单的例子。这里来一个完整的例子:

const str = `
  <style>
    p {
      color: white;
      background-color: #666;
      padding: 5px;
    }
  </style>
  <p><slot name="my-text">My default text</slot></p>
`
class MyParagraph extends HTMLElement {
  constructor() {
    super();
    const template = document.createElement('template');
    template.innerHTML = str;
    const templateContent = template.content;
    this.attachShadow({mode: 'open'}).appendChild(
      templateContent.cloneNode(true)
    );
  }
}
customElements.define('my-paragraph', MyParagraph);

完整的组件

不过这样的组件功能还太弱了,因为很多时候组件之间是需要有交互的,比如父组件向子组件传递参数,子组件调用父组件回调函数。因为它是HTML标签,所以很自然地想到通过属性来传递。而恰好组件也有生命周期函数来监听属性的变化,看似完美!不过问题又来了,首先是性能问题,这样会增加对dom的读写操作。其次是数据类型问题,HTML标签上只能传递字符串这类简单的数据,而对于对象、数组、函数等这类复杂的数据就无能为力了。你很可能想到对它们进行序列化和反序列化来实现,一来是弄得页面很不美观(想象一个长度为100的数组参数被序列化后的样子)。二来是操作复杂,不停地序列化和反序列化既容易出错也增加性能消耗。三来是一些数据无法被序列化,比如正则表达式、日期对象等。好在我们可以通过选择器获取DOM实例来传递参数。但是这样的话就不可避免地操作DOM,这可不是个好的处理方式。另一方面,就组件内部而言,如果我们需要动态地将一些数据显示到页面上也需要操作DOM。

组件内部视图与数据地通信

将数据映射到视图我们可以采用数据绑定的形式来实现,而视图的变化影响到数据可以采用事件的绑定的形式。

数据绑定

怎么杨将视图和数据建立绑定关系,通常的做法是通过特定的模板语法来实现,比如说使用指令。例如用x-bind指令来将数据体虫到视图的文本内容中。脏值检测的机制在性能上有损耗我们不考虑,那么剩下的就是利用 Object.defineProperty这种监听属性值变化的方式来实现。同时需要注意的是,一个数据可以对应多个视图,所以不能直接监听,而是要建立一个队列来处理。整理一下实现思路:

  1. 通过选择器找出带有x-bind属性的元素,以及该属性的值,比如 <div x-bind="text"></div> 的属性值是text
  2. 建立一个监听队列dispatcher保存属性值以及对应元素的处理函数。比如上面的元素监听的是 text属性,处理函数是this.textContent = value;
  3. 建立一个数据模型state,编写对应属性的set函数,当值发生变化时执行 dispatcher中的函数。

示例代码:

// 指令选择器以及对应处理函数
const map = {
  'x-bind'(value) {
    this.textContent = undefined === value ? '' : value;
  }
};
// 建立监听队列,监听数据对象属性值得变动,然后遍历执行函数
for (const p in map) {
  forEach(this.qsa(`[${p}]`), dom => {
    const property = attr(dom, p).split('.').shift();
    this.dispatcher[property] = this.dispatcher[property] || [];
    const fn = map[p].bind(dom);
    fn(this.state[property]);
    this.dispatcher[property].push(fn);
  });
}
for (const property in this.dispatcher) {
  defineProperty(property);
}
// 监听数据对象属性
const defineProperty = p => {
  const prefix = '_s_';
  Object.defineProperty(this.state, p, {
    get: () => {
      return this[prefix + p];
    },
    set: value => {
      if(this[prefix + p] !== value) {
        this.dispatcher[p].forEach(fun => fun(value, this[prefix + p]));
        this[prefix + p] = value;
      }
    }
  });
};

这里不是操作了DOM了吗?没关系,我们可以把DOM操作放入基类中,那么对于业务组件就不再需要接触DOM了。

小结:这里使用VueJS同样的数据绑定方式,但是由于数据对象属性只能有一个 set 函数,所以建立了一个监听队列来进行处理不同元素的数据绑定,这种队列遍历的方式和AngularJS脏值检测的机制有些类似,但是触发机制不同、数组长度更小。

事件绑定

事件的绑定思路比数据绑定更简单,直接在DOM元素上进行监听即可。我们以click事件为例进行绑定,创建一个事件绑定的指令,比如 x-click。实现思路:

  1. 利用DOM选择器找到带有x-click属性的元素。
  2. 读取x-click属性值,这时候我们需要对属性值进行一下判断,因为属性值有可能是函数名比如 x-click=fn,有可能是函数调用x-click=fn(a, true)
  3. 对于基础数据类型进行判断,比如布尔值、字符串,并加入到调用参数列表中。
  4. 为DOM元素添加事件监听,当事件触发时调用对应函数,传入参数。

示例代码:

const map = ['x-click'];
map.forEach(event => {
  forEach(this.qsa(`[${event}]`), dom => {
    // 获取属性值
    const property = attr(dom, event);
    // 获取函数名
    const fnName = property.split('(')[0];
    // 获取函数参数
    const params = property.indexOf('(') > 0 ? property.replace(/.*\((.*)\)/, '$1').split(',') : [];
    let args = [];
    // 解析函数参数
    params.forEach(param => {
      const p = param.trim();
      const str = p.replace(/^'(.*)'$/, '$1').replace(/^"(.*)"$/, '$1');
      if (str !== p) { // string
        args.push(str);
      } else if (p === 'true' || p === 'false') { // boolean
        args.push(p === 'true');
      } else if (!isNaN(p)) {
        args.push(p * 1);
      } else {
        args.push(this.state[p]);
      }
    });
    // 监听事件
    on(event.replace('x-', ''), dom, e => {
      // 调用函数并传入参数
      this[fnName](...params, e);
    });
  });
});

对于表单控件的双向数据绑定也很容易,即在建立数据绑定修改value,然后建立事件绑定监听input事件即可。

组件与组件之间的通信

解决完组件内部的视图与数据的映射问题我们来着手解决组件之间的通信问题。组件需要提供一个属性对象来接收参数,我们设定为props

父=>子,数据传递

父组件要将值传入子组件的props属性,需要获取子组件的实例,然后修改 props属性。这样的话就不可避免的操作DOM,那么我们考虑将DOM操作法放在基类中进行。那么问题来了,怎么找到哪些标签是子组件,子组件有哪些属性是需要绑定的?可以通过命名规范和选择其来获取吗?比如组件名称都以cmp-开头,选择器支不支持暂且不说,这种要求既约束编码命名,同时有没有规范保证。简单地说就是没有静态检测机制,如果有开发者写的组件不是以 cmp-开头,运行时发现数据传递失败检查起来会比较麻烦。所以可以在另一个地方对组件名称进行采集,那就是注册组件函数。我们通过customElements.define函数来注册组件,一种方式是直接对该函数进行重载,在注册组件的时候记录组件名称,但是实现有些难度,而且对原生API函数修改难以保证不会对其它代码产生影响。所以折中的方式是对齐封装,然后利用封装的函数进行组件注册。这样我们就可以记录所有注册的组件名了,然后创建实例来获取对应 props我们就解决了上面提出的问题。同时在props对象的属性上编写 set函数进行监听。到了这一步还只完成了一半,因为我们还没有把数据传递给子组件。我们不要操作DOM的话那就只能利用已有的数据绑定机制了,将需要传递的属性绑定到数据对象上。梳理一下思路:

  1. 编写子组件的时候建立props对象,并声明需要被传参的属性, 比如this.props = {id: ''}
  2. 编写子组件的时候不通过原生customElements.define,而是使用封装过的函数,比如 defineComponent来注册,这样可以记录组件名和对应的props属性。
  3. 父组件在使用子组件的时候进行遍历,找出子组件和对应的props对象。
  4. 将子组件props对象的属性绑定到父组件的数据对象 state属性上,这样当父组件state属性值发生变化时,会自动修改子组件 props属性值。

示例代码:

const components = {};
/**
 * 注册组件函数
 * @param {string} 组件(标签)名
 * @param {class} 组件实现类
 */
export const defineComponent = (name, componentClass) => {
  // 注册组件
  customElements.define(name, componentClass);
  // 创建组件实例
  const cmp = document.createElement(name);
  // 存储组件名以及对应的props属性
  components[name] = Object.getOwnPropertyNames(cmp.props) || [];
};
// 注册子组件
class ChildComponent extends Component {
  constructor() {
    // 通过基类来创建模板
    // 通过基类来监听props
    super(template, {
      id: value => {
        // ...
      }
    });
  }
}

defineComponent('child-component', ChildComponent);

<!-- 使用子组件 -->
<child-component id="myId"></child-component>

// 注册父组件
class ParentComponent extends Component {
  constructor() {
    super(template);
    this.state.myId = 'xxx';
  }
}

上面的代码中有很多地方可以继续优化,具体查看文末示例代码。

子=>父,回调函数

子组件的参数要传回给父组件,可以采用回调函数的形式。比较麻烦的时候调用函数时需要用到父组件的作用域。可以将父组件的函数进行作用域绑定然后传入子组件props对象属性,这样子组件就可以正常调用和传参了。因为回调函数操作方式和参数不一样,参数是被动接收,回调函数是主动调用,所以需要在声明时进行标注,比如参考AngularJS指令的scope对象属性的声明方式,用“&”符号来表示回调函数。理清一下思路:

  1. 子组件类中声明props的属性为回调函数,如 this.props = {onClick:'&'}
  2. 父组件初始化时,在模板上传递对应属性, 如<child-compoennt on-click="click"></child-component>
  3. 根据子组件属性值找到对应的父组件函数,然后将父组件函数绑定作用域并传入。如childComponent.props.onClick = this.click.bind(this)
  4. 子组件中调用父组件函数, 如this.props.onClick(...)

示例代码:

// 注册子组件
class ChildComponent extends Component {
  constructor() {
    // 通过基类来声明回调函数属性
    super(template, {
      onClick: '&'
    });
    ...
    this.props.onClick(...);
  }
}

defineComponent('child-component', ChildComponent);

<!-- 父组件中使用子组件 -->
<child-component on-click="click"></child-component>

// 注册父组件
class ParentComponent extends Component {
  constructor() {
    super(template);
  }
  // 事件传递放在基类中操作
  click(data) {
    ...
  }
}

穿越组件层级的通信

有些组件需要子孙组件进行通信,层层传递会编写很多额外的代码,所以我们可以通过总线模式来进行操作。即建立一个全局模块,数据发送者发送消息和数据,数据接收者进行监听。

示例代码

// bus.js
// 监听队列
const dispatcher = {};
/** 
 * 接收消息
 * name 
 */
export const on = (name, cb) => {
  dispatcher[name] = dispatcher[name] || [];
  const key = Math.random().toString(26).substring(2, 10);
  // 将监听函数放入队列并生成唯一key
  dispatcher[name].push({
    key,
    fn: cb
  });
  return key;
};
// 发送消息
export const emit = function(name, data) {
  const dispatchers = dispatcher[name] || [];
  // 轮询监听队列并调用函数
  dispatchers.forEach(dp => {
    dp.fn(data, this);
  });
};
// 取消监听
export const un = (name, key) => {
  const list = dispatcher[name] || [];
  const index = list.findIndex(item => item.key === key);
  // 从监听队列中删除监听函数
  if(index > -1) {
    list.splice(index, 1);
    return true;
  } else {
    return false;
  }
};

// ancestor.js
import {on} from './bus.js';

class AncestorComponent extends Component {
  constructor() {
    super();
    on('finish', data => {
      //...
    })    
  }
}

// child.js
class ChildComponent extends Component {
  constructor() {
    super();
    emit('finish', data);
  }
}

总结

关于基类的详细代码可以参考文末的仓库地址,目前项目遵循的是按需添加原则,只实现了一些基础的操作,并没有把所有可能用到的指令写完。所以还不足以称之为“框架”,只是给大家提供实现思路以及编写原生代码的信心。

具体示例:https://github.com/yalishizhude/web-component

本文可被转发或分享,但必须保留完整图文信息和出处,作者保留追究一切法律责任的权利和手段~

搜索关注公众号“web学习社”~