0%

有一个很简单的需求如下:

给你一段URL,在其中插入一些参数,并返回新的URL,如何实现?

这里的参数,有时候称为query,有时候称为params,一般称为search,指的是

http://www.example.com:80/path/to/myfile.html?key1=value1&key2=value2#SomewhereInTheDocument

中的key1key2

最近正在对接阿里云的金融级实人认证,在传递认证成功回调页时就遇到了这样一个问题,大厂有时也会考虑得不全面。

第一层: 直接拼

1
2
3
4
5
import qs from 'query-string'
function resolve (url) {
const params = qs.stringify({ a: 1, b: 2 }) // a=1&b=2
return url + '?' + params
}

实际业务中,90%的场景这样写没问题,但如果url的值是这样的:

1
http://taobao.com/?c=3&d=4

最终结果就是

1
http://taobao.com/?c=3&d=4?a=1&b=2

将这段URL中的search解析,得到的结果是

1
2
3
4
5
{
c: '3',
d: '4?a=1',
b: '2',
}

显然不符合预期。

第二层: 兼容已存在的search

1
2
3
4
5
import qs from 'query-string'
function resolve (url) {
const params = qs.stringify({ a: 1, b: 2 }) // a=1&b=2
return url + url.includes('?') ? '&' : '?' + params
}

看上去问题似乎解决了,很多人也只考虑到这一层,但现在还有这样一种url:

1
http://taobao.com/#/xxx

尤其是单页应用,这个形式的hash路由非常常见。

如果只是简单地拼接到URL尾部:

1
http://taobao.com/#/xxx?a=1&b=2

将这段URL中的search解析,得到的结果是

1
{}

可以看到拼接的参数根本没跑到search里面去。

也就是说,只要URL中出现了#,这之后出现的?就不会被视作search的起始标志,而是hash的一部分。

如果URL再复杂一点,比如:

1
http://taobao.com/?c=3&d=4#/xxx

上面的拼接会变成:

1
http://taobao.com/?c=3&d=4#/xxx&a=1&b=2

不但没有按照预期插入参数,还破坏了原本的hash结构。

实际上,以vue-router为例,它的路由系统中恰好就用到了hash中的?

比如:

1
http://example.com/user/:foo/info?c=3&d=4#/xxx?a=1&b=2

在vue-router里,xxx是路由的path,foo被称作params,ab被称作query,分别可以通过
route.paramsroute.query获取。

cd才是search,需要从location.search中解析。

如果后端接收了这样一段GET请求,hash后面的东西都会被抛弃,只有search可以被接收和解析。

因此,插入参数不能出现在#之后,也就是说,简单地在URL后面拼接字符串是不行的。

第三层: 只处理search

1
2
3
4
5
6
7
import qs from 'query-string'
function resolve (url) {
const params = qs.stringify({ a: 1, b: 2 }) // a=1&b=2
const urlObj = new URL(url)
urlObj.search += urlObj.search.startsWith('?') ? '&' : '?' + params
return urlObj.href
}

这样就可以处理上面的复杂情况了。

使用URL对象是一个讨巧的办法,将url字符串解析为URL对象后,可以只修改它的search属性而不影响其他部分。

axios的源码中,我们可以看到另一种标准实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function buildURL(url, params) {
if (!params) {
return url;
}

// params序列化过程略
var hashmarkIndex = url.indexOf('#');
if (hashmarkIndex !== -1) {
url = url.slice(0, hashmarkIndex);
}

url += (url.indexOf('?') === -1 ? '?' : '&') + params;

return url;
};

思路很简单,就是把序列化后的params插入到URL的末尾,但若存在hash,则插到hash之前,若存在search,则连接符改用&

相关链接:

What is a URL

URL对象

Vue-router 路由对象

阿里正在将淘宝无线开放平台等业务整合为商家应用,类似于支付宝小程序,也就是以小程序的形式进行淘宝天猫店铺的展现、营销。

最近正在开发商家应用小程序,踩了无数坑,原本以为微信小程序已经够坑了,没想到这个几乎照搬微信小程序模式的淘宝小程序更坑。毕竟刚起步,抱怨也没用,将我遇到的问题先记录下来,让后来者可以少花一些时间。

微信小程序遇到开发问题时有一个社区平台可以交流。优点是开发者之间可以互相交流,有一些回帖非常有价值;缺点是提问不一定能得到官方解答,很多帖子可能会石沉大海。

而阿里系这边,反馈问题不能通过社区,只能使用工单系统。优点是有问必答,工作日一般半小时内就可以看到回复,几句话说不清的还可以转钉钉详聊;缺点则是回答者对专业问题几乎一窍不通,只能算是小程序开发者与官方平台开发者的传话人,与所有客服系统一样,只会把平台文档里已经有的东西车轱辘话来回说,除非你用明确的语言表示自己这个问题确实不在文档里。而且一旦客服表示问题复杂邀请你进了钉钉详聊,那这个问题可能很快能解答,也可能再也没有回音。另外,工单系统看上去是10年前开发的样子,贴图、贴代码都极不友好,搜索功能也几乎是废弃状态。

