详解小程序中h5页面onShow实现及跨页面通信方案


Posted in Javascript onMay 30, 2019

小程序webview的现状

h5页面在小程序中的交互(跳转)场景

  • h5跳转小程序native页面(如:调用小程序地址选择能力,然后返回对应的地址信息给h5页面)
  • h5跳转己方业务线的h5页面(内部页面交互,方式比较多样)
  • h5跳转其它业务线的h5页面(如:交易流程,相关页面可能有其他业务线提供)

主要痛点

在完成相关操作后, 页面状态需要更新 ,目前常见的更新方式有如下两种:

  • 第一种:通过url传参(如:url中加入__isonshowrefresh=1,告诉webview再次onshow时候刷新),把需要传递的参数拼接到url中,重新打开url。
  • 第二种:需要跳转到新的页面进行数据更新(如:下单页 - 地址选择页 - 新的下单页)

第一种方案,功能上没有问题,但会导致页面刷新,如果页面操作复杂,需要多次刷新

第二种方案,正向操作时体验比方案一好,但导致了另外一个问题:操作 跳转层级过深 ,尤其返回的时候简直让人崩溃。

 小程序中,h5页面打开新页面方式

我们先来看下小程序中常见的h5跳h5的方式:

  • 方式1:直接用location.href跳转,返回时候各机型表现不一致,有的会刷页面,重新执行js,有的会直接展示之前的缓存
  • 方式2:通过路由hash跳转,返回触发hashchange,页面不刷新,js层面重现渲染
  • 方式3:跳转页面打开一个新的webview,相当于每个页面都是一个独立的webview

我们采用的是方式3,理由如下:

  1. 打开新页面时的效果更趋近于native间的跳转(当然新打开的页面也会重新加载静态资源,同时这也有另一个问题,一旦你打开10个层级后,再打开新的webview就没反应了,这个是小程序10层限制)
  2. 返回的体验也更趋近于native,同时保证页面状态统一(不会出现有的直接展示,有的会重新执行js)
  3. webview通过this.src拿到的链接即为当前页面链接,因为如果页面自行通过路由和location.href跳转,页面链接变更后,webview并不会知晓,这种方案,webview通过this.src拿到的链接始终是当前页面的链接。

由于这种方案可能会达到小程序的10层限制。所以在一些重要页面建议加入“ 回到首页 ”的操作,通过这个操作来缩短小程序历史栈

回到首页方案简述

(如果不感兴趣这部分可以直接略过)

wx.miniProgram.reLaunch({
 url: '/pages/webview/bridge?url=项目首页地址'
})

先声明,我们webview的路径是/pages/webview/webview

/pages/webview/bridge是个中转页,有如下特点: 该页面并 不是最终打开h5页面的webview页 ,而是一个 中转页

主要用作返回处理

  • 页面逻辑: 如果是第一次展示,则跳转/pages/webview/webview,同时把url传过去,正常打开h5
  • 如果不是第一次展示,说明是从webview返回过来的,直接重定向到小程序首页

这个中转页:主要保证reLaunch到某h5页面后,用户仍然可以点击返回到小程序首页。

该方案通常用于:小程序中内嵌了多个业务线的h5页面这种场景。

一个内容发布场景

我们从首页进入发布页,完成发布后,跳转至商品详情页

那么对于一个新用户来讲,整个操作过程是这样的:

  • 首页(点击发布)
  • 进入发布页面(选择发布商品的分类)
  • 进入商品分类页(选择完成后)
  • 将分类id拼入url,进入新的发布页面(选取件地址)
  • 进入地址列表页(如果新用户是没有地址的,点击新增地址)
  • 进入新增地址页(添加完成后)
  • 将地址id拼如url,进入又一个新的发布页面(编辑完信息后点击发布)
  • 进入发布成功页(点击查看商品详情)
  • 进入商品详情页

这个场景就是同一个页面,里面不同的内容项需要跳转不同的页面去操作,然后再回到原来页面更新状态的问题。

假如商品详情页没有“回到首页”的入口,那么这个用户要想回到首页。。。需要按8次“返回” = =!

经过这个体验后,我想一般的用户是没有勇气再发布内容的。

当然也有另一种这种折中方案

就是商品提到的,在连接中加入某个标志位,比如在url中加入__isonshowrefresh=1,webview在打开连接时候,会去读取这个参数,如果有,则每次在onShow时候,重新加载url,通过刷新页面进行页面状态更新。

