├── .browserslistrc
├── .editorconfig
├── .eslintrc.js
├── .gitignore
├── LICENSE
├── README.md
├── babel.config.js
├── doc
└── Vue隐藏技能——运行时渲染.md
├── husky.config.js
├── lint-staged.config.js
├── package.json
├── public
├── favicon.ico
├── iframe.html
└── index.html
├── scripts
└── preCommit.sh
├── src
├── app.vue
├── assets
│ └── logo.png
├── components
│ ├── banner.vue
│ └── helloWorld.vue
├── main.js
├── router
│ └── index.js
├── store
│ ├── index.js
│ └── variable.js
├── utils
│ ├── dom.js
│ └── vm.js
└── views
│ ├── about.vue
│ ├── customCode
│ ├── codeEditor.vue
│ ├── index.vue
│ ├── mountCrossIframe.vue
│ ├── mountSameIframe.vue
│ ├── service.js
│ ├── withComponent.vue
│ └── withMount.vue
│ └── home.vue
└── vue.config.js
/.browserslistrc:
--------------------------------------------------------------------------------
1 | > 1%
2 | last 2 versions
3 | not dead
4 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*.{js,jsx,ts,tsx,vue}]
2 | indent_style = space
3 | indent_size = 2
4 | end_of_line = lf
5 | trim_trailing_whitespace = true
6 | insert_final_newline = true
7 | max_line_length = 100
8 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const level = process.env.NODE_ENV === 'production' ? 2 : 1
2 |
3 | module.exports = {
4 | root: true,
5 | env: {
6 | node: true,
7 | },
8 | extends: [
9 | 'plugin:vue/essential',
10 | '@vue/airbnb',
11 | ],
12 | parserOptions: {
13 | parser: 'babel-eslint',
14 | },
15 | rules: {
16 | // 'import/no-unresolved': 0,
17 | // 'import/newline-after-import': 0,
18 | // 'import/imports-first': 0,
19 | // 'import/extensions': 0,
20 | // 'import/no-dynamic-require': 0,
21 | // 'import/no-extraneous-dependencies': 0,
22 | // 'import/prefer-default-export': 0,
23 | // 'import/no-named-as-default': 0,
24 | // 'import/no-webpack-loader-syntax': 0,
25 |
26 | // 关闭的规则
27 | 'vue/script-indent': 0,
28 | 'import/extensions': 0,
29 | 'import/prefer-default-export': 0,
30 |
31 | 'arrow-parens': 0,
32 | 'arrow-body-style': 0,
33 | 'consistent-return': 0,
34 | 'function-paren-newline': 0,
35 | 'prefer-destructuring': 0,
36 | 'prefer-promise-reject-errors': 0,
37 | 'no-return-assign': 0,
38 | semi: 0,
39 |
40 | // 开发时关闭的规则
41 | 'vue/html-indent': [level, 2, { baseIndent: 0, closeBracket: 1, alignAttributesVertically: false }],
42 | 'vue/no-unused-components': level,
43 |
44 | 'block-spacing': level,
45 | 'brace-style': level,
46 | camelcase: level,
47 | 'comma-spacing': level,
48 | 'comma-dangle': level,
49 | indent: [level, 2, { SwitchCase: 1 }],
50 | 'key-spacing': level,
51 | 'max-len': level,
52 | 'no-console': [level, { allow: ['warn', 'error', 'info'] }],
53 | 'no-debugger': level,
54 | 'no-empty': [level, { allowEmptyCatch: true }],
55 | 'no-mixed-operators': level,
56 | 'no-multiple-empty-lines': [level, { maxEOF: 2, max: 2, maxBOF: 1 }],
57 | 'no-multi-spaces': [level, { ignoreEOLComments: true }],
58 | 'no-param-reassign': level,
59 | 'no-underscore-dangle': level,
60 | 'no-unreachable': level,
61 | 'no-unused-vars': level,
62 | 'object-curly-newline': [level, { consistent: true }],
63 | 'prefer-const': level,
64 | 'padded-blocks': level,
65 | 'quote-props': level,
66 | quotes: level,
67 | 'spaced-comment': level,
68 | 'space-before-blocks': level,
69 | 'space-before-function-paren': level,
70 | 'space-infix-ops': level,
71 |
72 | // 'arrow-spacing': 1,
73 | // camelcase: 0,
74 | // 'comma-dangle': [1, 'only-multiline'],
75 | // 'comma-spacing': 1,
76 | // eqeqeq: 1,
77 | // 'func-names': [1, 'never'],
78 | // 'guard-for-in': 1,
79 | // 'key-spacing': 1,
80 | // 'keyword-spacing': 1,
81 | // indent: [2, 2, { SwitchCase: 1 }],
82 | // 'max-len': 1,
83 | // 'new-cap': 1,
84 | // 'newline-per-chained-call': 0,
85 | // 'no-console': [
86 | // level,
87 | // {
88 | // allow: ['warn', 'error', 'info'],
89 | // },
90 | // ],
91 | // 'no-debugger': level,
92 | // 'no-empty-function': 1,
93 | // 'no-trailing-spaces': [2, {
94 | // skipBlankLines: true
95 | // }],
96 | // 'no-new': 1,
97 | // 'no-mixed-operators': 0,
98 | // 'no-multiple-empty-lines': [1, { max: 2, maxEOF: 1, maxBOF: 1 }],
99 | // 'no-multi-str': 0,
100 | // 'no-multi-spaces': [1, { ignoreEOLComments: true }],
101 | // 'no-unused-vars': [2, { args: 'none' }],
102 | // 'no-unused-expressions': [2, { allowShortCircuit: true }],
103 | // 'no-underscore-dangle': [1, { allowAfterThis: true }],
104 | // 'no-unneeded-ternary': 1,
105 | // 'no-restricted-syntax': [1, 'DebuggerStatement'],
106 | // 'no-plusplus': [1, { allowForLoopAfterthoughts: true }],
107 | // 'no-param-reassign': 0,
108 | // 'no-shadow': 0,
109 | // 'object-shorthand': 0,
110 | // 'object-curly-spacing': 1,
111 | // 'object-curly-newline': [1, { consistent: true }],
112 | // 'operator-linebreak': 0,
113 | // 'one-var': 1,
114 | // 'one-var-declaration-per-line': [1, 'initializations'],
115 | // 'prefer-arrow-callback': 0,
116 | // 'prefer-spread': 1,
117 | // quotes: [1, 'single', {
118 | // avoidEscape: true,
119 | // allowTemplateLiterals: true
120 | // }],
121 | // 'quote-props': 1,
122 | // radix: [1, 'as-needed'],
123 | // 'spaced-comment': 1,
124 | // 'space-infix-ops': 1,
125 | // semi: 0,
126 | // 'space-before-function-paren': [1, 'never'],
127 | // 'space-before-blocks': 1,
128 | },
129 | };
130 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /dist
4 |
5 |
6 | # local env files
7 | .env.local
8 | .env.*.local
9 |
10 | # Log files
11 | npm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 | pnpm-debug.log*
15 |
16 | # Editor directories and files
17 | .idea
18 | .vscode
19 | *.suo
20 | *.ntvs*
21 | *.njsproj
22 | *.sln
23 | *.sw?
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 cof
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # vue-demo
2 |
3 | **vue demo 地址**: [https://merfais.github.io/vue-demo/#/](https://merfais.github.io/vue-demo/#/)
4 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | '@vue/cli-plugin-babel/preset',
4 | ],
5 | };
6 |
--------------------------------------------------------------------------------
/doc/Vue隐藏技能——运行时渲染.md:
--------------------------------------------------------------------------------
1 | ### 一语惊人
2 |
3 | 前段时间接了一个需求:能不能让用户自制组件,从而达到定制渲染某个区域的目的。说实话接到这个需求心中一惊,感叹这个想法真是大胆呀,但作为打工人,秉承着只要思想不滑坡,办法总比困难多的打工魂,即使是刀山也得上呀,历经几日的摸索调研,发现其实VUE一早就支持了这么做,只不过时过境迁,渐渐被遗忘了这个隐藏的技能。
4 |
5 | 大致说一下项目的背景:我们做了一个拖拽生成报表的系统,通过拖拽内置的组件供用户定制自己的报表形态,但毕竟内置的组件有限,可定制性不高,那么给用户开放一个code组件,让用户自己通过写`template` + `js` + `css`的方式自由定制岂不是妙哉。
6 |
7 | ### 重提渐进式
8 |
9 | 那么该怎么实现呢?我们先来看一vue官方的介绍
10 |
11 | > [Vue (读音 /vjuː/,类似于 view) 是一套用于构建用户界面的`渐进式框架`。与其它大型框架不同的是,Vue 被设计为可以自底向上逐层应用。](https://cn.vuejs.org/v2/guide/index.html#Vue-js-%E6%98%AF%E4%BB%80%E4%B9%88)
12 |
13 | 很多时候我们貌似已经忽略了`渐进式`这回事,现在基于VUE开发的项目大多都采用vue cli生成,以vue单文件的方式编码,webpack编译打包的形式发布。这与渐进式有什么关系呢,确实没有关系。
14 |
15 | 渐进式其实指的在一个已存在的但并未使用vue的项目上接入vue,使用vue,直到所有的HTML渐渐替换为通过vue渲染完成,渐进开发,渐进迁移,这种方式在vue刚出现那几年比较多,现在或许在一些古老的项目也会出现。
16 |
17 | 为什么要提`渐进式`呢?因为渐进式是不需要本地编译的,有没有get到点!对,就是`不需要本地编译,而是运行时编译`。
18 |
19 | ### 本地编译与运行时编译
20 |
21 | 用户想通过编写`template` + `js` + `css`的方式实现运行时渲染页面,那肯定是不能本地编译的(此处的编译指将vue文件编译为js资源文件),即不能把用户写的代码像编译源码一样打包成静态资源文件。
22 |
23 | 这些代码只能原样持久化到数据库,每次打开页面再恢复回来,实时编译。毕竟不是纯js文件,是不能直接运行的,它需要一个运行时环境,运行时编译,这个环境就是 [vue的运行时 + 编译器](https://cn.vuejs.org/v2/guide/installation.html#%E8%BF%90%E8%A1%8C%E6%97%B6-%E7%BC%96%E8%AF%91%E5%99%A8-vs-%E5%8F%AA%E5%8C%85%E5%90%AB%E8%BF%90%E8%A1%8C%E6%97%B6)。
24 |
25 | 有了思路也只是窥到了天机,神功练成还是要打磨细节。具体怎么做,容我一步步道来。
26 |
27 | ### 技术干货
28 |
29 | #### 第一步:需要一个运行时编译环境
30 |
31 | 按[官方的介绍](https://learning.dcloud.io/#/?vid=2),通过script标签引入vue就可以渐进式开发了,也就具备了运行时+编译器,如下
32 | ```html
33 |
34 |
35 |
36 | Document
37 |
38 |
39 |
40 | {{message}}
41 |
49 |
50 |
51 | ```
52 |
53 | 但通过vue单文件+webpack编译的方式,再引入一个vue就多余了,通过CLI也是可以的,只需要在vue.config.js中打开runtimeCompiler开关就行了,[详细看文档](https://cli.vuejs.org/zh/config/#runtimecompiler)。
54 |
55 | 此时我们就有了一个运行时编译环境
56 |
57 | #### 第二步:把用户的代码注册到系统中
58 |
59 | 把代码渲染出来有两个方案
60 |
61 |
62 | 1. 通过 [注册组件](https://cn.vuejs.org/v2/guide/components-registration.html) 的方式,把代码注册为vue实例的组件,注册组件又分 全局注册 和 局部注册 两种方式
63 | 2. 通过挂载点直接挂载vue实例, 即通过`new Vue({ el: '#id' })`的方式
64 |
65 | ##### 第一种方案:动态组件
66 |
67 | 对于这种方式,在官方文档中,组件注册章节,最后给出了一个注意点
68 | > 记住全局注册的行为必须在根 Vue 实例 (通过 new Vue) `创建之前`发生。
69 |
70 | 因此,并不能通过调用`Vue.component('my-component-name', {/* */})`的方式将用户的代码注册到系统中,因为运行时Vue实例已经创建完,用户的代码是在实例完Vue后才进来的,那我们只能通过局部注册的方式了,类似这样
71 | ```jsx
72 | var ComponentB = {
73 | components: {
74 | 'component-a': {
75 | ...customJsLogic,
76 | name: 'custom-component',
77 | template: 'custom template
',
78 | }
79 | },
80 | // ...
81 | }
82 | ```
83 |
84 | 但想一下,好像不太对,这还是在写源码,运行时定义了`ComponentB`组件怎么用呢,怎么把`ComponentB`在一个已经编译完页面上渲染出来呢?找不到入口点,把用户代码注入到`components`对象上也无法注册到系统中,无法渲染出来。
85 |
86 | 就止步于此了吗?该怎么办呢?
87 |
88 | 想一下为什么要在`components`中先注册(声明)下组件,然后才能使用?component本质上只不过是一个js object而已。其实主要是为了服务于template模板语法,当你在template中写了 ``,有了这个注册声明才能在编译时找到`compA`。如果不使用template,那么这个注册就可以省了。
89 |
90 | 不使用template怎么渲染呢,使用[render函数](https://cn.vuejs.org/v2/guide/render-function.html)呀!
91 |
92 | 在render函数中如果使用createElement就比较麻烦了,API很复杂,对于渲染一整段用户定义的template也略显吃力,使用jsx就方便多了,都1202年了,想必大家对jsx都应该有所了解。
93 |
94 | 回到项目上,需要使用用户代码的地方不止一处,都用render函数写一遍略显臃肿,那么做一个code的容器,容器负责渲染用户的代码,使用地方把容器挂上就行了。
95 |
96 | + 容器核心代码
97 |
98 | ```javascript
99 | export default {
100 | name: 'customCode',
101 | props: {
102 | template: String, // template模板
103 | js: String, // js逻辑
104 | css: String, // css样式
105 | },
106 | computed: {
107 | className() {
108 | // 生成唯一class,主要用于做scoped的样式
109 | const uid = Math.random().toString(36).slice(2)
110 | return `custom-code-${uid}`
111 | },
112 | scopedStyle() {
113 | if (this.css) {
114 | const scope = `.${this.className}`
115 | const regex = /(^|\})\s*([^{]+)/g
116 | // 为class加前缀,做类似scope的效果
117 | return this.css.trim().replace(regex, (m, g1, g2) => {
118 | return g1 ? `${g1} ${scope} ${g2}` : `${scope} ${g2}`
119 | })
120 | }
121 | return ''
122 | },
123 | component() {
124 | // 把代码字符串转成js对象
125 | const component = safeStringToObject(this.js)
126 |
127 | // 去掉template的前后标签
128 | const template = (this.template || '')
129 | .replace(/^ *< *template *>|<\/ *template *> *$/g, '')
130 | .trim()
131 |
132 | // 注入template或render,设定template优先级高于render
133 | if (this.template) {
134 | component.template = this.template
135 | component.render = undefined
136 | } else if (!component.render) {
137 | component.render = '未提供模板或render函数
'
138 | }
139 |
140 | return component
141 | },
142 | },
143 | render() {
144 | const { component } = this
145 | return
146 |
147 |
148 |
149 | },
150 | }
151 | ```
152 |
153 | + 容器使用
154 |
155 | ```html
156 |
157 |
158 |
159 | ```
160 | 以上只是核心的逻辑部分,除了这些,在项目实战中还应考虑容错处理,错误大致可以分两种
161 |
162 | 1. 用户代码语法错误
163 |
164 | 主要是js部分,对于css和template的错误,浏览器有一定的纠错的机制,不至于崩了。
165 |
166 | 这部分的处理主要借助于`safeStringToObject`这个函数,如果有语法错误,则返回Error,处理一下回显给用户,代码大致如下
167 |
168 | ```javascript
169 | // component对象在result.value上取,如果result.error有值,则代表出现了错误
170 | component() {
171 | // 把代码字符串转成js对象
172 | const result = safeStringToObject(this.js)
173 |
174 | const component = result.value
175 | if (result.error) {
176 | console.error('js 脚本错误', result.error)
177 | result.error = {
178 | msg: result.error.toString(),
179 | type: 'js脚本错误',
180 | }
181 | result.value = { hasError: true }
182 | return result
183 | }
184 |
185 | // ...
186 |
187 | retrun result
188 | }
189 | ```
190 |
191 | 2. 组件运行时错误
192 |
193 | 既然把js逻辑交给了用户控制,那么像类型错误,从undefined中读值,把非函数变量当函数运行,甚至拼写错误等这些运行时错误就很有可能发生。
194 |
195 | 这部分的处理需要通过在容器组件上添加 [`errorCaptured`这个官方钩子](https://cn.vuejs.org/v2/api/index.html#errorCaptured),来捕获子组件的错误,因为并没有一个途径可以获取组件自身运行时错误的钩子。代码大致如下
196 |
197 | ```javascript
198 | errorCaptured(err, vm, info) {
199 | this.subCompErr = {
200 | msg: err && err.toString && err.toString() || err,
201 | type: '自定义组件运行时错误:',
202 | }
203 | console.error('自定义组件运行时错误:', err, vm, info)
204 | },
205 | ```
206 |
207 | 结合错误处理,如果希望用户能看到错误信息,则render函数需要把错误展示出来,代码大致如下
208 |
209 | ```Jsx
210 | render() {
211 | const { error: compileErr, value: component } = this.component
212 | const error = compileErr || this.subCompErr
213 | let errorDom
214 | if (error) {
215 | errorDom =
216 |
{error.type}
217 |
{error.msg}
218 |
219 | }
220 | return
221 |
222 |
223 |
224 |
225 | {errorDom}
226 |
227 | },
228 | ```
229 |
230 | 这里还有一个点,用户发现组件发生了错误后会修改代码,使其再次渲染,错误的回显需要特别处理下。
231 |
232 | 对于js脚本错误,因component是计算属性,随着computed计算属性再次计算,如果js脚本没有错误,导出的component可重绘出来,
233 |
234 | 但对于运行时错误,使用`this.subCompErr`内部变量保存,props修改了,这个值却不会被修改,因此需要打通props关联,通过添加watch的方式解决,这里为什么没有放在component的计算属性中做,一是违背计算属性设计原则,二是component可能并不仅仅依赖js,css,template这个props的变化,而`this.subCompErr`只需要和这个三个props关联,这么做会有多余的重置逻辑。
235 |
236 | 还有一种场景就是子组件自身可能有定时刷新逻辑,定期或不定期的重绘,一旦发生了错误,也会导致一直显示错误信息,因为用户的代码拿不到`this.subCompErr`的值,因此也无法重置此值,这种情况,可通过注入`beforeUpdate`钩子解决,代码大致如下
237 |
238 | ```javascript
239 | computed: {
240 | component() {
241 | // 把代码字符串转成js对象
242 | const result = safeStringToObject(this.js)
243 | const component = result.value
244 | // ...
245 | // 注入mixins
246 | component.mixins = [{
247 | // 注入 beforeUpdate 钩子,用于子组件重绘时,清理父组件捕获的异常
248 | beforeUpdate: () => {
249 | this.subCompErr = null
250 | },
251 | }]
252 | // ...
253 | return result
254 | },
255 | },
256 | watch: {
257 | js() {
258 | // 当代码变化时,清空error,重绘
259 | this.subCompErr = null
260 | },
261 | template() {
262 | // 当代码变化时,清空error,重绘
263 | this.subCompErr = null
264 | },
265 | css() {
266 | // 当代码变化时,清空error,重绘
267 | this.subCompErr = null
268 | },
269 | },
270 | ```
271 | **`完整的代码见`:[https://github.com/merfais/vue-demo/blob/main/src/views/customCode/withComponent.vue](https://github.com/merfais/vue-demo/blob/main/src/views/customCode/withComponent.vue)**
272 |
273 | **`完整的demo见`:[https://merfais.github.io/vue-demo/#/custom-code](https://merfais.github.io/vue-demo/#/custom-code)**
274 |
275 | ##### 第二种方案:动态实例
276 |
277 | 我们知道在利用vue构建的系统中,页面由组件构成,页面本身其实也是组件,只是在部分参数和挂载方式上有些区别而已。这第二种方式就是将用户的代码视为一个page,通过new一个vm实例,再在DOM挂载点挂载vm(`new Vue(component).$mount('#id')`)的方式渲染。
278 |
279 | 动态实例方案与动态组件方案大致相同,都要通过computed属性,生成`component`对象和`scopedStyle`对象进行渲染,但也有些许的区别,动态实例比动态组件需要多考虑以下几点:
280 |
281 | 1. 需要一个稳定的挂载点
282 |
283 | 从vue2.0开始,vue实例的挂载策略变更为,[所有的挂载元素会被 Vue 生成的 DOM 替换](https://cn.vuejs.org/v2/api/#el),在此策略下,一旦执行挂载,原来的DOM就会消失,不能再次挂载。但我们需要实现代码变更后能够重新渲染,这就要求挂载点要稳定存在,解决方案是对用户的template进行注入,每次渲染前,在template外层包一层带固定id的DOM
284 |
285 | 2. 运行时错误捕获`errorCaptured`需要注入到`component`对象上,不再需要注入`beforeUpdate`钩子
286 |
287 | 因为通过`new Vue()`的方式创建了一个新的vm实例,不再是容器组件的子组件,所以容器组件上的`errorCaptured`无法捕获新vm的运行时错误,`new Vue(component)`中参数component是顶层组件,根据 [Vue错误传播规则](https://cn.vuejs.org/v2/api/#errorCaptured) 可知,在非特殊控制的情况下,顶层的 `errorCaptured` 会捕获到错误
288 |
289 | 3. 首次挂载需要制造一定的延迟才能渲染
290 |
291 | 由于挂载点含在DOM在容器内,与计算属性导出的`component`对象在首次挂载时时序基本是一致的,导致挂载vm(`$mount('#id')`)时,DOM可能还没有渲染到文档流上,因此在首次渲染时需要一定的延迟后再挂载vm。
292 |
293 | 以上的不同点,并未给渲染用户自定义代码带来任何优势,反而增加了限制,尤其 **需要稳定挂载点** 这一条,需要对用户提供的template做二次注入,包裹挂载点,才能实现用户修改组件后的实时渲染更新,因此,也不能支持用户定义render函数,因为无法获取未经运行的render函数的返回值,也就无法注入外层的挂载点。
294 |
295 | 另外一点也需要注意,这种方式也是无法在容器组件中使用template定义渲染模板的,因为如果在template中写style标签会出现以下编译错误,但style标签是必须的,需要为自定义组件提供scoped的样式。(当然,也可以通过提供appendStyle函数实现动态添加style标签,但这样并没有更方便,因此没有必要)
296 |
297 | ```shell
298 | Errors compiling template:
299 |
300 | Templates should only be responsible for mapping the state to the UI. Avoid placing tags with side-effects in your templates, such as
305 | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
306 | 5 |
307 | | ^^^^^^^
308 | ```
309 |
310 | 鉴于以上缺点,就不提供核心代码示范了,直接给源码和demo
311 |
312 | **`完整的代码见`:[https://github.com/merfais/vue-demo/blob/main/src/views/customCode/withMount.vue](https://github.com/merfais/vue-demo/blob/main/src/views/customCode/withMount.vue)**
313 |
314 | **`完整的demo见`:[https://merfais.github.io/vue-demo/#/custom-code](https://merfais.github.io/vue-demo/#/custom-code)**
315 |
316 | 想一下,如果动态实例方案仅仅有以上缺点,那考虑这种方案有什么意义呢?其实,它的意义在于,动态实例方案主要应用于iframe渲染,而使用iframe渲染的目的则是为了隔离。
317 |
318 | iframe会创建独立于主站的一个域,这种隔离可以很好地防止js污染和css污染,隔离方式又分为跨域隔离和非跨域隔离两种,跨域则意味着完全隔离,非跨域则是半隔离,其主要区别在于安全策略的限制,这个我们最后再说。
319 |
320 | iframe是否跨域由iframe的src的值决定,设置同域的src或不设置src均符合同域策略,否则是跨域。对于没有设置src的iframe,页面只能加载一个空的iframe,因此还需要在iframe加载完后再动态加载依赖的资源,如:vuejs,其他运行时的依赖库(示例demo加载了ant-design-vue)等。如果设置了src,则可以将依赖通过script标签和link标签提前写到静态页面文件中,使依赖资源在加载iframe时自动完成加载。
321 |
322 | 先介绍半隔离方式,即通过非跨域iframe渲染,首先需要渲染一个iframe,我们使用不设置src的方式,这样更具备通用性,可以用于任意的站点。核心代码如下
323 |
324 | ```html
325 |
326 |
327 |
328 | ```
329 |
330 | 由于是位于同域,主站与iframe可以互相读取window和document引用,因为,可以动态加载资源,核心代码如下
331 |
332 | ```javascript
333 | methods: {
334 | mountResource() {
335 | // 添加依赖的css
336 | appendLink('https://cdn.bootcdn.net/ajax/libs/ant-design-vue/1.7.2/antd.min.css', this.iframeDoc)
337 | // 添加依赖的js,保留handler用于首次渲染的异步控制
338 | this.mountResourceHandler = appendScriptLink([{
339 | src: 'https://cdn.bootcdn.net/ajax/libs/vue/2.6.12/vue.min.js',
340 | defer: true,
341 | }, {
342 | src: 'https://cdn.bootcdn.net/ajax/libs/ant-design-vue/1.7.2/antd.min.js',
343 | defer: true,
344 | }], this.iframeDoc)
345 | },
346 | },
347 | mounted() {
348 | this.iframeDoc = this.$refs.iframe.contentDocument
349 | this.mountResource()
350 | },
351 | ```
352 |
353 | 接下来是组件对象组装和挂载,基本上和动态组件的大同小异,只是挂载不再通过render函数。先上核心代码,再说注意点。
354 |
355 | ```javascript
356 | computed: {
357 | component() {
358 | // 把代码字符串转成js对象
359 | const component = safeStringToObject(this.js)
360 |
361 | // 关联css,为的是修改css后可自动重绘
362 | component.css = this.css
363 |
364 | // 去掉template的前后标签
365 | const template = (this.template || '')
366 | .replace(/^ *< *template *>|<\/ *template *> *$/g, '')
367 | .trim()
368 |
369 | // 注入template或render,设定template优先级高于render
370 | if (template) {
371 | component.template = template
372 | component.render = undefined
373 | } else if (!component.render) {
374 | component.template = '未提供模板或render函数'
375 | }
376 |
377 | return component
378 | },
379 | },
380 | watch: {
381 | component() {
382 | if (this.hasInit) {
383 | this.mountCode()
384 | } else if (this.mountResourceHandler) {
385 | this.mountResourceHandler.then(() => {
386 | this.hasInit = true
387 | this.mountCode()
388 | })
389 | }
390 | },
391 | },
392 | methods: {
393 | mountCode() {
394 | // 添加css
395 | const css = this.component.css
396 | delete this.component.css
397 | removeElement(this.styleId, this.iframeDoc)
398 | this.styleId = appendStyle(css, this.iframeDoc)
399 |
400 | // 重建挂载点
401 | if (this.iframeDoc.body.firstElementChild) {
402 | this.iframeDoc.body.removeChild(this.iframeDoc.body.firstElementChild)
403 | }
404 | prependDom({ tag: 'div', id: 'app' }, this.iframeDoc)
405 |
406 | // 挂载实例
407 | const Vue = this.iframeWin.Vue
408 | new Vue(this.component).$mount('#app')
409 | },
410 | },
411 | ```
412 |
413 | 注意点:
414 | 1. iframe的渲染到文档流后才能添加依赖资源,依赖资源加载完才能执行vm的挂载,首次加载时需要控制时序
415 | 2. vm挂载点的重建采用了永远添加在body的第一个子元素的方式,这么做的原因是一些第三方的库(如ant-design-vue)也会向body中动态添加element,虽然采用`docment.body.innerHTML=''`的方式可以快速且干净的清空body内容,但也会将第三方库添加的内容给干掉,导致第三方库全部或部分不可用。
416 | 3. 为了使css变化后也引发重绘,在计算属性`component`中也绑定了css的值,但这对于新建vm实例这个字段是无用的,也可以通过watch css的方式实现
417 |
418 | 接下来考虑错误处理,对于iframe挂载的错误处理稍有不同,为了尽量不干预用户的代码,此模式下的错误渲染采用重建DOM,重新渲染vm的策略,即发生错误后,无论是静态的语法错误还是运行时错误,都重绘。当然这种做法也就丢失了组件自刷新的功能,因为一旦发生错误,原来的组件会被卸载,渲染为错误信息。核心代码如下
419 |
420 | ```Jsx
421 | computed: {
422 | component() {
423 | if (this.subCompErr) {
424 | return this.renderError(this.subCompErr)
425 | }
426 |
427 | // 把代码字符串转成js对象
428 | const result = safeStringToObject(this.js)
429 | if (result.error) {
430 | return this.renderError({
431 | type: 'js脚本错误',
432 | msg: result.error.toString(),
433 | })
434 | }
435 |
436 | const component = result.value
437 |
438 | // 注入errorCaptured, 用于错误自定义组件运行时捕获
439 | component.errorCaptured = (err, vm, info) => {
440 | this.subCompErr = {
441 | msg: err && err.toString && err.toString(),
442 | type: '自定义组件运行时错误:',
443 | }
444 | console.error('自定义组件运行时错误:', err, vm, info)
445 | }
446 |
447 | return component
448 | },
449 | },
450 | watch: {
451 | js() {
452 | // 当代码变化时,清空error,重绘
453 | this.subCompErr = null
454 | },
455 | template() {
456 | // 当代码变化时,清空error,重绘
457 | this.subCompErr = null
458 | },
459 | css() {
460 | // 当代码变化时,清空error,重绘
461 | this.subCompErr = null
462 | },
463 | },
464 | methods: {
465 | renderError({ type, msg }) {
466 | return {
467 | render() {
468 | return
469 |
{type}
470 |
{msg}
471 |
472 | },
473 | }
474 | },
475 | },
476 | ```
477 |
478 | 除了错误处理,还需解决一下iframe的一些特性,比如边框,滚动条,默认宽高,其中比较棘手是iframe高度有默认值,并不会随着iframe的内容自适应高度,但对于自定义组件的渲染,需要动态计算高度,固定高度是不行的。
479 |
480 | 边框,滚动条,宽度可通过修改iframe的属性解决,见上面的template代码。
481 |
482 | 高度自适应的解决方案是通过`MutationObserver`观测iframe的body变化,在回调中计算挂载点(第一个子元素)的高度,然后再修改iframe本身的高度。之所以没有直接使用body的高度,是因为body有默认的高度,当被渲染的组件高度小于body高度时,直接使用body的高度是错的。 核心代码如下
483 |
484 | ```javascript
485 | mounted() {
486 | // 通过观察器观察iframe的body变化后修改iframe的高度,
487 | // 使用iframe后垂直的margin重合效果会丢失
488 | const observer = new MutationObserver(() => {
489 | const firstEle = this.iframeDoc.body.firstElementChild
490 | const rect = firstEle.getBoundingClientRect()
491 | const marginTop = parseFloat(window.getComputedStyle(firstEle).marginTop, 10)
492 | const marginBottom = parseFloat(window.getComputedStyle(firstEle).marginBottom, 10)
493 | this.$refs.iframe.height = `${rect.height + marginTop + marginBottom}px`
494 | })
495 | observer.observe(this.iframeDoc.body, { childList: true })
496 | },
497 | ```
498 |
499 | 使用iframe还存在一些局限性,最需要注意的一点就是由于iframe是独立的窗体,那么渲染出来的组件只能封在这个窗体内,因此,像一些本应该是全局的toast, modal, drawer都会被局限在iframe内,无法覆盖到全局上。
500 |
501 | **`完整的代码见`:[https://github.com/merfais/vue-demo/blob/main/src/views/customCode/mountSameIframe.vue](https://github.com/merfais/vue-demo/blob/main/src/views/customCode/mountSameIframe.vue)**
502 |
503 | **`完整的demo见`:[https://merfais.github.io/vue-demo/#/custom-code](https://merfais.github.io/vue-demo/#/custom-code)**
504 |
505 | 至此非跨域iframe渲染全部逻辑介绍完毕,接下来看一下跨域iframe的渲染。跨域iframe与非跨域iframe的渲染过程基本是一致的,只是有由于跨域,隔离的更彻底。其主要体现在主域与iframe域不能互相读写对方的文档流document。
506 |
507 | 此限制带来的变化有以下几点
508 |
509 | 1. 依赖的资源需要提前内置在iframe内。
510 |
511 | 内置指的是将依赖的资源通过script,link标签添加到html文件中,随html一并加载。有一点还需要注意,如果挂载vm时需要依赖某些资源,需要添加资源加载的回调,加载成功后再通知主域挂载。
512 |
513 | 2. iframe重新绘制需要各种元素操作只能由iframe自己完成
514 |
515 | 在非跨域iframe模式下所有的元素操作都在主域中完成,在跨域模式下这些操作和流程控制都需要以script编码的方式内置在html内,在接到主域的挂载消息后,完整挂载过程。
516 |
517 | 3. 主域与iframe的通信需要通过`postMessage`。
518 |
519 | 为了通用性,调用`postMessage`时可以设置`origin = *`,但由于接收postMessage消息通过 `window.addEventListener("message", callback)`这种通用的方式,可能会接受来自多个域的非期待的消息,因此,需要对通信消息定制特殊协议格式,防止出现处理了未知消息而发生异常。
520 |
521 | 两者间通信是双向的,主站向iframe只需传递一种消息,即含组件完整内容的挂载消息,iframe接到消息后执行重绘渲染逻辑;iframe向主站传递两种消息,一是可以挂载的状态消息,主站接到消息后执行首次渲染逻辑,即发送首次挂载消息,二是body size变化的消息,主站接到消息后修改iframe的尺寸。
522 |
523 | 在处理主域将组件内容通过`postMessage`传给iframe时,碰到了一个棘手的问题,postMessage对可传递的数据有限制,具体的限制可查看 [The structured clone algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm),这个限制导致`Function`类型的数据无法传过去,但组件很多功能需要使用函数才能实现,无法跨越这个限制,组件能力将损失过半或更甚。
524 |
525 | 对于这个限制的解决方案是:对不支持的数据类型进行序列化,转成支持的类型,如string,渲染时再反序列化回来。核心代码如下
526 |
527 | ```javascript
528 | // 序列化
529 | function serialize(data) {
530 | // 对象深度递归
531 | if (Object.prototype.toString.call(data) === '[object Object]') {
532 | const result = {}
533 | forEach(data, (item, key) => {
534 | result[key] = this.serialize(item)
535 | })
536 | return result
537 | }
538 | if (Array.isArray(data)) {
539 | return data.map(item => this.serialize(item))
540 | }
541 | // 函数前后打上特殊标记后转成string
542 | if (typeof data === 'function') {
543 | return encodeURI(`##${data.toString()}##`)
544 | }
545 | // 其他类型直接返回
546 | return data
547 | }
548 | // 反序列化
549 | function deserialize(data) {
550 | // 对象深度递归
551 | if (Object.prototype.toString.call(data) === '[object Object]') {
552 | const result = {}
553 | Object.keys(data).forEach((key) => {
554 | result[key] = this.deserialize(data[key])
555 | })
556 | return result
557 | }
558 | if (Array.isArray(data)) {
559 | return data.map(item => this.deserialize(item))
560 | }
561 | // string类型尝试解析
562 | if (typeof data === 'string') {
563 | const str = decodeURI(data)
564 | // 匹配特殊标记,匹配成功,反转为function
565 | const matched = str.match(/^##([^#]*)##$/)
566 | if (matched) {
567 | // string转成function可以用eval也可用new Function
568 | return newFn(matched[1])
569 | }
570 | return data
571 | }
572 | // 其他类型直接返回
573 | return data
574 | }
575 | ```
576 |
577 | 序列化方案看似完美,其实也有诸多的不便,毕竟是一种降级,需要特别注意的一点是,**闭包被破坏**,或者说是不支持闭包函数,举个例子:
578 |
579 | ```jsx
580 | computed: {
581 | component() {
582 | // 把代码字符串转成js对象
583 | const result = safeStringToObject(this.js)
584 | if (result.error) {
585 | return this.renderError({
586 | type: 'js脚本错误',
587 | msg: result.error.toString(),
588 | })
589 | }
590 | // ...
591 | return component
592 | },
593 | },
594 | methods: {
595 | renderError({ type, msg }) {
596 | return {
597 | // 这里用到了闭包,render函数使用了外层变量type和msg,
598 | // renderError函数执行结束后这两个变量并不会释放,需等render函数执行后才会释放
599 | render() {
600 | return
601 |
{type}
602 |
{msg}
603 |
604 | }
605 | }
606 | },
607 | },
608 | ```
609 | 上面在生成 component 对象时调用了函数`renderError`,此函数返回了一个函数`render`,且使用了外层函数`renderError`的两个参数,正常情况下运行是没有问题的,`type`和`msg`的引用(引用计数)会等到`render`函数执行后才会释放(引用计数清零)。
610 |
611 | 但 component 对象经过序列化后,其内部的函数被转成了字符串,因而丢失了函数的所有特性,闭包也因此丢失,经反序列化回来后,虽然还原了函数,但闭包关系无法恢复,因此,这种写法,在执行render时,`type`和`msg`两个参数会变为`undefined`。
612 |
613 | 为了规避这种限制,应在导出 component 对象时避免使用含闭包的函数, 上例中的错误处理可通过以下方式解决
614 |
615 | ```jsx
616 | computed: {
617 | component() {
618 | // 把代码字符串转成js对象
619 | const result = safeStringToObject(this.js)
620 | if (result.error) {
621 | const template = this.genErrorTpl({
622 | type: 'js脚本错误',
623 | msg: result.error.toString(),
624 | })
625 | return { template }
626 | }
627 | // ...
628 | return component
629 | },
630 | },
631 | methods: {
632 | genErrorTpl({ type, msg }) {
633 | return ``
634 | },
635 | }
636 | ```
637 |
638 |
639 | **`完整的代码见`:**
640 |
641 | + **`组件`:[https://github.com/merfais/vue-demo/blob/main/src/views/customCode/mountCrossIframe.vue](https://github.com/merfais/vue-demo/blob/main/src/views/customCode/mountCrossIframe.vue)**
642 | + **`iframe`: [https://gitlab.com/merfais/static-page/-/blob/master/public/iframe.html](https://gitlab.com/merfais/static-page/-/blob/master/public/iframe.html)**
643 |
644 | **`完整的demo见`:[https://merfais.github.io/vue-demo/#/custom-code](https://merfais.github.io/vue-demo/#/custom-code)**
645 |
646 | ### XSS注入与安全
647 |
648 | 通常情况下,在需要将用户输入持久化的系统中,都要考虑XSS的注入攻击,而防止注入的主要表现则是使用户输入的数据不被执行,或不能被执行。
649 |
650 | 而前文介绍的要支持用户自定义组件的渲染,恰好就是要执行用户代码,可见,此功能势必会带来XSS注入风险。
651 |
652 | 因此,在使用此功能时要慎重,在不同的应用场景中,要根据系统的安全级别,选取相应的方案。对比以上四种方案(1种动态组件,3种动态挂载)可做以下选择
653 |
654 | 在一些相对安全(允许xss注入,注入后没有安全问题)的系统中,可以使用前三种方案中的任意一种,这三种都是可以通过注入获取用户cookie的。个人推荐使用第一种动态渲染方案,因为此方案灵活性和渲染完整度都是最高的。
655 |
656 | 在一些不太安全(xss注入可能会泄露cookie中的身份信息)的系统中,推荐使用最后一种跨域组件挂载方案,通过完全隔离策略可以最大程度的降低风险,当然此方案也有很多的局限性。
657 |
658 |
--------------------------------------------------------------------------------
/husky.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | hooks: {
3 | 'pre-commit': 'lint-staged',
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/lint-staged.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | '*': [
3 | './scripts/preCommit.sh',
4 | 'git add',
5 | ],
6 | };
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vue-demo",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "serve": "vue-cli-service serve",
7 | "build": "vue-cli-service build",
8 | "lint": "NODE_ENV=production eslint -c ./.eslintrc.js --ext js,jsx,vue --max-warnings=0 --no-error-on-unmatched-pattern ./",
9 | "lint:fix": "NODE_ENV=production eslint -c ./.eslintrc.js --fix --ext js,jsx,vue --max-warnings=0 --no-error-on-unmatched-pattern ./"
10 | },
11 | "dependencies": {
12 | "ant-design-vue": "^1.7.2",
13 | "codemirror": "^5.59.1",
14 | "core-js": "^3.6.5",
15 | "lodash": "^4.17.20",
16 | "vue": "^2.6.11",
17 | "vue-codemirror": "^4.0.6",
18 | "vue-router": "^3.2.0",
19 | "vuex": "^3.4.0"
20 | },
21 | "devDependencies": {
22 | "@vue/cli-plugin-babel": "~4.5.0",
23 | "@vue/cli-plugin-eslint": "~4.5.0",
24 | "@vue/cli-plugin-router": "~4.5.0",
25 | "@vue/cli-plugin-vuex": "~4.5.0",
26 | "@vue/cli-service": "~4.5.0",
27 | "@vue/eslint-config-airbnb": "^5.0.2",
28 | "babel-eslint": "^10.1.0",
29 | "eslint": "^6.7.2",
30 | "eslint-plugin-import": "^2.20.2",
31 | "eslint-plugin-vue": "^6.2.2",
32 | "husky": "^4.3.8",
33 | "less": "^4.1.0",
34 | "less-loader": "^7.2.1",
35 | "lint-staged": "^10.5.3",
36 | "vue-template-compiler": "^2.6.11"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/merfais/vue-demo/8748b2ecef8d62bdc06bb1c2a4caf7ac5f16c1c2/public/favicon.ico
--------------------------------------------------------------------------------
/public/iframe.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | I'm iframe
9 |
17 |
18 |
19 |
20 |
146 |
147 |
148 |
149 |
152 |
167 |
168 |
169 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | <%= htmlWebpackPlugin.options.title %>
9 |
10 |
11 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/scripts/preCommit.sh:
--------------------------------------------------------------------------------
1 | #! /bin/bash
2 |
3 | export NODE_ENV='production'
4 | git diff --cached --name-only | \
5 | grep -E "src/.*\.(js|jsx|ts|tsx|vue)$" |\
6 | grep -v 'mocker' |\
7 | xargs eslint -c ./.eslintrc.js --max-warnings=0 --fix --no-error-on-unmatched-pattern
8 | exit $?
9 |
--------------------------------------------------------------------------------
/src/app.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
16 |
26 |
--------------------------------------------------------------------------------
/src/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/merfais/vue-demo/8748b2ecef8d62bdc06bb1c2a4caf7ac5f16c1c2/src/assets/logo.png
--------------------------------------------------------------------------------
/src/components/banner.vue:
--------------------------------------------------------------------------------
1 |
2 |
28 |
29 |
55 |
103 |
--------------------------------------------------------------------------------
/src/components/helloWorld.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
{{ msg }}
4 |
5 | For a guide and recipes on how to configure / customize this project,
6 | check out the
7 | vue-cli documentation.
8 |
9 |
Installed CLI Plugins
10 |
16 |
Essential Links
17 |
24 |
Ecosystem
25 |
32 |
33 |
34 |
35 |
43 |
44 |
45 |
61 |
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 | import Antd from 'ant-design-vue';
3 | import 'ant-design-vue/dist/antd.css';
4 | import Codemirror from 'vue-codemirror';
5 | import 'codemirror/lib/codemirror.css';
6 | import App from './app.vue';
7 | import router from './router';
8 | import store from './store';
9 |
10 | Vue.config.productionTip = false;
11 |
12 | Vue.use(Antd);
13 | Vue.use(Codemirror, /* {
14 | options: { theme: 'base16-dark', ... },
15 | events: ['scroll', ...]
16 | } */);
17 |
18 | new Vue({
19 | router,
20 | store,
21 | render: (h) => h(App),
22 | }).$mount('#app');
23 |
--------------------------------------------------------------------------------
/src/router/index.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 | import VueRouter from 'vue-router';
3 |
4 | Vue.use(VueRouter);
5 |
6 | export const routes = [
7 | {
8 | path: '/',
9 | name: 'Home',
10 | component: () => import(/* webpackChunkName: "home" */ '@/views/home.vue'),
11 | },
12 | {
13 | path: '/custom-code',
14 | name: 'CustomCode',
15 | component: () => import(/* webpackChunkName customCode */ '@/views/customCode/index.vue'),
16 | },
17 | ];
18 |
19 | const router = new VueRouter({
20 | routes,
21 | });
22 |
23 | export default router;
24 |
--------------------------------------------------------------------------------
/src/store/index.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import Vuex from 'vuex'
3 | import variable from './variable'
4 |
5 | Vue.use(Vuex)
6 |
7 | export default new Vuex.Store({
8 | state: {
9 | filePath: '',
10 | pageName: '',
11 | },
12 | mutations: {
13 | setState(state, payload) {
14 | Object.assign(state, payload)
15 | },
16 | },
17 | actions: {
18 | },
19 | modules: {
20 | variable,
21 | },
22 | })
23 |
--------------------------------------------------------------------------------
/src/store/variable.js:
--------------------------------------------------------------------------------
1 | import map from 'lodash/map'
2 |
3 | const variable = {
4 | namespaced: true,
5 | state: {
6 | map: {
7 | varA: 1,
8 | },
9 | },
10 | getters: {
11 | list(state, getters) {
12 | return map(state.map, (item) => {
13 | const getterKey = `${item.key}/value`
14 | let value = ''
15 | try {
16 | value = JSON.stringify(getters[getterKey])
17 | } catch (e) {}
18 | return {
19 | key: item.key,
20 | type: item.type,
21 | name: item.name,
22 | value,
23 | }
24 | })
25 | },
26 | },
27 | mutations: {
28 | setState(state, payload) {
29 | Object.assign(state, payload)
30 | },
31 | },
32 | actions: {
33 | },
34 | }
35 |
36 | export default variable
37 |
--------------------------------------------------------------------------------
/src/utils/dom.js:
--------------------------------------------------------------------------------
1 | export function appendLink(href, doc = document) {
2 | const link = doc.createElement('link');
3 | link.rel = 'stylesheet';
4 | link.type = 'text/css';
5 | link.href = href;
6 | link.media = 'all';
7 | doc.head.appendChild(link);
8 | }
9 |
10 | export function appendScriptLink(data, doc = document) {
11 | const list = !Array.isArray(data) ? [data] : data;
12 | return Promise.all(list.map(item => new Promise(resolve => {
13 | const script = doc.createElement('script');
14 | script.type = 'text/javascript';
15 | script.onload = resolve;
16 | Object.assign(script, item);
17 | doc.head.appendChild(script);
18 | })))
19 | }
20 |
21 | export function appendStyle(data, doc = document) {
22 | const style = doc.createElement('style');
23 | style.id = Math.random().toString(36).slice(2);
24 | style.type = 'text/css';
25 | style.appendChild(doc.createTextNode(data));
26 | doc.head.appendChild(style);
27 | return style.id;
28 | }
29 |
30 | export function appendScript(data, doc = document) {
31 | const script = doc.createElement('script');
32 | script.id = Math.random().toString(36).slice(2);
33 | script.type = 'text/javascript';
34 | script.appendChild(doc.createTextNode(data));
35 | doc.head.appendChild(script);
36 | return script.id;
37 | }
38 |
39 | export function prependDom(data, doc = document) {
40 | const tag = typeof data === 'string' ? data : data.tag;
41 | const dom = doc.createElement(tag);
42 | Object.assign(dom, data)
43 | doc.body.prepend(dom);
44 | }
45 |
46 | export function removeElement(id, doc = document) {
47 | if (!id) {
48 | return
49 | }
50 | const ele = doc.getElementById(id);
51 | if (ele) {
52 | ele.parentNode.removeChild(ele);
53 | }
54 | }
55 |
56 |
--------------------------------------------------------------------------------
/src/utils/vm.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 将字符串转换成代码对象
3 | * @param code 代码
4 | * @param value 默认值
5 | * @param params scoped变量,上下文变量,类似全局变量
6 | */
7 | export function stringToCode(code, value, params) {
8 | const result = { value, error: null }
9 | try {
10 | result.value = new Function('context', `return ${code}`)(params) || value // eslint-disable-line no-new-func
11 | } catch (e) {
12 | console.error('js脚本错误:', e)
13 | result.error = e
14 | }
15 | return result
16 | }
17 |
18 | /**
19 | * 执行一段字符串格式的函数
20 | */
21 | export function runFnInVm(code, params, globalParams) {
22 | const NOOP = args => args
23 | const result = stringToCode(code, NOOP, globalParams)
24 | const fn = result.value
25 | result.value = params
26 | if (result.error) {
27 | return result
28 | }
29 | if (typeof fn !== 'function') {
30 | console.error('非法的js脚本函数', fn)
31 | result.error = new Error('非法的js脚本函数')
32 | return result
33 | }
34 | try {
35 | result.value = fn.call(fn, params)
36 | } catch (e) {
37 | console.error('js脚本执行错误:', e)
38 | result.error = e
39 | }
40 | return result
41 | }
42 |
--------------------------------------------------------------------------------
/src/views/about.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
This is an about page
4 |
5 |
6 |
--------------------------------------------------------------------------------
/src/views/customCode/codeEditor.vue:
--------------------------------------------------------------------------------
1 |
74 |
85 |
--------------------------------------------------------------------------------
/src/views/customCode/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
12 |
17 |
18 |
19 |
20 |
21 |
24 | 预览 >>
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
124 |
125 |
165 |
--------------------------------------------------------------------------------
/src/views/customCode/mountCrossIframe.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
156 |
158 |
--------------------------------------------------------------------------------
/src/views/customCode/mountSameIframe.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
187 |
189 |
--------------------------------------------------------------------------------
/src/views/customCode/service.js:
--------------------------------------------------------------------------------
1 | export const template = `
2 |
3 |
4 | 点我
5 |
6 |
{{item}}
7 |
8 | `
9 |
10 |
11 | export const js = `function generate() {
12 | return {
13 | name: 'customCode',
14 | methods: {
15 | onClick() {
16 | const cookie = window.parent.document.cookie
17 | this.$message.info(\`消息提示: cookie = \${cookie}\`)
18 | }
19 | },
20 | };
21 | }`
22 |
23 | export const css = `.wrapper {
24 | margin: 10px;
25 | padding: 10px;
26 | border: 1px solid #ccc;
27 | }
28 | `
29 |
--------------------------------------------------------------------------------
/src/views/customCode/withComponent.vue:
--------------------------------------------------------------------------------
1 |
160 |
175 |
--------------------------------------------------------------------------------
/src/views/customCode/withMount.vue:
--------------------------------------------------------------------------------
1 |
173 |
188 |
--------------------------------------------------------------------------------
/src/views/home.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |

5 |
6 |
7 |
8 |
12 |
13 |
{{item.name}}
14 |
15 |
16 |
17 |
18 |
19 |
35 |
36 |
90 |
--------------------------------------------------------------------------------
/vue.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | const devServer = {
4 | port: 8000,
5 | watchOptions: {
6 | // poll: true,
7 | },
8 | disableHostCheck: true,
9 | };
10 |
11 | const chainWebpack = (config) => {
12 | config.resolve.alias
13 | .set('@', path.resolve(__dirname, 'src'))
14 | .set('src', path.resolve(__dirname, 'src'))
15 | .set('plugins', path.resolve(__dirname, 'src/plugins'))
16 | .set('components', path.resolve(__dirname, 'src/components'))
17 | .set('router', path.resolve(__dirname, 'src/router'))
18 | .set('store', path.resolve(__dirname, 'src/store'))
19 | .set('services', path.resolve(__dirname, 'src/services'))
20 | .set('network', path.resolve(__dirname, 'src/network'))
21 | .set('utils', path.resolve(__dirname, 'src/utils'))
22 | .set('pages', path.resolve(__dirname, 'src/pages'))
23 | .set('views', path.resolve(__dirname, 'src/views'));
24 |
25 | if (process.env.NODE_ENV === 'development') {
26 | config.devtool(false);
27 | }
28 | };
29 |
30 | const pages = {
31 | index: {
32 | // page 的入口
33 | entry: 'src/main.js',
34 | // 模板来源
35 | template: 'public/index.html',
36 | // 在 dist/index.html 的输出
37 | filename: 'index.html',
38 | // 当使用 title 选项时,
39 | // template 中的 title 标签需要是 <%= htmlWebpackPlugin.options.title %>
40 | title: 'demo',
41 | // 在这个页面中包含的块,默认情况下会包含
42 | // 提取出来的通用 chunk 和 vendor chunk。
43 | chunks: ['chunk-vendors', 'chunk-common', 'index'],
44 | },
45 | };
46 |
47 | module.exports = {
48 | pages,
49 | publicPath: './',
50 | chainWebpack,
51 | devServer,
52 | // lintOnSave: false,
53 | runtimeCompiler: true,
54 | productionSourceMap: false,
55 | };
56 |
--------------------------------------------------------------------------------