alpine.js使用及原理简介

你们好。今天简单介绍下alpine.js的使用和原理。css

为何会想到介绍alpine.js呢?有如下几个缘由:html

  • 此前刚接触了tailwindcss写了篇文章作了简单介绍。而alpine.js的标语则是“像写tailwindcss同样写js”,同时tailwindcss也是apline.js的赞助者。
  • 先后端在通过完全的分离以后,服务端渲染再次成为热门议题。除了最主流的SSR(服务端渲染+前端水合)方案以外,也出现了适合不一样场景的不一样方案,例如JAMTALLTALLLaravel主推的一套快速的全栈开发方案,是TailwindCSSAlpine.jsLaravelLivewire的首字母缩写。
  • 最后一个缘由,由于在reactvue大火以前,本身所在的团队也曾经开发过相似alpine.js的库,不免有些亲切感。

简介

alpine.js以相比react或vue这些大框架低不少的成本提供了响应式和申明式的组件编写方式前端

像写tailwindcss同样写jsvue

alpine.js官方的这两句简介足以归纳其与当前主流前端框架的不一样之处。apline.js主打的就是轻和快。node

TALL是传统的后端渲染机制,面对的用户也是以PHP开发者为主。不一样于SSR的跨端组件,TALL以传统的后端模板机制完成页面渲染,前端再经过alpine.js提供交互。做为这套技术栈中前端重要的一环,轻量级、学习成本低,都是alpine.js的加分项。react

alpine.js无需安装,免去了webpackyarn之类的学习成本,相似vue的语法也很是容易上手。为了保持轻巧,alpine.js选择了一些不一样的实现方式,例如不依赖虚拟 DOM,模板经过遍历 DOM 来解析等等,这些会在文章后半部分介绍。webpack

使用apline

开始使用

一般,咱们只需在页面上引入alpine.js就能够了:git

<script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine.js" defer></script>
复制代码

而后来看一个简单的例子:github

<div x-data="{ open: false }">
  <button @click="open = true">Open Dropdown</button>
  <ul x-show="open" @click.away="open = false" >
    Dropdown Body
  </ul>
</div>
复制代码

在咱们的 HTML 中编写这段代码,alpine.js会在页面加载完成以后,将其初始化为组件。没错,咱们几乎不须要额外写任何 JS,就实现了一个简单的组件。web

指令(Directives)

alpine.js经过提供不一样的指令,这里简单介绍几个:

x-data

提供组件的初始数据。alpine.js正是经过这个属性来界定组件边界的,一个带有x-data指令的 dom 元素会被编译成一个组件。

除了内联的初始数据,咱们也能调用函数:

// html
<div x-data="{ ...dropdown(), ...anotherMixin()}">
  <button x-on:click="open">Open</button>
  <div x-show="isOpen()" x-on:click.away="close">
    // Dropdown
  </div>
</div>

// js
function dropdown() {
  return {
    show: false,
    open() { this.show = true },
    close() { this.show = false },
    isOpen() { return this.show === true },
  }
}
复制代码

x-init

能够把x-init想象成 Vue 的mounted

x-init="() => { // we have access to the post-dom-initialization state here // }"
复制代码

x-bind

用来绑定属性,例如:

x-bind:class="{ 'hidden': myFlag }"

// x-bind:disabled="myFlag"
复制代码

x-on

事件侦听,一样支持x-on:@两种形式,以及提供了例如selfpreventaway等修饰符:

x-on:click="foo = 'bar'"
@input.debounce.750="fetchSomething()"
复制代码

x-model

相似v-model

<input type="text" x-model="foo">
<input x-model.number="age">
<input x-model.debounce="search">
复制代码

x-ref

用来获取 dom 元素:

<div x-ref="foo"></div><button x-on:click="$refs.foo.innerText = 'bar'"></button>
复制代码

x-for

必须以template标签包裹单个根组件:

<template x-for="item in items" :key="item">
  <div x-text="item"></div>
</template>
复制代码

x-spread

相似 JSX 中 { ...props }的写法:

// html
<div x-data="dropdown()">
  <button x-spread="trigger">Open Dropdown</button>
  <span x-spread="dialogue">Dropdown Contents</span>
</div>

// js
function dropdown() {
  return {
    open: false,
    trigger: {
      ['@click']() {
        this.open = true
      },
    },
    dialogue: {
      ['x-show']() {
        return this.open
      },
      ['@click.away']() {
        this.open = false
      },
    }
  }
}
复制代码

x-cloak

x-cloak属性会在组件初始化后被移除,所以能够添加如下css使得有这个属性的 DOM 元素在初始化后才展现:

