From 9f13ba2afdb774260f242a4b501d0e98bc4d268a Mon Sep 17 00:00:00 2001 From: pimlie Date: Fri, 25 Oct 2019 15:27:32 +0200 Subject: [PATCH] refactor: use watchers to track all meta changes --- examples/vue-router/app.js | 87 ++++-- examples/vue-router/vue-meta-next/meta.js | 319 +++++++++++++++++++++ examples/vue-router/vue-meta-next/mixin.js | 90 ++++++ examples/vue-router/vue-meta-next/util.js | 66 +++++ 4 files changed, 546 insertions(+), 16 deletions(-) create mode 100644 examples/vue-router/vue-meta-next/meta.js create mode 100644 examples/vue-router/vue-meta-next/mixin.js create mode 100644 examples/vue-router/vue-meta-next/util.js diff --git a/examples/vue-router/app.js b/examples/vue-router/app.js index 8355d92..376feb3 100644 --- a/examples/vue-router/app.js +++ b/examples/vue-router/app.js @@ -1,11 +1,18 @@ import Vue from 'vue' import VueMeta from 'vue-meta' import Router from 'vue-router' +import { createMixin } from './vue-meta-next/mixin' Vue.use(Router) -Vue.use(VueMeta, { - refreshOnceOnNavigation: true +/*Vue.use(VueMeta, { + refreshOnceOnNavigation: false, + //waitOnDestroyed: false, + //debounceWait: 0 }) +/**/ + +Vue.mixin(createMixin()) +/**/ let metaUpdated = 'no' const ChildComponent = { @@ -15,20 +22,25 @@ const ChildComponent = {

You're looking at the {{ page }} page

Has metaInfo been updated due to navigation? {{ metaUpdated }}

`, - metaInfo () { - return { - title: `${this.page} - ${this.date && this.date.toTimeString()}`, - bodyAttrs: { - class: 'child-component' - }, - afterNavigation () { - metaUpdated = 'yes' - } - } + metaInfo: { + title() { + return `${this.page} - ${this.date && this.date.toTimeString()}` + }, + /*bodyAttrs: { + class: 'child-component' + },*/ + meta: [ + function () { return { vmid: 'descr', name: 'description', content: `description at ${this && this.date2 && this.date2.toTimeString()}` } }, + { name: 'og:description', content: `og:description at ${this && this.date2 && this.date2.toTimeString()}` } + ], + /*afterNavigation () { + metaUpdated = 'yes' + }*/ }, data () { return { date: null, + date2: null, metaUpdated } }, @@ -36,9 +48,14 @@ const ChildComponent = { this.interval = setInterval(() => { this.date = new Date() }, 1000) + + this.interval2 = setInterval(() => { + this.date2 = new Date() + }, 3000) }, destroyed () { clearInterval(this.interval) + clearInterval(this.interval2) } } @@ -65,23 +82,47 @@ const router = new Router({ ] }) +const getTime = () => window.performance.now() + +let navtime +let navtimes = [] + const App = { router, + metaInfo() { + return { + meta: [ + { vmid: 'descr', name: 'description', content: 'JAHASJDKASD' } + ] + } + }, + created() { + router.beforeEach((to, from, next) => { + navtime = getTime() + console.log('before', navtime) + next() + }) + + router.afterEach((to, from) => { + console.log('after', getTime()) + navtimes.push((getTime()) - navtime) + console.log(navtimes.length, navtimes.reduce((acc, v) => (acc + v), 0) / navtimes.length, document.title, '|', top.location.pathname) + }) + }, template: `

vue-router

Home About - -

Inspect Element to see the meta info

` } +// const app = new Vue(App) - +/* const { set, remove } = app.$meta().addApp('custom') set({ @@ -93,8 +134,22 @@ set({ ] }) setTimeout(() => remove(), 3000) - +*/ app.$mount('#app') +/* +let i = 0 +const interval = setInterval(() => { + router.push(top.location.href.includes('about') ? '/' : '/about') + i++ + if (i == 3) { + navtimes.shift() + navtimes.shift() + } + if (i >= 50) { + clearInterval(interval) + } +}, 1000) +/**/ /* const waitFor = time => new Promise(r => setTimeout(r, time || 1000)) diff --git a/examples/vue-router/vue-meta-next/meta.js b/examples/vue-router/vue-meta-next/meta.js new file mode 100644 index 0000000..d240ea7 --- /dev/null +++ b/examples/vue-router/vue-meta-next/meta.js @@ -0,0 +1,319 @@ +import Vue from 'vue' +import { + sortReverse, + sortMetaData, + createMetaInfoForTypeKey, + createWatcher +} from './util' + +// This object contains the metadata returned by vue components +// grouped by vmid. metadata without vmid is not listed here. +// The purpose of this object is to be able to re-assign the vmid +// when the VueComponent that supplied the vmid with the highest priority +// gets destroyed +// -> title is a special case, its always treated as if it has a vmid +export const allMetaData = { + title: [], + meta: {} +} + +// This object shadows the metadata that is actually rendered +// -> title is a special case, its always treated as if it has a vmid +// -> for meta (and link, script, etc) the signature here is: { +// any: [] // <- an array with all metadata objects without a vmid +// [vmid]: {} // <- for each vmid the vmid is used as key with the metadata object as value +export const displayedMetaData = Vue.observable({ + title: undefined, + meta: { + any: [] + } +}) + +// find the meta data with the highest priority, if multiple components exists +// with the same priority then the lowest component id is used +function evaluateMetaDataToUse(vm, type, key) { + // TODO: add error handling + if (key) { + const allMetaDataForTypeById = allMetaData[type][key] + + if (allMetaDataForTypeById.length > 1) { + allMetaDataForTypeById.sort(sortMetaData) + } + + // re-use the previous HTMLElement if possible + if (displayedMetaData[type][key]) { + // we need to copy the HTMLElement and unwatch function if they exist + for (const prop of ['_el', '_unwatch']) { + allMetaDataForTypeById[0][prop] = displayedMetaData[type][key][prop] + } + } + + // add watcher + Vue.set(displayedMetaData[type], key, allMetaDataForTypeById[0]) + + // check if this a new element that wasnt displayed before, if so + // add a watcher to create/modify the HTMLElement and store the + // returned unwatcher to prevent memory leaks + if (!allMetaDataForTypeById[0]._unwatch) { + const unwatch = vm.$root.$watch( + `$data.metaInfo.${type}.${key}`, + createWatcher(type), + { deep: true, immediate: true } + ) + + allMetaDataForTypeById[0]._unwatch = unwatch + } + return + } + + // TODO: this should only be called for title? maybe base? + + const allMetaDataForTypeById = allMetaData[type] + if (allMetaDataForTypeById.length > 1) { + // only sort when there is more then 1 element + allMetaDataForTypeById.sort(sortMetaData) + } + + Vue.set(displayedMetaData, type, allMetaDataForTypeById[0]) +} + +// This method is called every time a watcher is triggered on the +// classic metaInfo / head property of a SFC +export function updateMetaInfo(vm, data) { + for (const type in data) { + if (typeof data[type] === 'function') { + console.warn(`prop ${type} ignored, function within functions are not supported`) + continue + } + updateMetaInfoForType(vm, data[type], type) + } +} + +// To support changes bya single type (meta, link script) only, this method is +// used as the real updater method. It can also call itself +// -> nestedIndex: important prop. Its the indicator which groups updates returned +// from a meta array element item function. The index is 1-based (not 0-base) so +// we can still check for falsy values, eg: +// metaInfo: { +// meta: [ // <- calls updateMetaInfoForType without nestedIndex +// function () { return {} }, // <- calls updateMetaInfoForType without nestedIndex = 1 +// function () { return {} } // <- calls updateMetaInfoForType without nestedIndex = 2 +// ], +export function updateMetaInfoForType(vm, data, type, nestedIndex) { + nestedIndex = nestedIndex || 0 + + const _cid = vm._vueMeta.id + const allMetaDataForType = allMetaData[type] + + if (type === 'title') { + const index = allMetaDataForType.findIndex(metaData => metaData._cid === _cid) + + if (index === -1) { + allMetaDataForType.push({ _cid, prio: vm._vueMeta.depth || _cid, content: data }) + + evaluateMetaDataToUse(vm, type) + } else { + allMetaDataForType[index].content = data + } + + return + } + + if (!Array.isArray(data)) { + console.log(`What is this, an unsupported feature perhaps? Expected array for ${type}`) + return + } + + const vmidsSeen = [] + + for (const [idx, metaData] of Object.entries(data)) { + if (typeof metaData === 'function') { + const unwatch = vm.$watch( + metaData, + (newValue) => { + // ensure its an array + if (!Array.isArray(newValue)) { + newValue = [newValue] + } + updateMetaInfoForType(vm, newValue, type, idx + 1) + }, + { immediate: true } + ) + vm._vueMeta.unwatch.push(unwatch) + continue + } + + const { vmid } = metaData + if (!vmid) { + continue + } + + vmidsSeen.push(vmid) + + allMetaDataForType[vmid] = allMetaDataForType[vmid] || [] + + // TODO: not sure anymore if we need this + const _key = createMetaInfoForTypeKey(vm, vmid, nestedIndex) + const prio = metaData.prio || vm._vueMeta.depth || _cid + const index = allMetaDataForType[vmid].findIndex(metaData => metaData._key === _key) + + // set this before the splice when vmid was already lsited + const newOrPrioChanged = index === -1 || allMetaDataForType[vmid][index].prio !== prio + + // we can assign directly to metaData because its the result of a watcher + metaData = Object.assign(metaData, { + _cid, + _nested: nestedIndex, + _key, + prio + }) + + if (index === -1) { + allMetaDataForType[vmid].push(metaData) + } else { + // we are going to assign the metadata object to the eixsiting one in allMetaData + // TODO: is this really necessary? cant we just copy el/unwatch again and thats it? think so? + for (const prop in allMetaDataForType[vmid][index]) { + // we need to keep these props and we know they dont + if (['_el', '_unwatch'].includes(prop) || prop in metaData) { + continue + } + + delete allMetaDataForType[vmid][index][prop] + } + + Object.assign(allMetaDataForType[vmid][index], metaData) + } + + // only re-evaluate which data to show when its new data or the priority has changed + if (newOrPrioChanged) { + evaluateMetaDataToUse(vm, type, vmid) + } + } + + // remove old/previous metadata from this component + removeMetaDataForType(vm, _cid, type, ({ vmid, _nested }) => { + // dont remove if the metadata has a different nesting index + if (nestedIndex !== _nested) { + return false + } + + // remove if metadata doesnt has vmid or when its vmid wasnt listed in this update + return !vmid || !vmidsSeen.includes(vmid) + }) + + for (const metaData of data) { + if (typeof metaData === 'function' || metaData.vmid) { + // these types have already been handled above, ignore + continue + } + + // clone metaData!! + displayedMetaData[type].any.push({ + ...metaData, + _cid, + _nested: nestedIndex + }) + } +} + +export function removeMetaDataForType(vm, componentId, type, filter) { + for (const key in displayedMetaData[type]) { + if (key === 'any') { + // the any key lists all the metadata without a vmid + displayedMetaData[type][key] + .map((metaData, index) => { + if (metaData._cid !== componentId) { + return -1 + } + + if (filter && !filter(metaData)) { + return -1 + } + + return index + }) + .filter(index => index > -1) + // reverse sort to make sure array indexes arent changed after splicing + .sort(sortReverse) + .map(index => { + const { _el: el, vmid } = displayedMetaData[type][key][index] + if (el) { + el.remove() + } + + displayedMetaData[type][key].splice(index, 1) + + if (vmid) { + evaluateMetaDataToUse(vm, type, vmid) + } + }) + + continue + } + + // all other keys are vmid's, check that the currently rendered meta data + // belongs to the current component. If so, remove the metadata and + // re-assign the rendered vmid to a new value (if possible) + const { _cid, _el, _unwatch, vmid } = displayedMetaData[type][key] + if (_cid === componentId) { + // only remove vmids which we havent seen + if (filter && !filter(displayedMetaData[type][key])) { + continue + } + + if (_el) { + _el.remove() + } + + if (_unwatch) { + _unwatch() + } + + displayedMetaData[type][key] = null + evaluateMetaDataToUse(vm, type, vmid) + } + } +} + +// remove all the meta data from a component once it gets destroyed +export function onComponentDestroyed(vm, componentId) { + if (!vm.$options.metaInfo) { + return + } + + vm._vueMeta.unwatch.forEach(unwatch => unwatch()) + + componentId = componentId || vm._vueMeta.id + + for (const type in allMetaData) { + if (type === 'title') { + const reassignInfo = displayedMetaData[type] && displayedMetaData[type]._cid === componentId + const index = allMetaData[type].findIndex(({ _cid }) => _cid === componentId) + + if (index > -1) { + allMetaData[type].splice(index, 1) + + if (reassignInfo) { + evaluateMetaDataToUse(vm, type) + } + } + continue + } + + for (const vmid in allMetaData[type]) { + const index = allMetaData[type][vmid].findIndex(({ _cid }) => _cid === componentId) + if (index > -1) { + allMetaData[type][vmid].splice(index, 1) + } + } + } + + for (const type in displayedMetaData) { + if (type === 'title') { + continue + } + + removeMetaDataForType(vm, componentId, type) + } +} diff --git a/examples/vue-router/vue-meta-next/mixin.js b/examples/vue-router/vue-meta-next/mixin.js new file mode 100644 index 0000000..ddc64bb --- /dev/null +++ b/examples/vue-router/vue-meta-next/mixin.js @@ -0,0 +1,90 @@ +import { + allMetaData, + displayedMetaData, + onComponentDestroyed, + updateMetaInfo, + updateMetaInfoForType +} from './meta' + +import { + getComponentDepth, + createWatcher +} from './util' + +let vueMetaComponentId = 1 +let watchersAdded = false + +export function createMixin() { + return { + created() { + // add global meta watchers on root only once for the full page + // TODO: maybe create our own vue-meta Vue instance for that? + // -> new Vue instance takes a coupe of ms + if (this === this.$root) { + if (watchersAdded) { + return + } + watchersAdded = true + + // for easily debugging + this.$data.metaInfo = displayedMetaData + this.$data.allMetaInfo = allMetaData + + for (const type in displayedMetaData) { + if (type === 'title') { + this.$watch( + `$data.metaInfo.${type}`, + createWatcher(type), + { deep: true, immediate: true } + ) + continue + } + + for (const key in displayedMetaData[type]) { + this.$watch( + `$data.metaInfo.${type}.${key}`, + createWatcher(type), + { deep: true, immediate: true } + ) + } + } + } + + if (this.$options.metaInfo) { + this._vueMeta = { + id: vueMetaComponentId++, + depth: getComponentDepth(this), + unwatch: [] + } + + if (typeof this.$options.metaInfo === 'function') { + this.$watch( + this.$options.metaInfo, + (newValue) => updateMetaInfo(this, newValue), + { immediate: true } + ) + return + } + + for (const type in this.$options.metaInfo) { + const metaInfoForType = this.$options.metaInfo[type] + + if (typeof metaInfoForType === 'function') { + this.$watch( + metaInfoForType, + (newValue) => updateMetaInfoForType(this, newValue, type), + { deep: true, immediate: true } + ) + + continue + } + + updateMetaInfoForType(this, metaInfoForType, type) + } + } + }, + beforeDestroy() { + onComponentDestroyed(this) + } + } +} diff --git a/examples/vue-router/vue-meta-next/util.js b/examples/vue-router/vue-meta-next/util.js new file mode 100644 index 0000000..74f68ed --- /dev/null +++ b/examples/vue-router/vue-meta-next/util.js @@ -0,0 +1,66 @@ +export const internalProps = [ + 'prio' +] + +export const sortReverse = (a, b) => (b - a) +export const sortMetaData = (a, b) => { + if (a.prio === b.prio) { + return a._cid < b._cid ? -1 : 1 + } + + return a.prio > b.prio ? -1 : 1 +} + +export function createMetaInfoForTypeKey(vm, type, vmid, nestedIndex) { + return `${vm._vueMeta.id}-${type}${vmid ? '-'.concat(vmid) : ''}${nestedIndex ? '-'.concat(nestedIndex) : ''}` +} + +// the depth of a component is the default meta priority, as in +// the deeper the component the more important its meta data +export function getComponentDepth(vm) { + let depth = 0 + let parent = vm + + while (parent !== vm.$root) { + depth++ + parent = parent.$parent + } + + return depth +} + +export function updateElement(meta) { + const el = meta._el || document.createElement('meta') + + // set non-private props as attribute + Object.keys(meta) + .filter(key => key[0] !== '_' && !internalProps.includes(key)) + .forEach(key => { + if (!el.hasAttribute(key) || el.getAttribute(key) != meta[key]) { // eslint-disable-line eqeqeq + el.setAttribute(key, meta[key]) + } + }) + + if (meta._el) { + return + } + + meta._el = el + document.head.appendChild(el) +} + +export function createWatcher(type) { + return function metaDataWatcher(newValue) { + if (type === 'title') { + document.title = (newValue && newValue.content) || '' + return + } + + if (Array.isArray(newValue)) { + newValue.forEach(updateElement) + return + } + + updateElement(newValue) + } +}