THE
BLOG
nextjs 加载 iconify 如何 SSR 化

> 由于开发项目页面多且复杂,所以选择使用到 iconify 图标库。使用到的是 nextjs,由于 iconify 在ssr中的一些缺陷,只能通过 json 去将图标 ssr 化,导致搭配 vscode 插件 Iconify IntelliSense 预览图标和书写体验不佳,所以进行封装改善开发体验。 ## 使用: ```typescript <AxIcon icon="line-md:close-circle-twotone" className="mr-2" size={48} /> ``` ## 完整代码 ```typescript "use client"; import { cn } from "@/lib/utils"; import { icons as entypoIcons, IconifyJSON } from "@iconify-json/entypo"; import { icons as lineMDIcons } from "@iconify-json/line-md"; import { icons as phIcons } from "@iconify-json/ph"; import { icons as tablerIcons } from "@iconify-json/tabler"; import { replaceIDs } from "@iconify/react"; import { getIconData, iconToHTML, iconToSVG } from "@iconify/utils"; interface AxIconProps { icon: string; size?: string | number; className?: string; } export default function AxIcon({ icon, size = 16, className }: AxIconProps) { const [brand, iconName] = icon.split(":"); const brandIcons: Record<string, IconifyJSON> = { tabler: tablerIcons, entypo: entypoIcons, ph: phIcons, "line-md": lineMDIcons }; if (!brandIcons[brand]) return; const iconData = getIconData(brandIcons[brand], iconName); if (!iconData) return; const renderData = iconToSVG(iconData, { height: size, width: size }); const svgStr = iconToHTML(replaceIDs(renderData.body), { ...renderData.attributes, class: cn("!w-auto !h-auto", className) }); return <div dangerouslySetInnerHTML={{ __html: svgStr }} />; } ```

avatar
出自
a1ex0012
nestjs
JavaScript
后端
nestjs 链接 notion API

测试

avatar
出自
a1ex0012
高级轮播效果实现