[x-cloak] {
    display: none !important;
}
复制代码

魔术属性

在内联代码中,alpine.js提供了一些属性来协助咱们完成功能

$el

用来获取组件的根元素:

<div x-data>
  <button @click="$el.innerHTML = 'foo'">Replace me with "foo"</button>
</div>
复制代码

$refs

用以获取子元素

$event

DOM 事件:

<input x-on:input="alert($event.target.value)">
复制代码

$dispatch

发出自定义事件:

<div @custom-event="console.log($event.detail.foo)">
  <button @click="$dispatch('custom-event', { foo: 'bar' })">
</div>
复制代码

$nextTick

alpine.js更新 DOM 后执行代码:

div x-data="{ fruit: 'apple' }">
  <button
    x-on:click="
      fruit = 'pear';
      $nextTick(() => { console.log($event.target.innerText) });
    "
    x-text="fruit"
  ></button>
</div>
复制代码

$watch

观察组件数据变化:

<div x-data="{ open: false }" x-init="$watch('open', value => console.log(value))">
    <button @click="open = ! open">Toggle Open</button>
</div>
复制代码

apline代码浅析

最后简单来看下apline.js的源码。做为一个总共不到2000行的库,alpine.js代码结构和流程能够说是比较清晰明了的。

若是你大体了解过Vue或其余框架的原理,那么如下内容都是比较熟悉的了。

初始化

监听DOMContentLoaded事件:

function domReady() {
  return new Promise(resolve => {
    if (document.readyState == "loading") {
      document.addEventListener("DOMContentLoaded", resolve);
    } else {
      resolve();
    }
  });
}
复制代码

遍历全部包含x-data属性的 DOM 节点:

discoverComponents: function discoverComponents(callback) {
  const rootEls = document.querySelectorAll('[x-data]');
  rootEls.forEach(rootEl => {
    callback(rootEl);
  });
},
复制代码

并初始化组件(Component类):

initializeComponent: function initializeComponent(el) {
  // ...
  el.__x = new Component(el);
  // ...
},

// ...

class Component {
  constructor(el, componentForClone = null) {
    // ...
  }
}
复制代码

响应式数据

首先初始化数据,使用getAttribute获取到x-data属性的值:

const dataAttr = this.$el.getAttribute('x-data');
const dataExpression = dataAttr === '' ? '{}' : dataAttr;

this.unobservedData = componentForClone ? componentForClone.getUnobservedData() : saferEval(el, dataExpression, dataExtras);
复制代码

saferEval使用new Function来执行表达式以初始化数据:

function saferEval(el, expression, dataContext, additionalHelperVariables = {}) {
  return tryCatch(() => {
    if (typeof expression === 'function') {
      return expression.call(dataContext);
    }

    return new Function(['$data', ...Object.keys(additionalHelperVariables)], `var __alpine_result; with($data) { __alpine_result = ${expression} }; return __alpine_result`)(dataContext, ...Object.values(additionalHelperVariables));
  }, {
    el,
    expression
  });
}
复制代码

接着,是响应式原理。这里主要涉及两个类:ReactiveMembraneReactiveProxyHandler

一个组件包含一个ReactiveMembrane实例,其构造函数中将接收valueMutated回调:

function wrap(data, mutationCallback) {
  let membrane = new ReactiveMembrane({
    valueMutated(target, key) {
      mutationCallback(target, key);
    }
  });
  // ...
}
复制代码

当数据被改动时,会调用valueMutated回调,从而调用组件的updateElements方法,更新 DOM(这里经过debounce来使得多个同步的数据修改能够一块儿被执行):

wrapDataInObservable(data) {
  var self = this;
  let updateDom = debounce(function () {
    self.updateElements(self.$el);
  }, 0);
  return wrap(data, (target, key) => {
    // ...
    updateDom();
  });
}
复制代码

ReactiveProxyHandler构造函数接收两个参数,一个是数据对象,另外一个则是一个ReactiveMembrane实例:

class ReactiveProxyHandler {
  constructor(membrane, value) {
      this.originalTarget = value;
      this.membrane = membrane;
  }
  // ...
}
复制代码

alpine.js的响应式是基于Proxy的,ReactiveProxyHandler实例会做为Proxy构造函数的第二个参数(这里还使用了懒初始化的技巧):

get reactive() {
    const reactiveHandler = new ReactiveProxyHandler(membrane, distortedValue);
    // caching the reactive proxy after the first time it is accessed
    const proxy = new Proxy(createShadowTarget(distortedValue), reactiveHandler);
    registerProxy(proxy, value);
    ObjectDefineProperty(this, 'reactive', { value: proxy });
    return proxy;
},
复制代码

