preface

This article will introduce how to implement a Navigator control that renders the corresponding components of the route without using the router-view provided by VUe-Router, and gradually add functions such as primary and secondary stage differentiation, page caching, page switching animation, and left-swipe back support.

The source code for this component is at my github: github.com/lqt0223/nav…

Demo of this component: navigator-demo.herokuapp.com/#/view1 (it is recommended to open it on a mobile device (on aN iOS device you may encounter a left-swipe return conflict when using a browser such as Safari), or use the Chrome Dev Tool, Open in mobile mode to support touch events)

demand

The webApp developed by the author’s company is a single page application, and the original framework used is Backbone. This app uses the popular top Header, middle Content, and bottom Tabbar layout.

An example of tabbar

This layout typically requires headers, content, and tabbar with the following rendering logic

  • Tabbar is instantiated only once during app runtime
  • Header and Content have a one-to-one relationship. Different views have different titles
  • After clicking the button of Tabbar, the view presented is in the APPMain view
    • For example, if there are five buttons on tabbar in the image below, there are five main views in the app
    • The main view is generally assigned to the presentation of the most important functions in the APP that are first shown to users, such as “my information”, “Commodity list”, “Home page recommendation”, etc
    • The main view should only be instantiated once during app execution. For example, when a user opens the home page for the first time, the dynamic content on the home page can be rendered through API calls. Created, Mounted, and other life cycle functions should not be called
    • No animation is required to switch between main views of the app
  • Click the button in Content or Header to render the view in the appDeputy view
    • The secondary view is the view used by all function pages except the main view
    • The secondary view is usually assigned to the presentation of functions in the app that are secondary, designed to display specific data, or have a long process, such as “detailed introduction of a product”, “a certain step in the registration form”, etc.
    • Any page switch involving a side view requires animation
    • Each time you jump to a side view, the side view needs to be a new instance, depending on the situation.
    • From the side view, you can swipe left to return to the previous view
    • (Refer to the following section for specific jump rules)

In Backbone era, a routing rule only consists of path matching mode and corresponding processing function. When the hash part of the URL changes and the changed value conforms to a routing rule, the handler specified by the routing rule is called. In the processing function, we need to implement all the logic of updating and rendering the page content.

In the Vue era, every small part of a page, down to the entire page itself, is a Vue component. A routing rule in a Vue consists of the matching pattern of the path and the corresponding component. When the hash part of the URL changes and the changed value conforms to a routing rule, Vue instantiates the component corresponding to this rule and renders it to the Router-View component in app. We don’t need to implement the logic of page content updating and rendering ourselves.

Through the above comparison, we can find that Backbone needs to achieve the rendering logic of the corresponding route, so we can achieve the above page cache, animation transition and other functions. A vue-Router-based router-view, however, does not prevent the default behavior of some frameworks (such as the corresponding Component being a new instance every time a route is switched).

It is possible to hack by defining a routing rule with an empty component attribute and using vue-Router’s beforeEach hook function. But by the time I started implementing this requirement, my colleagues had already written all the routing rules with component attributes. In order to reduce code modification and achieve certain reusability through the implementation of custom controls, the author finally decided to abandon the router-view provided by vue-Router and write a routing view component of his own.

The simplest router-view

As mentioned in the previous section, we need to implement our own Navigator without relying on the router-View component that vue-Router officially provides. By analyzing the functions and features of router-View, we can conclude:

  • As a component, router-view does not have its own fixed template. This means that we can only implement this component using the Render function
  • In the Render method of this component, you need to return the vNode of the component to which the current route corresponds
  • The render method of this component is called when the component’s data property or the state of the component’s injected object changes, and the value of the state is updated when called.

After some time of fumble, it can be seen that: when the render function is called, the component corresponding to the current route can be accessed in the scope of the render function by the following attribute: this.$route.matched[0].components.default

The semantics of the above code are: the default component in the component specified by the first routing rule to which the current route matches

The first argument to the render function (usually called h) is a function inside vue that is used to create a VNode. It can either use the h(Tag, Attributes, children) form to return vNodes for arbitrary attributes and structures, or it can use the H (Component) form to return vNodes for specific components.

So we just need to implement the Render method like this to implement a basic router-view:

render(h) {
  return h(this.$route.matched[0].components.default)
}Copy the code

Use this.$createElement instead of h in the scope of a component other than the Render function