[https://spring-slider.uiinitiative.com/](https://spring-slider.uiinitiative.com/)

avatar
出自
a1ex0012
文件上传压缩

### **文件上传压缩** ### **1. 前端调用** ### **2. 后端实现(tinily)** 上传文件接口 ```text // 上传文件 userRouter.post(  "/file",  datalize([filed("image").required()]),  uploadFileError,  checkSize,  verifyAuth,  // checkId,  // deleteOldFile,  saveGlobalFile,  uploadFile ); ``` 其他中间件见名思意,实现靠 saveGlobalFile ```text // tinyPng获取key const tinify = require("tinify"); tinify.key = "1Gvd*************fj48WZ"; ​ const saveGlobalFile = async (ctx, next) => {  // 由于内部基于回调方式实现,所以手动构造promise  async function wait() {    return new Promise((resolve) => {      tinify       .fromBuffer(ctx.file.buffer)       .toBuffer(async function (err, resultData) {          if (err) throw err;          resolve(resultData);       });   }); }  // 从上层构造的file对象解析畜buffer,通过tinify讲buffer转换成新的 buffer 存入文件 实现压缩  const resultData = await wait();  const filename =    "image-" +    ctx.file.size +   (Date.now() + "").slice(0, 10) +    `.${ctx.file.mimetype.split("/")[1]}`;  fs.writeFileSync(    path.resolve(__dirname, "../uploads/image", filename),    resultData );  // 暴露信息做sql操作  ctx.request.body.extendname = ctx.file.mimetype;  ctx.request.body.filepath = `/images/${filename}`;  await next(); }; ``` ### **3. 静态托管暴露文件** ```text const koaStatic = require("koa-static"); app.use(koaStatic("./src/uploads/")); ​ ```

avatar
出自
a1ex0012
资源预加载-preloadjs

### **资源预加载-preloadjs** ```text mounted() {   canvas2image = require('~/utils/canvas2image').default   require('long-press-event')   setupWebViewJavascriptBridge(bridge => {     jsBridge = bridge   })   this.loadAsset()   document.addEventListener('visibilitychange', () => {     if (document.hidden && this.audio.instance) {       this.audio.instance.paused = true     } else if (       !document.hidden &&       this.audio.instance &&       this.audio.instance.paused     ) {       this.audio.instance.paused = false     }   })   document.body.addEventListener(     'touchmove',     function(e) {       e.preventDefault()     },     { passive: false }   )   // window.eruda.init() }, ``` ```text loadAsset() {      const preload = new window.createjs.LoadQueue(true)      preload.installPlugin(window.createjs.Sound)      // window.createjs.Sound.registerPlugins([window.createjs.HTMLAudioPlugin]) // need this so it doesn't default to Web Audio ​      const manifest = Object.keys(this.theme)       .filter(key => key !== 'name')       .map(key => ({          id: key,          src: this.theme[key]       }))      const randomIndex = getRandomInt(0, musicList.length - 1)      const music = musicList[randomIndex]      this.audio.title = music.replace('.mp3', '')      manifest.push({        id: 'bgm',        src: `https://static.juzicon.com/official/huodong/qixi/bgm/${music}`     })      preload.loadManifest(manifest) ​      preload.on('progress', e => {        this.loadingProgress = parseInt(e.loaded * 100, 10)     })      preload.on('complete', e => {        this.loading = false        setTimeout(() => {          this.content2 = [...this.content]       }, 1000)        const backgroundImage = preload.getResult('backgroundImage', true)        const cdImage = preload.getResult('cdImage', true)        const shareBackgroundImg = preload.getResult('shareBackgroundImg', true)        const cdArm = preload.getResult('cdArm', true)        const previewBackgroundImg = preload.getResult(          'previewBackgroundImg',          true       )        this.loadedResources = {          cdImage: window.URL.createObjectURL(cdImage),          shareBackgroundImg: window.URL.createObjectURL(shareBackgroundImg),          backgroundImage: window.URL.createObjectURL(backgroundImage),          cdArm: window.URL.createObjectURL(cdArm),          previewBackgroundImg: window.URL.createObjectURL(previewBackgroundImg)       } ​        const bgm = window.createjs.Sound.play('bgm', {          loop: -1       })        this.audio.total = dayjs.duration(bgm.duration).format('mm:ss')        this.audio.instance = bgm        const callback = () => {          // if (bgm.position >= 5000 && !this.rulesDialog.loaded) {          //   this.rulesDialog.show = true          //   this.rulesDialog.loaded = true          // }          this.audio.position = dayjs.duration(bgm.position).format('mm:ss')          this.audio.percent = ((bgm.position / bgm.duration) * 100).toFixed(3)          window.requestAnimationFrame(callback)       }        window.requestAnimationFrame(callback)        this.$nextTick(async () => {          const image = await this.convertToImage(this.$refs['canvas-preview'])          this.previewDialog.image = image.src          const image2 = await this.convertToImage(this.$refs.canvas)          this.previewDialog.downloadImage = image2.src       })     })   }, ``` 加载字体 ```text manifest.push({  src: [   {      src:      "local('Songti SY'), url(https://static.juzicon.com/fonts/SourceHanSerifCN-Bold.otf) format('woff')",      family: 'Songti SY'   },   {      src:      "local('Handwrit SC'), url(https://static.juzicon.com/fonts/%E6%89%8B%E5%86%99%E4%BD%93/ShouShuti.woff2) format('woff')",      family: 'Handwrit SC',      weight: 'bold'   } ],  type: 'font',  injectCSS: true }) ```

avatar
出自
a1ex0012
ali验证码接入(预加载)

### **ali验证码接入(预加载)** > 原生环境预加载页面解决调用时间长的问题 ```text <template lang="html">  <div>    <div id="captcha-element"></div>    <div id="button"></div>  </div> </template> ​ <script> import { Toast } from 'vant' ​ export default {  data() {    return {} },  computed: {},  asyncData({ query, error, $axios, n2scheme }) {    return {      phoneNumber: '',      $axios,      isWaiting: false,      captcha: null,      isIOS: query.n2p === 'ios'   } },  head: {    script: [     {        src:          'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js'     }   ] },  mounted() {    this.init()    const self = this    // 暴露给原生调用    window.openCaptcha = phoneNumber => {      self.openCaptcha(phoneNumber)   } },  methods: {    actionReport(key, value) {      try {        if (this.isIOS) {          window.webkit &&            window.webkit.messageHandlers.getVerifyResult.postMessage({              verifyResult: key,              msg: value           })       } else {          window.testInterface &&            window.testInterface.getVerifyResult(key, value)       }     } catch (err) {        Toast('err', err.message)     }   },    openCaptcha(phoneNumber) {      this.phoneNumber = phoneNumber      // 预加载判断验证码是否加载完成,主要限制用户页面加载验证码还未初始化就点击获取验证码,添加loading动画      if (this.captcha) {        button.click()     } else {        this.isWaiting = true     }      // button &&   },    init() {      const res = Toast.loading({        forbidClick: true,        duration: 0     })      const $axios = this.$axios      const isWaiting = this.isWaiting      const self = this      // 弹出式      initAliyunCaptcha({        SceneId: 'xxx', // 场景ID。根据步骤二新建验证场景后,您可以在验证码场景列表,获取该场景的场景ID        prefix: 'xxx', // 身份标。开通阿里云验证码2.0后,您可以在控制台概览页面的实例基本信息卡片区域,获取身份标        mode: 'popup', // 验证码模式。popup表示要集成的验证码模式为弹出式。无需修改        element: '#captcha-element', // 页面上预留的渲染验证码的元素,与原代码中预留的页面元素保持一致。        button: '#button', // 触发验证码弹窗的元素。button表示单击登录按钮后,触发captchaVerifyCallback函数。您可以根据实际使用的元素修改element的值        captchaVerifyCallback: captchaVerifyCallback, // 业务请求(带验证码校验)回调函数,无需修改        onBizResultCallback: onBizResultCallback, // 业务请求结果回调函数,无需修改        getInstance: getInstance, // 绑定验证码实例函数,无需修改        slideStyle: {          width: 360,          height: 40       }, // 滑块验证码样式,支持自定义宽度和高度,单位为px。其中,width最小值为320 px        language: 'cn' // 验证码语言类型,支持简体中文(cn)、繁体中文(tw)、英文(en)     }) ​      // 绑定验证码实例函数。该函数为固定写法,无需修改      function getInstance(instance) {        Toast.clear()        self.captcha = instance        if (self.isWaiting) {          button && button.click()          // this.isWaiting = false       }        const closeBtn = document.getElementById('aliyunCaptcha-btn-close')        if (closeBtn) {          closeBtn.addEventListener('click', () => {            self.actionReport(2, '')         })       }        const maskBtn = document.getElementById('aliyunCaptcha-mask')        if (maskBtn) {          maskBtn.addEventListener('click', () => {            self.actionReport(2, '')         })       }     } ​      // 业务请求(带验证码校验)回调函数      /**       * @name captchaVerifyCallback       * @function       * 请求参数:由验证码脚本回调的验证参数,不需要做任何处理,直接传给服务端即可       * @params {string} captchaVerifyParam       * 返回参数:字段名固定,captchaResult为必选;如无业务验证场景时,bizResult为可选       * @returns {{captchaResult: boolean, bizResult?: boolean|undefined}}       */ ​      async function captchaVerifyCallback(captchaVerifyParam) {        const phoneNumber = self.phoneNumber        const url = '/mobi/mg/services/x'x'x'        const data = new FormData()        data.append('verify_param', captchaVerifyParam)        data.append('mobile', phoneNumber)        const result = await $axios.post(`${url}`, data)        const bizResult = result.data.code === 2000        const captchaResult = result.data.code !== 40016        let key = 0        if (captchaResult) {          // 验证业务          key = bizResult ? 1 : 0       } else {          key = 3       }        const verifyResult = {          captchaResult,          bizResult       }        self.actionReport(key, result.data.message)        return verifyResult        // return verifyResult;     }      // 业务请求验证结果回调函数      function onBizResultCallback(bizResult) {}   } } } </script> ​ <style lang="scss" module> :global {  .container {    padding-bottom: 0; } } ​ .container {  position: relative;  max-width: 800px;  margin: 0 auto;  height: 100%;  background: #fff;  background-size: 28%; } .intro {  text-align: center;  margin-top: 20px;  padding: 0; } .logo {  max-width: 80px; } .title {  font-weight: 500;  color: #444;  font-size: 32px; } .desc {  color: #333;  line-height: 1.5;  font-size: 14px; } .download {  margin-top: 20px; } ​ .download a {  display: inline-block;  width: 118px;  margin: 16px 16px 0 0;  padding: 8px 0;  border-radius: 20px;  line-height: 20px;  font-size: 12px;  text-decoration: none;  border: 1px solid #dcdfe6;  color: #606266; } ​ .sooner {  margin-top: 8px;  font-size: 12px;  color: #f56c6c;  text-decoration: underline; } ​ .sooner a {  color: inherit; } ​ .download .download-btn {  display: inline-block;  width: auto;  margin: 0;  padding: 10px;  border-radius: 5px;  line-height: 20px;  text-decoration: none;  border: none;  font-size: 16px;  color: #fff;  background-color: #66b1ff; } ​ .download-qrcode {  width: 100px; } ​ .tip {  color: #333;  font-size: 12px; } ​ .slogan {  font-weight: 400;  font-size: 20px; } ​ .phone {  position: absolute;  left: 50%;  bottom: 0;  width: 90%;  transform: translateX(-50%);  pointer-events: none; } ​ .background {  max-width: 100%; } ​ .top {  position: relative;  height: 100vh; ​ &::after {    content: '';    position: absolute;    bottom: 0;    left: 0;    width: 100%;    height: 20px;    background-image: linear-gradient(      0deg,      hsla(0, 0%, 59%, 0.15),      hsla(0, 0%, 59%, 0)   );    z-index: 3; } } ​ .section {  border-bottom: 1px solid #f0f0f0;  padding: 60px 0 30px;  text-align: center; ​ &-title {    font-weight: 400;    font-size: 20px; } ​ &-desc {    padding: 10px 30px 25px;    line-height: 1.5;    font-size: 15px;    font-weight: 300; } ​ &-img {    max-width: 200px;    margin-top: 20px; } } ​ .bottom {  text-align: center;  padding: 40px 0; } ​ .back-home {  display: block;  margin-top: 15px;  font-size: 15px;  color: #999;  -webkit-tap-highlight-color: rgba(#000, 0); } ​ .size {  color: #888;  font-size: 13px;  margin-top: 8px; } </style> ​ ```

avatar
出自
a1ex0012
node路由两种注入方式(约定式/装饰器)

### **node路由两种注入方式(约定式/装饰器)** ### **1. 约定式** ```text // 通过约定式文件路径设置,自动注册路由 const routerDir = path.resolve(__dirname, "../router"); const routes = fs.readdirSync(routerDir); routes.forEach((route) => {  const routeitem = require(path.resolve(routerDir, route));  app.use(routeitem.routes()).use(routeitem.allowedMethods()); }); ``` ### **2. 借助ts装饰器** 原始方式的实现 ```text const articleRouter = new Router({ prefix: "/article" }); // 插入一篇文章 articleRouter.post(  "/",  verifyAuth,  upload.any(),  datalize([    filed("title").required(),    filed("content_md").required(),    filed("category_id").required(),    filed("tag_id").required(), ]),  datalizeError,  checkCategory,  checkTag,  saveCover,  add ); ​ // 更改文章封面 articleRouter.post(  "/cover/:article_id",  verifyAuth,  upload.any(),  datalize([filed("cover")]),  datalizeError,  checkCover,  deleteOldCover,  changeCoverById ); ``` 装饰器实现 ```text // 使用 // @prefix("/evaluate") // @get("/:ISBN") // @post("/:ISBN") // @use(verfyAuth) import { Context } from "koa"; import prefix from "../Decorator/prefixDecorator"; import { get, post } from "../Decorator/methodDecorator"; import bookServer from "../service/book"; import { success } from "../common/ResResult"; import evaluateServer from "../service/evaluate"; import use from "../Decorator/useDecorator"; import { verfyAuth } from "../middleware/user"; @prefix("/evaluate") class EvaluateController {  @get("/:ISBN")  async findAllEvaluateByISBN(ctx: Context) {    const { ISBN } = ctx.params;    const result = await evaluateServer.getEvaluateReplyList(ISBN);    ctx.body = success(result); }  @use(verfyAuth)  @post("/:ISBN")  async addEvaluateBook(ctx: Context) {    const { ISBN } = ctx.params;    const { content, evaluator, evaluatedegree } = ctx.request.body;    const result = await evaluateServer.addEvaluateBook(      ISBN,      content,      evaluator,      ctx.user.userid,      evaluatedegree   );    ctx.body = success(result); }  @use(verfyAuth)  @post("/reply/:evaluateid")  async applyInfo(ctx: Context) {    const { evaluateid } = ctx.params;    const { content, replyor } = ctx.request.body;    const result = await evaluateServer.applyInfo(evaluateid, content, replyor);    ctx.body = success(result); } } // 实现 // @prefix("/evaluate") import Router from "koa-router"; import type { IRequestType } from "./type"; import rootRouter from "../config/rootRouter"; import { Middleware } from "koa"; function prefix(prefix: string) {  return function (target: new (...args: any[]) => any) {    const router = new Router({ prefix }); ​    // 遍历类中所有的方法 => defineMeta时保存的key    for (let fn in target.prototype) {      // 获取路径和方法      const path = Reflect.getMetadata("path", target.prototype, fn);      const type: IRequestType = Reflect.getMetadata(        "type",        target.prototype,        fn     );      const middlewareArr: Middleware[] = Reflect.getMetadata(        "middleware",        target.prototype,        fn     );      if (middlewareArr) {        router[type](path, ...middlewareArr, target.prototype[fn]);     } else {        router[type](path, target.prototype[fn]);     }      // router上添加路由   }    // 载入rootRouter    rootRouter.use(router.routes(), router.allowedMethods()); }; } ​ export default prefix; // @get("/:ISBN") // @post("/:ISBN") ​ import { IRequestType } from "./type"; ​ function getMethodDecorator(type: IRequestType) {  return function (path: string) {    return function (target: any, key: string, desc: PropertyDescriptor) {      // 元信息保存路径名      Reflect.defineMetadata("path", path, target, key);      // 元信息保存方法名      Reflect.defineMetadata("type", type, target, key);   }; }; } ​ const get = getMethodDecorator(IRequestType.GET); const post = getMethodDecorator(IRequestType.POST); const del = getMethodDecorator(IRequestType.DEL); const put = getMethodDecorator(IRequestType.PUT); ​ export { get, post, del, put }; ​ // @use(verfyAuth) import { Middleware } from "koa"; ​ function use(middleware: Middleware) {  return function (target: any, key: string) {    // 先获取中间件    let mw: Middleware[] = Reflect.getMetadata("middleware", target, key);    if (!mw) {      // 没有中间件      mw = [middleware];   } else {      mw.push(middleware);   }    // 定义中间件    Reflect.defineMetadata("middleware", mw, target, key); }; } ​ export default use; ```

avatar
出自
a1ex0012
大文件上传

## **大文件上传** ### **后端实现** 1. 上传文件路由 ```text testRouter.post("/up", upload.any(), async (ctx, next) => {  await sleep(Math.random() * 10000 + 2000);  // 保存文件  fs.writeFileSync(    `./rebase/${ctx.request.files[0].fieldname}`,    ctx.request.files[0].buffer );  ctx.body = "/abc success"; }); ``` 2. 合并文件路由 1. appendFileSync:用于将给定数据同步附加到文件。如果不存在,将创建一个新文件。 ```text testRouter.post("/merge", async (ctx, next) => {  const { name } = ctx.request.body;  // 1. 获取所有分片  const chunks = fs.readdirSync("./rebase");  // 2. 拼接文件名字  const currTime = new Date();  const fileName = `${currTime.getFullYear()}_${    currTime.getMonth() + 1  }_${currTime.getDate()}_${Date.now().toString().slice(-5)}_${Math.ceil(    Math.random() * 100000 )}`;  // 3. 便利文件通过appendFile整合文件  for (let k of chunks) {    fs.appendFileSync(      `./upload/${fileName}.${name.split(".")[1]}`,      fs.readFileSync(`./rebase/${k}`)   );    // 4. 删除该文件    fs.unlinkSync(`./rebase/${k}`); }  ctx.body = "/merge success"; }); ``` ### **前端实现** 1. 布局 ```text    <input type="file" id="btn" @change="ced" />    <span id="upload" @click="send">上传</span> ``` 2. 实现ced和send 1. FileReader:是一个对象,其唯一目的是从 `Blob`(因此也从 `File`)对象中读取数据。 它使用事件来传递数据,因为从磁盘读取数据可能比较费时间。 - **`readAsArrayBuffer(blob)`** —— 将数据读取为二进制格式的 `ArrayBuffer`。 - `onload`读取完成事件回调 2. SparkMD5:用于校验字符串或者文件,以防止文件、字符串被“篡改”。因为如果文件、散列值不一样,说明文件内容也是不一样的 ```text const ced = (e) => {  // 1.拿到上传的文件  const file = e.target.files[0];  console.log(file);  fileInfo = file; }; const send = () => {  // 2.前端校验  const { size, type } = fileInfo;  // if (size > 20 * 1024 * 1024) {  //   alert("文件过大");  //   btn.value = "";  //   return;  // }  // if (type.split("/")[0] !== "image") {  //   alert("文件类型错误");  //   btn.value = "";  // }  // 3.获取文件buffer  const fileRead = new FileReader();  fileRead.readAsArrayBuffer(fileInfo);  // 4.监听转换完成  fileRead.onload = (res) => {    // 5.转换完成获取文件buffer    const buf = res.target.result;    // 6.通过文件内容获取唯一hash    const sp5 = new SparkMD5.ArrayBuffer();    sp5.append(buf);    const hash = sp5.end(); ​    // 7.分割大文件 5m为一片,buf数组保存到partList    const partSize = 5 * 1024 * 1024;    const pageNum = Math.ceil(fileInfo.size / partSize);    let current = 0;    const partList = [];    for (let i = 0; i < pageNum; i++) {      let reqItem = {        chunk: fileInfo.slice(current, current + partSize),        filename: `${hash}_${i}`,     };      current += partSize;      partList.push(reqItem);   }    // 8.创建切片请求    let count = 0;    for (let item of partList) {      // 9.构建formdata      const formData = new FormData();      formData.append(item.filename, item.chunk);      // 10.并行发送上传请求      axios.post("http://localhost:1000/test/up", formData).then((res) => {        count++;        // 所有片段上传成功,合并服务器碎片为完整文件        if (count === pageNum) {          axios           .post("http://localhost:1000/test/merge", { name: "a.mp4" })           .then((res) => {              console.log(res);           });       }        console.log(res);     });   } }; }; ```

avatar
出自
a1ex0012
关于锚点跳转被遮挡的问题

213 > 对于页面内部跳转跳转到指定位置时,我们可以采用js的方式进行scroll移动,但是我们更为方便的是可以采用锚点的方式进行跳转。 ## **锚点的使用方法** 1. 通过a标签的href形式设置 ```text <a href='#a'>锚点a</a> ``` 2. 通过给dom元素增加id的形式,当点击跳转到对应#a时,该dom元素或被移动在视口最上方 ```text <div id='a'></div> ``` > 一般用于购物类网站的楼层跳转和信息类网站的文章目录跳转 锚点跳转是跳转到将对应的添加了锚点元素跳转到浏览器视口的最上方 与其等同的**Element.scrollIntoView()**也能实现此方法 ```text      const aaa = document.getElementsByTagName("a")[0];      const ccc = document.getElementsByTagName("div")[2];      aaa.onclick = () => {        ccc.scrollIntoView();     }; ``` **Element.scrollIntoView()**其内部接受一个参数 - boolean类型参数 1. true(default) 表示元素的顶端将和其所在滚动区的可视区域的顶端对齐 2. false 表示元素的底端将和其所在滚动区的可视区域的底端对齐 ## **锚点跳转遮挡问题** > 我们都知道锚点跳转时跳转到元素的视口顶部,如果是顶部有fixed定位遮挡该如何处理呢 - 通常网站会有一个nav,当页面滚动时动态设置定位方式,当滚动到某一高度时nav会固定在页面顶部,就像这样 - 然而我们要调集锚点进行跳转时就会出现这种情况 - 而我们的需求是让它的标题显示出来,像这样 ### **解决方案** 1. 我们可以通过js的方式去处理:在点击锚点时取消其默认行为,重写其点击后的动作 1. 获取到该锚点跳转的hash 2. 获取所有dom元素匹配到id为该hash值的元素 3. 将元素定位在`navH` px上,`navH` 即遮挡的nav高度 2. 上面方法明显繁琐,那有没有简单的方法呢?当然有 3. 通过给滚动的元素设置`scroll-padding-top`的方式,其他方向同理 ```text <style>    html{        scroll-padding-top:60px   } </style> ``` 1. 当给滚动元素设置了scroll-paddingtop时,点击锚点时会从滚动端口的顶部边缘向内偏移,值作为有效长度或百分比。 2. 当你设置值为60px时,点击锚点会向内容不偏移60px,像这样: > 能用css实现就不用js实现,end

avatar
出自
a1ex0012