首页
Search
1
解决visual studio code (vscode)安装时没有选择安装路径问题
322 阅读
2
如何在 Clash for Windows 上配置服务
217 阅读
3
Linux 下 Bash 脚本 bad interpreter 报错的解决方法
150 阅读
4
Arch Linux 下解决 KDE Plasma Discover 的 Unable to load applications 错误
149 阅读
5
uniapp打包app提示通讯录权限问题,如何取消通讯录权限
113 阅读
clash
服务器
javascript
全部
游戏资讯
登录
Search
加速器之家
累计撰写
1,228
篇文章
累计收到
0
条评论
首页
栏目
clash
服务器
javascript
全部
游戏资讯
页面
搜索到
1228
篇与
的结果
2024-10-20
nextjs 如何将静态资源发布到 CDN
nextjs 是基于 react 的服务端同构指出框架,在使用的过程中也多多少少遇到过几个问题,其中最大的问题就是静态资源的发布了。 1. 如何基于文件内容进行 hash 命名 # Next.js uses a constant generated at build time to identify which version of your application is being served. This can cause problems in multi-server deployments when next build is ran on every server. In order to keep a static build id between builds you can provide the generateBuildId function: 按照官网上的说法,每次发布都会生成新的 hash 路径,即使当前没有任何的变动。例如某次发布的路径是/_next/static/tZonUgEY-GPCEExGbFapL/pages/index.js,那么下次的 hash 必然不是这个值。这样导致的一个问题是:如果在多台机器上发布并 build 时,会导致每次 build 产生的值不同。如果想固定某个值或者使用某个值,一个是可以先 build 完成后后再分发,或者,可以在next.config.js中自定义generateBuildId:// 来自官网上的例子 // next.config.js module.exports = { generateBuildId: async () => { return "my-build-id"; }, }; npm 上也有提供相应的安装包,可以使用当前 git 提交的 hash 值作为 buildId:next-build-id。可是这种存在的一个问题就是:即使文件没有发生变动,或者我只修改了首页的代码,发布完成后,pages 下所有的资源都需要重新加载,有用户建议使用内容的 hash 值作为每个资源的路径,但官方好像好像不太情愿,说实现起来比较困难,详情可以看这个 issue: use content hash in pages chunk name。在这条 issue 中,有用户自己实现一个插件,不过我还没用过,有兴趣的同学可以尝试下。 2. 路径的拼接规则 # 静态资源上传到 CDN,这是存在目前存在的最大的问题,虽然在next.config.js中可以配置assetPrefix字段,但实际使用起来还是非常困难。打包后的 js 和 css,引用路由均为/_next/static开头:如图片中所示,带有 data-next-page 属性的,实际上访问的是.next/server/static/[hash]/pages/_app.js;不带这个属性的,访问的路径是.next/static/runtime/webpack-[hash].js我们以 2019/09/16 提交的 nextjs 源码为例:pages_document,里面有全局脱水数据的注入,页面相关的 js 和静态资源的 js 的拼接:// 页面相关的js // assetPrefix为我们在next.config.js中配置的前缀 // ${buildId}即为每次打包生成的hash值,在本地环境下值为development // _devOnlyInvalidateCacheQueryString: 变动的时间戳,正式环境中为空, _devOnlyInvalidateCacheQueryString: process.env.NODE_ENV !== 'production' ? '?ts=' + Date.now() : '' src={assetPrefix + encodeURI(`/_next/static/${buildId}/pages${getPageFile(page)}`) + _devOnlyInvalidateCacheQueryString} // 静态资源的js src={`${assetPrefix}/_next/${file}${_devOnlyInvalidateCacheQueryString}`} 全局脱水数据的注入 上面的页面编译后的路径是.next/server/static/{hash}/pages/_document.js,这些 js 读取的路径是分别由 2 个 json 文件控制的。 sever/pages-manifest.json:加载页面相关的 js,nextjs 是服务端渲染+客户端渲染两种方式,刷新页面时使用的服务端渲染(使用server/static/{hash}/pages/中的文件),切换路由时使用的是客户端渲染(使用static/{hash}/pages/中的文件),这里加载的 js,是用于在路由切换时使用客户端渲染的方式; static/build-manifest.json:加载静态资源的 js,使用static/里除 hash 路径外的资源; 我们了解这些,主要是为了理解 js 的路径是怎样拼接完成的。 3. 如何发布静态资源到 CDN # 静态资源发布到 CDN 其实很简单,只要把.next/static下目录的资源上传上去即可。最困难的是如何替换代码中的路径。把这个目录下的静态文件上传到 CDN 后,生成的地址会变成: 从第 2 部分中能看到,代码中使用assetPrefix作为静态资源的前缀时,只是单纯的拼接到了最前面而已,拼接后的地址是: 中间多出了/_next/static的路径,最后的结果是页面需要加载的资源和上传的资源路径不一致,就会各种 404。这里我的解决方案很简单粗暴,读取编译后的文件,然后执行 node 程序,将里面的字符替换掉:const fs = require("fs"); const glob = require("glob"); const list = glob.sync(".next"); list.forEach((file) => { let data = fs.readFileSync(file, "utf8"); if (file.indexOf("_document.js") > -1) { data = data.replace(/\/_next\//g, "/").replace(/static\/" \+ buildId/g, '" + buildId'); fs.writeFileSync(file, data); console.log(file, "success"); } else if (file.indexOf("build-manifest.json") > -1) { data = data.replace(/static\//g, ""); fs.writeFileSync(file, data); console.log(file, "success"); } else if (data.indexOf("/_next/static") > -1) { data = data.replace(/\/_next\/static\//g, "/"); fs.writeFileSync(file, data); console.log(file, "success"); } }); 这样就能就可以保证项目的 CDN 地址和真正上传的地址是一致的了。
2024年10月20日
47 阅读
0 评论
0 点赞
2024-10-20
十大经典排序算法(javascript实现)
0 算法概述 # 0.1 算法分类 # 十种常见排序算法可以分为两大类:比较类排序:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破O(nlogn),因此也称为非线性时间比较类排序。 非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此也称为线性时间非比较类排序。 0.2 算法复杂度 # 0.3 相关概念 # 稳定:如果a原本在b前面,而a=b,排序之后a仍然在b的前面。 不稳定:如果a原本在b的前面,而a=b,排序之后 a 可能会出现在 b 的后面。 时间复杂度:对排序数据的总的操作次数。反映当n变化时,操作次数呈现什么规律。 空间复杂度:是指算法在计算机 内执行时所需存储空间的度量,它也是数据规模n的函数。 1 冒泡排序(Bubble Sort) # 冒泡排序是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。 1.1 算法描述 # 比较相邻的元素。如果第一个比第二个大,就交换它们两个; 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数; 针对所有的元素重复以上的步骤,除了最后一个; 重复步骤1~3,直到排序完成。 1.2 动图演示 # 1.3 代码实现 # function bubbleSort(arr) { const len = arr.length; for (let i = 0; i < len - 1; i++) { for (let j = 0; j < len - 1 - i; j++) { if (arr[j] > arr[j + 1]) { // 相邻元素两两对比 let temp = arr[j + 1]; // 元素交换 arr[j + 1] = arr[j]; arr[j] = temp; } } } return arr; } 2 选择排序(Selection Sort) # 选择排序(Selection-sort)是一种简单直观的排序算法。它的工作原理:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。 2.1 算法描述 # n个记录的直接选择排序可经过n-1趟直接选择排序得到有序结果。具体算法描述如下: 初始状态:无序区为R[1..n],有序区为空; 第i趟排序(i=1, 2, 3…n-1)开始时,当前有序区和无序区分别为R[1..i-1]和R(i..n)。该趟排序从当前无序区中-选出关键字最小的记录 R[k],将它与无序区的第1个记录R交换,使R[1..i]和R[i+1..n)分别变为记录个数增加1个的新有序区和记录个数减少1个的新无序区; n-1趟结束,数组有序化了。 2.2 动图演示 # 2.3 代码实现 # function selectionSort(arr) { const len = arr.length; let minIndex, temp; for (let i = 0; i < len - 1; i++) { minIndex = i; for (let j = i + 1; j < len; j++) { if (arr[j] < arr[minIndex]) { // 寻找最小的数 minIndex = j; // 将最小数的索引保存 } } temp = arr[i]; arr[i] = arr[minIndex]; arr[minIndex] = temp; } return arr; } 2.4 算法分析 # 表现最稳定的排序算法之一,因为无论什么数据进去都是O(n2)的时间复杂度,所以用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了吧。理论上讲,选择排序可能也是平时排序一般人想到的最多的排序方法了吧。 3 插入排序(Insertion Sort) # 插入排序(Insertion-Sort)的算法描述是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。 3.1 算法描述 # 一般来说,插入排序都采用in-place在数组上实现。具体算法描述如下: 从第一个元素开始,该元素可以认为已经被排序; 取出下一个元素,在已经排序的元素序列中从后向前扫描; 如果该元素(已排序)大于新元素,将该元素移到下一位置; 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置; 将新元素插入到该位置后; 重复步骤2~5。 3.2 动图演示 # 3.3 代码实现 # function insertionSort(arr) { const len = arr.length; let preIndex, current; for (let i = 1; i < len; i++) { preIndex = i - 1; current = arr[i]; while (preIndex >= 0 && arr[preIndex] > current) { arr[preIndex + 1] = arr[preIndex]; preIndex--; } arr[preIndex + 1] = current; } return arr; } 3.4 算法分析 # 插入排序在实现上,通常采用in-place排序(即只需用到O(1)的额外空间的排序),因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。 4 希尔排序(Shell Sort) # 1959年Shell发明,第一个突破O(n2)的排序算法,是简单插入排序的改进版。它与插入排序的不同之处在于,它会优先比较距离较远的元素。希尔排序又叫缩小增量排序。 4.1 算法描述 # 先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,具体算法描述: 选择一个增量序列t1,t2,…,tk,其中ti>tj,tk=1; 按增量序列个数k,对序列进行k 趟排序; 每趟排序,根据对应的增量ti,将待排序列分割成若干长度为m 的子序列,分别对各子表进行直接插入排序。仅增量因子为1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。 4.2 动图演示 # 4.3 代码实现 # function shellSort(arr) { const len = arr.length; for (let gap = Math.floor(len / 2); gap > 0; gap = Math.floor(gap / 2)) { // 注意:这里和动图演示的不一样,动图是分组执行,实际操作是多个分组交替执行 for (let i = gap; i < len; i++) { let j = i; let current = arr[i]; while (j - gap >= 0 && current < arr[j - gap]) { arr[j] = arr[j - gap]; j = j - gap; } arr[j] = current; } } return arr; } 4.4 算法分析 # 希尔排序的核心在于间隔序列的设定。既可以提前设定好间隔序列,也可以动态的定义间隔序列。动态定义间隔序列的算法是《算法(第4版)》的合著者Robert Sedgewick提出的。 5 归并排序(Merge Sort) # 归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并。 5.1 算法描述 # 把长度为n的输入序列分成两个长度为n/2的子序列; 对这两个子序列分别采用归并排序; 将两个排序好的子序列合并成一个最终的排序序列。 5.2 动图演示 # 5.3 代码实现 # function mergeSort(arr) { var len = arr.length; if (len < 2) { return arr; } var middle = Math.floor(len / 2), left = arr.slice(0, middle), right = arr.slice(middle); return merge(mergeSort(left), mergeSort(right)); } function merge(left, right) { var result = []; while (left.length > 0 && right.length > 0) { if (left[0] arr[largest]) { largest = right; } if (largest != i) { swap(arr, i, largest); heapify(arr, largest); } } function swap(arr, i, j) { var temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } function heapSort(arr) { buildMaxHeap(arr); for (var i = arr.length - 1; i > 0; i--) { swap(arr, 0, i); len--; heapify(arr, 0); } return arr; } 8 计数排序(Counting Sort) # 计数排序不是基于比较的排序算法,其核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。 作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。 8.1 算法描述 # 找出待排序的数组中最大和最小的元素; 统计数组中每个值为i的元素出现的次数,存入数组C的第i项; 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加); 反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1。 8.2 动图演示 # 8.3 代码实现 # function countingSort(arr, maxValue) { var bucket = new Array(maxValue + 1), sortedIndex = 0; arrLen = arr.length, bucketLen = maxValue + 1; for (var i = 0; i < arrLen; i++) { if (!bucket[arr[i]]) { bucket[arr[i]] = 0; } bucket[arr[i]]++; } for (var j = 0; j < bucketLen; j++) { while (bucket[j] > 0) { arr[sortedIndex++] = j; bucket[j]--; } } return arr; } 8.4 算法分析 # 计数排序是一个稳定的排序算法。当输入的元素是 n 个 0到 k 之间的整数时,时间复杂度是O(n+k),空间复杂度也是O(n+k),其排序速度快于任何比较排序算法。当k不是很大并且序列比较集中时,计数排序是一个很有效的排序算法。 9 桶排序(Bucket Sort) # 桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。桶排序 (Bucket sort)的工作的原理:假设输入数据服从均匀分布,将数据分到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排)。 9.1 算法描述 # 设置一个定量的数组当作空桶; 遍历输入数据,并且把数据一个一个放到对应的桶里去; 对每个不是空的桶进行排序; 从不是空的桶里把排好序的数据拼接起来。 9.2 图片演示 # 9.3 代码实现 # function bucketSort(arr, bucketSize) { if (arr.length === 0) { return arr; } var i; var minValue = arr[0]; var maxValue = arr[0]; for (i = 1; i < arr.length; i++) { if (arr[i] < minValue) { minValue = arr[i]; // 输入数据的最小值 } else if (arr[i] > maxValue) { maxValue = arr[i]; // 输入数据的最大值 } } // 桶的初始化 var DEFAULT_BUCKET_SIZE = 5; // 设置桶的默认数量为5 bucketSize = bucketSize || DEFAULT_BUCKET_SIZE; var bucketCount = Math.floor((maxValue - minValue) / bucketSize) + 1; var buckets = new Array(bucketCount); for (i = 0; i < buckets.length; i++) { buckets[i] = []; } // 利用映射函数将数据分配到各个桶中 for (i = 0; i < arr.length; i++) { buckets[Math.floor((arr[i] - minValue) / bucketSize)].push(arr[i]); } arr.length = 0; for (i = 0; i < buckets.length; i++) { insertionSort(buckets[i]); // 对每个桶进行排序,这里使用了插入排序 for (var j = 0; j < buckets[i].length; j++) { arr.push(buckets[i][j]); } } return arr; } 9.4 算法分析 # 桶排序最好情况下使用线性时间O(n),桶排序的时间复杂度,取决与对各个桶之间数据进行排序的时间复杂度,因为其它部分的时间复杂度都为O(n)。很显然,桶划分的越小,各个桶之间的数据越少,排序所用的时间也会越少。但相应的空间消耗就会增大。 10 基数排序(Radix Sort) # 基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。 10.1 算法描述 # 取得数组中的最大数,并取得位数; arr为原始数组,从最低位开始取每个位组成radix数组; 对radix进行计数排序(利用计数排序适用于小范围数的特点); 10.2 动图演示 # 10.3 代码实现 # var counter = []; function radixSort(arr, maxDigit) { var mod = 10; var dev = 1; for (var i = 0; i < maxDigit; i++, dev *= 10, mod *= 10) { for (var j = 0; j < arr.length; j++) { var bucket = parseInt((arr[j] % mod) / dev); if (counter[bucket] == null) { counter[bucket] = []; } counter[bucket].push(arr[j]); } var pos = 0; for (var j = 0; j < counter.length; j++) { var value = null; if (counter[j] != null) { while ((value = counter[j].shift()) != null) { arr[pos++] = value; } } } } return arr; } 10.4 算法分析 # 基数排序基于分别排序,分别收集,所以是稳定的。但基数排序的性能比桶排序要略差,每一次关键字的桶分配都需要O(n)的时间复杂度,而且分配之后得到新的关键字序列又需要O(n)的时间复杂度。假如待排数据可以分为d个关键字,则基数排序的时间复杂度将是O(d*2n) ,当然d要远远小于n,因此基本上还是线性级别的。基数排序的空间复杂度为O(n+k),其中k为桶的数量。一般来说n>>k,因此额外空间需要大概n个左右。
2024年10月20日
6 阅读
0 评论
0 点赞
2024-10-20
文字与元素居中的方式
我们经常会让元素进行上下左右的居中,这里提供几种方法供大家使用。 1. 绝对定位与 margin # 当我们提前知道要居中元素的长度和宽度时,可以使用这种方式:.container { position: relative; width: 300px; height: 200px; border: 1px solid #333333; } .content { background-color: #ccc; width: 160px; height: 100px; position: absolute; top: 50%; left: 50%; margin-left: -80px; /* 宽度的一半 */ margin-top: -50px; /* 高度的一半 */ } absolute与margin的居中布局 2. 绝对定位与 transform # 当要居中的元素不定宽和定高时,我们可以使用transform来让元素进行偏移。.container { position: relative; width: 300px; height: 200px; border: 1px solid #333333; } .content { background-color: #ccc; position: absolute; top: 50%; left: 50%; transform: translate3d(-50%, -50%, 0); text-align: center; } transform的居中布局蚊子的博客 3. line-height # line-height其实是行高,我们可以用行高来调整布局!不过这个方案有一个比较大的缺点是:文案必须是单行的,多行的话,设置的行高就会有问题。.container { width: 300px; height: 200px; border: 1px solid #333333; } .content { line-height: 200px; } line-height的居中布局 4. table 布局 # 给容器元素设置display: table,当前元素设置display: table-cell:.container { width: 300px; height: 200px; border: 1px solid #333333; display: table; } .content { display: table-cell; vertical-align: middle; text-align: center; } line-height的居中布局蚊子的博客 5. flex 布局 # 我们可以给父级元素设置为display: flex,利用 flex 中的align-items和justify-content设置垂直方向和水平方向的居中。这种方式也不限制中间元素的宽度和高度。同时,flex 布局也能替换line-height方案在某些 Android 机型中文字不居中的问题。.container { width: 300px; height: 200px; border: 1px solid #333333; display: flex; align-items: center; justify-content: center; } .content { background-color: #ccc; text-align: center; } flex的居中布局蚊子的博客 6. 总结 # 每种上下左右居中的方案都有不同的适用场景,现在通常是第 2 种方案和第 5 种方案适用的比较多。
2024年10月20日
6 阅读
0 评论
0 点赞
2024-10-20
第10页
支付宝推出租房平台的一点看法「芝麻分超 650 可月付房租且免押金」的支付宝租房平台未来发展前景如何?➥Read Moreposted @2017/10/12webpack的入门教程在webpack中,一切皆资源,CSS,JS,图片等都可以作为资源处理。webpack在配置大型项目时,可能会很大很复杂的配置,这里我们就从简单的2-3个页面的配置开始说起➥Read Moreposted @2017/09/24参加腾讯深圳 IMWebConf 2017 前端开发者大会是什么体验?作为前端开发的蚊子,前天有幸参加了IMWebConf2017的大会。从一名来自北京的路人的角度来将,深圳的空气是潮湿的,这次大会的组织是井然有序的➥Read Moreposted @2017/09/18作为开发者,如何树立个人品牌?作为一名开发者,如何展示自己,如何树立起自己的个人品牌呢?➥Read Moreposted @2017/09/18浅谈javascript设计模式之发布订阅者模式发布订阅者模式是为了发布者和订阅者之间避免产生依赖关系,发布订阅者之间的订阅关系由一个中介列表来维护。发布者只需做好发布功能,至于订阅者是谁,订阅者做了什么事情,发布者是无需关心的➥Read Moreposted @2017/09/15Vue组件实现tips的总结组件,顾名思义,就是把一个相对独立,而且会多次使用的功能抽象出来,成为一个组件!如果我们要把某个功能抽象为一个组件时,要做到这个组件对其他人来说是个黑盒子,他们不用关心里面是怎么实现的,只需要根据约定的接口调用即可!➥Read Moreposted @2017/09/14金秋9月的思考哪有什么岁月静好,不过是有人替你负重前行➥Read Moreposted @2017/09/01如何实现一个楼中楼的评论系统多说,网易云跟帖等第三方评论系统无法长期维护更新,蚊子就自己实现一个楼中楼的评论系统➥Read Moreposted @2017/09/01如何在windows安装php redis扩展如果自己的自己是windows系统,想联调php与redis,今天找了半天的程序与redis扩展,怕自己忘了,同时给大家做个记录和参考➥Read Moreposted @2017/08/27对博客进行了彻底的改造在coding网站开始对静态博客收费后,各个第三方的评论系统也相继无法使用的情况下,产生了对博客进行改造的想法➥Read Moreposted @2017/08/22vue实现对数据的增删改查(CURD)在数据列表中,通常会有对数据的增加、删除,修改和查找等操作,那么使用vue该如何实现呢➥Read Moreposted @2017/07/10用CSS3实现无限循环的无缝滚动在页面中循环展示信息的功能之前一般是用js来实现的,那么用CSS3该如何实现实现呢➥Read Moreposted @2017/07/03使用vue实现tab操作tab功能在网页中是比较常见的,那么用vue怎么实现tab操作呢,与jQuery实现tab的思路有什么区别呢?➥Read Moreposted @2017/07/02原生js实现简单的链式操作如何用原生js实现一个简单的链式操作➥Read Moreposted @2017/06/19第一次使用vue构建一个上传图片表单在慢慢学习vue,正好在工作中用上了,记录一下➥Read Moreposted @2017/04/02多说评论系统宣布将要关闭多说官方宣布将在2017年6月1日正式关停服务,陪伴我们这么长时间的第三方评论系统进入倒计时了。➥Read Moreposted @2017/03/21ci框架在去掉index.php时导致404的解决方案ci框架的URL中默认是带有index.php的,通常我们会去掉这个index.php,不过按照官方的方法,有时会导致只能访问首页,其他控制器全部为404➥Read Moreposted @2017/03/21从0到1学习node(八)之异步控制工具async异步编程在js编程中一直是比较麻烦和难以调试,在node中同样也是,当有多个异步操作时,那代码简直是难看的要死,而async正好是解决异步编程的利器➥Read Moreposted @2017/02/25从0到1学习node(七)之express搭建简易论坛在上节我们稍微了解了下express的基础知识,这节我们使用express搭建一个简易的论坛系统➥Read Moreposted @2017/02/20从0到1学习node(六)之express初识express是一个基于 Node.js 平台的极简、灵活的web应用开发框架,它提供一系列强大的特性、丰富的API接口,帮助我们可以快速地创建各种web和移动应用➥Read Moreposted @2017/02/18
2024年10月20日
9 阅读
0 评论
0 点赞
2024-10-20
腾讯新闻抢金达人活动node同构直出渲染方案的总结
我们的业务在展开的过程中,前端渲染的模式主要经历了三个阶段:服务端渲染、前端渲染和目前的同构直出渲染方案。服务端渲染的主要特点是前后端没有分离,前端写完页面样式和结构后,再将页面交给后端套数据,最后再一起联调。同时前端的发布也依赖于后端的同学;但是优点也很明显:页面渲染速度快,同时 SEO 效果好。为了解决前后端没有分离的问题,后来就出现了前端渲染的这种模式,路由选择和页面渲染,全部放在前端进行。前后端通过接口进行交互,各端可以更加专注自己的业务,发布时也是独立发布。但缺点是页面渲染慢,严重依赖 js 文件的加载速度,当 js 文件加载失败或者 CDN 出现波动时,页面会直接挂掉。我们之前大部分的业务都是前端渲染的模式,有部分的用户反馈页面 loading 时间长,页面渲染速度慢,尤其是在老旧的 Android 机型上这个问题更加地明显。node同构直出渲染方案可以避免服务端渲染和前端渲染存在的缺点,同时前后端都是用 js 写的,能够实现数据、组件、工具方法等能实现前后端的共享。 1. 效果 # 首先来看下统计数据的结果,可以看到从前端渲染模式切换到 node 同构直出渲染模式后,整页的加载耗时从 3500ms 降低到了 2100 毫秒左右,整体的加载速度提高了将近 40%。但这个数据也不是最终的数据,因为当时要赶着上线的时间,很多东西还没来及优化,在后续的优化完成后,可以看到整体的的加载耗时又下降到了 1600ms 左右,再次下降了 500ms 左右。从 3500ms 降低到 1600ms,整整加快了 1900ms 的加载速度,整体提升了 54%。优化的手段在稍后也会讲解到。 2. 遇到的挑战 # 在进行同构直出渲染方案,也对目前存在的技术,并结合自身的技术栈,对整体的架构进行梳理。梳理出接下来存在的重点和难点: 如何保持数据、路由、状态、基础组件的同构共用?如何区分客户端和服务端? 如何进行数据请求,是否存在跨域的请求?在服务端、浏览器端和新闻客户端内都是怎样进行数据请求的,各自都有什么特点,是否可以封装一下? 工程化:如何区分开发环境、测试环境、预发布环境和正式环境?单元测试如何执行?是否可以自动化发布? 项目的页面有什么特点,页面、接口数据、组件等是否可以缓存?如何进行缓存?是否存在个性化的数据? 如何记录日志,上报项目的性能数据,如请求量、前端页面加载的整页耗时、错误率、后端耗时等数据?如何在 node 服务出现异常时(如负载过高、内存泄露)进行告警? 如何进行容灾处理,当出现异常情况时如何降级,并告知开发者快速的修复! node 是单线程运行,如何充分利用多核? 性能优化:预加载、图片懒加载、使用 service worker、延迟加载 js、IntersectionObserver 延迟加载组件等 针对我们项目初期的规划中,可能出现的问题一一进行解决,最终我们的项目也能够实现的差不离了,某些比较大的模块我可能需要单独拿出来写一篇文章进行总结。 3. 功能实现 # 3.1 前后端的同构 # 使用 node 服务端同构指出渲染方案,最主要的是数据等结构能够实现前后端的同构共享。同构方面主要是实现:数据同构、状态同构、组件同构和路由同构等。数据同构:对于相同的虚拟 DOM 元素,在服务端使用 renderToNodeStream 把渲染结果以“流“的形式塞给 response 对象,这样就不用等到 html 都渲染出来才能给浏览器端返回结果,“流”的作用就是有多少内容给多少内容,能够进一步改进了“第一次有意义的渲染时间”。同时,在浏览器端,使用 hydrate 把虚拟 dom 渲染为真实的 DOM 元素。若浏览器端对比服务端渲染的组件数,若发生不一致的情况时,不再直接丢掉全部的内容,而是进行局部的渲染。因此在使用服务端的渲染过程中,要保证前端后组件数据的一致性。这里将服务端请求的数据,插入到 js 的全局变量中,随着 html 一起渲染到浏览器端(脱水);这是在浏览器端,就可以拿到脱水的数据来初始化组件,添加交互等等(注水)。状态同构方面:我们这里使用mobx为每个用户创建一个全局的状态管理,这样数据可以进行统一的管理,而不用组件之间衣岑层传递。组件同构:编写的基础组件或其他组件可以在服务端和客户端都能使用,同时使用typeof window==='undefined'或process.browser来判断当前是客户端还是服务端,以此来屏蔽某端不支持的操作。路由统一:客户端使用BrowserRouter,服务端使用StaticRouter。在同构的过程中,最开始时还没太理解这个概念,在编码阶段就遇到了这样的问题。例如我们有个小轮播,这个轮播是将数组打乱随机展示的,我将从服务端请求到的数据打乱后渲染到页面上,结果调试窗口中输出一条错误信息(我们这里用个样例数据来代替):const list = ['勋章', '答题卡', '达人榜', '红包', '公告']; 在render()中随机输出:{ list.sort(() => (Math.random() < 0.5 ? 1 : -1)).map(item => ( {item} )); } 结果在控制台输出了警告信息,同时最终展示出来的信息并不是打乱排序: Warning: Text content did not match. Server: "红包" Client: "答题卡" 输出的警告信息是因为客户端发现当前与服务端的数据不一致后,客户端重新进行了渲染,并给出了警告信息。我们在渲染的时候才把数组打乱顺序,服务端是按照打乱顺序后的数据渲染的,但是传递给客户端的数据还是原始数据,造成了前后端数据不一致的问题。如果真的想要随机排序,可以在获取服务端的数据后,直接先排好序,然后再渲染,这样服务端和客户端的数据就会保持一致。在 nextjs 中就是getInitialProps中操作。 3.2 如何进行数据请求 # 基于我们项目主要是在新闻客户端内运行的特点,我们要考虑多种数据请求的方式:服务端、浏览器端、新闻客户端内,是否跨域等特点,然后形成一个完整的统一的多终端数据请求体系。 服务端:使用 http 模块或者 axios 等第三方组件发起 http 请求,并透传 ua 和 cookie 给接口; 新闻客户端:使用新闻客户端提供的 jsapi 发起接口请求,注意 iOS 和 Android 不同 APP 中请求方式的差异; 浏览器端跨域请求:创建一个 script 标签发起接口请求,并设置超时时间; 浏览器端同域请求:优先使用fetch,然后使用XMLHttpRequest发起接口请求。 这里将多终端的数据进行封装,对外提供统一而稳定的调用方式,业务层无需关心当前的请求从哪个终端发起。// 发起接口请求 // @params {string} url 请求的地址 // @params {object} opts 请求的参数 const request = (url: string, opts: any): Promise => {}; 同时,我们也在请求接口的方法中添加上监控处理,如监控接口的请求量、耗时、失败率等信息,做到详细的信息记录,快速地进行定位和相应。 3.3 工程化 # 工程化是一个很大的概念,我们这里仅仅从几个小点上进行说明。我们的项目目前都是部署在 skte 上,通过设置不同的环境变量来区分当前是测试环境、预发布环境和正式环境。同时,因为我们的业务主要是在新闻客户端内访问的特点,很多的单元测试无法完全覆盖,只能进行部分的单元测试,确保基础功能的正常运作。现在接入了完全自动化的 CI(持续集成)/CD(持续部署),基于 git 分支的方式进行发布构建,当开发者完成编码工作后,推送到 test/pre/master 分支后,进行单元测试的校验,通过后就会自动集成和部署。 3.4 缓存 # 缓存的优点自不必多说: 加快了浏览器加载网页的速度; 减少了冗余的数据传输,节省网络流量和带宽; 减少服务器的负担,大大提高了网站的性能。 但同时增加缓存,整体项目的复杂度也会增加,我们需要评估下项目是否适合缓存、适用于哪种缓存机制、缓存失效时如何处理。缓存的机制主要有: 浏览器强缓存或 nginx 缓存:缓存固定的时长,例如 30ms 的时间,在这 30ms 的时间内读取缓存中的数据,这种缓存的缺点是数据无法及时更新,必须等到缓存时间到后才能更新; 状态缓存或全局缓存:这适用于路由之间多次切换或者缓存用户个性化的数据,只在单次访问的过程中有效; 内存缓存:将缓存存储于内存中,无需额外的 I/O 开销,读写速度快;但缺点是数据容易失效,一旦程序出现异常时缓存直接丢失,同时内存缓存无法达到进程之间的共享。这里当我们使用浏览器的协商缓存时,即根据生成的内容产生ETag值,若 etag 值相同则使用缓存,否则请求服务器的数据,这就会造成不同进程之间缓存的数据可能不一样,etag 多次失效的问题。内存缓存尤其要注意内存泄露的问题 分布式缓存:使用独立的第三方缓存,如 Redis 或 Memcached,好处时多个进程之间可以共享,同时减少项目本身对缓存淘汰算法的处理 不同的项目或者不同的页面采用不同的缓存策略。 不常更新数据的页面如首页、排行榜页面等,可以使用浏览器强缓存或者接口缓存; 用户头像、昵称、个性化等数据使用状态管理; 接口数据可以使用第三方缓存 在对接口的数据缓存时,尤其要注意的是接口正常返回时,才缓存数据,否则交给业务层处理。同时,在使用缓存的过程中,还注意缓存失效的问题。 缓存失效 含义 解决方案 缓存雪崩 所有的缓存同一时间失效 设置随机的缓存时间 缓存穿透 缓存中不存在,数据库中也不存在 缓存中设置一个空值,且缓存时间较短 随机 key 请求 恶意地使用随机 key 请求,导致无法命中缓存 布隆过滤器,未在过滤器中的数据直接拦截 为缓存的 key 缓存中没有单数据库中有 请求成功后,缓存数据,并将数据返回 3.5 日志记录 # 详细的日志记录能够让我们很方便地了解项目效果和排查问题。前后端的表现形式不一样,我们也区分前后端进行日志的上报。前端主要上报页面的性能信息,服务端主要上报程序的异常、CPU 和内存的使用状况等。在前端方面,我们可以使用window.performance经过简单的计算得到一些网页的性能数据: 首次加载耗时: domLoading - fetchStart; 整页耗时: loadEventEnd - fetchStart; 错误率: 错误日志量/请求量; DNS 耗时: domainLookupEnd - domainLookupStart; TCP 耗时: connectEnd - connectStart; 后端耗时: responseStart - requestStart; html 耗时: responseEnd - responseStart; DOM 耗时: domContentLoadedEventEnd - responseEnd; 同时我们也需要捕获前端代码中的一些报错: 全局捕获,error: window.addEventListener( 'error', (message, filename, lineNo, colNo, stackError) => { console.log(message); // 错误信息的描述 console.log(filename); // 错误所在的文件 console.log(lineNo); // 错误所在的行号 console.log(colNo); // 错误所在的列号 console.log(stackError); // 错误的堆栈信息 } ); 全局捕获,unhandledrejection: 当 Promise 被 reject 且没有 reject 处理器的时候,会触发 unhandledrejection 事件;这可能发生在 window 下,但也可能发生在 Worker 中。 这对于调试回退错误处理非常有用。window.addEventListener('unhandledrejection', event => { console.log(event); }); 接口异步请求时 这里可以对fetch和XMLHttpRequest进行重新的封装,既不影响正常的业务逻辑,也可以进行错误上报。XMLHttpRequest 的封装:const xmlhttp = window.XMLHttpRequest; const _oldSend = xmlhttp.prototype.send; xmlhttp.prototype.send = function() { if (this['addEventListener']) { this['addEventListener']('error', _handleEvent); this['addEventListener']('load', _handleEvent); this['addEventListener']('abort', _handleEvent); } else { var _oldStateChange = this['onreadystatechange']; this['onreadystatechange'] = function(event) { if (this.readyState === 4) { _handleEvent(event); } _oldStateChange && _oldStateChange.apply(this, arguments); }; } return _oldSend.apply(this, arguments); }; fetch 的封装:const oldFetch = window.fetch; window.fetch = function() { return _oldFetch .apply(this, arguments) .then(res => { if (!res.ok) { // True if status is HTTP 2xx // 上报错误 } return res; }) .catch(error => { // 上报错误 throw error; }); }; 服务端的日志根据严重程度,主要可以分为以下的几个类别: error: 错误,未预料到的问题; warning: 警告,出现了在预期内的异常,但是项目可以正常运行,整体可控; info: 常规,正常的信息记录; silly: 不明原因造成的; 我们针对可能出现的异常程度进行不同类别(level)的上报,这里我们采用了两种记录策略,分别使用网络日志boss和本地日志winston分别进行记录。boss 日志里记录较为简单的信息,方便通过浏览器进行快速地排查;winston 记录详细的本地日志,当通过简单的日志信息无法定位时,则使用更为详细的本地日志进行排查。使用winston进行服务端日志的上报,按照日期进行分类,上报的主要信息有:当前时间、服务器、进程 ID、消息、堆栈追踪等:// https://github.com/winstonjs/winston logger = createLogger({ level: 'info', format: combine(label({ label: 'right meow!' }), timestamp(), myFormat), // winston.format.json(), defaultMeta: { service: 'user-service' }, transports: [ new transports.File({ filename: `/data/log/question/answer.error.${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}.log`, level: 'error' }) ] }); 同时 nodejs 服务本身的监控机制也充分利用上,例如包括 http 状态码,内存占用(process.memoryUsage)等。在日志的统计过程中,加入告警机制,当告警数量或者数值超过一定的范围,则向开发者的微信和邮箱发出告警信息和设备。例如其中的一条告警规则是:当页面的加载时间小于 10ms 或者超过 6000ms 则发出告警信息,小于 10ms 时说明页面挂掉了,大于 6000ms 说明服务器可能出现异常,导致资源加载时间过长。同时也要及时地关注用户反馈平台,若产生了一个用户的反馈,必然是有更多的用户存在这样的问题。 3.6 容灾处理 # 日志记录和告警等都是事故发生后才产生的行为,我们应当如何保证在我们看到日志信息并修复问题之前的这段时间里,服务至少能够还是是正常运行的,而不是白屏或者 5xx 等信息。这里我们要做的就是线上服务的容灾处理。 可能存在的问题 容灾措施 后端接口异常 使用默认数据,并及时告知接口方 瞬时流量高、CPU 负载率过高 自动扩容,并告警 node 服务异常,如 4xx,5xx 等 nginx 自动将服务转向静态页面,并告警转发的次数 静态资源导致的样式异常 将首屏或者首页的样式嵌入到页面中 容灾处理与日志信息的记录,保障我们项目能够正常地在线上运行。 3.7 cluster 模块 # nodejs 作为一种单线程、单进程运行的程序,如果只是简单的使用的话(node app.js),存在着如下一些问题: 无法充分利用多核 cpu 机器的性能, 服务不稳定,一个未处理的异常都会导致整个程序退出 没有成熟的日志管理方案、 没有服务/进程监控机制 所幸,nodejs 为我们提供了cluster模块,什么是cluster:简单的说, 在服务器上同时启动多个进程。 每个进程里都跑的是同一份源代码(好比把以前一个进程的工作分给多个进程去做)。 更神奇的是,这些进程可以同时监听一个端口(Cluster 实现原理)。 其中: 负责启动其他进程的叫做 Master 进程,他好比是个『包工头』,不做具体的工作,只负责启动其他进程。 其他被启动的叫 Worker 进程,顾名思义就是干活的『工人』。它们接收请求,对外提供服务。 Worker 进程的数量一般根据服务器的 CPU 核数来定,这样就可以完美利用多核资源。 cluster 模块可以创建共享服务器端口的子进程。这里举一个著名的官方案例:const cluster = require('cluster'); const http = require('http'); const os = require('os'); if (cluster.isMaster) { // 当前为主进程 console.log(`主进程 ${process.pid} 正在运行`); // 启动子进程 for (let i = 0, len = os.cpus().length; i < len; i++) { cluster.fork(); } cluster.on('exit', worker => { console.log(`子进程 ${worker.process.pid} 已退出`); }); } else { http.createServer((req, res) => { res.writeHead(200); res.end('hello world\n'); }).listen(8000); console.log(`子进程 ${process.pid} 已启动`); } 当有进程退出时,则会触发exit事件,例如我们 kill 掉 69030 的进程时:>kill -9 69030 子进程 69030 已退出 我们尝试 kill 掉某个进程,发现子进程是不会自动重新创建的,这里我可以修改下exit事件,当触发这个事件后重新创建一个子进程:cluster.on('exit', worker => { console.log(`子进程 ${worker.process.pid} 已退出`); // log日志记录 cluster.fork(); }); 主进程与子进程之间的通信:每个进程之间是相互独立的,可是每个进程都可以与主进程进行通信。这样就能把很多需要每个子进程都需要处理的问题,放到主进程里处理,例如日志记录、缓存等。我们在 3.4 缓存小节中也有讲“内存缓存无法达到进程之间的共享”,可是我们可以把缓存提高到主进程中进行缓存。if (cluster.isMaster) { Object.values(cluster.workers).forEach(worker => { // 向所有的进程都发布一条消息 worker.send({ timestamp: Date.now() }); // 接收当前worker发送的消息 worker.on('message', msg => { console.log( `主进程接收到 ${worker.process.pid} 的消息:` + JSON.stringify(msg) ); }); }); } else { process.on('message', msg => { console.log(`子进程 ${process.pid} 获取信息:${JSON.stringify(msg)}`); process.send({ timestamp: msg.timestamp, random: Math.random() }); }); } 不过若线上生产环境使用的话,我们需要给这套代码添加很多的逻辑。这里可以使用pm2来维护我们的 node 项目,同时 pm2 也能启用 cluster 模式。pm2 的官网是http://pm2.keymetrics.io,github 是https://github.com/Unitech/pm2。主要特点有: 原生的集群化支持(使用 Node cluster 集群模块) 记录应用重启的次数和时间 后台 daemon 模式运行 0 秒停机重载,非常适合程序升级 停止不稳定的进程(避免无限循环) 控制台监控 实时集中 log 处理 强健的 API,包含远程控制和实时的接口 API ( Nodejs 模块,允许和 PM2 进程管理器交互 ) 退出时自动杀死进程 内置支持开机启动(支持众多 linux 发行版和 macos) nodejs 服务的工作都可以托管给 pm2 处理。pm2 以当前最大的 CPU 数量启动 cluster 模式:pm2 start server.js -i max 不过我们的项目使用配置文件来启动的,ecosystem.config.js:module.exports = { apps: [ { name: 'question', script: 'server.js', instances: 'max', exec_mode: 'cluster', autorestart: true, watch: false, max_memory_restart: '1G', env_test: { NEXT_APP_ENV: 'testing' }, env_pre: { NEXT_APP_ENV: 'pre' }, env: { NEXT_APP_ENV: 'production' } } ] }; 然后启动即可:pm2 start ecosystem.config.js 关于使用 node 来编写 cluster 模式,还是用 pm2 来启动 cluster 模式,还是要看项目的需要。使用 node 编写时,自己可以控制各个进程之间的通信,让每个进程做自己的事情;而 pm2 来启动的话,在整体健壮性上更好一些。 3.8 性能优化 # 我们应当首先保证首页和首屏的加载,一个是首屏需要的样式直接嵌入到页面中加载,再一个是首屏和次屏的数据分开加载。我们在首页的数据主要是瀑布流的方式加载,而瀑布流是需要 js 计算的,因此这里我们先加载几条数据,保证首屏是有数据的,然后接下来的数据使用 js 计算应当放在哪个位置。再一个是使用 service worker 来本地缓存 css 和 js 资源,更具体的使用,可以访问service worker 在新闻红包活动中的应用。这里我们使用 IntersectionObserver 封装了通用的组件懒加载方案,因为在使用 scroll 事件中,我们可能还需要手动节流和防抖动,同时,因为图片加载的快慢,导致需要多次获取元素的 offsetTop 值。而 IntersectionObserver 就能完美地避免这些问题,同时,我们也能看到,这一属性在高版本浏览器中也得到了支持,在低版本浏览器中,我可以使用 polyfill 的方式进行兼容处理处理;我将这个功能封装为一个组件,对外提供几个监听方法,将需要懒加载的组件或者资源作为子组件,进行包裹,同时,我们这里也建议建议使用者,使用默认的骨架屏撑起元素未渲染时的页面。因为在直接使用懒加载渲染时,假如不使用骨架屏的话,用户是先看到白屏,然后突然渲染内容,页面给用户一种强烈抖动的感觉。真实组件在最后真正展示出来时,需要一定的时间和空间,时间是从资源加载到渲染完毕需要时间;而空间指的是页面布局中需要给真实组件留出一定的问题,一个是为了避免页面,再一个使用骨架屏后: 提升用户的感知体验 保证切换的一致性 提供可见性观察的目标对象,为执行懒加载的组件保证可见性的区域 这里实现的通用懒加载组件,对外提供了几个回调方法:onInPage, onOutPage, onInited 等。这个通用的组件懒加载方案可以使用在如下的场景下: 懒加载的粒度可大可小,大到 1 个组件或者几个组件,小到一个图片即可; 页面模块曝光率的数据上报,这样可以计算模块从曝光到参与的一个漏斗数据; 长列表中的无限滚动:我们可以监听页面底部的一个透明元素,当这个透明元素即将可见时,加载并渲染下一页的数据。 当然,长列表无限滚动的优先,不仅限于使用可见性代替滚动事件,也还有其他的优化手段。 4. 总结 # 虽然啰里啰嗦了一大堆,但也这是我们同构直出渲染方案的开始,我们还有很长的路要走。应用型技术的难点不是在克服技术问题,而是在于能够不断的结合自身的产品体验,发现其中存在的体验问题,不断使用更好的技术方案去优化用户的体验,为整个产品发展添砖加瓦。
2024年10月20日
5 阅读
0 评论
0 点赞
1
...
68
69
70
...
246