这个体验也不爽,就是在复杂的页面会多次刷新。

声明

我下面要讲的这个方案并不是停留在设想阶段,它已经在线上跑了

想看效果的朋友,可以在微信小程序中搜:

“转转二手交易网”-“0元免费领”-(底部)“送闲置赚星星”-进入到发布页后

分类(跳转h5,选中内容后返回,将参数传给之前的h5)

取件地址(跳转native原生地址选择,选中后返回,将参数传给之前的h5)

OK,我们进入今天的主题

小程序中h5页面onShow和跨页面通信的实现

首先想到的就是onShow方法的实现,之前有人提议用visibilitychange来实现onShow方法。

但调研过后,这种方式在ios中表现符合预期,但是在安卓手机里,是不能按预期触发的。所以该方案被我否了。

于是就有了下面的方案

原理介绍

这个方案需要h5和小程序的webview都做处理。

核心思想: 利用webview的hash特性

详解小程序中h5页面onShow实现及跨页面通信方案 

  • 小程序通过hash传参,页面不会更新(这个和浏览器一样)
  • h5可以通过hashchange捕获最新参数,进行自定义逻辑处理
  • 最后执行window.history.go(-1)

为什么要执行window.history.go(-1)

这一步是整个方案的精髓:

  • 因为hash变更会导致webview历史栈长度+1,用户需要多一次返回操作。但这一步明显是多余的。
  • 同时window.history.go(-1)后,会把webview在hash中添加的参数去掉,还能保证和之前的url一致。

 方案延伸(跨页面数据传递)

小程序里另个一常见的场景就是调用第三业务(或者己方业务),在做完某些操作后需要把选中的数据带回之前的页面。

如前面提到的例子:发布页,需要选择发布类型,然后返回,发布页发布类型局部更新

当然有些同学会说:我可以用setInterval,监控localStorage。在新页面选中内容后,设置localStorage,然后在返回不就可以了。

我这里说的是 通用方案 。如果页面都是由己方业务线维护的当然可以随便折腾。

但是一旦涉及到第三方业务线,尤其不同域名页面的业务调用,这种通信方式就尴尬了。

那我的方案怎么处理呢,我总结了一张图

详解小程序中h5页面onShow实现及跨页面通信方案

我们来解读一下这张图:

  • webview1打开发布页面,h5绑定hashchange事件(因为webview通过hash传值时会触发该事件)
  • 将自定义的onShow方法缓存。在hashchange触发时,寻找指定参数,如果存在则触发
  • 用户点击跳转到类型选择页
  • 这时会打开一个新的webview2页面实例,打开类型选择页
  • 用户操作完成,调用wx.miniProgram.postMessage把数据发送给webview,并返回
  • webview由于绑定了bindmessage事件,在返回时会接收到h5发送的数据
  • 同时将接收到的数据缓存在一个全局的store中,webview2销毁,小程序执行返回
  • 从webview2返回到webview1,这时webview1的onShow钩子会触发
  • webview1读取全局的store,将要发送的参数取出,拼接h5链接的hash部分,并重新打开该链接
  • 虽然重新打开链接,由于仅仅是hash部分的变化,所以页面不会刷新
  • 但会触发h5页面的hashchange,此时调用用户自定义的onShow方法,读取hash参数,进行页面更新
  • h5页面在执行完onShow方法后,调用window.history.go(-1),恢复历史栈

整个过程就是这样

代码示意:

小程序

小程序webview要先做几方面考虑:

  • 出于平滑接入的考虑,不能上来搞一刀切,要保证现有页面再不做任何修改的情况下继续访问。
  • 新能力要通过额外参数区分,如:检测url中的query部分,带有__isonshowpro=1再进行通过hash方式传参。
  • 改造原有逻辑,让__isonshowpro=1时,hash处理逻辑优先级最高
  • 参数定义,在前面加入了两个下划线,目的是为了分区url中正常的参数

小程序端webview.wpy

<web-view wx:if="{{url}}" src="{{url}}" binderror="onError" bindload="onLoaded" bindmessage="onPostMessage"></web-view>

