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等等成爲前端主流技術棧的大潮下,仍是出現了一些有着不一樣理念的工具和技術棧,瞭解一下也是挺有趣的。

相關文章
相關標籤/搜索