- 微信3D小游戏下HUD实现及UI控件制作
[图片] 由于微信小游戏开发/运行环境的种种限制,使其UI实现很麻烦。官方出过一个教程《微信3D小游戏下的HUD绘制的经验分享》,里面对数学原理、三维理论、绘制分析讲解偏重,但对具体实现操作偏少,从后面留言看,对新手不友好吧。如果没有基于opengl/webgl实现过一次(或完整阅读过类似实现代码/ 伪代码),要依据该教程实现自己的HUD,还是有很多路子要折腾一番的。 业余时间利用three.js折腾的小游戏里,也实现了一小套UI控件,之前写的《球球作战之迷宫系统》文章后面也有网友咨询它的实现方法,当时简单回复过原理。下面把基于我业余经验,实现的HUD及用同样原理实现的UI控件完整地分享出来,与官方教程差异在:偏重实操,提供可直接使用的控件源代码。控件代码还有待优化/完善,希望能起到抛砖引玉的作用。 实现过程: 1,单独创建一个scene和一个正交投影相机,代码如下: this.hudScene = new THREE.Scene(); this.orthCamera = new THREE.OrthographicCamera(window.innerWidth / - 2, window.innerWidth / 2,window.innerHeight / 2, window.innerHeight / - 2, 0, 30 ); this.orthCamera.position.set( 0, 0, 3 ); 我们将在这个场景中创建HUD(UI元素);该相机的投影中心正对屏幕中心,设置相机位置使其镜头面平行于手机屏幕,便于后面设置HUD位置(UI布局)。 2,在上面scene中创建mesh对象(plane或box),将文字/图片等绘制或纹理依附其上(根据需要控制好透明区域),代码如下: //创建画布用于绘制HUD信息(UI图片/文字) this.labelCanvas = document.createElement(‘canvas’); this.labelContext = this.labelCanvas.getContext(‘2d’); this.labelContext.font = this.opt.font || ‘24px Arial’; this.labelContext.fillStyle = this.opt.style || ‘rgba( 255, 0, 0, 1 )’; // 以上面的HUD文字(UI)为基础创建纹理 this.labelTexture = new THREE.Texture( this.labelCanvas ); this.labelTexture.magFilter = THREE.LinearFilter; this.labelTexture.minFilter = THREE.LinearFilter; this.labelTexture.needsUpdate = true; // 创建 3D mesh 对象, 添加到 hudScene 空间 let labelMaterial = new THREE.MeshBasicMaterial( { map: this.labelTexture, transparent: true, opacity: this.opt.opacity || 0.80, color: 0xffffff} ); let labelPlane = new THREE.PlaneBufferGeometry( this.labelCanvas.width, this.labelCanvas.height ); this.labelMesh = new THREE.Mesh( labelPlane, labelMaterial ); this.hudScene.add( this.labelMesh ); 3,使用该场景、相机,在主renderer上进行渲染: renderer.render( this.hudScene, this.orthCamera ); 以上是绘制的实现,对于UI控件还需要支持触控事件。实现过程如下: 1,用微信API获取屏幕坐标: 在wx.onTouchStart或wx.onTouchEnd的回调中获取touch信息,主要是触摸的屏幕x,y坐标; for (let i = 0; i < res.touches.length; i++) { if (res.touches[i].clientX === undefined) res.touches[i].clientX = touches[i].screenX; if (res.touches[i].clientY === undefined) res.touches[i].clientY = touches[i].screenY; } 2,将屏幕二维坐标转换到HUD场景的三维空间坐标: this.mouse = new THREE.Vector2(); this.mouse.x = ( touch.clientX / window.innerWidth ) * 2 - 1; this.mouse.y = - ( touch.clientY / window.innerHeight ) * 2 + 1; 触控点在三维空间的 z 坐标使用0或相机的z值 3,从该三维空间坐标点发射射线判断是否击中HUD(UI元素): 射线从触控点转换出的场景中的三维点开始,平行于Z轴,向投影面而去,代码如下. this.raycaster = new THREE.Raycaster(); this.raycaster.setFromCamera(this.mouse, this. orthCamera ); let intersects = this.raycaster.intersectObjects( this.hudScene.children ); if ( intersects.length > 0 && intersects[0].object && this.opt.enableHit) { for(let i = 0; i < intersects.length; i++){ let hittedObj = intersects[i].object; if (hittedObj && hittedObj.id === this.id && typeof this.opt.hittedCB === ‘function’) { this.opt.hittedCB(touch, this.mouse, hittedObj, self); return true; } } } return false; 以上是实现的核心代码部分。要包装成可用的控件,还需配合wx的API进行调用,及做好UI资源的利用,布局管理(不同分辨率下自动适配,涉及到坐标动态转换),API设计等。github里上传了我在用的实现,还比较简陋,不过已经可用,控件的图片资源使用的是微信官方小游戏demo里的图片,github地址:https://github.com/aceway/wx3DUIComponent 使用说明: 1,创建管理控件: this.components = new THREE.UIComponent({}); 2,创建按钮: this.optHud = {}; this.tipsHUD = self.components.addButton(optHud); this.showHUD = true; // 需要时更新文字,字体等… this.tipsHUD.update(“Game Over!”); let optExit = {}; Object.assign(optExit , CFG.UI.BTN_EXIT); optExit.hittedCB = (touch, mouse, obj, theBtn)=>{ wx.exitMiniProgram({}); }; this.btnExit = this.components.addButton(optExit); 3,触控事件: theGame = this; wx.onTouchEnd((res)=>{ if (theGame.components.hitButtonTest(res.changedTouches)){ return; } … }); 就这些,欢迎关注微信公众号: HTML53D [图片]
2019-03-03 - 1个开发如何撑起一个过亿用户的小程序
2018年12月,腾讯相册累计用户量突破[代码]1亿[代码],月活1200万,阿拉丁指数排行 [代码]Top 30[代码],已经成为小程序生态的重量级玩家。 三个多月来,腾讯相册围绕【在微信分享相册照片】这一核心场景,快速优化和新增一系列社交化功能,配合适当的运营,实现累计用户量突破[代码]1亿[代码],大大超过预期。 [图片] (腾讯相册用户量破亿) 可是,谁曾想到,这样一个亿级体量的小程序,竟然是一个开发做出来的?他又是有哪般“绝技”,可以一个人撑起一个用户过亿的小程序? 后台人力紧缺,怎么办? 当我第一次见到腾讯相册小程序的开发David(化名)时,他显得忧心忡忡。 “年底的目标是要过千万的用户,但现在只有几位前端和后台开发。不仅如此,我们的后台开发还不是百分百能够投入到这个项目,大部分时间要抽身支援其它项目,人力非常紧缺。此外,原有后台系统有不少历史包袱,在原有架构上做新的社交化功能开发是不现实的。怎么办? “要不试试’小程序·云开发’吧,只需要前端就可以把小程序搞起,正好解决我们缺后台的难题。” 于是,David作为腾讯相册前端开发团队的骨干,担当起用小程序·云开发实现腾讯相册小程序社交化功能的重任。 “第一次接触到’小程序·云开发‘时,觉得这个东西(小程序·云开发)理念挺新颖的———小程序无服务开发模式。在一般的小程序开发中,有三大功能小程序开无法绕开后台的帮助,它门分别是数据读取、文件管理以及敏感逻辑的处理(如权限)。因此,传统的开发模式,在小程序端都必须发送请求到后台进行鉴权,并且处理相关的文件或者数据。即使使用 Node 来搭建后端服务,也需要耗费不少的搭基础架构、后期运维的工作量。” [图片] “而小程序·云开发则释放了小程序开发者的手脚,赋予了开发者安全、稳定读取数据、上传文件和控制权限的能力,其它的负载、容灾、监控等,我们小程序开发者只需要关注业务逻辑,专注写好业务逻辑即可,其他的事情完全可以不用操心了!本来我还一筹莫展,了解完’小程序·云开发‘的产品原理以后,我瞬间心里有谱了。” 二维码扫不出来了 [图片] 道路总是不平坦的 ,在腾讯相册小程序通往用户破亿的道路上,困难重重。 由于腾讯相册的二维码需要带上的信息量过大,因此它的二维码显得密密麻麻。这种密集的二维码在某些Android机型下,容易出现无法识别小程序的问题。 这严重制约了腾讯相册小程序分享获客的能力。 [图片] (需要存储name, ownerid, page等大量信息) 这个事情的解决并不难,只需后台开发把数据先存储到数据库中,然后把数据id放到分享链接上,这样,链接便可以转化成32个字符的短链接,让二维码看起来没有那么密集了。 但由于后台人力不足,于是前端开发David利用小程序· 云开发的数据库存储能力,通过调用db.collection(‘qr’).add接口,快速实现数据在数据库中的存储。 [图片] (云开发数据库,格式类似MongoDB) [图片] (云开发数据库索引,可加快数据读取) [图片] 此外,腾讯相册还借住小程序·云开发的云函数能力,生成辨识度更高的小程序码(小程序码文档),用以在朋友圈里传播分享。 [图片] (生成小程序码的云函数逻辑) [图片] (优化后的分享图片和小程序码) 2天上线评论点赞功能 [图片] (评论与点赞功能) 腾讯相册在微信端的核心应用场景是“在微信做分享相册照片”,为了增强腾讯相册用户在微信里的互动,提升用户粘性和留存,腾讯相册决定新增评论与点赞功能,并且把聊天评论就直接在微信聊天窗口里面实现。 在这里,腾讯相册的David面临了两个选择,一是按原开发模式(前台开发-后台开发-前后台联调)做这个功能,面临的问题便是开发周期长、缺后台、迭代速度慢;另一个就是借助云开发的能力,亲自上阵。 为了加快产品迭代速度,David决定采取云开发的开发方式。评论、点赞通过云开发的数据库插入和查询接口,如db.collection(‘comment’).add,很快就实现了。 但遇到棘手的问题是,对于一些敏感的操作比如删除和编辑评论、点赞这些敏感操作,还需要到用户的鉴权操作,而这些鉴权信息,都在原有的后台。此时,云函数的路由功能便发挥出作用了。 [图片] (评论点赞逻辑) 用户进行评论点赞的时候,会在小程序端发起请求调用云函数并带上 [代码]openid[代码],云函数用 [代码]openid[代码] 查询原有的后台服务看看该用户是否有权限进行操作,如果用户具有权限,则把评论和点赞的数据都写入云开发的数据库中。 就这样,借住小程序·云开发的能力,腾讯相册仅用2天时间,完成了在传统开发模式下需要1周多工作量的开发工作。 原有开发模式 云开发全栈开发 工作量 后台1周(微信登录态校验+业务逻辑server开发)+ 前后台联调1天 1 - 2天,无需联调 什么是小程序·云开发? 小程序·云开发是基于腾讯云研发的全新 云开发 Tencent Cloud Base(简称 TCB) 服务,腾讯云与微信力推的这套云开发服务的诞生,恰逢其时地帮助腾讯相册走出开发效率的瓶颈。 [图片] (基于腾讯云的云开发) 小程序云目前提供三大基础能力支持: 云函数:充当了后台的角色,开发者可以在上面用 Node (后续还会支持 PHP, Python 等)写后台逻辑,跟微信私有协议天然鉴权,可以云函数里直接获取 [代码]appid[代码], [代码]openid[代码], [代码]unionid[代码] 等重要鉴权信息,大大简化了小程序后台的开发工作量。 数据库:一个既可在小程序前端操作,也能在云函数中读写的文档型数据库,提供控制台可视化管理 文件存储:在小程序前端和云函数里都可以直接上传/下载云端文件,提供控制台可视化管理 如果你是全新开发的小程序,架构非常轻量简单,如下图。 [图片] 如果你是已有的小程序,部份需要跟原有后台交互的功能,完全可把云函数作为路由,节省获取openid 等用户信息的逻辑,如下图: [图片] 相关资料 小程序·云开发解决方案 小程序·云开发文档
2019-02-19 - 云开发实战分享|随手记Lite小程序
作者:锋少 一、做一款轻便的备忘录小工具 有次在外地出差开会,台上的演讲者妙语连珠,分享的颇具启发性,我想把这些分享都记录下来,但一时找不到合适的记录地方,于是和身边大多数人一样,打开微信在 [代码]文件传输助手[代码]或 [代码]亲人的微信[代码]开始记录。但是这种形式的记录没有形成一个完整的记录体系,且时间久了很难再找到。 会后我在软件市场找了一圈,都没有找到特别合适的软件应用:一方面它们功能太繁琐复杂,远超我的需求;另一方面像这种不是特别高频使用的 [代码]APP[代码],说实话,我真不太愿意专门为此下载安装。 于是我想到小程序 —— [代码]轻量[代码]、 [代码]便捷[代码]、 [代码]即开即用[代码],用小程序开发这么一款这样做备忘录的小工具,非常合适。 二、主要功能 创建备忘录:内容快速记录,支持表情和图片,自动获取标题和时间,可选择记录位置 备忘录查询:历史记录按时间排序,允许记录回查,导航到记录位置 备忘录修改:允许重复编辑修改 三、随手记Lite功能实现 3.1、准备工作 1、注册微信小程序账号: 方式一:直接注册(https://mp.weixin.qq.com/wxopen/waregister?action=step1) 方式二:已经有微信公众号(已认证)朋友可以直接【登录公众号】 -> 【小程序管理】 -> 【添加】->【快速注册并认证小程序】 注册完成后,找到小程序的 [代码]AppID[代码]和 [代码]AppSecret[代码] [图片] 2、下载微信开发者工具、创建项目 ,打开开发者工具,键入项目目录、项目名称、刚才的 [代码]AppID[代码],此时项目创建成功,然后点击开发者工具上方的【云开发】开通云开发。小程序·云开发官方地址(https://developers.weixin.qq.com/miniprogram/dev/wxcloud/basis/getting-started.html) 3.2功能实现一: 创建备忘录(用户文本及图片内容的上传存储) 1)功能简要描述:对于随手记Lite来说,为用户提供文本及图片上传,快速记录,是最基本的功能 2)从用户端到云开发的信息处理流程图及描述 [图片] 如果记录中包含图片,则先上传图片,将返回的 [代码]fileID[代码]添加到数组,最后将数组和记录内容上传到云数据库;如果记录中不包含图片则直接将记录内容上传到云数据库。 3)功能核心代码 [代码]// 用户填写信息后,提交事件 fromSubmit: function(e) { let submitInfo = e.detail.value let categoryId = Number(submitInfo.categoryIndex) var title = submitInfo.title ? submitInfo.title : this.data.datas.title2 var images = []; for (vari = 0; i < this.data.uploadImages.length; i++) { let randString = Math.floor(Math.random() * 1000000).toString() + '.png' wx.cloud.uploadFile({ // 以昵称创建云存储文件夹路径 cloudPath: wx.getStorageSync("wxUserInfo").nickName + '/' + Date.now() + '.png', filePath: this.data.uploadImages[i], success: res => { images = images.concat(res.fileID) // 图片全部上传完毕,上传记录数据 if (images.length == this.data.uploadImages.length) { db.collection('allnote') .add({ data: { "titlename": title, "createtime": util.formatTime2(new Date()), "updatetime": null, "address": submitInfo.locationName ? submitInfo.locationName : "未选择位置", "latAndLong": this.data.datas.latAndLong, "content": submitInfo.content, "images": images, 'nickName': wx.getStorageSync("wxUserInfo").nickName, 'avatar': wx.getStorageSync("wxUserInfo").avatarUrl, 'province': wx.getStorageSync("wxUserInfo").province, } }) .then(res => { wx.hideLoading() wx.navigateBack({}) }) } }, fail: console.error }) } } }, [代码] 3.2 功能实现二:记录查询功能 1)功能简要描述:已经创建备忘录的用户,可以按照时间顺序查看所有记录及任意记录的详细内容 2)从用户端到云开发的信息处理流程图及描述 [图片] 小程序端用户上传自己的 [代码]userId[代码]到云函数,云函数根据该 [代码]userId[代码]到云数据库请求对应数据,数据返回到云函数后,云函数进行排序和时间格式截取处理,最后将数据返回到小程序端。 3)功能核心代码 [代码]<!--云函数端--> // 云函数入口函数 exports.main = async(event, context) => { return await new Promise(function(resolve, reject) { var notes = [] var notesNew = [] db.collection('allnote').where({ _openid: event.userOpenid }).get().then(res => { console.log(res) notes = res.data if (res.data) { // 由于时间格式问题,orderBy('createtime', 'desc')排序无效,所以使用以下排序方式 // 按照时间倒叙排列 notes.sort(function(a, b) { return Date.parse(b.createtime) - Date.parse(a.createtime); }); // 显示的时间格式转换,截取秒 for (var i = 0; i < res.data.length; i++) { let showTime = res.data[i].createtime.substring(0, res.data[i].createtime.length - 3) notes[i].showCreateTime = showTime // 数据解码 notes[i].titlename = decodeURI(notes[i].titlename) // 将加入新格式的时间放入新的数组 notesNew.push(notes[i]) } // 返回新的数组 resolve(notesNew) } }).catch(error => { reject(error) }) }) } <!--小程序端--> // 请求心得数据 requestNoteData: function() { let _this = this; wx.cloud.callFunction({ name: 'getMyNote', data: { userOpenid: wx.getStorageSync('userId'), skip:0 } }).then(res => { console.log(res) _this.setData({ notes: res.result }) }).catch(err => { console.log(err) }) }, [代码] 3.3 功能实现三:备忘录修改功能 1)功能简要描述:对于记录详细内容,用户除了可以查看时间、位置和内容之外,还可以进行内容的编辑和删除记录 2)从用户端到云开发的信息处理流程图及描述 [图片] 用户修改记录时,直接上传新的记录内容和记录id调用 [代码].update[代码]进行即可。 3)功能核心代码 [代码]<!--记录详情页数据获取--> onLoad: function(options) { // 根基记录id获取记录详情 db.collection('allnote') .where({ _id: options._id }) .get() .then(res => { console.log(res) noteData = res.data[0] // 显示的时间格式转换,截取秒 let showTime = res.data[0].createtime.substring(0, res.data[0].createtime.length - 3) noteData.createtime = showTime // 数据解码,如果上传的时候没有encodeURI,则不需要decodeURI noteData.titlename = decodeURI(noteData.titlename) noteData.content = decodeURI(noteData.content) _this.setData({ noteData: noteData }) wx.hideLoading() }) }, <!--更新单条记录--> // 单条记录提交更新 fromSubmit: function(e) { let _this = this; var submitInfo = e.detail.value db.collection('allnote').doc(_this.data.noteData._id).update({ data: { titlename: submitInfo.name, content: submitInfo.content } }) .then(res => { wx.navigateBack({ }) }) .catch(console.error) }, [代码] 四、小结 其实一开始,我的随手记LIte小程序是计划用小程序+WEB后台开发实现的,从买域名(备案···)到后台开发环境搭建占到了这个项目的2/3的时间,且域名、服务器、CA证书都需要管理续费。 有一天我收到公众号推送小程序·云开发的消息,打开一看太惊喜了,用1天时间了解学习了下,第二天我便将后台切换到了云开发。 我可以将大部分精力都放在业务的实现上,可以说是零部署,零维护,也不需要操心域名和服务器相关的东西,而且云控制台的数据和文件都是可视化管理,用户登录寥寥几行代码就可实现,常规的数据读写都封装好了接口。 期待小程序·云开发开放出更多的接口和功能··· 五、项目预览 [图片]
2019-03-07 - 如何不用服务器来开发一个小游戏
作者:代长新 我是代长新,来自上海享物说,主要负责游戏客户端研发。 [图片] 享物说是一个大家可以互相赠送物品,有趣、不花钱的社区平台。为了创造更好的社区氛围,我们决定通过小游戏来增加社区的趣味性和互动性。 《乐享花园》是我们在小游戏领域的第一个实践。这个游戏,从立项到做完,准确的说是客户端做完,我们一共用了3天的时间。 但是,当时我们种花浇花、领水滴任务都是通过浏览器缓存实现的,如果要上线还要等服务端人员到位,否则玩家清理一下手机,自己种的花就没了。但等我们服务端人员到位,再到游戏上线,就是几周以后的事情了。 小游戏开发之痛:无法摆脱对服务端的依赖 相信,这也是大部分小游戏开发时会遇到的问题 —— 功能很简单,但就是摆脱不了对服务端的依赖。如下图: [图片] 具体来说,小游戏对服务端的依赖主要有以下两个方面: 1、微信接口只支持在服务端调用 这就意味着,我们必须为这些接口架设一个中转服务器。如果没有这个中转服务器,我们就没法做用户登录,没法获取用户头像、名称信息,也拿不到access_token,更没有办法调用其他微信接口,如内容审查。 2、游戏功能实现需要服务器开发 对于很多小游戏来说,我们唯一用到服务端的地方就是,储存当前的关卡进度,展示一个世界排行,就可以了。而想要实现这么一个简单的需求时,你会发现,隔行如隔山。 如何用云开发解决小游戏在服务端痛点? 1、云函数实现微信接口调用 曾经,我想过绕开服务器,直接通过客户端请求微信接口,结果踩了一个坑。 当时做的是聊天功能,需要对玩家发送的消息进行内容审查。我看完了接口文档,就跑过去跟服务器同学说,内容审查我这边全部做掉就可以,他那边不需要做额外的处理。 [图片] 等我们调通,上了体验版,一打开报错,我才想起来,这个接口文档的上面,有一行小字,而且颜色是灰色的,上面写着:此接口应在后端服务器调用。 第一次看到这句话,还以为它只不过是一个警告,所以根本没把它放在心上,哪知道它居然是一个error!而在这之前,我还特意做了一些我认为比较人性化的设计,比如使用这个接口需要一个密钥,这个密钥是有有效期的,当密钥过期的时候,我会把玩家发送的内容保存起来,向后端拉取新的密钥后,再发送出去,这样对于玩家来说,整个过程是无感知的。而现在则意味着所有这些都要服务器去实现了。 后来,我通过云开发来实现多有接口调用,事情就简单多了。 [图片] 就拿登录来说吧。 由于云函数具有微信天然鉴权的能力,可以直接返回openid,这一点对做登录确实很方便。乐享花园需要和享物说平台打通小红花积分数据,所以需要用户的unionid信息,这一步也是在云函数中实现的。 还有access_token,就是刚才用到的密钥,**为什么要单独说这个密钥呢?因为它会用到云函数特别有意思的功能,那就是定时触发器。**由于这个密钥是有两个小时有效期的,我们设定一个小时间隔定时刷新,保存到数据库中,用的时候直接从数据库中取出来就可以了,这样可以保证密钥永远是不过期的。 [图片] 通过云开发,为微信接口准备的中转服务器就不需要了;更重要的是,**服务端与微信接口分离,无需关心客户端场景。**不管这个客户端,是来自h5游戏,还是来自小游戏环境,对于服务端来说,都是一样的,再也不需要为客户端提供这样那样的权限接口。 2、云函数+数据库,实现全局排行榜功能 正如前面提到的痛点,小游戏开发对服务端的另一个依赖是游戏功能的实现。对于大部分小游戏来说,我们唯一用到服务端的地方就是:保存用户数据,展示一个世界排行榜。而如果用传统服务器实现这些功能的话,你会发现需要了解的后端架构知识非常庞大。 有次,我到服务端同学的旁边,原本是打算diss他的,因为我功能已经写完了,他还不知道在忙些什么东西。这时我看到他在做什么呢 —— 一边写dockfile文件,一边写linux命令,一边打开Postman调试,完了后发邮件给运维说要执行几个mysql语句。 而所有这些都还没有涉及到他要开发的游戏功能! 所以说,一门后端语言从会写,到可以放到生产环境中,是两个完全不一样的概念。 云开发提供了数据库、云函数、云存储,通过这些能力,我们完全可以取代服务器来实现游戏功能。 在《乐享花园》里,我们通过云开发实现了全民成语接龙这个游戏功能,并且只用了2个云函数就实现了我们对服务器的全部需求。这里简单介绍一下这两个云函数: **第一个云函数是用来展示世界排行榜。**由于云函数拉取数据库的条目是有限制的,最大是100条,其实这个已经足够满足需求了;当然了,你要说我们的客户端很牛,性能不是问题,数据什么的先给我来个2000条,也不是不可以,这里做个处理就可以了。 另外在检索数据库数据时,这个过程会很慢,一定要记得,在后台添加数据库索引,可以把这个过程理解为通过磁盘换取CPU计算。这样速度会快很多。 [图片] **第二个云函数是用来上报玩家数据。**这个比较简单,一行代码搞定。 [图片] 就这样从微信接口调用,到游戏功能开发,一款不需要服务器的小游戏就全部开发完成了。 小结 其实,云开发可以使用的业务场景,还有很多,比如, 绕过微信https域名请求限制 存放游戏的全局设置 保存玩家的个性化数据 。。。 作为开发者,也希望云开发未来,可以提供更多的业务场景支持,比如 websocket,刚才说的聊天服务器,就可以省掉了; 帧同步,实时对战类游戏的实现,就不再有压力; 日志服务,方便统计,和排查玩家的行为,方便游戏迭代优化; 大数据统计分析,可以做一些事件漏斗等等~ 这样小游戏的研发门槛,就降得很低很低了!
2019-03-06 - 如何写出一手好的小程序之多端架构篇
本文大致需要 14m+ 的阅读时间。 简述小程序的通信体系 为了大家能更好的开发出一些高质量、高性能的小程序,这里带大家理解一下小程序在不同端上架构体系的区分,更好的让大家理解小程序一些特有的代码写作方式。 整个小程序开发生态主要可以分为两部分: 桌面 nwjs 的微信开发者工具(PC 端) 移动 APP 的正式运行环境 一开始的考虑是使用双线程模型来解决安全和可控性问题。不过,随着开发的复杂度提升,原有的双线程通信耗时对于一些高性能的小程序来说,变得有些不可接受。也就是每次更新 UI 都是通过 webview 来手动调用 API 实现更新。原始的基础架构,可以参考官方图: [图片] 不过上面那张图其实有点误导行为,因为,webview 渲染执行在手机端上其实是内核来操作的,webview 只是内核暴露的一下 DOM/BOM 接口而已。所以,这里就有一个性能突破点就是,JSCore 能否通过 Native 层直接拿到内核的相关接口?答案是可以的,所以上面那种图其实可以简单的再进行一下相关划分,新的如图所示: [图片] 简单来说就是,内核改改,然后将规范的 webview 接口,选择性的抽一份给 JsCore 调用。但是,有个限制是 Android 端比较自由,通过 V8 提供 plugin 机制可以这么做,而 IOS 上,苹果爸爸是不允许的,除非你用的是 IOS 原生组件,这样的话就会扯到同层渲染这个逻辑。其实他们的底层内容都是一致的。 后面为了大家能更好理解在小程序具体开发过程中,手机端调试和在开发者工具调试的大致区分,下面我们来分析一下两者各自的执行逻辑。 tl;dr 开发者工具 通信体系 (只能采用双向通信) 即,所有指令都是通过 appservice <=> nwjs 中间层 <=> webview Native 端运行的通信体系: 小程序基础通信:双向通信-- ( core <=> webview <=> intermedia <=> appservice ) 高阶组件通信:单向通信体系 ( appservice <= android/Swift => core) JSCore 具体执行 appservice 的逻辑内容 开发者工具的通信模式 一开始考虑到安全可控的原因使用的是双线程模型,简单来说你的所有 JS 执行都是在 JSCore 中完成的,无论是绑定的事件、属性、DOM操作等,都是。 开发者工具,主要是运行在 PC 端,它内部是使用 nwjs 来做,不过为了更好的理解,这里,直接按照 nwjs 的大致技术来讲。开发者工具使用的架构是 基于 nwjs 来管理一个 webviewPool,通过 webviewPool 中,实现 appservice_webview 和 content_webview。 所以在小程序上的一些性能难点,开发者工具上并不会构成很大的问题。比如说,不会有 canvas 元素上不能放置 div,video 元素不能设置自定义控件等。整个架构如图: [图片] 当你打开开发者工具时,你第一眼看见的其实是 appservice_webview 中的 [代码]Console[代码] 内容。 [图片] content_webview 对外其实没必要暴露出来,因为里面执行的小程序底层的基础库和 开发者实际写的代码关系不大。大家理解的话,可以就把显示的 WXML 假想为 content_webview。 [图片] 当你在实际预览页面执行逻辑时,都是通过 content_webview 把对应触发的信令事件传递给 service_webview。因为是双线程通信,这里只要涉及到 DOM 事件处理或者其他数据通信的都是异步的,这点在写代码的时候,其实非常重要。 如果在开发时,需要什么困难,欢迎联系:开发者专区 | 微信开放社区 IOS/Android 协议分析 前面简单了解了开发者工具上,小程序模拟的架构。而实际运行到手机上,里面的架构设计可能又会有所不同。主要的原因有: IOS 和 Android 对于 webview 的渲染逻辑不同 手机上性能瓶颈,JS 原始不适合高性能计算 video 等特殊元素上不能被其他 div 覆盖 … 一开始做小程序的双线程架构和开发者工具比较类似,content_webview 控制页面渲染,appservice 在手机上使用 JSCore 来进行执行。它的默认架构图其实就是这个: [图片] 但是,随着用户量的满满增多,对小程序的期望也就越高: 小程序的性能是被狗吃了么? 小程序打开速度能快一点么? 小程序的包大小为什么这么小? … 这些,我们都知道,所以都在慢慢一点一点的优化。考虑到原生 webview 的渲染性能很差,组内大神 rex 提出了使用同层渲染来解决性能问题。这个办法,不仅搞定了 video 上不能覆盖其他元素,也提高了一下组件渲染的性能。 开发者在手机上具体开发时,对于某些 高阶组件,像 video、canvas 之类的,需要注意它们的通信架构和上面的双线程通信来说,有了一些本质上的区别。为了性能,这里底层使用的是原生组件来进行渲染。这里的通信成本其实就回归到 native 和 appservice 的通信。 为了大家更好的理解 appservice 和 native 的关系,这里顺便简单介绍一下 JSCore 的相关执行方法。 JSCore 深入浅出 在 IOS 和 Android 上,都提供了 JSCore 这项工程技术,目的是为了独立运行 JS 代码,而且还提供了 JSCore 和 Native 通信的接口。这就意味着,通过 Native 调起一个 JSCore,可以很好的实现 Native 逻辑代码的日常变更,而不需要过分的依靠发版本来解决对应的问题,其实如果不是特别严谨,也可以直接说是一种 "热更新" 机制。 在 Android 和 IOS 平台都提供了各自运行的 JSCore,在国内大环境下运行的工程库为: Anroid: 国内平台较为分裂,不过由于其使用的都是 Google 的 Android 平台,所以,大部分都是基于 chromium 内核基础上,加上中间层来实现的。在腾讯内部通常使用的是 V8 JSCore。 IOS: 在 IOS 平台上,由于是一整个生态闭源,在使用时,只能是基于系统内嵌的 webkit 引擎来执行,提供 webkit-JavaScriptCore 来完成。 这里我们主要以具有官方文档的 webkit-JavaScriptCore 来进行讲解。 JSCore 核心基础 普遍意义上的 JSCore 执行架构可以分为三部分 JSVirtualMachine、JSContext、JSValue。由这三者构成了 JSCore 的执行内容。具体解释参考如下: JSVirtualMachine: 它通过实例化一个 VM 环境来执行 js 代码,如果你有多个 js 需要执行,就需要实例化多个 VM。并且需要注意这几个 VM 之间是不能相互交互的,因为容易出现 GC 问题。 JSContext: jsContext 是 js代码执行的上下文对象,相当于一个 webview 中的 window 对象。在同一个 VM 中,你可以传递不同的 Context。 JSValue: 和 WASM 类似,JsValue 主要就是为了解决 JS 数据类型和 swift 数据类型之间的相互映射。也就是说任何挂载在 jsContext 的内容都是 JSValue 类型,swift 在内部自动实现了和 JS 之间的类型转换。 大体内容可以参考这张架构图: [图片] 当然,除了正常的执行逻辑的上述是三个架构体外,还有提供接口协议的类架构。 JSExport: 它 是 JSCore 里面,用来暴露 native 接口的一个 protocol。简单来说,它会直接将 native 的相关属性和方法,直接转换成 prototype object 上的方法和属性。 简单执行 JS 脚本 使用 JSCore 可以在一个上下文环境中执行 JS 代码。首先你需要导入 JSCore: [代码]import JavaScriptCore //记得导入JavaScriptCore [代码] 然后利用 Context 挂载的 evaluateScript 方法,像 new Function(xxx) 一样传递字符串进行执行。 [代码]let contet:JSContext = JSContext() // 实例化 JSContext context.evaluateScript("function combine(firstName, lastName) { return firstName + lastName; }") let name = context.evaluateScript("combine('villain', 'hr')") print(name) //villainhr // 在 swift 中获取 JS 中定义的方法 let combine = context.objectForKeyedSubscript("combine") // 传入参数调用: // 因为 function 传入参数实际上就是一个 arguemnts[fake Array],在 swift 中就需要写成 Array 的形式 let name2 = combine.callWithArguments(["jimmy","tian"]).toString() print(name2) // jimmytian [代码] 如果你想执行一个本地打进去 JS 文件的话,则需要在 swift 里面解析出 JS 文件的路径,并转换为 String 对象。这里可以直接使用 swift 提供的系统接口,Bundle 和 String 对象来对文件进行转换。 [代码]lazy var context: JSContext? = { let context = JSContext() // 1 guard let commonJSPath = Bundle.main.path(forResource: "common", ofType: "js") else { // 利用 Bundle 加载本地 js 文件内容 print("Unable to read resource files.") return nil } // 2 do { let common = try String(contentsOfFile: commonJSPath, encoding: String.Encoding.utf8) // 读取文件 _ = context?.evaluateScript(common) // 使用 evaluate 直接执行 JS 文件 } catch (let error) { print("Error while processing script file: \(error)") } return context }() [代码] JSExport 接口的暴露 JSExport 是 JSCore 里面,用来暴露 native 接口的一个 protocol,能够使 JS 代码直接调用 native 的接口。简单来说,它会直接将 native 的相关属性和方法,直接转换成 prototype object 上的方法和属性。 那在 JS 代码中,如何执行 Swift 的代码呢?最简单的方式是直接使用 JSExport 的方式来实现 class 的传递。通过 JSExport 生成的 class,实际上就是在 JSContext 里面传递一个全局变量(变量名和 swift 定义的一致)。这个全局变量其实就是一个原型 prototype。而 swift 其实就是通过 context?.setObject(xxx) API ,来给 JSContext 导入一个全局的 Object 接口对象。 那应该如何使用该 JSExport 协议呢? 首先定义需要 export 的 protocol,比如,这里我们直接定义一个分享协议接口: [代码]@objc protocol WXShareProtocol: JSExport { // js调用App的微信分享功能 演示字典参数的使用 func wxShare(callback:(share)->Void) // setShareInfo func wxSetShareMsg(dict: [String: AnyObject]) // 调用系统的 alert 内容 func showAlert(title: String,msg:String) } [代码] 在 protocol 中定义的都是 public 方法,需要暴露给 JS 代码直接使用的,没有在 protocol 里面声明的都算是 私有 属性。接着我们定义一下具体 WXShareInface 的实现: [代码]@objc class WXShareInterface: NSObject, WXShareProtocol { weak var controller: UIViewController? weak var jsContext: JSContext? var shareObj:[String:AnyObject] func wxShare(_ succ:()->{}) { // 调起微信分享逻辑 //... // 成功分享回调 succ() } func setShareMsg(dict:[String:AnyObject]){ self.shareObj = ["name":dict.name,"msg":dict.msg] // ... } func showAlert(title: String, message: String) { let alert = AlertController(title: title, message: message, preferredStyle: .Alert) // 设置 alert 类型 alert.addAction(AlertAction(title: "确定", style: .Default, handler: nil)) // 弹出消息 self.controller?.presentViewController(alert, animated: true, completion: nil) } // 当用户内容改变时,触发 JS 中的 userInfoChange 方法。 // 该方法是,swift 中私有的,不会保留给 JSExport func userChange(userInfo:[String:AnyObject]) { let jsHandlerFunc = self.jsContext?.objectForKeyedSubscript("\(userInfoChange)") let dict = ["name": userInfo.name, "age": userInfo.age] jsHandlerFunc?.callWithArguments([dict]) } } [代码] 类是已经定义好了,但是我们需要将当前的类和 JSContext 进行绑定。具体步骤是将当前的 Class 转换为 Object 类型注入到 JSContext 中。 [代码]lazy var context: JSContext? = { let context = JSContext() let shareModel = WXShareInterface() do { // 注入 WXShare Class 对象,之后在 JSContext 就可以直接通过 window.WXShare 调用 swift 里面的对象 context?.setObject(shareModel, forKeyedSubscript: "WXShare" as (NSCopying & NSObjectProtocol)!) } catch (let error) { print("Error while processing script file: \(error)") } return context }() [代码] 这样就完成了将 swift 类注入到 JSContext 的步骤,余下的只是调用问题。这里主要考虑到你 JS 执行的位置。比如,你可以直接通过 JSCore 执行 JS,或者直接将 JSContext 和 webview 的 Context 绑定在一起。 直接本地执行 JS 的话,我们需要先加载本地的 js 文件,然后执行。现在本地有一个 share.js 文件: [代码]// share.js 文件 WXShare.setShareMsg({ name:"villainhr", msg:"Learn how to interact with JS in swift" }); WXShare.wxShare(()=>{ console.log("the sharing action has done"); }) [代码] 然后,我们需要像之前一样加载它并执行: [代码]// swift native 代码 // swift 代码 func init(){ guard let shareJSPath = Bundle.main.path(forResource:"common",ofType:"js") else{ return } do{ // 加载当前 shareJS 并使用 JSCore 解析执行 let shareJS = try String(contentsOfFile: shareJSPath, encoding: String.Encoding.utf8) self.context?.evaluateScript(shareJS) } catch(let error){ print(error) } } [代码] 如果你想直接将当前的 WXShareInterface 绑定到 Webview Context 中的话,前面实例的 Context 就需要直接修改为 webview 的 Context。对于 UIWebview 可以直接获得当前 webview 的Context,但是 WKWebview 已经没有了直接获取 context 的接口,wkwebview 更推崇使用前文的 scriptMessageHandler 来做 jsbridge。当然,获取 wkwebview 中的 context 也不是没有办法,可以通过 KVO 的 trick 方式来拿到。 [代码]// 在 webview 加载完成时,注入相关的接口 func webViewDidFinishLoad(webView: UIWebView) { // 加载当前 View 中的 JSContext self.jsContext = webView.valueForKeyPath("documentView.webView.mainFrame.javaScriptContext") as! JSContext let model = WXShareInterface() model.controller = self model.jsContext = self.jsContext // 将 webview 的 jsContext 和 Interface 绑定 self.jsContext.setObject(model, forKeyedSubscript: "WXShare") // 打开远程 URL 网页 // guard let url = URL(string: "https://www.villainhr.com") else { // return //} // 如果没有加载远程 URL,可以直接加载 // let request = URLRequest(url: url) // webView.load(request) // 在 jsContext 中直接以 html 的形式解析 js 代码 // let url = NSBundle.mainBundle().URLForResource("demo", withExtension: "html") // self.jsContext.evaluateScript(try? String(contentsOfURL: url!, encoding: NSUTF8StringEncoding)) // 监听当前 jsContext 的异常 self.jsContext.exceptionHandler = { (context, exception) in print("exception:", exception) } } [代码] 然后,我们可以直接通过上面的 share.js 调用 native 的接口。 原生组件的通信 JSCore 实际上就是在 native 的一个线程中执行,它里面没有 DOM、BOM 等接口,它的执行和 nodeJS 的环境比较类似。简单来说,它就是 ECMAJavaScript 的解析器,不涉及任何环境。 在 JSCore 中,和原生组件的通信其实也就是 native 中两个线程之间的通信。对于一些高性能组件来说,这个通信时延已经减少很多了。 那两个之间通信,是传递什么呢? 就是 事件,DOM 操作等。在同层渲染中,这些信息其实都是内核在管理。所以,这里的通信架构其实就变为: [图片] Native Layer 在 Native 中,可以通过一些手段能够在内核中设置 proxy,能很好的捕获用户在 UI 界面上触发的事件,这里由于涉及太深的原生知识,我就不过多介绍了。简单来说就是,用户的一些 touch 事件,可以直接通过 内核暴露的接口,在 Native Layer 中触发对应的事件。这里,我们可以大致理解内核和 Native Layer 之间的关系,但是实际渲染的 webview 和内核有是什么关系呢? 在实际渲染的 webview 中,里面的内容其实是小程序的基础库 JS 和 HTML/CSS 文件。内核通过执行这些文件,会在内部自己维护一个渲染树,这个渲染树,其实和 webview 中 HTML 内容一一对应。上面也说过,Native Layer 也可以和内核进行交互,但这里就会存在一个 线程不安全的现象,有两个线程同时操作一个内核,很可能会造成泄露。所以,这里 Native Layer 也有一些限制,即,它不能直接操作页面的渲染树,只能在已有的渲染树上去做节点类型的替换。 最后总结 这篇文章的主要目的,是让大家更加了解一下小程序架构模式在开发者工具和手机端上的不同,更好的开发出一些高性能、优质的小程序应用。这也是小程序中心一直在做的事情。最后,总结一下前面将的几个重要的点: 开发者工具只有双线程架构,通过 appservice_webview 和 content_webview 的通信,实现小程序手机端的模拟。 手机端上,会根据组件性能要求的不能对应优化使用不同的通信架构。 正常 div 渲染,使用 JSCore 和 webview 的双线程通信 video/map/canvas 等高阶组件,通常是利用内核的接口,实现同层渲染。通信模式就直接简化为 内核 <=> Native <=> appservice。(速度贼快) 参考: 教程 | 《小程序开发指南》
2019-02-19 - 一次在微信小程序里跑 h5 页面的尝试
前言 标题看起来有点唬人,在微信小程序里跑 h5 页面,不会又是说使用 web-view 组件来搞吧?确实,使用 web-view 组件可以达到跑 h5 页面的要求,但是 web-view 组件在使用上还是有一些限制:不支持个人类型与海外类型的小程序、不支持全屏、页面与小程序通信不方便、很多小程序接口无法直接调用等。 那么,还有其他的法子么?理论上来说还是有的。 运行环境 h5 页面是运行在 web 环境下,小程序本身也是基于 web 的,那为什么一直没有办法让 h5 在小程序里直接运行呢?原因在于小程序特有的运行环境。 以一个小程序的页面为例,通常一个小程序的页面至少包含三个文件:wxss 文件、 wxml 文件和 js 文件。其中 wxml 文件和 wxss 文件组成了页面的视图层,js 文件则属于页面的逻辑层,在小程序中,视图层和逻辑层是在不同的线程中执行的。小程序里所有页面的逻辑层都在一个 js 线程中运行,而视图层则分别在不同的 view 线程中。通常一个页面对应一个 view 线程,为了对性能的控制,不会允许用户无节制的启动 view 线程,所以也就有了页面栈数量的限制(目前最多允许打开十层页面)。 [图片] 在 view 线程中是有类似浏览器一样的环境,但是只有页面的视图层在上面跑,页面的渲染完全基于另一个 js 线程传输过来的数据。js 线程是一个纯净的 js 环境,那些你想要调的 document.getElementById、location.href 等 dom/bom 接口通通都没有,你只能在这里执行 js 代码,调用官方提供的接口,而页面的逻辑层就是在这样的线程中跑。这样问题就出来了,页面会渲染成什么样子,完全基于初始模板和数据,你想调接口来修改页面结构,门都没有~ 方案制定 小程序的运行环境如此特殊,以至于它的开发模式也比较另类,但是还是有很多人希望能够将开发过程大一统,一份代码各端运行。那么在限制如此之大的情况下,要怎么做的? 现在市面上有一些基于 react 或 vue 搞出来的工具,它们要求你使用 react 或者 vue 来写页面,由构建工具来编译到各个环境上可运行的目标代码,因为 react 和 vue 本身是基于数据来驱动的组件化框架,在一定程度上限制开发者直接调用 dom/bom 接口,所以可以比较方便的实现代码的编译转化。 PS:因为小程序本身的限制,react 和 vue 的小部分功能也是无法做到完全兼容的,不过大部分的实现都是 OK 的。 不过这不是我想要的,因为它们虽然实现得很漂亮,但是仍有不小的开发局限,你必须选择 vue、react 等框架的其中一种来使用。我想要的是更原生、更底层的大一统方案,底层到除了 vue、react 之外,你甚至可以在小程序中使用 jQuery。 目标太大会扯着蛋,至少得先有一个思路才行。 回到先前提到,小程序的逻辑层是在一个纯净的 js 线程中跑,那里没有 dom/bom 接口,只能运行页面逻辑层的代码,那么我们想办法在逻辑层建一棵 dom 树,把基本的 dom/bom 接口都模拟出来不就行了么? [图片] 乍一想好像可以,但是这里隐藏着一个问题:逻辑层中 dom 树的变更要如何转变成数据并更新到视图层呢? 这里很重要的一点:小程序提供了自定义组件,并且支持递归引用。也就是说,我们可以将 h5 中的 div、span、ul 等 dom 节点转成自定义组件,逻辑层里的每个 dom 节点都会对应一个视图层中的自定义组件实例,节点更新了,我们找到对应的自定义组件实例进行更新即可。 开始实施 方案很简单,但是实现起来的话细节和坑特别多,下面且听我挑几个关键的 trick 来介绍: dom 树的模拟 想要在小程序的逻辑层模拟出一个 dom 树,本质上和建立一棵虚拟树类似,因为它并不是真实的 dom 树。整个流程简单来说是这样的: [图片] 不管是页面中的静态 html 内容还是使用 innerHTML 等接口动态插入的 html 内容都可以走上面的流程来进行 dom 树的创建。dom 树创建比较简单,只是细节比较多,此处的问题是建立好的 dom 树要如何渲染到视图层呢?上面提到了一个关键点:将 dom 节点转成小程序的自定义组件。 此处再重复一次表强调:小程序的自定义组件支持递归引用! 什么叫递归引用?感觉用文字表达不如直接用代码说明来的快,我们先直接编写一个名为 element 的自定义组件: [代码]// element.js Component({ data: { children: ['div', 'span'], } }) [代码] [代码]<!-- element.wxml --> <block wx:for="{{children}}"> <div wx:if="{{item === 'div'}}"></div> <span wx:elif="{{item === 'span'}}"></span> </block> [代码] [代码]{ component: true, usingComponents: { "div": "./element", "span": "./element" } } [代码] 看代码应该很容易就能明白,所谓递归引用就是自己可以作为自己的一个子组件使用,这样可以很方便地将 dom 树给渲染在逻辑层,并且也不需要我们为每种节点标签给编写一个自定义组件,如上述例子,只需要编写一个 element 自定义组件基本可以覆盖所有标签。 同样,根据上述例子可以很明显的看出,每个节点只负责渲染自己的子节点,其他如孙子节点等后代节点一概不管,这样就避免了在更新时需要将整棵树结构传递给视图层的麻烦问题。 对于从逻辑层向视图层传递数据,小程序里有个数据包大小的限制,此处若同步整棵树结构,一来可能爆了这个限制,二来会传递很多无用数据,增大更新开销。 建立联系 创建好 dom 树后,下一步就是要想办法将小程序创建的自定义组件实例和我们创建的 dom 节点给一一联系起来。首先,我们得给每个 dom 节点生成一个节点 id 用于区分,根节点的节点 id 可以在构建过程中直接生成,假定根节点就是 body 节点,那么生成的页面的 wxml 和 json 中大概是这样的: [代码]<!-- page.wxml --> <body data-private-node-id="node-id-xxxxxx"></body> [代码] [代码]{ usingComponents: { "body": "../components/element", } } [代码] body 节点同样是用上面例子中的 element 自定义组件来渲染,修改下 element 自定义组件的 js 文件: [代码]// element.js Component({ data: { children: []. }, attached() { const nodeId = this.dataset.privateNodeId // 节点 id } }) [代码] 如上述代码所述,在自定义组件的 attached 生命周期中即可拿到节点 id,然后根据此节点 id 从 dom 树中找到对应的 dom 节点,两者之间即可建立关系。 找到根节点后,后续节点就好办了。根节点对应的自定义组件实例在和 dom 节点建立联系后,就可以通过 dom 节点拿到子节点列表,进而开始渲染子节点。 由上可知,每个节点只负责渲染自己的子节点,每个节点的渲染流程都和根节点一样: 拿到节点 id 和 dom 节点建立联系 通过 dom 节点拿到子节点列表 渲染子节点 根据这个逻辑修改一下上述例子中的 element 自定义组件的代码: [代码]// element.js Component({ data: { children: []. }, attached() { const nodeId = this.dataset.privateNodeId // 节点 id const domNode = getDomNode(nodeId) // 根据节点 id 找到 dom 节点 const children = domNode.children // 拿到子节点列表 // 渲染子节点 this.setData({ children, }) } }) [代码] [代码]<!-- element.wxml --> <block wx:for="{{children}}"> <div wx:if="{{item.tagName === 'DIV'}}" data-private-node-id="{{item.nodeId}}"></div> <span wx:elif="{{item.tagName === 'SPAN'}}" data-private-node-id="{{item.nodeId}}"></span> </block> [代码] 如此递归下去,即可将自定义组件实例和 dom 节点一一联系起来,同时将整棵 dom 树渲染出来。 渲染信息的同步 假若逻辑层想要获取视图层的渲染信息,比如调 [代码]getComputedStyle[代码] 接口获取节点样式、使用 [代码]clientWidth[代码] 或取节点宽度等,因为逻辑层本身只是模拟了 dom 树,并没有实现布局和渲染的模拟(理论上可以实现,只是开销巨大,而且很难与小程序本身环境的渲染结果对齐),所以这块实现起来很头疼。 目前的方案上使用小程序的 [代码]selectorQuery[代码] 接口来拉取渲染信息,因为此接口只能异步拉取,所以没法完整模拟渲染信息的即时同步。为了尽可能做到相对同步,在初始渲染完成后尝试拉取一次渲染信息,之后在每次触发节点更新后再异步拉取渲染信息,同时提供一个异步接口给某些需要即时拉取渲染信息的场景中使用。 全局对象的处理 上面提到小程序的逻辑层是跑在一个 js 线程中,这个 js 线程是一个纯净的 js 线程,别说那些 bom、dom 接口了,连一个正经的全局对象都没有。 小程序中其实有 global 对象,但是和我们熟悉的 nodejs 中的 global 对象实现不一样,所以此处按下不提。 做前端开发的同学们应该都知道,h5 环境中声明在全局的变量/函数会挂在 window 下,在页面的其他地方是可以使用或者是通过 [代码]window.xxx[代码] 的方式来访问的。在小程序的运行环境中这些是不存在的,那就意味着会有下述这些问题: [代码]// a.js function xxx() {} window.yyy = function() {} // b.js window.xxx() // 报 xxx 不存在 yyy() // 报 yyy 不存在 [代码] 这个问题很严重,要知道我们很多时候访问一些对象都会忽略掉 [代码]window.[代码] 这个前缀,比如使用 location 对象的时候。也就是说,必须对这些 js 做一遍后处理,强制将全局函数挂在 window 下,强制通过使用 [代码]window.xxx[代码] 的方式访问全局变量/函数。经过后处理后,上述例子会变成下面这样: [代码]// a.js function xxx() {} window.yyy = function() {} window.xxx = xxx // 强制挂在 window 下 // b.js window.xxx() // 正常执行 window.yyy() // 强制补充 window 前缀,正常执行 [代码] 嗯,完美解决! 其他 上面提到的是整体的实现框架,也简单的提到几个坑。当然,这只不过是冰山一角,事实上需要兼容的坑、无法按照标准实现的接口和完全无法实现的特性犹如满天星,数都数不清,幸运的是我们并不完全依赖到所有的接口,有些时候我们仅仅需要用到像 querySelector、事件系统、异步请求等接口就可以构造出大部分 h5 页面,而这些大多是能做到兼容的,所以对于某些不太复杂的场景来说还是可行的。 工具试用 按照上面的思路实现了一个差不多的构建工具,暂且命名为 [代码]h5-to-miniprogram[代码],只看名字就知道这是个想干嘛的工具,不过这终究是个玩票性质的尝试,有兴趣的同学也可以点击此处翻看源码~
2019-02-27