你們好。今天簡單介紹下alpine.js的使用和原理。css
爲何會想到介紹alpine.js
呢?有如下幾個緣由:html
tailwindcss
並寫了篇文章作了簡單介紹。而alpine.js
的標語則是「像寫tailwindcss同樣寫js」,同時tailwindcss
也是apline.js
的贊助者。SSR
(服務端渲染+前端水合)方案以外,也出現了適合不一樣場景的不一樣方案,例如JAM
和TALL。TALL
是Laravel
主推的一套快速的全棧開發方案,是TailwindCSS
、Alpine.js
、Laravel
和Livewire
的首字母縮寫。react
和vue
大火以前,本身所在的團隊也曾經開發過相似alpine.js
的庫,不免有些親切感。alpine.js以相比react或vue這些大框架低不少的成本提供了響應式和申明式的組件編寫方式前端
像寫tailwindcss同樣寫jsvue
alpine.js
官方的這兩句簡介足以歸納其與當前主流前端框架的不一樣之處。apline.js
主打的就是輕和快。node
TALL
是傳統的後端渲染機制,面對的用戶也是以PHP開發者爲主。不一樣於SSR
的跨端組件,TALL
以傳統的後端模板機制完成頁面渲染,前端再經過alpine.js
提供交互。做爲這套技術棧中前端重要的一環,輕量級、學習成本低,都是alpine.js
的加分項。react
alpine.js
無需安裝,免去了webpack
、yarn
之類的學習成本,相似vue
的語法也很是容易上手。爲了保持輕巧,alpine.js
選擇了一些不一樣的實現方式,例如不依賴虛擬 DOM,模板經過遍歷 DOM 來解析等等,這些會在文章後半部分介紹。webpack
一般,咱們只需在頁面上引入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
alpine.js
經過提供不一樣的指令,這裏簡單介紹幾個:
提供組件的初始數據。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
想象成 Vue 的mounted
:
x-init="() => { // we have access to the post-dom-initialization state here // }"
複製代碼
用來綁定屬性,例如:
x-bind:class="{ 'hidden': myFlag }"
// x-bind:disabled="myFlag"
複製代碼
事件偵聽,一樣支持x-on:
和@
兩種形式,以及提供了例如self
、prevent
、away
等修飾符:
x-on:click="foo = 'bar'"
@input.debounce.750="fetchSomething()"
複製代碼
相似v-model
:
<input type="text" x-model="foo">
<input x-model.number="age">
<input x-model.debounce="search">
複製代碼
用來獲取 dom 元素:
<div x-ref="foo"></div><button x-on:click="$refs.foo.innerText = 'bar'"></button>
複製代碼
必須以template
標籤包裹單個根組件:
<template x-for="item in items" :key="item">
<div x-text="item"></div>
</template>
複製代碼
相似 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
屬性會在組件初始化後被移除,所以能夠添加如下css
使得有這個屬性的 DOM 元素在初始化後才展現:
[x-cloak] {
display: none !important;
}
複製代碼
在內聯代碼中,alpine.js
提供了一些屬性來協助咱們完成功能
用來獲取組件的根元素:
<div x-data>
<button @click="$el.innerHTML = 'foo'">Replace me with "foo"</button>
</div>
複製代碼
用以獲取子元素
DOM 事件:
<input x-on:input="alert($event.target.value)">
複製代碼
發出自定義事件:
<div @custom-event="console.log($event.detail.foo)">
<button @click="$dispatch('custom-event', { foo: 'bar' })">
</div>
複製代碼
在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>
複製代碼
觀察組件數據變化:
<div x-data="{ open: false }" x-init="$watch('open', value => console.log(value))">
<button @click="open = ! open">Toggle Open</button>
</div>
複製代碼
最後簡單來看下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
});
}
複製代碼
接着,是響應式原理。這裏主要涉及兩個類:ReactiveMembrane
和ReactiveProxyHandler
。
一個組件包含一個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);
}
複製代碼
當修改數據時,membrane
的valueMutated
方法被調用,並最終更新 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;
}
複製代碼
alpine.js
的模板解析過程是經過遍歷 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
方法經過firstElementChild
和nextElementSibling
來遍歷 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);
}
複製代碼
在registerListeners
和resolveBoundAttributes
方法中,將遍歷元素的屬性,並經過對應的指令進行處理。
在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
就是click
,el
就是當前要綁定事件的 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
對數據進行修改時,組件會觸發從新渲染。
若是是x-model
,則調用registerModelListener
方法:
case 'model':
registerModelListener(this, el, modifiers, expression, extraVars);
break;
複製代碼
須要判斷 DOM 元素類型(如input
、radio
等)所對應的事件,以及如何從事件取值,並一樣經過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)
});
});
}
複製代碼
這裏經過registerListener
的extraVars
傳入額外的參數rightSideOfExpression
,來使得這裏的listenerExpression
能夠正確的獲取修改後的值。
generateModelAssignmentFunction
對不一樣輸入元素類型就行判斷,以正確獲取值。例如對於input
,就是對event.target.value
再根據修飾符處理一下:
const rawValue = event.target.value;
return modifiers.includes('number') ? safeParseNumber(rawValue) : modifiers.includes('trim') ? rawValue.trim() : rawValue;
複製代碼
registerListeners
處理了x-on
和x-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;
}
複製代碼
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
等等成爲前端主流技術棧的大潮下,仍是出現了一些有着不一樣理念的工具和技術棧,瞭解一下也是挺有趣的。