当从嵌套的对象、数组中读取值时,须要递归的建立ReactiveProxyHandler实例,绑定到同一个membrane上:

get(shadowTarget, key) {
  const { originalTarget, membrane } = this;
  const value = originalTarget[key];
  // ...
  return membrane.getProxy(value);
}
复制代码

当修改数据时,membranevalueMutated方法被调用,并最终更新 DOM:

set(shadowTarget, key, value) {
  const { originalTarget, membrane: { valueMutated } } = this;
  const oldValue = originalTarget[key];
  if (oldValue !== value) {
    originalTarget[key] = value;
    valueMutated(originalTarget, key);
  }
  else if (key === 'length' && isArray(originalTarget)) {
    valueMutated(originalTarget, key);
  }
  return true;
}
复制代码

DOM 渲染

alpine.js的模板解析过程是经过遍历 DOM 树和元素节点的属性来完成的,更新时也不经过虚拟 DOM 这样的机制,而是直接修改 DOM。

遍历 DOM

DOM 的初始化和更新都须要从组件的根元素开始遍历,对遍历到的元素判断其是否为嵌套的组件,若是是则建立对应组件,若是不是则初始化/更新 DOM 元素,并在最后清理$nextTick添加的回调:

initializeElements(rootEl, extraVars = () => {}) {
  this.walkAndSkipNestedComponents(rootEl, el => {
    // ...
    this.initializeElement(el, extraVars);
  }, el => {
    el.__x = new Component(el);
  });
  this.executeAndClearRemainingShowDirectiveStack();
  this.executeAndClearNextTickStack(rootEl);
}

updateElements(rootEl, extraVars = () => {}) {
  this.walkAndSkipNestedComponents(rootEl, el => {
    // ...
    this.updateElement(el, extraVars);
  }, el => {
    el.__x = new Component(el);
  });
  this.executeAndClearRemainingShowDirectiveStack();
  this.executeAndClearNextTickStack(rootEl);
}

walkAndSkipNestedComponents(el, callback, initializeComponentCallback = () => {}) {
  walk(el, el => {
    // We've hit a component.
    if (el.hasAttribute('x-data')) {
      // If it's not the current one.
      if (!el.isSameNode(this.$el)) {
        // Initialize it if it's not.
        if (!el.__x) initializeComponentCallback(el); // Now we'll let that sub-component deal with itself.

        return false;
      }
    }

    return callback(el);
  });
}
复制代码

walk方法经过firstElementChildnextElementSibling来遍历 DOM 树:

function walk(el, callback) {
  if (callback(el) === false) return;
  let node = el.firstElementChild;

  while (node) {
    walk(node, callback);
    node = node.nextElementSibling;
  }
}
复制代码

组件的初始化和更新的不一样之处在于,初始化须要获取并处理元素本来的 class,以及绑定事件:

initializeElement(el, extraVars) {
  // To support class attribute merging, we have to know what the element's
  // original class attribute looked like for reference.
  if (el.hasAttribute('class') && getXAttrs(el, this).length > 0) {
    el.__x_original_classes = convertClassStringToArray(el.getAttribute('class'));
  }

  this.registerListeners(el, extraVars);
  this.resolveBoundAttributes(el, true, extraVars);
}

updateElement(el, extraVars) {
  this.resolveBoundAttributes(el, false, extraVars);
}
复制代码

registerListenersresolveBoundAttributes方法中,将遍历元素的属性,并经过对应的指令进行处理。

指令

on

registerListeners中,若是判断指令为on,则调用registerListener进行事件绑定:

case 'on':
  registerListener(this, el, value, modifiers, expression, extraVars);
  break;
复制代码

registerListener中,须要根据不一样的修饰符进行各类处理。这里就先无论这些修饰符,看下对于@click="open = !open"这段简单的代码会发生什么。

对于以上场景,能够将registerListener简化为:

function registerListener(component, el, event, modifiers, expression, extraVars = {}) {
  let handler = e => {
    runListenerHandler(component, expression, e, extraVars);
  };

  el.addEventListener(event, handler);
}
复制代码

这里modifiers修饰符为空数组,event就是clickel就是当前要绑定事件的 DOM 元素,express就是字符串open = !open

首先经过addEventListener绑定事件,而后在事件回调中执行表达式,最终调用的是saferEvalNoReturn方法:

function saferEvalNoReturn(el, expression, dataContext, additionalHelperVariables = {}) {
  return tryCatch(() => {
    // ...
    return Promise.resolve(new AsyncFunction(['dataContext', ...Object.keys(additionalHelperVariables)], `with(dataContext) { ${expression} }`)(dataContext, ...Object.values(additionalHelperVariables)));
  }, {
    el,
    expression
  });
}
复制代码