The lines

The simplest router-view example above shows that our custom component’s Render method is called when the route changes. We just need to return the vNode we want to render in the Render method.

If you just return the vNode of the corresponding component, you’re nowhere near the page caching and view stack functionality you need. The logic of the Navigator’s Render method is as follows:

  • Create one within the componentthis.cacheObject to add a vNode cache to the page if it has not already been cached when render is calledthis.cache[routeName] = h(this.$route.matched[0].components.default)
  • Create one within the componentthis.historyArray that records the current route each time a route jump (i.e. render is called) occurs
  • In the render function, according tothis.historyRoute history from theThis cacheTake out the corresponding cached VNodes one by one to form a vNode side by side with each history page. As long as the vNode for the page currently routed is at the end of these side-by-side VNodes, you can render the page correctly by setting the appropriate CSS style for each page.

Here is an example to illustrate the above logic:

To start the app, first render the contents of the #home page.

This. cache = {home: vnode instance of home.vue} this.history = ['home'] // Render the vNode returned by the render function, which will render the following DOM structure<div class="navigator">
  <div class="navigator-page">
    <! -- Contents of the home page -->
  </div>
</div>Copy the code

After the app is started, the user clicks the register button, and the contents of the # Register page need to be displayed. At this time:

This. cache = {home: vnode instance of component home. vue, register: } this.history = ['home', 'Register '] // The vNode returned by the render function will be rendered as follows<div class="navigator">
  <div class="navigator-page">
    <! -- Contents of the home page -->
  </div>
  <div class="navigator-page">
    <! -- Register page contents -->
  </div>
</div>Copy the code

Note that we have wrapped parent nodes with class names navigator and Navigator-Page around the vNode we want to render. This is to specify the same style required for full-screen rendering to each page DOM, such as Position: Absolute, etc

Jump behavior sorting

As mentioned in the previous section, jumping between views results in different rendering behavior depending on the starting and ending views from which the jump occurs. Here is the arrangement:

The original view A new view Whether the new view has been accessed behavior
Main view Main view Yes/no Replace the contents of the APP view area directly
Main view Deputy view Yes/no The new view enters the view area from right to left, and the old view exits the view area from right to left
Deputy view Main view Yes/no Replace the view below the current subview with the target main view, and make the new view enter the view area from left to right and the old view exit the view area from left to right
Deputy view Deputy view no The new view enters the view area from right to left, and the old view exits the view area from right to left
Deputy view Deputy view is Replace the view below the current side view with the target side view and make the new view enter the view area from left to right and the old view exit the view area from left to right

The above collation is quite abstract, and the demo linked below is an example of this logic. View1 and view3 are the primary views, and view2 and view4 are the secondary views.

navigator-demo.herokuapp.com/#/view1

By sorting through the above, we can abstract the view management of the entire app into the following mode (only part of the logic is shown) :

page_stack_draft

Handling jump behavior

In the previous section, we sorted out 5 kinds of jump behaviors under different circumstances. Here, we briefly analyze several of them and explain the implementation difficulties. You can refer to the navigator source code for all the specific logic.

Master view to master view

This should be the simplest case, in any case, a route jump from the main view to the main view, we just need to “replace” the page content in the APP view area. The actual code implementation looks like this:

// fromRoute is the key of the previous route, toRoute is the key of the current route

// From the main view
if (this.isMain(this.cache[this.fromRoute].$route)) {
  // Go to the main view
  if (this.isMain(this.cache[this.toRoute].$route)) {
    // If there is a key for the current route in history, this record is transposed to the end; If no, add one
    if (this.history.indexOf(this.toRoute) > - 1) {
      this.history.splice(this.history.indexOf(this.toRoute), 1)}this.history.push(this.toRoute)
    // Make some changes to the VNode itself in the mainToMain method, or DOM operations that need to be performed in nextTick
    this.mainToMain(this.toRoute)
  }
}

At this point, the history records in this.history are arranged in the desired cascading order
// Just retrieve the cached vNode according to the history and return it

const children = []
for (let i = 0; i < this.history.length; i++) {
  const cached = this.cache[this.history[i]]
  const node = this.wrap(cached) The wrap method adds a 
      
  children.push(node)
}

const composedVNode = h('div', {
  class: 'navigator'
}, children)

return composedVNodeCopy the code

Primary view to secondary view

