admin管理员组文章数量:1326474
I use vanilla JS for interactive webpages, and I'm trying to understand whether my design pattern is correctly implementing the principle of reactivity.
(Note: I'm not referring to the library React -- though I'm happy for answers to draw on features or strategies of such libraries).
My basic understanding is that you have some data which acts as your single source of truth, you listen for changes, and when that data changes, your page|app|ponent re-renders to reflect that.
Here's a simplified version of what I do, with questions after.
Let's say I have my single source of truth:
let data = {}
data.someContent = 'Hello World'
data.color = 'red'
and my app's markup in a template string for dynamic rendering:
function template(data) {
return `
<div id="app" style="color:${data.color}">${data.someContent}</div>
`
}
// assume there are also plain HTML inputs on the page, outside of what gets re-rendered.
a function that renders based on the data:
function render(data) {
document.getElementById('app').innerHtml = template(data)
}
then, for the equivalent of reactivity from client-side updates:
document.addEventListener('input', (e) => {
data[e.target.id] = e.target.value // update data to reflect input
render(data) // re-render based on new data
})
and from server-side updates:
function fetchDataAndReRender() {
data.propToUpdate = // fetch data from server
render(data) // again, re-render
return
}
So, we've got the single source of truth and re-rendering based on data updates.
- Is there another pillar to this, beyond the trio of data, listeners, and rendering?
- I understand that libraries usually listen directly to changes on the
data
object, e.g. via Proxies. It seems like the only advantage to that is avoiding manually callingrender()
. Is that correct?
--
EDIT: Adding a link to a blog post that mimics the vanilla reactive strategy above: / There are some minor variations, but it serves as a good reference point for anyone interested in this style.
I use vanilla JS for interactive webpages, and I'm trying to understand whether my design pattern is correctly implementing the principle of reactivity.
(Note: I'm not referring to the library React -- though I'm happy for answers to draw on features or strategies of such libraries).
My basic understanding is that you have some data which acts as your single source of truth, you listen for changes, and when that data changes, your page|app|ponent re-renders to reflect that.
Here's a simplified version of what I do, with questions after.
Let's say I have my single source of truth:
let data = {}
data.someContent = 'Hello World'
data.color = 'red'
and my app's markup in a template string for dynamic rendering:
function template(data) {
return `
<div id="app" style="color:${data.color}">${data.someContent}</div>
`
}
// assume there are also plain HTML inputs on the page, outside of what gets re-rendered.
a function that renders based on the data:
function render(data) {
document.getElementById('app').innerHtml = template(data)
}
then, for the equivalent of reactivity from client-side updates:
document.addEventListener('input', (e) => {
data[e.target.id] = e.target.value // update data to reflect input
render(data) // re-render based on new data
})
and from server-side updates:
function fetchDataAndReRender() {
data.propToUpdate = // fetch data from server
render(data) // again, re-render
return
}
So, we've got the single source of truth and re-rendering based on data updates.
- Is there another pillar to this, beyond the trio of data, listeners, and rendering?
- I understand that libraries usually listen directly to changes on the
data
object, e.g. via Proxies. It seems like the only advantage to that is avoiding manually callingrender()
. Is that correct?
--
EDIT: Adding a link to a blog post that mimics the vanilla reactive strategy above: https://css-tricks./reactive-uis-vanillajs-part-1-pure-functional-style/ There are some minor variations, but it serves as a good reference point for anyone interested in this style.
Share Improve this question edited Jan 2 at 9:08 Peter Seliger 13.4k3 gold badges30 silver badges44 bronze badges asked Jul 25, 2022 at 18:03 ultraGentleultraGentle 6,3472 gold badges26 silver badges58 bronze badges 4- 1 Could whoever left the close vote indicate why? As far as I can tell, this is on topic for SO, shows work, isn't a duplicate, etc. – ultraGentle Commented Jul 25, 2022 at 18:58
- Maybe this is something that can help understand what reactivity is. – Picci Commented Jul 25, 2022 at 19:30
- @Picci Thanks! As far as I can tell, what I'm describing is reactive -- that's why I'm wondering if I'm missing something. There are certainly other implementations of reactivity, but the result and principle seem the same. – ultraGentle Commented Jul 25, 2022 at 19:38
- I have a long ment that I have posted as an answer since it is too long to fit here. – Picci Commented Jul 25, 2022 at 20:54
3 Answers
Reset to default 3The main thing I'm missing here is a way to optimize re-renders.
Interacting with the DOM is expensive, so apart from using a Proxy
around your data/state to automatically call render
, you would ideally want to avoid just replacing all the HTML for your app if only one element or, say, one attribute, has changed:
Consider updating an
<input>
. If you are not able to detect that only its value has changed and update that, and instead you replace the whole HTML that includes this input, the cursor will move to the end after each re-render (so probably after every character I type).Consider a drag & drop feature. If you are not able to detect that the user is dragging something across the screen and only update the CSS
transform
property on it, and instead you replace the whole HTML that includes the element being dragged, the performance is going to be horrible. Also, you'll be interrupting the drag events, so this won't work at all.
Another possible optimization would be batching updates, so if you update your data 3 times in a row, you could potentially only update the HTML once with the final result. You already mentioned you debounce continuous input events, which would be one way of doing it.
Answers to your ments:
(1) I assumed you wanted a general purpose solution, that's where being able to detect changes and only update the parts of the DOM that need to be updated is a must.
If your only need to (re)render small static chunks of HTML, then you can probably go without this. The main two reasons being:
As you said, there's no interactivity, so you'll not experience the issues I exemplified.
As you updates are small, you are unlikely to see any performance issues by replacing blocks of HTML instead of trying to update the existing elements in the DOM.
(2) In any case, the main problem you would have to face if trying to update only what has change would not be finding the data that has changed (regardless of whether you use a Proxy or not), but to map that to the elements that need to be updated and actions that would perform those updates.
(3) You can also make promises on the app side, say, that the data must be immutable (like with React).
The OP's interpretation of "reactivity" within the context of model and view ...
My basic understanding is that you have some data which acts as your single source of truth, you listen for changes, and when that data changes, your page|app|ponent re-renders to reflect that.
... is correct. The change of a model's value immediately gets reflected by an update of the corresponding/related part of the view/DOM. Nevertheless, what the OP's implementation/example misses is ...
1st an abstraction of the re-/rendering part which binds the data-values to their corresponding/related text- respectively attribute-nodes within the DOM
and 2nd a really good abstraction for a data-node that actively emits/dispatches/signals any value change.
One, for instance could start with the latter, a simple data-node which within some later/further refactoring step(s) could be used as base for a more plex tree like data structure of branches (linked data-nodes) and leafs (primitive values like strings, numbers, booleans etc.).
Such a data-node implementation could be achieved by extending EventTarget
together with each DataNode
instance being wrapped into a Proxy
instance.
Example code (Note: only the get
and set
traps have been implemented in order to avoid over-plexity within the example) ...
// module 'data-node.js'
/**
* @class
* @extends EventTarget
*/
class DataNode extends EventTarget {
/**
* @param {Object} data
*/
constructor(data) {
super();
Object.assign(this, structuredClone(data ?? {}));
}
}
/**
* @param {Object} data
* @returns {typeof DataNode}
*/
export function createObservableDataNode(data) {
return new Proxy(new DataNode(data), {
set(target, key, currentValue, proxy) {
const recentValue = Reflect.get(target, key, proxy);
// guard.
if (currentValue === recentValue) {
return true;
}
const eventOption = {
detail: {
changeType: 'patch',
dataNode: proxy,
key,
value: {
recent: recentValue,
current: currentValue,
},
},
};
const success = Reflect.set(target, key, currentValue, proxy);
if (success) {
target.dispatchEvent(new CustomEvent('data-change', eventOption));
target.dispatchEvent(
new CustomEvent(`data-change::keypath::${key}`, eventOption)
);
} else {
throw new TypeError(
'Unmanaged Type Error from setting new data value.'
);
}
return success;
},
get(target, key, proxy) {
const value = Reflect.get(target, key, proxy);
return (
(
(key === 'addEventListener' || key === 'removeEventListener') &&
value.bind(target)
) ||
value
);
},
});
}
Achieving a fully functional automation for the render/re-rendering part is much more challenging, though everything boils down to a single special render function (called e.g. html
) based on a Tagged Template which is a more capable variant of a Template Literal.
The tagged template allows both, accessing parts of the markup and the data, thus enabling an initial render process where the html
's return value is a fully parsed document-fragment where each of the template's data-insertions is represented by ...
either a single text-node which has its value updated with every of its corresponding data-value change
or by a process which initially renders attribute-node values and updates them whenever related data-values change.
The next following example code utilizes the just two functions createObservableDataNode
and html
which are necessary for establishing a reactive binding between a data-node and its related view. An older answer and code of mine, provided at a similar question at SO ... "How can I update div content after JavaScript object change?" .., will be taken as the example's base in order to demonstrate exactly that ...
/*
* the only necessary code in order to apply reactive data re-/rendering
*/
// import { createObservableDataNode } from './data-node';
// import { html } from './render.js';
function fetchAndUpdateTemperaturesNorthOf(observableData) {
const url =
'https://api.open-meteo./v1/forecast?longitude=9.7332¤t_weather=true&latitude=';
let { latitude } = observableData;
--latitude;
const intervalId = setInterval(async () => {
if (++latitude < 90) {
const response = await fetch(url + latitude);
const meteoData = await response.json();
const { current_weather: cw } = meteoData;
observableData.latitude = latitude.toFixed(4);
observableData.cwTime = cw.time;
observableData.cwTemperature = cw.temperature;
} else {
clearInterval(intervalId);
observableData.fetchState = 'finished.';
}
}, 1000);
observableData.fetchState = '... running ...';
}
const dataNode = createObservableDataNode({
latitude: 51.3705,
longitude: 9.7332,
cwTime: '...',
cwTemperature: '...',
fetchState: 'not yet initialized.',
});
const fragment = html`
<section>
<h1>Current temperatures, north of Hanover, Germany</h1>
<dl>
<dt>time</dt>
<dd>${[dataNode, 'cwTime']}</dd>
<dt>latitude</dt>
<dd>${[dataNode, 'latitude']}</dd>
<dt>longitude</dt>
<dd>${[dataNode, 'longitude']}</dd>
<dt>temperature</dt>
<dd>${[dataNode, 'cwTemperature']}°C</dd>
<dt>fetch state</dt>
<dd class="fetch-state">${[dataNode, 'fetchState']}</dd>
</dl>
</section>`;
document.body.appendChild(fragment);
fetchAndUpdateTemperaturesNorthOf(dataNode);
body, h1, dl { margin: 0; }
h1 { font-size: .8em; }
dl { font-size: .85em; }
dd { font-family: monospace; font-size: 1.2em; font-weight: 600; color: #666; }
.fetch-state { font-size: 1em; font-weight: 900; color: #000; }
<!--
the necessary reactive library functionality provided as modules
-->
<script>
// module 'data-node.js'
class DataNode extends EventTarget {
constructor(data) {
super();
Object.assign(this, structuredClone(data ?? {}));
}
get [Symbol.toStringTag]() {
return 'DataNode';
}
}
function isDataNode(value) {
return Object.prototype.toString.call(value) === '[object DataNode]';
}
function createObservableDataNode(data) {
return new Proxy(new DataNode(data), {
set(target, key, currentValue, proxy) {
const recentValue = Reflect.get(target, key, proxy);
// guard.
if (currentValue === recentValue) {
return true;
}
const eventOption = {
detail: {
changeType: 'patch',
dataNode: proxy,
key,
value: {
recent: recentValue,
current: currentValue,
},
},
};
const success = Reflect.set(target, key, currentValue, proxy);
if (success) {
target.dispatchEvent(new CustomEvent('data-change', eventOption));
target.dispatchEvent(
new CustomEvent(`data-change::keypath::${key}`, eventOption)
);
} else {
throw new TypeError(
'Unmanaged Type Error from setting new data value.'
);
}
return success;
},
get(target, key, proxy) {
const value = Reflect.get(target, key, proxy);
return (
((key === 'addEventListener' || key === 'removeEventListener') &&
value.bind(target)) ||
value
);
},
});
}
</script>
<script>
// module 'keypath.js'
// import { DataNode } from './data-node';
function parseKeypathTokens(value) {
const regXKeypathTokens =
/(?<!\\)\[((?<idx>\d+)|(?<quote>["'])(?<key>.*?)\k<quote>)(?<!\\)\]|(?<!\\)(?<dot>\.)/;
const keys = [];
let result, index, dot, idx, key;
while ((result = regXKeypathTokens.exec(value))) {
({
index,
groups: { dot, idx, key },
} = result);
if (dot) {
if (index !== 0) {
keys.push({
key: value.slice(0, index),
});
}
value = value.slice(index + 1);
} else if (idx) {
if (index !== 0) {
keys.push({
key: value.slice(0, index),
});
}
keys.push({
index: parseInt(idx, 10),
});
value = value.slice(index + idx.length + 2);
} else if (key) {
if (index !== 0) {
keys.push({
key: value.slice(0, index),
});
}
keys.push({
key,
});
value = value.slice(index + key.length + 4);
}
}
if (value !== '') {
keys.push({
key: value,
});
}
return keys;
}
function getValueByKeypath(data, keypath) {
return parseKeypathTokens(keypath).reduce(
(dataNode, token) => {
return dataNode?.[token.key ?? token.index];
},
data
);
}
</script>
<script>
// module 'render.js'
// import { DataNode, isDataNode } from './data-node';
// import { getValueByKeypath } from './keypath';
function isDataEntry(value) {
return (
Array.isArray(value) &&
isDataNode(value.at(0)) &&
typeof value.at(1) === 'string'
);
}
function aggregateAttributeNodeConnectedDataAndSanitizeNodeValue(
collector,
dataId
) {
const { attrNode, dataIndex, dataList } = collector;
const attrValue = attrNode.nodeValue;
const regXTracer = new RegExp(String.raw`<span ${dataId} ></span>`, '');
if (regXTracer.test(attrValue)) {
dataList.push({
id: dataId,
entry: dataIndex.get(dataId),
});
attrNode.nodeValue = attrValue.replace(regXTracer, dataId);
}
return collector;
}
function replaceDataIdWithDataValue(
template, { id: dataId, entry: [data, keypath] }
) {
return template.replace(dataId, getValueByKeypath(data, keypath));
}
function rerenderAttributeNodeValue(attrNode, dataList, template /*, evt */) {
attrNode.nodeValue = dataList.reduce(replaceDataIdWithDataValue, template);
}
function bindAttributeNodeDataReactively(attrNode, dataList) {
const rerenderValue = rerenderAttributeNodeValue.bind(
null,
attrNode,
dataList,
attrNode.nodeValue
);
dataList.forEach(({ entry: [data, keypath] }) =>
data.addEventListener(`data-change::keypath::${keypath}`, rerenderValue)
);
rerenderValue();
}
function mapAttributeNodeAndDataIdConnection(collector, attrName) {
const { fragment } = collector;
collector.dataIds.reduce(
(dataBinding, dataId) => {
fragment
.querySelectorAll(`[${attrName}~="${dataId}"]`)
.forEach((elmNode) => {
const attrNode = elmNode.getAttributeNode(attrName);
if (!dataBinding.has(attrNode)) {
dataBinding.set(attrNode, new Set());
}
dataBinding.get(attrNode).add(dataId);
});
return dataBinding;
},
collector.dataBinding
);
return collector;
}
function createReactiveAttributeNodeDataBinding(
fragment,
attrNames,
dataIndex
) {
const {
dataBinding: dataIdsByAttrNode,
} = attrNames.values().reduce(mapAttributeNodeAndDataIdConnection, {
fragment,
dataIds: dataIndex.keys(),
dataBinding: new Map(),
});
dataIdsByAttrNode
.entries()
.forEach(
([attrNode, dataIds]) => {
const { dataList } = dataIds
.values()
.reduce(aggregateAttributeNodeConnectedDataAndSanitizeNodeValue, {
attrNode,
dataIndex,
dataList: [],
});
bindAttributeNodeDataReactively(attrNode, dataList);
}
);
}
function rerenderTextNodeValue(textNode, evt) {
textNode.nodeValue = evt.detail.value.current;
}
function bindTextNodeDataReactively(elmNode, data, dataIndex) {
const dataId = elmNode.getAttributeNames().at(0);
const [dataNode, keypath] = dataIndex.get(dataId);
if (dataNode !== data) {
throw new ReferenceError(
'The data binding ran into an un(re)solvable mismatch of data-node references.'
);
}
const textNode = document.createTextNode(getValueByKeypath(data, keypath));
data.addEventListener(
`data-change::keypath::${keypath}`,
rerenderTextNodeValue.bind(null, textNode)
);
elmNode.replaceWith(textNode);
}
function aggregateTraceElementsSelector(selector, dataId) {
return (selector && `${selector}, span[${dataId}]`) || `span[${dataId}]`;
}
function createReactiveTextNodeDataBinding(fragment, data, dataIds, dataIndex) {
const selector = dataIds
.values()
.reduce(aggregateTraceElementsSelector, '');
fragment
.querySelectorAll(selector)
.forEach((elmNode) => bindTextNodeDataReactively(elmNode, data, dataIndex));
}
function parseDocumentFragment(markup, mimeType) {
const fragment = document.createDocumentFragment();
const elmBody = new DOMParser().parseFromString(markup, mimeType).body;
while (elmBody.firstChild) {
fragment.appendChild(elmBody.removeChild(elmBody.firstChild));
}
return fragment;
}
function createReactiveDocumentFragment(
dataBinding,
dataIndex,
attrNames,
markup,
mimeType = 'text/html'
) {
const fragment = parseDocumentFragment(markup, mimeType);
dataBinding
.entries()
.forEach(([data, dataIds]) =>
createReactiveTextNodeDataBinding(fragment, data, dataIds, dataIndex)
);
createReactiveAttributeNodeDataBinding(fragment, attrNames, dataIndex);
return fragment;
}
function aggregateTraceableMarkupAndMapDataBinding(collector, token, idx) {
const {
dataBinding,
dataIndex,
attrNames,
markup,
dataEntries,
regXAttrName,
} = collector;
const dataEntry = dataEntries.at(idx);
const [data, keypath = ''] = (isDataEntry(dataEntry) && dataEntry) || [
dataEntry,
];
const isMustBeReactive = isDataNode(data);
if (isMustBeReactive) {
if (!dataBinding.has(data)) {
dataBinding.set(data, new Set());
}
const dataIds = dataBinding.get(data);
const dataId = `data-${crypto.randomUUID()}`;
const tracer = `<span ${dataId} ></span>`;
const { attrName } =
regXAttrName.exec(token)?.groups ?? {};
if (attrName) {
attrNames.add(attrName);
}
dataIds.add(dataId);
dataIndex.set(dataId, [data, keypath]);
collector.markup = [markup, token, tracer].join('');
} else if (Object.hasOwn(dataEntries, idx)) {
collector.markup = [markup, token, data].join('');
} else {
collector.markup = [markup, token].join('');
}
return collector;
}
function html(tokenList, ...dataEntries) {
const {
dataBinding,
dataIndex,
attrNames,
markup,
} = tokenList.reduce(aggregateTraceableMarkupAndMapDataBinding, {
dataBinding: new Map(),
dataIndex: new Map(),
attrNames: new Set(),
markup: '',
dataEntries,
regXAttrName:
/\s(?<attrName>(?:_|\p{L})[\p{L}\p{N}_-]+)\s*=\s*(?<quote>['"])(?:[^'"]|(?<=\\)['"])*$/u,
});
return createReactiveDocumentFragment(
dataBinding,
dataIndex,
attrNames,
markup
);
}
</script>
This is not an answer to your question. It is a ment too long to fit in the ment space.
What "thinking reactive" means, at least in my opinion
"thinking reactive" means, as far as I can tell, that you model your problem in terms of "streams of events" and the "side effects" such events have, for instance what happens on your screen when some of these events occur.
In an front end app, the first event is "somebody opens the app" and the side effect is that "you see something on your screen", usually the browser.
Then, starting from that event, you switch to another stream of event, or probably the bination of many streams of events.
This bination can be the bination of:
- "the user clicks buttonA"
- "the user scrolls down the page"
Each of these streams of events has its own side effects:
- "the user clicks buttonA" may lead you to another page
- "the user scrolls down the page" may trigger a call to a remote service for the next page of data
The summary is that, in reactive style, your write the book of "what happens when" and then you unfold this book.
In more precise terms, you can declare how an entire application will behave as a single stream of events and the related side effects.
Once your declaration is plete (and correct) you simply subscribe to the stream and let the events, and their side effect, flow.
Is it worth programming this way down to the extreme? Probably not, as all extreme positions.
Is it convenient some times? Definitely yes.
本文标签: javascriptImplementing reactivity in vanilla JSStack Overflow
版权声明:本文标题:javascript - Implementing reactivity in vanilla JS - Stack Overflow 内容由网友自发贡献,该文观点仅代表作者本人, 转载请联系作者并注明出处:http://www.betaflare.com/web/1742209527a2433448.html, 本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容,一经查实,本站将立刻删除。
发表评论