详解小程序中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 相关文章推荐
url 特殊字符 传递参数解决方法
Jan 01 Javascript
JQuery 绑定select标签的onchange事件,弹出选择的值,并实现跳转、传参
Jan 06 Javascript
JQuery中使用on方法绑定hover事件实例
Dec 09 Javascript
基于jquery的手风琴图片展示效果实现方法
Dec 16 Javascript
Node.js的包详细介绍
Jan 14 Javascript
Javascript编程之继承实例汇总
Nov 28 Javascript
利用jQuery实现WordPress中@的ID悬浮显示评论内容
Dec 11 Javascript
Bootstrap时间选择器datetimepicker和daterangepicker使用实例解析
Sep 17 Javascript
jQuery编写设置和获取颜色的插件
Jan 09 Javascript
详解vue2父组件传递props异步数据到子组件的问题
Jun 29 Javascript
vue2实现数据请求显示loading图
Nov 28 Javascript
详解React服务端渲染从入门到精通
Mar 28 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
介绍一些PHP判断变量的函数
2012/04/24 PHP
ThinkPHP采用实现三级循环代码实例
2014/07/18 PHP
tp5实现微信小程序多图片上传到服务器功能
2018/07/16 PHP
Javascript 继承机制实例
2009/08/12 Javascript
js获取元素在浏览器中的绝对位置
2010/07/24 Javascript
jquery 查找iframe父级页面元素的实现代码
2011/08/28 Javascript
artDialog双击会关闭对话框的修改过程分享
2013/08/05 Javascript
js处理自己不能定义二维数组的方法详解
2014/03/03 Javascript
jquery中获得元素尺寸和坐标的方法整理
2014/05/18 Javascript
js星星评分效果
2014/07/24 Javascript
使用JavaScript获取地址栏参数的方法
2014/12/19 Javascript
第一章之初识Bootstrap
2016/04/25 Javascript
将鼠标焦点定位到文本框最后(代码分享)
2017/01/11 Javascript
讲解vue-router之什么是动态路由
2018/05/28 Javascript
js实现指定时间倒计时效果
2019/08/26 Javascript
如何阻止小程序遮罩层下方图层滚动
2019/09/05 Javascript
微信小程序实现点击图片放大预览
2019/10/21 Javascript
element-ui 文件上传修改文件名的方法示例
2019/11/05 Javascript
Vue根据条件添加click事件的方式
2019/11/09 Javascript
nestjs返回给前端数据格式的封装实现
2021/02/22 Javascript
Python实现的简单万年历例子分享
2014/04/25 Python
简单介绍Python中的几种数据类型
2016/01/02 Python
浅谈pandas中Dataframe的查询方法([], loc, iloc, at, iat, ix)
2018/04/10 Python
Python 读取某个目录下所有的文件实例
2018/06/23 Python
使用Python处理BAM的方法
2018/09/28 Python
Python迷宫生成和迷宫破解算法实例
2019/12/24 Python
用pandas划分数据集实现训练集和测试集
2020/07/20 Python
html5摇一摇代码优化包括DeviceMotionEvent等等
2014/09/01 HTML / CSS
StubHub西班牙:购买和出售全球活动门票
2017/06/05 全球购物
柒牌官方商城:中国男装优秀品牌
2017/06/30 全球购物
美国最大的在线水培用品商店:GrowersHouse.com
2018/08/14 全球购物
中学老师的自我评价
2013/11/07 职场文书
学校个人对照检查材料
2014/08/26 职场文书
原来实习报告是这样写的呀!
2019/07/03 职场文书
大学生奖学金获奖感言(范文)
2019/08/15 职场文书
分析SQL窗口函数之取值窗口函数
2022/04/21 Oracle