Vue custom directive uses the updated Dom (or $el)

泄露秘密 提交于 2021-02-08 07:36:45

问题


I want to design one custom directive to replace 'cx' to <strong>cx</strong> for all TextNodes in the Dom Tree.

Below is what I had tried so far:

Vue.config.productionTip = false

function removeKeywords(el, keyword){
  if(!keyword) return
  let n = null
  let founds = []
  walk=document.createTreeWalker(el,NodeFilter.SHOW_TEXT,null,false)
  while(n=walk.nextNode()) {
    if(n.textContent.trim().length < 1) continue
    founds.push(n)
  }
  let result = []
  founds.forEach((item) => {
    if( new RegExp('cx', 'ig').test(item.textContent) ) {
      let kNode = document.createElement('span')
      kNode.innerHTML = item.textContent.replace(new RegExp('(.*?)(cx)(.*?)', 'ig'), '$1<strong>$2</strong>$3')
      item.parentNode.insertBefore(kNode, item)
      item.parentNode.removeChild(item)
    }
  })
}

let myDirective = {}
myDirective.install = function install(Vue) {
  let timeoutIDs = {}
  Vue.directive('keyword-highlight', {
    bind: function bind(el, binding, vnode) {
      clearTimeout(timeoutIDs[binding.value.id])
      if(!binding.value) return
      timeoutIDs[binding.value.id] = setTimeout(() => {
        removeKeywords(el, binding.value.keyword)
      }, 500)
    },
    componentUpdated: function componentUpdated(el, binding, vnode) {
      clearTimeout(timeoutIDs[binding.value.id])
      timeoutIDs[binding.value.id] = setTimeout(() => {
        removeKeywords(el, binding.value.keyword)
      }, 500)
    }
  });
};
Vue.use(myDirective)
app = new Vue({
  el: "#app",
  data: {
    keyword: 'abc',
    keyword1: 'xyz'
  },
  methods: {
  }
})
.header {
  background-color:red;
}

strong {
  background-color:yellow
}
<script src="https://unpkg.com/vue@2.5.16/dist/vue.js"></script>
<div id="app">
  <input v-model="keyword">
  <input v-model="keyword1">
  <h1>Test Case 1: try to change 2nd input to <span class="header">anything</span></h1>
  <div v-keyword-highlight="{keyword:keyword, id:1}">
    <p>Test1<span>Test2</span>Test3<span>{{keyword}}{{keyword1}}</span></p>
  </div>
  <h1>Test Case 2 which is working</h1>
  <div :key="keyword+keyword1" v-keyword-highlight="{keyword:keyword, id:2}">
    <p>Test1<span>Test2</span>Test3<span>{{keyword}}{{keyword1}}</span></p>
  </div>
</div>

First Case: It should be caused by related VNode already been replaced by <span><strong></strong></span>, so will not get updated with the data properties correctly.

Second Case: It works as expected. The solution is added :key to force mount the component, so when update is triggered, it will render with the template and latest data properties then mount.

But I prefer to force mount in the directive hook instead of bind :key at the component, or get the updated Dom($el) based on the template and the latest data properties. so anyone else who want to use this directive doesn't need to case about the :key.

Many thanks for any.


回答1:


I'm not sure this is the best practice since there are warnings against modifying vnode, but this works in your sample to dynamically add the key

vnode.key = vnode.elm.innerText

The weird thing I notice that the first directive responds to componentUpdated but the second does not, even though the second inner elements update their values but the first does not - which is contrary to what you would expect.

Note that the change occurs because the second instance calls bind again when the inputs change, not because of the code in componentUpdated.

console.clear()
Vue.config.productionTip = false

function removeKeywords(el, keyword){
  console.log(el, keyword)
  if(!keyword) return
  let n = null
  let founds = []
  walk=document.createTreeWalker(el,NodeFilter.SHOW_TEXT,null,false)
  while(n=walk.nextNode()) {
    if(n.textContent.trim().length < 1) continue
    founds.push(n)
  }
  let result = []
  founds.forEach((item) => {
    if( new RegExp('cx', 'ig').test(item.textContent) ) {
      let kNode = document.createElement('span')
      kNode.innerHTML = item.textContent.replace(new RegExp('(.*?)(cx)(.*?)', 'ig'), '$1<strong>$2</strong>$3')
      item.parentNode.insertBefore(kNode, item)
      item.parentNode.removeChild(item)
    }
  })
}

let myDirective = {}
myDirective.install = function install(Vue) {
  let timeoutIDs = {}
  Vue.directive('keyword-highlight', {
    bind: function bind(el, binding, vnode) {
      console.log('bind', binding.value.id)
      clearTimeout(timeoutIDs[binding.value.id])
      if(!binding.value) return
      vnode.key = vnode.elm.innerText
      timeoutIDs[binding.value.id] = setTimeout(() => {
        removeKeywords(el, binding.value.keyword)
      }, 500)
    },
    componentUpdated: function componentUpdated(el, binding, vnode) {
      //clearTimeout(timeoutIDs[binding.value.id])
      //timeoutIDs[binding.value.id] = setTimeout(() => {
        //removeKeywords(el, binding.value.keyword)
      //}, 500)
    }
  });
};
Vue.use(myDirective)
app = new Vue({
  el: "#app",
  data: {
    keyword: 'abc',
    keyword1: 'xyz'
  },
  methods: {
  }
})
.header {
  background-color:red;
}