In this case, since the secondary view is always a new instance when navigated to, we just need to add a new history for this.history.

Transition effects are required from the primary view to the secondary view. To improve the customizability of the component, we use onBeforeEnter, onBeforeLeave, onEnter, onLeave to provide the implementation interface of the transition animation to the user of the component. The use of these interfaces is very similar to the use of transition JavaScript hooks in vue, see vuejs.org/v2/guide/tr…

// onBeforeEnter calls back to the state of the incoming element before entering
// el is the element to be entered, and done is the callback to be executed after the animation is finished
onBeforeEnter(el, done) {
  const h = (a)= > {
    done()
    el.removeEventListener('transitionend', h)
  }
  el.addEventListener('transitionend', h)
  el.style.transform = 'translateX(100%)'
  el.style.transition = 'all 0.3 s'
},

// the onEnter callback is the state of the incoming element after entry
// el is the incoming element, and done is the callback that needs to be executed after the animation is finished
onEnter(el, done) {
  const h = (a)= > {
    done()
    el.removeEventListener('transitionend', h)
  }
  el.addEventListener('transitionend', h)
  el.style.transform = 'translateX(0%)'
  el.style.transition = 'all 0.3 s'
},Copy the code

These interfaces are implemented in components as follows:

// Because the generated DOM needs to be exposed, the element lookup method here needs to be performed in nextTick, otherwise the node cannot be found
setTimeout((a)= > {
  // In the wrap method we have implemented wrapping the page vNode with the parent node we need
  // Wrap can also add attributes like id: 'navigator-page-path-name' to the parent of the page vNode
  // We can get the corresponding DOM directly here
  const leaveEl = document.querySelector(The '#' + this.getNavigatorPageId(fromRoute))
  const enterEl = document.querySelector(The '#' + this.getNavigatorPageId(toRoute))

  // Call the onBefore series of methods
  this.onBeforeLeave(leaveEl, this.transitionEndCallback)
  // After a short interval, call the on series methods
  setTimeout((a)= > {
    this.onLeave(leaveEl, this.transitionEndCallback)
  }, 50);

  this.onBeforeEnter(enterEl, this.transitionEndCallback)
  setTimeout((a)= > {
    this.onEnter(enterEl, this.transitionEndCallback)
  }, 50);
}, 0)Copy the code

What is about this. Here transitionEndCallback, please see the next section.

Secondary view to primary view

In this case, there is one more “clean up” step than in the two cases above.

The so-called “clearing” is because after routing from the secondary view to the primary view is complete, the exited secondary view needs to be completely destroyed. Therefore, when the transition animation ends, we need to “clean up” from the following aspects:

  • this.historyThe entry for the subview in
  • this.cacheVnode cache for the secondary view in
  • The DOM of the component’s side view that has been rendered

Instead of using the DOM API directly for the final DOM cleanup, I chose a vUE comparison: call the Render method again and return the cleaned VNode.

Mentioned in the previous section of this. TransitionEndCallback method will be called when we need the DOM, its implementation is very simple, as follows:

transitionEndCallback() {
  this.clear = true
}Copy the code

Simply changing the component’s this.$data.clear triggers the Render method again. We can implement DOM cleanup logic for clear=true:

// this.clear is the property of a response set in this.data
if (this.clear) {
  this.clear = false
  // Clean up the contents of this.history and clean up the contents of this.cache accordingly
  const toClear = this.history.splice(this.history.indexOf(this.toRoute) + 1)
  for (let i = 0; i < toClear.length; i++) {
    delete this.cache[toClear[i]]
  }

  // Compose the final vNode tree
  const children = []
  for (let i = 0; i < this.history.length; i++) {
    const cached = this.cache[this.history[i]]
    const node = this.wrap(cached)
    children.push(node)
  }

  const composedVNode = h('div', {
    class: 'navigator'.on: {
      touchmove: this.handleTouchMove,
      touchstart: this.handleTouchStart,
      touchend: this.handleTouchEnd
    }
  }, children)
  return composedVNode
}Copy the code

Talk about when the Render method is called

The render method of a Vue component can be called when:

  • Render is called when the data source on which the build itself renders is modified. For example,this.$dataWhen the declared property in the
  • vm.$routeWhen the vue-router plugin is used, the route changes