这里经过with将上下文设置为已经用Proxy代理过的组件数据,所以当expression对数据进行修改时,组件会触发从新渲染。

model

若是是x-model,则调用registerModelListener方法:

case 'model':
  registerModelListener(this, el, modifiers, expression, extraVars);
  break;
复制代码

须要判断 DOM 元素类型(如inputradio等)所对应的事件,以及如何从事件取值,并一样经过registerListener添加侦听:

function registerModelListener(component, el, modifiers, expression, extraVars) {
  var event = el.tagName.toLowerCase() === 'select' || ['checkbox', 'radio'].includes(el.type) || modifiers.includes('lazy') ? 'change' : 'input';
  const listenerExpression = `${expression} = rightSideOfExpression($event, ${expression})`;
  registerListener(component, el, event, modifiers, listenerExpression, () => {
    return _objectSpread2(_objectSpread2({}, extraVars()), {}, {
      rightSideOfExpression: generateModelAssignmentFunction(el, modifiers, expression)
    });
  });
}
复制代码

这里经过registerListenerextraVars传入额外的参数rightSideOfExpression,来使得这里的listenerExpression能够正确的获取修改后的值。

generateModelAssignmentFunction对不一样输入元素类型就行判断,以正确获取值。例如对于input,就是对event.target.value再根据修饰符处理一下:

const rawValue = event.target.value;
return modifiers.includes('number') ? safeParseNumber(rawValue) : modifiers.includes('trim') ? rawValue.trim() : rawValue;
复制代码

text

registerListeners处理了x-onx-model两种指令,其余的则在resolveBoundAttributes中处理。

例如x-text

case 'text':
  var output = this.evaluateReturnExpression(el, expression, extraVars);
  handleTextDirective(el, output, expression);
  break;
复制代码

这里先调用evaluateReturnExpression获取表达式的执行结果,随后调用handleTextDirective设置元素的textContent

function handleTextDirective(el, output, expression) {
  if (output === undefined && expression.match(/\./)) {
    output = '';
  }

  el.textContent = output;
}
复制代码

for

handleForDirective用来处理x-for指令:

case 'for':
  handleForDirective(this, el, expression, initialUpdate, extraVars);
  break;
复制代码

handleForDirective中,首先须要解析表达式,获取遍历的目标数组、下标和当前值的名称:

let iteratorNames = typeof expression === 'function' ? parseForExpression(component.evaluateReturnExpression(templateEl, expression)) : parseForExpression(expression);
let items = evaluateItemsAndReturnEmptyIfXIfIsPresentAndFalseOnElement(component, templateEl, iteratorNames, extraVars);
复制代码

随后,遍历数组,根据key尝试重用元素,若是找到可重用的元素,就调用updateElements对其更新;不然,经过模板(templateEl)建立新元素并初始化:

let currentEl = templateEl;
items.forEach((item, index) => {
  let iterationScopeVariables = getIterationScopeVariables(iteratorNames, item, index, items, extraVars());
  let currentKey = generateKeyForIteration(component, templateEl, index, iterationScopeVariables);
  let nextEl = lookAheadForMatchingKeyedElementAndMoveItIfFound(currentEl.nextElementSibling, currentKey); // If we haven't found a matching key, insert the element at the current position.

  if (!nextEl) {
    nextEl = addElementInLoopAfterCurrentEl(templateEl, currentEl); // And transition it in if it's not the first page load.

    transitionIn(nextEl, () => {}, () => {}, component, initialUpdate);
    nextEl.__x_for = iterationScopeVariables;
    component.initializeElements(nextEl, () => nextEl.__x_for); // Otherwise update the element we found.
  } else {
    // Temporarily remove the key indicator to allow the normal "updateElements" to work.
    delete nextEl.__x_for_key;
    nextEl.__x_for = iterationScopeVariables;
    component.updateElements(nextEl, () => nextEl.__x_for);
  }

  currentEl = nextEl;
  currentEl.__x_for_key = currentKey;
});
removeAnyLeftOverElementsFromPreviousUpdate(currentEl, component);
复制代码

其余的指令就不一一介绍了,逻辑也比较简单、直观。

小结

以上就是alpine.js使用和原理的一个简单介绍。在webpack、less / sass / css in js、三大框架、SSR等等成为前端主流技术栈的大潮下,仍是出现了一些有着不一样理念的工具和技术栈,了解一下也是挺有趣的。

相关文章
相关标签/搜索