- Omi 多端开发之 - omip 适配 h5 原理揭秘
写在前面 Omi 框架是腾讯开源的下一代前端框架,提供桌面、移动和小程序整体解决方案(One framework. Mobile & Desktop & Mini Program), Omip 是 Omi 团队开发的跨端开发工具集,支持小程序和 H5 SPA,最新的 omip 已经适配了 h5,如下方新增的两条命令: [代码]npm i omi-cli -g omi init-p my-app cd my-app npm start //开发小程序 npm run dev:h5 //开发 h5 npm run build:h5 //发布 h5 [代码] node 版本要求 >= 8 也支持一条命令 [代码]npx omi-cli init-p my-app[代码] (npm v5.2.0+) 当然也支持 TypeScript: [代码]omi init-p-ts my-app [代码] TypeScript 的其他命令和上面一样,也支持小程序和 h5 SPA 开发。 开发预览 [图片] 特性包括: 一次学习,多处开发,一次开发,多处运行 使用 JSX,表达能力和编程体验大于模板 支持使用 npm/yarn 安装管理第三方依赖 支持使用 ES6+,ES2015+,TypeScript 支持使用 CSS 预编译器 小程序 API 优化,异步 API Promise 化 超轻量的依赖包,顺从小程序标签和组件的设计 webpack、热加载、less等你要的都有 支持 SVG ! SVG ! SVG ! Omip 不仅可以一键生成小程序,还能一键生成 h5 SPA。怎么做到的?下面来一一列举难点,逐个击破。 问题列表 CSS rpx 转换问题 app.css 作用域问题 JSX 里的小程序标签映射 CSS 里的小程序标签映射 wx api 适配 集成路由 CSS rpx 转换问题 小程序扩展尺寸单位 rpx(responsive pixel): 可以根据屏幕宽度进行自适应。规定屏幕宽为750rpx。如在 iPhone6 上,屏幕宽度为375px,共有750个物理像素,则750rpx = 375px = 750物理像素,1rpx = 0.5px = 1物理像素。 这个特性大受好评,制作响应式网站非常有用。因为浏览器是不支持 rpx 单位,所以需要运行时转换,刚好 omi 内置了这个函数: [代码]function rpx(str) { return str.replace(/([1-9]\d*|0)(\.\d*)*rpx/g, (a, b) => { return (window.innerWidth * Number(b)) / 750 + 'px' }) } [代码] 从 rpx 源码可以看到,需要运行时转换 rpx,而非编译时!因为只有运行时能拿到 屏幕宽度,omi 早期版本已经支持运行时的 rpx 转换: [代码]import { WeElement, define, rpx } from 'omi' define('my-ele', class extends WeElement { static css = rpx(`div { font-size: 375rpx }`) render() { return ( <div>my ele</div> ) } }) [代码] app.css 作用域问题 小程序 Shadow tree 与 omi 有一点点不一样,omi 是从根开始 shadow root,而小程序是从自定义组件开始,omio 则没有 shadow root。 Omi Omio 小程序 Shadow DOM 从根节点开始 无 从自定义组件开始 Scoped CSS 从根节点开始局部作用域,浏览器 scoped 从根节点开始局部作用域(运行时 scoped) 自定义组件局部作用域 所以,app.css 需要污染到 page 里的 WXML/JSX,但在 omi 和 omio 中样式都是隔离的, 需要怎么做才能突破隔离?先看 app.js 源码: [代码]import './app.css' //注意这行!!! import './pages/index/index' import { render, WeElement, define } from 'omi' define('my-app', class extends WeElement { config = { pages: [ 'pages/index/index', 'pages/list/index', 'pages/detail/index', 'pages/logs/index' ], window: { backgroundTextStyle: 'light', navigationBarBackgroundColor: '#fff', navigationBarTitleText: 'WeChat', navigationBarTextStyle: 'black' } [代码] 上面是使用 omip 开发小程序的入口 js 文件,也是 webpack 编译的入口文件,在 cli 进行语法树分析的时候,可以拿到 import 的各个细节,然后做一些变换处理,比如下面 ImportDeclaration(即 import 语句) 的处理: [代码]traverse(ast, { ImportDeclaration: { enter (astPath) { const node = astPath.node const source = node.source const specifiers = node.specifiers let value = source.value //当 app.js 里 import 的文件是以 .css 结尾的时候 if(value.endsWith('.css')){ //读取对应 js 目录的 css 文件,移除 css 当中的注释,保存到 appCSS 变量中 appCSS = fs.readFileSync(filePath.replace('.js','.css'), 'utf-8').replace(/\/\*[^*]*\*+([^/][^*]*\*+)*\//g, '') //移除这里条 import 语句 astPath.remove() return } [代码] 得到了 appCSS 之后,想办法注入到所有 page 当中: [代码] traverse(ast, { ImportDeclaration: { enter (astPath) { const node = astPath.node const source = node.source let value = source.value const specifiers = node.specifiers //当 import 的文件是以 .css 结尾的时候 if(value.endsWith('.css')){ //读取对应 js 目录的 css 文件,移除 css 当中的注释,保存到 css 变量中 let css = fs.readFileSync(filePath.replace('.js','.css'), 'utf-8').replace(/\/\*[^*]*\*+([^/][^*]*\*+)*\//g, '') //page 注入 appCSS if(filePath.indexOf('/src/pages/') !== -1||filePath.indexOf('\\src\\pages\\') !== -1){ css = appCSS + css } //把 import 语句替换成 const ___css = Omi.rpx(.....) 的形式! astPath.replaceWith(t.variableDeclaration('const',[t.variableDeclarator(t.identifier(`___css`),t.callExpression(t.identifier('Omi.rpx'),[t.stringLiteral(css)]),)])) return } ... [代码] 这就够了吗?不够!因为 ___css 并没有使用到,需要注入到 WeElement Class 的静态属性 css 上,继续 ast transformation: [代码]const programExitVisitor = { ClassBody: { exit (astPath) { //注入静态属性 const css = ___css astPath.unshiftContainer('body', t.classProperty( t.identifier('static css'), t.identifier('___css') )) } } } [代码] 编译出得 page 长这个样子: [代码]import { WeElement, define } from "../../libs/omip-h5/omi.esm"; const ___css = Omi.rpx("\n.container {\n height: 100%;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: space-between;\n padding: 200rpx 0;\n box-sizing: border-box;\n} \n\n.userinfo {\n display: flex;\n flex-direction: column;\n align-items: center;\n}\n\n.userinfo-avatar {\n width: 128rpx;\n height: 128rpx;\n margin: 20rpx;\n border-radius: 50%;\n}\n\n.userinfo-nickname {\n color: #aaa;\n}\n\n.usermotto {\n margin-top: 200px;\n}"); const app = getApp(); define('page-index', class extends WeElement { static css = ___css; data = { motto: 'Hello Omip', userInfo: {}, hasUserInfo: false, canIUse: wx.canIUse('button.open-type.getUserInfo') ... ... [代码] 大功告成! 标签映射 由于小程序里的一些标签在浏览器中不能够识别,比如浏览器不识别 view、text 等标签,需要转换成浏览器识别的标签,所以这里列了一个映射表: [代码]const mapTag = { 'view': 'div', 'picker': 'select', 'image': 'img', 'navigator': 'a', 'text': 'span' } const getNodeName = function(name){ if(mapTag[name]) return mapTag[name] return name } [代码] 在 [代码]h[代码] 函数创建虚拟 dom 的时候进行 [代码]getNodeName[代码]: [代码]function h(nodeName, attributes) { ... ... var p = new VNode(); p.nodeName = getNodeName(nodeName); p.children = children; p.attributes = attributes == null ? undefined : attributes; p.key = attributes == null ? undefined : attributes.key; ... ... return p; } [代码] 这里还有遗留问题,比如内置的一些原生组件如: scroll-view movable-view cover-view cover-image rich-text picker-view functional-page-navigator live-player live-pusher 这些组件如果你需要开发 h5,就别用上面这些组件。如果一定要使用上面的组件,那么请使用 omi 先实现上面的组件。 CSS 里的小程序标签映射 [代码]const map = require('./tag-mapping') const css = require('css') const cssWhat = require('css-what') const cssStringify = require('./css-stringify') function compileWxss(str) { let obj = css.parse(str) obj.stylesheet.rules.forEach(rule => { rule.selectors && rule.selectors.forEach((selector, index) => { let sltObjs = cssWhat(selector) sltObjs.forEach(sltObj => { sltObj.forEach(item => { if (item.type == 'tag') { item.name = map(item.name) } }) }) rule.selectors[index] = cssStringify(sltObjs) }) }) return css.stringify(obj) } [代码] 转换前: [代码].abc view { color: red; } [代码] 转换后 [代码].abc div { color: red; } [代码] wx api 适配 这里需要注意的是,不是所有 api 都能适配,只能适配一部分: wx web wx.request XMLHttpRequest 界面 api(confirm、loaing、toast等) 实现对应的omi组件 数据存储 api localStorage wx 特有的 api 还包括一些特有的生命周期函数,如: onShow onHide 这是 wx 里 Page 里的生命周期,而 omi 是不包含的。这里需要在 router 的回调函数中进行主动调用。具体怎么出发且看路由管理。 集成路由 先看 cli 编译出来的 app.js 路由部分: [代码] render() { return <o-router mode={"hash"} publicPath={"/"} routes={[{ path: '/pages/index/index', componentLoader: () => import( /* webpackChunkName: "index_index" */'./pages/index/index'), isIndex: true }, { path: '/pages/list/index', componentLoader: () => import( /* webpackChunkName: "list_index" */'./pages/list/index'), isIndex: false }, { path: '/pages/detail/index', componentLoader: () => import( /* webpackChunkName: "detail_index" */'./pages/detail/index'), isIndex: false }, { path: '/pages/logs/index', componentLoader: () => import( /* webpackChunkName: "logs_index" */'./pages/logs/index'), isIndex: false }]} customRoutes={{}} basename={"/"} />; } }); render(<my-app />, '#app'); [代码] 4个页面各自做了分包,这样可以加快首屏节省带宽按需加载。接下来看 [代码]<o-router />[代码] 的实现: [代码]import { WeElement, define, render } from "../omip-h5/omi.esm"; import 'omi-router'; let currentPage = null; let stackList = []; define('o-router', class extends WeElement { _firstTime = true; installed() { ... ... } }); export function routeUpdate(vnode, selector, byNative, root) { ... ... } window.onscroll = function () { ... ... }; [代码] 具体实现细节可以去看 o-router 源码,主要实现了下面一些功能: 依赖了 omi-router 进行路由变更的监听(hash change) 依赖 window.onscroll 记录了上一 page 的滚动位置,方便在回退时候还原滚动条位置 记录了 page 容器的 display,不能无脑 display none 和 display block 切换,因为可能是 display flex 等 依靠 omi-router 判断是否是系统后退行为 在正确的时机触发页面的 onShow 和 onHide 新开页面 scrollTop 重制为 0 wx.navigateTo 直接调用 omi-router 的 route 方法 开始使用吧 → Omip Github Omi 相关任何问题疑问反馈意见欢迎进群交流。
2019-04-26 - 小程序canvas的那些事
背景 业务场景需要在小程序内生成活动的分享海报,图片中的某些数据需动态展示。可行的方案有️二: 服务端合成:直接返回给前端图片URL 客户端合成:客户端利用canvas绘制 在当前业务场景下,使用客户端合成会优于服务端合成,避免造成不必要的服务器CPU浪费。 下面主要谈谈**客户端(canvas)**合成的过程。 实现思路 小程序端发起请求,获取需动态展示的数据; 利用canvas绘制画布; 导出图片保存到相册。 小技巧&那些坑 理想很丰满,现实很骨感。 实现思路很简单,然而,在实现过程中,发现会趟一些坑,也有一些小技巧,遂记录下来,以供参考。 promise化 画布的绘制依赖系统信息(自适应和优化图片清晰度)和动态数据。故画布需要在所有前置条件都准备完成时,方可绘制。为了提高代码优雅度和维护性,建议用promise化,避免回调地狱(Callback Hell)。 [代码] let promise1 = new Promise((resolve, reject) => { this.getData(resolve, reject) }); let promise2 = new Promise((resolve, reject) => { this.getSystemInfo(resolve, reject) }); Promise.all([promise1, promise2]).then(() => { this.drawCanvas() }).catch(err => { console.log(err) }); [代码] 自适应 1、为了在各个机型下保持大小自适应,需要计算出缩放比: [代码] getSystemInfo(resolve, reject) { try { const res = wx.getSystemInfoSync() //缓存系统信息 systemInfo = res //这里视觉基于iPone6(375*667)设计,2x图视觉,可以填写750,1x图视觉,可以填写375 zoom = res.windowWidth / 750 * 1 resolve() } catch (e) { // Do something when catch error reject("获取机型失败") } } [代码] 2、绘制时进行按缩放比进行缩放,如: [代码]ctx.drawImage(imgUrl, x * zoom, y * zoom, w * zoom, h * zoom) [代码] 绘制网络图片 经测试,绘制CDN图片需要先将图片下载到本地,在进行绘制: [代码]wx.downloadFile({ url: imgUrl, success: res => { if (res.statusCode === 200) { ctx.drawImage(res.tempFilePath, 326 * zoom, 176 * zoom, 14 * zoom, 14 * zoom) } } }) [代码] 绘制base64图片 因为业务上某些原因,依赖的图片数据,后端只能以base64格式返回给前端,而小程序在真机上无法直接绘制(开发工具OK)。 解决思路(存在兼容性问题,fileManager**基础库 1.9.9 **开始支持): 1、调用fileManager.writeFile存储base64到本地; 2、绘制本地图片。 实现代码如下: [代码]// 先获得一个文件实例 fileManager = wx.getFileSystemManager() // 把图片base64格式转存到本地,用于canvas绘制 fileManager.writeFile({ filePath: `${wx.env.USER_DATA_PATH}/qrcode.png`, data: self.data.qrcode, encoding: 'base64', success: () => { //此处需先调用wx.getImageInfo,方可绘制成功 wx.getImageInfo({ src: `${wx.env.USER_DATA_PATH}/qrcode.png`, success: () => { //绘制二维码 ctx.drawImage(`${wx.env.USER_DATA_PATH}/qrcode.png`, 207 * zoom, 313 * zoom, 148 * zoom, 148 * zoom) ctx.draw() } }) } }) [代码] 保存到本地相册 wx.saveImageToPhotosAlbum这个API需用户授权,故开发者需做好拒绝授权的兼容。此处实现对拒绝授权的场景进行引导。 [代码]canvas2Img(e) { wx.getSetting({ success(res) { if (res.authSetting['scope.writePhotosAlbum'] === undefined) { //呼起授权界面 wx.authorize({ scope: 'scope.writePhotosAlbum', success() { save() } }) } else if (res.authSetting['scope.writePhotosAlbum'] === false) { //引导拒绝过授权的用户授权 wx.showModal({ title: '温馨提示', content: '需要您授权保存到相册的权限', success: res => { if (res.confirm) { wx.openSetting({ success(res) { if (res.authSetting['scope.writePhotosAlbum']) { save() } } }) } } }) } else { save() } } }) function save() { wx.canvasToTempFilePath({ x: 0, y: 0, width: 562*zoom, height: 792*zoom, destWidth: 562*zoom*systemInfo.pixelRatio, destHeight: 792*zoom*systemInfo.pixelRatio, fileType: 'png', quality: 1, canvasId: 'shareImg', success: res => { wx.saveImageToPhotosAlbum({ filePath: res.tempFilePath, success: () => { wx.showModal({ content: '保存成功', showCancel: false, confirmText: '确认' }) }, fail: res => { if (res.errMsg !== 'saveImageToPhotosAlbum:fail cancel') { wx.showModal({ content: '保存到相册失败', showCancel: false }) } } }) }, fail: () => { wx.showModal({ content: '保存到相册失败', showCancel: false }) } }) } [代码] 导出清晰图片 wx.canvasToTempFilePath有destWidth(输出的图片的宽度)和destHeight(输出的图片的高度)属性。若此处以canvas的宽高去填写的话,在高像素手机下,导出的图片会模糊。 原因:destWidth和destHeight单位是物理像素(pixel),canvas绘制的时候用的是逻辑像素(物理像素=逻辑像素 * density),所以这里如果只是使用canvas中的width和height(逻辑像素)作为输出图片的长宽的话,生成的图片width和height实际上是缩放了到canvas的 1 / density大小了,所以就显得比较模糊了。 这里应该乘以设备像素比,实现如下: [代码]wx.canvasToTempFilePath({ x: 0, y: 0, width: 562*zoom, height: 792*zoom, destWidth: 562*zoom*systemInfo.pixelRatio, destHeight: 792*zoom*systemInfo.pixelRatio, fileType: 'png', quality: 1, canvasId: 'shareImg' )} [代码] 特殊字体的绘制 研究发现,目前小程序canvas无法支持设置特殊字体,而业务生成的海报,又期望以特殊字体去呈现,最终取了个折中方案——保留数字部分的特殊样式。 实现方式为:把0-9这10个数字单独切图,用ctx.drawImage API,以图片形式去绘制。 [代码]drawNum(num, x, y, w, h) { return new Promise(function (resolve, reject) { //这里存储0-9的图片CDN链接 let numMap = [] wx.downloadFile({ url: numMap[num], success: res => { if (res.statusCode === 200) { ctx.drawImage(res.tempFilePath, x * zoom, y * zoom, w * zoom, h * zoom) resolve() } }, fail: () => { reject() } }) }) } [代码] 安卓机型图片绘制锯齿化问题 测试发现,同样的绘制方案,在安卓下,调用ctx.drawImage方法,图片会出现锯齿问题。测试还发现,原像素越高,锯齿化程度降低(但业务上使用太大像素的素材也不合理),这里需要客户端底层进行优化,目前没有找到合适的解决方案。 总结 个人觉得,目前小程序canvas就底层能力上相比web还有一些不足。所以应注意两点: 提前从业务出发,考虑当前实现的可行性,以便采取更优方案(如特殊字体,像素要求等); 若绘制canvas导出图片是个高频场景,可参考html2canvas进行封装,以便提高效能(SelectorQuery节点查询需1.9.90以上)。 ps:之前有想过利用web-view方式,在传统网页去绘制,然后通过web-view和小程序的通信来实现的方式。时间原因,并未尝试,感兴趣同学可以尝试下。
2019-03-07 - 好的经验要分享:chooseImage转base64
好的经验必须要分享:chooseImage后转base64 现在网上各种帖子的解决方案存在各种各样的问题,不说了,直接贴代码,手机亲测,没有问题 const fileManager = wx.getFileSystemManager(); [图片]
2018-08-31