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

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

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

第三方js的现状

不管是新入行的小白仍是有经验的开发者,前端圈里的人必定听过这类第三方js的大名。 一方面是由于它们实在太火了:前端

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

另外一方面是由于用它们开发很是方便:node

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

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

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

第三方js的反作用

雪球滚起来

若是如今让你开发一个项目,你会怎么作? 假设你熟悉的是React,那么用能够用create-react-app快速搭建一个项目。ios

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

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

相似下面这张图:github

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

有些第三方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);
  }
}
复制代码

总结

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

具体示例:github.com/yalishizhud…

原文连接:tech.gtxlab.com/web-compone…


做者信息:朱德龙,人和将来高级前端工程师。

相关文章
相关标签/搜索