✨家人们记得点个账号关注,会持续发布大前端领域技术文章💕 🍃
🐝 重要出现频率高
■ 基础
鸿蒙基于前端的typescript所以也会问到 当然还有很多 这里列举了较为频率高的。
CSS选择器 🐝
CSS选择器优先级
权重顺序: !important > 行内样式 > ID > 类、伪类、属性 > 标签 > 通配符 > 继承 > 浏览器默认属性
权重大小
通配符0
标签 1
类/伪类/属性 10
ID 100
行内样式 1000
important 无穷大
CSS3新特性 🐝
- 属性选择器选择器
- 圆角
- 阴影
- 渐变
- 多列布局
- css自定艺术性
- 等等
谈谈你对盒模型的理解 🐝
问:什么是盒模型?
网页布局中CSS布局的思想模型,
主要规定元素和元素之间的关系
例如:内容(content),内边距(padding),边框(border),外边距(margin)
问: 标准盒模型、怪异盒模型
用box-sizing告诉浏览器如何计算一个元素是总宽度和总高度
标准盒模型 box-sizing: content-box
一个块的总宽度 width + margin(左右) + padding(左右) + border(左右)
IE盒模型或怪异盒模型 box-sizing: border-box
一个块的总宽度 = width(已包含padding和border) + margin(左右)
定位 position
static 默认值,没有定位,元素出现在正常的文档流中;
relative 相对定位,相对位置为 自身默认位置;
absolute绝对定位,相对位置为 最近父非static的元素,没有就是body/可见视口/浏览器窗口;
fixed固定定位,对象位置为 body/可见视口/浏览神龙教主器窗口;
sticky粘性定位,该定位基于用户滚动的位置;
static 默认
relative、absolute 淘宝京东轮播图左右箭头、分页器、热卖商品、新品等等
fixed 传统网站两侧滚动广告、还有右下角客服、返回顶部等等
sticky 网站导航栏 例如 www.discuz.net/
水平垂直居中 🐝
方法1:【移动推荐】利用flex弹性布局 justify-content/ align-items center
方法2:【需要宽高】利用position定位 top/left 50% margin-top/left 负自身一半
方法3:【需要宽高】利用position定位 top/right/bottom/left =0 然后 margin: auto
方法4:【留心兼容】利用position定位 transform translate(-50%,-50%)
方法5:【留心兼容】利用position定位 calc((100% - 100px) / 2);
等等
两栏布局 🐝
方法1:左固定浮动、右触发BFC
方法2:左固定浮动、右margin-left
方法3:左固定定位、右margin-left
方法4:父弹性盒、左固定、右flex:1
等等
<style>
*{padding:0px;margin:0px;}
.test {width:50px;height:50px;background: black;color:#fff;}
</style>
<!-- 方法1:左固定浮动、右触发BFC -->
<style>
.left1 {width:200px;height:100px;background:#ccc;float:left;}
.right1 {height:100px;background:red;overflow: hidden;}
</style>
<div class="left1">你好</div>
<div class="right1"><div class="test">中国</div></div>
<!-- 方法2:左固定浮动、右margin-left -->
<style>
.left2 {width:200px;height:100px;background:#ccc;float:left;}
.right2 {height:100px;background:red;margin-left:200px;}
</style>
<div class="left2">你好</div>
<div class="right2"><div class="test">中国</div></div>
<!-- 方法3:左固定定位、右margin-left -->
<style>
.left3 {width:200px;height:100px;background:#ccc;position:absolute;}
.right3 {height:100px;background:red;margin-left:200px;}
</style>
<div class="left3">你好</div>
<div class="right3"><div class="test">中国</div></div>
<!-- 方法4:父弹性盒、左固定、右flex:1 -->
<style>
.main {display: flex;}
.left4 {width:200px;height:100px;background:#ccc;}
.right4 {height:100px;background:red;flex:1;}
</style>
<div class="main">
<div class="left4">你好</div>
<div class="right4"><div class="test">中国</div></div>
</div>
弹性布局 flex布局 🐝
www.zhangxinxu.com/wordpress/2…
| 容器属性 | 子项属性 | ||||||||
|---|---|---|---|---|---|---|---|---|---|
| flex-direction: row | row-reverse | column | column-reverse; | order | |||||
| flex-wrap: nowrap | wrap | wrap-reverse; | flex-grow | ||||||
| flex-flow: <‘flex-direction’> | <‘flex-wrap’> | flex-shrink | |||||||
| justify-content: flex-start | flex-end | center | space-between | space-around | space-evenly; | flex-basis | |||
| align-items: stretch | flex-start | flex-end | center | baseline; | flex: none | auto | [ <'flex-grow'> <'flex-shrink'>? | <'flex-basis'> ] | |
| align-content: stretch | flex-start | flex-end | center | space-between | space-around | space-evenly; | align-self |
弹性和布局后,可以给子项上添加
order 子项排序、 默认0 子项原位置
flex-grow
flex-grow属性定义子项放大比例或分配容器的剩余空间,默认为0,即如果存在剩余空间,也不放大。
写1剩余空白间隙大家等分
写2,其他项目都为1,则前者占据的剩余空间将比其他项多一倍。
flex-shrink 子项收缩规则、默认1 当空间不足时都将等比例缩小,写0则不缩小
flex-basis 子项分配剩余空间之前神龙教主默认大小、默认值是auto,就是自动子项本来大小。有设置width则占据空间就是width,没有设置就按内容宽度来
flex属性是flex-grow,flex-shrink和flex-basis的缩写。
flex默认值等同于flex:0 1 auto;flex:none等同于flex:0 0 auto;flex:auto等同于flex:1 1 auto;flex:1等同于flex:1 1 0;
谈谈你对flex:1的理解 🐝
它是flex-grow,flex-shrink和flex-basis的缩写。剩余空间大家等分,空间不足等比缩小,子项分配剩余空间之前用本身大小
网格布局 Grid 布局
网格布局是将页面中的元素划分成一个个各自,然后控制小格子进行合并
www.zhangxinxu.com/wordpress/2…
网格和弹性盒布局区别
- 维度:
-
- 弹性盒布局是一维布局模神龙教主型,适合用于沿着一个方向(水平或垂直)排列元素。
- 网格布局是二维布局模型,可以同时控制行和列,更适合复杂的布局结构。
- 控制:
-
- 弹性盒布局通过设置容器的属性来控制子元素的排列方式、对齐方式、间距等。
- 网格布局通过定义网格的行列数量、大小、对齐方式等属性来实现布局。
- 灵活性:
-
- 弹性盒布局更适合于动态内容或不规则布局,可以根据内容的大小和数量自动调整布局。
- 网格布局更适合于网格状的布局,可以精确控制每个网格的大小和位置。
- 兼容性:
-
- 弹性盒布局的兼容性较好,适用于大多数现代浏览器。
- 网格布局的兼容性也在不断改善,但相对于弹性盒布局,在一些老版本的浏览器上可能存在兼容性问题。
CSS三角形怎么实现
宽度、高度设为0,然后设置边框样式全透明
width: 0;
height: 0;
border-top: 40px solid transparent;
border-left: 40px solid transparent;
border-right: 40px solid transparent;
border-bottom: 40px solid #ff0000;
■ 鸿蒙基础 ArkUI
版本
推荐23年10-12月左右!!!!
接触鸿蒙多久了,什么时候开始的,项目用的api几
用过 api 几个阶段
- 回答:api9、api11、api12都用过
- 分析
2023年 09月 api10
2023年 10月 api6/10都有 (6代码类似于小程序开发)
2024年 02月 api11 (不对外开放合作伙伴用) DevEco4
2024年 03月后 陆续用api11 (对外开放) DevEco4
2024年 05月 用api12 (不对外开放合作伙伴用 他们是华为深度合作的外包公司 优先使用) DevEco5.0.0
2024年 06月19 陆续用api12 (对外开放)
2025年 02月 陆续用api14 DevEco5.0.2
内容溢出如何解决 🐝
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis }) // 超长文本裁剪略号代替
// .textOverflow({ overflow: TextOverflow.Clip }) // 超长文本裁剪
// .textOverflow({ overflow: TextOverflow.MARQUEE }) // 超长文本滚动显示
特殊的场景:展开收起评论 需要计算、文字跑马灯
Text里面是否可以写子组件
可以写Span子组件
developer.huawei.com/consumer/cn…
如何复用样式
通过装饰器 @Styles、@Extend
@Styles、@Extend有什么区别
@Extend更强 支持font-size、font-weigth等等、并且可以传递参数
有几种布局方式 🐝
- 鸿蒙中常用的布局方式
- ArkTs你说一下有几种布局方式?
线性布局 (Row/Column)
层叠布局 (Stack)
网格布局 (Grid)
弹性布局 (Flex)
相对布局 (RelativeContainer)
栅格布局 (GridRow/GridCol)
轮播图问题
- 用过轮播图吗,有没有遇到什么问题,比如卡顿的情况
- 使用轮播组件的时候有没有遇到过其他问题吗?怎么解决的
单独写一个Swiper卡顿
解决:加一个if判断,有数据时渲染
网格布局,层叠布局的应用场景
网格布局:场景有九宫格图片展示、日历、计算器等
层叠布局:当一个元素覆盖在另一个元素上时 例如返回顶部、弹窗广告等
绝对定位和相对定位
- 用过相对布局吗?说一下相对布局
- 绝对定位和相对定位的区别? 这俩个谁是以父容器定位的
.position({x:0,y:0}) 绝对定位,确定子组件相对父组件的位置。
.offset({x:0,y:0}) 相对偏移,组件相对原本的布局位置进行偏移。
在鸿蒙里面官方推荐使用什么布局
页面创建时,扁平化减少了中间的嵌套层级,使总的组件节点的数量越少,在进行布局时所需要进行的计算相对越少。
页面更新时,当要更新的结构是嵌套子树的结构,其树内包含过多节点时,整体更新会导致更新的节点数过多,造成布局性能劣化。
所以当页面不存在冗余节点时,可以考虑是否能够通过替换为更高级的布局使得页面扁平化,来达到减少节点数的目的。主要方式可以参考:
RelativeContainer 通过相对布局实现扁平化。
绝对定位 通过锚点定位实现扁平化。
Grid 通过二维布局实现扁平化。
或者回答
- 使用Column/Row代替Flex
- 减少层级嵌套,通过相对布局 RelativeContainer
总元素节点为20个Text的情况下,来对比其性能消耗。通过Profiler工具获取其首帧绘制时间进行对比。对比结果如下表:
| 对比指标 | Column/Row | Stack | Flex | RelativeContanier | Grid/GridItem |
|---|---|---|---|---|---|
| 首帧绘制 | 7.13ms | 7.34ms | 11.71ms | 9.13ms | 12.62ms |
| Measure | 2.63ms | 2.70ms | 7.59ms | 3.59ms | 8.68ms |
| Layout | 0.74ms | 0.77ms | 0.83ms | 0.77ms | 0.92ms |
■ 鸿蒙进阶 ArkTS
💕 week1 变量/判断/循环/函数等
let、const
面试题1:let和const区别
相同点-都是块级作用域/不能重复定义
不同点-let修改/const不能修改
面试题2:const真的不能修改吗
原始类型:不可以
对象类型:可以
原理:const不可以修改栈里面的地址,但是可以修改堆里面的数据
面试题3:暂时性死区
let前面的区域就是暂时性死区,必须先定义再使用
面试题4:let和const如何选
普遍用const、当需要再改成let 例如Date、xhr、promise对象等
const d = new Date()
面试题5:var和let区别
var 函数作用域 可以重复定义 可以修改
let 块级作用域 不能重复定义 可以修改
说出JS数据类型有哪些 🐝
原始类型/基本类型:null、undefined、boolean、number、string、symbol、bigint
对象类型/引用类型/复杂/复合类型:
标准普通对象:object
标准特殊对象:Array、Date、Math、RegExp、Error……
非标准特殊对象:Number、String、Boolean……
可调用/执行对象「函数」:function
JS引用类型object、array还有哪些
可调用/执行对象「函数」:function
标准特殊对象:Array、Date、Math、RegExp、Error……
非标准特殊对象:Number、String、Boolean……
如何把数据强制转换为数值型
说明:面试概率低、工作概率高
语法:parseInt、parseFloat、Number
说出数据转换为布尔型的结果
null、undefined、0、NaN、空字符串结果是false、其他都是true
说出数据转换为字符串型的结果
都字符串
String(内容)、内容.toString()
说出数据转换为数值型的结果
Number(数据)
| 数据 | 结果 |
|---|---|
| null | 0 |
| undefined | NaN |
| true/false | 1/0 |
| 123/NaN | 123/NaN |
| ''/'123'/'123a' | 0/123/NaN |
null 和 undefined 有什么区别? 🐝
undefined 表示未定义,新定义的变量没有赋值就是undefined
null表示清空,当一个变量不用的时候,除了等待网页关闭销毁,也可以把它赋值为null。此时游览器会进行一个回收也就是清空内存。
了解1
typeof(null)会是一个object。最初这么设计的原因为:通常用作一个空引用一个空对象的预期,就像一个占位符。typeof的这种行为已经被确认为一个错误,虽然提出了修正,出于后兼容的目的,这一点已经保持不变。
了解2
null == undefined true
null === undefined false
为什么 0.1 + 0.2 !== 0.3? 你如何解决这个问题? 🐝❄️
因为 0.1 这样的数值用二进制表示你就会发现无法整除,
最后算下来会是0.0001100110011001...由于存储空神龙教主间有限,位数是无限的,只能取近似。
代码:0.1 + 0.2 == 0.3 // false
代码:0.625 + 0.625 == 1.25 // true
进制转换规则:www.runoob.com/w3cnote/dec…
十进制整数 -> 转二进制
举例:(5).toString(2) // 结果 101
转换:除2反向取余
2| 5 --- 余1
------
2| 2 --- 余0
------
1
举例:(13).toString(2) // 结果 1101
转换:除2反向取余
2| 13 --- 余1
------
2| 6 --- 余0
------
2| 3 --- 余1
------
1
核对:https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fwww.pianshen.com%2Fimages%2F732%2Faf802c5be33b4934e6a6fec52a208734.png&refer=http%3A%2F%2Fwww.pianshen.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1642634303&t=293eccde3cc561a3ed2d38f8cf57eea9
十进制小数 -> 转二进制
举例:(0.625).toString(2) // 结果 0.101
转换:乘2正向取整
0.625*2=1.25======取出整数部分1
0.25*2=0.5========取出整数部分0
0.5*2=1==========取出整数部分1
举例:(0.7).toString(2) // 结果 0.101100110011001100110011 0011 ...
0.7*2=1.4========取出整数部分1
0.4*2=0.8========取出整数部分0
0.8*2=1.6========取出整数部分1
0.6*2=1.2========取出整数部分1
0.2*2=0.4========取出整数部分0
0.4*2=0.8========取出整数部分0
0.8*2=1.6========取出整数部分1
0.6*2=1.2========取出整数部分1
0.2*2=0.4========取出整数部分0
...
推荐解决: 浮点数转化成整数
实战使用:购物车结算时,商品价格(33.01)-优惠券价格(5),本应该是28.01,但是实际的结果是28.009999999999998
33.01 - 5 = 28.009999999999998
(33.01*100 - 5*100) / 100 = 28.01
- es6 解决办法
es6 提供了 Number.EPSILON,这个值等于 2^-52 ,这个值非常非常小,在底层计算机已经帮我们运算好,并且无限接近 0,但不等于 0,这个时候我们只要判断误差值在这个范围内就可以说明是相等的。
function numbersequal(a,b){
return Math.abs(a-b)<Number.EPSILON
}
var a = 0.1 + 0.2;
var b = 0.3;
console.log(numbersequal(a, b))
// true
- 四舍五入
采用四舍五入方法,取了一个 10 位小数
function numTofixed(num) {
if (typeof num == 'number') {
num = parseFloat(num.toFixed(10))
}
return num;
}
numTofixed(0.1 + 0.2);
// 0.3
- 使用第三方库
math.js
bignumber.js
....
💕 week2 对象/数组/内置对象/es6/正则
说出变量在内存中的分配 🐝
栈:存 原始类型【名字】&【数据】、对象类型的【名字】&【堆地址】
堆:对象类型数据
说出变量赋值内存分配 🐝
原始类型:变量赋值,栈开辟内存直接存数据 -> 数据互补影响对象类型:变量赋值,栈开辟内存,存放神龙教主堆地址 -> 数据相互影响
切记切记切记:上课讲的形参也会出现传值、传地址问题
var num = 100
function fn(n) {
// var 形参 = 实参
// var n = num也就是100 原始类型 传值 不影响
n = 200
console.log(n) // 打印: 200
}
fn(num)
console.log(num) // 打印: 100
// --------------------------
var obj = { name: 'Jack' }
function fn(o) {
// var 形参 = 实参
// var o = obj 对象类型赋值 传地址 相互影响
o.name = 'Rose' //
console.log(o.name) // 打印:Rose
}
fn(obj)
console.log(obj.name) // 打印:Rose
栈 堆
obj:地址1 地址1:{ name: 'Jack改成Rose' }
fn函数里面的o变量:地址1 函数调用完毕o变量就销毁了
@State装饰器响应式失效原因、如何解决 🐝❄️
鸿蒙规定一维数据变化有响应式,二维没有
解决:修改一维数据 或者 通过 @Observed、@ObjectLink装饰一下 放到子组件中用
或者
v2版@ObservedV2 @Trace
1 把@component改成@ComponentV2 @State改成private或者@Local
2 给子类加@ObservedV2 @Trace
判断是否是数组 🐝
方法1:通过语法 Array.isArray()
方法2:instanceof
方法3:原型链(constructor)
..... We
Array.isArray([1, 2 ,3]) // true
[1, 2 ,3] instanceof Array // true
[1, 2 ,3].constructor === Array // true
Array.isArray('aa') // false
'aa' instanceof Array // false
'aa'.constructor === Array // false
如何交换两个变量的值
- 临时变量法
var a = 1
var b = 2;
var temp = a
a = b
b = temp
- 加减法
var a = 1
var b = 2;
a = a+b // a = 1+2 = 3
b = a-b // b = 3-2 = 1
a = a-b // a = 3-1 = 2
- 解构赋值法 week3
var a = 1
var b = 2;
[a, b] = [b, a]
- 数组法
var a = 1
var b = 2
a = [a, b]
b = a[0]
a = a[1]
- 对象法
var a = 1
var b = 2
a = { a: b, b: a }
b = a.b
a = a.a
- 等等太多了
说出数组有哪些方法 🐝
·数据操作:shift/unshift/pop/push
遍历数据: forEach/map/filter/find/findIndex
工作常用:concat/join/indexOf/includes
学习常用:reverse/splice
了解:sort
说出数组哪些方法会改变原数据
数据操作:shift/unshift/pop/push
学习常用:reverse/splice
了解:sort
如何实现数组去重 🐝
方法1:通过es6新增的Set数据结构、和展开运算符去重 [...new Set(重复数组)] week3
方法2:通过filter配合indexOf实神龙教主现数组去重
方法3:定义空数组,通过forEach遍历重复的数组,通过indexOf判断当前值是否在数组中,不在就push
方法4:定义空数组,通过forEach遍历重复的数组,通过includes判断当前值是否在数组中,不在就push
方法5:利用对象的属性去重
方法6:....
// 方法1:通过es6新增的Set数据结构结果 [...new Set(重复数组)]
var arr = [1,2,3,2,3]
console.log([...new Set(arr)])
// 方法2:通过filter配合indexOf实现数组去重
var arr = [1,2,3,2,3]
var newarr = arr.filter(function(item, i) {
// i=0 item值是1 arr.indexOf(item) 返回0 真
// i=1 item值是2 arr.indexOf(item) 返回1 真
// i=2 item值是3 arr.indexOf(item) 返回2 真
// i=3 item值是2 arr.indexOf(item) 返回1 假
// i=4 item值是3 arr.indexOf(item) 返回2 假
return i === arr.indexOf(item);
})
console.log(newarr)
// 方法3:定义空数组,通过forEach遍历重复的数组,通过indexOf判断当前值是否在数组中,不在就push
var newarr = []
var arr = [1,2,3,2,3]
arr.forEach(function(item, i) {
if (newarr.indexOf(item) === -1)
{
newarr.push(item)
}
})
console.log(newarr)
// 方法4:定义空数组,通过forEach遍历重复的数组,通过includes判断当前值是否在数组中,不在就push
var newarr = []
var arr = [1,2,3,2,3]
arr.forEach(function(item, i) {
if (!newarr.includes(item))
{
newarr.push(item)
}
})
console.log(newarr)
// 方法5:利用对象的属性去重
var arr = [1,2,3,2,3]
var newarr = []
var obj = {}
arr.forEach(function(item) {
if (!obj[item]) {
obj[item] = item // 出现记录
newarr.push(item)
}
})
说出字符串常用方法
工作常用:数组、查找、替换、截取、大小、空格
split、replace、substr、toUpperCase/toLowerCase、trim、slice
学习:.length、lastIndexOf、repeat
正则
匹配手机号:/^1\d{10}$/.test(数据)
匹配邮箱:/^\w{2,20}@\w{2, 20}.(com|org|cn|edu)$/.test(数据)
匹配中文:/^[\u4e00-\u9fa5]+$/.test(数据)
去所有空格:str.replaceAll(' ', '') 或 str.replace(/\s/g, '')
购物车去非数字:str.replace(/\D/g, '')
es6新增语法
# 2015年 ES6
修饰符:let、const
解构赋值:let {键:变量名=默认值,...,键:变量名=默认值} = {键:值,....,键:值} !!!
字符串相关扩展:模板字符串
函数相关扩展:箭头函数!!!、函数形参默认值、rest参数 arguments
对象相关扩展:对象简写!!!、链判断操作符 ?. 空判断操作符??
数组相关扩展:find/findIndex/indexOf/includes
新增原始类型:symbol、bigint
-----------
// 重要 中等 中等 重要 重要
其他新增:展开运算符、新增数据结构、循环forof、module模块化、class类等等
# 2016年 ES7:Array.prototype.includes
# 2017年 ES8:Async functions
# 2018年 ES9:Promise.prototype.finally
# 2019年 ES10
# 2020年 ES11:String.prototype.matchAll、import()、BigInt、Promise.allSettled、
# 2021年 ES12:String.prototype.replaceAll、Promise.any、
# 2022年 ES13:
💕 week3 面向对象/事件
事件有哪些
通用事件按照触发类型来分类,包括触屏事件、键鼠事件、焦点事件和拖拽事件。
-
- 鼠标事件是指通过连接和使用外设鼠标/触控板操作时所响应的事件。
- 按键事件是指通过连接和使用外设键盘操作时所响应的事件。
- 焦点事件:通过以上方式控制组件焦点的能力和响应的事件。
- 拖拽事件:由触屏事件和键鼠事件发起,包括手指/手写笔长按组件拖拽和鼠标拖拽。
- 事件分发:描述触控类事件(不包括按键,焦点)响应链的命中收集过程。
手势事件由绑定手势方法和绑定的手势组成,绑定的手势可以分为单一手势和组合手势两种类型,根据手势的复杂程度进行区分。
-
绑定手势方法:用于在组件上绑定单一手势或组合手势,并声明所绑定的手势的响应优先级。
-
单一手势:手势的基本单元,是所有复杂手势的组成部分。
-
组合手势:由多个单一手势组合而成,可以根据声明的类型将多个单一手势按照一定规则组合成组合手势,并进行使用。
💕 week4 服务器请求 callback、promise、http、async/await
谈谈你对HTTP理解
面试:HTTP是超文本传输协议,规定了客户端和服务端如何通信,然后由两个部分组成分别是请求、响应
学习:就是一个规则,你必须按照这个规则才可以和后端交互拿数据
概念:超文本传输协议
作用:规定客户端和服务端通信技术
场景:网页、APP等
请求组成:请求行(地址/状态吗/请求方式)、请求头(ua、content-type、token、cookie)、请求体(接口参数)
响应组成:响应行(同上)、响应头(暂略)、响应体(接口数据调试错误)
HTTP周边:强制缓存、协商缓存
强制缓存:就是文件直接从本地缓存中获取,不需要发送请求。
响应头 Cache-Control : 86400
expires
协商缓存/对比缓存
在响应头部 Response Headers 中,有两种资源标识:
Last-Modified资源的最后修改时间,对应请求头为If-Modified-Since;Etag资源的唯一标识,所谓唯神龙教主一,可以想象成时人类的指纹,具有唯一性;但Etag的本质是一个字符串;对应请求头为If-None-Match。
Last-Modified 和 Etag
- 当响应头部
Response Headers同时存在Last-Modified和Etag的值时,会优先使用Etag; Last-Modified只能精确到秒级;- 如果资源被重复生成,而内容不变,则
Etag更精确。
请求行/响应行:method、url、status
请求头headers、cookie、content-type、ua、if-none-match、if-modified-since
请求体:请求参数
响应头:content-type 告诉浏览器如何解析数 、etag、last-modified、 Cache-Control...
响应体:响应的数据
get和post有什么区别
安全角度:post相对比get安全 原因get会在地址栏 因为在地址栏就有访问历史记录 post请求体不会存在来留痕
数据角度:post相对传输的数据比get多 get会受到不同浏览器地址栏长度限制,post服务器配置
上传图片:2M 一般只能上传png、jpg gif不允许
谈谈你对http、https的理解,有什么区别
http超文本通讯协议 80
https也是超文本通讯协议 相对http更加安全 443
谈谈你对Event Loop理解
遇到异步代码会交给其他线程处理,然后放到队列中,
事件循环主要是从队列中取出代码放到执行栈中交给js引擎线程处理
说出宏任务、微任务各有哪些
单词含义:I input 输入、 O output 输出
用户角度IO操作:鼠标键盘-是计算机输入信息,显示器-是输出设备
电脑角度IO操作:CPU、内存与其他设备之间数据转移过程就是IO操作,例如数据从磁盘读到内存,或者内存写到磁盘
编程角度IO操作:进程读取数据操作
- 宏任务:
整体代码(script)
定时器(setTimeout、setInterval)
I/O操作(DOM事件、AJAX异步请求)
setImmediate(node环境)
requestAnimationFrame(浏览器环境)
- 微任务
Promise.then catch finally
async/await(底层还是promise)
process.nextTick(node环境)
MutationObserver(浏览器环境)
说出先执行宏任务还是微任务
1宏n微
谈谈你对promise的理解
概念:ES6异步编程解决方案
作用:常用于封装ajax异步请求
说一下promise原理
底层创建了Promise构造函数,并且给该构造函数绑定了then、catch、finally等原型方法,和reject、resolve、all、race等静态方法。
说一下promise几个状态
进行中、成功了、失败了
const p = new Promise((resolve, reject) => {
// 发送异步请求
// 默认触发的 所以是进行中状态
})
追问:为什么状态不可逆
回答:底层Promise构造函数中会判断当前是否是pending进行中状态,不是就会终止代码执行 所以不可逆
// 明确:底层Promise源码大概是这么写的
function Promise(callback) {
this.PromiseState = 'pending'
this.PromiseResult = undefined
const resolve = data => {
if (this.PromiseState != 'pending') return
this.PromiseState = 'fulfilled'
this.PromiseResult = data
}
const reject = error => {
if (this.PromiseState != 'pending') return
this.PromiseState = 'rejected'
this.PromiseResult = error
}
try {
// callback(参数1, 参数2)
// callback(() => {}, () => {})
callback(resolve, reject)
} catch(error) {
reject(error.toString())
}
}
// 然后:你写
const p = new Promise((resolve, reject) => {
resolve(数据1)
reject(数据2)
})
追问:状态之间怎么转换?
回答:通过promise的then机制,来实现状态切换
const p = new Promise((resolve, reject) => {
resolve(数据1)
})
p
.then(res => {
return Promise.resolve('失败的')
})
Promise.all、Promise.allSettled区别
Promise.all( 数组里面是一个个Promise对象 ) 有一个失败就走失败
Promise.allSettled( 数组里面是一个个Promise对象 ) 没有失败
Promise.race、Promise.any区别
Promise.race( 数组里面是一个个Promise对象 ) 根据第一个最快返回的决定状态
Promise.any( 数组里面是一个个Promise对象 ) 有一个成功就是then 都失败 才是catch
谈谈你对同步异步的理解
会被加入到队列的代码称之为异步代码,例如 http、setTimeout/setInterval、Promise.then 等等,他们不按书写✍🏻顺序执行打印结果的代码
按照书写顺序执行打印的代码称之为同步代码
谈谈你对async,await的理解
通过async修饰function、await修饰promise,
底层将await后面的表达式会先执行一遍,再将await下一行代码加入到微任务中。
■ 鸿蒙核心
🥥 实战常问 🐝🐝🐝🐝🐝🐝🐝
-
装饰器
-
组件通信
-
生命周期
-
状态管理
-
数据持久化
-
进程线程
-
Stage 模型
-
Web 服务
-
组件导航
-
服务卡片
-
一次开发,多端部署
-
分层架构
🍃 api9、api12区别
- ArkTS类型更加严格
使用具体的类型而非any或unknown
不支持通过索引访问字段
对象字面量不能用于类型声明
对象不支持解构赋值
部分支持展开运算符 => 数组支持/对象不支持
不支持for .. in
等等
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V13/typescript-to-arkts-migration-guide-V13#%E4%B8%8D%E6%94%AF%E6%8C%81%E5%AF%B9%E5%87%BD%E6%95%B0%E5%A3%B0%E6%98%8E%E5%B1%9E%E6%80%A7
- @Prop api12可以设置默认值、并且可以支持对象。
- API12的文档和示例可能会更加丰富和完善,有助于开发者更快地掌握新特性并开始开发。
- 生态系统变化 -> API12发布时,鸿蒙的生态系统可能已经变得更加成熟,有更多的第三方应用和服务可供集成。
- 其他小细节
uniapp 把小程序项目生成wgt格式,然后通过鸿蒙点击直接打开小程序
...
🍃 路由
- 跳转模式、和区别
两种分别是pushUrl和replaceUrl
区别:low一点 一个保留当前页 一个不保留
逼格:pushUrl会向页面栈中追加新页面,replaceurl直接替换页面栈中的当前页
- 实例模式
有两种,分别是
Standard 多实例/标准实例
Single 单实例
如何选: 商品详情、搜索详情 页面栈中 仅仅想保留一份 就单实例 否则都是多实例
用过, 商品列表页跳转商品详情页并且呢需要保留页面栈,设置调转到主题页如果要保证每次只有一个主题切换页存在于页面栈中用单实例,搜索列表页跳转详情页如果该结果已经被查看过了不要再新建页面则使用单实例。
商品列表、商品详情
登录、会员中心
搜索列表、搜索详情 单实例
设置、主题 单实例 结论:取决于需求
- 谈谈你对页面栈的理解和特点
特点:1-可以更方便路由 前进后退,2-提升性能 例如返回直接访问 如果没有页面栈返回重新创建开销大
追问:push的c相对的页面栈,replaceurl的b页面是什么样子的,c在站点还是a在站点 =》push/replace页面栈发生变化
pushUrl 页面栈新增一个
reaplceUrl 直接替换页面栈
- 页面栈的最大容量,超过会怎么样
页面栈的最大容量为32个页面。
报错 =》 解决:如果超过这个限制,可以调用router.clear方法清空历史页面栈,释放内存空间。
- 路由传参
router.pushUrl({
url: '',
params: { a:1,b:2 }
})
const params = router.getParams() as ParamsType
console.log('接口参数:', JSON.stringify(params))
console.log('接口参数:', params.a)
🍃 自定义构建函数
- @Builder传参响应式失效如何解决
可以不传直接组件状态
或者引用传参(就是传对象)
- 如何实现全局Builder
wrapBuilder函数把@Builder包括导出就行
- 如何侦听@State数据变化
@Watch
- @BuilderParam是什么作用,其中的this指向是什么
组件通信传递UI数据的
或者
父传子,传递声明式组件数据
子中的this 因为是在子里面调用 => 如果要父中this 把父的函数改成箭头
- @Builder 是做什么的,和组件有什么区别
自定义构建函数,主要用来增加builder层代码可读性 把ui层代码做了轻量级封装
区别在于:没有生命周期、没有状态 轻量级封装; 如果仅仅是页面的封装直接@Builder 如果封装好了有很多状态、需要异步请求咱们得进一步封装成自定义组件。
- Builder里自定义传递参数,有两个传递方式,是哪两种;追问:参数类型和参数声明必须一致吗?能传undefined、null吗?
普通数据传递、和引用传递 也就是写对象格式传递状态才会有响应式效果
参数的类型必须与参数声明的类型一致,不允许undefined、null和返回undefined、null的表达式。
- 如何封装全局@Builder
通过wrapBuilder:封装全局@Builder
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/arkts-wrapbuilder-V5
🍃 装饰器 ⭐️
- 装饰器有哪些
- 装饰器区别
@State组件内状态、@Prop父子单向、@Link父子双向、
@Provide/@Consume后代组件双向同步、@Observed/@ObjectLink嵌套类对象属性变化
@StorageProp/@StorageLink
@Watch、@Builder、@BuilderParam
@Track
还有v2新增的状态管理:@ObservedV2/@Trace、@Local、@Param、@Once、@Event、@Provider/@Consumer、@Monitor、@Computed
- @Link和@Prop区别🐝🐝
1、@Prop单向,@Link 双向
2、@Prop可以设置默认值、@Link不可以
这个不说了:api9 @Prop不可以设置默认值,并且@Prop不支持对象; api12中@Prop升级了可以设置默认值,并且支持对象
- @state和@prop的区别
@State是组件状态 主要用来收集组件内的数据 例如表单, 也可以代替@Prop收数据 100%不用 所以后台状态管理升级了
@Prop是组件父把数据传递子组件中使用。
- 嵌套类型用什么装饰器
@Observed/@ObjectLink嵌套类对象属性变化
- 如果内部对象的数据变换,会实时刷新吗?🐝🐝
普通数组/对象可以
二维数据修改没有响应式,需要通过装饰器@Observed/@ObjectLink
- @prop,@Link需要初始化吗
@Link一直不行
@Prop api9不可以 api12中可以了
- @Provide装饰器和@Consume装饰器得理解
@Provide 是用来声明某个模块提供的服务,其他模块可以通过这个装饰器进行引用。
@Consume 是用来声明某个模块需要使用的服务,从而实现依赖注入。
这种机制使得模块之间的耦合度降低,提高了代码的可维护性和重用性。通过这样一种方式,开发者可以更方便地实现模块之间的协作,更加灵活地管理项目的结构。
- @State能修饰复杂的数据类型吗?或者说嵌套对象
可以
- @watch是用来干什么的 追问:监听到了会做什么操作呢
侦听数据变化的,减少视图层写事件 -> 看需求
- @Watch作用,第一次加载是否会调用
侦听状态变化,不会
- @State原理
- @ObjectLink的事件原理有了解过吗?
- 鸿蒙装饰器作用和原理
大概就是通过属性装饰器函数进行封装,
内部通过defineProperty劫持数据变化
// 属性装饰器函数
function logProperty(target: any, propertyKey: string) {
let value = target[propertyKey];
// 属性 getter
const getter = function () {
console.log(`Getting value of property ${propertyKey}: ${value}`);
return value;
};
// 属性 setter
const setter = function (newValue: any) {
console.log(`Setting value of property ${propertyKey} to: ${newValue}`);
value = newValue;
};
// 重新定义属性
Object.defineProperty(target, propertyKey, {
get: getter,
set: setter,
enumerable: true,
configurable: true,
});
}
// 示例类
class MyClass {
@logProperty myProperty: string = 'Initial value';
}
// 使用示例
const instance = new MyClass();
console.log(instance.myProperty); // 获取属性值
instance.myProperty = 'New value'; // 设置属性值
console.log(instance.myProperty); // 再次获取属性值
🍃 组件状态共享/组件通信 ⭐️
- 组件件的通信有几种方式🐝
不加修饰符
@Prop单向
@Link双向
@Observed/@ObjectLink嵌套类对象属性变化
@Provide/@Consume后代组件双向同步、
@StorageProp/@StorageLink全局状态存储也可以实现组件通信
路由传参params
@BuilderParam 传递声明式渲染数据
Emitter进行线程间通信 => on劫持数据传递,emit发送
import { emitter } from '@kit.BasicServicesKit';
emitter.on({
eventId: 通信唯一标识
}, (eventData: emitter.EventData): void => {
console.log('收到推送的消息:', JSON.stringify(eventData))
});
emitter.emit({
eventId: 通信唯一标识,
priority: emitter.EventPriority.LOW
}, {
data: {
content: 'c',
id: 1,
isEmpty: false
}
});
- 父向子传传有几种写法🐝
不加修饰符 或者写 @State都可以
@Prop单向
@Link双向
@Observed/@ObjectLink嵌套类对象属性变化
@BuilderParam 传递声明式渲染数据
状态管理v2 @Param
- 鸿蒙跨层级的组件通信
@Provide/@Consume后代组件双向同步
Emitter进行线程间通信 => on劫持数据传递,emit发送
@StorageProp/@StorageLink全局状态存储也可以实现组件通信神龙教主
🍃 生命周期 ⭐ ️
- 分别说出组件、页面、UIAbility生命周期和场景 🐝🐝
※ 页面
页面渲染前 aboutToAppear 请求接口
build
页面渲染后 onPageShow 访问量+1、登陆成功返回登录页展示最新的头像 只要来这个重新看这个页面就触发
页面隐藏 onPageHide
页面销毁 aboutToDisappear 清空定时器 释放内存
系统返回 onBackPress 返回true终止路由跳转、false-正常 按两下退出
※ 组件(自定义组件)
aboutToAppear 获取路由参数/请求接口
build
onDidBuild 埋点(统计这个页面访问次数 很少很少)
aboutToDisappear 清空非鸿蒙资源
※ 应用/窗口/UIAbility
onCreate 打开一个新窗口传递参数可以在这里获取;服务卡片跳转过来传递的参数也可以获取
onWindowStageCreate 注册首选项、规定首次加载的页面
onForeground 切换前台
onBackground 切换后台
onWindowStageDestory
onDestory
- 自定义组件的生命周期
aboutToAppear 获取路由参数/请求接口
build
onDidBuild 埋点
aboutToDisAppear 清空非鸿蒙资源
- aboutToAppear在什么时候执行
build之前
🍃 状态管理 ⭐️
状态管理有哪些🐝🐝
组件状态 @State、@Prop、@Link、@Provide/@Consum、@Observed/@ObjectLink
应用状态 LocalStorage、AppStorage、PersistentStorage、Environment
以及状态管理周边的@Watch、$$运算符、@Track
还有v2新增的状态管理:@ObservedV2/@Trace、@Local、@Param、@Once、@Require、@Event、@Provider/@Consumer、@Monitor、@Computed
面试官提问方式
- 状态管理,修饰器有哪些
- 大概谈一谈你的那些状态管理,都用到了那些
- 组件拥有的状态管理
- 鸿蒙中的全局状态管理有哪些方式
AppStorage和Localstorage区别、哪里用?
区别
- 场景:LocalStorage页面状态、AppStorage全局状态
- 功能:AppStorage配合PersistentStorage支持数据持久化
场景:在多个页面之间进行状态数据共享有哪些方法
v2新增了哪些装饰器,为什么升级,有什么用🐝🐝
@ObservedV2/@Trace 属性深度侦听 可以解决对象深层数据响应式失效问题、还有@ObjectLink父级UI不刷新问题
@Local 纯组件内部状态、更加语义化 @State也可以接收父组件传递的数据
@Param 父传子 v1太多了
@Once 初始化同步一次
@Event 子调用父的方法改变数据 子改父
@Provider/@Consumer 跨组件层级双向同步,更强了支持方法、支持初始化、支持别名等
@Monitor 状态变量修改侦听,更强可以一次性侦听多个,可以获取修改前后数据
@Computed 计算属性
🍃 数据持久化 ⭐️
数据持久化用过哪些? 🐝🐝
用户首选项(Preferences):通过用户保存应用的配置信息,用户的个性化设置(字体大小,是否开启夜间模式)、用户登录信息、手动选择的城市、下拉上次更新日期、首页统计数据缓存、搜索历史、个人中心数据统计、个人中心最近学习缓存等等
键值型数据接口(KV-Store):非关系数据库,一般用的少,可以用于同应用跨设备数据同步,数据库备份与恢复。
关系型数据库(RelationalStore): 关系型数据库,可用来存储离线数据,例如离线收藏、使用搜索记录、视频缓存到本地列表等大量数据库缓存、首页列表数据缓存、下载、收藏、最近缓存。
首选项如何实现持久化 🐝🐝
a.utils目录下创建PreferenceUtil.ets工具
- this.dataPreferences = preferences.getPreferencesSync(context, {name: 'myStore'})
- this.dataPreferences?.putSync(key, value)
- this.dataPreferences?.flushSync()
- this.dataPreferences?.getSync()
b.entryability中初始化PreferenceUtil实例
c.import {preferencesUtil} from '../utils/PreferenceUtil'
preferencesUtil.get()
preferencesUtil.put()
preferencesUtil.set()
首选项和PersistentStorage区别
1.PersistentStorage是同步写入磁盘;Preferences是全量加载进内存。
2.PersistentStorage的持久化变量最好是小于2kb的数据;Preferences存储的数据不超过一万条 累计不超过50M,Key键为string类型,要求非空且长度不超过80个字节,如果Value值为string类型,长度不超过8192个字节。
3.PersistentStorage只能在UI页面内使用。
4.PersistentStorage不要大量的数据持久化,因为PersistentStorage写入磁盘的操作是同步的,大量的数据本地化读写会同步在UI线程中执行,影响UI渲染性能。如果开发者需要存储大量的数据,建议使用数据库api。
5.PersistentStorage存储AppStorage属性UI状态,以确保这些属性在应用程序重新启动时的值与应用程序关闭时的值相同;Preferences一般为应用保存用户的个性化设置(字体大小,是否开启夜间模式)等
PersistentStorage进行数据持久化,和preferences用户首选项使用场景?
PersistentStorage适合存小于2Kb的数据,
preferences 适合存用户的个性化设置(字体大小,是否开启夜间模式)、用户登录信息等数据,但是不要超多一万条神龙教主 累计不超过50M。
非UI页面使用用户首选项时context如何获取
可以在UIAbility里通过AppStorage或LocalStorage里存储context,然后在非ArkUI页面里使用。
this.context
getContext(this)
通过用户首选项实现数据持久化之后,如果App更新版本,之前首选项的数据是否会保留
持久化之后的数据是存在文件中的,App更新版本之后是存在的。
关系型数据库(RelationalStore)如何实现
a.utils目录下创建RdbUtil.ets工具
1. loadRdbStore relationalStore.getRdbStore、 store.executeSql(tableSql)、保存起来
2. 调用insert/delete/query实现增删查功能
b.entryability中初始化RdbUtil实例
c.import {xxxTable} from '../utils/RdbUtil'
xxxTable.get()
xxxTable.put()
xxxTable.set()
键值型数据接口(KV-Store)如何实现
a.utils目录下创建KVStoreUtil.ets工具
import { distributedKVStore } from "@kit.ArkData";
import { common } from "@kit.AbilityKit";
export type KVValueType = Uint8Array | string | number | boolean
class KVStoreUtil {
private kvStore: distributedKVStore.SingleKVStore | undefined = undefined;
// 创建商店对象
loadKVStore(context:common.UIAbilityContext) {}
// 操作商店对象
put(key:string, value: KVValueType) { }
get(key:string) { }
delete(key:string) {}
}
export const kvStoreUtil = new KVStoreUtil()
b.entryability中初始化KVStoreUtil实例
c.import {kvStoreUtil} from '../utils/KVStoreUtil'
kvStoreUtil.get()
kvStoreUtil.put()
kvStoreUtil.set()
跨模块、跨进程时如何保证正常读取首选项中数据?
跨模块:
由于context不同,获取到的是不同的首选项实例,因此会导致在跨模块、多页面等场景下取不到数据。此时可以考虑通过单例类在EntryAbility中存一个全局的context,或者使用应用级context。
跨进程:
不同进程一般只有在同沙箱的场景才能访问到一个preference文件,多进程可以通过dataGroupId在多个进程间共用一个preference文件。
首选项存储如果多个任务同时写入、并发的情况能保证数据的准确性吗
首选项无法保证进程并发安全,会有文件损坏和数据丢失的风险,不支持在多进程场景下使用。
对于多线程操作首选项和数据库是不是线程安全的?还是每一个线程独立的
是线程安全的。
用户首选项是线程安全的吗
首选项是线程安全的,所以多线程访问可以保证数据一致性,但只支持同进程,不支持多进程。
不依赖接口 用手机存储 怎么存储
自己通过文件管理直接写数据然后获取出来
let filePath = pathDir + "/test.txt";
let file = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
let str: string = "hello, world";
let writeLen = fs.writeSync(file.fd, str);
console.info("write data to file succeed and size is:" + writeLen);
fs.closeSync(file);
🍃 进程线程 ⭐️
- 谈谈你对进程线程的理解 🐝🐝
进程:
- 进程是正在执行的程序的实例,它是系统资源分配的基本单位。
- 进程是一个独立的程序运行环境
- 是系统进行资源分配的基本单位
线程:
- 线程是进程内部的执行单位
- 是操作系统能够进行运算调度的最小单位。
- 鸿蒙线程之间内存共享吗?
developer.huawei.com/consumer/cn…
不共享
ArkTS采用消息通信的Actor并发模型 所以具有内存隔离特性
java是内存共享的并发模型
- 鸿蒙arkts是单线程还是多线程、为什么是单线程呢
- 鸿蒙 stage 是单线程还是多线程
ArkTS是单线程的,单线程可以避免锁机制
- 鸿蒙如何实现多线程 🐝🐝
taskpool、worker
- 进程/线程通信🐝🐝
| 通信场景 | 能力支持 | |
|---|---|---|
| 同Ability通信 | Emitter、EventHub、CommonEvent | |
| 跨Ability通信 | Emitter、EventHub、CommonEvent | |
| 跨线程通信 | Emitter、CommonEvent、Worker、Taskpool | |
| 跨进程通信 | CommonEvent、IPC&RPC |
dataShare跨应用通信:用于应用管理其自身数据,同时支持同个设备上不同应用间的数据共享。
// eventHub 线程内通信
// 侦听 getContext(this).eventHub.on('自定义监听频道', (data:string,数据n:类型...) => console.log(data) )
// 推送 getContext(this).eventHub.emit('自定义监听频道', 数据, 数据n)
// emitter 线程间通信
// 侦听 emitter.on( {eventId:通信唯一标识}, (e:emitter.EventData) => console.log(data) )
// 推送 emitter.emit( {eventId:通信唯一标识}, { data:{content:数据} } )
// 公共事件-自定义事件
// 侦听 createSubscriber、subscribe
// 推送 publish
- 在taskpool中,@Concurrent任务池子线程返回数据,execute执行任务获取数据 从而实现线程通信
- 在taskpool中,@Concurrent任务池子线程通过taskpool.Task.sendData发送数据,宿主线程监听到的数据task.onReceiveData((result: string) => {})
- 在worker中,彼此通过postMessage发送、通过onmessage获取消息
- 在worker中,Worker同步调用宿主线程的接口
```
宿主线程 workerInstance.registerGlobalCallObject("picData", picData);
然后,在Worker中通过callGlobalCallObjectMethod接口就可以调用宿主线程中的setUp()方法了。
```
- taskpool和worker区别 🐝🐝
短时任务用TaskPool、长时任务用worker
场景对比
TaskPool和Worker均支持多线程并发能力。由于TaskPool的工作线程会绑定系统的调度优先级,并且支持负载均衡(自动扩缩容),而Worker需要开发者自行创建,存在创建耗时以及不支持设置调度优先级,故在性能方面使用TaskPool会优于Worker,因此大多数场景推荐使用TaskPool。
TaskPool偏向独立任务维度,该任务在线程中执行,无需关注线程的生命周期,超长任务(大于3分钟且非长时任务)会被系统自动回收;而Worker偏向线程的维度,支持长时间占据线程执行,需要主动管理线程生命周期。
常见的一些开发场景及适用具体说明如下:
- 运行时间超过3分钟(不包含Promise和async/await异步调用的耗时,例如网络下载、文件读写等I/O任务的耗时)的任务。例如后台进行1小时的预测算法训练等CPU密集型任务,需要使用Worker。场景示例可参考常驻任务开发指导。
- 有关联的一系列同步任务。例如在一些需要创建、使用句柄的场景中,句柄创建每次都是不同的,该句柄需永久保存,保证使用该句柄进行操作,需要使用Worker。场景示例可参考使用Worker处理关联的同步任务。
- 需要设置优先级的任务。例如图库直方图绘制场景,后台计算的直方图数据会用于前台界面的显示,影响用户体验,需要高优先级处理,需要使用TaskPool。
- 需要频繁取消的任务。例如图库大图浏览场景,为提升体验,会同时缓存当前图片左右侧各2张图片,往一侧滑动跳到下一张图片时,要取消另一侧的一个缓存任务,需要使用TaskPool。
- 大量或者调度点较分散的任务。例如大型应用的多个模块包含多个耗时任务,不方便使用Worker去做负载管理,推荐采用TaskPool。场景示例可参考批量数据写数据库场景。
TaskPool和Worker的实现特点对比
| 实现 | TaskPool | Worker |
|---|---|---|
| 内存模型 | 线程间隔离,内存不共享。 | 线程间隔离,内存不共享。 |
| 参数传递机制 | 采用标准的结构化克隆算法(Structured Clone)进行序列化、反序列化,完成参数传递。支持ArrayBuffer转移和SharedArrayBuffer共享。 | 采用标准的结构化克隆算法(Structured Clone)进行序列化、反序列化,完成参数传递。支持ArrayBuffer转移和SharedArrayBuffer共享。 |
| 参数传递 | 直接传递,无需封装,默认进行transfer。 | 消息对象唯一参数,需要自己封装。 |
| 方法调用 | 直接将方法传入调用。 | 在Worker线程中进行消息解析并调用对应方法。 |
| 返回值 | 异步调用后默认返回。 | 主动发送消息,需在onmessage解析赋值。 |
| 生命周期 | TaskPool自行管理生命周期,无需关心任务负载高低。 | 开发者自行管理Worker的数量及生命周期。 |
| 任务池个数上限 | 自动管理,无需配置。 | 同个进程下,最多支持同时开启64个Worker线程,实际数量由进程内存决定。 |
| 任务执行时长上限 | 3分钟(不包含Promise和async/await异步调用的耗时,例如网络下载、文件读写等I/O任务的耗时),长时任务无执行时长上限。 | 无限制。 |
| 设置任务的优先级 | 支持配置任务优先级。 | 不支持。 |
| 执行任务的取消 | 支持取消已经发起的任务。 | 不支持。 |
| 线程复用 | 支持。 | 不支持。 |
| 任务延时执行 | 支持。 | 不支持。 |
| 设置任务依赖关系 | 支持。 | 不支持。 |
| 串行队列 | 支持。 | 不支持。 |
| 任务组 | 支持。 | 不支持。 |
- Worker和TaskPool的线程数量是否有限制
TaskPool内部会动态调整线程个数,不支持设置数量,只需要往线程池中抛任务,确保高优先级任务的及时执行。
Worker的线程个数最多64个,如果Worker超过规定个数,会创建失败。
在使用时,TaskPool与Worker两者独立,不相互影响,因此Worker在达到上限数量时,不会影响TaskPool。Worker是固定数量,当前是64个。TaskPool线程池的数量会根据硬件条件、任务负载等情况动态调整。
- 鸿蒙有几个进程线程
系统的进程模型如下图所示。
- 通常情况下,应用中(同一Bundle名称)的所有UIAbility、ServiceExtensionAbility和DataShareExtensionAbility均是运行在同一个独立进程(主进程)中,如下图中绿色部分的“Main Process”。
UIAbility 页面能力 窗口 -> 加载页面
ServiceExtensionAbility 后台服务扩展能力,提供后台运行并对外提供相应能力。
DataShareExtensionAbility 数据共享扩展能力,用于对外提供数据读写服务。
- 应用中(同一Bundle名称)的所有同一类型ExtensionAbility(除ServiceExtensionAbility和DataShareExtensionAbility外)均是运行在一个独立进程中,如下图中蓝色部分的“FormExtensionAbility Process”、“InputMethodExtensionAbility Process”、其他ExtensionAbility Process。
FormExtensionAbility:卡片扩展能力,提供卡片开发能力。
WorkSchedulerExtensionAbility:延时任务扩展能力,允许应用在系统闲时执行实时性不高的任务。
InputMethodExtensionAbility:输入法扩展能力,用于开发输入法应用。
AccessibilityExtensionAbility:无障碍服务扩展能力,支持访问与操作前台界面。
FileShareExtensionAbility:文件共享扩展能力,用于应用间的文件分享。预留能力,仅系统应用支持。
等等
- WebView拥有独立的渲染进程,如下图中黄色部分的“Render Process”。
鸿蒙加载h5网页
图1 进程模型示意图
编辑
- Stage模型下的线程主要有如下三类:
-
- 主线程
-
- TaskPool Worker线程
-
-
- 用于执行耗时操作,支持设置调度优先级、负载均衡等功能,推荐使用。
-
-
- Worker线程
-
-
- 用于执行耗时操作,支持线程间通信。
-
TaskPool与Worker的运作机制、通信手段和使用方法可以参考TaskPool和Worker的对比。
编辑
- 主线程有哪些职责呢
- 主线程
- 如何实现异步开发/并发/处理
Promise和async/await
- 谈谈你对pormise的理解,有几个状态
异步编程解决方案
romise有三种状态:pending(进行中)、fulfilled(已完成)和rejected(已拒绝)。Promise对象创建后处于pending状态,并在异步操作完成后转换为fulfilled或rejected状态。
- promise和async、await关系
promise是异步编程解决方案
async/await是一种用于处理异步操作的Promise语法糖,使得编写异步代码变得更加简单和易读。通过使用async关键字声明一个函数为异步函数,并使用await关键字等待Promise的解析(完成或拒绝),以同步的方式编写异步操作的代码。
一般做项目接口返回的数据都保存在promise中,咱们通过async/await就可以获取出来
- 异步处理和主线程有什么关系、如何通信
异步操作的设计初衷就是为了减少对主线程(UI线程)的影响,从而提升应用的响应性和用户体验。
通信直接修改@State状态就行
- 你的项目中有没有用到多线程
developer.huawei.com/consumer/cn…
- promise 并发是单线程还是多线程
遇到pormise会放到微任务队列,然后等js线程执行完毕,再由event loop事件循环对象把promise数据交给js线程处理
- 子线程和主线程的优先级及任务执行策略是什么
主线程作为UI线程,拥有最高优先级。在负载较高时,执行会更快;负载较低时,效率差别不大。
子线程可以通过优先级设置,任务优先级等影响调度。
- 在多线程并发场景中,如何实现安全访问同一块共享内存
可以使用共享对象SharedArrayBuffer实现。SharedArrayBuffer对象存储的数据在同时被修改时,需要通过Atomics原子操作保证其同步性,即下个操作开始之前务必需要等到上个操作已经结束。
🍃 文件基础服务
谈谈你对沙箱的理解
应用沙箱是一种以安全防护为目的的隔离机制,避免数据受到恶意路径穿越访问。在这种沙箱的保护机制下,应用可见的目录范围即为“应用沙箱目录”。
-
对于每个应用,系统会在内部存储空间映射出一个专属的“应用沙箱目录”,它是“应用文件目录”与一部分系统文件(应用运行必需的少量系统文件)所在的目录组成的集合。
-
应用沙箱限制了应用可见的数据范围。在“应用沙箱目录”中,应用仅能看到自己的应用文件以及少量的系统文件(应用运行必需的少量系统文件)。因此,本应用的文件也不为其他应用可见,从而保护了应用文件的安全。
-
应用可以在“应用文件目录”下保存和处理自己的应用文件;系统文件及其目录对于应用是只读的;而应用若需访问用户文件,则需要通过特定API同时经过用户的相应授权才能进行。
沙箱目录和缓存清除机制
-
cache 应用缓存目录(应用cache目录大小超过配额或者系统空间达到一定条件,自动触发清理该目录下文件;随应用卸载而清理)
-
preferences 用户首选项(随应用卸载而清理)
-
temp 应用临时文件路径(应用退出后即清理。保存应用的临时生成的数据,主要包括数据库缓存、图片缓存、临时日志文件、以及下载的应用安装包文件等。此路径下存储使用后即可删除的数据)
-
files 默认长期保存的文件路径;随应用卸载而清理。
图片上传
1、使用Picker选择媒体库的图片与视频 (返回一个临时的图片地址 file:// 咱们可以直接预览 也可以继续向后走 拿到服务器地址再预览)
const photoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
...
const photoViewPicker = new photoAccessHelper.PhotoViewPicker();
photoViewPicker.select(photoSelectOptions).then().catch()
2、把图片拷贝到应用的缓存目录 (不支持直接通过相册本地路径请求接口,仅支持通过缓存目录上传 context.cacheDir)
- 2.1 通过 openSync 读取 媒体库文件
- 2.2 通过 fileIo.copyFileSync(file.fd媒体文件, copyFilePath沙箱文件)
细节:copyFilePath沙箱中文件生成规则必须唯一
fileType
fileName 时间戳 + 随机数1000~9999 + . 后缀 (别忘了含后缀)
copyFilePath
3、上传文件 request.uploadFile到服务器
request.uploadFile(上下文信息, 匹配对象{
请求方式method: 'POST'
请求地址url: '',
请求头
header: {},
请求参数
files: [
{ name: '参数名', uri: "internal://cache/沙箱目录下的文件名含后缀", type:'',filename:'文件名含后缀' }
]
})
.then(uploadTask => {
// uploadTask.on(类型, 回调函数)
// progress 订阅上传任务进度事件,使用callback异步回调。
// headerReceive 服务器返回的
// complete 订阅上传任务完成
// fail 订阅上传任务失败
})
拍照上传
// 原来:Picker选择媒体库资源
// 现在:拍照返回的也是媒体库资源路径file://...
let pickerProfile: picker.PickerProfile = {
cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK
};
let pickerResult: picker.PickerResult = await picker.pick(getContext(this),
[picker.PickerMediaType.PHOTO, picker.PickerMediaType.VIDEO], pickerProfile);
console.log("the pick pickerResult is:" + JSON.stringify(pickerResult));
this.uris.push(pickerResult.resultUri)
和图片上传一样,区别 camera.CameraPosition.CAMERA_POSITION_BACK
附件上传
import { picker } from '@kit.CoreFileKit';
import { fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
import { BusinessError, request } from '@kit.BasicServicesKit';
import log from '@open/log';
@Entry
@Component
struct Test {
build() {
Button('选择文档上传').onClick(() => {
// 一、获取临时的图片地址
const documentSelectOptions = new picker.DocumentSelectOptions();
// 创建文件选择器实例
const documentViewPicker = new picker.DocumentViewPicker(getContext(this));
documentViewPicker.select(documentSelectOptions).then((documentSelectResult: Array<string>) => {
//文件选择成功后,返回被选中文档的uri结果集。
// uris = documentSelectResult;
console.info('documentViewPicker.select to file succeed and uris are:' + documentSelectResult);
// 二、拷贝到沙箱目录 cacheDir
const context = getContext(this)
const fileType = documentSelectResult[0].split('.').pop() as string // ✅ 小心
const fileName = Date.now() + '_' + (Math.floor(Math.random() * (99999 - 11111)) + 11111) + '.' + fileType
const copyFilePath = context.cacheDir + '/' + fileName
const file = fileIo.openSync(documentSelectResult[0], fileIo.OpenMode.READ_ONLY)
fileIo.copyFileSync(file.fd, copyFilePath)
// 三、调用request上传
let uploadTask: request.UploadTask;
let uploadConfig: request.UploadConfig = {
url: 'http://123.56.141.187:8001/upload/create', // 需要手动将 url 替换为真实服务器的 HTTP 协议地址
header: { 'Accept': '*/*' },
method: "POST",
files: [
{ filename: fileName, name: "file", uri: "internal://cache/"+fileName, type: fileType }
],
data: [{ name: "serverImageName", value: fileName }],
};
try {
request.uploadFile(getContext(), uploadConfig).then((data: request.UploadTask) => {
// -----
data.on('progress', (uploadedSize: number, totalSize: number) => {
console.log('上传中:', totalSize, uploadedSize)
})
data.on('headerReceive', (data: object) => {
console.log('订阅上传任务HTTP响应事件:'+JSON.stringify(data))
log.init({ close:false })
log.info((data as Record<string,object>).body)
})
data.on('complete', () => {
console.log('上传完成 http://tmp00002.zhaodashen.cn/'+fileName)
})
// -----
}).catch((err: BusinessError) => {
console.error(`Failed to request the upload. Code: ${err.code}, message: ${err.message}`);
});
} catch (err) {
console.error(`Failed to request the upload. err: ${JSON.stringify(err)}`);
}
// -----------------
}).catch((err: BusinessError) => {
console.error(`Invoke documentViewPicker.select failed, code is ${err.code}, message is ${err.message}`);
})
})
}
}
文件下载
request.downloadFile(getContext(), {
url,
filePath: this.filePath + fileName // 字符串变数组 [...,'node-v22.16.0.pkg'] 通过pop弹出最后数据 pop是一个函数 返回弹出的数据
}).then(uploadTask => {
uploadTask.on("progress", (receivedSize: number, totalSize: number) => {
// receivedSize 当前下载字节
// totalSize 总字节
console.log('数据:', receivedSize, totalSize)
this.currentSize = receivedSize
this.totalSize = totalSize
})
uploadTask.on("complete", () => {
console.log('下载完成')
})
})
}
资源目录存媒体库
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
import { promptAction } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
async function savePhotoToGallery(context: common.UIAbilityContext) {
let helper = photoAccessHelper.getPhotoAccessHelper(context);
try {
// onClick触发后5秒内通过createAsset接口创建图片文件,5秒后createAsset权限收回。
let uri = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'jpg');
// 使用uri打开文件,可以持续写入内容,写入过程不受时间限制
let file = await fileIo.open(uri, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
// $r('app.media.startIcon')需要替换为开发者所需的图像资源文件
context.resourceManager.getMediaContent($r('app.media.startIcon').id, 0)
.then(async value => {
let media = value.buffer;
// 写到媒体库文件中
await fileIo.write(file.fd, media);
await fileIo.close(file.fd);
promptAction.showToast({ message: '已保存至相册!' });
});
}
catch (error) {
const err: BusinessError = error as BusinessError;
console.error(`Failed to save photo. Code is ${err.code}, message is ${err.message}`);
}
}
@Entry
@Component
struct Index {
build() {
Row() {
Column({ space: 10 }) {
// $r('app.media.startIcon')需要替换为开发者所需的图像资源文件
Image($r('app.media.startIcon'))
.height(400)
.width('100%')
SaveButton()
.padding({top: 12, bottom: 12, left: 24, right: 24})
.onClick(async (event: ClickEvent, result: SaveButtonOnClickResult) => {
if (result === SaveButtonOnClickResult.SUCCESS) {
const context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
// 免去权限申请和权限请求等环节,获得临时授权,保存对应图片
savePhotoToGallery(context);
} else {
promptAction.showToast({ message: '设置权限失败!' })
}
})
}
.width('100%')
}
.height('100%')
.backgroundColor(0xF1F3F5)
}
}
沙箱文件存媒体库
编辑
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
import { promptAction } from '@kit.ArkUI';
import { BusinessError, request } from '@kit.BasicServicesKit';
import { image } from '@kit.ImageKit';
async function savePhotoToGallery(context: common.UIAbilityContext, url:string) {
let helper = photoAccessHelper.getPhotoAccessHelper(context);
try {
// onClick触发后10秒内通过createAsset接口创建图片文件,10秒后createAsset权限收回。
let uri = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'jpg');
// 使用uri打开文件,可以持续写入内容,写入过程不受时间限制
let file = await fileIo.open(uri, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
// 写到媒体库文件中
const source = image.createImageSource(url) // 图片对象
const packer = image.createImagePacker() // 创建打包对象
const buffer = await packer.packing(source, {
quality: 100,
format: 'image/jpeg'
})
await fileIo.write(file.fd, buffer);
await fileIo.close(file.fd);
promptAction.showToast({ message: '已保存至相册!' });
}
catch (error) {
const err: BusinessError = error as BusinessError;
console.error(`Failed to save photo. Code is ${err.code}, message is ${err.message}`);
}
}
@Entry
@Component
struct Index {
build() {
Row() {
Column({ space: 10 }) {
// $r('app.media.startIcon')需要替换为开发者所需的图像资源文件
Image($r('app.media.startIcon'))
.height(400)
.width('100%')
SaveButton()
.padding({top: 12, bottom: 12, left: 24, right: 24})
.onClick(async (event: ClickEvent, result: SaveButtonOnClickResult) => {
if (result === SaveButtonOnClickResult.SUCCESS) {
const context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
// 免去权限申请和权限请求等环节,获得临时授权,保存对应图片
// =======================
try {
// 需要手动将 url 替换为真实服务器的 HTTP 协议地址
request.downloadFile(
getContext(),
{
url: 'http://tmp00002.zhaodashen.cn/jd.png',
filePath: context.filesDir + '/hello3.jpg'
}
).then((data: request.DownloadTask) => {
let downloadTask: request.DownloadTask = data;
downloadTask.on('progress', (receivedSize: number, totalSize: number) => {
console.log('下载中:', receivedSize, totalSize)
})
downloadTask.on('complete', () => {
console.log('下载完成') // 保存到媒体库中✅
savePhotoToGallery(context, context.filesDir + '/hello3.jpg');
})
}).catch((err: BusinessError) => {
console.error(`Failed to request the download. Code: ${err.code}, message: ${err.message}`);
})
} catch (err) {
console.error(`Failed to request the download. err: ${JSON.stringify(err)}`);
}
// =======================
} else {
promptAction.showToast({ message: '设置权限失败!' })
}
})
}
.width('100%')
}
.height('100%')
.backgroundColor(0xF1F3F5)
}
}
🍃 程序访问控制
申请权限
a 在配置文件中声明权限
b 校验当前是否已经授权 checkAccessToken
c 动态向用户申请授权 requestPermissionsFromUser
d 处理授权结果 requestPermissionOnSetting 如果用户授权,则可以继续访问目标操作。 如果用户拒绝授权,则需要提示用户必须授权才能访问当前页面的功能,并引导用户到系统应用“设置”中打开相应的权限。 在common架构base模块下封装授权文件
developer.huawei.com/consumer/cn…
地理位置
1-配置申请位置权限 同时申请ohos.permission.APPROXIMATELY_LOCATION和ohos.permission.LOCATION 获取到精准位置,精准度在米级别。
2-检查全局位置开关是否打开 geoLocationManager.isLocationEnabled();
- 2.1 没开 atManager.requestGlobalSwitch(getContext(this), abilityAccessCtrl.SwitchType.LOCATION) 拉起全局设置位置开关页
- 2.2 开了 继续
3-获取当前位置 geoLocationManager.getCurrentLocation(request) 可以获取用户经度纬度
4-可以继续通过geoLocationManager.getAddressesFromLocation()地理编码转化为可读性较强地址
- 获取设备的位置信息,需要有位置权限,位置权限申请
// 位置信息(权限组)
{
"name": "ohos.permission.APPROXIMATELY_LOCATION",
"reason": '$string:permission_reason_location',
"usedScene": {}
},
{
"name": "ohos.permission.LOCATION",
"reason": '$string:permission_reason_location',
"usedScene": {}
},
按照步骤申请权限 developer.huawei.com/consumer/cn…
import AccessControl from '../utils/AccessControl'
import { geoLocationManager } from '@kit.LocationKit';
import { abilityAccessCtrl } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
@Entry
@Component
struct Test {
async getLocation() {
try {
// 2. 位置开关是否打开
const locationEnabled = geoLocationManager.isLocationEnabled();
console.log('slj 2 检查全局位置开发是否打开')
if (!locationEnabled) {
// 用于UIAbility/UIExtensionAbility拉起全局开关设置弹框
const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
const state = await atManager.requestGlobalSwitch(getContext(this), abilityAccessCtrl.SwitchType.LOCATION).then((data: Boolean) => {
console.info('slj 2延伸 success/cancel 拉起设置定位弹窗:' + JSON.stringify(data));
return data // boolean
}).catch((err: BusinessError) => {
console.error('slj 2延伸 error 拉起设置定位弹窗:' + JSON.stringify(err));
return false
});
if (!state) return
}
// 3. 获取当前位置
let request: geoLocationManager.SingleLocationRequest = {
'locatingPriority': geoLocationManager.LocatingPriority.PRIORITY_LOCATING_SPEED,
'locatingTimeoutMs': 10000
}
try {
geoLocationManager.getCurrentLocation(request).then((result) => { // 调用getCurrentLocation获取当前设备位置,通过promise接收上报的位置
console.log('current location: ' + JSON.stringify(result));
// current location: {"latitude":40.11524270229282,"longitude":116.24535759507577,"altitude":0,"accuracy":12.354018,"speed":0,"timeStamp":1735883027842,"direction":0,"timeSinceBoot":2116903847068,"additionSize":1,"additions":["requestId:d3485aca-b24b-451a-8057-26fc7600731f"],"additionsMap":{},"altitudeAccuracy":0,"speedAccuracy":0,"directionAccuracy":0,"uncertaintyOfTimeSinceBoot":0,"sourceType":2}
try {
geoLocationManager.getAddressesFromLocation({
maxItems: 1, //指定返回位置信息的最大个数,
longitude: result.longitude,
latitude: result.latitude,
locale: 'zh',
}, (err, data) => {
if (err) {
console.log('getAddressesFromLocation err: ' + JSON.stringify(err));
return
}
console.log('getAddressesFromLocation data: ' + JSON.stringify(data));
console.log('纬度:', data[0].latitude)
console.log('经度:', data[0].longitude)
console.log('地址:', data[0].placeName)
console.log('表示国家信息。countryName:', data[0].countryName)
console.log('表示国家以下的一级行政区,一般是省/州。administrativeArea:', data[0].administrativeArea)
console.log('表示国家以下的二级行政区,一般是市。subAdministrativeArea:', data[0].subAdministrativeArea)
console.log('表示子城市信息,一般是区/县。subLocality:', data[0].subLocality)
console.log('表示路名信息。locality:', data[0].locality)
console.log('表示路名信息。:', data[0].locality)
});
} catch (err) {
console.error("errCode:" + JSON.stringify(err));
}
// ....
})
.catch((error:BusinessError) => { // 接收上报的错误码
console.error('promise, getCurrentLocation: error=' + JSON.stringify(error));
});
} catch (err) {
console.error("errCode:" + JSON.stringify(err));
}
// ==========
} catch (err) {
console.error("errCode:" + err.code + ", message:" + err.message);
}
}
async aboutToAppear() {
// 1, 获取设备的位置信息,需要有位置权限,位置权限申请的方法和步骤见申请位置权限开发指导。
const state = await AccessControl.checkPermissions(['ohos.permission.LOCATION', "ohos.permission.APPROXIMATELY_LOCATION"])
console.log('slj 1 授权', state)
if (state) {
this.getLocation()
}
}
build() {
}
}
持续定位
持续定位。多用于导航、运动轨迹、出行等场景。
首先要实例化ContinuousLocationRequest对象,用于告知系统该向应用提供何种类型的位置服务,以及位置结果上报的频率。
- 设置locationScenario:
建议locationScenario参数优先根据应用的使用场景进行设置,该参数枚举值定义参见UserActivityScenario,例如地图在导航时使用NAVIGATION参数,可以持续在室内和室外场景获取位置用于导航。
- 设置interval:
表示上报位置信息的时间间隔,单位是秒,默认值为1秒。如果对位置上报时间间隔无特殊要求,可以不填写该字段。
以地图导航场景为例,调用方式如下:
import { geoLocationManager } from '@kit.LocationKit';
let request: geoLocationManager.ContinuousLocationRequest= {
'interval': 1,
'locationScenario': geoLocationManager.UserActivityScenario.NAVIGATION
}
let locationCallback = (location:geoLocationManager.Location):void => {
console.log('locationCallback: data: ' + JSON.stringify(location));
};
try {
geoLocationManager.on('locationChange', request, locationCallback);
} catch (err) {
console.error("errCode:" + JSON.stringify(err));
}
如果不主动结束定位可能导致设备功耗高,耗电快;建议在不需要获取定位信息时及时结束定位。
geoLocationManager.off('locationChange', locationCallback);
- 完整代码
import AccessControl from '../utils/AccessControl'
import { geoLocationManager } from '@kit.LocationKit';
import { abilityAccessCtrl } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
@Entry
@Component
struct Test {
async getLocation() {
try {
// 2. 位置开关是否打开
const locationEnabled = geoLocationManager.isLocationEnabled();
console.log('slj 2 检查全局位置开发是否打开')
if (!locationEnabled) {
// 用于UIAbility/UIExtensionAbility拉起全局开关设置弹框
const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
const state = await atManager.requestGlobalSwitch(getContext(this), abilityAccessCtrl.SwitchType.LOCATION).then((data: Boolean) => {
console.info('slj 2延伸 success/cancel 拉起设置定位弹窗:' + JSON.stringify(data));
return data // boolean
}).catch((err: BusinessError) => {
console.error('slj 2延伸 error 拉起设置定位弹窗:' + JSON.stringify(err));
return false
});
if (!state) return
}
// 3. 获取当前位置
let request: geoLocationManager.SingleLocationRequest = {
'locatingPriority': geoLocationManager.LocatingPriority.PRIORITY_LOCATING_SPEED,
'locatingTimeoutMs': 10000
}
try {
// 3.1 首次获取位置信息
geoLocationManager.getCurrentLocation(request).then((result) => { // 调用getCurrentLocation获取当前设备位置,通过promise接收上报的位置
console.log('current location: ' + JSON.stringify(result));
// current location: {"latitude":40.11524270229282,"longitude":116.24535759507577,"altitude":0,"accuracy":12.354018,"speed":0,"timeStamp":1735883027842,"direction":0,"timeSinceBoot":2116903847068,"additionSize":1,"additions":["requestId:d3485aca-b24b-451a-8057-26fc7600731f"],"additionsMap":{},"altitudeAccuracy":0,"speedAccuracy":0,"directionAccuracy":0,"uncertaintyOfTimeSinceBoot":0,"sourceType":2}
try {
geoLocationManager.getAddressesFromLocation({
maxItems: 1, //指定返回位置信息的最大个数,
longitude: result.longitude,
latitude: result.latitude,
locale: 'zh',
}, (err, data) => {
if (err) {
console.log('getAddressesFromLocation err: ' + JSON.stringify(err));
return
}
console.log('slj 3.1 【直接】获取经度纬度 getAddressesFromLocation data: ' + JSON.stringify(data));
console.log('纬度:', data[0].latitude)
console.log('经度:', data[0].longitude)
console.log('地址:', data[0].placeName)
console.log('表示国家信息。countryName:', data[0].countryName)
console.log('表示国家以下的一级行政区,一般是省/州。administrativeArea:', data[0].administrativeArea)
console.log('表示国家以下的二级行政区,一般是市。subAdministrativeArea:', data[0].subAdministrativeArea)
console.log('表示子城市信息,一般是区/县。subLocality:', data[0].subLocality)
console.log('表示路名信息。locality:', data[0].locality)
console.log('表示路名信息。:', data[0].locality)
});
} catch (err) {
console.error("errCode:" + JSON.stringify(err));
}
// ....
})
.catch((error:BusinessError) => { // 接收上报的错误码
console.error('promise, getCurrentLocation: error=' + JSON.stringify(error));
});
// 3.2 持续获取位置信息(含首次)
try {
geoLocationManager.on('locationChange', {
'interval': 1, // 1s
'locationScenario': geoLocationManager.UserActivityScenario.SPORT // 适用于记录用户位置轨迹的场景,如运动类应用记录轨迹功能。
}, (location:geoLocationManager.Location):void => {
console.log('slj 3.2【持续】获取经度纬度locationCallback: data: ' + JSON.stringify(location));
});
} catch (err) {
console.error("errCode:" + JSON.stringify(err));
}
// ======
} catch (err) {
console.error("errCode:" + JSON.stringify(err));
}
// ==========
} catch (err) {
console.error("errCode:" + err.code + ", message:" + err.message);
}
}
async aboutToAppear() {
// 1, 获取设备的位置信息,需要有位置权限,位置权限申请的方法和步骤见申请位置权限开发指导。
const state = await AccessControl.checkPermissions([
'ohos.permission.LOCATION', "ohos.permission.APPROXIMATELY_LOCATION",
// 'ohos.permission.LOCATION_IN_BACKGROUND', // ✅ 新增1
])
console.log('slj 1 授权', state)
if (state) {
this.getLocation()
}
}
build() {
}
}
后台定位-长时任务
应用退至后台后,在后台需要长时间运行用户可感知的任务,如播放音乐、导航等。为防止应用进程被挂起,导致对应功能异常,可以申请长时任务,使应用在后台长时间运行。在长时任务中可以申请多种类型的任务,并对任务类型进行更新。应用退后台执行业务时,系统会做一致性校验,确保应用在执行相应的长时任务。同时,系统有与长时任务相关联的通知栏消息,用户删除通知栏消息时,系统会自动停止长时任务。
- 需要申请ohos.permission.KEEP_BACKGROUND_RUNNING权限,配置方式请参见声明权限。
- 声明后台模式类型。
在module.json5配置文件中为需要使用长时任务的UIAbility声明相应的长时任务类型(配置文件中填写长时任务类型的配置项)。
"module": {
"abilities": [
{
"name": "EntryAbility",
// ...
"backgroundModes": [
// 长时任务类型的配置项
"audioRecording"
或者
"location" ✅
]
}
],
...
}
- 创建BackgroundTaskUtil.ets文件封装长短时任务
import { common, WantAgent, wantAgent } from "@kit.AbilityKit";
import { BusinessError } from "@kit.BasicServicesKit";
import { backgroundTaskManager } from "@kit.BackgroundTasksKit";
class BackgroundTaskUtil {
startLongTask(typeList: Array<string>) {
let wantAgentInfo: wantAgent.WantAgentInfo = {
// 点击通知后,将要执行的动作列表
// 添加需要被拉起应用的bundleName和abilityName
wants: [
{
bundleName: (getContext(this) as common.UIAbilityContext).abilityInfo.bundleName,
abilityName: (getContext(this) as common.UIAbilityContext).abilityInfo.name
}
],
// 指定点击通知栏消息后的动作是拉起ability
actionType: wantAgent.OperationType.START_ABILITY,
// 使用者自定义的一个私有值
requestCode: 0,
// 点击通知后,动作执行属性
actionFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG]
};
try {
// 通过wantAgent模块下getWantAgent方法获取WantAgent对象
wantAgent.getWantAgent(wantAgentInfo).then((wantAgentObj: WantAgent) => {
try {
// let list: Array<string> = ["audioRecording"];
backgroundTaskManager.startBackgroundRunning(getContext(this), typeList, wantAgentObj).then((res: backgroundTaskManager.ContinuousTaskNotification) => {
console.info("Operation startBackgroundRunning succeeded");
// 此处执行具体的长时任务逻辑,如录音,录制等。
}).catch((error: BusinessError) => {
console.error(`Failed to Operation startBackgroundRunning. code is ${error.code} message is ${error.message}`);
});
} catch (error) {
console.error(`Failed to Operation startBackgroundRunning. code is ${(error as BusinessError).code} message is ${(error as BusinessError).message}`);
}
});
} catch (error) {
console.error(`Failed to Operation getWantAgent. code is ${(error as BusinessError).code} message is ${(error as BusinessError).message}`);
}
}
stopLongTask() {
backgroundTaskManager.stopBackgroundRunning(getContext(this)).then(() => {
console.info(`Succeeded in operationing stopBackgroundRunning.`);
}).catch((err: BusinessError) => {
console.error(`Failed to operation stopBackgroundRunning. Code is ${err.code}, message is ${err.message}`);
});
}
}
export default new BackgroundTaskUtil()
- 示例代码
import AccessControl from '../utils/AccessControl'
import { geoLocationManager } from '@kit.LocationKit';
import { abilityAccessCtrl } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import BackgroundTaskUtil from '../utils/BackgroundTaskUtil'
@Entry
@Component
struct Test {
locationCallback(location:geoLocationManager.Location) {
console.log('slj 3.2【持续】获取经度纬度locationCallback: data: ' + JSON.stringify(location));
}
async getLocation() {
try {
// 2. 位置开关是否打开
const locationEnabled = geoLocationManager.isLocationEnabled();
console.log('slj 2 检查全局位置开发是否打开')
if (!locationEnabled) {
// 用于UIAbility/UIExtensionAbility拉起全局开关设置弹框
const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
const state = await atManager.requestGlobalSwitch(getContext(this), abilityAccessCtrl.SwitchType.LOCATION).then((data: Boolean) => {
console.info('slj 2延伸 success/cancel 拉起设置定位弹窗:' + JSON.stringify(data));
return data // boolean
}).catch((err: BusinessError) => {
console.error('slj 2延伸 error 拉起设置定位弹窗:' + JSON.stringify(err));
return false
});
if (!state) return
}
// 3. 获取当前位置
try {
// ✅ !!!!!
// ✅ !!!!!
// ✅ !!!!!
// ✅ !!!!!
// ✅ !!!!!
BackgroundTaskUtil.startLongTask(["location"])
// 3.1 首次获取位置信息
// geoLocationManager.getCurrentLocation(request).then((result) => {})
// 3.2 持续获取位置信息(含首次)
try {
geoLocationManager.on('locationChange', {
'interval': 1, // 1s
'locationScenario': geoLocationManager.UserActivityScenario.SPORT // 适用于记录用户位置轨迹的场景,如运动类应用记录轨迹功能。
}, this.locationCallback);
} catch (err) {
console.error("errCode:" + JSON.stringify(err));
}
// ======
} catch (err) {
console.error("errCode:" + JSON.stringify(err));
}
// ==========
} catch (err) {
console.error("errCode:" + err.code + ", message:" + err.message);
}
}
async aboutToAppear() {
// 1, 获取设备的位置信息,需要有位置权限,位置权限申请的方法和步骤见申请位置权限开发指导。
const state = await AccessControl.checkPermissions(['ohos.permission.LOCATION', "ohos.permission.APPROXIMATELY_LOCATION"])
console.log('slj 1 授权', state)
if (state) {
this.getLocation()
}
}
aboutToDisappear() {
BackgroundTaskUtil.stopLongTask()
geoLocationManager.off('locationChange', this.locationCallback);
}
build() {
}
}
🍃Stage模型 UIAbility ⭐️
UIAbility的理解
UIAbility组件是系统调度的基本单元,为应用提供绘制界面的窗口。一个应用可以包含一个或多个UIAbility组件。
UIAbility 有几种启动方式🐝🐝
UIAbility的启动模式是指UIAbility实例在启动时的不同呈现状态。针对不同的业务场景,系统提供了三种启动模式:
说明:standard是multiton的曾用名,效果与多实例模式一致。
UIAbility生命周期区别,使用场景🐝🐝
UIAbility的生命周期包括
onCreate 通过startAbility打开获取首次参数 onNewWant获取后续参数
onWindowStageCreate
onForeground
onBackground
onWindowStageDestroy
onDestroy四个状态
多个 uiability 之间是如何通信的
- startAbility传参 onCreate、onNewWant获取
- AppStorage
- emitter
Button('打开花小猪小程序').onClick((event: ClickEvent) => {
const want: Want = {
// Want参数信息
deviceId: '', // deviceId为空表示本设备
bundleName: 'com.example.ability',
abilityName: 'MiniProgramAbility',
parameters: {
id: 1111,
other: 2
}
};
this.context.startAbility(want);
})
获取
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
// 需 求:把 窗口数据 <=> 页面
// 方案1:AppStorage存储
// 方案2:eventHub
AppStorage.setOrCreate('goodsImg', want.parameters?.goodsImg)
AppStorage.setOrCreate('goodsTitle', want.parameters?.goodsTitle)
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate');
}
onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
AppStorage.setOrCreate('goodsImg', want.parameters?.goodsImg)
AppStorage.setOrCreate('goodsTitle', want.parameters?.goodsTitle)
}
FA模型、Stage模型区别
FA模型Stage有什么区别:核心区别 Stage模型多个UIAbility运行在同一个独立进程;FA模型每个页面组件独占一个ArkTS引擎实例
1、FA有 PageAbility、ServiceAbility、DataAbility; 而Stage模型 UIAbility、ExtensionAbility
2、FA模型2类进程 主进程,渲染进程 ; 而Stage三类进程 主进程、ExtensionAbility进程 渲染进程
3、FA模型通过config.json配置 Stage模型通过app.json5、module.json5配置
| 项目 | FA模型 | Stage模型 |
|---|---|---|
| 应用组件 | 1. 组件分类 | 1. 组件分类 |
| 进程模型 | 有两类进程:1. 主进程2. 渲染进程详细介绍请参见进程模型。 | 有三类进程:1. 主进程2. ExtensionAbility进程3. 渲染进程详细介绍请参见进程模型。 |
| 线程模型 | 1. ArkTS引擎实例的创建一个进程可以运行多个应用组件实例,每个应用组件实例运行在一个单独的ArkTS引擎实例中。2. 线程模型每个ArkTS引擎实例都在一个单独线程(非主线程)上创建,主线程没有ArkTS引擎实例。3. 进程内对象共享:不支持。详细介绍请参见线程模型。 | 1. ArkTS引擎实例的创建一个进程可以运行多个应用组件实例,所有应用组件实例共享一个ArkTS引擎实例。2. 线程模型ArkTS引擎实例在主线程上创建。3. 进程内对象共享:支持。详细介绍请参见线程模型。 |
| 应用配置文件 | 使用config.json描述应用信息、HAP信息和应用组件信息。详细介绍请参见应用配置文件概述(FA模型)。 | 使用app.json5描述应用信息,module.json5描述HAP信息、应用组件信息。详细介绍请参见应用配置文件概述(Stage模型)。 |
应用通信/跨设备通信🐝🐝
拉起指定应用 developer.huawei.com/consumer/cn…
let context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
let link: string = "https://www.example.com/programs?action=showall";
// 仅以App Linking的方式打开应用
context.openLink(link, { appLinkingOnly: true })
.then(() => {
console.info('openlink success.');
})
.catch((error: BusinessError) => {
console.error(`openlink failed. error:${JSON.stringify(error)}`);
});
处理链接
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { url } from '@kit.ArkTS';
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
// 从want中获取传入的链接信息。
// 如传入的url为:https://www.example.com/programs?action=showall
let uri = want?.uri
if (uri) {
// 从链接中解析query参数,拿到参数后,开发者可根据自己的业务需求进行后续的处理。
let urlObject = url.URL.parseURL(want?.uri);
let action = urlObject.params.get('action')
// 例如,当action为showall时,展示所有的节目。
if (action === "showall"){
//...
}
//...
}
}
}
- 拉起指定类型的应用
module.json5
{
...
...
"querySchemes": [
"amapuri",
],
"requestPermissions": [
{
"name": "ohos.permission.PRIVACY_WINDOW"
},
{
"name": "ohos.permission.GET_NETWORK_INFO"
},
....
....
]
....
....
....
}
code
this.array.push(new Item('拉起短信界面并指定联系人', getMmsWant()))
this.array.push(new Item('拉起浏览器并打开指定网页', getBrowsableWant()))
this.array.push(new Item('拉起应用市场对应的应用详情界面', getAppGalleryDetailWant()))
this.array.push(new Item('拉起设置应用HOME-WLAN界面', getWifiEntryWant()))
this.array.push(new Item('拉起HOME-系统和更新-输入法页面', getSetInputWant()))
this.array.push(new Item('拉起开启定位的设置页', getLocationManagerSettingsWant()))
this.array.push(new Item('拉起拨号界面并显示待拨出的号码', makeCall))
function getMmsWant() {
let want: Want = {
bundleName: 'com.ohos.mms',
abilityName: 'com.ohos.mms.MainAbility',
parameters: {
contactObjects: JSON.stringify([{ "contactsName": 'ZhangSan', "telephone": "16888880000" }]),
content: "短信内容测试321",
pageFlag: 'conversation'
}
};
return want
}
function getBrowsableWant() {
let want: Want = {
action: 'ohos.want.action.viewData',
entities: ['entity.system.browsable'],
uri: 'https://www.huawei.com'
};
return want
}
function getAppGalleryDetailWant() {
let bundleName = "com.amap.hmapp"
let want: Want = {
action: 'ohos.want.action.appdetail',
uri: 'store://appgallery.huawei.com/app/detail?id=' + bundleName, // bundleName为需要打开应用详情的应用的包名
};
return want
}
function getWifiEntryWant() {
let want: Want = {
bundleName: 'com.huawei.hmos.settings',
abilityName: 'com.huawei.hmos.settings.MainAbility',
uri: 'wifi_entry' // 根据”设置”应用配置的界面信息,选择不同的uri
};
return want
}
function getSetInputWant() {
let want: Want = {
bundleName: 'com.huawei.hmos.settings',
abilityName: 'com.huawei.hmos.settings.MainAbility',
uri: 'set_input' // 根据”设置”应用配置的界面信息,选择不同的uri
};
return want
}
function getLocationManagerSettingsWant() {
let want: Want = {
bundleName: 'com.huawei.hmos.settings',
abilityName: 'com.huawei.hmos.settings.MainAbility',
uri: 'location_manager_settings' // 根据”设置”应用配置的界面信息,选择不同的uri
};
return want
}
function makeCall() {
call.makeCall("13800000000", (err: BusinessError) => {
if (err) {
console.error(`makeCall fail, err->${JSON.stringify(err)}`);
} else {
console.log(`makeCall success`);
}
});
}
function checkApp() {
try {
let link: string = "amapuri://"
let appName: string = "高德地图"
let data = bundleManager.canOpenLink(link);
hilog.info(0x0000, 'testTag', 'canOpenLink successfully: %{public}s', JSON.stringify(data));
if (data) {
promptAction.showToast({
message: `${appName} APP 已安装`,
duration: 2000,
bottom: '500lpx'
});
} else {
promptAction.showToast({
message: `${appName} APP 未安装`,
duration: 2000,
bottom: '500lpx'
});
}
} catch (err) {
if (err['code'] == 17700056) {
/*{
"module": {
"querySchemes": [
"amapuri",
],*/
promptAction.showToast({
message: '请在src/main/module.json5配置link,参考注释 querySchemes',
duration: 2000,
bottom: '500lpx'
});
} else {
promptAction.showToast({
message: '未知异常',
duration: 2000,
bottom: '500lpx'
});
}
let message = (err as BusinessError).message;
hilog.error(0x0000, 'testTag', 'canOpenLink failed: %{public}s', message);
}
}
显示Want和隐式Want区别?
- 显式Want:在启动目标应用组件时,调用方传入的want参数中指定了abilityName和bundleName,称为显式Want。
显式Want通常用于应用内组件启动。
const want = {
deviceId: '', // deviceId为空表示本设备
bundleName: 'com.example.myapplication', 指定应用
abilityName: 'FuncAbility', 指定窗口
}
this.context.startAbility(want);
- 隐式Want:在启动目标应用组件时,调用方传入的want参数中未指定abilityName,称为隐式Want。
const want = {
action: 'ohos.want.action.search',
entities: [ 'entity.system.browsable' ],
uri: 'https://www.test.com:8080/query/student',
type: 'text/plain',
};
this.context.startAbility(want);
显式Want在启动目标应用组件时,显式指定了能力名称(abilityName)和包名称(bundleName),通常用于应用内组件启动。隐式Want未指定这些信息,主要通过动作(action)、实体(entities)、URI和类型(type)等匹配目标组件,系统会根据这些属性在安装的应用中查找合适的组件
■ 鸿蒙WebView应用开发
JSX、脚手架、类组件
🐝🐝🐝vue和react区别、vue2和vue3区别
🐝 jsx的优点
🌛 babel原理
🐝🐝🐝 函数组件和类组件区别
🐝🐝🐝 函数组件如何状态管理
🐝🐝🐝 状态state、和属性props区别;如何选
🐝 谈谈你对合成事件的理解、优势、原理、和原生事件区别
样式、判断、循环、表单、ref、组件通信、跨域
🐝🐝🐝react中如何解决样式污染问题
方案1:Inline styling 行内样式
🐝🐝🐝数组常用方法、数组去重至少写3种方案
🐝🐝🐝为什么循环的时候要写key
🐝🐝🐝谈谈你对受控组件、和非受控组件的理解
🐝🐝🐝如何实现组件通信
🐝🐝🐝项目跨域是如何解决的?
1、谷歌插件
2、关闭浏览器安全机制
3、服务器声明允许跨域
4、前端代理 也就是修改配置文件
生命周期
🐝🐝🐝react生命周期有哪些&应用场景
单词 触发机制 应用场景
挂载
constructor 首次 初始化ref、state等属性
static getDerivedStateFromProps 首次&状态/属性 较少,监控props更新state
componentWillMount 首次 弃1
render 首次&状态/属性 渲染页面
componentDidMount 首次 异步请求、操作DOM 如echarts、swiper
更新属性/状态
static getDerivedStateFromProps 首次&状态/属性 较少,监控props更新state
componentWillReceiveProps 仅属性 弃2
shouldComponentUpdate 属性/状态 子组件减少渲染次数 性能优化
componentWillUpdate 属性/状态 弃3
render 首次&状态/属性 渲染页面
getSnapshotBeforeUpdate 属性/状态 DOM更新前收集DOM信息交给componentDidUpdate
componentDidUpdate 属性/状态 监控更新进一步操作DOM、聊天、echarts
卸载
componentWillUnmount 组件卸载 清除非react资源 例如登陆定时器
错误
componentDidCatch 后代组件发错异常错误
问1:react生命周期有哪些
答1:挺多的挂载、更新、卸载等等,然后我常用的就是componentDidMount
问2:具体呢
答2
componentWillMount
render
componentDidMount
componentWillUpdate
render
componentDidUpdate
componentWillUnmount
...
🐝🐝🐝react16.3新增了哪些、弃用了哪些
新增
单词 触发机制 场景
static getDerivedStateFromProps 首次&属性/状态 案例较少,主要监控props变化更新state用
getSnapshotBeforeUpdate 属性/状态 案例更少,在更新DOM之前收集DOM信息
弃用
componentWillMount 首次 弃1
componentWillReceiveProps 仅属性 弃2
componentWillUpdate 属性/状态 弃3
🐝🐝🐝react用来优化性能的是哪一个生命周期,说一下实战如何优化使用
shouldComponentUpdate
避免父组件更新状态或者属性后避免子组件不必要渲染
🐝🐝🐝父组件更新状态或者属性如何避免子组件不必要渲染
通过shouldComponentUpdate
或者直接继承PureComponent
🐝🐝🐝说出新增的生命周期各自应用场景
单词 触发机制 场景
static getDerivedStateFromProps 首次&属性/状态 案例较少,主要监控props变化更新state用
getSnapshotBeforeUpdate 属性/状态 案例更少,在更新DOM之前收集DOM信息
🐝🐝父子组件mount生命周期执行顺序
父子嵌套生命周期(一般个1~2个概率)
mount 首次刷新或者组件销毁后重新创建**
f will mount
f render
s will mount
s render
s did mount
f did mount
update
f will update
f render
s will receive props
s will update
s render
s did update
f did update
生命周期形参了解:仅给你传递意识
函数组件、hooks、含TypeScript
🌛 谈谈你对hooks理解
🌛 用hooks 意义
🐝🐝🐝 hook种类
🐝🐝🐝 你用过哪些 hooks
🐝🐝🐝 自己封装过哪些hook
-
useLocalStorage:将数据存储到本地存储中,包括设置、获取和删除数据。 神龙教主
-
useDebounce:防抖动 Hook,用于在用户输入停止后延迟一段时间再执行函数。
-
useFetch:用于获取数据的 Hook,包括加载中、错误和数据三种状态。
-
useForm:用于处理表单数据的 Hook,包括设置、获取和重置表单数据。
🐝🐝🐝 useMemo、useCallback 有什么区别
🐝 useRef 需要注意什么
🌛 useRef 和 createRef 有什么区别
等等等
✨家人们点个juejin账号关注,会持续发布大前端领域技术文章💕 🍃
✨家人们点个juejin账号关注,会持续发布大前端领域技术文章💕 🍃
✨家人们点个juejin账号关注,会持续发布大前端领域技术文章💕 🍃
^_^ 点关注、不迷路、主播带你学技术 (๑′ᴗ‵๑)I Lᵒᵛᵉᵧₒᵤ❤