大家好。今天简单介绍下alpine.js的使用和原理。
为什么会想到介绍alpine.js
呢?有以下几个原因:
- 此前刚接触了
tailwindcss
并写了篇文章做了简单介绍。而alpine.js
的标语则是“像写tailwindcss一样写js”,同时tailwindcss
也是apline.js
的赞助者。 - 前后端在经过彻底的分离之后,服务端渲染再次成为热门议题。除了最主流的
SSR
(服务端渲染+前端水合)方案之外,也出现了适合不同场景的不同方案,例如JAM
和TALL。TALL
是Laravel
主推的一套快速的全栈开发方案,是TailwindCSS
、Alpine.js
、Laravel
和Livewire
的首字母缩写。 - 最后一个原因,因为在
react
和vue
大火之前,自己所在的团队也曾经开发过类似alpine.js
的库,难免有些亲切感。
简介
alpine.js
官方的这两句简介足以概括其与当前主流前端框架的不同之处。apline.js
主打的就是轻和快。
TALL
是传统的后端渲染机制,面对的用户也是以php开发者为主。不同于SSR
的跨端组件,TALL
以传统的后端模板机制完成页面渲染,前端再通过alpine.js
提供交互。作为这套技术栈中前端重要的一环,轻量级、学习成本低,都是alpine.js
的加分项。
alpine.js
无需安装,免去了webpack
、yarn
之类的学习成本,类似vue
的语法也非常容易上手。为了保持轻巧,alpine.js
选择了一些不同的实现方式,例如不依赖虚拟 DOM,模板通过遍历 DOM 来解析等等,这些会在文章后半部分介绍。
使用apline
开始使用
通常,我们只需在页面上引入alpine.js
就可以了:
<script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine.js" defer></script>
然后来看一个简单的例子:
<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,就实现了一个简单的组件。
指令(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:
和@
两种形式,以及提供了例如self
、prevent
、away
等修饰符:
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
});
}
接着,是响应式原理。这里主要涉及两个类: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;
}
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
方法通过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
方法中,将遍历元素的属性,并通过对应的指令进行处理。
指令
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
就是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
对数据进行修改时,组件会触发重新渲染。
model
如果是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;
text
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;
}
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
等等成为前端主流技术栈的大潮下,还是出现了一些有着不同理念的工具和技术栈,了解一下也是挺有趣的。
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!