Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: homework 2 versions #11

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[![Node.js CI](https://github.com/icebox1234-FE-course-2022/homework9/actions/workflows/node.js.yml/badge.svg)](https://github.com/icebox1234-FE-course-2022/homework9/actions/workflows/node.js.yml)
### 步骤

* Fork 代码仓库并拉到本地
Expand Down
5 changes: 2 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,22 @@
"test": "jest --config jest.config.js",
"lint": "vue-cli-service lint"
},
"dependencies": {
"dependencies": {
"core-js": "^3.8.3",
"tapable": "^2.2.1",
"vue": "^3.2.13"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-unit-jest": "^5.0.8",
"@vue/cli-service": "~5.0.0",
"@vue/test-utils": "^2.0.2",
"@vue/vue3-jest": "^27.0.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"@vue/cli-plugin-unit-jest": "^5.0.8",
"@vue/vue3-jest": "^27.0.0"
"eslint-plugin-vue": "^8.0.3"
},
"eslintConfig": {
"root": true,
Expand Down
24 changes: 10 additions & 14 deletions src/components/HelloWorld.vue
Original file line number Diff line number Diff line change
@@ -1,28 +1,24 @@
<template>
<div class="hello" data-spma="aa">
<span>show spm:{{spmText}}</span>
<span>show spm:<span id="spmText">{{ spmText }}</span></span>
<div data-spmb="bb">
<button data-spmc="cc">Click it</button>
<button data-spmc="cc" class="buttonA">Click it a</button>
</div>
<div data-spmb="dd">
<button data-spmc="ff">Click it</button>
<button data-spmc="ff" class="buttonB">Click it b</button>
</div>
<div data-spmb="ee" data-spm-expose="true" class="exposeE">
expose
</div>
</div>
</template>

<script>
// TODO 利用事件代理实现一个简单的收集spm信息的方法,注意不是针对每一个按钮进行函数绑定。场景:考虑一下如果一个页面中有很多按钮,需要如何处理
export default {
name: 'HelloWorld',
data: ()=>{
return {
spmText: 'xx.xx.xx'
}
}
}
<script setup>
/* eslint-disable */
import { useSpm } from '../hooks/useSpm';
const { spmText } = useSpm();
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {
margin: 40px 0 0;
Expand Down
18 changes: 18 additions & 0 deletions src/hooks/useSpm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* eslint-disable */
import { onMounted, ref } from 'vue';
import { Spm } from '../lib/spm';
import { Spm_V2 } from '../lib/spm-v2';

export function useSpm() {
const spmText = ref('');
onMounted(() => {
const spm = new Spm();
spm.hooks.click.tap('spmTextPlugin', (reportInfo) => {
spmText.value = reportInfo.spmId || '';
});
spm.hooks.expose.tap('spmTextPlugin', (reportInfo) => {
spmText.value = reportInfo.spmId || '';
})
});
return { spmText };
}
98 changes: 98 additions & 0 deletions src/lib/spm-v2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/* eslint-disable */
import { SyncHook } from 'tapable';
const SPM_LOCATION = ['spma', 'spmb', 'spmc', 'spmd'];
export const EVENT_TYPE = { click: 'click', expose: 'expose' };

export class Spm_V2 {
constructor() {
this.hooks = {
click: new SyncHook(['reportInfo']),
expose: new SyncHook(['reportInfo'])
};
document.addEventListener('click', this.clickHandler.bind(this));
window.addEventListener('beforeunload', () => {
this.io.disconnect();
document.removeEventListener('click', this.clickHandler);
});
this.initExpose();
}
clickHandler(event) {
const element = event.target;
if (this.isValidSpmNode(element)) {
const spmId = this.generateSpmId(element);
this.hooks.click.call({ spmId });
}
}
initExpose() {
this.io = new IntersectionObserver(this.exposeHandler.bind(this), { root: document.body })
const exposeEleArr = Array.from(document.querySelectorAll('[data-spm-expose]'));
exposeEleArr.forEach(ele => {
this.io.observe(ele);
});
}
exposeHandler(entries = []) {
entries.forEach(entry => {
const element = entry.target;
if (this.isValidSpmNode(element)) {
const spmId = this.generateSpmId(element);
this.hooks.expose.call({ spmId });
}
})
}
generateSpmId(element) {
let ids = Array.from({ length: 4 });
let curEle = element;
const isEmpty = (obj) => {
return Object.keys(obj).length === 0;
}
const findSpmPath = (spmLocationIndex) => {
if (spmLocationIndex < 0) {
return;
}
while (curEle !== document.body.parentElement) {
const spmInfo = this.filterSpmInfo(curEle);
if (!isEmpty(spmInfo)) {
break;
}
curEle = curEle.parentElement;
}
if (curEle === document.body.parentElement) {
return;
}
const spm = this.filterSpmInfo(curEle);
const spmLocation = spm[SPM_LOCATION[spmLocationIndex]];
if (!spmLocation) {
findSpmPath(spmLocationIndex - 1);
} else {
ids[spmLocationIndex] = spmLocation;
curEle = curEle.parentElement;
findSpmPath(spmLocationIndex - 1);
}
}
findSpmPath(3);
return this.isValidSpmInfo(ids) ? ids.filter(item => item).join('.') : '';
}
isValidSpmNode(element) {
const { spmc, spmd, spmExpose } = this.filterSpmInfo(element);
return spmd || spmc || spmExpose;
}
isValidSpmInfo(ids = []) {
if (ids.length <= 0) {
return false;
}
if (!ids[0] || !ids[1]) {
return false;
}
return true;
}
filterSpmInfo(element) {
const spmInfo = {};
Object.keys(element.dataset).filter(item => {
return item.indexOf('spm') !== -1;
}).forEach(item => {
spmInfo[item] = element.dataset[item];
});
return spmInfo;
}

}
137 changes: 137 additions & 0 deletions src/lib/spm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/* eslint-disable */
import { SyncHook } from 'tapable';
const SPM_LOCATION = ['spma', 'spmb', 'spmc', 'spmd'];
export const EVENT_TYPE = { click: 'click', expose: 'expose' };
export class Spm {
constructor(defaultEventType = EVENT_TYPE.click) {
this.hooks = {
click: new SyncHook(['reportInfo']),
expose: new SyncHook(['reportInfo'])
};
this.spmNodeTree = [];
this.defaultEventType = defaultEventType;
this.initSpmTree();
window.addEventListener('beforeunload', () => {
this.destorySpm(this.spmNodeTree);
});
}
/**
* @description 筛选需要埋点的dom并构造树结构
*/
initSpmTree() {
this.spmNodeTree = this.generateSpmNodes(document, 0, null);
}
/**
* @param {HTMLElement} range 搜索范围
* @param {number} spmLocationIndex abcd位指针
* @param {any} parent 父节点
* @returns 当前层次需要埋点上报的dom信息
*/
generateSpmNodes(range, spmLocationIndex, parent) {
const layerNodes = Array.from(
range.querySelectorAll(`[data-${SPM_LOCATION[spmLocationIndex]}]`
) || [])
.map(node => {
let nodeInfo = {};
Object.keys(node.dataset).filter(field => {
return field.indexOf('spm') !== -1;
}).forEach(item => {
if (node.dataset.hasOwnProperty(item)) {
nodeInfo[item] = node.dataset[item];
}
})
nodeInfo.spmId = `${parent ? (parent.nodeInfo.spmId + '.') : ''}${nodeInfo[`${SPM_LOCATION[spmLocationIndex]}`]}`;
const spmInfo = {
nodeInfo,
node,
children: [],
parent,
};
return spmInfo;
});
for (const layerNode of layerNodes) {
layerNode.children = this.generateSpmNodes(layerNode.node, spmLocationIndex + 1, layerNode);
layerNode.eventsToReport = this.setEventReporter(layerNode.node, layerNode);
}
return layerNodes;
}
/**
*
* @param {HTMLElement} element 设置事件响应的dom
* @param {any} spmInfo 对应dom中的spm信息
*/
setEventReporter(element, spmInfo) {
const { spmClick, spmExpose } = spmInfo.nodeInfo || {};
const clickEventHandler = () => {
this.handleClick(spmInfo.nodeInfo);
};
const setClickEventReporter = () => {
element.addEventListener('click', clickEventHandler);
return {
[EVENT_TYPE.click]: clickEventHandler
};
}
const exposeEventHandler = () => {
this.handleExpose(spmInfo.nodeInfo);
}
const setExposeEventReporter = () => {
// TODO 处理的有点难看,应该只用一个IntersectionObserver就可以了
const io = new IntersectionObserver(exposeEventHandler, { root: document.body });
io.observe(element);
return {
[EVENT_TYPE.expose]: io
}
}
const eventObj = {};
if ((!spmClick && !spmExpose) && spmInfo.children.length <= 0) {
if (this.defaultEventType === EVENT_TYPE.click) {
return setClickEventReporter();
}
if (this.defaultEventType === EVENT_TYPE.expose) {
return setExposeEventReporter();
}
}
if (spmExpose) {
eventObj[EVENT_TYPE.expose] = setExposeEventReporter();
}
if (spmClick && spmInfo.children.length <= 0) {
eventObj[EVENT_TYPE.click] = setClickEventReporter();
}
return eventObj;
}

unsetEventReporter(layerNode) {
const { node = null, eventsToReport = {} } = layerNode;
if (!node) {
return;
}
Object.keys(eventsToReport).forEach(eventName => {
if (eventName === EVENT_TYPE.click) {
element.removeEventListener(eventName, eventsToReport[eventName]);
}
if (eventName === EVENT_TYPE.expose) {
eventsToReport[eventName].disconnect();
}
})
}

destorySpm(spmNodeTree = []) {
for (const node of spmNodeTree) {
if (node.children.length <= 0) {
this.unsetEventReporter(node);
} else {
this.destorySpm(node.children || []);
}
}
}

handleClick(nodeInfo) {
const reportInfo = { spmId: nodeInfo.spmId };
this.hooks.click.call(reportInfo);
}

handleExpose(nodeInfo) {
const reportInfo = { spmId: nodeInfo.spmId };
this.hooks.expose.call(reportInfo);
};
}
32 changes: 25 additions & 7 deletions tests/unit/example.spec.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { shallowMount, mount } from '@vue/test-utils'
import { shallowMount } from '@vue/test-utils'
import HelloWorld from '@/components/HelloWorld.vue';
import { warn } from 'vue';

jest.setTimeout(10000)
const observe = jest.fn((ele) => { return ele.dataset });
const unobserve = jest.fn();
window.IntersectionObserver = jest.fn((callback, options) => {
window.exposeCallback = callback;
return {
observe,
unobserve,
};
})

describe('HelloWorld.vue', () => {
it('校验第一个按钮的spm是否为aa.bb.cc', async () => {
const wrapper = shallowMount(HelloWorld, { attachTo: document.body });
Expand All @@ -14,8 +22,18 @@ describe('HelloWorld.vue', () => {
describe('HelloWorld.vue', () => {
it('校验第一个按钮的spm是否为aa.dd.ff', async () => {
const wrapper = shallowMount(HelloWorld, { attachTo: document.body })
const button = wrapper.findAll('button')
await button[1].trigger('click')
expect(wrapper.vm.spmText).toMatch('aa.dd.ff')
const button = wrapper.findAll('button');
await button[1].trigger('click');
expect(wrapper.vm.spmText).toMatch('aa.dd.ff');
})
})
})

describe('HelloWorld.vue', () => {
it('粗糙的模拟一下曝光', () => {
const wrapper = shallowMount(HelloWorld, { attachTo: document.body });
expect(observe).toReturnWith(expect.objectContaining({ spmExpose: 'true', spmb: 'ee' }));
const exposeEle = wrapper.find('.exposeE');
window.exposeCallback([{ target: exposeEle.wrapperElement }]);
expect(wrapper.vm.spmText).toMatch('aa.ee');
})
})
Loading