Later, I discovered during development that since vuex had been imported into our project, the Render method was also triggered when any state in the Vm. $store changed. We don’t need to render anything new, so we can ignore it with the following code:

// Since the render method is caused by other global state changes, the route does not change
if (this.toRoute === this.fromRoute) {
  // The old vnode of the vUE component is stored on the _vnode property
  return this._vnode
}Copy the code

We can also use this._vnode for error handling. If the app accidentally jumps to a routing address that has no routing rules, return this._vnode and leave the page as it is.

Swipe left to return the implementation

Adding this means that we need to listen for the TouchStart, TouchMove, and TouchEnd events on a container element.

As can be seen from the above, it is assumed that the main view home is loaded when the app starts, and then the user clicks the register button, and the app loads the secondary view Register. The internal vNode structure of our component is as follows

<div class="navigator"> <! -- Touch events should be bound to this node -->
  <div class="navigator-page">
    <! -- Contents of the home page -->
  </div>
  <div class="navigator-page">
    <! -- Register page contents -->
  </div>
</div>Copy the code

This touch event should be bound to the outermost component root node because it is fixed every time it is rendered.

When creating a VNode using the h method, the V-ON directive used to bind events becomes the ON attribute, for example:

render(h) {
  return h('div', {
    class: 'navigator'.on: {
      touchmove: this.handleTouchMove,
      touchstart: this.handleTouchStart,
      touchend: this.handleTouchEnd
    }
  }, children)
}Copy the code

When creating a Vnode using the H method, you can refer to the vnode class definition in vUE if you need to specify various attributes of the node. See github.com/vuejs/vue/b…

Then, we will implement the logic of handleTouchMove, handleTouchStart and handleTouchEnd accordingly.

Here, to improve the customizability of the component, we use a prop called onTouch that lets the component user customize how the page changes when touched and dragged. Here is an example in use:

// enterEl indicates the element to be entered (the bottom page for left-swiping back)
// leaveEl indicates the element that is about to leave (i.e. the top page in the case of the left swipe back)
onTouch(enterEl, leaveEl, x, y) {
  const screenWidth = window.document.documentElement.clientWidth
  const touchXRatio = x / screenWidth
  // Since this element may have been set to a transition style value in previous callbacks such as onBeforeLeave, change back to None
  enterEl.style.transition = 'none'
  leaveEl.style.transition = 'none'
  enterEl.style.transform = `translate(${touchXRatio * 100}%) `
  leaveEl.style.transform = `translate(${touchXRatio * 50 - 50}%) `
}Copy the code

The implementation of this interface is also simple:

handleTouchMove(e) {
  if (this.touching) {
    // Since the component's DOM is already rendered when the touchMove event is triggered, you can directly access the required DOM with this.$el
    const childrenEl = this.$el.children
    const enterEl = Array.prototype.slice.call(childrenEl, - 1) [0]
    const leaveEl = Array.prototype.slice.call(childrenEl, 2 -.- 1) [0]
    this.onTouch(enterEl, leaveEl, e.touches[0].pageX, e.touches[0].pageY)
  }
}Copy the code

Slightly more complicated is the handleTouchEnd implementation. When the TouchEnd event occurs, if the horizontal position of the touch is greater than the threshold, we need to continue playing the returned transition animation and call this.$router.go(-1) to complete the backward step. The trouble is that the $router change causes the render method to be called again.

Here, we use a control variable backInvokedByGesture to indicate that the render is caused after the left-slide operation is completed and the route changes. At this point, we need to manually clear the last element in this.history (that is, the history of the view left when we swipe back), clean the corresponding this.cache, and return the final vNode tree. The code is as follows:

handleTouchEnd(e) {
  if (this.touching) {
    const childrenEl = this.$el.children
    const el = Array.prototype.slice.call(childrenEl, - 1) [0]
    const leaveEl = Array.prototype.slice.call(childrenEl, 2 -.- 1) [0]
    const x = e.changedTouches[0].pageX
    const y = e.changedTouches[0].pageY
    // The horizontal position at the end of the touch is greater than the threshold
    if (x / window.document.documentElement.clientWidth > this.swipeBackReleaseThreshold) {
      // Manually control route rollback
      this.onBeforeLeave(leaveEl, () => {
        this.backInvokedByGesture = true
        this.transitionEndCallback()
        this.$router.go(- 1)})this.onBeforeEnter(el, () => {})
    } else {
      // Stay on the original page
      this.onLeave(leaveEl, () => {})
      this.onEnter(el, () => {})
    }
  }
  this.touching = false
}