// 链接处理工具方法
import util from '@/lib/util';
// 全局数据存储操作类
import routeParams from '@/lib/routeParams';
const urlReg = /^(https?\:\/\/[^?#]+)(\?[^#]*)?(#[^\?&]+)?(.+)?$/;
let messageData = {};

export default class extends wepy.page {
 data = {
  // 页面展示次数
  pageShowCount: 0,
  // 页面url中query部分的参数对象
  mQuery: {},
  ...
 }
 
 onShow(){
  ++this.pageShowCount;
  // 获取其他页面经过操作后,需要传递给h5的参数
  let data = routeParams.getBackFromData() || {};
  // webview页面状态更新
  if(this.pageShowCount > 1 && this.mQuery.__isonshowpro && this.mQuery.__isonshowpro === '1' || data.refresh){
   // 获取需要传递给h5页面的参数
   let refreshParam = data.refreshParam;
   ...
   // 如果连接中带有需要处理onShow逻辑的参数(通过url的hash和h5交互,而不是刷页面)
   if (this.pageShowCount > 1 && this.mQuery.__isonshowpro === '1') {
    let [whole, mainUrl, queryStr, hashStr, hashQueryStr] = urlReg.exec(this.url);
    // 在url的hash中加入新的参数
    hashStr = (hashStr || '#').substring(1);
    if (refreshParam) {
     delete refreshParam.refresh;
    }
    const messageData = this.getNavigateMessageData();
    // 将需要更新的参数传给页面hash
    hashStr = util.addQuery(hashStr, Object.assign({
     // onshow标志位
     __isonshow: 1,
     // wa主动触发hashchange标志位
     // 其实目前通过__isonshow就可以判断是wa主动触发hashchange
     // 设置该字段是为了明确功能,且以后扩展用
     __wachangehash: 1,
     // 时间戳刷新
     __hashtimestamp: Date.now()
    }, messageData, refreshParam));
    this.url = mainUrl + queryStr + '#' + hashStr;
    console.log('【webview-hashchange-url】', this.url);
    // 这里要加个延迟,否则在webview返回到webview时,无法触发hashchange,应该是小程序bug
    setTimeout(()=> {
     this.$apply();
    }, 50);
   // 通过修改query参数,刷新webview
   } else {
    ...
   }
   ...
  }
 }
 
 /**
  * 获取需要发送的消息数据
  */
 getNavigateMessageData(){
  let rst = {};
  for(let i in messageData){
   /* message结构:
    message: {
     key: 'xx',    // 消息名称
     content: 'xx',  // 消息内容
     trigger: {    // 触发条件
      type: '',    // 触发类型 
                - immediately 在下一次onshow或者打开页面中立刻触发,
                - url 在找到指定h5链接时触发
      content: ''   // 条件内容
                - type=immediately 时为空
                - type=url 时候为h5链接地址
     }
    }
   */
   const message = messageData[i];
   const trigger = message.trigger || {};
   // 立刻发送、路径触发
   if(trigger.type === 'immediately' || trigger.type === 'url' && this.url.indexOf(trigger.content) > -1){
    // 将key和content集合到一个对象中,便于hash直接设置
    rst[message.key] = message.content;
    // 消息通知后,从缓存中删除
    delete messageData[message.key];
   }
  }
  console.log('【webview-get-message】', rst);
  console.log('【webview-message-cache】', messageData);
  return rst;
 }
 
 /**
  * 存储消息数据
  */
 storeNavigateMessageData(message){
  if(message && message.key){
   console.log('【webview-store-message】', message)
   // 通过key设置每一条消息名称
   messageData[message.key] = message;
   console.log('【webview-message-cache】', messageData);
  }
 }
 
 methods = {
  // 接收发送过来的消息
  onPostMessage(e){
   if(!e.detail.data)return;
   const detailData = e.detail.data;
   // 获取消息数据
   let messageData = getValueFromMixedArray(detailData, 'messageData', true);
   if (messageData) {
    // 存储
    this.storeNavigateMessageData(messageData);
   }
   ...
  }
 }
 
 ...
}

上面东西看着挺多,总结下来就是几点:

  • 绑定bindmessage事件
  • 接收到页面传来的消息之后,需要按照一定规则存起来(我是按照key存储的)
  • webview在触发onShow钩子时候,按照之前传过来的触发条件(condition),取出需要发送的消息数据
  • 将数据拼接到url的hash部分,并加入特有的标志位,重新加载url

h5端

h5端在做修改时也要考虑几点:

最好能把这些交互逻辑封装起来

让业务方比较简单方便的调用

这里我新定义了2个方法

onShow(callback)

  • 描述:这个和小程序onShow钩子一样,只不过是给h5调用的
  • 参数:callback 回调方法

例子:发布页面,需要选择分类,返回时需要更新分类信息

import { isZZWA, onShow } from '@/lib/sdk'
import URL from '@/lib/url'

...
created () {
if (isZZWA()) {
 onShow(() => {
 // 地址信息
  const addressInfo = URL.getHashParam('zzwaAddress')
   console.log('addressInfo:', decodeURIComponent(addressInfo))
   ...
   // 分类信息
   const selecteCateInfo = URL.getHashParam('selecteCateInfo')
   console.log('selecteCateInfo:', selecteCateInfo)
   ...
 } else {
  ...
 }
}
...

serviceDone(data, condition)

描述:业务结束,需要将数据传递给指定页面

参数:

data Object 需要传递的数据 {key: 'xx', content: 'xx'}

condition String|Number 触发条件

  • String 指定url的路径,当webview打开指定的url触发onshow时,会发送该消息
  • Number 返回到指定的测试,类似history.go(-1),如: -1,-2

例子:类型选择页

import { isZZWA, serviceDone } from '@/lib/sdk'
// 类型选择点击
typeChooseClick (param, type) {
 ...
 if (isZZWA()) {
  // 需要返回的数据
  const data = {
   key: 'selecteCateInfo',
   content: JSON.stringify({...})
  }
  // 通过postMessage发送给小程序,-1表示返回上一页面
  serviceDone(data, -1)
 } else {
  ...
 } 
}

ok,我们来看看h5端的sdk是怎么实现的

import util from './util';

class WASDK {
 /**
  * Create a instance.
  * @ignore
  */
 constructor(){
  // hashchang事件处理
  if('onhashchange' in window && window.addEventListener && !WASDK.hashInfo.isInit){
   // 更新标志位
   WASDK.hashInfo.isInit = true;
   // 绑定hashchange
   window.addEventListener('hashchange', ()=>{
    // 如果小程序webview修改的hash,才进行处理
    if (util.getHash(window.location.href, '__wachangehash') === '1') {
     // 这块有个坑:
     // ios小程序webview在修改完url的hash之后,页面hashchange和更新都可以正常触发
     // 但是:h5调用部分小程序能力会失败(如:ios在设置完hash后,调用wx.uploadImg会失败,需要重新设置wx.config)
     // 因为ios小程序的逻辑是,url只要发生变化,wx.config中的appId就找不到了
     // 所以需要重新进行wx.config配置
     // 这一步是获取之前设置wx.config的参数(需要从服务端拿,因为之前已经获取过了,这里从缓存直接取)
     const jsticket = window.native && window.native.adapter && window.native.adapter.jsticket || null;
     const ua = navigator.userAgent;
     // 非安卓系统要重新设置wx.config
     if (jsticket && !(ua.indexOf('Android') > -1 || ua.indexOf('Adr') > -1)) {
      window.wx.config({
       debug: false,
       appId: jsticket.appId,
       timestamp: jsticket.timestamp,
       nonceStr: jsticket.noncestr,
       signature: jsticket.signature,
       jsApiList: ['onMenuShareTimeline', 'onMenuShareAppMessage', 'onMenuShareQQ',
        'onMenuShareQZone', 'onMenuShareWeibo', 'scanQRCode', 'chooseImage', 'uploadImage', 'previewImage', 'getLocation', 'openLocation']
      })
     }
     // 触发缓存数组的回调
     WASDK.hashInfo.callbackArr.forEach(callback=>{
      callback();
     })
     // 执行返回操作(这一步是重点!!)
     // 因为webview设置完hash参数后,会使webview历史栈+1
     // 而实际并不需要这次多余的历史记录,所以需要执行返回操作把它去掉
     // 即便是返回操作,也仅仅是hash层面的变更,所以不会触发页面刷新
     // 用setTimeout表示在下一次事件循环进行返回操作。如果后面有对dom操作可以在当前次事件循环完成
     setTimeout(()=>{
      window.history.go(-1);
     }, 0);
    }
   }, false)
  }
 }

 /**
  * hash相关信息
  */
 static hashInfo = {
  // 是否已经初始化
  isInit: false,
  // hash回调香瓜数组
  callbackArr: []
 }
 
 /**
  * 页面再次展示时钩子方法
  * @param {Function} callback - 必填, callback回调方法, 回传参数为hash部分问号后面的参数解析对象
  */
 @execLog
 onShow(callback){
  if (typeof callback === 'function') {
   // 对回调方法进行onshow逻辑包装,并推入缓存数组
   WASDK.hashInfo.callbackArr.push(function(){
    // 检查是否是指定参数发生变化
    if(util.getHash(window.location.href, '__isonshow') === '1'){
     // 触发onShow回调
     callback();
    }
   })
  } else {
   util.console.error(`参数错误,调用onShow请传入正确callback回调`);
  }
 }
 
 /**
  * 业务处理完成并发送消息
  * @param {Object}      obj - 必填项,消息对象
  * @param {String}      obj.key - 必填项,消息名称
  * @param {String}      obj.content - 可选项,消息内容,默认空串,如果是内容对象,请转换成字符串
  * @param {String|Number}  condition - 可选项,默认仅进行postMessage
  *               String - 可以传指定url的路径,当小程序webview打开指定的url或者onshow时,会触发该消息
  *                    也可传小程序path,这个为以后预留
  *               Number - 返回到指定的测试,类似history.go(-1),如: -1,-2
  */
 @execLog
 serviceDone(obj, condition){
  if(obj && obj.key){
   // 消息体
   const message = {
    // 消息名称
    key: obj.key,
    // 消息体
    content: obj.content || '',
    // 触发条件
    trigger: {
     // 类型 'immediately'在下一次onshow中立刻触发, 'url',在找到指定h5链接时触发,'path'在打开指定小程序路径时触发
     type: 'immediately',
     // 条件内容,immediately是为空,url是为h5链接地址,path是为小程序路径
     content: ''
    }
   };
   // 解析触发条件
   condition = condition || 0;
   // 如果是路径
   if(typeof condition === 'string' && (condition.indexOf('http') > -1 || condition.indexOf('pages/') > -1)){
    // 设置消息触发条件
    message.trigger = {
     type: condition.indexOf('http') > -1 ? 'url' : 'path',
     content: condition
    }
   }
   // 发送消息
   wx.miniProgram.postMessage({
    data: {
     messageData: message
    }
   });
   // 如果不是url或者path触发,则对conditon是否需要返回进行判断
   if(message.trigger.type === 'immediately'){
    // 查看是否需要返回指定的层级,兼容传入'-1'字符串这种类型的场景
    try{
     condition = parseInt(condition, 10);
    }catch(e){}
    // 保证返回级数的正确性
    if(condition && typeof condition === 'number' && !isNaN(condition)){
     this.handler.navigateBack({delta: Math.abs(condition)});
    }
   }
  }else{
   util.console.error(`参数错误,调用serviceDone方法,传入的对象中不包含key值`);
  }
 }
 
 ...
}

window.native = new Native();

export default native;

这个看着也挺多,总结下来是两点:

onShow方法的实现

绑定一个hashchange事件(这里做了防止重复绑定事件的处理)

将传入的onShow自定义事件缓存在一个数组中,hashchange触发时,根据特有的标志位__isonshow和__wachangehash确定是否触发

serviceDone方法的实现

  • 处理传过来的数据
  • 处理该数据的触发条件:immediately表示最近的一次onShow触发,或者自己指定url
  • 通过wx.miniProgram.postMessage发送数据

ok,整个方案就介绍完了

结语

最早的方案并不完全是这样的,但原理是一样的。在我实现的过程中发现原始方案有很多问题

于是我又做了大量的改造和细节优化,于是形成了上面的最终方案。

这个方案属于侵入式改造方案,需要各业务方改造自己的代码。虽然有一定改造成本,但用户体验的收益非常明显。

ps:我们的QA在测试时都说“这用起来就爽多了”

注意:

采用这个方案需要注意几点:

  1. 如果采用这种方式通信,需要在当前页面url的query部分加入__isonshowpro=1,否则是不会通过hash通信的
  2. 同时要保证页面确实调用了onShow方法,否则页面也是不会刷新的
  3. 如果第三方业务需要传值,需要统一采用serviceDone方法通信

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
javascript 面向对象编程基础:继承
Aug 21 Javascript
原生javascript实现图片轮播效果代码
Sep 03 Javascript
jQuery-serialize()输出序列化form表单值的方法
Dec 26 Javascript
jQuery javaScript捕获回车事件(示例代码)
Nov 07 Javascript
js中回调函数的学习笔记
Jul 31 Javascript
Javascript中实现String.startsWith和endsWith方法
Jun 10 Javascript
js html5 css俄罗斯方块游戏再现
Oct 17 Javascript
JavaScript中object和Object的区别(详解)
Feb 27 Javascript
jQuery返回定位插件详解
May 15 jQuery
React Form组件的实现封装杂谈
May 07 Javascript
WebPack配置vue多页面的技巧
May 15 Javascript
基于Vue组件化的日期联动选择器功能的实现代码
Nov 30 Javascript
JS通过ajax + 多列布局 + 自动加载实现瀑布流效果
May 30 #Javascript
全面分析JavaScript 继承
May 30 #Javascript
浅谈Express.js解析Post数据类型的正确姿势
May 30 #Javascript
vue组件三大核心概念图文详解
May 30 #Javascript
详解一次Vue低版本安卓白屏问题的解决过程
May 30 #Javascript
基于iview的router常用控制方式
May 30 #Javascript
深入了解js原型模式
May 30 #Javascript
You might like
使用MaxMind 根据IP地址对访问者定位
2006/10/09 PHP
在PHP3中实现SESSION的功能(三)
2006/10/09 PHP
PHP面向对象学习笔记之二 生成对象的设计模式
2012/10/06 PHP
基于jquery的内容循环滚动小模块(仿新浪微博未登录首页滚动微博显示)
2011/03/28 Javascript
关于js日期转化为毫秒数“节省20%的效率和和节省9个字符“问题
2012/03/01 Javascript
js变换显示图片的实例
2013/04/16 Javascript
IE8对JS通过属性和数组遍历解析不一样的地方探讨
2013/05/06 Javascript
js控制frameSet示例
2013/09/10 Javascript
js数字转换为float,取N位小数
2014/02/08 Javascript
文本框水印提示效果的简单实现代码
2014/02/22 Javascript
jquery.mobile 共同布局遇到的问题小结
2015/02/10 Javascript
Javascript实现div层渐隐效果的方法
2015/05/30 Javascript
JavaScript中字符串(string)转json的2种方法
2015/06/25 Javascript
微信小程序 input输入框控件详解及实例(多种示例)
2016/12/14 Javascript
AngularJs篇:使用AngularJs打造一个简易权限系统的实现代码
2016/12/26 Javascript
基于JavaScript实现全选、不选和反选效果
2017/02/15 Javascript
Vue.js仿Metronic高级表格(一)静态设计
2017/04/17 Javascript
clipboard.js在移动端复制失败的解决方法
2018/06/13 Javascript
React手稿之 React-Saga的详解
2018/11/12 Javascript
layer.open回调获取弹出层参数的实现方法
2019/09/10 Javascript
解决在Vue中使用axios用form表单出现的问题
2019/10/30 Javascript
详解ES6 CLASS在微信小程序中的应用实例
2020/04/24 Javascript
微信小程序中使用 async/await的方法实例分析
2020/05/06 Javascript
移动端JS实现拖拽两种方法解析
2020/10/12 Javascript
[19:24]DOTA2客户端使用指南 一分钟快速设置轻松超神
2013/09/24 DOTA
[03:08]Ti4观战指南上
2014/07/07 DOTA
[01:33:25]DOTA2-DPC中国联赛 正赛 Elephant vs IG BO3 第一场 1月24日
2021/03/11 DOTA
python使用锁访问共享变量实例解析
2018/02/08 Python
python实现图书管理系统
2018/03/12 Python
tensorflow 查看梯度方式
2020/02/04 Python
html2canvas生成的图片偏移不完整的解决方法
2020/05/19 HTML / CSS
Vans奥地利官方网站:美国原创极限运动潮牌
2018/09/30 全球购物
机电一体化自荐信
2013/12/10 职场文书
国际商务专业求职信
2014/07/15 职场文书
戒毒悔改检讨书
2014/09/21 职场文书
Win11怎么添加用户?Win11添加用户账户的方法
2022/07/15 数码科技