strong {
  background-color:yellow
}
<script src="https://unpkg.com/vue@2.5.16/dist/vue.js"></script>
<div id="app">
  <input v-model="keyword">
  <input v-model="keyword1">
  <h1>Test Case 1: try to change 2nd input to <span class="header">anything</span></h1>
  <div v-keyword-highlight="{keyword:keyword, id:1}">
    <p>Test1<span>Test2</span>Test3<span>{{keyword}}{{keyword1}}</span></p>
  </div>
  <h1>Test Case 2 which is working</h1>
  <div :key="keyword+keyword1" v-keyword-highlight.keyword1="{keyword:keyword, id:2}">
    <p>Test1<span>Test2</span>Test3<span>{{keyword}}{{keyword1}}</span></p>
  </div>
</div>



回答2:


I found Vue uses Vue.patch to compare old/new nodes then generate out Dom elements.

Check Vue Github Lifecycle source code, so the first element can be one Dom object which will be mounted.

So I follow the steps to uses the third parameter of the directive hooks (bind, componentUpdated, update etc) to generate new Dom elements, then copy it to the first parameter of the directive hooks.

Finally below demo seems work: no force re-mount, only re-compile VNodes.

PS: I uses deepClone methods to clone vnode because inside of the function __patch__(oldNode, newNode, hydrating), it will modify newNode.

PS: As Vue directive access its instance said, inside the hooks of the directive, uses vnode.context to access the instance.

Edit: loop all childrens under test, then append to el, simple copy test.innerHTML to el.innerHTML will cause some issues like the button is not working.

Then test this directive in my actual project like <div v-keyword-highlight>very complicated template</div>, it is working fine so far.

function deepClone (vnodes, createElement) {
  let clonedProperties = ['text', 'isComment', 'componentOptions', 'elm', 'context', 'ns', 'isStatic', 'key']
  function cloneVNode (vnode) {
    let clonedChildren = vnode.children && vnode.children.map(cloneVNode)
    let cloned = createElement(vnode.tag, vnode.data, clonedChildren)
    clonedProperties.forEach(function (item) {
      cloned[item] = vnode[item]
    })
    return cloned
  }
  return vnodes.map(cloneVNode)
}

function addStylesForKeywords(el, keyword){
  if(!keyword) return
  let n = null
  let founds = []
  walk=document.createTreeWalker(el,NodeFilter.SHOW_TEXT,null,false)
  while(n=walk.nextNode()) {
    if(n.textContent.trim().length < 1) continue
    founds.push(n)
  }
  let result = []
  founds.forEach((item) => {
    if( new RegExp('cx', 'ig').test(item.textContent) ) {
      let kNode = document.createElement('span')
      kNode.innerHTML = item.textContent.replace(new RegExp('(.*?)(cx)(.*?)', 'ig'), '$1<strong>$2</strong>$3')
      item.parentNode.insertBefore(kNode, item)
      item.parentNode.removeChild(item)
    }
  })
}

let myDirective = {}
myDirective.install = function install(Vue) {
  let timeoutIDs = {}
  let temp = Vue.extend({
  template: '<p>{{firstName}} {{lastName}} aka {{alias}}</p>'
  })
  let fakeVue = new temp()
  Vue.directive('keyword-highlight', {
    bind: function bind(el, binding, vnode) {
      clearTimeout(timeoutIDs[binding.value.id])
      if(!binding.value) return
      timeoutIDs[binding.value.id] = setTimeout(() => {
        addStylesForKeywords(el, binding.value.keyword)
      }, 500)
    },
    componentUpdated: function componentUpdated(el, binding, vnode) {
      let fakeELement = document.createElement('div')
      //vnode is readonly, but method=__patch__(orgNode, newNode) will load new dom into the second parameter=newNode.$el, so uses the cloned one instead
      let clonedNewNode = deepClone([vnode], vnode.context.$createElement)[0]
      let test = clonedNewNode.context.__patch__(fakeELement, clonedNewNode)

      while (el.firstChild) {
          el.removeChild(el.firstChild);
      }
      test.childNodes.forEach((item) => {
        el.appendChild(item)
      })
      clearTimeout(timeoutIDs[binding.value.id])
      timeoutIDs[binding.value.id] = setTimeout(() => {
        addStylesForKeywords(el, binding.value.keyword)
      }, 500)
    }
  });
};
Vue.use(myDirective)
Vue.config.productionTip = false
app = new Vue({
  el: "#app",
  data: {
    keyword: 'abc',
    keyword1: 'xyz'
  },
  methods: {
    changeData: function () {
      this.keyword += 'c'
      this.keyword1 = 'x' + this.keyword1
      console.log('test')
    }
  }
})
.header {
  background-color:red;
}

strong {
  background-color:yellow
}
<script src="https://unpkg.com/vue@2.5.16/dist/vue.js"></script>
<script src="https://unpkg.com/lodash"></script>
<div id="app">
  <input v-model="keyword">
  <input v-model="keyword1">
  <h4>Test Case 3 <span class="header"></span></h4>
  <div v-keyword-highlight="{keyword:keyword, id:1}">
    <p>Test1<span>Test2</span>Test3<span>{{keyword}}{{keyword1}}</span></p>
    <button @click="changeData()">Click me</button>
  </div>
</div>


来源:https://stackoverflow.com/questions/50594818/vue-custom-directive-uses-the-updated-dom-or-el

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!