再多吐槽一句,文档系统也非常糟糕,V1和V3文档同时存在并运行,很多文档中的链接都没有更新,你会在逐步的浏览中先后打开v1、v3甚至支付宝小程序的文档,而更复杂的TOP接口文档,可能会让你进一步陷入迷惑,甚至连你想要的功能应该属于哪个分类都难以确定。

言归正传,记录下我开发中遇到的问题。

发起网络请求

我原本已经封装了一套基于my.request的api工具,结果真机一调,发现不能用。

文档没有删干净,仿微信的my.request已经被废弃了。

对于云应用,现在官方强推的唯一用法是cloud.application.httpRequest

https://miniapp.open.taobao.com/docV3.htm?docId=118538&docType=1

父组件不能定义自定义子组件的class

比如

1
<aaa class="test" />

这种写法是无效的,父组件定义到自定义组件上的样式会被丢弃。

微信小程序和VUE中,像这样的定义在最终会与组件最外层内部定义的class合并到一起,但在淘宝小程序里就是不行(官方提供的组件倒是可以)。

我的临时处理方式是:

1
2
3
<view class="test">
<aaa />
</view>

然后样式写:

1
.test > view

如果你用了stylelint之类的校验工具,记得将view这个标签关键字加到白名单。

也许也可以加一个名叫class的props来迂回实现,待验证

上传图片

上传图片的实现折腾了我很久,而这几乎是所有开发者绕不开的问题,我很诧异官方竟然没有及时更新文档来指出。正确的方法在本节内容的最后,前面的试错方法是行不通的。

首先,文档确实存在了这样一个接口:my.uploadFile

所以我的第一反应就是用它来实现图片上传,微信小程序也是这么做的。

实际上my.uploadFile就是一个传递流的HTTP请求,我希望将图片上传到阿里云OSS服务,因此,我需要手写模拟出OSS上传所需的各种参数。

参考了这篇文档来实现。

注意,这是在小程序客户端实现根据密钥生成签名,私钥暴露到了前端,理论上有安全隐患。

拼完参数以后,还要指定上传服务器地址,也就是HTTP请求的URL。

例如:https://my-oss-name.oss-cn-hangzhou.aliyuncs.com

四级域名是阿里云OSS的bucket名称。

折腾完上面这些以后,在本地开发者工具上测试没有问题,但预览和真机调试时发现报错。
错误信息为

1
{errorCode:4,errorMessage: "no premission"}

工单反馈才知道,my.uploadFile限制了域名。只允许以下域名规则:

1
2
3
4
5
6
miniappcloud.taobao.com
media.taobao.com
upload.media.aliyun.com
miniappcloud-common-file.oss-cn-zhangjiakou.aliyuncs.com
\\S\\.alicdn\\.com$
\\S\\.mmstat\\.com$

简单来说,就是my.uploadFile这个接口只有阿里内部服务可以使用,无法被普通开发者调用,应当屏蔽和废弃。

上传图片现在只有一条路可走,就是改用阿里云开发中的云存储

这里问题又来了,文档说,小程序关联云服务,只能在云开发和云应用里二选一

而我们的后台用的就是云应用,难道就不给用云开发里的云存储功能了?

幸好实际使用发现,即使选择了云应用,仍然可以使用云开发的云存储功能。不过,云存储的管理只能在开发者工具中进行。

注意,每次点击开发者工具中的云开发,都会自动在项目下创建server和client目录,并将项目原来的文件包括node_modules全部挪到client下,如果你不希望目录被改变,记得先启用git,没事别点这个按钮

云存储的调用就不详述了,但还有一个坑待验证:

使用云存储上传成功后,拿到的是一个cloud协议前缀的内部地址和一个URL。

文档和客服的反馈都表示,这个URL不是永久有效的,也就是你如果想要访问这个文件,需要先调用云存储的接口获取临时地址。

这个设计带来一个麻烦的问题,不能直接把存储的图片地址赋值给image组件,因为image所需的URL只能异步获取到,你需要自己再封装一个组件用来显示云存储图片。

不过实际使用发现,上传成功后获得的这个URL到目前为止还没有过期,就先这样用着了。

预览和真机调试时,二维码丢失全局参数(2020-05-18似乎已修复)

在编译模式中添加的全局参数,本地调试都能生效,但预览和真机调试时,这些参数都被丢弃了。在编译模式里的配置项只有默认页面是生效的。

询问工单后得知,只能自行添加参数。

首先点击编译成功后得到的二维码图案,获得一串地址,类似这样

1
https://m.duanqu.com?_ariver_appid=xxx&nbsv=0.1.2004151019.52&nbsource=debug&nbsn=DEBUG&channelId=1jOXeERZ54A_2IS7tG_0Co1du_0149_1vA&isRemoteX=true

