diff --git a/README.md b/README.md index 2f0a8ed0..97944a8b 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,6 @@ rrweb 主要由 3 部分组成: - 处理跨域请求错误 - 转移至 web worker 中执行 - 实现传输数据压缩 - - 移除 inline script - 验证移动端录制效果 - rrweb-player - 改进播放器 UI 样式 @@ -37,6 +36,7 @@ rrweb 主要由 3 部分组成: - [序列化](./docs/serialization.md) - [增量快照](./docs/observer.md) - [回放](./docs/replay.md) +- [沙盒](./docs/sandbox.md) ## Contribute Guide diff --git a/docs/sandbox.md b/docs/sandbox.md new file mode 100644 index 00000000..8b8ce4a7 --- /dev/null +++ b/docs/sandbox.md @@ -0,0 +1,45 @@ +# 沙盒 + +在[序列化设计](./serialization.md)中我们提到了“去脚本化”的处理,即在回放时我们不应该执行被录制页面中的 JavaScript,在重建快照的过程中我们将所有 `script` 标签改写为 `noscript` 标签解决了部分问题。但仍有一些脚本化的行为是不包含在 `script` 标签中的,例如 HTML 中的 inline script、表单提交等。 + +脚本化的行为多种多样,如果仅过滤已知场景难免有所疏漏,而一旦有脚本被执行就可能造成不可逆的非预期结果。因此我们通过 HTML 提供的 iframe 沙盒功能进行浏览器层面的限制。 + +## iframe sandbox + +我们在重建快照时将被录制的 DOM 重建在一个 `iframe` 元素中,通过设置它的 `sandbox` 属性,我们可以禁止以下行为: + +- 表单提交 +- `window.open` 等弹出窗 +- JS 脚本(包含 inline event handler 和 `` ) + +这与我们的预期是相符的,尤其是对 JS 脚本的处理相比自行实现会更加安全、可靠。 + +## 避免链接跳转 + +当点击 a 元素链接时默认事件为跳转至它的 href 属性对应的 URL。在重放时我们会通过重建跳转后页面 DOM 的方式保证视觉上的正确重放,而原本的跳转则应该被禁止执行。 + +通常我们会通过事件代理捕获所有的 a 元素点击事件,再通过 `event.preventDefault()` 禁用默认事件。但当我们将回放页面放在沙盒内时,所有的 event handler 都将不被执行,我们也就无法实现事件代理。 + +重新查看我们回放交互事件增量快照的实现,我们会发现其实 `click` 事件是可以不被重放的。因为在禁用 JS 的情况下点击行为并不会产生视觉上的影响,也无需被感知。 + +不过为了优化回放的效果,我们可以在点击时给模拟的鼠标元素添加特殊的动画效果,用来提示观看者此处发生了一次点击。 + +## iframe 样式设置 + +由于我们将 DOM 重建在 iframe 中,所以我们无法通过父页面的 CSS 样式表影响 iframe 中的元素。但是在不允许 JS 脚本执行的情况下 `noscript` 标签会被显示,而我们希望将其隐藏,就需要动态的向 iframe 中添加样式,示例代码如下: + +```typescript +const injectStyleRules: string[] = [ + 'iframe { background: #f1f3f5 }', + 'noscript { display: none !important; }', +]; + +const styleEl = document.createElement('style'); +const { documentElement, head } = this.iframe.contentDocument!; +documentElement!.insertBefore(styleEl, head); +for (let idx = 0; idx < injectStyleRules.length; idx++) { + (styleEl.sheet! as CSSStyleSheet).insertRule(injectStyleRules[idx], idx); +} +``` + +需要注意的是这个插入的 style 元素在被录制页面中并不存在,所以我们不能将其序列化,否则 `id -> Node` 的映射将出现错误。 \ No newline at end of file diff --git a/package.json b/package.json index 17c93891..04cff323 100644 --- a/package.json +++ b/package.json @@ -53,8 +53,7 @@ "typescript": "^3.1.6" }, "dependencies": { - "delegated-events": "git+https://git@github.com/rrweb-io/delegated-events.git", "mitt": "^1.1.3", - "rrweb-snapshot": "^0.6.6" + "rrweb-snapshot": "^0.6.8" } } diff --git a/src/replay/index.ts b/src/replay/index.ts index ed5d695d..ee4e0b38 100644 --- a/src/replay/index.ts +++ b/src/replay/index.ts @@ -1,6 +1,5 @@ import { rebuild, buildNodeWithSN } from 'rrweb-snapshot'; import * as mittProxy from 'mitt'; -import { on, off } from 'delegated-events'; import Timer from './timer'; import { EventType, @@ -18,6 +17,7 @@ import { actionWithDelay, } from '../types'; import { mirror } from '../utils'; +import injectStyleRules from './styles/inject-style'; import './styles/style.css'; // https://github.com/rollup/rollup/issues/1267#issuecomment-296395734 @@ -138,6 +138,7 @@ export class Replayer { this.wrapper.appendChild(this.mouse); this.iframe = document.createElement('iframe'); + this.iframe.setAttribute('sandbox', 'allow-same-origin'); this.wrapper.appendChild(this.iframe); } @@ -208,21 +209,13 @@ export class Replayer { event: fullSnapshotEvent & { timestamp: number }, ) { mirror.map = rebuild(event.data.node, this.iframe.contentDocument!)[1]; + const styleEl = document.createElement('style'); + const { documentElement, head } = this.iframe.contentDocument!; + documentElement!.insertBefore(styleEl, head); + for (let idx = 0; idx < injectStyleRules.length; idx++) { + (styleEl.sheet! as CSSStyleSheet).insertRule(injectStyleRules[idx], idx); + } this.waitForStylesheetLoad(); - // avoid form submit to refresh the iframe - off('submit', 'form', this.preventDefault, { - document: this.iframe.contentDocument!, - }); - on('submit', 'form', this.preventDefault, { - document: this.iframe.contentDocument!, - }); - // avoid a link click to refresh the iframe - off('click', 'a', this.preventDefault, { - document: this.iframe.contentDocument!, - }); - on('click', 'a', this.preventDefault, { - document: this.iframe.contentDocument!, - }); } /** @@ -262,10 +255,6 @@ export class Replayer { } } - private preventDefault(evt: Event) { - evt.preventDefault(); - } - private applyIncremental(d: incrementalData, isSync: boolean) { switch (d.source) { case IncrementalSource.Mutation: { @@ -377,13 +366,24 @@ export class Replayer { } const event = new Event(MouseInteractions[d.type].toLowerCase()); const target = (mirror.getNode(d.id) as Node) as HTMLElement; - target.dispatchEvent(event); if (d.type === MouseInteractions.Blur) { target.blur(); } else if (d.type === MouseInteractions.Click) { - target.click(); + /** + * Click has no visual impact when replaying and may + * trigger navigation when apply to an link. + * So we will not call click(), instead we add an + * animation to the mouse element which indicate user + * clicked at this moment. + */ + this.mouse.classList.remove('active'); + // tslint:disable-next-line + void this.mouse.offsetWidth; + this.mouse.classList.add('active'); } else if (d.type === MouseInteractions.Focus) { target.focus(); + } else { + target.dispatchEvent(event); } break; } diff --git a/src/replay/styles/inject-style.ts b/src/replay/styles/inject-style.ts new file mode 100644 index 00000000..ff311580 --- /dev/null +++ b/src/replay/styles/inject-style.ts @@ -0,0 +1,6 @@ +const rules: string[] = [ + 'iframe { background: #ccc }', + 'noscript { display: none !important; }', +]; + +export default rules; diff --git a/src/replay/styles/style.css b/src/replay/styles/style.css index fef00b0a..02d0e778 100644 --- a/src/replay/styles/style.css +++ b/src/replay/styles/style.css @@ -3,8 +3,12 @@ } .replayer-mouse { position: absolute; - width: 1px; - height: 1px; + width: 20px; + height: 20px; + background-size: contain; + background-position: center center; + background-repeat: no-repeat; + background-image: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9JzMwMHB4JyB3aWR0aD0nMzAwcHgnICBmaWxsPSIjMDAwMDAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGRhdGEtbmFtZT0iTGF5ZXIgMSIgdmlld0JveD0iMCAwIDUwIDUwIiB4PSIwcHgiIHk9IjBweCI+PHRpdGxlPkRlc2lnbl90bnA8L3RpdGxlPjxwYXRoIGQ9Ik00OC43MSw0Mi45MUwzNC4wOCwyOC4yOSw0NC4zMywxOEExLDEsMCwwLDAsNDQsMTYuMzlMMi4zNSwxLjA2QTEsMSwwLDAsMCwxLjA2LDIuMzVMMTYuMzksNDRhMSwxLDAsMCwwLDEuNjUuMzZMMjguMjksMzQuMDgsNDIuOTEsNDguNzFhMSwxLDAsMCwwLDEuNDEsMGw0LjM4LTQuMzhBMSwxLDAsMCwwLDQ4LjcxLDQyLjkxWm0tNS4wOSwzLjY3TDI5LDMyYTEsMSwwLDAsMC0xLjQxLDBsLTkuODUsOS44NUwzLjY5LDMuNjlsMzguMTIsMTRMMzIsMjcuNThBMSwxLDAsMCwwLDMyLDI5TDQ2LjU5LDQzLjYyWiI+PC9wYXRoPjwvc3ZnPg=='); } .replayer-mouse::after { content: ''; @@ -12,7 +16,27 @@ width: 20px; height: 20px; border-radius: 10px; - background: thistle; + background: rgb(73, 80, 246); transform: translate(-10px, -10px); - opacity: 0.5; + opacity: 0.3; +} +.replayer-mouse.active::after { + animation: click 0.3s ease-in-out 1; +} + +@keyframes click { + 0% { + opacity: 0.3; + width: 20px; + height: 20px; + border-radius: 10px; + transform: translate(-10px, -10px); + } + 50% { + opacity: 0.5; + width: 10px; + height: 10px; + border-radius: 5px; + transform: translate(-5px, -5px); + } }