// Logic for backInvokedByGesture in the render method
if (this.backInvokedByGesture) {
  this.backInvokedByGesture = false
  // Delete the last item in this.history and clear the corresponding cache in this.cache
  const toDelete = this.history.pop()
  delete this.cache[toDelete]

  // Compose the final vNode tree
  const children = []
  for (let i = 0; i < this.history.length; i++) {
    const cached = this.cache[this.history[i]]
    const node = this.wrap(cached)
    children.push(node)
  }

  const composedVNode = h('div', {
    class: 'navigator'.on: {
      touchmove: this.handleTouchMove,
      touchstart: this.handleTouchStart,
      touchend: this.handleTouchEnd
    }
  }, children)
  return composedVNode
}Copy the code

You’re done

The finished Navigator component has rich interfaces:

  1. You can use isMain to determine which pages need to be in the main view and which pages need to be in the secondary view
  2. A series of transition hooks such as onBeforeEnter, onEnter, onBeforeLeave and onLeave can be used to achieve the transition effect
  3. You can use the onTouch method to achieve the effect of moving when touching
  4. SwipeBackEdgeThreshold can be used to specify the required distance between the finger and the left edge when the left swipe touch action is triggered
  5. Can use swipeBackReleaseThreshold provisions left slide release were judged to be a back operation

The use of the Navigator component is as follows:

// template
<navigator
:on-before-enter="transitionBeforeEnter"
:on-before-leave="transitionBeforeLeave"
:on-enter="transitionEnter"
:on-leave="transitionLeave"
:is-main="isMain"
:on-touch="onTouch"
:swipe-back-edge-threshold="0.05"
:swipe-back-release-threshold="0.5"
>
</navigator>

// script
transitionBeforeEnter(el, done) {
  el.style.transition = 'all ' + this.transitionDuration + 'ms'
  const h = (a)= > {
    done()
    el.removeEventListener('transitionend', h)
  }
  el.addEventListener('transitionend', h)
  this.setElementTranslateX(el, '100%')
},
transitionBeforeLeave(el, done) {
  el.style.transition = 'all ' + this.transitionDuration + 'ms'
  const h = (a)= > {
    done()
    el.removeEventListener('transitionend', h)
  }
  el.addEventListener('transitionend', h)
  this.setElementTranslateX(el, '0%')
},
transitionEnter(el, done) {
  el.style.transition = 'all ' + this.transitionDuration + 'ms'
  const h = (a)= > {
    done()
    el.removeEventListener('transitionend', h)
  }
  el.addEventListener('transitionend', h)
  this.setElementTranslateX(el, '0%')
},
transitionLeave(el, done) {
  el.style.transition = 'all ' + this.transitionDuration + 'ms'
  const h = (a)= > {
    done()
    el.removeEventListener('transitionend', h)
  }
  el.addEventListener('transitionend', h)
  this.setElementTranslateX(el, '50%')},$route = vm.$route, the current route
// Here several routes with specific names are set as the main view
isMain(route) {
  const list = ['Card'.'Rewards'.'Profile'.'Home'.'Coupons']
  return list.indexOf(route.name) > - 1
},
onTouch(enterEl, leaveEl, x, y) {
  const screenWidth = window.document.documentElement.clientWidth
  const touchXRatio = x / screenWidth
  enterEl.style.transition = 'none'
  leaveEl.style.transition = 'none'
  enterEl.style.transform = `translate(${touchXRatio * 100}%) `
  leaveEl.style.transform = `translate(${touchXRatio * 50 - 50}%) `
}Copy the code

Afterword.

Originally, Vue and VUe-Router provided router-view, keep-alive, and Transition built-in components, corresponding to routing view, page cache, and incoming and outgoing effects respectively. However, when I nested them, I failed to achieve the desired effect. It’s also hard to hack by reading the source code. Helpless under the choice of their own implementation control, complete control of these logic. As features were added, the code grew in complexity and underwent a major rewrite or two.

There are still many shortcomings in the navigator implementation, such as the way the rendering component is implemented is too simple to correspond to the situation of nested routes. However, in the process of implementation, I deepened my understanding of the function of render function, trigger timing, and the creation of vNode knowledge, which is also a big gain.