然后需要将全局参数进行url编码,比如原始参数

1
dev=true&id=123

编码后变成

1
dev%3Dtrue%26id%3D123

作为query参数的值,拼接到url上,变成

1
https://m.duanqu.com?_ariver_appid=xxx&nbsv=0.1.2004151019.52&nbsource=debug&nbsn=DEBUG&channelId=1jOXeERZ54A_2IS7tG_0Co1du_0149_1vA&isRemoteX=true&query=dev%3Dtrue%26id%3D123

最后在浏览器打开这段地址,然后进行扫码。

每次真机调试都要这么来一次,非常麻烦,不知道什么时候才能修正。

PC端基础组件overlay运行不正常

PC端尝试使用overlay组件实现弹窗效果。

要弹出一个居中的overlay,需要这样配置:

1
2
3
4
5
6
7
8
<overlay
visible="{{ visible }}"
onRequestClose="onClose"
align="cc cc"
hasMask
>
弹窗内容
</overlay>

随后发现两个致命问题:

  1. overlay层级无法修改

弹窗内部如果有进行系统级弹窗的操作,比如执行my.chooseImage后,系统弹出的选择相册菜单被盖在了当前弹层下方。
并且文档中没有关于overlay层级调整的相关方法。

  1. overlay中有任意自定义组件,第二次弹出时必定崩溃

比如最简结构

1
2
3
<overlay visible={{visible}}>
<aaa />
</overlay>

当visible第二次被修改为true时,app崩溃,错误提示为

1
worker render components is not sync! can not find id from path: 1-1-1-3-1:/src/components/aaa/index

这个问题客服只说转给开发了,但至今没有反馈,因此overlay无法被实用,只能自己实现一个弹窗组件。

自定义组件的样式是全局生效的

page的样式只在page内生效,这是有共识的,但我没想到的是component下定义的样式竟然会是全局生效的。

不同的自定义组件中出现同名样式时,会互相影响和覆盖。

而商家PC端现在官方强推的是使用扩展组件里的路由系统(顺带一提这路由系统非常简陋,使用又繁琐),
如果使用了路由系统,就意味着所有页面都是component,这时候这种全局scope就会严重影响开发。

临时的解决方式是,手动在组件样式最外层套上当前组件的class(用scss会比较方便)

PC端千牛使用远程调试时无法收到调试信息

按照文档配置:

https://miniapp.open.taobao.com/docV3.htm?docId=118310&docType=1

能正常从千牛中启动预览包,能打开chrome上的devtool标签页,并且关闭当前的预览应用时,能看到chrome弹出连接断开的提示。

但预览应用中执行的各种输出控制台信息,在chrome调试标签页都没有反应,看不到任何变化和输出。

工单反馈说:

目前发现 chrome 80.0.3985.0 及以上版本(也可能是79以上,边界没有定位得很精确),能连接上小程序js环境,甚至能打断点调试代码,但就是看不到console输出,直接在console里输入 console.log,也是不输出的。此问题已知待解决,请尝试用低版本chrome绕过。

我不想装旧版本的chrome,有精力的同学可以试一下。

商家后台千牛PC端应用上传后,预览码无法在千牛客户端打开

商家后台千牛PC端应用上传成功,在版本管理-开发阶段中可以查看到,性能任务已通过,但查看并复制预览码后,在千牛的开发者设置中粘贴预览码并打开时,提示页面不存在。

在本地调试时点击预览后复制二维码并粘贴打开是正常的。

这实际上并不是小程序本身的问题,而是千牛端即使处于登录状态,也可能有什么类似session的东西过期了,需要手动重新登录一下千牛。

hideLoading以后执行showToast无效

文档里写了

在 my.showToast 之前调用 my.hideLoading,toast 被 my.hideLoading 覆盖,将不展示。

如果我将异步请求封装为公共方法,在请求前showLoading,请求后hideLoading,结果就无法在请求完成后显示结果。

工单没有对这个问题做出有效解答,实际测试发现在开发者工具上模拟时不会弹出toast,但真机却可以正常弹出。

分享在path里添加的参数在终端打开时取不到

这算得上是一个大坑。

实际测试发现app.onLoad里取不到,page.onLoad里才有。

通过分享打开小程序时,得从页面onload的query里取参数。

1
2
3
onLoad(query){
console.log(query)
}

通过扫码打开时,得从app的options里取参数。

1
2
3
onLoad(options) {
console.log(options.query)
}

这里有一个很关键的问题,对商家应用来说,启动参数是不可或缺的,比如营销活动的id,没有id,应用就不能进行任何实际操作或展示。

因此,我只能将初始化动作变成一个分几步走的过程:

  1. 创建全局的初始化promise对象
  2. app.onLoad中获取参数,如果没有取到关键字,则跳过
  3. page.onLoad中获取参数,如果还是没有取到关键字,则进入模拟数据展示,使用模拟数据进入下一步

