Amazing words
Some time ago, I received a requirement: can users make their own components, so as to achieve the purpose of custom rendering of a certain area. Demand the truth from the heart in a surprised, exclamation this idea is really bold, but as a part-time job, taking as long as the thought not landslide, way better than more difficult the work of the soul, even the wheels on the ah, groped after several days of investigation, finds the VUE supports do so early in the morning, but over time, gradually forgotten this hidden skills.
To outline the background of the project: We have created a drag-and-drop system for generating reports. By drag-and-drop the built-in components, users can customize their report forms. However, after all, the built-in components are limited and the customizable capability is not high.
Go back to incremental
So how do you do that? Let’s take a look at the official introduction of VUE
Vue (pronounced vju/curliest, similar to View) is a set used to build user interfaces
Progressive framework
. Unlike other large frameworks, Vue is designed to be applied layer by layer from the bottom up.
A lot of times we seem to have forgotten about progressiveness, and most of the projects developed on VUE today are generated by the VUE CLI, coded as VUE single files, and distributed as webpack compilations. What does this have to do with incrementalism? It doesn’t.
Incremental means using vUE on an existing project that doesn’t use VUE, and using VUE until all HTML is gradually replaced with vUE rendering, incremental development, and incremental migration, which was more common in the early years of VUE, and may now be common in older projects.
Why do we talk about incremental? Since progressive is not required to compile locally, there is no get to the point! Yes, you don’t need to compile locally, you need to compile at run time.
Local versus runtime compilation
If a user wants to write template + JS + CSS to render a page at runtime, it cannot be compiled locally (compilation here means compiling vue files into JS resource files), that is, it cannot package user-written code into static resource files like compiling source code.
This code can only be persisted to the database as is, restored each time the page is opened, and compiled in real time. After all, it is not a pure JS file, it cannot run directly, it needs a runtime environment, runtime compilation, this environment is the vue runtime + compiler.
With the idea is just a glimpse of the sky, or to polish the details. How to do that? Let me take a walk.
Technology of dry
Step 1: You need a runtime compilation environment
According to the official introduction, vue can be developed incrementally through the script tag, and thus has the runtime + compiler, as follows
<! DOCTYPEhtml>
<html lang="en">
<head>
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
</head>
<body>
<div id="app">{{message}}</div>
<script type="text/javascript">
var app = new Vue({
el: '#app'.data: {
message: 'Hello Vue! '}})</script>
</body>
</html>
Copy the code
However, through vue single file + Webpack compilation, it is unnecessary to introduce another Vue, through the CLI can also be, just need to open the runtimeCompiler switch in vue.config.js, look at the document in detail.
We now have a runtime compilation environment
Step 2: Register the user’s code with the system
There are two options for rendering code
- The code is registered as a component of the VUE instance by registering the component, which is divided into two ways: global registration and local registration
- Mount vue instance directly through mount point, that is, pass
new Vue({ el: '#id' })
The way of
The first option: dynamic components
For this approach, a note is given at the end of the component Registration section of the official documentation
Remember that the action of global registration must occur before the root Vue instance (via new Vue) is created.
Vue.component(‘my-component-name’, {/* */}) is not available to register the user’s code in the system, because the runtime Vue instance has been created and the user’s code comes in after the instance. Like this
var ComponentB = {
components: {
'component-a': {
...customJsLogic,
name: 'custom-component'.template: '<div>custom template</div>',}},// ...
}
Copy the code
But think about it, it seems not quite right, this is still writing source code, runtime defined ComponentB component how to use, how to render ComponentB in a compiled page? No entry point can be found, user code injected into the Components object cannot be registered in the system and cannot be rendered.
Is that the end of it? What to do?
Why register (declare) the following components in components before they can be used? Component is essentially nothing more than a JS object. The main purpose of this is to serve the template syntax. When you write
Use the render function instead of template.
Using createElement in the render function is a bit of a hassle, the API is very complex, and rendering a full user-defined template is a bit of a drag. Using JSX is a lot easier, and it’s 1202 years old.
Back to the project, the user code needs to be used in more than one place, all using the render function is a bit bloated, so make a code container, the container is responsible for rendering the user code, use the container to hang up the line.
- Container core code
export default {
name: 'customCode'.props: {
template: String./ / template template
js: String./ / js logic
css: String./ / CSS styles
},
computed: {
className() {
// Generate a unique class, mainly for scoped styling
const uid = Math.random().toString(36).slice(2)
return `custom-code-${uid}`
},
scopedStyle() {
if (this.css) {
const scope = `.The ${this.className}`
const regex = /(^|\})\s*([^{]+)/g
// Add a scope prefix to class
return this.css.trim().replace(regex, (m, g1, g2) = > {
return g1 ? `${g1} ${scope} ${g2}` : `${scope} ${g2}`})}return ' '
},
component() {
// Convert the code string into a JS object
const component = safeStringToObject(this.js)
// Remove the front and back tags of template
const template = (this.template || ' ')
.replace(/^ *< *template *>|<\/ *template *> *$/g.' ')
.trim()
// Inject template or render and set template priority over render
if (this.template) {
component.template = this.template
component.render = undefined
} else if(! component.render) { component.render ='
no template or render function
'
}
return component
},
},
render() {
const { component } = this
return <div class={this.className}>
<style>{this.scopedStyle}</style>
<component />
</div>}},Copy the code
- containers
<template>
<custom-code :js="js" :template="template" :css="css" />
</template>
Copy the code
The above is just the core logic part, in addition to these, in the actual combat of the project should also consider fault tolerance processing, mistakes can be roughly divided into two kinds
-
User code syntax error
The browser has a mechanism for correcting errors in CSS and template.
This part of the processing mainly relies on the safeStringToObject function, if there is a syntax Error, it will return Error, processing the output to the user, the code is roughly as follows
// Component object on result.value. If result.error has a value, it indicates an error component() { // Convert the code string into a JS object const result = safeStringToObject(this.js) const component = result.value if (result.error) { console.error('JS script error', result.error) result.error = { msg: result.error.toString(), type: 'JS script error', } result.value = { hasError: true } return result } // ... retrun result } Copy the code
-
Component runtime error
With the JS logic in the hands of the user, runtime errors such as type errors, reading values from undefined, running non-function variables as functions, and even spelling errors are likely to occur.
This part of the process involves adding an official hook to the container component to catch child component errors, because there is no way to catch the component’s own run-time errors. The code looks like this
errorCaptured(err, vm, info) { this.subCompErr = { msg: err && err.toString && err.toString() || err, type: 'Custom component runtime error:',}console.error('Custom component runtime error:', err, vm, info) }, Copy the code
In conjunction with error handling, if you want the user to see the error message, the render function needs to display the error, which looks like this
render() {
const { error: compileErr, value: component } = this.component
const error = compileErr || this.subCompErr
let errorDom
if (error) {
errorDom = <div class='error-msg-wrapper'>
<div>{error.type}</div>
<div>{error.msg}</div>
</div>
}
return <div class='code-preview-wrapper'>
<div class={this.className}>
<style>{this.scopedStyle}</style>
<component />
</div>
{errorDom}
</div>
},
Copy the code
There is also a point where the user finds an error in the component and changes the code to render it again, and the error echo needs special handling.
For JS script errors, component is a calculated attribute, and if the JS script has no errors, the exported component can be redrawn, because component is computed again with computed attributes.
For runtime errors, this. SubCompErr is used as an internal variable, but this value will not be modified. Therefore, we need to make the connection between props and add watch to solve the problem. Component might not only depend on changes of JS, CSS, and template props, but this.subCompErr only needs to be associated with these three props, which would require redundant reset logic.
Another scenario is that the child component itself may have timed refresh logic that redraws periodically or irregularly. If an error occurs, the error message will always be displayed because the user’s code cannot get the value of this.subCompErr and therefore cannot reset it. This can be resolved by injecting beforeUpdate hooks. The code looks like this
computed: {
component() {
// Convert the code string into a JS object
const result = safeStringToObject(this.js)
const component = result.value
// ...
/ / into mixins
component.mixins = [{
// Inject beforeUpdate hooks that clean up exceptions caught by the parent when the child is redrawn
beforeUpdate: () = > {
this.subCompErr = null}}]// ...
return result
},
},
watch: {
js() {
// When code changes, clear error and redraw
this.subCompErr = null
},
template() {
// When code changes, clear error and redraw
this.subCompErr = null
},
css() {
// When code changes, clear error and redraw
this.subCompErr = null}},Copy the code
See the complete code
:Github.com/merfais/vue…
See the full demo
:Merfais. Making. IO # / vue – demo / /…
The second scheme: dynamic instance
We know that in systems built with VUE, pages are made up of components, and the pages themselves are components, with some differences in parameters and mount. This second approach treats the user’s code as a page, rendering it by new a VM instance and mounting the VM at the DOM mount point (new Vue(Component).$mount(‘#id’)).
The dynamic instance scheme is similar to the dynamic component scheme in that it generates component objects and scopedStyle objects for rendering through computed properties, but there are some differences. Dynamic instances need to consider the following more than dynamic components:
-
You need a stable mount point
Since Vue2.0, the mount policy of vue instances has changed to that all mounted elements are replaced by the DOM generated by VUE. Under this policy, once a mount is performed, the original DOM disappears and cannot be mounted again. However, we need to implement re-rendering after code changes, which requires a stable mount point. The solution is to inject the user’s template, and wrap a DOM with a fixed ID around the template each time before rendering
-
Run-time error capture errorCaptured needs to be injected into the Component object, not the beforeUpdate hook
ErrorCaptured on a container component does not catch runtime errors because a new VM instance is created as a new Vue() and is no longer a child of the container component. The component parameter in New Vue(Component) is the top-level component. According to Vue error propagation rules, errorCaptured at the top can catch errors without special controls
-
The first mount requires some lag before rendering
Because the mount point is contained in the DOM container, the timing of the first mount is basically the same as that of the component exported by the calculated properties. As a result, the DOM may not have been rendered to the document stream when the VM ($mount(‘#id’)) is mounted, so a delay is required for the first rendering before the VM is mounted.
The above differences, did not bring any advantage to render the user custom code, actually increased restrictions, especially need stable mount point This article, the need for users to provide the template to do the secondary injection, mount point, to achieve real-time rendering update after the user to change the component, therefore, cannot support user-defined render function, Since you can’t get the return value of the render function that hasn’t been run, you can’t inject the outer mount point.
It is also important to note that you cannot use template to define the render template in the container component in this way, because the following compilation error will occur if you write style tags in the template, but style tags are required and scoped styles will be provided for custom components. (Of course, it is also possible to dynamically add style tags by providing appendStyle functions, but this is not more convenient, so it is not necessary.)
Errors compiling template:
Templates should only be responsible for mapping the state to the UI. Avoid placing tags with side-effects in your templates, such as <style>, as they will not be parsed.
2 | <span :class="className">
3 | <span id="uid" />
4 | <style>{this.scopedStyle}</style>
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
5 | </span>
| ^^^^^^^
Copy the code
In view of the above shortcomings, will not provide core code demonstration, directly to the source code and demo
See the complete code
:Github.com/merfais/vue…
See the full demo
:Merfais. Making. IO # / vue – demo / /…
What’s the point of considering dynamic instance scenarios if these are the only disadvantages? In fact, its significance is that the dynamic instance scheme is primarily used for iframe rendering, which is used for isolation purposes.
Iframe will create a domain independent of the master site. This isolation can well prevent JS pollution and CSS pollution. There are two isolation methods: cross-domain isolation and non-cross-domain isolation, cross-domain means complete isolation, and non-cross-domain means semi-isolation.
Whether an iframe is cross-domain is determined by the SRC value of the iframe. If the SRC value is set or not set, the iframe is cross-domain. If the iframe is not set to SRC, the page can only load an empty IFrame, so you need to load dependent resources dynamically after the iframe is loaded, such as vuejs, other runtime dependent libraries (example demo loads Ant-Design-vue), etc. If SRC is set, dependencies can be written in advance to static page files with script tags and link tags so that dependent resources are automatically loaded when the IFrame is loaded.
The semi-isolated method is to render an IFrame through non-cross-domain iframe. We will render an IFrame without setting SRC, which is more general and can be applied to any site. The core code is as follows
<template>
<iframe ref='iframe' frameborder="0" scrolling="no" width="100%" />
</template>
Copy the code
Since it is in the same domain, the host and iframe can read window and Document references from each other, because resources can be dynamically loaded. The core code is as follows
methods: {
mountResource() {
// Add a dependent CSS
appendLink('https://cdn.bootcdn.net/ajax/libs/ant-design-vue/1.7.2/antd.min.css'.this.iframeDoc)
// Add dependent JS and keep the handler for asynchronous control of the first rendering
this.mountResourceHandler = appendScriptLink([{
src: 'https://cdn.bootcdn.net/ajax/libs/vue/2.6.12/vue.min.js'.defer: true}, {src: 'https://cdn.bootcdn.net/ajax/libs/ant-design-vue/1.7.2/antd.min.js'.defer: true,}],this.iframeDoc)
},
},
mounted() {
this.iframeDoc = this.$refs.iframe.contentDocument
this.mountResource()
},
Copy the code
Next comes component object assembly and mounting, which is essentially the same as dynamic components, except that the mounting is no longer done through the Render function. Core code first, then pay attention to the point.
computed: {
component() {
// Convert the code string into a JS object
const component = safeStringToObject(this.js)
// Associate CSS so that CSS changes can be automatically redrawn
component.css = this.css
// Remove the front and back tags of template
const template = (this.template || ' ')
.replace(/^ *< *template *>|<\/ *template *> *$/g.' ')
.trim()
// Inject template or render and set template priority over render
if (template) {
component.template = template
component.render = undefined
} else if(! component.render) { component.template =' Template not provided or render function '
}
return component
},
},
watch: {
component() {
if (this.hasInit) {
this.mountCode()
} else if (this.mountResourceHandler) {
this.mountResourceHandler.then(() = > {
this.hasInit = true
this.mountCode()
})
}
},
},
methods: {
mountCode() {
/ / add the CSS
const css = this.component.css
delete this.component.css
removeElement(this.styleId, this.iframeDoc)
this.styleId = appendStyle(css, this.iframeDoc)
// Rebuild the mount point
if (this.iframeDoc.body.firstElementChild) {
this.iframeDoc.body.removeChild(this.iframeDoc.body.firstElementChild)
}
prependDom({ tag: 'div'.id: 'app' }, this.iframeDoc)
// Mount the instance
const Vue = this.iframeWin.Vue
new Vue(this.component).$mount('#app')}},Copy the code
Note:
- Dependent resources can only be added after the rendering of iframe is added to the document flow, and vm mount can only be performed after the loading of dependent resources. The timing needs to be controlled during the first loading
- The RECONSTRUCTION of the VM mount point takes the approach of always adding the first child element to the body. The reason for this is that some third-party libraries (such as Ant-Design-Vue) also add elements dynamically to the body, although it does
docment.body.innerHTML=''
Is a quick and clean way to empty the body content, but it will also wipe out the content added by the third party library, making the third party library completely or partially unavailable. - In order to make CSS changes also trigger redraw, in the calculation of properties
component
Is also bound to the CSS value, but this is not useful for the new VM instance field, can also be implemented by watch CSS
The error handling for iframe mounts is slightly different. In order to minimize interference with the user’s code, error rendering in this mode adopts the strategy of rebuilding the DOM and re-rendering the VM, that is, when errors occur, both static syntax errors and runtime errors are redrawn. Of course, this approach also loses the component self-refresh function, because if an error occurs, the original component will be unloaded and rendered as an error message. The core code is as follows
computed: {
component() {
if (this.subCompErr) {
return this.renderError(this.subCompErr)
}
// Convert the code string into a JS object
const result = safeStringToObject(this.js)
if (result.error) {
return this.renderError({
type: 'JS script error'.msg: result.error.toString(),
})
}
const component = result.value
ErrorCaptured is used for error custom component runtime capture
component.errorCaptured = (err, vm, info) = > {
this.subCompErr = {
msg: err && err.toString && err.toString(),
type: 'Custom component runtime error:',}console.error('Custom component runtime error:', err, vm, info)
}
return component
},
},
watch: {
js() {
// When code changes, clear error and redraw
this.subCompErr = null
},
template() {
// When code changes, clear error and redraw
this.subCompErr = null
},
css() {
// When code changes, clear error and redraw
this.subCompErr = null}},methods: {
renderError({ type, msg }) {
return {
render() {
return <div style='color: red'>
<div>{type}</div>
<div>{msg}</div>
</div>}}},},Copy the code
In addition to error handling, some iframe features need to be addressed, such as borders, scrollbars, and default width and height. The tricky part is that the iframe height has a default value and does not adapt to the content of the iframe, but for custom component rendering, the height needs to be calculated dynamically, not fixed.
The border, scrollbar, and width can be fixed by modifying the iframe properties, as shown in the template code above.
A highly adaptive solution is to observe changes in the body of an IFrame through a MutationObserver, calculate the height of the mount point (the first child) in a callback, and then modify the height of the IFrame itself. The body height is not used directly because the body has a default height and it is wrong to use the body height directly when the component being rendered is smaller than the body height. The core code is as follows
mounted() {
// Change the height of the iframe by observing the body of the iframe.
// The vertical margin overlap effect is lost with iframe
const observer = new MutationObserver(() = > {
const firstEle = this.iframeDoc.body.firstElementChild
const rect = firstEle.getBoundingClientRect()
const marginTop = parseFloat(window.getComputedStyle(firstEle).marginTop, 10)
const marginBottom = parseFloat(window.getComputedStyle(firstEle).marginBottom, 10)
this.$refs.iframe.height = `${rect.height + marginTop + marginBottom}px`
})
observer.observe(this.iframeDoc.body, { childList: true})},Copy the code
There are some limitations to using an IFrame. The most important thing to note is that since an IFrame is a separate form, rendered components can only be enclosed within the form. Therefore, toasts, modal, and drawers that should be global are restricted to the IFrame and cannot be covered globally.
See the complete code
:Github.com/merfais/vue…
See the full demo
:Merfais. Making. IO # / vue – demo / /…
Now that the logic for non-cross-domain IFrame rendering is complete, let’s look at cross-domain IFrame rendering. The rendering process of cross-domain IFrames is basically the same as that of non-cross-domain iframes, but with more complete isolation due to cross-domain. It is mainly reflected in that the primary domain and iframe domain cannot read and write each other’s document flow Document.
The changes brought about by this restriction are as follows
-
Dependent resources need to be built into the IFrame in advance.
Built-in refers to adding dependent resources to an HTML file via script, link tags, and loading along with the HTML. Note also that if you need to rely on resources to mount a VM, you need to add a callback for resource loading, and notify the primary domain of mount after successful loading.
-
Iframe redrawing requires various element operations that can only be done by the IFrame itself
In non-cross-domain IFrame mode, all element operations are completed in the primary domain. In cross-domain mode, these operations and flow control need to be embedded in HTML in script encoding mode. After receiving the mount message from the primary domain, the complete mount process is completed.
-
The primary domain communicates with iframe through postMessage.
For generality, origin = * can be set when calling postMessage, but since postMessage messages are received via window.addeventListener (“message”, callback) in a generic way, Unexpected messages from multiple domains may be accepted, so you need to customize special protocol formats for communication messages to prevent exceptions when processing unknown messages.
The communication between the two is bidirectional. The master station only needs to transmit one message to iframe, namely the mount message containing the complete content of the component. After receiving the message, iframe performs redrawing rendering logic. Iframe transmits two kinds of messages to the master station, one is the status message that can be mounted, and the master station performs the first rendering logic after receiving the message, that is, sending the first mount message, and the other is the message that the body size changes, and the master station changes the size of the IFrame after receiving the message.
The structured Clone Algorithm presents a tricky problem when The host field passes component content to iframe via postMessage. PostMessage has restrictions on The data that can be passed. This limit prevents data of Function type from being passed, but many functions of a component require functions to be implemented. If this limit is not crossed, the component loses half or more of its capacity.
The solution to this limitation is to serialize an unsupported data type, convert it to a supported type, such as String, and deserialize it back at render time. The core code is as follows
/ / the serialization
function serialize(data) {
// Object depth recursion
if (Object.prototype.toString.call(data) === '[object Object]') {
const result = {}
forEach(data, (item, key) = > {
result[key] = this.serialize(item)
})
return result
}
if (Array.isArray(data)) {
return data.map(item= > this.serialize(item))
}
// The function is marked with special marks before and after and converted to string
if (typeof data === 'function') {
return encodeURI(` # #${data.toString()}# # `)}// Other types return directly
return data
}
// deserialize
function deserialize(data) {
// Object depth recursion
if (Object.prototype.toString.call(data) === '[object Object]') {
const result = {}
Object.keys(data).forEach((key) = > {
result[key] = this.deserialize(data[key])
})
return result
}
if (Array.isArray(data)) {
return data.map(item= > this.deserialize(item))
}
// String parsing attempts
if (typeof data === 'string') {
const str = decodeURI(data)
// match special flag, match success, return function
const matched = str.match(([^ ^ / # # #] *) # # $/)
if (matched) {
// String can be converted to function using eval or new function
return newFn(matched[1])}return data
}
// Other types return directly
return data
}
Copy the code
The serialization scheme seems to be perfect, but it also has a lot of inconvenience. After all, it is a kind of degradation. It needs to be noted that closure is broken, or closure functions are not supported, for example:
computed: {
component() {
// Convert the code string into a JS object
const result = safeStringToObject(this.js)
if (result.error) {
return this.renderError({
type: 'JS script error'.msg: result.error.toString(),
})
}
// ...
return component
},
},
methods: {
renderError({ type, msg }) {
return {
// The render function uses the outer variables type and MSG,
// renderError will not be released until render is executed
render() {
return <div style='color: red'>
<div>{type}</div>
<div>{msg}</div>
</div>}}}},Copy the code
RenderError is called when the Component object is generated. This function returns render and uses two parameters of the renderError function. This function normally runs fine. References to type and MSG (reference count) are not released until the render function executes (reference count is cleared).
However, after the component object is serialized, its internal functions are converted to strings, thus losing all features of the function and the closure. After deserialization, the function is restored, but the closure relationship cannot be restored. Therefore, in this way, when render is executed, The type and MSG parameters will become undefined.
To circumvent this limitation, avoid using functions with closures when exporting Component objects. The error handling in the above example can be resolved in the following way
computed: {
component() {
// Convert the code string into a JS object
const result = safeStringToObject(this.js)
if (result.error) {
const template = this.genErrorTpl({
type: 'JS script error'.msg: result.error.toString(),
})
return { template }
}
// ...
return component
},
},
methods: {
genErrorTpl({ type, msg }) {
return `<div style='color: red'><div>${type}</div><div>${msg}</div></div>`}},Copy the code
See the complete code
:
component
:Github.com/merfais/vue…iframe
: Gitlab.com/merfais/sta…
See the full demo
:Merfais. Making. IO # / vue – demo / /…
XSS injection and security
Typically, XSS injection attacks are considered in systems where user input is persisted, and the main aspect of preventing injection is that data entered by the user is not executed or cannot be executed.
Support for the rendering of user-defined components is required to execute user code, which brings XSS injection risks.
Therefore, exercise caution when using this function. Select a solution based on the system security level in different application scenarios. Compare the above four solutions (one dynamic component, three dynamic mounts) to make the following choices
On relatively secure systems that allow XSS injection and have no security issues after injection, you can use any of the first three options, all of which can be used to retrieve user cookies through injection. I recommend using the first dynamic rendering scheme because it is the most flexible and complete rendering.
In less secure systems (where XSS injection may reveal identity information in cookies), the last cross-domain component mount option is recommended to minimize the risk with a full isolation strategy, although this option has many limitations.