Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Diff Attributes #2

Merged
merged 1 commit into from
Dec 9, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion example/components/Form.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ function Form() {
<option value="usa">United States</option>
</select>` : '' }

${state.form.firstName.length && state.form.lastName.length ? '<input type="submit" value="Submit">' : '' }
<input type="submit" value="Submit" ${state.form.firstName.length && state.form.lastName.length ? '' : 'hidden' }>
</form>
</div>
`
Expand Down
34 changes: 34 additions & 0 deletions example/components/TodoList.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { reactive, on, nextTick } from '@oddx/reactive'

function TodoList() {
const state = reactive({
todos: [],
text: ''
})

function addTodo() {
state.todos.push(state.text)
nextTick()
state.text = ' '
}

return () => {
on('#form', 'submit', e => e.preventDefault())
on('#input', 'input', e => state.text = e.target.value)
on('#add-btn', 'click', addTodo)
return `
<form id="form">
<label>
<span>Add Todo</span>
<input id="input" value="${state.text}" />
</label>
<button id="add-btn" type="submit" ${state.text.length > 1 ? '' : 'hidden'}>Add</button>
<ul>
${state.todos.map(todo => `<li>${todo}</li>`).join('')}
</ul>
</form>
`
}
}

export default TodoList
2 changes: 2 additions & 0 deletions example/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { render, Router } from '@oddx/reactive'
import Index from './components/Index'
import Post from './components/Post'
import Form from './components/Form'
import TodoList from './components/TodoList'
import CopyRight from './components/Copyright'

const router = Router.useRouter()
router.route('/', Index)
router.route('/posts/:id', Post)
router.route('/form', Form)
router.route('/todo', TodoList)

router.render('#app')

Expand Down
183 changes: 183 additions & 0 deletions src/diffAtt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Attributes that might be changed dynamically
// Examples: <input value="something"/> <button hide></button>
const dynamicAttributes = ['checked', 'selected', 'value', 'hide']

interface Attribute {
att: string,
value: string
}

function find(arr: any, callback: any) {
const matches = arr.filter(callback)
if (matches.length < 1) return null
return matches[0]
}

export function diffAtts(template: HTMLElement, elem: HTMLElement): void {
const templateAtts = getAttributes(template, true)
const elemAtts = getAttributes(elem)

const remove = elemAtts.filter((att: Attribute) => {
if (dynamicAttributes.indexOf(att.att) > -1) return false
const getAtt = find(templateAtts, (newAtt: Attribute) => {
return att.att === newAtt.att
})
return getAtt === null
})

const change = templateAtts.filter((att: Attribute) => {
const getAtt = find(elemAtts, (elemAtt: Attribute) => {
return att.att === elemAtt.att
})
return getAtt === null || getAtt.value !== att.value
})

addAttributes(elem, change)
removeAttributes(elem, remove)
}

function getDynamicAttributes(node: HTMLElement, atts: Attribute, isTemplate?: boolean) {
dynamicAttributes.forEach((prop) => {
// @ts-ignore
if ((!node[prop] && node[prop] !== 0) || (isTemplate && node.tagName.toLowerCase() === 'option' && prop === 'selected')
|| (isTemplate && node.tagName.toLowerCase() === 'select' && prop === 'value')) return
// @ts-ignore
atts.push(getAttribute(prop, node[prop]))
})
}

function getBaseAttributes(node: HTMLElement, isTemplate?: boolean) {
return Array.prototype.reduce.call(node.attributes, (arr: any, attribute: any) => {
if ((dynamicAttributes.indexOf(attribute.name) < 0 || (isTemplate && attribute.name === 'selected'))
&& (attribute.name.length > 7 ? attribute.name.slice(0, 7) !== 'default' : true))
{
arr.push(getAttribute(attribute.name, attribute.value))
}
return arr
}, [])
}

function getAttribute(name: string, value: string): Attribute {
return {
att: name,
value: value
}
}

function getAttributes(node: HTMLElement, isTemplate?: boolean) {
if (node.nodeType !== 1) return []
const atts = getBaseAttributes(node, isTemplate)
getDynamicAttributes(node, atts, isTemplate)
return atts
}

function addAttributes(elem: HTMLElement, atts: Attribute[]) {
atts.forEach((attribute) => {
// If the attribute is a class, use className
// Else if it's style, diff and update styles
// Otherwise, set the attribute
if (attribute.att === 'class') {
elem.className = attribute.value
} else if (attribute.att === 'style') {
diffStyles(elem, attribute.value);
} else {
if (attribute.att in elem) {
try {
// @ts-ignore
elem[attribute.att] = attribute.value
// @ts-ignore
if (!elem[attribute.att] && elem[attribute.att] !== 0) {
// @ts-ignore
elem[attribute.att] = true
}
} catch (e) {}
}
try {
elem.setAttribute(attribute.att, attribute.value)
} catch (e) {}
}
})
}

export function addDefaultAtts(node: HTMLElement): void {
// Only run on elements
if (node.nodeType !== 1) return

// Check for default attributes
// Add/remove as needed
Array.prototype.forEach.call(node.attributes, (attribute: any) => {
if (attribute.name.length < 8 || attribute.name.slice(0, 7) !== 'default') return
addAttributes(node, [getAttribute(attribute.name.slice(7).toLowerCase(), attribute.value)])
removeAttributes(node, [getAttribute(attribute.name, attribute.value)])
})

// If there are child nodes, recursively check them
if (node.childNodes) {
Array.prototype.forEach.call(node.childNodes, (childNode: HTMLElement) => {
addDefaultAtts(childNode)
})
}
}

function removeAttributes(elem: HTMLElement, atts: Attribute[]) {
atts.forEach((attribute) => {
// If the attribute is a class, use className
// Else if it's style, remove all styles
// Otherwise, use removeAttribute()
if (attribute.att === 'class') {
elem.className = ''
} else {
if (attribute.att in elem) {
try {
// @ts-ignore
elem[attribute.att] = ''
} catch (e) {}
}
try {
elem.removeAttribute(attribute.att)
} catch (e) {}
}
})
}

function diffStyles(elem: HTMLElement, styles: any) {
// Get style map
const styleMap = getStyleMap(styles)

// Get styles to remove
const remove = Array.prototype.filter.call(elem.style, (style: any) => {
const findStyle = find(styleMap, function(newStyle: any) {
return newStyle.name === style && newStyle.value === elem.style[style]
})
return findStyle === null
})

// Add and remove styles
removeStyles(elem, remove)
changeStyles(elem, styleMap)
}

function getStyleMap(styles: any) {
return styles.split('').reduce((arr: any, style: any) => {
const col = style.indexOf(':')
if (col) {
arr.push({
name: style.slice(0, col).trim(),
value: style.slice(col + 1).trim()
})
}
return arr
}, [])
}

function removeStyles(elem: HTMLElement, styles: any) {
styles.forEach((style: any) => {
elem.style[style] = ''
})
}

function changeStyles(elem: HTMLElement, styles: any) {
styles.forEach((style: any) => {
elem.style[style.name] = style.value
})
}
2 changes: 1 addition & 1 deletion src/lifeCycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ function useLifeCycle() {

makeFuncReactiveAndExecuteIt(() => {
const templateHTML = stringToHTML(typeof fn !== 'function' ? fn : fn());
const elem = document.querySelector(selector)
const elem = <HTMLElement>document.querySelector(selector)
patch(templateHTML, elem);
})
}
Expand Down
46 changes: 26 additions & 20 deletions src/patch.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { addDefaultAtts, diffAtts } from './diffAtt'

export function stringToHTML(str: string): HTMLElement {
const parser = new DOMParser();
const doc = parser.parseFromString(str, 'text/html');
Expand All @@ -12,66 +14,70 @@ const NODE_TYPE_CONST = {
}

// Patch DOM, diffing with currentDOM
export function patch(template: HTMLElement | Node, elem: Element | HTMLElement | DocumentFragment) {
const currentDOMNodes = Array.prototype.slice.call(elem.childNodes)
const templateNodes = Array.prototype.slice.call(template.childNodes)
export function patch(template: HTMLElement, elem: HTMLElement | DocumentFragment): void {
const oldNodes = Array.prototype.slice.call(elem.childNodes)
const newNodes = Array.prototype.slice.call(template.childNodes)

// If extra elements in DOM, remove them
let count = currentDOMNodes.length - templateNodes.length
let count = oldNodes.length - newNodes.length
if (count > 0) {
for (; count > 0; count--) {
currentDOMNodes[currentDOMNodes.length - count].parentNode.removeChild(currentDOMNodes[currentDOMNodes.length - count])
oldNodes[oldNodes.length - count].parentNode.removeChild(oldNodes[oldNodes.length - count])
}
}

// Diff each item in the templateNodes
templateNodes.forEach(function(node: Node, index: number) {
// Diff each item in the newNodes
newNodes.forEach((node: HTMLElement, index: number) => {
// If element doesn't exist, create it
if (!currentDOMNodes[index]) {
if (!oldNodes[index]) {
addDefaultAtts(node)
elem.appendChild(node.cloneNode(true))
return
}

// If element is not the same type, replace it with new element
if (getNodeType(node) !== getNodeType(currentDOMNodes[index])) {
currentDOMNodes[index].parentNode.replaceChild(node.cloneNode(true), currentDOMNodes[index])
if (getNodeType(node) !== getNodeType(oldNodes[index])) {
oldNodes[index].parentNode.replaceChild(node.cloneNode(true), oldNodes[index])
return
}

// If attributes are different, update them
diffAtts(node, oldNodes[index]);

// If content is different, update it
const templateContent = getNodeContent(node)
if (templateContent && templateContent !== getNodeContent(currentDOMNodes[index])) {
currentDOMNodes[index].textContent = templateContent
if (templateContent && templateContent !== getNodeContent(oldNodes[index])) {
oldNodes[index].textContent = templateContent
}

// If target element should be empty, wipe it
if (currentDOMNodes[index].childNodes.length > 0 && node.childNodes.length < 1) {
currentDOMNodes[index].innerHTML = ''
if (oldNodes[index].childNodes.length > 0 && node.childNodes.length < 1) {
oldNodes[index].innerHTML = ''
return
}

// If element is empty and shouldn't be, build it up
// This uses a document fragment to minimize reflow
if (currentDOMNodes[index].childNodes.length < 1 && node.childNodes.length > 0) {
if (oldNodes[index].childNodes.length < 1 && node.childNodes.length > 0) {
const fragment = document.createDocumentFragment()
patch(node, fragment)
currentDOMNodes[index].appendChild(fragment)
oldNodes[index].appendChild(fragment)
return
}

// If there are existing child elements that need to be modified, diff them
if (node.childNodes.length > 0) {
patch(node, currentDOMNodes[index])
patch(node, oldNodes[index])
}
})

function getNodeType(node: Node | HTMLElement) {
function getNodeType(node: HTMLElement): string {
if (node.nodeType === NODE_TYPE_CONST.TEXT_NODE) return 'text'
if (node.nodeType === NODE_TYPE_CONST.COMMENT_NODE) return 'comment'
return "tagName" in node ? node.tagName.toLowerCase() : null
return node.tagName.toLowerCase()
}

function getNodeContent(node: Node | HTMLElement) {
function getNodeContent(node: HTMLElement): string {
return node.childNodes && node.childNodes.length > 0 ? null : node.textContent
}
}
Loading