还记得前面二维码不带参数的问题吗?现在提审时不能像微信那样为某个版本添加编译参数,只能把组装好参数的二维码对应的URL贴到审核备注里,而应用审核者经常不看备注直接扫码打开应用,他看到一片空白时,就会拒绝通过。为了增加过审几率,必须制作一个模拟数据展示模式。

  1. 根据参数进行初始化动作,比如判断运行环境,初始化api接口cloud实例,将活动id放到globalData里等等。最后将初始化promise对象置为resolved。
  2. app或者页面的后续动作都要在初始化promise对象完成后进行。关键在于cloud实例的初始化也是异步的,只有在cloud初始化完毕后,才能执行获取数据等异步请求操作。

PC端千牛只能在page.onLoad里接受参数

与前一个问题有微妙的不同。

生成的二维码地址如:

1
https://m.duanqu.com?_ariver_appid=xxx&nbsv=0.1.2004161617.2&nbsource=debug&nbsn=DEBUG&query=dev%3Dtrue

并不能在app的onLoad里拿到query信息。

只能在page的onLoad里拿到整个options对象。

区别在这里,文档描述的正常场景中,onLoad的入参应该是一个query对象。我不确定这种不协调的差异什么时候会被修正,建议将处理语句写成:

1
let query = options.query || options

返回结果只有一个false,会自动变成空对象

后台返回的内容要注意了,比如有个接口是检测用户是否会员或者检测用户剩余游戏次数,不要直接返回一个boolean或者number对象。

因为cloud.application.httpRequest这个接口我估计会用一个if条件来判断后台返回的data。如果后台返回false或者0,前端拿到的最终结果总是一个空对象{},前端也用if判断就会出错。

因此,确保所有接口返回的内容都是一个复杂对象。

跳转到店铺固定页接口天猫无反应

使用my.tb.navigateToTaobaoPage跳转到店铺会员页,在手淘上正常,天猫上无反应,且没有错误提示

工单答复为:

my.tb.xxx 接口预计在月底版本支持天猫客户端

image的相对路径是相对于页面而不是当前组件的

这个问题也很恶心。等于说组件不能有独属于自己的图片存放目录,无法实现解耦。

工单答复也是建议图片统一放到assets目录下,或者放到OSS上。

我反问1耦合性怎么办,2远程请求影响加载速度怎么办。

我认为这明显是个BUG,客服只说已反馈给开发,目前无下文。

分享设置的content自定义淘口令无效

分享的使用文档

内容实际上是从支付宝小程序文档里复制的,里面的描述都还是吱口令

工单答复是,暂不支持,文档会去掉(截止发文时还没去掉)

关注店铺

需要到C端模板下的权限管理中,申请

商家应用商品收藏、店铺关注权限包

注意不是最外面应用管理下的商家应用,那里的权限管理中没有该权限包

关注店铺相关接口为

https://miniapp.open.taobao.com/docV3.htm?docId=913&docType=20

注意这里的id,不是店铺Id,而是店铺所属卖家Id。

需要再申请一个卖家信息查询权限包,由服务端查询并返回给消费者端。

实际使用时,安卓下测试通过。

IOS下执行出错,错误为:

1
{ code:2 , errorMessage: "参数错误:id" }

另外,IOS下的Error对象字段也与安卓不同,不是标准的Error对象。

安卓下返回的错误提示文字字段为message,而IOS下是errorMessage。

后来发现原来是id不能为字符串,必须是number类型。

权益中心插件配置

发放优惠券需要用到权益中心。

权益中心插件,本地模拟器不能运行,只能在PC千牛上调试。

同样的,因为chrome版本问题导致看不到调试信息,目前还未修复。

使用过程中发现无法以组件形式引入插件,折腾半天以后发现,原来是文档不一致。

https://console.open.taobao.com/?spm=a219a.7386653.0.0.143d669aiBAGsb#/market/plugin?tabKey=document&pluginId=3000000002026202

插件中心的这篇文档害人不浅。

1
2
3
4
5
6
"plugins": {
"myPlugin": {
"version": "0.0.1",
"provider": "3000000002026202"
}
}

注意app.json不要跟着示例写,provider上没有双括号。

然后插件文档里说支持组件形式调用,实际上是不行的。使用文档以

https://miniapp.open.taobao.com/docV3.htm?docId=117142&docType=1

这篇为准。

页面配置transparentTitle

设置transparentTitle的值为always时,安卓下表现正常。

但IOS上向上滑动时,会随着滑动在顶部出现白色导航栏,且导航栏上面没有标题文字,也不能用titleBarColor修改文字颜色,只是一个白色长条。

结果因为这个长条,审核无法通过。

需要将该字段删除,默认就是不显示导航栏。

PC端表单组件文档未完善,validate几乎无法使用

文档中validateState的描述中提到:

校验状态,如不设置,则会根据校验规则自动生成

