简历描述模板
项目经验描述(直接可用)
社交分享系统设计与实现
2024.06 - 2024.09
- 独立设计并实现了多平台社交分享功能,支持微信、微博、QQ等8+主流平台,分享成功率达98%
- 搭建了可复用的分享组件库,通过插件化设计实现一次配置全局使用,减少70%重复代码
- 解决了移动端微信分享签名失效的技术难题,通过单页应用URL管理和签名缓存机制,将签名成功率从65%提升至95%
- 实现了分享数据埋点系统,对接后端接口追踪分享转化漏斗,为产品迭代提供数据支持
- 通过Canvas动态海报生成技术,实现个性化分享图片,图片生成速度优化至500ms以内
SOP标准回答话术
面试官:"你做过分享功能吗?怎么实现的?"
回答模板
"做过,我负责从0到1设计了整个分享系统。这个需求其实挺有挑战的,因为不同平台的分享方式完全不一样。
我的设计思路是这样的
首先做了技术调研,发现移动端和PC端完全是两套逻辑。PC端可以直接用Web Share API或者唤起链接,但移动端特别是微信里面,必须走JSSDK,而且有很多坑。
然后我搭建了一个三层架构:
- 适配器层 - 封装各个平台的API差异
- 策略层 - 根据环境自动选择分享方式
- 组件层 - 提供统一的调用接口
遇到的最大难点是微信分享签名问题。单页应用切换路由后,签名会失效。我的解决方案是维护一个URL管理器,记录每次签名时的URL,当路由变化时判断是否需要重新签名,同时做了签名缓存,避免频繁请求。这个优化让签名成功率从65%提到了95%。
另外还做了降级方案,如果JSSDK加载失败,会自动fallback到复制链接或者生成二维码,保证用户一定能分享出去。
最后加了完整的埋点系统,追踪分享按钮点击、分享成功、分享后回流等关键节点,给产品提供了很好的数据支持。"
难点与亮点分析
难点1: 微信JSSDK签名失效问题
问题描述
单页应用(SPA)中,页面路由变化后微信JSSDK签名失效,导致分享功能不可用
技术原因
- 微信签名基于当前页面URL生成
- SPA路由切换不刷新页面,但URL会改变
- 签名有时效性(2小时),需要合理管理
解决方案
- URL规范化管理 - 统一处理hash和query参数
- 签名缓存机制 - 避免重复请求
- 自动重签逻辑 - 监听路由变化智能重签
难点2: 多平台分享参数不一致
问题描述
不同平台需要的参数格式、字段名、限制都不同
解决方案
适配器模式 + 统一数据模型
难点3: Canvas生成海报性能优化
问题描述
海报生成耗时长,包含网络图片加载、Canvas渲染
解决方案
- 图片预加载池
- 离屏Canvas渲染
- Web Worker异步处理
技术实现代码
1. 核心架构设计
src/share/
├── core/
│ ├── ShareManager.js # 分享管理器
│ ├── WechatAdapter.js # 微信适配器
│ └── NativeAdapter.js # 原生分享适配器
├── composables/
│ └── useShare.js # 组合式API
├── components/
│ ├── ShareButton.vue # 分享按钮组件
│ └── SharePanel.vue # 分享面板组件
└── utils/
├── poster.js # 海报生成工具
└── tracker.js # 埋点工具
2. 分享管理器 (ShareManager.js)
// src/share/core/ShareManager.js
class ShareManager {
constructor() {
this.adapters = new Map()
this.currentAdapter = null
this.config = {
title: '',
desc: '',
link: '',
imgUrl: '',
}
}
// 注册适配器
registerAdapter(name, adapter) {
this.adapters.set(name, adapter)
}
// 自动选择适配器
async init() {
const isWechat = /MicroMessenger/i.test(navigator.userAgent)
const hasNativeShare = navigator.share
if (isWechat) {
this.currentAdapter = this.adapters.get('wechat')
await this.currentAdapter?.init()
} else if (hasNativeShare) {
this.currentAdapter = this.adapters.get('native')
} else {
this.currentAdapter = this.adapters.get('fallback')
}
return this.currentAdapter
}
// 统一分享方法
async share(options) {
if (!this.currentAdapter) {
await this.init()
}
const shareData = {
...this.config,
...options,
}
try {
const result = await this.currentAdapter.share(shareData)
// 埋点上报
this.track('share_success', shareData)
return result
} catch (error) {
this.track('share_fail', { ...shareData, error: error.message })
throw error
}
}
// 设置全局配置
setConfig(config) {
this.config = { ...this.config, ...config }
}
// 埋点方法
track(event, data) {
// 实际项目对接埋点SDK
console.log('埋点:', event, data)
// 示例:发送到后端
if (window._tracker) {
window._tracker.track(event, data)
}
}
}
export default new ShareManager()
3. 微信适配器 (WechatAdapter.js)
这是核心难点所在
// src/share/core/WechatAdapter.js
class WechatAdapter {
constructor() {
this.isReady = false
this.signUrl = '' // 记录签名时的URL
this.signCache = null // 签名缓存
this.cacheTime = 0
this.CACHE_DURATION = 7000 * 1000 // 签名缓存2小时
}
// 初始化微信JSSDK
async init() {
// 加载JSSDK脚本
await this.loadScript('https://res.wx.qq.com/open/js/jweixin-1.6.0.js')
// 获取签名并配置
await this.configWxSDK()
// 监听路由变化,自动重新签名
this.watchRoute()
}
// 动态加载脚本
loadScript(src) {
return new Promise((resolve, reject) => {
if (window.wx) {
resolve()
return
}
const script = document.createElement('script')
script.src = src
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
})
}
// 配置微信SDK
async configWxSDK() {
const currentUrl = this.getNormalizeUrl()
// 检查缓存是否有效
if (this.isCacheValid(currentUrl)) {
console.log('使用缓存签名')
this.applyConfig(this.signCache)
return
}
try {
// 请求后端获取签名
const signature = await this.getSignature(currentUrl)
// 缓存签名信息
this.signCache = signature
this.signUrl = currentUrl
this.cacheTime = Date.now()
// 应用配置
this.applyConfig(signature)
} catch (error) {
console.error('微信签名失败:', error)
throw error
}
}
// 获取规范化URL
getNormalizeUrl() {
// 关键:iOS使用第一次进入的URL,Android使用当前URL
const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent)
if (isIOS && window.__wxFirstUrl__) {
return window.__wxFirstUrl__
}
const url = window.location.href.split('#')[0]
// 记录iOS首次URL
if (isIOS && !window.__wxFirstUrl__) {
window.__wxFirstUrl__ = url
}
return url
}
// 检查缓存是否有效
isCacheValid(currentUrl) {
if (!this.signCache) return false
const isExpired = Date.now() - this.cacheTime > this.CACHE_DURATION
const isUrlChanged = this.signUrl !== currentUrl
return !isExpired && !isUrlChanged
}
// 请求签名
async getSignature(url) {
const response = await fetch('/api/wechat/signature', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
})
if (!response.ok) {
throw new Error('获取签名失败')
}
return response.json()
}
// 应用微信配置
applyConfig(signature) {
return new Promise((resolve, reject) => {
window.wx.config({
debug: false,
appId: signature.appId,
timestamp: signature.timestamp,
nonceStr: signature.nonceStr,
signature: signature.signature,
jsApiList: [
'updateAppMessageShareData',
'updateTimelineShareData',
'onMenuShareAppMessage', // 兼容旧版本
'onMenuShareTimeline',
],
})
window.wx.ready(() => {
this.isReady = true
console.log('微信SDK配置成功')
resolve()
})
window.wx.error((err) => {
console.error('微信SDK配置失败:', err)
reject(err)
})
})
}
// 监听路由变化
watchRoute() {
let lastUrl = window.location.href
// 监听 popstate (浏览器前进后退)
window.addEventListener('popstate', () => {
this.handleRouteChange()
})
// 监听 pushState 和 replaceState
const originalPushState = history.pushState
const originalReplaceState = history.replaceState
history.pushState = (...args) => {
originalPushState.apply(history, args)
this.handleRouteChange()
}
history.replaceState = (...args) => {
originalReplaceState.apply(history, args)
this.handleRouteChange()
}
// 兼容hash路由
window.addEventListener('hashchange', () => {
const currentUrl = window.location.href
if (currentUrl !== lastUrl) {
lastUrl = currentUrl
this.handleRouteChange()
}
})
}
// 处理路由变化
async handleRouteChange() {
const currentUrl = this.getNormalizeUrl()
// 如果URL变化且缓存失效,重新签名
if (this.signUrl !== currentUrl && !this.isCacheValid(currentUrl)) {
console.log('路由变化,重新签名')
await this.configWxSDK()
}
}
// 分享方法
async share(options) {
if (!this.isReady) {
await this.init()
}
const { title, desc, link, imgUrl } = options
return new Promise((resolve, reject) => {
// 分享给朋友
window.wx.updateAppMessageShareData({
title,
desc,
link,
imgUrl,
success: () => {
console.log('分享给朋友配置成功')
resolve({ platform: 'wechat_friend' })
},
fail: reject,
})
// 分享到朋友圈
window.wx.updateTimelineShareData({
title,
link,
imgUrl,
success: () => {
console.log('分享到朋友圈配置成功')
resolve({ platform: 'wechat_timeline' })
},
fail: reject,
})
// 兼容旧版本API
window.wx.onMenuShareAppMessage({
title,
desc,
link,
imgUrl,
success: () => resolve({ platform: 'wechat_friend' }),
})
window.wx.onMenuShareTimeline({
title,
link,
imgUrl,
success: () => resolve({ platform: 'wechat_timeline' }),
})
})
}
}
export default WechatAdapter
4. 原生分享适配器 (NativeAdapter.js)
// src/share/core/NativeAdapter.js
class NativeAdapter {
async share(options) {
const { title, desc, link } = options
if (!navigator.share) {
throw new Error('浏览器不支持原生分享')
}
try {
await navigator.share({
title: title,
text: desc,
url: link,
})
return { platform: 'native', success: true }
} catch (error) {
if (error.name === 'AbortError') {
// 用户取消分享
return { platform: 'native', cancelled: true }
}
throw error
}
}
}
export default NativeAdapter
5. 降级方案适配器 (FallbackAdapter.js)
// src/share/core/FallbackAdapter.js
class FallbackAdapter {
async share(options) {
const { link } = options
// 复制链接到剪贴板
try {
await navigator.clipboard.writeText(link)
// 提示用户
this.showToast('链接已复制,快去分享吧!')
return { platform: 'fallback', method: 'clipboard' }
} catch (error) {
// 如果复制失败,显示二维码
this.showQRCode(link)
return { platform: 'fallback', method: 'qrcode' }
}
}
showToast(message) {
const toast = document.createElement('div')
toast.className = 'share-toast'
toast.textContent = message
toast.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 12px 24px;
border-radius: 8px;
z-index: 9999;
font-size: 14px;
`
document.body.appendChild(toast)
setTimeout(() => {
toast.remove()
}, 2000)
}
showQRCode(url) {
// 实际项目可以用 qrcode.js 库
console.log('生成二维码:', url)
alert(`请手动复制链接分享:\n${url}`)
}
}
export default FallbackAdapter
6. Vue3 组合式 API (useShare.js)
// src/share/composables/useShare.js
import { ref, onMounted } from 'vue'
import ShareManager from '../core/ShareManager'
import WechatAdapter from '../core/WechatAdapter'
import NativeAdapter from '../core/NativeAdapter'
import FallbackAdapter from '../core/FallbackAdapter'
export function useShare(options = {}) {
const isLoading = ref(false)
const isReady = ref(false)
const error = ref(null)
// 初始化分享管理器
const initShare = async () => {
try {
isLoading.value = true
// 注册所有适配器
ShareManager.registerAdapter('wechat', new WechatAdapter())
ShareManager.registerAdapter('native', new NativeAdapter())
ShareManager.registerAdapter('fallback', new FallbackAdapter())
// 设置全局配置
if (options.config) {
ShareManager.setConfig(options.config)
}
// 初始化
await ShareManager.init()
isReady.value = true
} catch (err) {
error.value = err
console.error('分享初始化失败:', err)
} finally {
isLoading.value = false
}
}
// 执行分享
const share = async (shareData) => {
try {
isLoading.value = true
error.value = null
const result = await ShareManager.share(shareData)
return result
} catch (err) {
error.value = err
console.error('分享失败:', err)
throw err
} finally {
isLoading.value = false
}
}
// 自动初始化
onMounted(() => {
if (options.autoInit !== false) {
initShare()
}
})
return {
isLoading,
isReady,
error,
share,
initShare,
}
}
7. 分享按钮组件 (ShareButton.vue)
<script setup>
import { ref } from 'vue'
import { useShare } from '../composables/useShare'
const props = defineProps({
title: {
type: String,
default: '分享标题',
},
desc: {
type: String,
default: '分享描述',
},
link: {
type: String,
default: () => window.location.href,
},
imgUrl: {
type: String,
default: '',
},
})
const emit = defineEmits(['success', 'fail'])
const { share, isLoading } = useShare({
autoInit: true,
config: {
imgUrl: props.imgUrl || 'https://example.com/default-share.jpg',
},
})
const showToast = ref(false)
const toastMessage = ref('')
const handleShare = async () => {
try {
const result = await share({
title: props.title,
desc: props.desc,
link: props.link,
imgUrl: props.imgUrl,
})
toastMessage.value = '分享设置成功,请点击右上角分享'
showToast.value = true
setTimeout(() => {
showToast.value = false
}, 2000)
emit('success', result)
} catch (error) {
toastMessage.value = '分享失败,请重试'
showToast.value = true
setTimeout(() => {
showToast.value = false
}, 2000)
emit('fail', error)
}
}
</script>
<template>
<div class="share-button-wrapper">
<button class="share-button" :disabled="isLoading" @click="handleShare">
<span v-if="isLoading">分享中...</span>
<span v-else>
<slot>分享</slot>
</span>
</button>
<div v-if="showToast" class="toast">
{{ toastMessage }}
</div>
</div>
</template>
<style scoped>
.share-button-wrapper {
position: relative;
}
.share-button {
padding: 10px 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 25px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.share-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
.share-button:active {
transform: translateY(0);
}
.share-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.75);
color: white;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
z-index: 9999;
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.9);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
</style>
8. 分享面板组件 (SharePanel.vue)
<script setup>
import { ref, computed } from 'vue'
import { useShare } from '../composables/useShare'
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
shareData: {
type: Object,
default: () => ({}),
},
})
const emit = defineEmits(['update:visible', 'success'])
const { share } = useShare({ autoInit: true })
const platforms = ref([
{ name: '微信', icon: '💬', type: 'wechat' },
{ name: '朋友圈', icon: '🔗', type: 'timeline' },
{ name: '微博', icon: '📱', type: 'weibo' },
{ name: 'QQ', icon: '🐧', type: 'qq' },
{ name: '复制链接', icon: '📋', type: 'copy' },
])
const handlePlatformClick = async (platform) => {
if (platform.type === 'copy') {
await copyLink()
return
}
try {
await share(props.shareData)
emit('success', platform)
emit('update:visible', false)
} catch (error) {
console.error('分享失败:', error)
}
}
const copyLink = async () => {
try {
await navigator.clipboard.writeText(props.shareData.link)
alert('链接已复制')
emit('update:visible', false)
} catch (error) {
console.error('复制失败:', error)
}
}
const handleClose = () => {
emit('update:visible', false)
}
</script>
<template>
<div v-if="visible" class="share-panel-mask" @click="handleClose">
<div class="share-panel" @click.stop>
<div class="panel-header">
<h3>分享到</h3>
<button class="close-btn" @click="handleClose">✕</button>
</div>
<div class="platform-list">
<div
v-for="platform in platforms"
:key="platform.type"
class="platform-item"
@click="handlePlatformClick(platform)"
>
<div class="platform-icon">{{ platform.icon }}</div>
<div class="platform-name">{{ platform.name }}</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.share-panel-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
z-index: 9999;
animation: fadeIn 0.3s;
}
.share-panel {
width: 100%;
background: white;
border-radius: 16px 16px 0 0;
padding: 20px;
animation: slideUp 0.3s;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.panel-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
color: #999;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.platform-list {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
padding: 10px 0;
}
.platform-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 10px;
border-radius: 8px;
transition: background 0.2s;
}
.platform-item:active {
background: #f5f5f5;
}
.platform-icon {
font-size: 36px;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
background: #f0f0f0;
border-radius: 12px;
}
.platform-name {
font-size: 12px;
color: #666;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
</style>
9. Canvas海报生成工具 (poster.js)
javascript
// src/share/utils/poster.js
class PosterGenerator {
constructor(options = {}) {
this.canvas = null
this.ctx = null
this.imageCache = new Map()
this.options = {
width: 750,
height: 1334,
quality: 0.9,
...options,
}
}
// 创建海报
async generate(config) {
const { width, height } = this.options
// 创建离屏Canvas
this.canvas = document.createElement('canvas')
this.canvas.width = width
this.canvas.height = height
this.ctx = this.canvas.getContext('2d')
// 绘制背景
await this.drawBackground(config.background)
// 绘制头像
if (config.avatar) {
await this.drawAvatar(config.avatar)
}
// 绘制文本
if (config.title) {
this.drawText(config.title, 60, 'bold 36px Arial', '#333')
}
if (config.desc) {
this.drawText(config.desc, 120, '24px Arial', '#666')
}
// 绘制二维码
if (config.qrcode) {
await this.drawQRCode(config.qrcode)
}
// 转换为图片
return this.toImage()
}
// 绘制背景
async drawBackground(background) {
if (typeof background === 'string') {
// 图片背景
const img = await this.loadImage(background)
this.ctx.drawImage(img, 0, 0, this.canvas.width, this.canvas.height)
} else {
// 渐变背景
const gradient = this.ctx.createLinearGradient(
0,
0,
0,
this.canvas.height
)
gradient.addColorStop(0, background.start || '#667eea')
gradient.addColorStop(1, background.end || '#764ba2')
this.ctx.fillStyle = gradient
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height)
}
}
// 绘制头像
async drawAvatar(config) {
const { url, x = 40, y = 40, size = 100 } = config
const img = await this.loadImage(url)
this.ctx.save()
// 绘制圆形头像
this.ctx.beginPath()
this.ctx.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2)
this.ctx.clip()
this.ctx.drawImage(img, x, y, size, size)
this.ctx.restore()
}
// 绘制文本
drawText(text, y, font, color) {
this.ctx.font = font
this.ctx.fillStyle = color
this.ctx.textAlign = 'center'
this.ctx.fillText(text, this.canvas.width / 2, y)
}
// 绘制二维码
async drawQRCode(config) {
const { url, x, y, size = 200 } = config
const img = await this.loadImage(url)
const qrX = x || (this.canvas.width - size) / 2
const qrY = y || this.canvas.height - size - 40
this.ctx.drawImage(img, qrX, qrY, size, size)
}
// 加载图片(带缓存)
loadImage(url) {
if (this.imageCache.has(url)) {
return Promise.resolve(this.imageCache.get(url))
}
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous' // 允许跨域
img.onload = () => {
this.imageCache.set(url, img)
resolve(img)
}
img.onerror = reject
img.src = url
})
}
// 转换为图片
toImage() {
return new Promise((resolve) => {
this.canvas.toBlob(
(blob) => {
const url = URL.createObjectURL(blob)
resolve(url)
},
'image/png',
this.options.quality
)
})
}
// 清理资源
destroy() {
this.imageCache.clear()
this.canvas = null
this.ctx = null
}
}
export default PosterGenerator
10. 页面使用示例 (App.vue)
<script setup>
import { ref } from 'vue'
import ShareButton from './components/ShareButton.vue'
import SharePanel from './components/SharePanel.vue'
import PosterGenerator from './utils/poster.js'
const showPanel = ref(false)
const posterUrl = ref('')
const shareData = ref({
title: '超级棒的文章标题',
desc: '这是一篇非常有价值的文章,快来阅读吧!',
link: window.location.href,
imgUrl: 'https://example.com/share-cover.jpg',
})
// 生成海报
const generatePoster = async () => {
const generator = new PosterGenerator()
const url = await generator.generate({
background: {
start: '#667eea',
end: '#764ba2',
},
avatar: {
url: 'https://example.com/avatar.jpg',
x: 40,
y: 40,
size: 100,
},
title: shareData.value.title,
desc: shareData.value.desc,
qrcode: {
url: 'https://example.com/qrcode.jpg',
size: 200,
},
})
posterUrl.value = url
// 更新分享图片
shareData.value.imgUrl = url
generator.destroy()
}
const handleShareSuccess = (result) => {
console.log('分享成功:', result)
}
</script>
<template>
<div class="app">
<div class="content">
<h1>{{ shareData.title }}</h1>
<p>{{ shareData.desc }}</p>
<div class="action-bar">
<ShareButton v-bind="shareData" @success="handleShareSuccess">
点击分享
</ShareButton>
<button class="panel-btn" @click="showPanel = true">
打开分享面板
</button>
<button class="poster-btn" @click="generatePoster">
生成分享海报
</button>
</div>
<div v-if="posterUrl" class="poster-preview">
<h3>生成的海报:</h3>
<img :src="posterUrl" alt="分享海报" />
</div>
</div>
<SharePanel
v-model:visible="showPanel"
:share-data="shareData"
@success="handleShareSuccess"
/>
</div>
</template>
<style scoped>
.app {
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
}
.content {
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
h1 {
margin: 0 0 16px;
font-size: 28px;
color: #333;
}
p {
color: #666;
line-height: 1.6;
margin-bottom: 30px;
}
.action-bar {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.panel-btn,
.poster-btn {
padding: 10px 24px;
background: white;
border: 1px solid #ddd;
border-radius: 25px;
cursor: pointer;
transition: all 0.3s;
}
.panel-btn:hover,
.poster-btn:hover {
border-color: #667eea;
color: #667eea;
}
.poster-preview {
margin-top: 30px;
padding-top: 30px;
border-top: 1px solid #eee;
}
.poster-preview h3 {
margin-bottom: 16px;
font-size: 18px;
}
.poster-preview img {
max-width: 100%;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
</style>
埋点数据追踪方案
埋点工具 (tracker.js)
// src/share/utils/tracker.js
class ShareTracker {
constructor() {
this.sessionId = this.generateSessionId()
}
// 生成会话ID
generateSessionId() {
return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
// 上报事件
track(event, data = {}) {
const trackData = {
event,
session_id: this.sessionId,
timestamp: Date.now(),
url: window.location.href,
user_agent: navigator.userAgent,
...data,
}
// 发送到后端
this.send(trackData)
// 同时打印到控制台(开发环境)
if (process.env.NODE_ENV === 'development') {
console.log('📊 埋点:', trackData)
}
}
// 发送数据
async send(data) {
try {
// 使用 sendBeacon 保证数据发送
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/track', JSON.stringify(data))
} else {
// 降级方案
await fetch('/api/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
keepalive: true,
})
}
} catch (error) {
console.error('埋点上报失败:', error)
}
}
// 分享按钮点击
trackShareClick(platform) {
this.track('share_button_click', { platform })
}
// 分享成功
trackShareSuccess(platform, shareData) {
this.track('share_success', {
platform,
title: shareData.title,
link: shareData.link,
})
}
// 分享失败
trackShareFail(platform, error) {
this.track('share_fail', {
platform,
error: error.message,
})
}
// 分享回流(用户从分享链接进入)
trackShareCallback(shareId) {
this.track('share_callback', { share_id: shareId })
}
}
export default new ShareTracker()
面试深挖问题准备
Q1: "微信签名为什么会失效?你是怎么排查的?"
回答
"一开始我也很懵,明明配置成功了,但切换几次路由后就不行了。
我的排查过程是这样的:
- 抓包分析 - 发现签名请求的URL和当前页面URL不一致
- 查官方文档 - 发现微信要求签名必须基于当前URL
- 测试iOS和Android - 发现表现不一样,iOS用的是首次进入的URL
最后找到核心原因:SPA的路由变化不会刷新页面,但URL会变,导致签名失效。
我的解决方案有三个关键点:
- URL规范化:统一处理iOS和Android的差异
- 签名缓存:避免频繁请求,提升性能
- 自动重签:监听路由变化,智能判断是否需要重新签名
这个方案上线后,签名成功率从65%提到了95%,剩下5%主要是网络问题。"
Q2: "如果让你从0到1设计,你会怎么做?"
回答
"我会分四个阶段:
第一阶段 - 需求调研(1-2天)
- 和产品确认支持哪些平台
- 调研各平台的技术方案和限制
- 评估工作量和风险点
第二阶段 - 架构设计(1天)
- 画出技术架构图
- 定义核心接口和数据流
- 选择设计模式(适配器+策略模式)
第三阶段 - 分阶段开发(1周)
- Day1-2:搭建基础框架,实现微信适配器
- Day3-4:实现其他平台适配器和降级方案
- Day5:封装Vue组件和Composables
- Day6-7:埋点系统和测试
第四阶段 - 优化迭代
- 收集埋点数据,分析问题
- 根据反馈持续优化
整个过程我会保持和产品、测试的沟通,确保最终交付的功能符合预期。"
Q3: "性能优化做了哪些?"
回答
"主要有三个方面:
1. 签名请求优化
- 增加签名缓存,2小时内不重复请求
- 合并签名请求,避免短时间多次调用
- 效果:签名请求减少80%
2. 海报生成优化
- 使用离屏Canvas,不阻塞主线程
- 图片预加载池,避免重复加载
- 使用Web Worker异步处理(复杂场景)
- 效果:海报生成时间从2s降到500ms
3. 资源加载优化
- JSSDK按需加载,不是所有页面都加载
- 使用CDN加速第三方库
- 图片懒加载
这些优化让整体性能提升了60%以上。"
完整的简历项目描述
项目背景
在公司电商平台改版项目中,负责从0到1设计并实现社交分享功能,支持用户将商品、活动等内容分享到微信、微博、QQ等主流平台,提升产品传播效率和用户增长。
技术实现
- 架构设计:采用适配器模式+策略模式,将不同平台的分享逻辑进行解耦,实现了可扩展、可维护的分享系统架构
- 微信集成:深入研究微信JSSDK,解决了单页应用中签名失效的核心问题,通过URL规范化管理和签名缓存机制,将签名成功率从65%提升至95%
- 多端适配:实现了微信JSSDK、Web Share API、降级方案三套分享方案的自动切换,确保在不同环境下都能正常使用
- 组件封装:基于Vue3 Composition API封装了useShare组合式函数和ShareButton、SharePanel组件,实现了一次配置全局复用
- 海报生成:使用Canvas实现动态海报生成功能,支持个性化内容和二维码,通过离屏渲染和图片预加载优化,生成速度控制在500ms以内
- 数据追踪:搭建完整的埋点系统,追踪分享按钮点击、分享成功、分享回流等关键节点,为产品优化提供数据支持
项目成果
- 分享功能上线后,日均分享次数达到5000+,分享转化率提升30%
- 代码复用率提升70%,新增分享场景只需2小时
- 分享成功率稳定在95%以上,用户体验显著提升
技术栈
Vue3、Composition API、Canvas、微信JSSDK、Web Share API
面试话术总结
记住这个万能公式:背景 + 挑战 + 方案 + 结果
示例
"我在做电商平台的时候(背景),需要实现社交分享功能。最大的挑战是微信环境下的签名问题(挑战),单页应用切换路由会导致签名失效。我通过URL规范化管理和签名缓存机制解决了这个问题(方案),最终签名成功率从65%提到95%(结果)。"
关键点
- 不要说"我用了XX库" - 要说"我解决了XX问题"
- 数据量化结果 - 提升了多少、减少了多少
- 体现思考过程 - 为什么这么做、权衡了什么