返回笔记首页

前端分享功能全局设计方案

主题配置

简历描述模板

项目经验描述(直接可用)

社交分享系统设计与实现

2024.06 - 2024.09

  • 独立设计并实现了多平台社交分享功能,支持微信、微博、QQ等8+主流平台,分享成功率达98%
  • 搭建了可复用的分享组件库,通过插件化设计实现一次配置全局使用,减少70%重复代码
  • 解决了移动端微信分享签名失效的技术难题,通过单页应用URL管理和签名缓存机制,将签名成功率从65%提升至95%
  • 实现了分享数据埋点系统,对接后端接口追踪分享转化漏斗,为产品迭代提供数据支持
  • 通过Canvas动态海报生成技术,实现个性化分享图片,图片生成速度优化至500ms以内

SOP标准回答话术

面试官:"你做过分享功能吗?怎么实现的?"

回答模板

"做过,我负责从0到1设计了整个分享系统。这个需求其实挺有挑战的,因为不同平台的分享方式完全不一样。

我的设计思路是这样的

首先做了技术调研,发现移动端和PC端完全是两套逻辑。PC端可以直接用Web Share API或者唤起链接,但移动端特别是微信里面,必须走JSSDK,而且有很多坑。

然后我搭建了一个三层架构:

  1. 适配器层 - 封装各个平台的API差异
  2. 策略层 - 根据环境自动选择分享方式
  3. 组件层 - 提供统一的调用接口

遇到的最大难点是微信分享签名问题。单页应用切换路由后,签名会失效。我的解决方案是维护一个URL管理器,记录每次签名时的URL,当路由变化时判断是否需要重新签名,同时做了签名缓存,避免频繁请求。这个优化让签名成功率从65%提到了95%。

另外还做了降级方案,如果JSSDK加载失败,会自动fallback到复制链接或者生成二维码,保证用户一定能分享出去。

最后加了完整的埋点系统,追踪分享按钮点击、分享成功、分享后回流等关键节点,给产品提供了很好的数据支持。"


难点与亮点分析

难点1: 微信JSSDK签名失效问题

问题描述

单页应用(SPA)中,页面路由变化后微信JSSDK签名失效,导致分享功能不可用

技术原因
  • 微信签名基于当前页面URL生成
  • SPA路由切换不刷新页面,但URL会改变
  • 签名有时效性(2小时),需要合理管理
解决方案
  1. URL规范化管理 - 统一处理hash和query参数
  2. 签名缓存机制 - 避免重复请求
  3. 自动重签逻辑 - 监听路由变化智能重签

难点2: 多平台分享参数不一致

问题描述

不同平台需要的参数格式、字段名、限制都不同

解决方案

适配器模式 + 统一数据模型


难点3: Canvas生成海报性能优化

问题描述

海报生成耗时长,包含网络图片加载、Canvas渲染

解决方案
  • 图片预加载池
  • 离屏Canvas渲染
  • Web Worker异步处理

技术实现代码

1. 核心架构设计

plain
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)

javascript
// 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)

这是核心难点所在

javascript
// 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)

javascript
// 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)

javascript
// 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)

javascript
// 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)

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)

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

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)

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)

javascript
// 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: "微信签名为什么会失效?你是怎么排查的?"

回答

"一开始我也很懵,明明配置成功了,但切换几次路由后就不行了。

我的排查过程是这样的:

  1. 抓包分析 - 发现签名请求的URL和当前页面URL不一致
  2. 查官方文档 - 发现微信要求签名必须基于当前URL
  3. 测试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等主流平台,提升产品传播效率和用户增长。

技术实现

  1. 架构设计:采用适配器模式+策略模式,将不同平台的分享逻辑进行解耦,实现了可扩展、可维护的分享系统架构
  2. 微信集成:深入研究微信JSSDK,解决了单页应用中签名失效的核心问题,通过URL规范化管理和签名缓存机制,将签名成功率从65%提升至95%
  3. 多端适配:实现了微信JSSDK、Web Share API、降级方案三套分享方案的自动切换,确保在不同环境下都能正常使用
  4. 组件封装:基于Vue3 Composition API封装了useShare组合式函数和ShareButton、SharePanel组件,实现了一次配置全局复用
  5. 海报生成:使用Canvas实现动态海报生成功能,支持个性化内容和二维码,通过离屏渲染和图片预加载优化,生成速度控制在500ms以内
  6. 数据追踪:搭建完整的埋点系统,追踪分享按钮点击、分享成功、分享回流等关键节点,为产品优化提供数据支持

项目成果

  • 分享功能上线后,日均分享次数达到5000+,分享转化率提升30%
  • 代码复用率提升70%,新增分享场景只需2小时
  • 分享成功率稳定在95%以上,用户体验显著提升

技术栈

Vue3、Composition API、Canvas、微信JSSDK、Web Share API


面试话术总结

记住这个万能公式:背景 + 挑战 + 方案 + 结果

示例

"我在做电商平台的时候(背景),需要实现社交分享功能。最大的挑战是微信环境下的签名问题(挑战),单页应用切换路由会导致签名失效。我通过URL规范化管理和签名缓存机制解决了这个问题(方案),最终签名成功率从65%提到95%(结果)。"

关键点

  1. 不要说"我用了XX库" - 要说"我解决了XX问题"
  2. 数据量化结果 - 提升了多少、减少了多少
  3. 体现思考过程 - 为什么这么做、权衡了什么