但并无文档或例子说明校验规则在哪里配置。

已弃坑

由于上架应用几乎无人问津,业务已中止,弃坑了╮(╯▽╰)╭

BaseMapper提供的默认查询方法都是单表的,如果需要多表关联查询,同时还要支持分页,一个方案就是自己写Sql。

现在自己写Sql一般用的应该不是传统MyBatis那种写xml的老方法,而是通过在Mapper类里写注解来实现。

还需要解决的就是如何在自定义Sql语句中拼接MyBatisPlus的分页参数和查询条件。

示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public interface GameQueryMapper extends BaseMapper<GameQuery> {
String querySql = "SELECT a.*, b.org_id FROM t_game AS a LEFT JOIN t_game_game_org AS b ON b.game_id = a.id ";
String wrapperSql = "SELECT * from ( " + querySql + " ) AS q ${ew.customSqlSegment}";
/**
* 分页查询
*/
@Select(wrapperSql)
Page<GameQuery> page(Page page, @Param("ew") Wrapper queryWrapper);

/**
* 普通查询
*/
@Select(wrapperSql)
List<GameQuery> list(@Param("ew") Wrapper queryWrapper);

/**
* 单独查询
*/
@Select(wrapperSql)
QyyOrgQuery one(@Param("ew") Wrapper queryWrapper);
}

关键在于Sql字符串最后那一句${ew.customSqlSegment},是用来拼接LambdaQueryWrapper等查询条件包裹器对象最终输出的Sql语句的。

前面不能有WHERE,所以我在最外面又包了一层,将纯粹的多表关联查询语句与特殊组装语句区分开,这样不但可以在自定义Sql内部使用WHERE语句,也便于复制和创建新的Mapper。

但这里还要注意一个关键问题。

在Mapper里面自定义Sql注解对应的方法,其返回的Pojo对象,以及Mapper类指定的Pojo对象,他们必须完全一致。

即:extends BaseMapper<T> 中的T需要与Page<T>一致

如果我在public interface GameMapper extends BaseMapper<Game>里写了这样一个方法:

1
Page<GameQuery> page(Page page, @Param("ew") Wrapper queryWrapper);

即使GameQuery这个对应多表关联字段的Pojo继承自Game,也会出现如下错误:

1
evaluating expression 'ew.customSqlSegment'. Cause: org.apache.ibatis.ognl.OgnlException: customSqlSegment [com.baomidou.mybatisplus.core.exceptions.MybatisPlusException: can not find lambda cache for this entity

因此,多表关联分页查询的Mapper需要单独新建,与单表实例分开。

在将测试库的新增表通过Navicat向阿里云的MySql数据库复制后,发现前端页面请求查询失败。

目标数据库的默认排序规则是utf8mb4_0900_ai_ci,已存在的表都使用了这个编码,而来源数据库的排序规则是utf8mb4_unicode_ci,新复制的表都用的是后者。

数据库进行多表关联查询时,如果两张表的字符集或者排序规则不一致,就会报错。

从Navicat里手动修改编码效率很低,若只是改一下表,也用不了多久,但问题是只改表是不行的,表内所有varchar的编码并不会跟着表走。

因此还是需要走批量操作的路子。

批量修改字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
SELECT
CONCAT(
'ALTER TABLE `',
TABLE_NAME,
'` MODIFY `',
COLUMN_NAME,
'` ',
DATA_TYPE,
'(',
CHARACTER_MAXIMUM_LENGTH,
') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',
( CASE WHEN IS_NULLABLE = 'NO' THEN ' NOT NULL' ELSE '' END ),
';'
)
FROM
information_schema.COLUMNS
WHERE
TABLE_SCHEMA = '数据库名'
AND (
DATA_TYPE = 'varchar'
OR DATA_TYPE = 'char')

批量修改表

1
2
3
4
5
6
SELECT
CONCAT( 'ALTER TABLE ', TABLE_NAME, ' CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;' )
FROM
information_schema.TABLES
WHERE
TABLE_SCHEMA = '数据库名';

将以上SQL语句中的utf8mb4utf8mb4_unicode_ci数据库名分别改成自己需要的值,成功执行后,将执行结果即SQL语句复制出来,再执行这些SQL语句即可。

最近遇到这样一个需求:

  • 打开当前页面时,可能会带上一个参数pid

  • 页面初始化时,如果发现存在此参数,就立刻跳转到其对应的外部页面。

  • 当用户从外部页面后退时,可以回到当前页面,此时正常显示页面内容。

我的实现方式是:

  • hash传递pid

  • 在页面入口脚本中,如果hash中没有pid,就正常渲染页面。

  • 如果发现存在pid,就不渲染页面内容,而是通过ajax从后台获取pid对应的URL。然后从location.hash里去掉pid,再修改location.href进行跳转。

在PC端Chrome下测试正常通过,但在手机浏览器中出现了问题。

跳转到新页面后,按后退时不会回到当前页。在微信浏览器里,后退会直接关闭,在小米自带浏览器、夸克浏览器里,后退会回到标签页的初始页,移动端Chrome还是正常。

上百度搜了下,尝试了以下方案,均无效:

  • setTimeout延时再跳转
  • 使用location.assign()代替location.href赋值
  • 使用history.pushState手动写入当前页面URL后再跳转
  • window.onload里执行判断并延时跳转
  • window.onload里延时执行一个模拟点击事件并在该元素的点击回调中执行跳转

这其中倒是发现一个奇怪的现象,就是在延时跳转前,如果手动点击过页面任意位置,表现就会一切正常,新页面后退可以正常返回当前页,完全符合需求预期。

但是,使用dispatchEvent模拟出来的点击事件不行。

与之前做全屏api时遇到的问题类似,应该是浏览器有某种策略,要求history的操作只能在用户的真实点击操作中执行。

但奇怪的是搜了很多中文页,没有一个提到这样的问题。

最后在StackOverflow上找到了一些解答(类似问题同样很少,但总算有答案):

HTML标准 文档对history的使用有一些规范:

If any of the following conditions are met, let replacement flag be unset; otherwise, let it be set:

  • This Location object’s relevant Document has completely loaded, or
  • In the task in which the algorithm is running, an activation behavior is currently being processed whose click event’s isTrusted attribute is true, or
  • In the task in which the algorithm is running, the event listener for a click event, whose isTrusted attribute is true, is being handled.

大致的意思是:

如果满足以下任意条件之一, location对象的replacement标记置为默认,否则置为true。

(换句话说,如果不满足以下条件,对location进行任何操作,都不会产生新的历史记录)

  • location对象所在的文档已经完全加载
  • location的修改操作由click事件触发,且事件的isTrusted属性为true(也就是用户的真实点击操作)

英文不行,没有找到完全加载的精确定义。但我推测对遵循HTML标准的浏览器来说,只有用户与页面产生互动,才表示页面真的已经“完全加载”了。

在用户对页面进行操作前,只有最终展现的页面才被认为是有效页面,之前的所有中间跳转都应该被忽略,不计入历史记录,这样才能避免后退行为出现死锁。

我对这种设计表示理解,这段HTML标准的定义证实了我的推测。因此,原本预想的方案是走不通的,我只能调整需求了。

最终方案是增加一个跳转中间页,由用户点击来进行跳转。

话说Chrome没这个问题,说明Chrome在这一条上其实没有遵循HTML标准规范?

首先感谢开源项目UnblockNeteaseMusic

能听被屏蔽的歌曲靠的就是它,这篇教程只是让使用变得更为方便而已。

解锁的大致原理就是依靠代理的形式,嫁接可用的音频源到网易云音乐里被屏蔽的请求地址上。所以它其实可以适用在任何终端,只不过移动端的不太便利,代理设置只能全局,理想的方式是在路由器上搭建服务,或者发布到自己的云服务器上。

这里不讨论移动端的解决方案,只说PC端的,这个比较简单。

1 安装nodejs

照例node环境还是必需的,安装过程略。

2 打开命令行,执行以下脚本

命令行可以这样打开:右键单击屏幕左下方的windows图标,选择Windows Powershell

然后输入以下内容回车:

1
npx @nondanee/unblockneteasemusic -p 16300

其中,-p 16300是修改服务端口为16300,因为默认的8080很容易在写代码时冲突,端口号可以自选。

3 在网易云音乐客户端中设置代理

在设置菜单中找到自定义代理-HTTP代理,设置服务器为localhost,端口为16300,点击确定即可。

此时会提示重启,重启后已经可以看到周杰伦的歌都不再是灰色了,开始享受吧。


但是很明显有个体验问题:

每次重启电脑,难道都要这么走一遍吗?太麻烦了,开始优化吧。

优化方案一

在合适的位置(比如桌面)新建一个bat文件(新建文本文档,写完以下内容并保存后,把后缀名txt改为bat),比如unlockncm.bat,内容如下:

1
2
start /d "C:\Program Files (x86)\Netease\CloudMusic\cloudmusic.exe" cloudmusic.exe
npx @nondanee/unblockneteasemusic -p 16300

这个批处理文件的作用就是启动网易云音乐的同时启动解锁服务。这样只需要双击bat文件,就可以像之前双击启动网易云音乐那样听歌了。

不过bat文件的图标有点丑,所以可以在bat文件上单击右键然后创建快捷方式。

重命名快捷方式为“网易云音乐”,右键属性——更改图标——找到C:\Program Files (x86)\Netease\CloudMusic\cloudmusic.exe即可将快捷方式的图标变成网易云音乐。

这样一来就跟原来的网易云音乐图标一样,看不出区别了。


但是这样还有个体验问题:powershell这个窗口不能关,否则服务就会关闭,挂了代理的网易云音乐就会瘫痪。

而这个碍眼的窗口挂在那里实在是很碍眼,所以还得优化。

优化方案二

(使用方案二时,将方案一废弃,不要再用这个bat启用网易云音乐了)

在左下角Windows图标上右键,然后点击计算机管理,依次展开系统工具-任务计划程序-任务计划程序库

这时应该可以看到右边有一堆已存在的计划任务,为了避免日后维护麻烦,右键点击任务计划程序库,选择新文件夹,创建一个自己的文件夹,比如user

当然,你可以跳过这一步,直接创建任务。

user上右键选择创建任务。

名称随意,但是用户账户一定要选SYSTEM,操作如图。这样做的目的是让脚本执行时不要弹出窗口。

新建触发器,设置为登录时。

操作为启动程序,内容如下:

1
npx @nondanee/unblockneteasemusic -p 16300

你可以直接把这段命令整个复制到程序或脚本这栏,保存时会自动将参数切割到添加参数里。

条件和设置看需求决定。

这样,每次电脑重启时,服务就都已经存在于系统中了,可以随意打开网易云音乐听歌,感知不到代理服务的存在。

微信小程序的wxss、阿里旗下淘宝、支付宝小程序的acss等等语法很类似原生css,但是在web开发里用惯了动态css语言,再写回原生css很不习惯,尤其是父子样式的嵌套写法非常繁琐。

因此,我希望能有一个自动化构建方案,能够简单地将scss转换成小程序的样式语言。

方案1

以前写微信小程序的依赖库时用过,使用gulp编译,将源码和编译后的代码分别放到src和dist两个目录。gulp会处理src下面的所有文件,将其中的scss转换成css,并将其他所有文件原封不动挪到dist下相应位置。

这里就不详细说了,代码参考Wux

方案2

非常简单直接,使用Webstorm/IDEAFile Watchers功能实时转换。

安装Ruby和sass

确保命令行输入sass -v能出现版本号,安装过程略。

安装File Watchers

到插件市场上搜索并安装(已安装则跳过)

添加scss的转换脚本

现在安装完插件打开项目会自动弹出scss转css的向导,方便了很多。但还需要做一些修改,配置如下:

首先要将生成文件的后缀名改掉,比如这里我的淘宝小程序就得是acss

其次,将Arguments改为:

1
$FileName$:$FileNameWithoutExtension$.acss --no-cache --sourcemap=none --default-encoding utf-8 --style expanded

如果不加--no-cache,scss文件同目录下会出现一个.sass-cache目录。

如果不加--sourcemap=none, scss文件同目录下会出现一个.map文件。

如果不加--default-encoding utf-8, scss文件如果有中文注释转换就会报错。

style可不加,这里用的是无缩进和压缩的风格,反正小程序打包发布时还会压,这里保持可读性。

现在这个scss转换是单独作用于项目的,如果新建一个小程序项目,就需要重新添加(不建议设置成global,容易误伤)。

注意到File Watchers列表的右侧操作栏下方有导入导出按钮,可以将现在配好的设置导出保存,将来新建项目时只要导入一下就行了。


之后还有一个问题,如果我手动将编译后的css(即wxss或者acss,下略)文件删除,scss文件不改动的话,就不会重新编译出css文件。
或者万一监听失效或者不够及时,css还有可能是旧的。
所以还需要一个命令,用来将整个目录下的scss文件统一转换,确保没有遗漏和保持代码最新。

不过我看了半天sasssass-convert的文档,没有找到一个可用的写法,能让命令行遍历指定目录下的所有scss文件,将其转换成css放到源文件所在目录,并且将后缀名改为wxss或者acss

所以遍历这个行为只能交给nodejs来实现,代码如下:

创建编译脚本build/scss-convert.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
var path = require("path")
var fs = require("fs")
const { exec } = require('child_process')

const basePath = path.resolve(__dirname, '../')

function mapDir(dir, callback, finish) {
fs.readdir(dir, function(err, files) {
if (err) {
console.error(err)
return
}
files.forEach((filename, index) => {
let pathname = path.join(dir, filename)
fs.stat(pathname, (err, stats) => { // 读取文件信息
if (err) {
console.log('获取文件stats失败')
return
}
if (stats.isDirectory()) {
mapDir(pathname, callback, finish)
} else if (stats.isFile()) {
if (!['.scss'].includes(path.extname(pathname))) {
return
}
callback(pathname)
}
})
if (index === files.length - 1) {
finish && finish()
}
})
})
}

mapDir(
basePath,
function (file) {
const newFileWithoutExt = path.basename(file, '.scss')
if (newFileWithoutExt.startsWith('_')) {
return // 按照scss规则,下划线开头的文件不会生成css
}
// exec可以让nodejs执行外部命令
exec(`sass --no-cache --sourcemap=none --default-encoding utf-8 --style expanded ${file}:${newFileWithoutExt}.acss`, {
cwd: path.dirname(file) // 不写这个会导致生成的文件出现在根目录
}, (err, stdout, stderr) => {
if (err) {
console.log(err)
return
}
console.log(`stdout: ${stdout}`)
})
},
function() {
// console.log('xxx文件目录遍历完了')
}
)

package.json里添加一条script:

1
2
3
"scripts": {
"scss": "node build/scss-convert",
},

最近尝试用TypeScript写一个工具库,需要实现这样一个场景:

  1. 声明一个抽象类Parent

  2. 声明一组子类ChildA、ChildB继承这个Parent,实现它的抽象方法

  3. 实现一个方法,根据参数返回对应的子类

  4. 用拿到的子类创建实例

代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
abstract class Animal {
abstract makeSound(): void
}
class Dog extends Animal {
makeSound(): void {
console.log('woof')
}
}
class Cat extends Animal {
makeSound(): void {
console.log('meow')
}
}
const getAnimal = (name: string) => {
if (name === 'cat') return Cat
return Dog
}

const animal = new (getAnimal('dog'))()
animal.makeSound() // woof

首先注意new后面getAnimal方法的执行需要用括号包起来,否则将得到以下错误:

1
2
TS2350: Only a void function can be called with the 'new' keyword.
ESLint: A constructor name should not start with a lowercase letter.

随后按照严谨的做法,我尝试给这个getAnimal方法添加类型约束:

1
2
3
4
const getAnimal = (name: string): Animal => {
if (name === 'cat') return Cat
return Dog
}

马上得到了错误提示:

1
2
TS2351: This expression is not constructable. 
Type 'Animal' has no construct signatures.

这样写的错误在于,Animal描述的应当是一个由其创建的实例的类型(或者说类)。

比如改写成下面这样就没有问题了:

1
2
3
4
const getAnimalInstance = (name: string): Animal => {
if (name === 'cat') return new Cat()
return new Dog()
}

而上面的getAnimal方法返回的不是实例,是类(构造器)本身。

描述这种类型,需要用到TypeScript的new ()语法

1
2
3
4
const getAnimal = (name: string): { new (): Animal } => {
if (name === 'cat') return Cat
return Dog
}

或者这样写:

1
2
3
4
const getAnimal = (name: string): new () => Animal => {
if (name === 'cat') return Cat
return Dog
}

表示这个方法返回的是一个构造器,这个构造器可以创造出一个类型是Animal的实例。

最后的示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
abstract class Animal {
abstract makeSound(): void
}
class Dog extends Animal {
makeSound(): void {
console.log('woof')
}
}
class Cat extends Animal {
makeSound(): void {
console.log('meow')
}
}
const getAnimal = (name: string): { new (): Animal } => {
if (name === 'cat') return Cat
return Dog
}

const getAnimalInstance = (name: string): Animal => {
if (name === 'cat') return new Cat()
return new Dog()
}

console.log('sound:', new (getAnimal('dog'))().makeSound())
console.log('sound:', getAnimalInstance('cat').makeSound())

打开在线示例查看执行结果:

https://codepen.io/mirari/pen/xxbrvVd

点击Console打开控制台

参考文档:

TypeScript-泛型-在泛型里使用类类型

之前遇到了一个遮罩层级的奇怪问题。在dev环境能在弹窗前正常显示的全屏动画,在编译打包以后实际运行时跑到了弹窗的后面。
弹窗的z-index是500,按理说将动画放到501就应该能显示了,结果发现实际编译出来的z-index仅仅是1。
最后发现这个z-index被重新计算是cssnano的杰作。因为它只处理了项目本身的样式,而不包括第三方库的样式。

cssnano的配置如下:

1
2
3
4
5
"cssnano": {
"preset": "advanced",
"autoprefixer": false,
"postcss-zindex": false
}

可见z-index自动重新计算的配置应该是关闭的。
但是这配置实际上是从webpack2的旧项目上拷过来的,而新版本的cssnano已经悄悄修改了配置方式,这个写法等于什么都没配。
修改成以下配置就可以解决问题了:

1
2
3
4
5
6
"cssnano": {
"cssnano-preset-advanced": {
"zindex": false,
"autoprefixer": false
}
}

最近有个转盘抽奖的需求,搜了一下现有的轮子,有的是用jQuery的动画函数实现的,有的是用canvas绘图然后再用高频率的setTimeout调用旋转方法,前者太老了没法简单移植到vue项目,后者感觉性能表现可能不会太好。也有一些用CSS动画的方案,设计了加速-匀速-减速三个动画,再计算偏转角度让三个动画尽可能无缝衔接,但我感觉绕了大远路,应该有更简单轻量的实现方案。个人更倾向于用transition来实现,不过网上的例子感觉还不够好,有的倾斜文字都没有对齐,最后还是自己手写了一个。核心思路是用transition以及rotate实现旋转动画,使用transition-origin和rotate绘制出定位较为精确的轮盘奖项,同时支持动态设置奖品数量。